mandrel 2.47.0 → 2.48.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/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/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/docs/CHANGELOG.md +12 -0
- package/lib/cli/registry.js +63 -0
- package/package.json +1 -1
|
@@ -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
|
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* merge-envelopes.js — pure 3-way merge of baseline envelopes by row
|
|
3
|
+
* identity (Story #5215).
|
|
4
|
+
*
|
|
5
|
+
* ## Why this exists
|
|
6
|
+
*
|
|
7
|
+
* Every baseline write stamps `generatedAt`, and the stamp sits on line 4 of
|
|
8
|
+
* every envelope. Two branches that each refresh a baseline therefore always
|
|
9
|
+
* differ on that line, even when they moved completely disjoint rows — so
|
|
10
|
+
* git's LINE-based merge has to reconcile it. Whether it can separate that
|
|
11
|
+
* hunk from the moved rows is an accident of proximity, and both outcomes
|
|
12
|
+
* are bad:
|
|
13
|
+
*
|
|
14
|
+
* - it cannot → a conflict on work that never actually overlapped;
|
|
15
|
+
* - it can → it splices both sides' row lines together into a row set
|
|
16
|
+
* NEITHER side ever scored. That silent one is the worse failure: the
|
|
17
|
+
* ratchet then guards a number no scorer produced.
|
|
18
|
+
*
|
|
19
|
+
* A baseline is not a text file. It is a set of rows keyed by identity plus
|
|
20
|
+
* a rollup DERIVED from those rows, so merging it as text is a category
|
|
21
|
+
* error. This module merges it as what it is.
|
|
22
|
+
*
|
|
23
|
+
* ## Contract
|
|
24
|
+
*
|
|
25
|
+
* Pure: no filesystem, no process, no clock. `assertEnvelope` is deliberately
|
|
26
|
+
* NOT called here (it compiles schemas off disk on first use) — the driver
|
|
27
|
+
* validates what this returns.
|
|
28
|
+
*
|
|
29
|
+
* Per row identity, the standard 3-way rule: the side that differs from base
|
|
30
|
+
* wins; when both sides differ from base AND from each other, that identity
|
|
31
|
+
* is a conflict. Absence is a value, so a row deleted on one side and
|
|
32
|
+
* untouched on the other merges to deleted.
|
|
33
|
+
*
|
|
34
|
+
* Two invariants are load-bearing:
|
|
35
|
+
*
|
|
36
|
+
* 1. **The rollup is recomputed, never merged.** Merging two rollups is
|
|
37
|
+
* the same splice hazard compressed into a single number, and unlike a
|
|
38
|
+
* spliced row set it leaves no evidence. It is always derived from the
|
|
39
|
+
* merged rows via the kind's own `rollup()`.
|
|
40
|
+
* 2. **Identity comes from the kind module** (`rowIdentity`), never from
|
|
41
|
+
* `keyField`. CRAP groups by file and identifies by method; keying on
|
|
42
|
+
* `keyField` would drop every method in a file but one.
|
|
43
|
+
*
|
|
44
|
+
* @module lib/baselines/merge-envelopes
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
import { deepEqual } from '../json-utils.js';
|
|
48
|
+
import { KNOWN_KINDS } from './envelope.js';
|
|
49
|
+
import { getKindModule } from './kernel.js';
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Envelope keys that are NOT merged side-by-side: `rows` merge by identity,
|
|
53
|
+
* `rollup` is recomputed from them, and `generatedAt` resolves to the later
|
|
54
|
+
* of the two stamps rather than conflicting (it is metadata about when a
|
|
55
|
+
* scorer ran, not a scored value — treating it as content is the whole bug).
|
|
56
|
+
*/
|
|
57
|
+
const DERIVED_KEYS = Object.freeze(['rows', 'rollup', 'generatedAt']);
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Identify an envelope's kind from its `$schema` reference.
|
|
61
|
+
*
|
|
62
|
+
* Derived from `KNOWN_KINDS` rather than pattern-matched, so a file that is
|
|
63
|
+
* not a known per-kind envelope answers `null` — which is how the driver
|
|
64
|
+
* knows to hand it back to git's text merge instead of guessing at a shape
|
|
65
|
+
* it does not understand. `baselines/*.json` also matches several
|
|
66
|
+
* non-envelope baselines (arch-cycles, cyclomatic, dead-exports, …).
|
|
67
|
+
*
|
|
68
|
+
* @param {unknown} envelope
|
|
69
|
+
* @returns {string|null}
|
|
70
|
+
*/
|
|
71
|
+
export function kindFromEnvelope(envelope) {
|
|
72
|
+
const ref = envelope?.$schema;
|
|
73
|
+
if (typeof ref !== 'string') return null;
|
|
74
|
+
const base = ref.split('/').pop();
|
|
75
|
+
for (const kind of KNOWN_KINDS) {
|
|
76
|
+
if (base === `${kind}.schema.json`) return kind;
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The 3-way choice for one value. `undefined` means "absent on this side",
|
|
83
|
+
* which makes deletion just another value rather than a special case.
|
|
84
|
+
*
|
|
85
|
+
* @param {unknown} base
|
|
86
|
+
* @param {unknown} ours
|
|
87
|
+
* @param {unknown} theirs
|
|
88
|
+
* @returns {{ conflict: boolean, value?: unknown }}
|
|
89
|
+
*/
|
|
90
|
+
function choose(base, ours, theirs) {
|
|
91
|
+
if (deepEqual(ours, theirs)) return { conflict: false, value: ours };
|
|
92
|
+
if (deepEqual(ours, base)) return { conflict: false, value: theirs };
|
|
93
|
+
if (deepEqual(theirs, base)) return { conflict: false, value: ours };
|
|
94
|
+
return { conflict: true };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Index rows by the kind's `rowIdentity`. A duplicate identity within one
|
|
99
|
+
* side is fatal rather than last-write-wins: it means the incoming file
|
|
100
|
+
* already violates the identity contract, and merging it would silently
|
|
101
|
+
* drop a row.
|
|
102
|
+
*
|
|
103
|
+
* @param {Array<object>} rows
|
|
104
|
+
* @param {(row: object) => string} rowIdentity
|
|
105
|
+
* @param {string} side
|
|
106
|
+
* @returns {Map<string, object>}
|
|
107
|
+
*/
|
|
108
|
+
function indexRows(rows, rowIdentity, side) {
|
|
109
|
+
const out = new Map();
|
|
110
|
+
for (const [idx, row] of (rows ?? []).entries()) {
|
|
111
|
+
if (!row || typeof row !== 'object') {
|
|
112
|
+
throw new TypeError(
|
|
113
|
+
`mergeEnvelopes: ${side} row at index ${idx} is not an object`,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
const id = rowIdentity(row);
|
|
117
|
+
if (out.has(id)) {
|
|
118
|
+
throw new Error(
|
|
119
|
+
`mergeEnvelopes: ${side} carries two rows with identity "${id}" — the baseline violates the identity contract and cannot be merged safely`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
out.set(id, row);
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Resolve `generatedAt` to the later of the two sides. A stamp is metadata,
|
|
129
|
+
* so it never conflicts: the merged file describes a tree scored as recently
|
|
130
|
+
* as the newer of its inputs.
|
|
131
|
+
*
|
|
132
|
+
* @param {string|undefined} ours
|
|
133
|
+
* @param {string|undefined} theirs
|
|
134
|
+
* @returns {string|undefined}
|
|
135
|
+
*/
|
|
136
|
+
function laterStamp(ours, theirs) {
|
|
137
|
+
if (typeof ours !== 'string') return theirs;
|
|
138
|
+
if (typeof theirs !== 'string') return ours;
|
|
139
|
+
const a = Date.parse(ours);
|
|
140
|
+
const b = Date.parse(theirs);
|
|
141
|
+
if (Number.isNaN(a) || Number.isNaN(b)) return ours > theirs ? ours : theirs;
|
|
142
|
+
return a >= b ? ours : theirs;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Merge the envelope-level stamps (`$schema`, `kernelVersion`, and per-kind
|
|
147
|
+
* extras like `scoringSemantics`) by the same 3-way rule as rows. A double
|
|
148
|
+
* bump to different values is a genuine conflict — two branches disagreeing
|
|
149
|
+
* about which scorer produced the file.
|
|
150
|
+
*
|
|
151
|
+
* @param {object} base
|
|
152
|
+
* @param {object} ours
|
|
153
|
+
* @param {object} theirs
|
|
154
|
+
* @returns {{ merged: object, conflicts: Array<object> }}
|
|
155
|
+
*/
|
|
156
|
+
function mergeStamps(base, ours, theirs) {
|
|
157
|
+
const keys = new Set(
|
|
158
|
+
[...Object.keys(ours), ...Object.keys(theirs), ...Object.keys(base)].filter(
|
|
159
|
+
(k) => !DERIVED_KEYS.includes(k),
|
|
160
|
+
),
|
|
161
|
+
);
|
|
162
|
+
const merged = {};
|
|
163
|
+
const conflicts = [];
|
|
164
|
+
for (const key of keys) {
|
|
165
|
+
const pick = choose(base[key], ours[key], theirs[key]);
|
|
166
|
+
if (pick.conflict) {
|
|
167
|
+
conflicts.push({
|
|
168
|
+
scope: 'envelope',
|
|
169
|
+
identity: key,
|
|
170
|
+
base: base[key],
|
|
171
|
+
ours: ours[key],
|
|
172
|
+
theirs: theirs[key],
|
|
173
|
+
});
|
|
174
|
+
merged[key] = ours[key];
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (pick.value !== undefined) merged[key] = pick.value;
|
|
178
|
+
}
|
|
179
|
+
return { merged, conflicts };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* 3-way merge two baseline envelopes against their common ancestor.
|
|
184
|
+
*
|
|
185
|
+
* @param {{
|
|
186
|
+
* base: object|null,
|
|
187
|
+
* ours: object,
|
|
188
|
+
* theirs: object,
|
|
189
|
+
* kind?: string,
|
|
190
|
+
* components?: Array<object>,
|
|
191
|
+
* }} params
|
|
192
|
+
* - `base` — the merge ancestor; `null` (or a rowless object) when the
|
|
193
|
+
* file was added on both sides.
|
|
194
|
+
* - `components` — passed straight to the kind's `rollup()`. Defaults to
|
|
195
|
+
* `[]`, which is what `refreshBaseline` effectively uses, so a merged
|
|
196
|
+
* envelope carries the same `{'*': …}` rollup shape a refresh writes.
|
|
197
|
+
* @returns {{
|
|
198
|
+
* kind: string,
|
|
199
|
+
* envelope: object,
|
|
200
|
+
* conflicts: Array<{scope: string, identity: string, base?: unknown, ours?: unknown, theirs?: unknown}>,
|
|
201
|
+
* }}
|
|
202
|
+
*/
|
|
203
|
+
export function mergeEnvelopes({
|
|
204
|
+
base,
|
|
205
|
+
ours,
|
|
206
|
+
theirs,
|
|
207
|
+
kind,
|
|
208
|
+
components = [],
|
|
209
|
+
} = {}) {
|
|
210
|
+
const resolvedKind =
|
|
211
|
+
kind ?? kindFromEnvelope(ours) ?? kindFromEnvelope(theirs);
|
|
212
|
+
if (!resolvedKind) {
|
|
213
|
+
throw new Error(
|
|
214
|
+
'mergeEnvelopes: could not resolve a known baseline kind from the envelopes',
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
const mod = getKindModule(resolvedKind);
|
|
218
|
+
const baseEnv = base && typeof base === 'object' ? base : { rows: [] };
|
|
219
|
+
|
|
220
|
+
const baseRows = indexRows(baseEnv.rows, mod.rowIdentity, 'base');
|
|
221
|
+
const ourRows = indexRows(ours?.rows, mod.rowIdentity, 'ours');
|
|
222
|
+
const theirRows = indexRows(theirs?.rows, mod.rowIdentity, 'theirs');
|
|
223
|
+
|
|
224
|
+
const conflicts = [];
|
|
225
|
+
const rows = [];
|
|
226
|
+
const identities = new Set([
|
|
227
|
+
...ourRows.keys(),
|
|
228
|
+
...theirRows.keys(),
|
|
229
|
+
...baseRows.keys(),
|
|
230
|
+
]);
|
|
231
|
+
for (const id of identities) {
|
|
232
|
+
const b = baseRows.get(id);
|
|
233
|
+
const o = ourRows.get(id);
|
|
234
|
+
const t = theirRows.get(id);
|
|
235
|
+
const pick = choose(b, o, t);
|
|
236
|
+
if (pick.conflict) {
|
|
237
|
+
conflicts.push({
|
|
238
|
+
scope: 'row',
|
|
239
|
+
identity: id,
|
|
240
|
+
base: b,
|
|
241
|
+
ours: o,
|
|
242
|
+
theirs: t,
|
|
243
|
+
});
|
|
244
|
+
// Keep the ours-side value so the row set stays well-formed; the
|
|
245
|
+
// driver renders the conflict markers around it from this record.
|
|
246
|
+
if (o !== undefined) rows.push(o);
|
|
247
|
+
else if (t !== undefined) rows.push(t);
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
if (pick.value !== undefined) rows.push(pick.value);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const sortedRows = mod.sortRows(rows);
|
|
254
|
+
const { merged: stamps, conflicts: stampConflicts } = mergeStamps(
|
|
255
|
+
baseEnv,
|
|
256
|
+
ours ?? {},
|
|
257
|
+
theirs ?? {},
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
const envelope = {
|
|
261
|
+
...stamps,
|
|
262
|
+
generatedAt: laterStamp(ours?.generatedAt, theirs?.generatedAt),
|
|
263
|
+
rollup: mod.rollup(sortedRows, components),
|
|
264
|
+
rows: sortedRows,
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
return {
|
|
268
|
+
kind: resolvedKind,
|
|
269
|
+
envelope,
|
|
270
|
+
conflicts: [...stampConflicts, ...conflicts],
|
|
271
|
+
};
|
|
272
|
+
}
|