styleproof 3.20.0 → 4.0.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.
@@ -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` : ''})`,
@@ -25,11 +25,27 @@
25
25
  import fs from 'node:fs';
26
26
  import path from 'node:path';
27
27
  import { diffStyleMapDirs, findingLabel } from '../dist/diff.js';
28
+ // The shared grouping brain (leaf — no Playwright-adjacent imports) that already
29
+ // dedupes the report: group identical change-sets across surfaces and fold derived
30
+ // longhands. Used for the HUMAN output only; --json stays the raw machine contract.
31
+ import {
32
+ cleanFindings,
33
+ groupBySignature,
34
+ groupByPath,
35
+ groupTitle,
36
+ summarizeProps,
37
+ derivedLonghandCount,
38
+ formatSurfaceList,
39
+ classifyChrome,
40
+ surfaceBase,
41
+ } from '../dist/change-groups.js';
28
42
  import {
29
43
  DEFAULT_MAP_STORE_BRANCH,
30
44
  DEFAULT_REMOTE,
31
45
  assertCompatibleMapDirs,
32
46
  cleanupCachedCaptureDirs,
47
+ manifestlessError,
48
+ manifestlessSide,
33
49
  resolveCachedCaptureDirs,
34
50
  } from '../dist/map-store.js';
35
51
  import {
@@ -39,8 +55,9 @@ import {
39
55
  showHelpAndExit,
40
56
  unknownFlagMessage,
41
57
  } from '../dist/cli-errors.js';
42
- import { readInventories } from '../dist/capture.js';
58
+ import { readInventories, readResidue, surfaceElementPaths } from '../dist/capture.js';
43
59
  import { auditRunInventory, readAckFile } from '../dist/inventory.js';
60
+ import { auditRunResidue, readResidueAckFile } from '../dist/data-residue.js';
44
61
  import { auditCoverage, auditDeterminism, COVERAGE_LEDGER } from '../dist/coverage.js';
45
62
  import { isMapFile } from '../dist/map-store.js';
46
63
 
@@ -100,6 +117,70 @@ function printInventoryAudit(audit) {
100
117
  return unexplained.length;
101
118
  }
102
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
+
103
184
  // ── coverage provenance (the completeness basis of a green) ──────────────────────
104
185
  // The head bundle carries a coverage ledger (the declared registry). The gate audits
105
186
  // the ACTUALLY-captured surfaces against it, so "clean" states its basis — complete vs
@@ -254,7 +335,13 @@ let result;
254
335
  let inventoryAudit = null;
255
336
  let coverageVerdict = null;
256
337
  let determinismVerdict = null;
338
+ let residueAudit = null;
339
+ let surfacePaths = new Map();
257
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));
258
345
  assertCompatibleMapDirs(dirA, dirB);
259
346
  result = diffStyleMapDirs(dirA, dirB);
260
347
  // Read inventory + the certification ledgers here, while the (possibly cached/restored)
