Skip to content

Commit a26097c

Browse files
authored
feat(js/ts): automatically detect unused modules (#18)
1 parent d3bddd8 commit a26097c

File tree

3 files changed

+128
-1
lines changed

3 files changed

+128
-1
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ When run with `--ci` the CLI exits with a non-zero code if any findings are dete
108108
- Staged-file scanning: run only what will be committed (fast pre-commit checks).
109109
- Husky integration: optional pre-commit hooks to block commits locally.
110110
- CI-ready: `--ci` mode for failing pipelines on findings.
111+
- Unused JS/TS module detection: Each scan, CodeGuardian will warn about JavaScript and TypeScript files that are not imported or required by any other file (excluding entry points like `index.js`, `main.ts`, etc.). These warnings help you clean up unused code, but do not block CI or fail the scan.
111112

112113

113114
## CLI options

src/scanner.js

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import fg from 'fast-glob';
44
import ignore from 'ignore';
55
import { execSync } from 'node:child_process';
66
import chalk from 'chalk';
7-
7+
import { findUnusedModules } from './unusedModuleDetector.js';
88

99
const DEFAULT_CONFIG_FILES = ['.codeguardianrc.json', 'codeguardian.config.json'];
1010

@@ -101,6 +101,11 @@ async function run({ configPath = null, staged = false, verbose = false } = {})
101101

102102
const findings = [];
103103
let filesScanned = 0;
104+
// For unused module detection
105+
const jsTsFiles = [];
106+
const importMap = new Map(); // file -> [imported files]
107+
const allFilesSet = new Set();
108+
104109
for (const file of files) {
105110
// small optimization: skip binary-ish files by extension
106111
const ext = path.extname(file).toLowerCase();
@@ -118,6 +123,76 @@ async function run({ configPath = null, staged = false, verbose = false } = {})
118123
if (fileFindings.length > 0) {
119124
findings.push({ file, matches: fileFindings });
120125
}
126+
127+
// Collect JS/TS files for unused module detection and unused import detection
128+
if ([".js", ".ts"].includes(ext) && !file.includes(".test") && !file.includes("spec") && !file.includes("config") && !file.includes("setup")) {
129+
jsTsFiles.push(file);
130+
allFilesSet.add(path.resolve(file));
131+
// Parse imports/requires
132+
const imports = [];
133+
// ES imports (capture imported identifiers)
134+
const esImportRegex = /import\s+((?:[\w*{},\s]+)?)\s*from\s*["']([^"']+)["']/g;
135+
let match;
136+
const importDetails = [];
137+
while ((match = esImportRegex.exec(content))) {
138+
const imported = match[1].trim();
139+
const source = match[2];
140+
// Parse imported identifiers
141+
let identifiers = [];
142+
if (imported.startsWith("* as ")) {
143+
identifiers.push(imported.replace("* as ", "").trim());
144+
} else if (imported.startsWith("{")) {
145+
// Named imports
146+
identifiers = imported.replace(/[{}]/g, "").split(",").map(s => s.trim().split(" as ")[0]).filter(Boolean);
147+
} else if (imported) {
148+
identifiers.push(imported.split(",")[0].trim());
149+
}
150+
importDetails.push({ source, identifiers });
151+
imports.push(source);
152+
}
153+
// CommonJS requires (variable assignment)
154+
const requireVarRegex = /(?:const|let|var)\s+([\w{}*,\s]+)\s*=\s*require\(["']([^"']+)["']\)/g;
155+
while ((match = requireVarRegex.exec(content))) {
156+
const imported = match[1].trim();
157+
const source = match[2];
158+
let identifiers = [];
159+
if (imported.startsWith("{")) {
160+
identifiers = imported.replace(/[{}]/g, "").split(",").map(s => s.trim());
161+
} else if (imported) {
162+
identifiers.push(imported.split(",")[0].trim());
163+
}
164+
importDetails.push({ source, identifiers });
165+
imports.push(source);
166+
}
167+
// Bare require (no variable assignment)
168+
const requireRegex = /require\(["']([^"']+)["']\)/g;
169+
while ((match = requireRegex.exec(content))) {
170+
imports.push(match[1]);
171+
}
172+
importMap.set(path.resolve(file), imports);
173+
// Unused import detection
174+
// For each imported identifier, check if it's used in the file
175+
const unusedImports = [];
176+
for (const imp of importDetails) {
177+
for (const id of imp.identifiers) {
178+
// Simple usage check: look for identifier in code (excluding import line)
179+
const usageRegex = new RegExp(`\\b${id.replace(/[$()*+.?^{}|\\]/g, "\\$&")}\\b`, "g");
180+
// Remove import lines
181+
const codeWithoutImports = content.replace(esImportRegex, "").replace(requireVarRegex, "");
182+
const usageCount = (codeWithoutImports.match(usageRegex) || []).length;
183+
if (usageCount === 0) {
184+
unusedImports.push(id);
185+
}
186+
}
187+
}
188+
if (unusedImports.length > 0) {
189+
console.log(chalk.yellowBright(`\nWarning: Unused imports in ${file}:`));
190+
for (const id of unusedImports) {
191+
console.log(chalk.yellow(` ${id}`));
192+
}
193+
console.log(chalk.gray('These imports are present but never used in this file.'));
194+
}
195+
}
121196
}
122197

123198
// Print nice output
@@ -133,6 +208,16 @@ async function run({ configPath = null, staged = false, verbose = false } = {})
133208
}
134209
}
135210

211+
// Unused JS/TS module detection (warn only)
212+
const unused = findUnusedModules(jsTsFiles, importMap);
213+
if (unused.length > 0) {
214+
console.log(chalk.yellowBright(`\nWarning: Unused modules detected (not imported by any other file):`));
215+
for (const f of unused) {
216+
console.log(chalk.yellow(` ${f}`));
217+
}
218+
console.log(chalk.gray('These files are not blocking CI, but consider cleaning up unused modules.'));
219+
}
220+
136221
const endTime = process.hrtime.bigint();
137222
const endMem = process.memoryUsage().heapUsed;
138223
const durationMs = Number(endTime - startTime) / 1e6;

src/unusedModuleDetector.js

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// Unused module detection logic for CodeGuardian
2+
// Scans for JS/TS files not imported by any other file
3+
4+
import { access } from 'node:fs/promises';
5+
import path from 'node:path';
6+
7+
8+
export async function findUnusedModules (jsTsFiles, importMap) {
9+
// Build set of all imported files (resolved to absolute)
10+
const importedSet = new Set();
11+
for (const [file, imports] of importMap.entries()) {
12+
for (const imp of imports) {
13+
if (imp.startsWith("./") || imp.startsWith("../")) {
14+
let resolved;
15+
const candidates = [
16+
path.resolve(path.dirname(file), imp),
17+
path.resolve(path.dirname(file), imp + ".js"),
18+
path.resolve(path.dirname(file), imp + ".ts")
19+
];
20+
for (const candidate of candidates) {
21+
try {
22+
await access(candidate);
23+
resolved = candidate;
24+
break;
25+
} catch {}
26+
}
27+
if (resolved) importedSet.add(resolved);
28+
}
29+
}
30+
}
31+
// Entry points: index.js/ts, cli.js/ts, main.js/ts
32+
const entryRegex = /\b(index|cli|main)\.(js|ts)\b/i;
33+
const unused = [];
34+
for (const file of jsTsFiles) {
35+
const abs = path.resolve(file);
36+
if (!importedSet.has(abs) && !entryRegex.test(path.basename(file))) {
37+
unused.push(file);
38+
}
39+
}
40+
return unused;
41+
}

0 commit comments

Comments
 (0)