@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,380 @@
|
|
|
1
|
+
// codeweb — overlap detector v2 (body-confirmed)
|
|
2
|
+
// Consumes <WS>/graph.json (post domain-mapping; WS = CODEWEB_WS, default .live), computes the
|
|
3
|
+
// overlap graph, and — when the target source is on disk — CONFIRMS each duplicate against the
|
|
4
|
+
// real function bodies (token-shingle Jaccard via node.line+node.loc). Body similarity is the
|
|
5
|
+
// authoritative confidence; structural corroboration (shared downstream calls + similar LOC) is
|
|
6
|
+
// the fallback when source is unavailable. Writes overlaps[] back into the graph and emits
|
|
7
|
+
// <WS>/overlap.md.
|
|
8
|
+
//
|
|
9
|
+
// Signals:
|
|
10
|
+
// A. Redefinition clusters — same symbol name in >=2 files; body-confirmed when source present.
|
|
11
|
+
// · utility names -> duplicate-logic
|
|
12
|
+
// · CLI-scaffold names -> shared-responsibility (one combined finding)
|
|
13
|
+
// B. Structural twins — different-named fns whose downstream call *names* match.
|
|
14
|
+
//
|
|
15
|
+
// Confidence (with source): body mean >=0.6 high(confirmed) · 0.35-0.6 medium(DRIFTED) ·
|
|
16
|
+
// 0.15-0.35 low · <0.15 refuted (dismissed as coincidental). Precision over recall.
|
|
17
|
+
|
|
18
|
+
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
19
|
+
import { shingles, jaccard } from './lib/shingles.mjs'; // shared body-shingle primitives (one truth)
|
|
20
|
+
import { roleOf } from './lib/graph-ops.mjs'; // v7: code roles — findings scope to product code
|
|
21
|
+
import { sourceReader } from './lib/cli.mjs'; // shared body access (one truth)
|
|
22
|
+
|
|
23
|
+
const WS = process.env.CODEWEB_WS || '.live'; // per-target workspace dir (orchestrator sets this)
|
|
24
|
+
const GRAPH_PATH = `${WS}/graph.json`;
|
|
25
|
+
const OVERLAP_MD = `${WS}/overlap.md`;
|
|
26
|
+
const graph = JSON.parse(readFileSync(GRAPH_PATH, 'utf8'));
|
|
27
|
+
const SOURCE_ROOT = graph.meta?.root || null; // target root recorded at extraction; null => structural fallback
|
|
28
|
+
const HAVE_SOURCE = !!SOURCE_ROOT && existsSync(SOURCE_ROOT);
|
|
29
|
+
|
|
30
|
+
const ENTRYPOINTS = new Set(['main']);
|
|
31
|
+
const SCAFFOLD = new Set(['parseArgs', 'parseArgv', 'usage', 'showHelp', 'printUsage', 'help', 'printHelp', 'cli']);
|
|
32
|
+
// KW / tokenize / jaccard / shingles now imported from ./lib/shingles.mjs (lifted, one truth).
|
|
33
|
+
|
|
34
|
+
// TWIN_JACCARD is a cheap RECALL pre-filter on shared downstream-call names; body-shingle
|
|
35
|
+
// confirmation (below) is the precision gate, so this can be loose. At 0.8 it never fired on
|
|
36
|
+
// real targets (0/516 candidate pairs passed); 0.5 surfaces candidates for body confirmation.
|
|
37
|
+
const TWIN_MIN_OUT = 4, TWIN_JACCARD = 0.5, LOC_CV_TIGHT = 0.4, K = 3;
|
|
38
|
+
// Scale caps (Spec B addendum) — both pairwise passes are quadratic in group size, and monorepo-
|
|
39
|
+
// scale repos (TypeScript: thousands of same-named visitors, hub labels with 1000s of callers)
|
|
40
|
+
// turn them into minutes. Caps are deterministic and REPORTED (md header + finding evidence),
|
|
41
|
+
// never silent: GROUP_CAP samples big same-name groups for body confirmation (sorted by id);
|
|
42
|
+
// TWIN_HUB_CAP skips twin-seeding through labels more-called than any credible twin signal —
|
|
43
|
+
// sharing a mega-hub says nothing about twin-ness (the clusterer strips those hubs for the same
|
|
44
|
+
// reason).
|
|
45
|
+
const GROUP_CAP = 12, TWIN_HUB_CAP = 50, TWIN_PAIR_BUDGET = 200000;
|
|
46
|
+
const SEV = { low: 1, medium: 2, high: 3 };
|
|
47
|
+
const SEV_NAME = ['', 'low', 'medium', 'high'];
|
|
48
|
+
const CONF = { refuted: 0, low: 1, medium: 2, high: 3 };
|
|
49
|
+
|
|
50
|
+
const topDir = (file) => { const s = file.split('/')[0]; return /\.[^.]+$/.test(s) ? '(root)' : s; };
|
|
51
|
+
const domainOf = (n) => (n.domain && !/misc|loose|unassigned/i.test(n.domain) ? n.domain : topDir(n.file));
|
|
52
|
+
const commonDir = (files) => { const parts = files.map((f) => f.split('/').slice(0, -1)); let pre = parts[0] || []; for (const p of parts.slice(1)) { let i = 0; while (i < pre.length && i < p.length && pre[i] === p[i]) i++; pre = pre.slice(0, i); } return pre.length ? pre.join('/') : 'lib'; };
|
|
53
|
+
const band = (n) => (n >= 5 ? 3 : n >= 3 ? 2 : 1);
|
|
54
|
+
const severityFor = (files, domains) => SEV_NAME[Math.max(band(files), band(domains))];
|
|
55
|
+
const cv = (xs) => { const m = xs.reduce((a, b) => a + b, 0) / xs.length; if (!m) return 0; return Math.sqrt(xs.reduce((a, b) => a + (b - m) ** 2, 0) / xs.length) / m; };
|
|
56
|
+
const intersectAll = (sets) => { if (sets.length < 2) return new Set(); let acc = new Set(sets[0]); for (const s of sets.slice(1)) acc = new Set([...acc].filter((x) => s.has(x))); return acc; };
|
|
57
|
+
|
|
58
|
+
// ---- body access (read-only, by line range) ----
|
|
59
|
+
const reader = sourceReader(SOURCE_ROOT);
|
|
60
|
+
// Memoized by node id: Signal A shingles each node once, but Signal-B twin pairs used to
|
|
61
|
+
// re-shingle the same hub-adjacent nodes dozens of times — on monorepo-scale graphs with
|
|
62
|
+
// thousand-line bodies (TypeScript's checker.ts) that WAS the pipeline's wall. Pure caching,
|
|
63
|
+
// zero semantics change.
|
|
64
|
+
const _shingleMemo = new Map();
|
|
65
|
+
// BODY_LINE_CAP (Spec B addendum): thousand-line bodies (TypeScript's checker.ts) yield 10k+
|
|
66
|
+
// element shingle sets, and pairwise Jaccard over those made Signal A alone exceed 9 minutes on
|
|
67
|
+
// a 28k-symbol graph. Bodies longer than the cap shingle their FIRST 400 lines — deterministic,
|
|
68
|
+
// counted, and declared in the md header + affected findings' evidence. Never silent.
|
|
69
|
+
const BODY_LINE_CAP = 400;
|
|
70
|
+
let bodiesCapped = 0;
|
|
71
|
+
const cappedIds = new Set();
|
|
72
|
+
const bodyShingles = (n) => {
|
|
73
|
+
if (_shingleMemo.has(n.id)) return _shingleMemo.get(n.id);
|
|
74
|
+
let body = reader.bodyOf(n);
|
|
75
|
+
if (body != null && (n.loc || 0) > BODY_LINE_CAP) {
|
|
76
|
+
body = body.split('\n').slice(0, BODY_LINE_CAP).join('\n');
|
|
77
|
+
bodiesCapped++; cappedIds.add(n.id);
|
|
78
|
+
}
|
|
79
|
+
const s = body == null ? null : shingles(body, K);
|
|
80
|
+
const out = s && s.size ? s : null;
|
|
81
|
+
_shingleMemo.set(n.id, out);
|
|
82
|
+
return out;
|
|
83
|
+
};
|
|
84
|
+
const bodyConfidence = (nodes) => {
|
|
85
|
+
const sample = nodes.length > GROUP_CAP
|
|
86
|
+
? nodes.slice().sort((a, b) => (a.id < b.id ? -1 : 1)).slice(0, GROUP_CAP)
|
|
87
|
+
: nodes;
|
|
88
|
+
const sets = sample.map(bodyShingles).filter(Boolean);
|
|
89
|
+
if (sets.length < 2) return null;
|
|
90
|
+
const sampled = nodes.length > GROUP_CAP ? { used: sample.length, of: nodes.length } : null;
|
|
91
|
+
const sims = [];
|
|
92
|
+
for (let i = 0; i < sets.length; i++) for (let j = i + 1; j < sets.length; j++) sims.push(jaccard(sets[i], sets[j]));
|
|
93
|
+
// reduce, not Math.min(...sims): a large same-name cluster makes sims O(n^2), and spreading it as
|
|
94
|
+
// call args overflows the stack (express crashed here). reduce is identical in value, spread-free.
|
|
95
|
+
const mean = sims.reduce((a, b) => a + b, 0) / sims.length, min = sims.reduce((a, b) => Math.min(a, b), Infinity);
|
|
96
|
+
const confidence = mean >= 0.6 ? 'high' : mean >= 0.35 ? 'medium' : mean >= 0.15 ? 'low' : 'refuted';
|
|
97
|
+
return { mean, min, confidence, drifted: mean >= 0.35 && mean < 0.6, sampled };
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const isDecl = (file) => /\.d\.ts$/.test(file);
|
|
101
|
+
// v7: findings scope to PRODUCT code by default — duplicated test fixtures are intentional
|
|
102
|
+
// isolation, not consolidation targets ("merge 23 playground text() helpers" was the flagship bad
|
|
103
|
+
// advice). CODEWEB_ALL_ROLES=1 restores the old everything-scope; the skipped count is reported in
|
|
104
|
+
// the .md header (no silent truncation).
|
|
105
|
+
const ALL_ROLES = process.env.CODEWEB_ALL_ROLES === '1';
|
|
106
|
+
const nodeRole = (n) => n.role || roleOf(n.file);
|
|
107
|
+
const allDefs = graph.nodes.filter((n) => n.kind !== 'module' && !isDecl(n.file));
|
|
108
|
+
const defs = ALL_ROLES ? allDefs : allDefs.filter((n) => nodeRole(n) === 'product');
|
|
109
|
+
const nonProductSkipped = allDefs.length - defs.length;
|
|
110
|
+
const byId = new Map(graph.nodes.map((n) => [n.id, n]));
|
|
111
|
+
// reverse call-degree for the interface-pattern check (framework hooks have no in-repo callers)
|
|
112
|
+
const callIn = new Map();
|
|
113
|
+
for (const e of graph.edges) { if (e.kind !== 'call') continue; callIn.set(e.to, (callIn.get(e.to) || 0) + 1); }
|
|
114
|
+
|
|
115
|
+
const outLabels = new Map();
|
|
116
|
+
for (const e of graph.edges) { if (e.kind !== 'call') continue; const to = byId.get(e.to); if (!to) continue; if (!outLabels.has(e.from)) outLabels.set(e.from, new Set()); outLabels.get(e.from).add(to.label); }
|
|
117
|
+
|
|
118
|
+
const _T = process.env.CODEWEB_TIMING === '1'; let _t = performance.now();
|
|
119
|
+
const _mark = (label) => { if (_T) console.error('[overlap-timing] ' + label + ': ' + Math.round(performance.now() - _t) + 'ms'); _t = performance.now(); };
|
|
120
|
+
_mark('setup');
|
|
121
|
+
// ---- Signal A: redefinition clusters ------------------------------------------------
|
|
122
|
+
const byLabel = new Map();
|
|
123
|
+
for (const n of defs) { if (!byLabel.has(n.label)) byLabel.set(n.label, []); byLabel.get(n.label).push(n); }
|
|
124
|
+
|
|
125
|
+
const overlaps = [];
|
|
126
|
+
const scaffoldCluster = [];
|
|
127
|
+
|
|
128
|
+
let _labelsDone = 0;
|
|
129
|
+
for (const [label, nodes] of byLabel) {
|
|
130
|
+
if (_T && ++_labelsDone % 2000 === 0) console.error(`[overlap-timing] signal-A progress: ${_labelsDone} labels, ${Math.round(performance.now() - _t)}ms`);
|
|
131
|
+
const files = [...new Set(nodes.map((n) => n.file))];
|
|
132
|
+
if (files.length < 2) continue;
|
|
133
|
+
if (ENTRYPOINTS.has(label)) continue;
|
|
134
|
+
if (SCAFFOLD.has(label)) { scaffoldCluster.push({ label, nodes, files }); continue; }
|
|
135
|
+
|
|
136
|
+
const domains = [...new Set(nodes.map(domainOf))];
|
|
137
|
+
const locs = nodes.map((n) => n.loc);
|
|
138
|
+
const medLoc = locs.slice().sort((a, b) => a - b)[locs.length >> 1];
|
|
139
|
+
|
|
140
|
+
// INTERFACE PATTERN, not duplication: >=4 same-named implementations of which >=75% have no
|
|
141
|
+
// in-repo caller — a framework contract (bundler plugin hooks, visitors, handlers). "Merge these"
|
|
142
|
+
// is wrong advice; emit a demoted informational finding instead.
|
|
143
|
+
const uncalled = nodes.filter((n) => !(callIn.get(n.id) > 0)).length;
|
|
144
|
+
if (files.length >= 4 && uncalled / nodes.length >= 0.75) {
|
|
145
|
+
overlaps.push({
|
|
146
|
+
kind: 'interface-pattern', confidence: 'low', drifted: false, bodySim: null,
|
|
147
|
+
severity: 'low', rank: files.length,
|
|
148
|
+
title: `\`${label}\` implemented ${files.length}× — framework contract, not duplication`,
|
|
149
|
+
domains, nodes: nodes.map((n) => n.id),
|
|
150
|
+
evidence: `${nodes.length} same-named implementations across ${files.length} files; ${uncalled} have no in-repo caller (invoked by a framework/runner, not by this codebase).`,
|
|
151
|
+
recommendation: `Do NOT merge — these implement a shared interface/hook contract. If the copies share setup logic, extract the shared part; the \`${label}\` entry points stay separate.`,
|
|
152
|
+
});
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// authoritative: body confirmation; fallback: structural corroboration
|
|
157
|
+
if (_T) console.error(`[overlap-timing] entering group "${label}" x${nodes.length} (locs: ${nodes.slice(0, 5).map((n) => n.loc).join(',')})`);
|
|
158
|
+
const _bc0 = _T ? performance.now() : 0;
|
|
159
|
+
const body = HAVE_SOURCE ? bodyConfidence(nodes) : null;
|
|
160
|
+
if (_T && performance.now() - _bc0 > 500) console.error(`[overlap-timing] slow group "${label}" x${nodes.length}: ${Math.round(performance.now() - _bc0)}ms`);
|
|
161
|
+
let confidence, basis;
|
|
162
|
+
if (body) {
|
|
163
|
+
confidence = body.confidence;
|
|
164
|
+
basis = body.drifted
|
|
165
|
+
? `DRIFTED — copies diverged (body avg ${(body.mean * 100).toFixed(0)}%, min ${(body.min * 100).toFixed(0)}%); risk of inconsistent fixes`
|
|
166
|
+
: body.confidence === 'refuted'
|
|
167
|
+
? `body-refuted (avg ${(body.mean * 100).toFixed(0)}%) — same name, different logic; likely coincidental`
|
|
168
|
+
: `body-confirmed (avg ${(body.mean * 100).toFixed(0)}%, min ${(body.min * 100).toFixed(0)}%)`;
|
|
169
|
+
if (body.sampled) basis += `; body sampled ${body.sampled.used}/${body.sampled.of} (large group cap)`;
|
|
170
|
+
if (nodes.some((n) => cappedIds.has(n.id))) basis += `; long bodies shingled on their first ${BODY_LINE_CAP} lines`;
|
|
171
|
+
} else {
|
|
172
|
+
const callSets = nodes.map((n) => outLabels.get(n.id)).filter((s) => s && s.size);
|
|
173
|
+
const shared = intersectAll(callSets);
|
|
174
|
+
const locCV = cv(locs);
|
|
175
|
+
const score = (shared.size > 0 ? 2 : 0) + (locCV < LOC_CV_TIGHT ? 1 : 0);
|
|
176
|
+
confidence = score >= 2 ? 'high' : score === 1 ? 'medium' : 'low';
|
|
177
|
+
basis = `structural: ${shared.size ? `share calls {${[...shared].slice(0, 4).join(', ')}}` : 'no shared calls'}, LOC cv=${locCV.toFixed(2)}`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
overlaps.push({
|
|
181
|
+
kind: 'duplicate-logic', confidence, drifted: !!(body && body.drifted), bodySim: body ? +body.mean.toFixed(3) : null,
|
|
182
|
+
severity: severityFor(files.length, domains.length), rank: files.length * 10 + domains.length,
|
|
183
|
+
title: `\`${label}\` re-implemented in ${files.length} files` + (body && body.drifted ? ' (drifted)' : ''),
|
|
184
|
+
domains, nodes: nodes.map((n) => n.id),
|
|
185
|
+
evidence: `${nodes.length} definitions of \`${label}()\` across ${files.length} files, ${domains.length} domain(s); median ${medLoc} LOC. ${basis}. Sites: ${files.slice(0, 5).join(', ')}${files.length > 5 ? `, +${files.length - 5} more` : ''}`,
|
|
186
|
+
recommendation: body && body.drifted
|
|
187
|
+
? `Reconcile the ${files.length} drifted copies into one \`${label}\` in \`${commonDir(files)}/\` — they have diverged, so pick the correct behaviour deliberately.`
|
|
188
|
+
: `Extract one \`${label}\` into \`${commonDir(files)}/\`; import at the ${files.length} sites; delete the local copies.`,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Combined CLI-scaffold finding
|
|
193
|
+
if (scaffoldCluster.length) {
|
|
194
|
+
const allNodes = scaffoldCluster.flatMap((c) => c.nodes);
|
|
195
|
+
const files = [...new Set(allNodes.map((n) => n.file))];
|
|
196
|
+
const domains = [...new Set(allNodes.map(domainOf))];
|
|
197
|
+
const breakdown = scaffoldCluster.sort((a, b) => b.files.length - a.files.length).map((c) => `\`${c.label}\` ×${c.files.length}`).join(', ');
|
|
198
|
+
// quantify the consolidation: current scaffolding LOC vs one shared module + a small per-script spec
|
|
199
|
+
const currentLoc = allNodes.reduce((s, n) => s + (n.loc || 0), 0);
|
|
200
|
+
const MODULE_LOC = 70, PER_SCRIPT_LOC = 12;
|
|
201
|
+
const estAfter = MODULE_LOC + PER_SCRIPT_LOC * files.length;
|
|
202
|
+
const saved = Math.max(0, currentLoc - estAfter);
|
|
203
|
+
overlaps.push({
|
|
204
|
+
kind: 'shared-responsibility', confidence: 'high', drifted: false, bodySim: null, severity: 'high', rank: 100000 + files.length,
|
|
205
|
+
title: `CLI scaffolding hand-rolled across ${files.length} scripts`,
|
|
206
|
+
domains, nodes: allNodes.map((n) => n.id),
|
|
207
|
+
evidence: `Per-script reimplementation of CLI plumbing: ${breakdown} — ${allNodes.length} functions, ~${currentLoc} LOC across ${files.length} scripts. No shared CLI framework.`,
|
|
208
|
+
recommendation: `Introduce one shared CLI module (~${MODULE_LOC} LOC) exporting a declarative \`parseArgs(spec)\` + \`renderHelp(spec)\`; replace each script's parser with a ~${PER_SCRIPT_LOC}-LOC flag spec. Est. ~${currentLoc} → ~${estAfter} LOC (−${saved}). Preserve per-script behaviour (unknown-arg policy, dest remaps, number validation, repeatable flags) and snapshot-test each parser before deleting the original.`,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
_mark('signal-A (same-name groups + body confirm)');
|
|
213
|
+
// ---- Signal B: structural twins -----------------------------------------------------
|
|
214
|
+
const cand = [...outLabels.entries()].filter(([, s]) => s.size >= TWIN_MIN_OUT);
|
|
215
|
+
const inv = new Map();
|
|
216
|
+
for (const [id, s] of cand) for (const t of s) { if (!inv.has(t)) inv.set(t, []); inv.get(t).push(id); }
|
|
217
|
+
const seenPair = new Set();
|
|
218
|
+
const flagged = new Set(overlaps.flatMap((o) => o.nodes));
|
|
219
|
+
const twins = [];
|
|
220
|
+
// Deterministic global budget on twin-pair enumeration: smallest caller-groups first (a shared
|
|
221
|
+
// callee with 3 callers is strong twin evidence; one with 40 is weak), stop at the budget, and
|
|
222
|
+
// report what was skipped. On monorepo-scale graphs the sum of even sub-cap groups is millions
|
|
223
|
+
// of pairs — bounded here, never silently.
|
|
224
|
+
let hubLabelsSkipped = 0, budgetLabelsSkipped = 0, pairBudget = TWIN_PAIR_BUDGET;
|
|
225
|
+
const invEntries = [...inv.entries()].sort((a, b) => a[1].length - b[1].length || (a[0] < b[0] ? -1 : 1));
|
|
226
|
+
for (const [, callers] of invEntries) {
|
|
227
|
+
if (callers.length > TWIN_HUB_CAP) { hubLabelsSkipped++; continue; } // quadratic + weak evidence — reported below
|
|
228
|
+
const groupPairs = (callers.length * (callers.length - 1)) / 2;
|
|
229
|
+
if (groupPairs > pairBudget) { budgetLabelsSkipped++; continue; }
|
|
230
|
+
pairBudget -= groupPairs;
|
|
231
|
+
for (let i = 0; i < callers.length; i++) for (let j = i + 1; j < callers.length; j++) {
|
|
232
|
+
const x = callers[i], y = callers[j], key = x < y ? x + '|' + y : y + '|' + x;
|
|
233
|
+
if (seenPair.has(key)) continue; seenPair.add(key);
|
|
234
|
+
const nx = byId.get(x), ny = byId.get(y);
|
|
235
|
+
if (!nx || !ny || nx.file === ny.file || nx.label === ny.label) continue;
|
|
236
|
+
if (flagged.has(x) && flagged.has(y)) continue;
|
|
237
|
+
const sim = jaccard(outLabels.get(x), outLabels.get(y));
|
|
238
|
+
if (sim < TWIN_JACCARD) continue;
|
|
239
|
+
twins.push({ nx, ny, sim, sh: [...outLabels.get(x)].filter((t) => outLabels.get(y).has(t)) });
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
twins.sort((a, b) => b.sim - a.sim);
|
|
243
|
+
// De-duplicate by LABEL PAIR before emitting: several `<module>` nodes (one per file) pairing with
|
|
244
|
+
// the same function used to yield N findings with byte-identical titles ("X and <module> call the
|
|
245
|
+
// same 63% of helpers" ×3 — pure noise). Keep the highest-similarity pair per label pair; fold the
|
|
246
|
+
// other members into that finding's node list so nothing is silently dropped.
|
|
247
|
+
const byLabelPair = new Map();
|
|
248
|
+
for (const t of twins) {
|
|
249
|
+
const key = [t.nx.label, t.ny.label].sort().join('');
|
|
250
|
+
const cur = byLabelPair.get(key);
|
|
251
|
+
if (!cur) byLabelPair.set(key, { ...t, extraNodes: [] });
|
|
252
|
+
else cur.extraNodes.push(t.nx.id, t.ny.id);
|
|
253
|
+
}
|
|
254
|
+
const twinGroups = [...byLabelPair.values()];
|
|
255
|
+
_mark('signal-B (twin enumeration)');
|
|
256
|
+
// Body-confirm each twin like Signal A: a shared downstream-call shape is only suggestive —
|
|
257
|
+
// confirm against the real bodies (token-shingle Jaccard). Body sim becomes the authoritative
|
|
258
|
+
// confidence, so genuine parallel impls rank up, drifted copies are flagged, and pairs that
|
|
259
|
+
// merely call the same helpers but implement different logic get demoted/dismissed as coincidental.
|
|
260
|
+
for (const t of twinGroups.slice(0, 16)) {
|
|
261
|
+
const domains = [...new Set([domainOf(t.nx), domainOf(t.ny)])];
|
|
262
|
+
const body = HAVE_SOURCE ? bodyConfidence([t.nx, t.ny]) : null;
|
|
263
|
+
let confidence, drifted = false, bodySim = null, basis;
|
|
264
|
+
if (body) {
|
|
265
|
+
confidence = body.confidence;
|
|
266
|
+
drifted = body.drifted;
|
|
267
|
+
bodySim = +body.mean.toFixed(3);
|
|
268
|
+
basis = body.confidence === 'refuted'
|
|
269
|
+
? `bodies only ${(body.mean * 100).toFixed(0)}% similar — parallel call shape but distinct logic; likely coincidental`
|
|
270
|
+
: drifted
|
|
271
|
+
? `bodies ${(body.mean * 100).toFixed(0)}% similar — parallel implementations that have drifted`
|
|
272
|
+
: `bodies ${(body.mean * 100).toFixed(0)}% similar — confirmed parallel implementation`;
|
|
273
|
+
} else {
|
|
274
|
+
confidence = 'medium';
|
|
275
|
+
basis = 'structural only (body unreadable / source absent): matched on downstream call names';
|
|
276
|
+
}
|
|
277
|
+
const groupNodes = [...new Set([t.nx.id, t.ny.id, ...t.extraNodes])].sort();
|
|
278
|
+
const alsoNote = t.extraNodes.length ? ` (+${new Set(t.extraNodes).size} more same-shaped pairing(s), folded into this finding)` : '';
|
|
279
|
+
overlaps.push({
|
|
280
|
+
kind: 'parallel-impl', confidence, drifted, bodySim, severity: severityFor(2, domains.length),
|
|
281
|
+
rank: Math.round((bodySim != null ? bodySim : t.sim) * 5),
|
|
282
|
+
title: `\`${t.nx.label}\` and \`${t.ny.label}\` call the same ${Math.round(t.sim * 100)}% of helpers` + (drifted ? ' (drifted)' : ''),
|
|
283
|
+
domains, nodes: groupNodes,
|
|
284
|
+
evidence: `${t.nx.id} and ${t.ny.id} share downstream calls (name-Jaccard ${t.sim.toFixed(2)}): {${t.sh.slice(0, 6).join(', ')}}${alsoNote}. ${basis}.`,
|
|
285
|
+
recommendation: body && body.confidence === 'refuted'
|
|
286
|
+
? 'Despite the shared call shape the bodies differ — probably not the same logic; verify before merging.'
|
|
287
|
+
: 'Compare the two; if behaviour matches, keep one and route both call sites through it.',
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
_mark('signal-B (twin body confirm)');
|
|
292
|
+
// ---- Signal C: Type-3 (near-miss) clones — Spec H -----------------------------------
|
|
293
|
+
// Statement-fingerprint multisets (node.t3, emitted by the AST tier) pair bodies that share
|
|
294
|
+
// >=70% of their normalized statements — reorderings and small insertions the shingle and
|
|
295
|
+
// skeleton passes cannot see. REVIEW-only by construction (kind != duplicate-logic keeps them
|
|
296
|
+
// out of optimize's READY tier). Bounded: hashes owned by >20 nodes seed nothing (boilerplate
|
|
297
|
+
// statements are noise + quadratic dynamite), and pairs already flagged above are skipped.
|
|
298
|
+
const T3_JACCARD = 0.7, T3_OWNER_CAP = 20;
|
|
299
|
+
{
|
|
300
|
+
const t3Nodes = defs.filter((n) => Array.isArray(n.t3) && n.t3.length >= 6 && (n.kind === 'function' || n.kind === 'method'));
|
|
301
|
+
const countsOf = new Map(t3Nodes.map((n) => {
|
|
302
|
+
const m = new Map();
|
|
303
|
+
for (const h of n.t3) m.set(h, (m.get(h) || 0) + 1);
|
|
304
|
+
return [n.id, m];
|
|
305
|
+
}));
|
|
306
|
+
const owners = new Map(); // hash -> node ids
|
|
307
|
+
for (const n of t3Nodes) for (const h of new Set(n.t3)) { if (!owners.has(h)) owners.set(h, []); owners.get(h).push(n.id); }
|
|
308
|
+
const nById = new Map(t3Nodes.map((n) => [n.id, n]));
|
|
309
|
+
const pairShared = new Map(); // "a|b" -> shared multiset count
|
|
310
|
+
for (const [h, ids] of owners) {
|
|
311
|
+
if (ids.length > T3_OWNER_CAP) continue;
|
|
312
|
+
for (let i = 0; i < ids.length; i++) for (let j = i + 1; j < ids.length; j++) {
|
|
313
|
+
const a = ids[i] < ids[j] ? ids[i] : ids[j], b = ids[i] < ids[j] ? ids[j] : ids[i];
|
|
314
|
+
const key = a + '|' + b;
|
|
315
|
+
const inc = Math.min(countsOf.get(a).get(h) || 0, countsOf.get(b).get(h) || 0);
|
|
316
|
+
pairShared.set(key, (pairShared.get(key) || 0) + inc);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
const flaggedT3 = new Set(overlaps.flatMap((o) => o.nodes));
|
|
320
|
+
const t3Findings = [];
|
|
321
|
+
for (const [key, shared] of pairShared) {
|
|
322
|
+
const [aId, bId] = key.split('|');
|
|
323
|
+
const a = nById.get(aId), b = nById.get(bId);
|
|
324
|
+
if (a.label === b.label) continue; // same-name territory is Signal A's
|
|
325
|
+
if (flaggedT3.has(aId) && flaggedT3.has(bId)) continue;
|
|
326
|
+
const union = a.t3.length + b.t3.length - shared;
|
|
327
|
+
const sim = union ? shared / union : 0;
|
|
328
|
+
if (sim < T3_JACCARD) continue;
|
|
329
|
+
t3Findings.push({ a, b, shared, sim });
|
|
330
|
+
}
|
|
331
|
+
t3Findings.sort((x, y) => y.sim - x.sim || (x.a.id + x.b.id < y.a.id + y.b.id ? -1 : 1));
|
|
332
|
+
for (const f of t3Findings.slice(0, 24)) {
|
|
333
|
+
const domains = [...new Set([domainOf(f.a), domainOf(f.b)])];
|
|
334
|
+
overlaps.push({
|
|
335
|
+
kind: 'near-miss-clone', confidence: 'medium', drifted: false, bodySim: +f.sim.toFixed(3),
|
|
336
|
+
severity: 'medium', rank: Math.round(f.sim * 4),
|
|
337
|
+
title: `\`${f.a.label}\` and \`${f.b.label}\` are near-miss clones (${Math.round(f.sim * 100)}% shared statements)`,
|
|
338
|
+
domains, nodes: [f.a.id, f.b.id].sort(),
|
|
339
|
+
evidence: `${f.shared}/${f.a.t3.length + f.b.t3.length - f.shared} statement fingerprints shared (identifier/literal-normalized, order-independent) between ${f.a.id} (${f.a.t3.length} stmts) and ${f.b.id} (${f.b.t3.length} stmts) — reordered or lightly-edited copies the exact/Type-2 passes cannot see.`,
|
|
340
|
+
recommendation: `Read both bodies side by side (REVIEW — never auto-merge a near-miss): if the differences are accidental, reconcile deliberately and keep one; if intentional, a comment naming why prevents the next reader re-unifying them.`,
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
_mark('signal-C (near-miss)');
|
|
346
|
+
// ---- rank, split, write -------------------------------------------------------------
|
|
347
|
+
overlaps.sort((a, b) => SEV[b.severity] - SEV[a.severity] || CONF[b.confidence] - CONF[a.confidence] || b.rank - a.rank);
|
|
348
|
+
overlaps.forEach((o, i) => { o.id = 'ov' + (i + 1); delete o.rank; });
|
|
349
|
+
graph.overlaps = overlaps;
|
|
350
|
+
writeFileSync(GRAPH_PATH, JSON.stringify(graph));
|
|
351
|
+
|
|
352
|
+
const patternFindings = overlaps.filter((o) => o.kind === 'interface-pattern');
|
|
353
|
+
const findings = overlaps.filter((o) => o.kind !== 'interface-pattern' && (o.confidence === 'high' || o.confidence === 'medium'));
|
|
354
|
+
const unverified = overlaps.filter((o) => o.kind !== 'interface-pattern' && o.confidence === 'low');
|
|
355
|
+
const dismissed = overlaps.filter((o) => o.confidence === 'refuted');
|
|
356
|
+
const fmt = (o) => {
|
|
357
|
+
const syms = o.nodes.slice(0, 10).map((id) => ' - `' + id + '`').join('\n') + (o.nodes.length > 10 ? `\n - …+${o.nodes.length - 10} more` : '');
|
|
358
|
+
return [`### ${o.id} · [${o.severity.toUpperCase()}] ${o.title}`, `**Kind:** ${o.kind} · **Confidence:** ${o.confidence}${o.bodySim != null ? ` (body ${(o.bodySim * 100).toFixed(0)}%)` : ''} · **Domains:** ${o.domains.join(', ')}`, ``, o.evidence, ``, `**→ ${o.recommendation}**`, ``, `<details><summary>${o.nodes.length} symbols</summary>`, ``, syms, `</details>`, ``].join('\n');
|
|
359
|
+
};
|
|
360
|
+
const md = [
|
|
361
|
+
'# codeweb — overlap / consolidation opportunities',
|
|
362
|
+
'',
|
|
363
|
+
`> **${findings.length} findings** · ${patternFindings.length} interface patterns · ${unverified.length} unverified · ${dismissed.length} dismissed (body-refuted) on **${graph.meta?.target || 'target'}**.`,
|
|
364
|
+
`> ${HAVE_SOURCE ? 'Confidence is **body-confirmed** (token-shingle similarity of real function bodies).' : 'Source unavailable — confidence is structural (shared calls + LOC).'} Each finding is a checklist item.`,
|
|
365
|
+
...(nonProductSkipped ? ['', `> Scope: **product code** — ${nonProductSkipped} test/fixture/example/bench symbols excluded (set CODEWEB_ALL_ROLES=1 to include them).`] : []),
|
|
366
|
+
...(hubLabelsSkipped || budgetLabelsSkipped || bodiesCapped ? ['', `> Scale caps: ${hubLabelsSkipped} hub label(s) (>${TWIN_HUB_CAP} callers) excluded from twin seeding${budgetLabelsSkipped ? `; ${budgetLabelsSkipped} label(s) past the ${TWIN_PAIR_BUDGET.toLocaleString('en-US')}-pair twin budget (smallest groups seeded first)` : ''}${bodiesCapped ? `; ${bodiesCapped} long body/bodies shingled on their first ${BODY_LINE_CAP} lines` : ''}; same-name groups larger than ${GROUP_CAP} body-confirm on a ${GROUP_CAP}-node sample (marked in their evidence). Deterministic, never silent.`] : []),
|
|
367
|
+
'', '## Findings', '', ...findings.map(fmt),
|
|
368
|
+
...(patternFindings.length ? ['## Interface patterns (not duplication)', '', '_Same-named implementations of a framework contract — nothing in-repo calls them. Do not merge._', '', ...patternFindings.map(fmt)] : []),
|
|
369
|
+
'## Unverified candidates', '', '_Borderline body similarity (15–35%); confirm by reading before acting._', '', ...unverified.map(fmt),
|
|
370
|
+
'## Dismissed (body-refuted)', '', '_Same name, <15% body similarity — different logic, not duplication. Listed for transparency (no silent truncation)._', '',
|
|
371
|
+
...dismissed.map((o) => `- ${o.id} \`${o.title.replace(/`/g, '')}\` — body ${(o.bodySim * 100).toFixed(0)}%`),
|
|
372
|
+
].join('\n');
|
|
373
|
+
writeFileSync(OVERLAP_MD, md);
|
|
374
|
+
|
|
375
|
+
// ---- console summary ----------------------------------------------------------------
|
|
376
|
+
const drifted = findings.filter((o) => o.drifted).length;
|
|
377
|
+
console.log(`source: ${HAVE_SOURCE ? 'FOUND — body-confirmed' : 'absent — structural fallback'}`);
|
|
378
|
+
console.log(`findings ${findings.length} (incl. ${drifted} drifted) · unverified ${unverified.length} · dismissed ${dismissed.length}`);
|
|
379
|
+
console.log('--- top findings ---');
|
|
380
|
+
for (const o of findings.slice(0, 14)) console.log(`[${o.severity.toUpperCase().padEnd(6)} ${o.confidence.padEnd(6)}${o.bodySim != null ? ' ' + (o.bodySim * 100).toFixed(0).padStart(3) + '%' : ' '}] ${o.title.replace(/`/g, '')}`);
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// codeweb placement — where should a NEW symbol live, and does it duplicate something? Given the
|
|
3
|
+
// symbols a new function will call (its intended callees), suggest the domain it belongs in (by
|
|
4
|
+
// callee gravity) and the most-established file in that domain, and warn if it duplicates an existing
|
|
5
|
+
// symbol (by name, and — with --body — by body similarity via find-similar). Attacks sprawl +
|
|
6
|
+
// duplication BEFORE a line is committed. Read-only, deterministic. Built on ./lib/graph-ops.mjs.
|
|
7
|
+
//
|
|
8
|
+
// Usage: node placement.mjs <graph.json> --calls <id|label,...> [--name <label>] [--body <file>] [--json]
|
|
9
|
+
// Exit: 0 ok, 2 usage/IO.
|
|
10
|
+
|
|
11
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
12
|
+
import { spawnSync } from 'node:child_process';
|
|
13
|
+
import { resolve, dirname, join } from 'node:path';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
import { normalizeGraph, buildIndex, resolveSymbol } from './lib/graph-ops.mjs';
|
|
16
|
+
|
|
17
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
const USAGE = 'usage: placement.mjs <graph.json> --calls <id|label,...> [--name <label>] [--body <file>] [--json]';
|
|
19
|
+
import { die, emitJson, finish, loadGraph } from './lib/cli.mjs';
|
|
20
|
+
|
|
21
|
+
const argv = process.argv.slice(2);
|
|
22
|
+
let json = false, calls = null, name = null, body = null; const pos = [];
|
|
23
|
+
for (let i = 0; i < argv.length; i++) {
|
|
24
|
+
const t = argv[i];
|
|
25
|
+
if (t === '--json') json = true;
|
|
26
|
+
else if (t === '--calls') calls = argv[++i];
|
|
27
|
+
else if (t === '--name') name = argv[++i];
|
|
28
|
+
else if (t === '--body') body = argv[++i];
|
|
29
|
+
else if (!t.startsWith('-')) pos.push(t);
|
|
30
|
+
}
|
|
31
|
+
const graphPath = pos[0] || (process.env.CODEWEB_WS ? `${process.env.CODEWEB_WS}/graph.json` : null);
|
|
32
|
+
if (!graphPath || calls == null) die(USAGE, 2);
|
|
33
|
+
|
|
34
|
+
const { graph, abs } = loadGraph(graphPath, { usage: USAGE });
|
|
35
|
+
|
|
36
|
+
const index = buildIndex(graph);
|
|
37
|
+
|
|
38
|
+
// resolve --calls entries to node ids; track which entries resolved to nothing
|
|
39
|
+
const entries = calls.split(',').map((s) => s.trim()).filter(Boolean);
|
|
40
|
+
const resolved = []; const unresolved = [];
|
|
41
|
+
for (const e of entries) {
|
|
42
|
+
const ids = resolveSymbol(graph, e);
|
|
43
|
+
if (ids.length) resolved.push(...ids); else unresolved.push(e);
|
|
44
|
+
}
|
|
45
|
+
const calleeIds = [...new Set(resolved)].sort();
|
|
46
|
+
const calleeNodes = calleeIds.map((id) => index.byId.get(id)).filter(Boolean);
|
|
47
|
+
|
|
48
|
+
// suggested domain = plurality domain of resolved callees (tie -> lexicographically smallest)
|
|
49
|
+
let domain = 'unassigned', file = null, rationale;
|
|
50
|
+
if (calleeNodes.length) {
|
|
51
|
+
const counts = new Map();
|
|
52
|
+
for (const n of calleeNodes) counts.set(n.domain, (counts.get(n.domain) || 0) + 1);
|
|
53
|
+
domain = [...counts.entries()].sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))[0][0];
|
|
54
|
+
// suggested file = most-called file in that domain (highest aggregate callIn over its nodes; from
|
|
55
|
+
// the shared index — not a local recount), tie -> lexicographic. Candidates = files of the resolved
|
|
56
|
+
// callees that live in the chosen domain.
|
|
57
|
+
const candFiles = [...new Set(calleeNodes.filter((n) => n.domain === domain).map((n) => n.file))];
|
|
58
|
+
const fileScore = (f) => graph.nodes.filter((n) => n.file === f).reduce((s, n) => s + (index.callIn.get(n.id)?.size || 0), 0);
|
|
59
|
+
file = candFiles.map((f) => [f, fileScore(f)]).sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))[0][0];
|
|
60
|
+
const top = [...counts.entries()].sort((a, b) => b[1] - a[1])[0];
|
|
61
|
+
rationale = `${calleeNodes.length} resolved callee(s); ${top[1]} live in '${domain}' (plurality). Suggest co-locating in '${file}', the most depended-on file in that domain.`;
|
|
62
|
+
} else {
|
|
63
|
+
rationale = `none of the ${entries.length} --calls entr${entries.length === 1 ? 'y' : 'ies'} resolved to a known symbol — cannot infer a home from callee gravity (not guessing). Resolve the callees or place by hand.`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// reuse warnings — name (duplication-by-name) and, with --body, body similarity via find-similar.
|
|
67
|
+
const reuseWarnings = [];
|
|
68
|
+
if (name) {
|
|
69
|
+
for (const n of graph.nodes) if (n.label === name) reuseWarnings.push({ kind: 'name', id: n.id, file: n.file, domain: n.domain });
|
|
70
|
+
}
|
|
71
|
+
if (body) {
|
|
72
|
+
const r = spawnSync(process.execPath, [join(HERE, 'find-similar.mjs'), abs, '--body', resolve(body), '--k', '50', '--json'], { encoding: 'utf8', maxBuffer: 1 << 28 });
|
|
73
|
+
if (r.status === 0) {
|
|
74
|
+
try {
|
|
75
|
+
for (const m of JSON.parse(r.stdout).matches) if (m.sim >= 0.35) reuseWarnings.push({ kind: 'body', id: m.id, file: m.file, sim: m.sim, tier: m.tier });
|
|
76
|
+
} catch { /* find-similar produced no parseable output — skip body warnings */ }
|
|
77
|
+
}
|
|
78
|
+
// find-similar exit 2 (source unavailable) -> no body warnings, silently degrade to name-only
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const payload = { calls: { resolved: calleeIds, unresolved }, domain, file, rationale, reuseWarnings };
|
|
82
|
+
|
|
83
|
+
if (json) { emitJson(payload); } else {
|
|
84
|
+
|
|
85
|
+
console.log(`placement: ${calleeIds.length} resolved callee(s)${unresolved.length ? `, ${unresolved.length} unresolved` : ''}`);
|
|
86
|
+
console.log(` domain: ${domain}${file ? ` file: ${file}` : ''}`);
|
|
87
|
+
console.log(` ${rationale}`);
|
|
88
|
+
if (reuseWarnings.length) {
|
|
89
|
+
console.log(` ⚠ ${reuseWarnings.length} possible reuse target(s) — consider reusing instead of writing new:`);
|
|
90
|
+
for (const w of reuseWarnings) console.log(` [${w.kind}${w.sim != null ? ` ${(w.sim * 100).toFixed(0)}%` : ''}] ${w.id}`);
|
|
91
|
+
}
|
|
92
|
+
finish();
|
|
93
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// codeweb structural query CLI: answer graph questions an agent needs to ground itself in a repo
|
|
3
|
+
// BEFORE/while editing — who calls X, what X calls, the blast-radius of changing X, dependency
|
|
4
|
+
// cycles, and dead-code orphans. Read-only over graph.json (see references/graph-schema.md); never
|
|
5
|
+
// writes, never executes target code. Graph primitives live in ./lib/graph-ops.mjs.
|
|
6
|
+
//
|
|
7
|
+
// Usage:
|
|
8
|
+
// node query.mjs [graph.json] --callers <symbol>
|
|
9
|
+
// node query.mjs [graph.json] --callees <symbol>
|
|
10
|
+
// node query.mjs [graph.json] --impact <symbol> # transitive reverse-call closure
|
|
11
|
+
// node query.mjs [graph.json] --cycles # file-level dependency cycles (SCC >= 2)
|
|
12
|
+
// node query.mjs [graph.json] --orphans # no callers AND not exported
|
|
13
|
+
// ... add --json for machine-readable, deterministic (sorted) output.
|
|
14
|
+
//
|
|
15
|
+
// <symbol> is a node id (`file:label`) or a bare label; a bare label matching multiple nodes
|
|
16
|
+
// operates on the union of all matches (reported in `matched`).
|
|
17
|
+
// Exit codes: 0 = success (even if empty), 1 = symbol not found, 2 = usage / IO error.
|
|
18
|
+
|
|
19
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
20
|
+
import { resolve, join } from 'node:path';
|
|
21
|
+
import { normalizeGraph, buildIndex } from './lib/graph-ops.mjs';
|
|
22
|
+
import { runQuery } from './lib/query-core.mjs'; // payload assembly lives once (CLI + in-process MCP)
|
|
23
|
+
|
|
24
|
+
const USAGE = `usage: query.mjs [graph.json] <--callers|--callees|--tests|--dependents|--impact <symbol> | --cycles | --orphans> [--limit N] [--offset N] [--json]`;
|
|
25
|
+
import { die, emitJson, finish, capList, checkStaleness } from './lib/cli.mjs';
|
|
26
|
+
|
|
27
|
+
function parseArgs(argv) {
|
|
28
|
+
const o = { graph: null, query: null, symbol: null, json: false, help: false, queries: 0, limit: null, offset: 0 };
|
|
29
|
+
const withVal = { '--callers': 'callers', '--callees': 'callees', '--tests': 'tests', '--dependents': 'dependents', '--impact': 'impact' };
|
|
30
|
+
const noVal = { '--cycles': 'cycles', '--orphans': 'orphans' };
|
|
31
|
+
for (let i = 0; i < argv.length; i++) {
|
|
32
|
+
const t = argv[i];
|
|
33
|
+
if (t === '--json') o.json = true;
|
|
34
|
+
else if (t === '--limit') o.limit = Math.max(0, parseInt(argv[++i], 10) || 0);
|
|
35
|
+
else if (t === '--offset') o.offset = Math.max(0, parseInt(argv[++i], 10) || 0);
|
|
36
|
+
else if (t === '--help' || t === '-h') o.help = true;
|
|
37
|
+
else if (t in withVal) {
|
|
38
|
+
o.query = withVal[t]; o.queries++;
|
|
39
|
+
o.symbol = (argv[i + 1] && !argv[i + 1].startsWith('-')) ? argv[++i] : undefined;
|
|
40
|
+
} else if (t in noVal) { o.query = noVal[t]; o.queries++; }
|
|
41
|
+
else if (!t.startsWith('-') && o.graph === null) o.graph = t;
|
|
42
|
+
}
|
|
43
|
+
return o;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
47
|
+
const needsSymbol = ['callers', 'callees', 'tests', 'dependents', 'impact'].includes(opts.query);
|
|
48
|
+
if (opts.help || opts.queries !== 1 || (needsSymbol && !opts.symbol)) die(USAGE, 2);
|
|
49
|
+
|
|
50
|
+
const graphPath = resolve(opts.graph || join('.codeweb', 'graph.json'));
|
|
51
|
+
if (!existsSync(graphPath)) die(`graph not found: ${graphPath}`, 2);
|
|
52
|
+
let raw;
|
|
53
|
+
try { raw = JSON.parse(readFileSync(graphPath, 'utf8')); }
|
|
54
|
+
catch (e) { die(`invalid JSON in ${graphPath}: ${e.message}`, 2); }
|
|
55
|
+
|
|
56
|
+
const graph = normalizeGraph(raw);
|
|
57
|
+
const index = buildIndex(graph);
|
|
58
|
+
|
|
59
|
+
const { payload, code } = runQuery(graph, index, { query: opts.query, symbol: opts.symbol, limit: opts.limit, offset: opts.offset });
|
|
60
|
+
|
|
61
|
+
// awareness: annotate (never block) when the graph no longer matches disk
|
|
62
|
+
const staleInfo = checkStaleness(graph);
|
|
63
|
+
if (staleInfo && payload && payload.found !== false) {
|
|
64
|
+
payload.stale = staleInfo;
|
|
65
|
+
if (payload.summary) payload.summary += ` — graph is stale for ${staleInfo.count}+ file(s); run codeweb_refresh`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (opts.json) { emitJson(payload, code); } else {
|
|
69
|
+
|
|
70
|
+
if (payload.found === false) die(`symbol not found: ${opts.symbol}`, 1);
|
|
71
|
+
const p = payload;
|
|
72
|
+
if (p.query === 'callers' || p.query === 'callees' || p.query === 'tests') {
|
|
73
|
+
const extra = p.matched.length > 1 ? ` (${p.matched.length} matches: ${p.matched.join(', ')})` : '';
|
|
74
|
+
console.log(`${p.query} of ${p.symbol}${extra}: ${p.count}`);
|
|
75
|
+
for (const r of p.results) console.log(` ${r}`);
|
|
76
|
+
} else if (p.query === 'dependents') {
|
|
77
|
+
const extra = p.matched.length > 1 ? ` (${p.matched.length} matches)` : '';
|
|
78
|
+
console.log(`dependents of ${p.symbol}${extra}: ${p.count} (call ${p.byKind.call.length}, import ${p.byKind.import.length}, inherit ${p.byKind.inherit.length}, test ${p.byKind.test.length}, ref ${p.byKind.ref.length})`);
|
|
79
|
+
for (const r of p.results) console.log(` ${r}`);
|
|
80
|
+
} else if (p.query === 'impact') {
|
|
81
|
+
console.log(`impact of ${p.symbol}: ${p.count} functions across ${p.domains.length} domains`);
|
|
82
|
+
if (p.domains.length) console.log(` domains: ${p.domains.join(', ')}`);
|
|
83
|
+
for (const r of p.results) console.log(` ${r}`);
|
|
84
|
+
} else if (p.query === 'cycles') {
|
|
85
|
+
console.log(`${p.count} file-level dependency cycle(s):`);
|
|
86
|
+
for (const c of p.cycles) console.log(` ${c.join(' <-> ')}`);
|
|
87
|
+
} else if (p.query === 'orphans') {
|
|
88
|
+
console.log(`${p.count} orphan(s) — no callers and not exported:`);
|
|
89
|
+
for (const o of p.results) console.log(` ${o.id} [${o.domain}]`);
|
|
90
|
+
}
|
|
91
|
+
if (p.more) console.log(` … +${p.more.remaining} more (rerun with --offset ${p.more.nextOffset})`);
|
|
92
|
+
if (p.stale) console.log(` ⚠ graph is stale for ${p.stale.count}+ file(s) (${p.stale.files.slice(0, 3).join(', ')}…) — run codeweb_refresh`);
|
|
93
|
+
finish(code);
|
|
94
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// codeweb reading-order CLI (F8) — emit a foundations-first reading path for a scope, bounded to a
|
|
3
|
+
// budget. Read-only, deterministic. Built on ./lib/reading-order.mjs.
|
|
4
|
+
//
|
|
5
|
+
// Usage: node reading-order.mjs <graph.json> [--scope domain|file|symbol <value>] [--budget N] [--json]
|
|
6
|
+
// Exit: 0 ok, 2 usage/IO.
|
|
7
|
+
|
|
8
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
9
|
+
import { resolve } from 'node:path';
|
|
10
|
+
import { normalizeGraph } from './lib/graph-ops.mjs';
|
|
11
|
+
import { readingOrder } from './lib/reading-order.mjs';
|
|
12
|
+
|
|
13
|
+
const USAGE = 'usage: reading-order.mjs <graph.json> [--scope domain|file|symbol <value>] [--budget N] [--json]';
|
|
14
|
+
import { die, emitJson, finish, loadGraph } from './lib/cli.mjs';
|
|
15
|
+
|
|
16
|
+
const argv = process.argv.slice(2);
|
|
17
|
+
let json = false, budget = 40, scopeKind = 'all', scopeValue = null; const pos = [];
|
|
18
|
+
for (let i = 0; i < argv.length; i++) {
|
|
19
|
+
const t = argv[i];
|
|
20
|
+
if (t === '--json') json = true;
|
|
21
|
+
else if (t === '--budget') budget = Math.max(1, parseInt(argv[++i], 10) || 40);
|
|
22
|
+
else if (t === '--scope') { scopeKind = argv[++i]; scopeValue = argv[++i]; }
|
|
23
|
+
else if (!t.startsWith('-')) pos.push(t);
|
|
24
|
+
}
|
|
25
|
+
const { graph, abs } = loadGraph(pos[0], { usage: USAGE });
|
|
26
|
+
|
|
27
|
+
const scope = { kind: scopeKind, value: scopeValue };
|
|
28
|
+
const order = readingOrder(graph, { scope, budget });
|
|
29
|
+
const payload = { target: graph.meta?.target || 'target', scope, budget, count: order.length, order };
|
|
30
|
+
|
|
31
|
+
if (json) { emitJson(payload); } else {
|
|
32
|
+
|
|
33
|
+
console.log(`codeweb reading-order: ${order.length} symbol(s)${scopeKind !== 'all' ? ` in ${scopeKind} ${scopeValue}` : ''} — read top-down (foundations first):`);
|
|
34
|
+
order.forEach((o, i) => console.log(` ${String(i + 1).padStart(3)}. ${o.id}\n ${o.why}`));
|
|
35
|
+
finish();
|
|
36
|
+
}
|