From c9b029a088d3cb91e30a6b64e34014ca15221beb Mon Sep 17 00:00:00 2001 From: Shivam Sharma Date: Fri, 21 Nov 2025 19:29:21 +0530 Subject: [PATCH] feat(js/ts): automatically detect unused modules --- README.md | 1 + src/scanner.js | 87 ++++++++++++++++++++++++++++++++++++- src/unusedModuleDetector.js | 41 +++++++++++++++++ 3 files changed, 128 insertions(+), 1 deletion(-) create mode 100644 src/unusedModuleDetector.js diff --git a/README.md b/README.md index 258b30b..191de21 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,7 @@ When run with `--ci` the CLI exits with a non-zero code if any findings are dete - Staged-file scanning: run only what will be committed (fast pre-commit checks). - Husky integration: optional pre-commit hooks to block commits locally. - CI-ready: `--ci` mode for failing pipelines on findings. +- 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. ## CLI options diff --git a/src/scanner.js b/src/scanner.js index 29bb8b4..f64258d 100644 --- a/src/scanner.js +++ b/src/scanner.js @@ -4,7 +4,7 @@ import fg from 'fast-glob'; import ignore from 'ignore'; import { execSync } from 'node:child_process'; import chalk from 'chalk'; - +import { findUnusedModules } from './unusedModuleDetector.js'; const DEFAULT_CONFIG_FILES = ['.codeguardianrc.json', 'codeguardian.config.json']; @@ -101,6 +101,11 @@ async function run({ configPath = null, staged = false, verbose = false } = {}) const findings = []; let filesScanned = 0; + // For unused module detection + const jsTsFiles = []; + const importMap = new Map(); // file -> [imported files] + const allFilesSet = new Set(); + for (const file of files) { // small optimization: skip binary-ish files by extension const ext = path.extname(file).toLowerCase(); @@ -118,6 +123,76 @@ async function run({ configPath = null, staged = false, verbose = false } = {}) if (fileFindings.length > 0) { findings.push({ file, matches: fileFindings }); } + + // Collect JS/TS files for unused module detection and unused import detection + if ([".js", ".ts"].includes(ext) && !file.includes(".test") && !file.includes("spec") && !file.includes("config") && !file.includes("setup")) { + jsTsFiles.push(file); + allFilesSet.add(path.resolve(file)); + // Parse imports/requires + const imports = []; + // ES imports (capture imported identifiers) + const esImportRegex = /import\s+((?:[\w*{},\s]+)?)\s*from\s*["']([^"']+)["']/g; + let match; + const importDetails = []; + while ((match = esImportRegex.exec(content))) { + const imported = match[1].trim(); + const source = match[2]; + // Parse imported identifiers + let identifiers = []; + if (imported.startsWith("* as ")) { + identifiers.push(imported.replace("* as ", "").trim()); + } else if (imported.startsWith("{")) { + // Named imports + identifiers = imported.replace(/[{}]/g, "").split(",").map(s => s.trim().split(" as ")[0]).filter(Boolean); + } else if (imported) { + identifiers.push(imported.split(",")[0].trim()); + } + importDetails.push({ source, identifiers }); + imports.push(source); + } + // CommonJS requires (variable assignment) + const requireVarRegex = /(?:const|let|var)\s+([\w{}*,\s]+)\s*=\s*require\(["']([^"']+)["']\)/g; + while ((match = requireVarRegex.exec(content))) { + const imported = match[1].trim(); + const source = match[2]; + let identifiers = []; + if (imported.startsWith("{")) { + identifiers = imported.replace(/[{}]/g, "").split(",").map(s => s.trim()); + } else if (imported) { + identifiers.push(imported.split(",")[0].trim()); + } + importDetails.push({ source, identifiers }); + imports.push(source); + } + // Bare require (no variable assignment) + const requireRegex = /require\(["']([^"']+)["']\)/g; + while ((match = requireRegex.exec(content))) { + imports.push(match[1]); + } + importMap.set(path.resolve(file), imports); + // Unused import detection + // For each imported identifier, check if it's used in the file + const unusedImports = []; + for (const imp of importDetails) { + for (const id of imp.identifiers) { + // Simple usage check: look for identifier in code (excluding import line) + const usageRegex = new RegExp(`\\b${id.replace(/[$()*+.?^{}|\\]/g, "\\$&")}\\b`, "g"); + // Remove import lines + const codeWithoutImports = content.replace(esImportRegex, "").replace(requireVarRegex, ""); + const usageCount = (codeWithoutImports.match(usageRegex) || []).length; + if (usageCount === 0) { + unusedImports.push(id); + } + } + } + if (unusedImports.length > 0) { + console.log(chalk.yellowBright(`\nWarning: Unused imports in ${file}:`)); + for (const id of unusedImports) { + console.log(chalk.yellow(` ${id}`)); + } + console.log(chalk.gray('These imports are present but never used in this file.')); + } + } } // Print nice output @@ -133,6 +208,16 @@ async function run({ configPath = null, staged = false, verbose = false } = {}) } } + // Unused JS/TS module detection (warn only) + const unused = findUnusedModules(jsTsFiles, importMap); + if (unused.length > 0) { + console.log(chalk.yellowBright(`\nWarning: Unused modules detected (not imported by any other file):`)); + for (const f of unused) { + console.log(chalk.yellow(` ${f}`)); + } + console.log(chalk.gray('These files are not blocking CI, but consider cleaning up unused modules.')); + } + const endTime = process.hrtime.bigint(); const endMem = process.memoryUsage().heapUsed; const durationMs = Number(endTime - startTime) / 1e6; diff --git a/src/unusedModuleDetector.js b/src/unusedModuleDetector.js new file mode 100644 index 0000000..2f7e936 --- /dev/null +++ b/src/unusedModuleDetector.js @@ -0,0 +1,41 @@ +// Unused module detection logic for CodeGuardian +// Scans for JS/TS files not imported by any other file + +import { access } from 'node:fs/promises'; +import path from 'node:path'; + + +export async function findUnusedModules (jsTsFiles, importMap) { + // Build set of all imported files (resolved to absolute) + const importedSet = new Set(); + for (const [file, imports] of importMap.entries()) { + for (const imp of imports) { + if (imp.startsWith("./") || imp.startsWith("../")) { + let resolved; + const candidates = [ + path.resolve(path.dirname(file), imp), + path.resolve(path.dirname(file), imp + ".js"), + path.resolve(path.dirname(file), imp + ".ts") + ]; + for (const candidate of candidates) { + try { + await access(candidate); + resolved = candidate; + break; + } catch {} + } + if (resolved) importedSet.add(resolved); + } + } + } + // Entry points: index.js/ts, cli.js/ts, main.js/ts + const entryRegex = /\b(index|cli|main)\.(js|ts)\b/i; + const unused = []; + for (const file of jsTsFiles) { + const abs = path.resolve(file); + if (!importedSet.has(abs) && !entryRegex.test(path.basename(file))) { + unused.push(file); + } + } + return unused; +}