From fc5dcefce2a54120c8994896871ab995993028af Mon Sep 17 00:00:00 2001 From: Abdul Wahab Date: Thu, 10 Sep 2026 09:58:28 +0500 Subject: [PATCH] Fix: tighten stripe-child text and static cascade gates (#394) Keep flex/align out of the frozen expandStaticDeclaration map, require empty tagged stripe cues on the text path, and honor inset order plus rem width. Prepared with AI assistance. Co-authored-by: Cursor --- crates/detect/src/regex_matchers.rs | 88 ++++++++++++++++++++--------- crates/html/src/adapters.rs | 25 ++++---- crates/html/src/cascade/defaults.rs | 3 - crates/html/src/cascade/rules.rs | 35 +++++++++++- crates/html/tests/stripe_child.rs | 68 ++++++++++++++++++++-- docs/CLI-CONTRACT.md | 2 +- 6 files changed, 172 insertions(+), 49 deletions(-) diff --git a/crates/detect/src/regex_matchers.rs b/crates/detect/src/regex_matchers.rs index a4c02bbfc..1748f38db 100644 --- a/crates/detect/src/regex_matchers.rs +++ b/crates/detect/src/regex_matchers.rs @@ -146,36 +146,64 @@ where } } +/// Opening-tag span that contains `index`, if any. `end` is the `>` byte. +fn markup_tag_span(line: &str, index: usize) -> Option<(usize, usize)> { + let mut i = 0usize; + while i < line.len() { + let Some(rel) = line[i..].find('<') else { + return None; + }; + let tag_start = i + rel; + let after = &line[tag_start + 1..]; + if !after.chars().next().is_some_and(|c| c.is_ascii_alphabetic()) { + i = tag_start + 1; + continue; + } + let mut tag_end: Option = None; + scan_js(line, tag_start + 1, |ch, j, _p, _n, depth| { + if ch == '>' && depth.brace == 0 { + tag_end = Some(j); + return true; + } + false + }); + let Some(end) = tag_end else { + return None; + }; + if index >= tag_start && index <= end { + return Some((tag_start, end)); + } + i = end + 1; + } + None +} + /// JS: detect-text.mjs#containingMarkupTag (only its `text` is read). fn containing_markup_tag(line: &str) -> impl Fn(usize) -> String + '_ { move |index: usize| { - let mut i = 0usize; - while i < line.len() { - let Some(rel) = line[i..].find('<') else { break }; - let tag_start = i + rel; - let after = &line[tag_start + 1..]; - if !after.chars().next().is_some_and(|c| c.is_ascii_alphabetic()) { - i = tag_start + 1; - continue; - } - let mut tag_end: Option = None; - scan_js(line, tag_start + 1, |ch, j, _p, _n, depth| { - if ch == '>' && depth.brace == 0 { - tag_end = Some(j); - return true; - } - false - }); - let Some(end) = tag_end else { break }; - if index >= tag_start && index <= end { - return line[tag_start..end + 1].to_string(); - } - i = end + 1; - } - line.to_string() + markup_tag_span(line, index) + .map(|(start, end)| line[start..end + 1].to_string()) + .unwrap_or_else(|| line.to_string()) } } +fn is_self_closing_tag(tag: &str) -> bool { + tag.trim_end_matches('>').trim_end().ends_with('/') +} + +/// Text path cannot see the DOM. Require an empty or self-closing tag so a +/// `w-1 bg-amber-500` wrapper with content is not reported as a stripe. +fn stripe_child_markup_empty(line: &str, index: usize) -> bool { + let Some((start, end)) = markup_tag_span(line, index) else { + return false; + }; + if is_self_closing_tag(&line[start..end + 1]) { + return true; + } + let rest = line.get(end + 1..).unwrap_or("").trim_start(); + rest.is_empty() || rest.starts_with("> = Lazy::new(|| { } let scope = containing_markup_tag(line)(m.index); find_solid_chromatic_bg(&scope).is_some() + && stripe_child_markup_empty(line, m.index) + && STRIPE_CHILD_CUE_RE.is_match(&scope) && !scope_has_fixed_height(&scope) && !STRIPE_CHILD_ROUNDED_FULL_RE.is_match(&scope) && !STRIPE_CHILD_ARIA_RE.is_match(&scope) @@ -1495,11 +1529,11 @@ mod tests { vec!["w-1 + bg-amber-500 stripe child"] ); assert_eq!( - s(r#"
"#), + s(r#"
"#), vec!["w-[4px] + bg-blue-500 stripe child"] ); assert_eq!( - s(r#""#), + s(r#""#), vec!["w-0.5 + bg-rose-500 stripe child"] ); assert_eq!( @@ -1517,6 +1551,8 @@ mod tests { r#"
"# ) .is_empty()); + assert!(s(r#"
|
"#).is_empty()); + assert!(s(r#"
"#).is_empty()); } #[test] diff --git a/crates/html/src/adapters.rs b/crates/html/src/adapters.rs index 253757e7e..cc46774f0 100644 --- a/crates/html/src/adapters.rs +++ b/crates/html/src/adapters.rs @@ -9,11 +9,10 @@ use crate::background::{ a_ge, a_gt, read_own_background_color, resolve_background, resolve_background_info, resolve_border_radius_px, resolve_gradient_stops, sv, sv_opt, CustomPropMap, }; -use crate::cascade::{ - expand_static_box_values, split_css_tokens, StyleValues, -}; +use crate::cascade::{expand_static_box_values, split_css_tokens, StyleValues}; use crate::dom::{StaticDocument, StaticElement}; use crate::quality::{collapse_ws, pf0, resolve_font_size_px}; +use impeccable_core::checks::css_scan::css_length_to_px; use impeccable_core::checks::measures::{ self, border_colors_from_style, border_widths_from_style, check_gpt_thin_border_wide_shadow, check_oversized_h1, check_radial_spotlight, positioned_style_implies_escape, resolve_length_px, @@ -513,14 +512,14 @@ pub fn check_element_stripe_child(el: &StaticElement<'_>, style: &StyleValues) - return Vec::new(); } - let width = pf0(sv(style, "width")); + let width = css_length_to_px(sv(style, "width")).unwrap_or_else(|| pf0(sv(style, "width"))); let position = js::to_lower_case(sv(style, "position")); let host_style = host.style(); let edge = if position == "absolute" || position == "fixed" { let height_raw = sv(style, "height"); let inset = static_resolved_inset(style); - let height_stretches = height_raw == "100%" - || (static_edge_hugs(&inset[0]) && static_edge_hugs(&inset[2])); + let height_stretches = + height_raw == "100%" || (static_edge_hugs(&inset[0]) && static_edge_hugs(&inset[2])); if !height_stretches { return Vec::new(); } @@ -546,11 +545,12 @@ pub fn check_element_stripe_child(el: &StaticElement<'_>, style: &StyleValues) - } else { sv(host_style, "alignItems") }; - let is_stretch = - effective_align.is_empty() || effective_align == "stretch" || effective_align == "normal"; + let is_stretch = effective_align.is_empty() + || effective_align == "stretch" + || effective_align == "normal"; let height_raw = sv(style, "height"); - let height_stretches = (height_raw.is_empty() || height_raw == "auto" || height_raw == "100%") - && is_stretch; + let height_stretches = + height_raw == "100%" || ((height_raw.is_empty() || height_raw == "auto") && is_stretch); if !height_stretches { return Vec::new(); } @@ -558,10 +558,11 @@ pub fn check_element_stripe_child(el: &StaticElement<'_>, style: &StyleValues) - if siblings.len() < 2 { return Vec::new(); } + let reverse = pdir.contains("reverse"); if siblings.first() == Some(el) { - Some("left") + Some(if reverse { "right" } else { "left" }) } else if siblings.last() == Some(el) { - Some("right") + Some(if reverse { "left" } else { "right" }) } else { None } diff --git a/crates/html/src/cascade/defaults.rs b/crates/html/src/cascade/defaults.rs index 03f28f16f..89036337a 100644 --- a/crates/html/src/cascade/defaults.rs +++ b/crates/html/src/cascade/defaults.rs @@ -117,9 +117,6 @@ pub const STATIC_DEFAULT_STYLE: &[(&str, &str)] = &[ ("left", "auto"), ("inset", ""), ("display", ""), - ("flexDirection", "row"), - ("alignItems", "stretch"), - ("alignSelf", "auto"), ("overflow", "visible"), ("overflowX", "visible"), ("overflowY", "visible"), diff --git a/crates/html/src/cascade/rules.rs b/crates/html/src/cascade/rules.rs index f7289765a..98ce94e63 100644 --- a/crates/html/src/cascade/rules.rs +++ b/crates/html/src/cascade/rules.rs @@ -65,7 +65,8 @@ //! parse in the same process). use super::csstree::{self, Important, Node}; -use super::shorthand::expand_static_declaration; +use super::shorthand::{expand_static_box_values, expand_static_declaration, Expanded}; +use super::values::split_css_tokens; use impeccable_core::js; use indexmap::IndexMap; use once_cell::sync::Lazy; @@ -177,6 +178,32 @@ impl SpecifiedStore { } } +/// Layout properties the stripe-child static adapter needs that are not in +/// the frozen `expandStaticDeclaration` allowlist. Applied here so the +/// recorded vectors stay byte-equal. +fn extra_specified_expansions(prop: &str, value: &str) -> Vec { + let p = js::to_lower_case(prop); + let v = js::trim(value); + if v.is_empty() { + return Vec::new(); + } + match p.as_str() { + "flex-direction" => vec![("flexDirection".into(), v.to_string())], + "align-items" => vec![("alignItems".into(), v.to_string())], + "align-self" => vec![("alignSelf".into(), v.to_string())], + "inset" => { + let vals = expand_static_box_values(&split_css_tokens(v)); + vec![ + ("top".into(), vals[0].clone()), + ("right".into(), vals[1].clone()), + ("bottom".into(), vals[2].clone()), + ("left".into(), vals[3].clone()), + ] + } + _ => Vec::new(), + } +} + /// JS: css-cascade.mjs#applyStaticDeclaration(specified, node, prop, value, meta) pub fn apply_static_declaration( specified: &mut SpecifiedStore, @@ -186,7 +213,11 @@ pub fn apply_static_declaration( meta: &DeclMeta, ) { let map = specified.map.entry(node).or_default(); - for (expanded_prop, expanded_value) in expand_static_declaration(prop, value) { + let extra = extra_specified_expansions(prop, value); + for (expanded_prop, expanded_value) in expand_static_declaration(prop, value) + .into_iter() + .chain(extra) + { let existing = map.get(&expanded_prop).map(|d| &d.meta); if compare_static_priority(existing, meta) { let next = SpecifiedDecl { diff --git a/crates/html/tests/stripe_child.rs b/crates/html/tests/stripe_child.rs index d36ae472f..6c154e68c 100644 --- a/crates/html/tests/stripe_child.rs +++ b/crates/html/tests/stripe_child.rs @@ -2,11 +2,15 @@ use impeccable_html::{detect_html_source, DetectHtmlOptions}; use std::path::Path; fn side_tab_snippets(html: &str) -> Vec { - detect_html_source(html, Path::new("/app/stripe.html"), &DetectHtmlOptions::default()) - .into_iter() - .filter(|f| f.antipattern == "side-tab") - .map(|f| f.snippet) - .collect() + detect_html_source( + html, + Path::new("/app/stripe.html"), + &DetectHtmlOptions::default(), + ) + .into_iter() + .filter(|f| f.antipattern == "side-tab") + .map(|f| f.snippet) + .collect() } #[test] @@ -46,6 +50,7 @@ fn absolute_top_bottom_flags() { "#; let hits = side_tab_snippets(html); assert_eq!(hits.len(), 1); + assert!(hits[0].contains("stripe child (left)")); } #[test] @@ -107,3 +112,56 @@ fn neutral_and_contentful_and_wide_do_not_flag() { "#; assert!(side_tab_snippets(wide).is_empty()); } + +#[test] +fn rem_width_flags() { + let html = r#" +
Body
+"#; + let hits = side_tab_snippets(html); + assert_eq!(hits.len(), 1); + assert!(hits[0].contains("stripe child (left)")); +} + +#[test] +fn height_full_with_align_center_flags() { + let html = r#" +
Body
+"#; + let hits = side_tab_snippets(html); + assert_eq!(hits.len(), 1); + assert!(hits[0].contains("stripe child (left)")); +} + +#[test] +fn inset_after_left_longhand_flags() { + let html = r#" +
+"#; + let hits = side_tab_snippets(html); + assert_eq!(hits.len(), 1); + assert!(hits[0].contains("stripe child (left)")); +} + +#[test] +fn row_reverse_first_child_is_right() { + let html = r#" +
Content
+"#; + let hits = side_tab_snippets(html); + assert_eq!(hits.len(), 1); + assert!(hits[0].contains("stripe child (right)")); +} diff --git a/docs/CLI-CONTRACT.md b/docs/CLI-CONTRACT.md index 6d6a381ac..db131f2e0 100644 --- a/docs/CLI-CONTRACT.md +++ b/docs/CLI-CONTRACT.md @@ -332,7 +332,7 @@ Optional keys added later by engines (appended after the above): `ignoreValue` ( #### Static and regex engines (only what affects the contract) - `detectHtml`: reads file, imports `htmlparser2`, `css-select`, `css-tree`, `domutils`; on import failure prints once to stderr `impeccable detect: DEGRADED - HTML parser modules unavailable (htmlparser2, css-select, css-tree, domutils).\nFalling back to regex matching. Custom properties, selector matching and computed contrast are NOT evaluated; findings are an undercount, not a clean bill of health.\n` and falls back to `detectText`. Inlines `` that are local (not `/^(https?:)?\/\//i`), query/hash stripped. Runs element rules, design-system rules (`checkSourceDesignSystem` + `collectStaticDesignSystemFindings`, merged), then page rules only when `isFullPage(html)` (`/]|]/i` after stripping comments), plus text-content analyzers; ends with inline-ignore filtering. -- `detectText`: regex line matchers (ids: side-tab including Tailwind stripe-child `w-*` + chromatic `bg-*` utilities, border-accent-on-rounded, overused-font, gradient-text, ai-color-palette, gray-on-color, bounce-easing, layout-transition, broken-image), inset-stripe/pseudo-stripe CSS scans, `codex-grid-background`, `