@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
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file _supertypes.mjs - the types a declaration header names as its parents.
|
|
3
|
+
*
|
|
4
|
+
* Reads the header text that follows a type's name in a stripped body (comments
|
|
5
|
+
* and strings already blanked) and returns the simple names of its superclass,
|
|
6
|
+
* protocols or interfaces. Three syntaxes cover the stacks the graph indexes:
|
|
7
|
+
*
|
|
8
|
+
* colon Swift and Kotlin: `Name<T>(ctor): A, B<X>() where ... {`
|
|
9
|
+
* parens Python: `Name(A, B, metaclass=M):`
|
|
10
|
+
* extends TypeScript / JavaScript: `Name<T> extends A implements B, C {`
|
|
11
|
+
*
|
|
12
|
+
* Only names that start with a letter or underscore are kept, qualified names
|
|
13
|
+
* reduce to their last segment, and generic arguments, constructor calls and
|
|
14
|
+
* Kotlin delegation (`by impl`) are dropped. A name the graph does not define
|
|
15
|
+
* (String, Codable) simply produces no edge.
|
|
16
|
+
*
|
|
17
|
+
* Zero runtime dependencies (ADR-0004).
|
|
18
|
+
*
|
|
19
|
+
* @module pipeline/scripts/_supertypes
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const HEADER_MAX = 4000;
|
|
23
|
+
const OPEN = { "(": ")", "<": ">", "[": "]" };
|
|
24
|
+
const CLOSE = new Set([")", ">", "]"]);
|
|
25
|
+
|
|
26
|
+
/** `->` and `=>` are arrows, not the end of a generic argument list. */
|
|
27
|
+
const isArrow = (text, i) => text[i] === ">" && (text[i - 1] === "-" || text[i - 1] === "=");
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The header from `start` to the `{` that opens the body (or `;`, or Python's
|
|
31
|
+
* closing `:`) at bracket depth 0, capped so a declaration with no body cannot
|
|
32
|
+
* read on into the file.
|
|
33
|
+
*/
|
|
34
|
+
function headerText(body, start, syntax) {
|
|
35
|
+
let depth = 0;
|
|
36
|
+
const end = Math.min(body.length, start + HEADER_MAX);
|
|
37
|
+
for (let i = start; i < end; i++) {
|
|
38
|
+
const c = body[i];
|
|
39
|
+
if (c in OPEN) depth++;
|
|
40
|
+
else if (CLOSE.has(c) && depth > 0 && !isArrow(body, i)) depth--;
|
|
41
|
+
else if (depth === 0 && (c === "{" || c === ";")) return body.slice(start, i);
|
|
42
|
+
else if (depth === 0 && syntax === "parens" && c === ":") return body.slice(start, i);
|
|
43
|
+
}
|
|
44
|
+
return body.slice(start, end);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Split on commas at bracket depth 0. */
|
|
48
|
+
function splitTop(text) {
|
|
49
|
+
const parts = [];
|
|
50
|
+
let depth = 0;
|
|
51
|
+
let cur = "";
|
|
52
|
+
for (let i = 0; i < text.length; i++) {
|
|
53
|
+
const c = text[i];
|
|
54
|
+
if (c in OPEN) depth++;
|
|
55
|
+
else if (CLOSE.has(c) && depth > 0 && !isArrow(text, i)) depth--;
|
|
56
|
+
if (c === "," && depth === 0) {
|
|
57
|
+
parts.push(cur);
|
|
58
|
+
cur = "";
|
|
59
|
+
} else cur += c;
|
|
60
|
+
}
|
|
61
|
+
parts.push(cur);
|
|
62
|
+
return parts;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Skip one balanced bracket group starting at `i` when `text[i]` is `open`. */
|
|
66
|
+
function skipGroup(text, i, open) {
|
|
67
|
+
if (text[i] !== open) return i;
|
|
68
|
+
const close = OPEN[open];
|
|
69
|
+
let depth = 0;
|
|
70
|
+
for (let j = i; j < text.length; j++) {
|
|
71
|
+
if (text[j] === open) depth++;
|
|
72
|
+
else if (text[j] === close && !isArrow(text, j) && --depth === 0) return j + 1;
|
|
73
|
+
}
|
|
74
|
+
return text.length;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function simpleName(raw) {
|
|
78
|
+
const item = raw.replace(/^\s*(?:@[\w.]+(?:\([^)]*\))?\s*)+/, "").replace(/`/g, "");
|
|
79
|
+
const m = /^\s*(?:out\s+|in\s+)?([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*)/.exec(item);
|
|
80
|
+
if (!m || item.includes("=")) return null;
|
|
81
|
+
return m[1].split(".").pop();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function names(list) {
|
|
85
|
+
const out = [];
|
|
86
|
+
for (const item of splitTop(list)) {
|
|
87
|
+
const n = simpleName(item);
|
|
88
|
+
if (n && !out.includes(n)) out.push(n);
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* @param {string} body - stripped source
|
|
95
|
+
* @param {number} start - offset just past the declared name
|
|
96
|
+
* @param {"colon"|"parens"|"extends"} syntax
|
|
97
|
+
* @returns {string[]} simple supertype names, in header order
|
|
98
|
+
*/
|
|
99
|
+
export function supertypesAt(body, start, syntax) {
|
|
100
|
+
const header = headerText(body, start, syntax);
|
|
101
|
+
if (syntax === "parens") {
|
|
102
|
+
const m = /^\s*\(/.exec(header);
|
|
103
|
+
if (!m) return [];
|
|
104
|
+
const end = skipGroup(header, m[0].length - 1, "(");
|
|
105
|
+
return names(header.slice(m[0].length, end - 1));
|
|
106
|
+
}
|
|
107
|
+
if (syntax === "extends") {
|
|
108
|
+
let k = 0;
|
|
109
|
+
while (k < header.length && /\s/.test(header[k])) k++;
|
|
110
|
+
const rest = header.slice(skipGroup(header, k, "<"));
|
|
111
|
+
const out = [];
|
|
112
|
+
const ext = /\bextends\s+([\s\S]*?)(?=\bimplements\b|$)/.exec(rest);
|
|
113
|
+
const imp = /\bimplements\s+([\s\S]*)$/.exec(rest);
|
|
114
|
+
for (const part of [ext?.[1], imp?.[1]])
|
|
115
|
+
if (part) for (const n of names(part)) if (!out.includes(n)) out.push(n);
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
let i = 0;
|
|
119
|
+
while (i < header.length && /\s/.test(header[i])) i++;
|
|
120
|
+
i = skipGroup(header, i, "<");
|
|
121
|
+
let j = i;
|
|
122
|
+
while (j < header.length && /\s/.test(header[j])) j++;
|
|
123
|
+
const ctor =
|
|
124
|
+
/^(?:@[A-Za-z_][\w.]*(?:\([^)]*\))?\s+)*(?:(?:private|protected|internal|public)\s+)?constructor\s*/.exec(
|
|
125
|
+
header.slice(j),
|
|
126
|
+
);
|
|
127
|
+
if (ctor) j += ctor[0].length;
|
|
128
|
+
j = skipGroup(header, j, "(");
|
|
129
|
+
while (j < header.length && /\s/.test(header[j])) j++;
|
|
130
|
+
if (header[j] !== ":") return [];
|
|
131
|
+
const list = header.slice(j + 1).split(/\bwhere\b/)[0];
|
|
132
|
+
return names(list.replace(/\bby\s+[^,]*/g, ""));
|
|
133
|
+
}
|
|
@@ -16,6 +16,11 @@ import { escapeJiraWiki, wikiLink } from "./jira-wiki-escape.mjs";
|
|
|
16
16
|
import { redesignSections } from "../lib/analysis-sections.mjs";
|
|
17
17
|
|
|
18
18
|
const MAX_CRITERIA = 8;
|
|
19
|
+
|
|
20
|
+
// Panel border colours of a story section, the ones testers read stories in:
|
|
21
|
+
// the user story, the acceptance criteria, and every other section.
|
|
22
|
+
const PANEL = { story: "#d4d39e", criteria: "#d4a19e", info: "#9eb6d4" };
|
|
23
|
+
const panel = (colour, lines) => [`{panel:borderColor=${colour}}`, ...lines, "{panel}", ""];
|
|
19
24
|
const MAX_DESIGN_LINKS = 5;
|
|
20
25
|
|
|
21
26
|
// A criterion that describes a refusal, an error or an alternative path. One is
|
|
@@ -152,19 +157,30 @@ function linksOf(story, lines, ctx) {
|
|
|
152
157
|
|
|
153
158
|
function render({ userStory, acceptance, currentVsTarget, links }, sourceIds) {
|
|
154
159
|
const out = [];
|
|
155
|
-
if (userStory) out.push("h3. User story", escapeJiraWiki(userStory)
|
|
160
|
+
if (userStory) out.push(...panel(PANEL.story, ["h3. User story", escapeJiraWiki(userStory)]));
|
|
156
161
|
if (acceptance.length) {
|
|
157
|
-
out.push(
|
|
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")}`,
|
|
@@ -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
|
#
|
|
@@ -110,8 +113,37 @@ fi
|
|
|
110
113
|
# Phase 4 removes the worktree once the PR is open, so the salvaged copy under
|
|
111
114
|
# artifactsPath is tried FIRST. Reading the worktree path first would degrade
|
|
112
115
|
# silently for exactly the runs this script exists to rescue.
|
|
116
|
+
#
|
|
117
|
+
# After those, each worktree's newest `.pipeline/triage-round-<N>.json`: Phase 3
|
|
118
|
+
# writes every round there itself, and the copy to triage-output.json is a
|
|
119
|
+
# separate step a run can die before. A multi-repo run names its worktrees only
|
|
120
|
+
# under projects[], so those are read too, and the state directory last.
|
|
121
|
+
newest_round() {
|
|
122
|
+
[ -d "$1/.pipeline" ] || return 0
|
|
123
|
+
ls -t "$1"/.pipeline/triage-round-*.json 2>/dev/null | head -1
|
|
124
|
+
}
|
|
125
|
+
WORKTREES=$( { [ -n "$WORKTREE" ] && printf '%s\n' "$WORKTREE"; jq -r '.projects[]?.worktreePath // empty' "$STATE" 2>/dev/null; } | awk 'NF && !seen[$0]++')
|
|
113
126
|
TRIAGE=""
|
|
114
|
-
|
|
127
|
+
CANDIDATES=("${ARTIFACTS:+$ARTIFACTS/triage-output.json}")
|
|
128
|
+
# Within one worktree the newer of the copy and the newest round wins: a run
|
|
129
|
+
# that died before the copy step leaves the previous round in the copy.
|
|
130
|
+
newest_in_worktree() {
|
|
131
|
+
local copy="$1/triage-output.json" round
|
|
132
|
+
round=$(newest_round "$1")
|
|
133
|
+
if [ -f "$copy" ] && [ -n "$round" ]; then
|
|
134
|
+
ls -t "$copy" "$round" 2>/dev/null | head -1
|
|
135
|
+
elif [ -f "$copy" ]; then
|
|
136
|
+
printf '%s\n' "$copy"
|
|
137
|
+
else
|
|
138
|
+
printf '%s\n' "$round"
|
|
139
|
+
fi
|
|
140
|
+
}
|
|
141
|
+
while IFS= read -r wt; do
|
|
142
|
+
[ -n "$wt" ] || continue
|
|
143
|
+
CANDIDATES+=("$(newest_in_worktree "$wt")")
|
|
144
|
+
done <<< "$WORKTREES"
|
|
145
|
+
CANDIDATES+=("$(dirname "$STATE")/triage-output.json")
|
|
146
|
+
for cand in "${CANDIDATES[@]}"; do
|
|
115
147
|
[ -n "$cand" ] && [ -f "$cand" ] && { TRIAGE="$cand"; break; }
|
|
116
148
|
done
|
|
117
149
|
|
|
@@ -136,12 +168,38 @@ run_store() {
|
|
|
136
168
|
esac
|
|
137
169
|
}
|
|
138
170
|
|
|
171
|
+
# The store is named after the run's repository, never after the directory this
|
|
172
|
+
# script happens to run in: a SessionEnd hook fires wherever the session was.
|
|
173
|
+
# A live worktree resolves to its main checkout's folder, the same name the
|
|
174
|
+
# readers derive; otherwise the project recorded in the state.
|
|
175
|
+
slug_of_worktree() {
|
|
176
|
+
[ -d "$1" ] || return 0
|
|
177
|
+
local main
|
|
178
|
+
main=$(git -C "$1" worktree list --porcelain 2>/dev/null | sed -n '1s/^worktree //p')
|
|
179
|
+
[ -n "$main" ] || return 0
|
|
180
|
+
basename "$main" .git
|
|
181
|
+
}
|
|
182
|
+
SLUG=""
|
|
183
|
+
while IFS= read -r wt; do
|
|
184
|
+
[ -n "$wt" ] && [ -z "$SLUG" ] && SLUG=$(slug_of_worktree "$wt")
|
|
185
|
+
done <<< "$WORKTREES"
|
|
186
|
+
plain_slug() {
|
|
187
|
+
case "$1" in
|
|
188
|
+
"" | .* | *[!A-Za-z0-9._-]*) return 0 ;;
|
|
189
|
+
esac
|
|
190
|
+
printf '%s' "$1"
|
|
191
|
+
}
|
|
192
|
+
SLUG=$(plain_slug "$SLUG")
|
|
193
|
+
[ -n "$SLUG" ] || SLUG=$(plain_slug "$(jq -r '.project // .projects[0].name // empty' "$STATE" 2>/dev/null)")
|
|
194
|
+
SLUG_ARGS=()
|
|
195
|
+
[ -n "$SLUG" ] && SLUG_ARGS=(--repo-slug "$SLUG")
|
|
196
|
+
|
|
139
197
|
if [ -n "$TRIAGE" ]; then
|
|
140
198
|
INGESTED=$(run_store ingest \
|
|
141
|
-
node "$SCRIPTS/triage-memory.mjs" ingest --triage "$TRIAGE" --state "$STATE")
|
|
199
|
+
node "$SCRIPTS/triage-memory.mjs" ingest --triage "$TRIAGE" --state "$STATE" ${SLUG_ARGS[@]+"${SLUG_ARGS[@]}"})
|
|
142
200
|
if [ -n "$TASK_ID" ]; then
|
|
143
201
|
DISTILLED=$(run_store distill \
|
|
144
|
-
node "$SCRIPTS/learnings-ledger.mjs" from-triage --triage "$TRIAGE" --task "$TASK_ID")
|
|
202
|
+
node "$SCRIPTS/learnings-ledger.mjs" from-triage --triage "$TRIAGE" --task "$TASK_ID" ${SLUG_ARGS[@]+"${SLUG_ARGS[@]}"})
|
|
145
203
|
fi
|
|
146
204
|
fi
|
|
147
205
|
|
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
}
|
|
52
52
|
],
|
|
53
53
|
"referenceKinds": ["class", "interface", "object", "enum", "record", "typealias"],
|
|
54
|
+
"supertypes": { "syntax": "colon", "kinds": ["class", "interface", "object", "enum"] },
|
|
54
55
|
"referenceKindsNote": "fun is deliberately absent, for the reason Swift's rules record for func: a bare lowercase name matched across files is almost never a call to that exact declaration, and on a large app the generic ones (invoke, build, create, get) take the top god-node slots purely because each happened to be declared once. Composables are the tempting exception - they are capitalised and behave like types - but they are declared with fun, so including them means including every other fun with them.",
|
|
55
56
|
"ignoredIdentifiers": [
|
|
56
57
|
"this",
|
|
@@ -51,6 +51,11 @@
|
|
|
51
51
|
}
|
|
52
52
|
],
|
|
53
53
|
"referenceKinds": ["class", "struct", "enum", "protocol", "actor", "typealias"],
|
|
54
|
+
"supertypes": {
|
|
55
|
+
"syntax": "colon",
|
|
56
|
+
"kinds": ["class", "struct", "enum", "protocol", "actor"],
|
|
57
|
+
"extensionPattern": "\\bextension\\s+([A-Z][A-Za-z0-9_.]*)"
|
|
58
|
+
},
|
|
54
59
|
"referenceKindsNote": "func is deliberately absent. A bare lowercase method name matched across files is almost never a call to that exact declaration: on a large Swift app `with`, `size`, `name`, `image` and `model` took the top god-node slots purely because each happened to be declared in exactly one file. Functions still reach the graph through their `defines` edge, so they stay findable by name.",
|
|
55
60
|
"ignoredIdentifiers": [
|
|
56
61
|
"self",
|
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
}
|
|
47
47
|
],
|
|
48
48
|
"referenceKinds": ["class", "interface", "type", "enum", "const"],
|
|
49
|
+
"supertypes": { "syntax": "extends", "kinds": ["class", "interface"] },
|
|
49
50
|
"referenceKindsNote": "function is deliberately absent, for the reason Swift's rules record for func: a bare name matched across files is almost never a call to that exact declaration, and the generic ones (run, main, get, parse, handler) would take the top god-node slots purely because each happened to be declared once. Functions still reach the graph through their defines edge, so they stay findable by name. const is present but narrowed to an uppercase-initial declaration at column 0, which is what a component, a singleton or an exported table looks like; a lowercase const is a local and is not a declaration anyone references across files. Measured on this repo (126 sources): including function put `ok` at degree 52, `f` at 38, `tokens` at 15 and `arg` at 12 - local helper names that happen to be declared exactly once, so the ambiguity rule does not catch them either. Excluding it leaves a thin symbol layer, and that is the honest shape of this stack: a JavaScript module's exported unit is usually a function, so the useful graph here is the import graph between files. `graph-affected \"<file>.mjs\"` answers who imports a module, which is the question this stack actually gets asked.",
|
|
50
51
|
"ignoredIdentifiers": [
|
|
51
52
|
"this",
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
}
|
|
31
31
|
],
|
|
32
32
|
"referenceKinds": ["class"],
|
|
33
|
+
"supertypes": { "syntax": "parens", "kinds": ["class"] },
|
|
33
34
|
"referenceKindsNote": "def is deliberately absent, for the reason Swift's rules record for func and the node rules record with measurements: a bare lowercase name matched across files is almost never a call to that exact declaration, and Python's convention of short module-level helpers (main, run, load, parse, setup) makes it worse than most. Functions still reach the graph through their defines edge, so they stay findable by name.",
|
|
34
35
|
"ignoredIdentifiers": [
|
|
35
36
|
"self",
|
|
@@ -16,11 +16,18 @@
|
|
|
16
16
|
* ui_critical - *View.swift / *Screen.kt / Configuration w=1.5
|
|
17
17
|
* migration - DB schema / migration path w=4.0
|
|
18
18
|
* test_lines_removed - test file shrinks (removed > added) w=3.0
|
|
19
|
+
* dependents_reached - distinct non-test files that depend on the w=2.0
|
|
20
|
+
* changed declarations, two rings out, from
|
|
21
|
+
* the code graph; only with --graph, capped
|
|
22
|
+
* at 20 so one hub cannot outweigh a
|
|
23
|
+
* security path
|
|
19
24
|
*
|
|
20
25
|
* Inputs:
|
|
21
26
|
* --base <ref> Base ref. Default: origin/main, fallback: main
|
|
22
27
|
* --head <ref> Head ref. Default: HEAD
|
|
23
28
|
* --diff <path> Pre-computed diff file (skip git invocation)
|
|
29
|
+
* --graph <path> Code graph (graph-build.mjs). Adds dependents_reached;
|
|
30
|
+
* an unreadable graph is reported on stderr and skipped
|
|
24
31
|
* --root <path> Repo root for git -C. Default: cwd
|
|
25
32
|
* --task-id <id> Tag the output with a task id (no functional effect)
|
|
26
33
|
* --top <N> Truncate `files` to top N. Default: keep all,
|
|
@@ -40,7 +47,9 @@
|
|
|
40
47
|
|
|
41
48
|
import { readFileSync, existsSync } from "node:fs";
|
|
42
49
|
import { execFileSync } from "node:child_process";
|
|
43
|
-
import { unquoteGitPath
|
|
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 = {
|
|
@@ -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
|
+
}
|