Skip to content

Commit 2011b8e

Browse files
feat: detect phantom dependencies
1 parent 3e46407 commit 2011b8e

4 files changed

Lines changed: 98 additions & 20 deletions

File tree

pr592_comments.json

Whitespace-only changes.

src/index.ts

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ import { renderOverrideFindings } from "./output/override-findings-terminal.js";
5858
import { installSkill } from "./skills/install.js";
5959
import { readConfig, validateCaCertFile } from "./cli/config.js";
6060
import { runConfigCommand } from "./cli/config-command.js";
61-
import { readDirectDependencyNames } from "./utils/package-json.js";
61+
import { readDirectDependencyNames, readOverridesAndResolutions } from "./utils/package-json.js";
6262
import {
6363
applyFixesIfRequested,
6464
FixExecutionResult,
@@ -645,6 +645,20 @@ if (parsedArgs) {
645645
if (options.checkOverrides) {
646646
console.log(renderOverrideFindings(overrideFindings));
647647
}
648+
649+
if (options.usage) {
650+
if (scanState.pd001 && scanState.pd001.length > 0) {
651+
console.log(chalk.red("\n[PD001] Override-only Phantom Dependencies Detected (Build Breakers)"));
652+
scanState.pd001.forEach(pkg => console.log(` - ${pkg}`));
653+
}
654+
if (scanState.pd002 && scanState.pd002.length > 0) {
655+
console.log(chalk.yellow("\n[PD002] Transitive-only Phantom Dependencies Detected"));
656+
scanState.pd002.forEach(pkg => console.log(` - ${pkg}`));
657+
}
658+
if ((scanState.pd001 && scanState.pd001.length > 0) || (scanState.pd002 && scanState.pd002.length > 0)) {
659+
console.log(chalk.gray("\nAction: Declare these dependencies explicitly in your package.json.\n"));
660+
}
661+
}
648662
}
649663
}
650664

