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 = summarize_omena_bridge_source_syntax_index(source, Vec::new());
356        let snapshot = PluginWorkspaceSnapshotV0::new(
357            OmenaWorkspaceSnapshotIdV0::from_revision(IncrementalRevisionV0 { value: 7 }),
358            StyleIntelligenceSnapshotV0::new(&source_index),
359        );
360        let mut transform_ir = lower_transform_ir_from_source(
361            ".button { color: red; }",
362            StyleDialect::Css,
363            "input.css",
364        );
365        let mut plugin_ir = PluginTransformIrV0::new(&mut transform_ir, "semantic-observation");
366        let context = PluginTransformContextV0 {
367            snapshot_id: snapshot.snapshot_id(),
368            required_precision: FactPrecision::Conservative,
369        };
370        let (analysis, outcome) = execute_built_in_omena_plugin(
371            "semantic-observation",
372            &snapshot,
373            &mut plugin_ir,
374            context,
375        )
376        .ok_or("built-in plugin should be registered")?
377        .map_err(|_| "built-in plugin outcome should validate")?;
378
379        assert!(
380            analysis
381                .summary
382                .contains("snapshot 7 exposes 1 CVA class universes")
383        );
384        assert_eq!(analysis.precision, FactPrecision::Exact);
385        assert_eq!(outcome.precision, FactPrecision::Exact);
386        assert_eq!(outcome.change_summary.mutation_count, 0);
387        assert!(matches!(
388            outcome.decision,
389            TransformDecision::NoChange { .. }
390        ));
391        assert_eq!(
392            plugin_ir
393                .printed_css()
394                .map_err(|_| "plugin IR should remain printable")?,
395            ".button { color: red; }"
396        );
397        Ok(())
398    }
399
400    #[test]
401    fn built_in_registry_carries_transform_and_bundle_host_kinds() {
402        let registrations = built_in_omena_plugins()
403            .iter()
404            .map(|plugin| (plugin.metadata().plugin_id, plugin.metadata().kind))
405            .collect::<Vec<_>>();
406
407        assert_eq!(
408            registrations,
409            vec![
410                ("semantic-observation", PluginKindV0::Transform),
411                ("vite-bundle-host", PluginKindV0::BundleHost),
412            ]
413        );
414    }
415
416    #[test]
417    fn outcome_validation_rejects_unbound_snapshot_precision_and_evidence()
418    -> Result<(), &'static str> {
419        let source_index = summarize_omena_bridge_source_syntax_index("", Vec::new());
420        let snapshot = PluginWorkspaceSnapshotV0::new(
421            OmenaWorkspaceSnapshotIdV0::from_revision(IncrementalRevisionV0 { value: 1 }),
422            StyleIntelligenceSnapshotV0::new(&source_index),
423        );
424        let mut transform_ir =
425            lower_transform_ir_from_source("a {}", StyleDialect::Css, "input.css");
426        let mut plugin_ir = PluginTransformIrV0::new(&mut transform_ir, "semantic-observation");
427        let context = PluginTransformContextV0 {
428            snapshot_id: snapshot.snapshot_id(),
429            required_precision: FactPrecision::Exact,
430        };
431        assert_eq!(
432            execute_built_in_omena_plugin(
433                "semantic-observation",
434                &snapshot,
435                &mut plugin_ir,
436                PluginTransformContextV0 {
437                    snapshot_id: OmenaWorkspaceSnapshotIdV0 { value: 2 },
438                    ..context
439                },
440            ),
441            Some(Err(
442                PluginOutcomeValidationErrorV0::SnapshotIdentityMismatch
443            ))
444        );
445        let (_, mut outcome) = execute_built_in_omena_plugin(
446            "semantic-observation",
447            &snapshot,
448            &mut plugin_ir,
449            context,
450        )
451        .ok_or("built-in plugin should be registered")?
452        .map_err(|_| "built-in plugin outcome should validate")?;
453        let metadata = built_in_omena_plugins()
454            .first()
455            .ok_or("built-in plugin registry should be non-empty")?
456            .metadata();
457
458        outcome.precision = FactPrecision::Unknown;
459        assert_eq!(
460            validate_plugin_outcome(metadata, context, plugin_ir.mutation_count(), &outcome,),
461            Err(PluginOutcomeValidationErrorV0::PrecisionBelowRequired)
462        );
463        outcome.precision = FactPrecision::Exact;
464        outcome.evidence_reference = EvidenceNodeKeyV0::new("wrong", "evidence");
465        assert_eq!(
466            validate_plugin_outcome(metadata, context, plugin_ir.mutation_count(), &outcome,),
467            Err(PluginOutcomeValidationErrorV0::EvidenceReferenceMismatch)
468        );
469        Ok(())
470    }
471
472    #[test]
473    fn rejected_plugin_outcome_restores_the_input_ir() -> Result<(), &'static str> {
474        let source_index = summarize_omena_bridge_source_syntax_index("", Vec::new());
475        let snapshot = PluginWorkspaceSnapshotV0::new(
476            OmenaWorkspaceSnapshotIdV0::from_revision(IncrementalRevisionV0 { value: 3 }),
477            StyleIntelligenceSnapshotV0::new(&source_index),
478        );
479        let mut transform_ir = lower_transform_ir_from_source(
480            ".button { color: red; }",
481            StyleDialect::Css,
482            "input.css",
483        );
484        let original_ir = transform_ir.clone();
485        let mut plugin_ir = PluginTransformIrV0::new(
486            &mut transform_ir,
487            INVALID_MUTATION_PLUGIN_METADATA.plugin_id,
488        );
489        let result = execute_validated_plugin(
490            &InvalidMutationPlugin,
491            &snapshot,
492            &mut plugin_ir,
493            PluginTransformContextV0 {
494                snapshot_id: snapshot.snapshot_id(),
495                required_precision: FactPrecision::Exact,
496            },
497        );
498
499        assert_eq!(
500            result,
501            Err(PluginOutcomeValidationErrorV0::ChangeSummaryMismatch)
502        );
503        assert_eq!(plugin_ir.mutation_count(), 0);
504        assert_eq!(transform_ir, original_ir);
505        Ok(())
506    }
507}