@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
@@ -0,0 +1,133 @@
1
+ /**
2
+ * @file _supertypes.mjs - the types a declaration header names as its parents.
3
+ *
4
+ * Reads the header text that follows a type's name in a stripped body (comments
5
+ * and strings already blanked) and returns the simple names of its superclass,
6
+ * protocols or interfaces. Three syntaxes cover the stacks the graph indexes:
7
+ *
8
+ * colon Swift and Kotlin: `Name<T>(ctor): A, B<X>() where ... {`
9
+ * parens Python: `Name(A, B, metaclass=M):`
10
+ * extends TypeScript / JavaScript: `Name<T> extends A implements B, C {`
11
+ *
12
+ * Only names that start with a letter or underscore are kept, qualified names
13
+ * reduce to their last segment, and generic arguments, constructor calls and
14
+ * Kotlin delegation (`by impl`) are dropped. A name the graph does not define
15
+ * (String, Codable) simply produces no edge.
16
+ *
17
+ * Zero runtime dependencies (ADR-0004).
18
+ *
19
+ * @module pipeline/scripts/_supertypes
20
+ */
21
+
22
+ const HEADER_MAX = 4000;
23
+ const OPEN = { "(": ")", "<": ">", "[": "]" };
24
+ const CLOSE = new Set([")", ">", "]"]);
25
+
26
+ /** `->` and `=>` are arrows, not the end of a generic argument list. */
27
+ const isArrow = (text, i) => text[i] === ">" && (text[i - 1] === "-" || text[i - 1] === "=");
28
+
29
+ /**
30
+ * The header from `start` to the `{` that opens the body (or `;`, or Python's
31
+ * closing `:`) at bracket depth 0, capped so a declaration with no body cannot
32
+ * read on into the file.
33
+ */
34
+ function headerText(body, start, syntax) {
35
+ let depth = 0;
36
+ const end = Math.min(body.length, start + HEADER_MAX);
37
+ for (let i = start; i < end; i++) {
38
+ const c = body[i];
39
+ if (c in OPEN) depth++;
40
+ else if (CLOSE.has(c) && depth > 0 && !isArrow(body, i)) depth--;
41
+ else if (depth === 0 && (c === "{" || c === ";")) return body.slice(start, i);
42
+ else if (depth === 0 && syntax === "parens" && c === ":") return body.slice(start, i);
43
+ }
44
+ return body.slice(start, end);
45
+ }
46
+
47
+ /** Split on commas at bracket depth 0. */
48
+ function splitTop(text) {
49
+ const parts = [];
50
+ let depth = 0;
51
+ let cur = "";
52
+ for (let i = 0; i < text.length; i++) {
53
+ const c = text[i];
54
+ if (c in OPEN) depth++;
55
+ else if (CLOSE.has(c) && depth > 0 && !isArrow(text, i)) depth--;
56
+ if (c === "," && depth === 0) {
57
+ parts.push(cur);
58
+ cur = "";
59
+ } else cur += c;
60
+ }
61
+ parts.push(cur);
62
+ return parts;
63
+ }
64
+
65
+ /** Skip one balanced bracket group starting at `i` when `text[i]` is `open`. */
66
+ function skipGroup(text, i, open) {
67
+ if (text[i] !== open) return i;
68
+ const close = OPEN[open];
69
+ let depth = 0;
70
+ for (let j = i; j < text.length; j++) {
71
+ if (text[j] === open) depth++;
72
+ else if (text[j] === close && !isArrow(text, j) && --depth === 0) return j + 1;
73
+ }
74
+ return text.length;
75
+ }
76
+
77
+ function simpleName(raw) {
78
+ const item = raw.replace(/^\s*(?:@[\w.]+(?:\([^)]*\))?\s*)+/, "").replace(/`/g, "");
79
+ const m = /^\s*(?:out\s+|in\s+)?([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*)/.exec(item);
80
+ if (!m || item.includes("=")) return null;
81
+ return m[1].split(".").pop();
82
+ }
83
+
84
+ function names(list) {
85
+ const out = [];
86
+ for (const item of splitTop(list)) {
87
+ const n = simpleName(item);
88
+ if (n && !out.includes(n)) out.push(n);
89
+ }
90
+ return out;
91
+ }
92
+
93
+ /**
94
+ * @param {string} body - stripped source
95
+ * @param {number} start - offset just past the declared name
96
+ * @param {"colon"|"parens"|"extends"} syntax
97
+ * @returns {string[]} simple supertype names, in header order
98
+ */
99
+ export function supertypesAt(body, start, syntax) {
100
+ const header = headerText(body, start, syntax);
101
+ if (syntax === "parens") {
102
+ const m = /^\s*\(/.exec(header);
103
+ if (!m) return [];
104
+ const end = skipGroup(header, m[0].length - 1, "(");
105
+ return names(header.slice(m[0].length, end - 1));
106
+ }
107
+ if (syntax === "extends") {
108
+ let k = 0;
109
+ while (k < header.length && /\s/.test(header[k])) k++;
110
+ const rest = header.slice(skipGroup(header, k, "<"));
111
+ const out = [];
112
+ const ext = /\bextends\s+([\s\S]*?)(?=\bimplements\b|$)/.exec(rest);
113
+ const imp = /\bimplements\s+([\s\S]*)$/.exec(rest);
114
+ for (const part of [ext?.[1], imp?.[1]])
115
+ if (part) for (const n of names(part)) if (!out.includes(n)) out.push(n);
116
+ return out;
117
+ }
118
+ let i = 0;
119
+ while (i < header.length && /\s/.test(header[i])) i++;
120
+ i = skipGroup(header, i, "<");
121
+ let j = i;
122
+ while (j < header.length && /\s/.test(header[j])) j++;
123
+ const ctor =
124
+ /^(?:@[A-Za-z_][\w.]*(?:\([^)]*\))?\s+)*(?:(?:private|protected|internal|public)\s+)?constructor\s*/.exec(
125
+ header.slice(j),
126
+ );
127
+ if (ctor) j += ctor[0].length;
128
+ j = skipGroup(header, j, "(");
129
+ while (j < header.length && /\s/.test(header[j])) j++;
130
+ if (header[j] !== ":") return [];
131
+ const list = header.slice(j + 1).split(/\bwhere\b/)[0];
132
+ return names(list.replace(/\bby\s+[^,]*/g, ""));
133
+ }
@@ -16,6 +16,11 @@ import { escapeJiraWiki, wikiLink } from "./jira-wiki-escape.mjs";
16
16
  import { redesignSections } from "../lib/analysis-sections.mjs";
17
17
 
18
18
  const MAX_CRITERIA = 8;
19
+
20
+ // Panel border colours of a story section, the ones testers read stories in:
21
+ // the user story, the acceptance criteria, and every other section.
22
+ const PANEL = { story: "#d4d39e", criteria: "#d4a19e", info: "#9eb6d4" };
23
+ const panel = (colour, lines) => [`{panel:borderColor=${colour}}`, ...lines, "{panel}", ""];
19
24
  const MAX_DESIGN_LINKS = 5;
20
25
 
21
26
  // A criterion that describes a refusal, an error or an alternative path. One is
@@ -152,19 +157,30 @@ function linksOf(story, lines, ctx) {
152
157
 
153
158
  function render({ userStory, acceptance, currentVsTarget, links }, sourceIds) {
154
159
  const out = [];
155
- if (userStory) out.push("h3. User story", escapeJiraWiki(userStory), "");
160
+ if (userStory) out.push(...panel(PANEL.story, ["h3. User story", escapeJiraWiki(userStory)]));
156
161
  if (acceptance.length) {
157
- out.push("h3. Acceptance criteria", ...acceptance.map((a) => `# ${escapeJiraWiki(a)}`), "");
162
+ out.push(
163
+ ...panel(PANEL.criteria, [
164
+ "h3. Acceptance criteria",
165
+ ...acceptance.map((a) => `# ${escapeJiraWiki(a)}`),
166
+ ]),
167
+ );
158
168
  }
