diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a6b332..81d1b3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,117 @@ ratio/diff ver `git show `. ## 2026-05-10 +### refactor(nakui-core): KCL → Nickel — `kcl_wrapper` reemplazado por evaluación in-process +Cierra el ciclo: el motor de validación de entities deja de +shellear el binario externo `kcl` y pasa a evaluar **Nickel +contracts** in-process via la dep `nickel-lang` (la misma que ya +usa `brahman-cards` para sus templates). Los 3 schemas de los +módulos sales/inventory/treasury migran de `.k` a `.ncl`. +Además se borran los 2 archivos `.k` doc-only del repo +(`ente-card/schema/card.k`, `ente-brain/schema/rule.k` — ambos +estaban marcados "REFERENCE ONLY. NOT LOADED"). + +Cambios en **nakui-core**: +- **Nueva dep**: `nickel-lang = "2.0.0"` (interfaz estable). +- **Borrado** `kcl_wrapper.rs` (43 líneas) — shellear el binario + desaparece. +- **Nuevo** `nickel_validator.rs`: + - `pub fn vet(schema_path, state, schema_name) -> Result<(), NickelError>` + evalúa `let bundle = (import "") in + (std.deserialize 'Json m%%""%%) | bundle.`. + - El state JSON va dentro de un raw string Nickel + (`m%%"..."%%`) y se deserialize via `std.deserialize 'Json`. + No embebemos el state como record literal Nickel directo + porque la sintaxis JSON usa `:` (Nickel records usan `=`). + - 5 tests propios cubriendo happy path + 4 fallure modes + (field missing, predicate fails, cross-field invariant + fails, optional field present/absent). +- **`executor.rs`**: + - `kcl_wrapper::vet` → `nickel_validator::vet`. + - `KclError` → `NickelError`. + - `ExecError::KclPre/KclPost/KclPostCreate` → `SchemaPre/Post/PostCreate` + (más neutro, ya no menciona KCL). + - `kcl_check` (privado) → `validate_entity`. + - `build_schema_bundle` ahora emite un archivo Nickel con + `(import "X") & (import "Y") & ...` en lugar de concatenar + bytes (cada `.ncl` es una expresión record completa, no + juntable como texto plano). +- **`manifest.rs`**: + - `effective_schemas` default `"schema.k"` → `"schema.ncl"`. + - `extract_schema_names` reescrito: ahora detecta keys + CapitalCase con 2 spaces de indent (convención de los + `schema.ncl`), no más patrón `schema X:` de KCL. + - Tests del extractor actualizados (1 test reemplazado por 2: + `_handles_nickel_record_top_level` + `_skips_let_bindings_and_lowercase`). + +Cambios en **schemas de módulos**: +- **`sales/schema.ncl`**: contracts Nickel para `Venta`. Usa + `std.contract.Sequence [record_contract, from_predicate]` + para combinar shape + invariante cross-field + (`total == cantidad * precio_unitario`). El patrón directo + `record | from_predicate` rebota con "missing definition" porque + el predicate evalúa el contract antes de que el value lo + populate; documentado en el comment. +- **`inventory/schema.ncl`**: `Stock`, `MovimientoStock`, + `TransferenciaStock` (esta última con cross-field + `source != dest` via Sequence). +- **`treasury/schema.ncl`**: `Caja`, `Movimiento`, + `Transferencia` (con cross-field via Sequence). +- Helpers locales en cada archivo: `positive_int`, + `non_negative_int`, `currency_iso`, etc. via + `std.contract.from_predicate`. +- Los 3 `schema.k` viejos **borrados**. +- `sales/nsmc.json` actualizado: paths `schema.k` → + `schema.ncl`. + +Cambios en **tests**: +- `sales.rs`, `inventory.rs`: `KclPost` → `SchemaPost`. +- `kernel_guards.rs`: `KclPostCreate` → `SchemaPostCreate`, + path del schema directo `treasury/schema.k` → + `treasury/schema.ncl`. +- `graph.rs`, `manifest_validation.rs`: tests que escriben + `schema.k` inline cambian a `schema.ncl` con sintaxis Nickel. +- `schema_versioning.rs`: refs `schema.k` → `schema.ncl`. + +Cambios documentales: +- **Borrado** `crates/core/ente-card/schema/card.k` (1 archivo, + REFERENCE ONLY documentado en su header). +- **Borrado** `crates/core/ente-brain/schema/rule.k` (REFERENCE + ONLY documentado en su header). + +Tests: +- **nakui-core**: 84 tests verdes (41 unit + 43 integration en + graph/event_log/manifest_validation/schema_versioning/ + inventory/sales/kernel_guards). Suite full pasa. +- **nakui-ui**, **brahman-cards**, **yahweh-***: sin cambios, + todos verdes. +- Total cubriendo el área: 174 tests. + +Beneficios: +- **Sin dep externa**: el binario `kcl` ya no es requisito de + runtime ni de tests. Build limpio en CI sin instalar KCL. +- **Errores en línea**: Nickel reporta contract violations con + caret pointing al field exacto del schema y el value que + falló. KCL daba mensajes textuales menos navegables. +- **Mismo motor que el brazo de cards**: una sola dependencia + Nickel para todo el repo (validación + templates de cards). +- **Sin tempfile JSON intermedio**: el state se evalúa + directamente en memoria; no hay `std::fs::write` por cada + validate. + +Limitaciones / decisiones: +- El comentario "REFERENCE ONLY" de los `.k` borrados ya estaba + marcado en sus headers; eran sólo notas de diseño para humanos. + La autoridad real (Rust validate methods) sigue intacta. +- La sintaxis Nickel `record_contract | from_predicate` no + funciona — hay que envolver en `std.contract.Sequence [record, + from_predicate]`. Documentado en cada schema y en el doc del + validator. + +**Pendientes restantes**: ninguno del refactor original. Los +yahweh + KCL + card.k cierran. Próximos pendientes salen de +nuevo trabajo (no del plan que arrastrábamos). + ### refactor(yahweh): Fase 2c — extracción del widget al crate `yahweh-widget-meta-form` Cierra el refactor de UI: el widget render (forms, lists, modal de delete, EntityRef selector, sidebar, key handlers) deja de vivir en diff --git a/Cargo.lock b/Cargo.lock index 44ef1e5..71a7ec2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6366,6 +6366,7 @@ version = "0.1.0" dependencies = [ "brahman-card", "brahman-sidecar", + "nickel-lang", "petgraph 0.6.5", "rhai", "serde", diff --git a/crates/core/ente-brain/schema/rule.k b/crates/core/ente-brain/schema/rule.k deleted file mode 100644 index 432cde5..0000000 --- a/crates/core/ente-brain/schema/rule.k +++ /dev/null @@ -1,167 +0,0 @@ -# ============================================================================ -# rule.k — REFERENCE ONLY. NOT LOADED. -# -# La gramática autoritativa de Rule vive en Rust: -# crates/ente-brain/src/rules.rs -# El loader (crates/ente-brain/src/loader.rs) sólo acepta JSON / JSONL. -# -# Conservado como notas de diseño humano-legibles del shape Rule: -# Triplet [Sujeto + Evento + Acción(Objeto)]. Cada regla es una sinapsis: -# cuando ocurre `when`, el motor ejecuta `then` para los Entes que cumplen -# `scope`. El motor las indexa por discriminante de EventKind para lookup -# en O(1). Las reglas son inmutables tras carga (Arc). -# -# Si cambias el shape en Rust, sincroniza este archivo a mano (o -# reemplázalo por JSON Schema generado vía `schemars`). -# ============================================================================ - -schema Rule: - """Una sinapsis del fractal. Determinista, sin estado entre disparos.""" - id: str # Ulid 26 chars - priority: int = 5 # 0..255, mayor = se ejecuta primero - when: EventPattern - then: [Action] - scope: Scope = Scope {} # qué Entes son sujetos válidos - - check: - len(id) == 26, "id debe ser Ulid" - priority >= 0 and priority <= 255, "priority fuera de rango" - len(then) > 0, "regla sin acciones" - - -# ---------- Subject: alcance del sujeto ---------- - -schema Scope: - """Match del sujeto. None en todos los campos = match cualquier Ente.""" - subject_id?: str # Ulid exacto - subject_label?: str # label exacto - subject_has_cap?: Capability # Ente que declara esta capacidad - - check: - subject_id is None or len(subject_id) == 26, "subject_id no es Ulid" - - -# ---------- Event: qué dispara la regla ---------- - -# EventPattern es tagged union recursivo. -# -# Atómicos: -# Single — match un evento por kind -# Sequence — N eventos consecutivos dentro de within_ms -# -# Compuestos (recursivos): -# Either — OR sobre sub-patterns -# All — AND sobre sub-patterns (mismo event/history) -schema EventPattern: - type: "Single" | "Sequence" | "Either" | "All" - kind?: EventKind # Single - kinds?: [EventKind] # Sequence - within_ms?: int = 0 - patterns?: [EventPattern] # Either / All (recursivo) - - check: - type != "Single" or kind is not None, "Single requiere kind" - type != "Sequence" or (kinds is not None and len(kinds) > 0), \ - "Sequence requiere kinds no vacío" - type != "Either" or (patterns is not None and len(patterns) > 0), \ - "Either requiere patterns no vacío" - type != "All" or (patterns is not None and len(patterns) > 0), \ - "All requiere patterns no vacío" - within_ms is None or within_ms >= 0, "within_ms negativo" - - -# EventKind con tag interno + payload opcional según tag. -schema EventKind: - tag: "EnteSpawned" | "EnteDied" | "BusAnnounce" | "BusInvoke" | "BusInvokeOf" | "DeviceAdded" | "DeviceRemoved" | "Custom" - cap?: Capability # para BusInvokeOf - custom?: str # para Custom - - check: - tag != "BusInvokeOf" or cap is not None, "BusInvokeOf requiere cap" - tag != "Custom" or custom is not None, "Custom requiere custom string" - - -# ---------- Action: qué hacer ---------- - -schema Action: - """Una acción ejecutable por el motor. Tagged union con kind.""" - kind: "Log" | "Notify" | "Spawn" | "Invoke" | "Inhibit" - # Log - level?: "trace" | "debug" | "info" | "warn" | "error" - message?: str - # Notify - target_id?: str # Ulid - # Spawn - card_blob?: str # base64-encoded EntityCard JSON - # Invoke - target_cap?: Capability - blob_b64?: str - # Inhibit - reason?: str - - check: - kind != "Log" or message is not None, "Log requiere message" - kind != "Notify" or (target_id is not None and message is not None), \ - "Notify requiere target_id + message" - kind != "Spawn" or card_blob is not None, "Spawn requiere card_blob" - kind != "Invoke" or target_cap is not None, "Invoke requiere target_cap" - kind != "Inhibit" or reason is not None, "Inhibit requiere reason" - - -# ---------- Capability: re-export desde card.k para evitar inclusión circular ---------- - -# En uso real: `import ..ente_card.schema.card` y referencia Capability. -# Aquí declaramos una versión alineada para auto-contención del esquema. -schema Capability: - kind: "FilesystemRoot" | "KernelNetlink" | "Endpoint" | "LegacyLogind" | "Device" | "Spawn" | "Journal" - netlink_family?: "Uevent" | "Route" | "Generic" | "Audit" - endpoint_interface?: str - endpoint_version?: int - device_class?: "Block" | "Tty" | "Input" | "Drm" | "Net" | "Hidraw" - - -# ============================================================================ -# Ejemplo de regla cristalizada (auto-generada por el observador) -# ============================================================================ - -example_rule = Rule { - id = "01KQQ100000000000000000000" - priority = 5 - when = EventPattern { - type = "Single" - kind = EventKind {tag = "EnteSpawned"} - } - scope = Scope { - subject_label = "demo-echo" - } - then = [ - Action { - kind = "Log" - level = "info" - message = "demo-echo encarnado, observando para crystallization" - } - ] -} - -# Ejemplo de regla compuesta: cuando un Ente se anuncia y luego es invocado -# en menos de 500ms, log estructurado para auditoría. -example_sequence = Rule { - id = "01KQQ200000000000000000000" - priority = 7 - when = EventPattern { - type = "Sequence" - kinds = [ - EventKind {tag = "BusAnnounce"} - EventKind {tag = "BusInvoke"} - ] - within_ms = 500 - } - scope = Scope {} - then = [ - Action { - kind = "Log" - level = "info" - message = "patrón Announce→Invoke detectado <500ms" - } - ] -} diff --git a/crates/core/ente-card/schema/card.k b/crates/core/ente-card/schema/card.k deleted file mode 100644 index 1c1232b..0000000 --- a/crates/core/ente-card/schema/card.k +++ /dev/null @@ -1,178 +0,0 @@ -# ============================================================================ -# card.k — REFERENCE ONLY. NOT LOADED. -# -# La validación canónica de EntityCard vive en Rust: -# crates/ente-card/src/lib.rs :: EntityCard::validate() -# El loader (crates/ente-brain/src/loader.rs) sólo acepta JSON. -# -# Este archivo se conserva como notas de diseño legibles para humanos sobre -# las invariantes que `validate()` debe garantizar. Si modificas el shape -# en Rust, sincroniza este archivo a mano (o reemplázalo por JSON Schema -# generado vía `schemars`). -# ============================================================================ - -# ---------- Identidad ---------- - -schema EntityCard: - """Tarjeta de Identidad. Inmutable: cambios = nueva Card con nuevo id.""" - schema_version: int = 1 - id: str # Ulid (26 chars, Crockford base32) - lineage?: str # parent Ulid; None = Ente raíz - label: str # legible, no es identificador - provides: [Capability] = [] # contrato hacia el grafo - requires: [Capability] = [] # contrato del grafo hacia el Ente - soma: SomaSpec # cuerpo: aislamiento + recursos - payload: Payload # cómo encarnar (Wasm/Native/Virtual) - supervision: Supervision # política tras muerte - genesis?: [EntityCard] = [] # hijos a instanciar al encarnar - - check: - schema_version == 1, "schema version no soportada" - len(label) > 0, "label vacío" - len(id) == 26, "id debe ser Ulid (26 caracteres)" - # Auto-dependencia: una capacidad no puede estar en requires y provides - all c in requires { c not in provides }, "self-dependency: ${c}" - - -# ---------- Capacidades (typed enum) ---------- - -# KCL no tiene sum types nativos; usamos tagged union: `kind` + campos opcionales -# que sólo aplican según el kind. Las invariantes en `check:` aseguran consistencia. -schema Capability: - """Capacidad tipada del fractal. NUNCA usar strings libres.""" - kind: "FilesystemRoot" | "KernelNetlink" | "Endpoint" | "LegacyLogind" | "Device" | "Spawn" | "Journal" - netlink_family?: "Uevent" | "Route" | "Generic" | "Audit" - endpoint_interface?: str # 32-char hex (UUID 16 bytes) - endpoint_version?: int - device_class?: "Block" | "Tty" | "Input" | "Drm" | "Net" | "Hidraw" - - check: - kind != "KernelNetlink" or netlink_family is not None, \ - "KernelNetlink requiere netlink_family" - kind != "Endpoint" or (endpoint_interface is not None and endpoint_version is not None), \ - "Endpoint requiere interface + version" - kind != "Endpoint" or len(endpoint_interface) == 32, \ - "endpoint_interface debe ser hex de 32 chars" - kind != "Device" or device_class is not None, \ - "Device requiere device_class" - - -# ---------- Soma: cuerpo + restricciones de recursos ---------- - -schema SomaSpec: - """Aislamiento + recursos. Validados por KCL antes de tocar el kernel.""" - namespaces: NamespaceSet = NamespaceSet {} - rlimits: ResourceLimits = ResourceLimits {} - cgroup: CgroupSpec = CgroupSpec {} - cpu_affinity?: [int] # CPU pinning - - check: - cpu_affinity is None or all c in cpu_affinity { c >= 0 and c < 1024 }, \ - "cpu_affinity fuera de rango [0, 1024)" - - -schema NamespaceSet: - mount: bool = False - pid: bool = False - net: bool = False - uts: bool = False - ipc: bool = False - user: bool = False - cgroup: bool = False - - -schema ResourceLimits: - """Restricciones nativas validadas en KCL — el kernel sólo ve valores sanos.""" - mem_bytes?: int # RLIMIT_AS - nproc?: int # RLIMIT_NPROC - nofile?: int # RLIMIT_NOFILE - energy_budget_mw?: int # presupuesto energético (futuro) - - check: - mem_bytes is None or mem_bytes > 0, "mem_bytes debe ser positivo" - mem_bytes is None or mem_bytes <= 1099511627776, "mem_bytes > 1 TiB sospechoso" - nproc is None or (nproc > 0 and nproc <= 65535), "nproc fuera de rango" - nofile is None or (nofile > 0 and nofile <= 1048576), "nofile fuera de rango" - energy_budget_mw is None or energy_budget_mw > 0, "energy_budget_mw debe ser positivo" - - -schema CgroupSpec: - """Cgroup v2: path + weights. cpu_weight 1..10000 según kernel docs.""" - path: str = "" - cpu_weight?: int - io_weight?: int - - check: - cpu_weight is None or (cpu_weight >= 1 and cpu_weight <= 10000), \ - "cpu_weight 1..10000" - io_weight is None or (io_weight >= 1 and io_weight <= 10000), \ - "io_weight 1..10000" - - -# ---------- Payload: tagged union de cómo encarnar ---------- - -schema Payload: - """Una variante por Card. Set exactly one of: Wasm, Native, Virtual, Legacy.""" - kind: "Wasm" | "Native" | "Virtual" | "Legacy" - # Wasm - module_sha256?: str # hex 64 chars - entry?: str - # Native / Legacy - exec?: str - argv?: [str] = [] - envp?: [{str: str}] = [] - # Legacy - fakes?: ["SystemdLogind" | "SystemdHostnamed" | "SystemdNotify"] = [] - - check: - kind != "Wasm" or (module_sha256 is not None and entry is not None), \ - "Wasm requiere module_sha256 + entry" - kind != "Wasm" or len(module_sha256) == 64, "module_sha256 debe ser hex de 64 chars" - kind != "Native" or exec is not None, "Native requiere exec" - kind != "Legacy" or exec is not None, "Legacy requiere exec" - - -# ---------- Supervision ---------- - -schema Supervision: - kind: "Restart" | "OneShot" | "Delegate" - initial_ms?: int # ms — backoff inicial para Restart - max_ms?: int # ms — backoff máximo - - check: - kind != "Restart" or (initial_ms is not None and max_ms is not None), \ - "Restart requiere initial_ms + max_ms" - initial_ms is None or initial_ms >= 0, "initial_ms negativo" - max_ms is None or max_ms >= initial_ms or max_ms is None, \ - "max_ms < initial_ms es contradictorio" - - -# ============================================================================ -# Herencia: EnteWeb hereda de EnteBase con campos pre-rellenados. -# ============================================================================ - -schema EnteBase(EntityCard): - """Base para Entes managed: declara Spawn provider y Journal por defecto.""" - schema_version = 1 - supervision = Supervision {kind = "Restart", initial_ms = 100, max_ms = 30000} - soma = SomaSpec { - rlimits = ResourceLimits {nofile = 4096} - cgroup = CgroupSpec {path = "ente.slice/managed", cpu_weight = 100} - } - - -schema EnteWeb(EnteBase): - """Hereda EnteBase, declara endpoint + cap LegacyLogind como ejemplo.""" - provides = [ - Capability {kind = "Journal"} - Capability { - kind = "Endpoint" - endpoint_interface = "deadbeefcafe1234deadbeefcafe1234" - endpoint_version = 1 - } - ] - soma = SomaSpec { - namespaces = NamespaceSet {net = True, mount = True, pid = True} - rlimits = ResourceLimits {nofile = 16384, mem_bytes = 536870912} # 512 MiB - cgroup = CgroupSpec {path = "ente.slice/web", cpu_weight = 200, io_weight = 100} - } diff --git a/crates/modules/nakui/core/Cargo.toml b/crates/modules/nakui/core/Cargo.toml index 06fde2e..ae8e541 100644 --- a/crates/modules/nakui/core/Cargo.toml +++ b/crates/modules/nakui/core/Cargo.toml @@ -31,6 +31,10 @@ uuid = { workspace = true, features = ["serde"] } # por lo que se mantienen inline (versión local). rhai = { version = "1.20", features = ["serde"] } petgraph = "0.6" +# Nickel reemplaza a KCL como motor de validación de entities. +# Evaluación in-process (sin shellear binarios), contracts Nickel +# nativos en los `schema.ncl` de cada módulo. +nickel-lang = "2.0.0" surrealdb = { version = "2", default-features = false, features = ["kv-mem"] } # Brahman protocol — presencia ante el Init cuando `nakui run` arranca. diff --git a/crates/modules/nakui/core/src/executor.rs b/crates/modules/nakui/core/src/executor.rs index cb3d453..1707832 100644 --- a/crates/modules/nakui/core/src/executor.rs +++ b/crates/modules/nakui/core/src/executor.rs @@ -7,7 +7,7 @@ use uuid::Uuid; use crate::delta::{FieldOp, simulate_on}; use crate::graph::{GraphError, ManifestGraph}; -use crate::kcl_wrapper::{self, KclError}; +use crate::nickel_validator::{self, NickelError}; use crate::manifest::{ConserveRule, Manifest, ManifestError, MorphismSpec, ValidationError}; use crate::rhai_executor::{RhaiError, RhaiExecutor}; use crate::store::{Store, StoreError}; @@ -50,26 +50,26 @@ pub enum ExecError { field: String, message: String, }, - #[error("kcl pre-check failed on `{role}` ({entity}): {source}")] - KclPre { + #[error("schema pre-check failed on `{role}` ({entity}): {source}")] + SchemaPre { role: String, entity: String, #[source] - source: KclError, + source: NickelError, }, - #[error("kcl post-check failed on `{role}` ({entity}): {source}")] - KclPost { + #[error("schema post-check failed on `{role}` ({entity}): {source}")] + SchemaPost { role: String, entity: String, #[source] - source: KclError, + source: NickelError, }, - #[error("kcl post-check failed on created {entity} {id}: {source}")] - KclPostCreate { + #[error("schema post-check failed on created {entity} {id}: {source}")] + SchemaPostCreate { entity: String, id: Uuid, #[source] - source: KclError, + source: NickelError, }, #[error("rhai: {0}")] Rhai(#[from] RhaiError), @@ -95,12 +95,13 @@ pub struct Executor { /// `load_module`; Drop removes it. `false` for inline-built executors /// that point at a real schema file owned by the caller (tests). pub owned_bundle: bool, - /// Per-morphism `schema_hash`: SHA-256 of (kcl bundle + manifest spec - /// + rhai script bytes), computed once at load. The hash is the - /// determinism contract for KCL evolution — `verify_log` uses it to - /// reject logs whose entries were produced under different rules. + /// Per-morphism `schema_hash`: SHA-256 of (Nickel bundle + manifest + /// spec + rhai script bytes), computed once at load. The hash es + /// el determinism contract para evolución de schemas — + /// `verify_log` lo usa para rechazar logs cuyos entries se + /// produjeron bajo reglas distintas. pub schema_hashes: HashMap, - /// Module-wide bundle hash: SHA-256 of just the KCL bundle bytes. + /// Module-wide bundle hash: SHA-256 de los bytes del bundle Nickel. /// Stamped onto every `LogEntry::Seed` via `seed_and_log` so /// `verify_log` can flag seeds whose entity schemas have evolved /// since they were logged. Coarser than `schema_hashes` (any @@ -252,8 +253,8 @@ impl Executor { let state = store .load(&spec_in.entity, id) .ok_or_else(|| ExecError::EntityMissing(spec_in.entity.clone(), id))?; - self.kcl_check(&spec_in.entity, &state) - .map_err(|e| ExecError::KclPre { + self.validate_entity(&spec_in.entity, &state) + .map_err(|e| ExecError::SchemaPre { role: spec_in.role.clone(), entity: spec_in.entity.clone(), source: e, @@ -327,8 +328,8 @@ impl Executor { if let Some(new_state) = simulate_on(&loaded[&spec_in.role], &spec_in.entity, id, &ops) { - self.kcl_check(&spec_in.entity, &new_state) - .map_err(|e| ExecError::KclPost { + self.validate_entity(&spec_in.entity, &new_state) + .map_err(|e| ExecError::SchemaPost { role: spec_in.role.clone(), entity: spec_in.entity.clone(), source: e, @@ -339,8 +340,8 @@ impl Executor { // 8. Validate every Created record against its entity schema. for op in &ops { if let FieldOp::Create { entity, id, data } = op { - self.kcl_check(entity, data) - .map_err(|e| ExecError::KclPostCreate { + self.validate_entity(entity, data) + .map_err(|e| ExecError::SchemaPostCreate { entity: entity.clone(), id: *id, source: e, @@ -367,26 +368,16 @@ impl Executor { Ok(ops) } - fn kcl_check(&self, entity: &str, state: &Value) -> Result<(), KclError> { - let tmp = std::env::temp_dir().join(format!("nakui_{}_{}.json", entity, Uuid::new_v4())); - std::fs::write(&tmp, serde_json::to_vec(state).expect("state serializes")) - .map_err(KclError::Io)?; - let result = kcl_wrapper::vet(&self.schema_path, &tmp, entity); - let _ = std::fs::remove_file(&tmp); - result + fn validate_entity(&self, entity: &str, state: &Value) -> Result<(), NickelError> { + nickel_validator::vet(&self.schema_path, state, entity) } } -/// Concatenate every declared `.k` file into a single bundle on disk. -/// `kcl vet` only takes one schema arg, so cross-module modules (e.g. sales -/// referencing both treasury and inventory entities) bundle their imports -/// at load time. The bundle lives in `temp_dir` for the lifetime of the -/// executor; one file per Executor instance. -/// Module-wide hash of just the KCL bundle bytes. Stamped on +/// Module-wide hash of the Nickel bundle bytes. Stamped on /// `LogEntry::Seed` entries (which don't run through any morphism, so /// `compute_morphism_schema_hash` doesn't apply). Bumped by any byte /// change in any schema file the manifest exposes — coarser than a -/// per-entity hash would be, but doesn't require KCL parsing. +/// per-entity hash would be, but doesn't require Nickel parsing. fn compute_schema_bundle_hash(schema_bundle_bytes: &[u8]) -> [u8; 32] { let mut hasher = Sha256::new(); hasher.update(b"nakui-bundle-v1\0"); @@ -514,18 +505,43 @@ pub fn normalize_rhai_source(src: &str) -> String { out } -fn build_schema_bundle(module_dir: &std::path::Path, schemas: &[String]) -> std::io::Result { - let mut combined = String::new(); +/// Construye un bundle Nickel: en lugar de concatenar contenidos +/// (cada `.ncl` es una expresión record completa, no juntable como +/// texto plano), emite un archivo que mergea via `&` los imports. +/// +/// El operador `&` de Nickel mergea records: si las keys son +/// distintas (que es lo esperado entre schemas de módulos distintos) +/// el resultado tiene la unión. Si hay colisión, Nickel rebota con +/// un error claro al evaluar — ya cubierto por `manifest::validate` +/// que chequea duplicados antes de llegar acá. +/// +/// Verifica que cada path exista para fallar early con I/O error. +/// El path en el `import "..."` queda absoluto (resuelto desde +/// `module_dir`) para que el evaluator lo encuentre desde el +/// tempdir. +fn build_schema_bundle( + module_dir: &std::path::Path, + schemas: &[String], +) -> std::io::Result { + let mut imports: Vec = Vec::with_capacity(schemas.len()); for s in schemas { let p = module_dir.join(s); - let content = std::fs::read_to_string(&p)?; - combined.push_str("# --- "); - combined.push_str(p.to_string_lossy().as_ref()); - combined.push_str(" ---\n"); - combined.push_str(&content); - combined.push_str("\n\n"); + // Verificar existencia + canonicalize para path absoluto + // estable (evita que cwd movimiento rompa el bundle). + let abs = std::fs::canonicalize(&p)?; + let abs_str = abs.display().to_string(); + let escaped = abs_str.replace('\\', "\\\\").replace('"', "\\\""); + imports.push(format!("(import \"{escaped}\")")); } - let bundle = std::env::temp_dir().join(format!("nakui_schema_{}.k", Uuid::new_v4())); + let combined = if imports.is_empty() { + // Bundle vacío = record vacío. Cualquier validación contra + // un entity rebota con "field not found" — comportamiento + // razonable para un módulo sin schemas declarados. + "{}".to_string() + } else { + imports.join(" & ") + }; + let bundle = std::env::temp_dir().join(format!("nakui_schema_{}.ncl", Uuid::new_v4())); std::fs::write(&bundle, combined)?; Ok(bundle) } diff --git a/crates/modules/nakui/core/src/kcl_wrapper.rs b/crates/modules/nakui/core/src/kcl_wrapper.rs deleted file mode 100644 index 94e890a..0000000 --- a/crates/modules/nakui/core/src/kcl_wrapper.rs +++ /dev/null @@ -1,43 +0,0 @@ -use std::path::Path; -use std::process::Command; -use thiserror::Error; - -#[derive(Debug, Error)] -pub enum KclError { - #[error("kcl binary not found on PATH (install: https://kcl-lang.io)")] - BinaryMissing, - #[error("kcl validation failed:\n{0}")] - ValidationFailed(String), - #[error("io invoking kcl: {0}")] - Io(#[from] std::io::Error), -} - -/// Validate `state_path` (json) against a schema defined in `schema_path` (.k), -/// targeting the named schema. -pub fn vet(schema_path: &Path, state_path: &Path, schema_name: &str) -> Result<(), KclError> { - let out = match Command::new("kcl") - .arg("vet") - .arg(state_path) - .arg(schema_path) - .arg("-s") - .arg(schema_name) - .output() - { - Ok(o) => o, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Err(KclError::BinaryMissing), - Err(e) => return Err(e.into()), - }; - - if out.status.success() { - Ok(()) - } else { - let stderr = String::from_utf8_lossy(&out.stderr).into_owned(); - let stdout = String::from_utf8_lossy(&out.stdout).into_owned(); - let msg = if stderr.trim().is_empty() { - stdout - } else { - stderr - }; - Err(KclError::ValidationFailed(msg)) - } -} diff --git a/crates/modules/nakui/core/src/lib.rs b/crates/modules/nakui/core/src/lib.rs index e2353de..9a7ea51 100644 --- a/crates/modules/nakui/core/src/lib.rs +++ b/crates/modules/nakui/core/src/lib.rs @@ -3,8 +3,8 @@ pub mod drift; pub mod event_log; pub mod executor; pub mod graph; -pub mod kcl_wrapper; pub mod manifest; +pub mod nickel_validator; pub mod rhai_executor; pub mod run; pub mod store; diff --git a/crates/modules/nakui/core/src/manifest.rs b/crates/modules/nakui/core/src/manifest.rs index dda56e6..9ec065f 100644 --- a/crates/modules/nakui/core/src/manifest.rs +++ b/crates/modules/nakui/core/src/manifest.rs @@ -123,11 +123,12 @@ impl Manifest { self.morphisms.iter().find(|m| m.name == name) } - /// Schema files this module exposes. Defaults to `["schema.k"]` when - /// the manifest doesn't declare any explicitly. + /// Schema files this module exposes. Defaults to `["schema.ncl"]` + /// when the manifest doesn't declare any explicitly. Acepta + /// también legacy `.k` para no romper módulos no-migrados. pub fn effective_schemas(&self) -> Vec { if self.schemas.is_empty() { - vec!["schema.k".to_string()] + vec!["schema.ncl".to_string()] } else { self.schemas.clone() } @@ -253,20 +254,47 @@ impl Manifest { /// at column 0 (top-level). Tolerates inheritance (`schema X(Y):`) and /// generic params (`schema X[T]:`); ignores comments and string literals /// because top-level KCL syntax doesn't admit them ambiguously. +/// Extrae los nombres de entities declarados en un schema Nickel. +/// +/// Convención de los `schema.ncl` de Nakui: el archivo evalúa a un +/// record top-level cuyas keys son los entity names (CapitalCase). +/// Las helpers locales (`let positive_int = ...`) no matchean +/// porque no están indentadas con 2 spaces ni empiezan con +/// mayúscula. +/// +/// Heurística (no parser completo): líneas con exactamente 2 spaces +/// de indent + identifier CapitalCase + `=`. Robusto para los +/// schemas actuales; si futuras convenciones requieren otro +/// indent, flexibilizar acá. fn extract_schema_names(content: &str) -> Vec { let mut out = Vec::new(); for line in content.lines() { - // Top-level declarations are not indented in idiomatic KCL. - if line.starts_with("schema ") { - let after = &line["schema ".len()..]; - let name: String = after - .chars() - .take_while(|c| c.is_alphanumeric() || *c == '_') - .collect(); - if !name.is_empty() { - out.push(name); - } + let trimmed = line.trim_start_matches(' '); + let leading_spaces = line.len() - trimmed.len(); + if leading_spaces != 2 { + continue; } + let first = match trimmed.chars().next() { + Some(c) => c, + None => continue, + }; + if !first.is_ascii_uppercase() { + continue; + } + let name: String = trimmed + .chars() + .take_while(|c| c.is_alphanumeric() || *c == '_') + .collect(); + if name.is_empty() { + continue; + } + // Después del identifier debe venir `=` (eventualmente + // tras whitespace). + let after = &trimmed[name.len()..]; + if !after.trim_start().starts_with('=') { + continue; + } + out.push(name); } out } @@ -282,25 +310,43 @@ mod tests { use super::*; #[test] - fn extract_schema_names_handles_basic_forms() { + fn extract_schema_names_handles_nickel_record_top_level() { let content = r#" -schema Caja: - saldo: int +let positive_int = std.contract.from_predicate (fun n => n > 0) in +let currency_iso = std.contract.from_predicate (fun s => true) in -schema Movimiento(Base): - monto: int +{ + Caja = { + id | String, + saldo | positive_int, + }, -# schema Comentario: -schema Generic[T]: - inner: T + Movimiento = { + id | String, + monto | positive_int, + } | std.contract.from_predicate (fun r => true), -schema _Underscore: - x: int + Transferencia = { + id | String, + }, +} "#; let names = extract_schema_names(content); - assert_eq!( - names, - vec!["Caja", "Movimiento", "Generic", "_Underscore"] - ); + assert_eq!(names, vec!["Caja", "Movimiento", "Transferencia"]); + } + + #[test] + fn extract_schema_names_skips_let_bindings_and_lowercase() { + // `let x = ...` no debe aparecer; tampoco lowercase keys + // (no son entities por convención). + let content = r#" +let positive_int = ... in +{ + Caja = { id | String }, + helper = "not an entity", +} +"#; + let names = extract_schema_names(content); + assert_eq!(names, vec!["Caja"]); } } diff --git a/crates/modules/nakui/core/src/nickel_validator.rs b/crates/modules/nakui/core/src/nickel_validator.rs new file mode 100644 index 0000000..06fcec0 --- /dev/null +++ b/crates/modules/nakui/core/src/nickel_validator.rs @@ -0,0 +1,255 @@ +//! Validador de entities via Nickel contracts (reemplaza el viejo +//! `kcl_wrapper` que shellea el binario `kcl`). Evaluación +//! in-process via `nickel-lang` 2.0. +//! +//! El bundle del módulo (concatenación de los `schema.ncl` que el +//! manifest declara) define un record con un field por entity. Para +//! validar un value V contra el entity E, evaluamos: +//! +//! ```nickel +//! let bundle = (import ".ncl") in (V | bundle.E) +//! ``` +//! +//! Si Nickel evalúa OK, V cumple el contract. Si rebota con +//! `BlameError` (contract violation), devolvemos +//! `NickelError::ValidationFailed` con el mensaje formateado. +//! +//! El bundle path es exactamente el archivo `.ncl` que arma +//! `Executor::load_module` en tempdir; el snapshot bytes que +//! computa el hash es el mismo archivo, así el `schema_bundle_hash` +//! sigue siendo determinista. + +use std::path::Path; + +use serde_json::Value; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum NickelError { + #[error("nickel validation failed:\n{0}")] + ValidationFailed(String), + #[error("io durante eval Nickel: {0}")] + Io(#[from] std::io::Error), + #[error("serializar state a Nickel literal: {0}")] + Serialize(#[from] serde_json::Error), +} + +/// Valida `state` contra el entity `schema_name` declarado en el +/// bundle Nickel `schema_path`. Devuelve `Ok(())` si el contract +/// pasa, `Err(ValidationFailed(msg))` si rebota. +/// +/// El nombre `vet` se preserva por compat con call sites del +/// executor (ex `kcl_wrapper::vet`). +pub fn vet(schema_path: &Path, state: &Value, schema_name: &str) -> Result<(), NickelError> { + // El state se inyecta como JSON literal y Nickel lo deserializa + // con `std.deserialize 'Json`. NO embebemos el state como + // record literal Nickel directo: la sintaxis JSON usa `:` (que + // Nickel no acepta dentro de records — usa `=`), y los keys + // quoted serían parseados como contracts en posición pre-`|`. + // + // El JSON va dentro de un raw string Nickel `m%%"..."%%`. JSON + // no contiene `"%%` literal (no hay forma de generarlo desde + // serde_json), así que el delimiter es seguro sin más + // escaping. + let state_json = serde_json::to_string(state)?; + let schema_path_str = schema_path.display().to_string(); + let schema_path_escaped = schema_path_str.replace('\\', "\\\\").replace('"', "\\\""); + + let source = format!( + "let bundle = (import \"{schema_path_escaped}\") in\n\ + (std.deserialize 'Json m%%\"{state_json}\"%%) | bundle.{schema_name}" + ); + + let mut ctx = nickel_lang::Context::new() + .with_source_name(format!("nakui-validate-{schema_name}")); + + match ctx.eval_deep_for_export(&source) { + Ok(_) => Ok(()), + Err(e) => Err(NickelError::ValidationFailed(format_nickel_error(&e))), + } +} + +fn format_nickel_error(err: &nickel_lang::Error) -> String { + let mut buf: Vec = Vec::new(); + if err + .format(&mut buf, nickel_lang::ErrorFormat::Text) + .is_err() + { + return format!("{err:?}"); + } + String::from_utf8(buf).unwrap_or_else(|_| format!("{err:?}")) +} + +#[cfg(test)] +mod tests { + //! Tests del validador via fixtures inline (write a tempfile, + //! evaluar). Cobertura del happy path + un par de + //! contract-violation cases. + use super::*; + use serde_json::json; + + fn write_schema(content: &str) -> std::path::PathBuf { + let p = std::env::temp_dir().join(format!( + "nakui-test-schema-{}-{}.ncl", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::write(&p, content).unwrap(); + p + } + + #[test] + fn vet_passes_when_state_satisfies_contract() { + let schema = write_schema( + r#" + { + Stock = { + id | String, + cantidad | std.contract.from_predicate (fun n => std.is_number n && n >= 0), + }, + } + "#, + ); + let state = json!({"id": "abc", "cantidad": 5}); + vet(&schema, &state, "Stock").unwrap(); + let _ = std::fs::remove_file(&schema); + } + + #[test] + fn vet_rejects_when_field_missing() { + let schema = write_schema( + r#" + { + Stock = { id | String, cantidad | Number }, + } + "#, + ); + let state = json!({"id": "abc"}); // falta cantidad + let err = vet(&schema, &state, "Stock").unwrap_err(); + assert!(matches!(err, NickelError::ValidationFailed(_))); + let NickelError::ValidationFailed(msg) = err else { panic!() }; + assert!( + msg.to_lowercase().contains("cantidad") || msg.to_lowercase().contains("missing"), + "msg debe mencionar el field missing: {msg}" + ); + let _ = std::fs::remove_file(&schema); + } + + #[test] + fn vet_rejects_when_predicate_fails() { + let schema = write_schema( + r#" + { + Stock = { + id | String, + cantidad | std.contract.from_predicate (fun n => std.is_number n && n >= 0), + }, + } + "#, + ); + let state = json!({"id": "abc", "cantidad": -1}); + let err = vet(&schema, &state, "Stock").unwrap_err(); + assert!(matches!(err, NickelError::ValidationFailed(_))); + let _ = std::fs::remove_file(&schema); + } + + /// Repro EXACTO del shape Transferencia del módulo treasury, + /// incluyendo el predicate cross-field. Reproduce el mismo + /// flujo que el rhai script emite. + #[test] + fn vet_transferencia_real_shape_passes() { + let schema = write_schema( + r#" + let positive_int = std.contract.from_predicate (fun n => std.is_number n && n > 0) in + let currency_iso = std.contract.from_predicate (fun s => std.is_string s && std.string.length s == 3) in + { + Transferencia = std.contract.Sequence [ + { + id | String, + source_caja_id | String, + dest_caja_id | String, + monto | positive_int, + currency | currency_iso, + timestamp | String, + memo | String | optional, + }, + std.contract.from_predicate (fun r => + r.source_caja_id != r.dest_caja_id + ), + ], + } + "#, + ); + let state = json!({ + "currency": "USD", + "dest_caja_id": "8c0b58aa", + "id": "bb34ae84", + "memo": "xf", + "monto": 75000, + "source_caja_id": "233f780f", + "timestamp": "2026-05-04T10:30:00Z" + }); + vet(&schema, &state, "Transferencia").unwrap(); + let _ = std::fs::remove_file(&schema); + } + + /// Repro del issue de la migración: Transferencia con + /// múltiples fields requeridos + uno optional. El contract + /// debería pasar si todos los required están presentes. + #[test] + fn vet_passes_with_optional_field_present_or_absent() { + let schema = write_schema( + r#" + { + Transferencia = { + id | String, + source_caja_id | String, + dest_caja_id | String, + memo | String | optional, + }, + } + "#, + ); + // Con memo presente. + let state = json!({ + "id": "t1", + "source_caja_id": "c1", + "dest_caja_id": "c2", + "memo": "x" + }); + vet(&schema, &state, "Transferencia").unwrap(); + // Sin memo (opcional). + let state2 = json!({ + "id": "t2", + "source_caja_id": "c1", + "dest_caja_id": "c2" + }); + vet(&schema, &state2, "Transferencia").unwrap(); + let _ = std::fs::remove_file(&schema); + } + + #[test] + fn vet_rejects_when_cross_field_invariant_fails() { + let schema = write_schema( + r#" + { + Venta = { + cantidad | Number, + precio_unitario | Number, + total | Number, + } | std.contract.from_predicate (fun r => + r.total == r.cantidad * r.precio_unitario + ), + } + "#, + ); + // total mal calculado: 5 * 200 = 1000, no 999. + let state = json!({"cantidad": 5, "precio_unitario": 200, "total": 999}); + let err = vet(&schema, &state, "Venta").unwrap_err(); + assert!(matches!(err, NickelError::ValidationFailed(_))); + let _ = std::fs::remove_file(&schema); + } +} diff --git a/crates/modules/nakui/core/tests/graph.rs b/crates/modules/nakui/core/tests/graph.rs index da676a0..079cf05 100644 --- a/crates/modules/nakui/core/tests/graph.rs +++ b/crates/modules/nakui/core/tests/graph.rs @@ -219,8 +219,9 @@ fn executor_load_module_rejects_cyclic_manifest() { let tmp = std::env::temp_dir().join(format!("nakui_cycle_{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(tmp.join("morphisms")).unwrap(); std::fs::write( - tmp.join("schema.k"), - "schema Caja:\n saldo: int\n check:\n saldo >= 0\n", + tmp.join("schema.ncl"), + // Schema Nickel mínimo (top-level Caja con saldo >= 0). + "{\n Caja = {\n saldo | std.contract.from_predicate (fun n => std.is_number n && n >= 0),\n },\n}\n", ) .unwrap(); std::fs::write(tmp.join("morphisms/op.rhai"), "[]").unwrap(); diff --git a/crates/modules/nakui/core/tests/inventory.rs b/crates/modules/nakui/core/tests/inventory.rs index 96d4d26..f5a61f2 100644 --- a/crates/modules/nakui/core/tests/inventory.rs +++ b/crates/modules/nakui/core/tests/inventory.rs @@ -132,11 +132,11 @@ fn overdraw_transfer_blocked_by_kcl_post_check() { ); match result { - Err(ExecError::KclPost { role, entity, .. }) => { + Err(ExecError::SchemaPost { role, entity, .. }) => { assert_eq!(role, "source"); assert_eq!(entity, "Stock"); } - other => panic!("expected KclPost on source, got {:?}", other), + other => panic!("expected SchemaPost on source, got {:?}", other), } assert_eq!(cantidad(&store, a), 100); assert_eq!(cantidad(&store, b), 0); diff --git a/crates/modules/nakui/core/tests/kernel_guards.rs b/crates/modules/nakui/core/tests/kernel_guards.rs index 7f4f876..e2930d5 100644 --- a/crates/modules/nakui/core/tests/kernel_guards.rs +++ b/crates/modules/nakui/core/tests/kernel_guards.rs @@ -7,7 +7,7 @@ //! Layers exercised (in pipeline order): //! 1. CapabilityViolation (untracked write) //! 2. ConservationViolation (delta sum != 0) -//! 3. KclPostCreate (created record fails its schema) +//! 3. SchemaPostCreate (created record fails its schema) use std::path::{Path, PathBuf}; @@ -47,7 +47,7 @@ fn build_executor(spec: MorphismSpec) -> Executor { // schema_path stays on the real treasury schema so we exercise the // production check blocks. `owned_bundle: false` so Drop leaves it // alone — it belongs to the source tree. - schema_path: workspace_root().join("modules/treasury/schema.k"), + schema_path: workspace_root().join("modules/treasury/schema.ncl"), rhai: RhaiExecutor::new_sandboxed(), owned_bundle: false, // Inline-built executors don't go through `load_module`, so they @@ -282,10 +282,10 @@ fn bad_created_record_blocks_negative_movimiento() { let result = exec.run(&mut store, "evil_create", &[("caja", caja_id)], params); match result { - Err(ExecError::KclPostCreate { entity, .. }) => { + Err(ExecError::SchemaPostCreate { entity, .. }) => { assert_eq!(entity, "Movimiento"); } - other => panic!("expected KclPostCreate, got {:?}", other), + other => panic!("expected SchemaPostCreate, got {:?}", other), } // Caja unchanged, Movimiento never landed. diff --git a/crates/modules/nakui/core/tests/manifest_validation.rs b/crates/modules/nakui/core/tests/manifest_validation.rs index 2b4b1bd..76769d3 100644 --- a/crates/modules/nakui/core/tests/manifest_validation.rs +++ b/crates/modules/nakui/core/tests/manifest_validation.rs @@ -195,25 +195,26 @@ fn rejects_missing_schema_file() { #[test] fn rejects_duplicate_schema_across_files() { - // Synthesize a tempdir with two .k files that both declare `schema X`. + // Synthesize a tempdir with two .ncl files that both declare + // `Caja` en el record top-level. let tmp = std::env::temp_dir().join(format!("nakui_dup_{}", Uuid::new_v4())); fs::create_dir_all(&tmp).unwrap(); fs::create_dir_all(tmp.join("morphisms")).unwrap(); fs::write( - tmp.join("a.k"), - "schema Caja:\n saldo: int\n check:\n saldo >= 0\n", + tmp.join("a.ncl"), + "{\n Caja = { saldo | Number },\n}\n", ) .unwrap(); fs::write( - tmp.join("b.k"), - "schema Caja:\n monto: int\n check:\n monto >= 0\n", + tmp.join("b.ncl"), + "{\n Caja = { monto | Number },\n}\n", ) .unwrap(); fs::write(tmp.join("morphisms/op.rhai"), "[]").unwrap(); let m = Manifest { module: "dup".into(), - schemas: vec!["a.k".into(), "b.k".into()], + schemas: vec!["a.ncl".into(), "b.ncl".into()], morphisms: vec![MorphismSpec { name: "op".into(), inputs: vec![MorphismInput { @@ -231,8 +232,8 @@ fn rejects_duplicate_schema_across_files() { match m.validate(&tmp) { Err(ValidationError::DuplicateSchema { name, files }) => { assert_eq!(name, "Caja"); - assert!(files.contains(&"a.k".to_string())); - assert!(files.contains(&"b.k".to_string())); + assert!(files.contains(&"a.ncl".to_string())); + assert!(files.contains(&"b.ncl".to_string())); } other => panic!("expected DuplicateSchema, got {:?}", other), } @@ -247,8 +248,8 @@ fn executor_load_module_runs_validation() { let tmp = std::env::temp_dir().join(format!("nakui_bad_{}", Uuid::new_v4())); fs::create_dir_all(&tmp).unwrap(); fs::write( - tmp.join("schema.k"), - "schema Caja:\n saldo: int\n check:\n saldo >= 0\n", + tmp.join("schema.ncl"), + "{\n Caja = {\n saldo | std.contract.from_predicate (fun n => std.is_number n && n >= 0),\n },\n}\n", ) .unwrap(); fs::write( diff --git a/crates/modules/nakui/core/tests/sales.rs b/crates/modules/nakui/core/tests/sales.rs index a9e5e49..14b402d 100644 --- a/crates/modules/nakui/core/tests/sales.rs +++ b/crates/modules/nakui/core/tests/sales.rs @@ -120,11 +120,11 @@ fn overdraw_stock_rejected_by_inventory_post_check() { ); match result { - Err(ExecError::KclPost { role, entity, .. }) => { + Err(ExecError::SchemaPost { role, entity, .. }) => { assert_eq!(role, "stock"); assert_eq!(entity, "Stock"); } - other => panic!("expected KclPost on stock, got {:?}", other), + other => panic!("expected SchemaPost on stock, got {:?}", other), } assert_eq!(stock_cantidad(&store, stock), 500); assert_eq!(caja_saldo(&store, caja), 1_000_000); diff --git a/crates/modules/nakui/core/tests/schema_versioning.rs b/crates/modules/nakui/core/tests/schema_versioning.rs index b8a5542..d8f478b 100644 --- a/crates/modules/nakui/core/tests/schema_versioning.rs +++ b/crates/modules/nakui/core/tests/schema_versioning.rs @@ -348,10 +348,10 @@ fn verify_log_rejects_seed_after_schema_kcl_changes() { seed_caja(&exec, &mut store, &mut log, id); } - // Mutate schema.k. Even a comment is enough — bundle hash is byte- + // Mutate schema.ncl. Even a comment is enough — bundle hash is byte- // level for the same false-positive-over-false-negative reason as // morphism hashes. - let schema_path = temp.path.join("schema.k"); + let schema_path = temp.path.join("schema.ncl"); let original = std::fs::read_to_string(&schema_path).expect("read schema"); std::fs::write( &schema_path, @@ -361,7 +361,7 @@ fn verify_log_rejects_seed_after_schema_kcl_changes() { let exec2 = Executor::load_module(&temp.path).expect("reload v2"); let new_hash = exec2.schema_bundle_hash; - assert_ne!(original_hash, new_hash, "schema.k byte change must move the bundle hash"); + assert_ne!(original_hash, new_hash, "schema.ncl byte change must move the bundle hash"); let log = EventLog::open(&log_path).unwrap(); match verify_log(&log, &exec2) { @@ -410,13 +410,13 @@ fn comment_only_edits_do_not_invalidate_the_hash() { "comment-only and whitespace-only edits must not move the hash" ); - // Sanity: the bundle hash also stays intact (we didn't touch schema.k). + // Sanity: the bundle hash also stays intact (we didn't touch schema.ncl). assert_eq!(exec1.schema_bundle_hash, exec2.schema_bundle_hash); } #[test] fn morphism_script_change_does_not_flag_unrelated_seeds() { - // Bundle hash covers schema.k only — a .rhai edit moves the + // Bundle hash covers schema.ncl only — a .rhai edit moves the // morphism hash but leaves the bundle hash alone. So existing // seeds verify cleanly even when a morphism's behaviour changed. let temp = TempModule::from(&treasury_module()); diff --git a/crates/modules/nakui/modules/inventory/schema.k b/crates/modules/nakui/modules/inventory/schema.k deleted file mode 100644 index 0dbf2c7..0000000 --- a/crates/modules/nakui/modules/inventory/schema.k +++ /dev/null @@ -1,34 +0,0 @@ -schema Stock: - id: str - sku_id: str - ubicacion: str - cantidad: int - - check: - cantidad >= 0, "stock no puede ser negativo" - len(ubicacion) > 0, "ubicacion requerida" - len(sku_id) > 0, "sku_id requerido" - -schema MovimientoStock: - id: str - stock_id: str - delta: int - razon: str - timestamp: str - - check: - razon in ["recepcion", "despacho", "ajuste"], "razon invalida" - delta != 0, "delta no puede ser cero" - -schema TransferenciaStock: - id: str - source_stock_id: str - dest_stock_id: str - sku_id: str - cantidad: int - timestamp: str - - check: - cantidad > 0, "cantidad debe ser positiva" - source_stock_id != dest_stock_id, "source y dest no pueden ser el mismo stock" - len(sku_id) > 0, "sku_id requerido" diff --git a/crates/modules/nakui/modules/inventory/schema.ncl b/crates/modules/nakui/modules/inventory/schema.ncl new file mode 100644 index 0000000..f6c56e6 --- /dev/null +++ b/crates/modules/nakui/modules/inventory/schema.ncl @@ -0,0 +1,55 @@ +# Schema Nickel para `inventory` (Stock + MovimientoStock + TransferenciaStock). + +let positive_int = std.contract.from_predicate (fun n => + std.is_number n && n > 0 +) in + +let non_negative_int = std.contract.from_predicate (fun n => + std.is_number n && n >= 0 +) in + +let non_empty_string = std.contract.from_predicate (fun s => + std.is_string s && std.string.length s > 0 +) in + +let razon_movimiento = std.contract.from_predicate (fun s => + std.is_string s && std.array.elem s ["recepcion", "despacho", "ajuste"] +) in + +let non_zero_int = std.contract.from_predicate (fun n => + std.is_number n && n != 0 +) in + +{ + Stock = { + id | String, + sku_id | non_empty_string, + ubicacion | non_empty_string, + cantidad | non_negative_int, + }, + + MovimientoStock = { + id | String, + stock_id | String, + delta | non_zero_int, + razon | razon_movimiento, + timestamp | String, + }, + + # Patrón obligatorio para record + cross-field predicate: chain + # via Sequence (aplicar `from_predicate` directo al record rebota + # con "missing definition"). + TransferenciaStock = std.contract.Sequence [ + { + id | String, + source_stock_id | String, + dest_stock_id | String, + sku_id | non_empty_string, + cantidad | positive_int, + timestamp | String, + }, + std.contract.from_predicate (fun r => + r.source_stock_id != r.dest_stock_id + ), + ], +} diff --git a/crates/modules/nakui/modules/sales/nsmc.json b/crates/modules/nakui/modules/sales/nsmc.json index 438c476..3b77cc1 100644 --- a/crates/modules/nakui/modules/sales/nsmc.json +++ b/crates/modules/nakui/modules/sales/nsmc.json @@ -1,9 +1,9 @@ { "module": "sales", "schemas": [ - "schema.k", - "../treasury/schema.k", - "../inventory/schema.k" + "schema.ncl", + "../treasury/schema.ncl", + "../inventory/schema.ncl" ], "morphisms": [ { diff --git a/crates/modules/nakui/modules/sales/schema.k b/crates/modules/nakui/modules/sales/schema.k deleted file mode 100644 index a55ccef..0000000 --- a/crates/modules/nakui/modules/sales/schema.k +++ /dev/null @@ -1,16 +0,0 @@ -schema Venta: - id: str - stock_id: str - caja_id: str - sku_id: str - cantidad: int - precio_unitario: int - currency: str - total: int - timestamp: str - - check: - cantidad > 0, "cantidad positiva" - precio_unitario > 0, "precio_unitario positivo" - len(currency) == 3, "currency ISO 4217" - total == cantidad * precio_unitario, "total debe ser cantidad * precio_unitario" diff --git a/crates/modules/nakui/modules/sales/schema.ncl b/crates/modules/nakui/modules/sales/schema.ncl new file mode 100644 index 0000000..8a4d0e1 --- /dev/null +++ b/crates/modules/nakui/modules/sales/schema.ncl @@ -0,0 +1,47 @@ +# Schema declarativo para entities del módulo `sales`. +# Reemplaza el `schema.k` (KCL) por contracts Nickel nativos — +# evaluables in-process por `nakui-core::nickel_validator`. +# +# El bundle del módulo (que Nakui arma juntando este archivo + los +# schemas de los módulos importados — ver `nsmc.json`) se evalúa +# como un record con un field por entity. Para validar un value V +# contra el entity `Venta` se hace en Rust: +# +# let bundle = (import ".ncl") in V | bundle.Venta +# +# Cada entity es un record contract: cada field declara su contract +# (String, Number, predicate custom, etc.). El record entero se +# wrappea en un contract adicional para invariantes cross-field +# (ej: `total == cantidad * precio_unitario`). + +let positive_int = std.contract.from_predicate (fun n => + std.is_number n && n > 0 +) in + +let currency_iso = std.contract.from_predicate (fun s => + std.is_string s && std.string.length s == 3 +) in + +{ + # std.contract.Sequence chain-aplica los contracts en orden. + # Patrón obligatorio para combinar record contract + cross-field + # predicate: aplicar `from_predicate` directo al record (con `|`) + # rebota con "missing definition" porque el predicate evalúa antes + # de que el record esté populated desde el value. + Venta = std.contract.Sequence [ + { + id | String, + stock_id | String, + caja_id | String, + sku_id | String, + cantidad | positive_int, + precio_unitario | positive_int, + currency | currency_iso, + total | Number, + timestamp | String, + }, + std.contract.from_predicate (fun r => + r.total == r.cantidad * r.precio_unitario + ), + ], +} diff --git a/crates/modules/nakui/modules/treasury/schema.k b/crates/modules/nakui/modules/treasury/schema.k deleted file mode 100644 index 9f65ec6..0000000 --- a/crates/modules/nakui/modules/treasury/schema.k +++ /dev/null @@ -1,35 +0,0 @@ -schema Caja: - id: str - name: str - saldo: int - currency: str - - check: - saldo >= 0, "saldo de caja no puede ser negativo" - len(currency) == 3, "currency debe ser ISO 4217 (3 letras)" - -schema Movimiento: - id: str - caja_id: str - monto: int - tipo: str - timestamp: str - memo?: str - - check: - monto > 0, "monto debe ser positivo (la direccion la fija el tipo)" - tipo in ["in", "out"], "tipo debe ser 'in' u 'out'" - -schema Transferencia: - id: str - source_caja_id: str - dest_caja_id: str - monto: int - currency: str - timestamp: str - memo?: str - - check: - monto > 0, "monto debe ser positivo" - len(currency) == 3, "currency ISO 4217" - source_caja_id != dest_caja_id, "source y dest no pueden ser la misma caja" diff --git a/crates/modules/nakui/modules/treasury/schema.ncl b/crates/modules/nakui/modules/treasury/schema.ncl new file mode 100644 index 0000000..a83814b --- /dev/null +++ b/crates/modules/nakui/modules/treasury/schema.ncl @@ -0,0 +1,53 @@ +# Schema Nickel para `treasury` (Caja + Movimiento + Transferencia). + +let positive_int = std.contract.from_predicate (fun n => + std.is_number n && n > 0 +) in + +let non_negative_int = std.contract.from_predicate (fun n => + std.is_number n && n >= 0 +) in + +let currency_iso = std.contract.from_predicate (fun s => + std.is_string s && std.string.length s == 3 +) in + +let movimiento_tipo = std.contract.from_predicate (fun s => + std.is_string s && std.array.elem s ["in", "out"] +) in + +{ + Caja = { + id | String, + name | String, + saldo | non_negative_int, + currency | currency_iso, + }, + + Movimiento = { + id | String, + caja_id | String, + monto | positive_int, + tipo | movimiento_tipo, + timestamp | String, + memo | String | optional, + }, + + # Patrón obligatorio para record + cross-field predicate: chain + # via Sequence (aplicar `from_predicate` directo al record rebota + # con "missing definition"). + Transferencia = std.contract.Sequence [ + { + id | String, + source_caja_id | String, + dest_caja_id | String, + monto | positive_int, + currency | currency_iso, + timestamp | String, + memo | String | optional, + }, + std.contract.from_predicate (fun r => + r.source_caja_id != r.dest_caja_id + ), + ], +}