mandrel 2.47.0 → 2.49.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 (32) hide show
  1. package/.agents/agents/story-worker.md +49 -49
  2. package/.agents/docs/configuration.md +1 -0
  3. package/.agents/docs/quality-gates.md +48 -0
  4. package/.agents/scripts/lib/baselines/kernel.js +19 -0
  5. package/.agents/scripts/lib/baselines/kinds/bundle-size.js +12 -0
  6. package/.agents/scripts/lib/baselines/kinds/coverage.js +1 -0
  7. package/.agents/scripts/lib/baselines/kinds/crap.js +21 -5
  8. package/.agents/scripts/lib/baselines/kinds/duplication.js +1 -0
  9. package/.agents/scripts/lib/baselines/kinds/kind-factory.js +26 -1
  10. package/.agents/scripts/lib/baselines/kinds/lighthouse.js +1 -0
  11. package/.agents/scripts/lib/baselines/kinds/lint.js +12 -0
  12. package/.agents/scripts/lib/baselines/kinds/maintainability.js +1 -0
  13. package/.agents/scripts/lib/baselines/kinds/mutation.js +1 -0
  14. package/.agents/scripts/lib/baselines/merge-envelopes.js +272 -0
  15. package/.agents/scripts/lib/bootstrap/baseline-merge-driver.js +175 -0
  16. package/.agents/scripts/lib/bootstrap/quality-bootstrap.js +8 -2
  17. package/.agents/scripts/lib/observability/source-classifier.js +1 -0
  18. package/.agents/scripts/lib/orchestration/epic-container.js +48 -21
  19. package/.agents/scripts/lib/orchestration/epic-expansion.js +28 -6
  20. package/.agents/scripts/lib/orchestration/epic-rollup.js +66 -7
  21. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +32 -61
  22. package/.agents/scripts/lib/orchestration/single-story-close/phases/pre-gate-steps.js +171 -0
  23. package/.agents/scripts/lib/orchestration/story-close/baseline-upward-writeback.js +483 -0
  24. package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +6 -1
  25. package/.agents/scripts/merge-baseline.js +238 -0
  26. package/.agents/scripts/providers/github/errors.js +66 -10
  27. package/.agents/scripts/providers/github/sub-issues.js +8 -1
  28. package/.agents/workflows/helpers/deliver-digest.md +30 -26
  29. package/.agents/workflows/helpers/parallel-tooling.md +17 -0
  30. package/docs/CHANGELOG.md +25 -0
  31. package/lib/cli/registry.js +63 -0
  32. package/package.json +1 -1
@@ -48,53 +48,47 @@ the step-by-step. This shared core binds every role:
48
48
  You are a **Story delivery worker**: you take one Story from init through
49
49
  implementation to a **pushed branch**, then return. You do **not** close it —
50
50
  your caller owns the close-and-land tail. Follow the `helpers/deliver-story`
51
- workflow prose your caller hands you; this delta states the non-negotiable
51
+ prose your caller hands you; this delta states the non-negotiable
52
52
  MUSTs. Treat a blocking tool-permission prompt as a harness condition —
53
- transition to `agent::blocked` rather than waiting on an approval that
54
- cannot come.
53
+ flip to `agent::blocked` rather than waiting on an approval that cannot
54
+ come.
55
55
 
56
56
  ## Worktree discipline (MUST)
57
57
 
58
58
  1. Initialize with
59
59
  `node .agents/scripts/single-story-init.js --story <storyId>` from the
60
- **main checkout**, synchronously with the Bash maximum timeout — a
61
- per-worktree install can take minutes; do not background it.
62
- 2. Capture `workCwd` and `dependenciesInstalled` from the init envelope.
60
+ **main checkout**, synchronously at max Bash timeout — a per-worktree
61
+ install can take minutes; do not background it.
62
+ 2. Capture `workCwd` and `dependenciesInstalled` from the envelope.
63
63
  Work only inside the absolute `workCwd`; never move the main checkout's
64
- HEAD. Because cwd may reset between calls, anchor every path at `workCwd`.
64
+ HEAD. cwd may reset between calls, so anchor every path at `workCwd`.
65
65
 
