@mmerterden/multi-agent-pipeline 20.22.0 → 20.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/docs/adr/0010-own-code-graph.md +3 -2
  3. package/docs/facts.json +3 -3
  4. package/docs/features.md +2 -2
  5. package/install/_usage-lifecycle.mjs +44 -0
  6. package/install/catalog-history.json +1 -1
  7. package/install/index.mjs +2 -0
  8. package/manifest.json +52 -49
  9. package/package.json +1 -1
  10. package/pipeline/commands/multi-agent/create-jira/SKILL.md +1 -1
  11. package/pipeline/commands/multi-agent/setup/SKILL.md +3 -3
  12. package/pipeline/commands/multi-agent/update/SKILL.md +3 -3
  13. package/pipeline/lib/json-file-lock.mjs +24 -2
  14. package/pipeline/multi-agent-refs/analysis/evidence.md +1 -1
  15. package/pipeline/multi-agent-refs/cross-cli-contract.md +1 -0
  16. package/pipeline/multi-agent-refs/features/analysis-jira.md +5 -0
  17. package/pipeline/multi-agent-refs/features/code-graph.md +40 -3
  18. package/pipeline/multi-agent-refs/features/usage-reporting.md +40 -23
  19. package/pipeline/multi-agent-refs/generate-issue.md +11 -1
  20. package/pipeline/multi-agent-refs/knowledge.md +18 -0
  21. package/pipeline/multi-agent-refs/phases/phase-1-plan.md +2 -2
  22. package/pipeline/schemas/code-graph.schema.json +3 -3
  23. package/pipeline/schemas/prefs.schema.json +26 -4
  24. package/pipeline/scripts/_code-graph.mjs +121 -12
  25. package/pipeline/scripts/_diff-impact.mjs +12 -5
  26. package/pipeline/scripts/_supertypes.mjs +133 -0
  27. package/pipeline/scripts/analysis-story-body.mjs +24 -8
  28. package/pipeline/scripts/capture-flush.sh +4 -1
  29. package/pipeline/scripts/code-graph-rules/android.json +1 -0
  30. package/pipeline/scripts/code-graph-rules/ios.json +5 -0
  31. package/pipeline/scripts/code-graph-rules/node.json +1 -0
  32. package/pipeline/scripts/code-graph-rules/python.json +1 -0
  33. package/pipeline/scripts/git-path.mjs +21 -0
  34. package/pipeline/scripts/graph-affected.mjs +2 -1
  35. package/pipeline/scripts/graph-confirm.mjs +140 -0
  36. package/pipeline/scripts/learnings-ledger.mjs +3 -2
  37. package/pipeline/scripts/product-context.mjs +30 -19
  38. package/pipeline/scripts/symbol-existence-gate.mjs +21 -9
  39. package/pipeline/scripts/test-gap-scan.mjs +4 -1
  40. package/pipeline/scripts/test-strength.mjs +7 -8
  41. package/pipeline/scripts/usage-identity.mjs +1 -1
  42. package/pipeline/scripts/usage-register.mjs +181 -15
  43. package/pipeline/scripts/validate-code-graph.mjs +3 -3
  44. package/pipeline/scripts/valven-gate.mjs +9 -1
  45. package/pipeline/scripts/valven-port.mjs +16 -5
  46. package/pipeline/scripts/valven-split.mjs +6 -4
  47. package/pipeline/skills/.skill-manifest.json +3 -3
  48. package/pipeline/skills/shared/core/multi-agent-setup/SKILL.md +3 -3
  49. package/pipeline/skills/shared/core/multi-agent-update/SKILL.md +3 -3
@@ -70,8 +70,10 @@ registered after the stamp and `update` from an unknown version on one that
70
70
  registered before it), `v`, `vf`, `hs` (the host trees at that version) and `t`
71
71
  (the stamp time), and moves `reportedVersion` forward once the server accepted
72
72
  it. The id is derived from the version and the stamp time, so a resend replaces
