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 +610 -60
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
4
|
import { createRequire } from "module";
|
|
5
|
-
import
|
|
5
|
+
import process2 from "process";
|
|
6
6
|
import { Command, Option } from "commander";
|
|
7
7
|
import ora from "ora";
|
|
8
|
-
import
|
|
8
|
+
import pc3 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(
|
|
@@ -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
|
|
2045
|
-
const
|
|
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 (
|
|
2049
|
-
result.capped = { at: 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 (!
|
|
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
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
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,
|
|
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
|
|
2124
|
-
const
|
|
2125
|
-
|
|
2126
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
|
2288
|
-
|
|
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:
|
|
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:
|
|
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
|
|
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 =
|
|
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
|
|
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").
|
|
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 (
|
|
2522
|
-
await run(
|
|
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(
|
|
3071
|
+
async function run(path13, opts) {
|
|
2525
3072
|
if (opts.json && opts.md) {
|
|
2526
|
-
|
|
2527
|
-
|
|
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
|
-
|
|
3081
|
+
process2.stderr.write(`error: ${err.message}
|
|
2535
3082
|
`);
|
|
2536
|
-
|
|
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:
|
|
2545
|
-
isEnabled: !machineOutput &&
|
|
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:
|
|
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 &&
|
|
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
|
-
|
|
3114
|
+
process2.stdout.write(output);
|
|
2565
3115
|
if (opts.json) {
|
|
2566
|
-
for (const warning of result.warnings)
|
|
3116
|
+
for (const warning of result.warnings) process2.stderr.write(`warning: ${warning}
|
|
2567
3117
|
`);
|
|
2568
3118
|
}
|
|
2569
3119
|
if (gate?.tripped) {
|
|
2570
|
-
|
|
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
|
-
|
|
3124
|
+
process2.exitCode = 1;
|
|
2575
3125
|
}
|
|
2576
3126
|
} catch (err) {
|
|
2577
3127
|
spinner.stop();
|
|
2578
3128
|
if (err instanceof ScanError) {
|
|
2579
|
-
|
|
3129
|
+
process2.stderr.write(`error (${err.code}): ${err.message}
|
|
2580
3130
|
`);
|
|
2581
3131
|
} else {
|
|
2582
|
-
|
|
3132
|
+
process2.stderr.write(
|
|
2583
3133
|
`error: ${err instanceof Error ? err.stack ?? err.message : String(err)}
|
|
2584
3134
|
`
|
|
2585
3135
|
);
|
|
2586
3136
|
}
|
|
2587
|
-
|
|
3137
|
+
process2.exitCode = 2;
|
|
2588
3138
|
}
|
|
2589
3139
|
}
|
|
2590
3140
|
function filterForDisplay(result, minConfidence) {
|
|
2591
3141
|
if (minConfidence === "low") return result;
|
|
2592
|
-
const min =
|
|
2593
|
-
const findings = result.findings.filter((f) =>
|
|
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(
|
|
3146
|
+
await program.parseAsync(process2.argv);
|
|
2597
3147
|
//# sourceMappingURL=index.js.map
|