From b05de24c24ef1d1a510c1a5caf4e14b0471c142f Mon Sep 17 00:00:00 2001 From: Sergio Date: Sun, 10 May 2026 02:59:48 +0000 Subject: [PATCH] =?UTF-8?q?refactor(nakui-core):=20KCL=20=E2=86=92=20Nicke?= =?UTF-8?q?l=20=E2=80=94=20kcl=5Fwrapper=20reemplazado=20por=20evaluaci?= =?UTF-8?q?=C3=B3n=20in-process?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cierra el plan original. 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 usa el brazo de cards). Los 3 schemas de sales/inventory/treasury migran de .k a .ncl. nakui-core: - Nueva dep nickel-lang = "2.0.0". - Borrado kcl_wrapper.rs. - Nuevo nickel_validator.rs con vet(schema_path, state, entity) que evalúa `let bundle = (import "") in (std.deserialize 'Json m%%""%%) | bundle.`. - executor.rs: KclError → NickelError, KclPre/Post/PostCreate → SchemaPre/Post/PostCreate, kcl_check → validate_entity. build_schema_bundle ahora emite `(import "X") & (import "Y") & ...` en lugar de concatenar bytes (cada .ncl es expresión completa). - manifest.rs: default schema "schema.ncl", extract_schema_names reescrito para sintaxis Nickel record (CapitalCase keys con 2-space indent). Schemas migrados: - sales/schema.ncl: Venta con std.contract.Sequence [record, 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 antes de que el value populate el record; documentado en cada schema. - inventory/schema.ncl, treasury/schema.ncl: idem. - 3 schema.k viejos borrados; sales/nsmc.json paths actualizados. Tests: refs Kcl* renombradas; paths .k → .ncl; tests inline que escribían schema.k cambian a schema.ncl con sintaxis Nickel. 84 tests verdes en nakui-core. Doc-only borrados: - crates/core/ente-card/schema/card.k (REFERENCE ONLY). - crates/core/ente-brain/schema/rule.k (REFERENCE ONLY). Beneficios: sin dep externa al binario `kcl` (build CI limpio), errores Nickel en línea con caret pointing al field, mismo motor que cards (una dep para todo el repo), sin tempfile JSON intermedio. Cierra el plan original yahweh + KCL + card.k. Pendientes salen de nuevo trabajo. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 111 ++++++++ Cargo.lock | 1 + crates/core/ente-brain/schema/rule.k | 167 ------------ crates/core/ente-card/schema/card.k | 178 ------------ crates/modules/nakui/core/Cargo.toml | 4 + crates/modules/nakui/core/src/executor.rs | 104 ++++--- crates/modules/nakui/core/src/kcl_wrapper.rs | 43 --- crates/modules/nakui/core/src/lib.rs | 2 +- crates/modules/nakui/core/src/manifest.rs | 100 +++++-- .../nakui/core/src/nickel_validator.rs | 255 ++++++++++++++++++ crates/modules/nakui/core/tests/graph.rs | 5 +- crates/modules/nakui/core/tests/inventory.rs | 4 +- .../modules/nakui/core/tests/kernel_guards.rs | 8 +- .../nakui/core/tests/manifest_validation.rs | 21 +- crates/modules/nakui/core/tests/sales.rs | 4 +- .../nakui/core/tests/schema_versioning.rs | 10 +- .../modules/nakui/modules/inventory/schema.k | 34 --- .../nakui/modules/inventory/schema.ncl | 55 ++++ crates/modules/nakui/modules/sales/nsmc.json | 6 +- crates/modules/nakui/modules/sales/schema.k | 16 -- crates/modules/nakui/modules/sales/schema.ncl | 47 ++++ .../modules/nakui/modules/treasury/schema.k | 35 --- .../modules/nakui/modules/treasury/schema.ncl | 53 ++++ 23 files changed, 690 insertions(+), 573 deletions(-) delete mode 100644 crates/core/ente-brain/schema/rule.k delete mode 100644 crates/core/ente-card/schema/card.k delete mode 100644 crates/modules/nakui/core/src/kcl_wrapper.rs create mode 100644 crates/modules/nakui/core/src/nickel_validator.rs delete mode 100644 crates/modules/nakui/modules/inventory/schema.k create mode 100644 crates/modules/nakui/modules/inventory/schema.ncl delete mode 100644 crates/modules/nakui/modules/sales/schema.k create mode 100644 crates/modules/nakui/modules/sales/schema.ncl delete mode 100644 crates/modules/nakui/modules/treasury/schema.k create mode 100644 crates/modules/nakui/modules/treasury/schema.ncl 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 + ), + ], +}