From 4db168253c1d77398f0dd8fd32f59d04e432bc13 Mon Sep 17 00:00:00 2001 From: Sergio Date: Sat, 9 May 2026 16:06:31 +0000 Subject: [PATCH] =?UTF-8?q?feat(minga):=20multi-lenguaje=20en=20parser=20?= =?UTF-8?q?=E2=80=94=20Python,=20TypeScript,=20JavaScript,=20Go?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Minga deja de ser Rust-only. Cualquiera de los cinco dialectos (Rust + 4 nuevos) se ingresa al CAS por su AST normalizado, hashea estructuralmente, sincroniza por DHT como cualquier nodo. La auto-deteccion por extension hace que minga ingest archivo.{py,ts,js,go} "simplemente funcione". API nueva en minga_core::parse: - Funciones por dialecto: python, typescript, javascript, go (~6 LOC c/u sobre el parse_with comun). Mas la rust existente. - Enum Dialect con parse(source) y name() para logging. - detect_by_extension(ext) -> Option: rs/py/pyi/ts/js/mjs/ cjs/go (case-insensitive). None para extensiones desconocidas. Wire en minga-cli: - cmd_ingest deja de hardcodear parse::rust — usa detect_dialect(file)?.parse(...). - initial_scan + cmd_watch cambian is_rs_file -> is_supported_source. - CliError::UnsupportedLanguage { path, extension } nuevo, lista las extensiones reconocidas en el mensaje. Notas sobre hashing: - Hashing estructural (cas::hash_node) funciona para todos. NO es alpha-equivalente. - Hashing alpha-equivalente (alpha::hash_node_alpha) sigue siendo Rust-only — cada lenguaje tiene reglas distintas para binder vs constructor; implementacion per-language queda como work futuro (requiere conocimiento profundo de cada gramatica). - Sanity test structural_hash_distinguishes_languages verifica que "x = 1" parseado como Python != JS — las gramaticas no comparten kinds, hashes salen distintos. Importante para evitar colisiones. Deps nuevas (workspace + minga-core): - tree-sitter-python 0.23, tree-sitter-typescript 0.23 (modo LANGUAGE_TYPESCRIPT, no TSX), tree-sitter-javascript 0.23, tree-sitter-go 0.23. Tests: 9 nuevos en parse::tests (parse basico para 5 dialectos + detect_by_extension canonical/case-insensitive + name() + structural_hash_distinguishes_languages). 108 verdes en minga-core, 10 en minga-cli, sin regresion. Pendientes: alpha-hashing per-language; alpha-Rust documentados en alpha.rs (if let, while let, let-else, let-chains, or_pattern con bindings). --- CHANGELOG.md | 70 ++++++ Cargo.lock | 44 ++++ Cargo.toml | 4 + .../semantic_dht/minga-cli/src/commands.rs | 35 ++- .../semantic_dht/minga-cli/src/error.rs | 6 + .../semantic_dht/minga-core/Cargo.toml | 4 + .../semantic_dht/minga-core/src/parse.rs | 206 +++++++++++++++++- 7 files changed, 357 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f02f39d..d181a05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,76 @@ ratio/diff ver `git show `. ## 2026-05-09 +### 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 +estructuralmente, sincroniza por DHT como cualquier nodo. La +auto-detección por extensión hace que `minga ingest archivo.py` o +`.ts` o `.go` "simplemente funcione". + +API nueva en `minga_core::parse`: +- Funciones por dialecto (~6 LOC c/u sobre el `parse_with` común): + `python`, `typescript`, `javascript`, `go`. Más la `rust` existente. +- Enum `Dialect` con `parse(source) -> Result` y + `name() -> &'static str` para logging. +- `detect_by_extension(ext) -> Option`: mapea `rs`/`py`/ + `pyi`/`ts`/`js`/`mjs`/`cjs`/`go` (case-insensitive). `None` para + extensiones desconocidas — el caller decide si es error o se + ignora silente. + +Wire en `minga-cli`: +- `cmd_ingest` deja de hardcodear `parse::rust` — usa + `detect_dialect(file)?.parse(...)`. Acepta `.py`, `.ts`, `.js`, + `.go` además de `.rs`. +- `initial_scan` y `cmd_watch` cambian `is_rs_file` → `is_supported_source` + para incluir todas las extensiones soportadas en el filtro. +- `CliError::UnsupportedLanguage { path, extension }` nuevo, con + mensaje que lista las extensiones reconocidas. + +Notas sobre hashing: +- El AST normalizado (`SemanticNode`) descarta whitespace y + comentarios — propiedad universal de tree-sitter (extras). Misma + lógica para los 5 dialectos. +- Hashing **estructural** (`cas::hash_node`) funciona para todos: + dos textos semánticamente equivalentes-por-estructura producen el + mismo hash. NO α-equivalente (las variables ligadas distinguen). +- Hashing **α-equivalente** (`alpha::hash_node_alpha`) sigue siendo + Rust-only: cada lenguaje tiene reglas distintas para qué es + binder vs. constructor (def/lambda en Python, arrow functions en + TS/JS, func + closures en Go). Implementación per-language queda + como work futuro — requiere conocimiento profundo de cada + gramática y no se plantilla genéricamente. +- Sanity test `structural_hash_distinguishes_languages` verifica + que `x = 1` parseado como Python ≠ parseado como JavaScript: las + gramáticas no comparten kinds y los hashes salen distintos. + Importante para evitar colisiones cuando el mismo source se + ingresa bajo dialectos distintos. + +Deps nuevas (workspace + minga-core): +- `tree-sitter-python = "0.23"` +- `tree-sitter-typescript = "0.23"` (sólo el modo `LANGUAGE_TYPESCRIPT`, + no TSX — bumpear a TSX es agregar otro dialecto cuando se necesite). +- `tree-sitter-javascript = "0.23"` +- `tree-sitter-go = "0.23"` + +Tests: +- 9 nuevos en `parse::tests`: parse básico para los 5 dialectos + (Python con type hints, TS con tipos, JS sin tipos, Go con + package declaration), `detect_by_extension` canonical + + case-insensitive, `dialect_name`, `structural_hash_distinguishes_languages`. +- 108 tests verdes en minga-core (39 → 48 unit + integration tests + pre-existentes intactos). +- 10 tests verdes en minga-cli (sin regresión en el path Rust; + el refactor a `detect_dialect`/`is_supported_source` no rompe + nada). + +Pendientes futuros del changelog: +- α-hashing per-language (Python: def/lambda/comprehensions; + TS/JS: function/arrow/destructuring; Go: func/closure). Trabajo + profundo, scope independiente. +- α-Rust pendientes documentados en `alpha.rs`: `if let`, + `while let`, `let-else`, let-chains, `or_pattern` con bindings. + ### feat(brahman-handshake): multi-key identity — rotación de session sin perder peer_id lógico Cierra el último pendiente del plan de red P2P. Hasta ahora, rotar la keypair libp2p de un nodo cambiaba su `peer_id`, lo que diff --git a/Cargo.lock b/Cargo.lock index 956bbcd..941c298 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5874,7 +5874,11 @@ dependencies = [ "serde-big-array", "thiserror 2.0.18", "tree-sitter", + "tree-sitter-go", + "tree-sitter-javascript", + "tree-sitter-python", "tree-sitter-rust", + "tree-sitter-typescript", ] [[package]] @@ -10335,12 +10339,42 @@ dependencies = [ "tree-sitter-language", ] +[[package]] +name = "tree-sitter-go" +version = "0.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b13d476345220dbe600147dd444165c5791bf85ef53e28acbedd46112ee18431" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-javascript" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf40bf599e0416c16c125c3cec10ee5ddc7d1bb8b0c60fa5c4de249ad34dc1b1" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "tree-sitter-language" version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" +[[package]] +name = "tree-sitter-python" +version = "0.23.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d065aaa27f3aaceaf60c1f0e0ac09e1cb9eb8ed28e7bcdaa52129cffc7f4b04" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "tree-sitter-rust" version = "0.23.3" @@ -10351,6 +10385,16 @@ dependencies = [ "tree-sitter-language", ] +[[package]] +name = "tree-sitter-typescript" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c5f76ed8d947a75cc446d5fccd8b602ebf0cde64ccf2ffa434d873d7a575eff" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "trice" version = "0.4.0" diff --git a/Cargo.toml b/Cargo.toml index b7f40b9..3a4206b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -148,6 +148,10 @@ libp2p-allow-block-list = "0.6" # === Code parsing (minga) === tree-sitter = "0.24" tree-sitter-rust = "0.23" +tree-sitter-python = "0.23" +tree-sitter-typescript = "0.23" +tree-sitter-javascript = "0.23" +tree-sitter-go = "0.23" # === FS notify === notify = "6.1" diff --git a/crates/modules/semantic_dht/minga-cli/src/commands.rs b/crates/modules/semantic_dht/minga-cli/src/commands.rs index e052560..200b832 100644 --- a/crates/modules/semantic_dht/minga-cli/src/commands.rs +++ b/crates/modules/semantic_dht/minga-cli/src/commands.rs @@ -83,7 +83,8 @@ pub fn cmd_ingest( let repo = PersistentRepo::open(repo_path.join(REPO_DIRNAME))?; let source = fs::read_to_string(file)?; - let node = parse::rust(&source)?; + let dialect = detect_dialect(file)?; + let node = dialect.parse(&source)?; let hash = repo.nodes.put(&node)?; repo.mst.insert(hash)?; repo.attestations @@ -96,6 +97,21 @@ pub fn cmd_ingest( }) } +/// Detecta el dialecto desde la extensión del archivo. Error si la +/// extensión no corresponde a un lenguaje soportado. +fn detect_dialect(file: &Path) -> Result { + let ext = file + .extension() + .and_then(|e| e.to_str()) + .unwrap_or(""); + parse::detect_by_extension(ext).ok_or_else(|| { + CliError::UnsupportedLanguage { + path: file.to_path_buf(), + extension: ext.to_string(), + } + }) +} + /// `minga listen `: arranca el peer, escucha en `addr`, y /// acepta sincronizaciones entrantes hasta que el proceso se cierre. pub async fn cmd_listen( @@ -191,7 +207,7 @@ pub async fn cmd_watch( continue; } for path in &event.paths { - if is_rs_file(path) { + if is_supported_source(path) { match ingest_into_repo(&repo, &keypair, path) { Ok(hash) => { eprintln!("ingerido: {} → {}", path.display(), hash); @@ -213,7 +229,7 @@ fn initial_scan(repo: &PersistentRepo, keypair: &Keypair, dir: &Path) { }; for entry in entries.flatten() { let p = entry.path(); - if is_rs_file(&p) { + if is_supported_source(&p) { let _ = ingest_into_repo(repo, keypair, &p); } } @@ -225,7 +241,8 @@ fn ingest_into_repo( file: &Path, ) -> Result { let source = fs::read_to_string(file)?; - let node = parse::rust(&source)?; + let dialect = detect_dialect(file)?; + let node = dialect.parse(&source)?; let hash = repo.nodes.put(&node)?; repo.mst.insert(hash)?; repo.attestations @@ -234,8 +251,14 @@ fn ingest_into_repo( Ok(hash) } -fn is_rs_file(path: &Path) -> bool { - path.extension().and_then(|e| e.to_str()) == Some("rs") && path.is_file() +/// Detecta si un archivo debe ingerirse: existe, es regular, y su +/// extensión corresponde a un dialecto soportado. +fn is_supported_source(path: &Path) -> bool { + if !path.is_file() { + return false; + } + let ext = path.extension().and_then(|e| e.to_str()).unwrap_or(""); + parse::detect_by_extension(ext).is_some() } fn is_relevant_event(event: ¬ify::Event) -> bool { diff --git a/crates/modules/semantic_dht/minga-cli/src/error.rs b/crates/modules/semantic_dht/minga-cli/src/error.rs index f509f41..5592be3 100644 --- a/crates/modules/semantic_dht/minga-cli/src/error.rs +++ b/crates/modules/semantic_dht/minga-cli/src/error.rs @@ -40,4 +40,10 @@ pub enum CliError { #[error("notify (file watcher): {0}")] Notify(#[from] notify::Error), + + #[error( + "lenguaje no soportado para {path}: extensión '{extension}' no mapea \ + a ningún dialecto conocido (rs, py, pyi, ts, js, mjs, cjs, go)" + )] + UnsupportedLanguage { path: PathBuf, extension: String }, } diff --git a/crates/modules/semantic_dht/minga-core/Cargo.toml b/crates/modules/semantic_dht/minga-core/Cargo.toml index 6e385b6..c3b7427 100644 --- a/crates/modules/semantic_dht/minga-core/Cargo.toml +++ b/crates/modules/semantic_dht/minga-core/Cargo.toml @@ -9,6 +9,10 @@ description = "Minga core: semantic AST, content addressing, Merkle Search Tree. [dependencies] tree-sitter = { workspace = true } tree-sitter-rust = { workspace = true } +tree-sitter-python = { workspace = true } +tree-sitter-typescript = { workspace = true } +tree-sitter-javascript = { workspace = true } +tree-sitter-go = { workspace = true } blake3 = { workspace = true } thiserror = { workspace = true } ed25519-dalek = { workspace = true } diff --git a/crates/modules/semantic_dht/minga-core/src/parse.rs b/crates/modules/semantic_dht/minga-core/src/parse.rs index f0766b3..3a6e26f 100644 --- a/crates/modules/semantic_dht/minga-core/src/parse.rs +++ b/crates/modules/semantic_dht/minga-core/src/parse.rs @@ -1,8 +1,29 @@ -//! Adaptadores de parsing por dialecto. Hoy: Rust vía tree-sitter-rust. +//! Adaptadores de parsing por dialecto. //! -//! `parse::rust` produce un `SemanticNode` normalizado a partir de una -//! cadena de código fuente. El error es opaco a propósito: el caller no -//! necesita distinguir "gramática inválida" de "fallo del parser". +//! Cada función devuelve un [`SemanticNode`] normalizado a partir del +//! source code. La normalización vive en `ast::SemanticNode::from_tree_sitter` +//! y es agnóstica al lenguaje — cualquier tree-sitter grammar produce +//! el mismo shape de árbol semántico (sin whitespace, sin comentarios). +//! +//! Lenguajes soportados (cada uno son ~6 LOC + dep tree-sitter-X): +//! - [`rust`] — Rust completo (con α-hashing en `alpha::hash_node_alpha`). +//! - [`python`] — Python 3.x. +//! - [`typescript`] — TypeScript (no TSX). +//! - [`javascript`] — JavaScript / ECMAScript. +//! - [`go`] — Go. +//! +//! Para hashing α-equivalente, sólo Rust tiene implementación dedicada +//! hoy. Otros lenguajes caen al [`crate::cas::hash_node`] estructural, +//! que es α-NO-equivalente: dos versiones del mismo término que +//! difieren en nombres de variables ligadas tendrán hashes distintos. +//! Suficiente para detección de cambios; no para detección de +//! equivalencia semántica. +//! +//! ## Auto-detección por extensión +//! +//! [`detect_by_extension`] mapea `.rs` → Rust, `.py` → Python, etc. +//! Útil para `minga ingest` cuando el caller no quiere especificar +//! el dialecto a mano. use crate::ast::SemanticNode; use thiserror::Error; @@ -16,10 +37,183 @@ pub enum ParseError { NoTree, } -pub fn rust(source: &str) -> Result { - let lang: Language = tree_sitter_rust::LANGUAGE.into(); +/// Identificadores estables de cada dialecto soportado. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Dialect { + Rust, + Python, + TypeScript, + JavaScript, + Go, +} + +impl Dialect { + /// Nombre canónico para logging / display. + pub fn name(self) -> &'static str { + match self { + Dialect::Rust => "rust", + Dialect::Python => "python", + Dialect::TypeScript => "typescript", + Dialect::JavaScript => "javascript", + Dialect::Go => "go", + } + } + + /// Parsea `source` con la gramática de este dialecto. + pub fn parse(self, source: &str) -> Result { + match self { + Dialect::Rust => rust(source), + Dialect::Python => python(source), + Dialect::TypeScript => typescript(source), + Dialect::JavaScript => javascript(source), + Dialect::Go => go(source), + } + } +} + +/// Mapea una extensión de archivo (sin el `.`) al dialecto correspondiente. +/// `None` si la extensión no corresponde a un lenguaje soportado. +/// +/// ``` +/// use minga_core::parse::{detect_by_extension, Dialect}; +/// assert_eq!(detect_by_extension("rs"), Some(Dialect::Rust)); +/// assert_eq!(detect_by_extension("py"), Some(Dialect::Python)); +/// assert_eq!(detect_by_extension("unknown"), None); +/// ``` +pub fn detect_by_extension(ext: &str) -> Option { + match ext.to_ascii_lowercase().as_str() { + "rs" => Some(Dialect::Rust), + "py" | "pyi" => Some(Dialect::Python), + "ts" => Some(Dialect::TypeScript), + "js" | "mjs" | "cjs" => Some(Dialect::JavaScript), + "go" => Some(Dialect::Go), + _ => None, + } +} + +fn parse_with(lang: Language, source: &str) -> Result { let mut parser = Parser::new(); parser.set_language(&lang).map_err(|_| ParseError::Language)?; let tree = parser.parse(source, None).ok_or(ParseError::NoTree)?; Ok(SemanticNode::from_tree_sitter(tree.root_node(), source.as_bytes())) } + +pub fn rust(source: &str) -> Result { + parse_with(tree_sitter_rust::LANGUAGE.into(), source) +} + +pub fn python(source: &str) -> Result { + parse_with(tree_sitter_python::LANGUAGE.into(), source) +} + +pub fn typescript(source: &str) -> Result { + parse_with(tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(), source) +} + +pub fn javascript(source: &str) -> Result { + parse_with(tree_sitter_javascript::LANGUAGE.into(), source) +} + +pub fn go(source: &str) -> Result { + parse_with(tree_sitter_go::LANGUAGE.into(), source) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn assert_parses(d: Dialect, source: &str) -> SemanticNode { + let node = d.parse(source).expect("parse should succeed"); + // Sanity: el root siempre tiene al menos un child para code real. + assert!( + !node.children.is_empty(), + "{}: root node sin children — parse posiblemente vacío", + d.name() + ); + node + } + + #[test] + fn rust_parses_basic() { + assert_parses(Dialect::Rust, "fn add(a: i32, b: i32) -> i32 { a + b }"); + } + + #[test] + fn python_parses_basic() { + assert_parses( + Dialect::Python, + "def add(a: int, b: int) -> int:\n return a + b\n", + ); + } + + #[test] + fn typescript_parses_basic() { + assert_parses( + Dialect::TypeScript, + "function add(a: number, b: number): number { return a + b; }", + ); + } + + #[test] + fn javascript_parses_basic() { + assert_parses( + Dialect::JavaScript, + "function add(a, b) { return a + b; }", + ); + } + + #[test] + fn go_parses_basic() { + assert_parses( + Dialect::Go, + "package main\n\nfunc add(a, b int) int {\n return a + b\n}\n", + ); + } + + #[test] + fn detect_extension_canonical() { + assert_eq!(detect_by_extension("rs"), Some(Dialect::Rust)); + assert_eq!(detect_by_extension("py"), Some(Dialect::Python)); + assert_eq!(detect_by_extension("pyi"), Some(Dialect::Python)); + assert_eq!(detect_by_extension("ts"), Some(Dialect::TypeScript)); + assert_eq!(detect_by_extension("js"), Some(Dialect::JavaScript)); + assert_eq!(detect_by_extension("mjs"), Some(Dialect::JavaScript)); + assert_eq!(detect_by_extension("cjs"), Some(Dialect::JavaScript)); + assert_eq!(detect_by_extension("go"), Some(Dialect::Go)); + assert_eq!(detect_by_extension("unknown"), None); + assert_eq!(detect_by_extension(""), None); + } + + #[test] + fn detect_extension_case_insensitive() { + assert_eq!(detect_by_extension("RS"), Some(Dialect::Rust)); + assert_eq!(detect_by_extension("Py"), Some(Dialect::Python)); + assert_eq!(detect_by_extension("TS"), Some(Dialect::TypeScript)); + } + + #[test] + fn dialect_name_canonical() { + assert_eq!(Dialect::Rust.name(), "rust"); + assert_eq!(Dialect::Python.name(), "python"); + assert_eq!(Dialect::TypeScript.name(), "typescript"); + assert_eq!(Dialect::JavaScript.name(), "javascript"); + assert_eq!(Dialect::Go.name(), "go"); + } + + #[test] + fn structural_hash_distinguishes_languages() { + // Mismo "shape" textual pero distintos lenguajes producen + // árboles distintos (las gramáticas no coinciden) y por tanto + // hashes estructurales distintos. Importante para evitar + // colisiones en el CAS cuando el mismo source se ingiere + // bajo dialectos distintos. + use crate::cas::hash_node; + let py = Dialect::Python.parse("x = 1").unwrap(); + let js = Dialect::JavaScript.parse("x = 1").unwrap(); + assert_ne!( + hash_node(&py), + hash_node(&js), + "py y js deberían tener hashes distintos para el mismo source" + ); + } +}