styleproof 3.16.0 → 3.18.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,132 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [3.18.0] - 2026-07-06
11
+
12
+ ### Added
13
+
14
+ - **Crawl coverage guard (`defineCrawlCapture` gains `expected` + `exclude`).** A
15
+ link-crawled SPA can now reconcile its _rendered nav_ against a declared route
16
+ registry, both directions: a rendered link with no `expected` entry fails as a new
17
+ route with no owner, and an `expected` route the nav stopped linking fails as a nav
18
+ regression. For such an app the nav is the route universe, so this is the spec
19
+ guard's list-vs-ledger discipline with the nav as the source of truth. Because the
20
+ link set isn't known until the page renders, the check runs _inside the capture
21
+ test_ (fires when `STYLEMAP_DIR` is set), not in the plain suite like the Next
22
+ guard. `exclude` (`key → reason`) opts out conditionally-rendered links (auth /
23
+ feature-flag) so they can't flake the guard; an `exclude` key in neither `expected`
24
+ nor the rendered nav fails as stale. Opt-in and backward-compatible: omit `expected`
25
+ and the crawl behaves exactly as before (captures what the nav links to, asserts no
26
+ completeness). New pure `crawlCoverageGaps` export for asserting reconciliation
27
+ yourself. README's "protected out of the box" scoped to what's wired per framework.
28
+
29
+ ### Fixed
30
+
31
+ - **`--crawl` now honours the fail-loud contract on unreadable CSS.** Three deviations on
32
+ the crawl path let a page with a cross-origin stylesheet be certified without ever being
33
+ fully looked at:
34
+ - Breakpoint auto-detection swallowed the "unreadable stylesheet" throw and silently swept
35
+ only 1280px — every other band certified unchanged without being rendered. The crawl now
36
+ propagates the throw like every other entry point (`styleproof-capture` one-shot,
37
+ `styleproof-map`); the message advises pinning `--widths` for a cross-origin-CSS page.
38
+ - The coverage verifier read class vocabulary only from _readable_ sheets, so a design
39
+ served cross-origin could pass `--require-full-coverage` with an artificially complete
40
+ verdict. Unreadable sheets are now counted and surfaced as **named residue**: a plain
41
+ crawl prints `N stylesheet(s) unreadable — class coverage not provable against them`, and
42
+ `--require-full-coverage` treats them as residue → exit 4. (`CrawlCoverage` gains an
43
+ `unreadable: string[]` field; purely additive.)
44
+ - `--max-depth` had two different defaults (16 in `CRAWL_DEFAULTS`, 1000 in the CLI). The
45
+ CLI default is now **16 everywhere** — the cap exists to bound append-generator UIs (a
46
+ composer that appends a fresh-identity node per click, which dedup can't terminate); 1000
47
+ made it decorative. Raise with `--max-depth` for a genuinely deeper nest.
48
+ - **The navigable-removal hard-gate now has data out of the box for new scaffolds — and
49
+ says so when it doesn't.** The Action defaults the inventory gate _on_ (3.14.0), but
50
+ inventory _capture_ defaults off, so on a spec that doesn't opt in, no map carries an
51
+ inventory, the diff's `inventory` verdict is `null`, and the gate counted zero removals
52
+ forever — armed, but with no ammunition. `styleproof-init` already scaffolds
53
+ `inventory: true` in the generated capture spec (since 3.15.0's zero-config default), so
54
+ **freshly scaffolded projects get the protection**; existing specs are untouched and stay
55
+ opt-in. To make the mismatch impossible to miss on pre-existing specs, the Action's gate
56
+ step now prints a `::notice::` — _"inventory gate is on but the captured maps carry no
57
+ inventory — set `inventory: true`"_ — instead of silently passing green, and
58
+ `styleproof-diff --json` emits an `inventoryNote` explaining the `null` verdict. Both are
59
+ notices, never failures: a spec that deliberately omits inventory capture keeps working.
60
+
61
+ - **The compatibility guard now keys on the real browser build, not just the Playwright
62
+ npm version.** Each capture records `browser().version()` (the actual Chromium build) in
63
+ its manifest, and `styleproof-diff` / `styleproof-report` refuse to compare two maps whose
64
+ builds differ (exit 2, both builds named). The npm `@playwright/test` version was only a
65
+ proxy: the actual binary can change while it holds constant — a `playwright install`
66
+ re-download after a cache wipe, a different `PLAYWRIGHT_BROWSERS_PATH`, or a CI image
67
+ bump — and two maps captured under different Chromium builds used to pass the guard and
68
+ then diff for real, walling the PR with false diffs the canonicalizer can't absorb.
69
+ Backward compatible: the build is compared only when **both** manifests carry it, so
70
+ bundles cached before this field keep comparing against each other; a build change now
71
+ fires the scaffolded recapture fallback instead of a cross-build compare. Fonts are
72
+ documented as an environment responsibility (too noisy across machines to fingerprint
73
+ cheaply); see the same-environment note.
74
+
75
+ - **A missing map is now refused, not mislabelled as "all new surfaces".** When one dir
76
+ held zero captures while the other held some, `styleproof-diff` used to mark every
77
+ surface `missing` and exit `3` ("only new surfaces") — the Action then rendered the
78
+ whole app as 🆕 new baselines a reviewer could approve wholesale. An empty **base** (a
79
+ restore that "succeeded" into an empty dir, a wrong `--base-dir`, a contributor without
80
+ the pre-push hook) meant approving a possibly fully-regressed head as the baseline; an
81
+ empty **head** (a head capture that produced nothing) meant approving a head that
82
+ rendered zero surfaces. Now: a bundle that claims to exist yet holds zero captures — a
83
+ `styleproof-manifest.json` present alongside no maps, on either side — and any empty
84
+ head exit `2` with their own named causes (`base map missing: restore it from the map
85
+ store or recapture both sides — refusing to treat every surface as new` / `head map
86
+ missing: the head capture produced zero surfaces — recapture the head side; refusing to
87
+ treat every surface as removed/new`), distinct enough that the scaffolded workflow's
88
+ capture-needed fallback is the obvious remedy in CI logs. A truly **bare** base dir (no
89
+ manifest, no maps) still means "no baseline exists yet" — the first-adoption flow where
90
+ the base commit predates the capture spec — and keeps the exit-`3` new-surfaces review
91
+ path. To keep that discrimination sound, `styleproof-map` no longer writes a manifest
92
+ (or uploads) when a capture run produced zero surfaces. `styleproof-report` shares the
93
+ load path and is refused identically, so it can't render the misleading all-new page
94
+ either. Exit `3` keeps its meaning: no baseline for _these_ surfaces — new against an
95
+ existing baseline, or the very first one.
96
+
97
+ ### Security
98
+
99
+ - **Surface keys are escaped before they reach the privileged PR comment.** Surface keys
100
+ originate from artifact filenames — attacker-controlled in the fork capture/report split
101
+ — and flow into the bot comment (sliced from `report.md`'s headline). Markdown/HTML
102
+ control characters (`` ` ``, `[`, `]`, `(`, `)`, `<`, `>`, `|`) are now stripped where a
103
+ key is interpolated into the report headline, certification, and summary lines, so a
104
+ crafted key can't inject a link, image, or table into the comment. (Crop filenames were
105
+ already restricted to `[a-z0-9-]`; this is the display-side equivalent.) No code
106
+ execution was possible; this closes a Markdown-injection surface.
107
+
108
+ ## [3.17.0] - 2026-07-05
109
+
110
+ ### Changed
111
+
112
+ - **`report.md` is bounded so GitHub can always render it.** A large redesign used to
113
+ produce a `report.md` too big for GitHub's markdown viewer (it refuses to render past
114
+ ~512 KB) — the reviewer clicked through and got _"we can't show files this big"_, so the
115
+ report was useless exactly when the change was biggest. The generator now holds
116
+ `report.md` to a byte budget (`maxReportBytes`, default ~400 KB): it emits full property
117
+ tables greedily, then lists any remaining changed surfaces as one-liners (name · change
118
+ count · crop link) under an announced banner. The exhaustive per-row detail is always in
119
+ `report.json` and every crop in `crops/`, so nothing is dropped from the certification —
120
+ only the inline detail is capped. A report a reviewer can't open isn't a source of truth.
121
+
122
+ ### Added
123
+
124
+ - **`affectedSurfaces` — selective-remap core (opt-in, advisory).** Given the files a
125
+ change touched, a declared surface→entry map, and a module graph (any tool's output
126
+ in `{ from, to }` shape — dependency-cruiser maps directly), returns exactly the
127
+ surfaces that could have rendered differently, or the sentinel `'all'`. Sound by
128
+ construction: it over-approximates and resolves every uncertainty to `'all'` — global
129
+ stylesheets/tokens, vanilla (unscoped) stylesheets, `createGlobalStyle`, design-system
130
+ configs, unbounded `import(x)`, and unplaceable files all force a full re-capture;
131
+ computed `import(`../dir/${x}`)` is recovered as a bundler context module (directory-
132
+ level, never a miss). Ships with `classifyStyleChange`. Purely additive, adds no
133
+ dependency, and never touches the default gate — the gate still captures every surface
134
+ and lets the map be the oracle. New export; existing specs unaffected.
135
+
10
136
  ## [3.16.0] - 2026-07-05
11
137
 
12
138
  ### Added
package/README.md CHANGED
@@ -27,6 +27,7 @@ report. Intentional visual changes get approved; unexpected ones block or fail.
27
27
  - [Forks and Dependabot](#forks-and-dependabot)
28
28
  - [Optional: content layer](#optional-content-layer-advisory)
29
29
  - [Optional: React component layer](#optional-react-component-layer-advisory)
30
+ - [Optional: selective remap](#optional-selective-remap-advisory)
30
31
  - [Newly-added elements show their full style](#newly-added-elements-show-their-full-style)
31
32
  - [Reference](#reference)
32
33
  - [Blocking without branch protection](#blocking-without-branch-protection)
@@ -52,9 +53,22 @@ It is useful for:
52
53
 
53
54
  The important boundary: StyleProof only certifies states it can reach. If a state
54
55
  matters, list it as a surface, variant, popup, live state, or component-catalog
55
- surface. `expected` turns that inventory into a guard, so an uncaptured new page,
56
- component, modal, dropdown, or toast fails as missing coverage instead of
57
- 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:
58
72
  in review-gate mode it holds the status red until approved, then becomes part of
59
73
  the baseline once merged.
60
74
 
@@ -194,6 +208,17 @@ defineCrawlCapture({
194
208
 
195
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).
196
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
+
197
222
  **Component inventory: fail when the catalog misses a component.** StyleProof
198
223
  cannot render arbitrary component files by itself across frameworks; props,
199
224
  providers, loaders, portals, and app shell context are app-owned. What it can do
@@ -423,7 +448,7 @@ styleproof-capture https://example.com --crawl --out design # maps every reac
423
448
  styleproof-diff design .styleproof/maps/current # diff the whole surface vs your build
424
449
  ```
425
450
 
426
- 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.
427
452
 
428
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.
429
454
 
@@ -542,7 +567,7 @@ is missing or incompatible, CI recaptures both sides in the same pinned
542
567
  environment before reporting. Correctness wins over a stale cache, but the hot
543
568
  path is report-only.
544
569
 
545
- > **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.
546
571
 
547
572
  **Want the local side-by-side report** (not just a pass/fail diff)? Run `npx
548
573
  styleproof-report` after `styleproof-map`; it uses the same inferred base ref and
@@ -679,6 +704,37 @@ Capture reads the React fiber in-page (`__reactFiber$*`/`__reactProps$*` on Reac
679
704
 
680
705
  Like the content layer it is **advisory**: never fed to the certification diff or the gate, so captures stay deterministic. Component names are mangled in minified production builds, so it's most useful against a dev / non-minified target; on a non-React page the fiber keys are absent and the field is simply omitted.
681
706
 
707
+ ## Optional: selective remap (advisory)
708
+
709
+ On a large app, capturing every surface on every PR is the slow part. `affectedSurfaces` answers the question that lets you skip most of it: **given the files a change touched, which declared surfaces could have rendered differently?** Everything it doesn't return can reuse its committed base map.
710
+
711
+ It is **opt-in and never part of the default gate** — the gate still captures every surface and lets the map be the oracle. This is a helper for wiring a faster pre-push/CI path yourself, and it is built to be wrong only in the safe direction: when it cannot _prove_ a surface is unaffected, it returns the sentinel `'all'` (re-capture everything). A global stylesheet or token, a vanilla (unscoped) stylesheet, a `createGlobalStyle`, a design-system config, an unbounded `import(x)`, or a file it can't place — all resolve to `'all'`.
712
+
713
+ The module graph is an **input**, so StyleProof stays framework-agnostic and adds no dependency. Produce it with any tool whose output you can shape into `{ from, to }` edges — [dependency-cruiser](https://www.npmjs.com/package/dependency-cruiser)'s `modules[].dependencies[]` maps directly:
714
+
715
+ ```ts
716
+ import { affectedSurfaces } from 'styleproof';
717
+ import { readFileSync } from 'node:fs';
718
+
719
+ // A dependency-cruiser run: `depcruise src --no-config --output-type json`
720
+ const cruise = JSON.parse(readFileSync('dc.json', 'utf8'));
721
+ const graph = cruise.modules.flatMap((m) =>
722
+ (m.dependencies ?? []).map((d) => ({ from: m.source, to: d.resolved, dynamic: d.dynamic })),
723
+ );
724
+
725
+ const result = affectedSurfaces({
726
+ changedFiles: ['src/components/PriceTable.tsx'], // e.g. `git diff --name-only origin/main`
727
+ surfaces: { home: 'src/pages/Home.tsx', pricing: 'src/pages/Pricing.tsx' },
728
+ graph,
729
+ files: cruise.modules.map((m) => m.source),
730
+ readFile: (p) => readFileSync(p, 'utf8'),
731
+ });
732
+ // → Set { 'pricing' } (capture only these; reuse the base map for the rest)
733
+ // → 'all' (some change couldn't be bounded — capture everything)
734
+ ```
735
+
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 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.
737
+
682
738
  ## Newly-added elements show their full style
683
739
 
684
740
  When a PR **adds** an element, StyleProof now reports its **full resting computed style** (background, padding, font, radius, …), value-only, in addition to any interaction-state deltas — previously an added element surfaced only its `:hover`/`:focus` changes. The new element already gates via its `added` finding; this only enriches what you see, in both the report and the `styleproof-diff` CLI.
@@ -754,7 +810,7 @@ Non-visual and framework-injected elements (`<meta>`/`<title>`/`<script>`/`<styl
754
810
 
755
811
  - `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`.
756
812
  - `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.
757
- - `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`.
813
+ - `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`.
758
814
  - `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).
759
815
  - `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.
760
816
 
@@ -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
@@ -122,15 +124,25 @@ async function runCrawl() {
122
124
  `(${report.actionsTried} actions tried, ${report.skipped} skipped${report.failed.length ? `, ${report.failed.length} capture-failed` : ''})`,
123
125
  );
124
126
  const cov = report.coverage;
127
+ const unreadable = cov.unreadable ?? [];
128
+ if (unreadable.length > 0) {
129
+ console.log(
130
+ `⚠ coverage: ${unreadable.length} stylesheet(s) unreadable — class coverage not provable against them ` +
131
+ `(cross-origin, no CORS; make them same-origin / CORS-readable, or pin --widths):\n ${unreadable.join(' ')}`,
132
+ );
133
+ }
125
134
  if (cov.missing.length === 0) {
126
- console.log(`✓ coverage: all ${cov.defined} stylesheet classes rendered in at least one captured surface`);
135
+ if (unreadable.length === 0)
136
+ console.log(`✓ coverage: all ${cov.defined} stylesheet classes rendered in at least one captured surface`);
127
137
  } else {
128
138
  console.log(
129
139
  `⚠ coverage: ${cov.rendered}/${cov.defined} stylesheet classes rendered — ${cov.missing.length} never seen ` +
130
140
  `(dead CSS, or a state the crawl could not reach):\n ${cov.missing.join(' ')}`,
131
141
  );
132
- if (opts.requireFullCoverage) process.exit(4);
133
142
  }
143
+ // Residue under --require-full-coverage → exit 4: a never-seen class OR an
144
+ // unreadable sheet (whose vocabulary can't be proven covered at all).
145
+ if (opts.requireFullCoverage && (cov.missing.length > 0 || unreadable.length > 0)) process.exit(4);
134
146
  } finally {
135
147
  await browser.close();
136
148
  }
@@ -322,6 +322,15 @@ if (jsonOut)
322
322
  unacknowledged: inventoryAudit.unexplained.map((i) => i.key),
323
323
  staleAcknowledgements: inventoryAudit.staleAllowances,
324
324
  },
325
+ // Explain the `inventory: null` so a gate reading this JSON can tell "armed but no
326
+ // data" apart from "audited, nothing removed". Neither map carried inventory → set
327
+ // `inventory: true` in the capture spec (styleproof-init scaffolds it).
328
+ ...(inventoryAudit
329
+ ? {}
330
+ : {
331
+ inventoryNote:
332
+ 'no captured map carried an inventory — set `inventory: true` in the capture spec to arm the navigable-removal gate',
333
+ }),
325
334
  },
326
335
  null,
327
336
  2,
@@ -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
  });
@@ -29,6 +29,7 @@ import {
29
29
  MapStoreError,
30
30
  currentGitSha,
31
31
  expectedCompatibilityKey,
32
+ isMapFile,
32
33
  publishMapBundle,
33
34
  restoreMapBundle,
34
35
  workingTreeDirty,
@@ -307,6 +308,19 @@ if (result.error) {
307
308
  const status = result.status ?? 1;
308
309
  if (status === 0) {
309
310
  if (!keepHar) removeHarFiles(targetDir);
311
+ // A run that produced ZERO surface maps must not stamp a manifest (or upload):
312
+ // a manifest over an empty bundle would read as "a bundle that claims to exist
313
+ // yet holds nothing" and the diff would refuse it as a missing base map. A bare
314
+ // dir instead means "no baseline yet" — on a first adoption, capturing the base
315
+ // commit that predates the spec legitimately yields zero surfaces, and the diff
316
+ // then takes the exit-3 new-surfaces review path.
317
+ const captured = fs.existsSync(targetDir) ? fs.readdirSync(targetDir).filter(isMapFile).length : 0;
318
+ if (captured === 0) {
319
+ console.error(
320
+ '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',
321
+ );
322
+ process.exit(status);
323
+ }
310
324
  try {
311
325
  let manifestSha = sha || undefined;
312
326
  if (!manifestSha) {
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Selective remap: given the files a change touched, which declared surfaces
3
+ * could have rendered differently?
4
+ *
5
+ * This is the sound core behind "capture only what a PR can affect, reuse the
6
+ * committed base map for the rest" — an OPT-IN speed-up, never the default gate.
7
+ * The default gate captures every surface and lets the map be the oracle; this
8
+ * function only decides which surfaces a caller *may* skip, and it is built to
9
+ * be wrong only in the safe direction.
10
+ *
11
+ * The hard constraint: in the committed-map model a wrong "unaffected" is silent
12
+ * and fatal — a stale committed map matches the base, the diff is empty, and the
13
+ * regression ships green. So this function OVER-APPROXIMATES. When it cannot
14
+ * prove a surface is unaffected, it returns the sentinel `'all'`, meaning
15
+ * "re-capture everything." Every uncertainty resolves to `'all'`:
16
+ *
17
+ * - a global style change (a reset, `:root`/theme token, `@tailwind`, a
18
+ * `createGlobalStyle`, a design-system config) cascades everywhere → `'all'`;
19
+ * - a vanilla (non-module) stylesheet has a global class namespace the import
20
+ * graph cannot bound → `'all'`;
21
+ * - a computed dynamic `import(x)` with no static prefix could load anything
22
+ * → `'all'`; with a static prefix (`import(`../dir/${x}`)`) it is treated as
23
+ * a bundler context module — every file under that dir is a possible target;
24
+ * - a changed file the graph cannot place at all → `'all'`.
25
+ *
26
+ * The module graph is an INPUT, not a dependency: pass any tool's output in the
27
+ * {@link ModuleEdge} shape (dependency-cruiser's `modules[].dependencies[]` maps
28
+ * directly). StyleProof stays framework-agnostic and adds no dependency; the
29
+ * caller owns graph production, which is where framework-specific resolution
30
+ * lives.
31
+ *
32
+ * Pure and side-effect-free (I/O is injected via `readFile`) so it is fully
33
+ * unit-testable and deterministic.
34
+ */
35
+ /** One resolved import edge: `from` imports `to`. Mirrors a dependency-cruiser
36
+ * `modules[].dependencies[]` entry (use `module.source` as `from`, dependency
37
+ * `resolved` as `to`). `dynamic` is informational; resolution already happened. */
38
+ export type ModuleEdge = {
39
+ from: string;
40
+ to: string;
41
+ dynamic?: boolean;
42
+ };
43
+ export type AffectedSurfacesInput = {
44
+ /** Repo-relative paths the change touched (as they appear in the graph). */
45
+ changedFiles: Iterable<string>;
46
+ /** Declared surfaces: capture key → the surface's entry module path. */
47
+ surfaces: Record<string, string>;
48
+ /** Resolved import edges for the source tree (node_modules edges are ignored). */
49
+ graph: Iterable<ModuleEdge>;
50
+ /** Every candidate source file path — the universe a context-module glob can
51
+ * resolve within. Typically the graph's node set. */
52
+ files: Iterable<string>;
53
+ /** Read a source file's text (for style classification and dynamic-import
54
+ * recovery). Throwing/returning nothing is treated as "unknown" → `'all'`. */
55
+ readFile: (path: string) => string;
56
+ };
57
+ /** `'all'` means "re-capture everything" (some change could not be bounded). A
58
+ * `Set` of surface keys means exactly those surfaces can be affected; any not
59
+ * listed are provably unaffected and may reuse their committed base map. */
60
+ export type AffectedSurfaces = Set<string> | 'all';
61
+ /**
62
+ * Decide whether a single changed file's style scope is bounded to the files
63
+ * that import it (`'scope'` → follow the import graph) or escapes them
64
+ * (`'all'` → re-capture everything). Sound by construction: `'scope'` is
65
+ * returned only for provably-scoped changes (a CSS Module without escapes, or
66
+ * colocated CSS-in-JS with no global API); everything else, including anything
67
+ * unrecognized, is `'all'`.
68
+ */
69
+ export declare function classifyStyleChange(file: string, readFile: (p: string) => string): 'scope' | 'all';
70
+ /**
71
+ * Compute the set of declared surfaces a change could have altered, or `'all'`.
72
+ * See the module doc for the soundness contract. Any not in the returned set are
73
+ * provably unaffected and may reuse their committed base map.
74
+ */
75
+ export declare function affectedSurfaces(input: AffectedSurfacesInput): AffectedSurfaces;