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/dist/index.js CHANGED
@@ -2,10 +2,12 @@
2
2
 
3
3
  // src/index.ts
4
4
  import { createRequire } from "module";
5
- import process from "process";
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 pc2 from "picocolors";
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 posix = toPosix(p);
723
- return !scannedFiles || scannedFiles.has(posix) ? posix : void 0;
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 allStatic = evidence.every((e) => e.source === "static");
2170
- const cap = allStatic ? STATIC_CAPS[category] : 100;
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 (allStatic && raw > cap) {
2174
- result.capped = { at: cap, reason: STATIC_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 PROVIDERS) {
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: `![deadwood](${svgPath})`
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 pc from "picocolors";
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 = pc.createColors(useColor);
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 --min-confidence medium \xB7 --json for machines \xB7 --fail-on high for CI")}`
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 LEVEL_RANK2 = { low: 0, medium: 1, high: 2 };
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)").addOption(
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 (path12, opts) => {
2703
- await run(path12, opts);
3616
+ ).option("--no-color", "disable colors").action(async (path15, opts) => {
3617
+ await run(path15, opts);
2704
3618
  });
2705
- async function run(path12, opts) {
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
- process.stderr.write("error: --json and --md are mutually exclusive\n");
2708
- process.exitCode = 2;
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
- process.stderr.write(`error: ${err.message}
3742
+ process4.stderr.write(`error: ${err.message}
2716
3743
  `);
2717
- process.exitCode = 2;
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: process.stderr,
2726
- isEnabled: !machineOutput && process.stderr.isTTY === true
3757
+ stream: process4.stderr,
3758
+ isEnabled: !machineOutput && process4.stderr.isTTY === true
2727
3759
  });
2728
3760
  try {
2729
3761
  spinner.start();
2730
- const result = await scan({
2731
- path: path12,
2732
- include: opts.include,
2733
- exclude: opts.exclude,
2734
- defaultExcludes: opts.defaultExcludes,
2735
- categories: opts.category,
2736
- frameworks: opts.framework,
2737
- entries: opts.entry,
2738
- workspaces: opts.workspace,
2739
- toolVersion: pkg.version
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
- const useColor = opts.color && pc2.isColorSupported && process.stdout.isTTY === true;
2745
- const output = opts.json ? renderJson(displayed) : opts.md ? renderMd(displayed) : renderPretty(displayed, useColor);
2746
- process.stdout.write(output);
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) process.stderr.write(`warning: ${warning}
3807
+ for (const warning of result.warnings) process4.stderr.write(`warning: ${warning}
2749
3808
  `);
2750
3809
  }
2751
3810
  if (gate?.tripped) {
2752
- process.stderr.write(
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
- process.exitCode = 1;
3815
+ process4.exitCode = 1;
2757
3816
  }
2758
3817
  } catch (err) {
2759
3818
  spinner.stop();
2760
3819
  if (err instanceof ScanError) {
2761
- process.stderr.write(`error (${err.code}): ${err.message}
3820
+ process4.stderr.write(`error (${err.code}): ${err.message}
2762
3821
  `);
2763
3822
  } else {
2764
- process.stderr.write(
3823
+ process4.stderr.write(
2765
3824
  `error: ${err instanceof Error ? err.stack ?? err.message : String(err)}
2766
3825
  `
2767
3826
  );
2768
3827
  }
2769
- process.exitCode = 2;
3828
+ process4.exitCode = 2;
2770
3829
  }
2771
3830
  }
2772
3831
  function filterForDisplay(result, minConfidence) {
2773
3832
  if (minConfidence === "low") return result;
2774
- const min = LEVEL_RANK2[minConfidence];
2775
- const findings = result.findings.filter((f) => LEVEL_RANK2[f.confidence.level] >= min);
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(process.argv);
3837
+ await program.parseAsync(process4.argv);
2779
3838
  //# sourceMappingURL=index.js.map