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-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<Dialect>: 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).
4db168253c1d77398f0dd8fd32f59d04e432bc13 · ruwaq Sergio · 2026-05-09T16:06:31Z (5 killa ñawpaq) · tayta f9a3c3358 · kay commitpi sach'ata qhaway
Semántico sayk'uy · 29
crates/modules/semantic_dht/minga-cli/src/commands.rs
crates/modules/semantic_dht/minga-cli/src/error.rs
silq'uyninpi t'ikrakurqan enum CliError pub
crates/modules/semantic_dht/minga-core/src/parse.rs
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 <sha>`.
## 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<SemanticNode>` y
+ `name() -> &'static str` para logging.
+- `detect_by_extension(ext) -> Option<Dialect>`: 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<parse::Dialect, CliError> {
+ 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 <addr>`: 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<ContentHash, CliError> {
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<SemanticNode, ParseError> {
- 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<SemanticNode, ParseError> {
+ 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<Dialect> {
+ 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<SemanticNode, ParseError> {
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<SemanticNode, ParseError> {
+ parse_with(tree_sitter_rust::LANGUAGE.into(), source)
+}
+
+pub fn python(source: &str) -> Result<SemanticNode, ParseError> {
+ parse_with(tree_sitter_python::LANGUAGE.into(), source)
+}
+
+pub fn typescript(source: &str) -> Result<SemanticNode, ParseError> {
+ parse_with(tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(), source)
+}
+
+pub fn javascript(source: &str) -> Result<SemanticNode, ParseError> {
+ parse_with(tree_sitter_javascript::LANGUAGE.into(), source)
+}
+
+pub fn go(source: &str) -> Result<SemanticNode, ParseError> {
+ 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"
+ );
+ }
+}