@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,86 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// codeweb find-similar — reuse-at-write-time. Before an agent writes a function, it asks "does
|
|
3
|
+
// something already do this?": shingle a candidate body/signature and rank existing non-test
|
|
4
|
+
// function bodies by token-shingle Jaccard. Turns codeweb's post-hoc duplication detection into
|
|
5
|
+
// write-time PREVENTION. Read-only, deterministic. Shares the K=3 shingler with overlap.mjs via
|
|
6
|
+
// ./lib/shingles.mjs (one truth).
|
|
7
|
+
//
|
|
8
|
+
// Usage:
|
|
9
|
+
// node find-similar.mjs <graph.json> (--body <file> | --stdin | --signature "<text>") [--k N] [--json]
|
|
10
|
+
// Exit: 0 ok (even with zero matches), 2 usage / source unavailable.
|
|
11
|
+
|
|
12
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
13
|
+
import { resolve } from 'node:path';
|
|
14
|
+
import { normalizeGraph, isTestFile } from './lib/graph-ops.mjs';
|
|
15
|
+
import { shingles, jaccard } from './lib/shingles.mjs';
|
|
16
|
+
import { structuralShingles } from './lib/skeleton.mjs'; // F6: Type-2 (rename-invariant) similarity
|
|
17
|
+
|
|
18
|
+
const USAGE = 'usage: find-similar.mjs <graph.json> (--body <file> | --stdin | --signature "<text>") [--k N] [--structural] [--json]';
|
|
19
|
+
import { die, emitJson, finish, loadGraph, sourceReader } from './lib/cli.mjs';
|
|
20
|
+
|
|
21
|
+
const argv = process.argv.slice(2);
|
|
22
|
+
let json = false, body = null, stdin = false, signature = null, k = 10, structural = false; 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 === '--stdin') stdin = true;
|
|
27
|
+
else if (t === '--structural') structural = true;
|
|
28
|
+
else if (t === '--body') body = argv[++i];
|
|
29
|
+
else if (t === '--signature') signature = argv[++i];
|
|
30
|
+
else if (t === '--k') k = Math.max(1, parseInt(argv[++i], 10) || 10);
|
|
31
|
+
else if (!t.startsWith('-')) pos.push(t);
|
|
32
|
+
}
|
|
33
|
+
// F6: --structural ranks by skeleton (identifier-normalized) shingles, so a clone with all variables
|
|
34
|
+
// renamed scores ~1 even when its lexical (token) similarity is lower. Lexical is the default.
|
|
35
|
+
const shg = structural ? (s) => structuralShingles(s, 3) : (s) => shingles(s, 3);
|
|
36
|
+
// exactly one candidate source
|
|
37
|
+
const sources = [body != null, stdin, signature != null].filter(Boolean).length;
|
|
38
|
+
if (sources !== 1) die(USAGE, 2);
|
|
39
|
+
|
|
40
|
+
const { graph, abs } = loadGraph(pos[0], { usage: USAGE });
|
|
41
|
+
|
|
42
|
+
const root = graph.meta?.root || null;
|
|
43
|
+
if (!root || !existsSync(root)) die(`source unavailable: graph.meta.root is missing or not on disk — find-similar needs real bodies to compare (got ${root || 'none'})`, 2);
|
|
44
|
+
|
|
45
|
+
// read candidate text
|
|
46
|
+
let candidateText;
|
|
47
|
+
try {
|
|
48
|
+
if (body != null) candidateText = readFileSync(resolve(body), 'utf8');
|
|
49
|
+
else if (stdin) candidateText = readFileSync(0, 'utf8');
|
|
50
|
+
else candidateText = signature;
|
|
51
|
+
} catch (e) { die(`cannot read candidate: ${e.message}`, 2); }
|
|
52
|
+
|
|
53
|
+
const candidate = shg(candidateText);
|
|
54
|
+
|
|
55
|
+
// score every non-test function/method body
|
|
56
|
+
const reader = sourceReader(root);
|
|
57
|
+
const bodyOf = reader.bodyOf;
|
|
58
|
+
const tierOf = (s) => (s >= 0.6 ? 'high' : s >= 0.35 ? 'medium' : 'low'); // overlap.mjs bands
|
|
59
|
+
|
|
60
|
+
const matches = [];
|
|
61
|
+
for (const n of graph.nodes) {
|
|
62
|
+
if (n.kind !== 'function' && n.kind !== 'method') continue;
|
|
63
|
+
if (isTestFile(n.file)) continue;
|
|
64
|
+
const src = bodyOf(n);
|
|
65
|
+
if (src == null) continue;
|
|
66
|
+
const sim = jaccard(candidate, shg(src));
|
|
67
|
+
if (sim < 0.15) continue; // exclude below the low band
|
|
68
|
+
matches.push({ id: n.id, label: n.label, file: n.file, line: n.line, domain: n.domain, sim: +sim.toFixed(6), tier: tierOf(sim) });
|
|
69
|
+
}
|
|
70
|
+
matches.sort((a, b) => b.sim - a.sim || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
71
|
+
const top = matches.slice(0, k);
|
|
72
|
+
|
|
73
|
+
const payload = {
|
|
74
|
+
candidate: { source: body != null ? 'body' : stdin ? 'stdin' : 'signature', shingles: candidate.size, mode: structural ? 'structural' : 'lexical' },
|
|
75
|
+
matches: top, count: top.length, scanned: graph.nodes.filter((n) => (n.kind === 'function' || n.kind === 'method') && !isTestFile(n.file)).length,
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
if (json) { emitJson(payload); } else {
|
|
79
|
+
console.log(`find-similar: candidate (${payload.candidate.shingles} shingles) vs ${payload.scanned} existing symbols`);
|
|
80
|
+
if (!top.length) console.log(' no similar existing symbol (>=15%) — looks novel; safe to write.');
|
|
81
|
+
else {
|
|
82
|
+
console.log(` ${top.length} similar — consider reusing instead of re-implementing:`);
|
|
83
|
+
for (const m of top) console.log(` [${(m.sim * 100).toFixed(0).padStart(3)}% ${m.tier.padEnd(6)}] ${m.id} (${m.file}:${m.line})`);
|
|
84
|
+
}
|
|
85
|
+
finish(0);
|
|
86
|
+
}
|
package/scripts/find.mjs
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// codeweb find — concept search: turn an idea ("retry handling", "where is config parsed")
|
|
3
|
+
// into ranked symbols, deterministically. The one query family that doesn't need a name.
|
|
4
|
+
//
|
|
5
|
+
// node scripts/find.mjs <graph.json> <query words...> [--limit 10] [--offset N] [--json]
|
|
6
|
+
//
|
|
7
|
+
// Ranking: identifier/file/domain token match (camelCase & snake_case split, light stemming)
|
|
8
|
+
// weighted by exports, role (product first — unless the query asks for tests), and fan-in.
|
|
9
|
+
// Output is budgeted (top-N + true total + more.remaining), staleness-annotated like query.mjs.
|
|
10
|
+
|
|
11
|
+
import { die, emitJson, emitText, loadGraph, capList, checkStaleness } from './lib/cli.mjs';
|
|
12
|
+
import { buildIndex } from './lib/graph-ops.mjs';
|
|
13
|
+
import { findSymbols } from './lib/find-core.mjs';
|
|
14
|
+
|
|
15
|
+
const USAGE = 'usage: find.mjs <graph.json> <query words...> [--limit 10] [--offset N] [--json]';
|
|
16
|
+
const argv = process.argv.slice(2);
|
|
17
|
+
let json = false, limit = 10, offset = 0; 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 === '--limit') limit = parseInt(argv[++i], 10);
|
|
22
|
+
else if (t === '--offset') offset = parseInt(argv[++i], 10) || 0;
|
|
23
|
+
else if (t === '--full') limit = NaN; // capList treats non-finite as "everything"
|
|
24
|
+
else if (!t.startsWith('-')) pos.push(t);
|
|
25
|
+
}
|
|
26
|
+
const { graph } = loadGraph(pos[0], { usage: USAGE });
|
|
27
|
+
const query = pos.slice(1).join(' ').trim();
|
|
28
|
+
if (!query) die(USAGE, 2);
|
|
29
|
+
|
|
30
|
+
const index = buildIndex(graph);
|
|
31
|
+
const { qtoks, results } = findSymbols(graph, index, query);
|
|
32
|
+
if (!qtoks.length) die(`query "${query}" has no searchable terms after stopwords`, 2);
|
|
33
|
+
|
|
34
|
+
const c = capList(results, Number.isFinite(limit) ? limit : null, offset);
|
|
35
|
+
const domains = [...new Set(results.map((r) => r.domain))];
|
|
36
|
+
const payload = {
|
|
37
|
+
query, terms: qtoks,
|
|
38
|
+
summary: `"${query}": ${results.length} match(es) across ${domains.length} domain(s)${results.length ? ` — top: ${results[0].id}` : ''}`,
|
|
39
|
+
results: c.items, count: results.length, domains: domains.slice(0, 8),
|
|
40
|
+
};
|
|
41
|
+
if (c.remaining > 0) payload.more = { remaining: c.remaining, nextOffset: c.offset + c.items.length };
|
|
42
|
+
const stale = checkStaleness(graph);
|
|
43
|
+
if (stale) { payload.stale = stale; payload.summary += ` — graph is stale for ${stale.count}+ file(s); run codeweb_refresh`; }
|
|
44
|
+
|
|
45
|
+
if (json) { emitJson(payload); } else {
|
|
46
|
+
const L = [payload.summary];
|
|
47
|
+
for (const r of c.items) L.push(` ${String(r.score).padStart(6)} ${r.label} (${r.kind}${r.role !== 'product' ? `, ${r.role}` : ''}) ${r.file}:${r.line} [${r.domain}] ${r.match}`);
|
|
48
|
+
if (payload.more) L.push(` …+${payload.more.remaining} more (--offset ${payload.more.nextOffset})`);
|
|
49
|
+
emitText(L.join('\n'));
|
|
50
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// codeweb fitness (F6) — architecture-as-code. Check a graph against declared invariants and fail
|
|
3
|
+
// review on violation. Rule types: forbidden-dependency {from,to} (domain match), no-cycles,
|
|
4
|
+
// max-fan-in {limit}, max-symbol-loc {limit}, layer {order:[top..bottom]} (a domain may depend only
|
|
5
|
+
// on domains at/below it). Read-only, deterministic. Built on ./lib/graph-ops.mjs.
|
|
6
|
+
//
|
|
7
|
+
// Usage: node fitness.mjs <graph.json> [--rules codeweb.rules.json] [--json]
|
|
8
|
+
// rules file: { "rules": [ { "id", "type", "severity"?, ...params } ] } (severity default "error")
|
|
9
|
+
// Exit: 0 ok, 1 when >=1 error-severity violation, 2 usage/IO/unknown-rule.
|
|
10
|
+
|
|
11
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
12
|
+
import { resolve, join, dirname } from 'node:path';
|
|
13
|
+
import { normalizeGraph, buildIndex, fileCycles } from './lib/graph-ops.mjs';
|
|
14
|
+
|
|
15
|
+
const USAGE = 'usage: fitness.mjs <graph.json> [--rules codeweb.rules.json] [--json]';
|
|
16
|
+
import { die, emitJson, finish } from './lib/cli.mjs';
|
|
17
|
+
|
|
18
|
+
const argv = process.argv.slice(2);
|
|
19
|
+
let json = false, rulesArg = null; const pos = [];
|
|
20
|
+
for (let i = 0; i < argv.length; i++) { const t = argv[i]; if (t === '--json') json = true; else if (t === '--rules') rulesArg = argv[++i]; else if (!t.startsWith('-')) pos.push(t); }
|
|
21
|
+
const graphPath = pos[0] || (process.env.CODEWEB_WS ? `${process.env.CODEWEB_WS}/graph.json` : null);
|
|
22
|
+
if (!graphPath) die(USAGE, 2);
|
|
23
|
+
|
|
24
|
+
const gAbs = resolve(graphPath);
|
|
25
|
+
if (!existsSync(gAbs)) die(`graph not found: ${gAbs}`, 2);
|
|
26
|
+
let graph;
|
|
27
|
+
try { graph = normalizeGraph(JSON.parse(readFileSync(gAbs, 'utf8'))); }
|
|
28
|
+
catch (e) { die(`invalid JSON in ${gAbs}: ${e.message}`, 2); }
|
|
29
|
+
|
|
30
|
+
// locate rules: --rules, else codeweb.rules.json beside the graph, else in cwd
|
|
31
|
+
const rulesPath = rulesArg ? resolve(rulesArg)
|
|
32
|
+
: [join(dirname(gAbs), 'codeweb.rules.json'), resolve('codeweb.rules.json')].find(existsSync);
|
|
33
|
+
if (!rulesPath || !existsSync(rulesPath)) die('no rules file (pass --rules <codeweb.rules.json>)', 2);
|
|
34
|
+
let rules;
|
|
35
|
+
try { rules = JSON.parse(readFileSync(rulesPath, 'utf8')).rules; }
|
|
36
|
+
catch (e) { die(`invalid rules JSON in ${rulesPath}: ${e.message}`, 2); }
|
|
37
|
+
if (!Array.isArray(rules)) die('rules file must be { "rules": [ ... ] }', 2);
|
|
38
|
+
|
|
39
|
+
const index = buildIndex(graph);
|
|
40
|
+
const domOf = (id) => index.byId.get(id)?.domain || 'unassigned';
|
|
41
|
+
const violations = [];
|
|
42
|
+
const add = (rule, message, subjects) => { if (subjects.length) violations.push({ ruleId: rule.id, severity: rule.severity || 'error', type: rule.type, message, subjects }); };
|
|
43
|
+
|
|
44
|
+
for (const rule of rules) {
|
|
45
|
+
switch (rule.type) {
|
|
46
|
+
case 'forbidden-dependency': {
|
|
47
|
+
const subj = graph.edges.filter((e) => domOf(e.from) === rule.from && domOf(e.to) === rule.to).map((e) => `${e.from} -> ${e.to}`).sort();
|
|
48
|
+
add(rule, `${subj.length} forbidden edge(s) from '${rule.from}' to '${rule.to}'`, subj);
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
case 'layer': {
|
|
52
|
+
const rank = new Map((rule.order || []).map((d, i) => [d, i])); // index 0 = top layer
|
|
53
|
+
const subj = graph.edges.filter((e) => { const rf = rank.get(domOf(e.from)), rt = rank.get(domOf(e.to)); return rf != null && rt != null && rt < rf; }).map((e) => `${e.from} -> ${e.to}`).sort();
|
|
54
|
+
add(rule, `${subj.length} edge(s) depend on a higher layer (order: ${(rule.order || []).join(' > ')})`, subj);
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
case 'no-cycles': {
|
|
58
|
+
const subj = fileCycles(graph).map((c) => c.join(' <-> '));
|
|
59
|
+
add(rule, `${subj.length} file dependency cycle(s)`, subj);
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
case 'max-fan-in': {
|
|
63
|
+
const subj = graph.nodes.filter((n) => (index.callIn.get(n.id)?.size || 0) > rule.limit).map((n) => `${n.id} (${index.callIn.get(n.id).size} callers)`).sort();
|
|
64
|
+
add(rule, `${subj.length} symbol(s) exceed fan-in limit ${rule.limit}`, subj);
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
case 'max-symbol-loc': {
|
|
68
|
+
const subj = graph.nodes.filter((n) => (n.loc || 0) > rule.limit).map((n) => `${n.id} (${n.loc} loc)`).sort();
|
|
69
|
+
add(rule, `${subj.length} symbol(s) exceed loc limit ${rule.limit}`, subj);
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
default: die(`unknown rule type: ${rule.type} (rule id '${rule.id}')`, 2);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const errors = violations.filter((v) => v.severity === 'error');
|
|
77
|
+
const payload = { target: graph.meta?.target || 'target', rulesChecked: rules.length, violations, ok: errors.length === 0, errorCount: errors.length, warningCount: violations.length - errors.length };
|
|
78
|
+
const code = errors.length ? 1 : 0;
|
|
79
|
+
|
|
80
|
+
if (json) { emitJson(payload, code); } else {
|
|
81
|
+
|
|
82
|
+
console.log(`codeweb fitness: ${payload.target} — ${rules.length} rule(s), ${violations.length} violation(s) (${payload.errorCount} error, ${payload.warningCount} warning)`);
|
|
83
|
+
for (const v of violations) {
|
|
84
|
+
console.log(`\n[${v.severity.toUpperCase()}] ${v.ruleId} — ${v.message}`);
|
|
85
|
+
for (const s of v.subjects.slice(0, 12)) console.log(` ${s}`);
|
|
86
|
+
if (v.subjects.length > 12) console.log(` …+${v.subjects.length - 12} more`);
|
|
87
|
+
}
|
|
88
|
+
if (payload.ok) console.log('\nok — no error-level violations');
|
|
89
|
+
finish(code);
|
|
90
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Vendored tree-sitter grammars
|
|
2
|
+
|
|
3
|
+
These `.wasm` grammar files are **vendored on purpose** (committed to the repo) so the optional
|
|
4
|
+
tree-sitter engine is offline-reproducible and **determinism is pinned to an exact grammar version** —
|
|
5
|
+
the same input always yields the same graph. Recorded in `meta.engine` when the tree-sitter engine runs.
|
|
6
|
+
|
|
7
|
+
| File | Language | Source package | Version | ABI |
|
|
8
|
+
|------|----------|----------------|---------|-----|
|
|
9
|
+
| `tree-sitter-typescript.wasm` | TypeScript / TSX-free TS | `@vscode/tree-sitter-wasm` | 0.3.1 | 14 |
|
|
10
|
+
| `tree-sitter-java.wasm` | Java (dispatch tier) | `@vscode/tree-sitter-wasm` | 0.3.1 | 14 |
|
|
11
|
+
| `tree-sitter-c-sharp.wasm` | C# (dispatch tier) | `@vscode/tree-sitter-wasm` | 0.3.1 | 15 |
|
|
12
|
+
| `tree-sitter-python.wasm` | Python (dispatch tier, Spec F) | `@vscode/tree-sitter-wasm` | 0.3.1 | 14 |
|
|
13
|
+
| `tree-sitter-go.wasm` | Go (dispatch tier, Spec F) | `@vscode/tree-sitter-wasm` | 0.3.1 | 14 |
|
|
14
|
+
| `tree-sitter-rust.wasm` | Rust (dispatch tier, Spec F) | `@vscode/tree-sitter-wasm` | 0.3.1 | 14 |
|
|
15
|
+
|
|
16
|
+
## Runtime
|
|
17
|
+
|
|
18
|
+
The WASM runtime is `web-tree-sitter@0.26.9`, declared in the root `package.json` as an
|
|
19
|
+
**optionalDependency** — the regex engine (the default) needs zero dependencies; the tree-sitter tier
|
|
20
|
+
is opt-in. If the runtime is not installed, the engine reports unavailable and the extractor falls back
|
|
21
|
+
to the regex path per-file.
|
|
22
|
+
|
|
23
|
+
## The ABI rule (do not skip)
|
|
24
|
+
|
|
25
|
+
A grammar `.wasm` must sit inside the runtime's ABI window. `tree-sitter-wasms@0.1.13` ships ABI-13
|
|
26
|
+
grammars (built with `tree-sitter-cli@0.20.x`) that **fail to load** against `web-tree-sitter@0.26.9`
|
|
27
|
+
with a `dylink` metadata error. Always vendor a grammar whose ABI matches the pinned runtime, and bump
|
|
28
|
+
both together. The spike that established this is at `spike/tree-sitter/` (PR #17).
|
|
29
|
+
|
|
30
|
+
## Refreshing a grammar
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
npm i -D @vscode/tree-sitter-wasm@<version>
|
|
34
|
+
cp node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-typescript.wasm scripts/grammars/
|
|
35
|
+
# update the version/ABI row above, bump web-tree-sitter if the ABI moved, re-run the determinism test
|
|
36
|
+
```
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// codeweb hotspots CLI (F4) — rank symbols by refactoring priority (complexity x fan-in x churn) so an
|
|
3
|
+
// agent knows WHERE to optimize first in a large codebase. Read-only, deterministic. Built on
|
|
4
|
+
// ./lib/hotspots.mjs (formula one truth, shared with the tests). Churn is optional: --churn <map.json>
|
|
5
|
+
// or --git derives commit counts from the recorded meta.root.
|
|
6
|
+
//
|
|
7
|
+
// Usage: node hotspots.mjs <graph.json> [--churn <map.json> | --git] [--json]
|
|
8
|
+
// Exit: 0 ok, 2 usage/IO.
|
|
9
|
+
|
|
10
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
11
|
+
import { spawnSync } from 'node:child_process';
|
|
12
|
+
import { resolve } from 'node:path';
|
|
13
|
+
import { normalizeGraph } from './lib/graph-ops.mjs';
|
|
14
|
+
import { rankHotspots } from './lib/hotspots.mjs';
|
|
15
|
+
|
|
16
|
+
const USAGE = 'usage: hotspots.mjs <graph.json> [--churn <map.json> | --git] [--json]';
|
|
17
|
+
import { die, emitJson, finish, capList, loadGraph } from './lib/cli.mjs';
|
|
18
|
+
|
|
19
|
+
const argv = process.argv.slice(2);
|
|
20
|
+
let json = false, churnPath = null, useGit = false, limit = null; const pos = [];
|
|
21
|
+
for (let i = 0; i < argv.length; i++) {
|
|
22
|
+
const t = argv[i];
|
|
23
|
+
if (t === '--json') json = true;
|
|
24
|
+
else if (t === '--limit') limit = Math.max(0, parseInt(argv[++i], 10) || 0);
|
|
25
|
+
else if (t === '--churn') churnPath = argv[++i];
|
|
26
|
+
else if (t === '--git') useGit = true;
|
|
27
|
+
else if (!t.startsWith('-')) pos.push(t);
|
|
28
|
+
}
|
|
29
|
+
const { graph, abs } = loadGraph(pos[0], { usage: USAGE });
|
|
30
|
+
|
|
31
|
+
let churn = {};
|
|
32
|
+
if (churnPath) { try { churn = JSON.parse(readFileSync(resolve(churnPath), 'utf8')); } catch (e) { die(`invalid churn JSON: ${e.message}`, 2); } }
|
|
33
|
+
else if (useGit) {
|
|
34
|
+
const root = graph.meta?.root;
|
|
35
|
+
const r = spawnSync('git', ['-C', root || '.', 'log', '--format=', '--name-only'], { encoding: 'utf8', maxBuffer: 1 << 28 });
|
|
36
|
+
if (r.status === 0) for (const f of r.stdout.split(/\r?\n/)) if (f.trim()) churn[f.trim()] = (churn[f.trim()] || 0) + 1;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const full = rankHotspots(graph, { churn });
|
|
40
|
+
const capped = capList(full.ranked, limit);
|
|
41
|
+
const payload = { target: graph.meta?.target || 'target', summary: `${full.count} symbol(s) ranked by complexity x fan-in x churn`, ...full, ranked: capped.items };
|
|
42
|
+
if (capped.truncated) payload.more = { remaining: capped.remaining };
|
|
43
|
+
|
|
44
|
+
if (json) { emitJson(payload); } else {
|
|
45
|
+
|
|
46
|
+
console.log(`codeweb hotspots: ${payload.target} — ${payload.count} symbol(s) ranked by complexity x fan-in x churn`);
|
|
47
|
+
console.log(` weights: ${Object.entries(payload.weights).map(([k, v]) => `${k} ${v}`).join(', ')}`);
|
|
48
|
+
for (const r of payload.ranked.slice(0, 15)) {
|
|
49
|
+
const c = r.components;
|
|
50
|
+
console.log(` ${r.score.toFixed(3)} ${r.id} [cx ${c.complexity} in ${c.fanIn} churn ${c.churn}]`);
|
|
51
|
+
}
|
|
52
|
+
finish();
|
|
53
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// codeweb finding-annotations (F7) — a deterministic, no-model memory of human/agent judgements.
|
|
2
|
+
// Every finding gets a stable `fingerprint` of its ESSENTIAL identity (kind + the symbols it
|
|
3
|
+
// implicates), so a "false-positive" suppression in `.codeweb/annotations.json` survives re-runs
|
|
4
|
+
// (FPR-STABLE) but CANNOT mask a genuinely new issue: if the implicated symbols change, the
|
|
5
|
+
// fingerprint changes and the finding resurfaces (ANN-IDENTITY-CHANGE-RESURFACES). Writes only to
|
|
6
|
+
// `.codeweb` metadata, never to source. Pure except the two explicit IO helpers.
|
|
7
|
+
|
|
8
|
+
import { createHash } from 'node:crypto';
|
|
9
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
10
|
+
import { join, dirname } from 'node:path';
|
|
11
|
+
|
|
12
|
+
const ANN_FILE = 'annotations.json';
|
|
13
|
+
|
|
14
|
+
// Essential identity = kind + the sorted set of implicated node ids. Title/severity/evidence/order
|
|
15
|
+
// are cosmetic and excluded, so the same finding fingerprints identically across runs.
|
|
16
|
+
export function fingerprint(finding) {
|
|
17
|
+
const kind = finding.kind || '';
|
|
18
|
+
const nodes = [...new Set((finding.nodes || []).map(String))].sort();
|
|
19
|
+
return createHash('sha1').update(JSON.stringify({ kind, nodes })).digest('hex').slice(0, 16);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Read <dir>/annotations.json. `dir` IS the annotations directory (callers resolve `.codeweb`).
|
|
23
|
+
// Absent or corrupt -> an empty, well-formed annotation set (never throws).
|
|
24
|
+
export function loadAnnotations(dir) {
|
|
25
|
+
try {
|
|
26
|
+
const a = JSON.parse(readFileSync(join(dir, ANN_FILE), 'utf8'));
|
|
27
|
+
return { suppressions: Array.isArray(a.suppressions) ? a.suppressions : [] };
|
|
28
|
+
} catch { return { suppressions: [] }; }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Partition findings into {visible, suppressed}, attaching each finding's fingerprint. A finding is
|
|
32
|
+
// suppressed iff its fingerprint matches a 'false-positive' suppression. Pure (never mutates input).
|
|
33
|
+
export function applySuppressions(findings, annotations) {
|
|
34
|
+
const killed = new Set((annotations?.suppressions || []).filter((s) => s.verdict === 'false-positive').map((s) => s.fingerprint));
|
|
35
|
+
const visible = [], suppressed = [];
|
|
36
|
+
for (const f of findings) {
|
|
37
|
+
const withFp = { ...f, fingerprint: fingerprint(f) };
|
|
38
|
+
(killed.has(withFp.fingerprint) ? suppressed : visible).push(withFp);
|
|
39
|
+
}
|
|
40
|
+
return { visible, suppressed };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Append a suppression to <dir>/annotations.json, idempotent by fingerprint. Creates the dir if
|
|
44
|
+
// needed. Returns the updated annotation set.
|
|
45
|
+
export function addSuppression(dir, fp, { note = '', verdict = 'false-positive' } = {}) {
|
|
46
|
+
const ann = loadAnnotations(dir);
|
|
47
|
+
if (!ann.suppressions.some((s) => s.fingerprint === fp)) ann.suppressions.push({ fingerprint: fp, verdict, note });
|
|
48
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
49
|
+
writeFileSync(join(dir, ANN_FILE), JSON.stringify(ann, null, 2));
|
|
50
|
+
return ann;
|
|
51
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// bench-core — the oracle-A/B engine, factored out of bench/experiments/oracle-ab.mjs so the
|
|
2
|
+
// SAME arms/oracle/scoring serve two callers: the frozen paper experiment (canonical results in
|
|
3
|
+
// bench/results/oracle-ab.json) and the product CLI `scripts/bench.mjs` ("run grep-vs-codeweb on
|
|
4
|
+
// YOUR repo, graded by YOUR compiler"). One truth — a divergence here would make the product
|
|
5
|
+
// bench and the published numbers measure different things.
|
|
6
|
+
//
|
|
7
|
+
// Design (unchanged from the experiment):
|
|
8
|
+
// control = grep (`rg -n '\bX\b'` — the dump a grep-first agent ingests)
|
|
9
|
+
// treatment = codeweb (`--dependents`, budgeted pages of 20, as served over MCP)
|
|
10
|
+
// oracle = TypeScript LanguageService getReferencesAtPosition (optional — absent => ungraded)
|
|
11
|
+
// grading unit = FILES; cost = bytes injected into the agent context (tokens ~ bytes/4).
|
|
12
|
+
|
|
13
|
+
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
|
|
14
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
15
|
+
import { join, resolve } from 'node:path';
|
|
16
|
+
import { createRequire } from 'node:module';
|
|
17
|
+
import { runQuery } from './query-core.mjs';
|
|
18
|
+
|
|
19
|
+
/** Deterministic PRNG (mulberry32) — the seed IS the sample. */
|
|
20
|
+
export const mulberry = (a) => () => { a |= 0; a = (a + 0x6D2B79F5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
|
|
21
|
+
|
|
22
|
+
/** Repo-relative prefix of the benchmark scope (e.g. packages/vite/src), '' = whole repo. */
|
|
23
|
+
export const scopeOf = (srcRoot, graphRoot) =>
|
|
24
|
+
resolve(srcRoot).replace(/\\/g, '/').slice(resolve(graphRoot).replace(/\\/g, '/').length).replace(/^\//, '');
|
|
25
|
+
|
|
26
|
+
export const relOf = (abs, graphRoot) =>
|
|
27
|
+
abs.replace(/\\/g, '/').slice(resolve(graphRoot).replace(/\\/g, '/').length).replace(/^\//, '');
|
|
28
|
+
|
|
29
|
+
/** The optional oracle dependency, resolved from the caller's cwd (TS_MODULE overrides). */
|
|
30
|
+
export function loadTypescript(tsModule = process.env.TS_MODULE || 'typescript') {
|
|
31
|
+
try { return createRequire(join(process.cwd(), 'noop.js'))(tsModule); }
|
|
32
|
+
catch { return null; }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let _rg = null;
|
|
36
|
+
/** Is ripgrep on PATH? (The grep arm needs it; absent => that arm reports unavailable.) */
|
|
37
|
+
export function rgAvailable() {
|
|
38
|
+
if (_rg === null) _rg = spawnSync('rg', ['--version'], { stdio: 'ignore' }).error == null;
|
|
39
|
+
return _rg;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Deterministic symbol sample: exported product functions/classes in scope, label >= 4 chars,
|
|
44
|
+
* fan-in (calls + imports) >= 3 — symbols an agent would actually ask about. A fixed id list
|
|
45
|
+
* (A/B across engine versions needs identical tasks) bypasses sampling; absent ids are returned
|
|
46
|
+
* in `missing`, never silently dropped.
|
|
47
|
+
*/
|
|
48
|
+
export function sampleSymbols(graph, index, { scope = '', n = 30, seed = 42, fixedIds = null } = {}) {
|
|
49
|
+
if (fixedIds) {
|
|
50
|
+
const byId = new Map(graph.nodes.map((nd) => [nd.id, nd]));
|
|
51
|
+
return { sample: fixedIds.map((id) => byId.get(id)).filter(Boolean), missing: fixedIds.filter((id) => !byId.has(id)) };
|
|
52
|
+
}
|
|
53
|
+
const rng = mulberry(seed);
|
|
54
|
+
const pool = graph.nodes.filter((nd) =>
|
|
55
|
+
(nd.kind === 'function' || nd.kind === 'class') && nd.role === 'product' && nd.exports &&
|
|
56
|
+
nd.file.startsWith(scope) && nd.label.length >= 4 &&
|
|
57
|
+
(index.callIn.get(nd.id)?.size || 0) + (index.importIn.get(nd.id)?.size || 0) >= 3
|
|
58
|
+
).sort((a, b) => (a.id < b.id ? -1 : 1));
|
|
59
|
+
const sample = [];
|
|
60
|
+
while (sample.length < Math.min(n, pool.length) && pool.length) sample.push(pool.splice(Math.floor(rng() * pool.length), 1)[0]);
|
|
61
|
+
return { sample, missing: [] };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* TS LanguageService over the scope. oracleFiles(node) = files with >=1 compiler reference to the
|
|
66
|
+
* symbol OUTSIDE its declaration entry, or null when the oracle can't resolve it (excluded, not
|
|
67
|
+
* counted against either arm).
|
|
68
|
+
*/
|
|
69
|
+
export function makeTsOracle(ts, { srcRoot, graphRoot }) {
|
|
70
|
+
const tsFiles = [];
|
|
71
|
+
(function walk(d) { for (const e of readdirSync(d, { withFileTypes: true })) { const p = join(d, e.name); if (e.isDirectory()) { if (!/node_modules|__tests__/.test(e.name)) walk(p); } else if (/\.tsx?$/.test(e.name) && !/\.d\.ts$/.test(e.name) && !/\.(test|spec)\./.test(e.name)) tsFiles.push(p); } })(srcRoot);
|
|
72
|
+
const snapshots = new Map();
|
|
73
|
+
const host = {
|
|
74
|
+
getScriptFileNames: () => tsFiles,
|
|
75
|
+
getScriptVersion: () => '1',
|
|
76
|
+
getScriptSnapshot: (f) => { if (!snapshots.has(f)) { try { snapshots.set(f, ts.ScriptSnapshot.fromString(readFileSync(f, 'utf8'))); } catch { snapshots.set(f, null); } } return snapshots.get(f); },
|
|
77
|
+
getCurrentDirectory: () => srcRoot,
|
|
78
|
+
getCompilationSettings: () => ({ allowJs: true, module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022, moduleResolution: ts.ModuleResolutionKind.Bundler, noEmit: true, skipLibCheck: true }),
|
|
79
|
+
getDefaultLibFileName: (o) => ts.getDefaultLibFilePath(o),
|
|
80
|
+
fileExists: (f) => existsSync(f),
|
|
81
|
+
readFile: (f) => { try { return readFileSync(f, 'utf8'); } catch { return undefined; } },
|
|
82
|
+
directoryExists: (d) => { try { return statSync(d).isDirectory(); } catch { return false; } },
|
|
83
|
+
getDirectories: (d) => { try { return readdirSync(d, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name); } catch { return []; } },
|
|
84
|
+
};
|
|
85
|
+
const ls = ts.createLanguageService(host, ts.createDocumentRegistry());
|
|
86
|
+
const oracleFiles = (node) => {
|
|
87
|
+
const abs = join(graphRoot, node.file);
|
|
88
|
+
const text = host.readFile(abs);
|
|
89
|
+
if (!text) return null;
|
|
90
|
+
const lines = text.split(/\r?\n/);
|
|
91
|
+
const col = (lines[node.line - 1] || '').indexOf(node.label);
|
|
92
|
+
if (col === -1) return null;
|
|
93
|
+
const position = lines.slice(0, node.line - 1).reduce((s, l) => s + l.length + 1, 0) + col;
|
|
94
|
+
let refs;
|
|
95
|
+
try { refs = ls.getReferencesAtPosition(abs, position); } catch { return null; }
|
|
96
|
+
if (!refs || !refs.length) return null;
|
|
97
|
+
const files = new Set();
|
|
98
|
+
for (const r of refs) { if (!r.isDefinition) files.add(relOf(r.fileName, graphRoot)); }
|
|
99
|
+
return files.size ? files : null;
|
|
100
|
+
};
|
|
101
|
+
return { tsFileCount: tsFiles.length, oracleFiles };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** codeweb arm: --dependents, budgeted pages of 20 (the MCP default), paged to exhaustion. */
|
|
105
|
+
export function codewebArm(graph, index, node, limit = 20) {
|
|
106
|
+
let offset = 0, files = new Set(), bytes = 0, pages = 0;
|
|
107
|
+
for (;;) {
|
|
108
|
+
const { payload } = runQuery(graph, index, { query: 'dependents', symbol: node.id, limit, offset });
|
|
109
|
+
bytes += JSON.stringify(payload).length; pages++;
|
|
110
|
+
for (const id of payload.results || []) { const n = index.byId.get(id); if (n) files.add(n.file); }
|
|
111
|
+
if (!payload.more) break;
|
|
112
|
+
offset = payload.more.nextOffset;
|
|
113
|
+
}
|
|
114
|
+
return { files, bytes, pages };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** grep arm: one rg dump over the scope. null when rg is not installed. */
|
|
118
|
+
export function grepArm(node, { srcRoot, graphRoot }) {
|
|
119
|
+
if (!rgAvailable()) return null;
|
|
120
|
+
let out = '';
|
|
121
|
+
try { out = execFileSync('rg', ['-n', `\\b${node.label}\\b`, srcRoot], { encoding: 'utf8', maxBuffer: 1 << 28 }); }
|
|
122
|
+
catch (e) { out = e.stdout || ''; }
|
|
123
|
+
const files = new Set();
|
|
124
|
+
for (const line of out.split('\n')) { const m = /^(.*?):\d+:/.exec(line); if (m) files.add(relOf(resolve(m[1]), graphRoot)); }
|
|
125
|
+
files.delete(node.file); // the agent knows the definition site already
|
|
126
|
+
return { files, bytes: out.length };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export const score = (reported, truth) => {
|
|
130
|
+
const inter = [...reported].filter((f) => truth.has(f)).length;
|
|
131
|
+
return { recall: truth.size ? inter / truth.size : 1, precision: reported.size ? inter / reported.size : 1 };
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Blast-radius COST ("what transitively breaks?"): one budgeted codeweb_impact call vs the
|
|
136
|
+
* recursive grep loop an agent must run to a fixpoint — simulated GENEROUSLY for grep (the graph
|
|
137
|
+
* hands it each next frontier's symbol names; a real agent must read files to learn them).
|
|
138
|
+
* grep fields are null when rg is not installed.
|
|
139
|
+
*/
|
|
140
|
+
export function impactCost(graph, index, node, { srcRoot }) {
|
|
141
|
+
const { payload } = runQuery(graph, index, { query: 'impact', symbol: node.id, limit: 20 });
|
|
142
|
+
const cw = { bytes: JSON.stringify(payload).length, size: payload.count };
|
|
143
|
+
if (!rgAvailable()) return { codewebBytes: cw.bytes, impactSize: cw.size, grepBytes: null, grepRounds: null };
|
|
144
|
+
let bytes = 0, rounds = 0;
|
|
145
|
+
const seen = new Set([node.id]);
|
|
146
|
+
let frontier = [node];
|
|
147
|
+
while (frontier.length && rounds < 12) {
|
|
148
|
+
rounds++;
|
|
149
|
+
for (const label of [...new Set(frontier.map((n) => n.label))]) {
|
|
150
|
+
try { bytes += execFileSync('rg', ['-n', `\\b${label}\\b`, srcRoot], { encoding: 'utf8', maxBuffer: 1 << 28 }).length; }
|
|
151
|
+
catch (e) { bytes += (e.stdout || '').length; }
|
|
152
|
+
}
|
|
153
|
+
const next = [];
|
|
154
|
+
for (const n of frontier) for (const c of index.callIn.get(n.id) || []) {
|
|
155
|
+
if (!seen.has(c)) { seen.add(c); const cn = index.byId.get(c); if (cn) next.push(cn); }
|
|
156
|
+
}
|
|
157
|
+
frontier = next;
|
|
158
|
+
}
|
|
159
|
+
return { codewebBytes: cw.bytes, impactSize: cw.size, grepBytes: bytes, grepRounds: rounds };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export const mean = (xs) => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : 0);
|
|
163
|
+
|
|
164
|
+
/** Recall/precision/cost aggregate for one arm over graded rows (shape frozen by the paper run). */
|
|
165
|
+
export const aggArm = (rows, arm) => ({
|
|
166
|
+
meanRecall: +mean(rows.map((r) => r[arm].recall)).toFixed(3),
|
|
167
|
+
meanPrecision: +mean(rows.map((r) => r[arm].precision)).toFixed(3),
|
|
168
|
+
totalBytes: rows.reduce((s, r) => s + r[arm].bytes, 0),
|
|
169
|
+
meanBytes: Math.round(mean(rows.map((r) => r[arm].bytes))),
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
export const aggImpact = (rows) => ({
|
|
173
|
+
meanImpactSize: Math.round(mean(rows.map((r) => r.impact.impactSize))),
|
|
174
|
+
codewebMeanBytes: Math.round(mean(rows.map((r) => r.impact.codewebBytes))),
|
|
175
|
+
grepMeanBytes: Math.round(mean(rows.map((r) => r.impact.grepBytes))),
|
|
176
|
+
grepMeanRounds: +mean(rows.map((r) => r.impact.grepRounds)).toFixed(1),
|
|
177
|
+
costRatio: +(mean(rows.map((r) => r.impact.grepBytes)) / Math.max(1, mean(rows.map((r) => r.impact.codewebBytes)))).toFixed(1),
|
|
178
|
+
});
|