refactor(nakui-core): KCL → Nickel — kcl_wrapper reemplazado por evaluación in-process

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 "<schema>") in
  (std.deserialize 'Json m%%"<json>"%%) | bundle.<entity>`.
- 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) <noreply@anthropic.com>
This commit is contained in:
Sergio
2026-05-10 02:59:48 +00:00
parent b3a99f38dc
commit b05de24c24
23 changed files with 690 additions and 573 deletions
+3 -2
View File
@@ -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();
+2 -2
View File
@@ -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);
@@ -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.
@@ -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(
+2 -2
View File
@@ -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);
@@ -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());