1#[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}