159
169
  if (currentVsTarget.length) {
160
- out.push("h3. Current vs target", "||Id||Status||Where||");
161
- for (const r of currentVsTarget) {
162
- out.push(`|${escapeJiraWiki(r.id)}|${escapeJiraWiki(r.status)}|${escapeJiraWiki(r.where)}|`);
163
- }
164
- out.push("");
170
+ out.push(
171
+ ...panel(PANEL.info, [
172
+ "h3. Current vs target",
173
+ "||Id||Status||Where||",
174
+ ...currentVsTarget.map(
175
+ (r) => `|${escapeJiraWiki(r.id)}|${escapeJiraWiki(r.status)}|${escapeJiraWiki(r.where)}|`,
176
+ ),
177
+ ]),
178
+ );
165
179
  }
166
180
  if (links.length) {
167
- out.push("h3. Links", ...links.map((l) => `* ${wikiLink(l.label, l.url)}`), "");
181
+ out.push(
182
+ ...panel(PANEL.info, ["h3. Links", ...links.map((l) => `* ${wikiLink(l.label, l.url)}`)]),
183
+ );
168
184
  }
169
185
  out.push(
170
186
  `Sources: ${escapeJiraWiki(sourceIds.length ? sourceIds.join(", ") : "derived from the section heading")}`,
@@ -66,7 +66,10 @@ LOGS="${LOGS_ROOT:-$HOME/.claude/logs/multi-agent}"
66
66
  SCRIPTS="$HOME/.claude/scripts"
67
67
  [ -d "$SCRIPTS" ] || SCRIPTS="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
68
68
 
69
- say() { [ "$QUIET" -eq 1 ] && return 0; printf '%s\n' "$1"; }
69
+ say() {
70
+ [ "$QUIET" -eq 1 ] && return 0
71
+ if [ "$JSON" -eq 1 ]; then printf '%s\n' "$1" >&2; else printf '%s\n' "$1"; fi
72
+ }
70
73
 
71
74
  # Resolve the run to flush.
72
75
  #
@@ -110,8 +113,37 @@ fi
110
113
  # Phase 4 removes the worktree once the PR is open, so the salvaged copy under
111
114
  # artifactsPath is tried FIRST. Reading the worktree path first would degrade
112
115
  # silently for exactly the runs this script exists to rescue.
116
+ #
117
+ # After those, each worktree's newest `.pipeline/triage-round-<N>.json`: Phase 3
118
+ # writes every round there itself, and the copy to triage-output.json is a
119
+ # separate step a run can die before. A multi-repo run names its worktrees only
120
+ # under projects[], so those are read too, and the state directory last.
121
+ newest_round() {
122
+ [ -d "$1/.pipeline" ] || return 0
123
+ ls -t "$1"/.pipeline/triage-round-*.json 2>/dev/null | head -1
124
+ }
125
+ WORKTREES=$( { [ -n "$WORKTREE" ] && printf '%s\n' "$WORKTREE"; jq -r '.projects[]?.worktreePath // empty' "$STATE" 2>/dev/null; } | awk 'NF && !seen[$0]++')
113
126
  TRIAGE=""
114
- for cand in "${ARTIFACTS:+$ARTIFACTS/triage-output.json}" "${WORKTREE:+$WORKTREE/triage-output.json}"; do
127
+ CANDIDATES=("${ARTIFACTS:+$ARTIFACTS/triage-output.json}")
128
+ # Within one worktree the newer of the copy and the newest round wins: a run
129
+ # that died before the copy step leaves the previous round in the copy.
130
+ newest_in_worktree() {
131
+ local copy="$1/triage-output.json" round
132
+ round=$(newest_round "$1")
133
+ if [ -f "$copy" ] && [ -n "$round" ]; then
134
+ ls -t "$copy" "$round" 2>/dev/null | head -1
135
+ elif [ -f "$copy" ]; then
136
+ printf '%s\n' "$copy"
137
+ else
138
+ printf '%s\n' "$round"
139
+ fi
140
+ }
141
+ while IFS= read -r wt; do
142
+ [ -n "$wt" ] || continue
143
+ CANDIDATES+=("$(newest_in_worktree "$wt")")
144
+ done <<< "$WORKTREES"
145
+ CANDIDATES+=("$(dirname "$STATE")/triage-output.json")
146
+ for cand in "${CANDIDATES[@]}"; do
115
147
  [ -n "$cand" ] && [ -f "$cand" ] && { TRIAGE="$cand"; break; }
116
148
  done
117
149
 
@@ -136,12 +168,38 @@ run_store() {
136
168
  esac
137
169
  }
138
170
 
171
+ # The store is named after the run's repository, never after the directory this
172
+ # script happens to run in: a SessionEnd hook fires wherever the session was.
173
+ # A live worktree resolves to its main checkout's folder, the same name the
174
+ # readers derive; otherwise the project recorded in the state.
175
+ slug_of_worktree() {
176
+ [ -d "$1" ] || return 0
177
+ local main
178
+ main=$(git -C "$1" worktree list --porcelain 2>/dev/null | sed -n '1s/^worktree //p')
179
+ [ -n "$main" ] || return 0
180
+ basename "$main" .git
181
+ }
182
+ SLUG=""
183
+ while IFS= read -r wt; do
184
+ [ -n "$wt" ] && [ -z "$SLUG" ] && SLUG=$(slug_of_worktree "$wt")
185
+ done <<< "$WORKTREES"
186
+ plain_slug() {
187
+ case "$1" in
188
+ "" | .* | *[!A-Za-z0-9._-]*) return 0 ;;
189
+ esac
190
+ printf '%s' "$1"
191
+ }
192
+ SLUG=$(plain_slug "$SLUG")
193
+ [ -n "$SLUG" ] || SLUG=$(plain_slug "$(jq -r '.project // .projects[0].name // empty' "$STATE" 2>/dev/null)")
194
+ SLUG_ARGS=()
195
+ [ -n "$SLUG" ] && SLUG_ARGS=(--repo-slug "$SLUG")
196
+
139
197
  if [ -n "$TRIAGE" ]; then
