mandrel 2.61.0 → 2.63.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 (34) hide show
  1. package/.agents/docs/configuration.md +0 -1
  2. package/.agents/schemas/baselines/audit-baselines-envelope.schema.json +1 -1
  3. package/.agents/schemas/baselines/baseline-envelope.schema.json +2 -19
  4. package/.agents/schemas/baselines/bundle-size.schema.json +0 -24
  5. package/.agents/schemas/baselines/coverage.schema.json +0 -26
  6. package/.agents/schemas/baselines/crap.schema.json +0 -28
  7. package/.agents/schemas/baselines/duplication.schema.json +0 -38
  8. package/.agents/schemas/baselines/maintainability.schema.json +0 -45
  9. package/.agents/schemas/baselines/mutation.schema.json +0 -28
  10. package/.agents/scripts/check-dead-exports.js +1 -1
  11. package/.agents/scripts/lib/audit-baselines/headroom.js +1 -1
  12. package/.agents/scripts/lib/audit-baselines/kinds.js +12 -7
  13. package/.agents/scripts/lib/audit-baselines/rollup.js +41 -0
  14. package/.agents/scripts/lib/audit-baselines/staleness.js +14 -3
  15. package/.agents/scripts/lib/audit-baselines/surface-entry.js +1 -1
  16. package/.agents/scripts/lib/baselines/envelope.js +5 -50
  17. package/.agents/scripts/lib/baselines/merge-envelopes.js +16 -79
  18. package/.agents/scripts/lib/baselines/reader.js +7 -7
  19. package/.agents/scripts/lib/baselines/refresh-service.js +2 -7
  20. package/.agents/scripts/lib/baselines/writer.js +16 -45
  21. package/.agents/scripts/lib/coverage-baseline.js +0 -2
  22. package/.agents/scripts/lib/cyclomatic-ceiling.js +6 -18
  23. package/.agents/scripts/lib/git/sync-from-base.js +1 -1
  24. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +20 -27
  25. package/.agents/scripts/lib/orchestration/merge-queue.js +158 -0
  26. package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +115 -49
  27. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +49 -1
  28. package/.agents/scripts/merge-baseline.js +13 -11
  29. package/.agents/workflows/audit-baselines.md +2 -1
  30. package/docs/CHANGELOG.md +18 -0
  31. package/lib/cli/registry.js +1 -1
  32. package/lib/migrations/index.js +2 -0
  33. package/lib/migrations/steps/2.63.0-baseline-merge-queue-shape.js +96 -0
  34. package/package.json +1 -1
@@ -1,11 +1,10 @@
1
1
  /**
2
2
  * Pure 3-way merge of baseline envelopes by row identity. A line-based git
3
- * merge either conflicts on disjoint work (the `generatedAt` line) or splices
4
- * both sides into a row set no scorer produced.
3
+ * merge conflicts on disjoint rows that sit adjacent in sort order, and can
4
+ * splice both sides into a row set no scorer produced.
5
5
  *
6
6
  * Per identity the side that differs from base wins; both differing is a
7
- * conflict; absence is a value. The rollup is always recomputed from the
8
- * merged rows, never merged. Identity comes from the kind's `rowIdentity`,
7
+ * conflict; absence is a value. Identity comes from the kind's `rowIdentity`,
9
8
  * never `keyField`. No I/O: the driver validates the result.
10
9
  *
11
10
  * @module lib/baselines/merge-envelopes
@@ -15,8 +14,8 @@ import { deepEqual } from '../json-utils.js';
15
14
  import { KNOWN_KINDS } from './envelope.js';
16
15
  import { getKindModule } from './kernel.js';
17
16
 
18
- /** Keys not merged 3-way: rows by identity, rollup derived, stamp = later. */
19
- const DERIVED_KEYS = Object.freeze(['rows', 'rollup', 'generatedAt']);
17
+ /** Merged by identity, not as a stamp. */
18
+ const ROW_KEY = 'rows';
20
19
 
