@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,92 @@
|
|
|
1
|
+
// brief-core — the day-one briefing: everything an agent burns its first 20-50k tokens
|
|
2
|
+
// discovering, pre-computed from the graph into one ~2KB page. Where things live, what the
|
|
3
|
+
// repo hangs off, where the tests are, what's already known to be wrong. Injected at session
|
|
4
|
+
// start (hooks/session-brief.mjs), callable any time (codeweb_brief / scripts/brief.mjs).
|
|
5
|
+
|
|
6
|
+
import { fileCycles, orphans, fanInOf } from './graph-ops.mjs';
|
|
7
|
+
|
|
8
|
+
const CONFIRMED = new Set(['high', 'medium']);
|
|
9
|
+
|
|
10
|
+
/** Assemble the briefing object from a normalized graph + index. Pure; budgeting built in. */
|
|
11
|
+
export function buildBrief(graph, index) {
|
|
12
|
+
const nodes = graph.nodes || [];
|
|
13
|
+
const product = nodes.filter((n) => n.role === 'product' && n.kind !== 'module');
|
|
14
|
+
const files = new Set(nodes.map((n) => n.file));
|
|
15
|
+
const roles = {};
|
|
16
|
+
for (const n of nodes) roles[n.role || 'product'] = (roles[n.role || 'product'] || 0) + 1;
|
|
17
|
+
|
|
18
|
+
const loadBearing = product
|
|
19
|
+
.slice().sort((a, b) => fanInOf(index, b.id, true) - fanInOf(index, a.id, true) || (a.id < b.id ? -1 : 1)).slice(0, 10)
|
|
20
|
+
.filter((n) => fanInOf(index, n.id, true) > 0)
|
|
21
|
+
.map((n) => ({ id: n.id, label: n.label, fanIn: fanInOf(index, n.id, true), at: `${n.file}:${n.line}` }));
|
|
22
|
+
|
|
23
|
+
// entry points: exported product symbols nobody in-repo calls but which call a lot — the
|
|
24
|
+
// places execution starts (CLIs, handlers, public surface). Heuristic, labeled as such.
|
|
25
|
+
const outDegree = (id) => index.callOut?.get(id)?.size || 0;
|
|
26
|
+
const entryPoints = product
|
|
27
|
+
.filter((n) => n.exports && fanInOf(index, n.id, true) === 0 && outDegree(n.id) >= 2)
|
|
28
|
+
.sort((a, b) => outDegree(b.id) - outDegree(a.id) || (a.id < b.id ? -1 : 1))
|
|
29
|
+
.slice(0, 5)
|
|
30
|
+
.map((n) => ({ label: n.label, at: `${n.file}:${n.line}`, callsOut: outDegree(n.id) }));
|
|
31
|
+
|
|
32
|
+
const testNodes = nodes.filter((n) => n.role === 'test');
|
|
33
|
+
const testDirs = {};
|
|
34
|
+
for (const n of testNodes) {
|
|
35
|
+
const d = n.file.includes('/') ? n.file.slice(0, n.file.lastIndexOf('/')) : '.';
|
|
36
|
+
testDirs[d] = (testDirs[d] || 0) + 1;
|
|
37
|
+
}
|
|
38
|
+
const topTestDirs = Object.entries(testDirs).sort((a, b) => b[1] - a[1]).slice(0, 3).map(([d]) => d);
|
|
39
|
+
|
|
40
|
+
const domains = (graph.domains || [])
|
|
41
|
+
.filter((d) => (d.role || 'product') === 'product')
|
|
42
|
+
.sort((a, b) => (b.nodes || 0) - (a.nodes || 0)).slice(0, 6)
|
|
43
|
+
.map((d) => ({ name: d.name, nodes: d.nodes, summary: d.summary || '' }));
|
|
44
|
+
|
|
45
|
+
const dup = (graph.overlaps || []).filter((o) => o.kind !== 'interface-pattern' && (o.confidence == null || CONFIRMED.has(o.confidence))).length;
|
|
46
|
+
const cyc = fileCycles(graph).length;
|
|
47
|
+
const orph = orphans(graph, index).length;
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
target: graph.meta?.target || null,
|
|
51
|
+
root: graph.meta?.root || null,
|
|
52
|
+
generatedAt: graph.meta?.generatedAt || null,
|
|
53
|
+
languages: graph.meta?.languages || [],
|
|
54
|
+
size: { symbols: nodes.length, edges: (graph.edges || []).length, files: files.size, domains: (graph.domains || []).length },
|
|
55
|
+
roles,
|
|
56
|
+
domains,
|
|
57
|
+
loadBearing,
|
|
58
|
+
entryPoints,
|
|
59
|
+
tests: { symbols: testNodes.length, dirs: topTestDirs },
|
|
60
|
+
findings: { duplications: dup, cycles: cyc, orphanCandidates: orph },
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** One-page text rendering (the session-start injection format). */
|
|
65
|
+
export function renderBrief(b) {
|
|
66
|
+
const L = [];
|
|
67
|
+
L.push(`codeweb brief — ${b.target || b.root || 'mapped repo'}: ${b.size.symbols} symbols / ${b.size.files} files / ${b.size.domains} domains (${(b.languages || []).join(', ') || 'unknown languages'})`);
|
|
68
|
+
if (b.domains.length) {
|
|
69
|
+
L.push('areas:');
|
|
70
|
+
for (const d of b.domains) L.push(` - ${d.name} (${d.nodes}): ${d.summary}`);
|
|
71
|
+
}
|
|
72
|
+
if (b.loadBearing.length) {
|
|
73
|
+
L.push(`load-bearing (most depended-on — check impact before touching): ${b.loadBearing.slice(0, 6).map((s) => `${s.label}×${s.fanIn}`).join(', ')}`);
|
|
74
|
+
}
|
|
75
|
+
if (b.entryPoints.length) {
|
|
76
|
+
L.push(`entry points (heuristic): ${b.entryPoints.map((e) => `${e.label} @ ${e.at}`).join('; ')}`);
|
|
77
|
+
}
|
|
78
|
+
L.push(`tests: ${b.tests.symbols} test symbol(s)${b.tests.dirs.length ? ` under ${b.tests.dirs.join(', ')}` : ''}`);
|
|
79
|
+
const f = b.findings;
|
|
80
|
+
L.push(`known issues: ${f.duplications} duplication finding(s), ${f.cycles} file cycle(s), ${f.orphanCandidates} orphan candidate(s)`);
|
|
81
|
+
if (b.activity) {
|
|
82
|
+
const c = b.activity.counters;
|
|
83
|
+
const bits = [];
|
|
84
|
+
if (c.cardsDelivered) bits.push(`${c.cardsDelivered} pre-edit card(s)`);
|
|
85
|
+
if (c.cardCallersFollowed) bits.push(`${c.cardCallersFollowed} card-named caller(s) followed`);
|
|
86
|
+
if (c.regressionsFlagged) bits.push(`${c.regressionsFlagged} regression(s) flagged`);
|
|
87
|
+
if (c.queriesServed) bits.push(`${c.queriesServed} queries served`);
|
|
88
|
+
if (bits.length) L.push(`codeweb this month: ${bits.join(' · ')} (full receipt: scripts/stats.mjs)`);
|
|
89
|
+
}
|
|
90
|
+
L.push('ask codeweb before guessing: codeweb_find "<concept>" → codeweb_explain <id> → codeweb_context <id>.');
|
|
91
|
+
return L.join('\n');
|
|
92
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// codeweb optimization campaign planner (F5) — compose the three existing advisors (optimize /
|
|
2
|
+
// deadcode / break-cycles; each stays authoritative — one truth) into ONE ordered, cumulatively
|
|
3
|
+
// pre-flighted worklist with projected cumulative deltas. This is the "auto-optimize at any scale"
|
|
4
|
+
// deliverable: a big repo becomes a verified, conflict-free, prioritized refactor backlog the agent
|
|
5
|
+
// executes step by step. Read-only PLAN — execution stays with the agent + the gate. Pure.
|
|
6
|
+
//
|
|
7
|
+
// Phase order (CMP-ORDER): cuts -> deletes -> merges. Cuts are advisory (a manual dependency
|
|
8
|
+
// inversion, not a node op) and go first so a merge blocked by a cycle is unblocked first. Deletes of
|
|
9
|
+
// dead code only remove edges, so they can never introduce a cycle. Merges are pre-flighted against
|
|
10
|
+
// the graph WITH all prior steps applied (CMP-GREEN-CHAIN) — a merge that would introduce a file cycle
|
|
11
|
+
// is dropped, so applying the whole plan in order never creates a cycle absent at the start.
|
|
12
|
+
|
|
13
|
+
import { normalizeGraph, fileCycles, applyEdit, buildIndex, chooseCanonical } from './graph-ops.mjs';
|
|
14
|
+
|
|
15
|
+
const byRoiThenId = (a, b) => b.roi - a.roi || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
16
|
+
|
|
17
|
+
export function planCampaign(graph, { optimize = { opportunities: [] }, deadcode = { safe: [] }, breakCycles = { cycles: [] }, budget = Infinity } = {}) {
|
|
18
|
+
const g0 = normalizeGraph(structuredClone(graph));
|
|
19
|
+
// "New coupling" is judged by CONTAINMENT, not by the cycle's sorted-file key: an after-cycle is new
|
|
20
|
+
// only if its files were NOT all already mutually cyclic. (A merge/delete that contracts an existing
|
|
21
|
+
// SCC changes its key but introduces no new coupling — the same subtlety apply-edit.test pins.)
|
|
22
|
+
const baseSets = fileCycles(g0).map((c) => new Set(c));
|
|
23
|
+
const introducesCoupling = (cycles) => cycles.some((c) => !baseSets.some((bc) => c.every((f) => bc.has(f))));
|
|
24
|
+
|
|
25
|
+
// Phase 1 — cuts (advisory; each verified cut breaks one cycle). Step ids stay COMPACT: SCCs are
|
|
26
|
+
// disjoint, so the first (sorted) file names the cycle uniquely — a 60-file SCC must not put a
|
|
27
|
+
// multi-KB file list into every step id.
|
|
28
|
+
const cutSteps = (breakCycles.cycles || []).filter((c) => c.verified).map((c) => {
|
|
29
|
+
const files = c.files || [];
|
|
30
|
+
return {
|
|
31
|
+
id: `cut:${files[0] || '?'}${files.length > 1 ? `(+${files.length - 1})` : ''}`, type: 'cut', op: null, roi: 10,
|
|
32
|
+
gate: { ok: true }, delta: { locReclaimed: 0, cyclesBroken: 1 }, detail: c.cut || null, files,
|
|
33
|
+
};
|
|
34
|
+
}).sort(byRoiThenId);
|
|
35
|
+
|
|
36
|
+
// Phase 2 — safe dead-code deletes (cycle-neutral: an orphan is in no SCC). ROI = the symbol's
|
|
37
|
+
// span: deadcode emits `loc` (locSaved kept as a legacy alias so older advisor output still ranks).
|
|
38
|
+
const delSteps = (deadcode.safe || []).map((o) => {
|
|
39
|
+
const saved = o.locSaved ?? o.loc ?? 0;
|
|
40
|
+
return {
|
|
41
|
+
id: `del:${o.id}`, type: 'delete', op: { kind: 'delete', ids: [o.id] }, roi: saved,
|
|
42
|
+
gate: { ok: true }, delta: { locReclaimed: saved, cyclesBroken: 0 },
|
|
43
|
+
};
|
|
44
|
+
}).sort(byRoiThenId);
|
|
45
|
+
|
|
46
|
+
// Phase 3 — ready merges, cumulatively pre-flighted from the graph-with-prior-steps-applied.
|
|
47
|
+
// Deletes are independent node removals, so applying them as ONE batched op is equivalent to the
|
|
48
|
+
// sequential chain — and O(1) whole-graph clones instead of O(deletes). (549 per-step clones of a
|
|
49
|
+
// 1.8MB graph made campaign the 8.9s outlier while every other tool answered in ~100ms.)
|
|
50
|
+
const delIds = delSteps.map((s) => s.op.ids[0]);
|
|
51
|
+
let cur = delIds.length ? applyEdit(g0, { kind: 'delete', ids: delIds }) : g0;
|
|
52
|
+
const idx0 = buildIndex(g0);
|
|
53
|
+
const cands = (optimize.opportunities || [])
|
|
54
|
+
.filter((o) => o.tier === 'ready' && o.kind === 'duplicate-logic' && Array.isArray(o.nodes) && o.nodes.length >= 2)
|
|
55
|
+
.map((o) => ({ o, canonical: o.canonical || chooseCanonical(idx0, o.nodes), loc: o.locSaved || 0 }))
|
|
56
|
+
.sort((a, b) => b.loc - a.loc || (a.o.id < b.o.id ? -1 : a.o.id > b.o.id ? 1 : 0));
|
|
57
|
+
const mergeSteps = [];
|
|
58
|
+
for (const m of cands) {
|
|
59
|
+
const sim = applyEdit(cur, { kind: 'merge', ids: m.o.nodes, into: m.canonical });
|
|
60
|
+
if (introducesCoupling(fileCycles(sim))) continue; // would introduce NEW coupling -> drop (gate would block)
|
|
61
|
+
mergeSteps.push({
|
|
62
|
+
id: `merge:${m.canonical}`, type: 'merge', op: { kind: 'merge', ids: m.o.nodes.slice().sort(), into: m.canonical },
|
|
63
|
+
roi: m.loc, gate: { ok: true }, delta: { locReclaimed: m.loc, cyclesBroken: 0 },
|
|
64
|
+
});
|
|
65
|
+
cur = sim;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const ordered = [...cutSteps, ...delSteps, ...mergeSteps];
|
|
69
|
+
const steps = Number.isFinite(budget) ? ordered.slice(0, Math.max(0, budget)) : ordered;
|
|
70
|
+
let loc = 0, cyc = 0;
|
|
71
|
+
for (const s of steps) { loc += s.delta.locReclaimed; cyc += s.delta.cyclesBroken; s.cumulative = { locReclaimed: loc, cyclesBroken: cyc }; }
|
|
72
|
+
return { steps, totals: { steps: steps.length, cuts: steps.filter((s) => s.type === 'cut').length, deletes: steps.filter((s) => s.type === 'delete').length, merges: steps.filter((s) => s.type === 'merge').length, locReclaimed: loc, cyclesBroken: cyc } };
|
|
73
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Spec C: the claims audit — every evidence source the marketing surfaces cite must exist in the
|
|
2
|
+
// tree. Pure so check-consistency can wire it and tests can pin it without touching the real repo.
|
|
3
|
+
//
|
|
4
|
+
// A "source" is valid when it resolves to (a) an existing path relative to the repo root, (b) an
|
|
5
|
+
// existing file under bench/results/, or (c) a filename-prefix of something under bench/results/
|
|
6
|
+
// or bench/experiments/ (labels like "efficiency-pilot" cover a family of files).
|
|
7
|
+
|
|
8
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
|
|
11
|
+
function prefixHit(root, dir, label) {
|
|
12
|
+
try { return readdirSync(join(root, dir)).some((f) => f.startsWith(label)); } catch { return false; }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Validate one source label against the tree. */
|
|
16
|
+
export function sourceExists(root, source) {
|
|
17
|
+
if (!source || typeof source !== 'string') return false;
|
|
18
|
+
const s = source.replace(/^\.\//, '');
|
|
19
|
+
return existsSync(join(root, s))
|
|
20
|
+
|| existsSync(join(root, 'bench', 'results', s))
|
|
21
|
+
|| prefixHit(root, 'bench/results', s)
|
|
22
|
+
|| prefixHit(root, 'bench/experiments', s);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Audit the evidence ledger (product.json claims + proof.headline) and the README's
|
|
27
|
+
* bench-results references. Returns { ok, missing: [{where, source}] }.
|
|
28
|
+
*/
|
|
29
|
+
export function auditClaims(root, { product, readme }) {
|
|
30
|
+
const missing = [];
|
|
31
|
+
for (const c of product?.claims || []) {
|
|
32
|
+
if (!sourceExists(root, c.source)) missing.push({ where: `product.json claim "${c.claim}"`, source: c.source });
|
|
33
|
+
}
|
|
34
|
+
for (const h of product?.proof?.headline || []) {
|
|
35
|
+
if (!sourceExists(root, h.src)) missing.push({ where: `product.json headline "${h.label}"`, source: h.src });
|
|
36
|
+
}
|
|
37
|
+
const seen = new Set();
|
|
38
|
+
for (const m of String(readme || '').matchAll(/bench\/(?:results|experiments)\/[\w][\w.-]*/g)) {
|
|
39
|
+
const p = m[0];
|
|
40
|
+
if (seen.has(p)) continue; seen.add(p);
|
|
41
|
+
if (!existsSync(join(root, p))) missing.push({ where: 'README.md', source: p });
|
|
42
|
+
}
|
|
43
|
+
return { ok: missing.length === 0, missing };
|
|
44
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// codeweb shared CLI harness — the one place stdout/exit/graph-loading plumbing lives.
|
|
2
|
+
// Motivated by codeweb's own overlap finding ("CLI scaffolding hand-rolled across N scripts") and by
|
|
3
|
+
// a real output-corruption bug: `process.stdout.write(big); process.exit(0)` silently drops
|
|
4
|
+
// everything past the OS pipe buffer (~64KB) because exit() discards queued async writes. Every
|
|
5
|
+
// emitter below ends the process NATURALLY (process.exitCode + event-loop drain), which is the
|
|
6
|
+
// documented Node way to guarantee a full flush. stderr messages are small (< pipe buffer), so
|
|
7
|
+
// die() may still hard-exit.
|
|
8
|
+
|
|
9
|
+
import { readFileSync, existsSync, statSync } from 'node:fs';
|
|
10
|
+
import { resolve, dirname, join } from 'node:path';
|
|
11
|
+
import { normalizeGraph } from './graph-ops.mjs';
|
|
12
|
+
|
|
13
|
+
// A consumer like `| head -1` closes the pipe early; without a handler Node dies on EPIPE with a
|
|
14
|
+
// stack trace. Treat it as a normal end-of-output.
|
|
15
|
+
process.stdout.on('error', (e) => { if (e && e.code === 'EPIPE') process.exit(0); throw e; });
|
|
16
|
+
|
|
17
|
+
/** stderr + immediate exit. Only for SMALL diagnostic messages (they fit the pipe buffer). */
|
|
18
|
+
export function die(msg, code = 2) {
|
|
19
|
+
console.error(msg);
|
|
20
|
+
process.exit(code);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Set the exit code WITHOUT killing pending stdout writes. Callers must fall off the end. */
|
|
24
|
+
export function finish(code = 0) {
|
|
25
|
+
process.exitCode = code;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Write a JSON payload (any size) to stdout, flush-safe. Ends the turn via finish(code). */
|
|
29
|
+
export function emitJson(payload, code = 0) {
|
|
30
|
+
process.stdout.write(JSON.stringify(payload) + '\n');
|
|
31
|
+
finish(code);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Write pre-rendered text lines (any size) to stdout, flush-safe. */
|
|
35
|
+
export function emitText(text, code = 0) {
|
|
36
|
+
process.stdout.write(text.endsWith('\n') || text === '' ? text : text + '\n');
|
|
37
|
+
finish(code);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Resolve the graph path from an explicit arg or the CODEWEB_WS workspace, load, parse, normalize.
|
|
42
|
+
* Dies with the shared, actionable message on absence/corruption. Returns { graph, abs }.
|
|
43
|
+
*/
|
|
44
|
+
export function loadGraph(pathArg, { usage = null } = {}) {
|
|
45
|
+
const graphPath = pathArg || (process.env.CODEWEB_WS ? `${process.env.CODEWEB_WS}/graph.json` : null);
|
|
46
|
+
if (!graphPath) die(usage || 'usage: <graph.json> required (or set CODEWEB_WS)', 2);
|
|
47
|
+
const abs = resolve(graphPath);
|
|
48
|
+
if (!existsSync(abs)) die(`graph not found: ${abs} — build it first (run /codeweb, or: node scripts/run.mjs <target> --out-dir <target>/.codeweb)`, 2);
|
|
49
|
+
let graph;
|
|
50
|
+
try { graph = normalizeGraph(JSON.parse(readFileSync(abs, 'utf8'))); }
|
|
51
|
+
catch (e) { die(`invalid JSON in ${abs}: ${e.message}`, 2); }
|
|
52
|
+
return { graph, abs };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Cached, best-effort source access for a graph's target (meta.root) — THE body reader
|
|
57
|
+
* (context-pack, find-similar, diff rename-matching all read node spans; the logic lives once).
|
|
58
|
+
* bodyOf(node) = the exact source lines [line, line+loc-1], or null when unreadable.
|
|
59
|
+
*/
|
|
60
|
+
// One truth for "what counts as a mappable source file" — the extractor's SRC list, mirrored
|
|
61
|
+
// here for the hooks (Spec E consolidation; the hooks previously trailed the extractor's list).
|
|
62
|
+
export const SRC_RE = /\.(js|mjs|cjs|jsx|ts|tsx|py|rs|go|java|cs|rb|php|kt|kts|swift)$/;
|
|
63
|
+
|
|
64
|
+
// Walk up from a file to the nearest mapped workspace (.codeweb/graph.json). Previously
|
|
65
|
+
// duplicated verbatim in both hooks — codeweb's own campaign flagged it (Spec E dogfood).
|
|
66
|
+
export function findTarget(filePath) {
|
|
67
|
+
let dir = dirname(resolve(filePath));
|
|
68
|
+
for (let i = 0; i < 40; i++) {
|
|
69
|
+
const baseline = join(dir, '.codeweb', 'graph.json');
|
|
70
|
+
if (existsSync(baseline)) return { root: dir, baseline };
|
|
71
|
+
const parent = dirname(dir);
|
|
72
|
+
if (parent === dir) break;
|
|
73
|
+
dir = parent;
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function sourceReader(root) {
|
|
79
|
+
const available = !!root && existsSync(root);
|
|
80
|
+
const cache = new Map();
|
|
81
|
+
const linesOf = (rel) => {
|
|
82
|
+
if (!available) return null;
|
|
83
|
+
if (!cache.has(rel)) { try { cache.set(rel, readFileSync(root + '/' + rel, 'utf8').split(/\r?\n/)); } catch { cache.set(rel, null); } }
|
|
84
|
+
return cache.get(rel);
|
|
85
|
+
};
|
|
86
|
+
const bodyOf = (n) => {
|
|
87
|
+
const lines = n && linesOf(n.file);
|
|
88
|
+
if (!lines) return null;
|
|
89
|
+
return lines.slice(n.line - 1, n.line - 1 + (n.loc || 1)).join('\n');
|
|
90
|
+
};
|
|
91
|
+
return { available, linesOf, bodyOf };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Staleness check against the extractor's per-file stamps (meta.sources: {rel: {s,m}}). Returns
|
|
96
|
+
* null when the graph matches disk (or has no stamps/root); else { count, files: [up to 8 rels] }.
|
|
97
|
+
* stat-only — a few ms even on thousands of files. New files can't be detected without a walk
|
|
98
|
+
* (documented); changed + deleted are.
|
|
99
|
+
*/
|
|
100
|
+
export function checkStaleness(graph) {
|
|
101
|
+
const root = graph?.meta?.root, sources = graph?.meta?.sources;
|
|
102
|
+
if (!root || !sources || !existsSync(root)) return null;
|
|
103
|
+
const stale = [];
|
|
104
|
+
for (const [relPath, st] of Object.entries(sources)) {
|
|
105
|
+
try {
|
|
106
|
+
const cur = statSync(root + '/' + relPath);
|
|
107
|
+
if (cur.size !== st.s || Math.round(cur.mtimeMs) !== st.m) stale.push(relPath);
|
|
108
|
+
} catch { stale.push(relPath + ' (deleted)'); }
|
|
109
|
+
if (stale.length >= 64) break; // enough to know it's stale; don't stat forever
|
|
110
|
+
}
|
|
111
|
+
// directory stamps catch NEW files (a created file touches its directory's mtime)
|
|
112
|
+
for (const [relDir, m] of Object.entries(graph?.meta?.dirs || {})) {
|
|
113
|
+
if (stale.length >= 64) break;
|
|
114
|
+
try {
|
|
115
|
+
const cur = statSync(relDir === '.' ? root : root + '/' + relDir);
|
|
116
|
+
if (Math.round(cur.mtimeMs) !== m) stale.push(relDir + '/ (dir changed — new/removed files)');
|
|
117
|
+
} catch { stale.push(relDir + '/ (dir deleted)'); }
|
|
118
|
+
}
|
|
119
|
+
return stale.length ? { count: stale.length, files: stale.slice(0, 8) } : null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Signed-delta formatter for renderers ("+3", "-2", "+0" — a delta always shows its direction). */
|
|
123
|
+
export const sign = (n) => (n >= 0 ? `+${n}` : `${n}`);
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Shared list-truncation for budgeted output: keep the first `limit` items and describe the rest,
|
|
127
|
+
* so a tool can return top-N + an explicit remainder instead of an unbounded dump (no silent caps).
|
|
128
|
+
* limit == null / Infinity -> untouched.
|
|
129
|
+
*/
|
|
130
|
+
export function capList(items, limit, offset = 0) {
|
|
131
|
+
const all = Array.isArray(items) ? items : [];
|
|
132
|
+
const off = Number.isFinite(offset) && offset > 0 ? Math.floor(offset) : 0;
|
|
133
|
+
if (limit == null || !Number.isFinite(limit)) {
|
|
134
|
+
return { items: off ? all.slice(off) : all, total: all.length, offset: off, truncated: false, remaining: 0 };
|
|
135
|
+
}
|
|
136
|
+
const lim = Math.max(0, Math.floor(limit));
|
|
137
|
+
const slice = all.slice(off, off + lim);
|
|
138
|
+
const remaining = Math.max(0, all.length - (off + slice.length));
|
|
139
|
+
return { items: slice, total: all.length, offset: off, truncated: remaining > 0, remaining };
|
|
140
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// codeweb complexity primitives (F4) — approximate cyclomatic complexity + max nesting depth of a
|
|
2
|
+
// function body, computed from source text with NO parser (deterministic, zero-dependency, matches the
|
|
3
|
+
// regex-engine ethos). Shared by the extractor (node fields) and any consumer. Pure.
|
|
4
|
+
//
|
|
5
|
+
// Cyclomatic = 1 + decision points. Decision points are control-flow keywords + short-circuit/branch
|
|
6
|
+
// operators, counted on source with comments and string/template literals stripped (so a keyword
|
|
7
|
+
// inside a string never counts — the property CX-IGNORES-STRINGS-COMMENTS). Identifiers are never
|
|
8
|
+
// counted, so renaming can't change the number (CX-RENAME-INVARIANT). The exact per-language token set
|
|
9
|
+
// is documented below; it is an APPROXIMATION (no AST), good enough as a ranking signal for hotspots.
|
|
10
|
+
|
|
11
|
+
// Strip line/block comments and string/template literals to ` `, so tokens inside them don't count.
|
|
12
|
+
// Order: comments first, then strings (a `"` inside a `// comment` is already gone).
|
|
13
|
+
const strip = (src, lang) => {
|
|
14
|
+
let s = src;
|
|
15
|
+
if (lang === 'py') {
|
|
16
|
+
s = s.replace(/'''[\s\S]*?'''/g, ' ').replace(/"""[\s\S]*?"""/g, ' ').replace(/#[^\n]*/g, ' ');
|
|
17
|
+
} else {
|
|
18
|
+
s = s.replace(/\/\/[^\n]*/g, ' ').replace(/\/\*[\s\S]*?\*\//g, ' ');
|
|
19
|
+
}
|
|
20
|
+
// template literals (whole, incl. ${…}) then double/single quoted strings
|
|
21
|
+
s = s.replace(/`(?:\\.|[^`\\])*`/g, ' ').replace(/"(?:\\.|[^"\\])*"/g, ' ').replace(/'(?:\\.|[^'\\])*'/g, ' ');
|
|
22
|
+
return s;
|
|
23
|
+
};
|
|
24
|
+
const count = (s, re) => (s.match(re) || []).length;
|
|
25
|
+
|
|
26
|
+
// Approximate cyclomatic complexity. lang: 'py' uses Python decision tokens; everything else (js/ts/
|
|
27
|
+
// go/rust) uses the C-family set. Always >= 1.
|
|
28
|
+
export function cyclomatic(src, lang = 'js') {
|
|
29
|
+
const s = strip(src || '', lang);
|
|
30
|
+
let decisions;
|
|
31
|
+
if (lang === 'py') {
|
|
32
|
+
// if / elif / for / while / except + boolean `and` / `or` (else/try are not decisions)
|
|
33
|
+
decisions = count(s, /\b(?:if|elif|for|while|except)\b/g) + count(s, /\b(?:and|or)\b/g);
|
|
34
|
+
} else {
|
|
35
|
+
// if / for / while / case / catch + && || ?? + ternary ? (not part of ?? or ?.)
|
|
36
|
+
const kw = count(s, /\b(?:if|for|while|case|catch)\b/g);
|
|
37
|
+
const and = count(s, /&&/g), or = count(s, /\|\|/g), nullish = count(s, /\?\?/g);
|
|
38
|
+
const ternary = count(s.replace(/\?\?/g, ' ').replace(/\?\./g, ' '), /\?/g);
|
|
39
|
+
decisions = kw + and + or + nullish + ternary;
|
|
40
|
+
}
|
|
41
|
+
return 1 + decisions;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Max nesting depth. Brace languages: deepest `{}` nesting (strings/comments stripped). Python:
|
|
45
|
+
// deepest indentation level below the first line. Always >= 0.
|
|
46
|
+
export function nestingDepth(src, lang = 'js') {
|
|
47
|
+
if (lang === 'py') {
|
|
48
|
+
const lines = (src || '').split(/\r?\n/).filter((l) => l.trim() !== '');
|
|
49
|
+
if (!lines.length) return 0;
|
|
50
|
+
const indent = (l) => l.length - l.replace(/^\s+/, '').length;
|
|
51
|
+
const stack = [indent(lines[0])];
|
|
52
|
+
let max = 0;
|
|
53
|
+
for (const l of lines.slice(1)) {
|
|
54
|
+
const ind = indent(l);
|
|
55
|
+
while (stack.length > 1 && ind <= stack[stack.length - 1]) stack.pop();
|
|
56
|
+
if (ind > stack[stack.length - 1]) stack.push(ind);
|
|
57
|
+
max = Math.max(max, stack.length - 1);
|
|
58
|
+
}
|
|
59
|
+
return max;
|
|
60
|
+
}
|
|
61
|
+
const s = strip(src || '', lang);
|
|
62
|
+
let depth = 0, max = 0;
|
|
63
|
+
for (const ch of s) {
|
|
64
|
+
if (ch === '{') { depth++; if (depth > max) max = depth; }
|
|
65
|
+
else if (ch === '}') { if (depth > 0) depth--; }
|
|
66
|
+
}
|
|
67
|
+
return max;
|
|
68
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// codeweb incremental duplication check (F3) — closes the hole where a post-edit / refreshed graph
|
|
2
|
+
// (overlaps:[]) can't see a freshly-introduced clone. For each CHANGED symbol, body-confirm against
|
|
3
|
+
// every other non-test function body and report it if it duplicates one at the same "high" bar
|
|
4
|
+
// overlap.mjs uses (Jaccard >= 0.6). Reuses the ONE shingle/Jaccard truth (lib/shingles.mjs) and
|
|
5
|
+
// rounds to 6 dp like find-similar.mjs — no second similarity formula. Pure; bodies come from a
|
|
6
|
+
// `bodyOf` injector (tests) or are read from `root` by line range (the same read overlap.mjs uses).
|
|
7
|
+
|
|
8
|
+
import { readFileSync } from 'node:fs';
|
|
9
|
+
import { isTestFile } from './graph-ops.mjs';
|
|
10
|
+
import { shingles, jaccard } from './shingles.mjs';
|
|
11
|
+
|
|
12
|
+
const HIGH = 0.6, K = 3;
|
|
13
|
+
const round6 = (x) => +x.toFixed(6);
|
|
14
|
+
const isFn = (n) => n && (n.kind === 'function' || n.kind === 'method');
|
|
15
|
+
|
|
16
|
+
export function incrementalOverlap(graph, changedIds, { root = null, bodyOf = null } = {}) {
|
|
17
|
+
const byId = new Map(graph.nodes.map((n) => [n.id, n]));
|
|
18
|
+
const fileCache = new Map();
|
|
19
|
+
const readLines = (rel) => {
|
|
20
|
+
if (!fileCache.has(rel)) { try { fileCache.set(rel, readFileSync(root + '/' + rel, 'utf8').split(/\r?\n/)); } catch { fileCache.set(rel, null); } }
|
|
21
|
+
return fileCache.get(rel);
|
|
22
|
+
};
|
|
23
|
+
const getBody = (n) => {
|
|
24
|
+
if (bodyOf) return bodyOf(n);
|
|
25
|
+
if (!root) return null;
|
|
26
|
+
const lines = readLines(n.file);
|
|
27
|
+
return lines ? lines.slice(n.line - 1, n.line - 1 + (n.loc || 1)).join('\n') : null;
|
|
28
|
+
};
|
|
29
|
+
const shOf = new Map();
|
|
30
|
+
const shingleOf = (n) => { if (!shOf.has(n.id)) { const b = getBody(n); shOf.set(n.id, b ? shingles(b, K) : null); } return shOf.get(n.id); };
|
|
31
|
+
|
|
32
|
+
const pool = graph.nodes.filter((n) => isFn(n) && !isTestFile(n.file));
|
|
33
|
+
const out = [];
|
|
34
|
+
for (const id of changedIds.filter((x) => byId.has(x))) {
|
|
35
|
+
const n = byId.get(id);
|
|
36
|
+
if (!isFn(n) || isTestFile(n.file)) continue;
|
|
37
|
+
const cand = shingleOf(n);
|
|
38
|
+
if (!cand || !cand.size) continue;
|
|
39
|
+
let best = null;
|
|
40
|
+
for (const other of pool) {
|
|
41
|
+
if (other.id === id) continue; // never report a symbol as duplicating itself
|
|
42
|
+
const osh = shingleOf(other);
|
|
43
|
+
if (!osh || !osh.size) continue;
|
|
44
|
+
const sim = round6(jaccard(cand, osh));
|
|
45
|
+
if (sim < HIGH) continue; // precision over recall: only the confirmed bar
|
|
46
|
+
if (!best || sim > best.sim || (sim === best.sim && other.id < best.dupOf)) best = { dupOf: other.id, sim };
|
|
47
|
+
}
|
|
48
|
+
if (best) out.push({ id, dupOf: best.dupOf, sim: best.sim });
|
|
49
|
+
}
|
|
50
|
+
return out.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
51
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// find-core — concept search over the graph: "where is retry handled?" -> ranked symbols.
|
|
2
|
+
// Deterministic token matching + structural weighting; no LLM, no embeddings, no index build.
|
|
3
|
+
// Every other query tool needs a NAME; this one turns an idea into the right starting symbol
|
|
4
|
+
// (then explain/context/impact take over). Same-input-same-output, like everything else here.
|
|
5
|
+
|
|
6
|
+
// Grammatical noise only — never drop meaning-bearing words ("handled" must reach handleError).
|
|
7
|
+
const STOP = new Set([
|
|
8
|
+
'a', 'an', 'the', 'is', 'are', 'was', 'were', 'be', 'been', 'being', 'do', 'does', 'did',
|
|
9
|
+
'how', 'what', 'where', 'when', 'which', 'who', 'why', 'in', 'on', 'at', 'of', 'for', 'to',
|
|
10
|
+
'from', 'with', 'and', 'or', 'not', 'it', 'its', 'this', 'that', 'these', 'those', 'there',
|
|
11
|
+
'we', 'you', 'i', 'my', 'our', 'your', 'can', 'could', 'should', 'would', 'will', 'happen', 'happens',
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
/** Porter-lite: strip plural/participle/agent suffixes so query and identifier meet in the middle. */
|
|
15
|
+
export const stemToken = (w) => {
|
|
16
|
+
if (w.length > 4 && w.endsWith('ies')) return w.slice(0, -3) + 'y';
|
|
17
|
+
if (w.length > 5 && w.endsWith('ing')) w = w.slice(0, -3);
|
|
18
|
+
else if (w.length > 4 && (w.endsWith('ed') || w.endsWith('es') || w.endsWith('er'))) w = w.slice(0, -2);
|
|
19
|
+
else if (w.length > 3 && w.endsWith('s') && !w.endsWith('ss')) w = w.slice(0, -1);
|
|
20
|
+
if (w.length > 4 && w.endsWith('e')) w = w.slice(0, -1);
|
|
21
|
+
return w;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** Split an identifier/path segment on camelCase, snake_case, kebab-case, dots, digits. */
|
|
25
|
+
export const splitIdent = (s) => String(s)
|
|
26
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
27
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
|
|
28
|
+
.toLowerCase()
|
|
29
|
+
.split(/[^a-z0-9]+/)
|
|
30
|
+
.filter(Boolean);
|
|
31
|
+
|
|
32
|
+
/** Query -> stemmed, deduped, stopword-free tokens (order-independent). */
|
|
33
|
+
export const tokenizeQuery = (q) => [...new Set(splitIdent(q).filter((t) => !STOP.has(t)).map(stemToken))];
|
|
34
|
+
|
|
35
|
+
const stemmedSet = (tokens) => new Set(tokens.map(stemToken));
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Rank graph nodes for a free-text query. Returns ALL matches sorted (score desc, id asc) —
|
|
39
|
+
* budgeting is the caller's job (capList / MCP budget), so totals stay true.
|
|
40
|
+
* Row: { id, label, kind, file, line, domain, role, score, match } — `match` says WHY, compactly.
|
|
41
|
+
*/
|
|
42
|
+
export function findSymbols(graph, index, query) {
|
|
43
|
+
const qtoks = tokenizeQuery(query);
|
|
44
|
+
if (!qtoks.length) return { qtoks, results: [] };
|
|
45
|
+
const qJoined = qtoks.join('');
|
|
46
|
+
const wantsTests = qtoks.some((t) => t === 'test' || t === 'spec');
|
|
47
|
+
const results = [];
|
|
48
|
+
for (const n of graph.nodes) {
|
|
49
|
+
const labelToks = stemmedSet(splitIdent(n.label));
|
|
50
|
+
const labelJoined = splitIdent(n.label).map(stemToken).join('');
|
|
51
|
+
const idTail = n.id.slice(n.id.lastIndexOf(':') + 1);
|
|
52
|
+
const ownerToks = idTail === n.label ? labelToks : stemmedSet(splitIdent(idTail));
|
|
53
|
+
const segs = String(n.file || '').split('/');
|
|
54
|
+
const baseToks = stemmedSet(splitIdent(segs[segs.length - 1].replace(/\.[^.]+$/, '')));
|
|
55
|
+
const pathToks = stemmedSet(segs.slice(0, -1).flatMap(splitIdent));
|
|
56
|
+
const domainToks = stemmedSet(splitIdent(n.domain || ''));
|
|
57
|
+
let base = 0, matched = 0;
|
|
58
|
+
const why = [];
|
|
59
|
+
for (const qt of qtoks) {
|
|
60
|
+
let hit = 0;
|
|
61
|
+
if (labelToks.has(qt)) { hit = 3; why.push(`label:${qt}`); }
|
|
62
|
+
else if (qt.length >= 3 && [...labelToks].some((lt) => lt.startsWith(qt))) { hit = 1.5; why.push(`label~${qt}`); }
|
|
63
|
+
else if (ownerToks.has(qt)) { hit = 1.5; why.push(`owner:${qt}`); }
|
|
64
|
+
else if (baseToks.has(qt)) { hit = 2; why.push(`file:${qt}`); }
|
|
65
|
+
else if (pathToks.has(qt)) { hit = 1; why.push(`path:${qt}`); }
|
|
66
|
+
else if (domainToks.has(qt)) { hit = 0.75; why.push(`domain:${qt}`); }
|
|
67
|
+
if (hit) { base += hit; matched++; }
|
|
68
|
+
}
|
|
69
|
+
if (!matched) continue;
|
|
70
|
+
if (labelJoined === qJoined) { base += 4; why.unshift('label=query'); }
|
|
71
|
+
let score = base * (matched / qtoks.length);
|
|
72
|
+
if (n.exports) score *= 1.25;
|
|
73
|
+
if (n.kind === 'module') score *= 0.4;
|
|
74
|
+
if (!wantsTests) {
|
|
75
|
+
if (n.role === 'test' || n.role === 'fixture') score *= 0.6;
|
|
76
|
+
else if (n.role === 'generated' || n.role === 'bench' || n.role === 'example') score *= 0.5;
|
|
77
|
+
else score *= 1.2; // product
|
|
78
|
+
}
|
|
79
|
+
const inDegree = (index.callIn.get(n.id)?.size || 0) + (index.importIn.get(n.id)?.size || 0);
|
|
80
|
+
score *= 1 + Math.min(inDegree, 20) / 40;
|
|
81
|
+
results.push({ id: n.id, label: n.label, kind: n.kind, file: n.file, line: n.line, domain: n.domain || 'unassigned', role: n.role, score: +score.toFixed(2), match: why.join(' ') });
|
|
82
|
+
}
|
|
83
|
+
results.sort((a, b) => b.score - a.score || (a.id < b.id ? -1 : 1));
|
|
84
|
+
return { qtoks, results };
|
|
85
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// gate-md — render diff.mjs's JSON payload as the PR comment the CI gate posts. The gate used to
|
|
2
|
+
// be invisible unless it blocked ("today it only pass/fails"); this puts the structural review
|
|
3
|
+
// where reviewers already look, budgeted like every other codeweb surface (hard caps + explicit
|
|
4
|
+
// "+N more", never an unbounded dump).
|
|
5
|
+
|
|
6
|
+
import { sign } from './cli.mjs';
|
|
7
|
+
|
|
8
|
+
const MARKER = '<!-- codeweb-gate -->';
|
|
9
|
+
|
|
10
|
+
const cap = (arr, n) => ({ head: arr.slice(0, n), more: Math.max(0, arr.length - n) });
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* payload = the `diff.mjs --json` object. Returns the full comment body (marker included, so the
|
|
14
|
+
* workflow can find-and-update its own comment instead of stacking new ones).
|
|
15
|
+
*/
|
|
16
|
+
export function gateComment(p) {
|
|
17
|
+
const L = [];
|
|
18
|
+
L.push(MARKER);
|
|
19
|
+
L.push(`## codeweb gate — ${p.ok ? '✅ no structural regressions' : `❌ ${p.regressions.length} regression type(s)`}`);
|
|
20
|
+
L.push('');
|
|
21
|
+
const rn = p.nodes.renamed.length;
|
|
22
|
+
L.push(
|
|
23
|
+
`\`${p.before}\` → \`${p.after}\` · nodes +${p.nodes.added.length} −${p.nodes.removed.length}${rn ? ` ~${rn} renamed` : ''}` +
|
|
24
|
+
` · edges +${p.edges.added} −${p.edges.removed} · cross-domain Δ${sign(p.crossDomainEdges.delta)}` +
|
|
25
|
+
` · cycles +${p.cycles.added.length} −${p.cycles.removed.length} · overlaps +${p.overlaps.added.length} −${p.overlaps.removed.length}`
|
|
26
|
+
);
|
|
27
|
+
if (!p.ok) {
|
|
28
|
+
L.push('');
|
|
29
|
+
L.push('**Blocking:**');
|
|
30
|
+
for (const r of p.regressions) L.push(`- ❌ ${r}`);
|
|
31
|
+
}
|
|
32
|
+
// existing symbols that lost their last caller (brand-new and renamed-to nodes are not "lost")
|
|
33
|
+
const added = new Set(p.nodes.added), renamedTo = new Set(p.nodes.renamed.map((r) => r.to));
|
|
34
|
+
const lost = p.orphans.added.filter((id) => !added.has(id) && !renamedTo.has(id));
|
|
35
|
+
const section = (title, items, render, n) => {
|
|
36
|
+
if (!items.length) return;
|
|
37
|
+
const c = cap(items, n);
|
|
38
|
+
L.push('');
|
|
39
|
+
L.push(`**${title}**`);
|
|
40
|
+
for (const it of c.head) L.push(render(it));
|
|
41
|
+
if (c.more) L.push(`- …+${c.more} more`);
|
|
42
|
+
};
|
|
43
|
+
section('New dependency cycles', p.cycles.added, (c) => `- ${c.join(' → ')}`, 3);
|
|
44
|
+
section('New duplication findings', p.overlaps.added, (o) => `- ${o.kind}: ${o.title || '(untitled)'}`, 5);
|
|
45
|
+
section('Symbols that lost all callers', lost, (id) => `- \`${id}\``, 5);
|
|
46
|
+
section('Renames (not churn)', p.nodes.renamed, (r) => `- \`${r.from}\` → \`${r.to}\`${r.sim != null ? ` (body ${(r.sim * 100).toFixed(0)}%)` : ''}`, 5);
|
|
47
|
+
L.push('');
|
|
48
|
+
L.push('<sub>codeweb structural review (same verdict as the gate). Reproduce locally: `node scripts/ci-gate.mjs --base <base-sha> --target <dir>`.</sub>');
|
|
49
|
+
return L.join('\n') + '\n';
|
|
50
|
+
}
|