styleproof 3.21.0 → 4.0.1

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.
@@ -20,6 +20,7 @@ import { chromium } from '@playwright/test';
20
20
  import { isHelpArg, showHelpAndExit } from '../dist/cli-errors.js';
21
21
  import { UsageError, parseCaptureUrlArgs, runCaptureUrl, loadSetupSteps } from '../dist/capture-url.js';
22
22
  import { crawlAndCapture } from '../dist/crawl-surfaces.js';
23
+ import { writeCaptureManifest } from '../dist/map-store.js';
23
24
 
24
25
  const COMMAND = 'styleproof-capture';
25
26
 
@@ -122,6 +123,9 @@ async function runCrawl() {
122
123
  console.log(` ${'·'.repeat(s.depth)}${s.key} (${s.elements} elements)${ok ? '' : ' — CAPTURE FAILED'}`),
123
124
  };
124
125
  const report = await crawlAndCapture(page, crawlOpts);
126
+ // Stamp a manifest so a two-directory diff against this crawl output has the
127
+ // same-environment guard on both sides (v4 refuses a manifest-less side).
128
+ writeCaptureManifest({ dir: opts.out, screenshots: opts.screenshots });
125
129
  console.log(
126
130
  `✓ ${report.captured}/${report.surfaces.length} surface(s) × ${crawlOpts.widths.length} width(s) → ${opts.out} ` +
127
131
  `(${report.actionsTried} actions tried, ${report.skipped} skipped${report.failed.length ? `, ${report.failed.length} capture-failed` : ''})`,
@@ -44,6 +44,8 @@ import {
44
44
  DEFAULT_REMOTE,
45
45
  assertCompatibleMapDirs,
46
46
  cleanupCachedCaptureDirs,
47
+ manifestlessError,
48
+ manifestlessSide,
47
49
  resolveCachedCaptureDirs,
48
50
  } from '../dist/map-store.js';
49
51
  import {
@@ -53,8 +55,9 @@ import {
53
55
  showHelpAndExit,
54
56
  unknownFlagMessage,
55
57
  } from '../dist/cli-errors.js';
56
- import { readInventories, surfaceElementPaths } from '../dist/capture.js';
58
+ import { readInventories, readResidue, surfaceElementPaths } from '../dist/capture.js';
57
59
  import { auditRunInventory, readAckFile } from '../dist/inventory.js';
60
+ import { auditRunResidue, readResidueAckFile } from '../dist/data-residue.js';
58
61
  import { auditCoverage, auditDeterminism, COVERAGE_LEDGER } from '../dist/coverage.js';
59
62
  import { isMapFile } from '../dist/map-store.js';
60
63
 
@@ -114,6 +117,70 @@ function printInventoryAudit(audit) {
114
117
  return unexplained.length;
115
118
  }
116
119
 
120
+ // ── data-residue guard (gate by default) ─────────────────────────────────────────
121
+ // A data-boundary request (matching `replayUrl`) that FAILED during capture means the
122
+ // captured state embedded that endpoint's fallback branch — its response-driven states
123
+ // are unproven (issue #205). Residue is recorded + warned at capture time; here the diff
124
+ // SURFACES it, and — unless the head bundle opted down to `dataResidue: 'warn'` — an
125
+ // unacknowledged failing endpoint BLOCKS (exit 1). Acknowledge intentional ones in
126
+ // styleproof.data-residue.json. (Bundles captured before this field existed read as warn.)
127
+
128
+ // `key -> reason` acknowledged failing endpoints. Malformed JSON fails loud (exit 2),
129
+ // like the inventory ack file, so a broken file can't silently un-acknowledge a failure.
130
+ function loadAcknowledgedResidue() {
131
+ try {
132
+ return readResidueAckFile();
133
+ } catch (e) {
134
+ console.error(`${COMMAND}: ${e.message}`);
135
+ process.exit(2);
136
+ }
137
+ }
138
+
139
+ // Audit the HEAD bundle's residue against the ack ledger, carrying whether the head
140
+ // ledger armed the gate. Returns null when no captured map carried residue AND the
141
+ // gate wasn't armed — so a clean healthy run prints/gates nothing (byte-identical).
142
+ function readResidueAudit(dirB, armed) {
143
+ const headResidue = readResidue(dirB);
144
+ if (!armed && !headResidue.some((m) => m.dataResidue?.length)) return null;
145
+ const acknowledged = loadAcknowledgedResidue();
146
+ return { acknowledged, ...auditRunResidue(headResidue, acknowledged, armed) };
147
+ }
148
+
149
+ // Print the Data-residue section; return the count of UNACKNOWLEDGED failing endpoints
150
+ // that BLOCK (only when the gate is armed). No-op/0 when there was nothing to audit.
151
+ /** One line per residue entry: acknowledged entries show their reason; the rest are
152
+ * marked ✗ (armed — will block) or ⚠ (warn mode). */
153
+ function residueLine(r, ackReason, armed) {
154
+ if (ackReason !== undefined) return ` ${r.surface} · ${r.endpoint} (${r.reason}) — acknowledged: ${ackReason}`;
155
+ return ` ${armed ? '✗ ' : '⚠ '}${r.surface} · ${r.endpoint} (${r.reason})${armed ? ', unacknowledged' : ''}`;
156
+ }
157
+
158
+ /** The action footer: the gate (default) names the remedy; warn mode is the opt-out. */
159
+ function residueFooter(armed, unacknowledgedCount) {
160
+ if (!unacknowledgedCount) return null;
161
+ return armed
162
+ ? ` → ${unacknowledgedCount} unacknowledged failing endpoint(s): fixture each (page.route / liveStates), acknowledge intentional ones in styleproof.data-residue.json {"<key>":"<why>"}, or opt down with \`dataResidue: "warn"\` in the capture spec.`
163
+ : ' → recorded and warned (dataResidue: "warn" — the opt-out). Remove it to restore the default gate that BLOCKS on these.';
164
+ }
165
+
166
+ function printResidueAudit(audit) {
167
+ if (!audit) return 0;
168
+ const { residue, unacknowledged, staleAcknowledgements, armed } = audit;
169
+ if (!residue.length && !staleAcknowledgements.length) {
170
+ console.log('\n🩹 Data residue: no failing data-boundary request during capture');
171
+ return 0;
172
+ }
173
+ console.log('\n🩹 Data residue (data-boundary requests that FAILED during capture — fallback branch captured):');
174
+ for (const r of residue) console.log(residueLine(r, audit.acknowledged[r.key], armed));
175
+ for (const k of staleAcknowledgements)
176
+ console.log(` ⚠ stale acknowledgement (endpoint no longer failing/present): ${k}`);
177
+ const footer = residueFooter(armed, unacknowledged.length);
178
+ if (footer) console.log(footer);
179
+ // Only an ARMED gate blocks; warn-mode surfaces without gating. A stale acknowledgement
180
+ // always blocks when armed, so the ledger can't rot (mirrors the `exclude` guard).
181
+ return armed ? unacknowledged.length + staleAcknowledgements.length : 0;
182
+ }
183
+
117
184
  // ── coverage provenance (the completeness basis of a green) ──────────────────────
118
185
  // The head bundle carries a coverage ledger (the declared registry). The gate audits
119
186
  // the ACTUALLY-captured surfaces against it, so "clean" states its basis — complete vs
@@ -268,8 +335,13 @@ let result;
268
335
  let inventoryAudit = null;
269
336
  let coverageVerdict = null;
270
337
  let determinismVerdict = null;
338
+ let residueAudit = null;
271
339
  let surfacePaths = new Map();
272
340
  try {
341
+ // v4: a side without a manifest is unsupported — the same-environment guard can't be
342
+ // enforced, so refuse (exit 2 via the catch below) rather than compare on false footing.
343
+ const manifestless = manifestlessSide(dirA, dirB);
344
+ if (manifestless) throw new Error(manifestlessError(manifestless));
273
345
  assertCompatibleMapDirs(dirA, dirB);
274
346
  result = diffStyleMapDirs(dirA, dirB);
275
347
  // Read inventory + the certification ledgers here, while the (possibly cached/restored)
@@ -279,6 +351,9 @@ try {
279
351
  const headLedger = readLedger(dirB);
280
352
  coverageVerdict = auditCoverage(capturedSurfaceKeys(dirB), headLedger);
281
353
  determinismVerdict = auditDeterminism(readLedger(dirA), headLedger);
354
+ // Data-residue: the head bundle's failing data endpoints, gated only if its ledger
355
+ // armed `dataResidue: 'gate'`. Same "read while the dirs exist" rule as the ledgers.
356
+ residueAudit = readResidueAudit(dirB, headLedger?.dataResidue === 'gate');
282
357
  // Element-path sets per surface, for the shared-chrome tier — same "read while
283
358
  // the dirs exist" rule as the ledgers above.
284
359
  surfacePaths = surfaceElementPaths(dirA, dirB);
@@ -375,6 +450,7 @@ if (chrome.length) {
375
450
  for (const cg of rest) printGroup(cg);
376
451
 
377
452
  const invRemovals = printInventoryAudit(inventoryAudit);
453
+ const residueFails = printResidueAudit(residueAudit);
378
454
  const coverageFails = printCoverageVerdict(coverageVerdict);
379
455
  const determinismFails = printDeterminismVerdict(determinismVerdict);
380
456
 
@@ -410,6 +486,16 @@ if (jsonOut) {
410
486
  inventoryNote:
411
487
  'no captured map carried an inventory — set `inventory: true` in the capture spec to arm the navigable-removal gate',
412
488
  }),
489
+ // Additive data-residue field — the head bundle's failing data endpoints, parallel
490
+ // to inventory. `null` when nothing failed and the gate wasn't armed. `armed` says
491
+ // whether `unacknowledged` blocks; `blocking` is the CI-gating count.
492
+ dataResidue: residueAudit && {
493
+ armed: residueAudit.armed,
494
+ failing: residueAudit.residue.map((r) => r.key),
495
+ unacknowledged: residueAudit.unacknowledged.map((r) => r.key),
496
+ staleAcknowledgements: residueAudit.staleAcknowledgements,
497
+ blocking: residueFails,
498
+ },
413
499
  },
414
500
  null,
415
501
  2,
@@ -427,17 +513,21 @@ const newSurfaces = surfaces.filter((s) => s.missing).length;
427
513
  const surfaceCount = surfaces.length;
428
514
  const newNote = newSurfaces ? ` (+${newSurfaces} new surface(s) with no baseline)` : '';
429
515
  const invNote = invRemovals ? ` + ${invRemovals} unacknowledged inventory removal(s)` : '';
516
+ // residueFails counts unacknowledged failing endpoints AND stale acknowledgements (both gate).
517
+ const resNote = residueFails ? ` + ${residueFails} data-residue gate failure(s) (unacknowledged or stale)` : '';
430
518
  const covNote = coverageFails ? ` + ${coverageVerdict.uncovered.length} uncaptured registered surface(s)` : '';
431
519
  const detNote = determinismFails ? ' + determinism unproven' : '';
432
- const clean = total === 0 && invRemovals === 0 && !coverageFails && !determinismFails;
520
+ const clean = total === 0 && invRemovals === 0 && residueFails === 0 && !coverageFails && !determinismFails;
433
521
  console.log(
434
522
  clean
435
523
  ? newSurfaces === 0
436
524
  ? `\n✓ 0 changed surfaces across ${compared} captured surface(s): every computed style, pseudo-element, and hover/focus/active state matches`
437
525
  : `\nℹ ${newSurfaces} new surface(s) captured with no baseline to compare — review before baselining`
438
- : `\n✗ ${counts.dom} DOM change(s), ${counts.style} computed-style difference(s), ${counts.state} state-delta difference(s) across ${surfaceCount} surfaces${newNote}${invNote}${covNote}${detNote}`,
526
+ : `\n✗ ${counts.dom} DOM change(s), ${counts.style} computed-style difference(s), ${counts.state} state-delta difference(s) across ${surfaceCount} surfaces${newNote}${invNote}${resNote}${covNote}${detNote}`,
439
527
  );
440
528
  // 0 = identical, 1 = reviewable differences (incl. unacknowledged inventory removals, an
441
- // incomplete coverage registry, or an unproven-determinism capture), 3 = only new surfaces
442
- // (no baseline). 2 = usage/capture error.
443
- process.exit(total > 0 || invRemovals > 0 || coverageFails || determinismFails ? 1 : newSurfaces > 0 ? 3 : 0);
529
+ // unacknowledged failing data endpoint under an armed residue gate, an incomplete coverage
530
+ // registry, or an unproven-determinism capture), 3 = only new surfaces (no baseline). 2 = usage.
531
+ process.exit(
532
+ total > 0 || invRemovals > 0 || residueFails > 0 || coverageFails || determinismFails ? 1 : newSurfaces > 0 ? 3 : 0,
533
+ );
@@ -19,13 +19,19 @@
19
19
  * never disturbed or accidentally reused.
20
20
  * - .github/workflows/styleproof.yml: restores reusable maps from the
21
21
  * styleproof-maps branch and only captures in CI when the maps are missing.
22
+ * - .github/workflows/styleproof-approve.yml: the issue_comment handler that
23
+ * flips the StyleProof status when a reviewer ticks "Approve all changes".
24
+ * The report workflow runs with `require-approval: true`, so without this the
25
+ * approval checkbox is inert. GitHub only runs issue_comment workflows from the
26
+ * default branch, so it takes effect once the init PR merges.
22
27
  *
23
28
  * Idempotent: re-running never overwrites an existing spec (use --force) and
24
- * never touches an existing app playwright.config.ts. Exit 0 = done (or nothing
25
- * to do), 2 = usage error.
29
+ * never touches an existing app playwright.config.ts or an existing workflow.
30
+ * Exit 0 = done (or nothing to do), 2 = usage error.
26
31
  */
27
32
  import fs from 'node:fs';
28
33
  import path from 'node:path';
34
+ import { fileURLToPath } from 'node:url';
29
35
  // Import from the leaf module, not the barrel: styleproof-init only scaffolds
30
36
  // files and never captures. Pulling `../dist/index.js` here dragged the whole
31
37
  // library — capture, crawler, report, and six Playwright-importing modules —
@@ -53,6 +59,8 @@ What it writes:
53
59
  the two diverge. Otherwise it writes one sample surface + a commented guard block.
54
60
  - playwright.styleproof.config.ts, a dedicated production-build Playwright config
55
61
  - .github/workflows/styleproof.yml, a cache-first PR report workflow
62
+ - .github/workflows/styleproof-approve.yml, the "Approve all changes" gate
63
+ (active once merged to your default branch)
56
64
 
57
65
  After running, build and upload this commit's map outside CI when possible:
58
66
  npx styleproof-map
@@ -549,6 +557,65 @@ if (ci.wrote) {
549
557
  console.log(`${CI_PATH} already exists — left untouched`);
550
558
  }
551
559
 
560
+ // Approval gate — the issue_comment handler that flips the StyleProof status when a
561
+ // reviewer ticks "Approve all changes". The report workflow above runs with
562
+ // `require-approval: true`, so this is what makes the checkbox live; without it the
563
+ // gate can never go green. It's a static workflow (no package-manager wiring), so we
564
+ // copy the packaged example verbatim rather than regenerate it. GitHub only runs
565
+ // issue_comment workflows from the DEFAULT branch, so it activates when the init PR
566
+ // merges — writing it to the feature branch now is correct and harmless.
567
+ const APPROVE_PATH = '.github/workflows/styleproof-approve.yml';
568
+ const approveSource = path.join(
569
+ path.dirname(fileURLToPath(import.meta.url)),
570
+ '..',
571
+ 'example',
572
+ 'styleproof-approve.yml',
573
+ );
574
+ let approveWorkflow;
575
+ try {
576
+ approveWorkflow = fs.readFileSync(approveSource, 'utf8');
577
+ } catch {
578
+ // Packaged example missing (unexpected) — don't abort the rest of init.
579
+ console.warn(`could not read the approval workflow template at ${approveSource} — skipped`);
580
+ }
581
+ if (approveWorkflow !== undefined) {
582
+ const approve = writeFileSafe(APPROVE_PATH, approveWorkflow);
583
+ if (approve.wrote) {
584
+ touched.push(APPROVE_PATH);
585
+ console.log(`created ${APPROVE_PATH} (approval gate — active once merged to your default branch)`);
586
+ wroteSomething = true;
587
+ } else {
588
+ console.log(`${APPROVE_PATH} already exists — left untouched`);
589
+ }
590
+ }
591
+
592
+ // Pre-push publish hook — the default fast path. Capture locally at push time and
593
+ // publish to the SHA-keyed styleproof-maps branch; CI restores by SHA and stays
594
+ // report-only. Maps are NEVER committed to the PR branch: a shared tracked map path
595
+ // shows up in every PR's changed files and forces cross-PR rebases on each merge.
596
+ const HOOK = `#!/bin/sh
597
+ # StyleProof pre-push: capture this commit's map and publish it to the
598
+ # styleproof-maps branch, so CI restores it and reports without a browser.
599
+ # Maps never get committed to the PR branch.
600
+ # Skip (e.g. a docs-only push): STYLEPROOF_SKIP_CAPTURE=1 git push
601
+ set -e
602
+ [ "\${STYLEPROOF_SKIP_CAPTURE:-}" = "1" ] && exit 0
603
+ ${PM.exec(`styleproof-map --spec ${specPath}`)}
604
+ ${PM.exec('styleproof-diff')} || true # advisory: show drift before CI does
605
+ `;
606
+ const hookDir = fs.existsSync('.husky') ? '.husky' : '.githooks';
607
+ const hookPath = path.join(hookDir, 'pre-push');
608
+ const hook = writeFileSafe(hookPath, HOOK);
609
+ if (hook.wrote) {
610
+ fs.chmodSync(hookPath, 0o755);
611
+ touched.push(hookPath);
612
+ console.log(`created ${hookPath} (pre-push capture → publish; maps never land on the PR branch)`);
613
+ if (hookDir === '.githooks') console.log(' activate with: git config core.hooksPath .githooks');
614
+ wroteSomething = true;
615
+ } else {
616
+ console.log(`${hookPath} already exists — left untouched`);
617
+ }
618
+
552
619
  if (touched.length) {
553
620
  // State exactly what init wrote, and — because adopters have blamed init for the
554
621
  // `styleproof` entry their package manager's `install` added — say plainly that it
@@ -560,11 +627,12 @@ if (touched.length) {
560
627
  console.log('\nHow the gate works — it runs on your first PR with no extra steps:');
561
628
  console.log(' 1. Commit and open a PR. CI captures the base and head surfaces in one pinned');
562
629
  console.log(' environment and posts the StyleProof report — no local step required.');
563
- console.log(' 2. (Optional, faster) Pre-cache this commit so CI skips recapturing the base:');
564
- console.log(' npx styleproof-map');
565
- console.log(' It captures a production-build map into .styleproof/ and uploads the bundle to');
566
- console.log(' the styleproof-maps branch when the git remote is available; CI then restores');
567
- console.log(' it and generates the report without a browser.');
630
+ console.log(' 2. The pre-push hook captures each pushed commit into .styleproof/ and publishes');
631
+ console.log(' the bundle to the styleproof-maps branch; CI restores it by SHA and generates');
632
+ console.log(' the report without a browser. Maps never get committed to the PR branch.');
633
+ console.log(' Skip a push that cannot affect render: STYLEPROOF_SKIP_CAPTURE=1 git push');
634
+ console.log(' 3. Merge this PR. The approval workflow only runs from your default branch, so');
635
+ console.log(' the "Approve all changes" checkbox goes live once styleproof-approve.yml is there.');
568
636
 
569
637
  if (!wroteSomething) console.log('\nnothing to write — project already scaffolded.');
570
638
  process.exit(0);
@@ -254,11 +254,19 @@ function runVariantCrawl(env) {
254
254
  }
255
255
 
256
256
  const targetDir = path.join(baseDir, dir);
257
+ // Sample the tree state the capture is ABOUT to render, so the manifest can bind the
258
+ // map to it. A capture runs for minutes; if the source is edited or HEAD moves in that
259
+ // window, the map renders one state but would otherwise be stamped clean@post-HEAD and
260
+ // published as the authoritative map for a SHA it never rendered — a stale map every
261
+ // future diff against that SHA silently trusts as a false green.
257
262
  let dirtyBeforeCapture;
263
+ let headBeforeCapture;
258
264
  try {
259
265
  dirtyBeforeCapture = workingTreeDirty(process.cwd());
266
+ headBeforeCapture = currentGitSha(process.cwd());
260
267
  } catch {
261
268
  dirtyBeforeCapture = false;
269
+ headBeforeCapture = undefined;
262
270
  }
263
271
 
264
272
  if (restore) {
@@ -329,21 +337,27 @@ if (status === 0) {
329
337
  );
330
338
  process.exit(status);
331
339
  }
340
+ // Bind the map to the commit it actually started rendering (headBeforeCapture), not a
341
+ // HEAD that may have moved mid-capture. `--sha` still wins for callers that know better.
342
+ const manifestSha = sha || headBeforeCapture || 'local';
343
+ // Re-check the tree AFTER capture (ignoring the maps this run just wrote): if the source
344
+ // was edited, or HEAD moved, during the capture window, the map↔SHA binding is a lie —
345
+ // mark it dirty so publishMapBundle refuses to push a stale map into the SHA-keyed store.
346
+ let dirty = dirtyBeforeCapture;
347
+ try {
348
+ const rel = path.relative(process.cwd(), targetDir) || targetDir;
349
+ const headAfter = currentGitSha(process.cwd(), env);
350
+ if (workingTreeDirty(process.cwd(), rel) || (headBeforeCapture && headAfter !== headBeforeCapture)) dirty = true;
351
+ } catch {
352
+ // git unreadable now — keep the pre-capture verdict rather than guess
353
+ }
332
354
  try {
333
- let manifestSha = sha || undefined;
334
- if (!manifestSha) {
335
- try {
336
- manifestSha = currentGitSha(process.cwd(), env);
337
- } catch {
338
- manifestSha = 'local';
339
- }
340
- }
341
355
  const manifest = writeMapManifest({
342
356
  dir: targetDir,
343
357
  spec,
344
358
  sha: manifestSha,
345
359
  screenshots: screenshots !== '0',
346
- dirty: dirtyBeforeCapture,
360
+ dirty,
347
361
  env,
348
362
  });
349
363
  console.error(`styleproof-map: wrote ${targetDir} for ${manifest.sha.slice(0, 12)} (${manifest.compatibilityKey})`);
@@ -18,6 +18,8 @@ import {
18
18
  DEFAULT_REMOTE,
19
19
  assertCompatibleMapDirs,
20
20
  cleanupCachedCaptureDirs,
21
+ manifestlessError,
22
+ manifestlessSide,
21
23
  resolveCachedCaptureDirs,
22
24
  } from '../dist/map-store.js';
23
25
 
@@ -146,6 +148,10 @@ if (foldDetailsAt !== undefined && Number.isNaN(foldDetailsAt)) {
146
148
 
147
149
  let result;
148
150
  try {
151
+ // v4: refuse a manifest-less side (exit 2 via the catch) — same-environment
152
+ // compatibility can't be verified without a manifest on both sides.
153
+ const manifestless = manifestlessSide(beforeDir, afterDir);
154
+ if (manifestless) throw new Error(manifestlessError(manifestless));
149
155
  assertCompatibleMapDirs(beforeDir, afterDir);
150
156
  result = generateStyleMapReport({
151
157
  beforeDir,
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import { captureStyleMap, saveStyleMap, trackInflightRequests } from './capture.js';
4
4
  import { detectViewportWidths } from './breakpoints.js';
5
5
  import { runSetup } from './crawl-surfaces.js';
6
+ import { writeCaptureManifest } from './map-store.js';
6
7
  /**
7
8
  * One-shot capture of a single URL's computed-style map — no spec, no config,
8
9
  * no git. `defineStyleMapCapture` is the right tool when the surfaces live in
@@ -192,6 +193,10 @@ export async function captureUrlToDir(page, opts) {
192
193
  requests.dispose();
193
194
  }
194
195
  }
196
+ // Stamp a manifest so `styleproof-diff <thisDir> <build>` has the same-environment
197
+ // guard on both sides — v4 refuses to compare a manifest-less side. May run outside
198
+ // a git repo (a design mockup); the git fields degrade gracefully.
199
+ writeCaptureManifest({ dir: opts.out, screenshots: opts.screenshots });
195
200
  return results;
196
201
  }
197
202
  /** Launch Chromium, capture the URL, and close — the whole bin body given parsed options. */
package/dist/capture.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { Page } from '@playwright/test';
2
2
  import { type NavigableItem } from './inventory.js';
3
+ import { type DataResidueEntry } from './data-residue.js';
3
4
  /**
4
5
  * Computed-style capture: the browser's final resolved value for every CSS
5
6
  * longhand on every element, keyed by DOM structure (never by class name, so
@@ -132,6 +133,15 @@ export type StyleMap = {
132
133
  * by the certification diff. See docs/inventory-guard.md.
133
134
  */
134
135
  inventory?: NavigableItem[];
136
+ /**
137
+ * Data-boundary requests (matching `replayUrl`) that FAILED during this capture —
138
+ * a network error or a 4xx/5xx. Their presence means the captured state renders the
139
+ * endpoint's FALLBACK branch, so states driven by its real responses are uncaptured
140
+ * and unproven. Present only when the residue guard was armed (`dataResidue` set) and
141
+ * a failure actually occurred; absent (byte-identical to before) on a clean capture.
142
+ * Surfaced by the data-residue guard, never by the certification diff. See issue #205.
143
+ */
144
+ dataResidue?: DataResidueEntry[];
135
145
  };
136
146
  export type CaptureOptions = {
137
147
  /**
@@ -248,6 +258,39 @@ export declare function trackInflightRequests(page: Page): {
248
258
  pending: () => number;
249
259
  dispose: () => void;
250
260
  };
261
+ /**
262
+ * Watch the data boundary (`url`, the same `replayUrl` glob record/replay uses) for
263
+ * requests that FAIL during capture — a network-level failure or a 4xx/5xx response.
264
+ * A failing data request means the captured state renders that endpoint's FALLBACK
265
+ * branch; the response-driven state is uncaptured and unproven (issue #205).
266
+ *
267
+ * Matches purely by listening to `requestfailed`/`response` and testing the URL against
268
+ * the boundary glob — NOT via a `page.route`. A passive route can't be used to tag: the
269
+ * tracker is armed before `go()`, and a route the surface itself adds in `go()` (an abort
270
+ * fixture, the HAR replay route) runs first and never falls through to a tag route added
271
+ * earlier, so tagged requests would be missed. Listeners see every request regardless of
272
+ * routing, which is exactly what a residue observer needs. Arm BEFORE navigation, like
273
+ * {@link trackInflightRequests}, so the page's own load fetches are seen; deduped per
274
+ * `<surface>·<endpoint>` so the same failure across widths / a self-check re-run is one entry.
275
+ *
276
+ * ONLY failures are recorded — never a 2xx that merely wasn't fixtured: in recording mode
277
+ * every live 2xx is legitimately recorded, so a blanket "uncontrolled" flag would fire on
278
+ * every healthy record run (issue #205, "deliberately out of scope").
279
+ */
280
+ export declare function trackDataResidue(page: Page, url: string, surface: string): {
281
+ residue: () => DataResidueEntry[];
282
+ dispose: () => void;
283
+ };
284
+ /**
285
+ * A predicate matching a URL against a Playwright-style URL glob — the same micro-syntax
286
+ * `page.route`/`routeFromHAR` accept for `replayUrl`, replicated because Playwright exposes
287
+ * no public matcher and reaching into its bundled internals is fragile across versions.
288
+ * `**` spans path separators, `*` matches within a segment (not `/`), `?` is a literal
289
+ * (URL globs, unlike shell globs, treat `?` as the query delimiter), and `{a,b}` alternates.
290
+ * A plain (glob-char-free) string is treated as a substring match, matching Playwright's
291
+ * own "contains" fallback for non-glob route URLs.
292
+ */
293
+ export declare function urlMatcher(glob: string): (url: string) => boolean;
251
294
  export declare function captureStyleMap(page: Page, options?: CaptureOptions): Promise<StyleMap>;
252
295
  /** Write a style map to disk; gzipped when the path ends in `.gz`. */
253
296
  export declare function saveStyleMap(filePath: string, map: StyleMap): void;
@@ -261,6 +304,14 @@ export declare function loadStyleMap(filePath: string): StyleMap;
261
304
  export declare function readInventories(dir: string): Array<{
262
305
  inventory?: NavigableItem[];
263
306
  }>;
307
+ /**
308
+ * Read every surface map's `dataResidue` from a capture dir, in the shape the
309
+ * data-residue audit consumes. Lives here (not in data-residue.ts) so that module
310
+ * stays a pure leaf — it must not import `loadStyleMap` back from this file.
311
+ */
312
+ export declare function readResidue(dir: string): Array<{
313
+ dataResidue?: DataResidueEntry[];
314
+ }>;
264
315
  /**
265
316
  * Each captured surface key → the set of element paths it renders, unioned across
266
317
  * the given dirs (so an element present on only one side still "belongs" to the
package/dist/capture.js CHANGED
@@ -2,6 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { gzipSync, gunzipSync } from 'node:zlib';
4
4
  import { classifyInventory, collectNavAffordances } from './inventory.js';
5
+ import { endpointOf, residueKey } from './data-residue.js';
5
6
  import { isMapFile } from './map-store.js';
6
7
  const INTERACTIVE = 'a, button, input, textarea, select, summary, [role="button"], [tabindex]';
7
8
  // Freeze motion so every captured value is a settled end state, not a frame
@@ -598,6 +599,93 @@ export function trackInflightRequests(page) {
598
599
  },
599
600
  };
600
601
  }
602
+ /**
603
+ * Watch the data boundary (`url`, the same `replayUrl` glob record/replay uses) for
604
+ * requests that FAIL during capture — a network-level failure or a 4xx/5xx response.
605
+ * A failing data request means the captured state renders that endpoint's FALLBACK
606
+ * branch; the response-driven state is uncaptured and unproven (issue #205).
607
+ *
608
+ * Matches purely by listening to `requestfailed`/`response` and testing the URL against
609
+ * the boundary glob — NOT via a `page.route`. A passive route can't be used to tag: the
610
+ * tracker is armed before `go()`, and a route the surface itself adds in `go()` (an abort
611
+ * fixture, the HAR replay route) runs first and never falls through to a tag route added
612
+ * earlier, so tagged requests would be missed. Listeners see every request regardless of
613
+ * routing, which is exactly what a residue observer needs. Arm BEFORE navigation, like
614
+ * {@link trackInflightRequests}, so the page's own load fetches are seen; deduped per
615
+ * `<surface>·<endpoint>` so the same failure across widths / a self-check re-run is one entry.
616
+ *
617
+ * ONLY failures are recorded — never a 2xx that merely wasn't fixtured: in recording mode
618
+ * every live 2xx is legitimately recorded, so a blanket "uncontrolled" flag would fire on
619
+ * every healthy record run (issue #205, "deliberately out of scope").
620
+ */
621
+ export function trackDataResidue(page, url, surface) {
622
+ const byKey = new Map();
623
+ const inBoundary = urlMatcher(url);
624
+ const record = (request, reason) => {
625
+ if (!inBoundary(request.url()))
626
+ return;
627
+ const endpoint = endpointOf(request.url());
628
+ const key = residueKey(surface, endpoint);
629
+ if (!byKey.has(key))
630
+ byKey.set(key, { key, surface, endpoint, reason });
631
+ };
632
+ const onFailed = (r) => record(r, r.failure()?.errorText ?? 'request failed');
633
+ // A completed response's status is synchronous here, so a 4xx/5xx is recorded before
634
+ // capture reads the residue. A >=400 is a fallback-branch trigger just like a net failure.
635
+ const onResponse = (resp) => {
636
+ if (resp.status() >= 400)
637
+ record(resp.request(), `HTTP ${resp.status()}`);
638
+ };
639
+ page.on('requestfailed', onFailed);
640
+ page.on('response', onResponse);
641
+ return {
642
+ residue: () => Array.from(byKey.values()).sort((a, b) => a.key.localeCompare(b.key)),
643
+ dispose: () => {
644
+ page.off('requestfailed', onFailed);
645
+ page.off('response', onResponse);
646
+ },
647
+ };
648
+ }
649
+ /**
650
+ * A predicate matching a URL against a Playwright-style URL glob — the same micro-syntax
651
+ * `page.route`/`routeFromHAR` accept for `replayUrl`, replicated because Playwright exposes
652
+ * no public matcher and reaching into its bundled internals is fragile across versions.
653
+ * `**` spans path separators, `*` matches within a segment (not `/`), `?` is a literal
654
+ * (URL globs, unlike shell globs, treat `?` as the query delimiter), and `{a,b}` alternates.
655
+ * A plain (glob-char-free) string is treated as a substring match, matching Playwright's
656
+ * own "contains" fallback for non-glob route URLs.
657
+ */
658
+ export function urlMatcher(glob) {
659
+ if (!/[*?{}[\]]/.test(glob))
660
+ return (url) => url.includes(glob);
661
+ const specials = new Set(['.', '+', '^', '$', '|', '(', ')']);
662
+ let re = '';
663
+ for (let i = 0; i < glob.length; i++) {
664
+ const c = glob[i];
665
+ if (c === '*') {
666
+ if (glob[i + 1] === '*') {
667
+ re += '.*';
668
+ i++;
669
+ }
670
+ else
671
+ re += '[^/]*';
672
+ }
673
+ else if (c === '?')
674
+ re += '\\?';
675
+ else if (c === '{')
676
+ re += '(';
677
+ else if (c === '}')
678
+ re += ')';
679
+ else if (c === ',')
680
+ re += '|';
681
+ else if (specials.has(c))
682
+ re += `\\${c}`;
683
+ else
684
+ re += c;
685
+ }
686
+ const compiled = new RegExp(`^${re}$`);
687
+ return (url) => compiled.test(url);
688
+ }
601
689
  /** Settle the page and return the paths of live regions to exclude. */
602
690
  async function detectVolatile(page, ignore, stabilize, captureText, externalPending) {
603
691
  if (stabilize === false)
@@ -793,17 +881,29 @@ export function loadStyleMap(filePath) {
793
881
  'Re-capture it — a partial write or interrupted upload produces an unreadable .gz.', { cause: e });
794
882
  }
795
883
  }
884
+ /** Every surface map in a capture dir, loaded — the shared reader behind the
885
+ * per-field extractors below. */
886
+ function loadDirMaps(dir) {
887
+ return fs
888
+ .readdirSync(dir)
889
+ .filter(isMapFile)
890
+ .map((f) => loadStyleMap(path.join(dir, f)));
891
+ }
796
892
  /**
797
893
  * Read every surface map's navigable inventory from a capture dir, in the shape the
798
894
  * inventory audit consumes. One home for "read the inventories out of a dir", shared
799
895
  * by the diff CLI and the report (was duplicated in both).
800
896
  */
801
897
  export function readInventories(dir) {
802
- return fs
803
- .readdirSync(dir)
804
- .filter(isMapFile)
805
- .map((f) => loadStyleMap(path.join(dir, f)))
806
- .map((m) => (m.inventory ? { inventory: m.inventory } : {}));
898
+ return loadDirMaps(dir).map((m) => (m.inventory ? { inventory: m.inventory } : {}));
899
+ }
900
+ /**
901
+ * Read every surface map's `dataResidue` from a capture dir, in the shape the
902
+ * data-residue audit consumes. Lives here (not in data-residue.ts) so that module
903
+ * stays a pure leaf — it must not import `loadStyleMap` back from this file.
904
+ */
905
+ export function readResidue(dir) {
906
+ return loadDirMaps(dir).map((m) => (m.dataResidue ? { dataResidue: m.dataResidue } : {}));
807
907
  }
808
908
  /**
809
909
  * Each captured surface key → the set of element paths it renders, unioned across
@@ -86,6 +86,13 @@ export type CoverageLedger = {
86
86
  exclude: Record<string, string>;
87
87
  /** How this capture's determinism was established (3.10.0). Absent on older bundles. */
88
88
  determinism?: DeterminismBasis;
89
+ /**
90
+ * The data-residue guard mode this capture ran under (issue #205). `'gate'` (the v4
91
+ * default) — an unacknowledged failing data endpoint blocks the diff; `'warn'` — the
92
+ * explicit opt-out, residue is recorded and warned but never blocks. Absent on bundles
93
+ * captured before the field existed, and read as warn so they never gate retroactively.
94
+ */
95
+ dataResidue?: 'warn' | 'gate';
89
96
  };
90
97
  export type DeterminismVerdict = {
91
98
  /** `proven` — both sides self-checked or replayed; `unproven` — a side was neither, so