styleproof 4.0.2 → 4.2.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 CHANGED
@@ -7,6 +7,62 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [4.2.0] - 2026-07-13
11
+
12
+ ### Changed
13
+
14
+ - **`styleproof-capture --crawl` now follows same-origin nav links.** Every
15
+ page the site links to is crawled like the entry page, keyed by its route
16
+ (`about`, `pricing`, …), with class coverage aggregated across the pages that
17
+ share stylesheets. Previously the CLI crawl drove controls but silently
18
+ dropped links, so a multi-page site reported "1/1 surfaces, coverage ✓" while
19
+ losing every other page. `--no-follow-links` restores the entry-page-only
20
+ sweep.
21
+
22
+ ### Fixed
23
+
24
+ - **A non-head checkout in CI is no longer stamped with the pull request's head
25
+ SHA.** `currentGitSha` now trusts `git rev-parse HEAD` and relabels only a
26
+ checkout of the synthetic `GITHUB_SHA` merge commit, so a base-branch capture
27
+ (the scaffolded cache-miss job) can never publish a base-tree map under the
28
+ head's store key — a store poisoning that made later restores diff
29
+ base-vs-base and report a false green. `pull_request_target` payloads are no
30
+ longer trusted for relabeling at all, and a malformed `STYLEPROOF_SHA`
31
+ override now errors instead of silently falling back.
32
+ - **Annotation move-suppression now requires provable displacement.** A
33
+ cross-path annotation match may suppress its magenta boxes only when the
34
+ container where the two paths diverge gained or lost captured children, or
35
+ when the element slid into a vacated slot in its own container. This fixes
36
+ three truthfulness bugs at once: a size-changing duplicate restyle swap no
37
+ longer loses all visual proof, a sibling insertion into a uniform-shell list
38
+ (menus, navs) no longer re-boxes every unchanged displaced item, and an
39
+ independent removal + identical addition in different containers no longer
40
+ cancel each other's annotations to zero.
41
+ - **`styleproof-diff` no longer misreports fresh ad-hoc captures as predating
42
+ the determinism ledger.** The unknown-basis warning now names the real cause:
43
+ ad-hoc `styleproof-capture` output records no ledger; spec-driven captures
44
+ self-check and do.
45
+
46
+ ## [4.1.0] - 2026-07-12
47
+
48
+ ### Fixed
49
+
50
+ - **Annotated report crops no longer paint path-shifted subtrees as visual
51
+ changes.** When an unkeyed sibling insertion renumbers `:nth-child()` paths,
52
+ the report reconciles exact-equivalent entries across paths for annotation
53
+ only. Genuine additions and removals stay highlighted, while exhaustive
54
+ structural findings remain in the certification and audit details.
55
+ - **Pull-request captures and reports now stay bound to the real head commit.**
56
+ GitHub's synthetic merge SHA no longer becomes the map or report provenance,
57
+ and published report links point to the immutable report commit.
58
+ - **Certify-mode report comments now carry their source head marker**, so stale
59
+ comments can be distinguished from the current PR head.
60
+ - **Annotation reconciliation preserves duplicate additions, removals, and
61
+ forced-state changes.** Matching is one-to-one within a structural
62
+ neighborhood, and indistinguishable duplicate provenance is reported as a
63
+ deterministic unmatched occurrence rather than guessed. Ambiguous duplicate
64
+ restyle swaps retain visual proof on both sides.
65
+
10
66
  ## [4.0.2] - 2026-07-11
11
67
 
12
68
  ### Fixed
package/README.md CHANGED
@@ -217,6 +217,16 @@ summary, and the exact property change folded under a toggle. A change too small
217
217
  to see at 1:1 (say a 2px icon tweak) also gets a magnified zoom crop, so a
218
218
  sub-pixel change can't slip past a reviewer.
219
219
 
220
+ Annotation boxes reconcile exact-equivalent elements one-to-one within a structural
221
+ neighborhood. When an unkeyed sibling insertion shifts unchanged descendants, the
222
+ clean comparison still shows the complete rendered result and the audit keeps every
223
+ structural finding, but magenta boxes mark only unmatched additions, removals, and
224
+ restyles instead of painting the displaced subtree as changed. Visually identical
225
+ duplicates have no provable physical provenance, so the report marks a deterministic
226
+ unmatched occurrence rather than claiming which duplicate moved. When duplicate
227
+ restyles could also be explained as a swap, the report keeps both sides annotated
228
+ because the captured data cannot prove a move.
229
+
220
230
  When one change appears both on an ordinary page and in an open popup, the report
221
231
  chooses a representative where the changed element is visibly exposed, then prefers
222
232
  the ordinary page before using viewport width as a tie-breaker. Modal-background DOM
@@ -751,6 +761,8 @@ You watch one number as you implement: the diff starts large and shrinks toward
751
761
 
752
762
  A design is mostly _behind clicks_ — modals, drawers, popovers, tabs that don't exist in the DOM until you open them. A single capture sees only the landing state. `--crawl` maps the rest for you: point it at the URL and it drives every non-destructive control, keeps whatever opens a structurally new surface, and recurses into it — a modal's tabs, a drawer's sub-views, a popover's panels — capturing each under a derived key. No spec, no selectors, no hand-holding.
753
763
 
