@mmerterden/multi-agent-pipeline 20.21.0 → 20.23.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 (69) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/docs/adr/0010-own-code-graph.md +3 -2
  3. package/docs/adr/0017-change-impact-from-the-graph.md +69 -0
  4. package/docs/adr/README.md +1 -0
  5. package/docs/ecosystem.md +6 -5
  6. package/docs/facts.json +5 -4
  7. package/docs/features.md +2 -2
  8. package/install/_usage-lifecycle.mjs +44 -0
  9. package/install/catalog-history.json +1 -1
  10. package/install/index.mjs +2 -0
  11. package/install/templates/copilot-instructions.md +1 -0
  12. package/manifest.json +72 -65
  13. package/package.json +1 -1
  14. package/pipeline/commands/multi-agent/create-jira/SKILL.md +1 -1
  15. package/pipeline/commands/multi-agent/review/SKILL.md +2 -1
  16. package/pipeline/commands/multi-agent/setup/SKILL.md +9 -18
  17. package/pipeline/commands/multi-agent/update/SKILL.md +3 -3
  18. package/pipeline/lib/json-file-lock.mjs +24 -2
  19. package/pipeline/multi-agent-refs/_dev-context.md +15 -0
  20. package/pipeline/multi-agent-refs/analysis/evidence.md +6 -4
  21. package/pipeline/multi-agent-refs/analysis/locked.md +1 -1
  22. package/pipeline/multi-agent-refs/cross-cli-contract.md +2 -0
  23. package/pipeline/multi-agent-refs/features/analysis-jira.md +5 -0
  24. package/pipeline/multi-agent-refs/features/code-graph.md +66 -2
  25. package/pipeline/multi-agent-refs/features/product-context.md +238 -0
  26. package/pipeline/multi-agent-refs/features/usage-reporting.md +40 -23
  27. package/pipeline/multi-agent-refs/generate-issue.md +11 -1
  28. package/pipeline/multi-agent-refs/knowledge.md +20 -0
  29. package/pipeline/multi-agent-refs/phases/phase-1-plan.md +9 -14
  30. package/pipeline/multi-agent-refs/phases/phase-2-dev.md +1 -1
  31. package/pipeline/multi-agent-refs/phases/phase-3-review.md +7 -6
  32. package/pipeline/multi-agent-refs/platform-parity.md +23 -9
  33. package/pipeline/multi-agent-refs/setup/token-save-flow.md +24 -0
  34. package/pipeline/schemas/agent-state.schema.json +31 -0
  35. package/pipeline/schemas/analysis-spec.schema.json +14 -0
  36. package/pipeline/schemas/code-graph.schema.json +13 -2
  37. package/pipeline/schemas/diff-risk.schema.json +2 -1
  38. package/pipeline/schemas/prefs.schema.json +84 -4
  39. package/pipeline/scripts/_code-graph.mjs +188 -6
  40. package/pipeline/scripts/_diff-impact.mjs +319 -0
  41. package/pipeline/scripts/_supertypes.mjs +133 -0
  42. package/pipeline/scripts/analysis-story-body.mjs +24 -8
  43. package/pipeline/scripts/capture-flush.sh +62 -4
  44. package/pipeline/scripts/code-graph-rules/android.json +1 -0
  45. package/pipeline/scripts/code-graph-rules/ios.json +5 -0
  46. package/pipeline/scripts/code-graph-rules/node.json +1 -0
  47. package/pipeline/scripts/code-graph-rules/python.json +1 -0
  48. package/pipeline/scripts/diff-risk-score.mjs +80 -9
  49. package/pipeline/scripts/git-path.mjs +21 -0
  50. package/pipeline/scripts/graph-affected.mjs +168 -36
  51. package/pipeline/scripts/graph-confirm.mjs +140 -0
  52. package/pipeline/scripts/graph-query.mjs +16 -4
  53. package/pipeline/scripts/learnings-ledger.mjs +3 -2
  54. package/pipeline/scripts/product-context.mjs +550 -0
  55. package/pipeline/scripts/smoke-cross-cli-behavior.sh +39 -1
  56. package/pipeline/scripts/symbol-existence-gate.mjs +21 -9
  57. package/pipeline/scripts/test-gap-scan.mjs +4 -1
  58. package/pipeline/scripts/test-strength.mjs +7 -8
  59. package/pipeline/scripts/usage-identity.mjs +1 -1
  60. package/pipeline/scripts/usage-register.mjs +181 -15
  61. package/pipeline/scripts/validate-code-graph.mjs +3 -2
  62. package/pipeline/scripts/validate-diff-risk.mjs +1 -0
  63. package/pipeline/scripts/valven-gate.mjs +9 -1
  64. package/pipeline/scripts/valven-port.mjs +16 -5
  65. package/pipeline/scripts/valven-split.mjs +6 -4
  66. package/pipeline/skills/.skill-manifest.json +4 -4
  67. package/pipeline/skills/shared/core/multi-agent-review/SKILL.md +5 -1
  68. package/pipeline/skills/shared/core/multi-agent-setup/SKILL.md +11 -9
  69. package/pipeline/skills/shared/core/multi-agent-update/SKILL.md +3 -3
