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).
This commit is contained in:
Sergio
2026-05-09 16:06:31 +00:00
parent f9a3c33586
commit 4db168253c
7 changed files with 357 additions and 12 deletions
+70
View File
@@ -6,6 +6,76 @@ ratio/diff ver `git show <sha>`.
## 2026-05-09 ## 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 ### 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 Cierra el último pendiente del plan de red P2P. Hasta ahora, rotar
la keypair libp2p de un nodo cambiaba su `peer_id`, lo que la keypair libp2p de un nodo cambiaba su `peer_id`, lo que
Generated
+44
View File
@@ -5874,7 +5874,11 @@ dependencies = [
"serde-big-array", "serde-big-array",
"thiserror 2.0.18", "thiserror 2.0.18",
"tree-sitter", "tree-sitter",
"tree-sitter-go",
"tree-sitter-javascript",
"tree-sitter-python",
"tree-sitter-rust", "tree-sitter-rust",
"tree-sitter-typescript",
] ]
[[package]] [[package]]
@@ -10335,12 +10339,42 @@ dependencies = [
"tree-sitter-language", "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]] [[package]]
name = "tree-sitter-language" name = "tree-sitter-language"
version = "0.1.7" version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" 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]] [[package]]
name = "tree-sitter-rust" name = "tree-sitter-rust"
version = "0.23.3" version = "0.23.3"
@@ -10351,6 +10385,16 @@ dependencies = [
"tree-sitter-language", "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]] [[package]]
name = "trice" name = "trice"
version = "0.4.0" version = "0.4.0"
+4
View File
@@ -148,6 +148,10 @@ libp2p-allow-block-list = "0.6"
# === Code parsing (minga) === # === Code parsing (minga) ===
tree-sitter = "0.24" tree-sitter = "0.24"
tree-sitter-rust = "0.23" 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 === # === FS notify ===
notify = "6.1" notify = "6.1"
@@ -83,7 +83,8 @@ pub fn cmd_ingest(
let repo = PersistentRepo::open(repo_path.join(REPO_DIRNAME))?; let repo = PersistentRepo::open(repo_path.join(REPO_DIRNAME))?;
let source = fs::read_to_string(file)?; 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)?; let hash = repo.nodes.put(&node)?;
repo.mst.insert(hash)?; repo.mst.insert(hash)?;
repo.attestations 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 /// `minga listen <addr>`: arranca el peer, escucha en `addr`, y
/// acepta sincronizaciones entrantes hasta que el proceso se cierre. /// acepta sincronizaciones entrantes hasta que el proceso se cierre.
pub async fn cmd_listen( pub async fn cmd_listen(
@@ -191,7 +207,7 @@ pub async fn cmd_watch(
continue; continue;
} }
for path in &event.paths { for path in &event.paths {
if is_rs_file(path) { if is_supported_source(path) {
match ingest_into_repo(&repo, &keypair, path) { match ingest_into_repo(&repo, &keypair, path) {
Ok(hash) => { Ok(hash) => {
eprintln!("ingerido: {}{}", path.display(), hash); eprintln!("ingerido: {}{}", path.display(), hash);
@@ -213,7 +229,7 @@ fn initial_scan(repo: &PersistentRepo, keypair: &Keypair, dir: &Path) {
}; };
for entry in entries.flatten() { for entry in entries.flatten() {
let p = entry.path(); let p = entry.path();
if is_rs_file(&p) { if is_supported_source(&p) {
let _ = ingest_into_repo(repo, keypair, &p); let _ = ingest_into_repo(repo, keypair, &p);
} }
} }
@@ -225,7 +241,8 @@ fn ingest_into_repo(
file: &Path, file: &Path,
) -> Result<ContentHash, CliError> { ) -> Result<ContentHash, CliError> {
let source = fs::read_to_string(file)?; 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)?; let hash = repo.nodes.put(&node)?;
repo.mst.insert(hash)?; repo.mst.insert(hash)?;
repo.attestations repo.attestations
@@ -234,8 +251,14 @@ fn ingest_into_repo(
Ok(hash) Ok(hash)
} }
fn is_rs_file(path: &Path) -> bool { /// Detecta si un archivo debe ingerirse: existe, es regular, y su
path.extension().and_then(|e| e.to_str()) == Some("rs") && path.is_file() /// 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: &notify::Event) -> bool { fn is_relevant_event(event: &notify::Event) -> bool {
@@ -40,4 +40,10 @@ pub enum CliError {
#[error("notify (file watcher): {0}")] #[error("notify (file watcher): {0}")]
Notify(#[from] notify::Error), 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 },
} }
@@ -9,6 +9,10 @@ description = "Minga core: semantic AST, content addressing, Merkle Search Tree.
[dependencies] [dependencies]
tree-sitter = { workspace = true } tree-sitter = { workspace = true }
tree-sitter-rust = { 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 } blake3 = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
ed25519-dalek = { workspace = true } ed25519-dalek = { workspace = true }
@@ -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 //! Cada función devuelve un [`SemanticNode`] normalizado a partir del
//! cadena de código fuente. El error es opaco a propósito: el caller no //! source code. La normalización vive en `ast::SemanticNode::from_tree_sitter`
//! necesita distinguir "gramática inválida" de "fallo del parser". //! 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 crate::ast::SemanticNode;
use thiserror::Error; use thiserror::Error;
@@ -16,10 +37,183 @@ pub enum ParseError {
NoTree, NoTree,
} }
pub fn rust(source: &str) -> Result<SemanticNode, ParseError> { /// Identificadores estables de cada dialecto soportado.
let lang: Language = tree_sitter_rust::LANGUAGE.into(); #[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(); let mut parser = Parser::new();
parser.set_language(&lang).map_err(|_| ParseError::Language)?; parser.set_language(&lang).map_err(|_| ParseError::Language)?;
let tree = parser.parse(source, None).ok_or(ParseError::NoTree)?; let tree = parser.parse(source, None).ok_or(ParseError::NoTree)?;
Ok(SemanticNode::from_tree_sitter(tree.root_node(), source.as_bytes())) 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"
);
}
}