tawasuyu

tawasuyu/brahman

Rust 569TOML 215Markdown 68JSON 198173 Sutichaykuna

⎇ b05de24c24ef1d1a510c1a5caf4e14b0471c142f crates/modules/nakui/core/src/executor.rs Rust · 690 siq'ikuna · 25.6 KB Ñawpa willay Pi imata llamkarqan ch'uya

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