140
198
  INGESTED=$(run_store ingest \
141
- node "$SCRIPTS/triage-memory.mjs" ingest --triage "$TRIAGE" --state "$STATE")
199
+ node "$SCRIPTS/triage-memory.mjs" ingest --triage "$TRIAGE" --state "$STATE" ${SLUG_ARGS[@]+"${SLUG_ARGS[@]}"})
142
200
  if [ -n "$TASK_ID" ]; then
143
201
  DISTILLED=$(run_store distill \
144
- node "$SCRIPTS/learnings-ledger.mjs" from-triage --triage "$TRIAGE" --task "$TASK_ID")
202
+ node "$SCRIPTS/learnings-ledger.mjs" from-triage --triage "$TRIAGE" --task "$TASK_ID" ${SLUG_ARGS[@]+"${SLUG_ARGS[@]}"})
145
203
  fi
146
204
  fi
147
205
 
@@ -51,6 +51,7 @@
51
51
  }
52
52
  ],
53
53
  "referenceKinds": ["class", "interface", "object", "enum", "record", "typealias"],
54
+ "supertypes": { "syntax": "colon", "kinds": ["class", "interface", "object", "enum"] },
54
55
  "referenceKindsNote": "fun is deliberately absent, for the reason Swift's rules record for func: a bare lowercase name matched across files is almost never a call to that exact declaration, and on a large app the generic ones (invoke, build, create, get) take the top god-node slots purely because each happened to be declared once. Composables are the tempting exception - they are capitalised and behave like types - but they are declared with fun, so including them means including every other fun with them.",
