styleproof 3.8.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 +39 -0
- package/bin/styleproof-diff.mjs +94 -9
- package/dist/coverage.d.ts +42 -0
- package/dist/coverage.js +34 -0
- package/dist/diff.js +2 -1
- package/dist/map-store.d.ts +6 -0
- package/dist/map-store.js +9 -0
- package/dist/report.js +2 -3
- package/dist/runner.d.ts +0 -13
- package/dist/runner.js +29 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,45 @@ 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
|
+
|
|
26
|
+
## [3.9.0] - 2026-07-05
|
|
27
|
+
|
|
28
|
+
### Added
|
|
29
|
+
|
|
30
|
+
- **Coverage provenance — a green now states its completeness basis.** Source-of-truth
|
|
31
|
+
step 1: a green from `styleproof-diff` used to silently imply completeness it couldn't
|
|
32
|
+
back up (it certified only the surfaces it happened to capture). Now the capture writes
|
|
33
|
+
a coverage ledger (`styleproof-coverage.json`) into the bundle — the declared
|
|
34
|
+
`expected` registry, or `null` — and `styleproof-diff` reads it and:
|
|
35
|
+
- **blocks (exit 1) when a registered surface was never captured**, even if the style
|
|
36
|
+
diff is empty — the one failure the gate couldn't catch before (a green over a
|
|
37
|
+
surface it never looked at). This checks the surfaces actually captured, so a
|
|
38
|
+
declared surface whose capture _failed_ is caught here, where the suite guard (which
|
|
39
|
+
checks the declared list) cannot see it;
|
|
40
|
+
- prints the basis on every run: `✓ coverage complete — all N registered surface(s)
|
|
41
|
+
captured`, `✗ coverage INCOMPLETE — …`, or `⚠ completeness NOT asserted` (no
|
|
42
|
+
registry — certifies only the captured surfaces, so declare `expected` to certify
|
|
43
|
+
completeness). A crawl records `expected: null` honestly (it captures what the nav
|
|
44
|
+
links to, not proven-every-route).
|
|
45
|
+
- `isMapFile` / `RESERVED_BUNDLE_FILES` (map-store) — one place that knows which bundle
|
|
46
|
+
files are surface maps vs metadata sidecars, so a new sidecar can't read as a phantom
|
|
47
|
+
"new surface".
|
|
48
|
+
|
|
10
49
|
## [3.8.0] - 2026-07-05
|
|
11
50
|
|
|
12
51
|
### Added
|
package/bin/styleproof-diff.mjs
CHANGED
|
@@ -41,6 +41,8 @@ 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, auditDeterminism, COVERAGE_LEDGER } from '../dist/coverage.js';
|
|
45
|
+
import { isMapFile } from '../dist/map-store.js';
|
|
44
46
|
|
|
45
47
|
const COMMAND = path.basename(process.argv[1] ?? 'styleproof-diff').replace(/\.mjs$/, '');
|
|
46
48
|
|
|
@@ -55,7 +57,7 @@ const COMMAND = path.basename(process.argv[1] ?? 'styleproof-diff').replace(/\.m
|
|
|
55
57
|
function dirInventories(dir) {
|
|
56
58
|
return fs
|
|
57
59
|
.readdirSync(dir)
|
|
58
|
-
.filter(
|
|
60
|
+
.filter(isMapFile)
|
|
59
61
|
.map((f) => loadStyleMap(path.join(dir, f)))
|
|
60
62
|
.map((m) => (m.inventory ? { inventory: m.inventory } : {}));
|
|
61
63
|
}
|
|
@@ -110,6 +112,73 @@ function printInventoryAudit(audit) {
|
|
|
110
112
|
return unexplained.length;
|
|
111
113
|
}
|
|
112
114
|
|
|
115
|
+
// ── coverage provenance (the completeness basis of a green) ──────────────────────
|
|
116
|
+
// The head bundle carries a coverage ledger (the declared registry). The gate audits
|
|
117
|
+
// the ACTUALLY-captured surfaces against it, so "clean" states its basis — complete vs
|
|
118
|
+
// the registry, or explicitly "not asserted" — instead of silently implying completeness.
|
|
119
|
+
|
|
120
|
+
// Surface keys captured in a dir (file `<key>@<width>.json[.gz]` → `<key>`, deduped).
|
|
121
|
+
function capturedSurfaceKeys(dir) {
|
|
122
|
+
return [
|
|
123
|
+
...new Set(
|
|
124
|
+
fs
|
|
125
|
+
.readdirSync(dir)
|
|
126
|
+
.filter(isMapFile)
|
|
127
|
+
.map((f) => f.replace(/@\d+\.json(\.gz)?$/, '')),
|
|
128
|
+
),
|
|
129
|
+
];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function readLedger(dir) {
|
|
133
|
+
const p = path.join(dir, COVERAGE_LEDGER);
|
|
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
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Print the completeness verdict; return true if it BLOCKS (a registered surface is missing).
|
|
143
|
+
function printCoverageVerdict(v) {
|
|
144
|
+
if (v.basis === 'complete') {
|
|
145
|
+
console.log(`\n✓ coverage complete — all ${v.registrySize} registered surface(s) captured`);
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
if (v.basis === 'unasserted') {
|
|
149
|
+
console.log(
|
|
150
|
+
'\n⚠ completeness NOT asserted — the spec declared no `expected` registry, so this certifies only the\n' +
|
|
151
|
+
' surfaces that were captured, not that they are all of them. Declare `expected` to certify completeness.',
|
|
152
|
+
);
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
console.log(
|
|
156
|
+
`\n✗ coverage INCOMPLETE — ${v.uncovered.length} registered surface(s) not captured (of ${v.registrySize}):`,
|
|
157
|
+
);
|
|
158
|
+
for (const k of v.uncovered) console.log(` ✗ missing: ${k}`);
|
|
159
|
+
console.log(
|
|
160
|
+
" → capture each (or move it to `exclude` with a reason). A green can't certify what was never captured.",
|
|
161
|
+
);
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
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
|
+
|
|
113
182
|
const HELP = `${COMMAND} — certify a CSS refactor by diffing two computed-style map captures
|
|
114
183
|
|
|
115
184
|
usage: ${COMMAND} [baseRef] [options]
|
|
@@ -201,12 +270,18 @@ if (args.length <= 1) {
|
|
|
201
270
|
|
|
202
271
|
let result;
|
|
203
272
|
let inventoryAudit = null;
|
|
273
|
+
let coverageVerdict = null;
|
|
274
|
+
let determinismVerdict = null;
|
|
204
275
|
try {
|
|
205
276
|
assertCompatibleMapDirs(dirA, dirB);
|
|
206
277
|
result = diffStyleMapDirs(dirA, dirB);
|
|
207
|
-
// Read inventory here, while the (possibly cached/restored)
|
|
208
|
-
// the finally below deletes them in cached-map mode.
|
|
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.
|
|
209
281
|
inventoryAudit = readInventoryAudit(dirA, dirB);
|
|
282
|
+
const headLedger = readLedger(dirB);
|
|
283
|
+
coverageVerdict = auditCoverage(capturedSurfaceKeys(dirB), headLedger);
|
|
284
|
+
determinismVerdict = auditDeterminism(readLedger(dirA), headLedger);
|
|
210
285
|
} catch (e) {
|
|
211
286
|
console.error(e.message);
|
|
212
287
|
process.exit(2);
|
|
@@ -243,8 +318,14 @@ for (const sd of surfaces) {
|
|
|
243
318
|
}
|
|
244
319
|
|
|
245
320
|
const invRemovals = printInventoryAudit(inventoryAudit);
|
|
321
|
+
const coverageFails = printCoverageVerdict(coverageVerdict);
|
|
322
|
+
const determinismFails = printDeterminismVerdict(determinismVerdict);
|
|
246
323
|
|
|
247
|
-
if (jsonOut)
|
|
324
|
+
if (jsonOut)
|
|
325
|
+
fs.writeFileSync(
|
|
326
|
+
jsonOut,
|
|
327
|
+
JSON.stringify({ counts, surfaces, compared, coverage: coverageVerdict, determinism: determinismVerdict }, null, 2),
|
|
328
|
+
);
|
|
248
329
|
|
|
249
330
|
const total = counts.dom + counts.style + counts.state;
|
|
250
331
|
const newSurfaces = surfaces.filter((s) => s.missing).length;
|
|
@@ -252,13 +333,17 @@ const newSurfaces = surfaces.filter((s) => s.missing).length;
|
|
|
252
333
|
const surfaceCount = surfaces.length;
|
|
253
334
|
const newNote = newSurfaces ? ` (+${newSurfaces} new surface(s) with no baseline)` : '';
|
|
254
335
|
const invNote = invRemovals ? ` + ${invRemovals} unacknowledged inventory removal(s)` : '';
|
|
336
|
+
const covNote = coverageFails ? ` + ${coverageVerdict.uncovered.length} uncaptured registered surface(s)` : '';
|
|
337
|
+
const detNote = determinismFails ? ' + determinism unproven' : '';
|
|
338
|
+
const clean = total === 0 && invRemovals === 0 && !coverageFails && !determinismFails;
|
|
255
339
|
console.log(
|
|
256
|
-
|
|
340
|
+
clean
|
|
257
341
|
? newSurfaces === 0
|
|
258
342
|
? `\n✓ 0 changed surfaces across ${compared} captured surface(s): every computed style, pseudo-element, and hover/focus/active state matches`
|
|
259
343
|
: `\nℹ ${newSurfaces} new surface(s) captured with no baseline to compare — review before baselining`
|
|
260
|
-
: `\n✗ ${counts.dom} DOM change(s), ${counts.style} computed-style difference(s), ${counts.state} state-delta difference(s) across ${surfaceCount} surfaces${newNote}${invNote}`,
|
|
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}`,
|
|
261
345
|
);
|
|
262
|
-
// 0 = identical, 1 = reviewable differences (incl. unacknowledged inventory
|
|
263
|
-
//
|
|
264
|
-
|
|
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);
|
package/dist/coverage.d.ts
CHANGED
|
@@ -34,3 +34,45 @@ export type CoverageGaps = {
|
|
|
34
34
|
* dir), and it's exported so a consumer can assert coverage however it likes.
|
|
35
35
|
*/
|
|
36
36
|
export declare function coverageGaps(capturedKeys: Iterable<string>, expected: Iterable<string>, exclude?: Record<string, string>): CoverageGaps;
|
|
37
|
+
/** Bundled next to the maps, so the completeness basis travels with the capture. */
|
|
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';
|
|
46
|
+
export type CoverageLedger = {
|
|
47
|
+
version: 1;
|
|
48
|
+
/** The declared surface registry, or null when the spec asserted none. */
|
|
49
|
+
expected: string[] | null;
|
|
50
|
+
/** Reviewed opt-outs (`key → reason`). */
|
|
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';
|
|
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;
|
|
65
|
+
export type CoverageVerdict = {
|
|
66
|
+
/** `complete` — every registered surface captured; `incomplete` — a registered
|
|
67
|
+
* surface is missing (gates); `unasserted` — no registry, so a green can only
|
|
68
|
+
* certify the captured surfaces, not that they are all of them. */
|
|
69
|
+
basis: 'complete' | 'incomplete' | 'unasserted';
|
|
70
|
+
/** Size of the declared registry, or null when unasserted. */
|
|
71
|
+
registrySize: number | null;
|
|
72
|
+
/** Registered surfaces neither captured nor excluded — the coverage hole. */
|
|
73
|
+
uncovered: string[];
|
|
74
|
+
/** `exclude` entries no longer in `expected` — a rotted opt-out. */
|
|
75
|
+
staleExclusions: string[];
|
|
76
|
+
};
|
|
77
|
+
/** The gate's completeness call: audit what was actually captured against the ledger. */
|
|
78
|
+
export declare function auditCoverage(capturedKeys: Iterable<string>, ledger: CoverageLedger | null): CoverageVerdict;
|
package/dist/coverage.js
CHANGED
|
@@ -34,3 +34,37 @@ export function coverageGaps(capturedKeys, expected, exclude = {}) {
|
|
|
34
34
|
const staleExclusions = Object.keys(exclude).filter((k) => !expectedSet.has(k));
|
|
35
35
|
return { uncovered, staleExclusions };
|
|
36
36
|
}
|
|
37
|
+
// ── coverage provenance (the gate-level completeness assertion) ──────────────────
|
|
38
|
+
// The guard above runs in the app's SUITE. That fails a build when the spec forgets a
|
|
39
|
+
// route, but the GATE (styleproof-diff, reading captured maps) never learns whether
|
|
40
|
+
// coverage was asserted at all — so a green silently implies a completeness it can't
|
|
41
|
+
// back up. The capture writes this ledger into the bundle; the gate reads it and
|
|
42
|
+
// certifies "clean" only against a STATED basis, enforcing the registry against the
|
|
43
|
+
// maps actually captured (a declared surface whose capture FAILED is caught here, where
|
|
44
|
+
// the suite guard — which checks the declared list — cannot see it).
|
|
45
|
+
/** Bundled next to the maps, so the completeness basis travels with the capture. */
|
|
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
|
+
}
|
|
58
|
+
/** The gate's completeness call: audit what was actually captured against the ledger. */
|
|
59
|
+
export function auditCoverage(capturedKeys, ledger) {
|
|
60
|
+
if (!ledger || ledger.expected == null) {
|
|
61
|
+
return { basis: 'unasserted', registrySize: null, uncovered: [], staleExclusions: [] };
|
|
62
|
+
}
|
|
63
|
+
const { uncovered, staleExclusions } = coverageGaps(capturedKeys, ledger.expected, ledger.exclude);
|
|
64
|
+
return {
|
|
65
|
+
basis: uncovered.length ? 'incomplete' : 'complete',
|
|
66
|
+
registrySize: ledger.expected.length,
|
|
67
|
+
uncovered,
|
|
68
|
+
staleExclusions,
|
|
69
|
+
};
|
|
70
|
+
}
|
package/dist/diff.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { loadStyleMap, isUnder } from './capture.js';
|
|
4
|
+
import { isMapFile } from './map-store.js';
|
|
4
5
|
function diffProps(propsA, propsB, fallbackA, fallbackB, unsetA, unsetB) {
|
|
5
6
|
const changed = [];
|
|
6
7
|
for (const prop of new Set([...Object.keys(propsA), ...Object.keys(propsB)])) {
|
|
@@ -173,7 +174,7 @@ function tallyCounts(findings, counts) {
|
|
|
173
174
|
function indexDir(dir) {
|
|
174
175
|
return Object.fromEntries(fs
|
|
175
176
|
.readdirSync(dir)
|
|
176
|
-
.filter(
|
|
177
|
+
.filter(isMapFile)
|
|
177
178
|
.map((f) => [f.replace(/\.json(\.gz)?$/, ''), path.join(dir, f)]));
|
|
178
179
|
}
|
|
179
180
|
/** Diff every same-named capture between two directories. `volatile` is the
|
package/dist/map-store.d.ts
CHANGED
|
@@ -3,6 +3,12 @@ export declare const DEFAULT_MAP_LABEL = "current";
|
|
|
3
3
|
export declare const DEFAULT_MAP_STORE_BRANCH = "styleproof-maps";
|
|
4
4
|
export declare const DEFAULT_REMOTE = "origin";
|
|
5
5
|
export declare const MAP_MANIFEST = "styleproof-manifest.json";
|
|
6
|
+
/** Bundle files that sit alongside the maps but are NOT surfaces (manifest, coverage
|
|
7
|
+
* ledger, and any future sidecar). Every place that enumerates surface maps must skip
|
|
8
|
+
* these, or a sidecar reads as a phantom "new surface". */
|
|
9
|
+
export declare const RESERVED_BUNDLE_FILES: ReadonlySet<string>;
|
|
10
|
+
/** True for a captured surface map (`<key>@<width>.json[.gz]`), false for metadata. */
|
|
11
|
+
export declare function isMapFile(name: string): boolean;
|
|
6
12
|
export declare class MapStoreError extends Error {
|
|
7
13
|
}
|
|
8
14
|
export interface MapManifest {
|
package/dist/map-store.js
CHANGED
|
@@ -5,12 +5,21 @@ import { createRequire } from 'node:module';
|
|
|
5
5
|
import os from 'node:os';
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import { inferBaseRef } from './gitref.js';
|
|
8
|
+
import { COVERAGE_LEDGER } from './coverage.js';
|
|
8
9
|
export const DEFAULT_MAP_DIR = '.styleproof/maps';
|
|
9
10
|
export const DEFAULT_MAP_LABEL = 'current';
|
|
10
11
|
export const DEFAULT_MAP_STORE_BRANCH = 'styleproof-maps';
|
|
11
12
|
export const DEFAULT_REMOTE = 'origin';
|
|
12
13
|
export const MAP_MANIFEST = 'styleproof-manifest.json';
|
|
13
14
|
const GENERATED_DIRTY_ALLOWLIST = new Set(['next-env.d.ts']);
|
|
15
|
+
/** Bundle files that sit alongside the maps but are NOT surfaces (manifest, coverage
|
|
16
|
+
* ledger, and any future sidecar). Every place that enumerates surface maps must skip
|
|
17
|
+
* these, or a sidecar reads as a phantom "new surface". */
|
|
18
|
+
export const RESERVED_BUNDLE_FILES = new Set([MAP_MANIFEST, COVERAGE_LEDGER]);
|
|
19
|
+
/** True for a captured surface map (`<key>@<width>.json[.gz]`), false for metadata. */
|
|
20
|
+
export function isMapFile(name) {
|
|
21
|
+
return !RESERVED_BUNDLE_FILES.has(name) && /\.json(\.gz)?$/.test(name);
|
|
22
|
+
}
|
|
14
23
|
export class MapStoreError extends Error {
|
|
15
24
|
}
|
|
16
25
|
function runGit(cwd, args, maxBuffer = 1 << 28) {
|
package/dist/report.js
CHANGED
|
@@ -2,6 +2,7 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { PNG } from 'pngjs';
|
|
4
4
|
import { loadStyleMap } from './capture.js';
|
|
5
|
+
import { isMapFile } from './map-store.js';
|
|
5
6
|
import { diffStyleMapDirs, diffContentDirs, } from './diff.js';
|
|
6
7
|
import { describeChange, tokenIndex, toHex, trackCount } from './describe.js';
|
|
7
8
|
// Re-export the plain-English summariser so consumers (and tests) reach it
|
|
@@ -443,9 +444,7 @@ function liveCandidateLabel(candidate) {
|
|
|
443
444
|
return `${label} (${candidate.reason})`;
|
|
444
445
|
}
|
|
445
446
|
function captureFiles(dir) {
|
|
446
|
-
return fs.existsSync(dir)
|
|
447
|
-
? fs.readdirSync(dir).filter((f) => f !== 'styleproof-manifest.json' && /\.json(\.gz)?$/.test(f))
|
|
448
|
-
: [];
|
|
447
|
+
return fs.existsSync(dir) ? fs.readdirSync(dir).filter(isMapFile) : [];
|
|
449
448
|
}
|
|
450
449
|
function collectLiveCandidateLabels(beforeDir, afterDir) {
|
|
451
450
|
const seen = new Set();
|
package/dist/runner.d.ts
CHANGED
|
@@ -222,19 +222,6 @@ export declare function resolveBaseDir(baseDir: string | undefined, env?: string
|
|
|
222
222
|
export declare function resolveScreenshots(screenshots: boolean | undefined, env?: string | undefined): boolean;
|
|
223
223
|
/** The capture settings every capturer shares (everything bar the surface set). */
|
|
224
224
|
type CaptureConfig = Omit<DefineOptions, 'surfaces' | 'expected' | 'exclude'>;
|
|
225
|
-
/**
|
|
226
|
-
* Generate one Playwright test per surface × width that captures the style
|
|
227
|
-
* map to `<baseDir>/<dir>/<key>@<width>.json.gz`. Captures are made
|
|
228
|
-
* deterministic with no per-repo fixtures: the baseline run records each
|
|
229
|
-
* surface's data responses to a HAR, and the comparison run replays them (set
|
|
230
|
-
* STYLEPROOF_REPLAY_FROM=<baseline dir> on the comparison capture), while the
|
|
231
|
-
* clock is frozen so time-derived styling is stable.
|
|
232
|
-
*
|
|
233
|
-
* ```ts
|
|
234
|
-
* // styleproof.spec.ts
|
|
235
|
-
* defineStyleMapCapture({ surfaces: SURFACES, dir: process.env.STYLEMAP_DIR });
|
|
236
|
-
* ```
|
|
237
|
-
*/
|
|
238
225
|
export declare function defineStyleMapCapture(options: DefineOptions): void;
|
|
239
226
|
/** Options for {@link defineCrawlCapture}: where to crawl, how to filter/key the
|
|
240
227
|
* links, and the viewport sweep — plus the shared capture settings. */
|
package/dist/runner.js
CHANGED
|
@@ -3,7 +3,7 @@ import fs from 'node:fs';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { captureStyleMap, saveStyleMap, trackInflightRequests, } from './capture.js';
|
|
5
5
|
import { diffStyleMaps } from './diff.js';
|
|
6
|
-
import { coverageGaps } from './coverage.js';
|
|
6
|
+
import { coverageGaps, COVERAGE_LEDGER } from './coverage.js';
|
|
7
7
|
import { detectViewportWidths } from './breakpoints.js';
|
|
8
8
|
import { selectCrawlLinks } from './crawl.js';
|
|
9
9
|
/** One-line description of the first drift finding, for the self-check error. */
|
|
@@ -452,6 +452,27 @@ function resolveSettings(c) {
|
|
|
452
452
|
* defineStyleMapCapture({ surfaces: SURFACES, dir: process.env.STYLEMAP_DIR });
|
|
453
453
|
* ```
|
|
454
454
|
*/
|
|
455
|
+
/**
|
|
456
|
+
* Emit a test that records the coverage ledger into the capture bundle, so the GATE
|
|
457
|
+
* (styleproof-diff) can state its completeness basis — not just the app's own suite.
|
|
458
|
+
* `expected: null` records that the spec declared no registry, so a green can only
|
|
459
|
+
* certify the captured surfaces. Runs on a capture run (dir set) only.
|
|
460
|
+
*/
|
|
461
|
+
function writeCoverageLedgerTest(settings, dir, expected, exclude) {
|
|
462
|
+
test('styleproof coverage ledger', () => {
|
|
463
|
+
const outDir = path.join(settings.baseDir, dir);
|
|
464
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
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 };
|
|
473
|
+
fs.writeFileSync(path.join(outDir, COVERAGE_LEDGER), JSON.stringify(ledger, null, 2));
|
|
474
|
+
});
|
|
475
|
+
}
|
|
455
476
|
export function defineStyleMapCapture(options) {
|
|
456
477
|
const { surfaces, expected, exclude = {}, dir } = options;
|
|
457
478
|
const settings = resolveSettings(options);
|
|
@@ -475,6 +496,8 @@ export function defineStyleMapCapture(options) {
|
|
|
475
496
|
}
|
|
476
497
|
test.describe('styleproof capture', () => {
|
|
477
498
|
test.skip(!dir, 'set STYLEMAP_DIR=<label> to capture computed-style maps');
|
|
499
|
+
if (dir)
|
|
500
|
+
writeCoverageLedgerTest(settings, dir, expected ?? null, exclude);
|
|
478
501
|
for (const surface of captureSurfaces) {
|
|
479
502
|
if (surface.widths && surface.widths.length > 0) {
|
|
480
503
|
// Explicit widths: one parallelizable test per surface × width.
|
|
@@ -525,6 +548,11 @@ export function defineCrawlCapture(options) {
|
|
|
525
548
|
// that styleproof-map uses to select capture tests picks up crawl specs too.
|
|
526
549
|
test.describe('styleproof capture (crawl)', () => {
|
|
527
550
|
test.skip(!dir, 'set STYLEMAP_DIR=<label> to capture computed-style maps');
|
|
551
|
+
// A crawl DISCOVERS its surfaces from the nav — it has no registry to check against,
|
|
552
|
+
// so it records `expected: null` (completeness honestly "not asserted": the crawl
|
|
553
|
+
// captures what the nav links to, and can't prove that's every route in the app).
|
|
554
|
+
if (dir)
|
|
555
|
+
writeCoverageLedgerTest(settings, dir, null, {});
|
|
528
556
|
test('discover surfaces by crawling links, then capture each', async ({ page }) => {
|
|
529
557
|
// 1. Load the root and wait for its nav links to hydrate — an SPA renders them
|
|
530
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.
|
|
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",
|