styleproof 3.18.0 → 3.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +114 -0
- package/README.md +42 -1
- package/bin/styleproof-capture.mjs +4 -1
- package/bin/styleproof-diff.mjs +41 -32
- package/bin/styleproof-map.mjs +8 -0
- package/dist/affected-surfaces.d.ts +24 -0
- package/dist/affected-surfaces.js +115 -17
- package/dist/capture-url.d.ts +1 -1
- package/dist/capture.js +40 -26
- package/dist/coverage.d.ts +35 -0
- package/dist/coverage.js +54 -0
- package/dist/crawl-surfaces.js +14 -5
- package/dist/crawl.d.ts +16 -1
- package/dist/crawl.js +55 -7
- package/dist/danger.d.ts +13 -0
- package/dist/danger.js +13 -0
- package/dist/diff.js +3 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/inventory.js +9 -3
- package/dist/map-store.d.ts +5 -1
- package/dist/map-store.js +10 -3
- package/dist/report.js +23 -6
- package/dist/runner.d.ts +17 -0
- package/dist/runner.js +50 -6
- package/dist/variant-crawler.js +16 -4
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,120 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [3.19.0] - 2026-07-06
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Selective-remap wiring: `explainAffectedSurfaces` + the pre-push recipe.** The
|
|
15
|
+
sound core (`affectedSurfaces`) shipped in 3.17.0 returns a bare `Set | 'all'` that
|
|
16
|
+
names nothing; the new pure `explainAffectedSurfaces(result, allSurfaceKeys, reason?)`
|
|
17
|
+
formatter renders the verdict as reviewer-checkable lines — which surfaces re-capture
|
|
18
|
+
and which reuse their committed base map — so a pre-push hook or CI log can print the
|
|
19
|
+
skip list before anyone trusts it. `affectedSurfaces`'s return shape is unchanged
|
|
20
|
+
(backward-compatible; the helper takes the surface keys as a second argument rather
|
|
21
|
+
than extending the sentinel). README's selective-remap section gains the helper, its
|
|
22
|
+
output, and the full `git diff → dependency-cruiser → affectedSurfaces → capture subset`
|
|
23
|
+
pre-push recipe. Opt-in and advisory throughout — the default full-coverage gate is
|
|
24
|
+
untouched.
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- **Report tables escape hostile CSS values (no Markdown breakout).** A property
|
|
29
|
+
value carrying a `|` used to split a report table row, and a backtick used to close
|
|
30
|
+
the code span and leak live Markdown (`content:"…"`, `url(…)`, `font` strings). The
|
|
31
|
+
render boundary now escapes values instead of stripping them — `|` → `\|`, and the
|
|
32
|
+
code fence widens past the value's longest backtick run — so hostile values render
|
|
33
|
+
as one intact, readable cell. `report.md` only; the privileged review comment was
|
|
34
|
+
never affected.
|
|
35
|
+
- **The navigable-removal guard now sees SVG `<a>` nav links.** The in-page harvest
|
|
36
|
+
keyed anchors off `tagName === 'A'`, which is HTML-only (SVG reports lowercase `a`),
|
|
37
|
+
so an SVG nav link never entered the inventory and its removal never gated. The tag
|
|
38
|
+
check is now case-insensitive, the selector matches any-namespace `href`
|
|
39
|
+
(`a[*|href]`), and the target falls back to `xlink:href` — an `<svg><a>` link now
|
|
40
|
+
resolves like an HTML one.
|
|
41
|
+
- **Single-value `transform-origin`/`perspective-origin` jitter is suppressed.** The
|
|
42
|
+
sub-pixel-origin equality check required at least two length components, so a
|
|
43
|
+
one-value origin (`50px`) leaked rounding jitter as a false diff. One to three
|
|
44
|
+
components are now all suppressed within `ORIGIN_EPSILON_PX`; a real, larger change
|
|
45
|
+
still reports.
|
|
46
|
+
- **`styleproof-diff --json` exits 2 (not 1) when the file cannot be written.** A bad
|
|
47
|
+
`--json` path is a usage/setup error, but the write sat outside the guarded blocks
|
|
48
|
+
and let the failure fall through to exit 1 — which CI reads as a real diff. It now
|
|
49
|
+
reports to stderr and exits 2.
|
|
50
|
+
- **Crawl flag docs corrected.** `--max-depth`'s default is documented as 16 (not
|
|
51
|
+
"unbounded" in `--help`, not "3" in the JSDoc); `--until-covered` is now listed in
|
|
52
|
+
`styleproof-capture --help`.
|
|
53
|
+
- **Crawl no longer silently drops surfaces to key collisions, and mapping no longer
|
|
54
|
+
clicks title-only destructive controls.** Five soundness fixes across the crawl path:
|
|
55
|
+
- `selectCrawlLinks` deduped by raw URL, so `/about` and `/about/` — one route — were
|
|
56
|
+
two links that keyed identically and the second capture overwrote the first. Trailing
|
|
57
|
+
slashes are now normalized in the dedup identity (root `/` and the query string are
|
|
58
|
+
left intact), so a route is captured once.
|
|
59
|
+
- Genuinely distinct surfaces whose derived keys collide (e.g. `/a/b` and `/a-b` both
|
|
60
|
+
slugify to `a-b`) previously overwrote each other's map file. `selectCrawlLinks` now
|
|
61
|
+
disambiguates with a `-2`, `-3`, … suffix (mirroring the surface crawler), so every
|
|
62
|
+
surface survives.
|
|
63
|
+
- `defaultLinkKey` joined query-param values in iteration order, so `?tab=a&x=b` and
|
|
64
|
+
`?x=b&tab=a` — the same route — keyed as `a-b` vs `b-a` and flapped the coverage
|
|
65
|
+
guard into phantom regressions. Params are now sorted by name before joining.
|
|
66
|
+
- The surface crawler's clickable-candidate label omitted the `title` attribute, so an
|
|
67
|
+
icon-only `<button title="Delete">` labeled as `button` and slipped past the
|
|
68
|
+
destructive-action guard — mapping would click it. `title` is now part of the label
|
|
69
|
+
input in both crawlers.
|
|
70
|
+
- The variant crawler carried a weaker, divergent copy of the destructive-action word
|
|
71
|
+
list (missing `revoke|reset|wipe|drop|rotate|provision|seal|regenerate|renew`). Both
|
|
72
|
+
crawlers now share one `DANGER_SOURCE` constant, so mapping refuses the same set of
|
|
73
|
+
destructive controls everywhere.
|
|
74
|
+
- `defineStyleMapCapture` and `defineCrawlCapture` now assert every expanded capture
|
|
75
|
+
key is unique before running: the `surface.key-variant.key` join is ambiguous
|
|
76
|
+
(`a` + `b-c` and `a-b` + `c` both expand to `a-b-c`), which used to overwrite a map
|
|
77
|
+
file with no error. The key format is unchanged (it's public — filenames and report
|
|
78
|
+
identities); a collision now throws up front and names both origins so the author can
|
|
79
|
+
rename one.
|
|
80
|
+
- **Sass `@use`/`@forward` in a CSS Module now fails closed to `'all'`.** A
|
|
81
|
+
`.module.scss`/`.module.sass` that loads a partial via `@use`/`@forward` can pull in
|
|
82
|
+
global rules the JS import graph can't see, so `classifyStyleChange` now treats any
|
|
83
|
+
such file as global (`'all'`) — a sound over-approximation, no heuristics. A plain CSS
|
|
84
|
+
Module with only class selectors stays `'scope'` as before.
|
|
85
|
+
- **Legacy Sass/CSS `@import` in a CSS Module now fails closed too.** The fail-closed
|
|
86
|
+
load check missed the `@import` form, so a `.module.scss` loading a partial via
|
|
87
|
+
`@import "vars"` — whose members merge in exactly like `@use` — classified as `'scope'`
|
|
88
|
+
and could silently skip a re-capture. `classifyStyleChange` now treats any
|
|
89
|
+
`@use`/`@forward`/`@import` load as global (`'all'`), covering both the Sass partial
|
|
90
|
+
load and the plain-CSS pass-through (`@import url(x.css)`, whose selectors are not
|
|
91
|
+
hashed into the module's per-file scope, so it escapes the module). Only ever widens
|
|
92
|
+
toward `'all'`; a module with no load directive still stays `'scope'`.
|
|
93
|
+
- **`affectedSurfaces` now canonicalizes paths across all inputs, closing a silent
|
|
94
|
+
unsound skip.** `surfaces` entries, `changedFiles`, graph edges, and `files` are now
|
|
95
|
+
normalized to one spelling (strip a leading `./`, collapse `//`, resolve `.`/`..` as
|
|
96
|
+
pure string math — no fs), so a `./pages/Home.tsx` surface entry no longer misses a
|
|
97
|
+
reachability hit spelled `pages/Home.tsx` and gets dropped from the affected set. And a
|
|
98
|
+
declared surface whose entry path appears in neither `files` nor any graph edge is now
|
|
99
|
+
unplaceable → `'all'`, the same fail-closed rule as an unplaceable changed file.
|
|
100
|
+
- **Stale browser-build sidecar can no longer stamp a false fingerprint.**
|
|
101
|
+
`styleproof-map` now deletes any prior run's `styleproof-browser.json` before Playwright
|
|
102
|
+
runs, and `writeBrowserBuildSidecar(dir, undefined)` now removes an existing sidecar
|
|
103
|
+
rather than leaving it. Previously a reused capture dir plus a run that recorded no
|
|
104
|
+
browser version would fold the _previous_ run's build into this run's manifest, and
|
|
105
|
+
`assertCompatibleMapDirs` would trust that false `browserVersion` fingerprint.
|
|
106
|
+
- **`captureStyleMap` no longer leaks its motion-freeze `<style>` onto a reused page.**
|
|
107
|
+
The freeze injected for the base/forced-state reads was re-applied without a handle and
|
|
108
|
+
never removed, so on a page recaptured **without a reload** (an SPA `go()` that doesn't
|
|
109
|
+
navigate, multi-surface reuse, the self-check's re-run) the next capture's motion pass
|
|
110
|
+
read the still-frozen transition/animation longhands (`none`/`0s`) as its baseline —
|
|
111
|
+
phantom drift that surfaced as a **false "non-deterministic capture"** self-check
|
|
112
|
+
failure. The re-applied tag is now tracked and removed in a `finally`, so throw paths
|
|
113
|
+
clean up too. (No API change.)
|
|
114
|
+
- **`liveStates` + `expected` no longer reports a false coverage gap.** A surface with
|
|
115
|
+
`liveStates` is captured only as its split expansions (`home-loading`, `home-loaded`) —
|
|
116
|
+
the bare base key is dropped by design — but the coverage ledger recorded `expected`
|
|
117
|
+
in base keys and the gate compared captured keys literally, so a fully-captured app
|
|
118
|
+
failed the gate with `uncovered: ['home']` (live in 3.18.0). The ledger writer now
|
|
119
|
+
records `expected` already translated through the same liveState expansion, and the
|
|
120
|
+
suite-side guard maps each capture back to its originating `surfaceKey` — a precise
|
|
121
|
+
mapping via real metadata, so an unrelated `home-banner` never satisfies an uncaptured
|
|
122
|
+
`home`. Both the spec-driven and crawl capture paths are fixed consistently.
|
|
123
|
+
|
|
10
124
|
## [3.18.0] - 2026-07-06
|
|
11
125
|
|
|
12
126
|
### Added
|
package/README.md
CHANGED
|
@@ -733,7 +733,48 @@ const result = affectedSurfaces({
|
|
|
733
733
|
// → 'all' (some change couldn't be bounded — capture everything)
|
|
734
734
|
```
|
|
735
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
|
|
736
|
+
Two honest limits, both resolving to `'all'`: a computed `import(`../dir/${x}`)` is treated as a bundler **context module** (every file under that dir is a possible target, so precision there is directory-level, never a miss); and a CSS-Module (`.module.scss`/`.module.sass`) that carries a Sass `@use`/`@forward` load resolves to `'all'`, because those pull in a partial the JS import graph can't bound. One honest **residual** stays `'scope'` by design: the CSS-in-JS global list (`createGlobalStyle`, `injectGlobal`, `globalStyle`, …) must match the libraries you use — an allowlist can't fail closed on an _unknown_ member, so an unrecognized global API in a `.tsx` is the one way a scoped verdict could be unsound. Treat an unsupported styling system as a reason to skip selective remap. Because a PR-time miss would be silent, always let `main` (or a scheduled run) capture **all** surfaces as the trust-but-verify net.
|
|
737
|
+
|
|
738
|
+
### Show the skip list, then wire the pre-push hook
|
|
739
|
+
|
|
740
|
+
Before you trust a skip, print it. `explainAffectedSurfaces(result, allSurfaceKeys)` renders the verdict as reviewer-checkable lines — which surfaces re-capture and which reuse their committed base map — and takes an optional reason string for the `'all'` case:
|
|
741
|
+
|
|
742
|
+
```ts
|
|
743
|
+
import { affectedSurfaces, explainAffectedSurfaces } from 'styleproof';
|
|
744
|
+
|
|
745
|
+
const result = affectedSurfaces(/* … */);
|
|
746
|
+
console.log(explainAffectedSurfaces(result, Object.keys(surfaces)));
|
|
747
|
+
```
|
|
748
|
+
|
|
749
|
+
A scoped change (only `dashboard`'s subtree touched) prints:
|
|
750
|
+
|
|
751
|
+
```
|
|
752
|
+
selective remap: ON → re-capture 1, reuse 2 from base
|
|
753
|
+
↻ dashboard (re-capture — a changed file reaches it)
|
|
754
|
+
✓ home (reuse base map — no changed file reaches it)
|
|
755
|
+
✓ pricing (reuse base map — no changed file reaches it)
|
|
756
|
+
```
|
|
757
|
+
|
|
758
|
+
A global/token change fails closed to a full re-capture:
|
|
759
|
+
|
|
760
|
+
```
|
|
761
|
+
selective remap: OFF → re-capture all 3 surface(s) — src/tokens.css is a global (unscoped) stylesheet
|
|
762
|
+
↻ dashboard (re-capture)
|
|
763
|
+
↻ home (re-capture)
|
|
764
|
+
↻ pricing (re-capture)
|
|
765
|
+
```
|
|
766
|
+
|
|
767
|
+
Wired into a pre-push hook, the whole recipe is: diff the changed files, produce the graph, ask `affectedSurfaces`, print the skip list, then capture only the subset and reuse the committed base maps for the rest.
|
|
768
|
+
|
|
769
|
+
```sh
|
|
770
|
+
#!/usr/bin/env sh
|
|
771
|
+
# .husky/pre-push (opt-in; the default CI gate still captures every surface)
|
|
772
|
+
CHANGED=$(git diff --name-only origin/main...HEAD)
|
|
773
|
+
npx dependency-cruiser src --no-config --output-type json > dc.json
|
|
774
|
+
node scripts/selective-remap.mjs "$CHANGED" dc.json # affectedSurfaces + explain + capture subset
|
|
775
|
+
```
|
|
776
|
+
|
|
777
|
+
`scripts/selective-remap.mjs` is yours to own — it maps `dc.json` into `{ from, to }` edges (as above), calls `affectedSurfaces`, prints `explainAffectedSurfaces`, then captures only the returned keys and copies each reused surface's committed base map forward. `main` re-captures everything, so a PR-time miss is still caught at merge.
|
|
737
778
|
|
|
738
779
|
## Newly-added elements show their full style
|
|
739
780
|
|
|
@@ -55,7 +55,10 @@ whole surface: --crawl
|
|
|
55
55
|
fulfilled with 500 → error render)
|
|
56
56
|
--workers <n> concurrent sweep workers (default 4); same surface set as a
|
|
57
57
|
serial crawl — pass 1 for byte-stable key attribution
|
|
58
|
-
--
|
|
58
|
+
--until-covered stop the crawl early the moment every stylesheet class has
|
|
59
|
+
been rendered — a coverage-oriented sweep for design mockups
|
|
60
|
+
--max-depth <n> throttle recursion depth (default: 16 — backstop for
|
|
61
|
+
append-generator UIs)
|
|
59
62
|
--max-actions <n> throttle controls tried per state (default: unbounded)
|
|
60
63
|
--max-states <n> throttle total surfaces (default: unbounded)
|
|
61
64
|
--no-reset-storage don't clear localStorage between steps (default: clear)
|
package/bin/styleproof-diff.mjs
CHANGED
|
@@ -303,39 +303,48 @@ const invRemovals = printInventoryAudit(inventoryAudit);
|
|
|
303
303
|
const coverageFails = printCoverageVerdict(coverageVerdict);
|
|
304
304
|
const determinismFails = printDeterminismVerdict(determinismVerdict);
|
|
305
305
|
|
|
306
|
-
if (jsonOut)
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
unacknowledged:
|
|
323
|
-
|
|
306
|
+
if (jsonOut) {
|
|
307
|
+
// A write failure (bad --json path, unwritable dir) is a usage/setup error, not a
|
|
308
|
+
// "reviewable differences" result — exit 2, never leak the exit-1 that CI reads as
|
|
309
|
+
// a real diff.
|
|
310
|
+
try {
|
|
311
|
+
fs.writeFileSync(
|
|
312
|
+
jsonOut,
|
|
313
|
+
JSON.stringify(
|
|
314
|
+
{
|
|
315
|
+
counts,
|
|
316
|
+
surfaces,
|
|
317
|
+
compared,
|
|
318
|
+
coverage: coverageVerdict,
|
|
319
|
+
determinism: determinismVerdict,
|
|
320
|
+
// The inventory verdict, machine-readable — parallel to coverage/determinism and
|
|
321
|
+
// to the report's certification block. `null` when no capture carried inventory.
|
|
322
|
+
// `unacknowledged` is the gating set: a CI can hard-fail on `unacknowledged.length`.
|
|
323
|
+
inventory: inventoryAudit && {
|
|
324
|
+
removed: inventoryAudit.delta.removed.map((i) => i.key),
|
|
325
|
+
added: inventoryAudit.delta.added.map((i) => i.key),
|
|
326
|
+
unacknowledged: inventoryAudit.unexplained.map((i) => i.key),
|
|
327
|
+
staleAcknowledgements: inventoryAudit.staleAllowances,
|
|
328
|
+
},
|
|
329
|
+
// Explain the `inventory: null` so a gate reading this JSON can tell "armed but no
|
|
330
|
+
// data" apart from "audited, nothing removed". Neither map carried inventory → set
|
|
331
|
+
// `inventory: true` in the capture spec (styleproof-init scaffolds it).
|
|
332
|
+
...(inventoryAudit
|
|
333
|
+
? {}
|
|
334
|
+
: {
|
|
335
|
+
inventoryNote:
|
|
336
|
+
'no captured map carried an inventory — set `inventory: true` in the capture spec to arm the navigable-removal gate',
|
|
337
|
+
}),
|
|
324
338
|
},
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
},
|
|
335
|
-
null,
|
|
336
|
-
2,
|
|
337
|
-
),
|
|
338
|
-
);
|
|
339
|
+
null,
|
|
340
|
+
2,
|
|
341
|
+
),
|
|
342
|
+
);
|
|
343
|
+
} catch (e) {
|
|
344
|
+
console.error(`${COMMAND}: could not write --json ${jsonOut}: ${e.message}`);
|
|
345
|
+
process.exit(2);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
339
348
|
|
|
340
349
|
const total = counts.dom + counts.style + counts.state;
|
|
341
350
|
const newSurfaces = surfaces.filter((s) => s.missing).length;
|
package/bin/styleproof-map.mjs
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
unknownFlagMessage,
|
|
23
23
|
} from '../dist/cli-errors.js';
|
|
24
24
|
import {
|
|
25
|
+
BROWSER_BUILD_SIDECAR,
|
|
25
26
|
DEFAULT_MAP_DIR,
|
|
26
27
|
DEFAULT_MAP_LABEL,
|
|
27
28
|
DEFAULT_MAP_STORE_BRANCH,
|
|
@@ -285,6 +286,13 @@ if (restore) {
|
|
|
285
286
|
}
|
|
286
287
|
}
|
|
287
288
|
|
|
289
|
+
// Clear any prior run's browser-build sidecar before Playwright runs, so ONLY this
|
|
290
|
+
// run can have written it. The default dir (.styleproof/maps/current) is reused across
|
|
291
|
+
// runs; if this run records no browser version (the capture test not reached, or the
|
|
292
|
+
// handle unavailable), a stale sidecar would otherwise be read into the manifest and
|
|
293
|
+
// stamp a WRONG browser build that the compatibility guard then trusts as a fingerprint.
|
|
294
|
+
fs.rmSync(path.join(targetDir, BROWSER_BUILD_SIDECAR), { force: true });
|
|
295
|
+
|
|
288
296
|
const command = process.platform === 'win32' ? 'playwright.cmd' : 'playwright';
|
|
289
297
|
const configArgs =
|
|
290
298
|
fs.existsSync(STYLEPROOF_PLAYWRIGHT_CONFIG) && !hasPlaywrightConfigArg(playwrightArgs)
|
|
@@ -67,9 +67,33 @@ export type AffectedSurfaces = Set<string> | 'all';
|
|
|
67
67
|
* unrecognized, is `'all'`.
|
|
68
68
|
*/
|
|
69
69
|
export declare function classifyStyleChange(file: string, readFile: (p: string) => string): 'scope' | 'all';
|
|
70
|
+
/**
|
|
71
|
+
* Canonicalize a repo-relative path so the same file spells the same regardless
|
|
72
|
+
* of source (a `surfaces` value, a `changedFiles` entry, or a graph edge). Two
|
|
73
|
+
* tools disagree on `./pages/Home.tsx` vs `pages/Home.tsx` vs `pages//Home.tsx`;
|
|
74
|
+
* without one spelling, a reverse-reachability hit can silently miss the surface
|
|
75
|
+
* whose entry key was spelled differently, dropping it from the affected set —
|
|
76
|
+
* an unsound skip. Byte-cheap and fs-free: strip a leading `./`, collapse `//`,
|
|
77
|
+
* and drop `.`/`..` segments as pure string math (no realpath, no resolution). */
|
|
78
|
+
export declare function canonicalPath(p: string): string;
|
|
70
79
|
/**
|
|
71
80
|
* Compute the set of declared surfaces a change could have altered, or `'all'`.
|
|
72
81
|
* See the module doc for the soundness contract. Any not in the returned set are
|
|
73
82
|
* provably unaffected and may reuse their committed base map.
|
|
74
83
|
*/
|
|
75
84
|
export declare function affectedSurfaces(input: AffectedSurfacesInput): AffectedSurfaces;
|
|
85
|
+
/**
|
|
86
|
+
* Render an {@link affectedSurfaces} verdict as human-readable lines a pre-push
|
|
87
|
+
* hook (or CI log) can print, so a reviewer can sanity-check the skip list before
|
|
88
|
+
* trusting it. Pure formatter — no I/O, no graph work.
|
|
89
|
+
*
|
|
90
|
+
* @param result the value {@link affectedSurfaces} returned.
|
|
91
|
+
* @param allSurfaces every declared surface key (e.g. `Object.keys(surfaces)`),
|
|
92
|
+
* so the helper can name what is *reused from base* — the ones
|
|
93
|
+
* the verdict skips — not just what re-captures.
|
|
94
|
+
* @param reason optional one-line explanation for an `'all'` verdict (e.g.
|
|
95
|
+
* the classifying file, from {@link classifyStyleChange}). The
|
|
96
|
+
* library doesn't attach a reason to the sentinel, so pass it
|
|
97
|
+
* if the caller knows why; omitted, the `'all'` line stands alone.
|
|
98
|
+
*/
|
|
99
|
+
export declare function explainAffectedSurfaces(result: AffectedSurfaces, allSurfaces: Iterable<string>, reason?: string): string;
|
|
@@ -42,6 +42,15 @@ const GLOBAL_CSS = /(^|\})\s*(:root|html|body|\*)[\s,{]|@tailwind\b|@layer\s+bas
|
|
|
42
42
|
const CSSJS_GLOBAL = /\b(createGlobalStyle|injectGlobal|globalStyle|globalCss|createGlobalTheme)\b/;
|
|
43
43
|
// `:global(...)` escape hatch or cross-module composition pulls in outside scope.
|
|
44
44
|
const MODULE_ESCAPES = /:global\b|\bcompose[sd]?\b[^;]*\bfrom\b/;
|
|
45
|
+
// Sass `@use`/`@forward`/`@import` pull another sheet's members into a CSS-module
|
|
46
|
+
// file. dependency-cruiser parses JS imports, not Sass loads, so the import graph
|
|
47
|
+
// can't bound them — fail closed on any occurrence. `@import` covers both the Sass
|
|
48
|
+
// partial load (`@import "vars"`, whose members — possibly global rules — merge in
|
|
49
|
+
// exactly like `@use`) and the plain-CSS pass-through form (`@import url(x.css)`,
|
|
50
|
+
// `@import "sheet.css"`): a CSS `@import` composes an external sheet whose selectors
|
|
51
|
+
// are NOT hashed into the module's per-file scope, so it escapes the module too.
|
|
52
|
+
// Either way the change is unbounded → 'all'. Only widen; never narrows a verdict.
|
|
53
|
+
const SASS_LOAD = /@(?:use|forward|import)\b/;
|
|
45
54
|
const isConfig = (f) => /(?:^|\/)(?:tailwind|postcss|theme|tokens?|panda|uno)\.config\.[cm]?[jt]s$/.test(f) ||
|
|
46
55
|
/(?:^|\/)theme\.[cm]?[jt]s$/.test(f);
|
|
47
56
|
const isStyleSheet = (f) => /\.(css|scss|sass|less|styl)$/.test(f);
|
|
@@ -71,9 +80,10 @@ export function classifyStyleChange(file, readFile) {
|
|
|
71
80
|
return CSSJS_GLOBAL.test(src) ? 'all' : 'scope'; // .tsx: global CSS-in-JS or colocated scope
|
|
72
81
|
if (isStyleSheet(file)) {
|
|
73
82
|
if (isCssModule(file)) {
|
|
74
|
-
// Hashed per-file scope — but `:global`, cross-module `composes … from`,
|
|
75
|
-
// genuinely global selectors (`:root`, `html`, `@font-face`, …)
|
|
76
|
-
|
|
83
|
+
// Hashed per-file scope — but `:global`, cross-module `composes … from`,
|
|
84
|
+
// genuinely global selectors (`:root`, `html`, `@font-face`, …), and any
|
|
85
|
+
// `@use`/`@forward`/`@import` load (Sass partial or plain-CSS sheet) escape it.
|
|
86
|
+
return MODULE_ESCAPES.test(src) || GLOBAL_CSS.test(src) || SASS_LOAD.test(src) ? 'all' : 'scope';
|
|
77
87
|
}
|
|
78
88
|
return 'all'; // vanilla stylesheet: global class namespace, import graph can't bound it
|
|
79
89
|
}
|
|
@@ -83,6 +93,26 @@ export function classifyStyleChange(file, readFile) {
|
|
|
83
93
|
const DYNAMIC_IMPORT = /import\(\s*([^)]+?)\s*\)/g;
|
|
84
94
|
const dirOf = (p) => (p.includes('/') ? p.slice(0, p.lastIndexOf('/')) : '');
|
|
85
95
|
const isSource = (p) => !p.includes('node_modules');
|
|
96
|
+
/**
|
|
97
|
+
* Canonicalize a repo-relative path so the same file spells the same regardless
|
|
98
|
+
* of source (a `surfaces` value, a `changedFiles` entry, or a graph edge). Two
|
|
99
|
+
* tools disagree on `./pages/Home.tsx` vs `pages/Home.tsx` vs `pages//Home.tsx`;
|
|
100
|
+
* without one spelling, a reverse-reachability hit can silently miss the surface
|
|
101
|
+
* whose entry key was spelled differently, dropping it from the affected set —
|
|
102
|
+
* an unsound skip. Byte-cheap and fs-free: strip a leading `./`, collapse `//`,
|
|
103
|
+
* and drop `.`/`..` segments as pure string math (no realpath, no resolution). */
|
|
104
|
+
export function canonicalPath(p) {
|
|
105
|
+
const out = [];
|
|
106
|
+
for (const seg of p.split('/')) {
|
|
107
|
+
if (seg === '' || seg === '.')
|
|
108
|
+
continue;
|
|
109
|
+
if (seg === '..')
|
|
110
|
+
out.pop();
|
|
111
|
+
else
|
|
112
|
+
out.push(seg);
|
|
113
|
+
}
|
|
114
|
+
return out.join('/');
|
|
115
|
+
}
|
|
86
116
|
function link(rev, to, from) {
|
|
87
117
|
let s = rev.get(to);
|
|
88
118
|
if (!s)
|
|
@@ -147,22 +177,19 @@ function normalizeDir(fromDir, prefix) {
|
|
|
147
177
|
}
|
|
148
178
|
return out.join('/');
|
|
149
179
|
}
|
|
150
|
-
/** True if any changed file is a style change that escapes its importers. */
|
|
151
|
-
function anyGlobalStyleChange(changed, readFile) {
|
|
152
|
-
return changed.some((f) => (isStyleSheet(f) || isCode(f) || isConfig(f)) && classifyStyleChange(f, readFile) === 'all');
|
|
153
|
-
}
|
|
154
180
|
/**
|
|
155
181
|
* Build the reverse-import adjacency (`imported → importers`) from the graph plus
|
|
156
182
|
* recovered context-module edges. `'all'` when an unbounded dynamic import makes
|
|
157
|
-
* the whole reachability untrustworthy.
|
|
183
|
+
* the whole reachability untrustworthy. Paths are already canonical; `read`
|
|
184
|
+
* resolves a canonical path back to the caller's spelling before reading.
|
|
158
185
|
*/
|
|
159
|
-
function buildReverseGraph(
|
|
186
|
+
function buildReverseGraph(graph, files, read) {
|
|
160
187
|
const rev = new Map();
|
|
161
|
-
for (const e of
|
|
188
|
+
for (const e of graph)
|
|
162
189
|
if (isSource(e.from) && isSource(e.to))
|
|
163
190
|
link(rev, e.to, e.from);
|
|
164
191
|
for (const f of files) {
|
|
165
|
-
const src = isCode(f) ?
|
|
192
|
+
const src = isCode(f) ? read(f) : undefined;
|
|
166
193
|
if (src === undefined)
|
|
167
194
|
continue;
|
|
168
195
|
const extra = recoverContextEdges(f, src, files);
|
|
@@ -179,18 +206,45 @@ function buildReverseGraph(input, files) {
|
|
|
179
206
|
* provably unaffected and may reuse their committed base map.
|
|
180
207
|
*/
|
|
181
208
|
export function affectedSurfaces(input) {
|
|
182
|
-
|
|
209
|
+
// Canonicalize every path (surfaces values, changedFiles, graph from/to, files)
|
|
210
|
+
// through one spelling up front, so a `./`-prefixed or `//`-collapsed path from
|
|
211
|
+
// one source can't silently miss a match against another source's spelling.
|
|
212
|
+
// `readFile` is keyed on the caller's ORIGINAL spellings, so wrap it to resolve
|
|
213
|
+
// a canonical path back to the original it came from before reading.
|
|
214
|
+
const changed = [...input.changedFiles].map((f) => canonicalPath(f));
|
|
183
215
|
const files = [...input.files];
|
|
216
|
+
const canonFiles = files.map((f) => canonicalPath(f));
|
|
217
|
+
const originalByCanon = new Map();
|
|
218
|
+
files.forEach((orig, i) => originalByCanon.set(canonFiles[i], orig));
|
|
219
|
+
[...input.changedFiles].forEach((orig) => {
|
|
220
|
+
const c = canonicalPath(orig);
|
|
221
|
+
if (!originalByCanon.has(c))
|
|
222
|
+
originalByCanon.set(c, orig);
|
|
223
|
+
});
|
|
224
|
+
const surfaces = Object.fromEntries(Object.entries(input.surfaces).map(([k, f]) => [k, canonicalPath(f)]));
|
|
225
|
+
const graph = [...input.graph].map((e) => ({ ...e, from: canonicalPath(e.from), to: canonicalPath(e.to) }));
|
|
226
|
+
const read = (canon) => safeRead(input.readFile, originalByCanon.get(canon) ?? canon);
|
|
184
227
|
// 1. A style change that escapes its importers forces a full re-capture.
|
|
185
|
-
if (
|
|
228
|
+
if (changed.some((f) => isStyleOrCode(f) && classifyCanon(f, read) === 'all'))
|
|
186
229
|
return 'all';
|
|
187
230
|
// 2. Reverse reachability (+ recovered context edges; unbounded dynamic → all).
|
|
188
|
-
const rev = buildReverseGraph(
|
|
231
|
+
const rev = buildReverseGraph(graph, canonFiles, read);
|
|
189
232
|
if (rev === 'all')
|
|
190
233
|
return 'all';
|
|
191
|
-
// 3. Map each changed file to the surfaces that transitively import it.
|
|
192
|
-
|
|
193
|
-
|
|
234
|
+
// 3. Map each changed file to the surfaces that transitively import it.
|
|
235
|
+
const entryToKey = new Map(Object.entries(surfaces).map(([k, f]) => [f, k]));
|
|
236
|
+
// A surface whose entry path appears in neither `files` nor any graph edge is
|
|
237
|
+
// unplaceable: reverse reachability can never route a change to it, so a genuine
|
|
238
|
+
// hit would be dropped silently. Same fail-closed rule as an unplaceable changed
|
|
239
|
+
// file → 'all'.
|
|
240
|
+
const placeable = new Set(canonFiles);
|
|
241
|
+
for (const e of graph) {
|
|
242
|
+
placeable.add(e.from);
|
|
243
|
+
placeable.add(e.to);
|
|
244
|
+
}
|
|
245
|
+
for (const f of Object.values(surfaces))
|
|
246
|
+
if (!placeable.has(f))
|
|
247
|
+
return 'all';
|
|
194
248
|
const hit = new Set();
|
|
195
249
|
for (const f of changed) {
|
|
196
250
|
const reach = reverseReach(f, rev);
|
|
@@ -204,6 +258,18 @@ export function affectedSurfaces(input) {
|
|
|
204
258
|
}
|
|
205
259
|
return hit;
|
|
206
260
|
}
|
|
261
|
+
/** A changed file whose kind participates in style scope (stylesheet, code, or config). */
|
|
262
|
+
const isStyleOrCode = (f) => isStyleSheet(f) || isCode(f) || isConfig(f);
|
|
263
|
+
/** {@link classifyStyleChange} against a reader that returns `undefined` for a missing
|
|
264
|
+
* file (rather than throwing), matching the reverse-graph reader shape. */
|
|
265
|
+
function classifyCanon(file, read) {
|
|
266
|
+
return classifyStyleChange(file, (p) => {
|
|
267
|
+
const src = read(p);
|
|
268
|
+
if (src == null)
|
|
269
|
+
throw new Error('unreadable');
|
|
270
|
+
return src;
|
|
271
|
+
});
|
|
272
|
+
}
|
|
207
273
|
function reverseReach(file, rev) {
|
|
208
274
|
const seen = new Set();
|
|
209
275
|
const stack = [file];
|
|
@@ -218,3 +284,35 @@ function reverseReach(file, rev) {
|
|
|
218
284
|
}
|
|
219
285
|
return seen;
|
|
220
286
|
}
|
|
287
|
+
/**
|
|
288
|
+
* Render an {@link affectedSurfaces} verdict as human-readable lines a pre-push
|
|
289
|
+
* hook (or CI log) can print, so a reviewer can sanity-check the skip list before
|
|
290
|
+
* trusting it. Pure formatter — no I/O, no graph work.
|
|
291
|
+
*
|
|
292
|
+
* @param result the value {@link affectedSurfaces} returned.
|
|
293
|
+
* @param allSurfaces every declared surface key (e.g. `Object.keys(surfaces)`),
|
|
294
|
+
* so the helper can name what is *reused from base* — the ones
|
|
295
|
+
* the verdict skips — not just what re-captures.
|
|
296
|
+
* @param reason optional one-line explanation for an `'all'` verdict (e.g.
|
|
297
|
+
* the classifying file, from {@link classifyStyleChange}). The
|
|
298
|
+
* library doesn't attach a reason to the sentinel, so pass it
|
|
299
|
+
* if the caller knows why; omitted, the `'all'` line stands alone.
|
|
300
|
+
*/
|
|
301
|
+
export function explainAffectedSurfaces(result, allSurfaces, reason) {
|
|
302
|
+
const all = [...allSurfaces].sort();
|
|
303
|
+
if (result === 'all') {
|
|
304
|
+
const why = reason ? ` — ${reason}` : '';
|
|
305
|
+
return [
|
|
306
|
+
`selective remap: OFF → re-capture all ${all.length} surface(s)${why}`,
|
|
307
|
+
...all.map((k) => ` ↻ ${k} (re-capture)`),
|
|
308
|
+
].join('\n');
|
|
309
|
+
}
|
|
310
|
+
const recapture = [...result].sort();
|
|
311
|
+
const hit = new Set(recapture);
|
|
312
|
+
const reused = all.filter((k) => !hit.has(k));
|
|
313
|
+
return [
|
|
314
|
+
`selective remap: ON → re-capture ${recapture.length}, reuse ${reused.length} from base`,
|
|
315
|
+
...recapture.map((k) => ` ↻ ${k} (re-capture — a changed file reaches it)`),
|
|
316
|
+
...reused.map((k) => ` ✓ ${k} (reuse base map — no changed file reaches it)`),
|
|
317
|
+
].join('\n');
|
|
318
|
+
}
|
package/dist/capture-url.d.ts
CHANGED
|
@@ -45,7 +45,7 @@ export type CaptureUrlOptions = {
|
|
|
45
45
|
* discovered surface under a derived key. See {@link crawlAndCapture}.
|
|
46
46
|
*/
|
|
47
47
|
crawl: boolean;
|
|
48
|
-
/** crawl: recursion depth into opened surfaces (default
|
|
48
|
+
/** crawl: recursion depth into opened surfaces (default 16). */
|
|
49
49
|
maxDepth: number;
|
|
50
50
|
/** crawl: fresh controls driven per state (default: unbounded — try them all). */
|
|
51
51
|
maxActionsPerState: number;
|
package/dist/capture.js
CHANGED
|
@@ -727,33 +727,47 @@ export async function captureStyleMap(page, options = {}) {
|
|
|
727
727
|
// nulls motion to 0s), then re-apply it before reading everything else.
|
|
728
728
|
await freezeTag.evaluate((el) => el.remove());
|
|
729
729
|
const motion = await page.evaluate(capturePage, { ignore, motionOnly: true, captureText: false });
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
730
|
+
// Re-apply the freeze for the base + forced-state reads (both must see motion
|
|
731
|
+
// nulled). This tag is KEEP-a-handle: unlike the pre-motion tag above (which was
|
|
732
|
+
// explicitly removed), an un-tracked tag would accumulate on any page reused without
|
|
733
|
+
// a reload — a second capture on the same page (SPA go() that doesn't navigate,
|
|
734
|
+
// multi-surface reuse, the self-check's re-run) would then read this run's frozen
|
|
735
|
+
// motion (`none`/`0s`) as its baseline and report phantom drift. Remove it in a
|
|
736
|
+
// `finally` so throw paths (a settle timeout, a forced-state error) also leave the
|
|
737
|
+
// page clean, not just the happy path.
|
|
738
|
+
const refreezeTag = await page.addStyleTag({ content: FREEZE_CSS });
|
|
739
|
+
try {
|
|
740
|
+
const base = await page.evaluate(capturePage, { ignore, motionOnly: false, captureText, captureComponent });
|
|
741
|
+
dropVolatile(base.elements, volatile);
|
|
742
|
+
const overlays = (await page.evaluate(detectOverlayCandidates, { ignore })).filter((overlay) => base.elements[overlay.path]);
|
|
743
|
+
warnUntraversed(base.shadowHosts, base.sameOriginFrames);
|
|
744
|
+
mergeMotion(base.elements, motion.elements);
|
|
745
|
+
let states = {};
|
|
746
|
+
let statesSkipped = false;
|
|
747
|
+
if (captureStates) {
|
|
748
|
+
const forced = await captureForcedStates(page, ignore, maxInteractive, volatile);
|
|
749
|
+
states = forced.states;
|
|
750
|
+
statesSkipped = forced.skipped;
|
|
751
|
+
}
|
|
752
|
+
const tokens = await page.evaluate(capturePageTokens);
|
|
753
|
+
const inventory = await harvestInventoryFor(page, options.inventory);
|
|
754
|
+
return {
|
|
755
|
+
...(options.metadata ? { metadata: options.metadata } : {}),
|
|
756
|
+
defaults: base.defaults,
|
|
757
|
+
elements: base.elements,
|
|
758
|
+
states,
|
|
759
|
+
...(statesSkipped ? { statesSkipped: true } : {}),
|
|
760
|
+
...(volatile.length ? { volatile } : {}),
|
|
761
|
+
...(liveCandidates.length ? { liveCandidates } : {}),
|
|
762
|
+
...(overlays.length ? { overlays } : {}),
|
|
763
|
+
...(Object.keys(tokens).length ? { tokens } : {}),
|
|
764
|
+
...(inventory.length ? { inventory } : {}),
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
finally {
|
|
768
|
+
// Best-effort: the page may already be closing on a throw path.
|
|
769
|
+
await refreezeTag.evaluate((el) => el.remove()).catch(() => { });
|
|
742
770
|
}
|
|
743
|
-
const tokens = await page.evaluate(capturePageTokens);
|
|
744
|
-
const inventory = await harvestInventoryFor(page, options.inventory);
|
|
745
|
-
return {
|
|
746
|
-
...(options.metadata ? { metadata: options.metadata } : {}),
|
|
747
|
-
defaults: base.defaults,
|
|
748
|
-
elements: base.elements,
|
|
749
|
-
states,
|
|
750
|
-
...(statesSkipped ? { statesSkipped: true } : {}),
|
|
751
|
-
...(volatile.length ? { volatile } : {}),
|
|
752
|
-
...(liveCandidates.length ? { liveCandidates } : {}),
|
|
753
|
-
...(overlays.length ? { overlays } : {}),
|
|
754
|
-
...(Object.keys(tokens).length ? { tokens } : {}),
|
|
755
|
-
...(inventory.length ? { inventory } : {}),
|
|
756
|
-
};
|
|
757
771
|
}
|
|
758
772
|
/** Write a style map to disk; gzipped when the path ends in `.gz`. */
|
|
759
773
|
export function saveStyleMap(filePath, map) {
|
package/dist/coverage.d.ts
CHANGED
|
@@ -34,6 +34,41 @@ export type CoverageGaps = {
|
|
|
34
34
|
* dir), and it's exported so a consumer can assert coverage however it likes.
|
|
35
35
|
*/
|
|
36
36
|
export declare function coverageGaps(capturedKeys: Iterable<string>, expected: Iterable<string>, exclude?: Record<string, string>): CoverageGaps;
|
|
37
|
+
/**
|
|
38
|
+
* Translate captured surface keys into the DECLARED keys they satisfy for coverage.
|
|
39
|
+
*
|
|
40
|
+
* `expected` is stated in base surface keys (`home`), but a surface with `liveStates`
|
|
41
|
+
* is captured ONLY as its split expansions (`home-loading`, `home-loaded`) — the bare
|
|
42
|
+
* base key is dropped by design (the base live state is fuzzy). Comparing the expanded
|
|
43
|
+
* keys literally against `expected` would report the declared `home` as uncovered on a
|
|
44
|
+
* fully-captured app. Each expansion still carries its originating `surfaceKey`, so the
|
|
45
|
+
* declared base key is exactly recoverable: a capture satisfies its own key AND its
|
|
46
|
+
* `surfaceKey`. This is precise (it maps only real expansions back to their real base),
|
|
47
|
+
* not a suffix heuristic — an unrelated `home-banner` never satisfies an uncaptured `home`.
|
|
48
|
+
*/
|
|
49
|
+
export declare function coverageKeys(captured: Iterable<{
|
|
50
|
+
key: string;
|
|
51
|
+
metadata?: {
|
|
52
|
+
surfaceKey?: string;
|
|
53
|
+
};
|
|
54
|
+
}>): string[];
|
|
55
|
+
/**
|
|
56
|
+
* Rewrite a declared `expected` universe (base keys) into the keys that are actually
|
|
57
|
+
* captured to disk, so the GATE — which reads expanded map filenames (`home-loading`)
|
|
58
|
+
* and can't see each capture's `surfaceKey` metadata — can compare literally.
|
|
59
|
+
*
|
|
60
|
+
* A declared key `K` is replaced by its captured liveState expansions when `K` is NOT
|
|
61
|
+
* itself a captured key but expansions carrying `surfaceKey === K` exist. A directly
|
|
62
|
+
* captured `K` is kept; a genuinely uncaptured `K` is kept verbatim so the gate still
|
|
63
|
+
* flags it. This is the write-time half of {@link coverageKeys}: the ledger travels
|
|
64
|
+
* pre-translated, so `auditCoverage` needs no metadata at gate time.
|
|
65
|
+
*/
|
|
66
|
+
export declare function translateExpected(expected: readonly string[], captured: Iterable<{
|
|
67
|
+
key: string;
|
|
68
|
+
metadata?: {
|
|
69
|
+
surfaceKey?: string;
|
|
70
|
+
};
|
|
71
|
+
}>): string[];
|
|
37
72
|
/** Bundled next to the maps, so the completeness basis travels with the capture. */
|
|
38
73
|
export declare const COVERAGE_LEDGER = "styleproof-coverage.json";
|
|
39
74
|
/**
|