66
66
  ## Verify branch before every commit (MUST)
67
67
 
68
- Before staging or committing anything:
69
-
70
- ```bash
71
- git -C "<workCwd>" branch --show-current # MUST print story-<storyId>
72
- ```
73
-
74
- If it does not, **STOP** — never commit Story work to `main` or outside the
75
- worktree/branch. Re-run `single-story-init.js` (idempotent on partial
76
- state) to restore the branch first.
68
+ Before staging or committing, `git -C "<workCwd>" branch --show-current`
69
+ MUST print `story-<storyId>`. If it does not, **STOP** — never commit Story
70
+ work to `main` or outside the worktree/branch. Re-run
71
+ `single-story-init.js` (idempotent) to restore it.
77
72
 
78
73
  ## Commit discipline
79
74
 
80
- Author Conventional Commit subjects directly on `story-<storyId>` per
75
+ Author Conventional Commit subjects on `story-<storyId>` per
81
76
  [`git-conventions.md`](../rules/git-conventions.md): imperative mood,
82
- ≤100 chars, referencing the Story via `(refs #<storyId>)`. Never bypass the
83
- `commit-msg` hook with `--no-verify` / `--no-gpg-sign`. If a hook fails, fix
84
- the cause and add a follow-up commit; never amend the rejected one.
77
+ ≤100 chars, `(refs #<storyId>)`. Never bypass the `commit-msg` hook
78
+ (`--no-verify` / `--no-gpg-sign`); if one fails, fix the cause and add a
79
+ follow-up commit, never amend.
85
80
 
86
81
  ## Docs context — digest first
87
82
 
88
83
  Do **not** re-read every file in `project.docsContextFiles`. Read the
89
- `docsDigestPath` digest your caller passes, then pull full files on demand
90
- at the line numbers it names. A null `docsDigestPath` means no docs
91
- mandate — read a full doc only when the Story's context points at one.
84
+ `docsDigestPath` digest your caller passes, then pull files on demand at
85
+ the lines it names. A null `docsDigestPath` means no mandate.
92
86
 
93
- ## Close gates — one credited run, no ad-hoc stamping
87
+ ## Close gates — one credited run
94
88
 
95
89
  `single-story-close.js` runs the canonical close-validation chain
96
90
  (**typecheck, lint, test, format, maintainability, coverage, crap**) and is
97
- the authoritative gate — do not pre-run the chain. The **one** exception is
91
+ the authoritative gate — do not pre-run it. The **one** exception is
98
92
  the full suite: run it exactly once, after the self-eval loop's last fix
99
93
  commit and immediately before the push, in the shape close credits. A bare
100
94
  `npm test` / `pnpm run test` deposits **no** credit:
@@ -107,12 +101,20 @@ node <main-repo>/.agents/scripts/evidence-gate.js --standalone \
107
101
  --scope-id <storyId> --gate test --worktree <workCwd> -- npm test
