@mmerterden/multi-agent-pipeline 17.3.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.
@@ -0,0 +1,137 @@
1
+ {
2
+ "$comment": "Not a JSON Schema: the data the review file filter reads. Lives beside token-budget.json for the same reason - it is a budget-shaped decision the pipeline owns, versioned with the code that consumes it.",
3
+ "version": 1,
4
+ "patterns": [
5
+ {
6
+ "glob": "**/*.generated.*",
7
+ "reason": "generated file - review the generator, not its output"
8
+ },
9
+ {
10
+ "glob": "**/Generated/**",
11
+ "reason": "generated tree - review the generator, not its output"
12
+ },
13
+ {
14
+ "glob": "**/generated/**",
15
+ "reason": "generated tree - review the generator, not its output"
16
+ },
17
+ { "glob": "**/*.pb.go", "reason": "generated file - review the generator, not its output" },
18
+ { "glob": "**/*_pb2.py", "reason": "generated file - review the generator, not its output" },
19
+
20
+ {
21
+ "glob": "**/package-lock.json",
22
+ "reason": "lockfile - resolved by the package manager, not written by hand"
23
+ },
24
+ {
25
+ "glob": "**/yarn.lock",
26
+ "reason": "lockfile - resolved by the package manager, not written by hand"
27
+ },
28
+ {
29
+ "glob": "**/pnpm-lock.yaml",
30
+ "reason": "lockfile - resolved by the package manager, not written by hand"
31
+ },
32
+ {
33
+ "glob": "**/Package.resolved",
34
+ "reason": "lockfile - resolved by the package manager, not written by hand"
35
+ },
36
+ {
37
+ "glob": "**/Podfile.lock",
38
+ "reason": "lockfile - resolved by the package manager, not written by hand"
39
+ },
40
+ {
41
+ "glob": "**/Gemfile.lock",
42
+ "reason": "lockfile - resolved by the package manager, not written by hand"
43
+ },
44
+ {
45
+ "glob": "**/poetry.lock",
46
+ "reason": "lockfile - resolved by the package manager, not written by hand"
47
+ },
48
+ {
49
+ "glob": "**/Cargo.lock",
50
+ "reason": "lockfile - resolved by the package manager, not written by hand"
51
+ },
52
+ {
53
+ "glob": "**/gradle.lockfile",
54
+ "reason": "lockfile - resolved by the package manager, not written by hand"
55
+ },
56
+
57
+ {
58
+ "glob": "**/__snapshots__/**",
59
+ "reason": "recorded snapshot - the assertion is the test that records it"
60
+ },
61
+ {
62
+ "glob": "**/*.snap",
63
+ "reason": "recorded snapshot - the assertion is the test that records it"
64
+ },
65
+ {
66
+ "glob": "**/__Snapshots__/**",
67
+ "reason": "recorded snapshot - the assertion is the test that records it"
68
+ },
69
+ {
70
+ "glob": "**/ReferenceImages/**",
71
+ "reason": "recorded snapshot - the assertion is the test that records it"
72
+ },
73
+
74
+ {
75
+ "glob": "vendor/**",
76
+ "reason": "vendored third-party source - not ours to change in this diff"
77
+ },
78
+ {
79
+ "glob": "**/node_modules/**",
80
+ "reason": "vendored third-party source - not ours to change in this diff"
81
+ },
82
+ {
83
+ "glob": "Pods/**",
84
+ "reason": "vendored third-party source - not ours to change in this diff"
85
+ },
86
+ {
87
+ "glob": "**/third_party/**",
88
+ "reason": "vendored third-party source - not ours to change in this diff"
89
+ },
90
+
91
+ {
92
+ "glob": "**/*.min.js",
93
+ "reason": "build output - unreadable by design, and the source is elsewhere in the diff"
94
+ },
95
+ {
96
+ "glob": "**/*.min.css",
97
+ "reason": "build output - unreadable by design, and the source is elsewhere in the diff"
98
+ },
99
+ {
100
+ "glob": "**/*.map",
101
+ "reason": "build output - unreadable by design, and the source is elsewhere in the diff"
102
+ },
103
+ {
104
+ "glob": "dist/**",
105
+ "reason": "build output - unreadable by design, and the source is elsewhere in the diff"
106
+ },
107
+ {
108
+ "glob": "build/**",
109
+ "reason": "build output - unreadable by design, and the source is elsewhere in the diff"
110
+ },
111
+ {
112
+ "glob": ".build/**",
113
+ "reason": "build output - unreadable by design, and the source is elsewhere in the diff"
114
+ },
115
+ {
116
+ "glob": "**/DerivedData/**",
117
+ "reason": "build output - unreadable by design, and the source is elsewhere in the diff"
118
+ },
119
+
120
+ { "glob": "**/*.png", "reason": "binary asset - a text reviewer cannot read it" },
121
+ { "glob": "**/*.jpg", "reason": "binary asset - a text reviewer cannot read it" },
122
+ { "glob": "**/*.jpeg", "reason": "binary asset - a text reviewer cannot read it" },
123
+ { "glob": "**/*.gif", "reason": "binary asset - a text reviewer cannot read it" },
124
+ { "glob": "**/*.webp", "reason": "binary asset - a text reviewer cannot read it" },
125
+ { "glob": "**/*.pdf", "reason": "binary asset - a text reviewer cannot read it" },
126
+ { "glob": "**/*.zip", "reason": "binary asset - a text reviewer cannot read it" },
127
+ { "glob": "**/*.ipa", "reason": "binary asset - a text reviewer cannot read it" },
128
+ { "glob": "**/*.apk", "reason": "binary asset - a text reviewer cannot read it" },
129
+ { "glob": "**/*.aab", "reason": "binary asset - a text reviewer cannot read it" },
130
+ { "glob": "**/*.mp4", "reason": "binary asset - a text reviewer cannot read it" },
131
+ { "glob": "**/*.mov", "reason": "binary asset - a text reviewer cannot read it" },
132
+ { "glob": "**/*.ttf", "reason": "binary asset - a text reviewer cannot read it" },
133
+ { "glob": "**/*.otf", "reason": "binary asset - a text reviewer cannot read it" },
134
+ { "glob": "**/*.woff", "reason": "binary asset - a text reviewer cannot read it" },
135
+ { "glob": "**/*.woff2", "reason": "binary asset - a text reviewer cannot read it" }
136
+ ]
137
+ }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "https://github.com/mmerterden/multi-agent-pipeline/pipeline/schemas/reviewer-output.schema.json",
4
- "version": "1.2.0",
4
+ "version": "1.3.0",
5
5
  "title": "Multi-Agent Pipeline - Phase 4 reviewer output",
