Skip to main content

omena_query_transform_runner/
plugin_api.rs

1use omena_bridge::{
2    StyleIntelligenceClassUniverseV0, StyleIntelligenceCompletionV0, StyleIntelligenceHoverV0,
3    StyleIntelligenceProvider, StyleIntelligenceSnapshotV0, built_in_style_intelligence_provider,
4    style_intelligence_completions_at_offset, style_intelligence_hover_at_offset,
5};
6use omena_incremental::OmenaWorkspaceSnapshotIdV0;
7use omena_transform_cst::{
8    IrEditRegionV0, IrNodeIdV0, IrNodeKindV0, IrTransactionErrorV0, IrTransactionV0,
9    TransformIrPrintErrorV0, TransformIrV0, print_transform_ir_css,
10};
11use omena_transform_passes::{
12    TransformDecision, TransformPassExecutionOutcomeV0, TransformPassRuntimeStatus,
13};
14use serde::Serialize;
15
16pub use omena_abstract_value::FactPrecision;
17pub use omena_evidence_graph::EvidenceNodeKeyV0;
18
19pub const OMENA_PLUGIN_ABI_VERSION_V0: &str = "0";
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
22#[serde(rename_all = "camelCase")]
23pub enum PluginKindV0 {
24    Transform,
25    BundleHost,
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
29#[serde(rename_all = "camelCase")]
30pub struct PluginMetadataV0 {
31    pub plugin_id: &'static str,
32    pub kind: PluginKindV0,
33    pub version: &'static str,
34    pub abi_version: &'static str,
35    pub stability: &'static str,
36    pub capabilities: &'static [&'static str],
37}
38
39#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
40#[serde(rename_all = "camelCase")]
41pub struct PluginAnalysisV0 {
42    pub summary: String,
43    pub evidence_reference: EvidenceNodeKeyV0,
44    pub precision: FactPrecision,
45}
46
47#[derive(Debug, Clone, Copy)]
48pub struct PluginWorkspaceSnapshotV0<'snapshot> {
49    snapshot_id: OmenaWorkspaceSnapshotIdV0,
50    style_intelligence: StyleIntelligenceSnapshotV0<'snapshot>,
51}
52
53impl<'snapshot> PluginWorkspaceSnapshotV0<'snapshot> {
54    pub const fn new(
55        snapshot_id: OmenaWorkspaceSnapshotIdV0,
56        style_intelligence: StyleIntelligenceSnapshotV0<'snapshot>,
57    ) -> Self {
58        Self {
59            snapshot_id,
60            style_intelligence,
61        }
62    }
63
64    pub const fn snapshot_id(&self) -> OmenaWorkspaceSnapshotIdV0 {
65        self.snapshot_id
66    }
67
68    pub fn class_universe(&self, provider_id: &str) -> Option<StyleIntelligenceClassUniverseV0> {
69        built_in_style_intelligence_provider(provider_id)
70            .map(|provider| provider.class_universe(&self.style_intelligence))
71    }
72
73    pub fn completions_at_offset(&self, byte_offset: usize) -> Vec<StyleIntelligenceCompletionV0> {
74        style_intelligence_completions_at_offset(&self.style_intelligence, byte_offset)
75    }
76
77    pub fn hover_at_offset(&self, byte_offset: usize) -> Option<StyleIntelligenceHoverV0> {
78        style_intelligence_hover_at_offset(&self.style_intelligence, byte_offset)
79    }
80}
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
83#[serde(rename_all = "camelCase")]
84pub struct PluginTransformContextV0 {
85    pub snapshot_id: OmenaWorkspaceSnapshotIdV0,
86    pub required_precision: FactPrecision,
87}
88
89#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
90#[serde(rename_all = "camelCase")]
91pub struct PluginChangeSummaryV0 {
92    pub observed_node_count: usize,
93    pub mutation_count: usize,
94    pub output_changed: bool,
95}
96
97/// The required result of every plugin transform.
98///
99/// Evidence and precision are fields rather than optional metadata. Omitting
100/// either one is therefore a compile-time error.
101///
102/// ```compile_fail
103/// use omena_query_transform_runner::{FactPrecision, PluginOutcomeV0};
104///
105/// let _ = PluginOutcomeV0 {
106///     change_summary: todo!(),
107///     precision: FactPrecision::Exact,
108///     decision: todo!(),
109/// };
110/// ```
111#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
112#[serde(rename_all = "camelCase")]
113pub struct PluginOutcomeV0 {
114    pub change_summary: PluginChangeSummaryV0,
115    pub evidence_reference: EvidenceNodeKeyV0,
116    pub precision: FactPrecision,
117    pub decision: TransformDecision,
118}
119
120#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
121#[serde(rename_all = "camelCase")]
122pub struct PluginIrNodeV0 {
123    pub node_id: IrNodeIdV0,
124    pub kind: IrNodeKindV0,
125    pub source_span_start: usize,
126    pub source_span_end: usize,
127}
128
129pub struct PluginTransformIrV0<'ir> {
130    ir: &'ir mut TransformIrV0,
131    plugin_id: &'static str,
132    mutation_count: usize,
133}
134
135impl<'ir> PluginTransformIrV0<'ir> {
136    pub fn new(ir: &'ir mut TransformIrV0, plugin_id: &'static str) -> Self {
137        Self {
138            ir,
139            plugin_id,
140            mutation_count: 0,
141        }
142    }
143
144    pub fn source_id(&self) -> &str {
145        self.ir.source_id.as_str()
146    }
147
148    pub fn source_byte_len(&self) -> usize {
149        self.ir.source_byte_len
150    }
151
152    pub fn nodes(&self) -> Vec<PluginIrNodeV0> {
153        self.ir
154            .nodes
155            .iter()
156            .map(|node| PluginIrNodeV0 {
157                node_id: node.node_id,
158                kind: node.kind,
159                source_span_start: node.source_span_start,
160                source_span_end: node.source_span_end,
161            })
162            .collect()
163    }
164
165    pub fn printed_css(&self) -> Result<String, TransformIrPrintErrorV0> {
166        print_transform_ir_css(self.ir)
167    }
168
169    pub fn replace_node(
170        &mut self,
171        node_id: IrNodeIdV0,
172        canonical_text: impl Into<String>,
173    ) -> Result<(), IrTransactionErrorV0> {
174        let region = IrEditRegionV0::full(self.ir.source_byte_len);
175        let mut transaction = IrTransactionV0::new(self.ir, self.plugin_id, region);
176        transaction.replace_node(node_id, canonical_text)?;
177        transaction.commit()?;
178        self.mutation_count += 1;
179        Ok(())
180    }
181
182    pub const fn mutation_count(&self) -> usize {
183        self.mutation_count
184    }
185}
186
187pub trait OmenaPlugin: Sync {
188    fn metadata(&self) -> &'static PluginMetadataV0;
189
190    fn analyze(&self, snapshot: &PluginWorkspaceSnapshotV0<'_>) -> PluginAnalysisV0;
191
192    fn transform(
193        &self,
194        ir: &mut PluginTransformIrV0<'_>,
195        context: PluginTransformContextV0,
196    ) -> PluginOutcomeV0;
197}
198
199#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
200#[serde(rename_all = "camelCase")]
201pub enum PluginOutcomeValidationErrorV0 {
202    SnapshotIdentityMismatch,
203    AbiVersionMismatch,
204    PassIdentityMismatch,
205    ChangeSummaryMismatch,
206    DecisionMismatch,
207    EvidenceReferenceMismatch,
208    PrecisionBelowRequired,
209}
210
211pub fn validate_plugin_outcome(
212    metadata: &PluginMetadataV0,
213    context: PluginTransformContextV0,
214    observed_mutation_count: usize,
215    outcome: &PluginOutcomeV0,
216) -> Result<(), PluginOutcomeValidationErrorV0> {
217    if metadata.abi_version != OMENA_PLUGIN_ABI_VERSION_V0 {
218        return Err(PluginOutcomeValidationErrorV0::AbiVersionMismatch);
219    }
220    let execution_outcome = outcome.decision.compatibility_outcome();
221    if execution_outcome.pass_id != metadata.plugin_id {
222        return Err(PluginOutcomeValidationErrorV0::PassIdentityMismatch);
223    }
224    if outcome.change_summary.mutation_count != observed_mutation_count
225        || execution_outcome.mutation_count != observed_mutation_count
226    {
227        return Err(PluginOutcomeValidationErrorV0::ChangeSummaryMismatch);
228    }
229    let decision_matches_change = match outcome.decision {
230        TransformDecision::Applied { .. } => outcome.change_summary.output_changed,
231        TransformDecision::NoChange { .. } => !outcome.change_summary.output_changed,
232        TransformDecision::Blocked { .. } | TransformDecision::Rejected { .. } => {
233            observed_mutation_count == 0 && !outcome.change_summary.output_changed
234        }
235    };
236    if !decision_matches_change {
237        return Err(PluginOutcomeValidationErrorV0::DecisionMismatch);
238    }
239    if outcome.evidence_reference != execution_outcome.evidence_node_key() {
240        return Err(PluginOutcomeValidationErrorV0::EvidenceReferenceMismatch);
241    }
242    if !outcome.precision.satisfies(context.required_precision) {
243        return Err(PluginOutcomeValidationErrorV0::PrecisionBelowRequired);
244    }
245    Ok(())
246}
247
248pub(crate) fn execute_validated_plugin(
249    plugin: &dyn OmenaPlugin,
250    snapshot: &PluginWorkspaceSnapshotV0<'_>,
251    ir: &mut PluginTransformIrV0<'_>,
252    context: PluginTransformContextV0,
253) -> Result<(PluginAnalysisV0, PluginOutcomeV0), PluginOutcomeValidationErrorV0> {
254    let analysis = plugin.analyze(snapshot);
255    let checkpoint = ir.ir.clone();
256    let outcome = plugin.transform(ir, context);
257    match validate_plugin_outcome(plugin.metadata(), context, ir.mutation_count(), &outcome) {
258        Ok(()) => Ok((analysis, outcome)),
259        Err(error) => {
260            *ir.ir = checkpoint;
261            ir.mutation_count = 0;
262            Err(error)
263        }
264    }
265}
266
267pub(crate) fn no_change_plugin_outcome(
268    plugin_id: &'static str,
269    observed_node_count: usize,
270    precision: FactPrecision,
271) -> PluginOutcomeV0 {
272    let execution_outcome = TransformPassExecutionOutcomeV0 {
273        pass_id: plugin_id,
274        status: TransformPassRuntimeStatus::NoChange,
275        input_byte_len: 0,
276        output_byte_len: 0,
277        mutation_count: 0,
278        provenance_preserved: true,
279        detail: "plugin observed the transform IR without changing emitted CSS",
280    };
281    PluginOutcomeV0 {
282        change_summary: PluginChangeSummaryV0 {
283            observed_node_count,
284            mutation_count: 0,
285            output_changed: false,
286        },
287        evidence_reference: execution_outcome.evidence_node_key(),
288        precision,
289        decision: TransformDecision::NoChange {
290            reason: omena_transform_passes::TransformNoChangeReasonV0::NoMutation,
291            outcome: execution_outcome,
292        },
293    }
294}
295
296#[cfg(test)]
297mod tests {
298    use omena_bridge::{StyleIntelligenceSnapshotV0, summarize_omena_bridge_source_syntax_index};
299    use omena_incremental::{IncrementalRevisionV0, OmenaWorkspaceSnapshotIdV0};
300    use omena_transform_cst::{StyleDialect, lower_transform_ir_from_source};
301
302    use super::*;
303    use crate::plugins::{built_in_omena_plugins, execute_built_in_omena_plugin};
304
305    struct InvalidMutationPlugin;
306
307    static INVALID_MUTATION_PLUGIN_METADATA: PluginMetadataV0 = PluginMetadataV0 {
308        plugin_id: "invalid-mutation",
309        kind: PluginKindV0::Transform,
310        version: "0",
311        abi_version: OMENA_PLUGIN_ABI_VERSION_V0,
312        stability: "testOnly",
313        capabilities: &["transform"],
314    };
315
316    impl OmenaPlugin for InvalidMutationPlugin {
317        fn metadata(&self) -> &'static PluginMetadataV0 {
318            &INVALID_MUTATION_PLUGIN_METADATA
319        }
320
321        fn analyze(&self, _snapshot: &PluginWorkspaceSnapshotV0<'_>) -> PluginAnalysisV0 {
322            PluginAnalysisV0 {
323                summary: "invalid transform outcome fixture".to_string(),
324                evidence_reference: EvidenceNodeKeyV0::new("invalid", "analysis"),
325                precision: FactPrecision::Exact,
326            }
327        }
328
329        fn transform(
330            &self,
331            ir: &mut PluginTransformIrV0<'_>,
332            _context: PluginTransformContextV0,
333        ) -> PluginOutcomeV0 {
334            if let Some(declaration) = ir
335                .nodes()
336                .into_iter()
337                .find(|node| node.kind == IrNodeKindV0::Declaration)
338            {
339                assert!(ir.replace_node(declaration.node_id, "color: blue;").is_ok());
340            }
341            no_change_plugin_outcome(
342                INVALID_MUTATION_PLUGIN_METADATA.plugin_id,
343                ir.nodes().len(),
344                FactPrecision::Exact,
345            )
346        }
347    }
348
349    #[test]
350    fn built_in_plugin_round_trips_analysis_and_fail_closed_transform() -> Result<(), &'static str>
351    {
352        let source = r#"import { cva } from "class-variance-authority";
353const button = cva("btn", { variants: { intent: { primary: "a" } } });
354const value = button({ intent: "primary" });"#;
355        let source_index =
356            summarize_omena_bridge_source_syntax_index(source, Vec::new(), Vec::new());
357        let snapshot = PluginWorkspaceSnapshotV0::new(
358            OmenaWorkspaceSnapshotIdV0::from_revision(IncrementalRevisionV0 { value: 7 }),
359            StyleIntelligenceSnapshotV0::new(&source_index),
360        );
361        let mut transform_ir = lower_transform_ir_from_source(
362            ".button { color: red; }",
363            StyleDialect::Css,
364            "input.css",
365        );
366        let mut plugin_ir = PluginTransformIrV0::new(&mut transform_ir, "semantic-observation");
367        let context = PluginTransformContextV0 {
368            snapshot_id: snapshot.snapshot_id(),
369            required_precision: FactPrecision::Conservative,
370        };
371        let (analysis, outcome) = execute_built_in_omena_plugin(
372            "semantic-observation",
373            &snapshot,
374            &mut plugin_ir,
375            context,
376        )
377        .ok_or("built-in plugin should be registered")?
378        .map_err(|_| "built-in plugin outcome should validate")?;
379
380        assert!(
381            analysis
382                .summary
383                .contains("snapshot 7 exposes 1 CVA class universes")
384        );
385        assert_eq!(analysis.precision, FactPrecision::Exact);
386        assert_eq!(outcome.precision, FactPrecision::Exact);
387        assert_eq!(outcome.change_summary.mutation_count, 0);
388        assert!(matches!(
389            outcome.decision,
390            TransformDecision::NoChange { .. }
391        ));
392        assert_eq!(
393            plugin_ir
394                .printed_css()
395                .map_err(|_| "plugin IR should remain printable")?,
396            ".button { color: red; }"
397        );
398        Ok(())
399    }
400
401    #[test]
402    fn built_in_registry_carries_transform_and_bundle_host_kinds() {
403        let registrations = built_in_omena_plugins()
404            .iter()
405            .map(|plugin| (plugin.metadata().plugin_id, plugin.metadata().kind))
406            .collect::<Vec<_>>();
407
408        assert_eq!(
409            registrations,
410            vec![
411                ("semantic-observation", PluginKindV0::Transform),
412                ("vite-bundle-host", PluginKindV0::BundleHost),
413            ]
414        );
415    }
416
417    #[test]
418    fn outcome_validation_rejects_unbound_snapshot_precision_and_evidence()
419    -> Result<(), &'static str> {
420        let source_index = summarize_omena_bridge_source_syntax_index("", Vec::new(), Vec::new());
421        let snapshot = PluginWorkspaceSnapshotV0::new(
422            OmenaWorkspaceSnapshotIdV0::from_revision(IncrementalRevisionV0 { value: 1 }),
423            StyleIntelligenceSnapshotV0::new(&source_index),
424        );
425        let mut transform_ir =
426            lower_transform_ir_from_source("a {}", StyleDialect::Css, "input.css");
427        let mut plugin_ir = PluginTransformIrV0::new(&mut transform_ir, "semantic-observation");
428        let context = PluginTransformContextV0 {
429            snapshot_id: snapshot.snapshot_id(),
430            required_precision: FactPrecision::Exact,
431        };
432        assert_eq!(
433            execute_built_in_omena_plugin(
434                "semantic-observation",
435                &snapshot,
436                &mut plugin_ir,
437                PluginTransformContextV0 {
438                    snapshot_id: OmenaWorkspaceSnapshotIdV0 { value: 2 },
439                    ..context
440                },
441            ),
442            Some(Err(
443                PluginOutcomeValidationErrorV0::SnapshotIdentityMismatch
444            ))
445        );
446        let (_, mut outcome) = execute_built_in_omena_plugin(
447            "semantic-observation",
448            &snapshot,
449            &mut plugin_ir,
450            context,
451        )
452        .ok_or("built-in plugin should be registered")?
453        .map_err(|_| "built-in plugin outcome should validate")?;
454        let metadata = built_in_omena_plugins()
455            .first()
456            .ok_or("built-in plugin registry should be non-empty")?
457            .metadata();
458
459        outcome.precision = FactPrecision::Unknown;
460        assert_eq!(
461            validate_plugin_outcome(metadata, context, plugin_ir.mutation_count(), &outcome,),
462            Err(PluginOutcomeValidationErrorV0::PrecisionBelowRequired)
463        );
464        outcome.precision = FactPrecision::Exact;
465        outcome.evidence_reference = EvidenceNodeKeyV0::new("wrong", "evidence");
466        assert_eq!(
467            validate_plugin_outcome(metadata, context, plugin_ir.mutation_count(), &outcome,),
468            Err(PluginOutcomeValidationErrorV0::EvidenceReferenceMismatch)
469        );
470        Ok(())
471    }
472
473    #[test]
474    fn rejected_plugin_outcome_restores_the_input_ir() -> Result<(), &'static str> {
475        let source_index = summarize_omena_bridge_source_syntax_index("", Vec::new(), Vec::new());
476        let snapshot = PluginWorkspaceSnapshotV0::new(
477            OmenaWorkspaceSnapshotIdV0::from_revision(IncrementalRevisionV0 { value: 3 }),
478            StyleIntelligenceSnapshotV0::new(&source_index),
479        );
480        let mut transform_ir = lower_transform_ir_from_source(
481            ".button { color: red; }",
482            StyleDialect::Css,
483            "input.css",
484        );
485        let original_ir = transform_ir.clone();
486        let mut plugin_ir = PluginTransformIrV0::new(
487            &mut transform_ir,
488            INVALID_MUTATION_PLUGIN_METADATA.plugin_id,
489        );
490        let result = execute_validated_plugin(
491            &InvalidMutationPlugin,
492            &snapshot,
493            &mut plugin_ir,
494            PluginTransformContextV0 {
495                snapshot_id: snapshot.snapshot_id(),
496                required_precision: FactPrecision::Exact,
497            },
498        );
499
500        assert_eq!(
501            result,
502            Err(PluginOutcomeValidationErrorV0::ChangeSummaryMismatch)
503        );
504        assert_eq!(plugin_ir.mutation_count(), 0);
505        assert_eq!(transform_ir, original_ir);
506        Ok(())
507    }
508}