Files
pbakaus_impeccable/crates/html/tests/selector_ignores.rs
T
Paul BakausandClaude Fable 5.1 1d214f1e48 Add detector.ignoreSelectors: one component-level opt-out instead of an attribute per instance
An author who wants a rule off for one component has had two choices: put
`data-impeccable-ignore` on every instance, or silence the rule (or the
file) for the whole project. On impeccable-site #34 that meant eleven
attributes on eleven copies of the same 10px label for `undersized-ui-text`,
25 opt-out attributes in all. The count is the problem: the markup carries
noise, and the reviewer never sees how much was waived.

`detector.ignoreSelectors` is the declared twin of that attribute. One entry,
`{ rule, selector }`, waives the rule for every element the selector matches
and for that element's subtree, the same waiver the attribute grants the
element carrying it:

    impeccable ignores add-selector undersized-ui-text ".ks-tag" \
      --reason "10px mono index labels, confirmed"

The waiver is never silent. The engines stamp a waived finding with
`ignoredBy: "<selector>"` instead of dropping it; the config layer drops and
counts, and every scan prints one line per entry on stderr, in `--json` runs
too, so stdout stays the findings array:

    3 undersized-ui-text hits ignored by detector.ignoreSelectors on .ks-tag.

Where it applies: the browser engine (`BrowserConfig.ignoreSelectors`, also
readable from `window.__IMPECCABLE_CONFIG__`), the static HTML engine
(`DetectHtmlOptions.ignore_selectors`, and the `ignoreSelectors` option of
the wasm `detect_html_source_json` export), the detect CLI, and the design
hook. The text engine has no DOM and ignores the key. Entries can be scoped
with `files` globs like `ignoreValues`; `--no-config` disables them; `doctor`
validates their rule ids alongside `ignoreRules`. Nothing changes for a
project without the key: the engines stamp nothing, the CLI prints nothing,
the config writer does not add an empty `ignoreSelectors`, and the
per-instance attribute keeps working exactly as before.

Coverage: `crates/html/tests/selector_ignores.rs` (component, subtree,
wrong-rule, `*`, attribute parity), driver tests over the fake DOM,
`crates/detect` config tests (normalize, merge, per-target narrowing, the
tally), and oracle cases `detect-selector-ignore-*` / `ignores-selector-*`
over a new workspace. The eight re-recorded context/doctor goldens differ
only in the recognized-detector-keys sentence, which now lists the new key.

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:22:16 -07:00

116 lines
4.1 KiB
Rust

//! Component-level opt-outs in the static engine: one `{ rule, selector }`
//! entry stands in for the same `data-impeccable-ignore` attribute repeated
//! on every instance of a component, and the waived findings come back
//! stamped so a caller can count them.
use impeccable_core::findings::{ignored_by, Finding};
use impeccable_core::selector_ignores::SelectorIgnore;
use impeccable_html::{detect_html_source, DetectHtmlOptions};
use std::path::Path;
/// Three instances of one 10px mono label, the shape that earned eleven
/// attributes on impeccable-site #34.
const PAGE: &str = r#"<!doctype html>
<html><head><style>
.ks-tag { font-family: ui-monospace, monospace; font-size: 10px; }
.free-label { font-size: 10px; }
body { font-family: system-ui; font-size: 16px; }
</style></head>
<body>
<h1>Worlds</h1>
<p>Body copy long enough to read like a real paragraph on a real page somewhere.</p>
<span class="ks-tag">01 - Explore directions</span>
<span class="ks-tag">02 - See one built</span>
<span class="ks-tag">03 - Third label</span>
<span class="free-label">04 - Not part of the component</span>
</body></html>
"#;
fn scan(html: &str, entries: &[SelectorIgnore]) -> Vec<Finding> {
let opts = DetectHtmlOptions {
ignore_selectors: entries,
..DetectHtmlOptions::default()
};
detect_html_source(html, Path::new("/nonexistent/dir/page.html"), &opts)
}
fn undersized(findings: &[Finding]) -> Vec<&Finding> {
findings
.iter()
.filter(|f| f.antipattern == "undersized-ui-text")
.collect()
}
#[test]
fn one_entry_covers_every_instance_of_the_component() {
let before = scan(PAGE, &[]);
let hits = undersized(&before);
assert_eq!(hits.len(), 4, "fixture should flag all four labels");
assert!(hits.iter().all(|f| ignored_by(f).is_none()));
let after = scan(
PAGE,
&[SelectorIgnore::new("undersized-ui-text", ".ks-tag")],
);
let hits = undersized(&after);
assert_eq!(hits.len(), 4, "waived findings are stamped, not dropped");
let waived: Vec<&&Finding> = hits
.iter()
.filter(|f| ignored_by(f) == Some(".ks-tag"))
.collect();
assert_eq!(waived.len(), 3, "the three component instances are waived");
// The label outside the component is untouched, and so is every other rule.
let free: Vec<&&Finding> = hits.iter().filter(|f| ignored_by(f).is_none()).collect();
assert_eq!(free.len(), 1);
assert!(free[0].snippet.contains("Not part of the component"));
assert!(after
.iter()
.filter(|f| f.antipattern != "undersized-ui-text")
.all(|f| ignored_by(f).is_none()));
}
#[test]
fn an_entry_for_another_rule_waives_nothing() {
let after = scan(PAGE, &[SelectorIgnore::new("side-tab", ".ks-tag")]);
assert!(undersized(&after).iter().all(|f| ignored_by(f).is_none()));
}
#[test]
fn a_star_entry_waives_every_rule_on_the_component() {
let after = scan(PAGE, &[SelectorIgnore::new("*", ".ks-tag")]);
let waived = undersized(&after)
.iter()
.filter(|f| ignored_by(f).is_some())
.count();
assert_eq!(waived, 3);
}
#[test]
fn the_entry_covers_the_components_subtree() {
let html = r#"<!doctype html><html><head><style>
.ks-tag { font-size: 10px; }
</style></head><body><h1>Title</h1>
<p>Body copy long enough to read like a real paragraph on a real page somewhere.</p>
<span class="ks-tag">outer <b>inner label text</b></span>
</body></html>"#;
let after = scan(html, &[SelectorIgnore::new("undersized-ui-text", ".ks-tag")]);
assert!(
undersized(&after)
.iter()
.all(|f| ignored_by(f) == Some(".ks-tag")),
"a finding on a descendant is waived with its component"
);
}
#[test]
fn the_per_instance_attribute_still_works() {
let html = PAGE.replace(
r#"<span class="ks-tag">01"#,
r#"<span class="ks-tag" data-impeccable-ignore="undersized-ui-text">01"#,
);
// Attribute-waived findings never reach the caller at all, which is the
// behavior that shipped; the config entry is the countable alternative.
let after = scan(&html, &[]);
assert_eq!(undersized(&after).len(), 3);
}