deadwood-scan 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -2,13 +2,13 @@
2
2
 
3
3
  // src/index.ts
4
4
  import { createRequire } from "module";
5
- import process from "process";
5
+ import process2 from "process";
6
6
  import { Command, Option } from "commander";
7
7
  import ora from "ora";
8
- import pc2 from "picocolors";
8
+ import pc3 from "picocolors";
9
9
 
10
10
  // ../core/src/index.ts
11
- import fs8 from "fs";
11
+ import fs9 from "fs";
12
12
  import path11 from "path";
13
13
  import { glob as glob2 } from "tinyglobby";
14
14
 
@@ -981,6 +981,11 @@ var SIGNAL_LIST = [
981
981
  spare("static:string-dispatch", 20, "Symbol appears in a string-keyed dispatch table"),
982
982
  spare("static:script-reference", 60, "Referenced by a package.json script"),
983
983
  spare("static:config-reference", 60, "Referenced by a build/deploy config"),
984
+ spare(
985
+ "static:dev-config-reference",
986
+ 60,
987
+ "Referenced by a tooling config file (eslint/babel/CI/\u2026) rather than an import"
988
+ ),
984
989
  spare("static:manifest-reference", 60, "Referenced by a caller manifest (cron/CI/k8s)"),
985
990
  spare("static:job-registration", 50, "File registers scheduled jobs or queue workers"),
986
991
  spare(
@@ -1005,6 +1010,28 @@ var SIGNAL_LIST = [
1005
1010
  30,
1006
1011
  "Re-exported by a package entry point; external consumers are invisible to static analysis"
1007
1012
  ),
1013
+ // Git-history signals (git:*) — computed from the repo's own history, which
1014
+ // the CLI collects and injects via scan options (core never spawns git; see
1015
+ // evidence/gitHistory.ts). Deliberately `source: 'static'` so the static cap
1016
+ // still applies: history sharpens confidence, only PRODUCTION lifts the cap.
1017
+ kill("git:untouched-1y", 10, "No commit has touched this file in over a year"),
1018
+ spare(
1019
+ "git:recently-modified",
1020
+ 15,
1021
+ "This file was modified in the last 30 days \u2014 likely active work"
1022
+ ),
1023
+ // Dev/staging/test-traffic evidence (dev:*) — runtime evidence from
1024
+ // NON-production collectors (a probe pointed at a dev server or running
1025
+ // during an e2e suite). Only the spare direction ships for now: traffic
1026
+ // seen anywhere proves the route is wired (revival errs safe); a dev
1027
+ // zero-requests kill would need its own coverage-guarantee story.
1028
+ {
1029
+ id: "dev:request-seen",
1030
+ source: "development",
1031
+ direction: "spare",
1032
+ weight: -60,
1033
+ description: "Traffic observed in a development/staging/test environment"
1034
+ },
1008
1035
  // Reserved for the paid evidence layer — registered now so the scorer and
1009
1036
  // JSON contract already understand them.
1010
1037
  {
@@ -1036,6 +1063,8 @@ var STATIC_CAPS = {
1036
1063
  "unreachable-file": 85
1037
1064
  };
1038
1065
  var STATIC_CAP_REASON = "no runtime evidence \u2014 connect telemetry to confirm";
1066
+ var DEV_CAP = 90;
1067
+ var DEV_CAP_REASON = "dev traffic cannot confirm \u2014 production evidence completes it";
1039
1068
  function evidenceFor(signalId, detail, location) {
1040
1069
  const spec = SIGNALS.get(signalId);
1041
1070
  if (!spec) {
@@ -1784,9 +1813,6 @@ function findPathMatch2(routePath, literals) {
1784
1813
  );
1785
1814
  }
1786
1815
 
1787
- // ../core/src/evidence/dynamicImports.ts
1788
- import path10 from "path";
1789
-
1790
1816
  // ../core/src/evidence/types.ts
1791
1817
  function addOnce(finding, evidence) {
1792
1818
  if (!finding.evidence.some((e) => e.signal === evidence.signal)) {
@@ -1794,7 +1820,59 @@ function addOnce(finding, evidence) {
1794
1820
  }
1795
1821
  }
1796
1822
 
1823
+ // ../core/src/evidence/gitHistory.ts
1824
+ var DAY = 24 * 60 * 60;
1825
+ var STALE_SECONDS = 365 * DAY;
1826
+ var FRESH_SECONDS = 30 * DAY;
1827
+ function ageInWords(seconds) {
1828
+ const days = Math.floor(seconds / DAY);
1829
+ if (days < 1) return "today";
1830
+ if (days === 1) return "yesterday";
1831
+ if (days < 60) return `${days} days ago`;
1832
+ return `${Math.floor(days / 30)} months ago`;
1833
+ }
1834
+ function gitHistory(facts) {
1835
+ return {
1836
+ name: "git-history",
1837
+ provide(_ctx, findings) {
1838
+ if (!facts) return;
1839
+ for (const finding of findings) {
1840
+ if (finding.category === "unused-dependency") continue;
1841
+ const last = facts.lastModified[finding.location.file];
1842
+ if (last === void 0) {
1843
+ if (!facts.truncated) {
1844
+ addOnce(
1845
+ finding,
1846
+ evidenceFor("git:recently-modified", "not committed yet \u2014 likely work in progress")
1847
+ );
1848
+ }
1849
+ continue;
1850
+ }
1851
+ const age = facts.now - last;
1852
+ if (age <= FRESH_SECONDS) {
1853
+ addOnce(
1854
+ finding,
1855
+ evidenceFor(
1856
+ "git:recently-modified",
1857
+ `last commit touching this file was ${ageInWords(age)}`
1858
+ )
1859
+ );
1860
+ } else if (age >= STALE_SECONDS) {
1861
+ addOnce(
1862
+ finding,
1863
+ evidenceFor(
1864
+ "git:untouched-1y",
1865
+ `last commit touching this file was ${ageInWords(age)}`
1866
+ )
1867
+ );
1868
+ }
1869
+ }
1870
+ }
1871
+ };
1872
+ }
1873
+
1797
1874
  // ../core/src/evidence/dynamicImports.ts
1875
+ import path10 from "path";
1798
1876
  var dynamicImports = {
1799
1877
  name: "dynamic-imports",
1800
1878
  provide(ctx, findings) {
@@ -1926,6 +2004,126 @@ function truncate(s) {
1926
2004
  return s.length > 60 ? s.slice(0, 57) + "\u2026" : s;
1927
2005
  }
1928
2006
 
2007
+ // ../core/src/evidence/depConfigRefs.ts
2008
+ import fs8 from "fs";
2009
+ import { globSync } from "tinyglobby";
2010
+ var CONFIG_GLOBS = [
2011
+ "**/.eslintrc",
2012
+ "**/.eslintrc.{json,yml,yaml,js,cjs}",
2013
+ "**/eslint.config.{js,cjs,mjs,ts}",
2014
+ "**/.babelrc",
2015
+ "**/.babelrc.{json,js,cjs}",
2016
+ "**/babel.config.{json,js,cjs,mjs}",
2017
+ "**/.prettierrc",
2018
+ "**/.prettierrc.{json,yml,yaml,js,cjs}",
2019
+ "**/prettier.config.{js,cjs,mjs}",
2020
+ "**/.mocharc.{json,yml,yaml,js,cjs}",
2021
+ "**/jest.config.{js,cjs,mjs,ts,json}",
2022
+ "**/jest.setup.{js,cjs,mjs,ts}",
2023
+ "**/vitest.config.{js,cjs,mjs,ts}",
2024
+ "**/webpack.config.{js,cjs,mjs,ts}",
2025
+ "**/rollup.config.{js,cjs,mjs,ts}",
2026
+ "**/tsup.config.{js,cjs,mjs,ts}",
2027
+ "**/playwright.config.{js,cjs,mjs,ts}",
2028
+ "**/cypress.config.{js,cjs,mjs,ts}",
2029
+ "**/karma.conf.{js,cjs}",
2030
+ "**/tsconfig*.json",
2031
+ "**/.lintstagedrc",
2032
+ "**/.lintstagedrc.{json,yml,yaml}",
2033
+ "**/nyc.config.js",
2034
+ "**/.nycrc",
2035
+ "**/.nycrc.{json,yml,yaml}",
2036
+ "**/commitlint.config.{js,cjs,mjs}",
2037
+ "**/.github/workflows/*.{yml,yaml}",
2038
+ "**/.travis.yml",
2039
+ "**/appveyor.yml",
2040
+ "**/azure-pipelines.yml",
2041
+ "**/.gitlab-ci.yml"
2042
+ ];
2043
+ var CONFIG_PRESENCE = [
2044
+ { dep: "typescript", file: /^tsconfig[^/]*\.json$/ },
2045
+ { dep: "prettier", file: /^\.prettierrc|^prettier\.config\./ },
2046
+ { dep: "eslint", file: /^\.eslintrc|^eslint\.config\./ },
2047
+ { dep: "jest", file: /^jest\.(config|setup)\./ },
2048
+ { dep: "vitest", file: /^vitest\.config\./ },
2049
+ { dep: "mocha", file: /^\.mocharc/ },
2050
+ { dep: "nyc", file: /^\.nycrc|^nyc\.config\./ },
2051
+ { dep: "karma", file: /^karma\.conf\./ }
2052
+ ];
2053
+ var IGNORE = ["**/node_modules/**", "**/dist/**", "**/.git/**"];
2054
+ var MAX_FILES = 100;
2055
+ var MAX_FILE_BYTES = 256 * 1024;
2056
+ var depConfigRefs = {
2057
+ name: "dep-config-references",
2058
+ provide(ctx, findings) {
2059
+ const deps = findings.filter(
2060
+ (f) => f.category === "unused-dependency" && f.target.kind === "dependency"
2061
+ );
2062
+ if (deps.length === 0) return;
2063
+ const configs = readConfigFiles(ctx);
2064
+ if (configs.length === 0) return;
2065
+ for (const finding of deps) {
2066
+ if (finding.target.kind !== "dependency") continue;
2067
+ const name = finding.target.name;
2068
+ const hit = configs.find((c) => referencesDep(c, name));
2069
+ if (hit) {
2070
+ addOnce(finding, evidenceFor("static:dev-config-reference", `Referenced in ${hit.rel}`));
2071
+ continue;
2072
+ }
2073
+ const presence = CONFIG_PRESENCE.find((p) => p.dep === name);
2074
+ const owner = presence && configs.find((c) => presence.file.test(c.base));
2075
+ if (owner) {
2076
+ addOnce(
2077
+ finding,
2078
+ evidenceFor("static:dev-config-reference", `Its config file exists: ${owner.rel}`)
2079
+ );
2080
+ }
2081
+ }
2082
+ }
2083
+ };
2084
+ function readConfigFiles(ctx) {
2085
+ let paths;
2086
+ try {
2087
+ paths = globSync(CONFIG_GLOBS, { cwd: ctx.root, ignore: IGNORE, absolute: true, dot: true });
2088
+ } catch {
2089
+ return [];
2090
+ }
2091
+ const out = [];
2092
+ for (const p of paths.map(toPosix).sort().slice(0, MAX_FILES)) {
2093
+ try {
2094
+ if (fs8.statSync(p).size > MAX_FILE_BYTES) continue;
2095
+ out.push({
2096
+ rel: ctx.rel(p),
2097
+ base: p.slice(p.lastIndexOf("/") + 1).toLowerCase(),
2098
+ text: fs8.readFileSync(p, "utf8")
2099
+ });
2100
+ } catch {
2101
+ }
2102
+ }
2103
+ return out;
2104
+ }
2105
+ function referencesDep(config, depName) {
2106
+ if (wordMatch(config.text, depName)) return true;
2107
+ if (config.base.includes("eslint")) {
2108
+ const m = /^eslint-(?:plugin|config)-(.+)$/.exec(depName);
2109
+ if (m && wordMatch(config.text, m[1])) return true;
2110
+ const scoped = /^(@[^/]+)\/eslint-config$/.exec(depName);
2111
+ if (scoped && wordMatch(config.text, scoped[1])) return true;
2112
+ }
2113
+ if (config.base.includes("babel")) {
2114
+ const m = /^babel-(?:plugin|preset)-(.+)$/.exec(depName);
2115
+ if (m && wordMatch(config.text, m[1])) return true;
2116
+ }
2117
+ return false;
2118
+ }
2119
+ function wordMatch(text, name) {
2120
+ const re = new RegExp(`(^|[^\\w@/-])${escapeRe2(name)}($|[^\\w-])`, "m");
2121
+ return re.test(text);
2122
+ }
2123
+ function escapeRe2(s) {
2124
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2125
+ }
2126
+
1929
2127
  // ../core/src/evidence/jobRegistration.ts
1930
2128
  import ts6 from "typescript";
1931
2129
  var JOB_PACKAGES = /* @__PURE__ */ new Set([
@@ -2041,12 +2239,13 @@ function levelFor(score) {
2041
2239
  }
2042
2240
  function scoreFinding(category, evidence) {
2043
2241
  const raw = evidence.reduce((sum, e) => sum + e.weight, 0);
2044
- const allStatic = evidence.every((e) => e.source === "static");
2045
- const cap = allStatic ? STATIC_CAPS[category] : 100;
2242
+ const hasProduction = evidence.some((e) => e.source === "production" || e.source === "tombstone");
2243
+ const hasDev = evidence.some((e) => e.source === "development");
2244
+ const [cap, reason] = hasProduction ? [100, ""] : hasDev ? [DEV_CAP, DEV_CAP_REASON] : [STATIC_CAPS[category], STATIC_CAP_REASON];
2046
2245
  const score = Math.max(0, Math.min(cap, Math.round(raw)));
2047
2246
  const result = { score, level: levelFor(score) };
2048
- if (allStatic && raw > cap) {
2049
- result.capped = { at: cap, reason: STATIC_CAP_REASON };
2247
+ if (!hasProduction && raw > cap) {
2248
+ result.capped = { at: cap, reason };
2050
2249
  }
2051
2250
  return result;
2052
2251
  }
@@ -2065,6 +2264,26 @@ var DEFAULT_EXCLUDE = [
2065
2264
  "**/*.d.ts",
2066
2265
  "**/*.min.js"
2067
2266
  ];
2267
+ var NOISE_EXCLUDE = [
2268
+ // Generated code — owned by a generator, not deletable by hand.
2269
+ "**/generated/**",
2270
+ "**/__generated__/**",
2271
+ "**/*.generated.*",
2272
+ "**/.prisma/**",
2273
+ // Example/demo code — each file is its own intentional entry point.
2274
+ "**/examples/**",
2275
+ "**/example/**",
2276
+ "**/samples/**",
2277
+ "**/sample/**",
2278
+ // Test scaffolding — referenced by test runners in ways imports don't show
2279
+ // (jest loads __mocks__/ and setup files by convention/config string).
2280
+ "**/fixtures/**",
2281
+ "**/__fixtures__/**",
2282
+ "**/__mocks__/**",
2283
+ "**/testdata/**",
2284
+ "**/jest.setup.*",
2285
+ "**/vitest.setup.*"
2286
+ ];
2068
2287
  var ALL_CATEGORIES = [
2069
2288
  "dead-route",
2070
2289
  "unused-export",
@@ -2089,6 +2308,7 @@ var PROVIDERS = [
2089
2308
  dynamicImports,
2090
2309
  stringDispatch,
2091
2310
  packageScripts,
2311
+ depConfigRefs,
2092
2312
  jobRegistration,
2093
2313
  manifestReference,
2094
2314
  comments
@@ -2096,37 +2316,43 @@ var PROVIDERS = [
2096
2316
  async function scan(options) {
2097
2317
  const startedAt = Date.now();
2098
2318
  const root = absPosix(options.path);
2099
- if (!fs8.existsSync(root) || !fs8.statSync(root).isDirectory()) {
2319
+ if (!fs9.existsSync(root) || !fs9.statSync(root).isDirectory()) {
2100
2320
  throw new ScanError("INVALID_PATH", `Not a directory: ${options.path}`);
2101
2321
  }
2102
2322
  const warnings = [];
2103
2323
  const { units: allUnits, warnings: wsWarnings } = await discoverWorkspaces(root);
2104
2324
  warnings.push(...wsWarnings);
2105
2325
  const units = options.workspaces && options.workspaces.length > 0 ? allUnits.filter((u, i) => i === 0 || options.workspaces.includes(u.name)) : allUnits;
2106
- const files = await glob2([...DEFAULT_INCLUDE, ...options.include ?? []], {
2107
- cwd: root,
2108
- ignore: [...DEFAULT_EXCLUDE, ...options.exclude ?? []],
2109
- absolute: true,
2110
- dot: true
2111
- });
2326
+ const include = [...DEFAULT_INCLUDE, ...options.include ?? []];
2327
+ const baseIgnore = [...DEFAULT_EXCLUDE, ...options.exclude ?? []];
2328
+ const globOpts = { cwd: root, absolute: true, dot: true, followSymbolicLinks: false };
2329
+ const files = await glob2(include, { ...globOpts, ignore: baseIgnore });
2112
2330
  if (files.length === 0) {
2113
2331
  throw new ScanError("NO_SOURCE_FILES", `No JS/TS source files found under ${options.path}`);
2114
2332
  }
2333
+ const reportableFiles = options.defaultExcludes === false ? void 0 : new Set(
2334
+ (await glob2(include, { ...globOpts, ignore: [...baseIgnore, ...NOISE_EXCLUDE] })).map(
2335
+ toPosix
2336
+ )
2337
+ );
2115
2338
  const graph = new ModuleGraph();
2116
2339
  for (const file of files.map(toPosix).sort()) {
2340
+ const source = readSourceSafe(file, warnings, (f) => relPosix(root, f));
2341
+ if (source === void 0) continue;
2117
2342
  try {
2118
- graph.addFile(parseFile(file, fs8.readFileSync(file, "utf8")));
2343
+ graph.addFile(parseFile(file, source));
2119
2344
  } catch {
2120
2345
  warnings.push(`Could not parse ${relPosix(root, file)}; it is excluded from analysis.`);
2121
2346
  }
2122
2347
  }
2123
- const scannedFiles = new Set(graph.files.keys());
2124
- const wsEntries = units.filter((u) => u.name !== ".").map((u) => ({ name: u.name, dir: u.dir, entryFile: guessUnitEntry(u, scannedFiles) }));
2125
- graph.link(new Resolver(root, wsEntries, scannedFiles, warnings));
2126
- const manifestCallers = await collectManifestCallers(root, scannedFiles, warnings);
2348
+ const parsedFiles = new Set(graph.files.keys());
2349
+ const scannedFiles = reportableFiles ? new Set([...parsedFiles].filter((f) => reportableFiles.has(f))) : parsedFiles;
2350
+ const wsEntries = units.filter((u) => u.name !== ".").map((u) => ({ name: u.name, dir: u.dir, entryFile: guessUnitEntry(u, parsedFiles) }));
2351
+ graph.link(new Resolver(root, wsEntries, parsedFiles, warnings));
2352
+ const manifestCallers = await collectManifestCallers(root, parsedFiles, warnings);
2127
2353
  const entriesByUnit = /* @__PURE__ */ new Map();
2128
2354
  for (const unit of units) {
2129
- entriesByUnit.set(unit, discoverEntryPoints(unit, scannedFiles, warnings));
2355
+ entriesByUnit.set(unit, discoverEntryPoints(unit, parsedFiles, warnings));
2130
2356
  }
2131
2357
  const rootUnit = units[0];
2132
2358
  for (const caller of manifestCallers) {
@@ -2137,7 +2363,7 @@ async function scan(options) {
2137
2363
  });
2138
2364
  }
2139
2365
  for (const ref of options.entries ?? []) {
2140
- const resolved = resolveEntryRef(root, ref, scannedFiles) ?? tryFile(path11.resolve(root, ref));
2366
+ const resolved = resolveEntryRef(root, ref, parsedFiles) ?? tryFile(path11.resolve(root, ref));
2141
2367
  if (resolved) {
2142
2368
  pushEntry(entriesByUnit, unitForFile(units, toPosix(resolved)), {
2143
2369
  file: toPosix(resolved),
@@ -2151,7 +2377,7 @@ async function scan(options) {
2151
2377
  for (const unit of units) {
2152
2378
  const eps = entriesByUnit.get(unit);
2153
2379
  if (!eps.some((e) => e.tag !== "test" && e.reason !== "config")) {
2154
- const guess = guessUnitEntry(unit, scannedFiles);
2380
+ const guess = guessUnitEntry(unit, parsedFiles);
2155
2381
  if (guess)
2156
2382
  pushEntry(entriesByUnit, unit, { file: guess, reason: "main", detail: "convention" });
2157
2383
  }
@@ -2204,7 +2430,7 @@ async function scan(options) {
2204
2430
  rel: (file) => relPosix(root, file),
2205
2431
  abs: (relPath) => `${root}/${relPath}`
2206
2432
  };
2207
- for (const provider of PROVIDERS) {
2433
+ for (const provider of [...PROVIDERS, gitHistory(options.gitFacts)]) {
2208
2434
  provider.provide(providerCtx, findings);
2209
2435
  }
2210
2436
  for (const finding of findings) {
@@ -2223,10 +2449,28 @@ async function scan(options) {
2223
2449
  root,
2224
2450
  workspaces: units.map((u) => u.name),
2225
2451
  findings,
2226
- stats: computeStats(graph, units, findings, routesTotal, Date.now() - startedAt),
2452
+ stats: computeStats(graph, scannedFiles, units, findings, routesTotal, Date.now() - startedAt),
2227
2453
  warnings: [...new Set(warnings)]
2228
2454
  };
2229
2455
  }
2456
+ var MAX_SOURCE_BYTES = 8 * 1024 * 1024;
2457
+ function readSourceSafe(file, warnings, rel) {
2458
+ try {
2459
+ const st = fs9.lstatSync(file);
2460
+ if (!st.isFile()) {
2461
+ warnings.push(`Skipped ${rel(file)}: not a regular file (symlink or special file).`);
2462
+ return void 0;
2463
+ }
2464
+ if (st.size > MAX_SOURCE_BYTES) {
2465
+ warnings.push(`Skipped ${rel(file)}: exceeds ${MAX_SOURCE_BYTES} bytes.`);
2466
+ return void 0;
2467
+ }
2468
+ return fs9.readFileSync(file, "utf8");
2469
+ } catch {
2470
+ warnings.push(`Could not read ${rel(file)}; it is excluded from analysis.`);
2471
+ return void 0;
2472
+ }
2473
+ }
2230
2474
  function pushEntry(map, unit, entry) {
2231
2475
  const list = map.get(unit);
2232
2476
  if (!list) {
@@ -2258,7 +2502,14 @@ async function collectManifestCallers(root, scannedFiles, warnings) {
2258
2502
  "**/cron.d/**",
2259
2503
  "webpack.config.{js,cjs,mjs,ts}"
2260
2504
  ],
2261
- { cwd: root, ignore: ["**/node_modules/**", "**/dist/**", "**/.git/**"], dot: true }
2505
+ // followSymbolicLinks:false same untrusted-content rule as the source
2506
+ // globs (a symlinked serverless.yml → host file must not be read).
2507
+ {
2508
+ cwd: root,
2509
+ ignore: ["**/node_modules/**", "**/dist/**", "**/.git/**"],
2510
+ dot: true,
2511
+ followSymbolicLinks: false
2512
+ }
2262
2513
  );
2263
2514
  for (const rel of manifestFiles.map(toPosix).sort()) {
2264
2515
  try {
@@ -2281,23 +2532,24 @@ async function collectManifestCallers(root, scannedFiles, warnings) {
2281
2532
  }
2282
2533
  return callers.filter((c) => scannedFiles.has(c.file));
2283
2534
  }
2284
- function computeStats(graph, units, findings, routesTotal, durationMs) {
2535
+ function computeStats(graph, scannedFiles, units, findings, routesTotal, durationMs) {
2285
2536
  const flagged = (category) => findings.filter((f) => f.category === category && f.confidence.score >= 50).length;
2286
2537
  let exportsTotal = 0;
2287
- for (const pf of graph.files.values()) {
2288
- exportsTotal += new Set(pf.exports.map((e) => e.name)).size;
2538
+ for (const file of scannedFiles) {
2539
+ const pf = graph.files.get(file);
2540
+ if (pf) exportsTotal += new Set(pf.exports.map((e) => e.name)).size;
2289
2541
  }
2290
2542
  let depsTotal = 0;
2291
2543
  for (const u of units) {
2292
2544
  depsTotal += Object.keys(u.packageJson.dependencies ?? {}).length + Object.keys(u.packageJson.devDependencies ?? {}).length;
2293
2545
  }
2294
2546
  return {
2295
- filesScanned: graph.files.size,
2547
+ filesScanned: scannedFiles.size,
2296
2548
  durationMs,
2297
2549
  routes: { total: routesTotal, flagged: flagged("dead-route") },
2298
2550
  exports: { total: exportsTotal, flagged: flagged("unused-export") },
2299
2551
  dependencies: { total: depsTotal, flagged: flagged("unused-dependency") },
2300
- files: { total: graph.files.size, flagged: flagged("unreachable-file") }
2552
+ files: { total: scannedFiles.size, flagged: flagged("unreachable-file") }
2301
2553
  };
2302
2554
  }
2303
2555
 
@@ -2317,6 +2569,282 @@ function evaluateFailOn(findings, spec) {
2317
2569
  return { count, tripped: count >= spec.min };
2318
2570
  }
2319
2571
 
2572
+ // src/git.ts
2573
+ import { spawnSync } from "child_process";
2574
+ var MAX_COMMITS = 5e4;
2575
+ var MAX_BUFFER = 128 * 1024 * 1024;
2576
+ var MARK = "";
2577
+ function collectGitFacts(dir, now = /* @__PURE__ */ new Date()) {
2578
+ const out = runGit(dir, [
2579
+ "log",
2580
+ `--max-count=${MAX_COMMITS}`,
2581
+ "--format=%x01%ct",
2582
+ "--name-only",
2583
+ "--no-renames",
2584
+ "--relative",
2585
+ "--",
2586
+ "."
2587
+ ]);
2588
+ if (out === void 0) return void 0;
2589
+ const lastModified = {};
2590
+ let commits = 0;
2591
+ let current = 0;
2592
+ for (const rawLine of out.split("\n")) {
2593
+ const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
2594
+ if (line.length === 0) continue;
2595
+ if (line.startsWith(MARK)) {
2596
+ const ts7 = Number(line.slice(1));
2597
+ if (!Number.isFinite(ts7)) continue;
2598
+ current = ts7;
2599
+ commits++;
2600
+ continue;
2601
+ }
2602
+ if (current === 0) continue;
2603
+ const path13 = normalizeGitPath(line);
2604
+ if (lastModified[path13] === void 0) lastModified[path13] = current;
2605
+ }
2606
+ if (commits === 0) return void 0;
2607
+ return {
2608
+ lastModified,
2609
+ truncated: commits >= MAX_COMMITS,
2610
+ now: Math.floor(now.getTime() / 1e3)
2611
+ };
2612
+ }
2613
+ function normalizeGitPath(line) {
2614
+ if (line.startsWith('"') && line.endsWith('"') && line.length >= 2) {
2615
+ try {
2616
+ return JSON.parse(line);
2617
+ } catch {
2618
+ return line;
2619
+ }
2620
+ }
2621
+ return line;
2622
+ }
2623
+ function runGit(dir, args) {
2624
+ try {
2625
+ const res = spawnSync("git", ["-C", dir, ...args], {
2626
+ encoding: "utf8",
2627
+ maxBuffer: MAX_BUFFER,
2628
+ windowsHide: true
2629
+ });
2630
+ if (res.error || res.status !== 0 || typeof res.stdout !== "string") return void 0;
2631
+ return res.stdout;
2632
+ } catch {
2633
+ return void 0;
2634
+ }
2635
+ }
2636
+
2637
+ // src/clean.ts
2638
+ import fs10 from "fs";
2639
+ import path12 from "path";
2640
+ import readline from "readline/promises";
2641
+ import { spawnSync as spawnSync2 } from "child_process";
2642
+ import process from "process";
2643
+ import pc from "picocolors";
2644
+ var LEVEL_RANK2 = { low: 0, medium: 1, high: 2 };
2645
+ async function runClean(dir, opts) {
2646
+ const root = path12.resolve(dir);
2647
+ const useColor = opts.color && process.stdout.isTTY === true;
2648
+ const paint = {
2649
+ dead: (s) => useColor ? pc.red(s) : s,
2650
+ ok: (s) => useColor ? pc.green(s) : s,
2651
+ dim: (s) => useColor ? pc.dim(s) : s,
2652
+ bold: (s) => useColor ? pc.bold(s) : s
2653
+ };
2654
+ if (!opts.dryRun && !opts.yes && process.stdin.isTTY !== true) {
2655
+ process.stderr.write("error: interactive clean needs a TTY \u2014 use --yes or --dry-run\n");
2656
+ process.exitCode = 2;
2657
+ return;
2658
+ }
2659
+ if (opts.commit && !opts.dryRun) {
2660
+ if (!isGitRepo(root)) {
2661
+ process.stderr.write(
2662
+ "error: not a git repository \u2014 deadwood clean commits each removal so it is revertable.\n Use --no-commit to delete without commits (you own the undo story then).\n"
2663
+ );
2664
+ process.exitCode = 2;
2665
+ return;
2666
+ }
2667
+ if (!workingTreeClean(root)) {
2668
+ process.stderr.write(
2669
+ "error: working tree is not clean \u2014 commit or stash first, so each removal is its own revertable commit.\n"
2670
+ );
2671
+ process.exitCode = 2;
2672
+ return;
2673
+ }
2674
+ }
2675
+ process.stderr.write("scanning\u2026\n");
2676
+ const gitFacts = opts.git ? collectGitFacts(root) : void 0;
2677
+ const result = await scan({ path: root, gitFacts });
2678
+ const min = LEVEL_RANK2[opts.minConfidence];
2679
+ const eligible = result.findings.filter((f) => LEVEL_RANK2[f.confidence.level] >= min);
2680
+ const removals = [];
2681
+ let unsupported = 0;
2682
+ for (const f of eligible) {
2683
+ const removal = planRemoval(root, f, result);
2684
+ if (removal) removals.push(removal);
2685
+ else if (f.category === "dead-route" || f.category === "unused-export") unsupported++;
2686
+ }
2687
+ if (removals.length === 0) {
2688
+ process.stdout.write(
2689
+ `Nothing removable at ${opts.minConfidence}+ confidence.` + (unsupported > 0 ? ` (${unsupported} route/export finding${unsupported === 1 ? "" : "s"} need manual review \u2014 automated line surgery isn't provable yet.)` : "") + "\n"
2690
+ );
2691
+ return;
2692
+ }
2693
+ process.stdout.write(
2694
+ `${paint.bold(String(removals.length))} removable finding${removals.length === 1 ? "" : "s"} at ${opts.minConfidence}+ confidence` + (unsupported > 0 ? paint.dim(` (+${unsupported} route/export need manual review)`) : "") + "\n\n"
2695
+ );
2696
+ const rl = !opts.dryRun && !opts.yes ? readline.createInterface({ input: process.stdin, output: process.stderr }) : void 0;
2697
+ let acceptAll = Boolean(opts.yes);
2698
+ let removed = 0;
2699
+ let skipped = 0;
2700
+ let failed = 0;
2701
+ try {
2702
+ for (const removal of removals) {
2703
+ const f = removal.finding;
2704
+ process.stdout.write(
2705
+ `${paint.dead(describeTarget(f.target))} ${paint.dim(`${f.location.file}:${f.location.line}`)} ${f.confidence.score}% ${f.confidence.level}
2706
+ `
2707
+ );
2708
+ for (const e of f.evidence.slice(0, 3)) {
2709
+ process.stdout.write(paint.dim(` ${e.direction === "spare" ? "~" : "-"} ${e.detail}
2710
+ `));
2711
+ }
2712
+ process.stdout.write(` \u2192 ${removal.describe}
2713
+ `);
2714
+ if (opts.dryRun) {
2715
+ process.stdout.write(paint.dim(" dry-run: not applied\n\n"));
2716
+ continue;
2717
+ }
2718
+ let accepted = acceptAll;
2719
+ if (!accepted && rl) {
2720
+ const answer = (await rl.question(" [d]elete / [s]kip / [a]ll remaining / [q]uit > ")).trim().toLowerCase();
2721
+ if (answer === "q") break;
2722
+ if (answer === "a") {
2723
+ acceptAll = true;
2724
+ accepted = true;
2725
+ } else {
2726
+ accepted = answer === "d" || answer === "y";
2727
+ }
2728
+ }
2729
+ if (!accepted) {
2730
+ skipped++;
2731
+ process.stdout.write(paint.dim(" skipped\n\n"));
2732
+ continue;
2733
+ }
2734
+ try {
2735
+ const staged = removal.apply();
2736
+ if (opts.commit) {
2737
+ commitRemoval(root, staged, `chore(deadwood): remove ${describeTarget(f.target)}`);
2738
+ }
2739
+ removed++;
2740
+ process.stdout.write(paint.ok(` removed${opts.commit ? " + committed" : ""}
2741
+
2742
+ `));
2743
+ } catch (err) {
2744
+ failed++;
2745
+ process.stderr.write(
2746
+ ` error: ${err instanceof Error ? err.message : String(err)} \u2014 leaving it in place
2747
+
2748
+ `
2749
+ );
2750
+ }
2751
+ }
2752
+ } finally {
2753
+ rl?.close();
2754
+ }
2755
+ if (opts.dryRun) {
2756
+ process.stdout.write(
2757
+ `dry run: ${removals.length} removal${removals.length === 1 ? "" : "s"} planned, nothing touched.
2758
+ `
2759
+ );
2760
+ return;
2761
+ }
2762
+ process.stdout.write(
2763
+ `${paint.ok(String(removed))} removed, ${skipped} skipped${failed > 0 ? `, ${paint.dead(String(failed))} failed` : ""}.` + (removed > 0 && opts.commit ? " Each removal is its own commit \u2014 `git revert <sha>` undoes any of them." : "") + "\n"
2764
+ );
2765
+ if (failed > 0) process.exitCode = 2;
2766
+ }
2767
+ function planRemoval(root, f, result) {
2768
+ if (f.category === "unreachable-file" && f.target.kind === "file") {
2769
+ const abs = containedPath(root, f.target.file);
2770
+ if (!abs || !fs10.existsSync(abs)) return null;
2771
+ const file = f.target.file;
2772
+ return {
2773
+ finding: f,
2774
+ describe: `delete file ${file}`,
2775
+ apply: () => {
2776
+ fs10.rmSync(abs);
2777
+ return [file];
2778
+ }
2779
+ };
2780
+ }
2781
+ if (f.category === "unused-dependency" && f.target.kind === "dependency") {
2782
+ const pkgRel = f.location.file;
2783
+ const abs = containedPath(root, pkgRel);
2784
+ if (!abs || !fs10.existsSync(abs) || !pkgRel.endsWith("package.json")) return null;
2785
+ const { name, depType } = f.target;
2786
+ return {
2787
+ finding: f,
2788
+ describe: `remove ${name} from ${depType} in ${pkgRel}`,
2789
+ apply: () => {
2790
+ removeDependency(abs, name, depType);
2791
+ return [pkgRel];
2792
+ }
2793
+ };
2794
+ }
2795
+ void result;
2796
+ return null;
2797
+ }
2798
+ function containedPath(root, rel) {
2799
+ const abs = path12.resolve(root, rel);
2800
+ const normRoot = path12.resolve(root) + path12.sep;
2801
+ return abs.startsWith(normRoot) ? abs : void 0;
2802
+ }
2803
+ function removeDependency(pkgPath, name, depType) {
2804
+ const raw = fs10.readFileSync(pkgPath, "utf8");
2805
+ const pkg2 = JSON.parse(raw);
2806
+ const deps = pkg2[depType];
2807
+ if (typeof deps !== "object" || deps === null || !(name in deps)) {
2808
+ throw new Error(`${name} not found in ${depType}`);
2809
+ }
2810
+ delete deps[name];
2811
+ const indent = /^([ \t]+)"/m.exec(raw)?.[1] ?? " ";
2812
+ const trailingNewline = raw.endsWith("\n") ? "\n" : "";
2813
+ fs10.writeFileSync(pkgPath, JSON.stringify(pkg2, null, indent) + trailingNewline);
2814
+ }
2815
+ function isGitRepo(dir) {
2816
+ return runGit2(dir, ["rev-parse", "--is-inside-work-tree"])?.trim() === "true";
2817
+ }
2818
+ function workingTreeClean(dir) {
2819
+ const out = runGit2(dir, ["status", "--porcelain"]);
2820
+ return out !== void 0 && out.trim().length === 0;
2821
+ }
2822
+ function commitRemoval(root, relPaths, message) {
2823
+ const add = spawnSync2("git", ["-C", root, "add", "--", ...relPaths], {
2824
+ windowsHide: true,
2825
+ encoding: "utf8"
2826
+ });
2827
+ if (add.status !== 0) throw new Error(`git add failed: ${firstLine(add.stderr)}`);
2828
+ const commit = spawnSync2("git", ["-C", root, "commit", "-q", "-m", message, "--", ...relPaths], {
2829
+ windowsHide: true,
2830
+ encoding: "utf8"
2831
+ });
2832
+ if (commit.status !== 0) throw new Error(`git commit failed: ${firstLine(commit.stderr)}`);
2833
+ }
2834
+ function firstLine(s) {
2835
+ const line = (s ?? "").split("\n").find((l) => l.trim().length > 0);
2836
+ return line?.trim() ?? "unknown git error";
2837
+ }
2838
+ function runGit2(dir, args) {
2839
+ try {
2840
+ const res = spawnSync2("git", ["-C", dir, ...args], { encoding: "utf8", windowsHide: true });
2841
+ if (res.error || res.status !== 0) return void 0;
2842
+ return res.stdout;
2843
+ } catch {
2844
+ return void 0;
2845
+ }
2846
+ }
2847
+
2320
2848
  // src/reporters/json.ts
2321
2849
  function renderJson(result) {
2322
2850
  return JSON.stringify(result, null, 2) + "\n";
@@ -2379,7 +2907,7 @@ function escapeMd(s) {
2379
2907
  }
2380
2908
 
2381
2909
  // src/reporters/pretty.ts
2382
- import pc from "picocolors";
2910
+ import pc2 from "picocolors";
2383
2911
  var CATEGORY_TITLES2 = {
2384
2912
  "dead-route": "ROUTES THAT APPEAR DEAD",
2385
2913
  "unused-export": "EXPORTS THAT APPEAR UNUSED",
@@ -2388,7 +2916,7 @@ var CATEGORY_TITLES2 = {
2388
2916
  };
2389
2917
  var RULE_WIDTH = 72;
2390
2918
  function renderPretty(result, useColor) {
2391
- const c = pc.createColors(useColor);
2919
+ const c = pc2.createColors(useColor);
2392
2920
  const lines = [];
2393
2921
  const { stats } = result;
2394
2922
  lines.push("");
@@ -2507,33 +3035,52 @@ var CATEGORIES = [
2507
3035
  "unused-dependency",
2508
3036
  "unreachable-file"
2509
3037
  ];
2510
- var LEVEL_RANK2 = { low: 0, medium: 1, high: 2 };
3038
+ var LEVEL_RANK3 = { low: 0, medium: 1, high: 2 };
2511
3039
  var program = new Command();
2512
- program.name("deadwood").description("Find dead code and prove it \u2014 scans a JS/TS repo and prints a shock report").version(pkg.version).argument("[path]", "directory to scan", ".").option("--json", "machine-readable ScanResult on stdout").option("--md", "markdown report (PR comments / Slack)").option("--fail-on <spec>", "CI gate: 'high', 'medium', or 'high:5' (exit 1 when tripped)").option("--include <glob...>", "extra include globs").option("--exclude <glob...>", "extra exclude globs").addOption(new Option("--category <category...>", "limit findings").choices(CATEGORIES)).option("--workspace <name...>", "limit monorepo scan to these packages").addOption(
3040
+ program.name("deadwood").description("Find dead code and prove it \u2014 scans a JS/TS repo and prints a shock report").version(pkg.version).argument("[path]", "directory to scan", ".").option("--json", "machine-readable ScanResult on stdout").option("--md", "markdown report (PR comments / Slack)").option("--fail-on <spec>", "CI gate: 'high', 'medium', or 'high:5' (exit 1 when tripped)").option("--include <glob...>", "extra include globs").option("--exclude <glob...>", "extra exclude globs").option(
3041
+ "--no-default-excludes",
3042
+ "also scan generated/, examples/, fixtures/, __mocks__/ (excluded by default as noise)"
3043
+ ).addOption(new Option("--category <category...>", "limit findings").choices(CATEGORIES)).option("--workspace <name...>", "limit monorepo scan to these packages").addOption(
2513
3044
  new Option("--framework <framework...>", "force route framework detection").choices([
2514
3045
  "express",
2515
3046
  "fastify",
2516
3047
  "nest",
2517
3048
  "next"
2518
3049
  ])
2519
- ).option("--entry <file...>", "extra entry points (false-positive escape hatch)").addOption(
3050
+ ).option("--entry <file...>", "extra entry points (false-positive escape hatch)").option("--no-git", "skip git-history evidence (untouched-for-a-year / recently-modified nudges)").addOption(
2520
3051
  new Option("--min-confidence <level>", "hide findings below this level").choices(["low", "medium", "high"]).default("low")
2521
- ).option("--no-color", "disable colors").action(async (path12, opts) => {
2522
- await run(path12, opts);
3052
+ ).option("--no-color", "disable colors").action(async (path13, opts) => {
3053
+ await run(path13, opts);
3054
+ });
3055
+ program.command("clean").description("guided deletion: review findings and remove them, one revertable commit each").argument("[path]", "directory to clean", ".").option("--dry-run", "print the removal plan without touching anything").option("--yes", "accept every removal at/above the confidence floor (non-interactive)").addOption(
3056
+ new Option("--min-confidence <level>", "only offer removals at/above this level").choices(["medium", "high"]).default("medium")
3057
+ ).option("--no-commit", "delete without committing (also lifts the clean-tree requirement)").option("--no-git", "skip git-history evidence in the underlying scan").option("--no-color", "disable colors").action(async (path13, opts) => {
3058
+ try {
3059
+ await runClean(path13, opts);
3060
+ } catch (err) {
3061
+ if (err instanceof ScanError) {
3062
+ process2.stderr.write(`error (${err.code}): ${err.message}
3063
+ `);
3064
+ } else {
3065
+ process2.stderr.write(`error: ${err instanceof Error ? err.message : String(err)}
3066
+ `);
3067
+ }
3068
+ process2.exitCode = 2;
3069
+ }
2523
3070
  });
2524
- async function run(path12, opts) {
3071
+ async function run(path13, opts) {
2525
3072
  if (opts.json && opts.md) {
2526
- process.stderr.write("error: --json and --md are mutually exclusive\n");
2527
- process.exitCode = 2;
3073
+ process2.stderr.write("error: --json and --md are mutually exclusive\n");
3074
+ process2.exitCode = 2;
2528
3075
  return;
2529
3076
  }
2530
3077
  const failOnSpec = (() => {
2531
3078
  try {
2532
3079
  return opts.failOn ? parseFailOn(opts.failOn) : void 0;
2533
3080
  } catch (err) {
2534
- process.stderr.write(`error: ${err.message}
3081
+ process2.stderr.write(`error: ${err.message}
2535
3082
  `);
2536
- process.exitCode = 2;
3083
+ process2.exitCode = 2;
2537
3084
  return null;
2538
3085
  }
2539
3086
  })();
@@ -2541,57 +3088,60 @@ async function run(path12, opts) {
2541
3088
  const machineOutput = Boolean(opts.json || opts.md);
2542
3089
  const spinner = ora({
2543
3090
  text: "scanning\u2026",
2544
- stream: process.stderr,
2545
- isEnabled: !machineOutput && process.stderr.isTTY === true
3091
+ stream: process2.stderr,
3092
+ isEnabled: !machineOutput && process2.stderr.isTTY === true
2546
3093
  });
2547
3094
  try {
2548
3095
  spinner.start();
3096
+ const gitFacts = opts.git ? collectGitFacts(path13) : void 0;
2549
3097
  const result = await scan({
2550
- path: path12,
3098
+ path: path13,
2551
3099
  include: opts.include,
2552
3100
  exclude: opts.exclude,
3101
+ defaultExcludes: opts.defaultExcludes,
2553
3102
  categories: opts.category,
2554
3103
  frameworks: opts.framework,
2555
3104
  entries: opts.entry,
2556
3105
  workspaces: opts.workspace,
2557
- toolVersion: pkg.version
3106
+ toolVersion: pkg.version,
3107
+ gitFacts
2558
3108
  });
2559
3109
  spinner.stop();
2560
3110
  const gate = failOnSpec ? evaluateFailOn(result.findings, failOnSpec) : void 0;
2561
3111
  const displayed = filterForDisplay(result, opts.minConfidence);
2562
- const useColor = opts.color && pc2.isColorSupported && process.stdout.isTTY === true;
3112
+ const useColor = opts.color && pc3.isColorSupported && process2.stdout.isTTY === true;
2563
3113
  const output = opts.json ? renderJson(displayed) : opts.md ? renderMd(displayed) : renderPretty(displayed, useColor);
2564
- process.stdout.write(output);
3114
+ process2.stdout.write(output);
2565
3115
  if (opts.json) {
2566
- for (const warning of result.warnings) process.stderr.write(`warning: ${warning}
3116
+ for (const warning of result.warnings) process2.stderr.write(`warning: ${warning}
2567
3117
  `);
2568
3118
  }
2569
3119
  if (gate?.tripped) {
2570
- process.stderr.write(
3120
+ process2.stderr.write(
2571
3121
  `deadwood: --fail-on ${opts.failOn} tripped (${gate.count} finding${gate.count === 1 ? "" : "s"} at/above '${failOnSpec.level}')
2572
3122
  `
2573
3123
  );
2574
- process.exitCode = 1;
3124
+ process2.exitCode = 1;
2575
3125
  }
2576
3126
  } catch (err) {
2577
3127
  spinner.stop();
2578
3128
  if (err instanceof ScanError) {
2579
- process.stderr.write(`error (${err.code}): ${err.message}
3129
+ process2.stderr.write(`error (${err.code}): ${err.message}
2580
3130
  `);
2581
3131
  } else {
2582
- process.stderr.write(
3132
+ process2.stderr.write(
2583
3133
  `error: ${err instanceof Error ? err.stack ?? err.message : String(err)}
2584
3134
  `
2585
3135
  );
2586
3136
  }
2587
- process.exitCode = 2;
3137
+ process2.exitCode = 2;
2588
3138
  }
2589
3139
  }
2590
3140
  function filterForDisplay(result, minConfidence) {
2591
3141
  if (minConfidence === "low") return result;
2592
- const min = LEVEL_RANK2[minConfidence];
2593
- const findings = result.findings.filter((f) => LEVEL_RANK2[f.confidence.level] >= min);
3142
+ const min = LEVEL_RANK3[minConfidence];
3143
+ const findings = result.findings.filter((f) => LEVEL_RANK3[f.confidence.level] >= min);
2594
3144
  return { ...result, findings };
2595
3145
  }
2596
- await program.parseAsync(process.argv);
3146
+ await program.parseAsync(process2.argv);
2597
3147
  //# sourceMappingURL=index.js.map