Compare commits

..
Author SHA1 Message Date
Paul Bakaus 40e050afda Fix home-scoped update checks
Prepared with AI assistance under maintainer authorization.
2026-09-15 09:52:48 -07:00
61 changed files with 129 additions and 1744 deletions
+1 -4
View File
@@ -462,16 +462,13 @@ 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`, `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.
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.
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`.
-10
View File
@@ -36,7 +36,6 @@ function __visualContrastOptions(options = {}, config = {}) {
: false;
return {
...options,
...(Array.isArray(config.ignoreSelectors) ? { ignoreSelectors: config.ignoreSelectors } : {}),
maxCandidates: Number.isFinite(options.visualContrastMaxCandidates)
? options.visualContrastMaxCandidates
: Number.isFinite(options.maxCandidates)
@@ -48,15 +47,6 @@ function __visualContrastOptions(options = {}, config = {}) {
};
}
// Engines keep waiver stamps for callers that report suppression counts.
// UI consumers render only reportable findings, including after visual passes.
function __reportableGroups(groups) {
return groups.map(group => ({
...group,
findings: group.findings.filter(finding => !finding.ignoredBy),
})).filter(group => group.findings.length > 0);
}
// The analyses the lazy pass watches: unresolved only because the text was
// outside the viewport, and addressable.
function __lazyVisualContrastCandidates(analyses) {
-2
View File
@@ -42,7 +42,6 @@ function createVisualContrast(IO) {
function collectVisualContrastCandidates(options = {}) {
return __p(IO.coreSync('collect_visual_contrast_candidates', __j({
ignoreSelectors: options.ignoreSelectors,
maxCandidates: options.maxCandidates,
imageOnly: options.imageOnly,
})));
@@ -50,7 +49,6 @@ function createVisualContrast(IO) {
async function collectVisualContrastCandidatesAsync(options = {}) {
return core('collect_visual_contrast_candidates', __j({
ignoreSelectors: options.ignoreSelectors,
maxCandidates: options.maxCandidates,
imageOnly: options.imageOnly,
}));
+4 -10
View File
@@ -73,11 +73,6 @@ 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,
@@ -118,7 +113,7 @@ if (IS_BROWSER && !__impeccable) {
};
function browserFindingsFromMap(groupMap) {
return __reportableGroups([...groupMap.entries()].map(([el, findings]) => ({ el, findings })));
return [...groupMap.entries()].map(([el, findings]) => ({ el, findings }));
}
function collectBrowserFindings() {
@@ -135,7 +130,7 @@ if (IS_BROWSER && !__impeccable) {
return {
groupMap,
allFindings: browserFindingsFromMap(groupMap),
pageLevelFindings: collected.pageLevel.filter(f => !f.ignoredBy),
pageLevelFindings: collected.pageLevel,
};
}
@@ -172,7 +167,6 @@ if (IS_BROWSER && !__impeccable) {
}
function addVisualContrastResult(groupMap, result, options = {}) {
if (result?.ignoredBy) return false;
const elId = __impeccable.visual_contrast_result_el(JSON.stringify(result));
const el = __el(elId);
if (!el) return false;
@@ -468,12 +462,12 @@ if (IS_BROWSER && !__impeccable) {
if (__impeccable.snapshot_has_needs()) out = { needs: JSON.parse(__impeccable.snapshot_take_needs()) };
rounds++;
}
const serialized = JSON.parse(__impeccable.serialize_findings(JSON.stringify(__reportableGroups(out.groups))));
const serialized = JSON.parse(__impeccable.serialize_findings(JSON.stringify(out.groups)));
const unknownStyleProps = JSON.parse(__impeccable.snapshot_unknown_style_props());
__impeccable.snapshot_clear();
return {
findings: serialized,
pageLevel: out.pageLevel.filter(f => !f.ignoredBy),
pageLevel: out.pageLevel,
stats: { ...cap.stats, rounds, unknownStyleProps, captureMs: t1 - t0, coreMs: performance.now() - t1 },
};
};
+3 -9
View File
@@ -111,11 +111,6 @@
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,
@@ -123,13 +118,12 @@
}
function serialize(wasm, groups) {
return JSON.parse(wasm.serialize_findings(JSON.stringify(__reportableGroups(groups))));
return JSON.parse(wasm.serialize_findings(JSON.stringify(groups)));
}
// addVisualContrastResult over id-keyed groups: the two decisions are the
// core's; this only keeps the map.
function addVisualContrastResult(wasm, groups, result) {
if (result?.ignoredBy) return 0;
const elId = wasm.visual_contrast_result_el(JSON.stringify(result));
if (!elId) return 0;
let group = groups.find(g => g.el === elId);
@@ -150,12 +144,12 @@
const vc = createVisualContrast(IO);
const t0 = performance.now();
const collected = JSON.parse(await IO.core('collect_browser_findings', configJson(config)));
const groups = __reportableGroups(collected.groups);
const groups = collected.groups;
const stats = { elements: n, coreMs: performance.now() - t0, unknownStyleProps: JSON.parse(wasm.snapshot_unknown_style_props()) };
await ask(session, {
stage: 'findings',
groups,
pageLevel: collected.pageLevel.filter(f => !f.ignoredBy),
pageLevel: collected.pageLevel,
serialized: serialize(wasm, groups),
stats,
});
+7 -28
View File
@@ -233,9 +233,6 @@ 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 {
@@ -387,12 +384,6 @@ 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;
}
@@ -468,7 +459,6 @@ 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
@@ -496,7 +486,6 @@ 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")),
});
}
@@ -526,7 +515,6 @@ fn scan_page_inner(
id: f.id,
snippet: f.snippet,
ignore_value: String::new(),
ignored_by: String::new(),
severity: String::new(),
})
.collect(),
@@ -539,13 +527,12 @@ fn scan_page_inner(
id: "script-error".to_string(),
snippet: message,
ignore_value: String::new(),
ignored_by: String::new(),
severity: String::new(),
});
}
let analyses = step(profile, "visual-contrast", "browser-analyze", url, || {
snapshot_engine::analyze_visual_contrast(page, &base, 12.0, true, &config.ignore_selectors)
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)?;
@@ -579,7 +566,6 @@ 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],
@@ -612,17 +598,12 @@ fn run_visual_contrast_fallback(
.iter()
.any(|s| Some(s.as_str()) == r.get("selector").and_then(Value::as_str))
})
.map(|r| {
let f = r.get("finding").expect("filtered on a truthy finding");
let id = js_str(f.get("id"));
let ignored_by = js_str_or_empty(r.get("ignoredBy"));
RawResult {
id,
snippet: js_str(f.get("snippet")),
ignore_value: String::new(),
ignored_by,
severity: String::new(),
}
.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(),
})
.collect();
@@ -663,12 +644,10 @@ fn run_visual_contrast_fallback(
.map_err(cdp_err)?;
Ok::<_, EngineError>(
f.map(|f| {
let ignored_by = js_str_or_empty(candidate.get("ignoredBy"));
vec![RawResult {
id: f.id.to_string(),
snippet: f.snippet,
ignore_value: String::new(),
ignored_by,
severity: String::new(),
}]
})
+1 -4
View File
@@ -186,13 +186,11 @@ 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
@@ -532,11 +530,10 @@ pub fn analyze_visual_contrast(
base: &SnapshotDom,
max_candidates: f64,
scroll_offscreen: bool,
ignores: &[impeccable_core::selector_ignores::SelectorIgnore],
) -> CdpResult<Vec<Value>> {
let options = json!({ "maxCandidates": max_candidates });
let candidates = resolve_needs(base, page, |d| {
visual::collect_visual_contrast_candidates_with_ignores(d, &options, ignores)
visual::collect_visual_contrast_candidates(d, &options)
})?;
let mut results: Vec<Value> = Vec::with_capacity(candidates.len());
let restore = live_scroll(page)?;
+1 -8
View File
@@ -39,14 +39,7 @@ const KNOWN_CONFIG_KEYS: [&str; 9] =
["hook", "detector", "updateCheck", "stalenessCheck", "projectRoots", "buildPath", "browser", "$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; 6] = [
"ignoreRules",
"ignoreFiles",
"ignoreValues",
"ignoreSelectors",
"designSystem",
"extensions",
];
const KNOWN_DETECTOR_KEYS: [&str; 5] = ["ignoreRules", "ignoreFiles", "ignoreValues", "designSystem", "extensions"];
struct NativeEvidence {
platform: &'static str,
+6 -25
View File
@@ -141,31 +141,12 @@ 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) = 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 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 !unknown.is_empty() {
out.push(finding(
"detector-ignore-rules-unknown",
+2 -180
View File
@@ -8,7 +8,6 @@ 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.
@@ -64,41 +63,6 @@ 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
@@ -399,7 +363,6 @@ 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) {
@@ -552,7 +515,6 @@ pub fn check_browser_design_system_sources(
),
severity: None,
ignore_value: Some(display),
ignored_by: None,
});
}
}
@@ -763,10 +725,6 @@ pub fn selector_nodes_for_live_dom(dom: &dyn Dom, selector: &str) -> Option<Vec<
/// pulsing-dot hero promotion. Returns `{ type, detail, severity? }`; the
/// caller applies `_ruleOk`.
pub fn scoped_html_pattern_findings(dom: &dyn Dom) -> Vec<BrowserFinding> {
scoped_html_pattern_findings_with_ignores(dom, &[])
}
fn scoped_html_pattern_findings_with_ignores(dom: &dyn Dom, ignores: &[SelectorIgnore]) -> Vec<BrowserFinding> {
let html = dom.document_html_for_patterns();
// Linked stylesheets are absent from the page's outerHTML, so the probe
// hands their readable, live-resolving rules to the style corpus (#709).
@@ -779,7 +737,6 @@ fn scoped_html_pattern_findings_with_ignores(dom: &dyn Dom, ignores: &[SelectorI
let all = crate::checks::html_patterns::check_html_patterns(&html, Some(&corpora));
let mut out = Vec::new();
for f in all {
let mut pattern_waived = None;
if let Some(selector) = f.selector.as_deref().filter(|s| !s.is_empty()) {
let Some(matches) = selector_nodes_for_live_dom(dom, selector) else {
continue;
@@ -787,21 +744,11 @@ fn scoped_html_pattern_findings_with_ignores(dom: &dyn Dom, ignores: &[SelectorI
if matches.is_empty() {
continue;
}
let active: Vec<_> = matches.into_iter().filter(|el| !scoped_ignore_active(dom, *el, &f.id)).collect();
if active.is_empty() {
if !matches.iter().any(|el| !scoped_ignore_active(dom, *el, &f.id)) {
continue;
}
// One CSS finding can cover many elements. Keep it reportable
// unless every match not already attribute-waived is covered.
for el in active {
match waiving_selector(ignores, &f.id, |sel| matches!(dom.closest(el, sel), Ok(Some(_)))) {
Some(sel) => { pattern_waived = pattern_waived.or(Some(sel.to_string())); }
None => { pattern_waived = None; break; }
}
}
}
let mut item = BrowserFinding::new(f.id.clone(), f.snippet.clone());
item.ignored_by = pattern_waived;
if let Some(sev) = f.severity.as_ref().filter(|s| !s.is_empty()) {
item.severity = Some(sev.clone());
} else if f.id == "pulsing-dot" {
@@ -886,12 +833,6 @@ 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();
@@ -1490,7 +1431,7 @@ pub fn collect_browser_findings(dom: &dyn Dom, config: &BrowserConfig) -> Collec
page_pass(&mut groups, &mut page_level, q::check_page_quality_dom(dom));
page_pass(&mut groups, &mut page_level, hits(pc::check_cream_palette(dom)));
page_pass(&mut groups, &mut page_level, scoped_html_pattern_findings_with_ignores(dom, &config.ignore_selectors));
page_pass(&mut groups, &mut page_level, scoped_html_pattern_findings(dom));
// Rule-pack page rules run after every built-in page pass, through the
// same attribution as the built-in checks that name their own element.
@@ -1523,7 +1464,6 @@ 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 }
}
@@ -1931,124 +1871,6 @@ mod tests {
let pass = json!({ "status": "pass", "selector": "#t", "finding": null });
assert_eq!(visual_contrast_result_el(&d, &pass), None);
}
#[test]
fn pattern_selector_ignores_cover_matches_not_the_body() {
let mut d = FakeDom::new();
let (_, body) = d.with_page();
d.html_for_patterns = "<style>.title { background: linear-gradient(90deg, #f00, #00f); -webkit-background-clip: text; color: transparent; }</style>".into();
let a = d.add(Some(body), "h1");
let b = d.add(Some(body), "h2");
for el in [a, b] { d.add_selector(el, ".title"); }
d.add_selector(a, ".Waived");
let cfg = BrowserConfig {
ignore_selectors: vec![SelectorIgnore::new("gradient-text", ".Waived")],
..BrowserConfig::default()
};
let stamp = |d: &FakeDom| {
collect_browser_findings(d, &cfg).groups.into_iter().flat_map(|g| g.findings)
.find(|f| f.type_ == "gradient-text").expect("pattern finding").ignored_by
};
assert_eq!(stamp(&d), None, "one uncovered match keeps the finding");
d.set_attr(b, "data-impeccable-ignore", "gradient-text");
assert_eq!(stamp(&d).as_deref(), Some(".Waived"), "attribute and config coverage combine");
d.add_selector(b, ".Waived");
assert_eq!(stamp(&d).as_deref(), Some(".Waived"));
}
#[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,7 +1309,6 @@ pub fn check_element_blinking_cursor_dom(dom: &dyn Dom, el: ElId) -> Vec<Browser
None
},
ignore_value: None,
ignored_by: None,
}]
}
+1 -59
View File
@@ -205,17 +205,6 @@ pub fn collect_visual_contrast_reasons(dom: &dyn Dom, el: ElId) -> Vec<String> {
/// JS: index.mjs#collectVisualContrastCandidates(options)
pub fn collect_visual_contrast_candidates(dom: &dyn Dom, options: &Value) -> Vec<Value> {
let config: super::BrowserConfig = serde_json::from_value(options.clone()).unwrap_or_default();
collect_visual_contrast_candidates_with_ignores(dom, options, &config.ignore_selectors)
}
/// Evaluate component waivers while the actual candidate element is in hand.
/// Display selectors can be non-unique; they must not identify the waiver owner.
pub fn collect_visual_contrast_candidates_with_ignores(
dom: &dyn Dom,
options: &Value,
ignores: &[impeccable_foundation::selector_ignores::SelectorIgnore],
) -> Vec<Value> {
let max_candidates = match options.get("maxCandidates") {
Some(Value::Number(n)) if n.as_f64().map_or(false, f64::is_finite) => {
n.as_f64().unwrap()
@@ -226,9 +215,8 @@ pub fn collect_visual_contrast_candidates_with_ignores(
let body = dom.body();
let root = dom.document_element();
let mut candidates: Vec<Value> = Vec::new();
let mut reportable_count = 0usize;
for el in dom.query_all(None, "*").unwrap_or_default() {
if (reportable_count as f64) >= max_candidates {
if (candidates.len() as f64) >= max_candidates {
break;
}
if closest_or_none(dom, el, OVERLAY_SELECTOR).is_some() {
@@ -308,18 +296,6 @@ pub fn collect_visual_contrast_candidates_with_ignores(
let text = slice_utf16_prefix(&collapse_ws(js::trim(&direct)), 80);
let mut m = Map::new();
m.insert("selector".into(), Value::String(super::driver::generate_selector(dom, el)));
if let Some(selector) = impeccable_foundation::selector_ignores::waiving_selector(
ignores, "low-contrast", |selector| matches!(dom.closest(el, selector), Ok(Some(_))),
) {
// Retain a bounded waived sample for suppression tallies without
// spending the budget reserved for reportable candidates.
if ((candidates.len() - reportable_count) as f64) >= max_candidates {
continue;
}
m.insert("ignoredBy".into(), Value::String(selector.to_string()));
} else {
reportable_count += 1;
}
m.insert("tagName".into(), Value::String(tag));
m.insert("text".into(), Value::String(text));
m.insert("threshold".into(), json!(threshold));
@@ -1086,40 +1062,6 @@ mod tests {
Rgba::new(r, g, b, a)
}
#[test]
fn candidate_waivers_use_the_element_not_its_non_unique_display_selector() {
let mut dom = FakeDom::new();
let (_, body) = dom.with_page();
for waived in [true, true, false] {
let host = dom.add(Some(body), "section");
dom.set_rect(host, 0.0, 0.0, 400.0, 300.0);
if waived {
dom.add_selector(host, ".Waived");
}
let mut parent = host;
for _ in 0..12 {
parent = dom.add(Some(parent), "div");
dom.set_rect(parent, 0.0, 0.0, 400.0, 300.0);
}
let el = dom.add(Some(parent), "p");
dom.set_rect(el, 0.0, 0.0, 200.0, 40.0);
dom.add_text(el, "Repeated component text");
dom.set_styles(el, &[("color", "rgb(120, 120, 120)"), ("textShadow", "1px 1px black")]);
}
let candidates = collect_visual_contrast_candidates(&dom, &json!({
"maxCandidates": 1,
"ignoreSelectors": [{ "rule": "low-contrast", "selector": ".Waived" }]
}));
assert_eq!(candidates.len(), 2, "one waived sample plus the reserved reportable slot");
assert_eq!(candidates[0]["selector"], candidates[1]["selector"]);
assert_eq!(candidates[0]["ignoredBy"], ".Waived");
assert!(candidates[1].get("ignoredBy").is_none());
let early = unresolved(&candidates[0], "needs screenshot pixels");
assert_eq!(early["ignoredBy"], ".Waived");
let ordinary = collect_visual_contrast_candidates(&dom, &json!({}));
assert!(ordinary.iter().all(|c| c.get("ignoredBy").is_none()));
}
#[test]
fn blend_and_worst_color() {
let fg = rgba(0.0, 0.0, 0.0, 0.5);
+1 -1
View File
@@ -17,7 +17,7 @@ pub mod checks;
pub use impeccable_foundation::{
color, constants, fdlibm_trig, findings, fonts, inline_ignores, js, js_ext_a, js_ext_b, page,
registry, rule_pack, selector_ignores,
registry, rule_pack,
};
#[cfg(any(test, feature = "vectors"))]
+4 -81
View File
@@ -9,8 +9,7 @@ use impeccable_core::registry::{filter_by_scopes, rule_scopes};
use serde_json::Value;
use crate::config::{
filter_detection_findings_reported, read_detection_config, selector_ignores_for_target,
selector_ignores_for_url, should_ignore_detection_file, DetectionConfig, IgnoredBySelector,
filter_detection_findings, read_detection_config, should_ignore_detection_file, DetectionConfig,
};
use crate::design_system::{load_design_system_for_target, DesignSystemCache};
use crate::detect_text::{detect_text, TextOptions};
@@ -58,16 +57,7 @@ Exit status:
Project config:
Respects .impeccable/config.json and .impeccable/config.local.json detector
settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues,
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.
and detector.designSystem.enabled.
Inline ignores:
In-file comments waive a finding where it lives and travel with the file:
@@ -217,33 +207,6 @@ 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 {
@@ -288,26 +251,6 @@ 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();
};
@@ -564,8 +507,6 @@ 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,
@@ -646,10 +587,7 @@ fn detect_cli(args_in: &[String], io: &mut Io, engines: &Engines) -> Result<i32,
result?;
}
// 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);
all = filter_detection_findings(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 {
@@ -667,22 +605,13 @@ 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!(
@@ -696,18 +625,12 @@ 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)
}
@@ -753,7 +676,7 @@ fn scan_targets(
let local = file_url_to_local_path(target);
ctx.scan_options_for(local.as_deref())
} else {
ctx.url_scan_options()
ctx.base.clone()
};
let result = match (shared, ctx.engines.url) {
(Some(s), _) => s.detect_url(target, &url_options),
+2 -359
View File
@@ -5,7 +5,6 @@
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};
@@ -43,7 +42,6 @@ const DETECTOR_CONFIG_KEYS: &[&str] = &[
"ignoreRules",
"ignoreFiles",
"ignoreValues",
"ignoreSelectors",
"designSystem",
"advisoryRules",
];
@@ -80,44 +78,6 @@ 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.
@@ -126,7 +86,6 @@ 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>,
}
@@ -172,9 +131,6 @@ 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> {
@@ -234,22 +190,6 @@ 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 {
@@ -703,187 +643,6 @@ 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,
@@ -1007,24 +766,8 @@ pub fn should_ignore_detection_file(file_path: &str, root: &str, config: &Detect
false
}
/// 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`].
/// JS: impeccable-config.mjs#filterDetectionFindings
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![];
}
@@ -1070,13 +813,7 @@ fn is_ignored_finding_value(finding: &Finding, ignore_values: &[IgnoreValueEntry
}
fn finding_matches_scoped_ignore_file(finding: &Finding, globs: &[String]) -> bool {
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);
let file_path = js::trim(&finding.file);
if file_path.is_empty() {
return false;
}
@@ -1384,98 +1121,4 @@ mod tests {
assert_eq!(decode_uri_component("Open%20Sans"), "Open Sans");
assert_eq!(decode_uri_component("bad%zz"), "bad%zz");
}
fn config_with_selectors(raw: &str) -> DetectionConfig {
let mut config = DetectionConfig::with_defaults();
let parsed: Value = serde_json::from_str(raw).unwrap();
apply_detection_config_source(&mut config, parsed.as_object());
config
}
#[test]
fn ignore_selectors_parse_normalize_and_merge() {
let config = config_with_selectors(
r#"{"ignoreSelectors":[
{"rule":"Undersized-UI-Text","selector":" .ks-tag ","reason":"by design"},
{"rule":"","selector":".x"},
{"rule":"side-tab"},
"nope",
{"rule":"undersized-ui-text","selector":".ks-tag","reason":"second word wins"},
{"rule":"glow-effect","selector":".demo","files":["src/demo/**"," "]}
]}"#,
);
// Half-entries and junk are dropped, and the same rule+selector+files
// key is one entry the later value replaces.
assert_eq!(config.ignore_selectors.len(), 2);
let first = &config.ignore_selectors[0];
assert_eq!(first.rule, "undersized-ui-text");
assert_eq!(first.selector, ".ks-tag");
assert_eq!(first.reason.as_deref(), Some("second word wins"));
assert_eq!(
config.ignore_selectors[1].files.as_deref(),
Some(["src/demo/**".to_string()].as_slice())
);
}
#[test]
fn selector_ignores_are_narrowed_per_target() {
let config = config_with_selectors(
r#"{"ignoreSelectors":[
{"rule":"undersized-ui-text","selector":".ks-tag"},
{"rule":"glow-effect","selector":".demo","files":["src/demo/**"]}
]}"#,
);
let everywhere = selector_ignores_for_target(&config, "src/pages/index.astro");
assert_eq!(everywhere.len(), 1);
assert_eq!(everywhere[0].selector, ".ks-tag");
let scoped = selector_ignores_for_target(&config, "src/demo/playground.astro");
assert_eq!(scoped.len(), 2);
// A URL scan is covered by the unscoped entries only: a `files` glob
// describes repo paths, and must not reach a URL path that happens to
// end the same way.
assert_eq!(selector_ignores_for_url(&config).len(), 1);
let url_globs = config_with_selectors(
r#"{"ignoreSelectors":[
{"rule":"glow-effect","selector":".demo","files":["index.html"]}
]}"#,
);
assert!(selector_ignores_for_url(&url_globs).is_empty());
assert_eq!(
selector_ignores_for_target(&url_globs, "src/index.html").len(),
1
);
// `--no-config` leaves the list empty, so nothing is waived.
assert!(selector_ignores_for_target(&DetectionConfig::raw(), "a.html").is_empty());
}
#[test]
fn stamped_findings_leave_the_reportable_set_as_a_count() {
let stamp = |rule: &str, selector: Option<&str>| {
impeccable_core::findings::stamp_ignored_by(
impeccable_core::findings::finding(rule, "a.html", "snip", 0.0),
selector,
)
};
let findings = vec![
stamp("undersized-ui-text", Some(".ks-tag")),
stamp("undersized-ui-text", Some(".ks-tag")),
stamp("side-tab", Some(".ks-tag")),
stamp("undersized-ui-text", None),
];
let (kept, report) =
filter_detection_findings_reported(findings, &DetectionConfig::with_defaults());
assert_eq!(kept.len(), 1);
assert_eq!(report.len(), 2);
assert_eq!(report[0].rule, "undersized-ui-text");
assert_eq!(report[0].selector, ".ks-tag");
assert_eq!(report[0].count, 2);
assert_eq!(report[1].count, 1);
// Nothing stamped, nothing reported.
let (kept, report) = filter_detection_findings_reported(
vec![stamp("side-tab", None)],
&DetectionConfig::with_defaults(),
);
assert_eq!(kept.len(), 1);
assert!(report.is_empty());
}
}
-5
View File
@@ -7,7 +7,6 @@ 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;
@@ -24,10 +23,6 @@ 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.
+14 -204
View File
@@ -8,7 +8,7 @@ use impeccable_core::js;
use crate::config::{
get_config_path, get_local_config_path, normalize_ignore_value, read_detection_config,
read_raw_detection_config, synthetic_ignore_value, write_detection_config, DetectionConfig,
IgnoreSelectorEntry, IgnoreValueEntry,
IgnoreValueEntry,
};
use crate::jsp;
@@ -21,11 +21,9 @@ 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:
@@ -34,22 +32,14 @@ Scope:
--all For remove/clear, apply to shared and local
Value options:
--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.
--file <glob> Scope add-value/remove-value to a file glob
--reason <text> Store or update a reason on add-value
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> {
@@ -58,11 +48,9 @@ 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,
})
@@ -203,27 +191,6 @@ 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() {
@@ -232,29 +199,21 @@ 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)),
];
// 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")
format!(
" designSystem: {}",
if config.design_system_enabled == Some(false) {
"disabled"
} else {
"enabled"
}
),
]
.join("\n")
}
fn rel_or_abs(cwd: &str, target: &str) -> String {
@@ -467,140 +426,6 @@ 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],
@@ -668,18 +493,6 @@ 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() {
@@ -695,7 +508,6 @@ 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!(
@@ -735,11 +547,9 @@ pub fn run(args: &[String], io: &mut Io) -> i32 {
"add-rule" => add_rule(&cwd, &rest),
"add-file" => add_file(&cwd, &rest),
"add-value" => add_value(&cwd, &rest),
"add-selector" => add_selector(&cwd, &rest),
"remove-rule" => remove_rule(&cwd, &rest),
"remove-file" => remove_file(&cwd, &rest),
"remove-value" => remove_value(&cwd, &rest),
"remove-selector" => remove_selector(&cwd, &rest),
_ => clear(&cwd, &rest),
};
match out {
-55
View File
@@ -39,17 +39,6 @@ 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 {
@@ -59,7 +48,6 @@ impl BrowserFinding {
detail: detail.into(),
severity: None,
ignore_value: None,
ignored_by: None,
}
}
/// `{ type: f.id, detail: f.snippet }` from a Section 3 hit.
@@ -139,35 +127,6 @@ 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)]
@@ -186,20 +145,6 @@ pub struct BrowserConfig {
/// overlay. Serialized as `disabledValues`.
#[serde(default, deserialize_with = "de_disabled_values")]
pub disabled_values: Vec<DisabledValue>,
/// `window.__IMPECCABLE_CONFIG__?.ignoreSelectors`: the project's
/// component-level opt-outs, `[{ rule, selector }]`. Every finding on an
/// element the selector matches (or on a descendant of one) is stamped
/// with that selector instead of being reported clean, the same waiver
/// `data-impeccable-ignore` grants the element that carries it. Honored
/// in every mode: unlike `disabledRules`, this list is the project's own
/// config rather than a browser-extension preference. Empty by default,
/// and skipped in serialization so a config without it is byte-identical.
#[serde(
default,
deserialize_with = "de_ignore_selectors",
skip_serializing_if = "Vec::is_empty"
)]
pub ignore_selectors: Vec<crate::selector_ignores::SelectorIgnore>,
/// `window.__IMPECCABLE_CONFIG__?.skipScan === true` (only honored in
/// extension mode): the page is waived wholesale by detector.ignoreFiles,
/// so every scan stage answers empty.
-36
View File
@@ -84,31 +84,6 @@ 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::*;
@@ -132,15 +107,4 @@ mod tests {
assert_eq!(finding("script-error", "f", "s", 0.0).severity, "error");
assert!(try_finding("nope", "f", "s", 0.0).is_none());
}
#[test]
fn ignored_by_stamp_round_trips_and_stays_off_by_default() {
let plain = finding("side-tab", "a.html", "s", 0.0);
assert_eq!(ignored_by(&stamp_ignored_by(plain.clone(), None)), None);
assert_eq!(ignored_by(&stamp_ignored_by(plain.clone(), Some(""))), None);
let stamped = stamp_ignored_by(plain, Some(".ks-tag"));
assert_eq!(ignored_by(&stamped), Some(".ks-tag"));
let json = serde_json::to_string(&stamped).unwrap();
assert!(json.ends_with(r#""snippet":"s","ignoredBy":".ks-tag"}"#), "{json}");
}
}
-1
View File
@@ -25,7 +25,6 @@ pub mod page;
pub mod registry;
pub mod rule_pack;
pub mod rules;
pub mod selector_ignores;
#[cfg(any(test, feature = "vectors"))]
pub mod vectors;
-108
View File
@@ -1,108 +0,0 @@
//! 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
);
}
}
+1 -34
View File
@@ -515,11 +515,7 @@ fn detect_proposed_html(
let tmp = dir.join(jsp::basename(file_path));
let result = (|| {
std::fs::write(&tmp, content).map_err(|e| e.to_string())?;
// Read proposed bytes from the temporary file, but resolve project
// selector scopes against the file the user is actually editing.
let findings = rt.html.detect_html(
&tmp.to_string_lossy(), &scan.to_scan_options(file_path), &mut std::io::sink(),
).map_err(|e| e.message)?;
let findings = detector_detect_html(rt, &tmp.to_string_lossy(), scan)?;
Ok(findings
.into_iter()
.map(|mut f| {
@@ -920,32 +916,3 @@ pub fn run(rt: &Runtime, stdin: &str, io: &mut impeccable_common::Io) -> i32 {
io.out(&out.stdout);
0
}
#[cfg(test)]
mod tests {
use super::*;
use impeccable_detect::engines::{EngineError, HtmlEngine, ScanOptions};
#[test]
fn proposed_html_scopes_ignores_to_the_original_file() {
struct Probe;
impl HtmlEngine for Probe {
fn detect_html(&self, path: &str, options: &ScanOptions, _: &mut dyn std::io::Write) -> Result<Vec<Finding>, EngineError> {
assert!(path.contains("impeccable-pre-"), "content is read from the temporary file");
assert_eq!(options.ignore_selectors.len(), 1, "scope uses the original target");
assert_eq!(options.ignore_selectors[0].selector, ".Waived");
Ok(vec![])
}
}
let rt = Runtime::new("/project".into(), Default::default(), "/impeccable".into(), "/impeccable", &Probe);
let scan = HookScanOptions {
ignore_selectors: vec![impeccable_detect::config::IgnoreSelectorEntry {
rule: "*".into(), selector: ".Waived".into(),
files: Some(vec!["src/pages/**".into()]),
..Default::default()
}],
..Default::default()
};
detect_proposed_html(&rt, "<h1>Proposed</h1>", "/project/src/pages/index.html", &scan).unwrap();
}
}
+6 -33
View File
@@ -13,9 +13,8 @@ use impeccable_core::findings::Finding;
use impeccable_core::js;
use impeccable_detect::config::{
extract_finding_ignore_value, filter_detection_findings, matches_any_glob,
merge_ignore_selectors, normalize_ignore_rule, normalize_ignore_value,
normalize_ignore_value_entries, selector_ignores_for_target, DetectionConfig,
IgnoreSelectorEntry, IgnoreValueEntry,
normalize_ignore_rule, normalize_ignore_value, normalize_ignore_value_entries, DetectionConfig,
IgnoreValueEntry,
};
use impeccable_detect::design_system::{load_design_system_for_cwd, resolve_design_md_path, DesignSystem};
use impeccable_detect::detect_text::{detect_text, TextOptions};
@@ -374,8 +373,6 @@ 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,
@@ -392,7 +389,6 @@ 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(),
@@ -486,9 +482,6 @@ 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);
}
@@ -996,7 +989,6 @@ 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,
};
@@ -1623,9 +1615,6 @@ 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 {
@@ -1635,19 +1624,12 @@ impl HookScanOptions {
.map(|d| d.md_newer_than_json)
.unwrap_or(false)
}
pub fn to_scan_options(&self, target: &str) -> ScanOptions {
pub fn to_scan_options(&self) -> 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,
}
}
@@ -1655,19 +1637,11 @@ 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 {
design_system: None,
ignore_selectors,
};
return HookScanOptions::default();
}
HookScanOptions {
design_system: load_design_system_for_cwd(project_cwd).map(Rc::new),
ignore_selectors,
}
}
@@ -1679,8 +1653,7 @@ pub fn design_system_options_for_file(
file_path: &str,
) -> HookScanOptions {
if !config.design_system_enabled {
// Same as above: the waivers travel even when no design system does.
return design_system_options(config, project_cwd);
return HookScanOptions::default();
}
let project = impeccable_context::context::resolve_project(
project_cwd,
@@ -1722,7 +1695,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(file_path), &mut sink)
.detect_html(file_path, &scan.to_scan_options(), &mut sink)
.map_err(|e| e.message)
}
+3 -36
View File
@@ -28,8 +28,7 @@ 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::{stamp_ignored_by, try_finding, Finding};
use impeccable_core::selector_ignores::{waiving_selector, SelectorIgnore};
use impeccable_core::findings::{try_finding, Finding};
use impeccable_core::inline_ignores::apply_inline_ignores;
use impeccable_core::page::is_full_page;
use once_cell::sync::Lazy;
@@ -81,13 +80,6 @@ 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
@@ -236,14 +228,8 @@ 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(stamp_ignored_by(f, waived));
findings.push(f);
}
}
}
@@ -330,7 +316,6 @@ 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);
@@ -344,24 +329,6 @@ 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.iter().filter(|el| !scoped_ignore_active(el, &f.id)) {
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) {
@@ -369,7 +336,7 @@ pub fn detect_html_source(
item.severity = sev.clone();
}
impeccable_core::findings::derive_advisory_flag(&mut item);
findings.push(stamp_ignored_by(item, pattern_waived.as_deref()));
findings.push(item);
}
}
-1
View File
@@ -80,7 +80,6 @@ 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 {
-132
View File
@@ -1,132 +0,0 @@
//! 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 pattern_waivers_combine_attribute_and_config_coverage() {
let html = r#"<html><head><style>.title { background: linear-gradient(90deg, #f00, #00f); -webkit-background-clip: text; color: transparent; }</style></head><body>
<h1 class="title Waived">One</h1><h2 class="title" data-impeccable-ignore="gradient-text">Two</h2>
</body></html>"#;
let entries = [SelectorIgnore::new("gradient-text", ".Waived")];
let findings = scan(html, &entries);
let hits: Vec<_> = findings.iter().filter(|f| f.antipattern == "gradient-text").collect();
assert_eq!(hits.len(), 2, "one element finding and one stylesheet pattern");
assert!(hits.iter().all(|f| ignored_by(f) == Some(".Waived")));
let uncovered = scan(&html.replace("data-impeccable-ignore=\"gradient-text\"", ""), &entries);
let hits: Vec<_> = uncovered.iter().filter(|f| f.antipattern == "gradient-text").collect();
assert_eq!(hits.len(), 3, "two elements and one stylesheet pattern");
assert_eq!(hits.iter().filter(|f| ignored_by(f).is_none()).count(), 2,
"the uncovered element and shared pattern remain reportable");
}
#[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
-35
View File
@@ -9,8 +9,6 @@
//! ignoreFiles detector.ignoreFiles globs, unioned across roots, so a
//! wholly waived page scans to zero findings in the overlay
//! just as it reports nothing through the CLI and the hook.
//! ignoreSelectors component opt-outs ({rule, selector, files?}), resolved
//! to the served page by the same browser-side scope matcher.
//! roots served-root prefixes derived from the inject config's own
//! `files` globs. Never derived from the ignore globs: one
//! entry scoped to prototype/library/** would lend
@@ -73,7 +71,6 @@ pub fn collect_project_detector_ignores(
let mut ignore_files: Vec<String> = Vec::new();
let mut value_keys: Vec<String> = Vec::new();
let mut value_entries: Vec<Value> = Vec::new();
let mut selector_entries: Vec<Value> = Vec::new();
for dir in &config_roots {
// readConfig merges config.json with the gitignored
// config.local.json and type-checks both, exactly as the edit hook
@@ -115,17 +112,6 @@ pub fn collect_project_detector_ignores(
value_entries.push(Value::Object(serialized));
}
}
for entry in &config.ignore_selectors {
let mut serialized = serde_json::json!({ "rule": entry.rule, "selector": entry.selector });
let mut files = entry.files.clone().unwrap_or_default();
files.sort();
if !files.is_empty() {
serialized["files"] = serde_json::json!(files);
}
if !selector_entries.contains(&serialized) {
selector_entries.push(serialized);
}
}
}
let (roots, page_files) = read_live_served_pages(cwd, env, &config_roots[0], repo_root);
@@ -135,9 +121,6 @@ pub fn collect_project_detector_ignores(
Value::Array(ignore_rules.into_iter().map(Value::String).collect()),
);
out.insert("ignoreValues".into(), Value::Array(value_entries));
if !selector_entries.is_empty() {
out.insert("ignoreSelectors".into(), Value::Array(selector_entries));
}
out.insert(
"ignoreFiles".into(),
Value::Array(ignore_files.into_iter().map(Value::String).collect()),
@@ -286,24 +269,6 @@ mod tests {
out
}
#[test]
fn collects_selector_ignores_across_roots_without_private_metadata() {
let repo = Tmp::new();
let app = format!("{}/site", repo.path());
repo.write("site/package.json", "{}");
let entries = json!([
{ "rule": "low-contrast", "selector": ".Card", "files": ["b/**", "a/**"], "reason": "local" }
]);
repo.write(".impeccable/config.json", &detector_config(json!({ "ignoreSelectors": entries })));
repo.write("site/.impeccable/config.local.json", &detector_config(json!({
"ignoreSelectors": [{ "rule": "low-contrast", "selector": ".Card", "files": ["a/**", "b/**"] }]
})));
let out = collect(&app, Some(&repo.path()));
assert_eq!(out["ignoreSelectors"], json!([
{ "rule": "low-contrast", "selector": ".Card", "files": ["a/**", "b/**"] }
]));
}
#[test]
fn collects_waivers_roots_and_page_files_from_a_single_root() {
let app = Tmp::new();
+8 -7
View File
@@ -140,25 +140,26 @@ fn locale_compare(a: &str, b: &str) -> std::cmp::Ordering {
fn check(io: &mut Io) -> R<()> {
let (sys, _) = ctx(io);
let root = sys.find_project_root();
if sys.is_already_installed(&root, None).is_none() {
// A home-rooted check is the user-level equivalent of `update --global`.
// Keep both verbs on the canonical provider paths so stale legacy paths
// (for example ~/.pi/skills) cannot make only `check` report drift.
let scope = if sys.is_home_dir(&root) { Some(Scope::User) } else { None };
if sys.is_already_installed(&root, scope).is_none() {
out(io, "Impeccable is not installed in this project.");
out(io, "Run `npx impeccable install` to install.");
return Err(Flow::Exit(0));
}
let providers = sys.find_installed_providers(&root, None);
let providers = sys.find_installed_providers(&root, scope);
out(io, "Checking for updates...\n");
let result = (|| -> Result<bool, String> {
let bundle_dir = bundle::download_and_extract_bundle(&sys)?;
// JS: agentScope 'user' for a home-rooted checkout (d2a9efb9), so
// check() judges agent freshness against the user agent dirs.
let agent_scope = if sys.is_home_dir(&root) { Some(Scope::User) } else { None };
let up_to_date = bundle::is_up_to_date(&sys, &root, &providers, &bundle_dir, None, agent_scope)?;
let up_to_date = bundle::is_up_to_date(&sys, &root, &providers, &bundle_dir, scope, scope)?;
util::rm_rf(&bundle_dir);
Ok(up_to_date)
})();
match result {
Ok(true) => {
let v = sys.get_skills_version(&root, None);
let v = sys.get_skills_version(&root, scope);
out(io, &format!("Skills are up to date{}.", version_suffix(&v)));
}
Ok(false) => {
+34
View File
@@ -460,6 +460,40 @@ fn check_accepts_current_copilot_user_agents_in_home_rooted_checkout() {
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn check_ignores_stale_legacy_pi_skills_when_the_user_install_is_current() {
let root = temp_root("pi-check-home-scope");
let home = format!("{root}/home");
let tmpdir = format!("{root}/tmp");
for d in [&home, &tmpdir] {
std::fs::create_dir_all(d).unwrap();
}
let bundle_root = create_fake_universal_bundle(&root, &[".pi"]);
let env = base_env(&home, &tmpdir, &bundle_root);
let r = run_cli(
&["install", "-y", "--scope=global", "--no-hooks", "--providers=pi"],
&home,
&env,
);
assert_eq!(r.code, 0, "{}\n{}", r.stdout, r.stderr);
let canonical = format!("{home}/.pi/agent/skills/impeccable");
let legacy = format!("{home}/.pi/skills/impeccable");
std::fs::create_dir_all(format!("{home}/.pi/skills")).unwrap();
std::fs::create_dir_all(&legacy).unwrap();
write(&format!("{legacy}/SKILL.md"), "---\nname: impeccable\nversion: 1.0.0-stale\n---\n");
assert!(std::path::Path::new(&canonical).exists());
let update = run_cli(&["update", "--global", "-y", "--no-hooks"], &home, &env);
assert!(update.stdout.contains("Skills are up to date"), "{}\n{}", update.stdout, update.stderr);
let check = run_cli(&["check"], &home, &env);
assert!(check.stdout.contains("Skills are up to date"), "{}\n{}", check.stdout, check.stderr);
assert!(!check.stdout.contains("Updates available"), "{}", check.stdout);
std::fs::remove_dir_all(&root).ok();
}
// ─── inferred agent update scope (d2a9efb9) ──────────────────────────────────
#[test]
+1 -28
View File
@@ -7,22 +7,13 @@
//! ```json
//! {
//! "inlineIgnores": true,
//! "designSystem": { "frontmatter": { ... }, "sidecar": { ... } },
//! "ignoreSelectors": [{ "rule": "undersized-ui-text", "selector": ".ks-tag" }]
//! "designSystem": { "frontmatter": { ... }, "sidecar": { ... } }
//! }
//! ```
//!
//! - `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`
@@ -51,7 +42,6 @@ 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;
@@ -75,7 +65,6 @@ 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 {
@@ -98,24 +87,9 @@ 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,
}
}
@@ -168,7 +142,6 @@ 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)
+7 -13
View File
@@ -184,7 +184,7 @@ Examples:
Then `files = walkDir(resolved).filter(f => !shouldIgnoreDetectionFile(f, cwd, config))`. If `files.length > 50 && stdin.isTTY && !json && !quiet`: `stderr> \nFound ${n} files (${htmlCount} HTML) in ${target}.\nScanning may take a while${htmlCount > 10 ? ' (static HTML/CSS processes each HTML file individually)' : ''}.\nTarget a specific subdirectory to narrow scope.\n` then readline prompt `Continue? [Y/n] ` on stderr; empty or `/^y(es)?$/i` continues; otherwise `stderr> Aborted.\n`, `exit 0`. Then `buildImportGraph(files)` → reverse map; each file scanned with its own options; findings from a file that is imported get `f.importedBy = [basename(importer), ...]` (Set iteration order).
- **File**: skipped if `shouldIgnoreDetectionFile`; else `detectLocalFile`.
- `detectLocalFile(fp, opts)`: extension (lowercased) in `HTML_EXTENSIONS = {'.html','.htm'}``detectHtml(fp, opts)`; else `detectText(readFileSync(fp,'utf-8'), fp, opts)`.
4. Post-filter: `filterDetectionFindings(all, config)` (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.
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.
5. Partition `{primary, advisory}` by `f.advisory === true || f.severity === 'advisory'`.
Any target that cannot be scanned sets `hadOperationalFailure` (#711): a URL
@@ -201,8 +201,7 @@ 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))`.
- **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)`.
- no findings: json → `stdout> []\n`; text/quiet → nothing. `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`).
@@ -245,7 +244,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), `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'`.
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'`.
**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'] }`.
@@ -270,8 +269,7 @@ 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.
- **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-*`.
- Tests: `tests/inline-ignores.test.mjs`; fixture `scoped-ignore.html`.
#### Config file (`cli/lib/impeccable-config.mjs`) — `.impeccable/config.json` + `.impeccable/config.local.json`
@@ -280,27 +278,24 @@ 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`: 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).
- `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).
- `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`, `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).
- 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).
- 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:
```
@@ -312,7 +307,6 @@ 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:
@@ -389,7 +383,7 @@ retain their local-development trust behavior. See [bundle signing](BUNDLE-SIGNI
Already installed (and not `--force`): `Impeccable skills are already installed (found in ${provider}/).`; compares tree hashes (`sha256` of file content with `\.(claude|cursor|...)\/skills\/` normalized to `.PROVIDER/skills/`); if differs → refresh + `Updated ${n} skill(s) to v${v}.`; missing hooks repaired; else `Skills are up to date (v${v}).` + `Run with --force to reinstall.`; offline → `Could not check for skill updates: ${msg}` + `Existing skills were left unchanged.`; ends `Done!` or the above; `exit 0`. Version read from `^version:\s*(.+)$` in installed `impeccable/SKILL.md`.
- **update flags**: `-y|--yes`, `--force`, `--no-hooks`, scope flags as above (unknown → `Unknown update scope: ${v}. Use --project or --user.`). Resolves project vs user installs holding an `impeccable`/`*-impeccable`/`teach-impeccable` skill; none → `No impeccable skill folders found in this project or at the user level.` + `Run \`npx impeccable install\` to install first.`, exit 1; both → prompt `Update which? [project]/user: ` (non-TTY defaults project). Prints `Updating the ${label} install: ${root} (${providers})`, linked providers note, `Checking for updates...`; up to date → `Skills are up to date (vX).` [+hooks] + `Nothing else to do.`, exit 0; else `Found skills in: ...`, prompt `Update skills in N provider folder(s)? (Y/n) ` (n/no → `Aborted.` exit 0), refresh, `Updated N skill(s) to vX.`, `Done!`.
- **link**: `--source=<path>` (default `.impeccable`), `--providers`, `--force`, `-y`. Source must contain `dist/universal/` or provider `*/skills` dirs, else `Could not find compiled skills in ${src}. Expected dist/universal/ or provider skill folders.` Prompts `Link impeccable skills into N folder(s)? (Y/n) `; creates relative dir symlinks; existing non-link skipped with warning unless `--force`; output `Linked impeccable into: ... (N linked, N already linked, N skipped).` + submodule hint.
- **check**: not installed → `Impeccable is not installed in this project.` + `Run \`npx impeccable install\` to install.` exit 0; else `Checking for updates...\n` then `Skills are up to date (vX).` or `Updates available.` + `Run \`npx impeccable update\` to update.`; failure → `Could not check for updates: ${msg}` exit 1.
- **check**: not installed → `Impeccable is not installed in this project.` + `Run \`npx impeccable install\` to install.` exit 0; else `Checking for updates...\n` then `Skills are up to date (vX).` or `Updates available.` + `Run \`npx impeccable update\` to update.`; failure → `Could not check for updates: ${msg}` exit 1. A home-rooted check uses user scope, matching `update --global`: provider-specific canonical global paths are compared, while stale legacy duplicates such as `~/.pi/skills` do not create false update notices.
- Prompts: non-TTY `ask()` reads answers line-by-line from stdin (fd 0) after echoing the question; TTY SIGINT → `PromptAbortError` (`code IMPECCABLE_PROMPT_ABORT`) → cli.js prints `\nAborted.` exit 130. ANSI (`\x1b[36m` accent, `\x1b[1m` bold, `\x1b[2m` dim, `\x1b[32m` good) only when stdout is TTY, `NO_COLOR` unset, `TERM !== 'dumb'`.
- Tests: `tests/skills-cli.test.js`, `tests/cli-remote-e2e` (opt-in).
+2 -10
View File
@@ -204,19 +204,11 @@ 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? }, ignoreSelectors?: [{ rule, selector }] }`
Both take `{ inlineIgnores?: boolean, designSystem?: { frontmatter?, sidecar? } }`
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. `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.
`Map`s that JSON cannot hold. 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
+1 -9
View File
@@ -12,7 +12,7 @@ This command toggles the hook **per project** by editing `.impeccable/config.jso
Declare server-side template extensions under **`detector.extensions`** when the project uses Blade, Twig, ERB, or Handlebars files; the hook skips them otherwise because they sit outside the built-in extension list. One entry per extension, `{ "ext": ".blade.php", "engine": "html" }`. `engine` picks the analyzer (`html` for markup templates, `text` for JS/TS/CSS-like files) and defaults to `html`. Match against the end of the filename, so double extensions like `.blade.php` and `.html.erb` work. Config only adds extensions; the built-in list always applies.
Manual `npx impeccable detect` scans use the same project filter config by default: `detector.ignoreRules`, `detector.ignoreFiles`, `detector.ignoreValues`, `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.
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.
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,11 +63,9 @@ 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:
@@ -94,12 +92,6 @@ 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
+1 -15
View File
@@ -232,21 +232,7 @@
disabledValues.push({ rule, value });
}
const ignoreSelectors = [];
for (const entry of asArray(config.ignoreSelectors)) {
if (!entry || typeof entry.rule !== 'string' || typeof entry.selector !== 'string') continue;
const rule = normalizeIgnoreRule(entry.rule);
const selector = entry.selector.trim();
if (!rule || !selector) continue;
const files = asArray(entry.files).filter((glob) => typeof glob === 'string' && glob.trim());
if (files.length > 0 && !matchesScope(files, candidates)) continue;
ignoreSelectors.push({ rule, selector });
}
return {
disabledRules: [...disabledRules], disabledValues, skipScan: false,
...(ignoreSelectors.length > 0 ? { ignoreSelectors } : {}),
};
return { disabledRules: [...disabledRules], disabledValues, skipScan: false };
}
root.__IMPECCABLE_LIVE_IGNORES__ = {
-1
View File
@@ -12117,7 +12117,6 @@ void main() {
scanId,
disabledRules: ignores.disabledRules || [],
disabledValues: ignores.disabledValues || [],
ignoreSelectors: ignores.ignoreSelectors || [],
skipScan: ignores.skipScan === true,
},
}, '*');
-46
View File
@@ -23,53 +23,7 @@ const resolve = loadIgnoresApi().resolveDetectIgnores;
const EMPTY = { disabledRules: [], disabledValues: [], skipScan: false };
describe('selector waivers in scan consumers', () => {
const source = readFileSync(join(REPO_ROOT, 'browser-bundle/30-scan-common.js'), 'utf8');
const api = vm.runInThisContext(`(function () { ${source}; return {
options: __visualContrastOptions, reportable: __reportableGroups,
}; })()`, { filename: '30-scan-common.js' });
it('carries page-resolved selectors into the visual and lazy candidate pass', () => {
const ignoreSelectors = [{ rule: 'low-contrast', selector: '.Card' }];
assert.deepEqual(api.options({}, { ignoreSelectors }).ignoreSelectors, ignoreSelectors);
assert.equal(api.options({}, { ignoreSelectors: null }).ignoreSelectors, undefined);
});
it('keeps raw stamps but never renders waived-only groups', () => {
const groups = [
{ el: 1, findings: [{ type: 'low-contrast', ignoredBy: '.Card' }] },
{ el: 2, findings: [{ type: 'side-tab' }, { type: 'low-contrast', ignoredBy: '.Card' }] },
];
assert.deepEqual(api.reportable(groups), [{ el: 2, findings: [{ type: 'side-tab' }] }]);
assert.equal(groups[0].findings.length, 1);
assert.equal(groups[1].findings.length, 2);
});
});
describe('live-browser-ignores resolver', () => {
it('resolves component selectors for the served page without changing selector case', () => {
const ignores = {
roots: ['prototype/'],
pageFiles: ['prototype/index.html', 'prototype/other.html'],
ignoreSelectors: [
{ rule: ' Low-Contrast ', selector: ' .Card ' },
{ rule: '*', selector: '#Hero', files: ['prototype/index.html'] },
{ rule: '*', selector: '.Other', files: ['prototype/other.html'] },
null, {}, { rule: '*', selector: 7 },
],
};
assert.deepEqual(resolve({ ignores, pathname: '/' }).ignoreSelectors, [
{ rule: 'low-contrast', selector: '.Card' },
{ rule: '*', selector: '#Hero' },
]);
assert.deepEqual(resolve({ ignores, pathname: '/other.html' }).ignoreSelectors, [
{ rule: 'low-contrast', selector: '.Card' },
{ rule: '*', selector: '.Other' },
]);
assert.deepEqual(resolve({ ignores: { ...ignores, ignoreFiles: ['**'] }, pathname: '/' }),
{ disabledRules: [], disabledValues: [], skipScan: true });
});
it('registers a versioned API on the root', () => {
const api = loadIgnoresApi();
assert.equal(api.version, 1);
-17
View File
@@ -103,23 +103,6 @@ export default function cases() {
{ id: 'detect-config-from-subdir', verb: 'detect', workspace: 'detect-config', cwd: 'src', args: ['--json', 'page.html'] },
// A file in one project must not pick up another project's DESIGN.md
{ id: 'detect-config-cross-project', verb: 'detect', workspace: 'detect-config', args: ['--json', `<REPO>/tests/fixtures/antipatterns/blinking-cursor.html`], isolateHome: false },
// Component-level opt-outs (detector.ignoreSelectors): one entry waives a
// rule for every instance of a component, and the scan says how many hits
// it suppressed instead of going quiet. The fourth label is outside the
// component and stays reported.
{ id: 'detect-selector-ignore-text', verb: 'detect', workspace: 'detect-selector-ignores', args: ['src/page.html'] },
{ id: 'detect-selector-ignore-json', verb: 'detect', workspace: 'detect-selector-ignores', args: ['--json', 'src/page.html'] },
{ id: 'detect-selector-ignore-quiet', verb: 'detect', workspace: 'detect-selector-ignores', args: ['--quiet', 'src/page.html'] },
{ id: 'detect-selector-ignore-no-config', verb: 'detect', workspace: 'detect-selector-ignores', args: ['--no-config', '--json', 'src/page.html'] },
// `impeccable ignores` CRUD for the same entries.
{ id: 'ignores-selector-list', verb: 'ignores', workspace: 'detect-selector-ignores', args: ['list'] },
{ id: 'ignores-selector-add', verb: 'ignores', workspace: 'detect-selector-ignores', args: ['add-selector', 'undersized-ui-text', '.free-label', '--reason', 'timestamp column'] },
{ id: 'ignores-selector-remove', verb: 'ignores', workspace: 'detect-selector-ignores', args: ['remove-selector', 'undersized-ui-text', '.ks-tag'] },
{ id: 'ignores-selector-missing-args', verb: 'ignores', workspace: 'detect-selector-ignores', args: ['add-selector', 'undersized-ui-text'] },
{ id: 'ignores-selector-star-refused', verb: 'ignores', workspace: 'detect-selector-ignores', args: ['add-selector', 'undersized-ui-text', '*'] },
{ id: 'ignores-help', verb: 'ignores', args: ['--help'] },
);
return out;
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,5 +1,5 @@
{
"stdout": "Usage: impeccable detect [options] [file-or-dir-or-url...]\n\nScan files or URLs for UI anti-patterns and design quality issues.\n\nOptions:\n --json Output results as JSON\n --quiet In text mode, only print the final findings count\n --scope <name> Only report rules in the given design domain\n (type, layout). Comma-separated.\n --viewport <WxH> Browser viewport for URL scans (default 1280x800),\n e.g. --viewport 390x844 for a mobile-width pass\n --no-config Do not apply project config, detector ignores, inline\n ignore comments, or DESIGN.md\n --no-inline-ignores Do not honor in-file impeccable-disable* ignore comments\n --no-design-system Do not load local DESIGN.md / .impeccable/design.json context\n --no-advisory Suppress advisory findings entirely (e.g. em-dash overuse)\n --help Show this help message\n\nAdvisory findings:\n Some rules are advisory: detected and listed in a separate section, but never\n counted as failures and never changing the exit code. They stay out of the\n failure count so they never block automation. --no-advisory hides them.\n\nOutput streams:\n Human-readable findings go to stderr so stdout stays available for structured\n output. Use --json for JSON on stdout, or redirect text with 2> findings.txt.\n\nExit status:\n 0 Scan completed with no primary findings (advisories may still be listed)\n 1 At least one requested target could not be scanned\n 2 Scan completed with primary findings\n Operational failure takes precedence when a multi-target scan is partial.\n\nProject config:\n Respects .impeccable/config.json and .impeccable/config.local.json detector\n settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues,\n 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",
"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",
"stderr": "",
"exit": 0,
"signal": null,
@@ -1,7 +0,0 @@
{
"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": {}
}
@@ -1,7 +0,0 @@
{
"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": {}
}
@@ -1,7 +0,0 @@
{
"stdout": "",
"stderr": "1 anti-pattern found.\n3 undersized-ui-text hits ignored by detector.ignoreSelectors on .ks-tag.\n",
"exit": 2,
"signal": null,
"files": {}
}
@@ -1,7 +0,0 @@
{
"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`, `browser`, `$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",
"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`, `browser`, `$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",
"stderr": "",
"exit": 0,
"signal": null,
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,5 +1,5 @@
{
"stdout": "Impeccable doctor: <WS>\n\nneeds a command (2):\n product-schema-legacy [PRODUCT.md]\n PRODUCT.md has no schema stamp and none of the sections the current record adds (Positioning, Operating Context, Evidence on Hand, Product Principles), so it predates this version of the product record.\n → Offer `init`, which preserves confirmed answers and fills the gaps by interview. Do not rewrite the file from inference.\n design-sidecar-schema-outdated [DESIGN.json]\n DESIGN.json is schemaVersion 1; the current sidecar is 2. Token primitives moved to the DESIGN.md frontmatter, so the old shape carries values that are now read from two places.\n → Offer `document` to regenerate the sidecar. It reads the existing DESIGN.md, so no interview is needed.\n\nworth saying (9):\n product-deprecated-register [PRODUCT.md]\n PRODUCT.md still carries a `## Register` section. v4 replaced the brand/product register axis with the four visitor modes (Persuade, Operate, Read, Experience), which are chosen per surface and persisted in that surface's brief. Nothing reads `## Register` any more.\n → Treat `## Register` as absent for every decision this session. Offer to delete the section; do not let its value influence the work either way.\n design-sidecar-stale [DESIGN.json]\n DESIGN.md was edited after DESIGN.json was generated, so the sidecar's ramps, shadows, motion tokens, and component snippets may contradict it.\n → Offer `document` to refresh the sidecar, preserving DESIGN.md.\n design-md-coverage [DESIGN.md]\n DESIGN.md has no components section. Agents generating new screens get no normative guidance for those, and the live design panel renders generic approximations in their place.\n → Ask whether the section never applied or was never written. `document` fills it from the code if the project has the answer in its CSS.\n config-unknown-keys [.impeccable/config.json]\n .impeccable/config.json has top-level key(s) nothing reads: `theme`. Recognized keys are `hook`, `detector`, `updateCheck`, `stalenessCheck`, `projectRoots`, `buildPath`, `browser`, `$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",
"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`, `browser`, `$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",
"stderr": "",
"exit": 0,
"signal": null,
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,5 +1,5 @@
{
"stdout": "Impeccable doctor: <WS>\n\nneeds a command (2):\n product-schema-legacy [PRODUCT.md]\n PRODUCT.md has no schema stamp and none of the sections the current record adds (Positioning, Operating Context, Evidence on Hand, Product Principles), so it predates this version of the product record.\n → Offer `init`, which preserves confirmed answers and fills the gaps by interview. Do not rewrite the file from inference.\n design-sidecar-schema-outdated [DESIGN.json]\n DESIGN.json is schemaVersion 1; the current sidecar is 2. Token primitives moved to the DESIGN.md frontmatter, so the old shape carries values that are now read from two places.\n → Offer `document` to regenerate the sidecar. It reads the existing DESIGN.md, so no interview is needed.\n\nworth saying (9):\n product-deprecated-register [PRODUCT.md]\n PRODUCT.md still carries a `## Register` section. v4 replaced the brand/product register axis with the four visitor modes (Persuade, Operate, Read, Experience), which are chosen per surface and persisted in that surface's brief. Nothing reads `## Register` any more.\n → Treat `## Register` as absent for every decision this session. Offer to delete the section; do not let its value influence the work either way.\n design-sidecar-stale [DESIGN.json]\n DESIGN.md was edited after DESIGN.json was generated, so the sidecar's ramps, shadows, motion tokens, and component snippets may contradict it.\n → Offer `document` to refresh the sidecar, preserving DESIGN.md.\n design-md-coverage [DESIGN.md]\n DESIGN.md has no components section. Agents generating new screens get no normative guidance for those, and the live design panel renders generic approximations in their place.\n → Ask whether the section never applied or was never written. `document` fills it from the code if the project has the answer in its CSS.\n config-unknown-keys [.impeccable/config.json]\n .impeccable/config.json has top-level key(s) nothing reads: `theme`. Recognized keys are `hook`, `detector`, `updateCheck`, `stalenessCheck`, `projectRoots`, `buildPath`, `browser`, `$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",
"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`, `browser`, `$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",
"stderr": "",
"exit": 0,
"signal": null,
-7
View File
@@ -1,7 +0,0 @@
{
"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": {}
}
@@ -1,7 +0,0 @@
{
"stdout": "Added undersized-ui-text on .free-label to shared detector ignoreSelectors (.impeccable/config.json).\n",
"stderr": "",
"exit": 0,
"signal": null,
"files": {}
}
@@ -1,7 +0,0 @@
{
"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": {}
}
@@ -1,7 +0,0 @@
{
"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": {}
}
@@ -1,7 +0,0 @@
{
"stdout": "Removed 1 from shared (.impeccable/config.json).\n",
"stderr": "",
"exit": 0,
"signal": null,
"files": {}
}
@@ -1,7 +0,0 @@
{
"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": {}
}
@@ -1,11 +0,0 @@
{
"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/**"] }
]
}
}
@@ -1 +0,0 @@
{ "name": "detect-selector-ignores", "private": true }
@@ -1,19 +0,0 @@
<!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>