mandrel 2.54.0 → 2.55.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 (114) hide show
  1. package/.agents/agents/story-worker.md +24 -23
  2. package/.agents/audit-checklists/accessibility.md +0 -3
  3. package/.agents/audit-checklists/mobile.md +0 -4
  4. package/.agents/docs/agentrc-reference.json +4 -2
  5. package/.agents/docs/configuration.md +2 -0
  6. package/.agents/schemas/agentrc.schema.json +15 -1
  7. package/.agents/schemas/lifecycle/merge.unlanded.schema.json +2 -1
  8. package/.agents/schemas/story-deliver-terminal.schema.json +1 -0
  9. package/.agents/scripts/audit-to-stories.js +158 -7
  10. package/.agents/scripts/check-audit-attribution.js +119 -62
  11. package/.agents/scripts/check-test-portability.js +512 -0
  12. package/.agents/scripts/coverage-capture.js +17 -10
  13. package/.agents/scripts/evidence-gate.js +31 -4
  14. package/.agents/scripts/generate-workflows-doc.js +65 -14
  15. package/.agents/scripts/git-cleanup.js +4 -0
  16. package/.agents/scripts/lib/ITicketingProvider.js +78 -0
  17. package/.agents/scripts/lib/audit-advisories.js +195 -0
  18. package/.agents/scripts/lib/audit-attribution.js +22 -0
  19. package/.agents/scripts/lib/audit-to-stories/dedupe-against-github.js +68 -5
  20. package/.agents/scripts/lib/audit-to-stories/issue-index.js +83 -0
  21. package/.agents/scripts/lib/audit-to-stories/ledger-commit.js +60 -114
  22. package/.agents/scripts/lib/audit-to-stories/ledger-pr.js +347 -0
  23. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +169 -44
  24. package/.agents/scripts/lib/baselines/merge-envelopes.js +298 -32
  25. package/.agents/scripts/lib/bootstrap/baseline-merge-driver.js +180 -14
  26. package/.agents/scripts/lib/cli-args.js +26 -0
  27. package/.agents/scripts/lib/close-validation/gates.js +113 -7
  28. package/.agents/scripts/lib/close-validation/process.js +7 -3
  29. package/.agents/scripts/lib/close-validation/runner.js +62 -11
  30. package/.agents/scripts/lib/config/ci.js +28 -9
  31. package/.agents/scripts/lib/config-settings-schema-delivery.js +7 -0
  32. package/.agents/scripts/lib/config-settings-schema.js +19 -1
  33. package/.agents/scripts/lib/coverage-capture-fullscope.js +23 -11
  34. package/.agents/scripts/lib/coverage-capture-incremental.js +22 -16
  35. package/.agents/scripts/lib/coverage-capture-usage.js +5 -1
  36. package/.agents/scripts/lib/coverage-capture.js +77 -3
  37. package/.agents/scripts/lib/findings/route-finding.js +4 -2
  38. package/.agents/scripts/lib/full-suite-lock.js +232 -6
  39. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  40. package/.agents/scripts/lib/git/sync-from-base.js +130 -13
  41. package/.agents/scripts/lib/observability/source-classifier.js +1 -0
  42. package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +10 -2
  43. package/.agents/scripts/lib/orchestration/check-baselines/phases/refresh-ack.js +75 -15
  44. package/.agents/scripts/lib/orchestration/deliver-recover.js +82 -43
  45. package/.agents/scripts/lib/orchestration/dependency-candidates.js +8 -4
  46. package/.agents/scripts/lib/orchestration/epic-candidates.js +9 -4
  47. package/.agents/scripts/lib/orchestration/epic-container.js +66 -4
  48. package/.agents/scripts/lib/orchestration/epic-rollup.js +233 -84
  49. package/.agents/scripts/lib/orchestration/file-assumptions.js +218 -16
  50. package/.agents/scripts/lib/orchestration/git-cleanup/phases/branches.js +93 -7
  51. package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes.js +22 -6
  52. package/.agents/scripts/lib/orchestration/git-cleanup/phases/parse-args.js +26 -5
  53. package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +13 -2
  54. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +35 -5
  55. package/.agents/scripts/lib/orchestration/merge-block-class.js +18 -3
  56. package/.agents/scripts/lib/orchestration/merge-poll.js +284 -40
  57. package/.agents/scripts/lib/orchestration/plan-persist/epic-adoption.js +49 -2
  58. package/.agents/scripts/lib/orchestration/plan-persist/epic-ops.js +43 -7
  59. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +24 -1
  60. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +5 -0
  61. package/.agents/scripts/lib/orchestration/plan-persist/summary.js +3 -0
  62. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +63 -0
  63. package/.agents/scripts/lib/orchestration/plan-persist/wave-serialisation.js +110 -0
  64. package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +130 -40
  65. package/.agents/scripts/lib/orchestration/resolve-stories.js +44 -1
  66. package/.agents/scripts/lib/orchestration/review-providers/native.js +31 -11
  67. package/.agents/scripts/lib/orchestration/review-providers/scoped-lint.js +27 -24
  68. package/.agents/scripts/lib/orchestration/run-epilogue.js +59 -38
  69. package/.agents/scripts/lib/orchestration/single-story-close/close-note.js +81 -0
  70. package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +40 -51
  71. package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +10 -2
  72. package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +101 -0
  73. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +351 -28
  74. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +27 -6
  75. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +117 -22
  76. package/.agents/scripts/lib/orchestration/story-close/baseline-upward-writeback.js +94 -12
  77. package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +6 -1
  78. package/.agents/scripts/lib/orchestration/ticket-validator.js +25 -14
  79. package/.agents/scripts/lib/orchestration/ticketing/bulk.js +30 -0
  80. package/.agents/scripts/lib/orchestration/verify-credit.js +37 -0
  81. package/.agents/scripts/lib/pinned-override-notes.js +41 -53
  82. package/.agents/scripts/lib/pinned-override-resolve.js +212 -0
  83. package/.agents/scripts/lib/qa/resolve-qa-contract.js +18 -0
  84. package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +173 -9
  85. package/.agents/scripts/lib/skills/walk-skill-files.js +24 -7
  86. package/.agents/scripts/lib/test-temp.js +167 -30
  87. package/.agents/scripts/lib/validation-evidence.js +37 -0
  88. package/.agents/scripts/lib/wave-runner/footprint.js +167 -14
  89. package/.agents/scripts/lib/wave-runner/live-probe.js +7 -1
  90. package/.agents/scripts/lib/wave-runner/ready-set.js +1 -1
  91. package/.agents/scripts/merge-baseline.js +175 -21
  92. package/.agents/scripts/providers/github/errors.js +22 -1
  93. package/.agents/scripts/providers/github/issues.js +106 -1
  94. package/.agents/scripts/providers/github/sub-issue-add.js +18 -1
  95. package/.agents/scripts/providers/github.js +6 -0
  96. package/.agents/scripts/resolve-stories.js +44 -34
  97. package/.agents/scripts/single-story-close.js +5 -0
  98. package/.agents/scripts/stories-wave-tick.js +37 -13
  99. package/.agents/templates/docs/audit-sweep-runbook.md +41 -7
  100. package/.agents/workflows/audit-accessibility.md +16 -31
  101. package/.agents/workflows/audit-mobile.md +20 -37
  102. package/.agents/workflows/git-cleanup.md +17 -3
  103. package/.agents/workflows/helpers/audit-lens-core.md +45 -0
  104. package/.agents/workflows/helpers/deliver-digest.md +7 -6
  105. package/.agents/workflows/helpers/deliver-reference.md +35 -14
  106. package/.agents/workflows/helpers/deliver-story-reference.md +7 -4
  107. package/.agents/workflows/helpers/deliver-story.md +15 -12
  108. package/.agents/workflows/helpers/plan-reference.md +7 -0
  109. package/.agents/workflows/mandrel-plan.md +4 -7
  110. package/.agents/workflows/memory-consolidate.md +14 -9
  111. package/docs/CHANGELOG.md +27 -0
  112. package/lib/cli/registry.js +64 -21
  113. package/lib/cli/sync.js +27 -2
  114. package/package.json +7 -4