108
102
  ```
109
103
 
110
- Sharing `lint` / `typecheck` evidence with close via `evidence-gate.js` is
111
- fine; never stamp coverage / CRAP fresh any other way.
104
+ Dispatch it in the **background**: it routinely outruns the host's
105
+ synchronous Bash ceiling, and its completion re-invokes you — that
106
+ notification is the signal. Never spawn a task to poll or `sleep`-loop
107
+ against it; a waiter whose condition is wrong outlives the agent. Share
108
+ `lint` / `typecheck` evidence with close via `evidence-gate.js`; never
109
+ stamp coverage / CRAP fresh any other way.
110
+
111
+ **It can legitimately run nothing.** With nothing changed under the CRAP
112
+ `targetDirs` it skips capture and exits 0. An exit code is never evidence a
113
+ gate did work — its **output** is: no credit was deposited, so run the full
114
+ suite yourself before handing off.
112
115
 
113
- Before trusting a gate's output — or diagnosing a red one — read
114
- [`known-tooling-behavior.md`](../rules/known-tooling-behavior.md): measured
115
- cases where a command prints what it does not mean.
116
+ Gate output that lies: [`known-tooling-behavior.md`](../rules/known-tooling-behavior.md).
117
+ Waiter traps: [`parallel-tooling.md`](../workflows/helpers/parallel-tooling.md) Rule 2.
116
118
 
117
119
  ## Acceptance self-eval before close (MUST)
118
120
 
@@ -120,27 +122,27 @@ After the implementation commits land and **before** flipping to `closing`,
120
122
  run the bounded acceptance self-eval loop
121
123
  ([`acceptance-self-eval.md`](../workflows/helpers/acceptance-self-eval.md)).
122
124
  It scores the change set you computed **once** and injected into the critic
123
- — never one the critic re-derives (Story #4593) — against each
124
- `acceptance[]` item, consuming `verify[]` output as required evidence. Gate
125
- outcomes: **proceed** → flip to `closing`, push, hand off; **redraft** → fix
126
- the flagged criteria, commit, re-eval; **block** → take the blocked path
127
- below. Never silently hand off an unscored branch.
125
+ — never one it re-derives — against each `acceptance[]` item,
126
+ consuming `verify[]` output as evidence. **proceed** → flip to `closing`,
127
+ push, hand off; **redraft** → fix the flagged criteria, commit, re-eval;
128
+ **block** → take the blocked path below. Never hand off an unscored
129
+ branch.
128
130
 
129
131
  ## Lifecycle: progress & blocked (MUST)
130
132
 
131
- - **Progress.** Relay one terse line per phase transition (e.g.
133
+ - **Progress.** One terse line per phase transition (e.g.
132
134
  `Story #<id>: implementing → closing`).
133
- - **Blocked.** When you genuinely cannot proceed, transition the Story to
135
+ - **Blocked.** When you cannot proceed, transition the Story to
134
136
  `agent::blocked`, post a `friction` comment naming the decision needed
135
137
  (or the unmet criteria and their evidence), and **exit non-zero**.
136
- **Never fall silent** — a stalled child without an `agent::blocked` label
137
- and no commit is indistinguishable from a dead one.
138
+ **Never fall silent** — a stalled child with no label and no commit is
139
+ indistinguishable from a dead one.
138
140
 
139
- ## Land or block — the only sanctioned landing (#4483, MUST)
141
+ ## Land or block — the only sanctioned landing (MUST)
140
142
 
141
- The Story's init envelope carries `remoteVerified` + `remoteProbe`. When
142
- `remoteVerified` is `false`, transition the Story to `agent::blocked`
143
- quoting `remoteProbe.detail` and stop. A PR opened by
143
+ The init envelope carries `remoteVerified` + `remoteProbe`. When
144
+ `remoteVerified` is `false`, flip to `agent::blocked` quoting
145
+ `remoteProbe.detail` and stop. A PR opened by
144
146
  `single-story-close.js` is the only sanctioned landing.
145
147
 
146
148
  ## Your turn ends at a pushed branch (MUST)
@@ -148,14 +150,12 @@ quoting `remoteProbe.detail` and stop. A PR opened by
148
150
  You do **not** run close. Push `story-<storyId>` to `origin` — confirming
149
151
  the remote ref moved — and return. The dispatching orchestrator runs
150
152
  `single-story-close.js` in its own session, serialized against your
151
- siblings. Do not open the PR, do not flip `agent::done`, and do not spawn
152
- a child to close on your behalf. If the push itself fails, take the blocked
153
- path above rather than returning a hand-off you cannot back.
153
+ siblings. Do not open the PR, flip `agent::done`, or spawn a child to close
154
+ on your behalf. If the push fails, take the blocked path above.
154
155
 
155
156
  ## Return contract — the hand-off report
156
157
 
157
158
  A short, literal hand-off your caller can act on: Story id, `workCwd`,
158
159
  branch, pushed head SHA, self-eval verdict, `verify[]` evidence. Say plainly
