From e77a32f4d6a09fe29701811f4d2e60dd2cb678db Mon Sep 17 00:00:00 2001 From: sergio Date: Fri, 22 May 2026 13:23:44 +0000 Subject: [PATCH] =?UTF-8?q?feat(minga):=20minga-vfs=20=E2=80=94=20proyecta?= =?UTF-8?q?=20el=20repo=20como=20filesystem=20FUSE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit minga-vfs deja de ser un stub: monta el repositorio direccionado por contenido como un filesystem FUSE de sólo lectura. roots/ da el código fuente reconstruido (formato normalizado) de cada raíz del MST; cas/ resuelve cualquier hash bajo demanda como S-expression. Capas separadas: render (SemanticNode→texto, puro) + source (contrato NodeSource, backends sled/memoria) + fs (único módulo con fuser). Nuevo subcomando `minga mount `. Dep fuser 0.15 sin libfuse-dev (default-features = false). 14 tests nuevos, sin regresión en minga-cli. Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 2 +- Cargo.lock | 30 ++ Cargo.toml | 5 + crates/modules/semantic_dht/SDD.md | 41 ++- .../modules/semantic_dht/minga-cli/Cargo.toml | 3 +- .../semantic_dht/minga-cli/src/commands.rs | 17 + .../modules/semantic_dht/minga-cli/src/lib.rs | 3 +- .../semantic_dht/minga-cli/src/main.rs | 20 +- .../modules/semantic_dht/minga-vfs/Cargo.toml | 8 +- .../modules/semantic_dht/minga-vfs/src/fs.rs | 346 ++++++++++++++++++ .../modules/semantic_dht/minga-vfs/src/lib.rs | 82 ++++- .../semantic_dht/minga-vfs/src/render.rs | 268 ++++++++++++++ .../semantic_dht/minga-vfs/src/source.rs | 154 ++++++++ .../minga-vfs/tests/projection.rs | 77 ++++ docs/changelog/minga.md | 52 +++ 15 files changed, 1094 insertions(+), 14 deletions(-) create mode 100644 crates/modules/semantic_dht/minga-vfs/src/fs.rs create mode 100644 crates/modules/semantic_dht/minga-vfs/src/render.rs create mode 100644 crates/modules/semantic_dht/minga-vfs/src/source.rs create mode 100644 crates/modules/semantic_dht/minga-vfs/tests/projection.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index c986739..9cf7c42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ subdirectorio del workspace. - [`chasqui`](docs/changelog/chasqui.md) — 15 entradas - [`init`](docs/changelog/init.md) — 1 entradas -- [`minga`](docs/changelog/minga.md) — 5 entradas +- [`minga`](docs/changelog/minga.md) — 6 entradas - [`misc`](docs/changelog/misc.md) — 2 entradas - [`nahual`](docs/changelog/nahual.md) — 17 entradas - [`nakui`](docs/changelog/nakui.md) — 20 entradas diff --git a/Cargo.lock b/Cargo.lock index 30aaeef..53fda81 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4690,6 +4690,21 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" +[[package]] +name = "fuser" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53274f494609e77794b627b1a3cddfe45d675a6b2e9ba9c0fdc8d8eee2184369" +dependencies = [ + "libc", + "log", + "memchr", + "nix 0.29.0", + "page_size", + "smallvec", + "zerocopy", +] + [[package]] name = "futf" version = "0.1.5" @@ -7676,6 +7691,7 @@ dependencies = [ "minga-core", "minga-p2p", "minga-store", + "minga-vfs", "notify", "rpassword", "tempfile", @@ -7753,7 +7769,11 @@ dependencies = [ name = "minga-vfs" version = "0.1.0" dependencies = [ + "fuser", + "libc", "minga-core", + "minga-store", + "tempfile", ] [[package]] @@ -9501,6 +9521,16 @@ dependencies = [ "sha2", ] +[[package]] +name = "page_size" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "pageant" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index aa06ebd..f4ed048 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -380,6 +380,11 @@ tree-sitter-go = "0.23" # === FS notify === notify = "6.1" +# === FUSE (minga-vfs) === +# default-features = false: prescinde de pkg-config/libfuse-dev en build. +# El montaje pasa a ser Rust puro (vía el helper `fusermount3` en runtime). +fuser = { version = "0.15", default-features = false } + # === CLI / auth (minga) === clap = { version = "4", features = ["derive"] } rpassword = "7" diff --git a/crates/modules/semantic_dht/SDD.md b/crates/modules/semantic_dht/SDD.md index cb6af73..434a137 100644 --- a/crates/modules/semantic_dht/SDD.md +++ b/crates/modules/semantic_dht/SDD.md @@ -11,14 +11,17 @@ y los publica en un DHT. Búsqueda por similitud semántica cross-repo. | `minga-core` | lib | Parser + α-hashing (Rust/Py/TS/JS/Go) + scoring | | `minga-store` | lib | Sled backend para índice local | | `minga-p2p` | lib | Capa libp2p: kad + bootstrap + provider records | -| `minga-vfs` | lib | Stub (2 LOC) — FUSE que proyecta el índice como filesystem | -| `minga-cli` | bin | CLI: index, search, peers, bootstrap | +| `minga-vfs` | lib | FUSE: monta el repo como filesystem de sólo lectura | +| `minga-cli` | bin | CLI: init, status, ingest, listen, sync, watch, mount | ## Dependencias - `minga-core` ← `tree-sitter`, `tree-sitter-rust`, `-python`, `-ts`, - `-js`, `-go`. Consumido por store/p2p/cli. + `-js`, `-go`. Consumido por store/p2p/vfs/cli. - `minga-p2p` ← `protocol/brahman-net` (libp2p compartido). +- `minga-vfs` ← `minga-store` (lee el `PersistentRepo`), `fuser` + (`default-features = false`: sin pkg-config/libfuse-dev en build), + `libc`. ## α-hashing @@ -27,10 +30,34 @@ implementaciones idénticas con identifiers distintos colisionen en el mismo hash. Cubre let-else, if-let, or-patterns, let-chains en Rust; cierres en Py/TS/JS/Go. +## minga-vfs — proyección como filesystem + +`minga mount ` monta el repo direccionado por contenido como un +filesystem FUSE de sólo lectura. Layout: + +```text +README explicación del propio montaje +roots/ código fuente reconstruido (formato normalizado) de + cada raíz del MST — `ls roots/` las lista todas +cas/ S-expression del subárbol de ese hash — el directorio + NO se enumera, pero `cat cas/` resuelve cualquier + hash conocido (el "blob por hash bajo demanda") +``` + +El render del fuente es **normalizado**, no byte-exacto: el AST +descartó whitespace y comentarios al ingerir, así que `roots/` +re-indenta por estructura de llaves. Python (cuya estructura vive en +la indentación) sale como secuencia de tokens. + +Capas (separabilidad): `render` (SemanticNode → texto, puro) + +`source` (contrato `NodeSource`, backends sled/memoria) + `fs` (único +módulo acoplado a `fuser`). + ## Estado -LOC 5,091. Indexa repos reales. 15 TODOs en core (más patterns para -cada lenguaje). `minga-vfs` aún no implementado — bloquea el montaje -del repo como filesystem (paths virtuales → blobs por hash bajo -demanda). NO está relacionado con Mónadas (esas son de `akasha/`). +Pipeline core completo: indexa repos reales, sincroniza P2P, y se +monta como filesystem. 15 TODOs en core (más patterns para cada +lenguaje). `minga-vfs` implementado — el montaje del repo como +filesystem (paths virtuales → blobs por hash bajo demanda) ya +funciona. NO está relacionado con Mónadas (esas son de `akasha/`). Ver `docs/changelog/minga.md`. diff --git a/crates/modules/semantic_dht/minga-cli/Cargo.toml b/crates/modules/semantic_dht/minga-cli/Cargo.toml index 94be395..1faf961 100644 --- a/crates/modules/semantic_dht/minga-cli/Cargo.toml +++ b/crates/modules/semantic_dht/minga-cli/Cargo.toml @@ -4,7 +4,7 @@ version.workspace = true edition.workspace = true license.workspace = true authors.workspace = true -description = "CLI de Minga: init, status, ingest, listen, sync." +description = "CLI de Minga: init, status, ingest, listen, sync, watch, mount." [[bin]] name = "minga" @@ -14,6 +14,7 @@ path = "src/main.rs" minga-core = { path = "../minga-core" } minga-p2p = { path = "../minga-p2p" } minga-store = { path = "../minga-store" } +minga-vfs = { path = "../minga-vfs" } clap = { workspace = true } rpassword = { workspace = true } tokio = { workspace = true } diff --git a/crates/modules/semantic_dht/minga-cli/src/commands.rs b/crates/modules/semantic_dht/minga-cli/src/commands.rs index 200b832..1b4fb8c 100644 --- a/crates/modules/semantic_dht/minga-cli/src/commands.rs +++ b/crates/modules/semantic_dht/minga-cli/src/commands.rs @@ -97,6 +97,23 @@ pub fn cmd_ingest( }) } +/// `minga mount `: monta el repositorio como un filesystem FUSE +/// de sólo lectura. Cada hash del store se vuelve un archivo +/// navegable con `ls`/`cat`. Bloquea hasta que se desmonte el punto +/// (`fusermount -u ` o una señal al proceso). +pub fn cmd_mount( + repo_path: &Path, + passphrase: &str, + mountpoint: &Path, +) -> Result<(), CliError> { + // Cargar el keypair valida la passphrase: montar es navegar el + // repo, así que pedimos la misma credencial que `status`/`watch`. + let _keypair = keypair_file::load(repo_path.join(KEYPAIR_FILENAME), passphrase)?; + let repo = PersistentRepo::open(repo_path.join(REPO_DIRNAME))?; + minga_vfs::mount(minga_vfs::RepoSource::new(repo), mountpoint)?; + Ok(()) +} + /// 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 { diff --git a/crates/modules/semantic_dht/minga-cli/src/lib.rs b/crates/modules/semantic_dht/minga-cli/src/lib.rs index 47b9e36..6c7e855 100644 --- a/crates/modules/semantic_dht/minga-cli/src/lib.rs +++ b/crates/modules/semantic_dht/minga-cli/src/lib.rs @@ -10,6 +10,7 @@ pub mod commands; pub mod error; pub use commands::{ - cmd_ingest, cmd_init, cmd_listen, cmd_status, cmd_sync, cmd_watch, IngestResult, RepoStatus, + cmd_ingest, cmd_init, cmd_listen, cmd_mount, cmd_status, cmd_sync, cmd_watch, IngestResult, + RepoStatus, }; pub use error::CliError; diff --git a/crates/modules/semantic_dht/minga-cli/src/main.rs b/crates/modules/semantic_dht/minga-cli/src/main.rs index 10a9f88..4d15cab 100644 --- a/crates/modules/semantic_dht/minga-cli/src/main.rs +++ b/crates/modules/semantic_dht/minga-cli/src/main.rs @@ -5,7 +5,7 @@ use std::process::ExitCode; use clap::{Parser, Subcommand}; use minga_cli::{ - cmd_ingest, cmd_init, cmd_listen, cmd_status, cmd_sync, cmd_watch, CliError, + cmd_ingest, cmd_init, cmd_listen, cmd_mount, cmd_status, cmd_sync, cmd_watch, CliError, }; #[derive(Parser)] @@ -59,6 +59,14 @@ enum Command { /// Directorio a vigilar. dir: PathBuf, }, + + /// Monta el repositorio como filesystem FUSE de sólo lectura. + /// Cada hash del store se vuelve un archivo navegable con + /// `ls`/`cat`. Bloquea hasta `fusermount -u `. + Mount { + /// Punto de montaje: un directorio existente. + point: PathBuf, + }, } fn main() -> ExitCode { @@ -116,6 +124,16 @@ fn run() -> Result<(), CliError> { .map_err(|e| CliError::Io(std::io::Error::new(std::io::ErrorKind::Other, e)))?; rt.block_on(cmd_watch(&cli.repo, &pass, &dir))?; } + Command::Mount { point } => { + let pass = prompt_passphrase()?; + println!( + "Montando {} en {}. `fusermount -u {}` para desmontar.", + cli.repo.display(), + point.display(), + point.display() + ); + cmd_mount(&cli.repo, &pass, &point)?; + } } Ok(()) } diff --git a/crates/modules/semantic_dht/minga-vfs/Cargo.toml b/crates/modules/semantic_dht/minga-vfs/Cargo.toml index 48f1d97..cc9d195 100644 --- a/crates/modules/semantic_dht/minga-vfs/Cargo.toml +++ b/crates/modules/semantic_dht/minga-vfs/Cargo.toml @@ -4,7 +4,13 @@ version.workspace = true edition.workspace = true license.workspace = true authors.workspace = true -description = "Virtual File System de Minga (FUSE). Aún no implementado." +description = "Virtual File System de Minga: proyecta el repositorio direccionado por contenido como un filesystem FUSE de sólo lectura." [dependencies] minga-core = { path = "../minga-core" } +minga-store = { path = "../minga-store" } +fuser = { workspace = true } +libc = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/crates/modules/semantic_dht/minga-vfs/src/fs.rs b/crates/modules/semantic_dht/minga-vfs/src/fs.rs new file mode 100644 index 0000000..e8fd40d --- /dev/null +++ b/crates/modules/semantic_dht/minga-vfs/src/fs.rs @@ -0,0 +1,346 @@ +//! Adaptador a `fuser`: el único módulo del crate acoplado a FUSE. +//! +//! Traduce el contrato [`NodeSource`] a la `Filesystem` trait. El +//! filesystem es de sólo lectura y de estructura fija (ver el layout en +//! la documentación del crate). Los inodos estáticos (raíz, `README`, +//! `roots/`, `cas/`) tienen números reservados; los archivos por hash +//! reciben un inodo dinámico la primera vez que se nombran, estable a +//! partir de ahí. +//! +//! `fuser` 0.15 despacha las peticiones de forma secuencial en un único +//! hilo de sesión, así que los métodos toman `&mut self` y mutamos los +//! mapas internos sin necesidad de locks. + +use std::collections::HashMap; +use std::ffi::OsStr; +use std::time::{Duration, SystemTime}; + +use fuser::{ + FileAttr, FileType, Filesystem, ReplyAttr, ReplyData, ReplyDirectory, ReplyEntry, ReplyStatfs, + Request, +}; +use minga_core::ContentHash; + +use crate::render::{render_sexp, render_source}; +use crate::source::{reconstruct, NodeSource}; + +/// TTL de las respuestas cacheadas por el kernel. El contenido es +/// inmutable (direccionado por contenido), pero el *conjunto* de raíces +/// crece con cada ingest; 1 s es el compromiso habitual. +const TTL: Duration = Duration::from_secs(1); + +// Inodos estáticos. Los dinámicos arrancan en INO_DYNAMIC_BASE. +const INO_ROOT: u64 = 1; +const INO_README: u64 = 2; +const INO_ROOTS_DIR: u64 = 3; +const INO_CAS_DIR: u64 = 4; +const INO_DYNAMIC_BASE: u64 = 16; + +/// Contenido del archivo `/README` del propio montaje. +const README: &str = "\ +Minga VFS — proyección de sólo lectura de un repositorio Minga. + +Layout: + roots/ Código fuente reconstruido (formato normalizado) de + cada archivo ingerido. `ls roots/` los lista todos. + cas/ S-expression del subárbol con ese hash. Este + directorio NO se lista (son demasiados nodos), pero + `cat cas/` resuelve cualquier hash conocido. + +El hash es un BLAKE3 de 64 hex en minúsculas sobre la ESTRUCTURA +semántica del código: whitespace y comentarios no cuentan. Por eso +`roots/` es una reconstrucción normalizada, no el archivo +original byte-a-byte. + +Filesystem de sólo lectura. Desmontar: fusermount -u . +"; + +/// Cuál de los dos directorios de hashes; determina el renderizado. +#[derive(Clone, Copy)] +enum Dir { + /// `roots/` — código fuente reconstruido. + Roots, + /// `cas/` — S-expression del árbol. + Cas, +} + +/// Implementación de `fuser::Filesystem` sobre un [`NodeSource`]. +pub struct MingaFs { + source: S, + /// Siguiente inodo dinámico libre. + next_ino: u64, + /// `(inodo_padre, nombre)` → inodo dinámico, para que un mismo hash + /// conserve su inodo entre llamadas. + name_to_ino: HashMap<(u64, String), u64>, + /// Inodo dinámico → contenido ya renderizado. Cachea el resultado + /// del primer `lookup`/`read` de cada archivo. + content: HashMap>, + /// Marca de tiempo uniforme para todos los atributos. + epoch: SystemTime, + uid: u32, + gid: u32, +} + +impl MingaFs { + /// Construye el filesystem sobre `source`. Los archivos virtuales + /// quedan a nombre del usuario y grupo del proceso, para que pueda + /// leerlos sin `allow_other`. + pub fn new(source: S) -> Self { + Self { + source, + next_ino: INO_DYNAMIC_BASE, + name_to_ino: HashMap::new(), + content: HashMap::new(), + epoch: SystemTime::now(), + // SAFETY: getuid/getgid son siempre seguras, sin efectos. + uid: unsafe { libc::getuid() }, + gid: unsafe { libc::getgid() }, + } + } + + /// Inodo dinámico para `(parent, name)`, asignándolo si es la + /// primera vez que se ve. + fn intern_ino(&mut self, parent: u64, name: &str) -> u64 { + if let Some(&ino) = self.name_to_ino.get(&(parent, name.to_string())) { + return ino; + } + let ino = self.next_ino; + self.next_ino += 1; + self.name_to_ino.insert((parent, name.to_string()), ino); + ino + } + + /// Resuelve un nombre bajo `roots/` o `cas/`: parsea el hash, + /// reconstruye el nodo, lo renderiza según `dir`, cachea el + /// contenido y devuelve `(inodo, tamaño)`. `None` si el nombre no + /// es un hash válido o el nodo no está en el store. + fn resolve(&mut self, dir: Dir, parent: u64, name: &str) -> Option<(u64, usize)> { + let hash = parse_hash(name)?; + let node = reconstruct(&self.source, &hash)?; + let rendered = match dir { + Dir::Roots => render_source(&node), + Dir::Cas => render_sexp(&node), + }; + let bytes = rendered.into_bytes(); + let size = bytes.len(); + let ino = self.intern_ino(parent, name); + self.content.insert(ino, bytes); + Some((ino, size)) + } + + fn dir_attr(&self, ino: u64) -> FileAttr { + FileAttr { + ino, + size: 0, + blocks: 0, + atime: self.epoch, + mtime: self.epoch, + ctime: self.epoch, + crtime: self.epoch, + kind: FileType::Directory, + perm: 0o555, + nlink: 2, + uid: self.uid, + gid: self.gid, + rdev: 0, + blksize: 512, + flags: 0, + } + } + + fn file_attr(&self, ino: u64, size: usize) -> FileAttr { + let size = size as u64; + FileAttr { + ino, + size, + blocks: size.div_ceil(512), + atime: self.epoch, + mtime: self.epoch, + ctime: self.epoch, + crtime: self.epoch, + kind: FileType::RegularFile, + perm: 0o444, + nlink: 1, + uid: self.uid, + gid: self.gid, + rdev: 0, + blksize: 512, + flags: 0, + } + } +} + +impl Filesystem for MingaFs { + fn lookup(&mut self, _req: &Request<'_>, parent: u64, name: &OsStr, reply: ReplyEntry) { + let Some(name) = name.to_str() else { + reply.error(libc::ENOENT); + return; + }; + match parent { + INO_ROOT => match name { + "README" => reply.entry(&TTL, &self.file_attr(INO_README, README.len()), 0), + "roots" => reply.entry(&TTL, &self.dir_attr(INO_ROOTS_DIR), 0), + "cas" => reply.entry(&TTL, &self.dir_attr(INO_CAS_DIR), 0), + _ => reply.error(libc::ENOENT), + }, + INO_ROOTS_DIR | INO_CAS_DIR => { + let dir = if parent == INO_ROOTS_DIR { + Dir::Roots + } else { + Dir::Cas + }; + match self.resolve(dir, parent, name) { + Some((ino, size)) => reply.entry(&TTL, &self.file_attr(ino, size), 0), + None => reply.error(libc::ENOENT), + } + } + // Los archivos no tienen hijos. + _ => reply.error(libc::ENOENT), + } + } + + fn getattr(&mut self, _req: &Request<'_>, ino: u64, _fh: Option, reply: ReplyAttr) { + match ino { + INO_ROOT | INO_ROOTS_DIR | INO_CAS_DIR => reply.attr(&TTL, &self.dir_attr(ino)), + INO_README => reply.attr(&TTL, &self.file_attr(INO_README, README.len())), + _ => match self.content.get(&ino) { + Some(bytes) => { + let size = bytes.len(); + reply.attr(&TTL, &self.file_attr(ino, size)); + } + None => reply.error(libc::ENOENT), + }, + } + } + + fn read( + &mut self, + _req: &Request<'_>, + ino: u64, + _fh: u64, + offset: i64, + size: u32, + _flags: i32, + _lock_owner: Option, + reply: ReplyData, + ) { + let data: &[u8] = if ino == INO_README { + README.as_bytes() + } else { + match self.content.get(&ino) { + Some(bytes) => bytes.as_slice(), + None => { + reply.error(libc::ENOENT); + return; + } + } + }; + let start = (offset.max(0) as usize).min(data.len()); + let end = start.saturating_add(size as usize).min(data.len()); + reply.data(&data[start..end]); + } + + fn readdir( + &mut self, + _req: &Request<'_>, + ino: u64, + _fh: u64, + offset: i64, + mut reply: ReplyDirectory, + ) { + // Lista completa, incluidos `.` y `..`; el `offset` indica + // desde qué entrada reanudar. + let entries: Vec<(u64, FileType, String)> = match ino { + INO_ROOT => vec![ + (INO_ROOT, FileType::Directory, ".".into()), + (INO_ROOT, FileType::Directory, "..".into()), + (INO_README, FileType::RegularFile, "README".into()), + (INO_ROOTS_DIR, FileType::Directory, "roots".into()), + (INO_CAS_DIR, FileType::Directory, "cas".into()), + ], + INO_ROOTS_DIR => { + let mut v = vec![ + (INO_ROOTS_DIR, FileType::Directory, ".".into()), + (INO_ROOT, FileType::Directory, "..".into()), + ]; + for hash in self.source.roots() { + let name = hash.to_string(); + let child = self.intern_ino(INO_ROOTS_DIR, &name); + v.push((child, FileType::RegularFile, name)); + } + v + } + // `cas/` no se enumera: resuelve sólo por `lookup` directo. + INO_CAS_DIR => vec![ + (INO_CAS_DIR, FileType::Directory, ".".into()), + (INO_ROOT, FileType::Directory, "..".into()), + ], + _ => { + reply.error(libc::ENOTDIR); + return; + } + }; + + for (i, (e_ino, kind, name)) in entries.into_iter().enumerate().skip(offset as usize) { + // El offset del siguiente registro es `i + 1`. + if reply.add(e_ino, (i + 1) as i64, kind, name) { + break; // buffer del kernel lleno + } + } + reply.ok(); + } + + fn statfs(&mut self, _req: &Request<'_>, _ino: u64, reply: ReplyStatfs) { + // blocks, bfree, bavail, files, ffree, bsize, namelen, frsize. + reply.statfs(0, 0, 0, 0, 0, 512, 255, 512); + } +} + +/// Parsea un nombre de archivo como un `ContentHash`: exactamente 64 +/// dígitos hex en minúsculas (el formato que produce `Display`). +fn parse_hash(name: &str) -> Option { + if name.len() != 64 { + return None; + } + let mut bytes = [0u8; 32]; + let raw = name.as_bytes(); + for (i, slot) in bytes.iter_mut().enumerate() { + let hi = hex_val(raw[2 * i])?; + let lo = hex_val(raw[2 * i + 1])?; + *slot = (hi << 4) | lo; + } + Some(ContentHash(bytes)) +} + +fn hex_val(c: u8) -> Option { + match c { + b'0'..=b'9' => Some(c - b'0'), + b'a'..=b'f' => Some(c - b'a' + 10), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_hash_accepts_64_lowercase_hex() { + let h = parse_hash(&"ab".repeat(32)).expect("64 hex válidos"); + assert_eq!(h.0, [0xab; 32]); + } + + #[test] + fn parse_hash_rejects_bad_length_and_chars() { + assert!(parse_hash("abc").is_none()); + assert!(parse_hash(&"AB".repeat(32)).is_none(), "mayúsculas no"); + assert!(parse_hash(&"zz".repeat(32)).is_none(), "no-hex no"); + } + + #[test] + fn parse_hash_roundtrips_display() { + let original = ContentHash([0x3f; 32]); + let back = parse_hash(&original.to_string()).expect("roundtrip"); + assert_eq!(original, back); + } +} diff --git a/crates/modules/semantic_dht/minga-vfs/src/lib.rs b/crates/modules/semantic_dht/minga-vfs/src/lib.rs index fd0117c..8fd30fa 100644 --- a/crates/modules/semantic_dht/minga-vfs/src/lib.rs +++ b/crates/modules/semantic_dht/minga-vfs/src/lib.rs @@ -1,2 +1,80 @@ -//! minga-vfs: proyección virtual del repositorio como filesystem (vía -//! FUSE). Resuelve hashes a bloques de código bajo demanda. Pendiente. +//! `minga-vfs`: proyecta el repositorio de Minga —direccionado por +//! contenido semántico— como un filesystem FUSE de **sólo lectura**. +//! +//! Minga guarda código como un grafo de `StoredNode`s identificados por +//! `ContentHash`; los archivos ingeridos son las raíces del MST. Este +//! crate convierte ese grafo en algo que cualquier herramienta Unix +//! (`ls`, `cat`, `grep`, un editor) puede recorrer, sin exponer `sled` +//! ni la API del store. +//! +//! ## Layout del filesystem +//! +//! ```text +//! / +//! ├── README explicación del propio VFS +//! ├── roots/ un archivo por raíz del MST (cada archivo ingerido) +//! │ └── código fuente reconstruido, formato normalizado +//! └── cas/ cualquier nodo del store, resuelto bajo demanda +//! └── S-expression del subárbol con ese hash +//! ``` +//! +//! `roots/` **se enumera** (`ls` lista todas las raíces). `cas/` no se +//! enumera —son potencialmente decenas de miles de nodos— pero +//! `cas/` resuelve cualquier hash conocido: ése es el "blob por +//! hash bajo demanda". El mismo hash bajo `roots/` y bajo `cas/` da dos +//! vistas del mismo nodo: fuente reconstruida vs. árbol literal. +//! +//! ## Arquitectura (separabilidad) +//! +//! - [`render`] — `SemanticNode` → texto. Lógica pura, sin IO ni FUSE; +//! reutilizable por un frontend web o TUI. +//! - [`source`] — el contrato [`NodeSource`] y sus backends (`sled` +//! vía [`RepoSource`], memoria vía [`MemSource`]). +//! - `fs` — el único módulo acoplado a `fuser`: traduce el contrato a +//! la `Filesystem` trait. + +mod fs; +pub mod render; +pub mod source; + +pub use fs::MingaFs; +pub use source::{reconstruct, MemSource, NodeSource, RepoSource}; + +use std::io; +use std::path::Path; + +use fuser::MountOption; + +/// Opciones de montaje comunes: sólo lectura, etiquetado como `minga` +/// para que aparezca legible en `mount` / `df`. +fn mount_options() -> Vec { + vec![ + MountOption::RO, + MountOption::FSName("minga".to_string()), + MountOption::Subtype("minga".to_string()), + ] +} + +/// Monta el VFS en `mountpoint` y **bloquea** hasta que se desmonte +/// (`fusermount -u `, `umount`, o una señal al proceso). +/// +/// El punto de montaje debe ser un directorio existente. El filesystem +/// es de sólo lectura: toda escritura falla con `EROFS`/`EACCES`. +pub fn mount(source: S, mountpoint: P) -> io::Result<()> +where + S: NodeSource, + P: AsRef, +{ + fuser::mount2(MingaFs::new(source), mountpoint, &mount_options()) +} + +/// Como [`mount`] pero spawnea un hilo de fondo y retorna de inmediato. +/// La sesión queda viva mientras el `BackgroundSession` no se dropee; +/// dropearlo desmonta el filesystem. +pub fn spawn_mount(source: S, mountpoint: P) -> io::Result +where + S: NodeSource + Send + 'static, + P: AsRef, +{ + fuser::spawn_mount2(MingaFs::new(source), mountpoint, &mount_options()) +} diff --git a/crates/modules/semantic_dht/minga-vfs/src/render.rs b/crates/modules/semantic_dht/minga-vfs/src/render.rs new file mode 100644 index 0000000..93a728b --- /dev/null +++ b/crates/modules/semantic_dht/minga-vfs/src/render.rs @@ -0,0 +1,268 @@ +//! Renderizado de un `SemanticNode` a texto legible. Lógica **pura**: +//! sin IO, sin FUSE, sin `sled`. El VFS la usa para materializar el +//! contenido de cada archivo virtual bajo demanda, pero es reutilizable +//! por cualquier frontend (web, TUI). +//! +//! Dos vistas complementarias: +//! +//! - [`render_source`]: reconstrucción **canónica** del código fuente. +//! El AST semántico descartó whitespace y comentarios al ingerir +//! (son `extra` en tree-sitter), así que esto NO recupera el archivo +//! original byte-a-byte: es una forma *normalizada*, re-indentada por +//! estructura de llaves. Para lenguajes con bloques por llaves +//! (Rust/TS/JS/Go) sale legible; Python —cuya estructura vive en la +//! indentación, y la indentación es trivia— sale como una secuencia +//! de tokens en pocas líneas. Es esperado: el hash es de la +//! estructura, no del formato. +//! +//! - [`render_sexp`]: el árbol como S-expression indentada. Vista +//! exacta y sin pérdida de lo que el store guarda de verdad. + +use minga_core::SemanticNode; + +/// Reconstruye el código fuente de un subárbol en forma canónica. +/// +/// Recolecta los tokens hoja en orden y los re-imprime con un +/// pretty-printer mínimo, consciente de llaves: indenta tras `{`, +/// desindenta antes de `}`, corta línea tras `;`. El resultado termina +/// siempre con exactamente un `\n`. +pub fn render_source(node: &SemanticNode) -> String { + let mut tokens = Vec::new(); + collect_leaves(node, &mut tokens); + pretty_print(&tokens) +} + +/// Recolecta el texto de los nodos hoja en orden de recorrido (DFS). +/// Sólo las hojas tienen `leaf_text`; los nodos internos se recurren. +fn collect_leaves(node: &SemanticNode, out: &mut Vec) { + match &node.leaf_text { + Some(bytes) => { + let text = String::from_utf8_lossy(bytes); + let trimmed = text.trim(); + if !trimmed.is_empty() { + out.push(trimmed.to_string()); + } + } + None => { + for child in &node.children { + collect_leaves(child, out); + } + } + } +} + +/// Tokens que no quieren un espacio a su izquierda (puntuación que se +/// pega al token anterior). Incluye `(` y `[`: en una vista normalizada +/// se pegan al identificador previo (`main()`, `v[0]`) — el caso de +/// llamada/indexado es el dominante. +fn no_space_before(t: &str) -> bool { + matches!( + t, + ")" | "]" | "," | ";" | "." | "::" | "?" | ":" | "!" | "(" | "[" + ) +} + +/// Tokens tras los cuales no va espacio (abren un grupo o son prefijos +/// que se pegan al token siguiente). +fn no_space_after(t: &str) -> bool { + matches!(t, "(" | "[" | "." | "::" | "!" | "#") +} + +/// ¿Hace falta un espacio entre `prev` y `cur` en una misma línea? +fn needs_space(cur: &str, prev: &str) -> bool { + !no_space_before(cur) && !no_space_after(prev) +} + +fn push_indent(out: &mut String, indent: usize) { + for _ in 0..indent { + out.push_str(" "); + } +} + +/// Re-imprime una secuencia de tokens con indentación por llaves. +fn pretty_print(tokens: &[String]) -> String { + let mut out = String::new(); + let mut indent: usize = 0; + // ¿Hay ya contenido en la línea en curso? + let mut line_open = false; + + for (i, tok) in tokens.iter().enumerate() { + let t = tok.as_str(); + let next = tokens.get(i + 1).map(String::as_str); + match t { + "{" => { + if line_open { + out.push(' '); + } + out.push('{'); + indent += 1; + out.push('\n'); + line_open = false; + } + "}" => { + indent = indent.saturating_sub(1); + if line_open { + out.push('\n'); + } + push_indent(&mut out, indent); + out.push('}'); + line_open = true; + // `} else`, `},`, `};`, `})`, `}.` se quedan en línea; + // cualquier otra cosa abre línea nueva. + if !matches!(next, Some("else") | Some(",") | Some(";") | Some(")") | Some(".")) { + out.push('\n'); + line_open = false; + } + } + ";" => { + out.push(';'); + out.push('\n'); + line_open = false; + } + _ => { + if !line_open { + push_indent(&mut out, indent); + line_open = true; + } else if let Some(prev) = tokens.get(i.wrapping_sub(1)).map(String::as_str) { + if i > 0 && needs_space(t, prev) { + out.push(' '); + } + } + out.push_str(t); + } + } + } + + // Final canónico: exactamente un newline. + while out.ends_with([' ', '\t', '\n']) { + out.pop(); + } + out.push('\n'); + out +} + +/// Renderiza el subárbol como S-expression indentada (2 espacios por +/// nivel). Cada nodo es `(kind ...)`; los nodos con `field_name` lo +/// prefijan como `field: (kind ...)`; las hojas llevan su texto entre +/// comillas. Es la representación literal de lo que hay en el store. +pub fn render_sexp(node: &SemanticNode) -> String { + let mut out = String::new(); + sexp_node(node, 0, &mut out); + out.push('\n'); + out +} + +fn sexp_node(node: &SemanticNode, depth: usize, out: &mut String) { + for _ in 0..depth { + out.push_str(" "); + } + // Convención tree-sitter: el nombre de campo va FUERA del paréntesis. + if let Some(field) = &node.field_name { + out.push_str(field); + out.push_str(": "); + } + out.push('('); + out.push_str(&node.kind); + + match &node.leaf_text { + Some(bytes) => { + out.push(' '); + out.push('"'); + out.push_str(&escape(&String::from_utf8_lossy(bytes))); + out.push('"'); + out.push(')'); + } + None if node.children.is_empty() => { + out.push(')'); + } + None => { + for child in &node.children { + out.push('\n'); + sexp_node(child, depth + 1, out); + } + out.push(')'); + } + } +} + +/// Escapa una cadena para que quepa entre comillas en la S-expression. +fn escape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '\\' => out.push_str("\\\\"), + '"' => out.push_str("\\\""), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + _ => out.push(c), + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use minga_core::ast::SemanticNode; + + fn leaf(kind: &str, text: &str) -> SemanticNode { + SemanticNode { + kind: kind.to_string(), + field_name: None, + leaf_text: Some(text.as_bytes().to_vec()), + children: Vec::new(), + } + } + + fn branch(kind: &str, children: Vec) -> SemanticNode { + SemanticNode { + kind: kind.to_string(), + field_name: None, + leaf_text: None, + children, + } + } + + #[test] + fn source_indents_on_braces() { + // tokens: fn main ( ) { let x = 1 ; } + let tree = branch( + "fn_item", + vec![ + leaf("fn", "fn"), + leaf("ident", "main"), + leaf("(", "("), + leaf(")", ")"), + leaf("{", "{"), + leaf("let", "let"), + leaf("ident", "x"), + leaf("=", "="), + leaf("int", "1"), + leaf(";", ";"), + leaf("}", "}"), + ], + ); + let out = render_source(&tree); + assert!(out.contains("fn main()"), "tokens pegados a paréntesis: {out:?}"); + assert!(out.contains(" let x = 1;"), "cuerpo indentado: {out:?}"); + assert!(out.ends_with("}\n"), "termina en una sola llave + newline: {out:?}"); + } + + #[test] + fn sexp_shows_kinds_fields_and_leaves() { + let mut id = leaf("identifier", "x"); + id.field_name = Some("name".to_string()); + let tree = branch("declaration", vec![id]); + let out = render_sexp(&tree); + assert!(out.contains("(declaration")); + assert!(out.contains("name: (identifier \"x\")")); + } + + #[test] + fn sexp_escapes_special_chars() { + let out = render_sexp(&leaf("string", "a\"b\nc")); + assert!(out.contains("\\\""), "comilla escapada: {out:?}"); + assert!(out.contains("\\n"), "newline escapado: {out:?}"); + } +} diff --git a/crates/modules/semantic_dht/minga-vfs/src/source.rs b/crates/modules/semantic_dht/minga-vfs/src/source.rs new file mode 100644 index 0000000..73fa4ba --- /dev/null +++ b/crates/modules/semantic_dht/minga-vfs/src/source.rs @@ -0,0 +1,154 @@ +//! El contrato [`NodeSource`] —lo mínimo que el VFS necesita de un +//! repositorio Minga— y sus dos backends. Agnóstico de `fuser`. +//! +//! El VFS no quiere conocer `sled` ni la estructura interna del store: +//! sólo necesita (a) enumerar las raíces del MST y (b) resolver un nodo +//! por hash. Eso es [`NodeSource`]. [`RepoSource`] lo implementa sobre +//! el [`PersistentRepo`] en disco; [`MemSource`] sobre un `MemStore` en +//! RAM (tests, índices efímeros recién sincronizados). + +use minga_core::{ContentHash, SemanticNode, StoredNode}; +use minga_store::PersistentRepo; + +/// Lo que el VFS necesita de un repositorio para proyectarlo. +pub trait NodeSource { + /// Hashes raíz: el conjunto de claves del MST, un elemento por + /// archivo ingerido. Es lo que se lista bajo `roots/`. + fn roots(&self) -> Vec; + + /// Resuelve un único nodo (un eslabón del grafo) por su hash. + /// `None` si no está en el almacén. + fn get(&self, hash: &ContentHash) -> Option; +} + +/// Reconstruye el `SemanticNode` completo de un hash, resolviendo +/// recursivamente sus hijos contra `source`. +/// +/// Devuelve `None` si el almacén está incompleto: o el propio `hash` +/// falta, o lo hace algún descendiente (puede ocurrir en un repo a +/// medio sincronizar). +pub fn reconstruct(source: &S, hash: &ContentHash) -> Option +where + S: NodeSource + ?Sized, +{ + let stored = source.get(hash)?; + let mut children = Vec::with_capacity(stored.children.len()); + for child in &stored.children { + children.push(reconstruct(source, child)?); + } + Some(SemanticNode { + kind: stored.kind, + field_name: stored.field_name, + leaf_text: stored.leaf_text, + children, + }) +} + +/// [`NodeSource`] respaldado por un [`PersistentRepo`] de `minga-store` +/// (almacén `sled` en disco). Es la fuente que usa `minga mount`. +pub struct RepoSource { + repo: PersistentRepo, +} + +impl RepoSource { + /// Envuelve un repo ya abierto. La propiedad pasa al `RepoSource`: + /// el repo se cierra cuando éste se dropea. + pub fn new(repo: PersistentRepo) -> Self { + Self { repo } + } +} + +impl NodeSource for RepoSource { + fn roots(&self) -> Vec { + // Las claves del MST corruptas (si las hubiera) se descartan en + // silencio: un par de entradas ilegibles no deben tirar el `ls`. + self.repo.mst.iter().filter_map(Result::ok).collect() + } + + fn get(&self, hash: &ContentHash) -> Option { + self.repo.nodes.get(hash).ok().flatten() + } +} + +/// [`NodeSource`] en memoria: un `MemStore` más un conjunto explícito +/// de raíces. Para tests y para montar índices que viven sólo en RAM. +#[derive(Default)] +pub struct MemSource { + store: minga_core::MemStore, + roots: Vec, +} + +impl MemSource { + pub fn new() -> Self { + Self::default() + } + + /// Inserta un árbol como raíz (un "archivo") y devuelve su hash. + /// Idempotente: ingerir dos veces el mismo árbol no lo duplica. + pub fn add_root(&mut self, node: &SemanticNode) -> ContentHash { + use minga_core::NodeStore; + let hash = self.store.put(node); + if !self.roots.contains(&hash) { + self.roots.push(hash); + } + hash + } +} + +impl NodeSource for MemSource { + fn roots(&self) -> Vec { + self.roots.clone() + } + + fn get(&self, hash: &ContentHash) -> Option { + use minga_core::NodeStore; + self.store.get(hash).cloned() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use minga_core::ast::SemanticNode; + + fn leaf(kind: &str, text: &str) -> SemanticNode { + SemanticNode { + kind: kind.to_string(), + field_name: None, + leaf_text: Some(text.as_bytes().to_vec()), + children: Vec::new(), + } + } + + #[test] + fn mem_source_reconstructs_what_it_stored() { + let tree = SemanticNode { + kind: "root".to_string(), + field_name: None, + leaf_text: None, + children: vec![leaf("a", "1"), leaf("b", "2")], + }; + let mut src = MemSource::new(); + let hash = src.add_root(&tree); + + assert_eq!(src.roots(), vec![hash]); + let back = reconstruct(&src, &hash).expect("debe reconstruir"); + assert_eq!(back, tree); + } + + #[test] + fn add_root_is_idempotent() { + let tree = leaf("only", "x"); + let mut src = MemSource::new(); + let h1 = src.add_root(&tree); + let h2 = src.add_root(&tree); + assert_eq!(h1, h2); + assert_eq!(src.roots().len(), 1); + } + + #[test] + fn unknown_hash_reconstructs_to_none() { + let src = MemSource::new(); + assert!(reconstruct(&src, &ContentHash([0u8; 32])).is_none()); + } +} diff --git a/crates/modules/semantic_dht/minga-vfs/tests/projection.rs b/crates/modules/semantic_dht/minga-vfs/tests/projection.rs new file mode 100644 index 0000000..c41decc --- /dev/null +++ b/crates/modules/semantic_dht/minga-vfs/tests/projection.rs @@ -0,0 +1,77 @@ +//! Cobertura de la capa agnóstica del VFS (render + source) sobre +//! código real parseado con tree-sitter. No monta FUSE: verifica que +//! la proyección hash → contenido es correcta de punta a punta. + +use minga_core::parse; +use minga_vfs::render::{render_sexp, render_source}; +use minga_vfs::source::{reconstruct, MemSource, NodeSource}; + +#[test] +fn ingest_rust_then_reconstruct_is_lossless_at_the_ast_level() { + let original = parse::rust("fn add(a: i32, b: i32) -> i32 { a + b }").unwrap(); + + let mut src = MemSource::new(); + let hash = src.add_root(&original); + + // El árbol reconstruido desde el store debe ser idéntico bit a bit + // al que se ingirió: el direccionamiento por contenido lo garantiza. + let back = reconstruct(&src, &hash).expect("el hash recién insertado resuelve"); + assert_eq!(back, original); +} + +#[test] +fn roots_lists_every_ingested_file() { + let mut src = MemSource::new(); + let a = src.add_root(&parse::rust("fn a() {}").unwrap()); + let b = src.add_root(&parse::python("def b():\n pass\n").unwrap()); + + let roots = src.roots(); + assert_eq!(roots.len(), 2); + assert!(roots.contains(&a)); + assert!(roots.contains(&b)); +} + +#[test] +fn source_view_recovers_rust_keywords_and_structure() { + let node = parse::rust("fn main() { let x = 1; }").unwrap(); + let mut src = MemSource::new(); + let hash = src.add_root(&node); + + let rebuilt = reconstruct(&src, &hash).unwrap(); + let text = render_source(&rebuilt); + + for token in ["fn", "main", "let", "x", "1"] { + assert!(text.contains(token), "falta `{token}` en:\n{text}"); + } + // El cuerpo entre llaves debe quedar indentado en su propia línea. + assert!(text.contains("\n "), "cuerpo sin indentar:\n{text}"); +} + +#[test] +fn sexp_view_exposes_tree_sitter_node_kinds() { + let node = parse::rust("fn main() {}").unwrap(); + let mut src = MemSource::new(); + let hash = src.add_root(&node); + + let rebuilt = reconstruct(&src, &hash).unwrap(); + let sexp = render_sexp(&rebuilt); + + assert!(sexp.contains("(source_file"), "raíz del árbol:\n{sexp}"); + assert!(sexp.contains("function_item"), "el ítem función:\n{sexp}"); +} + +#[test] +fn deduplicated_subtrees_share_one_node() { + // Dos archivos con la misma función `helper` deben compartir el + // subárbol en el store: ingerir el segundo no lo vuelve a guardar. + let mut src = MemSource::new(); + let one = parse::rust("fn helper() { 42 }").unwrap(); + let two = parse::rust("fn helper() { 42 }").unwrap(); + + let h1 = src.add_root(&one); + let h2 = src.add_root(&two); + + // Estructura idéntica ⇒ mismo hash ⇒ una sola raíz. + assert_eq!(h1, h2); + assert_eq!(src.roots().len(), 1); +} diff --git a/docs/changelog/minga.md b/docs/changelog/minga.md index e396e1f..c04f179 100644 --- a/docs/changelog/minga.md +++ b/docs/changelog/minga.md @@ -1,5 +1,57 @@ # Changelog — minga (semantic_dht) +### feat(minga-vfs): VFS FUSE — el repo como filesystem de sólo lectura +`minga-vfs` deja de ser un stub de 2 LOC: ahora monta el repositorio +direccionado por contenido como un filesystem FUSE navegable con +`ls`/`cat`/`grep`/un editor, sin exponer `sled`. + +Layout del montaje: +- `README` — explicación del propio VFS. +- `roots/` — un archivo por raíz del MST (cada archivo + ingerido), con el código fuente **reconstruido** en formato + normalizado. `ls roots/` las enumera todas. +- `cas/` — la S-expression del subárbol de cualquier hash. + Este directorio NO se enumera (decenas de miles de nodos) pero + `cat cas/` resuelve cualquier hash conocido: es el "blob + por hash bajo demanda" que la SDD pedía. + +Arquitectura, separando por capas (ver feedback de separabilidad): +- **`render`** — `SemanticNode` → texto. Lógica pura, sin IO ni + FUSE. `render_source` re-imprime los tokens hoja con un + pretty-printer mínimo consciente de llaves (indenta tras `{`, + corta tras `;`); `render_sexp` vuelca el árbol literal. El render + de fuente es normalizado, no byte-exacto: el AST descartó + whitespace y comentarios al ingerir. Python, cuya estructura vive + en la indentación, sale como secuencia de tokens — esperado. +- **`source`** — el contrato `NodeSource` (`roots()` + `get()`) y + `reconstruct()`. Backends: `RepoSource` sobre el `PersistentRepo` + de `sled`, `MemSource` en RAM (tests / índices efímeros). +- **`fs`** — único módulo acoplado a `fuser`: implementa la + `Filesystem` trait. Inodos estáticos reservados (1-4), dinámicos + desde 16 con tabla `(padre, nombre) → inodo` estable. Cada + archivo se renderiza y cachea en el primer `lookup`. + +Dep nueva (workspace + minga-vfs): +- `fuser = "0.15"` con `default-features = false`: prescinde de + `pkg-config`/`libfuse-dev` en build; el montaje pasa a Rust puro + (helper `fusermount3` en runtime). + +CLI: nuevo subcomando `minga mount ` (`cmd_mount`), que abre +el repo, lo envuelve en `RepoSource` y bloquea hasta +`fusermount -u `. Pide la passphrase igual que `status`. + +Tests: **14** nuevos en minga-vfs (9 unit: render con llaves, +S-expression con campos/escape, parseo de hash de 64 hex, roundtrip +de `MemSource`; 5 integration: ingest→reconstruct lossless a nivel +AST, `roots/` lista todo, vistas source/sexp, deduplicación de +subárboles). Los 10 tests de minga-cli intactos (sin regresión). + +Pendientes: +- `cas/` cachea contenido sin tope: navegar un repo gigante crece + en RAM. Un LRU sería el siguiente paso si molesta. +- Sin extensión en los nombres (`roots/`): no guardamos el + lenguaje original, así que el editor no autodetecta sintaxis. + ### feat(minga-explorer): listings de items recientes en cada stat card Iter 12. Hasta ahora minga-explorer mostraba sólo counts (3 números). Ahora cada stat card muestra también un sample de los