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