styleproof 3.17.0 → 3.19.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,218 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [3.19.0] - 2026-07-06
11
+
12
+ ### Added
13
+
14
+ - **Selective-remap wiring: `explainAffectedSurfaces` + the pre-push recipe.** The
15
+ sound core (`affectedSurfaces`) shipped in 3.17.0 returns a bare `Set | 'all'` that
16
+ names nothing; the new pure `explainAffectedSurfaces(result, allSurfaceKeys, reason?)`
17
+ formatter renders the verdict as reviewer-checkable lines — which surfaces re-capture
18
+ and which reuse their committed base map — so a pre-push hook or CI log can print the
19
+ skip list before anyone trusts it. `affectedSurfaces`'s return shape is unchanged
20
+ (backward-compatible; the helper takes the surface keys as a second argument rather
21
+ than extending the sentinel). README's selective-remap section gains the helper, its
22
+ output, and the full `git diff → dependency-cruiser → affectedSurfaces → capture subset`
23
+ pre-push recipe. Opt-in and advisory throughout — the default full-coverage gate is
24
+ untouched.
25
+
26
+ ### Fixed
27
+
28
+ - **Report tables escape hostile CSS values (no Markdown breakout).** A property
29
+ value carrying a `|` used to split a report table row, and a backtick used to close
30
+ the code span and leak live Markdown (`content:"…"`, `url(…)`, `font` strings). The
31
+ render boundary now escapes values instead of stripping them — `|` → `\|`, and the
32
+ code fence widens past the value's longest backtick run — so hostile values render
33
+ as one intact, readable cell. `report.md` only; the privileged review comment was
34
+ never affected.
35
+ - **The navigable-removal guard now sees SVG `<a>` nav links.** The in-page harvest
36
+ keyed anchors off `tagName === 'A'`, which is HTML-only (SVG reports lowercase `a`),
37
+ so an SVG nav link never entered the inventory and its removal never gated. The tag
38
+ check is now case-insensitive, the selector matches any-namespace `href`
39
+ (`a[*|href]`), and the target falls back to `xlink:href` — an `<svg><a>` link now
40
+ resolves like an HTML one.
41
+ - **Single-value `transform-origin`/`perspective-origin` jitter is suppressed.** The
42
+ sub-pixel-origin equality check required at least two length components, so a
43
+ one-value origin (`50px`) leaked rounding jitter as a false diff. One to three
44
+ components are now all suppressed within `ORIGIN_EPSILON_PX`; a real, larger change
45
+ still reports.
46
+ - **`styleproof-diff --json` exits 2 (not 1) when the file cannot be written.** A bad
47
+ `--json` path is a usage/setup error, but the write sat outside the guarded blocks
48
+ and let the failure fall through to exit 1 — which CI reads as a real diff. It now
49
+ reports to stderr and exits 2.
50
+ - **Crawl flag docs corrected.** `--max-depth`'s default is documented as 16 (not
51
+ "unbounded" in `--help`, not "3" in the JSDoc); `--until-covered` is now listed in
52
+ `styleproof-capture --help`.
53
+ - **Crawl no longer silently drops surfaces to key collisions, and mapping no longer
54
+ clicks title-only destructive controls.** Five soundness fixes across the crawl path:
55
+ - `selectCrawlLinks` deduped by raw URL, so `/about` and `/about/` — one route — were
56
+ two links that keyed identically and the second capture overwrote the first. Trailing
57
+ slashes are now normalized in the dedup identity (root `/` and the query string are
58
+ left intact), so a route is captured once.
59
+ - Genuinely distinct surfaces whose derived keys collide (e.g. `/a/b` and `/a-b` both
60
+ slugify to `a-b`) previously overwrote each other's map file. `selectCrawlLinks` now
61
+ disambiguates with a `-2`, `-3`, … suffix (mirroring the surface crawler), so every
62
+ surface survives.
63
+ - `defaultLinkKey` joined query-param values in iteration order, so `?tab=a&x=b` and
64
+ `?x=b&tab=a` — the same route — keyed as `a-b` vs `b-a` and flapped the coverage
65
+ guard into phantom regressions. Params are now sorted by name before joining.
66
+ - The surface crawler's clickable-candidate label omitted the `title` attribute, so an
67
+ icon-only `<button title="Delete">` labeled as `button` and slipped past the
68
+ destructive-action guard — mapping would click it. `title` is now part of the label
69
+ input in both crawlers.
70
+ - The variant crawler carried a weaker, divergent copy of the destructive-action word
71
+ list (missing `revoke|reset|wipe|drop|rotate|provision|seal|regenerate|renew`). Both
72
+ crawlers now share one `DANGER_SOURCE` constant, so mapping refuses the same set of
73
+ destructive controls everywhere.
74
+ - `defineStyleMapCapture` and `defineCrawlCapture` now assert every expanded capture
75
+ key is unique before running: the `surface.key-variant.key` join is ambiguous
76
+ (`a` + `b-c` and `a-b` + `c` both expand to `a-b-c`), which used to overwrite a map
77
+ file with no error. The key format is unchanged (it's public — filenames and report
78
+ identities); a collision now throws up front and names both origins so the author can
79
+ rename one.
80
+ - **Sass `@use`/`@forward` in a CSS Module now fails closed to `'all'`.** A
81
+ `.module.scss`/`.module.sass` that loads a partial via `@use`/`@forward` can pull in
82
+ global rules the JS import graph can't see, so `classifyStyleChange` now treats any
83
+ such file as global (`'all'`) — a sound over-approximation, no heuristics. A plain CSS
84
+ Module with only class selectors stays `'scope'` as before.
85
+ - **Legacy Sass/CSS `@import` in a CSS Module now fails closed too.** The fail-closed
86
+ load check missed the `@import` form, so a `.module.scss` loading a partial via
87
+ `@import "vars"` — whose members merge in exactly like `@use` — classified as `'scope'`
88
+ and could silently skip a re-capture. `classifyStyleChange` now treats any
89
+ `@use`/`@forward`/`@import` load as global (`'all'`), covering both the Sass partial
90
+ load and the plain-CSS pass-through (`@import url(x.css)`, whose selectors are not
91
+ hashed into the module's per-file scope, so it escapes the module). Only ever widens
92
+ toward `'all'`; a module with no load directive still stays `'scope'`.
93
+ - **`affectedSurfaces` now canonicalizes paths across all inputs, closing a silent
94
+ unsound skip.** `surfaces` entries, `changedFiles`, graph edges, and `files` are now
95
+ normalized to one spelling (strip a leading `./`, collapse `//`, resolve `.`/`..` as
96
+ pure string math — no fs), so a `./pages/Home.tsx` surface entry no longer misses a
97
+ reachability hit spelled `pages/Home.tsx` and gets dropped from the affected set. And a
98
+ declared surface whose entry path appears in neither `files` nor any graph edge is now
99
+ unplaceable → `'all'`, the same fail-closed rule as an unplaceable changed file.
100
+ - **Stale browser-build sidecar can no longer stamp a false fingerprint.**
101
+ `styleproof-map` now deletes any prior run's `styleproof-browser.json` before Playwright
102
+ runs, and `writeBrowserBuildSidecar(dir, undefined)` now removes an existing sidecar
103
+ rather than leaving it. Previously a reused capture dir plus a run that recorded no
104
+ browser version would fold the _previous_ run's build into this run's manifest, and
105
+ `assertCompatibleMapDirs` would trust that false `browserVersion` fingerprint.
106
+ - **`captureStyleMap` no longer leaks its motion-freeze `<style>` onto a reused page.**
107
+ The freeze injected for the base/forced-state reads was re-applied without a handle and
108
+ never removed, so on a page recaptured **without a reload** (an SPA `go()` that doesn't
109
+ navigate, multi-surface reuse, the self-check's re-run) the next capture's motion pass
110
+ read the still-frozen transition/animation longhands (`none`/`0s`) as its baseline —
111
+ phantom drift that surfaced as a **false "non-deterministic capture"** self-check
112
+ failure. The re-applied tag is now tracked and removed in a `finally`, so throw paths
113
+ clean up too. (No API change.)
114
+ - **`liveStates` + `expected` no longer reports a false coverage gap.** A surface with
115
+ `liveStates` is captured only as its split expansions (`home-loading`, `home-loaded`) —
116
+ the bare base key is dropped by design — but the coverage ledger recorded `expected`
117
+ in base keys and the gate compared captured keys literally, so a fully-captured app
118
+ failed the gate with `uncovered: ['home']` (live in 3.18.0). The ledger writer now
119
+ records `expected` already translated through the same liveState expansion, and the
120
+ suite-side guard maps each capture back to its originating `surfaceKey` — a precise
121
+ mapping via real metadata, so an unrelated `home-banner` never satisfies an uncaptured
122
+ `home`. Both the spec-driven and crawl capture paths are fixed consistently.
123
+
124
+ ## [3.18.0] - 2026-07-06
125
+
126
+ ### Added
127
+
128
+ - **Crawl coverage guard (`defineCrawlCapture` gains `expected` + `exclude`).** A
129
+ link-crawled SPA can now reconcile its _rendered nav_ against a declared route
130
+ registry, both directions: a rendered link with no `expected` entry fails as a new
131
+ route with no owner, and an `expected` route the nav stopped linking fails as a nav
132
+ regression. For such an app the nav is the route universe, so this is the spec
133
+ guard's list-vs-ledger discipline with the nav as the source of truth. Because the
134
+ link set isn't known until the page renders, the check runs _inside the capture
135
+ test_ (fires when `STYLEMAP_DIR` is set), not in the plain suite like the Next
136
+ guard. `exclude` (`key → reason`) opts out conditionally-rendered links (auth /
137
+ feature-flag) so they can't flake the guard; an `exclude` key in neither `expected`
138
+ nor the rendered nav fails as stale. Opt-in and backward-compatible: omit `expected`
139
+ and the crawl behaves exactly as before (captures what the nav links to, asserts no
140
+ completeness). New pure `crawlCoverageGaps` export for asserting reconciliation
141
+ yourself. README's "protected out of the box" scoped to what's wired per framework.
142
+
143
+ ### Fixed
144
+
145
+ - **`--crawl` now honours the fail-loud contract on unreadable CSS.** Three deviations on
146
+ the crawl path let a page with a cross-origin stylesheet be certified without ever being
147
+ fully looked at:
148
+ - Breakpoint auto-detection swallowed the "unreadable stylesheet" throw and silently swept
149
+ only 1280px — every other band certified unchanged without being rendered. The crawl now
150
+ propagates the throw like every other entry point (`styleproof-capture` one-shot,
151
+ `styleproof-map`); the message advises pinning `--widths` for a cross-origin-CSS page.
152
+ - The coverage verifier read class vocabulary only from _readable_ sheets, so a design
153
+ served cross-origin could pass `--require-full-coverage` with an artificially complete
154
+ verdict. Unreadable sheets are now counted and surfaced as **named residue**: a plain
155
+ crawl prints `N stylesheet(s) unreadable — class coverage not provable against them`, and
156
+ `--require-full-coverage` treats them as residue → exit 4. (`CrawlCoverage` gains an
157
+ `unreadable: string[]` field; purely additive.)
158
+ - `--max-depth` had two different defaults (16 in `CRAWL_DEFAULTS`, 1000 in the CLI). The
159
+ CLI default is now **16 everywhere** — the cap exists to bound append-generator UIs (a
160
+ composer that appends a fresh-identity node per click, which dedup can't terminate); 1000
161
+ made it decorative. Raise with `--max-depth` for a genuinely deeper nest.
162
+ - **The navigable-removal hard-gate now has data out of the box for new scaffolds — and
163
+ says so when it doesn't.** The Action defaults the inventory gate _on_ (3.14.0), but
164
+ inventory _capture_ defaults off, so on a spec that doesn't opt in, no map carries an
165
+ inventory, the diff's `inventory` verdict is `null`, and the gate counted zero removals
166
+ forever — armed, but with no ammunition. `styleproof-init` already scaffolds
167
+ `inventory: true` in the generated capture spec (since 3.15.0's zero-config default), so
168
+ **freshly scaffolded projects get the protection**; existing specs are untouched and stay
169
+ opt-in. To make the mismatch impossible to miss on pre-existing specs, the Action's gate
170
+ step now prints a `::notice::` — _"inventory gate is on but the captured maps carry no
171
+ inventory — set `inventory: true`"_ — instead of silently passing green, and
172
+ `styleproof-diff --json` emits an `inventoryNote` explaining the `null` verdict. Both are
173
+ notices, never failures: a spec that deliberately omits inventory capture keeps working.
174
+
175
+ - **The compatibility guard now keys on the real browser build, not just the Playwright
176
+ npm version.** Each capture records `browser().version()` (the actual Chromium build) in
177
+ its manifest, and `styleproof-diff` / `styleproof-report` refuse to compare two maps whose
178
+ builds differ (exit 2, both builds named). The npm `@playwright/test` version was only a
179
+ proxy: the actual binary can change while it holds constant — a `playwright install`
180
+ re-download after a cache wipe, a different `PLAYWRIGHT_BROWSERS_PATH`, or a CI image
181
+ bump — and two maps captured under different Chromium builds used to pass the guard and
182
+ then diff for real, walling the PR with false diffs the canonicalizer can't absorb.
183
+ Backward compatible: the build is compared only when **both** manifests carry it, so
184
+ bundles cached before this field keep comparing against each other; a build change now
185
+ fires the scaffolded recapture fallback instead of a cross-build compare. Fonts are
186
+ documented as an environment responsibility (too noisy across machines to fingerprint
187
+ cheaply); see the same-environment note.
188
+
189
+ - **A missing map is now refused, not mislabelled as "all new surfaces".** When one dir
190
+ held zero captures while the other held some, `styleproof-diff` used to mark every
191
+ surface `missing` and exit `3` ("only new surfaces") — the Action then rendered the
192
+ whole app as 🆕 new baselines a reviewer could approve wholesale. An empty **base** (a
193
+ restore that "succeeded" into an empty dir, a wrong `--base-dir`, a contributor without
194
+ the pre-push hook) meant approving a possibly fully-regressed head as the baseline; an
195
+ empty **head** (a head capture that produced nothing) meant approving a head that
196
+ rendered zero surfaces. Now: a bundle that claims to exist yet holds zero captures — a
197
+ `styleproof-manifest.json` present alongside no maps, on either side — and any empty
198
+ head exit `2` with their own named causes (`base map missing: restore it from the map
199
+ store or recapture both sides — refusing to treat every surface as new` / `head map
200
+ missing: the head capture produced zero surfaces — recapture the head side; refusing to
201
+ treat every surface as removed/new`), distinct enough that the scaffolded workflow's
202
+ capture-needed fallback is the obvious remedy in CI logs. A truly **bare** base dir (no
203
+ manifest, no maps) still means "no baseline exists yet" — the first-adoption flow where
204
+ the base commit predates the capture spec — and keeps the exit-`3` new-surfaces review
205
+ path. To keep that discrimination sound, `styleproof-map` no longer writes a manifest
206
+ (or uploads) when a capture run produced zero surfaces. `styleproof-report` shares the
207
+ load path and is refused identically, so it can't render the misleading all-new page
208
+ either. Exit `3` keeps its meaning: no baseline for _these_ surfaces — new against an
209
+ existing baseline, or the very first one.
210
+
211
+ ### Security
212
+
213
+ - **Surface keys are escaped before they reach the privileged PR comment.** Surface keys
214
+ originate from artifact filenames — attacker-controlled in the fork capture/report split
215
+ — and flow into the bot comment (sliced from `report.md`'s headline). Markdown/HTML
216
+ control characters (`` ` ``, `[`, `]`, `(`, `)`, `<`, `>`, `|`) are now stripped where a
217
+ key is interpolated into the report headline, certification, and summary lines, so a
218
+ crafted key can't inject a link, image, or table into the comment. (Crop filenames were
219
+ already restricted to `[a-z0-9-]`; this is the display-side equivalent.) No code
220
+ execution was possible; this closes a Markdown-injection surface.
221
+
10
222
  ## [3.17.0] - 2026-07-05
11
223
 
12
224
  ### Changed
package/README.md CHANGED
@@ -53,9 +53,22 @@ It is useful for:
53
53
 
54
54
  The important boundary: StyleProof only certifies states it can reach. If a state
55
55
  matters, list it as a surface, variant, popup, live state, or component-catalog
56
- surface. `expected` turns that inventory into a guard, so an uncaptured new page,
57
- component, modal, dropdown, or toast fails as missing coverage instead of
58
- silently passing. A surface that exists only on the PR head is still reviewable:
56
+ surface. `expected` turns that inventory into a guard: a key it lists that is
57
+ neither captured nor excluded fails as missing coverage instead of silently
58
+ passing. What's guarded depends on how `expected` is fed
59
+
60
+ - **Next.js:** auto-guarded. `styleproof-init` wires `expected` to the routes it
61
+ discovers, so a new page fails the guard with nothing to keep in sync.
62
+ - **Link-crawled SPAs:** pass `expected` to `defineCrawlCapture` and the crawl
63
+ reconciles it against the _rendered nav_ (the route universe for such an app),
64
+ both directions — a new linked route with no `expected` entry fails, and an
65
+ `expected` route the nav stopped linking fails. This runs inside the capture, so
66
+ it fires when you capture (unlike the Next guard, which runs in your plain suite).
67
+ - **Other frameworks:** point `expected` at your own route registry.
68
+ - **Modals, dropdowns, toasts:** guarded only for the state keys you enumerate in
69
+ `expected` (e.g. `dashboard-dialog-open`) — nothing discovers UI states for you.
70
+
71
+ A surface that exists only on the PR head is still reviewable:
59
72
  in review-gate mode it holds the status red until approved, then becomes part of
60
73
  the baseline once merged.
61
74
 
@@ -195,6 +208,17 @@ defineCrawlCapture({
195
208
 
196
209
  Each discovered link becomes a surface keyed by its URL (`/?tab=overview` → `overview`; pass `key` for a different scheme). The app only has to render its nav as real `<a href>` links — a button-only nav (`<button onClick>`) exposes nothing to crawl. Replay, self-check and clock-freeze behave exactly as for explicit surfaces; one Playwright test runs the whole sweep (the link set isn't known until the page renders).
197
210
 
211
+ Pass `expected` (a route registry) to turn the crawl into a coverage guard: the crawl reconciles the rendered link set against it, both directions — a rendered link with no `expected` entry fails as a new route with no owner, and an `expected` route the nav stopped linking fails as a nav regression. For a link-crawled SPA the rendered nav _is_ the route universe, so this is the same list-vs-ledger discipline as the spec guard with the nav as the source of truth. Because the link set isn't known until the page renders, this reconciliation runs _inside the capture test_ — so it fires when you capture (`STYLEMAP_DIR` set), not in every `npm test`, unlike the static Next guard. A link that renders conditionally (behind auth or a feature flag) would otherwise make the guard flaky either direction; list it in `exclude` (`key → reason`) to opt it out visibly — an `exclude` key in neither `expected` nor the rendered nav fails as stale, so the ledger can't rot. Omit `expected` and the crawl keeps its default: capture what the nav links to, assert no completeness.
212
+
213
+ ```ts
214
+ defineCrawlCapture({
215
+ from: '/',
216
+ expected: ['index', 'pricing'], // the routes the nav must link to
217
+ exclude: { admin: 'feature-flagged, renders only for staff' },
218
+ dir: process.env.STYLEMAP_DIR,
219
+ });
220
+ ```
221
+
198
222
  **Component inventory: fail when the catalog misses a component.** StyleProof
199
223
  cannot render arbitrary component files by itself across frameworks; props,
200
224
  providers, loaders, portals, and app shell context are app-owned. What it can do
@@ -424,7 +448,7 @@ styleproof-capture https://example.com --crawl --out design # maps every reac
424
448
  styleproof-diff design .styleproof/maps/current # diff the whole surface vs your build
425
449
  ```
426
450
 
427
- It's **exhaustive by default**: the crawl stops when there is nothing left to drive — every control tried once, every structurally new surface captured — not at a budget. Termination is guaranteed by dedup (controls dedup by selector, surfaces by a structural fingerprint), and the `--max-depth` / `--max-actions` / `--max-states` flags exist only as deliberate throttles. It's deterministic (document order; the same surface reached two ways is captured once) and self-settling — it waits for an async app (React/Vue/Babel that boots after `load`) to mount before reading, so a bare crawl of a client-rendered page still captures the mounted UI.
451
+ It's **exhaustive by default**: the crawl stops when there is nothing left to drive — every control tried once, every structurally new surface captured — not at a budget. Dedup bounds the normal case controls dedup by selector, surfaces by a structural fingerprint, so a finite UI runs out of new surfaces — and the `--max-depth` cap bounds the pathological one: an append-generator (a composer that appends a fresh-identity node per click) never repeats a fingerprint, so dedup can't stop it; the depth cap (16 by default) does. `--max-depth` / `--max-actions` / `--max-states` are otherwise deliberate throttles. It's deterministic (document order; the same surface reached two ways is captured once) and self-settling — it waits for an async app (React/Vue/Babel that boots after `load`) to mount before reading, so a bare crawl of a client-rendered page still captures the mounted UI.
428
452
 
429
453
  What makes exhaustive affordable is that the sweep works **in place**: standing in a state, each control is clicked right where the page is, and a cheap DOM fingerprint decides what happened — a no-op click costs nothing, and only a state-changing click pays a reset (fresh navigation + replay of the click-path), which is then **verified by fingerprint** so children are never attributed to the wrong parent. New surfaces are captured at every width the moment they're reached — a deep or animated click-path is never re-driven to capture, so it can't be the thing that drops a surface. Progress streams as it goes, one line per captured surface. And it's **parallel by default** — `--workers <n>` (default 4) sweeps states concurrently on isolated browser contexts with the exact same surface set as a serial crawl (dedup is shared; children only enter the queue when their parent's sweep completes); `--workers 1` if you want byte-stable dup-key attribution.
430
454
 
@@ -543,7 +567,7 @@ is missing or incompatible, CI recaptures both sides in the same pinned
543
567
  environment before reporting. Correctness wins over a stale cache, but the hot
544
568
  path is report-only.
545
569
 
546
- > **Same-environment note.** Computed styles depend on the browser build and installed fonts, so maps are only comparable when captured in the same runtime environment. StyleProof records a compatibility key to select the right cached bundle and refuses to compare maps captured under different browser/platform settings; CI then recaptures both sides instead of producing a bogus report.
570
+ > **Same-environment note.** Computed styles depend on the browser build and installed fonts, so maps are only comparable when captured in the same runtime environment. StyleProof records a compatibility key to select the right cached bundle and refuses to compare maps captured under different browser/platform settings; CI then recaptures both sides instead of producing a bogus report. Each capture also records the **real browser build** (`browser().version()`) in its manifest — the npm `@playwright/test` version is only a proxy, and the actual Chromium binary can change while it holds constant (a `playwright install` re-download, a different `PLAYWRIGHT_BROWSERS_PATH`, a CI image bump). When both sides carry it, a differing build refuses to compare (exit 2, both builds named) instead of walling the PR with false diffs. **Installed fonts are your responsibility:** they are noisy across machines (user-installed families, OS updates, and no cheap cross-platform enumeration), so StyleProof does not fingerprint them — capture both sides on the same fonts, which is what CI's pinned image already gives you.
547
571
 
548
572
  **Want the local side-by-side report** (not just a pass/fail diff)? Run `npx
549
573
  styleproof-report` after `styleproof-map`; it uses the same inferred base ref and
@@ -709,7 +733,48 @@ const result = affectedSurfaces({
709
733
  // → 'all' (some change couldn't be bounded — capture everything)
710
734
  ```
711
735
 
712
- Two honest limits, both resolving to `'all'`: a computed `import(`../dir/${x}`)` is treated as a bundler **context module** (every file under that dir is a possible target, so precision there is directory-level, never a miss); and the CSS-in-JS global list (`createGlobalStyle`, `injectGlobal`, `globalStyle`, …) must match the libraries you use — an unrecognized global API is the one way a scoped verdict could be unsound, so treat an unsupported styling system as a reason to skip selective remap. Because a PR-time miss would be silent, always let `main` (or a scheduled run) capture **all** surfaces as the trust-but-verify net.
736
+ Two honest limits, both resolving to `'all'`: a computed `import(`../dir/${x}`)` is treated as a bundler **context module** (every file under that dir is a possible target, so precision there is directory-level, never a miss); and a CSS-Module (`.module.scss`/`.module.sass`) that carries a Sass `@use`/`@forward` load resolves to `'all'`, because those pull in a partial the JS import graph can't bound. One honest **residual** stays `'scope'` by design: the CSS-in-JS global list (`createGlobalStyle`, `injectGlobal`, `globalStyle`, …) must match the libraries you use — an allowlist can't fail closed on an _unknown_ member, so an unrecognized global API in a `.tsx` is the one way a scoped verdict could be unsound. Treat an unsupported styling system as a reason to skip selective remap. Because a PR-time miss would be silent, always let `main` (or a scheduled run) capture **all** surfaces as the trust-but-verify net.
737
+
738
+ ### Show the skip list, then wire the pre-push hook
739
+
740
+ Before you trust a skip, print it. `explainAffectedSurfaces(result, allSurfaceKeys)` renders the verdict as reviewer-checkable lines — which surfaces re-capture and which reuse their committed base map — and takes an optional reason string for the `'all'` case:
741
+
742
+ ```ts
743
+ import { affectedSurfaces, explainAffectedSurfaces } from 'styleproof';
744
+
745
+ const result = affectedSurfaces(/* … */);
746
+ console.log(explainAffectedSurfaces(result, Object.keys(surfaces)));
747
+ ```
748
+
749
+ A scoped change (only `dashboard`'s subtree touched) prints:
750
+
751
+ ```
752
+ selective remap: ON → re-capture 1, reuse 2 from base
753
+ ↻ dashboard (re-capture — a changed file reaches it)
754
+ ✓ home (reuse base map — no changed file reaches it)
755
+ ✓ pricing (reuse base map — no changed file reaches it)
756
+ ```
757
+
758
+ A global/token change fails closed to a full re-capture:
759
+
760
+ ```
761
+ selective remap: OFF → re-capture all 3 surface(s) — src/tokens.css is a global (unscoped) stylesheet
762
+ ↻ dashboard (re-capture)
763
+ ↻ home (re-capture)
764
+ ↻ pricing (re-capture)
765
+ ```
766
+
767
+ Wired into a pre-push hook, the whole recipe is: diff the changed files, produce the graph, ask `affectedSurfaces`, print the skip list, then capture only the subset and reuse the committed base maps for the rest.
768
+
769
+ ```sh
770
+ #!/usr/bin/env sh
771
+ # .husky/pre-push (opt-in; the default CI gate still captures every surface)
772
+ CHANGED=$(git diff --name-only origin/main...HEAD)
773
+ npx dependency-cruiser src --no-config --output-type json > dc.json
774
+ node scripts/selective-remap.mjs "$CHANGED" dc.json # affectedSurfaces + explain + capture subset
775
+ ```
776
+
777
+ `scripts/selective-remap.mjs` is yours to own — it maps `dc.json` into `{ from, to }` edges (as above), calls `affectedSurfaces`, prints `explainAffectedSurfaces`, then captures only the returned keys and copies each reused surface's committed base map forward. `main` re-captures everything, so a PR-time miss is still caught at merge.
713
778
 
714
779
  ## Newly-added elements show their full style
715
780
 
@@ -786,7 +851,7 @@ Non-visual and framework-injected elements (`<meta>`/`<title>`/`<script>`/`<styl
786
851
 
787
852
  - `styleproof-init` — scaffold the gate: the capture spec, a dedicated `playwright.styleproof.config.ts` (production-build `webServer`, parallel capture), `.gitignore` cache entries, and the cache-first report workflow. One command. Generated commands follow the repo's lockfile (`bun.lock`/`bun.lockb`, `pnpm-lock.yaml`, `yarn.lock`, or npm by default), respect pnpm/Corepack version pins, and detect Vite/Next production preview commands instead of assuming every repo has `start`.
788
853
  - `styleproof-map` — capture the current commit's computed-style map through Playwright. By default it writes `.styleproof/maps/current`, keeps screenshots for reports, writes a manifest, and uploads to `styleproof-maps` outside CI when the working tree was clean and a git remote exists. Pass `--crawl-base-url` plus repeated `--crawl-route` to run `styleproof-variants` before capture, `--no-upload`, `--restore --sha <commit>`, `--spec`, `--dir`, `--base-dir`, or `--no-screenshots` for custom flows.
789
- - `styleproof-diff` — the certify gate. With no args, it restores cached maps for the current commit and inferred base (`GITHUB_BASE_REF`, `branch.<name>.gh-merge-base`, `gh pr view`, then main/master fallbacks); `styleproof-diff main` / `styleproof-diff master` pins the base; `styleproof-diff <beforeDir> <afterDir>` keeps the manual two-directory form for CI fallback captures. Exits `0` certified (identical), `1` on a diff, `2` on a usage/capture error, `3` when only new surfaces are present (no baseline to diff against; approval policy decides whether to gate). A clean run prints `0 changed surfaces across N captured surface(s)`, and `--json` includes `compared`.
854
+ - `styleproof-diff` — the certify gate. With no args, it restores cached maps for the current commit and inferred base (`GITHUB_BASE_REF`, `branch.<name>.gh-merge-base`, `gh pr view`, then main/master fallbacks); `styleproof-diff main` / `styleproof-diff master` pins the base; `styleproof-diff <beforeDir> <afterDir>` keeps the manual two-directory form for CI fallback captures. Exits `0` certified (identical); `1` on a reviewable diff — computed-style/DOM/state differences, and equally an unacknowledged inventory removal, an incomplete coverage registry, or an unproven-determinism capture; `2` on a usage/capture error (including a **missing map** — a bundle that claims to exist yet holds zero captures, i.e. a `styleproof-manifest.json` present with no maps, on either side, or a head capture that produced nothing; refused loudly rather than mislabelled as all-new); `3` when only new surfaces are present (no baseline for _those_ surfaces to diff against — new surfaces against an existing baseline, or a base dir with no manifest at all, meaning no baseline was ever captured: the first-adoption review path; approval policy decides whether to gate). A clean run prints `0 changed surfaces across N captured surface(s)`, and `--json` includes `compared`.
790
855
  - `styleproof-report` — render the diff to a Markdown report with before/after crops. With no args, it reports cached maps for the current commit against the inferred base; `styleproof-report main` / `styleproof-report master` pins the base; `styleproof-report <beforeDir> <afterDir> --out <dir>` keeps the manual two-directory form. Add `--include-content` for the opt-in, advisory content section (see above).
791
856
  - `styleproof-variants` — crawl a running app for one-step state variants and write `styleproof.variants.generated.json`. Pass `--base-url`, repeat `--route`, and use `--strict` when unresolved skipped/live candidates should fail automation.
792
857
 
@@ -43,7 +43,9 @@ whole surface: --crawl
43
43
  --require-full-coverage
44
44
  exit 4 unless every class the page's stylesheets define was
45
45
  rendered in a captured surface — the machine check that
46
- NOTHING in the design was missed (coverage is always printed)
46
+ NOTHING in the design was missed (coverage is always printed).
47
+ An unreadable cross-origin sheet is residue too: its vocabulary
48
+ can't be proven covered, so it also fails the check.
47
49
  --setup <file> JSON steps (goto/fill/click/waitFor) run after EVERY fresh
48
50
  navigation — how input-gated states (login, unlock) become
49
51
  crawlable. \${ENV_VAR} in value/url is read from the
@@ -53,7 +55,10 @@ whole surface: --crawl
53
55
  fulfilled with 500 → error render)
54
56
  --workers <n> concurrent sweep workers (default 4); same surface set as a
55
57
  serial crawl — pass 1 for byte-stable key attribution
56
- --max-depth <n> throttle recursion depth (default: unbounded)
58
+ --until-covered stop the crawl early the moment every stylesheet class has
59
+ been rendered — a coverage-oriented sweep for design mockups
60
+ --max-depth <n> throttle recursion depth (default: 16 — backstop for
61
+ append-generator UIs)
57
62
  --max-actions <n> throttle controls tried per state (default: unbounded)
58
63
  --max-states <n> throttle total surfaces (default: unbounded)
59
64
  --no-reset-storage don't clear localStorage between steps (default: clear)
@@ -122,15 +127,25 @@ async function runCrawl() {
122
127
  `(${report.actionsTried} actions tried, ${report.skipped} skipped${report.failed.length ? `, ${report.failed.length} capture-failed` : ''})`,
123
128
  );
124
129
  const cov = report.coverage;
130
+ const unreadable = cov.unreadable ?? [];
131
+ if (unreadable.length > 0) {
132
+ console.log(
133
+ `⚠ coverage: ${unreadable.length} stylesheet(s) unreadable — class coverage not provable against them ` +
134
+ `(cross-origin, no CORS; make them same-origin / CORS-readable, or pin --widths):\n ${unreadable.join(' ')}`,
135
+ );
136
+ }
125
137
  if (cov.missing.length === 0) {
126
- console.log(`✓ coverage: all ${cov.defined} stylesheet classes rendered in at least one captured surface`);
138
+ if (unreadable.length === 0)
139
+ console.log(`✓ coverage: all ${cov.defined} stylesheet classes rendered in at least one captured surface`);
127
140
  } else {
128
141
  console.log(
129
142
  `⚠ coverage: ${cov.rendered}/${cov.defined} stylesheet classes rendered — ${cov.missing.length} never seen ` +
130
143
  `(dead CSS, or a state the crawl could not reach):\n ${cov.missing.join(' ')}`,
131
144
  );
132
- if (opts.requireFullCoverage) process.exit(4);
133
145
  }
146
+ // Residue under --require-full-coverage → exit 4: a never-seen class OR an
147
+ // unreadable sheet (whose vocabulary can't be proven covered at all).
148
+ if (opts.requireFullCoverage && (cov.missing.length > 0 || unreadable.length > 0)) process.exit(4);
134
149
  } finally {
135
150
  await browser.close();
136
151
  }
@@ -303,30 +303,48 @@ const invRemovals = printInventoryAudit(inventoryAudit);
303
303
  const coverageFails = printCoverageVerdict(coverageVerdict);
304
304
  const determinismFails = printDeterminismVerdict(determinismVerdict);
305
305
 
306
- if (jsonOut)
307
- fs.writeFileSync(
308
- jsonOut,
309
- JSON.stringify(
310
- {
311
- counts,
312
- surfaces,
313
- compared,
314
- coverage: coverageVerdict,
315
- determinism: determinismVerdict,
316
- // The inventory verdict, machine-readable — parallel to coverage/determinism and
317
- // to the report's certification block. `null` when no capture carried inventory.
318
- // `unacknowledged` is the gating set: a CI can hard-fail on `unacknowledged.length`.
319
- inventory: inventoryAudit && {
320
- removed: inventoryAudit.delta.removed.map((i) => i.key),
321
- added: inventoryAudit.delta.added.map((i) => i.key),
322
- unacknowledged: inventoryAudit.unexplained.map((i) => i.key),
323
- staleAcknowledgements: inventoryAudit.staleAllowances,
306
+ if (jsonOut) {
307
+ // A write failure (bad --json path, unwritable dir) is a usage/setup error, not a
308
+ // "reviewable differences" result — exit 2, never leak the exit-1 that CI reads as
309
+ // a real diff.
310
+ try {
311
+ fs.writeFileSync(
312
+ jsonOut,
313
+ JSON.stringify(
314
+ {
315
+ counts,
316
+ surfaces,
317
+ compared,
318
+ coverage: coverageVerdict,
319
+ determinism: determinismVerdict,
320
+ // The inventory verdict, machine-readable — parallel to coverage/determinism and
321
+ // to the report's certification block. `null` when no capture carried inventory.
322
+ // `unacknowledged` is the gating set: a CI can hard-fail on `unacknowledged.length`.
323
+ inventory: inventoryAudit && {
324
+ removed: inventoryAudit.delta.removed.map((i) => i.key),
325
+ added: inventoryAudit.delta.added.map((i) => i.key),
326
+ unacknowledged: inventoryAudit.unexplained.map((i) => i.key),
327
+ staleAcknowledgements: inventoryAudit.staleAllowances,
328
+ },
329
+ // Explain the `inventory: null` so a gate reading this JSON can tell "armed but no
330
+ // data" apart from "audited, nothing removed". Neither map carried inventory → set
331
+ // `inventory: true` in the capture spec (styleproof-init scaffolds it).
332
+ ...(inventoryAudit
333
+ ? {}
334
+ : {
335
+ inventoryNote:
336
+ 'no captured map carried an inventory — set `inventory: true` in the capture spec to arm the navigable-removal gate',
337
+ }),
324
338
  },
325
- },
326
- null,
327
- 2,
328
- ),
329
- );
339
+ null,
340
+ 2,
341
+ ),
342
+ );
343
+ } catch (e) {
344
+ console.error(`${COMMAND}: could not write --json ${jsonOut}: ${e.message}`);
345
+ process.exit(2);
346
+ }
347
+ }
330
348
 
331
349
  const total = counts.dom + counts.style + counts.state;
332
350
  const newSurfaces = surfaces.filter((s) => s.missing).length;
@@ -182,6 +182,13 @@ defineCrawlCapture({
182
182
  dir: process.env.STYLEMAP_DIR,
183
183
  // A single-route SPA whose views are ?tab= / client-routed? Keep only those:
184
184
  // match: /\\?tab=/,
185
+ // Turn the crawl into a coverage guard: reconcile the rendered nav against a route
186
+ // registry, both directions — a new linked route with no \`expected\` entry fails, and
187
+ // an \`expected\` route the nav stopped linking fails. (Runs inside the capture, so it
188
+ // fires when you capture, not in every test run.) List conditionally-rendered links
189
+ // (auth / feature-flag) in \`exclude\` so they can't flake the guard either direction:
190
+ // expected: ['index', 'pricing'],
191
+ // exclude: { admin: 'feature-flagged, renders only for staff' },
185
192
  // Certify menus, dialogs, tabs, and form-error states on every surface as variants:
186
193
  // variants: [{ key: 'menu-open', go: async (page) => { await page.getByRole('button', { name: /menu/i }).click(); } }],
187
194
  });
@@ -22,6 +22,7 @@ import {
22
22
  unknownFlagMessage,
23
23
  } from '../dist/cli-errors.js';
24
24
  import {
25
+ BROWSER_BUILD_SIDECAR,
25
26
  DEFAULT_MAP_DIR,
26
27
  DEFAULT_MAP_LABEL,
27
28
  DEFAULT_MAP_STORE_BRANCH,
@@ -29,6 +30,7 @@ import {
29
30
  MapStoreError,
30
31
  currentGitSha,
31
32
  expectedCompatibilityKey,
33
+ isMapFile,
32
34
  publishMapBundle,
33
35
  restoreMapBundle,
34
36
  workingTreeDirty,
@@ -284,6 +286,13 @@ if (restore) {
284
286
  }
285
287
  }
286
288
 
289
+ // Clear any prior run's browser-build sidecar before Playwright runs, so ONLY this
290
+ // run can have written it. The default dir (.styleproof/maps/current) is reused across
291
+ // runs; if this run records no browser version (the capture test not reached, or the
292
+ // handle unavailable), a stale sidecar would otherwise be read into the manifest and
293
+ // stamp a WRONG browser build that the compatibility guard then trusts as a fingerprint.
294
+ fs.rmSync(path.join(targetDir, BROWSER_BUILD_SIDECAR), { force: true });
295
+
287
296
  const command = process.platform === 'win32' ? 'playwright.cmd' : 'playwright';
288
297
  const configArgs =
289
298
  fs.existsSync(STYLEPROOF_PLAYWRIGHT_CONFIG) && !hasPlaywrightConfigArg(playwrightArgs)
@@ -307,6 +316,19 @@ if (result.error) {
307
316
  const status = result.status ?? 1;
308
317
  if (status === 0) {
309
318
  if (!keepHar) removeHarFiles(targetDir);
319
+ // A run that produced ZERO surface maps must not stamp a manifest (or upload):
320
+ // a manifest over an empty bundle would read as "a bundle that claims to exist
321
+ // yet holds nothing" and the diff would refuse it as a missing base map. A bare
322
+ // dir instead means "no baseline yet" — on a first adoption, capturing the base
323
+ // commit that predates the spec legitimately yields zero surfaces, and the diff
324
+ // then takes the exit-3 new-surfaces review path.
325
+ const captured = fs.existsSync(targetDir) ? fs.readdirSync(targetDir).filter(isMapFile).length : 0;
326
+ if (captured === 0) {
327
+ console.error(
328
+ 'styleproof-map: 0 surfaces captured — no manifest written; if this is the base side of a first adoption, the diff will treat it as no-baseline',
329
+ );
330
+ process.exit(status);
331
+ }
310
332
  try {
311
333
  let manifestSha = sha || undefined;
312
334
  if (!manifestSha) {
@@ -67,9 +67,33 @@ export type AffectedSurfaces = Set<string> | 'all';
67
67
  * unrecognized, is `'all'`.
68
68
  */
69
69
  export declare function classifyStyleChange(file: string, readFile: (p: string) => string): 'scope' | 'all';
70
+ /**
71
+ * Canonicalize a repo-relative path so the same file spells the same regardless
72
+ * of source (a `surfaces` value, a `changedFiles` entry, or a graph edge). Two
73
+ * tools disagree on `./pages/Home.tsx` vs `pages/Home.tsx` vs `pages//Home.tsx`;
74
+ * without one spelling, a reverse-reachability hit can silently miss the surface
75
+ * whose entry key was spelled differently, dropping it from the affected set —
76
+ * an unsound skip. Byte-cheap and fs-free: strip a leading `./`, collapse `//`,
77
+ * and drop `.`/`..` segments as pure string math (no realpath, no resolution). */
78
+ export declare function canonicalPath(p: string): string;
70
79
  /**
71
80
  * Compute the set of declared surfaces a change could have altered, or `'all'`.
72
81
  * See the module doc for the soundness contract. Any not in the returned set are
73
82
  * provably unaffected and may reuse their committed base map.
74
83
  */
75
84
  export declare function affectedSurfaces(input: AffectedSurfacesInput): AffectedSurfaces;
85
+ /**
86
+ * Render an {@link affectedSurfaces} verdict as human-readable lines a pre-push
87
+ * hook (or CI log) can print, so a reviewer can sanity-check the skip list before
88
+ * trusting it. Pure formatter — no I/O, no graph work.
89
+ *
90
+ * @param result the value {@link affectedSurfaces} returned.
91
+ * @param allSurfaces every declared surface key (e.g. `Object.keys(surfaces)`),
92
+ * so the helper can name what is *reused from base* — the ones
93
+ * the verdict skips — not just what re-captures.
94
+ * @param reason optional one-line explanation for an `'all'` verdict (e.g.
95
+ * the classifying file, from {@link classifyStyleChange}). The
96
+ * library doesn't attach a reason to the sentinel, so pass it
97
+ * if the caller knows why; omitted, the `'all'` line stands alone.
98
+ */
99
+ export declare function explainAffectedSurfaces(result: AffectedSurfaces, allSurfaces: Iterable<string>, reason?: string): string;