159
- that the branch is pushed and unclosed. Never hand-compose a terminal
160
- envelope — that document belongs to close, and inventing one makes an
161
- unlanded Story look landed.
160
+ the branch is pushed and unclosed. Never hand-compose a terminal envelope —
161
+ inventing one makes an unlanded Story look landed.
@@ -690,6 +690,7 @@ Claude Code web environment-variables UI for web sessions.
690
690
  | `WEBHOOK_SECRET` | No | Shared secret used to sign outbound webhook payloads as `X-Signature-256: sha256=<hmac>`. Unset ships unsigned payloads. |
691
691
  | `MANDREL_ALLOW_TEST_WEBHOOKS` | No | Set to `1` to keep `NOTIFICATION_WEBHOOK_URL` live inside `npm test` / `npm run test:profile`. Default behaviour scrubs the env var from the test child so no URL resolves and the webhook never fires (see below). |
692
692
  | `MANDREL_POOL_CONCURRENCY` | No | Upper bound on the width of every `runOnPool` worker pool in the process (the MI and CRAP scan pools). Precedence is: a caller's explicit `concurrency` → this variable → a clamp of 4 under `node:test` → `os.availableParallelism()`. Set it on a constrained or shared runner where one pool per core oversubscribes the host; a non-numeric value is ignored rather than collapsing the pool. |
693
+ | `MANDREL_BASELINE_GENERATED_AT` | No | Pins the `generatedAt` stamp every baseline envelope carries, instead of reading the clock. Set it for a reproducible build, or to make a hand-run refresh diff against a known stamp. It changes only the stamp — rows and rollup are unaffected, and a refresh that moves no row still rewrites nothing. Concurrent refreshes no longer need it to avoid conflicting: `baselines/*.json` merges by row identity (see the baseline merge driver in [quality-gates.md](quality-gates.md)). |
693
694
  | `MANDREL_AGENTRC_VALIDATOR` | No | Set to `dynamic` to compile the `.agentrc.json` AJV validator at runtime instead of loading the committed precompiled one (see below). Costs ~35 ms per process; the escape hatch exists for a hand-edited schema or a host where the generated module will not load. |
694
695
 
695
696
  ### The `.agentrc` validator is precompiled
@@ -889,6 +889,54 @@ The schemas live under [`.agents/schemas/baselines/`](../schemas/baselines/).
889
889
  The shared AJV instance is built by `buildBaselineSchemaAjv()` in
890
890
  [`.agents/scripts/lib/baseline-schema-registry.js`](../scripts/lib/baseline-schema-registry.js).
891
891
 
892
+ ### Concurrent refreshes — the baseline merge driver
893
+
894
+ `generatedAt` sits on line 4 of every envelope, so two branches that each
895
+ refresh a baseline **always** differ there, even when they moved completely
896
+ disjoint rows. Git merges JSON as text, and whether it can separate that hunk
897
+ from the moved rows is an accident of proximity. Both outcomes are wrong:
898
+
899
+ - it cannot → a conflict on work that never overlapped (the `coverage.json` /
900
+ `maintainability.json` "always conflicts" pattern);
901
+ - it can → it splices both sides' row lines into a row set **neither side
902
+ scored** (the `crap.json` "silently auto-merges" pattern). The ratchet then
903
+ guards a number no scorer ever produced.
904
+
905
+ A baseline is a set of rows keyed by identity plus a rollup derived from them,
906
+ so [`merge-baseline.js`](../scripts/merge-baseline.js) merges it as that. Per
907
+ row identity the standard 3-way rule applies; only a genuine double move
908
+ conflicts, and then markers wrap that row alone. The rollup is always
909
+ **recomputed** from the merged rows — merging two rollups is the same splice
910
+ hazard compressed into one number — and `generatedAt` resolves to the later of
911
+ the two stamps rather than conflicting.
912
+
913
+ Row identity comes from the kind module's `rowIdentity(row)`, which is
914
+ deliberately not `keyField`: CRAP groups by file (`keyField: 'path'`) but
915
+ ships one row per method, so keying on `keyField` would drop every method in a
916
+ file but one. Any `baselines/*.json` whose `$schema` is not a known per-kind
917
+ envelope — `arch-cycles`, `cyclomatic`, `dead-exports`, `audit-ledger`,
918
+ `context-budget`, `workflow-citations` — is handed straight back to
919
+ `git merge-file`, so registering the driver cannot change their behaviour.
920
+
921
+ Registration has two halves:
922
+
923
+ ```bash
924
+ # 1. tracked, installed by `node .agents/scripts/apply-quality-bootstrap.js`
925
+ # → .gitattributes: baselines/*.json merge=mandrel-baseline
926
+ # 2. per clone — git will not run a command chosen by whoever wrote the repo
927
+ git config merge.mandrel-baseline.driver "node .agents/scripts/merge-baseline.js %O %A %B %P"
928
+ ```
929
+
930
+ Only the first ships with the repository, and a clone missing the second
931
+ degrades **silently** back to the text merge. `mandrel doctor`'s
932
+ `merge-driver` check is the guard: it prints the exact `git config` line
933
+ above, and passes as skipped when `.gitattributes` does not declare the
934
+ driver at all.
935
+
936
+ `MANDREL_BASELINE_GENERATED_AT` pins the stamp for a reproducible build (see
937
+ the environment table in [configuration.md](configuration.md)). It is no
938
+ longer needed to dodge merge conflicts.
939
+
892
940
  ### Per-kind shapes
