gioser

tawasuyu/brahman

Rust 582TOML 222Markdown 83JSON 198635 Definiciones

⎇ b3a99f38dcbcd3819b2358b3c54732f61baaf7a4 crates/modules/nakui/core/src/executor.rs Rust · 674 líneas · 24.9 KB Historia Quién tocó qué crudo

feat(nakui-core,nakui-ui): FieldOp::Clear — borrar values vía form vacío f0c0a7186 · Sergio ·

1use serde_json::{Value, json};
2use sha2::{Digest, Sha256};
3use std::collections::{BTreeMap, HashMap, HashSet};
4use std::path::{Path, PathBuf};
5use thiserror::Error;
6use uuid::Uuid;
7
8use crate::delta::{FieldOp, simulate_on};
9use crate::graph::{GraphError, ManifestGraph};
10use crate::kcl_wrapper::{self, KclError};
11use crate::manifest::{ConserveRule, Manifest, ManifestError, MorphismSpec, ValidationError};
12use crate::rhai_executor::{RhaiError, RhaiExecutor};
13use crate::store::{Store, StoreError};
14
15#[derive(Debug, Error)]
16pub enum ExecError {
17    #[error("morphism `{0}` not in manifest")]
18    UnknownMorphism(String),
19    #[error("missing input role `{role}` for morphism `{morphism}`")]
20    MissingInput { morphism: String, role: String },
21    #[error("duplicate input id {id} bound to roles `{role_a}` and `{role_b}`")]
22    DuplicateInputId {
23        id: Uuid,
24        role_a: String,
25        role_b: String,
26    },
27    #[error("entity `{0}` id `{1}` not found in store")]
28    EntityMissing(String, Uuid),
29    #[error(
30        "capability violation: morphism `{morphism}` produced op on `{token}` not in writes={declared:?}"
31    )]
32    CapabilityViolation {
33        morphism: String,
34        token: String,
35        declared: Vec<String>,
36    },
37    #[error(
38        "conservation violated: Σ Δ {entity}.{field} where {group_by} = {group:?} = {total} (expected 0)"
39    )]
40    ConservationViolation {
41        entity: String,
42        field: String,
43        group_by: String,
44        group: String,
45        total: i128,
46    },
47    #[error("conservation rule {entity}.{field}: {message}")]
48    ConservationMalformed {
49        entity: String,
50        field: String,
51        message: String,
52    },
53    #[error("kcl pre-check failed on `{role}` ({entity}): {source}")]
54    KclPre {
55        role: String,
56        entity: String,
57        #[source]
58        source: KclError,
59    },
60    #[error("kcl post-check failed on `{role}` ({entity}): {source}")]
61    KclPost {
62        role: String,
63        entity: String,
64        #[source]
65        source: KclError,
66    },
67    #[error("kcl post-check failed on created {entity} {id}: {source}")]
68    KclPostCreate {
69        entity: String,
70        id: Uuid,
71        #[source]
72        source: KclError,
73    },
74    #[error("rhai: {0}")]
75    Rhai(#[from] RhaiError),
76    #[error("store: {0}")]
77    Store(#[from] StoreError),
78    #[error("manifest: {0}")]
79    Manifest(#[from] ManifestError),
80    #[error("manifest validation: {0}")]
81    ManifestValidation(#[from] ValidationError),
82    #[error("manifest graph: {0}")]
83    Graph(#[from] GraphError),
84    #[error("io: {0}")]
85    Io(#[from] std::io::Error),
86}
87
88pub struct Executor {
89    pub manifest: Manifest,
90    pub graph: ManifestGraph,
91    pub module_dir: PathBuf,
92    pub schema_path: PathBuf,
93    pub rhai: RhaiExecutor,
94    /// `true` when `schema_path` is a tempfile bundle created by
95    /// `load_module`; Drop removes it. `false` for inline-built executors
96    /// that point at a real schema file owned by the caller (tests).
97    pub owned_bundle: bool,
98    /// Per-morphism `schema_hash`: SHA-256 of (kcl bundle + manifest spec
99    /// + rhai script bytes), computed once at load. The hash is the
100    /// determinism contract for KCL evolution — `verify_log` uses it to
101    /// reject logs whose entries were produced under different rules.
102    pub schema_hashes: HashMap<String, [u8; 32]>,
103    /// Module-wide bundle hash: SHA-256 of just the KCL bundle bytes.
104    /// Stamped onto every `LogEntry::Seed` via `seed_and_log` so
105    /// `verify_log` can flag seeds whose entity schemas have evolved
106    /// since they were logged. Coarser than `schema_hashes` (any
107    /// schema.k edit moves it, even one that doesn't affect the seeded
108    /// entity) but cheap and conservative — false positives over false
109    /// negatives, like the morphism hash.
110    pub schema_bundle_hash: [u8; 32],
111}
112
113impl Drop for Executor {
114    fn drop(&mut self) {
115        if self.owned_bundle {
116            let _ = std::fs::remove_file(&self.schema_path);
117        }
118    }
119}
120
121/// One row of the bound-inputs map. Holds both `role` and `entity` so the
122/// capability check can verify a Set's `path.entity` matches the role's
123/// declared entity (catches uuid-collision and lazy scripts).
124#[derive(Debug, Clone)]
125struct InputBinding {
126    role: String,
127    entity: String,
128}
129
130impl Executor {
131    pub fn load_module(module_dir: impl Into<PathBuf>) -> Result<Self, ExecError> {
132        let module_dir = module_dir.into();
133        let manifest = Manifest::load(&module_dir.join("nsmc.json"))?;
134        manifest.validate(&module_dir)?;
135        let graph = ManifestGraph::build(&manifest)?;
136        let schema_path = build_schema_bundle(&module_dir, &manifest.effective_schemas())?;
137
138        let schema_bundle_bytes = std::fs::read(&schema_path)?;
139        let schema_bundle_hash = compute_schema_bundle_hash(&schema_bundle_bytes);
140        let mut schema_hashes = HashMap::with_capacity(manifest.morphisms.len());
141        for spec in &manifest.morphisms {
142            let script_path = module_dir.join(&spec.script);
143            let hash = compute_morphism_schema_hash(&schema_bundle_bytes, spec, &script_path)?;
144            schema_hashes.insert(spec.name.clone(), hash);
145        }
146
147        Ok(Self {
148            manifest,
149            graph,
150            module_dir,
151            schema_path,
152            rhai: RhaiExecutor::new_sandboxed(),
153            owned_bundle: true,
154            schema_hashes,
155            schema_bundle_hash,
156        })
157    }
158
159    /// Hash for the named morphism in the currently loaded module. `None`
160    /// if no such morphism is declared. Used by `verify_log` to enforce
161    /// the schema-version contract.
162    pub fn schema_hash(&self, morphism: &str) -> Option<[u8; 32]> {
163        self.schema_hashes.get(morphism).copied()
164    }
165
166    /// Single 32-byte hash representing the entire module's schema:
167    /// every morphism's hash, in canonical name order, framed and
168    /// chained. Snapshots pin this so a snapshot taken under bundle A
169    /// can be detected when later loaded against bundle B.
170    pub fn module_schema_hash(&self) -> [u8; 32] {
171        let mut entries: Vec<(&String, &[u8; 32])> = self.schema_hashes.iter().collect();
172        entries.sort_by_key(|(name, _)| name.as_str().to_owned());
173        let mut hasher = Sha256::new();
174        hasher.update(b"nakui-module-v1\0");
175        for (name, hash) in entries {
176            hasher.update((name.len() as u64).to_le_bytes());
177            hasher.update(name.as_bytes());
178            hasher.update(hash);
179        }
180        hasher.finalize().into()
181    }
182
183    /// Compute the ops for a morphism without mutating the store.
184    ///
185    /// Pipeline:
186    ///   1. Resolve manifest spec; bind caller's role->id to spec inputs.
187    ///   2. Reject duplicate ids across roles.
188    ///   3. Load every input entity; KCL pre-check each.
189    ///   4. Run the Rhai script with `{ states, ids, params }`.
190    ///   5. Capability check: every Set targets a tracked id whose entity
191    ///      matches the role's declared entity, and produces a `<role>.<field>`
192    ///      token in `writes`; Create/Delete produce `<entity>` tokens.
193    ///   6. Delta-level invariants (conservation rules).
194    ///   7. Per-input KCL post-check (skipped for inputs that the ops Delete).
195    ///   8. KCL-validate every Created record against its entity schema.
196    ///   9. Pre-apply check: store.apply_dry_run guarantees apply will land.
197    ///
198    /// On `Ok`, the returned ops are *contractually applicable* — caller can
199    /// log first and then apply, knowing apply will succeed barring transient
200    /// backend faults.
201    pub fn compute<S: Store>(
202        &self,
203        store: &S,
204        morphism_name: &str,
205        inputs: &[(&str, Uuid)],
206        params: Value,
207    ) -> Result<Vec<FieldOp>, ExecError> {
208        let spec: &MorphismSpec = self
209            .manifest
210            .morphism(morphism_name)
211            .ok_or_else(|| ExecError::UnknownMorphism(morphism_name.to_string()))?;
212
213        // 1. Bind inputs.
214        let inputs_map: BTreeMap<String, Uuid> = inputs
215            .iter()
216            .map(|(role, id)| (role.to_string(), *id))
217            .collect();
218        for spec_in in &spec.inputs {
219            if !inputs_map.contains_key(&spec_in.role) {
220                return Err(ExecError::MissingInput {
221                    morphism: morphism_name.to_string(),
222                    role: spec_in.role.clone(),
223                });
224            }
225        }
226
227        // 2. Build id -> binding (role + entity), rejecting duplicates.
228        let mut id_to_input: HashMap<Uuid, InputBinding> = HashMap::new();
229        for spec_in in &spec.inputs {
230            let id = inputs_map[&spec_in.role];
231            if let Some(other) = id_to_input.get(&id) {
232                return Err(ExecError::DuplicateInputId {
233                    id,
234                    role_a: other.role.clone(),
235                    role_b: spec_in.role.clone(),
236                });
237            }
238            id_to_input.insert(
239                id,
240                InputBinding {
241                    role: spec_in.role.clone(),
242                    entity: spec_in.entity.clone(),
243                },
244            );
245        }
246
247        // 3. Load + pre-check every input.
248        let mut loaded: BTreeMap<String, Value> = BTreeMap::new();
249        let mut id_strings: BTreeMap<String, String> = BTreeMap::new();
250        for spec_in in &spec.inputs {
251            let id = inputs_map[&spec_in.role];
252            let state = store
253                .load(&spec_in.entity, id)
254                .ok_or_else(|| ExecError::EntityMissing(spec_in.entity.clone(), id))?;
255            self.kcl_check(&spec_in.entity, &state)
256                .map_err(|e| ExecError::KclPre {
257                    role: spec_in.role.clone(),
258                    entity: spec_in.entity.clone(),
259                    source: e,
260                })?;
261            loaded.insert(spec_in.role.clone(), state);
262            id_strings.insert(spec_in.role.clone(), id.to_string());
263        }
264
265        // 4. Rhai.
266        let script_path = self.module_dir.join(&spec.script);
267        let input = json!({
268            "states": loaded,
269            "ids": id_strings,
270            "params": params,
271        });
272        let ops = self.rhai.run(&script_path, input)?;
273
274        // 5. Capability check.
275        let declared: HashSet<&str> = spec.writes.iter().map(String::as_str).collect();
276        for op in &ops {
277            let token = match op {
278                // Set y Clear comparten el mismo token shape: ambos
279                // mutan un field específico de un record tracked.
280                FieldOp::Set { path, .. } | FieldOp::Clear { path } => {
281                    match id_to_input.get(&path.id) {
282                        Some(binding) if binding.entity == path.entity => {
283                            format!("{}.{}", binding.role, path.field)
284                        }
285                        Some(_) => {
286                            return Err(ExecError::CapabilityViolation {
287                                morphism: morphism_name.to_string(),
288                                token: format!(
289                                    "<entity-mismatch>.{}.{}",
290                                    path.entity, path.field
291                                ),
292                                declared: spec.writes.clone(),
293                            });
294                        }
295                        None => {
296                            return Err(ExecError::CapabilityViolation {
297                                morphism: morphism_name.to_string(),
298                                token: format!(
299                                    "<untracked id>.{}.{}",
300                                    path.entity, path.field
301                                ),
302                                declared: spec.writes.clone(),
303                            });
304                        }
305                    }
306                }
307                FieldOp::Create { entity, .. } => entity.clone(),
308                FieldOp::Delete { entity, .. } => entity.clone(),
309            };
310            if !declared.contains(token.as_str()) {
311                return Err(ExecError::CapabilityViolation {
312                    morphism: morphism_name.to_string(),
313                    token,
314                    declared: spec.writes.clone(),
315                });
316            }
317        }
318
319        // 6. Conservation invariants.
320        for rule in &spec.invariants.conserve {
321            check_conservation(rule, &loaded, &id_to_input, &ops)?;
322        }
323
324        // 7. Per-input KCL post-check; skip Deleted inputs.
325        for spec_in in &spec.inputs {
326            let id = inputs_map[&spec_in.role];
327            if let Some(new_state) =
328                simulate_on(&loaded[&spec_in.role], &spec_in.entity, id, &ops)
329            {
330                self.kcl_check(&spec_in.entity, &new_state)
331                    .map_err(|e| ExecError::KclPost {
332                        role: spec_in.role.clone(),
333                        entity: spec_in.entity.clone(),
334                        source: e,
335                    })?;
336            }
337        }
338
339        // 8. Validate every Created record against its entity schema.
340        for op in &ops {
341            if let FieldOp::Create { entity, id, data } = op {
342                self.kcl_check(entity, data)
343                    .map_err(|e| ExecError::KclPostCreate {
344                        entity: entity.clone(),
345                        id: *id,
346                        source: e,
347                    })?;
348            }
349        }
350
351        // 9. Pre-apply check: structural compatibility with current store state.
352        store.apply_dry_run(&ops)?;
353
354        Ok(ops)
355    }
356
357    /// compute + apply, for callers that don't need event logging.
358    pub fn run<S: Store>(
359        &self,
360        store: &mut S,
361        morphism_name: &str,
362        inputs: &[(&str, Uuid)],
363        params: Value,
364    ) -> Result<Vec<FieldOp>, ExecError> {
365        let ops = self.compute(store, morphism_name, inputs, params)?;
366        store.apply(&ops)?;
367        Ok(ops)
368    }
369
370    fn kcl_check(&self, entity: &str, state: &Value) -> Result<(), KclError> {
371        let tmp = std::env::temp_dir().join(format!("nakui_{}_{}.json", entity, Uuid::new_v4()));
372        std::fs::write(&tmp, serde_json::to_vec(state).expect("state serializes"))
373            .map_err(KclError::Io)?;
374        let result = kcl_wrapper::vet(&self.schema_path, &tmp, entity);
375        let _ = std::fs::remove_file(&tmp);
376        result
377    }
378}
379
380/// Concatenate every declared `.k` file into a single bundle on disk.
381/// `kcl vet` only takes one schema arg, so cross-module modules (e.g. sales
382/// referencing both treasury and inventory entities) bundle their imports
383/// at load time. The bundle lives in `temp_dir` for the lifetime of the
384/// executor; one file per Executor instance.
385/// Module-wide hash of just the KCL bundle bytes. Stamped on
386/// `LogEntry::Seed` entries (which don't run through any morphism, so
387/// `compute_morphism_schema_hash` doesn't apply). Bumped by any byte
388/// change in any schema file the manifest exposes — coarser than a
389/// per-entity hash would be, but doesn't require KCL parsing.
390fn compute_schema_bundle_hash(schema_bundle_bytes: &[u8]) -> [u8; 32] {
391    let mut hasher = Sha256::new();
392    hasher.update(b"nakui-bundle-v1\0");
393    hasher.update((schema_bundle_bytes.len() as u64).to_le_bytes());
394    hasher.update(schema_bundle_bytes);
395    hasher.finalize().into()
396}
397
398/// Per-morphism schema hash. SHA-256 with length-prefixed framing over
399/// three inputs that together determine the morphism's deterministic
400/// behaviour: the KCL schema bundle (entity shapes + invariants), the
401/// manifest spec (writes, conserve, depends_on, etc.), and a
402/// **normalized** form of the Rhai script — comments stripped and
403/// whitespace runs collapsed, with string literals preserved exactly.
404///
405/// The normalization makes the hash invariant to cosmetic edits (a
406/// developer adding a `// TODO` doesn't invalidate the log) without
407/// missing real behavioural changes. The framing tag is bumped to
408/// `nakui-schema-v2` so logs hashed under v1 (raw bytes) cleanly fail
409/// SchemaMismatch on upgrade rather than silently divergence.
410fn compute_morphism_schema_hash(
411    schema_bundle_bytes: &[u8],
412    spec: &MorphismSpec,
413    script_path: &Path,
414) -> std::io::Result<[u8; 32]> {
415    let script_bytes = std::fs::read(script_path)?;
416    let script_source = std::str::from_utf8(&script_bytes).map_err(|e| {
417        std::io::Error::new(
418            std::io::ErrorKind::InvalidData,
419            format!("script {} is not valid UTF-8: {}", script_path.display(), e),
420        )
421    })?;
422    let normalized_script = normalize_rhai_source(script_source);
423    let spec_json = serde_json::to_vec(spec).expect("MorphismSpec serializes");
424
425    let mut hasher = Sha256::new();
426    hasher.update(b"nakui-schema-v2\0");
427    hasher.update(b"schema:");
428    hasher.update((schema_bundle_bytes.len() as u64).to_le_bytes());
429    hasher.update(schema_bundle_bytes);
430    hasher.update(b"spec:");
431    hasher.update((spec_json.len() as u64).to_le_bytes());
432    hasher.update(&spec_json);
433    hasher.update(b"script:");
434    hasher.update((normalized_script.len() as u64).to_le_bytes());
435    hasher.update(normalized_script.as_bytes());
436    Ok(hasher.finalize().into())
437}
438
439/// Strip line/block comments and collapse whitespace runs in a Rhai
440/// source string. Preserves string literals exactly. Used to make the
441/// schema hash invariant to cosmetic edits.
442///
443/// Limitations:
444///   - Doesn't handle backtick template literals (Rhai 1.x interp
445///     strings). If the modules ever start using them, the normalizer
446///     must be extended; until then it's not a concern for the
447///     production scripts in `modules/`.
448///   - Doesn't handle nested block comments — Rhai itself doesn't
449///     either.
450pub fn normalize_rhai_source(src: &str) -> String {
451    let mut out = String::with_capacity(src.len());
452    let mut chars = src.chars().peekable();
453    let mut prev_was_space = true; // strip leading whitespace
454
455    while let Some(c) = chars.next() {
456        // Line comment: //...\n
457        if c == '/' && chars.peek() == Some(&'/') {
458            chars.next();
459            while let Some(&n) = chars.peek() {
460                if n == '\n' {
461                    break;
462                }
463                chars.next();
464            }
465            continue;
466        }
467        // Block comment: /* ... */
468        if c == '/' && chars.peek() == Some(&'*') {
469            chars.next();
470            let mut prev = '\0';
471            while let Some(n) = chars.next() {
472                if prev == '*' && n == '/' {
473                    break;
474                }
475                prev = n;
476            }
477            continue;
478        }
479        // String literal: copy verbatim including escape sequences.
480        if c == '"' {
481            out.push('"');
482            while let Some(n) = chars.next() {
483                if n == '\\' {
484                    out.push('\\');
485                    if let Some(esc) = chars.next() {
486                        out.push(esc);
487                    }
488                } else if n == '"' {
489                    out.push('"');
490                    break;
491                } else {
492                    out.push(n);
493                }
494            }
495            prev_was_space = false;
496            continue;
497        }
498        // Whitespace run → single space (or nothing if at edge).
499        if c.is_whitespace() {
500            if !prev_was_space {
501                out.push(' ');
502                prev_was_space = true;
503            }
504            continue;
505        }
506        // Regular character.
507        out.push(c);
508        prev_was_space = false;
509    }
510
511    if out.ends_with(' ') {
512        out.pop();
513    }
514    out
515}
516
517fn build_schema_bundle(module_dir: &std::path::Path, schemas: &[String]) -> std::io::Result<PathBuf> {
518    let mut combined = String::new();
519    for s in schemas {
520        let p = module_dir.join(s);
521        let content = std::fs::read_to_string(&p)?;
522        combined.push_str("# --- ");
523        combined.push_str(p.to_string_lossy().as_ref());
524        combined.push_str(" ---\n");
525        combined.push_str(&content);
526        combined.push_str("\n\n");
527    }
528    let bundle = std::env::temp_dir().join(format!("nakui_schema_{}.k", Uuid::new_v4()));
529    std::fs::write(&bundle, combined)?;
530    Ok(bundle)
531}
532
533fn check_conservation(
534    rule: &ConserveRule,
535    loaded: &BTreeMap<String, Value>,
536    id_to_input: &HashMap<Uuid, InputBinding>,
537    ops: &[FieldOp],
538) -> Result<(), ExecError> {
539    let mut delta_by_group: HashMap<String, i128> = HashMap::new();
540
541    for op in ops {
542        if let FieldOp::Set { path, value } = op {
543            if path.entity != rule.entity || path.field != rule.field {
544                continue;
545            }
546            let binding = id_to_input
547                .get(&path.id)
548                .filter(|b| b.entity == path.entity)
549                .ok_or_else(|| ExecError::ConservationMalformed {
550                    entity: rule.entity.clone(),
551                    field: rule.field.clone(),
552                    message: format!(
553                        "Set on id {} with entity {} cannot be reconciled to a tracked input",
554                        path.id, path.entity
555                    ),
556                })?;
557            let old_state = &loaded[&binding.role];
558            let old_val =
559                old_state
560                    .get(&rule.field)
561                    .and_then(Value::as_i64)
562                    .ok_or_else(|| ExecError::ConservationMalformed {
563                        entity: rule.entity.clone(),
564                        field: rule.field.clone(),
565                        message: format!("old value at role `{}` is not i64", binding.role),
566                    })?;
567            let new_val =
568                value
569                    .as_i64()
570                    .ok_or_else(|| ExecError::ConservationMalformed {
571                        entity: rule.entity.clone(),
572                        field: rule.field.clone(),
573                        message: format!("Set value at role `{}` is not i64", binding.role),
574                    })?;
575            let group_key = match &rule.group_by {
576                Some(g) => old_state
577                    .get(g)
578                    .and_then(Value::as_str)
579                    .unwrap_or("")
580                    .to_string(),
581                None => String::new(),
582            };
583            *delta_by_group.entry(group_key).or_insert(0) +=
584                (new_val as i128) - (old_val as i128);
585        }
586    }
587
588    for (group, total) in &delta_by_group {
589        if *total != 0 {
590            return Err(ExecError::ConservationViolation {
591                entity: rule.entity.clone(),
592                field: rule.field.clone(),
593                group_by: rule.group_by.clone().unwrap_or_else(|| "(global)".into()),
594                group: group.clone(),
595                total: *total,
596            });
597        }
598    }
599    Ok(())
600}
601
602#[cfg(test)]
603mod tests {
604    use super::*;
605
606    #[test]
607    fn normalize_strips_line_and_block_comments() {
608        let src = r#"
609// header comment
610let x = 1; // trailing
611/* block
612   spans lines */
613let y = 2;
614"#;
615        let normalized = normalize_rhai_source(src);
616        assert_eq!(normalized, "let x = 1; let y = 2;");
617    }
618
619    #[test]
620    fn normalize_collapses_whitespace_runs() {
621        let src = "let     a  =\t\t1;\n\n\n\nlet b   =   2;";
622        let normalized = normalize_rhai_source(src);
623        assert_eq!(normalized, "let a = 1; let b = 2;");
624    }
625
626    #[test]
627    fn normalize_preserves_strings_verbatim_including_double_spaces() {
628        // The double space, the // inside, and the escape are preserved
629        // exactly because they're inside a string literal — semantic
630        // content, not cosmetic.
631        let src = r#"let s = "hello  // not a comment \"world\"";"#;
632        let normalized = normalize_rhai_source(src);
633        assert_eq!(normalized, r#"let s = "hello  // not a comment \"world\"";"#);
634    }
635
636    #[test]
637    fn normalize_is_idempotent() {
638        let src = "// a\nlet x  =  1;\n";
639        let n1 = normalize_rhai_source(src);
640        let n2 = normalize_rhai_source(&n1);
641        assert_eq!(n1, n2);
642    }
643
644    #[test]
645    fn normalize_distinguishes_real_changes() {
646        // Adding a new statement is a non-cosmetic change — the
647        // normalized output must reflect it.
648        let a = "let x = 1;";
649        let b = "let x = 1; let y = 2;";
650        assert_ne!(normalize_rhai_source(a), normalize_rhai_source(b));
651
652        // Same for changing a literal value.
653        let c = "let x = 1;";
654        let d = "let x = 2;";
655        assert_ne!(normalize_rhai_source(c), normalize_rhai_source(d));
656    }
657
658    #[test]
659    fn normalize_handles_comment_at_end_without_newline() {
660        let src = "let x = 1; // no trailing newline";
661        let normalized = normalize_rhai_source(src);
662        assert_eq!(normalized, "let x = 1;");
663    }
664
665    #[test]
666    fn normalize_handles_unterminated_block_comment() {
667        // Defensive: if someone writes `/* ...` and forgets to close,
668        // we don't infinite-loop or panic. The trailing content is
669        // discarded, which is fine — Rhai won't parse this either.
670        let src = "let x = 1; /* never ends";
671        let normalized = normalize_rhai_source(src);
672        assert_eq!(normalized, "let x = 1;");
673    }
674}