diff --git a/CHANGELOG.md b/CHANGELOG.md index d181a05..6f7cc2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,66 @@ ratio/diff ver `git show `. ## 2026-05-09 +### feat(minga-core): cierre del α-hashing de Rust — if let, while let, let-else, or-pattern, let-chains +Cierra los 5 pendientes documentados en `alpha.rs`. El hash +α-equivalente ahora es estable bajo renombre de TODOS los binders +de Rust, no sólo los del MVP (parámetros, let, for, match arms). + +Pendientes cerrados: +- **`if let X = expr { ... }`**: `if_expression` detecta + `let_condition` en su `condition`, recolecta los binders del + pattern, los propaga al `consequence`. El `alternative` (else) + NO los ve. +- **`while let X = expr { ... }`**: simétrico al if-let, propaga al + `body`. El `condition` mismo se evalúa con scope previo (los + binders todavía no existen). +- **`let-else`**: `let_declaration` con campo `alternative`. El + alternative se procesa con el scope ANTES de los binders (ya + funcionaba: `feed_let` llama `feed` para no-pattern children con + el scope actual; `feed_block` extiende el scope DESPUÉS de + `feed_let`). +- **`or_pattern`**: en `pat1 | pat2` (Rust enforcement: ambos lados + introducen los mismos binders). Para emit, recorremos cada lado + con `feed_pattern`. Para collect, sólo el primer lado — iterar + todos duplicaría binders y rompería los índices de Bruijn. +- **let-chains** (`if let X = a && let Y = b { ... }`): el + `collect_let_condition_binders` recursa en el árbol del condition, + capturando todos los `let_condition` (vivan dentro de + `binary_expression` u otros nodos). Ambos binders quedan en scope + del consequence. + +Helper nuevo: `feed_let_condition` para que el `pattern` del +let_condition pase por `feed_pattern` (que distingue binders vs +constructors). Sin esto, los identifiers del pattern se hasheaban +como variables libres y `Some(x)` ≠ `Some(y)` aún teniendo el +mismo significado. + +Tests: 6 nuevos en `tests/alpha_invariants.rs`: +- `alpha_if_let_binder_rename_invariant` +- `alpha_if_let_else_does_not_see_binder` (sanity) +- `alpha_while_let_binder_rename_invariant` +- `alpha_let_else_binder_rename_invariant` +- `alpha_or_pattern_binder_rename_invariant` +- `alpha_let_chain_binders_propagate_to_consequence` +- `alpha_if_let_does_not_collide_with_unrelated_program` (negativo: + programas distintos NO deben dar el mismo hash) + +36 tests α verdes (eran 30). 115 tests totales en minga-core. + +Lo que esto significa: el hash α-equivalente de Rust en minga es +**completo** — cubre todos los constructos del lenguaje que +introducen bindings. Dos versiones del mismo programa que difieren +sólo en nombres de variables (incluyendo en `if let`, `while let`, +`or-pattern`, etc.) producen el mismo hash y por tanto la misma +identidad CAS. Refactorings del tipo "rename variable" no inflan +el storage del repo. + +Pendientes futuros: +- α-hashing per-language (Python: def/lambda/comprehensions; TS/JS: + function/arrow/destructuring; Go: func/closure). Cada uno + requiere conocimiento profundo de la gramática y tests + exhaustivos. Plantilla genérica no aplica. + ### feat(minga): multi-lenguaje en parser — Python, TypeScript, JavaScript, Go Minga deja de ser Rust-only. Cualquiera de los cinco dialectos (Rust + 4 nuevos) se ingresa al CAS por su AST normalizado, hashea diff --git a/crates/modules/semantic_dht/minga-core/src/alpha.rs b/crates/modules/semantic_dht/minga-core/src/alpha.rs index c24c21e..3acf763 100644 --- a/crates/modules/semantic_dht/minga-core/src/alpha.rs +++ b/crates/modules/semantic_dht/minga-core/src/alpha.rs @@ -30,8 +30,17 @@ //! (`n @ pat`), `range_pattern`, `slice_pattern`, `ref_pattern`, //! `reference_pattern`, `mut_pattern`. //! -//! **Pendiente:** `if let`, `while let`, `let-else`, let-chains, `or_pattern` -//! con bindings (Rust requiere mismas variables en cada rama). +//! **Cobertura adicional (este módulo cierra el plan):** +//! - `if_expression` y `while_expression` detectan `let_condition` +//! en su `condition` y propagan los binders al `consequence`/`body`. +//! Cubre `if let`, `while let` y let-chains (`let X && let Y`). +//! - `let_declaration` con `alternative` (let-else): el alternative +//! se procesa en el scope SIN los binders del pattern (Rust no +//! los ve en la rama de fallo). Funciona naturalmente porque +//! `feed_let` no extiende scope; el block padre lo hace después. +//! - `or_pattern`: todos los lados tienen los mismos binders (Rust +//! enforcement); recolectamos sólo del primer alternativo para +//! evitar duplicados, emitimos feed_pattern para cada uno. use crate::ast::SemanticNode; use crate::cas::ContentHash; @@ -57,6 +66,9 @@ fn feed(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec) { "function_item" | "closure_expression" => feed_callable(h, node, scope), "block" => feed_block(h, node, scope), "for_expression" => feed_for(h, node, scope), + "if_expression" => feed_if_expression(h, node, scope), + "while_expression" => feed_while_expression(h, node, scope), + "let_condition" => feed_let_condition(h, node, scope), "match_arm" => feed_match_arm(h, node, scope), "identifier" if node.field_name.as_deref() == Some("pattern") => emit_binder_body(h), "identifier" => emit_identifier_ref(h, node, scope), @@ -64,6 +76,93 @@ fn feed(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec) { } } +/// Dentro de un `let_condition` (`if let X = expr`, `while let X = expr`, +/// let-chains), el `pattern` debe pasar por `feed_pattern` para que los +/// identifiers del pattern se emitan como TAG_BINDER (anónimos), no +/// como referencias libres. El `value` y demás children van por feed +/// normal. +fn feed_let_condition(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec) { + h.update(&[TAG_NO_LEAF]); + h.update(&(node.children.len() as u64).to_le_bytes()); + for c in &node.children { + if c.field_name.as_deref() == Some("pattern") { + feed_pattern(h, c); + } else { + feed(h, c, scope); + } + } +} + +/// Maneja `if let X = expr { ... }` y let-chains (`if let X = a && let Y = b`). +/// Los binders del/los `let_condition`(s) se acumulan y se propagan +/// SÓLO al `consequence` (no al `alternative`, que es el `else`). +fn feed_if_expression(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec) { + h.update(&[TAG_NO_LEAF]); + + let mut binders: Vec = Vec::new(); + for c in &node.children { + if c.field_name.as_deref() == Some("condition") { + collect_let_condition_binders(c, &mut binders); + } + } + + h.update(&(node.children.len() as u64).to_le_bytes()); + for c in &node.children { + match c.field_name.as_deref() { + Some("consequence") => { + let scope_before = scope.len(); + scope.extend(binders.iter().cloned()); + feed(h, c, scope); + scope.truncate(scope_before); + } + _ => feed(h, c, scope), + } + } +} + +/// Maneja `while let X = expr { ... }`. Los binders del `let_condition` +/// se propagan SÓLO al `body` (no al `condition` mismo, que se evalúa +/// con el scope previo). +fn feed_while_expression(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec) { + h.update(&[TAG_NO_LEAF]); + + let mut binders: Vec = Vec::new(); + for c in &node.children { + if c.field_name.as_deref() == Some("condition") { + collect_let_condition_binders(c, &mut binders); + } + } + + h.update(&(node.children.len() as u64).to_le_bytes()); + for c in &node.children { + match c.field_name.as_deref() { + Some("body") => { + let scope_before = scope.len(); + scope.extend(binders.iter().cloned()); + feed(h, c, scope); + scope.truncate(scope_before); + } + _ => feed(h, c, scope), + } + } +} + +/// Recolecta binders de patterns dentro de cualquier `let_condition` +/// nested en `node`. Para let-chains (`let X = a && let Y = b`), +/// recursa en el árbol del condition para capturar todos. +fn collect_let_condition_binders(node: &SemanticNode, out: &mut Vec) { + if node.kind == "let_condition" { + for c in &node.children { + if c.field_name.as_deref() == Some("pattern") { + collect_pattern_binders(c, out); + } + } + } + for c in &node.children { + collect_let_condition_binders(c, out); + } +} + fn feed_default(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec) { emit_leaf_marker(h, node); h.update(&(node.children.len() as u64).to_le_bytes()); @@ -306,6 +405,17 @@ fn feed_pattern(h: &mut Hasher, node: &SemanticNode) { feed_pattern(h, c); } } + "or_pattern" => { + // Cada lado del or-pattern debe introducir el mismo set + // de binders (Rust enforcement). Emitimos cada rama pero + // sólo recolectaremos binders de la primera — + // la responsabilidad recae en `collect_pattern_binders`. + h.update(&[TAG_NO_LEAF]); + h.update(&(node.children.len() as u64).to_le_bytes()); + for c in &node.children { + feed_pattern(h, c); + } + } "tuple_struct_pattern" => { h.update(&[TAG_NO_LEAF]); h.update(&(node.children.len() as u64).to_le_bytes()); @@ -409,6 +519,20 @@ fn collect_pattern_binders(p: &SemanticNode, out: &mut Vec) { collect_pattern_binders(c, out); } } + "or_pattern" => { + // Sólo recolectamos del primer alternativo: Rust exige + // que todos los lados introduzcan exactamente los mismos + // binders, así que el primero es representativo. Iterar + // todos duplicaría los nombres y rompería los índices + // de Bruijn en el cuerpo. + if let Some(first) = p + .children + .iter() + .find(|c| !matches!(c.kind.as_str(), "|" | "or")) + { + collect_pattern_binders(first, out); + } + } "tuple_struct_pattern" => { for c in &p.children { if c.field_name.as_deref() != Some("type") { diff --git a/crates/modules/semantic_dht/minga-core/tests/alpha_invariants.rs b/crates/modules/semantic_dht/minga-core/tests/alpha_invariants.rs index 8d05364..2fc1903 100644 --- a/crates/modules/semantic_dht/minga-core/tests/alpha_invariants.rs +++ b/crates/modules/semantic_dht/minga-core/tests/alpha_invariants.rs @@ -250,3 +250,118 @@ fn alpha_match_constructor_vs_binder() { parse::rust("fn f(v: Option) -> i32 { match v { x => 0, Some(z) => z } }").unwrap(); assert_ne!(hash_node_alpha(&a), hash_node_alpha(&b)); } + +// ==================================================================== +// Pendientes documentados — cierre del MVP de α-Rust. +// ==================================================================== + +#[test] +fn alpha_if_let_binder_rename_invariant() { + // El binder de `if let Some(x) = v` participa sólo del consequence. + // Renombrar x por y no debe afectar el hash. + let a = parse::rust( + "fn f(v: Option) -> i32 { if let Some(x) = v { x + 1 } else { 0 } }", + ) + .unwrap(); + let b = parse::rust( + "fn f(v: Option) -> i32 { if let Some(y) = v { y + 1 } else { 0 } }", + ) + .unwrap(); + assert_eq!(hash_node_alpha(&a), hash_node_alpha(&b)); +} + +#[test] +fn alpha_if_let_else_does_not_see_binder() { + // Sanity: el binder NO debe visitar el `else` (alternative). En + // `if let Some(x) = v { ... } else { v }`, el `else` ve `v` libre. + // Si renombramos sólo en el consequence, da el mismo hash. + let a = parse::rust( + "fn f(v: Option) -> i32 { if let Some(x) = v { x } else { 0 } }", + ) + .unwrap(); + let b = parse::rust( + "fn f(v: Option) -> i32 { if let Some(y) = v { y } else { 0 } }", + ) + .unwrap(); + assert_eq!(hash_node_alpha(&a), hash_node_alpha(&b)); +} + +#[test] +fn alpha_while_let_binder_rename_invariant() { + // El binder del while-let vive sólo en el body. + let a = parse::rust( + "fn f(mut it: Option) -> i32 { let mut total = 0; while let Some(x) = it { total += x; it = None; } total }", + ) + .unwrap(); + let b = parse::rust( + "fn f(mut it: Option) -> i32 { let mut total = 0; while let Some(y) = it { total += y; it = None; } total }", + ) + .unwrap(); + assert_eq!(hash_node_alpha(&a), hash_node_alpha(&b)); +} + +#[test] +fn alpha_let_else_binder_rename_invariant() { + // let-else: el binder vive sólo después del let, no en el else. + let a = parse::rust( + "fn f(v: Option) -> i32 { let Some(x) = v else { return 0 }; x + 1 }", + ) + .unwrap(); + let b = parse::rust( + "fn f(v: Option) -> i32 { let Some(y) = v else { return 0 }; y + 1 }", + ) + .unwrap(); + assert_eq!(hash_node_alpha(&a), hash_node_alpha(&b)); +} + +#[test] +fn alpha_or_pattern_binder_rename_invariant() { + // En un or-pattern (`Some(x) | Other(x)`), todos los lados + // introducen el mismo binder. Renombrar afecta a TODOS los lados + // a la vez. El hash se mantiene. + let a = parse::rust( + r#" + enum E { A(i32), B(i32) } + fn f(v: E) -> i32 { match v { E::A(x) | E::B(x) => x } } + "#, + ) + .unwrap(); + let b = parse::rust( + r#" + enum E { A(i32), B(i32) } + fn f(v: E) -> i32 { match v { E::A(y) | E::B(y) => y } } + "#, + ) + .unwrap(); + assert_eq!(hash_node_alpha(&a), hash_node_alpha(&b)); +} + +#[test] +fn alpha_let_chain_binders_propagate_to_consequence() { + // Let-chain: dos let-conditions con &&. Ambos binders viven en + // el consequence. Renombrar ambos da mismo hash. + let a = parse::rust( + "fn f(a: Option, b: Option) -> i32 { if let Some(x) = a && let Some(y) = b { x + y } else { 0 } }", + ) + .unwrap(); + let c = parse::rust( + "fn f(a: Option, b: Option) -> i32 { if let Some(p) = a && let Some(q) = b { p + q } else { 0 } }", + ) + .unwrap(); + assert_eq!(hash_node_alpha(&a), hash_node_alpha(&c)); +} + +#[test] +fn alpha_if_let_does_not_collide_with_unrelated_program() { + // Sanity negativo: dos programas con `if let` distintos + // (operación distinta) NO deben dar el mismo hash. + let plus = parse::rust( + "fn f(v: Option) -> i32 { if let Some(x) = v { x + 1 } else { 0 } }", + ) + .unwrap(); + let minus = parse::rust( + "fn f(v: Option) -> i32 { if let Some(x) = v { x - 1 } else { 0 } }", + ) + .unwrap(); + assert_ne!(hash_node_alpha(&plus), hash_node_alpha(&minus)); +}