Skip to main content

omena_transform_cst/
lib.rs

1//! Transform CST contract substrate for the post-v5 omena-css track.
2//!
3//! This crate intentionally starts at the contract layer: transform passes are
4//! only valid when they declare which semantic/cascade facts they read and what
5//! cascade-safety obligation they must preserve.
6
7pub use omena_parser::StyleDialect;
8use omena_parser::{
9    ParsedAnimationFactKind, ParsedCssModuleComposesFactKind, ParsedCssModuleValueFactKind,
10    ParsedIcssFactKind, ParsedSassSymbolFactKind, ParsedSelectorFactKind, ParsedVariableFactKind,
11    collect_style_facts,
12};
13use serde::Serialize;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
16#[serde(rename_all = "camelCase")]
17pub enum TransformLayer {
18    SemanticReadOnly,
19    SemanticAware,
20    Commodity,
21    Emission,
22}
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
25#[serde(rename_all = "camelCase")]
26pub enum TransformPassKind {
27    WhitespaceStrip,
28    CommentStrip,
29    NumberCompression,
30    UnitNormalization,
31    ColorCompression,
32    UrlQuoteStrip,
33    StringQuoteNormalize,
34    SelectorIsWhereCompression,
35    ShorthandCombining,
36    RuleDeduplication,
37    RuleMerging,
38    SelectorMerging,
39    EmptyRuleRemoval,
40    VendorPrefixing,
41    LightDarkLowering,
42    ColorMixLowering,
43    OklchOklabLowering,
44    ColorFunctionLowering,
45    LogicalToPhysical,
46    NestingUnwrap,
47    ScopeFlatten,
48    LayerFlatten,
49    SupportsStaticEval,
50    MediaStaticEval,
51    CalcReduction,
52    ImportInline,
53    ScssModuleEvaluate,
54    LessModuleEvaluate,
55    HashCssModuleClassNames,
56    ResolveCssModulesComposes,
57    ValueResolution,
58    StaticVarSubstitution,
59    TreeShakeClass,
60    TreeShakeKeyframes,
61    TreeShakeValue,
62    TreeShakeCustomProperty,
63    DeadMediaBranchRemoval,
64    DeadSupportsBranchRemoval,
65    DesignTokenRouting,
66    PrintCss,
67}
68
69pub const TRANSFORM_PASS_CATALOG_LEN: usize = 40;
70
71pub const fn all_transform_pass_kinds() -> [TransformPassKind; TRANSFORM_PASS_CATALOG_LEN] {
72    [
73        TransformPassKind::WhitespaceStrip,
74        TransformPassKind::CommentStrip,
75        TransformPassKind::NumberCompression,
76        TransformPassKind::UnitNormalization,
77        TransformPassKind::ColorCompression,
78        TransformPassKind::UrlQuoteStrip,
79        TransformPassKind::StringQuoteNormalize,
80        TransformPassKind::SelectorIsWhereCompression,
81        TransformPassKind::ShorthandCombining,
82        TransformPassKind::RuleDeduplication,
83        TransformPassKind::RuleMerging,
84        TransformPassKind::SelectorMerging,
85        TransformPassKind::EmptyRuleRemoval,
86        TransformPassKind::VendorPrefixing,
87        TransformPassKind::LightDarkLowering,
88        TransformPassKind::ColorMixLowering,
89        TransformPassKind::OklchOklabLowering,
90        TransformPassKind::ColorFunctionLowering,
91        TransformPassKind::LogicalToPhysical,
92        TransformPassKind::NestingUnwrap,
93        TransformPassKind::ScopeFlatten,
94        TransformPassKind::LayerFlatten,
95        TransformPassKind::SupportsStaticEval,
96        TransformPassKind::MediaStaticEval,
97        TransformPassKind::CalcReduction,
98        TransformPassKind::ImportInline,
99        TransformPassKind::ScssModuleEvaluate,
100        TransformPassKind::LessModuleEvaluate,
101        TransformPassKind::HashCssModuleClassNames,
102        TransformPassKind::ResolveCssModulesComposes,
103        TransformPassKind::ValueResolution,
104        TransformPassKind::StaticVarSubstitution,
105        TransformPassKind::TreeShakeClass,
106        TransformPassKind::TreeShakeKeyframes,
107        TransformPassKind::TreeShakeValue,
108        TransformPassKind::TreeShakeCustomProperty,
109        TransformPassKind::DeadMediaBranchRemoval,
110        TransformPassKind::DeadSupportsBranchRemoval,
111        TransformPassKind::DesignTokenRouting,
112        TransformPassKind::PrintCss,
113    ]
114}
115
116impl TransformPassKind {
117    pub const fn ordinal(self) -> u8 {
118        match self {
119            Self::WhitespaceStrip => 1,
120            Self::CommentStrip => 2,
121            Self::NumberCompression => 3,
122            Self::UnitNormalization => 4,
123            Self::ColorCompression => 5,
124            Self::UrlQuoteStrip => 6,
125            Self::StringQuoteNormalize => 7,
126            Self::SelectorIsWhereCompression => 8,
127            Self::ShorthandCombining => 9,
128            Self::RuleDeduplication => 10,
129            Self::RuleMerging => 11,
130            Self::SelectorMerging => 12,
131            Self::EmptyRuleRemoval => 13,
132            Self::VendorPrefixing => 14,
133            Self::LightDarkLowering => 15,
134            Self::ColorMixLowering => 16,
135            Self::OklchOklabLowering => 17,
136            Self::ColorFunctionLowering => 18,
137            Self::LogicalToPhysical => 19,
138            Self::NestingUnwrap => 20,
139            Self::ScopeFlatten => 21,
140            Self::LayerFlatten => 22,
141            Self::SupportsStaticEval => 23,
142            Self::MediaStaticEval => 24,
143            Self::CalcReduction => 25,
144            Self::ImportInline => 26,
145            Self::ScssModuleEvaluate => 27,
146            Self::LessModuleEvaluate => 28,
147            Self::HashCssModuleClassNames => 29,
148            Self::ResolveCssModulesComposes => 30,
149            Self::ValueResolution => 31,
150            Self::StaticVarSubstitution => 32,
151            Self::TreeShakeClass => 33,
152            Self::TreeShakeKeyframes => 34,
153            Self::TreeShakeValue => 35,
154            Self::TreeShakeCustomProperty => 36,
155            Self::DeadMediaBranchRemoval => 37,
156            Self::DeadSupportsBranchRemoval => 38,
157            Self::DesignTokenRouting => 39,
158            Self::PrintCss => 40,
159        }
160    }
161
162    pub const fn label(self) -> &'static str {
163        self.id()
164    }
165
166    pub const fn title(self) -> &'static str {
167        match self {
168            Self::WhitespaceStrip => "whitespace strip",
169            Self::CommentStrip => "comment strip",
170            Self::NumberCompression => "number compression",
171            Self::UnitNormalization => "unit normalization",
172            Self::ColorCompression => "color compression",
173            Self::UrlQuoteStrip => "url quote strip",
174            Self::StringQuoteNormalize => "string and font value normalize",
175            Self::SelectorIsWhereCompression => "selector alias compression",
176            Self::ShorthandCombining => "shorthand combining",
177            Self::RuleDeduplication => "rule deduplication",
178            Self::RuleMerging => "rule merging",
179            Self::SelectorMerging => "selector merging",
180            Self::EmptyRuleRemoval => "empty rule removal",
181            Self::VendorPrefixing => "vendor prefixing",
182            Self::LightDarkLowering => "light-dark lowering",
183            Self::ColorMixLowering => "color-mix lowering",
184            Self::OklchOklabLowering => "oklch/oklab lowering",
185            Self::ColorFunctionLowering => "color() lowering",
186            Self::LogicalToPhysical => "logical to physical",
187            Self::NestingUnwrap => "nesting unwrap",
188            Self::ScopeFlatten => "@scope flatten",
189            Self::LayerFlatten => "@layer flatten",
190            Self::SupportsStaticEval => "@supports static eval",
191            Self::MediaStaticEval => "@media static eval",
192            Self::CalcReduction => "calc() reduction",
193            Self::ImportInline => "@import inline",
194            Self::ScssModuleEvaluate => "SCSS module evaluate",
195            Self::LessModuleEvaluate => "Less module evaluate",
196            Self::HashCssModuleClassNames => "CSS Modules class hashing",
197            Self::ResolveCssModulesComposes => "composes resolution",
198            Self::ValueResolution => "@value resolution",
199            Self::StaticVarSubstitution => "custom property static resolve",
200            Self::TreeShakeClass => "tree shaking class",
201            Self::TreeShakeKeyframes => "tree shaking keyframes",
202            Self::TreeShakeValue => "tree shaking value",
203            Self::TreeShakeCustomProperty => "tree shaking custom-property",
204            Self::DeadMediaBranchRemoval => "dead @media branch removal",
205            Self::DeadSupportsBranchRemoval => "dead @supports branch removal",
206            Self::DesignTokenRouting => "design-token routing",
207            Self::PrintCss => "printer + sourcemap composer",
208        }
209    }
210
211    pub const fn id(self) -> &'static str {
212        match self {
213            Self::WhitespaceStrip => "whitespace-strip",
214            Self::CommentStrip => "comment-strip",
215            Self::NumberCompression => "number-compression",
216            Self::UnitNormalization => "unit-normalization",
217            Self::ColorCompression => "color-compression",
218            Self::UrlQuoteStrip => "url-quote-strip",
219            Self::StringQuoteNormalize => "string-quote-normalize",
220            Self::SelectorIsWhereCompression => "selector-is-where-compression",
221            Self::ShorthandCombining => "shorthand-combining",
222            Self::RuleDeduplication => "rule-deduplication",
223            Self::RuleMerging => "rule-merging",
224            Self::SelectorMerging => "selector-merging",
225            Self::EmptyRuleRemoval => "empty-rule-removal",
226            Self::VendorPrefixing => "vendor-prefixing",
227            Self::LightDarkLowering => "light-dark-lowering",
228            Self::ColorMixLowering => "color-mix-lowering",
229            Self::OklchOklabLowering => "oklch-oklab-lowering",
230            Self::ColorFunctionLowering => "color-function-lowering",
231            Self::LogicalToPhysical => "logical-to-physical",
232            Self::NestingUnwrap => "nesting-unwrap",
233            Self::ScopeFlatten => "scope-flatten",
234            Self::LayerFlatten => "layer-flatten",
235            Self::SupportsStaticEval => "supports-static-eval",
236            Self::MediaStaticEval => "media-static-eval",
237            Self::CalcReduction => "calc-reduction",
238            Self::ImportInline => "import-inline",
239            Self::ScssModuleEvaluate => "scss-module-evaluate",
240            Self::LessModuleEvaluate => "less-module-evaluate",
241            Self::HashCssModuleClassNames => "css-modules-class-hashing",
242            Self::ResolveCssModulesComposes => "composes-resolution",
243            Self::ValueResolution => "value-resolution",
244            Self::StaticVarSubstitution => "custom-property-static-resolve",
245            Self::TreeShakeClass => "tree-shake-class",
246            Self::TreeShakeKeyframes => "tree-shake-keyframes",
247            Self::TreeShakeValue => "tree-shake-value",
248            Self::TreeShakeCustomProperty => "tree-shake-custom-property",
249            Self::DeadMediaBranchRemoval => "dead-media-branch-removal",
250            Self::DeadSupportsBranchRemoval => "dead-supports-branch-removal",
251            Self::DesignTokenRouting => "design-token-routing",
252            Self::PrintCss => "print-css",
253        }
254    }
255
256    pub const fn layer(self) -> TransformLayer {
257        match self {
258            Self::ImportInline
259            | Self::ScssModuleEvaluate
260            | Self::LessModuleEvaluate
261            | Self::HashCssModuleClassNames
262            | Self::ResolveCssModulesComposes
263            | Self::ValueResolution
264            | Self::StaticVarSubstitution
265            | Self::TreeShakeClass
266            | Self::TreeShakeKeyframes
267            | Self::TreeShakeValue
268            | Self::TreeShakeCustomProperty
269            | Self::DeadMediaBranchRemoval
270            | Self::DeadSupportsBranchRemoval
271            | Self::DesignTokenRouting => TransformLayer::SemanticAware,
272            Self::PrintCss => TransformLayer::Emission,
273            _ => TransformLayer::Commodity,
274        }
275    }
276
277    pub const fn reads_semantic_graph(self) -> bool {
278        matches!(
279            self,
280            Self::ImportInline
281                | Self::ScssModuleEvaluate
282                | Self::LessModuleEvaluate
283                | Self::HashCssModuleClassNames
284                | Self::ResolveCssModulesComposes
285                | Self::ValueResolution
286                | Self::StaticVarSubstitution
287                | Self::TreeShakeClass
288                | Self::TreeShakeKeyframes
289                | Self::TreeShakeValue
290                | Self::TreeShakeCustomProperty
291                | Self::DeadMediaBranchRemoval
292                | Self::DeadSupportsBranchRemoval
293                | Self::DesignTokenRouting
294        )
295    }
296
297    pub const fn reads_cascade_model(self) -> bool {
298        matches!(
299            self,
300            Self::ShorthandCombining
301                | Self::RuleDeduplication
302                | Self::RuleMerging
303                | Self::SelectorMerging
304                | Self::ScopeFlatten
305                | Self::LayerFlatten
306                | Self::StaticVarSubstitution
307                | Self::DeadMediaBranchRemoval
308                | Self::DeadSupportsBranchRemoval
309        )
310    }
311
312    pub const fn read_model(self) -> TransformPassReadModel {
313        match self {
314            Self::VendorPrefixing
315            | Self::LightDarkLowering
316            | Self::ColorMixLowering
317            | Self::OklchOklabLowering
318            | Self::ColorFunctionLowering
319            | Self::LogicalToPhysical
320            | Self::NestingUnwrap => TransformPassReadModel::TargetData,
321            Self::ShorthandCombining
322            | Self::RuleDeduplication
323            | Self::RuleMerging
324            | Self::SelectorMerging
325            | Self::ScopeFlatten
326            | Self::LayerFlatten
327            | Self::StaticVarSubstitution
328            | Self::DeadMediaBranchRemoval
329            | Self::DeadSupportsBranchRemoval => TransformPassReadModel::CascadeModel,
330            Self::TreeShakeClass
331            | Self::TreeShakeKeyframes
332            | Self::TreeShakeValue
333            | Self::TreeShakeCustomProperty
334            | Self::DesignTokenRouting => TransformPassReadModel::BridgeReachability,
335            Self::ImportInline
336            | Self::ScssModuleEvaluate
337            | Self::LessModuleEvaluate
338            | Self::HashCssModuleClassNames
339            | Self::ResolveCssModulesComposes
340            | Self::ValueResolution => TransformPassReadModel::SemanticGraph,
341            Self::PrintCss => TransformPassReadModel::Emission,
342            _ => TransformPassReadModel::SyntaxOnly,
343        }
344    }
345}
346
347#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
348#[serde(rename_all = "camelCase")]
349pub enum TransformPassReadModel {
350    SyntaxOnly,
351    TargetData,
352    CascadeModel,
353    SemanticGraph,
354    BridgeReachability,
355    Emission,
356}
357
358#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
359#[serde(rename_all = "camelCase")]
360pub struct TransformPassContractV0 {
361    pub ordinal: u8,
362    pub label: &'static str,
363    pub id: &'static str,
364    pub title: &'static str,
365    pub kind: TransformPassKind,
366    pub layer: TransformLayer,
367    pub read_model: TransformPassReadModel,
368    pub reads_semantic_graph: bool,
369    pub reads_cascade_model: bool,
370    pub writes_css: bool,
371    pub cascade_safe: bool,
372    pub cascade_safety_witness: CascadeSafetyWitnessV0,
373    pub cascade_safe_obligation: &'static str,
374}
375
376#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
377#[serde(rename_all = "camelCase")]
378pub struct CascadeSafetyWitnessV0 {
379    pub pass_id: &'static str,
380    pub obligation: &'static str,
381    pub enforced_at: &'static str,
382}
383
384#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
385#[serde(rename_all = "camelCase")]
386pub struct TransformDagEdgeV0 {
387    pub from: &'static str,
388    pub to: &'static str,
389    pub reason: &'static str,
390}
391
392#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
393#[serde(rename_all = "camelCase")]
394pub struct TransformCstBoundarySummaryV0 {
395    pub schema_version: &'static str,
396    pub product: &'static str,
397    pub representation: &'static str,
398    pub pass_contracts: Vec<TransformPassContractV0>,
399    pub dag_edges: Vec<TransformDagEdgeV0>,
400    pub pass_catalog_count: usize,
401    pub semantic_aware_pass_count: usize,
402    pub commodity_pass_count: usize,
403    pub emission_pass_count: usize,
404    pub full_pass_catalog_covered: bool,
405    pub all_passes_declare_cascade_obligation: bool,
406    pub all_passes_have_compile_time_cascade_witness: bool,
407    pub stable_transform_ir_ready: bool,
408    pub provenance_derivation_forest_scaffold_ready: bool,
409    pub provenance_preservation_required: bool,
410    pub next_surfaces: Vec<&'static str>,
411}
412
413#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
414#[serde(rename_all = "camelCase")]
415pub enum StableTransformIrNodeKindV0 {
416    ClassSelector,
417    IdSelector,
418    PlaceholderSelector,
419    CustomPropertyDeclaration,
420    CustomPropertyReference,
421    ScssVariableDeclaration,
422    ScssVariableReference,
423    LessVariableDeclaration,
424    LessVariableReference,
425    SassSymbolDeclaration,
426    SassSymbolReference,
427    SassModuleEdge,
428    KeyframesDeclaration,
429    AnimationNameReference,
430    CssModuleValueDefinition,
431    CssModuleValueReference,
432    CssModuleValueImportSource,
433    CssModuleComposesTarget,
434    CssModuleComposesImportSource,
435    IcssExportName,
436    IcssImportLocalName,
437    IcssImportRemoteName,
438    IcssImportSource,
439    AtRule,
440}
441
442impl StableTransformIrNodeKindV0 {
443    pub const fn id(self) -> &'static str {
444        match self {
445            Self::ClassSelector => "class-selector",
446            Self::IdSelector => "id-selector",
447            Self::PlaceholderSelector => "placeholder-selector",
448            Self::CustomPropertyDeclaration => "custom-property-declaration",
449            Self::CustomPropertyReference => "custom-property-reference",
450            Self::ScssVariableDeclaration => "scss-variable-declaration",
451            Self::ScssVariableReference => "scss-variable-reference",
452            Self::LessVariableDeclaration => "less-variable-declaration",
453            Self::LessVariableReference => "less-variable-reference",
454            Self::SassSymbolDeclaration => "sass-symbol-declaration",
455            Self::SassSymbolReference => "sass-symbol-reference",
456            Self::SassModuleEdge => "sass-module-edge",
457            Self::KeyframesDeclaration => "keyframes-declaration",
458            Self::AnimationNameReference => "animation-name-reference",
459            Self::CssModuleValueDefinition => "css-module-value-definition",
460            Self::CssModuleValueReference => "css-module-value-reference",
461            Self::CssModuleValueImportSource => "css-module-value-import-source",
462            Self::CssModuleComposesTarget => "css-module-composes-target",
463            Self::CssModuleComposesImportSource => "css-module-composes-import-source",
464            Self::IcssExportName => "icss-export-name",
465            Self::IcssImportLocalName => "icss-import-local-name",
466            Self::IcssImportRemoteName => "icss-import-remote-name",
467            Self::IcssImportSource => "icss-import-source",
468            Self::AtRule => "at-rule",
469        }
470    }
471}
472
473#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
474#[serde(rename_all = "camelCase")]
475pub struct StableTransformIrNodeV0 {
476    pub node_id: String,
477    pub kind: StableTransformIrNodeKindV0,
478    pub kind_id: &'static str,
479    pub label: String,
480    pub semantic_key: String,
481    pub source_span_start: usize,
482    pub source_span_end: usize,
483    pub provenance_anchor_index: usize,
484}
485
486#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
487#[serde(rename_all = "camelCase")]
488pub struct TransformCstProvenanceAnchorV0 {
489    pub anchor_index: usize,
490    pub node_id: String,
491    pub semantic_key: String,
492    pub source_span_start: usize,
493    pub source_span_end: usize,
494}
495
496#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
497#[serde(rename_all = "camelCase")]
498pub struct StableTransformIrV0 {
499    pub schema_version: &'static str,
500    pub product: &'static str,
501    pub dialect: &'static str,
502    pub source_byte_len: usize,
503    pub semantic_signature: String,
504    pub node_count: usize,
505    pub parser_error_count: usize,
506    pub contains_bogus_or_trivia: bool,
507    pub stable_post_semantic_ir: bool,
508    pub nodes: Vec<StableTransformIrNodeV0>,
509    pub provenance_anchors: Vec<TransformCstProvenanceAnchorV0>,
510}
511
512#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
513#[serde(rename_all = "camelCase")]
514pub struct TransformCstArtifactV0 {
515    pub schema_version: &'static str,
516    pub product: &'static str,
517    pub source_byte_len: usize,
518    pub semantic_signature: String,
519    pub stable_ir: StableTransformIrV0,
520    pub stable_ir_node_count: usize,
521    pub parser_error_count: usize,
522    pub contains_bogus_or_trivia: bool,
523    pub pass_ids: Vec<&'static str>,
524    pub provenance_preserved: bool,
525}
526
527pub fn summarize_omena_transform_cst_boundary() -> TransformCstBoundarySummaryV0 {
528    let pass_contracts = default_transform_pass_contracts();
529    let semantic_aware_pass_count = pass_contracts
530        .iter()
531        .filter(|contract| contract.layer == TransformLayer::SemanticAware)
532        .count();
533    let commodity_pass_count = pass_contracts
534        .iter()
535        .filter(|contract| contract.layer == TransformLayer::Commodity)
536        .count();
537    let emission_pass_count = pass_contracts
538        .iter()
539        .filter(|contract| contract.layer == TransformLayer::Emission)
540        .count();
541    let all_passes_declare_cascade_obligation = pass_contracts
542        .iter()
543        .all(|contract| !contract.cascade_safe_obligation.is_empty());
544    let all_passes_have_compile_time_cascade_witness = pass_contracts.iter().all(|contract| {
545        contract.cascade_safe
546            && contract.cascade_safety_witness.pass_id == contract.id
547            && contract.cascade_safety_witness.obligation == contract.cascade_safe_obligation
548            && contract.cascade_safety_witness.enforced_at == "compile-time-exhaustive-pass-catalog"
549    });
550    let pass_catalog_count = pass_contracts.len();
551
552    TransformCstBoundarySummaryV0 {
553        schema_version: "0",
554        product: "omena-transform-cst.boundary",
555        representation: "post-semantic-provenance-preserving-transform-cst",
556        pass_contracts,
557        dag_edges: default_transform_dag_edges(),
558        pass_catalog_count,
559        semantic_aware_pass_count,
560        commodity_pass_count,
561        emission_pass_count,
562        full_pass_catalog_covered: pass_catalog_count == TRANSFORM_PASS_CATALOG_LEN,
563        all_passes_declare_cascade_obligation,
564        all_passes_have_compile_time_cascade_witness,
565        stable_transform_ir_ready: true,
566        provenance_derivation_forest_scaffold_ready: true,
567        provenance_preservation_required: true,
568        next_surfaces: Vec::new(),
569    }
570}
571
572pub fn build_transform_cst_artifact(
573    source: &str,
574    semantic_signature: impl Into<String>,
575    passes: &[TransformPassKind],
576) -> TransformCstArtifactV0 {
577    build_transform_cst_artifact_with_dialect(source, StyleDialect::Css, semantic_signature, passes)
578}
579
580pub fn build_transform_cst_artifact_with_dialect(
581    source: &str,
582    dialect: StyleDialect,
583    semantic_signature: impl Into<String>,
584    passes: &[TransformPassKind],
585) -> TransformCstArtifactV0 {
586    let semantic_signature = semantic_signature.into();
587    let stable_ir =
588        build_stable_transform_ir_from_source(source, dialect, semantic_signature.clone());
589    let stable_ir_node_count = stable_ir.node_count;
590    let parser_error_count = stable_ir.parser_error_count;
591    let contains_bogus_or_trivia = stable_ir.contains_bogus_or_trivia;
592
593    TransformCstArtifactV0 {
594        schema_version: "0",
595        product: "omena-transform-cst.artifact",
596        source_byte_len: source.len(),
597        semantic_signature,
598        stable_ir,
599        stable_ir_node_count,
600        parser_error_count,
601        contains_bogus_or_trivia,
602        pass_ids: passes.iter().map(|pass| pass.id()).collect(),
603        provenance_preserved: true,
604    }
605}
606
607pub fn build_stable_transform_ir_from_source(
608    source: &str,
609    dialect: StyleDialect,
610    semantic_signature: impl Into<String>,
611) -> StableTransformIrV0 {
612    let facts = collect_style_facts(source, dialect);
613    let mut nodes = Vec::new();
614
615    for selector in facts.selectors {
616        push_ir_node(
617            &mut nodes,
618            stable_ir_selector_kind(selector.kind),
619            selector.name,
620            selector.range.start().into(),
621            selector.range.end().into(),
622        );
623    }
624
625    for variable in facts.variables {
626        push_ir_node(
627            &mut nodes,
628            stable_ir_variable_kind(variable.kind),
629            variable.name,
630            variable.range.start().into(),
631            variable.range.end().into(),
632        );
633    }
634
635    for symbol in facts.sass_symbols {
636        push_ir_node(
637            &mut nodes,
638            stable_ir_sass_symbol_kind(symbol.kind),
639            format!("{}:{}", symbol.symbol_kind, symbol.name),
640            symbol.range.start().into(),
641            symbol.range.end().into(),
642        );
643    }
644
645    for edge in facts.sass_module_edges {
646        push_ir_node(
647            &mut nodes,
648            StableTransformIrNodeKindV0::SassModuleEdge,
649            edge.source,
650            edge.range.start().into(),
651            edge.range.end().into(),
652        );
653    }
654
655    for animation in facts.animations {
656        push_ir_node(
657            &mut nodes,
658            stable_ir_animation_kind(animation.kind),
659            animation.name,
660            animation.range.start().into(),
661            animation.range.end().into(),
662        );
663    }
664
665    for value in facts.css_module_values {
666        push_ir_node(
667            &mut nodes,
668            stable_ir_css_module_value_kind(value.kind),
669            value.name,
670            value.range.start().into(),
671            value.range.end().into(),
672        );
673    }
674
675    for composes in facts.css_module_composes {
676        push_ir_node(
677            &mut nodes,
678            stable_ir_css_module_composes_kind(composes.kind),
679            composes.name,
680            composes.range.start().into(),
681            composes.range.end().into(),
682        );
683    }
684
685    for icss in facts.icss {
686        push_ir_node(
687            &mut nodes,
688            stable_ir_icss_kind(icss.kind),
689            icss.name,
690            icss.range.start().into(),
691            icss.range.end().into(),
692        );
693    }
694
695    for at_rule in facts.at_rules {
696        push_ir_node(
697            &mut nodes,
698            StableTransformIrNodeKindV0::AtRule,
699            at_rule.name,
700            at_rule.range.start().into(),
701            at_rule.range.end().into(),
702        );
703    }
704
705    nodes.sort_by(|left, right| {
706        left.source_span_start
707            .cmp(&right.source_span_start)
708            .then_with(|| left.source_span_end.cmp(&right.source_span_end))
709            .then_with(|| left.kind.cmp(&right.kind))
710            .then_with(|| left.label.cmp(&right.label))
711    });
712
713    let mut provenance_anchors = Vec::with_capacity(nodes.len());
714    for (index, node) in nodes.iter_mut().enumerate() {
715        node.node_id = format!("ir:{index}");
716        node.provenance_anchor_index = index;
717        provenance_anchors.push(TransformCstProvenanceAnchorV0 {
718            anchor_index: index,
719            node_id: node.node_id.clone(),
720            semantic_key: node.semantic_key.clone(),
721            source_span_start: node.source_span_start,
722            source_span_end: node.source_span_end,
723        });
724    }
725
726    let node_count = nodes.len();
727    let parser_error_count = facts.error_count;
728
729    StableTransformIrV0 {
730        schema_version: "0",
731        product: "omena-transform-cst.stable-ir",
732        dialect: transform_cst_style_dialect_label(dialect),
733        source_byte_len: source.len(),
734        semantic_signature: semantic_signature.into(),
735        node_count,
736        parser_error_count,
737        contains_bogus_or_trivia: false,
738        stable_post_semantic_ir: parser_error_count == 0,
739        nodes,
740        provenance_anchors,
741    }
742}
743
744pub fn default_transform_pass_contracts() -> Vec<TransformPassContractV0> {
745    all_transform_pass_kinds()
746        .into_iter()
747        .map(transform_pass_contract)
748        .collect()
749}
750
751fn transform_pass_contract(kind: TransformPassKind) -> TransformPassContractV0 {
752    let cascade_safety_witness = cascade_safety_witness(kind);
753
754    TransformPassContractV0 {
755        ordinal: kind.ordinal(),
756        label: kind.label(),
757        id: kind.id(),
758        title: kind.title(),
759        kind,
760        layer: kind.layer(),
761        read_model: kind.read_model(),
762        reads_semantic_graph: kind.reads_semantic_graph(),
763        reads_cascade_model: kind.reads_cascade_model(),
764        writes_css: true,
765        cascade_safe: true,
766        cascade_safety_witness,
767        cascade_safe_obligation: cascade_safety_witness.obligation,
768    }
769}
770
771pub const fn cascade_safety_witness(kind: TransformPassKind) -> CascadeSafetyWitnessV0 {
772    CascadeSafetyWitnessV0 {
773        pass_id: kind.id(),
774        obligation: cascade_safe_obligation(kind),
775        enforced_at: "compile-time-exhaustive-pass-catalog",
776    }
777}
778
779pub const fn cascade_safe_obligation(kind: TransformPassKind) -> &'static str {
780    match kind {
781        TransformPassKind::WhitespaceStrip => {
782            "may remove only whitespace outside string, url, attr, and calc-sensitive token boundaries"
783        }
784        TransformPassKind::CommentStrip => {
785            "may remove comments only when source-map provenance preserves the removed span"
786        }
787        TransformPassKind::NumberCompression => {
788            "may rewrite only numerically equivalent literal tokens"
789        }
790        TransformPassKind::UnitNormalization => {
791            "may normalize only dimension values whose computed value is unchanged"
792        }
793        TransformPassKind::ColorCompression => "may rewrite only color-equivalent literal tokens",
794        TransformPassKind::UrlQuoteStrip => {
795            "may remove url quotes only when the unquoted token grammar remains equivalent"
796        }
797        TransformPassKind::StringQuoteNormalize => {
798            "may normalize string quotes and font keyword aliases only when computed text and font values remain equivalent"
799        }
800        TransformPassKind::SelectorIsWhereCompression => {
801            "must preserve selector specificity, keyframe timeline positions, and matching semantics under the cascade model"
802        }
803        TransformPassKind::ShorthandCombining => {
804            "must prove longhand and shorthand cascade outcomes are equivalent"
805        }
806        TransformPassKind::RuleDeduplication => {
807            "must preserve origin, layer, specificity, and order for every surviving declaration"
808        }
809        TransformPassKind::RuleMerging => {
810            "must prove merged rule order cannot change declaration winners"
811        }
812        TransformPassKind::SelectorMerging => {
813            "must preserve selector identity and post-hash module semantics"
814        }
815        TransformPassKind::EmptyRuleRemoval => {
816            "may remove rules only when no source-visible semantic marker is attached"
817        }
818        TransformPassKind::VendorPrefixing => {
819            "must add target-required prefixed declarations without changing modern target outcomes"
820        }
821        TransformPassKind::LightDarkLowering => {
822            "must lower only when target data requires fallback branches and provenance tracks both branches"
823        }
824        TransformPassKind::ColorMixLowering => {
825            "must lower only when color-space conversion is target-equivalent"
826        }
827        TransformPassKind::OklchOklabLowering => {
828            "must preserve color semantics within the configured target fallback precision"
829        }
830        TransformPassKind::ColorFunctionLowering => {
831            "must preserve color semantics within the configured target fallback precision"
832        }
833        TransformPassKind::LogicalToPhysical => {
834            "must run only under explicit directionality options"
835        }
836        TransformPassKind::NestingUnwrap => {
837            "must preserve nested selector expansion and specificity"
838        }
839        TransformPassKind::ScopeFlatten => {
840            "must preserve scoped matching semantics or emit a blocked result"
841        }
842        TransformPassKind::LayerFlatten => "must preserve layer order in CascadeKey comparison",
843        TransformPassKind::SupportsStaticEval => {
844            "may remove branches only when the target feature predicate is known"
845        }
846        TransformPassKind::MediaStaticEval => {
847            "may remove branches only when the configured media predicate is known"
848        }
849        TransformPassKind::CalcReduction => {
850            "may reduce only syntax-equivalent or computed-value-equivalent calc expressions"
851        }
852        TransformPassKind::ImportInline => {
853            "must preserve import-site media, supports, layer wrappers, and source provenance"
854        }
855        TransformPassKind::ScssModuleEvaluate => {
856            "must preserve SCSS namespace, show/hide, mixin, variable, and source provenance facts"
857        }
858        TransformPassKind::LessModuleEvaluate => {
859            "must preserve Less variable, mixin, namespace, and source provenance facts"
860        }
861        TransformPassKind::HashCssModuleClassNames => {
862            "must rewrite every source and style reference through the same selector identity map"
863        }
864        TransformPassKind::ResolveCssModulesComposes => {
865            "must preserve exported class set and composed class provenance"
866        }
867        TransformPassKind::ValueResolution => {
868            "must preserve @value graph resolution and cycle diagnostics"
869        }
870        TransformPassKind::StaticVarSubstitution => {
871            "must preserve custom-property fixed-point semantics or emit a provenance-backed blocked result"
872        }
873        TransformPassKind::TreeShakeClass => {
874            "may remove classes only when bridge reachability proves no reachable source expression observes them"
875        }
876        TransformPassKind::TreeShakeKeyframes => {
877            "may remove keyframes only when animation-name reachability proves they are unobservable"
878        }
879        TransformPassKind::TreeShakeValue => {
880            "may remove @value declarations only when value-graph traversal proves they are unreachable"
881        }
882        TransformPassKind::TreeShakeCustomProperty => {
883            "may remove custom properties only when var() reachability proves they are unobservable"
884        }
885        TransformPassKind::DeadMediaBranchRemoval => {
886            "may remove @media branches only when target and cascade witnesses prove deadness"
887        }
888        TransformPassKind::DeadSupportsBranchRemoval => {
889            "may remove @supports branches only when target and cascade witnesses prove deadness"
890        }
891        TransformPassKind::DesignTokenRouting => {
892            "must preserve design-token provenance while routing declarations across package boundaries"
893        }
894        TransformPassKind::PrintCss => {
895            "must emit a source-map trace for every non-trivia transformed span"
896        }
897    }
898}
899
900fn push_ir_node(
901    nodes: &mut Vec<StableTransformIrNodeV0>,
902    kind: StableTransformIrNodeKindV0,
903    label: impl Into<String>,
904    source_span_start: usize,
905    source_span_end: usize,
906) {
907    let label = label.into();
908    let kind_id = kind.id();
909    nodes.push(StableTransformIrNodeV0 {
910        node_id: String::new(),
911        kind,
912        kind_id,
913        semantic_key: format!("{kind_id}:{label}"),
914        label,
915        source_span_start,
916        source_span_end,
917        provenance_anchor_index: 0,
918    });
919}
920
921const fn stable_ir_selector_kind(kind: ParsedSelectorFactKind) -> StableTransformIrNodeKindV0 {
922    match kind {
923        ParsedSelectorFactKind::Class => StableTransformIrNodeKindV0::ClassSelector,
924        ParsedSelectorFactKind::Id => StableTransformIrNodeKindV0::IdSelector,
925        ParsedSelectorFactKind::Placeholder => StableTransformIrNodeKindV0::PlaceholderSelector,
926    }
927}
928
929const fn stable_ir_variable_kind(kind: ParsedVariableFactKind) -> StableTransformIrNodeKindV0 {
930    match kind {
931        ParsedVariableFactKind::ScssDeclaration => {
932            StableTransformIrNodeKindV0::ScssVariableDeclaration
933        }
934        ParsedVariableFactKind::ScssReference => StableTransformIrNodeKindV0::ScssVariableReference,
935        ParsedVariableFactKind::LessDeclaration => {
936            StableTransformIrNodeKindV0::LessVariableDeclaration
937        }
938        ParsedVariableFactKind::LessReference => StableTransformIrNodeKindV0::LessVariableReference,
939        ParsedVariableFactKind::CustomPropertyDeclaration => {
940            StableTransformIrNodeKindV0::CustomPropertyDeclaration
941        }
942        ParsedVariableFactKind::CustomPropertyReference => {
943            StableTransformIrNodeKindV0::CustomPropertyReference
944        }
945    }
946}
947
948const fn stable_ir_sass_symbol_kind(kind: ParsedSassSymbolFactKind) -> StableTransformIrNodeKindV0 {
949    match kind {
950        ParsedSassSymbolFactKind::VariableDeclaration
951        | ParsedSassSymbolFactKind::MixinDeclaration
952        | ParsedSassSymbolFactKind::FunctionDeclaration => {
953            StableTransformIrNodeKindV0::SassSymbolDeclaration
954        }
955        ParsedSassSymbolFactKind::VariableReference
956        | ParsedSassSymbolFactKind::MixinInclude
957        | ParsedSassSymbolFactKind::FunctionCall => {
958            StableTransformIrNodeKindV0::SassSymbolReference
959        }
960    }
961}
962
963const fn stable_ir_animation_kind(kind: ParsedAnimationFactKind) -> StableTransformIrNodeKindV0 {
964    match kind {
965        ParsedAnimationFactKind::KeyframesDeclaration => {
966            StableTransformIrNodeKindV0::KeyframesDeclaration
967        }
968        ParsedAnimationFactKind::AnimationNameReference => {
969            StableTransformIrNodeKindV0::AnimationNameReference
970        }
971    }
972}
973
974const fn stable_ir_css_module_value_kind(
975    kind: ParsedCssModuleValueFactKind,
976) -> StableTransformIrNodeKindV0 {
977    match kind {
978        ParsedCssModuleValueFactKind::Definition => {
979            StableTransformIrNodeKindV0::CssModuleValueDefinition
980        }
981        ParsedCssModuleValueFactKind::Reference => {
982            StableTransformIrNodeKindV0::CssModuleValueReference
983        }
984        ParsedCssModuleValueFactKind::ImportSource => {
985            StableTransformIrNodeKindV0::CssModuleValueImportSource
986        }
987    }
988}
989
990const fn stable_ir_css_module_composes_kind(
991    kind: ParsedCssModuleComposesFactKind,
992) -> StableTransformIrNodeKindV0 {
993    match kind {
994        ParsedCssModuleComposesFactKind::Target => {
995            StableTransformIrNodeKindV0::CssModuleComposesTarget
996        }
997        ParsedCssModuleComposesFactKind::ImportSource => {
998            StableTransformIrNodeKindV0::CssModuleComposesImportSource
999        }
1000    }
1001}
1002
1003const fn stable_ir_icss_kind(kind: ParsedIcssFactKind) -> StableTransformIrNodeKindV0 {
1004    match kind {
1005        ParsedIcssFactKind::ExportName => StableTransformIrNodeKindV0::IcssExportName,
1006        ParsedIcssFactKind::ImportLocalName => StableTransformIrNodeKindV0::IcssImportLocalName,
1007        ParsedIcssFactKind::ImportRemoteName => StableTransformIrNodeKindV0::IcssImportRemoteName,
1008        ParsedIcssFactKind::ImportSource => StableTransformIrNodeKindV0::IcssImportSource,
1009    }
1010}
1011
1012pub const fn transform_cst_style_dialect_label(dialect: StyleDialect) -> &'static str {
1013    match dialect {
1014        StyleDialect::Css => "css",
1015        StyleDialect::Scss => "scss",
1016        StyleDialect::Sass => "sass",
1017        StyleDialect::Less => "less",
1018    }
1019}
1020
1021pub fn default_transform_dag_edges() -> Vec<TransformDagEdgeV0> {
1022    vec![
1023        TransformDagEdgeV0 {
1024            from: "import-inline",
1025            to: "custom-property-static-resolve",
1026            reason: "var() resolution needs the full custom-property graph from inlined files",
1027        },
1028        TransformDagEdgeV0 {
1029            from: "scss-module-evaluate",
1030            to: "custom-property-static-resolve",
1031            reason: "SCSS evaluation can introduce custom-property declarations",
1032        },
1033        TransformDagEdgeV0 {
1034            from: "less-module-evaluate",
1035            to: "custom-property-static-resolve",
1036            reason: "Less evaluation can introduce custom-property declarations",
1037        },
1038        TransformDagEdgeV0 {
1039            from: "composes-resolution",
1040            to: "css-modules-class-hashing",
1041            reason: "hashing must run after composed class expansion",
1042        },
1043        TransformDagEdgeV0 {
1044            from: "nesting-unwrap",
1045            to: "css-modules-class-hashing",
1046            reason: "hashing must run after nested selectors are expanded into final selector branches",
1047        },
1048        TransformDagEdgeV0 {
1049            from: "css-modules-class-hashing",
1050            to: "selector-merging",
1051            reason: "selector merging must see post-hash selector identities",
1052        },
1053        TransformDagEdgeV0 {
1054            from: "number-compression",
1055            to: "selector-merging",
1056            reason: "selector merging must see canonical declaration numeric values",
1057        },
1058        TransformDagEdgeV0 {
1059            from: "unit-normalization",
1060            to: "selector-merging",
1061            reason: "selector merging must see canonical declaration unit values",
1062        },
1063        TransformDagEdgeV0 {
1064            from: "color-compression",
1065            to: "selector-merging",
1066            reason: "selector merging must see canonical declaration color values",
1067        },
1068        TransformDagEdgeV0 {
1069            from: "url-quote-strip",
1070            to: "selector-merging",
1071            reason: "selector merging must see canonical url() values",
1072        },
1073        TransformDagEdgeV0 {
1074            from: "string-quote-normalize",
1075            to: "selector-merging",
1076            reason: "selector merging must see canonical string values",
1077        },
1078        TransformDagEdgeV0 {
1079            from: "shorthand-combining",
1080            to: "selector-merging",
1081            reason: "selector merging must see canonical shorthand declaration blocks",
1082        },
1083        TransformDagEdgeV0 {
1084            from: "calc-reduction",
1085            to: "selector-merging",
1086            reason: "selector merging must see reduced calc() declaration values",
1087        },
1088        TransformDagEdgeV0 {
1089            from: "selector-merging",
1090            to: "whitespace-strip",
1091            reason: "whitespace stripping must run after selector merging emits final selector lists",
1092        },
1093        TransformDagEdgeV0 {
1094            from: "custom-property-static-resolve",
1095            to: "calc-reduction",
1096            reason: "var() inside calc may resolve to numeric literals that enable reduction",
1097        },
1098        TransformDagEdgeV0 {
1099            from: "value-resolution",
1100            to: "supports-static-eval",
1101            reason: "@value references inside @supports preludes must resolve before static branch evaluation",
1102        },
1103        TransformDagEdgeV0 {
1104            from: "value-resolution",
1105            to: "media-static-eval",
1106            reason: "@value references inside @media preludes must resolve before static media normalization",
1107        },
1108        TransformDagEdgeV0 {
1109            from: "custom-property-static-resolve",
1110            to: "supports-static-eval",
1111            reason: "var() references inside @supports preludes must resolve before static branch evaluation",
1112        },
1113        TransformDagEdgeV0 {
1114            from: "custom-property-static-resolve",
1115            to: "media-static-eval",
1116            reason: "var() references inside @media preludes must resolve before static media normalization",
1117        },
1118        TransformDagEdgeV0 {
1119            from: "tree-shake-class",
1120            to: "rule-deduplication",
1121            reason: "tree shaking must run before rule deduplication can hide dead rules",
1122        },
1123        TransformDagEdgeV0 {
1124            from: "tree-shake-keyframes",
1125            to: "rule-deduplication",
1126            reason: "keyframe reachability must settle before rule deduplication",
1127        },
1128        TransformDagEdgeV0 {
1129            from: "tree-shake-value",
1130            to: "rule-deduplication",
1131            reason: "@value reachability must settle before rule deduplication",
1132        },
1133        TransformDagEdgeV0 {
1134            from: "tree-shake-custom-property",
1135            to: "rule-deduplication",
1136            reason: "custom-property reachability must settle before rule deduplication",
1137        },
1138        TransformDagEdgeV0 {
1139            from: "tree-shake-class",
1140            to: "empty-rule-removal",
1141            reason: "class tree shaking can leave ordinary and group rules empty",
1142        },
1143        TransformDagEdgeV0 {
1144            from: "tree-shake-keyframes",
1145            to: "empty-rule-removal",
1146            reason: "keyframe tree shaking can leave enclosing group rules empty",
1147        },
1148        TransformDagEdgeV0 {
1149            from: "tree-shake-value",
1150            to: "empty-rule-removal",
1151            reason: "@value tree shaking can leave module-only wrappers empty",
1152        },
1153        TransformDagEdgeV0 {
1154            from: "tree-shake-custom-property",
1155            to: "empty-rule-removal",
1156            reason: "custom-property tree shaking can leave declaration-only rules empty",
1157        },
1158        TransformDagEdgeV0 {
1159            from: "comment-strip",
1160            to: "empty-rule-removal",
1161            reason: "comment-only rules become removable empty rules after comment stripping",
1162        },
1163        TransformDagEdgeV0 {
1164            from: "light-dark-lowering",
1165            to: "vendor-prefixing",
1166            reason: "prefixing runs after target lowering produces final declarations",
1167        },
1168        TransformDagEdgeV0 {
1169            from: "color-mix-lowering",
1170            to: "vendor-prefixing",
1171            reason: "prefixing runs after target lowering produces final declarations",
1172        },
1173        TransformDagEdgeV0 {
1174            from: "oklch-oklab-lowering",
1175            to: "vendor-prefixing",
1176            reason: "prefixing runs after target lowering produces final declarations",
1177        },
1178        TransformDagEdgeV0 {
1179            from: "color-function-lowering",
1180            to: "vendor-prefixing",
1181            reason: "prefixing runs after target lowering produces final declarations",
1182        },
1183        TransformDagEdgeV0 {
1184            from: "logical-to-physical",
1185            to: "vendor-prefixing",
1186            reason: "prefixing runs after target lowering produces final declarations",
1187        },
1188        TransformDagEdgeV0 {
1189            from: "nesting-unwrap",
1190            to: "vendor-prefixing",
1191            reason: "prefixing runs after target lowering produces final declarations",
1192        },
1193        TransformDagEdgeV0 {
1194            from: "scope-flatten",
1195            to: "vendor-prefixing",
1196            reason: "prefixing runs after target lowering produces final declarations",
1197        },
1198        TransformDagEdgeV0 {
1199            from: "layer-flatten",
1200            to: "vendor-prefixing",
1201            reason: "prefixing runs after target lowering produces final declarations",
1202        },
1203        TransformDagEdgeV0 {
1204            from: "supports-static-eval",
1205            to: "vendor-prefixing",
1206            reason: "prefixing runs after target branch evaluation produces final declarations",
1207        },
1208        TransformDagEdgeV0 {
1209            from: "media-static-eval",
1210            to: "vendor-prefixing",
1211            reason: "prefixing runs after target branch evaluation produces final declarations",
1212        },
1213        TransformDagEdgeV0 {
1214            from: "calc-reduction",
1215            to: "print-css",
1216            reason: "printer consumes the final reduced transform CST",
1217        },
1218        TransformDagEdgeV0 {
1219            from: "whitespace-strip",
1220            to: "print-css",
1221            reason: "printer consumes the final trivia policy",
1222        },
1223    ]
1224}
1225
1226#[cfg(test)]
1227mod tests {
1228    use super::{
1229        StableTransformIrNodeKindV0, StyleDialect, TRANSFORM_PASS_CATALOG_LEN, TransformLayer,
1230        TransformPassKind, build_stable_transform_ir_from_source, build_transform_cst_artifact,
1231        summarize_omena_transform_cst_boundary,
1232    };
1233
1234    #[test]
1235    fn exposes_transform_cst_boundary_with_full_pass_catalog() {
1236        let boundary = summarize_omena_transform_cst_boundary();
1237
1238        assert_eq!(boundary.schema_version, "0");
1239        assert_eq!(boundary.product, "omena-transform-cst.boundary");
1240        assert_eq!(boundary.pass_catalog_count, TRANSFORM_PASS_CATALOG_LEN);
1241        assert!(boundary.full_pass_catalog_covered);
1242        assert_eq!(boundary.semantic_aware_pass_count, 14);
1243        assert_eq!(boundary.commodity_pass_count, 25);
1244        assert_eq!(boundary.emission_pass_count, 1);
1245        assert!(boundary.all_passes_declare_cascade_obligation);
1246        assert!(boundary.all_passes_have_compile_time_cascade_witness);
1247        assert!(boundary.stable_transform_ir_ready);
1248        assert!(boundary.provenance_derivation_forest_scaffold_ready);
1249        assert!(boundary.provenance_preservation_required);
1250        assert!(!boundary.next_surfaces.contains(&"omena-transform-passes"));
1251        assert!(!boundary.next_surfaces.contains(&"omena-transform-print"));
1252        assert!(!boundary.next_surfaces.contains(&"salsaTransformQueries"));
1253        assert!(!boundary.next_surfaces.contains(&"sourceMapSpanPrecision"));
1254        assert!(boundary.pass_contracts.iter().any(|contract| {
1255            contract.kind == TransformPassKind::TreeShakeClass
1256                && contract.label == "tree-shake-class"
1257                && contract.layer == TransformLayer::SemanticAware
1258                && contract.reads_semantic_graph
1259                && contract.cascade_safe
1260                && contract.cascade_safety_witness.pass_id == "tree-shake-class"
1261                && contract.cascade_safety_witness.enforced_at
1262                    == "compile-time-exhaustive-pass-catalog"
1263        }));
1264        assert!(boundary.dag_edges.iter().any(|edge| {
1265            edge.from == "composes-resolution" && edge.to == "css-modules-class-hashing"
1266        }));
1267    }
1268
1269    #[test]
1270    fn transform_cst_artifact_preserves_semantic_signature_and_pass_ids() {
1271        let artifact = build_transform_cst_artifact(
1272            ".button { color: var(--brand); }",
1273            "semantic:button:brand",
1274            &[
1275                TransformPassKind::StaticVarSubstitution,
1276                TransformPassKind::ColorCompression,
1277            ],
1278        );
1279
1280        assert_eq!(artifact.product, "omena-transform-cst.artifact");
1281        assert_eq!(artifact.source_byte_len, 32);
1282        assert_eq!(artifact.semantic_signature, "semantic:button:brand");
1283        assert_eq!(artifact.stable_ir.product, "omena-transform-cst.stable-ir");
1284        assert_eq!(artifact.stable_ir.dialect, "css");
1285        assert_eq!(artifact.parser_error_count, 0);
1286        assert!(!artifact.contains_bogus_or_trivia);
1287        assert!(artifact.stable_ir.stable_post_semantic_ir);
1288        assert_eq!(
1289            artifact.stable_ir_node_count,
1290            artifact.stable_ir.provenance_anchors.len()
1291        );
1292        assert_eq!(
1293            artifact.pass_ids,
1294            vec!["custom-property-static-resolve", "color-compression"]
1295        );
1296        assert!(artifact.provenance_preserved);
1297    }
1298
1299    #[test]
1300    fn stable_transform_ir_consumes_parser_semantic_facts_without_trivia_or_bogus_nodes() {
1301        let ir = build_stable_transform_ir_from_source(
1302            r#"
1303@use "./tokens" as tokens;
1304@value primary from "./colors.module.css";
1305.button {
1306  composes: reset from "./reset.module.css";
1307  --brand: tokens.$brand;
1308  color: var(--brand);
1309}
1310"#,
1311            StyleDialect::Scss,
1312            "semantic:scss-button",
1313        );
1314
1315        assert_eq!(ir.product, "omena-transform-cst.stable-ir");
1316        assert_eq!(ir.dialect, "scss");
1317        assert_eq!(ir.parser_error_count, 0);
1318        assert!(!ir.contains_bogus_or_trivia);
1319        assert!(ir.stable_post_semantic_ir);
1320        assert_eq!(ir.node_count, ir.nodes.len());
1321        assert_eq!(ir.node_count, ir.provenance_anchors.len());
1322        assert!(ir.nodes.iter().any(|node| {
1323            node.kind == StableTransformIrNodeKindV0::ClassSelector && node.label == "button"
1324        }));
1325        assert!(ir.nodes.iter().any(|node| {
1326            node.kind == StableTransformIrNodeKindV0::CustomPropertyDeclaration
1327                && node.label == "--brand"
1328        }));
1329        assert!(ir.nodes.iter().any(|node| {
1330            node.kind == StableTransformIrNodeKindV0::CustomPropertyReference
1331                && node.label == "--brand"
1332        }));
1333        assert!(ir.nodes.iter().any(|node| {
1334            node.kind == StableTransformIrNodeKindV0::SassModuleEdge && node.label == "./tokens"
1335        }));
1336        assert!(
1337            ir.nodes
1338                .windows(2)
1339                .all(|pair| pair[0].source_span_start <= pair[1].source_span_start)
1340        );
1341    }
1342}