@ghostlygawd/codeweb 0.9.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.
Files changed (81) hide show
  1. package/.claude-plugin/plugin.json +30 -0
  2. package/CHANGELOG.md +480 -0
  3. package/LICENSE +21 -0
  4. package/README.md +573 -0
  5. package/agents/codeweb-dissector.md +56 -0
  6. package/agents/codeweb-domain-mapper.md +63 -0
  7. package/commands/codeweb.md +57 -0
  8. package/hooks/hooks.json +47 -0
  9. package/hooks/post-edit-diff.mjs +83 -0
  10. package/hooks/pre-edit-impact.mjs +94 -0
  11. package/hooks/session-brief.mjs +53 -0
  12. package/package.json +64 -0
  13. package/scripts/annotate.mjs +46 -0
  14. package/scripts/bench-ts-engine.mjs +59 -0
  15. package/scripts/bench.mjs +133 -0
  16. package/scripts/break-cycles.mjs +0 -0
  17. package/scripts/brief.mjs +28 -0
  18. package/scripts/build-report.mjs +182 -0
  19. package/scripts/campaign.mjs +61 -0
  20. package/scripts/check-consistency.mjs +45 -0
  21. package/scripts/ci-gate.mjs +86 -0
  22. package/scripts/cluster3.mjs +112 -0
  23. package/scripts/codemod.mjs +130 -0
  24. package/scripts/context-pack.mjs +118 -0
  25. package/scripts/deadcode.mjs +96 -0
  26. package/scripts/diff.mjs +0 -0
  27. package/scripts/explain.mjs +87 -0
  28. package/scripts/extract-symbols.mjs +1307 -0
  29. package/scripts/find-similar.mjs +86 -0
  30. package/scripts/find.mjs +50 -0
  31. package/scripts/fitness.mjs +90 -0
  32. package/scripts/grammars/PROVENANCE.md +36 -0
  33. package/scripts/grammars/tree-sitter-c-sharp.wasm +0 -0
  34. package/scripts/grammars/tree-sitter-go.wasm +0 -0
  35. package/scripts/grammars/tree-sitter-java.wasm +0 -0
  36. package/scripts/grammars/tree-sitter-python.wasm +0 -0
  37. package/scripts/grammars/tree-sitter-rust.wasm +0 -0
  38. package/scripts/grammars/tree-sitter-typescript.wasm +0 -0
  39. package/scripts/hotspots.mjs +53 -0
  40. package/scripts/lib/annotations.mjs +51 -0
  41. package/scripts/lib/bench-core.mjs +178 -0
  42. package/scripts/lib/brief-core.mjs +92 -0
  43. package/scripts/lib/campaign.mjs +73 -0
  44. package/scripts/lib/claims-check.mjs +44 -0
  45. package/scripts/lib/cli.mjs +140 -0
  46. package/scripts/lib/complexity.mjs +68 -0
  47. package/scripts/lib/dup-check.mjs +51 -0
  48. package/scripts/lib/find-core.mjs +85 -0
  49. package/scripts/lib/gate-md.mjs +50 -0
  50. package/scripts/lib/graph-ops.mjs +319 -0
  51. package/scripts/lib/hotspots.mjs +34 -0
  52. package/scripts/lib/query-core.mjs +85 -0
  53. package/scripts/lib/reading-order.mjs +53 -0
  54. package/scripts/lib/reliance.mjs +143 -0
  55. package/scripts/lib/risk.mjs +18 -0
  56. package/scripts/lib/shingles.mjs +39 -0
  57. package/scripts/lib/skeleton.mjs +41 -0
  58. package/scripts/lib/stats.mjs +92 -0
  59. package/scripts/lib/ts-engine.mjs +605 -0
  60. package/scripts/mcp-server.mjs +370 -0
  61. package/scripts/optimize.mjs +152 -0
  62. package/scripts/overlap.mjs +380 -0
  63. package/scripts/placement.mjs +93 -0
  64. package/scripts/query.mjs +94 -0
  65. package/scripts/reading-order.mjs +36 -0
  66. package/scripts/refresh.mjs +73 -0
  67. package/scripts/release-utils.mjs +158 -0
  68. package/scripts/release.mjs +62 -0
  69. package/scripts/report-template.html +730 -0
  70. package/scripts/review.mjs +112 -0
  71. package/scripts/risk.mjs +77 -0
  72. package/scripts/run.mjs +96 -0
  73. package/scripts/screenshot.mjs +104 -0
  74. package/scripts/simulate-edit.mjs +70 -0
  75. package/scripts/stats.mjs +31 -0
  76. package/scripts/trend.mjs +147 -0
  77. package/scripts/version-sync.mjs +19 -0
  78. package/skills/codebase-anatomy/SKILL.md +174 -0
  79. package/skills/codebase-anatomy/references/engine-detection.md +49 -0
  80. package/skills/codebase-anatomy/references/graph-schema.md +120 -0
  81. package/skills/codebase-anatomy/references/overlap-heuristics.md +52 -0
