The Rust engine: one binary replaces every script and the JS detector, fully open (#714)

* Add oracle harness: verb goldens and function-level vectors

Records stdout/stderr/exit/files for every impeccable verb over a fixed
corpus and replays them against an alternate implementation. Adds a loader
hook that captures per-function call vectors from the pure engine modules.

Prepared with AI assistance (Claude Code).

* Oracle: hook, hook-before-edit, hook-admin cases and goldens

Prepared with AI assistance (Claude Code).

* Add docs/CLI-CONTRACT.md: observable behavior of every impeccable verb

Prepared with AI assistance (Claude Code).

* Oracle: context/doctor/pin/surface-brief/critique/palette/embed/signals/csp/seed/genimg/question cases and goldens

Prepared with AI assistance (Claude Code).

* Oracle: live-mode cases and goldens (roots, inject, wrap, insert, accept, session, manual edits, daemon)

Prepared with AI assistance (Claude Code).

* Oracle: mask the binary path before HOME; export launcher env to the binary

Prepared with AI assistance (Claude Code).

* detect: set process.exitCode instead of exiting after the final write

process.exit() right after a large piped stdout write truncated JSON output
at the pipe buffer boundary; found by the oracle harness. Re-record the six
directory-scan goldens that had captured the truncation.

Prepared with AI assistance (Claude Code).

* Oracle: normalize the hook-admin command in both runtimes' forms and audit chars

Prepared with AI assistance (Claude Code).

* Skill text: invoke the impeccable launcher instead of node scripts

Every `node {{scripts_path}}/<name>.mjs` becomes `{{scripts_path}}/impeccable <verb>`
(context-signals -> signals, hook-admin -> hooks). Setup step 1 drops Node, points
Windows shells without sh at impeccable.cmd, and says the launcher runs a
self-contained binary. allowed-tools follows.

Prepared with AI assistance (Claude Code).

* Scripts dir: replace the Node scripts with the impeccable launcher

skill/scripts keeps command-metadata.json and the page JS; every .mjs entry
point, lib/, and live/ are gone (the binary owns those verbs). Adds the POSIX
launcher, impeccable.cmd, VERSION (copied from the new root ENGINE_VERSION),
scripts/fetch-engine.mjs (bun run fetch:engine) to pull the pinned binary
into skill/scripts/bin/<os>-<arch>/, and gitignores that bin dir.

Prepared with AI assistance (Claude Code).

* Build: ship the launcher instead of bundling the JS engine

readSourceFiles no longer copies cli/engine into the skill; the scripts
payload is the launcher (executable bit preserved through dist, plugin/, and
universal.zip), impeccable.cmd, VERSION (synced from ENGINE_VERSION on every
build), the page JS, and command-metadata.json. Hook manifests call
`<scripts>/impeccable hook` behind an existence guard (Codex adds a
commandWindows sibling calling impeccable.cmd; Cursor runs hook-before-edit;
GitHub keeps the git rev-parse form; Grok mirrors Claude); the Node probe and
systemMessage notice are gone. build:release fetches the pinned engine for
every target (lenient) and stages bin/<os-arch>/ into the dist skill copies
after root harness dirs and plugin/ were synced, so git-delivered trees stay
launcher-only. The detection-rule count check reads the vendored
extension/detector/antipatterns.json and is skipped when absent.
build:browser is a stub; the codex prefix rewrite leaves
`{{scripts_path}}/impeccable` alone.

Prepared with AI assistance (Claude Code).

* CLI: turn the impeccable npm package into a platform-binary shim

cli/engine, cli/lib, and cli/bin/commands are gone; their behavior lives in
the engine binary. cli/bin/cli.js now resolves the binary from IMPECCABLE_BIN,
the @impeccable/cli-<os>-<arch> optional dependency (templates under
cli/platform-packages/, published by the engine release), the
~/.impeccable/bin/<version>/ cache, or a checksum-verified download, and
execs it. package.json drops the engine dependencies and the library
exports; puppeteer moves to devDependencies for the icon scripts.
README.npm.md describes the shim.

Prepared with AI assistance (Claude Code).

* Tests: gate behavior on the oracle and the engine binary

Unit tests of the deleted Node scripts and the JS detector are removed;
their behavior is pinned by tests/oracle goldens (frozen JS behavior plus
reviewed deltas) and the engine's own tests. tests/oracle.test.mjs replays
the corpus against the binary (IMPECCABLE_BIN or skill/scripts/bin/<target>/,
via tests/lib/engine-bin.mjs) and skips cleanly without one; the framework
fixture sweep drives live-inject, live-wrap, and detect-csp through the
binary the same way. record.mjs learns --bin. The function-level vectors
under tests/oracle/vectors/calls are committed as the frozen snapshot they
can no longer be regenerated from. Suites: core trimmed to build and
transformer tests, oracle added to the default run, detector/live reduced to
packaging and reference checks, the live-e2e helper tests move to the opt-in
live-e2e lane pending its retarget, cli-remote-e2e is an empty placeholder.

Prepared with AI assistance (Claude Code).

* Docs: describe the launcher, the engine pin, and the oracle gate

CLAUDE.md gains an Engine binary section (launcher lookup order, ENGINE_VERSION,
untracked binaries, how tests get one, the oracle as behavior gate, what stays
JavaScript) and drops the Node-script and JS-detector descriptions; the CLI
and detection-rule sections point at the shim and the engine repo. README.md
states the skill needs no runtime and lists the launcher-based hook commands;
AGENTS.md follows. CLI-CONTRACT.md's intro notes the scripts it quotes are
the recorded source, not the tree.

Prepared with AI assistance (Claude Code).

* Tests: tighten the hook command guard assertion

Prepared with AI assistance (Claude Code).

* Oracle: re-golden 46 cases for the engine's own command names; record them in DELTAS.md

Prepared with AI assistance (Claude Code).

* Build: ship launcher-only release zips by default

IMPECCABLE_BUNDLE_ENGINE=1 opts in to staging the engine binaries into the
dist skill copies. Bundling every target into every provider copy put
dist/universal.zip near 340 MB, past the 25 MB Cloudflare Pages file cap
that impeccable install downloads through.

Prepared with AI assistance (Claude Code).

* Tests: drive the live-e2e orchestrator through the engine binary

The session, fake-agent loop, steer test, and manual-edit probe spawn
<binary> <verb> (live-server, live, live-inject, live-wrap, live-insert,
live-accept, live-poll, live-complete) resolved by tests/lib/engine-bin.mjs
instead of node skill/scripts/live-*.mjs; the completion typing the agent
imported from the deleted live/completion.mjs is a small local helper. The
live-e2e helper unit tests move back into the default live suite (the steer
loop skips without a binary).

Prepared with AI assistance (Claude Code).

* Tests: run new-work-e2e through the engine's serve-question and generate-image verbs

Prepared with AI assistance (Claude Code).

* Tests: point the skill-behavior harness at the launcher and engine binary

The bash tool exports IMPECCABLE_BIN so the staged skill's launcher runs
without a download; scenarios assert on 'impeccable context' instead of
context.mjs and skip without a binary.

Prepared with AI assistance (Claude Code).

* Tests: note what plugin-e2e validates before and after the generated-output sync

Prepared with AI assistance (Claude Code).

* Oracle: record the engine's 'wasm-unsafe-eval' CSP meta patch as a reviewed delta

Prepared with AI assistance (Claude Code).

* Rebase reconciliation: fold main's post-freeze work into the swapped tree

The rebase onto origin/main brought changes whose JS engine halves left the
tree with the swap. This commit reconciles what survives:

- Suite map: register main's comp-fidelity unit tests (build-phase,
  comp-diff, font-match, hero-checks) in the core suite and
  live-browser-ignores in the live suite.
- Payload guard: the skill scripts payload now allowlists the comp-fidelity
  build pipeline (comp-spec/comp-diff/build-phase/font-match and their libs),
  the one Node toolchain that has not moved into the engine.
- Drop skill/scripts/live/project-ignores.mjs, lib/live-path-globs.mjs, and
  their test: they import hook-lib/live-inject/impeccable-paths, which the
  swap deleted, and their consumer (the JS live server) is the engine now.
- skill text: the comp pipeline's calls to engine verbs (generate-image,
  embed-prompt) use the launcher spelling.
- Oracle: re-record 17 detect goldens over the fixture set main changed
  (oklch #592, color-mix #578, 1D grid #615, the two comp-fidelity rules)
  and record the gap in DELTAS.md; those JS rule changes are not yet ported
  to the engine, and the goldens pin its current behavior.

bun run test (oracle included) and bun run build are green on this tree.

AI-assisted change: implemented with Claude Code.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WaJv2c4oN8wS7Ttq4XRqyx

* Launcher: engine-probe PATH validation, working .cmd download path; CI: drop stale path, add oracle job

Byte-identical copies of the engine repo's launchers (engine main
af7572c): the retired 3.x npm CLI on PATH or in ~/.impeccable/bin is
rejected by the engine-probe handshake instead of hijacking every verb;
impeccable.cmd's download path is rewritten as straight-line goto flow
(the parenthesized blocks expanded %url%/%cached% at parse time, making
it dead code) with certutil sha256 verification and a windows-arm64 ->
x64 asset fallback; the final error points at the release download
instead of npm i -g (npm still serves the 3.x CLI).

ci.yml: the generated-output check no longer diffs the deleted
cli/engine/detect-antipatterns-browser.js, and a new oracle job fetches
the pinned engine (bun run fetch:engine) and replays tests/oracle/
against it. The job is continue-on-error with a loud warning until the
first engine release exists; flipping it to required is a release-time
toggle, documented in the workflow.

Verified here: sh -n on both launcher copies, bun run build green, full
oracle replay against the rebuilt engine binary green (770 pass, 0
fail), and a launcher behavior test proving a fake 3.x CLI on PATH is
skipped while the download + checksum chain completes against a local
file server.

Prepared with AI assistance (Claude Code).

* Oracle: restore detector goldens to post-fix behavior after the engine ports

The Aug 17-31 detector fixes (oklch parsing, color-mix nested hex, 1D grid
pass, comment stripping, root-relative linked stylesheets, URL userinfo
redaction, inert ignore-value refusal) and the comp-fidelity rules
organic-clip-path / buried-raster are ported to the engine. Re-records the
gap-pinning detect goldens from the fixed binary (glow.html included: its
.photo-opaque-grad column now carries the buried-raster finding it was
written for), replays the frozen checkHtmlPatterns call vectors through the
last JS engine state in history (db1462b9^; args untouched, 14 of 101
results moved), and rewrites the DELTAS gap section into the landed-ports
note. Each re-recorded json fixture golden byte-matches that JS state's
output; oracle: 770 pass, 0 fail.

Prepared with AI assistance (Claude Code).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WaJv2c4oN8wS7Ttq4XRqyx

* Oracle: pin the Aug 17-31 verb fixes ported to the Rust engine

New cases: hook-session-grok-edit-then-stop (Grok Build camelCase envelope,
end_turn/shutdown/stopHookActive Stop handling, 35ae0733 + bfe634e2 +
3c442af7, #646), hook-session-codex-stop-decision (Codex Stop emits
decision/block, c9e7cd8a, #603), and doctor-order-boot-and-deep (boot and
deep findings keep their established artifact order, 80997663).

Re-recorded goldens whose old bytes froze pre-fix behavior, with a
DELTAS.md entry naming each upstream hash: the Stop finding-cache sync
(3c442af7), the Edit|Write manifests without the retired MultiEdit matcher
(7d5c60d2), and the failWithRollback field order (1f2c3f9d).

Prepared with AI assistance (Claude Code).

* Oracle: drop a duplicated DELTAS section

The verb-fix section landed twice when two porting sessions staged the
same file; keep one copy.

Prepared with AI assistance (Claude Code).

* Oracle: pin the hooks ignore-value inert-entry refusal

Three hadmin-ignore-value-inert-* cases record the engine's port of
be87f5eb (#662) to hooks ignore-value: an exact value for a rule whose
findings can never extract one is refused with the wildcard-plus-file
route (and no config write), while the wildcard scoped form for the same
rule is accepted. Goldens recorded from the engine binary and verified
byte-for-byte against the ea360025 hook-admin.mjs on the same sequences.
No existing golden changes, so no DELTAS entry is owed.

Prepared with AI assistance (Claude Code).

* Launcher: fail closed on a missing download checksum (engine triage C1)

Byte-identical sync of the engine repo's launchers: a freshly downloaded
engine binary now runs only after verifying against its .sha256 sidecar.
A sidecar that cannot be fetched, or a machine with no sha256 tool,
refuses the download instead of exec'ing an unverified binary; the
wget-only path fetches the sidecar too. Binaries already on PATH or in
the cache that pass engine-probe are unaffected.

Prepared with AI assistance (Claude Code).

Co-Authored-By: Claude Code <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WaJv2c4oN8wS7Ttq4XRqyx

* Enforce engine-before-skill release order (triage D4)

The launcher, npm shim, and `impeccable install` all resolve the engine
binary for the pinned ENGINE_VERSION, so a skill/CLI release or a rust-swap
merge published ahead of the engine release + platform packages dead-ends
every install path. Add a mechanical guard:

- scripts/check-engine-release.mjs: verifies all five dist binaries +
  .sha256 and the five @impeccable/cli-<os>-<arch> npm platform packages
  exist for the pinned ENGINE_VERSION; names missing assets, exits non-zero.
  Honors IMPECCABLE_DOWNLOAD_BASE.
- release.mjs: hard-fails release:skill and release:cli when assets are
  missing; extension is exempt (vendored WASM detector, no engine exec).
- CI engine-release-ready job: runs the check, continue-on-error with a
  loud ::warning until the first engine release exists (flip to false then).
- CLAUDE.md Releases: documents the enforced ordering.

Prepared with AI assistance (Claude Code).

* Oracle: re-record the Sep-1 verb fixes ported to the Rust engine

Five fixes landed on main in JS between the swap branch and its rebase and were
ported to the engine; the goldens they touch are re-recorded from the fixed
binary, each engine output first diffed byte-for-byte against the upstream JS on
the same inputs. DELTAS.md documents every case with its upstream hash.

- critique-* (usage/unknown/latest-existing/write-then-read/write-monorepo-child):
  the #660 critique close path (identity + fingerprint freshness, ~NNNN
  collision suffix, closed flag, close verb, latest --json). Upstream 5211bdf4.
- detect-* (new overused-font fixture cases, dir/scope/no-advisory sweeps):
  the #678 overused-font primary-face change (a system stack keeps its system
  face, so a Roboto fallback no longer flags). Upstream 2cfd6076.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WaJv2c4oN8wS7Ttq4XRqyx

* Tests: fix pre-existing release-guard staging on the swap branch

release.test.mjs was already red on the swap branch: release.mjs imports
check-engine-release.mjs and fetch-engine.mjs (the D4 engine release-order
guard), which the temp work tree never staged, so every dry run failed to
resolve the module instead of exercising the guard. Stage both modules and set
IMPECCABLE_SKIP_ENGINE_CHECK=1 so the guard does not probe the network; this
suite predates the guard and only covers the version/changelog/artifact checks.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WaJv2c4oN8wS7Ttq4XRqyx

* oracle: pin E8 stale-hook-manifest detector fallback (context)

Cover the v3-to-launcher upgrade fix (triage E8) recorded from the engine
binary and hand-reviewed:

- context-stale-hook-manifest: a .claude/settings.local.json naming the retired
  `node .../hook.mjs` script under the claude-code provider emits
  MANUAL_DETECTOR_REQUIRED, because the stale marker no longer counts as an
  active hook (its script is gone after the update).
- context-launcher-hook-active: the same manifest in the launcher form still
  suppresses MANUAL_DETECTOR_REQUIRED, confirming the launcher marker is
  recognized as active.

The only difference between the two goldens is the MANUAL_DETECTOR_REQUIRED
block. No existing golden moved: every other context case runs under the source
provider, whose hook-manifest list is empty, so none of them scan a manifest.
Also null IMPECCABLE_PROVIDER_ID in the case BASE_ENV so a recording machine's
value cannot leak. DELTAS.md records the intentional divergence from JS parity.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WaJv2c4oN8wS7Ttq4XRqyx

* Tests: stop two harness hangs from wedging a whole run

Two suites could hang forever and never print a tally, because the one
mechanism that could interrupt the wedged work was missing on both paths.

Hang 1 (bun run test / build-phase.test.mjs): the test's run() helper
spawned every child with spawnSync and no timeout. spawnSync blocks the
test worker's thread, so node's --test-timeout (an event-loop timer)
cannot interrupt a child that wedges (a fork/exec blocked on OS resources
under concurrency, a gate's comp-diff grandchild, or a stray browser
launch). Bound every child with spawnSync timeout + killSignal SIGKILL so
a wedge becomes a fast, named failure the next test survives.

Hang 2 (bun run test:skill-behavior): runTurn called generateText with no
client-side deadline, so a stalled provider stream kept the fetch (and the
whole node process) alive past the per-test timeout, producing no tally.
Attach a real AbortSignal (default 840s, under the 900s per-test cap):
on expiry the fetch aborts, the turn throws, and the scenario
fails-and-continues. The unref'd timer is cleared on completion.

Runner backstops: run-tests.mjs now spawns each command as a detached
process-group leader and enforces a per-suite wall-clock cap that SIGKILLs
the entire group (workers, grandchildren, browsers) on expiry, with
SIGINT/SIGTERM forwarded so Ctrl-C still reaps the tree. The core node
batch gets a finite --test-timeout (180s); skill-behavior gets a 60min
group cap. Env overrides: IMPECCABLE_TEST_WALL_CLOCK_MS,
IMPECCABLE_SKILL_BEHAVIOR_TURN_TIMEOUT_MS,
IMPECCABLE_BUILD_PHASE_RUN_TIMEOUT_MS.

Proof: bun run test green twice (~60s); scoped claude-sonnet-5
skill-behavior sweep terminates with a tally (20 tests, ~32min) where the
840s abort caught a wedged redesign turn and the sweep continued instead
of hanging.

Prepared with AI assistance (Claude Code).

* launcher: export skill-dir env before the IMPECCABLE_BIN exec (sync engine fix)

Prepared with AI assistance (Claude Code).

* Node-free swap: comp-fidelity verbs move to the engine

The four comp-fidelity scripts (comp-spec, comp-diff, font-match, build-phase)
and their six libs are ported into the impeccable-engine binary. This removes
the last Node .mjs from the skill: `git ls-files skill/scripts | grep '\.mjs$'`
now returns nothing.

- reference/new-work.md, reference/visualize.md, and the asset-producer /
  finish-reviewer agents now invoke `{{scripts_path}}/impeccable <verb>` instead
  of `node <script>.mjs`.
- Deleted the ten ported .mjs and the four JS unit tests that imported them
  (their behavior is now covered by the engine's Rust tests and the oracle);
  removed those files from scripts/test-suites.mjs.
- Added oracle cases (comp-*, font-match-*, build-phase-*) over a comp-basic
  workspace, recorded from the engine binary; the deterministic outputs are
  byte-identical to the JS the scripts left behind.
- docs/CLI-CONTRACT.md documents the four verbs, the CDP font rendering, and
  the runtime-resolved (never-committed) font-index catalog.

The font-index catalog JSON stays shipped in the skill (data/font-index.json);
the engine resolves it at run time and never vendors it.

Prepared with AI assistance (Claude Code).

* reorg: public plumbing for the in-repo Rust workspace and the two-release flow

The engine binaries move from the impeccable-dist channel to this repo's own
GitHub Releases (tag engine-v<ENGINE_VERSION>), and the closed detector the
engine links arrives as detector-v<DETECTOR_VERSION> releases on the same
repo. This commit wires the public side for that; the crates themselves land
in the next commit.

- Launcher (sh + cmd), npm shim, fetch-engine and check-engine-release now
  download from github.com/pbakaus/impeccable/releases/download/engine-v<X>/.
- release.mjs gains `engine`: verifies ENGINE_VERSION against the platform
  package pins and the detector release, tags, pushes; release-engine.yml
  builds the five targets and publishes. check-detector-release.mjs is the
  matching release-order guard (with tests).
- Root Cargo.toml (workspace, lto = false with the reason), rust-toolchain.toml
  (exact pin), DETECTOR_VERSION, /target ignored.
- CI: rust + rust-windows jobs and an oracle job that replays the goldens
  against a source build, warn-only until the first detector release exists;
  ci-test-plan exposes a `rust` output.
- docs/ENGINE.md (the crate map and the closed-detector mechanism) and the
  CLAUDE.md engine, release-order and rules sections.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* reorg C: the open Rust runtime joins this repo as one Cargo workspace

The engine no longer lives in a separate repo. `crates/` is a snapshot of the
open crates (foundation, core, common, context, live, hook, skills, comp,
comp-verbs, html, browser, detect, cli) plus `Cargo.lock`, taken as a git
archive of the engine repo at the commit that finished the boundary split.
None of that repo's history comes with it, and none of it should: the closed
half stays private.

The closed half is the rule engine. It ships as a prebuilt native archive per
target, `libimpeccable_detector.a`, published as a `detector-v<X>` GitHub
Release on this repo. `crates/core/build.rs` resolves and links it three ways:
`IMPECCABLE_DETECTOR_LIB=<dir>` for a local detector build, else the
`~/.impeccable/detector/<version>/<target>/` cache, else a download verified
against its `.sha256` sidecar. `crates/core` is a thin shim over a three-symbol
C ABI; nothing above it knows the boundary exists.

What changed versus the engine repo copy:

- Every crate manifest moves from `license-file.workspace` to
  `license.workspace` (this workspace declares Apache-2.0), and the workspace
  gains the `postcard` dependency the boundary encoding needs.
- The launcher contract test reads `skill/scripts/impeccable{,.cmd}` instead of
  a sibling `launcher/` dir, and `engine_binary` downloads from
  `github.com/pbakaus/impeccable/releases/download/engine-v<version>/` instead
  of the retired dist repo. No oracle golden carried the old URL, so no
  re-recording was owed.
- The tests that hunted for a public repo through `IMPECCABLE_PUBLIC_REPO`,
  `../impeccable-second` or a hardcoded home directory now resolve the root as
  `CARGO_MANIFEST_DIR/../..`, because they are in it. The env var stays as an
  override for an out-of-tree checkout.
- The in-page bundle (`detect-antipatterns-browser.js`, 2 MB of generated wasm
  glue) is no longer tracked. `crates/core/build.rs` resolves it beside the
  archive, hands the path to `impeccable_core::browser::IN_PAGE_BUNDLE_JS`, and
  live mode serves that. `scripts/check-detector-release.mjs` now requires it
  and its `.sha256` in a detector release.
- The live crate embeds `skill/scripts/live-browser*.js` and
  `modern-screenshot.umd.js` directly rather than through vendored copies, so
  the binary and the installed skill cannot drift.
- `crates/browser/assets/` (an unused second copy of the bundle) is gone.
- `tests/lib/engine-bin.mjs` also accepts `target/release/impeccable`, so a
  plain `cargo build --release -p impeccable` is enough to run `bun run test`.

Verified with the archive from a local detector build: `cargo test --workspace`
267 pass, oracle 795 pass / 0 fail / 0 missing, `bun run build` clean, the
default suite green, and the launcher's `engine-probe` handshake answering
through `skill/scripts/impeccable`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* docs: bring RUNTIME-ENV and PORTING-GUIDE over with the runtime

They describe the binary's environment contract and the parity method every
crate here was ported with; both belong next to the crates now.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* core/build.rs: refuse a detector archive built by another rustc, in plain words

The archive links only against the exact rustc that built it; a mismatch
used to surface as pages of undefined std symbols from the linker. The
detector repo now writes rustc-version.txt next to the archive (and ships it
with the release); when it is present, build.rs compares it with its own
compiler and names both versions.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* build:extension: ship the wasm-core extension shell and vendor its detector from the detector release

`bun run build:extension` was broken on this branch: it still imported the
deleted JS engine (cli/engine/registry/antipatterns.mjs,
scripts/lib/browser-detector-bundle.js).

The shipped shell now matches the new design. The content script only
snapshots the DOM; an extension-owned offscreen document runs the
WebAssembly rule core over that snapshot, so the scanned page's CSP no
longer matters. That replaces the old approach of injecting a JS rules
bundle into the page. New files: extension/offscreen/offscreen.html, plus
the "offscreen" permission and a 'wasm-unsafe-eval' extension_pages CSP in
the manifest.

The manifest version stays at 1.3.3. The shell's own manifest carried
2.0.0; feature branches never bump versions, so the bump is a release step.

The five generated detector pieces (core.js, core_bg.wasm, snapshot.js,
overlay.js, antipatterns.json) are vendored at build time into the
gitignored extension/detector/ by the new scripts/lib/detector-bundle.mjs,
which resolves them the same three ways crates/core/build.rs resolves the
native archive: IMPECCABLE_DETECTOR_LIB/extension-detector/, the
~/.impeccable/detector/<DETECTOR_VERSION>/ cache, then a checksum-verified
download of detector-browser-bundle.zip from the detector release.
antipatterns.json is no longer regenerated here.

The zip packaging is unchanged. The Firefox variant still builds so
`web-ext lint` keeps covering the shared shell, but it cannot scan: Gecko
has no chrome.offscreen API. The build prints a one-line warning saying so.

Also here: a referenced-path check that fails the build when the manifest
or the service worker points at a file that is not in extension/, a
resolver unit test wired into the core suite, and the detector rule count
in the READMEs synced to the 61 the vendored registry carries.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* oracle: replay byte-for-byte on Linux too

The corpus was recorded on macOS and eight cases failed on ubuntu CI for
reasons that were all environment, not behavior:

- stageWorkspace returns the realpath of the staged dir. macOS's tmpdir is a
  symlink and two goldens (context-dir-override, live-accept-source-locked)
  had recorded that artifact; both re-recorded, reviewed in DELTAS.md. The
  source-locked case now actually exercises the lock it is named for.
- context-lowercase-product-name declares platforms: ['darwin', 'win32'];
  run.mjs skips such cases elsewhere and says so in the summary.
- The hook-project workspace's empty provider skill folders (.claude,
  .cursor) are now tracked with .gitkeep; git cannot track empty
  directories, so a fresh checkout had none and hooks on found nothing to
  repair.
- crates/live's read_dir_raw sorts entries by name: the goldens hold the
  order macOS returned, Linux returns hash order, and the source-candidate
  lists in live-commit output depended on it.

macOS: 795 pass, 0 fail. The Svelte accept cases additionally need the
public repo's node_modules on the machine that runs them (CI now installs
them).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* oracle: mask <HOME> only at path boundaries (a short home like /root ate 'roots.json')

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* oracle: track live-html's dist/generated.html (the root dist/ ignore hid it from CI checkouts)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* release-engine: darwin-x64 builds on macos-14 (macos-13 is retired)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* Open the detector: the rule crates join the workspace, the C-ABI goes away

The detector is open source. The rules it ships were already public in this
repo's git history and in every npm tarball of the JS engine, so a closed
binary bought nothing it could keep; the moat is the service (the catalog,
the labs, the review pipeline), not the check functions. Keeping them behind
a prebuilt archive cost a C-ABI, an exact toolchain pin, a build-time
download, a second release to order ahead of every engine release, and a
serde layer that had to serve two encodings.

Deleted
- crates/core/src/ffi.rs, crates/core/build.rs, crates/core/tests/boundary.rs
  and the shim modules under src/checks and src/browser.
- crates/foundation/src/boundary.rs and the postcard dependency.
- DETECTOR_VERSION, scripts/check-detector-release.mjs and its test, the
  check:detector-release script, the detector gate and
  IMPECCABLE_SKIP_DETECTOR_CHECK in scripts/release.mjs.
- scripts/lib/detector-bundle.mjs and tests/detector-bundle.test.mjs (the
  vendoring path for the closed browser bundle).
- scripts/build-browser-detector.js and the build:browser script (a stub
  since the JS engine left the tree).
- xtask's detector-archive subcommand and its public-repo lookup.

Came back
- crates/core is now the rule logic itself: every check_* / scan_*, the
  browser adapters, the visual-contrast decisions. It re-exports foundation
  as before, so no consumer changed. Its vectors dispatcher is the union of
  both id tables again, and tests/vectors.rs replays the frozen vectors
  straight through it.
- crates/wasm and crates/xtask join the workspace. cargo xtask bundle builds
  the in-page bundle from browser-bundle/ plus the wasm core, writes
  dist/, refreshes the tracked crates/live/assets/detect-antipatterns-
  browser.js, and writes extension/detector/. bun run build:extension runs
  it instead of downloading.
- crates/live/assets/detect-antipatterns-browser.js is tracked again; live
  mode embeds it and serves it as /detect.js.
- Serde is back to plain derives: no is_human_readable branch in
  js::json_number, derived Serialize for Rgba and BrowserFinding with their
  skip_serializing_if attributes.
- profile.release has lto = "fat" again; rust-toolchain.toml is plain
  stable plus the wasm32 target. The rust, rust-windows and oracle CI jobs
  lose continue-on-error and can be required.

Verified
- cargo build --workspace --all-targets: clean, no warnings.
- cargo test --workspace: 346 pass, 0 fail (the 8 boundary tests are gone
  with the boundary).
- cargo build -p impeccable-wasm --target wasm32-unknown-unknown --release: ok.
- cargo xtask bundle && cargo xtask bundle --check: reproducible; the
  regenerated bundle is committed (it differs from the archived one, which
  was built with a pinned rustc and lto = false).
- cargo build --release -p impeccable: no linker warnings, 12.5 MB (the
  same source at lto = false is 13.1 MB).
- oracle: 795 pass, 0 fail, 0 accepted deltas, 0 missing goldens.
- bun run build, bun run build:extension, web-ext lint (0 errors,
  8 warnings), bun run test: 363 + 80 + 1 + 1 + 133 + 180 + 4 pass, 0 fail.
- impeccable detect --no-config --json tests/fixtures/antipatterns: 128.7 ms
  median of 5.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* core: doc comments drop the open/closed split

The rule crate and the foundation crate are both Apache-2.0 in one
workspace now, so "open", "closed" and "crosses the boundary" no longer
describe anything. Comments only.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* Rule packs: downstream crates add rules on all three engines; wasm detect surface

A crate that depends on this workspace can now add rules without forking
it. `impeccable_core::rule_pack::RulePack` (object-safe, Send + Sync +
Debug) carries a pack's registry rows plus three hooks that default to
empty: `check_text` for the text engine, `check_element_dom` and
`check_page_dom` for the browser driver. `impeccable_html::StaticRulePack`
adds `check_document` for the static engine, where the document model
belongs to the html crate and detect cannot name it.

The registry keeps ANTIPATTERNS as the built-in list; `registry::extend`
appends a pack's rows and every lookup consults them after the built-ins,
so a pack can never shadow a built-in id (extend panics on a collision and
is idempotent per slice). `all_antipatterns()` is the built-ins followed by
the registered rows.

Hook order, chosen so built-in output cannot move:

- detect_text: after every matcher, analyzer and the dedupe, before inline
  ignores, so `impeccable-disable` waives pack rules like built-in ones.
- detect_html_source: after the element rules, the design-system merge and
  the page passes, again before inline ignores. One pack pass per HTML
  file: the document hook when set, otherwise the text hook over the raw
  source, so a pack implementing both never reports twice.
- collect_browser_findings: the element hook at the end of the per-element
  loop through the same disabled-rules filter and group, the page hook
  after every built-in page pass with the same el-or-body attribution.

A pack travels on TextOptions / ScanOptions, DetectHtmlOptions
(static_rule_pack plus rule_pack), StaticHtmlEngine, and BrowserConfig
(serde-skipped: a pack is a Rust value, not JSON from the page). The
shipped binary installs none.

`crates/wasm --features detect` exposes the two file engines as JSON
exports for hosts that cannot exec the binary: `detect_text_json` and
`detect_html_source_json`, options `{ inlineIgnores?, designSystem? }`,
returning the findings array `detect --json` prints. `antipatterns_json`
now includes a pack's rows. `set_rule_pack` and `set_static_rule_pack` are
Rust-only, for a crate that links this one as an rlib.

Tests: registry extension and collision in foundation, one test pack per
engine (crates/core, crates/detect, crates/html tests) proving each hook
fires, that the built-in findings are unchanged, and that the waivers and
the disabled-rules list cover pack rules, plus the wasm export shapes.
Workspace tests 346 to 361, oracle 795/0 unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* docs: the cutover checklist under the open design

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* bundle: the page JS and the bundler become a library crate downstream packs can reuse

The in-page bundle, the extension pieces, the registry JSON and the
wasm-pack call were reachable only through `cargo xtask bundle`, which read
`browser-bundle/*.js` from the repo root. A downstream crate that links
impeccable-core + impeccable-wasm with its own rule pack had to copy the
page JS to produce a detector bundle for its module.

They move to `impeccable-bundle` (crates/bundle), which embeds every
`browser-bundle/*.js` with `include_str!` and exposes `in_page_bundle`,
`extension_pieces`, `registry_json`, `check_capture_contract` and
`wasm_pack_build`. Nothing writes files or exits the process; the caller
places the bytes. `registry_json` now reads `all_antipatterns()`, so an
installed pack's rows land in `antipatterns.json` too (no built-in change).

xtask becomes the workspace's caller and writes the same files to the same
places; `cargo xtask bundle` is byte-identical, tracked live asset included.
`IMPECCABLE_BUNDLE_SKIP_WASM_PACK` is the skip switch's new name, the old
`IMPECCABLE_XTASK_SKIP_WASM_PACK` still works.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* The immediate tier moves to the registry, and reaches wasm

The design hook's immediate-tier list is the set of rule ids worth fixing
at the edit site, and a downstream reviewer wants the same set to decide
how loudly a finding is reported. `impeccable-hook` is native-only, so the
list moves to `impeccable_core::registry` (the hook re-exports it) and the
`detect` feature gains `immediate_tier_rules_json()`.

The export is behind `detect`, which the in-page bundle does not build, so
the tracked browser asset is unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* docs: Pristine tracks the engine by revision pin, not npm

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* docs: the cutover checklist is maintainer-side, not part of the tree

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* Port: Fix flat type hierarchy false positives (#702)

Upstream sha 84728e9ce4.

The rule now reads rendered semantic roles and the dominant size per role
instead of the raw set of font sizes on the page, and it fires only when
every adjacent role step is under 1.25x.

- crates/core checks::rules gains TYPE_HIERARCHY_SELECTOR / MIN_ROLES /
  MIN_STEP_RATIO, typeHierarchyRole, dominantTypeRoleSize and
  checkFlatTypeHierarchySamples, the shared half of checks.mjs.
- crates/core browser::page_checks gets checkFlatTypeHierarchyFromDoc over
  the Dom trait, with the overlay skip selector checkTypography passes.
- crates/html page.rs gets the same walk over StaticDocument.
- crates/detect drops the source-only analyzer: flat-type-hierarchy leaves
  REGEX_ANALYZERS, the text-content analyzers shift to index 1, and
  analyzer_rule_id loses its first row.
- crates/html cascade defaults gain contentVisibility, and crates/foundation
  registry carries the reworded description.

Goldens re-recorded (the binary now matches origin/main's JS engine on every
one of these fixtures, verified by scanning the shared corpus with both):
glow, icon-tile-stack, layout, modern-color-borders, motion,
named-color-borders, numbered-section-markers, oklch-neon-text,
typography-should-flag, json and text.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* Port: Fix detector URL scans and advisory handling (#709)

Upstream sha fa44839f72.

Advisory handling. `severity` becomes the canonical registry field: the
`advisory` bool leaves `Antipattern`, `advisory_rule_ids` filters on
`severity == "advisory"`, and `derive_advisory_flag` stamps the finding's
`advisory: true` from the effective severity, so a per-finding promotion or
demotion carries the flag. The html and browser engines call it after their
severity override; the detect CLI and the hook accept either spelling; the
driver's serializer and the wasm registry exports derive it the same way.
em-dash-overuse moves from `advisory: true` to `severity: "advisory"`.

URL scans. `expand_joined_url_targets` splits an argv value that is entirely
whitespace-separated URLs and leaves paths with spaces alone. The browser
driver reads the readable linked-stylesheet corpus into the HTML pattern
corpora and resolves a finding's selector with `selector_nodes_for_live_dom`
/ `pseudo_element_host_selector`, so an unresolvable selector drops the
finding instead of keeping it page-level. The CSSOM walk itself is page JS:
`browser-bundle/15-snapshot.js` gains `__snapLinkedStylesheetText` (grouping
rules flattened, container-query probes, effective keyframes) and puts it in
the snapshot as `linkedCss`; `10-probe.js` exposes the same for the in-page
route, and the Dom trait carries `linked_stylesheet_text`.

Also `enclosing_css_selector` blanks comments before hunting the previous
declaration delimiter, and `check_typography` reports the uniquely most-used
family instead of every family over a 15% share.

Verified: `impeccable detect --no-config --json tests/fixtures/antipatterns`
is now byte-identical to `node cli/bin/cli.js` on an origin/main worktree
over the shared corpus (432 findings). The two changed lines in
tests/oracle/vectors/calls/rules.checks/checkHtmlPatterns.jsonl were
re-recorded by running origin/main's `checkHtmlPatterns` over the frozen
args; only the comment-polluted selector changed. Goldens re-recorded for
the advisory partition (config-*, fixture gemini/gpt-tells,
numbered-section-labels, scoped-ignore, shape-assembled-illustration,
color, em-dash-entities) and the help text, each cross-checked against the
JS on origin/main.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* Port: stop gray-on-color false positives on Tailwind opacity and JSX (#707)

Upstream sha 32b270f4e8.

`find_solid_chromatic_bg` replaces the bare `bg-<hue>-<n>` match in both
engines: a `bg-blue-500/10` tint is a wash, not a solid fill. The `regex`
crate has no lookahead, so the maximal digit run plus the word boundary is
matched as before and the byte after it is tested for `/`.

The text engine gains the JS-source scanner (`scan_js`) and the scope
helpers on top of it: `containing_markup_tag` keeps a gray text class from
pairing with a background in a sibling tag on the same line, and
`find_ternary_split` / `exclusive_class_scopes` split a `cond ? a : b`
class expression into its arms, recursing into nested ternaries, ignoring
`?.` and `??`, and keeping a common prefix and post-ternary suffix in every
arm. `MatchCtx` now carries the match offset the scope lookup needs.

Verified against origin/main's JS: all eleven cases from the upstream test
file plus a nested / nullish / suffix set produce byte-identical findings on
both engines; they are pinned as Rust unit tests in `regex_matchers` and
`checks::rules`. The shared fixture corpus stays byte-identical.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* Port: resolve unique --target names in monorepos (#706)

Upstream sha 8b326fc81e.

`resolve_target_path` / `find_unique_bare_target` in `crates/context`: a
`--target` that does not exist and reduces to a single path segment under
cwd resolves to the one workspace candidate with that name, so `--target a`
selects `apps/a`. A caller that already absolutized the name against cwd
(live and the other helpers do) takes the same route. Ambiguous or unknown
names still report the miss.

The context CLI resolves the target once and hands the resolved path to
`load_context`, replacing `path_exists_for_target`.

Oracle: four new `context-monorepo-target-bare-*` cases (bare name,
absolutized bare name, unknown name, bare name from a child cwd).
`context-monorepo-target-b-inherits` was re-recorded: resolving the target
before `load_context` changes its `surfaceBriefReason` from `not-found` to
`invalid-target`, which is what origin/main's `context.mjs` prints for the
same run.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* Port: Fix Next.js 16 CSP and parent hook discovery (#710)

Upstream sha 672ca29642.

CSP. `detect-csp` recognizes Next.js 16's `proxy.{ts,js,mjs}` request hook
beside `middleware.*`, but only where it sits at a project root or its `src/`
directory: the scan root itself, or a nested directory carrying a Next
project marker (a `next.config.*`, an `app` / `pages` dir, or a `next`
dependency). A same-named helper elsewhere in the tree is not the framework
hook.

Context. `find_git_boundary_root` gives `resolve_project` a git-boundary
notion: an explicit target inside its own repository resolves against that
repository, and an external target resolves against its own root, so caller
context never leaks across the boundary. `hook_manifest_search_roots`
replaces the cwd/projectRoot/repoRoot triple with a walk up from the
project root that stops at the first git boundary, and each root's own hook
lifecycle config is honored before its manifest counts as coverage.

Verified against origin/main's JS: nine `detect-csp` placements and five
hook-discovery scenarios (enclosing harness root, that root disabled,
sibling target, nested git target, markerless nested git target) produce
identical output.

Oracle: five `csp-proxy-*` cases and five `context-hook-*` /
`context-markerless-nested-git-target` cases. Four route-target goldens were
re-recorded because #710 resolves a `/`-prefixed target outside the
workspace; each was cross-checked against origin/main, and
`surface-brief-write-route` has a DELTAS entry for the one wording
difference (an unwritable filesystem root).

`tests/framework-fixtures.test.mjs`'s new proxy-placement block came in from
the merge importing the deleted `detectCsp`; it now drives `detect-csp`
through the binary like the rest of that file.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* Port: fail URL scans when the browser is unavailable (#711)

Upstream sha f2f9958be1e6a4ecb1fbd5ef1ae1b7d9c53e0d24 (Fix: fail URL scans
when the browser is unavailable).

`detect` gains an operational-failure flag. Exit 1 now means at least one
requested target could not be scanned, and it takes precedence over exit 2,
because findings from the targets that did scan do not turn a partial scan
into a complete one. The flag is set by an unreachable path, an unreadable
directory or file in a dir walk, a per-file scan that throws, a URL scan
that throws, and a shared-browser setup failure.

- `walk_dir_reporting` and `build_import_graph_reporting` take a read-error
  callback; the plain wrappers stay for callers that do not report. A file
  the graph could not read is skipped for the scan too.
- `SharedBrowser::ensure_launched` is the eager half of
  `createBrowserDetector()`: the CLI brings the browser up before the loop so
  a launch failure prints one `Error:` line and every URL target is skipped,
  instead of the lazy launch reporting once per URL.
- The static engine and the text path spell a permission failure the way Node
  does (`EACCES: permission denied, open '<path>'`), which is what
  `Error: cannot scan <target>: <message>` prints.
- Usage text and docs/CLI-CONTRACT.md carry the exit-status block.

Verified against origin/main's JS: missing target, missing target alongside a
flagging file, unreadable file, unreadable file beside a readable sibling,
unreadable directory, unreadable nested directory, a clean scan, and a
browser-unavailable scan of one and of two URLs all agree on exit code,
stdout and stderr (the browser-not-found wording is the pre-existing
puppeteer-vs-discovery difference).

Oracle: `detect-missing-file` and `detect-missing-file-json` re-recorded at
exit 1, plus new `detect-missing-file-with-findings`,
`detect-unreadable-file-json` and `detect-unreadable-file-in-dir`, each
cross-checked against origin/main. `detect-help` carries the new block.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* Port: OpenCode slash command bridge (#483)

Upstream sha 9736a9f6e9.

OpenCode does not honor `user-invocable: true` on SKILL.md frontmatter, so a
pinned skill never reaches its slash menu. `pin` now writes
`commands/impeccable-<cmd>.md` on the OpenCode command schema instead, and
skips `.opencode` in the SKILL.md loop so no unreachable
`.opencode/skills/<cmd>` is left behind. `unpin` mirrors it, marker-guarded,
and reaches both scopes even when the skill itself is gone.

`find_opencode_commands_dirs` covers the project-local dir when the project
has the skill and the user config dir when Impeccable is installed globally,
resolving that dir the way the CLI does (`OPENCODE_CONFIG_DIR` ->
`XDG_CONFIG_HOME/opencode` -> `~/.config/opencode`).

The build-tooling half of the upstream change (transformers, the OpenCode
command the build generates, `root-commands-sync`) came in with the merge and
needed no port.

Verified against origin/main's pin.mjs across seven scenarios (no harness,
project scope, user scope, a foreign command file, pin then unpin, unpin over
a foreign file, unpin with nothing pinned): identical stdout, identical file
sets, identical file contents apart from the one deliberate difference.

Oracle: five `pin-opencode-*` cases, with a DELTAS entry for the bridge body
naming the launcher rather than `node .../context.mjs`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* Port: Fix Codex skill version metadata (#703)

Upstream sha 482368511a.

Codex's validator rejects unknown top-level keys, so the Codex and `.agents`
skills now carry `version` under the spec-defined `metadata:` map. Both
version readers learn the same parser: `parse_skill_frontmatter_version` in
`crates/context` (the boot update check) and `extract_version` in
`crates/skills` (`getSkillsVersion`). A metadata version wins, a legacy
top-level one still reads, only the map's own indent level counts, tabs count
as two spaces, and a comment line is skipped.

The build-tooling half (`versionInMetadata` on the two providers, the YAML
emitter's nested-object branch) came in with the merge.

Fourteen frontmatter shapes were recorded from origin/main's
`parseSkillFrontmatterVersion` and pinned as unit tests in both crates.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* Port: Fix skill subcommand help handling (#708)

Upstream sha a264199177.

`install`, `link`, `update` and `check` render static help before entering
any operational path, through both the top-level verb and the legacy `skills`
namespace, for `--help` and `-h` alike.

Verified against origin/main's `cli/bin/cli.js`: all six spellings produce
identical text and exit codes.

Oracle: a new `tests/oracle/cases/skills.mjs` with seven help cases. Only the
help paths are pinned there; every other installer path writes into harness
directories or reaches the network.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* Oracle: goldens for the three fixtures the merge added

`tests/fixtures/antipatterns/` gained `flat-type-hierarchy.html` (#702) and
`linked-url-patterns.{css,html}` (#709) with the merge, so the corpus
generator produced six `detect-fixture-*` cases with no goldens and the
directory-wide cases (`detect-dir-*`, `detect-scope-*`, `detect-no-advisory-*`)
moved.

Every golden here was recorded from the binary and then cross-checked against
`node cli/bin/cli.js` on an origin/main worktree over the same files: the six
per-fixture cases agree byte for byte in JSON and text, and a full scan of
`tests/fixtures/antipatterns` produces 432 findings identical on both engines
after normalizing the repo path.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* Port: the installer half of the OpenCode command bridge (#483)

Upstream sha 9736a9f6e9, the part of it that
lives in `cli/bin/commands/skills.mjs` rather than `pin.mjs`.

`copy_provider_commands` mirrors `copy_provider_skills` for a provider's
compiled `commands/` dir: project scope writes `<root>/<configDir>/commands`,
user scope writes the config dir OpenCode actually scans
(`OPENCODE_CONFIG_DIR` -> `XDG_CONFIG_HOME/opencode` -> `~/.config/opencode`),
and a pre-#406 global install at `~/.opencode/commands/` loses exactly the
files just written while siblings, symlinked dirs and home-rooted git repos
are left alone. It runs on install, on the reinstall refresh, on update, and
on link, which is the only path that can deliver the bridge to a linked
install.

`is_up_to_date` now compares the bundle's command files too, so an install
whose skills match but whose bridge is missing or drifted refreshes instead of
reporting success while the slash command stays absent. Only bundle-shipped
files are compared, so a pinned shortcut never affects freshness.

`tests/copy-provider-commands.test.js` arrived with the merge importing the
deleted `cli/bin/commands/skills.mjs`; its scenarios are ported to
`crates/skills/tests/provider_commands_tests.rs` (project scope, the three
user-scope dir resolutions, the legacy migration and its two guards, a
provider with no commands dir, and the four `isUpToDate` command-awareness
cases), and the file is removed and deregistered.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* CI: the first full run on the branch, three fixes

- The oracle harness masks the climb to the root a /-prefixed target
  produces (<UP_TO_ROOT>/): the number of `../` is the staged tmpdir's depth
  (7 on macOS, 2 on Linux), not the verb's behavior. surface-brief-path-slash
  re-recorded.
- Two context test helpers canonicalized their temp dir, which on Windows
  yields a \\?\ verbatim path that takes `/` literally; they strip the prefix
  like Node's realpathSync. The critique-storage identity test compares
  against the platform's own resolved path.
- Every job that drives the binary end to end (live-e2e smoke and full,
  accept-cleanup, the DeepSeek sweep, the remote CLI smoke) builds it from
  the checkout first; before, they looked for a release that does not exist.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* context tests: the verbatim-prefix strip spells the prefix once

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* context test: derive the snapshot identity from the verb's own resolver

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* context test: JSON-quote the snapshot identity, as the verb does

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* detect test: import resolution against platform-form paths

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* hook test: the stock cache path in the host's path form; Windows CI runs every crate's tests before failing

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* windows: skills tests pass on Windows

The two test temp roots kept `canonicalize`'s `\\?\` verbatim prefix, and the
kernel takes a verbatim path literally, so every `/`-joined path built under
them was an invalid filename. Strip it the way Node's `realpathSync` does.
The manifest, artifact and sibling-binary expectations hard-coded POSIX
separators for paths the product joins with the host's semantics; derive them
from `jsp::join` instead.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* windows: hook tests pass on Windows

Same verbatim-prefix strip on the test temp roots, plus expectations derived
from the helpers the product uses: cache keys and scan targets from
`jsp::join`, the config path in an admin message from the same relative form
`path.relative` renders, and the footer hints from `quote_command_arg`, which
deliberately switches to the double-quoted Windows form (#476 / #533). The
env lock no longer poisons the sibling tests when one of them fails.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* windows: html oracle goldens compare on Windows

The goldens pin the `<REPO>`-masked fixture path recorded on POSIX. Mask, then
render the remainder with `/` so a Windows checkout's backslashes are not read
as a finding difference. The goldens are untouched.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* windows: widen the live read-deadline test's margin

Timing only. The watchdog polls in 50ms steps against a ~15.6ms Windows system
timer while the crate's tests run in parallel, so the later request takes its
turn later there. The bound stays far under the 60s read timeout a
deadline-less read would hold the ticket for, so the test still distinguishes
the fix from the regression.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* windows: the request read deadline was not enforced on Windows

Windows does not unblock a `recv` already parked in the kernel when another
thread calls `shutdown` on the same socket, so the watchdog could not end a
silent connection's read and it held its turnstile place for the whole 60s
header timeout instead of the 10s deadline. Bound the read at the socket too,
which enforces the same deadline everywhere; the watchdog stays as the backstop
for a connection that trickles bytes without ever completing a request. POSIX
behavior is unchanged: the watchdog already closed the socket at the deadline.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* windows: hook tests derive the rest of the host path forms

The test temp helper's `write` returned a `PathBuf::join` result, which keeps
the `/` inside the relative part and so does not match what the hook resolves a
relative target to on Windows. Three more admin messages and the cache-root slug
pinned the POSIX spelling of paths the product renders with the host's
semantics (`path.resolve` also prefixes the current drive there).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* windows: skills test fixtures name USERPROFILE, and the win32 quoted form

`os.homedir()` reads USERPROFILE on Windows, so a fixture home that named only
HOME sent the global installs into the runner's real profile. The Windows hook
command carries the JSON-quoted path, so a host path's backslashes arrive
escaped; derive the expectation instead of pinning the POSIX spelling.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* windows: check the oracle fixtures out with LF

A finding's snippet carries the scanned file's own bytes, and the goldens were
recorded from a POSIX checkout, so a CRLF checkout of a linked stylesheet reads
as a finding difference. The goldens are untouched.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* windows: check the grok global-install manifests as JSON

The Windows hook command carries the JSON-quoted launcher path, so the path's
backslashes are escaped once inside the command and again by the manifest file
itself. Read the manifest as JSON and look for either quoting form instead of
counting escaping layers in a raw substring match.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* npm shim: refuse a download with no verifiable sidecar

The skill launcher and `impeccable install` both fail closed when a
release binary's `.sha256` sidecar cannot be fetched or carries no hash:
they refuse rather than cache an unverified binary. The npm shim did not.
It only compared when a hash was present, so a 404, an empty sidecar, or
a truncated one all wrote the payload straight into
`~/.impeccable/bin/<version>/` and exec'd it.

It now refuses in the same cases, with wording that matches the launcher,
and writes nothing until the hash matches, so a refusal leaves the cache
dir empty. IMPECCABLE_BIN and the optional-dependency lookup are
untouched: neither downloads.

tests/cli-shim.test.mjs runs the real shim against a throwaway HTTP
server and covers missing, empty, and mismatched sidecars, plus the
matching-sidecar and IMPECCABLE_BIN paths. The two refusal cases fail
against the old shim.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* Oracle fixture: declare the vite plugin the web workspace imports

`live-workspaces/apps/web/vite.config.js` imports `@vitejs/plugin-react`
but the workspace's package.json listed only `vite`. No oracle case
installs or evaluates that config (the three `live-boot-workspaces-*`
cases stop at root resolution), so the fixture was never wrong at
runtime, only self-contradictory to read. Adding the devDependency keeps
the goldens byte-equal.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* Vectors: drop the 12,208 byte-identical repeat lines

The recorder deduplicated by arguments per run, not across runs, so the
frozen call snapshot arrived with 12,208 lines (43% of 28,266) that
repeat an earlier line byte for byte. Every one re-asserts what its first
occurrence already asserts, and `crates/core/tests/vectors.rs` replays
line by line with no count anywhere, so removing them changes nothing it
checks: the replay still reports 8,321 pass, 0 fail.

Duplicates were removed with `awk '!seen[$0]++'`, keeping first
occurrences and file order, and every changed file was checked to equal
that transform of its old contents. No line was added, reordered, or
rewritten, and no vector file gained or lost a distinct call. The tree
drops from 9.2 MB to 5.7 MB.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* Fix: restore the live overlay's disabledValues waivers in the engine

The JS engine applied value-level ignore waivers at the tail of
collectBrowserFindings: `_disabledValues` read the entries the live
overlay resolved for the page (skill/scripts/live-browser-ignores.js
sends them as config.disabledValues), and filtered the assembled
findings by the value each one reported, with design-system-color
compared by color value rather than by spelling so a hex waiver
suppressed a finding the browser reported as rgb(...). The Rust port
dropped that stage: `disabledValues` appeared nowhere in the workspace
or in browser-bundle, so a project entry like

    [detector]
    ignoreValues = [{ rule = "overused-font", value = "geist mono" }]

stopped reaching the overlay. The rules the CLI and the edit hook waive
kept drawing markers and counting toward the badge.

Restore it end to end:

* BrowserConfig gains `disabled_values`, parsed leniently so a
  hand-edited __IMPECCABLE_CONFIG__ entry of the wrong shape is dropped
  rather than failing the whole config, the way the JS filter did.
* The driver applies the waivers after every pass, so a rule pack's
  findings are covered the same way the built-in ones are, honoring the
  entries only in extension mode exactly as the JS read them. The
  normalizer, the value extractor (including the rule that bounce-easing
  without a direct ignoreValue offers no value) and the hex/rgb color
  key are ported alongside it.
* collectConfigJson in the in-page bundle and configJson in the
  offscreen bundle forward the field. The extension never sends it, so
  its behavior is unchanged.

Coverage: two driver unit tests (suppression by font value, by hex
waiver across the rgb spelling, and the extension-mode gate; plus the
config parse and the normalizers), a skipScan test that pins the empty
shape for every stage the core produces, and
crates/wasm/tools/disabled-values-check.mjs, a browser-backed check
ported from the retired tests/detect-antipatterns-browser.test.mjs case
that the swap left without a replacement. Against the previous bundle it
fails on exactly the three waiver assertions and passes the skipScan
one, which is the shape of the regression.

Two related review findings were checked and are not defects. skipScan
is gated on extension mode in both the driver and the bundle, which is
what the JS did (index.mjs#skipScanActive), and the live overlay runs in
extension mode: live-browser.js sets `s.dataset.impeccableExtension` on
the injected /detect.js tag, and the overlay's whole detect toggle
travels over the postMessage loop that 50-scan.js installs only under
EXTENSION_MODE. The visual contrast stage is not leaking either:
collectBrowserFindingsAsync and scan() both consult skipScanActive(),
and the offscreen path skips its visual pass on config.skipScan.

The tracked live asset is regenerated (cargo xtask bundle). The oracle
replays with zero unreviewed differences: the new field defaults empty
and the filter is inert without it, and no CLI path sets extension mode.

AI-assisted change: implemented with Claude Code under maintainer
direction.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* Shim test: run from a staged copy and prove the download happened

The three fail-closed cases cleared IMPECCABLE_BIN and pointed
IMPECCABLE_HOME at a temp dir, but locate() prefers an installed
@impeccable/cli-<os>-<arch> before the cache or a download. Those
platform packages ship with every engine release and are a merge
prerequisite, so as soon as one is installed under the repo the cases
would resolve it and go green without fetching anything. Confirmed by
hand: with a platform package staged in node_modules, running the shim
against an unreachable download base still exits 0 from the package.

The shim now runs from a throwaway copy at <tmp>/cli/bin/cli.js beside a
copy of the repo's package.json, with no node_modules on the lookup path
above it, so require.resolve of the platform package fails the way it
does on a machine without the optional dependency. Production code is
unchanged; there is no test-only branch in the shim.

The fixture server also records every request now, and each download case
asserts the asset and sidecar URLs were actually requested, so a future
lookup shortcut fails loudly instead of passing on an untested path. A
sixth case installs a fake platform package next to the staged shim and
asserts the shim prefers it with the server untouched, which pins the
precedence the other cases depend on being absent.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* Live: the loader now hands off when the resume is the arrival

The overlay could sit in its generating shader over a DOM that already
held all three variants, and only a page refresh cleared it (#719).

The server's generation preflight runs live-wrap with
--defer-source-write, so the wrapper and every variant reach the DOM in a
single HMR batch. The deferred-wrapper scout is constructed at init and
the variant MutationObserver at Go; observer callbacks run in
construction order, so on that batch the scout resumes first and
resumeSession, not the observer, is the transition into CYCLING. It set
the state and the bar but never called hideShaderOverlay(), so the frozen
capture of the original stayed painted over the variants. It also
reported browser_resumed, which does not count as publication progress,
and then disconnected and re-created the observer, dropping the records
that observer had already queued for the same batch, so variants_ready
never fired at all.

resumeSession now finishes the same transition the observer does (shader
down, inline edit off, insert session finalized, params panel rebuilt)
and reports variants_ready when it already holds every variant. The
deferred scout names itself in the journal as
browser_resumed_deferred_wrapper, so the two resume paths are no longer
indistinguishable.

Wrapper resolution goes through findVariantsWrapper, which prefers a
wrapper that actually holds non-original variants. A target inside a
.map() renders one wrapper per item, and an agent that relocates the
wrapper out of the shared primitive live-wrap scaffolded leaves an empty
one behind; first match could pin either and strand the session at 0/N.
With zero or one match this is the querySelector it replaces.

Tests: waitForCycling now asserts the generating shader is gone once the
bar cycles, across every runtime fixture (it failed on vite8-react-plain
before this change and passes after), marked no-retry so the reload
recovery cannot hide it. Source-shape tests pin the transition, the
variants_ready report, and the wrapper preference.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* Live server: stop ends the process, SSE skips the mutation lane

Two Rust-only regressions found while investigating #719, both of which
can leave a tab waiting on a broadcast that never comes.

/stop ran shutdown() but never set shutting_down, and the accept loop
only breaks on that flag or a signal, so a stopped server kept its port
and kept answering while its server.json was already deleted. The next
`impeccable live` then booted a second server on another port and a tab
could reattach to the zombie. Node's shutdown() ended in process.exit(0).
The flag is now set after the response is written, so `stop` still reads
"stopping" instead of a reset connection, and the accept loop (already
non-blocking) exits on its next pass.

GET /events took a turnstile ticket and waited its turn before
registering, even though handle_sse releases that ticket two statements
later and needs no arrival ordering. A peer that stalls mid-request holds
the lane for the whole READ_REQUEST_DEADLINE, so a reconnecting stream
could sit unregistered for up to 10 seconds (measured 9.71s against 0.00s
on Node); broadcast is fire-and-forget, so a `done` landing in that
window reaches an empty client set and is gone. Registering early can
only make a stream see more broadcasts. The one cost is that the
connected frame's activeSessions snapshot may miss a mutation still in
flight, and the browser treats that snapshot as a hint. Preflights still
take a turn: answering those out of order reorders the POSTs the browser
issues behind them.

The route classification moved into releases_ticket_up_front so it can be
unit tested. tests/live-server-leak.test.mjs gains a guard that a stopped
server's pid is gone and its port is free.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* Live: the shader teardown can no longer race its own construction

The new cycling assertion caught a real defect on CI: vite8-react-insert
reached CYCLING with #impeccable-live-shader still painted over the page.

showShaderOverlay is async. It appends its canvas synchronously, then
awaits createImageBitmap and finishes the GL setup before it publishes
shaderState. hideShaderOverlay returned early on a null shaderState, so a
teardown that landed inside that window did nothing, and the construction
then published itself over a session that had already left GENERATING,
with no teardown left to run. The scroll tick kept repositioning it,
which is why the CI page.html shows the canvas sized from the capture
rect but styled to the cycling anchor.

Every teardown now bumps a shader epoch before it does anything else, and
a construction pins the epoch it owns and abandons its canvas (releasing
the GL context) at every point past an await and before any publish,
including both bitmap-fallback publishes. A teardown also drops a shader
node that no shaderState owns, so an already-orphaned canvas cannot
survive one.

Reproduced by widening the append-to-publish window: with a 400ms delay
after uiAppend, vite8-react-insert failed with the CI error and the probe
showed the teardown arriving at CYCLING with shaderState still null.
The same run passes with this change, as does a 1500ms window on insert
and plain. Locally that window is about 4ms, which is why it only showed
on a slower runner.

The four remaining setLiveState('CYCLING') sites that did not lower the
loader now do: the SSE done handler (the one route that can reach CYCLING
from GENERATING), the Svelte republish remount, and the two accept
failure recoveries.

The e2e assertion already waits up to 5s for the shader to clear, so it
was never racing a legitimate teardown; it is left as it is.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* Live: every active-session wrapper lookup goes through the resolver

Cursor Bugbot on #720: findVariantsWrapper alone was not enough.
resolveBarAnchor, the visible-variant element, mountedParameterCount,
readVisibleVariantFromDOM, showVariantInDOM, the source injection, and
the whole accept path still took the first [data-impeccable-variants]
match, so in the relocated-wrapper case Tune never bound and the bar kept
anchoring to the empty scaffold even after the resume reached CYCLING.

Thirteen call sites now resolve through findVariantsWrapper. The resolver
split in two so a missing id cannot silently widen the lookup to any
session: findVariantsWrapper(sessionId) returns null without an id, and
findAnyVariantsWrapper() is the entry point for the two resume paths that
have no id yet. Both share pickPopulatedVariantsWrapper, which is the old
querySelector whenever there are fewer than two matches.

Discard cleanup now hides every duplicate wrapper rather than the first,
since a target inside a `.map()` renders one per item and hiding one left
the rest of the discarded variants on screen.

What still takes a raw first match is deliberate: bare existence checks,
selector strings for stylesheets and observers (which want to cover every
match), querySelectorAll sweeps, the parsed source document, and the
Svelte component wrapper, which holds no variant children at all. The
source-shape test pins that exact set by name, so a new raw lookup fails
until it is either routed through the resolver or justified there.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* Live: a discard releases every wrapper it hid

Bugbot on #720: the non-restoreOriginal discard now hides every matching
wrapper, but the delayed fallback still released only the first
querySelector hit. A target inside a `.map()` renders one wrapper per
item, so the rest stayed at display:none and their original content never
came back on the static and missed-HMR flows that fallback exists for.

The hide, the existence checks, and the release now all speak about the
same set. discardedWrappers(sessionId) is the one place that collects it;
releaseDiscardedStaticWrappers takes the stylesheet down once and
releases each wrapper; releaseDiscardedStaticWrapper drops its sessionId
argument and just unwinds the node it is given. The HMR-ownership
decision still reads the first wrapper, which is fair: duplicates all
render from one source element, so ownership is uniform across them. The
reload branch is unchanged because a reload restores every original at
once.

Covered by a source-shape test rather than an e2e scenario:
hasFrameworkHmrOwnership is true for every React, Vue, and Svelte runtime
fixture, so all of them take the watcher path and none can reach the
static release. The existing framework-ownership guards in the same file
move to the new shape and keep their intent, including the one that says
only non-discard cleanup may blank the wrapper while waiting for HMR.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* Release: publish the npm platform packages in one command

bun run release:platform-packages downloads each engine-v<ENGINE_VERSION>
binary with its .sha256 sidecar (required; nothing unverified is
published), stages the package from cli/platform-packages/<target> with
the version stamped, the executable at bin/ and the repo LICENSE, and
runs npm publish --access public. Targets already on the registry are
skipped so a re-run resumes after a partial failure. Preconditions:
package.json pins equal ENGINE_VERSION and npm is logged in.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* release-engine: pin checkout, upload-artifact and download-artifact at v7

The v4 pins target Node 20, which the runner now deprecates and forces
onto Node 24 with a warning on every step. The rest of the workflows
already use v7.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* Tests: make the temp-dir helpers unique under a coarse clock

Windows' system clock is coarse enough that two parallel tests could get
the same pid-plus-nanoseconds directory name and then remove each
other's files (rust-windows: close_verb_round_trip_and_ownership,
NotFound). A per-process counter is appended to the name.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* Tests: declare the temp-dir counter in the hook cache-root tests

The previous commit referenced TMP_SEQ there without defining it.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-09-04 10:42:45 -07:00
committed by GitHub
co-authored by Claude
parent 695df68a58
commit e2ff625b63
1676 changed files with 161105 additions and 110026 deletions
+283
View File
@@ -0,0 +1,283 @@
//! The DOM probe the browser rules run against.
//!
//! The in-page bundle keeps only measurement in JavaScript: every rule that
//! used to be a `checkElement*DOM` / `check*DOM` adapter in `checks.mjs` and
//! the driver in `browser/injected/index.mjs` is Rust code written against
//! this trait. The wasm crate implements it by calling back into a small JS
//! probe object (element handles are indexes into a JS-side registry); unit
//! tests implement it with [`super::fake_dom::FakeDom`].
//!
//! Semantics mirror the DOM APIs the JS called, one method per API, so a
//! ported function reads like the source: `dom.style(el, "fontSize")` is
//! `getComputedStyle(el).fontSize`, `dom.closest(el, sel)` is
//! `el.closest(sel)`, and so on. Where the JS wrapped a call in `try/catch`
//! (invalid selectors), the method returns `Result` and the caller keeps the
//! same fallback.
/// An element handle. `0` is never a valid element (the JS registry keeps
/// index 0 empty), so `Option<ElId>` marshals as a plain u32.
pub type ElId = u32;
/// `DOMRect` as `getBoundingClientRect()` returns it.
#[derive(Debug, Clone, Copy, PartialEq, Default, serde::Serialize, serde::Deserialize)]
pub struct Rect {
pub x: f64,
pub y: f64,
pub width: f64,
pub height: f64,
pub top: f64,
pub right: f64,
pub bottom: f64,
pub left: f64,
}
impl Rect {
pub fn from_xywh(x: f64, y: f64, width: f64, height: f64) -> Self {
Rect {
x,
y,
width,
height,
top: y,
right: x + width,
bottom: y + height,
left: x,
}
}
/// JS `[rect.top, rect.right, rect.bottom, rect.left, rect.width, rect.height].every(Number.isFinite)`.
pub fn all_finite(&self) -> bool {
[
self.top,
self.right,
self.bottom,
self.left,
self.width,
self.height,
]
.iter()
.all(|v| v.is_finite())
}
}
/// An invalid selector: the DOM threw a `SyntaxError`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SelectorError;
/// One `@keyframes` frame as the CSSOM exposes it: the declarations in
/// `frame.style` order (`[prop, value]`, prop as the CSSOM spells it, i.e.
/// hyphenated).
#[derive(Debug, Clone, PartialEq, Default, serde::Serialize, serde::Deserialize)]
pub struct KeyframeFrame {
pub decls: Vec<(String, String)>,
}
/// The DOM measurement surface. Element handles are opaque `u32`s.
pub trait Dom {
// ── document / window ─────────────────────────────────────────────
fn document_element(&self) -> Option<ElId>;
fn body(&self) -> Option<ElId>;
/// `document.querySelectorAll(sel)` (root `None`) or `el.querySelectorAll(sel)`.
fn query_all(&self, root: Option<ElId>, selector: &str) -> Result<Vec<ElId>, SelectorError>;
/// `document.querySelector(sel)` / `el.querySelector(sel)`.
fn query_one(&self, root: Option<ElId>, selector: &str) -> Result<Option<ElId>, SelectorError>;
fn inner_width(&self) -> f64;
fn inner_height(&self) -> f64;
fn scroll_x(&self) -> f64;
fn scroll_y(&self) -> f64;
/// `location.hostname`.
fn hostname(&self) -> String;
/// `document.elementFromPoint(x, y)`.
fn element_from_point(&self, x: f64, y: f64) -> Option<ElId>;
/// `document.elementsFromPoint(x, y)`.
fn elements_from_point(&self, x: f64, y: f64) -> Vec<ElId>;
/// `CSS.escape(s)`.
fn css_escape(&self, s: &str) -> String;
/// The frames of the `@keyframes` rule named `name`, walking
/// `document.styleSheets` in order (nested rules included, cross-origin
/// sheets skipped) and returning the FIRST rule with that name; `None`
/// when no sheet declares it. Mirrors `keyframesToggleVisibilityDOM`'s
/// walk order.
fn keyframes(&self, name: &str) -> Option<Vec<KeyframeFrame>>;
/// `document.documentElement.cloneNode(true)` with every
/// `[id^="impeccable-live-"]` node removed, serialized as `outerHTML`.
fn document_html_for_patterns(&self) -> String;
/// The CSS of every readable linked stylesheet whose rules resolve to a
/// live element, flattened out of its grouping rules (#709). Empty when
/// the probe cannot read the CSSOM.
fn linked_stylesheet_text(&self) -> String {
String::new()
}
// ── element identity / tree ───────────────────────────────────────
/// `el.tagName` (uppercase for HTML elements, as-is for SVG/foreign).
fn tag_name(&self, el: ElId) -> String;
/// `el.namespaceURI`.
fn namespace_uri(&self, el: ElId) -> String;
fn parent(&self, el: ElId) -> Option<ElId>;
fn children(&self, el: ElId) -> Vec<ElId>;
fn previous_element_sibling(&self, el: ElId) -> Option<ElId>;
fn next_element_sibling(&self, el: ElId) -> Option<ElId>;
/// `a.contains(b)` (true when `a === b`).
fn contains(&self, a: ElId, b: ElId) -> bool;
fn matches(&self, el: ElId, selector: &str) -> Result<bool, SelectorError>;
fn closest(&self, el: ElId, selector: &str) -> Result<Option<ElId>, SelectorError>;
// ── attributes / text ─────────────────────────────────────────────
/// `el.getAttribute(name)`; `None` when absent.
fn attr(&self, el: ElId, name: &str) -> Option<String>;
/// `typeof el.id === 'string' ? el.id : null` (a `<form>` with a named
/// `id` control shadows the getter with the element).
fn id_prop(&self, el: ElId) -> Option<String>;
/// `typeof el.className === 'string' ? el.className : null` (SVG
/// elements expose an `SVGAnimatedString`).
fn class_name_prop(&self, el: ElId) -> Option<String>;
/// `el.textContent` (`""` when null).
fn text_content(&self, el: ElId) -> String;
/// `el.innerText` when it is a non-empty string, else `None`.
fn inner_text(&self, el: ElId) -> Option<String>;
/// The `textContent` of every direct child text node (`nodeType === 3`),
/// in order. Empty text nodes are included (they matter for `join(' ')`).
fn direct_text_nodes(&self, el: ElId) -> Vec<String>;
/// `el.isContentEditable`.
fn is_content_editable(&self, el: ElId) -> bool;
/// `el.hidden` (the boolean IDL attribute).
fn hidden_prop(&self, el: ElId) -> bool;
// ── computed style / geometry ─────────────────────────────────────
/// `getComputedStyle(el)[prop]` with `prop` as the JS spelled it
/// (`backgroundColor`, `clip-path`, `float`, ...); `""` when the value is
/// null/undefined.
fn style(&self, el: ElId, prop: &str) -> String;
/// `getComputedStyle(el, pseudo)[prop]`; `None` when getComputedStyle
/// threw or returned nothing (the JS `try { ps = ... } catch { continue }`
/// plus `!ps` guard). `pseudo` is `"::before"` / `"::after"`.
fn pseudo_style(&self, el: ElId, pseudo: &str, prop: &str) -> Option<String>;
/// `el.getBoundingClientRect()`.
fn rect(&self, el: ElId) -> Rect;
fn client_width(&self, el: ElId) -> f64;
fn client_height(&self, el: ElId) -> f64;
fn client_left(&self, el: ElId) -> f64;
fn scroll_width(&self, el: ElId) -> f64;
fn scroll_left(&self, el: ElId) -> f64;
fn offset_width(&self, el: ElId) -> f64;
fn offset_height(&self, el: ElId) -> f64;
/// `el.checkVisibility({ checkOpacity: false, checkVisibilityCSS: true })`;
/// `None` when the method does not exist.
fn check_visibility(&self, el: ElId) -> Option<bool>;
/// `getDirectTextRect(el)` from index.mjs: the union of the client rects
/// of every non-blank direct text node (rects narrower/shorter than 1px
/// dropped); `None` when there is none.
fn direct_text_rect(&self, el: ElId) -> Option<Rect>;
}
// ── shared helpers over the trait ─────────────────────────────────────────
/// `el.tagName.toLowerCase()`.
pub fn tag_lower(dom: &dyn Dom, el: ElId) -> String {
crate::js::to_lower_case(&dom.tag_name(el))
}
/// `el.getAttribute('class') || ''`.
pub fn class_attr(dom: &dyn Dom, el: ElId) -> String {
dom.attr(el, "class").unwrap_or_default()
}
/// `String(el.getAttribute?.('class') || el.className || '')`.
pub fn class_attr_or_prop(dom: &dyn Dom, el: ElId) -> String {
match dom.attr(el, "class") {
Some(c) if !c.is_empty() => c,
_ => match dom.class_name_prop(el) {
Some(c) if !c.is_empty() => c,
// JS `String(el.className)` on an SVGAnimatedString gives
// "[object SVGAnimatedString]"; the JS callers only regex-test the
// result and none of the patterns match that string, so "" is
// observably identical.
_ => String::new(),
},
}
}
/// `typeof el.id === 'string' ? el.id : (el.getAttribute('id') || '')`.
pub fn safe_id(dom: &dyn Dom, el: ElId) -> String {
match dom.id_prop(el) {
Some(id) => id,
None => dom.attr(el, "id").unwrap_or_default(),
}
}
/// `[...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('')`.
pub fn direct_text(dom: &dyn Dom, el: ElId) -> String {
dom.direct_text_nodes(el).concat()
}
/// `[...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > min)`.
pub fn has_direct_text_longer_than(dom: &dyn Dom, el: ElId, min: usize) -> bool {
dom.direct_text_nodes(el)
.iter()
.any(|t| crate::js_ext_b::utf16_len(crate::js::trim(t)) > min)
}
/// `getComputedStyle(el).x || ''` — the trait already returns "" for
/// null/undefined, so this is just [`Dom::style`]; kept for readability at
/// call sites that mirror `style.x || ''`.
pub fn style_or_empty(dom: &dyn Dom, el: ElId, prop: &str) -> String {
dom.style(el, prop)
}
/// JS `parseFloat(style.x) || 0`.
pub fn style_px(dom: &dyn Dom, el: ElId, prop: &str) -> f64 {
let n = crate::js::parse_float(&dom.style(el, prop));
if crate::js_ext_a::num_truthy(n) {
n
} else {
0.0
}
}
/// JS `parseFloat(s) || 0`.
pub fn pf0(s: &str) -> f64 {
let n = crate::js::parse_float(s);
if crate::js_ext_a::num_truthy(n) {
n
} else {
0.0
}
}
/// `el.closest(sel)` where the JS wrapped the call in `try/catch` and treated
/// a throw as "no match".
pub fn closest_or_none(dom: &dyn Dom, el: ElId, selector: &str) -> Option<ElId> {
dom.closest(el, selector).unwrap_or(None)
}
/// `el.matches(sel)` with a throw read as false.
pub fn matches_or_false(dom: &dyn Dom, el: ElId, selector: &str) -> bool {
dom.matches(el, selector).unwrap_or(false)
}
/// Iterate `el, el.parentElement, ...` while the node is an element.
pub fn ancestors_inclusive(dom: &dyn Dom, el: ElId) -> Vec<ElId> {
let mut out = Vec::new();
let mut cur = Some(el);
while let Some(c) = cur {
out.push(c);
cur = dom.parent(c);
}
out
}
/// A live element's computed style as a [`crate::css::measures::StyleMap`],
/// so the browser adapters can hand `getComputedStyle(el)` to the pure
/// helpers that take a style map (`isScreenReaderOnlyTextStyle`,
/// `positionedStyleImpliesEscape`, `isRepeatedTextContainer`, ...). Real
/// browsers define every property, so `prop` is always `Some`.
pub struct ElStyle<'a> {
pub dom: &'a dyn Dom,
pub el: ElId,
}
impl crate::css::measures::StyleMap for ElStyle<'_> {
fn prop(&self, name: &str) -> Option<String> {
Some(self.dom.style(self.el, name))
}
}
+506
View File
@@ -0,0 +1,506 @@
//! A table-driven in-memory [`Dom`] for unit tests. Not a browser: it holds
//! exactly the facts a test declares (tags, attributes, computed-style
//! values, rects, text nodes) and answers selector queries from a per-element
//! list of selectors the test says match, plus the trivial cases (`*`, a bare
//! tag name, comma lists of those). Its job is to pin thresholds and snippet
//! formats; byte parity is proven by the A/B differential against Chrome.
use super::dom::{Dom, ElId, KeyframeFrame, Rect, SelectorError};
use std::collections::HashMap;
#[derive(Debug, Clone)]
pub enum FakeNode {
Text(String),
El(ElId),
}
#[derive(Debug, Clone, Default)]
pub struct FakeEl {
pub tag: String,
pub ns: String,
pub attrs: Vec<(String, String)>,
pub styles: HashMap<String, String>,
pub pseudo_styles: HashMap<(String, String), String>,
pub rect: Rect,
pub child_nodes: Vec<FakeNode>,
pub parent: Option<ElId>,
pub client_width: f64,
pub client_height: f64,
pub client_left: f64,
pub scroll_width: f64,
pub scroll_left: f64,
pub offset_width: f64,
pub offset_height: f64,
pub is_content_editable: bool,
pub hidden: bool,
pub check_visibility: Option<bool>,
pub direct_text_rect: Option<Rect>,
/// Selectors (exact strings) this element matches beyond `*` and its tag.
pub selectors: Vec<String>,
/// `id` IDL property override (`None` = "not a string", falls back to attr).
pub id_prop_is_string: bool,
/// `className` IDL property is a string (false for SVG).
pub class_name_is_string: bool,
/// `innerText` override.
pub inner_text: Option<String>,
}
#[derive(Debug, Default)]
pub struct FakeDom {
pub els: Vec<FakeEl>,
pub document_element: Option<ElId>,
pub body: Option<ElId>,
pub inner_width: f64,
pub inner_height: f64,
pub scroll_x: f64,
pub scroll_y: f64,
pub hostname: String,
/// `(x, y)` → element stack, topmost first.
pub points: Vec<((f64, f64), Vec<ElId>)>,
pub keyframes: HashMap<String, Vec<KeyframeFrame>>,
pub html_for_patterns: String,
}
impl FakeDom {
pub fn new() -> Self {
FakeDom {
// index 0 is never an element
els: vec![FakeEl::default()],
inner_width: 1280.0,
inner_height: 800.0,
..Default::default()
}
}
/// Add an element under `parent` (`None` for the root). Returns its id.
/// A real browser defines every computed property; the fake starts with
/// the few the "is this rendered" walks read (`opacity: 1`,
/// `visibility: visible`, `display: block`) so a test only declares what
/// it is about.
pub fn add(&mut self, parent: Option<ElId>, tag: &str) -> ElId {
let id = self.els.len() as ElId;
let mut styles = HashMap::new();
styles.insert("opacity".to_string(), "1".to_string());
styles.insert("visibility".to_string(), "visible".to_string());
styles.insert("display".to_string(), "block".to_string());
self.els.push(FakeEl {
styles,
tag: tag.to_string(),
ns: if tag == "svg" || tag == "text" || tag == "path" || tag == "rect" {
"http://www.w3.org/2000/svg".to_string()
} else {
"http://www.w3.org/1999/xhtml".to_string()
},
parent,
id_prop_is_string: true,
class_name_is_string: true,
..Default::default()
});
if let Some(p) = parent {
self.els[p as usize].child_nodes.push(FakeNode::El(id));
}
if tag == "html" && self.document_element.is_none() {
self.document_element = Some(id);
}
if tag == "body" && self.body.is_none() {
self.body = Some(id);
}
id
}
/// Add a root `<html><body>` pair and return `(html, body)`.
pub fn with_page(&mut self) -> (ElId, ElId) {
let html = self.add(None, "html");
let body = self.add(Some(html), "body");
(html, body)
}
pub fn el_mut(&mut self, id: ElId) -> &mut FakeEl {
&mut self.els[id as usize]
}
pub fn el(&self, id: ElId) -> &FakeEl {
&self.els[id as usize]
}
pub fn set_style(&mut self, id: ElId, prop: &str, value: &str) -> &mut Self {
self.el_mut(id)
.styles
.insert(prop.to_string(), value.to_string());
self
}
pub fn set_styles(&mut self, id: ElId, pairs: &[(&str, &str)]) -> &mut Self {
for (p, v) in pairs {
self.set_style(id, p, v);
}
self
}
pub fn set_pseudo_style(
&mut self,
id: ElId,
pseudo: &str,
prop: &str,
value: &str,
) -> &mut Self {
self.el_mut(id)
.pseudo_styles
.insert((pseudo.to_string(), prop.to_string()), value.to_string());
self
}
pub fn set_attr(&mut self, id: ElId, name: &str, value: &str) -> &mut Self {
let el = self.el_mut(id);
if let Some(slot) = el.attrs.iter_mut().find(|(n, _)| n == name) {
slot.1 = value.to_string();
} else {
el.attrs.push((name.to_string(), value.to_string()));
}
self
}
pub fn set_rect(&mut self, id: ElId, x: f64, y: f64, w: f64, h: f64) -> &mut Self {
self.el_mut(id).rect = Rect::from_xywh(x, y, w, h);
self
}
pub fn add_text(&mut self, id: ElId, text: &str) -> &mut Self {
self.el_mut(id)
.child_nodes
.push(FakeNode::Text(text.to_string()));
self
}
/// Declare that `id` matches `selector` (exact string) for `matches` /
/// `closest` / `query_all`.
pub fn add_selector(&mut self, id: ElId, selector: &str) -> &mut Self {
self.el_mut(id).selectors.push(selector.to_string());
self
}
pub fn set_point(&mut self, x: f64, y: f64, stack: Vec<ElId>) -> &mut Self {
self.points.push(((x, y), stack));
self
}
fn all_in_order(&self, root: Option<ElId>) -> Vec<ElId> {
let mut out = Vec::new();
let roots: Vec<ElId> = match root {
Some(r) => self.child_elements(r),
None => (1..self.els.len() as ElId)
.filter(|&i| self.els[i as usize].parent.is_none())
.collect(),
};
fn walk(dom: &FakeDom, el: ElId, out: &mut Vec<ElId>) {
out.push(el);
for c in dom.child_elements(el) {
walk(dom, c, out);
}
}
for r in roots {
walk(self, r, &mut out);
}
out
}
fn child_elements(&self, el: ElId) -> Vec<ElId> {
self.els[el as usize]
.child_nodes
.iter()
.filter_map(|n| match n {
FakeNode::El(id) => Some(*id),
_ => None,
})
.collect()
}
fn matches_one(&self, el: ElId, token: &str) -> bool {
let token = token.trim();
if token == "*" {
return true;
}
let e = &self.els[el as usize];
if token.eq_ignore_ascii_case(&e.tag) {
return true;
}
e.selectors.iter().any(|s| s == token)
}
fn matches_sel(&self, el: ElId, selector: &str) -> bool {
// Whole-string match first (tests may declare the full list), then
// top-level comma split.
if self.els[el as usize]
.selectors
.iter()
.any(|s| s == selector)
{
return true;
}
split_top_level_commas(selector)
.iter()
.any(|t| self.matches_one(el, t))
}
fn text_content_of(&self, el: ElId, out: &mut String) {
for n in &self.els[el as usize].child_nodes {
match n {
FakeNode::Text(t) => out.push_str(t),
FakeNode::El(id) => self.text_content_of(*id, out),
}
}
}
}
fn split_top_level_commas(s: &str) -> Vec<String> {
let mut out = Vec::new();
let mut depth = 0i32;
let mut cur = String::new();
for ch in s.chars() {
match ch {
'(' | '[' => {
depth += 1;
cur.push(ch);
}
')' | ']' => {
depth -= 1;
cur.push(ch);
}
',' if depth == 0 => {
out.push(cur.trim().to_string());
cur.clear();
}
_ => cur.push(ch),
}
}
if !cur.trim().is_empty() {
out.push(cur.trim().to_string());
}
out
}
impl Dom for FakeDom {
fn document_element(&self) -> Option<ElId> {
self.document_element
}
fn body(&self) -> Option<ElId> {
self.body
}
fn query_all(&self, root: Option<ElId>, selector: &str) -> Result<Vec<ElId>, SelectorError> {
if selector.contains("!!") {
return Err(SelectorError);
}
Ok(self
.all_in_order(root)
.into_iter()
.filter(|&e| self.matches_sel(e, selector))
.collect())
}
fn query_one(&self, root: Option<ElId>, selector: &str) -> Result<Option<ElId>, SelectorError> {
Ok(self.query_all(root, selector)?.into_iter().next())
}
fn inner_width(&self) -> f64 {
self.inner_width
}
fn inner_height(&self) -> f64 {
self.inner_height
}
fn scroll_x(&self) -> f64 {
self.scroll_x
}
fn scroll_y(&self) -> f64 {
self.scroll_y
}
fn hostname(&self) -> String {
self.hostname.clone()
}
fn element_from_point(&self, x: f64, y: f64) -> Option<ElId> {
self.elements_from_point(x, y).into_iter().next()
}
fn elements_from_point(&self, x: f64, y: f64) -> Vec<ElId> {
// nearest declared point within 0.5px, else the deepest element whose
// rect contains the point (last in document order wins as "topmost").
for ((px, py), stack) in &self.points {
if (px - x).abs() < 0.5 && (py - y).abs() < 0.5 {
return stack.clone();
}
}
let mut hits: Vec<ElId> = self
.all_in_order(None)
.into_iter()
.filter(|&e| {
let r = self.els[e as usize].rect;
r.width > 0.0
&& r.height > 0.0
&& x >= r.left
&& x <= r.right
&& y >= r.top
&& y <= r.bottom
})
.collect();
hits.reverse();
hits
}
fn css_escape(&self, s: &str) -> String {
s.to_string()
}
fn keyframes(&self, name: &str) -> Option<Vec<KeyframeFrame>> {
self.keyframes.get(name).cloned()
}
fn document_html_for_patterns(&self) -> String {
self.html_for_patterns.clone()
}
fn tag_name(&self, el: ElId) -> String {
let e = &self.els[el as usize];
if e.ns == "http://www.w3.org/1999/xhtml" {
e.tag.to_ascii_uppercase()
} else {
e.tag.clone()
}
}
fn namespace_uri(&self, el: ElId) -> String {
self.els[el as usize].ns.clone()
}
fn parent(&self, el: ElId) -> Option<ElId> {
self.els[el as usize].parent
}
fn children(&self, el: ElId) -> Vec<ElId> {
self.child_elements(el)
}
fn previous_element_sibling(&self, el: ElId) -> Option<ElId> {
let p = self.els[el as usize].parent?;
let sibs = self.child_elements(p);
let i = sibs.iter().position(|&s| s == el)?;
if i == 0 {
None
} else {
Some(sibs[i - 1])
}
}
fn next_element_sibling(&self, el: ElId) -> Option<ElId> {
let p = self.els[el as usize].parent?;
let sibs = self.child_elements(p);
let i = sibs.iter().position(|&s| s == el)?;
sibs.get(i + 1).copied()
}
fn contains(&self, a: ElId, b: ElId) -> bool {
let mut cur = Some(b);
while let Some(c) = cur {
if c == a {
return true;
}
cur = self.els[c as usize].parent;
}
false
}
fn matches(&self, el: ElId, selector: &str) -> Result<bool, SelectorError> {
if selector.contains("!!") {
return Err(SelectorError);
}
Ok(self.matches_sel(el, selector))
}
fn closest(&self, el: ElId, selector: &str) -> Result<Option<ElId>, SelectorError> {
if selector.contains("!!") {
return Err(SelectorError);
}
let mut cur = Some(el);
while let Some(c) = cur {
if self.matches_sel(c, selector) {
return Ok(Some(c));
}
cur = self.els[c as usize].parent;
}
Ok(None)
}
fn attr(&self, el: ElId, name: &str) -> Option<String> {
self.els[el as usize]
.attrs
.iter()
.find(|(n, _)| n == name)
.map(|(_, v)| v.clone())
}
fn id_prop(&self, el: ElId) -> Option<String> {
if self.els[el as usize].id_prop_is_string {
Some(self.attr(el, "id").unwrap_or_default())
} else {
None
}
}
fn class_name_prop(&self, el: ElId) -> Option<String> {
if self.els[el as usize].class_name_is_string {
Some(self.attr(el, "class").unwrap_or_default())
} else {
None
}
}
fn text_content(&self, el: ElId) -> String {
let mut s = String::new();
self.text_content_of(el, &mut s);
s
}
fn inner_text(&self, el: ElId) -> Option<String> {
self.els[el as usize].inner_text.clone()
}
fn direct_text_nodes(&self, el: ElId) -> Vec<String> {
self.els[el as usize]
.child_nodes
.iter()
.filter_map(|n| match n {
FakeNode::Text(t) => Some(t.clone()),
_ => None,
})
.collect()
}
fn is_content_editable(&self, el: ElId) -> bool {
self.els[el as usize].is_content_editable
}
fn hidden_prop(&self, el: ElId) -> bool {
self.els[el as usize].hidden
}
fn style(&self, el: ElId, prop: &str) -> String {
self.els[el as usize]
.styles
.get(prop)
.cloned()
.unwrap_or_default()
}
fn pseudo_style(&self, el: ElId, pseudo: &str, prop: &str) -> Option<String> {
let e = &self.els[el as usize];
// A pseudo with no declared props at all reads as `content: none`
// (the JS `!ps || ps.content === 'none'` guard).
if !e.pseudo_styles.keys().any(|(p, _)| p == pseudo) {
return Some(if prop == "content" {
"none".to_string()
} else {
String::new()
});
}
Some(
e.pseudo_styles
.get(&(pseudo.to_string(), prop.to_string()))
.cloned()
.unwrap_or_default(),
)
}
fn rect(&self, el: ElId) -> Rect {
self.els[el as usize].rect
}
fn client_width(&self, el: ElId) -> f64 {
self.els[el as usize].client_width
}
fn client_height(&self, el: ElId) -> f64 {
self.els[el as usize].client_height
}
fn client_left(&self, el: ElId) -> f64 {
self.els[el as usize].client_left
}
fn scroll_width(&self, el: ElId) -> f64 {
self.els[el as usize].scroll_width
}
fn scroll_left(&self, el: ElId) -> f64 {
self.els[el as usize].scroll_left
}
fn offset_width(&self, el: ElId) -> f64 {
self.els[el as usize].offset_width
}
fn offset_height(&self, el: ElId) -> f64 {
self.els[el as usize].offset_height
}
fn check_visibility(&self, el: ElId) -> Option<bool> {
self.els[el as usize].check_visibility
}
fn direct_text_rect(&self, el: ElId) -> Option<Rect> {
self.els[el as usize].direct_text_rect
}
}
+213
View File
@@ -0,0 +1,213 @@
//! The shared half of the in-page rule set: the DOM probe trait every engine
//! implements ([`dom::Dom`]), the snapshot implementation and its selector
//! engine, the test fake, and the plain-data types the browser checks take
//! in and hand back. The checks themselves live in `impeccable-core`.
//!
//! - `dom`: the [`dom::Dom`] trait, `ElId`, `Rect`, shared helpers.
//! - `snapshot`: [`snapshot::SnapshotDom`], the trait over a serialized page
//! (the extension's CSP-proof path); `selector`: the Chrome-flavored
//! selector engine it matches with.
//! - `fake_dom`: a table-driven fake for unit tests (test builds only).
//! - `visual`: the plain-data plans and rects of the visual-contrast
//! subsystem.
pub mod dom;
#[cfg(any(test, feature = "fake-dom"))]
pub mod fake_dom;
pub mod selector;
pub mod snapshot;
pub mod visual;
use serde::{Deserialize, Serialize};
pub use dom::{Dom, ElId, Rect};
/// The `{ type, detail, severity?, ignoreValue? }` shape the overlay loop
/// carries (`checkElement*DOM(el).map(f => ({ type: f.id, detail: f.snippet }))`).
/// Field order matches the JS object literal so serialized JSON is byte-equal.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BrowserFinding {
#[serde(rename = "type")]
pub type_: String,
pub detail: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub severity: Option<String>,
#[serde(
default,
rename = "ignoreValue",
skip_serializing_if = "Option::is_none"
)]
pub ignore_value: Option<String>,
}
impl BrowserFinding {
pub fn new(type_: impl Into<String>, detail: impl Into<String>) -> Self {
BrowserFinding {
type_: type_.into(),
detail: detail.into(),
severity: None,
ignore_value: None,
}
}
/// `{ type: f.id, detail: f.snippet }` from a Section 3 hit.
pub fn from_hit(hit: &crate::rules::types::RuleHit) -> Self {
BrowserFinding::new(hit.id.clone(), hit.snippet.clone())
}
/// `{ type: f.id, detail: f.snippet }` from a measures Finding.
pub fn from_measure(f: &crate::css::measures::Finding) -> Self {
BrowserFinding::new(f.id.clone(), f.snippet.clone())
}
}
/// A finding attributed to an element (`{ el, type, detail }` from the
/// page-level checks that name their own target). `el == None` means "the
/// check attributes to document.body" (JS `f.el || document.body`).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ElFinding {
pub el: Option<ElId>,
pub finding: BrowserFinding,
}
/// One entry of the driver's group map: `{ el, findings }` in insertion order.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct FindingGroup {
pub el: ElId,
pub findings: Vec<BrowserFinding>,
}
/// One `{ rule, value }` entry of `window.__IMPECCABLE_CONFIG__.disabledValues`:
/// a project `ignoreValues` waiver the live overlay resolved for this page
/// (live-browser-ignores.js) and forwarded for the scan to apply where the
/// findings are assembled. Rule and value are carried raw; the driver
/// normalizes them the way the CLI's `isIgnoredFindingValue` does.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct DisabledValue {
pub rule: String,
pub value: String,
}
/// JS `.filter(e => e && typeof e === 'object' && e.rule && e.value)` over
/// whatever the page put on the config. `__IMPECCABLE_CONFIG__` arrives in
/// whatever state it was written in, so a hand-edited entry of the wrong
/// shape is dropped rather than failing the parse of the whole config.
fn de_disabled_values<'de, D>(de: D) -> Result<Vec<DisabledValue>, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = serde_json::Value::deserialize(de)?;
let Some(items) = raw.as_array() else {
return Ok(Vec::new());
};
Ok(items
.iter()
.filter_map(|entry| {
let obj = entry.as_object()?;
// JS `String(e.rule)` after the truthiness filter: an empty
// string and a numeric 0 are both falsy, so both drop the entry.
let text = |key: &str| match obj.get(key) {
Some(serde_json::Value::String(s)) => s.clone(),
Some(serde_json::Value::Number(n)) => {
let v = n.as_f64().unwrap_or(0.0);
if v == 0.0 {
String::new()
} else {
crate::js::number_to_string(v)
}
}
_ => String::new(),
};
let rule = text("rule");
let value = text("value");
if rule.is_empty() || value.is_empty() {
return None;
}
Some(DisabledValue { rule, value })
})
.collect())
}
/// What the bundle passes into `collectBrowserFindings`: extension mode and
/// the relevant slice of `window.__IMPECCABLE_CONFIG__`.
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BrowserConfig {
#[serde(default)]
pub extension_mode: bool,
/// `window.__IMPECCABLE_CONFIG__?.disabledRules || []` (only honored in
/// extension mode, exactly as the JS reads it).
#[serde(default)]
pub disabled_rules: Vec<String>,
/// `window.__IMPECCABLE_CONFIG__?.disabledValues || []` (only honored in
/// extension mode, exactly as the JS reads it). `disabled_rules` waives
/// whole rules; these waive one reported value of one rule, which is how
/// a project entry like `overused-font = "geist mono"` reaches the
/// overlay. Serialized as `disabledValues`.
#[serde(default, deserialize_with = "de_disabled_values")]
pub disabled_values: Vec<DisabledValue>,
/// `window.__IMPECCABLE_CONFIG__?.skipScan === true` (only honored in
/// extension mode): the page is waived wholesale by detector.ignoreFiles,
/// so every scan stage answers empty.
#[serde(default)]
pub skip_scan: bool,
/// `window.__IMPECCABLE_CONFIG__?.designSystem`, raw.
#[serde(default)]
pub design_system: Option<serde_json::Value>,
/// `window.__IMPECCABLE_CONFIG__?.lineLengthMax` (any JSON value; the JS
/// applies `|| 80`).
#[serde(default)]
pub line_length_max: Option<serde_json::Value>,
/// The installed rule pack, when the host linked one in
/// ([`crate::rule_pack`]). Not part of the JSON config: a pack is a Rust
/// value, so it is skipped in both directions and a config parsed from
/// the page carries `None`.
#[serde(skip)]
pub rule_pack: Option<&'static dyn crate::rule_pack::RulePack>,
}
impl BrowserConfig {
/// JS `(window.__IMPECCABLE_CONFIG__?.lineLengthMax) || 80`.
pub fn line_max(&self) -> f64 {
match &self.line_length_max {
Some(serde_json::Value::Number(n)) => {
let v = n.as_f64().unwrap_or(f64::NAN);
if crate::js_ext_a::num_truthy(v) {
v
} else {
80.0
}
}
Some(serde_json::Value::String(s)) if !s.is_empty() => {
// JS keeps the string; `textLen > lineMax` then compares
// number-to-string. Coerce like `>` would.
let v = crate::js::string_to_number(s);
if v.is_nan() {
f64::NAN
} else {
v
}
}
_ => 80.0,
}
}
}
/// JS: checks.mjs#measureHiddenTextDOM() result.
#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HiddenTextMeasure {
#[serde(with = "crate::js::json_number")]
pub total_chars: f64,
#[serde(with = "crate::js::json_number")]
pub hidden_chars: f64,
pub hidden_samples: Vec<String>,
}
/// The result of `collectBrowserFindings()`: the group map in insertion
/// order and the page-level list (banner content).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CollectResult {
pub groups: Vec<FindingGroup>,
pub page_level: Vec<BrowserFinding>,
}
+793
View File
@@ -0,0 +1,793 @@
//! A CSS selector engine over a page snapshot, tuned to what Chrome's
//! `querySelector` / `matches` / `closest` accept and match, so the rules
//! (which pass their selectors verbatim, see `dom.rs`) see the same answers
//! whether the probe is the live DOM or a [`super::snapshot::Snapshot`].
//!
//! The parser and matcher are the `selectors` crate (Servo / Firefox's
//! engine); this module supplies the `SelectorImpl` (atoms, the
//! non-tree-structural pseudo-class surface, the pseudo-element surface) and
//! the `Element` view over snapshot nodes. Where `crates/html/src/select.rs`
//! deliberately observes css-select (the JS *static* engine), this one
//! observes the browser:
//!
//! - unknown pseudo-classes and pseudo-elements are parse errors (Chrome
//! throws `SyntaxError`; the rules read that as [`SelectorError`]);
//! - known pseudo-elements parse and never match in `matches`/`querySelector`;
//! - `::-webkit-*` pseudo-elements parse (Chrome accepts unknown vendor ones);
//! - user-action and form-state pseudo-classes (`:hover`, `:checked`,
//! `:disabled`, ...) match through the state list the snapshot recorded for
//! the element (`captureStates` in `browser-bundle/15-snapshot.js`);
//! `:link` / `:any-link` come from tag + `href`; `:visited` never matches
//! (Chrome hides it from scripts too);
//! - type and attribute-name matching is ASCII-case-insensitive for HTML
//! elements and case-sensitive for SVG/MathML, as in an HTML document;
//! - id and class matching follows the document's quirks mode.
use cssparser::{CowRcStr, Parser as CssParser, SourceLocation, ToCss};
use precomputed_hash::PrecomputedHash;
use selectors::attr::{AttrSelectorOperation, CaseSensitivity, NamespaceConstraint};
use selectors::bloom::BloomFilter;
use selectors::context::{
MatchingContext, MatchingMode, NeedsSelectorFlags, QuirksMode, SelectorCaches,
};
use selectors::matching::{self, MatchingForInvalidation};
use selectors::parser::{
self, NonTSPseudoClass as NonTSPseudoClassTrait, ParseRelative,
PseudoElement as PseudoElementTrait, SelectorList, SelectorParseErrorKind,
};
use selectors::{Element, OpaqueElement};
use std::fmt;
use super::snapshot::{Snapshot, NS_XHTML};
/// A string atom for the selector types (attribute values, identifiers,
/// local names, namespaces).
#[derive(Debug, Clone, PartialEq, Eq, Default, Hash)]
pub struct Atom(pub String);
impl<'a> From<&'a str> for Atom {
fn from(s: &'a str) -> Self {
Atom(s.to_string())
}
}
impl AsRef<str> for Atom {
fn as_ref(&self) -> &str {
&self.0
}
}
impl ToCss for Atom {
fn to_css<W: fmt::Write>(&self, dest: &mut W) -> fmt::Result {
cssparser::serialize_identifier(&self.0, dest)
}
}
impl PrecomputedHash for Atom {
fn precomputed_hash(&self) -> u32 {
// FNV-1a over the bytes; only used for bloom filtering, which we
// opt out of anyway.
let mut h: u32 = 0x811c_9dc5;
for b in self.0.bytes() {
h ^= b as u32;
h = h.wrapping_mul(0x0100_0193);
}
h
}
}
/// Attribute values serialize as strings.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct AttrValue(pub String);
impl<'a> From<&'a str> for AttrValue {
fn from(s: &'a str) -> Self {
AttrValue(s.to_string())
}
}
impl AsRef<str> for AttrValue {
fn as_ref(&self) -> &str {
&self.0
}
}
impl ToCss for AttrValue {
fn to_css<W: fmt::Write>(&self, dest: &mut W) -> fmt::Result {
cssparser::serialize_string(&self.0, dest)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Impl;
impl parser::SelectorImpl for Impl {
type ExtraMatchingData<'a> = ();
type AttrValue = AttrValue;
type Identifier = Atom;
type LocalName = Atom;
type NamespaceUrl = Atom;
type NamespacePrefix = Atom;
type BorrowedNamespaceUrl = Atom;
type BorrowedLocalName = Atom;
type NonTSPseudoClass = PseudoClass;
type PseudoElement = PseudoElement;
}
/// The non-tree-structural pseudo-classes Chrome parses. `State(name)`
/// matches when the snapshot recorded that state for the element;
/// `Derived(name)` is computed from tag/attributes; `Never` parses and never
/// matches (`:visited` and the pseudo-classes whose truth a snapshot cannot
/// carry).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PseudoClass {
State(&'static str),
Derived(&'static str),
Never(&'static str),
Lang(String),
Dir(String),
CustomState(String),
}
impl NonTSPseudoClassTrait for PseudoClass {
type Impl = Impl;
fn is_active_or_hover(&self) -> bool {
matches!(
self,
PseudoClass::State("hover") | PseudoClass::State("active")
)
}
fn is_user_action_state(&self) -> bool {
matches!(
self,
PseudoClass::State("hover")
| PseudoClass::State("active")
| PseudoClass::State("focus")
| PseudoClass::State("focus-within")
| PseudoClass::State("focus-visible")
)
}
}
impl ToCss for PseudoClass {
fn to_css<W: fmt::Write>(&self, dest: &mut W) -> fmt::Result {
match self {
PseudoClass::State(n) | PseudoClass::Derived(n) | PseudoClass::Never(n) => {
write!(dest, ":{}", n)
}
PseudoClass::Lang(s) => write!(dest, ":lang({})", s),
PseudoClass::Dir(s) => write!(dest, ":dir({})", s),
PseudoClass::CustomState(s) => write!(dest, ":state({})", s),
}
}
}
/// Pseudo-classes whose truth the snapshot records per element
/// (`el.matches(':<name>')` at capture time). Keep in sync with
/// `STATE_PSEUDOS` in `browser-bundle/15-snapshot.js`.
pub const STATE_PSEUDOS: &[&str] = &[
"hover",
"active",
"focus",
"focus-within",
"focus-visible",
"target",
"target-within",
"checked",
"indeterminate",
"disabled",
"required",
"invalid",
"user-invalid",
"user-valid",
"in-range",
"out-of-range",
"placeholder-shown",
"default",
"open",
"autofill",
"-webkit-autofill",
"popover-open",
"modal",
"fullscreen",
"-webkit-full-screen",
"picture-in-picture",
"playing",
"buffering",
"seeking",
"muted",
"volume-locked",
];
/// Complements of recorded states, computed in Rust from tag + recorded
/// states so the snapshot only carries the (rare) positive side.
const DERIVED_PSEUDOS: &[&str] = &[
"defined",
"enabled",
"optional",
"valid",
"read-only",
"read-write",
"link",
"any-link",
"paused",
"closed",
];
/// Parse-only: Chrome accepts these but a snapshot has no truth for them.
const NEVER_PSEUDOS: &[&str] = &[
"visited",
"local-link",
"current",
"past",
"future",
"host",
"scope-context",
"-webkit-any-link",
"xr-overlay",
];
/// The pseudo-elements Chrome parses.
const PSEUDO_ELEMENTS: &[&str] = &[
"before",
"after",
"first-line",
"first-letter",
"selection",
"placeholder",
"marker",
"backdrop",
"file-selector-button",
"cue",
"spelling-error",
"grammar-error",
"target-text",
"details-content",
"search-text",
"scroll-marker",
"scroll-marker-group",
"scroll-button",
"column",
"checkmark",
"picker-icon",
"view-transition",
"view-transition-group",
"view-transition-image-pair",
"view-transition-old",
"view-transition-new",
];
/// A pseudo-element: parsed, never matched (Chrome's `matches` /
/// `querySelector` never match pseudo-elements).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PseudoElement(pub String);
impl PseudoElementTrait for PseudoElement {
type Impl = Impl;
}
impl ToCss for PseudoElement {
fn to_css<W: fmt::Write>(&self, dest: &mut W) -> fmt::Result {
write!(dest, "::{}", self.0)
}
}
struct SelParser;
impl<'i> parser::Parser<'i> for SelParser {
type Impl = Impl;
type Error = SelectorParseErrorKind<'i>;
fn parse_is_and_where(&self) -> bool {
true
}
fn parse_has(&self) -> bool {
true
}
fn parse_nth_child_of(&self) -> bool {
true
}
fn parse_part(&self) -> bool {
true
}
fn parse_slotted(&self) -> bool {
true
}
fn parse_host(&self) -> bool {
true
}
fn is_is_alias(&self, name: &str) -> bool {
name.eq_ignore_ascii_case("-webkit-any")
}
/// Chrome: `:is()` / `:where()` / `:has()` (since 105 for :is/:where; :has
/// is unforgiving) — the crate applies forgiveness only to :is/:where.
fn allow_forgiving_selectors(&self) -> bool {
true
}
fn parse_non_ts_pseudo_class(
&self,
location: SourceLocation,
name: CowRcStr<'i>,
) -> Result<PseudoClass, cssparser::ParseError<'i, Self::Error>> {
let lower = name.to_ascii_lowercase();
if let Some(n) = STATE_PSEUDOS.iter().find(|n| **n == lower) {
return Ok(PseudoClass::State(n));
}
if let Some(n) = DERIVED_PSEUDOS.iter().find(|n| **n == lower) {
return Ok(PseudoClass::Derived(n));
}
if let Some(n) = NEVER_PSEUDOS.iter().find(|n| **n == lower) {
return Ok(PseudoClass::Never(n));
}
Err(
location.new_custom_error(SelectorParseErrorKind::UnsupportedPseudoClassOrElement(
name,
)),
)
}
fn parse_non_ts_functional_pseudo_class<'t>(
&self,
name: CowRcStr<'i>,
parser: &mut CssParser<'i, 't>,
_after_part: bool,
) -> Result<PseudoClass, cssparser::ParseError<'i, Self::Error>> {
let lower = name.to_ascii_lowercase();
match lower.as_str() {
"lang" => {
// Chrome accepts a comma-separated list of ranges.
let mut langs = Vec::new();
loop {
let v = parser.expect_ident_or_string()?.as_ref().to_owned();
langs.push(v);
if parser.try_parse(|p| p.expect_comma()).is_err() {
break;
}
}
Ok(PseudoClass::Lang(langs.join(",")))
}
"dir" => {
let v = parser.expect_ident()?.as_ref().to_ascii_lowercase();
Ok(PseudoClass::Dir(v))
}
"state" => {
let v = parser.expect_ident()?.as_ref().to_owned();
Ok(PseudoClass::CustomState(v))
}
"host-context" | "-webkit-any" => {
// Consume the argument; never matches outside shadow trees.
while parser.next_including_whitespace().is_ok() {}
Ok(PseudoClass::Never("host-context"))
}
_ => Err(parser.new_custom_error(
SelectorParseErrorKind::UnsupportedPseudoClassOrElement(name),
)),
}
}
fn parse_pseudo_element(
&self,
location: SourceLocation,
name: CowRcStr<'i>,
) -> Result<PseudoElement, cssparser::ParseError<'i, Self::Error>> {
let lower = name.to_ascii_lowercase();
if PSEUDO_ELEMENTS.contains(&lower.as_str()) || lower.starts_with("-webkit-") {
return Ok(PseudoElement(lower));
}
Err(
location.new_custom_error(SelectorParseErrorKind::UnsupportedPseudoClassOrElement(
name,
)),
)
}
fn parse_functional_pseudo_element<'t>(
&self,
name: CowRcStr<'i>,
parser: &mut CssParser<'i, 't>,
) -> Result<PseudoElement, cssparser::ParseError<'i, Self::Error>> {
let lower = name.to_ascii_lowercase();
match lower.as_str() {
"highlight"
| "part"
| "cue"
| "cue-region"
| "view-transition-group"
| "view-transition-image-pair"
| "view-transition-old"
| "view-transition-new"
| "slotted"
| "picker" => {
while parser.next_including_whitespace().is_ok() {}
Ok(PseudoElement(lower))
}
_ => Err(parser.new_custom_error(
SelectorParseErrorKind::UnsupportedPseudoClassOrElement(name),
)),
}
}
fn default_namespace(&self) -> Option<Atom> {
None
}
/// No `@namespace` in a selector API call: any prefix is a SyntaxError,
/// except the universal `*|`.
fn namespace_for_prefix(&self, _prefix: &Atom) -> Option<Atom> {
None
}
}
/// A parsed selector list.
#[derive(Debug, Clone)]
pub struct Selector {
list: SelectorList<Impl>,
}
impl Selector {
/// Parse a selector list; `Err(())` where Chrome would throw `SyntaxError`.
pub fn parse(text: &str) -> Result<Selector, ()> {
let mut input = cssparser::ParserInput::new(text);
let mut p = CssParser::new(&mut input);
SelectorList::parse(&SelParser, &mut p, ParseRelative::No)
.map(|list| Selector { list })
.map_err(|_| ())
}
/// Whether `el` matches any selector in the list. `scope` is the
/// element `:scope` refers to (the `el.querySelectorAll` root), if any;
/// with `None`, `:scope` is the document root.
pub fn matches(&self, snap: &Snapshot, el: u32, scope: Option<u32>) -> bool {
let mut caches = SelectorCaches::default();
let quirks = if snap.quirks {
QuirksMode::Quirks
} else {
QuirksMode::NoQuirks
};
let mut ctx = MatchingContext::new(
MatchingMode::Normal,
None,
&mut caches,
quirks,
NeedsSelectorFlags::No,
MatchingForInvalidation::No,
);
let scope_el = scope.map(|id| SnapEl { snap, id });
ctx.scope_element = scope_el.as_ref().map(|e| e.opaque());
let e = SnapEl { snap, id: el };
matching::matches_selector_list(&self.list, &e, &mut ctx)
}
}
/// An element of the snapshot as the `selectors` crate sees it.
#[derive(Clone, Copy)]
pub struct SnapEl<'a> {
pub snap: &'a Snapshot,
pub id: u32,
}
impl fmt::Debug for SnapEl<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "SnapEl({})", self.id)
}
}
impl<'a> SnapEl<'a> {
fn node(&self) -> &'a super::snapshot::SnapNode {
self.snap.node(self.id)
}
fn is_html(&self) -> bool {
self.snap.ns_uri(self.id) == NS_XHTML
}
fn has_state(&self, name: &str) -> bool {
self.node().states.iter().any(|s| s == name)
}
fn tag_lower(&self) -> String {
self.node().tag.to_ascii_lowercase()
}
fn is_form_control(&self) -> bool {
matches!(
self.tag_lower().as_str(),
"button" | "input" | "select" | "textarea" | "optgroup" | "option" | "fieldset"
) && self.is_html()
}
fn is_disabled(&self) -> bool {
self.has_state("disabled")
}
fn is_read_write(&self) -> bool {
// Chrome: text-ish inputs and textareas without readonly/disabled,
// plus editing hosts.
if self.node().content_editable {
return true;
}
if !self.is_html() {
return false;
}
let tag = self.tag_lower();
let readonly = self.snap.attr(self.id, "readonly").is_some();
if readonly || self.is_disabled() {
return false;
}
match tag.as_str() {
"textarea" => true,
"input" => {
let ty = self
.snap
.attr(self.id, "type")
.map(|t| t.to_ascii_lowercase())
.unwrap_or_default();
matches!(
ty.as_str(),
"" | "text"
| "search"
| "url"
| "tel"
| "email"
| "password"
| "date"
| "month"
| "week"
| "time"
| "datetime-local"
| "number"
)
}
_ => false,
}
}
fn derived(&self, name: &str) -> bool {
match name {
// The capture records the (rare) complement as `undefined`.
"defined" => !self.has_state("undefined"),
"enabled" => self.is_form_control() && !self.is_disabled(),
"optional" => {
self.is_html()
&& matches!(self.tag_lower().as_str(), "input" | "select" | "textarea")
&& !self.has_state("required")
}
"valid" => {
self.is_html()
&& matches!(
self.tag_lower().as_str(),
"input" | "select" | "textarea" | "form" | "fieldset"
)
&& !self.has_state("invalid")
}
"read-write" => self.is_read_write(),
"read-only" => !self.is_read_write(),
"link" | "any-link" => self.is_link(),
"paused" => {
self.is_html()
&& matches!(self.tag_lower().as_str(), "video" | "audio")
&& !self.has_state("playing")
}
"closed" => {
self.is_html()
&& matches!(self.tag_lower().as_str(), "details" | "dialog" | "select")
&& !self.has_state("open")
}
_ => false,
}
}
fn lang(&self) -> Option<String> {
let mut cur = Some(*self);
while let Some(el) = cur {
if let Some(v) = el.snap.attr(el.id, "lang") {
return Some(v);
}
if el.is_html() {
if let Some(v) = el.snap.attr(el.id, "xml:lang") {
return Some(v);
}
}
cur = el.parent_element();
}
None
}
}
impl<'a> Element for SnapEl<'a> {
type Impl = Impl;
fn opaque(&self) -> OpaqueElement {
OpaqueElement::new(self.node())
}
fn parent_element(&self) -> Option<Self> {
self.node().parent.map(|id| SnapEl {
snap: self.snap,
id,
})
}
fn parent_node_is_shadow_root(&self) -> bool {
false
}
fn containing_shadow_host(&self) -> Option<Self> {
None
}
fn is_pseudo_element(&self) -> bool {
false
}
fn prev_sibling_element(&self) -> Option<Self> {
self.snap
.previous_element_sibling(self.id)
.map(|id| SnapEl {
snap: self.snap,
id,
})
}
fn next_sibling_element(&self) -> Option<Self> {
self.snap.next_element_sibling(self.id).map(|id| SnapEl {
snap: self.snap,
id,
})
}
fn first_element_child(&self) -> Option<Self> {
self.node().children.first().map(|id| SnapEl {
snap: self.snap,
id: *id,
})
}
fn is_html_element_in_html_document(&self) -> bool {
self.is_html()
}
/// The crate passes the lowercase name for HTML elements (see
/// `is_html_element_in_html_document`) and the original otherwise; the
/// snapshot keeps `tagName` (uppercase for HTML, as-is for SVG), so
/// compare case-insensitively for HTML and exactly for the rest.
fn has_local_name(&self, name: &Atom) -> bool {
if self.is_html() {
self.node().tag.eq_ignore_ascii_case(&name.0)
} else {
self.node().tag == name.0
}
}
fn has_namespace(&self, ns: &Atom) -> bool {
self.snap.ns_uri(self.id) == ns.0
}
fn is_same_type(&self, other: &Self) -> bool {
self.node().tag == other.node().tag
&& self.snap.ns_uri(self.id) == other.snap.ns_uri(other.id)
}
fn attr_matches(
&self,
ns: &NamespaceConstraint<&Atom>,
local_name: &Atom,
operation: &AttrSelectorOperation<&AttrValue>,
) -> bool {
// Attributes in the snapshot carry no namespace (getAttribute
// names); a specific non-empty namespace constraint cannot match.
if let NamespaceConstraint::Specific(url) = ns {
if !url.0.is_empty() {
return false;
}
}
let html = self.is_html();
self.node().attrs.iter().any(|(k, v)| {
let name_ok = if html {
k.eq_ignore_ascii_case(&local_name.0)
} else {
k == &local_name.0
};
name_ok && operation.eval_str(v)
})
}
fn match_non_ts_pseudo_class(
&self,
pc: &PseudoClass,
_context: &mut MatchingContext<'_, Self::Impl>,
) -> bool {
match pc {
PseudoClass::State(name) => self.has_state(name),
PseudoClass::Derived(name) => self.derived(name),
PseudoClass::Never(_) => false,
PseudoClass::Lang(ranges) => {
let Some(have) = self.lang() else {
return false;
};
let have = have.to_ascii_lowercase();
ranges.split(',').any(|want| {
let want = want.trim().to_ascii_lowercase();
if want == "*" {
return !have.is_empty();
}
if want.is_empty() {
return have.is_empty();
}
// Extended filtering, simplified: prefix on subtag boundary,
// with `*-` wildcards accepted only as a leading subtag.
let want = want.strip_prefix("*-").unwrap_or(&want);
have == want
|| have.starts_with(&format!("{}-", want))
|| have.contains(&format!("-{}-", want))
|| have.ends_with(&format!("-{}", want))
})
}
PseudoClass::Dir(dir) => {
// Snapshot records the resolved direction as a state
// (`dir-ltr` / `dir-rtl`) when the capture asked for it.
self.has_state(&format!("dir-{}", dir))
}
PseudoClass::CustomState(_) => false,
}
}
fn match_pseudo_element(
&self,
_pe: &PseudoElement,
_context: &mut MatchingContext<Self::Impl>,
) -> bool {
false
}
fn is_link(&self) -> bool {
self.is_html()
&& matches!(self.tag_lower().as_str(), "a" | "area" | "link")
&& self.snap.attr(self.id, "href").is_some()
}
fn is_html_slot_element(&self) -> bool {
self.is_html() && self.tag_lower() == "slot"
}
fn has_id(&self, id: &Atom, case_sensitivity: CaseSensitivity) -> bool {
match self.snap.attr(self.id, "id") {
Some(v) => case_sensitivity.eq(id.0.as_bytes(), v.as_bytes()),
None => false,
}
}
fn has_class(&self, name: &Atom, case_sensitivity: CaseSensitivity) -> bool {
match self.snap.attr(self.id, "class") {
Some(v) => v
.split(|c: char| matches!(c, ' ' | '\t' | '\n' | '\x0C' | '\r'))
.any(|c| !c.is_empty() && case_sensitivity.eq(name.0.as_bytes(), c.as_bytes())),
None => false,
}
}
fn has_custom_state(&self, _name: &Atom) -> bool {
false
}
fn imported_part(&self, _: &Atom) -> Option<Atom> {
None
}
fn is_part(&self, _name: &Atom) -> bool {
false
}
/// `:empty`: no element or non-empty... per spec, no children other than
/// comments/PIs; Chrome: text nodes of any content make it non-empty
/// (even whitespace).
fn is_empty(&self) -> bool {
self.node().child_nodes.iter().all(|n| match n {
super::snapshot::ChildNode::El(_) => false,
super::snapshot::ChildNode::Text(t) => t.is_empty(),
super::snapshot::ChildNode::CData(v) => v.iter().all(|t| t.is_empty()),
})
}
fn is_root(&self) -> bool {
self.node().parent.is_none() && Some(self.id) == self.snap.document_element
}
fn apply_selector_flags(&self, _flags: matching::ElementSelectorFlags) {}
fn add_element_unique_hashes(&self, _filter: &mut BloomFilter) -> bool {
false
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_surface_matches_chrome() {
assert!(Selector::parse("a:hover").is_ok());
assert!(Selector::parse("a:focus").is_ok());
assert!(Selector::parse("a::before").is_ok());
assert!(Selector::parse("a::-webkit-scrollbar").is_ok());
assert!(Selector::parse("a::foo").is_err());
assert!(Selector::parse("a:contains(x)").is_err());
assert!(Selector::parse("input:disabled").is_ok());
assert!(Selector::parse(":scope > p.lead").is_ok());
assert!(Selector::parse("[class*=\"badge\" i]").is_ok());
assert!(Selector::parse("li:not(:last-child)").is_ok());
assert!(Selector::parse("div:has(> img)").is_ok());
assert!(Selector::parse(".x)").is_err());
assert!(Selector::parse("").is_err());
assert!(Selector::parse("svg|rect").is_err());
assert!(Selector::parse("h1, h2, [role=\"heading\"]").is_ok());
assert!(Selector::parse("[tabindex]:not([tabindex=\"-1\"])").is_ok());
assert!(Selector::parse("div:nth-of-type(2) > span:nth-child(3)").is_ok());
assert!(Selector::parse("#\\31 23").is_ok());
assert!(Selector::parse(":lang(en, fr)").is_ok());
assert!(Selector::parse("a:visited").is_ok());
assert!(Selector::parse(":is(a, :nope)").is_ok(), "forgiving :is");
assert!(Selector::parse(":not(:nope)").is_err());
}
}
File diff suppressed because it is too large Load Diff
+94
View File
@@ -0,0 +1,94 @@
//! The plain-data shapes of the visual-contrast subsystem: painted-image
//! rects, the raster plan, the stack walk's node list, and the two staged
//! plans (`CssPlan`, `Prepared`) the caller resolves between passes. The
//! decisions that produce them live in `impeccable-core`.
use super::dom::{ElId, Rect};
use crate::color::Rgba;
use serde::{Deserialize, Serialize};
use serde_json::Value;
/// The `{ left, top, width, height, intrinsicWidth, intrinsicHeight }` box.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PaintedRect {
pub left: f64,
pub top: f64,
pub width: f64,
pub height: f64,
pub intrinsic_width: f64,
pub intrinsic_height: f64,
}
/// A `{ left, top, width, height }` container box (a DOMRect or a plain rect).
#[derive(Debug, Clone, Copy, PartialEq, Deserialize, Serialize)]
pub struct Box4 {
pub left: f64,
pub top: f64,
pub width: f64,
pub height: f64,
}
impl From<Rect> for Box4 {
fn from(r: Rect) -> Self {
Box4 {
left: r.left,
top: r.top,
width: r.width,
height: r.height,
}
}
}
// ─── raster sampling helpers (sampleDrawablePixel) ─────────────────────────
/// JS: index.mjs#sampleDrawablePixel — the canvas geometry: intrinsic size
/// scaled to a 640px raster budget.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RasterPlan {
pub width: f64,
pub height: f64,
pub scale_x: f64,
pub scale_y: f64,
}
// ─── the background stack walk (sampleVisualBackgroundAtPoint) ─────────────
/// One node of the stack walk with the branch it takes.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct StackNode {
pub el: ElId,
/// `"img"` (sampleImageElement), `"raster"` (canvas/video), `"css"`.
pub kind: String,
}
/// The plan for `sampleCssBackground(node, style, point, textColor)`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum CssPlan {
/// A finished sample (analytic gradient, solid background, or the
/// unresolved fallback).
Sample { sample: Value },
/// A `url()` layer the JS must load and raster-sample.
Url {
url: String,
size: String,
position: String,
},
}
/// The outcome of the pre-analysis of a candidate.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum Prepared {
/// An early result (`status: 'unresolved'`), returned as-is.
Early { early: Value },
/// Ready to sample: the element, the sample points, and the text color.
Ready {
el: ElId,
points: Vec<Value>,
#[serde(rename = "textColor")]
text_color: Rgba,
},
}
File diff suppressed because it is too large Load Diff
+260
View File
@@ -0,0 +1,260 @@
//! Port of `cli/engine/shared/constants.mjs`. Sets keep the JS insertion
//! order as slices so anything that iterates them matches.
/// JS `SAFE_TAGS`.
pub const SAFE_TAGS: &[&str] = &[
"blockquote",
"nav",
"a",
"input",
"textarea",
"select",
"pre",
"code",
"span",
"th",
"td",
"tr",
"li",
"label",
"button",
"hr",
"html",
"head",
"body",
"script",
"style",
"link",
"meta",
"title",
"br",
"img",
"svg",
"path",
"circle",
"rect",
"line",
"polyline",
"polygon",
"g",
"defs",
"use",
];
/// JS `BORDER_SAFE_TAGS`: `SAFE_TAGS` without `label` (card-shaped clickable
/// labels are a canonical side-tab shape and must stay detectable).
pub const BORDER_SAFE_TAGS: &[&str] = &[
"blockquote",
"nav",
"a",
"input",
"textarea",
"select",
"pre",
"code",
"span",
"th",
"td",
"tr",
"li",
"button",
"hr",
"html",
"head",
"body",
"script",
"style",
"link",
"meta",
"title",
"br",
"img",
"svg",
"path",
"circle",
"rect",
"line",
"polyline",
"polygon",
"g",
"defs",
"use",
];
/// JS `OVERUSED_FONTS`.
pub const OVERUSED_FONTS: &[&str] = &[
// Older monoculture (still ubiquitous):
"inter",
"roboto",
"open sans",
"lato",
"montserrat",
"arial",
"helvetica",
// Newer monoculture (the Anthropic-skill / Vercel / GitHub default wave):
"fraunces",
"instrument sans",
"instrument serif",
"geist",
"geist sans",
"geist mono",
"mona sans",
"plus jakarta sans",
"space grotesk",
"recoleta",
];
/// JS `GOOGLE_DOMAINS`.
pub const GOOGLE_DOMAINS: &[&str] = &[
"google.com",
"youtube.com",
"android.com",
"chromium.org",
"chrome.com",
"web.dev",
"gstatic.com",
"firebase.google.com",
];
/// JS `VERCEL_DOMAINS`.
pub const VERCEL_DOMAINS: &[&str] = &["vercel.com", "nextjs.org", "v0.app"];
/// JS `GITHUB_DOMAINS`.
pub const GITHUB_DOMAINS: &[&str] = &["github.com", "githubnext.com"];
/// JS `BRAND_FONT_DOMAINS`: font name -> hostname suffixes where it is allowed.
pub const BRAND_FONT_DOMAINS: &[(&str, &[&str])] = &[
("roboto", GOOGLE_DOMAINS),
("google sans", GOOGLE_DOMAINS),
("product sans", GOOGLE_DOMAINS),
("geist", VERCEL_DOMAINS),
("geist sans", VERCEL_DOMAINS),
("geist mono", VERCEL_DOMAINS),
("mona sans", GITHUB_DOMAINS),
];
/// JS `isBrandFontOnOwnDomain(font)`. The JS reads the global `location`;
/// here the caller passes `location.hostname` (`None` when there is no
/// `location`, i.e. outside a browser), which the JS treats as "not on its
/// own domain".
pub fn is_brand_font_on_own_domain(font: &str, hostname: Option<&str>) -> bool {
let Some(hostname) = hostname else {
return false;
};
let Some((_, allowed)) = BRAND_FONT_DOMAINS.iter().find(|(name, _)| *name == font) else {
return false;
};
let host = crate::js::to_lower_case(hostname);
allowed
.iter()
.any(|suffix| host == *suffix || host.ends_with(&format!(".{}", suffix)))
}
/// JS `CSS_GENERIC_FONTS`. Overused-font primary selection skips only CSS
/// generics so a system stack keeps the system face as primary.
pub const CSS_GENERIC_FONTS: &[&str] = &[
"serif",
"sans-serif",
"monospace",
"cursive",
"fantasy",
"inherit",
"initial",
"unset",
"revert",
];
/// JS `GENERIC_FONTS`. Includes the CSS generics plus platform faces for
/// design-system/serif resolution.
pub const GENERIC_FONTS: &[&str] = &[
"serif",
"sans-serif",
"monospace",
"cursive",
"fantasy",
"inherit",
"initial",
"unset",
"revert",
"system-ui",
"ui-serif",
"ui-sans-serif",
"ui-monospace",
"ui-rounded",
"-apple-system",
"blinkmacsystemfont",
"segoe ui",
];
/// JS `WCAG_LARGE_TEXT_PX` = 18 * (96 / 72).
pub const WCAG_LARGE_TEXT_PX: f64 = 18.0 * (96.0 / 72.0);
/// JS `WCAG_LARGE_BOLD_TEXT_PX` = 14 * (96 / 72).
pub const WCAG_LARGE_BOLD_TEXT_PX: f64 = 14.0 * (96.0 / 72.0);
/// JS `EM_DASH_FLOOR`.
pub const EM_DASH_FLOOR: usize = 8;
/// JS `EM_DASH_CHARS_PER_DASH`.
pub const EM_DASH_CHARS_PER_DASH: usize = 500;
/// JS `KNOWN_SERIF_FONTS`.
pub const KNOWN_SERIF_FONTS: &[&str] = &[
"fraunces",
"recoleta",
"newsreader",
"playfair display",
"playfair",
"cormorant",
"cormorant garamond",
"garamond",
"eb garamond",
"tiempos",
"tiempos headline",
"tiempos text",
"lora",
"vollkorn",
"spectral",
"source serif pro",
"source serif 4",
"source serif",
"ibm plex serif",
"merriweather",
"libre caslon",
"libre baskerville",
"baskerville",
"georgia",
"times new roman",
"times",
"dm serif display",
"dm serif text",
"instrument serif",
"gt sectra",
"ogg",
"canela",
"freight display",
"freight text",
];
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn border_safe_tags_is_safe_tags_minus_label() {
let expected: Vec<&str> = SAFE_TAGS
.iter()
.copied()
.filter(|t| *t != "label")
.collect();
assert_eq!(BORDER_SAFE_TAGS, expected.as_slice());
}
#[test]
fn brand_font_domains() {
assert!(is_brand_font_on_own_domain(
"roboto",
Some("Fonts.Google.com")
));
assert!(is_brand_font_on_own_domain("geist", Some("vercel.com")));
assert!(!is_brand_font_on_own_domain("geist", Some("notvercel.com")));
assert!(!is_brand_font_on_own_domain("inter", Some("google.com")));
assert!(!is_brand_font_on_own_domain("roboto", None));
}
}
+769
View File
@@ -0,0 +1,769 @@
//! CSS value parsing and measurement helpers, plus the plain-data input and
//! output types the rule checks are written against.
//!
//! Everything here is open: it reads CSS values, resolves custom properties,
//! measures lengths, alphas and shadows, and defines the structs that carry a
//! check's inputs and its hits. The checks themselves live in the detector.
//!
//! Style-reading helpers take a [`StyleMap`]: any lookup from the JS
//! camelCase computed-style property name (`borderTopWidth`, `clipPath`) to
//! its string value, so a jsdom-style map, a real cascade, and a test
//! `HashMap` all fit.
use crate::color::{self, Rgba};
use crate::js::{self, ci, math_max, math_max3, parse_float, WS, WS_CHARS};
use crate::js_ext_b::num_truthy;
use crate::rules::types::D;
use once_cell::sync::Lazy;
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
macro_rules! re {
($name:ident, $pat:expr) => {
static $name: Lazy<Regex> = Lazy::new(|| Regex::new(&$pat).expect(stringify!($name)));
};
}
/// A computed-style lookup keyed by the JS camelCase property name. `None`
/// stands for JS `undefined`; JS code that reads `style.x || ''` treats both
/// `None` and `Some("")` alike.
pub trait StyleMap {
fn prop(&self, name: &str) -> Option<String>;
}
impl StyleMap for HashMap<String, String> {
fn prop(&self, name: &str) -> Option<String> {
self.get(name).cloned()
}
}
impl StyleMap for HashMap<&str, &str> {
fn prop(&self, name: &str) -> Option<String> {
self.get(name).map(|s| s.to_string())
}
}
impl<F: Fn(&str) -> Option<String>> StyleMap for F {
fn prop(&self, name: &str) -> Option<String> {
self(name)
}
}
/// `style.x || ''`
pub fn prop_or_empty(style: &dyn StyleMap, name: &str) -> String {
style.prop(name).unwrap_or_default()
}
/// JS `parseFloat(x) || 0` (NaN and -0 both become +0).
pub fn parse_float_or_zero(s: Option<&str>) -> f64 {
let n = match s {
Some(s) => parse_float(s),
None => f64::NAN,
};
if num_truthy(n) {
n
} else {
0.0
}
}
// ─── Section 4: lengths, colors, var() ──────────────────────────────────────
/// JS: checks.mjs#parseRadiusToPx. Parse a single CSS length token to
/// pixels; percentages convert against `width_px` when one is supplied,
/// else the raw percentage number is returned. `width_px` NaN reads as
/// "no width".
pub fn parse_radius_to_px(value: Option<&str>, width_px: f64) -> Option<f64> {
re!(WS_RE, format!("{}+", WS));
re!(PCT_END, "%$");
let value = value?;
if value.is_empty() {
return None;
}
let trimmed = js::trim(value);
if trimmed.is_empty() {
return None;
}
let first = WS_RE.split(trimmed).next().unwrap_or("");
let num = parse_float(first);
if num.is_nan() {
return None;
}
if PCT_END.is_match(first) {
if num_truthy(width_px) && width_px > 0.0 {
return Some((num / 100.0) * width_px);
}
return Some(num);
}
Some(num)
}
/// The custom-property lookup `resolveVarRefs` reads (`customPropMap.get`).
pub trait CustomProps {
fn get(&self, name: &str) -> Option<String>;
}
impl CustomProps for HashMap<String, String> {
fn get(&self, name: &str) -> Option<String> {
HashMap::get(self, name).cloned()
}
}
impl CustomProps for Vec<(String, String)> {
fn get(&self, name: &str) -> Option<String> {
self.iter().find(|(k, _)| k == name).map(|(_, v)| v.clone())
}
}
impl<F: Fn(&str) -> Option<String>> CustomProps for F {
fn get(&self, name: &str) -> Option<String> {
self(name)
}
}
/// JS: checks.mjs#resolveVarRefs. Resolve `var(--x[, fallback])` refs in a
/// value string, recursing up to 8 levels for chained refs. Returns the
/// input unchanged when no refs are present or a chain does not resolve.
pub fn resolve_var_refs(raw: &str, custom_props: &dyn CustomProps, depth: u32) -> String {
re!(
VAR_RE,
format!(
r"var\({ws}*(--[a-zA-Z0-9_-]+){ws}*(?:,{ws}*([^)]+))?\)",
ws = WS
)
);
if !raw.contains("var(") {
return raw.to_string();
}
if depth > 8 {
return raw.to_string();
}
VAR_RE
.replace_all(raw, |caps: &regex::Captures| {
let name = caps.get(1).map(|m| m.as_str()).unwrap_or("");
if let Some(v) = custom_props.get(name) {
return resolve_var_refs(&v, custom_props, depth + 1);
}
match caps.get(2) {
Some(fb) if !fb.as_str().is_empty() => {
resolve_var_refs(js::trim(fb.as_str()), custom_props, depth + 1)
}
_ => caps.get(0).map(|m| m.as_str()).unwrap_or("").to_string(),
}
})
.into_owned()
}
/// JS: checks.mjs#parseColorResolved. Resolve var() refs (when a map is
/// given), then parse. `None` on any failure.
pub fn parse_color_resolved(
s: Option<&str>,
custom_props: Option<&dyn CustomProps>,
) -> Option<Rgba> {
let s = s?;
if s.is_empty() {
return None;
}
let resolved = match custom_props {
Some(map) => resolve_var_refs(s, map, 0),
None => s.to_string(),
};
color::parse_any_color(Some(&resolved))
}
/// JS: checks.mjs#resolveLengthPx. Resolve a CSS length given a font-size
/// context; `None` for `normal` / `auto` / `inherit` / unparseable.
pub fn resolve_length_px(value: Option<&str>, font_size_px: f64) -> Option<f64> {
let value = value?;
if value.is_empty() || value == "normal" || value == "auto" || value == "inherit" {
return None;
}
let num = parse_float(value);
if num.is_nan() {
return None;
}
if value.ends_with("px") {
return Some(num);
}
if value.ends_with("rem") {
return Some(num * 16.0);
}
if value.ends_with("em") {
return Some(num * font_size_px);
}
if value.ends_with('%') {
return Some((num / 100.0) * font_size_px);
}
Some(num * font_size_px)
}
/// JS: checks.mjs#cssColorIsTransparent.
pub fn css_color_is_transparent(value: Option<&str>) -> bool {
re!(
ZERO_RGBA,
format!(
r"^rgba\({ws}*{d}+{ws}*,{ws}*{d}+{ws}*,{ws}*{d}+{ws}*,{ws}*0(?:\.0+)?{ws}*\)$",
ws = WS,
d = D
)
);
let Some(value) = value else { return true };
if value.is_empty() {
return true;
}
let s = js::to_lower_case(js::trim(value));
if s.is_empty() || s == "transparent" || s == "rgba(0, 0, 0, 0)" {
return true;
}
if let Some(parsed) = color::parse_any_color(Some(&s)) {
return parsed.alpha_or_one() <= 0.05;
}
ZERO_RGBA.is_match(&s)
}
/// JS: checks.mjs#colorsNearlyMatch.
pub fn colors_nearly_match(a: Option<&str>, b: Option<&str>) -> bool {
let (Some(ca), Some(cb)) = (color::parse_any_color(a), color::parse_any_color(b)) else {
return false;
};
let alpha_delta = (ca.alpha_or_one() - cb.alpha_or_one()).abs();
let channel_delta = math_max3(
(ca.r - cb.r).abs(),
(ca.g - cb.g).abs(),
(ca.b - cb.b).abs(),
);
alpha_delta <= 0.03 && channel_delta <= 3.0
}
// ─── Radial spotlight ───────────────────────────────────────────────────────
/// JS: checks.mjs#SPOTLIGHT_COLOR_TOKEN_RE (JS `/i`, ASCII `\b`).
pub static SPOTLIGHT_COLOR_TOKEN_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(&format!(
r"(?:{rgb}[aA]?|{hsl}[aA]?|{oklch}|{oklab}|{lab}|{lch}|{hwb}|{colormix})\([^)]*(?:\([^)]*\))?[^)]*\)|#[0-9a-fA-F]{{3,8}}(?-u:\b)|(?-u:\b){transparent}(?-u:\b)",
rgb = ci("rgb"),
hsl = ci("hsl"),
oklch = ci("oklch"),
oklab = ci("oklab"),
lab = ci("lab"),
lch = ci("lch"),
hwb = ci("hwb"),
colormix = ci("color-mix"),
transparent = ci("transparent"),
))
.expect("SPOTLIGHT_COLOR_TOKEN_RE")
});
/// One radial-gradient color stop as `parseRadialGradientStops` reads it.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GradientStop {
pub color: Option<Rgba>,
pub transparent: bool,
}
/// JS: checks.mjs#parseRadialGradientStops. The ordered stops of the FIRST
/// non-repeating radial-gradient in a background value, or `None` when
/// there is no plain radial-gradient to read.
pub fn parse_radial_gradient_stops(value: Option<&str>) -> Option<Vec<GradientStop>> {
re!(HAS_RADIAL, ci("radial-gradient"));
re!(
GRAD_RE,
format!(r"({}-)?{}\(", ci("repeating"), ci("radial-gradient"))
);
re!(TRANSPARENT_ONLY, format!("^{}$", ci("transparent")));
let value = value?;
if value.is_empty() || !HAS_RADIAL.is_match(value) {
return None;
}
let bytes = value.as_bytes();
for g in GRAD_RE.captures_iter(value) {
if g.get(1).is_some() {
continue; // repeating-* is a pattern, not a spotlight
}
let start = g.get(0).map(|m| m.start()).unwrap_or(0);
let open = match value[start..].find('(') {
Some(i) => start + i,
None => return None,
};
let mut depth = 0i32;
let mut end: Option<usize> = None;
for (i, &b) in bytes.iter().enumerate().skip(open) {
if b == b'(' {
depth += 1;
} else if b == b')' {
depth -= 1;
if depth == 0 {
end = Some(i);
break;
}
}
}
let Some(end) = end else { return None };
let args = color::split_top_level_commas(&value[open + 1..end]);
let stop_args: Vec<&String> = args
.iter()
.filter(|a| SPOTLIGHT_COLOR_TOKEN_RE.is_match(a))
.collect();
if stop_args.len() < 2 {
return None;
}
return Some(
stop_args
.iter()
.map(|a| {
let Some(tok) = SPOTLIGHT_COLOR_TOKEN_RE.find(a) else {
return GradientStop {
color: None,
transparent: false,
};
};
if TRANSPARENT_ONLY.is_match(tok.as_str()) {
return GradientStop {
color: None,
transparent: true,
};
}
let c = color::parse_any_color(Some(tok.as_str()));
let transparent = matches!(c, Some(c) if c.alpha_or_one() <= 0.05);
GradientStop {
color: c,
transparent,
}
})
.collect(),
);
}
None
}
/// A `{ id, snippet }` finding as the pure checks return them.
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Finding {
pub id: String,
pub snippet: String,
}
impl Finding {
pub fn new(id: &str, snippet: String) -> Self {
Finding {
id: id.to_string(),
snippet,
}
}
}
/// Input of `checkRadialSpotlight`. `width` / `height` NaN when the JS
/// caller would pass `undefined`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RadialSpotlightInput<'a> {
#[serde(borrow)]
pub gradient_value: Option<&'a str>,
pub width: f64,
pub height: f64,
#[serde(borrow)]
pub label: Option<&'a str>,
}
/// JS: checks.mjs#TAILWIND_BG_HEX (insertion order preserved).
pub const TAILWIND_BG_HEX: &[(&str, &str)] = &[
("bg-amber-50", "#fffbeb"),
("bg-amber-100", "#fef3c7"),
("bg-orange-50", "#fff7ed"),
("bg-orange-100", "#ffedd5"),
("bg-yellow-50", "#fefce8"),
("bg-stone-50", "#fafaf9"),
("bg-stone-100", "#f5f5f4"),
("bg-stone-200", "#e7e5e4"),
];
/// A layout rect (`getBoundingClientRect`-shaped) as the pure gate reads it.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct Rect {
pub width: f64,
pub height: f64,
}
/// Input of `checkOversizedH1`; `viewport_*` default to 0 in JS.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct OversizedH1Input<'a> {
#[serde(borrow)]
pub tag: &'a str,
pub font_size: f64,
#[serde(borrow)]
pub heading_text: &'a str,
pub rect: Option<Rect>,
pub viewport_width: f64,
pub viewport_height: f64,
}
// ─── Hairline border + wide diffuse shadow ──────────────────────────────────
/// JS: checks.mjs#CSS_COLOR_TOKEN_RE (JS `/gi`, ASCII `\b`).
pub static CSS_COLOR_TOKEN_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(&format!(
r"(?:{rgb}[aA]?|{hsl}[aA]?|{oklch}|{oklab}|{lab}|{lch}|{color})\([^)]*\)|#[0-9a-fA-F]{{3,8}}(?-u:\b)|(?-u:\b)(?:{black}|{white}|{transparent}|{currentcolor})(?-u:\b)",
rgb = ci("rgb"),
hsl = ci("hsl"),
oklch = ci("oklch"),
oklab = ci("oklab"),
lab = ci("lab"),
lch = ci("lch"),
color = ci("color"),
black = ci("black"),
white = ci("white"),
transparent = ci("transparent"),
currentcolor = ci("currentcolor"),
))
.expect("CSS_COLOR_TOKEN_RE")
});
/// JS: checks.mjs#shadowLayerAlpha. Alpha of the first color token in one
/// box-shadow layer; 1 when there is none or it does not parse.
pub fn shadow_layer_alpha(layer: &str) -> f64 {
let Some(m) = CSS_COLOR_TOKEN_RE.find(layer) else {
return 1.0;
};
if js::to_lower_case(m.as_str()) == "transparent" {
return 0.0;
}
color::parse_any_color(Some(m.as_str()))
.map(|c| c.alpha_or_one())
.unwrap_or(1.0)
}
/// JS `boxShadow.split(/,(?![^()]*\))/)`: split on commas that are not
/// inside parentheses (a comma followed by a `)` before any `(` stays).
fn split_shadow_layers(s: &str) -> Vec<&str> {
let bytes = s.as_bytes();
let mut out = Vec::new();
let mut start = 0usize;
for (i, &b) in bytes.iter().enumerate() {
if b != b',' {
continue;
}
let mut inside = false;
for &c in &bytes[i + 1..] {
if c == b'(' {
break;
}
if c == b')' {
inside = true;
break;
}
}
if inside {
continue;
}
out.push(&s[start..i]);
start = i + 1;
}
out.push(&s[start..]);
out
}
/// JS: checks.mjs#shadowMaxBlurPx. Largest blur radius across the layers
/// whose color alpha is at least `min_alpha` (JS default 0).
pub fn shadow_max_blur_px(box_shadow: Option<&str>, min_alpha: Option<f64>) -> f64 {
re!(WORD_RE, r"(?-u:\b)[a-zA-Z]+(?-u:\b)");
re!(NUM_RE, format!(r"-?{d}*\.?{d}+", d = D));
let min_alpha = min_alpha.unwrap_or(0.0);
let Some(box_shadow) = box_shadow else {
return 0.0;
};
if box_shadow.is_empty() || box_shadow == "none" {
return 0.0;
}
let mut max_blur = 0.0f64;
for layer in split_shadow_layers(box_shadow) {
if shadow_layer_alpha(layer) < min_alpha {
continue;
}
let cleaned = CSS_COLOR_TOKEN_RE.replace_all(layer, " ");
let cleaned = WORD_RE.replace_all(&cleaned, " ");
let nums: Vec<f64> = NUM_RE
.find_iter(&cleaned)
.map(|m| parse_float(m.as_str()))
.collect();
if nums.len() >= 3 {
max_blur = math_max(max_blur, nums[2]);
}
}
max_blur
}
/// JS: checks.mjs#cssColorAlpha.
pub fn css_color_alpha(value: Option<&str>) -> f64 {
if css_color_is_transparent(value) {
return 0.0;
}
color::parse_any_color(value)
.map(|c| c.alpha_or_one())
.unwrap_or(1.0)
}
/// Input of `checkGptThinBorderWideShadow`.
// Serialize only: `border_widths: &[f64]` and `border_colors: &[Option<String>]`
// are borrowed slices, which serde cannot deserialize into.
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct GptBorderShadowInput<'a> {
pub border_widths: &'a [f64],
/// JS `borderColors?.[index] || ''`: `None` for a missing array, and
/// each entry `None` for a missing / empty color.
pub border_colors: Option<&'a [Option<String>]>,
pub box_shadow: Option<&'a str>,
}
/// JS: checks.mjs#borderWidthsFromStyle. Top, right, bottom, left.
pub fn border_widths_from_style(style: &dyn StyleMap) -> [f64; 4] {
[
parse_float_or_zero(style.prop("borderTopWidth").as_deref()),
parse_float_or_zero(style.prop("borderRightWidth").as_deref()),
parse_float_or_zero(style.prop("borderBottomWidth").as_deref()),
parse_float_or_zero(style.prop("borderLeftWidth").as_deref()),
]
}
/// JS: checks.mjs#borderColorsFromStyle. Top, right, bottom, left (`''`
/// when unset).
pub fn border_colors_from_style(style: &dyn StyleMap) -> [String; 4] {
[
prop_or_empty(style, "borderTopColor"),
prop_or_empty(style, "borderRightColor"),
prop_or_empty(style, "borderBottomColor"),
prop_or_empty(style, "borderLeftColor"),
]
}
/// A value `metricLengthPx` accepts: a JS number, a string, or anything else
/// (`undefined`, `null`, ...), which parses to nothing.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub enum LengthInput<'a> {
Number(f64),
Text(&'a str),
Missing,
}
impl<'a> From<Option<&'a str>> for LengthInput<'a> {
fn from(v: Option<&'a str>) -> Self {
match v {
Some(s) => LengthInput::Text(s),
None => LengthInput::Missing,
}
}
}
impl From<Option<f64>> for LengthInput<'_> {
fn from(v: Option<f64>) -> Self {
match v {
Some(n) => LengthInput::Number(n),
None => LengthInput::Missing,
}
}
}
/// JS: checks.mjs#metricLengthPx (`font_size_px` default 16).
pub fn metric_length_px(value: LengthInput, font_size_px: f64) -> Option<f64> {
match value {
LengthInput::Number(n) if n.is_finite() => Some(n),
LengthInput::Number(_) => None,
LengthInput::Text(s) => resolve_length_px(Some(s), font_size_px),
LengthInput::Missing => None,
}
}
/// JS: checks.mjs#firstMetricLengthPx.
pub fn first_metric_length_px(font_size_px: f64, values: &[LengthInput]) -> Option<f64> {
for v in values {
if let Some(parsed) = metric_length_px(*v, font_size_px) {
return Some(parsed);
}
}
None
}
/// JS: checks.mjs#expandBoxShorthand. One to four values to
/// `[top, right, bottom, left]`.
// JS-PARITY: expandBoxShorthand on an empty array yields four `undefined`s;
// this returns an empty Vec (no caller reaches it with no parts).
pub fn expand_box_shorthand<T: Clone>(parts: &[T]) -> Vec<T> {
match parts.len() {
0 => vec![],
1 => vec![
parts[0].clone(),
parts[0].clone(),
parts[0].clone(),
parts[0].clone(),
],
2 => vec![
parts[0].clone(),
parts[1].clone(),
parts[0].clone(),
parts[1].clone(),
],
3 => vec![
parts[0].clone(),
parts[1].clone(),
parts[2].clone(),
parts[1].clone(),
],
_ => vec![
parts[0].clone(),
parts[1].clone(),
parts[2].clone(),
parts[3].clone(),
],
}
}
/// JS: checks.mjs#clippedByInset. `clip-path: inset(...)` that removes the
/// whole box.
pub fn clipped_by_inset(clip_path: Option<&str>) -> bool {
re!(INSET_RE, format!(r"^inset{ws}*\(([^)]*)\)$", ws = WS));
re!(ROUND_RE, format!(r"{ws}+round{ws}+", ws = WS));
re!(WS_RE, format!("{}+", WS));
re!(PCT_RE, format!(r"^(-?{d}+(?:\.{d}+)?)%$", d = D));
let s = js::to_lower_case(js::trim(clip_path.unwrap_or("")));
let Some(m) = INSET_RE.captures(&s) else {
return false;
};
let inner = m.get(1).map(|x| x.as_str()).unwrap_or("");
let before_round = js::trim(ROUND_RE.split(inner).next().unwrap_or(""));
if before_round.is_empty() {
return false;
}
let parts: Vec<&str> = WS_RE.split(before_round).take(4).collect();
let values = expand_box_shorthand(&parts);
let mut nums: Vec<f64> = Vec::with_capacity(4);
for v in values {
let Some(pm) = PCT_RE.captures(js::trim(v)) else {
return false;
};
nums.push(parse_float(pm.get(1).map(|x| x.as_str()).unwrap_or("")));
}
if nums.len() < 4 {
return false;
}
let (top, right, bottom, left) = (nums[0], nums[1], nums[2], nums[3]);
top + bottom >= 100.0 || left + right >= 100.0
}
/// JS: checks.mjs#clippedByRect. Legacy `clip: rect(...)` that removes the
/// whole box.
pub fn clipped_by_rect(clip: Option<&str>) -> bool {
re!(RECT_RE, format!(r"^rect{ws}*\(([^)]*)\)$", ws = WS));
re!(SEP_RE, format!(r"[,{ws}]+", ws = WS_CHARS));
let s = js::to_lower_case(js::trim(clip.unwrap_or("")));
let Some(m) = RECT_RE.captures(&s) else {
return false;
};
let inner = m.get(1).map(|x| x.as_str()).unwrap_or("");
let values: Vec<&str> = SEP_RE
.split(inner)
.map(js::trim)
.filter(|v| !v.is_empty())
.collect();
if values.len() != 4 {
return false;
}
let mut nums: Vec<f64> = Vec::with_capacity(4);
for v in &values {
match metric_length_px(LengthInput::Text(v), 16.0) {
Some(n) => nums.push(n),
None => return false,
}
}
let (top, right, bottom, left) = (nums[0], nums[1], nums[2], nums[3]);
bottom <= top || right <= left
}
/// The measured box `isScreenReaderOnlyTextStyle` may receive alongside the
/// style (JS `metrics = {}`; each field optional).
#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)]
pub struct SrOnlyMetrics {
pub width: Option<f64>,
pub client_width: Option<f64>,
pub height: Option<f64>,
pub client_height: Option<f64>,
}
/// JS: checks.mjs#isScreenReaderOnlyTextStyle. Visually-hidden-but-readable
/// text: a 1x1 absolutely positioned clipped box, an `inset()` clip-path
/// that removes the box, or a legacy `clip: rect(...)` that does.
pub fn is_screen_reader_only_text_style(
style: Option<&dyn StyleMap>,
metrics: &SrOnlyMetrics,
) -> bool {
let Some(style) = style else { return false };
let clips_overflow = ["overflow", "overflowX", "overflowY"]
.iter()
.map(|p| js::to_lower_case(&prop_or_empty(style, p)))
.any(|v| v == "hidden" || v == "clip");
let font_size_prop = style.prop("fontSize");
let font_size = match metric_length_px(LengthInput::from(font_size_prop.as_deref()), 16.0) {
Some(n) if num_truthy(n) => n,
_ => 16.0,
};
let width_prop = style.prop("width");
let inline_size_prop = style.prop("inlineSize");
let width = first_metric_length_px(
font_size,
&[
LengthInput::from(metrics.width),
LengthInput::from(metrics.client_width),
LengthInput::from(width_prop.as_deref()),
LengthInput::from(inline_size_prop.as_deref()),
],
);
let height_prop = style.prop("height");
let block_size_prop = style.prop("blockSize");
let height = first_metric_length_px(
font_size,
&[
LengthInput::from(metrics.height),
LengthInput::from(metrics.client_height),
LengthInput::from(height_prop.as_deref()),
LengthInput::from(block_size_prop.as_deref()),
],
);
let is_tiny = matches!((width, height), (Some(w), Some(h)) if w <= 2.0 && h <= 2.0);
let is_absolutely_hidden = js::to_lower_case(&prop_or_empty(style, "position")) == "absolute"
&& is_tiny
&& clips_overflow;
let clip_path_raw = match style.prop("clipPath") {
Some(v) if !v.is_empty() => v,
_ => prop_or_empty(style, "webkitClipPath"),
};
let clip_path = js::trim(&clip_path_raw).to_string();
let clip_raw = prop_or_empty(style, "clip");
let clip = js::trim(&clip_raw).to_string();
is_absolutely_hidden || clipped_by_inset(Some(&clip_path)) || clipped_by_rect(Some(&clip))
}
// ─── Content hidden at rest ─────────────────────────────────────────────────
/// Input of `checkContentHiddenAtRest` (JS defaults: 0, 0, `[]`).
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct ContentHiddenInput {
pub total_chars: f64,
pub hidden_chars: f64,
pub hidden_samples: Vec<String>,
}
#[cfg(test)]
mod tests {
use super::*;
// Expected values below were produced by running the JS functions in Node.
#[test]
fn split_shadow_layers_cases() {
assert_eq!(
split_shadow_layers("0 1px 2px rgba(0,0,0,0.3), 0 0 30px hsl(1, 2%, 3%)"),
vec!["0 1px 2px rgba(0,0,0,0.3)", " 0 0 30px hsl(1, 2%, 3%)"]
);
assert_eq!(split_shadow_layers("none"), vec!["none"]);
}
}
+5
View File
@@ -0,0 +1,5 @@
//! CSS reading: value parsing and measurement (`measures`) and the
//! stylesheet-text utilities the scanners are built on (`scan`).
pub mod measures;
pub mod scan;
+564
View File
@@ -0,0 +1,564 @@
//! Stylesheet-text helpers: the parsing, indexing and keyframe-collection
//! utilities that read raw CSS (no DOM), plus the finding shapes the scanners
//! return. The scanners themselves live in `impeccable-core`.
use crate::js::{self, ci, math_max, math_min, parse_float, WS, WS_CHARS};
use crate::js_ext_a::{
is_word_byte, last_index_of_byte, split_commas_outside_parens, split_ws, JsMap,
};
use crate::rules::types::{ANY, B};
use once_cell::sync::Lazy;
use regex::Regex;
use serde::{Deserialize, Serialize};
macro_rules! re {
($name:ident, $pat:expr) => {
static $name: Lazy<Regex> = Lazy::new(|| Regex::new(&$pat).expect(stringify!($name)));
};
}
/// A CSS custom-property map (`--name` -> raw value), first declaration wins.
pub type CustomProps = JsMap<String>;
/// A declaration block map (`prop` -> value), last declaration wins.
pub type DeclMap = JsMap<String>;
/// A `{ index, snippet }` hit; `index` is a byte offset into the scanned text.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct IndexedHit {
pub index: usize,
pub snippet: String,
}
/// A rule-block finding: `{ id, snippet, index?, selector?, severity? }`.
/// Fields the JS leaves off a given finding are `None`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PatternFinding {
pub id: String,
pub snippet: String,
pub selector: Option<String>,
/// Byte offset into the scanned CSS text.
pub index: Option<usize>,
pub severity: Option<String>,
}
fn u8_at(s: &str, i: usize) -> Option<u8> {
s.as_bytes().get(i).copied()
}
// ─── collectCssCustomProps ──────────────────────────────────────────────────
re!(
CUSTOM_PROP_RE,
format!(r"(--[A-Za-z0-9_-]+){WS}*:{WS}*([^;{{}}]+)")
);
/// JS: checks.mjs#collectCssCustomProps
pub fn collect_css_custom_props(content: &str) -> CustomProps {
let mut map = JsMap::new();
for m in CUSTOM_PROP_RE.captures_iter(content) {
let name = &m[1];
if !map.has(name) {
map.set(name, js::trim(&m[2]).to_string());
}
}
map
}
// ─── enclosingCssSelector ───────────────────────────────────────────────────
re!(WS_RUN_RE, format!(r"{WS}+"));
re!(SELECTOR_COMMENT_RE, format!(r"/\*{ANY}*?\*/"));
re!(
KEYFRAME_STEP_RE,
format!(
r"^(?:{from}|{to})(?:{WS}*,{WS}*(?:{from}|{to}))*$",
from = ci("from"),
to = ci("to")
)
);
/// JS: checks.mjs#enclosingCssSelector. `index` is a byte offset into
/// `css_text` (JS passes a UTF-16 index; callers here convert).
pub fn enclosing_css_selector(css_text: &str, index: usize) -> Option<String> {
if css_text.is_empty() {
return None;
}
let open = last_index_of_byte(css_text, b'{', index)?;
// A match inside an inline style fragment (`style="…"` appended to the
// corpus by buildHtmlPatternCorpora) has no enclosing rule; the previous
// `{` belongs to some other selector.
if let Some(close_before_index) = last_index_of_byte(css_text, b'}', index) {
if close_before_index > open {
return None;
}
}
// Ignore delimiters inside comments when locating the previous
// declaration. Blanking each comment to its own length keeps every index
// into the original source valid (#709).
let before_open = SELECTOR_COMMENT_RE.replace_all(&css_text[..open], |c: &regex::Captures| {
" ".repeat(c[0].len())
});
let prev_close = match (
before_open.rfind('}'),
before_open.rfind(';'),
) {
(Some(a), Some(b)) => Some(a.max(b)),
(Some(a), None) => Some(a),
(None, Some(b)) => Some(b),
(None, None) => None,
};
let slice_start = prev_close.map(|p| p + 1).unwrap_or(0);
let no_comments = SELECTOR_COMMENT_RE.replace_all(&css_text[slice_start..open], "");
let raw_trim = js::trim(&no_comments);
let raw = WS_RUN_RE.replace_all(raw_trim, " ").into_owned();
if raw.is_empty()
|| raw.starts_with('@')
|| raw.as_bytes()[0].is_ascii_digit()
|| raw.bytes().any(|b| b == b'{' || b == b'}' || b == b'<')
{
return None;
}
if KEYFRAME_STEP_RE.is_match(&raw) {
return None;
}
Some(raw)
}
// ─── Rule-block helpers ─────────────────────────────────────────────────────
/// JS `CSS_RULE_BLOCK_SOURCE`: `selector { declarations }` pairs; the block
/// body excludes braces so nested structures yield their innermost rules.
pub const CSS_RULE_BLOCK_SOURCE: &str = r"([^{};]+)\{([^{}]*)\}";
re!(
IMPORTANT_TAIL_RE,
format!(r"{WS}*!{}{WS}*$", ci("important"))
);
/// JS: checks.mjs#parseCssDeclBlock
pub fn parse_css_decl_block(block: &str) -> DeclMap {
let mut decls = JsMap::new();
for part in block.split(';') {
let idx = match part.find(':') {
Some(i) if i > 0 => i,
_ => continue,
};
let prop = js::to_lower_case(js::trim(&part[..idx]));
let value_raw = IMPORTANT_TAIL_RE.replace(&part[idx + 1..], "");
let value = js::trim(&value_raw);
if !prop.is_empty() && !value.is_empty() {
decls.set(&prop, value.to_string());
}
}
decls
}
re!(
CSS_LENGTH_RE,
format!(
r"^(-?[0-9.]+)({px}|{rem}|{em})$",
px = ci("px"),
rem = ci("rem"),
em = ci("em")
)
);
/// JS: checks.mjs#cssLengthToPx
pub fn css_length_to_px(value: &str) -> Option<f64> {
let m = CSS_LENGTH_RE.captures(js::trim(value))?;
let n = parse_float(&m[1]);
if m[2].eq_ignore_ascii_case("px") {
Some(n)
} else {
Some(n * 16.0)
}
}
re!(ZERO_OFFSET_RE, r"^-?0(?:px|%|rem|em)?$".to_string());
/// JS: checks.mjs#isZeroOffset
pub fn is_zero_offset(value: Option<&str>) -> bool {
match value {
None => false,
Some(v) => ZERO_OFFSET_RE.is_match(js::trim(v)),
}
}
/// A byte that continues an identifier-ish CSS token (`[A-Za-z0-9_-]`).
pub fn is_word_or_dash(b: u8) -> bool {
is_word_byte(b) || b == b'-'
}
// ─── Keyframes ──────────────────────────────────────────────────────────────
re!(
KEYFRAMES_RE,
format!(r"@(?:-webkit-)?keyframes{WS}+([A-Za-z0-9_-]+){WS}*\{{")
);
/// Walk from `from` (just past an opening brace) to the matching close;
/// returns (byte position after the close or the text end, closed?).
fn scan_brace_block(content: &str, from: usize) -> (usize, bool) {
let bytes = content.as_bytes();
let mut depth: i64 = 1;
let mut i = from;
while i < bytes.len() && depth > 0 {
if bytes[i] == b'{' {
depth += 1;
} else if bytes[i] == b'}' {
depth -= 1;
}
i += 1;
}
(i, depth == 0)
}
/// JS `content.slice(re.lastIndex, Math.max(re.lastIndex, i - 1))` for a
/// keyframes body: the text between the braces, or (unterminated) all but
/// the last code unit.
fn brace_body(content: &str, from: usize, i: usize, closed: bool) -> &str {
let mut e = i;
if closed {
e = i - 1;
} else if e > 0 {
// JS-PARITY: drops one UTF-16 unit; a lone surrogate half cannot be
// represented, so an astral final char is dropped whole.
e -= 1;
while !content.is_char_boundary(e) {
e -= 1;
}
}
&content[from..e.max(from)]
}
re!(
TRANSLATE_X_PCT_RE,
format!(
r"{B}{translate}(?:[xX]|3[dD])?\({WS}*(-?[0-9.]+)%",
translate = ci("translate")
)
);
re!(
SCALE_OR_OPACITY_RE,
format!(
r"{B}{scale}\(|{B}{opacity}{WS}*:",
scale = ci("scale"),
opacity = ci("opacity")
)
);
/// JS: checks.mjs#collectMarqueeKeyframes (a Set, in insertion order)
pub fn collect_marquee_keyframes(content: &str) -> Vec<String> {
let mut names: Vec<String> = Vec::new();
let mut pos = 0usize;
while let Some(m) = KEYFRAMES_RE.captures_at(content, pos) {
let after = m.get(0).unwrap().end();
let (i, closed) = scan_brace_block(content, after);
let body = brace_body(content, after, i, closed);
pos = i;
let pct: Vec<f64> = TRANSLATE_X_PCT_RE
.captures_iter(body)
.map(|xm| parse_float(&xm[1]))
.collect();
if pct.is_empty() {
continue;
}
if pct.len() == 1 && SCALE_OR_OPACITY_RE.is_match(body) {
continue;
}
let travel_pct = if pct.len() > 1 {
let mut mx = f64::NEG_INFINITY;
let mut mn = f64::INFINITY;
for &p in &pct {
mx = math_max(mx, p);
mn = math_min(mn, p);
}
mx - mn
} else {
pct[0].abs()
};
if travel_pct >= 20.0 {
let name = m[1].to_string();
if !names.contains(&name) {
names.push(name);
}
}
}
names
}
re!(OPACITY_DECL_RE, format!(r"{B}{}{WS}*:", ci("opacity")));
re!(
BOX_SHADOW_DECL_RE,
format!(r"{B}{}{WS}*:", ci("box-shadow"))
);
re!(
TRANSFORM_SCALE_RE,
format!(
r"{B}{transform}{WS}*:[^;{{}}]*{B}{scale}",
transform = ci("transform"),
scale = ci("scale")
)
);
/// JS: checks.mjs#collectPulseKeyframes (Map name -> pulses?)
pub fn collect_pulse_keyframes(content: &str) -> JsMap<bool> {
let mut map: JsMap<bool> = JsMap::new();
let mut pos = 0usize;
while let Some(m) = KEYFRAMES_RE.captures_at(content, pos) {
let after = m.get(0).unwrap().end();
let (i, closed) = scan_brace_block(content, after);
let body = brace_body(content, after, i, closed);
let pulses = OPACITY_DECL_RE.is_match(body)
|| BOX_SHADOW_DECL_RE.is_match(body)
|| TRANSFORM_SCALE_RE.is_match(body);
let name = &m[1];
if !map.has(name) || pulses {
map.set(name, pulses);
}
pos = i;
}
map
}
/// JS `ANIMATION_VALUE_KEYWORDS`.
pub const ANIMATION_VALUE_KEYWORDS: &[&str] = &[
"ease",
"ease-in",
"ease-out",
"ease-in-out",
"linear",
"infinite",
"alternate",
"alternate-reverse",
"normal",
"reverse",
"none",
"forwards",
"backwards",
"both",
"running",
"paused",
"step-start",
"step-end",
"inherit",
"initial",
"unset",
];
re!(INFINITE_RE, format!(r"{B}{}{B}", ci("infinite")));
re!(IDENT_RE, r"^[a-zA-Z_-][A-Za-z0-9_-]*$".to_string());
/// JS: checks.mjs#infiniteAnimationNames
pub fn infinite_animation_names(decls: &DeclMap) -> Vec<String> {
let mut out = Vec::new();
if let Some(shorthand) = decls.get("animation").filter(|s| !s.is_empty()) {
for layer in split_commas_outside_parens(shorthand) {
if !INFINITE_RE.is_match(layer) {
continue;
}
let name = split_ws(layer).into_iter().find(|t| {
IDENT_RE.is_match(t)
&& !ANIMATION_VALUE_KEYWORDS.contains(&js::to_lower_case(t).as_str())
});
if let Some(n) = name {
out.push(n.to_string());
}
}
}
if let Some(name_decl) = decls.get("animation-name").filter(|s| !s.is_empty()) {
let count = decls
.get("animation-iteration-count")
.map(|s| s.as_str())
.unwrap_or("");
if INFINITE_RE.is_match(count) {
for raw in name_decl.split(',') {
let t = js::trim(raw);
if !t.is_empty() && js::to_lower_case(t) != "none" {
out.push(t.to_string());
}
}
}
}
out
}
re!(
REDUCED_MOTION_RE,
format!(
r"@{media}[^{{]*{prm}{WS}*:{WS}*{reduce}[^{{]*\{{",
media = ci("media"),
prm = ci("prefers-reduced-motion"),
reduce = ci("reduce")
)
);
/// JS: checks.mjs#stripReducedMotionBlocks
pub fn strip_reduced_motion_blocks(content: &str) -> String {
let mut out = String::new();
let mut last = 0usize;
let mut pos = 0usize;
while let Some(m) = REDUCED_MOTION_RE.find_at(content, pos) {
let (i, _) = scan_brace_block(content, m.end());
out.push_str(&content[last..m.start()]);
last = i;
pos = i;
}
out.push_str(&content[last..]);
out
}
/// JS: checks.mjs#landmarkSourceRanges (byte offsets)
pub fn landmark_source_ranges(content: &str) -> Vec<(usize, usize)> {
let mut ranges = Vec::new();
for tag in ["header", "nav"] {
let re = Regex::new(&format!(r"<{t}{B}|</{t}{WS}*>", t = ci(tag))).expect("landmark re");
let mut stack: Vec<usize> = Vec::new();
for m in re.find_iter(content) {
if m.as_str().as_bytes()[1] == b'/' {
if let Some(start) = stack.pop() {
ranges.push((start, m.start()));
}
} else {
stack.push(m.start());
}
}
}
ranges
}
/// JS: checks.mjs#indexInSourceRanges
pub fn index_in_source_ranges(index: usize, ranges: &[(usize, usize)]) -> bool {
ranges
.iter()
.any(|(start, end)| index >= *start && index < *end)
}
re!(SELECTOR_COMBINATOR_RE, format!(r"[{WS_CHARS}>+~]+"));
re!(ID_TOKEN_RE, r"#([A-Za-z_][A-Za-z0-9_-]*)".to_string());
re!(CLASS_TOKEN_RE, r"\.([A-Za-z_][A-Za-z0-9_-]*)".to_string());
/// Iterate JS `/<[a-zA-Z][^>]*\b<attr>\s*=\s*["']…["']/gi` matches. With
/// `exact` the value must equal `needle` (ASCII case-insensitive); otherwise
/// the value is the run of non-quote chars and must contain `needle` as a
/// `[\w-]`-delimited token. Yields match start offsets in scan order,
/// advancing past each match the way `lastIndex` does.
fn attr_tag_match_starts(content: &str, attr: &str, needle: &str, exact: bool) -> Vec<usize> {
let bytes = content.as_bytes();
let n = bytes.len();
let mut starts = Vec::new();
let mut pos = 0usize;
let is_quote = |b: u8| b == b'"' || b == b'\'';
while pos < n {
// Next `<` followed by an ASCII letter.
let start = match (pos..n)
.find(|&i| bytes[i] == b'<' && i + 1 < n && bytes[i + 1].is_ascii_alphabetic())
{
Some(s) => s,
None => break,
};
let tag_end = (start + 1..n).find(|&i| bytes[i] == b'>').unwrap_or(n);
// Candidate attribute positions q in (start+1, tag_end], last first
// (greedy `[^>]*` backtracks from the longest prefix).
let mut found: Option<usize> = None;
let mut q = tag_end;
while q > start + 2 {
q -= 1;
if q + attr.len() > n || is_word_byte(bytes[q - 1]) {
continue;
}
if !bytes[q..q + attr.len()].eq_ignore_ascii_case(attr.as_bytes()) {
continue;
}
// `\s*=\s*["']`
let mut k = q + attr.len();
k += content.len() - k - js::trim_start(&content[k..]).len();
if u8_at(content, k) != Some(b'=') {
continue;
}
k += 1;
k += content.len() - k - js::trim_start(&content[k..]).len();
if !u8_at(content, k).map_or(false, is_quote) {
continue;
}
k += 1;
let value_start = k;
let mut value_end = value_start;
while value_end < n && !is_quote(bytes[value_end]) {
value_end += 1;
}
if value_end >= n {
continue; // no closing quote
}
let value = &content[value_start..value_end];
let ok = if exact {
value.eq_ignore_ascii_case(needle)
} else {
value_contains_token(value, needle)
};
if ok {
found = Some(value_end + 1);
break;
}
}
match found {
Some(end) => {
starts.push(start);
pos = end.max(start + 1);
}
None => pos = start + 1,
}
}
starts
}
/// `[^"']*(?<![\w-])NEEDLE(?![\w-])[^"']*` over a quote-free value, ASCII
/// case-insensitive.
fn value_contains_token(value: &str, needle: &str) -> bool {
let vb = value.as_bytes();
let nl = needle.len();
if nl == 0 || vb.len() < nl {
return false;
}
for t in 0..=vb.len() - nl {
if !vb[t..t + nl].eq_ignore_ascii_case(needle.as_bytes()) {
continue;
}
let before_ok = t == 0 || !is_word_or_dash(vb[t - 1]);
let after_ok = t + nl == vb.len() || !is_word_or_dash(vb[t + nl]);
if before_ok && after_ok {
return true;
}
}
false
}
/// JS: checks.mjs#selectorHitsLandmark
pub fn selector_hits_landmark(content: &str, selector: &str, ranges: &[(usize, usize)]) -> bool {
if ranges.is_empty() {
return false;
}
let last = SELECTOR_COMBINATOR_RE
.split(selector)
.filter(|s| !s.is_empty())
.last()
.unwrap_or("");
let id_match = ID_TOKEN_RE.captures(last).map(|m| m[1].to_string());
let class_match = CLASS_TOKEN_RE.captures(last).map(|m| m[1].to_string());
let starts = if let Some(id) = id_match {
attr_tag_match_starts(content, "id", &id, true)
} else if let Some(cls) = class_match {
attr_tag_match_starts(content, "class", &cls, false)
} else {
return false;
};
starts
.into_iter()
.any(|s| index_in_source_ranges(s, ranges))
}
+562
View File
@@ -0,0 +1,562 @@
//! `Math.sin` / `Math.cos` exactly as V8 computes them in Node: the fdlibm
//! `sin`/`cos` from `src/base/ieee754.cc` (`__kernel_sin`, `__kernel_cos`,
//! `__ieee754_rem_pio2`, `__kernel_rem_pio2`). Node's V8 is built without
//! `V8_USE_LIBM_TRIG_FUNCTIONS`, so this is the code path behind `Math.sin`.
//! Platform libms differ from it in the last bit on a few percent of inputs.
//!
//! JS-PARITY: V8 compiles this C with clang, whose default `-ffp-contract=on`
//! fuses `a * b + c` into FMA on arm64 builds. Node on Apple Silicon therefore
//! returns a value one ulp away from this port for roughly 0.5% of arguments
//! (Node on x86-64 matches it exactly). No recorded vector distinguishes the
//! two, and every consumer rounds the result to an 8-bit channel.
//!
//! Original: Copyright (C) 1993 by Sun Microsystems, Inc. (fdlibm), as
//! carried in V8 under the V8 license.
#![allow(clippy::excessive_precision)]
#[inline]
fn high_word(d: f64) -> i32 {
(d.to_bits() >> 32) as u32 as i32
}
#[inline]
fn low_word(d: f64) -> u32 {
(d.to_bits() & 0xFFFF_FFFF) as u32
}
#[inline]
fn insert_words(hi: i32, lo: u32) -> f64 {
f64::from_bits(((hi as u32 as u64) << 32) | lo as u64)
}
#[inline]
fn set_high_word(d: f64, hi: i32) -> f64 {
insert_words(hi, low_word(d))
}
#[inline]
fn set_low_word(d: f64, lo: u32) -> f64 {
insert_words(high_word(d), lo)
}
/// fdlibm `scalbn`: x × 2^n.
pub fn scalbn(x: f64, n: i32) -> f64 {
const TWO54: f64 = 1.80143985094819840000e+16;
const TWOM54: f64 = 5.55111512312578270212e-17;
const HUGE: f64 = 1.0e300;
const TINY: f64 = 1.0e-300;
let mut x = x;
let mut hx = high_word(x);
let lx = low_word(x);
let mut k = (hx & 0x7ff00000) >> 20;
if k == 0 {
if (lx | (hx & 0x7fffffff) as u32) == 0 {
return x;
}
x *= TWO54;
hx = high_word(x);
k = ((hx & 0x7ff00000) >> 20) - 54;
if n < -50000 {
return TINY * x;
}
}
if k == 0x7ff {
return x + x;
}
k += n;
if k > 0x7fe {
return HUGE * if x < 0.0 { -HUGE } else { HUGE };
}
if k > 0 {
return set_high_word(x, (hx & 0x800fffff_u32 as i32) | (k << 20));
}
if k <= -54 {
if n > 50000 {
return HUGE * if x < 0.0 { -HUGE } else { HUGE };
}
return TINY * if x < 0.0 { -TINY } else { TINY };
}
k += 54;
x = set_high_word(x, (hx & 0x800fffff_u32 as i32) | (k << 20));
x * TWOM54
}
const TWO_OVER_PI: [i32; 66] = [
0xA2F983, 0x6E4E44, 0x1529FC, 0x2757D1, 0xF534DD, 0xC0DB62, 0x95993C, 0x439041, 0xFE5163,
0xABDEBB, 0xC561B7, 0x246E3A, 0x424DD2, 0xE00649, 0x2EEA09, 0xD1921C, 0xFE1DEB, 0x1CB129,
0xA73EE8, 0x8235F5, 0x2EBB44, 0x84E99C, 0x7026B4, 0x5F7E41, 0x3991D6, 0x398353, 0x39F49C,
0x845F8B, 0xBDF928, 0x3B1FF8, 0x97FFDE, 0x05980F, 0xEF2F11, 0x8B5A0A, 0x6D1F6D, 0x367ECF,
0x27CB09, 0xB74F46, 0x3F669E, 0x5FEA2D, 0x7527BA, 0xC7EBE5, 0xF17B3D, 0x0739F7, 0x8A5292,
0xEA6BFB, 0x5FB11F, 0x8D5D08, 0x560330, 0x46FC7B, 0x6BABF0, 0xCFBC20, 0x9AF436, 0x1DA9E3,
0x91615E, 0xE61B08, 0x659985, 0x5F14A0, 0x68408D, 0xFFD880, 0x4D7327, 0x310606, 0x1556CA,
0x73A8C9, 0x60E27B, 0xC08C6B,
];
const NPIO2_HW: [i32; 32] = [
0x3FF921FB, 0x400921FB, 0x4012D97C, 0x401921FB, 0x401F6A7A, 0x4022D97C, 0x4025FDBB, 0x402921FB,
0x402C463A, 0x402F6A7A, 0x4031475C, 0x4032D97C, 0x40346B9C, 0x4035FDBB, 0x40378FDB, 0x403921FB,
0x403AB41B, 0x403C463A, 0x403DD85A, 0x403F6A7A, 0x40407E4C, 0x4041475C, 0x4042106C, 0x4042D97C,
0x4043A28C, 0x40446B9C, 0x404534AC, 0x4045FDBB, 0x4046C6CB, 0x40478FDB, 0x404858EB, 0x404921FB,
];
/// fdlibm `__kernel_rem_pio2`.
fn kernel_rem_pio2(
x: &[f64],
y: &mut [f64; 3],
e0: i32,
nx: i32,
prec: usize,
ipio2: &[i32],
) -> i32 {
const INIT_JK: [i32; 4] = [2, 3, 4, 6];
const PIO2: [f64; 8] = [
1.57079625129699707031e+00,
7.54978941586159635335e-08,
5.39030252995776476554e-15,
3.28200341580791294123e-22,
1.27065575308067607349e-29,
1.22933308981111328932e-36,
2.73370053816464559624e-44,
2.16741683877804819444e-51,
];
const ZERO: f64 = 0.0;
const ONE: f64 = 1.0;
const TWO24: f64 = 1.67772160000000000000e+07;
const TWON24: f64 = 5.96046447753906250000e-08;
let mut iq = [0i32; 20];
let mut f = [0f64; 20];
let mut fq = [0f64; 20];
let mut q = [0f64; 20];
let jk = INIT_JK[prec];
let jp = jk;
let jx = nx - 1;
let mut jv = (e0 - 3) / 24;
if jv < 0 {
jv = 0;
}
let mut q0 = e0 - 24 * (jv + 1);
let mut j = jv - jx;
let m = jx + jk;
for i in 0..=m {
f[i as usize] = if j < 0 {
ZERO
} else {
ipio2[j as usize] as f64
};
j += 1;
}
for i in 0..=jk {
let mut fw = 0.0;
for j in 0..=jx {
fw += x[j as usize] * f[(jx + i - j) as usize];
}
q[i as usize] = fw;
}
let mut jz = jk;
let mut z;
let mut n;
let mut ih;
loop {
// recompute:
let mut i = 0i32;
let mut jj = jz;
z = q[jz as usize];
while jj > 0 {
let fw = (TWON24 * z) as i32 as f64;
iq[i as usize] = (z - TWO24 * fw) as i32;
z = q[(jj - 1) as usize] + fw;
i += 1;
jj -= 1;
}
z = scalbn(z, q0);
z -= 8.0 * (z * 0.125).floor();
n = z as i32;
z -= n as f64;
ih = 0;
if q0 > 0 {
let i2 = iq[(jz - 1) as usize] >> (24 - q0);
n += i2;
iq[(jz - 1) as usize] -= i2 << (24 - q0);
ih = iq[(jz - 1) as usize] >> (23 - q0);
} else if q0 == 0 {
ih = iq[(jz - 1) as usize] >> 23;
} else if z >= 0.5 {
ih = 2;
}
if ih > 0 {
n += 1;
let mut carry = 0;
for i in 0..jz {
let j = iq[i as usize];
if carry == 0 {
if j != 0 {
carry = 1;
iq[i as usize] = 0x1000000 - j;
}
} else {
iq[i as usize] = 0xFFFFFF - j;
}
}
if q0 > 0 {
match q0 {
1 => iq[(jz - 1) as usize] &= 0x7FFFFF,
2 => iq[(jz - 1) as usize] &= 0x3FFFFF,
_ => {}
}
}
if ih == 2 {
z = ONE - z;
if carry != 0 {
z -= scalbn(ONE, q0);
}
}
}
if z == ZERO {
let mut j = 0;
let mut i = jz - 1;
while i >= jk {
j |= iq[i as usize];
i -= 1;
}
if j == 0 {
let mut k = 1;
while jk >= k && iq[(jk - k) as usize] == 0 {
k += 1;
}
for i in (jz + 1)..=(jz + k) {
f[(jx + i) as usize] = ipio2[(jv + i) as usize] as f64;
let mut fw = 0.0;
for j in 0..=jx {
fw += x[j as usize] * f[(jx + i - j) as usize];
}
q[i as usize] = fw;
}
jz += k;
continue;
}
}
break;
}
if z == 0.0 {
jz -= 1;
q0 -= 24;
while iq[jz as usize] == 0 {
jz -= 1;
q0 -= 24;
}
} else {
z = scalbn(z, -q0);
if z >= TWO24 {
let fw = (TWON24 * z) as i32 as f64;
iq[jz as usize] = (z - TWO24 * fw) as i32;
jz += 1;
q0 += 24;
iq[jz as usize] = fw as i32;
} else {
iq[jz as usize] = z as i32;
}
}
let mut fw = scalbn(ONE, q0);
let mut i = jz;
while i >= 0 {
q[i as usize] = fw * iq[i as usize] as f64;
fw *= TWON24;
i -= 1;
}
let mut i = jz;
while i >= 0 {
let mut fw = 0.0;
let mut k = 0;
while k <= jp && k <= jz - i {
fw += PIO2[k as usize] * q[(i + k) as usize];
k += 1;
}
fq[(jz - i) as usize] = fw;
i -= 1;
}
match prec {
0 => {
let mut fw = 0.0;
let mut i = jz;
while i >= 0 {
fw += fq[i as usize];
i -= 1;
}
y[0] = if ih == 0 { fw } else { -fw };
}
1 | 2 => {
let mut fw = 0.0;
let mut i = jz;
while i >= 0 {
fw += fq[i as usize];
i -= 1;
}
y[0] = if ih == 0 { fw } else { -fw };
let mut fw = fq[0] - fw;
for i in 1..=jz {
fw += fq[i as usize];
}
y[1] = if ih == 0 { fw } else { -fw };
}
_ => {
let mut i = jz;
while i > 0 {
let fw = fq[(i - 1) as usize] + fq[i as usize];
fq[i as usize] += fq[(i - 1) as usize] - fw;
fq[(i - 1) as usize] = fw;
i -= 1;
}
let mut i = jz;
while i > 1 {
let fw = fq[(i - 1) as usize] + fq[i as usize];
fq[i as usize] += fq[(i - 1) as usize] - fw;
fq[(i - 1) as usize] = fw;
i -= 1;
}
let mut fw = 0.0;
let mut i = jz;
while i >= 2 {
fw += fq[i as usize];
i -= 1;
}
if ih == 0 {
y[0] = fq[0];
y[1] = fq[1];
y[2] = fw;
} else {
y[0] = -fq[0];
y[1] = -fq[1];
y[2] = -fw;
}
}
}
n & 7
}
/// fdlibm `__ieee754_rem_pio2` (the original fdlibm shape with the
/// `npio2_hw` table, as V8 carries it).
fn rem_pio2(x: f64, y: &mut [f64; 2]) -> i32 {
const ZERO: f64 = 0.0;
const HALF: f64 = 0.5;
const TWO24: f64 = 1.67772160000000000000e+07;
const INVPIO2: f64 = 6.36619772367581382433e-01;
const PIO2_1: f64 = 1.57079632673412561417e+00;
const PIO2_1T: f64 = 6.07710050650619224932e-11;
const PIO2_2: f64 = 6.07710050630396597660e-11;
const PIO2_2T: f64 = 2.02226624879595063154e-21;
const PIO2_3: f64 = 2.02226624871116645580e-21;
const PIO2_3T: f64 = 8.47842766036889956997e-32;
let hx = high_word(x);
let ix = hx & 0x7FFFFFFF;
if ix <= 0x3FE921FB {
y[0] = x;
y[1] = 0.0;
return 0;
}
if ix < 0x4002D97C {
if hx > 0 {
let mut z = x - PIO2_1;
if ix != 0x3FF921FB {
y[0] = z - PIO2_1T;
y[1] = (z - y[0]) - PIO2_1T;
} else {
z -= PIO2_2;
y[0] = z - PIO2_2T;
y[1] = (z - y[0]) - PIO2_2T;
}
return 1;
} else {
let mut z = x + PIO2_1;
if ix != 0x3FF921FB {
y[0] = z + PIO2_1T;
y[1] = (z - y[0]) + PIO2_1T;
} else {
z += PIO2_2;
y[0] = z + PIO2_2T;
y[1] = (z - y[0]) + PIO2_2T;
}
return -1;
}
}
if ix <= 0x413921FB {
let mut t = x.abs();
let n = (t * INVPIO2 + HALF) as i32;
let fn_ = n as f64;
let mut r = t - fn_ * PIO2_1;
let mut w = fn_ * PIO2_1T;
if n < 32 && ix != NPIO2_HW[(n - 1) as usize] {
y[0] = r - w;
} else {
let j = ix >> 20;
y[0] = r - w;
let high = high_word(y[0]);
let mut i = j - ((high >> 20) & 0x7FF);
if i > 16 {
t = r;
w = fn_ * PIO2_2;
r = t - w;
w = fn_ * PIO2_2T - ((t - r) - w);
y[0] = r - w;
let high = high_word(y[0]);
i = j - ((high >> 20) & 0x7FF);
if i > 49 {
t = r;
w = fn_ * PIO2_3;
r = t - w;
w = fn_ * PIO2_3T - ((t - r) - w);
y[0] = r - w;
}
}
}
y[1] = (r - y[0]) - w;
if hx < 0 {
y[0] = -y[0];
y[1] = -y[1];
return -n;
}
return n;
}
if ix >= 0x7FF00000 {
y[0] = x - x;
y[1] = y[0];
return 0;
}
let low = low_word(x);
let mut z = set_low_word(0.0, low);
let e0 = (ix >> 20) - 1046;
z = set_high_word(z, ix - (((e0 as u32) << 20) as i32));
let mut tx = [0f64; 3];
for item in tx.iter_mut().take(2) {
*item = (z as i32) as f64;
z = (z - *item) * TWO24;
}
tx[2] = z;
let mut nx = 3;
while tx[(nx - 1) as usize] == ZERO {
nx -= 1;
}
let mut yy = [0f64; 3];
let n = kernel_rem_pio2(&tx, &mut yy, e0, nx, 2, &TWO_OVER_PI);
y[0] = yy[0];
y[1] = yy[1];
if hx < 0 {
y[0] = -y[0];
y[1] = -y[1];
return -n;
}
n
}
/// fdlibm `__kernel_cos`.
fn kernel_cos(x: f64, y: f64) -> f64 {
const ONE: f64 = 1.0;
const C1: f64 = 4.16666666666666019037e-02;
const C2: f64 = -1.38888888888741095749e-03;
const C3: f64 = 2.48015872894767294178e-05;
const C4: f64 = -2.75573143513906633035e-07;
const C5: f64 = 2.08757232129817482790e-09;
const C6: f64 = -1.13596475577881948265e-11;
let ix = high_word(x) & 0x7FFFFFFF;
if ix < 0x3E400000 && (x as i32) == 0 {
return ONE;
}
let z = x * x;
let r = z * (C1 + z * (C2 + z * (C3 + z * (C4 + z * (C5 + z * C6)))));
if ix < 0x3FD33333 {
ONE - (0.5 * z - (z * r - x * y))
} else {
let qx = if ix > 0x3FE90000 {
0.28125
} else {
insert_words(ix - 0x00200000, 0)
};
let iz = 0.5 * z - qx;
let a = ONE - qx;
a - (iz - (z * r - x * y))
}
}
/// fdlibm `__kernel_sin`.
fn kernel_sin(x: f64, y: f64, iy: i32) -> f64 {
const HALF: f64 = 0.5;
const S1: f64 = -1.66666666666666324348e-01;
const S2: f64 = 8.33333333332248946124e-03;
const S3: f64 = -1.98412698298579493134e-04;
const S4: f64 = 2.75573137070700676789e-06;
const S5: f64 = -2.50507602534068634195e-08;
const S6: f64 = 1.58969099521155010221e-10;
let ix = high_word(x) & 0x7FFFFFFF;
if ix < 0x3E400000 && (x as i32) == 0 {
return x;
}
let z = x * x;
let v = z * x;
let r = S2 + z * (S3 + z * (S4 + z * (S5 + z * S6)));
if iy == 0 {
x + v * (S1 + z * r)
} else {
x - ((z * (HALF * y - v * r) - y) - v * S1)
}
}
/// fdlibm `sin`.
pub fn fdlibm_sin(x: f64) -> f64 {
let ix = high_word(x) & 0x7FFFFFFF;
if ix <= 0x3FE921FB {
return kernel_sin(x, 0.0, 0);
}
if ix >= 0x7FF00000 {
return x - x;
}
let mut y = [0f64; 2];
let n = rem_pio2(x, &mut y);
match n & 3 {
0 => kernel_sin(y[0], y[1], 1),
1 => kernel_cos(y[0], y[1]),
2 => -kernel_sin(y[0], y[1], 1),
_ => -kernel_cos(y[0], y[1]),
}
}
/// fdlibm `cos`.
pub fn fdlibm_cos(x: f64) -> f64 {
let ix = high_word(x) & 0x7FFFFFFF;
if ix <= 0x3FE921FB {
return kernel_cos(x, 0.0);
}
if ix >= 0x7FF00000 {
return x - x;
}
let mut y = [0f64; 2];
let n = rem_pio2(x, &mut y);
match n & 3 {
0 => kernel_cos(y[0], y[1]),
1 => -kernel_sin(y[0], y[1], 1),
2 => -kernel_cos(y[0], y[1]),
_ => kernel_sin(y[0], y[1], 1),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn smoke() {
assert_eq!(fdlibm_sin(0.0), 0.0);
assert_eq!(fdlibm_cos(0.0), 1.0);
assert!((fdlibm_sin(std::f64::consts::FRAC_PI_2) - 1.0).abs() < 1e-15);
assert!((fdlibm_cos(std::f64::consts::PI) + 1.0).abs() < 1e-15);
assert!((fdlibm_sin(1e10) - (1e10f64).sin()).abs() < 1e-9);
assert!((fdlibm_cos(1e22) - (1e22f64).cos()).abs() < 1e-9);
assert!((fdlibm_sin(-3.0) - (-3.0f64).sin()).abs() < 1e-15);
assert!(fdlibm_sin(f64::INFINITY).is_nan());
assert!(fdlibm_cos(f64::NAN).is_nan());
}
}
+110
View File
@@ -0,0 +1,110 @@
//! Port of `cli/engine/findings.mjs`: the finding object every engine emits.
use crate::inline_ignores::IgnorableFinding;
use crate::registry::{get_ap, Antipattern};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
/// A detector finding, serialized in the exact JS key order:
/// `antipattern, name, description, severity, category, file, line, snippet`
/// then `advisory` (only when true), then any extra keys a caller spread in
/// (`ignoreValue`, ...).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Finding {
pub antipattern: String,
pub name: String,
pub description: String,
pub severity: String,
/// JS `ap.category || null`.
pub category: Option<String>,
pub file: String,
#[serde(with = "crate::js::json_number")]
pub line: f64,
pub snippet: String,
/// JS `advisory: true`, derived from the effective severity (#709).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub advisory: Option<bool>,
/// Extra keys spread onto the finding by callers, in insertion order.
#[serde(flatten)]
pub extras: Map<String, Value>,
}
impl IgnorableFinding for Finding {
fn antipattern(&self) -> Option<&str> {
Some(&self.antipattern)
}
fn line_number(&self) -> f64 {
self.line
}
}
/// JS `finding(id, filePath, snippet, line = 0)` for a rule already resolved
/// from the registry.
pub fn finding_for(ap: &Antipattern, file_path: &str, snippet: &str, line: f64) -> Finding {
let mut f = Finding {
antipattern: ap.id.to_string(),
name: ap.name.to_string(),
description: ap.description.to_string(),
severity: ap.severity.unwrap_or("warning").to_string(),
category: Some(ap.category.to_string()),
file: file_path.to_string(),
line,
snippet: snippet.to_string(),
advisory: None,
extras: Map::new(),
};
derive_advisory_flag(&mut f);
f
}
/// JS: findings.mjs#deriveAdvisoryFlag. `advisory: true` is stamped when and
/// only when the effective severity is `'advisory'`, so a per-finding severity
/// promotion or demotion carries the flag with it (#709).
pub fn derive_advisory_flag(item: &mut Finding) {
item.advisory = if item.severity == "advisory" {
Some(true)
} else {
None
};
}
/// JS `finding(id, filePath, snippet, line = 0)`. Returns `None` for an id
/// that is not in the registry (where the JS would throw a TypeError).
pub fn try_finding(id: &str, file_path: &str, snippet: &str, line: f64) -> Option<Finding> {
get_ap(id).map(|ap| finding_for(ap, file_path, snippet, line))
}
/// JS `finding(id, filePath, snippet, line = 0)`.
///
/// # Panics
/// When `id` is not a registered rule, mirroring the JS `TypeError` on
/// `ap.name`; rule ids are program constants, never user input.
pub fn finding(id: &str, file_path: &str, snippet: &str, line: f64) -> Finding {
try_finding(id, file_path, snippet, line)
.unwrap_or_else(|| panic!("finding(): unknown antipattern id {id:?}"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shape_and_order() {
let f = finding("side-tab", "a.html", "snip", 0.0);
let json = serde_json::to_string(&f).unwrap();
assert!(json.starts_with(
r#"{"antipattern":"side-tab","name":"Side-tab accent border","description":"#
));
assert!(json.contains(
r#""severity":"warning","category":"slop","file":"a.html","line":0,"snippet":"snip"}"#
));
assert!(!json.contains("advisory"));
let mut adv = finding("em-dash-overuse", "a.html", "s", 3.0);
adv.extras
.insert("ignoreValue".into(), Value::String("x".into()));
let json = serde_json::to_string(&adv).unwrap();
assert!(json.ends_with(r#""snippet":"s","advisory":true,"ignoreValue":"x"}"#));
assert_eq!(finding("script-error", "f", "s", 0.0).severity, "error");
assert!(try_finding("nope", "f", "s", 0.0).is_none());
}
}
+120
View File
@@ -0,0 +1,120 @@
//! Port of `cli/engine/shared/fonts.mjs`.
use crate::js::{self, ci, WS_CHARS};
use once_cell::sync::Lazy;
use regex::Regex;
/// JS `GOOGLE_FONTS_URL_RE` = `/fonts\.googleapis\.com\/css2?\?[^"'\s)<>]*/gi`.
static GOOGLE_FONTS_URL_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(&format!(
r#"{fonts}\.{googleapis}\.{com}/{css}2?\?[^"'{WSC})<>]*"#,
fonts = ci("fonts"),
googleapis = ci("googleapis"),
com = ci("com"),
css = ci("css"),
WSC = WS_CHARS
))
.unwrap()
});
/// JS `normalizeGoogleFontFamilyParam(value)`.
pub fn normalize_google_font_family_param(value: &str) -> Vec<String> {
value
.split('|')
.map(|part| js::to_lower_case(js::trim(part.split(':').next().unwrap_or(""))))
.filter(|s| !s.is_empty())
.collect()
}
/// Percent-decode one application/x-www-form-urlencoded value the way
/// `URLSearchParams` does: `+` is a space, `%XX` is a byte, invalid UTF-8
/// becomes U+FFFD.
fn form_urldecode(s: &str) -> String {
let bytes = s.as_bytes();
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
let b = bytes[i];
if b == b'+' {
out.push(b' ');
i += 1;
} else if b == b'%' && i + 2 < bytes.len() {
let h = &bytes[i + 1..i + 3];
match std::str::from_utf8(h)
.ok()
.and_then(|hs| u8::from_str_radix(hs, 16).ok())
{
Some(v) if h.iter().all(|c| c.is_ascii_hexdigit()) => {
out.push(v);
i += 3;
}
_ => {
out.push(b'%');
i += 1;
}
}
} else {
out.push(b);
i += 1;
}
}
String::from_utf8_lossy(&out).into_owned()
}
/// `new URLSearchParams(query).getAll('family')`.
fn get_all_family(query: &str) -> Vec<String> {
let mut out = Vec::new();
for pair in query.split('&') {
if pair.is_empty() {
continue;
}
let (name, value) = match pair.find('=') {
Some(i) => (&pair[..i], &pair[i + 1..]),
None => (pair, ""),
};
if form_urldecode(name) == "family" {
out.push(form_urldecode(value));
}
}
out
}
/// JS `extractGoogleFontFamilies(text)`.
pub fn extract_google_font_families(text: &str) -> Vec<String> {
let mut families = Vec::new();
if text.is_empty() {
return families;
}
for m in GOOGLE_FONTS_URL_RE.find_iter(text) {
let url = m.as_str();
let Some(query_start) = url.find('?') else {
continue;
};
let query = url[query_start + 1..].replace("&amp;", "&");
for value in get_all_family(&query) {
families.extend(normalize_google_font_family_param(&value));
}
}
families
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extracts_families() {
let html = r#"<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&amp;family=Playfair+Display&display=swap" rel="stylesheet">"#;
assert_eq!(
extract_google_font_families(html),
vec!["inter", "playfair display"]
);
let css = "@import url(https://fonts.googleapis.com/css?family=Roboto|Open+Sans:400,700);";
assert_eq!(
extract_google_font_families(css),
vec!["roboto", "open sans"]
);
assert!(extract_google_font_families("").is_empty());
assert_eq!(form_urldecode("a%20b%zz+c%E2%9C%93"), "a b%zz c\u{2713}");
}
}
+208
View File
@@ -0,0 +1,208 @@
//! Port of `cli/engine/shared/inline-ignores.mjs`: eslint-disable-style
//! waivers that live in the scanned file (`impeccable-disable`,
//! `impeccable-disable-line`, `impeccable-disable-next-line`).
use crate::js::{self, ci, WS};
use once_cell::sync::Lazy;
use regex::Regex;
/// JS `DIRECTIVE_RE` =
/// `/impeccable-(disable-next-line|disable-line|disable)\b[ \t]*([^\n\r]*)/gi`.
static DIRECTIVE_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(&format!(
r"{imp}-({dnl}|{dl}|{d})(?-u:\b)[ \t]*([^\n\r]*)",
imp = ci("impeccable"),
dnl = ci("disable-next-line"),
dl = ci("disable-line"),
d = ci("disable")
))
.unwrap()
});
/// JS `TRAILING_CLOSER_RE` = `/\s*(?:\*\/\}?|--+>|\*\}|#\}|%>|\}\})\s*$/`.
static TRAILING_CLOSER_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(&format!(
r"{WS}*(?:\*/\}}?|--+>|\*\}}|#\}}|%>|\}}\}}){WS}*$"
))
.unwrap()
});
/// JS `/\s*(?:--+|:)\s*/`.
static REASON_SEP_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(&format!(r"{WS}*(?:--+|:){WS}*")).unwrap());
/// JS `/[\s,]+/`.
static TOKEN_SPLIT_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(&format!(r"[{},]+", js::WS_CHARS)).unwrap());
/// Cheap bail-out `/impeccable-disable/i`.
static HAS_DIRECTIVE_RE: Lazy<Regex> = Lazy::new(|| Regex::new(&ci("impeccable-disable")).unwrap());
/// An insertion-ordered set of rule ids (JS `Set<string>`).
pub type RuleSet = Vec<String>;
/// The parsed directives of one file (JS `parseInlineIgnores` result).
/// `line` and `next_line` are insertion-ordered maps keyed by the 1-based
/// line the directive targets (JS `Map<number, Set<string>>`).
#[derive(Debug, Clone, Default, PartialEq)]
pub struct InlineIgnores {
pub file: RuleSet,
pub line: Vec<(usize, RuleSet)>,
pub next_line: Vec<(usize, RuleSet)>,
}
/// JS `normalizeRule(token)`.
pub fn normalize_rule(token: &str) -> String {
js::to_lower_case(js::trim(token))
}
/// JS `parseRuleList(remainder)`.
fn parse_rule_list(remainder: &str) -> Vec<String> {
let stripped = TRAILING_CLOSER_RE.replace(remainder, "");
let mut text: &str = js::trim(&stripped);
if let Some(m) = REASON_SEP_RE.find(text) {
text = &text[..m.start()];
}
let tokens: Vec<String> = TOKEN_SPLIT_RE
.split(text)
.map(normalize_rule)
.filter(|t| !t.is_empty())
.collect();
if tokens.is_empty() || tokens.iter().any(|t| t == "*") {
return vec!["*".to_string()];
}
tokens
}
fn add_rules(set: &mut RuleSet, rules: &[String]) {
for rule in rules {
if !set.iter().any(|r| r == rule) {
set.push(rule.clone());
}
}
}
fn get_set(map: &mut Vec<(usize, RuleSet)>, key: usize) -> &mut RuleSet {
if let Some(pos) = map.iter().position(|(k, _)| *k == key) {
&mut map[pos].1
} else {
map.push((key, Vec::new()));
&mut map.last_mut().unwrap().1
}
}
/// JS `parseInlineIgnores(content)`.
pub fn parse_inline_ignores(content: Option<&str>) -> InlineIgnores {
let mut result = InlineIgnores::default();
let text = content.unwrap_or("");
if !HAS_DIRECTIVE_RE.is_match(text) {
return result;
}
for (i, line) in text.split('\n').enumerate() {
for m in DIRECTIVE_RE.captures_iter(line) {
let variant = js::to_lower_case(m.get(1).unwrap().as_str());
let rules = parse_rule_list(m.get(2).map(|g| g.as_str()).unwrap_or(""));
if variant == "disable" {
add_rules(&mut result.file, &rules);
} else if variant == "disable-line" {
add_rules(get_set(&mut result.line, i + 1), &rules);
} else {
// disable-next-line on line i+1 targets line i+2.
add_rules(get_set(&mut result.next_line, i + 2), &rules);
}
}
}
result
}
fn set_matches(set: Option<&RuleSet>, rule: &str) -> bool {
match set {
Some(set) => set.iter().any(|r| r == "*" || r == rule),
None => false,
}
}
fn map_get<'a>(map: &'a [(usize, RuleSet)], key: usize) -> Option<&'a RuleSet> {
map.iter().find(|(k, _)| *k == key).map(|(_, s)| s)
}
/// The two fields `isInlineIgnored` reads off a finding.
pub trait IgnorableFinding {
/// JS `finding.antipattern` (None when absent / not a string).
fn antipattern(&self) -> Option<&str>;
/// JS `Number(finding.line)`.
fn line_number(&self) -> f64;
}
/// JS `isInlineIgnored(finding, directives)`.
pub fn is_inline_ignored<F: IgnorableFinding + ?Sized>(
finding: &F,
directives: &InlineIgnores,
) -> bool {
let rule = normalize_rule(finding.antipattern().unwrap_or(""));
if rule.is_empty() {
return false;
}
if set_matches(Some(&directives.file), &rule) {
return true;
}
// `Number(finding.line) || 0`
let mut line = finding.line_number();
if line.is_nan() {
line = 0.0;
}
if line > 0.0 {
// Map keys are the integer line numbers written by the parser; a
// non-integer line can never match one.
if line.fract() == 0.0 && line <= usize::MAX as f64 {
let key = line as usize;
if set_matches(map_get(&directives.line, key), &rule) {
return true;
}
if set_matches(map_get(&directives.next_line, key), &rule) {
return true;
}
}
}
false
}
/// JS `hasDirectives(directives)`.
pub fn has_directives(directives: &InlineIgnores) -> bool {
!directives.file.is_empty() || !directives.line.is_empty() || !directives.next_line.is_empty()
}
/// JS `applyInlineIgnores(findings, content)`: drop findings waived by an
/// inline directive in the same file's source text.
pub fn apply_inline_ignores<F: IgnorableFinding>(
findings: Vec<F>,
content: Option<&str>,
) -> Vec<F> {
if findings.is_empty() {
return findings;
}
let directives = parse_inline_ignores(content);
if !has_directives(&directives) {
return findings;
}
findings
.into_iter()
.filter(|f| !is_inline_ignored(f, &directives))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_directives() {
let src = "<!-- impeccable-disable low-contrast -- exported -->\nx /* impeccable-disable-line design-system-font */\n// impeccable-disable-next-line bounce-easing: reason\nfoo\n";
let d = parse_inline_ignores(Some(src));
assert_eq!(d.file, vec!["low-contrast"]);
assert_eq!(d.line, vec![(2, vec!["design-system-font".to_string()])]);
assert_eq!(d.next_line, vec![(4, vec!["bounce-easing".to_string()])]);
let bare = parse_inline_ignores(Some("<!-- impeccable-disable -->"));
assert_eq!(bare.file, vec!["*"]);
}
}
+863
View File
@@ -0,0 +1,863 @@
//! JavaScript semantics helpers shared by the whole port.
//!
//! Everything here reproduces ECMAScript / V8 behavior bit for bit so that
//! numbers, strings, and math results the JS engine would have produced come
//! out identical from the Rust side. Nothing in this module touches I/O.
// ─── Whitespace and string helpers ──────────────────────────────────────────
/// The characters JS `String.prototype.trim` and the regex `\s` class treat as
/// whitespace: ECMA-262 WhiteSpace (TAB, VT, FF, SP, NBSP, ZWNBSP, and the
/// Unicode `Zs` category) plus LineTerminator (LF, CR, LS, PS).
pub fn is_js_whitespace(c: char) -> bool {
matches!(
c,
'\u{0009}'
| '\u{000A}'
| '\u{000B}'
| '\u{000C}'
| '\u{000D}'
| '\u{0020}'
| '\u{00A0}'
| '\u{1680}'
| '\u{2000}'
..='\u{200A}'
| '\u{2028}'
| '\u{2029}'
| '\u{202F}'
| '\u{205F}'
| '\u{3000}'
| '\u{FEFF}'
)
}
/// The body of a regex character class matching exactly the JS `\s` set, for
/// splicing into `regex` crate patterns (whose `\s` is Unicode White_Space and
/// differs at U+0085 and U+FEFF).
pub const WS_CHARS: &str = r"\t\n\x0B\x0C\r \x{A0}\x{1680}\x{2000}-\x{200A}\x{2028}\x{2029}\x{202F}\x{205F}\x{3000}\x{FEFF}";
/// A regex class equal to JS `\s`.
pub const WS: &str = r"[\t\n\x0B\x0C\r \x{A0}\x{1680}\x{2000}-\x{200A}\x{2028}\x{2029}\x{202F}\x{205F}\x{3000}\x{FEFF}]";
/// JS `String.prototype.trim`.
pub fn trim(s: &str) -> &str {
s.trim_matches(is_js_whitespace)
}
/// JS `String.prototype.trimStart`.
pub fn trim_start(s: &str) -> &str {
s.trim_start_matches(is_js_whitespace)
}
/// JS `String.prototype.toLowerCase` (Unicode default full case mapping;
/// Rust's `to_lowercase` implements the same mapping including final sigma).
pub fn to_lower_case(s: &str) -> String {
s.to_lowercase()
}
/// JS `String.prototype.toUpperCase`.
pub fn to_upper_case(s: &str) -> String {
s.to_uppercase()
}
/// Expand an ASCII keyword into a case-insensitive regex fragment
/// (`oklch` -> `[oO][kK][lL][cC][hH]`). JS non-unicode `/i` never lets a
/// non-ASCII character match an ASCII one, so this is exactly its behavior
/// for ASCII literals, unlike the regex crate's Unicode case folding.
pub fn ci(word: &str) -> String {
let mut out = String::with_capacity(word.len() * 4);
for c in word.chars() {
if c.is_ascii_alphabetic() {
out.push('[');
out.push(c.to_ascii_lowercase());
out.push(c.to_ascii_uppercase());
out.push(']');
} else {
out.push_str(&regex::escape(&c.to_string()));
}
}
out
}
// ─── Number -> string ───────────────────────────────────────────────────────
/// Shortest round-trip decimal digits of a positive finite f64, as
/// (digits, n) where value = 0.d1d2..dk × 10^n, i.e. ECMA-262's (s, k, n)
/// with `digits.len() == k`.
fn shortest_digits(v: f64) -> (String, i32) {
debug_assert!(v.is_finite() && v > 0.0);
// Rust's `{:e}` is the shortest representation that round-trips.
let s = format!("{:e}", v);
let (mant, exp) = s.split_once('e').expect("exp form");
let exp: i32 = exp.parse().expect("exp int");
let mut digits: String = mant.chars().filter(|c| *c != '.').collect();
// ECMA-262 Number::toString: when two shortest candidates are equally
// close to x, choose the even one. Rust picks the upper one. A tie means
// the exact expansion of x has exactly k+1 significant digits ending in
// 5; check cheaply at 21 digits, then confirm on the exact expansion.
let k = digits.len();
if k < 17 {
let probe = exact_sig_digits(v, 20);
if probe.len() == k + 1 && probe.ends_with('5') {
let exact = exact_sig_digits(v, 1100);
if exact.len() == k + 1 && exact.ends_with('5') {
let lower = &exact[..k];
let last = lower.as_bytes()[k - 1] - b'0';
if last % 2 == 0 {
digits = lower.to_string();
} else {
let mut up = lower.as_bytes().to_vec();
let mut i = k;
loop {
if i == 0 {
up.insert(0, b'1');
break;
}
i -= 1;
if up[i] == b'9' {
up[i] = b'0';
} else {
up[i] += 1;
break;
}
}
let mut up = String::from_utf8(up).unwrap();
while up.len() > 1 && up.ends_with('0') {
up.pop();
}
digits = up;
}
}
}
}
// mant is d.ddd × 10^exp = 0.dddd × 10^(exp+1)
(digits, exp + 1)
}
/// Significant digits of `v` rounded (half-even) to `prec + 1` digits, with
/// trailing zeros removed. `prec = 1100` yields the exact expansion.
fn exact_sig_digits(v: f64, prec: usize) -> String {
let s = format!("{:.*e}", prec, v);
let (mant, _) = s.split_once('e').expect("exp form");
let mut digits: String = mant.chars().filter(|c| *c != '.').collect();
while digits.len() > 1 && digits.ends_with('0') {
digits.pop();
}
digits
}
/// JS `Number.prototype.toString()` (radix 10), per ECMA-262 Number::toString.
pub fn number_to_string(v: f64) -> String {
if v.is_nan() {
return "NaN".to_string();
}
if v == 0.0 {
return "0".to_string();
}
if v.is_infinite() {
return if v > 0.0 {
"Infinity".to_string()
} else {
"-Infinity".to_string()
};
}
if v < 0.0 {
return format!("-{}", number_to_string(-v));
}
let (digits, n) = shortest_digits(v);
let k = digits.len() as i32;
if k <= n && n <= 21 {
let mut out = digits;
for _ in 0..(n - k) {
out.push('0');
}
return out;
}
if 0 < n && n <= 21 {
let (a, b) = digits.split_at(n as usize);
return format!("{}.{}", a, b);
}
if -6 < n && n <= 0 {
let mut out = String::from("0.");
for _ in 0..(-n) {
out.push('0');
}
out.push_str(&digits);
return out;
}
let e = n - 1;
let sign = if e < 0 { '-' } else { '+' };
if k == 1 {
return format!("{}e{}{}", digits, sign, e.abs());
}
let (a, b) = digits.split_at(1);
format!("{}.{}e{}{}", a, b, sign, e.abs())
}
/// The next representable double above `v` (for positive finite `v`).
fn next_double(v: f64) -> f64 {
if v.is_nan() || v == f64::INFINITY {
return v;
}
if v == 0.0 {
return f64::from_bits(1);
}
let bits = v.to_bits();
if v > 0.0 {
f64::from_bits(bits + 1)
} else {
f64::from_bits(bits - 1)
}
}
/// Unbiased exponent of the double's integer-mantissa form (V8 `Double::Exponent`).
fn double_exponent(v: f64) -> i32 {
let bits = v.to_bits();
let biased = ((bits >> 52) & 0x7ff) as i32;
if biased == 0 {
// Denormal
return -1074;
}
biased - 1075
}
/// JS `Number.prototype.toString(radix)` for radix 2..36 (V8's
/// `DoubleToRadixCString`). Radix 10 delegates to [`number_to_string`].
pub fn number_to_string_radix(value: f64, radix: u32) -> String {
if radix == 10 {
return number_to_string(value);
}
if value.is_nan() {
return "NaN".to_string();
}
if value == 0.0 {
return "0".to_string();
}
if value.is_infinite() {
return if value > 0.0 {
"Infinity".to_string()
} else {
"-Infinity".to_string()
};
}
const CHARS: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyz";
let radix_f = radix as f64;
let negative = value < 0.0;
let value = if negative { -value } else { value };
let mut integer = value.floor();
let mut fraction = value - integer;
let mut delta = 0.5 * (next_double(value) - value);
delta = delta.max(next_double(0.0));
let mut frac_digits: Vec<u8> = Vec::new();
if fraction >= delta {
loop {
fraction *= radix_f;
delta *= radix_f;
let digit = fraction as i32;
frac_digits.push(CHARS[digit as usize]);
fraction -= digit as f64;
if fraction > 0.5 || (fraction == 0.5 && (digit & 1) == 1) {
if fraction + delta > 1.0 {
// Carry over into already written digits.
loop {
match frac_digits.pop() {
None => {
integer += 1.0;
break;
}
Some(c) => {
let d = if c > b'9' {
(c - b'a') as u32 + 10
} else {
(c - b'0') as u32
};
if d + 1 < radix {
frac_digits.push(CHARS[(d + 1) as usize]);
break;
}
}
}
}
break;
}
}
if !(fraction >= delta) {
break;
}
}
}
// Integer digits, filling unrepresented low digits with zero.
let mut int_digits: Vec<u8> = Vec::new();
while double_exponent(integer / radix_f) > 0 {
integer /= radix_f;
int_digits.push(b'0');
}
loop {
let remainder = integer % radix_f;
int_digits.push(CHARS[remainder as usize]);
integer = (integer - remainder) / radix_f;
if !(integer > 0.0) {
break;
}
}
let mut out = String::new();
if negative {
out.push('-');
}
for &c in int_digits.iter().rev() {
out.push(c as char);
}
if !frac_digits.is_empty() {
out.push('.');
for &c in &frac_digits {
out.push(c as char);
}
}
out
}
/// JS `Number.prototype.toFixed(digits)`: rounds the exact decimal expansion
/// half-up (ties pick the larger n), unlike Rust's ties-to-even formatting.
pub fn to_fixed(v: f64, digits: usize) -> String {
if !v.is_finite() {
return number_to_string(v);
}
if v.abs() >= 1e21 {
return number_to_string(v);
}
if v < 0.0 {
return format!("-{}", to_fixed(-v, digits));
}
// Exact decimal expansion of the double (a double has at most 1074
// fractional digits, so 1100 places is exact with trailing zeros).
let exact = format!("{:.1100}", v.abs());
let (int_part, frac_part) = exact.split_once('.').expect("fixed form");
let keep = &frac_part[..digits];
let rest = &frac_part[digits..];
let round_up = match rest.as_bytes().first() {
None => false,
Some(&c) => c > b'5' || (c == b'5'), // remainder >= .5 rounds up (half-up)
};
let mut buf: Vec<u8> = format!("{}{}", int_part, keep).into_bytes();
if round_up {
let mut i = buf.len();
loop {
if i == 0 {
buf.insert(0, b'1');
break;
}
i -= 1;
if buf[i] == b'9' {
buf[i] = b'0';
} else {
buf[i] += 1;
break;
}
}
}
let int_len = buf.len() - digits;
let mut out = String::from_utf8(buf[..int_len].to_vec()).unwrap();
if digits > 0 {
out.push('.');
out.push_str(std::str::from_utf8(&buf[int_len..]).unwrap());
}
out
}
// ─── String -> number ───────────────────────────────────────────────────────
fn scan_decimal_prefix(s: &str) -> Option<(usize, String)> {
// Returns (byte length consumed, normalized literal for Rust parsing).
let b = s.as_bytes();
let mut i = 0;
let mut norm = String::new();
if i < b.len() && (b[i] == b'+' || b[i] == b'-') {
if b[i] == b'-' {
norm.push('-');
}
i += 1;
}
if s[i..].starts_with("Infinity") {
norm.push_str("inf");
return Some((i + "Infinity".len(), norm));
}
let int_start = i;
while i < b.len() && b[i].is_ascii_digit() {
i += 1;
}
let int_digits = &s[int_start..i];
let mut frac_digits = "";
let mut consumed = i;
if i < b.len() && b[i] == b'.' {
let fs = i + 1;
let mut j = fs;
while j < b.len() && b[j].is_ascii_digit() {
j += 1;
}
frac_digits = &s[fs..j];
if !int_digits.is_empty() || !frac_digits.is_empty() {
consumed = j;
}
}
if int_digits.is_empty() && frac_digits.is_empty() {
return None;
}
// Exponent
let mut exp = String::new();
if consumed < b.len() && (b[consumed] == b'e' || b[consumed] == b'E') {
let mut j = consumed + 1;
let mut e = String::from("e");
if j < b.len() && (b[j] == b'+' || b[j] == b'-') {
e.push(b[j] as char);
j += 1;
}
let ds = j;
while j < b.len() && b[j].is_ascii_digit() {
j += 1;
}
if j > ds {
e.push_str(&s[ds..j]);
exp = e;
consumed = j;
}
}
if int_digits.is_empty() {
norm.push('0');
} else {
norm.push_str(int_digits);
}
if !frac_digits.is_empty() {
norm.push('.');
norm.push_str(frac_digits);
}
norm.push_str(&exp);
Some((consumed, norm))
}
/// JS global `parseFloat`.
pub fn parse_float(s: &str) -> f64 {
let t = trim_start(s);
match scan_decimal_prefix(t) {
None => f64::NAN,
Some((_, norm)) => norm.parse::<f64>().unwrap_or(f64::NAN),
}
}
/// JS `Number(string)` / unary `+` on a string (StringToNumber).
pub fn string_to_number(s: &str) -> f64 {
let t = trim(s);
if t.is_empty() {
return 0.0;
}
let lower_prefix = |p: &str| {
t.len() > 2 && t.as_bytes()[0] == b'0' && (t.as_bytes()[1] | 0x20) == p.as_bytes()[1]
};
if lower_prefix("0x") {
return parse_radix_digits(&t[2..], 16).unwrap_or(f64::NAN);
}
if lower_prefix("0o") {
return parse_radix_digits(&t[2..], 8).unwrap_or(f64::NAN);
}
if lower_prefix("0b") {
return parse_radix_digits(&t[2..], 2).unwrap_or(f64::NAN);
}
match scan_decimal_prefix(t) {
Some((n, norm)) if n == t.len() => norm.parse::<f64>().unwrap_or(f64::NAN),
_ => f64::NAN,
}
}
fn parse_radix_digits(s: &str, radix: u32) -> Option<f64> {
if s.is_empty() {
return None;
}
let mut v = 0.0f64;
for c in s.chars() {
let d = c.to_digit(radix)?;
v = v * radix as f64 + d as f64;
}
Some(v)
}
/// JS global `parseInt(string, radix)`. `radix == 0` means "auto" (10, or 16
/// after a `0x` prefix). Returns NaN when no digit can be read.
pub fn parse_int(s: &str, radix: u32) -> f64 {
let mut t = trim_start(s);
let mut sign = 1.0;
if let Some(rest) = t.strip_prefix('-') {
sign = -1.0;
t = rest;
} else if let Some(rest) = t.strip_prefix('+') {
t = rest;
}
let mut r = radix;
let mut strip_prefix = true;
if r != 0 {
if !(2..=36).contains(&r) {
return f64::NAN;
}
if r != 16 {
strip_prefix = false;
}
} else {
r = 10;
}
if strip_prefix && (t.starts_with("0x") || t.starts_with("0X")) {
t = &t[2..];
r = 16;
}
let end = t
.chars()
.take_while(|c| c.to_digit(r).is_some())
.map(|c| c.len_utf8())
.sum::<usize>();
if end == 0 {
return f64::NAN;
}
let digits = &t[..end];
let v = if r == 10 {
digits.parse::<f64>().unwrap_or(f64::NAN)
} else {
parse_radix_digits(digits, r).unwrap_or(f64::NAN)
};
sign * v
}
// ─── Math ───────────────────────────────────────────────────────────────────
/// JS `Math.round`: nearest integer, ties toward +∞, preserving -0.
pub fn math_round(x: f64) -> f64 {
if !x.is_finite() {
return x;
}
let f = x.floor();
let diff = x - f;
let r = if diff >= 0.5 { f + 1.0 } else { f };
if r == 0.0 && x < 0.0 {
-0.0
} else {
r
}
}
/// JS `Math.max` over two values (NaN-propagating, +0 beats -0).
pub fn math_max(a: f64, b: f64) -> f64 {
if a.is_nan() || b.is_nan() {
return f64::NAN;
}
if a == 0.0 && b == 0.0 {
return if a.is_sign_negative() && b.is_sign_negative() {
-0.0
} else {
0.0
};
}
if a > b {
a
} else {
b
}
}
/// JS `Math.min` over two values (NaN-propagating, -0 beats +0).
pub fn math_min(a: f64, b: f64) -> f64 {
if a.is_nan() || b.is_nan() {
return f64::NAN;
}
if a == 0.0 && b == 0.0 {
return if a.is_sign_negative() || b.is_sign_negative() {
-0.0
} else {
0.0
};
}
if a < b {
a
} else {
b
}
}
/// JS `Math.max(a, b, c)`.
pub fn math_max3(a: f64, b: f64, c: f64) -> f64 {
math_max(math_max(a, b), c)
}
/// JS `Math.min(a, b, c)`.
pub fn math_min3(a: f64, b: f64, c: f64) -> f64 {
math_min(math_min(a, b), c)
}
/// JS `Math.hypot(...values)` as V8 computes it: scale by the max, Kahan-sum
/// the squares, `sqrt(sum) * max`.
pub fn math_hypot(values: &[f64]) -> f64 {
if values.is_empty() {
return 0.0;
}
let mut one_nan = false;
let mut max = 0.0f64;
let mut abs: Vec<f64> = Vec::with_capacity(values.len());
for &v in values {
if v.is_nan() {
one_nan = true;
abs.push(0.0);
} else {
let a = v.abs();
abs.push(a);
if a > max {
max = a;
}
}
}
if max == f64::INFINITY {
return f64::INFINITY;
}
if one_nan {
return f64::NAN;
}
if max == 0.0 {
return 0.0;
}
let mut sum = 0.0f64;
let mut compensation = 0.0f64;
for a in abs {
let n = a / max;
let summand = n * n - compensation;
let preliminary = sum + summand;
compensation = (preliminary - sum) - summand;
sum = preliminary;
}
sum.sqrt() * max
}
/// JS `Math.sin` as V8 computes it in Node: fdlibm `sin` (Node builds V8
/// without `V8_USE_LIBM_TRIG_FUNCTIONS`, so `base::ieee754::sin` is fdlibm).
pub fn math_sin(x: f64) -> f64 {
crate::fdlibm_trig::fdlibm_sin(x)
}
/// JS `Math.cos` as V8 computes it in Node (see [`math_sin`]).
pub fn math_cos(x: f64) -> f64 {
crate::fdlibm_trig::fdlibm_cos(x)
}
/// JS `Math.pow` / `**` as V8 computes it (`v8::internal::math::pow` with
/// `--use-std-math-pow`, the default): the ECMAScript special cases, then
/// `std::pow` from the platform libm, which is what `f64::powf` calls.
pub fn math_pow(x: f64, y: f64) -> f64 {
if y.is_nan() {
return f64::NAN;
}
if y.is_infinite() && (x == 1.0 || x == -1.0) {
return f64::NAN;
}
if y == 2.0 {
return x * x;
}
if y == 0.5 {
if x.is_infinite() {
return f64::INFINITY;
}
return (x + 0.0).sqrt();
}
x.powf(y)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn number_to_string_cases() {
assert_eq!(number_to_string(0.1 + 0.2), "0.30000000000000004");
assert_eq!(number_to_string(1e21), "1e+21");
assert_eq!(
number_to_string(123456789012345680000.0),
"123456789012345680000"
);
assert_eq!(number_to_string(5e-7), "5e-7");
assert_eq!(number_to_string(0.000001), "0.000001");
assert_eq!(number_to_string(-0.0), "0");
assert_eq!(number_to_string(f64::NAN), "NaN");
assert_eq!(number_to_string(f64::INFINITY), "Infinity");
assert_eq!(number_to_string(f64::NEG_INFINITY), "-Infinity");
assert_eq!(number_to_string(1.0), "1");
assert_eq!(number_to_string(-1.5), "-1.5");
assert_eq!(
number_to_string(1.7976931348623157e308),
"1.7976931348623157e+308"
);
assert_eq!(number_to_string(5e-324), "5e-324");
assert_eq!(number_to_string(1234.5678), "1234.5678");
assert_eq!(number_to_string(1.5e-7), "1.5e-7");
assert_eq!(number_to_string(100.0), "100");
assert_eq!(number_to_string(255.0), "255");
// Shortest-digit ties pick the even candidate (ECMA-262), not the upper.
assert_eq!(number_to_string(980014115057302.25), "980014115057302.2");
assert_eq!(number_to_string(896675801537170.25), "896675801537170.2");
assert_eq!(number_to_string(-980014115057302.25), "-980014115057302.2");
}
#[test]
fn number_to_string_radix_cases() {
assert_eq!(number_to_string_radix(255.0, 16), "ff");
assert_eq!(number_to_string_radix(0.0, 16), "0");
assert_eq!(number_to_string_radix(10.0, 16), "a");
assert_eq!(number_to_string_radix(255.5, 16), "ff.8");
assert_eq!(number_to_string_radix(-255.0, 16), "-ff");
assert_eq!(number_to_string_radix(0.1, 16), "0.1999999999999a");
assert_eq!(number_to_string_radix(f64::NAN, 16), "NaN");
assert_eq!(number_to_string_radix(1e21, 16), "3635c9adc5dea00000");
}
#[test]
fn to_fixed_cases() {
assert_eq!(to_fixed(2.5, 0), "3");
assert_eq!(to_fixed(1.005, 2), "1.00");
assert_eq!(to_fixed(1.5, 0), "2");
assert_eq!(to_fixed(0.5, 0), "1");
assert_eq!(to_fixed(-2.5, 0), "-3");
assert_eq!(to_fixed(-0.0001, 2), "-0.00");
assert_eq!(to_fixed(0.0, 2), "0.00");
assert_eq!(to_fixed(-0.0, 2), "0.00");
assert_eq!(to_fixed(1e21, 2), "1e+21");
assert_eq!(to_fixed(4.35, 1), "4.3");
assert_eq!(to_fixed(4.45, 1), "4.5");
assert_eq!(to_fixed(9.995, 2), "9.99");
assert_eq!(to_fixed(99.5, 0), "100");
assert_eq!(to_fixed(0.000001, 7), "0.0000010");
assert_eq!(to_fixed(f64::NAN, 2), "NaN");
assert_eq!(to_fixed(3.6, 1), "3.6");
assert_eq!(to_fixed(1.45, 1), "1.4");
assert_eq!(to_fixed(8.345, 2), "8.35");
}
#[test]
fn parse_float_cases() {
assert_eq!(parse_float(" 1.5abc"), 1.5);
assert!(parse_float("abc").is_nan());
assert!(parse_float("").is_nan());
assert!(parse_float(".").is_nan());
assert_eq!(parse_float(".5"), 0.5);
assert_eq!(parse_float("1."), 1.0);
assert_eq!(parse_float("1e"), 1.0);
assert_eq!(parse_float("1e3x"), 1000.0);
assert_eq!(parse_float("-.5"), -0.5);
assert_eq!(parse_float("1.2.3"), 1.2);
assert!(parse_float("--5").is_nan());
assert_eq!(parse_float("Infinityx"), f64::INFINITY);
assert_eq!(parse_float("-Infinity"), f64::NEG_INFINITY);
assert_eq!(parse_float("\u{a0}\u{feff}42"), 42.0);
assert!(parse_float("0x10").is_nan() == false && parse_float("0x10") == 0.0);
assert_eq!(parse_float("21.5%"), 21.5);
}
#[test]
fn string_to_number_cases() {
assert_eq!(string_to_number(""), 0.0);
assert_eq!(string_to_number(" "), 0.0);
assert_eq!(string_to_number("12"), 12.0);
assert_eq!(string_to_number("1."), 1.0);
assert_eq!(string_to_number(".5"), 0.5);
assert!(string_to_number("1.2.3").is_nan());
assert!(string_to_number(".").is_nan());
assert!(string_to_number("1e").is_nan());
assert_eq!(string_to_number("0x10"), 16.0);
assert_eq!(string_to_number("-Infinity"), f64::NEG_INFINITY);
assert_eq!(string_to_number("+.5"), 0.5);
}
#[test]
fn parse_int_cases() {
assert_eq!(parse_int("ff", 16), 255.0);
assert_eq!(parse_int("0xff", 16), 255.0);
assert_eq!(parse_int("0xff", 0), 255.0);
assert_eq!(parse_int("12px", 10), 12.0);
assert!(parse_int("px", 10).is_nan());
assert_eq!(parse_int("-08", 10), -8.0);
assert!(parse_int("1", 1).is_nan());
assert_eq!(parse_int("zz", 36), 1295.0);
}
#[test]
fn math_round_cases() {
assert_eq!(math_round(2.5), 3.0);
assert_eq!(math_round(-2.5), -2.0);
assert_eq!(math_round(0.49999999999999994), 0.0);
assert!(math_round(-0.3).is_sign_negative());
assert!(math_round(-0.5).is_sign_negative() && math_round(-0.5) == 0.0);
assert_eq!(math_round(-1e-20), 0.0);
assert_eq!(math_round(1e300), 1e300);
}
#[test]
fn math_minmax_cases() {
assert!(math_max(1.0, f64::NAN).is_nan());
assert!(math_max(-0.0, 0.0).is_sign_positive());
assert!(math_min(-0.0, 0.0).is_sign_negative());
assert_eq!(math_max3(1.0, 3.0, 2.0), 3.0);
assert_eq!(math_min3(1.0, 3.0, 2.0), 1.0);
}
#[test]
fn math_smoke() {
assert_eq!(math_pow(2.0, 10.0), 1024.0);
assert_eq!(math_pow(4.0, 0.5), 2.0);
assert!(math_pow(1.0, f64::INFINITY).is_nan());
assert_eq!(math_pow(f64::NEG_INFINITY, 0.5), f64::INFINITY);
assert!(math_pow(-2.0, 0.5).is_nan());
assert!(math_pow(2.0, f64::NAN).is_nan());
assert_eq!(math_pow(-0.0, 0.5), 0.0);
assert!(math_pow(-0.0, 0.5).is_sign_positive());
assert!((math_pow(0.5, 2.4) - 0.18946457081379978).abs() < 1e-15);
assert_eq!(math_sin(0.0), 0.0);
assert_eq!(math_cos(0.0), 1.0);
assert!((math_sin(std::f64::consts::FRAC_PI_2) - 1.0).abs() < 1e-15);
assert!((math_cos(std::f64::consts::PI) + 1.0).abs() < 1e-15);
assert!((math_sin(1e10) - (1e10f64).sin()).abs() < 1e-9);
assert!((math_cos(1e22) - (1e22f64).cos()).abs() < 1e-9);
assert_eq!(math_hypot(&[3.0, 4.0]), 5.0);
assert!(math_pow(-8.0, 1.0 / 3.0).is_nan());
assert_eq!(math_pow(-2.0, 3.0), -8.0);
}
#[test]
fn trim_cases() {
assert_eq!(trim("\u{feff} a \u{a0}\n"), "a");
assert_eq!(trim("\u{85}a"), "\u{85}a");
}
}
/// Serde helpers that write an `f64` the way `JSON.stringify` does: integral
/// values without a fractional part (`0`, not `0.0`), non-finite as `null`.
pub mod json_number {
use serde::{Deserialize, Deserializer, Serialize, Serializer};
pub fn serialize<S: Serializer>(v: &f64, s: S) -> Result<S::Ok, S::Error> {
if v.is_finite() && v.fract() == 0.0 && v.abs() < 9.007_199_254_740_992e15 {
(*v as i64).serialize(s)
} else {
v.serialize(s)
}
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<f64, D::Error> {
f64::deserialize(d)
}
/// Same, for `Option<f64>`.
pub mod option {
use serde::{Deserialize, Deserializer, Serialize, Serializer};
pub fn serialize<S: Serializer>(v: &Option<f64>, s: S) -> Result<S::Ok, S::Error> {
match v {
Some(x) => super::serialize(x, s),
None => Option::<f64>::None.serialize(s),
}
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<f64>, D::Error> {
Option::<f64>::deserialize(d)
}
}
}
+242
View File
@@ -0,0 +1,242 @@
//! JS-semantics helpers needed by the group-A checks port (`checks::rules`,
//! `checks::css_scan`, `checks::html_patterns`) that `js.rs` does not carry.
//! Kept in its own file so parallel porting work does not collide.
use crate::js;
/// JS `String.prototype.length`: UTF-16 code units.
pub fn utf16_length(s: &str) -> usize {
s.chars().map(|c| c.len_utf16()).sum()
}
/// The UTF-16 code-unit index that JS would report for the char starting at
/// byte offset `byte_idx` of `s` (what `RegExp#exec` puts in `.index`).
pub fn utf16_index(s: &str, byte_idx: usize) -> usize {
utf16_length(&s[..byte_idx.min(s.len())])
}
/// JS `str.slice(0, end)` in UTF-16 code units. A cut through a surrogate
/// pair drops the orphan half (a lone surrogate has no Rust representation).
pub fn slice_utf16_start(s: &str, end: usize) -> String {
let mut out = String::new();
let mut units = 0usize;
for c in s.chars() {
let n = c.len_utf16();
if units + n > end {
break;
}
units += n;
out.push(c);
}
out
}
/// Byte offset of the char boundary that is `units` UTF-16 code units after
/// `byte_idx` (clamped to the string end). A boundary inside a surrogate
/// pair rounds up to the end of that char.
pub fn advance_utf16(s: &str, byte_idx: usize, units: usize) -> usize {
let mut pos = byte_idx;
let mut left = units;
for c in s[byte_idx..].chars() {
if left == 0 {
break;
}
let n = c.len_utf16();
pos += c.len_utf8();
left = left.saturating_sub(n);
}
pos
}
/// Byte offset of the char boundary that is `units` UTF-16 code units before
/// `byte_idx` (clamped to 0). A boundary inside a surrogate pair rounds down
/// to the start of that char.
pub fn retreat_utf16(s: &str, byte_idx: usize, units: usize) -> usize {
let mut pos = byte_idx;
let mut left = units;
for c in s[..byte_idx].chars().rev() {
if left == 0 {
break;
}
let n = c.len_utf16();
pos -= c.len_utf8();
left = left.saturating_sub(n);
}
pos
}
/// JS `value.split(/,(?![^(]*\))/)`: split on commas that are not followed
/// by a `)` before the next `(` (i.e. commas outside parentheses, judged by
/// the lookahead alone, exactly as the JS regex does).
pub fn split_commas_outside_parens(s: &str) -> Vec<&str> {
let bytes = s.as_bytes();
let mut parts = Vec::new();
let mut start = 0usize;
for (i, &b) in bytes.iter().enumerate() {
if b != b',' {
continue;
}
// Lookahead `[^(]*\)`: is there a `)` before any `(` after the comma?
let mut closes_first = false;
for &c in &bytes[i + 1..] {
if c == b'(' {
break;
}
if c == b')' {
closes_first = true;
break;
}
}
if closes_first {
continue;
}
parts.push(&s[start..i]);
start = i + 1;
}
parts.push(&s[start..]);
parts
}
/// JS `str.split(/\s+/)` (JS whitespace; keeps the empty leading / trailing
/// pieces JS produces).
pub fn split_ws(s: &str) -> Vec<&str> {
let mut parts = Vec::new();
let mut start = 0usize;
let mut in_ws = false;
let mut ws_start = 0usize;
for (i, c) in s.char_indices() {
if js::is_js_whitespace(c) {
if !in_ws {
in_ws = true;
ws_start = i;
}
} else if in_ws {
in_ws = false;
parts.push(&s[start..ws_start]);
start = i;
}
}
if in_ws {
parts.push(&s[start..ws_start]);
parts.push("");
} else {
parts.push(&s[start..]);
}
parts
}
/// JS truthiness of a number.
pub fn num_truthy(n: f64) -> bool {
n != 0.0 && !n.is_nan()
}
/// JS `\w` (ASCII word character).
pub fn is_word_byte(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'_'
}
/// JS `String.prototype.lastIndexOf(needle, from)` for a one-byte needle:
/// the last position `<= from` holding `needle`.
pub fn last_index_of_byte(s: &str, needle: u8, from: usize) -> Option<usize> {
let bytes = s.as_bytes();
if bytes.is_empty() {
return None;
}
let mut i = from.min(bytes.len() - 1);
loop {
if bytes[i] == needle {
return Some(i);
}
if i == 0 {
return None;
}
i -= 1;
}
}
/// An insertion-ordered string map with JS `Map` semantics: `set` on an
/// existing key updates the value in place, iteration follows first
/// insertion.
#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct JsMap<V> {
entries: Vec<(String, V)>,
}
impl<V> JsMap<V> {
pub fn new() -> Self {
JsMap {
entries: Vec::new(),
}
}
pub fn get(&self, key: &str) -> Option<&V> {
self.entries.iter().find(|(k, _)| k == key).map(|(_, v)| v)
}
pub fn get_mut(&mut self, key: &str) -> Option<&mut V> {
self.entries
.iter_mut()
.find(|(k, _)| k == key)
.map(|(_, v)| v)
}
pub fn has(&self, key: &str) -> bool {
self.entries.iter().any(|(k, _)| k == key)
}
pub fn set(&mut self, key: &str, value: V) {
if let Some(slot) = self.entries.iter_mut().find(|(k, _)| k == key) {
slot.1 = value;
} else {
self.entries.push((key.to_string(), value));
}
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn iter(&self) -> impl Iterator<Item = &(String, V)> {
self.entries.iter()
}
pub fn entries(&self) -> &[(String, V)] {
&self.entries
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn utf16_helpers() {
assert_eq!(utf16_length("a😀b"), 4);
assert_eq!(utf16_index("a😀b", 5), 3);
assert_eq!(slice_utf16_start("a😀b", 2), "a");
assert_eq!(slice_utf16_start("a😀b", 3), "a😀");
assert_eq!(slice_utf16_start("abc", 60), "abc");
assert_eq!(advance_utf16("a😀b", 0, 1), 1);
assert_eq!(advance_utf16("a😀b", 1, 1), 5);
assert_eq!(retreat_utf16("a😀b", 5, 1), 1);
assert_eq!(retreat_utf16("a😀b", 5, 3), 0);
}
#[test]
fn split_helpers() {
assert_eq!(
split_commas_outside_parens("0 0 4px rgba(1,2,3,.4), 1px 1px red"),
vec!["0 0 4px rgba(1,2,3,.4)", " 1px 1px red"]
);
assert_eq!(split_commas_outside_parens(""), vec![""]);
assert_eq!(split_commas_outside_parens("a,b,"), vec!["a", "b", ""]);
assert_eq!(split_ws("a b"), vec!["a", "b"]);
assert_eq!(split_ws(" a b "), vec!["", "a", "b", ""]);
assert_eq!(split_ws(""), vec![""]);
assert_eq!(split_ws("abc"), vec!["abc"]);
}
#[test]
fn last_index_of() {
assert_eq!(last_index_of_byte("a{b{c", b'{', 4), Some(3));
assert_eq!(last_index_of_byte("a{b{c", b'{', 2), Some(1));
assert_eq!(last_index_of_byte("a{b{c", b'{', 0), None);
assert_eq!(last_index_of_byte("", b'{', 0), None);
}
}
+49
View File
@@ -0,0 +1,49 @@
//! JS-semantics helpers needed by the group-B checks port that `js.rs` does
//! not carry (kept separate so parallel work does not collide).
/// JS `String.prototype.length`: UTF-16 code units, not chars or bytes.
pub fn utf16_len(s: &str) -> usize {
s.chars().map(|c| c.len_utf16()).sum()
}
/// JS `str.slice(0, end)` in UTF-16 code units. A cut through a surrogate
/// pair yields U+FFFD for the orphan half (what a lossy re-decode of the JS
/// string would give).
pub fn slice_utf16_prefix(s: &str, end: usize) -> String {
if utf16_len(s) <= end {
return s.to_string();
}
let units: Vec<u16> = s.encode_utf16().take(end).collect();
String::from_utf16_lossy(&units)
}
/// JS truthiness of a number: `0`, `-0`, and `NaN` are falsy.
pub fn num_truthy(n: f64) -> bool {
n != 0.0 && !n.is_nan()
}
/// JS `String.prototype.split(sep)` with a regex separator that never
/// matches empty text: the pieces between matches, keeping empty pieces at
/// the ends exactly as JS does.
pub fn split_regex<'a>(re: &regex::Regex, s: &'a str) -> Vec<&'a str> {
re.split(s).collect()
}
/// JS SameValueZero, the equality `Set` uses (NaN equals NaN, +0 equals -0).
pub fn same_value_zero(a: f64, b: f64) -> bool {
(a.is_nan() && b.is_nan()) || a == b
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn utf16_cases() {
assert_eq!(utf16_len("abc"), 3);
assert_eq!(utf16_len("a😀"), 3);
assert_eq!(slice_utf16_prefix("a😀b", 2), "a\u{FFFD}");
assert_eq!(slice_utf16_prefix("abc", 60), "abc");
assert_eq!(slice_utf16_prefix("abcd", 2), "ab");
}
}
+30
View File
@@ -0,0 +1,30 @@
//! impeccable-foundation: the helper layer of the impeccable engine. It carries
//! the JS runtime semantics the port depends on (number and string
//! formatting, trig, regex fragments), colour maths, the rule registry,
//! inline-ignore handling, page and font helpers, the DOM probe trait with
//! its snapshot implementation and selector engine, the plain-data types
//! every rule check takes in and hands back, and the rule-pack extension
//! point downstream crates add rules through.
//!
//! What is NOT here is the rule logic: the `check_*` and `scan_*` functions
//! and their heuristics live in `impeccable-core`. This crate has no
//! filesystem, process, or network access; it compiles to wasm.
pub mod browser;
pub mod color;
pub mod constants;
pub mod css;
pub mod fdlibm_trig;
pub mod findings;
pub mod fonts;
pub mod inline_ignores;
pub mod js;
pub mod js_ext_a;
pub mod js_ext_b;
pub mod page;
pub mod registry;
pub mod rule_pack;
pub mod rules;
#[cfg(any(test, feature = "vectors"))]
pub mod vectors;
+39
View File
@@ -0,0 +1,39 @@
//! Port of `cli/engine/shared/page.mjs`.
use crate::js::{ci, WS};
use once_cell::sync::Lazy;
use regex::Regex;
static COMMENT_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"<!--[\s\S]*?-->").unwrap());
static FULL_PAGE_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(&format!(
r"<!{doctype}{WS}|<{html}[{WSC}>]|<{head}[{WSC}>]",
doctype = ci("doctype"),
html = ci("html"),
head = ci("head"),
WSC = crate::js::WS_CHARS
))
.unwrap()
});
/// JS `isFullPage(content)`: content looks like a full page rather than a
/// component/partial (checked with HTML comments stripped).
pub fn is_full_page(content: &str) -> bool {
let stripped = COMMENT_RE.replace_all(content, "");
FULL_PAGE_RE.is_match(&stripped)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects_full_pages() {
assert!(is_full_page("<!DOCTYPE html><p>x</p>"));
assert!(is_full_page("<HTML>"));
assert!(is_full_page("<head>"));
assert!(!is_full_page("<!-- <html> --><div>x</div>"));
assert!(!is_full_page("<header>"));
assert!(!is_full_page("<div>partial</div>"));
}
}
+964
View File
@@ -0,0 +1,964 @@
//! Port of `cli/engine/registry/antipatterns.mjs`: the rule registry, in
//! source order, with every field the JS objects carry, plus the extension
//! point rule packs register their own rows through ([`extend`]).
//!
//! [`ANTIPATTERNS`] stays the built-in list, byte-for-byte what the JS
//! shipped. Rows a pack registers live in a separate list that every lookup
//! consults after the built-ins, so an engine with no pack installed behaves
//! exactly as before and a pack can never shadow a built-in id.
use std::sync::{OnceLock, RwLock};
/// One `ANTIPATTERNS` entry. Optional fields are `None` where the JS object
/// has no such key.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Antipattern {
pub id: &'static str,
pub category: &'static str,
/// JS `scopes` (e.g. `['type']`).
pub scopes: Option<&'static [&'static str]>,
/// JS `severity` (`'error'`, `'advisory'`); `finding()` defaults it to
/// `'warning'` when absent.
pub severity: Option<&'static str>,
pub name: &'static str,
pub description: &'static str,
/// JS `skillSection`.
pub skill_section: Option<&'static str>,
/// JS `skillGuideline`.
pub skill_guideline: Option<&'static str>,
}
/// JS `ANTIPATTERNS`, in registry order.
pub static ANTIPATTERNS: &[Antipattern] = &[
Antipattern {
id: "side-tab",
category: "slop",
scopes: None,
severity: None,
name: "Side-tab accent border",
description: "Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.",
skill_section: Some("Visual Details"),
skill_guideline: Some("colored accent stripe"),
},
Antipattern {
id: "border-accent-on-rounded",
category: "slop",
scopes: None,
severity: None,
name: "Border accent on rounded element",
description: "Thick accent border on a rounded card — the border clashes with the rounded corners. Remove the border or the border-radius.",
skill_section: Some("Visual Details"),
skill_guideline: Some("colored accent stripe"),
},
Antipattern {
id: "overused-font",
category: "slop",
scopes: Some(&["type"]),
severity: None,
name: "Overused font",
description: "Inter, Roboto, Fraunces, Geist, Plus Jakarta Sans, and Space Grotesk are used on so many sites they no longer feel distinctive. Each new wave of AI-generated UIs converges on the same handful of faces. Choose a face that gives your interface personality.",
skill_section: Some("Typography"),
skill_guideline: Some("overused fonts like Inter"),
},
Antipattern {
id: "flat-type-hierarchy",
category: "slop",
scopes: Some(&["type"]),
severity: None,
name: "Flat type hierarchy",
description: "Dominant heading and body roles are separated by less than 1.25× at every step, leaving the size hierarchy flat. Add at least one stronger size step.",
skill_section: Some("Typography"),
skill_guideline: Some("flat type hierarchy"),
},
Antipattern {
id: "gradient-text",
category: "slop",
scopes: None,
severity: None,
name: "Gradient text",
description: "Gradient text is decorative rather than meaningful — a common AI tell, especially on headings and metrics. Use solid colors for text.",
skill_section: Some("Color & Contrast"),
skill_guideline: Some("gradient text for"),
},
Antipattern {
id: "ai-color-palette",
category: "slop",
scopes: None,
severity: None,
name: "AI color palette",
description: "Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. Choose a distinctive, intentional palette.",
skill_section: Some("Color & Contrast"),
skill_guideline: Some("AI color palette"),
},
Antipattern {
id: "cream-palette",
category: "slop",
scopes: None,
severity: None,
name: "Cream / beige palette",
description: "A warm cream or beige page background has become the default \"tasteful\" AI surface, reached for by reflex. Choose a background that comes from a deliberate palette, not the safe warm off-white.",
skill_section: Some("Color & Contrast"),
skill_guideline: Some("cream and beige as the default surface"),
},
Antipattern {
id: "nested-cards",
category: "slop",
scopes: Some(&["layout"]),
severity: None,
name: "Nested cards",
description: "Cards inside cards create visual noise and excessive depth. Flatten the hierarchy — use spacing, typography, and dividers instead of nesting containers.",
skill_section: Some("Layout & Space"),
skill_guideline: Some("Nest cards inside cards"),
},
Antipattern {
id: "monotonous-spacing",
category: "slop",
scopes: Some(&["layout"]),
severity: None,
name: "Monotonous spacing",
description: "The same spacing value used everywhere — no rhythm, no variation. Use tight groupings for related items and generous separations between sections.",
skill_section: Some("Layout & Space"),
skill_guideline: Some("same spacing everywhere"),
},
Antipattern {
id: "bounce-easing",
category: "slop",
scopes: None,
severity: None,
name: "Bounce or elastic easing",
description: "Bounce and elastic easing feel dated and tacky. Real objects decelerate smoothly — use exponential easing (ease-out-quart/quint/expo) instead.",
skill_section: Some("Motion"),
skill_guideline: Some("bounce or elastic easing"),
},
Antipattern {
id: "pulsing-dot",
category: "slop",
scopes: None,
severity: None,
name: "Pulsing status dot",
description: "Small pulsing status dots simulate liveness decoratively. Reserve pulse animation for indicators tied to genuinely live, changing data; a static indicator with clear labeling is honest and calmer.",
skill_section: Some("Motion"),
skill_guideline: Some("decorative pulsing status dot"),
},
Antipattern {
id: "blinking-cursor",
category: "slop",
scopes: None,
severity: Some("advisory"),
name: "Decorative blinking cursor",
description: "A blinking text cursor animated into a hero or landing section simulates typing where no input exists. It borrows the dev-tool aesthetic as decoration. Real editable fields draw their own caret; anywhere else, let the composition hold attention without a fake prompt.",
skill_section: Some("Motion"),
skill_guideline: None,
},
Antipattern {
id: "shape-assembled-illustration",
category: "slop",
scopes: None,
severity: Some("advisory"),
name: "Shape-assembled illustration",
description: "A large inline SVG that builds a pictorial scene from a pile of primitive shapes reads as placeholder clip art, not illustration. Icons, logos, and data graphics are fine at their scale; a hero-sized visual deserves real artwork, a photograph, or a deliberately drawn graphic.",
skill_section: Some("Imagery"),
skill_guideline: None,
},
Antipattern {
id: "organic-clip-path",
category: "quality",
scopes: None,
severity: None,
name: "Organic contour drawn as clip-path",
description: "A clip-path polygon with many arbitrary vertices, or a curved clip-path path(), is CSS approximating a torn edge, blob, or silhouette. It reads as the cheap version of the effect and is usually a produced or photographic material replaced with code. Derive an alpha matte from the real image, or ship the shape as a cut-out raster; keep clip-path for geometry (cut corners, diagonals, hexagons).",
skill_section: Some("Imagery"),
skill_guideline: Some("geometric masks standing in for organic contours"),
},
Antipattern {
id: "buried-raster",
category: "quality",
scopes: None,
severity: None,
name: "Raster buried under a wash or opacity",
description: "A background image under a near-opaque gradient wash, or a raster on an element at near-zero opacity, never reaches the screen: the page shows the wash, and the produced texture or photo ships as a compliance token. Let the material show (a tint under 0.9 alpha, a blend mode, an opacity you can see) or remove the file.",
skill_section: Some("Imagery"),
skill_guideline: Some("a produced material must survive to the screen"),
},
Antipattern {
id: "dark-glow",
category: "slop",
scopes: None,
severity: None,
name: "Glowing shadow accents",
description: "Colored glow shadows — a zero-offset chromatic halo (box- or text-shadow) on any background, or any colored blurred shadow on a dark background — are the default \"cool\" look of AI-generated UIs. Use neutral elevation shadows and subtle, purposeful lighting instead.",
skill_section: Some("Color & Contrast"),
skill_guideline: Some("dark mode with glowing accents"),
},
Antipattern {
id: "radial-halo",
category: "slop",
scopes: None,
severity: None,
name: "Radial-gradient background halo",
description: "A chromatic radial-gradient wash — saturated at the center, fading to transparent — used as a decorative background glow on a dark page. Same tell as glowing shadows, drawn with a gradient instead of a shadow. Ground the surface with a solid or subtly shifted background instead.",
skill_section: Some("Color & Contrast"),
skill_guideline: Some("dark mode with glowing accents"),
},
Antipattern {
id: "radial-spotlight-glow",
category: "slop",
scopes: None,
severity: None,
name: "Decorative radial spotlight glow",
description: "A soft, low-opacity accent-colored radial gradient fading to transparent, dropped behind a hero or section as a \"spotlight.\" It is a reflex AI decoration — the translucent cousin of the saturated radial halo. Let the surface stand on its own, or light the composition with a deliberate material accent rather than a floating colored haze.",
skill_section: Some("Color & Contrast"),
skill_guideline: Some("dark mode with glowing accents"),
},
Antipattern {
id: "marquee",
category: "slop",
scopes: None,
severity: None,
name: "Auto-scrolling marquee",
description: "Continuously auto-scrolling content demands attention it has not earned and hides half its content at any moment. Reserve motion for content that changes; let readers move at their own pace.",
skill_section: Some("Motion"),
skill_guideline: Some("auto-scrolling marquee"),
},
Antipattern {
id: "icon-tile-stack",
category: "slop",
scopes: Some(&["layout"]),
severity: None,
name: "Icon tile stacked above heading",
description: "A small rounded-square icon container above a heading is the universal AI feature-card template — every generator outputs this exact shape. Try a side-by-side icon and heading, or let the icon sit in flow without its own container.",
skill_section: Some("Typography"),
skill_guideline: Some("large icons with rounded corners above every heading"),
},
Antipattern {
id: "italic-serif-display",
category: "slop",
scopes: Some(&["type"]),
severity: None,
name: "Italic serif display headline",
description: "Oversized italic serif (Fraunces, Recoleta, Playfair, Newsreader-italic) as the primary hero headline reads as taste in isolation but has become the universal AI-startup landing page hero. Set roman, or move to a non-serif display face. Editorial / magazine register may legitimately want this — judge by context.",
skill_section: Some("Typography"),
skill_guideline: Some("oversized italic serif as the hero headline"),
},
Antipattern {
id: "hero-eyebrow-chip",
category: "slop",
scopes: Some(&["type"]),
severity: None,
name: "Hero eyebrow / pill chip",
description: "A tiny uppercase letter-spaced label sitting immediately above an oversized hero headline — or the same shape rendered as a pill chip — is now the default AI SaaS hero. Drop the eyebrow, integrate the kicker into the headline, or run it as a navigation breadcrumb instead.",
skill_section: Some("Typography"),
skill_guideline: Some("tiny uppercase tracked label above the hero headline"),
},
Antipattern {
id: "kicker-above-heading",
category: "slop",
scopes: Some(&["type"]),
severity: None,
name: "Kicker / eyebrow label above heading",
description: "A tiny tracked uppercase or small-caps label sitting as its own block directly above a heading is banned outright, repeated or not. Generated kickers never earn their place: the heading carries its own weight. Delete the label and let the heading speak; if the words matter, work them into the heading or the body.",
skill_section: Some("Typography"),
skill_guideline: Some("kicker or eyebrow labels above headings"),
},
Antipattern {
id: "numbered-section-labels",
category: "slop",
scopes: Some(&["type"]),
severity: Some("advisory"),
name: "Tiny numbered section labels",
description: "Small numeric index labels riding next to section headings, repeated section after section, are AI editorial scaffolding — a page numbering its own chapters instead of earning structure. Let hierarchy, content, and rhythm carry the sequence.",
skill_section: Some("Layout & Space"),
skill_guideline: Some("numbered section markers"),
},
Antipattern {
id: "em-dash-overuse",
category: "slop",
scopes: None,
severity: Some("advisory"),
name: "Em-dash overuse",
description: "Em-dash saturation in body copy is an AI cadence tell. Advisory only: humans use em-dashes legitimately, so this fires only on saturation — at least 8 em-dashes (— or --) at a density near one per 500 characters of body text — never on a long article that uses a few. Prefer commas, colons, periods, or parentheses.",
skill_section: Some("Copy"),
skill_guideline: Some("no em dashes"),
},
Antipattern {
id: "marketing-buzzword",
category: "slop",
scopes: None,
severity: None,
name: "Marketing buzzword",
description: "Generic SaaS phrases (streamline / empower / supercharge / world-class / enterprise-grade / next-generation / cutting-edge / etc) are instant AI tells. Pick a specific verb and noun that says what the product literally does.",
skill_section: Some("Copy"),
skill_guideline: Some("marketing buzzwords"),
},
Antipattern {
id: "aphoristic-cadence",
category: "slop",
scopes: None,
severity: None,
name: "Aphoristic-cadence copy",
description: "Three or more sections landing on a short rebuttal sentence (\"X. No Y.\" / \"X. Just Y.\") or a manufactured-contrast aphorism (\"Not a feature. A platform.\") reads as AI cadence, not voice. Once is fine; the pattern is the tell.",
skill_section: Some("Copy"),
skill_guideline: Some("aphoristic cadence"),
},
Antipattern {
id: "oversized-h1",
category: "slop",
scopes: Some(&["type"]),
severity: None,
name: "Oversized hero headline",
description: "A full-sentence headline set at display size ends up dominating the viewport, leaving no room for anything else above the fold. A punchy one- or two-word headline at that size is fine — the problem is a long headline blown up too large. Set long headlines smaller, or tighten the copy.",
skill_section: Some("Typography"),
skill_guideline: Some("long headline set at display size"),
},
Antipattern {
id: "extreme-negative-tracking",
category: "slop",
scopes: Some(&["type"]),
severity: None,
name: "Crushed letter spacing",
description: "Letter-spacing pulled tighter than the point where characters keep their own shapes costs legibility. Tighten display type optically, not destructively.",
skill_section: Some("Typography"),
skill_guideline: Some("letter spacing crushed past legibility"),
},
Antipattern {
id: "broken-image",
category: "quality",
scopes: None,
severity: None,
name: "Broken or placeholder image",
description: "<img> tags with empty src, missing src, or placeholder values ship as broken-image boxes. Use real images, generated assets, or remove the tag.",
skill_section: Some("Imagery"),
skill_guideline: Some("broken image references"),
},
Antipattern {
id: "script-error",
category: "quality",
scopes: None,
severity: Some("error"),
name: "Uncaught script error on load",
description: "A script threw an uncaught exception or failed to parse while the page loaded. Broken JavaScript silently kills reveals, interactions, and dynamic content, and can leave most of a page invisible. Fix the error before judging anything else.",
skill_section: None,
skill_guideline: None,
},
Antipattern {
id: "content-hidden-at-rest",
category: "quality",
scopes: Some(&["layout"]),
severity: Some("error"),
name: "Content invisible at rest",
description: "A large share of the page text sits at opacity 0 or visibility hidden even after every reveal handler had a chance to run. This is the failed-reveal signature: the content shipped but never becomes visible. Make content visible by default and let JavaScript enhance its entrance instead of gating its existence.",
skill_section: None,
skill_guideline: None,
},
Antipattern {
id: "edge-flush-cards",
category: "quality",
scopes: Some(&["layout"]),
severity: None,
name: "Cards flush against the scroller edge",
description: "Cards inside a horizontal scroller or tab panel sit flush against the container edge at rest while keeping a gutter on the other side, so their edges and rounded corners get cut off. Usually the panel is sized wider than its clip box. Keep a consistent inset on both sides.",
skill_section: None,
skill_guideline: None,
},
Antipattern {
id: "text-occlusion",
category: "quality",
scopes: Some(&["layout"]),
severity: None,
name: "Text occluded by an overlapping element",
description: "Text is painted under an opaque element or a second text run, so part of it cannot be read. A decorative box, a stacked layer, or an inline element with leaked padding lands on the words instead of beside them. Give overlapping layers room, or move the text out from under the layer above it.",
skill_section: Some("Layout & Space"),
skill_guideline: None,
},
Antipattern {
id: "first-viewport-column-overflow",
category: "quality",
scopes: Some(&["layout"]),
severity: None,
name: "One column stretches the first viewport",
description: "A multi-column opening section lets one column run far past the fold while its sibling fits in a single viewport, so the short column floats in dead space and the fold falls deep inside one section. Balance the columns, cap the tall one, or let the long content flow below the opening row.",
skill_section: Some("Layout & Space"),
skill_guideline: None,
},
Antipattern {
id: "gray-on-color",
category: "quality",
scopes: None,
severity: None,
name: "Gray text on colored background",
description: "Gray text looks washed out on colored backgrounds. Use a darker shade of the background color instead, or white/near-white for contrast.",
skill_section: Some("Color & Contrast"),
skill_guideline: Some("gray text on colored backgrounds"),
},
Antipattern {
id: "low-contrast",
category: "quality",
scopes: None,
severity: None,
name: "Low contrast text",
description: "Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.",
skill_section: None,
skill_guideline: None,
},
Antipattern {
id: "layout-transition",
category: "quality",
scopes: None,
severity: None,
name: "Layout property animation",
description: "Animating width, height, padding, or margin causes layout thrash and janky performance. Use transform and opacity instead, or grid-template-rows for height animations.",
skill_section: Some("Motion"),
skill_guideline: Some("Animate layout properties"),
},
Antipattern {
id: "line-length",
category: "quality",
scopes: Some(&["type", "layout"]),
severity: None,
name: "Line length too long",
description: "Text lines wider than ~80 characters are hard to read. The eye loses its place tracking back to the start of the next line. Add a max-width (65ch to 75ch) to text containers.",
skill_section: Some("Layout & Space"),
skill_guideline: Some("wrap beyond ~80 characters"),
},
Antipattern {
id: "cramped-padding",
category: "quality",
scopes: Some(&["layout"]),
severity: None,
name: "Cramped padding",
description: "Text is too close to the edge of its container. Two shapes: (1) an element with its own text where the padding is too low for the font size, and (2) a wrapper with text-bearing children and near-zero padding against a visible boundary (border, outline, or non-transparent background) — children land flush against the boundary line. Add at least 8px (ideally 1216px) of padding inside bordered, outlined, or colored containers.",
skill_section: Some("Layout & Space"),
skill_guideline: Some("inside bordered or colored containers"),
},
Antipattern {
id: "body-text-viewport-edge",
category: "quality",
scopes: Some(&["layout"]),
severity: None,
name: "Body text touching viewport edge",
description: "Body paragraphs render flush against the left or right viewport edge with no container providing horizontal padding. Wrap content in a container with at least 16px (ideally 24-32px) of horizontal padding, or apply max-width with mx-auto.",
skill_section: None,
skill_guideline: None,
},
Antipattern {
id: "tight-leading",
category: "quality",
scopes: Some(&["type"]),
severity: None,
name: "Tight line height",
description: "Line height below 1.3x the font size makes multi-line text hard to read. Use 1.5 to 1.7 for body text so lines have room to breathe.",
skill_section: None,
skill_guideline: None,
},
Antipattern {
id: "skipped-heading",
category: "quality",
scopes: Some(&["type"]),
severity: None,
name: "Skipped heading level",
description: "Heading levels should not skip (e.g. h1 then h3 with no h2). Screen readers use heading hierarchy for navigation. Skipping levels breaks the document outline.",
skill_section: None,
skill_guideline: None,
},
Antipattern {
id: "heading-rhythm",
category: "quality",
scopes: Some(&["layout", "type"]),
severity: None,
name: "Heading crowded against the previous block",
description: "A heading binds to the content it introduces, so the rendered space above it should exceed the space below it. When headings across a page sit as close or closer to the block above than to their own content, every section reads as if it captions the previous one. Open up the space above each heading.",
skill_section: Some("Layout & Space"),
skill_guideline: None,
},
Antipattern {
id: "justified-text",
category: "quality",
scopes: Some(&["type"]),
severity: None,
name: "Justified text",
description: "Justified text without hyphenation creates uneven word spacing (\"rivers of white\"). Use text-align: left for body text, or enable hyphens: auto if you must justify.",
skill_section: None,
skill_guideline: None,
},
Antipattern {
id: "tiny-text",
category: "quality",
scopes: Some(&["type"]),
severity: None,
name: "Tiny body text",
description: "Body text below 12px is hard to read, especially on high-DPI screens. Use at least 14px for body content, 16px is ideal.",
skill_section: None,
skill_guideline: None,
},
Antipattern {
id: "undersized-ui-text",
category: "quality",
scopes: Some(&["type"]),
severity: None,
name: "Undersized functional text",
description: "Interactive and content-bearing UI text (links, buttons, nav items, labels, table cells, meta rows, timecodes) below 11px is a legibility failure, not a style choice. WCAG sets no absolute pixel floor, but functional text under 11px is a defensible quality bar: it fails on high-DPI and small viewports and it degrades tap and read targets. The 11px floor holds even inside a footer; only non-interactive legal smallprint gets the softer 10px floor. Being ON the DESIGN.md size ramp does not exempt a value here: adding 8px to the ramp launders the token but not the legibility problem, and that is exactly the escape hatch this rule closes. Exempts sup/sub, visually-hidden (sr-only) text, and code/terminal contexts. Decorative letterspaced micro-labels are still functional and stay in scope.",
skill_section: None,
skill_guideline: None,
},
Antipattern {
id: "all-caps-body",
category: "quality",
scopes: Some(&["type"]),
severity: None,
name: "All-caps body text",
description: "Long passages in uppercase are hard to read. We recognize words by shape (ascenders and descenders), which all-caps removes. Reserve uppercase for short labels and headings.",
skill_section: Some("Typography"),
skill_guideline: Some("long body passages in uppercase"),
},
Antipattern {
id: "wide-tracking",
category: "quality",
scopes: Some(&["type"]),
severity: None,
name: "Wide letter spacing on body text",
description: "Letter spacing above 0.05em on body text disrupts natural character groupings and slows reading. Reserve wide tracking for short uppercase labels only.",
skill_section: None,
skill_guideline: None,
},
Antipattern {
id: "text-overflow",
category: "quality",
scopes: Some(&["layout"]),
severity: None,
name: "Content overflowing its container",
description: "Content renders wider than its container, spilling out or forcing a horizontal scrollbar. Let text wrap, constrain widths, or give the region a deliberate scroll affordance.",
skill_section: Some("Layout & Space"),
skill_guideline: Some("content wider than its container"),
},
Antipattern {
id: "repeated-container-text",
category: "quality",
scopes: None,
severity: None,
name: "Same text repeated inside one container",
description: "The same literal text rendered three or more times in structurally different spots inside a single card or panel is redundant messaging — usually a status or label wired into every slot of a template. Say it once, in the slot where it matters most.",
skill_section: None,
skill_guideline: None,
},
Antipattern {
id: "clipped-overflow-container",
category: "quality",
scopes: Some(&["layout"]),
severity: None,
name: "Positioned child clipped by overflow container",
description: "A clipping container (overflow hidden or clip) wrapping an absolutely-positioned child cuts off tooltips, menus, and popovers that need to escape. Let the overflow be visible, or move the positioned layer out of the clip.",
skill_section: Some("Layout & Space"),
skill_guideline: Some("overflow container clipping positioned children"),
},
Antipattern {
id: "design-system-font",
category: "quality",
scopes: Some(&["type"]),
severity: None,
name: "Font outside DESIGN.md",
description: "A font is used that is not declared in DESIGN.md typography. Use the documented type system or update DESIGN.md if this is an intentional brand addition.",
skill_section: Some("Typography"),
skill_guideline: Some("font family outside the project design system"),
},
Antipattern {
id: "design-system-color",
category: "quality",
scopes: None,
severity: Some("advisory"),
name: "Color outside DESIGN.md",
description: "A literal color is outside the DESIGN.md palette and sidecar tonal ramps. This may be legitimate, but it should be an intentional design-system addition rather than drift.",
skill_section: Some("Color & Contrast"),
skill_guideline: Some("literal color outside the project design system"),
},
Antipattern {
id: "design-system-radius",
category: "quality",
scopes: None,
severity: Some("advisory"),
name: "Radius outside DESIGN.md",
description: "A border-radius value is outside the DESIGN.md rounded scale. Use a documented radius token or update the design system if the new shape is intentional.",
skill_section: Some("Visual Details"),
skill_guideline: Some("border radius outside the project design system"),
},
Antipattern {
id: "design-system-font-size",
category: "quality",
scopes: Some(&["type"]),
severity: Some("advisory"),
name: "Font size outside DESIGN.md",
description: "A literal font-size is off the type ramp documented in DESIGN.md typography. Use a documented size step or update the design system if the new step is intentional.",
skill_section: Some("Typography"),
skill_guideline: Some("font size outside the project design system"),
},
Antipattern {
id: "gpt-thin-border-wide-shadow",
category: "slop",
scopes: None,
severity: Some("advisory"),
name: "Hairline border with wide shadow",
description: "A hairline border paired with a wide, diffuse shadow is a recurring generated-UI signature. Commit to one — a defined edge or a soft elevation — rather than both at once.",
skill_section: Some("Visual Details"),
skill_guideline: Some("hairline border plus wide diffuse shadow"),
},
Antipattern {
id: "repeating-stripes-gradient",
category: "slop",
scopes: None,
severity: Some("advisory"),
name: "Repeating-gradient stripes",
description: "Repeating-gradient stripes used as surface decoration are a recurring generated-UI signature. Reach for a deliberate texture or leave the surface plain.",
skill_section: Some("Visual Details"),
skill_guideline: Some("repeating-gradient decorative stripes"),
},
Antipattern {
id: "codex-grid-background",
category: "slop",
scopes: None,
severity: Some("advisory"),
name: "Decorative grid-line background",
description: "A decorative grid or line-field background drawn with hairline linear-gradient layers tiled by a fixed pixel cell is a recurring generated-UI signature. Reserve grid overlays for actual canvas, map, blueprint, or measurement surfaces; elsewhere use product structure or a plain surface.",
skill_section: Some("Visual Details"),
skill_guideline: Some("two-axis grid-line gradient background"),
},
Antipattern {
id: "theater-slop-phrase",
category: "slop",
scopes: None,
severity: Some("advisory"),
name: "Theater framing copy",
description: "Dismissing something as \"theater\" is a recurring generated-copy tic. Say plainly what the thing does or does not do.",
skill_section: Some("Copy"),
skill_guideline: Some("theater framing copy"),
},
Antipattern {
id: "image-hover-transform",
category: "slop",
scopes: None,
severity: Some("advisory"),
name: "Image hover transform",
description: "Scaling or rotating an image on hover is a recurring generated-UI signature. Let imagery sit still, or use a subtler, purposeful interaction.",
skill_section: Some("Motion"),
skill_guideline: Some("image scale or rotate on hover"),
},
];
/// The rules the design hook fixes at edit time rather than deferring to a
/// review pass: broken output, objective legibility failures, single-property
/// mechanical slop, and design-system drift. Every one of them is mechanical,
/// unambiguous, and cheap to correct at the edit site.
///
/// It lives here rather than in `impeccable-hook` because the hook crate is
/// native-only (it reaches for the filesystem and the process environment)
/// while downstream consumers want the same list from wasm. `hook_lib`
/// re-exports it; the `detect` feature of `impeccable-wasm` exports it as
/// JSON.
pub const IMMEDIATE_TIER_RULES: &[&str] = &[
// Broken output.
"broken-image",
"text-overflow",
"clipped-overflow-container",
"body-text-viewport-edge",
// Objective contrast / legibility failures.
"low-contrast",
"gray-on-color",
"tiny-text",
// Single-property mechanical slop, trivial to fix at the edit site.
"gradient-text",
"dark-glow",
// Design-system drift compounds if not corrected at edit time.
"design-system-font",
"design-system-color",
"design-system-radius",
"design-system-font-size",
];
/// JS `RULE_ENGINE_SUPPORT`.
pub const RULE_ENGINE_SUPPORT: &[(&str, &[&str])] = &[
("regex", &["source", "page-analyzer"]),
("static-html", &["element", "page"]),
("browser", &["element", "page", "layout"]),
("visual", &["visual-contrast"]),
];
/// Rows registered by rule packs, in registration order. One entry per
/// `extend` call; `&'static` all the way down, so a lookup can hand out
/// `&'static Antipattern` without holding the lock.
static EXTRA_ROWS: OnceLock<RwLock<Vec<&'static [Antipattern]>>> = OnceLock::new();
fn extra_rows() -> &'static RwLock<Vec<&'static [Antipattern]>> {
EXTRA_ROWS.get_or_init(|| RwLock::new(Vec::new()))
}
/// A snapshot of the registered slices. Cheap when nothing is registered (an
/// empty `Vec` does not allocate), which is every built-in build.
fn extra_slices() -> Vec<&'static [Antipattern]> {
match extra_rows().read() {
Ok(rows) => rows.clone(),
// The list is append-only `&'static` rows, so a lock poisoned by a
// panic elsewhere is still sound to read; ignoring the poison keeps a
// rejected `extend` from making every later lookup miss the rows that
// did register.
Err(poisoned) => poisoned.into_inner().clone(),
}
}
/// Register a rule pack's rows. Every registry lookup then resolves them
/// after the built-ins. Calling it again with the same slice is a no-op, so a
/// pack that installs itself from more than one entry point is safe.
///
/// Callers register at startup, before any scan. There is no way to
/// unregister: a rule pack is a property of the process, not of a run.
///
/// # Panics
/// When a row's id collides with a built-in id or with a row another pack
/// already registered. A duplicate id would make `get_antipattern` answer
/// with whichever row came first, which is not a behavior worth guessing at.
pub fn extend(rows: &'static [Antipattern]) {
let mut registered = match extra_rows().write() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
if registered.iter().any(|slice| std::ptr::eq(*slice, rows)) {
return;
}
// Collect the complaint first and panic after the guard is dropped: a
// panic while holding the write guard would poison the lock, and a
// rejected registration should leave the registry exactly as it was.
let mut rejected: Option<String> = None;
for row in rows {
if let Some(existing) = ANTIPATTERNS.iter().find(|built_in| built_in.id == row.id) {
rejected = Some(format!(
"registry::extend: rule id {:?} collides with the built-in rule {:?}; \
namespace pack ids (e.g. \"mypack/{}\")",
row.id, existing.name, row.id
));
break;
}
if registered
.iter()
.any(|slice| slice.iter().any(|other| other.id == row.id))
{
rejected = Some(format!(
"registry::extend: rule id {:?} is already registered by another rule pack",
row.id
));
break;
}
}
if let Some(message) = rejected {
drop(registered);
panic!("{message}");
}
registered.push(rows);
}
/// Every rule the process knows: the built-ins in registry order, then each
/// pack's rows in registration order.
pub fn all_antipatterns() -> impl Iterator<Item = &'static Antipattern> {
ANTIPATTERNS
.iter()
.chain(extra_slices().into_iter().flat_map(|slice| slice.iter()))
}
/// JS `getAntipattern(id)`, extended with the rule packs' rows.
pub fn get_antipattern(id: &str) -> Option<&'static Antipattern> {
all_antipatterns().find(|rule| rule.id == id)
}
/// JS `getAP(id)` from `findings.mjs` (an alias of `getAntipattern`).
pub fn get_ap(id: &str) -> Option<&'static Antipattern> {
get_antipattern(id)
}
/// JS `ADVISORY_RULE_IDS`: ids of rules whose registry severity is
/// `'advisory'`, in registry order. `severity` is the canonical field; the
/// finding serializer derives its `advisory: true` output flag from it (#709).
pub fn advisory_rule_ids() -> impl Iterator<Item = &'static str> {
all_antipatterns()
.filter(|rule| rule.severity == Some("advisory"))
.map(|rule| rule.id)
}
/// JS `isAdvisoryRule(id)`.
pub fn is_advisory_rule(id: &str) -> bool {
advisory_rule_ids().any(|r| r == id)
}
/// JS `getRulesForCategory(category)`.
pub fn get_rules_for_category(category: &str) -> Vec<&'static Antipattern> {
all_antipatterns()
.filter(|rule| rule.category == category)
.collect()
}
/// JS `getRuleEngineSupport(engine)` (empty for an unknown engine).
pub fn get_rule_engine_support(engine: &str) -> &'static [&'static str] {
RULE_ENGINE_SUPPORT
.iter()
.find(|(e, _)| *e == engine)
.map(|(_, s)| *s)
.unwrap_or(&[])
}
/// JS `RULE_SCOPES`: every scope tag declared by any rule, first-seen order.
pub fn rule_scopes() -> Vec<&'static str> {
let mut out: Vec<&'static str> = Vec::new();
for rule in all_antipatterns() {
for scope in rule.scopes.unwrap_or(&[]) {
if !out.contains(scope) {
out.push(scope);
}
}
}
out
}
/// JS `filterByScopes(findings, scopes)`: keep findings whose rule declares
/// at least one requested scope; an empty scope list keeps everything.
pub fn filter_by_scopes<F, G>(findings: Vec<F>, scopes: &[&str], antipattern_of: G) -> Vec<F>
where
G: Fn(&F) -> &str,
{
if scopes.is_empty() {
return findings;
}
findings
.into_iter()
.filter(|f| {
get_antipattern(antipattern_of(f))
.and_then(|rule| rule.scopes)
.unwrap_or(&[])
.iter()
.any(|scope| scopes.contains(scope))
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn registry_shape() {
assert_eq!(ANTIPATTERNS.len(), 61);
assert_eq!(ANTIPATTERNS[0].id, "side-tab");
assert_eq!(rule_scopes(), vec!["type", "layout"]);
assert!(is_advisory_rule("em-dash-overuse"));
// #709: severity is the canonical advisory field, so every
// `severity: "advisory"` rule is an advisory rule.
assert!(is_advisory_rule("blinking-cursor"));
assert_eq!(
get_antipattern("blinking-cursor").unwrap().severity,
Some("advisory")
);
assert_eq!(
get_rule_engine_support("browser"),
&["element", "page", "layout"]
);
assert!(get_rule_engine_support("nope").is_empty());
let ids: std::collections::HashSet<&str> = ANTIPATTERNS.iter().map(|r| r.id).collect();
assert_eq!(ids.len(), ANTIPATTERNS.len());
}
/// Rows a test pack registers: no scopes and not advisory, so the
/// built-in assertions in `registry_shape` hold whatever order the test
/// threads run in.
static PACK_ROWS: &[Antipattern] = &[
Antipattern {
id: "testpack/one",
category: "quality",
scopes: None,
severity: Some("warning"),
name: "Test pack rule one",
description: "First row of the registry-extension test pack.",
skill_section: None,
skill_guideline: None,
},
Antipattern {
id: "testpack/two",
category: "testpack-only",
scopes: None,
severity: Some("error"),
name: "Test pack rule two",
description: "Second row of the registry-extension test pack.",
skill_section: None,
skill_guideline: None,
},
];
static COLLIDING_ROWS: &[Antipattern] = &[Antipattern {
id: "side-tab",
category: "quality",
scopes: None,
severity: None,
name: "Collides with a built-in",
description: "Registering this must panic.",
skill_section: None,
skill_guideline: None,
}];
#[test]
fn extension_is_visible_to_every_lookup() {
assert!(get_antipattern("testpack/one").is_none());
extend(PACK_ROWS);
// Idempotent per slice.
extend(PACK_ROWS);
extend(PACK_ROWS);
let one = get_antipattern("testpack/one").expect("pack row resolves");
assert_eq!(one.name, "Test pack rule one");
assert_eq!(
get_ap("testpack/two").map(|r| r.severity),
Some(Some("error"))
);
assert!(get_antipattern("testpack/nope").is_none());
// Built-ins still come first and are untouched.
let all: Vec<&str> = all_antipatterns().map(|r| r.id).collect();
assert_eq!(all.len(), ANTIPATTERNS.len() + PACK_ROWS.len());
assert_eq!(all[0], "side-tab");
assert_eq!(
&all[ANTIPATTERNS.len()..],
&["testpack/one", "testpack/two"]
);
// Category and advisory views see the rows.
let quality: Vec<&str> = get_rules_for_category("quality")
.iter()
.map(|r| r.id)
.collect();
assert!(quality.contains(&"testpack/one"));
assert_eq!(
get_rules_for_category("testpack-only")
.iter()
.map(|r| r.id)
.collect::<Vec<_>>(),
vec!["testpack/two"]
);
assert!(!is_advisory_rule("testpack/one"));
// A finding built from a pack row carries the pack's metadata.
let f = crate::findings::finding("testpack/two", "a.tsx", "snip", 3.0);
assert_eq!(f.name, "Test pack rule two");
assert_eq!(f.severity, "error");
assert_eq!(f.category.as_deref(), Some("testpack-only"));
}
#[test]
fn built_in_id_collision_panics() {
let err = std::panic::catch_unwind(|| extend(COLLIDING_ROWS)).unwrap_err();
let message = err
.downcast_ref::<String>()
.map(String::as_str)
.unwrap_or("");
assert!(
message.contains("collides with the built-in rule"),
"{message}"
);
assert!(message.contains("side-tab"), "{message}");
assert!(get_antipattern("side-tab")
.unwrap()
.name
.starts_with("Side-tab"));
}
}
+75
View File
@@ -0,0 +1,75 @@
//! Rule packs: how a crate that depends on the engine adds rules of its own
//! without forking it.
//!
//! A pack is one process-lifetime value (`&'static dyn RulePack`) that carries
//! its own registry rows and implements the hooks it has rules for. The
//! built-in rules are never a pack: they are compiled in and always run. A
//! pack runs after them, on every engine that got a reference to it, and its
//! findings pass through the same waivers and filters as built-in findings.
//!
//! Three steps for a downstream crate:
//!
//! 1. Declare the registry rows as a `static [Antipattern]` and hand them
//! back from [`RulePack::registry`]. Ids should be namespaced
//! (`myproject/my-rule`) so they cannot collide with built-in ids.
//! 2. Call [`install`] once at startup, before any scan. That is what makes
//! `get_antipattern` (and therefore every finding's name, description,
//! category, and severity) resolve the pack's ids.
//! 3. Pass the pack into the engine being run: `TextOptions.rule_pack` /
//! `ScanOptions.rule_pack` (text engine), `DetectHtmlOptions`
//! (`rule_pack` plus `impeccable_html::StaticRulePack`), or
//! `BrowserConfig.rule_pack` (the in-page / snapshot driver).
//!
//! The trait is object-safe and every hook has a default that answers empty,
//! so a pack implements only the engines it has rules for.
use crate::browser::{BrowserFinding, Dom, ElFinding, ElId};
use crate::findings::Finding;
use crate::registry::Antipattern;
/// A set of extra rules, plus the hooks they run on.
///
/// `Send + Sync` because a pack is shared across whatever threads the host
/// runs scans on; `Debug` because the option types that carry a pack
/// reference (notably `BrowserConfig`) derive `Debug`. `#[derive(Debug)]` on a
/// unit struct is enough.
pub trait RulePack: Send + Sync + std::fmt::Debug {
/// The pack's registry rows. [`install`] hands these to
/// [`crate::registry::extend`].
fn registry(&self) -> &'static [Antipattern];
/// Text/source engine, once per file, after the built-in matchers and
/// page analyzers and before inline ignores. `ext` is the lowercased
/// extension with its dot (`".tsx"`), empty for a file without one.
fn check_text(&self, content: &str, file_path: &str, ext: &str) -> Vec<Finding> {
let _ = (content, file_path, ext);
Vec::new()
}
/// Browser rules over the DOM probe, once per element in the driver's
/// element loop (same skipped elements as the built-ins), after the
/// built-in element rules. Findings run through the same disabled-rule
/// filter and group under the same element.
fn check_element_dom(&self, dom: &dyn Dom, el: ElId) -> Vec<BrowserFinding> {
let _ = (dom, el);
Vec::new()
}
/// Browser page-level rules, after the built-in page passes. A finding
/// with `el: None` is attributed to `document.body`, like the built-in
/// page checks that name their own target.
fn check_page_dom(&self, dom: &dyn Dom) -> Vec<ElFinding> {
let _ = dom;
Vec::new()
}
}
/// Register a pack's rows in the registry. Idempotent for the same pack, and
/// safe to call before or after the engines are wired.
///
/// # Panics
/// When a row's id collides with a built-in id or with an already registered
/// pack's id (see [`crate::registry::extend`]).
pub fn install(pack: &'static dyn RulePack) {
crate::registry::extend(pack.registry());
}
@@ -0,0 +1,11 @@
//! The corpora the HTML-pattern scan is run against. Building them and
//! scanning them lives in the detector.
use serde::{Deserialize, Serialize};
/// JS `{ styleText, classText }`.
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct HtmlPatternCorpora {
pub style_text: String,
pub class_text: String,
}
+8
View File
@@ -0,0 +1,8 @@
//! The plain-data vocabulary of the rule set: the hit and option structs the
//! element checks are written against (`types`), the selector and tag lists
//! plus text parsers the text rules share (`text`), and the HTML-pattern
//! corpora (`html_patterns`).
pub mod html_patterns;
pub mod text;
pub mod types;
+213
View File
@@ -0,0 +1,213 @@
//! The selector lists, tag lists and thresholds the text rules are written
//! against, the plain-data inputs of the kicker and numbered-label checks,
//! and the two text parsers they share. The candidate gates and the checks
//! themselves live in the detector.
use crate::js::{self, parse_int, WS, WS_CHARS};
use crate::js_ext_b::utf16_len;
use crate::rules::types::D;
use once_cell::sync::Lazy;
use regex::Regex;
use serde::{Deserialize, Serialize};
macro_rules! re {
($name:ident, $pat:expr) => {
static $name: Lazy<Regex> = Lazy::new(|| Regex::new(&$pat).expect(stringify!($name)));
};
}
// ─── Shared constants (selectors, tag sets, regexes) ────────────────────────
/// JS: checks.mjs#HEADING_TAGS.
pub const HEADING_TAGS: &[&str] = &["h1", "h2", "h3", "h4", "h5", "h6"];
/// JS: checks.mjs#KICKER_SKIP_SELECTOR.
pub const KICKER_SKIP_SELECTOR: &str = "nav,form,table,thead,tbody,tfoot,figure,figcaption,ol,ul,li,[role=\"navigation\"],[aria-label*=\"breadcrumb\" i],[class*=\"breadcrumb\" i],[aria-hidden=\"true\"],[data-impeccable-allow-kickers]";
/// JS: checks.mjs#KICKER_CARD_CONTEXT_SELECTOR.
pub const KICKER_CARD_CONTEXT_SELECTOR: &str =
"article,button,a,li,[role=\"listitem\"],[role=\"option\"]";
/// JS: checks.mjs#NUMBERED_LABEL_TAGS.
pub const NUMBERED_LABEL_TAGS: &[&str] = &["span", "p", "div", "small", "em", "strong", "b"];
/// JS: checks.mjs#REPEATED_TEXT_SKIP_SELECTOR.
pub const REPEATED_TEXT_SKIP_SELECTOR: &str = "table,select,datalist,nav,menu,[role=\"navigation\"],[role=\"menu\"],[role=\"menubar\"],[role=\"listbox\"],[role=\"grid\"],[role=\"tablist\"],[role=\"radiogroup\"],[aria-hidden=\"true\"]";
/// JS: checks.mjs#REPEATED_TEXT_CONTAINER_TAGS.
pub const REPEATED_TEXT_CONTAINER_TAGS: &[&str] = &[
"div", "section", "article", "aside", "main", "figure", "form", "fieldset", "details", "li",
];
/// JS: checks.mjs#QUALITY_TEXT_TAGS.
pub const QUALITY_TEXT_TAGS: &[&str] = &["p", "li", "td", "th", "dd", "blockquote", "figcaption"];
/// JS: checks.mjs#TEXT_EDGE_TAGS (upper-case tag names, as the JS set).
pub const TEXT_EDGE_TAGS: &[&str] = &[
"A",
"BUTTON",
"CODE",
"DD",
"DT",
"FIGCAPTION",
"H1",
"H2",
"H3",
"H4",
"H5",
"H6",
"LI",
"P",
"PRE",
"SPAN",
"TD",
"TH",
];
/// JS: checks.mjs#SR_ONLY_SELECTOR.
pub const SR_ONLY_SELECTOR: &str = ".sr-only, .visually-hidden, .visuallyhidden, .screen-reader, .screen-reader-only, .screenreader, .a11y-hidden, .hidden-visually, [class*=\"sr-only\" i], [class*=\"visually-hidden\" i], [class*=\"visuallyhidden\" i], [class*=\"screen-reader\" i], [class*=\"screenreader\" i]";
/// JS: checks.mjs#NON_RENDERED_TAGS.
pub const NON_RENDERED_TAGS: &[&str] = &[
"script", "style", "title", "noscript", "template", "head", "meta", "link", "base", "param",
"source", "track", "datalist", "col", "colgroup", "map", "area",
];
/// JS: checks.mjs#TEXT_OVERFLOW_SKIP_TAGS.
pub const TEXT_OVERFLOW_SKIP_TAGS: &[&str] = &[
"pre", "code", "textarea", "svg", "canvas", "select", "option", "marquee",
];
/// JS: checks.mjs#CURSOR_GLYPH_RE (`/^[_|▀-▟■▮❙❚|]$/`).
pub static CURSOR_GLYPH_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"^[_|▀-▟■▮❙❚|]$").expect("CURSOR_GLYPH_RE"));
/// JS: checks.mjs#CURSOR_FIRST_VIEWPORT_PX.
pub const CURSOR_FIRST_VIEWPORT_PX: f64 = 1200.0;
/// JS: checks.mjs#HIDDEN_TEXT_EXCLUDE_TAGS.
pub const HIDDEN_TEXT_EXCLUDE_TAGS: &[&str] = &[
"script", "style", "noscript", "template", "title", "head", "meta", "link", "option",
"optgroup", "select", "datalist", "dialog",
];
/// JS: checks.mjs#OCCLUSION_TEXT_SKIP_TAGS.
pub const OCCLUSION_TEXT_SKIP_TAGS: &[&str] = &["script", "style", "noscript", "template", "title"];
/// JS: checks.mjs#POSITIONED_CHILD_INTERACTIVE_SELECTOR.
pub const POSITIONED_CHILD_INTERACTIVE_SELECTOR: &str = "a[href],button,input,select,summary,textarea,[tabindex]:not([tabindex=\"-1\"]),[role=\"button\"],[role=\"dialog\"],[role=\"link\"],[role=\"listbox\"],[role=\"menu\"],[role=\"menuitem\"],[role=\"option\"],[role=\"tooltip\"]";
// ─── Kicker above heading ───────────────────────────────────────────────────
/// Input of `isKickerCandidate`. Numbers are JS numbers: pass NaN where the
/// JS caller would pass `undefined`; strings are `''` where JS would pass
/// `undefined` / `''` (both are falsy there).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct KickerCandidateInput<'a> {
pub heading_level: f64,
#[serde(borrow)]
pub heading_text: &'a str,
pub heading_font_size: f64,
#[serde(borrow)]
pub kicker_tag: &'a str,
#[serde(borrow)]
pub kicker_text: &'a str,
#[serde(borrow)]
pub kicker_text_transform: &'a str,
#[serde(borrow)]
pub kicker_font_variant: &'a str,
pub kicker_font_size: f64,
pub kicker_letter_spacing: f64,
}
/// JS `text.replace(/^"|"$/g, '')`: one leading and one trailing quote.
pub fn strip_edge_quotes(text: &str) -> String {
let s = text.strip_prefix('"').unwrap_or(text);
let s = s.strip_suffix('"').unwrap_or(s);
s.to_string()
}
// ─── Numbered section labels ────────────────────────────────────────────────
/// `{ index, text }` from `parseNumberedLabelText`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct NumberedLabel {
pub index: f64,
pub text: String,
}
/// JS: checks.mjs#parseNumberedLabelText. A zero-padded / two-digit bare
/// index, or a 1-2 digit index, a non-word separator, and a short label.
pub fn parse_numbered_label_text(raw_text: Option<&str>) -> Option<NumberedLabel> {
re!(WS_RE, format!("{}+", WS));
re!(TWO_DIGIT_RE, format!(r"^({d}{{2}})$", d = D));
re!(
SEP_RE,
format!(
r"^({d}{{1,2}}){ws}*[^0-9A-Za-z_{wsc}]{ws}*[^{wsc}]",
d = D,
ws = WS,
wsc = WS_CHARS
)
);
let collapsed = WS_RE.replace_all(raw_text.unwrap_or(""), " ");
let text = js::trim(&collapsed);
if text.is_empty() || utf16_len(text) > 40 {
return None;
}
let m = TWO_DIGIT_RE
.captures(text)
.or_else(|| SEP_RE.captures(text))?;
let index = parse_int(&m[1], 10);
if !index.is_finite() || index > 40.0 {
return None;
}
Some(NumberedLabel {
index,
text: text.to_string(),
})
}
/// Input of `isNumberedSectionLabelCandidate`. Numbers are JS numbers (NaN
/// for `undefined`); `label_index` `None` for JS `null` / `undefined`;
/// `label_font_weight` is the raw style string (`Number(x) || 400`).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct NumberedLabelCandidateInput<'a> {
#[serde(borrow)]
pub heading_tag: &'a str,
#[serde(borrow)]
pub heading_text: &'a str,
pub heading_font_size: f64,
#[serde(borrow)]
pub label_tag: &'a str,
pub label_index: Option<f64>,
#[serde(borrow)]
pub label_text: &'a str,
pub label_font_size: f64,
pub label_letter_spacing: f64,
#[serde(borrow)]
pub label_font_weight: &'a str,
#[serde(borrow)]
pub label_font_family: &'a str,
#[serde(borrow)]
pub label_text_transform: &'a str,
#[serde(borrow)]
pub label_color: &'a str,
}
/// One `collectNumberedSectionLabelCandidates` entry.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct NumberedLabelCandidate {
pub index: f64,
pub label_text: String,
pub heading_tag: String,
pub heading_text: String,
}
// ─── Em-dash overuse ────────────────────────────────────────────────────────
/// JS: constants.mjs#EM_DASH_FLOOR.
pub const EM_DASH_FLOOR: usize = 8;
/// JS: constants.mjs#EM_DASH_CHARS_PER_DASH.
pub const EM_DASH_CHARS_PER_DASH: usize = 500;
+315
View File
@@ -0,0 +1,315 @@
//! The shapes the element rules are written against: the `{ id, snippet }`
//! hit, the per-check option structs the callers fill in, and the small
//! DOM-semantics helpers (heading tags, emoji-only text, shadow-layer
//! parsing) that both the open callers and the detector need. The checks
//! themselves live in the detector.
use crate::color::{named_color, parse_any_color, Rgba};
use crate::js::{self, ci, parse_float};
use once_cell::sync::Lazy;
use regex::Regex;
use serde::{Deserialize, Serialize};
macro_rules! re {
($name:ident, $pat:expr) => {
static $name: Lazy<Regex> = Lazy::new(|| Regex::new(&$pat).expect(stringify!($name)));
};
}
/// JS `\d`.
pub const D: &str = "[0-9]";
/// JS `\w`.
pub const W: &str = "[A-Za-z0-9_]";
/// JS `\b` (ASCII word boundary).
pub const B: &str = r"(?-u:\b)";
/// JS `.` (no line terminators: LF, CR, LS, PS).
pub const DOT: &str = "[^\n\r\\x{2028}\\x{2029}]";
/// JS `[\s\S]`.
pub const ANY: &str = "(?s:.)";
/// A `{ id, snippet }` finding, the shape every Section 3 check returns.
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct RuleHit {
pub id: String,
pub snippet: String,
}
impl RuleHit {
pub fn new(id: &str, snippet: String) -> Self {
RuleHit {
id: id.to_string(),
snippet,
}
}
}
/// JS `SET.has(v)` over a static list.
pub fn set_has(set: &[&str], v: &str) -> bool {
set.contains(&v)
}
// ─── checkBorders ───────────────────────────────────────────────────────────
/// The four border sides, JS `widths` / `colors` objects keyed Top / Right /
/// Bottom / Left.
#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)]
pub struct Sides<T> {
pub top: T,
pub right: T,
pub bottom: T,
pub left: T,
}
impl<T: Copy> Sides<T> {
/// `[Top, Right, Bottom, Left][i]`, the order the JS side loops in.
pub fn get(&self, i: usize) -> T {
match i {
0 => self.top,
1 => self.right,
2 => self.bottom,
_ => self.left,
}
}
}
/// JS `checkBorders` opts.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct BorderOpts {
pub badge_like: bool,
pub status_context: bool,
pub tab_context: bool,
}
// ─── isEmojiOnlyText ────────────────────────────────────────────────────────
const EMOJI_CLASS: &str = r"[\x{1F1E6}-\x{1F1FF}\x{1F300}-\x{1F9FF}\x{1FA00}-\x{1FAFF}\x{2600}-\x{27BF}\x{2300}-\x{23FF}\x{FE0F}\x{200D}\x{1F3FB}-\x{1F3FF}]";
re!(EMOJI_CHAR_RE, EMOJI_CLASS.to_string());
/// JS: checks.mjs#isEmojiOnlyText
pub fn is_emoji_only_text(text: &str) -> bool {
if text.is_empty() {
return false;
}
if !EMOJI_CHAR_RE.is_match(text) {
return false;
}
let stripped = EMOJI_CHAR_RE.replace_all(text, "");
js::trim(&stripped).is_empty()
}
// ─── checkColors ────────────────────────────────────────────────────────────
/// JS `checkColors` opts.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct ColorOpts {
pub tag: String,
pub text_color: Option<Rgba>,
pub bg_color: Option<Rgba>,
pub effective_bg: Option<Rgba>,
pub effective_bg_stops: Option<Vec<Rgba>>,
pub font_size: f64,
pub font_weight: f64,
pub has_direct_text: bool,
pub is_emoji_only: bool,
pub bg_clip: Option<String>,
pub bg_image: Option<String>,
/// The element's class list, already joined with spaces (JS accepts a
/// string or a DOMTokenList; `Array.from(list).join(' ')`).
pub class_list: Option<String>,
/// JS `DETECTOR_IS_BROWSER` (`typeof window !== 'undefined'`): the
/// static engines pass false, the browser build true.
pub detector_is_browser: bool,
}
// ─── checkHoverContrast ─────────────────────────────────────────────────────
/// JS `checkHoverContrast` opts.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct HoverContrastOpts {
pub tag: String,
pub text_color: Option<Rgba>,
pub bg: Option<Rgba>,
pub own_bg_alpha: Option<f64>,
pub font_size: f64,
pub font_weight: f64,
pub has_direct_text: bool,
pub is_emoji_only: bool,
}
/// JS `HEADING_TAGS`.
pub const HEADING_TAGS: &[&str] = &["h1", "h2", "h3", "h4", "h5", "h6"];
pub fn is_heading_tag(tag: &str) -> bool {
set_has(HEADING_TAGS, tag)
}
// ─── checkIconTile ──────────────────────────────────────────────────────────
/// JS `checkIconTile` opts.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct IconTileOpts {
pub heading_tag: String,
pub heading_text: Option<String>,
pub heading_top: f64,
pub sibling_tag: Option<String>,
pub sibling_width: f64,
pub sibling_height: f64,
pub sibling_bottom: f64,
pub sibling_bg_color: Option<Rgba>,
pub sibling_bg_image: Option<String>,
pub sibling_border_width: f64,
pub sibling_border_radius: f64,
pub has_icon_child: bool,
pub icon_child_width: f64,
}
// ─── resolveSerif / checkItalicSerif ────────────────────────────────────────
/// JS `resolveSerif` result `{ primary, isSerif }`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SerifResolution {
pub primary: Option<String>,
pub is_serif: bool,
}
/// JS `checkItalicSerif` opts.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct ItalicSerifOpts {
pub tag: String,
pub font_style: Option<String>,
pub font_family: Option<String>,
pub font_size: f64,
pub heading_text: Option<String>,
}
// ─── checkHeroEyebrow ───────────────────────────────────────────────────────
/// JS `checkHeroEyebrow` opts.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct HeroEyebrowOpts {
pub heading_tag: String,
pub heading_text: Option<String>,
pub heading_font_size: f64,
pub heading_in_application_context: bool,
pub sibling_tag: Option<String>,
pub sibling_text: Option<String>,
pub sibling_text_transform: Option<String>,
pub sibling_font_size: f64,
pub sibling_letter_spacing: f64,
/// JS `Number(siblingFontWeight) || 400`; numbers arrive stringified.
pub sibling_font_weight: Option<String>,
pub sibling_color: Option<String>,
pub sibling_has_accent_dash_pseudo: bool,
}
// ─── checkKickerAboveHeading ────────────────────────────────────────────────
/// One kicker candidate as `collectKickerCandidates` produces it.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct KickerCandidate {
pub heading_tag: String,
pub heading_text: String,
pub kicker_text: String,
}
/// JS `checkMotion` opts.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct MotionOpts {
pub tag: String,
pub transition_property: Option<String>,
pub animation_name: Option<String>,
pub timing_functions: Option<String>,
pub class_list: Option<String>,
}
// ─── findShadowColor / extractShadowLengths / checkGlow ─────────────────────
/// JS `findShadowColor` result `{ color, start, end }`; `start` / `end` are
/// byte offsets into the layer.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ShadowColor {
pub color: Option<Rgba>,
pub start: usize,
pub end: usize,
}
re!(
SHADOW_COLOR_FN,
format!(
r"(?:{}[aA]?|{}[aA]?|{}|{}|{}|{}|{}|{})\([^)]*\)",
ci("rgb"),
ci("hsl"),
ci("hwb"),
ci("oklch"),
ci("oklab"),
ci("lch"),
ci("lab"),
ci("color")
)
);
re!(SHADOW_HEX, format!(r"#[0-9a-fA-F]{{3,8}}{B}"));
re!(SHADOW_WORD, r"[a-zA-Z][a-zA-Z]*".to_string());
/// JS: checks.mjs#findShadowColor
pub fn find_shadow_color(layer: &str) -> Option<ShadowColor> {
if let Some(m) = SHADOW_COLOR_FN.find(layer) {
return Some(ShadowColor {
color: parse_any_color(Some(m.as_str())),
start: m.start(),
end: m.end(),
});
}
if let Some(m) = SHADOW_HEX.find(layer) {
return Some(ShadowColor {
color: parse_any_color(Some(m.as_str())),
start: m.start(),
end: m.end(),
});
}
for m in SHADOW_WORD.find_iter(layer) {
// JS-PARITY: `CSS_NAMED_COLORS[word]` is a plain-object lookup, so an
// inherited name like "constructor" would yield `{ a: 1 }` in JS;
// no CSS shadow carries such a word, and Rust skips it.
if let Some(named) = named_color(&js::to_lower_case(m.as_str())) {
return Some(ShadowColor {
color: Some(Rgba::new(named.r, named.g, named.b, 1.0)),
start: m.start(),
end: m.end(),
});
}
}
None
}
re!(SHADOW_LEN, format!(r"(-?{D}*\.?{D}+)(px|rem|em)?"));
/// JS: checks.mjs#extractShadowLengths
pub fn extract_shadow_lengths(layer: &str, color_span: Option<(usize, usize)>) -> Vec<f64> {
let stripped: String = match color_span {
Some((s, e)) => format!("{} {}", &layer[..s], &layer[e..]),
None => layer.to_string(),
};
let mut vals = Vec::new();
for m in SHADOW_LEN.captures_iter(&stripped) {
let mut v = parse_float(&m[1]);
if matches!(m.get(2).map(|u| u.as_str()), Some("rem") | Some("em")) {
v *= 16.0;
}
vals.push(v);
}
vals
}
/// JS `checkGlow` opts.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct GlowOpts {
pub box_shadow: Option<String>,
pub text_shadow: Option<String>,
pub effective_bg: Option<Rgba>,
}
+153
View File
@@ -0,0 +1,153 @@
//! Vector-replay dispatch for the open `rules.checks` helpers (group a):
//! the emoji test and the stylesheet-text utilities. The detector keeps the
//! arms for its own checks and reuses the `Js` accessors below.
use crate::css::scan as css_scan;
use crate::js;
use crate::rules::types as rules;
use crate::vectors::{decode, encode, Js};
use serde_json::Value;
/// The `rules.checks` functions this group replays. `impeccable_core`'s
/// `vectors::KNOWN_FUNCTIONS` folds this in, so a caller sees one table.
pub const KNOWN_FNS: &[&str] = &[
"isEmojiOnlyText",
"collectCssCustomProps",
"enclosingCssSelector",
"cssLengthToPx",
"isZeroOffset",
"collectMarqueeKeyframes",
"collectPulseKeyframes",
"stripReducedMotionBlocks",
];
// ─── Js accessors, shared with the detector-side arms ──────────────────────
pub fn field<'a>(j: &'a Js, key: &str) -> Option<&'a Js> {
match j {
Js::Obj(fields) => fields.iter().find(|(k, _)| k == key).map(|(_, v)| v),
_ => None,
}
}
pub fn to_number(j: Option<&Js>) -> f64 {
match j {
None | Some(Js::Undef) => f64::NAN,
Some(Js::Null) => 0.0,
Some(Js::Bool(b)) => {
if *b {
1.0
} else {
0.0
}
}
Some(Js::Num(n)) => *n,
Some(Js::Str(s)) => js::string_to_number(s),
_ => f64::NAN,
}
}
pub fn truthy(j: Option<&Js>) -> bool {
match j {
None | Some(Js::Undef) | Some(Js::Null) => false,
Some(Js::Bool(b)) => *b,
Some(Js::Num(n)) => *n != 0.0 && !n.is_nan(),
Some(Js::Str(s)) => !s.is_empty(),
_ => true,
}
}
/// A string-ish field: strings pass through; `undefined` / `null` are None;
/// other primitives stringify the way template literals / `String()` would.
pub fn opt_str(j: Option<&Js>) -> Option<String> {
match j {
None | Some(Js::Undef) | Some(Js::Null) => None,
Some(Js::Str(s)) => Some(s.clone()),
Some(Js::Num(n)) => Some(js::number_to_string(*n)),
Some(Js::Bool(b)) => Some(b.to_string()),
Some(Js::Arr(items)) => Some(
items
.iter()
.map(|i| opt_str(Some(i)).unwrap_or_default())
.collect::<Vec<_>>()
.join(","),
),
_ => None,
}
}
pub fn str_or_empty(j: Option<&Js>) -> String {
opt_str(j).unwrap_or_default()
}
fn strs_to_js(items: &[String]) -> Vec<Js> {
items.iter().map(|s| Js::Str(s.clone())).collect()
}
pub fn call(module: &str, fn_name: &str, args: &[Value]) -> Option<Value> {
if module != "rules.checks" {
return None;
}
let a: Vec<Js> = args.iter().map(decode).collect();
let arg = |i: usize| -> Option<&Js> {
match a.get(i) {
Some(Js::Undef) | None => None,
Some(v) => Some(v),
}
};
let result: Js = match fn_name {
"isEmojiOnlyText" => Js::Bool(rules::is_emoji_only_text(&str_or_empty(arg(0)))),
"collectCssCustomProps" => {
let map = css_scan::collect_css_custom_props(&str_or_empty(arg(0)));
Js::Map(
map.iter()
.map(|(k, v)| (Js::Str(k.clone()), Js::Str(v.clone())))
.collect(),
)
}
"enclosingCssSelector" => {
let text = str_or_empty(arg(0));
let idx = to_number(arg(1));
if !idx.is_finite() {
Js::Null
} else {
// Recorded index is UTF-16; map to a byte offset.
let mut units = 0usize;
let mut byte = text.len();
for (i, c) in text.char_indices() {
if units as f64 >= idx {
byte = i;
break;
}
units += c.len_utf16();
}
if idx < 0.0 {
byte = 0;
}
css_scan::enclosing_css_selector(&text, byte)
.map(Js::Str)
.unwrap_or(Js::Null)
}
}
"cssLengthToPx" => match css_scan::css_length_to_px(&str_or_empty(arg(0))) {
Some(n) => Js::Num(n),
None => Js::Null,
},
"isZeroOffset" => Js::Bool(css_scan::is_zero_offset(opt_str(arg(0)).as_deref())),
"collectMarqueeKeyframes" => Js::Set(strs_to_js(&css_scan::collect_marquee_keyframes(
&str_or_empty(arg(0)),
))),
"collectPulseKeyframes" => {
let map = css_scan::collect_pulse_keyframes(&str_or_empty(arg(0)));
Js::Map(
map.iter()
.map(|(k, v)| (Js::Str(k.clone()), Js::Bool(*v)))
.collect(),
)
}
"stripReducedMotionBlocks" => {
Js::Str(css_scan::strip_reduced_motion_blocks(&str_or_empty(arg(0))))
}
_ => return None,
};
Some(encode(&result))
}
+174
View File
@@ -0,0 +1,174 @@
//! Vector-replay dispatch for the open `rules.checks` helpers (group b):
//! the CSS measurement helpers and the numbered-label parser. The detector
//! keeps the arms for its own checks and reuses the `Js` accessors below.
use super::rgba_to_js;
use crate::css::measures;
use crate::js;
use crate::rules::text as text_rules;
use crate::vectors::{decode, encode, Js};
use serde_json::Value;
/// The `rules.checks` functions this group replays. `impeccable_core`'s
/// `vectors::KNOWN_FUNCTIONS` folds this in, so a caller sees one table.
pub const KNOWN_FNS: &[&str] = &[
"parseRadiusToPx",
"resolveVarRefs",
"parseColorResolved",
"resolveLengthPx",
"shadowMaxBlurPx",
"parseNumberedLabelText",
];
pub fn field<'a>(j: &'a Js, key: &str) -> Option<&'a Js> {
match j {
Js::Obj(fields) => fields.iter().find(|(k, _)| k == key).map(|(_, v)| v),
_ => None,
}
}
/// JS ToNumber for the shapes the vectors carry.
pub fn to_number(j: &Js) -> f64 {
match j {
Js::Undef => f64::NAN,
Js::Null => 0.0,
Js::Bool(b) => {
if *b {
1.0
} else {
0.0
}
}
Js::Num(n) => *n,
Js::Str(s) => js::string_to_number(s),
_ => f64::NAN,
}
}
pub fn num_field(j: &Js, key: &str) -> f64 {
field(j, key).map(to_number).unwrap_or(f64::NAN)
}
/// A string field, `None` for absent / undefined / null.
pub fn str_field<'a>(j: &'a Js, key: &str) -> Option<&'a str> {
match field(j, key) {
Some(Js::Str(s)) => Some(s.as_str()),
_ => None,
}
}
/// `x || ''` for a string field.
pub fn str_field_or_empty<'a>(j: &'a Js, key: &str) -> &'a str {
str_field(j, key).unwrap_or("")
}
pub fn opt_str(j: &Js) -> Option<&str> {
match j {
Js::Str(s) => Some(s.as_str()),
_ => None,
}
}
pub fn opt_num_to_js(n: Option<f64>) -> Js {
match n {
Some(n) => Js::Num(n),
None => Js::Null,
}
}
/// A recorded `Map` of custom properties.
pub struct JsCustomProps(Vec<(String, String)>);
impl measures::CustomProps for JsCustomProps {
fn get(&self, name: &str) -> Option<String> {
self.0
.iter()
.find(|(k, _)| k == name)
.map(|(_, v)| v.clone())
}
}
pub fn custom_props_from(j: &Js) -> Option<JsCustomProps> {
match j {
Js::Map(entries) => Some(JsCustomProps(
entries
.iter()
.filter_map(|(k, v)| match (k, v) {
(Js::Str(k), Js::Str(v)) => Some((k.clone(), v.clone())),
_ => None,
})
.collect(),
)),
_ => None,
}
}
pub fn call(module: &str, fn_name: &str, args: &[Value]) -> Option<Value> {
if module != "rules.checks" {
return None;
}
let a: Vec<Js> = args.iter().map(decode).collect();
let arg = |i: usize| a.get(i).cloned().unwrap_or(Js::Undef);
let result: Js = match fn_name {
"parseRadiusToPx" => {
let value = arg(0);
let width = to_number(&arg(1));
opt_num_to_js(measures::parse_radius_to_px(opt_str(&value), width))
}
"resolveVarRefs" => {
let raw = arg(0);
let Js::Str(raw) = &raw else {
return Some(encode(&raw));
};
let map = custom_props_from(&arg(1)).unwrap_or(JsCustomProps(vec![]));
let depth = match arg(2) {
Js::Undef => 0.0,
other => to_number(&other),
};
let depth = if depth.is_nan() {
0
} else {
depth.max(0.0) as u32
};
Js::Str(measures::resolve_var_refs(raw, &map, depth))
}
"parseColorResolved" => {
let s = arg(0);
let map = custom_props_from(&arg(1));
let out = measures::parse_color_resolved(
opt_str(&s),
map.as_ref().map(|m| m as &dyn measures::CustomProps),
);
match out {
Some(c) => rgba_to_js(&c),
None => Js::Null,
}
}
"resolveLengthPx" => {
let value = arg(0);
let fs = to_number(&arg(1));
opt_num_to_js(measures::resolve_length_px(opt_str(&value), fs))
}
"shadowMaxBlurPx" => {
let bs = arg(0);
let opts = arg(1);
let min_alpha = match field(&opts, "minAlpha") {
None | Some(Js::Undef) => None,
Some(v) => Some(to_number(v)),
};
Js::Num(measures::shadow_max_blur_px(opt_str(&bs), min_alpha))
}
"parseNumberedLabelText" => {
let raw = arg(0);
match text_rules::parse_numbered_label_text(opt_str(&raw)) {
Some(l) => Js::Obj(vec![
("index".to_string(), Js::Num(l.index)),
("text".to_string(), Js::Str(l.text)),
]),
None => Js::Null,
}
}
_ => return None,
};
Some(encode(&result))
}
+450
View File
@@ -0,0 +1,450 @@
//! Replay codec and open dispatcher for the recorded JS call vectors
//! (`tests/oracle/vectors/calls/<module>/<fn>.jsonl` in the public repo).
//!
//! Values use the recorder's encoding for what JSON cannot carry:
//! `{"$undef":true}`, `{"$nan":true}`, `{"$inf":1|-1}`, `{"$negzero":true}`,
//! `{"$map":[[k,v],...]}`, `{"$set":[...]}`. [`decode`] turns that into a
//! [`Js`] value, [`call`] runs the Rust port, and [`encode`] writes the result
//! back in the same encoding so a test can compare it with the JS result.
pub mod checks_a;
pub mod checks_b;
use crate::color::{self, Rgba};
use crate::inline_ignores::{self, IgnorableFinding, InlineIgnores};
use crate::js;
use serde_json::{json, Map, Value};
/// A decoded JS value.
#[derive(Debug, Clone, PartialEq)]
pub enum Js {
Undef,
Null,
Bool(bool),
Num(f64),
Str(String),
Arr(Vec<Js>),
Obj(Vec<(String, Js)>),
Map(Vec<(Js, Js)>),
Set(Vec<Js>),
}
impl Js {
fn get(&self, key: &str) -> Option<&Js> {
match self {
Js::Obj(fields) => fields.iter().find(|(k, _)| k == key).map(|(_, v)| v),
_ => None,
}
}
/// JS `ToNumber` for the value shapes the vectors carry.
fn to_number(&self) -> f64 {
match self {
Js::Undef => f64::NAN,
Js::Null => 0.0,
Js::Bool(b) => {
if *b {
1.0
} else {
0.0
}
}
Js::Num(n) => *n,
Js::Str(s) => js::string_to_number(s),
_ => f64::NAN,
}
}
fn as_str(&self) -> Option<&str> {
match self {
Js::Str(s) => Some(s),
_ => None,
}
}
fn as_f64(&self) -> Option<f64> {
match self {
Js::Num(n) => Some(*n),
_ => None,
}
}
fn is_nullish(&self) -> bool {
matches!(self, Js::Undef | Js::Null)
}
}
/// Decode a recorder-encoded JSON value.
pub fn decode(v: &Value) -> Js {
match v {
Value::Null => Js::Null,
Value::Bool(b) => Js::Bool(*b),
Value::Number(n) => Js::Num(n.as_f64().unwrap_or(f64::NAN)),
Value::String(s) => Js::Str(s.clone()),
Value::Array(items) => Js::Arr(items.iter().map(decode).collect()),
Value::Object(map) => {
if map.len() == 1 {
if let Some((k, val)) = map.iter().next() {
match k.as_str() {
"$undef" => return Js::Undef,
"$nan" => return Js::Num(f64::NAN),
"$inf" => {
return Js::Num(if val.as_f64().unwrap_or(1.0) < 0.0 {
f64::NEG_INFINITY
} else {
f64::INFINITY
})
}
"$negzero" => return Js::Num(-0.0),
"$map" => {
let entries = val
.as_array()
.map(|a| {
a.iter()
.map(|pair| {
let p = pair.as_array().expect("$map pair");
(decode(&p[0]), decode(&p[1]))
})
.collect()
})
.unwrap_or_default();
return Js::Map(entries);
}
"$set" => {
let items = val
.as_array()
.map(|a| a.iter().map(decode).collect())
.unwrap_or_default();
return Js::Set(items);
}
_ => {}
}
}
}
Js::Obj(map.iter().map(|(k, v)| (k.clone(), decode(v))).collect())
}
}
}
fn encode_number(n: f64) -> Value {
if n.is_nan() {
json!({ "$nan": true })
} else if n == f64::INFINITY {
json!({ "$inf": 1 })
} else if n == f64::NEG_INFINITY {
json!({ "$inf": -1 })
} else if n == 0.0 && n.is_sign_negative() {
json!({ "$negzero": true })
} else if n.fract() == 0.0 && n.abs() < 9.007_199_254_740_992e15 {
Value::from(n as i64)
} else {
Value::from(n)
}
}
/// Encode a JS value in the recorder's encoding.
pub fn encode(j: &Js) -> Value {
match j {
Js::Undef => json!({ "$undef": true }),
Js::Null => Value::Null,
Js::Bool(b) => Value::Bool(*b),
Js::Num(n) => encode_number(*n),
Js::Str(s) => Value::String(s.clone()),
Js::Arr(items) => Value::Array(items.iter().map(encode).collect()),
Js::Obj(fields) => {
let mut m = Map::new();
for (k, v) in fields {
m.insert(k.clone(), encode(v));
}
Value::Object(m)
}
Js::Map(entries) => {
json!({ "$map": entries.iter().map(|(k, v)| Value::Array(vec![encode(k), encode(v)])).collect::<Vec<_>>() })
}
Js::Set(items) => json!({ "$set": items.iter().map(encode).collect::<Vec<_>>() }),
}
}
fn rgba_from(j: &Js) -> Option<Rgba> {
match j {
Js::Obj(_) => Some(Rgba {
r: j.get("r").map(|v| v.to_number()).unwrap_or(f64::NAN),
g: j.get("g").map(|v| v.to_number()).unwrap_or(f64::NAN),
b: j.get("b").map(|v| v.to_number()).unwrap_or(f64::NAN),
a: match j.get("a") {
None | Some(Js::Undef) => None,
Some(v) => Some(v.to_number()),
},
}),
_ => None,
}
}
pub(crate) fn rgba_to_js(c: &Rgba) -> Js {
let mut fields = vec![
("r".to_string(), Js::Num(c.r)),
("g".to_string(), Js::Num(c.g)),
("b".to_string(), Js::Num(c.b)),
];
if let Some(a) = c.a {
fields.push(("a".to_string(), Js::Num(a)));
}
Js::Obj(fields)
}
fn opt_rgba_to_js(c: Option<Rgba>) -> Js {
match c {
Some(c) => rgba_to_js(&c),
None => Js::Null,
}
}
/// A recorded finding object, viewed through the two fields
/// `isInlineIgnored` reads.
struct JsFinding<'a>(&'a Js);
impl IgnorableFinding for JsFinding<'_> {
fn antipattern(&self) -> Option<&str> {
// JS `String(token || '')`: a non-string, truthy value would stringify;
// the vectors only carry strings or absent.
self.0.get("antipattern").and_then(|v| v.as_str())
}
fn line_number(&self) -> f64 {
self.0
.get("line")
.map(|v| v.to_number())
.unwrap_or(f64::NAN)
}
}
fn set_from(j: Option<&Js>) -> Vec<String> {
match j {
Some(Js::Set(items)) => items
.iter()
.filter_map(|i| i.as_str().map(|s| s.to_string()))
.collect(),
_ => Vec::new(),
}
}
fn map_from(j: Option<&Js>) -> Vec<(usize, Vec<String>)> {
match j {
Some(Js::Map(entries)) => entries
.iter()
.filter_map(|(k, v)| k.as_f64().map(|n| (n as usize, set_from(Some(v)))))
.collect(),
_ => Vec::new(),
}
}
fn directives_from(j: &Js) -> InlineIgnores {
InlineIgnores {
file: set_from(j.get("file")),
line: map_from(j.get("line")),
next_line: map_from(j.get("nextLine")),
}
}
fn set_to_js(set: &[String]) -> Js {
Js::Set(set.iter().map(|s| Js::Str(s.clone())).collect())
}
fn map_to_js(map: &[(usize, Vec<String>)]) -> Js {
Js::Map(
map.iter()
.map(|(k, v)| (Js::Num(*k as f64), set_to_js(v)))
.collect(),
)
}
fn directives_to_js(d: &InlineIgnores) -> Js {
Js::Obj(vec![
("file".to_string(), set_to_js(&d.file)),
("line".to_string(), map_to_js(&d.line)),
("nextLine".to_string(), map_to_js(&d.next_line)),
])
}
/// The `shared.color` functions this dispatcher replays.
pub const COLOR_FNS: &[&str] = &[
"isNeutralColor",
"parseRgb",
"relativeLuminance",
"contrastRatio",
"parseGradientColors",
"extractColorFunctionTokens",
"hasChroma",
"getHue",
"colorToHex",
"oklabToRgb",
"oklchToRgb",
"labToRgb",
"lchToRgb",
"colorFunctionToRgb",
"hslToRgb",
"hwbToRgb",
"splitTopLevelCommas",
"parseColorMix",
"parseAnyColor",
"compositeColorOver",
"isNoPaintColorValue",
];
/// The `shared.inline-ignores` functions this dispatcher replays.
pub const INLINE_IGNORE_FNS: &[&str] = &[
"parseInlineIgnores",
"isInlineIgnored",
"applyInlineIgnores",
];
/// Every (module, fn) pair the open dispatcher knows, so a test can report
/// which of them have no recorded vectors. `impeccable_core` re-exports this
/// and adds the detector's own tables.
pub const KNOWN_FUNCTIONS: &[(&str, &[&str])] = &[
("shared.color", COLOR_FNS),
("shared.inline-ignores", INLINE_IGNORE_FNS),
("rules.checks", checks_a::KNOWN_FNS),
("rules.checks", checks_b::KNOWN_FNS),
];
/// Invoke the Rust port of `<module>.<fn_name>` with recorder-encoded
/// arguments; returns the recorder-encoded result, or `None` when the
/// function is not known to the open dispatcher.
pub fn call(module: &str, fn_name: &str, args: &[Value]) -> Option<Value> {
if let Some(v) = checks_a::call(module, fn_name, args) {
return Some(v);
}
if let Some(v) = checks_b::call(module, fn_name, args) {
return Some(v);
}
let a: Vec<Js> = args.iter().map(decode).collect();
let arg = |i: usize| a.get(i).cloned().unwrap_or(Js::Undef);
let str_arg = |i: usize| -> Option<String> { arg(i).as_str().map(|s| s.to_string()) };
let num_arg = |i: usize| arg(i).to_number();
let result: Js = match (module, fn_name) {
("shared.color", "isNeutralColor") => {
Js::Bool(color::is_neutral_color(str_arg(0).as_deref()))
}
("shared.color", "parseRgb") => opt_rgba_to_js(color::parse_rgb(str_arg(0).as_deref())),
("shared.color", "relativeLuminance") => {
Js::Num(color::relative_luminance(&rgba_from(&arg(0))?))
}
("shared.color", "contrastRatio") => Js::Num(color::contrast_ratio(
&rgba_from(&arg(0))?,
&rgba_from(&arg(1))?,
)),
("shared.color", "parseGradientColors") => Js::Arr(
color::parse_gradient_colors(str_arg(0).as_deref())
.iter()
.map(rgba_to_js)
.collect(),
),
("shared.color", "extractColorFunctionTokens") => Js::Arr(
color::extract_color_function_tokens(str_arg(0).as_deref())
.into_iter()
.map(Js::Str)
.collect(),
),
("shared.color", "hasChroma") => {
let c = rgba_from(&arg(0));
let threshold = if arg(1) == Js::Undef {
None
} else {
Some(num_arg(1))
};
Js::Bool(color::has_chroma(c.as_ref(), threshold))
}
("shared.color", "getHue") => Js::Num(color::get_hue(rgba_from(&arg(0)).as_ref())),
("shared.color", "colorToHex") => Js::Str(color::color_to_hex(rgba_from(&arg(0)).as_ref())),
("shared.color", "oklabToRgb") => {
rgba_to_js(&color::oklab_to_rgb(num_arg(0), num_arg(1), num_arg(2)))
}
("shared.color", "oklchToRgb") => {
rgba_to_js(&color::oklch_to_rgb(num_arg(0), num_arg(1), num_arg(2)))
}
("shared.color", "labToRgb") => {
rgba_to_js(&color::lab_to_rgb(num_arg(0), num_arg(1), num_arg(2)))
}
("shared.color", "lchToRgb") => {
rgba_to_js(&color::lch_to_rgb(num_arg(0), num_arg(1), num_arg(2)))
}
("shared.color", "colorFunctionToRgb") => opt_rgba_to_js(color::color_function_to_rgb(
str_arg(0).as_deref().unwrap_or(""),
num_arg(1),
num_arg(2),
num_arg(3),
)),
("shared.color", "hslToRgb") => {
rgba_to_js(&color::hsl_to_rgb(num_arg(0), num_arg(1), num_arg(2)))
}
("shared.color", "hwbToRgb") => {
rgba_to_js(&color::hwb_to_rgb(num_arg(0), num_arg(1), num_arg(2)))
}
("shared.color", "splitTopLevelCommas") => Js::Arr(
color::split_top_level_commas(&str_arg(0)?)
.into_iter()
.map(Js::Str)
.collect(),
),
("shared.color", "parseColorMix") => opt_rgba_to_js(color::parse_color_mix(&str_arg(0)?)),
("shared.color", "parseAnyColor") => {
opt_rgba_to_js(color::parse_any_color(str_arg(0).as_deref()))
}
("shared.color", "compositeColorOver") => rgba_to_js(&color::composite_color_over(
&rgba_from(&arg(0))?,
&rgba_from(&arg(1))?,
)),
("shared.color", "isNoPaintColorValue") => {
let v = arg(0);
let s = match &v {
Js::Str(s) => Some(s.as_str()),
Js::Undef | Js::Null => None,
_ => return None,
};
Js::Bool(color::is_no_paint_color_value(s))
}
("shared.inline-ignores", "parseInlineIgnores") => {
directives_to_js(&inline_ignores::parse_inline_ignores(str_arg(0).as_deref()))
}
("shared.inline-ignores", "isInlineIgnored") => {
let finding = arg(0);
let directives = directives_from(&arg(1));
Js::Bool(inline_ignores::is_inline_ignored(
&JsFinding(&finding),
&directives,
))
}
("shared.inline-ignores", "applyInlineIgnores") => {
let findings = match arg(0) {
Js::Arr(items) => items,
other => return Some(encode(&other)),
};
let content = arg(1);
let content_str = if content.is_nullish() {
None
} else {
content.as_str().map(|s| s.to_string())
};
let wrapped: Vec<JsFinding> = findings.iter().map(JsFinding).collect();
let kept = inline_ignores::apply_inline_ignores(wrapped, content_str.as_deref());
Js::Arr(kept.into_iter().map(|f| f.0.clone()).collect())
}
_ => return None,
};
Some(encode(&result))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roundtrip_encoding() {
for v in [
json!({ "$undef": true }),
json!({ "$nan": true }),
json!({ "$inf": -1 }),
json!({ "$negzero": true }),
json!({ "$map": [[6, { "$set": ["a"] }]] }),
json!([1, "x", null, { "r": 1, "a": 0.5 }]),
] {
assert_eq!(encode(&decode(&v)), v);
}
}
}