styleproof 3.9.0 → 3.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,22 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [3.10.0] - 2026-07-06
11
+
12
+ ### Added
13
+
14
+ - **Determinism provenance — a green needs a proven-deterministic capture.** Source-of-
15
+ truth step 2: a clean diff of two _nondeterministic_ captures is meaningless (they
16
+ might just happen to match). The capture now records its determinism basis in the
17
+ ledger — `self-checked` (captured twice, styles matched — a drift would have failed the
18
+ capture), `replayed` (rendered against a recorded HAR, deterministic by construction),
19
+ or `unproven` (neither) — and `styleproof-diff`:
20
+ - **blocks (exit 1) when either side is `unproven`**, even on an empty style diff;
21
+ - prints `✓ determinism proven — base X, head Y`, `✗ determinism NOT proven …`, or
22
+ `⚠ determinism basis unknown` (an older bundle with no field — degrades, never a
23
+ false red). The record-then-replay flow (base `self-checked`, head `replayed`) is
24
+ proven, as it should be.
25
+
10
26
  ## [3.9.0] - 2026-07-05
11
27
 
12
28
  ### Added
@@ -41,7 +41,7 @@ import {
41
41
  } from '../dist/cli-errors.js';
42
42
  import { loadStyleMap } from '../dist/capture.js';
43
43
  import { auditRunInventory } from '../dist/inventory.js';
44
- import { auditCoverage, COVERAGE_LEDGER } from '../dist/coverage.js';
44
+ import { auditCoverage, auditDeterminism, COVERAGE_LEDGER } from '../dist/coverage.js';
45
45
  import { isMapFile } from '../dist/map-store.js';
46
46
 
47
47
  const COMMAND = path.basename(process.argv[1] ?? 'styleproof-diff').replace(/\.mjs$/, '');
@@ -129,17 +129,14 @@ function capturedSurfaceKeys(dir) {
129
129
  ];
130
130
  }
131
131
 
