etdl_compiler/extension.rs
1//! Generic semantic-extension mechanism for the ETDL compiler.
2//!
3//! This is the reusable extension seam the Reliability Supplement and any
4//! future tree-event/domain supplement plug into. The core compiler only knows
5//! "a document may declare supplements; a registered extension may add
6//! validation and semantic processing." It does not embed reliability-specific
7//! logic here.
8//!
9//! The lifecycle (adapted to the existing pipeline):
10//!
11//! ```text
12//! parse
13//! -> core validation
14//! -> extension discovery (supplement declarations -> registry lookup)
15//! -> extension validation
16//! -> extension semantic processing (e.g. external probability resolution)
17//! -> core compilation
18//! -> code generation
19//! ```
20
21use etdl_parser::ast::EtlDocument;
22use std::collections::BTreeMap;
23
24use crate::validate::Diagnostic;
25
26/// The context an extension receives while processing a document.
27#[derive(Debug, Clone)]
28pub struct ExtensionContext<'a> {
29 pub doc: &'a EtlDocument,
30 /// Base directory for resolving relative artifact paths.
31 pub base_dir: &'a std::path::Path,
32 /// Free-form configuration for the extension (e.g. from `x-reliability`).
33 pub config: BTreeMap<String, serde_yaml::Value>,
34}
35
36impl<'a> ExtensionContext<'a> {
37 pub fn new(doc: &'a EtlDocument, base_dir: &'a std::path::Path) -> Self {
38 ExtensionContext {
39 doc,
40 base_dir,
41 config: BTreeMap::new(),
42 }
43 }
44}
45
46/// Self-reported metadata about what a supplement validates and declares —
47/// colocated in the same module as the `parse_and_validate_*`/`validate()`
48/// logic that actually produces `diagnostic_codes`, so a change to one is a
49/// scroll away from the other, not a separate hand-maintained copy in a
50/// different crate. `etdl capabilities`/`etdl supplement list` read this
51/// generically (`ExtensionRegistry::list`/`lookup` + `descriptor()`)
52/// instead of each caller hard-coding a per-supplement summary — see
53/// `crate::performance`'s `descriptor()` for the reference shape every
54/// built-in supplement follows.
55#[derive(Debug, Clone, Copy, Default)]
56pub struct SupplementDescriptor {
57 /// One-line human summary of what this supplement validates/declares.
58 /// Empty (the default) for an extension that doesn't override
59 /// `EtdlExtension::descriptor` — e.g. a dynamically loaded `.wasm`
60 /// plugin, whose wire ABI (`docs/reference/supplement-plugins.md`)
61 /// carries no description field to report here.
62 pub summary: &'static str,
63 /// The schema/version string this supplement's `x-*` field is
64 /// versioned against (e.g. `"etdl.performance/1.0"`), if it has one
65 /// distinct from `EtdlExtension::version()`.
66 pub schema: Option<&'static str>,
67 /// Every diagnostic code this supplement's own validation can produce.
68 pub diagnostic_codes: &'static [&'static str],
69 /// Other supplement ids this one has a real dependency on, if any (e.g.
70 /// `etdl.security` on `etdl.tree-event` — see `crate::security`'s
71 /// module docs for what "dependency" means in practice here).
72 pub requires: &'static [&'static str],
73}
74
75/// A semantic extension that plugs into the ETDL compiler.
76///
77/// Implementations SHOULD be lightweight and deterministic. An extension must
78/// not silently change core ETDL semantics; it adds validation and semantic
79/// processing only.
80pub trait EtdlExtension: Send + Sync {
81 /// The namespaced extension id, e.g. `etdl.reliability`.
82 fn id(&self) -> &str;
83
84 /// The extension version.
85 fn version(&self) -> &str;
86
87 /// Self-reported description of this supplement, for `etdl
88 /// capabilities`/`etdl supplement list` to surface generically.
89 /// Default: empty (an extension with nothing distinctive to report,
90 /// e.g. a third-party `Compiler::with_extension` caller in a test, or a
91 /// dynamically loaded `.wasm` plugin — see [`SupplementDescriptor`]).
92 fn descriptor(&self) -> SupplementDescriptor {
93 SupplementDescriptor::default()
94 }
95
96 /// Validate the document's use of this extension. Called after core
97 /// validation; diagnostics are appended to `diagnostics`.
98 fn validate(
99 &self,
100 doc: &EtlDocument,
101 context: &ExtensionContext<'_>,
102 diagnostics: &mut Vec<Diagnostic>,
103 );
104
105 /// Optional semantic processing step, run before fault-tree evaluation.
106 /// Returns diagnostics. Implementations that resolve external values (e.g.
107 /// probabilities) surface them here.
108 fn process(
109 &self,
110 _doc: &EtlDocument,
111 _context: &ExtensionContext<'_>,
112 _diagnostics: &mut Vec<Diagnostic>,
113 ) -> Box<dyn ExtensionResult + '_> {
114 Box::new(NoopExtensionResult)
115 }
116}
117
118/// Result of an extension's semantic processing step. The reliability extension
119/// returns resolved external probabilities through this; future extensions may
120/// return their own typed results.
121pub trait ExtensionResult {
122 /// The extension id that produced this result.
123 fn extension_id(&self) -> &str;
124
125 /// Basic-event probability overrides this extension's processing step
126 /// resolved, as `(override_key, value)` pairs — the same shape
127 /// `fault_tree::BasicEventOverrides` already consumes. Default: none.
128 /// An extension that resolves external values into fault-tree
129 /// probabilities (as the reliability extension does today, via its own
130 /// dedicated, hard-coded path in `Compiler::run_extensions`) overrides
131 /// this so a *generically registered* extension (`Compiler::
132 /// with_extension`) can contribute overrides the same way, without
133 /// `run_extensions` needing to know the extension's concrete result
134 /// type.
135 fn basic_event_overrides(&self) -> Vec<(String, f64)> {
136 Vec::new()
137 }
138}
139
140/// A no-op result (extensions that do no semantic processing).
141pub struct NoopExtensionResult;
142
143impl ExtensionResult for NoopExtensionResult {
144 fn extension_id(&self) -> &str {
145 ""
146 }
147}
148
149/// A deterministic registry of registered extensions.
150#[derive(Default)]
151pub struct ExtensionRegistry {
152 extensions: BTreeMap<String, Box<dyn EtdlExtension>>,
153}
154
155impl ExtensionRegistry {
156 pub fn new() -> Self {
157 ExtensionRegistry::default()
158 }
159
160 /// Register an extension. Registering a duplicate id replaces the previous
161 /// entry (deterministic last-write-wins).
162 pub fn register<E: EtdlExtension + 'static>(&mut self, extension: E) {
163 self.extensions
164 .insert(extension.id().to_string(), Box::new(extension));
165 }
166
167 pub fn lookup(&self, id: &str) -> Option<&dyn EtdlExtension> {
168 self.extensions.get(id).map(|b| b.as_ref())
169 }
170
171 pub fn contains(&self, id: &str) -> bool {
172 self.extensions.contains_key(id)
173 }
174
175 /// Registered extension ids, sorted (deterministic).
176 pub fn list(&self) -> Vec<&str> {
177 self.extensions.keys().map(|s| s.as_str()).collect()
178 }
179}
180
181/// Built-in extensions shipped with the compiler. This registry is
182/// discoverability/support-checking only (`etdl capabilities`, `etdl
183/// supplement list`, the E-108/W-407 "is this supplement supported" check
184/// in `validate::supplement_is_supported`) — it does **not** by itself make
185/// an extension's `validate`/`process` run during `Compiler::validate`/
186/// `compile`. Tree Event and Reliability each additionally have their own
187/// special-cased call elsewhere in the pipeline; Performance instead relies
188/// on `Compiler::new()` also seeding `Compiler::extensions` with it, so it
189/// executes through the same generic path a third-party `with_extension`
190/// supplement uses — see `crate::performance`'s module docs for why that's
191/// the preferred shape for a new supplement going forward.
192pub fn builtin_registry() -> ExtensionRegistry {
193 let mut registry = ExtensionRegistry::new();
194 // Domain-neutral, always compiled in — not gated behind the
195 // `reliability` feature.
196 registry.register(crate::tree_event::TreeEventExtension::new());
197 registry.register(crate::performance::PerformanceExtension::new());
198 registry.register(crate::safety::SafetyExtension::new());
199 registry.register(crate::diagnostics::DiagnosticsExtension::new());
200 registry.register(crate::security::SecurityExtension::new());
201 #[cfg(feature = "reliability")]
202 {
203 registry.register(crate::reliability::ReliabilityExtension::new());
204 }
205 registry
206}
207
208#[cfg(test)]
209mod tests {
210 use super::*;
211
212 struct TestExt;
213
214 impl EtdlExtension for TestExt {
215 fn id(&self) -> &str {
216 "etdl.test"
217 }
218 fn version(&self) -> &str {
219 "1.0"
220 }
221 fn validate(
222 &self,
223 _doc: &EtlDocument,
224 _context: &ExtensionContext<'_>,
225 _diagnostics: &mut Vec<Diagnostic>,
226 ) {
227 }
228 }
229
230 #[test]
231 fn registry_is_deterministic() {
232 let mut r = ExtensionRegistry::new();
233 r.register(TestExt);
234 r.register(TestExt);
235 assert!(r.contains("etdl.test"));
236 assert!(r.lookup("etdl.test").is_some());
237 assert_eq!(r.list(), vec!["etdl.test"]);
238 }
239
240 #[test]
241 fn lookup_missing_is_none() {
242 let r = ExtensionRegistry::new();
243 assert!(r.lookup("etdl.nope").is_none());
244 }
245
246 /// `etdl capabilities`/`etdl supplement list` read every built-in
247 /// extension's `descriptor()` generically (see `docs/CLI.md`'s `etdl
248 /// capabilities` section) — an extension registered here with the
249 /// trait's silent default (empty `summary`, empty `diagnostic_codes`)
250 /// would print as a blank line with no way for a caller to notice.
251 /// This guards that every built-in actually overrides `descriptor()`,
252 /// so a future supplement added to `builtin_registry()` without one
253 /// fails a test instead of shipping silently undocumented.
254 #[test]
255 fn every_built_in_extension_has_a_non_empty_descriptor() {
256 let registry = builtin_registry();
257 for id in registry.list() {
258 let ext = registry.lookup(id).expect("listed");
259 let d = ext.descriptor();
260 assert!(
261 !d.summary.is_empty(),
262 "{id}: EtdlExtension::descriptor() left `summary` at the trait default (empty) — implement it"
263 );
264 assert!(
265 !d.diagnostic_codes.is_empty(),
266 "{id}: EtdlExtension::descriptor() left `diagnostic_codes` at the trait default (empty) — implement it"
267 );
268 }
269 }
270}