@@ -697,7 +711,9 @@ if (parsedArgs) {
697711
const reachesFailOn = (f: { severity: SeverityLabel }) =>
698712
severityOrder[f.severity] >= severityOrder[failLevel];
699713
const shouldFail =
700-
scanState.sorted.some(reachesFailOn) || overrideFindings.some(reachesFailOn);
714+
scanState.sorted.some(reachesFailOn) ||
715+
overrideFindings.some(reachesFailOn) ||
716+
(options.usage && ((scanState.pd001 && scanState.pd001.length > 0) || (scanState.pd002 && scanState.pd002.length > 0)));
701717
const exitCode = shouldFail ? 1 : 0;
702718

703719
// Emit scan.finished event and close audit-log
@@ -773,11 +789,32 @@ async function scanProject(params: {
773789
scanFilePath: params.scanInput.filePath,
774790
}, params.debugLog);
775791

792+
let pd001: string[] = [];
793+
let pd002: string[] = [];
794+
776795
if (params.options.usage) {
777-
logInfo(`Scanning project source for usage hints...`, params.options);
796+
logInfo(`Scanning project source for usage hints and phantom dependencies...`, params.options);
778797
const usageStartedAt = Date.now();
779-
const pkgNames = new Set(findings.map(f => f.pkg.name));
780-
const usageData = scanProjectForPackageUsage(params.projectPath, pkgNames);
798+
799+
// Pass undefined to get ALL imported packages for phantom dependency detection
800+
const usageData = scanProjectForPackageUsage(params.projectPath);
801+
802+
const allLockfilePackages = new Set(params.scanInput.packages.map(p => p.name));
803+
const overridesAndResolutions = readOverridesAndResolutions(params.projectPath);
804+
const directDeps = directDependencyNames || new Set<string>();
805+
806+
for (const importedPkg of Object.keys(usageData)) {
807+
if (usageData[importedPkg].length === 0) continue;
808+
809+
if (!directDeps.has(importedPkg)) {
810+
if (overridesAndResolutions.has(importedPkg)) {
811+
pd001.push(importedPkg);
812+
} else if (allLockfilePackages.has(importedPkg)) {
813+
pd002.push(importedPkg);
814+
}
815+
}
816+
}
817+
781818
let matchedPackages = 0;
782819
for (const finding of findings) {
783820
const files = usageData[finding.pkg.name];
@@ -794,8 +831,10 @@ async function scanProject(params: {
794831
if (params.options.debug) {
795832
params.debugLog("Usage scan", {
796833
durationMs: Date.now() - usageStartedAt,
797-
packagesChecked: pkgNames.size,
834+
packagesChecked: Object.keys(usageData).length,
798835
matchedPackages,
836+
pd001: pd001.length,
837+
pd002: pd002.length,
799838
});
800839
}
801840
}
@@ -828,6 +867,8 @@ async function scanProject(params: {
828867
tableFindings,
829868
suggestedFixCommands,
830869
allPackages: params.scanInput.packages,
870+
pd001,
871+
pd002,
831872
};
832873
}
833874

src/usage/scanner.ts

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -27,15 +27,17 @@ function getBareModuleName(importPath: string): string {
2727

2828
export function scanProjectForPackageUsage(
2929
projectPath: string,
30-
packagesToLookFor: Set<string>,
30+
packagesToLookFor?: Set<string>,
3131
): Record<string, string[]> {
3232
const results: Record<string, string[]> = {};
33-
for (const pkg of packagesToLookFor) {
34-
results[pkg] = [];
35-
}
33+
if (packagesToLookFor) {
34+
for (const pkg of packagesToLookFor) {
35+
results[pkg] = [];
36+
}
3637

37-
if (packagesToLookFor.size === 0) {
38-
return results;
38+
if (packagesToLookFor.size === 0) {
39+
return results;
40+
}
3941
}
4042

4143
// Cap at 5000 files to prevent performance issues on massive projects
@@ -80,14 +82,16 @@ export function scanProjectForPackageUsage(
8082
}
8183

8284
// Fast path: skip regex if the file doesn't contain any target package names
83-
let hasPotentialMatch = false;
84-
for (const pkg of packagesToLookFor) {
85-
if (content.includes(pkg)) {
86-
hasPotentialMatch = true;
87-
break;
85+
if (packagesToLookFor) {
86+
let hasPotentialMatch = false;
87+
for (const pkg of packagesToLookFor) {
88+
if (content.includes(pkg)) {
89+
hasPotentialMatch = true;
90+
break;
91+
}
8892
}
93+
if (!hasPotentialMatch) return;
8994
}
90-
if (!hasPotentialMatch) return;
9195

9296
const matches = content.matchAll(IMPORT_REQUIRE_REGEX);
9397
const foundPackages = new Set<string>();
@@ -96,15 +100,20 @@ export function scanProjectForPackageUsage(
96100
const importPath = match[1] || match[2] || match[3] || match[4];
97101
if (importPath) {
98102
const bare = getBareModuleName(importPath);
99-
if (bare && packagesToLookFor.has(bare)) {
100-
foundPackages.add(bare);
103+
if (bare) {
104+
if (!packagesToLookFor || packagesToLookFor.has(bare)) {
105+
foundPackages.add(bare);
106+
}
101107
}
102108
}
103109
}
104110

105111
for (const pkg of foundPackages) {
106112
// Store relative path for cleaner output
107113
const relPath = path.relative(projectPath, filePath);
114+
if (!results[pkg]) {
115+
results[pkg] = [];
116+
}
108117
results[pkg].push(relPath);
109118
}
110119
}

src/utils/package-json.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,3 +141,31 @@ function expandWorkspacePattern(projectPath: string, segments: string[], current
141141
export function isRecord(value: unknown): value is Record<string, unknown> {
142142
return !!value && typeof value === "object" && !Array.isArray(value);
143143
}
144+
145+
export function readOverridesAndResolutions(projectPath: string): Set<string> {
146+
const packageJsonPath = path.join(projectPath, "package.json");
147+
const result = new Set<string>();
148+
149+
if (!fs.existsSync(packageJsonPath)) {
150+
return result;
151+
}
152+
153+
const manifest = readPackageJsonObject(packageJsonPath);
154+
if (!manifest) {
155+
return result;
156+
}
157+
158+
const sections: Array<Record<string, unknown> | undefined> = [
159+
isRecord(manifest.overrides) ? manifest.overrides : undefined,
160+
isRecord(manifest.resolutions) ? manifest.resolutions : undefined,
161+
];
162+
163+
for (const section of sections) {
164+
if (!section) continue;
165+
for (const name of Object.keys(section)) {
166+
result.add(name);
167+
}
168+
}
169+
170+
return result;
171+
}

0 commit comments

Comments
 (0)