Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -461,13 +461,16 @@ npx impeccable detect --no-config src/ # raw scan, ignoring project config
npx impeccable ignores list # show detector ignores
npx impeccable ignores add-file "src/legacy/**"
npx impeccable ignores add-value overused-font Inter --reason "Brand font"
npx impeccable ignores add-selector undersized-ui-text ".ks-tag" --reason "10px mono label, by design"
```

The detector catches 61 deterministic issues across AI slop (side-tab borders, purple gradients, bounce easing, dark glows) and general design quality (line length, cramped padding, small touch targets, skipped headings, and more).

Human-readable findings are diagnostics written to stderr, so redirect them with `2> findings.txt`. Use `--json` for machine-readable results on stdout. Exit `0` means the scan completed without primary findings, exit `2` means it completed with primary findings, and exit `1` means at least one requested target could not be scanned; operational failure takes precedence for a partial multi-target scan. URL scans inspect the rendered DOM, computed layout, and accessible linked stylesheets; browser security still prevents reading cross-origin CSS without CORS. A clean detector run is evidence, not proof of visual or accessibility quality: it does not replace inspecting the rendered experience across relevant viewports.

By default, `detect` respects the same `.impeccable/config.json` and `.impeccable/config.local.json` detector config as the design hook: `detector.ignoreRules`, `detector.ignoreFiles`, `detector.ignoreValues`, and `detector.designSystem.enabled`. Hook lifecycle settings such as `hook.enabled` only affect automatic hook execution.
By default, `detect` respects the same `.impeccable/config.json` and `.impeccable/config.local.json` detector config as the design hook: `detector.ignoreRules`, `detector.ignoreFiles`, `detector.ignoreValues`, `detector.ignoreSelectors`, and `detector.designSystem.enabled`. Hook lifecycle settings such as `hook.enabled` only affect automatic hook execution.

`detector.ignoreSelectors` is the component-level opt-out. One entry, written by `ignores add-selector <rule> "<selector>"`, waives that rule for every element the CSS selector matches and for that element's subtree, so a component with eleven instances takes one line of config instead of eleven `data-impeccable-ignore` attributes in the markup. What it suppressed is never silent: each scan prints a line per entry on stderr, `3 undersized-ui-text hits ignored by detector.ignoreSelectors on .ks-tag.`, in JSON mode too, so a reviewer sees the count next to the findings.

For a waiver that should travel with one file instead of the repo config, add an inline comment in the file: `<!-- impeccable-disable overused-font: exported brand doc -->`. The marker works in any comment syntax, scopes to the whole file (or one line with `impeccable-disable-line` / `impeccable-disable-next-line`), and is bypassed by `--no-inline-ignores` or `--no-config`.

Expand Down
15 changes: 15 additions & 0 deletions crates/browser/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,9 @@ struct RawResult {
snippet: String,
ignore_value: String,
severity: String,
/// The `detector.ignoreSelectors` selector that waived this finding, when
/// one did. Empty for everything else.
ignored_by: String,
}

fn cdp_err(e: CdpError) -> EngineError {
Expand Down Expand Up @@ -384,6 +387,12 @@ fn detect_url_impl(
item.extras
.insert("ignoreValue".into(), Value::String(r.ignore_value));
}
if !r.ignored_by.is_empty() {
item.extras.insert(
impeccable_core::findings::IGNORED_BY_KEY.into(),
Value::String(r.ignored_by),
);
}
if !r.severity.is_empty() && r.severity != item.severity {
item.severity = r.severity;
}
Expand Down Expand Up @@ -459,6 +468,7 @@ fn scan_page_inner(
let config = snapshot_engine::browser_config(
serialize_design_system_for_browser(options.design_system.as_deref()),
options.rule_pack,
options.ignore_selectors.clone(),
);

// Deterministic pass: capture the page and run the rule core natively over
Expand Down Expand Up @@ -486,6 +496,7 @@ fn scan_page_inner(
id: js_str(f.get("type")),
snippet: js_str(f.get("detail")),
ignore_value: js_str_or_empty(f.get("ignoreValue")),
ignored_by: js_str_or_empty(f.get("ignoredBy")),
severity: js_str_or_empty(f.get("severity")),
});
}
Expand Down Expand Up @@ -515,6 +526,7 @@ fn scan_page_inner(
id: f.id,
snippet: f.snippet,
ignore_value: String::new(),
ignored_by: String::new(),
severity: String::new(),
})
.collect(),
Expand All @@ -527,6 +539,7 @@ fn scan_page_inner(
id: "script-error".to_string(),
snippet: message,
ignore_value: String::new(),
ignored_by: String::new(),
severity: String::new(),
});
}
Expand Down Expand Up @@ -603,6 +616,7 @@ fn run_visual_contrast_fallback(
id: js_str(f.get("id")),
snippet: js_str(f.get("snippet")),
ignore_value: String::new(),
ignored_by: String::new(),
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated
Comment thread
pbakaus marked this conversation as resolved.
Outdated
severity: String::new(),
})
.collect();
Expand Down Expand Up @@ -648,6 +662,7 @@ fn run_visual_contrast_fallback(
id: f.id.to_string(),
snippet: f.snippet,
ignore_value: String::new(),
ignored_by: String::new(),
severity: String::new(),
}]
})
Expand Down
2 changes: 2 additions & 0 deletions crates/browser/src/snapshot_engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -186,11 +186,13 @@ pub fn resolve_needs<T>(
pub fn browser_config(
design_system: Value,
rule_pack: Option<&'static dyn impeccable_core::rule_pack::RulePack>,
ignore_selectors: Vec<impeccable_core::selector_ignores::SelectorIgnore>,
) -> BrowserConfig {
BrowserConfig {
extension_mode: false,
disabled_rules: Vec::new(),
disabled_values: Vec::new(),
ignore_selectors,
skip_scan: false,
design_system: if design_system.is_null() {
None
Expand Down
9 changes: 8 additions & 1 deletion crates/context/src/staleness.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,14 @@ const KNOWN_CONFIG_KEYS: [&str; 8] =
["hook", "detector", "updateCheck", "stalenessCheck", "projectRoots", "buildPath", "$schema", "version"];
const BUILD_PATH_VALUES: [&str; 2] = ["comp", "code"];
const DIRECTION_WORK_PATHS: [&str; 2] = [".impeccable/surfaces", ".impeccable/mocks/decision"];
const KNOWN_DETECTOR_KEYS: [&str; 5] = ["ignoreRules", "ignoreFiles", "ignoreValues", "designSystem", "extensions"];
const KNOWN_DETECTOR_KEYS: [&str; 6] = [
"ignoreRules",
"ignoreFiles",
"ignoreValues",
"ignoreSelectors",
"designSystem",
"extensions",
];

struct NativeEvidence {
platform: &'static str,
Expand Down
31 changes: 25 additions & 6 deletions crates/context/src/staleness_deep.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,12 +141,31 @@ pub fn check_detector_ignores(project_root: &str, known_rule_ids: Option<&[Strin
continue;
}
let rel = to_relative(Some(&fp), project_root).unwrap();
if let (Some(known), Some(rules)) = (known_rule_ids, detector.get("ignoreRules").and_then(|v| v.as_array())) {
let unknown: Vec<String> = rules
.iter()
.map(|r| js_trim(&js_string_or_empty(r)).to_lowercase())
.filter(|r| !r.is_empty() && r != "*" && !known.contains(r))
.collect();
if let Some(known) = known_rule_ids {
let rules = detector
.get("ignoreRules")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
// A component ignore names a rule too, and a typo there is the
// same dead entry: it waives nothing and nobody hears about it.
let selector_rules: Vec<Value> = detector
.get("ignoreSelectors")
.and_then(|v| v.as_array())
.map(|list| {
list.iter()
.filter_map(|e| e.get("rule").cloned())
.collect()
})
.unwrap_or_default();
let mut unknown: Vec<String> = Vec::new();
for r in rules.iter().chain(selector_rules.iter()) {
let id = js_trim(&js_string_or_empty(r)).to_lowercase();
if id.is_empty() || id == "*" || known.contains(&id) || unknown.contains(&id) {
continue;
}
unknown.push(id);
}
if !unknown.is_empty() {
out.push(finding(
"detector-ignore-rules-unknown",
Expand Down
127 changes: 127 additions & 0 deletions crates/core/src/browser/driver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use super::dom::{tag_lower, Dom, ElId, Rect};
use super::element_checks::check_element_borders_dom;
use super::{BrowserConfig, BrowserFinding, DisabledValue, FindingGroup};
use crate::js_ext_a::JsMap;
use impeccable_foundation::selector_ignores::{waiving_selector, SelectorIgnore};
use serde::Serialize;

/// The collect result type is shared.
Expand Down Expand Up @@ -63,6 +64,41 @@ pub fn add_browser_findings(
}
}

/// Apply the project's component-level opt-outs (`detector.ignoreSelectors`)
/// to a collected group list.
///
/// The semantics are the attribute's: an entry waives its rule for every
/// element the selector matches and for that element's subtree, which is what
/// `element.closest(selector)` answers. Findings are stamped rather than
/// dropped, so the layer that owns the ignore list can report "N hits ignored
/// by config on `.ks-tag`" instead of quietly reporting nothing.
pub fn stamp_selector_ignores(
dom: &dyn Dom,
groups: &mut [FindingGroup],
entries: &[SelectorIgnore],
) {
if entries.is_empty() {
return;
}
for group in groups.iter_mut() {
// Handle 0 is JS null (a missing document.body): nothing to match.
if group.el == 0 {
continue;
}
for f in group.findings.iter_mut() {
if f.ignored_by.is_some() {
continue;
}
let el = group.el;
if let Some(selector) = waiving_selector(entries, &f.type_, |sel| {
matches!(dom.closest(el, sel), Ok(Some(_)))
}) {
f.ignored_by = Some(selector.to_string());
}
}
}
}

// ─── Design system (index.mjs) ──────────────────────────────────────────────

/// The `seen` sets `collectBrowserFindings` threads through the element loop
Expand Down Expand Up @@ -363,6 +399,7 @@ pub fn check_element_design_system_dom(
detail,
severity: None,
ignore_value: Some(value),
ignored_by: None,
};

if ds.has_fonts && browser_has_direct_text(dom, el) {
Expand Down Expand Up @@ -515,6 +552,7 @@ pub fn check_browser_design_system_sources(
),
severity: None,
ignore_value: Some(display),
ignored_by: None,
});
}
}
Expand Down Expand Up @@ -833,6 +871,12 @@ pub fn serialize_findings(dom: &dyn Dom, groups: &[FindingGroup]) -> serde_json:
"description".into(),
Value::String(ap.map(|a| a.description).unwrap_or("").to_string()),
);
// Only present when a detector.ignoreSelectors entry waived
Comment thread
cursor[bot] marked this conversation as resolved.
// this finding, so a scan without the feature serializes
// exactly what it always did.
if let Some(selector) = f.ignored_by.as_ref() {
m.insert("ignoredBy".into(), Value::String(selector.clone()));
}
Value::Object(m)
})
.collect();
Expand Down Expand Up @@ -1464,6 +1508,7 @@ pub fn collect_browser_findings(dom: &dyn Dom, config: &BrowserConfig) -> Collec
page_level.retain(|f| !browser_value_ignored(f, &disabled_values));
}

stamp_selector_ignores(dom, &mut groups, &config.ignore_selectors);
CollectResult { groups, page_level }
}

Expand Down Expand Up @@ -1871,6 +1916,88 @@ mod tests {
let pass = json!({ "status": "pass", "selector": "#t", "finding": null });
assert_eq!(visual_contrast_result_el(&d, &pass), None);
}

#[test]
fn config_selector_ignores_stamp_the_component_and_its_subtree() {
// The #34 shape: one component, many instances, one rule.
let mut d = FakeDom::new();
let (_h, body) = d.with_page();
let mut tags = Vec::new();
for _ in 0..3 {
let tag = d.add(Some(body), "span");
d.add_selector(tag, ".ks-tag");
tags.push(tag);
}
let inner = d.add(Some(tags[0]), "b");
let other = d.add(Some(body), "a");
d.add_selector(other, ".cta");

let mut groups: Vec<FindingGroup> = tags
.iter()
.chain([&inner, &other])
.map(|el| FindingGroup {
el: *el,
findings: vec![
BrowserFinding::new("undersized-ui-text", "10px functional text"),
BrowserFinding::new("wide-tracking", "letter-spacing: 0.08em"),
],
})
.collect();

let entries = vec![SelectorIgnore::new("undersized-ui-text", ".ks-tag")];
stamp_selector_ignores(&d, &mut groups, &entries);

// Three instances plus the descendant: waived, and each carries the
// selector that waived it rather than vanishing.
for g in groups.iter().take(4) {
assert_eq!(g.findings[0].ignored_by.as_deref(), Some(".ks-tag"));
// Only the named rule is waived.
assert_eq!(g.findings[1].ignored_by, None);
}
// An element outside the component keeps both findings clean.
assert_eq!(groups[4].findings[0].ignored_by, None);
assert_eq!(groups[4].findings[1].ignored_by, None);

// Serialization carries the stamp, and only when there is one.
let json = serialize_findings(&d, &groups);
let first = &json[0]["findings"][0];
assert_eq!(first["ignoredBy"], json!(".ks-tag"));
assert_eq!(json[0]["findings"][1].get("ignoredBy"), None);
}

#[test]
fn config_selector_ignores_are_off_without_entries() {
let mut d = FakeDom::new();
let (_h, body) = d.with_page();
let tag = d.add(Some(body), "span");
d.add_selector(tag, ".ks-tag");
let mut groups = vec![FindingGroup {
el: tag,
findings: vec![BrowserFinding::new("undersized-ui-text", "10px")],
}];
stamp_selector_ignores(&d, &mut groups, &[]);
assert_eq!(groups[0].findings[0].ignored_by, None);
// A `*` entry waives every rule on the component, as the attribute does.
stamp_selector_ignores(&d, &mut groups, &[SelectorIgnore::new("*", ".ks-tag")]);
assert_eq!(groups[0].findings[0].ignored_by.as_deref(), Some(".ks-tag"));
}

#[test]
fn browser_config_reads_ignore_selectors_from_the_page_config() {
let cfg: BrowserConfig = serde_json::from_str(
r#"{"ignoreSelectors":[{"rule":"Undersized-UI-Text","selector":".ks-tag"}]}"#,
)
.unwrap();
assert_eq!(cfg.ignore_selectors.len(), 1);
// Normalization is the constructor's job, not the parser's: the raw
// value round-trips and `covers_rule` folds case.
assert!(SelectorIgnore::new(&cfg.ignore_selectors[0].rule, ".ks-tag")
.covers_rule("undersized-ui-text"));
let bare: BrowserConfig = serde_json::from_str("{}").unwrap();
assert!(bare.ignore_selectors.is_empty());
// A config without the key serializes without it.
assert!(!serde_json::to_string(&bare).unwrap().contains("ignoreSelectors"));
}
}

#[cfg(test)]
Expand Down
1 change: 1 addition & 0 deletions crates/core/src/browser/element_checks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1309,6 +1309,7 @@ pub fn check_element_blinking_cursor_dom(dom: &dyn Dom, el: ElId) -> Vec<Browser
None
},
ignore_value: None,
ignored_by: None,
}]
}

Expand Down
2 changes: 1 addition & 1 deletion crates/core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ pub mod checks;

pub use impeccable_foundation::{
color, constants, fdlibm_trig, findings, fonts, inline_ignores, js, js_ext_a, js_ext_b, page,
registry, rule_pack,
registry, rule_pack, selector_ignores,
};

#[cfg(any(test, feature = "vectors"))]
Expand Down
Loading