55
56
  "ignoredIdentifiers": [
56
57
  "this",
@@ -51,6 +51,11 @@
51
51
  }
52
52
  ],
53
53
  "referenceKinds": ["class", "struct", "enum", "protocol", "actor", "typealias"],
54
+ "supertypes": {
55
+ "syntax": "colon",
56
+ "kinds": ["class", "struct", "enum", "protocol", "actor"],
57
+ "extensionPattern": "\\bextension\\s+([A-Z][A-Za-z0-9_.]*)"
58
+ },
54
59
  "referenceKindsNote": "func is deliberately absent. A bare lowercase method name matched across files is almost never a call to that exact declaration: on a large Swift app `with`, `size`, `name`, `image` and `model` took the top god-node slots purely because each happened to be declared in exactly one file. Functions still reach the graph through their `defines` edge, so they stay findable by name.",
55
60
  "ignoredIdentifiers": [
56
61
  "self",
@@ -46,6 +46,7 @@
46
46
  }
47
47
  ],
48
48
  "referenceKinds": ["class", "interface", "type", "enum", "const"],
49
+ "supertypes": { "syntax": "extends", "kinds": ["class", "interface"] },
49
50
  "referenceKindsNote": "function is deliberately absent, for the reason Swift's rules record for func: a bare name matched across files is almost never a call to that exact declaration, and the generic ones (run, main, get, parse, handler) would take the top god-node slots purely because each happened to be declared once. Functions still reach the graph through their defines edge, so they stay findable by name. const is present but narrowed to an uppercase-initial declaration at column 0, which is what a component, a singleton or an exported table looks like; a lowercase const is a local and is not a declaration anyone references across files. Measured on this repo (126 sources): including function put `ok` at degree 52, `f` at 38, `tokens` at 15 and `arg` at 12 - local helper names that happen to be declared exactly once, so the ambiguity rule does not catch them either. Excluding it leaves a thin symbol layer, and that is the honest shape of this stack: a JavaScript module's exported unit is usually a function, so the useful graph here is the import graph between files. `graph-affected \"<file>.mjs\"` answers who imports a module, which is the question this stack actually gets asked.",
50
51
  "ignoredIdentifiers": [
51
52
  "this",
@@ -30,6 +30,7 @@
30
30
  }
