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
@@ -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 {