73
- the record. It runs after a successful registration, after setup or update finds
74
- a token already onboarded, and after every accepted run event. The panel lists
73
+ the record. The installer starts it, detached, right after it writes the stamps
74
+ (`MULTI_AGENT_SKIP_USAGE_REPORT=1` turns that off); it also runs after a
75
+ successful registration, after setup or update finds a token already onboarded,
76
+ and after every accepted run event. The panel lists
75
77
  the records under "Kurulumlar" and keeps them out of the run counts.
76
78
 
77
79
  The panel derives a run's status from the last event it received. A run still
@@ -137,23 +139,33 @@ undo it (`decideRegistration`). So the personal account has to be reachable only
137
139
  once, at the moment of registration.
138
140
 
139
141
  `usage-register.mjs --json` reports `needsPersonalAccount: true` only when the
140
- machine is not pinned and no account was found at all: no gh account, no
141
- credential-helper token and no SSH key with access. That is the last resort.
142
- Setup and update then ask once (AskUserQuestion per
143
- `$HOME/.claude/multi-agent-refs/picker-contract.md`; label
144
- `Add personal account (Recommended)` / `Skip`) and offer either fix:
145
-
146
- - **gh**: note the active account (`gh api user --jq .login`), have the person
147
- run the browser login for the account that holds access in their own terminal
148
- (in Claude Code: `! gh auth login --hostname github.com --web`; gh keeps both
149
- accounts), then switch the work account back so pull requests keep their
150
- author: `gh auth switch --hostname github.com --user <the noted account>`.
151
- - **SSH key**: add this machine's public key (for example `~/.ssh/id_ed25519.pub`)
152
- under the personal account's GitHub Settings > SSH and GPG keys. Nothing else
153
- changes on the machine.
154
-
155
- Then `node "$HOME/.claude/scripts/usage-register.mjs" --refresh --json` finds
156
- the account and pins it. Skip leaves reporting off; the next update asks again.
142
+ machine is not pinned, no account with access was found (no gh account, no
143
+ credential-helper token, no SSH key) and no name was declared. Setup and update
144
+ then ask once for the person's GitHub username (free text; Skip leaves
145
+ reporting off) and run:
146
+
147
+ ```bash
148
+ node "$HOME/.claude/scripts/usage-register.mjs" --login "$GITHUB_LOGIN" --json
149
+ ```
150
+
151
+ `$GITHUB_LOGIN` is the name the person typed. `--login` keeps it as
152
+ `usageLog.declaredLogin` and prefers it. When this
153
+ machine can prove that account (gh, credential helper or SSH key), the proof is
154
+ used as below. When it cannot, which is the usual case on a machine signed in
155
+ with a work account only, the name is registered as declared: the server issues
156
+ a token only for a collaborator (the repository owner registers with a proof),
157
+ and marks its row `claimed:<machine>`. Later runs (the Phase 0 backstop) register a declared name
158
+ the same way, without asking again. A name that is not a collaborator answers
159
+ `not_collaborator` (the owner's name, `proof_required`) and is forgotten, so the
160
+ next setup or update asks again once the repository owner has added it.
161
+
162
+ A declared name never replaces a proof: it is used only when the machine proves
163
+ no account, and never over a stored token that was issued for a proven login
164
+ (`usageLog.registeredClaim` marks a declared one, which a later proof replaces).
165
+ Declared registrations are run records only. The server refuses one for the
166
+ repository owner, rate-limits each login, caps declared tokens per login apart
167
+ from proven ones, marks their run records `claimed`, and does not accept them
168
+ for `/multi-agent:feedback`, which needs a proven registration.
157
169
 
158
170
  ## 4. The server side
159
171
 
@@ -174,6 +186,8 @@ The proof depends on how the account was found:
174
186
  with `{ "u", "c", "ssh": { "challenge", "signature" } }` and no
175
187
  Authorization header.
176
188
 
189
+ - **declared**: no proof. `{ "u", "c", "claim": true }`, no Authorization header.
190
+
177
191
  For a bearer, the server resolves the token to a GitHub login and refuses a
178
192
  mismatch. For an SSH proof, the challenge is stateless and HMAC-bound to the
179
193
  login, valid for five minutes; the server checks it, then accepts the signature
@@ -196,7 +210,8 @@ another host.
196
210
  | `401` `ssh_signature_invalid` | the signature did not verify |
197
211
  | `401` `ssh_key_not_on_account` | the key is not on that account; add its `.pub` to it |
198
212
  | `401` `ssh_key_unsupported` | the key type is not accepted; use an ed25519, rsa or ecdsa-p256 key |
199
- | `403` (no access) | the login needs collaborator access |
213
+ | `403` `not_collaborator` | the login is not a collaborator; the owner adds it (a declared name is forgotten and asked for again) |
214
+ | `403` `proof_required` | the declared name is the repository owner's, which registers only with a proof (gh or an SSH key); the name is forgotten |
200
215
  | challenge `404` / `405` | the endpoint does not accept an SSH key as proof yet |
201
216
  | signing failed | the key needs a passphrase and is not in ssh-agent: `ssh-add <key>` |
202
217
  | `accepted: false` | `declined` |
@@ -276,6 +291,8 @@ is off, it turns the switch on and says so rather than reporting "unchanged".
276
291
 
277
292
  ## 8. Where it is NOT called
278
293
 
279
- The installer. `install.js` lays down files and nothing else; seeding state is the
280
- one thing the install contract forbids, and a fresh machine has no preferences
281
- file for the registration to write into. Setup creates it; registration follows.
294
+ The installer. `install.js` never registers: seeding state is the one thing the
295
+ install contract forbids, and a fresh machine has no preferences file for the
296
+ registration to write into. Setup creates it; registration follows. The only
297
+ reporting the installer starts is `usage-report.mjs --lifecycle`, which sends the
298
+ install record with a token that already resolves and registers nothing.
@@ -58,6 +58,16 @@ Each type has a standard section skeleton. `A` = always present. `C` = condition
58
58
  | | Screenshots | C | user pasted image(s) or Figma screenshot fetched |
59
59
  | | Dependencies / Notes | C | real content only |
60
60
 
61
+ **Story panels.** Each Story section renders inside a coloured wiki panel, its `h3.` heading as the panel's first line, so testers see the story, the criteria and the rest at a glance:
62
+
63
+ | Story section | Panel |
64
+ |---|---|
65
+ | User Story | `{panel:borderColor=#d4d39e}` |
66
+ | Acceptance Criteria | `{panel:borderColor=#d4a19e}` |
67
+ | Every other section | `{panel:borderColor=#9eb6d4}` |
68
+
69
+ Each panel closes with `{panel}` on its own line. Task and Bug keep plain `h3.` sections.
70
+
61
71
  Section headings render in `outputLanguage` (e.g. tr: "Detaylı Açıklama", "Kapsam", "Kabul Kriterleri", "Test Senaryoları", "Adımlar", "Beklenen Sonuç", "Gerçekleşen Sonuç", "Ortam", "Ekran Görüntüleri", "API Contract", "Design Reference", "Notlar").
62
72
 
63
73
  ## Flow
@@ -172,7 +182,7 @@ curl -s "$SWAGGER_URL" -o /tmp/generate-issue-$$-swagger.json # add auth header
172
182
 
173
183
  Write summary + description in `outputLanguage` from the type's **standard template** (baseline, not mined headings):
174
184
  - **Summary**: `{minedPrefix} {concise title}`, truncated to 255 chars.
175
- - **Description**: render the type's standard section skeleton in Jira wiki markup (`h3.` headings), applying auto-sizing:
185
+ - **Description**: render the type's standard section skeleton in Jira wiki markup (`h3.` headings; for a Story each section inside its coloured panel, see Story panels), applying auto-sizing:
176
186
  - **Always-present** sections are filled from `FREE_TEXT` (+ mining for Test Scenarios style). If an always-present section has no user content and cannot be derived, it stays an explicit open question for step 8 - it is not fabricated.
177
187
  - **Conditional** sections render only when their trigger fired (see the Standard templates table): Design Reference when `FIGMA_URL` set (`[Figma|{FIGMA_URL}]` + frame name + node id); API Contract when 6b produced a body; Screenshots when images were staged; Notes/Dependencies only with real content. Otherwise the heading is omitted entirely.
178
188
  - Bug: logs / stack traces / device+OS mentions from `FREE_TEXT` map into Environment and Screenshots / Logs; anything underivable becomes a step-8 question.
@@ -50,6 +50,24 @@ Each task teaches the project a bit more. Token cost decreases over time.
50
50
 
51
51
  They all complement each other - no conflicts.
52
52
 
53
+ ### Which store a run fills without a model
54
+
55
+ The four `.md` files are the one layer only a model writes: Phase 5 Step 3
56
+ distils what the run learned into prose. Everything a run can record
57
+ mechanically has its own store and its own Phase 1 injection:
58
+
59
+ | Store | Filled by, when | Reaches Phase 1 through |
60
+ |---|---|---|
61
+ | `memory/multi-agent/<repo>/learnings-ledger.jsonl` | `learnings-ledger.mjs from-triage`, by `capture-flush.sh` at every phase boundary | `learnings-ledger.mjs profile` and `brief` (Step 1) |
62
+ | `memory/multi-agent/<repo>/triage-corpus.jsonl` | `triage-memory.mjs ingest`, by `capture-flush.sh` at every phase boundary | `triage-memory.mjs query` (prior findings) |
63
+ | `knowledge/<repo>/code-graph.json`, `GRAPH_REPORT.md` | `graph-build.mjs`, `graph-report.mjs`, in Phase 1 and after Phase 5 | `graph-query.mjs` / `graph-affected.mjs`, when `codeGraph.enabled` |
64
+ | `knowledge/<repo>/product-context.jsonl` | `product-context.mjs record`, Phase 1 Step 1.6 | `product-context.mjs recall` (Step 1.6) |
65
+ | repo profile | `repo-profile.mjs ensure`, Phase 1 Step 1 | `state.repoProfile` |
66
+
67
+ A run that stops before Phase 5 therefore still teaches the next one; only the
68
+ prose layer waits for a run that finishes. Generated copies of these stores in
69
+ the `.md` files would inject the same rows twice, so none are written.
70
+
53
71
  ### Knowledge Maintenance
54
72
 
55
73
  Knowledge files grow over time. Maintenance rules:
@@ -168,9 +168,9 @@ This informs:
168
168
 
169
169
  Gated by `prefs.global.repoMap.enabled` (default: `false`). When enabled, runs `$HOME/.claude/scripts/repo-map.mjs` and injects the budgeted result into each Explore prompt as `${REPO_MAP}`. Aider-style: deterministic, no embeddings, sub-second, advisory only. Full wiring (helper invocation, properties, when-to-enable): `$HOME/.claude/multi-agent-refs/features/repo-map.md`.
170
170
 
171
- #### Step 2.6 - Code Graph Injection (advisory, opt-in)
171
+ #### Step 2.6 - Code Graph Injection (advisory)
172
172
 
173
- Gated by `prefs.global.codeGraph.enabled` (default: `false`). With a rule file for `detectedStack`, Phase 1 refreshes the graph and queries it; `graph-affected` feeds `analysis.touchedAreas[]`. Zero API cost, read-only. Commands and measurements: `$HOME/.claude/multi-agent-refs/features/code-graph.md`.
173
+ Gated by `prefs.global.codeGraph.enabled` (default: `true`). With a rule file for `detectedStack`, Phase 1 refreshes the graph and queries it; `graph-affected` feeds `analysis.touchedAreas[]`. Zero API cost, read-only. Commands and measurements: `$HOME/.claude/multi-agent-refs/features/code-graph.md`.
174
174
 
175
175
  **A valid result REPLACES the opening sweep** rather than sitting beside it: Explore starts from those files and walks outward, with no broad `Glob`/`Grep` pass first - running both pays twice, and the second re-derives what the graph said. No graph (missing rule, stale, or disabled) leaves the previous behaviour untouched; a task naming a symbol or path is still grepped directly.
176
176
 
@@ -8,8 +8,8 @@
8
8
  "additionalProperties": false,
9
9
  "properties": {
10
10
  "schemaVersion": {
11
- "enum": ["1.0.0", "1.1.0"],
12
- "description": "1.1.0 adds endLine on symbol nodes. A 1.0.0 graph stays readable; diff mapping falls back to file level for it."
11
+ "enum": ["1.0.0", "1.1.0", "1.2.0"],
12
+ "description": "1.1.0 adds endLine on symbol nodes; 1.2.0 adds inherits edges (subtype to supertype). Older graphs stay readable; diff mapping falls back to file level for a 1.0.0 graph."
13
13
  },
14
14
  "stack": {
15
15
  "type": "string",
@@ -81,7 +81,7 @@
81
81
  "properties": {
82
82
  "from": { "type": "string" },
83
83
  "to": { "type": "string" },
84
- "kind": { "type": "string", "enum": ["defines", "imports", "references"] }
84
+ "kind": { "type": "string", "enum": ["defines", "imports", "references", "inherits"] }
85
85
  }
86
86
  }
87
87
  },
@@ -1273,9 +1273,14 @@
1273
1273
  "type": "string",
1274
1274
  "description": "Shared ingest token the receiving endpoint validates. May be left empty here and supplied via env MULTI_AGENT_USAGE_TOKEN instead. Local prefs only, never synced."
1275
1275
  },
