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.
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].map(([, v]) => v).filter(Boolean);
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,97 @@ 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: a trailing slash on the path is not a
84
+ * distinct surface (`/about` and `/about/` render the same route), so it's stripped
85
+ * from the identity — but never from the root `/` itself, and never from the query.
86
+ * The navigable url the caller returns keeps its original form; only the SET
87
+ * membership test is normalized, so the first-seen href still wins.
88
+ */
89
+ function dedupIdentity(pathAndSearch) {
90
+ const q = pathAndSearch.indexOf('?');
91
+ const path = q === -1 ? pathAndSearch : pathAndSearch.slice(0, q);
92
+ const search = q === -1 ? '' : pathAndSearch.slice(q);
93
+ const normPath = path.length > 1 ? path.replace(/\/+$/, '') || '/' : path;
94
+ return normPath + search;
95
+ }
73
96
  /**
74
97
  * Turn a page's raw `<a href>` values into a deduped, keyed surface list.
75
98
  *
76
99
  * Each href is classified by {@link toLink} (resolve against `base`, keep http(s)
77
100
  * same-origin, drop a bare in-page fragment of the crawl root, apply `match`); the
78
- * survivors are deduped by path+query. Order follows first appearance in `hrefs`, so
101
+ * survivors are deduped by path+query (trailing slash normalized `/about` and
102
+ * `/about/` are one surface, not two). Order follows first appearance in `hrefs`, so
79
103
  * the capture order is the nav's order — stable across runs.
104
+ *
105
+ * Keys are then disambiguated: two GENUINELY different surfaces whose derived keys
106
+ * collide (e.g. `/a?tab=x` and `/b?tab=x` both → `x` under {@link defaultLinkKey})
107
+ * would otherwise both write `<key>@<width>.json.gz` and the second would silently
108
+ * overwrite the first — a captured surface vanishing without a trace. Instead the
109
+ * second gets a `-2` suffix (mirroring the surface crawler's `deriveKey`), so both
110
+ * survive as distinct maps. Trailing-slash duplicates never reach here — they're
111
+ * already deduped to one surface above — so this only fires on real collisions.
80
112
  */
81
113
  export function selectCrawlLinks(hrefs, opts) {
82
114
  const base = new URL(opts.base);
83
115
  const keyFor = opts.key ?? defaultLinkKey;
84
116
  const seen = new Set();
117
+ const usedKeys = new Set();
85
118
  const out = [];
119
+ // Disambiguate a key against those already emitted, mirroring deriveKey: first
120
+ // wins bare, the next collider gets `-2`, `-3`, … — deterministic in nav order.
121
+ const uniqueKey = (key) => {
122
+ let k = key;
123
+ for (let i = 2; usedKeys.has(k); i++)
124
+ k = `${key}-${i}`;
125
+ usedKeys.add(k);
126
+ return k;
127
+ };
128
+ const push = (link) => {
129
+ out.push({ key: uniqueKey(link.key), url: link.url });
130
+ };
86
131
  if (opts.includeSelf) {
87
132
  const selfUrl = base.pathname + base.search;
88
- seen.add(selfUrl);
89
- out.push({ key: keyFor(base), url: selfUrl });
133
+ seen.add(dedupIdentity(selfUrl));
134
+ push({ key: keyFor(base), url: selfUrl });
90
135
  }
91
136
  for (const href of hrefs) {
92
137
  const link = href ? toLink(href, base, keyFor, opts.match) : null;
93
- if (!link || seen.has(link.url))
138
+ if (!link)
94
139
  continue;
95
- seen.add(link.url);
96
- out.push(link);
140
+ const id = dedupIdentity(link.url);
141
+ if (seen.has(id))
142
+ continue;
143
+ seen.add(id);
144
+ push(link);
97
145
  }
98
146
  return out;
99
147
  }
