⎇ d3649bfd1a80153652c2a028315369bb4b184024
crates/modules/nakui/core/src/manifest.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
refactor(nakui-core): KCL → Nickel — kcl_wrapper reemplazado por evaluación in-process
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}