@@ -19,11 +19,19 @@ import path from 'node:path';
19
19
 
20
20
  import { normalizeSeverity } from '../findings/severity.js';
21
21
 
22
- const KEY_LINE = /^\s*-\s*\*\*([^:*]+):\*\*\s*(.*)$/;
22
+ // A lens writes its field bullets either way round — `- **Severity:** High`
23
+ // (colon inside the bold run) or `- **Severity**: High` (colon outside it).
24
+ // Accepting only the first silently dropped the axis of every finding written
25
+ // the second way: it parsed as prose, so the block carried no severity, and the
26
+ // grouping-header rule below then read the whole finding as an organisational
27
+ // heading. Both spellings are the same field.
28
+ const KEY_LINE = /^\s*-\s*\*\*([^:*]+?)\s*(?::\*\*|\*\*\s*:)\s*(.*)$/;
23
29
  const HEADING_FINDING = /^(#{3,4})\s+(.+?)\s*$/;
24
- const SEVERITY_KEY_LINE = /^\s*-\s*\*\*(?:severity|impact)\s*:\*\*/i;
30
+ const SEVERITY_KEY_LINE =
31
+ /^\s*-\s*\*\*(?:severity|impact)\s*(?::\*\*|\*\*\s*:)/i;
25
32
  const TALLY_LINE =
26
33
  /severity\s+tally\s*:?\**\s*critical\s+(\d+)\s*\/\s*high\s+(\d+)\s*\/\s*medium\s+(\d+)\s*\/\s*low\s+(\d+)/i;
34
+ const TALLY_LINE_GLOBAL = new RegExp(TALLY_LINE.source, 'gi');
27
35
  const HEADING_SECTION = /^##\s+(.+?)\s*$/;
28
36
  const PATH_HINT =
29
37
  /(?<![\w/])([A-Za-z0-9_./\\@-]+\.(?:js|ts|tsx|jsx|mjs|cjs|md|json|yaml|yml|css|scss|html|py|go|rs|java|kt|rb|sh|ps1|tf|env))(?![\w])/g;
@@ -269,72 +277,186 @@ function carriesSeverity(block) {
269
277
  }
270
278
 
271
279
  /**
272
- * Resolve `###` headings that are **grouping headers** rather than findings.
280
+ * Does this block carry any `- **Key:** value` field bullet at all? A finding
281
+ * block is a field record; a grouping header is a heading with prose (or
282
+ * nothing) under it. Read together with {@link carriesSeverity} this is what
283
+ * lets an axis-less heading be recognised as organisational **without** having
284
+ * to see `####` children under it.
273
285
  *
274
- * Several lenses nest their findings one level deeper — a `###` per dimension
286
+ * @param {{ bodyLines: string[] }} block
287
+ * @returns {boolean}
288
+ */
289
+ function carriesFieldBullet(block) {
290
+ return block.bodyLines.some((line) => KEY_LINE.test(line));
291
+ }
292
+
293
+ /**
294
+ * A block that declares nothing: no severity axis and no field bullets. A
295
+ * `### Robust` header whose whole body is `_No findings._` is the canonical
296
+ * case — it used to parse as a severity-less finding with no files and no
297
+ * recommendation, which an unattended sweep then filed as an empty Story.
298
+ *
299
+ * @param {{ bodyLines: string[] }} block
300
+ * @returns {boolean}
301
+ */
302
+ function isEmptyBlock(block) {
303
+ return !carriesSeverity(block) && !carriesFieldBullet(block);
304
+ }
305
+
306
+ /**
307
+ * Decide whether a `###` block is a **grouping header** rather than a finding,
308
+ * reading its **whole subtree** — its own body and its `####` children.
309
+ *
310
+ * Several lenses nest their findings one level deeper: a `###` per dimension
275
311
  * (`### Perceivable`), each holding `####` finding blocks. Read flat, that
276
- * report parsed as one severity-less finding per dimension with no files and
277
- * no recommendation, and `--auto` filed those empties (Story #5144). The rule
278
- * this applies: a `###` heading that carries no `Severity:`/`Impact:` line and
279
- * is followed by `####` headings is a grouping header — its `####` children
280
- * are emitted as findings and the header itself never is.
312
+ * report parsed as one severity-less finding per dimension, and the unattended
313
+ * sweep filed those empties. The subtree rule this applies, in order:
314
+ *
315
+ * 1. A block whose own body carries the severity axis **or any field
316
+ * bullet** is a finding. Its `####` sub-sections fold back into its body
317
+ * rather than splitting into phantom findings, so flat reports parse
318
+ * exactly as before.
319
+ * 2. A block whose title leads with a backticked path — the anchor the
320
+ * finding-block skeleton mandates — is a finding even when its own body
321
+ * is empty, because its fields live under a `#### Evidence`-style
322
+ * sub-section. Folding is what stops that sub-section's heading from
323
+ * becoming the finding's title.
324
+ * 3. Otherwise it is a grouping header when its children look like findings:
325
+ * any child carrying its own path anchor, or more than one child carrying
326
+ * the severity axis. A **single** axis-bearing child under an anchorless
327
+ * header is the `#### Evidence` shape again, so it folds.
328
+ * 4. A block with no children at all and nothing declared is a grouping
329
+ * header (or a stray heading) either way — it yields no finding.
281
330
  *
282
- * A `###` heading that DOES carry the axis line keeps the previous behaviour:
283
- * its `####` sub-sections fold back into its own body rather than splitting
284
- * into phantom findings, so existing flat reports parse exactly as before.
331
+ * @param {{ title: string, bodyLines: string[] }} parent
332
+ * @param {Array<{ title: string, bodyLines: string[] }>} children
333
+ * @returns {boolean}
334
+ */
335
+ function isGroupingHeader(parent, children) {
336
+ if (!isEmptyBlock(parent)) return false;
337
+ if (TITLE_ANCHOR.test(parent.title)) return false;
338
+ if (children.length === 0) return true;
339
+ if (children.some((child) => TITLE_ANCHOR.test(child.title))) return true;
340
+ return children.filter((child) => carriesSeverity(child)).length > 1;
341
+ }
342
+
343
+ /**
344
+ * Fold the flat heading stream into findings: emit a grouping header's
345
+ * children, fold a finding's sub-sections into its own body, and drop every
346
+ * block that declares nothing.
285
347
  *
286
348
  * @param {Array<{ level: number, title: string, bodyLines: string[] }>} blocks
287
349
  * @returns {Array<{ level: number, title: string, bodyLines: string[] }>}
288
350
  */
289
351
  function foldGroupingHeaders(blocks) {
290
352
  const out = [];
291
- let parent = null;
292
- for (const block of blocks) {
293
- if (block.level <= 3) {
294
- parent = block;
295
- out.push(block);
353
+ const keep = (block) => {
354
+ if (!isEmptyBlock(block)) out.push(block);
355
+ };
356
+
357
+ let i = 0;
358
+ while (i < blocks.length) {
359
+ const block = blocks[i];
360
+ if (block.level > 3) {
361
+ // A `####` with no `###` parent above it — read it on its own terms.
362
+ keep(block);
363
+ i += 1;
296
364
  continue;
297
365
  }
298
- if (!parent) {
299
- out.push(block);
300
- continue;
366
+ const children = [];
367
+ let j = i + 1;
368
+ while (j < blocks.length && blocks[j].level > 3) {
369
+ children.push(blocks[j]);
370
+ j += 1;
301
371
  }
302
- if (carriesSeverity(parent)) {
303
- parent.bodyLines.push(`#### ${block.title}`, ...block.bodyLines);
304
- continue;
372
+ if (isGroupingHeader(block, children)) {
373
+ for (const child of children) keep(child);
374
+ } else {
375
+ for (const child of children) {
376
+ block.bodyLines.push(`#### ${child.title}`, ...child.bodyLines);
377
+ }
378
+ keep(block);
305
379
  }
306
- parent.isGroupingHeader = true;
307
- out.push(block);
380
+ i = j;
308
381
  }
309
- return out.filter((block) => !block.isGroupingHeader);
382
+ return out;
310
383
  }
311
384
 
312
385
  /**
313
- * Read the machine-readable severity tally the report envelope mandates in its
314
- * `## Executive Summary`:
386
+ * Slice the `## Executive Summary` section out of a report, or `null` when the
387
+ * report declares none. The tally the cross-check trusts is the one the report
388
+ * envelope mandates *there*; a `Severity tally:` string anywhere else is prose
389
+ * quoting the format, not a declaration.
315
390
  *
316
- * ```text
317
- * Severity tally: Critical 0 / High 2 / Medium 1 / Low 0
318
- * ```
391
+ * @param {string} markdown
392
+ * @returns {{ start: number, end: number }|null} character offsets.
393
+ */
394
+ function executiveSummaryRange(markdown) {
395
+ const heading = /^##\s+executive\s+summary\s*$/gim;
396
+ const opened = heading.exec(markdown);
397
+ if (!opened) return null;
398
+ const start = opened.index + opened[0].length;
399
+ const next = /^##\s+/gm;
400
+ next.lastIndex = start;
401
+ const closed = next.exec(markdown);
402
+ return { start, end: closed ? closed.index : markdown.length };
403
+ }
404
+
405
+ /**
406
+ * Read every `Severity tally:` line the report carries, scoped to its
407
+ * Executive Summary and failing closed on more than one.
319
408
  *
320
- * The line is what lets a consumer cross-check what the lens says it found
409
+ * The tally is what lets a consumer cross-check what the lens says it found
321
410
  * against what the parser actually extracted — a parse that silently drops
322
- * findings is otherwise indistinguishable from a clean report. `Info` is never
323
- * counted (the severity scale already excludes it from scheduled work).
411
+ * findings is otherwise indistinguishable from a clean report. That only holds
412
+ * while exactly one line claims to be the tally. A report whose prose quotes
413
+ * the format a second time (a remediation section restating a lens's output,
414
+ * say) used to have whichever line the regex reached first silently adopted as
415
+ * the declaration, so the cross-check compared the parse against an arbitrary
416
+ * one of two numbers. Both are now reported and the report fails.
417
+ *
418
+ * `Info` is never counted (the severity scale already excludes it from
419
+ * scheduled work).
324
420
  *
325
421
  * @param {string} markdown — full report text.
326
- * @returns {{ critical: number, high: number, medium: number, low: number }|null}
327
- * `null` when the report declares no tally at all.
422
+ * @returns {{ tally: {critical:number,high:number,medium:number,low:number}|null,
423
+ * matches: string[], duplicate: boolean }}
328
424
  */
329
- export function parseSeverityTally(markdown) {
330
- if (typeof markdown !== 'string') return null;
331
- const match = TALLY_LINE.exec(markdown);
332
- if (!match) return null;
425
+ export function readSeverityTally(markdown) {
426
+ if (typeof markdown !== 'string') {
427
+ return { tally: null, matches: [], duplicate: false };
428
+ }
429
+ const range = executiveSummaryRange(markdown);
430
+ const matches = [];
431
+ TALLY_LINE_GLOBAL.lastIndex = 0;
432
+ for (const hit of markdown.matchAll(TALLY_LINE_GLOBAL)) {
433
+ // Outside an Executive Summary the whole document is the scope; with one,
434
+ // a line beyond it is a stray that must not be adopted silently.
435
+ matches.push({ text: hit[0].trim(), groups: hit, index: hit.index });
436
+ }
437
+ const scoped = range
438
+ ? matches.filter((m) => m.index >= range.start && m.index < range.end)
439
+ : matches;
440
+ if (matches.length === 0) {
441
+ return { tally: null, matches: [], duplicate: false };
442
+ }
443
+ if (matches.length > 1) {
444
+ return {
445
+ tally: null,
446
+ matches: matches.map((m) => m.text),
447
+ duplicate: true,
448
+ };
449
+ }
450
+ const [only] = scoped.length > 0 ? scoped : matches;
333
451
  return {
334
- critical: Number(match[1]),
335
- high: Number(match[2]),
336
- medium: Number(match[3]),
337
- low: Number(match[4]),
452
+ tally: {
453
+ critical: Number(only.groups[1]),
454
+ high: Number(only.groups[2]),
455
+ medium: Number(only.groups[3]),
456
+ low: Number(only.groups[4]),
457
+ },
458
+ matches: [only.text],
459
+ duplicate: false,
338
460
  };
339
461
  }
340
462
 
@@ -446,7 +568,10 @@ export function parseAuditReports(reports, { repoRoot } = {}) {
446
568
  }
447
569
 
448
570
  export const __testing = {
571
+ carriesFieldBullet,
449
572
  carriesSeverity,
573
+ isEmptyBlock,
574
+ isGroupingHeader,
450
575
  foldGroupingHeaders,
451
576
  normaliseSeverity,
452
577
  extractFilePaths,
@@ -179,6 +179,56 @@ function mergeStamps(base, ours, theirs) {
179
179
  return { merged, conflicts };
180
180
  }
181
181
 
182
+ /**
183
+ * The row half of a 3-way merge, shared by the envelope path and the plain
184
+ * row-baseline path below. Both merge a set of rows keyed by identity against
185
+ * a common ancestor; they differ only in where the identity comes from and in
186
+ * what they assemble around the result.
187
+ *
188
+ * @param {{
189
+ * baseEnv: object,
190
+ * ours: object|null|undefined,
191
+ * theirs: object|null|undefined,
192
+ * rowIdentity: (row: object) => string,
193
+ * }} params
194
+ * @returns {{ rows: Array<object>, conflicts: Array<object> }}
195
+ */
196
+ function mergeRowSets({ baseEnv, ours, theirs, rowIdentity }) {
197
+ const baseRows = indexRows(baseEnv?.rows, rowIdentity, 'base');
198
+ const ourRows = indexRows(ours?.rows, rowIdentity, 'ours');
199
+ const theirRows = indexRows(theirs?.rows, rowIdentity, 'theirs');
200
+
201
+ const conflicts = [];
202
+ const rows = [];
203
+ const identities = new Set([
204
+ ...ourRows.keys(),
205
+ ...theirRows.keys(),
206
+ ...baseRows.keys(),
207
+ ]);
208
+ for (const id of identities) {
209
+ const b = baseRows.get(id);
210
+ const o = ourRows.get(id);
211
+ const t = theirRows.get(id);
212
+ const pick = choose(b, o, t);
213
+ if (pick.conflict) {
214
+ conflicts.push({
215
+ scope: 'row',
216
+ identity: id,
217
+ base: b,
218
+ ours: o,
219
+ theirs: t,
220
+ });
221
+ // Keep the ours-side value so the row set stays well-formed; the
222
+ // driver renders the conflict markers around it from this record.
223
+ if (o !== undefined) rows.push(o);
224
+ else if (t !== undefined) rows.push(t);
225
+ continue;
226
+ }
227
+ if (pick.value !== undefined) rows.push(pick.value);
228
+ }
229
+ return { rows, conflicts };
230
+ }
231
+
182
232
  /**
183
233
  * 3-way merge two baseline envelopes against their common ancestor.
184
234
  *
@@ -217,38 +267,12 @@ export function mergeEnvelopes({
217
267
  const mod = getKindModule(resolvedKind);
218
268
  const baseEnv = base && typeof base === 'object' ? base : { rows: [] };
219
269
 
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
- }
270
+ const { rows, conflicts } = mergeRowSets({
271
+ baseEnv,
272
+ ours,
273
+ theirs,
274
+ rowIdentity: mod.rowIdentity,
275
+ });
252
276
 
253
277
  const sortedRows = mod.sortRows(rows);
254
278
  const { merged: stamps, conflicts: stampConflicts } = mergeStamps(
@@ -270,3 +294,245 @@ export function mergeEnvelopes({
270
294
  conflicts: [...stampConflicts, ...conflicts],
271
295
  };
272
296
  }
297
+
298
+ /**
299
+ * Emit the object's keys in a declared order, then anything left over.
300
+ *
301
+ * The invariant this serves is "a clean driver merge stays byte-identical to
302
+ * a regeneration": both generators below write
303
+ * `JSON.stringify(envelope, null, 2)`, which preserves insertion order, so a
304
+ * merged file whose keys are in a different order than the generator's is a
305
+ * spurious diff on every subsequent refresh. Unknown keys are appended rather
306
+ * than dropped — a merge is never the place to lose a field.
307
+ *
308
+ * @param {object} obj
309
+ * @param {string[]} order
310
+ * @returns {object}
311
+ */
312
+ function orderKeys(obj, order) {
313
+ const out = {};
314
+ for (const key of order) {
315
+ if (obj[key] !== undefined) out[key] = obj[key];
316
+ }
317
+ for (const key of Object.keys(obj)) {
318
+ if (out[key] === undefined && obj[key] !== undefined) out[key] = obj[key];
319
+ }
320
+ return out;
321
+ }
322
+
323
+ /**
324
+ * Derive `cyclomatic`'s rollup from its merged rows — the same arithmetic
325
+ * `cyclomatic-ceiling.js#buildCyclomaticEnvelope` applies at generation time.
326
+ *
327
+ * Deriving rather than merging matters more here than anywhere: the rollup is
328
+ * three counts over the whole row set, so two branches that each add one row
329
+ * BOTH write `filesAboveCeiling: n + 1`. A 3-way merge sees two identical
330
+ * values and resolves them clean — to a number that is wrong by one, with no
331
+ * conflict and no evidence. That is the silent splice this module exists to
332
+ * refuse, compressed into a single field.
333
+ *
334
+ * @param {Array<object>} rows
335
+ * @returns {{ '*': { filesAboveCeiling: number, methodsAboveCeiling: number, maxCyclomatic: number } }}
336
+ */
337
+ function cyclomaticRollup(rows) {
338
+ let methods = 0;
339
+ let max = 0;
340
+ for (const row of rows) {
341
+ methods += Number(row?.methodsAboveCeiling ?? 0);
342
+ const rowMax = Number(row?.maxCyclomatic ?? 0);
343
+ if (rowMax > max) max = rowMax;
344
+ }
345
+ return {
346
+ '*': {
347
+ filesAboveCeiling: rows.length,
348
+ methodsAboveCeiling: methods,
349
+ maxCyclomatic: max,
350
+ },
351
+ };
352
+ }
353
+
354
+ /**
355
+ * Baselines that are row sets but NOT per-kind envelopes (Story #5277).
356
+ *
357
+ * `baselines/*.json` matches more than the eight kernel kinds. Three of the
358
+ * extras are row sets with a real identity, and before this registry the
359
+ * driver handed all three straight back to `git merge-file` — so `.gitattributes`
360
+ * declared them driver-merged while they were still text-merged, with exactly
361
+ * the two failure modes the driver replaces. `dead-exports*.json` in particular
362
+ * is a long, uniform list of two-key objects: the shape git splices most
363
+ * happily and least visibly.
364
+ *
365
+ * `rollup: null` is a genuine absence, not an omission — the dead-export
366
+ * generator writes no rollup key at all, so deriving one would invent a field
367
+ * the checker never reads and the generator would strip on the next refresh.
368
+ * `cyclomatic` does carry one, and derives it (see {@link cyclomaticRollup}).
369
+ *
370
+ * `keyOrder` mirrors each generator's own insertion order; see
371
+ * {@link orderKeys}.
372
+ */
373
+ const PLAIN_BASELINE_KINDS = Object.freeze({
374
+ cyclomatic: Object.freeze({
375
+ rowIdentity: (row) => String(row?.file ?? ''),
376
+ sortRows: (rows) =>
377
+ [...rows].sort((a, b) => String(a.file).localeCompare(String(b.file))),
378
+ rollup: cyclomaticRollup,
379
+ keyOrder: ['$schema', 'generatedAt', 'ceiling', 'rollup', 'rows'],
380
+ }),
381
+ 'dead-exports': Object.freeze({
382
+ rowIdentity: (row) => `${row?.file ?? ''}::${row?.symbol ?? ''}`,
383
+ sortRows: (rows) =>
384
+ [...rows].sort(
385
+ (a, b) =>
386
+ String(a.file).localeCompare(String(b.file)) ||
387
+ String(a.symbol).localeCompare(String(b.symbol)),
388
+ ),
389
+ rollup: null,
390
+ keyOrder: ['$schema', 'kernelVersion', 'generatedAt', 'mode', 'rows'],
391
+ }),
392
+ });
393
+
394
+ /** `dead-exports.json` and `dead-exports-production.json` share a `$schema`. */
395
+ const PLAIN_SCHEMA_KINDS = Object.freeze({
396
+ 'cyclomatic.schema.json': 'cyclomatic',
397
+ 'dead-exports.schema.json': 'dead-exports',
398
+ });
399
+
400
+ /**
401
+ * Identify a plain row baseline from its `$schema`, or `null`.
402
+ *
403
+ * The production dead-export pass reports as its own kind so the regenerate
404
+ * remedy can name the right command, even though the two share a schema and
405
+ * merge identically.
406
+ *
407
+ * @param {unknown} envelope
408
+ * @returns {string|null}
409
+ */
410
+ export function plainKindFromEnvelope(envelope) {
411
+ const ref = envelope?.$schema;
412
+ if (typeof ref !== 'string') return null;
413
+ const kind = PLAIN_SCHEMA_KINDS[ref.split('/').pop()] ?? null;
414
+ if (kind === 'dead-exports' && envelope?.mode === 'production') {
415
+ return 'dead-exports-production';
416
+ }
417
+ return kind;
418
+ }
419
+
420
+ /** The merge spec for a plain kind, collapsing the two dead-export passes. */
421
+ function plainSpec(kind) {
422
+ return PLAIN_BASELINE_KINDS[
423
+ kind === 'dead-exports-production' ? 'dead-exports' : kind
424
+ ];
425
+ }
426
+
427
+ /**
428
+ * 3-way merge a plain row baseline by row identity.
429
+ *
430
+ * Same contract as {@link mergeEnvelopes} — rows merge by identity, stamps
431
+ * merge 3-way, `generatedAt` resolves to the later of the two — minus the
432
+ * kernel protocol these kinds do not participate in.
433
+ *
434
+ * @param {{ base: object|null, ours: object, theirs: object, kind?: string }} params
435
+ * @returns {{ kind: string, envelope: object, conflicts: Array<object> }}
436
+ */
437
+ export function mergePlainBaseline({ base, ours, theirs, kind } = {}) {
438
+ const resolvedKind =
439
+ kind ?? plainKindFromEnvelope(ours) ?? plainKindFromEnvelope(theirs);
440
+ const spec = resolvedKind ? plainSpec(resolvedKind) : undefined;
441
+ if (!spec) {
442
+ throw new Error(
443
+ 'mergePlainBaseline: could not resolve a known plain baseline kind from the envelopes',
444
+ );
445
+ }
446
+ const baseEnv = base && typeof base === 'object' ? base : { rows: [] };
447
+ const { rows, conflicts } = mergeRowSets({
448
+ baseEnv,
449
+ ours,
450
+ theirs,
451
+ rowIdentity: spec.rowIdentity,
452
+ });
453
+ const sortedRows = spec.sortRows(rows);
454
+ const { merged: stamps, conflicts: stampConflicts } = mergeStamps(
455
+ baseEnv,
456
+ ours ?? {},
457
+ theirs ?? {},
458
+ );
459
+ const envelope = orderKeys(
460
+ {
461
+ ...stamps,
462
+ generatedAt: laterStamp(ours?.generatedAt, theirs?.generatedAt),
463
+ ...(spec.rollup ? { rollup: spec.rollup(sortedRows) } : {}),
464
+ rows: sortedRows,
465
+ },
466
+ spec.keyOrder,
467
+ );
468
+ return {
469
+ kind: resolvedKind,
470
+ envelope,
471
+ conflicts: [...stampConflicts, ...conflicts],
472
+ };
473
+ }
474
+
475
+ /** Per-kind regeneration commands; anything unlisted follows the convention. */
476
+ const REGENERATE_COMMANDS = Object.freeze({
477
+ 'dead-exports': 'npm run dead-exports:update',
478
+ 'dead-exports-production': 'npm run dead-exports:update',
479
+ cyclomatic: 'npm run cyclomatic:update',
480
+ });
481
+
482
+ /**
483
+ * The command that re-derives a baseline from the tree.
484
+ *
485
+ * A conflicted merge leaves the rollup describing a row set nobody scored:
486
+ * the driver derives it from rows that still carry conflict markers around
487
+ * them, and no hand-resolution of those markers updates it. Resolving the
488
+ * rows is therefore only half the job, and the half operators skip — so the
489
+ * driver says the other half out loud rather than leaving a plausible-looking
490
+ * number behind.
491
+ *
492
+ * @param {string} kind
493
+ * @returns {string}
494
+ */
495
+ export function baselineRegenerateRemedy(kind) {
496
+ return REGENERATE_COMMANDS[kind] ?? `npm run ${kind}:update`;
497
+ }
498
+
499
+ /**
500
+ * Wrap each conflicted envelope-level stamp in git conflict markers.
501
+ *
502
+ * Envelope conflicts used to be reported on stderr alone, and the file was
503
+ * left holding the ours-side value with no marker in it. Git reads the
504
+ * driver's non-zero exit as "conflicted" and leaves the path unmerged, so the
505
+ * operator opens a file that looks cleanly merged and has to reconstruct from
506
+ * a scrollback line which stamp disagreed. A `kernelVersion` double-bump is
507
+ * exactly the case where the two sides mean different scorers and picking one
508
+ * silently is wrong.
509
+ *
510
+ * Operates on the canonical text the merged projection already produced, so
511
+ * every unconflicted line stays byte-identical to a clean merge.
512
+ *
513
+ * @param {string} text Canonical serialization of the merged envelope.
514
+ * @param {Array<{ identity: string, ours?: unknown, theirs?: unknown }>} conflicts
515
+ * @returns {string}
516
+ */
517
+ export function renderStampConflict(text, conflicts) {
518
+ let out = text;
519
+ for (const conflict of conflicts) {
520
+ const key = conflict?.identity;
521
+ if (typeof key !== 'string' || key.length === 0) continue;
522
+ const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
523
+ const pattern = new RegExp(`^([ \\t]*)"${escaped}"(: .*)$`, 'm');
524
+ const match = pattern.exec(out);
525
+ if (!match) continue;
526
+ const [line, indent, tail] = match;
527
+ const comma = tail.endsWith(',') ? ',' : '';
528
+ const render = (value) =>
529
+ value === undefined
530
+ ? ''
531
+ : `${indent}${JSON.stringify(key)}: ${JSON.stringify(value)}${comma}\n`;
532
+ out = out.replace(
533
+ line,
534
+ `<<<<<<< ours\n${render(conflict.ours)}=======\n${render(conflict.theirs)}>>>>>>> theirs`,
535
+ );
536
+ }
537
+ return out;
538
+ }