@ia-qa/self-healing 1.7.18 → 1.7.20

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/dist/aom.js CHANGED
@@ -88,12 +88,26 @@ function depthMismatch(baseline, current) {
88
88
  }
89
89
  /** The sentence every door prints or attaches when `depthMismatch` returns non-null. */
90
90
  function depthMismatchMessage(m) {
91
- const deeper = m.baseline > m.current ? 'baseline' : 'current capture';
92
- const shallower = deeper === 'baseline' ? 'current capture' : 'baseline';
93
- return (`Exploration depth mismatch the ${deeper} was captured with \`map --deep\`, the ${shallower} ` +
91
+ const baselineDeeper = m.baseline > m.current;
92
+ const deeper = baselineDeeper ? 'baseline' : 'current capture';
93
+ const shallower = baselineDeeper ? 'current capture' : 'baseline';
94
+ const head = `Exploration depth mismatch — the ${deeper} was captured with \`map --deep\`, the ${shallower} ` +
94
95
  `was not (depth ${m.baseline} vs ${m.current}). A deep contract holds the elements behind ` +
95
- `menus, tabs and dialogs; a shallow one cannot, so those elements read as \`lost\` here. Most ` +
96
- `of this verdict is probably that, not drift in your app. Capture both sides the same way.`);
96
+ `menus, tabs and dialogs; a shallow one cannot. `;
97
+ // What follows must describe what the tool then DOES, and since 1.7.17 that changed:
98
+ // those elements are held out of the verdict rather than counted `lost`. The old wording
99
+ // survived the change and contradicted the line printed a few rows below it in the same
100
+ // run — the precise failure `depthDelta.ts` warns about, two readers of one run made to
101
+ // disagree. Branched, because only the deep-baseline direction holds anything out: the
102
+ // reverse produces `added` rows, which never moved a verdict.
103
+ return baselineDeeper
104
+ ? head +
105
+ `Those elements are held OUT of this verdict rather than counted as \`lost\` — neither ` +
106
+ `present nor gone, simply not measured, and listed below. Capture both sides the same ` +
107
+ `way to gate them.`
108
+ : head +
109
+ `The extra elements the deep side found appear as \`added\`, which never moves a verdict, ` +
110
+ `so this comparison stands. Capture both sides the same way to gate them too.`;
97
111
  }