148
+ export function crawlCoverageGaps(discoveredKeys, expected, exclude = {}) {
149
+ const discovered = new Set(discoveredKeys);
150
+ const expectedSet = new Set(expected);
151
+ const missing = [...expectedSet].filter((k) => !discovered.has(k) && !(k in exclude));
152
+ const unexpected = [...discovered].filter((k) => !expectedSet.has(k) && !(k in exclude));
153
+ const staleExclusions = Object.keys(exclude).filter((k) => !expectedSet.has(k) && !discovered.has(k));
154
+ return { missing, unexpected, staleExclusions };
155
+ }
156
+ /**
157
+ * Reconcile the crawled link set against `expected` (via {@link crawlCoverageGaps}) and
158
+ * render the failure message, or `null` when the nav reconciles. `from` names the crawl
159
+ * root in the message. Kept pure and out of the capture test so the wording is
160
+ * unit-testable and {@link defineCrawlCapture} just throws what this returns.
161
+ */
162
+ export function crawlCoverageError(from, discoveredKeys, expected, exclude = {}) {
163
+ const { missing, unexpected, staleExclusions } = crawlCoverageGaps(discoveredKeys, expected, exclude);
164
+ const problems = [];
165
+ if (missing.length)
166
+ problems.push(`nav regression — expected route(s) no longer linked from ${from}: ${missing.join(', ')}. ` +
167
+ `Restore the link, or move the key to \`exclude\` with a reason.`);
168
+ if (unexpected.length)
169
+ problems.push(`new route(s) with no owner — link(s) rendered at ${from} but absent from \`expected\`: ` +
170
+ `${unexpected.join(', ')}. Add each to \`expected\`, or to \`exclude\` with a reason.`);
171
+ if (staleExclusions.length)
172
+ problems.push(`stale \`exclude\` — key(s) in neither \`expected\` nor the rendered nav ` +
173
+ `(renamed or removed?): ${staleExclusions.join(', ')}.`);
174
+ return problems.length ? `styleproof crawl coverage gap:\n${problems.join('\n')}` : null;
175
+ }
@@ -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';
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)])) {
@@ -36,7 +70,9 @@ function dropLayoutEquivalentMarginProps(props, a, b) {
36
70
  }
37
71
  function pxParts(value) {
38
72
  const parts = value.trim().split(/\s+/);
39
- if (parts.length < 2 || parts.length > 3)
73
+ // 1–3 components: a single-value origin (`50px`) jitters the same way as the
74
+ // 2/3-component form and must be suppressed identically.
75
+ if (parts.length < 1 || parts.length > 3)
40
76
  return null;
41
77
  const values = parts.map((part) => {
42
78
  const match = /^(-?\d+(?:\.\d+)?)px$/.exec(part);
@@ -189,6 +225,25 @@ export function diffStyleMapDirs(dirA, dirB) {
189
225
  const names = [...new Set([...Object.keys(indexA), ...Object.keys(indexB)])].sort();
190
226
  if (names.length === 0)
191
227
  throw new Error(`no .json(.gz) captures found in ${dirA} or ${dirB}`);
228
+ // A whole side with zero captures is a missing MAP, not a set of genuinely
229
+ // new/removed surfaces — either way every surface would carry a `missing`
230
+ // marker and the run would read as "all new" (exit 3, approvable). Refuse
231
+ // each direction loudly with its own named cause — with one exception:
232
+ //
233
+ // Base side: only when the dir carries a bundle manifest. Manifest + zero maps
234
+ // means a restore/capture that claims success yet delivered nothing (a corrupt
235
+ // bundle) — breakage. A BARE dir (no manifest either) means no baseline was
236
+ // ever captured — the first-adoption flow, where the recapture fallback checks
237
+ // out a base commit that predates the capture spec. That legitimately yields
238
+ // zero surfaces and must keep the exit-3 "new surfaces, review before
239
+ // baselining" onboarding path, so it falls through.
240
+ if (Object.keys(indexA).length === 0 && fs.existsSync(path.join(dirA, MAP_MANIFEST)))
241
+ throw new MissingBaseMapError();
242
+ // Head side: UNCONDITIONAL (bare or manifest-present). The onboarding
243
+ // asymmetry only exists on the base side — the head is the commit under test,
244
+ // so a head that produced zero captures is always breakage, never a review flow.
245
+ if (Object.keys(indexB).length === 0)
246
+ throw new MissingHeadMapError();
192
247
  const surfaces = [];
193
248
  const counts = { dom: 0, style: 0, state: 0 };
194
249
  let volatile = 0;
package/dist/index.d.ts CHANGED
@@ -15,13 +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';
26
+ export { affectedSurfaces, classifyStyleChange, explainAffectedSurfaces } from './affected-surfaces.js';
27
27
  export type { ModuleEdge, AffectedSurfacesInput, AffectedSurfaces } from './affected-surfaces.js';
package/dist/index.js CHANGED
@@ -8,8 +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';
15
+ export { affectedSurfaces, classifyStyleChange, explainAffectedSurfaces } from './affected-surfaces.js';
package/dist/inventory.js CHANGED
@@ -50,7 +50,9 @@ export function collectNavAffordances() {
50
50
  };
51
51
  const nameOf = (el) => (el.getAttribute('aria-label') || el.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 80);
52
52
  const internalPath = (el) => {
53
- const raw = (el.getAttribute('href') || '').trim();
53
+ // SVG anchors carry the target in `xlink:href` (legacy) or `href`; fall back so
54
+ // an <svg><a> nav link resolves like an HTML one.
55
+ const raw = (el.getAttribute('href') || el.getAttribute('xlink:href') || '').trim();
54
56
  if (!raw || raw.startsWith('#'))
55
57
  return null;
56
58
  try {
@@ -67,14 +69,18 @@ export function collectNavAffordances() {
67
69
  // Erring broad is correct here: a stray non-nav button is harmless noise, but a
68
70
  // MISSED nav item defeats the guard. Prefer semantic markup (role=tablist) for
69
71
  // fully reliable harvesting; see docs/inventory-guard.md.
70
- const SEL = 'a[href], [role="tab"], [role="menuitem"], [role="menuitemradio"], [role="menuitemcheckbox"], nav button, [role="navigation"] button, [role="tablist"] button, [class*="navtab" i] button, [class*="nav-tab" i] button, [class*="subnav" i] button, [class*="subtab" i] button, [class*="tabs" i] button';
72
+ // `a[*|href]` (any-namespace href) not `a[href]`: an SVG anchor may carry only the
73
+ // XLink-namespaced `xlink:href`, which `a[href]` never selects — so the xlink:href
74
+ // fallback in internalPath would be dead without it.
75
+ const SEL = 'a[*|href], [role="tab"], [role="menuitem"], [role="menuitemradio"], [role="menuitemcheckbox"], nav button, [role="navigation"] button, [role="tablist"] button, [class*="navtab" i] button, [class*="nav-tab" i] button, [class*="subnav" i] button, [class*="subtab" i] button, [class*="tabs" i] button';
71
76
  return Array.from(document.querySelectorAll(SEL))
72
77
  .filter(visible)
73
78
  .map((el) => ({
74
79
  tag: el.tagName.toLowerCase(),
75
80
  role: (el.getAttribute('role') || '').toLowerCase(),
76
81
  name: nameOf(el),
77
- internalPath: el.tagName === 'A' ? internalPath(el) : null,
82
+ // SVG anchors report tagName `a` (lowercase), HTML reports `A` — match either.
83
+ internalPath: el.tagName.toLowerCase() === 'a' ? internalPath(el) : null,
78
84
  testId: el.getAttribute('data-testid'),
79
85
  domId: el.getAttribute('id'),
80
86
  controls: el.getAttribute('aria-controls'),
@@ -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,13 @@ 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. Write-or-CLEAR semantics: an undefined version
52
+ * REMOVES any existing sidecar rather than leaving it, so a reused capture dir (e.g. the
53
+ * default `.styleproof/maps/current`) can never carry a PRIOR run's build into this run's
54
+ * manifest — that would stamp a false browser-build fingerprint the compatibility guard
55
+ * then trusts. Best-effort: the delete is forced and ignores a missing file. */
56
+ export declare function writeBrowserBuildSidecar(dir: string, browserVersion: string | undefined): void;
42
57
  export declare function expectedCompatibilityKey(options?: {
43
58
  cwd?: string;
44
59
  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([MAP_MANIFEST, COVERAGE_LEDGER]);
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,30 @@ 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. Write-or-CLEAR semantics: an undefined version
84
+ * REMOVES any existing sidecar rather than leaving it, so a reused capture dir (e.g. the
85
+ * default `.styleproof/maps/current`) can never carry a PRIOR run's build into this run's
86
+ * manifest — that would stamp a false browser-build fingerprint the compatibility guard
87
+ * then trusts. Best-effort: the delete is forced and ignores a missing file. */
88
+ export function writeBrowserBuildSidecar(dir, browserVersion) {
89
+ const sidecar = path.join(dir, BROWSER_BUILD_SIDECAR);
90
+ if (!browserVersion) {
91
+ fs.rmSync(sidecar, { force: true });
92
+ return;
93
+ }
94
+ fs.mkdirSync(dir, { recursive: true });
95
+ fs.writeFileSync(sidecar, JSON.stringify({ browserVersion }, null, 2));
96
+ }
97
+ function readBrowserBuildSidecar(dir) {
98
+ try {
99
+ const parsed = JSON.parse(fs.readFileSync(path.join(dir, BROWSER_BUILD_SIDECAR), 'utf8'));
100
+ return parsed.browserVersion;
101
+ }
102
+ catch {
103
+ return undefined;
104
+ }
105
+ }
74
106
  function hasHar(dir) {
75
107
  if (!fs.existsSync(dir))
76
108
  return false;
@@ -137,6 +169,7 @@ export function remoteExists(remote = DEFAULT_REMOTE, cwd = process.cwd()) {
137
169
  export function writeMapManifest(options) {
138
170
  const cwd = options.cwd ?? process.cwd();
139
171
  const input = compatibilityInput({ cwd, spec: options.spec, baseUrl: options.env?.BASE_URL ?? process.env.BASE_URL });
172
+ const browserVersion = readBrowserBuildSidecar(options.dir);
140
173
  const manifest = {
141
174
  version: 1,
142
175
  packageVersion: input.packageVersion,
@@ -147,6 +180,7 @@ export function writeMapManifest(options) {
147
180
  ...(input.lockfile ? { lockfile: input.lockfile } : {}),
148
181
  ...(input.lockfileHash ? { lockfileHash: input.lockfileHash } : {}),
149
182
  ...(input.playwrightVersion ? { playwrightVersion: input.playwrightVersion } : {}),
183
+ ...(browserVersion ? { browserVersion } : {}),
150
184
  platform: input.platform,
151
185
  arch: input.arch,
152
186
  nodeMajor: input.nodeMajor,
@@ -186,12 +220,20 @@ export function assertCompatibleMapDirs(beforeDir, afterDir) {
186
220
  playwrightVersion: after.playwrightVersion ?? '',
187
221
  baseUrl: after.baseUrl ?? '',
188
222
  };
223
+ // Browser build is the actual renderer, but it's optional: only compare when BOTH sides
224
+ // carry it, so bundles cached before this field existed stay comparable to each other.
225
+ // A field on one side only can't be a proven mismatch.
226
+ if (before.browserVersion && after.browserVersion) {
227
+ beforeRuntime.browserVersion = before.browserVersion;
228
+ afterRuntime.browserVersion = after.browserVersion;
229
+ }
189
230
  if (JSON.stringify(beforeRuntime) === JSON.stringify(afterRuntime))
190
231
  return;
232
+ const build = (m) => (m.browserVersion ? `, browser ${m.browserVersion}` : '');
191
233
  throw new MapStoreError([
192
234
  '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'})`,
235
+ `before ${before.sha.slice(0, 12)}: ${before.compatibilityKey} (${before.platform}/${before.arch}, Playwright ${before.playwrightVersion ?? 'unknown'}${build(before)})`,
236
+ `after ${after.sha.slice(0, 12)}: ${after.compatibilityKey} (${after.platform}/${after.arch}, Playwright ${after.playwrightVersion ?? 'unknown'}${build(after)})`,
195
237
  'Next: rebuild one side with styleproof-map in the same environment, or let CI recapture both maps.',
196
238
  ].join('\n'));
197
239
  }
package/dist/report.js CHANGED
@@ -385,6 +385,14 @@ export function prettyLabel(p, cls) {
385
385
  const first = cls.split(/\s+/)[0] ?? '';
386
386
  return /^[a-z][a-z0-9-]*$/.test(first) ? `${tag}.${first}` : tag;
387
387
  }
388
+ // Surface keys originate from artifact filenames — attacker-controlled in the
389
+ // fork capture/report split, and they flow into the PRIVILEGED PR-comment summary
390
+ // (the Action slices report.md above the first `### `). Strip the Markdown/HTML
391
+ // control characters (`` ` ``, [ ] ( ), < >, |) that could inject a link, image,
392
+ // or table into that bot comment. Escaping at the render boundary — the keys stay
393
+ // legible; only the injection surface is removed. (Crop FILENAMES are separately
394
+ // restricted to [a-z0-9-]; this is the display-side equivalent.)
395
+ const safeKey = (s) => s.replace(/[`[\]()<>|]/g, '-');
388
396
  const surfaceBase = (s) => s.replace(/@\d+$/, '');
389
397
  const surfaceWidth = (s) => Number(s.match(/@(\d+)$/)?.[1] ?? 0);
390
398
  function pushSurfaceWidth(byBase, base, surface) {
@@ -396,7 +404,7 @@ function renderSurfaceGroups(byBase) {
396
404
  return [...byBase]
397
405
  .map(([base, ws]) => {
398
406
  const widths = ws.filter((w) => w > 0).sort((a, b) => b - a);
399
- return widths.length ? `${base} @ ${widths.join(', ')}` : base;
407
+ return widths.length ? `${safeKey(base)} @ ${widths.join(', ')}` : safeKey(base);
400
408
  })
401
409
  .join(' · ');
402
410
  }
@@ -502,9 +510,26 @@ function regionHeading(regionPaths, findings) {
502
510
  const label = anchors.length > 1 ? `\`${head}\` + ${anchors.length - 1} more` : `\`${head}\``;
503
511
  return `${label} · ${groupTitle(findings)}`;
504
512
  }
513
+ // CSS values are author/attacker-influenced (content:"…", url("…"), font-family
514
+ // strings), so at the render boundary they get their OWN escaper — distinct from
515
+ // safeKey, which strips control chars from surface keys. Values must stay READABLE
516
+ // (a mangled url(…) is useless), so we ESCAPE rather than strip:
517
+ // • `|` → `\|` — an unescaped pipe splits the table row (GitHub honours the
518
+ // backslash even inside a code span).
519
+ // • backticks — a bare backtick would close the code span and leak live
520
+ // Markdown; widen the fence to one more backtick than the
521
+ // value's longest run, padding a space when it touches an edge
522
+ // (GitHub's rule for a code span that starts/ends with a tick).
523
+ function codeValue(v) {
524
+ const escaped = v.replace(/\|/g, '\\|');
525
+ const longestRun = Math.max(0, ...(escaped.match(/`+/g) ?? []).map((r) => r.length));
526
+ const fence = '`'.repeat(longestRun + 1);
527
+ const pad = /^`|`$/.test(escaped) ? ' ' : '';
528
+ return `${fence}${pad}${escaped}${pad}${fence}`;
529
+ }
505
530
  // A "no value here" marker renders as an em dash; colours render as `#hex` so the
506
531
  // table cell shows GitHub's live swatch.
507
- const cell = (v) => (isNonValue(v) ? '—' : `\`${toHex(v)}\``);
532
+ const cell = (v) => (isNonValue(v) ? '—' : codeValue(toHex(v)));
508
533
  // Long values (gradients, data URIs) would swamp the table, but truncating each
509
534
  // side independently can show two IDENTICAL cells for a real diff: both
510
535
  // sides of a gradient rendered as the same rgba while the actual change — a
@@ -537,7 +562,7 @@ function cellPair(before, after) {
537
562
  if (isNonValue(before) || isNonValue(after))
538
563
  return [cell(before), cell(after)];
539
564
  const [b, a] = excerptPair(before, after);
540
- return [`\`${toHex(b)}\``, `\`${toHex(a)}\``];
565
+ return [codeValue(toHex(b)), codeValue(toHex(a))];
541
566
  }
542
567
  function beforeAfterTable(rows) {
543
568
  return [
@@ -545,14 +570,14 @@ function beforeAfterTable(rows) {
545
570
  '| --- | --- | --- |',
546
571
  ...rows.map((r) => {
547
572
  const [b, a] = cellPair(r.before, r.after);
548
- return `| \`${r.prop}\` | ${b} | ${a} |`;
573
+ return `| ${codeValue(r.prop)} | ${b} | ${a} |`;
549
574
  }),
550
575
  ];
551
576
  }
552
577
  // A brand-new element has no meaningful "before", so its resting style renders
553
578
  // value-only (the After column), mirroring the added-element interaction-states table.
554
579
  function valueTable(rows) {
555
- return ['| Property | Value |', '| --- | --- |', ...rows.map((r) => `| \`${r.prop}\` | ${cell(r.after)} |`)];
580
+ return ['| Property | Value |', '| --- | --- |', ...rows.map((r) => `| ${codeValue(r.prop)} | ${cell(r.after)} |`)];
556
581
  }
557
582
  /** `Button (variant=primary, size=sm)` — the React component + sanitized props
558
583
  * the element captured (advisory; present only with captureComponent). */
@@ -579,8 +604,8 @@ function statesSection(states, added) {
579
604
  for (const c of summarizeProps(st.props)) {
580
605
  const [b, a] = cellPair(c.before, c.after);
581
606
  rows.push(added
582
- ? `| \`:${st.state}\` | \`${c.prop}\` | ${cell(c.after)} |`
583
- : `| \`:${st.state}\` | \`${c.prop}\` | ${b} → ${a} |`);
607
+ ? `| ${codeValue(`:${st.state}`)} | ${codeValue(c.prop)} | ${cell(c.after)} |`
608
+ : `| ${codeValue(`:${st.state}`)} | ${codeValue(c.prop)} | ${b} → ${a} |`);
584
609
  }
585
610
  if (!rows.length)
586
611
  return [];
@@ -821,7 +846,7 @@ function renderContentSurface(ctx, surface, changes, seq) {
821
846
  const mapB = loadStyleMap(findCapture(ctx.afterDir, surface));
822
847
  const pngA = readPng(path.join(ctx.beforeDir, `${surface}.png`));
823
848
  const pngB = readPng(path.join(ctx.afterDir, `${surface}.png`));
824
- const md = ['', `### \`${surface}\` · ${changes.length} content change(s)`];
849
+ const md = ['', `### \`${safeKey(surface)}\` · ${changes.length} content change(s)`];
825
850
  for (const c of changes) {
826
851
  seq++;
827
852
  md.push('', `**\`${prettyLabel(c.path, c.cls)}\`**`, '', `- before: \`${clipText(c.before) || '(empty)'}\``, `- after: \`${clipText(c.after) || '(empty)'}\``, ...contentCropLines(ctx, surface, c, mapA, mapB, pngA, pngB, seq));
@@ -888,7 +913,7 @@ function coverageLine(cov) {
888
913
  if (cov.basis === 'complete')
889
914
  return `- **Coverage** — ✓ complete (all ${cov.registrySize} registered surface(s) captured)`;
890
915
  if (cov.basis === 'incomplete')
891
- return `- **Coverage** — ✗ INCOMPLETE (${cov.uncovered.length} registered surface(s) not captured: ${cov.uncovered.join(', ')})`;
916
+ return `- **Coverage** — ✗ INCOMPLETE (${cov.uncovered.length} registered surface(s) not captured: ${cov.uncovered.map(safeKey).join(', ')})`;
892
917
  return '- **Coverage** — ⚠ not asserted (no `expected` registry; certifies only the captured surfaces)';
893
918
  }
894
919
  function determinismLine(det) {
@@ -900,7 +925,7 @@ function determinismLine(det) {
900
925
  }
901
926
  function inventoryLine(inv) {
902
927
  if (inv.unexplained.length > 0) {
903
- const keys = inv.unexplained.map((i) => i.key);
928
+ const keys = inv.unexplained.map((i) => safeKey(i.key));
904
929
  return `- **Inventory** — ⚠ ${inv.unexplained.length} navigable affordance(s) removed, unacknowledged: ${keys.slice(0, 8).join(', ')}${keys.length > 8 ? ', …' : ''}`;
905
930
  }
906
931
  if (inv.delta.removed.length > 0)
@@ -1176,7 +1201,7 @@ function renderNewSurface(p, ctx, cropSeq) {
1176
1201
  const png = readPng(path.join(srcDir, `${p.sd.surface}.png`));
1177
1202
  const md = [
1178
1203
  '',
1179
- `### \`${p.sd.surface}\` · new surface ${NEW_SURFACE_MARKER}`,
1204
+ `### \`${safeKey(p.sd.surface)}\` · new surface ${NEW_SURFACE_MARKER}`,
1180
1205
  '',
1181
1206
  `_${formatSurfaceWithContext(p.sd.surface, map)}_`,
1182
1207
  ];
@@ -1214,7 +1239,7 @@ function cappedNoticeLines(budget) {
1214
1239
  * name (and how many surfaces share the identical change) · change count · a crop
1215
1240
  * link so the reviewer can still see it without opening report.json. */
1216
1241
  function compactChangeSummary(cg, json, img) {
1217
- const surface = cg.rep.sd.surface;
1242
+ const surface = safeKey(cg.rep.sd.surface);
1218
1243
  const more = cg.surfaces.length > 1 ? ` (+${cg.surfaces.length - 1} more)` : '';
1219
1244
  const regions = json.regions ?? [];
1220
1245
  const composite = regions[0]?.images?.composite;
@@ -1317,7 +1342,7 @@ export function generateStyleMapReport(opts) {
1317
1342
  const r = renderNewSurface(p, ctx, cropSeq);
1318
1343
  json.push(r.json);
1319
1344
  cropSeq = r.cropSeq;
1320
- emitDetail(r.md, `- \`${p.sd.surface}\` · new surface`);
1345
+ emitDetail(r.md, `- \`${safeKey(p.sd.surface)}\` · new surface`);
1321
1346
  }
1322
1347
  md.push(...contentSection.md);
1323
1348
  const reportMdPath = path.join(outDir, 'report.md');