6
6
  "description": "Contract for a single code-reviewer subagent's JSON output in Phase 4 Step 2. Every host dispatches 3 parallel reviewers; the middle slot is CLI-aware: Claude Code (Fable, Opus, Sonnet); Copilot CLI (Opus, GPT-5.4, Sonnet); Codex CLI dispatches 3 (gpt-5.6 at xhigh, gpt-5.4, gpt-5.6 at medium). Every reviewer must return an object matching this shape before Opus triage merges them. v1.1.0 adds the rule-ID conformance checklist: when the orchestrator supplies a ${CRITERIA} block (Phase 4 Step 1.78), the reviewer must return one conformance row per selected rule ID. Findings alone cannot answer 'was this applied completely' - a reviewer that opened nothing returns the same empty findings array as one that checked everything. v1.2.0 adds the optional per-finding fingerprint (Phase 4 Step 2.1): the stable id a finding keeps across review rounds.",
7
7
  "type": "object",
@@ -55,6 +55,32 @@
55
55
  }
56
56
  }
57
57
  }
58
+ },
59
+ "fileCoverage": {
60
+ "type": "array",
61
+ "description": "One row per file in the supplied review denominator, and none outside it. Required whenever a denominator was supplied. The set is computed by review-file-filter.mjs and written to disk BEFORE the reviewer runs, for the same reason selectedRules[] is: after seeing the diff, 'I did not look at that one' and 'there was nothing there' become the same sentence. An empty findings[] from a reviewer that skipped nine of ten files is not a clean review.",
62
+ "items": {
63
+ "type": "object",
64
+ "additionalProperties": false,
65
+ "required": ["path", "verdict"],
66
+ "properties": {
67
+ "path": {
68
+ "type": "string",
69
+ "minLength": 1,
70
+ "description": "Must appear in the supplied denominator, byte for byte."
71
+ },
72
+ "verdict": {
73
+ "type": "string",
74
+ "enum": ["reviewed", "skipped"],
75
+ "description": "reviewed = read in full. skipped = not read, and the reason says why. There is deliberately no 'partial': a file read in part is read, and what was not understood belongs in a finding."
76
+ },
77
+ "reason": {
78
+ "type": "string",
79
+ "minLength": 4,
80
+ "description": "Required when verdict is 'skipped'. Names why the file was not read (too large for the budget, truncated by the diff cap, binary content). 'Not relevant' is a review decision, not a skip reason."
81
+ }
82
+ }
83
+ }
58
84
  }
