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