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
### 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
Generated
+44
View File
@@ -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"
+4
View File
@@ -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"
@@ -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: &notify::Event) -> bool {
@@ -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 },
}
@@ -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 }
@@ -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"
);
}
}