styleproof 3.16.0 → 3.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +126 -0
- package/README.md +62 -6
- package/bin/styleproof-capture.mjs +15 -3
- package/bin/styleproof-diff.mjs +9 -0
- package/bin/styleproof-init.mjs +7 -0
- package/bin/styleproof-map.mjs +14 -0
- package/dist/affected-surfaces.d.ts +75 -0
- package/dist/affected-surfaces.js +220 -0
- package/dist/capture-url.js +6 -1
- package/dist/crawl-surfaces.d.ts +5 -1
- package/dist/crawl-surfaces.js +15 -11
- package/dist/crawl.d.ts +35 -0
- package/dist/crawl.js +28 -0
- package/dist/diff.d.ts +26 -0
- package/dist/diff.js +54 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.js +2 -1
- package/dist/map-store.d.ts +11 -0
- package/dist/map-store.js +38 -3
- package/dist/report.d.ts +9 -0
- package/dist/report.js +61 -8
- package/dist/runner.d.ts +23 -19
- package/dist/runner.js +94 -54
- package/package.json +1 -1
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Selective remap: given the files a change touched, which declared surfaces
|
|
3
|
+
* could have rendered differently?
|
|
4
|
+
*
|
|
5
|
+
* This is the sound core behind "capture only what a PR can affect, reuse the
|
|
6
|
+
* committed base map for the rest" — an OPT-IN speed-up, never the default gate.
|
|
7
|
+
* The default gate captures every surface and lets the map be the oracle; this
|
|
8
|
+
* function only decides which surfaces a caller *may* skip, and it is built to
|
|
9
|
+
* be wrong only in the safe direction.
|
|
10
|
+
*
|
|
11
|
+
* The hard constraint: in the committed-map model a wrong "unaffected" is silent
|
|
12
|
+
* and fatal — a stale committed map matches the base, the diff is empty, and the
|
|
13
|
+
* regression ships green. So this function OVER-APPROXIMATES. When it cannot
|
|
14
|
+
* prove a surface is unaffected, it returns the sentinel `'all'`, meaning
|
|
15
|
+
* "re-capture everything." Every uncertainty resolves to `'all'`:
|
|
16
|
+
*
|
|
17
|
+
* - a global style change (a reset, `:root`/theme token, `@tailwind`, a
|
|
18
|
+
* `createGlobalStyle`, a design-system config) cascades everywhere → `'all'`;
|
|
19
|
+
* - a vanilla (non-module) stylesheet has a global class namespace the import
|
|
20
|
+
* graph cannot bound → `'all'`;
|
|
21
|
+
* - a computed dynamic `import(x)` with no static prefix could load anything
|
|
22
|
+
* → `'all'`; with a static prefix (`import(`../dir/${x}`)`) it is treated as
|
|
23
|
+
* a bundler context module — every file under that dir is a possible target;
|
|
24
|
+
* - a changed file the graph cannot place at all → `'all'`.
|
|
25
|
+
*
|
|
26
|
+
* The module graph is an INPUT, not a dependency: pass any tool's output in the
|
|
27
|
+
* {@link ModuleEdge} shape (dependency-cruiser's `modules[].dependencies[]` maps
|
|
28
|
+
* directly). StyleProof stays framework-agnostic and adds no dependency; the
|
|
29
|
+
* caller owns graph production, which is where framework-specific resolution
|
|
30
|
+
* lives.
|
|
31
|
+
*
|
|
32
|
+
* Pure and side-effect-free (I/O is injected via `readFile`) so it is fully
|
|
33
|
+
* unit-testable and deterministic.
|
|
34
|
+
*/
|
|
35
|
+
// A stylesheet whose scope escapes the file that imports it. Any of these means
|
|
36
|
+
// a change cascades beyond the import graph's reach.
|
|
37
|
+
const GLOBAL_CSS = /(^|\})\s*(:root|html|body|\*)[\s,{]|@tailwind\b|@layer\s+base\b|@theme\b|@font-face\b/;
|
|
38
|
+
// CSS-in-JS global APIs. NOTE: soundness depends on this list being complete for
|
|
39
|
+
// the libraries in use — an unlisted global API in a .tsx would be misread as a
|
|
40
|
+
// scoped (local) change. Extend deliberately; when unsure, the caller should
|
|
41
|
+
// treat the styling system as unsupported and skip selective remap.
|
|
42
|
+
const CSSJS_GLOBAL = /\b(createGlobalStyle|injectGlobal|globalStyle|globalCss|createGlobalTheme)\b/;
|
|
43
|
+
// `:global(...)` escape hatch or cross-module composition pulls in outside scope.
|
|
44
|
+
const MODULE_ESCAPES = /:global\b|\bcompose[sd]?\b[^;]*\bfrom\b/;
|
|
45
|
+
const isConfig = (f) => /(?:^|\/)(?:tailwind|postcss|theme|tokens?|panda|uno)\.config\.[cm]?[jt]s$/.test(f) ||
|
|
46
|
+
/(?:^|\/)theme\.[cm]?[jt]s$/.test(f);
|
|
47
|
+
const isStyleSheet = (f) => /\.(css|scss|sass|less|styl)$/.test(f);
|
|
48
|
+
const isCssModule = (f) => /\.module\.(css|scss|sass|less|styl)$/.test(f);
|
|
49
|
+
const isCode = (f) => /\.[cm]?[jt]sx?$/.test(f);
|
|
50
|
+
/**
|
|
51
|
+
* Decide whether a single changed file's style scope is bounded to the files
|
|
52
|
+
* that import it (`'scope'` → follow the import graph) or escapes them
|
|
53
|
+
* (`'all'` → re-capture everything). Sound by construction: `'scope'` is
|
|
54
|
+
* returned only for provably-scoped changes (a CSS Module without escapes, or
|
|
55
|
+
* colocated CSS-in-JS with no global API); everything else, including anything
|
|
56
|
+
* unrecognized, is `'all'`.
|
|
57
|
+
*/
|
|
58
|
+
export function classifyStyleChange(file, readFile) {
|
|
59
|
+
if (isConfig(file))
|
|
60
|
+
return 'all'; // design-system config cascades to every surface
|
|
61
|
+
let src;
|
|
62
|
+
try {
|
|
63
|
+
src = readFile(file);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return 'all'; // cannot read → cannot prove local
|
|
67
|
+
}
|
|
68
|
+
if (src == null)
|
|
69
|
+
return 'all';
|
|
70
|
+
if (isCode(file))
|
|
71
|
+
return CSSJS_GLOBAL.test(src) ? 'all' : 'scope'; // .tsx: global CSS-in-JS or colocated scope
|
|
72
|
+
if (isStyleSheet(file)) {
|
|
73
|
+
if (isCssModule(file)) {
|
|
74
|
+
// Hashed per-file scope — but `:global`, cross-module `composes … from`, and
|
|
75
|
+
// genuinely global selectors (`:root`, `html`, `@font-face`, …) all escape it.
|
|
76
|
+
return MODULE_ESCAPES.test(src) || GLOBAL_CSS.test(src) ? 'all' : 'scope';
|
|
77
|
+
}
|
|
78
|
+
return 'all'; // vanilla stylesheet: global class namespace, import graph can't bound it
|
|
79
|
+
}
|
|
80
|
+
return 'all'; // unknown file kind → fail closed
|
|
81
|
+
}
|
|
82
|
+
// `import(` with a non-string-literal argument, capturing the argument text.
|
|
83
|
+
const DYNAMIC_IMPORT = /import\(\s*([^)]+?)\s*\)/g;
|
|
84
|
+
const dirOf = (p) => (p.includes('/') ? p.slice(0, p.lastIndexOf('/')) : '');
|
|
85
|
+
const isSource = (p) => !p.includes('node_modules');
|
|
86
|
+
function link(rev, to, from) {
|
|
87
|
+
let s = rev.get(to);
|
|
88
|
+
if (!s)
|
|
89
|
+
rev.set(to, (s = new Set()));
|
|
90
|
+
s.add(from);
|
|
91
|
+
}
|
|
92
|
+
/** Read a source file, mapping any throw/nullish result to `undefined`. */
|
|
93
|
+
function safeRead(readFile, path) {
|
|
94
|
+
try {
|
|
95
|
+
return readFile(path) ?? undefined;
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Resolve one computed `import()` argument to the directory its bundler context
|
|
103
|
+
* module is rooted at: `null` for a plain string literal (the resolver already
|
|
104
|
+
* captured it), `'unbounded'` when there is no static directory prefix (target
|
|
105
|
+
* could be any file), else the normalized directory.
|
|
106
|
+
*/
|
|
107
|
+
function contextDir(arg, fromDir) {
|
|
108
|
+
if (/^['"]/.test(arg))
|
|
109
|
+
return null; // plain string literal
|
|
110
|
+
const prefix = arg.match(/^`([^$`]*)/)?.[1]; // static head of a template literal
|
|
111
|
+
if (!prefix || !prefix.includes('/'))
|
|
112
|
+
return 'unbounded'; // e.g. import(name)
|
|
113
|
+
return normalizeDir(fromDir, prefix);
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Recover computed `import()`s the graph resolver dropped, as bundler context
|
|
117
|
+
* modules. Returns extra edges to add, or `'unbounded'` if any dynamic import
|
|
118
|
+
* has no static directory prefix (its target could be any file → the whole
|
|
119
|
+
* reachability is untrustworthy → caller must return `'all'`).
|
|
120
|
+
*/
|
|
121
|
+
function recoverContextEdges(from, src, files) {
|
|
122
|
+
const edges = [];
|
|
123
|
+
const fromDir = dirOf(from);
|
|
124
|
+
for (const m of src.matchAll(DYNAMIC_IMPORT)) {
|
|
125
|
+
const base = contextDir(m[1], fromDir);
|
|
126
|
+
if (base === 'unbounded')
|
|
127
|
+
return 'unbounded';
|
|
128
|
+
if (base === null)
|
|
129
|
+
continue;
|
|
130
|
+
for (const f of files)
|
|
131
|
+
if (dirOf(f) === base && isCode(f))
|
|
132
|
+
edges.push({ from, to: f, dynamic: true });
|
|
133
|
+
}
|
|
134
|
+
return edges;
|
|
135
|
+
}
|
|
136
|
+
// Resolve a `../a/b/` style prefix against a source file's directory, without fs.
|
|
137
|
+
function normalizeDir(fromDir, prefix) {
|
|
138
|
+
const parts = (fromDir ? fromDir.split('/') : []).concat(prefix.split('/'));
|
|
139
|
+
const out = [];
|
|
140
|
+
for (const p of parts) {
|
|
141
|
+
if (p === '' || p === '.')
|
|
142
|
+
continue;
|
|
143
|
+
if (p === '..')
|
|
144
|
+
out.pop();
|
|
145
|
+
else
|
|
146
|
+
out.push(p);
|
|
147
|
+
}
|
|
148
|
+
return out.join('/');
|
|
149
|
+
}
|
|
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
|
+
/**
|
|
155
|
+
* Build the reverse-import adjacency (`imported → importers`) from the graph plus
|
|
156
|
+
* recovered context-module edges. `'all'` when an unbounded dynamic import makes
|
|
157
|
+
* the whole reachability untrustworthy.
|
|
158
|
+
*/
|
|
159
|
+
function buildReverseGraph(input, files) {
|
|
160
|
+
const rev = new Map();
|
|
161
|
+
for (const e of input.graph)
|
|
162
|
+
if (isSource(e.from) && isSource(e.to))
|
|
163
|
+
link(rev, e.to, e.from);
|
|
164
|
+
for (const f of files) {
|
|
165
|
+
const src = isCode(f) ? safeRead(input.readFile, f) : undefined;
|
|
166
|
+
if (src === undefined)
|
|
167
|
+
continue;
|
|
168
|
+
const extra = recoverContextEdges(f, src, files);
|
|
169
|
+
if (extra === 'unbounded')
|
|
170
|
+
return 'all';
|
|
171
|
+
for (const e of extra)
|
|
172
|
+
link(rev, e.to, e.from);
|
|
173
|
+
}
|
|
174
|
+
return rev;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Compute the set of declared surfaces a change could have altered, or `'all'`.
|
|
178
|
+
* See the module doc for the soundness contract. Any not in the returned set are
|
|
179
|
+
* provably unaffected and may reuse their committed base map.
|
|
180
|
+
*/
|
|
181
|
+
export function affectedSurfaces(input) {
|
|
182
|
+
const changed = [...input.changedFiles];
|
|
183
|
+
const files = [...input.files];
|
|
184
|
+
// 1. A style change that escapes its importers forces a full re-capture.
|
|
185
|
+
if (anyGlobalStyleChange(changed, input.readFile))
|
|
186
|
+
return 'all';
|
|
187
|
+
// 2. Reverse reachability (+ recovered context edges; unbounded dynamic → all).
|
|
188
|
+
const rev = buildReverseGraph(input, files);
|
|
189
|
+
if (rev === 'all')
|
|
190
|
+
return 'all';
|
|
191
|
+
// 3. Map each changed file to the surfaces that transitively import it. A file
|
|
192
|
+
// the graph can't place (no importers, not a surface entry) → 'all'.
|
|
193
|
+
const entryToKey = new Map(Object.entries(input.surfaces).map(([k, f]) => [f, k]));
|
|
194
|
+
const hit = new Set();
|
|
195
|
+
for (const f of changed) {
|
|
196
|
+
const reach = reverseReach(f, rev);
|
|
197
|
+
if (reach.size === 0 && !entryToKey.has(f))
|
|
198
|
+
return 'all';
|
|
199
|
+
for (const src of [f, ...reach]) {
|
|
200
|
+
const key = entryToKey.get(src);
|
|
201
|
+
if (key)
|
|
202
|
+
hit.add(key);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return hit;
|
|
206
|
+
}
|
|
207
|
+
function reverseReach(file, rev) {
|
|
208
|
+
const seen = new Set();
|
|
209
|
+
const stack = [file];
|
|
210
|
+
while (stack.length) {
|
|
211
|
+
const cur = stack.pop();
|
|
212
|
+
for (const importer of rev.get(cur) ?? []) {
|
|
213
|
+
if (!seen.has(importer)) {
|
|
214
|
+
seen.add(importer);
|
|
215
|
+
stack.push(importer);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return seen;
|
|
220
|
+
}
|
package/dist/capture-url.js
CHANGED
|
@@ -26,7 +26,12 @@ const DEFAULTS = {
|
|
|
26
26
|
screenshots: true,
|
|
27
27
|
crawl: false,
|
|
28
28
|
// Exhaustive by default — these are safety backstops, not budgets.
|
|
29
|
-
maxDepth:
|
|
29
|
+
// maxDepth mirrors CRAWL_DEFAULTS.maxDepth (crawl-surfaces.ts): 16 is
|
|
30
|
+
// exhaustive for real UI — no human-navigable surface is 16 clicks from load.
|
|
31
|
+
// The cap exists to bound append-generator UIs (a composer that appends a
|
|
32
|
+
// fresh-identity node per click, which dedup can't terminate); a higher value
|
|
33
|
+
// would make it decorative. Raise with --max-depth for a genuinely deeper nest.
|
|
34
|
+
maxDepth: 16,
|
|
30
35
|
maxActionsPerState: 100000,
|
|
31
36
|
maxStates: 100000,
|
|
32
37
|
resetStorage: true,
|
package/dist/crawl-surfaces.d.ts
CHANGED
|
@@ -44,11 +44,15 @@ export type CrawledSurface = {
|
|
|
44
44
|
};
|
|
45
45
|
/** Did the crawl SEE everything the design styles? `missing` lists classes the
|
|
46
46
|
* page's own stylesheets select on that never appeared in any captured surface —
|
|
47
|
-
* dead CSS, or a state the crawl could not reach.
|
|
47
|
+
* dead CSS, or a state the crawl could not reach. `unreadable` names stylesheets
|
|
48
|
+
* the browser could not parse (cross-origin, no CORS): their class vocabulary is
|
|
49
|
+
* invisible, so coverage cannot be PROVEN against them. Full coverage = empty
|
|
50
|
+
* `missing` AND empty `unreadable`. */
|
|
48
51
|
export type CrawlCoverage = {
|
|
49
52
|
defined: number;
|
|
50
53
|
rendered: number;
|
|
51
54
|
missing: string[];
|
|
55
|
+
unreadable: string[];
|
|
52
56
|
};
|
|
53
57
|
export type CrawlReport = {
|
|
54
58
|
surfaces: CrawledSurface[];
|
package/dist/crawl-surfaces.js
CHANGED
|
@@ -249,12 +249,15 @@ function domShape() {
|
|
|
249
249
|
/* c8 ignore stop */
|
|
250
250
|
/** Runs in the browser: every class name the page's OWN stylesheets select on —
|
|
251
251
|
* the design's defined vocabulary, read from the parsed CSSOM (inline and
|
|
252
|
-
* same-origin sheets
|
|
253
|
-
*
|
|
254
|
-
*
|
|
252
|
+
* same-origin sheets). Coverage is checked against this, so "fully covered" means
|
|
253
|
+
* every class the design styles was seen rendered in at least one captured
|
|
254
|
+
* surface. A cross-origin sheet the browser can't parse is NOT silently skipped:
|
|
255
|
+
* its href is returned in `unreadable` so coverage names it as residue rather than
|
|
256
|
+
* certifying completeness while blind to it. */
|
|
255
257
|
/* c8 ignore start */
|
|
256
258
|
function collectDefinedClasses() {
|
|
257
259
|
const out = new Set();
|
|
260
|
+
const unreadable = [];
|
|
258
261
|
const scan = (rules) => {
|
|
259
262
|
if (!rules)
|
|
260
263
|
return;
|
|
@@ -271,13 +274,13 @@ function collectDefinedClasses() {
|
|
|
271
274
|
};
|
|
272
275
|
for (const sheet of document.styleSheets) {
|
|
273
276
|
try {
|
|
274
|
-
scan(sheet.cssRules);
|
|
277
|
+
scan(sheet.cssRules); // throws for a cross-origin sheet with no CORS
|
|
275
278
|
}
|
|
276
279
|
catch {
|
|
277
|
-
|
|
280
|
+
unreadable.push(sheet.href ?? '<inline>');
|
|
278
281
|
}
|
|
279
282
|
}
|
|
280
|
-
return [...out];
|
|
283
|
+
return { classes: [...out], unreadable };
|
|
281
284
|
}
|
|
282
285
|
/* c8 ignore stop */
|
|
283
286
|
/** Structural fingerprint of the page's CURRENT state. Dedup key for surfaces. */
|
|
@@ -842,16 +845,17 @@ async function discover(page, opts) {
|
|
|
842
845
|
await gotoFresh(page, opts);
|
|
843
846
|
// No widths given? Detect the page's real @media breakpoints (like the
|
|
844
847
|
// one-shot path does) and sweep one width per band — automatically. Detection
|
|
845
|
-
// reads every stylesheet
|
|
846
|
-
//
|
|
848
|
+
// reads every stylesheet and THROWS if one is cross-origin/unreadable: it never
|
|
849
|
+
// silently sweeps a single width, which would certify every other band
|
|
850
|
+
// unchanged without looking at it. Pin `--widths` for a cross-origin-CSS page.
|
|
847
851
|
if (opts.widths.length === 0) {
|
|
848
|
-
const widths = await detectViewportWidths(page)
|
|
852
|
+
const widths = await detectViewportWidths(page);
|
|
849
853
|
opts = { ...opts, widths };
|
|
850
854
|
if ((widths[0] ?? 1280) !== 1280)
|
|
851
855
|
await gotoFresh(page, opts); // re-pin discovery width BEFORE the base fingerprint
|
|
852
856
|
}
|
|
853
857
|
page.off('request', onRequest);
|
|
854
|
-
const defined = await page.evaluate(collectDefinedClasses)
|
|
858
|
+
const { classes: defined, unreadable } = await page.evaluate(collectDefinedClasses);
|
|
855
859
|
const fp = await fingerprint(page);
|
|
856
860
|
const st = {
|
|
857
861
|
seen: new Set([fp.sig]),
|
|
@@ -881,7 +885,7 @@ async function discover(page, opts) {
|
|
|
881
885
|
skipped: counters.skipped,
|
|
882
886
|
captured: st.captured,
|
|
883
887
|
failed: st.failed,
|
|
884
|
-
coverage: { defined: defined.length, rendered: defined.length - missing.length, missing },
|
|
888
|
+
coverage: { defined: defined.length, rendered: defined.length - missing.length, missing, unreadable },
|
|
885
889
|
};
|
|
886
890
|
}
|
|
887
891
|
/**
|
package/dist/crawl.d.ts
CHANGED
|
@@ -60,3 +60,38 @@ export declare function defaultLinkKey(url: URL): string;
|
|
|
60
60
|
* the capture order is the nav's order — stable across runs.
|
|
61
61
|
*/
|
|
62
62
|
export declare function selectCrawlLinks(hrefs: Iterable<string | null | undefined>, opts: SelectLinksOptions): CrawlLink[];
|
|
63
|
+
/**
|
|
64
|
+
* The reconciliation of a rendered nav (the crawl's discovered link keys) against a
|
|
65
|
+
* declared `expected` universe, both directions. Where the spec guard treats the
|
|
66
|
+
* hand-listed `surfaces` as what's captured, here the crawl's DISCOVERED links are —
|
|
67
|
+
* the nav is the route universe for a link-crawled SPA, so it is the source of truth.
|
|
68
|
+
*
|
|
69
|
+
* - `missing`: an `expected` key with no rendered link and no `exclude` entry — a
|
|
70
|
+
* nav-regression (a route the app promised is no longer linked).
|
|
71
|
+
* - `unexpected`: a rendered link with no `expected` entry and no `exclude` entry — a
|
|
72
|
+
* new route/view with no owner in the registry.
|
|
73
|
+
* - `staleExclusions`: an `exclude` key absent from BOTH `expected` and the rendered
|
|
74
|
+
* set — a rotted opt-out.
|
|
75
|
+
*
|
|
76
|
+
* Unlike {@link CoverageGaps} (which permits captured-not-expected so a spec can
|
|
77
|
+
* tighten its registry over time), the crawl asserts BOTH directions strictly: the
|
|
78
|
+
* rendered link set is complete by construction, so an unowned link is a real gap.
|
|
79
|
+
* Pure and browser-free so it's unit-testable; {@link import('./runner.js')} wraps it
|
|
80
|
+
* in the crawl capture test, where the link set is finally known.
|
|
81
|
+
*/
|
|
82
|
+
export type CrawlCoverageGaps = {
|
|
83
|
+
/** Expected keys with no rendered link and no `exclude` — a nav regression. */
|
|
84
|
+
missing: string[];
|
|
85
|
+
/** Rendered link keys absent from `expected` and `exclude` — a route with no owner. */
|
|
86
|
+
unexpected: string[];
|
|
87
|
+
/** `exclude` keys in neither `expected` nor the rendered set — a rotted opt-out. */
|
|
88
|
+
staleExclusions: string[];
|
|
89
|
+
};
|
|
90
|
+
export declare function crawlCoverageGaps(discoveredKeys: Iterable<string>, expected: Iterable<string>, exclude?: Record<string, string>): CrawlCoverageGaps;
|
|
91
|
+
/**
|
|
92
|
+
* Reconcile the crawled link set against `expected` (via {@link crawlCoverageGaps}) and
|
|
93
|
+
* render the failure message, or `null` when the nav reconciles. `from` names the crawl
|
|
94
|
+
* root in the message. Kept pure and out of the capture test so the wording is
|
|
95
|
+
* unit-testable and {@link defineCrawlCapture} just throws what this returns.
|
|
96
|
+
*/
|
|
97
|
+
export declare function crawlCoverageError(from: string, discoveredKeys: Iterable<string>, expected: Iterable<string>, exclude?: Record<string, string>): string | null;
|
package/dist/crawl.js
CHANGED
|
@@ -97,3 +97,31 @@ export function selectCrawlLinks(hrefs, opts) {
|
|
|
97
97
|
}
|
|
98
98
|
return out;
|
|
99
99
|
}
|
|
100
|
+
export function crawlCoverageGaps(discoveredKeys, expected, exclude = {}) {
|
|
101
|
+
const discovered = new Set(discoveredKeys);
|
|
102
|
+
const expectedSet = new Set(expected);
|
|
103
|
+
const missing = [...expectedSet].filter((k) => !discovered.has(k) && !(k in exclude));
|
|
104
|
+
const unexpected = [...discovered].filter((k) => !expectedSet.has(k) && !(k in exclude));
|
|
105
|
+
const staleExclusions = Object.keys(exclude).filter((k) => !expectedSet.has(k) && !discovered.has(k));
|
|
106
|
+
return { missing, unexpected, staleExclusions };
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Reconcile the crawled link set against `expected` (via {@link crawlCoverageGaps}) and
|
|
110
|
+
* render the failure message, or `null` when the nav reconciles. `from` names the crawl
|
|
111
|
+
* root in the message. Kept pure and out of the capture test so the wording is
|
|
112
|
+
* unit-testable and {@link defineCrawlCapture} just throws what this returns.
|
|
113
|
+
*/
|
|
114
|
+
export function crawlCoverageError(from, discoveredKeys, expected, exclude = {}) {
|
|
115
|
+
const { missing, unexpected, staleExclusions } = crawlCoverageGaps(discoveredKeys, expected, exclude);
|
|
116
|
+
const problems = [];
|
|
117
|
+
if (missing.length)
|
|
118
|
+
problems.push(`nav regression — expected route(s) no longer linked from ${from}: ${missing.join(', ')}. ` +
|
|
119
|
+
`Restore the link, or move the key to \`exclude\` with a reason.`);
|
|
120
|
+
if (unexpected.length)
|
|
121
|
+
problems.push(`new route(s) with no owner — link(s) rendered at ${from} but absent from \`expected\`: ` +
|
|
122
|
+
`${unexpected.join(', ')}. Add each to \`expected\`, or to \`exclude\` with a reason.`);
|
|
123
|
+
if (staleExclusions.length)
|
|
124
|
+
problems.push(`stale \`exclude\` — key(s) in neither \`expected\` nor the rendered nav ` +
|
|
125
|
+
`(renamed or removed?): ${staleExclusions.join(', ')}.`);
|
|
126
|
+
return problems.length ? `styleproof crawl coverage gap:\n${problems.join('\n')}` : null;
|
|
127
|
+
}
|
package/dist/diff.d.ts
CHANGED
|
@@ -9,6 +9,32 @@ export type PropChange = {
|
|
|
9
9
|
before: string;
|
|
10
10
|
after: string;
|
|
11
11
|
};
|
|
12
|
+
/**
|
|
13
|
+
* The before dir carries a bundle MANIFEST but ZERO captures while the after dir
|
|
14
|
+
* held some — a restore or capture that claims success yet delivered no maps (a
|
|
15
|
+
* corrupt bundle, a wrong --base-dir pointed at a manifest-only dir). Without
|
|
16
|
+
* this guard every after surface diffs as `missing: 'before'` (exit 3, "only new
|
|
17
|
+
* surfaces") and a whole app of regressions becomes one approvable "🆕 all new"
|
|
18
|
+
* report. The CLIs map this to exit 2 — a hard error, never the rubber-stampable
|
|
19
|
+
* exit 3. A truly BARE base dir (no manifest, no maps) is different: it means
|
|
20
|
+
* "never captured — no baseline exists yet", the first-adoption flow where the
|
|
21
|
+
* base commit predates the capture spec, and it keeps the exit-3 review path.
|
|
22
|
+
* (Both dirs empty stays the plain "no captures found" throw.)
|
|
23
|
+
*/
|
|
24
|
+
export declare class MissingBaseMapError extends Error {
|
|
25
|
+
constructor();
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The mirror case: the AFTER (head) dir held ZERO captures while the before dir
|
|
29
|
+
* held some — a head capture or restore that produced nothing. Without this
|
|
30
|
+
* guard every base surface marks `missing: 'after'`, the CLI's new-surface count
|
|
31
|
+
* (which tallies BOTH directions) exits 3, and a head that rendered nothing
|
|
32
|
+
* becomes an approvable "all new surfaces" report — and, once approved, the
|
|
33
|
+
* next base. Same exit-2 path via the CLIs' existing catch.
|
|
34
|
+
*/
|
|
35
|
+
export declare class MissingHeadMapError extends Error {
|
|
36
|
+
constructor();
|
|
37
|
+
}
|
|
12
38
|
export type Finding = {
|
|
13
39
|
kind: 'dom';
|
|
14
40
|
path: string;
|
package/dist/diff.js
CHANGED
|
@@ -1,8 +1,42 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { loadStyleMap, isUnder } from './capture.js';
|
|
4
|
-
import { isMapFile } from './map-store.js';
|
|
4
|
+
import { isMapFile, MAP_MANIFEST } from './map-store.js';
|
|
5
5
|
import { styleValuesEqual } from './canonicalize.js';
|
|
6
|
+
/**
|
|
7
|
+
* The before dir carries a bundle MANIFEST but ZERO captures while the after dir
|
|
8
|
+
* held some — a restore or capture that claims success yet delivered no maps (a
|
|
9
|
+
* corrupt bundle, a wrong --base-dir pointed at a manifest-only dir). Without
|
|
10
|
+
* this guard every after surface diffs as `missing: 'before'` (exit 3, "only new
|
|
11
|
+
* surfaces") and a whole app of regressions becomes one approvable "🆕 all new"
|
|
12
|
+
* report. The CLIs map this to exit 2 — a hard error, never the rubber-stampable
|
|
13
|
+
* exit 3. A truly BARE base dir (no manifest, no maps) is different: it means
|
|
14
|
+
* "never captured — no baseline exists yet", the first-adoption flow where the
|
|
15
|
+
* base commit predates the capture spec, and it keeps the exit-3 review path.
|
|
16
|
+
* (Both dirs empty stays the plain "no captures found" throw.)
|
|
17
|
+
*/
|
|
18
|
+
export class MissingBaseMapError extends Error {
|
|
19
|
+
constructor() {
|
|
20
|
+
super('base map missing: restore it from the map store or recapture both sides — refusing to treat every surface as new. ' +
|
|
21
|
+
'Next: run styleproof-map --restore --sha <base>, or let CI recapture both sides.');
|
|
22
|
+
this.name = 'MissingBaseMapError';
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The mirror case: the AFTER (head) dir held ZERO captures while the before dir
|
|
27
|
+
* held some — a head capture or restore that produced nothing. Without this
|
|
28
|
+
* guard every base surface marks `missing: 'after'`, the CLI's new-surface count
|
|
29
|
+
* (which tallies BOTH directions) exits 3, and a head that rendered nothing
|
|
30
|
+
* becomes an approvable "all new surfaces" report — and, once approved, the
|
|
31
|
+
* next base. Same exit-2 path via the CLIs' existing catch.
|
|
32
|
+
*/
|
|
33
|
+
export class MissingHeadMapError extends Error {
|
|
34
|
+
constructor() {
|
|
35
|
+
super('head map missing: the head capture produced zero surfaces — recapture the head side; refusing to treat every surface as removed/new. ' +
|
|
36
|
+
'Next: re-run styleproof-map on the head commit, or let CI recapture both sides.');
|
|
37
|
+
this.name = 'MissingHeadMapError';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
6
40
|
function diffProps(propsA, propsB, fallbackA, fallbackB, unsetA, unsetB) {
|
|
7
41
|
const changed = [];
|
|
8
42
|
for (const prop of new Set([...Object.keys(propsA), ...Object.keys(propsB)])) {
|
|
@@ -189,6 +223,25 @@ export function diffStyleMapDirs(dirA, dirB) {
|
|
|
189
223
|
const names = [...new Set([...Object.keys(indexA), ...Object.keys(indexB)])].sort();
|
|
190
224
|
if (names.length === 0)
|
|
191
225
|
throw new Error(`no .json(.gz) captures found in ${dirA} or ${dirB}`);
|
|
226
|
+
// A whole side with zero captures is a missing MAP, not a set of genuinely
|
|
227
|
+
// new/removed surfaces — either way every surface would carry a `missing`
|
|
228
|
+
// marker and the run would read as "all new" (exit 3, approvable). Refuse
|
|
229
|
+
// each direction loudly with its own named cause — with one exception:
|
|
230
|
+
//
|
|
231
|
+
// Base side: only when the dir carries a bundle manifest. Manifest + zero maps
|
|
232
|
+
// means a restore/capture that claims success yet delivered nothing (a corrupt
|
|
233
|
+
// bundle) — breakage. A BARE dir (no manifest either) means no baseline was
|
|
234
|
+
// ever captured — the first-adoption flow, where the recapture fallback checks
|
|
235
|
+
// out a base commit that predates the capture spec. That legitimately yields
|
|
236
|
+
// zero surfaces and must keep the exit-3 "new surfaces, review before
|
|
237
|
+
// baselining" onboarding path, so it falls through.
|
|
238
|
+
if (Object.keys(indexA).length === 0 && fs.existsSync(path.join(dirA, MAP_MANIFEST)))
|
|
239
|
+
throw new MissingBaseMapError();
|
|
240
|
+
// Head side: UNCONDITIONAL (bare or manifest-present). The onboarding
|
|
241
|
+
// asymmetry only exists on the base side — the head is the commit under test,
|
|
242
|
+
// so a head that produced zero captures is always breakage, never a review flow.
|
|
243
|
+
if (Object.keys(indexB).length === 0)
|
|
244
|
+
throw new MissingHeadMapError();
|
|
192
245
|
const surfaces = [];
|
|
193
246
|
const counts = { dom: 0, style: 0, state: 0 };
|
|
194
247
|
let volatile = 0;
|
package/dist/index.d.ts
CHANGED
|
@@ -15,11 +15,13 @@ export { discoverNextRoutes } from './routes.js';
|
|
|
15
15
|
export type { DiscoveredRoute } from './routes.js';
|
|
16
16
|
export { discoverComponentFiles, componentCatalogSurfaces } from './components.js';
|
|
17
17
|
export type { DiscoveredComponent, DiscoverComponentFilesOptions, ComponentCatalogSurfaceOptions, } from './components.js';
|
|
18
|
-
export { selectCrawlLinks, defaultLinkKey } from './crawl.js';
|
|
19
|
-
export type { CrawlLink, LinkMatch, SelectLinksOptions } from './crawl.js';
|
|
18
|
+
export { selectCrawlLinks, defaultLinkKey, crawlCoverageGaps, crawlCoverageError } from './crawl.js';
|
|
19
|
+
export type { CrawlLink, LinkMatch, SelectLinksOptions, CrawlCoverageGaps } from './crawl.js';
|
|
20
20
|
export { harvestStyleVariants } from './variant-crawler.js';
|
|
21
21
|
export type { HarvestAction, HarvestedLiveState, HarvestedRoute, HarvestedVariant, HarvestRoute, HarvestSkip, VariantHarvest, VariantHarvestOptions, } from './variant-crawler.js';
|
|
22
22
|
export { diffStyleMaps, diffStyleMapDirs, diffContentMaps, diffContentDirs, findingLabel } from './diff.js';
|
|
23
23
|
export type { Finding, PropChange, SurfaceDiff, DiffCounts, ContentChange } from './diff.js';
|
|
24
24
|
export { generateStyleMapReport, summarizeProps, prettyLabel } from './report.js';
|
|
25
25
|
export type { ReportOptions, ReportResult } from './report.js';
|
|
26
|
+
export { affectedSurfaces, classifyStyleChange } from './affected-surfaces.js';
|
|
27
|
+
export type { ModuleEdge, AffectedSurfacesInput, AffectedSurfaces } from './affected-surfaces.js';
|
package/dist/index.js
CHANGED
|
@@ -8,7 +8,8 @@ export { coverageGaps } from './coverage.js';
|
|
|
8
8
|
export { detectViewportWidths, mediaTextWidthBoundaries, widthsFromBoundaries } from './breakpoints.js';
|
|
9
9
|
export { discoverNextRoutes } from './routes.js';
|
|
10
10
|
export { discoverComponentFiles, componentCatalogSurfaces } from './components.js';
|
|
11
|
-
export { selectCrawlLinks, defaultLinkKey } from './crawl.js';
|
|
11
|
+
export { selectCrawlLinks, defaultLinkKey, crawlCoverageGaps, crawlCoverageError } from './crawl.js';
|
|
12
12
|
export { harvestStyleVariants } from './variant-crawler.js';
|
|
13
13
|
export { diffStyleMaps, diffStyleMapDirs, diffContentMaps, diffContentDirs, findingLabel } from './diff.js';
|
|
14
14
|
export { generateStyleMapReport, summarizeProps, prettyLabel } from './report.js';
|
|
15
|
+
export { affectedSurfaces, classifyStyleChange } from './affected-surfaces.js';
|
package/dist/map-store.d.ts
CHANGED
|
@@ -3,6 +3,10 @@ export declare const DEFAULT_MAP_LABEL = "current";
|
|
|
3
3
|
export declare const DEFAULT_MAP_STORE_BRANCH = "styleproof-maps";
|
|
4
4
|
export declare const DEFAULT_REMOTE = "origin";
|
|
5
5
|
export declare const MAP_MANIFEST = "styleproof-manifest.json";
|
|
6
|
+
/** Sidecar written during a capture run (where a browser handle is in scope) recording
|
|
7
|
+
* the real browser build (`browser().version()`). `writeMapManifest` runs after Playwright
|
|
8
|
+
* has exited — no browser — so it reads the build back from here. Not a surface map. */
|
|
9
|
+
export declare const BROWSER_BUILD_SIDECAR = "styleproof-browser.json";
|
|
6
10
|
/** Bundle files that sit alongside the maps but are NOT surfaces (manifest, coverage
|
|
7
11
|
* ledger, and any future sidecar). Every place that enumerates surface maps must skip
|
|
8
12
|
* these, or a sidecar reads as a phantom "new surface". */
|
|
@@ -21,6 +25,10 @@ export interface MapManifest {
|
|
|
21
25
|
lockfile?: string;
|
|
22
26
|
lockfileHash?: string;
|
|
23
27
|
playwrightVersion?: string;
|
|
28
|
+
/** Real browser build (`browser().version()`), recorded at capture time. The npm
|
|
29
|
+
* `@playwright/test` version can hold constant while this changes (re-download, a
|
|
30
|
+
* different browser store, a CI image bump), so this is what actually gates a compare. */
|
|
31
|
+
browserVersion?: string;
|
|
24
32
|
platform: string;
|
|
25
33
|
arch: string;
|
|
26
34
|
nodeMajor: string;
|
|
@@ -39,6 +47,9 @@ export interface CachedCaptureDirs {
|
|
|
39
47
|
compatibilityKey: string;
|
|
40
48
|
tmpRoot: string;
|
|
41
49
|
}
|
|
50
|
+
/** Record the real browser build into the capture dir. Called from a capture run, where a
|
|
51
|
+
* Playwright browser handle is in scope. Best-effort: a missing version is simply not written. */
|
|
52
|
+
export declare function writeBrowserBuildSidecar(dir: string, browserVersion: string | undefined): void;
|
|
42
53
|
export declare function expectedCompatibilityKey(options?: {
|
|
43
54
|
cwd?: string;
|
|
44
55
|
spec?: string;
|
package/dist/map-store.js
CHANGED
|
@@ -11,11 +11,19 @@ export const DEFAULT_MAP_LABEL = 'current';
|
|
|
11
11
|
export const DEFAULT_MAP_STORE_BRANCH = 'styleproof-maps';
|
|
12
12
|
export const DEFAULT_REMOTE = 'origin';
|
|
13
13
|
export const MAP_MANIFEST = 'styleproof-manifest.json';
|
|
14
|
+
/** Sidecar written during a capture run (where a browser handle is in scope) recording
|
|
15
|
+
* the real browser build (`browser().version()`). `writeMapManifest` runs after Playwright
|
|
16
|
+
* has exited — no browser — so it reads the build back from here. Not a surface map. */
|
|
17
|
+
export const BROWSER_BUILD_SIDECAR = 'styleproof-browser.json';
|
|
14
18
|
const GENERATED_DIRTY_ALLOWLIST = new Set(['next-env.d.ts']);
|
|
15
19
|
/** Bundle files that sit alongside the maps but are NOT surfaces (manifest, coverage
|
|
16
20
|
* ledger, and any future sidecar). Every place that enumerates surface maps must skip
|
|
17
21
|
* these, or a sidecar reads as a phantom "new surface". */
|
|
18
|
-
export const RESERVED_BUNDLE_FILES = new Set([
|
|
22
|
+
export const RESERVED_BUNDLE_FILES = new Set([
|
|
23
|
+
MAP_MANIFEST,
|
|
24
|
+
COVERAGE_LEDGER,
|
|
25
|
+
BROWSER_BUILD_SIDECAR,
|
|
26
|
+
]);
|
|
19
27
|
/** True for a captured surface map (`<key>@<width>.json[.gz]`), false for metadata. */
|
|
20
28
|
export function isMapFile(name) {
|
|
21
29
|
return !RESERVED_BUNDLE_FILES.has(name) && /\.json(\.gz)?$/.test(name);
|
|
@@ -71,6 +79,23 @@ function detectLockfile(cwd) {
|
|
|
71
79
|
}
|
|
72
80
|
return {};
|
|
73
81
|
}
|
|
82
|
+
/** Record the real browser build into the capture dir. Called from a capture run, where a
|
|
83
|
+
* Playwright browser handle is in scope. Best-effort: a missing version is simply not written. */
|
|
84
|
+
export function writeBrowserBuildSidecar(dir, browserVersion) {
|
|
85
|
+
if (!browserVersion)
|
|
86
|
+
return;
|
|
87
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
88
|
+
fs.writeFileSync(path.join(dir, BROWSER_BUILD_SIDECAR), JSON.stringify({ browserVersion }, null, 2));
|
|
89
|
+
}
|
|
90
|
+
function readBrowserBuildSidecar(dir) {
|
|
91
|
+
try {
|
|
92
|
+
const parsed = JSON.parse(fs.readFileSync(path.join(dir, BROWSER_BUILD_SIDECAR), 'utf8'));
|
|
93
|
+
return parsed.browserVersion;
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
74
99
|
function hasHar(dir) {
|
|
75
100
|
if (!fs.existsSync(dir))
|
|
76
101
|
return false;
|
|
@@ -137,6 +162,7 @@ export function remoteExists(remote = DEFAULT_REMOTE, cwd = process.cwd()) {
|
|
|
137
162
|
export function writeMapManifest(options) {
|
|
138
163
|
const cwd = options.cwd ?? process.cwd();
|
|
139
164
|
const input = compatibilityInput({ cwd, spec: options.spec, baseUrl: options.env?.BASE_URL ?? process.env.BASE_URL });
|
|
165
|
+
const browserVersion = readBrowserBuildSidecar(options.dir);
|
|
140
166
|
const manifest = {
|
|
141
167
|
version: 1,
|
|
142
168
|
packageVersion: input.packageVersion,
|
|
@@ -147,6 +173,7 @@ export function writeMapManifest(options) {
|
|
|
147
173
|
...(input.lockfile ? { lockfile: input.lockfile } : {}),
|
|
148
174
|
...(input.lockfileHash ? { lockfileHash: input.lockfileHash } : {}),
|
|
149
175
|
...(input.playwrightVersion ? { playwrightVersion: input.playwrightVersion } : {}),
|
|
176
|
+
...(browserVersion ? { browserVersion } : {}),
|
|
150
177
|
platform: input.platform,
|
|
151
178
|
arch: input.arch,
|
|
152
179
|
nodeMajor: input.nodeMajor,
|
|
@@ -186,12 +213,20 @@ export function assertCompatibleMapDirs(beforeDir, afterDir) {
|
|
|
186
213
|
playwrightVersion: after.playwrightVersion ?? '',
|
|
187
214
|
baseUrl: after.baseUrl ?? '',
|
|
188
215
|
};
|
|
216
|
+
// Browser build is the actual renderer, but it's optional: only compare when BOTH sides
|
|
217
|
+
// carry it, so bundles cached before this field existed stay comparable to each other.
|
|
218
|
+
// A field on one side only can't be a proven mismatch.
|
|
219
|
+
if (before.browserVersion && after.browserVersion) {
|
|
220
|
+
beforeRuntime.browserVersion = before.browserVersion;
|
|
221
|
+
afterRuntime.browserVersion = after.browserVersion;
|
|
222
|
+
}
|
|
189
223
|
if (JSON.stringify(beforeRuntime) === JSON.stringify(afterRuntime))
|
|
190
224
|
return;
|
|
225
|
+
const build = (m) => (m.browserVersion ? `, browser ${m.browserVersion}` : '');
|
|
191
226
|
throw new MapStoreError([
|
|
192
227
|
'maps were captured in different runtime environments',
|
|
193
|
-
`before ${before.sha.slice(0, 12)}: ${before.compatibilityKey} (${before.platform}/${before.arch}, Playwright ${before.playwrightVersion ?? 'unknown'})`,
|
|
194
|
-
`after ${after.sha.slice(0, 12)}: ${after.compatibilityKey} (${after.platform}/${after.arch}, Playwright ${after.playwrightVersion ?? 'unknown'})`,
|
|
228
|
+
`before ${before.sha.slice(0, 12)}: ${before.compatibilityKey} (${before.platform}/${before.arch}, Playwright ${before.playwrightVersion ?? 'unknown'}${build(before)})`,
|
|
229
|
+
`after ${after.sha.slice(0, 12)}: ${after.compatibilityKey} (${after.platform}/${after.arch}, Playwright ${after.playwrightVersion ?? 'unknown'}${build(after)})`,
|
|
195
230
|
'Next: rebuild one side with styleproof-map in the same environment, or let CI recapture both maps.',
|
|
196
231
|
].join('\n'));
|
|
197
232
|
}
|
package/dist/report.d.ts
CHANGED
|
@@ -56,6 +56,15 @@ export type ReportOptions = {
|
|
|
56
56
|
* cause) for the reviewer's eye.
|
|
57
57
|
*/
|
|
58
58
|
includeContent?: boolean;
|
|
59
|
+
/**
|
|
60
|
+
* Byte ceiling for report.md so GitHub can always render it (its markdown viewer
|
|
61
|
+
* refuses to render files past ~512 KB). Once the accumulated report would exceed
|
|
62
|
+
* this, the remaining changed surfaces are listed as one-liners (name · change
|
|
63
|
+
* count · crop link) instead of full property tables — the exhaustive per-row
|
|
64
|
+
* detail is always kept in report.json and every crop in crops/, so nothing is
|
|
65
|
+
* lost, just relocated. Default 400_000 (~0.4 MB). Set to Infinity to never cap.
|
|
66
|
+
*/
|
|
67
|
+
maxReportBytes?: number;
|
|
59
68
|
};
|
|
60
69
|
export type ReportResult = {
|
|
61
70
|
/** Surfaces carrying a reviewable change (excludes new, one-sided surfaces). */
|