@mmerterden/multi-agent-pipeline 20.21.0 → 20.22.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.
- package/CHANGELOG.md +33 -0
- package/docs/adr/0017-change-impact-from-the-graph.md +69 -0
- package/docs/adr/README.md +1 -0
- package/docs/ecosystem.md +6 -5
- package/docs/facts.json +4 -3
- package/install/catalog-history.json +1 -1
- package/install/templates/copilot-instructions.md +1 -0
- package/manifest.json +44 -40
- package/package.json +1 -1
- package/pipeline/commands/multi-agent/review/SKILL.md +2 -1
- package/pipeline/commands/multi-agent/setup/SKILL.md +6 -15
- package/pipeline/multi-agent-refs/_dev-context.md +15 -0
- package/pipeline/multi-agent-refs/analysis/evidence.md +5 -3
- package/pipeline/multi-agent-refs/analysis/locked.md +1 -1
- package/pipeline/multi-agent-refs/cross-cli-contract.md +1 -0
- package/pipeline/multi-agent-refs/features/code-graph.md +27 -0
- package/pipeline/multi-agent-refs/features/product-context.md +238 -0
- package/pipeline/multi-agent-refs/knowledge.md +2 -0
- package/pipeline/multi-agent-refs/phases/phase-1-plan.md +7 -12
- package/pipeline/multi-agent-refs/phases/phase-2-dev.md +1 -1
- package/pipeline/multi-agent-refs/phases/phase-3-review.md +7 -6
- package/pipeline/multi-agent-refs/platform-parity.md +23 -9
- package/pipeline/multi-agent-refs/setup/token-save-flow.md +24 -0
- package/pipeline/schemas/agent-state.schema.json +31 -0
- package/pipeline/schemas/analysis-spec.schema.json +14 -0
- package/pipeline/schemas/code-graph.schema.json +12 -1
- package/pipeline/schemas/diff-risk.schema.json +2 -1
- package/pipeline/schemas/prefs.schema.json +58 -0
- package/pipeline/scripts/_code-graph.mjs +76 -3
- package/pipeline/scripts/_diff-impact.mjs +312 -0
- package/pipeline/scripts/capture-flush.sh +58 -3
- package/pipeline/scripts/diff-risk-score.mjs +80 -9
- package/pipeline/scripts/graph-affected.mjs +167 -36
- package/pipeline/scripts/graph-query.mjs +16 -4
- package/pipeline/scripts/product-context.mjs +539 -0
- package/pipeline/scripts/smoke-cross-cli-behavior.sh +39 -1
- package/pipeline/scripts/validate-code-graph.mjs +2 -1
- package/pipeline/scripts/validate-diff-risk.mjs +1 -0
- package/pipeline/skills/.skill-manifest.json +3 -3
- package/pipeline/skills/shared/core/multi-agent-review/SKILL.md +5 -1
- package/pipeline/skills/shared/core/multi-agent-setup/SKILL.md +8 -6
|
@@ -35,7 +35,7 @@ import { isExcludedBy } from "./_path-match.mjs";
|
|
|
35
35
|
|
|
36
36
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
37
37
|
|
|
38
|
-
export const GRAPH_SCHEMA_VERSION = "1.
|
|
38
|
+
export const GRAPH_SCHEMA_VERSION = "1.1.0";
|
|
39
39
|
|
|
40
40
|
/**
|
|
41
41
|
* Stack words the pipeline uses elsewhere, mapped to the rule-file id.
|
|
@@ -256,6 +256,74 @@ function indentOf(text, index) {
|
|
|
256
256
|
return i - start;
|
|
257
257
|
}
|
|
258
258
|
|
|
259
|
+
const leading = (line) => line.length - line.replace(/^[ \t]+/, "").length;
|
|
260
|
+
const ATTRIBUTE_RE = /^[ \t]*@/;
|
|
261
|
+
const CLOSER_RE = /^[ \t]*[}\])]/;
|
|
262
|
+
const OPENS_BLOCK_RE = /(?:[{:=]|=>|->)[ \t]*$/;
|
|
263
|
+
const DIRECTIVE_RE = /^#(?:if|elseif|else|endif)\b/;
|
|
264
|
+
const STORED_PROPERTY_RE = /\b(?:var|let|val)\b/;
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Give every definition a span: `startLine` (the declaration line, or the first
|
|
268
|
+
* of the attribute or decorator lines directly above it, never a stored
|
|
269
|
+
* property) and `endLine` (the last line indented deeper than the declaration,
|
|
270
|
+
* plus a closing bracket at the declaration's own indentation when one
|
|
271
|
+
* follows). A closing bracket that opens the body again - the end of a
|
|
272
|
+
* multi-line parameter list, `) -> Int {`, `):`, `) : Base() {` - continues the
|
|
273
|
+
* span, and compiler directives in column 0 (`#if`, `#endif`) are skipped like
|
|
274
|
+
* blank lines. Code after the span, at the same or a lower indentation,
|
|
275
|
+
* belongs to no declaration until the next one starts.
|
|
276
|
+
*
|
|
277
|
+
* Indentation is the nesting signal the engine already trusts (see indentOf),
|
|
278
|
+
* so this needs no parser and holds for Python as well as the brace languages.
|
|
279
|
+
*
|
|
280
|
+
* @param {Array<{line: number, indent: number}>} definitions - mutated in place
|
|
281
|
+
* @param {string[]} lines - the stripped body, one entry per source line
|
|
282
|
+
*/
|
|
283
|
+
export function assignSpans(definitions, lines) {
|
|
284
|
+
for (const d of definitions) {
|
|
285
|
+
let start = d.line;
|
|
286
|
+
while (start > 1) {
|
|
287
|
+
const above = lines[start - 2];
|
|
288
|
+
if (
|
|
289
|
+
!ATTRIBUTE_RE.test(above) ||
|
|
290
|
+
leading(above) !== d.indent ||
|
|
291
|
+
STORED_PROPERTY_RE.test(above)
|
|
292
|
+
)
|
|
293
|
+
break;
|
|
294
|
+
start--;
|
|
295
|
+
}
|
|
296
|
+
d.startLine = start;
|
|
297
|
+
}
|
|
298
|
+
const ordered = [...definitions].sort((a, b) => a.line - b.line || a.indent - b.indent);
|
|
299
|
+
for (let i = 0; i < ordered.length; i++) {
|
|
300
|
+
const d = ordered[i];
|
|
301
|
+
let limit = lines.length;
|
|
302
|
+
for (let j = i + 1; j < ordered.length; j++) {
|
|
303
|
+
if (ordered[j].line > d.line && ordered[j].indent <= d.indent) {
|
|
304
|
+
limit = ordered[j].startLine - 1;
|
|
305
|
+
break;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
let end = d.line;
|
|
309
|
+
for (let n = d.line + 1; n <= limit; n++) {
|
|
310
|
+
const text = lines[n - 1];
|
|
311
|
+
if (!text.trim() || DIRECTIVE_RE.test(text)) continue;
|
|
312
|
+
const ind = leading(text);
|
|
313
|
+
if (ind > d.indent) {
|
|
314
|
+
end = n;
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
if (ind === d.indent && CLOSER_RE.test(text)) {
|
|
318
|
+
end = n;
|
|
319
|
+
if (OPENS_BLOCK_RE.test(text)) continue;
|
|
320
|
+
}
|
|
321
|
+
break;
|
|
322
|
+
}
|
|
323
|
+
d.endLine = end;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
259
327
|
/**
|
|
260
328
|
* Extract one file's definitions, imports and identifier set.
|
|
261
329
|
*
|
|
@@ -303,11 +371,14 @@ export function extractFile(text, rules) {
|
|
|
303
371
|
kind: pattern.kind || pattern.id,
|
|
304
372
|
patternId: pattern.id,
|
|
305
373
|
line: lineOf(body, m.index),
|
|
374
|
+
indent: indentOf(body, m.index),
|
|
306
375
|
nested: indentOf(body, m.index) > 0,
|
|
307
376
|
});
|
|
308
377
|
}
|
|
309
378
|
}
|
|
310
379
|
|
|
380
|
+
assignSpans(definitions, body.split("\n"));
|
|
381
|
+
for (const d of definitions) delete d.indent;
|
|
311
382
|
return { definitions, imports, tokens: identifiers(body) };
|
|
312
383
|
}
|
|
313
384
|
|
|
@@ -325,11 +396,11 @@ export function extractFile(text, rules) {
|
|
|
325
396
|
export function listSourceFiles(root, rules) {
|
|
326
397
|
let candidates;
|
|
327
398
|
try {
|
|
328
|
-
const out = execFileSync("git", ["-C", root, "ls-files", "-co", "--exclude-standard"], {
|
|
399
|
+
const out = execFileSync("git", ["-C", root, "ls-files", "-z", "-co", "--exclude-standard"], {
|
|
329
400
|
encoding: "utf8",
|
|
330
401
|
maxBuffer: 64 * 1024 * 1024,
|
|
331
402
|
});
|
|
332
|
-
candidates = out.split("\
|
|
403
|
+
candidates = out.split("\0").filter(Boolean);
|
|
333
404
|
} catch {
|
|
334
405
|
candidates = walkDir(root, root);
|
|
335
406
|
}
|
|
@@ -427,6 +498,8 @@ export function buildGraph({
|
|
|
427
498
|
name: def.name,
|
|
428
499
|
path,
|
|
429
500
|
line: def.line,
|
|
501
|
+
...(def.endLine ? { endLine: def.endLine } : {}),
|
|
502
|
+
...(def.startLine && def.startLine !== def.line ? { startLine: def.startLine } : {}),
|
|
430
503
|
symbolKind: def.kind,
|
|
431
504
|
isTest: isTestPath(path, rules),
|
|
432
505
|
nested: def.nested === true,
|
|
@@ -0,0 +1,312 @@
|
|
|
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 { unquotePathIfNeeded } 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 text = raw.startsWith('"') ? raw : raw.replace(/\t.*$/, "");
|
|
33
|
+
const path = unquotePathIfNeeded(text);
|
|
34
|
+
if (path === "/dev/null") return null;
|
|
35
|
+
return path.startsWith(prefix) ? path.slice(prefix.length) : path;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function addLine(set, line) {
|
|
39
|
+
set.add(Math.max(1, line));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function toRanges(lines) {
|
|
43
|
+
const sorted = [...lines].sort((a, b) => a - b);
|
|
44
|
+
const out = [];
|
|
45
|
+
for (const l of sorted) {
|
|
46
|
+
const last = out[out.length - 1];
|
|
47
|
+
if (last && l === last.end + 1) last.end = l;
|
|
48
|
+
else if (!last || l > last.end) out.push({ start: l, end: l });
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Every file of a unified diff with the lines it touched on each side.
|
|
55
|
+
*
|
|
56
|
+
* New side: an added line counts as itself, and a deletion as the line now
|
|
57
|
+
* standing where the removed lines were. Old side: a removed line counts as
|
|
58
|
+
* itself, and an insertion as the old line standing where it went in. Either way a hunk that
|
|
59
|
+
* only adds or only removes still lands inside the declaration it changed.
|
|
60
|
+
* Hunks are counted from their headers, so concatenated `diff -u` output with
|
|
61
|
+
* no `diff --git` lines parses too, CRLF included, at any context size.
|
|
62
|
+
*
|
|
63
|
+
* @param {string} diffText - unified diff
|
|
64
|
+
* @returns {Array<{oldPath: string|null, newPath: string|null, oldRanges: Array<{start: number, end: number}>, newRanges: Array<{start: number, end: number}>}>}
|
|
65
|
+
*/
|
|
66
|
+
export function parseDiffFiles(diffText) {
|
|
67
|
+
const files = [];
|
|
68
|
+
let cur = null;
|
|
69
|
+
let pendingOld;
|
|
70
|
+
let oldLine = 0;
|
|
71
|
+
let newLine = 0;
|
|
72
|
+
let oldLeft = 0;
|
|
73
|
+
let newLeft = 0;
|
|
74
|
+
for (const line of String(diffText).split("\n")) {
|
|
75
|
+
const raw = line.endsWith("\r") ? line.slice(0, -1) : line;
|
|
76
|
+
if (oldLeft > 0 || newLeft > 0) {
|
|
77
|
+
if (raw.startsWith("+")) {
|
|
78
|
+
addLine(cur.newLines, newLine);
|
|
79
|
+
addLine(cur.oldLines, oldLine);
|
|
80
|
+
newLine++;
|
|
81
|
+
newLeft--;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (raw.startsWith("-")) {
|
|
85
|
+
addLine(cur.oldLines, oldLine);
|
|
86
|
+
addLine(cur.newLines, newLine);
|
|
87
|
+
oldLine++;
|
|
88
|
+
oldLeft--;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (raw.startsWith(" ") || raw === "") {
|
|
92
|
+
oldLine++;
|
|
93
|
+
newLine++;
|
|
94
|
+
oldLeft--;
|
|
95
|
+
newLeft--;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (raw.startsWith("\\")) continue;
|
|
99
|
+
oldLeft = 0;
|
|
100
|
+
newLeft = 0;
|
|
101
|
+
}
|
|
102
|
+
if (raw.startsWith("diff --git ")) {
|
|
103
|
+
pendingOld = undefined;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (raw.startsWith("--- ")) {
|
|
107
|
+
pendingOld = sidePath(raw.slice(4), "a/");
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (raw.startsWith("+++ ")) {
|
|
111
|
+
cur = {
|
|
112
|
+
oldPath: pendingOld === undefined ? null : pendingOld,
|
|
113
|
+
newPath: sidePath(raw.slice(4), "b/"),
|
|
114
|
+
oldLines: new Set(),
|
|
115
|
+
newLines: new Set(),
|
|
116
|
+
};
|
|
117
|
+
files.push(cur);
|
|
118
|
+
pendingOld = undefined;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
const hunk = HUNK_RE.exec(raw);
|
|
122
|
+
if (hunk && cur) {
|
|
123
|
+
const oldCount = hunk[2] === undefined ? 1 : Number(hunk[2]);
|
|
124
|
+
const newCount = hunk[4] === undefined ? 1 : Number(hunk[4]);
|
|
125
|
+
oldLine = Number(hunk[1]);
|
|
126
|
+
newLine = Number(hunk[3]);
|
|
127
|
+
oldLeft = oldCount;
|
|
128
|
+
newLeft = newCount;
|
|
129
|
+
if (newCount === 0) addLine(cur.newLines, newLine);
|
|
130
|
+
if (oldCount === 0) addLine(cur.oldLines, oldLine);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return files
|
|
134
|
+
.filter((f) => f.oldPath || f.newPath)
|
|
135
|
+
.map((f) => ({
|
|
136
|
+
oldPath: f.oldPath,
|
|
137
|
+
newPath: f.newPath,
|
|
138
|
+
oldRanges: toRanges(f.oldLines),
|
|
139
|
+
newRanges: toRanges(f.newLines),
|
|
140
|
+
}));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Line ranges per file on one side of the diff. `new` (default) keys by the
|
|
145
|
+
* new path; a deleted file falls back to its old path. `old` keys by the old
|
|
146
|
+
* path; an added file falls back to its new path. Pick the side the graph was
|
|
147
|
+
* built from (`graphSide`): its spans are line numbers in that tree.
|
|
148
|
+
*
|
|
149
|
+
* @param {string} diffText - unified diff
|
|
150
|
+
* @param {"new"|"old"} [side]
|
|
151
|
+
* @returns {Map<string, Array<{start: number, end: number}>>} in diff order
|
|
152
|
+
*/
|
|
153
|
+
export function parseHunkRanges(diffText, side = "new") {
|
|
154
|
+
const out = new Map();
|
|
155
|
+
for (const f of parseDiffFiles(diffText)) {
|
|
156
|
+
const path = side === "old" ? f.oldPath || f.newPath : f.newPath || f.oldPath;
|
|
157
|
+
const ranges = side === "old" ? f.oldRanges : f.newRanges;
|
|
158
|
+
if (ranges.length) out.set(path, ranges);
|
|
159
|
+
}
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Declarations a set of ranges falls in.
|
|
165
|
+
*
|
|
166
|
+
* `changed` holds the innermost declaration per range - what a person would
|
|
167
|
+
* call "the function I edited". `seeds` adds every enclosing declaration, since
|
|
168
|
+
* references in the graph point at types, not at the methods inside them, and a
|
|
169
|
+
* walk seeded with the method alone would find no dependents. A range that hits
|
|
170
|
+
* no declaration (top-level code) seeds its file.
|
|
171
|
+
*
|
|
172
|
+
* @param {object} graph
|
|
173
|
+
* @param {Map<string, Array<{start: number, end: number}>>} ranges
|
|
174
|
+
* @returns {{changed: object[], seeds: object[], fileLevel: string[], notIndexed: string[]}}
|
|
175
|
+
*/
|
|
176
|
+
export function changedSymbols(graph, ranges) {
|
|
177
|
+
const files = new Map();
|
|
178
|
+
const symbolsByPath = new Map();
|
|
179
|
+
for (const n of graph.nodes) {
|
|
180
|
+
if (n.kind === "file") files.set(n.path, n);
|
|
181
|
+
else if (n.kind === "symbol" && n.path) {
|
|
182
|
+
if (!symbolsByPath.has(n.path)) symbolsByPath.set(n.path, []);
|
|
183
|
+
symbolsByPath.get(n.path).push(n);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const changed = new Map();
|
|
188
|
+
const seeds = new Map();
|
|
189
|
+
const fileLevel = [];
|
|
190
|
+
const notIndexed = [];
|
|
191
|
+
|
|
192
|
+
for (const [path, list] of ranges) {
|
|
193
|
+
const fileNode = files.get(path);
|
|
194
|
+
if (!fileNode) {
|
|
195
|
+
notIndexed.push(path);
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
const symbols = symbolsByPath.get(path) || [];
|
|
199
|
+
const spanned = symbols.filter((s) => Number.isInteger(s.endLine));
|
|
200
|
+
if (symbols.length && !spanned.length) {
|
|
201
|
+
fileLevel.push(path);
|
|
202
|
+
seeds.set(fileNode.id, fileNode);
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
for (const r of list) {
|
|
206
|
+
const containing = spanned
|
|
207
|
+
.filter((s) => (s.startLine ?? s.line) <= r.end && s.endLine >= r.start)
|
|
208
|
+
.sort((a, b) => b.line - a.line);
|
|
209
|
+
if (!containing.length) {
|
|
210
|
+
seeds.set(fileNode.id, fileNode);
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
const inner = containing[0];
|
|
214
|
+
changed.set(inner.id, inner);
|
|
215
|
+
for (const s of containing) seeds.set(s.id, s);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return { changed: [...changed.values()], seeds: [...seeds.values()], fileLevel, notIndexed };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function gitOut(root, args) {
|
|
223
|
+
try {
|
|
224
|
+
return execFileSync("git", ["-C", root, ...args], {
|
|
225
|
+
encoding: "utf8",
|
|
226
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
227
|
+
}).trim();
|
|
228
|
+
} catch {
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* How far the graph is behind the tree. `changedFiles` compares each indexed
|
|
235
|
+
* file's size and mtime with the manifest written at build time, so an edit
|
|
236
|
+
* that was never committed counts; `commitMoved` compares the build commit with
|
|
237
|
+
* HEAD. Either makes the graph stale - an answer from it is about the tree as
|
|
238
|
+
* it was, and callers say so instead of presenting it as current.
|
|
239
|
+
*
|
|
240
|
+
* @param {object} graph
|
|
241
|
+
* @param {{root?: string}} [opts] - defaults to the root recorded in the graph
|
|
242
|
+
* @returns {{stale: boolean, commitMoved: boolean|null, baseCommit: string|null, head: string|null, changedFiles: string[], removedFiles: string[]}}
|
|
243
|
+
*/
|
|
244
|
+
export function graphFreshness(graph, { root = graph.root } = {}) {
|
|
245
|
+
const manifest = graph.manifest || {};
|
|
246
|
+
const changedFiles = [];
|
|
247
|
+
const removedFiles = [];
|
|
248
|
+
for (const [path, prev] of Object.entries(manifest)) {
|
|
249
|
+
const abs = join(root, path);
|
|
250
|
+
if (!existsSync(abs)) {
|
|
251
|
+
removedFiles.push(path);
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
const st = statSync(abs);
|
|
255
|
+
if (st.size !== prev.size || Math.floor(st.mtimeMs) !== prev.mtimeMs) changedFiles.push(path);
|
|
256
|
+
}
|
|
257
|
+
const head = root ? gitOut(root, ["rev-parse", "HEAD"]) : null;
|
|
258
|
+
const baseCommit = graph.baseCommit || null;
|
|
259
|
+
const commitMoved = head && baseCommit ? head !== baseCommit : null;
|
|
260
|
+
return {
|
|
261
|
+
stale: Boolean(commitMoved) || changedFiles.length > 0 || removedFiles.length > 0,
|
|
262
|
+
commitMoved,
|
|
263
|
+
baseCommit,
|
|
264
|
+
head,
|
|
265
|
+
changedFiles: changedFiles.sort(),
|
|
266
|
+
removedFiles: removedFiles.sort(),
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Which side of a diff the graph's line numbers describe. A graph that matches
|
|
272
|
+
* the tree at `root` (same HEAD, no file edited since the build) describes the
|
|
273
|
+
* new side; any other graph was built before the change - on the base checkout,
|
|
274
|
+
* or before the edit - and its spans are old-side line numbers.
|
|
275
|
+
*
|
|
276
|
+
* @param {object} graph
|
|
277
|
+
* @param {{root?: string}} [opts]
|
|
278
|
+
* @returns {"new"|"old"}
|
|
279
|
+
*/
|
|
280
|
+
export function graphSide(graph, opts = {}) {
|
|
281
|
+
return graphFreshness(graph, opts).stale ? "old" : "new";
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const REF_RE = /^(?!-)[A-Za-z0-9._/~^@{}-]{1,200}$/;
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* The unified diff for `<base>` (working tree against base, uncommitted work
|
|
288
|
+
* included) or `<base>..<head>` (two commits), with no context lines.
|
|
289
|
+
*
|
|
290
|
+
* @param {string} root
|
|
291
|
+
* @param {string} spec
|
|
292
|
+
* @returns {string}
|
|
293
|
+
*/
|
|
294
|
+
export function diffFor(root, spec) {
|
|
295
|
+
const parts = String(spec).split("..");
|
|
296
|
+
if (parts.length > 2 || parts.some((p) => !REF_RE.test(p))) {
|
|
297
|
+
throw new Error(`not a ref or ref range: ${JSON.stringify(String(spec).slice(0, 80))}`);
|
|
298
|
+
}
|
|
299
|
+
const args = [
|
|
300
|
+
"-C",
|
|
301
|
+
root,
|
|
302
|
+
"diff",
|
|
303
|
+
"-U0",
|
|
304
|
+
"--no-color",
|
|
305
|
+
"--no-ext-diff",
|
|
306
|
+
"--src-prefix=a/",
|
|
307
|
+
"--dst-prefix=b/",
|
|
308
|
+
"--end-of-options",
|
|
309
|
+
...parts,
|
|
310
|
+
];
|
|
311
|
+
return execFileSync("git", args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
|
312
|
+
}
|
|
@@ -110,8 +110,37 @@ fi
|
|
|
110
110
|
# Phase 4 removes the worktree once the PR is open, so the salvaged copy under
|
|
111
111
|
# artifactsPath is tried FIRST. Reading the worktree path first would degrade
|
|
112
112
|
# silently for exactly the runs this script exists to rescue.
|
|
113
|
+
#
|
|
114
|
+
# After those, each worktree's newest `.pipeline/triage-round-<N>.json`: Phase 3
|
|
115
|
+
# writes every round there itself, and the copy to triage-output.json is a
|
|
116
|
+
# separate step a run can die before. A multi-repo run names its worktrees only
|
|
117
|
+
# under projects[], so those are read too, and the state directory last.
|
|
118
|
+
newest_round() {
|
|
119
|
+
[ -d "$1/.pipeline" ] || return 0
|
|
120
|
+
ls -t "$1"/.pipeline/triage-round-*.json 2>/dev/null | head -1
|
|
121
|
+
}
|
|
122
|
+
WORKTREES=$( { [ -n "$WORKTREE" ] && printf '%s\n' "$WORKTREE"; jq -r '.projects[]?.worktreePath // empty' "$STATE" 2>/dev/null; } | awk 'NF && !seen[$0]++')
|
|
113
123
|
TRIAGE=""
|
|
114
|
-
|
|
124
|
+
CANDIDATES=("${ARTIFACTS:+$ARTIFACTS/triage-output.json}")
|
|
125
|
+
# Within one worktree the newer of the copy and the newest round wins: a run
|
|
126
|
+
# that died before the copy step leaves the previous round in the copy.
|
|
127
|
+
newest_in_worktree() {
|
|
128
|
+
local copy="$1/triage-output.json" round
|
|
129
|
+
round=$(newest_round "$1")
|
|
130
|
+
if [ -f "$copy" ] && [ -n "$round" ]; then
|
|
131
|
+
ls -t "$copy" "$round" 2>/dev/null | head -1
|
|
132
|
+
elif [ -f "$copy" ]; then
|
|
133
|
+
printf '%s\n' "$copy"
|
|
134
|
+
else
|
|
135
|
+
printf '%s\n' "$round"
|
|
136
|
+
fi
|
|
137
|
+
}
|
|
138
|
+
while IFS= read -r wt; do
|
|
139
|
+
[ -n "$wt" ] || continue
|
|
140
|
+
CANDIDATES+=("$(newest_in_worktree "$wt")")
|
|
141
|
+
done <<< "$WORKTREES"
|
|
142
|
+
CANDIDATES+=("$(dirname "$STATE")/triage-output.json")
|
|
143
|
+
for cand in "${CANDIDATES[@]}"; do
|
|
115
144
|
[ -n "$cand" ] && [ -f "$cand" ] && { TRIAGE="$cand"; break; }
|
|
116
145
|
done
|
|
117
146
|
|
|
@@ -136,12 +165,38 @@ run_store() {
|
|
|
136
165
|
esac
|
|
137
166
|
}
|
|
138
167
|
|
|
168
|
+
# The store is named after the run's repository, never after the directory this
|
|
169
|
+
# script happens to run in: a SessionEnd hook fires wherever the session was.
|
|
170
|
+
# A live worktree resolves to its main checkout's folder, the same name the
|
|
171
|
+
# readers derive; otherwise the project recorded in the state.
|
|
172
|
+
slug_of_worktree() {
|
|
173
|
+
[ -d "$1" ] || return 0
|
|
174
|
+
local main
|
|
175
|
+
main=$(git -C "$1" worktree list --porcelain 2>/dev/null | sed -n '1s/^worktree //p')
|
|
176
|
+
[ -n "$main" ] || return 0
|
|
177
|
+
basename "$main" .git
|
|
178
|
+
}
|
|
179
|
+
SLUG=""
|
|
180
|
+
while IFS= read -r wt; do
|
|
181
|
+
[ -n "$wt" ] && [ -z "$SLUG" ] && SLUG=$(slug_of_worktree "$wt")
|
|
182
|
+
done <<< "$WORKTREES"
|
|
183
|
+
plain_slug() {
|
|
184
|
+
case "$1" in
|
|
185
|
+
"" | .* | *[!A-Za-z0-9._-]*) return 0 ;;
|
|
186
|
+
esac
|
|
187
|
+
printf '%s' "$1"
|
|
188
|
+
}
|
|
189
|
+
SLUG=$(plain_slug "$SLUG")
|
|
190
|
+
[ -n "$SLUG" ] || SLUG=$(plain_slug "$(jq -r '.project // .projects[0].name // empty' "$STATE" 2>/dev/null)")
|
|
191
|
+
SLUG_ARGS=()
|
|
192
|
+
[ -n "$SLUG" ] && SLUG_ARGS=(--repo-slug "$SLUG")
|
|
193
|
+
|
|
139
194
|
if [ -n "$TRIAGE" ]; then
|
|
140
195
|
INGESTED=$(run_store ingest \
|
|
141
|
-
node "$SCRIPTS/triage-memory.mjs" ingest --triage "$TRIAGE" --state "$STATE")
|
|
196
|
+
node "$SCRIPTS/triage-memory.mjs" ingest --triage "$TRIAGE" --state "$STATE" ${SLUG_ARGS[@]+"${SLUG_ARGS[@]}"})
|
|
142
197
|
if [ -n "$TASK_ID" ]; then
|
|
143
198
|
DISTILLED=$(run_store distill \
|
|
144
|
-
node "$SCRIPTS/learnings-ledger.mjs" from-triage --triage "$TRIAGE" --task "$TASK_ID")
|
|
199
|
+
node "$SCRIPTS/learnings-ledger.mjs" from-triage --triage "$TRIAGE" --task "$TASK_ID" ${SLUG_ARGS[@]+"${SLUG_ARGS[@]}"})
|
|
145
200
|
fi
|
|
146
201
|
fi
|
|
147
202
|
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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 = {
|