tawasuyu

tawasuyu/brahman

Rust 326TOML 123JSON 17Markdown 144606 Sutichaykuna

⎇ d3649bfd1a80153652c2a028315369bb4b184024 crates/modules/nakui/core/src/manifest.rs Rust · 352 siq'ikuna · 12.1 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::{Deserialize, Serialize};
2use std::collections::{HashMap, HashSet};
3use std::path::Path;
4use thiserror::Error;
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct Manifest {
8    pub module: String,
9    /// Schema files that compose this module's KCL surface. Paths are
10    /// resolved relative to the module directory; cross-module references
11    /// use `"../other_module/schema.k"`. Defaults to `["schema.k"]` when
12    /// the field is absent — the single-file case.
13    #[serde(default)]
14    pub schemas: Vec<String>,
15    pub morphisms: Vec<MorphismSpec>,
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct MorphismSpec {
20    pub name: String,
21    pub inputs: Vec<MorphismInput>,
22    pub reads: Vec<String>,
23    pub writes: Vec<String>,
24    #[serde(default)]
25    pub invariants: Invariants,
26    #[serde(default)]
27    pub depends_on: Vec<String>,
28    pub script: String,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct MorphismInput {
33    pub role: String,
34    pub entity: String,
35}
36
37#[derive(Debug, Clone, Default, Serialize, Deserialize)]
38pub struct Invariants {
39    /// Sum-conservation rules. The total Δ of (entity, field) across the ops
40    /// produced by the morphism must be zero — optionally bucketed by another
41    /// field on the entity (e.g. group_by="currency" so USD and EUR are
42    /// independent ledgers).
43    #[serde(default)]
44    pub conserve: Vec<ConserveRule>,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct ConserveRule {
49    pub entity: String,
50    pub field: String,
51    #[serde(default)]
52    pub group_by: Option<String>,
53}
54
55#[derive(Debug, Error)]
56pub enum ManifestError {
57    #[error("io reading manifest: {0}")]
58    Io(#[from] std::io::Error),
59    #[error("parsing manifest json: {0}")]
60    Parse(#[from] serde_json::Error),
61}
62
63/// Errors raised by `Manifest::validate`. Each variant flags a specific
64/// semantic issue caught before the kernel ever runs the module — these
65/// are the contract between manifest authors (humans or AI) and Nakui.
66#[derive(Debug, Error)]
67pub enum ValidationError {
68    #[error("morphism name `{0}` declared more than once")]
69    DuplicateMorphism(String),
70    #[error("morphism `{morphism}`: input role `{role}` declared more than once")]
71    DuplicateRole { morphism: String, role: String },
72    #[error(
73        "morphism `{morphism}`: input entity `{entity}` is not declared in any schema file (known: {known:?})"
74    )]
75    InputUnknownEntity {
76        morphism: String,
77        entity: String,
78        known: Vec<String>,
79    },
80    #[error(
81        "morphism `{morphism}`: writes token `{token}` references unknown role `{role}` (declared roles: {roles:?})"
82    )]
83    WritesUnknownRole {
84        morphism: String,
85        token: String,
86        role: String,
87        roles: Vec<String>,
88    },
89    #[error(
90        "morphism `{morphism}`: writes token `{token}` is not a declared role.field nor a known entity name"
91    )]
92    WritesUnknownEntity { morphism: String, token: String },
93    #[error("morphism `{morphism}`: conserve rule references unknown entity `{entity}`")]
94    ConserveUnknownEntity { morphism: String, entity: String },
95    #[error("morphism `{morphism}`: depends_on `{dep}` does not name a morphism in this manifest")]
96    DependsOnUnknown { morphism: String, dep: String },
97    #[error("morphism `{morphism}`: script file `{script}` not found at {resolved}")]
98    ScriptMissing {
99        morphism: String,
100        script: String,
101        resolved: String,
102    },
103    #[error("schema file `{path}` declared in manifest does not exist at {resolved}")]
104    SchemaFileMissing { path: String, resolved: String },
105    #[error("schema name `{name}` is declared in multiple files: {files:?}")]
106    DuplicateSchema { name: String, files: Vec<String> },
107    #[error("io reading schema `{path}`: {source}")]
108    Io {
109        path: String,
110        #[source]
111        source: std::io::Error,
112    },
113}
114
115impl Manifest {
116    pub fn load(path: &Path) -> Result<Self, ManifestError> {
117        let text = std::fs::read_to_string(path)?;
118        let m: Self = serde_json::from_str(&text)?;
119        Ok(m)
120    }
121
122    pub fn morphism(&self, name: &str) -> Option<&MorphismSpec> {
123        self.morphisms.iter().find(|m| m.name == name)
124    }
125
126    /// Schema files this module exposes. Defaults to `["schema.ncl"]`
127    /// when the manifest doesn't declare any explicitly. Acepta
128    /// también legacy `.k` para no romper módulos no-migrados.
129    pub fn effective_schemas(&self) -> Vec<String> {
130        if self.schemas.is_empty() {
131            vec!["schema.ncl".to_string()]
132        } else {
133            self.schemas.clone()
134        }
135    }
136
137    /// Run all semantic checks. Catches author errors that would otherwise
138    /// surface as opaque runtime failures — misspelled entity names that
139    /// silently make conservation a no-op, role typos in writes that allow
140    /// any op through, unresolvable script paths, etc.
141    pub fn validate(&self, module_dir: &Path) -> Result<(), ValidationError> {
142        // 1. Resolve schemas: read each file, parse schema names, detect
143        //    cross-file duplicates. Build the set of known entity names.
144        let mut entity_to_files: HashMap<String, Vec<String>> = HashMap::new();
145        for s in self.effective_schemas() {
146            let resolved = module_dir.join(&s);
147            if !resolved.exists() {
148                return Err(ValidationError::SchemaFileMissing {
149                    path: s.clone(),
150                    resolved: resolved.display().to_string(),
151                });
152            }
153            let content = std::fs::read_to_string(&resolved).map_err(|e| {
154                ValidationError::Io {
155                    path: s.clone(),
156                    source: e,
157                }
158            })?;
159            for name in extract_schema_names(&content) {
160                entity_to_files.entry(name).or_default().push(s.clone());
161            }
162        }
163        for (name, files) in &entity_to_files {
164            if files.len() > 1 {
165                return Err(ValidationError::DuplicateSchema {
166                    name: name.clone(),
167                    files: files.clone(),
168                });
169            }
170        }
171        let known_entities: HashSet<&str> =
172            entity_to_files.keys().map(String::as_str).collect();
173
174        // 2. Manifest-level: morphism names must be unique.
175        let mut seen: HashSet<&str> = HashSet::new();
176        for m in &self.morphisms {
177            if !seen.insert(m.name.as_str()) {
178                return Err(ValidationError::DuplicateMorphism(m.name.clone()));
179            }
180        }
181        let known_morphisms: HashSet<&str> =
182            self.morphisms.iter().map(|m| m.name.as_str()).collect();
183
184        // 3. Per-morphism checks.
185        for m in &self.morphisms {
186            let mut roles: HashSet<&str> = HashSet::new();
187            for inp in &m.inputs {
188                if !roles.insert(inp.role.as_str()) {
189                    return Err(ValidationError::DuplicateRole {
190                        morphism: m.name.clone(),
191                        role: inp.role.clone(),
192                    });
193                }
194                if !known_entities.contains(inp.entity.as_str()) {
195                    return Err(ValidationError::InputUnknownEntity {
196                        morphism: m.name.clone(),
197                        entity: inp.entity.clone(),
198                        known: sorted(&known_entities),
199                    });
200                }
201            }
202
203            for token in &m.writes {
204                if let Some((role, _field)) = token.split_once('.') {
205                    if !roles.contains(role) {
206                        return Err(ValidationError::WritesUnknownRole {
207                            morphism: m.name.clone(),
208                            token: token.clone(),
209                            role: role.to_string(),
210                            roles: m.inputs.iter().map(|i| i.role.clone()).collect(),
211                        });
212                    }
213                } else if !known_entities.contains(token.as_str()) {
214                    return Err(ValidationError::WritesUnknownEntity {
215                        morphism: m.name.clone(),
216                        token: token.clone(),
217                    });
218                }
219            }
220
221            for rule in &m.invariants.conserve {
222                if !known_entities.contains(rule.entity.as_str()) {
223                    return Err(ValidationError::ConserveUnknownEntity {
224                        morphism: m.name.clone(),
225                        entity: rule.entity.clone(),
226                    });
227                }
228            }
229
230            for dep in &m.depends_on {
231                if !known_morphisms.contains(dep.as_str()) {
232                    return Err(ValidationError::DependsOnUnknown {
233                        morphism: m.name.clone(),
234                        dep: dep.clone(),
235                    });
236                }
237            }
238
239            let script_resolved = module_dir.join(&m.script);
240            if !script_resolved.exists() {
241                return Err(ValidationError::ScriptMissing {
242                    morphism: m.name.clone(),
243                    script: m.script.clone(),
244                    resolved: script_resolved.display().to_string(),
245                });
246            }
247        }
248
249        Ok(())
250    }
251}
252
253/// Cheap line-scan over a `.k` file to extract every `schema NAME` declared
254/// at column 0 (top-level). Tolerates inheritance (`schema X(Y):`) and
255/// generic params (`schema X[T]:`); ignores comments and string literals
256/// because top-level KCL syntax doesn't admit them ambiguously.
257/// Extrae los nombres de entities declarados en un schema Nickel.
258///
259/// Convención de los `schema.ncl` de Nakui: el archivo evalúa a un
260/// record top-level cuyas keys son los entity names (CapitalCase).
261/// Las helpers locales (`let positive_int = ...`) no matchean
262/// porque no están indentadas con 2 spaces ni empiezan con
263/// mayúscula.
264///
265/// Heurística (no parser completo): líneas con exactamente 2 spaces
266/// de indent + identifier CapitalCase + `=`. Robusto para los
267/// schemas actuales; si futuras convenciones requieren otro
268/// indent, flexibilizar acá.
269fn extract_schema_names(content: &str) -> Vec<String> {
270    let mut out = Vec::new();
271    for line in content.lines() {
272        let trimmed = line.trim_start_matches(' ');
273        let leading_spaces = line.len() - trimmed.len();
274        if leading_spaces != 2 {
275            continue;
276        }
277        let first = match trimmed.chars().next() {
278            Some(c) => c,
279            None => continue,
280        };
281        if !first.is_ascii_uppercase() {
282            continue;
283        }
284        let name: String = trimmed
285            .chars()
286            .take_while(|c| c.is_alphanumeric() || *c == '_')
287            .collect();
288        if name.is_empty() {
289            continue;
290        }
291        // Después del identifier debe venir `=` (eventualmente
292        // tras whitespace).
293        let after = &trimmed[name.len()..];
294        if !after.trim_start().starts_with('=') {
295            continue;
296        }
297        out.push(name);
298    }
299    out
300}
301
302fn sorted(set: &HashSet<&str>) -> Vec<String> {
303    let mut v: Vec<String> = set.iter().map(|s| s.to_string()).collect();
304    v.sort();
305    v
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311
312    #[test]
313    fn extract_schema_names_handles_nickel_record_top_level() {
314        let content = r#"
315let positive_int = std.contract.from_predicate (fun n => n > 0) in
316let currency_iso = std.contract.from_predicate (fun s => true) in
317
318{
319  Caja = {
320    id | String,
321    saldo | positive_int,
322  },
323
324  Movimiento = {
325    id | String,
326    monto | positive_int,
327  } | std.contract.from_predicate (fun r => true),
328
329  Transferencia = {
330    id | String,
331  },
332}
333"#;
334        let names = extract_schema_names(content);
335        assert_eq!(names, vec!["Caja", "Movimiento", "Transferencia"]);
336    }
337
338    #[test]
339    fn extract_schema_names_skips_let_bindings_and_lowercase() {
340        // `let x = ...` no debe aparecer; tampoco lowercase keys
341        // (no son entities por convención).
342        let content = r#"
343let positive_int = ... in
344{
345  Caja = { id | String },
346  helper = "not an entity",
347}
348"#;
349        let names = extract_schema_names(content);
350        assert_eq!(names, vec!["Caja"]);
351    }
352}