@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.
- package/CHANGELOG.md +29 -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 +52 -49
- 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/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 +5 -0
- 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 +26 -4
- package/pipeline/scripts/_code-graph.mjs +121 -12
- package/pipeline/scripts/_diff-impact.mjs +12 -5
- package/pipeline/scripts/_supertypes.mjs +133 -0
- package/pipeline/scripts/analysis-story-body.mjs +24 -8
- 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 +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/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
|
@@ -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.
|
|
74
|
-
|
|
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
|
|
141
|
-
credential-helper token
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
the
|
|
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`
|
|
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`
|
|
280
|
-
|
|
281
|
-
|
|
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
|
|
171
|
+
#### Step 2.6 - Code Graph Injection (advisory)
|
|
172
172
|
|
|
173
|
-
Gated by `prefs.global.codeGraph.enabled` (default: `
|
|
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
|
|
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.
|
|
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":
|
|
1528
|
-
"description": "Master switch. When false, Phase 1 Step 2.6 and the Phase 5 refresh are skipped
|
|
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.
|
|
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,
|
|
269
|
-
*
|
|
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
|
-
|
|
290
|
-
leading(above)
|
|
291
|
-
STORED_PROPERTY_RE.test(above)
|
|
292
|
-
)
|
|
293
|
-
|
|
294
|
-
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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
|
}
|
|
@@ -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.
|
|
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
|
+
}
|