tawasuyu

tawasuyu/brahman

Rust 582TOML 222Markdown 83JSON 198635 Definitions

feat(minga): minga-vfs — proyecta el repo como filesystem FUSE

minga-vfs deja de ser un stub: monta el repositorio direccionado por
contenido como un filesystem FUSE de sólo lectura. roots/<hash> da el
código fuente reconstruido (formato normalizado) de cada raíz del MST;
cas/<hash> 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 <punto>`. 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 <noreply@anthropic.com>

e77a32f4d6a09fe29701811f4d2e60dd2cb678db · by sergio · (5 months ago) · parent 762bf95df · browse the tree at this commit

Semantic delta · 71

crates/modules/semantic_dht/minga-cli/src/commands.rs

crates/modules/semantic_dht/minga-cli/src/main.rs

  • signature changed enum Command
  • body changed fn run

crates/modules/semantic_dht/minga-vfs/src/fs.rs

crates/modules/semantic_dht/minga-vfs/src/lib.rs

crates/modules/semantic_dht/minga-vfs/src/render.rs

crates/modules/semantic_dht/minga-vfs/src/source.rs

15 files · +1094 −14

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 <punto>` monta el repo direccionado por contenido como un
+filesystem FUSE de sólo lectura. Layout:
+
+```text
+README          explicación del propio montaje
+roots/<hash>    código fuente reconstruido (formato normalizado) de
+                cada raíz del MST — `ls roots/` las lista todas
+cas/<hash>      S-expression del subárbol de ese hash — el directorio
+                NO se enumera, pero `cat cas/<hash>` 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/<hash>`
+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 <punto>`: 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 <punto>` 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<parse::Dialect, CliError> {
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 <punto>`.
+    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/<hash>   Código fuente reconstruido (formato normalizado) de
+                 cada archivo ingerido. `ls roots/` los lista todos.
+  cas/<hash>     S-expression del subárbol con ese hash. Este
+                 directorio NO se lista (son demasiados nodos), pero
+                 `cat cas/<hash>` 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/<hash>` es una reconstrucción normalizada, no el archivo
+original byte-a-byte.
+
+Filesystem de sólo lectura. Desmontar: fusermount -u <punto>.
+";
+
+/// 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<S: NodeSource> {
+    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<u64, Vec<u8>>,
+    /// Marca de tiempo uniforme para todos los atributos.
+    epoch: SystemTime,
+    uid: u32,
+    gid: u32,
+}
+
+impl<S: NodeSource> MingaFs<S> {
+    /// 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<S: NodeSource> Filesystem for MingaFs<S> {
+    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<u64>, 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<u64>,
+        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<ContentHash> {
+    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<u8> {
+    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
+//! <punto-de-montaje>/
+//! ├── README          explicación del propio VFS
+//! ├── roots/          un archivo por raíz del MST (cada archivo ingerido)
+//! │   └── <hash64>    código fuente reconstruido, formato normalizado
+//! └── cas/            cualquier nodo del store, resuelto bajo demanda
+//!     └── <hash64>    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/<hash>` 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<MountOption> {
+    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 <punto>`, `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<S, P>(source: S, mountpoint: P) -> io::Result<()>
+where
+    S: NodeSource,
+    P: AsRef<Path>,
+{
+    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<S, P>(source: S, mountpoint: P) -> io::Result<fuser::BackgroundSession>
+where
+    S: NodeSource + Send + 'static,
+    P: AsRef<Path>,
+{
+    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<String>) {
+    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 {
+        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<ContentHash>;
+
+    /// 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<StoredNode>;
+}
+
+/// 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<S>(source: &S, hash: &ContentHash) -> Option<SemanticNode>
+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<ContentHash> {
+        // 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<StoredNode> {
+        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<ContentHash>,
+}
+
+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<ContentHash> {
+        self.roots.clone()
+    }
+
+    fn get(&self, hash: &ContentHash) -> Option<StoredNode> {
+        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/<hash>` — 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/<hash>` — la S-expression del subárbol de cualquier hash.
+  Este directorio NO se enumera (decenas de miles de nodos) pero
+  `cat cas/<hash>` 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 <punto>` (`cmd_mount`), que abre
+el repo, lo envuelve en `RepoSource` y bloquea hasta
+`fusermount -u <punto>`. 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/<hash>`): 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