mandrel 2.53.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.
- package/.agents/agents/story-worker.md +24 -23
- package/.agents/audit-checklists/accessibility.md +0 -3
- package/.agents/audit-checklists/mobile.md +0 -4
- package/.agents/docs/agentrc-reference.json +4 -2
- package/.agents/docs/configuration.md +2 -0
- package/.agents/schemas/agentrc.schema.json +15 -1
- package/.agents/schemas/lifecycle/merge.unlanded.schema.json +2 -1
- package/.agents/schemas/story-deliver-terminal.schema.json +1 -0
- package/.agents/scripts/audit-to-stories.js +158 -7
- package/.agents/scripts/check-audit-attribution.js +119 -62
- package/.agents/scripts/check-test-portability.js +512 -0
- package/.agents/scripts/coverage-capture.js +17 -10
- package/.agents/scripts/evidence-gate.js +31 -4
- package/.agents/scripts/generate-workflows-doc.js +65 -14
- package/.agents/scripts/git-cleanup.js +4 -0
- package/.agents/scripts/lib/ITicketingProvider.js +78 -0
- package/.agents/scripts/lib/audit-advisories.js +195 -0
- package/.agents/scripts/lib/audit-attribution.js +22 -0
- package/.agents/scripts/lib/audit-to-stories/dedupe-against-github.js +68 -5
- package/.agents/scripts/lib/audit-to-stories/issue-index.js +83 -0
- package/.agents/scripts/lib/audit-to-stories/ledger-commit.js +60 -114
- package/.agents/scripts/lib/audit-to-stories/ledger-pr.js +347 -0
- package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +169 -44
- package/.agents/scripts/lib/baselines/merge-envelopes.js +298 -32
- package/.agents/scripts/lib/bootstrap/baseline-merge-driver.js +180 -14
- package/.agents/scripts/lib/cli-args.js +26 -0
- package/.agents/scripts/lib/close-validation/gates.js +113 -7
- package/.agents/scripts/lib/close-validation/process.js +7 -3
- package/.agents/scripts/lib/close-validation/runner.js +62 -11
- package/.agents/scripts/lib/config/ci.js +28 -9
- package/.agents/scripts/lib/config-settings-schema-delivery.js +7 -0
- package/.agents/scripts/lib/config-settings-schema.js +19 -1
- package/.agents/scripts/lib/coverage-capture-fullscope.js +23 -11
- package/.agents/scripts/lib/coverage-capture-incremental.js +22 -16
- package/.agents/scripts/lib/coverage-capture-usage.js +5 -1
- package/.agents/scripts/lib/coverage-capture.js +77 -3
- package/.agents/scripts/lib/findings/route-finding.js +4 -2
- package/.agents/scripts/lib/full-suite-lock.js +232 -6
- package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
- package/.agents/scripts/lib/git/sync-from-base.js +130 -13
- package/.agents/scripts/lib/observability/source-classifier.js +1 -0
- package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +10 -2
- package/.agents/scripts/lib/orchestration/check-baselines/phases/refresh-ack.js +75 -15
- package/.agents/scripts/lib/orchestration/deliver-recover.js +82 -43
- package/.agents/scripts/lib/orchestration/dependency-candidates.js +8 -4
- package/.agents/scripts/lib/orchestration/epic-candidates.js +9 -4
- package/.agents/scripts/lib/orchestration/epic-container.js +66 -4
- package/.agents/scripts/lib/orchestration/epic-rollup.js +241 -84
- package/.agents/scripts/lib/orchestration/file-assumptions.js +218 -16
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/branches.js +93 -7
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes.js +22 -6
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/parse-args.js +26 -5
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +13 -2
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +35 -5
- package/.agents/scripts/lib/orchestration/merge-block-class.js +18 -3
- package/.agents/scripts/lib/orchestration/merge-poll.js +284 -40
- package/.agents/scripts/lib/orchestration/plan-persist/epic-adoption.js +49 -2
- package/.agents/scripts/lib/orchestration/plan-persist/epic-ops.js +43 -7
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +24 -1
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +5 -0
- package/.agents/scripts/lib/orchestration/plan-persist/summary.js +3 -0
- package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +119 -6
- package/.agents/scripts/lib/orchestration/plan-persist/wave-serialisation.js +110 -0
- package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +130 -40
- package/.agents/scripts/lib/orchestration/resolve-stories.js +44 -1
- package/.agents/scripts/lib/orchestration/review-providers/native.js +31 -11
- package/.agents/scripts/lib/orchestration/review-providers/scoped-lint.js +27 -24
- package/.agents/scripts/lib/orchestration/run-epilogue.js +59 -38
- package/.agents/scripts/lib/orchestration/single-story-close/close-note.js +81 -0
- package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +40 -51
- package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +10 -2
- package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +101 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +351 -28
- package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +27 -6
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +117 -22
- package/.agents/scripts/lib/orchestration/story-close/baseline-upward-writeback.js +94 -12
- package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +6 -1
- package/.agents/scripts/lib/orchestration/ticket-validator.js +25 -14
- package/.agents/scripts/lib/orchestration/ticketing/bulk.js +70 -6
- package/.agents/scripts/lib/orchestration/verify-credit.js +37 -0
- package/.agents/scripts/lib/pinned-override-notes.js +41 -53
- package/.agents/scripts/lib/pinned-override-resolve.js +212 -0
- package/.agents/scripts/lib/qa/resolve-qa-contract.js +18 -0
- package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +173 -9
- package/.agents/scripts/lib/skills/walk-skill-files.js +24 -7
- package/.agents/scripts/lib/test-temp.js +167 -30
- package/.agents/scripts/lib/validation-evidence.js +37 -0
- package/.agents/scripts/lib/wave-runner/footprint.js +167 -14
- package/.agents/scripts/lib/wave-runner/live-probe.js +7 -1
- package/.agents/scripts/lib/wave-runner/ready-set.js +1 -1
- package/.agents/scripts/merge-baseline.js +175 -21
- package/.agents/scripts/providers/github/errors.js +22 -1
- package/.agents/scripts/providers/github/issues.js +106 -1
- package/.agents/scripts/providers/github/sub-issue-add.js +18 -1
- package/.agents/scripts/providers/github.js +6 -0
- package/.agents/scripts/resolve-stories.js +44 -34
- package/.agents/scripts/single-story-close.js +5 -0
- package/.agents/scripts/stories-wave-tick.js +37 -13
- package/.agents/templates/docs/audit-sweep-runbook.md +41 -7
- package/.agents/workflows/audit-accessibility.md +16 -31
- package/.agents/workflows/audit-mobile.md +20 -37
- package/.agents/workflows/git-cleanup.md +17 -3
- package/.agents/workflows/helpers/audit-lens-core.md +45 -0
- package/.agents/workflows/helpers/deliver-digest.md +7 -6
- package/.agents/workflows/helpers/deliver-reference.md +40 -16
- package/.agents/workflows/helpers/deliver-story-reference.md +7 -4
- package/.agents/workflows/helpers/deliver-story.md +15 -12
- package/.agents/workflows/helpers/plan-reference.md +8 -1
- package/.agents/workflows/mandrel-plan.md +4 -7
- package/.agents/workflows/memory-consolidate.md +14 -9
- package/docs/CHANGELOG.md +34 -0
- package/lib/cli/registry.js +64 -21
- package/lib/cli/sync.js +27 -2
- 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
|
-
|
|
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 =
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
277
|
-
*
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
*
|
|
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
|
-
*
|
|
283
|
-
*
|
|
284
|
-
*
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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 (
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
|
|
307
|
-
out.push(block);
|
|
380
|
+
i = j;
|
|
308
381
|
}
|
|
309
|
-
return out
|
|
382
|
+
return out;
|
|
310
383
|
}
|
|
311
384
|
|
|
312
385
|
/**
|
|
313
|
-
*
|
|
314
|
-
*
|
|
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
|
-
*
|
|
317
|
-
*
|
|
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
|
|
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.
|
|
323
|
-
*
|
|
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:
|
|
327
|
-
*
|
|
422
|
+
* @returns {{ tally: {critical:number,high:number,medium:number,low:number}|null,
|
|
423
|
+
* matches: string[], duplicate: boolean }}
|
|
328
424
|
*/
|
|
329
|
-
export function
|
|
330
|
-
if (typeof markdown !== 'string')
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
+
}
|