deadwood-scan 0.2.0 → 0.3.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
@@ -8,7 +8,7 @@ import ora from "ora";
8
8
  import pc2 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(
@@ -1926,6 +1931,126 @@ function truncate(s) {
1926
1931
  return s.length > 60 ? s.slice(0, 57) + "\u2026" : s;
1927
1932
  }
1928
1933
 
1934
+ // ../core/src/evidence/depConfigRefs.ts
1935
+ import fs8 from "fs";
1936
+ import { globSync } from "tinyglobby";
1937
+ var CONFIG_GLOBS = [
1938
+ "**/.eslintrc",
1939
+ "**/.eslintrc.{json,yml,yaml,js,cjs}",
1940
+ "**/eslint.config.{js,cjs,mjs,ts}",
1941
+ "**/.babelrc",
1942
+ "**/.babelrc.{json,js,cjs}",
1943
+ "**/babel.config.{json,js,cjs,mjs}",
1944
+ "**/.prettierrc",
1945
+ "**/.prettierrc.{json,yml,yaml,js,cjs}",
1946
+ "**/prettier.config.{js,cjs,mjs}",
1947
+ "**/.mocharc.{json,yml,yaml,js,cjs}",
1948
+ "**/jest.config.{js,cjs,mjs,ts,json}",
1949
+ "**/jest.setup.{js,cjs,mjs,ts}",
1950
+ "**/vitest.config.{js,cjs,mjs,ts}",
1951
+ "**/webpack.config.{js,cjs,mjs,ts}",
1952
+ "**/rollup.config.{js,cjs,mjs,ts}",
1953
+ "**/tsup.config.{js,cjs,mjs,ts}",
1954
+ "**/playwright.config.{js,cjs,mjs,ts}",
1955
+ "**/cypress.config.{js,cjs,mjs,ts}",
1956
+ "**/karma.conf.{js,cjs}",
1957
+ "**/tsconfig*.json",
1958
+ "**/.lintstagedrc",
1959
+ "**/.lintstagedrc.{json,yml,yaml}",
1960
+ "**/nyc.config.js",
1961
+ "**/.nycrc",
1962
+ "**/.nycrc.{json,yml,yaml}",
1963
+ "**/commitlint.config.{js,cjs,mjs}",
1964
+ "**/.github/workflows/*.{yml,yaml}",
1965
+ "**/.travis.yml",
1966
+ "**/appveyor.yml",
1967
+ "**/azure-pipelines.yml",
1968
+ "**/.gitlab-ci.yml"
1969
+ ];
1970
+ var CONFIG_PRESENCE = [
1971
+ { dep: "typescript", file: /^tsconfig[^/]*\.json$/ },
1972
+ { dep: "prettier", file: /^\.prettierrc|^prettier\.config\./ },
1973
+ { dep: "eslint", file: /^\.eslintrc|^eslint\.config\./ },
1974
+ { dep: "jest", file: /^jest\.(config|setup)\./ },
1975
+ { dep: "vitest", file: /^vitest\.config\./ },
1976
+ { dep: "mocha", file: /^\.mocharc/ },
1977
+ { dep: "nyc", file: /^\.nycrc|^nyc\.config\./ },
1978
+ { dep: "karma", file: /^karma\.conf\./ }
1979
+ ];
1980
+ var IGNORE = ["**/node_modules/**", "**/dist/**", "**/.git/**"];
1981
+ var MAX_FILES = 100;
1982
+ var MAX_FILE_BYTES = 256 * 1024;
1983
+ var depConfigRefs = {
1984
+ name: "dep-config-references",
1985
+ provide(ctx, findings) {
1986
+ const deps = findings.filter(
1987
+ (f) => f.category === "unused-dependency" && f.target.kind === "dependency"
1988
+ );
1989
+ if (deps.length === 0) return;
1990
+ const configs = readConfigFiles(ctx);
1991
+ if (configs.length === 0) return;
1992
+ for (const finding of deps) {
1993
+ if (finding.target.kind !== "dependency") continue;
1994
+ const name = finding.target.name;
1995
+ const hit = configs.find((c) => referencesDep(c, name));
1996
+ if (hit) {
1997
+ addOnce(finding, evidenceFor("static:dev-config-reference", `Referenced in ${hit.rel}`));
1998
+ continue;
1999
+ }
2000
+ const presence = CONFIG_PRESENCE.find((p) => p.dep === name);
2001
+ const owner = presence && configs.find((c) => presence.file.test(c.base));
2002
+ if (owner) {
2003
+ addOnce(
2004
+ finding,
2005
+ evidenceFor("static:dev-config-reference", `Its config file exists: ${owner.rel}`)
2006
+ );
2007
+ }
2008
+ }
2009
+ }
2010
+ };
2011
+ function readConfigFiles(ctx) {
2012
+ let paths;
2013
+ try {
2014
+ paths = globSync(CONFIG_GLOBS, { cwd: ctx.root, ignore: IGNORE, absolute: true, dot: true });
2015
+ } catch {
2016
+ return [];
2017
+ }
2018
+ const out = [];
2019
+ for (const p of paths.map(toPosix).sort().slice(0, MAX_FILES)) {
2020
+ try {
2021
+ if (fs8.statSync(p).size > MAX_FILE_BYTES) continue;
2022
+ out.push({
2023
+ rel: ctx.rel(p),
2024
+ base: p.slice(p.lastIndexOf("/") + 1).toLowerCase(),
2025
+ text: fs8.readFileSync(p, "utf8")
2026
+ });
2027
+ } catch {
2028
+ }
2029
+ }
2030
+ return out;
2031
+ }
2032
+ function referencesDep(config, depName) {
2033
+ if (wordMatch(config.text, depName)) return true;
2034
+ if (config.base.includes("eslint")) {
2035
+ const m = /^eslint-(?:plugin|config)-(.+)$/.exec(depName);
2036
+ if (m && wordMatch(config.text, m[1])) return true;
2037
+ const scoped = /^(@[^/]+)\/eslint-config$/.exec(depName);
2038
+ if (scoped && wordMatch(config.text, scoped[1])) return true;
2039
+ }
2040
+ if (config.base.includes("babel")) {
2041
+ const m = /^babel-(?:plugin|preset)-(.+)$/.exec(depName);
2042
+ if (m && wordMatch(config.text, m[1])) return true;
2043
+ }
2044
+ return false;
2045
+ }
2046
+ function wordMatch(text, name) {
2047
+ const re = new RegExp(`(^|[^\\w@/-])${escapeRe2(name)}($|[^\\w-])`, "m");
2048
+ return re.test(text);
2049
+ }
2050
+ function escapeRe2(s) {
2051
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2052
+ }
2053
+
1929
2054
  // ../core/src/evidence/jobRegistration.ts
1930
2055
  import ts6 from "typescript";
1931
2056
  var JOB_PACKAGES = /* @__PURE__ */ new Set([
@@ -2065,6 +2190,26 @@ var DEFAULT_EXCLUDE = [
2065
2190
  "**/*.d.ts",
2066
2191
  "**/*.min.js"
2067
2192
  ];
2193
+ var NOISE_EXCLUDE = [
2194
+ // Generated code — owned by a generator, not deletable by hand.
2195
+ "**/generated/**",
2196
+ "**/__generated__/**",
2197
+ "**/*.generated.*",
2198
+ "**/.prisma/**",
2199
+ // Example/demo code — each file is its own intentional entry point.
2200
+ "**/examples/**",
2201
+ "**/example/**",
2202
+ "**/samples/**",
2203
+ "**/sample/**",
2204
+ // Test scaffolding — referenced by test runners in ways imports don't show
2205
+ // (jest loads __mocks__/ and setup files by convention/config string).
2206
+ "**/fixtures/**",
2207
+ "**/__fixtures__/**",
2208
+ "**/__mocks__/**",
2209
+ "**/testdata/**",
2210
+ "**/jest.setup.*",
2211
+ "**/vitest.setup.*"
2212
+ ];
2068
2213
  var ALL_CATEGORIES = [
2069
2214
  "dead-route",
2070
2215
  "unused-export",
@@ -2089,6 +2234,7 @@ var PROVIDERS = [
2089
2234
  dynamicImports,
2090
2235
  stringDispatch,
2091
2236
  packageScripts,
2237
+ depConfigRefs,
2092
2238
  jobRegistration,
2093
2239
  manifestReference,
2094
2240
  comments
@@ -2096,37 +2242,43 @@ var PROVIDERS = [
2096
2242
  async function scan(options) {
2097
2243
  const startedAt = Date.now();
2098
2244
  const root = absPosix(options.path);
2099
- if (!fs8.existsSync(root) || !fs8.statSync(root).isDirectory()) {
2245
+ if (!fs9.existsSync(root) || !fs9.statSync(root).isDirectory()) {
2100
2246
  throw new ScanError("INVALID_PATH", `Not a directory: ${options.path}`);
2101
2247
  }
2102
2248
  const warnings = [];
2103
2249
  const { units: allUnits, warnings: wsWarnings } = await discoverWorkspaces(root);
2104
2250
  warnings.push(...wsWarnings);
2105
2251
  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
- });
2252
+ const include = [...DEFAULT_INCLUDE, ...options.include ?? []];
2253
+ const baseIgnore = [...DEFAULT_EXCLUDE, ...options.exclude ?? []];
2254
+ const globOpts = { cwd: root, absolute: true, dot: true, followSymbolicLinks: false };
2255
+ const files = await glob2(include, { ...globOpts, ignore: baseIgnore });
2112
2256
  if (files.length === 0) {
2113
2257
  throw new ScanError("NO_SOURCE_FILES", `No JS/TS source files found under ${options.path}`);
2114
2258
  }
2259
+ const reportableFiles = options.defaultExcludes === false ? void 0 : new Set(
2260
+ (await glob2(include, { ...globOpts, ignore: [...baseIgnore, ...NOISE_EXCLUDE] })).map(
2261
+ toPosix
2262
+ )
2263
+ );
2115
2264
  const graph = new ModuleGraph();
2116
2265
  for (const file of files.map(toPosix).sort()) {
2266
+ const source = readSourceSafe(file, warnings, (f) => relPosix(root, f));
2267
+ if (source === void 0) continue;
2117
2268
  try {
2118
- graph.addFile(parseFile(file, fs8.readFileSync(file, "utf8")));
2269
+ graph.addFile(parseFile(file, source));
2119
2270
  } catch {
2120
2271
  warnings.push(`Could not parse ${relPosix(root, file)}; it is excluded from analysis.`);
2121
2272
  }
2122
2273
  }
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);
2274
+ const parsedFiles = new Set(graph.files.keys());
2275
+ const scannedFiles = reportableFiles ? new Set([...parsedFiles].filter((f) => reportableFiles.has(f))) : parsedFiles;
2276
+ const wsEntries = units.filter((u) => u.name !== ".").map((u) => ({ name: u.name, dir: u.dir, entryFile: guessUnitEntry(u, parsedFiles) }));
2277
+ graph.link(new Resolver(root, wsEntries, parsedFiles, warnings));
2278
+ const manifestCallers = await collectManifestCallers(root, parsedFiles, warnings);
2127
2279
  const entriesByUnit = /* @__PURE__ */ new Map();
2128
2280
  for (const unit of units) {
2129
- entriesByUnit.set(unit, discoverEntryPoints(unit, scannedFiles, warnings));
2281
+ entriesByUnit.set(unit, discoverEntryPoints(unit, parsedFiles, warnings));
2130
2282
  }
2131
2283
  const rootUnit = units[0];
2132
2284
  for (const caller of manifestCallers) {
@@ -2137,7 +2289,7 @@ async function scan(options) {
2137
2289
  });
2138
2290
  }
2139
2291
  for (const ref of options.entries ?? []) {
2140
- const resolved = resolveEntryRef(root, ref, scannedFiles) ?? tryFile(path11.resolve(root, ref));
2292
+ const resolved = resolveEntryRef(root, ref, parsedFiles) ?? tryFile(path11.resolve(root, ref));
2141
2293
  if (resolved) {
2142
2294
  pushEntry(entriesByUnit, unitForFile(units, toPosix(resolved)), {
2143
2295
  file: toPosix(resolved),
@@ -2151,7 +2303,7 @@ async function scan(options) {
2151
2303
  for (const unit of units) {
2152
2304
  const eps = entriesByUnit.get(unit);
2153
2305
  if (!eps.some((e) => e.tag !== "test" && e.reason !== "config")) {
2154
- const guess = guessUnitEntry(unit, scannedFiles);
2306
+ const guess = guessUnitEntry(unit, parsedFiles);
2155
2307
  if (guess)
2156
2308
  pushEntry(entriesByUnit, unit, { file: guess, reason: "main", detail: "convention" });
2157
2309
  }
@@ -2223,10 +2375,28 @@ async function scan(options) {
2223
2375
  root,
2224
2376
  workspaces: units.map((u) => u.name),
2225
2377
  findings,
2226
- stats: computeStats(graph, units, findings, routesTotal, Date.now() - startedAt),
2378
+ stats: computeStats(graph, scannedFiles, units, findings, routesTotal, Date.now() - startedAt),
2227
2379
  warnings: [...new Set(warnings)]
2228
2380
  };
2229
2381
  }
2382
+ var MAX_SOURCE_BYTES = 8 * 1024 * 1024;
2383
+ function readSourceSafe(file, warnings, rel) {
2384
+ try {
2385
+ const st = fs9.lstatSync(file);
2386
+ if (!st.isFile()) {
2387
+ warnings.push(`Skipped ${rel(file)}: not a regular file (symlink or special file).`);
2388
+ return void 0;
2389
+ }
2390
+ if (st.size > MAX_SOURCE_BYTES) {
2391
+ warnings.push(`Skipped ${rel(file)}: exceeds ${MAX_SOURCE_BYTES} bytes.`);
2392
+ return void 0;
2393
+ }
2394
+ return fs9.readFileSync(file, "utf8");
2395
+ } catch {
2396
+ warnings.push(`Could not read ${rel(file)}; it is excluded from analysis.`);
2397
+ return void 0;
2398
+ }
2399
+ }
2230
2400
  function pushEntry(map, unit, entry) {
2231
2401
  const list = map.get(unit);
2232
2402
  if (!list) {
@@ -2258,7 +2428,14 @@ async function collectManifestCallers(root, scannedFiles, warnings) {
2258
2428
  "**/cron.d/**",
2259
2429
  "webpack.config.{js,cjs,mjs,ts}"
2260
2430
  ],
2261
- { cwd: root, ignore: ["**/node_modules/**", "**/dist/**", "**/.git/**"], dot: true }
2431
+ // followSymbolicLinks:false same untrusted-content rule as the source
2432
+ // globs (a symlinked serverless.yml → host file must not be read).
2433
+ {
2434
+ cwd: root,
2435
+ ignore: ["**/node_modules/**", "**/dist/**", "**/.git/**"],
2436
+ dot: true,
2437
+ followSymbolicLinks: false
2438
+ }
2262
2439
  );
2263
2440
  for (const rel of manifestFiles.map(toPosix).sort()) {
2264
2441
  try {
@@ -2281,23 +2458,24 @@ async function collectManifestCallers(root, scannedFiles, warnings) {
2281
2458
  }
2282
2459
  return callers.filter((c) => scannedFiles.has(c.file));
2283
2460
  }
2284
- function computeStats(graph, units, findings, routesTotal, durationMs) {
2461
+ function computeStats(graph, scannedFiles, units, findings, routesTotal, durationMs) {
2285
2462
  const flagged = (category) => findings.filter((f) => f.category === category && f.confidence.score >= 50).length;
2286
2463
  let exportsTotal = 0;
2287
- for (const pf of graph.files.values()) {
2288
- exportsTotal += new Set(pf.exports.map((e) => e.name)).size;
2464
+ for (const file of scannedFiles) {
2465
+ const pf = graph.files.get(file);
2466
+ if (pf) exportsTotal += new Set(pf.exports.map((e) => e.name)).size;
2289
2467
  }
2290
2468
  let depsTotal = 0;
2291
2469
  for (const u of units) {
2292
2470
  depsTotal += Object.keys(u.packageJson.dependencies ?? {}).length + Object.keys(u.packageJson.devDependencies ?? {}).length;
2293
2471
  }
2294
2472
  return {
2295
- filesScanned: graph.files.size,
2473
+ filesScanned: scannedFiles.size,
2296
2474
  durationMs,
2297
2475
  routes: { total: routesTotal, flagged: flagged("dead-route") },
2298
2476
  exports: { total: exportsTotal, flagged: flagged("unused-export") },
2299
2477
  dependencies: { total: depsTotal, flagged: flagged("unused-dependency") },
2300
- files: { total: graph.files.size, flagged: flagged("unreachable-file") }
2478
+ files: { total: scannedFiles.size, flagged: flagged("unreachable-file") }
2301
2479
  };
2302
2480
  }
2303
2481
 
@@ -2509,7 +2687,10 @@ var CATEGORIES = [
2509
2687
  ];
2510
2688
  var LEVEL_RANK2 = { low: 0, medium: 1, high: 2 };
2511
2689
  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(
2690
+ 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(
2691
+ "--no-default-excludes",
2692
+ "also scan generated/, examples/, fixtures/, __mocks__/ (excluded by default as noise)"
2693
+ ).addOption(new Option("--category <category...>", "limit findings").choices(CATEGORIES)).option("--workspace <name...>", "limit monorepo scan to these packages").addOption(
2513
2694
  new Option("--framework <framework...>", "force route framework detection").choices([
2514
2695
  "express",
2515
2696
  "fastify",
@@ -2550,6 +2731,7 @@ async function run(path12, opts) {
2550
2731
  path: path12,
2551
2732
  include: opts.include,
2552
2733
  exclude: opts.exclude,
2734
+ defaultExcludes: opts.defaultExcludes,
2553
2735
  categories: opts.category,
2554
2736
  frameworks: opts.framework,
2555
2737
  entries: opts.entry,