@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.
- package/CHANGELOG.md +62 -0
- package/docs/adr/0010-own-code-graph.md +3 -2
- 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 +5 -4
- package/docs/features.md +2 -2
- package/install/_usage-lifecycle.mjs +44 -0
- package/install/catalog-history.json +1 -1
- package/install/index.mjs +2 -0
- package/install/templates/copilot-instructions.md +1 -0
- package/manifest.json +72 -65
- package/package.json +1 -1
- package/pipeline/commands/multi-agent/create-jira/SKILL.md +1 -1
- package/pipeline/commands/multi-agent/review/SKILL.md +2 -1
- package/pipeline/commands/multi-agent/setup/SKILL.md +9 -18
- package/pipeline/commands/multi-agent/update/SKILL.md +3 -3
- package/pipeline/lib/json-file-lock.mjs +24 -2
- package/pipeline/multi-agent-refs/_dev-context.md +15 -0
- package/pipeline/multi-agent-refs/analysis/evidence.md +6 -4
- package/pipeline/multi-agent-refs/analysis/locked.md +1 -1
- package/pipeline/multi-agent-refs/cross-cli-contract.md +2 -0
- package/pipeline/multi-agent-refs/features/analysis-jira.md +5 -0
- package/pipeline/multi-agent-refs/features/code-graph.md +66 -2
- package/pipeline/multi-agent-refs/features/product-context.md +238 -0
- package/pipeline/multi-agent-refs/features/usage-reporting.md +40 -23
- package/pipeline/multi-agent-refs/generate-issue.md +11 -1
- package/pipeline/multi-agent-refs/knowledge.md +20 -0
- package/pipeline/multi-agent-refs/phases/phase-1-plan.md +9 -14
- 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 +13 -2
- package/pipeline/schemas/diff-risk.schema.json +2 -1
- package/pipeline/schemas/prefs.schema.json +84 -4
- package/pipeline/scripts/_code-graph.mjs +188 -6
- package/pipeline/scripts/_diff-impact.mjs +319 -0
- package/pipeline/scripts/_supertypes.mjs +133 -0
- package/pipeline/scripts/analysis-story-body.mjs +24 -8
- package/pipeline/scripts/capture-flush.sh +62 -4
- package/pipeline/scripts/code-graph-rules/android.json +1 -0
- package/pipeline/scripts/code-graph-rules/ios.json +5 -0
- package/pipeline/scripts/code-graph-rules/node.json +1 -0
- package/pipeline/scripts/code-graph-rules/python.json +1 -0
- package/pipeline/scripts/diff-risk-score.mjs +80 -9
- package/pipeline/scripts/git-path.mjs +21 -0
- package/pipeline/scripts/graph-affected.mjs +168 -36
- package/pipeline/scripts/graph-confirm.mjs +140 -0
- package/pipeline/scripts/graph-query.mjs +16 -4
- package/pipeline/scripts/learnings-ledger.mjs +3 -2
- package/pipeline/scripts/product-context.mjs +550 -0
- package/pipeline/scripts/smoke-cross-cli-behavior.sh +39 -1
- package/pipeline/scripts/symbol-existence-gate.mjs +21 -9
- package/pipeline/scripts/test-gap-scan.mjs +4 -1
- package/pipeline/scripts/test-strength.mjs +7 -8
- package/pipeline/scripts/usage-identity.mjs +1 -1
- package/pipeline/scripts/usage-register.mjs +181 -15
- package/pipeline/scripts/validate-code-graph.mjs +3 -2
- package/pipeline/scripts/validate-diff-risk.mjs +1 -0
- package/pipeline/scripts/valven-gate.mjs +9 -1
- package/pipeline/scripts/valven-port.mjs +16 -5
- package/pipeline/scripts/valven-split.mjs +6 -4
- package/pipeline/skills/.skill-manifest.json +4 -4
- package/pipeline/skills/shared/core/multi-agent-review/SKILL.md +5 -1
- package/pipeline/skills/shared/core/multi-agent-setup/SKILL.md +11 -9
- 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.
|
|
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
|
|
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
|
-
|
|
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("\
|
|
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
|
-
|
|
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
|
+
}
|