1276
+ "declaredLogin": {
1277
+ "type": "string",
1278
+ "pattern": "^[A-Za-z0-9][A-Za-z0-9_-]{0,38}$",
1279
+ "description": "The GitHub username the person typed at setup or update (usage-register.mjs --login). Registered as declared when this machine cannot prove that account; the server issues a token only when it is a collaborator (the repository owner registers with a proof). Forgotten when the server refuses it. Local prefs only, never synced."
1280
+ },
1276
1281
  "reportAs": {
1277
1282
  "type": "string",
1278
- "description": "Preferred GitHub login when more than one account is logged in to gh. It is checked first, and used only if that account can read accessRepo; otherwise the next logged-in account with access is used. Local prefs only, never synced."
1283
+ "description": "Preferred GitHub login when more than one account is logged in to gh. usage-register.mjs --login sets it to the declared name, and removes it with that name when the server refuses the name. It is checked first, and used only if that account can read accessRepo; otherwise the next logged-in account with access is used. Local prefs only, never synced."
1279
1284
  },
1280
1285
  "accessRepo": {
1281
1286
  "type": "string",
@@ -1320,6 +1325,23 @@
1320
1325
  "type": "string",
1321
1326
  "description": "ISO time of the registration that issued the stored token."
1322
1327
  },