764
+ It follows the nav too: every same-origin page the site links to is crawled the same way, keyed by its route (`about`, `pricing`, `blog-post`, …), with class coverage aggregated across the pages that share stylesheets. `--no-follow-links` keeps the sweep to the entry page's interactive surface only.
765
+
754
766
  ```bash
755
767
  styleproof-capture https://example.com --crawl --out design # maps every reachable surface
756
768
  styleproof-diff design .styleproof/maps/current # diff the whole surface vs your build
@@ -20,6 +20,7 @@ import { chromium } from '@playwright/test';
20
20
  import { isHelpArg, showHelpAndExit } from '../dist/cli-errors.js';
21
21
  import { UsageError, parseCaptureUrlArgs, runCaptureUrl, loadSetupSteps } from '../dist/capture-url.js';
22
22
  import { crawlAndCapture } from '../dist/crawl-surfaces.js';
23
+ import { selectCrawlLinks, dedupIdentity } from '../dist/crawl.js';
23
24
  import { writeCaptureManifest } from '../dist/map-store.js';
24
25
 
25
26
  const COMMAND = 'styleproof-capture';
@@ -56,6 +57,9 @@ whole surface: --crawl
56
57
  fulfilled with 500 → error render)
57
58
  --workers <n> concurrent sweep workers (default 4); same surface set as a
58
59
  serial crawl — pass 1 for byte-stable key attribution
60
+ --no-follow-links crawl the entry page's interactive surface only. By default
61
+ every same-origin page the nav links to is crawled too,
62
+ each keyed by its route (about, pricing, blog-post, ...)
59
63
  --until-covered stop the crawl early the moment every stylesheet class has
60
64
  been rendered — a coverage-oriented sweep for design mockups
61
65
  --max-depth <n> throttle recursion depth (default: 16 — backstop for
@@ -95,61 +99,133 @@ try {
95
99
  throw e;
96
100
  }
97
101
 
102
+ // Read the freshly-loaded page's same-origin nav links, keyed by route.
103
+ async function harvestPageLinks(page, url) {
104
+ await page.goto(url, { waitUntil: 'load' });
105
+ const hrefs = await page.$$eval('a[href]', (els) => els.map((e) => e.getAttribute('href'))).catch(() => []);
106
+ return selectCrawlLinks(hrefs, { base: page.url() });
107
+ }
108
+
109
+ function printCoverage(cov, label) {
110
+ const unreadable = cov.unreadable ?? [];
111
+ if (unreadable.length > 0) {
112
+ console.log(
113
+ `⚠ coverage${label}: ${unreadable.length} stylesheet(s) unreadable — class coverage not provable against them ` +
114
+ `(cross-origin, no CORS; make them same-origin / CORS-readable, or pin --widths):\n ${unreadable.join(' ')}`,
115
+ );
116
+ }
117
+ if (cov.missing.length === 0) {
118
+ if (unreadable.length === 0)
119
+ console.log(
120
+ `✓ coverage${label}: all ${cov.defined} stylesheet classes rendered in at least one captured surface`,
121
+ );
122
+ } else {
123
+ console.log(
124
+ `⚠ coverage${label}: ${cov.rendered}/${cov.defined} stylesheet classes rendered — ${cov.missing.length} never seen ` +
125
+ `(dead CSS, or a state the crawl could not reach):\n ${cov.missing.join(' ')}`,
126
+ );
127
+ }
128
+ }
129
+
130
+ function pageCrawlOptions(browser, url, prefix, statesLeft) {
131
+ return {
132
+ url,
133
+ out: opts.out,
134
+ widths: opts.widths, // empty = auto-detect the page's real breakpoints
135
+ ignore: opts.ignore,
136
+ height: opts.height,
137
+ screenshots: opts.screenshots,
138
+ waitSelector: opts.waitSelector,
139
+ maxDepth: opts.maxDepth,
140
+ maxActionsPerState: opts.maxActionsPerState,
141
+ maxStates: statesLeft,
142
+ resetStorage: opts.resetStorage,
143
+ setup: setupSteps,
144
+ dataStates: opts.dataStates,
145
+ stopWhenCovered: opts.untilCovered,
146
+ workers: opts.workers,
147
+ keyPrefix: prefix,
148
+ // each worker page in its OWN context, so storage resets can't interfere
149
+ newPage: async () => (await browser.newContext()).newPage(),
150
+ // Stream each surface as it is captured, so progress is visible live and an
151
+ // interrupted run still shows exactly what it mapped.
152
+ onSurface: (s, ok) =>
153
+ console.log(` ${'·'.repeat(s.depth)}${s.key} (${s.elements} elements)${ok ? '' : ' — CAPTURE FAILED'}`),
154
+ };
155
+ }
156
+
157
+ // Aggregate coverage: pages share stylesheets, so a class unrendered on one
158
+ // page but rendered on another IS covered. defined = rendered ∪ missing.
159
+ function aggregateCoverage(reports) {
160
+ const rendered = new Set(reports.flatMap((r) => r.coverage.renderedClasses));
161
+ const missing = [...new Set(reports.flatMap((r) => r.coverage.missing))].filter((c) => !rendered.has(c)).sort();
162
+ const unreadable = [...new Set(reports.flatMap((r) => r.coverage.unreadable ?? []))];
163
+ return { defined: rendered.size + missing.length, rendered: rendered.size, missing, unreadable };
164
+ }
165
+
166
+ function printCrawlSummary(reports) {
167
+ const surfaces = reports.reduce((n, r) => n + r.surfaces.length, 0);
168
+ const captured = reports.reduce((n, r) => n + r.captured, 0);
169
+ const tried = reports.reduce((n, r) => n + r.actionsTried, 0);
170
+ const skipped = reports.reduce((n, r) => n + r.skipped, 0);
171
+ const failed = reports.flatMap((r) => r.failed);
172
+ const widths = opts.widths.length ? `${opts.widths.length} width(s)` : 'auto widths';
173
+ console.log(
174
+ `✓ ${captured}/${surfaces} surface(s) across ${reports.length} page(s) × ${widths} → ${opts.out} ` +
175
+ `(${tried} actions tried, ${skipped} skipped${failed.length ? `, ${failed.length} capture-failed` : ''})`,
176
+ );
177
+ }
178
+
179
+ // Enqueue every not-yet-seen page the just-crawled page links to, giving each a
180
+ // unique route-key prefix ('base' is reserved for the entry crawl's root).
181
+ function enqueueLinkedPages(links, sweep) {
182
+ for (const link of links) {
183
+ const id = dedupIdentity(link.url);
184
+ if (sweep.seenPages.has(id)) continue;
185
+ sweep.seenPages.add(id);
186
+ let prefix = link.key;
187
+ for (let i = 2; sweep.usedPrefixes.has(prefix); i++) prefix = `${link.key}-${i}`;
188
+ sweep.usedPrefixes.add(prefix);
189
+ sweep.queue.push({ url: new URL(link.url, sweep.entry).href, prefix });
190
+ }
191
+ }
192
+
98
193
  async function runCrawl() {
99
194
  const browser = await chromium.launch();
100
195
  try {
101
196
  const page = await browser.newPage();
102
- const crawlOpts = {
103
- url: opts.url,
104
- out: opts.out,
105
- widths: opts.widths, // empty = auto-detect the page's real breakpoints
106
- ignore: opts.ignore,
107
- height: opts.height,
108
- screenshots: opts.screenshots,
109
- waitSelector: opts.waitSelector,
110
- maxDepth: opts.maxDepth,
111
- maxActionsPerState: opts.maxActionsPerState,
112
- maxStates: opts.maxStates,
113
- resetStorage: opts.resetStorage,
114
- setup: setupSteps,
115
- dataStates: opts.dataStates,
116
- stopWhenCovered: opts.untilCovered,
117
- workers: opts.workers,
118
- // each worker page in its OWN context, so storage resets can't interfere
119
- newPage: async () => (await browser.newContext()).newPage(),
120
- // Stream each surface as it is captured, so progress is visible live and an
121
- // interrupted run still shows exactly what it mapped.
122
- onSurface: (s, ok) =>
123
- console.log(` ${'·'.repeat(s.depth)}${s.key} (${s.elements} elements)${ok ? '' : ' — CAPTURE FAILED'}`),
197
+ // Page-level breadth-first sweep: crawl the entry page's whole interactive
198
+ // surface, then every same-origin page its nav links to (and theirs), each
199
+ // namespaced by its route key so a shared --out directory never collides.
200
+ const entry = new URL(opts.url);
201
+ const sweep = {
202
+ entry,
203
+ queue: [{ url: opts.url, prefix: '' }],
204
+ seenPages: new Set([dedupIdentity(entry.pathname + entry.search)]),
205
+ usedPrefixes: new Set(['base']), // 'base' is the entry crawl's root key
124
206
  };
125
- const report = await crawlAndCapture(page, crawlOpts);
207
+ const reports = [];
208
+ let statesLeft = opts.maxStates;
209
+
210
+ while (sweep.queue.length > 0 && statesLeft > 0) {
211
+ const { url, prefix } = sweep.queue.shift();
212
+ const report = await crawlAndCapture(page, pageCrawlOptions(browser, url, prefix, statesLeft));
213
+ reports.push(report);
214
+ statesLeft -= report.surfaces.length;
215
+ if (opts.followLinks) enqueueLinkedPages(await harvestPageLinks(page, url), sweep);
216
+ }
217
+ if (sweep.queue.length > 0)
218
+ console.log(`⚠ --max-states reached: ${sweep.queue.length} linked page(s) left uncrawled — raise --max-states`);
219
+
126
220
  // Stamp a manifest so a two-directory diff against this crawl output has the
127
221
  // same-environment guard on both sides (v4 refuses a manifest-less side).
128
222
  writeCaptureManifest({ dir: opts.out, screenshots: opts.screenshots });
129
- console.log(
130
- `✓ ${report.captured}/${report.surfaces.length} surface(s) × ${crawlOpts.widths.length} width(s) → ${opts.out} ` +
131
- `(${report.actionsTried} actions tried, ${report.skipped} skipped${report.failed.length ? `, ${report.failed.length} capture-failed` : ''})`,
132
- );
133
- const cov = report.coverage;
134
- const unreadable = cov.unreadable ?? [];
135
- if (unreadable.length > 0) {
136
- console.log(
137
- `⚠ coverage: ${unreadable.length} stylesheet(s) unreadable — class coverage not provable against them ` +
138
- `(cross-origin, no CORS; make them same-origin / CORS-readable, or pin --widths):\n ${unreadable.join(' ')}`,
139
- );
140
- }
141
- if (cov.missing.length === 0) {
142
- if (unreadable.length === 0)
143
- console.log(`✓ coverage: all ${cov.defined} stylesheet classes rendered in at least one captured surface`);
144
- } else {
145
- console.log(
146
- `⚠ coverage: ${cov.rendered}/${cov.defined} stylesheet classes rendered — ${cov.missing.length} never seen ` +
147
- `(dead CSS, or a state the crawl could not reach):\n ${cov.missing.join(' ')}`,
148
- );
149
- }
223
+ printCrawlSummary(reports);
224
+ const cov = aggregateCoverage(reports);
225
+ printCoverage(cov, reports.length > 1 ? ` (${reports.length} pages)` : '');
150
226
  // Residue under --require-full-coverage → exit 4: a never-seen class OR an
151
227
  // unreadable sheet (whose vocabulary can't be proven covered at all).
152
- if (opts.requireFullCoverage && (cov.missing.length > 0 || unreadable.length > 0)) process.exit(4);
228
+ if (opts.requireFullCoverage && (cov.missing.length > 0 || cov.unreadable.length > 0)) process.exit(4);
153
229
  } finally {
154
230
  await browser.close();
155
231
  }
@@ -238,7 +238,13 @@ function printDeterminismVerdict(v) {
238
238
  return false;
239
239
  }
240
240
  if (v.status === 'unknown') {
241
- console.log('\n⚠ determinism basis unknown a capture predates the determinism ledger; recapture to certify it.');
241
+ // No ledger at all = an ad-hoc `styleproof-capture` output (which doesn't
242
+ // self-check) or a pre-3.10 bundle; a spec capture records the basis.
243
+ console.log(
244
+ '\n⚠ determinism basis unknown — a side carries no determinism ledger (an ad-hoc styleproof-capture\n' +
245
+ ' output, or a capture from before the ledger existed). A spec-driven capture (styleproof-map)\n' +
246
+ ' self-checks and records it; ad-hoc captures are compared as-is.',
247
+ );
242
248
  return false;
243
249
  }
244
250
  console.log(
@@ -72,6 +72,9 @@ export type CaptureUrlOptions = {
72
72
  dataStates: boolean;
73
73
  /** crawl: concurrent sweep workers (default 4). 1 = byte-stable key attribution. */
74
74
  workers: number;
75
+ /** crawl: also crawl every same-origin page the nav links to (default true).
76
+ * Off = the entry page's interactive surface only. */
77
+ followLinks: boolean;
75
78
  };
76
79
  /**
77
80
  * Parse `styleproof-capture` argv into options. Pure and throwing so the CLI
@@ -40,6 +40,7 @@ const DEFAULTS = {
40
40
  untilCovered: false,
41
41
  dataStates: true,
42
42
  workers: 4,
43
+ followLinks: true,
43
44
  };
44
45
  function positiveNumber(raw, flag) {
45
46
  const n = Number(raw);
@@ -81,6 +82,8 @@ const BOOL_FLAGS = {
81
82
  '--until-covered': (o) => (o.untilCovered = true),
82
83
  '--data-states': (o) => (o.dataStates = true),
83
84
  '--no-data-states': (o) => (o.dataStates = false),
85
+ '--follow-links': (o) => (o.followLinks = true),
86
+ '--no-follow-links': (o) => (o.followLinks = false),
84
87
  };
85
88
  // Apply one argv token to the accumulator; returns the index to resume from
86
89
  // (advanced past a consumed `--flag value` pair). Flat early-returns so the
@@ -131,6 +134,7 @@ export function parseCaptureUrlArgs(argv) {
131
134
  setupFile: undefined,
132
135
  dataStates: DEFAULTS.dataStates,
133
136
  workers: DEFAULTS.workers,
137
+ followLinks: DEFAULTS.followLinks,
134
138
  };
135
139
  const positional = [];
136
140
  for (let i = 0; i < argv.length; i++)
@@ -53,6 +53,9 @@ export type CrawlCoverage = {
53
53
  rendered: number;
54
54
  missing: string[];
55
55
  unreadable: string[];
56
+ /** The class names that WERE rendered — so a multi-page caller can aggregate
57
+ * coverage across crawls sharing stylesheets (defined = rendered ∪ missing). */
58
+ renderedClasses: string[];
56
59
  };
57
60
  export type CrawlReport = {
58
61
  surfaces: CrawledSurface[];
@@ -122,6 +125,9 @@ export type SurfaceCrawlOptions = {
122
125
  /** Factory for worker pages — create each in its OWN browser context so
123
126
  * storage resets cannot interfere across concurrent sweeps. */
124
127
  newPage?: () => Promise<Page>;
128
+ /** Namespace for every derived surface key (multi-page sweeps): the root
129
+ * surface keys as the prefix itself, sub-states as `<prefix>-<label>`. */
130
+ keyPrefix?: string;
125
131
  };
126
132
  export declare const CRAWL_DEFAULTS: {
127
133
  height: number;
@@ -37,8 +37,12 @@ function pathAndSearch(url) {
37
37
  return url;
38
38
  }
39
39
  }
40
- function deriveKey(steps, used) {
41
- const base = steps.length === 0 ? 'base' : slug(steps[steps.length - 1].label);
40
+ function deriveKey(steps, used, prefix = '') {
41
+ const bare = steps.length === 0 ? 'base' : slug(steps[steps.length - 1].label);
42
+ // A prefixed crawl (one page of a multi-page sweep) keys its root surface as
43
+ // the prefix itself and namespaces every sub-state under it, so two pages'
44
+ // surfaces can never overwrite each other in a shared --out directory.
45
+ const base = prefix === '' ? bare : bare === 'base' ? prefix : `${prefix}-${bare}`;
42
46
  let key = base;
43
47
  for (let i = 2; used.has(key); i++)
44
48
  key = `${base}-${i}`;
@@ -500,7 +504,7 @@ function stateKey(steps) {
500
504
  /** Record a newly-found surface, capture it in place (page is already there), and
501
505
  * queue it for its own sweep. Streams progress via onSurface. */
502
506
  async function record(page, opts, newPath, depth, fp, st, sink, retryOnly = false, viaRetry = false) {
503
- const key = deriveKey(newPath, st.used);
507
+ const key = deriveKey(newPath, st.used, opts.keyPrefix ?? '');
504
508
  const surface = { key, depth, path: newPath, elements: fp.elements };
505
509
  st.surfaces.push(surface);
506
510
  let addsVocab = false;
@@ -753,7 +757,7 @@ async function recordDataState(page, opts, mode, st) {
753
757
  st.seen.add(fp.sig);
754
758
  for (const c of fp.classes)
755
759
  st.classes.add(c);
756
- const key = deriveKey([{ action: 'click', selector: `(data:${mode})`, label: mode, reason: 'data-state' }], st.used);
760
+ const key = deriveKey([{ action: 'click', selector: `(data:${mode})`, label: mode, reason: 'data-state' }], st.used, opts.keyPrefix ?? '');
757
761
  const surface = { key, depth: 0, path: [], elements: fp.elements };
758
762
  st.surfaces.push(surface);
759
763
  await captureAndReport(page, opts, surface, st);
@@ -894,7 +898,13 @@ async function discover(page, opts) {
894
898
  skipped: counters.skipped,
895
899
  captured: st.captured,
896
900
  failed: st.failed,
897
- coverage: { defined: defined.length, rendered: defined.length - missing.length, missing, unreadable },
901
+ coverage: {
902
+ defined: defined.length,
903
+ rendered: defined.length - missing.length,
904
+ missing,
905
+ unreadable,
906
+ renderedClasses: defined.filter((c) => st.classes.has(c)).sort(),
907
+ },
898
908
  };
899
909
  }
900
910
  /**
package/dist/crawl.d.ts CHANGED
@@ -57,6 +57,23 @@ export type SelectLinksOptions = {
57
57
  * nav-regressions / unowned routes for a route that never changed.
58
58
  */
59
59
  export declare function defaultLinkKey(url: URL): string;
60
+ /**
61
+ * Dedup identity for a navigable path+query. Two forms of the same route must share
62
+ * one identity, or a static multi-page site (whose nav links the `.html` files) gets
63
+ * captured twice as byte-near-identical maps, doubling the work and duplicating every
64
+ * finding in the diff:
65
+ *
66
+ * - A trailing slash isn't a distinct surface (`/about` and `/about/` render the same
67
+ * route), so it's stripped — but never from the root `/` itself, nor from the query.
68
+ * - A trailing `index.html` is the directory's index (`/index.html` IS `/`, and
69
+ * `/docs/index.html` IS `/docs/`), so it collapses to the directory path. Only the
70
+ * literal `index.html` filename normalizes — a real `about.html` is left untouched
71
+ * and stays a distinct surface from `about`.
72
+ *
73
+ * The navigable url the caller returns keeps its original form; only the SET
74
+ * membership test is normalized, so the first-seen href still wins.
75
+ */
76
+ export declare function dedupIdentity(pathAndSearch: string): string;
60
77
  /**
61
78
  * Turn a page's raw `<a href>` values into a deduped, keyed surface list.
62
79
  *
package/dist/crawl.js CHANGED
@@ -95,7 +95,7 @@ function toLink(href, base, keyFor, match) {
95
95
  * The navigable url the caller returns keeps its original form; only the SET
96
96
  * membership test is normalized, so the first-seen href still wins.
97
97
  */
98
- function dedupIdentity(pathAndSearch) {
98
+ export function dedupIdentity(pathAndSearch) {
99
99
  const q = pathAndSearch.indexOf('?');
100
100
  const path = q === -1 ? pathAndSearch : pathAndSearch.slice(0, q);
101
101
  const search = q === -1 ? '' : pathAndSearch.slice(q);
package/dist/map-store.js CHANGED
@@ -139,13 +139,47 @@ export function expectedCompatibilityKey(options = {}) {
139
139
  }))).slice(0, 16);
140
140
  }
141
141
  export function currentGitSha(cwd = process.cwd(), env = process.env) {
142
- const fromEnv = env.GITHUB_SHA || env.GITHUB_HEAD_SHA;
143
- if (fromEnv && /^[0-9a-f]{7,40}$/i.test(fromEnv))
144
- return fromEnv;
145
- const sha = gitOutput(cwd, ['rev-parse', 'HEAD']);
146
- if (!sha)
147
- throw new MapStoreError('must run inside a git repository, or pass --sha <commit>');
148
- return sha;
142
+ const fromEvent = (() => {
143
+ // pull_request_target is deliberately absent: there GITHUB_SHA *is* the base
144
+ // tip, so its default checkout would be relabeled to the fork's (attacker-
145
+ // chosen) head. A pull_request_target job that really checks out the head
146
+ // gets the right SHA from `git rev-parse HEAD` with no relabel needed.
147
+ if (!env.GITHUB_EVENT_PATH || !['pull_request', 'workflow_run'].includes(env.GITHUB_EVENT_NAME ?? '')) {
148
+ return undefined;
149
+ }
150
+ try {
151
+ const event = JSON.parse(fs.readFileSync(env.GITHUB_EVENT_PATH, 'utf8'));
152
+ return event.pull_request?.head?.sha ?? event.workflow_run?.head_sha;
153
+ }
154
+ catch {
155
+ return undefined;
156
+ }
157
+ })();
158
+ // STYLEPROOF_SHA/GITHUB_HEAD_SHA are explicit overrides: they always win, and
159
+ // a malformed value errors instead of silently falling through to a wrong label.
160
+ const explicit = env.STYLEPROOF_SHA || env.GITHUB_HEAD_SHA;
161
+ if (explicit) {
162
+ if (!/^[0-9a-f]{7,40}$/i.test(explicit)) {
163
+ throw new MapStoreError(`STYLEPROOF_SHA/GITHUB_HEAD_SHA is not a commit SHA: ${explicit}`);
164
+ }
165
+ return explicit;
166
+ }
167
+ const head = gitOutput(cwd, ['rev-parse', 'HEAD']);
168
+ if (head) {
169
+ // The checked-out tree is the truth. The one exception: a checkout of the
170
+ // synthetic GITHUB_SHA commit (pull_request merge commit / workflow_run
171
+ // default tip) is labeled with the event's real head, because nothing ever
172
+ // restores by the synthetic SHA. A checkout of anything else — e.g. the
173
+ // base branch in a cache-miss job — keeps its own SHA, so a base-tree map
174
+ // is never published under the head's store key (a false-green poisoning).
175
+ if (fromEvent && head === env.GITHUB_SHA)
176
+ return fromEvent;
177
+ return head;
178
+ }
179
+ const fallback = fromEvent ?? env.GITHUB_SHA;
180
+ if (fallback && /^[0-9a-f]{7,40}$/i.test(fallback))
181
+ return fallback;
182
+ throw new MapStoreError('must run inside a git repository, or pass --sha <commit>');
149
183
  }
150
184
  export function refSha(ref, cwd = process.cwd()) {
151
185
  const sha = gitOutput(cwd, ['rev-parse', `${ref}^{commit}`]);
package/dist/report.js CHANGED
@@ -48,6 +48,195 @@ function outermost(paths) {
48
48
  function innermost(paths) {
49
49
  return paths.filter((p) => !paths.some((q) => q !== p && q.startsWith(p + ' > ')));
50
50
  }
51
+ function sortedProperties(props) {
52
+ return Object.entries(props).sort(([left], [right]) => left.localeCompare(right, 'en'));
53
+ }
54
+ function restingAnnotationIdentity(entry) {
55
+ if (!entry)
56
+ return null;
57
+ const sortedPseudo = Object.fromEntries(Object.entries(entry.pseudo ?? {})
58
+ .sort(([left], [right]) => left.localeCompare(right, 'en'))
59
+ .map(([pseudo, properties]) => [pseudo, sortedProperties(properties)]));
60
+ return [
61
+ entry.tag,
62
+ entry.cls,
63
+ entry.rect?.[2] ?? null,
64
+ entry.rect?.[3] ?? null,
65
+ sortedProperties(entry.style),
66
+ sortedPseudo,
67
+ ];
68
+ }
69
+ function normalizeStructuralPath(elementPath) {
70
+ return elementPath.replace(/:nth-(?:child|of-type)\(\d+\)/g, (selector) => selector.replace(/\d+/, '*'));
71
+ }
72
+ function annotationScope(elementPath) {
73
+ const parentSeparator = elementPath.lastIndexOf(' > ');
74
+ return normalizeStructuralPath(parentSeparator === -1 ? '' : elementPath.slice(0, parentSeparator));
75
+ }
76
+ function relativeStateTarget(ownerPath, targetPath) {
77
+ if (targetPath === ownerPath)
78
+ return '';
79
+ const ownerPseudoPrefix = `${ownerPath}::`;
80
+ if (targetPath.startsWith(ownerPseudoPrefix))
81
+ return targetPath.slice(ownerPath.length);
82
+ const descendantPrefix = `${ownerPath} > `;
83
+ const relativePath = targetPath.startsWith(descendantPrefix) ? targetPath.slice(descendantPrefix.length) : targetPath;
84
+ return normalizeStructuralPath(relativePath);
85
+ }
86
+ function canonicalForcedStates(map, ownerPath) {
87
+ return Object.entries(map.states?.[ownerPath] ?? {})
88
+ .sort(([left], [right]) => left.localeCompare(right, 'en'))
89
+ .map(([stateName, deltas]) => [
90
+ stateName,
91
+ Object.entries(deltas)
92
+ .map(([targetPath, properties]) => [
93
+ relativeStateTarget(ownerPath, targetPath),
94
+ restingAnnotationIdentity(map.elements[targetPath]),
95
+ sortedProperties(properties),
96
+ ])
97
+ .sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right), 'en')),
98
+ ]);
99
+ }
100
+ function annotationIdentity(map, elementPath, entry) {
101
+ return JSON.stringify([restingAnnotationIdentity(entry), canonicalForcedStates(map, elementPath)]);
102
+ }
103
+ function sortedAnnotationPaths(paths) {
104
+ return [...paths].sort((left, right) => left.localeCompare(right, 'en', { numeric: true }));
105
+ }
106
+ function indexAnnotationIdentities(map) {
107
+ const pathsByIdentity = new Map();
108
+ for (const [elementPath, entry] of Object.entries(map.elements)) {
109
+ const identity = annotationIdentity(map, elementPath, entry);
110
+ pathsByIdentity.set(identity, [...(pathsByIdentity.get(identity) ?? []), elementPath]);
111
+ }
112
+ return pathsByIdentity;
113
+ }
114
+ function pathsByAnnotationScope(paths) {
115
+ const pathsByScope = new Map();
116
+ for (const elementPath of paths) {
117
+ const scope = annotationScope(elementPath);
118
+ pathsByScope.set(scope, [...(pathsByScope.get(scope) ?? []), elementPath]);
119
+ }
120
+ for (const scopedPaths of pathsByScope.values())
121
+ scopedPaths.sort((left, right) => left.localeCompare(right, 'en', { numeric: true }));
122
+ return pathsByScope;
123
+ }
124
+ /** Captured children per concrete container path, for the displacement proof. */
125
+ function containerChildCounts(map) {
126
+ const counts = new Map();
127
+ for (const elementPath of Object.keys(map.elements)) {
128
+ const separator = elementPath.lastIndexOf(' > ');
129
+ const container = separator === -1 ? '' : elementPath.slice(0, separator);
130
+ counts.set(container, (counts.get(container) ?? 0) + 1);
131
+ }
132
+ return counts;
133
+ }
134
+ /** The concrete container where two element paths diverge (never the leaf itself). */
135
+ function deepestCommonContainer(beforePath, afterPath) {
136
+ const beforeSegments = beforePath.split(' > ');
137
+ const afterSegments = afterPath.split(' > ');
138
+ const shared = [];
139
+ const limit = Math.min(beforeSegments.length, afterSegments.length) - 1;
140
+ for (let i = 0; i < limit && beforeSegments[i] === afterSegments[i]; i++)
141
+ shared.push(beforeSegments[i]);
142
+ return shared.join(' > ');
143
+ }
144
+ function containerOf(elementPath) {
145
+ const separator = elementPath.lastIndexOf(' > ');
146
+ return separator === -1 ? '' : elementPath.slice(0, separator);
147
+ }
148
+ /**
149
+ * A cross-path match is a MOVE claim, and a matched pair's annotations are
150
+ * suppressed — so the move must be PROVABLE from the captured data, one of:
151
+ *
152
+ * - the container where the two paths diverge gained or lost captured children
153
+ * (a sibling insertion/removal displaced everything after it), or
154
+ * - a same-container slide into a vacated slot: the source slot emptied and the
155
+ * destination slot is new. That is displacement by an UNCAPTURED sibling — an
156
+ * injected `<style>`/`<script>` shifts `nth-child` without entering the census.
157
+ *
158
+ * A pair with neither proof — a style swap between siblings, a pure reorder of
159
+ * occupied slots, or a coincidental twin in a cousin container — stays
160
+ * annotated, because the data cannot prove nothing changed there.
161
+ */
162
+ function canReconcileAnnotationPair(beforeMap, afterMap, beforeCounts, afterCounts, beforePath, afterPath, remainingAfter) {
163
+ if (!remainingAfter.has(afterPath))
164
+ return false;
165
+ const divergence = deepestCommonContainer(beforePath, afterPath);
166
+ if ((beforeCounts.get(divergence) ?? 0) !== (afterCounts.get(divergence) ?? 0))
167
+ return true;
168
+ if (containerOf(beforePath) !== containerOf(afterPath))
169
+ return false;
170
+ return !beforeMap.elements[afterPath] && !afterMap.elements[beforePath];
171
+ }
172
+ function reconcileIdentityPaths(beforeMap, afterMap, beforeCounts, afterCounts, beforePaths, afterPaths, matches) {
173
+ const remainingBefore = new Set(beforePaths);
174
+ const remainingAfter = new Set(afterPaths);
175
+ // Preserve stable paths first. This keeps duplicate occurrences deterministic
176
+ // without claiming which indistinguishable physical node was inserted.
177
+ for (const beforePath of beforePaths) {
178
+ if (!remainingAfter.has(beforePath))
179
+ continue;
180
+ matches.beforeToAfter.set(beforePath, beforePath);
181
+ matches.afterToBefore.set(beforePath, beforePath);
182
+ remainingBefore.delete(beforePath);
183
+ remainingAfter.delete(beforePath);
184
+ }
185
+ const remainingAfterPathsByScope = pathsByAnnotationScope(remainingAfter);
186
+ // Reconcile only within the same normalized structural neighborhood. Any
187
+ // excess occurrence remains unmatched and is annotated as an addition/removal.
188
+ for (const beforePath of sortedAnnotationPaths([...remainingBefore])) {
189
+ const candidates = remainingAfterPathsByScope.get(annotationScope(beforePath)) ?? [];
190
+ const afterPath = candidates.find((candidate) => canReconcileAnnotationPair(beforeMap, afterMap, beforeCounts, afterCounts, beforePath, candidate, remainingAfter));
191
+ if (!afterPath)
192
+ continue;
193
+ matches.beforeToAfter.set(beforePath, afterPath);
194
+ matches.afterToBefore.set(afterPath, beforePath);
195
+ remainingBefore.delete(beforePath);
196
+ remainingAfter.delete(afterPath);
197
+ }
198
+ }
199
+ function reconcileAnnotationPaths(beforeMap, afterMap) {
200
+ const beforePathsByIdentity = indexAnnotationIdentities(beforeMap);
201
+ const afterPathsByIdentity = indexAnnotationIdentities(afterMap);
202
+ const beforeCounts = containerChildCounts(beforeMap);
203
+ const afterCounts = containerChildCounts(afterMap);
204
+ const matches = {
205
+ beforeToAfter: new Map(),
206
+ afterToBefore: new Map(),
207
+ };
208
+ const identities = new Set([...beforePathsByIdentity.keys(), ...afterPathsByIdentity.keys()]);
209
+ for (const identity of identities) {
210
+ reconcileIdentityPaths(beforeMap, afterMap, beforeCounts, afterCounts, sortedAnnotationPaths(beforePathsByIdentity.get(identity) ?? []), sortedAnnotationPaths(afterPathsByIdentity.get(identity) ?? []), matches);
211
+ }
212
+ return matches;
213
+ }
214
+ function annotationSides(finding, beforeMoved, afterMoved) {
215
+ if (finding.kind !== 'dom')
216
+ return { before: !beforeMoved, after: !afterMoved };
217
+ if (finding.change === 'removed')
218
+ return { before: !beforeMoved, after: false };
219
+ if (finding.change === 'added')
220
+ return { before: false, after: !afterMoved };
221
+ return { before: true, after: true };
222
+ }
223
+ function annotationPaths(findings, beforeMap, afterMap) {
224
+ const matches = reconcileAnnotationPaths(beforeMap, afterMap);
225
+ const beforePaths = new Set();
226
+ const afterPaths = new Set();
227
+ for (const finding of findings) {
228
+ const beforeMatch = matches.beforeToAfter.get(finding.path);
229
+ const afterMatch = matches.afterToBefore.get(finding.path);
230
+ const beforeMoved = beforeMatch !== undefined && beforeMatch !== finding.path;
231
+ const afterMoved = afterMatch !== undefined && afterMatch !== finding.path;
232
+ const sides = annotationSides(finding, beforeMoved, afterMoved);
233
+ if (sides.before)
234
+ beforePaths.add(finding.path);
235
+ if (sides.after)
236
+ afterPaths.add(finding.path);
237
+ }
238
+ return { before: innermost([...beforePaths]), after: innermost([...afterPaths]) };
239
+ }
51
240
  /** Headline counts with the zeros dropped — `0 state-delta difference(s)` is noise. */
52
241
  function changeCountLabel(shown) {
53
242
  const parts = [];
@@ -853,9 +1042,9 @@ function buildRegionImages(args) {
853
1042
  // cards) on each side — not the merged container the crop anchors on, whose box would
854
1043
  // just trace the whole frame. An element present on only one side (added/removed) is
855
1044
  // boxed only there.
856
- const markPaths = innermost([...new Set(regionFindings.map((f) => f.path))]);
857
- const rectsA = markPaths.map((p) => mapA.elements[p]?.rect).filter((r) => !!r);
858
- const rectsB = markPaths.map((p) => mapB.elements[p]?.rect).filter((r) => !!r);
1045
+ const markedPaths = annotationPaths(regionFindings, mapA, mapB);
1046
+ const rectsA = markedPaths.before.map((p) => mapA.elements[p]?.rect).filter((r) => !!r);
1047
+ const rectsB = markedPaths.after.map((p) => mapB.elements[p]?.rect).filter((r) => !!r);
859
1048
  const annotatedBefore = annotateCrop(before, rectsA);
860
1049
  const annotatedAfter = annotateCrop(after, rectsB);
861
1050
  const images = {
@@ -869,8 +1058,10 @@ function buildRegionImages(args) {
869
1058
  // Name the changed element(s) so the reviewer knows where to look without expanding
870
1059
  // anything (e.g. `changed: span.caret`).
871
1060
  const changedNames = [
872
- ...new Set(markPaths
873
- .map((p) => mapA.elements[p] ?? mapB.elements[p])
1061
+ ...new Set([
1062
+ ...markedPaths.before.map((elementPath) => mapA.elements[elementPath]),
1063
+ ...markedPaths.after.map((elementPath) => mapB.elements[elementPath]),
1064
+ ]
874
1065
  .filter((e) => !!e)
875
1066
  .map((e) => (e.cls ? `${e.tag}.${e.cls.split(/\s+/)[0]}` : e.tag))),
876
1067
  ].slice(0, 3);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "styleproof",
3
- "version": "4.0.2",
3
+ "version": "4.2.0",
4
4
  "description": "Catch every CSS change before it ships — review PRs and certify refactors by the browser's computed styles, not pixels. Works with any styling system.",
5
5
  "keywords": [
6
6
  "playwright",