98
112
  async function extractInteractiveElements(page) {
99
113
  return page.evaluate(extract_1.extractInPage);
package/dist/aom.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"aom.js","sourceRoot":"","sources":["../src/aom.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"aom.js","sourceRoot":"","sources":["../src/aom.ts"],"names":[],"mappings":"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"}
@@ -100,6 +100,58 @@ export declare function runDiff(args: string[]): Promise<void>;
100
100
  * counts. A verdict that changed without a line saying why is the silent behaviour this
101
101
  * package treats as a defect, and here the silence would be a green one.
102
102
  */
103
+ /**
104
+ * The CSS-Page-Object check did not run, and the verdict above does not cover it.
105
+ *
106
+ * Printed wherever a verdict is: this is the difference between "your selectors are fine"
107
+ * and "nobody looked at your selectors", and the second one wearing the first one's green
108
+ * is the failure mode this whole package exists to refuse.
109
+ */
110
+ /**
111
+ * How to get a gate that DOES cover the suite's selectors — which depends on whether this
112
+ * project has pages to walk.
113
+ *
114
+ * `map && diff` was printed unconditionally, and on the zero-config path `init` now
115
+ * recommends it is a closed loop: `pages` is empty, so `map` exits 1 with "No pages
116
+ * configured" and points back at capture-during-run, which is where the reader already is.
117
+ * A remedy that returns you to your starting point is worse than none — it costs a run to
118
+ * find out, and it teaches that the advice is not to be trusted.
119
+ */
120
+ /**
121
+ * The exit code when a run left the suite's own selectors unjudged.
122
+ *
123
+ * Transparency fixed the honesty of the message and left the harder question open: what
124
+ * should a CI *do* with it? For a team whose Page Objects are CSS, the check that did not
125
+ * run is the whole gate — so `diff` returned exit 0 while their suite was red, and no flag
126
+ * could say otherwise.
127
+ *
128
+ * **It is `2`, not `1`, and that is the whole design.** The contract in `exitCodes.ts` is
129
+ * explicit: 1 is "answered, and the answer is no"; 2 is "could not answer — NOT a failure
130
+ * of your app, and never a pass". Bindings that were never resolved are the second, exactly.
131
+ * Returning 1 would tell a pipeline the application drifted, which is the mistake this
132
+ * package documents at length — an agent once wrote `non-zero = BLOCK` into a generated
133
+ * workflow and turned "I do not know" into a permanently red build.
134
+ *
135
+ * **Behind `--strict`, and not on by default.** Every capture-path project would otherwise
136
+ * go non-zero on upgrade, over a limitation none of them introduced; a gate nobody can
137
+ * leave on is a gate nobody turns on. `--strict` already means "tighten what counts as
138
+ * failure", so it is the lever that exists rather than a new one to discover.
139
+ *
140
+ * **A real finding outranks an unanswered question.** BLOCK (and `--strict` FIX) still
141
+ * exit 1: something WAS answered and it is bad. Only a run that would otherwise be green
142
+ * reports 2 — a green that covers less than the reader thinks.
143
+ */
144
+ export declare function bindingExitCode(failing: boolean, strict: boolean, coverage: BindingCoverage | undefined): 0 | 1 | 2;
145
+ export declare function bindingGateRemedy(hasDeclaredPages: boolean): string;
146
+ /**
147
+ * Why the process is leaving with 2 on a run whose verdict says PASS.
148
+ *
149
+ * An exit code with no sentence behind it is the thing this package refuses everywhere
150
+ * else: the reader sees a green verdict and a non-zero exit and has to guess which is
151
+ * lying. Neither is — they answer different questions.
152
+ */
153
+ export declare function printBindingStrictExit(c: BindingCoverage | undefined, json: boolean): void;
154
+ export declare function printBindingCoverage(c: BindingCoverage | undefined): void;
103
155
  export declare function printDepthUnmeasured(u: DepthUnmeasured | undefined): void;
104
156
  export declare function printSystemic(outcome: SystemicOutcome): void;
105
157
  export declare function printNameMasks(outcome: MaskOutcome | undefined): void;
@@ -117,7 +169,7 @@ export declare function printNameDrift(findings: NameDriftFinding[] | undefined)
117
169
  * and never in `--json` mode, where it would corrupt the machine-readable payload.
118
170
  * Shared with `run`, which ends on the same verdict.
119
171
  */
120
- export declare function driftSummaryLine(verb: string, verdict: 'PASS' | 'FIX' | 'BLOCK', c: Report['counts'], nameDrift?: NameDriftFinding[], bindingDrift?: BindingDrift[], depthUnmeasured?: DepthUnmeasured): void;
172
+ export declare function driftSummaryLine(verb: string, verdict: 'PASS' | 'FIX' | 'BLOCK', c: Report['counts'], nameDrift?: NameDriftFinding[], bindingDrift?: BindingDrift[], depthUnmeasured?: DepthUnmeasured, bindingCoverage?: BindingCoverage): void;
121
173
  export interface DirDiffCollection {
122
174
  reports: Array<{
123
175
  name: string;
@@ -220,6 +272,8 @@ export interface DirReport {
220
272
  * that the green it just got covers less than the contract does.
221
273
  */
222
274
  depthUnmeasured?: DepthUnmeasured;
275
+ /** The CSS-Page-Object check did not run this walk. Emitted, not merely printed. */
276
+ bindingCoverage?: BindingCoverage;
223
277
  }
224
278
  export declare function aggregateReports(reports: Array<{
225
279
  name: string;
@@ -291,4 +345,24 @@ export interface Report {
291
345
  * the same way — never "none were held out because none exist".
292
346
  */
293
347
  depthUnmeasured?: DepthUnmeasured;
348
+ /**
349
+ * The suite's own selector strings were NOT judged this run, though the inventory holds
350
+ * some. Absent means they were judged — never "there were none to judge", which is what
351
+ * silence used to mean and what made a capture-path `diff` a false green.
352
+ */
353
+ bindingCoverage?: BindingCoverage;
354
+ }
355
+ /**
356
+ * Why the CSS-Page-Object check did not run.
357
+ *
358
+ * `absent` — no `_resolved/` for this side. Capture-during-run writes contracts and no
359
+ * bindings (it has no browser at merge time), so this is the whole `run` path today.
360
+ * `stale` — one was there and was refused: it describes an older moment than the contract
361
+ * beside it, so using it would compare a live capture against a `map` from before the drift.
362
+ */
363
+ export interface BindingCoverage {
364
+ reason: 'absent' | 'stale';
365
+ /** How many selector strings the inventory holds — the size of what went unjudged. */
366
+ inventoried: number;
367
+ side: 'baseline' | 'current' | 'both';
294
368
  }
package/dist/cli/diff.js CHANGED
@@ -41,6 +41,10 @@ exports.pageShotMap = pageShotMap;
41
41
  exports.buildEvidence = buildEvidence;
42
42
  exports.writeDriftReport = writeDriftReport;
43
43
  exports.runDiff = runDiff;
44
+ exports.bindingExitCode = bindingExitCode;
45
+ exports.bindingGateRemedy = bindingGateRemedy;
46
+ exports.printBindingStrictExit = printBindingStrictExit;
47
+ exports.printBindingCoverage = printBindingCoverage;
44
48
  exports.printDepthUnmeasured = printDepthUnmeasured;
45
49
  exports.printSystemic = printSystemic;
46
50
  exports.printNameMasks = printNameMasks;
@@ -376,7 +380,7 @@ async function runDiff(args) {
376
380
  const report = attachBaselineHrefs((0, nameMask_1.diffWithMasks)(baseline, current, (0, nameMask_1.loadNameMasks)(), usage), baseline);
377
381
  // Same rule as the directory walk: the resolutions sit next to the mappings they
378
382
  // describe, so a two-file diff reaches them without being told where to look.
379
- attachBindingDrift(report, files[0], files[1], baseline.page);
383
+ attachBindingDrift(report, files[0], files[1], baseline.page, baseline, current);
380
384
  // A rename is harmless to a CSS-anchored suite and fatal to a name-anchored
381
385
  // one. Only the test inventory can tell the two apart, so the verdict is
382
386
  // raised here rather than inside the shared matcher.
@@ -388,6 +392,7 @@ async function runDiff(args) {
388
392
  else {
389
393
  printHuman(report, files, usage);
390
394
  printDepthUnmeasured(report.depthUnmeasured);
395
+ printBindingCoverage(report.bindingCoverage);
391
396
  printNameMasks(report.nameMask);
392
397
  printNameDrift(report.nameDrift);
393
398
  }
@@ -405,9 +410,11 @@ async function runDiff(args) {
405
410
  }
406
411
  (0, emit_1.recordDriftRun)('diff', singleAgg, { baseUrl: (0, config_2.configuredBaseUrl)() });
407
412
  const failing = report.verdict === 'BLOCK' || (strict && report.verdict === 'FIX');
408
- process.exitCode = failing ? 1 : 0;
413
+ process.exitCode = bindingExitCode(failing, strict, report.bindingCoverage);
414
+ if (process.exitCode === 2)
415
+ printBindingStrictExit(report.bindingCoverage, json);
409
416
  if (!json)
410
- driftSummaryLine('diff', report.verdict, report.counts, report.nameDrift, report.bindingDrift, report.depthUnmeasured);
417
+ driftSummaryLine('diff', report.verdict, report.counts, report.nameDrift, report.bindingDrift, report.depthUnmeasured, report.bindingCoverage);
411
418
  }
412
419
  /**
413
420
  * What `nameMask` did, in two registers.
@@ -440,6 +447,111 @@ async function runDiff(args) {
440
447
  * counts. A verdict that changed without a line saying why is the silent behaviour this
441
448
  * package treats as a defect, and here the silence would be a green one.
442
449
  */
450
+ /**
451
+ * The CSS-Page-Object check did not run, and the verdict above does not cover it.
452
+ *
453
+ * Printed wherever a verdict is: this is the difference between "your selectors are fine"
454
+ * and "nobody looked at your selectors", and the second one wearing the first one's green
455
+ * is the failure mode this whole package exists to refuse.
456
+ */
457
+ /**
458
+ * How to get a gate that DOES cover the suite's selectors — which depends on whether this
459
+ * project has pages to walk.
460
+ *
461
+ * `map && diff` was printed unconditionally, and on the zero-config path `init` now
462
+ * recommends it is a closed loop: `pages` is empty, so `map` exits 1 with "No pages
463
+ * configured" and points back at capture-during-run, which is where the reader already is.
464
+ * A remedy that returns you to your starting point is worse than none — it costs a run to
465
+ * find out, and it teaches that the advice is not to be trusted.
466
+ */
467
+ /**
468
+ * The exit code when a run left the suite's own selectors unjudged.
469
+ *
470
+ * Transparency fixed the honesty of the message and left the harder question open: what
471
+ * should a CI *do* with it? For a team whose Page Objects are CSS, the check that did not
472
+ * run is the whole gate — so `diff` returned exit 0 while their suite was red, and no flag
473
+ * could say otherwise.
474
+ *
475
+ * **It is `2`, not `1`, and that is the whole design.** The contract in `exitCodes.ts` is
476
+ * explicit: 1 is "answered, and the answer is no"; 2 is "could not answer — NOT a failure
477
+ * of your app, and never a pass". Bindings that were never resolved are the second, exactly.
478
+ * Returning 1 would tell a pipeline the application drifted, which is the mistake this
479
+ * package documents at length — an agent once wrote `non-zero = BLOCK` into a generated
480
+ * workflow and turned "I do not know" into a permanently red build.
481
+ *
482
+ * **Behind `--strict`, and not on by default.** Every capture-path project would otherwise
483
+ * go non-zero on upgrade, over a limitation none of them introduced; a gate nobody can
484
+ * leave on is a gate nobody turns on. `--strict` already means "tighten what counts as
485
+ * failure", so it is the lever that exists rather than a new one to discover.
486
+ *
487
+ * **A real finding outranks an unanswered question.** BLOCK (and `--strict` FIX) still
488
+ * exit 1: something WAS answered and it is bad. Only a run that would otherwise be green
489
+ * reports 2 — a green that covers less than the reader thinks.
490
+ */
491
+ function bindingExitCode(failing, strict, coverage) {
492
+ if (failing)
493
+ return 1;
494
+ return strict && coverage ? 2 : 0;
495
+ }
496
+ function bindingGateRemedy(hasDeclaredPages) {
497
+ return hasDeclaredPages
498
+ ? ' For a gate that covers them: `ia-qa-heal map && ia-qa-heal diff`.'
499
+ : ' To gate them you need declared pages — `map` walks a list, and this project has none:\n' +
500
+ ' ia-qa-heal discover --sitemap --apply (reads them from the app)\n' +
501
+ ' then ia-qa-heal map && ia-qa-heal diff\n' +
502
+ ' Until then this run gates elements and labels, never the selector strings.';
503
+ }
504
+ /** Does this project declare pages? Absent or unreadable config is treated as none. */
505
+ function hasDeclaredPages() {
506
+ try {
507
+ return (0, config_2.loadConfig)().pages.length > 0;
508
+ }
509
+ catch {
510
+ return false;
511
+ }
512
+ }
513
+ /**
514
+ * Why the process is leaving with 2 on a run whose verdict says PASS.
515
+ *
516
+ * An exit code with no sentence behind it is the thing this package refuses everywhere
517
+ * else: the reader sees a green verdict and a non-zero exit and has to guess which is
518
+ * lying. Neither is — they answer different questions.
519
+ */
520
+ function printBindingStrictExit(c, json) {
521
+ if (!c || json)
522
+ return;
523
+ console.error(`\n⚠️ --strict: exiting 2 because ${c.inventoried} test selector` +
524
+ `${c.inventoried === 1 ? '' : 's'} went unjudged (see above).\n` +
525
+ ` 2 means "could not answer", never "your app is broken" — the drift that WAS\n` +
526
+ ` measured is PASS. Drop \`--strict\`, or give the run a way to resolve them.`);
527
+ }
528
+ function printBindingCoverage(c) {
529
+ if (!c)
530
+ return;
531
+ const remedy = bindingGateRemedy(hasDeclaredPages());
532
+ // `side` names the side that LACKS a usable resolution, so every sentence below has to
533
+ // read that way round — "the current has one that describes this capture" said the
534
+ // opposite of what the field means.
535
+ const where = c.side === 'both' ? 'neither side has one' : `the ${c.side} side has none`;
536
+ if (c.reason === 'stale') {
537
+ console.log(`\n 🧭 Selector bindings NOT measured — ${where} that describes this capture.\n` +
538
+ ` A resolution left by an earlier \`map\` was found and refused: it is older than the\n` +
539
+ ` contract beside it, so it would have compared your live capture against the app as\n` +
540
+ ` it was BEFORE the drift — a green built out of a stale file.\n` +
541
+ ` Your ${c.inventoried} inventoried test selector${c.inventoried === 1 ? '' : 's'} ` +
542
+ `${c.inventoried === 1 ? 'is' : 'are'} not covered by the verdict above.\n` +
543
+ remedy);
544
+ return;
545
+ }
546
+ console.log(`\n 🧭 Selector bindings NOT measured — ${where}.\n` +
547
+ ` Capture-during-run records contracts, not bindings: resolving a test's own CSS\n` +
548
+ ` selectors needs a live DOM, and the merge that writes these contracts has none.\n` +
549
+ ` So the CSS-Page-Object check — the one that catches \`.btn-primary\` breaking — is\n` +
550
+ ` NOT running here, and your ${c.inventoried} inventoried selector${c.inventoried === 1 ? '' : 's'} ` +
551
+ `${c.inventoried === 1 ? 'is' : 'are'} unjudged.\n` +
552
+ ` Label drift and element drift above are real; this is what they do not cover.\n` +
553
+ remedy);
554
+ }
443
555
  function printDepthUnmeasured(u) {
444
556
  if (!u || u.selectors.length === 0)
445
557
  return;
@@ -526,7 +638,7 @@ function printNameDrift(findings) {
526
638
  * and never in `--json` mode, where it would corrupt the machine-readable payload.
527
639
  * Shared with `run`, which ends on the same verdict.
528
640
  */
529
- function driftSummaryLine(verb, verdict, c, nameDrift = [], bindingDrift = [], depthUnmeasured) {
641
+ function driftSummaryLine(verb, verdict, c, nameDrift = [], bindingDrift = [], depthUnmeasured, bindingCoverage) {
530
642
  const kind = verdict === 'PASS' ? 'ok' : verdict === 'FIX' ? 'fix' : 'block';
531
643
  const nameFixable = nameDrift.filter((f) => f.fixable).length;
532
644
  const nameManual = nameDrift.length - nameFixable;
@@ -550,10 +662,16 @@ function driftSummaryLine(verb, verdict, c, nameDrift = [], bindingDrift = [], d
550
662
  // contract has to say so here — the same rule that makes a page left out of a `run`
551
663
  // `stale` rather than a pass.
552
664
  const unmeasured = depthUnmeasured?.selectors.length ?? 0;
665
+ // "suite safe" is a claim about the SUITE, and the check that judges the suite's own
666
+ // selectors is exactly the one the capture path does not run. A PASS that says it while
667
+ // N inventoried selectors went unjudged is the sentence a CI stops reading at.
553
668
  const headline = verdict === 'PASS'
554
- ? unmeasured > 0
555
- ? `PASS · no drift in what was measured · ${unmeasured} not measured (depth mismatch)`
556
- : 'PASS · no drift, suite safe'
669
+ ? bindingCoverage
670
+ ? `PASS · no drift in what was measured · ${bindingCoverage.inventoried} test selector` +
671
+ `${bindingCoverage.inventoried === 1 ? '' : 's'} not judged`
672
+ : unmeasured > 0
673
+ ? `PASS · no drift in what was measured · ${unmeasured} not measured (depth mismatch)`
674
+ : 'PASS · no drift, suite safe'
557
675
  : verdict === 'FIX'
558
676
  ? `FIX · ${fixCause} → \`fix\``
559
677
  : `BLOCK · ${blockCause} — human needed`;
@@ -623,6 +741,7 @@ async function runDirDiff(baselineDir, currentDir, opts) {
623
741
  else {
624
742
  printAggregate(aggregate, reports, baselineDir, currentDir, usage);
625
743
  printDepthUnmeasured(aggregate.depthUnmeasured);
744
+ printBindingCoverage(aggregate.bindingCoverage);
626
745
  printSystemic(systemic);
627
746
  printNameMasks(aggregate.nameMask);
628
747
  printNameDrift(aggregate.nameDrift);
@@ -644,9 +763,11 @@ async function runDirDiff(baselineDir, currentDir, opts) {
644
763
  baseUrl: (0, config_2.configuredBaseUrl)(),
645
764
  });
646
765
  const failing = aggregate.verdict === 'BLOCK' || (opts.strict && aggregate.verdict === 'FIX');
647
- process.exitCode = failing ? 1 : 0;
766
+ process.exitCode = bindingExitCode(failing, opts.strict, aggregate.bindingCoverage);
767
+ if (process.exitCode === 2)
768
+ printBindingStrictExit(aggregate.bindingCoverage, opts.json);
648
769
  if (!opts.json)
649
- driftSummaryLine('diff', aggregate.verdict, aggregate.totals, aggregate.nameDrift, aggregate.bindingDrift, aggregate.depthUnmeasured);
770
+ driftSummaryLine('diff', aggregate.verdict, aggregate.totals, aggregate.nameDrift, aggregate.bindingDrift, aggregate.depthUnmeasured, aggregate.bindingCoverage);
650
771
  }
651
772
  /**
652
773
  * Judge the suite's own selector strings against the two moments, and fold the result
@@ -659,9 +780,36 @@ async function runDirDiff(baselineDir, currentDir, opts) {
659
780
  * `elementNow` is the contract diff's own conclusion, handed in rather than recomputed:
660
781
  * a binding verdict must never disagree with the element verdict printed beside it.
661
782
  */
662
- function attachBindingDrift(report, baselineFile, currentFile, page) {
663
- const before = (0, resolution_1.loadResolution)(page, path.join(path.dirname(baselineFile), config_1.RESOLVED_DIRNAME));
664
- const after = (0, resolution_1.loadResolution)(page, path.join(path.dirname(currentFile), config_1.RESOLVED_DIRNAME));
783
+ function attachBindingDrift(report, baselineFile, currentFile, page, baseline, current) {
784
+ const loadFor = (file, mapping) => {
785
+ const found = (0, resolution_1.loadResolution)(page, path.join(path.dirname(file), config_1.RESOLVED_DIRNAME));
786
+ if (!found)
787
+ return { resolution: null, stale: false };
788
+ // A resolution left behind by an earlier `map`, sitting next to a contract a live
789
+ // capture just rewrote, describes a page that is 34 seconds out of date — and being
790
+ // identical to the baseline's, it makes the binding diff empty. That is the false
791
+ // green, and it is why this is checked rather than trusted.
792
+ if (mapping && !(0, resolution_1.resolutionMatchesCapture)(found, mapping))
793
+ return { resolution: null, stale: true };
794
+ return { resolution: found, stale: false };
795
+ };
796
+ const b = loadFor(baselineFile, baseline);
797
+ const c = loadFor(currentFile, current);
798
+ const before = b.resolution;
799
+ const after = c.resolution;
800
+ // Not measured, and said so. The inventory is the trigger: a project that never ran
801
+ // `ingest` has no selector strings to judge and is owed no notice — but one that HAS
802
+ // them and gets no binding verdict is being handed a green over the very check the
803
+ // README opens with ("a Page Object written in CSS"). Same rule as `stale` pages and
804
+ // the depth hold-out: a thing nobody looked at is never a pass.
805
+ const inventoried = Object.keys((0, ingest_1.loadUsage)()?.selectors ?? {}).length;
806
+ if (inventoried > 0 && (!before || !after)) {
807
+ report.bindingCoverage = {
808
+ reason: b.stale || c.stale ? 'stale' : 'absent',
809
+ inventoried,
810
+ side: !before && !after ? 'both' : !before ? 'baseline' : 'current',
811
+ };
812
+ }
665
813
  if (!before || !after)
666
814
  return report;
667
815
  const bySelector = new Map(report.rows.map((r) => [r.selector, r]));
@@ -729,7 +877,7 @@ function collectDirDiff(baselineDir, currentDir) {
729
877
  const report = attachBaselineHrefs((0, nameMask_1.diffWithMasks)(baseline, current, masks, usage), baseline);
730
878
  report.nameDrift = (0, nameDrift_1.assessNameDrift)(report.rows, current.elements, usage);
731
879
  report.verdict = (0, nameDrift_1.escalateVerdict)(report.verdict, report.nameDrift);
732
- return attachBindingDrift(report, baselineFile, currentFile, page);
880
+ return attachBindingDrift(report, baselineFile, currentFile, page, baseline, current);
733
881
  };
734
882
  const layoutsDir = path.join(baselineDir, '_layouts');
735
883
  if (fs.existsSync(layoutsDir)) {
@@ -828,12 +976,15 @@ function aggregateReports(reports) {
828
976
  const bindingDrift = reports.flatMap((r) => r.report.bindingDrift ?? []);
829
977
  const nameMask = (0, nameMask_1.mergeMaskOutcomes)(reports.map((r) => r.report.nameMask));
830
978
  const depthUnmeasured = (0, depthDelta_1.mergeDepthUnmeasured)(reports.map((r) => r.report.depthUnmeasured));
979
+ // One fact about the run, not one per page: every page shares the same _resolved/ dir.
980
+ const bindingCoverage = reports.map((r) => r.report.bindingCoverage).find(Boolean);
831
981
  return {
832
982
  verdict,
833
983
  ...(nameDrift.length > 0 ? { nameDrift } : {}),
834
984
  ...(bindingDrift.length > 0 ? { bindingDrift } : {}),
835
985
  ...(nameMask ? { nameMask } : {}),
836
986
  ...(depthUnmeasured ? { depthUnmeasured } : {}),
987
+ ...(bindingCoverage ? { bindingCoverage } : {}),
837
988
  layoutVerdict,
838
989
  layoutCounts,
839
990
  pageReports: pageReports.map((r) => ({