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