diff --git a/packages/core/src/check-dead-code.ts b/packages/core/src/check-dead-code.ts index 279a68c6c..3fbe9f31b 100644 --- a/packages/core/src/check-dead-code.ts +++ b/packages/core/src/check-dead-code.ts @@ -137,6 +137,12 @@ interface DeadCodeWorkerUnusedDependency { readonly isDevDependency: boolean; } +interface DeadCodeWorkerSkippedDependency { + readonly name: string; + readonly isDevDependency: boolean; + readonly reason: string; +} + interface DeadCodeWorkerCircularDependency { readonly files: ReadonlyArray; } @@ -145,6 +151,7 @@ interface DeadCodeWorkerResult { readonly unusedFiles: ReadonlyArray; readonly unusedExports: ReadonlyArray; readonly unusedDependencies: ReadonlyArray; + readonly skippedDependencies: ReadonlyArray; readonly circularDependencies: ReadonlyArray; readonly summaryCacheStats?: DeadCodeSummaryCacheStats; } @@ -190,6 +197,11 @@ process.stdin.on("end", () => { name: unusedDependency.name, isDevDependency: unusedDependency.isDevDependency, })), + skippedDependencies: result.skippedDependencies.map((skippedDependency) => ({ + name: skippedDependency.name, + isDevDependency: skippedDependency.isDevDependency, + reason: skippedDependency.reason, + })), circularDependencies: result.circularDependencies.map((cycle) => ({ files: cycle.files, })), @@ -359,6 +371,25 @@ const parseUnusedDependencies = (value: unknown): DeadCodeWorkerUnusedDependency return unusedDependencies; }; +const parseSkippedDependencies = (value: unknown): DeadCodeWorkerSkippedDependency[] => { + const values = parseArray(value, "skippedDependencies"); + const skippedDependencies: DeadCodeWorkerSkippedDependency[] = []; + for (const [index, entry] of values.entries()) { + if (!isRecord(entry)) { + throw new Error(`Dead-code worker returned invalid skippedDependencies[${index}].`); + } + skippedDependencies.push({ + name: parseString(entry.name, `skippedDependencies[${index}].name`), + isDevDependency: parseBoolean( + entry.isDevDependency, + `skippedDependencies[${index}].isDevDependency`, + ), + reason: parseString(entry.reason, `skippedDependencies[${index}].reason`), + }); + } + return skippedDependencies; +}; + const parseCircularDependencies = (value: unknown): DeadCodeWorkerCircularDependency[] => { const values = parseArray(value, "circularDependencies"); const circularDependencies: DeadCodeWorkerCircularDependency[] = []; @@ -390,6 +421,7 @@ const parseDeadCodeWorkerResult = (value: unknown): DeadCodeWorkerResult => { unusedFiles: parseUnusedFiles(value.unusedFiles), unusedExports: parseUnusedExports(value.unusedExports), unusedDependencies: parseUnusedDependencies(value.unusedDependencies), + skippedDependencies: parseSkippedDependencies(value.skippedDependencies), circularDependencies: parseCircularDependencies(value.circularDependencies), ...(summaryCacheStats ? { summaryCacheStats } : {}), }; diff --git a/packages/deslop-js/src/index.ts b/packages/deslop-js/src/index.ts index 13f617f0b..40e24cbe7 100644 --- a/packages/deslop-js/src/index.ts +++ b/packages/deslop-js/src/index.ts @@ -144,6 +144,7 @@ export type { UnusedFile, UnusedExport, UnusedDependency, + SkippedDependency, CircularDependency, UnusedType, UnusedTypeKind, @@ -313,6 +314,7 @@ const buildEmptyScanResult = (errors: DeslopError[], elapsedMs: number): ScanRes unusedFiles: [], unusedExports: [], unusedDependencies: [], + skippedDependencies: [], circularDependencies: [], unusedTypes: [], misclassifiedDependencies: [], diff --git a/packages/deslop-js/src/report/generate.ts b/packages/deslop-js/src/report/generate.ts index 85b9833a3..6a16ec52f 100644 --- a/packages/deslop-js/src/report/generate.ts +++ b/packages/deslop-js/src/report/generate.ts @@ -74,12 +74,14 @@ export const generateReport = ( [], errorSink, ); - const unusedDependencies = safeReportDetector( + const stalePackagesResult = safeReportDetector( "detectStalePackages", () => detectStalePackages(graph, config, summaryCache), - [], + { unusedDependencies: [], skippedDependencies: [] }, errorSink, ); + const unusedDependencies = stalePackagesResult.unusedDependencies; + const skippedDependencies = stalePackagesResult.skippedDependencies; const circularDependencies = safeReportDetector( "detectCycles", () => detectCycles(graph), @@ -286,6 +288,7 @@ export const generateReport = ( unusedFiles, unusedExports, unusedDependencies, + skippedDependencies, circularDependencies, unusedTypes: semanticResult.unusedTypes, misclassifiedDependencies: semanticResult.misclassifiedDependencies, diff --git a/packages/deslop-js/src/report/packages.ts b/packages/deslop-js/src/report/packages.ts index 15c393127..cf542740f 100644 --- a/packages/deslop-js/src/report/packages.ts +++ b/packages/deslop-js/src/report/packages.ts @@ -1,7 +1,7 @@ import { resolve, join } from "node:path"; import { readFileSync, existsSync } from "node:fs"; import fg from "fast-glob"; -import type { DependencyGraph, UnusedDependency, DeslopConfig } from "../types.js"; +import type { DependencyGraph, UnusedDependency, SkippedDependency, DeslopConfig } from "../types.js"; import { IMPLICIT_DEPENDENCIES } from "../constants.js"; import { extractPackageName } from "../utils/package-name.js"; import { collectOverrideMappingsFromRecord } from "../utils/collect-override-mappings-from-record.js"; @@ -98,7 +98,7 @@ export const detectStalePackages = ( graph: DependencyGraph, config: DeslopConfig, summaryCache?: SummaryCache, -): UnusedDependency[] => { +): { unusedDependencies: UnusedDependency[]; skippedDependencies: SkippedDependency[] } => { const packageJsonPath = resolve(config.rootDir, "package.json"); let packageJson: PackageJsonDependencies; @@ -106,7 +106,7 @@ export const detectStalePackages = ( const content = readFileSync(packageJsonPath, "utf-8"); packageJson = JSON.parse(content); } catch { - return []; + return { unusedDependencies: [], skippedDependencies: [] }; } const dependencies = packageJson.dependencies ?? {}; @@ -122,6 +122,7 @@ export const detectStalePackages = ( const declaredNames = new Set(declaredDependencies.keys()); const usedPackageNames = collectUsedPackages(graph); + const skippedDependencies: SkippedDependency[] = []; const monorepoRoot = findMonorepoRoot(config.rootDir); const nodeModulesSearchRoots = @@ -144,7 +145,15 @@ export const detectStalePackages = ( // Shipping a CLI binary is itself evidence of use — binaries run from // Makefiles, CI, git hooks, and `npx`, none of which the static scan sees. - for (const packageName of packagesProvidingBinary) usedPackageNames.add(packageName); + for (const packageName of packagesProvidingBinary) { + usedPackageNames.add(packageName); + const isDevDependency = declaredDependencies.get(packageName) ?? false; + skippedDependencies.push({ + name: packageName, + isDevDependency, + reason: `provides a binary and is assumed to be used (binaries are often invoked from scripts, CI, git hooks, or via npx in ways the static scan cannot detect)`, + }); + } for (const workspacePackageJsonPath of allPackageJsonPaths) { const scriptReferenced = collectScriptReferencedPackages( @@ -265,8 +274,19 @@ export const detectStalePackages = ( const candidateUnused = new Set(); for (const [dependencyName] of declaredDependencies) { - if (isAlwaysConsideredUsed(dependencyName)) continue; if (usedPackageNames.has(dependencyName)) continue; + + const skipReason = getSkipReason(dependencyName); + if (skipReason) { + const isDevDependency = declaredDependencies.get(dependencyName) ?? false; + skippedDependencies.push({ + name: dependencyName, + isDevDependency, + reason: skipReason, + }); + continue; + } + candidateUnused.add(dependencyName); } @@ -294,7 +314,7 @@ export const detectStalePackages = ( }); } - return unusedDependencies; + return { unusedDependencies, skippedDependencies }; }; const collectUsedPackages = (graph: DependencyGraph): Set => { @@ -1006,3 +1026,20 @@ const isAlwaysConsideredUsed = (dependencyName: string): boolean => { if (ALWAYS_USED_SUFFIXES.some((suffix) => dependencyName.endsWith(suffix))) return true; return false; }; + +const getSkipReason = (dependencyName: string): string | undefined => { + if (IMPLICIT_DEPENDENCIES.has(dependencyName)) { + return `is an implicit dependency and is assumed to be used`; + } + for (const prefix of ALWAYS_USED_PREFIXES) { + if (dependencyName.startsWith(prefix)) { + return `matches allowlisted prefix "${prefix}" and is assumed to be used (packages with this prefix typically have side effects, auto-configuration, or indirect usage patterns that are difficult to detect statically)`; + } + } + for (const suffix of ALWAYS_USED_SUFFIXES) { + if (dependencyName.endsWith(suffix)) { + return `matches allowlisted suffix "${suffix}" and is assumed to be used (packages with this suffix typically have side effects or indirect usage patterns that are difficult to detect statically)`; + } + } + return undefined; +}; diff --git a/packages/deslop-js/src/types.ts b/packages/deslop-js/src/types.ts index 0a9ebb409..fe48127cc 100644 --- a/packages/deslop-js/src/types.ts +++ b/packages/deslop-js/src/types.ts @@ -184,6 +184,12 @@ export interface UnusedDependency { reason: string; } +export interface SkippedDependency { + name: string; + isDevDependency: boolean; + reason: string; +} + export interface CircularDependency { files: string[]; } @@ -597,6 +603,7 @@ export interface ScanResult { unusedFiles: UnusedFile[]; unusedExports: UnusedExport[]; unusedDependencies: UnusedDependency[]; + skippedDependencies: SkippedDependency[]; circularDependencies: CircularDependency[]; unusedTypes: UnusedType[]; misclassifiedDependencies: MisclassifiedDependency[]; diff --git a/packages/deslop-js/tests/analyze.test.ts b/packages/deslop-js/tests/analyze.test.ts index 93f05a932..1d15ee653 100644 --- a/packages/deslop-js/tests/analyze.test.ts +++ b/packages/deslop-js/tests/analyze.test.ts @@ -39,6 +39,9 @@ const deadExportsByFile = (result: ScanResult, fixtureDir: string): Record result.unusedDependencies.map((dep) => dep.name).sort(); +const skippedDependencyNames = (result: ScanResult): string[] => + result.skippedDependencies.map((dep) => dep.name).sort(); + describe("simple-app", () => { it("should detect orphan file", async () => { const result = await scanFixture("simple-app"); @@ -88,6 +91,53 @@ describe("simple-app", () => { }); }); +describe("skipped-deps-test", () => { + it("should track dependencies skipped due to allowlisted prefixes", async () => { + const result = await scanFixture("skipped-deps-test"); + const skipped = skippedDependencyNames(result); + + assert.ok( + skipped.includes("expo-status-bar"), + `expo-status-bar should be skipped (expo- prefix), got: ${skipped}`, + ); + assert.ok( + skipped.includes("@react-navigation/native"), + `@react-navigation/native should be skipped (@react-navigation/ prefix), got: ${skipped}`, + ); + assert.ok( + skipped.includes("@types/lodash"), + `@types/lodash should be skipped (@types/ prefix), got: ${skipped}`, + ); + assert.ok( + skipped.includes("webpack-cli"), + `webpack-cli should be skipped (webpack- prefix), got: ${skipped}`, + ); + }); + + it("should provide descriptive reasons for skipped dependencies", async () => { + const result = await scanFixture("skipped-deps-test"); + + const expoSkipped = result.skippedDependencies.find((dep) => dep.name === "expo-status-bar"); + assert.ok(expoSkipped, "expo-status-bar should be in skipped list"); + assert.match(expoSkipped.reason, /expo-/i, "reason should mention expo- prefix"); + assert.match(expoSkipped.reason, /allowlisted prefix/i, "reason should mention allowlisted prefix"); + + const typesSkipped = result.skippedDependencies.find((dep) => dep.name === "@types/lodash"); + assert.ok(typesSkipped, "@types/lodash should be in skipped list"); + assert.match(typesSkipped.reason, /@types\//i, "reason should mention @types/ prefix"); + assert.equal(typesSkipped.isDevDependency, true, "@types/lodash should be marked as dev dependency"); + }); + + it("should still flag genuinely unused dependencies", async () => { + const result = await scanFixture("skipped-deps-test"); + const unused = staleDependencyNames(result); + const skipped = skippedDependencyNames(result); + + assert.ok(unused.includes("lodash"), `lodash should be unused, got: ${unused}`); + assert.ok(!skipped.includes("lodash"), `lodash should not be skipped, got: ${skipped}`); + }); +}); + describe("astro-app", () => { it("treats Astro's default Sharp image service as used", async () => { const result = await scanFixture("astro-app"); diff --git a/packages/deslop-js/tests/fixtures/skipped-deps-test/package.json b/packages/deslop-js/tests/fixtures/skipped-deps-test/package.json new file mode 100644 index 000000000..627309831 --- /dev/null +++ b/packages/deslop-js/tests/fixtures/skipped-deps-test/package.json @@ -0,0 +1,16 @@ +{ + "name": "skipped-deps-test", + "main": "src/index.ts", + "dependencies": { + "react": "^18.0.0", + "expo-status-bar": "^2.0.0", + "@react-navigation/native": "^6.0.0", + "lodash": "^4.17.21" + }, + "devDependencies": { + "@types/lodash": "^4.0.0", + "typescript": "^5.0.0", + "webpack-cli": "^5.0.0", + "mkdirp": "^3.0.0" + } +} diff --git a/packages/deslop-js/tests/fixtures/skipped-deps-test/src/index.ts b/packages/deslop-js/tests/fixtures/skipped-deps-test/src/index.ts new file mode 100644 index 000000000..e563a341e --- /dev/null +++ b/packages/deslop-js/tests/fixtures/skipped-deps-test/src/index.ts @@ -0,0 +1,3 @@ +import { createElement } from 'react'; + +export const App = () => createElement('div', null, 'hello');