Compare commits

...
Author SHA1 Message Date
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
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
51 changed files with 1474 additions and 69 deletions
+4 -1
View File
@@ -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`.
+5
View File
@@ -73,6 +73,11 @@ if (IS_BROWSER && !__impeccable) {
// applies them where the findings are assembled, because the overlay
// draws its markers from the collected findings.
disabledValues: Array.isArray(config.disabledValues) ? config.disabledValues : [],
// detector.ignoreSelectors: the project's component-level opt-outs,
// [{ rule, selector }]. The core waives a finding on any element the
// selector matches, and on its subtree, the way the
// data-impeccable-ignore attribute waives the element carrying it.
ignoreSelectors: Array.isArray(config.ignoreSelectors) ? config.ignoreSelectors : [],
designSystem: config.designSystem == null ? null : config.designSystem,
lineLengthMax: config.lineLengthMax == null ? null : config.lineLengthMax,
skipScan: config.skipScan === true,
+5
View File
@@ -111,6 +111,11 @@
extensionMode: true,
disabledRules: Array.isArray(config.disabledRules) ? config.disabledRules : [],
disabledValues: Array.isArray(config.disabledValues) ? config.disabledValues : [],
// detector.ignoreSelectors: the project's component-level opt-outs,
// [{ rule, selector }]. The core waives a finding on any element the
// selector matches, and on its subtree, the way the
// data-impeccable-ignore attribute waives the element carrying it.
ignoreSelectors: Array.isArray(config.ignoreSelectors) ? config.ignoreSelectors : [],
designSystem: config.designSystem == null ? null : config.designSystem,
lineLengthMax: config.lineLengthMax == null ? null : config.lineLengthMax,
skipScan: config.skipScan === true,
+54 -7
View File
@@ -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 {
@@ -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;
}
@@ -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
@@ -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")),
});
}
@@ -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(),
@@ -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(),
});
}
@@ -535,7 +548,24 @@ fn scan_page_inner(
snapshot_engine::analyze_visual_contrast(page, &base, 12.0, true)
})
.map_err(cdp_err)?;
let visual = run_visual_contrast_fallback(page, &analyses, &serialized_groups, viewport, profile, url)?;
// The visual pass produces findings outside `collect_browser_findings`,
// so the component-level opt-outs are applied here against the same
// post-reveal snapshot, keyed on each candidate's own selector.
let waive = |selector: &str, rule: &str| -> String {
use impeccable_core::browser::Dom as _;
if config.ignore_selectors.is_empty() || selector.is_empty() {
return String::new();
}
let Ok(Some(el)) = base.query_one(None, selector) else {
return String::new();
};
impeccable_core::selector_ignores::waiving_selector(&config.ignore_selectors, rule, |sel| {
matches!(base.closest(el, sel), Ok(Some(_)))
})
.unwrap_or_default()
.to_string()
};
let visual = run_visual_contrast_fallback(page, &analyses, &serialized_groups, viewport, profile, url, &waive)?;
results.extend(visual);
Ok(results)
}
@@ -566,6 +596,7 @@ fn reveal_sweep(page: &mut Page<'_>) -> Result<(), CdpError> {
/// target)`: the JS post-processing of the analytic/canvas analyses
/// (`analyzeVisualContrast`, computed natively in [`snapshot_engine`]) plus the
/// screenshot pixel fallback for candidates the analyses left unresolved.
#[allow(clippy::too_many_arguments)]
fn run_visual_contrast_fallback(
page: &mut Page<'_>,
browser_analyses: &[Value],
@@ -573,6 +604,9 @@ fn run_visual_contrast_fallback(
viewport: Viewport,
profile: Option<&DetectorProfile>,
target: &str,
// `(candidate selector, rule id) -> the detector.ignoreSelectors selector
// that waives it, or empty`.
waive: &dyn Fn(&str, &str) -> String,
) -> Result<Vec<RawResult>, EngineError> {
let existing_low_contrast: Vec<String> = serialized_groups
.iter()
@@ -598,12 +632,18 @@ fn run_visual_contrast_fallback(
.iter()
.any(|s| Some(s.as_str()) == r.get("selector").and_then(Value::as_str))
})
.filter_map(|r| r.get("finding"))
.map(|f| RawResult {
id: js_str(f.get("id")),
snippet: js_str(f.get("snippet")),
ignore_value: String::new(),
severity: String::new(),
.map(|r| {
let selector = r.get("selector").and_then(Value::as_str).unwrap_or("");
let f = r.get("finding").expect("filtered on a truthy finding");
let id = js_str(f.get("id"));
let ignored_by = waive(selector, &id);
RawResult {
id,
snippet: js_str(f.get("snippet")),
ignore_value: String::new(),
ignored_by,
severity: String::new(),
}
})
.collect();
@@ -635,6 +675,11 @@ fn run_visual_contrast_fallback(
})
.collect();
for candidate in filtered {
let candidate_selector = candidate
.get("selector")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let result = step_findings(profile, "visual-contrast", "pixel-diff", target, || {
let f = screenshot_contrast::capture_visual_contrast_candidate(
page,
@@ -644,10 +689,12 @@ fn run_visual_contrast_fallback(
.map_err(cdp_err)?;
Ok::<_, EngineError>(
f.map(|f| {
let ignored_by = waive(&candidate_selector, f.id);
vec![RawResult {
id: f.id.to_string(),
snippet: f.snippet,
ignore_value: String::new(),
ignored_by,
severity: String::new(),
}]
})
+2
View File
@@ -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
+8 -1
View File
@@ -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,
+25 -6
View File
@@ -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",
+139
View File
@@ -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.
@@ -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
@@ -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) {
@@ -515,6 +552,7 @@ pub fn check_browser_design_system_sources(
),
severity: None,
ignore_value: Some(display),
ignored_by: None,
});
}
}
@@ -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
// 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();
@@ -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 }
}
@@ -1871,6 +1916,100 @@ 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);
// The parser normalizes, so a page config written by hand still
// matches: the rule folds case, the selector keeps it.
assert_eq!(cfg.ignore_selectors[0].rule, "undersized-ui-text");
assert_eq!(cfg.ignore_selectors[0].selector, ".ks-tag");
let bare: BrowserConfig = serde_json::from_str("{}").unwrap();
assert!(bare.ignore_selectors.is_empty());
// A hand-edited entry of the wrong shape drops itself, never the whole
// config: `unwrap_or_default()` at the wasm boundary would otherwise
// lose the design system with it.
let junk: BrowserConfig = serde_json::from_str(
r#"{"lineLengthMax":90,"ignoreSelectors":[{},null,"nope",{"rule":"side-tab"},{"rule":"side-tab","selector":".x"}]}"#,
)
.unwrap();
assert_eq!(junk.ignore_selectors.len(), 1);
assert_eq!(junk.line_max(), 90.0);
let not_a_list: BrowserConfig =
serde_json::from_str(r#"{"ignoreSelectors":"nope"}"#).unwrap();
assert!(not_a_list.ignore_selectors.is_empty());
// A config without the key serializes without it.
assert!(!serde_json::to_string(&bare).unwrap().contains("ignoreSelectors"));
}
}
#[cfg(test)]
@@ -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,
}]
}
+1 -1
View File
@@ -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"))]
+81 -4
View File
@@ -9,7 +9,8 @@ use impeccable_core::registry::{filter_by_scopes, rule_scopes};
use serde_json::Value;
use crate::config::{
filter_detection_findings, read_detection_config, should_ignore_detection_file, DetectionConfig,
filter_detection_findings_reported, read_detection_config, selector_ignores_for_target,
selector_ignores_for_url, should_ignore_detection_file, DetectionConfig, IgnoredBySelector,
};
use crate::design_system::{load_design_system_for_target, DesignSystemCache};
use crate::detect_text::{detect_text, TextOptions};
@@ -57,7 +58,16 @@ Exit status:
Project config:
Respects .impeccable/config.json and .impeccable/config.local.json detector
settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues,
and detector.designSystem.enabled.
detector.ignoreSelectors, and detector.designSystem.enabled.
Component ignores:
detector.ignoreSelectors waives one rule for every element a CSS selector
matches, and for that element's subtree: one entry for a component instead
of a data-impeccable-ignore attribute on each of its instances. Write one
with `impeccable ignores add-selector <rule> \"<selector>\"`. Every scan
prints what it suppressed on stderr, in --json runs too, so the exception
stays visible:
3 undersized-ui-text hits ignored by detector.ignoreSelectors on .ks-tag.
Inline ignores:
In-file comments waive a finding where it lives and travel with the file:
@@ -207,6 +217,33 @@ fn format_advisory_section(advisory: &[&Finding], stderr_tty: bool) -> String {
lines.join("\n")
}
/// The component-level opt-outs that fired on this run, one line each.
///
/// A `detector.ignoreSelectors` entry waives every instance of a component at
/// once, so the only way a reviewer learns it is there is for the scan to say
/// what it suppressed. Empty when the project has no such entry, or when the
/// entries it has matched nothing, so a scan is unchanged until the feature
/// is used.
pub fn format_ignored_by_selector(report: &[IgnoredBySelector], stderr_tty: bool) -> String {
if report.is_empty() {
return String::new();
}
let mut lines = Vec::with_capacity(report.len());
for r in report {
lines.push(dim(
&format!(
"{} {} hit{} ignored by detector.ignoreSelectors on {}.",
r.count,
r.rule,
if r.count == 1 { "" } else { "s" },
r.selector
),
stderr_tty,
));
}
lines.join("\n")
}
/// JS: main.mjs#formatFindings
pub fn format_findings(findings: &[Finding], json_mode: bool, stderr_tty: bool) -> String {
if json_mode {
@@ -251,6 +288,26 @@ impl<'a> Ctx<'a> {
}
fn scan_options_for(&mut self, local_path: Option<&str>) -> ScanOptions {
let mut options = self.design_system_options_for(local_path);
// Component-level opt-outs are per target: an entry with `files`
// governs the targets its globs match, an entry without governs all
// of them. `--no-config` leaves the list empty, so nothing is waived.
options.ignore_selectors =
selector_ignores_for_target(&self.config, local_path.unwrap_or_default());
options
}
/// The URL scan's options: no local design system to resolve, and only
/// the unscoped component ignores, since a `files` glob names repo paths
/// rather than URLs.
fn url_scan_options(&self) -> ScanOptions {
ScanOptions {
ignore_selectors: selector_ignores_for_url(&self.config),
..self.base.clone()
}
}
fn design_system_options_for(&mut self, local_path: Option<&str>) -> ScanOptions {
let (Some(local_path), true) = (local_path, self.design_system_enabled) else {
return self.base.clone();
};
@@ -507,6 +564,8 @@ fn detect_cli(args_in: &[String], io: &mut Io, engines: &Engines) -> Result<i32,
design_system: None,
viewport,
profile: None,
// Filled in per target: an ignoreSelectors entry can be scoped to files.
ignore_selectors: Vec::new(),
// The `impeccable` binary installs no rule pack; a library caller that
// does sets this before handing the options to an engine.
rule_pack: None,
@@ -587,7 +646,10 @@ fn detect_cli(args_in: &[String], io: &mut Io, engines: &Engines) -> Result<i32,
result?;
}
all = filter_detection_findings(all, &ctx.config);
// Findings the engines stamped with a component-level opt-out leave the
// reportable set here and come back as a count.
let ignored_by_selector;
(all, ignored_by_selector) = filter_detection_findings_reported(all, &ctx.config);
let scope_refs: Vec<&str> = scopes.iter().map(|s| s.as_str()).collect();
all = filter_by_scopes(all, &scope_refs, |f: &Finding| f.antipattern.as_str());
if no_advisory {
@@ -605,13 +667,22 @@ fn detect_cli(args_in: &[String], io: &mut Io, engines: &Engines) -> Result<i32,
} else {
0
};
// The ignored-by-config tally goes to stderr in every mode: in --json,
// stdout stays the findings array a consumer parses.
let ignored_note = format_ignored_by_selector(&ignored_by_selector, stderr_tty);
if !all.is_empty() {
if json_mode {
let text = format_findings(&all, true, stderr_tty);
ctx.io.out(&format!("{text}\n"));
if !ignored_note.is_empty() {
ctx.io.err(&format!("{ignored_note}\n"));
}
} else if quiet_mode {
ctx.io
.err(&format!("{}\n", format_finding_summary(primary_len)));
if !ignored_note.is_empty() {
ctx.io.err(&format!("{ignored_note}\n"));
}
if advisory_len > 0 {
let note = dim(
&format!(
@@ -625,12 +696,18 @@ fn detect_cli(args_in: &[String], io: &mut Io, engines: &Engines) -> Result<i32,
} else {
let text = format_findings(&all, false, stderr_tty);
ctx.io.err(&format!("{text}\n"));
if !ignored_note.is_empty() {
ctx.io.err(&format!("\n{ignored_note}\n"));
}
}
return Ok(exit_code);
}
if json_mode {
ctx.io.out("[]\n");
}
if !ignored_note.is_empty() {
ctx.io.err(&format!("{ignored_note}\n"));
}
Ok(exit_code)
}
@@ -676,7 +753,7 @@ fn scan_targets(
let local = file_url_to_local_path(target);
ctx.scan_options_for(local.as_deref())
} else {
ctx.base.clone()
ctx.url_scan_options()
};
let result = match (shared, ctx.engines.url) {
(Some(s), _) => s.detect_url(target, &url_options),
+359 -2
View File
@@ -5,6 +5,7 @@
use impeccable_core::findings::Finding;
use impeccable_core::js::{self, math_round, number_to_string, parse_float, parse_int};
use impeccable_core::selector_ignores::SelectorIgnore;
use once_cell::sync::Lazy;
use regex::Regex;
use serde_json::{Map, Value};
@@ -42,6 +43,7 @@ const DETECTOR_CONFIG_KEYS: &[&str] = &[
"ignoreRules",
"ignoreFiles",
"ignoreValues",
"ignoreSelectors",
"designSystem",
"advisoryRules",
];
@@ -78,6 +80,44 @@ impl IgnoreValueEntry {
}
}
/// One normalized `ignoreSelectors` entry: a component-level opt-out.
///
/// `{ rule, selector }` waives one rule for every element the selector
/// matches and for that element's subtree, which is the same waiver
/// `data-impeccable-ignore="<rule>"` grants the element that carries it. The
/// point is the count: eleven instances of one component take one entry here
/// instead of eleven attributes in the markup, and the engine reports what
/// the entry suppressed rather than staying silent about it.
#[derive(Debug, Clone, PartialEq, Default)]
pub struct IgnoreSelectorEntry {
pub rule: String,
pub selector: String,
pub files: Option<Vec<String>>,
pub created_at: Option<String>,
pub reason: Option<String>,
}
impl IgnoreSelectorEntry {
pub fn to_json(&self) -> Value {
let mut m = Map::new();
m.insert("rule".into(), Value::String(self.rule.clone()));
m.insert("selector".into(), Value::String(self.selector.clone()));
if let Some(files) = &self.files {
m.insert(
"files".into(),
Value::Array(files.iter().map(|f| Value::String(f.clone())).collect()),
);
}
if let Some(c) = &self.created_at {
m.insert("createdAt".into(), Value::String(c.clone()));
}
if let Some(r) = &self.reason {
m.insert("reason".into(), Value::String(r.clone()));
}
Value::Object(m)
}
}
/// The detector config object (`readDetectionConfig` / `readRawDetectionConfig`
/// result). `design_system` is `Some` when the JS object carries a
/// `designSystem` key.
@@ -86,6 +126,7 @@ pub struct DetectionConfig {
pub ignore_rules: Vec<String>,
pub ignore_files: Vec<String>,
pub ignore_values: Vec<IgnoreValueEntry>,
pub ignore_selectors: Vec<IgnoreSelectorEntry>,
pub design_system_enabled: Option<bool>,
pub advisory_rules: Option<String>,
}
@@ -131,6 +172,9 @@ fn apply_detection_config_source(config: &mut DetectionConfig, raw: Option<&Map<
if let Some(Value::Array(values)) = raw.get("ignoreValues") {
config.ignore_values = merge_ignore_values(&config.ignore_values, values);
}
if let Some(Value::Array(selectors)) = raw.get("ignoreSelectors") {
config.ignore_selectors = merge_ignore_selectors(&config.ignore_selectors, selectors);
}
}
fn unique_strings(values: Vec<String>) -> Vec<String> {
@@ -190,6 +234,22 @@ pub fn write_detection_config(
for (k, v) in normalize_detection_config_for_write(detector_config) {
next_detector.insert(k, v);
}
// `ignoreSelectors` is written only by a project that uses it, so a config
// that never opted into component-level ignores does not grow an empty
// key on the next `ignores add-rule`.
if !detector_config.ignore_selectors.is_empty()
|| next_detector.contains_key("ignoreSelectors")
{
next_detector.insert(
"ignoreSelectors".into(),
Value::Array(
normalize_ignore_selector_entries_typed(&detector_config.ignore_selectors)
.iter()
.map(IgnoreSelectorEntry::to_json)
.collect(),
),
);
}
let mut next = existing.clone();
next.insert("detector".into(), Value::Object(next_detector));
match next_hook {
@@ -643,6 +703,187 @@ fn merge_ignore_values(existing: &[IgnoreValueEntry], incoming: &[Value]) -> Vec
map.into_iter().map(|(_, e)| e).collect()
}
/// `normalizeIgnoreValueEntries`' twin for `ignoreSelectors`. The rule is
/// lowercased like every other rule id; the selector keeps its case (CSS
/// class names are case-sensitive) and only loses surrounding whitespace.
/// An entry missing either half is dropped: a selector ignore with no
/// selector would be `ignoreRules`, and one with no rule would be
/// `ignoreFiles` by another name.
pub fn normalize_ignore_selector_entries(entries: &[Value]) -> Vec<IgnoreSelectorEntry> {
let mut out = Vec::new();
for entry in entries {
let Value::Object(entry) = entry else {
continue;
};
let rule = normalize_ignore_rule(
&entry
.get("rule")
.map(js_string_or_empty)
.unwrap_or_default(),
);
let selector = js::trim(
&entry
.get("selector")
.map(js_string_or_empty)
.unwrap_or_default(),
)
.to_string();
if rule.is_empty() || selector.is_empty() {
continue;
}
let mut files: Vec<String> = Vec::new();
if let Some(Value::String(f)) = entry.get("file") {
if !js::trim(f).is_empty() {
files.push(js::trim(f).to_string());
}
}
if let Some(Value::Array(list)) = entry.get("files") {
for f in list {
if let Value::String(f) = f {
if !js::trim(f).is_empty() {
files.push(js::trim(f).to_string());
}
}
}
}
let files = unique_strings(files);
let mut normalized = IgnoreSelectorEntry {
rule,
selector,
files: if files.is_empty() { None } else { Some(files) },
created_at: None,
reason: None,
};
if let Some(Value::String(c)) = entry.get("createdAt") {
if !js::trim(c).is_empty() {
normalized.created_at = Some(js::trim(c).to_string());
}
}
if let Some(Value::String(r)) = entry.get("reason") {
if !js::trim(r).is_empty() {
normalized.reason = Some(js::trim(r).to_string());
}
}
out.push(normalized);
}
out
}
/// The same normalization over already-typed entries (idempotent on write).
pub fn normalize_ignore_selector_entries_typed(
entries: &[IgnoreSelectorEntry],
) -> Vec<IgnoreSelectorEntry> {
let raw: Vec<Value> = entries.iter().map(IgnoreSelectorEntry::to_json).collect();
normalize_ignore_selector_entries(&raw)
}
fn selector_entry_key(entry: &IgnoreSelectorEntry) -> String {
format!(
"{}\0{}\0{}",
entry.rule,
entry.selector,
ignore_value_files_key(entry.files.as_ref())
)
}
/// Merge raw `ignoreSelectors` JSON into an existing list, later entries
/// replacing earlier ones with the same rule + selector + files key. Shared
/// with the hook's own config reader.
pub fn merge_ignore_selectors(
existing: &[IgnoreSelectorEntry],
incoming: &[Value],
) -> Vec<IgnoreSelectorEntry> {
let mut map: Vec<(String, IgnoreSelectorEntry)> = Vec::new();
let mut set = |entry: IgnoreSelectorEntry| {
let key = selector_entry_key(&entry);
if let Some(slot) = map.iter_mut().find(|(k, _)| *k == key) {
slot.1 = entry;
} else {
map.push((key, entry));
}
};
for entry in normalize_ignore_selector_entries_typed(existing) {
set(entry);
}
for entry in normalize_ignore_selector_entries(incoming) {
set(entry);
}
map.into_iter().map(|(_, e)| e).collect()
}
/// The entries that govern one local scan target, as the engines take them.
///
/// An entry with no `files` covers every target. An entry with `files` covers
/// the paths its globs match, tested the way a scoped `ignoreValues` entry is
/// (raw path, then each `/`-suffix of it).
pub fn selector_ignores_for_target(
config: &DetectionConfig,
target: &str,
) -> Vec<SelectorIgnore> {
selector_ignores_filtered(config, |files| path_matches_scoped_globs(target, files))
}
/// The entries that govern a URL scan: the unscoped ones only.
///
/// `files` globs describe repo paths, and a URL is not one. Matching them
/// against the URL would let a glob like `index.html` reach
/// `https://example.com/index.html` by accident, scoping an ignore to a page
/// the entry never named.
pub fn selector_ignores_for_url(config: &DetectionConfig) -> Vec<SelectorIgnore> {
selector_ignores_filtered(config, |_| false)
}
fn selector_ignores_filtered(
config: &DetectionConfig,
covers: impl Fn(&[String]) -> bool,
) -> Vec<SelectorIgnore> {
normalize_ignore_selector_entries_typed(&config.ignore_selectors)
.into_iter()
.filter(|e| match &e.files {
Some(files) if !files.is_empty() => covers(files),
_ => true,
})
.map(|e| SelectorIgnore::new(&e.rule, &e.selector))
.collect()
}
/// One `(rule, selector)` pair and how many findings it waived on this run.
#[derive(Debug, Clone, PartialEq)]
pub struct IgnoredBySelector {
pub rule: String,
pub selector: String,
pub count: usize,
}
/// Split the findings the engines stamped with `ignoredBy` out of the
/// reportable set, counted by rule and selector in first-seen order. This is
/// what turns a component-level opt-out into a number a reviewer can read
/// instead of silence.
pub fn partition_selector_ignored(findings: Vec<Finding>) -> (Vec<Finding>, Vec<IgnoredBySelector>) {
let mut kept = Vec::with_capacity(findings.len());
let mut report: Vec<IgnoredBySelector> = Vec::new();
for f in findings {
match impeccable_core::findings::ignored_by(&f) {
Some(selector) => {
let rule = normalize_ignore_rule(&f.antipattern);
match report
.iter_mut()
.find(|r| r.rule == rule && r.selector == selector)
{
Some(slot) => slot.count += 1,
None => report.push(IgnoredBySelector {
rule,
selector: selector.to_string(),
count: 1,
}),
}
}
None => kept.push(f),
}
}
(kept, report)
}
fn escape_glob_char(c: char) -> bool {
matches!(
c,
@@ -766,8 +1007,24 @@ pub fn should_ignore_detection_file(file_path: &str, root: &str, config: &Detect
false
}
/// JS: impeccable-config.mjs#filterDetectionFindings
/// JS: impeccable-config.mjs#filterDetectionFindings, plus the
/// component-level opt-outs the engines stamped. Callers that want the count
/// of what a selector ignore suppressed use
/// [`filter_detection_findings_reported`].
pub fn filter_detection_findings(findings: Vec<Finding>, config: &DetectionConfig) -> Vec<Finding> {
filter_detection_findings_reported(findings, config).0
}
/// `filterDetectionFindings` with the selector-ignore tally alongside it.
pub fn filter_detection_findings_reported(
findings: Vec<Finding>,
config: &DetectionConfig,
) -> (Vec<Finding>, Vec<IgnoredBySelector>) {
let (findings, report) = partition_selector_ignored(findings);
(filter_by_rules_and_values(findings, config), report)
}
fn filter_by_rules_and_values(findings: Vec<Finding>, config: &DetectionConfig) -> Vec<Finding> {
if findings.is_empty() {
return vec![];
}
@@ -813,7 +1070,13 @@ fn is_ignored_finding_value(finding: &Finding, ignore_values: &[IgnoreValueEntry
}
fn finding_matches_scoped_ignore_file(finding: &Finding, globs: &[String]) -> bool {
let file_path = js::trim(&finding.file);
path_matches_scoped_globs(&finding.file, globs)
}
/// JS `findingMatchesScopedIgnoreFile`'s path test: the raw path, then every
/// `/`-suffix of it, so `src/a.css` is matched by `a.css` too.
fn path_matches_scoped_globs(path: &str, globs: &[String]) -> bool {
let file_path = js::trim(path);
if file_path.is_empty() {
return false;
}
@@ -1121,4 +1384,98 @@ mod tests {
assert_eq!(decode_uri_component("Open%20Sans"), "Open Sans");
assert_eq!(decode_uri_component("bad%zz"), "bad%zz");
}
fn config_with_selectors(raw: &str) -> DetectionConfig {
let mut config = DetectionConfig::with_defaults();
let parsed: Value = serde_json::from_str(raw).unwrap();
apply_detection_config_source(&mut config, parsed.as_object());
config
}
#[test]
fn ignore_selectors_parse_normalize_and_merge() {
let config = config_with_selectors(
r#"{"ignoreSelectors":[
{"rule":"Undersized-UI-Text","selector":" .ks-tag ","reason":"by design"},
{"rule":"","selector":".x"},
{"rule":"side-tab"},
"nope",
{"rule":"undersized-ui-text","selector":".ks-tag","reason":"second word wins"},
{"rule":"glow-effect","selector":".demo","files":["src/demo/**"," "]}
]}"#,
);
// Half-entries and junk are dropped, and the same rule+selector+files
// key is one entry the later value replaces.
assert_eq!(config.ignore_selectors.len(), 2);
let first = &config.ignore_selectors[0];
assert_eq!(first.rule, "undersized-ui-text");
assert_eq!(first.selector, ".ks-tag");
assert_eq!(first.reason.as_deref(), Some("second word wins"));
assert_eq!(
config.ignore_selectors[1].files.as_deref(),
Some(["src/demo/**".to_string()].as_slice())
);
}
#[test]
fn selector_ignores_are_narrowed_per_target() {
let config = config_with_selectors(
r#"{"ignoreSelectors":[
{"rule":"undersized-ui-text","selector":".ks-tag"},
{"rule":"glow-effect","selector":".demo","files":["src/demo/**"]}
]}"#,
);
let everywhere = selector_ignores_for_target(&config, "src/pages/index.astro");
assert_eq!(everywhere.len(), 1);
assert_eq!(everywhere[0].selector, ".ks-tag");
let scoped = selector_ignores_for_target(&config, "src/demo/playground.astro");
assert_eq!(scoped.len(), 2);
// A URL scan is covered by the unscoped entries only: a `files` glob
// describes repo paths, and must not reach a URL path that happens to
// end the same way.
assert_eq!(selector_ignores_for_url(&config).len(), 1);
let url_globs = config_with_selectors(
r#"{"ignoreSelectors":[
{"rule":"glow-effect","selector":".demo","files":["index.html"]}
]}"#,
);
assert!(selector_ignores_for_url(&url_globs).is_empty());
assert_eq!(
selector_ignores_for_target(&url_globs, "src/index.html").len(),
1
);
// `--no-config` leaves the list empty, so nothing is waived.
assert!(selector_ignores_for_target(&DetectionConfig::raw(), "a.html").is_empty());
}
#[test]
fn stamped_findings_leave_the_reportable_set_as_a_count() {
let stamp = |rule: &str, selector: Option<&str>| {
impeccable_core::findings::stamp_ignored_by(
impeccable_core::findings::finding(rule, "a.html", "snip", 0.0),
selector,
)
};
let findings = vec![
stamp("undersized-ui-text", Some(".ks-tag")),
stamp("undersized-ui-text", Some(".ks-tag")),
stamp("side-tab", Some(".ks-tag")),
stamp("undersized-ui-text", None),
];
let (kept, report) =
filter_detection_findings_reported(findings, &DetectionConfig::with_defaults());
assert_eq!(kept.len(), 1);
assert_eq!(report.len(), 2);
assert_eq!(report[0].rule, "undersized-ui-text");
assert_eq!(report[0].selector, ".ks-tag");
assert_eq!(report[0].count, 2);
assert_eq!(report[1].count, 1);
// Nothing stamped, nothing reported.
let (kept, report) = filter_detection_findings_reported(
vec![stamp("side-tab", None)],
&DetectionConfig::with_defaults(),
);
assert_eq!(kept.len(), 1);
assert!(report.is_empty());
}
}
+5
View File
@@ -7,6 +7,7 @@ use std::rc::Rc;
use impeccable_core::findings::Finding;
use impeccable_core::rule_pack::RulePack;
use impeccable_core::selector_ignores::SelectorIgnore;
use crate::design_system::DesignSystem;
use crate::profiler::DetectorProfile;
@@ -23,6 +24,10 @@ pub struct ScanOptions {
pub viewport: Option<(u32, u32)>,
/// JS `options.profile` (library callers only; no CLI flag).
pub profile: Option<Rc<DetectorProfile>>,
/// The project's component-level opt-outs for this target
/// (`detector.ignoreSelectors`, narrowed to the entries whose `files`
/// globs cover it). Empty under `--no-config`.
pub ignore_selectors: Vec<SelectorIgnore>,
/// The installed rule pack (`impeccable_core::rule_pack`), passed through
/// to the text engine and on to the HTML engine. `None` in the `impeccable`
/// binary, which ships the built-in rules only.
+204 -14
View File
@@ -8,7 +8,7 @@ use impeccable_core::js;
use crate::config::{
get_config_path, get_local_config_path, normalize_ignore_value, read_detection_config,
read_raw_detection_config, synthetic_ignore_value, write_detection_config, DetectionConfig,
IgnoreValueEntry,
IgnoreSelectorEntry, IgnoreValueEntry,
};
use crate::jsp;
@@ -21,9 +21,11 @@ Actions:
add-rule <rule> [--all-values] Ignore a rule
add-file <glob> Ignore files by glob
add-value <rule> <value> Ignore one rule/value pair
add-selector <rule> <selector> Ignore one rule on a component, everywhere
remove-rule <rule> Remove a rule ignore
remove-file <glob> Remove a file ignore
remove-value <rule> <value> Remove a rule/value ignore
remove-selector <rule> <selector> Remove a component ignore
clear Clear detector ignores in the selected scope
Scope:
@@ -32,14 +34,22 @@ Scope:
--all For remove/clear, apply to shared and local
Value options:
--file <glob> Scope add-value/remove-value to a file glob
--reason <text> Store or update a reason on add-value
--file <glob> Scope add-value/add-selector to a file glob
--reason <text> Store or update a reason on add-value/add-selector
Component ignores (add-selector) waive one rule for every element a CSS
selector matches, and for that element's subtree. One entry replaces the
same data-impeccable-ignore attribute repeated on every instance of a
component, and the scan reports how many hits it suppressed instead of
going quiet.
Examples:
impeccable ignores add-file \"src/legacy/**\"
impeccable ignores add-value overused-font Inter --reason \"Brand font\"
impeccable ignores add-value design-system-color \"*\" --file \"src/demo.css\"
impeccable ignores add-selector undersized-ui-text \".ks-tag\" --reason \"10px mono label, by design\"
impeccable ignores remove-value overused-font Inter
impeccable ignores remove-selector undersized-ui-text \".ks-tag\"
";
fn action_for(arg: &str) -> Option<&'static str> {
@@ -48,9 +58,11 @@ fn action_for(arg: &str) -> Option<&'static str> {
"add-rule" | "ignore-rule" => "add-rule",
"add-file" | "ignore-file" => "add-file",
"add-value" | "ignore-value" | "update-value" => "add-value",
"add-selector" | "ignore-selector" | "update-selector" => "add-selector",
"remove-rule" | "rm-rule" => "remove-rule",
"remove-file" | "rm-file" => "remove-file",
"remove-value" | "rm-value" => "remove-value",
"remove-selector" | "rm-selector" => "remove-selector",
"clear" => "clear",
_ => return None,
})
@@ -191,6 +203,27 @@ fn format_values(values: &[IgnoreValueEntry]) -> String {
.join(", ")
}
fn format_selectors(entries: &[IgnoreSelectorEntry]) -> String {
if entries.is_empty() {
return "(none)".to_string();
}
entries
.iter()
.map(|e| {
let file_suffix = match &e.files {
Some(f) if !f.is_empty() => format!(" [{}]", f.join(", ")),
_ => String::new(),
};
let reason_suffix = match &e.reason {
Some(r) if !r.is_empty() => format!(" - {r}"),
_ => String::new(),
};
format!("{} on {}{file_suffix}{reason_suffix}", e.rule, e.selector)
})
.collect::<Vec<_>>()
.join(", ")
}
fn format_config(label: &str, config: &DetectionConfig) -> String {
let none_or = |v: &[String]| {
if v.is_empty() {
@@ -199,21 +232,29 @@ fn format_config(label: &str, config: &DetectionConfig) -> String {
v.join(", ")
}
};
[
let mut lines = vec![
format!("{label}:"),
format!(" ignoreRules: {}", none_or(&config.ignore_rules)),
format!(" ignoreFiles: {}", none_or(&config.ignore_files)),
format!(" ignoreValues: {}", format_values(&config.ignore_values)),
format!(
" designSystem: {}",
if config.design_system_enabled == Some(false) {
"disabled"
} else {
"enabled"
}
),
]
.join("\n")
];
// Listed only where the project uses component ignores, so the familiar
// four-line block is unchanged for everyone else.
if !config.ignore_selectors.is_empty() {
lines.push(format!(
" ignoreSelectors: {}",
format_selectors(&config.ignore_selectors)
));
}
lines.push(format!(
" designSystem: {}",
if config.design_system_enabled == Some(false) {
"disabled"
} else {
"enabled"
}
));
lines.join("\n")
}
fn rel_or_abs(cwd: &str, target: &str) -> String {
@@ -426,6 +467,140 @@ fn add_value(cwd: &str, args: &[String]) -> R<String> {
))
}
struct SelectorArgs {
rule: String,
selector: String,
files: Vec<String>,
reason: String,
}
/// `add-selector <rule> <selector...> [--file <glob>]... [--reason <text...>]`.
/// The selector keeps its case and its internal spacing (`.card .ks-tag` is a
/// descendant selector, not two arguments), so positionals after the rule are
/// joined rather than normalized the way an ignore value is.
fn parse_selector_args(args: &[String]) -> R<SelectorArgs> {
let mut positionals: Vec<String> = Vec::new();
let mut files: Vec<String> = Vec::new();
let mut reason = String::new();
let mut i = 0;
while i < args.len() {
let arg = args[i].as_str();
if arg == "--reason" {
let mut chunks = Vec::new();
while i + 1 < args.len() && !args[i + 1].starts_with("--") {
i += 1;
chunks.push(args[i].clone());
}
reason = js::trim(&chunks.join(" ")).to_string();
} else if let Some(v) = arg.strip_prefix("--reason=") {
reason = js::trim(v).to_string();
} else if arg == "--file" || arg == "--files" {
if i + 1 >= args.len() {
return Err(format!("{arg} requires a glob"));
}
i += 1;
files.push(require_glob(&args[i], arg)?);
} else if let Some(v) = arg.strip_prefix("--file=") {
files.push(require_glob(v, "--file")?);
} else if let Some(v) = arg.strip_prefix("--files=") {
files.push(require_glob(v, "--files")?);
} else if arg.starts_with("--") {
return Err(format!("Unknown add-selector flag: {arg}"));
} else {
positionals.push(arg.to_string());
}
i += 1;
}
let rule = js::to_lower_case(js::trim(
positionals.first().map(String::as_str).unwrap_or(""),
));
let selector = js::trim(&positionals.get(1..).unwrap_or(&[]).join(" ")).to_string();
if rule.is_empty() || selector.is_empty() {
return Err(
"Pass a rule id and a CSS selector, e.g. impeccable ignores add-selector undersized-ui-text \".ks-tag\""
.to_string(),
);
}
if selector == "*" {
return Err("A `*` selector waives the rule everywhere. Use add-rule for that, or name the component's selector.".to_string());
}
let mut scoped: Vec<String> = Vec::new();
for f in files.into_iter().filter(|f| !f.is_empty()) {
if !scoped.contains(&f) {
scoped.push(f);
}
}
scoped.sort();
Ok(SelectorArgs {
rule,
selector,
files: scoped,
reason,
})
}
fn selector_key(rule: &str, selector: &str, files: &[String]) -> String {
let mut sorted = files.to_vec();
sorted.sort();
format!(
"{}\0{}\0{}",
js::to_lower_case(js::trim(rule)),
js::trim(selector),
sorted.join("\u{1f}")
)
}
fn selector_entry_key(e: &IgnoreSelectorEntry) -> String {
selector_key(
&e.rule,
&e.selector,
e.files.as_deref().unwrap_or_default(),
)
}
fn add_selector(cwd: &str, args: &[String]) -> R<String> {
let scope = parse_scope(args, false)?;
let parsed = parse_selector_args(&scope.rest)?;
let mut config = read_raw_detection_config(cwd, scope.local);
let key = selector_key(&parsed.rule, &parsed.selector, &parsed.files);
if let Some(existing) = config
.ignore_selectors
.iter_mut()
.find(|e| selector_entry_key(e) == key)
{
if !parsed.reason.is_empty() {
existing.reason = Some(parsed.reason.clone());
}
if !parsed.files.is_empty() {
existing.files = Some(parsed.files.clone());
}
} else {
config.ignore_selectors.push(IgnoreSelectorEntry {
rule: parsed.rule.clone(),
selector: parsed.selector.clone(),
files: if parsed.files.is_empty() {
None
} else {
Some(parsed.files.clone())
},
created_at: Some(iso_now()),
reason: if parsed.reason.is_empty() {
None
} else {
Some(parsed.reason.clone())
},
});
}
let target = write_scope(cwd, &config, scope.local)?;
Ok(format!(
"Added {} on {} to {} detector ignoreSelectors ({}).",
parsed.rule,
parsed.selector,
if scope.local { "local" } else { "shared" },
rel_or_abs(cwd, &target)
))
}
fn remove_from_scopes(
cwd: &str,
args: &[String],
@@ -493,6 +668,18 @@ fn remove_value(cwd: &str, args: &[String]) -> R<String> {
})
}
fn remove_selector(cwd: &str, args: &[String]) -> R<String> {
remove_from_scopes(cwd, args, |config, rest| {
let parsed = parse_selector_args(rest)?;
let key = selector_key(&parsed.rule, &parsed.selector, &parsed.files);
let before = config.ignore_selectors.len();
config
.ignore_selectors
.retain(|e| selector_entry_key(e) != key);
Ok(before - config.ignore_selectors.len())
})
}
fn clear(cwd: &str, args: &[String]) -> R<String> {
let scope = parse_scope(args, true)?;
if !scope.rest.is_empty() {
@@ -508,6 +695,7 @@ fn clear(cwd: &str, args: &[String]) -> R<String> {
config.ignore_rules.clear();
config.ignore_files.clear();
config.ignore_values.clear();
config.ignore_selectors.clear();
write_scope(cwd, &config, is_local)?;
}
Ok(format!(
@@ -547,9 +735,11 @@ pub fn run(args: &[String], io: &mut Io) -> i32 {
"add-rule" => add_rule(&cwd, &rest),
"add-file" => add_file(&cwd, &rest),
"add-value" => add_value(&cwd, &rest),
"add-selector" => add_selector(&cwd, &rest),
"remove-rule" => remove_rule(&cwd, &rest),
"remove-file" => remove_file(&cwd, &rest),
"remove-value" => remove_value(&cwd, &rest),
"remove-selector" => remove_selector(&cwd, &rest),
_ => clear(&cwd, &rest),
};
match out {
+55
View File
@@ -39,6 +39,17 @@ pub struct BrowserFinding {
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 {
@@ -48,6 +59,7 @@ impl BrowserFinding {
detail: detail.into(),
severity: None,
ignore_value: None,
ignored_by: None,
}
}
/// `{ type: f.id, detail: f.snippet }` from a Section 3 hit.
@@ -127,6 +139,35 @@ where
.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)]
@@ -145,6 +186,20 @@ pub struct BrowserConfig {
/// 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.
+36
View File
@@ -84,6 +84,31 @@ pub fn finding(id: &str, file_path: &str, snippet: &str, line: f64) -> Finding {
.unwrap_or_else(|| panic!("finding(): unknown antipattern id {id:?}"))
}
/// The extras key an engine stamps on a finding a component-level opt-out
/// (`detector.ignoreSelectors`) waived, carrying the selector that waived it.
/// The finding still travels; the config layer drops and counts it.
pub const IGNORED_BY_KEY: &str = "ignoredBy";
/// Stamp `ignoredBy` when a selector waived this finding. `None` leaves the
/// finding untouched, so nothing changes for a project without the config.
pub fn stamp_ignored_by(mut finding: Finding, selector: Option<&str>) -> Finding {
if let Some(selector) = selector.filter(|s| !s.is_empty()) {
finding.extras.insert(
IGNORED_BY_KEY.to_string(),
Value::String(selector.to_string()),
);
}
finding
}
/// The selector that waived this finding, when one did.
pub fn ignored_by(finding: &Finding) -> Option<&str> {
match finding.extras.get(IGNORED_BY_KEY) {
Some(Value::String(s)) if !s.is_empty() => Some(s.as_str()),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -107,4 +132,15 @@ mod tests {
assert_eq!(finding("script-error", "f", "s", 0.0).severity, "error");
assert!(try_finding("nope", "f", "s", 0.0).is_none());
}
#[test]
fn ignored_by_stamp_round_trips_and_stays_off_by_default() {
let plain = finding("side-tab", "a.html", "s", 0.0);
assert_eq!(ignored_by(&stamp_ignored_by(plain.clone(), None)), None);
assert_eq!(ignored_by(&stamp_ignored_by(plain.clone(), Some(""))), None);
let stamped = stamp_ignored_by(plain, Some(".ks-tag"));
assert_eq!(ignored_by(&stamped), Some(".ks-tag"));
let json = serde_json::to_string(&stamped).unwrap();
assert!(json.ends_with(r#""snippet":"s","ignoredBy":".ks-tag"}"#), "{json}");
}
}
+1
View File
@@ -25,6 +25,7 @@ pub mod page;
pub mod registry;
pub mod rule_pack;
pub mod rules;
pub mod selector_ignores;
#[cfg(any(test, feature = "vectors"))]
pub mod vectors;
+108
View File
@@ -0,0 +1,108 @@
//! Component-level opt-outs: one `{ rule, selector }` pair waives a rule for
//! every element the selector matches, and for that element's subtree.
//!
//! This is the declared twin of the `data-impeccable-ignore` attribute. The
//! attribute waives the element that carries it; a selector ignore waives
//! every instance of a component from one line of project config, so an
//! author with eleven copies of the same 10px label writes one entry instead
//! of eleven attributes.
//!
//! The engines do not drop what a selector ignore covers. They stamp the
//! finding with the selector that waived it (`Finding.ignoredBy` /
//! `BrowserFinding.ignoredBy`), and the config layer that owns the ignore
//! list drops and counts them, so "silenced" stays countable.
use serde::{Deserialize, Serialize};
/// One `detector.ignoreSelectors` entry, reduced to what an engine needs.
/// `rule` is a lowercased rule id or `*` (every rule); `selector` is a CSS
/// selector matched against the finding's element and its ancestors.
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct SelectorIgnore {
pub rule: String,
pub selector: String,
}
impl SelectorIgnore {
/// Normalizing constructor: the rule is trimmed and lowercased the way
/// `data-impeccable-ignore` tokens are, the selector keeps its case
/// (`.ksTag` and `.kstag` are different classes) and only loses
/// surrounding whitespace.
pub fn new(rule: impl AsRef<str>, selector: impl AsRef<str>) -> Self {
SelectorIgnore {
rule: crate::js::to_lower_case(crate::js::trim(rule.as_ref())),
selector: crate::js::trim(selector.as_ref()).to_string(),
}
}
/// Usable only with both halves present. An entry with an empty selector
/// would waive everything, which is what `ignoreRules` is for.
pub fn is_valid(&self) -> bool {
!self.rule.is_empty() && !self.selector.is_empty()
}
/// `*` covers every rule, exactly as it does in the attribute.
pub fn covers_rule(&self, rule_id: &str) -> bool {
if !self.is_valid() {
return false;
}
self.rule == "*" || self.rule == crate::js::to_lower_case(crate::js::trim(rule_id))
}
}
/// The first entry that waives `rule_id` for an element, where `closest`
/// answers the DOM's `element.closest(selector) !== null` (self or ancestor).
/// Returns the selector that waived it, which is what the finding carries.
pub fn waiving_selector<'a>(
entries: &'a [SelectorIgnore],
rule_id: &str,
mut closest: impl FnMut(&str) -> bool,
) -> Option<&'a str> {
entries
.iter()
.find(|e| e.covers_rule(rule_id) && closest(&e.selector))
.map(|e| e.selector.as_str())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalizes_rule_and_selector() {
let e = SelectorIgnore::new(" Undersized-UI-Text ", " .ks-tag ");
assert_eq!(e.rule, "undersized-ui-text");
assert_eq!(e.selector, ".ks-tag");
assert!(e.is_valid());
}
#[test]
fn half_an_entry_is_not_an_entry() {
assert!(!SelectorIgnore::new("", ".ks-tag").is_valid());
assert!(!SelectorIgnore::new("side-tab", " ").is_valid());
assert!(!SelectorIgnore::new("", ".ks-tag").covers_rule("side-tab"));
}
#[test]
fn star_covers_every_rule() {
let e = SelectorIgnore::new("*", ".demo");
assert!(e.covers_rule("side-tab"));
assert!(e.covers_rule("UNDERSIZED-UI-TEXT"));
}
#[test]
fn waiving_selector_picks_the_first_match() {
let entries = vec![
SelectorIgnore::new("side-tab", ".nope"),
SelectorIgnore::new("undersized-ui-text", ".ks-tag"),
SelectorIgnore::new("undersized-ui-text", ".also"),
];
let hit = waiving_selector(&entries, "undersized-ui-text", |s| s != ".nope");
assert_eq!(hit, Some(".ks-tag"));
assert_eq!(waiving_selector(&entries, "glow-effect", |_| true), None);
assert_eq!(
waiving_selector(&entries, "undersized-ui-text", |_| false),
None
);
}
}
+33 -6
View File
@@ -13,8 +13,9 @@ use impeccable_core::findings::Finding;
use impeccable_core::js;
use impeccable_detect::config::{
extract_finding_ignore_value, filter_detection_findings, matches_any_glob,
normalize_ignore_rule, normalize_ignore_value, normalize_ignore_value_entries, DetectionConfig,
IgnoreValueEntry,
merge_ignore_selectors, normalize_ignore_rule, normalize_ignore_value,
normalize_ignore_value_entries, selector_ignores_for_target, DetectionConfig,
IgnoreSelectorEntry, IgnoreValueEntry,
};
use impeccable_detect::design_system::{load_design_system_for_cwd, resolve_design_md_path, DesignSystem};
use impeccable_detect::detect_text::{detect_text, TextOptions};
@@ -373,6 +374,8 @@ pub struct HookConfig {
pub ignore_rules: Vec<String>,
pub ignore_files: Vec<String>,
pub ignore_values: Vec<IgnoreValueEntry>,
/// `detector.ignoreSelectors`: the project's component-level opt-outs.
pub ignore_selectors: Vec<IgnoreSelectorEntry>,
pub extensions: Vec<ExtensionEntry>,
pub per_edit_rules: String,
pub advisory_rules: String,
@@ -389,6 +392,7 @@ impl Default for HookConfig {
ignore_rules: vec![],
ignore_files: vec![],
ignore_values: vec![],
ignore_selectors: vec![],
extensions: vec![],
per_edit_rules: "immediate".to_string(),
advisory_rules: "exclude".to_string(),
@@ -482,6 +486,9 @@ fn apply_detector_config_source(config: &mut HookConfig, raw: Option<&Map<String
if let Some(Value::Array(list)) = raw.get("ignoreValues") {
config.ignore_values = merge_ignore_values(&config.ignore_values, list);
}
if let Some(Value::Array(list)) = raw.get("ignoreSelectors") {
config.ignore_selectors = merge_ignore_selectors(&config.ignore_selectors, list);
}
if let Some(Value::Array(list)) = raw.get("extensions") {
config.extensions = merge_extensions(&config.extensions, list);
}
@@ -989,6 +996,7 @@ pub fn filter_findings(findings: Vec<Finding>, config: &HookConfig) -> Vec<Findi
ignore_rules: config.ignore_rules.clone(),
ignore_files: vec![],
ignore_values: config.ignore_values.clone(),
ignore_selectors: config.ignore_selectors.clone(),
design_system_enabled: None,
advisory_rules: None,
};
@@ -1615,6 +1623,9 @@ pub fn should_emit_ack_for_file(file_path: &str, config: &HookConfig) -> bool {
#[derive(Default, Clone)]
pub struct HookScanOptions {
pub design_system: Option<Rc<DesignSystem>>,
/// The project's component-level opt-outs, narrowed per file when the
/// options are handed to an engine.
pub ignore_selectors: Vec<IgnoreSelectorEntry>,
}
impl HookScanOptions {
@@ -1624,12 +1635,19 @@ impl HookScanOptions {
.map(|d| d.md_newer_than_json)
.unwrap_or(false)
}
pub fn to_scan_options(&self) -> ScanOptions {
pub fn to_scan_options(&self, target: &str) -> ScanOptions {
ScanOptions {
inline_ignores: true,
design_system: self.design_system.clone(),
viewport: None,
profile: None,
ignore_selectors: selector_ignores_for_target(
&DetectionConfig {
ignore_selectors: self.ignore_selectors.clone(),
..DetectionConfig::raw()
},
target,
),
rule_pack: None,
}
}
@@ -1637,11 +1655,19 @@ impl HookScanOptions {
/// JS: designSystemOptions(config, detector, projectCwd)
pub fn design_system_options(config: &HookConfig, project_cwd: &str) -> HookScanOptions {
// Component ignores are not design-system state: a project with
// `designSystem.enabled: false` still opted its components out, and the
// hook would otherwise re-report them on every edit.
let ignore_selectors = config.ignore_selectors.clone();
if !config.design_system_enabled {
return HookScanOptions::default();
return HookScanOptions {
design_system: None,
ignore_selectors,
};
}
HookScanOptions {
design_system: load_design_system_for_cwd(project_cwd).map(Rc::new),
ignore_selectors,
}
}
@@ -1653,7 +1679,8 @@ pub fn design_system_options_for_file(
file_path: &str,
) -> HookScanOptions {
if !config.design_system_enabled {
return HookScanOptions::default();
// Same as above: the waivers travel even when no design system does.
return design_system_options(config, project_cwd);
}
let project = impeccable_context::context::resolve_project(
project_cwd,
@@ -1695,7 +1722,7 @@ pub fn detector_detect_html(
) -> Result<Vec<Finding>, String> {
let mut sink = std::io::sink();
rt.html
.detect_html(file_path, &scan.to_scan_options(), &mut sink)
.detect_html(file_path, &scan.to_scan_options(file_path), &mut sink)
.map_err(|e| e.message)
}
+36 -3
View File
@@ -28,7 +28,8 @@ use crate::profile::{self, Meta, ProfileSink};
use crate::quality::{check_element_quality, check_page_quality_from_doc, pf0};
use impeccable_core::checks::html_patterns::{check_html_patterns, HtmlPatternCorpora};
use impeccable_core::checks::rules::RuleHit;
use impeccable_core::findings::{try_finding, Finding};
use impeccable_core::findings::{stamp_ignored_by, try_finding, Finding};
use impeccable_core::selector_ignores::{waiving_selector, SelectorIgnore};
use impeccable_core::inline_ignores::apply_inline_ignores;
use impeccable_core::page::is_full_page;
use once_cell::sync::Lazy;
@@ -80,6 +81,13 @@ pub struct DetectHtmlOptions<'a> {
/// Sink for the JS `process.stderr.write` notices (unreadable linked
/// stylesheets); `None` drops them.
pub warn: Option<&'a dyn Fn(&str)>,
/// The project's component-level opt-outs (`detector.ignoreSelectors`),
/// already narrowed to the entries whose `files` globs cover this file.
/// An element finding whose element (or an ancestor of it) matches one of
/// these selectors is stamped `ignoredBy: "<selector>"` rather than
/// dropped, so the config layer can count what it suppresses. Empty by
/// default, which is the behavior every existing caller gets.
pub ignore_selectors: &'a [SelectorIgnore],
/// A rule pack's static-document hook: rules over the parsed page.
pub static_rule_pack: Option<&'static dyn StaticRulePack>,
/// The same pack's engine-wide text hook. An HTML file gets **one** pack
@@ -228,8 +236,14 @@ pub fn detect_html_source(
if scoped_ignore_active(el, &h.id) {
continue;
}
// A component-level opt-out waives the same way the attribute
// does (self or ancestor), but the finding is stamped rather
// than dropped so the config layer can count it.
let waived = waiving_selector(options.ignore_selectors, &h.id, |sel| {
el.closest(sel).is_some()
});
if let Some(f) = mk(&h.id, &h.snippet) {
findings.push(f);
findings.push(stamp_ignored_by(f, waived));
}
}
}
@@ -316,6 +330,7 @@ pub fn detect_html_source(
},
);
for f in pattern_hits {
let mut pattern_waived: Option<String> = None;
if let Some(selector) = f.selector.as_deref() {
let stripped = PSEUDO_STRIP_RE.replace_all(selector, "");
let stripped = impeccable_core::js::trim(&stripped);
@@ -329,6 +344,24 @@ pub fn detect_html_source(
{
continue;
}
// A selector-backed pattern finding is waived by config
// only when every element it names is covered, the same
// all-or-nothing rule the attribute pass above applies.
if !matches.is_empty() {
let mut covering: Option<&str> = None;
for el in &matches {
match waiving_selector(options.ignore_selectors, &f.id, |sel| {
el.closest(sel).is_some()
}) {
Some(sel) => covering = covering.or(Some(sel)),
None => {
covering = None;
break;
}
}
}
pattern_waived = covering.map(str::to_string);
}
}
}
if let Some(mut item) = mk(&f.id, &f.snippet) {
@@ -336,7 +369,7 @@ pub fn detect_html_source(
item.severity = sev.clone();
}
impeccable_core::findings::derive_advisory_flag(&mut item);
findings.push(item);
findings.push(stamp_ignored_by(item, pattern_waived.as_deref()));
}
}
+1
View File
@@ -80,6 +80,7 @@ impl HtmlEngine for StaticHtmlEngine {
warn: Some(&warn),
static_rule_pack: self.static_rule_pack,
rule_pack: options.rule_pack,
ignore_selectors: &options.ignore_selectors,
};
detect_html(Path::new(path), &html_options).map_err(|e| {
EngineError::new(match e {
+115
View File
@@ -0,0 +1,115 @@
//! 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);
}
File diff suppressed because one or more lines are too long
+28 -1
View File
@@ -7,13 +7,22 @@
//! ```json
//! {
//! "inlineIgnores": true,
//! "designSystem": { "frontmatter": { ... }, "sidecar": { ... } }
//! "designSystem": { "frontmatter": { ... }, "sidecar": { ... } },
//! "ignoreSelectors": [{ "rule": "undersized-ui-text", "selector": ".ks-tag" }]
//! }
//! ```
//!
//! - `inlineIgnores` (default `true`): apply the `impeccable-disable` waivers
//! found in the source, exactly as the CLI does. `false` reports waived
//! findings too.
//! - `ignoreSelectors`: the project's component-level opt-outs, the
//! `detector.ignoreSelectors` entries whose `files` globs cover this file
//! (the host narrows them; the engine matches selectors only). A finding on
//! an element the selector matches, or on a descendant of one, comes back
//! carrying `ignoredBy: "<selector>"` instead of being dropped, so a host
//! can report how many hits an author's opt-out silenced. Applies to the
//! HTML engine, where elements exist; the text engine has no DOM to match
//! against and ignores the key.
//! - `designSystem`: the DESIGN.md inputs, not a pre-normalized object (the
//! JS API's `options.designSystem` carried `Set`s and `Map`s, which JSON
//! cannot). `frontmatter` is the parsed DESIGN.md frontmatter, `sidecar`
@@ -42,6 +51,7 @@ use std::path::Path;
use std::sync::OnceLock;
use impeccable_detect::design_system::{normalize_design_system, DesignSystem};
use impeccable_core::selector_ignores::SelectorIgnore;
use impeccable_detect::detect_text::{detect_text, TextOptions};
use impeccable_html::{detect_html_source, DesignSystemHook, DetectHtmlOptions, StaticRulePack};
use serde_json::Value;
@@ -65,6 +75,7 @@ pub fn installed_static_rule_pack() -> Option<&'static dyn StaticRulePack> {
struct Options {
inline_ignores: bool,
design_system: Option<DesignSystem>,
ignore_selectors: Vec<SelectorIgnore>,
}
fn parse_options(options_json: &str) -> Options {
@@ -87,9 +98,24 @@ fn parse_options(options_json: &str) -> Options {
false,
))
});
let ignore_selectors = parsed
.get("ignoreSelectors")
.and_then(Value::as_array)
.map(|list| {
list.iter()
.filter_map(|e| {
let rule = e.get("rule").and_then(Value::as_str)?;
let selector = e.get("selector").and_then(Value::as_str)?;
let entry = SelectorIgnore::new(rule, selector);
entry.is_valid().then_some(entry)
})
.collect()
})
.unwrap_or_default();
Options {
inline_ignores,
design_system,
ignore_selectors,
}
}
@@ -142,6 +168,7 @@ pub fn detect_html_source_json(html: &str, file_path: &str, options_json: &str)
warn: None,
static_rule_pack: installed_static_rule_pack(),
rule_pack: crate::installed_rule_pack(),
ignore_selectors: &options.ignore_selectors,
},
);
findings_json(&findings)
+12 -6
View File
@@ -184,7 +184,7 @@ Examples:
Then `files = walkDir(resolved).filter(f => !shouldIgnoreDetectionFile(f, cwd, config))`. If `files.length > 50 && stdin.isTTY && !json && !quiet`: `stderr> \nFound ${n} files (${htmlCount} HTML) in ${target}.\nScanning may take a while${htmlCount > 10 ? ' (static HTML/CSS processes each HTML file individually)' : ''}.\nTarget a specific subdirectory to narrow scope.\n` then readline prompt `Continue? [Y/n] ` on stderr; empty or `/^y(es)?$/i` continues; otherwise `stderr> Aborted.\n`, `exit 0`. Then `buildImportGraph(files)` → reverse map; each file scanned with its own options; findings from a file that is imported get `f.importedBy = [basename(importer), ...]` (Set iteration order).
- **File**: skipped if `shouldIgnoreDetectionFile`; else `detectLocalFile`.
- `detectLocalFile(fp, opts)`: extension (lowercased) in `HTML_EXTENSIONS = {'.html','.htm'}``detectHtml(fp, opts)`; else `detectText(readFileSync(fp,'utf-8'), fp, opts)`.
4. Post-filter: `filterDetectionFindings(all, config)` (ignoreRules/ignoreValues), then `filterByScopes(all, scopes)` (keeps findings whose rule declares any requested scope; empty scopes = no filter), then `--no-advisory` drop.
4. Post-filter: `filterDetectionFindings(all, config)` (component ignores, then ignoreRules/ignoreValues), then `filterByScopes(all, scopes)` (keeps findings whose rule declares any requested scope; empty scopes = no filter), then `--no-advisory` drop.
5. Partition `{primary, advisory}` by `f.advisory === true || f.severity === 'advisory'`.
Any target that cannot be scanned sets `hadOperationalFailure` (#711): a URL
@@ -201,7 +201,8 @@ target.
- quiet: `stderr> ${primary.length} anti-pattern${n===1?'':'s'} found.\n`; if advisory: `stderr> dim(`${adv} advisory note${adv===1?'':'s'} (not counted).`) + '\n'`.
- text: `stderr> formatFindings(all,false) + '\n'`.
- `exit(hadOperationalFailure ? 1 : (primary.length > 0 ? 2 : 0))`.
- no findings: json → `stdout> []\n`; text/quiet → nothing. `exit(hadOperationalFailure ? 1 : 0)`.
- **Component-ignore tally** (`detector.ignoreSelectors`, see the config section): when entries waived anything, one `dim` line per `{rule, selector}` goes to **stderr in every mode**, so `--json` stdout stays the findings array a consumer parses: `stderr> ${count} ${rule} hit${count===1?'':'s'} ignored by detector.ignoreSelectors on ${selector}.\n`. In text mode it follows the findings after a blank line; in quiet mode it precedes the advisory note; with no findings at all it is still printed. Nothing is emitted when the project has no such entry or the entries matched nothing, so unchanged projects see unchanged output.
- no findings: json → `stdout> []\n`; text/quiet → nothing but the tally above. `exit(hadOperationalFailure ? 1 : 0)`.
- Exit 1 takes precedence over exit 2: findings from the targets that did scan
do not turn a partial scan into a complete one (#711).
- Any other exit: `1` for arg errors above; uncaught exceptions propagate to `cli.js` catch (`exit 1`).
@@ -244,7 +245,7 @@ Example (non-TTY):
{ antipattern: id, name: ap.name, description: ap.description, severity: ap.severity || 'warning', category: ap.category || null, file: filePath, line, snippet }
// plus, only when the effective severity is 'advisory': advisory: true
```
Optional keys added later by engines (appended after the above): `ignoreValue` (design-system rules; browser findings with a value), `importedBy` (dir scans), `severity` may be overwritten by per-finding promotion (browser & html-patterns, e.g. pulsing dot in a header). Design-system findings are `{...finding(...), ...extras}` where extras = `{ ignoreValue }`. Static-HTML and browser findings have `line: 0`; regex findings have 1-based lines. `severity` values in registry: `'warning'` (default), `'advisory'` (many generated-UI tells and design-system-color/radius/font-size, numbered-section-labels, blinking-cursor, shape-assembled-illustration), `'error'` (`script-error`, `content-hidden-at-rest`). `severity` is the canonical advisory field (#709): `deriveAdvisoryFlag` stamps `advisory: true` when and only when the effective severity is `'advisory'`, so a per-finding promotion or demotion carries the flag with it, and every `severity:'advisory'` rule is partitioned out of the failure count and the exit code. `isAdvisory` accepts either `finding.advisory === true` or `finding.severity === 'advisory'`.
Optional keys added later by engines (appended after the above): `ignoreValue` (design-system rules; browser findings with a value), `ignoredBy` (the `detector.ignoreSelectors` selector that waived the finding; the CLI drops and counts these, so they appear only in engine-level output such as the wasm exports), `importedBy` (dir scans), `severity` may be overwritten by per-finding promotion (browser & html-patterns, e.g. pulsing dot in a header). Design-system findings are `{...finding(...), ...extras}` where extras = `{ ignoreValue }`. Static-HTML and browser findings have `line: 0`; regex findings have 1-based lines. `severity` values in registry: `'warning'` (default), `'advisory'` (many generated-UI tells and design-system-color/radius/font-size, numbered-section-labels, blinking-cursor, shape-assembled-illustration), `'error'` (`script-error`, `content-hidden-at-rest`). `severity` is the canonical advisory field (#709): `deriveAdvisoryFlag` stamps `advisory: true` when and only when the effective severity is `'advisory'`, so a per-finding promotion or demotion carries the flag with it, and every `severity:'advisory'` rule is partitioned out of the failure count and the exit code. `isAdvisory` accepts either `finding.advisory === true` or `finding.severity === 'advisory'`.
**Categories**: `category` is `'slop'` (AI tells) or `'quality'`. Category has **no effect on output**, ordering, or exit codes; it is only carried in the finding and used by `getRulesForCategory`. Registry (59 ids, in order): side-tab, border-accent-on-rounded, overused-font, flat-type-hierarchy, gradient-text, ai-color-palette, cream-palette, nested-cards, monotonous-spacing, bounce-easing, pulsing-dot, blinking-cursor, shape-assembled-illustration, dark-glow, radial-halo, radial-spotlight-glow, marquee, icon-tile-stack, italic-serif-display, hero-eyebrow-chip, kicker-above-heading, numbered-section-labels, em-dash-overuse, marketing-buzzword, aphoristic-cadence, oversized-h1, extreme-negative-tracking, broken-image, script-error, content-hidden-at-rest, edge-flush-cards, text-occlusion, first-viewport-column-overflow, gray-on-color, low-contrast, layout-transition, line-length, cramped-padding, body-text-viewport-edge, tight-leading, skipped-heading, heading-rhythm, justified-text, tiny-text, undersized-ui-text, all-caps-body, wide-tracking, text-overflow, repeated-container-text, clipped-overflow-container, design-system-font, design-system-color, design-system-radius, design-system-font-size, gpt-thin-border-wide-shadow, repeating-stripes-gradient, codex-grid-background, theater-slop-phrase, image-hover-transform. Scopes: `type` = overused-font, flat-type-hierarchy, italic-serif-display, hero-eyebrow-chip, kicker-above-heading, numbered-section-labels, oversized-h1, extreme-negative-tracking, line-length, tight-leading, skipped-heading, heading-rhythm, justified-text, tiny-text, undersized-ui-text, all-caps-body, wide-tracking, design-system-font, design-system-font-size; `layout` = nested-cards, monotonous-spacing, icon-tile-stack, content-hidden-at-rest, edge-flush-cards, text-occlusion, first-viewport-column-overflow, line-length, cramped-padding, body-text-viewport-edge, heading-rhythm, text-overflow, clipped-overflow-container. `RULE_ENGINE_SUPPORT = { regex: Set['source','page-analyzer'], 'static-html': Set['element','page'], browser: Set['element','page','layout'], visual: Set['visual-contrast'] }`.
@@ -269,7 +270,8 @@ Optional keys added later by engines (appended after the above): `ignoreValue` (
- Applied inside `detectText` and `detectHtml` at the end unless `options.inlineIgnores === false` (set by `--no-config` or `--no-inline-ignores`). Not applied to URL scans.
- Fast path: skip unless `/impeccable-disable/i` occurs.
- **DOM-scoped ignore** (`rules/checks.mjs scopedIgnoreActive`): attribute `data-impeccable-ignore="rule-a rule-b"` (split on `/[\s,]+/`, lowercased; empty value or `*` = all) on an element waives matching findings for it and its subtree in browser, extension, and static engines. In `detectHtml`'s html-patterns pass, selector-backed findings are dropped when every element matched by the (pseudo-stripped) selector is under a waiver; unmatched selectors keep the finding.
- Tests: `tests/inline-ignores.test.mjs`; fixture `scoped-ignore.html`.
- **Component-scoped ignore** (`detector.ignoreSelectors`): the declared twin of that attribute, for a component whose instances would otherwise each carry one. An entry `{ rule, selector }` waives `rule` for every element matching `selector` and for that element's subtree (`element.closest(selector) !== null`), in the browser engine (`BrowserConfig.ignoreSelectors`, also readable from `window.__IMPECCABLE_CONFIG__`) and the static HTML engine (`DetectHtmlOptions.ignore_selectors`). Rule `*` covers every rule. The engines **stamp** rather than drop: a waived finding comes back carrying `ignoredBy: "<selector>"` (`BrowserFinding.ignoredBy`, serialized on the group finding; `Finding.ignoredBy` in the extras), and the config layer drops it and counts it, so the suppression is a number rather than silence. The text engine has no DOM and never stamps.
- Tests: `tests/inline-ignores.test.mjs`; fixture `scoped-ignore.html`. Component ignores: `crates/html/tests/selector_ignores.rs`, `crates/core/src/browser/driver.rs` tests, oracle `detect-selector-ignore-*`.
#### Config file (`cli/lib/impeccable-config.mjs`) — `.impeccable/config.json` + `.impeccable/config.local.json`
@@ -278,24 +280,27 @@ Optional keys added later by engines (appended after the above): `ignoreValue` (
```json
{ "detector": { "ignoreRules": ["side-tab"], "ignoreFiles": ["src/legacy/**"],
"ignoreValues": [{ "rule": "overused-font", "value": "inter", "files": ["src/a.css"], "createdAt": "ISO", "reason": "..." }],
"ignoreSelectors": [{ "rule": "undersized-ui-text", "selector": ".ks-tag", "files": ["src/a.astro"], "createdAt": "ISO", "reason": "..." }],
"designSystem": { "enabled": true }, "advisoryRules": "include"|"exclude" },
"hook": { "consent": "accepted"|"declined", ... }, "updateCheck": true }
```
- `readDetectionConfig(root)`: start `{ignoreRules:[],ignoreFiles:[],ignoreValues:[],designSystem:{enabled:true}}`; for shared then local: apply legacy `raw.hook.*` section then `raw.detector.*`. Arrays are unioned (`uniqueStrings`, String-coerced); ignoreValues merged by key `rule\0value\0sortedFiles.join('\x1f')` (later wins); `designSystem.enabled` false only when literally `false`; `advisoryRules` copied only if `'include'|'exclude'`. Invalid JSON / non-object files are ignored silently. **No validation errors are ever raised by the CLI**; the only validation of ignore lists lives in `skill/scripts/lib/staleness-deep.mjs checkDetectorIgnores` (doctor): unknown `ignoreRules` ids vs live `ANTIPATTERNS` → finding `detector-ignore-rules-unknown` (severity `mention`); non-glob `ignoreFiles` entries that don't exist → `detector-ignore-files-missing`.
- `normalizeIgnoreValue(v)`: trim, strip one leading/trailing quote, `+`→space, collapse whitespace, lowercase. Rules lowercased/trimmed.
- `normalizeIgnoreValueEntries`: keeps `{rule, value, [files], [createdAt], [reason]}` in **that key order**; `file` (string) and `files` merged, trimmed, deduped.
- `ignoreSelectors` (component ignores): merged by key `rule\0selector\0sortedFiles.join('\x1f')` (later wins), normalized to `{rule, selector, [files], [createdAt], [reason]}` in **that key order**. The rule is lowercased/trimmed like every rule id; the **selector keeps its case** (CSS class names are case-sensitive) and only loses surrounding whitespace. An entry missing either half is dropped. `selectorIgnoresForTarget(config, target)` narrows the list per local scan target: an entry without `files` covers every target, one with `files` covers the paths its globs match (raw path then each `/`-suffix). A URL scan uses `selectorIgnoresForUrl(config)`, the unscoped entries only: a `files` glob names repo paths, so it must not reach a URL that happens to end the same way. `--no-config` leaves the list empty. The key is **written only by a project that uses it**, so an existing config does not grow an empty `ignoreSelectors` on the next `ignores add-rule`. `doctor`'s `detector-ignore-rules-unknown` validates the `rule` of each entry alongside `ignoreRules`.
- Glob → regex: `**``.*` (swallowing a following `/`), `*``[^/]*`, `?``[^/]`, `{a,b}``(?:a|b)`, regex specials escaped; anchored `^...$`. `matchesAnyGlob` tests the `/`-normalized path and its basename.
- `shouldIgnoreDetectionFile(filePath, root, config)`: raw path, absolute path, and root-relative path (if inside root) tested against `ignoreFiles`.
- `filterDetectionFindings`: drop when `ignoreRules` has the rule, or an `ignoreValues` entry matches: same rule; entry.value `*` (wildcard) OR extracted value equals (with color-key equality for `design-system-color`: rgb/hex/hsl parsed to `r,g,b,round(a*255)`); if entry has `files`, `finding.file` (or any `/`-suffix of it) must glob-match; a wildcard with no files never matches (unscoped `*` disallowed).
- `filterDetectionFindings`: first drop every finding the engines stamped with `ignoredBy` (the component ignores above), counted by `{rule, selector, count}` in first-seen order; `filterDetectionFindingsReported` returns that tally beside the kept findings. Then drop when `ignoreRules` has the rule, or an `ignoreValues` entry matches: same rule; entry.value `*` (wildcard) OR extracted value equals (with color-key equality for `design-system-color`: rgb/hex/hsl parsed to `r,g,b,round(a*255)`); if entry has `files`, `finding.file` (or any `/`-suffix of it) must glob-match; a wildcard with no files never matches (unscoped `*` disallowed).
- `extractFindingIgnoreValue`: only for `overused-font, bounce-easing, design-system-font, design-system-color, design-system-radius, design-system-font-size`; source `finding.ignoreValue || finding.value`, else parse `detail`/`snippet`: bounce → `animate-bounce`, `cubic-bezier(...)`, or animation token matching `/bounce|elastic|wobble|jiggle|spring/i`; fonts → `Primary font:`, `Google Fonts:`, `font-family:` value, or `family=` URL param (decoded).
#### `impeccable ignores` (`cli/bin/commands/ignores.mjs`)
- Actions/aliases: `status|ls|list`→list (default when no action), `add-rule|ignore-rule`, `add-file|ignore-file`, `add-value|ignore-value|update-value`, `remove-rule|rm-rule`, `remove-file|rm-file`, `remove-value|rm-value`, `clear`. `--help`/`-h` prints usage (stdout). Unknown → throws `Unknown ignores action: ${a}. Run "impeccable ignores --help".` (exit 1 via cli.js).
- Actions/aliases: `status|ls|list`→list (default when no action), `add-rule|ignore-rule`, `add-file|ignore-file`, `add-value|ignore-value|update-value`, `add-selector|ignore-selector|update-selector`, `remove-rule|rm-rule`, `remove-file|rm-file`, `remove-value|rm-value`, `remove-selector|rm-selector`, `clear`. `--help`/`-h` prints usage (stdout). Unknown → throws `Unknown ignores action: ${a}. Run "impeccable ignores --help".` (exit 1 via cli.js).
- Scope flags: `--shared` (default), `--local`, `--all` (remove/clear only); more than one → error `Pass only one scope flag: --shared, --local, or --all` (or `--shared or --local`).
- `add-rule <rule> [--all-values] [--reason ...]`: `overused-font` without `--all-values` → error "overused-font is value-specific by default. Use add-value overused-font <font>, or add-rule overused-font --all-values for broad suppression." Output: `Added ${rule} to ${local?'local':'shared'} detector ignoreRules (${relpath}).`
- `add-file <glob>``Added ${glob} to ... detector ignoreFiles (...)`.
- `add-value <rule> <value...> [--file <glob>]... [--reason <text...>]`: value = normalized join of positionals after rule; `--file`/`--files`/`--file=`/`--files=` (empty or flag-like → error); unknown `--x``Unknown add-value flag: --x`; `*` value requires `--file`; existing entry (same key) updates reason/files, else pushes `{rule,value,[files],createdAt: ISO now,[reason]}`. Output `Added ${rule}=${value} to ... detector ignoreValues (...)`.
- `add-selector <rule> <selector...> [--file <glob>]... [--reason <text...>]`: the selector is the positionals after the rule joined with a space and trimmed, case preserved (`.card .ks-tag` is one descendant selector). Missing rule or selector → `Pass a rule id and a CSS selector, e.g. impeccable ignores add-selector undersized-ui-text ".ks-tag"`; selector `*``A \`*\` selector waives the rule everywhere. Use add-rule for that, or name the component's selector.`; unknown `--x` → `Unknown add-selector flag: --x`. An existing entry (same rule + selector + files) updates reason/files, else pushes `{rule,selector,[files],createdAt: ISO now,[reason]}`. Output `Added ${rule} on ${selector} to ... detector ignoreSelectors (...)`.
- `remove-*``Removed ${n} from shared (path), ${n} from local (path).` or `No matching detector ignore found.` `clear``Cleared detector ignores in ${'shared and local config'|'local config'|'shared config'}.`
- `list` output:
```
@@ -307,6 +312,7 @@ Optional keys added later by engines (appended after the above): `ignoreValue` (
ignoreRules: (none)
ignoreFiles: ...
ignoreValues: rule=value [glob1, glob2] - reason, ...
ignoreSelectors: rule on selector [glob1, glob2] - reason, ... (omitted when empty)
designSystem: enabled|disabled
Shared:
+10 -2
View File
@@ -204,11 +204,19 @@ over the file-scanning engines, JSON in and JSON out:
- `detect_text_json(content, file_path, options_json)`
- `detect_html_source_json(html, file_path, options_json)`
Both take `{ inlineIgnores?: boolean, designSystem?: { frontmatter?, sidecar? } }`
Both take
`{ inlineIgnores?: boolean, designSystem?: { frontmatter?, sidecar? }, ignoreSelectors?: [{ rule, selector }] }`
and return the findings array `impeccable detect --json` prints, same keys and
same order. `designSystem` carries the DESIGN.md inputs rather than a
normalized object, because the JS API's normalized form used `Set`s and
`Map`s that JSON cannot hold. Unparseable options fall back to the defaults.
`Map`s that JSON cannot hold. `ignoreSelectors` is the project's
component-level opt-out (`detector.ignoreSelectors`), already narrowed by the
host to the entries whose `files` globs cover this file: a finding on an
element the selector matches, or on a descendant of one, comes back carrying
`ignoredBy: "<selector>"` rather than being dropped, so the host can count
what an author's opt-out silenced and say so. Only the HTML engine matches
selectors; the text engine has no DOM and ignores the key. Unparseable
options fall back to the defaults.
`antipatterns_json()` lists the built-ins followed by any pack's rows, and
`immediate_tier_rules_json()` returns the design hook's immediate tier (the
rule ids worth fixing at the edit site). That list lives in
+9 -1
View File
@@ -12,7 +12,7 @@ This command toggles the hook **per project** by editing `.impeccable/config.jso
Declare server-side template extensions under **`detector.extensions`** when the project uses Blade, Twig, ERB, or Handlebars files; the hook skips them otherwise because they sit outside the built-in extension list. One entry per extension, `{ "ext": ".blade.php", "engine": "html" }`. `engine` picks the analyzer (`html` for markup templates, `text` for JS/TS/CSS-like files) and defaults to `html`. Match against the end of the filename, so double extensions like `.blade.php` and `.html.erb` work. Config only adds extensions; the built-in list always applies.
Manual `npx impeccable detect` scans use the same project filter config by default: `detector.ignoreRules`, `detector.ignoreFiles`, `detector.ignoreValues`, and `detector.designSystem.enabled`. `hook.enabled` only controls automatic hook execution, not manual CLI scans. Use `npx impeccable detect --no-config ...` for a raw detector run that ignores project config/context. Use `npx impeccable ignores ...` for direct CLI CRUD on the same detector ignores.
Manual `npx impeccable detect` scans use the same project filter config by default: `detector.ignoreRules`, `detector.ignoreFiles`, `detector.ignoreValues`, `detector.ignoreSelectors`, and `detector.designSystem.enabled`. `hook.enabled` only controls automatic hook execution, not manual CLI scans. Use `npx impeccable detect --no-config ...` for a raw detector run that ignores project config/context. Use `npx impeccable ignores ...` for direct CLI CRUD on the same detector ignores.
Supported harnesses: Claude Code (`.claude/settings.local.json` in the project, which is gitignored so the hook stays machine-local; a hook you move into the shared `settings.json` is honored in place too), Codex (`.codex/hooks.json` in the project), Cursor (`.cursor/hooks.json` in the project), Grok Build (`.grok/hooks/impeccable.json` in the project; requires `/hooks-trust` or `--trust`), and GitHub Copilot (`.github/hooks/impeccable.json` in the project, a team-shared committed file that both the Copilot CLI and the cloud agent read). For the Copilot CLI, repo-level hooks fire once `.github/hooks/impeccable.json` is committed to the repository's default branch.
@@ -63,9 +63,11 @@ Prefer the narrowest exception:
- If the finding line shows an `ignore-value <rule> <value>` pair, pass it to `impeccable hooks ignore-value` with your `--reason`. This writes shared `.impeccable/config.json` by default.
- For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` for the specific value. Do not use `ignore-rule overused-font` for a specific font.
- If the finding has no value-specific command, such as `side-tab`, scope that one rule to the file: `ignore-value <id> "*" --file <path>`. Run `npx impeccable detect <path>` first to see what actually fires there.
- If the same rule fires on every instance of one component, waive the component once instead of per instance: `npx impeccable ignores add-selector <rule> "<css-selector>" --reason "..."`. It writes `detector.ignoreSelectors` in the same `.impeccable/config.json`, waives that rule for every element the selector matches and for its subtree, and every scan then reports how many hits it suppressed, so the exception stays visible. Eleven copies of the same 10px label want one entry here, not eleven `data-impeccable-ignore` attributes. This is a component-wide suppression: ask the user first, as you would for `ignore-file`. There is no `hooks ignore-selector`; use the `ignores` command.
- Reach for `ignore-file <path>` only when the whole file is out of scope for design review: a fixture, a generated artifact, a deliberate slop demo. It silences every rule for that file permanently, including rules that have not been written yet. A real UI surface with one noisy rule wants the file-scoped value ignore above.
- Use `ignore-rule <id>` only when the user asks to suppress that whole rule across the project. For broad overused-font suppression, use `ignore-rule overused-font --all-values` only when the user asks to ignore overused fonts generally.
- Prefer config ignores (the commands above) by default; they keep suppressions in one reviewable place. Reach for an inline comment only when the waiver must travel with a single file that leaves the repo (a generated/exported standalone document, an emailed HTML file). The supported marker is `impeccable-disable <rule>` (whole file) or `impeccable-disable-line` / `impeccable-disable-next-line` (one line), in any comment syntax, with an optional reason after `:` or `--`. The detector honors it by default; `--no-inline-ignores` or `--no-config` bypasses it.
- The DOM equivalent, `data-impeccable-ignore="<rule>"` on an element, waives that element and its subtree. It belongs on a one-off: a single demo block, one deliberately ugly sample. Repeating it across every instance of a component is the sign you wanted `ignores add-selector` instead: the attribute hides the count from whoever reviews the change, the config entry reports it.
Example value-specific exception:
@@ -92,6 +94,12 @@ for everything else:
{{scripts_path}}/impeccable hooks ignore-value design-system-font-size "*" --file "src/overlay/widget.js" --reason "Injected widget builds its own type scale; DESIGN.md's ramp describes the site"
```
Example component exception, for one rule across every instance of a component:
```bash
npx impeccable ignores add-selector undersized-ui-text ".ks-tag" --reason "User confirmed: 10px mono index labels, decorative counters beside the heading"
```
Example whole-file exception, for a file that is out of scope entirely:
```bash
+17
View File
@@ -103,6 +103,23 @@ export default function cases() {
{ id: 'detect-config-from-subdir', verb: 'detect', workspace: 'detect-config', cwd: 'src', args: ['--json', 'page.html'] },
// A file in one project must not pick up another project's DESIGN.md
{ id: 'detect-config-cross-project', verb: 'detect', workspace: 'detect-config', args: ['--json', `<REPO>/tests/fixtures/antipatterns/blinking-cursor.html`], isolateHome: false },
// Component-level opt-outs (detector.ignoreSelectors): one entry waives a
// rule for every instance of a component, and the scan says how many hits
// it suppressed instead of going quiet. The fourth label is outside the
// component and stays reported.
{ id: 'detect-selector-ignore-text', verb: 'detect', workspace: 'detect-selector-ignores', args: ['src/page.html'] },
{ id: 'detect-selector-ignore-json', verb: 'detect', workspace: 'detect-selector-ignores', args: ['--json', 'src/page.html'] },
{ id: 'detect-selector-ignore-quiet', verb: 'detect', workspace: 'detect-selector-ignores', args: ['--quiet', 'src/page.html'] },
{ id: 'detect-selector-ignore-no-config', verb: 'detect', workspace: 'detect-selector-ignores', args: ['--no-config', '--json', 'src/page.html'] },
// `impeccable ignores` CRUD for the same entries.
{ id: 'ignores-selector-list', verb: 'ignores', workspace: 'detect-selector-ignores', args: ['list'] },
{ id: 'ignores-selector-add', verb: 'ignores', workspace: 'detect-selector-ignores', args: ['add-selector', 'undersized-ui-text', '.free-label', '--reason', 'timestamp column'] },
{ id: 'ignores-selector-remove', verb: 'ignores', workspace: 'detect-selector-ignores', args: ['remove-selector', 'undersized-ui-text', '.ks-tag'] },
{ id: 'ignores-selector-missing-args', verb: 'ignores', workspace: 'detect-selector-ignores', args: ['add-selector', 'undersized-ui-text'] },
{ id: 'ignores-selector-star-refused', verb: 'ignores', workspace: 'detect-selector-ignores', args: ['add-selector', 'undersized-ui-text', '*'] },
{ id: 'ignores-help', verb: 'ignores', args: ['--help'] },
);
return out;
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,5 +1,5 @@
{
"stdout": "Usage: impeccable detect [options] [file-or-dir-or-url...]\n\nScan files or URLs for UI anti-patterns and design quality issues.\n\nOptions:\n --json Output results as JSON\n --quiet In text mode, only print the final findings count\n --scope <name> Only report rules in the given design domain\n (type, layout). Comma-separated.\n --viewport <WxH> Browser viewport for URL scans (default 1280x800),\n e.g. --viewport 390x844 for a mobile-width pass\n --no-config Do not apply project config, detector ignores, inline\n ignore comments, or DESIGN.md\n --no-inline-ignores Do not honor in-file impeccable-disable* ignore comments\n --no-design-system Do not load local DESIGN.md / .impeccable/design.json context\n --no-advisory Suppress advisory findings entirely (e.g. em-dash overuse)\n --help Show this help message\n\nAdvisory findings:\n Some rules are advisory: detected and listed in a separate section, but never\n counted as failures and never changing the exit code. They stay out of the\n failure count so they never block automation. --no-advisory hides them.\n\nOutput streams:\n Human-readable findings go to stderr so stdout stays available for structured\n output. Use --json for JSON on stdout, or redirect text with 2> findings.txt.\n\nExit status:\n 0 Scan completed with no primary findings (advisories may still be listed)\n 1 At least one requested target could not be scanned\n 2 Scan completed with primary findings\n Operational failure takes precedence when a multi-target scan is partial.\n\nProject config:\n Respects .impeccable/config.json and .impeccable/config.local.json detector\n settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues,\n and detector.designSystem.enabled.\n\nInline ignores:\n In-file comments waive a finding where it lives and travel with the file:\n <!-- impeccable-disable overused-font -- exported brand doc -->\n .brand { font-family: Inter } /* impeccable-disable-line overused-font */\n // impeccable-disable-next-line bounce-easing: intentional bounce\n impeccable-disable applies to the whole file; -line / -next-line are scoped.\n List one or more rule ids (comma-separated), or omit them / use * for all.\n\nDetection modes:\n HTML files Static HTML/CSS analysis (default, catches linked CSS)\n Non-HTML files Regex pattern matching (CSS, JSX, TSX, etc.)\n URLs Puppeteer full browser rendering (auto-detected;\n http(s):// and file:// URLs; accessible linked CSS included)\n\nExamples:\n impeccable detect src/\n impeccable detect index.html\n impeccable detect https://example.com\n impeccable detect --json .\n impeccable detect --no-config src/\n",
"stdout": "Usage: impeccable detect [options] [file-or-dir-or-url...]\n\nScan files or URLs for UI anti-patterns and design quality issues.\n\nOptions:\n --json Output results as JSON\n --quiet In text mode, only print the final findings count\n --scope <name> Only report rules in the given design domain\n (type, layout). Comma-separated.\n --viewport <WxH> Browser viewport for URL scans (default 1280x800),\n e.g. --viewport 390x844 for a mobile-width pass\n --no-config Do not apply project config, detector ignores, inline\n ignore comments, or DESIGN.md\n --no-inline-ignores Do not honor in-file impeccable-disable* ignore comments\n --no-design-system Do not load local DESIGN.md / .impeccable/design.json context\n --no-advisory Suppress advisory findings entirely (e.g. em-dash overuse)\n --help Show this help message\n\nAdvisory findings:\n Some rules are advisory: detected and listed in a separate section, but never\n counted as failures and never changing the exit code. They stay out of the\n failure count so they never block automation. --no-advisory hides them.\n\nOutput streams:\n Human-readable findings go to stderr so stdout stays available for structured\n output. Use --json for JSON on stdout, or redirect text with 2> findings.txt.\n\nExit status:\n 0 Scan completed with no primary findings (advisories may still be listed)\n 1 At least one requested target could not be scanned\n 2 Scan completed with primary findings\n Operational failure takes precedence when a multi-target scan is partial.\n\nProject config:\n Respects .impeccable/config.json and .impeccable/config.local.json detector\n settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues,\n detector.ignoreSelectors, and detector.designSystem.enabled.\n\nComponent ignores:\n detector.ignoreSelectors waives one rule for every element a CSS selector\n matches, and for that element's subtree: one entry for a component instead\n of a data-impeccable-ignore attribute on each of its instances. Write one\n with `impeccable ignores add-selector <rule> \"<selector>\"`. Every scan\n prints what it suppressed on stderr, in --json runs too, so the exception\n stays visible:\n 3 undersized-ui-text hits ignored by detector.ignoreSelectors on .ks-tag.\n\nInline ignores:\n In-file comments waive a finding where it lives and travel with the file:\n <!-- impeccable-disable overused-font -- exported brand doc -->\n .brand { font-family: Inter } /* impeccable-disable-line overused-font */\n // impeccable-disable-next-line bounce-easing: intentional bounce\n impeccable-disable applies to the whole file; -line / -next-line are scoped.\n List one or more rule ids (comma-separated), or omit them / use * for all.\n\nDetection modes:\n HTML files Static HTML/CSS analysis (default, catches linked CSS)\n Non-HTML files Regex pattern matching (CSS, JSX, TSX, etc.)\n URLs Puppeteer full browser rendering (auto-detected;\n http(s):// and file:// URLs; accessible linked CSS included)\n\nExamples:\n impeccable detect src/\n impeccable detect index.html\n impeccable detect https://example.com\n impeccable detect --json .\n impeccable detect --no-config src/\n",
"stderr": "",
"exit": 0,
"signal": null,
@@ -0,0 +1,7 @@
{
"stdout": "[\n {\n \"antipattern\": \"undersized-ui-text\",\n \"name\": \"Undersized functional text\",\n \"description\": \"Interactive and content-bearing UI text (links, buttons, nav items, labels, table cells, meta rows, timecodes) below 11px is a legibility failure, not a style choice. WCAG sets no absolute pixel floor, but functional text under 11px is a defensible quality bar: it fails on high-DPI and small viewports and it degrades tap and read targets. The 11px floor holds even inside a footer; only non-interactive legal smallprint gets the softer 10px floor. Being ON the DESIGN.md size ramp does not exempt a value here: adding 8px to the ramp launders the token but not the legibility problem, and that is exactly the escape hatch this rule closes. Exempts sup/sub, visually-hidden (sr-only) text, and code/terminal contexts. Decorative letterspaced micro-labels are still functional and stay in scope.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"<WS>/src/page.html\",\n \"line\": 0,\n \"snippet\": \"10px functional text \\\"04 - Not part of the component\\\" (below 11px floor)\"\n }\n]\n",
"stderr": "3 undersized-ui-text hits ignored by detector.ignoreSelectors on .ks-tag.\n",
"exit": 2,
"signal": null,
"files": {}
}
@@ -0,0 +1,7 @@
{
"stdout": "[\n {\n \"antipattern\": \"undersized-ui-text\",\n \"name\": \"Undersized functional text\",\n \"description\": \"Interactive and content-bearing UI text (links, buttons, nav items, labels, table cells, meta rows, timecodes) below 11px is a legibility failure, not a style choice. WCAG sets no absolute pixel floor, but functional text under 11px is a defensible quality bar: it fails on high-DPI and small viewports and it degrades tap and read targets. The 11px floor holds even inside a footer; only non-interactive legal smallprint gets the softer 10px floor. Being ON the DESIGN.md size ramp does not exempt a value here: adding 8px to the ramp launders the token but not the legibility problem, and that is exactly the escape hatch this rule closes. Exempts sup/sub, visually-hidden (sr-only) text, and code/terminal contexts. Decorative letterspaced micro-labels are still functional and stay in scope.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"<WS>/src/page.html\",\n \"line\": 0,\n \"snippet\": \"10px functional text \\\"01 - Explore directions\\\" (below 11px floor)\"\n },\n {\n \"antipattern\": \"undersized-ui-text\",\n \"name\": \"Undersized functional text\",\n \"description\": \"Interactive and content-bearing UI text (links, buttons, nav items, labels, table cells, meta rows, timecodes) below 11px is a legibility failure, not a style choice. WCAG sets no absolute pixel floor, but functional text under 11px is a defensible quality bar: it fails on high-DPI and small viewports and it degrades tap and read targets. The 11px floor holds even inside a footer; only non-interactive legal smallprint gets the softer 10px floor. Being ON the DESIGN.md size ramp does not exempt a value here: adding 8px to the ramp launders the token but not the legibility problem, and that is exactly the escape hatch this rule closes. Exempts sup/sub, visually-hidden (sr-only) text, and code/terminal contexts. Decorative letterspaced micro-labels are still functional and stay in scope.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"<WS>/src/page.html\",\n \"line\": 0,\n \"snippet\": \"10px functional text \\\"02 - See one built\\\" (below 11px floor)\"\n },\n {\n \"antipattern\": \"undersized-ui-text\",\n \"name\": \"Undersized functional text\",\n \"description\": \"Interactive and content-bearing UI text (links, buttons, nav items, labels, table cells, meta rows, timecodes) below 11px is a legibility failure, not a style choice. WCAG sets no absolute pixel floor, but functional text under 11px is a defensible quality bar: it fails on high-DPI and small viewports and it degrades tap and read targets. The 11px floor holds even inside a footer; only non-interactive legal smallprint gets the softer 10px floor. Being ON the DESIGN.md size ramp does not exempt a value here: adding 8px to the ramp launders the token but not the legibility problem, and that is exactly the escape hatch this rule closes. Exempts sup/sub, visually-hidden (sr-only) text, and code/terminal contexts. Decorative letterspaced micro-labels are still functional and stay in scope.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"<WS>/src/page.html\",\n \"line\": 0,\n \"snippet\": \"10px functional text \\\"03 - Third label\\\" (below 11px floor)\"\n },\n {\n \"antipattern\": \"undersized-ui-text\",\n \"name\": \"Undersized functional text\",\n \"description\": \"Interactive and content-bearing UI text (links, buttons, nav items, labels, table cells, meta rows, timecodes) below 11px is a legibility failure, not a style choice. WCAG sets no absolute pixel floor, but functional text under 11px is a defensible quality bar: it fails on high-DPI and small viewports and it degrades tap and read targets. The 11px floor holds even inside a footer; only non-interactive legal smallprint gets the softer 10px floor. Being ON the DESIGN.md size ramp does not exempt a value here: adding 8px to the ramp launders the token but not the legibility problem, and that is exactly the escape hatch this rule closes. Exempts sup/sub, visually-hidden (sr-only) text, and code/terminal contexts. Decorative letterspaced micro-labels are still functional and stay in scope.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"<WS>/src/page.html\",\n \"line\": 0,\n \"snippet\": \"10px functional text \\\"04 - Not part of the component\\\" (below 11px floor)\"\n }\n]\n",
"stderr": "",
"exit": 2,
"signal": null,
"files": {}
}
@@ -0,0 +1,7 @@
{
"stdout": "",
"stderr": "1 anti-pattern found.\n3 undersized-ui-text hits ignored by detector.ignoreSelectors on .ks-tag.\n",
"exit": 2,
"signal": null,
"files": {}
}
@@ -0,0 +1,7 @@
{
"stdout": "",
"stderr": "\n<WS>/src/page.html\n [undersized-ui-text] 10px functional text \"04 - Not part of the component\" (below 11px floor)\n → Interactive and content-bearing UI text (links, buttons, nav items, labels, table cells, meta rows, timecodes) below 11px is a legibility failure, not a style choice. WCAG sets no absolute pixel floor, but functional text under 11px is a defensible quality bar: it fails on high-DPI and small viewports and it degrades tap and read targets. The 11px floor holds even inside a footer; only non-interactive legal smallprint gets the softer 10px floor. Being ON the DESIGN.md size ramp does not exempt a value here: adding 8px to the ramp launders the token but not the legibility problem, and that is exactly the escape hatch this rule closes. Exempts sup/sub, visually-hidden (sr-only) text, and code/terminal contexts. Decorative letterspaced micro-labels are still functional and stay in scope.\n\n1 anti-pattern found.\n\n3 undersized-ui-text hits ignored by detector.ignoreSelectors on .ks-tag.\n",
"exit": 2,
"signal": null,
"files": {}
}
File diff suppressed because one or more lines are too long
@@ -1,5 +1,5 @@
{
"stdout": "Impeccable doctor: <WS>\n\nneeds a command (1):\n product-schema-legacy [PRODUCT.md]\n PRODUCT.md has no schema stamp and none of the sections the current record adds (Positioning, Operating Context, Evidence on Hand, Product Principles), so it predates this version of the product record.\n → Offer `init`, which preserves confirmed answers and fills the gaps by interview. Do not rewrite the file from inference.\n\nworth saying (8):\n product-deprecated-register [PRODUCT.md]\n PRODUCT.md still carries a `## Register` section. v4 replaced the brand/product register axis with the four visitor modes (Persuade, Operate, Read, Experience), which are chosen per surface and persisted in that surface's brief. Nothing reads `## Register` any more.\n → Treat `## Register` as absent for every decision this session. Offer to delete the section; do not let its value influence the work either way.\n design-md-coverage [DESIGN.md]\n DESIGN.md has no components section. Agents generating new screens get no normative guidance for those, and the live design panel renders generic approximations in their place.\n → Ask whether the section never applied or was never written. `document` fills it from the code if the project has the answer in its CSS.\n config-unknown-keys [.impeccable/config.json]\n .impeccable/config.json has top-level key(s) nothing reads: `theme`. Recognized keys are `hook`, `detector`, `updateCheck`, `stalenessCheck`, `projectRoots`, `buildPath`, `$schema`, `version`.\n → Report the exact keys to the user. A near-miss of a real key is a setting that has never applied.\n config-invalid-build-path [.impeccable/config.json]\n .impeccable/config.json sets `buildPath` to \"fast\", which nothing reads. The values are `comp` and `code`.\n → Report the value. An unread `buildPath` does not fall back to the other path; it falls back to the default, so a project meaning `code` has been building comp-led.\n config-unknown-detector-keys [.impeccable/config.json]\n .impeccable/config.json has `detector` key(s) nothing reads: `mode`. Recognized keys are `ignoreRules`, `ignoreFiles`, `ignoreValues`, `designSystem`, `extensions`.\n → Report the exact keys. `ignoreRule` for `ignoreRules` is the common one, and it silences nothing.\n detector-ignore-rules-unknown [.impeccable/config.json]\n .impeccable/config.json ignores rule id(s) the detector does not have: `not-a-real-rule`. Either the rule was renamed or removed, or the id was mistyped and has never suppressed anything.\n → Report the exact ids. Removing them is safe; keeping a dead ignore hides that the rule is gone.\n detector-ignore-files-missing [.impeccable/config.json]\n .impeccable/config.json ignores file path(s) that no longer exist: `src/vendor/missing.css`.\n → Ask whether the file moved (repoint the entry) or was deleted (drop it). A stale entry silently stops covering the file that replaced it.\n surface-brief-orphaned [.impeccable/surfaces/src-old-astro.md]\n 1 persisted surface brief(s) name a primary target that no longer exists: .impeccable/surfaces/src-old-astro.md → src/old.astro.\n → Ask whether the surface moved (repoint the brief) or was removed (delete the brief). Until then the brief is authority for a file that is gone.\n\nautomatic (1):\n legacy-live-state [.impeccable-live.json]\n Live-mode state sits in retired location(s): `.impeccable-live.json`. Current live mode writes under `.impeccable/live/`.\n → These are read only through backward-compatible fallbacks and are safe to delete once no live session is running. No user decision is needed.\n\nApplied nothing.\nLeft alone:\n legacy-live-state: delete by hand once no live session is running\n",
"stdout": "Impeccable doctor: <WS>\n\nneeds a command (1):\n product-schema-legacy [PRODUCT.md]\n PRODUCT.md has no schema stamp and none of the sections the current record adds (Positioning, Operating Context, Evidence on Hand, Product Principles), so it predates this version of the product record.\n → Offer `init`, which preserves confirmed answers and fills the gaps by interview. Do not rewrite the file from inference.\n\nworth saying (8):\n product-deprecated-register [PRODUCT.md]\n PRODUCT.md still carries a `## Register` section. v4 replaced the brand/product register axis with the four visitor modes (Persuade, Operate, Read, Experience), which are chosen per surface and persisted in that surface's brief. Nothing reads `## Register` any more.\n → Treat `## Register` as absent for every decision this session. Offer to delete the section; do not let its value influence the work either way.\n design-md-coverage [DESIGN.md]\n DESIGN.md has no components section. Agents generating new screens get no normative guidance for those, and the live design panel renders generic approximations in their place.\n → Ask whether the section never applied or was never written. `document` fills it from the code if the project has the answer in its CSS.\n config-unknown-keys [.impeccable/config.json]\n .impeccable/config.json has top-level key(s) nothing reads: `theme`. Recognized keys are `hook`, `detector`, `updateCheck`, `stalenessCheck`, `projectRoots`, `buildPath`, `$schema`, `version`.\n → Report the exact keys to the user. A near-miss of a real key is a setting that has never applied.\n config-invalid-build-path [.impeccable/config.json]\n .impeccable/config.json sets `buildPath` to \"fast\", which nothing reads. The values are `comp` and `code`.\n → Report the value. An unread `buildPath` does not fall back to the other path; it falls back to the default, so a project meaning `code` has been building comp-led.\n config-unknown-detector-keys [.impeccable/config.json]\n .impeccable/config.json has `detector` key(s) nothing reads: `mode`. Recognized keys are `ignoreRules`, `ignoreFiles`, `ignoreValues`, `ignoreSelectors`, `designSystem`, `extensions`.\n → Report the exact keys. `ignoreRule` for `ignoreRules` is the common one, and it silences nothing.\n detector-ignore-rules-unknown [.impeccable/config.json]\n .impeccable/config.json ignores rule id(s) the detector does not have: `not-a-real-rule`. Either the rule was renamed or removed, or the id was mistyped and has never suppressed anything.\n → Report the exact ids. Removing them is safe; keeping a dead ignore hides that the rule is gone.\n detector-ignore-files-missing [.impeccable/config.json]\n .impeccable/config.json ignores file path(s) that no longer exist: `src/vendor/missing.css`.\n → Ask whether the file moved (repoint the entry) or was deleted (drop it). A stale entry silently stops covering the file that replaced it.\n surface-brief-orphaned [.impeccable/surfaces/src-old-astro.md]\n 1 persisted surface brief(s) name a primary target that no longer exists: .impeccable/surfaces/src-old-astro.md → src/old.astro.\n → Ask whether the surface moved (repoint the brief) or was removed (delete the brief). Until then the brief is authority for a file that is gone.\n\nautomatic (1):\n legacy-live-state [.impeccable-live.json]\n Live-mode state sits in retired location(s): `.impeccable-live.json`. Current live mode writes under `.impeccable/live/`.\n → These are read only through backward-compatible fallbacks and are safe to delete once no live session is running. No user decision is needed.\n\nApplied nothing.\nLeft alone:\n legacy-live-state: delete by hand once no live session is running\n",
"stderr": "",
"exit": 0,
"signal": null,
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,5 +1,5 @@
{
"stdout": "Impeccable doctor: <WS>\n\nneeds a command (2):\n product-schema-legacy [PRODUCT.md]\n PRODUCT.md has no schema stamp and none of the sections the current record adds (Positioning, Operating Context, Evidence on Hand, Product Principles), so it predates this version of the product record.\n → Offer `init`, which preserves confirmed answers and fills the gaps by interview. Do not rewrite the file from inference.\n design-sidecar-schema-outdated [DESIGN.json]\n DESIGN.json is schemaVersion 1; the current sidecar is 2. Token primitives moved to the DESIGN.md frontmatter, so the old shape carries values that are now read from two places.\n → Offer `document` to regenerate the sidecar. It reads the existing DESIGN.md, so no interview is needed.\n\nworth saying (9):\n product-deprecated-register [PRODUCT.md]\n PRODUCT.md still carries a `## Register` section. v4 replaced the brand/product register axis with the four visitor modes (Persuade, Operate, Read, Experience), which are chosen per surface and persisted in that surface's brief. Nothing reads `## Register` any more.\n → Treat `## Register` as absent for every decision this session. Offer to delete the section; do not let its value influence the work either way.\n design-sidecar-stale [DESIGN.json]\n DESIGN.md was edited after DESIGN.json was generated, so the sidecar's ramps, shadows, motion tokens, and component snippets may contradict it.\n → Offer `document` to refresh the sidecar, preserving DESIGN.md.\n design-md-coverage [DESIGN.md]\n DESIGN.md has no components section. Agents generating new screens get no normative guidance for those, and the live design panel renders generic approximations in their place.\n → Ask whether the section never applied or was never written. `document` fills it from the code if the project has the answer in its CSS.\n config-unknown-keys [.impeccable/config.json]\n .impeccable/config.json has top-level key(s) nothing reads: `theme`. Recognized keys are `hook`, `detector`, `updateCheck`, `stalenessCheck`, `projectRoots`, `buildPath`, `$schema`, `version`.\n → Report the exact keys to the user. A near-miss of a real key is a setting that has never applied.\n config-invalid-build-path [.impeccable/config.json]\n .impeccable/config.json sets `buildPath` to \"fast\", which nothing reads. The values are `comp` and `code`.\n → Report the value. An unread `buildPath` does not fall back to the other path; it falls back to the default, so a project meaning `code` has been building comp-led.\n config-unknown-detector-keys [.impeccable/config.json]\n .impeccable/config.json has `detector` key(s) nothing reads: `mode`. Recognized keys are `ignoreRules`, `ignoreFiles`, `ignoreValues`, `designSystem`, `extensions`.\n → Report the exact keys. `ignoreRule` for `ignoreRules` is the common one, and it silences nothing.\n detector-ignore-rules-unknown [.impeccable/config.json]\n .impeccable/config.json ignores rule id(s) the detector does not have: `not-a-real-rule`. Either the rule was renamed or removed, or the id was mistyped and has never suppressed anything.\n → Report the exact ids. Removing them is safe; keeping a dead ignore hides that the rule is gone.\n detector-ignore-files-missing [.impeccable/config.json]\n .impeccable/config.json ignores file path(s) that no longer exist: `src/vendor/missing.css`.\n → Ask whether the file moved (repoint the entry) or was deleted (drop it). A stale entry silently stops covering the file that replaced it.\n surface-brief-orphaned [.impeccable/surfaces/src-old-astro.md]\n 1 persisted surface brief(s) name a primary target that no longer exists: .impeccable/surfaces/src-old-astro.md → src/old.astro.\n → Ask whether the surface moved (repoint the brief) or was removed (delete the brief). Until then the brief is authority for a file that is gone.\n\nautomatic (2):\n design-sidecar-legacy-path [DESIGN.json]\n The design sidecar sits at DESIGN.json, a location kept only for backward compatibility.\n → Move it to .impeccable/design.json the next time the sidecar is written. No user decision is needed.\n legacy-live-state [.impeccable-live.json]\n Live-mode state sits in retired location(s): `.impeccable-live.json`. Current live mode writes under `.impeccable/live/`.\n → These are read only through backward-compatible fallbacks and are safe to delete once no live session is running. No user decision is needed.\n\nApplied:\n Moved DESIGN.json to .impeccable/design.json.\nLeft alone:\n legacy-live-state: delete by hand once no live session is running\n",
"stdout": "Impeccable doctor: <WS>\n\nneeds a command (2):\n product-schema-legacy [PRODUCT.md]\n PRODUCT.md has no schema stamp and none of the sections the current record adds (Positioning, Operating Context, Evidence on Hand, Product Principles), so it predates this version of the product record.\n → Offer `init`, which preserves confirmed answers and fills the gaps by interview. Do not rewrite the file from inference.\n design-sidecar-schema-outdated [DESIGN.json]\n DESIGN.json is schemaVersion 1; the current sidecar is 2. Token primitives moved to the DESIGN.md frontmatter, so the old shape carries values that are now read from two places.\n → Offer `document` to regenerate the sidecar. It reads the existing DESIGN.md, so no interview is needed.\n\nworth saying (9):\n product-deprecated-register [PRODUCT.md]\n PRODUCT.md still carries a `## Register` section. v4 replaced the brand/product register axis with the four visitor modes (Persuade, Operate, Read, Experience), which are chosen per surface and persisted in that surface's brief. Nothing reads `## Register` any more.\n → Treat `## Register` as absent for every decision this session. Offer to delete the section; do not let its value influence the work either way.\n design-sidecar-stale [DESIGN.json]\n DESIGN.md was edited after DESIGN.json was generated, so the sidecar's ramps, shadows, motion tokens, and component snippets may contradict it.\n → Offer `document` to refresh the sidecar, preserving DESIGN.md.\n design-md-coverage [DESIGN.md]\n DESIGN.md has no components section. Agents generating new screens get no normative guidance for those, and the live design panel renders generic approximations in their place.\n → Ask whether the section never applied or was never written. `document` fills it from the code if the project has the answer in its CSS.\n config-unknown-keys [.impeccable/config.json]\n .impeccable/config.json has top-level key(s) nothing reads: `theme`. Recognized keys are `hook`, `detector`, `updateCheck`, `stalenessCheck`, `projectRoots`, `buildPath`, `$schema`, `version`.\n → Report the exact keys to the user. A near-miss of a real key is a setting that has never applied.\n config-invalid-build-path [.impeccable/config.json]\n .impeccable/config.json sets `buildPath` to \"fast\", which nothing reads. The values are `comp` and `code`.\n → Report the value. An unread `buildPath` does not fall back to the other path; it falls back to the default, so a project meaning `code` has been building comp-led.\n config-unknown-detector-keys [.impeccable/config.json]\n .impeccable/config.json has `detector` key(s) nothing reads: `mode`. Recognized keys are `ignoreRules`, `ignoreFiles`, `ignoreValues`, `ignoreSelectors`, `designSystem`, `extensions`.\n → Report the exact keys. `ignoreRule` for `ignoreRules` is the common one, and it silences nothing.\n detector-ignore-rules-unknown [.impeccable/config.json]\n .impeccable/config.json ignores rule id(s) the detector does not have: `not-a-real-rule`. Either the rule was renamed or removed, or the id was mistyped and has never suppressed anything.\n → Report the exact ids. Removing them is safe; keeping a dead ignore hides that the rule is gone.\n detector-ignore-files-missing [.impeccable/config.json]\n .impeccable/config.json ignores file path(s) that no longer exist: `src/vendor/missing.css`.\n → Ask whether the file moved (repoint the entry) or was deleted (drop it). A stale entry silently stops covering the file that replaced it.\n surface-brief-orphaned [.impeccable/surfaces/src-old-astro.md]\n 1 persisted surface brief(s) name a primary target that no longer exists: .impeccable/surfaces/src-old-astro.md → src/old.astro.\n → Ask whether the surface moved (repoint the brief) or was removed (delete the brief). Until then the brief is authority for a file that is gone.\n\nautomatic (2):\n design-sidecar-legacy-path [DESIGN.json]\n The design sidecar sits at DESIGN.json, a location kept only for backward compatibility.\n → Move it to .impeccable/design.json the next time the sidecar is written. No user decision is needed.\n legacy-live-state [.impeccable-live.json]\n Live-mode state sits in retired location(s): `.impeccable-live.json`. Current live mode writes under `.impeccable/live/`.\n → These are read only through backward-compatible fallbacks and are safe to delete once no live session is running. No user decision is needed.\n\nApplied:\n Moved DESIGN.json to .impeccable/design.json.\nLeft alone:\n legacy-live-state: delete by hand once no live session is running\n",
"stderr": "",
"exit": 0,
"signal": null,
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,5 +1,5 @@
{
"stdout": "Impeccable doctor: <WS>\n\nneeds a command (2):\n product-schema-legacy [PRODUCT.md]\n PRODUCT.md has no schema stamp and none of the sections the current record adds (Positioning, Operating Context, Evidence on Hand, Product Principles), so it predates this version of the product record.\n → Offer `init`, which preserves confirmed answers and fills the gaps by interview. Do not rewrite the file from inference.\n design-sidecar-schema-outdated [DESIGN.json]\n DESIGN.json is schemaVersion 1; the current sidecar is 2. Token primitives moved to the DESIGN.md frontmatter, so the old shape carries values that are now read from two places.\n → Offer `document` to regenerate the sidecar. It reads the existing DESIGN.md, so no interview is needed.\n\nworth saying (9):\n product-deprecated-register [PRODUCT.md]\n PRODUCT.md still carries a `## Register` section. v4 replaced the brand/product register axis with the four visitor modes (Persuade, Operate, Read, Experience), which are chosen per surface and persisted in that surface's brief. Nothing reads `## Register` any more.\n → Treat `## Register` as absent for every decision this session. Offer to delete the section; do not let its value influence the work either way.\n design-sidecar-stale [DESIGN.json]\n DESIGN.md was edited after DESIGN.json was generated, so the sidecar's ramps, shadows, motion tokens, and component snippets may contradict it.\n → Offer `document` to refresh the sidecar, preserving DESIGN.md.\n design-md-coverage [DESIGN.md]\n DESIGN.md has no components section. Agents generating new screens get no normative guidance for those, and the live design panel renders generic approximations in their place.\n → Ask whether the section never applied or was never written. `document` fills it from the code if the project has the answer in its CSS.\n config-unknown-keys [.impeccable/config.json]\n .impeccable/config.json has top-level key(s) nothing reads: `theme`. Recognized keys are `hook`, `detector`, `updateCheck`, `stalenessCheck`, `projectRoots`, `buildPath`, `$schema`, `version`.\n → Report the exact keys to the user. A near-miss of a real key is a setting that has never applied.\n config-invalid-build-path [.impeccable/config.json]\n .impeccable/config.json sets `buildPath` to \"fast\", which nothing reads. The values are `comp` and `code`.\n → Report the value. An unread `buildPath` does not fall back to the other path; it falls back to the default, so a project meaning `code` has been building comp-led.\n config-unknown-detector-keys [.impeccable/config.json]\n .impeccable/config.json has `detector` key(s) nothing reads: `mode`. Recognized keys are `ignoreRules`, `ignoreFiles`, `ignoreValues`, `designSystem`, `extensions`.\n → Report the exact keys. `ignoreRule` for `ignoreRules` is the common one, and it silences nothing.\n detector-ignore-rules-unknown [.impeccable/config.json]\n .impeccable/config.json ignores rule id(s) the detector does not have: `not-a-real-rule`. Either the rule was renamed or removed, or the id was mistyped and has never suppressed anything.\n → Report the exact ids. Removing them is safe; keeping a dead ignore hides that the rule is gone.\n detector-ignore-files-missing [.impeccable/config.json]\n .impeccable/config.json ignores file path(s) that no longer exist: `src/vendor/missing.css`.\n → Ask whether the file moved (repoint the entry) or was deleted (drop it). A stale entry silently stops covering the file that replaced it.\n surface-brief-orphaned [.impeccable/surfaces/src-old-astro.md]\n 1 persisted surface brief(s) name a primary target that no longer exists: .impeccable/surfaces/src-old-astro.md → src/old.astro.\n → Ask whether the surface moved (repoint the brief) or was removed (delete the brief). Until then the brief is authority for a file that is gone.\n\nautomatic (2):\n design-sidecar-legacy-path [DESIGN.json]\n The design sidecar sits at DESIGN.json, a location kept only for backward compatibility.\n → Move it to .impeccable/design.json the next time the sidecar is written. No user decision is needed.\n legacy-live-state [.impeccable-live.json]\n Live-mode state sits in retired location(s): `.impeccable-live.json`. Current live mode writes under `.impeccable/live/`.\n → These are read only through backward-compatible fallbacks and are safe to delete once no live session is running. No user decision is needed.\n\nRun `<IMPECCABLE> doctor --fix` to apply the automatic migrations, or `/impeccable doctor` to work through all of them.\n",
"stdout": "Impeccable doctor: <WS>\n\nneeds a command (2):\n product-schema-legacy [PRODUCT.md]\n PRODUCT.md has no schema stamp and none of the sections the current record adds (Positioning, Operating Context, Evidence on Hand, Product Principles), so it predates this version of the product record.\n → Offer `init`, which preserves confirmed answers and fills the gaps by interview. Do not rewrite the file from inference.\n design-sidecar-schema-outdated [DESIGN.json]\n DESIGN.json is schemaVersion 1; the current sidecar is 2. Token primitives moved to the DESIGN.md frontmatter, so the old shape carries values that are now read from two places.\n → Offer `document` to regenerate the sidecar. It reads the existing DESIGN.md, so no interview is needed.\n\nworth saying (9):\n product-deprecated-register [PRODUCT.md]\n PRODUCT.md still carries a `## Register` section. v4 replaced the brand/product register axis with the four visitor modes (Persuade, Operate, Read, Experience), which are chosen per surface and persisted in that surface's brief. Nothing reads `## Register` any more.\n → Treat `## Register` as absent for every decision this session. Offer to delete the section; do not let its value influence the work either way.\n design-sidecar-stale [DESIGN.json]\n DESIGN.md was edited after DESIGN.json was generated, so the sidecar's ramps, shadows, motion tokens, and component snippets may contradict it.\n → Offer `document` to refresh the sidecar, preserving DESIGN.md.\n design-md-coverage [DESIGN.md]\n DESIGN.md has no components section. Agents generating new screens get no normative guidance for those, and the live design panel renders generic approximations in their place.\n → Ask whether the section never applied or was never written. `document` fills it from the code if the project has the answer in its CSS.\n config-unknown-keys [.impeccable/config.json]\n .impeccable/config.json has top-level key(s) nothing reads: `theme`. Recognized keys are `hook`, `detector`, `updateCheck`, `stalenessCheck`, `projectRoots`, `buildPath`, `$schema`, `version`.\n → Report the exact keys to the user. A near-miss of a real key is a setting that has never applied.\n config-invalid-build-path [.impeccable/config.json]\n .impeccable/config.json sets `buildPath` to \"fast\", which nothing reads. The values are `comp` and `code`.\n → Report the value. An unread `buildPath` does not fall back to the other path; it falls back to the default, so a project meaning `code` has been building comp-led.\n config-unknown-detector-keys [.impeccable/config.json]\n .impeccable/config.json has `detector` key(s) nothing reads: `mode`. Recognized keys are `ignoreRules`, `ignoreFiles`, `ignoreValues`, `ignoreSelectors`, `designSystem`, `extensions`.\n → Report the exact keys. `ignoreRule` for `ignoreRules` is the common one, and it silences nothing.\n detector-ignore-rules-unknown [.impeccable/config.json]\n .impeccable/config.json ignores rule id(s) the detector does not have: `not-a-real-rule`. Either the rule was renamed or removed, or the id was mistyped and has never suppressed anything.\n → Report the exact ids. Removing them is safe; keeping a dead ignore hides that the rule is gone.\n detector-ignore-files-missing [.impeccable/config.json]\n .impeccable/config.json ignores file path(s) that no longer exist: `src/vendor/missing.css`.\n → Ask whether the file moved (repoint the entry) or was deleted (drop it). A stale entry silently stops covering the file that replaced it.\n surface-brief-orphaned [.impeccable/surfaces/src-old-astro.md]\n 1 persisted surface brief(s) name a primary target that no longer exists: .impeccable/surfaces/src-old-astro.md → src/old.astro.\n → Ask whether the surface moved (repoint the brief) or was removed (delete the brief). Until then the brief is authority for a file that is gone.\n\nautomatic (2):\n design-sidecar-legacy-path [DESIGN.json]\n The design sidecar sits at DESIGN.json, a location kept only for backward compatibility.\n → Move it to .impeccable/design.json the next time the sidecar is written. No user decision is needed.\n legacy-live-state [.impeccable-live.json]\n Live-mode state sits in retired location(s): `.impeccable-live.json`. Current live mode writes under `.impeccable/live/`.\n → These are read only through backward-compatible fallbacks and are safe to delete once no live session is running. No user decision is needed.\n\nRun `<IMPECCABLE> doctor --fix` to apply the automatic migrations, or `/impeccable doctor` to work through all of them.\n",
"stderr": "",
"exit": 0,
"signal": null,
+7
View File
@@ -0,0 +1,7 @@
{
"stdout": "Usage: impeccable ignores <action> [options]\n\nManage detector ignores in .impeccable config.\n\nActions:\n list Show merged, shared, and local ignores\n add-rule <rule> [--all-values] Ignore a rule\n add-file <glob> Ignore files by glob\n add-value <rule> <value> Ignore one rule/value pair\n add-selector <rule> <selector> Ignore one rule on a component, everywhere\n remove-rule <rule> Remove a rule ignore\n remove-file <glob> Remove a file ignore\n remove-value <rule> <value> Remove a rule/value ignore\n remove-selector <rule> <selector> Remove a component ignore\n clear Clear detector ignores in the selected scope\n\nScope:\n --shared Write .impeccable/config.json (default)\n --local Write .impeccable/config.local.json\n --all For remove/clear, apply to shared and local\n\nValue options:\n --file <glob> Scope add-value/add-selector to a file glob\n --reason <text> Store or update a reason on add-value/add-selector\n\nComponent ignores (add-selector) waive one rule for every element a CSS\nselector matches, and for that element's subtree. One entry replaces the\nsame data-impeccable-ignore attribute repeated on every instance of a\ncomponent, and the scan reports how many hits it suppressed instead of\ngoing quiet.\n\nExamples:\n impeccable ignores add-file \"src/legacy/**\"\n impeccable ignores add-value overused-font Inter --reason \"Brand font\"\n impeccable ignores add-value design-system-color \"*\" --file \"src/demo.css\"\n impeccable ignores add-selector undersized-ui-text \".ks-tag\" --reason \"10px mono label, by design\"\n impeccable ignores remove-value overused-font Inter\n impeccable ignores remove-selector undersized-ui-text \".ks-tag\"\n",
"stderr": "",
"exit": 0,
"signal": null,
"files": {}
}
@@ -0,0 +1,7 @@
{
"stdout": "Added undersized-ui-text on .free-label to shared detector ignoreSelectors (.impeccable/config.json).\n",
"stderr": "",
"exit": 0,
"signal": null,
"files": {}
}
@@ -0,0 +1,7 @@
{
"stdout": "Impeccable detector ignores\n shared file: .impeccable/config.json\n local file: .impeccable/config.local.json\n\nMerged:\n ignoreRules: (none)\n ignoreFiles: (none)\n ignoreValues: (none)\n ignoreSelectors: undersized-ui-text on .ks-tag - 10px mono label, confirmed by the author, glow-effect on .demo-stage [src/demo/**]\n designSystem: enabled\n\nShared:\n ignoreRules: (none)\n ignoreFiles: (none)\n ignoreValues: (none)\n ignoreSelectors: undersized-ui-text on .ks-tag - 10px mono label, confirmed by the author, glow-effect on .demo-stage [src/demo/**]\n designSystem: enabled\n\nLocal:\n ignoreRules: (none)\n ignoreFiles: (none)\n ignoreValues: (none)\n designSystem: enabled\n",
"stderr": "",
"exit": 0,
"signal": null,
"files": {}
}
@@ -0,0 +1,7 @@
{
"stdout": "",
"stderr": "Pass a rule id and a CSS selector, e.g. impeccable ignores add-selector undersized-ui-text \".ks-tag\"\n",
"exit": 1,
"signal": null,
"files": {}
}
@@ -0,0 +1,7 @@
{
"stdout": "Removed 1 from shared (.impeccable/config.json).\n",
"stderr": "",
"exit": 0,
"signal": null,
"files": {}
}
@@ -0,0 +1,7 @@
{
"stdout": "",
"stderr": "A `*` selector waives the rule everywhere. Use add-rule for that, or name the component's selector.\n",
"exit": 1,
"signal": null,
"files": {}
}
@@ -0,0 +1,11 @@
{
"detector": {
"ignoreRules": [],
"ignoreFiles": [],
"ignoreValues": [],
"ignoreSelectors": [
{ "rule": "undersized-ui-text", "selector": ".ks-tag", "reason": "10px mono label, confirmed by the author" },
{ "rule": "glow-effect", "selector": ".demo-stage", "files": ["src/demo/**"] }
]
}
}
@@ -0,0 +1 @@
{ "name": "detect-selector-ignores", "private": true }
@@ -0,0 +1,19 @@
<!doctype html>
<html>
<head>
<title>Component opt-out</title>
<style>
body { font-family: system-ui; font-size: 16px; color: #111; background: #fff; }
.ks-tag { font-family: ui-monospace, monospace; font-size: 10px; }
.free-label { font-size: 10px; }
</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>