31
31
  ],
32
32
  "referenceKinds": ["class"],
33
+ "supertypes": { "syntax": "parens", "kinds": ["class"] },
33
34
  "referenceKindsNote": "def is deliberately absent, for the reason Swift's rules record for func and the node rules record with measurements: a bare lowercase name matched across files is almost never a call to that exact declaration, and Python's convention of short module-level helpers (main, run, load, parse, setup) makes it worse than most. Functions still reach the graph through their defines edge, so they stay findable by name.",
34
35
  "ignoredIdentifiers": [
35
36
  "self",
@@ -16,11 +16,18 @@
16
16
  * ui_critical - *View.swift / *Screen.kt / Configuration w=1.5
17
17
  * migration - DB schema / migration path w=4.0
18
18
  * test_lines_removed - test file shrinks (removed > added) w=3.0
19
+ * dependents_reached - distinct non-test files that depend on the w=2.0
20
+ * changed declarations, two rings out, from
21
+ * the code graph; only with --graph, capped
22
+ * at 20 so one hub cannot outweigh a
23
+ * security path
19
24
  *
20
25
  * Inputs:
21
26
  * --base <ref> Base ref. Default: origin/main, fallback: main
22
27
  * --head <ref> Head ref. Default: HEAD
23
28
  * --diff <path> Pre-computed diff file (skip git invocation)
29
+ * --graph <path> Code graph (graph-build.mjs). Adds dependents_reached;
30
+ * an unreadable graph is reported on stderr and skipped
24
31
  * --root <path> Repo root for git -C. Default: cwd
25
32
  * --task-id <id> Tag the output with a task id (no functional effect)
26
33
  * --top <N> Truncate `files` to top N. Default: keep all,
@@ -40,7 +47,9 @@
40
47
 
41
48
  import { readFileSync, existsSync } from "node:fs";
42
49
  import { execFileSync } from "node:child_process";
43
- import { unquoteGitPath, unquotePathIfNeeded } from "./git-path.mjs";
50
+ import { unquoteGitPath } from "./git-path.mjs";
51
+ import { affected } from "./graph-affected.mjs";
52
+ import { parseDiffFiles, changedSymbols, graphSide } from "./_diff-impact.mjs";
44
53
 
45
54
  const argv = process.argv.slice(2);
46
55
  const flags = {};
@@ -65,6 +74,9 @@ const DIFF_FILE = flags.diff || null;
65
74
  const TASK_ID = flags["task-id"] || null;
66
75
  const TOP = flags.top ? Number(flags.top) : null;
67
76
  const PRETTY = flags.json === true;
77
+ const GRAPH_FILE = flags.graph && flags.graph !== true ? flags.graph : null;
78
+ const DEPENDENTS_CAP = 20;
79
+ const DEPENDENTS_DEPTH = 2;
68
80
 
69
81
  function git(args) {
70
82
  return execFileSync("git", ["-C", ROOT, ...args], { encoding: "utf8" }).trim();
@@ -95,7 +107,7 @@ function loadDiff() {
95
107
  if (!existsSync(DIFF_FILE)) throw new Error(`diff file not found: ${DIFF_FILE}`);
96
108
  return readFileSync(DIFF_FILE, "utf8");
97
109
  }
98
- return git(["diff", "--unified=0", `${BASE}...${HEAD}`]);
110
+ return git(["diff", "--unified=0", "--src-prefix=a/", "--dst-prefix=b/", `${BASE}...${HEAD}`]);
99
111
  }
100
112
 
101
113
  function loadNumstat() {
@@ -118,16 +130,30 @@ function loadNumstat() {
118
130
  }
119
131
  return Array.from(map.entries()).map(([path, v]) => ({ path, ...v }));
120
132
  }
121
- const out = git(["diff", "--numstat", `${BASE}...${HEAD}`]);
133
+ // -z: paths are raw (never quoted), and a rename is `A\tR\t\0old\0new\0`,
134
+ // so the new path is read directly instead of parsed out of `{a => b}`.
135
+ const out = execFileSync("git", ["-C", ROOT, "diff", "--numstat", "-z", `${BASE}...${HEAD}`], {
136
+ encoding: "utf8",
137
+ });
122
138
  if (!out) return [];
123
- return out.split("\n").map((line) => {
124
- const [a, r, ...rest] = line.split("\t");
125
- return {
126
- path: unquotePathIfNeeded(rest.join("\t")),
139
+ const fields = out.split("\0");
140
+ const rows = [];
141
+ for (let i = 0; i < fields.length; i++) {
142
+ const head = fields[i];
143
+ if (!head) continue;
144
+ const [a, r, path] = head.split("\t");
145
+ let name = path;
146
+ if (path === "") {
147
+ name = fields[i + 2];
148
+ i += 2;
149
+ }
150
+ rows.push({
151
+ path: name,
127
152
  added: a === "-" ? 0 : Number(a) || 0,
128
153
  removed: r === "-" ? 0 : Number(r) || 0,
129
- };
130
- });
154
+ });
155
+ }
156
+ return rows;
131
157
  }
