Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file added pr592_comments.json
Empty file.
53 changes: 47 additions & 6 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -656,6 +656,20 @@ if (parsedArgs) {
}
if (showOverrideHint) printOverrideHint();
}

if (options.usage) {
if (scanState.pd001 && scanState.pd001.length > 0) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Phantom detection is gated on options.usage, but --usage is a separate feature for import-reachability filtering with its own semantics. Users who don't pass --usage get no phantom detection at all, and users who do pass --usage get phantom detection they didn't ask for. These should be decoupled - either run phantom detection unconditionally or add a dedicated --check-phantoms flag.

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)) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Phantom findings aren't wired into --json output - machine consumers parsing JSON (CI pipelines, dashboards) get no phantom data at all. The pd001/pd002 results need to be included in the JSON schema before this feature is usable in CI.

console.log(chalk.gray("\nAction: Declare these dependencies explicitly in your package.json.\n"));
}
}
}
}

Expand Down Expand Up @@ -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)));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any phantom finding triggers exit code 1 here regardless of what --fail-on is set to. A project running --fail-on critical would fail hard on any phantom import, including low-risk dev tooling. This silently breaks the CI contract --fail-on is supposed to provide.

const exitCode = shouldFail ? 1 : 0;

// Emit scan.finished event and close audit-log
Expand Down Expand Up @@ -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<string>();

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];
Expand All @@ -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,
});
}
}
Expand Down Expand Up @@ -839,6 +878,8 @@ async function scanProject(params: {
tableFindings,
suggestedFixCommands,
allPackages: params.scanInput.packages,
pd001,
pd002,
};
}

Expand Down
37 changes: 23 additions & 14 deletions src/usage/scanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,17 @@ function getBareModuleName(importPath: string): string {

export function scanProjectForPackageUsage(
projectPath: string,
packagesToLookFor: Set<string>,
packagesToLookFor?: Set<string>,
): Record<string, string[]> {
const results: Record<string, string[]> = {};
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
Expand Down Expand Up @@ -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<string>();
Expand All @@ -96,15 +100,20 @@ 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);
}
}
}
}

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);
}
}
Expand Down
28 changes: 28 additions & 0 deletions src/utils/package-json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,3 +157,31 @@ export function hasOverrideEntries(dir: string): boolean {
return false;
}
}

export function readOverridesAndResolutions(projectPath: string): Set<string> {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The existing hasOverrideEntries just above this (line 151-153) correctly checks pnpm.overrides nested under the pnpm key. readOverridesAndResolutions only reads top-level overrides and resolutions, so pnpm projects using pnpm.overrides will have their overridden packages misclassified as pd002 instead of pd001.

const packageJsonPath = path.join(projectPath, "package.json");
const result = new Set<string>();

if (!fs.existsSync(packageJsonPath)) {
return result;
}

const manifest = readPackageJsonObject(packageJsonPath);
if (!manifest) {
return result;
}

const sections: Array<Record<string, unknown> | 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;
}