@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,112 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// codeweb review (F5) — turn a change into a deterministic STRUCTURAL review. Maps changed line
|
|
3
|
+
// ranges to the symbols whose body span they touch, then reports the blast radius, domains touched,
|
|
4
|
+
// and per-symbol caller counts (review-prioritization). With --before, adds the structural delta
|
|
5
|
+
// (new file cycles + symbols that lost all callers — the structuralRegressions subset, NOT the
|
|
6
|
+
// overlap delta a refreshed graph can't populate). Read-only, deterministic. Built on graph-ops.
|
|
7
|
+
//
|
|
8
|
+
// Usage:
|
|
9
|
+
// node review.mjs <graph.json> --changed <file[:s-e],...> # explicit hunks (file = whole file)
|
|
10
|
+
// node review.mjs <graph.json> --range <gitref> # derive hunks via git diff --unified=0
|
|
11
|
+
// ... [--before <graph.json>] [--gate] [--json]
|
|
12
|
+
// Exit: 0 ok (advisory), 1 with --gate when a structural regression is present, 2 usage/IO.
|
|
13
|
+
//
|
|
14
|
+
// NOTE: changed-symbol selection uses the extractor's recorded [line, line+loc-1] span, which is
|
|
15
|
+
// best-effort (loc is clamped/brace-matched); it can under-select on truncated bodies. --range path
|
|
16
|
+
// assumes the graph's file paths match git's (graph mapped at the repo root).
|
|
17
|
+
|
|
18
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
19
|
+
import { spawnSync } from 'node:child_process';
|
|
20
|
+
import { resolve } from 'node:path';
|
|
21
|
+
import { normalizeGraph, reviewImpact, structuralRegressions } from './lib/graph-ops.mjs';
|
|
22
|
+
import { incrementalOverlap } from './lib/dup-check.mjs'; // F3: duplication-delta in the edit gate
|
|
23
|
+
|
|
24
|
+
const USAGE = 'usage: review.mjs <graph.json> (--changed <file[:s-e],...> | --range <gitref>) [--before <graph.json>] [--gate] [--json]';
|
|
25
|
+
import { die, emitJson, finish, loadGraph } from './lib/cli.mjs';
|
|
26
|
+
|
|
27
|
+
const argv = process.argv.slice(2);
|
|
28
|
+
let json = false, gate = false, changed = null, range = null, before = null; const pos = [];
|
|
29
|
+
for (let i = 0; i < argv.length; i++) {
|
|
30
|
+
const t = argv[i];
|
|
31
|
+
if (t === '--json') json = true;
|
|
32
|
+
else if (t === '--gate') gate = true;
|
|
33
|
+
else if (t === '--changed') changed = argv[++i];
|
|
34
|
+
else if (t === '--range') range = argv[++i];
|
|
35
|
+
else if (t === '--before') before = argv[++i];
|
|
36
|
+
else if (!t.startsWith('-')) pos.push(t);
|
|
37
|
+
}
|
|
38
|
+
const graphPath = pos[0];
|
|
39
|
+
if (!graphPath || (changed == null && range == null)) die(USAGE, 2);
|
|
40
|
+
|
|
41
|
+
const load = (p) => loadGraph(p).graph; // Spec E: one truth with every other CLI (was a duplicated pre-loadGraph copy)
|
|
42
|
+
const graph = load(graphPath);
|
|
43
|
+
|
|
44
|
+
// build hunks from --changed (explicit) or --range (git)
|
|
45
|
+
function parseChanged(csv) {
|
|
46
|
+
const fileRanges = new Map(); // file -> [[s,e]...] | 'whole'
|
|
47
|
+
for (const entry of csv.split(',').map((s) => s.trim()).filter(Boolean)) {
|
|
48
|
+
const ci = entry.indexOf(':');
|
|
49
|
+
if (ci === -1) { fileRanges.set(entry, 'whole'); continue; }
|
|
50
|
+
const file = entry.slice(0, ci), rng = entry.slice(ci + 1);
|
|
51
|
+
if (fileRanges.get(file) === 'whole') continue;
|
|
52
|
+
const m = /^(\d+)-(\d+)$/.exec(rng);
|
|
53
|
+
if (!m) die(`bad range in --changed (want file:start-end): ${entry}`, 2);
|
|
54
|
+
const arr = fileRanges.get(file) || []; arr.push([+m[1], +m[2]]); fileRanges.set(file, arr);
|
|
55
|
+
}
|
|
56
|
+
return [...fileRanges].map(([file, v]) => ({ file, ranges: v === 'whole' ? null : v }));
|
|
57
|
+
}
|
|
58
|
+
function hunksFromGit(ref) {
|
|
59
|
+
const r = spawnSync('git', ['diff', '--unified=0', ref], { encoding: 'utf8', maxBuffer: 1 << 28 });
|
|
60
|
+
if (r.status !== 0) die(`git diff failed: ${(r.stderr || '').trim()}`, 2);
|
|
61
|
+
const byFile = new Map(); let cur = null;
|
|
62
|
+
for (const line of r.stdout.split(/\r?\n/)) {
|
|
63
|
+
let m;
|
|
64
|
+
if ((m = /^\+\+\+ b\/(.+)$/.exec(line))) { cur = m[1] === '/dev/null' ? null : m[1]; if (cur && !byFile.has(cur)) byFile.set(cur, []); }
|
|
65
|
+
else if (cur && (m = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/.exec(line))) {
|
|
66
|
+
const start = +m[1], len = m[2] === undefined ? 1 : +m[2];
|
|
67
|
+
byFile.get(cur).push([start, start + Math.max(len, 1) - 1]);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return [...byFile].map(([file, ranges]) => ({ file, ranges }));
|
|
71
|
+
}
|
|
72
|
+
const hunks = range != null ? hunksFromGit(range) : parseChanged(changed);
|
|
73
|
+
|
|
74
|
+
const impact = reviewImpact(graph, hunks);
|
|
75
|
+
let structural = null, hasRegression = false;
|
|
76
|
+
if (before != null) {
|
|
77
|
+
const sr = structuralRegressions(load(before), graph);
|
|
78
|
+
structural = sr;
|
|
79
|
+
hasRegression = sr.newCycles.length > 0 || sr.lostCallers.length > 0;
|
|
80
|
+
}
|
|
81
|
+
// F3: duplication delta — when the target source is readable, body-confirm whether any CHANGED symbol
|
|
82
|
+
// now duplicates an existing one (the overlap delta a refreshed graph's overlaps:[] cannot show). A
|
|
83
|
+
// new body-confirmed duplication is a regression (fails --gate), closing the prevent-duplication hole.
|
|
84
|
+
const root = graph.meta?.root;
|
|
85
|
+
const newDuplications = (root && existsSync(root)) ? incrementalOverlap(graph, impact.changedSymbols, { root }) : [];
|
|
86
|
+
if (newDuplications.length) hasRegression = true;
|
|
87
|
+
|
|
88
|
+
const payload = { ...impact, filesChanged: hunks.map((h) => h.file).sort(), structural, newDuplications };
|
|
89
|
+
const code = (gate && hasRegression) ? 1 : 0;
|
|
90
|
+
|
|
91
|
+
if (json) { emitJson(payload, code); } else {
|
|
92
|
+
|
|
93
|
+
console.log(`codeweb review: ${impact.changedSymbols.length} changed symbol(s) across ${payload.filesChanged.length} file(s)`);
|
|
94
|
+
console.log(` domains touched: ${impact.domainsTouched.join(', ') || '(none)'}`);
|
|
95
|
+
console.log(` blast radius: ${impact.blastRadius.count} transitive dependent(s)`);
|
|
96
|
+
if (impact.callerCounts.length) {
|
|
97
|
+
console.log(' highest-fan-in changed symbols (review first):');
|
|
98
|
+
for (const c of impact.callerCounts.slice(0, 8)) console.log(` ${c.callers} caller(s) ${c.id}`);
|
|
99
|
+
}
|
|
100
|
+
if (structural) {
|
|
101
|
+
if (structural.newCycles.length || structural.lostCallers.length) {
|
|
102
|
+
console.log(' STRUCTURAL REGRESSIONS:');
|
|
103
|
+
if (structural.newCycles.length) console.log(` x ${structural.newCycles.length} new file cycle(s): ${structural.newCycles.map((c) => c.join('+')).join(', ')}`);
|
|
104
|
+
if (structural.lostCallers.length) console.log(` x ${structural.lostCallers.length} symbol(s) lost all callers: ${structural.lostCallers.join(', ')}`);
|
|
105
|
+
} else console.log(' structural: ok — no new cycles or lost-caller regressions vs --before');
|
|
106
|
+
}
|
|
107
|
+
if (newDuplications.length) {
|
|
108
|
+
console.log(` NEW DUPLICATION (body-confirmed) — ${newDuplications.length}:`);
|
|
109
|
+
for (const d of newDuplications) console.log(` x ${d.id} duplicates ${d.dupOf} (${(d.sim * 100).toFixed(0)}%)`);
|
|
110
|
+
}
|
|
111
|
+
finish(code);
|
|
112
|
+
}
|
package/scripts/risk.mjs
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// codeweb risk (F7) — rank symbols by change-risk so a reviewer triages the dangerous ones first.
|
|
3
|
+
// risk = weighted, graph-max-normalized blend of fan-in, fan-out, loc, transitive blast radius, and
|
|
4
|
+
// git churn (the formula + weights live in ./lib/risk.mjs — one truth, shared with the tests).
|
|
5
|
+
// Read-only, deterministic. Built on ./lib/graph-ops.mjs.
|
|
6
|
+
//
|
|
7
|
+
// Usage: node risk.mjs <graph.json> [--changed <file,...>] [--churn <map.json> | --git] [--json]
|
|
8
|
+
// --churn map.json: { "<relpath>": <commitCount> } --git: derive churn from `git log` (integration)
|
|
9
|
+
// Exit: 0 ok, 2 usage/IO.
|
|
10
|
+
|
|
11
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
12
|
+
import { spawnSync } from 'node:child_process';
|
|
13
|
+
import { resolve } from 'node:path';
|
|
14
|
+
import { normalizeGraph, buildIndex, impactOf } from './lib/graph-ops.mjs';
|
|
15
|
+
import { RISK_WEIGHTS, riskScore } from './lib/risk.mjs';
|
|
16
|
+
|
|
17
|
+
const USAGE = 'usage: risk.mjs <graph.json> [--changed <file,...>] [--churn <map.json> | --git] [--json]';
|
|
18
|
+
import { die, emitJson, finish, capList, loadGraph } from './lib/cli.mjs';
|
|
19
|
+
|
|
20
|
+
const argv = process.argv.slice(2);
|
|
21
|
+
let json = false, changed = null, churnPath = null, useGit = false, limit = null; const pos = [];
|
|
22
|
+
for (let i = 0; i < argv.length; i++) {
|
|
23
|
+
const t = argv[i];
|
|
24
|
+
if (t === '--json') json = true;
|
|
25
|
+
else if (t === '--limit') limit = Math.max(0, parseInt(argv[++i], 10) || 0);
|
|
26
|
+
else if (t === '--changed') changed = argv[++i];
|
|
27
|
+
else if (t === '--churn') churnPath = argv[++i];
|
|
28
|
+
else if (t === '--git') useGit = true;
|
|
29
|
+
else if (!t.startsWith('-')) pos.push(t);
|
|
30
|
+
}
|
|
31
|
+
const { graph, abs } = loadGraph(pos[0], { usage: USAGE });
|
|
32
|
+
|
|
33
|
+
// churn map: file -> commit count
|
|
34
|
+
let churn = {};
|
|
35
|
+
if (churnPath) { try { churn = JSON.parse(readFileSync(resolve(churnPath), 'utf8')); } catch (e) { die(`invalid churn JSON: ${e.message}`, 2); } }
|
|
36
|
+
else if (useGit) {
|
|
37
|
+
const root = graph.meta?.root;
|
|
38
|
+
const r = spawnSync('git', ['-C', root || '.', 'log', '--format=', '--name-only'], { encoding: 'utf8', maxBuffer: 1 << 28 });
|
|
39
|
+
if (r.status === 0) for (const f of r.stdout.split(/\r?\n/)) if (f.trim()) churn[f.trim()] = (churn[f.trim()] || 0) + 1;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const index = buildIndex(graph);
|
|
43
|
+
// components per node (raw structural metrics + churn-by-file)
|
|
44
|
+
const comp = graph.nodes.map((n) => ({
|
|
45
|
+
id: n.id, file: n.file, domain: n.domain,
|
|
46
|
+
fanIn: index.callIn.get(n.id)?.size || 0,
|
|
47
|
+
fanOut: index.callOut.get(n.id)?.size || 0,
|
|
48
|
+
loc: n.loc || 0,
|
|
49
|
+
blast: impactOf(index, [n.id]).length,
|
|
50
|
+
churn: churn[n.file] || 0,
|
|
51
|
+
}));
|
|
52
|
+
// graph-max per component (normalization denominator)
|
|
53
|
+
const maxes = { fanIn: 0, fanOut: 0, loc: 0, blast: 0, churn: 0 };
|
|
54
|
+
for (const c of comp) for (const k of Object.keys(maxes)) maxes[k] = Math.max(maxes[k], c[k]);
|
|
55
|
+
|
|
56
|
+
let ranked = comp.map((c) => ({ id: c.id, file: c.file, domain: c.domain, risk: riskScore(c, maxes), components: { fanIn: c.fanIn, fanOut: c.fanOut, loc: c.loc, blast: c.blast, churn: c.churn } }))
|
|
57
|
+
.sort((a, b) => b.risk - a.risk || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
58
|
+
|
|
59
|
+
if (changed != null) {
|
|
60
|
+
const files = new Set(changed.split(',').map((s) => s.trim()).filter(Boolean));
|
|
61
|
+
ranked = ranked.filter((r) => files.has(r.file));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const capped = capList(ranked, limit);
|
|
65
|
+
const payload = { target: graph.meta?.target || 'target', summary: `${ranked.length} symbol(s) ranked by change-risk${changed != null ? ' (changed only)' : ''}`, weights: RISK_WEIGHTS, maxes, count: ranked.length, ranked: capped.items };
|
|
66
|
+
if (capped.truncated) payload.more = { remaining: capped.remaining };
|
|
67
|
+
|
|
68
|
+
if (json) { emitJson(payload); } else {
|
|
69
|
+
|
|
70
|
+
console.log(`codeweb risk: ${payload.target} — ${ranked.length} symbol(s) ranked by change-risk${changed != null ? ' (changed only)' : ''}`);
|
|
71
|
+
console.log(` weights: ${Object.entries(RISK_WEIGHTS).map(([k, v]) => `${k} ${v}`).join(', ')}`);
|
|
72
|
+
for (const r of ranked.slice(0, 15)) {
|
|
73
|
+
const c = r.components;
|
|
74
|
+
console.log(` ${r.risk.toFixed(3)} ${r.id} [in ${c.fanIn} out ${c.fanOut} loc ${c.loc} blast ${c.blast} churn ${c.churn}]`);
|
|
75
|
+
}
|
|
76
|
+
finish();
|
|
77
|
+
}
|
package/scripts/run.mjs
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// codeweb — pipeline orchestrator (the entry point the /codeweb command + codebase-anatomy skill call).
|
|
3
|
+
// Runs the full deterministic pipeline for ONE target into its OWN workspace, so analyzing several
|
|
4
|
+
// targets never clobbers each other's outputs. Resolves all paths from the plugin root, so it works
|
|
5
|
+
// regardless of the caller's cwd (e.g. `node ${CLAUDE_PLUGIN_ROOT}/scripts/run.mjs <target>`).
|
|
6
|
+
//
|
|
7
|
+
// node scripts/run.mjs <SRC> [--target <label>] [--out-dir <dir>]
|
|
8
|
+
//
|
|
9
|
+
// Default workspace: <plugin>/.codeweb/runs/<slug> (override with --out-dir, e.g. <target>/.codeweb).
|
|
10
|
+
// Stages read their workspace from CODEWEB_WS (default '.live' when run standalone).
|
|
11
|
+
// Outputs in the workspace: fragment.json, graph.json, overlap.md, report.html, report.md.
|
|
12
|
+
// Read-only over the target; never executes target code.
|
|
13
|
+
|
|
14
|
+
import { execFileSync } from 'node:child_process';
|
|
15
|
+
import { mkdirSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
16
|
+
import { createHash } from 'node:crypto';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
import { dirname, join, resolve } from 'node:path';
|
|
19
|
+
|
|
20
|
+
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); // plugin root (parent of scripts/)
|
|
21
|
+
|
|
22
|
+
// Bump when ANY downstream stage (cluster/overlap/optimize/report) changes behavior — it keys the
|
|
23
|
+
// stage memo, so an old workspace can never serve outputs computed by an older pipeline.
|
|
24
|
+
const MEMO_VERSION = 1;
|
|
25
|
+
|
|
26
|
+
const argv = process.argv.slice(2);
|
|
27
|
+
const opts = { src: null, target: null, outDir: null, open: false, full: false };
|
|
28
|
+
for (let i = 0; i < argv.length; i++) {
|
|
29
|
+
const t = argv[i];
|
|
30
|
+
if (t === '--target') opts.target = argv[++i];
|
|
31
|
+
else if (t === '--out-dir') opts.outDir = argv[++i];
|
|
32
|
+
else if (t === '--open') opts.open = true;
|
|
33
|
+
else if (t === '--full') opts.full = true;
|
|
34
|
+
else if (!opts.src) opts.src = t;
|
|
35
|
+
}
|
|
36
|
+
if (!opts.src) { console.error('usage: run.mjs <SRC> [--target <label>] [--out-dir <dir>]'); process.exit(1); }
|
|
37
|
+
// Resolve the target against the CALLER's cwd (not the plugin root the stages run in) — a relative
|
|
38
|
+
// <SRC> must mean the same thing as a relative --out-dir. Fail here with one clean line, not a
|
|
39
|
+
// stage-level stack trace.
|
|
40
|
+
opts.src = resolve(opts.src);
|
|
41
|
+
if (!existsSync(opts.src)) { console.error(`[run] target not found: ${opts.src}`); process.exit(1); }
|
|
42
|
+
|
|
43
|
+
// slug = whole target label (or last 2 path segments of src), so e.g. ecc/scripts -> "ecc-scripts"
|
|
44
|
+
// rather than a collision-prone "scripts".
|
|
45
|
+
const base = opts.target || opts.src.replace(/\\/g, '/').replace(/\/+$/, '').split('/').slice(-2).join('/');
|
|
46
|
+
const slug = base.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'target';
|
|
47
|
+
const ws = opts.outDir ? resolve(opts.outDir) : join(ROOT, '.codeweb', 'runs', slug);
|
|
48
|
+
mkdirSync(ws, { recursive: true });
|
|
49
|
+
|
|
50
|
+
const env = { ...process.env, CODEWEB_WS: ws };
|
|
51
|
+
const node = process.execPath;
|
|
52
|
+
const S = (p) => join(ROOT, p);
|
|
53
|
+
const run = (label, file, args, useEnv) => {
|
|
54
|
+
console.error(`\n[run] ${label}`);
|
|
55
|
+
try {
|
|
56
|
+
execFileSync(node, [file, ...args], { stdio: 'inherit', env: useEnv ? env : process.env, cwd: ROOT });
|
|
57
|
+
} catch (e) {
|
|
58
|
+
// The stage already printed its own diagnostics (stdio inherited) — add one clean line, not a
|
|
59
|
+
// raw execFileSync stack dump.
|
|
60
|
+
console.error(`\n[run] stage '${label}' failed${typeof e?.status === 'number' ? ` (exit ${e.status})` : ''} — aborting`);
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const targetArg = opts.target ? ['--target', opts.target] : [];
|
|
66
|
+
// Extract always runs — it is the change detector — and rides the scan cache (Spec A), so a
|
|
67
|
+
// no-change re-run costs ~the regex baseline instead of a full parse.
|
|
68
|
+
run('extract', S('scripts/extract-symbols.mjs'), [opts.src, ...targetArg, '--cache', join(ws, '.scan-cache.json'), '--out', join(ws, 'fragment.json')], false);
|
|
69
|
+
|
|
70
|
+
// Spec B (docs/specs/perf-stage-memo-scale.md): the four downstream stages are pure functions of
|
|
71
|
+
// (fragment bytes, CODEWEB_* levers, pipeline version). When that key matches the previous run and
|
|
72
|
+
// every output still exists, reuse the outputs — wall-time changes, never a byte. --full forces.
|
|
73
|
+
const STAGE_OUTPUTS = ['graph.json', 'overlap.md', 'optimize.md', 'report.html', 'report.md'];
|
|
74
|
+
const levers = Object.keys(process.env)
|
|
75
|
+
.filter((k) => k.startsWith('CODEWEB_') && k !== 'CODEWEB_WS').sort()
|
|
76
|
+
.map((k) => `${k}=${process.env[k]}`).join(';');
|
|
77
|
+
const memoKey = createHash('sha1')
|
|
78
|
+
.update(`v${MEMO_VERSION}|${levers}|`).update(readFileSync(join(ws, 'fragment.json')))
|
|
79
|
+
.digest('hex');
|
|
80
|
+
const memoPath = join(ws, '.stages.json');
|
|
81
|
+
let prevKey = null;
|
|
82
|
+
try { prevKey = JSON.parse(readFileSync(memoPath, 'utf8')).key; } catch { /* absent/corrupt -> compute */ }
|
|
83
|
+
const reusable = !opts.full && prevKey === memoKey && STAGE_OUTPUTS.every((f) => existsSync(join(ws, f)));
|
|
84
|
+
|
|
85
|
+
if (reusable) {
|
|
86
|
+
console.error('\n[run] stages reused (fragment unchanged) — skipping downstream recompute; --full forces');
|
|
87
|
+
} else {
|
|
88
|
+
run('cluster', S('scripts/cluster3.mjs'), [], true);
|
|
89
|
+
run('overlap', S('scripts/overlap.mjs'), [], true);
|
|
90
|
+
run('optimize', S('scripts/optimize.mjs'), [join(ws, 'graph.json'), '--out', join(ws, 'optimize.md')], false);
|
|
91
|
+
run('report', S('scripts/build-report.mjs'), [join(ws, 'graph.json'), ...(opts.open ? ['--open'] : [])], false);
|
|
92
|
+
try { writeFileSync(memoPath, JSON.stringify({ key: memoKey, at: new Date().toISOString() }) + '\n'); } catch { /* memo is best-effort */ }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
console.error(`\n[run] done -> ${ws}`);
|
|
96
|
+
console.error(`[run] ${ws}/report.html · report.md · overlap.md · optimize.md · graph.json · fragment.json`);
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// codeweb screenshot — the demo-shot pipeline, deterministic and staged. Every committed
|
|
3
|
+
// screenshot regenerates from the REAL report with one command, at retina scale, in states
|
|
4
|
+
// that actually show the product (a selected symbol with its blast lit, a populated
|
|
5
|
+
// inspector) instead of default first paints. Layout is seeded, so reruns produce stable
|
|
6
|
+
// frames.
|
|
7
|
+
//
|
|
8
|
+
// node scripts/screenshot.mjs <report.html> --out <dir> [--prefix shot] [--scale 2]
|
|
9
|
+
//
|
|
10
|
+
// Emits: <prefix>-findings.png, -graph.png (areas overview), -blast.png (top hotspot
|
|
11
|
+
// selected, neighbors lit), -treemap.png, -matrix.png
|
|
12
|
+
// Requires playwright + a chromium (PLAYWRIGHT_BROWSERS_PATH or CODEWEB_CHROMIUM).
|
|
13
|
+
|
|
14
|
+
import { resolve, join } from 'node:path';
|
|
15
|
+
import { mkdirSync, existsSync, readdirSync } from 'node:fs';
|
|
16
|
+
import { createRequire } from 'node:module';
|
|
17
|
+
import { pathToFileURL } from 'node:url';
|
|
18
|
+
|
|
19
|
+
const argv = process.argv.slice(2);
|
|
20
|
+
let report = null, outDir = 'shots', prefix = 'shot', scale = 2;
|
|
21
|
+
for (let i = 0; i < argv.length; i++) {
|
|
22
|
+
const t = argv[i];
|
|
23
|
+
if (t === '--out') outDir = argv[++i];
|
|
24
|
+
else if (t === '--prefix') prefix = argv[++i];
|
|
25
|
+
else if (t === '--scale') scale = parseFloat(argv[++i]) || 2;
|
|
26
|
+
else if (!t.startsWith('-')) report = t;
|
|
27
|
+
}
|
|
28
|
+
if (!report) { console.error('usage: screenshot.mjs <report.html> --out <dir> [--prefix p] [--scale 2]'); process.exit(2); }
|
|
29
|
+
|
|
30
|
+
// dev-only dependency, resolved from the CALLER'S cwd (the engine itself stays zero-dep)
|
|
31
|
+
let chromium;
|
|
32
|
+
try {
|
|
33
|
+
const req = createRequire(join(process.cwd(), 'noop.js'));
|
|
34
|
+
const mod = await import(pathToFileURL(req.resolve('playwright')).href);
|
|
35
|
+
chromium = mod.chromium || (mod.default && mod.default.chromium);
|
|
36
|
+
if (!chromium) throw new Error('no chromium export');
|
|
37
|
+
} catch { console.error('playwright not resolvable from cwd — npm i playwright somewhere and run from there (dev-only; the report itself has zero deps)'); process.exit(2); }
|
|
38
|
+
|
|
39
|
+
function findChromium() {
|
|
40
|
+
if (process.env.CODEWEB_CHROMIUM) return process.env.CODEWEB_CHROMIUM;
|
|
41
|
+
const root = process.env.PLAYWRIGHT_BROWSERS_PATH;
|
|
42
|
+
if (root && existsSync(root)) {
|
|
43
|
+
for (const d of readdirSync(root)) if (d.startsWith('chromium')) {
|
|
44
|
+
const p = join(root, d, 'chrome-linux', 'chrome');
|
|
45
|
+
if (existsSync(p)) return p;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return undefined; // let playwright resolve its own download
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
mkdirSync(resolve(outDir), { recursive: true });
|
|
52
|
+
const VW = 1600, VH = 1000, PANEL = 340;
|
|
53
|
+
const browser = await chromium.launch({ executablePath: findChromium() });
|
|
54
|
+
const page = await browser.newPage({ viewport: { width: VW, height: VH }, deviceScaleFactor: scale });
|
|
55
|
+
page.on('pageerror', (e) => { console.error('page error: ' + e.message); process.exitCode = 1; });
|
|
56
|
+
await page.goto('file://' + resolve(report));
|
|
57
|
+
await page.waitForTimeout(800);
|
|
58
|
+
// clip = crop to CONTENT (CSS px) — a README thumbnail of a full frame is unreadable; empty
|
|
59
|
+
// canvas never ships. Clips are clamped to the viewport.
|
|
60
|
+
const shot = (name, clip) => {
|
|
61
|
+
if (clip) {
|
|
62
|
+
const x = Math.max(0, Math.floor(clip.x)), y = Math.max(0, Math.floor(clip.y));
|
|
63
|
+
clip = { x, y, width: Math.min(VW - x, Math.ceil(clip.width)), height: Math.min(VH - y, Math.ceil(clip.height)) };
|
|
64
|
+
}
|
|
65
|
+
return page.screenshot({ path: join(resolve(outDir), `${prefix}-${name}.png`), ...(clip ? { clip } : {}) });
|
|
66
|
+
};
|
|
67
|
+
const tab = async (v) => { await page.click(`.tab[data-view="${v}"]`); await page.waitForTimeout(700); };
|
|
68
|
+
const graphClip = async (pad) => {
|
|
69
|
+
const b = await page.evaluate(() => window.__codewebStage && window.__codewebStage.graphBBox && window.__codewebStage.graphBBox());
|
|
70
|
+
if (!b) return null;
|
|
71
|
+
// graph bbox + header row; always include the inspector column on the right
|
|
72
|
+
return { x: Math.max(0, b.x - pad), y: 0, width: VW - Math.max(0, b.x - pad), height: Math.min(VH, 48 + b.y + b.h + pad) };
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// 1. findings — first paint, overview card populated by construction
|
|
76
|
+
await shot('findings');
|
|
77
|
+
|
|
78
|
+
// 2. graph — the areas overview, settled, cropped to the drawn content
|
|
79
|
+
await tab('graph');
|
|
80
|
+
await page.waitForTimeout(3200);
|
|
81
|
+
await page.click('#gFit');
|
|
82
|
+
await page.waitForTimeout(400);
|
|
83
|
+
await shot('graph', await graphClip(40));
|
|
84
|
+
|
|
85
|
+
// 3. blast — top hotspot selected (zoomed), its area expanded, inspector populated
|
|
86
|
+
const staged = await page.evaluate(() => window.__codewebStage ? window.__codewebStage.topHotspot() : null);
|
|
87
|
+
if (staged) { await page.waitForTimeout(2600); await shot('blast', await graphClip(24)); }
|
|
88
|
+
else console.error('stage hook missing — blast shot skipped (old report build?)');
|
|
89
|
+
|
|
90
|
+
// 4. treemap — dense edge-to-edge, full frame
|
|
91
|
+
await tab('treemap'); await shot('treemap');
|
|
92
|
+
|
|
93
|
+
// 5. matrix — cropped to the table + legend (it occupies a corner of the full frame)
|
|
94
|
+
await tab('matrix');
|
|
95
|
+
const mClip = await page.evaluate(() => {
|
|
96
|
+
const t = document.querySelector('table.m'); const l = document.querySelector('#view-matrix .legend');
|
|
97
|
+
if (!t) return null;
|
|
98
|
+
const tr = t.getBoundingClientRect(), lr = l ? l.getBoundingClientRect() : tr;
|
|
99
|
+
return { x: 0, y: 0, width: Math.max(tr.right, lr.right) + 24, height: Math.max(tr.bottom, lr.bottom) + 16 };
|
|
100
|
+
});
|
|
101
|
+
await shot('matrix', mClip);
|
|
102
|
+
|
|
103
|
+
await browser.close();
|
|
104
|
+
console.log(`[screenshot] ${staged ? 5 : 4} frame(s) -> ${resolve(outDir)}/${prefix}-*.png${staged ? ` (blast: ${staged})` : ''}`);
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// codeweb simulate-edit — predict the regression gate's STRUCTURAL verdict for a hypothetical edit,
|
|
3
|
+
// WITHOUT performing it. Lets an agent discard doomed edits for ~zero cost before generating a line.
|
|
4
|
+
// Scoped to structuralRegressions (new file-cycles + symbols that lose all callers) — the same
|
|
5
|
+
// subset the post-edit hook enforces. Duplication delta needs the full body-confirmed pipeline and
|
|
6
|
+
// is intentionally OUT OF SCOPE (documented, not silently dropped). Read-only; never writes.
|
|
7
|
+
// Built on ./lib/graph-ops.mjs (shares applyEdit with optimize.mjs — one truth).
|
|
8
|
+
//
|
|
9
|
+
// Usage:
|
|
10
|
+
// node simulate-edit.mjs <graph.json> --delete <symbol>
|
|
11
|
+
// node simulate-edit.mjs <graph.json> --merge <s1,s2,...> [--into <id>] (default canonical = smallest id)
|
|
12
|
+
// node simulate-edit.mjs <graph.json> --move <symbol> --to <file>
|
|
13
|
+
// (or set CODEWEB_WS instead of <graph.json>) add --json for machine output.
|
|
14
|
+
// Exit: 0 ok (verdict lives in projected.ok), 1 symbol not found, 2 usage/IO.
|
|
15
|
+
|
|
16
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
17
|
+
import { resolve } from 'node:path';
|
|
18
|
+
import { normalizeGraph, resolveSymbol, applyEdit, structuralRegressions } from './lib/graph-ops.mjs';
|
|
19
|
+
|
|
20
|
+
const USAGE = 'usage: simulate-edit.mjs <graph.json> (--delete <sym> | --merge <s1,s2,..> [--into <id>] | --move <sym> --to <file>) [--json]';
|
|
21
|
+
import { die, emitJson, finish, loadGraph } from './lib/cli.mjs';
|
|
22
|
+
|
|
23
|
+
const argv = process.argv.slice(2);
|
|
24
|
+
let json = false, del = null, merge = null, into = null, move = null, to = null; const pos = [];
|
|
25
|
+
for (let i = 0; i < argv.length; i++) {
|
|
26
|
+
const t = argv[i];
|
|
27
|
+
if (t === '--json') json = true;
|
|
28
|
+
else if (t === '--delete') del = argv[++i];
|
|
29
|
+
else if (t === '--merge') merge = argv[++i];
|
|
30
|
+
else if (t === '--into') into = argv[++i];
|
|
31
|
+
else if (t === '--move') move = argv[++i];
|
|
32
|
+
else if (t === '--to') to = argv[++i];
|
|
33
|
+
else if (!t.startsWith('-')) pos.push(t);
|
|
34
|
+
}
|
|
35
|
+
if ([del, merge, move].filter((x) => x != null).length !== 1) die(USAGE, 2);
|
|
36
|
+
if (move != null && to == null) die('move requires --to <file>', 2);
|
|
37
|
+
|
|
38
|
+
const { graph, abs } = loadGraph(pos[0], { usage: USAGE });
|
|
39
|
+
|
|
40
|
+
let opName, after, target, intoOut = null, toOut = null;
|
|
41
|
+
if (del != null) {
|
|
42
|
+
const ids = resolveSymbol(graph, del);
|
|
43
|
+
if (!ids.length) die(`symbol not found: ${del}`, 1);
|
|
44
|
+
opName = 'delete'; target = ids; after = applyEdit(graph, { kind: 'delete', ids });
|
|
45
|
+
} else if (merge != null) {
|
|
46
|
+
const syms = merge.split(',').map((s) => s.trim()).filter(Boolean);
|
|
47
|
+
const ids = [...new Set(syms.flatMap((s) => resolveSymbol(graph, s)))].sort();
|
|
48
|
+
if (ids.length < 2) die(`merge needs >=2 resolved symbols (got ${ids.length})`, ids.length ? 2 : 1);
|
|
49
|
+
const canonical = into != null ? (resolveSymbol(graph, into)[0] || into) : ids[0]; // default: smallest id
|
|
50
|
+
opName = 'merge'; target = ids; intoOut = canonical; after = applyEdit(graph, { kind: 'merge', ids, into: canonical });
|
|
51
|
+
} else {
|
|
52
|
+
const ids = resolveSymbol(graph, move);
|
|
53
|
+
if (!ids.length) die(`symbol not found: ${move}`, 1);
|
|
54
|
+
opName = 'move'; target = ids; toOut = to;
|
|
55
|
+
after = ids.reduce((g, id) => applyEdit(g, { kind: 'move', id, to }), graph); // move every matched def
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const { newCycles, lostCallers } = structuralRegressions(graph, after);
|
|
59
|
+
const projected = { newCycles, lostCallers, ok: newCycles.length === 0 && lostCallers.length === 0 };
|
|
60
|
+
const payload = { op: opName, target, into: intoOut, to: toOut, projected };
|
|
61
|
+
|
|
62
|
+
if (json) { emitJson(payload); } else {
|
|
63
|
+
|
|
64
|
+
console.log(`simulate-edit: ${opName} ${target.join(', ')}${intoOut ? ` -> ${intoOut}` : ''}${toOut ? ` -> ${toOut}` : ''}`);
|
|
65
|
+
console.log(`projected gate: ${projected.ok ? 'PASS — the gate would accept this edit (exit 0)' : 'BLOCK — the gate would reject this edit (exit 1)'}`);
|
|
66
|
+
if (newCycles.length) console.log(` new file cycle(s): ${newCycles.map((c) => c.join(' -> ')).join(' | ')}`);
|
|
67
|
+
if (lostCallers.length) console.log(` symbol(s) left with no callers: ${lostCallers.join(', ')}`);
|
|
68
|
+
console.log(' (structural pre-flight: duplication delta is out of scope — run the full pipeline for that.)');
|
|
69
|
+
finish();
|
|
70
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// codeweb stats — the value receipt: what codeweb actually did in this workspace, month by
|
|
3
|
+
// month, counted at the moment it happened (hooks + MCP server). Local-only by construction;
|
|
4
|
+
// see lib/stats.mjs. Empty ledger -> says so and explains how counters accrue.
|
|
5
|
+
//
|
|
6
|
+
// node scripts/stats.mjs <graph.json> [--json]
|
|
7
|
+
|
|
8
|
+
import { die, emitJson, emitText, loadGraph } from './lib/cli.mjs';
|
|
9
|
+
import { readStats, monthLine } from './lib/stats.mjs';
|
|
10
|
+
|
|
11
|
+
const USAGE = 'usage: stats.mjs <graph.json> [--json]';
|
|
12
|
+
const argv = process.argv.slice(2);
|
|
13
|
+
let json = false; const pos = [];
|
|
14
|
+
for (const t of argv) { if (t === '--json') json = true; else if (!t.startsWith('-')) pos.push(t); else die(USAGE, 2); }
|
|
15
|
+
|
|
16
|
+
const { graph, abs } = loadGraph(pos[0], { usage: USAGE });
|
|
17
|
+
const s = readStats(abs);
|
|
18
|
+
|
|
19
|
+
if (json) { emitJson(s || { empty: true, note: 'no activity recorded yet — counters accrue as the hooks and MCP server run (CODEWEB_NO_STATS=1 disables)' }); }
|
|
20
|
+
else {
|
|
21
|
+
const L = [`codeweb activity — ${graph.meta?.target || abs}`];
|
|
22
|
+
const months = s ? Object.keys(s.months || {}).sort() : [];
|
|
23
|
+
const lines = months.map((m) => ({ m, line: monthLine(s.months[m]) })).filter((x) => x.line);
|
|
24
|
+
if (!lines.length) {
|
|
25
|
+
L.push(' no activity recorded yet — counters accrue as the hooks and MCP server run.');
|
|
26
|
+
L.push(' (local-only: stats.json lives beside the graph and never leaves this machine; CODEWEB_NO_STATS=1 disables)');
|
|
27
|
+
} else {
|
|
28
|
+
for (const { m, line } of lines) L.push(` ${m}: ${line}`);
|
|
29
|
+
}
|
|
30
|
+
emitText(L.join('\n'));
|
|
31
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// codeweb trend — body-confirmed duplication + cross-domain coupling over a series of snapshots,
|
|
3
|
+
// so a one-shot map becomes a dashboard you re-open: is the codebase consolidating or sprawling?
|
|
4
|
+
//
|
|
5
|
+
// Two modes:
|
|
6
|
+
// node trend.mjs <graph.json> [<graph.json>...] [--labels a,b,..] [--json]
|
|
7
|
+
// Render a trend from pre-built graph snapshots, oldest -> newest.
|
|
8
|
+
// node trend.mjs --git <repo> [--last N] [--focus <subdir>] [--json]
|
|
9
|
+
// Snapshot each of the last N commits via an ephemeral git worktree + the deterministic
|
|
10
|
+
// pipeline, then render. Read-only over the repo; worktrees live under the OS temp dir and
|
|
11
|
+
// are removed after each commit (the caller's working tree is never touched).
|
|
12
|
+
//
|
|
13
|
+
// Metrics per snapshot:
|
|
14
|
+
// confirmed = duplicate-logic overlaps with confidence 'high' (body-confirmed real duplications)
|
|
15
|
+
// candidates = duplicate-logic overlaps not 'refuted' (confirmed + unverified)
|
|
16
|
+
// coupling = total weight of non-test edges whose endpoints sit in different domains
|
|
17
|
+
|
|
18
|
+
import { readFileSync, mkdtempSync, rmSync } from 'node:fs';
|
|
19
|
+
import { tmpdir } from 'node:os';
|
|
20
|
+
import { join, dirname, resolve } from 'node:path';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
import { execFileSync } from 'node:child_process';
|
|
23
|
+
|
|
24
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
25
|
+
const SPARK = '▁▂▃▄▅▆▇█';
|
|
26
|
+
|
|
27
|
+
function parseArgs(argv) {
|
|
28
|
+
const a = { graphs: [], git: null, last: 10, focus: '.', json: false, labels: null };
|
|
29
|
+
for (let i = 0; i < argv.length; i++) {
|
|
30
|
+
const t = argv[i];
|
|
31
|
+
if (t === '--git') a.git = argv[++i];
|
|
32
|
+
else if (t === '--last') a.last = Math.max(1, parseInt(argv[++i], 10) || 10);
|
|
33
|
+
else if (t === '--focus') a.focus = argv[++i];
|
|
34
|
+
else if (t === '--labels') a.labels = (argv[++i] || '').split(',');
|
|
35
|
+
else if (t === '--json') a.json = true;
|
|
36
|
+
else a.graphs.push(t);
|
|
37
|
+
}
|
|
38
|
+
return a;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ---- pure metrics ----
|
|
42
|
+
function metrics(g) {
|
|
43
|
+
const overlaps = Array.isArray(g.overlaps) ? g.overlaps : [];
|
|
44
|
+
const dl = overlaps.filter((o) => o.kind === 'duplicate-logic');
|
|
45
|
+
const confirmed = dl.filter((o) => o.confidence === 'high').length;
|
|
46
|
+
const candidates = dl.filter((o) => o.confidence !== 'refuted').length;
|
|
47
|
+
const nodes = Array.isArray(g.nodes) ? g.nodes : [];
|
|
48
|
+
const edges = Array.isArray(g.edges) ? g.edges : [];
|
|
49
|
+
const dom = new Map(nodes.map((n) => [n.id, n.domain || 'unassigned']));
|
|
50
|
+
let coupling = 0;
|
|
51
|
+
for (const e of edges) {
|
|
52
|
+
if (e.kind === 'test') continue;
|
|
53
|
+
const a = dom.get(e.from), b = dom.get(e.to);
|
|
54
|
+
if (a != null && b != null && a !== b) coupling += e.weight || 1;
|
|
55
|
+
}
|
|
56
|
+
return { confirmed, candidates, coupling, nodes: nodes.length, files: new Set(nodes.map((n) => n.file).filter(Boolean)).size };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ---- pure rendering ----
|
|
60
|
+
function sparkline(values) {
|
|
61
|
+
if (!values.length) return '';
|
|
62
|
+
const min = Math.min(...values), max = Math.max(...values);
|
|
63
|
+
if (max === min) return SPARK[0].repeat(values.length); // flat series
|
|
64
|
+
return values.map((v) => SPARK[Math.round(((v - min) / (max - min)) * (SPARK.length - 1))]).join('');
|
|
65
|
+
}
|
|
66
|
+
function arrow(values) {
|
|
67
|
+
if (values.length < 2) return '→0';
|
|
68
|
+
const d = values[values.length - 1] - values[0];
|
|
69
|
+
return d > 0 ? `↑${d}` : d < 0 ? `↓${-d}` : '→0';
|
|
70
|
+
}
|
|
71
|
+
function line(label, seq) {
|
|
72
|
+
return `${label.padEnd(22)} ${sparkline(seq)} ${seq.join(' → ')} (${arrow(seq)})`;
|
|
73
|
+
}
|
|
74
|
+
function renderTrend(rows, { json } = {}) {
|
|
75
|
+
if (json) return JSON.stringify({ snapshots: rows }, null, 2);
|
|
76
|
+
const confirmed = rows.map((r) => r.confirmed);
|
|
77
|
+
const coupling = rows.map((r) => r.coupling);
|
|
78
|
+
const symbols = rows.map((r) => r.nodes);
|
|
79
|
+
const out = [];
|
|
80
|
+
out.push(`# codeweb — trend (${rows.length} snapshot${rows.length === 1 ? '' : 's'})`);
|
|
81
|
+
out.push('');
|
|
82
|
+
out.push('> Oldest → newest. **confirmed** = body-confirmed duplicate-logic findings; **coupling** = cross-domain edge weight.');
|
|
83
|
+
out.push('');
|
|
84
|
+
out.push('```');
|
|
85
|
+
out.push(line('confirmed duplications', confirmed));
|
|
86
|
+
out.push(line('cross-domain coupling', coupling));
|
|
87
|
+
out.push(line('symbols', symbols));
|
|
88
|
+
out.push('```');
|
|
89
|
+
out.push('');
|
|
90
|
+
out.push('| # | snapshot | symbols | confirmed dups | candidates | coupling |');
|
|
91
|
+
out.push('|---|----------|--------:|---------------:|-----------:|---------:|');
|
|
92
|
+
rows.forEach((r, i) => out.push(`| ${i + 1} | ${r.label} | ${r.nodes} | ${r.confirmed} | ${r.candidates} | ${r.coupling} |`));
|
|
93
|
+
out.push('');
|
|
94
|
+
const d = confirmed[confirmed.length - 1] - confirmed[0];
|
|
95
|
+
out.push(
|
|
96
|
+
rows.length < 2 ? '➡️ Single snapshot — run with more to see a trend.'
|
|
97
|
+
: d > 0 ? `⚠️ Duplication is **rising** (+${d} confirmed since the first snapshot).`
|
|
98
|
+
: d < 0 ? `✅ Duplication is **falling** (${d} confirmed since the first snapshot).`
|
|
99
|
+
: '➡️ Duplication is **flat** across the window.'
|
|
100
|
+
);
|
|
101
|
+
return out.join('\n');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ---- snapshot sources ----
|
|
105
|
+
const loadSnapshot = (p) => JSON.parse(readFileSync(resolve(p), 'utf8'));
|
|
106
|
+
|
|
107
|
+
function gitSnapshots(repo, last, focus) {
|
|
108
|
+
const git = (args) => execFileSync('git', ['-C', repo, ...args], { encoding: 'utf8' });
|
|
109
|
+
const log = git(['log', '-n', String(last), '--format=%H\t%cs', '--', focus]).trim();
|
|
110
|
+
if (!log) return [];
|
|
111
|
+
const commits = log.split(/\r?\n/).map((l) => { const [sha, date] = l.split('\t'); return { sha, date }; }).reverse();
|
|
112
|
+
const rows = [];
|
|
113
|
+
for (const { sha, date } of commits) {
|
|
114
|
+
const base = mkdtempSync(join(tmpdir(), 'codeweb-trend-'));
|
|
115
|
+
const wt = join(base, 'wt'), ws = join(base, 'ws');
|
|
116
|
+
const label = `${sha.slice(0, 7)} (${date})`;
|
|
117
|
+
try {
|
|
118
|
+
git(['worktree', 'add', '--detach', '--force', wt, sha]);
|
|
119
|
+
execFileSync(process.execPath, [join(HERE, 'run.mjs'), join(wt, focus), '--target', sha.slice(0, 7), '--out-dir', ws], { stdio: 'ignore' });
|
|
120
|
+
rows.push({ label, ...metrics(loadSnapshot(join(ws, 'graph.json'))) });
|
|
121
|
+
} catch (e) {
|
|
122
|
+
rows.push({ label, error: String((e && e.message) || e), confirmed: 0, candidates: 0, coupling: 0, nodes: 0, files: 0 });
|
|
123
|
+
} finally {
|
|
124
|
+
try { git(['worktree', 'remove', '--force', wt]); } catch { /* best-effort */ }
|
|
125
|
+
try { rmSync(base, { recursive: true, force: true }); } catch { /* best-effort */ }
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return rows;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ---- main ----
|
|
132
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
133
|
+
let rows = [];
|
|
134
|
+
if (opts.git) {
|
|
135
|
+
rows = gitSnapshots(resolve(opts.git), opts.last, opts.focus);
|
|
136
|
+
} else if (opts.graphs.length) {
|
|
137
|
+
rows = opts.graphs.map((p, i) => {
|
|
138
|
+
const g = loadSnapshot(p);
|
|
139
|
+
const label = (opts.labels && opts.labels[i]) || (g.meta && g.meta.target) || `snapshot ${i + 1}`;
|
|
140
|
+
return { label, ...metrics(g) };
|
|
141
|
+
});
|
|
142
|
+
} else {
|
|
143
|
+
console.error('usage: trend.mjs <graph.json...> [--labels a,b] [--json] | trend.mjs --git <repo> [--last N] [--focus <sub>] [--json]');
|
|
144
|
+
process.exit(2);
|
|
145
|
+
}
|
|
146
|
+
if (!rows.length) { console.error('[codeweb] no snapshots to chart'); process.exit(1); }
|
|
147
|
+
console.log(renderTrend(rows, { json: opts.json }));
|