styleproof 3.18.0 → 3.20.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 +191 -0
- package/README.md +62 -6
- package/bin/styleproof-capture.mjs +4 -1
- package/bin/styleproof-diff.mjs +41 -32
- package/bin/styleproof-init.mjs +49 -20
- 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/cli-errors.js +7 -2
- 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 +67 -7
- package/dist/danger.d.ts +13 -0
- package/dist/danger.js +13 -0
- package/dist/diff.js +38 -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 +159 -35
- package/dist/variant-crawler.js +16 -4
- package/package.json +2 -2
|
@@ -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/cli-errors.js
CHANGED
|
@@ -14,9 +14,14 @@ export function cliErrorMessage(error) {
|
|
|
14
14
|
*/
|
|
15
15
|
export function cachedMapsUnavailableMessage(command, purpose, error) {
|
|
16
16
|
return [
|
|
17
|
-
`${command}: cached maps are not available for this ${purpose}`,
|
|
17
|
+
`${command}: cached maps are not available for this ${purpose} — nothing was compared`,
|
|
18
18
|
cliErrorMessage(error),
|
|
19
|
-
|
|
19
|
+
// Name the two ways forward explicitly so a newcomer never reads "nothing compared"
|
|
20
|
+
// as "certified clean": the cached-map path only works where the base is restorable
|
|
21
|
+
// (CI, or a repo with the map-store remote), and the two-directory form always works
|
|
22
|
+
// off already-captured maps with no git remote at all.
|
|
23
|
+
`Next: run this in CI (or a repo with the 'origin' remote) where the base map is restorable, ` +
|
|
24
|
+
`or capture both sides and compare them directly: ${command} <beforeDir> <afterDir>.`,
|
|
20
25
|
].join('\n');
|
|
21
26
|
}
|
|
22
27
|
export function unknownFlagMessage(command, flag) {
|
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
|
/**
|
package/dist/coverage.js
CHANGED
|
@@ -34,6 +34,60 @@ export function coverageGaps(capturedKeys, expected, exclude = {}) {
|
|
|
34
34
|
const staleExclusions = Object.keys(exclude).filter((k) => !expectedSet.has(k));
|
|
35
35
|
return { uncovered, staleExclusions };
|
|
36
36
|
}
|
|
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 function coverageKeys(captured) {
|
|
50
|
+
const keys = new Set();
|
|
51
|
+
for (const c of captured) {
|
|
52
|
+
keys.add(c.key);
|
|
53
|
+
if (c.metadata?.surfaceKey)
|
|
54
|
+
keys.add(c.metadata.surfaceKey);
|
|
55
|
+
}
|
|
56
|
+
return [...keys];
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Rewrite a declared `expected` universe (base keys) into the keys that are actually
|
|
60
|
+
* captured to disk, so the GATE — which reads expanded map filenames (`home-loading`)
|
|
61
|
+
* and can't see each capture's `surfaceKey` metadata — can compare literally.
|
|
62
|
+
*
|
|
63
|
+
* A declared key `K` is replaced by its captured liveState expansions when `K` is NOT
|
|
64
|
+
* itself a captured key but expansions carrying `surfaceKey === K` exist. A directly
|
|
65
|
+
* captured `K` is kept; a genuinely uncaptured `K` is kept verbatim so the gate still
|
|
66
|
+
* flags it. This is the write-time half of {@link coverageKeys}: the ledger travels
|
|
67
|
+
* pre-translated, so `auditCoverage` needs no metadata at gate time.
|
|
68
|
+
*/
|
|
69
|
+
export function translateExpected(expected, captured) {
|
|
70
|
+
const capturedKeys = new Set();
|
|
71
|
+
const expansionsByBase = new Map();
|
|
72
|
+
for (const c of captured) {
|
|
73
|
+
capturedKeys.add(c.key);
|
|
74
|
+
const base = c.metadata?.surfaceKey;
|
|
75
|
+
if (base && base !== c.key) {
|
|
76
|
+
const list = expansionsByBase.get(base) ?? [];
|
|
77
|
+
list.push(c.key);
|
|
78
|
+
expansionsByBase.set(base, list);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const out = new Set();
|
|
82
|
+
for (const k of expected) {
|
|
83
|
+
if (capturedKeys.has(k) || !expansionsByBase.has(k))
|
|
84
|
+
out.add(k);
|
|
85
|
+
else
|
|
86
|
+
for (const exp of expansionsByBase.get(k))
|
|
87
|
+
out.add(exp);
|
|
88
|
+
}
|
|
89
|
+
return [...out];
|
|
90
|
+
}
|
|
37
91
|
// ── coverage provenance (the gate-level completeness assertion) ──────────────────
|
|
38
92
|
// The guard above runs in the app's SUITE. That fails a build when the spec forgets a
|
|
39
93
|
// route, but the GATE (styleproof-diff, reading captured maps) never learns whether
|
package/dist/crawl-surfaces.js
CHANGED
|
@@ -3,6 +3,7 @@ import fs from 'node:fs';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { captureStyleMap, saveStyleMap, trackInflightRequests } from './capture.js';
|
|
5
5
|
import { detectViewportWidths } from './breakpoints.js';
|
|
6
|
+
import { DANGER_SOURCE } from './danger.js';
|
|
6
7
|
// Exhaustive by default — these ceilings are safety backstops, not budgets.
|
|
7
8
|
export const CRAWL_DEFAULTS = {
|
|
8
9
|
height: 900,
|
|
@@ -44,11 +45,14 @@ function deriveKey(steps, used) {
|
|
|
44
45
|
used.add(key);
|
|
45
46
|
return key;
|
|
46
47
|
}
|
|
47
|
-
/** Runs in the browser: every visible, enabled, non-navigating control worth trying.
|
|
48
|
+
/** Runs in the browser: every visible, enabled, non-navigating control worth trying.
|
|
49
|
+
* `dangerSource` is the shared destructive-label pattern (see {@link DANGER_SOURCE}),
|
|
50
|
+
* passed in because this function is serialized into the browser and can't close over
|
|
51
|
+
* a Node `RegExp`. */
|
|
48
52
|
/* c8 ignore start */ // fallow-ignore-next-line complexity
|
|
49
|
-
function collectClickable() {
|
|
53
|
+
function collectClickable(dangerSource) {
|
|
50
54
|
const SEMANTIC = 'button,summary,[role="button"],[role="tab"],[role="menuitem"],[role="combobox"],select,form';
|
|
51
|
-
const DANGER =
|
|
55
|
+
const DANGER = new RegExp(dangerSource, 'i');
|
|
52
56
|
const esc = (v) => CSS.escape(v);
|
|
53
57
|
const quote = (v) => JSON.stringify(v);
|
|
54
58
|
const visible = (el) => {
|
|
@@ -81,7 +85,12 @@ function collectClickable() {
|
|
|
81
85
|
}
|
|
82
86
|
return pathSelector(el);
|
|
83
87
|
};
|
|
84
|
-
const labelFor = (el) =>
|
|
88
|
+
const labelFor = (el) =>
|
|
89
|
+
// `title` is included so an icon-only control (no text, no aria-label) that
|
|
90
|
+
// announces itself via a native tooltip — `<button title="Delete">🗑</button>` —
|
|
91
|
+
// still yields a meaningful label. Without it such a button labels as "button"
|
|
92
|
+
// and slips past the destructive guard below, which mapping must never click.
|
|
93
|
+
(el.getAttribute('aria-label') || el.getAttribute('name') || el.textContent || el.getAttribute('title') || '')
|
|
85
94
|
.replace(/\s+/g, ' ')
|
|
86
95
|
.trim()
|
|
87
96
|
.slice(0, 80) || el.tagName.toLowerCase();
|
|
@@ -684,7 +693,7 @@ function sweepWorkList(entry, all, opts, st, freshOnly = false, excludeIds = new
|
|
|
684
693
|
* in place — reaching that surface via a forward click is reliable, so its deep
|
|
685
694
|
* descendants are captured on the first visit instead of via a later reset. */
|
|
686
695
|
async function sweepCandidatesHere(page, opts, entry, st, sink, freshOnly = false, excludeIds = new Set()) {
|
|
687
|
-
const all = await page.evaluate(collectClickable).catch(() => []);
|
|
696
|
+
const all = await page.evaluate(collectClickable, DANGER_SOURCE).catch(() => []);
|
|
688
697
|
const work = sweepWorkList(entry, all, opts, st, freshOnly, excludeIds);
|
|
689
698
|
// Controls present HERE — passed to each child's in-place descent as its
|
|
690
699
|
// exclude set, so the descent skips this surface's mode-switchers/chrome and
|
package/dist/crawl.d.ts
CHANGED
|
@@ -49,6 +49,12 @@ export type SelectLinksOptions = {
|
|
|
49
49
|
* multi-segment route (`/blog/post`) still keys as `blog-post`. Param names are
|
|
50
50
|
* dropped (values carry the meaning); pass `key` to {@link selectCrawlLinks} when a
|
|
51
51
|
* project needs a different scheme.
|
|
52
|
+
*
|
|
53
|
+
* Params are sorted by name before their values are joined, so the SAME logical
|
|
54
|
+
* route keys identically regardless of the order the nav happened to render its
|
|
55
|
+
* query string (`/?tab=a&x=b` and `/?x=b&tab=a` both → `a-b`). Without this the
|
|
56
|
+
* key flaps with render order and the coverage guard reports phantom
|
|
57
|
+
* nav-regressions / unowned routes for a route that never changed.
|
|
52
58
|
*/
|
|
53
59
|
export declare function defaultLinkKey(url: URL): string;
|
|
54
60
|
/**
|
|
@@ -56,8 +62,17 @@ export declare function defaultLinkKey(url: URL): string;
|
|
|
56
62
|
*
|
|
57
63
|
* Each href is classified by {@link toLink} (resolve against `base`, keep http(s)
|
|
58
64
|
* same-origin, drop a bare in-page fragment of the crawl root, apply `match`); the
|
|
59
|
-
* survivors are deduped by path+query
|
|
65
|
+
* survivors are deduped by path+query (trailing slash normalized — `/about` and
|
|
66
|
+
* `/about/` are one surface, not two). Order follows first appearance in `hrefs`, so
|
|
60
67
|
* the capture order is the nav's order — stable across runs.
|
|
68
|
+
*
|
|
69
|
+
* Keys are then disambiguated: two GENUINELY different surfaces whose derived keys
|
|
70
|
+
* collide (e.g. `/a?tab=x` and `/b?tab=x` both → `x` under {@link defaultLinkKey})
|
|
71
|
+
* would otherwise both write `<key>@<width>.json.gz` and the second would silently
|
|
72
|
+
* overwrite the first — a captured surface vanishing without a trace. Instead the
|
|
73
|
+
* second gets a `-2` suffix (mirroring the surface crawler's `deriveKey`), so both
|
|
74
|
+
* survive as distinct maps. Trailing-slash duplicates never reach here — they're
|
|
75
|
+
* already deduped to one surface above — so this only fires on real collisions.
|
|
61
76
|
*/
|
|
62
77
|
export declare function selectCrawlLinks(hrefs: Iterable<string | null | undefined>, opts: SelectLinksOptions): CrawlLink[];
|
|
63
78
|
/**
|
package/dist/crawl.js
CHANGED
|
@@ -23,10 +23,19 @@
|
|
|
23
23
|
* multi-segment route (`/blog/post`) still keys as `blog-post`. Param names are
|
|
24
24
|
* dropped (values carry the meaning); pass `key` to {@link selectCrawlLinks} when a
|
|
25
25
|
* project needs a different scheme.
|
|
26
|
+
*
|
|
27
|
+
* Params are sorted by name before their values are joined, so the SAME logical
|
|
28
|
+
* route keys identically regardless of the order the nav happened to render its
|
|
29
|
+
* query string (`/?tab=a&x=b` and `/?x=b&tab=a` both → `a-b`). Without this the
|
|
30
|
+
* key flaps with render order and the coverage guard reports phantom
|
|
31
|
+
* nav-regressions / unowned routes for a route that never changed.
|
|
26
32
|
*/
|
|
27
33
|
export function defaultLinkKey(url) {
|
|
28
34
|
const segs = url.pathname.split('/').filter(Boolean);
|
|
29
|
-
const values = [...url.searchParams]
|
|
35
|
+
const values = [...url.searchParams]
|
|
36
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
37
|
+
.map(([, v]) => v)
|
|
38
|
+
.filter(Boolean);
|
|
30
39
|
const slug = [...segs, ...values]
|
|
31
40
|
.join('-')
|
|
32
41
|
.replace(/[^a-zA-Z0-9]+/g, '-')
|
|
@@ -70,30 +79,81 @@ function toLink(href, base, keyFor, match) {
|
|
|
70
79
|
url.hash = ''; // navigate the surface, not a scroll anchor within it
|
|
71
80
|
return { key: keyFor(url), url: path };
|
|
72
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* Dedup identity for a navigable path+query. Two forms of the same route must share
|
|
84
|
+
* one identity, or a static multi-page site (whose nav links the `.html` files) gets
|
|
85
|
+
* captured twice as byte-near-identical maps, doubling the work and duplicating every
|
|
86
|
+
* finding in the diff:
|
|
87
|
+
*
|
|
88
|
+
* - A trailing slash isn't a distinct surface (`/about` and `/about/` render the same
|
|
89
|
+
* route), so it's stripped — but never from the root `/` itself, nor from the query.
|
|
90
|
+
* - A trailing `index.html` is the directory's index (`/index.html` IS `/`, and
|
|
91
|
+
* `/docs/index.html` IS `/docs/`), so it collapses to the directory path. Only the
|
|
92
|
+
* literal `index.html` filename normalizes — a real `about.html` is left untouched
|
|
93
|
+
* and stays a distinct surface from `about`.
|
|
94
|
+
*
|
|
95
|
+
* The navigable url the caller returns keeps its original form; only the SET
|
|
96
|
+
* membership test is normalized, so the first-seen href still wins.
|
|
97
|
+
*/
|
|
98
|
+
function dedupIdentity(pathAndSearch) {
|
|
99
|
+
const q = pathAndSearch.indexOf('?');
|
|
100
|
+
const path = q === -1 ? pathAndSearch : pathAndSearch.slice(0, q);
|
|
101
|
+
const search = q === -1 ? '' : pathAndSearch.slice(q);
|
|
102
|
+
// `/index.html` → `/`, `/docs/index.html` → `/docs/` (the preceding slash stays so
|
|
103
|
+
// the trailing-slash step below folds it into the same identity as `/docs` / `/docs/`).
|
|
104
|
+
const withoutIndex = path.replace(/(^|\/)index\.html$/, '$1');
|
|
105
|
+
const normPath = withoutIndex.length > 1 ? withoutIndex.replace(/\/+$/, '') || '/' : withoutIndex;
|
|
106
|
+
return normPath + search;
|
|
107
|
+
}
|
|
73
108
|
/**
|
|
74
109
|
* Turn a page's raw `<a href>` values into a deduped, keyed surface list.
|
|
75
110
|
*
|
|
76
111
|
* Each href is classified by {@link toLink} (resolve against `base`, keep http(s)
|
|
77
112
|
* same-origin, drop a bare in-page fragment of the crawl root, apply `match`); the
|
|
78
|
-
* survivors are deduped by path+query
|
|
113
|
+
* survivors are deduped by path+query (trailing slash normalized — `/about` and
|
|
114
|
+
* `/about/` are one surface, not two). Order follows first appearance in `hrefs`, so
|
|
79
115
|
* the capture order is the nav's order — stable across runs.
|
|
116
|
+
*
|
|
117
|
+
* Keys are then disambiguated: two GENUINELY different surfaces whose derived keys
|
|
118
|
+
* collide (e.g. `/a?tab=x` and `/b?tab=x` both → `x` under {@link defaultLinkKey})
|
|
119
|
+
* would otherwise both write `<key>@<width>.json.gz` and the second would silently
|
|
120
|
+
* overwrite the first — a captured surface vanishing without a trace. Instead the
|
|
121
|
+
* second gets a `-2` suffix (mirroring the surface crawler's `deriveKey`), so both
|
|
122
|
+
* survive as distinct maps. Trailing-slash duplicates never reach here — they're
|
|
123
|
+
* already deduped to one surface above — so this only fires on real collisions.
|
|
80
124
|
*/
|
|
81
125
|
export function selectCrawlLinks(hrefs, opts) {
|
|
82
126
|
const base = new URL(opts.base);
|
|
83
127
|
const keyFor = opts.key ?? defaultLinkKey;
|
|
84
128
|
const seen = new Set();
|
|
129
|
+
const usedKeys = new Set();
|
|
85
130
|
const out = [];
|
|
131
|
+
// Disambiguate a key against those already emitted, mirroring deriveKey: first
|
|
132
|
+
// wins bare, the next collider gets `-2`, `-3`, … — deterministic in nav order.
|
|
133
|
+
const uniqueKey = (key) => {
|
|
134
|
+
let k = key;
|
|
135
|
+
for (let i = 2; usedKeys.has(k); i++)
|
|
136
|
+
k = `${key}-${i}`;
|
|
137
|
+
usedKeys.add(k);
|
|
138
|
+
return k;
|
|
139
|
+
};
|
|
140
|
+
const push = (link) => {
|
|
141
|
+
out.push({ key: uniqueKey(link.key), url: link.url });
|
|
142
|
+
};
|
|
86
143
|
if (opts.includeSelf) {
|
|
87
144
|
const selfUrl = base.pathname + base.search;
|
|
88
|
-
seen.add(selfUrl);
|
|
89
|
-
|
|
145
|
+
seen.add(dedupIdentity(selfUrl));
|
|
146
|
+
push({ key: keyFor(base), url: selfUrl });
|
|
90
147
|
}
|
|
91
148
|
for (const href of hrefs) {
|
|
92
149
|
const link = href ? toLink(href, base, keyFor, opts.match) : null;
|
|
93
|
-
if (!link
|
|
150
|
+
if (!link)
|
|
151
|
+
continue;
|
|
152
|
+
const id = dedupIdentity(link.url);
|
|
153
|
+
if (seen.has(id))
|
|
94
154
|
continue;
|
|
95
|
-
seen.add(
|
|
96
|
-
|
|
155
|
+
seen.add(id);
|
|
156
|
+
push(link);
|
|
97
157
|
}
|
|
98
158
|
return out;
|
|
99
159
|
}
|
package/dist/danger.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ONE destructive-action guard, shared by every surface-discovery crawler
|
|
3
|
+
* (the exhaustive surface crawler in `crawl-surfaces.ts` and the one-step variant
|
|
4
|
+
* harvester in `variant-crawler.ts`). Mapping must never mutate: a control whose
|
|
5
|
+
* label matches this pattern is recorded but never clicked.
|
|
6
|
+
*
|
|
7
|
+
* Kept as a plain string (not a `RegExp`) because both crawlers build their
|
|
8
|
+
* candidate list inside `page.evaluate` — the classifier function is serialized
|
|
9
|
+
* into the browser, so it cannot close over a `RegExp` from Node. The source is
|
|
10
|
+
* passed in as an argument and recompiled in the browser; this module is the
|
|
11
|
+
* single source of truth for what "destructive" means.
|
|
12
|
+
*/
|
|
13
|
+
export declare const DANGER_SOURCE = "\\b(delete|remove|destroy|logout|log ?out|sign ?out|publish|deploy|pay|purchase|buy|checkout|archive|disconnect|revoke|reset|wipe|drop|rotate|provision|seal|regenerate|renew)\\b";
|
package/dist/danger.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ONE destructive-action guard, shared by every surface-discovery crawler
|
|
3
|
+
* (the exhaustive surface crawler in `crawl-surfaces.ts` and the one-step variant
|
|
4
|
+
* harvester in `variant-crawler.ts`). Mapping must never mutate: a control whose
|
|
5
|
+
* label matches this pattern is recorded but never clicked.
|
|
6
|
+
*
|
|
7
|
+
* Kept as a plain string (not a `RegExp`) because both crawlers build their
|
|
8
|
+
* candidate list inside `page.evaluate` — the classifier function is serialized
|
|
9
|
+
* into the browser, so it cannot close over a `RegExp` from Node. The source is
|
|
10
|
+
* passed in as an argument and recompiled in the browser; this module is the
|
|
11
|
+
* single source of truth for what "destructive" means.
|
|
12
|
+
*/
|
|
13
|
+
export const DANGER_SOURCE = '\\b(delete|remove|destroy|logout|log ?out|sign ?out|publish|deploy|pay|purchase|buy|checkout|archive|disconnect|revoke|reset|wipe|drop|rotate|provision|seal|regenerate|renew)\\b';
|