21
20
  /**
22
21
  * Kind from `$schema`, or `null` for anything not a known per-kind envelope.
@@ -76,20 +75,6 @@ function indexRows(rows, rowIdentity, side) {
76
75
  return out;
77
76
  }
78
77
 
79
- /**
80
- * @param {string|undefined} ours
81
- * @param {string|undefined} theirs
82
- * @returns {string|undefined}
83
- */
84
- function laterStamp(ours, theirs) {
85
- if (typeof ours !== 'string') return theirs;
86
- if (typeof theirs !== 'string') return ours;
87
- const a = Date.parse(ours);
88
- const b = Date.parse(theirs);
89
- if (Number.isNaN(a) || Number.isNaN(b)) return ours > theirs ? ours : theirs;
90
- return a >= b ? ours : theirs;
91
- }
92
-
93
78
  /**
94
79
  * 3-way merge of envelope stamps; a double bump to different values is a
95
80
  * real conflict (two scorers).
@@ -102,7 +87,7 @@ function laterStamp(ours, theirs) {
102
87
  function mergeStamps(base, ours, theirs) {
103
88
  const keys = new Set(
104
89
  [...Object.keys(ours), ...Object.keys(theirs), ...Object.keys(base)].filter(
105
- (k) => !DERIVED_KEYS.includes(k),
90
+ (k) => k !== ROW_KEY,
106
91
  ),
107
92
  );
108
93
  const merged = {};
@@ -170,15 +155,13 @@ function mergeRowSets({ baseEnv, ours, theirs, rowIdentity }) {
170
155
  }
171
156
 
172
157
  /**
173
- * `base` is `null` when the file was added on both sides. `components`
174
- * defaults to `[]`, matching what a refresh writes.
158
+ * `base` is `null` when the file was added on both sides.
175
159
  *
176
160
  * @param {{
177
161
  * base: object|null,
178
162
  * ours: object,
179
163
  * theirs: object,
180
164
  * kind?: string,
181
- * components?: Array<object>,
182
165
  * }} params
183
166
  * @returns {{
184
167
  * kind: string,
@@ -186,13 +169,7 @@ function mergeRowSets({ baseEnv, ours, theirs, rowIdentity }) {
186
169
  * conflicts: Array<{scope: string, identity: string, base?: unknown, ours?: unknown, theirs?: unknown}>,
187
170
  * }}
188
171
  */