@@ -264,6 +351,12 @@ try {
264
351
  const headLedger = readLedger(dirB);
265
352
  coverageVerdict = auditCoverage(capturedSurfaceKeys(dirB), headLedger);
266
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');
357
+ // Element-path sets per surface, for the shared-chrome tier — same "read while
358
+ // the dirs exist" rule as the ledgers above.
359
+ surfacePaths = surfaceElementPaths(dirA, dirB);
267
360
  } catch (e) {
268
361
  console.error(e.message);
269
362
  process.exit(2);
@@ -272,34 +365,92 @@ try {
272
365
  }
273
366
  const { surfaces, counts, compared } = result;
274
367
 
275
- for (const sd of surfaces) {
276
- if (sd.missing) {
277
- const side = sd.missing === 'before' ? 'after' : 'before';
278
- console.log(`\n${sd.surface}: new surface captured only in the ${side} set, no baseline to compare`);
279
- continue;
280
- }
368
+ // ── grouped human output ─────────────────────────────────────────────────────
369
+ // Reuse the report's dedup so one real change doesn't print once per surface with
370
+ // its derived-longhand echo: group surfaces that changed identically, fold the
371
+ // size/position-derived longhands behind a count, and keep the per-surface tally
372
+ // in each group's header line. --json below is untouched (the raw machine feed).
373
+
374
+ // One finding's lines: a heading, then its summarised property deltas (the same
375
+ // dedupe the report shows). Returns [] for a DOM finding (handled separately) or a
376
+ // finding whose props all summarised away.
377
+ function findingLines(f) {
378
+ if (f.kind === 'dom') return [];
379
+ const rows = summarizeProps(f.props);
380
+ if (!rows.length) return [];
381
+ const head =
382
+ f.kind === 'state'
383
+ ? ` [:${f.state}] ${findingLabel(f.path, f.cls)}${f.sub !== f.path ? ` ⇒ ${f.sub}` : ''}`
384
+ : ` ${findingLabel(f.path, f.cls)}${f.pseudo || ''}`;
385
+ return [head, ...rows.map((p) => ` ${p.prop}: ${p.before} → ${p.after}`)];
386
+ }
387
+
388
+ // A DOM finding's one-line heading (added/removed/retagged).
389
+ function domLine(dom) {
390
+ return dom.change === 'retagged'
391
+ ? ` DOM retagged: ${dom.path} ${dom.detail ?? ''}`
392
+ : ` DOM ${dom.change}: ${findingLabel(dom.path, dom.cls)}`;
393
+ }
394
+
395
+ // The element lines for one change group, from its representative's cleaned
396
+ // findings: one heading per element, then its summarised property deltas.
397
+ function elementLines(findings) {
281
398
  const lines = [];
282
- for (const f of sd.findings) {
283
- if (f.kind === 'dom') {
284
- lines.push(
285
- f.change === 'retagged'
286
- ? ` DOM retagged: ${f.path} ${f.detail}`
287
- : ` DOM ${f.change}: ${findingLabel(f.path, f.cls)}`,
288
- );
289
- } else if (f.kind === 'style') {
290
- lines.push(` ${findingLabel(f.path, f.cls)}${f.pseudo || ''}`);
291
- for (const p of f.props) lines.push(` ${p.prop}: ${p.before} → ${p.after}`);
292
- } else {
293
- lines.push(` [:${f.state}] ${findingLabel(f.path, f.cls)}${f.sub !== f.path ? ` ⇒ ${f.sub}` : ''}`);
294
- for (const p of f.props) lines.push(` ${p.prop}: ${p.before} → ${p.after}`);
295
- }
399
+ for (const group of groupByPath(findings)) {
400
+ const dom = group.find((f) => f.kind === 'dom');
401
+ if (dom) lines.push(domLine(dom));
402
+ for (const f of group) lines.push(...findingLines(f));
296
403
  }
297
- console.log(`\n${sd.surface}: ${lines.filter((l) => !l.startsWith(' ')).length} element(s) differ`);
404
+ return lines;
405
+ }
406
+
407
+ // New (one-sided) surfaces keep their own line, unchanged.
408
+ for (const sd of surfaces) {
409
+ if (!sd.missing) continue;
410
+ const side = sd.missing === 'before' ? 'after' : 'before';
411
+ console.log(`\n${sd.surface}: new surface — captured only in the ${side} set, no baseline to compare`);
412
+ }
413
+
414
+ // Group the changed surfaces the way the report does, so an identical change
415
+ // across N surfaces prints once (with the count), not N times.
416
+ const preparedForGrouping = surfaces
417
+ .filter((sd) => !sd.missing)
418
+ // Carry the RAW findings too, so we can report how many derived longhands the
419
+ // grouped view folded (the cleaned findings have them already removed).
420
+ .map((sd) => ({ surface: sd.surface, findings: cleanFindings(sd.findings), raw: sd.findings }))
421
+ .filter((p) => p.findings.length > 0);
422
+
423
+ function printGroup(cg) {
424
+ const lines = elementLines(cg.findings);
425
+ const derived = derivedLonghandCount(cg.rep.raw);
426
+ const foldNote = derived > 0 ? ` (+${derived} derived longhand${derived === 1 ? '' : 's'})` : '';
427
+ const others = cg.surfaces.length - 1;
428
+ const scope =
429
+ others > 0
430
+ ? `${cg.rep.surface} (+${others} more surface${others === 1 ? '' : 's'}: ${formatSurfaceList(cg.surfaces)})`
431
+ : cg.rep.surface;
432
+ console.log(`\n${scope}: ${groupTitle(cg.findings)}${foldNote}`);
298
433
  for (const line of lines.slice(0, MAX)) console.log(line);
299
434
  if (lines.length > MAX) console.log(` ... and ${lines.length - MAX} more lines (re-run with --max ${lines.length})`);
300
435
  }
301
436
 
437
+ // Shared-chrome tier: a change that rode the frame every view draws (nav/header)
438
+ // gets one banner up top, then its detail — so the reviewer reads "the nav changed
439
+ // everywhere" once, not once per surface entry. Presentational only.
440
+ const grouped = groupBySignature(preparedForGrouping);
441
+ const { chrome, rest } = classifyChrome(grouped, surfacePaths);
442
+ if (chrome.length) {
443
+ // Base count from the pre-cleanup surface set (dirB may be deleted by now).
444
+ const bases = new Set([...surfacePaths.keys()].map(surfaceBase)).size;
445
+ console.log(
446
+ `\n🧱 Global chrome change(s) — across all ${bases} surface(s): ${chrome.length} change(s) rode the shared frame every view draws (a persistent nav, header, or footer).`,
447
+ );
448
+ for (const cg of chrome) printGroup(cg);
449
+ }
450
+ for (const cg of rest) printGroup(cg);
451
+
302
452
  const invRemovals = printInventoryAudit(inventoryAudit);
453
+ const residueFails = printResidueAudit(residueAudit);
303
454
  const coverageFails = printCoverageVerdict(coverageVerdict);
304
455
  const determinismFails = printDeterminismVerdict(determinismVerdict);
305
456
 
@@ -335,6 +486,16 @@ if (jsonOut) {
335
486
  inventoryNote:
336
487
  'no captured map carried an inventory — set `inventory: true` in the capture spec to arm the navigable-removal gate',
337
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
+ },
338
499
  },
339
500
  null,
340
501
  2,
@@ -352,17 +513,21 @@ const newSurfaces = surfaces.filter((s) => s.missing).length;
352
513
  const surfaceCount = surfaces.length;
353
514
  const newNote = newSurfaces ? ` (+${newSurfaces} new surface(s) with no baseline)` : '';
354
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)` : '';
355
518
  const covNote = coverageFails ? ` + ${coverageVerdict.uncovered.length} uncaptured registered surface(s)` : '';
356
519
  const detNote = determinismFails ? ' + determinism unproven' : '';
357
- const clean = total === 0 && invRemovals === 0 && !coverageFails && !determinismFails;
520
+ const clean = total === 0 && invRemovals === 0 && residueFails === 0 && !coverageFails && !determinismFails;
358
521
  console.log(
359
522
  clean
360
523
  ? newSurfaces === 0
361
524
  ? `\n✓ 0 changed surfaces across ${compared} captured surface(s): every computed style, pseudo-element, and hover/focus/active state matches`
362
525
  : `\nℹ ${newSurfaces} new surface(s) captured with no baseline to compare — review before baselining`
363
- : `\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}`,
364
527
  );
365
528
  // 0 = identical, 1 = reviewable differences (incl. unacknowledged inventory removals, an
366
- // incomplete coverage registry, or an unproven-determinism capture), 3 = only new surfaces
367
- // (no baseline). 2 = usage/capture error.
368
- 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,4 +304,20 @@ 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
+ }>;
315
+ /**
316
+ * Each captured surface key → the set of element paths it renders, unioned across
317
+ * the given dirs (so an element present on only one side still "belongs" to the
318
+ * surface). Feeds the shared-chrome tier, which needs to know, per surface, which
319
+ * paths exist to tell a frame-wide change from one view's content. Shared by the
320
+ * report and the diff CLI (both already read maps this way).
321
+ */
322
+ export declare function surfaceElementPaths(...dirs: string[]): Map<string, Set<string>>;
264
323
  export {};