@mmerterden/multi-agent-pipeline 17.1.0 → 17.4.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 +194 -0
- package/README.md +7 -0
- package/README.tr.md +7 -0
- package/docs/token-budget-history.md +22 -0
- package/install/_dev-only-files.mjs +1 -0
- package/install/codex.mjs +18 -1
- package/install/copilot.mjs +17 -1
- package/package.json +1 -1
- package/pipeline/agents/code-reviewer.md +35 -1
- package/pipeline/commands/multi-agent/autopilot-on/SKILL.md +9 -1
- package/pipeline/lib/autopilot-state.sh +34 -0
- package/pipeline/multi-agent-refs/_dev-context.md +10 -0
- package/pipeline/multi-agent-refs/analysis/locked.md +4 -4
- package/pipeline/multi-agent-refs/analysis/redesign.md +8 -0
- package/pipeline/multi-agent-refs/analysis/render.md +2 -1
- package/pipeline/multi-agent-refs/analysis/review.md +9 -0
- package/pipeline/multi-agent-refs/android-guide.md +14 -0
- package/pipeline/multi-agent-refs/audit-guide.md +12 -0
- package/pipeline/multi-agent-refs/backend-guide.md +10 -0
- package/pipeline/multi-agent-refs/channels/confluence.md +11 -0
- package/pipeline/multi-agent-refs/channels/issue-comment.md +12 -0
- package/pipeline/multi-agent-refs/channels/jira.md +10 -0
- package/pipeline/multi-agent-refs/channels/pr-review-actions.md +13 -0
- package/pipeline/multi-agent-refs/channels/pr.md +37 -0
- package/pipeline/multi-agent-refs/component-dispatch.md +11 -0
- package/pipeline/multi-agent-refs/component-generation.md +11 -0
- package/pipeline/multi-agent-refs/conventions-defaults.md +15 -0
- package/pipeline/multi-agent-refs/cross-cli-contract.md +65 -0
- package/pipeline/multi-agent-refs/features/analysis-jira.md +11 -0
- package/pipeline/multi-agent-refs/features/code-graph.md +40 -0
- package/pipeline/multi-agent-refs/features/design-conformance.md +24 -0
- package/pipeline/multi-agent-refs/features/doctor.md +10 -0
- package/pipeline/multi-agent-refs/features/external-context-injection.md +7 -0
- package/pipeline/multi-agent-refs/features/jira-context.md +9 -0
- package/pipeline/multi-agent-refs/features/model-fallback.md +10 -0
- package/pipeline/multi-agent-refs/features/review-file-set.md +132 -0
- package/pipeline/multi-agent-refs/features/skill-conformance.md +13 -0
- package/pipeline/multi-agent-refs/features/url-enrichment.md +9 -0
- package/pipeline/multi-agent-refs/features/visual-evidence.md +42 -0
- package/pipeline/multi-agent-refs/generate-issue.md +7 -0
- package/pipeline/multi-agent-refs/issue-jira-triad.md +9 -0
- package/pipeline/multi-agent-refs/knowledge.md +6 -0
- package/pipeline/multi-agent-refs/multi-repo-integration-build.md +13 -0
- package/pipeline/multi-agent-refs/phases/modes.md +7 -0
- package/pipeline/multi-agent-refs/phases/operations.md +9 -0
- package/pipeline/multi-agent-refs/phases/phase-0-init.md +1 -1
- package/pipeline/multi-agent-refs/phases/phase-4-review.md +31 -23
- package/pipeline/multi-agent-refs/phases.md +11 -0
- package/pipeline/multi-agent-refs/picker-contract.md +12 -0
- package/pipeline/multi-agent-refs/platform-parity.md +10 -0
- package/pipeline/multi-agent-refs/progress-contract.md +10 -0
- package/pipeline/multi-agent-refs/setup/firebase.md +9 -0
- package/pipeline/multi-agent-refs/swiftui-guide.md +17 -0
- package/pipeline/multi-agent-refs/tracker-contract.md +12 -0
- package/pipeline/multi-agent-refs/web-guide.md +10 -0
- package/pipeline/multi-agent-refs/wiki-capture.md +11 -0
- package/pipeline/schemas/prefs.schema.json +4 -0
- package/pipeline/schemas/review-file-exclusions.json +137 -0
- package/pipeline/schemas/reviewer-output.schema.json +27 -1
- package/pipeline/schemas/token-budget.json +10 -19
- package/pipeline/scripts/autopilot-intake.mjs +5 -1
- package/pipeline/scripts/autopilot-runner.mjs +6 -1
- package/pipeline/scripts/autopilot-status.sh +3 -2
- package/pipeline/scripts/capture-evidence.sh +79 -11
- package/pipeline/scripts/diff-risk-score.mjs +1 -36
- package/pipeline/scripts/gen-ref-toc.mjs +279 -0
- package/pipeline/scripts/git-path.mjs +63 -0
- package/pipeline/scripts/glob-match.mjs +62 -0
- package/pipeline/scripts/graph-mermaid.mjs +251 -0
- package/pipeline/scripts/review-file-filter.mjs +180 -0
- package/pipeline/scripts/skill-conformance.mjs +1 -31
- package/pipeline/scripts/validate-analysis-doc.mjs +53 -0
- package/pipeline/scripts/validate-reviewer.mjs +90 -1
- package/pipeline/scripts/verify-citations.mjs +346 -0
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// gen-ref-toc.mjs - a table of contents on every long reference file.
|
|
3
|
+
//
|
|
4
|
+
// Anthropic's published Agent Skills guidance: "For reference files longer than
|
|
5
|
+
// 100 lines, include a table of contents". The reason is the same one behind
|
|
6
|
+
// the one-level-deep rule - an agent that reads a long file partially needs to
|
|
7
|
+
// know from the top what is further down, and without that it answers from the
|
|
8
|
+
// first screen and stops.
|
|
9
|
+
//
|
|
10
|
+
// Generated, never hand-written. Fifty-six files' worth of hand-maintained
|
|
11
|
+
// contents lists would be wrong by the second edit, and a wrong ToC is worse
|
|
12
|
+
// than none: it tells a reader a section exists where it does not.
|
|
13
|
+
//
|
|
14
|
+
// Section level is DERIVED, not assumed. These files are not consistent: some
|
|
15
|
+
// open at `##` and section at `###` (keychain.md), others open at `###` and
|
|
16
|
+
// section at `##` (phases/phase-3-dev.md). The rule is the shallowest heading
|
|
17
|
+
// depth that occurs more than once - a depth used exactly once is a title, not
|
|
18
|
+
// a section level.
|
|
19
|
+
//
|
|
20
|
+
// Not every long file is eligible, and the exclusion is measured rather than
|
|
21
|
+
// preferred. A contents list helps a file that is OPENED AND SKIMMED. It is
|
|
22
|
+
// pure cost on a file that is loaded whole by contract, because there is no
|
|
23
|
+
// partial read for it to rescue - and those files are already at their limit:
|
|
24
|
+
//
|
|
25
|
+
// the 8 phase docs a ToC each puts 5 of 8 over their per-phase max and the
|
|
26
|
+
// aggregate 1,533 tokens over 62,700
|
|
27
|
+
// rules.md always loaded; 59,908 of a 60,000 byte ceiling, and a
|
|
28
|
+
// ToC takes it to 60,997
|
|
29
|
+
// the analysis refs mounted as one set per analysis run; 158,500 byte
|
|
30
|
+
// ceiling, and the ToCs put them 2,298 over
|
|
31
|
+
//
|
|
32
|
+
// So the two published rules genuinely conflict there, and the ToC is the one
|
|
33
|
+
// that loses: raising either ceiling to buy navigation nobody navigates is the
|
|
34
|
+
// move this repo spent a release removing.
|
|
35
|
+
//
|
|
36
|
+
// The exclusion is DERIVED from those two budgets, never hand-listed. A
|
|
37
|
+
// document entering or leaving a budget changes its ToC status by itself, which
|
|
38
|
+
// is the difference between a rule and a list somebody has to remember.
|
|
39
|
+
//
|
|
40
|
+
// Usage:
|
|
41
|
+
// node gen-ref-toc.mjs write/refresh every eligible file
|
|
42
|
+
// node gen-ref-toc.mjs --check report drift, write nothing, exit 1 if any
|
|
43
|
+
// node gen-ref-toc.mjs --stats byte cost, write nothing
|
|
44
|
+
// node gen-ref-toc.mjs --verify every generated anchor resolves to a heading
|
|
45
|
+
|
|
46
|
+
import { readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
47
|
+
import { dirname, join, relative } from "node:path";
|
|
48
|
+
import { fileURLToPath } from "node:url";
|
|
49
|
+
|
|
50
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
51
|
+
const REFS = join(ROOT, "pipeline", "multi-agent-refs");
|
|
52
|
+
const MIN_LINES = 100;
|
|
53
|
+
const MARK_OPEN = "<!-- toc -->";
|
|
54
|
+
const MARK_CLOSE = "<!-- /toc -->";
|
|
55
|
+
|
|
56
|
+
// Every file that some other gate already holds to a byte or token ceiling.
|
|
57
|
+
function budgeted() {
|
|
58
|
+
const out = new Set();
|
|
59
|
+
const budget = JSON.parse(readFileSync(join(ROOT, "pipeline/schemas/token-budget.json"), "utf8"));
|
|
60
|
+
for (const phase of Object.keys(budget.phases)) {
|
|
61
|
+
out.add(join(REFS, "phases", `${phase}.md`));
|
|
62
|
+
}
|
|
63
|
+
// The always-loaded set, read out of the gate that owns it rather than copied.
|
|
64
|
+
const ctx = readFileSync(join(ROOT, "pipeline/scripts/smoke-context-budget.sh"), "utf8");
|
|
65
|
+
const block = ctx.match(/TRACKED="\n([\s\S]*?)"/);
|
|
66
|
+
if (block) {
|
|
67
|
+
for (const line of block[1].split("\n")) {
|
|
68
|
+
const t = line.trim();
|
|
69
|
+
if (t) out.add(join(ROOT, t));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// The analysis set, mounted whole per analysis run under its own ceiling. Same
|
|
74
|
+
// two globs that gate uses - `analysis/<name>.md` named by the analysis
|
|
75
|
+
// command, plus every analysis-template*.md.
|
|
76
|
+
const cmd = readFileSync(join(ROOT, "pipeline/commands/multi-agent/analysis/SKILL.md"), "utf8");
|
|
77
|
+
for (const m of cmd.matchAll(/analysis\/[a-z-]+\.md/g)) out.add(join(REFS, m[0]));
|
|
78
|
+
for (const e of readdirSync(REFS, { withFileTypes: true })) {
|
|
79
|
+
if (e.isFile() && /^analysis-template.*\.md$/.test(e.name)) out.add(join(REFS, e.name));
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function walk(dir, out = []) {
|
|
85
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
86
|
+
const p = join(dir, e.name);
|
|
87
|
+
if (e.isDirectory()) walk(p, out);
|
|
88
|
+
else if (e.name.endsWith(".md")) out.push(p);
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// GitHub's anchor rule, which is what a `](#...)` link resolves against.
|
|
94
|
+
function slug(text) {
|
|
95
|
+
return text
|
|
96
|
+
.toLowerCase()
|
|
97
|
+
.replace(/`/g, "")
|
|
98
|
+
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
|
|
99
|
+
.replace(/[^\w\s-]/g, "")
|
|
100
|
+
.trim()
|
|
101
|
+
.replace(/\s+/g, "-");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function sections(lines) {
|
|
105
|
+
const heads = [];
|
|
106
|
+
let fenced = false;
|
|
107
|
+
lines.forEach((l, i) => {
|
|
108
|
+
if (/^\s*```/.test(l)) fenced = !fenced;
|
|
109
|
+
if (fenced) return;
|
|
110
|
+
const m = l.match(/^(#{1,6})\s+(.*\S)\s*$/);
|
|
111
|
+
if (m) heads.push({ depth: m[1].length, text: m[2], line: i });
|
|
112
|
+
});
|
|
113
|
+
if (!heads.length) return { level: 0, heads: [] };
|
|
114
|
+
const counts = {};
|
|
115
|
+
for (const h of heads) counts[h.depth] = (counts[h.depth] || 0) + 1;
|
|
116
|
+
const depths = Object.keys(counts)
|
|
117
|
+
.map(Number)
|
|
118
|
+
.sort((a, b) => a - b);
|
|
119
|
+
// The shallowest depth used more than once. A depth used exactly once is the
|
|
120
|
+
// document's title and listing it as the only entry is a ToC of one.
|
|
121
|
+
const level = depths.find((d) => counts[d] > 1) ?? depths[depths.length - 1];
|
|
122
|
+
return { level, heads: heads.filter((h) => h.depth === level) };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function render(heads) {
|
|
126
|
+
const seen = new Map();
|
|
127
|
+
const rows = heads.map((h) => {
|
|
128
|
+
let a = slug(h.text);
|
|
129
|
+
const n = (seen.get(a) || 0) + 1;
|
|
130
|
+
seen.set(a, n);
|
|
131
|
+
if (n > 1) a = `${a}-${n - 1}`;
|
|
132
|
+
return `- [${h.text.replace(/\|/g, "\\|")}](#${a})`;
|
|
133
|
+
});
|
|
134
|
+
return [MARK_OPEN, ...rows, MARK_CLOSE].join("\n");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// After the frontmatter and after the first heading, so the reader sees what the
|
|
138
|
+
// file IS before a list of what is in it.
|
|
139
|
+
function insertionPoint(lines) {
|
|
140
|
+
let i = 0;
|
|
141
|
+
if (lines[0] === "---") {
|
|
142
|
+
i = 1;
|
|
143
|
+
while (i < lines.length && lines[i] !== "---") i += 1;
|
|
144
|
+
i += 1;
|
|
145
|
+
}
|
|
146
|
+
for (let j = i; j < lines.length; j += 1) {
|
|
147
|
+
if (/^#{1,6}\s/.test(lines[j])) return j + 1;
|
|
148
|
+
}
|
|
149
|
+
return i;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const BUDGETED = budgeted();
|
|
153
|
+
const excluded = [];
|
|
154
|
+
const files = walk(REFS)
|
|
155
|
+
.filter((f) => readFileSync(f, "utf8").split("\n").length - 1 > MIN_LINES)
|
|
156
|
+
.filter((f) => {
|
|
157
|
+
if (!BUDGETED.has(f)) return true;
|
|
158
|
+
excluded.push(relative(ROOT, f));
|
|
159
|
+
return false;
|
|
160
|
+
})
|
|
161
|
+
.sort();
|
|
162
|
+
|
|
163
|
+
// Does every `](#anchor)` in a generated block name a heading that exists in the
|
|
164
|
+
// same file? --check cannot answer that: it compares the file against THIS
|
|
165
|
+
// script's output, so a wrong slug rule agrees with itself. This validates the
|
|
166
|
+
// rule against the real headings instead, and it lives here so there is one
|
|
167
|
+
// definition of `slug`, not a second copy inside a gate.
|
|
168
|
+
if (process.argv.includes("--verify")) {
|
|
169
|
+
let total = 0;
|
|
170
|
+
const bad = [];
|
|
171
|
+
for (const f of walk(REFS)) {
|
|
172
|
+
const raw = readFileSync(f, "utf8");
|
|
173
|
+
if (!raw.includes(MARK_OPEN)) continue;
|
|
174
|
+
const lines = raw.split("\n");
|
|
175
|
+
const seen = new Map();
|
|
176
|
+
const anchors = new Set();
|
|
177
|
+
let fenced = false;
|
|
178
|
+
for (const l of lines) {
|
|
179
|
+
if (/^\s*```/.test(l)) fenced = !fenced;
|
|
180
|
+
if (fenced) continue;
|
|
181
|
+
const m = l.match(/^#{1,6}\s+(.*\S)\s*$/);
|
|
182
|
+
if (!m) continue;
|
|
183
|
+
let a = slug(m[1]);
|
|
184
|
+
const n = (seen.get(a) || 0) + 1;
|
|
185
|
+
seen.set(a, n);
|
|
186
|
+
if (n > 1) a = `${a}-${n - 1}`;
|
|
187
|
+
anchors.add(a);
|
|
188
|
+
}
|
|
189
|
+
const s0 = lines.indexOf(MARK_OPEN);
|
|
190
|
+
const e0 = lines.indexOf(MARK_CLOSE, s0);
|
|
191
|
+
for (const l of lines.slice(s0 + 1, e0)) {
|
|
192
|
+
const m = l.match(/\]\(#(.+)\)\s*$/);
|
|
193
|
+
if (!m) continue;
|
|
194
|
+
total += 1;
|
|
195
|
+
if (!anchors.has(m[1])) bad.push(`${relative(ROOT, f)} -> #${m[1]}`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (total === 0) {
|
|
199
|
+
console.log("gen-ref-toc: no anchors found at all - the generator produced nothing");
|
|
200
|
+
process.exit(1);
|
|
201
|
+
}
|
|
202
|
+
if (bad.length) {
|
|
203
|
+
console.log(`gen-ref-toc: ${bad.length} of ${total} anchor(s) point at no heading:`);
|
|
204
|
+
for (const b of bad.slice(0, 10)) console.log(` ${b}`);
|
|
205
|
+
process.exit(1);
|
|
206
|
+
}
|
|
207
|
+
console.log(`gen-ref-toc: ${total} anchors, all resolve to a heading in their own file`);
|
|
208
|
+
process.exit(0);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const mode = process.argv.includes("--check")
|
|
212
|
+
? "check"
|
|
213
|
+
: process.argv.includes("--stats")
|
|
214
|
+
? "stats"
|
|
215
|
+
: "write";
|
|
216
|
+
|
|
217
|
+
const drifted = [];
|
|
218
|
+
const skipped = [];
|
|
219
|
+
let added = 0;
|
|
220
|
+
|
|
221
|
+
for (const f of files) {
|
|
222
|
+
const raw = readFileSync(f, "utf8");
|
|
223
|
+
const rel = relative(ROOT, f);
|
|
224
|
+
const lines = raw.split("\n");
|
|
225
|
+
const { heads } = sections(lines);
|
|
226
|
+
// Fewer than three sections is a file that does not need a map of itself.
|
|
227
|
+
if (heads.length < 3) {
|
|
228
|
+
skipped.push(`${rel} (${heads.length} section(s))`);
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
const block = render(heads);
|
|
232
|
+
|
|
233
|
+
const start = lines.indexOf(MARK_OPEN);
|
|
234
|
+
let next;
|
|
235
|
+
if (start !== -1) {
|
|
236
|
+
const end = lines.indexOf(MARK_CLOSE, start);
|
|
237
|
+
if (end === -1) {
|
|
238
|
+
drifted.push(`${rel}: ${MARK_OPEN} with no ${MARK_CLOSE}`);
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
const current = lines.slice(start, end + 1).join("\n");
|
|
242
|
+
if (current === block) continue;
|
|
243
|
+
next = [...lines.slice(0, start), ...block.split("\n"), ...lines.slice(end + 1)];
|
|
244
|
+
drifted.push(`${rel}: contents no longer match the headings`);
|
|
245
|
+
} else {
|
|
246
|
+
const at = insertionPoint(lines);
|
|
247
|
+
next = [...lines.slice(0, at), "", ...block.split("\n"), ...lines.slice(at)];
|
|
248
|
+
drifted.push(`${rel}: no table of contents`);
|
|
249
|
+
}
|
|
250
|
+
const out = next.join("\n");
|
|
251
|
+
added += out.length - raw.length;
|
|
252
|
+
if (mode === "write") writeFileSync(f, out);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (mode === "stats") {
|
|
256
|
+
console.log(`eligible: ${files.length} ref files over ${MIN_LINES} lines`);
|
|
257
|
+
console.log(`excluded as loaded-whole-by-contract: ${excluded.length}`);
|
|
258
|
+
console.log(`skipped (under 3 sections): ${skipped.length}`);
|
|
259
|
+
console.log(
|
|
260
|
+
`would add: ${added} bytes total, ~${Math.round(added / 4)} tokens if every one were read`,
|
|
261
|
+
);
|
|
262
|
+
process.exit(0);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (mode === "check") {
|
|
266
|
+
if (drifted.length === 0) {
|
|
267
|
+
console.log(`gen-ref-toc: ${files.length} long ref(s) carry a current table of contents`);
|
|
268
|
+
process.exit(0);
|
|
269
|
+
}
|
|
270
|
+
console.log(
|
|
271
|
+
`gen-ref-toc: ${drifted.length} file(s) need \`node pipeline/scripts/gen-ref-toc.mjs\`:`,
|
|
272
|
+
);
|
|
273
|
+
for (const d of drifted) console.log(` ${d}`);
|
|
274
|
+
process.exit(1);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
console.log(
|
|
278
|
+
`gen-ref-toc: ${drifted.length} file(s) updated, ${skipped.length} skipped, ${added} bytes added`,
|
|
279
|
+
);
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file git-path.mjs - undo git's C-style path quoting, in one place.
|
|
5
|
+
*
|
|
6
|
+
* With `core.quotePath` on (the default), git quotes any path containing a
|
|
7
|
+
* non-ASCII byte or an unusual character: a Turkish filename comes out of
|
|
8
|
+
* `diff --name-only` as `"G\303\266r\303\274n\303\274m.swift"`, quotes and all.
|
|
9
|
+
* A consumer that treats that string as a path sees a name that matches no
|
|
10
|
+
* glob, resolves to no file, and simply DISAPPEARS from whatever it was
|
|
11
|
+
* feeding - which is how every non-ASCII-named file was once silently dropped
|
|
12
|
+
* from risk scoring.
|
|
13
|
+
*
|
|
14
|
+
* Shared rather than copied because the failure is silent on every axis that
|
|
15
|
+
* uses it: a dropped file is not an error anywhere, it is an absence.
|
|
16
|
+
*
|
|
17
|
+
* @module pipeline/scripts/git-path
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Reverse git's C-style quoting: octal byte escapes (`\NNN`, one per raw byte,
|
|
22
|
+
* so a multi-byte UTF-8 character is several consecutive triplets) plus the
|
|
23
|
+
* standard `\\ \" \t \n \r` escapes.
|
|
24
|
+
*
|
|
25
|
+
* @param {string} s the INNER text, without the surrounding quotes
|
|
26
|
+
* @returns {string}
|
|
27
|
+
*/
|
|
28
|
+
export function unquoteGitPath(s) {
|
|
29
|
+
const bytes = [];
|
|
30
|
+
for (let i = 0; i < s.length; i++) {
|
|
31
|
+
if (s[i] === "\\") {
|
|
32
|
+
const octal = s.slice(i + 1, i + 4);
|
|
33
|
+
if (/^[0-7]{3}$/.test(octal)) {
|
|
34
|
+
bytes.push(parseInt(octal, 8));
|
|
35
|
+
i += 3;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
const simple = { "\\": 92, '"': 34, t: 9, n: 10, r: 13 };
|
|
39
|
+
const next = s[i + 1];
|
|
40
|
+
if (next in simple) {
|
|
41
|
+
bytes.push(simple[next]);
|
|
42
|
+
i += 1;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
bytes.push(s.charCodeAt(i));
|
|
47
|
+
}
|
|
48
|
+
return Buffer.from(bytes).toString("utf-8");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Unquote a path only if git actually quoted it. An unquoted path is returned
|
|
53
|
+
* untouched, so this is safe to run over every line of any git path listing.
|
|
54
|
+
*
|
|
55
|
+
* @param {string} s
|
|
56
|
+
* @returns {string}
|
|
57
|
+
*/
|
|
58
|
+
export function unquotePathIfNeeded(s) {
|
|
59
|
+
if (s.length >= 2 && s[0] === '"' && s[s.length - 1] === '"') {
|
|
60
|
+
return unquoteGitPath(s.slice(1, -1));
|
|
61
|
+
}
|
|
62
|
+
return s;
|
|
63
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file glob-match.mjs - the one glob matcher, so two denominators cannot drift.
|
|
5
|
+
*
|
|
6
|
+
* `skill-conformance.mjs` matches a changed file against a rule's declared
|
|
7
|
+
* scope; `review-file-filter.mjs` matches the same file against the exclusion
|
|
8
|
+
* list. Those two answers decide what is reviewed and what it was measured
|
|
9
|
+
* against, so a second implementation is not a duplication of code but a
|
|
10
|
+
* duplication of MEANING: the day they disagree, a file is excluded from the
|
|
11
|
+
* review and still counted in the conformance denominator, and nothing says so.
|
|
12
|
+
*
|
|
13
|
+
* Deliberately minimal - the `**\/x`, `*.ext`, `dir/**` shapes the pattern
|
|
14
|
+
* lists actually use. No brace expansion, no extglob, no character classes: a
|
|
15
|
+
* pattern that needs them belongs in a list nobody can read either.
|
|
16
|
+
*
|
|
17
|
+
* @module pipeline/scripts/glob-match
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Compile a glob to an anchored RegExp.
|
|
22
|
+
*
|
|
23
|
+
* `**` followed by `/` matches zero or more path segments, so `**\/x` matches
|
|
24
|
+
* a bare `x` at the root as well as `a/b/x`. A bare `*` never crosses `/`.
|
|
25
|
+
*
|
|
26
|
+
* @param {string} glob
|
|
27
|
+
* @returns {RegExp}
|
|
28
|
+
*/
|
|
29
|
+
export function globToRegExp(glob) {
|
|
30
|
+
let re = "";
|
|
31
|
+
for (let i = 0; i < glob.length; i++) {
|
|
32
|
+
const c = glob[i];
|
|
33
|
+
if (c === "*") {
|
|
34
|
+
if (glob[i + 1] === "*") {
|
|
35
|
+
if (glob[i + 2] === "/") {
|
|
36
|
+
re += "(?:.*/)?";
|
|
37
|
+
i += 2;
|
|
38
|
+
} else {
|
|
39
|
+
re += ".*";
|
|
40
|
+
i += 1;
|
|
41
|
+
}
|
|
42
|
+
} else {
|
|
43
|
+
re += "[^/]*";
|
|
44
|
+
}
|
|
45
|
+
} else if (c === "?") re += "[^/]";
|
|
46
|
+
else re += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
47
|
+
}
|
|
48
|
+
return new RegExp(`^${re}$`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* True when `file` matches any glob, or when the list is empty - an unscoped
|
|
53
|
+
* rule applies everywhere.
|
|
54
|
+
*
|
|
55
|
+
* @param {string} file
|
|
56
|
+
* @param {string[]} globs
|
|
57
|
+
* @returns {boolean}
|
|
58
|
+
*/
|
|
59
|
+
export function matchesAnyGlob(file, globs) {
|
|
60
|
+
if (!globs || globs.length === 0) return true;
|
|
61
|
+
return globs.some((g) => globToRegExp(g).test(file));
|
|
62
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file graph-mermaid.mjs - draw the blast radius that was already measured.
|
|
5
|
+
*
|
|
6
|
+
* The PR body's Impact Analysis asks, in part 3, which symbols and files a
|
|
7
|
+
* change reaches. `graph-affected.mjs` answers exactly that question
|
|
8
|
+
* deterministically, from a graph pinned to a commit - and until now the answer
|
|
9
|
+
* was re-typed as prose by a model while the measurement sat unused on disk.
|
|
10
|
+
*
|
|
11
|
+
* This emits the same answer as a mermaid `flowchart`. Mermaid because GitHub
|
|
12
|
+
* renders it natively in pull requests, issues and markdown files, so the
|
|
13
|
+
* diagram costs no renderer, no plugin and no dependency: the output is text.
|
|
14
|
+
* Jira is deliberately NOT a target - its wiki renderer turns a mermaid fence
|
|
15
|
+
* into a literal `{code:mermaid}` block, so a diagram there is worse than the
|
|
16
|
+
* prose it replaced.
|
|
17
|
+
*
|
|
18
|
+
* Traversal is not reimplemented here. `findByName` and `affected` come from
|
|
19
|
+
* graph-affected.mjs, so the diagram and the text report can never disagree
|
|
20
|
+
* about what is affected - they are the same call.
|
|
21
|
+
*
|
|
22
|
+
* Inputs:
|
|
23
|
+
* "<symbol>" Symbol name; comma-separate for several seeds. Required.
|
|
24
|
+
* --graph <path> Graph file. Default: the same default graph-query uses
|
|
25
|
+
* --depth N Reverse traversal depth. Default: 2
|
|
26
|
+
* --kind K[,K] Restrict to these edge kinds
|
|
27
|
+
* --max-nodes N Node ceiling. Default: 25
|
|
28
|
+
* --direction LR|TD Flowchart direction. Default: LR
|
|
29
|
+
* --json Emit {mermaid, nodes, edges, truncated, baseCommit}
|
|
30
|
+
*
|
|
31
|
+
* Exit codes (same family as graph-affected.mjs, on purpose):
|
|
32
|
+
* 0 - drawn, possibly with nothing affected
|
|
33
|
+
* 1 - graph missing or unreadable; the reason goes to stderr and the
|
|
34
|
+
* caller records it as a gap rather than inventing a diagram
|
|
35
|
+
* 64 - usage error
|
|
36
|
+
*
|
|
37
|
+
* @module pipeline/scripts/graph-mermaid
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import { resolve } from "node:path";
|
|
41
|
+
import { parseFlags } from "./graph-build.mjs";
|
|
42
|
+
import { loadGraph, defaultGraphPath } from "./graph-query.mjs";
|
|
43
|
+
import { findByName, affected } from "./graph-affected.mjs";
|
|
44
|
+
|
|
45
|
+
const DEFAULT_MAX_NODES = 25;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A mermaid-safe identifier for a graph node id.
|
|
49
|
+
*
|
|
50
|
+
* Graph ids carry `:`, `/`, `#` and `.`, all of which mermaid reads as syntax.
|
|
51
|
+
* Rather than escaping them, ids are positional (`n0`, `n1`) and the real name
|
|
52
|
+
* travels in the quoted label, where mermaid only needs `"` handled.
|
|
53
|
+
*
|
|
54
|
+
* @param {number} i
|
|
55
|
+
* @returns {string}
|
|
56
|
+
*/
|
|
57
|
+
export function nodeKey(i) {
|
|
58
|
+
return `n${i}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The label a reader sees. Paths are shown basename-first because a column of
|
|
63
|
+
* identical directory prefixes is the fastest way to make a diagram unreadable,
|
|
64
|
+
* and the full path is already in the text report beside it.
|
|
65
|
+
*
|
|
66
|
+
* @param {object} node
|
|
67
|
+
* @returns {string}
|
|
68
|
+
*/
|
|
69
|
+
export function label(node) {
|
|
70
|
+
// A symbol is known by its name; a file by its basename. Using the path for
|
|
71
|
+
// both printed the DEFINING FILE as the label of every symbol, which drew a
|
|
72
|
+
// diagram where the thing that changed appeared to be something else.
|
|
73
|
+
const base =
|
|
74
|
+
node.kind === "file" || node.kind === "module"
|
|
75
|
+
? (node.path || node.name || node.id).split("/").pop()
|
|
76
|
+
: node.name || node.id;
|
|
77
|
+
const where = node.kind !== "file" && node.path ? ` - ${node.path.split("/").pop()}` : "";
|
|
78
|
+
return `${base}${where}`.replace(/"/g, "'");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Build the diagram model: which nodes survive the ceiling, which edges connect
|
|
83
|
+
* them, and how many were left out.
|
|
84
|
+
*
|
|
85
|
+
* Over the ceiling the highest-degree nodes are kept, because they are the ones
|
|
86
|
+
* a reviewer most needs to see, and the count of the rest is REPORTED. Silently
|
|
87
|
+
* dropping them would draw a small blast radius for a large change, which is
|
|
88
|
+
* the one failure a diagram must not have.
|
|
89
|
+
*
|
|
90
|
+
* @param {object} graph
|
|
91
|
+
* @param {{id: string, dist: number}[]} hits
|
|
92
|
+
* @param {string[]} seedIds
|
|
93
|
+
* @param {number} maxNodes
|
|
94
|
+
* @returns {{nodes: object[], edges: object[], truncated: number}}
|
|
95
|
+
*/
|
|
96
|
+
export function buildModel(graph, hits, seedIds, maxNodes) {
|
|
97
|
+
const byId = new Map(graph.nodes.map((n) => [n.id, n]));
|
|
98
|
+
const wanted = new Map();
|
|
99
|
+
|
|
100
|
+
for (const id of seedIds) {
|
|
101
|
+
const n = byId.get(id);
|
|
102
|
+
if (n) wanted.set(id, { node: n, dist: 0 });
|
|
103
|
+
}
|
|
104
|
+
for (const h of hits) {
|
|
105
|
+
if (wanted.has(h.id)) continue;
|
|
106
|
+
const n = byId.get(h.id);
|
|
107
|
+
if (n) wanted.set(h.id, { node: n, dist: h.dist });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Seeds are never dropped - a diagram without the thing that changed is not a
|
|
111
|
+
// smaller diagram, it is a different question. Order the rest by distance
|
|
112
|
+
// first (near blast radius before far), then by degree, then by id so the
|
|
113
|
+
// output is byte-stable across runs.
|
|
114
|
+
const seeds = [...wanted.values()].filter((e) => e.dist === 0);
|
|
115
|
+
const rest = [...wanted.values()]
|
|
116
|
+
.filter((e) => e.dist !== 0)
|
|
117
|
+
.sort(
|
|
118
|
+
(a, b) =>
|
|
119
|
+
a.dist - b.dist ||
|
|
120
|
+
(b.node.degree || 0) - (a.node.degree || 0) ||
|
|
121
|
+
a.node.id.localeCompare(b.node.id),
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
const room = Math.max(0, maxNodes - seeds.length);
|
|
125
|
+
const kept = [...seeds, ...rest.slice(0, room)];
|
|
126
|
+
const truncated = rest.length - Math.min(rest.length, room);
|
|
127
|
+
|
|
128
|
+
const keptIds = new Set(kept.map((e) => e.node.id));
|
|
129
|
+
const edges = graph.edges
|
|
130
|
+
.filter((e) => keptIds.has(e.from) && keptIds.has(e.to))
|
|
131
|
+
.sort(
|
|
132
|
+
(a, b) =>
|
|
133
|
+
a.from.localeCompare(b.from) || a.to.localeCompare(b.to) || a.kind.localeCompare(b.kind),
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
return { nodes: kept.map((e) => ({ ...e.node, dist: e.dist })), edges, truncated };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Render the model as mermaid text.
|
|
141
|
+
*
|
|
142
|
+
* @param {{nodes: object[], edges: object[], truncated: number}} model
|
|
143
|
+
* @param {{direction?: string, baseCommit?: string}} [opts]
|
|
144
|
+
* @returns {string}
|
|
145
|
+
*/
|
|
146
|
+
export function render(model, opts = {}) {
|
|
147
|
+
const dir = opts.direction || "LR";
|
|
148
|
+
const idx = new Map(model.nodes.map((n, i) => [n.id, nodeKey(i)]));
|
|
149
|
+
const L = [`flowchart ${dir}`];
|
|
150
|
+
|
|
151
|
+
for (const n of model.nodes) {
|
|
152
|
+
const key = idx.get(n.id);
|
|
153
|
+
// Seeds are the thing that changed; everything else is downstream of it.
|
|
154
|
+
L.push(n.dist === 0 ? ` ${key}["${label(n)}"]` : ` ${key}("${label(n)}")`);
|
|
155
|
+
}
|
|
156
|
+
for (const e of model.edges) {
|
|
157
|
+
L.push(` ${idx.get(e.from)} -->|${e.kind}| ${idx.get(e.to)}`);
|
|
158
|
+
}
|
|
159
|
+
if (model.truncated > 0) {
|
|
160
|
+
L.push(` more["+${model.truncated} more, not drawn"]`);
|
|
161
|
+
}
|
|
162
|
+
return L.join("\n");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function main() {
|
|
166
|
+
// Same argument contract as graph-affected.mjs, deliberately: positional parts
|
|
167
|
+
// are one name. Several seeds are comma-separated inside that name, so a PR
|
|
168
|
+
// touching three symbols draws one diagram rather than three.
|
|
169
|
+
const { flags, positional } = parseFlags(process.argv.slice(2));
|
|
170
|
+
const name = positional.join(" ").trim();
|
|
171
|
+
if (!name) {
|
|
172
|
+
console.error('graph-mermaid: a symbol name is required, e.g. graph-mermaid "Flight"');
|
|
173
|
+
process.exit(64);
|
|
174
|
+
}
|
|
175
|
+
const symbols = name
|
|
176
|
+
.split(",")
|
|
177
|
+
.map((s) => s.trim())
|
|
178
|
+
.filter(Boolean);
|
|
179
|
+
|
|
180
|
+
const graphPath = flags.graph && flags.graph !== true ? resolve(flags.graph) : defaultGraphPath();
|
|
181
|
+
let graph;
|
|
182
|
+
try {
|
|
183
|
+
graph = loadGraph(graphPath);
|
|
184
|
+
} catch {
|
|
185
|
+
// Not an error to shout about: a repo with no graph yet simply has no
|
|
186
|
+
// diagram, and the caller records the reason instead of drawing nothing and
|
|
187
|
+
// calling it empty.
|
|
188
|
+
console.error(`graph-mermaid: no code graph at ${graphPath} - run /multi-agent:graph first`);
|
|
189
|
+
process.exit(1);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const depth = Number(flags.depth && flags.depth !== true ? flags.depth : 2);
|
|
193
|
+
// A Set, not an array: `affected` calls `kinds.has(...)`, and an array there
|
|
194
|
+
// silently matches nothing rather than failing.
|
|
195
|
+
const kinds = flags.kind && flags.kind !== true ? new Set(String(flags.kind).split(",")) : null;
|
|
196
|
+
const maxNodes = Number(
|
|
197
|
+
flags["max-nodes"] && flags["max-nodes"] !== true ? flags["max-nodes"] : DEFAULT_MAX_NODES,
|
|
198
|
+
);
|
|
199
|
+
const direction = flags.direction === "TD" ? "TD" : "LR";
|
|
200
|
+
|
|
201
|
+
const seedNodes = symbols.flatMap((s) => findByName(graph, s));
|
|
202
|
+
if (seedNodes.length === 0) {
|
|
203
|
+
console.error(`graph-mermaid: no node named ${symbols.join(", ")} in ${graphPath}`);
|
|
204
|
+
process.exit(1);
|
|
205
|
+
}
|
|
206
|
+
// `affected` takes node OBJECTS, not ids - it reads `n.id` off each seed.
|
|
207
|
+
// Passing ids made every traversal start from `undefined` and return nothing,
|
|
208
|
+
// which looks exactly like "nothing depends on this".
|
|
209
|
+
const seen = new Set();
|
|
210
|
+
const seeds = seedNodes.filter((n) => !seen.has(n.id) && seen.add(n.id));
|
|
211
|
+
const hits = affected({ graph, seeds, depth, kinds });
|
|
212
|
+
const model = buildModel(
|
|
213
|
+
graph,
|
|
214
|
+
hits,
|
|
215
|
+
seeds.map((n) => n.id),
|
|
216
|
+
maxNodes,
|
|
217
|
+
);
|
|
218
|
+
const mermaid = render(model, { direction });
|
|
219
|
+
|
|
220
|
+
if (flags.json) {
|
|
221
|
+
console.log(
|
|
222
|
+
JSON.stringify(
|
|
223
|
+
{
|
|
224
|
+
mermaid,
|
|
225
|
+
nodes: model.nodes.map((n) => n.id),
|
|
226
|
+
edges: model.edges.map((e) => ({ from: e.from, to: e.to, kind: e.kind })),
|
|
227
|
+
truncated: model.truncated,
|
|
228
|
+
baseCommit: graph.baseCommit || null,
|
|
229
|
+
},
|
|
230
|
+
null,
|
|
231
|
+
2,
|
|
232
|
+
),
|
|
233
|
+
);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
console.log("```mermaid");
|
|
238
|
+
console.log(mermaid);
|
|
239
|
+
console.log("```");
|
|
240
|
+
// The pin, printed beside the diagram rather than inside it: a graph built at
|
|
241
|
+
// an older commit draws an older blast radius, and a reader who cannot see
|
|
242
|
+
// which commit it came from has no way to notice.
|
|
243
|
+
if (graph.baseCommit) {
|
|
244
|
+
console.log("");
|
|
245
|
+
console.log(`_Graph at \`${String(graph.baseCommit).slice(0, 12)}\`._`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
250
|
+
main();
|
|
251
|
+
}
|