deadwood-scan 0.3.0 → 0.5.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/README.md +158 -0
- package/dist/index.js +1110 -51
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
4
|
import { createRequire } from "module";
|
|
5
|
-
import
|
|
5
|
+
import nodePath from "path";
|
|
6
|
+
import fs13 from "fs";
|
|
7
|
+
import process4 from "process";
|
|
6
8
|
import { Command, Option } from "commander";
|
|
7
9
|
import ora from "ora";
|
|
8
|
-
import
|
|
10
|
+
import pc6 from "picocolors";
|
|
9
11
|
|
|
10
12
|
// ../core/src/index.ts
|
|
11
13
|
import fs9 from "fs";
|
|
@@ -719,8 +721,8 @@ function discoverEntryPoints(unit, scannedFiles, warnings) {
|
|
|
719
721
|
function resolveEntryRef(unitDir, ref, scannedFiles) {
|
|
720
722
|
const accept = (p) => {
|
|
721
723
|
if (!p) return void 0;
|
|
722
|
-
const
|
|
723
|
-
return !scannedFiles || scannedFiles.has(
|
|
724
|
+
const posix2 = toPosix(p);
|
|
725
|
+
return !scannedFiles || scannedFiles.has(posix2) ? posix2 : void 0;
|
|
724
726
|
};
|
|
725
727
|
const clean = ref.replace(/^\.\//, "");
|
|
726
728
|
const direct = accept(tryFile(path4.join(unitDir, clean)));
|
|
@@ -1010,6 +1012,49 @@ var SIGNAL_LIST = [
|
|
|
1010
1012
|
30,
|
|
1011
1013
|
"Re-exported by a package entry point; external consumers are invisible to static analysis"
|
|
1012
1014
|
),
|
|
1015
|
+
// Git-history signals (git:*) — computed from the repo's own history, which
|
|
1016
|
+
// the CLI collects and injects via scan options (core never spawns git; see
|
|
1017
|
+
// evidence/gitHistory.ts). Deliberately `source: 'static'` so the static cap
|
|
1018
|
+
// still applies: history sharpens confidence, only PRODUCTION lifts the cap.
|
|
1019
|
+
kill("git:untouched-1y", 10, "No commit has touched this file in over a year"),
|
|
1020
|
+
kill(
|
|
1021
|
+
"git:author-departed",
|
|
1022
|
+
10,
|
|
1023
|
+
"The last author of this file has had no repo activity for 6+ months"
|
|
1024
|
+
),
|
|
1025
|
+
kill(
|
|
1026
|
+
"git:never-modified-since-introduction",
|
|
1027
|
+
5,
|
|
1028
|
+
"Introduced in a single commit 6+ months ago and never touched again"
|
|
1029
|
+
),
|
|
1030
|
+
spare(
|
|
1031
|
+
"git:recently-modified",
|
|
1032
|
+
15,
|
|
1033
|
+
"This file was modified in the last 30 days \u2014 likely active work"
|
|
1034
|
+
),
|
|
1035
|
+
// Dev/staging/test-traffic evidence (dev:*) — runtime evidence from
|
|
1036
|
+
// NON-production collectors (a probe pointed at a dev server or running
|
|
1037
|
+
// during an e2e suite). Only the spare direction ships for now: traffic
|
|
1038
|
+
// seen anywhere proves the route is wired (revival errs safe); a dev
|
|
1039
|
+
// zero-requests kill would need its own coverage-guarantee story.
|
|
1040
|
+
{
|
|
1041
|
+
id: "dev:request-seen",
|
|
1042
|
+
source: "development",
|
|
1043
|
+
direction: "spare",
|
|
1044
|
+
weight: -60,
|
|
1045
|
+
description: "Traffic observed in a development/staging/test environment"
|
|
1046
|
+
},
|
|
1047
|
+
{
|
|
1048
|
+
// A small nudge with its own coverage guarantee: emitted only when an
|
|
1049
|
+
// ACTIVE non-production collector (fresh AND it reported real traffic in
|
|
1050
|
+
// the window — the suite provably ran) watched the route and never hit
|
|
1051
|
+
// it. Silence from an idle collector proves nothing and earns nothing.
|
|
1052
|
+
id: "dev:zero-requests-in-tests",
|
|
1053
|
+
source: "development",
|
|
1054
|
+
direction: "kill",
|
|
1055
|
+
weight: 10,
|
|
1056
|
+
description: "Watched by an active non-production collector that reported traffic, yet this route was never hit"
|
|
1057
|
+
},
|
|
1013
1058
|
// Reserved for the paid evidence layer — registered now so the scorer and
|
|
1014
1059
|
// JSON contract already understand them.
|
|
1015
1060
|
{
|
|
@@ -1041,6 +1086,8 @@ var STATIC_CAPS = {
|
|
|
1041
1086
|
"unreachable-file": 85
|
|
1042
1087
|
};
|
|
1043
1088
|
var STATIC_CAP_REASON = "no runtime evidence \u2014 connect telemetry to confirm";
|
|
1089
|
+
var DEV_CAP = 90;
|
|
1090
|
+
var DEV_CAP_REASON = "dev traffic cannot confirm \u2014 production evidence completes it";
|
|
1044
1091
|
function evidenceFor(signalId, detail, location) {
|
|
1045
1092
|
const spec = SIGNALS.get(signalId);
|
|
1046
1093
|
if (!spec) {
|
|
@@ -1789,9 +1836,6 @@ function findPathMatch2(routePath, literals) {
|
|
|
1789
1836
|
);
|
|
1790
1837
|
}
|
|
1791
1838
|
|
|
1792
|
-
// ../core/src/evidence/dynamicImports.ts
|
|
1793
|
-
import path10 from "path";
|
|
1794
|
-
|
|
1795
1839
|
// ../core/src/evidence/types.ts
|
|
1796
1840
|
function addOnce(finding, evidence) {
|
|
1797
1841
|
if (!finding.evidence.some((e) => e.signal === evidence.signal)) {
|
|
@@ -1799,7 +1843,104 @@ function addOnce(finding, evidence) {
|
|
|
1799
1843
|
}
|
|
1800
1844
|
}
|
|
1801
1845
|
|
|
1846
|
+
// ../core/src/evidence/gitHistory.ts
|
|
1847
|
+
var DAY = 24 * 60 * 60;
|
|
1848
|
+
var STALE_SECONDS = 365 * DAY;
|
|
1849
|
+
var FRESH_SECONDS = 30 * DAY;
|
|
1850
|
+
var DEPARTED_SECONDS = 180 * DAY;
|
|
1851
|
+
function ageInWords(seconds) {
|
|
1852
|
+
const days = Math.floor(seconds / DAY);
|
|
1853
|
+
if (days < 1) return "today";
|
|
1854
|
+
if (days === 1) return "yesterday";
|
|
1855
|
+
if (days < 60) return `${days} days ago`;
|
|
1856
|
+
return `${Math.floor(days / 30)} months ago`;
|
|
1857
|
+
}
|
|
1858
|
+
function gitHistory(facts) {
|
|
1859
|
+
return {
|
|
1860
|
+
name: "git-history",
|
|
1861
|
+
provide(_ctx, findings) {
|
|
1862
|
+
if (!facts) return;
|
|
1863
|
+
for (const finding of findings) {
|
|
1864
|
+
if (finding.category === "unused-dependency") continue;
|
|
1865
|
+
const last = facts.lastModified[finding.location.file];
|
|
1866
|
+
if (last === void 0) {
|
|
1867
|
+
if (!facts.truncated) {
|
|
1868
|
+
addOnce(
|
|
1869
|
+
finding,
|
|
1870
|
+
evidenceFor("git:recently-modified", "not committed yet \u2014 likely work in progress")
|
|
1871
|
+
);
|
|
1872
|
+
}
|
|
1873
|
+
continue;
|
|
1874
|
+
}
|
|
1875
|
+
const age = facts.now - last;
|
|
1876
|
+
if (age <= FRESH_SECONDS) {
|
|
1877
|
+
addOnce(
|
|
1878
|
+
finding,
|
|
1879
|
+
evidenceFor(
|
|
1880
|
+
"git:recently-modified",
|
|
1881
|
+
`last commit touching this file was ${ageInWords(age)}`
|
|
1882
|
+
)
|
|
1883
|
+
);
|
|
1884
|
+
continue;
|
|
1885
|
+
}
|
|
1886
|
+
if (age >= STALE_SECONDS) {
|
|
1887
|
+
addOnce(
|
|
1888
|
+
finding,
|
|
1889
|
+
evidenceFor(
|
|
1890
|
+
"git:untouched-1y",
|
|
1891
|
+
`last commit touching this file was ${ageInWords(age)}`
|
|
1892
|
+
)
|
|
1893
|
+
);
|
|
1894
|
+
}
|
|
1895
|
+
const author = facts.lastAuthor?.[finding.location.file];
|
|
1896
|
+
const active = author !== void 0 ? facts.authorLastActive?.[author] : void 0;
|
|
1897
|
+
if (active !== void 0 && facts.now - active >= DEPARTED_SECONDS) {
|
|
1898
|
+
addOnce(
|
|
1899
|
+
finding,
|
|
1900
|
+
evidenceFor(
|
|
1901
|
+
"git:author-departed",
|
|
1902
|
+
`last edited by an author with no repo activity for ${ageInWords(facts.now - active)}`
|
|
1903
|
+
)
|
|
1904
|
+
);
|
|
1905
|
+
}
|
|
1906
|
+
if (!facts.truncated && facts.singleCommit?.[finding.location.file] && age >= DEPARTED_SECONDS) {
|
|
1907
|
+
addOnce(
|
|
1908
|
+
finding,
|
|
1909
|
+
evidenceFor(
|
|
1910
|
+
"git:never-modified-since-introduction",
|
|
1911
|
+
`introduced ${ageInWords(age)} and never touched again`
|
|
1912
|
+
)
|
|
1913
|
+
);
|
|
1914
|
+
}
|
|
1915
|
+
}
|
|
1916
|
+
}
|
|
1917
|
+
};
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
// ../core/src/evidence/publicApi.ts
|
|
1921
|
+
function publicApi(files) {
|
|
1922
|
+
const declared = new Set(files ?? []);
|
|
1923
|
+
return {
|
|
1924
|
+
name: "public-api",
|
|
1925
|
+
provide(_ctx, findings) {
|
|
1926
|
+
if (declared.size === 0) return;
|
|
1927
|
+
for (const finding of findings) {
|
|
1928
|
+
if (finding.category !== "unused-export") continue;
|
|
1929
|
+
if (!declared.has(finding.location.file)) continue;
|
|
1930
|
+
addOnce(
|
|
1931
|
+
finding,
|
|
1932
|
+
evidenceFor(
|
|
1933
|
+
"static:public-api-reexport",
|
|
1934
|
+
"declared public API in deadwood.config.json \u2014 external consumers are invisible to static analysis"
|
|
1935
|
+
)
|
|
1936
|
+
);
|
|
1937
|
+
}
|
|
1938
|
+
}
|
|
1939
|
+
};
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1802
1942
|
// ../core/src/evidence/dynamicImports.ts
|
|
1943
|
+
import path10 from "path";
|
|
1803
1944
|
var dynamicImports = {
|
|
1804
1945
|
name: "dynamic-imports",
|
|
1805
1946
|
provide(ctx, findings) {
|
|
@@ -2166,12 +2307,13 @@ function levelFor(score) {
|
|
|
2166
2307
|
}
|
|
2167
2308
|
function scoreFinding(category, evidence) {
|
|
2168
2309
|
const raw = evidence.reduce((sum, e) => sum + e.weight, 0);
|
|
2169
|
-
const
|
|
2170
|
-
const
|
|
2310
|
+
const hasProduction = evidence.some((e) => e.source === "production" || e.source === "tombstone");
|
|
2311
|
+
const hasDev = evidence.some((e) => e.source === "development");
|
|
2312
|
+
const [cap, reason] = hasProduction ? [100, ""] : hasDev ? [DEV_CAP, DEV_CAP_REASON] : [STATIC_CAPS[category], STATIC_CAP_REASON];
|
|
2171
2313
|
const score = Math.max(0, Math.min(cap, Math.round(raw)));
|
|
2172
2314
|
const result = { score, level: levelFor(score) };
|
|
2173
|
-
if (
|
|
2174
|
-
result.capped = { at: cap, reason
|
|
2315
|
+
if (!hasProduction && raw > cap) {
|
|
2316
|
+
result.capped = { at: cap, reason };
|
|
2175
2317
|
}
|
|
2176
2318
|
return result;
|
|
2177
2319
|
}
|
|
@@ -2356,7 +2498,11 @@ async function scan(options) {
|
|
|
2356
2498
|
rel: (file) => relPosix(root, file),
|
|
2357
2499
|
abs: (relPath) => `${root}/${relPath}`
|
|
2358
2500
|
};
|
|
2359
|
-
for (const provider of
|
|
2501
|
+
for (const provider of [
|
|
2502
|
+
...PROVIDERS,
|
|
2503
|
+
gitHistory(options.gitFacts),
|
|
2504
|
+
publicApi(options.publicApi)
|
|
2505
|
+
]) {
|
|
2360
2506
|
provider.provide(providerCtx, findings);
|
|
2361
2507
|
}
|
|
2362
2508
|
for (const finding of findings) {
|
|
@@ -2495,6 +2641,763 @@ function evaluateFailOn(findings, spec) {
|
|
|
2495
2641
|
return { count, tripped: count >= spec.min };
|
|
2496
2642
|
}
|
|
2497
2643
|
|
|
2644
|
+
// src/git.ts
|
|
2645
|
+
import { spawnSync } from "child_process";
|
|
2646
|
+
var MAX_COMMITS = 5e4;
|
|
2647
|
+
var MAX_BUFFER = 128 * 1024 * 1024;
|
|
2648
|
+
var MARK = "";
|
|
2649
|
+
var SEP = "";
|
|
2650
|
+
function collectGitFacts(dir, now = /* @__PURE__ */ new Date()) {
|
|
2651
|
+
const out = runGit(dir, [
|
|
2652
|
+
"log",
|
|
2653
|
+
`--max-count=${MAX_COMMITS}`,
|
|
2654
|
+
"--format=%x01%ct%x02%ae",
|
|
2655
|
+
"--name-only",
|
|
2656
|
+
"--no-renames",
|
|
2657
|
+
"--relative",
|
|
2658
|
+
"--",
|
|
2659
|
+
"."
|
|
2660
|
+
]);
|
|
2661
|
+
if (out === void 0) return void 0;
|
|
2662
|
+
const lastModified = {};
|
|
2663
|
+
const lastAuthor = {};
|
|
2664
|
+
const authorLastActive = {};
|
|
2665
|
+
const commitCount = {};
|
|
2666
|
+
let commits = 0;
|
|
2667
|
+
let current = 0;
|
|
2668
|
+
let currentAuthor = "";
|
|
2669
|
+
for (const rawLine of out.split("\n")) {
|
|
2670
|
+
const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
|
|
2671
|
+
if (line.length === 0) continue;
|
|
2672
|
+
if (line.startsWith(MARK)) {
|
|
2673
|
+
const [tsRaw, author = ""] = line.slice(1).split(SEP);
|
|
2674
|
+
const ts7 = Number(tsRaw);
|
|
2675
|
+
if (!Number.isFinite(ts7)) continue;
|
|
2676
|
+
current = ts7;
|
|
2677
|
+
currentAuthor = author;
|
|
2678
|
+
commits++;
|
|
2679
|
+
if (author && authorLastActive[author] === void 0) authorLastActive[author] = ts7;
|
|
2680
|
+
continue;
|
|
2681
|
+
}
|
|
2682
|
+
if (current === 0) continue;
|
|
2683
|
+
const path15 = normalizeGitPath(line);
|
|
2684
|
+
if (lastModified[path15] === void 0) {
|
|
2685
|
+
lastModified[path15] = current;
|
|
2686
|
+
if (currentAuthor) lastAuthor[path15] = currentAuthor;
|
|
2687
|
+
}
|
|
2688
|
+
commitCount[path15] = (commitCount[path15] ?? 0) + 1;
|
|
2689
|
+
}
|
|
2690
|
+
if (commits === 0) return void 0;
|
|
2691
|
+
const singleCommit = {};
|
|
2692
|
+
for (const [path15, count] of Object.entries(commitCount)) {
|
|
2693
|
+
if (count === 1) singleCommit[path15] = true;
|
|
2694
|
+
}
|
|
2695
|
+
return {
|
|
2696
|
+
lastModified,
|
|
2697
|
+
truncated: commits >= MAX_COMMITS,
|
|
2698
|
+
now: Math.floor(now.getTime() / 1e3),
|
|
2699
|
+
lastAuthor,
|
|
2700
|
+
authorLastActive,
|
|
2701
|
+
singleCommit
|
|
2702
|
+
};
|
|
2703
|
+
}
|
|
2704
|
+
function normalizeGitPath(line) {
|
|
2705
|
+
if (line.startsWith('"') && line.endsWith('"') && line.length >= 2) {
|
|
2706
|
+
try {
|
|
2707
|
+
return JSON.parse(line);
|
|
2708
|
+
} catch {
|
|
2709
|
+
return line;
|
|
2710
|
+
}
|
|
2711
|
+
}
|
|
2712
|
+
return line;
|
|
2713
|
+
}
|
|
2714
|
+
function runGit(dir, args) {
|
|
2715
|
+
try {
|
|
2716
|
+
const res = spawnSync("git", ["-C", dir, ...args], {
|
|
2717
|
+
encoding: "utf8",
|
|
2718
|
+
maxBuffer: MAX_BUFFER,
|
|
2719
|
+
windowsHide: true
|
|
2720
|
+
});
|
|
2721
|
+
if (res.error || res.status !== 0 || typeof res.stdout !== "string") return void 0;
|
|
2722
|
+
return res.stdout;
|
|
2723
|
+
} catch {
|
|
2724
|
+
return void 0;
|
|
2725
|
+
}
|
|
2726
|
+
}
|
|
2727
|
+
|
|
2728
|
+
// src/clean.ts
|
|
2729
|
+
import fs10 from "fs";
|
|
2730
|
+
import path12 from "path";
|
|
2731
|
+
import readline from "readline/promises";
|
|
2732
|
+
import { spawnSync as spawnSync2 } from "child_process";
|
|
2733
|
+
import process from "process";
|
|
2734
|
+
import pc from "picocolors";
|
|
2735
|
+
var LEVEL_RANK2 = { low: 0, medium: 1, high: 2 };
|
|
2736
|
+
async function runClean(dir, opts) {
|
|
2737
|
+
const root = path12.resolve(dir);
|
|
2738
|
+
const useColor = opts.color && process.stdout.isTTY === true;
|
|
2739
|
+
const paint = {
|
|
2740
|
+
dead: (s) => useColor ? pc.red(s) : s,
|
|
2741
|
+
ok: (s) => useColor ? pc.green(s) : s,
|
|
2742
|
+
dim: (s) => useColor ? pc.dim(s) : s,
|
|
2743
|
+
bold: (s) => useColor ? pc.bold(s) : s
|
|
2744
|
+
};
|
|
2745
|
+
if (!opts.dryRun && !opts.yes && process.stdin.isTTY !== true) {
|
|
2746
|
+
process.stderr.write("error: interactive clean needs a TTY \u2014 use --yes or --dry-run\n");
|
|
2747
|
+
process.exitCode = 2;
|
|
2748
|
+
return;
|
|
2749
|
+
}
|
|
2750
|
+
if (opts.commit && !opts.dryRun) {
|
|
2751
|
+
if (!isGitRepo(root)) {
|
|
2752
|
+
process.stderr.write(
|
|
2753
|
+
"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"
|
|
2754
|
+
);
|
|
2755
|
+
process.exitCode = 2;
|
|
2756
|
+
return;
|
|
2757
|
+
}
|
|
2758
|
+
if (!workingTreeClean(root)) {
|
|
2759
|
+
process.stderr.write(
|
|
2760
|
+
"error: working tree is not clean \u2014 commit or stash first, so each removal is its own revertable commit.\n"
|
|
2761
|
+
);
|
|
2762
|
+
process.exitCode = 2;
|
|
2763
|
+
return;
|
|
2764
|
+
}
|
|
2765
|
+
}
|
|
2766
|
+
process.stderr.write("scanning\u2026\n");
|
|
2767
|
+
const gitFacts = opts.git ? collectGitFacts(root) : void 0;
|
|
2768
|
+
const result = await scan({ path: root, gitFacts });
|
|
2769
|
+
const min = LEVEL_RANK2[opts.minConfidence];
|
|
2770
|
+
const eligible = result.findings.filter((f) => LEVEL_RANK2[f.confidence.level] >= min);
|
|
2771
|
+
const removals = [];
|
|
2772
|
+
let unsupported = 0;
|
|
2773
|
+
for (const f of eligible) {
|
|
2774
|
+
const removal = planRemoval(root, f, result);
|
|
2775
|
+
if (removal) removals.push(removal);
|
|
2776
|
+
else if (f.category === "dead-route" || f.category === "unused-export") unsupported++;
|
|
2777
|
+
}
|
|
2778
|
+
if (removals.length === 0) {
|
|
2779
|
+
process.stdout.write(
|
|
2780
|
+
`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"
|
|
2781
|
+
);
|
|
2782
|
+
return;
|
|
2783
|
+
}
|
|
2784
|
+
process.stdout.write(
|
|
2785
|
+
`${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"
|
|
2786
|
+
);
|
|
2787
|
+
const rl = !opts.dryRun && !opts.yes ? readline.createInterface({ input: process.stdin, output: process.stderr }) : void 0;
|
|
2788
|
+
let acceptAll = Boolean(opts.yes);
|
|
2789
|
+
let removed = 0;
|
|
2790
|
+
let skipped = 0;
|
|
2791
|
+
let failed = 0;
|
|
2792
|
+
try {
|
|
2793
|
+
for (const removal of removals) {
|
|
2794
|
+
const f = removal.finding;
|
|
2795
|
+
process.stdout.write(
|
|
2796
|
+
`${paint.dead(describeTarget(f.target))} ${paint.dim(`${f.location.file}:${f.location.line}`)} ${f.confidence.score}% ${f.confidence.level}
|
|
2797
|
+
`
|
|
2798
|
+
);
|
|
2799
|
+
for (const e of f.evidence.slice(0, 3)) {
|
|
2800
|
+
process.stdout.write(paint.dim(` ${e.direction === "spare" ? "~" : "-"} ${e.detail}
|
|
2801
|
+
`));
|
|
2802
|
+
}
|
|
2803
|
+
process.stdout.write(` \u2192 ${removal.describe}
|
|
2804
|
+
`);
|
|
2805
|
+
if (opts.dryRun) {
|
|
2806
|
+
process.stdout.write(paint.dim(" dry-run: not applied\n\n"));
|
|
2807
|
+
continue;
|
|
2808
|
+
}
|
|
2809
|
+
let accepted = acceptAll;
|
|
2810
|
+
if (!accepted && rl) {
|
|
2811
|
+
const answer = (await rl.question(" [d]elete / [s]kip / [a]ll remaining / [q]uit > ")).trim().toLowerCase();
|
|
2812
|
+
if (answer === "q") break;
|
|
2813
|
+
if (answer === "a") {
|
|
2814
|
+
acceptAll = true;
|
|
2815
|
+
accepted = true;
|
|
2816
|
+
} else {
|
|
2817
|
+
accepted = answer === "d" || answer === "y";
|
|
2818
|
+
}
|
|
2819
|
+
}
|
|
2820
|
+
if (!accepted) {
|
|
2821
|
+
skipped++;
|
|
2822
|
+
process.stdout.write(paint.dim(" skipped\n\n"));
|
|
2823
|
+
continue;
|
|
2824
|
+
}
|
|
2825
|
+
try {
|
|
2826
|
+
const staged = removal.apply();
|
|
2827
|
+
if (opts.commit) {
|
|
2828
|
+
commitRemoval(root, staged, `chore(deadwood): remove ${describeTarget(f.target)}`);
|
|
2829
|
+
}
|
|
2830
|
+
removed++;
|
|
2831
|
+
process.stdout.write(paint.ok(` removed${opts.commit ? " + committed" : ""}
|
|
2832
|
+
|
|
2833
|
+
`));
|
|
2834
|
+
} catch (err) {
|
|
2835
|
+
failed++;
|
|
2836
|
+
process.stderr.write(
|
|
2837
|
+
` error: ${err instanceof Error ? err.message : String(err)} \u2014 leaving it in place
|
|
2838
|
+
|
|
2839
|
+
`
|
|
2840
|
+
);
|
|
2841
|
+
}
|
|
2842
|
+
}
|
|
2843
|
+
} finally {
|
|
2844
|
+
rl?.close();
|
|
2845
|
+
}
|
|
2846
|
+
if (opts.dryRun) {
|
|
2847
|
+
process.stdout.write(
|
|
2848
|
+
`dry run: ${removals.length} removal${removals.length === 1 ? "" : "s"} planned, nothing touched.
|
|
2849
|
+
`
|
|
2850
|
+
);
|
|
2851
|
+
return;
|
|
2852
|
+
}
|
|
2853
|
+
process.stdout.write(
|
|
2854
|
+
`${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"
|
|
2855
|
+
);
|
|
2856
|
+
if (failed > 0) process.exitCode = 2;
|
|
2857
|
+
}
|
|
2858
|
+
function planRemoval(root, f, result) {
|
|
2859
|
+
if (f.category === "unreachable-file" && f.target.kind === "file") {
|
|
2860
|
+
const abs = containedPath(root, f.target.file);
|
|
2861
|
+
if (!abs || !fs10.existsSync(abs)) return null;
|
|
2862
|
+
const file = f.target.file;
|
|
2863
|
+
return {
|
|
2864
|
+
finding: f,
|
|
2865
|
+
describe: `delete file ${file}`,
|
|
2866
|
+
apply: () => {
|
|
2867
|
+
fs10.rmSync(abs);
|
|
2868
|
+
return [file];
|
|
2869
|
+
}
|
|
2870
|
+
};
|
|
2871
|
+
}
|
|
2872
|
+
if (f.category === "unused-dependency" && f.target.kind === "dependency") {
|
|
2873
|
+
const pkgRel = f.location.file;
|
|
2874
|
+
const abs = containedPath(root, pkgRel);
|
|
2875
|
+
if (!abs || !fs10.existsSync(abs) || !pkgRel.endsWith("package.json")) return null;
|
|
2876
|
+
const { name, depType } = f.target;
|
|
2877
|
+
return {
|
|
2878
|
+
finding: f,
|
|
2879
|
+
describe: `remove ${name} from ${depType} in ${pkgRel}`,
|
|
2880
|
+
apply: () => {
|
|
2881
|
+
removeDependency(abs, name, depType);
|
|
2882
|
+
return [pkgRel];
|
|
2883
|
+
}
|
|
2884
|
+
};
|
|
2885
|
+
}
|
|
2886
|
+
void result;
|
|
2887
|
+
return null;
|
|
2888
|
+
}
|
|
2889
|
+
function containedPath(root, rel) {
|
|
2890
|
+
const abs = path12.resolve(root, rel);
|
|
2891
|
+
const normRoot = path12.resolve(root) + path12.sep;
|
|
2892
|
+
return abs.startsWith(normRoot) ? abs : void 0;
|
|
2893
|
+
}
|
|
2894
|
+
function removeDependency(pkgPath, name, depType) {
|
|
2895
|
+
const raw = fs10.readFileSync(pkgPath, "utf8");
|
|
2896
|
+
const pkg2 = JSON.parse(raw);
|
|
2897
|
+
const deps = pkg2[depType];
|
|
2898
|
+
if (typeof deps !== "object" || deps === null || !(name in deps)) {
|
|
2899
|
+
throw new Error(`${name} not found in ${depType}`);
|
|
2900
|
+
}
|
|
2901
|
+
delete deps[name];
|
|
2902
|
+
const indent = /^([ \t]+)"/m.exec(raw)?.[1] ?? " ";
|
|
2903
|
+
const trailingNewline = raw.endsWith("\n") ? "\n" : "";
|
|
2904
|
+
fs10.writeFileSync(pkgPath, JSON.stringify(pkg2, null, indent) + trailingNewline);
|
|
2905
|
+
}
|
|
2906
|
+
function isGitRepo(dir) {
|
|
2907
|
+
return runGit2(dir, ["rev-parse", "--is-inside-work-tree"])?.trim() === "true";
|
|
2908
|
+
}
|
|
2909
|
+
function workingTreeClean(dir) {
|
|
2910
|
+
const out = runGit2(dir, ["status", "--porcelain"]);
|
|
2911
|
+
return out !== void 0 && out.trim().length === 0;
|
|
2912
|
+
}
|
|
2913
|
+
function commitRemoval(root, relPaths, message) {
|
|
2914
|
+
const add = spawnSync2("git", ["-C", root, "add", "--", ...relPaths], {
|
|
2915
|
+
windowsHide: true,
|
|
2916
|
+
encoding: "utf8"
|
|
2917
|
+
});
|
|
2918
|
+
if (add.status !== 0) throw new Error(`git add failed: ${firstLine(add.stderr)}`);
|
|
2919
|
+
const commit = spawnSync2("git", ["-C", root, "commit", "-q", "-m", message, "--", ...relPaths], {
|
|
2920
|
+
windowsHide: true,
|
|
2921
|
+
encoding: "utf8"
|
|
2922
|
+
});
|
|
2923
|
+
if (commit.status !== 0) throw new Error(`git commit failed: ${firstLine(commit.stderr)}`);
|
|
2924
|
+
}
|
|
2925
|
+
function firstLine(s) {
|
|
2926
|
+
const line = (s ?? "").split("\n").find((l) => l.trim().length > 0);
|
|
2927
|
+
return line?.trim() ?? "unknown git error";
|
|
2928
|
+
}
|
|
2929
|
+
function runGit2(dir, args) {
|
|
2930
|
+
try {
|
|
2931
|
+
const res = spawnSync2("git", ["-C", dir, ...args], { encoding: "utf8", windowsHide: true });
|
|
2932
|
+
if (res.error || res.status !== 0) return void 0;
|
|
2933
|
+
return res.stdout;
|
|
2934
|
+
} catch {
|
|
2935
|
+
return void 0;
|
|
2936
|
+
}
|
|
2937
|
+
}
|
|
2938
|
+
|
|
2939
|
+
// src/repoConfig.ts
|
|
2940
|
+
import fs11 from "fs";
|
|
2941
|
+
import path13 from "path";
|
|
2942
|
+
var EMPTY = { entries: [], exclude: [], publicApi: [], warnings: [] };
|
|
2943
|
+
function loadRepoConfig(root) {
|
|
2944
|
+
const out = { ...EMPTY, entries: [], exclude: [], publicApi: [], warnings: [] };
|
|
2945
|
+
const cfgPath = path13.join(root, "deadwood.config.json");
|
|
2946
|
+
if (fs11.existsSync(cfgPath)) {
|
|
2947
|
+
try {
|
|
2948
|
+
const raw = JSON.parse(fs11.readFileSync(cfgPath, "utf8"));
|
|
2949
|
+
out.entries = stringArray(raw.entries, "entries", out.warnings);
|
|
2950
|
+
out.exclude = stringArray(raw.exclude, "exclude", out.warnings);
|
|
2951
|
+
out.publicApi = stringArray(raw.publicApi, "publicApi", out.warnings).map(posix);
|
|
2952
|
+
} catch (err) {
|
|
2953
|
+
out.warnings.push(
|
|
2954
|
+
`deadwood.config.json could not be parsed (${err instanceof Error ? err.message : "bad JSON"}); ignoring it.`
|
|
2955
|
+
);
|
|
2956
|
+
}
|
|
2957
|
+
}
|
|
2958
|
+
const ignorePath = path13.join(root, ".deadwoodignore");
|
|
2959
|
+
if (fs11.existsSync(ignorePath)) {
|
|
2960
|
+
try {
|
|
2961
|
+
for (const line of fs11.readFileSync(ignorePath, "utf8").split("\n")) {
|
|
2962
|
+
const glob3 = line.trim();
|
|
2963
|
+
if (glob3.length === 0 || glob3.startsWith("#")) continue;
|
|
2964
|
+
if (glob3.startsWith("!")) {
|
|
2965
|
+
out.warnings.push(
|
|
2966
|
+
`.deadwoodignore: negations ('!') are not supported \u2014 '${glob3}' skipped.`
|
|
2967
|
+
);
|
|
2968
|
+
continue;
|
|
2969
|
+
}
|
|
2970
|
+
out.exclude.push(glob3);
|
|
2971
|
+
}
|
|
2972
|
+
} catch {
|
|
2973
|
+
out.warnings.push(".deadwoodignore could not be read; ignoring it.");
|
|
2974
|
+
}
|
|
2975
|
+
}
|
|
2976
|
+
return out;
|
|
2977
|
+
}
|
|
2978
|
+
function stringArray(value, field, warnings) {
|
|
2979
|
+
if (value === void 0) return [];
|
|
2980
|
+
if (Array.isArray(value) && value.every((v) => typeof v === "string")) return value;
|
|
2981
|
+
warnings.push(`deadwood.config.json: '${field}' must be an array of strings; ignoring it.`);
|
|
2982
|
+
return [];
|
|
2983
|
+
}
|
|
2984
|
+
function posix(p) {
|
|
2985
|
+
return p.replace(/\\/g, "/");
|
|
2986
|
+
}
|
|
2987
|
+
|
|
2988
|
+
// src/delta.ts
|
|
2989
|
+
import { mkdtempSync, rmSync } from "fs";
|
|
2990
|
+
import os from "os";
|
|
2991
|
+
import path14 from "path";
|
|
2992
|
+
import { spawnSync as spawnSync3 } from "child_process";
|
|
2993
|
+
import pc2 from "picocolors";
|
|
2994
|
+
function diffFindings(base, head, baseRef) {
|
|
2995
|
+
const baseIds = new Set(base.findings.map((f) => f.id));
|
|
2996
|
+
const headIds = new Set(head.findings.map((f) => f.id));
|
|
2997
|
+
return {
|
|
2998
|
+
baseRef,
|
|
2999
|
+
added: head.findings.filter((f) => !baseIds.has(f.id)),
|
|
3000
|
+
resolved: base.findings.filter((f) => !headIds.has(f.id))
|
|
3001
|
+
};
|
|
3002
|
+
}
|
|
3003
|
+
async function scanBaseRef(root, ref, options) {
|
|
3004
|
+
const verify = spawnSync3(
|
|
3005
|
+
"git",
|
|
3006
|
+
["-C", root, "rev-parse", "--verify", "--quiet", `${ref}^{commit}`],
|
|
3007
|
+
{
|
|
3008
|
+
encoding: "utf8",
|
|
3009
|
+
windowsHide: true
|
|
3010
|
+
}
|
|
3011
|
+
);
|
|
3012
|
+
if (verify.error) throw new Error("git is not available \u2014 --diff-base needs git");
|
|
3013
|
+
if (verify.status !== 0) {
|
|
3014
|
+
throw new Error(`--diff-base '${ref}' is not a commit in this repository`);
|
|
3015
|
+
}
|
|
3016
|
+
const tmp = mkdtempSync(path14.join(os.tmpdir(), "deadwood-base-"));
|
|
3017
|
+
const add = spawnSync3("git", ["-C", root, "worktree", "add", "--detach", "--force", tmp, ref], {
|
|
3018
|
+
encoding: "utf8",
|
|
3019
|
+
windowsHide: true
|
|
3020
|
+
});
|
|
3021
|
+
if (add.status !== 0) {
|
|
3022
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
3023
|
+
throw new Error(`could not materialize '${ref}': ${(add.stderr ?? "").trim().split("\n")[0]}`);
|
|
3024
|
+
}
|
|
3025
|
+
try {
|
|
3026
|
+
return await scan({ ...options, path: tmp });
|
|
3027
|
+
} finally {
|
|
3028
|
+
spawnSync3("git", ["-C", root, "worktree", "remove", "--force", tmp], { windowsHide: true });
|
|
3029
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
3030
|
+
}
|
|
3031
|
+
}
|
|
3032
|
+
function renderDeltaPretty(delta, useColor) {
|
|
3033
|
+
const c = pc2.createColors(useColor);
|
|
3034
|
+
const lines = [""];
|
|
3035
|
+
lines.push(` ${c.bold("\u2620 deadwood delta")} ${c.dim(`vs ${delta.baseRef}`)}`);
|
|
3036
|
+
lines.push(` ${c.dim("\u2500".repeat(72))}`);
|
|
3037
|
+
const added = delta.added.length;
|
|
3038
|
+
const resolved = delta.resolved.length;
|
|
3039
|
+
lines.push(
|
|
3040
|
+
` ${added > 0 ? c.red(`+${added} introduced`) : c.green("+0 introduced")} \xB7 ${resolved > 0 ? c.green(`\u2212${resolved} resolved`) : c.dim("\u22120 resolved")}`
|
|
3041
|
+
);
|
|
3042
|
+
lines.push("");
|
|
3043
|
+
for (const f of delta.added) {
|
|
3044
|
+
lines.push(
|
|
3045
|
+
` ${c.red("+")} ${c.bold(describeTarget(f.target))} ${c.dim(`${f.location.file}:${f.location.line}`)} ${f.confidence.score}% ${f.confidence.level}`
|
|
3046
|
+
);
|
|
3047
|
+
const top = [...f.evidence].sort((a, b) => Math.abs(b.weight) - Math.abs(a.weight)).slice(0, 2);
|
|
3048
|
+
for (const e of top) lines.push(` ${c.dim(e.detail)}`);
|
|
3049
|
+
}
|
|
3050
|
+
if (added > 0) lines.push("");
|
|
3051
|
+
for (const f of delta.resolved) {
|
|
3052
|
+
lines.push(` ${c.green("\u2713")} ${c.dim(`resolved: ${describeTarget(f.target)}`)}`);
|
|
3053
|
+
}
|
|
3054
|
+
if (added === 0 && resolved === 0) {
|
|
3055
|
+
lines.push(` ${c.green("\u2713")} This change introduces no new dead code.`);
|
|
3056
|
+
}
|
|
3057
|
+
lines.push("");
|
|
3058
|
+
return lines.join("\n");
|
|
3059
|
+
}
|
|
3060
|
+
|
|
3061
|
+
// src/watch.ts
|
|
3062
|
+
import fs12 from "fs";
|
|
3063
|
+
import process2 from "process";
|
|
3064
|
+
import pc3 from "picocolors";
|
|
3065
|
+
var IGNORE2 = /(^|[\\/])(node_modules|\.git|dist|build|out|coverage|\.next)([\\/]|$)/;
|
|
3066
|
+
var DEBOUNCE_MS = 400;
|
|
3067
|
+
async function runWatch(dir, buildOptions, useColor) {
|
|
3068
|
+
const c = pc3.createColors(useColor);
|
|
3069
|
+
const stamp = () => (/* @__PURE__ */ new Date()).toTimeString().slice(0, 8);
|
|
3070
|
+
let prev = await scan(buildOptions());
|
|
3071
|
+
process2.stdout.write(
|
|
3072
|
+
`${c.dim(`[${stamp()}]`)} watching ${dir} \u2014 ${headline(prev)} ${c.dim("(ctrl-c to stop)")}
|
|
3073
|
+
`
|
|
3074
|
+
);
|
|
3075
|
+
let timer;
|
|
3076
|
+
let scanning = false;
|
|
3077
|
+
let dirty = false;
|
|
3078
|
+
const tick = async () => {
|
|
3079
|
+
if (scanning) {
|
|
3080
|
+
dirty = true;
|
|
3081
|
+
return;
|
|
3082
|
+
}
|
|
3083
|
+
scanning = true;
|
|
3084
|
+
try {
|
|
3085
|
+
const next = await scan(buildOptions());
|
|
3086
|
+
const delta = diffFindings(prev, next, "previous scan");
|
|
3087
|
+
if (delta.added.length === 0 && delta.resolved.length === 0) {
|
|
3088
|
+
process2.stdout.write(
|
|
3089
|
+
`${c.dim(`[${stamp()}]`)} ${c.dim("no change \u2014")} ${headline(next)}
|
|
3090
|
+
`
|
|
3091
|
+
);
|
|
3092
|
+
} else {
|
|
3093
|
+
const parts = [
|
|
3094
|
+
delta.added.length > 0 ? c.red(`+${delta.added.length}`) : void 0,
|
|
3095
|
+
delta.resolved.length > 0 ? c.green(`\u2212${delta.resolved.length}`) : void 0
|
|
3096
|
+
].filter(Boolean);
|
|
3097
|
+
process2.stdout.write(`${c.dim(`[${stamp()}]`)} ${parts.join(" ")} \u2014 ${headline(next)}
|
|
3098
|
+
`);
|
|
3099
|
+
for (const f of delta.added) {
|
|
3100
|
+
process2.stdout.write(
|
|
3101
|
+
` ${c.red("+")} ${describeTarget(f.target)} ${c.dim(`${f.location.file}:${f.location.line} \xB7 ${f.confidence.score}%`)}
|
|
3102
|
+
`
|
|
3103
|
+
);
|
|
3104
|
+
}
|
|
3105
|
+
for (const f of delta.resolved) {
|
|
3106
|
+
process2.stdout.write(
|
|
3107
|
+
` ${c.green("\u2713")} ${c.dim(`resolved: ${describeTarget(f.target)}`)}
|
|
3108
|
+
`
|
|
3109
|
+
);
|
|
3110
|
+
}
|
|
3111
|
+
}
|
|
3112
|
+
prev = next;
|
|
3113
|
+
} catch (err) {
|
|
3114
|
+
process2.stderr.write(
|
|
3115
|
+
`watch: scan failed: ${err instanceof Error ? err.message : String(err)}
|
|
3116
|
+
`
|
|
3117
|
+
);
|
|
3118
|
+
} finally {
|
|
3119
|
+
scanning = false;
|
|
3120
|
+
if (dirty) {
|
|
3121
|
+
dirty = false;
|
|
3122
|
+
void tick();
|
|
3123
|
+
}
|
|
3124
|
+
}
|
|
3125
|
+
};
|
|
3126
|
+
try {
|
|
3127
|
+
fs12.watch(dir, { recursive: true }, (_event, filename) => {
|
|
3128
|
+
if (filename && IGNORE2.test(String(filename))) return;
|
|
3129
|
+
clearTimeout(timer);
|
|
3130
|
+
timer = setTimeout(() => void tick(), DEBOUNCE_MS);
|
|
3131
|
+
});
|
|
3132
|
+
} catch (err) {
|
|
3133
|
+
process2.stderr.write(
|
|
3134
|
+
`error: --watch needs recursive fs.watch (${err instanceof Error ? err.message : String(err)})
|
|
3135
|
+
`
|
|
3136
|
+
);
|
|
3137
|
+
process2.exitCode = 2;
|
|
3138
|
+
return;
|
|
3139
|
+
}
|
|
3140
|
+
await new Promise(() => void 0);
|
|
3141
|
+
}
|
|
3142
|
+
function headline(r) {
|
|
3143
|
+
const flagged = r.findings.filter((f) => f.confidence.score >= 50).length;
|
|
3144
|
+
return `${flagged} flagged finding${flagged === 1 ? "" : "s"}`;
|
|
3145
|
+
}
|
|
3146
|
+
|
|
3147
|
+
// src/explain.ts
|
|
3148
|
+
import pc4 from "picocolors";
|
|
3149
|
+
var CAPS = {
|
|
3150
|
+
"dead-route": 70,
|
|
3151
|
+
"unused-export": 85,
|
|
3152
|
+
"unused-dependency": 85,
|
|
3153
|
+
"unreachable-file": 85
|
|
3154
|
+
};
|
|
3155
|
+
var MAKE_IT_LIVE = {
|
|
3156
|
+
"dead-route": "a string literal in the repo matching its path, a caller manifest (cron/CI/k8s), or observed traffic (dev retires it; production confirms either way)",
|
|
3157
|
+
"unused-export": "any import of the symbol from a reachable file",
|
|
3158
|
+
"unused-dependency": "any import of the package (or a config/script reference we can see)",
|
|
3159
|
+
"unreachable-file": "an import chain from any entry point, or listing it via --entry / deadwood.config.json"
|
|
3160
|
+
};
|
|
3161
|
+
function findMatches(result, query) {
|
|
3162
|
+
const q = query.toLowerCase();
|
|
3163
|
+
return result.findings.filter(
|
|
3164
|
+
(f) => f.id.startsWith(query) || describeTarget(f.target).toLowerCase().includes(q) || f.location.file.toLowerCase().includes(q)
|
|
3165
|
+
);
|
|
3166
|
+
}
|
|
3167
|
+
function renderExplanation(f, useColor) {
|
|
3168
|
+
const c = pc4.createColors(useColor);
|
|
3169
|
+
const lines = [""];
|
|
3170
|
+
lines.push(` ${c.bold(describeTarget(f.target))}`);
|
|
3171
|
+
lines.push(
|
|
3172
|
+
` ${c.dim(`${f.category} \xB7 ${f.location.file}:${f.location.line} \xB7 workspace ${f.workspace} \xB7 id ${f.id.slice(0, 12)}\u2026`)}`
|
|
3173
|
+
);
|
|
3174
|
+
lines.push("");
|
|
3175
|
+
lines.push(` ${c.dim("evidence (kill raises confidence, spare lowers it):")}`);
|
|
3176
|
+
let raw = 0;
|
|
3177
|
+
for (const e of f.evidence) {
|
|
3178
|
+
raw += e.weight;
|
|
3179
|
+
const sign = e.weight >= 0 ? `+${e.weight}` : `${e.weight}`;
|
|
3180
|
+
const mark = e.direction === "kill" ? c.red(sign.padStart(4)) : c.green(sign.padStart(4));
|
|
3181
|
+
lines.push(` ${mark} ${c.dim(`[${e.source}]`)} ${e.detail}`);
|
|
3182
|
+
}
|
|
3183
|
+
lines.push("");
|
|
3184
|
+
const cap = capFor(f);
|
|
3185
|
+
const capped = raw > cap;
|
|
3186
|
+
lines.push(
|
|
3187
|
+
` ${c.dim("score:")} \u03A3 weights = ${raw}${capped ? ` \u2192 capped at ${cap}` : ""} \u21D2 ${c.bold(
|
|
3188
|
+
`${f.confidence.score}% ${f.confidence.level}`
|
|
3189
|
+
)}`
|
|
3190
|
+
);
|
|
3191
|
+
if (f.confidence.capped) {
|
|
3192
|
+
lines.push(` ${c.dim(`cap reason: ${f.confidence.capped.reason}`)}`);
|
|
3193
|
+
}
|
|
3194
|
+
lines.push("");
|
|
3195
|
+
lines.push(` ${c.dim("what would make it live:")} ${MAKE_IT_LIVE[f.category]}`);
|
|
3196
|
+
lines.push("");
|
|
3197
|
+
return lines.join("\n");
|
|
3198
|
+
}
|
|
3199
|
+
function capFor(f) {
|
|
3200
|
+
if (f.evidence.some((e) => e.source === "production" || e.source === "tombstone")) return 100;
|
|
3201
|
+
if (f.evidence.some((e) => e.source === "development")) return 90;
|
|
3202
|
+
return CAPS[f.category];
|
|
3203
|
+
}
|
|
3204
|
+
function renderCandidates(matches, useColor) {
|
|
3205
|
+
const c = pc4.createColors(useColor);
|
|
3206
|
+
const lines = [` ${matches.length} findings match \u2014 narrow the query:`, ""];
|
|
3207
|
+
for (const f of matches.slice(0, 10)) {
|
|
3208
|
+
lines.push(
|
|
3209
|
+
` ${c.dim(f.id.slice(0, 8))} ${describeTarget(f.target)} ${c.dim(f.location.file)}`
|
|
3210
|
+
);
|
|
3211
|
+
}
|
|
3212
|
+
if (matches.length > 10) lines.push(c.dim(` \u2026 and ${matches.length - 10} more`));
|
|
3213
|
+
lines.push("");
|
|
3214
|
+
return lines.join("\n");
|
|
3215
|
+
}
|
|
3216
|
+
|
|
3217
|
+
// src/badge.ts
|
|
3218
|
+
function buildBadge(result, svgPath = "./deadwood-badge.svg") {
|
|
3219
|
+
const high = result.findings.filter((f) => f.confidence.level === "high").length;
|
|
3220
|
+
const flagged = result.findings.filter((f) => f.confidence.score >= 50).length;
|
|
3221
|
+
const [value, color] = high > 0 ? [`${high} high-confidence`, "#e5543f"] : flagged > 0 ? [`${flagged} flagged`, "#d0a215"] : ["clean", "#58b368"];
|
|
3222
|
+
const label = "deadwood";
|
|
3223
|
+
const svg = renderSvg(label, value, color);
|
|
3224
|
+
return {
|
|
3225
|
+
label,
|
|
3226
|
+
value,
|
|
3227
|
+
color,
|
|
3228
|
+
svg,
|
|
3229
|
+
markdown: ``
|
|
3230
|
+
};
|
|
3231
|
+
}
|
|
3232
|
+
function renderSvg(label, value, color) {
|
|
3233
|
+
const lw = Math.round(label.length * 6.5) + 14;
|
|
3234
|
+
const vw = Math.round(value.length * 6.5) + 14;
|
|
3235
|
+
const w = lw + vw;
|
|
3236
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="20" role="img" aria-label="${label}: ${value}">
|
|
3237
|
+
<rect width="${lw}" height="20" fill="#2c2f36"/>
|
|
3238
|
+
<rect x="${lw}" width="${vw}" height="20" fill="${color}"/>
|
|
3239
|
+
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
|
|
3240
|
+
<text x="${lw / 2}" y="14">${label}</text>
|
|
3241
|
+
<text x="${lw + vw / 2}" y="14">${value}</text>
|
|
3242
|
+
</g>
|
|
3243
|
+
</svg>
|
|
3244
|
+
`;
|
|
3245
|
+
}
|
|
3246
|
+
|
|
3247
|
+
// src/mcp.ts
|
|
3248
|
+
import readline2 from "readline";
|
|
3249
|
+
import process3 from "process";
|
|
3250
|
+
var PROTOCOL_VERSION = "2024-11-05";
|
|
3251
|
+
var CACHE_TTL_MS = 3e4;
|
|
3252
|
+
var LEVEL_RANK3 = { low: 0, medium: 1, high: 2 };
|
|
3253
|
+
async function runMcp(buildOptions, version, io = {}) {
|
|
3254
|
+
const input = io.input ?? process3.stdin;
|
|
3255
|
+
const output = io.output ?? process3.stdout;
|
|
3256
|
+
const send = (msg) => void output.write(JSON.stringify(msg) + "\n");
|
|
3257
|
+
let cached;
|
|
3258
|
+
const getResult = async () => {
|
|
3259
|
+
if (cached && Date.now() - cached.at < CACHE_TTL_MS) return cached.result;
|
|
3260
|
+
const result = await scan(buildOptions());
|
|
3261
|
+
cached = { at: Date.now(), result };
|
|
3262
|
+
return result;
|
|
3263
|
+
};
|
|
3264
|
+
const rl = readline2.createInterface({ input });
|
|
3265
|
+
for await (const line of rl) {
|
|
3266
|
+
if (line.trim().length === 0) continue;
|
|
3267
|
+
let req;
|
|
3268
|
+
try {
|
|
3269
|
+
req = JSON.parse(line);
|
|
3270
|
+
} catch {
|
|
3271
|
+
send({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "parse error" } });
|
|
3272
|
+
continue;
|
|
3273
|
+
}
|
|
3274
|
+
const id = req.id;
|
|
3275
|
+
try {
|
|
3276
|
+
if (req.method === "initialize") {
|
|
3277
|
+
send({
|
|
3278
|
+
jsonrpc: "2.0",
|
|
3279
|
+
id,
|
|
3280
|
+
result: {
|
|
3281
|
+
protocolVersion: typeof req.params?.protocolVersion === "string" ? req.params.protocolVersion : PROTOCOL_VERSION,
|
|
3282
|
+
capabilities: { tools: {} },
|
|
3283
|
+
serverInfo: { name: "deadwood", version }
|
|
3284
|
+
}
|
|
3285
|
+
});
|
|
3286
|
+
} else if (req.method?.startsWith("notifications/")) {
|
|
3287
|
+
} else if (req.method === "ping") {
|
|
3288
|
+
send({ jsonrpc: "2.0", id, result: {} });
|
|
3289
|
+
} else if (req.method === "tools/list") {
|
|
3290
|
+
send({ jsonrpc: "2.0", id, result: { tools: TOOLS } });
|
|
3291
|
+
} else if (req.method === "tools/call") {
|
|
3292
|
+
const name = req.params?.name;
|
|
3293
|
+
const args = req.params?.arguments ?? {};
|
|
3294
|
+
const result = await getResult();
|
|
3295
|
+
if (name === "deadwood_scan") {
|
|
3296
|
+
send(toolResult(id, summarize(result)));
|
|
3297
|
+
} else if (name === "deadwood_findings") {
|
|
3298
|
+
send(toolResult(id, filterFindings(result, args)));
|
|
3299
|
+
} else {
|
|
3300
|
+
send({
|
|
3301
|
+
jsonrpc: "2.0",
|
|
3302
|
+
id,
|
|
3303
|
+
error: { code: -32602, message: `unknown tool: ${String(name)}` }
|
|
3304
|
+
});
|
|
3305
|
+
}
|
|
3306
|
+
} else if (id !== void 0) {
|
|
3307
|
+
send({
|
|
3308
|
+
jsonrpc: "2.0",
|
|
3309
|
+
id,
|
|
3310
|
+
error: { code: -32601, message: `unknown method: ${req.method}` }
|
|
3311
|
+
});
|
|
3312
|
+
}
|
|
3313
|
+
} catch (err) {
|
|
3314
|
+
if (id !== void 0) {
|
|
3315
|
+
send({
|
|
3316
|
+
jsonrpc: "2.0",
|
|
3317
|
+
id,
|
|
3318
|
+
error: { code: -32e3, message: err instanceof Error ? err.message : String(err) }
|
|
3319
|
+
});
|
|
3320
|
+
}
|
|
3321
|
+
}
|
|
3322
|
+
}
|
|
3323
|
+
}
|
|
3324
|
+
var TOOLS = [
|
|
3325
|
+
{
|
|
3326
|
+
name: "deadwood_scan",
|
|
3327
|
+
description: 'Scan the repo for dead code. Returns headline stats (routes/exports/dependencies/files: total vs flagged) and finding counts by confidence. Wording is "appears dead" \u2014 static confidence is capped by design.',
|
|
3328
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false }
|
|
3329
|
+
},
|
|
3330
|
+
{
|
|
3331
|
+
name: "deadwood_findings",
|
|
3332
|
+
description: "List dead-code findings with their evidence chains. Filter by category, minimum confidence, file substring, or free-text query. Use before deleting code or to check whether a symbol/route/file is used anywhere.",
|
|
3333
|
+
inputSchema: {
|
|
3334
|
+
type: "object",
|
|
3335
|
+
properties: {
|
|
3336
|
+
category: {
|
|
3337
|
+
type: "string",
|
|
3338
|
+
enum: ["dead-route", "unused-export", "unused-dependency", "unreachable-file"]
|
|
3339
|
+
},
|
|
3340
|
+
minConfidence: { type: "string", enum: ["low", "medium", "high"] },
|
|
3341
|
+
file: { type: "string", description: "substring of the finding file path" },
|
|
3342
|
+
query: { type: "string", description: "substring of the finding target" },
|
|
3343
|
+
limit: { type: "number" }
|
|
3344
|
+
},
|
|
3345
|
+
additionalProperties: false
|
|
3346
|
+
}
|
|
3347
|
+
}
|
|
3348
|
+
];
|
|
3349
|
+
function toolResult(id, payload) {
|
|
3350
|
+
return {
|
|
3351
|
+
jsonrpc: "2.0",
|
|
3352
|
+
id,
|
|
3353
|
+
result: { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] }
|
|
3354
|
+
};
|
|
3355
|
+
}
|
|
3356
|
+
function summarize(r) {
|
|
3357
|
+
const by = (lv) => r.findings.filter((f) => f.confidence.level === lv).length;
|
|
3358
|
+
return {
|
|
3359
|
+
tool: r.tool,
|
|
3360
|
+
filesScanned: r.stats.filesScanned,
|
|
3361
|
+
stats: r.stats,
|
|
3362
|
+
findings: { total: r.findings.length, high: by("high"), medium: by("medium"), low: by("low") },
|
|
3363
|
+
note: 'Static confidence is capped (routes 70, others 85) \u2014 findings "appear" dead; only runtime evidence confirms.'
|
|
3364
|
+
};
|
|
3365
|
+
}
|
|
3366
|
+
function filterFindings(r, args) {
|
|
3367
|
+
let findings = r.findings;
|
|
3368
|
+
if (typeof args.category === "string")
|
|
3369
|
+
findings = findings.filter((f) => f.category === args.category);
|
|
3370
|
+
if (typeof args.minConfidence === "string" && args.minConfidence in LEVEL_RANK3) {
|
|
3371
|
+
const min = LEVEL_RANK3[args.minConfidence];
|
|
3372
|
+
findings = findings.filter((f) => LEVEL_RANK3[f.confidence.level] >= min);
|
|
3373
|
+
}
|
|
3374
|
+
if (typeof args.file === "string") {
|
|
3375
|
+
const q = args.file.toLowerCase();
|
|
3376
|
+
findings = findings.filter((f) => f.location.file.toLowerCase().includes(q));
|
|
3377
|
+
}
|
|
3378
|
+
if (typeof args.query === "string") {
|
|
3379
|
+
const q = args.query.toLowerCase();
|
|
3380
|
+
findings = findings.filter((f) => describeTarget(f.target).toLowerCase().includes(q));
|
|
3381
|
+
}
|
|
3382
|
+
const limit = typeof args.limit === "number" && args.limit > 0 ? Math.floor(args.limit) : 50;
|
|
3383
|
+
return {
|
|
3384
|
+
total: findings.length,
|
|
3385
|
+
findings: findings.slice(0, limit).map((f) => ({
|
|
3386
|
+
id: f.id,
|
|
3387
|
+
category: f.category,
|
|
3388
|
+
target: describeTarget(f.target),
|
|
3389
|
+
location: `${f.location.file}:${f.location.line}`,
|
|
3390
|
+
confidence: f.confidence,
|
|
3391
|
+
evidence: f.evidence.map((e) => ({
|
|
3392
|
+
signal: e.signal,
|
|
3393
|
+
direction: e.direction,
|
|
3394
|
+
weight: e.weight,
|
|
3395
|
+
detail: e.detail
|
|
3396
|
+
}))
|
|
3397
|
+
}))
|
|
3398
|
+
};
|
|
3399
|
+
}
|
|
3400
|
+
|
|
2498
3401
|
// src/reporters/json.ts
|
|
2499
3402
|
function renderJson(result) {
|
|
2500
3403
|
return JSON.stringify(result, null, 2) + "\n";
|
|
@@ -2557,7 +3460,7 @@ function escapeMd(s) {
|
|
|
2557
3460
|
}
|
|
2558
3461
|
|
|
2559
3462
|
// src/reporters/pretty.ts
|
|
2560
|
-
import
|
|
3463
|
+
import pc5 from "picocolors";
|
|
2561
3464
|
var CATEGORY_TITLES2 = {
|
|
2562
3465
|
"dead-route": "ROUTES THAT APPEAR DEAD",
|
|
2563
3466
|
"unused-export": "EXPORTS THAT APPEAR UNUSED",
|
|
@@ -2566,7 +3469,7 @@ var CATEGORY_TITLES2 = {
|
|
|
2566
3469
|
};
|
|
2567
3470
|
var RULE_WIDTH = 72;
|
|
2568
3471
|
function renderPretty(result, useColor) {
|
|
2569
|
-
const c =
|
|
3472
|
+
const c = pc5.createColors(useColor);
|
|
2570
3473
|
const lines = [];
|
|
2571
3474
|
const { stats } = result;
|
|
2572
3475
|
lines.push("");
|
|
@@ -2637,8 +3540,16 @@ function renderPretty(result, useColor) {
|
|
|
2637
3540
|
lines.push(` ${c.yellow("\u26A0")} ${c.dim(warning)}`);
|
|
2638
3541
|
}
|
|
2639
3542
|
lines.push("");
|
|
3543
|
+
const removable = result.findings.some(
|
|
3544
|
+
(f) => (f.category === "unreachable-file" || f.category === "unused-dependency") && f.confidence.score >= 50
|
|
3545
|
+
);
|
|
3546
|
+
if (removable) {
|
|
3547
|
+
lines.push(
|
|
3548
|
+
` ${c.green("\u2192")} ${c.bold("deadwood clean")} \u2014 review & remove these interactively ${c.dim("(one revertable commit each)")}`
|
|
3549
|
+
);
|
|
3550
|
+
}
|
|
2640
3551
|
lines.push(
|
|
2641
|
-
` ${c.dim("\u2192 --
|
|
3552
|
+
` ${c.dim("\u2192 --diff-base main for PR deltas \xB7 --json for machines \xB7 --fail-on high for CI")}`
|
|
2642
3553
|
);
|
|
2643
3554
|
lines.push("");
|
|
2644
3555
|
return lines.join("\n");
|
|
@@ -2685,7 +3596,7 @@ var CATEGORIES = [
|
|
|
2685
3596
|
"unused-dependency",
|
|
2686
3597
|
"unreachable-file"
|
|
2687
3598
|
];
|
|
2688
|
-
var
|
|
3599
|
+
var LEVEL_RANK4 = { low: 0, medium: 1, high: 2 };
|
|
2689
3600
|
var program = new Command();
|
|
2690
3601
|
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
3602
|
"--no-default-excludes",
|
|
@@ -2697,83 +3608,231 @@ program.name("deadwood").description("Find dead code and prove it \u2014 scans a
|
|
|
2697
3608
|
"nest",
|
|
2698
3609
|
"next"
|
|
2699
3610
|
])
|
|
2700
|
-
).option("--entry <file...>", "extra entry points (false-positive escape hatch)").
|
|
3611
|
+
).option("--entry <file...>", "extra entry points (false-positive escape hatch)").option(
|
|
3612
|
+
"--diff-base <ref>",
|
|
3613
|
+
"PR delta mode: report only findings INTRODUCED since this git ref; --fail-on gates the delta"
|
|
3614
|
+
).option("--watch", "re-scan on file changes and report the delta per save").option("--no-git", "skip git-history evidence (untouched-for-a-year / recently-modified nudges)").addOption(
|
|
2701
3615
|
new Option("--min-confidence <level>", "hide findings below this level").choices(["low", "medium", "high"]).default("low")
|
|
2702
|
-
).option("--no-color", "disable colors").action(async (
|
|
2703
|
-
await run(
|
|
3616
|
+
).option("--no-color", "disable colors").action(async (path15, opts) => {
|
|
3617
|
+
await run(path15, opts);
|
|
2704
3618
|
});
|
|
2705
|
-
|
|
3619
|
+
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(
|
|
3620
|
+
new Option("--min-confidence <level>", "only offer removals at/above this level").choices(["medium", "high"]).default("medium")
|
|
3621
|
+
).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 (path15, opts) => {
|
|
3622
|
+
try {
|
|
3623
|
+
await runClean(path15, opts);
|
|
3624
|
+
} catch (err) {
|
|
3625
|
+
if (err instanceof ScanError) {
|
|
3626
|
+
process4.stderr.write(`error (${err.code}): ${err.message}
|
|
3627
|
+
`);
|
|
3628
|
+
} else {
|
|
3629
|
+
process4.stderr.write(`error: ${err instanceof Error ? err.message : String(err)}
|
|
3630
|
+
`);
|
|
3631
|
+
}
|
|
3632
|
+
process4.exitCode = 2;
|
|
3633
|
+
}
|
|
3634
|
+
});
|
|
3635
|
+
program.command("explain").description(
|
|
3636
|
+
"unpack one finding: every evidence line, the confidence math, and what would make it live"
|
|
3637
|
+
).argument("<query>", "finding id prefix, target text, or file path substring").argument("[path]", "directory to scan", ".").option("--no-git", "skip git-history evidence").option("--no-color", "disable colors").action(async (query, path15, opts) => {
|
|
3638
|
+
try {
|
|
3639
|
+
const { options } = buildScanOptions(path15, { ...defaultCliOptions(), git: opts.git });
|
|
3640
|
+
const result = await scan(options);
|
|
3641
|
+
const matches = findMatches(result, query);
|
|
3642
|
+
const useColor = opts.color && pc6.isColorSupported && process4.stdout.isTTY === true;
|
|
3643
|
+
if (matches.length === 0) {
|
|
3644
|
+
process4.stderr.write(
|
|
3645
|
+
`no finding matches '${query}' \u2014 try a file path or run a scan first
|
|
3646
|
+
`
|
|
3647
|
+
);
|
|
3648
|
+
process4.exitCode = 2;
|
|
3649
|
+
} else if (matches.length > 1) {
|
|
3650
|
+
process4.stdout.write(renderCandidates(matches, useColor));
|
|
3651
|
+
process4.exitCode = 2;
|
|
3652
|
+
} else {
|
|
3653
|
+
process4.stdout.write(renderExplanation(matches[0], useColor));
|
|
3654
|
+
}
|
|
3655
|
+
} catch (err) {
|
|
3656
|
+
reportError(err);
|
|
3657
|
+
}
|
|
3658
|
+
});
|
|
3659
|
+
program.command("badge").description(
|
|
3660
|
+
"generate a self-hosted status badge SVG (no external service, nothing leaves the machine)"
|
|
3661
|
+
).argument("[path]", "directory to scan", ".").option("--output <file>", "write the SVG here instead of stdout").option("--no-git", "skip git-history evidence").action(async (path15, opts) => {
|
|
3662
|
+
try {
|
|
3663
|
+
const { options } = buildScanOptions(path15, { ...defaultCliOptions(), git: opts.git });
|
|
3664
|
+
const result = await scan(options);
|
|
3665
|
+
const badge = buildBadge(result, opts.output ?? "./deadwood-badge.svg");
|
|
3666
|
+
if (opts.output) {
|
|
3667
|
+
fs13.writeFileSync(opts.output, badge.svg);
|
|
3668
|
+
process4.stderr.write(
|
|
3669
|
+
`wrote ${opts.output} (${badge.value}) \u2014 reference it: ${badge.markdown}
|
|
3670
|
+
`
|
|
3671
|
+
);
|
|
3672
|
+
} else {
|
|
3673
|
+
process4.stdout.write(badge.svg);
|
|
3674
|
+
process4.stderr.write(
|
|
3675
|
+
`badge: ${badge.value} \u2014 save it and reference it: ${badge.markdown}
|
|
3676
|
+
`
|
|
3677
|
+
);
|
|
3678
|
+
}
|
|
3679
|
+
} catch (err) {
|
|
3680
|
+
reportError(err);
|
|
3681
|
+
}
|
|
3682
|
+
});
|
|
3683
|
+
program.command("mcp").description("serve the dead-code map to coding agents (Model Context Protocol over stdio)").argument("[path]", "directory to serve", ".").option("--no-git", "skip git-history evidence").action(async (path15, opts) => {
|
|
3684
|
+
try {
|
|
3685
|
+
await runMcp(
|
|
3686
|
+
() => buildScanOptions(path15, { ...defaultCliOptions(), git: opts.git }).options,
|
|
3687
|
+
pkg.version
|
|
3688
|
+
);
|
|
3689
|
+
} catch (err) {
|
|
3690
|
+
reportError(err);
|
|
3691
|
+
}
|
|
3692
|
+
});
|
|
3693
|
+
function defaultCliOptions() {
|
|
3694
|
+
return { defaultExcludes: true, git: true, minConfidence: "low", color: true };
|
|
3695
|
+
}
|
|
3696
|
+
function reportError(err) {
|
|
3697
|
+
if (err instanceof ScanError) {
|
|
3698
|
+
process4.stderr.write(`error (${err.code}): ${err.message}
|
|
3699
|
+
`);
|
|
3700
|
+
} else {
|
|
3701
|
+
process4.stderr.write(`error: ${err instanceof Error ? err.message : String(err)}
|
|
3702
|
+
`);
|
|
3703
|
+
}
|
|
3704
|
+
process4.exitCode = 2;
|
|
3705
|
+
}
|
|
3706
|
+
function buildScanOptions(dir, opts, { git = opts.git } = {}) {
|
|
3707
|
+
const cfg = loadRepoConfig(nodePath.resolve(dir));
|
|
3708
|
+
return {
|
|
3709
|
+
options: {
|
|
3710
|
+
path: dir,
|
|
3711
|
+
include: opts.include,
|
|
3712
|
+
exclude: [...opts.exclude ?? [], ...cfg.exclude],
|
|
3713
|
+
defaultExcludes: opts.defaultExcludes,
|
|
3714
|
+
categories: opts.category,
|
|
3715
|
+
frameworks: opts.framework,
|
|
3716
|
+
entries: [...opts.entry ?? [], ...cfg.entries],
|
|
3717
|
+
workspaces: opts.workspace,
|
|
3718
|
+
toolVersion: pkg.version,
|
|
3719
|
+
gitFacts: git ? collectGitFacts(dir) : void 0,
|
|
3720
|
+
publicApi: cfg.publicApi
|
|
3721
|
+
},
|
|
3722
|
+
configWarnings: cfg.warnings
|
|
3723
|
+
};
|
|
3724
|
+
}
|
|
3725
|
+
async function run(path15, opts) {
|
|
2706
3726
|
if (opts.json && opts.md) {
|
|
2707
|
-
|
|
2708
|
-
|
|
3727
|
+
process4.stderr.write("error: --json and --md are mutually exclusive\n");
|
|
3728
|
+
process4.exitCode = 2;
|
|
3729
|
+
return;
|
|
3730
|
+
}
|
|
3731
|
+
if (opts.watch && (opts.json || opts.md || opts.diffBase)) {
|
|
3732
|
+
process4.stderr.write(
|
|
3733
|
+
"error: --watch is interactive \u2014 it cannot combine with --json/--md/--diff-base\n"
|
|
3734
|
+
);
|
|
3735
|
+
process4.exitCode = 2;
|
|
2709
3736
|
return;
|
|
2710
3737
|
}
|
|
2711
3738
|
const failOnSpec = (() => {
|
|
2712
3739
|
try {
|
|
2713
3740
|
return opts.failOn ? parseFailOn(opts.failOn) : void 0;
|
|
2714
3741
|
} catch (err) {
|
|
2715
|
-
|
|
3742
|
+
process4.stderr.write(`error: ${err.message}
|
|
2716
3743
|
`);
|
|
2717
|
-
|
|
3744
|
+
process4.exitCode = 2;
|
|
2718
3745
|
return null;
|
|
2719
3746
|
}
|
|
2720
3747
|
})();
|
|
2721
3748
|
if (failOnSpec === null) return;
|
|
3749
|
+
const useColor = opts.color && pc6.isColorSupported && process4.stdout.isTTY === true;
|
|
3750
|
+
if (opts.watch) {
|
|
3751
|
+
await runWatch(path15, () => buildScanOptions(path15, opts).options, useColor);
|
|
3752
|
+
return;
|
|
3753
|
+
}
|
|
2722
3754
|
const machineOutput = Boolean(opts.json || opts.md);
|
|
2723
3755
|
const spinner = ora({
|
|
2724
3756
|
text: "scanning\u2026",
|
|
2725
|
-
stream:
|
|
2726
|
-
isEnabled: !machineOutput &&
|
|
3757
|
+
stream: process4.stderr,
|
|
3758
|
+
isEnabled: !machineOutput && process4.stderr.isTTY === true
|
|
2727
3759
|
});
|
|
2728
3760
|
try {
|
|
2729
3761
|
spinner.start();
|
|
2730
|
-
const
|
|
2731
|
-
|
|
2732
|
-
|
|
2733
|
-
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
}
|
|
3762
|
+
const { options, configWarnings } = buildScanOptions(path15, opts);
|
|
3763
|
+
const result = await scan(options);
|
|
3764
|
+
result.warnings.push(...configWarnings);
|
|
3765
|
+
let delta;
|
|
3766
|
+
if (opts.diffBase) {
|
|
3767
|
+
spinner.text = `scanning base ${opts.diffBase}\u2026`;
|
|
3768
|
+
const { options: baseOptions } = buildScanOptions(path15, opts, { git: false });
|
|
3769
|
+
const { path: _p, ...rest } = baseOptions;
|
|
3770
|
+
const base = await scanBaseRef(nodePath.resolve(path15), opts.diffBase, rest);
|
|
3771
|
+
delta = diffFindings(base, result, opts.diffBase);
|
|
3772
|
+
}
|
|
2741
3773
|
spinner.stop();
|
|
2742
|
-
const gate = failOnSpec ? evaluateFailOn(result.findings, failOnSpec) : void 0;
|
|
3774
|
+
const gate = failOnSpec ? evaluateFailOn(delta ? delta.added : result.findings, failOnSpec) : void 0;
|
|
2743
3775
|
const displayed = filterForDisplay(result, opts.minConfidence);
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
|
|
3776
|
+
let output;
|
|
3777
|
+
if (delta) {
|
|
3778
|
+
const displayedDelta = {
|
|
3779
|
+
...delta,
|
|
3780
|
+
added: filterForDisplay({ ...result, findings: delta.added }, opts.minConfidence).findings
|
|
3781
|
+
};
|
|
3782
|
+
output = opts.json ? JSON.stringify(
|
|
3783
|
+
{
|
|
3784
|
+
...displayed,
|
|
3785
|
+
delta: {
|
|
3786
|
+
baseRef: delta.baseRef,
|
|
3787
|
+
added: delta.added.map((f) => f.id),
|
|
3788
|
+
resolved: delta.resolved.map((f) => ({
|
|
3789
|
+
id: f.id,
|
|
3790
|
+
category: f.category,
|
|
3791
|
+
target: f.target
|
|
3792
|
+
}))
|
|
3793
|
+
}
|
|
3794
|
+
},
|
|
3795
|
+
null,
|
|
3796
|
+
2
|
|
3797
|
+
) + "\n" : opts.md ? `### \u2620 deadwood delta vs \`${delta.baseRef}\`
|
|
3798
|
+
|
|
3799
|
+
+${delta.added.length} introduced \xB7 \u2212${delta.resolved.length} resolved
|
|
3800
|
+
|
|
3801
|
+
` + renderMd({ ...displayed, findings: displayedDelta.added }) : renderDeltaPretty(displayedDelta, useColor);
|
|
3802
|
+
} else {
|
|
3803
|
+
output = opts.json ? renderJson(displayed) : opts.md ? renderMd(displayed) : renderPretty(displayed, useColor);
|
|
3804
|
+
}
|
|
3805
|
+
process4.stdout.write(output);
|
|
2747
3806
|
if (opts.json) {
|
|
2748
|
-
for (const warning of result.warnings)
|
|
3807
|
+
for (const warning of result.warnings) process4.stderr.write(`warning: ${warning}
|
|
2749
3808
|
`);
|
|
2750
3809
|
}
|
|
2751
3810
|
if (gate?.tripped) {
|
|
2752
|
-
|
|
2753
|
-
`deadwood: --fail-on ${opts.failOn} tripped (${gate.count} finding${gate.count === 1 ? "" : "s"} at/above '${failOnSpec.level}')
|
|
3811
|
+
process4.stderr.write(
|
|
3812
|
+
`deadwood: --fail-on ${opts.failOn} tripped (${gate.count} ${delta ? "INTRODUCED " : ""}finding${gate.count === 1 ? "" : "s"} at/above '${failOnSpec.level}')
|
|
2754
3813
|
`
|
|
2755
3814
|
);
|
|
2756
|
-
|
|
3815
|
+
process4.exitCode = 1;
|
|
2757
3816
|
}
|
|
2758
3817
|
} catch (err) {
|
|
2759
3818
|
spinner.stop();
|
|
2760
3819
|
if (err instanceof ScanError) {
|
|
2761
|
-
|
|
3820
|
+
process4.stderr.write(`error (${err.code}): ${err.message}
|
|
2762
3821
|
`);
|
|
2763
3822
|
} else {
|
|
2764
|
-
|
|
3823
|
+
process4.stderr.write(
|
|
2765
3824
|
`error: ${err instanceof Error ? err.stack ?? err.message : String(err)}
|
|
2766
3825
|
`
|
|
2767
3826
|
);
|
|
2768
3827
|
}
|
|
2769
|
-
|
|
3828
|
+
process4.exitCode = 2;
|
|
2770
3829
|
}
|
|
2771
3830
|
}
|
|
2772
3831
|
function filterForDisplay(result, minConfidence) {
|
|
2773
3832
|
if (minConfidence === "low") return result;
|
|
2774
|
-
const min =
|
|
2775
|
-
const findings = result.findings.filter((f) =>
|
|
3833
|
+
const min = LEVEL_RANK4[minConfidence];
|
|
3834
|
+
const findings = result.findings.filter((f) => LEVEL_RANK4[f.confidence.level] >= min);
|
|
2776
3835
|
return { ...result, findings };
|
|
2777
3836
|
}
|
|
2778
|
-
await program.parseAsync(
|
|
3837
|
+
await program.parseAsync(process4.argv);
|
|
2779
3838
|
//# sourceMappingURL=index.js.map
|