From e056bc47ac1ac6b2306f6c235e67ed58b9304a98 Mon Sep 17 00:00:00 2001 From: goureeshreddy Date: Thu, 25 Jun 2026 10:46:39 +0530 Subject: [PATCH] feat: detect phantom dependencies --- pr592_comments.json | 0 src/index.ts | 53 ++++++++++++++++++++++++++++++++++----- src/usage/scanner.ts | 37 ++++++++++++++++----------- src/utils/package-json.ts | 28 +++++++++++++++++++++ 4 files changed, 98 insertions(+), 20 deletions(-) create mode 100644 pr592_comments.json diff --git a/pr592_comments.json b/pr592_comments.json new file mode 100644 index 0000000..e69de29 diff --git a/src/index.ts b/src/index.ts index c778e3f..fd9e1e9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -59,7 +59,7 @@ import { renderOverrideFindings } from "./output/override-findings-terminal.js"; import { installSkill } from "./skills/install.js"; import { readConfig, validateCaCertFile } from "./cli/config.js"; import { runConfigCommand } from "./cli/config-command.js"; -import { readDirectDependencyNames, hasOverrideEntries } from "./utils/package-json.js"; +import { readDirectDependencyNames, hasOverrideEntries, readOverridesAndResolutions } from "./utils/package-json.js"; import { applyFixesIfRequested, FixExecutionResult, @@ -656,6 +656,20 @@ if (parsedArgs) { } if (showOverrideHint) printOverrideHint(); } + + if (options.usage) { + if (scanState.pd001 && scanState.pd001.length > 0) { + console.log(chalk.red("\n[PD001] Override-only Phantom Dependencies Detected (Build Breakers)")); + scanState.pd001.forEach(pkg => console.log(` - ${pkg}`)); + } + if (scanState.pd002 && scanState.pd002.length > 0) { + console.log(chalk.yellow("\n[PD002] Transitive-only Phantom Dependencies Detected")); + scanState.pd002.forEach(pkg => console.log(` - ${pkg}`)); + } + if ((scanState.pd001 && scanState.pd001.length > 0) || (scanState.pd002 && scanState.pd002.length > 0)) { + console.log(chalk.gray("\nAction: Declare these dependencies explicitly in your package.json.\n")); + } + } } } @@ -708,7 +722,9 @@ if (parsedArgs) { const reachesFailOn = (f: { severity: SeverityLabel }) => severityOrder[f.severity] >= severityOrder[failLevel]; const shouldFail = - scanState.sorted.some(reachesFailOn) || overrideFindings.some(reachesFailOn); + scanState.sorted.some(reachesFailOn) || + overrideFindings.some(reachesFailOn) || + (options.usage && ((scanState.pd001 && scanState.pd001.length > 0) || (scanState.pd002 && scanState.pd002.length > 0))); const exitCode = shouldFail ? 1 : 0; // Emit scan.finished event and close audit-log @@ -784,11 +800,32 @@ async function scanProject(params: { scanFilePath: params.scanInput.filePath, }, params.debugLog); + let pd001: string[] = []; + let pd002: string[] = []; + if (params.options.usage) { - logInfo(`Scanning project source for usage hints...`, params.options); + logInfo(`Scanning project source for usage hints and phantom dependencies...`, params.options); const usageStartedAt = Date.now(); - const pkgNames = new Set(findings.map(f => f.pkg.name)); - const usageData = scanProjectForPackageUsage(params.projectPath, pkgNames); + + // Pass undefined to get ALL imported packages for phantom dependency detection + const usageData = scanProjectForPackageUsage(params.projectPath); + + const allLockfilePackages = new Set(params.scanInput.packages.map(p => p.name)); + const overridesAndResolutions = readOverridesAndResolutions(params.projectPath); + const directDeps = directDependencyNames || new Set(); + + for (const importedPkg of Object.keys(usageData)) { + if (usageData[importedPkg].length === 0) continue; + + if (!directDeps.has(importedPkg)) { + if (overridesAndResolutions.has(importedPkg)) { + pd001.push(importedPkg); + } else if (allLockfilePackages.has(importedPkg)) { + pd002.push(importedPkg); + } + } + } + let matchedPackages = 0; for (const finding of findings) { const files = usageData[finding.pkg.name]; @@ -805,8 +842,10 @@ async function scanProject(params: { if (params.options.debug) { params.debugLog("Usage scan", { durationMs: Date.now() - usageStartedAt, - packagesChecked: pkgNames.size, + packagesChecked: Object.keys(usageData).length, matchedPackages, + pd001: pd001.length, + pd002: pd002.length, }); } } @@ -839,6 +878,8 @@ async function scanProject(params: { tableFindings, suggestedFixCommands, allPackages: params.scanInput.packages, + pd001, + pd002, }; } diff --git a/src/usage/scanner.ts b/src/usage/scanner.ts index 5f9808e..c70019b 100644 --- a/src/usage/scanner.ts +++ b/src/usage/scanner.ts @@ -27,15 +27,17 @@ function getBareModuleName(importPath: string): string { export function scanProjectForPackageUsage( projectPath: string, - packagesToLookFor: Set, + packagesToLookFor?: Set, ): Record { const results: Record = {}; - for (const pkg of packagesToLookFor) { - results[pkg] = []; - } + if (packagesToLookFor) { + for (const pkg of packagesToLookFor) { + results[pkg] = []; + } - if (packagesToLookFor.size === 0) { - return results; + if (packagesToLookFor.size === 0) { + return results; + } } // Cap at 5000 files to prevent performance issues on massive projects @@ -80,14 +82,16 @@ export function scanProjectForPackageUsage( } // Fast path: skip regex if the file doesn't contain any target package names - let hasPotentialMatch = false; - for (const pkg of packagesToLookFor) { - if (content.includes(pkg)) { - hasPotentialMatch = true; - break; + if (packagesToLookFor) { + let hasPotentialMatch = false; + for (const pkg of packagesToLookFor) { + if (content.includes(pkg)) { + hasPotentialMatch = true; + break; + } } + if (!hasPotentialMatch) return; } - if (!hasPotentialMatch) return; const matches = content.matchAll(IMPORT_REQUIRE_REGEX); const foundPackages = new Set(); @@ -96,8 +100,10 @@ export function scanProjectForPackageUsage( const importPath = match[1] || match[2] || match[3] || match[4]; if (importPath) { const bare = getBareModuleName(importPath); - if (bare && packagesToLookFor.has(bare)) { - foundPackages.add(bare); + if (bare) { + if (!packagesToLookFor || packagesToLookFor.has(bare)) { + foundPackages.add(bare); + } } } } @@ -105,6 +111,9 @@ export function scanProjectForPackageUsage( for (const pkg of foundPackages) { // Store relative path for cleaner output const relPath = path.relative(projectPath, filePath); + if (!results[pkg]) { + results[pkg] = []; + } results[pkg].push(relPath); } } diff --git a/src/utils/package-json.ts b/src/utils/package-json.ts index 87e85eb..f19d17b 100644 --- a/src/utils/package-json.ts +++ b/src/utils/package-json.ts @@ -157,3 +157,31 @@ export function hasOverrideEntries(dir: string): boolean { return false; } } + +export function readOverridesAndResolutions(projectPath: string): Set { + const packageJsonPath = path.join(projectPath, "package.json"); + const result = new Set(); + + if (!fs.existsSync(packageJsonPath)) { + return result; + } + + const manifest = readPackageJsonObject(packageJsonPath); + if (!manifest) { + return result; + } + + const sections: Array | undefined> = [ + isRecord(manifest.overrides) ? manifest.overrides : undefined, + isRecord(manifest.resolutions) ? manifest.resolutions : undefined, + ]; + + for (const section of sections) { + if (!section) continue; + for (const key of Object.keys(section)) { + result.add(key); + } + } + + return result; +}