mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 14:16:28 +03:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b50098a477 | ||
|
|
1d214f1e48 |
@@ -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`.
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
}]
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
}]
|
||||
}
|
||||
|
||||
|
||||
@@ -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"))]
|
||||
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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,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,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,
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user