mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-18 17:16:46 +03:00
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
156 lines
5.7 KiB
Rust
156 lines
5.7 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>,
|
|
}
|
|
|
|
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>,
|
|
}
|
|
|
|
/// 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__?.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>,
|
|
}
|