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 = 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}