@mmerterden/multi-agent-pipeline 16.12.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.
Files changed (46) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +4 -4
  3. package/README.tr.md +4 -4
  4. package/docs/adr/0010-own-code-graph.md +129 -0
  5. package/docs/adr/README.md +1 -0
  6. package/docs/architecture.md +2 -2
  7. package/docs/ecosystem.md +5 -5
  8. package/docs/features.md +8 -0
  9. package/package.json +1 -1
  10. package/pipeline/commands/multi-agent/graph/SKILL.md +105 -0
  11. package/pipeline/commands/multi-agent/help/SKILL.md +8 -8
  12. package/pipeline/commands/multi-agent/sync/SKILL.md +12 -12
  13. package/pipeline/commands/multi-agent/uninstall/SKILL.md +9 -7
  14. package/pipeline/multi-agent-refs/cross-cli-contract.md +10 -10
  15. package/pipeline/multi-agent-refs/features/code-graph.md +62 -0
  16. package/pipeline/multi-agent-refs/features/model-fallback.md +44 -2
  17. package/pipeline/multi-agent-refs/knowledge.md +6 -0
  18. package/pipeline/multi-agent-refs/phases/phase-0-init.md +1 -1
  19. package/pipeline/multi-agent-refs/phases/phase-1-analysis.md +5 -0
  20. package/pipeline/multi-agent-refs/phases/phase-4-review.md +3 -3
  21. package/pipeline/multi-agent-refs/phases/phase-7-report.md +2 -0
  22. package/pipeline/preferences-template.json +2 -0
  23. package/pipeline/schemas/code-graph.schema.json +91 -0
  24. package/pipeline/schemas/prefs.schema.json +45 -0
  25. package/pipeline/schemas/token-budget.json +2 -2
  26. package/pipeline/scripts/_code-graph.mjs +518 -0
  27. package/pipeline/scripts/_path-match.mjs +87 -0
  28. package/pipeline/scripts/code-graph-rules/android.json +130 -0
  29. package/pipeline/scripts/code-graph-rules/ios.json +95 -0
  30. package/pipeline/scripts/code-graph-rules/node.json +151 -0
  31. package/pipeline/scripts/code-graph-rules/python.json +91 -0
  32. package/pipeline/scripts/graph-affected.mjs +161 -0
  33. package/pipeline/scripts/graph-build.mjs +157 -0
  34. package/pipeline/scripts/graph-query.mjs +191 -0
  35. package/pipeline/scripts/graph-report.mjs +237 -0
  36. package/pipeline/scripts/smoke-cross-cli-behavior.sh +7 -4
  37. package/pipeline/scripts/test-gap-rules/ios.json +38 -10
  38. package/pipeline/scripts/test-gap-scan.mjs +2 -21
  39. package/pipeline/scripts/uninstall.mjs +11 -2
  40. package/pipeline/scripts/validate-code-graph.mjs +174 -0
  41. package/pipeline/skills/.skills-index.json +14 -3
  42. package/pipeline/skills/shared/README.md +6 -5
  43. package/pipeline/skills/shared/core/multi-agent-graph/SKILL.md +106 -0
  44. package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +11 -11
  45. package/pipeline/skills/shared/core/multi-agent-uninstall/SKILL.md +4 -4
  46. package/pipeline/skills/skills-index.md +4 -3
