styleproof 4.1.0 → 4.3.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,84 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [4.3.0] - 2026-07-13
11
+
12
+ ### Changed
13
+
14
+ - **A removed surface now blocks (exit 1) instead of riding the exit-3 "only new
15
+ surfaces" path.** A surface captured only on the base side is a deleted route
16
+ or a dropped width — a reviewable change, printed as `✗ REMOVED surface` —
17
+ never an onboarding case the approve-all box waves through under a "new
18
+ surfaces" banner. Exit 3 now means only NEW (head-side-only) surfaces.
19
+ - **Stale acknowledgements gate everywhere.** A stale `allowRemoved` entry in
20
+ `styleproof.inventory.json` now blocks like a stale data-residue
21
+ acknowledgement (and the Action's inventory hard-gate counts both), so a
22
+ ledger entry left behind cannot pre-acknowledge the next removal of that key.
23
+
24
+ ### Fixed
25
+
26
+ - **Crawls never capture an off-origin redirect target.** A same-origin link
27
+ that 302s to another origin (SSO, `/out?url=…`) previously entered the map as
28
+ a surface — third-party, nondeterministic content. The CLI sweep now skips the
29
+ page loudly and continues; a spec-driven capture fails naming the surface; an
30
+ entry URL that redirects off-origin is a hard error.
31
+ - **The gate now surfaces what it did NOT compare.** The diff prints (and
32
+ `--json` carries) the count of auto-detected volatile subtrees excluded from
33
+ the comparison, and warns when the forced-state layer was skipped on BOTH
34
+ sides — previously a head-side volatile region or a twice-skipped state layer
35
+ could hide a real change with zero trace in the output.
36
+ - **A corrupt coverage ledger is a hard error (exit 2), not a silent disarm.**
37
+ Unparseable `styleproof-coverage.json` used to read as "no registry", quietly
38
+ turning the coverage, determinism, and residue gates into warnings at once.
39
+ The residue footer also no longer misattributes a ledger-less bundle to the
40
+ `dataResidue: "warn"` opt-out.
41
+ - **The Action fails closed on unexpected diff exit codes.** A node crash, OOM
42
+ kill, or missing file (127/137/143/…) previously fell through to
43
+ `changed=false` — a green "No visual changes" status for a run that never
44
+ compared anything.
45
+ - **Approval can no longer wave through certification failures.** The Action
46
+ gained a post-approval hard gate (like the inventory gate) for incomplete
47
+ coverage, unproven determinism, and armed data-residue failures — none of
48
+ which are restyles the approve-all checkbox should clear.
49
+ - Stale `exclude` entries are printed with the coverage verdict, and "coverage
50
+ complete" now says "captured or explicitly excluded".
51
+
52
+ ## [4.2.0] - 2026-07-13
53
+
54
+ ### Changed
55
+
56
+ - **`styleproof-capture --crawl` now follows same-origin nav links.** Every
57
+ page the site links to is crawled like the entry page, keyed by its route
58
+ (`about`, `pricing`, …), with class coverage aggregated across the pages that
59
+ share stylesheets. Previously the CLI crawl drove controls but silently
60
+ dropped links, so a multi-page site reported "1/1 surfaces, coverage ✓" while
61
+ losing every other page. `--no-follow-links` restores the entry-page-only
62
+ sweep.
63
+
64
+ ### Fixed
65
+
66
+ - **A non-head checkout in CI is no longer stamped with the pull request's head
67
+ SHA.** `currentGitSha` now trusts `git rev-parse HEAD` and relabels only a
68
+ checkout of the synthetic `GITHUB_SHA` merge commit, so a base-branch capture
69
+ (the scaffolded cache-miss job) can never publish a base-tree map under the
70
+ head's store key — a store poisoning that made later restores diff
71
+ base-vs-base and report a false green. `pull_request_target` payloads are no
72
+ longer trusted for relabeling at all, and a malformed `STYLEPROOF_SHA`
73
+ override now errors instead of silently falling back.
74
+ - **Annotation move-suppression now requires provable displacement.** A
75
+ cross-path annotation match may suppress its magenta boxes only when the
76
+ container where the two paths diverge gained or lost captured children, or
77
+ when the element slid into a vacated slot in its own container. This fixes
78
+ three truthfulness bugs at once: a size-changing duplicate restyle swap no
79
+ longer loses all visual proof, a sibling insertion into a uniform-shell list
80
+ (menus, navs) no longer re-boxes every unchanged displaced item, and an
81
+ independent removal + identical addition in different containers no longer
82
+ cancel each other's annotations to zero.
83
+ - **`styleproof-diff` no longer misreports fresh ad-hoc captures as predating
84
+ the determinism ledger.** The unknown-basis warning now names the real cause:
85
+ ad-hoc `styleproof-capture` output records no ledger; spec-driven captures
86
+ self-check and do.
87
+
10
88
  ## [4.1.0] - 2026-07-12
11
89
 
12
90
  ### Fixed
@@ -21,6 +99,11 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
21
99
  and published report links point to the immutable report commit.
22
100
  - **Certify-mode report comments now carry their source head marker**, so stale
23
101
  comments can be distinguished from the current PR head.
102
+ - **Annotation reconciliation preserves duplicate additions, removals, and
103
+ forced-state changes.** Matching is one-to-one within a structural
104
+ neighborhood, and indistinguishable duplicate provenance is reported as a
105
+ deterministic unmatched occurrence rather than guessed. Ambiguous duplicate
106
+ restyle swaps retain visual proof on both sides.
24
107
 
25
108
  ## [4.0.2] - 2026-07-11
26
109
 
package/README.md CHANGED
@@ -217,11 +217,15 @@ summary, and the exact property change folded under a toggle. A change too small
217
217
  to see at 1:1 (say a 2px icon tweak) also gets a magnified zoom crop, so a
218
218
  sub-pixel change can't slip past a reviewer.
219
219
 
220
- Annotation boxes reconcile exact-equivalent elements that moved to a different
221
- structural path. When an unkeyed sibling insertion shifts unchanged descendants,
222
- the clean comparison still shows the complete rendered result and the audit keeps
223
- every structural finding, but magenta boxes mark only unmatched additions,
224
- removals, and restyles instead of painting the displaced subtree as changed.
220
+ Annotation boxes reconcile exact-equivalent elements one-to-one within a structural
221
+ neighborhood. When an unkeyed sibling insertion shifts unchanged descendants, the
222
+ clean comparison still shows the complete rendered result and the audit keeps every
223
+ structural finding, but magenta boxes mark only unmatched additions, removals, and
224
+ restyles instead of painting the displaced subtree as changed. Visually identical
225
+ duplicates have no provable physical provenance, so the report marks a deterministic
226
+ unmatched occurrence rather than claiming which duplicate moved. When duplicate
227
+ restyles could also be explained as a swap, the report keeps both sides annotated
228
+ because the captured data cannot prove a move.
225
229
 
226
230
  When one change appears both on an ordinary page and in an open popup, the report
227
231
  chooses a representative where the changed element is visibly exposed, then prefers
@@ -757,6 +761,8 @@ You watch one number as you implement: the diff starts large and shrinks toward
757
761
 
758
762
  A design is mostly _behind clicks_ — modals, drawers, popovers, tabs that don't exist in the DOM until you open them. A single capture sees only the landing state. `--crawl` maps the rest for you: point it at the URL and it drives every non-destructive control, keeps whatever opens a structurally new surface, and recurses into it — a modal's tabs, a drawer's sub-views, a popover's panels — capturing each under a derived key. No spec, no selectors, no hand-holding.
759
763
 
764
+ It follows the nav too: every same-origin page the site links to is crawled the same way, keyed by its route (`about`, `pricing`, `blog-post`, …), with class coverage aggregated across the pages that share stylesheets. `--no-follow-links` keeps the sweep to the entry page's interactive surface only.
765
+
760
766
  ```bash
761
767
  styleproof-capture https://example.com --crawl --out design # maps every reachable surface
762
768
  styleproof-diff design .styleproof/maps/current # diff the whole surface vs your build
@@ -1007,7 +1013,7 @@ Non-visual and framework-injected elements (`<meta>`/`<title>`/`<script>`/`<styl
1007
1013
 
1008
1014
  - `styleproof-init` — scaffold the gate: the capture spec (inventory guard on; Next.js repos get route discovery + the coverage guard, others a crawl-by-default spec), a dedicated `playwright.styleproof.config.ts` (production-build `webServer`, parallel capture), `.gitignore` cache entries, the cache-first report workflow, the approval workflow, and the pre-push publish hook. 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`.
1009
1015
  - `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.
1010
- - `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 unacknowledged failing data endpoint under an armed `dataResidue: 'gate'`, an incomplete coverage registry, or an unproven-determinism capture; `2` on a usage/capture error (including a **manifest-less side** — since **v4**, a two-directory compare where a side ships maps but no `styleproof-manifest.json` is refused loudly, naming the bare side(s), because the same-environment guard can't be enforced without one; re-capture with current StyleProof; **and** 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 — **and** the no-args case where the cached base map can't be restored at all: no map-store remote, no cached bundle, nothing to compare. A "nothing was compared" outcome always exits `2`, never a soft `0` that would read as certified; the error names the two ways forward — run in CI where the base is restorable, or use the two-directory form); `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 maps at all (and hence no manifest), 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`. The human output **groups the same way the report does**: surfaces that changed identically collapse into one finding (with the per-surface count on its header), longhands fold into shorthands, and size/position-derived longhands fold behind a `(+N derived longhands)` count — so one real change reads as one entry, not dozens of raw lines. A change that rode the shared frame every view draws (a persistent nav/header/footer) is promoted to a "🧱 Global chrome change" callout up top. `--json` stays the complete, unchanged machine contract — every surface and every raw longhand — regardless of the human grouping.
1016
+ - `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 unacknowledged failing data endpoint under an armed `dataResidue: 'gate'`, an incomplete coverage registry, or an unproven-determinism capture; `2` on a usage/capture error (including a **manifest-less side** — since **v4**, a two-directory compare where a side ships maps but no `styleproof-manifest.json` is refused loudly, naming the bare side(s), because the same-environment guard can't be enforced without one; re-capture with current StyleProof; **and** 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 — **and** the no-args case where the cached base map can't be restored at all: no map-store remote, no cached bundle, nothing to compare. A "nothing was compared" outcome always exits `2`, never a soft `0` that would read as certified; the error names the two ways forward — run in CI where the base is restorable, or use the two-directory form); `3` when only new surfaces are present — surfaces captured only on the **head** side (a surface present only on the **base** side is a **removed** surface, a reviewable change: exit `1`) — (no baseline for _those_ surfaces to diff against — new surfaces against an existing baseline, or a base dir with no maps at all (and hence no manifest), 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`. The human output **groups the same way the report does**: surfaces that changed identically collapse into one finding (with the per-surface count on its header), longhands fold into shorthands, and size/position-derived longhands fold behind a `(+N derived longhands)` count — so one real change reads as one entry, not dozens of raw lines. A change that rode the shared frame every view draws (a persistent nav/header/footer) is promoted to a "🧱 Global chrome change" callout up top. `--json` stays the complete, unchanged machine contract — every surface and every raw longhand — regardless of the human grouping.
1011
1017
  - `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).
1012
1018
  - `styleproof-capture` — one-shot capture of any URL (no spec): `styleproof-capture <url> --key <name> --out <dir>`, with `--widths` (omit to auto-detect `@media` bands), `--wait <selector>`, `--ignore <selector>`, `--no-screenshots`, and the crawler flags (`--crawl`, `--setup <file>`, `--require-full-coverage` → exit 4 on residue, `--until-covered`, `--workers <n>`, `--no-data-states`) described in [Match a design pixel-for-pixel](#match-a-design-pixel-for-pixel).
1013
1019
  - `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.
@@ -20,6 +20,7 @@ import { chromium } from '@playwright/test';
20
20
  import { isHelpArg, showHelpAndExit } from '../dist/cli-errors.js';
21
21
  import { UsageError, parseCaptureUrlArgs, runCaptureUrl, loadSetupSteps } from '../dist/capture-url.js';
22
22
  import { crawlAndCapture } from '../dist/crawl-surfaces.js';
23
+ import { selectCrawlLinks, dedupIdentity } from '../dist/crawl.js';
23
24
  import { writeCaptureManifest } from '../dist/map-store.js';
24
25
 
25
26
  const COMMAND = 'styleproof-capture';
@@ -56,6 +57,9 @@ whole surface: --crawl
56
57
  fulfilled with 500 → error render)
57
58
  --workers <n> concurrent sweep workers (default 4); same surface set as a
58
59
  serial crawl — pass 1 for byte-stable key attribution
60
+ --no-follow-links crawl the entry page's interactive surface only. By default
61
+ every same-origin page the nav links to is crawled too,
62
+ each keyed by its route (about, pricing, blog-post, ...)
59
63
  --until-covered stop the crawl early the moment every stylesheet class has
60
64
  been rendered — a coverage-oriented sweep for design mockups
61
65
  --max-depth <n> throttle recursion depth (default: 16 — backstop for
@@ -95,61 +99,147 @@ try {
95
99
  throw e;
96
100
  }
97
101
 
102
+ // Read the freshly-loaded page's same-origin nav links, keyed by route.
103
+ async function harvestPageLinks(page, url) {
104
+ await page.goto(url, { waitUntil: 'load' });
105
+ const hrefs = await page.$$eval('a[href]', (els) => els.map((e) => e.getAttribute('href'))).catch(() => []);
106
+ return selectCrawlLinks(hrefs, { base: page.url() });
107
+ }
108
+
109
+ function printCoverage(cov, label) {
110
+ const unreadable = cov.unreadable ?? [];
111
+ if (unreadable.length > 0) {
112
+ console.log(
113
+ `⚠ coverage${label}: ${unreadable.length} stylesheet(s) unreadable — class coverage not provable against them ` +
114
+ `(cross-origin, no CORS; make them same-origin / CORS-readable, or pin --widths):\n ${unreadable.join(' ')}`,
115
+ );
116
+ }
117
+ if (cov.missing.length === 0) {
118
+ if (unreadable.length === 0)
119
+ console.log(
120
+ `✓ coverage${label}: all ${cov.defined} stylesheet classes rendered in at least one captured surface`,
121
+ );
122
+ } else {
123
+ console.log(
124
+ `⚠ coverage${label}: ${cov.rendered}/${cov.defined} stylesheet classes rendered — ${cov.missing.length} never seen ` +
125
+ `(dead CSS, or a state the crawl could not reach):\n ${cov.missing.join(' ')}`,
126
+ );
127
+ }
128
+ }
129
+
130
+ function pageCrawlOptions(browser, url, prefix, statesLeft) {
131
+ return {
132
+ url,
133
+ out: opts.out,
134
+ widths: opts.widths, // empty = auto-detect the page's real breakpoints
135
+ ignore: opts.ignore,
136
+ height: opts.height,
137
+ screenshots: opts.screenshots,
138
+ waitSelector: opts.waitSelector,
139
+ maxDepth: opts.maxDepth,
140
+ maxActionsPerState: opts.maxActionsPerState,
141
+ maxStates: statesLeft,
142
+ resetStorage: opts.resetStorage,
143
+ setup: setupSteps,
144
+ dataStates: opts.dataStates,
145
+ stopWhenCovered: opts.untilCovered,
146
+ workers: opts.workers,
147
+ keyPrefix: prefix,
148
+ // each worker page in its OWN context, so storage resets can't interfere
149
+ newPage: async () => (await browser.newContext()).newPage(),
150
+ // Stream each surface as it is captured, so progress is visible live and an
151
+ // interrupted run still shows exactly what it mapped.
152
+ onSurface: (s, ok) =>
153
+ console.log(` ${'·'.repeat(s.depth)}${s.key} (${s.elements} elements)${ok ? '' : ' — CAPTURE FAILED'}`),
154
+ };
155
+ }
156
+
157
+ // Aggregate coverage: pages share stylesheets, so a class unrendered on one
158
+ // page but rendered on another IS covered. defined = rendered ∪ missing.
159
+ function aggregateCoverage(reports) {
160
+ const rendered = new Set(reports.flatMap((r) => r.coverage.renderedClasses));
161
+ const missing = [...new Set(reports.flatMap((r) => r.coverage.missing))].filter((c) => !rendered.has(c)).sort();
162
+ const unreadable = [...new Set(reports.flatMap((r) => r.coverage.unreadable ?? []))];
163
+ return { defined: rendered.size + missing.length, rendered: rendered.size, missing, unreadable };
164
+ }
165
+
166
+ function printCrawlSummary(reports) {
167
+ const surfaces = reports.reduce((n, r) => n + r.surfaces.length, 0);
168
+ const captured = reports.reduce((n, r) => n + r.captured, 0);
169
+ const tried = reports.reduce((n, r) => n + r.actionsTried, 0);
170
+ const skipped = reports.reduce((n, r) => n + r.skipped, 0);
171
+ const failed = reports.flatMap((r) => r.failed);
172
+ const widths = opts.widths.length ? `${opts.widths.length} width(s)` : 'auto widths';
173
+ console.log(
174
+ `✓ ${captured}/${surfaces} surface(s) across ${reports.length} page(s) × ${widths} → ${opts.out} ` +
175
+ `(${tried} actions tried, ${skipped} skipped${failed.length ? `, ${failed.length} capture-failed` : ''})`,
176
+ );
177
+ }
178
+
179
+ // Enqueue every not-yet-seen page the just-crawled page links to, giving each a
180
+ // unique route-key prefix ('base' is reserved for the entry crawl's root).
181
+ function enqueueLinkedPages(links, sweep) {
182
+ for (const link of links) {
183
+ const id = dedupIdentity(link.url);
184
+ if (sweep.seenPages.has(id)) continue;
185
+ sweep.seenPages.add(id);
186
+ let prefix = link.key;
187
+ for (let i = 2; sweep.usedPrefixes.has(prefix); i++) prefix = `${link.key}-${i}`;
188
+ sweep.usedPrefixes.add(prefix);
189
+ sweep.queue.push({ url: new URL(link.url, sweep.entry).href, prefix });
190
+ }
191
+ }
192
+
193
+ // Crawl one page of the sweep. The entry page failing is a broken run (rethrow);
194
+ // a LINKED page failing (e.g. an off-origin redirect) returns null after warning,
195
+ // and the sweep continues.
196
+ async function crawlPage(browser, page, url, prefix, statesLeft) {
197
+ try {
198
+ return await crawlAndCapture(page, pageCrawlOptions(browser, url, prefix, statesLeft));
199
+ } catch (e) {
200
+ if (prefix === '') throw e;
201
+ console.log(`⚠ ${url}: ${e instanceof Error ? e.message : String(e)} — page skipped`);
202
+ return null;
203
+ }
204
+ }
205
+
98
206
  async function runCrawl() {
99
207
  const browser = await chromium.launch();
100
208
  try {
101
209
  const page = await browser.newPage();
102
- const crawlOpts = {
103
- url: opts.url,
104
- out: opts.out,
105
- widths: opts.widths, // empty = auto-detect the page's real breakpoints
106
- ignore: opts.ignore,
107
- height: opts.height,
108
- screenshots: opts.screenshots,
109
- waitSelector: opts.waitSelector,
110
- maxDepth: opts.maxDepth,
111
- maxActionsPerState: opts.maxActionsPerState,
112
- maxStates: opts.maxStates,
113
- resetStorage: opts.resetStorage,
114
- setup: setupSteps,
115
- dataStates: opts.dataStates,
116
- stopWhenCovered: opts.untilCovered,
117
- workers: opts.workers,
118
- // each worker page in its OWN context, so storage resets can't interfere
119
- newPage: async () => (await browser.newContext()).newPage(),
120
- // Stream each surface as it is captured, so progress is visible live and an
121
- // interrupted run still shows exactly what it mapped.
122
- onSurface: (s, ok) =>
123
- console.log(` ${'·'.repeat(s.depth)}${s.key} (${s.elements} elements)${ok ? '' : ' — CAPTURE FAILED'}`),
210
+ // Page-level breadth-first sweep: crawl the entry page's whole interactive
211
+ // surface, then every same-origin page its nav links to (and theirs), each
212
+ // namespaced by its route key so a shared --out directory never collides.
213
+ const entry = new URL(opts.url);
214
+ const sweep = {
215
+ entry,
216
+ queue: [{ url: opts.url, prefix: '' }],
217
+ seenPages: new Set([dedupIdentity(entry.pathname + entry.search)]),
218
+ usedPrefixes: new Set(['base']), // 'base' is the entry crawl's root key
124
219
  };
125
- const report = await crawlAndCapture(page, crawlOpts);
220
+ const reports = [];
221
+ let statesLeft = opts.maxStates;
222
+
223
+ while (sweep.queue.length > 0 && statesLeft > 0) {
224
+ const { url, prefix } = sweep.queue.shift();
225
+ const report = await crawlPage(browser, page, url, prefix, statesLeft);
226
+ if (!report) continue; // linked page skipped loudly (e.g. off-origin redirect)
227
+ reports.push(report);
228
+ statesLeft -= report.surfaces.length;
229
+ if (opts.followLinks) enqueueLinkedPages(await harvestPageLinks(page, url), sweep);
230
+ }
231
+ if (sweep.queue.length > 0)
232
+ console.log(`⚠ --max-states reached: ${sweep.queue.length} linked page(s) left uncrawled — raise --max-states`);
233
+
126
234
  // Stamp a manifest so a two-directory diff against this crawl output has the
127
235
  // same-environment guard on both sides (v4 refuses a manifest-less side).
128
236
  writeCaptureManifest({ dir: opts.out, screenshots: opts.screenshots });
129
- console.log(
130
- `✓ ${report.captured}/${report.surfaces.length} surface(s) × ${crawlOpts.widths.length} width(s) → ${opts.out} ` +
131
- `(${report.actionsTried} actions tried, ${report.skipped} skipped${report.failed.length ? `, ${report.failed.length} capture-failed` : ''})`,
132
- );
133
- const cov = report.coverage;
134
- const unreadable = cov.unreadable ?? [];
135
- if (unreadable.length > 0) {
136
- console.log(
137
- `⚠ coverage: ${unreadable.length} stylesheet(s) unreadable — class coverage not provable against them ` +
138
- `(cross-origin, no CORS; make them same-origin / CORS-readable, or pin --widths):\n ${unreadable.join(' ')}`,
139
- );
140
- }
141
- if (cov.missing.length === 0) {
142
- if (unreadable.length === 0)
143
- console.log(`✓ coverage: all ${cov.defined} stylesheet classes rendered in at least one captured surface`);
144
- } else {
145
- console.log(
146
- `⚠ coverage: ${cov.rendered}/${cov.defined} stylesheet classes rendered — ${cov.missing.length} never seen ` +
147
- `(dead CSS, or a state the crawl could not reach):\n ${cov.missing.join(' ')}`,
148
- );
149
- }
237
+ printCrawlSummary(reports);
238
+ const cov = aggregateCoverage(reports);
239
+ printCoverage(cov, reports.length > 1 ? ` (${reports.length} pages)` : '');
150
240
  // Residue under --require-full-coverage → exit 4: a never-seen class OR an
151
241
  // unreadable sheet (whose vocabulary can't be proven covered at all).
152
- if (opts.requireFullCoverage && (cov.missing.length > 0 || unreadable.length > 0)) process.exit(4);
242
+ if (opts.requireFullCoverage && (cov.missing.length > 0 || cov.unreadable.length > 0)) process.exit(4);
153
243
  } finally {
154
244
  await browser.close();
155
245
  }
@@ -20,7 +20,8 @@
20
20
  *
21
21
  * Custom properties (--*) are ignored: they are inputs, not outcomes (see
22
22
  * README). Exit code 0 = identical, 1 = reviewable differences, 2 = usage/capture
23
- * error, 3 = only new surfaces (present on one side, no baseline to diff against).
23
+ * error, 3 = only NEW surfaces (present only on the head side, no baseline to diff
24
+ * against). A REMOVED surface (present only on the base side) is a change: exit 1.
24
25
  */
25
26
  import fs from 'node:fs';
26
27
  import path from 'node:path';
@@ -109,12 +110,15 @@ function printInventoryAudit(audit) {
109
110
  );
110
111
  }
111
112
  for (const it of delta.added) console.log(` + added: ${it.key} ("${it.label}")`);
112
- for (const k of staleAllowances) console.log(` ⚠ stale allowRemoved (key is not actually removed): ${k}`);
113
+ for (const k of staleAllowances)
114
+ console.log(` ✗ stale allowRemoved (key is not actually removed): ${k} — prune it from styleproof.inventory.json`);
113
115
  if (unexplained.length)
114
116
  console.log(
115
117
  ` → ${unexplained.length} unacknowledged removal(s): restore the affordance, or record the decision in styleproof.inventory.json {"<key>":"<why>"}.`,
116
118
  );
117
- return unexplained.length;
119
+ // A stale allowance BLOCKS like a stale residue acknowledgement: left in place it
120
+ // pre-acknowledges the NEXT removal of that key, so the ledger must not rot.
121
+ return unexplained.length + staleAllowances.length;
118
122
  }
119
123
 
120
124
  // ── data-residue guard (gate by default) ─────────────────────────────────────────
@@ -139,11 +143,11 @@ function loadAcknowledgedResidue() {
139
143
  // Audit the HEAD bundle's residue against the ack ledger, carrying whether the head
140
144
  // ledger armed the gate. Returns null when no captured map carried residue AND the
141
145
  // gate wasn't armed — so a clean healthy run prints/gates nothing (byte-identical).
142
- function readResidueAudit(dirB, armed) {
146
+ function readResidueAudit(dirB, armed, hasLedger) {
143
147
  const headResidue = readResidue(dirB);
144
148
  if (!armed && !headResidue.some((m) => m.dataResidue?.length)) return null;
145
149
  const acknowledged = loadAcknowledgedResidue();
146
- return { acknowledged, ...auditRunResidue(headResidue, acknowledged, armed) };
150
+ return { acknowledged, hasLedger, ...auditRunResidue(headResidue, acknowledged, armed) };
147
151
  }
148
152
 
149
153
  // Print the Data-residue section; return the count of UNACKNOWLEDGED failing endpoints
@@ -155,17 +159,20 @@ function residueLine(r, ackReason, armed) {
155
159
  return ` ${armed ? '✗ ' : '⚠ '}${r.surface} · ${r.endpoint} (${r.reason})${armed ? ', unacknowledged' : ''}`;
156
160
  }
157
161
 
158
- /** The action footer: the gate (default) names the remedy; warn mode is the opt-out. */
159
- function residueFooter(armed, unacknowledgedCount) {
162
+ /** The action footer: the gate (default) names the remedy; warn mode is the opt-out.
163
+ * A bundle with NO ledger at all is named as such — not misattributed to the opt-out. */
164
+ function residueFooter(armed, unacknowledgedCount, hasLedger) {
160
165
  if (!unacknowledgedCount) return null;
161
- return armed
162
- ? ` → ${unacknowledgedCount} unacknowledged failing endpoint(s): fixture each (page.route / liveStates), acknowledge intentional ones in styleproof.data-residue.json {"<key>":"<why>"}, or opt down with \`dataResidue: "warn"\` in the capture spec.`
163
- : ' recorded and warned (dataResidue: "warn" — the opt-out). Remove it to restore the default gate that BLOCKS on these.';
166
+ if (armed)
167
+ return ` → ${unacknowledgedCount} unacknowledged failing endpoint(s): fixture each (page.route / liveStates), acknowledge intentional ones in styleproof.data-residue.json {"<key>":"<why>"}, or opt down with \`dataResidue: "warn"\` in the capture spec.`;
168
+ if (!hasLedger)
169
+ return ' → recorded and warned — the head bundle carries no coverage ledger (ad-hoc or pre-3.10 capture), so the residue gate cannot arm. A spec-driven capture records the ledger and gates by default.';
170
+ return ' → recorded and warned (dataResidue: "warn" — the opt-out). Remove it to restore the default gate that BLOCKS on these.';
164
171
  }
165
172
 
166
173
  function printResidueAudit(audit) {
167
174
  if (!audit) return 0;
168
- const { residue, unacknowledged, staleAcknowledgements, armed } = audit;
175
+ const { residue, unacknowledged, staleAcknowledgements, armed, hasLedger } = audit;
169
176
  if (!residue.length && !staleAcknowledgements.length) {
170
177
  console.log('\n🩹 Data residue: no failing data-boundary request during capture');
171
178
  return 0;
@@ -174,7 +181,7 @@ function printResidueAudit(audit) {
174
181
  for (const r of residue) console.log(residueLine(r, audit.acknowledged[r.key], armed));
175
182
  for (const k of staleAcknowledgements)
176
183
  console.log(` ⚠ stale acknowledgement (endpoint no longer failing/present): ${k}`);
177
- const footer = residueFooter(armed, unacknowledged.length);
184
+ const footer = residueFooter(armed, unacknowledged.length, hasLedger);
178
185
  if (footer) console.log(footer);
179
186
  // Only an ARMED gate blocks; warn-mode surfaces without gating. A stale acknowledgement
180
187
  // always blocks when armed, so the ledger can't rot (mirrors the `exclude` guard).
@@ -204,14 +211,22 @@ function readLedger(dir) {
204
211
  try {
205
212
  return JSON.parse(fs.readFileSync(p, 'utf8'));
206
213
  } catch {
207
- return null; // a corrupt ledger reads as no registry "not asserted", never a false green
214
+ // A ledger that EXISTS but cannot be parsed is tampering or truncation, and
215
+ // reading it as "no registry" would silently disarm the coverage,
216
+ // determinism, AND residue gates at once. Fail loud instead.
217
+ console.error(
218
+ `${COMMAND}: corrupt coverage ledger: ${p} — recapture the bundle; refusing to compare with disarmed gates.`,
219
+ );
220
+ process.exit(2);
208
221
  }
209
222
  }
210
223
 
211
224
  // Print the completeness verdict; return true if it BLOCKS (a registered surface is missing).
212
225
  function printCoverageVerdict(v) {
213
226
  if (v.basis === 'complete') {
214
- console.log(`\n✓ coverage complete — all ${v.registrySize} registered surface(s) captured`);
227
+ console.log(`\n✓ coverage complete — all ${v.registrySize} registered surface(s) captured or explicitly excluded`);
228
+ for (const k of v.staleExclusions ?? [])
229
+ console.log(` ⚠ stale exclude (not in the registry): ${k} — prune it from the spec`);
215
230
  return false;
216
231
  }
217
232
  if (v.basis === 'unasserted') {
@@ -238,7 +253,13 @@ function printDeterminismVerdict(v) {
238
253
  return false;
239
254
  }
240
255
  if (v.status === 'unknown') {
241
- console.log('\n⚠ determinism basis unknown a capture predates the determinism ledger; recapture to certify it.');
256
+ // No ledger at all = an ad-hoc `styleproof-capture` output (which doesn't
257
+ // self-check) or a pre-3.10 bundle; a spec capture records the basis.
258
+ console.log(
259
+ '\n⚠ determinism basis unknown — a side carries no determinism ledger (an ad-hoc styleproof-capture\n' +
260
+ ' output, or a capture from before the ledger existed). A spec-driven capture (styleproof-map)\n' +
261
+ ' self-checks and records it; ad-hoc captures are compared as-is.',
262
+ );
242
263
  return false;
243
264
  }
244
265
  console.log(
@@ -265,7 +286,8 @@ options:
265
286
  -h, --help show this help
266
287
 
267
288
  exit: 0 identical (certified), 1 differences found, 2 usage/capture error,
268
- 3 only new surfaces (present on one side, no baseline to diff against).
289
+ 3 only NEW surfaces (present only on the head side, no baseline to diff
290
+ against); a REMOVED surface (present only on the base side) exits 1
269
291
  `;
270
292
 
271
293
  const argv = process.argv.slice(2);
@@ -353,7 +375,7 @@ try {
353
375
  determinismVerdict = auditDeterminism(readLedger(dirA), headLedger);
354
376
  // Data-residue: the head bundle's failing data endpoints, gated only if its ledger
355
377
  // armed `dataResidue: 'gate'`. Same "read while the dirs exist" rule as the ledgers.
356
- residueAudit = readResidueAudit(dirB, headLedger?.dataResidue === 'gate');
378
+ residueAudit = readResidueAudit(dirB, headLedger?.dataResidue === 'gate', headLedger != null);
357
379
  // Element-path sets per surface, for the shared-chrome tier — same "read while
358
380
  // the dirs exist" rule as the ledgers above.
359
381
  surfacePaths = surfaceElementPaths(dirA, dirB);
@@ -363,7 +385,7 @@ try {
363
385
  } finally {
364
386
  cleanupCachedCaptureDirs(cacheCapture);
365
387
  }
366
- const { surfaces, counts, compared } = result;
388
+ const { surfaces, counts, compared, volatile, statesUncertified } = result;
367
389
 
368
390
  // ── grouped human output ─────────────────────────────────────────────────────
369
391
  // Reuse the report's dedup so one real change doesn't print once per surface with
@@ -404,11 +426,17 @@ function elementLines(findings) {
404
426
  return lines;
405
427
  }
406
428
 
407
- // New (one-sided) surfaces keep their own line, unchanged.
429
+ // One-sided surfaces keep their own line. The two directions are NOT the same
430
+ // verdict: only-in-after is a NEW surface (no baseline, review before
431
+ // baselining); only-in-before is a REMOVED surface — a route or width that
432
+ // existed and is gone, which is a change, never an onboarding case.
408
433
  for (const sd of surfaces) {
409
434
  if (!sd.missing) continue;
410
- const side = sd.missing === 'before' ? 'after' : 'before';
411
- console.log(`\n${sd.surface}: new surface — captured only in the ${side} set, no baseline to compare`);
435
+ console.log(
436
+ sd.missing === 'before'
437
+ ? `\n${sd.surface}: new surface — captured only in the after set, no baseline to compare`
438
+ : `\n${sd.surface}: ✗ REMOVED surface — captured only in the before set; the head no longer renders it`,
439
+ );
412
440
  }
413
441
 
414
442
  // Group the changed surfaces the way the report does, so an identical change
@@ -466,6 +494,13 @@ if (jsonOut) {
466
494
  counts,
467
495
  surfaces,
468
496
  compared,
497
+ // Subtrees excluded from every layer of the comparison because a side
498
+ // auto-detected them as volatile (still mutating at capture settle).
499
+ // Changes inside them are NOT certified by this diff.
500
+ volatileExcluded: volatile,
501
+ // Surfaces whose forced-state layer was skipped on BOTH sides — the
502
+ // :hover/:focus/:active layer compared {} vs {} and certifies nothing.
503
+ statesUncertified,
469
504
  coverage: coverageVerdict,
470
505
  determinism: determinismVerdict,
471
506
  // The inventory verdict, machine-readable — parallel to coverage/determinism and
@@ -508,26 +543,48 @@ if (jsonOut) {
508
543
  }
509
544
 
510
545
  const total = counts.dom + counts.style + counts.state;
511
- const newSurfaces = surfaces.filter((s) => s.missing).length;
546
+ const newSurfaces = surfaces.filter((s) => s.missing === 'before').length;
547
+ const removedSurfaces = surfaces.filter((s) => s.missing === 'after').length;
512
548
  // One SurfaceDiff per distinct surface across both sides (incl. missing-on-one-side).
513
549
  const surfaceCount = surfaces.length;
550
+ if (volatile > 0)
551
+ console.log(
552
+ `\n⚠ ${volatile} auto-detected volatile subtree(s) excluded from the comparison (still mutating at capture\n` +
553
+ ' settle) — changes inside them are NOT certified. Fixture the region, or `ignore` it deliberately.',
554
+ );
555
+ if (statesUncertified > 0)
556
+ console.log(
557
+ `\n⚠ forced-state layer uncertified on ${statesUncertified} surface(s): BOTH captures skipped it, so\n` +
558
+ ' :hover/:focus/:active differences there were never compared.',
559
+ );
514
560
  const newNote = newSurfaces ? ` (+${newSurfaces} new surface(s) with no baseline)` : '';
515
- const invNote = invRemovals ? ` + ${invRemovals} unacknowledged inventory removal(s)` : '';
561
+ const removedNote = removedSurfaces ? ` + ${removedSurfaces} REMOVED surface(s)` : '';
562
+ const invNote = invRemovals ? ` + ${invRemovals} inventory gate failure(s) (unacknowledged or stale)` : '';
516
563
  // residueFails counts unacknowledged failing endpoints AND stale acknowledgements (both gate).
517
564
  const resNote = residueFails ? ` + ${residueFails} data-residue gate failure(s) (unacknowledged or stale)` : '';
518
565
  const covNote = coverageFails ? ` + ${coverageVerdict.uncovered.length} uncaptured registered surface(s)` : '';
519
566
  const detNote = determinismFails ? ' + determinism unproven' : '';
520
- const clean = total === 0 && invRemovals === 0 && residueFails === 0 && !coverageFails && !determinismFails;
567
+ const clean =
568
+ total === 0 &&
569
+ removedSurfaces === 0 &&
570
+ invRemovals === 0 &&
571
+ residueFails === 0 &&
572
+ !coverageFails &&
573
+ !determinismFails;
521
574
  console.log(
522
575
  clean
523
576
  ? newSurfaces === 0
524
577
  ? `\n✓ 0 changed surfaces across ${compared} captured surface(s): every computed style, pseudo-element, and hover/focus/active state matches`
525
578
  : `\nℹ ${newSurfaces} new surface(s) captured with no baseline to compare — review before baselining`
526
- : `\n✗ ${counts.dom} DOM change(s), ${counts.style} computed-style difference(s), ${counts.state} state-delta difference(s) across ${surfaceCount} surfaces${newNote}${invNote}${resNote}${covNote}${detNote}`,
579
+ : `\n✗ ${counts.dom} DOM change(s), ${counts.style} computed-style difference(s), ${counts.state} state-delta difference(s) across ${surfaceCount} surfaces${newNote}${removedNote}${invNote}${resNote}${covNote}${detNote}`,
527
580
  );
528
- // 0 = identical, 1 = reviewable differences (incl. unacknowledged inventory removals, an
529
- // unacknowledged failing data endpoint under an armed residue gate, an incomplete coverage
530
- // registry, or an unproven-determinism capture), 3 = only new surfaces (no baseline). 2 = usage.
581
+ // 0 = identical, 1 = reviewable differences (incl. a REMOVED surface, inventory/residue gate
582
+ // failures — unacknowledged or stale an incomplete coverage registry, or an
583
+ // unproven-determinism capture), 3 = ONLY new surfaces (no baseline). 2 = usage.
531
584
  process.exit(
532
- total > 0 || invRemovals > 0 || residueFails > 0 || coverageFails || determinismFails ? 1 : newSurfaces > 0 ? 3 : 0,
585
+ total > 0 || removedSurfaces > 0 || invRemovals > 0 || residueFails > 0 || coverageFails || determinismFails
586
+ ? 1
587
+ : newSurfaces > 0
588
+ ? 3
589
+ : 0,
533
590
  );
@@ -72,6 +72,9 @@ export type CaptureUrlOptions = {
72
72
  dataStates: boolean;
73
73
  /** crawl: concurrent sweep workers (default 4). 1 = byte-stable key attribution. */
74
74
  workers: number;
75
+ /** crawl: also crawl every same-origin page the nav links to (default true).
76
+ * Off = the entry page's interactive surface only. */
77
+ followLinks: boolean;
75
78
  };
76
79
  /**
77
80
  * Parse `styleproof-capture` argv into options. Pure and throwing so the CLI
@@ -40,6 +40,7 @@ const DEFAULTS = {
40
40
  untilCovered: false,
41
41
  dataStates: true,
42
42
  workers: 4,
43
+ followLinks: true,
43
44
  };
44
45
  function positiveNumber(raw, flag) {
45
46
  const n = Number(raw);
@@ -81,6 +82,8 @@ const BOOL_FLAGS = {
81
82
  '--until-covered': (o) => (o.untilCovered = true),
82
83
  '--data-states': (o) => (o.dataStates = true),
83
84
  '--no-data-states': (o) => (o.dataStates = false),
85
+ '--follow-links': (o) => (o.followLinks = true),
86
+ '--no-follow-links': (o) => (o.followLinks = false),
84
87
  };
85
88
  // Apply one argv token to the accumulator; returns the index to resume from
86
89
  // (advanced past a consumed `--flag value` pair). Flat early-returns so the
@@ -131,6 +134,7 @@ export function parseCaptureUrlArgs(argv) {
131
134
  setupFile: undefined,
132
135
  dataStates: DEFAULTS.dataStates,
133
136
  workers: DEFAULTS.workers,
137
+ followLinks: DEFAULTS.followLinks,
134
138
  };
135
139
  const positional = [];
136
140
  for (let i = 0; i < argv.length; i++)
@@ -53,6 +53,9 @@ export type CrawlCoverage = {
53
53
  rendered: number;
54
54
  missing: string[];
55
55
  unreadable: string[];
56
+ /** The class names that WERE rendered — so a multi-page caller can aggregate
57
+ * coverage across crawls sharing stylesheets (defined = rendered ∪ missing). */
58
+ renderedClasses: string[];
56
59
  };
57
60
  export type CrawlReport = {
58
61
  surfaces: CrawledSurface[];
@@ -122,6 +125,9 @@ export type SurfaceCrawlOptions = {
122
125
  /** Factory for worker pages — create each in its OWN browser context so
123
126
  * storage resets cannot interfere across concurrent sweeps. */
124
127
  newPage?: () => Promise<Page>;
128
+ /** Namespace for every derived surface key (multi-page sweeps): the root
129
+ * surface keys as the prefix itself, sub-states as `<prefix>-<label>`. */
130
+ keyPrefix?: string;
125
131
  };
126
132
  export declare const CRAWL_DEFAULTS: {
127
133
  height: number;
@@ -37,8 +37,12 @@ function pathAndSearch(url) {
37
37
  return url;
38
38
  }
39
39
  }
40
- function deriveKey(steps, used) {
41
- const base = steps.length === 0 ? 'base' : slug(steps[steps.length - 1].label);
40
+ function deriveKey(steps, used, prefix = '') {
41
+ const bare = steps.length === 0 ? 'base' : slug(steps[steps.length - 1].label);
42
+ // A prefixed crawl (one page of a multi-page sweep) keys its root surface as
43
+ // the prefix itself and namespaces every sub-state under it, so two pages'
44
+ // surfaces can never overwrite each other in a shared --out directory.
45
+ const base = prefix === '' ? bare : bare === 'base' ? prefix : `${prefix}-${bare}`;
42
46
  let key = base;
43
47
  for (let i = 2; used.has(key); i++)
44
48
  key = `${base}-${i}`;
@@ -404,6 +408,14 @@ async function scrollReveal(page) {
404
408
  async function gotoFresh(page, opts) {
405
409
  await page.setViewportSize({ width: opts.widths[0] ?? 1280, height: opts.height });
406
410
  await page.goto(opts.url, { waitUntil: 'load' });
411
+ // A same-origin URL can still 302 off-origin (SSO, /out?url=…). External
412
+ // content is nondeterministic and never belongs in a map — abort the crawl of
413
+ // this page rather than capture a third-party render as if it were the app.
414
+ const wanted = URL.canParse(opts.url) ? new URL(opts.url).origin : null; // relative url → origin unknowable
415
+ const landed = new URL(page.url()).origin;
416
+ if (wanted && landed !== wanted) {
417
+ throw new Error(`styleproof crawl: ${opts.url} redirected off-origin to ${landed} — external content is never captured`);
418
+ }
407
419
  // Tolerant wait: the generic settle below is the real readiness signal; an
408
420
  // optional waitSelector just accelerates it and must not fail the crawl.
409
421
  const ready = opts.waitSelector ? page.locator(opts.waitSelector).first() : null;
@@ -500,7 +512,7 @@ function stateKey(steps) {
500
512
  /** Record a newly-found surface, capture it in place (page is already there), and
501
513
  * queue it for its own sweep. Streams progress via onSurface. */
502
514
  async function record(page, opts, newPath, depth, fp, st, sink, retryOnly = false, viaRetry = false) {
503
- const key = deriveKey(newPath, st.used);
515
+ const key = deriveKey(newPath, st.used, opts.keyPrefix ?? '');
504
516
  const surface = { key, depth, path: newPath, elements: fp.elements };
505
517
  st.surfaces.push(surface);
506
518
  let addsVocab = false;
@@ -753,7 +765,7 @@ async function recordDataState(page, opts, mode, st) {
753
765
  st.seen.add(fp.sig);
754
766
  for (const c of fp.classes)
755
767
  st.classes.add(c);
756
- const key = deriveKey([{ action: 'click', selector: `(data:${mode})`, label: mode, reason: 'data-state' }], st.used);
768
+ const key = deriveKey([{ action: 'click', selector: `(data:${mode})`, label: mode, reason: 'data-state' }], st.used, opts.keyPrefix ?? '');
757
769
  const surface = { key, depth: 0, path: [], elements: fp.elements };
758
770
  st.surfaces.push(surface);
759
771
  await captureAndReport(page, opts, surface, st);
@@ -894,7 +906,13 @@ async function discover(page, opts) {
894
906
  skipped: counters.skipped,
895
907
  captured: st.captured,
896
908
  failed: st.failed,
897
- coverage: { defined: defined.length, rendered: defined.length - missing.length, missing, unreadable },
909
+ coverage: {
910
+ defined: defined.length,
911
+ rendered: defined.length - missing.length,
912
+ missing,
913
+ unreadable,
914
+ renderedClasses: defined.filter((c) => st.classes.has(c)).sort(),
915
+ },
898
916
  };
899
917
  }
900
918
  /**
package/dist/crawl.d.ts CHANGED
@@ -57,6 +57,23 @@ export type SelectLinksOptions = {
57
57
  * nav-regressions / unowned routes for a route that never changed.
58
58
  */
59
59
  export declare function defaultLinkKey(url: URL): string;
60
+ /**
61
+ * Dedup identity for a navigable path+query. Two forms of the same route must share
62
+ * one identity, or a static multi-page site (whose nav links the `.html` files) gets
63
+ * captured twice as byte-near-identical maps, doubling the work and duplicating every
64
+ * finding in the diff:
65
+ *
66
+ * - A trailing slash isn't a distinct surface (`/about` and `/about/` render the same
67
+ * route), so it's stripped — but never from the root `/` itself, nor from the query.
68
+ * - A trailing `index.html` is the directory's index (`/index.html` IS `/`, and
69
+ * `/docs/index.html` IS `/docs/`), so it collapses to the directory path. Only the
70
+ * literal `index.html` filename normalizes — a real `about.html` is left untouched
71
+ * and stays a distinct surface from `about`.
72
+ *
73
+ * The navigable url the caller returns keeps its original form; only the SET
74
+ * membership test is normalized, so the first-seen href still wins.
75
+ */
76
+ export declare function dedupIdentity(pathAndSearch: string): string;
60
77
  /**
61
78
  * Turn a page's raw `<a href>` values into a deduped, keyed surface list.
62
79
  *
package/dist/crawl.js CHANGED
@@ -95,7 +95,7 @@ function toLink(href, base, keyFor, match) {
95
95
  * The navigable url the caller returns keeps its original form; only the SET
96
96
  * membership test is normalized, so the first-seen href still wins.
97
97
  */
98
- function dedupIdentity(pathAndSearch) {
98
+ export function dedupIdentity(pathAndSearch) {
99
99
  const q = pathAndSearch.indexOf('?');
100
100
  const path = q === -1 ? pathAndSearch : pathAndSearch.slice(0, q);
101
101
  const search = q === -1 ? '' : pathAndSearch.slice(q);
package/dist/diff.d.ts CHANGED
@@ -87,6 +87,7 @@ export declare function diffStyleMapDirs(dirA: string, dirB: string): {
87
87
  surfaces: SurfaceDiff[];
88
88
  counts: DiffCounts;
89
89
  volatile: number;
90
+ statesUncertified: number;
90
91
  compared: number;
91
92
  };
92
93
  export declare function diffContentMaps(a: StyleMap, b: StyleMap): ContentChange[];
package/dist/diff.js CHANGED
@@ -281,7 +281,7 @@ export function diffStyleMapDirs(dirA, dirB) {
281
281
  throw new MissingHeadMapError();
282
282
  const surfaces = [];
283
283
  const counts = { dom: 0, style: 0, state: 0 };
284
- let volatile = 0;
284
+ const uncompared = { volatile: 0, statesUncertified: 0 };
285
285
  for (const surface of names) {
286
286
  if (!indexA[surface] || !indexB[surface]) {
287
287
  // A surface present on only one side has no baseline to diff against — it's
@@ -291,15 +291,22 @@ export function diffStyleMapDirs(dirA, dirB) {
291
291
  surfaces.push({ surface, missing: indexA[surface] ? 'after' : 'before', findings: [] });
292
292
  continue;
293
293
  }
294
- const mapA = loadStyleMap(indexA[surface]);
295
- const mapB = loadStyleMap(indexB[surface]);
296
- volatile += new Set([...(mapA.volatile ?? []), ...(mapB.volatile ?? [])]).size;
297
- const findings = diffStyleMaps(mapA, mapB);
294
+ const findings = diffSurfacePair(indexA[surface], indexB[surface], uncompared);
298
295
  tallyCounts(findings, counts);
299
296
  if (findings.length)
300
297
  surfaces.push({ surface, findings });
301
298
  }
302
- return { surfaces, counts, volatile, compared: names.length };
299
+ return { surfaces, counts, ...uncompared, compared: names.length };
300
+ }
301
+ /** Diff one paired surface, tallying what was NOT compared (volatile subtrees;
302
+ * a forced-state layer skipped on BOTH sides — {} vs {} certifies nothing). */
303
+ function diffSurfacePair(fileA, fileB, uncompared) {
304
+ const mapA = loadStyleMap(fileA);
305
+ const mapB = loadStyleMap(fileB);
306
+ uncompared.volatile += new Set([...(mapA.volatile ?? []), ...(mapB.volatile ?? [])]).size;
307
+ if (mapA.statesSkipped && mapB.statesSkipped)
308
+ uncompared.statesUncertified++;
309
+ return diffStyleMaps(mapA, mapB);
303
310
  }
304
311
  /**
305
312
  * Diff the OPT-IN content layer: elements whose own rendered text changed.
package/dist/map-store.js CHANGED
@@ -140,8 +140,11 @@ export function expectedCompatibilityKey(options = {}) {
140
140
  }
141
141
  export function currentGitSha(cwd = process.cwd(), env = process.env) {
142
142
  const fromEvent = (() => {
143
- if (!env.GITHUB_EVENT_PATH ||
144
- !['pull_request', 'pull_request_target', 'workflow_run'].includes(env.GITHUB_EVENT_NAME ?? '')) {
143
+ // pull_request_target is deliberately absent: there GITHUB_SHA *is* the base
144
+ // tip, so its default checkout would be relabeled to the fork's (attacker-
145
+ // chosen) head. A pull_request_target job that really checks out the head
146
+ // gets the right SHA from `git rev-parse HEAD` with no relabel needed.
147
+ if (!env.GITHUB_EVENT_PATH || !['pull_request', 'workflow_run'].includes(env.GITHUB_EVENT_NAME ?? '')) {
145
148
  return undefined;
146
149
  }
147
150
  try {
@@ -152,16 +155,31 @@ export function currentGitSha(cwd = process.cwd(), env = process.env) {
152
155
  return undefined;
153
156
  }
154
157
  })();
155
- // STYLEPROOF_SHA/GITHUB_HEAD_SHA are explicit overrides. On a pull_request
156
- // run, GITHUB_SHA is the synthetic merge commit, so consult the trusted event
157
- // payload before falling back to it.
158
- const fromEnv = env.STYLEPROOF_SHA || env.GITHUB_HEAD_SHA || fromEvent || env.GITHUB_SHA;
159
- if (fromEnv && /^[0-9a-f]{7,40}$/i.test(fromEnv))
160
- return fromEnv;
161
- const sha = gitOutput(cwd, ['rev-parse', 'HEAD']);
162
- if (!sha)
163
- throw new MapStoreError('must run inside a git repository, or pass --sha <commit>');
164
- return sha;
158
+ // STYLEPROOF_SHA/GITHUB_HEAD_SHA are explicit overrides: they always win, and
159
+ // a malformed value errors instead of silently falling through to a wrong label.
160
+ const explicit = env.STYLEPROOF_SHA || env.GITHUB_HEAD_SHA;
161
+ if (explicit) {
162
+ if (!/^[0-9a-f]{7,40}$/i.test(explicit)) {
163
+ throw new MapStoreError(`STYLEPROOF_SHA/GITHUB_HEAD_SHA is not a commit SHA: ${explicit}`);
164
+ }
165
+ return explicit;
166
+ }
167
+ const head = gitOutput(cwd, ['rev-parse', 'HEAD']);
168
+ if (head) {
169
+ // The checked-out tree is the truth. The one exception: a checkout of the
170
+ // synthetic GITHUB_SHA commit (pull_request merge commit / workflow_run
171
+ // default tip) is labeled with the event's real head, because nothing ever
172
+ // restores by the synthetic SHA. A checkout of anything else — e.g. the
173
+ // base branch in a cache-miss job — keeps its own SHA, so a base-tree map
174
+ // is never published under the head's store key (a false-green poisoning).
175
+ if (fromEvent && head === env.GITHUB_SHA)
176
+ return fromEvent;
177
+ return head;
178
+ }
179
+ const fallback = fromEvent ?? env.GITHUB_SHA;
180
+ if (fallback && /^[0-9a-f]{7,40}$/i.test(fallback))
181
+ return fallback;
182
+ throw new MapStoreError('must run inside a git repository, or pass --sha <commit>');
165
183
  }
166
184
  export function refSha(ref, cwd = process.cwd()) {
167
185
  const sha = gitOutput(cwd, ['rev-parse', `${ref}^{commit}`]);
package/dist/report.js CHANGED
@@ -48,47 +48,172 @@ function outermost(paths) {
48
48
  function innermost(paths) {
49
49
  return paths.filter((p) => !paths.some((q) => q !== p && q.startsWith(p + ' > ')));
50
50
  }
51
- function annotationIdentity(entry) {
51
+ function sortedProperties(props) {
52
+ return Object.entries(props).sort(([left], [right]) => left.localeCompare(right, 'en'));
53
+ }
54
+ function restingAnnotationIdentity(entry) {
55
+ if (!entry)
56
+ return null;
52
57
  const sortedPseudo = Object.fromEntries(Object.entries(entry.pseudo ?? {})
53
- .sort(([left], [right]) => left.localeCompare(right))
54
- .map(([pseudo, properties]) => [
55
- pseudo,
56
- Object.entries(properties).sort(([left], [right]) => left.localeCompare(right)),
57
- ]));
58
- return JSON.stringify([
58
+ .sort(([left], [right]) => left.localeCompare(right, 'en'))
59
+ .map(([pseudo, properties]) => [pseudo, sortedProperties(properties)]));
60
+ return [
59
61
  entry.tag,
60
62
  entry.cls,
61
63
  entry.rect?.[2] ?? null,
62
64
  entry.rect?.[3] ?? null,
63
- Object.entries(entry.style).sort(([left], [right]) => left.localeCompare(right)),
65
+ sortedProperties(entry.style),
64
66
  sortedPseudo,
67
+ ];
68
+ }
69
+ function normalizeStructuralPath(elementPath) {
70
+ return elementPath.replace(/:nth-(?:child|of-type)\(\d+\)/g, (selector) => selector.replace(/\d+/, '*'));
71
+ }
72
+ function annotationScope(elementPath) {
73
+ const parentSeparator = elementPath.lastIndexOf(' > ');
74
+ return normalizeStructuralPath(parentSeparator === -1 ? '' : elementPath.slice(0, parentSeparator));
75
+ }
76
+ function relativeStateTarget(ownerPath, targetPath) {
77
+ if (targetPath === ownerPath)
78
+ return '';
79
+ const ownerPseudoPrefix = `${ownerPath}::`;
80
+ if (targetPath.startsWith(ownerPseudoPrefix))
81
+ return targetPath.slice(ownerPath.length);
82
+ const descendantPrefix = `${ownerPath} > `;
83
+ const relativePath = targetPath.startsWith(descendantPrefix) ? targetPath.slice(descendantPrefix.length) : targetPath;
84
+ return normalizeStructuralPath(relativePath);
85
+ }
86
+ function canonicalForcedStates(map, ownerPath) {
87
+ return Object.entries(map.states?.[ownerPath] ?? {})
88
+ .sort(([left], [right]) => left.localeCompare(right, 'en'))
89
+ .map(([stateName, deltas]) => [
90
+ stateName,
91
+ Object.entries(deltas)
92
+ .map(([targetPath, properties]) => [
93
+ relativeStateTarget(ownerPath, targetPath),
94
+ restingAnnotationIdentity(map.elements[targetPath]),
95
+ sortedProperties(properties),
96
+ ])
97
+ .sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right), 'en')),
65
98
  ]);
66
99
  }
100
+ function annotationIdentity(map, elementPath, entry) {
101
+ return JSON.stringify([restingAnnotationIdentity(entry), canonicalForcedStates(map, elementPath)]);
102
+ }
103
+ function sortedAnnotationPaths(paths) {
104
+ return [...paths].sort((left, right) => left.localeCompare(right, 'en', { numeric: true }));
105
+ }
67
106
  function indexAnnotationIdentities(map) {
68
107
  const pathsByIdentity = new Map();
69
108
  for (const [elementPath, entry] of Object.entries(map.elements)) {
70
- const identity = annotationIdentity(entry);
109
+ const identity = annotationIdentity(map, elementPath, entry);
71
110
  pathsByIdentity.set(identity, [...(pathsByIdentity.get(identity) ?? []), elementPath]);
72
111
  }
73
112
  return pathsByIdentity;
74
113
  }
75
- function hasEquivalentEntryAtAnotherPath(elementPath, entry, samePathsByIdentity, otherPathsByIdentity) {
76
- if (!entry)
114
+ function pathsByAnnotationScope(paths) {
115
+ const pathsByScope = new Map();
116
+ for (const elementPath of paths) {
117
+ const scope = annotationScope(elementPath);
118
+ pathsByScope.set(scope, [...(pathsByScope.get(scope) ?? []), elementPath]);
119
+ }
120
+ for (const scopedPaths of pathsByScope.values())
121
+ scopedPaths.sort((left, right) => left.localeCompare(right, 'en', { numeric: true }));
122
+ return pathsByScope;
123
+ }
124
+ /** Captured children per concrete container path, for the displacement proof. */
125
+ function containerChildCounts(map) {
126
+ const counts = new Map();
127
+ for (const elementPath of Object.keys(map.elements)) {
128
+ const separator = elementPath.lastIndexOf(' > ');
129
+ const container = separator === -1 ? '' : elementPath.slice(0, separator);
130
+ counts.set(container, (counts.get(container) ?? 0) + 1);
131
+ }
132
+ return counts;
133
+ }
134
+ /** The concrete container where two element paths diverge (never the leaf itself). */
135
+ function deepestCommonContainer(beforePath, afterPath) {
136
+ const beforeSegments = beforePath.split(' > ');
137
+ const afterSegments = afterPath.split(' > ');
138
+ const shared = [];
139
+ const limit = Math.min(beforeSegments.length, afterSegments.length) - 1;
140
+ for (let i = 0; i < limit && beforeSegments[i] === afterSegments[i]; i++)
141
+ shared.push(beforeSegments[i]);
142
+ return shared.join(' > ');
143
+ }
144
+ function containerOf(elementPath) {
145
+ const separator = elementPath.lastIndexOf(' > ');
146
+ return separator === -1 ? '' : elementPath.slice(0, separator);
147
+ }
148
+ /**
149
+ * A cross-path match is a MOVE claim, and a matched pair's annotations are
150
+ * suppressed — so the move must be PROVABLE from the captured data, one of:
151
+ *
152
+ * - the container where the two paths diverge gained or lost captured children
153
+ * (a sibling insertion/removal displaced everything after it), or
154
+ * - a same-container slide into a vacated slot: the source slot emptied and the
155
+ * destination slot is new. That is displacement by an UNCAPTURED sibling — an
156
+ * injected `<style>`/`<script>` shifts `nth-child` without entering the census.
157
+ *
158
+ * A pair with neither proof — a style swap between siblings, a pure reorder of
159
+ * occupied slots, or a coincidental twin in a cousin container — stays
160
+ * annotated, because the data cannot prove nothing changed there.
161
+ */
162
+ function canReconcileAnnotationPair(beforeMap, afterMap, beforeCounts, afterCounts, beforePath, afterPath, remainingAfter) {
163
+ if (!remainingAfter.has(afterPath))
77
164
  return false;
78
- const identity = annotationIdentity(entry);
79
- const samePaths = samePathsByIdentity.get(identity) ?? [];
80
- const otherPaths = otherPathsByIdentity.get(identity) ?? [];
81
- // Reconcile only an unambiguous one-to-one move. If an identity is duplicated
82
- // on either side, an arbitrary cross-path match can hide a real restyle or an
83
- // inserted/removed sibling from the annotated proof.
84
- return samePaths.length === 1 && otherPaths.length === 1 && otherPaths[0] !== elementPath;
85
- }
86
- function annotationSides(finding, beforeMoved, afterMoved, reconcileMoved) {
87
- // A style/state finding already proves that the element at this path changed.
88
- // Only reconcile it when the same surface also has structural churn; without a
89
- // DOM finding, cross-path identity matching can erase a real style swap.
165
+ const divergence = deepestCommonContainer(beforePath, afterPath);
166
+ if ((beforeCounts.get(divergence) ?? 0) !== (afterCounts.get(divergence) ?? 0))
167
+ return true;
168
+ if (containerOf(beforePath) !== containerOf(afterPath))
169
+ return false;
170
+ return !beforeMap.elements[afterPath] && !afterMap.elements[beforePath];
171
+ }
172
+ function reconcileIdentityPaths(beforeMap, afterMap, beforeCounts, afterCounts, beforePaths, afterPaths, matches) {
173
+ const remainingBefore = new Set(beforePaths);
174
+ const remainingAfter = new Set(afterPaths);
175
+ // Preserve stable paths first. This keeps duplicate occurrences deterministic
176
+ // without claiming which indistinguishable physical node was inserted.
177
+ for (const beforePath of beforePaths) {
178
+ if (!remainingAfter.has(beforePath))
179
+ continue;
180
+ matches.beforeToAfter.set(beforePath, beforePath);
181
+ matches.afterToBefore.set(beforePath, beforePath);
182
+ remainingBefore.delete(beforePath);
183
+ remainingAfter.delete(beforePath);
184
+ }
185
+ const remainingAfterPathsByScope = pathsByAnnotationScope(remainingAfter);
186
+ // Reconcile only within the same normalized structural neighborhood. Any
187
+ // excess occurrence remains unmatched and is annotated as an addition/removal.
188
+ for (const beforePath of sortedAnnotationPaths([...remainingBefore])) {
189
+ const candidates = remainingAfterPathsByScope.get(annotationScope(beforePath)) ?? [];
190
+ const afterPath = candidates.find((candidate) => canReconcileAnnotationPair(beforeMap, afterMap, beforeCounts, afterCounts, beforePath, candidate, remainingAfter));
191
+ if (!afterPath)
192
+ continue;
193
+ matches.beforeToAfter.set(beforePath, afterPath);
194
+ matches.afterToBefore.set(afterPath, beforePath);
195
+ remainingBefore.delete(beforePath);
196
+ remainingAfter.delete(afterPath);
197
+ }
198
+ }
199
+ function reconcileAnnotationPaths(beforeMap, afterMap) {
200
+ const beforePathsByIdentity = indexAnnotationIdentities(beforeMap);
201
+ const afterPathsByIdentity = indexAnnotationIdentities(afterMap);
202
+ const beforeCounts = containerChildCounts(beforeMap);
203
+ const afterCounts = containerChildCounts(afterMap);
204
+ const matches = {
205
+ beforeToAfter: new Map(),
206
+ afterToBefore: new Map(),
207
+ };
208
+ const identities = new Set([...beforePathsByIdentity.keys(), ...afterPathsByIdentity.keys()]);
209
+ for (const identity of identities) {
210
+ reconcileIdentityPaths(beforeMap, afterMap, beforeCounts, afterCounts, sortedAnnotationPaths(beforePathsByIdentity.get(identity) ?? []), sortedAnnotationPaths(afterPathsByIdentity.get(identity) ?? []), matches);
211
+ }
212
+ return matches;
213
+ }
214
+ function annotationSides(finding, beforeMoved, afterMoved) {
90
215
  if (finding.kind !== 'dom')
91
- return reconcileMoved ? { before: !beforeMoved, after: !afterMoved } : { before: true, after: true };
216
+ return { before: !beforeMoved, after: !afterMoved };
92
217
  if (finding.change === 'removed')
93
218
  return { before: !beforeMoved, after: false };
94
219
  if (finding.change === 'added')
@@ -96,17 +221,15 @@ function annotationSides(finding, beforeMoved, afterMoved, reconcileMoved) {
96
221
  return { before: true, after: true };
97
222
  }
98
223
  function annotationPaths(findings, beforeMap, afterMap) {
99
- const beforePathsByIdentity = indexAnnotationIdentities(beforeMap);
100
- const afterPathsByIdentity = indexAnnotationIdentities(afterMap);
101
- const reconcileMoved = findings.some((finding) => finding.kind === 'dom');
224
+ const matches = reconcileAnnotationPaths(beforeMap, afterMap);
102
225
  const beforePaths = new Set();
103
226
  const afterPaths = new Set();
104
227
  for (const finding of findings) {
105
- const beforeEntry = beforeMap.elements[finding.path];
106
- const afterEntry = afterMap.elements[finding.path];
107
- const beforeMoved = hasEquivalentEntryAtAnotherPath(finding.path, beforeEntry, beforePathsByIdentity, afterPathsByIdentity);
108
- const afterMoved = hasEquivalentEntryAtAnotherPath(finding.path, afterEntry, afterPathsByIdentity, beforePathsByIdentity);
109
- const sides = annotationSides(finding, beforeMoved, afterMoved, reconcileMoved);
228
+ const beforeMatch = matches.beforeToAfter.get(finding.path);
229
+ const afterMatch = matches.afterToBefore.get(finding.path);
230
+ const beforeMoved = beforeMatch !== undefined && beforeMatch !== finding.path;
231
+ const afterMoved = afterMatch !== undefined && afterMatch !== finding.path;
232
+ const sides = annotationSides(finding, beforeMoved, afterMoved);
110
233
  if (sides.before)
111
234
  beforePaths.add(finding.path);
112
235
  if (sides.after)
@@ -877,7 +1000,7 @@ function reportHeadline(args) {
877
1000
  const candidates = liveCandidateLabels.length
878
1001
  ? ` Auto-detected live-state candidate(s): ${liveCandidateLabels.slice(0, 5).join('; ')}.`
879
1002
  : '';
880
- md.push('', `_${volatileCount} live region(s) auto-excluded as nondeterministic (a stream, ticker, or late-loading content) — they don't affect the check.${candidates}_`);
1003
+ md.push('', `_${volatileCount} live region(s) auto-excluded as nondeterministic (a stream, ticker, or late-loading content) — changes inside them are NOT certified by this check.${candidates}_`);
881
1004
  }
882
1005
  if (contentCount > 0 && (changeGroups.length > 0 || missing.length > 0)) {
883
1006
  md.push('', `📝 _${contentCount} advisory content change(s) below — they don't affect the check._`);
package/dist/runner.js CHANGED
@@ -815,10 +815,18 @@ export function defineCrawlCapture(options) {
815
815
  : null;
816
816
  if (gap)
817
817
  throw new Error(gap);
818
+ // The nav's hrefs are same-origin by selection, but a link can still 302
819
+ // off-origin (SSO, /out?url=…). External content is nondeterministic and
820
+ // never belongs in a map, so the landing origin is verified per surface.
821
+ const entryOrigin = new URL(page.url()).origin;
818
822
  const captureSurfaces = links.flatMap((link) => expandSurfaceVariants({
819
823
  key: link.key,
820
824
  go: async (p) => {
821
825
  await p.goto(link.url, { waitUntil: 'load' });
826
+ const landed = new URL(p.url()).origin;
827
+ if (landed !== entryOrigin) {
828
+ throw new Error(`styleproof crawl: ${link.url} redirected off-origin to ${landed} — external content is never captured`);
829
+ }
822
830
  if (settle)
823
831
  await settle(p);
824
832
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "styleproof",
3
- "version": "4.1.0",
3
+ "version": "4.3.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",