@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.
- package/.claude-plugin/plugin.json +30 -0
- package/CHANGELOG.md +480 -0
- package/LICENSE +21 -0
- package/README.md +573 -0
- package/agents/codeweb-dissector.md +56 -0
- package/agents/codeweb-domain-mapper.md +63 -0
- package/commands/codeweb.md +57 -0
- package/hooks/hooks.json +47 -0
- package/hooks/post-edit-diff.mjs +83 -0
- package/hooks/pre-edit-impact.mjs +94 -0
- package/hooks/session-brief.mjs +53 -0
- package/package.json +64 -0
- package/scripts/annotate.mjs +46 -0
- package/scripts/bench-ts-engine.mjs +59 -0
- package/scripts/bench.mjs +133 -0
- package/scripts/break-cycles.mjs +0 -0
- package/scripts/brief.mjs +28 -0
- package/scripts/build-report.mjs +182 -0
- package/scripts/campaign.mjs +61 -0
- package/scripts/check-consistency.mjs +45 -0
- package/scripts/ci-gate.mjs +86 -0
- package/scripts/cluster3.mjs +112 -0
- package/scripts/codemod.mjs +130 -0
- package/scripts/context-pack.mjs +118 -0
- package/scripts/deadcode.mjs +96 -0
- package/scripts/diff.mjs +0 -0
- package/scripts/explain.mjs +87 -0
- package/scripts/extract-symbols.mjs +1307 -0
- package/scripts/find-similar.mjs +86 -0
- package/scripts/find.mjs +50 -0
- package/scripts/fitness.mjs +90 -0
- package/scripts/grammars/PROVENANCE.md +36 -0
- package/scripts/grammars/tree-sitter-c-sharp.wasm +0 -0
- package/scripts/grammars/tree-sitter-go.wasm +0 -0
- package/scripts/grammars/tree-sitter-java.wasm +0 -0
- package/scripts/grammars/tree-sitter-python.wasm +0 -0
- package/scripts/grammars/tree-sitter-rust.wasm +0 -0
- package/scripts/grammars/tree-sitter-typescript.wasm +0 -0
- package/scripts/hotspots.mjs +53 -0
- package/scripts/lib/annotations.mjs +51 -0
- package/scripts/lib/bench-core.mjs +178 -0
- package/scripts/lib/brief-core.mjs +92 -0
- package/scripts/lib/campaign.mjs +73 -0
- package/scripts/lib/claims-check.mjs +44 -0
- package/scripts/lib/cli.mjs +140 -0
- package/scripts/lib/complexity.mjs +68 -0
- package/scripts/lib/dup-check.mjs +51 -0
- package/scripts/lib/find-core.mjs +85 -0
- package/scripts/lib/gate-md.mjs +50 -0
- package/scripts/lib/graph-ops.mjs +319 -0
- package/scripts/lib/hotspots.mjs +34 -0
- package/scripts/lib/query-core.mjs +85 -0
- package/scripts/lib/reading-order.mjs +53 -0
- package/scripts/lib/reliance.mjs +143 -0
- package/scripts/lib/risk.mjs +18 -0
- package/scripts/lib/shingles.mjs +39 -0
- package/scripts/lib/skeleton.mjs +41 -0
- package/scripts/lib/stats.mjs +92 -0
- package/scripts/lib/ts-engine.mjs +605 -0
- package/scripts/mcp-server.mjs +370 -0
- package/scripts/optimize.mjs +152 -0
- package/scripts/overlap.mjs +380 -0
- package/scripts/placement.mjs +93 -0
- package/scripts/query.mjs +94 -0
- package/scripts/reading-order.mjs +36 -0
- package/scripts/refresh.mjs +73 -0
- package/scripts/release-utils.mjs +158 -0
- package/scripts/release.mjs +62 -0
- package/scripts/report-template.html +730 -0
- package/scripts/review.mjs +112 -0
- package/scripts/risk.mjs +77 -0
- package/scripts/run.mjs +96 -0
- package/scripts/screenshot.mjs +104 -0
- package/scripts/simulate-edit.mjs +70 -0
- package/scripts/stats.mjs +31 -0
- package/scripts/trend.mjs +147 -0
- package/scripts/version-sync.mjs +19 -0
- package/skills/codebase-anatomy/SKILL.md +174 -0
- package/skills/codebase-anatomy/references/engine-detection.md +49 -0
- package/skills/codebase-anatomy/references/graph-schema.md +120 -0
- package/skills/codebase-anatomy/references/overlap-heuristics.md +52 -0
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// codeweb MCP server — stdio transport, newline-delimited JSON-RPC 2.0, zero-dependency. Exposes
|
|
3
|
+
// codeweb's structural queries as MCP tools an agent can call mid-task. Each tools/call shells out
|
|
4
|
+
// to the tested CLI artifact (what ships is what's tested).
|
|
5
|
+
//
|
|
6
|
+
// Agent-efficiency contract (the token flip):
|
|
7
|
+
// - `graph` is OPTIONAL everywhere: explicit arg > CODEWEB_WS > nearest `.codeweb/graph.json`
|
|
8
|
+
// walking up from cwd. A missing graph returns an ACTIONABLE error naming codeweb_map.
|
|
9
|
+
// - Responses are BUDGETED by default: list-heavy tools get a per-tool default limit (top-N by
|
|
10
|
+
// relevance + explicit `more.remaining`); pass `full: true` (or an explicit limit) to override.
|
|
11
|
+
// A budgeted call answers in ~1-2k tokens where the old behavior could exceed an entire context.
|
|
12
|
+
// - codeweb_map builds/rebuilds the graph so the loop never dead-ends on "graph not found".
|
|
13
|
+
//
|
|
14
|
+
// CRITICAL: stdout carries ONLY JSON-RPC messages (one per line). Any stray write to stdout
|
|
15
|
+
// corrupts the stream for the client, so all diagnostics go to stderr (here: none).
|
|
16
|
+
|
|
17
|
+
import { createInterface } from 'node:readline';
|
|
18
|
+
import { spawnSync } from 'node:child_process';
|
|
19
|
+
import { readFileSync, existsSync, statSync } from 'node:fs';
|
|
20
|
+
import { dirname, join, resolve } from 'node:path';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
import { normalizeGraph, buildIndex } from './lib/graph-ops.mjs';
|
|
23
|
+
import { runQuery } from './lib/query-core.mjs';
|
|
24
|
+
import { findSymbols } from './lib/find-core.mjs';
|
|
25
|
+
import { buildBrief } from './lib/brief-core.mjs';
|
|
26
|
+
import { bump, attachActivity } from './lib/stats.mjs';
|
|
27
|
+
import { checkStaleness } from './lib/cli.mjs';
|
|
28
|
+
|
|
29
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
30
|
+
const scriptOf = (f) => join(HERE, f);
|
|
31
|
+
|
|
32
|
+
// serverInfo.version derives from package.json at startup — the ONE version truth — so the MCP
|
|
33
|
+
// handshake can never drift from the shipped release again (it sat at 0.1.0 while the product was
|
|
34
|
+
// 0.2.0, uncaught because check-consistency didn't look here; it now does).
|
|
35
|
+
const PKG_VERSION = (() => {
|
|
36
|
+
try { return JSON.parse(readFileSync(join(HERE, '..', 'package.json'), 'utf8')).version || '0.0.0'; }
|
|
37
|
+
catch { return '0.0.0'; }
|
|
38
|
+
})();
|
|
39
|
+
const SERVER = { name: 'codeweb', version: PKG_VERSION };
|
|
40
|
+
// Protocol negotiation: echo a version we actually support; otherwise answer with our latest.
|
|
41
|
+
const PROTOCOLS = new Set(['2024-11-05', '2025-03-26', '2025-06-18']);
|
|
42
|
+
const DEFAULT_PROTOCOL = '2025-06-18';
|
|
43
|
+
const SPAWN_TIMEOUT_MS = 120_000; // a wedged child must not wedge the whole session
|
|
44
|
+
|
|
45
|
+
const INSTRUCTIONS = [
|
|
46
|
+
'codeweb answers structural questions about a mapped repo (graph.json) deterministically — no LLM, ~100ms.',
|
|
47
|
+
'Loop: BEFORE editing a symbol call codeweb_context (bounded edit window) or codeweb_impact (blast radius).',
|
|
48
|
+
'AFTER editing call codeweb_refresh, then codeweb_diff (before vs after) to gate the edit.',
|
|
49
|
+
'Before WRITING a new function call codeweb_find_similar (does this exist?) and codeweb_placement (where does it belong?).',
|
|
50
|
+
'No symbol name yet? codeweb_find turns a concept ("retry backoff") into ranked starting symbols.',
|
|
51
|
+
'`graph` is optional — the server finds the nearest .codeweb/graph.json from cwd. No graph yet? codeweb_map builds one (~3s for 3k symbols).',
|
|
52
|
+
'Responses are budgeted (top-N + more.remaining). Pass full:true for the unabridged list, or limit/offset to page.',
|
|
53
|
+
].join('\n');
|
|
54
|
+
|
|
55
|
+
const send = (msg) => process.stdout.write(JSON.stringify(msg) + '\n');
|
|
56
|
+
const reply = (id, result) => send({ jsonrpc: '2.0', id, result });
|
|
57
|
+
const fail = (id, code, message) => send({ jsonrpc: '2.0', id, error: { code, message } });
|
|
58
|
+
const errResult = (id, text) => reply(id, { content: [{ type: 'text', text }], isError: true });
|
|
59
|
+
|
|
60
|
+
// ---- graph auto-discovery -------------------------------------------------------------------
|
|
61
|
+
// Explicit arg > CODEWEB_WS workspace > nearest `.codeweb/graph.json` walking up from cwd.
|
|
62
|
+
function discoverGraph() {
|
|
63
|
+
if (process.env.CODEWEB_WS) {
|
|
64
|
+
const p = join(process.env.CODEWEB_WS, 'graph.json');
|
|
65
|
+
if (existsSync(p)) return p;
|
|
66
|
+
}
|
|
67
|
+
let dir = process.cwd();
|
|
68
|
+
for (let i = 0; i < 40; i++) {
|
|
69
|
+
const p = join(dir, '.codeweb', 'graph.json');
|
|
70
|
+
if (existsSync(p)) return p;
|
|
71
|
+
const up = dirname(dir);
|
|
72
|
+
if (up === dir) break;
|
|
73
|
+
dir = up;
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
const NO_GRAPH = 'no graph found — pass `graph`, or build one for this repo with the codeweb_map tool (or /codeweb). The graph lives at <target>/.codeweb/graph.json.';
|
|
78
|
+
|
|
79
|
+
// ---- persistent graph cache (in-process serving) ---------------------------------------------
|
|
80
|
+
// The stdio server lives for the whole session, so structural queries answer from a PARSED,
|
|
81
|
+
// INDEXED graph kept in memory — sub-ms after the first call instead of spawn+parse (~100ms)
|
|
82
|
+
// every time. Keyed by (path, mtime, size): a rebuilt/refreshed graph reloads transparently.
|
|
83
|
+
// The payloads come from the same lib/query-core.mjs the CLI ships — one truth, two transports.
|
|
84
|
+
const graphCache = new Map(); // abs path -> { m, s, graph, index }
|
|
85
|
+
function cachedGraph(absPath) {
|
|
86
|
+
const st = statSync(absPath);
|
|
87
|
+
const hit = graphCache.get(absPath);
|
|
88
|
+
if (hit && hit.m === st.mtimeMs && hit.s === st.size) return hit;
|
|
89
|
+
const graph = normalizeGraph(JSON.parse(readFileSync(absPath, 'utf8')));
|
|
90
|
+
const entry = { m: st.mtimeMs, s: st.size, graph, index: buildIndex(graph) };
|
|
91
|
+
graphCache.set(absPath, entry);
|
|
92
|
+
if (graphCache.size > 8) graphCache.delete(graphCache.keys().next().value); // a session touches few graphs
|
|
93
|
+
return entry;
|
|
94
|
+
}
|
|
95
|
+
const QUERY_KIND = { codeweb_callers: 'callers', codeweb_callees: 'callees', codeweb_tests: 'tests', codeweb_impact: 'impact', codeweb_cycles: 'cycles', codeweb_orphans: 'orphans' };
|
|
96
|
+
|
|
97
|
+
// ---- self-healing freshness -------------------------------------------------------------------
|
|
98
|
+
// Structural queries answered from a stale map are the trap an agent can't see; annotating helped,
|
|
99
|
+
// but the ambient fix is to REFRESH inline (incremental, ~1s) before answering. Scoped to the
|
|
100
|
+
// structural tools (refresh preserves domains but drops overlaps, so overlap-consuming advisors
|
|
101
|
+
// keep their input). Throttled per graph; CODEWEB_NO_AUTOREFRESH=1 disables; failure -> serve the
|
|
102
|
+
// stale answer WITH its stale annotation (never a dead end).
|
|
103
|
+
const AUTOREFRESH_TOOLS = new Set([...Object.keys(QUERY_KIND), 'codeweb_context', 'codeweb_explain', 'codeweb_find', 'codeweb_brief']);
|
|
104
|
+
const refreshAttempt = new Map(); // abs graph path -> last attempt ms
|
|
105
|
+
function autoRefresh(absGraph) {
|
|
106
|
+
if (process.env.CODEWEB_NO_AUTOREFRESH === '1') return;
|
|
107
|
+
try {
|
|
108
|
+
const { graph } = cachedGraph(absGraph);
|
|
109
|
+
if (!checkStaleness(graph)) return;
|
|
110
|
+
const last = refreshAttempt.get(absGraph) || 0;
|
|
111
|
+
if (Date.now() - last < 15_000) return; // one attempt per 15s per graph
|
|
112
|
+
refreshAttempt.set(absGraph, Date.now());
|
|
113
|
+
spawnSync(process.execPath, [scriptOf('refresh.mjs'), absGraph], { encoding: 'utf8', timeout: 60_000, maxBuffer: 1 << 24 });
|
|
114
|
+
bump(absGraph, 'autoRefreshes');
|
|
115
|
+
// the rewrite changed mtime/size — the next cachedGraph() reloads and serves fresh
|
|
116
|
+
} catch { /* stale-but-annotated beats broken */ }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ---- tool table ------------------------------------------------------------------------------
|
|
120
|
+
// need: required args (validated). opt: optional args (schema only). budget: {arg, flag, value} —
|
|
121
|
+
// when the caller passes neither that arg nor full:true, `flag value` is injected (default top-N).
|
|
122
|
+
// argv(a): CLI argv AFTER the graph path. bin defaults to query.mjs. input(a): child stdin.
|
|
123
|
+
const QUERY = scriptOf('query.mjs');
|
|
124
|
+
const TOOLS = [
|
|
125
|
+
{ name: 'codeweb_callers', need: ['symbol'], opt: ['graph', 'limit', 'offset', 'full'], budget: { arg: 'limit', flag: '--limit', value: 20 },
|
|
126
|
+
argv: (a) => ['--callers', a.symbol],
|
|
127
|
+
description: 'Direct callers (call-edge in-neighbors) of a symbol. Budgeted: top 20 by default (full:true for all).' },
|
|
128
|
+
{ name: 'codeweb_callees', need: ['symbol'], opt: ['graph', 'limit', 'offset', 'full'], budget: { arg: 'limit', flag: '--limit', value: 20 },
|
|
129
|
+
argv: (a) => ['--callees', a.symbol],
|
|
130
|
+
description: 'Direct callees (the functions a symbol calls). Budgeted: top 20 by default.' },
|
|
131
|
+
{ name: 'codeweb_impact', need: ['symbol'], opt: ['graph', 'limit', 'offset', 'full'], budget: { arg: 'limit', flag: '--limit', value: 20 },
|
|
132
|
+
argv: (a) => ['--impact', a.symbol],
|
|
133
|
+
description: 'Blast radius: every function transitively affected by changing a symbol, plus the domains touched. Call this BEFORE editing a symbol. Budgeted: summary + top 20 by fan-in (count is the true total; full:true for every id).' },
|
|
134
|
+
{ name: 'codeweb_cycles', need: [], opt: ['graph', 'limit', 'offset', 'full'], budget: { arg: 'limit', flag: '--limit', value: 15 },
|
|
135
|
+
argv: () => ['--cycles'],
|
|
136
|
+
description: 'File-level dependency cycles (circular imports/calls). Budgeted: top 15 by default.' },
|
|
137
|
+
{ name: 'codeweb_orphans', need: [], opt: ['graph', 'limit', 'offset', 'full'], budget: { arg: 'limit', flag: '--limit', value: 25 },
|
|
138
|
+
argv: () => ['--orphans'],
|
|
139
|
+
description: 'Uncalled and unexported symbols (dead-code candidates). Budgeted: top 25 by default; prefer codeweb_deadcode for a confidence-tiered plan.' },
|
|
140
|
+
{ name: 'codeweb_tests', need: ['symbol'], opt: ['graph', 'limit', 'offset', 'full'], budget: { arg: 'limit', flag: '--limit', value: 20 },
|
|
141
|
+
argv: (a) => ['--tests', a.symbol],
|
|
142
|
+
description: 'The tests that exercise a symbol (test-edge in-neighbors). Run the right subset after editing a symbol.' },
|
|
143
|
+
{ name: 'codeweb_diff', need: ['before', 'after'], opt: [], bin: scriptOf('diff.mjs'), graphless: true,
|
|
144
|
+
argv: (a) => [a.before, a.after],
|
|
145
|
+
description: 'Structural delta + regression verdict between two graph.json snapshots (before vs after an edit): nodes/edges/cycles/overlaps/orphans added & removed, coupling delta, and ok:false with reasons on a regression (new cycle, new duplication, a symbol that lost all callers). Call AFTER an edit to gate it.' },
|
|
146
|
+
{ name: 'codeweb_explain', need: ['symbol'], opt: ['graph'], bin: scriptOf('explain.mjs'),
|
|
147
|
+
argv: (a) => [a.symbol],
|
|
148
|
+
description: '"Tell me about X before I touch it" in ONE ~1KB card: identity, role, signature, complexity, fan-in/out, tests, blast radius + domains, top-5 callers/callees, and any duplication/pattern findings it belongs to. Start here; drill down with impact/context/callers.' },
|
|
149
|
+
{ name: 'codeweb_brief', need: [], opt: ['graph'], bin: scriptOf('brief.mjs'),
|
|
150
|
+
argv: () => [],
|
|
151
|
+
description: 'The day-one page for a mapped repo (~2KB): areas with summaries, the most depended-on symbols, entry points, test layout, and known issues (duplications/cycles/orphans). Call FIRST in a new session instead of exploring; then codeweb_find/explain to go deeper.' },
|
|
152
|
+
{ name: 'codeweb_find', need: ['query'], opt: ['graph', 'limit', 'offset', 'full'], budget: { arg: 'limit', flag: '--limit', value: 10 },
|
|
153
|
+
bin: scriptOf('find.mjs'),
|
|
154
|
+
argv: (a) => [a.query],
|
|
155
|
+
description: 'Concept search when you do NOT know the symbol name: free text ("retry handling") -> ranked symbols (identifier/file/domain token match, stemmed, weighted by exports/role/fan-in). Deterministic, no embeddings. Start here when orienting; feed the top id to codeweb_explain.' },
|
|
156
|
+
{ name: 'codeweb_context', need: ['symbol'], opt: ['graph', 'limit', 'window', 'full'], budget: { arg: 'limit', flag: '--limit', value: 12 },
|
|
157
|
+
bin: scriptOf('context-pack.mjs'),
|
|
158
|
+
argv: (a) => [a.symbol, ...(a.full ? ['--full-bodies'] : []), ...(a.window != null ? ['--window', String(a.window)] : [])],
|
|
159
|
+
description: 'Bounded edit window for a symbol in ONE call: its body, direct callers as CALL-SITE WINDOWS (±3 lines around each use — the lines that break if the contract changes), callees (location-only), and the impact set. Budgeted: 12 callers by default; full:true returns whole caller bodies (large).' },
|
|
160
|
+
{ name: 'codeweb_refresh', need: [], opt: ['graph'], bin: scriptOf('refresh.mjs'), argv: () => [],
|
|
161
|
+
description: 'Re-extract the graph from disk (meta.root) so mid-task queries reflect your edits, not a stale snapshot. Incremental; preserves domains, drops stale overlaps. Call AFTER you edit source and BEFORE re-querying impact/callers/context.' },
|
|
162
|
+
{ name: 'codeweb_find_similar', need: [], opt: ['graph', 'signature', 'body', 'structural'], bin: scriptOf('find-similar.mjs'),
|
|
163
|
+
valid: (a) => (a.signature || a.body) ? null : 'pass `signature` (a candidate signature) or `body` (a code snippet)',
|
|
164
|
+
argv: (a) => a.body ? ['--stdin', ...(a.structural ? ['--structural'] : [])] : ['--signature', a.signature, ...(a.structural ? ['--structural'] : [])],
|
|
165
|
+
input: (a) => a.body || undefined,
|
|
166
|
+
description: 'Before writing a function, ask "does something already do this?": ranks existing bodies by similarity to a candidate `signature` or `body` snippet. structural:true matches identifier-renamed (Type-2) clones. Call to AVOID re-implementing existing logic.' },
|
|
167
|
+
{ name: 'codeweb_placement', need: ['calls'], opt: ['graph'], bin: scriptOf('placement.mjs'),
|
|
168
|
+
argv: (a) => ['--calls', a.calls],
|
|
169
|
+
description: 'Where a NEW symbol belongs: given the comma-separated ids/labels it will call, suggests the domain + file by callee gravity, and warns if it duplicates an existing symbol.' },
|
|
170
|
+
{ name: 'codeweb_review', need: ['changed'], opt: ['graph', 'before', 'gate', 'limit', 'full'], bin: scriptOf('review.mjs'),
|
|
171
|
+
argv: (a) => ['--changed', a.changed, ...(a.before ? ['--before', a.before] : []), ...(a.gate ? ['--gate'] : [])],
|
|
172
|
+
description: 'Structural review of a change: changed files (comma-separated, optionally file:start-end) -> changed symbols, blast radius, domains, fan-in-ranked review order. With `before` (a prior graph.json) + gate:true it FAILS on a structural regression — the full review gate, agent-reachable.' },
|
|
173
|
+
{ name: 'codeweb_fitness', need: ['rules'], opt: ['graph'], bin: scriptOf('fitness.mjs'),
|
|
174
|
+
argv: (a) => ['--rules', a.rules],
|
|
175
|
+
description: 'Check the graph against architectural fitness rules (codeweb.rules.json): forbidden-dependency, layering, no-cycles, max-fan-in, max-symbol-loc. Reports violations.' },
|
|
176
|
+
{ name: 'codeweb_risk', need: [], opt: ['graph', 'changed', 'limit', 'full'], budget: { arg: 'limit', flag: '--limit', value: 15 }, bin: scriptOf('risk.mjs'),
|
|
177
|
+
argv: (a) => (a.changed ? ['--changed', a.changed] : []),
|
|
178
|
+
description: 'Rank symbols by change-risk (fan-in, fan-out, loc, blast radius, churn); `changed` scopes to a comma-separated file list. Budgeted: top 15 by default.' },
|
|
179
|
+
{ name: 'codeweb_break_cycles', need: [], opt: ['graph', 'limit', 'full'], budget: { arg: 'limit', flag: '--limit', value: 10 }, bin: scriptOf('break-cycles.mjs'), argv: () => [],
|
|
180
|
+
description: 'For each file dependency cycle, the cheapest dependency edge to sever — verified to actually break the cycle. Budgeted: top 10 cycles by default.' },
|
|
181
|
+
{ name: 'codeweb_deadcode', need: [], opt: ['graph', 'limit', 'full'], budget: { arg: 'limit', flag: '--limit', value: 20 }, bin: scriptOf('deadcode.mjs'), argv: () => [],
|
|
182
|
+
description: 'Confidence-tiered dead-code: safe-to-delete vs review-first (test-guarded or entrypoint-like), each with its loc span. Budgeted: top 20 per tier by span (totals stay true; full:true for everything).' },
|
|
183
|
+
{ name: 'codeweb_codemod', need: ['merge', 'into'], opt: ['graph'], bin: scriptOf('codemod.mjs'),
|
|
184
|
+
argv: (a) => ['--merge', a.merge, '--into', a.into],
|
|
185
|
+
description: 'Plan a consolidation merge (report-only): canonical survivor, exact deletions + caller rewrites, LOC reclaimed, and the projected regression-gate verdict. Read-only — does NOT modify source.' },
|
|
186
|
+
{ name: 'codeweb_hotspots', need: [], opt: ['graph', 'limit', 'full'], budget: { arg: 'limit', flag: '--limit', value: 15 }, bin: scriptOf('hotspots.mjs'), argv: () => [],
|
|
187
|
+
description: 'Rank symbols by refactoring priority (complexity x fan-in x churn): where to focus first. Budgeted: top 15 with raw components.' },
|
|
188
|
+
{ name: 'codeweb_campaign', need: [], opt: ['graph', 'budget', 'full'], budget: { arg: 'budget', flag: '--budget', value: 25 }, bin: scriptOf('campaign.mjs'), argv: () => [],
|
|
189
|
+
description: 'One ordered, gated optimization worklist (dead-code deletes + verified cycle cuts + duplicate merges), pre-flighted so applying in order never introduces a cycle. Budgeted: top 25 ROI steps by default (`budget` N or full:true for the whole plan).' },
|
|
190
|
+
{ name: 'codeweb_reading_order', need: [], opt: ['graph', 'scope', 'value', 'budget'], bin: scriptOf('reading-order.mjs'),
|
|
191
|
+
budget: { arg: 'budget', flag: '--budget', value: 20 },
|
|
192
|
+
argv: (a) => (a.scope && a.value ? ['--scope', a.scope, a.value] : []),
|
|
193
|
+
description: 'A foundations-first reading path (depended-upon leaves before orchestrators) to understand a codebase or one scope fast. scope: domain|file|symbol + value narrows it; budget bounds the list (default 20).' },
|
|
194
|
+
{ name: 'codeweb_map', need: [], opt: ['target', 'out'], graphless: true, map: true,
|
|
195
|
+
description: 'Build (or rebuild) the codeweb graph for a repo: runs the deterministic pipeline (extract -> cluster -> overlap -> optimize -> report) into <target>/.codeweb. ~3s for a 3k-symbol repo. Call when a query reports "no graph found", or after large changes. Returns the graph path + stats; artifacts include report.html and graph.json.' },
|
|
196
|
+
];
|
|
197
|
+
|
|
198
|
+
const PROP = {
|
|
199
|
+
graph: { type: 'string', description: 'Path to graph.json. OPTIONAL — defaults to CODEWEB_WS or the nearest .codeweb/graph.json above cwd' },
|
|
200
|
+
symbol: { type: 'string', description: 'A node id (file:label) or a bare label' },
|
|
201
|
+
query: { type: 'string', description: 'Free-text concept ("retry backoff", "where is config parsed") — no symbol name needed' },
|
|
202
|
+
before: { type: 'string', description: 'Path to the BEFORE graph.json snapshot' },
|
|
203
|
+
after: { type: 'string', description: 'Path to the AFTER graph.json snapshot' },
|
|
204
|
+
signature: { type: 'string', description: 'A candidate function signature to check for existing implementations' },
|
|
205
|
+
body: { type: 'string', description: 'A candidate code snippet (function body) to check for existing implementations' },
|
|
206
|
+
structural: { type: 'boolean', description: 'Match identifier-normalized skeletons (catches renamed/Type-2 clones)' },
|
|
207
|
+
calls: { type: 'string', description: 'Comma-separated ids/labels the new symbol will call (its intended callees)' },
|
|
208
|
+
changed: { type: 'string', description: 'Comma-separated changed files, each optionally file:start-end (whole file if no range)' },
|
|
209
|
+
rules: { type: 'string', description: 'Path to a codeweb.rules.json fitness-rules file' },
|
|
210
|
+
merge: { type: 'string', description: 'Comma-separated symbol ids/labels to consolidate (merge)' },
|
|
211
|
+
into: { type: 'string', description: 'The id/label to keep as the canonical survivor of the merge' },
|
|
212
|
+
gate: { type: 'boolean', description: 'Fail (isError-style exit) on a structural regression vs `before`' },
|
|
213
|
+
limit: { type: 'number', description: 'Max items to return (each tool has a sensible default; full:true disables)' },
|
|
214
|
+
offset: { type: 'number', description: 'Skip N items (page through a budgeted list via more.nextOffset)' },
|
|
215
|
+
full: { type: 'boolean', description: 'Return the unabridged result (disables the default budget). Large on big repos.' },
|
|
216
|
+
window: { type: 'number', description: 'Context lines around each call site (default 3)' },
|
|
217
|
+
scope: { type: 'string', description: 'Scope kind: domain | file | symbol' },
|
|
218
|
+
value: { type: 'string', description: 'The scope value (domain name, file path, or symbol)' },
|
|
219
|
+
budget: { type: 'number', description: 'Max steps/symbols to return' },
|
|
220
|
+
target: { type: 'string', description: 'Repo/subdir to map (default: the server cwd)' },
|
|
221
|
+
out: { type: 'string', description: 'Output workspace (default: <target>/.codeweb)' },
|
|
222
|
+
};
|
|
223
|
+
const schema = (t) => ({
|
|
224
|
+
type: 'object',
|
|
225
|
+
properties: Object.fromEntries([...t.need, ...(t.opt || [])].map((k) => [k, PROP[k]])),
|
|
226
|
+
required: t.need,
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// ---- codeweb_map -----------------------------------------------------------------------------
|
|
230
|
+
function handleMap(id, args) {
|
|
231
|
+
const target = resolve(args.target || process.cwd());
|
|
232
|
+
if (!existsSync(target)) return errResult(id, `target not found: ${target}`);
|
|
233
|
+
const out = resolve(args.out || join(target, '.codeweb'));
|
|
234
|
+
const r = spawnSync(process.execPath, [scriptOf('run.mjs'), target, '--out-dir', out], { encoding: 'utf8', maxBuffer: 1 << 28, timeout: 300_000 });
|
|
235
|
+
if (r.status !== 0) {
|
|
236
|
+
return errResult(id, `codeweb_map failed (exit ${r.status}): ${(r.stderr || '').trim().split('\n').slice(-3).join('\n')}`);
|
|
237
|
+
}
|
|
238
|
+
const graphPath = join(out, 'graph.json');
|
|
239
|
+
let stats = '';
|
|
240
|
+
try {
|
|
241
|
+
const g = JSON.parse(readFileSync(graphPath, 'utf8'));
|
|
242
|
+
stats = `${(g.nodes || []).length} symbols, ${(g.edges || []).length} edges, ${(g.domains || []).length} domains, ${(g.overlaps || []).length} overlap findings`;
|
|
243
|
+
} catch { stats = 'built (stats unreadable)'; }
|
|
244
|
+
return reply(id, { content: [{ type: 'text', text: JSON.stringify({ ok: true, graph: graphPath, summary: `mapped ${target}: ${stats}`, artifacts: { report: join(out, 'report.html'), optimize: join(out, 'optimize.md') } }) }] });
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ---- tools/call ------------------------------------------------------------------------------
|
|
248
|
+
function handleToolCall(id, params) {
|
|
249
|
+
const name = params && params.name;
|
|
250
|
+
const args = (params && params.arguments) || {};
|
|
251
|
+
const tool = TOOLS.find((t) => t.name === name);
|
|
252
|
+
if (!tool) return fail(id, -32602, `unknown tool: ${name}`);
|
|
253
|
+
for (const k of tool.need) {
|
|
254
|
+
if (typeof args[k] !== 'string' || args[k] === '') {
|
|
255
|
+
return errResult(id, `missing required argument: ${k}`);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
if (tool.valid) { const problem = tool.valid(args); if (problem) return errResult(id, problem); }
|
|
259
|
+
if (tool.map) return handleMap(id, args);
|
|
260
|
+
|
|
261
|
+
const cliArgs = [];
|
|
262
|
+
let graphPath = null;
|
|
263
|
+
if (!tool.graphless) {
|
|
264
|
+
graphPath = args.graph || discoverGraph();
|
|
265
|
+
if (!graphPath) return errResult(id, NO_GRAPH);
|
|
266
|
+
cliArgs.push(graphPath);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (graphPath) bump(resolve(graphPath), 'queriesServed'); // the receipt's denominator
|
|
270
|
+
if (graphPath && AUTOREFRESH_TOOLS.has(tool.name)) autoRefresh(resolve(graphPath));
|
|
271
|
+
|
|
272
|
+
// Fast path: the briefing assembles in-process from the cached graph (same object as the CLI
|
|
273
|
+
// via brief-core). Any surprise falls back to the spawned artifact.
|
|
274
|
+
if (tool.name === 'codeweb_brief') {
|
|
275
|
+
try {
|
|
276
|
+
const { graph, index } = cachedGraph(resolve(graphPath));
|
|
277
|
+
const payload = attachActivity(buildBrief(graph, index), resolve(graphPath));
|
|
278
|
+
const stale = checkStaleness(graph);
|
|
279
|
+
if (stale) payload.stale = stale;
|
|
280
|
+
return reply(id, { content: [{ type: 'text', text: JSON.stringify(payload) }] });
|
|
281
|
+
} catch { /* fall through to the spawned artifact */ }
|
|
282
|
+
}
|
|
283
|
+
// Fast path: concept search answers in-process from the cached graph (same ranking as the CLI
|
|
284
|
+
// via find-core; budget applied identically). Any surprise falls back to the spawned artifact.
|
|
285
|
+
if (tool.name === 'codeweb_find') {
|
|
286
|
+
try {
|
|
287
|
+
const { graph, index } = cachedGraph(resolve(graphPath));
|
|
288
|
+
const { qtoks, results } = findSymbols(graph, index, String(args.query || ''));
|
|
289
|
+
if (qtoks.length) {
|
|
290
|
+
const limit = args.limit != null ? Number(args.limit) : (args.full ? Infinity : tool.budget.value);
|
|
291
|
+
const offset = args.offset != null ? Math.max(0, Number(args.offset)) : 0;
|
|
292
|
+
const items = results.slice(offset, Number.isFinite(limit) ? offset + limit : undefined);
|
|
293
|
+
const domains = [...new Set(results.map((r) => r.domain))];
|
|
294
|
+
const payload = {
|
|
295
|
+
query: args.query, terms: qtoks,
|
|
296
|
+
summary: `"${args.query}": ${results.length} match(es) across ${domains.length} domain(s)${results.length ? ` — top: ${results[0].id}` : ''}`,
|
|
297
|
+
results: items, count: results.length, domains: domains.slice(0, 8),
|
|
298
|
+
};
|
|
299
|
+
const remaining = results.length - offset - items.length;
|
|
300
|
+
if (remaining > 0) payload.more = { remaining, nextOffset: offset + items.length };
|
|
301
|
+
const stale = checkStaleness(graph);
|
|
302
|
+
if (stale) { payload.stale = stale; payload.summary += ` — graph is stale for ${stale.count}+ file(s); run codeweb_refresh`; }
|
|
303
|
+
return reply(id, { content: [{ type: 'text', text: JSON.stringify(payload) }] });
|
|
304
|
+
}
|
|
305
|
+
} catch { /* fall through to the spawned artifact */ }
|
|
306
|
+
}
|
|
307
|
+
// Fast path: structural queries answer in-process from the cached graph (same payloads as the
|
|
308
|
+
// CLI via query-core). Any surprise falls back to the spawned, tested artifact.
|
|
309
|
+
const qkind = QUERY_KIND[tool.name];
|
|
310
|
+
if (qkind) {
|
|
311
|
+
try {
|
|
312
|
+
const { graph, index } = cachedGraph(resolve(graphPath));
|
|
313
|
+
const limit = args.limit != null ? Number(args.limit) : (args.full ? null : tool.budget.value);
|
|
314
|
+
const offset = args.offset != null ? Number(args.offset) : 0;
|
|
315
|
+
const { payload, code } = runQuery(graph, index, { query: qkind, symbol: args.symbol, limit, offset });
|
|
316
|
+
if (code === 0 || payload) {
|
|
317
|
+
const stale = payload.found === false ? null : checkStaleness(graph);
|
|
318
|
+
if (stale) {
|
|
319
|
+
payload.stale = stale;
|
|
320
|
+
if (payload.summary) payload.summary += ` — graph is stale for ${stale.count}+ file(s); run codeweb_refresh`;
|
|
321
|
+
}
|
|
322
|
+
return reply(id, { content: [{ type: 'text', text: JSON.stringify(payload) }] });
|
|
323
|
+
}
|
|
324
|
+
} catch { /* fall through to the spawned artifact */ }
|
|
325
|
+
}
|
|
326
|
+
cliArgs.push(...tool.argv(args));
|
|
327
|
+
// budget injection: default top-N unless the caller set the budget arg explicitly or asked full
|
|
328
|
+
if (tool.budget) {
|
|
329
|
+
const explicit = args[tool.budget.arg];
|
|
330
|
+
if (explicit != null) cliArgs.push(tool.budget.flag, String(explicit));
|
|
331
|
+
else if (!args.full) cliArgs.push(tool.budget.flag, String(tool.budget.value));
|
|
332
|
+
if (args.offset != null && (tool.opt || []).includes('offset')) cliArgs.push('--offset', String(args.offset));
|
|
333
|
+
}
|
|
334
|
+
const bin = tool.bin || QUERY;
|
|
335
|
+
const r = spawnSync(process.execPath, [bin, ...cliArgs, '--json'], { encoding: 'utf8', maxBuffer: 1 << 28, timeout: SPAWN_TIMEOUT_MS, input: tool.input ? tool.input(args) : undefined });
|
|
336
|
+
if (r.error && r.error.code === 'ETIMEDOUT') return errResult(id, `tool timed out after ${SPAWN_TIMEOUT_MS / 1000}s`);
|
|
337
|
+
if (r.status === 2 || r.error) {
|
|
338
|
+
const text = (r.stderr || (r.error && r.error.message) || 'query failed').trim();
|
|
339
|
+
// the CLIs' graph-not-found message gains the MCP-native next step
|
|
340
|
+
return errResult(id, /graph not found/.test(text) ? `${text}\n${NO_GRAPH}` : text);
|
|
341
|
+
}
|
|
342
|
+
// exit 0 (results) or 1 (found:false / gate-fail) both emit valid JSON on stdout — pass through.
|
|
343
|
+
return reply(id, { content: [{ type: 'text', text: (r.stdout || '').trim() }] });
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function handle(line) {
|
|
347
|
+
const s = line.trim();
|
|
348
|
+
if (!s) return;
|
|
349
|
+
let msg;
|
|
350
|
+
try { msg = JSON.parse(s); } catch { return fail(null, -32700, 'Parse error'); }
|
|
351
|
+
const { id, method, params } = msg;
|
|
352
|
+
if (id === undefined || id === null) return; // JSON-RPC notification: never responded to
|
|
353
|
+
if (method === 'initialize') {
|
|
354
|
+
const wanted = params && params.protocolVersion;
|
|
355
|
+
return reply(id, {
|
|
356
|
+
protocolVersion: PROTOCOLS.has(wanted) ? wanted : DEFAULT_PROTOCOL,
|
|
357
|
+
capabilities: { tools: {} },
|
|
358
|
+
serverInfo: SERVER,
|
|
359
|
+
instructions: INSTRUCTIONS,
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
if (method === 'ping') return reply(id, {});
|
|
363
|
+
if (method === 'tools/list') return reply(id, { tools: TOOLS.map((t) => ({ name: t.name, description: t.description, inputSchema: schema(t) })) });
|
|
364
|
+
if (method === 'tools/call') return handleToolCall(id, params);
|
|
365
|
+
return fail(id, -32601, `method not found: ${method}`);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const rl = createInterface({ input: process.stdin });
|
|
369
|
+
rl.on('line', handle);
|
|
370
|
+
rl.on('close', () => process.exit(0));
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// codeweb optimize — report-only consolidation advisor. Reads a graph.json whose overlaps[] were
|
|
3
|
+
// already computed by overlap.mjs, ranks the body-confirmed duplicate-logic findings into
|
|
4
|
+
// consolidation OPPORTUNITIES, and PRE-FLIGHTS each proposed merge against the regression gate's
|
|
5
|
+
// own file-cycle check — WITHOUT editing any source. This is the "gate -> optimizer" step 1: it
|
|
6
|
+
// turns the pass/fail gate (diff.mjs) into a periodic advisor that says WHAT to consolidate and
|
|
7
|
+
// whether the gate would accept it. No code is written. Built on ./lib/graph-ops.mjs so the
|
|
8
|
+
// call/cycle primitives live ONCE (codeweb dogfooding its own anti-duplication mission).
|
|
9
|
+
//
|
|
10
|
+
// Usage: node optimize.mjs <graph.json> [--json] [--out <optimize.md>] (or set CODEWEB_WS)
|
|
11
|
+
//
|
|
12
|
+
// Tiers (per finding):
|
|
13
|
+
// ready — body-confirmed high (bodySim >= 0.6), not drifted, kind duplicate-logic, AND the
|
|
14
|
+
// simulated merge adds NO new file cycle: the gate would pass (exit 0), duplication -1.
|
|
15
|
+
// blocked — the simulated naive merge WOULD introduce a new file-level dependency cycle: the gate
|
|
16
|
+
// would reject it (exit 1). Needs a neutral consolidation home, not a blind merge.
|
|
17
|
+
// review — drifted copies, merely-structural confidence (bodySim null), or a non-duplicate-logic
|
|
18
|
+
// finding (parallel-impl / shared-responsibility): needs human or agent judgement.
|
|
19
|
+
//
|
|
20
|
+
// Precision over recall: low/refuted findings are excluded outright. Advisory only — exits 0 on a
|
|
21
|
+
// clean read regardless of how many opportunities exist (it ADVISES; diff.mjs is the gate). Exit:
|
|
22
|
+
// 0 ok, 2 usage/IO.
|
|
23
|
+
|
|
24
|
+
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
25
|
+
import { resolve } from 'node:path';
|
|
26
|
+
import { normalizeGraph, buildIndex, callersOf, impactOf, fileCycles, applyEdit, chooseCanonical } from './lib/graph-ops.mjs';
|
|
27
|
+
|
|
28
|
+
const USAGE = 'usage: optimize.mjs <graph.json> [--json] [--out <optimize.md>] (or set CODEWEB_WS)';
|
|
29
|
+
const READY_BODYSIM = 0.6; // body-confirmed "high" floor — must match overlap.mjs's confidence band
|
|
30
|
+
import { die, emitJson, finish, loadGraph } from './lib/cli.mjs';
|
|
31
|
+
|
|
32
|
+
const argv = process.argv.slice(2);
|
|
33
|
+
let json = false, outMd = null; const paths = [];
|
|
34
|
+
for (let i = 0; i < argv.length; i++) {
|
|
35
|
+
const t = argv[i];
|
|
36
|
+
if (t === '--json') json = true;
|
|
37
|
+
else if (t === '--out') outMd = argv[++i];
|
|
38
|
+
else if (!t.startsWith('-')) paths.push(t);
|
|
39
|
+
}
|
|
40
|
+
const graphPath = paths[0] || (process.env.CODEWEB_WS ? `${process.env.CODEWEB_WS}/graph.json` : null);
|
|
41
|
+
if (!graphPath) die(USAGE, 2);
|
|
42
|
+
|
|
43
|
+
const { graph, abs } = loadGraph(graphPath, { usage: USAGE });
|
|
44
|
+
|
|
45
|
+
const index = buildIndex(graph);
|
|
46
|
+
const cycKey = (c) => c.join('|');
|
|
47
|
+
const beforeCycles = new Set(fileCycles(graph).map(cycKey));
|
|
48
|
+
|
|
49
|
+
// chooseCanonical now lives in ./lib/graph-ops.mjs (shared with codemod.mjs — one truth).
|
|
50
|
+
|
|
51
|
+
// actionable findings only: precision gate drops low/refuted (overlap.mjs's own "findings" set).
|
|
52
|
+
const candidates = graph.overlaps.filter((o) => o.confidence === 'high' || o.confidence === 'medium');
|
|
53
|
+
|
|
54
|
+
const SEV = { low: 1, medium: 2, high: 3 };
|
|
55
|
+
const opportunities = candidates.map((o) => {
|
|
56
|
+
const bodyHigh = o.bodySim != null && o.bodySim >= READY_BODYSIM && !o.drifted;
|
|
57
|
+
const mergeable = o.kind === 'duplicate-logic';
|
|
58
|
+
|
|
59
|
+
let canonical = null, projectedNewCycles = [], removesNodes = 0, callersRewired = 0, blastRadius = 0, locSaved = 0;
|
|
60
|
+
if (mergeable && o.nodes.length >= 2) {
|
|
61
|
+
canonical = chooseCanonical(index, o.nodes);
|
|
62
|
+
const losers = o.nodes.filter((id) => id !== canonical);
|
|
63
|
+
const sim = applyEdit(graph, { kind: 'merge', ids: o.nodes, into: canonical });
|
|
64
|
+
projectedNewCycles = fileCycles(sim).filter((c) => !beforeCycles.has(cycKey(c)));
|
|
65
|
+
removesNodes = losers.length;
|
|
66
|
+
callersRewired = callersOf(index, losers).length;
|
|
67
|
+
blastRadius = impactOf(index, o.nodes).length;
|
|
68
|
+
locSaved = losers.reduce((s, id) => s + (index.byId.get(id)?.loc || 0), 0);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
let tier;
|
|
72
|
+
if (!mergeable || !bodyHigh) tier = 'review';
|
|
73
|
+
else if (projectedNewCycles.length) tier = 'blocked';
|
|
74
|
+
else tier = 'ready';
|
|
75
|
+
|
|
76
|
+
const gate = tier === 'ready' ? 'would pass (exit 0) — duplication -1'
|
|
77
|
+
: tier === 'blocked' ? 'would block (exit 1) — merge introduces a new file cycle'
|
|
78
|
+
: 'needs review before the gate can judge';
|
|
79
|
+
const recommendation = tier === 'blocked'
|
|
80
|
+
? `Naive merge cycles via ${projectedNewCycles.map((c) => c.join('+')).join(', ')}. Host the canonical \`${o.title.match(/`([^`]+)`/)?.[1] || 'symbol'}\` in a neutral module that neither side depends on, then route both there. ${o.recommendation}`
|
|
81
|
+
: o.recommendation;
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
id: o.id, kind: o.kind, title: o.title, severity: o.severity, confidence: o.confidence,
|
|
85
|
+
bodySim: o.bodySim, drifted: !!o.drifted, tier, gate,
|
|
86
|
+
canonical, nodes: o.nodes, removesNodes, callersRewired, blastRadius, locSaved,
|
|
87
|
+
projectedNewCycles, recommendation,
|
|
88
|
+
};
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const tierRank = { ready: 0, blocked: 1, review: 2 };
|
|
92
|
+
opportunities.sort((a, b) =>
|
|
93
|
+
tierRank[a.tier] - tierRank[b.tier]
|
|
94
|
+
|| SEV[b.severity] - SEV[a.severity]
|
|
95
|
+
|| (b.bodySim ?? -1) - (a.bodySim ?? -1)
|
|
96
|
+
|| b.blastRadius - a.blastRadius
|
|
97
|
+
|| (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
98
|
+
|
|
99
|
+
const count = (t) => opportunities.filter((o) => o.tier === t).length;
|
|
100
|
+
const ready = opportunities.filter((o) => o.tier === 'ready');
|
|
101
|
+
const payload = {
|
|
102
|
+
target: graph.meta?.target || 'target',
|
|
103
|
+
totals: {
|
|
104
|
+
findings: opportunities.length,
|
|
105
|
+
ready: count('ready'), blocked: count('blocked'), review: count('review'),
|
|
106
|
+
duplicationRemovable: ready.length,
|
|
107
|
+
locReclaimable: ready.reduce((s, o) => s + o.locSaved, 0),
|
|
108
|
+
},
|
|
109
|
+
opportunities,
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
// ---- markdown artifact (written alongside overlap.md when --out is given) ----------
|
|
113
|
+
if (outMd) {
|
|
114
|
+
const t0 = payload.totals;
|
|
115
|
+
const item = (o) => {
|
|
116
|
+
const body = o.bodySim != null ? ` · **Body:** ${(o.bodySim * 100).toFixed(0)}%` : '';
|
|
117
|
+
const keep = o.canonical ? `\nKeep \`${o.canonical}\` · removes ${o.removesNodes} copy(ies) · rewires ${o.callersRewired} caller(s) · blast ${o.blastRadius} · ~${o.locSaved} LOC` : '';
|
|
118
|
+
return [`### ${o.id} · [${o.severity.toUpperCase()}] ${o.title}`,
|
|
119
|
+
`**Gate:** ${o.gate}${body} · **Confidence:** ${o.confidence}`, keep, ``, `**→ ${o.recommendation}**`, ``].join('\n');
|
|
120
|
+
};
|
|
121
|
+
const section = (title, blurb, tier) => {
|
|
122
|
+
const items = opportunities.filter((o) => o.tier === tier);
|
|
123
|
+
return [`## ${title}`, '', `_${blurb}_`, '', ...(items.length ? items.map(item) : ['_none_', ''])].join('\n');
|
|
124
|
+
};
|
|
125
|
+
const md = [
|
|
126
|
+
'# codeweb — consolidation advisory',
|
|
127
|
+
'',
|
|
128
|
+
`> **${t0.findings} actionable findings** · ${t0.ready} ready · ${t0.blocked} blocked · ${t0.review} review on **${payload.target}**.`,
|
|
129
|
+
`> Applying all **ready** merges would remove ${t0.duplicationRemovable} duplication finding(s) and reclaim ~${t0.locReclaimable} LOC while keeping the gate green. Advisory only — no code is written; each merge stays a human + gate decision.`,
|
|
130
|
+
'',
|
|
131
|
+
section('Ready — the gate would accept these', 'Body-confirmed ≥60%, not drifted, and the simulated merge stays acyclic. Each is a checklist item.', 'ready'),
|
|
132
|
+
section('Blocked — the gate would reject a naive merge', 'The simulated merge introduces a new file-level dependency cycle. Host the canonical in a neutral module first, then route both sides there.', 'blocked'),
|
|
133
|
+
section('Review — needs human or agent judgement', 'Drifted copies, merely-structural confidence, or non-duplicate-logic findings. Read before acting.', 'review'),
|
|
134
|
+
].join('\n');
|
|
135
|
+
writeFileSync(resolve(outMd), md);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (json) { emitJson(payload); } else {
|
|
139
|
+
|
|
140
|
+
const t = payload.totals;
|
|
141
|
+
console.log(`codeweb optimize: ${payload.target}`);
|
|
142
|
+
console.log(` ${t.findings} actionable findings · ${t.ready} ready · ${t.blocked} blocked · ${t.review} review`);
|
|
143
|
+
console.log(` if all ready merges applied: -${t.duplicationRemovable} duplication finding(s), ~${t.locReclaimable} LOC reclaimed (gate would stay green)`);
|
|
144
|
+
const TAG = { ready: 'READY ', blocked: 'BLOCKED', review: 'REVIEW ' };
|
|
145
|
+
for (const o of opportunities) {
|
|
146
|
+
console.log(`\n[${TAG[o.tier]} ${o.severity.toUpperCase().padEnd(6)} ${o.confidence.padEnd(6)}${o.bodySim != null ? ` ${(o.bodySim * 100).toFixed(0).padStart(3)}%` : ' '}] ${o.id} ${o.title.replace(/`/g, '')}`);
|
|
147
|
+
console.log(` gate: ${o.gate}`);
|
|
148
|
+
if (o.canonical) console.log(` keep \`${o.canonical}\` · removes ${o.removesNodes} copy(ies) · rewires ${o.callersRewired} caller(s) · blast ${o.blastRadius} · ~${o.locSaved} LOC`);
|
|
149
|
+
console.log(` -> ${o.recommendation}`);
|
|
150
|
+
}
|
|
151
|
+
finish();
|
|
152
|
+
}
|