132
- function readCoverageVerdict(dir) {
133
- let ledger = null;
132
+ function readLedger(dir) {
134
133
  const p = path.join(dir, COVERAGE_LEDGER);
135
- if (fs.existsSync(p)) {
136
- try {
137
- ledger = JSON.parse(fs.readFileSync(p, 'utf8'));
138
- } catch {
139
- /* a corrupt ledger reads as no registry → "not asserted", never a false green */
140
- }
134
+ if (!fs.existsSync(p)) return null;
135
+ try {
136
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
137
+ } catch {
138
+ return null; // a corrupt ledger reads as no registry → "not asserted", never a false green
141
139
  }
142
- return auditCoverage(capturedSurfaceKeys(dir), ledger);
143
140
  }
144
141
 
145
142
  // Print the completeness verdict; return true if it BLOCKS (a registered surface is missing).
@@ -165,6 +162,23 @@ function printCoverageVerdict(v) {
165
162
  return true;
166
163
  }
167
164
 
165
+ // Print the determinism verdict; return true if it BLOCKS (a side's capture was unproven).
166
+ function printDeterminismVerdict(v) {
167
+ if (v.status === 'proven') {
168
+ console.log(`\n✓ determinism proven — base ${v.base}, head ${v.head}`);
169
+ return false;
170
+ }
171
+ if (v.status === 'unknown') {
172
+ console.log('\n⚠ determinism basis unknown — a capture predates the determinism ledger; recapture to certify it.');
173
+ return false;
174
+ }
175
+ console.log(
176
+ `\n✗ determinism NOT proven — base ${v.base}, head ${v.head}. An unproven capture can drift, so a clean\n` +
177
+ ' diff might be two matching NONDETERMINISTIC reads. Enable selfCheck (default) or replay a recorded HAR.',
178
+ );
179
+ return true;
180
+ }
181
+
168
182
  const HELP = `${COMMAND} — certify a CSS refactor by diffing two computed-style map captures
169
183
 
170
184
  usage: ${COMMAND} [baseRef] [options]
@@ -257,14 +271,17 @@ if (args.length <= 1) {
257
271
  let result;
258
272
  let inventoryAudit = null;
259
273
  let coverageVerdict = null;
274
+ let determinismVerdict = null;
260
275
  try {
261
276
  assertCompatibleMapDirs(dirA, dirB);
262
277
  result = diffStyleMapDirs(dirA, dirB);
263
- // Read inventory + coverage here, while the (possibly cached/restored) dirs still
264
- // exist — the finally below deletes them in cached-map mode. Coverage is the HEAD
265
- // bundle's completeness basis.
278
+ // Read inventory + the certification ledgers here, while the (possibly cached/restored)
279
+ // dirs still exist — the finally below deletes them in cached-map mode. Coverage is the
280
+ // HEAD bundle's completeness basis; determinism needs both sides.
266
281
  inventoryAudit = readInventoryAudit(dirA, dirB);
267
- coverageVerdict = readCoverageVerdict(dirB);
282
+ const headLedger = readLedger(dirB);
283
+ coverageVerdict = auditCoverage(capturedSurfaceKeys(dirB), headLedger);
284
+ determinismVerdict = auditDeterminism(readLedger(dirA), headLedger);
268
285
  } catch (e) {
269
286
  console.error(e.message);
270
287
  process.exit(2);
@@ -302,9 +319,13 @@ for (const sd of surfaces) {
302
319
 
303
320
  const invRemovals = printInventoryAudit(inventoryAudit);
304
321
  const coverageFails = printCoverageVerdict(coverageVerdict);
322
+ const determinismFails = printDeterminismVerdict(determinismVerdict);
305
323
 
306
324
  if (jsonOut)
307
- fs.writeFileSync(jsonOut, JSON.stringify({ counts, surfaces, compared, coverage: coverageVerdict }, null, 2));
325
+ fs.writeFileSync(
326
+ jsonOut,
327
+ JSON.stringify({ counts, surfaces, compared, coverage: coverageVerdict, determinism: determinismVerdict }, null, 2),
328
+ );
308
329
 
309
330
  const total = counts.dom + counts.style + counts.state;
310
331
  const newSurfaces = surfaces.filter((s) => s.missing).length;
@@ -313,14 +334,16 @@ const surfaceCount = surfaces.length;
313
334
  const newNote = newSurfaces ? ` (+${newSurfaces} new surface(s) with no baseline)` : '';
314
335
  const invNote = invRemovals ? ` + ${invRemovals} unacknowledged inventory removal(s)` : '';
315
336
  const covNote = coverageFails ? ` + ${coverageVerdict.uncovered.length} uncaptured registered surface(s)` : '';
316
- const clean = total === 0 && invRemovals === 0 && !coverageFails;
337
+ const detNote = determinismFails ? ' + determinism unproven' : '';
338
+ const clean = total === 0 && invRemovals === 0 && !coverageFails && !determinismFails;
317
339
  console.log(
318
340
  clean
319
341
  ? newSurfaces === 0
320
342
  ? `\n✓ 0 changed surfaces across ${compared} captured surface(s): every computed style, pseudo-element, and hover/focus/active state matches`
321
343
  : `\nℹ ${newSurfaces} new surface(s) captured with no baseline to compare — review before baselining`
322
- : `\n✗ ${counts.dom} DOM change(s), ${counts.style} computed-style difference(s), ${counts.state} state-delta difference(s) across ${surfaceCount} surfaces${newNote}${invNote}${covNote}`,
344
+ : `\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}`,
323
345
  );
324
- // 0 = identical, 1 = reviewable differences (incl. unacknowledged inventory removals or
325
- // an incomplete coverage registry), 3 = only new surfaces (no baseline). 2 = usage/capture error.
326
- process.exit(total > 0 || invRemovals > 0 || coverageFails ? 1 : newSurfaces > 0 ? 3 : 0);
346
+ // 0 = identical, 1 = reviewable differences (incl. unacknowledged inventory removals, an
347
+ // incomplete coverage registry, or an unproven-determinism capture), 3 = only new surfaces
348
+ // (no baseline). 2 = usage/capture error.
349
+ process.exit(total > 0 || invRemovals > 0 || coverageFails || determinismFails ? 1 : newSurfaces > 0 ? 3 : 0);
@@ -36,13 +36,32 @@ export type CoverageGaps = {
36
36
  export declare function coverageGaps(capturedKeys: Iterable<string>, expected: Iterable<string>, exclude?: Record<string, string>): CoverageGaps;
37
37
  /** Bundled next to the maps, so the completeness basis travels with the capture. */
38
38
  export declare const COVERAGE_LEDGER = "styleproof-coverage.json";
39
+ /**
40
+ * How a capture's determinism was established — the second half of a trustworthy green.
41
+ * `self-checked`: captured twice and the computed styles matched (a drift would have
42
+ * failed the capture). `replayed`: rendered against a recorded HAR, so deterministic by
43
+ * construction. `unproven`: neither — the styles could have drifted and no one checked.
44
+ */
45
+ export type DeterminismBasis = 'self-checked' | 'replayed' | 'unproven';
39
46
  export type CoverageLedger = {
40
47
  version: 1;
41
48
  /** The declared surface registry, or null when the spec asserted none. */
42
49
  expected: string[] | null;
43
50
  /** Reviewed opt-outs (`key → reason`). */
44
51
  exclude: Record<string, string>;
52
+ /** How this capture's determinism was established (3.10.0). Absent on older bundles. */
53
+ determinism?: DeterminismBasis;
54
+ };
55
+ export type DeterminismVerdict = {
56
+ /** `proven` — both sides self-checked or replayed; `unproven` — a side was neither, so
57
+ * a clean diff might just be two matching NONDETERMINISTIC captures; `unknown` — an
58
+ * older bundle with no determinism field (degrade, don't block). */
59
+ status: 'proven' | 'unproven' | 'unknown';
60
+ base: DeterminismBasis | 'unknown';
61
+ head: DeterminismBasis | 'unknown';
45
62
  };
63
+ /** The gate's determinism call: a green needs BOTH sides proven (self-checked or replayed). */
64
+ export declare function auditDeterminism(base: CoverageLedger | null, head: CoverageLedger | null): DeterminismVerdict;
46
65
  export type CoverageVerdict = {
47
66
  /** `complete` — every registered surface captured; `incomplete` — a registered
48
67
  * surface is missing (gates); `unasserted` — no registry, so a green can only
package/dist/coverage.js CHANGED
@@ -44,6 +44,17 @@ export function coverageGaps(capturedKeys, expected, exclude = {}) {
44
44
  // the suite guard — which checks the declared list — cannot see it).
45
45
  /** Bundled next to the maps, so the completeness basis travels with the capture. */
46
46
  export const COVERAGE_LEDGER = 'styleproof-coverage.json';
47
+ /** The gate's determinism call: a green needs BOTH sides proven (self-checked or replayed). */
48
+ export function auditDeterminism(base, head) {
49
+ const b = base?.determinism ?? 'unknown';
50
+ const h = head?.determinism ?? 'unknown';
51
+ const proven = (d) => d === 'self-checked' || d === 'replayed';
52
+ if (b === 'unproven' || h === 'unproven')
53
+ return { status: 'unproven', base: b, head: h };
54
+ if (proven(b) && proven(h))
55
+ return { status: 'proven', base: b, head: h };
56
+ return { status: 'unknown', base: b, head: h };
57
+ }
47
58
  /** The gate's completeness call: audit what was actually captured against the ledger. */
48
59
  export function auditCoverage(capturedKeys, ledger) {
49
60
  if (!ledger || ledger.expected == null) {
package/dist/runner.js CHANGED
@@ -458,11 +458,18 @@ function resolveSettings(c) {
458
458
  * `expected: null` records that the spec declared no registry, so a green can only
459
459
  * certify the captured surfaces. Runs on a capture run (dir set) only.
460
460
  */
461
- function writeCoverageLedgerTest(baseDir, dir, expected, exclude) {
461
+ function writeCoverageLedgerTest(settings, dir, expected, exclude) {
462
462
  test('styleproof coverage ledger', () => {
463
- const outDir = path.join(baseDir, dir);
463
+ const outDir = path.join(settings.baseDir, dir);
464
464
  fs.mkdirSync(outDir, { recursive: true });
465
- const ledger = { version: 1, expected, exclude };
465
+ // Determinism basis: self-check ON proves it (a drift would have failed the capture);
466
+ // else a replay run is deterministic by construction; else it's unproven.
467
+ const determinism = settings.selfCheck
468
+ ? 'self-checked'
469
+ : settings.replayFrom
470
+ ? 'replayed'
471
+ : 'unproven';
472
+ const ledger = { version: 1, expected, exclude, determinism };
466
473
  fs.writeFileSync(path.join(outDir, COVERAGE_LEDGER), JSON.stringify(ledger, null, 2));
467
474
  });
468
475
  }
@@ -490,7 +497,7 @@ export function defineStyleMapCapture(options) {
490
497
  test.describe('styleproof capture', () => {
491
498
  test.skip(!dir, 'set STYLEMAP_DIR=<label> to capture computed-style maps');
492
499
  if (dir)
493
- writeCoverageLedgerTest(settings.baseDir, dir, expected ?? null, exclude);
500
+ writeCoverageLedgerTest(settings, dir, expected ?? null, exclude);
494
501
  for (const surface of captureSurfaces) {
495
502
  if (surface.widths && surface.widths.length > 0) {
496
503
  // Explicit widths: one parallelizable test per surface × width.
@@ -545,7 +552,7 @@ export function defineCrawlCapture(options) {
545
552
  // so it records `expected: null` (completeness honestly "not asserted": the crawl
546
553
  // captures what the nav links to, and can't prove that's every route in the app).
547
554
  if (dir)
548
- writeCoverageLedgerTest(settings.baseDir, dir, null, {});
555
+ writeCoverageLedgerTest(settings, dir, null, {});
549
556
  test('discover surfaces by crawling links, then capture each', async ({ page }) => {
550
557
  // 1. Load the root and wait for its nav links to hydrate — an SPA renders them
551
558
  // client-side, so they aren't in the initial HTML.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "styleproof",
3
- "version": "3.9.0",
3
+ "version": "3.10.0",
4
4
  "description": "Catch every CSS change before it ships — review PRs and certify refactors by the browser's computed styles, not pixels. Works with any styling system.",
5
5
  "keywords": [
6
6
  "playwright",