styleproof 4.1.0 → 4.3.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 +83 -0
- package/README.md +12 -6
- package/bin/styleproof-capture.mjs +135 -45
- package/bin/styleproof-diff.mjs +86 -29
- package/dist/capture-url.d.ts +3 -0
- package/dist/capture-url.js +4 -0
- package/dist/crawl-surfaces.d.ts +6 -0
- package/dist/crawl-surfaces.js +23 -5
- package/dist/crawl.d.ts +17 -0
- package/dist/crawl.js +1 -1
- package/dist/diff.d.ts +1 -0
- package/dist/diff.js +13 -6
- package/dist/map-store.js +30 -12
- package/dist/report.js +156 -33
- package/dist/runner.js +8 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,84 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [4.3.0] - 2026-07-13
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- **A removed surface now blocks (exit 1) instead of riding the exit-3 "only new
|
|
15
|
+
surfaces" path.** A surface captured only on the base side is a deleted route
|
|
16
|
+
or a dropped width — a reviewable change, printed as `✗ REMOVED surface` —
|
|
17
|
+
never an onboarding case the approve-all box waves through under a "new
|
|
18
|
+
surfaces" banner. Exit 3 now means only NEW (head-side-only) surfaces.
|
|
19
|
+
- **Stale acknowledgements gate everywhere.** A stale `allowRemoved` entry in
|
|
20
|
+
`styleproof.inventory.json` now blocks like a stale data-residue
|
|
21
|
+
acknowledgement (and the Action's inventory hard-gate counts both), so a
|
|
22
|
+
ledger entry left behind cannot pre-acknowledge the next removal of that key.
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- **Crawls never capture an off-origin redirect target.** A same-origin link
|
|
27
|
+
that 302s to another origin (SSO, `/out?url=…`) previously entered the map as
|
|
28
|
+
a surface — third-party, nondeterministic content. The CLI sweep now skips the
|
|
29
|
+
page loudly and continues; a spec-driven capture fails naming the surface; an
|
|
30
|
+
entry URL that redirects off-origin is a hard error.
|
|
31
|
+
- **The gate now surfaces what it did NOT compare.** The diff prints (and
|
|
32
|
+
`--json` carries) the count of auto-detected volatile subtrees excluded from
|
|
33
|
+
the comparison, and warns when the forced-state layer was skipped on BOTH
|
|
34
|
+
sides — previously a head-side volatile region or a twice-skipped state layer
|
|
35
|
+
could hide a real change with zero trace in the output.
|
|
36
|
+
- **A corrupt coverage ledger is a hard error (exit 2), not a silent disarm.**
|
|
37
|
+
Unparseable `styleproof-coverage.json` used to read as "no registry", quietly
|
|
38
|
+
turning the coverage, determinism, and residue gates into warnings at once.
|
|
39
|
+
The residue footer also no longer misattributes a ledger-less bundle to the
|
|
40
|
+
`dataResidue: "warn"` opt-out.
|
|
41
|
+
- **The Action fails closed on unexpected diff exit codes.** A node crash, OOM
|
|
42
|
+
kill, or missing file (127/137/143/…) previously fell through to
|
|
43
|
+
`changed=false` — a green "No visual changes" status for a run that never
|
|
44
|
+
compared anything.
|
|
45
|
+
- **Approval can no longer wave through certification failures.** The Action
|
|
46
|
+
gained a post-approval hard gate (like the inventory gate) for incomplete
|
|
47
|
+
coverage, unproven determinism, and armed data-residue failures — none of
|
|
48
|
+
which are restyles the approve-all checkbox should clear.
|
|
49
|
+
- Stale `exclude` entries are printed with the coverage verdict, and "coverage
|
|
50
|
+
complete" now says "captured or explicitly excluded".
|
|
51
|
+
|
|
52
|
+
## [4.2.0] - 2026-07-13
|
|
53
|
+
|
|
54
|
+
### Changed
|
|
55
|
+
|
|
56
|
+
- **`styleproof-capture --crawl` now follows same-origin nav links.** Every
|
|
57
|
+
page the site links to is crawled like the entry page, keyed by its route
|
|
58
|
+
(`about`, `pricing`, …), with class coverage aggregated across the pages that
|
|
59
|
+
share stylesheets. Previously the CLI crawl drove controls but silently
|
|
60
|
+
dropped links, so a multi-page site reported "1/1 surfaces, coverage ✓" while
|
|
61
|
+
losing every other page. `--no-follow-links` restores the entry-page-only
|
|
62
|
+
sweep.
|
|
63
|
+
|
|
64
|
+
### Fixed
|
|
65
|
+
|
|
66
|
+
- **A non-head checkout in CI is no longer stamped with the pull request's head
|
|
67
|
+
SHA.** `currentGitSha` now trusts `git rev-parse HEAD` and relabels only a
|
|
68
|
+
checkout of the synthetic `GITHUB_SHA` merge commit, so a base-branch capture
|
|
69
|
+
(the scaffolded cache-miss job) can never publish a base-tree map under the
|
|
70
|
+
head's store key — a store poisoning that made later restores diff
|
|
71
|
+
base-vs-base and report a false green. `pull_request_target` payloads are no
|
|
72
|
+
longer trusted for relabeling at all, and a malformed `STYLEPROOF_SHA`
|
|
73
|
+
override now errors instead of silently falling back.
|
|
74
|
+
- **Annotation move-suppression now requires provable displacement.** A
|
|
75
|
+
cross-path annotation match may suppress its magenta boxes only when the
|
|
76
|
+
container where the two paths diverge gained or lost captured children, or
|
|
77
|
+
when the element slid into a vacated slot in its own container. This fixes
|
|
78
|
+
three truthfulness bugs at once: a size-changing duplicate restyle swap no
|
|
79
|
+
longer loses all visual proof, a sibling insertion into a uniform-shell list
|
|
80
|
+
(menus, navs) no longer re-boxes every unchanged displaced item, and an
|
|
81
|
+
independent removal + identical addition in different containers no longer
|
|
82
|
+
cancel each other's annotations to zero.
|
|
83
|
+
- **`styleproof-diff` no longer misreports fresh ad-hoc captures as predating
|
|
84
|
+
the determinism ledger.** The unknown-basis warning now names the real cause:
|
|
85
|
+
ad-hoc `styleproof-capture` output records no ledger; spec-driven captures
|
|
86
|
+
self-check and do.
|
|
87
|
+
|
|
10
88
|
## [4.1.0] - 2026-07-12
|
|
11
89
|
|
|
12
90
|
### Fixed
|
|
@@ -21,6 +99,11 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
|
|
|
21
99
|
and published report links point to the immutable report commit.
|
|
22
100
|
- **Certify-mode report comments now carry their source head marker**, so stale
|
|
23
101
|
comments can be distinguished from the current PR head.
|
|
102
|
+
- **Annotation reconciliation preserves duplicate additions, removals, and
|
|
103
|
+
forced-state changes.** Matching is one-to-one within a structural
|
|
104
|
+
neighborhood, and indistinguishable duplicate provenance is reported as a
|
|
105
|
+
deterministic unmatched occurrence rather than guessed. Ambiguous duplicate
|
|
106
|
+
restyle swaps retain visual proof on both sides.
|
|
24
107
|
|
|
25
108
|
## [4.0.2] - 2026-07-11
|
|
26
109
|
|
package/README.md
CHANGED
|
@@ -217,11 +217,15 @@ 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
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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.
|
|
225
229
|
|
|
226
230
|
When one change appears both on an ordinary page and in an open popup, the report
|
|
227
231
|
chooses a representative where the changed element is visibly exposed, then prefers
|
|
@@ -757,6 +761,8 @@ You watch one number as you implement: the diff starts large and shrinks toward
|
|
|
757
761
|
|
|
758
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.
|
|
759
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
|
+
|
|
760
766
|
```bash
|
|
761
767
|
styleproof-capture https://example.com --crawl --out design # maps every reachable surface
|
|
762
768
|
styleproof-diff design .styleproof/maps/current # diff the whole surface vs your build
|
|
@@ -1007,7 +1013,7 @@ Non-visual and framework-injected elements (`<meta>`/`<title>`/`<script>`/`<styl
|
|
|
1007
1013
|
|
|
1008
1014
|
- `styleproof-init` — scaffold the gate: the capture spec (inventory guard on; Next.js repos get route discovery + the coverage guard, others a crawl-by-default spec), a dedicated `playwright.styleproof.config.ts` (production-build `webServer`, parallel capture), `.gitignore` cache entries, the cache-first report workflow, the approval workflow, and the pre-push publish hook. One command. Generated commands follow the repo's lockfile (`bun.lock`/`bun.lockb`, `pnpm-lock.yaml`, `yarn.lock`, or npm by default), respect pnpm/Corepack version pins, and detect Vite/Next production preview commands instead of assuming every repo has `start`.
|
|
1009
1015
|
- `styleproof-map` — capture the current commit's computed-style map through Playwright. By default it writes `.styleproof/maps/current`, keeps screenshots for reports, writes a manifest, and uploads to `styleproof-maps` outside CI when the working tree was clean and a git remote exists. Pass `--crawl-base-url` plus repeated `--crawl-route` to run `styleproof-variants` before capture, `--no-upload`, `--restore --sha <commit>`, `--spec`, `--dir`, `--base-dir`, or `--no-screenshots` for custom flows.
|
|
1010
|
-
- `styleproof-diff` — the certify gate. With no args, it restores cached maps for the current commit and inferred base (`GITHUB_BASE_REF`, `branch.<name>.gh-merge-base`, `gh pr view`, then main/master fallbacks); `styleproof-diff main` / `styleproof-diff master` pins the base; `styleproof-diff <beforeDir> <afterDir>` keeps the manual two-directory form for CI fallback captures. Exits `0` certified (identical); `1` on a reviewable diff — computed-style/DOM/state differences, and equally an unacknowledged inventory removal, an unacknowledged failing data endpoint under an armed `dataResidue: 'gate'`, an incomplete coverage registry, or an unproven-determinism capture; `2` on a usage/capture error (including a **manifest-less side** — since **v4**, a two-directory compare where a side ships maps but no `styleproof-manifest.json` is refused loudly, naming the bare side(s), because the same-environment guard can't be enforced without one; re-capture with current StyleProof; **and** a **missing map** — a bundle that claims to exist yet holds zero captures, i.e. a `styleproof-manifest.json` present with no maps, on either side, or a head capture that produced nothing; refused loudly rather than mislabelled as all-new — **and** the no-args case where the cached base map can't be restored at all: no map-store remote, no cached bundle, nothing to compare. A "nothing was compared" outcome always exits `2`, never a soft `0` that would read as certified; the error names the two ways forward — run in CI where the base is restorable, or use the two-directory form); `3` when only new surfaces are present (no baseline for _those_ surfaces to diff against — new surfaces against an existing baseline, or a base dir with no maps at all (and hence no manifest), meaning no baseline was ever captured: the first-adoption review path; approval policy decides whether to gate). A clean run prints `0 changed surfaces across N captured surface(s)`, and `--json` includes `compared`. The human output **groups the same way the report does**: surfaces that changed identically collapse into one finding (with the per-surface count on its header), longhands fold into shorthands, and size/position-derived longhands fold behind a `(+N derived longhands)` count — so one real change reads as one entry, not dozens of raw lines. A change that rode the shared frame every view draws (a persistent nav/header/footer) is promoted to a "🧱 Global chrome change" callout up top. `--json` stays the complete, unchanged machine contract — every surface and every raw longhand — regardless of the human grouping.
|
|
1016
|
+
- `styleproof-diff` — the certify gate. With no args, it restores cached maps for the current commit and inferred base (`GITHUB_BASE_REF`, `branch.<name>.gh-merge-base`, `gh pr view`, then main/master fallbacks); `styleproof-diff main` / `styleproof-diff master` pins the base; `styleproof-diff <beforeDir> <afterDir>` keeps the manual two-directory form for CI fallback captures. Exits `0` certified (identical); `1` on a reviewable diff — computed-style/DOM/state differences, and equally an unacknowledged inventory removal, an unacknowledged failing data endpoint under an armed `dataResidue: 'gate'`, an incomplete coverage registry, or an unproven-determinism capture; `2` on a usage/capture error (including a **manifest-less side** — since **v4**, a two-directory compare where a side ships maps but no `styleproof-manifest.json` is refused loudly, naming the bare side(s), because the same-environment guard can't be enforced without one; re-capture with current StyleProof; **and** a **missing map** — a bundle that claims to exist yet holds zero captures, i.e. a `styleproof-manifest.json` present with no maps, on either side, or a head capture that produced nothing; refused loudly rather than mislabelled as all-new — **and** the no-args case where the cached base map can't be restored at all: no map-store remote, no cached bundle, nothing to compare. A "nothing was compared" outcome always exits `2`, never a soft `0` that would read as certified; the error names the two ways forward — run in CI where the base is restorable, or use the two-directory form); `3` when only new surfaces are present — surfaces captured only on the **head** side (a surface present only on the **base** side is a **removed** surface, a reviewable change: exit `1`) — (no baseline for _those_ surfaces to diff against — new surfaces against an existing baseline, or a base dir with no maps at all (and hence no manifest), meaning no baseline was ever captured: the first-adoption review path; approval policy decides whether to gate). A clean run prints `0 changed surfaces across N captured surface(s)`, and `--json` includes `compared`. The human output **groups the same way the report does**: surfaces that changed identically collapse into one finding (with the per-surface count on its header), longhands fold into shorthands, and size/position-derived longhands fold behind a `(+N derived longhands)` count — so one real change reads as one entry, not dozens of raw lines. A change that rode the shared frame every view draws (a persistent nav/header/footer) is promoted to a "🧱 Global chrome change" callout up top. `--json` stays the complete, unchanged machine contract — every surface and every raw longhand — regardless of the human grouping.
|
|
1011
1017
|
- `styleproof-report` — render the diff to a Markdown report with before/after crops. With no args, it reports cached maps for the current commit against the inferred base; `styleproof-report main` / `styleproof-report master` pins the base; `styleproof-report <beforeDir> <afterDir> --out <dir>` keeps the manual two-directory form. Add `--include-content` for the opt-in, advisory content section (see above).
|
|
1012
1018
|
- `styleproof-capture` — one-shot capture of any URL (no spec): `styleproof-capture <url> --key <name> --out <dir>`, with `--widths` (omit to auto-detect `@media` bands), `--wait <selector>`, `--ignore <selector>`, `--no-screenshots`, and the crawler flags (`--crawl`, `--setup <file>`, `--require-full-coverage` → exit 4 on residue, `--until-covered`, `--workers <n>`, `--no-data-states`) described in [Match a design pixel-for-pixel](#match-a-design-pixel-for-pixel).
|
|
1013
1019
|
- `styleproof-variants` — crawl a running app for one-step state variants and write `styleproof.variants.generated.json`. Pass `--base-url`, repeat `--route`, and use `--strict` when unresolved skipped/live candidates should fail automation.
|
|
@@ -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,147 @@ 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
|
+
|
|
193
|
+
// Crawl one page of the sweep. The entry page failing is a broken run (rethrow);
|
|
194
|
+
// a LINKED page failing (e.g. an off-origin redirect) returns null after warning,
|
|
195
|
+
// and the sweep continues.
|
|
196
|
+
async function crawlPage(browser, page, url, prefix, statesLeft) {
|
|
197
|
+
try {
|
|
198
|
+
return await crawlAndCapture(page, pageCrawlOptions(browser, url, prefix, statesLeft));
|
|
199
|
+
} catch (e) {
|
|
200
|
+
if (prefix === '') throw e;
|
|
201
|
+
console.log(`⚠ ${url}: ${e instanceof Error ? e.message : String(e)} — page skipped`);
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
98
206
|
async function runCrawl() {
|
|
99
207
|
const browser = await chromium.launch();
|
|
100
208
|
try {
|
|
101
209
|
const page = await browser.newPage();
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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'}`),
|
|
210
|
+
// Page-level breadth-first sweep: crawl the entry page's whole interactive
|
|
211
|
+
// surface, then every same-origin page its nav links to (and theirs), each
|
|
212
|
+
// namespaced by its route key so a shared --out directory never collides.
|
|
213
|
+
const entry = new URL(opts.url);
|
|
214
|
+
const sweep = {
|
|
215
|
+
entry,
|
|
216
|
+
queue: [{ url: opts.url, prefix: '' }],
|
|
217
|
+
seenPages: new Set([dedupIdentity(entry.pathname + entry.search)]),
|
|
218
|
+
usedPrefixes: new Set(['base']), // 'base' is the entry crawl's root key
|
|
124
219
|
};
|
|
125
|
-
const
|
|
220
|
+
const reports = [];
|
|
221
|
+
let statesLeft = opts.maxStates;
|
|
222
|
+
|
|
223
|
+
while (sweep.queue.length > 0 && statesLeft > 0) {
|
|
224
|
+
const { url, prefix } = sweep.queue.shift();
|
|
225
|
+
const report = await crawlPage(browser, page, url, prefix, statesLeft);
|
|
226
|
+
if (!report) continue; // linked page skipped loudly (e.g. off-origin redirect)
|
|
227
|
+
reports.push(report);
|
|
228
|
+
statesLeft -= report.surfaces.length;
|
|
229
|
+
if (opts.followLinks) enqueueLinkedPages(await harvestPageLinks(page, url), sweep);
|
|
230
|
+
}
|
|
231
|
+
if (sweep.queue.length > 0)
|
|
232
|
+
console.log(`⚠ --max-states reached: ${sweep.queue.length} linked page(s) left uncrawled — raise --max-states`);
|
|
233
|
+
|
|
126
234
|
// Stamp a manifest so a two-directory diff against this crawl output has the
|
|
127
235
|
// same-environment guard on both sides (v4 refuses a manifest-less side).
|
|
128
236
|
writeCaptureManifest({ dir: opts.out, screenshots: opts.screenshots });
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
}
|
|
237
|
+
printCrawlSummary(reports);
|
|
238
|
+
const cov = aggregateCoverage(reports);
|
|
239
|
+
printCoverage(cov, reports.length > 1 ? ` (${reports.length} pages)` : '');
|
|
150
240
|
// Residue under --require-full-coverage → exit 4: a never-seen class OR an
|
|
151
241
|
// 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);
|
|
242
|
+
if (opts.requireFullCoverage && (cov.missing.length > 0 || cov.unreadable.length > 0)) process.exit(4);
|
|
153
243
|
} finally {
|
|
154
244
|
await browser.close();
|
|
155
245
|
}
|
package/bin/styleproof-diff.mjs
CHANGED
|
@@ -20,7 +20,8 @@
|
|
|
20
20
|
*
|
|
21
21
|
* Custom properties (--*) are ignored: they are inputs, not outcomes (see
|
|
22
22
|
* README). Exit code 0 = identical, 1 = reviewable differences, 2 = usage/capture
|
|
23
|
-
* error, 3 = only
|
|
23
|
+
* error, 3 = only NEW surfaces (present only on the head side, no baseline to diff
|
|
24
|
+
* against). A REMOVED surface (present only on the base side) is a change: exit 1.
|
|
24
25
|
*/
|
|
25
26
|
import fs from 'node:fs';
|
|
26
27
|
import path from 'node:path';
|
|
@@ -109,12 +110,15 @@ function printInventoryAudit(audit) {
|
|
|
109
110
|
);
|
|
110
111
|
}
|
|
111
112
|
for (const it of delta.added) console.log(` + added: ${it.key} ("${it.label}")`);
|
|
112
|
-
for (const k of staleAllowances)
|
|
113
|
+
for (const k of staleAllowances)
|
|
114
|
+
console.log(` ✗ stale allowRemoved (key is not actually removed): ${k} — prune it from styleproof.inventory.json`);
|
|
113
115
|
if (unexplained.length)
|
|
114
116
|
console.log(
|
|
115
117
|
` → ${unexplained.length} unacknowledged removal(s): restore the affordance, or record the decision in styleproof.inventory.json {"<key>":"<why>"}.`,
|
|
116
118
|
);
|
|
117
|
-
|
|
119
|
+
// A stale allowance BLOCKS like a stale residue acknowledgement: left in place it
|
|
120
|
+
// pre-acknowledges the NEXT removal of that key, so the ledger must not rot.
|
|
121
|
+
return unexplained.length + staleAllowances.length;
|
|
118
122
|
}
|
|
119
123
|
|
|
120
124
|
// ── data-residue guard (gate by default) ─────────────────────────────────────────
|
|
@@ -139,11 +143,11 @@ function loadAcknowledgedResidue() {
|
|
|
139
143
|
// Audit the HEAD bundle's residue against the ack ledger, carrying whether the head
|
|
140
144
|
// ledger armed the gate. Returns null when no captured map carried residue AND the
|
|
141
145
|
// gate wasn't armed — so a clean healthy run prints/gates nothing (byte-identical).
|
|
142
|
-
function readResidueAudit(dirB, armed) {
|
|
146
|
+
function readResidueAudit(dirB, armed, hasLedger) {
|
|
143
147
|
const headResidue = readResidue(dirB);
|
|
144
148
|
if (!armed && !headResidue.some((m) => m.dataResidue?.length)) return null;
|
|
145
149
|
const acknowledged = loadAcknowledgedResidue();
|
|
146
|
-
return { acknowledged, ...auditRunResidue(headResidue, acknowledged, armed) };
|
|
150
|
+
return { acknowledged, hasLedger, ...auditRunResidue(headResidue, acknowledged, armed) };
|
|
147
151
|
}
|
|
148
152
|
|
|
149
153
|
// Print the Data-residue section; return the count of UNACKNOWLEDGED failing endpoints
|
|
@@ -155,17 +159,20 @@ function residueLine(r, ackReason, armed) {
|
|
|
155
159
|
return ` ${armed ? '✗ ' : '⚠ '}${r.surface} · ${r.endpoint} (${r.reason})${armed ? ', unacknowledged' : ''}`;
|
|
156
160
|
}
|
|
157
161
|
|
|
158
|
-
/** The action footer: the gate (default) names the remedy; warn mode is the opt-out.
|
|
159
|
-
|
|
162
|
+
/** The action footer: the gate (default) names the remedy; warn mode is the opt-out.
|
|
163
|
+
* A bundle with NO ledger at all is named as such — not misattributed to the opt-out. */
|
|
164
|
+
function residueFooter(armed, unacknowledgedCount, hasLedger) {
|
|
160
165
|
if (!unacknowledgedCount) return null;
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
166
|
+
if (armed)
|
|
167
|
+
return ` → ${unacknowledgedCount} unacknowledged failing endpoint(s): fixture each (page.route / liveStates), acknowledge intentional ones in styleproof.data-residue.json {"<key>":"<why>"}, or opt down with \`dataResidue: "warn"\` in the capture spec.`;
|
|
168
|
+
if (!hasLedger)
|
|
169
|
+
return ' → recorded and warned — the head bundle carries no coverage ledger (ad-hoc or pre-3.10 capture), so the residue gate cannot arm. A spec-driven capture records the ledger and gates by default.';
|
|
170
|
+
return ' → recorded and warned (dataResidue: "warn" — the opt-out). Remove it to restore the default gate that BLOCKS on these.';
|
|
164
171
|
}
|
|
165
172
|
|
|
166
173
|
function printResidueAudit(audit) {
|
|
167
174
|
if (!audit) return 0;
|
|
168
|
-
const { residue, unacknowledged, staleAcknowledgements, armed } = audit;
|
|
175
|
+
const { residue, unacknowledged, staleAcknowledgements, armed, hasLedger } = audit;
|
|
169
176
|
if (!residue.length && !staleAcknowledgements.length) {
|
|
170
177
|
console.log('\n🩹 Data residue: no failing data-boundary request during capture');
|
|
171
178
|
return 0;
|
|
@@ -174,7 +181,7 @@ function printResidueAudit(audit) {
|
|
|
174
181
|
for (const r of residue) console.log(residueLine(r, audit.acknowledged[r.key], armed));
|
|
175
182
|
for (const k of staleAcknowledgements)
|
|
176
183
|
console.log(` ⚠ stale acknowledgement (endpoint no longer failing/present): ${k}`);
|
|
177
|
-
const footer = residueFooter(armed, unacknowledged.length);
|
|
184
|
+
const footer = residueFooter(armed, unacknowledged.length, hasLedger);
|
|
178
185
|
if (footer) console.log(footer);
|
|
179
186
|
// Only an ARMED gate blocks; warn-mode surfaces without gating. A stale acknowledgement
|
|
180
187
|
// always blocks when armed, so the ledger can't rot (mirrors the `exclude` guard).
|
|
@@ -204,14 +211,22 @@ function readLedger(dir) {
|
|
|
204
211
|
try {
|
|
205
212
|
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
206
213
|
} catch {
|
|
207
|
-
|
|
214
|
+
// A ledger that EXISTS but cannot be parsed is tampering or truncation, and
|
|
215
|
+
// reading it as "no registry" would silently disarm the coverage,
|
|
216
|
+
// determinism, AND residue gates at once. Fail loud instead.
|
|
217
|
+
console.error(
|
|
218
|
+
`${COMMAND}: corrupt coverage ledger: ${p} — recapture the bundle; refusing to compare with disarmed gates.`,
|
|
219
|
+
);
|
|
220
|
+
process.exit(2);
|
|
208
221
|
}
|
|
209
222
|
}
|
|
210
223
|
|
|
211
224
|
// Print the completeness verdict; return true if it BLOCKS (a registered surface is missing).
|
|
212
225
|
function printCoverageVerdict(v) {
|
|
213
226
|
if (v.basis === 'complete') {
|
|
214
|
-
console.log(`\n✓ coverage complete — all ${v.registrySize} registered surface(s) captured`);
|
|
227
|
+
console.log(`\n✓ coverage complete — all ${v.registrySize} registered surface(s) captured or explicitly excluded`);
|
|
228
|
+
for (const k of v.staleExclusions ?? [])
|
|
229
|
+
console.log(` ⚠ stale exclude (not in the registry): ${k} — prune it from the spec`);
|
|
215
230
|
return false;
|
|
216
231
|
}
|
|
217
232
|
if (v.basis === 'unasserted') {
|
|
@@ -238,7 +253,13 @@ function printDeterminismVerdict(v) {
|
|
|
238
253
|
return false;
|
|
239
254
|
}
|
|
240
255
|
if (v.status === 'unknown') {
|
|
241
|
-
|
|
256
|
+
// No ledger at all = an ad-hoc `styleproof-capture` output (which doesn't
|
|
257
|
+
// self-check) or a pre-3.10 bundle; a spec capture records the basis.
|
|
258
|
+
console.log(
|
|
259
|
+
'\n⚠ determinism basis unknown — a side carries no determinism ledger (an ad-hoc styleproof-capture\n' +
|
|
260
|
+
' output, or a capture from before the ledger existed). A spec-driven capture (styleproof-map)\n' +
|
|
261
|
+
' self-checks and records it; ad-hoc captures are compared as-is.',
|
|
262
|
+
);
|
|
242
263
|
return false;
|
|
243
264
|
}
|
|
244
265
|
console.log(
|
|
@@ -265,7 +286,8 @@ options:
|
|
|
265
286
|
-h, --help show this help
|
|
266
287
|
|
|
267
288
|
exit: 0 identical (certified), 1 differences found, 2 usage/capture error,
|
|
268
|
-
3 only
|
|
289
|
+
3 only NEW surfaces (present only on the head side, no baseline to diff
|
|
290
|
+
against); a REMOVED surface (present only on the base side) exits 1
|
|
269
291
|
`;
|
|
270
292
|
|
|
271
293
|
const argv = process.argv.slice(2);
|
|
@@ -353,7 +375,7 @@ try {
|
|
|
353
375
|
determinismVerdict = auditDeterminism(readLedger(dirA), headLedger);
|
|
354
376
|
// Data-residue: the head bundle's failing data endpoints, gated only if its ledger
|
|
355
377
|
// armed `dataResidue: 'gate'`. Same "read while the dirs exist" rule as the ledgers.
|
|
356
|
-
residueAudit = readResidueAudit(dirB, headLedger?.dataResidue === 'gate');
|
|
378
|
+
residueAudit = readResidueAudit(dirB, headLedger?.dataResidue === 'gate', headLedger != null);
|
|
357
379
|
// Element-path sets per surface, for the shared-chrome tier — same "read while
|
|
358
380
|
// the dirs exist" rule as the ledgers above.
|
|
359
381
|
surfacePaths = surfaceElementPaths(dirA, dirB);
|
|
@@ -363,7 +385,7 @@ try {
|
|
|
363
385
|
} finally {
|
|
364
386
|
cleanupCachedCaptureDirs(cacheCapture);
|
|
365
387
|
}
|
|
366
|
-
const { surfaces, counts, compared } = result;
|
|
388
|
+
const { surfaces, counts, compared, volatile, statesUncertified } = result;
|
|
367
389
|
|
|
368
390
|
// ── grouped human output ─────────────────────────────────────────────────────
|
|
369
391
|
// Reuse the report's dedup so one real change doesn't print once per surface with
|
|
@@ -404,11 +426,17 @@ function elementLines(findings) {
|
|
|
404
426
|
return lines;
|
|
405
427
|
}
|
|
406
428
|
|
|
407
|
-
//
|
|
429
|
+
// One-sided surfaces keep their own line. The two directions are NOT the same
|
|
430
|
+
// verdict: only-in-after is a NEW surface (no baseline, review before
|
|
431
|
+
// baselining); only-in-before is a REMOVED surface — a route or width that
|
|
432
|
+
// existed and is gone, which is a change, never an onboarding case.
|
|
408
433
|
for (const sd of surfaces) {
|
|
409
434
|
if (!sd.missing) continue;
|
|
410
|
-
|
|
411
|
-
|
|
435
|
+
console.log(
|
|
436
|
+
sd.missing === 'before'
|
|
437
|
+
? `\n${sd.surface}: new surface — captured only in the after set, no baseline to compare`
|
|
438
|
+
: `\n${sd.surface}: ✗ REMOVED surface — captured only in the before set; the head no longer renders it`,
|
|
439
|
+
);
|
|
412
440
|
}
|
|
413
441
|
|
|
414
442
|
// Group the changed surfaces the way the report does, so an identical change
|
|
@@ -466,6 +494,13 @@ if (jsonOut) {
|
|
|
466
494
|
counts,
|
|
467
495
|
surfaces,
|
|
468
496
|
compared,
|
|
497
|
+
// Subtrees excluded from every layer of the comparison because a side
|
|
498
|
+
// auto-detected them as volatile (still mutating at capture settle).
|
|
499
|
+
// Changes inside them are NOT certified by this diff.
|
|
500
|
+
volatileExcluded: volatile,
|
|
501
|
+
// Surfaces whose forced-state layer was skipped on BOTH sides — the
|
|
502
|
+
// :hover/:focus/:active layer compared {} vs {} and certifies nothing.
|
|
503
|
+
statesUncertified,
|
|
469
504
|
coverage: coverageVerdict,
|
|
470
505
|
determinism: determinismVerdict,
|
|
471
506
|
// The inventory verdict, machine-readable — parallel to coverage/determinism and
|
|
@@ -508,26 +543,48 @@ if (jsonOut) {
|
|
|
508
543
|
}
|
|
509
544
|
|
|
510
545
|
const total = counts.dom + counts.style + counts.state;
|
|
511
|
-
const newSurfaces = surfaces.filter((s) => s.missing).length;
|
|
546
|
+
const newSurfaces = surfaces.filter((s) => s.missing === 'before').length;
|
|
547
|
+
const removedSurfaces = surfaces.filter((s) => s.missing === 'after').length;
|
|
512
548
|
// One SurfaceDiff per distinct surface across both sides (incl. missing-on-one-side).
|
|
513
549
|
const surfaceCount = surfaces.length;
|
|
550
|
+
if (volatile > 0)
|
|
551
|
+
console.log(
|
|
552
|
+
`\n⚠ ${volatile} auto-detected volatile subtree(s) excluded from the comparison (still mutating at capture\n` +
|
|
553
|
+
' settle) — changes inside them are NOT certified. Fixture the region, or `ignore` it deliberately.',
|
|
554
|
+
);
|
|
555
|
+
if (statesUncertified > 0)
|
|
556
|
+
console.log(
|
|
557
|
+
`\n⚠ forced-state layer uncertified on ${statesUncertified} surface(s): BOTH captures skipped it, so\n` +
|
|
558
|
+
' :hover/:focus/:active differences there were never compared.',
|
|
559
|
+
);
|
|
514
560
|
const newNote = newSurfaces ? ` (+${newSurfaces} new surface(s) with no baseline)` : '';
|
|
515
|
-
const
|
|
561
|
+
const removedNote = removedSurfaces ? ` + ${removedSurfaces} REMOVED surface(s)` : '';
|
|
562
|
+
const invNote = invRemovals ? ` + ${invRemovals} inventory gate failure(s) (unacknowledged or stale)` : '';
|
|
516
563
|
// residueFails counts unacknowledged failing endpoints AND stale acknowledgements (both gate).
|
|
517
564
|
const resNote = residueFails ? ` + ${residueFails} data-residue gate failure(s) (unacknowledged or stale)` : '';
|
|
518
565
|
const covNote = coverageFails ? ` + ${coverageVerdict.uncovered.length} uncaptured registered surface(s)` : '';
|
|
519
566
|
const detNote = determinismFails ? ' + determinism unproven' : '';
|
|
520
|
-
const clean =
|
|
567
|
+
const clean =
|
|
568
|
+
total === 0 &&
|
|
569
|
+
removedSurfaces === 0 &&
|
|
570
|
+
invRemovals === 0 &&
|
|
571
|
+
residueFails === 0 &&
|
|
572
|
+
!coverageFails &&
|
|
573
|
+
!determinismFails;
|
|
521
574
|
console.log(
|
|
522
575
|
clean
|
|
523
576
|
? newSurfaces === 0
|
|
524
577
|
? `\n✓ 0 changed surfaces across ${compared} captured surface(s): every computed style, pseudo-element, and hover/focus/active state matches`
|
|
525
578
|
: `\nℹ ${newSurfaces} new surface(s) captured with no baseline to compare — review before baselining`
|
|
526
|
-
: `\n✗ ${counts.dom} DOM change(s), ${counts.style} computed-style difference(s), ${counts.state} state-delta difference(s) across ${surfaceCount} surfaces${newNote}${invNote}${resNote}${covNote}${detNote}`,
|
|
579
|
+
: `\n✗ ${counts.dom} DOM change(s), ${counts.style} computed-style difference(s), ${counts.state} state-delta difference(s) across ${surfaceCount} surfaces${newNote}${removedNote}${invNote}${resNote}${covNote}${detNote}`,
|
|
527
580
|
);
|
|
528
|
-
// 0 = identical, 1 = reviewable differences (incl.
|
|
529
|
-
// unacknowledged
|
|
530
|
-
//
|
|
581
|
+
// 0 = identical, 1 = reviewable differences (incl. a REMOVED surface, inventory/residue gate
|
|
582
|
+
// failures — unacknowledged or stale — an incomplete coverage registry, or an
|
|
583
|
+
// unproven-determinism capture), 3 = ONLY new surfaces (no baseline). 2 = usage.
|
|
531
584
|
process.exit(
|
|
532
|
-
total > 0 ||
|
|
585
|
+
total > 0 || removedSurfaces > 0 || invRemovals > 0 || residueFails > 0 || coverageFails || determinismFails
|
|
586
|
+
? 1
|
|
587
|
+
: newSurfaces > 0
|
|
588
|
+
? 3
|
|
589
|
+
: 0,
|
|
533
590
|
);
|
package/dist/capture-url.d.ts
CHANGED
|
@@ -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
|
package/dist/capture-url.js
CHANGED
|
@@ -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++)
|
package/dist/crawl-surfaces.d.ts
CHANGED
|
@@ -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;
|
package/dist/crawl-surfaces.js
CHANGED
|
@@ -37,8 +37,12 @@ function pathAndSearch(url) {
|
|
|
37
37
|
return url;
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
|
-
function deriveKey(steps, used) {
|
|
41
|
-
const
|
|
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}`;
|
|
@@ -404,6 +408,14 @@ async function scrollReveal(page) {
|
|
|
404
408
|
async function gotoFresh(page, opts) {
|
|
405
409
|
await page.setViewportSize({ width: opts.widths[0] ?? 1280, height: opts.height });
|
|
406
410
|
await page.goto(opts.url, { waitUntil: 'load' });
|
|
411
|
+
// A same-origin URL can still 302 off-origin (SSO, /out?url=…). External
|
|
412
|
+
// content is nondeterministic and never belongs in a map — abort the crawl of
|
|
413
|
+
// this page rather than capture a third-party render as if it were the app.
|
|
414
|
+
const wanted = URL.canParse(opts.url) ? new URL(opts.url).origin : null; // relative url → origin unknowable
|
|
415
|
+
const landed = new URL(page.url()).origin;
|
|
416
|
+
if (wanted && landed !== wanted) {
|
|
417
|
+
throw new Error(`styleproof crawl: ${opts.url} redirected off-origin to ${landed} — external content is never captured`);
|
|
418
|
+
}
|
|
407
419
|
// Tolerant wait: the generic settle below is the real readiness signal; an
|
|
408
420
|
// optional waitSelector just accelerates it and must not fail the crawl.
|
|
409
421
|
const ready = opts.waitSelector ? page.locator(opts.waitSelector).first() : null;
|
|
@@ -500,7 +512,7 @@ function stateKey(steps) {
|
|
|
500
512
|
/** Record a newly-found surface, capture it in place (page is already there), and
|
|
501
513
|
* queue it for its own sweep. Streams progress via onSurface. */
|
|
502
514
|
async function record(page, opts, newPath, depth, fp, st, sink, retryOnly = false, viaRetry = false) {
|
|
503
|
-
const key = deriveKey(newPath, st.used);
|
|
515
|
+
const key = deriveKey(newPath, st.used, opts.keyPrefix ?? '');
|
|
504
516
|
const surface = { key, depth, path: newPath, elements: fp.elements };
|
|
505
517
|
st.surfaces.push(surface);
|
|
506
518
|
let addsVocab = false;
|
|
@@ -753,7 +765,7 @@ async function recordDataState(page, opts, mode, st) {
|
|
|
753
765
|
st.seen.add(fp.sig);
|
|
754
766
|
for (const c of fp.classes)
|
|
755
767
|
st.classes.add(c);
|
|
756
|
-
const key = deriveKey([{ action: 'click', selector: `(data:${mode})`, label: mode, reason: 'data-state' }], st.used);
|
|
768
|
+
const key = deriveKey([{ action: 'click', selector: `(data:${mode})`, label: mode, reason: 'data-state' }], st.used, opts.keyPrefix ?? '');
|
|
757
769
|
const surface = { key, depth: 0, path: [], elements: fp.elements };
|
|
758
770
|
st.surfaces.push(surface);
|
|
759
771
|
await captureAndReport(page, opts, surface, st);
|
|
@@ -894,7 +906,13 @@ async function discover(page, opts) {
|
|
|
894
906
|
skipped: counters.skipped,
|
|
895
907
|
captured: st.captured,
|
|
896
908
|
failed: st.failed,
|
|
897
|
-
coverage: {
|
|
909
|
+
coverage: {
|
|
910
|
+
defined: defined.length,
|
|
911
|
+
rendered: defined.length - missing.length,
|
|
912
|
+
missing,
|
|
913
|
+
unreadable,
|
|
914
|
+
renderedClasses: defined.filter((c) => st.classes.has(c)).sort(),
|
|
915
|
+
},
|
|
898
916
|
};
|
|
899
917
|
}
|
|
900
918
|
/**
|
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/diff.d.ts
CHANGED
|
@@ -87,6 +87,7 @@ export declare function diffStyleMapDirs(dirA: string, dirB: string): {
|
|
|
87
87
|
surfaces: SurfaceDiff[];
|
|
88
88
|
counts: DiffCounts;
|
|
89
89
|
volatile: number;
|
|
90
|
+
statesUncertified: number;
|
|
90
91
|
compared: number;
|
|
91
92
|
};
|
|
92
93
|
export declare function diffContentMaps(a: StyleMap, b: StyleMap): ContentChange[];
|
package/dist/diff.js
CHANGED
|
@@ -281,7 +281,7 @@ export function diffStyleMapDirs(dirA, dirB) {
|
|
|
281
281
|
throw new MissingHeadMapError();
|
|
282
282
|
const surfaces = [];
|
|
283
283
|
const counts = { dom: 0, style: 0, state: 0 };
|
|
284
|
-
|
|
284
|
+
const uncompared = { volatile: 0, statesUncertified: 0 };
|
|
285
285
|
for (const surface of names) {
|
|
286
286
|
if (!indexA[surface] || !indexB[surface]) {
|
|
287
287
|
// A surface present on only one side has no baseline to diff against — it's
|
|
@@ -291,15 +291,22 @@ export function diffStyleMapDirs(dirA, dirB) {
|
|
|
291
291
|
surfaces.push({ surface, missing: indexA[surface] ? 'after' : 'before', findings: [] });
|
|
292
292
|
continue;
|
|
293
293
|
}
|
|
294
|
-
const
|
|
295
|
-
const mapB = loadStyleMap(indexB[surface]);
|
|
296
|
-
volatile += new Set([...(mapA.volatile ?? []), ...(mapB.volatile ?? [])]).size;
|
|
297
|
-
const findings = diffStyleMaps(mapA, mapB);
|
|
294
|
+
const findings = diffSurfacePair(indexA[surface], indexB[surface], uncompared);
|
|
298
295
|
tallyCounts(findings, counts);
|
|
299
296
|
if (findings.length)
|
|
300
297
|
surfaces.push({ surface, findings });
|
|
301
298
|
}
|
|
302
|
-
return { surfaces, counts,
|
|
299
|
+
return { surfaces, counts, ...uncompared, compared: names.length };
|
|
300
|
+
}
|
|
301
|
+
/** Diff one paired surface, tallying what was NOT compared (volatile subtrees;
|
|
302
|
+
* a forced-state layer skipped on BOTH sides — {} vs {} certifies nothing). */
|
|
303
|
+
function diffSurfacePair(fileA, fileB, uncompared) {
|
|
304
|
+
const mapA = loadStyleMap(fileA);
|
|
305
|
+
const mapB = loadStyleMap(fileB);
|
|
306
|
+
uncompared.volatile += new Set([...(mapA.volatile ?? []), ...(mapB.volatile ?? [])]).size;
|
|
307
|
+
if (mapA.statesSkipped && mapB.statesSkipped)
|
|
308
|
+
uncompared.statesUncertified++;
|
|
309
|
+
return diffStyleMaps(mapA, mapB);
|
|
303
310
|
}
|
|
304
311
|
/**
|
|
305
312
|
* Diff the OPT-IN content layer: elements whose own rendered text changed.
|
package/dist/map-store.js
CHANGED
|
@@ -140,8 +140,11 @@ export function expectedCompatibilityKey(options = {}) {
|
|
|
140
140
|
}
|
|
141
141
|
export function currentGitSha(cwd = process.cwd(), env = process.env) {
|
|
142
142
|
const fromEvent = (() => {
|
|
143
|
-
|
|
144
|
-
|
|
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 ?? '')) {
|
|
145
148
|
return undefined;
|
|
146
149
|
}
|
|
147
150
|
try {
|
|
@@ -152,16 +155,31 @@ export function currentGitSha(cwd = process.cwd(), env = process.env) {
|
|
|
152
155
|
return undefined;
|
|
153
156
|
}
|
|
154
157
|
})();
|
|
155
|
-
// STYLEPROOF_SHA/GITHUB_HEAD_SHA are explicit overrides
|
|
156
|
-
//
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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>');
|
|
165
183
|
}
|
|
166
184
|
export function refSha(ref, cwd = process.cwd()) {
|
|
167
185
|
const sha = gitOutput(cwd, ['rev-parse', `${ref}^{commit}`]);
|
package/dist/report.js
CHANGED
|
@@ -48,47 +48,172 @@ 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
|
|
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;
|
|
52
57
|
const sortedPseudo = Object.fromEntries(Object.entries(entry.pseudo ?? {})
|
|
53
|
-
.sort(([left], [right]) => left.localeCompare(right))
|
|
54
|
-
.map(([pseudo, properties]) => [
|
|
55
|
-
|
|
56
|
-
Object.entries(properties).sort(([left], [right]) => left.localeCompare(right)),
|
|
57
|
-
]));
|
|
58
|
-
return JSON.stringify([
|
|
58
|
+
.sort(([left], [right]) => left.localeCompare(right, 'en'))
|
|
59
|
+
.map(([pseudo, properties]) => [pseudo, sortedProperties(properties)]));
|
|
60
|
+
return [
|
|
59
61
|
entry.tag,
|
|
60
62
|
entry.cls,
|
|
61
63
|
entry.rect?.[2] ?? null,
|
|
62
64
|
entry.rect?.[3] ?? null,
|
|
63
|
-
|
|
65
|
+
sortedProperties(entry.style),
|
|
64
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')),
|
|
65
98
|
]);
|
|
66
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
|
+
}
|
|
67
106
|
function indexAnnotationIdentities(map) {
|
|
68
107
|
const pathsByIdentity = new Map();
|
|
69
108
|
for (const [elementPath, entry] of Object.entries(map.elements)) {
|
|
70
|
-
const identity = annotationIdentity(entry);
|
|
109
|
+
const identity = annotationIdentity(map, elementPath, entry);
|
|
71
110
|
pathsByIdentity.set(identity, [...(pathsByIdentity.get(identity) ?? []), elementPath]);
|
|
72
111
|
}
|
|
73
112
|
return pathsByIdentity;
|
|
74
113
|
}
|
|
75
|
-
function
|
|
76
|
-
|
|
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))
|
|
77
164
|
return false;
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
//
|
|
89
|
-
//
|
|
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) {
|
|
90
215
|
if (finding.kind !== 'dom')
|
|
91
|
-
return
|
|
216
|
+
return { before: !beforeMoved, after: !afterMoved };
|
|
92
217
|
if (finding.change === 'removed')
|
|
93
218
|
return { before: !beforeMoved, after: false };
|
|
94
219
|
if (finding.change === 'added')
|
|
@@ -96,17 +221,15 @@ function annotationSides(finding, beforeMoved, afterMoved, reconcileMoved) {
|
|
|
96
221
|
return { before: true, after: true };
|
|
97
222
|
}
|
|
98
223
|
function annotationPaths(findings, beforeMap, afterMap) {
|
|
99
|
-
const
|
|
100
|
-
const afterPathsByIdentity = indexAnnotationIdentities(afterMap);
|
|
101
|
-
const reconcileMoved = findings.some((finding) => finding.kind === 'dom');
|
|
224
|
+
const matches = reconcileAnnotationPaths(beforeMap, afterMap);
|
|
102
225
|
const beforePaths = new Set();
|
|
103
226
|
const afterPaths = new Set();
|
|
104
227
|
for (const finding of findings) {
|
|
105
|
-
const
|
|
106
|
-
const
|
|
107
|
-
const beforeMoved =
|
|
108
|
-
const afterMoved =
|
|
109
|
-
const sides = annotationSides(finding, beforeMoved, afterMoved
|
|
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);
|
|
110
233
|
if (sides.before)
|
|
111
234
|
beforePaths.add(finding.path);
|
|
112
235
|
if (sides.after)
|
|
@@ -877,7 +1000,7 @@ function reportHeadline(args) {
|
|
|
877
1000
|
const candidates = liveCandidateLabels.length
|
|
878
1001
|
? ` Auto-detected live-state candidate(s): ${liveCandidateLabels.slice(0, 5).join('; ')}.`
|
|
879
1002
|
: '';
|
|
880
|
-
md.push('', `_${volatileCount} live region(s) auto-excluded as nondeterministic (a stream, ticker, or late-loading content) —
|
|
1003
|
+
md.push('', `_${volatileCount} live region(s) auto-excluded as nondeterministic (a stream, ticker, or late-loading content) — changes inside them are NOT certified by this check.${candidates}_`);
|
|
881
1004
|
}
|
|
882
1005
|
if (contentCount > 0 && (changeGroups.length > 0 || missing.length > 0)) {
|
|
883
1006
|
md.push('', `📝 _${contentCount} advisory content change(s) below — they don't affect the check._`);
|
package/dist/runner.js
CHANGED
|
@@ -815,10 +815,18 @@ export function defineCrawlCapture(options) {
|
|
|
815
815
|
: null;
|
|
816
816
|
if (gap)
|
|
817
817
|
throw new Error(gap);
|
|
818
|
+
// The nav's hrefs are same-origin by selection, but a link can still 302
|
|
819
|
+
// off-origin (SSO, /out?url=…). External content is nondeterministic and
|
|
820
|
+
// never belongs in a map, so the landing origin is verified per surface.
|
|
821
|
+
const entryOrigin = new URL(page.url()).origin;
|
|
818
822
|
const captureSurfaces = links.flatMap((link) => expandSurfaceVariants({
|
|
819
823
|
key: link.key,
|
|
820
824
|
go: async (p) => {
|
|
821
825
|
await p.goto(link.url, { waitUntil: 'load' });
|
|
826
|
+
const landed = new URL(p.url()).origin;
|
|
827
|
+
if (landed !== entryOrigin) {
|
|
828
|
+
throw new Error(`styleproof crawl: ${link.url} redirected off-origin to ${landed} — external content is never captured`);
|
|
829
|
+
}
|
|
822
830
|
if (settle)
|
|
823
831
|
await settle(p);
|
|
824
832
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "styleproof",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.3.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",
|