Files
pbakaus_impeccable/crates/foundation/src/browser/mod.rs
T
Paul BakausandClaude Fable 5.1 b50098a477 Address review findings on the component ignore
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
2026-09-11 12:42:24 -07:00

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>,
}