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