styleproof 3.19.0 → 3.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +77 -0
- package/README.md +20 -5
- package/bin/styleproof-init.mjs +49 -20
- package/dist/cli-errors.js +7 -2
- package/dist/crawl.js +16 -4
- package/dist/diff.js +35 -0
- package/dist/runner.js +109 -29
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,83 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [3.20.0] - 2026-07-07
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **Crawl no longer double-captures `/` and `/index.html` as two surfaces.** On a
|
|
15
|
+
static multi-page site whose nav links the `.html` files, `/` and `/index.html`
|
|
16
|
+
(and `/dir/` vs `/dir/index.html`) are the same route but were captured twice as
|
|
17
|
+
byte-near-identical maps — doubling the capture work and duplicating every finding
|
|
18
|
+
in the diff. The crawl's dedup identity now normalizes a trailing `index.html` to
|
|
19
|
+
its directory path, so they collapse to one surface (first-seen href keeps its
|
|
20
|
+
original navigable form). Only the literal `index.html` filename normalizes — a
|
|
21
|
+
genuine `about.html` stays a distinct surface from `about`.
|
|
22
|
+
- **`styleproof-init` now states exactly which files it wrote — and that it did NOT
|
|
23
|
+
touch `package.json` or your lockfile.** Adopters attributed the `styleproof`
|
|
24
|
+
dependency entry (added by their package manager's `install`) to init; init only
|
|
25
|
+
ever reads `package.json` and writes the spec, the dedicated Playwright config,
|
|
26
|
+
`.gitignore` lines, and the CI workflow. The summary now enumerates those files and
|
|
27
|
+
says plainly that the manifest and lockfile were left untouched.
|
|
28
|
+
- **The no-comparison outcome of `styleproof-diff` / `styleproof-report` names both
|
|
29
|
+
ways forward.** When the no-args (inferred-base) path can restore no base map — no
|
|
30
|
+
map-store remote, no cached bundle — nothing is compared. That already exits `2`
|
|
31
|
+
(never a soft `0` a newcomer could read as "certified clean"); the message now says
|
|
32
|
+
"nothing was compared" and names the two working alternatives: run in CI (or a repo
|
|
33
|
+
with the `origin` remote) where the base is restorable, or the two-directory form
|
|
34
|
+
`styleproof-diff <beforeDir> <afterDir>`. A regression test pins the exit-2 contract
|
|
35
|
+
in a remote-less repo for both commands.
|
|
36
|
+
- **README: the Next.js coverage guard is described accurately.** The docs conflated
|
|
37
|
+
two behaviors. With the auto-wired spec, `surfaces` and `expected` both derive from
|
|
38
|
+
the same `discoverNextRoutes()` call, so a new static route is captured and expected
|
|
39
|
+
together — **auto-covered, never a guard failure**. The guard **fails** only on
|
|
40
|
+
genuine divergence (a dynamic route, a hand-maintained registry, or a route dropped
|
|
41
|
+
from `surfaces` while still `expected`). Rewrote the overclaiming passages and the
|
|
42
|
+
generated-spec comments to state both behaviors.
|
|
43
|
+
- **Layout-equivalent margin suppression no longer drops a real one-sided margin
|
|
44
|
+
change.** `dropLayoutEquivalentMarginProps` suppressed any horizontal
|
|
45
|
+
`margin-left/right/inline-start/inline-end` change whenever the element's rect
|
|
46
|
+
was unchanged — reasoning that a margin that doesn't move the box is cosmetic
|
|
47
|
+
drift. But a one-sided change (e.g. `margin-left: 0 → 40px` with `margin-right`
|
|
48
|
+
untouched) that leaves the rect identical only stayed put because _something
|
|
49
|
+
else compensated_; that is a genuine restyle, and it was silently dropped. The
|
|
50
|
+
suppression now fires only when there is no **demonstrable px imbalance**
|
|
51
|
+
between a side and its opposite — balanced drift (both sides move together) and
|
|
52
|
+
forced-state deltas are still suppressed exactly as before, but a one-sided
|
|
53
|
+
real change surfaces. A residual, consciously-deferred corner remains (a
|
|
54
|
+
perfectly _balanced_ change held in place by external compensation), documented
|
|
55
|
+
inline; closing it needs cross-element layout reasoning.
|
|
56
|
+
- **`styleproof-init` no longer imports the whole library barrel (fixes a CI
|
|
57
|
+
flake).** The scaffolder only needs `discoverNextRoutes`, but it imported it
|
|
58
|
+
from `dist/index.js` — dragging capture, the crawler, the report renderer, and
|
|
59
|
+
six Playwright-importing modules into a tool that writes files and captures
|
|
60
|
+
nothing. Loading that oversized module graph concurrently (init's own suite
|
|
61
|
+
spawns the CLI many times, alongside the rest of `node --test`) is what made
|
|
62
|
+
init's tests flake in CI, red-flagging releases with no code cause. It now
|
|
63
|
+
imports from the `dist/routes.js` leaf (`fs` + `path` only): init's transitive
|
|
64
|
+
module graph drops from 21 dist modules to 1, with zero Playwright modules on
|
|
65
|
+
its load path. Behaviour is unchanged; a regression test pins the leaf import.
|
|
66
|
+
- **Popup capture: verified reset + identity-bound triggers (no leaked-overlay
|
|
67
|
+
contamination, no wrong-trigger keying).** On a surface whose `go()` doesn't
|
|
68
|
+
navigate (SPA variants), the between-popups reset was Escape-only and assumed:
|
|
69
|
+
a toast or `[role="status"]` overlay Escape can't dismiss leaked into the next
|
|
70
|
+
popup's capture, and each reopen re-enumerated triggers positionally, so a
|
|
71
|
+
shifted trigger set (e.g. a click that adds a button) could key a popup under a
|
|
72
|
+
different trigger than the one originally enumerated. Triggers are now re-bound
|
|
73
|
+
by the DOM identity recorded at first enumeration, and the reset is verified
|
|
74
|
+
against the surface's pristine overlay set; a candidate that can't be opened
|
|
75
|
+
safely is skipped loudly (a `styleproof:` warning naming the popup and the
|
|
76
|
+
leaked overlay or missing trigger) instead of being captured contaminated,
|
|
77
|
+
mis-keyed, or — with self-check on — saved unproven. That identity is the
|
|
78
|
+
trigger's DOM path **and** its accessible label, not the path alone: for an
|
|
79
|
+
id-less trigger the path ends in `:nth-of-type`, which is still position within
|
|
80
|
+
a parent, so a same-tag same-parent sibling injected earlier in DOM order
|
|
81
|
+
between enumeration and reopen would slide the recorded path onto a different
|
|
82
|
+
trigger and key its popup under the wrong one — silently. Requiring the label
|
|
83
|
+
(the same aria-label/name/text/title accessible name the crawler reads) to match
|
|
84
|
+
too turns that mismatch into the same loud skip. Navigating surfaces are
|
|
85
|
+
unaffected.
|
|
86
|
+
|
|
10
87
|
## [3.19.0] - 2026-07-06
|
|
11
88
|
|
|
12
89
|
### Added
|
package/README.md
CHANGED
|
@@ -57,8 +57,12 @@ surface. `expected` turns that inventory into a guard: a key it lists that is
|
|
|
57
57
|
neither captured nor excluded fails as missing coverage instead of silently
|
|
58
58
|
passing. What's guarded depends on how `expected` is fed —
|
|
59
59
|
|
|
60
|
-
- **Next.js:** auto-
|
|
61
|
-
|
|
60
|
+
- **Next.js:** auto-covered. `styleproof-init` derives both `surfaces` and
|
|
61
|
+
`expected` from the same `discoverNextRoutes()` call, so a new static route lands
|
|
62
|
+
in both at once — captured and expected together, with nothing to keep in sync.
|
|
63
|
+
The guard then fails only on genuine divergence between the two: a dynamic route
|
|
64
|
+
(pre-excluded, so its reason surfaces), a hand-maintained registry, or a route
|
|
65
|
+
dropped from `surfaces` while still in `expected`.
|
|
62
66
|
- **Link-crawled SPAs:** pass `expected` to `defineCrawlCapture` and the crawl
|
|
63
67
|
reconciles it against the _rendered nav_ (the route universe for such an app),
|
|
64
68
|
both directions — a new linked route with no `expected` entry fails, and an
|
|
@@ -152,7 +156,7 @@ _and_ no head capture, so it never appears in any diff, and the status goes
|
|
|
152
156
|
green having never looked at it. This is the one thing the captures can't catch
|
|
153
157
|
on their own: a capture that was never taken.
|
|
154
158
|
|
|
155
|
-
Declare your app's route/view universe in `expected` and StyleProof emits a coverage-guard test in your **normal** suite (it runs even without `STYLEMAP_DIR` — it's a static check, no browser). It fails
|
|
159
|
+
Declare your app's route/view universe in `expected` and StyleProof emits a coverage-guard test in your **normal** suite (it runs even without `STYLEMAP_DIR` — it's a static check, no browser). It fails when `expected` and your captured surfaces diverge — a route you listed in `expected` with no surface and no `exclude` entry fails as missing coverage, so a registry entry can't quietly ship uncaptured:
|
|
156
160
|
|
|
157
161
|
```ts
|
|
158
162
|
import { defineStyleMapCapture } from 'styleproof';
|
|
@@ -168,7 +172,7 @@ defineStyleMapCapture({
|
|
|
168
172
|
|
|
169
173
|
A route that's neither a captured surface nor an `exclude` entry fails the guard; an `exclude` key that isn't in `expected` (a renamed/removed route) fails too, so the opt-out ledger can't quietly rot. Captured surfaces beyond `expected` are fine — one route can have several states (`landing`, `landing-nav-open`). Omit `expected` and behaviour is unchanged.
|
|
170
174
|
|
|
171
|
-
**Next.js: wired for you.** Run `styleproof-init` in a Next.js project and the generated spec discovers your routes (App Router `app/` + Pages Router `pages/`) at run time and
|
|
175
|
+
**Next.js: wired for you.** Run `styleproof-init` in a Next.js project and the generated spec discovers your routes (App Router `app/` + Pages Router `pages/`) at run time and derives **both** the surfaces and `expected` from that same `discoverNextRoutes()` call. Because they share one source, a static route you add later is captured and expected in the same step — auto-covered, never a guard failure, with nothing to keep in sync. The guard exists for the cases where the two genuinely diverge: a dynamic `[param]` route (it can't be navigated without a value, so it's placed in `exclude` with a reason rather than captured), a registry you hand-maintain instead of the live call, or a route you drop from `surfaces` while it's still `expected`:
|
|
172
176
|
|
|
173
177
|
```ts
|
|
174
178
|
import { defineStyleMapCapture, discoverNextRoutes } from 'styleproof';
|
|
@@ -292,6 +296,17 @@ semantic roots that were actually present in the computed-style map, so tests ca
|
|
|
292
296
|
assert a capture reached `role="dialog"`, `aria-modal`, `role="menu"`,
|
|
293
297
|
`role="listbox"`, or hot-toast text.
|
|
294
298
|
|
|
299
|
+
Triggers are enumerated once per surface and every reopen re-binds to that same
|
|
300
|
+
element by identity — its DOM path **and** its accessible label — never by
|
|
301
|
+
position. Between popups the surface is reset (Escape + `go()`) and the reset is
|
|
302
|
+
verified: if an overlay a previous popup left behind is still visible (Escape
|
|
303
|
+
closes dialogs, not toasts or status regions), or an enumerated trigger
|
|
304
|
+
disappeared or changed identity (e.g. a same-tag sibling shifted in earlier),
|
|
305
|
+
that candidate is **skipped loudly** — a `styleproof:` warning names the popup and
|
|
306
|
+
why — instead of capturing contaminated state or keying a popup under the wrong
|
|
307
|
+
trigger. Dismiss the leaking overlay in the surface's `go()`, or capture it as an
|
|
308
|
+
explicit variant.
|
|
309
|
+
|
|
295
310
|
**Harvest one-step variants.** Routes are not the whole UI: drawers, tabs,
|
|
296
311
|
dialogs, empty form errors, selects, and other one-step states need their own
|
|
297
312
|
captures. `styleproof-variants` opens a running app, tries semantic controls
|
|
@@ -851,7 +866,7 @@ Non-visual and framework-injected elements (`<meta>`/`<title>`/`<script>`/`<styl
|
|
|
851
866
|
|
|
852
867
|
- `styleproof-init` — scaffold the gate: the capture spec, a dedicated `playwright.styleproof.config.ts` (production-build `webServer`, parallel capture), `.gitignore` cache entries, and the cache-first report workflow. 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`.
|
|
853
868
|
- `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.
|
|
854
|
-
- `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 incomplete coverage registry, or an unproven-determinism capture; `2` on a usage/capture error (including 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); `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 manifest at all, 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`.
|
|
869
|
+
- `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 incomplete coverage registry, or an unproven-determinism capture; `2` on a usage/capture error (including 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 manifest at all, 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`.
|
|
855
870
|
- `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).
|
|
856
871
|
- `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.
|
|
857
872
|
|
package/bin/styleproof-init.mjs
CHANGED
|
@@ -7,10 +7,13 @@
|
|
|
7
7
|
* Writes:
|
|
8
8
|
* - <dir> (default e2e/styleproof.spec.ts): a starter capture spec with a
|
|
9
9
|
* minimal settle() helper (triggers scroll-reveal content; StyleProof itself
|
|
10
|
-
* handles fonts, animation freeze, and the settle). For a detected Next.js app it derives
|
|
11
|
-
* the `expected` coverage guard from the
|
|
12
|
-
*
|
|
13
|
-
*
|
|
10
|
+
* handles fonts, animation freeze, and the settle). For a detected Next.js app it derives BOTH the
|
|
11
|
+
* surfaces AND the `expected` coverage guard from the same `discoverNextRoutes()`
|
|
12
|
+
* call, so a static route added later is captured and expected together —
|
|
13
|
+
* auto-covered, never a guard failure; the guard fails only on genuine
|
|
14
|
+
* divergence (a dynamic route, a hand-maintained registry, or a route dropped
|
|
15
|
+
* from surfaces but still expected). Otherwise it writes one sample surface
|
|
16
|
+
* plus a commented guard block to wire to your own route registry.
|
|
14
17
|
* - playwright.styleproof.config.ts: a dedicated production-build Playwright
|
|
15
18
|
* config for StyleProof captures, so an existing app Playwright config is
|
|
16
19
|
* never disturbed or accidentally reused.
|
|
@@ -23,7 +26,12 @@
|
|
|
23
26
|
*/
|
|
24
27
|
import fs from 'node:fs';
|
|
25
28
|
import path from 'node:path';
|
|
26
|
-
|
|
29
|
+
// Import from the leaf module, not the barrel: styleproof-init only scaffolds
|
|
30
|
+
// files and never captures. Pulling `../dist/index.js` here dragged the whole
|
|
31
|
+
// library — capture, crawler, report, and six Playwright-importing modules —
|
|
32
|
+
// into a tiny scaffolder's load path, and that oversized concurrent module
|
|
33
|
+
// graph is what made init's tests flake in CI. routes.js needs only fs + path.
|
|
34
|
+
import { discoverNextRoutes } from '../dist/routes.js';
|
|
27
35
|
import { isHelpArg, showHelpAndExit } from '../dist/cli-errors.js';
|
|
28
36
|
|
|
29
37
|
const HELP = `styleproof-init — scaffold a styleproof capture spec
|
|
@@ -39,9 +47,10 @@ options:
|
|
|
39
47
|
|
|
40
48
|
What it writes:
|
|
41
49
|
- the spec at --dir, with a minimal settle() helper (scroll-reveal only).
|
|
42
|
-
In a Next.js app it discovers your routes at run time and
|
|
43
|
-
surfaces and the \`expected\` coverage guard
|
|
44
|
-
|
|
50
|
+
In a Next.js app it discovers your routes at run time and derives both the
|
|
51
|
+
surfaces and the \`expected\` coverage guard from that one call, so a new static
|
|
52
|
+
route is auto-covered (captured + expected together); the guard fails only when
|
|
53
|
+
the two diverge. Otherwise it writes one sample surface + a commented guard block.
|
|
45
54
|
- playwright.styleproof.config.ts, a dedicated production-build Playwright config
|
|
46
55
|
- .github/workflows/styleproof.yml, a cache-first PR report workflow
|
|
47
56
|
|
|
@@ -114,9 +123,12 @@ const HEADER = `/**
|
|
|
114
123
|
* npx styleproof-diff # compare cached base/head maps by commit SHA
|
|
115
124
|
*/`;
|
|
116
125
|
|
|
117
|
-
// Next.js detected: derive
|
|
118
|
-
// routes AT RUN TIME,
|
|
119
|
-
//
|
|
126
|
+
// Next.js detected: derive BOTH surfaces and the coverage guard from the app's
|
|
127
|
+
// routes AT RUN TIME, from one `discoverNextRoutes()` call — so a static page added
|
|
128
|
+
// later is a captured surface AND `expected` in the same step (auto-covered, never a
|
|
129
|
+
// guard failure), with no static list to drift. The guard fires only when the two
|
|
130
|
+
// diverge (a dynamic route, a hand-maintained registry, or a route dropped from
|
|
131
|
+
// surfaces but still expected).
|
|
120
132
|
const NEXT_SPEC = `import type { Page } from '@playwright/test';
|
|
121
133
|
import { defineStyleMapCapture, discoverNextRoutes, type Surface } from 'styleproof';
|
|
122
134
|
|
|
@@ -124,12 +136,12 @@ ${HEADER}
|
|
|
124
136
|
|
|
125
137
|
${SETTLE}
|
|
126
138
|
|
|
127
|
-
// Routes discovered from your Next.js app (app/ + pages/) at RUN TIME
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
// is your spec. Static routes each get a capture;
|
|
131
|
-
// navigated without a value, so they're listed in
|
|
132
|
-
// surface with a concrete param.
|
|
139
|
+
// Routes discovered from your Next.js app (app/ + pages/) at RUN TIME. Both SURFACES
|
|
140
|
+
// and \`expected\` below come from this one list, so a static route you add later is
|
|
141
|
+
// captured and expected together — covered automatically, with no surface list to
|
|
142
|
+
// keep in sync. Edit freely; this is your spec. Static routes each get a capture;
|
|
143
|
+
// dynamic [param] routes can't be navigated without a value, so they're listed in
|
|
144
|
+
// \`exclude\` until you add a surface with a concrete param.
|
|
133
145
|
const ROUTES = discoverNextRoutes();
|
|
134
146
|
|
|
135
147
|
const SURFACES: Surface[] = ROUTES.filter((r) => !r.dynamic).map((r) => ({
|
|
@@ -146,9 +158,10 @@ const SURFACES: Surface[] = ROUTES.filter((r) => !r.dynamic).map((r) => ({
|
|
|
146
158
|
|
|
147
159
|
defineStyleMapCapture({
|
|
148
160
|
surfaces: SURFACES,
|
|
149
|
-
// Coverage guard: every
|
|
150
|
-
// the suite fails (it runs without STYLEMAP_DIR — a static check, no browser).
|
|
151
|
-
//
|
|
161
|
+
// Coverage guard: every \`expected\` route must be a captured surface or excluded, or
|
|
162
|
+
// the suite fails (it runs without STYLEMAP_DIR — a static check, no browser). Since
|
|
163
|
+
// both sides come from ROUTES, static routes never trip it; it fires when they
|
|
164
|
+
// diverge — a dynamic route (excluded below), or a route you drop from SURFACES.
|
|
152
165
|
expected: ROUTES.map((r) => r.key),
|
|
153
166
|
exclude: Object.fromEntries(
|
|
154
167
|
ROUTES.filter((r) => r.dynamic).map((r) => [r.key, \`dynamic route (\${r.path}) — add a surface with a concrete param\`]),
|
|
@@ -480,9 +493,14 @@ const isNext = routes.length > 0;
|
|
|
480
493
|
const SPEC = isNext ? NEXT_SPEC : GENERIC_SPEC;
|
|
481
494
|
|
|
482
495
|
let wroteSomething = false;
|
|
496
|
+
// Every path init created or modified this run, so the summary can name exactly what
|
|
497
|
+
// it touched — and, by omission, what it did NOT (init never writes package.json or a
|
|
498
|
+
// lockfile; that's the package manager's `install`, not this scaffolder).
|
|
499
|
+
const touched = [];
|
|
483
500
|
|
|
484
501
|
const spec = writeFileSafe(specPath, SPEC, { force });
|
|
485
502
|
if (spec.wrote) {
|
|
503
|
+
touched.push(specPath);
|
|
486
504
|
console.log(`${spec.exists ? 'overwrote' : 'created'} ${specPath}`);
|
|
487
505
|
if (isNext) {
|
|
488
506
|
const dynamic = routes.filter((r) => r.dynamic).length;
|
|
@@ -502,6 +520,7 @@ if (spec.wrote) {
|
|
|
502
520
|
const configPath = 'playwright.styleproof.config.ts';
|
|
503
521
|
const config = writeFileSafe(configPath, CONFIG, { force });
|
|
504
522
|
if (config.wrote) {
|
|
523
|
+
touched.push(configPath);
|
|
505
524
|
console.log(`${config.exists ? 'overwrote' : 'created'} ${configPath} (dedicated StyleProof capture config)`);
|
|
506
525
|
wroteSomething = true;
|
|
507
526
|
} else {
|
|
@@ -515,6 +534,7 @@ if (fs.existsSync('playwright.config.ts') || fs.existsSync('playwright.config.js
|
|
|
515
534
|
|
|
516
535
|
const ignored = ['.styleproof/', 'test-results/', 'playwright-report/'].filter((line) => ensureGitignoreLine(line));
|
|
517
536
|
if (ignored.length) {
|
|
537
|
+
touched.push('.gitignore');
|
|
518
538
|
console.log(`updated .gitignore (${ignored.join(', ')})`);
|
|
519
539
|
wroteSomething = true;
|
|
520
540
|
}
|
|
@@ -522,12 +542,21 @@ if (ignored.length) {
|
|
|
522
542
|
// Cache-first CI report — never overwrite an existing workflow.
|
|
523
543
|
const ci = writeFileSafe(CI_PATH, CI_WORKFLOW);
|
|
524
544
|
if (ci.wrote) {
|
|
545
|
+
touched.push(CI_PATH);
|
|
525
546
|
console.log(`created ${CI_PATH} (cache-first StyleProof report)`);
|
|
526
547
|
wroteSomething = true;
|
|
527
548
|
} else {
|
|
528
549
|
console.log(`${CI_PATH} already exists — left untouched`);
|
|
529
550
|
}
|
|
530
551
|
|
|
552
|
+
if (touched.length) {
|
|
553
|
+
// State exactly what init wrote, and — because adopters have blamed init for the
|
|
554
|
+
// `styleproof` entry their package manager's `install` added — say plainly that it
|
|
555
|
+
// did NOT touch package.json or the lockfile. Truth over assumption.
|
|
556
|
+
console.log(`\nstyleproof-init wrote only: ${touched.join(', ')}`);
|
|
557
|
+
console.log('It did NOT modify package.json or your lockfile (that was your package manager’s install).');
|
|
558
|
+
}
|
|
559
|
+
|
|
531
560
|
console.log('\nHow the gate works — it runs on your first PR with no extra steps:');
|
|
532
561
|
console.log(' 1. Commit and open a PR. CI captures the base and head surfaces in one pinned');
|
|
533
562
|
console.log(' environment and posts the StyleProof report — no local step required.');
|
package/dist/cli-errors.js
CHANGED
|
@@ -14,9 +14,14 @@ export function cliErrorMessage(error) {
|
|
|
14
14
|
*/
|
|
15
15
|
export function cachedMapsUnavailableMessage(command, purpose, error) {
|
|
16
16
|
return [
|
|
17
|
-
`${command}: cached maps are not available for this ${purpose}`,
|
|
17
|
+
`${command}: cached maps are not available for this ${purpose} — nothing was compared`,
|
|
18
18
|
cliErrorMessage(error),
|
|
19
|
-
|
|
19
|
+
// Name the two ways forward explicitly so a newcomer never reads "nothing compared"
|
|
20
|
+
// as "certified clean": the cached-map path only works where the base is restorable
|
|
21
|
+
// (CI, or a repo with the map-store remote), and the two-directory form always works
|
|
22
|
+
// off already-captured maps with no git remote at all.
|
|
23
|
+
`Next: run this in CI (or a repo with the 'origin' remote) where the base map is restorable, ` +
|
|
24
|
+
`or capture both sides and compare them directly: ${command} <beforeDir> <afterDir>.`,
|
|
20
25
|
].join('\n');
|
|
21
26
|
}
|
|
22
27
|
export function unknownFlagMessage(command, flag) {
|
package/dist/crawl.js
CHANGED
|
@@ -80,9 +80,18 @@ function toLink(href, base, keyFor, match) {
|
|
|
80
80
|
return { key: keyFor(url), url: path };
|
|
81
81
|
}
|
|
82
82
|
/**
|
|
83
|
-
* Dedup identity for a navigable path+query
|
|
84
|
-
*
|
|
85
|
-
*
|
|
83
|
+
* Dedup identity for a navigable path+query. Two forms of the same route must share
|
|
84
|
+
* one identity, or a static multi-page site (whose nav links the `.html` files) gets
|
|
85
|
+
* captured twice as byte-near-identical maps, doubling the work and duplicating every
|
|
86
|
+
* finding in the diff:
|
|
87
|
+
*
|
|
88
|
+
* - A trailing slash isn't a distinct surface (`/about` and `/about/` render the same
|
|
89
|
+
* route), so it's stripped — but never from the root `/` itself, nor from the query.
|
|
90
|
+
* - A trailing `index.html` is the directory's index (`/index.html` IS `/`, and
|
|
91
|
+
* `/docs/index.html` IS `/docs/`), so it collapses to the directory path. Only the
|
|
92
|
+
* literal `index.html` filename normalizes — a real `about.html` is left untouched
|
|
93
|
+
* and stays a distinct surface from `about`.
|
|
94
|
+
*
|
|
86
95
|
* The navigable url the caller returns keeps its original form; only the SET
|
|
87
96
|
* membership test is normalized, so the first-seen href still wins.
|
|
88
97
|
*/
|
|
@@ -90,7 +99,10 @@ function dedupIdentity(pathAndSearch) {
|
|
|
90
99
|
const q = pathAndSearch.indexOf('?');
|
|
91
100
|
const path = q === -1 ? pathAndSearch : pathAndSearch.slice(0, q);
|
|
92
101
|
const search = q === -1 ? '' : pathAndSearch.slice(q);
|
|
93
|
-
|
|
102
|
+
// `/index.html` → `/`, `/docs/index.html` → `/docs/` (the preceding slash stays so
|
|
103
|
+
// the trailing-slash step below folds it into the same identity as `/docs` / `/docs/`).
|
|
104
|
+
const withoutIndex = path.replace(/(^|\/)index\.html$/, '$1');
|
|
105
|
+
const normPath = withoutIndex.length > 1 ? withoutIndex.replace(/\/+$/, '') || '/' : withoutIndex;
|
|
94
106
|
return normPath + search;
|
|
95
107
|
}
|
|
96
108
|
/**
|
package/dist/diff.js
CHANGED
|
@@ -63,9 +63,44 @@ const ORIGIN_EPSILON_PX = 0.05;
|
|
|
63
63
|
function sameRect(a, b) {
|
|
64
64
|
return !!a && !!b && a.every((v, i) => v === b[i]);
|
|
65
65
|
}
|
|
66
|
+
const HORIZONTAL_MARGIN_PAIRS = [
|
|
67
|
+
['margin-left', 'margin-right'],
|
|
68
|
+
['margin-inline-start', 'margin-inline-end'],
|
|
69
|
+
];
|
|
70
|
+
function marginPxDelta(p) {
|
|
71
|
+
const before = pxParts(p.before);
|
|
72
|
+
const after = pxParts(p.after);
|
|
73
|
+
return before?.length === 1 && after?.length === 1 ? after[0] - before[0] : null;
|
|
74
|
+
}
|
|
75
|
+
// True when one horizontal side moved by a different px amount than its
|
|
76
|
+
// opposite. Such a change would shift the box on its own, so an *identical*
|
|
77
|
+
// rect means something else compensated — a real restyle, not layout-equivalent
|
|
78
|
+
// drift. Non-px or state-sentinel values (`(state no longer changes it)`) aren't
|
|
79
|
+
// demonstrable, so they fall through to the balanced (drop) path unchanged.
|
|
80
|
+
function marginChangeHasPxImbalance(props) {
|
|
81
|
+
const delta = new Map();
|
|
82
|
+
for (const p of props) {
|
|
83
|
+
if (LAYOUT_EQUIVALENT_MARGIN_PROPS.has(p.prop))
|
|
84
|
+
delta.set(p.prop, marginPxDelta(p));
|
|
85
|
+
}
|
|
86
|
+
for (const [start, end] of HORIZONTAL_MARGIN_PAIRS) {
|
|
87
|
+
const ds = delta.has(start) ? delta.get(start) : 0;
|
|
88
|
+
const de = delta.has(end) ? delta.get(end) : 0;
|
|
89
|
+
if (ds !== null && de !== null && ds !== de)
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
66
94
|
function dropLayoutEquivalentMarginProps(props, a, b) {
|
|
67
95
|
if (!sameRect(a?.rect, b?.rect))
|
|
68
96
|
return props;
|
|
97
|
+
// ponytail: a balanced margin change with an unchanged rect is treated as
|
|
98
|
+
// layout-equivalent from computed style alone. That still drops the rare case
|
|
99
|
+
// where a *balanced* change was held in place by external compensation — a
|
|
100
|
+
// consciously-deferred, low-reach soundness corner; closing it needs
|
|
101
|
+
// cross-element layout reasoning. The common one-sided case is caught here.
|
|
102
|
+
if (marginChangeHasPxImbalance(props))
|
|
103
|
+
return props;
|
|
69
104
|
return props.filter((p) => !LAYOUT_EQUIVALENT_MARGIN_PROPS.has(p.prop));
|
|
70
105
|
}
|
|
71
106
|
function pxParts(value) {
|
package/dist/runner.js
CHANGED
|
@@ -104,7 +104,7 @@ export function resolvePopupCaptureOptions(input) {
|
|
|
104
104
|
};
|
|
105
105
|
}
|
|
106
106
|
async function popupDomSnapshot(page, options) {
|
|
107
|
-
return page.evaluate(({ popupSelector, triggerSelector, attr, max = 0 }) => {
|
|
107
|
+
return page.evaluate(({ popupSelector, triggerSelector, attr, max = 0, relocatePath, relocateLabel }) => {
|
|
108
108
|
const qsa = (sel) => {
|
|
109
109
|
try {
|
|
110
110
|
return [...document.querySelectorAll(sel)];
|
|
@@ -133,6 +133,13 @@ async function popupDomSnapshot(page, options) {
|
|
|
133
133
|
}
|
|
134
134
|
return parts.join(' > ');
|
|
135
135
|
};
|
|
136
|
+
// The trigger's accessible name — mirrors crawl-surfaces' labelFor (aria-label
|
|
137
|
+
// || name || textContent || title). `path` is positional for an id-less trigger;
|
|
138
|
+
// this pins identity so a shifted same-tag sibling can't silently steal the bind.
|
|
139
|
+
const labelOf = (el) => (el.getAttribute('aria-label') || el.getAttribute('name') || el.textContent || el.getAttribute('title') || '')
|
|
140
|
+
.replace(/\s+/g, ' ')
|
|
141
|
+
.trim()
|
|
142
|
+
.slice(0, 80) || el.tagName.toLowerCase();
|
|
136
143
|
const popupKey = (el) => {
|
|
137
144
|
const text = (el.textContent ?? '').replace(/\s+/g, ' ').trim().slice(0, 120);
|
|
138
145
|
return [
|
|
@@ -150,7 +157,15 @@ async function popupDomSnapshot(page, options) {
|
|
|
150
157
|
const popups = qsa(popupSelector).filter(visible);
|
|
151
158
|
const keys = popups.map(popupKey);
|
|
152
159
|
if (!triggerSelector || !attr)
|
|
153
|
-
return { keys,
|
|
160
|
+
return { keys, candidates: [], found: false };
|
|
161
|
+
for (const el of qsa(`[${attr}]`))
|
|
162
|
+
el.removeAttribute(attr);
|
|
163
|
+
if (relocatePath) {
|
|
164
|
+
const target = qsa(triggerSelector).find((el) => pathOf(el) === relocatePath && labelOf(el) === relocateLabel);
|
|
165
|
+
if (target)
|
|
166
|
+
target.setAttribute(attr, 'target');
|
|
167
|
+
return { keys, candidates: [], found: Boolean(target) };
|
|
168
|
+
}
|
|
154
169
|
const safeTrigger = (el) => {
|
|
155
170
|
const tag = el.tagName.toLowerCase();
|
|
156
171
|
if (el.matches(':disabled, [aria-disabled="true"]'))
|
|
@@ -161,24 +176,30 @@ async function popupDomSnapshot(page, options) {
|
|
|
161
176
|
return el.getAttribute('href')?.startsWith('#') ?? false;
|
|
162
177
|
return true;
|
|
163
178
|
};
|
|
164
|
-
|
|
165
|
-
el.
|
|
166
|
-
|
|
167
|
-
candidates.
|
|
168
|
-
return {
|
|
179
|
+
const candidates = qsa(triggerSelector)
|
|
180
|
+
.filter((el) => visible(el) && !popups.some((popup) => popup !== el && popup.contains(el)) && safeTrigger(el))
|
|
181
|
+
.slice(0, max);
|
|
182
|
+
candidates.forEach((el, index) => el.setAttribute(attr, String(index)));
|
|
183
|
+
return {
|
|
184
|
+
keys,
|
|
185
|
+
candidates: candidates.map((el, index) => ({ index, path: pathOf(el), label: labelOf(el) })),
|
|
186
|
+
found: false,
|
|
187
|
+
};
|
|
169
188
|
}, options);
|
|
170
189
|
}
|
|
171
190
|
async function visiblePopupKeys(page, selector) {
|
|
172
191
|
return (await popupDomSnapshot(page, { popupSelector: selector })).keys;
|
|
173
192
|
}
|
|
193
|
+
/** Enumerate + mark the surface's popup triggers ONCE, and record the pristine
|
|
194
|
+
* overlay keys in the same DOM snapshot — the reset baseline every reopen is
|
|
195
|
+
* verified against. */
|
|
174
196
|
async function markPopupCandidates(page, options) {
|
|
175
|
-
|
|
197
|
+
return popupDomSnapshot(page, {
|
|
176
198
|
triggerSelector: options.triggers,
|
|
177
199
|
popupSelector: options.overlays,
|
|
178
200
|
attr: POPUP_TRIGGER_ATTR,
|
|
179
201
|
max: options.max,
|
|
180
202
|
});
|
|
181
|
-
return snapshot.indexes.map((index) => ({ index }));
|
|
182
203
|
}
|
|
183
204
|
function expandOne(surface, variant, variantKind) {
|
|
184
205
|
return {
|
|
@@ -308,16 +329,37 @@ async function assertDeterministic(page, surface, first, captureText, pending) {
|
|
|
308
329
|
throw new Error(selfCheckErrorMessage(surface.key, drift, [...new Set([...(first.volatile ?? []), ...(again.volatile ?? [])])], liveCandidates));
|
|
309
330
|
}
|
|
310
331
|
}
|
|
311
|
-
|
|
332
|
+
/**
|
|
333
|
+
* Reset the surface (Escape + `go()`), then open ONE candidate's popup.
|
|
334
|
+
*
|
|
335
|
+
* Two guarantees the naive open loop lacked:
|
|
336
|
+
* - the reset is VERIFIED against the pristine overlay keys, not assumed —
|
|
337
|
+
* Escape is not a universal close, and a non-navigating `go()` clears nothing;
|
|
338
|
+
* - the trigger is re-bound by the (path, label) identity recorded at first
|
|
339
|
+
* enumeration, never by its position in a fresh enumeration (the trigger set can
|
|
340
|
+
* shift between opens and silently key the popup under a different trigger; the
|
|
341
|
+
* label pins identity where the path alone is still positional for id-less triggers).
|
|
342
|
+
* Either check failing is reported for the caller to skip loudly.
|
|
343
|
+
*/
|
|
344
|
+
async function openPopupCandidate(page, surface, width, height, options, candidate, pristine) {
|
|
312
345
|
await page.setViewportSize({ width, height });
|
|
313
346
|
await page.keyboard.press('Escape').catch(() => { });
|
|
314
347
|
await surface.go(page);
|
|
315
|
-
const
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
348
|
+
const snapshot = await popupDomSnapshot(page, {
|
|
349
|
+
popupSelector: options.overlays,
|
|
350
|
+
triggerSelector: options.triggers,
|
|
351
|
+
attr: POPUP_TRIGGER_ATTR,
|
|
352
|
+
relocatePath: candidate.path,
|
|
353
|
+
relocateLabel: candidate.label,
|
|
354
|
+
});
|
|
355
|
+
const leaked = snapshot.keys.filter((key) => !pristine.has(key));
|
|
356
|
+
if (leaked.length)
|
|
357
|
+
return { status: 'leaked', leaked };
|
|
358
|
+
if (!snapshot.found)
|
|
359
|
+
return { status: 'missing' };
|
|
360
|
+
const before = new Set(snapshot.keys);
|
|
319
361
|
await page
|
|
320
|
-
.locator(`[${POPUP_TRIGGER_ATTR}="
|
|
362
|
+
.locator(`[${POPUP_TRIGGER_ATTR}="target"]`)
|
|
321
363
|
.first()
|
|
322
364
|
.click({ timeout: Math.max(500, options.timeoutMs), noWaitAfter: true })
|
|
323
365
|
.catch(() => undefined);
|
|
@@ -325,10 +367,19 @@ async function openPopupCandidate(page, surface, width, height, options, candida
|
|
|
325
367
|
do {
|
|
326
368
|
const opened = (await visiblePopupKeys(page, options.overlays)).find((key) => !before.has(key));
|
|
327
369
|
if (opened)
|
|
328
|
-
return opened;
|
|
370
|
+
return { status: 'opened', key: opened };
|
|
329
371
|
await page.waitForTimeout(50);
|
|
330
372
|
} while (Date.now() < deadline);
|
|
331
|
-
return
|
|
373
|
+
return { status: 'none' };
|
|
374
|
+
}
|
|
375
|
+
/** A popup candidate skipped instead of captured wrong must be NAMED — a silent
|
|
376
|
+
* skip reads as "nothing to capture" when the truth is "couldn't capture safely". */
|
|
377
|
+
function warnPopupSkipped(surface, popupId, width, reason) {
|
|
378
|
+
// eslint-disable-next-line no-console
|
|
379
|
+
console.warn(`styleproof: skipped ${surface.key}-${popupId}@${width} — ${reason}`);
|
|
380
|
+
}
|
|
381
|
+
function leakedOverlaysDesc(leaked) {
|
|
382
|
+
return `overlay(s) the reset (Escape + go()) could not clear: ${leaked.join('; ')}`;
|
|
332
383
|
}
|
|
333
384
|
function popupMetadata(surface, popupId) {
|
|
334
385
|
return {
|
|
@@ -346,26 +397,52 @@ async function captureOpenedPopupMap(page, surface, s, pending, popupId) {
|
|
|
346
397
|
metadata: popupMetadata(surface, popupId),
|
|
347
398
|
});
|
|
348
399
|
}
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
400
|
+
/** Reopen the popup and throw on drift/no-reopen. Returns the leaked overlay keys
|
|
401
|
+
* instead when the popup itself defeats the reset (e.g. it IS a toast Escape can't
|
|
402
|
+
* dismiss) — the reopen can't run, so the caller discards the capture loudly
|
|
403
|
+
* rather than saving a map whose determinism was never proven. */
|
|
404
|
+
async function assertPopupDeterministic(page, surface, width, height, options, candidate, popupId, first, s, pending, pristine) {
|
|
405
|
+
const reopened = await openPopupCandidate(page, surface, width, height, options, candidate, pristine);
|
|
406
|
+
if (reopened.status === 'leaked')
|
|
407
|
+
return reopened.leaked;
|
|
408
|
+
if (reopened.status !== 'opened') {
|
|
409
|
+
throw new Error(`styleproof self-check failed: ${surface.key}-${popupId} popup did not reopen` +
|
|
410
|
+
(reopened.status === 'missing' ? ' (its trigger disappeared from the DOM or changed identity)' : ''));
|
|
411
|
+
}
|
|
353
412
|
const again = await captureOpenedPopupMap(page, surface, s, pending, popupId);
|
|
354
413
|
const drift = diffStyleMaps(first, again);
|
|
355
414
|
if (drift.length) {
|
|
356
415
|
throw new Error(selfCheckErrorMessage(`${surface.key}-${popupId}`, drift, first.volatile, first.liveCandidates));
|
|
357
416
|
}
|
|
417
|
+
return undefined;
|
|
358
418
|
}
|
|
359
|
-
async function capturePopupCandidate(page, surface, width, height, s, options, candidate) {
|
|
419
|
+
async function capturePopupCandidate(page, surface, width, height, s, options, candidate, pristine) {
|
|
360
420
|
const requests = trackInflightRequests(page);
|
|
421
|
+
const popupId = `popup-${String(candidate.index + 1).padStart(2, '0')}`;
|
|
361
422
|
try {
|
|
362
|
-
const
|
|
363
|
-
if (
|
|
423
|
+
const opened = await openPopupCandidate(page, surface, width, height, options, candidate, pristine);
|
|
424
|
+
if (opened.status === 'none')
|
|
364
425
|
return;
|
|
365
|
-
|
|
426
|
+
if (opened.status === 'leaked') {
|
|
427
|
+
warnPopupSkipped(surface, popupId, width, `${leakedOverlaysDesc(opened.leaked)} — capturing now would include the previous ` +
|
|
428
|
+
`popup's residue. Dismiss it in the surface's go(), or capture it as an explicit variant.`);
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
if (opened.status === 'missing') {
|
|
432
|
+
warnPopupSkipped(surface, popupId, width, `its originally-enumerated trigger is no longer identifiable after the reset (Escape + go()) — ` +
|
|
433
|
+
`gone from the DOM, or a shifted same-tag sibling no longer matches its recorded label; ` +
|
|
434
|
+
`skipping rather than re-binding to a different trigger.`);
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
366
437
|
const map = await captureOpenedPopupMap(page, surface, s, requests.pending, popupId);
|
|
367
|
-
if (s.selfCheck)
|
|
368
|
-
await assertPopupDeterministic(page, surface, width, height, options, candidate, popupId, map, s, requests.pending);
|
|
438
|
+
if (s.selfCheck) {
|
|
439
|
+
const leaked = await assertPopupDeterministic(page, surface, width, height, options, candidate, popupId, map, s, requests.pending, pristine);
|
|
440
|
+
if (leaked) {
|
|
441
|
+
warnPopupSkipped(surface, popupId, width, `reopening for the self-check found ${leakedOverlaysDesc(leaked)} — the popup itself ` +
|
|
442
|
+
`defeats the reset, so its determinism can't be verified and the capture is discarded.`);
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
369
446
|
const stem = path.join(s.baseDir, s.dir, `${surface.key}-${popupId}@${width}`);
|
|
370
447
|
saveStyleMap(`${stem}.json.gz`, map);
|
|
371
448
|
if (s.screenshots)
|
|
@@ -380,9 +457,12 @@ async function capturePopupSurfaces(page, surface, width, height, s) {
|
|
|
380
457
|
if (!options.enabled || options.max === 0)
|
|
381
458
|
return;
|
|
382
459
|
await surface.go(page);
|
|
383
|
-
const candidates = await markPopupCandidates(page, options);
|
|
460
|
+
const { keys, candidates } = await markPopupCandidates(page, options);
|
|
461
|
+
// Overlays legitimately visible in the surface's settled state (e.g. a permanent
|
|
462
|
+
// status region) — every reopen is verified back to this baseline before capture.
|
|
463
|
+
const pristine = new Set(keys);
|
|
384
464
|
for (const candidate of candidates) {
|
|
385
|
-
await capturePopupCandidate(page, surface, width, height, s, options, candidate);
|
|
465
|
+
await capturePopupCandidate(page, surface, width, height, s, options, candidate, pristine);
|
|
386
466
|
}
|
|
387
467
|
}
|
|
388
468
|
/** Drive one surface at one width to a settled state and save its style map (+ screenshot).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "styleproof",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.20.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",
|
|
@@ -90,6 +90,6 @@
|
|
|
90
90
|
"typescript": "^6.0.3",
|
|
91
91
|
"typescript-eslint": "^8.18.0",
|
|
92
92
|
"husky": "^9.1.7",
|
|
93
|
-
"fallow": "^2.
|
|
93
|
+
"fallow": "^3.2.0"
|
|
94
94
|
}
|
|
95
95
|
}
|