mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-16 08:06:24 +03:00
Four gaps the review bots found in the first commit:
- **The hook dropped the waivers when the design system was off.** Both
`design_system_options` paths returned `HookScanOptions::default()` when
`designSystem.enabled` is false, which left `ignore_selectors` empty, so an
opted-out component kept re-firing on every edit in exactly the projects
that have no DESIGN.md. Component ignores are not design-system state; they
travel either way now.
- **The in-page and extension scans never saw the key.** `BrowserConfig`
reads `ignoreSelectors`, but the two JS adapters that build that config
(`browser-bundle/50-scan.js` `collectConfigJson`, `60-offscreen.js`
`configJson`) listed their keys explicitly and dropped it, so the documented
`window.__IMPECCABLE_CONFIG__.ignoreSelectors` path did nothing. Both
forward it now, and the bundle is regenerated.
- **Visual-contrast findings skipped the stamp.** The URL engine's visual pass
produces its findings outside `collect_browser_findings`, so a
`low-contrast` hit on an opted-out component was reported rather than
waived. Each candidate carries its own selector, so the pass now resolves
that element against the same post-reveal snapshot and stamps what the
config waives. Page-level results (`content-hidden-at-rest`, `script-error`)
stay unstamped: they name no element.
- **One bad entry could discard the whole page config.** `ignoreSelectors`
used strict deserialization, so a hand-edited `{}` or `null` in the array
failed the parse of `BrowserConfig`, which the wasm entry points answer with
`unwrap_or_default()` — losing the design system and every other setting.
It now filters bad entries the way `disabledValues` does.
Also: a `files` glob no longer applies to a URL scan. Globs name repo paths,
and `index.html` reaching `https://example.com/index.html` would scope an
ignore to a page the entry never named. URL scans take the unscoped entries
only, which is what the docs already promised.
Assisted-by: Claude Code
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LQBUunp8QttxZqihybNmtL
269 lines
10 KiB
Rust
269 lines
10 KiB
Rust
//! 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>,
|
|
/// The `detector.ignoreSelectors` selector that waived this finding, when
|
|
/// one did. A stamped finding is still reported by the engine: the config
|
|
/// layer drops it and counts it, so a component-level opt-out shows up as
|
|
/// a number rather than as silence. `None` for everything else, and
|
|
/// skipped in serialization, so output without the feature is unchanged.
|
|
#[serde(
|
|
default,
|
|
rename = "ignoredBy",
|
|
skip_serializing_if = "Option::is_none"
|
|
)]
|
|
pub ignored_by: 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,
|
|
ignored_by: 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())
|
|
}
|
|
|
|
/// The same tolerance for `ignoreSelectors`: an entry that is not an object
|
|
/// with both halves is dropped, rather than failing the parse of the whole
|
|
/// config (which the wasm entry points answer with `unwrap_or_default()`,
|
|
/// silently losing the design system and every other setting with it).
|
|
fn de_ignore_selectors<'de, D>(
|
|
de: D,
|
|
) -> Result<Vec<crate::selector_ignores::SelectorIgnore>, 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()?;
|
|
let text = |key: &str| match obj.get(key) {
|
|
Some(serde_json::Value::String(s)) => s.clone(),
|
|
_ => String::new(),
|
|
};
|
|
let parsed =
|
|
crate::selector_ignores::SelectorIgnore::new(text("rule"), text("selector"));
|
|
parsed.is_valid().then_some(parsed)
|
|
})
|
|
.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__?.ignoreSelectors`: the project's
|
|
/// component-level opt-outs, `[{ rule, selector }]`. Every finding on an
|
|
/// element the selector matches (or on a descendant of one) is stamped
|
|
/// with that selector instead of being reported clean, the same waiver
|
|
/// `data-impeccable-ignore` grants the element that carries it. Honored
|
|
/// in every mode: unlike `disabledRules`, this list is the project's own
|
|
/// config rather than a browser-extension preference. Empty by default,
|
|
/// and skipped in serialization so a config without it is byte-identical.
|
|
#[serde(
|
|
default,
|
|
deserialize_with = "de_ignore_selectors",
|
|
skip_serializing_if = "Vec::is_empty"
|
|
)]
|
|
pub ignore_selectors: Vec<crate::selector_ignores::SelectorIgnore>,
|
|
/// `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>,
|
|
}
|