@mmerterden/multi-agent-pipeline 16.11.0 → 16.13.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/CHANGELOG.md +49 -0
- package/README.md +9 -7
- package/README.tr.md +9 -7
- package/docs/adr/0001-three-model-triage.md +5 -0
- package/docs/adr/0010-own-code-graph.md +129 -0
- package/docs/adr/README.md +1 -0
- package/docs/architecture.md +2 -2
- package/docs/ecosystem.md +5 -5
- package/docs/features.md +26 -2
- package/package.json +1 -1
- package/pipeline/claude-md-template.md +1 -1
- package/pipeline/commands/multi-agent/SKILL.md +1 -1
- package/pipeline/commands/multi-agent/analysis/SKILL.md +1 -1
- package/pipeline/commands/multi-agent/graph/SKILL.md +105 -0
- package/pipeline/commands/multi-agent/help/SKILL.md +10 -10
- package/pipeline/commands/multi-agent/resume-local/SKILL.md +2 -2
- package/pipeline/commands/multi-agent/review/SKILL.md +3 -3
- package/pipeline/commands/multi-agent/review-analysis/SKILL.md +1 -1
- package/pipeline/commands/multi-agent/sync/SKILL.md +12 -12
- package/pipeline/commands/multi-agent/uninstall/SKILL.md +9 -7
- package/pipeline/lib/figma-screenshot.sh +107 -7
- package/pipeline/lib/md2confluence-v3.py +133 -25
- package/pipeline/multi-agent-refs/analysis/locked.md +4 -3
- package/pipeline/multi-agent-refs/analysis/render.md +44 -9
- package/pipeline/multi-agent-refs/analysis/review.md +17 -1
- package/pipeline/multi-agent-refs/analysis-template-corporate.md +14 -3
- package/pipeline/multi-agent-refs/cross-cli-contract.md +10 -10
- package/pipeline/multi-agent-refs/features/code-graph.md +62 -0
- package/pipeline/multi-agent-refs/features/model-fallback.md +44 -2
- package/pipeline/multi-agent-refs/knowledge.md +7 -1
- package/pipeline/multi-agent-refs/phases/phase-0-init.md +1 -1
- package/pipeline/multi-agent-refs/phases/phase-1-analysis.md +5 -0
- package/pipeline/multi-agent-refs/phases/phase-4-review.md +9 -9
- package/pipeline/multi-agent-refs/phases/phase-7-report.md +2 -0
- package/pipeline/preferences-template.json +2 -0
- package/pipeline/schemas/analysis-spec.schema.json +2 -0
- package/pipeline/schemas/code-graph.schema.json +91 -0
- package/pipeline/schemas/prefs.schema.json +45 -0
- package/pipeline/schemas/reviewer-output.schema.json +1 -1
- package/pipeline/schemas/token-budget.json +2 -2
- package/pipeline/schemas/triage-output.schema.json +1 -1
- package/pipeline/scripts/_code-graph.mjs +518 -0
- package/pipeline/scripts/_path-match.mjs +87 -0
- package/pipeline/scripts/anonymize-findings.mjs +1 -1
- package/pipeline/scripts/code-graph-rules/android.json +130 -0
- package/pipeline/scripts/code-graph-rules/ios.json +95 -0
- package/pipeline/scripts/code-graph-rules/node.json +151 -0
- package/pipeline/scripts/code-graph-rules/python.json +91 -0
- package/pipeline/scripts/graph-affected.mjs +161 -0
- package/pipeline/scripts/graph-build.mjs +157 -0
- package/pipeline/scripts/graph-query.mjs +191 -0
- package/pipeline/scripts/graph-report.mjs +237 -0
- package/pipeline/scripts/smoke-cross-cli-behavior.sh +13 -10
- package/pipeline/scripts/test-gap-rules/ios.json +38 -10
- package/pipeline/scripts/test-gap-scan.mjs +2 -21
- package/pipeline/scripts/uninstall.mjs +11 -2
- package/pipeline/scripts/validate-analysis-doc.mjs +85 -23
- package/pipeline/scripts/validate-code-graph.mjs +174 -0
- package/pipeline/skills/.skills-index.json +14 -3
- package/pipeline/skills/shared/README.md +6 -5
- package/pipeline/skills/shared/core/multi-agent/SKILL.md +3 -3
- package/pipeline/skills/shared/core/multi-agent-graph/SKILL.md +106 -0
- package/pipeline/skills/shared/core/multi-agent-help/SKILL.md +2 -2
- package/pipeline/skills/shared/core/multi-agent-review/SKILL.md +2 -2
- package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +11 -11
- package/pipeline/skills/shared/core/multi-agent-uninstall/SKILL.md +4 -4
- package/pipeline/skills/skills-index.md +4 -3
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file graph-build.mjs - build the code graph for one repo.
|
|
5
|
+
*
|
|
6
|
+
* Full rebuild every time, by measurement rather than by principle: on the
|
|
7
|
+
* corporate iOS app (4,311 Swift sources, 14.3 MB) the whole pass takes under
|
|
8
|
+
* three seconds, so an incremental extraction path would add a cache file, a
|
|
9
|
+
* staleness bug surface and a second code path to test in exchange for nothing.
|
|
10
|
+
* The manifest is still written - Phase 1 uses it to report how far behind a
|
|
11
|
+
* graph is, not to rebuild a subset.
|
|
12
|
+
*
|
|
13
|
+
* Inputs:
|
|
14
|
+
* --root <path> Repo root. Default: cwd
|
|
15
|
+
* --stack <id> ios | android | node | python. Required.
|
|
16
|
+
* --out <path> Graph output. Default: ~/.claude/knowledge/<project>/code-graph.json
|
|
17
|
+
* --max-nodes N Refuse to write a graph larger than this. Default: 200000.
|
|
18
|
+
* Callers pass prefs.global.codeGraph.maxNodes. A tree that
|
|
19
|
+
* blows past it is vendored or generated source, and writing
|
|
20
|
+
* a several-hundred-megabyte file for it helps nobody, so
|
|
21
|
+
* the build fails loudly instead of succeeding quietly.
|
|
22
|
+
* --json Emit the summary as JSON instead of a line
|
|
23
|
+
*
|
|
24
|
+
* Output:
|
|
25
|
+
* Writes the graph, prints a one-line summary.
|
|
26
|
+
*
|
|
27
|
+
* Exit codes:
|
|
28
|
+
* 0 - built
|
|
29
|
+
* 1 - rule load, walk, write error, or graph past --max-nodes
|
|
30
|
+
* 64 - usage error
|
|
31
|
+
*
|
|
32
|
+
* @module pipeline/scripts/graph-build
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
|
|
36
|
+
import { execFileSync } from "node:child_process";
|
|
37
|
+
import { join, dirname, resolve, basename } from "node:path";
|
|
38
|
+
import { homedir } from "node:os";
|
|
39
|
+
import { fileURLToPath } from "node:url";
|
|
40
|
+
import {
|
|
41
|
+
loadRules,
|
|
42
|
+
listSourceFiles,
|
|
43
|
+
extractFile,
|
|
44
|
+
buildGraph,
|
|
45
|
+
diffManifest,
|
|
46
|
+
} from "./_code-graph.mjs";
|
|
47
|
+
|
|
48
|
+
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
|
|
49
|
+
const DEFAULT_MAX_NODES = 200000;
|
|
50
|
+
|
|
51
|
+
export function parseFlags(argv) {
|
|
52
|
+
const flags = {};
|
|
53
|
+
const positional = [];
|
|
54
|
+
for (let i = 0; i < argv.length; i++) {
|
|
55
|
+
const a = argv[i];
|
|
56
|
+
if (a.startsWith("--")) {
|
|
57
|
+
const k = a.slice(2);
|
|
58
|
+
const v = argv[i + 1];
|
|
59
|
+
if (v !== undefined && !v.startsWith("--")) {
|
|
60
|
+
flags[k] = v;
|
|
61
|
+
i++;
|
|
62
|
+
} else {
|
|
63
|
+
flags[k] = true;
|
|
64
|
+
}
|
|
65
|
+
} else {
|
|
66
|
+
positional.push(a);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return { flags, positional };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function defaultGraphPath(root) {
|
|
73
|
+
return join(homedir(), ".claude", "knowledge", basename(root), "code-graph.json");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function headCommit(root) {
|
|
77
|
+
try {
|
|
78
|
+
return execFileSync("git", ["-C", root, "rev-parse", "HEAD"], {
|
|
79
|
+
encoding: "utf8",
|
|
80
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
81
|
+
}).trim();
|
|
82
|
+
} catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function main() {
|
|
88
|
+
const { flags } = parseFlags(process.argv.slice(2));
|
|
89
|
+
const stack = flags.stack;
|
|
90
|
+
if (!stack || stack === true) {
|
|
91
|
+
process.stderr.write("graph-build: --stack <ios|android|node|python> required\n");
|
|
92
|
+
process.exit(64);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const root = resolve(flags.root || process.cwd());
|
|
96
|
+
if (!existsSync(root)) {
|
|
97
|
+
process.stderr.write(`graph-build: root not found: ${root}\n`);
|
|
98
|
+
process.exit(1);
|
|
99
|
+
}
|
|
100
|
+
const out = flags.out && flags.out !== true ? resolve(flags.out) : defaultGraphPath(root);
|
|
101
|
+
|
|
102
|
+
let rules;
|
|
103
|
+
try {
|
|
104
|
+
rules = loadRules(stack, SCRIPT_DIR);
|
|
105
|
+
} catch (e) {
|
|
106
|
+
process.stderr.write(`graph-build: ${e.message}\n`);
|
|
107
|
+
process.exit(1);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const startedAt = Date.now();
|
|
111
|
+
const files = listSourceFiles(root, rules);
|
|
112
|
+
const extracted = new Map();
|
|
113
|
+
for (const path of files) {
|
|
114
|
+
try {
|
|
115
|
+
extracted.set(path, extractFile(readFileSync(join(root, path), "utf8"), rules));
|
|
116
|
+
} catch {
|
|
117
|
+
/* unreadable file: skip, it simply does not reach the graph */
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const { manifest } = diffManifest(root, [...extracted.keys()], {});
|
|
122
|
+
const graph = buildGraph({
|
|
123
|
+
root,
|
|
124
|
+
stack,
|
|
125
|
+
extracted,
|
|
126
|
+
rules,
|
|
127
|
+
manifest,
|
|
128
|
+
baseCommit: headCommit(root),
|
|
129
|
+
generatedAt: new Date().toISOString(),
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const maxNodes = Number(flags["max-nodes"]) > 0 ? Number(flags["max-nodes"]) : DEFAULT_MAX_NODES;
|
|
133
|
+
if (graph.stats.nodes > maxNodes) {
|
|
134
|
+
process.stderr.write(
|
|
135
|
+
`graph-build: ${graph.stats.nodes} nodes exceeds --max-nodes ${maxNodes}; ` +
|
|
136
|
+
`nothing written. Narrow the tree with the stack rules' excludePathGlobs, ` +
|
|
137
|
+
`or raise prefs.global.codeGraph.maxNodes if this repo really is that large.\n`,
|
|
138
|
+
);
|
|
139
|
+
process.exit(1);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
mkdirSync(dirname(out), { recursive: true });
|
|
143
|
+
writeFileSync(out, JSON.stringify(graph), "utf8");
|
|
144
|
+
|
|
145
|
+
const elapsedMs = Date.now() - startedAt;
|
|
146
|
+
const summary = { out, elapsedMs, ...graph.stats };
|
|
147
|
+
if (flags.json === true) {
|
|
148
|
+
process.stdout.write(JSON.stringify(summary, null, 2) + "\n");
|
|
149
|
+
} else {
|
|
150
|
+
process.stdout.write(
|
|
151
|
+
`graph-build: ${graph.stats.files} files, ${graph.stats.nodes} nodes, ` +
|
|
152
|
+
`${graph.stats.edges} edges in ${elapsedMs}ms -> ${out}\n`,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (process.argv[1] && process.argv[1].endsWith("graph-build.mjs")) main();
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file graph-query.mjs - token-budgeted traversal of the code graph.
|
|
5
|
+
*
|
|
6
|
+
* Phase 1's read surface. Ranks graph nodes against the task text with the
|
|
7
|
+
* pipeline's own BM25 (`_retrieval.mjs`, already gated by eval-recall.mjs),
|
|
8
|
+
* takes the best seeds, then walks their edges breadth-first until the token
|
|
9
|
+
* budget is spent. The budget is the point: an unbounded neighbourhood dump is
|
|
10
|
+
* the context-stuffing anti-pattern this is supposed to replace, so the walk
|
|
11
|
+
* stops on budget rather than on depth alone.
|
|
12
|
+
*
|
|
13
|
+
* Inputs:
|
|
14
|
+
* "<question>" Free text. Required.
|
|
15
|
+
* --graph <path> Graph file. Default: ~/.claude/knowledge/<cwd name>/code-graph.json
|
|
16
|
+
* --budget N Output token cap. Default: 2000
|
|
17
|
+
* --depth N Max BFS depth from a seed. Default: 2
|
|
18
|
+
* --seeds N How many ranked seeds to expand. Default: 8
|
|
19
|
+
* --json Emit JSON instead of text
|
|
20
|
+
*
|
|
21
|
+
* Exit codes:
|
|
22
|
+
* 0 - answered (possibly empty)
|
|
23
|
+
* 1 - graph missing or unreadable
|
|
24
|
+
* 64 - usage error
|
|
25
|
+
*
|
|
26
|
+
* @module pipeline/scripts/graph-query
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
30
|
+
import { join, resolve, basename } from "node:path";
|
|
31
|
+
import { homedir } from "node:os";
|
|
32
|
+
import { bm25 } from "./_retrieval.mjs";
|
|
33
|
+
import { parseFlags } from "./graph-build.mjs";
|
|
34
|
+
|
|
35
|
+
const CHARS_PER_TOKEN = 4;
|
|
36
|
+
|
|
37
|
+
export function defaultGraphPath(cwd = process.cwd()) {
|
|
38
|
+
return join(homedir(), ".claude", "knowledge", basename(resolve(cwd)), "code-graph.json");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function loadGraph(path) {
|
|
42
|
+
if (!existsSync(path)) throw new Error(`graph not found: ${path}`);
|
|
43
|
+
const graph = JSON.parse(readFileSync(path, "utf8"));
|
|
44
|
+
if (!Array.isArray(graph.nodes) || !Array.isArray(graph.edges)) {
|
|
45
|
+
throw new Error(`graph is malformed: ${path}`);
|
|
46
|
+
}
|
|
47
|
+
return graph;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Adjacency in both directions.
|
|
52
|
+
*
|
|
53
|
+
* Traversal has to cross an edge either way: a task naming a base class must
|
|
54
|
+
* reach its subclasses (incoming) as readily as its own file (outgoing).
|
|
55
|
+
*
|
|
56
|
+
* @param {object} graph
|
|
57
|
+
* @returns {Map<string, Array<{id: string, kind: string, dir: string}>>}
|
|
58
|
+
*/
|
|
59
|
+
export function adjacency(graph) {
|
|
60
|
+
const adj = new Map();
|
|
61
|
+
const push = (from, to, kind, dir) => {
|
|
62
|
+
if (!adj.has(from)) adj.set(from, []);
|
|
63
|
+
adj.get(from).push({ id: to, kind, dir });
|
|
64
|
+
};
|
|
65
|
+
for (const e of graph.edges) {
|
|
66
|
+
push(e.from, e.to, e.kind, "out");
|
|
67
|
+
push(e.to, e.from, e.kind, "in");
|
|
68
|
+
}
|
|
69
|
+
return adj;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Rank nodes against the question.
|
|
74
|
+
*
|
|
75
|
+
* `name` outweighs `path` because a task names symbols far more often than
|
|
76
|
+
* directories, and `symbolKind` carries a little signal ("the FlightList view
|
|
77
|
+
* model") without letting the kind word alone win a match.
|
|
78
|
+
*
|
|
79
|
+
* @param {object} graph
|
|
80
|
+
* @param {string} question
|
|
81
|
+
* @returns {Array<{node: object, score: number}>} descending, zero scores dropped
|
|
82
|
+
*/
|
|
83
|
+
export function rankNodes(graph, question) {
|
|
84
|
+
const docs = graph.nodes.map((n) => ({
|
|
85
|
+
name: n.name || "",
|
|
86
|
+
path: n.path || "",
|
|
87
|
+
symbolKind: n.symbolKind || "",
|
|
88
|
+
}));
|
|
89
|
+
const scored = bm25(question, docs, { fieldWeights: { name: 3, path: 1, symbolKind: 1 } });
|
|
90
|
+
return scored
|
|
91
|
+
.filter((s) => s.score > 0)
|
|
92
|
+
.sort((a, b) => b.score - a.score)
|
|
93
|
+
.map((s) => ({ node: graph.nodes[s.index], score: s.score }));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function renderNode(node) {
|
|
97
|
+
const where = node.path ? `${node.path}${node.line ? `:${node.line}` : ""}` : "-";
|
|
98
|
+
const kind = node.symbolKind || node.kind;
|
|
99
|
+
return `${node.name} (${kind}) ${where}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Seed, then breadth-first expand until the budget is spent.
|
|
104
|
+
*
|
|
105
|
+
* @param {object} params
|
|
106
|
+
* @returns {{seeds: object[], hits: object[], truncated: boolean, tokens: number}}
|
|
107
|
+
*/
|
|
108
|
+
export function traverse({ graph, question, budget, depth, seedCount }) {
|
|
109
|
+
const ranked = rankNodes(graph, question);
|
|
110
|
+
const seeds = ranked.slice(0, seedCount).map((r) => r.node);
|
|
111
|
+
const byId = new Map(graph.nodes.map((n) => [n.id, n]));
|
|
112
|
+
const adj = adjacency(graph);
|
|
113
|
+
|
|
114
|
+
const seen = new Set(seeds.map((n) => n.id));
|
|
115
|
+
const hits = [];
|
|
116
|
+
let tokens = 0;
|
|
117
|
+
let truncated = false;
|
|
118
|
+
|
|
119
|
+
const queue = seeds.map((n) => ({ node: n, dist: 0, via: null }));
|
|
120
|
+
while (queue.length) {
|
|
121
|
+
const { node, dist, via } = queue.shift();
|
|
122
|
+
const cost = Math.ceil(renderNode(node).length / CHARS_PER_TOKEN);
|
|
123
|
+
if (tokens + cost > budget) {
|
|
124
|
+
truncated = true;
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
tokens += cost;
|
|
128
|
+
hits.push({ id: node.id, dist, via, line: renderNode(node), degree: node.degree });
|
|
129
|
+
|
|
130
|
+
if (dist >= depth) continue;
|
|
131
|
+
const neighbours = (adj.get(node.id) || [])
|
|
132
|
+
.slice()
|
|
133
|
+
.sort((a, b) => (byId.get(b.id)?.degree || 0) - (byId.get(a.id)?.degree || 0));
|
|
134
|
+
for (const n of neighbours) {
|
|
135
|
+
if (seen.has(n.id)) continue;
|
|
136
|
+
const target = byId.get(n.id);
|
|
137
|
+
if (!target) continue;
|
|
138
|
+
seen.add(n.id);
|
|
139
|
+
queue.push({ node: target, dist: dist + 1, via: `${n.kind}/${n.dir}` });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return { seeds, hits, truncated, tokens };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function main() {
|
|
147
|
+
const { flags, positional } = parseFlags(process.argv.slice(2));
|
|
148
|
+
const question = positional.join(" ").trim();
|
|
149
|
+
if (!question) {
|
|
150
|
+
process.stderr.write('graph-query: a question is required, e.g. graph-query "flight search"\n');
|
|
151
|
+
process.exit(64);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const path = flags.graph && flags.graph !== true ? resolve(flags.graph) : defaultGraphPath();
|
|
155
|
+
let graph;
|
|
156
|
+
try {
|
|
157
|
+
graph = loadGraph(path);
|
|
158
|
+
} catch (e) {
|
|
159
|
+
process.stderr.write(`graph-query: ${e.message}\n`);
|
|
160
|
+
process.exit(1);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const budget = Number(flags.budget) > 0 ? Number(flags.budget) : 2000;
|
|
164
|
+
const depth = Number(flags.depth) > 0 ? Number(flags.depth) : 2;
|
|
165
|
+
const seedCount = Number(flags.seeds) > 0 ? Number(flags.seeds) : 8;
|
|
166
|
+
|
|
167
|
+
const result = traverse({ graph, question, budget, depth, seedCount });
|
|
168
|
+
|
|
169
|
+
if (flags.json === true) {
|
|
170
|
+
process.stdout.write(JSON.stringify({ question, budget, ...result }, null, 2) + "\n");
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
process.stdout.write(`# ${question}\n\n`);
|
|
175
|
+
if (result.hits.length === 0) {
|
|
176
|
+
process.stdout.write("no matching nodes\n");
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
for (const hit of result.hits) {
|
|
180
|
+
const prefix = hit.dist === 0 ? "*" : " ".repeat(hit.dist) + "-";
|
|
181
|
+
const via = hit.via ? ` [${hit.via}]` : "";
|
|
182
|
+
process.stdout.write(`${prefix} ${hit.line}${via}\n`);
|
|
183
|
+
}
|
|
184
|
+
process.stdout.write(
|
|
185
|
+
`\n${result.hits.length} nodes, ~${result.tokens} tokens` +
|
|
186
|
+
(result.truncated ? " (budget reached)" : "") +
|
|
187
|
+
"\n",
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (process.argv[1] && process.argv[1].endsWith("graph-query.mjs")) main();
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file graph-report.mjs - GRAPH_REPORT.md, the human-and-model readable summary.
|
|
5
|
+
*
|
|
6
|
+
* Phase 1 reads this before deciding how wide to explore, and Phase 7 ships it
|
|
7
|
+
* next to the knowledge base. Sections are ordered by how much they narrow a
|
|
8
|
+
* search: hubs first (the types everything touches), then the module map, then
|
|
9
|
+
* the long tail that is usually safe to ignore.
|
|
10
|
+
*
|
|
11
|
+
* God-nodes exclude test files. A test base class is a hub of the test target,
|
|
12
|
+
* not of the architecture, and letting it rank pushes the real hubs off a list
|
|
13
|
+
* whose whole value is its first ten rows.
|
|
14
|
+
*
|
|
15
|
+
* Inputs:
|
|
16
|
+
* --graph <path> Graph file. Default: ~/.claude/knowledge/<cwd name>/code-graph.json
|
|
17
|
+
* --out <path> Report path. Default: GRAPH_REPORT.md beside the graph
|
|
18
|
+
* --top N Rows per ranked section. Default: 15
|
|
19
|
+
* --stdout Print instead of writing
|
|
20
|
+
* --status Print one header line and exit, writing nothing. This is
|
|
21
|
+
* what `/multi-agent:graph status` runs: the graph is a
|
|
22
|
+
* 22MB JSON on a large repo, so "read the header yourself"
|
|
23
|
+
* would mean pulling the whole file into a model's context.
|
|
24
|
+
*
|
|
25
|
+
* Exit codes:
|
|
26
|
+
* 0 - written
|
|
27
|
+
* 1 - graph missing or unreadable
|
|
28
|
+
*
|
|
29
|
+
* @module pipeline/scripts/graph-report
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { writeFileSync } from "node:fs";
|
|
33
|
+
import { execFileSync } from "node:child_process";
|
|
34
|
+
import { join, dirname, resolve } from "node:path";
|
|
35
|
+
import { parseFlags } from "./graph-build.mjs";
|
|
36
|
+
import { loadGraph, defaultGraphPath } from "./graph-query.mjs";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Top-level source directory of a path, used as the module bucket.
|
|
40
|
+
*
|
|
41
|
+
* @param {string} path
|
|
42
|
+
* @returns {string}
|
|
43
|
+
*/
|
|
44
|
+
export function moduleOf(path) {
|
|
45
|
+
if (!path) return "-";
|
|
46
|
+
const parts = path.split("/");
|
|
47
|
+
return parts.length > 1 ? parts[0] : ".";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Section inputs, derived once so the renderer stays declarative.
|
|
52
|
+
*
|
|
53
|
+
* @param {object} graph
|
|
54
|
+
* @param {number} top
|
|
55
|
+
* @returns {object}
|
|
56
|
+
*/
|
|
57
|
+
export function summarize(graph, top) {
|
|
58
|
+
const symbols = graph.nodes.filter((n) => n.kind === "symbol");
|
|
59
|
+
const files = graph.nodes.filter((n) => n.kind === "file");
|
|
60
|
+
|
|
61
|
+
const hubs = symbols
|
|
62
|
+
.filter((n) => !n.isTest)
|
|
63
|
+
.sort((a, b) => b.degree - a.degree)
|
|
64
|
+
.slice(0, top);
|
|
65
|
+
|
|
66
|
+
const modules = new Map();
|
|
67
|
+
for (const f of files) {
|
|
68
|
+
const key = moduleOf(f.path);
|
|
69
|
+
if (!modules.has(key)) modules.set(key, { files: 0, symbols: 0, tests: 0 });
|
|
70
|
+
const bucket = modules.get(key);
|
|
71
|
+
bucket.files++;
|
|
72
|
+
if (f.isTest) bucket.tests++;
|
|
73
|
+
}
|
|
74
|
+
for (const s of symbols) {
|
|
75
|
+
const key = moduleOf(s.path);
|
|
76
|
+
if (modules.has(key)) modules.get(key).symbols++;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const externals = graph.nodes
|
|
80
|
+
.filter((n) => n.kind === "module")
|
|
81
|
+
.sort((a, b) => b.degree - a.degree)
|
|
82
|
+
.slice(0, top);
|
|
83
|
+
|
|
84
|
+
const orphans = files.filter((f) => f.degree === 0);
|
|
85
|
+
|
|
86
|
+
const kinds = {};
|
|
87
|
+
for (const s of symbols) kinds[s.symbolKind] = (kinds[s.symbolKind] || 0) + 1;
|
|
88
|
+
|
|
89
|
+
return { hubs, modules, externals, orphans, kinds, fileCount: files.length };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* @param {object} graph
|
|
94
|
+
* @param {number} top
|
|
95
|
+
* @returns {string} markdown
|
|
96
|
+
*/
|
|
97
|
+
export function render(graph, top) {
|
|
98
|
+
const s = summarize(graph, top);
|
|
99
|
+
const L = [];
|
|
100
|
+
|
|
101
|
+
L.push(`# Code graph report`);
|
|
102
|
+
L.push("");
|
|
103
|
+
L.push(`- Root: \`${graph.root}\``);
|
|
104
|
+
L.push(`- Stack: ${graph.stack}`);
|
|
105
|
+
L.push(`- Built: ${graph.generatedAt}`);
|
|
106
|
+
if (graph.baseCommit) L.push(`- Commit: \`${graph.baseCommit.slice(0, 12)}\``);
|
|
107
|
+
L.push(
|
|
108
|
+
`- Scale: ${graph.stats.files} files, ${graph.stats.nodes} nodes, ${graph.stats.edges} edges`,
|
|
109
|
+
);
|
|
110
|
+
L.push("");
|
|
111
|
+
|
|
112
|
+
L.push(`## Architectural hubs`);
|
|
113
|
+
L.push("");
|
|
114
|
+
L.push(`The most connected non-test symbols. A change here reaches the widest surface.`);
|
|
115
|
+
L.push("");
|
|
116
|
+
L.push(`| Degree | Symbol | Kind | Path |`);
|
|
117
|
+
L.push(`|---|---|---|---|`);
|
|
118
|
+
for (const h of s.hubs) {
|
|
119
|
+
L.push(`| ${h.degree} | \`${h.name}\` | ${h.symbolKind} | \`${h.path}:${h.line || 1}\` |`);
|
|
120
|
+
}
|
|
121
|
+
L.push("");
|
|
122
|
+
|
|
123
|
+
L.push(`## Modules`);
|
|
124
|
+
L.push("");
|
|
125
|
+
L.push(`| Module | Files | Symbols | Test files |`);
|
|
126
|
+
L.push(`|---|---|---|---|`);
|
|
127
|
+
const sortedModules = [...s.modules.entries()].sort((a, b) => b[1].files - a[1].files);
|
|
128
|
+
for (const [name, m] of sortedModules) {
|
|
129
|
+
L.push(`| \`${name}\` | ${m.files} | ${m.symbols} | ${m.tests} |`);
|
|
130
|
+
}
|
|
131
|
+
L.push("");
|
|
132
|
+
|
|
133
|
+
L.push(`## External dependencies`);
|
|
134
|
+
L.push("");
|
|
135
|
+
L.push(`Imported names with no declaring file in this repo.`);
|
|
136
|
+
L.push("");
|
|
137
|
+
L.push(`| Imports | Module |`);
|
|
138
|
+
L.push(`|---|---|`);
|
|
139
|
+
for (const e of s.externals) L.push(`| ${e.degree} | \`${e.name}\` |`);
|
|
140
|
+
L.push("");
|
|
141
|
+
|
|
142
|
+
L.push(`## Symbol kinds`);
|
|
143
|
+
L.push("");
|
|
144
|
+
const kindRows = Object.entries(s.kinds).sort((a, b) => b[1] - a[1]);
|
|
145
|
+
L.push(kindRows.map(([k, n]) => `${k}: ${n}`).join(" · "));
|
|
146
|
+
L.push("");
|
|
147
|
+
|
|
148
|
+
L.push(`## Unconnected files`);
|
|
149
|
+
L.push("");
|
|
150
|
+
if (s.orphans.length === 0) {
|
|
151
|
+
L.push(`None: every file has at least one edge.`);
|
|
152
|
+
} else {
|
|
153
|
+
L.push(
|
|
154
|
+
`${s.orphans.length} of ${s.fileCount} files have no edge at all. ` +
|
|
155
|
+
`Either they declare nothing this stack's rules recognise, or nothing references them.`,
|
|
156
|
+
);
|
|
157
|
+
L.push("");
|
|
158
|
+
for (const o of s.orphans.slice(0, top)) L.push(`- \`${o.path}\``);
|
|
159
|
+
if (s.orphans.length > top) L.push(`- ... ${s.orphans.length - top} more`);
|
|
160
|
+
}
|
|
161
|
+
L.push("");
|
|
162
|
+
|
|
163
|
+
return L.join("\n");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* HEAD of a repo, or null when it is not a git checkout.
|
|
168
|
+
*
|
|
169
|
+
* @param {string} root
|
|
170
|
+
* @returns {string|null}
|
|
171
|
+
*/
|
|
172
|
+
function headCommit(root) {
|
|
173
|
+
try {
|
|
174
|
+
return execFileSync("git", ["-C", root, "rev-parse", "HEAD"], {
|
|
175
|
+
encoding: "utf8",
|
|
176
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
177
|
+
}).trim();
|
|
178
|
+
} catch {
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* One line: what the graph covers and whether it still matches the tree.
|
|
185
|
+
*
|
|
186
|
+
* @param {object} graph
|
|
187
|
+
* @param {string} path
|
|
188
|
+
* @returns {string}
|
|
189
|
+
*/
|
|
190
|
+
export function statusLine(graph, path) {
|
|
191
|
+
const head = headCommit(graph.root);
|
|
192
|
+
let freshness = "freshness unknown (no git HEAD)";
|
|
193
|
+
if (graph.baseCommit && head) {
|
|
194
|
+
freshness =
|
|
195
|
+
graph.baseCommit === head
|
|
196
|
+
? `current (${head.slice(0, 12)})`
|
|
197
|
+
: `STALE: built at ${graph.baseCommit.slice(0, 12)}, HEAD is ${head.slice(0, 12)}`;
|
|
198
|
+
} else if (!graph.baseCommit) {
|
|
199
|
+
freshness = "freshness unknown (graph carries no baseCommit)";
|
|
200
|
+
}
|
|
201
|
+
return (
|
|
202
|
+
`graph-status: ${graph.stack} | ${graph.stats.files} files, ${graph.stats.nodes} nodes, ` +
|
|
203
|
+
`${graph.stats.edges} edges | built ${graph.generatedAt} | ${freshness} | ${path}`
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function main() {
|
|
208
|
+
const { flags } = parseFlags(process.argv.slice(2));
|
|
209
|
+
const path = flags.graph && flags.graph !== true ? resolve(flags.graph) : defaultGraphPath();
|
|
210
|
+
|
|
211
|
+
let graph;
|
|
212
|
+
try {
|
|
213
|
+
graph = loadGraph(path);
|
|
214
|
+
} catch (e) {
|
|
215
|
+
process.stderr.write(`graph-report: ${e.message}\n`);
|
|
216
|
+
process.exit(1);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (flags.status === true) {
|
|
220
|
+
process.stdout.write(statusLine(graph, path) + "\n");
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const top = Number(flags.top) > 0 ? Number(flags.top) : 15;
|
|
225
|
+
const markdown = render(graph, top);
|
|
226
|
+
|
|
227
|
+
if (flags.stdout === true) {
|
|
228
|
+
process.stdout.write(markdown);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
const out =
|
|
232
|
+
flags.out && flags.out !== true ? resolve(flags.out) : join(dirname(path), "GRAPH_REPORT.md");
|
|
233
|
+
writeFileSync(out, markdown, "utf8");
|
|
234
|
+
process.stdout.write(`graph-report: ${out}\n`);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (process.argv[1] && process.argv[1].endsWith("graph-report.mjs")) main();
|
|
@@ -185,12 +185,12 @@ else
|
|
|
185
185
|
fi
|
|
186
186
|
|
|
187
187
|
# ──────────────────────────────────────────────────────────────────────────
|
|
188
|
-
# CLI-aware reviewer-count contract: Claude Code =
|
|
188
|
+
# CLI-aware reviewer-count contract: Claude Code = 3 (Fable + Opus + Sonnet), Copilot CLI
|
|
189
189
|
# = 3 (Opus + GPT-5.4 + Sonnet), Codex CLI = 3 (gpt-5.6 xhigh + gpt-5.4 + gpt-5.6
|
|
190
190
|
# medium). Nothing in code enforces the count (the orchestrator dispatches per the
|
|
191
191
|
# doc), so lock the CONTRACT here against drift across the phase doc, the schema,
|
|
192
192
|
# and the consensus block.
|
|
193
|
-
echo "→ reviewer-count contract (Claude=
|
|
193
|
+
echo "→ reviewer-count contract (Claude=3, Copilot=3, Codex=3)"
|
|
194
194
|
# `/multi-agent:update` runs this smoke on the user's machine, where the tree is
|
|
195
195
|
# ~/.claude/{schemas,multi-agent-refs} and NOT <root>/pipeline/*. Resolving the
|
|
196
196
|
# root by hand here reported three phantom failures in an install, which reads as
|
|
@@ -203,10 +203,13 @@ TRSCHEMA="${MA_SCHEMAS:+$MA_SCHEMAS/triage-output.schema.json}"
|
|
|
203
203
|
|
|
204
204
|
if [ -z "$P4" ] || [ ! -f "$P4" ]; then
|
|
205
205
|
echo " ↷ SKIP: phase-4-review.md not present in this $MA_LAYOUT layout"
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
206
|
+
# Two independent statements, because they can drift apart: the count sentence is
|
|
207
|
+
# the contract, and the matrix is what a reader dispatches from. Three regexes over
|
|
208
|
+
# overlapping prose used to stand in for this and let the count sentence 300 lines
|
|
209
|
+
# further down go stale for a whole release without failing.
|
|
210
|
+
elif grep -qF "Claude Code 3, Copilot CLI 3, Codex CLI 3" "$P4" \
|
|
211
|
+
&& grep -qE '^\| Reviewer 3 .*\|.*\|.*\|.*\|' "$P4"; then
|
|
212
|
+
pass "phase-4-review declares Claude=3 / Copilot=3 / Codex=3 reviewers, and the matrix has all three host columns"
|
|
210
213
|
else
|
|
211
214
|
fail "phase-4-review does not declare the CLI-aware reviewer count for all three hosts"
|
|
212
215
|
fi
|
|
@@ -231,13 +234,13 @@ else
|
|
|
231
234
|
fail "reviewer-output schema missing the CLI-aware note"
|
|
232
235
|
fi
|
|
233
236
|
|
|
234
|
-
# consensus.reviewerCount must accommodate
|
|
237
|
+
# consensus.reviewerCount must accommodate 3 on every host (min 1, no max < 3)
|
|
235
238
|
if [ -z "$TRSCHEMA" ] || [ ! -f "$TRSCHEMA" ]; then
|
|
236
239
|
echo " ↷ SKIP: triage-output.schema.json not present in this $MA_LAYOUT layout"
|
|
237
|
-
elif grep -q '"reviewerCount"' "$TRSCHEMA" && grep -qi "Claude Code =
|
|
238
|
-
pass "triage consensus.reviewerCount documents
|
|
240
|
+
elif grep -q '"reviewerCount"' "$TRSCHEMA" && grep -qi "Claude Code = 3, Copilot CLI = 3" "$TRSCHEMA"; then
|
|
241
|
+
pass "triage consensus.reviewerCount documents 3 reviewers on every host"
|
|
239
242
|
else
|
|
240
|
-
fail "triage consensus.reviewerCount does not document the
|
|
243
|
+
fail "triage consensus.reviewerCount does not document the 3-reviewer set"
|
|
241
244
|
fi
|
|
242
245
|
|
|
243
246
|
echo ""
|
|
@@ -13,20 +13,48 @@
|
|
|
13
13
|
"Generated/",
|
|
14
14
|
"Sourcery/",
|
|
15
15
|
"_Generated.swift",
|
|
16
|
-
"Tests/Fixtures/"
|
|
16
|
+
"Tests/Fixtures/",
|
|
17
|
+
".xctemplate/"
|
|
17
18
|
],
|
|
18
19
|
"publicApiPatterns": [
|
|
19
|
-
{
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
{
|
|
24
|
-
|
|
25
|
-
|
|
20
|
+
{
|
|
21
|
+
"id": "public_func",
|
|
22
|
+
"regex": "\\bpublic\\s+func\\s+([A-Za-z_][A-Za-z0-9_]*)"
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"id": "public_class",
|
|
26
|
+
"regex": "\\bpublic\\s+(?:final\\s+)?class\\s+([A-Z][A-Za-z0-9_]*)"
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"id": "public_struct",
|
|
30
|
+
"regex": "\\bpublic\\s+struct\\s+([A-Z][A-Za-z0-9_]*)"
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"id": "public_enum",
|
|
34
|
+
"regex": "\\bpublic\\s+enum\\s+([A-Z][A-Za-z0-9_]*)"
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"id": "public_proto",
|
|
38
|
+
"regex": "\\bpublic\\s+protocol\\s+([A-Z][A-Za-z0-9_]*)"
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"id": "public_init",
|
|
42
|
+
"regex": "\\bpublic\\s+init\\b"
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"id": "objc_export",
|
|
46
|
+
"regex": "@objc(?:Members)?\\s+(?:public\\s+)?(?:func|class|var)"
|
|
47
|
+
}
|
|
26
48
|
],
|
|
27
49
|
"swiftUiViewPatterns": [
|
|
28
|
-
{
|
|
29
|
-
|
|
50
|
+
{
|
|
51
|
+
"id": "view_struct",
|
|
52
|
+
"regex": "struct\\s+([A-Z][A-Za-z0-9_]*)\\s*:\\s*View\\b"
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"id": "config_struct",
|
|
56
|
+
"regex": "struct\\s+([A-Z][A-Za-z0-9_]*Configuration)\\b"
|
|
57
|
+
}
|
|
30
58
|
],
|
|
31
59
|
"expectedTestKindsForView": ["snapshot", "viewinspector", "unit"],
|
|
32
60
|
"snapshotPathHints": ["SnapshotTests", "__Snapshots__"],
|