styleproof 3.17.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.
@@ -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`, and
75
- // genuinely global selectors (`:root`, `html`, `@font-face`, …) all escape it.
76
- return MODULE_ESCAPES.test(src) || GLOBAL_CSS.test(src) ? 'all' : 'scope';
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(input, files) {
186
+ function buildReverseGraph(graph, files, read) {
160
187
  const rev = new Map();
161
- for (const e of input.graph)
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) ? safeRead(input.readFile, f) : undefined;
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
- const changed = [...input.changedFiles];
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 (anyGlobalStyleChange(changed, input.readFile))
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(input, files);
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. 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]));
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
+ }
@@ -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 3). */
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;
@@ -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: 1000,
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/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
- await page.addStyleTag({ content: FREEZE_CSS });
731
- const base = await page.evaluate(capturePage, { ignore, motionOnly: false, captureText, captureComponent });
732
- dropVolatile(base.elements, volatile);
733
- const overlays = (await page.evaluate(detectOverlayCandidates, { ignore })).filter((overlay) => base.elements[overlay.path]);
734
- warnUntraversed(base.shadowHosts, base.sameOriginFrames);
735
- mergeMotion(base.elements, motion.elements);
736
- let states = {};
737
- let statesSkipped = false;
738
- if (captureStates) {
739
- const forced = await captureForcedStates(page, ignore, maxInteractive, volatile);
740
- states = forced.states;
741
- statesSkipped = forced.skipped;
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) {
@@ -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
@@ -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. Empty missing = full coverage. */
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[];
@@ -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 = /\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/i;
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) => (el.getAttribute('aria-label') || el.getAttribute('name') || el.textContent || '')
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();
@@ -249,12 +258,15 @@ function domShape() {
249
258
  /* c8 ignore stop */
250
259
  /** Runs in the browser: every class name the page's OWN stylesheets select on —
251
260
  * the design's defined vocabulary, read from the parsed CSSOM (inline and
252
- * same-origin sheets; unreadable cross-origin sheets are skipped). Coverage is
253
- * checked against this, so "fully covered" means every class the design styles
254
- * was seen rendered in at least one captured surface. */
261
+ * same-origin sheets). Coverage is checked against this, so "fully covered" means
262
+ * every class the design styles was seen rendered in at least one captured
263
+ * surface. A cross-origin sheet the browser can't parse is NOT silently skipped:
264
+ * its href is returned in `unreadable` so coverage names it as residue rather than
265
+ * certifying completeness while blind to it. */
255
266
  /* c8 ignore start */
256
267
  function collectDefinedClasses() {
257
268
  const out = new Set();
269
+ const unreadable = [];
258
270
  const scan = (rules) => {
259
271
  if (!rules)
260
272
  return;
@@ -271,13 +283,13 @@ function collectDefinedClasses() {
271
283
  };
272
284
  for (const sheet of document.styleSheets) {
273
285
  try {
274
- scan(sheet.cssRules);
286
+ scan(sheet.cssRules); // throws for a cross-origin sheet with no CORS
275
287
  }
276
288
  catch {
277
- /* cross-origin sheet not the design's own vocabulary */
289
+ unreadable.push(sheet.href ?? '<inline>');
278
290
  }
279
291
  }
280
- return [...out];
292
+ return { classes: [...out], unreadable };
281
293
  }
282
294
  /* c8 ignore stop */
283
295
  /** Structural fingerprint of the page's CURRENT state. Dedup key for surfaces. */
@@ -681,7 +693,7 @@ function sweepWorkList(entry, all, opts, st, freshOnly = false, excludeIds = new
681
693
  * in place — reaching that surface via a forward click is reliable, so its deep
682
694
  * descendants are captured on the first visit instead of via a later reset. */
683
695
  async function sweepCandidatesHere(page, opts, entry, st, sink, freshOnly = false, excludeIds = new Set()) {
684
- const all = await page.evaluate(collectClickable).catch(() => []);
696
+ const all = await page.evaluate(collectClickable, DANGER_SOURCE).catch(() => []);
685
697
  const work = sweepWorkList(entry, all, opts, st, freshOnly, excludeIds);
686
698
  // Controls present HERE — passed to each child's in-place descent as its
687
699
  // exclude set, so the descent skips this surface's mode-switchers/chrome and
@@ -842,16 +854,17 @@ async function discover(page, opts) {
842
854
  await gotoFresh(page, opts);
843
855
  // No widths given? Detect the page's real @media breakpoints (like the
844
856
  // one-shot path does) and sweep one width per band — automatically. Detection
845
- // reads every stylesheet; if one is cross-origin/unreadable it falls back to
846
- // the single default width rather than dying.
857
+ // reads every stylesheet and THROWS if one is cross-origin/unreadable: it never
858
+ // silently sweeps a single width, which would certify every other band
859
+ // unchanged without looking at it. Pin `--widths` for a cross-origin-CSS page.
847
860
  if (opts.widths.length === 0) {
848
- const widths = await detectViewportWidths(page).catch(() => [1280]);
861
+ const widths = await detectViewportWidths(page);
849
862
  opts = { ...opts, widths };
850
863
  if ((widths[0] ?? 1280) !== 1280)
851
864
  await gotoFresh(page, opts); // re-pin discovery width BEFORE the base fingerprint
852
865
  }
853
866
  page.off('request', onRequest);
854
- const defined = await page.evaluate(collectDefinedClasses).catch(() => []);
867
+ const { classes: defined, unreadable } = await page.evaluate(collectDefinedClasses);
855
868
  const fp = await fingerprint(page);
856
869
  const st = {
857
870
  seen: new Set([fp.sig]),
@@ -881,7 +894,7 @@ async function discover(page, opts) {
881
894
  skipped: counters.skipped,
882
895
  captured: st.captured,
883
896
  failed: st.failed,
884
- coverage: { defined: defined.length, rendered: defined.length - missing.length, missing },
897
+ coverage: { defined: defined.length, rendered: defined.length - missing.length, missing, unreadable },
885
898
  };
886
899
  }
887
900
  /**
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,7 +62,51 @@ 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. Order follows first appearance in `hrefs`, so
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[];
78
+ /**
79
+ * The reconciliation of a rendered nav (the crawl's discovered link keys) against a
80
+ * declared `expected` universe, both directions. Where the spec guard treats the
81
+ * hand-listed `surfaces` as what's captured, here the crawl's DISCOVERED links are —
82
+ * the nav is the route universe for a link-crawled SPA, so it is the source of truth.
83
+ *
84
+ * - `missing`: an `expected` key with no rendered link and no `exclude` entry — a
85
+ * nav-regression (a route the app promised is no longer linked).
86
+ * - `unexpected`: a rendered link with no `expected` entry and no `exclude` entry — a
87
+ * new route/view with no owner in the registry.
88
+ * - `staleExclusions`: an `exclude` key absent from BOTH `expected` and the rendered
89
+ * set — a rotted opt-out.
90
+ *
91
+ * Unlike {@link CoverageGaps} (which permits captured-not-expected so a spec can
92
+ * tighten its registry over time), the crawl asserts BOTH directions strictly: the
93
+ * rendered link set is complete by construction, so an unowned link is a real gap.
94
+ * Pure and browser-free so it's unit-testable; {@link import('./runner.js')} wraps it
95
+ * in the crawl capture test, where the link set is finally known.
96
+ */
97
+ export type CrawlCoverageGaps = {
98
+ /** Expected keys with no rendered link and no `exclude` — a nav regression. */
99
+ missing: string[];
100
+ /** Rendered link keys absent from `expected` and `exclude` — a route with no owner. */
101
+ unexpected: string[];
102
+ /** `exclude` keys in neither `expected` nor the rendered set — a rotted opt-out. */
103
+ staleExclusions: string[];
104
+ };
105
+ export declare function crawlCoverageGaps(discoveredKeys: Iterable<string>, expected: Iterable<string>, exclude?: Record<string, string>): CrawlCoverageGaps;
106
+ /**
107
+ * Reconcile the crawled link set against `expected` (via {@link crawlCoverageGaps}) and
108
+ * render the failure message, or `null` when the nav reconciles. `from` names the crawl
109
+ * root in the message. Kept pure and out of the capture test so the wording is
110
+ * unit-testable and {@link defineCrawlCapture} just throws what this returns.
111
+ */
112
+ export declare function crawlCoverageError(from: string, discoveredKeys: Iterable<string>, expected: Iterable<string>, exclude?: Record<string, string>): string | null;