@mmerterden/multi-agent-pipeline 20.22.0 → 20.23.1
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 +38 -0
- package/docs/adr/0010-own-code-graph.md +3 -2
- package/docs/facts.json +3 -3
- 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/manifest.json +54 -51
- package/package.json +1 -1
- package/pipeline/commands/multi-agent/create-jira/SKILL.md +1 -1
- package/pipeline/commands/multi-agent/setup/SKILL.md +3 -3
- package/pipeline/commands/multi-agent/update/SKILL.md +3 -3
- package/pipeline/lib/analysis-jira-write.sh +25 -5
- package/pipeline/lib/json-file-lock.mjs +24 -2
- package/pipeline/multi-agent-refs/analysis/evidence.md +1 -1
- package/pipeline/multi-agent-refs/cross-cli-contract.md +1 -0
- package/pipeline/multi-agent-refs/features/analysis-jira.md +13 -2
- package/pipeline/multi-agent-refs/features/code-graph.md +40 -3
- 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 +18 -0
- package/pipeline/multi-agent-refs/phases/phase-1-plan.md +2 -2
- package/pipeline/schemas/code-graph.schema.json +3 -3
- package/pipeline/schemas/prefs.schema.json +29 -6
- package/pipeline/scripts/_code-graph.mjs +121 -12
- package/pipeline/scripts/_diff-impact.mjs +67 -32
- package/pipeline/scripts/_supertypes.mjs +146 -0
- package/pipeline/scripts/analysis-story-body.mjs +24 -8
- package/pipeline/scripts/analysis-story-tree.mjs +16 -2
- package/pipeline/scripts/capture-flush.sh +4 -1
- package/pipeline/scripts/code-graph-rules/android.json +1 -0
- package/pipeline/scripts/code-graph-rules/ios.json +6 -1
- package/pipeline/scripts/code-graph-rules/node.json +1 -0
- package/pipeline/scripts/code-graph-rules/python.json +1 -0
- package/pipeline/scripts/git-path.mjs +21 -0
- package/pipeline/scripts/graph-affected.mjs +2 -1
- package/pipeline/scripts/graph-confirm.mjs +140 -0
- package/pipeline/scripts/learnings-ledger.mjs +3 -2
- package/pipeline/scripts/product-context.mjs +30 -19
- 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 -3
- 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 +3 -3
- package/pipeline/skills/shared/core/multi-agent-setup/SKILL.md +3 -3
- package/pipeline/skills/shared/core/multi-agent-update/SKILL.md +3 -3
|
@@ -23,14 +23,13 @@
|
|
|
23
23
|
import { execFileSync } from "node:child_process";
|
|
24
24
|
import { existsSync, statSync } from "node:fs";
|
|
25
25
|
import { join } from "node:path";
|
|
26
|
-
import {
|
|
26
|
+
import { diffHeaderPath } from "./git-path.mjs";
|
|
27
27
|
|
|
28
28
|
const HUNK_RE = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
|
|
29
29
|
|
|
30
30
|
/** The path of a `---`/`+++` line: git-quoted or not, one-letter prefix removed. */
|
|
31
31
|
function sidePath(raw, prefix) {
|
|
32
|
-
const
|
|
33
|
-
const path = unquotePathIfNeeded(text);
|
|
32
|
+
const path = diffHeaderPath(raw);
|
|
34
33
|
if (path === "/dev/null") return null;
|
|
35
34
|
return path.startsWith(prefix) ? path.slice(prefix.length) : path;
|
|
36
35
|
}
|
|
@@ -39,7 +38,7 @@ function addLine(set, line) {
|
|
|
39
38
|
set.add(Math.max(1, line));
|
|
40
39
|
}
|
|
41
40
|
|
|
42
|
-
function toRanges(lines) {
|
|
41
|
+
function toRanges(lines, gaps = new Set()) {
|
|
43
42
|
const sorted = [...lines].sort((a, b) => a - b);
|
|
44
43
|
const out = [];
|
|
45
44
|
for (const l of sorted) {
|
|
@@ -47,21 +46,28 @@ function toRanges(lines) {
|
|
|
47
46
|
if (last && l === last.end + 1) last.end = l;
|
|
48
47
|
else if (!last || l > last.end) out.push({ start: l, end: l });
|
|
49
48
|
}
|
|
50
|
-
|
|
49
|
+
// A gap is a point between two lines where lines went in (or, on the new
|
|
50
|
+
// side, came out): `{start: before, end: before + 1, gap: true}`.
|
|
51
|
+
for (const before of gaps)
|
|
52
|
+
if (!sorted.includes(before) && !sorted.includes(before + 1))
|
|
53
|
+
out.push({ start: before, end: before + 1, gap: true });
|
|
54
|
+
return out.sort((a, b) => a.start - b.start);
|
|
51
55
|
}
|
|
52
56
|
|
|
53
57
|
/**
|
|
54
58
|
* Every file of a unified diff with the lines it touched on each side.
|
|
55
59
|
*
|
|
56
|
-
* New side: an added line counts as itself, and a deletion as the
|
|
57
|
-
*
|
|
58
|
-
* itself, and
|
|
59
|
-
*
|
|
60
|
+
* New side: an added line counts as itself, and a pure deletion as the gap
|
|
61
|
+
* between the two new lines it fell between. Old side: a removed line counts as
|
|
62
|
+
* itself, and a pure insertion as the gap between the two old lines it went in
|
|
63
|
+
* between. A gap belongs to the innermost declaration spanning both of its
|
|
64
|
+
* lines, so a hunk that only adds or only removes lands inside the declaration
|
|
65
|
+
* it changed, whatever the context size.
|
|
60
66
|
* Hunks are counted from their headers, so concatenated `diff -u` output with
|
|
61
67
|
* no `diff --git` lines parses too, CRLF included, at any context size.
|
|
62
68
|
*
|
|
63
69
|
* @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}>}>}
|
|
70
|
+
* @returns {Array<{oldPath: string|null, newPath: string|null, oldRanges: Array<{start: number, end: number, gap?: boolean}>, newRanges: Array<{start: number, end: number, gap?: boolean}>}>}
|
|
65
71
|
*/
|
|
66
72
|
export function parseDiffFiles(diffText) {
|
|
67
73
|
const files = [];
|
|
@@ -71,23 +77,36 @@ export function parseDiffFiles(diffText) {
|
|
|
71
77
|
let newLine = 0;
|
|
72
78
|
let oldLeft = 0;
|
|
73
79
|
let newLeft = 0;
|
|
80
|
+
// One run of -/+ lines. Its removed lines stand for it on the old side and
|
|
81
|
+
// its added lines on the new side; a run with nothing on one side is a gap
|
|
82
|
+
// there, between the lines either side of it.
|
|
83
|
+
let run = null;
|
|
84
|
+
const endRun = () => {
|
|
85
|
+
if (run && !run.removed) cur.oldGaps.add(run.oldBefore);
|
|
86
|
+
if (run && !run.added) cur.newGaps.add(run.newBefore);
|
|
87
|
+
run = null;
|
|
88
|
+
};
|
|
74
89
|
for (const line of String(diffText).split("\n")) {
|
|
75
90
|
const raw = line.endsWith("\r") ? line.slice(0, -1) : line;
|
|
76
91
|
if (oldLeft > 0 || newLeft > 0) {
|
|
77
|
-
if (raw.startsWith("+")) {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
92
|
+
if (raw.startsWith("+") || raw.startsWith("-")) {
|
|
93
|
+
run = run || { oldBefore: oldLine - 1, newBefore: newLine - 1, removed: 0, added: 0 };
|
|
94
|
+
if (raw.startsWith("+")) {
|
|
95
|
+
addLine(cur.newLines, newLine);
|
|
96
|
+
run.added++;
|
|
97
|
+
newLine++;
|
|
98
|
+
newLeft--;
|
|
99
|
+
} else {
|
|
100
|
+
addLine(cur.oldLines, oldLine);
|
|
101
|
+
run.removed++;
|
|
102
|
+
oldLine++;
|
|
103
|
+
oldLeft--;
|
|
104
|
+
}
|
|
105
|
+
if (oldLeft <= 0 && newLeft <= 0) endRun();
|
|
89
106
|
continue;
|
|
90
107
|
}
|
|
108
|
+
if (raw.startsWith("\\")) continue;
|
|
109
|
+
endRun();
|
|
91
110
|
if (raw.startsWith(" ") || raw === "") {
|
|
92
111
|
oldLine++;
|
|
93
112
|
newLine++;
|
|
@@ -95,7 +114,6 @@ export function parseDiffFiles(diffText) {
|
|
|
95
114
|
newLeft--;
|
|
96
115
|
continue;
|
|
97
116
|
}
|
|
98
|
-
if (raw.startsWith("\\")) continue;
|
|
99
117
|
oldLeft = 0;
|
|
100
118
|
newLeft = 0;
|
|
101
119
|
}
|
|
@@ -113,6 +131,8 @@ export function parseDiffFiles(diffText) {
|
|
|
113
131
|
newPath: sidePath(raw.slice(4), "b/"),
|
|
114
132
|
oldLines: new Set(),
|
|
115
133
|
newLines: new Set(),
|
|
134
|
+
oldGaps: new Set(),
|
|
135
|
+
newGaps: new Set(),
|
|
116
136
|
};
|
|
117
137
|
files.push(cur);
|
|
118
138
|
pendingOld = undefined;
|
|
@@ -122,12 +142,14 @@ export function parseDiffFiles(diffText) {
|
|
|
122
142
|
if (hunk && cur) {
|
|
123
143
|
const oldCount = hunk[2] === undefined ? 1 : Number(hunk[2]);
|
|
124
144
|
const newCount = hunk[4] === undefined ? 1 : Number(hunk[4]);
|
|
125
|
-
|
|
126
|
-
|
|
145
|
+
// An empty side names the line BEFORE the change; any other side names
|
|
146
|
+
// the change's first line.
|
|
147
|
+
oldLine = Number(hunk[1]) + (oldCount === 0 ? 1 : 0);
|
|
148
|
+
newLine = Number(hunk[3]) + (newCount === 0 ? 1 : 0);
|
|
127
149
|
oldLeft = oldCount;
|
|
128
150
|
newLeft = newCount;
|
|
129
|
-
if (
|
|
130
|
-
if (
|
|
151
|
+
if (oldCount === 0) cur.oldGaps.add(oldLine - 1);
|
|
152
|
+
if (newCount === 0) cur.newGaps.add(newLine - 1);
|
|
131
153
|
}
|
|
132
154
|
}
|
|
133
155
|
return files
|
|
@@ -135,8 +157,8 @@ export function parseDiffFiles(diffText) {
|
|
|
135
157
|
.map((f) => ({
|
|
136
158
|
oldPath: f.oldPath,
|
|
137
159
|
newPath: f.newPath,
|
|
138
|
-
oldRanges: toRanges(f.oldLines),
|
|
139
|
-
newRanges: toRanges(f.newLines),
|
|
160
|
+
oldRanges: toRanges(f.oldLines, f.oldGaps),
|
|
161
|
+
newRanges: toRanges(f.newLines, f.newGaps),
|
|
140
162
|
}));
|
|
141
163
|
}
|
|
142
164
|
|
|
@@ -203,8 +225,13 @@ export function changedSymbols(graph, ranges) {
|
|
|
203
225
|
continue;
|
|
204
226
|
}
|
|
205
227
|
for (const r of list) {
|
|
228
|
+
// A gap belongs to a declaration only when the lines on both sides do.
|
|
206
229
|
const containing = spanned
|
|
207
|
-
.filter((s) =>
|
|
230
|
+
.filter((s) =>
|
|
231
|
+
r.gap
|
|
232
|
+
? (s.startLine ?? s.line) <= r.start && s.endLine >= r.end
|
|
233
|
+
: (s.startLine ?? s.line) <= r.end && s.endLine >= r.start,
|
|
234
|
+
)
|
|
208
235
|
.sort((a, b) => b.line - a.line);
|
|
209
236
|
if (!containing.length) {
|
|
210
237
|
seeds.set(fileNode.id, fileNode);
|
|
@@ -268,16 +295,24 @@ export function graphFreshness(graph, { root = graph.root } = {}) {
|
|
|
268
295
|
}
|
|
269
296
|
|
|
270
297
|
/**
|
|
271
|
-
* Which side of a diff the graph's line numbers describe.
|
|
298
|
+
* Which side of a diff the graph's line numbers describe. For a commit range
|
|
299
|
+
* (`range`, `<base>..<head>`) the graph's build commit decides: built at the
|
|
300
|
+
* head, the new side; at the base, the old side. Otherwise a graph that matches
|
|
272
301
|
* the tree at `root` (same HEAD, no file edited since the build) describes the
|
|
273
302
|
* new side; any other graph was built before the change - on the base checkout,
|
|
274
303
|
* or before the edit - and its spans are old-side line numbers.
|
|
275
304
|
*
|
|
276
305
|
* @param {object} graph
|
|
277
|
-
* @param {{root?: string}} [opts]
|
|
306
|
+
* @param {{root?: string, range?: string}} [opts]
|
|
278
307
|
* @returns {"new"|"old"}
|
|
279
308
|
*/
|
|
280
309
|
export function graphSide(graph, opts = {}) {
|
|
310
|
+
const parts = typeof opts.range === "string" ? opts.range.split("..") : [];
|
|
311
|
+
if (parts.length === 2 && opts.root && graph.baseCommit && parts.every((p) => REF_RE.test(p))) {
|
|
312
|
+
const sha = (ref) => gitOut(opts.root, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]);
|
|
313
|
+
if (sha(parts[1]) === graph.baseCommit) return "new";
|
|
314
|
+
if (sha(parts[0]) === graph.baseCommit) return "old";
|
|
315
|
+
}
|
|
281
316
|
return graphFreshness(graph, opts).stale ? "old" : "new";
|
|
282
317
|
}
|
|
283
318
|
|
|
@@ -0,0 +1,146 @@
|
|
|
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):`, `Name[T](A):`
|
|
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 CALL_RE = /^\s*[A-Za-z_$][\w$.]*\s*(?:<[^()]*>)?\s*\(/;
|
|
24
|
+
const OPEN = { "(": ")", "<": ">", "[": "]" };
|
|
25
|
+
const CLOSE = new Set([")", ">", "]"]);
|
|
26
|
+
|
|
27
|
+
/** `->` and `=>` are arrows, not the end of a generic argument list. */
|
|
28
|
+
const isArrow = (text, i) => text[i] === ">" && (text[i - 1] === "-" || text[i - 1] === "=");
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The header from `start` to the `{` that opens the body (or `;`, or Python's
|
|
32
|
+
* closing `:`) at bracket depth 0, capped so a declaration with no body cannot
|
|
33
|
+
* read on into the file.
|
|
34
|
+
*/
|
|
35
|
+
function headerText(body, start, syntax) {
|
|
36
|
+
let depth = 0;
|
|
37
|
+
const end = Math.min(body.length, start + HEADER_MAX);
|
|
38
|
+
for (let i = start; i < end; i++) {
|
|
39
|
+
const c = body[i];
|
|
40
|
+
if (c in OPEN) depth++;
|
|
41
|
+
else if (CLOSE.has(c) && depth > 0 && !isArrow(body, i)) depth--;
|
|
42
|
+
else if (depth === 0 && (c === "{" || c === ";")) return body.slice(start, i);
|
|
43
|
+
else if (depth === 0 && syntax === "parens" && c === ":") return body.slice(start, i);
|
|
44
|
+
}
|
|
45
|
+
return body.slice(start, end);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Split on commas at bracket depth 0. */
|
|
49
|
+
function splitTop(text) {
|
|
50
|
+
const parts = [];
|
|
51
|
+
let depth = 0;
|
|
52
|
+
let cur = "";
|
|
53
|
+
for (let i = 0; i < text.length; i++) {
|
|
54
|
+
const c = text[i];
|
|
55
|
+
if (c in OPEN) depth++;
|
|
56
|
+
else if (CLOSE.has(c) && depth > 0 && !isArrow(text, i)) depth--;
|
|
57
|
+
if (c === "," && depth === 0) {
|
|
58
|
+
parts.push(cur);
|
|
59
|
+
cur = "";
|
|
60
|
+
} else cur += c;
|
|
61
|
+
}
|
|
62
|
+
parts.push(cur);
|
|
63
|
+
return parts;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Skip one balanced bracket group starting at `i` when `text[i]` is `open`. */
|
|
67
|
+
function skipGroup(text, i, open) {
|
|
68
|
+
if (text[i] !== open) return i;
|
|
69
|
+
const close = OPEN[open];
|
|
70
|
+
let depth = 0;
|
|
71
|
+
for (let j = i; j < text.length; j++) {
|
|
72
|
+
if (text[j] === open) depth++;
|
|
73
|
+
else if (text[j] === close && !isArrow(text, j) && --depth === 0) return j + 1;
|
|
74
|
+
}
|
|
75
|
+
return text.length;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function simpleName(raw) {
|
|
79
|
+
const item = raw.replace(/^\s*(?:@[\w.]+(?:\([^)]*\))?\s*)+/, "").replace(/`/g, "");
|
|
80
|
+
const m = /^\s*(?:out\s+|in\s+)?([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*)/.exec(item);
|
|
81
|
+
if (!m || item.includes("=")) return null;
|
|
82
|
+
return m[1].split(".").pop();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function names(list) {
|
|
86
|
+
const out = [];
|
|
87
|
+
for (const item of splitTop(list)) {
|
|
88
|
+
const n = simpleName(item);
|
|
89
|
+
if (n && !out.includes(n)) out.push(n);
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* @param {string} body - stripped source
|
|
96
|
+
* @param {number} start - offset just past the declared name
|
|
97
|
+
* @param {"colon"|"parens"|"extends"} syntax
|
|
98
|
+
* @returns {string[]} simple supertype names, in header order
|
|
99
|
+
*/
|
|
100
|
+
export function supertypesAt(body, start, syntax) {
|
|
101
|
+
const header = headerText(body, start, syntax);
|
|
102
|
+
if (syntax === "parens") {
|
|
103
|
+
let k = 0;
|
|
104
|
+
while (k < header.length && /\s/.test(header[k])) k++;
|
|
105
|
+
// PEP 695 type parameters come before the bases: `class Box[T](Base):`.
|
|
106
|
+
const rest = header.slice(skipGroup(header, k, "["));
|
|
107
|
+
const m = /^\s*\(/.exec(rest);
|
|
108
|
+
if (!m) return [];
|
|
109
|
+
const end = skipGroup(rest, m[0].length - 1, "(");
|
|
110
|
+
return names(rest.slice(m[0].length, end - 1));
|
|
111
|
+
}
|
|
112
|
+
if (syntax === "extends") {
|
|
113
|
+
let k = 0;
|
|
114
|
+
while (k < header.length && /\s/.test(header[k])) k++;
|
|
115
|
+
const rest = header.slice(skipGroup(header, k, "<"));
|
|
116
|
+
const out = [];
|
|
117
|
+
const ext = /\bextends\s+([\s\S]*?)(?=\bimplements\b|$)/.exec(rest);
|
|
118
|
+
const imp = /\bimplements\s+([\s\S]*)$/.exec(rest);
|
|
119
|
+
// A base built by a call (`extends mixin(Base)`) is the call's result,
|
|
120
|
+
// which names no type the graph can point at.
|
|
121
|
+
for (const part of [ext?.[1], imp?.[1]])
|
|
122
|
+
if (part)
|
|
123
|
+
for (const n of names(
|
|
124
|
+
splitTop(part)
|
|
125
|
+
.filter((p) => !CALL_RE.test(p))
|
|
126
|
+
.join(","),
|
|
127
|
+
))
|
|
128
|
+
if (!out.includes(n)) out.push(n);
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
let i = 0;
|
|
132
|
+
while (i < header.length && /\s/.test(header[i])) i++;
|
|
133
|
+
i = skipGroup(header, i, "<");
|
|
134
|
+
let j = i;
|
|
135
|
+
while (j < header.length && /\s/.test(header[j])) j++;
|
|
136
|
+
const ctor =
|
|
137
|
+
/^(?:@[A-Za-z_][\w.]*(?:\([^)]*\))?\s+)*(?:(?:private|protected|internal|public)\s+)?constructor\s*/.exec(
|
|
138
|
+
header.slice(j),
|
|
139
|
+
);
|
|
140
|
+
if (ctor) j += ctor[0].length;
|
|
141
|
+
j = skipGroup(header, j, "(");
|
|
142
|
+
while (j < header.length && /\s/.test(header[j])) j++;
|
|
143
|
+
if (header[j] !== ":") return [];
|
|
144
|
+
const list = header.slice(j + 1).split(/\bwhere\b/)[0];
|
|
145
|
+
return names(list.replace(/\bby\s+[^,]*/g, ""));
|
|
146
|
+
}
|
|
@@ -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(
|
|
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(
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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(
|
|
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")}`,
|
|
@@ -153,7 +153,7 @@ export function identityLabel(prefix, docId, sourceIds) {
|
|
|
153
153
|
* issueTree.projectKey where the tree is created
|
|
154
154
|
* issueTree.epicIssueType the type name for an epic on this site
|
|
155
155
|
* issueTree.storyIssueType the type name for a story
|
|
156
|
-
* issueTree.subtaskIssueType null = discover
|
|
156
|
+
* issueTree.subtaskIssueType a name, a {role: name} map, or null = discover
|
|
157
157
|
* issueTree.subtaskRoles how a story is split; ships empty on purpose
|
|
158
158
|
* issueTree.channelComponents channel -> this site's component name
|
|
159
159
|
* issueTree.channelTeams channel -> this site's team name
|
|
@@ -195,6 +195,17 @@ function readPrefs() {
|
|
|
195
195
|
}
|
|
196
196
|
}
|
|
197
197
|
|
|
198
|
+
/**
|
|
199
|
+
* The sub-task type a role is created as, from issueTree.subtaskIssueType: a
|
|
200
|
+
* name for every role, or a {role: name} map with "*" for any other role. Null
|
|
201
|
+
* means the writer discovers it from the site.
|
|
202
|
+
*/
|
|
203
|
+
export function subtaskTypeFor(setting, role) {
|
|
204
|
+
if (typeof setting === "string" && setting) return setting;
|
|
205
|
+
if (setting && typeof setting === "object") return setting[role] ?? setting["*"] ?? null;
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
|
|
198
209
|
/** Every open placeholder that makes a document unfit to become work. */
|
|
199
210
|
export function openItems(text) {
|
|
200
211
|
const found = [];
|
|
@@ -557,7 +568,10 @@ async function main() {
|
|
|
557
568
|
for (const line of s.description.split("\n")) process.stdout.write(` ${line}\n`);
|
|
558
569
|
}
|
|
559
570
|
for (const t of s.subtasks) {
|
|
560
|
-
|
|
571
|
+
const type = subtaskTypeFor(result.prefsUsed.subtaskIssueType, t.role);
|
|
572
|
+
process.stdout.write(
|
|
573
|
+
` subtask ${t.role} type ${type ?? "discovered from createmeta"} [${t.label}]\n`,
|
|
574
|
+
);
|
|
561
575
|
}
|
|
562
576
|
for (const c of s.clones || []) {
|
|
563
577
|
process.stdout.write(
|
|
@@ -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() {
|
|
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
|
#
|
|
@@ -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",
|
|
@@ -47,10 +47,15 @@
|
|
|
47
47
|
"importPatterns": [
|
|
48
48
|
{
|
|
49
49
|
"id": "import",
|
|
50
|
-
"regex": "^\\s*import\\s+(?:class|struct|enum|protocol|func|var|let|typealias)\\s+([A-Za-z_][A-Za-z0-9_]*)|^\\s*import\\s+([A-Za-z_][A-Za-z0-9_]*)"
|
|
50
|
+
"regex": "^\\s*(?:@[A-Za-z_]\\w*(?:\\([^)\\n]*\\))?\\s+)*import\\s+(?:class|struct|enum|protocol|func|var|let|typealias)\\s+([A-Za-z_][A-Za-z0-9_]*)|^\\s*(?:@[A-Za-z_]\\w*(?:\\([^)\\n]*\\))?\\s+)*import\\s+([A-Za-z_][A-Za-z0-9_]*)"
|
|
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",
|
|
@@ -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
|
+
}
|
|
@@ -171,7 +171,8 @@ function runDiff(flags, graph, depth, kinds) {
|
|
|
171
171
|
text = diffFor(root, flags.diff);
|
|
172
172
|
}
|
|
173
173
|
const freshness = graphFreshness(graph, { root });
|
|
174
|
-
const
|
|
174
|
+
const range = flags["diff-file"] && flags["diff-file"] !== true ? undefined : flags.diff;
|
|
175
|
+
const side = graphSide(graph, { root, range });
|
|
175
176
|
const mapped = changedSymbols(graph, parseHunkRanges(text, side));
|
|
176
177
|
const hits = affected({ graph, seeds: mapped.seeds, depth, kinds });
|
|
177
178
|
const result = {
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file graph-confirm.mjs - a name-based blast radius, checked by a language server.
|
|
5
|
+
*
|
|
6
|
+
* The code graph joins files to declarations by name, so a dependent it lists
|
|
7
|
+
* may only share a name with the changed type, and a dependent behind an
|
|
8
|
+
* ambiguous name is missing. The toolkit's `code_references` answers the same
|
|
9
|
+
* question semantically when the project has an index. This merges the two
|
|
10
|
+
* without trusting either blindly:
|
|
11
|
+
*
|
|
12
|
+
* confirmed a direct dependent the server also found
|
|
13
|
+
* name-only a direct dependent a ready semantic index did not find
|
|
14
|
+
* unchecked a deeper ring the server did not name (it depends on an
|
|
15
|
+
* intermediate declaration), or no answer could check it
|
|
16
|
+
*
|
|
17
|
+
* A deeper ring the server names directly is confirmed too.
|
|
18
|
+
*
|
|
19
|
+
* `lspOnly` lists files the server found that the graph did not. Only a result
|
|
20
|
+
* with `semantic: true` and `indexReady: true` confirms or demotes anything; a
|
|
21
|
+
* fallback answer or an index still building leaves every file unchecked,
|
|
22
|
+
* because "not found" from an unready index is not evidence.
|
|
23
|
+
*
|
|
24
|
+
* Inputs:
|
|
25
|
+
* --affected <path> graph-affected --json output
|
|
26
|
+
* --references <path> JSON array of code_references results, one per changed type
|
|
27
|
+
* --root <path> repository root the server's absolute paths are under
|
|
28
|
+
*
|
|
29
|
+
* Exit codes: 0 answered, 64 usage error.
|
|
30
|
+
*
|
|
31
|
+
* Zero runtime dependencies (ADR-0004).
|
|
32
|
+
*
|
|
33
|
+
* @module pipeline/scripts/graph-confirm
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { readFileSync, realpathSync } from "node:fs";
|
|
37
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
38
|
+
import { invokedDirectly } from "../lib/invoked-directly.mjs";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @param {{affected: Array<{path: string|null, dist: number}>}} affectedDoc
|
|
42
|
+
* @param {Array<{semantic?: boolean, indexReady?: boolean, results?: Array<{file: string}>}>} references
|
|
43
|
+
* @param {{root: string}} opts
|
|
44
|
+
*/
|
|
45
|
+
export function confirmImpact(affectedDoc, references, { root }) {
|
|
46
|
+
const usable = (references || []).filter(
|
|
47
|
+
(r) => r && r.semantic === true && r.indexReady === true,
|
|
48
|
+
);
|
|
49
|
+
const checked = usable.length > 0 && usable.length === (references || []).length;
|
|
50
|
+
const real = (p) => {
|
|
51
|
+
const abs = resolve(p);
|
|
52
|
+
try {
|
|
53
|
+
return realpathSync(abs);
|
|
54
|
+
} catch {
|
|
55
|
+
const parent = dirname(abs);
|
|
56
|
+
return parent === abs ? abs : join(real(parent), basename(abs));
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
const repo = real(root);
|
|
60
|
+
// A server answers with paths relative to its own workspace root (the
|
|
61
|
+
// nearest package), absolute ones through whatever spelling of a symlinked
|
|
62
|
+
// directory it was given, and files outside the repository (SDKs).
|
|
63
|
+
const rel = (file, serverRoot) => {
|
|
64
|
+
const abs = isAbsolute(file) ? file : resolve(serverRoot || root, file);
|
|
65
|
+
const r = relative(repo, real(abs));
|
|
66
|
+
return r && !r.startsWith("..") && !isAbsolute(r) ? r : null;
|
|
67
|
+
};
|
|
68
|
+
const found = new Set();
|
|
69
|
+
for (const r of usable)
|
|
70
|
+
for (const loc of r.results || []) {
|
|
71
|
+
const f = loc?.file ? rel(loc.file, r.root) : null;
|
|
72
|
+
if (f) found.add(f);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const seedPaths = new Set((affectedDoc.changedSymbols || []).map((c) => c.path).filter(Boolean));
|
|
76
|
+
for (const id of affectedDoc.seeds || []) {
|
|
77
|
+
const s = String(id);
|
|
78
|
+
if (s.startsWith("sym:")) seedPaths.add(s.slice(4, s.lastIndexOf("#")));
|
|
79
|
+
else if (s.startsWith("file:")) seedPaths.add(s.slice(5));
|
|
80
|
+
}
|
|
81
|
+
const graphPaths = new Set();
|
|
82
|
+
const affected = (affectedDoc.affected || []).map((row) => {
|
|
83
|
+
if (row.path) graphPaths.add(row.path);
|
|
84
|
+
let confirmation = "unchecked";
|
|
85
|
+
if (checked && row.path && found.has(row.path)) confirmation = "confirmed";
|
|
86
|
+
else if (checked && row.dist === 1 && row.path) confirmation = "name-only";
|
|
87
|
+
return { ...row, confirmation };
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const lspOnly = checked
|
|
91
|
+
? [...found].filter((f) => !graphPaths.has(f) && !seedPaths.has(f)).sort()
|
|
92
|
+
: [];
|
|
93
|
+
const byPath = new Map();
|
|
94
|
+
for (const a of affected) if (a.path && !byPath.has(a.path)) byPath.set(a.path, a.confirmation);
|
|
95
|
+
const count = (c) => [...byPath.values()].filter((v) => v === c).length;
|
|
96
|
+
return {
|
|
97
|
+
affected,
|
|
98
|
+
lspOnly,
|
|
99
|
+
summary: {
|
|
100
|
+
confirmed: count("confirmed"),
|
|
101
|
+
nameOnly: count("name-only"),
|
|
102
|
+
unchecked: count("unchecked"),
|
|
103
|
+
lspOnly: lspOnly.length,
|
|
104
|
+
checked,
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function flag(argv, name) {
|
|
110
|
+
const i = argv.indexOf(name);
|
|
111
|
+
const v = i >= 0 ? argv[i + 1] : undefined;
|
|
112
|
+
return v && !v.startsWith("--") ? v : null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function main(argv) {
|
|
116
|
+
const affectedPath = flag(argv, "--affected");
|
|
117
|
+
const referencesPath = flag(argv, "--references");
|
|
118
|
+
const root = flag(argv, "--root");
|
|
119
|
+
if (!affectedPath || !referencesPath || !root) {
|
|
120
|
+
process.stderr.write(
|
|
121
|
+
"usage: graph-confirm.mjs --affected <json> --references <json> --root <path>\n",
|
|
122
|
+
);
|
|
123
|
+
process.exitCode = 64;
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
let affectedDoc;
|
|
127
|
+
let references;
|
|
128
|
+
try {
|
|
129
|
+
affectedDoc = JSON.parse(readFileSync(affectedPath, "utf8"));
|
|
130
|
+
references = JSON.parse(readFileSync(referencesPath, "utf8"));
|
|
131
|
+
} catch (e) {
|
|
132
|
+
process.stderr.write(`graph-confirm: ${e.message}\n`);
|
|
133
|
+
process.exitCode = 64;
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (!Array.isArray(references)) references = [references];
|
|
137
|
+
process.stdout.write(`${JSON.stringify(confirmImpact(affectedDoc, references, { root }))}\n`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (invokedDirectly(import.meta.url)) main(process.argv.slice(2));
|