@@ -32,10 +32,11 @@ import { execFileSync } from "node:child_process";
32
32
  import { join, dirname, relative, sep } from "node:path";
33
33
  import { fileURLToPath } from "node:url";
34
34
  import { isExcludedBy } from "./_path-match.mjs";
35
+ import { supertypesAt } from "./_supertypes.mjs";
35
36
 
36
37
  const __dirname = dirname(fileURLToPath(import.meta.url));
37
38
 
38
- export const GRAPH_SCHEMA_VERSION = "1.0.0";
39
+ export const GRAPH_SCHEMA_VERSION = "1.2.0";
39
40
 
40
41
  /**
41
42
  * Stack words the pipeline uses elsewhere, mapped to the rule-file id.
@@ -107,6 +108,13 @@ export function loadRules(requested, baseDir = __dirname) {
107
108
  importSpecifiersAreStrings: graph.importSpecifiersAreStrings === true,
108
109
  ignoredIdentifiers: new Set(graph.ignoredIdentifiers || []),
109
110
  referenceKinds: new Set(graph.referenceKinds || []),
111
+ supertypes: graph.supertypes
112
+ ? {
113
+ syntax: graph.supertypes.syntax,
114
+ kinds: new Set(graph.supertypes.kinds || []),
115
+ extensionPattern: graph.supertypes.extensionPattern || null,
116
+ }
117
+ : null,
110
118
  };
111
119
  }
112
120
 
@@ -256,6 +264,125 @@ function indentOf(text, index) {
256
264
  return i - start;
257
265
  }
258
266
 
267
+ const leading = (line) => line.length - line.replace(/^[ \t]+/, "").length;
268
+ const ATTRIBUTE_RE = /^[ \t]*@/;
269
+ const CLOSER_RE = /^[ \t]*[}\])]/;
270
+ const OPENS_BLOCK_RE = /(?:[{:=]|=>|->)[ \t]*$/;
271
+ const DIRECTIVE_RE = /^#(?:if|elseif|else|endif)\b/;
272
+ const STORED_PROPERTY_RE = /\b(?:var|let|val)\b/;
273
+
274
+ const ATTRIBUTE_BLOCK_MAX = 80;
275
+ const count = (text, re) => (text.match(re) || []).length;
276
+ const bracketBalance = (text) =>
277
+ count(text, /\(/g) - count(text, /\)/g) + count(text, /\[/g) - count(text, /\]/g);
278
+ const CLOSER_ONLY_RE = /^[ \t]*[)\]]+[ \t,]*$/;
279
+ const DECLARES_RE =
280
+ /\b(?:func|init|class|struct|enum|protocol|actor|extension|typealias|def|fun|interface|object|var|let|val)\b/;
281
+
282
+ /** What follows an attribute's argument list on its line; empty when the list runs on. */
283
+ function afterArguments(rest) {
284
+ if (!/^\s*\(/.test(rest)) return rest;
285
+ let depth = 0;
286
+ for (let i = 0; i < rest.length; i++) {
287
+ if (rest[i] === "(" || rest[i] === "[") depth++;
288
+ else if ((rest[i] === ")" || rest[i] === "]") && --depth === 0) return rest.slice(i + 1);
289
+ }
290
+ return "";
291
+ }
292
+
293
+ /**
294
+ * The first line of an attribute whose arguments span several lines and close
295
+ * right above line `start` - `@available(iOS 16,` / `macOS 13, *)`, or a
296
+ * bracket list closed by `)` or `])` on its own line at the declaration's
297
+ * indentation - or null. The block opens at the declaration's indentation,
298
+ * keeps every other line deeper, balances its brackets, and is never a stored
299
+ * property's or another declaration that merely leads with an attribute.
300
+ */
301
+ function attributeBlockStart(lines, start, indent) {
302
+ for (let k = start - 1; k >= 1 && start - k <= ATTRIBUTE_BLOCK_MAX; k--) {
303
+ const text = lines[k - 1];
304
+ if (!text.trim()) return null;
305
+ if (leading(text) === indent && ATTRIBUTE_RE.test(text)) {
306
+ const rest = afterArguments(text.replace(/^[ \t]*@[\w.]+/, ""));
307
+ if (STORED_PROPERTY_RE.test(rest) || text.includes("{") || DECLARES_RE.test(rest))
308
+ return null;
309
+ return bracketBalance(lines.slice(k - 1, start - 1).join("\n")) === 0 && k < start - 1
310
+ ? k
311
+ : null;
312
+ }
313
+ if (leading(text) > indent) continue;
314
+ if (k === start - 1 && leading(text) === indent && CLOSER_ONLY_RE.test(text)) continue;
315
+ return null;
316
+ }
317
+ return null;
318
+ }
319
+
320
+ /**
321
+ * Give every definition a span: `startLine` (the declaration line, or the first
322
+ * of the attribute or decorator lines directly above it, including one whose
323
+ * parenthesised arguments run over several lines, never a stored property)
324
+ * and `endLine` (the last line indented deeper than the declaration,
325
+ * plus a closing bracket at the declaration's own indentation when one
326
+ * follows). A closing bracket that opens the body again - the end of a
327
+ * multi-line parameter list, `) -> Int {`, `):`, `) : Base() {` - continues the
328
+ * span, and compiler directives in column 0 (`#if`, `#endif`) are skipped like
329
+ * blank lines. Code after the span, at the same or a lower indentation,
330
+ * belongs to no declaration until the next one starts.
331
+ *
332
+ * Indentation is the nesting signal the engine already trusts (see indentOf),
333
+ * so this needs no parser and holds for Python as well as the brace languages.
334
+ *
335
+ * @param {Array<{line: number, indent: number}>} definitions - mutated in place
336
+ * @param {string[]} lines - the stripped body, one entry per source line
337
+ */
338
+ export function assignSpans(definitions, lines) {
339
+ for (const d of definitions) {
340
+ let start = d.line;
341
+ while (start > 1) {
342
+ const above = lines[start - 2];
343
+ if (
344
+ ATTRIBUTE_RE.test(above) &&
345
+ leading(above) === d.indent &&
346
+ !STORED_PROPERTY_RE.test(above)
347
+ ) {
348
+ start--;
349
+ continue;
350
+ }
351
+ const block = attributeBlockStart(lines, start, d.indent);
352
+ if (!block) break;
353
+ start = block;
354
+ }
355
+ d.startLine = start;
356
+ }
357
+ const ordered = [...definitions].sort((a, b) => a.line - b.line || a.indent - b.indent);
358
+ for (let i = 0; i < ordered.length; i++) {
359
+ const d = ordered[i];
360
+ let limit = lines.length;
361
+ for (let j = i + 1; j < ordered.length; j++) {
362
+ if (ordered[j].line > d.line && ordered[j].indent <= d.indent) {
363
+ limit = ordered[j].startLine - 1;
364
+ break;
365
+ }
366
+ }
367
+ let end = d.line;
368
+ for (let n = d.line + 1; n <= limit; n++) {
369
+ const text = lines[n - 1];
370
+ if (!text.trim() || DIRECTIVE_RE.test(text)) continue;
371
+ const ind = leading(text);
372
+ if (ind > d.indent) {
373
+ end = n;
374
+ continue;
375
+ }
376
+ if (ind === d.indent && CLOSER_RE.test(text)) {
377
+ end = n;
378
+ if (OPENS_BLOCK_RE.test(text)) continue;
379
+ }
380
+ break;
381
+ }
382
+ d.endLine = end;
383
+ }
384
+ }
385
+
259
386
  /**
260
387
  * Extract one file's definitions, imports and identifier set.
261
388
  *
@@ -298,17 +425,34 @@ export function extractFile(text, rules) {
298
425
  const key = `${name}:${pattern.kind || pattern.id}`;
299
426
  if (seen.has(key)) continue;
300
427
  seen.add(key);
428
+ const kind = pattern.kind || pattern.id;
429
+ const st = rules.supertypes;
430
+ const supertypes =
431
+ st && st.kinds.has(kind) ? supertypesAt(body, m.index + m[0].length, st.syntax) : [];
301
432
  definitions.push({
302
433
  name,
303
- kind: pattern.kind || pattern.id,
434
+ kind,
304
435
  patternId: pattern.id,
305
436
  line: lineOf(body, m.index),
437
+ indent: indentOf(body, m.index),
306
438
  nested: indentOf(body, m.index) > 0,
439
+ ...(supertypes.length ? { supertypes } : {}),
307
440
  });
308
441
  }
309
442
  }
310
443
 
311
- return { definitions, imports, tokens: identifiers(body) };
444
+ const conformances = [];
445
+ if (rules.supertypes?.extensionPattern) {
446
+ for (const m of body.matchAll(new RegExp(rules.supertypes.extensionPattern, "gm"))) {
447
+ const type = captured(m)?.split(".").pop();
448
+ const supertypes = supertypesAt(body, m.index + m[0].length, rules.supertypes.syntax);
449
+ if (type && supertypes.length) conformances.push({ type, supertypes });
450
+ }
451
+ }
452
+
453
+ assignSpans(definitions, body.split("\n"));
454
+ for (const d of definitions) delete d.indent;
455
+ return { definitions, imports, tokens: identifiers(body), conformances };
312
456
  }
313
457
 
314
458
  /**
@@ -325,11 +469,12 @@ export function extractFile(text, rules) {
325
469
  export function listSourceFiles(root, rules) {
326
470
  let candidates;
327
471
  try {
328
- const out = execFileSync("git", ["-C", root, "ls-files", "-co", "--exclude-standard"], {
472
+ const out = execFileSync("git", ["-C", root, "ls-files", "-z", "-co", "--exclude-standard"], {
329
473
  encoding: "utf8",
330
474
  maxBuffer: 64 * 1024 * 1024,
475
+ stdio: ["ignore", "pipe", "ignore"],
331
476
  });
332
- candidates = out.split("\n").filter(Boolean);
477
+ candidates = out.split("\0").filter(Boolean);
333
478
  } catch {
334
479
  candidates = walkDir(root, root);
335
480
  }
@@ -371,7 +516,8 @@ function walkDir(dir, root, acc = []) {
371
516
  */
372
517
  export function isTestPath(path, rules) {
373
518
  if (rules.testPathSuffixes.some((s) => path.endsWith(s))) return true;
374
- return rules.testPathContains.some((c) => path.includes(c));
519
+ const rooted = `/${path}`;
520
+ return rules.testPathContains.some((c) => rooted.includes(c));
375
521
  }
376
522
 
377
523
  export const fileNodeId = (path) => `file:${path}`;
@@ -427,6 +573,8 @@ export function buildGraph({
427
573
  name: def.name,
428
574
  path,
429
575
  line: def.line,
576
+ ...(def.endLine ? { endLine: def.endLine } : {}),
577
+ ...(def.startLine && def.startLine !== def.line ? { startLine: def.startLine } : {}),
430
578
  symbolKind: def.kind,
431
579
  isTest: isTestPath(path, rules),
432
580
  nested: def.nested === true,
@@ -473,6 +621,10 @@ export function buildGraph({
473
621
  }
474
622
  }
475
623
 
624
+ // Production code cannot depend on a type a test file declares, so a
625
+ // non-test file never gains an edge to one: a test helper named after a
626
+ // framework type (`protocol View` in a fake) would otherwise collect every
627
+ // file that mentions the framework's own.
476
628
  for (const [path, data] of extracted) {
477
629
  const fid = fileNodeId(path);
478
630
  const emitted = new Set();
@@ -483,6 +635,7 @@ export function buildGraph({
483
635
  const sid = candidates[0];
484
636
  const target = nodes.get(sid);
485
637
  if (target.path === path) continue;
638
+ if (target.isTest && !nodes.get(fid).isTest) continue;
486
639
  if (!rules.referenceKinds.has(target.symbolKind)) continue;
487
640
  if (emitted.has(sid)) continue;
488
641
  emitted.add(sid);
@@ -490,6 +643,35 @@ export function buildGraph({
490
643
  }
491
644
  }
492
645
 
646
+ // A subtype points at each supertype the graph defines once: its superclass,
647
+ // the protocols or interfaces it adopts, and conformances an extension adds.
648
+ // The file that names the supertype already references it; this edge says
649
+ // how, so a walk restricted to `inherits` answers "who implements this".
650
+ const unique = (name) => {
651
+ const c = symbolsByName.get(name);
652
+ return c && c.length === 1 ? c[0] : null;
653
+ };
654
+ const inherited = new Set();
655
+ const inherit = (from, superName) => {
656
+ const to = unique(superName);
657
+ const key = `${from}>${to}`;
658
+ if (!from || !to || from === to || inherited.has(key)) return;
659
+ if (nodes.get(to).isTest && !nodes.get(from).isTest) return;
660
+ inherited.add(key);
661
+ edges.push({ from, to, kind: "inherits" });
662
+ };
663
+ for (const [path, data] of extracted) {
664
+ for (const def of data.definitions) {
665
+ for (const sup of def.supertypes || []) inherit(symbolNodeId(path, def.name), sup);
666
+ }
667
+ const testFile = isTestPath(path, rules);
668
+ for (const c of data.conformances || []) {
669
+ const from = unique(c.type);
670
+ if (testFile && from && !nodes.get(from).isTest) continue;
671
+ for (const sup of c.supertypes) inherit(from, sup);
672
+ }
673
+ }
674
+
493
675
  for (const edge of edges) {
494
676
  if (nodes.has(edge.from)) nodes.get(edge.from).degree++;
495
677
  if (nodes.has(edge.to)) nodes.get(edge.to).degree++;
@@ -0,0 +1,319 @@
1
+ /**
2
+ * @file _diff-impact.mjs - a diff, mapped onto the code graph.
3
+ *
4
+ * Three questions a reviewer or a planner asks of a change, answered from the
5
+ * graph without a model:
6
+ *
7
+ * parseHunkRanges which lines of each file the diff touched, on the side
8
+ * the graph was built from (graphSide)
9
+ * changedSymbols which declarations those lines fall in, innermost first,
10
+ * and which files the graph does not index at all
11
+ * graphFreshness how far the graph is behind the tree it describes
12
+ *
13
+ * The reverse walk itself is graph-affected.mjs; this module only produces its
14
+ * seeds. A graph built before definition spans existed (schema 1.0.0) has no
15
+ * `endLine`, so a hunk in such a file seeds the whole file and the file is
16
+ * listed in `fileLevel`: a coarser answer that says it is coarse.
17
+ *
18
+ * Zero runtime dependencies (ADR-0004).
19
+ *
20
+ * @module pipeline/scripts/_diff-impact
21
+ */
22
+
23
+ import { execFileSync } from "node:child_process";
24
+ import { existsSync, statSync } from "node:fs";
25
+ import { join } from "node:path";
26
+ import { diffHeaderPath } from "./git-path.mjs";
27
+
28
+ const HUNK_RE = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
29
+
30
+ /** The path of a `---`/`+++` line: git-quoted or not, one-letter prefix removed. */
31
+ function sidePath(raw, prefix) {
32
+ const path = diffHeaderPath(raw);
33
+ if (path === "/dev/null") return null;
34
+ return path.startsWith(prefix) ? path.slice(prefix.length) : path;
35
+ }
36
+
37
+ function addLine(set, line) {
38
+ set.add(Math.max(1, line));
39
+ }
40
+
41
+ function toRanges(lines) {
42
+ const sorted = [...lines].sort((a, b) => a - b);
43
+ const out = [];
44
+ for (const l of sorted) {
45
+ const last = out[out.length - 1];
46
+ if (last && l === last.end + 1) last.end = l;
47
+ else if (!last || l > last.end) out.push({ start: l, end: l });
48
+ }
49
+ return out;
50
+ }
51
+
52
+ /**
53
+ * Every file of a unified diff with the lines it touched on each side.
54
+ *
55
+ * New side: an added line counts as itself, and a deletion as the line now
56
+ * standing where the removed lines were. Old side: a removed line counts as
57
+ * itself, and an insertion as the old line standing where it went in. Either way a hunk that
58
+ * only adds or only removes still lands inside the declaration it changed.
59
+ * Hunks are counted from their headers, so concatenated `diff -u` output with
60
+ * no `diff --git` lines parses too, CRLF included, at any context size.
61
+ *
62
+ * @param {string} diffText - unified diff
63
+ * @returns {Array<{oldPath: string|null, newPath: string|null, oldRanges: Array<{start: number, end: number}>, newRanges: Array<{start: number, end: number}>}>}
64
+ */
65
+ export function parseDiffFiles(diffText) {
66
+ const files = [];
67
+ let cur = null;
68
+ let pendingOld;
69
+ let oldLine = 0;
70
+ let newLine = 0;
71
+ let oldLeft = 0;
72
+ let newLeft = 0;
73
+ for (const line of String(diffText).split("\n")) {
74
+ const raw = line.endsWith("\r") ? line.slice(0, -1) : line;
75
+ if (oldLeft > 0 || newLeft > 0) {
76
+ if (raw.startsWith("+")) {
77
+ addLine(cur.newLines, newLine);
78
+ addLine(cur.oldLines, oldLine);
79
+ newLine++;
80
+ newLeft--;
81
+ continue;
82
+ }
83
+ if (raw.startsWith("-")) {
84
+ addLine(cur.oldLines, oldLine);
85
+ addLine(cur.newLines, newLine);
86
+ oldLine++;
87
+ oldLeft--;
88
+ continue;
89
+ }
90
+ if (raw.startsWith(" ") || raw === "") {
91
+ oldLine++;
92
+ newLine++;
93
+ oldLeft--;
94
+ newLeft--;
95
+ continue;
96
+ }
97
+ if (raw.startsWith("\\")) continue;
98
+ oldLeft = 0;
99
+ newLeft = 0;
100
+ }
101
+ if (raw.startsWith("diff --git ")) {
102
+ pendingOld = undefined;
103
+ continue;
104
+ }
105
+ if (raw.startsWith("--- ")) {
106
+ pendingOld = sidePath(raw.slice(4), "a/");
107
+ continue;
108
+ }
109
+ if (raw.startsWith("+++ ")) {
110
+ cur = {
111
+ oldPath: pendingOld === undefined ? null : pendingOld,
112
+ newPath: sidePath(raw.slice(4), "b/"),
113
+ oldLines: new Set(),
114
+ newLines: new Set(),
115
+ };
116
+ files.push(cur);
117
+ pendingOld = undefined;
118
+ continue;
119
+ }
120
+ const hunk = HUNK_RE.exec(raw);
121
+ if (hunk && cur) {
122
+ const oldCount = hunk[2] === undefined ? 1 : Number(hunk[2]);
123
+ const newCount = hunk[4] === undefined ? 1 : Number(hunk[4]);
124
+ oldLine = Number(hunk[1]);
125
+ newLine = Number(hunk[3]);
126
+ oldLeft = oldCount;
127
+ newLeft = newCount;
128
+ if (newCount === 0) addLine(cur.newLines, newLine);
129
+ if (oldCount === 0) addLine(cur.oldLines, oldLine);
130
+ }
131
+ }
132
+ return files
133
+ .filter((f) => f.oldPath || f.newPath)
134
+ .map((f) => ({
135
+ oldPath: f.oldPath,
136
+ newPath: f.newPath,
137
+ oldRanges: toRanges(f.oldLines),
138
+ newRanges: toRanges(f.newLines),
139
+ }));
140
+ }
141
+
142
+ /**
143
+ * Line ranges per file on one side of the diff. `new` (default) keys by the
144
+ * new path; a deleted file falls back to its old path. `old` keys by the old
145
+ * path; an added file falls back to its new path. Pick the side the graph was
146
+ * built from (`graphSide`): its spans are line numbers in that tree.
147
+ *
148
+ * @param {string} diffText - unified diff
149
+ * @param {"new"|"old"} [side]
150
+ * @returns {Map<string, Array<{start: number, end: number}>>} in diff order
151
+ */
152
+ export function parseHunkRanges(diffText, side = "new") {
153
+ const out = new Map();
154
+ for (const f of parseDiffFiles(diffText)) {
155
+ const path = side === "old" ? f.oldPath || f.newPath : f.newPath || f.oldPath;
156
+ const ranges = side === "old" ? f.oldRanges : f.newRanges;
157
+ if (ranges.length) out.set(path, ranges);
158
+ }
159
+ return out;
160
+ }
161
+
162
+ /**
163
+ * Declarations a set of ranges falls in.
164
+ *
165
+ * `changed` holds the innermost declaration per range - what a person would
166
+ * call "the function I edited". `seeds` adds every enclosing declaration, since
167
+ * references in the graph point at types, not at the methods inside them, and a
168
+ * walk seeded with the method alone would find no dependents. A range that hits
169
+ * no declaration (top-level code) seeds its file.
170
+ *
171
+ * @param {object} graph
172
+ * @param {Map<string, Array<{start: number, end: number}>>} ranges
173
+ * @returns {{changed: object[], seeds: object[], fileLevel: string[], notIndexed: string[]}}
174
+ */
175
+ export function changedSymbols(graph, ranges) {
176
+ const files = new Map();
177
+ const symbolsByPath = new Map();
178
+ for (const n of graph.nodes) {
179
+ if (n.kind === "file") files.set(n.path, n);
180
+ else if (n.kind === "symbol" && n.path) {
181
+ if (!symbolsByPath.has(n.path)) symbolsByPath.set(n.path, []);
182
+ symbolsByPath.get(n.path).push(n);
183
+ }
184
+ }
185
+
186
+ const changed = new Map();
187
+ const seeds = new Map();
188
+ const fileLevel = [];
189
+ const notIndexed = [];
190
+
191
+ for (const [path, list] of ranges) {
192
+ const fileNode = files.get(path);
193
+ if (!fileNode) {
194
+ notIndexed.push(path);
195
+ continue;
196
+ }
197
+ const symbols = symbolsByPath.get(path) || [];
198
+ const spanned = symbols.filter((s) => Number.isInteger(s.endLine));
199
+ if (symbols.length && !spanned.length) {
200
+ fileLevel.push(path);
201
+ seeds.set(fileNode.id, fileNode);
202
+ continue;
203
+ }
204
+ for (const r of list) {
205
+ const containing = spanned
206
+ .filter((s) => (s.startLine ?? s.line) <= r.end && s.endLine >= r.start)
207
+ .sort((a, b) => b.line - a.line);
208
+ if (!containing.length) {
209
+ seeds.set(fileNode.id, fileNode);
210
+ continue;
211
+ }
212
+ const inner = containing[0];
213
+ changed.set(inner.id, inner);
214
+ for (const s of containing) seeds.set(s.id, s);
215
+ }
216
+ }
217
+
218
+ return { changed: [...changed.values()], seeds: [...seeds.values()], fileLevel, notIndexed };
219
+ }
220
+
221
+ function gitOut(root, args) {
222
+ try {
223
+ return execFileSync("git", ["-C", root, ...args], {
224
+ encoding: "utf8",
225
+ stdio: ["ignore", "pipe", "ignore"],
226
+ }).trim();
227
+ } catch {
228
+ return null;
229
+ }
230
+ }
231
+
232
+ /**
233
+ * How far the graph is behind the tree. `changedFiles` compares each indexed
234
+ * file's size and mtime with the manifest written at build time, so an edit
235
+ * that was never committed counts; `commitMoved` compares the build commit with
236
+ * HEAD. Either makes the graph stale - an answer from it is about the tree as
237
+ * it was, and callers say so instead of presenting it as current.
238
+ *
239
+ * @param {object} graph
240
+ * @param {{root?: string}} [opts] - defaults to the root recorded in the graph
241
+ * @returns {{stale: boolean, commitMoved: boolean|null, baseCommit: string|null, head: string|null, changedFiles: string[], removedFiles: string[]}}
242
+ */
243
+ export function graphFreshness(graph, { root = graph.root } = {}) {
244
+ const manifest = graph.manifest || {};
245
+ const changedFiles = [];
246
+ const removedFiles = [];
247
+ for (const [path, prev] of Object.entries(manifest)) {
248
+ const abs = join(root, path);
249
+ if (!existsSync(abs)) {
250
+ removedFiles.push(path);
251
+ continue;
252
+ }
253
+ const st = statSync(abs);
254
+ if (st.size !== prev.size || Math.floor(st.mtimeMs) !== prev.mtimeMs) changedFiles.push(path);
255
+ }
256
+ const head = root ? gitOut(root, ["rev-parse", "HEAD"]) : null;
257
+ const baseCommit = graph.baseCommit || null;
258
+ const commitMoved = head && baseCommit ? head !== baseCommit : null;
259
+ return {
260
+ stale: Boolean(commitMoved) || changedFiles.length > 0 || removedFiles.length > 0,
261
+ commitMoved,
262
+ baseCommit,
263
+ head,
264
+ changedFiles: changedFiles.sort(),
265
+ removedFiles: removedFiles.sort(),
266
+ };
267
+ }
268
+
269
+ /**
270
+ * Which side of a diff the graph's line numbers describe. For a commit range
271
+ * (`range`, `<base>..<head>`) the graph's build commit decides: built at the
272
+ * head, the new side; at the base, the old side. Otherwise a graph that matches
273
+ * the tree at `root` (same HEAD, no file edited since the build) describes the
274
+ * new side; any other graph was built before the change - on the base checkout,
275
+ * or before the edit - and its spans are old-side line numbers.
276
+ *
277
+ * @param {object} graph
278
+ * @param {{root?: string, range?: string}} [opts]
279
+ * @returns {"new"|"old"}
280
+ */
281
+ export function graphSide(graph, opts = {}) {
282
+ const parts = typeof opts.range === "string" ? opts.range.split("..") : [];
283
+ if (parts.length === 2 && opts.root && graph.baseCommit && parts.every((p) => REF_RE.test(p))) {
284
+ const sha = (ref) => gitOut(opts.root, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]);
285
+ if (sha(parts[1]) === graph.baseCommit) return "new";
286
+ if (sha(parts[0]) === graph.baseCommit) return "old";
287
+ }
288
+ return graphFreshness(graph, opts).stale ? "old" : "new";
289
+ }
290
+
291
+ const REF_RE = /^(?!-)[A-Za-z0-9._/~^@{}-]{1,200}$/;
292
+
293
+ /**
294
+ * The unified diff for `<base>` (working tree against base, uncommitted work
295
+ * included) or `<base>..<head>` (two commits), with no context lines.
296
+ *
297
+ * @param {string} root
298
+ * @param {string} spec
299
+ * @returns {string}
300
+ */
301
+ export function diffFor(root, spec) {
302
+ const parts = String(spec).split("..");
303
+ if (parts.length > 2 || parts.some((p) => !REF_RE.test(p))) {
304
+ throw new Error(`not a ref or ref range: ${JSON.stringify(String(spec).slice(0, 80))}`);
305
+ }
306
+ const args = [
307
+ "-C",
308
+ root,
309
+ "diff",
310
+ "-U0",
311
+ "--no-color",
312
+ "--no-ext-diff",
313
+ "--src-prefix=a/",
314
+ "--dst-prefix=b/",
315
+ "--end-of-options",
316
+ ...parts,
317
+ ];
318
+ return execFileSync("git", args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
319
+ }