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.
- package/.agents/agents/story-worker.md +49 -49
- package/.agents/docs/configuration.md +1 -0
- package/.agents/docs/quality-gates.md +48 -0
- package/.agents/scripts/lib/baselines/kernel.js +19 -0
- package/.agents/scripts/lib/baselines/kinds/bundle-size.js +12 -0
- package/.agents/scripts/lib/baselines/kinds/coverage.js +1 -0
- package/.agents/scripts/lib/baselines/kinds/crap.js +21 -5
- package/.agents/scripts/lib/baselines/kinds/duplication.js +1 -0
- package/.agents/scripts/lib/baselines/kinds/kind-factory.js +26 -1
- package/.agents/scripts/lib/baselines/kinds/lighthouse.js +1 -0
- package/.agents/scripts/lib/baselines/kinds/lint.js +12 -0
- package/.agents/scripts/lib/baselines/kinds/maintainability.js +1 -0
- package/.agents/scripts/lib/baselines/kinds/mutation.js +1 -0
- package/.agents/scripts/lib/baselines/merge-envelopes.js +272 -0
- package/.agents/scripts/lib/bootstrap/baseline-merge-driver.js +175 -0
- package/.agents/scripts/lib/bootstrap/quality-bootstrap.js +8 -2
- package/.agents/scripts/lib/observability/source-classifier.js +1 -0
- package/.agents/scripts/lib/orchestration/epic-container.js +48 -21
- package/.agents/scripts/lib/orchestration/epic-expansion.js +28 -6
- package/.agents/scripts/lib/orchestration/epic-rollup.js +66 -7
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +32 -61
- package/.agents/scripts/lib/orchestration/single-story-close/phases/pre-gate-steps.js +171 -0
- package/.agents/scripts/lib/orchestration/story-close/baseline-upward-writeback.js +483 -0
- package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +6 -1
- package/.agents/scripts/merge-baseline.js +238 -0
- package/.agents/scripts/providers/github/errors.js +66 -10
- package/.agents/scripts/providers/github/sub-issues.js +8 -1
- package/.agents/workflows/helpers/deliver-digest.md +30 -26
- package/.agents/workflows/helpers/parallel-tooling.md +17 -0
- package/docs/CHANGELOG.md +25 -0
- package/lib/cli/registry.js +63 -0
- 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
|
-
|
|
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
|
-
|
|
54
|
-
|
|
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
|
|
61
|
-
|
|
62
|
-
2. Capture `workCwd` and `dependenciesInstalled` from the
|
|
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.
|
|
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
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
75
|
+
Author Conventional Commit subjects on `story-<storyId>` per
|
|
81
76
|
[`git-conventions.md`](../rules/git-conventions.md): imperative mood,
|
|
82
|
-
≤100 chars,
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
90
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
111
|
-
|
|
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
|
-
|
|
114
|
-
[`
|
|
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
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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.**
|
|
133
|
+
- **Progress.** One terse line per phase transition (e.g.
|
|
132
134
|
`Story #<id>: implementing → closing`).
|
|
133
|
-
- **Blocked.** When you
|
|
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
|
|
137
|
-
|
|
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 (
|
|
141
|
+
## Land or block — the only sanctioned landing (MUST)
|
|
140
142
|
|
|
141
|
-
The
|
|
142
|
-
`remoteVerified` is `false`,
|
|
143
|
-
|
|
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,
|
|
152
|
-
|
|
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
|
-
|
|
160
|
-
|
|
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
|
}
|
|
@@ -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:
|
|
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
|
-
|
|
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; `
|
|
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:
|
|
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) =>
|
|
313
|
+
identity: (row) => rowIdentity(row),
|
|
298
314
|
});
|
|
299
315
|
}
|
|
300
316
|
|
|
@@ -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
|
|
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,
|
|
@@ -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
|
}
|