132
158
 
133
159
  const SECURITY_GLOBS = [
@@ -332,6 +358,47 @@ function buildRow(stat, addedLines, allChangedPaths) {
332
358
  };
333
359
  }
334
360
 
361
+ function loadGraphOrNull(path) {
362
+ try {
363
+ const graph = JSON.parse(readFileSync(path, "utf8"));
364
+ if (!Array.isArray(graph.nodes) || !Array.isArray(graph.edges))
365
+ throw new Error("not a code graph");
366
+ return graph;
367
+ } catch (e) {
368
+ process.stderr.write(
369
+ `diff-risk-score: graph unreadable at ${path}, dependents_reached skipped: ${e.message}\n`,
370
+ );
371
+ return null;
372
+ }
373
+ }
374
+
375
+ /**
376
+ * Add dependents_reached to each row whose changed declarations have
377
+ * dependents in the graph. Test files are not counted as dependents: a change
378
+ * that only its own tests use is not wide.
379
+ */
380
+ function addGraphSignal(rows, graph, diffText, root) {
381
+ const side = graphSide(graph, { root });
382
+ const diffFiles = parseDiffFiles(diffText);
383
+ for (const row of rows) {
384
+ const f = diffFiles.find((x) => (x.newPath || x.oldPath) === row.path);
385
+ if (!f) continue;
386
+ const path = side === "old" ? f.oldPath || f.newPath : f.newPath || f.oldPath;
387
+ const r = side === "old" ? f.oldRanges : f.newRanges;
388
+ if (!r.length) continue;
389
+ const { seeds } = changedSymbols(graph, new Map([[path, r]]));
390
+ if (!seeds.length) continue;
391
+ const hits = affected({ graph, seeds, depth: DEPENDENTS_DEPTH });
392
+ const files = new Set(
393
+ hits.filter((h) => h.node?.path && !h.node.isTest).map((h) => h.node.path),
394
+ );
395
+ if (!files.size) continue;
396
+ const w = 2.0;
397
+ row.signals.push({ name: "dependents_reached", weight: w, value: files.size });
398
+ row.score = Math.round((row.score + Math.min(files.size, DEPENDENTS_CAP) * 2 * w) * 100) / 100;
399
+ }
400
+ }
401
+
335
402
  function main() {
336
403
  // Resolve the base ref ONCE, before any git diff runs. loadNumstat() and
337
404
  // loadDiff() both interpolate BASE; resolving it lazily inside loadDiff()
@@ -348,6 +415,10 @@ function main() {
348
415
  const rows = numstat.map((stat) =>
349
416
  buildRow(stat, perFileAdded.get(stat.path) || [], allChangedPaths),
350
417
  );
418
+ if (GRAPH_FILE) {
419
+ const graph = loadGraphOrNull(GRAPH_FILE);
420
+ if (graph) addGraphSignal(rows, graph, diffText, ROOT);
421
+ }
351
422
  rows.sort((a, b) => b.score - a.score || a.path.localeCompare(b.path));
352
423
 
353
424
  const totals = {
@@ -61,3 +61,24 @@ export function unquotePathIfNeeded(s) {
61
61
  }
62
62
  return s;
63
63
  }
64
+
65
+ /**
66
+ * The path a `---` or `+++` diff header names (the text after that marker): a
67
+ * quoted path ends at its closing quote, which git may follow with a tab, and
68
+ * an unquoted one ends at a tab.
69
+ *
70
+ * @param {string} text
71
+ * @returns {string}
72
+ */
73
+ export function diffHeaderPath(text) {
74
+ if (text.startsWith('"')) {
75
+ for (let i = 1; i < text.length; i++) {
76
+ if (text[i] === "\\") {
77
+ i++;
78
+ continue;
79
+ }
80
+ if (text[i] === '"') return unquoteGitPath(text.slice(1, i));
81
+ }
82
+ }
83
+ return text.replace(/\t.*$/, "");
84
+ }