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