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 +206 -24
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
|
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 (!
|
|
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
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
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,
|
|
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
|
|
2124
|
-
const
|
|
2125
|
-
|
|
2126
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
|
2288
|
-
|
|
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:
|
|
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:
|
|
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").
|
|
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,
|