Skip to main content

omena_transform_passes/runtime/
semantic_preservation.rs

1//! Semantic preservation comparator for transform outputs (adoption gate for external lowering).
2
3#[cfg(test)]
4use omena_cascade::{
5    run_cascade_conformance_seed_corpus, run_cascade_ordering_axis_self_check_corpus,
6};
7use omena_parser::{
8    ClosedWorldBundleV0, ModuleQualifiedSymbolSetV0, ParserDeclarationSyntaxFactV0, StyleDialect,
9    collect_parser_declaration_syntax_facts, lex,
10};
11#[cfg(test)]
12use omena_syntax::ident::AuthoredPropertyTextV0;
13use omena_syntax::{
14    SyntaxKind, css_keyword,
15    ident::{CanonicalPropertyKeyV0, PropertyNameV0},
16};
17use omena_transform_cst::{
18    IrBlockSpanV0, IrNodeKindV0, IrNodeV0, TransformIrV0, TransformPassKind,
19    lower_transform_ir_from_source, structural_block_spans_for_source,
20};
21#[cfg(test)]
22use serde::Deserialize;
23use serde::Serialize;
24use std::collections::{BTreeMap, BTreeSet};
25
26use crate::model::{
27    TransformSemanticObservationKeyAxisV0, TransformSemanticObservationOrderingRuleV0,
28    TransformSemanticObservationSurfaceV0, TransformSemanticObservationValueAxisV0,
29    TransformSemanticPreservationClaimScopeV0, TransformSemanticPreservationTelemetryV0,
30    TransformSemanticPreservationVocabularyReviewV0, TransformSemanticUnobservedAxisV0,
31};
32use crate::{
33    domains::{
34        css_modules_values::{
35            collect_css_modules_value_semantic_facts_from_ir,
36            collect_tree_shake_css_modules_value_removals_from_ir,
37        },
38        custom_property::{
39            collect_css_custom_property_semantic_facts_from_ir,
40            collect_tree_shake_css_custom_property_removals_from_ir,
41        },
42        keyframes::{
43            collect_referenced_keyframe_names_from_ir,
44            collect_tree_shake_css_keyframe_removals_from_ir, keyframe_name_is_reachable,
45        },
46        nesting::expand_nested_selector,
47        reachability::class_name_is_reachable,
48    },
49    helpers::selectors::selector_branch_owner_class_names,
50};
51
52impl TransformSemanticPreservationTelemetryV0 {
53    pub(crate) fn record(&mut self, decision: &TransformSemanticPreservationDecisionV0) {
54        self.observed_pass_count += 1;
55        if decision.preserved {
56            self.preserved_pass_count += 1;
57        } else {
58            self.blocked_pass_count += 1;
59        }
60    }
61
62    /// Records an executor-observed pass that admission blocked before semantic comparison.
63    /// It is deliberately not counted as preserved.
64    pub(crate) fn record_blocked_before_observation(&mut self) {
65        self.observed_pass_count += 1;
66        self.blocked_pass_count += 1;
67    }
68}
69
70impl Default for TransformSemanticPreservationTelemetryV0 {
71    fn default() -> Self {
72        Self {
73            observed_pass_count: 0,
74            preserved_pass_count: 0,
75            blocked_pass_count: 0,
76            observed_surface: semantic_observation_surface_descriptor(),
77        }
78    }
79}
80
81#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
82#[serde(rename_all = "camelCase")]
83pub struct TransformSemanticPreservationDecisionV0 {
84    pub pass_id: &'static str,
85    pub preserved: bool,
86    pub input_entry_count: usize,
87    pub output_entry_count: usize,
88    pub mismatch_count: usize,
89}
90
91pub fn compare_transform_css_semantics_v0(
92    input_css: &str,
93    output_css: &str,
94    dialect: StyleDialect,
95) -> TransformSemanticPreservationDecisionV0 {
96    let input_ir = lower_transform_ir_from_source(
97        input_css,
98        dialect,
99        "omena-transform-passes.semantic-comparison.input",
100    );
101    let output_ir = lower_transform_ir_from_source(
102        output_css,
103        dialect,
104        "omena-transform-passes.semantic-comparison.output",
105    );
106    let scope = SemanticObservationScopeV0::from_parts(None, None, &[], dialect);
107    compare_semantic_observation_for_pass_with_scopes(
108        "external-css-lowering",
109        &input_ir,
110        &output_ir,
111        scope,
112        scope,
113    )
114}
115
116#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
117#[serde(rename_all = "camelCase")]
118pub enum ExternalCssSemanticChangeKindV0 {
119    Added,
120    Removed,
121    Modified,
122}
123
124#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
125#[serde(rename_all = "camelCase")]
126pub enum ExternalCssSemanticChangeClassificationV0 {
127    Understood,
128    Passthrough,
129}
130
131#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
132#[serde(rename_all = "camelCase")]
133pub struct ExternalCssSemanticEntryV0 {
134    pub selector: String,
135    pub property: CanonicalPropertyKeyV0,
136    pub context: String,
137    pub value: String,
138    pub important: bool,
139}
140
141#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
142#[serde(rename_all = "camelCase")]
143pub struct ExternalCssSemanticChangeV0 {
144    pub kind: ExternalCssSemanticChangeKindV0,
145    pub classification: ExternalCssSemanticChangeClassificationV0,
146    pub explanation: &'static str,
147    pub before: Option<ExternalCssSemanticEntryV0>,
148    pub after: Option<ExternalCssSemanticEntryV0>,
149}
150
151/// Independent non-whitespace CST coverage evidence for an external CSS candidate.
152///
153/// The input's non-whitespace syntax units must remain an ordered subsequence
154/// of the output. The only output-only units admitted by this boundary are
155/// declarations independently classified as understood vendor-prefix
156/// additions. Everything else is counted as uncovered and fails closed. This
157/// layer is deliberately modulo ASCII whitespace: it does not independently
158/// prove preservation of whitespace-sensitive selector combinators, so
159/// adoption also requires the semantic-change classifier.
160#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
161#[serde(rename_all = "camelCase")]
162pub struct ExternalCssCstCoverageV0 {
163    pub input_unit_count: usize,
164    pub output_unit_count: usize,
165    pub understood_addition_unit_count: usize,
166    pub uncovered_input_unit_count: usize,
167    pub uncovered_output_unit_count: usize,
168    pub complete: bool,
169}
170
171#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
172#[serde(rename_all = "camelCase")]
173pub struct ExternalCssSemanticDiffV0 {
174    pub input_entry_count: usize,
175    pub output_entry_count: usize,
176    pub total_change_count: usize,
177    pub understood_change_count: usize,
178    pub passthrough_change_count: usize,
179    /// Producer-derived partition invariant for the per-change classification
180    /// counts. This is advisory metadata, not an independent adoption guard;
181    /// CST coverage and the reported change rows are checked separately.
182    pub semantic_change_accounting_complete: bool,
183    pub cst_coverage: ExternalCssCstCoverageV0,
184    pub changes: Vec<ExternalCssSemanticChangeV0>,
185}
186
187pub fn compare_external_css_semantic_changes_v0(
188    input_css: &str,
189    output_css: &str,
190    dialect: StyleDialect,
191) -> ExternalCssSemanticDiffV0 {
192    let input_ir = lower_transform_ir_from_source(
193        input_css,
194        dialect,
195        "omena-transform-passes.external-css-comparison.input",
196    );
197    let output_ir = lower_transform_ir_from_source(
198        output_css,
199        dialect,
200        "omena-transform-passes.external-css-comparison.output",
201    );
202    let scope = SemanticObservationScopeV0::from_parts(None, None, &[], dialect);
203    let input = semantic_observation(&input_ir, scope);
204    let output = semantic_observation(&output_ir, scope);
205    let mut changes = Vec::new();
206
207    for (key, input_value) in &input {
208        match output.get(key) {
209            None => changes.push(classify_external_semantic_change(
210                ExternalCssSemanticChangeKindV0::Removed,
211                Some(external_semantic_entry(key, input_value)),
212                None,
213                &input,
214                &output,
215            )),
216            Some(output_value) if output_value != input_value => {
217                changes.push(classify_external_semantic_change(
218                    ExternalCssSemanticChangeKindV0::Modified,
219                    Some(external_semantic_entry(key, input_value)),
220                    Some(external_semantic_entry(key, output_value)),
221                    &input,
222                    &output,
223                ))
224            }
225            Some(_) => {}
226        }
227    }
228    for (key, output_value) in &output {
229        if !input.contains_key(key) {
230            changes.push(classify_external_semantic_change(
231                ExternalCssSemanticChangeKindV0::Added,
232                None,
233                Some(external_semantic_entry(key, output_value)),
234                &input,
235                &output,
236            ));
237        }
238    }
239    changes.sort();
240    let cst_coverage = external_css_cst_coverage(
241        input_css,
242        output_css,
243        dialect,
244        &input_ir,
245        &output_ir,
246        changes.as_slice(),
247    );
248    external_css_semantic_diff_from_changes(input.len(), output.len(), changes, cst_coverage)
249}
250
251pub fn external_css_adoption_boundary_is_complete_v0(report: &ExternalCssSemanticDiffV0) -> bool {
252    report.cst_coverage.complete
253        && report.cst_coverage.uncovered_input_unit_count == 0
254        && report.cst_coverage.uncovered_output_unit_count == 0
255        && report.total_change_count == report.changes.len()
256        && report.understood_change_count
257            == report
258                .changes
259                .iter()
260                .filter(|change| {
261                    change.classification == ExternalCssSemanticChangeClassificationV0::Understood
262                })
263                .count()
264        && report.passthrough_change_count
265            == report
266                .changes
267                .iter()
268                .filter(|change| {
269                    change.classification == ExternalCssSemanticChangeClassificationV0::Passthrough
270                })
271                .count()
272        && report.understood_change_count + report.passthrough_change_count
273            == report.total_change_count
274}
275
276/// Compatibility name for the independently measured full-CST boundary.
277pub fn external_css_semantic_diff_is_total_v0(report: &ExternalCssSemanticDiffV0) -> bool {
278    external_css_adoption_boundary_is_complete_v0(report)
279}
280
281fn external_css_semantic_diff_from_changes(
282    input_entry_count: usize,
283    output_entry_count: usize,
284    changes: Vec<ExternalCssSemanticChangeV0>,
285    cst_coverage: ExternalCssCstCoverageV0,
286) -> ExternalCssSemanticDiffV0 {
287    let understood_change_count = changes
288        .iter()
289        .filter(|change| {
290            change.classification == ExternalCssSemanticChangeClassificationV0::Understood
291        })
292        .count();
293    let passthrough_change_count = changes.len().saturating_sub(understood_change_count);
294    let semantic_change_accounting_complete =
295        understood_change_count + passthrough_change_count == changes.len();
296    ExternalCssSemanticDiffV0 {
297        input_entry_count,
298        output_entry_count,
299        total_change_count: changes.len(),
300        understood_change_count,
301        passthrough_change_count,
302        semantic_change_accounting_complete,
303        cst_coverage,
304        changes,
305    }
306}
307
308#[derive(Debug, Clone, PartialEq, Eq)]
309struct ExternalCssCoverageUnitV0 {
310    key: String,
311    understood_addition: bool,
312}
313
314#[derive(Debug, Clone, PartialEq, Eq)]
315struct ExternalCssDeclarationCoverageSpanV0 {
316    start: usize,
317    end: usize,
318    key: String,
319    understood_addition: bool,
320}
321
322fn external_css_cst_coverage(
323    input_css: &str,
324    output_css: &str,
325    dialect: StyleDialect,
326    input_ir: &TransformIrV0,
327    output_ir: &TransformIrV0,
328    changes: &[ExternalCssSemanticChangeV0],
329) -> ExternalCssCstCoverageV0 {
330    let understood_entries = changes
331        .iter()
332        .filter(|change| {
333            change.kind == ExternalCssSemanticChangeKindV0::Added
334                && change.classification == ExternalCssSemanticChangeClassificationV0::Understood
335        })
336        .filter_map(|change| change.after.clone())
337        .collect::<BTreeSet<_>>();
338    let input_units = external_css_coverage_units(input_css, dialect, input_ir, &BTreeSet::new());
339    let output_units =
340        external_css_coverage_units(output_css, dialect, output_ir, &understood_entries);
341
342    let mut output_index = 0;
343    let mut understood_addition_unit_count = 0;
344    let mut uncovered_input_unit_count = 0;
345    let mut uncovered_output_unit_count = 0;
346
347    for input_unit in &input_units {
348        let mut matched = false;
349        while let Some(output_unit) = output_units.get(output_index) {
350            if output_unit.key == input_unit.key {
351                output_index += 1;
352                matched = true;
353                break;
354            }
355            if output_unit.understood_addition {
356                understood_addition_unit_count += 1;
357            } else {
358                uncovered_output_unit_count += 1;
359            }
360            output_index += 1;
361        }
362        if !matched {
363            uncovered_input_unit_count += 1;
364        }
365    }
366    for output_unit in &output_units[output_index..] {
367        if output_unit.understood_addition {
368            understood_addition_unit_count += 1;
369        } else {
370            uncovered_output_unit_count += 1;
371        }
372    }
373
374    ExternalCssCstCoverageV0 {
375        input_unit_count: input_units.len(),
376        output_unit_count: output_units.len(),
377        understood_addition_unit_count,
378        uncovered_input_unit_count,
379        uncovered_output_unit_count,
380        complete: uncovered_input_unit_count == 0 && uncovered_output_unit_count == 0,
381    }
382}
383
384fn external_css_coverage_units(
385    source: &str,
386    dialect: StyleDialect,
387    ir: &TransformIrV0,
388    understood_entries: &BTreeSet<ExternalCssSemanticEntryV0>,
389) -> Vec<ExternalCssCoverageUnitV0> {
390    let spans = external_css_declaration_coverage_spans(source, dialect, ir, understood_entries);
391    let mut units = Vec::new();
392    let mut cursor = 0;
393    for span in spans {
394        if span.start < cursor || span.end > source.len() {
395            continue;
396        }
397        units.extend(external_css_syntax_units(
398            source.get(cursor..span.start).unwrap_or_default(),
399            dialect,
400        ));
401        units.push(ExternalCssCoverageUnitV0 {
402            key: span.key,
403            understood_addition: span.understood_addition,
404        });
405        cursor = span.end;
406    }
407    units.extend(external_css_syntax_units(
408        source.get(cursor..).unwrap_or_default(),
409        dialect,
410    ));
411    units
412}
413
414fn external_css_declaration_coverage_spans(
415    source: &str,
416    dialect: StyleDialect,
417    ir: &TransformIrV0,
418    understood_entries: &BTreeSet<ExternalCssSemanticEntryV0>,
419) -> Vec<ExternalCssDeclarationCoverageSpanV0> {
420    let mut spans = ir
421        .nodes
422        .iter()
423        .filter(|node| !node.deleted && node.kind == IrNodeKindV0::Declaration)
424        .filter(|node| {
425            node.source_span_start < node.source_span_end && node.source_span_end <= source.len()
426        })
427        .map(|node| {
428            let declaration_entries = external_css_entries_for_declaration(ir, node);
429            let understood_addition = !declaration_entries.is_empty()
430                && declaration_entries
431                    .iter()
432                    .all(|entry| understood_entries.contains(entry));
433            ExternalCssDeclarationCoverageSpanV0 {
434                start: node.source_span_start,
435                end: node.source_span_end,
436                key: external_css_declaration_unit_key(
437                    source
438                        .get(node.source_span_start..node.source_span_end)
439                        .unwrap_or_default(),
440                    dialect,
441                ),
442                understood_addition,
443            }
444        })
445        .collect::<Vec<_>>();
446    spans.sort_by_key(|span| (span.start, span.end));
447    spans.dedup_by(|left, right| left.start == right.start && left.end == right.end);
448    spans
449}
450
451fn external_css_entries_for_declaration(
452    ir: &TransformIrV0,
453    node: &IrNodeV0,
454) -> Vec<ExternalCssSemanticEntryV0> {
455    let Some(declaration) = semantic_declaration_from_ir(ir, node) else {
456        return Vec::new();
457    };
458    let Some(selector_keys) = nearest_style_ancestor_selector_keys(ir, node) else {
459        return Vec::new();
460    };
461    let context = ancestor_at_rule_context_key(ir, node);
462    selector_keys
463        .into_iter()
464        .filter(|selector| {
465            !selector.eq_ignore_ascii_case(":export") && !selector.starts_with(":import")
466        })
467        .map(|selector| ExternalCssSemanticEntryV0 {
468            selector,
469            property: declaration.property_key.clone(),
470            context: context.clone(),
471            value: declaration.value.clone(),
472            important: declaration.important,
473        })
474        .collect()
475}
476
477fn external_css_declaration_unit_key(source: &str, dialect: StyleDialect) -> String {
478    format!(
479        "declaration:{}",
480        external_css_normalized_tokens(source, dialect)
481    )
482}
483
484fn external_css_syntax_units(
485    source: &str,
486    dialect: StyleDialect,
487) -> Vec<ExternalCssCoverageUnitV0> {
488    lex(source, dialect)
489        .tokens()
490        .iter()
491        .filter(|token| token.kind != SyntaxKind::Whitespace)
492        .map(|token| ExternalCssCoverageUnitV0 {
493            key: format!("syntax:{:?}:{}", token.kind, token.text),
494            understood_addition: false,
495        })
496        .collect()
497}
498
499fn external_css_normalized_tokens(source: &str, dialect: StyleDialect) -> String {
500    lex(source, dialect)
501        .tokens()
502        .iter()
503        .filter(|token| token.kind != SyntaxKind::Whitespace)
504        .map(|token| format!("{:?}:{}", token.kind, token.text))
505        .collect::<Vec<_>>()
506        .join("|")
507}
508
509fn classify_external_semantic_change(
510    kind: ExternalCssSemanticChangeKindV0,
511    before: Option<ExternalCssSemanticEntryV0>,
512    after: Option<ExternalCssSemanticEntryV0>,
513    input: &SemanticObservationV0,
514    output: &SemanticObservationV0,
515) -> ExternalCssSemanticChangeV0 {
516    let understood_prefix_addition = kind == ExternalCssSemanticChangeKindV0::Added
517        && after.as_ref().is_some_and(|entry| {
518            vendor_unprefixed_property(entry.property.as_str()).is_some_and(|unprefixed| {
519                let peer = SemanticObservationKeyV0 {
520                    selector_key: entry.selector.clone(),
521                    property: PropertyNameV0::from_authored(unprefixed).canonical_key(),
522                    context_key: entry.context.clone(),
523                };
524                [input.get(&peer), output.get(&peer)]
525                    .into_iter()
526                    .flatten()
527                    .any(|peer_value| {
528                        peer_value.value == entry.value && peer_value.important == entry.important
529                    })
530            })
531        });
532    let (classification, explanation) = if understood_prefix_addition {
533        (
534            ExternalCssSemanticChangeClassificationV0::Understood,
535            "targetVendorPrefixAddition",
536        )
537    } else {
538        (
539            ExternalCssSemanticChangeClassificationV0::Passthrough,
540            "externalSemanticChange",
541        )
542    };
543    ExternalCssSemanticChangeV0 {
544        kind,
545        classification,
546        explanation,
547        before,
548        after,
549    }
550}
551
552fn vendor_unprefixed_property(property: &str) -> Option<&str> {
553    ["-webkit-", "-moz-", "-ms-", "-o-"]
554        .into_iter()
555        .find_map(|prefix| property.strip_prefix(prefix))
556        .filter(|property| !property.is_empty())
557}
558
559fn external_semantic_entry(
560    key: &SemanticObservationKeyV0,
561    value: &SemanticObservationValueV0,
562) -> ExternalCssSemanticEntryV0 {
563    ExternalCssSemanticEntryV0 {
564        selector: key.selector_key.clone(),
565        property: key.property.clone(),
566        context: key.context_key.clone(),
567        value: value.value.clone(),
568        important: value.important,
569    }
570}
571
572pub(crate) fn semantic_preservation_applies(pass: TransformPassKind) -> bool {
573    matches!(
574        pass,
575        TransformPassKind::EmptyRuleRemoval
576            | TransformPassKind::RuleDeduplication
577            | TransformPassKind::RuleMerging
578            | TransformPassKind::SelectorMerging
579            | TransformPassKind::NestingUnwrap
580            | TransformPassKind::ScopeFlatten
581            | TransformPassKind::LayerFlatten
582            | TransformPassKind::TreeShakeClass
583            | TransformPassKind::TreeShakeKeyframes
584            | TransformPassKind::TreeShakeValue
585            | TransformPassKind::TreeShakeCustomProperty
586    )
587}
588
589#[cfg(test)]
590pub(crate) fn compare_semantic_observation_for_pass(
591    pass_id: &'static str,
592    input_ir: &TransformIrV0,
593    output_ir: &TransformIrV0,
594) -> TransformSemanticPreservationDecisionV0 {
595    compare_semantic_observation_for_pass_with_scope(
596        pass_id,
597        input_ir,
598        output_ir,
599        SemanticObservationScopeV0::default(),
600    )
601}
602
603#[cfg(test)]
604pub(crate) fn compare_semantic_observation_for_pass_with_scope<'a>(
605    pass_id: &'static str,
606    input_ir: &TransformIrV0,
607    output_ir: &TransformIrV0,
608    scope: SemanticObservationScopeV0<'a>,
609) -> TransformSemanticPreservationDecisionV0 {
610    compare_semantic_observation_for_pass_with_scopes(pass_id, input_ir, output_ir, scope, scope)
611}
612
613pub(crate) fn compare_semantic_observation_for_pass_with_scopes<'a>(
614    pass_id: &'static str,
615    input_ir: &TransformIrV0,
616    output_ir: &TransformIrV0,
617    input_scope: SemanticObservationScopeV0<'a>,
618    output_scope: SemanticObservationScopeV0<'a>,
619) -> TransformSemanticPreservationDecisionV0 {
620    let input = semantic_observation(input_ir, input_scope);
621    let output = semantic_observation(output_ir, output_scope);
622    let mismatch_count = semantic_observation_mismatch_count(&input, &output);
623    TransformSemanticPreservationDecisionV0 {
624        pass_id,
625        preserved: mismatch_count == 0,
626        input_entry_count: input.len(),
627        output_entry_count: output.len(),
628        mismatch_count,
629    }
630}
631
632#[derive(Debug, Clone, Copy)]
633pub(crate) struct SemanticObservationScopeV0<'a> {
634    reachable_class_names: Option<&'a [String]>,
635    reachable_keyframe_names: Option<&'a [String]>,
636    ignored_source_ranges: &'a [(usize, usize)],
637    dialect: StyleDialect,
638    force_ir_declarations: bool,
639}
640
641impl Default for SemanticObservationScopeV0<'_> {
642    fn default() -> Self {
643        Self {
644            reachable_class_names: None,
645            reachable_keyframe_names: None,
646            ignored_source_ranges: &[],
647            dialect: StyleDialect::Css,
648            force_ir_declarations: false,
649        }
650    }
651}
652
653impl<'a> SemanticObservationScopeV0<'a> {
654    fn from_parts(
655        reachable_class_names: Option<&'a [String]>,
656        reachable_keyframe_names: Option<&'a [String]>,
657        ignored_source_ranges: &'a [(usize, usize)],
658        dialect: StyleDialect,
659    ) -> Self {
660        Self {
661            reachable_class_names,
662            reachable_keyframe_names,
663            ignored_source_ranges,
664            dialect,
665            force_ir_declarations: !ignored_source_ranges.is_empty(),
666        }
667    }
668
669    pub(crate) fn for_pass(
670        pass: TransformPassKind,
671        dialect: StyleDialect,
672        closed_world_bundle: Option<&'a ClosedWorldBundleV0>,
673        module_qualified_symbols: Option<&'a ModuleQualifiedSymbolSetV0>,
674        module_reachable_class_names: Option<&'a [String]>,
675        projection: &'a SemanticObservationProjectionV0,
676    ) -> Self {
677        match pass {
678            TransformPassKind::TreeShakeClass
679            | TransformPassKind::TreeShakeKeyframes
680            | TransformPassKind::TreeShakeValue
681            | TransformPassKind::TreeShakeCustomProperty => Self::from_parts(
682                module_reachable_class_names
683                    .or_else(|| {
684                        module_qualified_symbols.map(ModuleQualifiedSymbolSetV0::class_names)
685                    })
686                    .or_else(|| {
687                        closed_world_bundle.map(|bundle| bundle.reachability().class_names())
688                    }),
689                projection.reachable_keyframe_names(),
690                projection.ignored_source_ranges(),
691                dialect,
692            ),
693            _ => Self::from_parts(
694                None,
695                projection.reachable_keyframe_names(),
696                projection.ignored_source_ranges(),
697                dialect,
698            ),
699        }
700    }
701
702    #[cfg(test)]
703    fn for_reachable_class_names(reachable_class_names: &'a [String]) -> Self {
704        Self::from_parts(Some(reachable_class_names), None, &[], StyleDialect::Css)
705    }
706
707    #[cfg(test)]
708    fn for_ignored_source_ranges(ignored_source_ranges: &'a [(usize, usize)]) -> Self {
709        Self::from_parts(None, None, ignored_source_ranges, StyleDialect::Css)
710    }
711
712    #[cfg(test)]
713    fn for_reachable_class_names_and_ignored_source_ranges(
714        reachable_class_names: &'a [String],
715        ignored_source_ranges: &'a [(usize, usize)],
716    ) -> Self {
717        Self::from_parts(
718            Some(reachable_class_names),
719            None,
720            ignored_source_ranges,
721            StyleDialect::Css,
722        )
723    }
724
725    pub(crate) fn without_ignored_source_ranges(self) -> Self {
726        Self {
727            reachable_class_names: self.reachable_class_names,
728            reachable_keyframe_names: self.reachable_keyframe_names,
729            ignored_source_ranges: &[],
730            dialect: self.dialect,
731            force_ir_declarations: self.force_ir_declarations,
732        }
733    }
734
735    pub(crate) fn for_cst_declarations(dialect: StyleDialect) -> Self {
736        Self {
737            reachable_class_names: None,
738            reachable_keyframe_names: None,
739            ignored_source_ranges: &[],
740            dialect,
741            force_ir_declarations: true,
742        }
743    }
744}
745
746#[derive(Debug, Clone, Default)]
747pub(crate) struct SemanticObservationProjectionV0 {
748    ignored_source_ranges: Vec<(usize, usize)>,
749    reachable_keyframe_names: Option<Vec<String>>,
750}
751
752impl SemanticObservationProjectionV0 {
753    pub(crate) fn for_pass_input(
754        pass: TransformPassKind,
755        input_ir: &TransformIrV0,
756        dialect: StyleDialect,
757        closed_world_bundle: Option<&ClosedWorldBundleV0>,
758        module_qualified_symbols: Option<&ModuleQualifiedSymbolSetV0>,
759        module_reachable_class_names: Option<&[String]>,
760    ) -> Self {
761        let Some(bundle) = closed_world_bundle else {
762            return Self::default();
763        };
764        let reachable_class_names = module_reachable_class_names.unwrap_or_else(|| {
765            module_qualified_symbols.map_or_else(
766                || bundle.reachability().class_names(),
767                ModuleQualifiedSymbolSetV0::class_names,
768            )
769        });
770        let reachable_keyframe_names = module_qualified_symbols.map_or_else(
771            || bundle.reachability().keyframe_names(),
772            ModuleQualifiedSymbolSetV0::keyframe_names,
773        );
774        let reachable_value_names = module_qualified_symbols.map_or_else(
775            || bundle.reachability().value_names(),
776            ModuleQualifiedSymbolSetV0::value_names,
777        );
778        let reachable_custom_property_names = module_qualified_symbols.map_or_else(
779            || bundle.reachability().custom_property_names(),
780            ModuleQualifiedSymbolSetV0::custom_property_names,
781        );
782        match pass {
783            TransformPassKind::TreeShakeKeyframes => Self {
784                ignored_source_ranges: collect_tree_shake_css_keyframe_removals_from_ir(
785                    input_ir,
786                    reachable_keyframe_names,
787                    reachable_class_names,
788                )
789                .into_iter()
790                .map(|removal| (removal.source_span_start, removal.source_span_end))
791                .collect(),
792                reachable_keyframe_names: None,
793            },
794            TransformPassKind::TreeShakeValue => Self {
795                ignored_source_ranges: collect_tree_shake_css_modules_value_removals_from_ir(
796                    input_ir,
797                    dialect,
798                    reachable_value_names,
799                    reachable_keyframe_names,
800                    reachable_class_names,
801                )
802                .into_iter()
803                .map(|removal| (removal.source_span_start, removal.source_span_end))
804                .collect(),
805                reachable_keyframe_names: None,
806            },
807            TransformPassKind::TreeShakeCustomProperty => Self {
808                ignored_source_ranges: collect_tree_shake_css_custom_property_removals_from_ir(
809                    input_ir,
810                    dialect,
811                    reachable_custom_property_names,
812                    reachable_keyframe_names,
813                    reachable_class_names,
814                )
815                .into_iter()
816                .map(|removal| (removal.source_span_start, removal.source_span_end))
817                .collect(),
818                reachable_keyframe_names: reachable_keyframe_names_for_closed_class_scope(
819                    input_ir,
820                    reachable_keyframe_names,
821                    reachable_class_names,
822                ),
823            },
824            _ => {
825                let _ = dialect;
826                Self::default()
827            }
828        }
829    }
830
831    pub(crate) fn ignored_source_ranges(&self) -> &[(usize, usize)] {
832        self.ignored_source_ranges.as_slice()
833    }
834
835    fn reachable_keyframe_names(&self) -> Option<&[String]> {
836        self.reachable_keyframe_names.as_deref()
837    }
838
839    #[cfg(test)]
840    fn for_keyframe_reachability(
841        input_ir: &TransformIrV0,
842        reachable_keyframe_names: &[String],
843        reachable_class_names: &[String],
844    ) -> Self {
845        Self {
846            ignored_source_ranges: collect_tree_shake_css_keyframe_removals_from_ir(
847                input_ir,
848                reachable_keyframe_names,
849                reachable_class_names,
850            )
851            .into_iter()
852            .map(|removal| (removal.source_span_start, removal.source_span_end))
853            .collect(),
854            reachable_keyframe_names: None,
855        }
856    }
857
858    #[cfg(test)]
859    fn for_value_reachability(
860        input_ir: &TransformIrV0,
861        dialect: StyleDialect,
862        reachable_value_names: &[String],
863        reachable_keyframe_names: &[String],
864        reachable_class_names: &[String],
865    ) -> Self {
866        Self {
867            ignored_source_ranges: collect_tree_shake_css_modules_value_removals_from_ir(
868                input_ir,
869                dialect,
870                reachable_value_names,
871                reachable_keyframe_names,
872                reachable_class_names,
873            )
874            .into_iter()
875            .map(|removal| (removal.source_span_start, removal.source_span_end))
876            .collect(),
877            reachable_keyframe_names: None,
878        }
879    }
880
881    #[cfg(test)]
882    fn for_custom_property_reachability(
883        input_ir: &TransformIrV0,
884        dialect: StyleDialect,
885        reachable_custom_property_names: &[AuthoredPropertyTextV0],
886        reachable_keyframe_names: &[String],
887        reachable_class_names: &[String],
888    ) -> Self {
889        Self {
890            ignored_source_ranges: collect_tree_shake_css_custom_property_removals_from_ir(
891                input_ir,
892                dialect,
893                reachable_custom_property_names,
894                reachable_keyframe_names,
895                reachable_class_names,
896            )
897            .into_iter()
898            .map(|removal| (removal.source_span_start, removal.source_span_end))
899            .collect(),
900            reachable_keyframe_names: reachable_keyframe_names_for_closed_class_scope(
901                input_ir,
902                reachable_keyframe_names,
903                reachable_class_names,
904            ),
905        }
906    }
907}
908
909fn reachable_keyframe_names_for_closed_class_scope(
910    input_ir: &TransformIrV0,
911    explicit_keyframe_names: &[String],
912    reachable_class_names: &[String],
913) -> Option<Vec<String>> {
914    let mut names = collect_referenced_keyframe_names_from_ir(input_ir, reachable_class_names)?;
915    for name in explicit_keyframe_names {
916        if !names.iter().any(|candidate| candidate == name) {
917            names.push(name.clone());
918        }
919    }
920    Some(names)
921}
922
923#[cfg(test)]
924#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
925#[serde(rename_all = "camelCase")]
926pub(crate) struct TransformSemanticPreservationKillRateReportV0 {
927    pub schema_version: &'static str,
928    pub product: &'static str,
929    pub fixture_count: usize,
930    pub rejected_count: usize,
931    pub required_rejected_count: usize,
932    pub non_empty_corpus: bool,
933    pub kill_rate_passed: bool,
934}
935
936#[cfg(test)]
937#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
938#[serde(rename_all = "camelCase")]
939pub(crate) struct TransformSemanticModelConformanceReportV0 {
940    pub schema_version: String,
941    pub product: String,
942    pub cascade_seed_product: String,
943    pub cascade_seed_case_count: usize,
944    pub cascade_seed_failed_count: usize,
945    pub cascade_seed_digest: String,
946    pub ordering_axis_self_check_product: String,
947    pub ordering_axis_self_check_case_count: usize,
948    pub ordering_axis_self_check_failed_count: usize,
949    pub ordering_axis_self_check_digest: String,
950    pub semantic_observation_case_count: usize,
951    pub semantic_observation_failed_count: usize,
952    pub model_conformance_passed: bool,
953}
954
955#[cfg(test)]
956pub(crate) fn summarize_semantic_preservation_model_conformance()
957-> Result<TransformSemanticModelConformanceReportV0, serde_json::Error> {
958    let cascade_seed = run_cascade_conformance_seed_corpus();
959    let ordering_axis_self_check = run_cascade_ordering_axis_self_check_corpus();
960    let cascade_seed_source = serde_json::to_string(&cascade_seed)?;
961    let ordering_axis_self_check_source = serde_json::to_string(&ordering_axis_self_check)?;
962    let semantic_observation_results = semantic_model_conformance_case_results();
963    let semantic_observation_failed_count = semantic_observation_results
964        .iter()
965        .filter(|result| !**result)
966        .count();
967
968    Ok(TransformSemanticModelConformanceReportV0 {
969        schema_version: "0".to_string(),
970        product: "omena-transform-passes.semantic-preservation-model-conformance".to_string(),
971        cascade_seed_product: cascade_seed.product.to_string(),
972        cascade_seed_case_count: cascade_seed.case_count,
973        cascade_seed_failed_count: cascade_seed.failed_count,
974        cascade_seed_digest: stable_semantic_report_digest(&[
975            "cascade-seed",
976            cascade_seed_source.as_str(),
977        ]),
978        ordering_axis_self_check_product: ordering_axis_self_check.product.to_string(),
979        ordering_axis_self_check_case_count: ordering_axis_self_check.case_count,
980        ordering_axis_self_check_failed_count: ordering_axis_self_check.failed_count,
981        ordering_axis_self_check_digest: stable_semantic_report_digest(&[
982            "ordering-axis-self-check",
983            ordering_axis_self_check_source.as_str(),
984        ]),
985        semantic_observation_case_count: semantic_observation_results.len(),
986        semantic_observation_failed_count,
987        model_conformance_passed: cascade_seed.failed_count == 0
988            && ordering_axis_self_check.failed_count == 0
989            && semantic_observation_failed_count == 0,
990    })
991}
992
993#[cfg(test)]
994pub(crate) fn summarize_semantic_preservation_kill_rate_for_fixture_source(
995    source: &str,
996    dialect: StyleDialect,
997) -> Result<TransformSemanticPreservationKillRateReportV0, serde_json::Error> {
998    let fixtures = serde_json::from_str::<Vec<TransformSemanticPreservationFixtureV0>>(source)?;
999    let mut rejected_count = 0usize;
1000
1001    for fixture in &fixtures {
1002        let Some(pass) = transform_pass_kind_from_fixture_id(fixture.pass_id.as_str()) else {
1003            continue;
1004        };
1005        if !semantic_preservation_applies(pass) {
1006            continue;
1007        }
1008        let input_ir = lower_transform_ir_from_source(
1009            fixture.input.as_str(),
1010            dialect,
1011            "omena-transform-passes.semantic-preservation.input",
1012        );
1013        let output_ir = lower_transform_ir_from_source(
1014            fixture.output.as_str(),
1015            dialect,
1016            "omena-transform-passes.semantic-preservation.output",
1017        );
1018        let projection = if !fixture.reachable_custom_property_names.is_empty()
1019            || pass == TransformPassKind::TreeShakeCustomProperty
1020        {
1021            SemanticObservationProjectionV0::for_custom_property_reachability(
1022                &input_ir,
1023                dialect,
1024                &fixture.reachable_custom_property_names,
1025                &fixture.reachable_keyframe_names,
1026                &fixture.reachable_class_names,
1027            )
1028        } else if !fixture.reachable_value_names.is_empty()
1029            || pass == TransformPassKind::TreeShakeValue
1030        {
1031            SemanticObservationProjectionV0::for_value_reachability(
1032                &input_ir,
1033                dialect,
1034                &fixture.reachable_value_names,
1035                &fixture.reachable_keyframe_names,
1036                &fixture.reachable_class_names,
1037            )
1038        } else if !fixture.reachable_keyframe_names.is_empty()
1039            || pass == TransformPassKind::TreeShakeKeyframes
1040        {
1041            SemanticObservationProjectionV0::for_keyframe_reachability(
1042                &input_ir,
1043                &fixture.reachable_keyframe_names,
1044                &fixture.reachable_class_names,
1045            )
1046        } else {
1047            SemanticObservationProjectionV0::default()
1048        };
1049        let scope = if !fixture.reachable_class_names.is_empty()
1050            && !projection.ignored_source_ranges().is_empty()
1051        {
1052            SemanticObservationScopeV0::for_reachable_class_names_and_ignored_source_ranges(
1053                &fixture.reachable_class_names,
1054                projection.ignored_source_ranges(),
1055            )
1056        } else if !fixture.reachable_class_names.is_empty() {
1057            SemanticObservationScopeV0::for_reachable_class_names(&fixture.reachable_class_names)
1058        } else if !projection.ignored_source_ranges().is_empty() {
1059            SemanticObservationScopeV0::for_ignored_source_ranges(
1060                projection.ignored_source_ranges(),
1061            )
1062        } else {
1063            SemanticObservationScopeV0::default()
1064        };
1065        let decision = compare_semantic_observation_for_pass_with_scopes(
1066            pass.id(),
1067            &input_ir,
1068            &output_ir,
1069            scope,
1070            scope.without_ignored_source_ranges(),
1071        );
1072        if !decision.preserved {
1073            rejected_count += 1;
1074        }
1075    }
1076
1077    let required_rejected_count = fixtures
1078        .iter()
1079        .filter(|fixture| fixture.expected_rejected)
1080        .count();
1081    Ok(TransformSemanticPreservationKillRateReportV0 {
1082        schema_version: "0",
1083        product: "omena-transform-passes.semantic-preservation-kill-rate",
1084        fixture_count: fixtures.len(),
1085        rejected_count,
1086        required_rejected_count,
1087        non_empty_corpus: !fixtures.is_empty(),
1088        kill_rate_passed: !fixtures.is_empty() && rejected_count >= required_rejected_count,
1089    })
1090}
1091
1092#[cfg(test)]
1093#[derive(Debug, Clone, Deserialize)]
1094#[serde(rename_all = "camelCase")]
1095struct TransformSemanticPreservationFixtureV0 {
1096    pass_id: String,
1097    input: String,
1098    output: String,
1099    expected_rejected: bool,
1100    #[serde(default)]
1101    reachable_class_names: Vec<String>,
1102    #[serde(default)]
1103    reachable_keyframe_names: Vec<String>,
1104    #[serde(default)]
1105    reachable_value_names: Vec<String>,
1106    #[serde(default)]
1107    reachable_custom_property_names: Vec<AuthoredPropertyTextV0>,
1108}
1109
1110#[cfg(test)]
1111fn transform_pass_kind_from_fixture_id(pass_id: &str) -> Option<TransformPassKind> {
1112    match pass_id {
1113        "empty-rule-removal" => Some(TransformPassKind::EmptyRuleRemoval),
1114        "rule-deduplication" => Some(TransformPassKind::RuleDeduplication),
1115        "rule-merging" => Some(TransformPassKind::RuleMerging),
1116        "selector-merging" => Some(TransformPassKind::SelectorMerging),
1117        "nesting-unwrap" => Some(TransformPassKind::NestingUnwrap),
1118        "scope-flatten" => Some(TransformPassKind::ScopeFlatten),
1119        "layer-flatten" => Some(TransformPassKind::LayerFlatten),
1120        "tree-shake-class" => Some(TransformPassKind::TreeShakeClass),
1121        "tree-shake-keyframes" => Some(TransformPassKind::TreeShakeKeyframes),
1122        "tree-shake-value" => Some(TransformPassKind::TreeShakeValue),
1123        "tree-shake-custom-property" => Some(TransformPassKind::TreeShakeCustomProperty),
1124        _ => None,
1125    }
1126}
1127
1128type SemanticObservationV0 = BTreeMap<SemanticObservationKeyV0, SemanticObservationValueV0>;
1129
1130#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
1131struct SemanticObservationKeyV0 {
1132    selector_key: String,
1133    property: CanonicalPropertyKeyV0,
1134    context_key: String,
1135}
1136
1137impl SemanticObservationKeyV0 {
1138    const FIELD_BINDINGS: [(&'static str, TransformSemanticObservationKeyAxisV0); 3] = [
1139        (
1140            "selector_key",
1141            TransformSemanticObservationKeyAxisV0::Selector,
1142        ),
1143        ("property", TransformSemanticObservationKeyAxisV0::Property),
1144        (
1145            "context_key",
1146            TransformSemanticObservationKeyAxisV0::Context,
1147        ),
1148    ];
1149}
1150
1151#[derive(Debug, Clone, PartialEq, Eq)]
1152struct SemanticObservationValueV0 {
1153    value: String,
1154    important: bool,
1155}
1156
1157impl SemanticObservationValueV0 {
1158    const FIELD_BINDINGS: [(&'static str, TransformSemanticObservationValueAxisV0); 2] = [
1159        ("value", TransformSemanticObservationValueAxisV0::Value),
1160        (
1161            "important",
1162            TransformSemanticObservationValueAxisV0::Important,
1163        ),
1164    ];
1165}
1166
1167fn semantic_observation_surface_descriptor() -> TransformSemanticObservationSurfaceV0 {
1168    TransformSemanticObservationSurfaceV0 {
1169        key_axes: SemanticObservationKeyV0::FIELD_BINDINGS
1170            .iter()
1171            .map(|(_, axis)| *axis)
1172            .collect(),
1173        value_axes: SemanticObservationValueV0::FIELD_BINDINGS
1174            .iter()
1175            .map(|(_, axis)| *axis)
1176            .collect(),
1177        ordering_rules: vec![
1178            TransformSemanticObservationOrderingRuleV0::SourceOrder,
1179            TransformSemanticObservationOrderingRuleV0::ImportantPrecedence,
1180        ],
1181        unobserved_axes: vec![
1182            TransformSemanticUnobservedAxisV0::NonStyleRuleDeclarationCarriers,
1183            TransformSemanticUnobservedAxisV0::IntraRuleDeclarationOrdering,
1184            TransformSemanticUnobservedAxisV0::InterSelectorSpecificityCompetition,
1185            TransformSemanticUnobservedAxisV0::CascadeLayerOrder,
1186            TransformSemanticUnobservedAxisV0::Origin,
1187            TransformSemanticUnobservedAxisV0::ScopeProximity,
1188            TransformSemanticUnobservedAxisV0::DomDependentMatching,
1189            TransformSemanticUnobservedAxisV0::Inheritance,
1190            TransformSemanticUnobservedAxisV0::CustomPropertyEnvironment,
1191            TransformSemanticUnobservedAxisV0::AnimationAndTransition,
1192        ],
1193        claim_scope: TransformSemanticPreservationClaimScopeV0::ObservedSurfaceOnly,
1194        vocabulary_review:
1195            TransformSemanticPreservationVocabularyReviewV0::DeferredUntilFullCascadeObservation,
1196    }
1197}
1198
1199#[derive(Debug, Clone, PartialEq, Eq)]
1200struct SemanticDeclarationCandidateV0 {
1201    key: SemanticObservationKeyV0,
1202    value: SemanticObservationValueV0,
1203    source_order: usize,
1204    source_span_start: usize,
1205    source_span_end: usize,
1206}
1207
1208#[derive(Debug, Clone)]
1209pub(crate) struct SemanticCascadeCandidateV0 {
1210    pub(crate) selector: String,
1211    pub(crate) property: omena_syntax::ident::AuthoredPropertyTextV0,
1212    pub(crate) value: String,
1213    pub(crate) important: bool,
1214    pub(crate) source_span_start: usize,
1215    pub(crate) source_span_end: usize,
1216    pub(crate) context_key: String,
1217}
1218
1219impl PartialEq for SemanticCascadeCandidateV0 {
1220    fn eq(&self, other: &Self) -> bool {
1221        self.selector == other.selector
1222            && self
1223                .property
1224                .to_property_name()
1225                .same_as(&other.property.to_property_name())
1226            && self.value == other.value
1227            && self.important == other.important
1228            && self.source_span_start == other.source_span_start
1229            && self.source_span_end == other.source_span_end
1230            && self.context_key == other.context_key
1231    }
1232}
1233
1234impl Eq for SemanticCascadeCandidateV0 {}
1235
1236#[cfg(test)]
1237mod authored_property_identity_tests {
1238    use super::*;
1239
1240    #[test]
1241    fn semantic_cascade_candidate_identity_uses_sealed_property_keys() {
1242        let candidate = |property: &str| SemanticCascadeCandidateV0 {
1243            selector: ".button".to_string(),
1244            property: omena_syntax::ident::AuthoredPropertyTextV0::new(property),
1245            value: "red".to_string(),
1246            important: false,
1247            source_span_start: 0,
1248            source_span_end: 1,
1249            context_key: "root".to_string(),
1250        };
1251
1252        assert_eq!(candidate("COLOR"), candidate(r"C\4f LOR"));
1253        assert_ne!(candidate("--foo"), candidate("--FOO"));
1254    }
1255}
1256
1257fn semantic_observation(
1258    ir: &TransformIrV0,
1259    scope: SemanticObservationScopeV0<'_>,
1260) -> SemanticObservationV0 {
1261    let mut observation = SemanticObservationV0::new();
1262    let candidates = semantic_declaration_candidates(ir, scope);
1263
1264    for candidate in candidates {
1265        match observation.get(&candidate.key) {
1266            Some(current) if current.important && !candidate.value.important => {
1267                continue;
1268            }
1269            _ => {
1270                observation.insert(candidate.key, candidate.value);
1271            }
1272        }
1273    }
1274
1275    observation
1276}
1277
1278fn semantic_declaration_candidates(
1279    ir: &TransformIrV0,
1280    scope: SemanticObservationScopeV0<'_>,
1281) -> Vec<SemanticDeclarationCandidateV0> {
1282    let mut candidates = ir
1283        .nodes
1284        .iter()
1285        .filter(|node| !node.deleted)
1286        .filter(|node| {
1287            !source_range_is_fully_ignored(node.source_span_start, node.source_span_end, scope)
1288        })
1289        .filter_map(|node| match node.kind {
1290            IrNodeKindV0::StyleRule => semantic_style_rule_candidates(ir, node, scope),
1291            IrNodeKindV0::AtRule => semantic_at_rule_style_rule_candidates(ir, node, scope),
1292            _ => None,
1293        })
1294        .flatten()
1295        .collect::<Vec<_>>();
1296    candidates.extend(semantic_css_modules_value_candidates(ir, scope));
1297    candidates.extend(semantic_custom_property_candidates(ir, scope));
1298    candidates.sort_by_key(|candidate| candidate.source_order);
1299    candidates
1300}
1301
1302pub(crate) fn semantic_cascade_candidates(
1303    ir: &TransformIrV0,
1304    scope: SemanticObservationScopeV0<'_>,
1305) -> Vec<SemanticCascadeCandidateV0> {
1306    semantic_declaration_candidates(ir, scope)
1307        .into_iter()
1308        .map(|candidate| SemanticCascadeCandidateV0 {
1309            selector: candidate.key.selector_key,
1310            property: omena_syntax::ident::AuthoredPropertyTextV0::new(
1311                candidate.key.property.as_str(),
1312            ),
1313            value: candidate.value.value,
1314            important: candidate.value.important,
1315            source_span_start: candidate.source_span_start,
1316            source_span_end: candidate.source_span_end,
1317            context_key: candidate.key.context_key,
1318        })
1319        .collect()
1320}
1321
1322fn semantic_custom_property_candidates(
1323    ir: &TransformIrV0,
1324    scope: SemanticObservationScopeV0<'_>,
1325) -> Vec<SemanticDeclarationCandidateV0> {
1326    collect_css_custom_property_semantic_facts_from_ir(ir)
1327        .into_iter()
1328        .filter(|fact| {
1329            !source_range_is_ignored(fact.source_span_start, fact.source_span_end, scope)
1330        })
1331        .map(|fact| SemanticDeclarationCandidateV0 {
1332            source_order: fact.source_span_start,
1333            source_span_start: fact.source_span_start,
1334            source_span_end: fact.source_span_end,
1335            key: SemanticObservationKeyV0 {
1336                selector_key: fact.fact_kind.to_string(),
1337                property: fact.name.to_property_name().canonical_key(),
1338                context_key: "css-custom-properties".to_string(),
1339            },
1340            value: SemanticObservationValueV0 {
1341                value: fact.value,
1342                important: false,
1343            },
1344        })
1345        .collect()
1346}
1347
1348fn semantic_css_modules_value_candidates(
1349    ir: &TransformIrV0,
1350    scope: SemanticObservationScopeV0<'_>,
1351) -> Vec<SemanticDeclarationCandidateV0> {
1352    collect_css_modules_value_semantic_facts_from_ir(ir, scope.dialect)
1353        .into_iter()
1354        .filter(|fact| {
1355            !source_range_is_ignored(fact.source_span_start, fact.source_span_end, scope)
1356        })
1357        .map(|fact| SemanticDeclarationCandidateV0 {
1358            source_order: fact.source_span_start,
1359            source_span_start: fact.source_span_start,
1360            source_span_end: fact.source_span_end,
1361            key: SemanticObservationKeyV0 {
1362                selector_key: fact.fact_kind.to_string(),
1363                property: PropertyNameV0::from_authored(fact.name).canonical_key(),
1364                context_key: "css-modules".to_string(),
1365            },
1366            value: SemanticObservationValueV0 {
1367                value: fact.value,
1368                important: false,
1369            },
1370        })
1371        .collect()
1372}
1373
1374fn semantic_style_rule_candidates(
1375    ir: &TransformIrV0,
1376    node: &IrNodeV0,
1377    scope: SemanticObservationScopeV0<'_>,
1378) -> Option<Vec<SemanticDeclarationCandidateV0>> {
1379    if has_deleted_ancestor(ir, node) {
1380        return None;
1381    }
1382    let selector_keys =
1383        observation_selector_keys(expanded_style_rule_selector_keys(ir, node)?, scope)
1384            .into_iter()
1385            .filter(|selector_key| {
1386                !selector_key.eq_ignore_ascii_case(":export")
1387                    && !selector_key.starts_with(":import")
1388            })
1389            .collect::<Vec<_>>();
1390    if selector_keys.is_empty() {
1391        return None;
1392    }
1393    let context_key = ancestor_at_rule_context_key(ir, node);
1394    let mut declarations = semantic_declarations_from_style_rule_text(ir, node, scope)
1395        .unwrap_or_else(|| semantic_declarations_from_direct_ir_children(ir, node, scope));
1396    declarations.sort_by_key(|declaration| declaration.source_order);
1397
1398    Some(candidates_from_selector_declarations(
1399        selector_keys.as_slice(),
1400        context_key.as_str(),
1401        declarations,
1402        node.source_span_start,
1403        node.source_span_end,
1404    ))
1405}
1406
1407fn semantic_at_rule_style_rule_candidates(
1408    ir: &TransformIrV0,
1409    node: &IrNodeV0,
1410    scope: SemanticObservationScopeV0<'_>,
1411) -> Option<Vec<SemanticDeclarationCandidateV0>> {
1412    if has_deleted_ancestor(ir, node) {
1413        return None;
1414    }
1415    let block_view = node_text_block_view(ir, node)?;
1416    let prelude = block_view.prelude(block_view.primary)?.trim();
1417    if !at_rule_prelude_is_reachable_in_scope(prelude, scope) {
1418        return None;
1419    }
1420    if has_style_rule_ancestor(ir, node) {
1421        return nested_at_rule_declaration_candidates(ir, node, prelude, scope);
1422    }
1423    let context_key = join_context_components(
1424        ancestor_at_rule_context_key(ir, node),
1425        at_rule_context_component_from_prelude(prelude),
1426    );
1427    let mut candidates = Vec::new();
1428
1429    for (index, rule_span) in block_view.direct_child_spans().into_iter().enumerate() {
1430        let selector = block_view.prelude(rule_span)?;
1431        if selector.trim_start().starts_with('@') {
1432            continue;
1433        }
1434        let selector_keys =
1435            observation_selector_keys(selector_keys_from_selector_text(selector), scope)
1436                .into_iter()
1437                .filter(|selector_key| {
1438                    !selector_key.eq_ignore_ascii_case(":export")
1439                        && !selector_key.starts_with(":import")
1440                })
1441                .collect::<Vec<_>>();
1442        if selector_keys.is_empty() {
1443            continue;
1444        }
1445        let declarations = semantic_declarations_from_block(
1446            block_view.source,
1447            rule_span,
1448            node.global_order
1449                .saturating_mul(4096)
1450                .saturating_add(index.saturating_mul(1024)),
1451        )
1452        .unwrap_or_default();
1453        candidates.extend(candidates_from_selector_declarations(
1454            selector_keys.as_slice(),
1455            context_key.as_str(),
1456            declarations,
1457            node.source_span_start,
1458            node.source_span_end,
1459        ));
1460    }
1461
1462    if candidates.is_empty() {
1463        None
1464    } else {
1465        Some(candidates)
1466    }
1467}
1468
1469fn nested_at_rule_declaration_candidates(
1470    ir: &TransformIrV0,
1471    node: &IrNodeV0,
1472    prelude: &str,
1473    scope: SemanticObservationScopeV0<'_>,
1474) -> Option<Vec<SemanticDeclarationCandidateV0>> {
1475    let (selector_keys, context_key) =
1476        if let Some(nest_selector) = nest_at_rule_selector_from_prelude(prelude) {
1477            let parent_selector = nearest_style_ancestor_expanded_selector(ir, node)?;
1478            let selector = expand_nested_selector(parent_selector.as_str(), nest_selector)?;
1479            (
1480                selector_keys_from_selector_text(selector.as_str()),
1481                ancestor_at_rule_context_key(ir, node),
1482            )
1483        } else {
1484            (
1485                nearest_style_ancestor_selector_keys(ir, node)?,
1486                join_context_components(
1487                    ancestor_at_rule_context_key(ir, node),
1488                    at_rule_context_component_from_prelude(prelude),
1489                ),
1490            )
1491        };
1492    let selector_keys = observation_selector_keys(selector_keys, scope)
1493        .into_iter()
1494        .filter(|selector_key| {
1495            !selector_key.eq_ignore_ascii_case(":export") && !selector_key.starts_with(":import")
1496        })
1497        .collect::<Vec<_>>();
1498    if selector_keys.is_empty() {
1499        return None;
1500    }
1501    let mut declarations = semantic_declarations_from_direct_ir_children(ir, node, scope);
1502    if declarations.is_empty() {
1503        return None;
1504    }
1505    declarations.sort_by_key(|declaration| declaration.source_order);
1506    Some(candidates_from_selector_declarations(
1507        selector_keys.as_slice(),
1508        context_key.as_str(),
1509        declarations,
1510        node.source_span_start,
1511        node.source_span_end,
1512    ))
1513}
1514
1515fn at_rule_prelude_is_reachable_in_scope(
1516    prelude: &str,
1517    scope: SemanticObservationScopeV0<'_>,
1518) -> bool {
1519    let Some(reachable_keyframe_names) = scope.reachable_keyframe_names else {
1520        return true;
1521    };
1522    let Some(keyframe_name) = keyframe_name_from_at_rule_prelude(prelude) else {
1523        return true;
1524    };
1525    keyframe_name_is_reachable(keyframe_name, reachable_keyframe_names)
1526}
1527
1528fn keyframe_name_from_at_rule_prelude(prelude: &str) -> Option<&str> {
1529    let trimmed = prelude.trim();
1530    let after_keyword = css_keyword(trimmed)
1531        .strip_prefix("@keyframes")
1532        .or_else(|| css_keyword(trimmed).strip_prefix("@-webkit-keyframes"))?;
1533    after_keyword.split_whitespace().next()
1534}
1535
1536fn observation_selector_keys(
1537    selector_keys: Vec<String>,
1538    scope: SemanticObservationScopeV0<'_>,
1539) -> Vec<String> {
1540    match scope.reachable_class_names {
1541        Some(reachable_class_names) => selector_keys
1542            .into_iter()
1543            .filter(|selector_key| {
1544                selector_is_reachable_in_closed_class_scope(selector_key, reachable_class_names)
1545            })
1546            .collect(),
1547        None => selector_keys,
1548    }
1549}
1550
1551fn selector_is_reachable_in_closed_class_scope(
1552    selector_key: &str,
1553    reachable_class_names: &[String],
1554) -> bool {
1555    let Some(owner_class_names) = selector_branch_owner_class_names(selector_key) else {
1556        return true;
1557    };
1558    owner_class_names
1559        .iter()
1560        .any(|owner| class_name_is_reachable(owner, reachable_class_names))
1561}
1562
1563fn source_range_is_ignored(
1564    source_span_start: usize,
1565    source_span_end: usize,
1566    scope: SemanticObservationScopeV0<'_>,
1567) -> bool {
1568    scope
1569        .ignored_source_ranges
1570        .iter()
1571        .any(|(start, end)| source_span_start < *end && source_span_end > *start)
1572}
1573
1574fn source_range_is_fully_ignored(
1575    source_span_start: usize,
1576    source_span_end: usize,
1577    scope: SemanticObservationScopeV0<'_>,
1578) -> bool {
1579    scope
1580        .ignored_source_ranges
1581        .iter()
1582        .any(|(start, end)| source_span_start >= *start && source_span_end <= *end)
1583}
1584
1585fn candidates_from_selector_declarations(
1586    selector_keys: &[String],
1587    context_key: &str,
1588    declarations: Vec<SemanticDeclarationV0>,
1589    source_span_start: usize,
1590    source_span_end: usize,
1591) -> Vec<SemanticDeclarationCandidateV0> {
1592    declarations
1593        .into_iter()
1594        .flat_map(|declaration| {
1595            let property = declaration.property_key;
1596            let value = declaration.value;
1597            let context_key = context_key.to_string();
1598            selector_keys
1599                .iter()
1600                .map(move |selector_key| SemanticDeclarationCandidateV0 {
1601                    key: SemanticObservationKeyV0 {
1602                        selector_key: selector_key.clone(),
1603                        property: property.clone(),
1604                        context_key: context_key.clone(),
1605                    },
1606                    value: SemanticObservationValueV0 {
1607                        value: value.clone(),
1608                        important: declaration.important,
1609                    },
1610                    source_order: declaration.source_order,
1611                    source_span_start,
1612                    source_span_end,
1613                })
1614        })
1615        .collect()
1616}
1617
1618#[derive(Debug, Clone, PartialEq, Eq)]
1619struct SemanticDeclarationV0 {
1620    property_key: CanonicalPropertyKeyV0,
1621    value: String,
1622    important: bool,
1623    source_order: usize,
1624}
1625
1626fn semantic_declaration_from_ir(
1627    ir: &TransformIrV0,
1628    node: &IrNodeV0,
1629) -> Option<SemanticDeclarationV0> {
1630    if has_deleted_ancestor(ir, node) {
1631        return None;
1632    }
1633    let source = node_text(ir, node)?.trim().trim_end_matches(';').trim();
1634    semantic_declaration_from_source(source, node.global_order)
1635}
1636
1637fn semantic_declarations_from_style_rule_text(
1638    ir: &TransformIrV0,
1639    node: &IrNodeV0,
1640    scope: SemanticObservationScopeV0<'_>,
1641) -> Option<Vec<SemanticDeclarationV0>> {
1642    if scope.force_ir_declarations {
1643        return None;
1644    }
1645    let block_view = node_text_block_view(ir, node)?;
1646    semantic_declarations_from_block(
1647        block_view.source,
1648        block_view.primary,
1649        node.global_order.saturating_mul(1024),
1650    )
1651}
1652
1653fn semantic_declarations_from_block(
1654    source: &str,
1655    span: IrBlockSpanV0,
1656    base_source_order: usize,
1657) -> Option<Vec<SemanticDeclarationV0>> {
1658    let body = source.get(span.body_start..span.body_end)?;
1659    if contains_nested_block_or_comment(body) {
1660        return None;
1661    }
1662    let declarations = split_declaration_list(body)
1663        .into_iter()
1664        .enumerate()
1665        .filter_map(|(index, declaration)| {
1666            semantic_declaration_from_source(
1667                declaration.as_str(),
1668                base_source_order.saturating_add(index),
1669            )
1670        })
1671        .collect::<Vec<_>>();
1672    if declarations.is_empty() {
1673        None
1674    } else {
1675        Some(declarations)
1676    }
1677}
1678
1679fn semantic_declaration_from_source(
1680    source: &str,
1681    source_order: usize,
1682) -> Option<SemanticDeclarationV0> {
1683    if source.is_empty() || contains_nested_block_or_comment(source) {
1684        return None;
1685    }
1686    let colon = declaration_colon_index(source)?;
1687    let property = source.get(..colon)?.trim();
1688    let value = source.get(colon + 1..)?.trim();
1689    if property.is_empty() || value.is_empty() {
1690        return None;
1691    }
1692    let property_name = PropertyNameV0::from_authored(property);
1693    Some(SemanticDeclarationV0 {
1694        property_key: property_name.canonical_key(),
1695        value: normalize_declaration_value(value),
1696        important: declaration_value_is_important(value),
1697        source_order,
1698    })
1699}
1700
1701fn expanded_style_rule_selector_keys(ir: &TransformIrV0, node: &IrNodeV0) -> Option<Vec<String>> {
1702    let selector = expanded_style_rule_selector_text(ir, node)?;
1703    let selector_keys = selector_keys_from_selector_text(selector.as_str());
1704    if selector_keys.is_empty() {
1705        None
1706    } else {
1707        Some(selector_keys)
1708    }
1709}
1710
1711fn expanded_style_rule_selector_text(ir: &TransformIrV0, node: &IrNodeV0) -> Option<String> {
1712    let mut selector = node_block_prelude(ir, node)?.trim().to_string();
1713    let mut expanded_parent_selector: Option<String> = None;
1714    for parent_selector in style_rule_ancestor_selectors(ir, node)?.into_iter().rev() {
1715        expanded_parent_selector = Some(match expanded_parent_selector {
1716            Some(expanded) => expand_nested_selector(expanded.as_str(), parent_selector.as_str())?,
1717            None => parent_selector,
1718        });
1719    }
1720    if let Some(parent_selector) = expanded_parent_selector {
1721        selector = expand_nested_selector(parent_selector.as_str(), selector.as_str())?;
1722    }
1723    Some(selector)
1724}
1725
1726fn nearest_style_ancestor_selector_keys(
1727    ir: &TransformIrV0,
1728    node: &IrNodeV0,
1729) -> Option<Vec<String>> {
1730    let mut parent = node.parent;
1731    while let Some(parent_id) = parent {
1732        let parent_node = ir.nodes.get(parent_id.index())?;
1733        if parent_node.deleted {
1734            return None;
1735        }
1736        if parent_node.kind == IrNodeKindV0::StyleRule {
1737            return expanded_style_rule_selector_keys(ir, parent_node);
1738        }
1739        parent = parent_node.parent;
1740    }
1741    None
1742}
1743
1744fn nearest_style_ancestor_expanded_selector(ir: &TransformIrV0, node: &IrNodeV0) -> Option<String> {
1745    let mut parent = node.parent;
1746    while let Some(parent_id) = parent {
1747        let parent_node = ir.nodes.get(parent_id.index())?;
1748        if parent_node.deleted {
1749            return None;
1750        }
1751        if parent_node.kind == IrNodeKindV0::StyleRule {
1752            return expanded_style_rule_selector_text(ir, parent_node);
1753        }
1754        parent = parent_node.parent;
1755    }
1756    None
1757}
1758
1759fn style_rule_ancestor_selectors(ir: &TransformIrV0, node: &IrNodeV0) -> Option<Vec<String>> {
1760    let mut selectors = Vec::new();
1761    let mut parent = node.parent;
1762    while let Some(parent_id) = parent {
1763        let parent_node = ir.nodes.get(parent_id.index())?;
1764        if parent_node.deleted {
1765            return None;
1766        }
1767        match parent_node.kind {
1768            IrNodeKindV0::StyleRule => {
1769                selectors.push(node_block_prelude(ir, parent_node)?.trim().to_string());
1770            }
1771            IrNodeKindV0::AtRule => {
1772                if let Some(selector) =
1773                    node_block_prelude(ir, parent_node).and_then(nest_at_rule_selector_from_prelude)
1774                {
1775                    selectors.push(selector.to_string());
1776                }
1777            }
1778            _ => {}
1779        }
1780        parent = parent_node.parent;
1781    }
1782    Some(selectors)
1783}
1784
1785fn nest_at_rule_selector_from_prelude(prelude: &str) -> Option<&str> {
1786    let prelude = prelude.trim();
1787    let selector = prelude.strip_prefix("@nest")?.trim();
1788    (!selector.is_empty()).then_some(selector)
1789}
1790
1791fn selector_keys_from_selector_text(selector: &str) -> Vec<String> {
1792    split_selector_list(selector)
1793        .into_iter()
1794        .map(|selector| normalize_selector_key(selector.as_str()))
1795        .filter(|selector| !selector.is_empty())
1796        .collect::<Vec<_>>()
1797}
1798
1799fn ancestor_at_rule_context_key(ir: &TransformIrV0, node: &IrNodeV0) -> String {
1800    let mut ancestors = Vec::new();
1801    let mut parent = node.parent;
1802    while let Some(parent_id) = parent {
1803        let Some(parent_node) = ir.nodes.get(parent_id.index()) else {
1804            break;
1805        };
1806        if parent_node.deleted {
1807            break;
1808        }
1809        if parent_node.kind == IrNodeKindV0::AtRule
1810            && let Some(context) = at_rule_context_component(ir, parent_node)
1811        {
1812            ancestors.push(context);
1813        }
1814        parent = parent_node.parent;
1815    }
1816    ancestors.reverse();
1817    ancestors.join("|")
1818}
1819
1820fn at_rule_context_component(ir: &TransformIrV0, node: &IrNodeV0) -> Option<String> {
1821    let prelude = node_block_prelude(ir, node).or_else(|| node_text(ir, node))?;
1822    at_rule_context_component_from_prelude(prelude.trim())
1823}
1824
1825fn at_rule_context_component_from_prelude(prelude: &str) -> Option<String> {
1826    if prelude.is_empty() {
1827        return None;
1828    }
1829    let normalized = normalize_space(prelude);
1830    if at_rule_prelude_is_semantically_transparent(normalized.as_str()) {
1831        None
1832    } else {
1833        Some(normalized)
1834    }
1835}
1836
1837fn at_rule_prelude_is_semantically_transparent(prelude: &str) -> bool {
1838    let lower = prelude.to_ascii_lowercase();
1839    let compact = lower
1840        .chars()
1841        .filter(|ch| !ch.is_ascii_whitespace())
1842        .collect::<String>();
1843    compact == "@scope(:root)"
1844        || lower.starts_with("@nest ")
1845        || lower
1846            .strip_prefix("@layer")
1847            .is_some_and(|name| !name.trim().is_empty() && !name.contains(','))
1848}
1849
1850fn join_context_components(base: String, current: Option<String>) -> String {
1851    match (base.is_empty(), current) {
1852        (true, Some(current)) => current,
1853        (false, Some(current)) => format!("{base}|{current}"),
1854        _ => base,
1855    }
1856}
1857
1858fn semantic_declarations_from_direct_ir_children(
1859    ir: &TransformIrV0,
1860    node: &IrNodeV0,
1861    scope: SemanticObservationScopeV0<'_>,
1862) -> Vec<SemanticDeclarationV0> {
1863    let syntax_facts = scope
1864        .force_ir_declarations
1865        .then(|| collect_parser_declaration_syntax_facts(ir.source_text(), scope.dialect));
1866    let mut declarations = if scope.force_ir_declarations {
1867        Vec::new()
1868    } else {
1869        semantic_declarations_from_direct_source_segments(ir, node, scope)
1870    };
1871    declarations.extend(
1872        node.children
1873            .iter()
1874            .filter_map(|child_id| ir.nodes.get(child_id.index()))
1875            .filter(|child| !child.deleted && child.kind == IrNodeKindV0::Declaration)
1876            .filter(|child| {
1877                !source_range_is_ignored(child.source_span_start, child.source_span_end, scope)
1878            })
1879            .filter_map(|child| {
1880                semantic_declaration_from_ir_fact(ir, child, syntax_facts.as_deref())
1881            }),
1882    );
1883    declarations.sort_by_key(|declaration| declaration.source_order);
1884    declarations.dedup_by(|left, right| {
1885        left.source_order == right.source_order
1886            && left.property_key == right.property_key
1887            && left.value == right.value
1888            && left.important == right.important
1889    });
1890    declarations
1891}
1892
1893fn semantic_declaration_from_ir_fact(
1894    ir: &TransformIrV0,
1895    node: &IrNodeV0,
1896    syntax_facts: Option<&[ParserDeclarationSyntaxFactV0]>,
1897) -> Option<SemanticDeclarationV0> {
1898    if node.canonical_text.is_none()
1899        && let Some(fact) = syntax_facts.and_then(|facts| {
1900            facts.iter().find(|fact| {
1901                fact.byte_span.start == node.source_span_start
1902                    && fact.byte_span.end <= node.source_span_end
1903            })
1904        })
1905    {
1906        return Some(SemanticDeclarationV0 {
1907            property_key: fact.property_key.clone(),
1908            value: fact.value_text.clone(),
1909            important: fact.important,
1910            source_order: node.global_order,
1911        });
1912    }
1913    semantic_declaration_from_ir(ir, node)
1914}
1915
1916fn semantic_declarations_from_direct_source_segments(
1917    ir: &TransformIrV0,
1918    node: &IrNodeV0,
1919    scope: SemanticObservationScopeV0<'_>,
1920) -> Vec<SemanticDeclarationV0> {
1921    let Some(block_span) = node.block_span else {
1922        return Vec::new();
1923    };
1924    let body_start = block_span.body_start;
1925    let body_end = block_span.body_end;
1926    let mut children = node
1927        .children
1928        .iter()
1929        .filter_map(|child_id| ir.nodes.get(child_id.index()))
1930        .filter(|child| {
1931            !child.deleted
1932                && matches!(child.kind, IrNodeKindV0::StyleRule | IrNodeKindV0::AtRule)
1933                && child.source_span_start >= body_start
1934                && child.source_span_end <= body_end
1935        })
1936        .collect::<Vec<_>>();
1937    children.sort_by_key(|child| (child.source_span_start, child.global_order));
1938
1939    let mut declarations = Vec::new();
1940    let mut cursor = body_start;
1941    for child in children {
1942        if cursor < child.source_span_start {
1943            declarations.extend(semantic_declarations_from_source_segment(
1944                ir,
1945                cursor,
1946                child.source_span_start,
1947                scope,
1948            ));
1949        }
1950        cursor = cursor.max(child.source_span_end);
1951    }
1952    if cursor < body_end {
1953        declarations.extend(semantic_declarations_from_source_segment(
1954            ir, cursor, body_end, scope,
1955        ));
1956    }
1957    declarations
1958}
1959
1960fn semantic_declarations_from_source_segment(
1961    ir: &TransformIrV0,
1962    start: usize,
1963    end: usize,
1964    scope: SemanticObservationScopeV0<'_>,
1965) -> Vec<SemanticDeclarationV0> {
1966    if source_range_is_ignored(start, end, scope) {
1967        return Vec::new();
1968    }
1969    let Some(segment) = ir.source_text().get(start..end) else {
1970        return Vec::new();
1971    };
1972    split_declaration_list(segment)
1973        .into_iter()
1974        .enumerate()
1975        .filter_map(|(index, declaration)| {
1976            semantic_declaration_from_source(declaration.as_str(), start.saturating_add(index))
1977        })
1978        .collect()
1979}
1980
1981fn semantic_observation_mismatch_count(
1982    input: &SemanticObservationV0,
1983    output: &SemanticObservationV0,
1984) -> usize {
1985    let missing_or_changed = input
1986        .iter()
1987        .filter(|(key, value)| output.get(*key) != Some(*value))
1988        .count();
1989    let added = output
1990        .keys()
1991        .filter(|key| !input.contains_key(*key))
1992        .count();
1993    missing_or_changed + added
1994}
1995
1996fn has_deleted_ancestor(ir: &TransformIrV0, node: &IrNodeV0) -> bool {
1997    let mut parent = node.parent;
1998    while let Some(parent_id) = parent {
1999        let Some(parent_node) = ir.nodes.get(parent_id.index()) else {
2000            return true;
2001        };
2002        if parent_node.deleted {
2003            return true;
2004        }
2005        parent = parent_node.parent;
2006    }
2007    false
2008}
2009
2010fn has_style_rule_ancestor(ir: &TransformIrV0, node: &IrNodeV0) -> bool {
2011    let mut parent = node.parent;
2012    while let Some(parent_id) = parent {
2013        let Some(parent_node) = ir.nodes.get(parent_id.index()) else {
2014            return false;
2015        };
2016        if parent_node.kind == IrNodeKindV0::StyleRule {
2017            return true;
2018        }
2019        parent = parent_node.parent;
2020    }
2021    false
2022}
2023
2024fn node_text<'a>(ir: &'a TransformIrV0, node: &'a IrNodeV0) -> Option<&'a str> {
2025    node.canonical_text.as_deref().or_else(|| {
2026        ir.source_text()
2027            .get(node.source_span_start..node.source_span_end)
2028    })
2029}
2030
2031struct NodeTextBlockViewV0<'source> {
2032    source: &'source str,
2033    primary: IrBlockSpanV0,
2034    spans: Vec<IrBlockSpanV0>,
2035}
2036
2037impl NodeTextBlockViewV0<'_> {
2038    fn prelude(&self, span: IrBlockSpanV0) -> Option<&str> {
2039        self.source.get(span.prelude_start..span.open_brace_start)
2040    }
2041
2042    fn direct_child_spans(&self) -> Vec<IrBlockSpanV0> {
2043        let nested = self
2044            .spans
2045            .iter()
2046            .copied()
2047            .filter(|span| {
2048                *span != self.primary
2049                    && self.primary.body_start <= span.prelude_start
2050                    && span.rule_end <= self.primary.body_end
2051            })
2052            .collect::<Vec<_>>();
2053        nested
2054            .iter()
2055            .copied()
2056            .filter(|candidate| {
2057                !nested.iter().any(|owner| {
2058                    owner != candidate
2059                        && owner.body_start <= candidate.prelude_start
2060                        && candidate.rule_end <= owner.body_end
2061                })
2062            })
2063            .collect()
2064    }
2065}
2066
2067fn node_text_block_view<'source>(
2068    ir: &'source TransformIrV0,
2069    node: &'source IrNodeV0,
2070) -> Option<NodeTextBlockViewV0<'source>> {
2071    let source = node_text(ir, node)?;
2072    if node.canonical_text.is_some() {
2073        let spans = structural_block_spans_for_source(source, ir_style_dialect(ir)?);
2074        let primary = spans
2075            .iter()
2076            .copied()
2077            .filter(|span| span.prelude_start == 0)
2078            .max_by_key(|span| span.rule_end)?;
2079        return Some(NodeTextBlockViewV0 {
2080            source,
2081            primary,
2082            spans,
2083        });
2084    }
2085
2086    let primary = shift_block_span(node.block_span?, node.source_span_start)?;
2087    let spans = ir
2088        .structural_block_spans()
2089        .iter()
2090        .copied()
2091        .filter(|span| {
2092            node.source_span_start <= span.prelude_start && span.rule_end <= node.source_span_end
2093        })
2094        .filter_map(|span| shift_block_span(span, node.source_span_start))
2095        .collect();
2096    Some(NodeTextBlockViewV0 {
2097        source,
2098        primary,
2099        spans,
2100    })
2101}
2102
2103fn shift_block_span(span: IrBlockSpanV0, offset: usize) -> Option<IrBlockSpanV0> {
2104    Some(IrBlockSpanV0 {
2105        prelude_start: span.prelude_start.checked_sub(offset)?,
2106        open_brace_start: span.open_brace_start.checked_sub(offset)?,
2107        body_start: span.body_start.checked_sub(offset)?,
2108        body_end: span.body_end.checked_sub(offset)?,
2109        rule_end: span.rule_end.checked_sub(offset)?,
2110    })
2111}
2112
2113fn ir_style_dialect(ir: &TransformIrV0) -> Option<StyleDialect> {
2114    match ir.dialect {
2115        "css" => Some(StyleDialect::Css),
2116        "scss" => Some(StyleDialect::Scss),
2117        "sass" => Some(StyleDialect::Sass),
2118        "less" => Some(StyleDialect::Less),
2119        _ => None,
2120    }
2121}
2122
2123fn node_block_prelude<'source>(
2124    ir: &'source TransformIrV0,
2125    node: &'source IrNodeV0,
2126) -> Option<&'source str> {
2127    let view = node_text_block_view(ir, node)?;
2128    view.source
2129        .get(view.primary.prelude_start..view.primary.open_brace_start)
2130}
2131
2132fn normalize_selector_key(selector: &str) -> String {
2133    normalize_space(selector)
2134}
2135
2136fn normalize_declaration_value(value: &str) -> String {
2137    normalize_space(value)
2138}
2139
2140fn normalize_space(value: &str) -> String {
2141    value.split_whitespace().collect::<Vec<_>>().join(" ")
2142}
2143
2144fn split_selector_list(selector: &str) -> Vec<String> {
2145    let mut parts = Vec::new();
2146    let mut start = 0usize;
2147    let mut quote = None;
2148    let mut escaped = false;
2149    let mut paren_depth = 0usize;
2150    let mut bracket_depth = 0usize;
2151
2152    for (index, byte) in selector.bytes().enumerate() {
2153        if let Some(quote_byte) = quote {
2154            if escaped {
2155                escaped = false;
2156            } else if byte == b'\\' {
2157                escaped = true;
2158            } else if byte == quote_byte {
2159                quote = None;
2160            }
2161            continue;
2162        }
2163
2164        match byte {
2165            b'\'' | b'"' => quote = Some(byte),
2166            b'(' => paren_depth = paren_depth.saturating_add(1),
2167            b')' => paren_depth = paren_depth.saturating_sub(1),
2168            b'[' => bracket_depth = bracket_depth.saturating_add(1),
2169            b']' => bracket_depth = bracket_depth.saturating_sub(1),
2170            b',' if paren_depth == 0 && bracket_depth == 0 => {
2171                if let Some(part) = selector.get(start..index) {
2172                    parts.push(part.trim().to_string());
2173                }
2174                start = index.saturating_add(1);
2175            }
2176            _ => {}
2177        }
2178    }
2179
2180    if let Some(part) = selector.get(start..) {
2181        parts.push(part.trim().to_string());
2182    }
2183    parts
2184}
2185
2186fn split_declaration_list(body: &str) -> Vec<String> {
2187    let mut parts = Vec::new();
2188    let mut start = 0usize;
2189    let mut quote = None;
2190    let mut escaped = false;
2191    let mut paren_depth = 0usize;
2192    let mut bracket_depth = 0usize;
2193
2194    for (index, byte) in body.bytes().enumerate() {
2195        if let Some(quote_byte) = quote {
2196            if escaped {
2197                escaped = false;
2198            } else if byte == b'\\' {
2199                escaped = true;
2200            } else if byte == quote_byte {
2201                quote = None;
2202            }
2203            continue;
2204        }
2205
2206        match byte {
2207            b'\'' | b'"' => quote = Some(byte),
2208            b'(' => paren_depth = paren_depth.saturating_add(1),
2209            b')' => paren_depth = paren_depth.saturating_sub(1),
2210            b'[' => bracket_depth = bracket_depth.saturating_add(1),
2211            b']' => bracket_depth = bracket_depth.saturating_sub(1),
2212            b';' if paren_depth == 0 && bracket_depth == 0 => {
2213                if let Some(part) = body.get(start..index) {
2214                    let trimmed = part.trim();
2215                    if !trimmed.is_empty() {
2216                        parts.push(trimmed.to_string());
2217                    }
2218                }
2219                start = index.saturating_add(1);
2220            }
2221            _ => {}
2222        }
2223    }
2224
2225    if let Some(part) = body.get(start..) {
2226        let trimmed = part.trim();
2227        if !trimmed.is_empty() {
2228            parts.push(trimmed.to_string());
2229        }
2230    }
2231    parts
2232}
2233
2234fn contains_nested_block_or_comment(source: &str) -> bool {
2235    let bytes = source.as_bytes();
2236    let mut index = 0usize;
2237    let mut quote = None;
2238    let mut escaped = false;
2239    while index < bytes.len() {
2240        let byte = bytes[index];
2241        if let Some(quote_byte) = quote {
2242            if escaped {
2243                escaped = false;
2244            } else if byte == b'\\' {
2245                escaped = true;
2246            } else if byte == quote_byte {
2247                quote = None;
2248            }
2249            index += 1;
2250            continue;
2251        }
2252        if matches!(byte, b'\'' | b'"') {
2253            quote = Some(byte);
2254            index += 1;
2255            continue;
2256        }
2257        if matches!(byte, b'{' | b'}') || (byte == b'/' && bytes.get(index + 1) == Some(&b'*')) {
2258            return true;
2259        }
2260        index += 1;
2261    }
2262    false
2263}
2264
2265fn declaration_colon_index(source: &str) -> Option<usize> {
2266    let bytes = source.as_bytes();
2267    let mut index = 0usize;
2268    let mut quote = None;
2269    let mut escaped = false;
2270    let mut paren_depth = 0usize;
2271    let mut bracket_depth = 0usize;
2272
2273    while index < bytes.len() {
2274        let byte = bytes[index];
2275        if let Some(quote_byte) = quote {
2276            if escaped {
2277                escaped = false;
2278            } else if byte == b'\\' {
2279                escaped = true;
2280            } else if byte == quote_byte {
2281                quote = None;
2282            }
2283            index += 1;
2284            continue;
2285        }
2286        match byte {
2287            b'\'' | b'"' => quote = Some(byte),
2288            b'(' => paren_depth = paren_depth.saturating_add(1),
2289            b')' => paren_depth = paren_depth.saturating_sub(1),
2290            b'[' => bracket_depth = bracket_depth.saturating_add(1),
2291            b']' => bracket_depth = bracket_depth.saturating_sub(1),
2292            b':' if paren_depth == 0 && bracket_depth == 0 => return Some(index),
2293            _ => {}
2294        }
2295        index += 1;
2296    }
2297    None
2298}
2299
2300fn declaration_value_is_important(value: &str) -> bool {
2301    let bytes = value.as_bytes();
2302    let mut index = 0usize;
2303    while index < bytes.len() {
2304        if bytes[index] == b'!' {
2305            let rest = value.get(index + 1..).unwrap_or_default().trim_start();
2306            return rest
2307                .get(.."important".len())
2308                .is_some_and(|candidate| candidate.eq_ignore_ascii_case("important"));
2309        }
2310        index += 1;
2311    }
2312    false
2313}
2314
2315#[cfg(test)]
2316fn semantic_model_conformance_case_results() -> Vec<bool> {
2317    let cases = [
2318        (
2319            "empty-rule-removal",
2320            ".a { color: red; }\n.a { color: blue; }\n.empty {}\n",
2321            ".a { color: red; }\n.a { color: blue; }\n",
2322            true,
2323        ),
2324        (
2325            "rule-deduplication",
2326            ".a { color: red !important; }\n.a { color: blue; }\n",
2327            ".a { color: red !important; }\n.a { color: blue; }\n",
2328            true,
2329        ),
2330        (
2331            "rule-deduplication",
2332            "@media (min-width: 1px) { .a { color: red; } }\n.a { color: blue; }\n",
2333            "@media (min-width: 1px) { .a { color: red; } }\n.a { color: blue; }\n",
2334            true,
2335        ),
2336        (
2337            "rule-deduplication",
2338            ".a { color: red !important; }\n.a { color: blue; }\n",
2339            ".a { color: blue; }\n",
2340            false,
2341        ),
2342        (
2343            "tree-shake-class",
2344            ".used { color: red; }\n.dead { color: blue; }\n",
2345            ".used { color: red; }\n",
2346            true,
2347        ),
2348        (
2349            "tree-shake-keyframes",
2350            "@keyframes used { to { opacity: 1; } }\n@keyframes dead { to { opacity: 0; } }\n.btn { animation: used 1s; }\n",
2351            "@keyframes used { to { opacity: 1; } }\n.btn { animation: used 1s; }\n",
2352            true,
2353        ),
2354        (
2355            "tree-shake-value",
2356            "@value used: red;\n@value dead: blue;\n.btn { color: used; }\n",
2357            "@value used: red;\n.btn { color: used; }\n",
2358            true,
2359        ),
2360        (
2361            "tree-shake-custom-property",
2362            "@property --used { syntax: \"<color>\"; inherits: false; initial-value: red; }\n@property --dead { syntax: \"<color>\"; inherits: false; initial-value: blue; }\n:root { --used: red; --dead: blue; }\n.btn { color: var(--used); }\n",
2363            "@property --used { syntax: \"<color>\"; inherits: false; initial-value: red; }\n:root { --used: red; }\n.btn { color: var(--used); }\n",
2364            true,
2365        ),
2366        (
2367            "nesting-unwrap",
2368            ".card { color: red; & .title { color: blue; } }\n",
2369            ".card { color: red; }\n.card .title { color: blue; }\n",
2370            true,
2371        ),
2372        (
2373            "scope-flatten",
2374            "@scope (:root) { .card { color: red; } }\n",
2375            ".card { color: red; }\n",
2376            true,
2377        ),
2378        (
2379            "layer-flatten",
2380            "@layer theme { .card { color: red; } }\n",
2381            ".card { color: red; }\n",
2382            true,
2383        ),
2384    ];
2385
2386    cases
2387        .into_iter()
2388        .map(|(pass_id, input, output, expected_preserved)| {
2389            let input_ir = lower_transform_ir_from_source(input, StyleDialect::Css, "input");
2390            let output_ir = lower_transform_ir_from_source(output, StyleDialect::Css, "output");
2391            let reachable_class_names = vec!["used".to_string()];
2392            let keyframe_class_names = vec!["btn".to_string()];
2393            let value_class_names = vec!["btn".to_string()];
2394            let custom_property_class_names = vec!["btn".to_string()];
2395            let projection = if pass_id == "tree-shake-keyframes" {
2396                SemanticObservationProjectionV0::for_keyframe_reachability(
2397                    &input_ir,
2398                    &[],
2399                    &keyframe_class_names,
2400                )
2401            } else if pass_id == "tree-shake-value" {
2402                SemanticObservationProjectionV0::for_value_reachability(
2403                    &input_ir,
2404                    StyleDialect::Css,
2405                    &[],
2406                    &[],
2407                    &value_class_names,
2408                )
2409            } else if pass_id == "tree-shake-custom-property" {
2410                SemanticObservationProjectionV0::for_custom_property_reachability(
2411                    &input_ir,
2412                    StyleDialect::Css,
2413                    &[],
2414                    &[],
2415                    &custom_property_class_names,
2416                )
2417            } else {
2418                SemanticObservationProjectionV0::default()
2419            };
2420            let scope = if pass_id == "tree-shake-class" {
2421                SemanticObservationScopeV0::for_reachable_class_names(&reachable_class_names)
2422            } else if pass_id == "tree-shake-keyframes" {
2423                SemanticObservationScopeV0::for_ignored_source_ranges(
2424                    projection.ignored_source_ranges(),
2425                )
2426            } else if pass_id == "tree-shake-value" {
2427                SemanticObservationScopeV0::for_reachable_class_names_and_ignored_source_ranges(
2428                    &value_class_names,
2429                    projection.ignored_source_ranges(),
2430                )
2431            } else if pass_id == "tree-shake-custom-property" {
2432                SemanticObservationScopeV0::for_reachable_class_names_and_ignored_source_ranges(
2433                    &custom_property_class_names,
2434                    projection.ignored_source_ranges(),
2435                )
2436            } else {
2437                SemanticObservationScopeV0::default()
2438            };
2439            let decision = compare_semantic_observation_for_pass_with_scopes(
2440                pass_id,
2441                &input_ir,
2442                &output_ir,
2443                scope,
2444                scope.without_ignored_source_ranges(),
2445            );
2446            decision.preserved == expected_preserved
2447        })
2448        .collect()
2449}
2450
2451#[cfg(test)]
2452fn stable_semantic_report_digest(parts: &[&str]) -> String {
2453    let mut hash = 0xcbf2_9ce4_8422_2325_u64;
2454    for part in parts {
2455        for byte in part.as_bytes() {
2456            hash ^= u64::from(*byte);
2457            hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
2458        }
2459        hash ^= 0xff;
2460        hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
2461    }
2462    format!("fnv1a64:{hash:016x}")
2463}
2464
2465#[cfg(test)]
2466#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2467#[serde(rename_all = "camelCase")]
2468struct SemanticObservationContractV0 {
2469    schema_version: String,
2470    product: String,
2471    cases: Vec<SemanticObservationContractCaseV0>,
2472}
2473
2474#[cfg(test)]
2475#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2476#[serde(rename_all = "camelCase")]
2477struct SemanticObservationContractCaseV0 {
2478    case_id: String,
2479    entries: Vec<SemanticObservationContractEntryV0>,
2480}
2481
2482#[cfg(test)]
2483#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2484#[serde(rename_all = "camelCase")]
2485struct SemanticObservationContractEntryV0 {
2486    selector: String,
2487    property: String,
2488    context: String,
2489    value: String,
2490    important: bool,
2491}
2492
2493#[cfg(test)]
2494fn semantic_observation_contract_snapshot() -> SemanticObservationContractV0 {
2495    let cases = [
2496        (
2497            "cascade-and-media",
2498            StyleDialect::Css,
2499            ".card { color: red; } .card { color: blue !important; } @media (width > 20rem) { .card { display: grid; } }",
2500        ),
2501        (
2502            "nested-scss",
2503            StyleDialect::Scss,
2504            ".card { color: red; &:hover { color: blue; } @media (width > 20rem) { &__title { display: block; } } }",
2505        ),
2506        (
2507            "keyframes",
2508            StyleDialect::Css,
2509            "@keyframes fade { from { opacity: 0; } 50% { opacity: .5; } to { opacity: 1; } } .card { animation: fade 1s; }",
2510        ),
2511        (
2512            "css-modules",
2513            StyleDialect::Css,
2514            "@value tone: red; :export { exported: tone; } .button { composes: base from \"./base.css\"; color: tone; }",
2515        ),
2516        (
2517            "delimiter-and-comment",
2518            StyleDialect::Css,
2519            ".card { content: \"{; }\"; /* observer falls back to typed declarations */ color: red; }",
2520        ),
2521    ]
2522    .into_iter()
2523    .map(|(case_id, dialect, source)| {
2524        let ir = lower_transform_ir_from_source(source, dialect, case_id);
2525        let scope = SemanticObservationScopeV0::from_parts(None, None, &[], dialect);
2526        let entries = semantic_observation(&ir, scope)
2527            .into_iter()
2528            .map(|(key, value)| SemanticObservationContractEntryV0 {
2529                selector: key.selector_key,
2530                property: key.property.as_str().to_string(),
2531                context: key.context_key,
2532                value: value.value,
2533                important: value.important,
2534            })
2535            .collect();
2536        SemanticObservationContractCaseV0 {
2537            case_id: case_id.to_string(),
2538            entries,
2539        }
2540    })
2541    .collect();
2542
2543    SemanticObservationContractV0 {
2544        schema_version: "0".to_string(),
2545        product: "omena-transform-passes.semantic-observation-contract".to_string(),
2546        cases,
2547    }
2548}
2549
2550#[cfg(test)]
2551mod tests {
2552    use super::*;
2553
2554    #[test]
2555    fn extracts_keyframe_names_from_mixed_case_at_rule_preludes() {
2556        assert_eq!(
2557            keyframe_name_from_at_rule_prelude("@KEYFRAMES fade"),
2558            Some("fade")
2559        );
2560        assert_eq!(
2561            keyframe_name_from_at_rule_prelude("@-WeBkIt-KeYfRaMeS pulse"),
2562            Some("pulse")
2563        );
2564    }
2565
2566    fn struct_field_names(source: &str, struct_name: &str) -> Vec<String> {
2567        let marker = format!("struct {struct_name} {{");
2568        let body = source.split_once(&marker).map(|(_, body)| body);
2569        assert!(body.is_some(), "missing struct {struct_name}");
2570        let Some(body) = body else {
2571            return Vec::new();
2572        };
2573        body.lines()
2574            .take_while(|line| line.trim() != "}")
2575            .filter_map(|line| {
2576                let (field, _) = line.trim().trim_end_matches(',').split_once(':')?;
2577                let field = field.trim();
2578                field
2579                    .chars()
2580                    .all(|character| character == '_' || character.is_ascii_alphanumeric())
2581                    .then(|| field.to_string())
2582            })
2583            .collect()
2584    }
2585
2586    fn observer_shape_matches_bindings(source: &str) -> bool {
2587        let key_fields = struct_field_names(source, "SemanticObservationKeyV0");
2588        let value_fields = struct_field_names(source, "SemanticObservationValueV0");
2589        let bound_key_fields = SemanticObservationKeyV0::FIELD_BINDINGS
2590            .iter()
2591            .map(|(field, _)| field.to_string())
2592            .collect::<Vec<_>>();
2593        let bound_value_fields = SemanticObservationValueV0::FIELD_BINDINGS
2594            .iter()
2595            .map(|(field, _)| field.to_string())
2596            .collect::<Vec<_>>();
2597        key_fields == bound_key_fields && value_fields == bound_value_fields
2598    }
2599
2600    #[test]
2601    fn semantic_observation_surface_descriptor_matches_observer_types() {
2602        let source = include_str!("semantic_preservation.rs");
2603        assert!(observer_shape_matches_bindings(source));
2604
2605        let descriptor = semantic_observation_surface_descriptor();
2606        assert_eq!(
2607            descriptor.key_axes,
2608            SemanticObservationKeyV0::FIELD_BINDINGS
2609                .iter()
2610                .map(|(_, axis)| *axis)
2611                .collect::<Vec<_>>()
2612        );
2613        assert_eq!(
2614            descriptor.value_axes,
2615            SemanticObservationValueV0::FIELD_BINDINGS
2616                .iter()
2617                .map(|(_, axis)| *axis)
2618                .collect::<Vec<_>>()
2619        );
2620        assert_eq!(
2621            descriptor.ordering_rules,
2622            vec![
2623                TransformSemanticObservationOrderingRuleV0::SourceOrder,
2624                TransformSemanticObservationOrderingRuleV0::ImportantPrecedence,
2625            ]
2626        );
2627        assert_eq!(descriptor.unobserved_axes.len(), 10);
2628        assert!(
2629            descriptor
2630                .unobserved_axes
2631                .contains(&TransformSemanticUnobservedAxisV0::NonStyleRuleDeclarationCarriers)
2632        );
2633        assert!(
2634            descriptor
2635                .unobserved_axes
2636                .contains(&TransformSemanticUnobservedAxisV0::IntraRuleDeclarationOrdering)
2637        );
2638        assert_eq!(
2639            descriptor.claim_scope,
2640            TransformSemanticPreservationClaimScopeV0::ObservedSurfaceOnly
2641        );
2642        assert_eq!(
2643            descriptor.vocabulary_review,
2644            TransformSemanticPreservationVocabularyReviewV0::DeferredUntilFullCascadeObservation
2645        );
2646    }
2647
2648    #[test]
2649    fn semantic_observation_surface_descriptor_detects_shape_drift() {
2650        let source = include_str!("semantic_preservation.rs");
2651        let widened = source.replacen(
2652            "context_key: String,",
2653            "context_key: String,\n    specificity: String,",
2654            1,
2655        );
2656        assert!(!observer_shape_matches_bindings(&widened));
2657    }
2658
2659    #[test]
2660    fn semantic_observation_ordering_matches_the_disclosed_rules() {
2661        let ir = lower_transform_ir_from_source(
2662            ".a { color: red; } .a { color: blue !important; } .a { color: green; } .b { color: red; } .b { color: blue; }",
2663            StyleDialect::Css,
2664            "semantic-observation-ordering",
2665        );
2666        let observation = semantic_observation(&ir, SemanticObservationScopeV0::default());
2667
2668        let important = observation.get(&SemanticObservationKeyV0 {
2669            selector_key: ".a".to_string(),
2670            property: PropertyNameV0::standard("color").canonical_key(),
2671            context_key: String::new(),
2672        });
2673        assert!(important.is_some(), "important declaration observation");
2674        if let Some(important) = important {
2675            assert_eq!(important.value, "blue !important");
2676            assert!(important.important);
2677        }
2678
2679        let source_order = observation.get(&SemanticObservationKeyV0 {
2680            selector_key: ".b".to_string(),
2681            property: PropertyNameV0::standard("color").canonical_key(),
2682            context_key: String::new(),
2683        });
2684        assert!(
2685            source_order.is_some(),
2686            "source-order declaration observation"
2687        );
2688        if let Some(source_order) = source_order {
2689            assert_eq!(source_order.value, "blue");
2690            assert!(!source_order.important);
2691        }
2692    }
2693
2694    #[test]
2695    fn semantic_observation_matches_committed_contract() -> Result<(), serde_json::Error> {
2696        let actual = semantic_observation_contract_snapshot();
2697        let expected = serde_json::from_str::<SemanticObservationContractV0>(include_str!(
2698            "../../fixtures/semantic-preservation/observer-contract.json"
2699        ))?;
2700
2701        assert_eq!(actual, expected);
2702        Ok(())
2703    }
2704
2705    #[test]
2706    fn semantic_observation_contract_detects_a_dropped_declaration() -> Result<(), serde_json::Error>
2707    {
2708        let expected = serde_json::from_str::<SemanticObservationContractV0>(include_str!(
2709            "../../fixtures/semantic-preservation/observer-contract.json"
2710        ))?;
2711        let mut lossy = semantic_observation_contract_snapshot();
2712        assert_eq!(lossy.cases[0].case_id, "cascade-and-media");
2713        let case = &mut lossy.cases[0];
2714        let entry_count = case.entries.len();
2715        case.entries
2716            .retain(|entry| entry.property != "display" || entry.context.is_empty());
2717        assert_eq!(case.entries.len() + 1, entry_count);
2718        assert_ne!(lossy, expected);
2719
2720        let input = lower_transform_ir_from_source(
2721            ".card { color: red; display: grid; }",
2722            StyleDialect::Css,
2723            "semantic-drop-input",
2724        );
2725        let output = lower_transform_ir_from_source(
2726            ".card { color: red; }",
2727            StyleDialect::Css,
2728            "semantic-drop-output",
2729        );
2730        let decision = compare_semantic_observation_for_pass("rule-deduplication", &input, &output);
2731        assert!(!decision.preserved);
2732        assert_eq!(decision.mismatch_count, 1);
2733        Ok(())
2734    }
2735
2736    #[test]
2737    fn observation_ignores_removed_empty_rules() {
2738        let input = lower_transform_ir_from_source(
2739            ".a { color: red; }\n.empty {}\n",
2740            StyleDialect::Css,
2741            "test",
2742        );
2743        let output =
2744            lower_transform_ir_from_source(".a { color: red; }\n", StyleDialect::Css, "test");
2745        let decision = compare_semantic_observation_for_pass("empty-rule-removal", &input, &output);
2746
2747        assert!(decision.preserved);
2748        assert_eq!(decision.mismatch_count, 0);
2749        assert_eq!(decision.input_entry_count, 1);
2750        assert_eq!(decision.output_entry_count, 1);
2751    }
2752
2753    #[test]
2754    fn observation_catches_declared_value_changes() {
2755        let input = lower_transform_ir_from_source(".a { color: red; }", StyleDialect::Css, "test");
2756        let output =
2757            lower_transform_ir_from_source(".a { color: blue; }", StyleDialect::Css, "test");
2758        let decision = compare_semantic_observation_for_pass("rule-deduplication", &input, &output);
2759
2760        assert!(!decision.preserved);
2761        assert_eq!(decision.mismatch_count, 1);
2762    }
2763
2764    #[test]
2765    fn observation_projects_class_tree_shake_to_reachable_selectors() {
2766        let reachable_class_names = vec!["used".to_string()];
2767        let input = lower_transform_ir_from_source(
2768            ".used { color: red; }\n.dead { color: blue; }\n.used, .dead-mixed { background: blue; }\n",
2769            StyleDialect::Css,
2770            "test",
2771        );
2772        let output = lower_transform_ir_from_source(
2773            ".used { color: red; }\n.used { background: blue; }\n",
2774            StyleDialect::Css,
2775            "test",
2776        );
2777        let decision = compare_semantic_observation_for_pass_with_scope(
2778            "tree-shake-class",
2779            &input,
2780            &output,
2781            SemanticObservationScopeV0::for_reachable_class_names(&reachable_class_names),
2782        );
2783
2784        assert!(decision.preserved);
2785        assert_eq!(decision.mismatch_count, 0);
2786    }
2787
2788    #[test]
2789    fn observation_rejects_reachable_class_tree_shake_changes() {
2790        let reachable_class_names = vec!["used".to_string()];
2791        let input = lower_transform_ir_from_source(
2792            ".used { color: red; }\n.dead { color: blue; }\n",
2793            StyleDialect::Css,
2794            "test",
2795        );
2796        let output =
2797            lower_transform_ir_from_source(".used { color: green; }\n", StyleDialect::Css, "test");
2798        let decision = compare_semantic_observation_for_pass_with_scope(
2799            "tree-shake-class",
2800            &input,
2801            &output,
2802            SemanticObservationScopeV0::for_reachable_class_names(&reachable_class_names),
2803        );
2804
2805        assert!(!decision.preserved);
2806    }
2807
2808    #[test]
2809    fn observation_projects_keyframe_tree_shake_to_reachable_rules() {
2810        let reachable_class_names = vec!["btn".to_string()];
2811        let input = lower_transform_ir_from_source(
2812            "@keyframes used { to { opacity: 1; } }\n@keyframes dead { to { opacity: 0; } }\n.btn { animation: used 1s; }\n",
2813            StyleDialect::Css,
2814            "test",
2815        );
2816        let output = lower_transform_ir_from_source(
2817            "@keyframes used { to { opacity: 1; } }\n.btn { animation: used 1s; }\n",
2818            StyleDialect::Css,
2819            "test",
2820        );
2821        let projection = SemanticObservationProjectionV0::for_keyframe_reachability(
2822            &input,
2823            &[],
2824            &reachable_class_names,
2825        );
2826        let decision = compare_semantic_observation_for_pass_with_scopes(
2827            "tree-shake-keyframes",
2828            &input,
2829            &output,
2830            SemanticObservationScopeV0::for_ignored_source_ranges(
2831                projection.ignored_source_ranges(),
2832            ),
2833            SemanticObservationScopeV0::default(),
2834        );
2835
2836        assert!(decision.preserved);
2837        assert_eq!(decision.mismatch_count, 0);
2838    }
2839
2840    #[test]
2841    fn observation_rejects_reachable_keyframe_tree_shake_changes() {
2842        let reachable_class_names = vec!["btn".to_string()];
2843        let input = lower_transform_ir_from_source(
2844            "@keyframes used { to { opacity: 1; } }\n@keyframes dead { to { opacity: 0; } }\n.btn { animation: used 1s; }\n",
2845            StyleDialect::Css,
2846            "test",
2847        );
2848        let output = lower_transform_ir_from_source(
2849            "@keyframes used { to { opacity: 0; } }\n.btn { animation: used 1s; }\n",
2850            StyleDialect::Css,
2851            "test",
2852        );
2853        let projection = SemanticObservationProjectionV0::for_keyframe_reachability(
2854            &input,
2855            &[],
2856            &reachable_class_names,
2857        );
2858        let decision = compare_semantic_observation_for_pass_with_scopes(
2859            "tree-shake-keyframes",
2860            &input,
2861            &output,
2862            SemanticObservationScopeV0::for_ignored_source_ranges(
2863                projection.ignored_source_ranges(),
2864            ),
2865            SemanticObservationScopeV0::default(),
2866        );
2867
2868        assert!(!decision.preserved);
2869    }
2870
2871    #[test]
2872    fn observation_projects_value_tree_shake_to_reachable_values() {
2873        let reachable_class_names = vec!["btn".to_string()];
2874        let input = lower_transform_ir_from_source(
2875            "@value used: red;\n@value dead: blue;\n.btn { color: used; }\n",
2876            StyleDialect::Css,
2877            "test",
2878        );
2879        let output = lower_transform_ir_from_source(
2880            "@value used: red;\n.btn { color: used; }\n",
2881            StyleDialect::Css,
2882            "test",
2883        );
2884        let projection = SemanticObservationProjectionV0::for_value_reachability(
2885            &input,
2886            StyleDialect::Css,
2887            &[],
2888            &[],
2889            &reachable_class_names,
2890        );
2891        let decision = compare_semantic_observation_for_pass_with_scopes(
2892            "tree-shake-value",
2893            &input,
2894            &output,
2895            SemanticObservationScopeV0::for_reachable_class_names_and_ignored_source_ranges(
2896                &reachable_class_names,
2897                projection.ignored_source_ranges(),
2898            ),
2899            SemanticObservationScopeV0::for_reachable_class_names(&reachable_class_names),
2900        );
2901
2902        assert!(decision.preserved);
2903        assert_eq!(decision.mismatch_count, 0);
2904    }
2905
2906    #[test]
2907    fn observation_rejects_reachable_value_tree_shake_changes() {
2908        let reachable_class_names = vec!["btn".to_string()];
2909        let input = lower_transform_ir_from_source(
2910            "@value used: red;\n@value dead: blue;\n.btn { color: used; }\n",
2911            StyleDialect::Css,
2912            "test",
2913        );
2914        let output = lower_transform_ir_from_source(
2915            "@value used: blue;\n.btn { color: used; }\n",
2916            StyleDialect::Css,
2917            "test",
2918        );
2919        let projection = SemanticObservationProjectionV0::for_value_reachability(
2920            &input,
2921            StyleDialect::Css,
2922            &[],
2923            &[],
2924            &reachable_class_names,
2925        );
2926        let decision = compare_semantic_observation_for_pass_with_scopes(
2927            "tree-shake-value",
2928            &input,
2929            &output,
2930            SemanticObservationScopeV0::for_reachable_class_names_and_ignored_source_ranges(
2931                &reachable_class_names,
2932                projection.ignored_source_ranges(),
2933            ),
2934            SemanticObservationScopeV0::for_reachable_class_names(&reachable_class_names),
2935        );
2936
2937        assert!(!decision.preserved);
2938    }
2939
2940    #[test]
2941    fn observation_projects_custom_property_tree_shake_to_reachable_roots() {
2942        let reachable_class_names = vec!["btn".to_string()];
2943        let input = lower_transform_ir_from_source(
2944            "@property --used { syntax: \"<color>\"; inherits: false; initial-value: red; }\n@property --dead { syntax: \"<color>\"; inherits: false; initial-value: blue; }\n:root { --used: red; --dead: blue; }\n.btn { color: var(--used); }\n",
2945            StyleDialect::Css,
2946            "test",
2947        );
2948        let output = lower_transform_ir_from_source(
2949            "@property --used { syntax: \"<color>\"; inherits: false; initial-value: red; }\n:root { --used: red; }\n.btn { color: var(--used); }\n",
2950            StyleDialect::Css,
2951            "test",
2952        );
2953        let projection = SemanticObservationProjectionV0::for_custom_property_reachability(
2954            &input,
2955            StyleDialect::Css,
2956            &[],
2957            &[],
2958            &reachable_class_names,
2959        );
2960        let decision = compare_semantic_observation_for_pass_with_scopes(
2961            "tree-shake-custom-property",
2962            &input,
2963            &output,
2964            SemanticObservationScopeV0::for_reachable_class_names_and_ignored_source_ranges(
2965                &reachable_class_names,
2966                projection.ignored_source_ranges(),
2967            ),
2968            SemanticObservationScopeV0::for_reachable_class_names(&reachable_class_names),
2969        );
2970
2971        assert!(decision.preserved);
2972        assert_eq!(decision.mismatch_count, 0);
2973    }
2974
2975    #[test]
2976    fn observation_rejects_reachable_custom_property_registration_changes() {
2977        let reachable_class_names = vec!["btn".to_string()];
2978        let input = lower_transform_ir_from_source(
2979            "@property --used { syntax: \"<color>\"; inherits: false; initial-value: red; }\n@property --dead { syntax: \"<color>\"; inherits: false; initial-value: blue; }\n:root { --used: red; --dead: blue; }\n.btn { color: var(--used); }\n",
2980            StyleDialect::Css,
2981            "test",
2982        );
2983        let output = lower_transform_ir_from_source(
2984            "@property --used { syntax: \"<color>\"; inherits: false; initial-value: blue; }\n:root { --used: red; }\n.btn { color: var(--used); }\n",
2985            StyleDialect::Css,
2986            "test",
2987        );
2988        let projection = SemanticObservationProjectionV0::for_custom_property_reachability(
2989            &input,
2990            StyleDialect::Css,
2991            &[],
2992            &[],
2993            &reachable_class_names,
2994        );
2995        let decision = compare_semantic_observation_for_pass_with_scopes(
2996            "tree-shake-custom-property",
2997            &input,
2998            &output,
2999            SemanticObservationScopeV0::for_reachable_class_names_and_ignored_source_ranges(
3000                &reachable_class_names,
3001                projection.ignored_source_ranges(),
3002            ),
3003            SemanticObservationScopeV0::for_reachable_class_names(&reachable_class_names),
3004        );
3005
3006        assert!(!decision.preserved);
3007    }
3008
3009    #[test]
3010    fn observation_expands_selector_lists_for_selector_merging() {
3011        let input = lower_transform_ir_from_source(
3012            ".a { color: red; }\n.b { color: red; }\n:is(.c, .d) { color: blue; }\n",
3013            StyleDialect::Css,
3014            "test",
3015        );
3016        let output = lower_transform_ir_from_source(
3017            ".a, .b { color: red; }\n:is(.c, .d) { color: blue; }\n",
3018            StyleDialect::Css,
3019            "test",
3020        );
3021        let decision = compare_semantic_observation_for_pass("selector-merging", &input, &output);
3022
3023        assert!(decision.preserved);
3024        assert_eq!(decision.mismatch_count, 0);
3025        assert_eq!(decision.input_entry_count, 3);
3026        assert_eq!(decision.output_entry_count, 3);
3027    }
3028
3029    #[test]
3030    fn observation_preserves_rule_merging_declaration_union() {
3031        let input = lower_transform_ir_from_source(
3032            ".a { color: red; }\n.a { background: blue; }\n",
3033            StyleDialect::Css,
3034            "test",
3035        );
3036        let output = lower_transform_ir_from_source(
3037            ".a { color: red; background: blue; }\n",
3038            StyleDialect::Css,
3039            "test",
3040        );
3041        let decision = compare_semantic_observation_for_pass("rule-merging", &input, &output);
3042
3043        assert!(decision.preserved);
3044        assert_eq!(decision.mismatch_count, 0);
3045        assert_eq!(decision.input_entry_count, 2);
3046        assert_eq!(decision.output_entry_count, 2);
3047    }
3048
3049    #[test]
3050    fn external_css_diff_classifies_known_prefixes_and_preserves_unknown_changes() {
3051        let input = ".input { appearance: none; } ::placeholder { color: gray; }";
3052        let output = ".input { -webkit-appearance: none; appearance: none; } ::-moz-placeholder { color: gray; } ::placeholder { color: gray; }";
3053        let report = compare_external_css_semantic_changes_v0(input, output, StyleDialect::Css);
3054
3055        assert!(report.understood_change_count >= 1);
3056        assert!(report.passthrough_change_count >= 1);
3057        assert_eq!(
3058            report.understood_change_count + report.passthrough_change_count,
3059            report.total_change_count
3060        );
3061        assert!(report.changes.iter().any(|change| {
3062            change.classification == ExternalCssSemanticChangeClassificationV0::Understood
3063                && change
3064                    .after
3065                    .as_ref()
3066                    .is_some_and(|entry| entry.property.as_str() == "-webkit-appearance")
3067        }));
3068        assert!(report.changes.iter().any(|change| {
3069            change.classification == ExternalCssSemanticChangeClassificationV0::Passthrough
3070                && change
3071                    .after
3072                    .as_ref()
3073                    .is_some_and(|entry| entry.selector.contains("::-moz-placeholder"))
3074        }));
3075    }
3076
3077    #[test]
3078    fn external_css_cst_coverage_is_whitespace_modulo_but_selector_semantics_are_not() {
3079        let report = compare_external_css_semantic_changes_v0(
3080            ".a .b { color: red; }",
3081            ".a.b { color: red; }",
3082            StyleDialect::Css,
3083        );
3084
3085        assert!(report.cst_coverage.complete);
3086        assert_eq!(report.cst_coverage.uncovered_input_unit_count, 0);
3087        assert_eq!(report.cst_coverage.uncovered_output_unit_count, 0);
3088        assert_eq!(report.understood_change_count, 0);
3089        assert!(report.passthrough_change_count > 0);
3090        assert_eq!(report.passthrough_change_count, report.total_change_count);
3091    }
3092
3093    #[test]
3094    fn external_css_diff_totality_rejects_an_unreported_change() {
3095        let mut report = compare_external_css_semantic_changes_v0(
3096            ".input { appearance: none; }",
3097            ".input { -webkit-appearance: none; appearance: none; }",
3098            StyleDialect::Css,
3099        );
3100        assert!(external_css_adoption_boundary_is_complete_v0(&report));
3101
3102        report.changes.clear();
3103        assert!(!external_css_adoption_boundary_is_complete_v0(&report));
3104    }
3105
3106    #[test]
3107    fn external_css_diff_does_not_understand_a_prefix_with_different_semantics() {
3108        let report = compare_external_css_semantic_changes_v0(
3109            ".input { appearance: none; }",
3110            ".input { -webkit-appearance: auto; appearance: none; }",
3111            StyleDialect::Css,
3112        );
3113
3114        assert_eq!(report.understood_change_count, 0);
3115        assert_eq!(report.passthrough_change_count, 1);
3116    }
3117
3118    #[test]
3119    fn semantic_preservation_broken_translation_corpus_rejects_known_bad_outputs()
3120    -> Result<(), serde_json::Error> {
3121        let report = summarize_semantic_preservation_kill_rate_for_fixture_source(
3122            include_str!("../../fixtures/semantic-preservation/broken-simple.json"),
3123            StyleDialect::Css,
3124        )?;
3125
3126        assert!(report.non_empty_corpus);
3127        assert_eq!(report.fixture_count, 2);
3128        assert_eq!(report.required_rejected_count, 2);
3129        assert_eq!(report.rejected_count, 2);
3130        assert!(report.kill_rate_passed);
3131        Ok(())
3132    }
3133
3134    #[test]
3135    fn semantic_preservation_broken_merge_corpus_rejects_known_bad_outputs()
3136    -> Result<(), serde_json::Error> {
3137        let report = summarize_semantic_preservation_kill_rate_for_fixture_source(
3138            include_str!("../../fixtures/semantic-preservation/broken-merge.json"),
3139            StyleDialect::Css,
3140        )?;
3141
3142        assert!(report.non_empty_corpus);
3143        assert_eq!(report.fixture_count, 2);
3144        assert_eq!(report.required_rejected_count, 2);
3145        assert_eq!(report.rejected_count, 2);
3146        assert!(report.kill_rate_passed);
3147        Ok(())
3148    }
3149
3150    #[test]
3151    fn semantic_preservation_broken_shake_corpus_rejects_known_bad_outputs()
3152    -> Result<(), serde_json::Error> {
3153        let report = summarize_semantic_preservation_kill_rate_for_fixture_source(
3154            include_str!("../../fixtures/semantic-preservation/broken-shake.json"),
3155            StyleDialect::Css,
3156        )?;
3157
3158        assert!(report.non_empty_corpus);
3159        assert_eq!(report.fixture_count, 8);
3160        assert_eq!(report.required_rejected_count, 8);
3161        assert_eq!(report.rejected_count, 8);
3162        assert!(report.kill_rate_passed);
3163        Ok(())
3164    }
3165
3166    #[test]
3167    fn semantic_preservation_broken_flatten_corpus_rejects_known_bad_outputs()
3168    -> Result<(), serde_json::Error> {
3169        let report = summarize_semantic_preservation_kill_rate_for_fixture_source(
3170            include_str!("../../fixtures/semantic-preservation/broken-flatten.json"),
3171            StyleDialect::Css,
3172        )?;
3173
3174        assert!(report.non_empty_corpus);
3175        assert_eq!(report.fixture_count, 3);
3176        assert_eq!(report.required_rejected_count, 3);
3177        assert_eq!(report.rejected_count, 3);
3178        assert!(report.kill_rate_passed);
3179        Ok(())
3180    }
3181
3182    #[test]
3183    fn semantic_preservation_model_conformance_report_matches_committed_artifact()
3184    -> Result<(), serde_json::Error> {
3185        let actual = summarize_semantic_preservation_model_conformance()?;
3186        let expected = serde_json::from_str::<TransformSemanticModelConformanceReportV0>(
3187            include_str!("../../fixtures/semantic-preservation/model-conformance.json"),
3188        )?;
3189
3190        assert_eq!(actual, expected);
3191        assert!(actual.model_conformance_passed);
3192        assert_eq!(actual.cascade_seed_failed_count, 0);
3193        assert_eq!(actual.ordering_axis_self_check_failed_count, 0);
3194        assert_eq!(actual.semantic_observation_failed_count, 0);
3195        Ok(())
3196    }
3197}