@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,319 @@
|
|
|
1
|
+
// codeweb shared graph primitives — pure functions over a graph.json object (see graph-schema.md).
|
|
2
|
+
// Imported by scripts/query.mjs and scripts/diff.mjs so the call/cycle/orphan logic lives ONCE
|
|
3
|
+
// (codeweb dogfooding its own anti-duplication mission). No I/O, no process.exit — callers own
|
|
4
|
+
// those. Deterministic: every returned list is sorted.
|
|
5
|
+
|
|
6
|
+
const asArray = (x) => (Array.isArray(x) ? x : []);
|
|
7
|
+
const byIdLt = (a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
8
|
+
|
|
9
|
+
// Test-file predicate (shared by find-similar, the extractor's test-edge classification, and the
|
|
10
|
+
// dead-code workflow — one truth). Matches `*.test.*`, `*.spec.*`, `*_test.*`, or a path segment
|
|
11
|
+
// `tests/` | `test/` | `__tests__/`. Forward-slashed relative paths.
|
|
12
|
+
// Canonical edge identity (from+to+kind) — was re-implemented in break-cycles/diff/shards (Spec E dogfood).
|
|
13
|
+
export const edgeKey = (e) => [e.from, e.to, e.kind].join(String.fromCharCode(0)); // NUL-separated: ids can contain spaces
|
|
14
|
+
|
|
15
|
+
export const isTestFile = (file) =>
|
|
16
|
+
/(?:^|\/)(?:tests?|__tests__|spec)\//.test(file || '') || /(?:\.test\.|\.spec\.|_test\.|_spec\.)/.test(file || '') ||
|
|
17
|
+
/(?:^|\/)src\/test\//.test(file || '') || // Maven/Gradle convention
|
|
18
|
+
/(?:Tests?|Spec)\.(?:java|cs|php|swift|kt)$/.test(file || ''); // FooTest.java / FooTests.swift / FooTest.php …
|
|
19
|
+
|
|
20
|
+
// Code ROLE by path — product code vs the supporting cast (one truth: the extractor stamps it on
|
|
21
|
+
// every node; normalizeGraph back-fills older graphs). Rankings and default report views scope to
|
|
22
|
+
// `product` so monorepo fixture noise can't dominate recommendations.
|
|
23
|
+
export function roleOf(file) {
|
|
24
|
+
const f = file || '';
|
|
25
|
+
if (isTestFile(f)) return 'test';
|
|
26
|
+
if (/(^|\/)(fixtures?|__fixtures__|__mocks__|mocks?)\//.test(f)) return 'fixture';
|
|
27
|
+
if (/(^|\/)(examples?|samples?|demos?|playgrounds?|playground|sandbox|e2e)\//.test(f)) return 'example';
|
|
28
|
+
if (/(^|\/)(benchmarks?|bench|perf)\//.test(f)) return 'bench';
|
|
29
|
+
if (/\.(min|bundle)\.[cm]?js$/.test(f) || /(^|\/)(generated|__generated__)\//.test(f)) return 'generated';
|
|
30
|
+
return 'product';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Spec E: per-repo role OVERRIDES (codeweb.rules.json `roles: [{glob, role}]`) — heuristics can't
|
|
34
|
+
// know a repo's private layout ("docs/ here is generated site output"). Compile the config into a
|
|
35
|
+
// matcher; first matching glob wins; an unknown role THROWS (extraction fails loudly, exit 2).
|
|
36
|
+
export const VALID_ROLES = new Set(['product', 'test', 'fixture', 'example', 'bench', 'generated', 'vendored']);
|
|
37
|
+
|
|
38
|
+
function globToRegex(glob) {
|
|
39
|
+
let re = '';
|
|
40
|
+
for (let i = 0; i < glob.length; i++) {
|
|
41
|
+
const c = glob[i];
|
|
42
|
+
if (c === '*') {
|
|
43
|
+
if (glob[i + 1] === '*') { re += glob[i + 2] === '/' ? '(?:.*/)?' : '.*'; i += glob[i + 2] === '/' ? 2 : 1; }
|
|
44
|
+
else re += '[^/]*';
|
|
45
|
+
} else if (c === '?') re += '[^/]';
|
|
46
|
+
else re += /[.+^${}()|[\]\\]/.test(c) ? '\\' + c : c;
|
|
47
|
+
}
|
|
48
|
+
return new RegExp('^' + re + '$');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Compile `roles` config into (relPath) => role|null. Throws on an invalid entry. */
|
|
52
|
+
export function compileRoleOverrides(roles) {
|
|
53
|
+
if (!roles) return () => null;
|
|
54
|
+
if (!Array.isArray(roles)) throw new Error('codeweb.rules.json: `roles` must be an array of {glob, role}');
|
|
55
|
+
const compiled = roles.map((r, i) => {
|
|
56
|
+
if (!r || typeof r.glob !== 'string' || typeof r.role !== 'string') throw new Error(`codeweb.rules.json roles[${i}]: need {glob, role}`);
|
|
57
|
+
if (!VALID_ROLES.has(r.role)) throw new Error(`codeweb.rules.json roles[${i}] ("${r.glob}"): unknown role "${r.role}" (valid: ${[...VALID_ROLES].join('|')})`);
|
|
58
|
+
return { re: globToRegex(r.glob), role: r.role };
|
|
59
|
+
});
|
|
60
|
+
return (relPath) => { for (const c of compiled) if (c.re.test(relPath)) return c.role; return null; };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Fill the same defaults build-report.mjs applies, so every consumer sees a well-formed graph.
|
|
64
|
+
export function normalizeGraph(graph) {
|
|
65
|
+
const g = graph || {};
|
|
66
|
+
g.meta = g.meta || {};
|
|
67
|
+
g.nodes = asArray(g.nodes);
|
|
68
|
+
g.edges = asArray(g.edges);
|
|
69
|
+
g.domains = asArray(g.domains);
|
|
70
|
+
g.overlaps = asArray(g.overlaps);
|
|
71
|
+
for (const n of g.nodes) {
|
|
72
|
+
if (n.domain == null || n.domain === '') n.domain = 'unassigned';
|
|
73
|
+
if (typeof n.exports !== 'boolean') n.exports = false;
|
|
74
|
+
if (n.file == null) n.file = '';
|
|
75
|
+
if (!n.role) n.role = roleOf(n.file); // pre-v7 graphs: derive
|
|
76
|
+
}
|
|
77
|
+
return g;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Adjacency indexes. callIn/callOut are CALL edges only; hasIncoming tracks any call|import in-edge.
|
|
81
|
+
export function buildIndex(graph) {
|
|
82
|
+
const byId = new Map(graph.nodes.map((n) => [n.id, n]));
|
|
83
|
+
const callIn = new Map();
|
|
84
|
+
const callOut = new Map();
|
|
85
|
+
const inheritIn = new Map(); // reverse inherit: base -> {subclasses}, for impact reachability
|
|
86
|
+
const testIn = new Map(); // F4: reverse `test` edges: prod symbol -> {test nodes exercising it}
|
|
87
|
+
const importIn = new Map(); // reverse `import` edges: symbol -> {symbols that import it}
|
|
88
|
+
const refIn = new Map(); // reverse `ref` edges: class -> {symbols using it via instanceof / static method}
|
|
89
|
+
const hasIncoming = new Set();
|
|
90
|
+
for (const e of graph.edges) {
|
|
91
|
+
if (e.kind === 'call') {
|
|
92
|
+
if (!callIn.has(e.to)) callIn.set(e.to, new Set());
|
|
93
|
+
callIn.get(e.to).add(e.from);
|
|
94
|
+
if (!callOut.has(e.from)) callOut.set(e.from, new Set());
|
|
95
|
+
callOut.get(e.from).add(e.to);
|
|
96
|
+
}
|
|
97
|
+
if (e.kind === 'inherit') {
|
|
98
|
+
if (!inheritIn.has(e.to)) inheritIn.set(e.to, new Set());
|
|
99
|
+
inheritIn.get(e.to).add(e.from);
|
|
100
|
+
}
|
|
101
|
+
if (e.kind === 'test') {
|
|
102
|
+
if (!testIn.has(e.to)) testIn.set(e.to, new Set());
|
|
103
|
+
testIn.get(e.to).add(e.from);
|
|
104
|
+
}
|
|
105
|
+
if (e.kind === 'import') {
|
|
106
|
+
if (!importIn.has(e.to)) importIn.set(e.to, new Set());
|
|
107
|
+
importIn.get(e.to).add(e.from);
|
|
108
|
+
}
|
|
109
|
+
if (e.kind === 'ref') {
|
|
110
|
+
if (!refIn.has(e.to)) refIn.set(e.to, new Set());
|
|
111
|
+
refIn.get(e.to).add(e.from);
|
|
112
|
+
}
|
|
113
|
+
// NOTE: `test` is intentionally EXCLUDED from hasIncoming — a symbol referenced only by tests is
|
|
114
|
+
// still a production orphan (the signal F10 consumes). Production callers also exclude tests.
|
|
115
|
+
if (e.kind === 'call' || e.kind === 'import' || e.kind === 'inherit' || e.kind === 'ref') hasIncoming.add(e.to);
|
|
116
|
+
}
|
|
117
|
+
return { byId, callIn, callOut, inheritIn, testIn, importIn, refIn, hasIncoming };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Resolve a symbol to node ids: exact id wins; else every node whose label matches (sorted).
|
|
121
|
+
export function resolveSymbol(graph, sym) {
|
|
122
|
+
if (graph.nodes.some((n) => n.id === sym)) return [sym];
|
|
123
|
+
return graph.nodes.filter((n) => n.label === sym).map((n) => n.id).sort();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const unionSorted = (ids, adj) => {
|
|
127
|
+
const out = new Set();
|
|
128
|
+
for (const id of ids) for (const x of (adj.get(id) || [])) out.add(x);
|
|
129
|
+
return [...out].sort();
|
|
130
|
+
};
|
|
131
|
+
// Pick the canonical survivor of a merge cluster: most callers (least disruptive to keep), tie ->
|
|
132
|
+
// smallest loc, tie -> lexicographically smallest id. Deterministic. Shared by optimize + codemod.
|
|
133
|
+
export function chooseCanonical(index, ids) {
|
|
134
|
+
return ids.slice().sort((a, b) => {
|
|
135
|
+
const ca = index.callIn.get(a)?.size || 0, cb = index.callIn.get(b)?.size || 0;
|
|
136
|
+
if (cb !== ca) return cb - ca;
|
|
137
|
+
const la = index.byId.get(a)?.loc || 0, lb = index.byId.get(b)?.loc || 0;
|
|
138
|
+
if (la !== lb) return la - lb;
|
|
139
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
140
|
+
})[0];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export const callersOf = (index, ids) => unionSorted(ids, index.callIn);
|
|
144
|
+
export const calleesOf = (index, ids) => unionSorted(ids, index.callOut);
|
|
145
|
+
export const testersOf = (index, ids) => unionSorted(ids, index.testIn); // F4: tests exercising a symbol
|
|
146
|
+
export const importersOf = (index, ids) => unionSorted(ids, index.importIn); // symbols that import a symbol
|
|
147
|
+
export const refsOf = (index, ids) => unionSorted(ids, index.refIn); // symbols using a class via instanceof / static method
|
|
148
|
+
|
|
149
|
+
// Every distinct symbol that DEPENDS on a target, across all in-edge kinds (call ∪ import ∪ inherit ∪
|
|
150
|
+
// test ∪ ref) — the "who would I have to touch if I changed this?" set. Unlike callersOf (call-only),
|
|
151
|
+
// this surfaces cross-file importers, subclasses, and instanceof/static-method users an agent must
|
|
152
|
+
// update on a refactor. Deterministic.
|
|
153
|
+
export const dependentsOf = (index, ids) => {
|
|
154
|
+
const out = new Set();
|
|
155
|
+
for (const adj of [index.callIn, index.importIn, index.inheritIn, index.testIn, index.refIn])
|
|
156
|
+
for (const id of ids) for (const x of (adj.get(id) || [])) out.add(x);
|
|
157
|
+
return [...out].sort();
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
// Fan-in of one node: reverse call edges, optionally + reverse imports (the "how depended-on is
|
|
161
|
+
// this?" number every ranking uses — one definition, so consumers can't drift).
|
|
162
|
+
export const fanInOf = (index, id, withImports = false) =>
|
|
163
|
+
(index.callIn.get(id)?.size || 0) + (withImports ? (index.importIn.get(id)?.size || 0) : 0);
|
|
164
|
+
|
|
165
|
+
// Transitive reverse-call closure (blast radius) from all seeds, excluding the seeds themselves.
|
|
166
|
+
export function impactOf(index, seedIds) {
|
|
167
|
+
const seeds = new Set(seedIds);
|
|
168
|
+
const visited = new Set(seedIds);
|
|
169
|
+
const queue = [...seedIds];
|
|
170
|
+
while (queue.length) {
|
|
171
|
+
const cur = queue.shift();
|
|
172
|
+
// reverse-reachability over callers AND subclasses: changing a node affects what calls it and
|
|
173
|
+
// what inherits from it.
|
|
174
|
+
const upstream = [...(index.callIn.get(cur) || []), ...(index.inheritIn?.get(cur) || [])];
|
|
175
|
+
for (const dep of upstream) {
|
|
176
|
+
if (!visited.has(dep)) { visited.add(dep); queue.push(dep); }
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return [...visited].filter((id) => !seeds.has(id)).sort();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// F5: map changed line-ranges to the symbols they touch, plus blast radius. hunks =
|
|
183
|
+
// [{ file, ranges:[[start,end],...] }]; ranges null/empty = the whole file. A node is "changed"
|
|
184
|
+
// iff its recorded span [line, line+loc-1] intersects a changed range — best-effort, inheriting
|
|
185
|
+
// bodyEnd's clamp limits (can under-select on truncated bodies; documented in review.mjs).
|
|
186
|
+
export function reviewImpact(graph, hunks) {
|
|
187
|
+
const index = buildIndex(graph);
|
|
188
|
+
const byFile = new Map();
|
|
189
|
+
for (const h of hunks) {
|
|
190
|
+
if (!byFile.has(h.file)) byFile.set(h.file, []);
|
|
191
|
+
if (h.ranges == null || h.ranges.length === 0) byFile.get(h.file).push(null);
|
|
192
|
+
else for (const r of h.ranges) byFile.get(h.file).push(r);
|
|
193
|
+
}
|
|
194
|
+
const changed = [];
|
|
195
|
+
for (const n of graph.nodes) {
|
|
196
|
+
const rs = byFile.get(n.file);
|
|
197
|
+
if (!rs) continue;
|
|
198
|
+
const start = n.line, end = n.line + (n.loc || 1) - 1;
|
|
199
|
+
if (rs.some((r) => r == null || !(end < r[0] || start > r[1]))) changed.push(n.id);
|
|
200
|
+
}
|
|
201
|
+
changed.sort();
|
|
202
|
+
const blast = impactOf(index, changed);
|
|
203
|
+
const domainsTouched = [...new Set(changed.map((id) => index.byId.get(id)?.domain || 'unassigned'))].sort();
|
|
204
|
+
const callerCounts = changed.map((id) => ({ id, callers: index.callIn.get(id)?.size || 0 })).sort((a, b) => b.callers - a.callers || (a.id < b.id ? -1 : 1));
|
|
205
|
+
return { changedSymbols: changed, blastRadius: { count: blast.length, ids: blast }, domainsTouched, callerCounts };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// File-level dependency cycles: strongly-connected components of size >= 2 in the file graph built
|
|
209
|
+
// from call+import edges. ITERATIVE Tarjan (explicit work stack) so deep graphs can't overflow the
|
|
210
|
+
// JS call stack. Deterministic: neighbors and the final list are sorted.
|
|
211
|
+
export function fileCycles(graph) {
|
|
212
|
+
const fileOf = new Map(graph.nodes.map((n) => [n.id, n.file]));
|
|
213
|
+
const adj = new Map();
|
|
214
|
+
const files = new Set();
|
|
215
|
+
for (const e of graph.edges) {
|
|
216
|
+
if (e.kind !== 'call' && e.kind !== 'import' && e.kind !== 'inherit' && e.kind !== 'ref') continue;
|
|
217
|
+
const f = fileOf.get(e.from), t = fileOf.get(e.to);
|
|
218
|
+
if (!f || !t || f === t) continue;
|
|
219
|
+
files.add(f); files.add(t);
|
|
220
|
+
if (!adj.has(f)) adj.set(f, new Set());
|
|
221
|
+
adj.get(f).add(t);
|
|
222
|
+
}
|
|
223
|
+
const neigh = (v) => [...(adj.get(v) || [])].sort();
|
|
224
|
+
const idx = new Map(), low = new Map(), onStack = new Set(), sccStack = [];
|
|
225
|
+
const out = [];
|
|
226
|
+
let counter = 0;
|
|
227
|
+
for (const root of [...files].sort()) {
|
|
228
|
+
if (idx.has(root)) continue;
|
|
229
|
+
idx.set(root, counter); low.set(root, counter); counter++; sccStack.push(root); onStack.add(root);
|
|
230
|
+
const work = [{ v: root, ns: neigh(root), i: 0 }];
|
|
231
|
+
while (work.length) {
|
|
232
|
+
const frame = work[work.length - 1];
|
|
233
|
+
if (frame.i < frame.ns.length) {
|
|
234
|
+
const w = frame.ns[frame.i++];
|
|
235
|
+
if (!idx.has(w)) {
|
|
236
|
+
idx.set(w, counter); low.set(w, counter); counter++; sccStack.push(w); onStack.add(w);
|
|
237
|
+
work.push({ v: w, ns: neigh(w), i: 0 });
|
|
238
|
+
} else if (onStack.has(w)) {
|
|
239
|
+
low.set(frame.v, Math.min(low.get(frame.v), idx.get(w)));
|
|
240
|
+
}
|
|
241
|
+
} else {
|
|
242
|
+
if (low.get(frame.v) === idx.get(frame.v)) {
|
|
243
|
+
const comp = []; let w;
|
|
244
|
+
do { w = sccStack.pop(); onStack.delete(w); comp.push(w); } while (w !== frame.v);
|
|
245
|
+
if (comp.length >= 2) out.push(comp.sort());
|
|
246
|
+
}
|
|
247
|
+
work.pop();
|
|
248
|
+
if (work.length) {
|
|
249
|
+
const parent = work[work.length - 1].v;
|
|
250
|
+
low.set(parent, Math.min(low.get(parent), low.get(frame.v)));
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return out.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Dead-code candidates: no incoming call|import edge AND not exported. Sorted by id.
|
|
259
|
+
export function orphans(graph, index) {
|
|
260
|
+
return graph.nodes
|
|
261
|
+
.filter((n) => n.kind !== 'module' && !index.hasIncoming.has(n.id) && !n.exports)
|
|
262
|
+
.map((n) => ({ id: n.id, file: n.file, domain: n.domain }))
|
|
263
|
+
.sort(byIdLt);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Edges-only structural regressions between two snapshots — the fast subset the post-edit hook
|
|
267
|
+
// checks (re-extraction gives nodes+edges, not domains/overlaps): a file-level cycle present in
|
|
268
|
+
// `after` but not `before`, and a symbol that EXISTS in both but lost ALL its callers (deleting a
|
|
269
|
+
// symbol entirely is not a regression — only a surviving-but-orphaned one is). Duplication and
|
|
270
|
+
// coupling deltas need the full pipeline; that stays diff.mjs.
|
|
271
|
+
export function structuralRegressions(before, after) {
|
|
272
|
+
const b = normalizeGraph(before), a = normalizeGraph(after);
|
|
273
|
+
const cycleKey = (c) => c.join('|');
|
|
274
|
+
const beforeCycles = new Set(fileCycles(b).map(cycleKey));
|
|
275
|
+
const newCycles = fileCycles(a).filter((c) => !beforeCycles.has(cycleKey(c)));
|
|
276
|
+
const bi = buildIndex(b), ai = buildIndex(a);
|
|
277
|
+
const afterIds = new Set(a.nodes.map((n) => n.id));
|
|
278
|
+
const lostCallers = [];
|
|
279
|
+
for (const [id, callers] of bi.callIn) {
|
|
280
|
+
if (callers.size && afterIds.has(id) && !(ai.callIn.get(id)?.size)) lostCallers.push(id);
|
|
281
|
+
}
|
|
282
|
+
return { newCycles, lostCallers: lostCallers.sort() };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Pure structural edit simulation — models delete / merge / move on a DEEP COPY of the graph and
|
|
286
|
+
// never mutates its argument, so simulate-edit and optimize build the hypothetical graph through
|
|
287
|
+
// ONE path (codeweb dogfooding "logic lives once"). Returns a normalized graph; overlaps are
|
|
288
|
+
// dropped (they'd be stale — recompute via the pipeline if needed).
|
|
289
|
+
// op = { kind:'delete', ids:[...] } | { kind:'merge', ids:[...], into } | { kind:'move', id, to }
|
|
290
|
+
export function applyEdit(graph, op) {
|
|
291
|
+
const g = normalizeGraph(structuredClone(graph)); // clone first: normalizeGraph mutates, the input must not change
|
|
292
|
+
const base = { meta: g.meta, domains: g.domains, overlaps: [] };
|
|
293
|
+
if (op.kind === 'delete') {
|
|
294
|
+
const drop = new Set(op.ids);
|
|
295
|
+
return normalizeGraph({ ...base,
|
|
296
|
+
nodes: g.nodes.filter((n) => !drop.has(n.id)),
|
|
297
|
+
edges: g.edges.filter((e) => !drop.has(e.from) && !drop.has(e.to)) });
|
|
298
|
+
}
|
|
299
|
+
if (op.kind === 'move') {
|
|
300
|
+
return normalizeGraph({ ...base,
|
|
301
|
+
nodes: g.nodes.map((n) => (n.id === op.id ? { ...n, file: op.to } : n)),
|
|
302
|
+
edges: g.edges });
|
|
303
|
+
}
|
|
304
|
+
if (op.kind === 'merge') {
|
|
305
|
+
const copies = new Set(op.ids);
|
|
306
|
+
const map = (id) => (copies.has(id) ? op.into : id);
|
|
307
|
+
const nodes = g.nodes.filter((n) => !(copies.has(n.id) && n.id !== op.into));
|
|
308
|
+
const seen = new Set(); const edges = [];
|
|
309
|
+
for (const e of g.edges) {
|
|
310
|
+
const from = map(e.from), to = map(e.to);
|
|
311
|
+
if (from === to) continue; // self-loop (incl. merged-into-itself) -> dropped
|
|
312
|
+
const k = `${from} ${to} ${e.kind}`;
|
|
313
|
+
if (seen.has(k)) continue; seen.add(k); // de-dup redirected edges
|
|
314
|
+
edges.push({ from, to, kind: e.kind });
|
|
315
|
+
}
|
|
316
|
+
return normalizeGraph({ ...base, nodes, edges });
|
|
317
|
+
}
|
|
318
|
+
throw new Error(`applyEdit: unknown op kind '${op && op.kind}'`);
|
|
319
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// codeweb hotspot scoring (F4) — rank symbols by refactoring priority: complexity x fan-in x churn
|
|
2
|
+
// (the Tornhill model). "Where do I start" in a huge repo. The formula lives here so hotspots.mjs and
|
|
3
|
+
// its tests share one truth (mirrors lib/risk.mjs). Pure.
|
|
4
|
+
//
|
|
5
|
+
// score = Σ wᵢ · normᵢ, normᵢ = componentᵢ / graph-max(componentᵢ) (0 when the max is 0). Weights are
|
|
6
|
+
// non-negative and sum to 1, so score ∈ [0,1] and is monotonic non-decreasing in each component for
|
|
7
|
+
// fixed maxes (HOT-DOMINANCE).
|
|
8
|
+
|
|
9
|
+
import { buildIndex } from './graph-ops.mjs';
|
|
10
|
+
|
|
11
|
+
export const HOTSPOT_WEIGHTS = { complexity: 0.5, fanIn: 0.3, churn: 0.2 };
|
|
12
|
+
|
|
13
|
+
export function hotspotScore(components, maxes) {
|
|
14
|
+
let s = 0;
|
|
15
|
+
for (const k of Object.keys(HOTSPOT_WEIGHTS)) {
|
|
16
|
+
const m = maxes[k] || 0;
|
|
17
|
+
s += HOTSPOT_WEIGHTS[k] * (m > 0 ? (components[k] || 0) / m : 0);
|
|
18
|
+
}
|
|
19
|
+
return s;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Rank function/method nodes (the ones carrying complexity) by hotspot score. churn: { <relpath>: count }.
|
|
23
|
+
export function rankHotspots(graph, { churn = {} } = {}) {
|
|
24
|
+
const index = buildIndex(graph);
|
|
25
|
+
const comps = graph.nodes
|
|
26
|
+
.filter((n) => n.kind === 'function' || n.kind === 'method')
|
|
27
|
+
.map((n) => ({ id: n.id, file: n.file, domain: n.domain, complexity: n.complexity || 0, fanIn: index.callIn.get(n.id)?.size || 0, churn: churn[n.file] || 0 }));
|
|
28
|
+
const maxes = { complexity: 0, fanIn: 0, churn: 0 };
|
|
29
|
+
for (const c of comps) for (const k of Object.keys(maxes)) maxes[k] = Math.max(maxes[k], c[k]);
|
|
30
|
+
const ranked = comps
|
|
31
|
+
.map((c) => ({ id: c.id, file: c.file, domain: c.domain, score: hotspotScore(c, maxes), components: { complexity: c.complexity, fanIn: c.fanIn, churn: c.churn } }))
|
|
32
|
+
.sort((a, b) => b.score - a.score || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
33
|
+
return { weights: HOTSPOT_WEIGHTS, maxes, count: ranked.length, ranked };
|
|
34
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// codeweb query core — the payload assembly behind query.mjs, extracted so the MCP server can
|
|
2
|
+
// answer structural queries IN-PROCESS against a cached parsed graph (sub-ms after the first call)
|
|
3
|
+
// while the CLI keeps shipping the exact same payloads (one truth, two transports).
|
|
4
|
+
// Pure over (graph, index): no I/O, no process, no exit codes — callers own those.
|
|
5
|
+
|
|
6
|
+
import { buildIndex, resolveSymbol, callersOf, calleesOf, testersOf, importersOf, refsOf, dependentsOf, impactOf, fileCycles, orphans } from './graph-ops.mjs';
|
|
7
|
+
import { capList } from './cli.mjs';
|
|
8
|
+
|
|
9
|
+
// Budgeted list cap: `count` stays the TRUE total, `more` describes the remainder (absent when
|
|
10
|
+
// nothing was cut).
|
|
11
|
+
function budget(payload, field, limit, offset) {
|
|
12
|
+
if (limit == null && !offset) return payload;
|
|
13
|
+
const c = capList(payload[field], limit, offset);
|
|
14
|
+
payload[field] = c.items;
|
|
15
|
+
if (c.remaining > 0) payload.more = { remaining: c.remaining, nextOffset: c.offset + c.items.length };
|
|
16
|
+
return payload;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Confidence calibration for empty dependents/callers answers: "0 results" must not sound equally
|
|
20
|
+
// sure for a public-API symbol (external callers invisible by construction), an exported one, or a
|
|
21
|
+
// symbol in a repo that routes calls dynamically. Returns the caveat string or null.
|
|
22
|
+
function zeroCallerCaveat(graph, index, matched) {
|
|
23
|
+
const nodes = matched.map((id) => index.byId.get(id)).filter(Boolean);
|
|
24
|
+
if (nodes.some((n) => n.pub)) return 'public API (reachable from a package entrypoint) — external callers likely';
|
|
25
|
+
if (nodes.some((n) => n.exports)) return 'exported — external use possible';
|
|
26
|
+
if (graph.meta?.dynamic) return `repo routes calls dynamically in ${graph.meta.dynamic.files} file(s) — absence of callers is weaker evidence`;
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Run one structural query. opts: { query, symbol, limit, offset }.
|
|
32
|
+
* Returns { payload, code } — code 1 = symbol not found (payload carries found:false).
|
|
33
|
+
*/
|
|
34
|
+
export function runQuery(graph, index, opts) {
|
|
35
|
+
const { query, symbol, limit = null, offset = 0 } = opts;
|
|
36
|
+
index = index || buildIndex(graph);
|
|
37
|
+
let payload, code = 0;
|
|
38
|
+
if (query === 'callers' || query === 'callees' || query === 'tests') {
|
|
39
|
+
const matched = resolveSymbol(graph, symbol);
|
|
40
|
+
if (!matched.length) { payload = { query, symbol, found: false }; code = 1; }
|
|
41
|
+
else {
|
|
42
|
+
const results = query === 'callers' ? callersOf(index, matched) : query === 'callees' ? calleesOf(index, matched) : testersOf(index, matched);
|
|
43
|
+
payload = budget({ query, symbol, summary: `${results.length} ${query === 'tests' ? 'test(s) exercise' : query + ' of'} ${symbol}`, matched, results, count: results.length }, 'results', limit, offset);
|
|
44
|
+
if (query === 'callers' && !results.length) {
|
|
45
|
+
const caveat = zeroCallerCaveat(graph, index, matched);
|
|
46
|
+
if (caveat) { payload.caveat = caveat; payload.summary += ` — ⚠ ${caveat}`; }
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
} else if (query === 'dependents') {
|
|
50
|
+
const matched = resolveSymbol(graph, symbol);
|
|
51
|
+
if (!matched.length) { payload = { query: 'dependents', symbol, found: false }; code = 1; }
|
|
52
|
+
else {
|
|
53
|
+
const results = dependentsOf(index, matched);
|
|
54
|
+
const inheritIn = [...new Set(matched.flatMap((id) => [...(index.inheritIn.get(id) || [])]))].sort();
|
|
55
|
+
const byKind = { call: callersOf(index, matched), import: importersOf(index, matched), inherit: inheritIn, test: testersOf(index, matched), ref: refsOf(index, matched) };
|
|
56
|
+
const kindCounts = Object.fromEntries(Object.entries(byKind).map(([k, v]) => [k, v.length]));
|
|
57
|
+
// under a budget, byKind collapses to counts (the full per-kind lists are the bulk of the payload)
|
|
58
|
+
payload = { query: 'dependents', symbol, summary: `${results.length} dependent(s) of ${symbol} (${Object.entries(kindCounts).map(([k, v]) => `${k} ${v}`).join(', ')})`, matched, results, byKind: limit != null ? kindCounts : byKind, count: results.length };
|
|
59
|
+
budget(payload, 'results', limit, offset);
|
|
60
|
+
if (!results.length) {
|
|
61
|
+
const caveat = zeroCallerCaveat(graph, index, matched);
|
|
62
|
+
if (caveat) { payload.caveat = caveat; payload.summary += ` — ⚠ ${caveat}`; }
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
} else if (query === 'impact') {
|
|
66
|
+
const matched = resolveSymbol(graph, symbol);
|
|
67
|
+
if (!matched.length) { payload = { query: 'impact', symbol, found: false }; code = 1; }
|
|
68
|
+
else {
|
|
69
|
+
const results = impactOf(index, matched);
|
|
70
|
+
const domains = [...new Set(results.map((id) => index.byId.get(id)?.domain || 'unassigned'))].sort();
|
|
71
|
+
// under a budget, rank the surviving ids by fan-in — top-N must be the most RELEVANT N.
|
|
72
|
+
const ranked = limit != null
|
|
73
|
+
? results.slice().sort((a, b) => (index.callIn.get(b)?.size || 0) - (index.callIn.get(a)?.size || 0) || (a < b ? -1 : 1))
|
|
74
|
+
: results;
|
|
75
|
+
payload = budget({ query: 'impact', symbol, summary: `editing ${symbol} touches ${results.length} function(s) across ${domains.length} domain(s)`, matched, results: ranked, domains, count: results.length }, 'results', limit, offset);
|
|
76
|
+
}
|
|
77
|
+
} else if (query === 'cycles') {
|
|
78
|
+
const cycles = fileCycles(graph);
|
|
79
|
+
payload = budget({ query: 'cycles', summary: `${cycles.length} file-level dependency cycle(s)`, cycles, count: cycles.length }, 'cycles', limit, offset);
|
|
80
|
+
} else if (query === 'orphans') {
|
|
81
|
+
const results = orphans(graph, index);
|
|
82
|
+
payload = budget({ query: 'orphans', summary: `${results.length} orphan(s) — no callers and not exported`, results, count: results.length }, 'results', limit, offset);
|
|
83
|
+
}
|
|
84
|
+
return { payload, code };
|
|
85
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// codeweb reading-order (F8) — a minimal, dependency-ordered reading path for understanding a scope at
|
|
2
|
+
// any scale: foundations (the depended-upon leaves) first, orchestrators last, bounded to a budget. A
|
|
3
|
+
// curated tour instead of blind grep. Pure, deterministic. Built on lib/graph-ops.mjs.
|
|
4
|
+
//
|
|
5
|
+
// RO-FOUNDATIONS-FIRST: a valid "callee before caller" linearization of the in-scope call DAG — when A
|
|
6
|
+
// calls B and both are emitted, B precedes A. Greedy "fewest unemitted in-scope callees first" gives a
|
|
7
|
+
// reverse-topo order on DAGs (a sink has 0 unemitted callees) and degrades gracefully on cycles
|
|
8
|
+
// (fewest-deps, then highest in-scope fan-in, then id). RO-BUDGET: keeps the first `budget` foundations.
|
|
9
|
+
|
|
10
|
+
import { buildIndex, resolveSymbol } from './graph-ops.mjs';
|
|
11
|
+
|
|
12
|
+
function scopeIdsOf(graph, index, scope) {
|
|
13
|
+
const kind = (scope && scope.kind) || 'all';
|
|
14
|
+
if (kind === 'domain') return graph.nodes.filter((n) => (n.domain || 'unassigned') === scope.value).map((n) => n.id);
|
|
15
|
+
if (kind === 'file') return graph.nodes.filter((n) => n.file === scope.value).map((n) => n.id);
|
|
16
|
+
if (kind === 'symbol') {
|
|
17
|
+
const seeds = resolveSymbol(graph, scope.value);
|
|
18
|
+
const set = new Set(seeds); const q = [...seeds];
|
|
19
|
+
while (q.length) { const cur = q.shift(); for (const c of (index.callOut.get(cur) || [])) if (!set.has(c)) { set.add(c); q.push(c); } }
|
|
20
|
+
return [...set];
|
|
21
|
+
}
|
|
22
|
+
return graph.nodes.filter((n) => n.kind !== 'module').map((n) => n.id); // 'all'
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function readingOrder(graph, { scope = { kind: 'all' }, budget = Infinity } = {}) {
|
|
26
|
+
const index = buildIndex(graph);
|
|
27
|
+
const inScope = new Set(scopeIdsOf(graph, index, scope));
|
|
28
|
+
const calleesIn = new Map(), fanInIn = new Map();
|
|
29
|
+
for (const id of inScope) { calleesIn.set(id, new Set([...(index.callOut.get(id) || [])].filter((x) => inScope.has(x)))); fanInIn.set(id, 0); }
|
|
30
|
+
for (const id of inScope) for (const c of calleesIn.get(id)) fanInIn.set(c, (fanInIn.get(c) || 0) + 1);
|
|
31
|
+
|
|
32
|
+
const emitted = new Set(), order = [];
|
|
33
|
+
const remaining = [...inScope].sort();
|
|
34
|
+
while (remaining.length) {
|
|
35
|
+
let best = null;
|
|
36
|
+
for (const id of remaining) {
|
|
37
|
+
let un = 0; for (const c of calleesIn.get(id)) if (!emitted.has(c)) un++;
|
|
38
|
+
const cand = { id, un, fi: fanInIn.get(id) || 0 };
|
|
39
|
+
if (!best || cand.un < best.un || (cand.un === best.un && (cand.fi > best.fi || (cand.fi === best.fi && cand.id < best.id)))) best = cand;
|
|
40
|
+
}
|
|
41
|
+
emitted.add(best.id); order.push(best.id);
|
|
42
|
+
remaining.splice(remaining.indexOf(best.id), 1);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const out = order.map((id) => {
|
|
46
|
+
const fi = fanInIn.get(id) || 0, co = calleesIn.get(id).size;
|
|
47
|
+
const why = fi === 0
|
|
48
|
+
? (co ? `entry point — orchestrates ${co} in-scope symbol(s)` : 'isolated symbol')
|
|
49
|
+
: `foundation — ${fi} in-scope caller(s)${co ? `, depends on ${co}` : ''}`;
|
|
50
|
+
return { id, why };
|
|
51
|
+
});
|
|
52
|
+
return Number.isFinite(budget) ? out.slice(0, Math.max(0, budget)) : out;
|
|
53
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// reliance — what callers ACTUALLY depend on. The most common breaking edit is changing a
|
|
2
|
+
// return shape while some caller still destructures `.timeout` off it; the call sites are all
|
|
3
|
+
// in the graph, so read the dependency off them and say it in one line: "callers rely on
|
|
4
|
+
// {timeout, retries} · awaited 3/4 · args 1-2". Conservative by construction: only patterns
|
|
5
|
+
// visible on the call-site line count; absence of a report means "nothing detected", never
|
|
6
|
+
// "nothing relied on".
|
|
7
|
+
|
|
8
|
+
import { callersOf } from './graph-ops.mjs';
|
|
9
|
+
|
|
10
|
+
const MAX_SITES = 40;
|
|
11
|
+
const PROMISE_PLUMBING = new Set(['then', 'catch', 'finally']);
|
|
12
|
+
|
|
13
|
+
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
14
|
+
|
|
15
|
+
/** Count top-level commas of the first LABEL(...) call on the line; null when unparsable. */
|
|
16
|
+
function argCount(line, label) {
|
|
17
|
+
const start = line.search(new RegExp(`\\b${escapeRe(label)}\\s*\\(`));
|
|
18
|
+
if (start === -1) return null;
|
|
19
|
+
let i = line.indexOf('(', start);
|
|
20
|
+
let depth = 0, args = 0, sawAny = false, quote = null;
|
|
21
|
+
for (; i < line.length; i++) {
|
|
22
|
+
const ch = line[i];
|
|
23
|
+
if (quote) { if (ch === quote && line[i - 1] !== '\\') quote = null; continue; }
|
|
24
|
+
if (ch === "'" || ch === '"' || ch === '`') { quote = ch; continue; }
|
|
25
|
+
if (ch === '(' || ch === '[' || ch === '{') depth++;
|
|
26
|
+
else if (ch === ')' || ch === ']' || ch === '}') {
|
|
27
|
+
depth--;
|
|
28
|
+
if (depth === 0) return sawAny ? args + 1 : 0;
|
|
29
|
+
} else if (depth === 1) {
|
|
30
|
+
if (ch === ',') args++;
|
|
31
|
+
else if (!/\s/.test(ch)) sawAny = true;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return null; // call spans lines — skip this site for arg counting
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Inspect the call sites of `node` (via its graph callers + on-disk source) and report the
|
|
39
|
+
* observable reliance. Returns null when source is unreadable or no call sites were found.
|
|
40
|
+
*/
|
|
41
|
+
export function callerReliance(graph, index, node, reader) {
|
|
42
|
+
if (!reader?.available || !node?.label) return null;
|
|
43
|
+
const label = node.label;
|
|
44
|
+
const callRe = new RegExp(`\\b${escapeRe(label)}\\s*\\(`);
|
|
45
|
+
const destructureRe = new RegExp(`(?:const|let|var)\\s*\\{([^}]*)\\}\\s*=\\s*(?:await\\s+)?[\\w.$]*\\b${escapeRe(label)}\\s*\\(`);
|
|
46
|
+
const memberRe = new RegExp(`\\b${escapeRe(label)}\\s*\\([^()]*\\)\\s*\\.(\\w+)`);
|
|
47
|
+
const awaitRe = new RegExp(`\\bawait\\s+[\\w.$]*\\b${escapeRe(label)}\\s*\\(`);
|
|
48
|
+
// v2 (Spec G): exception + nullability reliance, same line-visible conservatism.
|
|
49
|
+
const catchChainRe = new RegExp(`\\b${escapeRe(label)}\\s*\\([^()]*\\)\\s*\\.catch\\s*\\(`);
|
|
50
|
+
const negatedRe = new RegExp(`!\\s*[\\w.$]*\\b${escapeRe(label)}\\s*\\(`);
|
|
51
|
+
const optChainRe = new RegExp(`\\b${escapeRe(label)}\\s*\\([^()]*\\)\\s*\\?\\.`);
|
|
52
|
+
const coalesceRe = new RegExp(`\\b${escapeRe(label)}\\s*\\([^()]*\\)\\s*\\?\\?`);
|
|
53
|
+
const nullCmpRe = new RegExp(`\\b${escapeRe(label)}\\s*\\([^()]*\\)\\s*[!=]==?\\s*(?:null|undefined)`);
|
|
54
|
+
|
|
55
|
+
const fields = new Set();
|
|
56
|
+
let sites = 0, awaited = 0, minArgs = null, maxArgs = null, handlesErrors = 0, nullChecked = 0;
|
|
57
|
+
for (const callerId of callersOf(index, [node.id])) {
|
|
58
|
+
if (sites >= MAX_SITES) break;
|
|
59
|
+
const caller = index.byId.get(callerId);
|
|
60
|
+
const lines = caller && reader.linesOf(caller.file);
|
|
61
|
+
if (!lines) continue;
|
|
62
|
+
const from = caller.line - 1, to = Math.min(lines.length, from + (caller.loc || 1));
|
|
63
|
+
let tryDepth = 0; // line-granular try-enclosure inside THIS caller's body
|
|
64
|
+
for (let i = from; i < to && sites < MAX_SITES; i++) {
|
|
65
|
+
const line = lines[i].replace(/(^|[^:])\/\/.*$/, '$1'); // strip line comments, keep ://
|
|
66
|
+
const opens = (line.match(/\btry\s*\{/g) || []).length;
|
|
67
|
+
const closes = (line.match(/\}\s*(?:catch|finally)\b/g) || []).length;
|
|
68
|
+
if (!callRe.test(line)) { tryDepth = Math.max(0, tryDepth + opens - closes); continue; }
|
|
69
|
+
sites++;
|
|
70
|
+
let thenable = false;
|
|
71
|
+
const d = destructureRe.exec(line);
|
|
72
|
+
if (d) {
|
|
73
|
+
for (const part of d[1].split(',')) {
|
|
74
|
+
const name = part.trim().split(/[:=]/)[0].trim();
|
|
75
|
+
if (name && !name.startsWith('...')) fields.add(name);
|
|
76
|
+
}
|
|
77
|
+
} else {
|
|
78
|
+
const m = memberRe.exec(line);
|
|
79
|
+
if (m) {
|
|
80
|
+
if (PROMISE_PLUMBING.has(m[1])) thenable = true;
|
|
81
|
+
else fields.add(m[1]);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (awaitRe.test(line) || thenable) awaited++;
|
|
85
|
+
if (tryDepth > 0 || catchChainRe.test(line)) handlesErrors++;
|
|
86
|
+
if (optChainRe.test(line) || coalesceRe.test(line) || nullCmpRe.test(line) || negatedRe.test(line)) nullChecked++;
|
|
87
|
+
const a = argCount(line, label);
|
|
88
|
+
if (a != null) {
|
|
89
|
+
minArgs = minArgs == null ? a : Math.min(minArgs, a);
|
|
90
|
+
maxArgs = maxArgs == null ? a : Math.max(maxArgs, a);
|
|
91
|
+
}
|
|
92
|
+
tryDepth = Math.max(0, tryDepth + opens - closes);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (!sites) return null;
|
|
96
|
+
const out = { sites, awaited };
|
|
97
|
+
if (fields.size) out.fields = [...fields].sort();
|
|
98
|
+
if (minArgs != null) out.argRange = [minArgs, maxArgs];
|
|
99
|
+
if (handlesErrors) out.handlesErrors = handlesErrors;
|
|
100
|
+
if (nullChecked) out.nullChecked = nullChecked;
|
|
101
|
+
const mutated = paramMutations(node, reader);
|
|
102
|
+
if (mutated.length) out.mutatesParams = mutated;
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// v2 (Spec G): parameter-mutation hazard — the CALLEE writing through a named parameter means
|
|
107
|
+
// callers share that object. Body-line patterns only: `p.x =`, `p[..] =`, `Object.assign(p`,
|
|
108
|
+
// and the mutating array methods. No parameters or no source -> no claim.
|
|
109
|
+
const MUTATING_METHODS = 'push|pop|shift|unshift|splice|sort|reverse|fill|copyWithin';
|
|
110
|
+
function paramMutations(node, reader) {
|
|
111
|
+
const params = node?.signature?.params || [];
|
|
112
|
+
if (!params.length) return [];
|
|
113
|
+
const lines = reader.linesOf(node.file);
|
|
114
|
+
if (!lines) return [];
|
|
115
|
+
const from = node.line - 1, to = Math.min(lines.length, from + (node.loc || 1));
|
|
116
|
+
const mutated = new Set();
|
|
117
|
+
for (const p of params) {
|
|
118
|
+
const pe = escapeRe(p);
|
|
119
|
+
const res = [
|
|
120
|
+
new RegExp(`\\b${pe}\\s*(?:\\.[\\w$]+|\\[[^\\]]+\\])\\s*[+\\-*/]?=(?!=)`),
|
|
121
|
+
new RegExp(`Object\\.assign\\(\\s*${pe}\\b`),
|
|
122
|
+
new RegExp(`\\b${pe}\\s*(?:\\.[\\w$]+)*\\.(?:${MUTATING_METHODS})\\s*\\(`),
|
|
123
|
+
];
|
|
124
|
+
for (let i = from; i < to; i++) {
|
|
125
|
+
const line = lines[i].replace(/(^|[^:])\/\/.*$/, '$1');
|
|
126
|
+
if (res.some((re) => re.test(line))) { mutated.add(p); break; }
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return [...mutated].sort();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** One compact line for cards/hooks, or null when there is nothing worth saying. */
|
|
133
|
+
export function relianceLine(r) {
|
|
134
|
+
if (!r) return null;
|
|
135
|
+
const parts = [];
|
|
136
|
+
if (r.fields?.length) parts.push(`callers use {${r.fields.join(', ')}} of the result — keep those`);
|
|
137
|
+
if (r.awaited > 0) parts.push(`awaited ${r.awaited}/${r.sites}`);
|
|
138
|
+
if (r.argRange) parts.push(`args ${r.argRange[0] === r.argRange[1] ? r.argRange[0] : `${r.argRange[0]}-${r.argRange[1]}`}`);
|
|
139
|
+
if (r.handlesErrors) parts.push(`${r.handlesErrors} caller(s) handle errors from this — thrown types are contract`);
|
|
140
|
+
if (r.nullChecked) parts.push(`${r.nullChecked} caller(s) null-check the result — keep null/undefined returns possible`);
|
|
141
|
+
if (r.mutatesParams?.length) parts.push(r.mutatesParams.map((p) => `mutates its argument "${p}" — callers share that object`).join(' · '));
|
|
142
|
+
return parts.length ? parts.join(' · ') : null;
|
|
143
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// codeweb risk scoring primitive (F7) — the change-risk formula in ONE place so risk.mjs and its
|
|
2
|
+
// tests share the same constants (the test imports these; it does not re-hardcode them). Pure.
|
|
3
|
+
//
|
|
4
|
+
// risk = Σ wᵢ · normᵢ(componentᵢ), where normᵢ = componentᵢ / graph-max(componentᵢ) (0 when the max
|
|
5
|
+
// is 0). Weights are non-negative and sum to 1, so risk ∈ [0,1] and is monotonic non-decreasing in
|
|
6
|
+
// each component for fixed maxes (pinned by RK-MONOTONE).
|
|
7
|
+
|
|
8
|
+
export const RISK_WEIGHTS = { fanIn: 0.30, fanOut: 0.15, loc: 0.15, blast: 0.30, churn: 0.10 };
|
|
9
|
+
|
|
10
|
+
export function riskScore(components, maxes) {
|
|
11
|
+
let s = 0;
|
|
12
|
+
for (const k of Object.keys(RISK_WEIGHTS)) {
|
|
13
|
+
const m = maxes[k] || 0;
|
|
14
|
+
const norm = m > 0 ? (components[k] || 0) / m : 0;
|
|
15
|
+
s += RISK_WEIGHTS[k] * norm;
|
|
16
|
+
}
|
|
17
|
+
return s;
|
|
18
|
+
}
|