189
- export function mergeEnvelopes({
190
- base,
191
- ours,
192
- theirs,
193
- kind,
194
- components = [],
195
- } = {}) {
172
+ export function mergeEnvelopes({ base, ours, theirs, kind } = {}) {
196
173
  const resolvedKind =
197
174
  kind ?? kindFromEnvelope(ours) ?? kindFromEnvelope(theirs);
198
175
  if (!resolvedKind) {
@@ -217,12 +194,7 @@ export function mergeEnvelopes({
217
194
  theirs ?? {},
218
195
  );
219
196
 
220
- const envelope = {
221
- ...stamps,
222
- generatedAt: laterStamp(ours?.generatedAt, theirs?.generatedAt),
223
- rollup: mod.rollup(sortedRows, components),
224
- rows: sortedRows,
225
- };
197
+ const envelope = { ...stamps, rows: sortedRows };
226
198
 
227
199
  return {
228
200
  kind: resolvedKind,
@@ -251,41 +223,15 @@ function orderKeys(obj, order) {
251
223
  }
252
224
 
253
225
  /**
254
- * Same arithmetic as `buildCyclomaticEnvelope`. Must be derived: two branches
255
- * each adding a row both write `n + 1`, which a 3-way merge resolves cleanly
256
- * and wrongly.
257
- *
258
- * @param {Array<object>} rows
259
- * @returns {{ '*': { filesAboveCeiling: number, methodsAboveCeiling: number, maxCyclomatic: number } }}
260
- */
261
- function cyclomaticRollup(rows) {
262
- let methods = 0;
263
- let max = 0;
264
- for (const row of rows) {
265
- methods += Number(row?.methodsAboveCeiling ?? 0);
266
- const rowMax = Number(row?.maxCyclomatic ?? 0);
267
- if (rowMax > max) max = rowMax;
268
- }
269
- return {
270
- '*': {
271
- filesAboveCeiling: rows.length,
272
- methodsAboveCeiling: methods,
273
- maxCyclomatic: max,
274
- },
275
- };
276
- }
277
-
278
- /**
279
- * Row-set baselines that are not per-kind envelopes. `rollup: null` is real:
280
- * the dead-export generator writes none. `keyOrder` mirrors each generator.
226
+ * Row-set baselines that are not per-kind envelopes. `keyOrder` mirrors each
227
+ * generator.
281
228
  */
282
229
  const PLAIN_BASELINE_KINDS = Object.freeze({
283
230
  cyclomatic: Object.freeze({
284
231
  rowIdentity: (row) => String(row?.file ?? ''),
285
232
  sortRows: (rows) =>
286
233
  [...rows].sort((a, b) => String(a.file).localeCompare(String(b.file))),
287
- rollup: cyclomaticRollup,
288
- keyOrder: ['$schema', 'generatedAt', 'ceiling', 'rollup', 'rows'],
234
+ keyOrder: ['$schema', 'ceiling', 'rows'],
289
235
  }),
290
236
  'dead-exports': Object.freeze({
291
237
  rowIdentity: (row) => `${row?.file ?? ''}::${row?.symbol ?? ''}`,
@@ -295,8 +241,7 @@ const PLAIN_BASELINE_KINDS = Object.freeze({
295
241
  String(a.file).localeCompare(String(b.file)) ||
296
242
  String(a.symbol).localeCompare(String(b.symbol)),
297
243
  ),
298
- rollup: null,
299
- keyOrder: ['$schema', 'kernelVersion', 'generatedAt', 'mode', 'rows'],
244
+ keyOrder: ['$schema', 'kernelVersion', 'mode', 'rows'],
300
245
  }),
301
246
  });
302
247
 
@@ -357,15 +302,7 @@ export function mergePlainBaseline({ base, ours, theirs, kind } = {}) {
357
302
  ours ?? {},
358
303
  theirs ?? {},
359
304
  );
360
- const envelope = orderKeys(
361
- {
362
- ...stamps,
363
- generatedAt: laterStamp(ours?.generatedAt, theirs?.generatedAt),
364
- ...(spec.rollup ? { rollup: spec.rollup(sortedRows) } : {}),
365
- rows: sortedRows,
366
- },
367
- spec.keyOrder,
368
- );
305
+ const envelope = orderKeys({ ...stamps, rows: sortedRows }, spec.keyOrder);
369
306
  return {
370
307
  kind: resolvedKind,
371
308
  envelope,
@@ -380,8 +317,8 @@ const REGENERATE_COMMANDS = Object.freeze({
380
317
  });
381
318
 
382
319
  /**
383
- * After a conflicted merge, hand-resolving rows never updates the rollup, so
384
- * the driver must name the regeneration command.
320
+ * After a conflicted merge, hand-resolved rows describe a tree nobody scored,
321
+ * so the driver must name the regeneration command.
385
322
  *
386
323
  * @param {string} kind
387
324
  * @returns {string}
@@ -130,21 +130,21 @@ function validate(kind, parsed, sourceHint) {
130
130
  /**
131
131
  * The one narrowing projection for every loaded envelope. It is an
132
132
  * allow-list: compat axes read stamps off the loaded object and fail closed
133
- * on `undefined`, so every envelope stamp must be carried through here.
133
+ * on `undefined`, so every envelope stamp must be carried through here. The
134
+ * committed file carries no rollup; a consumer that needs one derives it from
135
+ * `rows` through the kind module.
134
136
  *
135
137
  * @param {string} kind
136
138
  * @param {object} parsed A validated baseline envelope.
137
- * @returns {{ rollup: object, rows: Array<object>, kernelVersion: string, generatedAt: string }}
139
+ * @returns {{ rows: Array<object>, kernelVersion: string }}
138
140
  */
139
141
  function shapeEnvelope(kind, parsed) {
140
142
  const rows = Array.isArray(parsed.rows)
141
143
  ? parsed.rows.map((row) => canonicaliseRow(kind, row))
142
144
  : [];
143
145
  return {
144
- rollup: parsed.rollup ?? { '*': {} },
145
146
  rows,
146
147
  kernelVersion: parsed.kernelVersion,
147
- generatedAt: parsed.generatedAt,
148
148
  scoringSemantics: parsed.scoringSemantics,
149
149
  tsTranspilerVersion: parsed.tsTranspilerVersion,
150
150
  provenanceStamped: parsed.provenanceStamped,
@@ -154,7 +154,7 @@ function shapeEnvelope(kind, parsed) {
154
154
  /**
155
155
  * @param {string} kind
156
156
  * @param {string} absolutePath
157
- * @returns {{ rollup: object, rows: Array<object>, kernelVersion: string, generatedAt: string }}
157
+ * @returns {{ rows: Array<object>, kernelVersion: string }}
158
158
  */
159
159
  function readAndShape(kind, absolutePath) {
160
160
  let raw;
@@ -195,7 +195,7 @@ function inferKindFromSchema(schemaValue) {
195
195
  /**
196
196
  * @param {string} kind
197
197
  * @param {{ configPath?: string, cwd?: string }} [opts]
198
- * @returns {{ rollup: object, rows: Array<object>, kernelVersion: string, generatedAt: string }}
198
+ * @returns {{ rows: Array<object>, kernelVersion: string }}
199
199
  */
200
200
  export function load(kind, opts = {}) {
201
201
  if (!Object.hasOwn(KIND_TO_SCHEMA_FILE, kind)) {
@@ -214,7 +214,7 @@ export function load(kind, opts = {}) {
214
214
  *
215
215
  * @param {string} absolutePath
216
216
  * @param {{ kind?: string }} [opts]
217
- * @returns {{ rollup: object, rows: Array<object>, kernelVersion: string, generatedAt: string }}
217
+ * @returns {{ rows: Array<object>, kernelVersion: string }}
218
218
  */
219
219
  export function loadFile(absolutePath, opts = {}) {
220
220
  if (typeof absolutePath !== 'string' || absolutePath.length === 0) {
@@ -260,7 +260,6 @@ export function resolveDefaultScorer(kind, { cwd } = {}) {
260
260
  * fs?: typeof nodeFs,
261
261
  * gitDiff?: (args: { baseRef: string, headRef: string, cwd: string }) => Iterable<string> | Promise<Iterable<string>>,
262
262
  * cwd?: string,
263
- * generatedAt?: string,
264
263
  * requireRowsForScopeFiles?: boolean,
265
264
  * requiredScopeFilePredicate?: (file: string) => boolean,
266
265
  * }} opts
@@ -285,7 +284,6 @@ export async function refreshBaseline(opts = {}) {
285
284
  fs = nodeFs,
286
285
  gitDiff = defaultGitDiff,
287
286
  cwd = process.cwd(),
288
- generatedAt,
289
287
  requireRowsForScopeFiles = false,
290
288
  requiredScopeFilePredicate,
291
289
  } = opts;
@@ -349,11 +347,10 @@ export async function refreshBaseline(opts = {}) {
349
347
  scope.mode === 'full'
350
348
  ? null
351
349
  : { mode: 'diff', files: new Set(scope.files) },
352
- generatedAt: generatedAt ?? process.env.MANDREL_BASELINE_GENERATED_AT,
353
350
  });
354
351
 
355
352
  // The writer returns the prior object itself when nothing changed; skip the
356
- // write so on-disk bytes (including `generatedAt`) stay verbatim.
353
+ // write so on-disk bytes stay verbatim.
357
354
  let wrote = false;
358
355
  if (priorEnvelope === null || envelope !== priorEnvelope) {
359
356
  writeEnvelopeFile(writePath, envelope, { fsImpl: fs });
@@ -523,9 +520,7 @@ function readPriorEnvelope(writePath, fs) {
523
520
  parsed &&
524
521
  typeof parsed === 'object' &&
525
522
  !Array.isArray(parsed) &&
526
- Array.isArray(parsed.rows) &&
527
- parsed.rollup &&
528
- typeof parsed.rollup === 'object'
523
+ Array.isArray(parsed.rows)
529
524
  ) {
530
525
  return parsed;
531
526
  }
@@ -1,7 +1,6 @@
1
1
  /**
2
2
  * Shared baseline writer, the single funnel for envelope assembly
3
- * (`write`: project, canonical-assert, merge, stabilise, sort, rollup, stamp,
4
- * validate) and serialisation (`writeFile`: atomic tmp + rename).
3
+ * (`write`: project, canonical-assert, merge, stabilise, sort, stamp, validate) and serialisation (`writeFile`: atomic tmp + rename).
5
4
  *
6
5
  * @module lib/baselines/writer
7
6
  */
@@ -16,16 +15,14 @@ import { assertCanonical } from './path-canon.js';
16
15
  /**
17
16
  * Assemble and validate an envelope (no I/O). With `scope`, out-of-scope
18
17
  * `prior` rows are preserved first; with `epsilon`, sub-epsilon deltas then
19
- * resolve to prior bytes. `prior` rows must already be canonical. When rows
20
- * and rollup deep-equal the prior envelope, the prior is returned unchanged
21
- * so a no-op refresh leaves no `generatedAt` diff.
18
+ * resolve to prior bytes. `prior` rows must already be canonical. When the
19
+ * rows deep-equal the prior envelope's, the prior is returned unchanged so a
20
+ * no-op refresh leaves no diff.
22
21
  *
23
22
  * @param {{
24
23
  * kind: string,
25
24
  * rows: Array<object>,
26
- * components?: Array<object>,
27
25
  * kernelVersion?: string,
28
- * generatedAt?: string,
29
26
  * prior?: Array<object>,
30
27
  * priorEnvelope?: object,
31
28
  * epsilon?: number,
@@ -36,9 +33,7 @@ import { assertCanonical } from './path-canon.js';
36
33
  export function write({
37
34
  kind,
38
35
  rows,
39
- components,
40
36
  kernelVersion,
41
- generatedAt,
42
37
  prior,
43
38
  priorEnvelope,
44
39
  epsilon,
@@ -79,20 +74,9 @@ export function write({
79
74
  const stabilised = stabiliseRows(mod, merged, prior, epsilon);
80
75
 
81
76
  const sortedRows = mod.sortRows(stabilised);
82
- const rollup = mod.rollup(sortedRows, components ?? []);
83
-
84
- if (!Object.hasOwn(rollup, '*')) {
85
- throw new Error(
86
- `writer.write: ${kind} rollup is missing the required "*" key`,
87
- );
88
- }
89
77
 
90
78
  const priorEnv = resolvePriorEnvelope(priorEnvelope, prior);
91
- if (
92
- priorEnv &&
93
- deepEqual(sortedRows, priorEnv.rows) &&
94
- deepEqual(rollup, priorEnv.rollup)
95
- ) {
79
+ if (priorEnv && deepEqual(sortedRows, priorEnv.rows)) {
96
80
  // No smuggling an invalid envelope through the short-circuit.
97
81
  assertEnvelope(priorEnv);
98
82
  return priorEnv;
@@ -101,9 +85,7 @@ export function write({
101
85
  const envelope = buildEnvelope({
102
86
  kind,
103
87
  rows: sortedRows,
104
- rollup,
105
88
  kernelVersion: kernelVersion ?? currentKernelVersion(kind),
106
- generatedAt,
107
89
  extras:
108
90
  typeof mod.envelopeExtras === 'function' ? mod.envelopeExtras() : null,
109
91
  });
@@ -113,29 +95,20 @@ export function write({
113
95
 
114
96
  /** `priorEnvelope`, else `prior` when it is a whole envelope, else null. */
115
97
  function resolvePriorEnvelope(priorEnvelope, prior) {
116
- if (
117
- priorEnvelope &&
118
- typeof priorEnvelope === 'object' &&
119
- !Array.isArray(priorEnvelope) &&
120
- Array.isArray(priorEnvelope.rows) &&
121
- priorEnvelope.rollup &&
122
- typeof priorEnvelope.rollup === 'object'
123
- ) {
124
- return priorEnvelope;
125
- }
126
- if (
127
- prior &&
128
- typeof prior === 'object' &&
129
- !Array.isArray(prior) &&
130
- Array.isArray(prior.rows) &&
131
- prior.rollup &&
132
- typeof prior.rollup === 'object'
133
- ) {
134
- return prior;
135
- }
98
+ if (isEnvelopeObject(priorEnvelope)) return priorEnvelope;
99
+ if (isEnvelopeObject(prior)) return prior;
136
100
  return null;
137
101
  }
138
102
 
103
+ function isEnvelopeObject(value) {
104
+ return (
105
+ Boolean(value) &&
106
+ typeof value === 'object' &&
107
+ !Array.isArray(value) &&
108
+ Array.isArray(value.rows)
109
+ );
110
+ }
111
+
139
112
  /**
140
113
  * Serialise deterministically (fixed top-level key order, two-space indent,
141
114
  * trailing newline) and atomically (tmp + rename).
@@ -161,11 +134,9 @@ export function writeFile(absPath, envelope, opts = {}) {
161
134
  const canonical = {
162
135
  $schema: envelope.$schema,
163
136
  kernelVersion: envelope.kernelVersion,
164
- generatedAt: envelope.generatedAt,
165
137
  scoringSemantics: envelope.scoringSemantics,
166
138
  tsTranspilerVersion: envelope.tsTranspilerVersion,
167
139
  provenanceStamped: envelope.provenanceStamped,
168
- rollup: envelope.rollup,
169
140
  rows: envelope.rows,
170
141
  };
171
142
 
@@ -156,8 +156,6 @@ function writeEnvelopeViaFsImpl(abs, envelope, fsImpl) {
156
156
  const canonical = {
157
157
  $schema: envelope.$schema,
158
158
  kernelVersion: envelope.kernelVersion,
159
- generatedAt: envelope.generatedAt,
160
- rollup: envelope.rollup,
161
159
  rows: envelope.rows,
162
160
  };
163
161
  fsImpl.writeFileSync(abs, `${JSON.stringify(canonical, null, 2)}\n`);
@@ -163,29 +163,17 @@ export function diffCyclomaticRows(baselineRows, currentRows) {
163
163
  }
164
164
 
165
165
  /**
166
- * @param {{ rows: Array<object>, ceiling: number, generatedAt?: string }} args
166
+ * No stamp and no rollup: both would rewrite on every refresh and conflict
167
+ * textually wherever the merge driver does not run.
168
+ *
169
+ * @param {{ rows: Array<object>, ceiling: number }} args
167
170
  * @returns {object}
168
171
  */
169
- export function buildCyclomaticEnvelope({ rows, ceiling, generatedAt }) {
170
- const safeRows = rows ?? [];
171
- let methods = 0;
172
- let max = 0;
173
- for (const row of safeRows) {
174
- methods += Number(row.methodsAboveCeiling ?? 0);
175
- if (Number(row.maxCyclomatic ?? 0) > max) max = Number(row.maxCyclomatic);
176
- }
172
+ export function buildCyclomaticEnvelope({ rows, ceiling }) {
177
173
  return {
178
174
  $schema: CYCLOMATIC_BASELINE_SCHEMA,
179
- generatedAt: generatedAt ?? new Date().toISOString(),
180
175
  ceiling,
181
- rollup: {
182
- '*': {
183
- filesAboveCeiling: safeRows.length,
184
- methodsAboveCeiling: methods,
185
- maxCyclomatic: max,
186
- },
187
- },
188
- rows: safeRows,
176
+ rows: rows ?? [],
189
177
  };
190
178
  }
191
179
 
@@ -38,7 +38,7 @@ function refuseWithoutMergeDriver(cwd, gitSpawn) {
38
38
  `.gitattributes routes baselines/*.json through the mandrel-baseline merge ` +
39
39
  `driver, but ${BASELINE_MERGE_DRIVER_CONFIG_KEY} is unset in this clone. ` +
40
40
  'Merging origin now would text-merge generated baselines, which conflicts on ' +
41
- 'the generatedAt stamp and can splice rows neither branch scored. Register the ' +
41
+ 'adjacent rows and can splice rows neither branch scored. Register the ' +
42
42
  `driver, then re-run:\n ${BASELINE_MERGE_DRIVER_REMEDY}\n` +
43
43
  ' (or: npm run baselines:merge-driver)',
44
44
  remedy: BASELINE_MERGE_DRIVER_REMEDY,
@@ -17,34 +17,28 @@ import { applyFloors, flattenBreaches } from './floors.js';
17
17
  import { applyRefreshAcknowledgment } from './refresh-ack.js';
18
18
 
19
19
  /**
20
- * Recompute the `*` floor rollup without `ignoreGlobs` rows, in case a
21
- * baseline was poisoned by some route other than generation (which already
22
- * drops them); an ignored file could otherwise breach a global floor. A no-op
23
- * for a correct baseline; compare is untouched.
20
+ * The floor rollup, derived from the rows (the committed file carries none).
21
+ * `ignoreGlobs` rows are dropped first in case a baseline was poisoned by some
22
+ * route other than generation, which already drops them; an ignored file
23
+ * could otherwise breach a global floor.
24
24
  *
25
- * @param {{ kind: string, baseline: { rollup?: object, rows?: object[] }, ignoreGlobs?: string[], cwd?: string }} args
25
+ * @param {{ kind: string, baseline: { rows?: object[] }, ignoreGlobs?: string[], cwd?: string }} args
26
26
  * @returns {object}
27
27
  */
28
- function rollupExcludingIgnored({ kind, baseline, ignoreGlobs, cwd }) {
29
- const rollup = baseline?.rollup;
30
- if (!Array.isArray(ignoreGlobs) || ignoreGlobs.length === 0) return rollup;
31
- const rows = baseline?.rows;
32
- if (!Array.isArray(rows) || rows.length === 0) return rollup;
33
- let mod;
34
- try {
35
- mod = getKindModule(kind);
36
- } catch {
37
- return rollup;
38
- }
39
- if (mod?.keyField !== 'path' || typeof mod.rollup !== 'function')
40
- return rollup;
41
- const kept = rows.filter((row) => {
42
- const p = row?.path;
43
- return typeof p !== 'string' || !isIgnoredByGlobs(p, ignoreGlobs, cwd);
44
- });
45
- if (kept.length === rows.length) return rollup;
46
- const recomputed = mod.rollup(kept);
47
- return { ...rollup, '*': recomputed?.['*'] ?? rollup?.['*'] };
28
+ function floorRollupOf({ kind, baseline, ignoreGlobs, cwd }) {
29
+ const mod = getKindModule(kind);
30
+ const rows = Array.isArray(baseline?.rows) ? baseline.rows : [];
31
+ const filterIgnored =
32
+ mod.keyField === 'path' &&
33
+ Array.isArray(ignoreGlobs) &&
34
+ ignoreGlobs.length > 0;
35
+ const kept = filterIgnored
36
+ ? rows.filter((row) => {
37
+ const p = row?.path;
38
+ return typeof p !== 'string' || !isIgnoredByGlobs(p, ignoreGlobs, cwd);
39
+ })
40
+ : rows;
41
+ return mod.rollup(kept);
48
42
  }
49
43
 
50
44
  function loadHeadBaseline(kind, cwd, configPath) {
@@ -87,7 +81,6 @@ function buildGateReport({
87
81
  baseRef: cmp.baseRef ?? null,
88
82
  // Without this a compare arm that never ran looks identical to a clean run.
89
83
  baseRead: cmp.baseRead === true,
90
- generatedAt: baseline.generatedAt,
91
84
  acknowledged: ack.acknowledged,
92
85
  // Acknowledgment can be partial; name which regressions it cleared.
93
86
  acknowledgedKeys: ack.acknowledgedKeys,
@@ -114,7 +107,7 @@ export async function evaluateKind({
114
107
  schemaError: { tag: 'semantics', message: semanticsError },
115
108
  };
116
109
  }
117
- const floorRollup = rollupExcludingIgnored({
110
+ const floorRollup = floorRollupOf({
118
111
  kind,
119
112
  baseline,
120
113
  ignoreGlobs: gateBlock.ignoreGlobs,
@@ -0,0 +1,158 @@
1
+ /**
2
+ * merge-queue.js — the one GraphQL seam for a merge-queue-protected base:
3
+ * whether the PR's base requires a queue, whether the PR sits in it, and the
4
+ * dequeue. Keyed on the PR node, so no owner/repo is needed.
5
+ */
6
+
7
+ import { gh as defaultGh, describeGhFailure } from '../gh-exec.js';
8
+
9
+ /** Bound on each merge-queue GraphQL call, so a wedged `gh` cannot stall close. */
10
+ const MERGE_QUEUE_GH_TIMEOUT_MS = 30_000;
11
+
12
+ const MERGE_QUEUE_STATE_QUERY =
13
+ 'query($id: ID!) { node(id: $id) { ... on PullRequest { isMergeQueueEnabled isInMergeQueue } } }';
14
+
15
+ const DEQUEUE_MUTATION =
16
+ 'mutation($id: ID!) { dequeuePullRequest(input: { id: $id }) { mergeQueueEntry { id } } }';
17
+
18
+ /** `gh` stderr when the base requires a merge queue and the arm spelling is wrong. */
19
+ export const MERGE_QUEUE_REFUSAL = /when merge queue enabled/i;
20
+
21
+ /**
22
+ * @param {{ stdout?: string }|string|undefined} result
23
+ * @returns {object|null}
24
+ */
25
+ function parseGraphqlData(result) {
26
+ const text = typeof result === 'string' ? result : result?.stdout;
27
+ const parsed = JSON.parse(String(text ?? ''));
28
+ if (Array.isArray(parsed?.errors) && parsed.errors.length > 0) {
29
+ throw new Error(
30
+ `graphql: ${parsed.errors.map((e) => e?.message).join('; ')}`,
31
+ );
32
+ }
33
+ return parsed?.data ?? null;
34
+ }
35
+
36
+ /**
37
+ * Whether the PR's base requires a merge queue, and whether the PR sits in
38
+ * it now. Keyed on the PR node, so it needs no owner/repo. Never throws: any
39
+ * failure returns `queueRequired: null` and the caller keeps the non-queue
40
+ * behaviour.
41
+ *
42
+ * @param {{ prNumber?: number|string, prRef?: string, prNodeId?: string,
43
+ * gh?: object, timeoutMs?: number }} args `prNodeId` skips the id lookup.
44
+ * @returns {Promise<{ queueRequired: boolean|null, inQueue: boolean|null,
45
+ * prNodeId: string|null, error?: string }>}
46
+ */
47
+ export async function readMergeQueueState({
48
+ prNumber,
49
+ prRef,
50
+ prNodeId,
51
+ gh,
52
+ timeoutMs = MERGE_QUEUE_GH_TIMEOUT_MS,
53
+ }) {
54
+ const facade = gh ?? defaultGh;
55
+ let id = prNodeId ?? null;
56
+ try {
57
+ if (!id) {
58
+ const view = await facade.pr.view(String(prRef ?? prNumber), ['id']);
59
+ id = typeof view?.id === 'string' && view.id ? view.id : null;
60
+ }
61
+ if (!id) throw new Error('PR node id unavailable');
62
+ const data = parseGraphqlData(
63
+ await facade.api({
64
+ method: 'POST',
65
+ endpoint: 'graphql',
66
+ body: { query: MERGE_QUEUE_STATE_QUERY, variables: { id } },
67
+ execOpts: { timeoutMs },
68
+ }),
69
+ );
70
+ const node = data?.node;
71
+ if (typeof node?.isMergeQueueEnabled !== 'boolean') {
72
+ throw new Error('merge-queue fields absent from the GraphQL response');
73
+ }
74
+ return {
75
+ queueRequired: node.isMergeQueueEnabled,
76
+ inQueue: node.isInMergeQueue === true,
77
+ prNodeId: id,
78
+ };
79
+ } catch (err) {
80
+ return {
81
+ queueRequired: null,
82
+ inQueue: null,
83
+ prNodeId: id,
84
+ error: describeGhFailure(err),
85
+ };
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Take an enqueued PR out of the merge queue. `--disable-auto` cannot: GitHub
91
+ * clears the auto-merge request on enqueue, so it reads as never armed.
92
+ *
93
+ * @returns {Promise<{ ok: boolean, detail: string }>}
94
+ */
95
+ async function dequeuePullRequest({ prNodeId, gh }) {
96
+ try {
97
+ parseGraphqlData(
98
+ await (gh ?? defaultGh).api({
99
+ method: 'POST',
100
+ endpoint: 'graphql',
101
+ body: { query: DEQUEUE_MUTATION, variables: { id: prNodeId } },
102
+ execOpts: { timeoutMs: MERGE_QUEUE_GH_TIMEOUT_MS },
103
+ }),
104
+ );
105
+ return { ok: true, detail: 'dequeued' };
106
+ } catch (err) {
107
+ return { ok: false, detail: describeGhFailure(err) };
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Arm on a merge-queue base: a bare `--auto`, and never the direct-merge
113
+ * fallback — a merge that bypassed the queue would defeat the protection it
114
+ * exists for.
115
+ *
116
+ * @returns {Promise<{ enabled: boolean, mergeQueue: true, reason?: string }>}
117
+ */
118
+ export async function armForMergeQueue({ exec, prNumber, armCwd }) {
119
+ try {
120
+ const result = await exec(['pr', 'merge', String(prNumber), '--auto'], {
121
+ cwd: armCwd,
122
+ });
123
+ if (result.status === 0) return { enabled: true, mergeQueue: true };
124
+ return {
125
+ enabled: false,
126
+ mergeQueue: true,
127
+ reason: `merge-queue arm failed; gh-exit-${result.status}: ${(result.stderr ?? '').trim().slice(0, 200)}`,
128
+ };
129
+ } catch (err) {
130
+ return {
131
+ enabled: false,
132
+ mergeQueue: true,
133
+ reason: `gh-spawn-error: ${err?.message ?? err}`,
134
+ };
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Disarm an enqueued PR by dequeueing it. The caller owns the still-armed
140
+ * warning, so both disarm paths share it.
141
+ *
142
+ * @returns {Promise<{ disarmed: boolean, alreadyUnarmed: false, detail: string }>}
143
+ */
144
+ export async function disarmByDequeue({ prNodeId, ref, gh, progress }) {
145
+ const dequeued = await dequeuePullRequest({ prNodeId, gh });
146
+ if (!dequeued.ok) {
147
+ return {
148
+ disarmed: false,
149
+ alreadyUnarmed: false,
150
+ detail: `merge-queue dequeue failed: ${dequeued.detail}`.slice(0, 200),
151
+ };
152
+ }
153
+ progress?.(
154
+ 'CONFIRM',
155
+ `🔓 PR #${ref} removed from the merge queue — the PR stays open and hand-mergeable.`,
156
+ );
157
+ return { disarmed: true, alreadyUnarmed: false, detail: 'dequeued' };
158
+ }