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
This commit is contained in:
Paul Bakaus
2026-09-03 20:09:50 -07:00
co-authored by Claude Code
parent 689e5150d9
commit f32d374ac7
9 changed files with 675 additions and 6 deletions
+5
View File
@@ -68,6 +68,11 @@ if (IS_BROWSER && !__impeccable) {
return JSON.stringify({
extensionMode: EXTENSION_MODE,
disabledRules: Array.isArray(config.disabledRules) ? config.disabledRules : [],
// The live overlay resolves the project's ignoreValues for this page
// and forwards the survivors here (live-browser-ignores.js); the core
// applies them where the findings are assembled, because the overlay
// draws its markers from the collected findings.
disabledValues: Array.isArray(config.disabledValues) ? config.disabledValues : [],
designSystem: config.designSystem == null ? null : config.designSystem,
lineLengthMax: config.lineLengthMax == null ? null : config.lineLengthMax,
skipScan: config.skipScan === true,
+1
View File
@@ -110,6 +110,7 @@
return JSON.stringify({
extensionMode: true,
disabledRules: Array.isArray(config.disabledRules) ? config.disabledRules : [],
disabledValues: Array.isArray(config.disabledValues) ? config.disabledValues : [],
designSystem: config.designSystem == null ? null : config.designSystem,
lineLengthMax: config.lineLengthMax == null ? null : config.lineLengthMax,
skipScan: config.skipScan === true,
+1
View File
@@ -190,6 +190,7 @@ pub fn browser_config(
BrowserConfig {
extension_mode: false,
disabled_rules: Vec::new(),
disabled_values: Vec::new(),
skip_scan: false,
design_system: if design_system.is_null() {
None
+417 -1
View File
@@ -6,7 +6,7 @@
#![allow(unused_imports)]
use super::dom::{tag_lower, Dom, ElId, Rect};
use super::element_checks::check_element_borders_dom;
use super::{BrowserConfig, BrowserFinding, FindingGroup};
use super::{BrowserConfig, BrowserFinding, DisabledValue, FindingGroup};
use crate::js_ext_a::JsMap;
use serde::Serialize;
@@ -1065,6 +1065,233 @@ fn hits(v: Vec<crate::checks::rules::RuleHit>) -> Vec<BrowserFinding> {
v.iter().map(BrowserFinding::from_hit).collect()
}
// ─── value-level suppression (JS: index.mjs#collectBrowserFindings tail) ────
//
// `disabledRules` waives whole rules; this applies the config's remaining
// `ignoreValues` entries, which the CLI filters through
// `isIgnoredFindingValue` (crates/detect config.rs), so a project waiver like
// `overused-font = "geist mono"` reaches the overlay and the extension too.
/// The six rules whose findings carry a matchable value; keep in step with
/// `extract_finding_ignore_value` in crates/detect. Everything else is
/// suppressed by rule or by file scope, both already resolved into
/// `disabledRules` before the scan message was sent.
const DIRECT_VALUE_RULES: &[&str] = &[
"overused-font",
"bounce-easing",
"design-system-font",
"design-system-color",
"design-system-radius",
"design-system-font-size",
];
static EDGE_QUOTE_RE: once_cell::sync::Lazy<regex::Regex> =
once_cell::sync::Lazy::new(|| regex::Regex::new(r#"^["']|["']$"#).expect("EDGE_QUOTE_RE"));
static WS_RUN_RE: once_cell::sync::Lazy<regex::Regex> = once_cell::sync::Lazy::new(|| {
regex::Regex::new(&format!("[{}]+", crate::js::WS_CHARS)).expect("WS_RUN_RE")
});
static PRIMARY_FONT_RE: once_cell::sync::Lazy<regex::Regex> =
once_cell::sync::Lazy::new(|| {
regex::Regex::new(&format!(
"(?i:Primary font):[{}]*([^()\n;]+)",
crate::js::WS_CHARS
))
.expect("PRIMARY_FONT_RE")
});
static GOOGLE_LABEL_RE: once_cell::sync::Lazy<regex::Regex> =
once_cell::sync::Lazy::new(|| {
regex::Regex::new(&format!(
"(?i:Google Fonts):[{}]*([^()\n;]+)",
crate::js::WS_CHARS
))
.expect("GOOGLE_LABEL_RE")
});
static FAMILY_RE: once_cell::sync::Lazy<regex::Regex> = once_cell::sync::Lazy::new(|| {
regex::Regex::new(&format!(
r#"(?i:font-family)[{ws}]*:[{ws}]*["']?([^'",;\n]+)"#,
ws = crate::js::WS_CHARS
))
.expect("FAMILY_RE")
});
static COLOR_HEX_RE: once_cell::sync::Lazy<regex::Regex> = once_cell::sync::Lazy::new(|| {
regex::Regex::new("^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$").expect("COLOR_HEX_RE")
});
static COLOR_RGB_RE: once_cell::sync::Lazy<regex::Regex> = once_cell::sync::Lazy::new(|| {
regex::Regex::new(r"^rgba?\(([\s\S]*)\)$").expect("COLOR_RGB_RE")
});
static COLOR_CHANNEL_RE: once_cell::sync::Lazy<regex::Regex> =
once_cell::sync::Lazy::new(|| {
regex::Regex::new(r"^(-?\d*\.?\d+)(%)?$").expect("COLOR_CHANNEL_RE")
});
/// JS `_normValue`: trim, drop one edge quote at each end, `+` to space,
/// collapse whitespace runs, lowercase.
pub fn normalize_browser_ignore_value(value: &str) -> String {
let t = crate::js::trim(value);
let t = EDGE_QUOTE_RE.replace_all(t, "");
let t = t.replace('+', " ");
let t = WS_RUN_RE.replace_all(&t, " ");
crate::js::to_lower_case(&t)
}
/// JS `_colorKey`: `design-system-color` compares by color value, not by
/// spelling, because the browser reports computed `rgb(...)` strings while
/// waivers are usually written as hex. Mirrors `colorIgnoreKey` in
/// crates/detect for the hex and `rgb()`/`rgba()` forms; hsl stays CLI-only,
/// as it was in the JS engine.
pub fn browser_color_ignore_key(value: &str) -> String {
let text = crate::js::to_lower_case(crate::js::trim(value));
if let Some(m) = COLOR_HEX_RE.captures(&text) {
let digits = m.get(1).unwrap().as_str();
let expanded: String = if digits.len() <= 4 {
digits.chars().flat_map(|c| [c, c]).collect()
} else {
digits.to_string()
};
let bytes: Vec<u32> = expanded
.as_bytes()
.chunks(2)
.map(|c| u32::from_str_radix(std::str::from_utf8(c).unwrap_or("0"), 16).unwrap_or(0))
.collect();
let a = bytes.get(3).copied().unwrap_or(255);
return format!("{},{},{},{}", bytes[0], bytes[1], bytes[2], a);
}
let Some(m) = COLOR_RGB_RE.captures(&text) else {
return String::new();
};
// JS: body.trim().replace(/\s*\/\s*/g, ' / '), then split on ',' with the
// trailing `a / b` group re-split, or on whitespace when there is no comma.
let body = crate::js::trim(m.get(1).unwrap().as_str()).to_string();
let body = slash_spaced(&body);
let parts: Vec<String> = if body.contains(',') {
let mut parts: Vec<String> = body
.split(',')
.map(|p| crate::js::trim(p).to_string())
.filter(|p| !p.is_empty())
.collect();
if let Some(last) = parts.last().cloned() {
if last.contains('/') {
parts.pop();
parts.extend(
last.split('/')
.map(|p| crate::js::trim(p).to_string())
.filter(|p| !p.is_empty()),
);
}
}
parts
} else {
WS_RUN_RE
.split(&body)
.filter(|p| !p.is_empty() && *p != "/")
.map(|p| p.to_string())
.collect()
};
if parts.len() < 3 || parts.len() > 4 {
return String::new();
}
let channel = |raw: &str, is_alpha: bool| -> Option<f64> {
let m = COLOR_CHANNEL_RE.captures(crate::js::trim(raw))?;
let mut v: f64 = m.get(1).unwrap().as_str().parse().ok()?;
if m.get(2).is_some() {
v = if is_alpha { v / 100.0 } else { v * 2.55 };
}
let max = if is_alpha { 1.0 } else { 255.0 };
if !v.is_finite() || v < 0.0 || v > max {
return None;
}
Some(if is_alpha { v } else { v.round() })
};
let (Some(r), Some(g), Some(b)) = (
channel(&parts[0], false),
channel(&parts[1], false),
channel(&parts[2], false),
) else {
return String::new();
};
let a = match parts.get(3) {
None => 1.0,
Some(p) => match channel(p, true) {
Some(v) => v,
None => return String::new(),
},
};
format!(
"{},{},{},{}",
crate::js::number_to_string(r),
crate::js::number_to_string(g),
crate::js::number_to_string(b),
crate::js::number_to_string((a * 255.0).round())
)
}
/// JS `.replace(/\s*\/\s*/g, ' / ')`.
fn slash_spaced(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let chars: Vec<char> = text.chars().collect();
let is_ws = |c: char| c.is_whitespace() || c == '\u{feff}';
let mut i = 0;
while i < chars.len() {
let start = i;
while i < chars.len() && is_ws(chars[i]) {
i += 1;
}
if i < chars.len() && chars[i] == '/' {
i += 1;
while i < chars.len() && is_ws(chars[i]) {
i += 1;
}
out.push_str(" / ");
continue;
}
i = start;
out.push(chars[i]);
i += 1;
}
out
}
/// JS `_findingValue`: the value a finding of a value-scoped rule offers to a
/// waiver, or empty when it offers none.
fn browser_finding_ignore_value(f: &BrowserFinding) -> String {
if !DIRECT_VALUE_RULES.contains(&f.type_.as_str()) {
return String::new();
}
if let Some(direct) = f.ignore_value.as_deref().filter(|s| !s.is_empty()) {
return normalize_browser_ignore_value(direct);
}
// The CLI routes bounce-easing through extractMotionIgnoreValue and never
// the font regexes; without a direct ignoreValue there is no value to
// match, so do not invent one from unrelated CSS text.
if f.type_ == "bounce-easing" {
return String::new();
}
// The design-system checks set `ignoreValue` on their findings; the detail
// fallback catches overused-font, whose value lives in its sentence.
for re in [&*PRIMARY_FONT_RE, &*GOOGLE_LABEL_RE, &*FAMILY_RE] {
if let Some(m) = re.captures(&f.detail) {
return normalize_browser_ignore_value(m.get(1).unwrap().as_str());
}
}
String::new()
}
/// JS `_valueIgnored`.
fn browser_value_ignored(f: &BrowserFinding, entries: &[(String, String)]) -> bool {
let value = browser_finding_ignore_value(f);
if value.is_empty() {
return false;
}
entries.iter().any(|(rule, entry_value)| {
rule == &f.type_
&& (entry_value == &value
|| (f.type_ == "design-system-color" && {
let key = browser_color_ignore_key(entry_value);
!key.is_empty() && key == browser_color_ignore_key(&value)
}))
})
}
/// JS: index.mjs#collectBrowserFindings()
pub fn collect_browser_findings(dom: &dyn Dom, config: &BrowserConfig) -> CollectResult {
use super::element_checks as ec;
@@ -1212,6 +1439,31 @@ pub fn collect_browser_findings(dom: &dyn Dom, config: &BrowserConfig) -> Collec
el_pass(&mut groups, pack.check_page_dom(dom));
}
// Value-level suppression runs last, over everything the passes produced,
// so a project waiver covers a rule pack's findings the same way it covers
// the built-in ones. JS: index.mjs#collectBrowserFindings() tail.
let disabled_values: Vec<(String, String)> = if config.extension_mode {
config
.disabled_values
.iter()
.map(|e| {
(
crate::js::to_lower_case(crate::js::trim(&e.rule)),
normalize_browser_ignore_value(&e.value),
)
})
.collect()
} else {
Vec::new()
};
if !disabled_values.is_empty() {
for group in groups.iter_mut() {
group.findings.retain(|f| !browser_value_ignored(f, &disabled_values));
}
groups.retain(|g| !g.findings.is_empty());
page_level.retain(|f| !browser_value_ignored(f, &disabled_values));
}
CollectResult { groups, page_level }
}
@@ -1422,6 +1674,170 @@ mod tests {
assert!(!out.groups.is_empty());
}
#[test]
fn skip_scan_covers_every_stage_of_the_collect_pass() {
// The visual-contrast stage is not part of this pass (it runs in the
// page, 50-scan.js), but everything the core produces has to be gone:
// element groups, page-level findings, and the rule pack.
let mut d = FakeDom::new();
let (html, body) = d.with_page();
let head = d.add(Some(html), "head");
let link = d.add(Some(head), "link");
d.set_attr(link, "href", "https://fonts.googleapis.com/css2?family=Poppins");
d.add_selector(link, "link[href*=\"fonts.googleapis.com/css\"]");
let p = d.add(Some(body), "p");
d.add_text(p, "Hello");
d.set_styles(p, &[("fontFamily", "Poppins, sans-serif")]);
d.el_mut(p).check_visibility = Some(true);
let ds = json!({ "present": true, "hasFonts": true, "allowedFonts": ["Inter"] });
let cfg = BrowserConfig {
extension_mode: true,
skip_scan: true,
design_system: Some(ds),
..Default::default()
};
let out = collect_browser_findings(&d, &cfg);
assert!(out.groups.is_empty());
assert!(out.page_level.is_empty());
}
/// The waiver plumbing the live overlay depends on: `disabledValues`
/// entries resolved by live-browser-ignores.js suppress the matching
/// findings where the findings are assembled, since the overlay draws its
/// markers from what this pass returns. JS: index.mjs#collectBrowserFindings
/// value-level suppression (issue #639).
#[test]
fn disabled_values_suppress_matching_findings() {
let make_dom = || {
let mut d = FakeDom::new();
let (_h, body) = d.with_page();
let p = d.add(Some(body), "p");
d.add_text(p, "Hello");
d.set_styles(
p,
&[
("fontFamily", "Poppins, sans-serif"),
("color", "rgb(255, 0, 0)"),
("backgroundColor", "rgba(0, 0, 0, 0)"),
],
);
d.el_mut(p).check_visibility = Some(true);
d
};
let ds = json!({
"present": true,
"hasFonts": true, "allowedFonts": ["Inter"],
"hasColors": true, "allowedColors": [{ "r": 10, "g": 20, "b": 30 }]
});
let base = BrowserConfig {
extension_mode: true,
design_system: Some(ds),
..Default::default()
};
let types = |out: &CollectResult| -> Vec<String> {
out.groups
.iter()
.flat_map(|g| g.findings.iter().map(|f| f.type_.clone()))
.collect()
};
let unfiltered = collect_browser_findings(&make_dom(), &base);
assert!(types(&unfiltered).contains(&"design-system-font".to_string()));
assert!(types(&unfiltered).contains(&"design-system-color".to_string()));
// A font waiver drops its finding and leaves the unrelated one.
let font_waived = BrowserConfig {
disabled_values: vec![DisabledValue {
rule: "design-system-font".into(),
value: "Poppins".into(),
}],
..base.clone()
};
let out = collect_browser_findings(&make_dom(), &font_waived);
assert!(!types(&out).contains(&"design-system-font".to_string()));
assert!(types(&out).contains(&"design-system-color".to_string()));
// Color waivers match by value, not by spelling: the browser reports
// computed rgb(...) and the waiver is written as hex.
let color_waived = BrowserConfig {
disabled_values: vec![DisabledValue {
rule: "design-system-color".into(),
value: "#ff0000".into(),
}],
..base.clone()
};
let out = collect_browser_findings(&make_dom(), &color_waived);
assert!(!types(&out).contains(&"design-system-color".to_string()));
assert!(types(&out).contains(&"design-system-font".to_string()));
// A waiver for another rule's value changes nothing.
let unrelated = BrowserConfig {
disabled_values: vec![DisabledValue {
rule: "design-system-font".into(),
value: "Inter".into(),
}],
..base.clone()
};
let out = collect_browser_findings(&make_dom(), &unrelated);
assert_eq!(types(&out), types(&unfiltered));
// Outside extension mode the config is whatever the page set, so the
// list is ignored, exactly as the JS read it.
let non_ext = BrowserConfig {
extension_mode: false,
..font_waived
};
let out = collect_browser_findings(&make_dom(), &non_ext);
assert!(types(&out).contains(&"design-system-font".to_string()));
}
#[test]
fn disabled_values_parse_and_normalize_like_the_js() {
// JS `.filter(e => e && typeof e === 'object' && e.rule && e.value)`:
// a hand-edited __IMPECCABLE_CONFIG__ drops bad entries, it does not
// fail the whole config.
let cfg: BrowserConfig = serde_json::from_str(
r##"{"extensionMode":true,"disabledValues":[
{"rule":"overused-font","value":"Geist+Mono"},
{"rule":"design-system-font"},
{"value":"orphan"},
"nope",
{"rule":"design-system-color","value":"#FFF"}
]}"##,
)
.unwrap();
assert_eq!(cfg.disabled_values.len(), 2);
assert_eq!(cfg.disabled_values[0].value, "Geist+Mono");
// A config with no disabledValues at all still parses.
let bare: BrowserConfig = serde_json::from_str(r#"{"extensionMode":true}"#).unwrap();
assert!(bare.disabled_values.is_empty());
let junk: BrowserConfig =
serde_json::from_str(r#"{"disabledValues":"not an array"}"#).unwrap();
assert!(junk.disabled_values.is_empty());
assert_eq!(normalize_browser_ignore_value(" \"Geist+Mono\" "), "geist mono");
assert_eq!(browser_color_ignore_key("#fff"), "255,255,255,255");
assert_eq!(browser_color_ignore_key("rgb(255, 0, 0)"), "255,0,0,255");
assert_eq!(
browser_color_ignore_key("rgb(255 255 255 / 50%)"),
"255,255,255,128"
);
// hsl stays CLI-only in the browser matcher, as it was in the JS.
assert_eq!(browser_color_ignore_key("hsl(0, 0%, 100%)"), "");
assert_eq!(browser_color_ignore_key("not a color"), "");
// bounce-easing without a direct ignoreValue offers no value: the CLI
// routes it through the motion extractor and never the font regexes.
let bounce = BrowserFinding::new("bounce-easing", "font-family: Poppins");
assert!(browser_finding_ignore_value(&bounce).is_empty());
let mut carried = BrowserFinding::new("overused-font", "Primary font: Poppins (etc)");
assert_eq!(browser_finding_ignore_value(&carried), "poppins");
carried.ignore_value = Some("Space Grotesk".into());
assert_eq!(browser_finding_ignore_value(&carried), "space grotesk");
// A rule outside the six offers nothing to match.
assert!(browser_finding_ignore_value(&BrowserFinding::new("glow-effect", "x")).is_empty());
}
#[test]
fn html_pattern_query_strips_pseudos_and_dangling_commas() {
assert_eq!(html_pattern_query(".card::before"), Some(".card".to_string()));
+3 -1
View File
@@ -86,4 +86,6 @@ pub mod text_collectors;
pub mod visual;
pub use dom::{Dom, ElId, Rect};
pub use impeccable_foundation::browser::{BrowserConfig, BrowserFinding, ElFinding, FindingGroup};
pub use impeccable_foundation::browser::{
BrowserConfig, BrowserFinding, DisabledValue, ElFinding, FindingGroup,
};
+58
View File
@@ -76,6 +76,57 @@ pub struct FindingGroup {
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)]
@@ -87,6 +138,13 @@ pub struct BrowserConfig {
/// 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.
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -39,7 +39,8 @@ pub fn installed_rule_pack() -> Option<&'static dyn RulePack> {
}
/// `collectBrowserFindings()`: `config_json` is `{ extensionMode,
/// disabledRules, designSystem, lineLengthMax }`; returns
/// disabledRules, disabledValues, skipScan, designSystem, lineLengthMax }`;
/// returns
/// `{ groups: [{ el, findings }], pageLevel: [...] }`.
#[wasm_bindgen]
pub fn collect_browser_findings(config_json: &str) -> String {
+179
View File
@@ -0,0 +1,179 @@
#!/usr/bin/env node
// disabledValues regression check of the in-page WASM bundle, ported from the
// retired tests/detect-antipatterns-browser.test.mjs case
// "extension mode suppresses disabledValues entries from scan config"
// (issue #639). The live overlay resolves the project's ignoreValues per page
// (skill/scripts/live-browser-ignores.js) and sends the survivors as
// config.disabledValues; the detector must filter them where the findings are
// assembled, since the overlay draws its markers from the collected findings.
// Color waivers match by value rather than by spelling, so a hex waiver has to
// suppress a finding the browser reported as rgb(...).
//
// Verification tooling like skip-scan-check.mjs: it needs this repo's fixtures
// plus puppeteer and a built bundle (`cargo xtask bundle`).
//
// node crates/wasm/tools/disabled-values-check.mjs [--public <other checkout>] [--verbose]
//
// Exit 0 when every contract holds, 1 otherwise.
import fs from 'node:fs';
import http from 'node:http';
import path from 'node:path';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
const args = process.argv.slice(2);
const flag = (n) => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] : null; };
const verbose = args.includes('--verbose');
const here = path.dirname(fileURLToPath(import.meta.url));
const engineRoot = path.resolve(here, '../../..');
const publicRepo = path.resolve(flag('--public') || process.env.IMPECCABLE_PUBLIC_REPO || engineRoot);
const require = createRequire(path.join(publicRepo, 'package.json'));
const puppeteer = require('puppeteer');
const BUNDLE = fs.readFileSync(path.join(engineRoot, 'dist/detect-antipatterns-browser.js'), 'utf8');
// The JSON-safe payload shape the extension panel and the URL engine inject as
// __IMPECCABLE_CONFIG__.designSystem, for the DESIGN.md the design-system.html
// fixture is written against.
const designSystem = {
present: true,
hasFonts: true,
allowedFonts: ['avenir next', 'ibm plex sans'],
hasColors: true,
allowedColors: [
{ r: 36, g: 31, b: 26 },
{ r: 247, g: 244, b: 238 },
{ r: 255, g: 255, b: 255 },
{ r: 184, g: 66, b: 46 },
{ r: 212, g: 199, b: 185 },
],
hasRadii: true,
allowedRadii: [4, 8, 32],
hasPillRadius: true,
};
const dir = path.join(publicRepo, 'tests/fixtures/antipatterns');
const server = http.createServer((req, res) => {
const f = path.join(dir, decodeURIComponent(req.url.split('?')[0]));
try {
const body = fs.readFileSync(f);
res.setHeader('Content-Type', f.endsWith('.css') ? 'text/css' : f.endsWith('.js') ? 'application/javascript' : f.endsWith('.svg') ? 'image/svg+xml' : f.endsWith('.png') ? 'image/png' : 'text/html; charset=utf-8');
res.end(body);
} catch { res.statusCode = 404; res.end(); }
}).listen(0);
const port = server.address().port;
const browser = await puppeteer.launch({
headless: true,
executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || undefined,
args: process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : [],
});
let failures = 0;
const fail = (msg) => { failures++; console.log(`FAIL ${msg}`); };
const ok = (msg) => console.log(`OK ${msg}`);
try {
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 800 });
await page.goto(`http://127.0.0.1:${port}/design-system.html`, { waitUntil: 'load' });
await page.evaluate(() => {
document.documentElement.dataset.impeccableExtension = 'true';
window.__impeccableMessages = [];
window.addEventListener('message', event => {
if (event.source !== window || !event.data?.source?.startsWith('impeccable-')) return;
window.__impeccableMessages.push(event.data);
});
});
await page.evaluate(BUNDLE);
const scan = (scanId, disabledValues, extraConfig = {}) => page.evaluate(async (config) => {
window.postMessage({ source: 'impeccable-command', action: 'scan', config }, '*');
const deadline = Date.now() + 5000;
while (
Date.now() < deadline &&
!window.__impeccableMessages.some(message =>
message.source === 'impeccable-results' && message.scanId === config.scanId)
) {
await new Promise(resolve => setTimeout(resolve, 25));
}
const resultMessage = window.__impeccableMessages.find(message =>
message.source === 'impeccable-results' && message.scanId === config.scanId);
const flat = (resultMessage?.findings || []).flatMap(group => group.findings || []);
return {
total: flat.length,
colors: flat.filter(finding => finding.type === 'design-system-color').length,
colorValues: flat
.filter(finding => finding.type === 'design-system-color')
.map(finding => finding.ignoreValue || ''),
fonts: flat
.filter(finding => finding.type === 'design-system-font')
.map(finding => finding.ignoreValue || ''),
};
}, { scanId, visualContrast: false, designSystem, ...(disabledValues ? { disabledValues } : {}), ...extraConfig });
const unfiltered = await scan('scan-dv-1');
if (verbose) console.log(' unfiltered:', JSON.stringify(unfiltered));
const poppins = (values) => values.some(value => /poppins/i.test(value));
if (poppins(unfiltered.fonts)) {
ok('control scan reported the undocumented poppins font');
} else {
fail(`expected an undocumented poppins font finding, got: ${JSON.stringify(unfiltered)}`);
}
const filtered = await scan('scan-dv-2', [{ rule: 'design-system-font', value: 'poppins' }]);
if (!poppins(filtered.fonts)) {
ok('the poppins waiver suppressed its finding');
} else {
fail(`expected the poppins waiver to suppress its finding, got: ${JSON.stringify(filtered)}`);
}
const waivedCount = unfiltered.fonts.filter(value => /poppins/i.test(value)).length;
if (filtered.total === unfiltered.total - waivedCount) {
ok('exactly the waived findings disappeared');
} else {
fail(`expected exactly the waived findings to disappear, got: ${JSON.stringify({ unfiltered, filtered })}`);
}
if (filtered.colors === unfiltered.colors) {
ok('unrelated design-system findings survived');
} else {
fail(`expected unrelated design-system findings to survive, got: ${JSON.stringify({ unfiltered, filtered })}`);
}
// Color waivers match by value, not by spelling: the browser reports
// computed rgb(...) strings while the waiver is written as hex.
const rgbToHex = (value) => {
const m = String(value).match(/^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/i);
if (!m) return null;
return `#${[m[1], m[2], m[3]].map(n => Number(n).toString(16).padStart(2, '0')).join('')}`;
};
const rgbColor = unfiltered.colorValues.find(value => rgbToHex(value));
if (!rgbColor) {
fail(`expected an rgb()-reported design-system-color finding, got: ${JSON.stringify(unfiltered.colorValues)}`);
} else {
const hexWaiver = rgbToHex(rgbColor);
const colorFiltered = await scan('scan-dv-3', [{ rule: 'design-system-color', value: hexWaiver }]);
const waivedColorCount = unfiltered.colorValues.filter(value => value === rgbColor).length;
if (colorFiltered.colors === unfiltered.colors - waivedColorCount) {
ok(`the hex waiver ${hexWaiver} suppressed the ${rgbColor} findings`);
} else {
fail(`expected the hex waiver ${hexWaiver} to suppress the ${rgbColor} findings, got: ${JSON.stringify({ colorValues: unfiltered.colorValues, colorFiltered })}`);
}
if (poppins(colorFiltered.fonts)) {
ok('unrelated font findings survived the color waiver');
} else {
fail(`expected unrelated font findings to survive the color waiver, got: ${JSON.stringify(colorFiltered)}`);
}
}
// A page waived wholesale by detector.ignoreFiles arrives with
// config.skipScan and must scan to nothing at all.
const skipped = await scan('scan-dv-4', null, { skipScan: true });
if (skipped.total === 0) {
ok('skipScan emptied the scan');
} else {
fail(`expected skipScan to empty the scan, got: ${JSON.stringify(skipped)}`);
}
await page.close();
} finally {
await browser.close().catch(() => {});
server.close();
}
process.exit(failures ? 1 : 0);