893
941
 
894
942
  Each kind contributes a `rows[]` schema and a `rollup` axis set. The
@@ -35,6 +35,7 @@ import {
35
35
  name as bundleSizeName,
36
36
  projectRow as bundleSizeProjectRow,
37
37
  rollup as bundleSizeRollup,
38
+ rowIdentity as bundleSizeRowIdentity,
38
39
  sortRows as bundleSizeSortRows,
39
40
  } from './kinds/bundle-size.js';
40
41
  import {
@@ -46,6 +47,7 @@ import {
46
47
  name as coverageName,
47
48
  projectRow as coverageProjectRow,
48
49
  rollup as coverageRollup,
50
+ rowIdentity as coverageRowIdentity,
49
51
  sortRows as coverageSortRows,
50
52
  } from './kinds/coverage.js';
51
53
  import {
@@ -59,6 +61,7 @@ import {
59
61
  name as crapName,
60
62
  projectRow as crapProjectRow,
61
63
  rollup as crapRollup,
64
+ rowIdentity as crapRowIdentity,
62
65
  sortRows as crapSortRows,
63
66
  } from './kinds/crap.js';
64
67
  import {
@@ -70,6 +73,7 @@ import {
70
73
  name as duplicationName,
71
74
  projectRow as duplicationProjectRow,
72
75
  rollup as duplicationRollup,
76
+ rowIdentity as duplicationRowIdentity,
73
77
  sortRows as duplicationSortRows,
74
78
  } from './kinds/duplication.js';
75
79
  import {
@@ -81,6 +85,7 @@ import {
81
85
  name as lighthouseName,
82
86
  projectRow as lighthouseProjectRow,
83
87
  rollup as lighthouseRollup,
88
+ rowIdentity as lighthouseRowIdentity,
84
89
  sortRows as lighthouseSortRows,
85
90
  } from './kinds/lighthouse.js';
86
91
  import {
@@ -92,6 +97,7 @@ import {
92
97
  name as lintName,
93
98
  projectRow as lintProjectRow,
94
99
  rollup as lintRollup,
100
+ rowIdentity as lintRowIdentity,
95
101
  sortRows as lintSortRows,
96
102
  } from './kinds/lint.js';
97
103
  import {
@@ -103,6 +109,7 @@ import {
103
109
  name as maintainabilityName,
104
110
  projectRow as maintainabilityProjectRow,
105
111
  rollup as maintainabilityRollup,
112
+ rowIdentity as maintainabilityRowIdentity,
106
113
  sortRows as maintainabilitySortRows,
107
114
  } from './kinds/maintainability.js';
108
115
  import {
@@ -115,6 +122,7 @@ import {
115
122
  name as mutationName,
116
123
  projectRow as mutationProjectRow,
117
124
  rollup as mutationRollup,
125
+ rowIdentity as mutationRowIdentity,
118
126
  sortRows as mutationSortRows,
119
127
  } from './kinds/mutation.js';
120
128
 
@@ -135,6 +143,9 @@ function bindKindModule(members) {
135
143
  return Object.freeze({
136
144
  name: members.name,
137
145
  keyField: members.keyField,
146
+ // Story #5215: the merge identity, distinct from the `keyField`
147
+ // grouping key above — CRAP groups by file and identifies by method.
148
+ rowIdentity: members.rowIdentity,
138
149
  kernelVersion: members.kernelVersion,
139
150
  projectRow: members.projectRow,
140
151
  sortRows: members.sortRows,
@@ -159,6 +170,7 @@ const KIND_MODULES = Object.freeze({
159
170
  lint: bindKindModule({
160
171
  name: lintName,
161
172
  keyField: lintKeyField,
173
+ rowIdentity: lintRowIdentity,
162
174
  kernelVersion: lintKernelVersion,
163
175
  projectRow: lintProjectRow,
164
176
  sortRows: lintSortRows,
@@ -170,6 +182,7 @@ const KIND_MODULES = Object.freeze({
170
182
  coverage: bindKindModule({
171
183
  name: coverageName,
172
184
  keyField: coverageKeyField,
185
+ rowIdentity: coverageRowIdentity,
173
186
  kernelVersion: coverageKernelVersion,
174
187
  projectRow: coverageProjectRow,
175
188
  sortRows: coverageSortRows,
@@ -181,6 +194,7 @@ const KIND_MODULES = Object.freeze({
181
194
  crap: bindKindModule({
182
195
  name: crapName,
183
196
  keyField: crapKeyField,
197
+ rowIdentity: crapRowIdentity,
184
198
  kernelVersion: crapKernelVersion,
185
199
  projectRow: crapProjectRow,
186
200
  sortRows: crapSortRows,
@@ -194,6 +208,7 @@ const KIND_MODULES = Object.freeze({
194
208
  maintainability: bindKindModule({
195
209
  name: maintainabilityName,
196
210
  keyField: maintainabilityKeyField,
211
+ rowIdentity: maintainabilityRowIdentity,
197
212
  kernelVersion: maintainabilityKernelVersion,
198
213
  projectRow: maintainabilityProjectRow,
199
214
  sortRows: maintainabilitySortRows,
@@ -205,6 +220,7 @@ const KIND_MODULES = Object.freeze({
205
220
  mutation: bindKindModule({
206
221
  name: mutationName,
207
222
  keyField: mutationKeyField,
223
+ rowIdentity: mutationRowIdentity,
208
224
  kernelVersion: mutationKernelVersion,
209
225
  projectRow: mutationProjectRow,
210
226
  sortRows: mutationSortRows,
@@ -217,6 +233,7 @@ const KIND_MODULES = Object.freeze({
217
233
  lighthouse: bindKindModule({
218
234
  name: lighthouseName,
219
235
  keyField: lighthouseKeyField,
236
+ rowIdentity: lighthouseRowIdentity,
220
237
  kernelVersion: lighthouseKernelVersion,
221
238
  projectRow: lighthouseProjectRow,
222
239
  sortRows: lighthouseSortRows,
@@ -228,6 +245,7 @@ const KIND_MODULES = Object.freeze({
228
245
  'bundle-size': bindKindModule({
229
246
  name: bundleSizeName,
230
247
  keyField: bundleSizeKeyField,
248
+ rowIdentity: bundleSizeRowIdentity,
231
249
  kernelVersion: bundleSizeKernelVersion,
232
250
  projectRow: bundleSizeProjectRow,
233
251
  sortRows: bundleSizeSortRows,
@@ -239,6 +257,7 @@ const KIND_MODULES = Object.freeze({
239
257
  duplication: bindKindModule({
240
258
  name: duplicationName,
241
259
  keyField: duplicationKeyField,
260
+ rowIdentity: duplicationRowIdentity,
242
261
  kernelVersion: duplicationKernelVersion,
243
262
  projectRow: duplicationProjectRow,
244
263
  sortRows: duplicationSortRows,
@@ -28,6 +28,18 @@ export function projectRow(row) {
28
28
  };
29
29
  }
30
30
 
31
+ /**
32
+ * Canonical row identity (Story #5215). This kind does not use the shared
33
+ * factory scaffold, so it declares the protocol member itself; `bundle` is
34
+ * unique per row here, which a shipped-baseline injectivity test pins.
35
+ *
36
+ * @param {{bundle: string, rawKb: number, gzippedKb: number}} row
37
+ * @returns {string}
38
+ */
39
+ export function rowIdentity(row) {
40
+ return row.bundle;
41
+ }
42
+
31
43
  export function sortRows(rows) {
32
44
  return [...rows].sort((a, b) => a.bundle.localeCompare(b.bundle));
33
45
  }
@@ -61,6 +61,7 @@ function perfectCoverageRow(path) {
61
61
 
62
62
  export const {
63
63
  kernelVersion,
64
+ rowIdentity,
64
65
  sortRows,
65
66
  rollup,
66
67
  compare,
@@ -242,14 +242,30 @@ export const rollup = makeRollup({ aggregate });
242
242
  * No I/O. No process exit. No friction emission.
243
243
  */
244
244
  export const compare = makeCompare({
245
- identity: crapRowKey,
245
+ identity: rowIdentity,
246
246
  betterIsHigher: false,
247
247
  metricField: 'crap',
248
248
  // Removed methods whose crap > 0 are improvements (the debt is gone).
249
249
  removedIsImprovement: (b) => (b.crap ?? 0) > 0,
250
250
  });
251
251
 
252
- function crapRowKey(row) {
252
+ /**
253
+ * Canonical CRAP row identity (Story #5215) — the composite
254
+ * `path::method@startLine`, exported under the protocol name every kind
255
+ * module answers to.
256
+ *
257
+ * This kind is the reason identity is a separate concept from `keyField`.
258
+ * `keyField` is `'path'` because the rollup groups by file, but a file
259
+ * ships one row per method, so a merge keyed on `keyField` would collapse
260
+ * every method in a file to one row and drop the rest. `compare`,
261
+ * `applyEpsilon` and `mergeRows` have always keyed on this composite;
262
+ * exporting it makes the same identity available to callers that used to
263
+ * have no choice but to guess from `keyField`.
264
+ *
265
+ * @param {{path: string, method: string, startLine: number}} row
266
+ * @returns {string}
267
+ */
268
+ export function rowIdentity(row) {
253
269
  return `${row.path}::${row.method}@${row.startLine}`;
254
270
  }
255
271
 
@@ -258,7 +274,7 @@ function crapRowKey(row) {
258
274
  // absorbed the per-file queue wiring, both callers are inside it, so the
259
275
  // exports — and the re-export that used to live here — were reachable from
260
276
  // tests alone. `resolveIncrementalContext` is the production door to the
261
- // index; `crapRowKey` above is the composite key it halves.
277
+ // index; `rowIdentity` above is the composite key it halves.
262
278
 
263
279
  /**
264
280
  * Pure stabilizer for s-stability-epsilon (Story #1964). CRAP rows match
@@ -271,7 +287,7 @@ function crapRowKey(row) {
271
287
  * @returns {Array<object>}
272
288
  */
273
289
  export const applyEpsilon = makeEpsilon({
274
- identity: crapRowKey,
290
+ identity: rowIdentity,
275
291
  metricField: 'crap',
276
292
  });
277
293
 
@@ -294,7 +310,7 @@ export function mergeRows(prior, regenerated, scope) {
294
310
  regenerated,
295
311
  scope,
296
312
  scopeKey: (row) => row.path,
297
- identity: (row) => crapRowKey(row),
313
+ identity: (row) => rowIdentity(row),
298
314
  });
299
315
  }
300
316
 
@@ -89,6 +89,7 @@ function roundTo2(value) {
89
89
 
90
90
  export const {
91
91
  kernelVersion,
92
+ rowIdentity,
92
93
  sortRows,
93
94
  rollup,
94
95
  compare,
@@ -38,7 +38,10 @@ import { mergeRowsByScope } from '../scope.js';
38
38
  * | { kind: 'improvement-when', when: (row: object) => boolean },
39
39
  * perfectRow?: (key: string) => object,
40
40
  * }} opts
41
- * - `keyField` — row identity property (`'path'` or `'route'`)
41
+ * - `keyField` — row grouping property (`'path'` or `'route'`):
42
+ * the rollup/scope key. The generated
43
+ * `rowIdentity` derives from it, but the two are
44
+ * distinct concepts — see `rowIdentity` below.
42
45
  * - `kernelVersion` — static semver, or a thunk for kinds that pin
43
46
  * to another kind's kernel (MI → CRAP)
44
47
  * - `axes` — metric property names compared per row
@@ -57,6 +60,7 @@ import { mergeRowsByScope } from '../scope.js';
57
60
  * - `perfectRow` — builds the perfect row for the policies above
58
61
  * @returns {{
59
62
  * kernelVersion: () => string,
63
+ * rowIdentity: (row: object) => string,
60
64
  * sortRows: (rows: object[]) => object[],
61
65
  * rollup: (rows: object[], components?: object[]) => Record<string, object>,
62
66
  * compare: (head: object, base: object) => object,
@@ -78,6 +82,26 @@ export function makeBaselineKind({
78
82
  const kernelVersionFn =
79
83
  typeof kernelVersion === 'function' ? kernelVersion : () => kernelVersion;
80
84
 
85
+ /**
86
+ * Canonical row identity (Story #5215) — the string a 3-way merge keys a
87
+ * row on, and the contract every kind module must satisfy.
88
+ *
89
+ * Deliberately a separate concept from `keyField`, even though the five
90
+ * scaffold kinds derive one from the other. `keyField` answers "which
91
+ * component does this row roll up into", so a kind is free to declare a
92
+ * grouping key coarser than a row (CRAP declares `'path'` while shipping
93
+ * one row per method). Identity answers "is this the same row", and a
94
+ * merge that confuses the two silently drops every sibling sharing a key.
95
+ * Callers therefore read `rowIdentity` off the kind module and never
96
+ * rebuild a key from `keyField` themselves.
97
+ *
98
+ * @param {object} row
99
+ * @returns {string}
100
+ */
101
+ function rowIdentity(row) {
102
+ return String(keyOf(row));
103
+ }
104
+
81
105
  function sortRows(rows) {
82
106
  return [...rows].sort((a, b) => keyOf(a).localeCompare(keyOf(b)));
83
107
  }
@@ -183,6 +207,7 @@ export function makeBaselineKind({
183
207
 
184
208
  return {
185
209
  kernelVersion: kernelVersionFn,
210
+ rowIdentity,
186
211
  sortRows,
187
212
  rollup,
188
213
  compare,
@@ -69,6 +69,7 @@ function perfectLighthouseRow(route) {
69
69
 
70
70
  export const {
71
71
  kernelVersion,
72
+ rowIdentity,
72
73
  sortRows,
73
74
  rollup,
74
75
  compare,
@@ -67,6 +67,18 @@ export function projectRow(row) {
67
67
  };
68
68
  }
69
69
 
70
+ /**
71
+ * Canonical row identity (Story #5215). This kind does not use the shared
72
+ * factory scaffold, so it declares the protocol member itself; `path` is
73
+ * unique per row here, which a shipped-baseline injectivity test pins.
74
+ *
75
+ * @param {{path: string, errorCount: number, warningCount: number}} row
76
+ * @returns {string}
77
+ */
78
+ export function rowIdentity(row) {
79
+ return row.path;
80
+ }
81
+
70
82
  export function sortRows(rows) {
71
83
  return [...rows].sort((a, b) => a.path.localeCompare(b.path));
72
84
  }
@@ -109,6 +109,7 @@ function aggregate(rows) {
109
109
 
110
110
  export const {
111
111
  kernelVersion,
112
+ rowIdentity,
112
113
  sortRows,
113
114
  rollup,
114
115
  compare,
@@ -178,6 +178,7 @@ function aggregate(rows) {
178
178
 
179
179
  export const {
180
180
  kernelVersion,
181
+ rowIdentity,
181
182
  sortRows,
182
183
  rollup,
183
184
  compare,