@@ -0,0 +1,518 @@
1
+ /**
2
+ * @file _code-graph.mjs - deterministic, LLM-free code graph for Phase 1 and Phase 7.
3
+ *
4
+ * Phase 1 narrows its Explore fan-out with this graph, and Phase 7 refreshes it
5
+ * instead of hand-writing architecture.md from a single task's window. The
6
+ * design follows graphify (Graphify-Labs/graphify): AST-quality extraction with
7
+ * zero LLM cost, one graph file, an incremental manifest gate, god-node hubs,
8
+ * and token-budgeted traversal as the query surface. The extraction itself is
9
+ * regex over comment-stripped source rather than tree-sitter - a tree-sitter
10
+ * binding would be an npm runtime dependency, which ADR-0004 forbids. That
11
+ * trade is deliberate: definitions and imports survive it, call graphs and type
12
+ * resolution do not, and neither consumer needs those.
13
+ *
14
+ * Definition and import patterns come from the rule files. References do NOT:
15
+ * a per-language reference regex is both brittle and redundant once every
16
+ * defined symbol name is known, so pass 2 intersects each file's identifier set
17
+ * with the global symbol index instead. That is language-agnostic and cannot
18
+ * drift from the definition patterns, because it is derived from them.
19
+ *
20
+ * Two passes, one read per file:
21
+ * 1. strip comments and strings, extract definitions + imports, keep the
22
+ * file's identifier set (a Set of strings, not the source text)
23
+ * 2. intersect each identifier set with the symbol index to emit references
24
+ *
25
+ * Zero runtime dependencies (ADR-0004).
26
+ *
27
+ * @module pipeline/scripts/_code-graph
28
+ */
29
+
30
+ import { readFileSync, existsSync, statSync, readdirSync } from "node:fs";
31
+ import { execFileSync } from "node:child_process";
32
+ import { join, dirname, relative, sep } from "node:path";
33
+ import { fileURLToPath } from "node:url";
34
+ import { isExcludedBy } from "./_path-match.mjs";
35
+
36
+ const __dirname = dirname(fileURLToPath(import.meta.url));
37
+
38
+ export const GRAPH_SCHEMA_VERSION = "1.0.0";
39
+
40
+ /**
41
+ * Load and merge the two rule files for a stack.
42
+ *
43
+ * `test-gap-rules/<stack>.json` is read directly rather than copied: it already
44
+ * owns sourceExtensions, excludePathGlobs and the test-path predicates, and a
45
+ * second copy would drift the first time a pattern changed.
46
+ *
47
+ * @param {string} stack - ios | android | node | python
48
+ * @param {string} [baseDir] - directory holding the two rule dirs
49
+ * @returns {object} merged rules
50
+ */
51
+ export function loadRules(stack, baseDir = __dirname) {
52
+ const gapPath = join(baseDir, "test-gap-rules", `${stack}.json`);
53
+ const graphPath = join(baseDir, "code-graph-rules", `${stack}.json`);
54
+ if (!existsSync(gapPath)) throw new Error(`no test-gap rules for stack '${stack}': ${gapPath}`);
55
+ if (!existsSync(graphPath))
56
+ throw new Error(`no code-graph rules for stack '${stack}': ${graphPath}`);
57
+
58
+ const gap = JSON.parse(readFileSync(gapPath, "utf8"));
59
+ const graph = JSON.parse(readFileSync(graphPath, "utf8"));
60
+
61
+ if (graph.stack !== stack || gap.stack !== stack) {
62
+ throw new Error(`rule file stack mismatch for '${stack}'`);
63
+ }
64
+ if (!Array.isArray(graph.definitionPatterns) || graph.definitionPatterns.length === 0) {
65
+ throw new Error(`code-graph rules for '${stack}' declare no definitionPatterns`);
66
+ }
67
+ if (!Array.isArray(graph.referenceKinds) || graph.referenceKinds.length === 0) {
68
+ throw new Error(`code-graph rules for '${stack}' declare no referenceKinds`);
69
+ }
70
+
71
+ return {
72
+ stack,
73
+ sourceExtensions: gap.sourceExtensions || [],
74
+ excludePathGlobs: gap.excludePathGlobs || [],
75
+ testPathSuffixes: gap.testPathSuffixes || [],
76
+ testPathContains: gap.testPathContains || [],
77
+ comments: graph.comments || {},
78
+ definitionPatterns: graph.definitionPatterns,
79
+ importPatterns: graph.importPatterns || [],
80
+ importSpecifiersAreStrings: graph.importSpecifiersAreStrings === true,
81
+ ignoredIdentifiers: new Set(graph.ignoredIdentifiers || []),
82
+ referenceKinds: new Set(graph.referenceKinds || []),
83
+ };
84
+ }
85
+
86
+ /**
87
+ * Remove comments and string literals so they cannot produce phantom edges.
88
+ *
89
+ * Character-scanned rather than regex-replaced: a regex pass cannot tell a `//`
90
+ * inside a string from a real comment, and that difference is exactly what
91
+ * makes reference edges noisy. Replaces removed spans with spaces so byte
92
+ * offsets, and therefore line numbers, are preserved.
93
+ *
94
+ * @param {string} text - source text
95
+ * @param {object} comments - rules.comments
96
+ * @returns {string} same length, comments and string bodies blanked
97
+ */
98
+ export function stripCode(text, comments = {}) {
99
+ const line = comments.line || [];
100
+ const block = comments.block || [];
101
+ const quotes = comments.string || [];
102
+ const out = text.split("");
103
+
104
+ let i = 0;
105
+ const blank = (from, to) => {
106
+ for (let k = from; k < to && k < out.length; k++) {
107
+ if (out[k] !== "\n") out[k] = " ";
108
+ }
109
+ };
110
+
111
+ while (i < text.length) {
112
+ const rest = text.slice(i, i + 8);
113
+
114
+ const lineTok = line.find((t) => rest.startsWith(t));
115
+ if (lineTok) {
116
+ let end = text.indexOf("\n", i);
117
+ if (end === -1) end = text.length;
118
+ blank(i, end);
119
+ i = end;
120
+ continue;
121
+ }
122
+
123
+ const blockTok = block.find(([open]) => rest.startsWith(open));
124
+ if (blockTok) {
125
+ const [open, close] = blockTok;
126
+ let end = text.indexOf(close, i + open.length);
127
+ end = end === -1 ? text.length : end + close.length;
128
+ blank(i, end);
129
+ i = end;
130
+ continue;
131
+ }
132
+
133
+ const quoteTok = quotes.find((q) => rest.startsWith(q));
134
+ if (quoteTok) {
135
+ let j = i + quoteTok.length;
136
+ while (j < text.length) {
137
+ if (text[j] === "\\") {
138
+ j += 2;
139
+ continue;
140
+ }
141
+ if (text.startsWith(quoteTok, j)) {
142
+ j += quoteTok.length;
143
+ break;
144
+ }
145
+ if (text[j] === "\n" && quoteTok.length === 1) break;
146
+ j++;
147
+ }
148
+ blank(i, j);
149
+ i = j;
150
+ continue;
151
+ }
152
+
153
+ i++;
154
+ }
155
+
156
+ return out.join("");
157
+ }
158
+
159
+ /**
160
+ * First populated capture group of a match.
161
+ *
162
+ * Rule-file patterns may alternate (Swift's `import class Mod.Sym` needs one
163
+ * branch, plain `import Mod` another), which shifts the meaningful group.
164
+ * Reading m[1] alone silently dropped every match of a later branch.
165
+ *
166
+ * @param {RegExpMatchArray} m
167
+ * @returns {string|undefined}
168
+ */
169
+ function captured(m) {
170
+ return m.slice(1).find((g) => g !== undefined);
171
+ }
172
+
173
+ const IMPORT_LINE_RE = /^[ \t]*import[ \t].*$/gm;
174
+
175
+ /**
176
+ * Blank every import line, preserving offsets.
177
+ *
178
+ * Swift's submodule form - `import class DesignKit.PrimaryButton` - otherwise
179
+ * feeds the `class` definition pattern, which registers the MODULE name as a
180
+ * declared type. On a large Swift app that alone lifted two module names into
181
+ * the top ten god-nodes, which is how the bug surfaced. Imports are extracted
182
+ * before this runs, so nothing is lost.
183
+ *
184
+ * @param {string} stripped - output of stripCode
185
+ * @returns {string} same length, import lines blanked
186
+ */
187
+ function blankImportLines(stripped) {
188
+ return stripped.replace(IMPORT_LINE_RE, (line) => " ".repeat(line.length));
189
+ }
190
+
191
+ const IDENTIFIER_RE = /[A-Za-z_][A-Za-z0-9_]*/g;
192
+
193
+ /**
194
+ * Distinct identifiers in a piece of stripped source.
195
+ *
196
+ * @param {string} stripped - output of stripCode
197
+ * @returns {Set<string>}
198
+ */
199
+ export function identifiers(stripped) {
200
+ const found = new Set();
201
+ for (const m of stripped.matchAll(IDENTIFIER_RE)) found.add(m[0]);
202
+ return found;
203
+ }
204
+
205
+ function lineOf(text, index) {
206
+ let line = 1;
207
+ for (let i = 0; i < index && i < text.length; i++) if (text[i] === "\n") line++;
208
+ return line;
209
+ }
210
+
211
+ /**
212
+ * Leading whitespace on the line holding this index.
213
+ *
214
+ * Nesting is read from the line's indentation, not from where the pattern
215
+ * happened to match: `public final class Foo` matches at `class`, seven columns
216
+ * in, and is still a top-level declaration. Indentation is the signal that holds
217
+ * across every language these rules cover - a brace language indents a nested
218
+ * type by convention, Python by grammar - which is why the test lives in the
219
+ * engine rather than in a per-stack rule.
220
+ *
221
+ * @param {string} text
222
+ * @param {number} index
223
+ * @returns {number}
224
+ */
225
+ function indentOf(text, index) {
226
+ const start = text.lastIndexOf("\n", index - 1) + 1;
227
+ let i = start;
228
+ while (i < text.length && (text[i] === " " || text[i] === "\t")) i++;
229
+ return i - start;
230
+ }
231
+
232
+ /**
233
+ * Extract one file's definitions, imports and identifier set.
234
+ *
235
+ * @param {string} text - raw source
236
+ * @param {object} rules - merged rules from loadRules
237
+ * @returns {{definitions: Array, imports: string[], tokens: Set<string>}}
238
+ */
239
+ export function extractFile(text, rules) {
240
+ const stripped = stripCode(text, rules.comments);
241
+
242
+ // Swift and the JVM name a module with a bare identifier, so the fully
243
+ // stripped body is the right text to read imports from. A JavaScript module
244
+ // specifier is a string literal, and stripping strings blanks it: run the
245
+ // import patterns over a text that kept its strings, or every import edge in
246
+ // that stack disappears with nothing to notice it. Comments are still gone
247
+ // either way, so an import written inside a comment never counts, and the
248
+ // definition and identifier passes below still read the fully stripped body.
249
+ const importSource = rules.importSpecifiersAreStrings
250
+ ? stripCode(text, { line: rules.comments.line, block: rules.comments.block })
251
+ : stripped;
252
+
253
+ const imports = [];
254
+ for (const pattern of rules.importPatterns) {
255
+ const re = new RegExp(pattern.regex, "gm");
256
+ for (const m of importSource.matchAll(re)) {
257
+ const mod = captured(m);
258
+ if (mod && !imports.includes(mod)) imports.push(mod);
259
+ }
260
+ }
261
+
262
+ const body = blankImportLines(stripped);
263
+ const definitions = [];
264
+ const seen = new Set();
265
+
266
+ for (const pattern of rules.definitionPatterns) {
267
+ const re = new RegExp(pattern.regex, "gm");
268
+ for (const m of body.matchAll(re)) {
269
+ const name = captured(m);
270
+ if (!name) continue;
271
+ const key = `${name}:${pattern.kind || pattern.id}`;
272
+ if (seen.has(key)) continue;
273
+ seen.add(key);
274
+ definitions.push({
275
+ name,
276
+ kind: pattern.kind || pattern.id,
277
+ patternId: pattern.id,
278
+ line: lineOf(body, m.index),
279
+ nested: indentOf(body, m.index) > 0,
280
+ });
281
+ }
282
+ }
283
+
284
+ return { definitions, imports, tokens: identifiers(body) };
285
+ }
286
+
287
+ /**
288
+ * Every source file under root, honouring .gitignore and excludePathGlobs.
289
+ *
290
+ * Uses `git ls-files` when root is a work tree, so .gitignore and
291
+ * .git/info/exclude are respected without reimplementing them; falls back to a
292
+ * directory walk otherwise.
293
+ *
294
+ * @param {string} root - absolute repo root
295
+ * @param {object} rules - merged rules
296
+ * @returns {string[]} repo-relative paths, sorted
297
+ */
298
+ export function listSourceFiles(root, rules) {
299
+ let candidates;
300
+ try {
301
+ const out = execFileSync("git", ["-C", root, "ls-files", "-co", "--exclude-standard"], {
302
+ encoding: "utf8",
303
+ maxBuffer: 64 * 1024 * 1024,
304
+ });
305
+ candidates = out.split("\n").filter(Boolean);
306
+ } catch {
307
+ candidates = walkDir(root, root);
308
+ }
309
+
310
+ const isSource = (p) => {
311
+ if (!rules.sourceExtensions.some((ext) => p.endsWith(ext))) return false;
312
+ if (isExcludedBy(p, rules.excludePathGlobs)) return false;
313
+ return true;
314
+ };
315
+
316
+ return candidates.filter(isSource).sort();
317
+ }
318
+
319
+ function walkDir(dir, root, acc = []) {
320
+ let entries;
321
+ try {
322
+ entries = readdirSync(dir, { withFileTypes: true });
323
+ } catch {
324
+ return acc;
325
+ }
326
+ for (const entry of entries) {
327
+ if (entry.name === ".git" || entry.name === "node_modules") continue;
328
+ const full = join(dir, entry.name);
329
+ if (entry.isDirectory()) walkDir(full, root, acc);
330
+ else if (entry.isFile()) acc.push(relative(root, full).split(sep).join("/"));
331
+ }
332
+ return acc;
333
+ }
334
+
335
+ /**
336
+ * Is this a test file per the stack's test-path predicates?
337
+ *
338
+ * Test files stay in the graph - they are how Phase 1 finds existing coverage -
339
+ * but they are tagged so the report can separate them from production hubs.
340
+ *
341
+ * @param {string} path - repo-relative path
342
+ * @param {object} rules - merged rules
343
+ * @returns {boolean}
344
+ */
345
+ export function isTestPath(path, rules) {
346
+ if (rules.testPathSuffixes.some((s) => path.endsWith(s))) return true;
347
+ return rules.testPathContains.some((c) => path.includes(c));
348
+ }
349
+
350
+ export const fileNodeId = (path) => `file:${path}`;
351
+ export const symbolNodeId = (path, name) => `sym:${path}#${name}`;
352
+
353
+ /**
354
+ * Build the graph from a set of extracted files.
355
+ *
356
+ * Pass 2 resolves a reference only when the identifier maps to exactly one
357
+ * defined symbol. An ambiguous name (the same type declared in several files)
358
+ * would otherwise fan out to every candidate and turn the busiest names into
359
+ * false hubs, which is the failure that makes god-node output useless.
360
+ *
361
+ * @param {object} params
362
+ * @param {string} params.root - absolute repo root
363
+ * @param {string} params.stack
364
+ * @param {Map<string, object>} params.extracted - relPath -> extractFile result
365
+ * @param {object} params.rules
366
+ * @param {object} [params.manifest] - relPath -> {size, mtimeMs}
367
+ * @param {string|null} [params.baseCommit]
368
+ * @param {string} [params.generatedAt] - ISO timestamp, injected by the caller
369
+ * @returns {object} graph conforming to code-graph.schema.json
370
+ */
371
+ export function buildGraph({
372
+ root,
373
+ stack,
374
+ extracted,
375
+ rules,
376
+ manifest = {},
377
+ baseCommit = null,
378
+ generatedAt,
379
+ }) {
380
+ const nodes = new Map();
381
+ const edges = [];
382
+ const symbolsByName = new Map();
383
+
384
+ for (const [path, data] of extracted) {
385
+ const fid = fileNodeId(path);
386
+ nodes.set(fid, {
387
+ id: fid,
388
+ kind: "file",
389
+ name: path.split("/").pop(),
390
+ path,
391
+ isTest: isTestPath(path, rules),
392
+ degree: 0,
393
+ });
394
+ for (const def of data.definitions) {
395
+ const sid = symbolNodeId(path, def.name);
396
+ if (!nodes.has(sid)) {
397
+ nodes.set(sid, {
398
+ id: sid,
399
+ kind: "symbol",
400
+ name: def.name,
401
+ path,
402
+ line: def.line,
403
+ symbolKind: def.kind,
404
+ isTest: isTestPath(path, rules),
405
+ nested: def.nested === true,
406
+ degree: 0,
407
+ });
408
+ }
409
+ edges.push({ from: fid, to: sid, kind: "defines" });
410
+ // A nested declaration stays a node and keeps its defines edge, so it is
411
+ // still findable by name, but it never becomes a reference target. Sealed
412
+ // hierarchies name their cases after the concept they model - Icon, Color,
413
+ // Success, Disabled - and each is declared exactly once, so the ambiguity
414
+ // rule below does not catch them: every file that merely mentions the
415
+ // framework's Color would otherwise gain an edge to one app's nested case.
416
+ if (def.nested === true) continue;
417
+ if (!symbolsByName.has(def.name)) symbolsByName.set(def.name, []);
418
+ const bucket = symbolsByName.get(def.name);
419
+ if (!bucket.includes(sid)) bucket.push(sid);
420
+ }
421
+ }
422
+
423
+ const byBasename = new Map();
424
+ for (const path of extracted.keys()) {
425
+ const base = path
426
+ .split("/")
427
+ .pop()
428
+ .replace(/\.[^.]+$/, "");
429
+ if (!byBasename.has(base)) byBasename.set(base, []);
430
+ byBasename.get(base).push(path);
431
+ }
432
+
433
+ for (const [path, data] of extracted) {
434
+ const fid = fileNodeId(path);
435
+ for (const mod of data.imports) {
436
+ const targets = byBasename.get(mod);
437
+ if (targets && targets.length === 1) {
438
+ edges.push({ from: fid, to: fileNodeId(targets[0]), kind: "imports" });
439
+ } else {
440
+ const mid = `module:${mod}`;
441
+ if (!nodes.has(mid)) {
442
+ nodes.set(mid, { id: mid, kind: "module", name: mod, path: null, degree: 0 });
443
+ }
444
+ edges.push({ from: fid, to: mid, kind: "imports" });
445
+ }
446
+ }
447
+ }
448
+
449
+ for (const [path, data] of extracted) {
450
+ const fid = fileNodeId(path);
451
+ const emitted = new Set();
452
+ for (const token of data.tokens) {
453
+ if (rules.ignoredIdentifiers.has(token)) continue;
454
+ const candidates = symbolsByName.get(token);
455
+ if (!candidates || candidates.length !== 1) continue;
456
+ const sid = candidates[0];
457
+ const target = nodes.get(sid);
458
+ if (target.path === path) continue;
459
+ if (!rules.referenceKinds.has(target.symbolKind)) continue;
460
+ if (emitted.has(sid)) continue;
461
+ emitted.add(sid);
462
+ edges.push({ from: fid, to: sid, kind: "references" });
463
+ }
464
+ }
465
+
466
+ for (const edge of edges) {
467
+ if (nodes.has(edge.from)) nodes.get(edge.from).degree++;
468
+ if (nodes.has(edge.to)) nodes.get(edge.to).degree++;
469
+ }
470
+
471
+ return {
472
+ schemaVersion: GRAPH_SCHEMA_VERSION,
473
+ stack,
474
+ root,
475
+ baseCommit,
476
+ generatedAt,
477
+ stats: {
478
+ files: extracted.size,
479
+ nodes: nodes.size,
480
+ edges: edges.length,
481
+ },
482
+ nodes: [...nodes.values()],
483
+ edges,
484
+ manifest,
485
+ };
486
+ }
487
+
488
+ /**
489
+ * Manifest entry for one file, used as the incremental gate.
490
+ *
491
+ * @param {string} absPath
492
+ * @returns {{size: number, mtimeMs: number}}
493
+ */
494
+ export function manifestEntry(absPath) {
495
+ const st = statSync(absPath);
496
+ return { size: st.size, mtimeMs: Math.floor(st.mtimeMs) };
497
+ }
498
+
499
+ /**
500
+ * Which files changed since the previous manifest?
501
+ *
502
+ * @param {string} root
503
+ * @param {string[]} files - repo-relative paths
504
+ * @param {object} prevManifest
505
+ * @returns {{changed: string[], removed: string[], manifest: object}}
506
+ */
507
+ export function diffManifest(root, files, prevManifest = {}) {
508
+ const manifest = {};
509
+ const changed = [];
510
+ for (const path of files) {
511
+ const entry = manifestEntry(join(root, path));
512
+ manifest[path] = entry;
513
+ const prev = prevManifest[path];
514
+ if (!prev || prev.size !== entry.size || prev.mtimeMs !== entry.mtimeMs) changed.push(path);
515
+ }
516
+ const removed = Object.keys(prevManifest).filter((p) => !(p in manifest));
517
+ return { changed, removed, manifest };
518
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * @file _path-match.mjs - shared path-glob matching for the stack rule files.
3
+ *
4
+ * `excludePathGlobs` has more than one consumer now: test-gap-scan.mjs walks a
5
+ * diff, _code-graph.mjs walks a whole tree, and both must agree on which paths
6
+ * a rule file excludes. A second copy of the matcher would drift the first time
7
+ * a pattern shape changed - the same reason _stack-routing.mjs was extracted
8
+ * out of build-stack-plugins.mjs - and the two consumers would then silently
9
+ * disagree about what is in scope.
10
+ *
11
+ * Zero runtime dependencies (ADR-0004): the glob subset is hand-rolled rather
12
+ * than delegated to a micromatch-style library.
13
+ *
14
+ * @module pipeline/scripts/_path-match
15
+ */
16
+
17
+ const DOUBLE_STAR_TOKEN = "\u0000";
18
+
19
+ /**
20
+ * Translate a glob's body to regex source, without anchors.
21
+ *
22
+ * `**` is tokenized before the single-`*` pass so the `*` rewrite cannot mangle
23
+ * the already-translated `.*`, which broke cross-directory matches.
24
+ *
25
+ * @param {string} pattern
26
+ * @returns {string} regex source
27
+ */
28
+ function globBody(pattern) {
29
+ return pattern
30
+ .replace(/\./g, "\\.")
31
+ .replace(/\*\*/g, DOUBLE_STAR_TOKEN)
32
+ .replace(/\*/g, "[^/]*")
33
+ .replace(new RegExp(DOUBLE_STAR_TOKEN, "g"), ".*");
34
+ }
35
+
36
+ /**
37
+ * Translate one rule-file glob to a fully anchored RegExp.
38
+ *
39
+ * @param {string} pattern - glob from a rule file's excludePathGlobs
40
+ * @returns {RegExp} anchored matcher
41
+ */
42
+ export function globToRegExp(pattern) {
43
+ return new RegExp("^" + globBody(pattern) + "$");
44
+ }
45
+
46
+ /**
47
+ * Does this path match one rule-file glob?
48
+ *
49
+ * Four pattern shapes, in the order the rule files use them:
50
+ *
51
+ * `build/` a directory segment anywhere in the path
52
+ * `*.egg-info/` the same, with a wildcard in the segment
53
+ * `**\/BuildConfig.java` a full-path pattern, anchored both ends
54
+ * `*.d.ts` a path suffix, with a wildcard
55
+ * `.pb.swift` a literal path suffix
56
+ *
57
+ * The two wildcard shapes used to fall through to a literal `endsWith` /
58
+ * `includes` test against the pattern text, which can never be true: nothing
59
+ * ends with the four characters `*.d.` followed by `ts`. `*.d.ts` in the node
60
+ * rules and `*.egg-info/` in the python rules were both silently inert, so
61
+ * generated typings and build residue reached every consumer of these rules.
62
+ *
63
+ * @param {string} path - repo-relative path
64
+ * @param {string} pattern - one glob
65
+ * @returns {boolean}
66
+ */
67
+ export function matchesGlob(path, pattern) {
68
+ if (pattern.endsWith("/")) {
69
+ if (pattern.includes("*")) return new RegExp("(^|/)" + globBody(pattern)).test(path);
70
+ return path.includes(pattern) || path.startsWith(pattern);
71
+ }
72
+ if (pattern.includes("**")) return globToRegExp(pattern).test(path);
73
+ if (pattern.includes("*")) return new RegExp("(^|/)" + globBody(pattern) + "$").test(path);
74
+ return path.endsWith(pattern);
75
+ }
76
+
77
+ /**
78
+ * Is this path excluded by a rule file's excludePathGlobs?
79
+ *
80
+ * @param {string} path - repo-relative path
81
+ * @param {string[]|undefined} globs - rules.excludePathGlobs
82
+ * @returns {boolean} false when the rule file declares no globs
83
+ */
84
+ export function isExcludedBy(path, globs) {
85
+ if (!Array.isArray(globs)) return false;
86
+ return globs.some((pattern) => matchesGlob(path, pattern));
87
+ }