@@ -0,0 +1,130 @@
1
+ #!/usr/bin/env node
2
+ // codeweb codemod (F8) — turn a consolidation OPPORTUNITY (or an explicit merge) into a concrete,
3
+ // gate-checked edit PLAN, and optionally APPLY it. Default is plan-only (pure). --write is
4
+ // conservative + REVERSIBLE: it refuses when the plan predicts a regression or a loser label can't
5
+ // be rewritten unambiguously, backs up every touched file, applies the edits, RE-EXTRACTS, and
6
+ // reverts byte-for-byte if the structural gate regresses. Source-rewriting is structural best-effort
7
+ // (it never re-introduces the byName guessing the extractor refuses). Built on ./lib/graph-ops.mjs
8
+ // (shares applyEdit / structuralRegressions / chooseCanonical — one truth with simulate-edit/optimize).
9
+ //
10
+ // Usage: node codemod.mjs <graph.json> (--opportunity <ovId> | --merge <ids> --into <id>) [--json] [--write]
11
+ // Exit: 0 ok, 1 predicted/actual regression (no net change), 2 usage/IO/ambiguous.
12
+
13
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
14
+ import { spawnSync } from 'node:child_process';
15
+ import { resolve, dirname, join } from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
+ import { normalizeGraph, buildIndex, callersOf, impactOf, applyEdit, structuralRegressions, chooseCanonical, resolveSymbol } from './lib/graph-ops.mjs';
18
+
19
+ const HERE = dirname(fileURLToPath(import.meta.url));
20
+ const USAGE = 'usage: codemod.mjs <graph.json> (--opportunity <ovId> | --merge <ids> --into <id>) [--json] [--write]';
21
+ import { die, emitJson, finish, loadGraph } from './lib/cli.mjs';
22
+
23
+ const argv = process.argv.slice(2);
24
+ let json = false, doWrite = false, opp = null, merge = null, into = null; const pos = [];
25
+ for (let i = 0; i < argv.length; i++) {
26
+ const t = argv[i];
27
+ if (t === '--json') json = true;
28
+ else if (t === '--write') doWrite = true;
29
+ else if (t === '--opportunity') opp = argv[++i];
30
+ else if (t === '--merge') merge = argv[++i];
31
+ else if (t === '--into') into = argv[++i];
32
+ else if (!t.startsWith('-')) pos.push(t);
33
+ }
34
+ const graphPath = pos[0] || (process.env.CODEWEB_WS ? `${process.env.CODEWEB_WS}/graph.json` : null);
35
+ if (!graphPath || (opp == null && merge == null)) die(USAGE, 2);
36
+
37
+ const { graph, abs } = loadGraph(graphPath, { usage: USAGE });
38
+
39
+ const index = buildIndex(graph);
40
+
41
+ // resolve the cluster to merge + the canonical survivor
42
+ let ids, canonical;
43
+ if (opp != null) {
44
+ const o = graph.overlaps.find((x) => x.id === opp);
45
+ if (!o) die(`opportunity not found: ${opp}`, 2);
46
+ ids = [...new Set(o.nodes)];
47
+ if (ids.length < 2) die(`opportunity ${opp} has <2 nodes`, 2);
48
+ canonical = chooseCanonical(index, ids);
49
+ } else {
50
+ ids = [...new Set(merge.split(',').flatMap((s) => resolveSymbol(graph, s.trim())))];
51
+ if (ids.length < 2) die(`--merge needs >=2 resolved symbols (got ${ids.length})`, ids.length ? 2 : 1);
52
+ canonical = into != null ? (resolveSymbol(graph, into)[0] || into) : chooseCanonical(index, ids);
53
+ }
54
+ const losers = ids.filter((id) => id !== canonical);
55
+ const byId = index.byId;
56
+
57
+ // projected gate (the SAME oracle simulate-edit is pinned to — one truth)
58
+ const after = applyEdit(graph, { kind: 'merge', ids, into: canonical });
59
+ const sr = structuralRegressions(graph, after);
60
+ const projectedGate = { newCycles: sr.newCycles, lostCallers: sr.lostCallers, ok: sr.newCycles.length === 0 && sr.lostCallers.length === 0 };
61
+
62
+ const deletions = losers.map((id) => { const n = byId.get(id); return { id, file: n.file, range: [n.line, n.line + (n.loc || 1) - 1] }; });
63
+ const rewrites = callersOf(index, losers).map((cid) => { const n = byId.get(cid); return { callerId: cid, file: n?.file ?? null, line: n?.line ?? null }; });
64
+ const locReclaimed = losers.reduce((s, id) => s + (byId.get(id)?.loc || 0), 0);
65
+ const canonLabel = byId.get(canonical)?.label;
66
+ const plan = { canonical, canonicalLabel: canonLabel, losers, deletions, rewrites, blastRadius: impactOf(index, ids).length, locReclaimed, projectedGate };
67
+
68
+ // ---- --write: conservative, gated, reversible ------------------------------------------------
69
+ let writeResult = null, code = 0;
70
+ if (doWrite) {
71
+ const root = graph.meta?.root;
72
+ if (!root || !existsSync(root)) die(`--write needs graph.meta.root on disk (got ${root || 'none'})`, 2);
73
+ if (!projectedGate.ok) { writeResult = { applied: false, reason: 'the gate predicts a regression — refusing to write', projectedGate }; code = 1; }
74
+ else {
75
+ // a loser whose label differs from the canonical's must have a GLOBALLY-UNIQUE label to rewrite
76
+ // safely (the token unambiguously refers to it); else refuse (never guess — the extractor's rule).
77
+ const labelCount = (lab) => graph.nodes.filter((n) => n.label === lab).length;
78
+ const renameLosers = losers.filter((id) => byId.get(id).label !== canonLabel);
79
+ const ambiguous = renameLosers.filter((id) => labelCount(byId.get(id).label) !== 1);
80
+ if (ambiguous.length) { writeResult = { applied: false, reason: `cannot safely rewrite ambiguous label(s): ${ambiguous.map((id) => byId.get(id).label).join(', ')} — apply by hand`, ambiguous }; code = 2; }
81
+ else {
82
+ const touched = [...new Set([...deletions.map((d) => d.file), ...rewrites.map((r) => r.file).filter(Boolean)])];
83
+ const backup = new Map();
84
+ for (const f of touched) { const p = join(root, f); backup.set(f, existsSync(p) ? readFileSync(p, 'utf8') : null); }
85
+ const restore = () => { for (const [f, txt] of backup) if (txt != null) writeFileSync(join(root, f), txt); };
86
+ try {
87
+ // 1) delete loser definition line-ranges (descending per file so indices stay valid)
88
+ const delByFile = new Map();
89
+ for (const d of deletions) { if (!delByFile.has(d.file)) delByFile.set(d.file, []); delByFile.get(d.file).push(d.range); }
90
+ for (const [f, ranges] of delByFile) {
91
+ const p = join(root, f); const raw = readFileSync(p, 'utf8');
92
+ const eol = raw.includes('\r\n') ? '\r\n' : '\n'; // preserve the file's line ending (no silent CRLF->LF)
93
+ const lines = raw.split(/\r?\n/);
94
+ for (const [s, e] of ranges.slice().sort((a, b) => b[0] - a[0])) lines.splice(s - 1, e - s + 1);
95
+ writeFileSync(p, lines.join(eol));
96
+ }
97
+ // 2) rewrite each rename-loser's label token -> canonical label, across every touched file
98
+ const renameLabels = [...new Set(renameLosers.map((id) => byId.get(id).label))];
99
+ for (const f of touched) {
100
+ const p = join(root, f); if (!existsSync(p)) continue;
101
+ let txt = readFileSync(p, 'utf8');
102
+ for (const lab of renameLabels) txt = txt.replace(new RegExp(`\\b${lab.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'g'), canonLabel);
103
+ writeFileSync(p, txt);
104
+ }
105
+ // 3) re-extract + gate; revert on regression
106
+ const r = spawnSync(process.execPath, [join(HERE, 'extract-symbols.mjs'), root], { encoding: 'utf8', maxBuffer: 1 << 28 });
107
+ if (r.status !== 0) { restore(); writeResult = { applied: false, reason: `re-extraction failed; reverted` }; code = 1; }
108
+ else {
109
+ const fresh = normalizeGraph(JSON.parse(r.stdout));
110
+ const post = structuralRegressions(graph, fresh);
111
+ if (post.newCycles.length || post.lostCallers.length) { restore(); writeResult = { applied: false, reason: 'post-edit gate regressed; reverted', post }; code = 1; }
112
+ else writeResult = { applied: true, filesTouched: touched, reExtractGate: { newCycles: post.newCycles, lostCallers: post.lostCallers, ok: true } };
113
+ }
114
+ } catch (e) { restore(); writeResult = { applied: false, reason: `error during write; reverted: ${e.message}` }; code = 1; }
115
+ }
116
+ }
117
+ }
118
+
119
+ const payload = { ...plan, write: writeResult };
120
+ if (json) { emitJson(payload, code); } else {
121
+
122
+ console.log(`codeweb codemod: merge ${ids.length} -> keep ${canonical}`);
123
+ console.log(` removes ${losers.length} copy(ies), rewires ${rewrites.length} caller(s), ~${locReclaimed} LOC, blast ${plan.blastRadius}`);
124
+ console.log(` projected gate: ${projectedGate.ok ? 'PASS' : 'BLOCK'}${projectedGate.ok ? '' : ` (${projectedGate.newCycles.length} new cycle, ${projectedGate.lostCallers.length} lost-caller)`}`);
125
+ console.log(' deletions:'); for (const d of deletions) console.log(` ${d.file}:${d.range[0]}-${d.range[1]} (${d.id})`);
126
+ console.log(' rewrites:'); for (const r of rewrites) console.log(` ${r.file}:${r.line} (${r.callerId})`);
127
+ if (writeResult) console.log(` write: ${writeResult.applied ? `APPLIED to ${writeResult.filesTouched.length} file(s)` : `NOT applied — ${writeResult.reason}`}`);
128
+ else console.log(' (plan-only — pass --write to apply, gated + reversible)');
129
+ finish(code);
130
+ }
@@ -0,0 +1,118 @@
1
+ #!/usr/bin/env node
2
+ // codeweb context-pack — blast-radius-scoped context for a symbol. Returns the target body, the
3
+ // direct callers (the call sites that break if the contract changes — as CALL-SITE WINDOWS, a few
4
+ // lines around each use, not whole function bodies), the direct callees (dependencies —
5
+ // location-only to stay bounded), and the transitive impact set (ids only). Lets an agent edit with
6
+ // a minimal window instead of grepping and reading whole files. Read-only, deterministic.
7
+ //
8
+ // The window default is the token budget made real: whole caller bodies blew a single "bounded"
9
+ // pack past 300KB on a busy symbol (88 callers x full functions); the agent needs the call sites.
10
+ // --full-bodies restores the old shape when a consumer genuinely wants every caller in full.
11
+ //
12
+ // Usage: node context-pack.mjs <graph.json> <symbol> [--window N] [--full-bodies] [--limit N] [--json]
13
+ // Exit: 0 ok, 1 symbol not found, 2 usage/IO.
14
+
15
+ import { readFileSync, existsSync } from 'node:fs';
16
+ import { resolve } from 'node:path';
17
+ import { normalizeGraph, buildIndex, resolveSymbol, callersOf, calleesOf, impactOf } from './lib/graph-ops.mjs';
18
+
19
+ const USAGE = 'usage: context-pack.mjs <graph.json> <symbol> [--window N] [--full-bodies] [--limit N] [--json] (or set CODEWEB_WS)';
20
+ import { die, emitJson, finish, capList, checkStaleness, loadGraph, sourceReader } from './lib/cli.mjs';
21
+
22
+ const argv = process.argv.slice(2);
23
+ let json = false, windowN = 3, fullBodies = false, limit = null; const pos = [];
24
+ for (let i = 0; i < argv.length; i++) {
25
+ const t = argv[i];
26
+ if (t === '--json') json = true;
27
+ else if (t === '--window') windowN = Math.max(0, parseInt(argv[++i], 10) || 3);
28
+ else if (t === '--full-bodies') fullBodies = true;
29
+ else if (t === '--limit') limit = Math.max(0, parseInt(argv[++i], 10) || 0);
30
+ else if (!t.startsWith('-')) pos.push(t);
31
+ }
32
+ let graphPath, symbol;
33
+ if (pos.length >= 2) { graphPath = pos[0]; symbol = pos[1]; }
34
+ else if (pos.length === 1 && process.env.CODEWEB_WS) { graphPath = `${process.env.CODEWEB_WS}/graph.json`; symbol = pos[0]; }
35
+ else die(USAGE, 2);
36
+
37
+ const { graph, abs } = loadGraph(graphPath, { usage: USAGE });
38
+
39
+ const ids = resolveSymbol(graph, symbol);
40
+ if (!ids.length) die(`symbol not found: ${symbol}`, 1);
41
+
42
+ const index = buildIndex(graph);
43
+ const callerIds = callersOf(index, ids);
44
+ const calleeIds = calleesOf(index, ids);
45
+ const blast = impactOf(index, ids);
46
+
47
+ // CP-BODY-FIDELITY: sourceReader serves the exact source lines [line, line+loc-1] (one truth,
48
+ // shared with find-similar + diff rename-matching).
49
+ const reader = sourceReader(graph.meta?.root || null);
50
+ const sourceAvailable = reader.available;
51
+ const readLines = reader.linesOf;
52
+ const bodyOf = reader.bodyOf;
53
+ const view = (n, withBody) => {
54
+ const o = { id: n.id, label: n.label, kind: n.kind, file: n.file, line: n.line, loc: n.loc, domain: n.domain, exports: n.exports, signature: n.signature ?? null };
55
+ if (withBody) o.body = bodyOf(n);
56
+ return o;
57
+ };
58
+ // CALL-SITE WINDOWS: inside the caller's recorded span, every line that references the target label,
59
+ // each with ±windowN lines of context. Windows overlapping-adjacent are merged. This is the part of
60
+ // the caller an agent actually needs to update; the full body stays one --full-bodies away.
61
+ const targetLabels = [...new Set(ids.map((id) => index.byId.get(id)?.label).filter(Boolean))];
62
+ const labelRe = targetLabels.length ? new RegExp(`\\b(${targetLabels.map((l) => l.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})\\b`) : null;
63
+ const windowsOf = (n) => {
64
+ if (!sourceAvailable || !labelRe) return [];
65
+ const lines = readLines(n.file);
66
+ if (!lines) return [];
67
+ const start = n.line, end = Math.min(n.line + (n.loc || 1) - 1, lines.length);
68
+ const hits = [];
69
+ for (let ln = start; ln <= end; ln++) if (labelRe.test(lines[ln - 1] || '')) hits.push(ln);
70
+ const windows = [];
71
+ for (const h of hits.slice(0, 8)) { // a caller with >8 call sites: the first 8 windows tell the story
72
+ const s = Math.max(start, h - windowN), e = Math.min(end, h + windowN);
73
+ const last = windows[windows.length - 1];
74
+ if (last && s <= last.endLine + 1) { last.endLine = e; last.callLines.push(h); }
75
+ else windows.push({ startLine: s, endLine: e, callLines: [h] });
76
+ }
77
+ for (const w of windows) w.text = lines.slice(w.startLine - 1, w.endLine).join('\n');
78
+ return windows;
79
+ };
80
+ const callerView = (id) => {
81
+ const n = byId.get(id);
82
+ const o = view(n, fullBodies);
83
+ if (!fullBodies) o.windows = windowsOf(n);
84
+ return o;
85
+ };
86
+ const byId = index.byId;
87
+ const cappedCallers = capList(callerIds, limit);
88
+ const cappedCallees = capList(calleeIds, limit);
89
+ const cappedBlast = capList(blast, limit != null ? Math.max(limit, 25) : null);
90
+ const payload = {
91
+ symbol, matched: ids, sourceAvailable,
92
+ summary: `${symbol}: ${callerIds.length} caller(s), ${calleeIds.length} callee(s), blast radius ${blast.length}`,
93
+ mode: fullBodies ? 'full-bodies' : `call-site windows (±${windowN} lines)`,
94
+ target: ids.map((id) => view(byId.get(id), true)),
95
+ callers: cappedCallers.items.map(callerView), // call sites that may need updating
96
+ callees: cappedCallees.items.map((id) => view(byId.get(id), false)), // dependencies: location-only (bounded)
97
+ blastRadius: { count: blast.length, ids: cappedBlast.items }, // transitive impact: ids only
98
+ };
99
+ if (cappedCallers.truncated) payload.moreCallers = { remaining: cappedCallers.remaining };
100
+ const staleInfo = checkStaleness(graph);
101
+ if (staleInfo) { payload.stale = staleInfo; payload.summary += ` — graph is stale for ${staleInfo.count}+ file(s); run codeweb_refresh`; }
102
+ if (cappedCallees.truncated) payload.moreCallees = { remaining: cappedCallees.remaining };
103
+
104
+ if (json) { emitJson(payload); } else {
105
+
106
+ console.log(`context-pack: ${symbol} -> ${ids.join(', ')}`);
107
+ console.log(`source: ${sourceAvailable ? `available — ${payload.mode}` : 'absent — bodies null'}`);
108
+ for (const t of payload.target) {
109
+ console.log(`\n# target ${t.id} (${t.file}:${t.line}, ${t.loc} loc, ${t.domain})`);
110
+ if (t.body) console.log(t.body);
111
+ }
112
+ console.log(`\ncallers (${payload.callers.length}) — call sites that may need updating:`);
113
+ for (const c of payload.callers) console.log(` ${c.id} (${c.file}:${c.line})`);
114
+ console.log(`callees (${payload.callees.length}) — dependencies:`);
115
+ for (const c of payload.callees) console.log(` ${c.id}`);
116
+ console.log(`blast radius: ${payload.blastRadius.count} transitive caller(s)`);
117
+ finish();
118
+ }
@@ -0,0 +1,96 @@
1
+ #!/usr/bin/env node
2
+ // codeweb deadcode (F10) — turn the orphans candidate list into a confidence-tiered action plan.
3
+ // Partitions orphans (no production caller, not exported) into `safe` (no test edge, not defined in
4
+ // a test file, not an entrypoint-like name — high-confidence dead) and `review` (referenced by a
5
+ // test, defined in a test file (helper/mock/case registration), or an entrypoint-like name a
6
+ // framework/CLI may invoke without a code edge). Honestly surfaces the
7
+ // orphans caveat (extraction drops ambiguous call edges, so cross-check). Read-only, advisory,
8
+ // deterministic. Built on ./lib/graph-ops.mjs (uses the SAME orphans + testIn as query.mjs — one truth).
9
+ //
10
+ // Usage: node deadcode.mjs <graph.json> [--json] (or set CODEWEB_WS)
11
+ // Exit: 0 (advisory), 2 usage/IO.
12
+
13
+ import { readFileSync, existsSync } from 'node:fs';
14
+ import { resolve, dirname, join } from 'node:path';
15
+ import { normalizeGraph, buildIndex, orphans, isTestFile } from './lib/graph-ops.mjs';
16
+ import { fingerprint, loadAnnotations } from './lib/annotations.mjs'; // F7: false-positive suppression memory
17
+
18
+ // Entrypoint-like names that may be invoked by a framework / CLI / test runner rather than via a
19
+ // code edge — so an uncalled one is "review", not "safe to delete". (Mirrored by the test oracle.)
20
+ const ENTRYPOINTS = new Set(['main', 'default', 'index', 'setup', 'teardown', 'init']);
21
+ // A/B lever (mirrors CODEWEB_LEGACY_FALLBACK / CODEWEB_HUB_INDEG): restore the pre-fix behavior where
22
+ // a function DEFINED IN a test file falls through to `safe`. Defaults OFF (shipped: test-file -> review).
23
+ // The effectiveness study flips this on to prove the H13 fix is load-bearing (safe-tier precision drops).
24
+ const DEADCODE_LEGACY = process.env.CODEWEB_DEADCODE_LEGACY === '1';
25
+ const USAGE = 'usage: deadcode.mjs <graph.json> [--json] (or set CODEWEB_WS)';
26
+ import { die, emitJson, finish, capList, loadGraph } from './lib/cli.mjs';
27
+
28
+ const argv = process.argv.slice(2);
29
+ let json = false, showSuppressed = false, annDir = null, limit = null; const pos = [];
30
+ for (let i = 0; i < argv.length; i++) {
31
+ const t = argv[i];
32
+ if (t === '--json') json = true;
33
+ else if (t === '--show-suppressed') showSuppressed = true;
34
+ else if (t === '--annotations') annDir = argv[++i];
35
+ else if (t === '--limit') limit = Math.max(0, parseInt(argv[++i], 10) || 0);
36
+ else if (!t.startsWith('-')) pos.push(t);
37
+ }
38
+ const { graph, abs } = loadGraph(pos[0], { usage: USAGE });
39
+
40
+ const index = buildIndex(graph);
41
+ const CAVEAT = 'extraction drops ambiguous call edges (precision over recall), so a genuinely-called symbol can surface here — cross-check before deleting';
42
+
43
+ const safe = [], review = [];
44
+ for (const o of orphans(graph, index)) { // orphans = no call|import|inherit incoming, not exported
45
+ const node = index.byId.get(o.id);
46
+ const label = node?.label || o.id;
47
+ const testers = index.testIn.get(o.id)?.size || 0;
48
+ const file = node?.file || o.file;
49
+ const loc = node?.loc || 0; // deleting this orphan reclaims its span — campaign's delete-ROI signal
50
+ if (testers > 0) review.push({ ...o, loc, reason: `referenced only by ${testers} test(s) — the test may be its only user; remove the test too, or it is genuinely used` });
51
+ else if (!DEADCODE_LEGACY && isTestFile(file)) review.push({ ...o, loc, reason: `defined in a test file '${file}' — a test runner may invoke it (helper, mock, or case registration) without a code edge, so deleting it can break tests` });
52
+ else if (ENTRYPOINTS.has(label)) review.push({ ...o, loc, reason: `entrypoint-like name '${label}' — may be invoked by a framework/CLI/test runner, not via a code edge` });
53
+ else safe.push({ ...o, loc, reason: 'no production caller, not exported, no test edge, not in a test file — high-confidence dead' }); // the shared caveat lives once in payload.note
54
+ }
55
+
56
+ // F7: every finding carries a stable fingerprint (kind 'orphan' + its id). A '.codeweb/annotations.json'
57
+ // false-positive suppression hides a safe finding by default (so a confirmed not-dead symbol stops
58
+ // resurfacing) and is COUNTED; --show-suppressed reveals them. Suppression keys on identity, so if the
59
+ // symbol id changes the fingerprint changes and it is NOT silently hidden.
60
+ for (const o of safe) o.fingerprint = fingerprint({ kind: 'orphan', nodes: [o.id] });
61
+ for (const o of review) o.fingerprint = fingerprint({ kind: 'orphan', nodes: [o.id] });
62
+ const dir = annDir || join(dirname(abs), '.codeweb');
63
+ const killed = new Set(loadAnnotations(dir).suppressions.filter((s) => s.verdict === 'false-positive').map((s) => s.fingerprint));
64
+ const suppressed = safe.filter((o) => killed.has(o.fingerprint));
65
+ const visibleSafe = showSuppressed ? safe : safe.filter((o) => !killed.has(o.fingerprint));
66
+
67
+ // Budget: totals stay TRUE; the lists cap at --limit each, biggest spans first (the deletes worth
68
+ // doing first), with an explicit remainder — never a silent cut.
69
+ const byLocDesc = (a, b) => (b.loc || 0) - (a.loc || 0) || (a.id < b.id ? -1 : 1);
70
+ const capSafe = capList(limit != null ? visibleSafe.slice().sort(byLocDesc) : visibleSafe, limit);
71
+ const capReview = capList(limit != null ? review.slice().sort(byLocDesc) : review, limit);
72
+ const payload = {
73
+ target: graph.meta?.target || 'target',
74
+ summary: `${visibleSafe.length + review.length} orphan(s): ${visibleSafe.length} safe to delete, ${review.length} need review${suppressed.length ? `, ${suppressed.length} suppressed` : ''}`,
75
+ totals: { orphans: visibleSafe.length + review.length, safe: visibleSafe.length, review: review.length, suppressed: suppressed.length },
76
+ note: CAVEAT,
77
+ safe: capSafe.items, review: capReview.items, suppressed,
78
+ };
79
+ if (capSafe.truncated) payload.moreSafe = { remaining: capSafe.remaining };
80
+ if (capReview.truncated) payload.moreReview = { remaining: capReview.remaining };
81
+
82
+ if (json) { emitJson(payload); } else {
83
+
84
+ const t = payload.totals;
85
+ console.log(`codeweb deadcode: ${payload.target} — ${t.orphans} orphan(s): ${t.safe} safe, ${t.review} review${t.suppressed ? `, ${t.suppressed} suppressed` : ''}`);
86
+ console.log(`\nsafe to delete (no caller, not exported, no test):`);
87
+ for (const o of payload.safe) console.log(` ${o.id} [${o.domain}] (${o.loc} loc)`);
88
+ if (payload.moreSafe) console.log(` … +${payload.moreSafe.remaining} more`);
89
+ if (!safe.length) console.log(' (none)');
90
+ console.log(`\nreview first (tests reference it, or entrypoint-like):`);
91
+ for (const o of payload.review) console.log(` ${o.id} — ${o.reason}`);
92
+ if (payload.moreReview) console.log(` … +${payload.moreReview.remaining} more`);
93
+ if (!review.length) console.log(' (none)');
94
+ console.log(`\nnote: ${CAVEAT}.`);
95
+ finish();
96
+ }
Binary file
@@ -0,0 +1,87 @@
1
+ #!/usr/bin/env node
2
+ // codeweb explain — ONE bounded card answering "tell me about X before I touch it": identity,
3
+ // role, contract (signature/complexity), who depends on it (fan-in/out, tests, blast radius),
4
+ // the top callers/callees by fan-in, and any duplication/pattern findings it belongs to.
5
+ // The question agents ask most, previously 3-4 separate calls. Read-only, deterministic, small
6
+ // by construction (~1KB) — counts + top-5s, never exhaustive lists (those stay one tool away).
7
+ //
8
+ // Usage: node explain.mjs <graph.json> <symbol> [--json] (or set CODEWEB_WS and pass <symbol>)
9
+ // Exit: 0 ok, 1 symbol not found, 2 usage/IO.
10
+
11
+ import { buildIndex, resolveSymbol, callersOf, calleesOf, testersOf, impactOf, fanInOf } from './lib/graph-ops.mjs';
12
+ import { callerReliance, relianceLine } from './lib/reliance.mjs';
13
+
14
+ const USAGE = 'usage: explain.mjs <graph.json> <symbol> [--json] (or set CODEWEB_WS)';
15
+ import { die, emitJson, finish, loadGraph, checkStaleness, sourceReader } from './lib/cli.mjs';
16
+
17
+ const argv = process.argv.slice(2);
18
+ let json = false; const pos = [];
19
+ for (const t of argv) { if (t === '--json') json = true; else if (!t.startsWith('-')) pos.push(t); }
20
+ let graphArg = null, symbol = null;
21
+ if (pos.length >= 2) { graphArg = pos[0]; symbol = pos[1]; }
22
+ else if (pos.length === 1) { symbol = pos[0]; }
23
+ if (!symbol) die(USAGE, 2);
24
+ const { graph } = loadGraph(graphArg, { usage: USAGE });
25
+
26
+ const ids = resolveSymbol(graph, symbol);
27
+ if (!ids.length) { if (json) emitJson({ symbol, found: false }, 1); else die(`symbol not found: ${symbol}`, 1); }
28
+ else {
29
+ const index = buildIndex(graph);
30
+ const reader = sourceReader(graph.meta && graph.meta.root);
31
+ const topBy = (list, n) => list.slice().sort((a, b) => fanInOf(index, b) - fanInOf(index, a) || (a < b ? -1 : 1)).slice(0, n);
32
+
33
+ const cards = ids.map((id) => {
34
+ const n = index.byId.get(id);
35
+ const callers = callersOf(index, [id]);
36
+ const callees = calleesOf(index, [id]);
37
+ const tests = testersOf(index, [id]);
38
+ const blast = impactOf(index, [id]);
39
+ const blastDomains = [...new Set(blast.map((x) => index.byId.get(x)?.domain || 'unassigned'))];
40
+ const findings = (graph.overlaps || []).filter((o) => (o.nodes || []).includes(id))
41
+ .map((o) => ({ id: o.id, kind: o.kind, title: o.title, confidence: o.confidence }));
42
+ const reliance = callerReliance(graph, index, n, reader);
43
+ // confidence calibration: say when "N callers" is NOT the whole story
44
+ const caveat = n.pub
45
+ ? 'public API (reachable from a package entrypoint) — external callers likely; renames are breaking'
46
+ : (callers.length === 0 && n.exports)
47
+ ? '0 in-repo callers but exported — external use possible'
48
+ : (callers.length === 0 && graph.meta?.dynamic)
49
+ ? `repo routes calls dynamically in ${graph.meta.dynamic.files} file(s) — absence of callers is weaker evidence`
50
+ : null;
51
+ return {
52
+ ...(n.pub ? { publicApi: true } : {}),
53
+ ...(caveat ? { caveat } : {}),
54
+ ...(reliance ? { callersRelyOn: reliance } : {}),
55
+ id, label: n.label, kind: n.kind, role: n.role || 'product', domain: n.domain,
56
+ at: `${n.file}:${n.line}`, loc: n.loc, exports: n.exports,
57
+ signature: n.signature ?? null,
58
+ ...(n.complexity != null ? { complexity: n.complexity, maxDepth: n.maxDepth } : {}),
59
+ dependents: { callers: callers.length, tests: tests.length, blastRadius: blast.length, blastDomains: blastDomains.length },
60
+ topCallers: topBy(callers, 5),
61
+ topCallees: topBy(callees, 5),
62
+ tests: tests.slice(0, 3),
63
+ findings,
64
+ summary: `${n.kind} ${n.label} (${n.role || 'product'}, ${n.domain}) — ${callers.length} caller(s), ${tests.length} test(s), blast ${blast.length} across ${blastDomains.length} domain(s)${findings.length ? `; in ${findings.length} finding(s)` : ''}${relianceLine(reliance) ? `; ${relianceLine(reliance)}` : ''}${caveat ? `; ⚠ ${caveat}` : ''}`,
65
+ };
66
+ });
67
+ const payload = { symbol, matched: ids, cards, summary: cards.map((c) => c.summary).join(' | ') };
68
+ const stale = checkStaleness(graph);
69
+ if (stale) { payload.stale = stale; payload.summary += ` — graph is stale for ${stale.count}+ file(s); run codeweb_refresh`; }
70
+
71
+ if (json) emitJson(payload);
72
+ else {
73
+ for (const c of cards) {
74
+ console.log(`# ${c.id}`);
75
+ console.log(` ${c.summary}`);
76
+ console.log(` at ${c.at} · ${c.loc} loc${c.exports ? ' · exported' : ''}${c.signature ? ` · (${(c.signature.params || []).join(', ')})${c.signature.returns ? ' -> ' + c.signature.returns : ''}` : ''}${c.complexity != null ? ` · cx ${c.complexity}` : ''}`);
77
+ if (c.caveat) console.log(` ⚠ ${c.caveat}`);
78
+ if (c.callersRelyOn) console.log(` callers rely on: ${relianceLine(c.callersRelyOn)}`);
79
+ if (c.topCallers.length) console.log(` top callers: ${c.topCallers.join(', ')}`);
80
+ if (c.topCallees.length) console.log(` calls: ${c.topCallees.join(', ')}`);
81
+ if (c.tests.length) console.log(` tests: ${c.tests.join(', ')}`);
82
+ for (const f of c.findings) console.log(` finding: [${f.kind}/${f.confidence}] ${f.title}`);
83
+ }
84
+ if (payload.stale) console.log(` ⚠ graph stale for ${payload.stale.count}+ file(s) — run codeweb_refresh`);
85
+ finish();
86
+ }
87
+ }