59
85
  },
60
86
  "$defs": {
@@ -40,6 +40,7 @@
40
40
 
41
41
  import { readFileSync, existsSync } from "node:fs";
42
42
  import { execFileSync } from "node:child_process";
43
+ import { unquoteGitPath, unquotePathIfNeeded } from "./git-path.mjs";
43
44
 
44
45
  const argv = process.argv.slice(2);
45
46
  const flags = {};
@@ -171,42 +172,6 @@ const TEST_PATH_GLOBS = [
171
172
  const DIFF_GIT_QUOTED_RE = /^diff --git "a\/((?:[^"\\]|\\.)*)" "b\/((?:[^"\\]|\\.)*)"$/;
172
173
  const DIFF_GIT_UNQUOTED_RE = /^diff --git a\/(.+?) b\/(.+)$/;
173
174
 
174
- // Reverse git's C-style quoting: octal byte escapes (\NNN, one per raw byte -
175
- // a multi-byte UTF-8 character is several consecutive \NNN triplets) plus
176
- // the standard \\ \" \t \n \r escapes.
177
- function unquoteGitPath(s) {
178
- const bytes = [];
179
- for (let i = 0; i < s.length; i++) {
180
- if (s[i] === "\\") {
181
- const octal = s.slice(i + 1, i + 4);
182
- if (/^[0-7]{3}$/.test(octal)) {
183
- bytes.push(parseInt(octal, 8));
184
- i += 3;
185
- continue;
186
- }
187
- const simple = { "\\": 92, '"': 34, t: 9, n: 10, r: 13 };
188
- const next = s[i + 1];
189
- if (next in simple) {
190
- bytes.push(simple[next]);
191
- i += 1;
192
- continue;
193
- }
194
- }
195
- bytes.push(s.charCodeAt(i));
196
- }
197
- return Buffer.from(bytes).toString("utf-8");
198
- }
199
-
200
- // `git diff --numstat` quotes a non-ASCII/unusual path the same C-style way
201
- // as the `diff --git` header line (e.g. `"\303\226deme.swift"`), just
202
- // without the surrounding "diff --git a/...b/..." context to detect it by.
203
- function unquotePathIfNeeded(s) {
204
- if (s.length >= 2 && s[0] === '"' && s[s.length - 1] === '"') {
205
- return unquoteGitPath(s.slice(1, -1));
206
- }
207
- return s;
208
- }
209
-
210
175
  // Returns the b/-side path from a `diff --git` header line, or null if the
211
176
  // line isn't one. Handles both the quoted and unquoted forms.
212
177
  function matchDiffGitHeader(line) {
@@ -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
+ }