1328
+ "claimNonce": {
1329
+ "type": "string",
1330
+ "pattern": "^[0-9a-f]{8,32}$",
1331
+ "description": "Random suffix of the machine label a declared registration is sent with, so nobody else can name the label and replace this machine's token. Written once by usage-register.mjs."
1332
+ },
1333
+ "claimFailedAt": {
1334
+ "type": "string",
1335
+ "description": "ISO time a declared registration last failed for a reason other than a refusal of the name (rate limit, cap, endpoint down). The claim is not sent again for a day unless the name is typed again."
1336
+ },
1337
+ "reportAsBefore": {
1338
+ "type": "string",
1339
+ "description": "The reportAs a declared name replaced, put back when that name is refused."
1340
+ },
1341
+ "registeredClaim": {
1342
+ "type": "boolean",
1343
+ "description": "true when the stored token was issued for a declared login rather than a proven one. A proof found later, for any login with access, replaces it; a declared name never replaces a proven registration."
1344
+ },
1323
1345
  "registeredFallback": {
1324
1346
  "type": "boolean",
1325
1347
  "description": "Deprecated (20.17.0). true marked a registration made with an account that cannot read accessRepo; it is not treated as pinned and is replaced once an account with access is found."
@@ -1520,12 +1542,12 @@
1520
1542
  "codeGraph": {
1521
1543
  "type": "object",
1522
1544
  "additionalProperties": false,
1523
- "description": "v16.1+ - deterministic, LLM-free code graph (symbols, imports, references) built by pipeline/scripts/graph-build.mjs into ~/.claude/knowledge/<project>/code-graph.json. Phase 1 Step 2.6 queries it to narrow the Explore starting set; Phase 5 Step 3 refreshes it after the branch changed code. Zero API cost, zero runtime dependencies (ADR-0004), read-only on the repo. Off by default: with it off the pipeline behaves exactly as it did before. Measured on a 4,300-file Swift app at a fixed 30k retrieval budget, it roughly doubled coverage at under half the token cost on domain-word searches, and was marginally worse than plain grep when the task already names an exact symbol.",
1545
+ "description": "v16.1+ - deterministic, LLM-free code graph (symbols, imports, references) built by pipeline/scripts/graph-build.mjs into ~/.claude/knowledge/<project>/code-graph.json. Phase 1 Step 2.6 queries it to narrow the Explore starting set; Phase 5 Step 3 refreshes it after the branch changed code. Zero API cost, zero runtime dependencies (ADR-0004), read-only on the repo. On by default; with it off no graph is built or refreshed. Measured on a 4,300-file Swift app at a fixed 30k retrieval budget, it roughly doubled coverage at under half the token cost on domain-word searches, and was marginally worse than plain grep when the task already names an exact symbol.",
1524
1546
  "properties": {
1525
1547
  "enabled": {
1526
1548
  "type": "boolean",
1527
- "default": false,
1528
- "description": "Master switch. When false, Phase 1 Step 2.6 and the Phase 5 refresh are skipped entirely and no graph file is written."
1549
+ "default": true,
1550
+ "description": "Master switch, on by default. When false, Phase 1 Step 2.6 and the Phase 5 refresh are skipped and no graph is built or refreshed; a graph file already on disk is still read by Phase 3 and the analysis ranking."
1529
1551
  },
1530
1552
  "autoRefresh": {
1531
1553
  "type": "boolean",
@@ -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.1.0";
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
 
@@ -263,10 +271,57 @@ const OPENS_BLOCK_RE = /(?:[{:=]|=>|->)[ \t]*$/;
263
271
  const DIRECTIVE_RE = /^#(?:if|elseif|else|endif)\b/;
264
272
  const STORED_PROPERTY_RE = /\b(?:var|let|val)\b/;
265
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
+
266
320
  /**
267
321
  * Give every definition a span: `startLine` (the declaration line, or the first
268
- * of the attribute or decorator lines directly above it, never a stored
269
- * property) and `endLine` (the last line indented deeper than the declaration,
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,
270
325
  * plus a closing bracket at the declaration's own indentation when one
271
326
  * follows). A closing bracket that opens the body again - the end of a
272
327
  * multi-line parameter list, `) -> Int {`, `):`, `) : Base() {` - continues the
@@ -286,12 +341,16 @@ export function assignSpans(definitions, lines) {
286
341
  while (start > 1) {
287
342
  const above = lines[start - 2];
288
343
  if (
289
- !ATTRIBUTE_RE.test(above) ||
290
- leading(above) !== d.indent ||
291
- STORED_PROPERTY_RE.test(above)
292
- )
293
- break;
294
- start--;
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;
295
354
  }
296
355
  d.startLine = start;
297
356
  }
@@ -366,20 +425,34 @@ export function extractFile(text, rules) {
366
425
  const key = `${name}:${pattern.kind || pattern.id}`;
367
426
  if (seen.has(key)) continue;
368
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) : [];
369
432
  definitions.push({
370
433
  name,
371
- kind: pattern.kind || pattern.id,
434
+ kind,
372
435
  patternId: pattern.id,
373
436
  line: lineOf(body, m.index),
374
437
  indent: indentOf(body, m.index),
375
438
  nested: indentOf(body, m.index) > 0,
439
+ ...(supertypes.length ? { supertypes } : {}),
376
440
  });
377
441
  }
378
442
  }
379
443
 
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
+
380
453
  assignSpans(definitions, body.split("\n"));
381
454
  for (const d of definitions) delete d.indent;
382
- return { definitions, imports, tokens: identifiers(body) };
455
+ return { definitions, imports, tokens: identifiers(body), conformances };
383
456
  }
384
457
 
385
458
  /**
@@ -399,6 +472,7 @@ export function listSourceFiles(root, rules) {
399
472
  const out = execFileSync("git", ["-C", root, "ls-files", "-z", "-co", "--exclude-standard"], {
400
473
  encoding: "utf8",
401
474
  maxBuffer: 64 * 1024 * 1024,
475
+ stdio: ["ignore", "pipe", "ignore"],
402
476
  });
403
477
  candidates = out.split("\0").filter(Boolean);
404
478
  } catch {
@@ -442,7 +516,8 @@ function walkDir(dir, root, acc = []) {
442
516
  */
443
517
  export function isTestPath(path, rules) {
444
518
  if (rules.testPathSuffixes.some((s) => path.endsWith(s))) return true;
445
- return rules.testPathContains.some((c) => path.includes(c));
519
+ const rooted = `/${path}`;
520
+ return rules.testPathContains.some((c) => rooted.includes(c));
446
521
  }
447
522
 
448
523
  export const fileNodeId = (path) => `file:${path}`;
@@ -546,6 +621,10 @@ export function buildGraph({
546
621
  }
547
622
  }
548
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.
549
628
  for (const [path, data] of extracted) {
550
629
  const fid = fileNodeId(path);
551
630
  const emitted = new Set();
@@ -556,6 +635,7 @@ export function buildGraph({
556
635
  const sid = candidates[0];
557
636
  const target = nodes.get(sid);
558
637
  if (target.path === path) continue;
638
+ if (target.isTest && !nodes.get(fid).isTest) continue;
559
639
  if (!rules.referenceKinds.has(target.symbolKind)) continue;
560
640
  if (emitted.has(sid)) continue;
561
641
  emitted.add(sid);
@@ -563,6 +643,35 @@ export function buildGraph({
563
643
  }
564
644
  }
565
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
+
566
675
  for (const edge of edges) {
567
676
  if (nodes.has(edge.from)) nodes.get(edge.from).degree++;
568
677
  if (nodes.has(edge.to)) nodes.get(edge.to).degree++;
@@ -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 { unquotePathIfNeeded } from "./git-path.mjs";
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 text = raw.startsWith('"') ? raw : raw.replace(/\t.*$/, "");
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
  }
@@ -268,16 +267,24 @@ export function graphFreshness(graph, { root = graph.root } = {}) {
268
267
  }
269
268
 
270
269
  /**
271
- * Which side of a diff the graph's line numbers describe. A graph that matches
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
272
273
  * the tree at `root` (same HEAD, no file edited since the build) describes the
273
274
  * new side; any other graph was built before the change - on the base checkout,
274
275
  * or before the edit - and its spans are old-side line numbers.
275
276
  *
276
277
  * @param {object} graph
277
- * @param {{root?: string}} [opts]
278
+ * @param {{root?: string, range?: string}} [opts]
278
279
  * @returns {"new"|"old"}
279
280
  */
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
+ }
281
288
  return graphFreshness(graph, opts).stale ? "old" : "new";
282
289
  }
283
290
 
@@ -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
+ }