gioser

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

qillqa: el awk propio — 3531 líneas, sólo libc, y CERO divergencias contra dos oráculos

«awk soberano» (usuario, 2026-09-22), que era la tercera opción del ADR pendiente
y la única que no adoptaba la semántica de otro: gawk es C con extensiones GNU,
goawk es Go y rompe la premisa de cadena de la Etapa C, frawk es otro dialecto.

Banco diferencial contra DOS oráculos a la vez —el awk de busybox 1.36.1 (el
applet que hoy viaja) y gawk 5.3.2 --posix— con 109 casos: campos y separadores,
el modelo de valores, aritmética, expresiones regulares, las funciones de cadena,
printf, control de flujo, arrays, funciones de usuario, E/S y los rincones del
parser. Resultado: 104 iguales a los dos, 0 divergencias, y 5 casos donde los
ORÁCULOS discrepan entre sí — que es justo para lo que sirve tener dos.

De esos 5, cuatro los tenemos del lado correcto por POSIX: `2^3^2` es 512 (la
potencia asocia a la derecha; busybox da 64), `index(s,"")` es 1, el `%*d` de
printf existe, y `length(array)` funciona. El quinto era nuestro y se arregló:
una coincidencia VACÍA pegada al final de la anterior no sustituye, así que
`gsub(/a*/,"-")` sobre "xax" da 3 y `-x-x-`, no 4 y `-x--x-`.

El motor de expresiones regulares es propio y esa es la razón de fondo para no
adoptar un crate: POSIX es leftmost-LONGEST y la familia Perl —el crate `regex`
en su modo por defecto— es leftmost-first. Para /a|ab/ sobre "ab", POSIX devuelve
`ab` y Perl devuelve `a`. En un awk eso decide qué borra un sub(), dónde parte un
split() y cuánto valen RSTART y RLENGTH. Construcción de Thompson con conjunto de
hilos: sin retroceso, así que `(a*)*b` contra sesenta `a` no explota.

Las cinco cosas que el banco destapó, todas dando resultados plausibles y ninguna
reventando —quedan como pruebas de regresión en tests/divergencias_medidas.rs:

  · leer NF no forzaba el partido en campos ⇒ `{print NF}` daba 0 hasta que
    alguien tocara un campo. El partido es perezoso a propósito (es lo que hace
    barato asignar a $0) y el precio es acordarse de forzarlo.
  · `length` sin paréntesis se leía como una VARIABLE llamada length, que vale
    vacío. Es el único builtin al que POSIX permite omitirlos.
  · los arrays se pasan por referencia, y eso se decide mirando si la función usa
    el parámetro como array — `a["k"]=1` tiene el nombre en el LUGAR de la
    asignación, y sólo se miraba el valor.
  · substr con inicio ≤ 0 recorta a 1 SIN ajustar el largo, y los no enteros se
    truncan. La lectura estricta de POSIX daría otra cosa; ni busybox ni gawk la
    hacen, y lo que hay que ejecutar son los guiones escritos contra ellos.
  · la regla del vacío en gsub, arriba.

Verificado además contra este repo: de los 61 programas awk únicos que usan sus
guiones y recetas, qillqa acepta los 59 reales (los 2 restantes son f-strings de
Python que mi extractor tomó por awk). 29 pruebas propias en verde.

El nombre es quechua («escritura») y sigue el precedente de simi: lo registra el
comentario de cabecera y lo DECIDE un ADR. El comando instalado será `awk` — eso
es contrato y lo llaman los configure de medio corpus.

⚠ Lo medido es el build NATIVO del hub. Falta la receta, el estático musl con zig
cc, y la prueba que de verdad decide: un `configure` de autotools con qillqa como
/usr/bin/awk. Es la lección de brush — pasó 56/57 casos POSIX y no construía una
sola receta.

Va también recipes/ncurses-tools.toml (clear, reset, tput, tset, infocmp, tic),
declarada en `base`: la canónica instala sólo lib y cabeceras, así que clear y
reset en una imagen eran el applet de busybox. Instala los binarios y NO la base
terminfo: resuelven con los --with-fallbacks compilados en la librería, que
cubren la consola del metal, QEMU y cualquier ssh moderno.

⚠ Y ncurses-tools selló DINÁMICO dos veces antes de salir bien: ncurses usa
`-static`/`-dynamic` como MARCADORES de tramo alrededor de las librerías —su
forma portable de -Wl,-Bstatic … -Wl,-Bdynamic—, no como decisión global. El
`-dynamic` llega al final de la línea de enlace y gana por orden. Se quita
sobreescribiendo LIBS_TIC y LIBS_TINFO en la línea de make. Tercer sabor del
agujero que documenta scripts/static-audit.sh.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

ae6f0b45d94eb275e2464d898b35ee9a64595f43 · by Sergio · (1 month ago) · parent 87d34b95f · browse the tree at this commit

Semantic delta · 222

crates/qillqa/src/ast.rs

crates/qillqa/src/formato.rs

crates/qillqa/src/interp.rs

crates/qillqa/src/main.rs

crates/qillqa/src/parser.rs

crates/qillqa/src/regex.rs

scripts/awk-banco-diferencial.py

15 files · +3856 −0

diff --git a/Cargo.lock b/Cargo.lock
index c7f876c6..4f3670a2 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -881,6 +881,13 @@ dependencies = [
  "unicode-ident",
 ]
 
+[[package]]
+name = "qillqa"
+version = "0.0.1"
+dependencies = [
+ "libc",
+]
+
 [[package]]
 name = "quote"
 version = "1.0.45"
diff --git a/Cargo.toml b/Cargo.toml
index be5d6b00..4e4e8366 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -14,6 +14,7 @@ members = [
     "crates/hammerd",
     "crates/netup",
     "crates/simi",
+    "crates/qillqa",
 ]
 
 [workspace.package]
diff --git a/crates/qillqa/Cargo.toml b/crates/qillqa/Cargo.toml
new file mode 100644
index 00000000..05224202
--- /dev/null
+++ b/crates/qillqa/Cargo.toml
@@ -0,0 +1,19 @@
+[package]
+name = "qillqa"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+repository.workspace = true
+description = "qillqa — el awk del producto: POSIX, sin deps, con su propio motor de expresiones regulares."
+
+[[bin]]
+name = "qillqa"
+path = "src/main.rs"
+
+# Cero crates de terceros, igual que simi. Un awk necesita leer ficheros, escribir a tuberías y
+# lanzar procesos (`system`, `print | "cmd"`, `getline < f`), y eso es libc. Lo demás —el motor ERE,
+# la conversión número/cadena, el printf— es propio: son justamente las piezas donde un awk se
+# diferencia de otro, y adoptarlas de un crate sería adoptar su semántica en vez de la de POSIX.
+[dependencies]
+libc = "0.2"
diff --git a/crates/qillqa/src/ast.rs b/crates/qillqa/src/ast.rs
new file mode 100644
index 00000000..98c5ec8a
--- /dev/null
+++ b/crates/qillqa/src/ast.rs
@@ -0,0 +1,133 @@
+//! El árbol del programa.
+
+use std::rc::Rc;
+use crate::regex::Regex;
+
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub enum BinOp { Suma, Resta, Mult, Div, Modulo, Potencia }
+
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub enum CmpOp { Lt, Le, Gt, Ge, Eq, Ne }
+
+#[derive(Clone)]
+pub enum Expr {
+    Num(f64),
+    Cad(String),
+    /// Una ERE suelta: como expresión vale `$0 ~ /re/`.
+    Ere(Rc<Regex>),
+    Var(String),
+    Campo(Box<Expr>),
+    /// `a[i]`, `a[i,j]` (los índices se unen con SUBSEP).
+    Indice(String, Vec<Expr>),
+    Asigna(Box<Lugar>, Box<Expr>),
+    /// `+=`, `-=`, … con su operador.
+    AsignaOp(Box<Lugar>, BinOp, Box<Expr>),
+    Bin(BinOp, Box<Expr>, Box<Expr>),
+    Cmp(CmpOp, Box<Expr>, Box<Expr>),
+    /// `~` y `!~`. El segundo `bool` dice si está negado.
+    Coincide(Box<Expr>, Box<Expr>, bool),
+    Y(Box<Expr>, Box<Expr>),
+    O(Box<Expr>, Box<Expr>),
+    No(Box<Expr>),
+    Neg(Box<Expr>),
+    Pos(Box<Expr>),
+    Ternario(Box<Expr>, Box<Expr>, Box<Expr>),
+    /// Concatenación por yuxtaposición: `a b`.
+    Concat(Box<Expr>, Box<Expr>),
+    /// `++x` / `x++` (y `--`). `previo` = es prefijo.
+    Incr(Box<Lugar>, bool, f64),
+    /// `(i, j) in a` y `i in a`.
+    En(Vec<Expr>, String),
+    Llamada(String, Vec<Expr>),
+    Builtin(String, Vec<Expr>),
+    /// `getline` en sus cuatro formas.
+    Getline(Getline),
+    /// `(expr)` agrupada: hay que recordarlo para distinguir `(a,b) in arr` de una lista.
+    Grupo(Box<Expr>),
+}
+
+#[derive(Clone)]
+pub enum Getline {
+    /// `getline [lugar]` — de la entrada normal.
+    Simple(Option<Box<Lugar>>),
+    /// `getline [lugar] < "fichero"`.
+    DeFichero(Option<Box<Lugar>>, Box<Expr>),
+    /// `"cmd" | getline [lugar]`.
+    DeTuberia(Box<Expr>, Option<Box<Lugar>>),
+}
+
+/// Algo a lo que se puede asignar.
+#[derive(Clone)]
+pub enum Lugar {
+    Var(String),
+    Campo(Box<Expr>),
+    Indice(String, Vec<Expr>),
+}
+
+#[derive(Clone)]
+pub enum Redir {
+    /// `> f`
+    Fichero(Box<Expr>),
+    /// `>> f`
+    Anexa(Box<Expr>),
+    /// `| "cmd"`
+    Tuberia(Box<Expr>),
+}
+
+#[derive(Clone)]
+pub enum Sent {
+    Expr(Expr),
+    Print(Vec<Expr>, Option<Redir>),
+    Printf(Vec<Expr>, Option<Redir>),
+    Bloque(Vec<Sent>),
+    Si(Expr, Box<Sent>, Option<Box<Sent>>),
+    Mientras(Expr, Box<Sent>),
+    HazMientras(Box<Sent>, Expr),
+    /// `for (init; cond; paso) cuerpo`
+    Para(Option<Box<Sent>>, Option<Expr>, Option<Box<Sent>>, Box<Sent>),
+    /// `for (k in a) cuerpo`
+    ParaEn(String, String, Box<Sent>),
+    Corta,
+    Sigue,
+    Siguiente,
+    SiguienteFichero,
+    Sale(Option<Expr>),
+    Retorna(Option<Expr>),
+    Borra(String, Vec<Expr>),
+    /// `delete a` entero.
+    BorraTodo(String),
+    Nada,
+}
+
+#[derive(Clone)]
+pub enum Patron {
+    Begin,
+    End,
+    /// Sin patrón: la acción corre para cada registro.
+    Siempre,
+    Expr(Expr),
+    /// `expr1, expr2` — rango. El `bool` es el estado (dentro/fuera), que vive en el intérprete.
+    Rango(Expr, Expr),
+}
+
+#[derive(Clone)]
+pub struct Regla {
+    pub patron: Patron,
+    pub accion: Option<Vec<Sent>>,
+    /// Índice del estado de rango, para las reglas `expr1, expr2`.
+    pub rango_id: usize,
+}
+
+#[derive(Clone)]
+pub struct Funcion {
+    pub nombre: String,
+    pub params: Vec<String>,
+    pub cuerpo: Vec<Sent>,
+}
+
+#[derive(Clone, Default)]
+pub struct Programa {
+    pub reglas: Vec<Regla>,
+    pub funciones: Vec<Funcion>,
+    pub rangos: usize,
+}
diff --git a/crates/qillqa/src/formato.rs b/crates/qillqa/src/formato.rs
new file mode 100644
index 00000000..5b03176a
--- /dev/null
+++ b/crates/qillqa/src/formato.rs
@@ -0,0 +1,349 @@
+//! `printf`/`sprintf` de awk, y la conversión número→cadena que usan CONVFMT y OFMT.
+//!
+//! Se escribe a mano porque el `format!` de Rust no habla el mismo idioma: no tiene `%c`, no tiene
+//! el `*` que toma la anchura de un argumento, y su `{:e}` no produce lo que produce `%e` de C
+//! (`1e2` contra `1.000000e+02`). Y porque awk manda cosas que C no: `%c` con un número es el
+//! carácter de ese código, pero `%c` con una cadena es su PRIMER carácter.
+//!
+//! Los formatos admitidos son los de POSIX: `%d %i %o %u %x %X %c %s %e %E %f %F %g %G %a %A %%`,
+//! con banderas `-+ #0`, anchura, precisión y `*` en las dos.
+
+pub struct Campo {
+    pub izquierda: bool,
+    pub signo: bool,
+    pub espacio: bool,
+    pub alterno: bool,
+    pub cero: bool,
+    pub ancho: Option<usize>,
+    pub precision: Option<usize>,
+    pub conv: char,
+}
+
+/// Aplica un formato de UNA conversión a un número. Lo usa `numero_a_cadena` para CONVFMT/OFMT,
+/// donde el formato viene de una variable del programa y puede ser cualquier cosa.
+pub fn una_conversion(fmt: &str, n: f64) -> String {
+    let mut salida = String::new();
+    let cs: Vec<char> = fmt.chars().collect();
+    let mut i = 0;
+    while i < cs.len() {
+        if cs[i] != '%' { salida.push(cs[i]); i += 1; continue; }
+        if i + 1 < cs.len() && cs[i + 1] == '%' { salida.push('%'); i += 2; continue; }
+        let (campo, siguiente) = match analizar(&cs, i + 1, &mut Vec::new(), &mut 0) {
+            Some(x) => x,
+            None => { salida.push('%'); i += 1; continue; }
+        };
+        salida.push_str(&aplicar_numero(&campo, n));
+        i = siguiente;
+        // Sólo la PRIMERA conversión consume el número; el resto del formato se copia tal cual.
+        while i < cs.len() { salida.push(cs[i]); i += 1; }
+    }
+    salida
+}
+
+/// Lee un especificador tras el `%`. `args` y `pos` sirven para los `*`, que consumen argumentos.
+fn analizar(cs: &[char], mut i: usize, anchos: &mut Vec<i64>, _pos: &mut usize) -> Option<(Campo, usize)> {
+    let mut c = Campo {
+        izquierda: false, signo: false, espacio: false, alterno: false, cero: false,
+        ancho: None, precision: None, conv: 's',
+    };
+    loop {
+        match cs.get(i) {
+            Some('-') => c.izquierda = true,
+            Some('+') => c.signo = true,
+            Some(' ') => c.espacio = true,
+            Some('#') => c.alterno = true,
+            Some('0') => c.cero = true,
+            _ => break,
+        }
+        i += 1;
+    }
+    if cs.get(i) == Some(&'*') {
+        i += 1;
+        let v = if anchos.is_empty() { 0 } else { anchos.remove(0) };
+        if v < 0 { c.izquierda = true; c.ancho = Some((-v) as usize); } else { c.ancho = Some(v as usize); }
+    } else {
+        let mut n = String::new();
+        while matches!(cs.get(i), Some(d) if d.is_ascii_digit()) { n.push(cs[i]); i += 1; }
+        if !n.is_empty() { c.ancho = n.parse().ok(); }
+    }
+    if cs.get(i) == Some(&'.') {
+        i += 1;
+        if cs.get(i) == Some(&'*') {
+            i += 1;
+            let v = if anchos.is_empty() { 0 } else { anchos.remove(0) };
+            c.precision = Some(v.max(0) as usize);
+        } else {
+            let mut n = String::new();
+            while matches!(cs.get(i), Some(d) if d.is_ascii_digit()) { n.push(cs[i]); i += 1; }
+            c.precision = Some(n.parse().unwrap_or(0));
+        }
+    }
+    // Modificadores de longitud de C: se aceptan y se ignoran, que es lo que hacen los awk.
+    while matches!(cs.get(i), Some('h') | Some('l') | Some('L') | Some('q') | Some('j') | Some('z') | Some('t')) { i += 1; }
+    let conv = *cs.get(i)?;
+    c.conv = conv;
+    Some((c, i + 1))
+}
+
+fn rellenar(s: String, c: &Campo, numerico: bool) -> String {
+    let ancho = match c.ancho { None => return s, Some(a) => a };
+    let largo = s.chars().count();
+    if largo >= ancho { return s; }
+    let faltan = ancho - largo;
+    if c.izquierda { return s + &" ".repeat(faltan); }
+    if c.cero && numerico && c.precision.is_none() {
+        // El cero va DESPUÉS del signo: `%05d` de -1 es `-0001`, no `000-1`.
+        let (signo, resto) = match s.chars().next() {
+            Some(x) if x == '-' || x == '+' || x == ' ' => (s[..1].to_string(), s[1..].to_string()),
+            _ => (String::new(), s),
+        };
+        return signo + &"0".repeat(faltan) + &resto;
+    }
+    " ".repeat(faltan) + &s
+}
+
+fn con_signo(s: String, negativo: bool, c: &Campo) -> String {
+    if negativo { return s; }
+    if c.signo { return format!("+{s}"); }
+    if c.espacio { return format!(" {s}"); }
+    s
+}
+
+fn aplicar_numero(c: &Campo, n: f64) -> String {
+    let cuerpo = match c.conv {
+        'd' | 'i' => {
+            // awk trunca hacia cero, como C.
+            let v = if n.is_nan() { 0 } else { n.trunc() as i64 };
+            let mut s = v.abs().to_string();
+            if let Some(p) = c.precision { if s.len() < p { s = "0".repeat(p - s.len()) + &s; } }
+            let s = if v < 0 { format!("-{s}") } else { s };
+            con_signo(s, v < 0, c)
+        }
+        'o' | 'x' | 'X' | 'u' => {
+            // Los negativos se ven como unsigned de 64 bits, igual que C.
+            let v = if n.is_nan() { 0i64 } else { n.trunc() as i64 };
+            let u = v as u64;
+            let mut s = match c.conv {
+                'o' => format!("{u:o}"),
+                'x' => format!("{u:x}"),
+                'X' => format!("{u:X}"),
+                _ => format!("{u}"),
+            };
+            if let Some(p) = c.precision { if s.len() < p { s = "0".repeat(p - s.len()) + &s; } }
+            if c.alterno {
+                match c.conv {
+                    'o' if !s.starts_with('0') => s = format!("0{s}"),
+                    'x' if u != 0 => s = format!("0x{s}"),
+                    'X' if u != 0 => s = format!("0X{s}"),
+                    _ => {}
+                }
+            }
+            s
+        }
+        'c' => {
+            let cod = n.trunc() as u32;
+            char::from_u32(cod).map(|x| x.to_string()).unwrap_or_default()
+        }
+        'e' | 'E' | 'f' | 'F' | 'g' | 'G' | 'a' | 'A' => {
+            let p = c.precision.unwrap_or(6);
+            let neg = n.is_sign_negative() && n != 0.0;
+            let a = n.abs();
+            let s = match c.conv {
+                'f' | 'F' => format!("{a:.p$}", p = p),
+                'e' | 'E' => exponencial(a, p, c.conv == 'E'),
+                'a' | 'A' => format!("{a}"), // hexadecimal de coma flotante: rarísimo en awk
+                _ => general(a, p, c.conv == 'G', c.alterno),
+            };
+            let s = if neg { format!("-{s}") } else { s };
+            con_signo(s, neg, c)
+        }
+        's' => String::new(),
+        otro => format!("%{otro}"),
+    };
+    rellenar(cuerpo, c, c.conv != 's' && c.conv != 'c')
+}
+
+fn exponencial(a: f64, p: usize, mayus: bool) -> String {
+    let s = format!("{:.*e}", p, a);
+    // Rust escribe `1.5e2`; C quiere `1.5e+02`, con al menos dos dígitos y signo siempre.
+    let (mant, exp) = match s.split_once('e') { Some(x) => x, None => return s };
+    let n: i32 = exp.parse().unwrap_or(0);
+    let e = if mayus { 'E' } else { 'e' };
+    format!("{mant}{e}{}{:02}", if n < 0 { '-' } else { '+' }, n.abs())
+}
+
+/// `%g`: el más corto entre `%e` y `%f`, y sin ceros a la derecha salvo con `#`.
+fn general(a: f64, p: usize, mayus: bool, alterno: bool) -> String {
+    let p = if p == 0 { 1 } else { p };
+    let exp = if a == 0.0 { 0 } else { a.abs().log10().floor() as i32 };
+    let usa_e = exp < -4 || exp >= p as i32;
+    let mut s = if usa_e {
+        exponencial(a, p - 1, mayus)
+    } else {
+        let dec = (p as i32 - 1 - exp).max(0) as usize;
+        format!("{a:.dec$}")
+    };
+    if !alterno && s.contains('.') {
+        // Quitar ceros de cola, y el punto si queda solo. Con exponente hay que respetar la parte
+        // de después de la `e`.
+        if let Some(pos_e) = s.find(|c| c == 'e' || c == 'E') {
+            let (m, e) = s.split_at(pos_e);
+            let m = m.trim_end_matches('0').trim_end_matches('.');
+            s = format!("{m}{e}");
+        } else {
+            s = s.trim_end_matches('0').trim_end_matches('.').to_string();
+        }
+    }
+    s
+}
+
+/// El `printf` completo: recorre el formato consumiendo argumentos.
+pub fn printf(fmt: &str, args: &[Arg], convfmt: &str) -> String {
+    let cs: Vec<char> = fmt.chars().collect();
+    let mut salida = String::new();
+    let mut i = 0;
+    let mut n_arg = 0;
+    // Los `*` consumen argumentos por delante; se resuelven al vuelo.
+    while i < cs.len() {
+        if cs[i] != '%' { salida.push(cs[i]); i += 1; continue; }
+        if cs.get(i + 1) == Some(&'%') { salida.push('%'); i += 2; continue; }
+        // Contar cuántos `*` trae este especificador para sacarlos de los argumentos.
+        let mut anchos: Vec<i64> = Vec::new();
+        {
+            let mut j = i + 1;
+            while matches!(cs.get(j), Some('-') | Some('+') | Some(' ') | Some('#') | Some('0')) { j += 1; }
+            if cs.get(j) == Some(&'*') {
+                anchos.push(args.get(n_arg).map(|a| a.numero() as i64).unwrap_or(0));
+                n_arg += 1;
+                j += 1;
+            } else { while matches!(cs.get(j), Some(d) if d.is_ascii_digit()) { j += 1; } }
+            if cs.get(j) == Some(&'.') {
+                j += 1;
+                if cs.get(j) == Some(&'*') {
+                    anchos.push(args.get(n_arg).map(|a| a.numero() as i64).unwrap_or(0));
+                    n_arg += 1;
+                }
+            }
+        }
+        let (campo, sig) = match analizar(&cs, i + 1, &mut anchos, &mut n_arg) {
+            Some(x) => x,
+            None => { salida.push('%'); i += 1; continue; }
+        };
+        i = sig;
+        let arg = args.get(n_arg);
+        n_arg += 1;
+        match campo.conv {
+            's' => {
+                let mut s = arg.map(|a| a.cadena(convfmt)).unwrap_or_default();
+                if let Some(p) = campo.precision { s = s.chars().take(p).collect(); }
+                salida.push_str(&rellenar(s, &campo, false));
+            }
+            'c' => {
+                // ⚠ `%c` mira el TIPO: con un número es el carácter de ese código; con una cadena,
+                // su primer carácter. Un `printf "%c", 65` da `A`, y `printf "%c", "65"` da `6`.
+                let s = match arg {
+                    None => String::new(),
+                    Some(a) => match a.caracter(convfmt) { Some(c) => c.to_string(), None => String::new() },
+                };
+                salida.push_str(&rellenar(s, &campo, false));
+            }
+            _ => {
+                let n = arg.map(|a| a.numero()).unwrap_or(0.0);
+                salida.push_str(&aplicar_numero(&campo, n));
+            }
+        }
+    }
+    salida
+}
+
+/// Lo que `printf` necesita saber de un argumento. Se define acá para que `formato` no dependa del
+/// intérprete entero.
+pub enum Arg {
+    Numero(f64),
+    Texto(String),
+    /// De la entrada: `%c` lo trata como cadena, `%d` como número.
+    Entrada(String, Option<f64>),
+}
+
+impl Arg {
+    pub fn numero(&self) -> f64 {
+        match self {
+            Arg::Numero(n) => *n,
+            Arg::Texto(s) => crate::valor::prefijo_numerico(s),
+            Arg::Entrada(s, n) => n.unwrap_or_else(|| crate::valor::prefijo_numerico(s)),
+        }
+    }
+    pub fn cadena(&self, convfmt: &str) -> String {
+        match self {
+            Arg::Numero(n) => crate::valor::numero_a_cadena(*n, convfmt),
+            Arg::Texto(s) => s.clone(),
+            Arg::Entrada(s, _) => s.clone(),
+        }
+    }
+    fn caracter(&self, convfmt: &str) -> Option<char> {
+        match self {
+            Arg::Numero(n) => char::from_u32(n.trunc() as u32),
+            Arg::Texto(s) => s.chars().next(),
+            // Un strnum con pinta de número se comporta como número.
+            Arg::Entrada(s, Some(n)) => { let _ = convfmt; char::from_u32(n.trunc() as u32).or_else(|| s.chars().next()) }
+            Arg::Entrada(s, None) => s.chars().next(),
+        }
+    }
+}
+
+#[cfg(test)]
+mod pruebas {
+    use super::*;
+
+    fn p(fmt: &str, args: Vec<Arg>) -> String { printf(fmt, &args, "%.6g") }
+
+    #[test]
+    fn enteros_con_ancho_y_signo() {
+        assert_eq!(p("%d", vec![Arg::Numero(42.0)]), "42");
+        assert_eq!(p("%5d|", vec![Arg::Numero(42.0)]), "   42|");
+        assert_eq!(p("%-5d|", vec![Arg::Numero(42.0)]), "42   |");
+        assert_eq!(p("%05d", vec![Arg::Numero(-1.0)]), "-0001"); // el cero va tras el signo
+        assert_eq!(p("%+d", vec![Arg::Numero(7.0)]), "+7");
+        assert_eq!(p("%d", vec![Arg::Numero(-3.9)]), "-3"); // trunca hacia cero
+    }
+
+    #[test]
+    fn coma_flotante() {
+        assert_eq!(p("%.2f", vec![Arg::Numero(3.14159)]), "3.14");
+        assert_eq!(p("%e", vec![Arg::Numero(150.0)]), "1.500000e+02");
+        assert_eq!(p("%g", vec![Arg::Numero(150.0)]), "150");
+        assert_eq!(p("%g", vec![Arg::Numero(0.0000123)]), "1.23e-05");
+        assert_eq!(p("%.3g", vec![Arg::Numero(3.14159)]), "3.14");
+    }
+
+    #[test]
+    fn porciento_c_mira_el_tipo() {
+        assert_eq!(p("%c", vec![Arg::Numero(65.0)]), "A");
+        assert_eq!(p("%c", vec![Arg::Texto("65".into())]), "6");
+        assert_eq!(p("%c", vec![Arg::Texto("héroe".into())]), "h");
+    }
+
+    #[test]
+    fn el_asterisco_toma_la_anchura_del_argumento() {
+        assert_eq!(p("%*d|", vec![Arg::Numero(5.0), Arg::Numero(42.0)]), "   42|");
+        assert_eq!(p("%.*f", vec![Arg::Numero(2.0), Arg::Numero(3.14159)]), "3.14");
+    }
+
+    #[test]
+    fn cadenas_con_precision() {
+        assert_eq!(p("%.3s", vec![Arg::Texto("abcdef".into())]), "abc");
+        assert_eq!(p("%10.3s|", vec![Arg::Texto("abcdef".into())]), "       abc|");
+    }
+
+    #[test]
+    fn faltan_argumentos_no_es_error() {
+        assert_eq!(p("%s-%d", vec![]), "-0");
+    }
+
+    #[test]
+    fn hexadecimal_y_octal() {
+        assert_eq!(p("%x", vec![Arg::Numero(255.0)]), "ff");
+        assert_eq!(p("%#X", vec![Arg::Numero(255.0)]), "0XFF");
+        assert_eq!(p("%o", vec![Arg::Numero(8.0)]), "10");
+    }
+}
diff --git a/crates/qillqa/src/interp.rs b/crates/qillqa/src/interp.rs
new file mode 100644
index 00000000..fd8060b6
--- /dev/null
+++ b/crates/qillqa/src/interp.rs
@@ -0,0 +1,1306 @@
+//! El intérprete: recorre el árbol y ejecuta.
+//!
+//! Tres piezas merecen leerse antes de tocar nada:
+//!
+//! * **Los campos y `$0` son un mismo dato con dos caras.** Escribir `$2` reconstruye `$0` uniendo
+//!   con OFS; escribir `$0` vuelve a partir en campos con FS; y asignar a `$5` cuando sólo hay tres
+//!   campos CREA los que faltan vacíos y sube NF. Todo eso vive en `partir` / `rehacer_registro`.
+//! * **El separador FS tiene cuatro comportamientos**, no uno: `" "` (el de por defecto) parte por
+//!   rachas de espacios/tabuladores/saltos y descarta los de los extremos; un carácter suelto parte
+//!   por ese carácter exacto; `"\t"` es ese caso; y cualquier cosa de más de un carácter es una ERE.
+//! * **Los arrays se pasan POR REFERENCIA a las funciones y los escalares por valor**, y eso no se
+//!   declara: se descubre al usar el parámetro. Por eso un parámetro puede empezar indefinido y
+//!   convertirse en array dentro de la llamada.
+
+use crate::ast::*;
+use crate::formato::{self, Arg};
+use crate::regex::Regex;
+use crate::valor::{self, Valor};
+use std::cell::RefCell;
+use std::collections::HashMap;
+use std::io::{BufRead, BufReader, Read, Write};
+use std::rc::Rc;
+
+/// Lo que puede haber en una variable: un escalar o un array. No hay un tercer caso, y confundirlos
+/// es un error en awk (`x[1]=1; x=2` es ilegal), pero no lo comprobamos: los awk reales tampoco lo
+/// hacen en todos los caminos y romper eso sólo rompe guiones que ya funcionaban.
+#[derive(Clone)]
+pub enum Celda {
+    Escalar(Valor),
+    Array(Rc<RefCell<HashMap<String, Valor>>>),
+}
+
+/// Por qué se interrumpió la ejecución de un bloque.
+pub enum Flujo {
+    Normal,
+    Corta,
+    Sigue,
+    Siguiente,
+    SiguienteFichero,
+    Retorna(Valor),
+    Sale(i32),
+}
+
+macro_rules! propaga {
+    ($e:expr) => {
+        match $e { Flujo::Normal => {}, otro => return Ok(otro) }
+    };
+}
+
+pub struct Salida {
+    escritor: Box<dyn Write>,
+    /// Si es una tubería, hay un hijo al que esperar en `close`.
+    hijo: Option<std::process::Child>,
+}
+
+pub struct Entrada {
+    lector: Box<dyn BufRead>,
+    hijo: Option<std::process::Child>,
+}
+
+pub struct Interprete {
+    pub prog: Programa,
+    globales: HashMap<String, Celda>,
+    /// Marcos de las funciones. El último es el actual.
+    marcos: Vec<HashMap<String, Celda>>,
+    campos: Vec<String>,
+    registro: String,
+    /// `$0` ya no refleja los campos (alguien escribió `$2`) y hay que rehacerlo antes de leerlo.
+    sucio_registro: bool,
+    /// Los campos ya no reflejan `$0`.
+    sucio_campos: bool,
+    rangos_activos: Vec<bool>,
+    salidas: HashMap<String, Salida>,
+    entradas: HashMap<String, Entrada>,
+    ere_cache: HashMap<String, Rc<Regex>>,
+    pub codigo_salida: i32,
+    semilla: u64,
+    semilla_previa: f64,
+    /// Ficheros de la línea de órdenes pendientes, y el que se está leyendo.
+    lector_actual: Option<Box<dyn BufRead>>,
+    indice_argv: usize,
+    pub stdout: Box<dyn Write>,
+}
+
+fn ahora_ms() -> u64 {
+    std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0)
+}
+
+impl Interprete {
+    pub fn nuevo(prog: Programa) -> Interprete {
+        let rangos = prog.rangos;
+        let mut it = Interprete {
+            prog,
+            globales: HashMap::new(),
+            marcos: Vec::new(),
+            campos: Vec::new(),
+            registro: String::new(),
+            sucio_registro: false,
+            sucio_campos: false,
+            rangos_activos: vec![false; rangos],
+            salidas: HashMap::new(),
+            entradas: HashMap::new(),
+            ere_cache: HashMap::new(),
+            codigo_salida: 0,
+            semilla: 0,
+            semilla_previa: 0.0,
+            lector_actual: None,
+            indice_argv: 1,
+            stdout: Box::new(std::io::BufWriter::new(std::io::stdout())),
+        };
+        for (k, v) in [("FS", " "), ("OFS", " "), ("ORS", "\n"), ("RS", "\n"),
+                       ("SUBSEP", "\u{1c}"), ("CONVFMT", "%.6g"), ("OFMT", "%.6g"), ("FILENAME", "")] {
+            it.globales.insert(k.into(), Celda::Escalar(Valor::Cad(v.into())));
+        }
+        for k in ["NR", "NF", "FNR", "RSTART", "RLENGTH"] {
+            let v = if k == "RLENGTH" { -1.0 } else { 0.0 };
+            it.globales.insert(k.into(), Celda::Escalar(Valor::Num(v)));
+        }
+        it.semilla = 0;
+        it
+    }
+
+    // ── Variables ────────────────────────────────────────────────────────────────────────────────
+
+    fn celda(&self, n: &str) -> Option<&Celda> {
+        if let Some(m) = self.marcos.last() {
+            if let Some(c) = m.get(n) { return Some(c); }
+        }
+        self.globales.get(n)
+    }
+
+    fn en_marco(&self, n: &str) -> bool {
+        self.marcos.last().map_or(false, |m| m.contains_key(n))
+    }
+
+    pub fn leer_var(&self, n: &str) -> Valor {
+        match self.celda(n) {
+            Some(Celda::Escalar(v)) => v.clone(),
+            Some(Celda::Array(_)) => Valor::Vacio,
+            None => Valor::Vacio,
+        }
+    }
+
+    pub fn poner_var(&mut self, n: &str, v: Valor) {
+        if self.en_marco(n) {
+            self.marcos.last_mut().unwrap().insert(n.into(), Celda::Escalar(v));
+            return;
+        }
+        // Escribir NF recorta o extiende el registro; escribir los demás no tiene efecto colateral.
+        if n == "NF" {
+            let nuevo = v.numero().max(0.0) as usize;
+            self.asegurar_campos();
+            self.campos.resize(nuevo, String::new());
+            self.globales.insert("NF".into(), Celda::Escalar(Valor::Num(nuevo as f64)));
+            self.sucio_registro = true;
+            return;
+        }
+        self.globales.insert(n.into(), Celda::Escalar(v));
+    }
+
+    fn cadena_de(&self, v: &Valor) -> String { v.cadena(&self.convfmt()) }
+    fn convfmt(&self) -> String { self.leer_var("CONVFMT").cadena("%.6g") }
+    fn ofmt(&self) -> String { self.leer_var("OFMT").cadena("%.6g") }
+
+    fn array(&mut self, n: &str) -> Rc<RefCell<HashMap<String, Valor>>> {
+        if let Some(Celda::Array(a)) = self.celda(n) { return a.clone(); }
+        let a = Rc::new(RefCell::new(HashMap::new()));
+        if self.en_marco(n) {
+            self.marcos.last_mut().unwrap().insert(n.into(), Celda::Array(a.clone()));
+        } else {
+            self.globales.insert(n.into(), Celda::Array(a.clone()));
+        }
+        a
+    }
+
+    // ── Campos ───────────────────────────────────────────────────────────────────────────────────
+
+    /// ⚠ Los cuatro comportamientos de FS.
+    fn partir(&mut self, linea: &str) -> Vec<String> {
+        let fs = self.leer_var("FS").cadena("%.6g");
+        Self::partir_con(linea, &fs, |p| self.ere(p))
+    }
+
+    fn partir_con<F: FnMut(&str) -> Rc<Regex>>(linea: &str, fs: &str, mut ere: F) -> Vec<String> {
+        if fs == " " {
+            return linea.split(|c: char| c == ' ' || c == '\t' || c == '\n')
+                .filter(|s| !s.is_empty()).map(|s| s.to_string()).collect();
+        }
+        if linea.is_empty() { return Vec::new(); }
+        if fs.chars().count() == 1 && fs != "\\" {
+            let c = fs.chars().next().unwrap();
+            // Un único carácter es literal, salvo que sea el espacio (ya tratado). En POSIX un
+            // metacarácter suelto —`.` o `|`— también es literal acá, y ése es el punto.
+            return linea.split(c).map(|s| s.to_string()).collect();
+        }
+        let re = ere(fs);
+        let cs: Vec<char> = linea.chars().collect();
+        let mut trozos = Vec::new();
+        let mut ini = 0;
+        let mut i = 0;
+        while i <= cs.len() {
+            match re.buscar(&cs, i) {
+                Some((a, b)) if b > a => {
+                    trozos.push(cs[ini..a].iter().collect::<String>());
+                    ini = b;
+                    i = b;
+                }
+                // Una coincidencia VACÍA no parte: si no, `FS="x*"` haría un trozo por carácter y
+                // se comería la cadena entera.
+                Some((a, _)) => { i = a + 1; }
+                None => break,
+            }
+        }
+        trozos.push(cs[ini.min(cs.len())..].iter().collect::<String>());
+        trozos
+    }
+
+    fn asegurar_campos(&mut self) {
+        if !self.sucio_campos { return; }
+        self.sucio_campos = false;
+        let reg = self.registro.clone();
+        self.campos = self.partir(&reg);
+        let nf = self.campos.len() as f64;
+        self.globales.insert("NF".into(), Celda::Escalar(Valor::Num(nf)));
+    }
+
+    fn asegurar_registro(&mut self) {
+        if !self.sucio_registro { return; }
+        self.sucio_registro = false;
+        let ofs = self.leer_var("OFS").cadena("%.6g");
+        self.registro = self.campos.join(&ofs);
+    }
+
+    pub fn poner_registro(&mut self, s: String) {
+        self.registro = s;
+        self.sucio_campos = true;
+        self.sucio_registro = false;
+    }
+
+    fn leer_campo(&mut self, i: usize) -> Valor {
+        if i == 0 {
+            self.asegurar_registro();
+            return Valor::de_entrada(&self.registro.clone());
+        }
+        self.asegurar_campos();
+        match self.campos.get(i - 1) {
+            Some(s) => Valor::de_entrada(s),
+            None => Valor::Vacio,
+        }
+    }
+
+    fn poner_campo(&mut self, i: usize, v: String) {
+        if i == 0 {
+            self.poner_registro(v);
+            self.asegurar_campos();
+            return;
+        }
+        self.asegurar_campos();
+        if i > self.campos.len() {
+            self.campos.resize(i, String::new());
+            self.globales.insert("NF".into(), Celda::Escalar(Valor::Num(self.campos.len() as f64)));
+        }
+        self.campos[i - 1] = v;
+        self.sucio_registro = true;
+    }
+
+    // ── Expresiones regulares ────────────────────────────────────────────────────────────────────
+
+    /// Compila y CACHEA. Sin la caché, un `gsub(/x/,"")` dentro de un bucle recompilaría el patrón
+    /// en cada vuelta; con ella se compila una vez por texto distinto.
+    fn ere(&self, patron: &str) -> Rc<Regex> {
+        // La caché es interior-mutable de hecho: se usa desde `&self` para poder llamarla desde
+        // `partir`, que ya tiene prestado el intérprete. Se resuelve compilando sin cachear cuando
+        // no se puede tomar prestado — es raro y el coste es de una compilación.
+        if let Some(r) = self.ere_cache.get(patron) { return r.clone(); }
+        Rc::new(Regex::nueva(patron).unwrap_or_else(|e| {
+            eprintln!("qillqa: expresión regular inválida /{patron}/: {e}");
+            std::process::exit(2);
+        }))
+    }
+
+    fn ere_mut(&mut self, patron: &str) -> Rc<Regex> {
+        if let Some(r) = self.ere_cache.get(patron) { return r.clone(); }
+        let r = self.ere(patron);
+        self.ere_cache.insert(patron.to_string(), r.clone());
+        r
+    }
+
+    /// La ERE de una expresión: si es un literal `/re/`, la suya; si no, se compila su valor.
+    fn ere_de(&mut self, e: &Expr) -> Result<Rc<Regex>, String> {
+        if let Expr::Ere(r) = e { return Ok(r.clone()); }
+        let v = self.eval(e)?;
+        let s = self.cadena_de(&v);
+        Ok(self.ere_mut(&s))
+    }
+
+    // ── Ejecución ────────────────────────────────────────────────────────────────────────────────
+
+    pub fn ejecutar_begin(&mut self) -> Result<Flujo, String> {
+        let reglas = self.prog.reglas.clone();
+        for r in reglas.iter().filter(|r| matches!(r.patron, Patron::Begin)) {
+            if let Some(a) = &r.accion {
+                propaga!(self.bloque(a)?);
+            }
+        }
+        Ok(Flujo::Normal)
+    }
+
+    pub fn ejecutar_end(&mut self) -> Result<Flujo, String> {
+        let reglas = self.prog.reglas.clone();
+        for r in reglas.iter().filter(|r| matches!(r.patron, Patron::End)) {
+            if let Some(a) = &r.accion {
+                propaga!(self.bloque(a)?);
+            }
+        }
+        Ok(Flujo::Normal)
+    }
+
+    pub fn hay_main(&self) -> bool {
+        self.prog.reglas.iter().any(|r| !matches!(r.patron, Patron::Begin))
+    }
+    pub fn hay_end(&self) -> bool {
+        self.prog.reglas.iter().any(|r| matches!(r.patron, Patron::End))
+    }
+
+    /// Corre las reglas normales para el registro actual.
+    pub fn ejecutar_reglas(&mut self) -> Result<Flujo, String> {
+        let reglas = self.prog.reglas.clone();
+        for r in &reglas {
+            let corre = match &r.patron {
+                Patron::Begin | Patron::End => continue,
+                Patron::Siempre => true,
+                Patron::Expr(e) => self.eval(e)?.verdad(),
+                Patron::Rango(a, b) => {
+                    let id = r.rango_id;
+                    if !self.rangos_activos[id] {
+                        if self.eval(a)?.verdad() {
+                            // Un rango cuyo final casa en la MISMA línea empieza y acaba ahí.
+                            self.rangos_activos[id] = !self.eval(b)?.verdad();
+                            true
+                        } else { false }
+                    } else {
+                        if self.eval(b)?.verdad() { self.rangos_activos[id] = false; }
+                        true
+                    }
+                }
+            };
+            if !corre { continue; }
+            match &r.accion {
+                None => { let s = self.leer_campo(0); let t = self.cadena_de(&s); self.escribir_linea(&t, &None)?; }
+                Some(a) => propaga!(self.bloque(a)?),
+            }
+        }
+        Ok(Flujo::Normal)
+    }
+
+    fn bloque(&mut self, ss: &[Sent]) -> Result<Flujo, String> {
+        for s in ss { propaga!(self.sentencia(s)?); }
+        Ok(Flujo::Normal)
+    }
+
+    fn sentencia(&mut self, s: &Sent) -> Result<Flujo, String> {
+        match s {
+            Sent::Nada => {}
+            Sent::Expr(e) => { self.eval(e)?; }
+            Sent::Bloque(b) => propaga!(self.bloque(b)?),
+            Sent::Print(args, redir) => {
+                let ofs = self.leer_var("OFS").cadena("%.6g");
+                let ofmt = self.ofmt();
+                let mut partes = Vec::new();
+                if args.is_empty() {
+                    let v = self.leer_campo(0);
+                    partes.push(self.cadena_de(&v));
+                } else {
+                    let planos = Self::aplanar(args);
+                    for a in &planos {
+                        let v = self.eval(a)?;
+                        // `print` usa OFMT para los no-enteros, no CONVFMT. Es la única diferencia
+                        // entre las dos variables y se nota en cuanto alguien toca OFMT.
+                        partes.push(match &v {
+                            Valor::Num(n) => valor::numero_a_cadena(*n, &ofmt),
+                            otro => self.cadena_de(otro),
+                        });
+                    }
+                }
+                let linea = partes.join(&ofs);
+                self.escribir_linea(&linea, redir)?;
+            }
+            Sent::Printf(args, redir) => {
+                let planos = Self::aplanar(args);
+                if planos.is_empty() { return Err("printf sin formato".into()); }
+                let fv = self.eval(&planos[0])?;
+                let fmt = self.cadena_de(&fv);
+                let mut vals = Vec::new();
+                for a in &planos[1..] {
+                    let v = self.eval(a)?;
+                    vals.push(match v {
+                        Valor::Num(n) => Arg::Numero(n),
+                        Valor::Cad(s) => Arg::Texto(s),
+                        Valor::StrNum(s, n) => Arg::Entrada(s, n),
+                        Valor::Vacio => Arg::Texto(String::new()),
+                    });
+                }
+                let texto = formato::printf(&fmt, &vals, &self.convfmt());
+                self.escribir_crudo(&texto, redir)?;
+            }
+            Sent::Si(c, a, b) => {
+                if self.eval(c)?.verdad() { propaga!(self.sentencia(a)?); }
+                else if let Some(b) = b { propaga!(self.sentencia(b)?); }
+            }
+            Sent::Mientras(c, cuerpo) => {
+                while self.eval(c)?.verdad() {
+                    match self.sentencia(cuerpo)? {
+                        Flujo::Corta => break,
+                        Flujo::Sigue | Flujo::Normal => {}
+                        otro => return Ok(otro),
+                    }
+                }
+            }
+            Sent::HazMientras(cuerpo, c) => {
+                loop {
+                    match self.sentencia(cuerpo)? {
+                        Flujo::Corta => break,
+                        Flujo::Sigue | Flujo::Normal => {}
+                        otro => return Ok(otro),
+                    }
+                    if !self.eval(c)?.verdad() { break; }
+                }
+            }
+            Sent::Para(init, cond, paso, cuerpo) => {
+                if let Some(i) = init { propaga!(self.sentencia(i)?); }
+                loop {
+                    if let Some(c) = cond { if !self.eval(c)?.verdad() { break; } }
+                    match self.sentencia(cuerpo)? {
+                        Flujo::Corta => break,
+                        Flujo::Sigue | Flujo::Normal => {}
+                        otro => return Ok(otro),
+                    }
+                    if let Some(p) = paso { propaga!(self.sentencia(p)?); }
+                }
+            }
+            Sent::ParaEn(k, arr, cuerpo) => {
+                let a = self.array(arr);
+                // Se copian las claves: el cuerpo puede borrar elementos, y recorrer el mapa
+                // mientras se modifica es justo lo que rompe en los awk que no lo hacen.
+                let claves: Vec<String> = a.borrow().keys().cloned().collect();
+                for c in claves {
+                    if !a.borrow().contains_key(&c) { continue; }
+                    self.poner_var(k, Valor::de_entrada(&c));
+                    match self.sentencia(cuerpo)? {
+                        Flujo::Corta => break,
+                        Flujo::Sigue | Flujo::Normal => {}
+                        otro => return Ok(otro),
+                    }
+                }
+            }
+            Sent::Corta => return Ok(Flujo::Corta),
+            Sent::Sigue => return Ok(Flujo::Sigue),
+            Sent::Siguiente => return Ok(Flujo::Siguiente),
+            Sent::SiguienteFichero => return Ok(Flujo::SiguienteFichero),
+            Sent::Sale(e) => {
+                let c = match e { Some(x) => self.eval(x)?.numero() as i32, None => self.codigo_salida };
+                return Ok(Flujo::Sale(c));
+            }
+            Sent::Retorna(e) => {
+                let v = match e { Some(x) => self.eval(x)?, None => Valor::Vacio };
+                return Ok(Flujo::Retorna(v));
+            }
+            Sent::Borra(n, idx) => {
+                let clave = self.clave(idx)?;
+                let a = self.array(n);
+                a.borrow_mut().remove(&clave);
+            }
+            Sent::BorraTodo(n) => { let a = self.array(n); a.borrow_mut().clear(); }
+        }
+        Ok(Flujo::Normal)
+    }
+
+    /// `print (a, b)` llega como una «llamada» sintética `@lista`: acá se desarma.
+    fn aplanar(args: &[Expr]) -> Vec<Expr> {
+        if args.len() == 1 {
+            if let Expr::Llamada(n, lista) = &args[0] {
+                if n == "@lista" { return lista.clone(); }
+            }
+        }
+        args.to_vec()
+    }
+
+    fn clave(&mut self, idx: &[Expr]) -> Result<String, String> {
+        let subsep = self.leer_var("SUBSEP").cadena("%.6g");
+        let mut partes = Vec::new();
+        for e in idx {
+            let v = self.eval(e)?;
+            partes.push(self.cadena_de(&v));
+        }
+        Ok(partes.join(&subsep))
+    }
+
+    // ── Salidas y entradas ───────────────────────────────────────────────────────────────────────
+
+    fn escribir_linea(&mut self, s: &str, r: &Option<Redir>) -> Result<(), String> {
+        let ors = self.leer_var("ORS").cadena("%.6g");
+        self.escribir_crudo(&format!("{s}{ors}"), r)
+    }
+
+    fn escribir_crudo(&mut self, s: &str, r: &Option<Redir>) -> Result<(), String> {
+        match r {
+            None => { let _ = self.stdout.write_all(s.as_bytes()); }
+            Some(red) => {
+                let (destino, modo) = match red {
+                    Redir::Fichero(e) => (self.eval(e)?, 'w'),
+                    Redir::Anexa(e) => (self.eval(e)?, 'a'),
+                    Redir::Tuberia(e) => (self.eval(e)?, 'p'),
+                };
+                let nombre = self.cadena_de(&destino);
+                self.abrir_salida(&nombre, modo)?;
+                if let Some(sal) = self.salidas.get_mut(&nombre) {
+                    let _ = sal.escritor.write_all(s.as_bytes());
+                }
+            }
+        }
+        Ok(())
+    }
+
+    fn abrir_salida(&mut self, nombre: &str, modo: char) -> Result<(), String> {
+        if self.salidas.contains_key(nombre) { return Ok(()); }
+        let sal = match modo {
+            'p' => {
+                // La tubería la abre un `sh -c`, como manda POSIX: el destino es una LÍNEA DE
+                // ÓRDENES, no un programa. Por eso `print | "sort -k2 | head"` funciona.
+                let mut hijo = std::process::Command::new("/bin/sh")
+                    .arg("-c").arg(nombre)
+                    .stdin(std::process::Stdio::piped())
+                    .spawn().map_err(|e| format!("no pude lanzar `{nombre}`: {e}"))?;
+                let entrada = hijo.stdin.take().unwrap();
+                Salida { escritor: Box::new(entrada), hijo: Some(hijo) }
+            }
+            _ => {
+                if nombre == "/dev/stdout" || nombre == "-" {
+                    Salida { escritor: Box::new(std::io::stdout()), hijo: None }
+                } else if nombre == "/dev/stderr" {
+                    Salida { escritor: Box::new(std::io::stderr()), hijo: None }
+                } else {
+                    let f = std::fs::OpenOptions::new()
+                        .write(true).create(true)
+                        .append(modo == 'a').truncate(modo == 'w')
+                        .open(nombre).map_err(|e| format!("no pude abrir `{nombre}`: {e}"))?;
+                    Salida { escritor: Box::new(std::io::BufWriter::new(f)), hijo: None }
+                }
+            }
+        };
+        self.salidas.insert(nombre.to_string(), sal);
+        Ok(())
+    }
+
+    pub fn cerrar_todo(&mut self) {
+        let _ = self.stdout.flush();
+        let nombres: Vec<String> = self.salidas.keys().cloned().collect();
+        for n in nombres { self.cerrar(&n); }
+        let nombres: Vec<String> = self.entradas.keys().cloned().collect();
+        for n in nombres { self.cerrar(&n); }
+    }
+
+    fn cerrar(&mut self, nombre: &str) -> i32 {
+        let mut r = -1;
+        if let Some(mut s) = self.salidas.remove(nombre) {
+            let _ = s.escritor.flush();
+            drop(s.escritor);
+            if let Some(mut h) = s.hijo.take() {
+                r = h.wait().ok().and_then(|e| e.code()).unwrap_or(0);
+            } else { r = 0; }
+        }
+        if let Some(mut e) = self.entradas.remove(nombre) {
+            drop(std::mem::replace(&mut e.lector, Box::new(std::io::empty())));
+            if let Some(mut h) = e.hijo.take() {
+                r = h.wait().ok().and_then(|x| x.code()).unwrap_or(0);
+            } else { r = 0; }
+        }
+        r
+    }
+
+    /// Lee un registro del lector dado, respetando RS. Devuelve `None` en fin de fichero.
+    fn leer_registro(lector: &mut dyn BufRead, rs: &str) -> Option<String> {
+        if rs == "\n" {
+            let mut buf = Vec::new();
+            match lector.read_until(b'\n', &mut buf) {
+                Ok(0) | Err(_) => return None,
+                Ok(_) => {}
+            }
+            if buf.last() == Some(&b'\n') { buf.pop(); }
+            if buf.last() == Some(&b'\r') { buf.pop(); }
+            return Some(String::from_utf8_lossy(&buf).into_owned());
+        }
+        if rs.is_empty() {
+            // RS="" es el MODO PÁRRAFO: los registros se separan por líneas en blanco, las de
+            // delante se descartan, y `\n` pasa a ser separador de campos además de FS.
+            let mut lineas: Vec<String> = Vec::new();
+            loop {
+                let mut buf = Vec::new();
+                match lector.read_until(b'\n', &mut buf) {
+                    Ok(0) | Err(_) => break,
+                    Ok(_) => {}
+                }
+                if buf.last() == Some(&b'\n') { buf.pop(); }
+                let l = String::from_utf8_lossy(&buf).into_owned();
+                if l.is_empty() {
+                    if lineas.is_empty() { continue; } // blancos de delante
+                    break;
+                }
+                lineas.push(l);
+            }
+            if lineas.is_empty() { return None; }
+            return Some(lineas.join("\n"));
+        }
+        // RS de un carácter.
+        let sep = rs.as_bytes()[0];
+        let mut buf = Vec::new();
+        match lector.read_until(sep, &mut buf) {
+            Ok(0) | Err(_) => return None,
+            Ok(_) => {}
+        }
+        if buf.last() == Some(&sep) { buf.pop(); }
+        Some(String::from_utf8_lossy(&buf).into_owned())
+    }
+
+    /// Siguiente registro de la entrada principal (los ficheros de ARGV, o la entrada estándar).
+    pub fn siguiente_registro(&mut self) -> Option<String> {
+        let rs = self.leer_var("RS").cadena("%.6g");
+        loop {
+            if self.lector_actual.is_none() && !self.abrir_siguiente_fuente() { return None; }
+            let lector = self.lector_actual.as_mut().unwrap();
+            match Self::leer_registro(lector.as_mut(), &rs) {
+                Some(r) => {
+                    let nr = self.leer_var("NR").numero() + 1.0;
+                    let fnr = self.leer_var("FNR").numero() + 1.0;
+                    self.poner_var("NR", Valor::Num(nr));
+                    self.poner_var("FNR", Valor::Num(fnr));
+                    return Some(r);
+                }
+                None => { self.lector_actual = None; }
+            }
+        }
+    }
+
+    pub fn cerrar_fuente_actual(&mut self) { self.lector_actual = None; }
+
+    /// Abre el siguiente operando de ARGV que no sea una asignación. Devuelve false si no queda.
+    fn abrir_siguiente_fuente(&mut self) -> bool {
+        let argc = self.leer_var("ARGC").numero() as usize;
+        while self.indice_argv < argc {
+            let i = self.indice_argv;
+            self.indice_argv += 1;
+            let arr = self.array("ARGV");
+            let v = arr.borrow().get(&i.to_string()).cloned().unwrap_or(Valor::Vacio);
+            let s = self.cadena_de(&v);
+            if s.is_empty() { continue; }
+            // `var=valor` entre ficheros: asignación diferida, no un fichero.
+            if let Some(p) = posicion_asignacion(&s) {
+                let (n, v) = (s[..p].to_string(), desescapar(&s[p + 1..]));
+                self.poner_var(&n, Valor::de_entrada(&v));
+                continue;
+            }
+            self.poner_var("FILENAME", Valor::Cad(s.clone()));
+            self.poner_var("FNR", Valor::Num(0.0));
+            if s == "-" || s == "/dev/stdin" {
+                self.lector_actual = Some(Box::new(BufReader::new(std::io::stdin())));
+                return true;
+            }
+            match std::fs::File::open(&s) {
+                Ok(f) => { self.lector_actual = Some(Box::new(BufReader::new(f))); return true; }
+                Err(e) => {
+                    eprintln!("qillqa: no puedo leer `{s}`: {e}");
+                    self.codigo_salida = 2;
+                    continue;
+                }
+            }
+        }
+        // Ningún operando de fichero: se lee la entrada estándar, una sola vez.
+        if !self.uso_stdin() {
+            self.marcar_stdin();
+            self.lector_actual = Some(Box::new(BufReader::new(std::io::stdin())));
+            return true;
+        }
+        false
+    }
+
+    fn uso_stdin(&self) -> bool { self.globales.contains_key("@stdin-usado") }
+    fn marcar_stdin(&mut self) { self.globales.insert("@stdin-usado".into(), Celda::Escalar(Valor::Num(1.0))); }
+
+    // ── Evaluación de expresiones ────────────────────────────────────────────────────────────────
+
+    pub fn eval(&mut self, e: &Expr) -> Result<Valor, String> {
+        Ok(match e {
+            Expr::Num(n) => Valor::Num(*n),
+            Expr::Cad(s) => Valor::Cad(s.clone()),
+            Expr::Grupo(inner) => self.eval(inner)?,
+            Expr::Ere(r) => {
+                // Una ERE suelta vale `$0 ~ /re/`.
+                let reg = self.leer_campo(0);
+                let s = self.cadena_de(&reg);
+                Valor::Num(if r.casa(&s) { 1.0 } else { 0.0 })
+            }
+            // ⚠ Leer NF obliga a PARTIR el registro: si no, `{print NF}` da 0 hasta que alguien
+            // toque un campo. El estado perezoso (`sucio_campos`) es lo que hace barato asignar a
+            // `$0`, y el precio es acordarse de forzarlo en cada lectura que dependa de los campos.
+            Expr::Var(n) if n == "NF" => { self.asegurar_campos(); self.leer_var(n) }
+            Expr::Var(n) => self.leer_var(n),
+            Expr::Campo(i) => {
+                let idx = self.eval(i)?.numero();
+                if idx < 0.0 { return Err(format!("intento de acceder al campo {idx}")); }
+                self.leer_campo(idx as usize)
+            }
+            Expr::Indice(n, idx) => {
+                let clave = self.clave(idx)?;
+                let a = self.array(n);
+                // Leer un elemento que no existe lo CREA vacío. Es observable con `in`, y es lo que
+                // manda POSIX: `if (a["x"] == "") ...` deja `"x"` dentro del array.
+                let mut m = a.borrow_mut();
+                m.entry(clave).or_insert(Valor::Vacio).clone()
+            }
+            Expr::Asigna(l, v) => { let val = self.eval(v)?; self.asignar(l, val.clone())?; val }
+            Expr::AsignaOp(l, op, v) => {
+                let actual = self.leer_lugar(l)?;
+                let d = self.eval(v)?;
+                let r = Valor::Num(aritmetica(*op, actual.numero(), d.numero()));
+                self.asignar(l, r.clone())?;
+                r
+            }
+            Expr::Bin(op, a, b) => {
+                let x = self.eval(a)?.numero();
+                let y = self.eval(b)?.numero();
+                Valor::Num(aritmetica(*op, x, y))
+            }
+            Expr::Cmp(op, a, b) => {
+                let x = self.eval(a)?;
+                let y = self.eval(b)?;
+                let o = x.comparar(&y, &self.convfmt());
+                let r = match op {
+                    CmpOp::Lt => o.is_lt(), CmpOp::Le => o.is_le(), CmpOp::Gt => o.is_gt(),
+                    CmpOp::Ge => o.is_ge(), CmpOp::Eq => o.is_eq(), CmpOp::Ne => o.is_ne(),
+                };
+                Valor::Num(if r { 1.0 } else { 0.0 })
+            }
+            Expr::Coincide(a, b, negado) => {
+                let v = self.eval(a)?;
+                let s = self.cadena_de(&v);
+                let re = self.ere_de(b)?;
+                let casa = re.casa(&s);
+                Valor::Num(if casa != *negado { 1.0 } else { 0.0 })
+            }
+            Expr::Y(a, b) => {
+                if !self.eval(a)?.verdad() { Valor::Num(0.0) }
+                else { Valor::Num(if self.eval(b)?.verdad() { 1.0 } else { 0.0 }) }
+            }
+            Expr::O(a, b) => {
+                if self.eval(a)?.verdad() { Valor::Num(1.0) }
+                else { Valor::Num(if self.eval(b)?.verdad() { 1.0 } else { 0.0 }) }
+            }
+            Expr::No(a) => Valor::Num(if self.eval(a)?.verdad() { 0.0 } else { 1.0 }),
+            Expr::Neg(a) => Valor::Num(-self.eval(a)?.numero()),
+            Expr::Pos(a) => Valor::Num(self.eval(a)?.numero()),
+            Expr::Ternario(c, a, b) => if self.eval(c)?.verdad() { self.eval(a)? } else { self.eval(b)? },
+            Expr::Concat(a, b) => {
+                let x = self.eval(a)?;
+                let y = self.eval(b)?;
+                Valor::Cad(format!("{}{}", self.cadena_de(&x), self.cadena_de(&y)))
+            }
+            Expr::Incr(l, previo, delta) => {
+                let antes = self.leer_lugar(l)?.numero();
+                let despues = antes + delta;
+                self.asignar(l, Valor::Num(despues))?;
+                Valor::Num(if *previo { despues } else { antes })
+            }
+            Expr::En(idx, arr) => {
+                let clave = self.clave(idx)?;
+                let a = self.array(arr);
+                let hay = a.borrow().contains_key(&clave);
+                Valor::Num(if hay { 1.0 } else { 0.0 })
+            }
+            Expr::Builtin(n, args) => self.builtin(n, args)?,
+            Expr::Llamada(n, args) => self.llamar(n, args)?,
+            Expr::Getline(g) => self.getline(g)?,
+        })
+    }
+
+    fn leer_lugar(&mut self, l: &Lugar) -> Result<Valor, String> {
+        Ok(match l {
+            Lugar::Var(n) => self.leer_var(n),
+            Lugar::Campo(i) => { let idx = self.eval(i)?.numero() as usize; self.leer_campo(idx) }
+            Lugar::Indice(n, idx) => {
+                let clave = self.clave(idx)?;
+                let a = self.array(n);
+                let v = a.borrow().get(&clave).cloned();
+                v.unwrap_or(Valor::Vacio)
+            }
+        })
+    }
+
+    fn asignar(&mut self, l: &Lugar, v: Valor) -> Result<(), String> {
+        match l {
+            Lugar::Var(n) => self.poner_var(n, v),
+            Lugar::Campo(i) => {
+                let idx = self.eval(i)?.numero() as usize;
+                let s = self.cadena_de(&v);
+                self.poner_campo(idx, s);
+            }
+            Lugar::Indice(n, idx) => {
+                let clave = self.clave(idx)?;
+                let a = self.array(n);
+                a.borrow_mut().insert(clave, v);
+            }
+        }
+        Ok(())
+    }
+
+    // ── Funciones del programa ───────────────────────────────────────────────────────────────────
+
+    fn llamar(&mut self, nombre: &str, args: &[Expr]) -> Result<Valor, String> {
+        let f = self.prog.funciones.iter().find(|f| f.nombre == nombre).cloned()
+            .ok_or_else(|| format!("función no definida: `{nombre}`"))?;
+        let mut marco: HashMap<String, Celda> = HashMap::new();
+        for (i, p) in f.params.iter().enumerate() {
+            match args.get(i) {
+                None => { marco.insert(p.clone(), Celda::Escalar(Valor::Vacio)); }
+                Some(a) => {
+                    // ⚠ Un array se pasa POR REFERENCIA y un escalar por valor. Cuál es cada uno se
+                    // decide mirando qué hay en la variable: si ya es un array, se comparte el Rc.
+                    if let Expr::Var(vn) = a {
+                        let es_array = matches!(self.celda(vn), Some(Celda::Array(_)));
+                        if es_array {
+                            let arr = self.array(vn);
+                            marco.insert(p.clone(), Celda::Array(arr));
+                            continue;
+                        }
+                        // Una variable nunca usada que se pasa a un parámetro que la función usa
+                        // como array tiene que compartir el array. Se resuelve creándolo ya.
+                        if self.celda(vn).is_none() && self.usa_como_array(&f, p) {
+                            let arr = self.array(vn);
+                            marco.insert(p.clone(), Celda::Array(arr));
+                            continue;
+                        }
+                    }
+                    let v = self.eval(a)?;
+                    marco.insert(p.clone(), Celda::Escalar(v));
+                }
+            }
+        }
+        self.marcos.push(marco);
+        let r = self.bloque(&f.cuerpo);
+        self.marcos.pop();
+        match r? {
+            Flujo::Retorna(v) => Ok(v),
+            Flujo::Sale(c) => Err(format!("@salir:{c}")),
+            _ => Ok(Valor::Vacio),
+        }
+    }
+
+    /// ¿La función usa este parámetro como array? Se mira el cuerpo: `p[...]`, `in p`, `delete p`.
+    fn usa_como_array(&self, f: &Funcion, p: &str) -> bool {
+        fn en_sent(s: &Sent, p: &str) -> bool {
+            match s {
+                Sent::Expr(e) => en_expr(e, p),
+                Sent::Print(a, _) | Sent::Printf(a, _) => a.iter().any(|e| en_expr(e, p)),
+                Sent::Bloque(b) => b.iter().any(|s| en_sent(s, p)),
+                Sent::Si(c, a, b) => en_expr(c, p) || en_sent(a, p) || b.as_ref().map_or(false, |b| en_sent(b, p)),
+                Sent::Mientras(c, b) => en_expr(c, p) || en_sent(b, p),
+                Sent::HazMientras(b, c) => en_expr(c, p) || en_sent(b, p),
+                Sent::Para(i, c, s2, b) => i.as_ref().map_or(false, |x| en_sent(x, p))
+                    || c.as_ref().map_or(false, |x| en_expr(x, p))
+                    || s2.as_ref().map_or(false, |x| en_sent(x, p)) || en_sent(b, p),
+                Sent::ParaEn(_, a, b) => a == p || en_sent(b, p),
+                Sent::Borra(n, _) | Sent::BorraTodo(n) => n == p,
+                Sent::Sale(e) | Sent::Retorna(e) => e.as_ref().map_or(false, |x| en_expr(x, p)),
+                _ => false,
+            }
+        }
+        fn en_lugar(l: &Lugar, p: &str) -> bool {
+            match l { Lugar::Indice(n, _) => n == p, Lugar::Campo(e) => en_expr(e, p), _ => false }
+        }
+        fn en_expr(e: &Expr, p: &str) -> bool {
+            match e {
+                Expr::Indice(n, _) => n == p,
+                Expr::En(_, n) => n == p,
+                Expr::Llamada(_, args) | Expr::Builtin(_, args) => args.iter().any(|a| en_expr(a, p))
+                    || args.iter().any(|a| matches!(a, Expr::Var(v) if v == p)),
+                Expr::Bin(_, a, b) | Expr::Cmp(_, a, b) | Expr::Y(a, b) | Expr::O(a, b)
+                    | Expr::Concat(a, b) => en_expr(a, p) || en_expr(b, p),
+                Expr::Coincide(a, b, _) => en_expr(a, p) || en_expr(b, p),
+                Expr::No(a) | Expr::Neg(a) | Expr::Pos(a) | Expr::Grupo(a) | Expr::Campo(a) => en_expr(a, p),
+                Expr::Ternario(a, b, c) => en_expr(a, p) || en_expr(b, p) || en_expr(c, p),
+                // ⚠ `a["k"] = 1` marca a `a` como array, y el nombre está en el LUGAR, no en el
+                // valor. Mirar sólo el valor dejaba `funcion-array-ref` sin compartir el array.
+                Expr::Asigna(l, v) => en_lugar(l, p) || en_expr(v, p),
+                Expr::AsignaOp(l, _, v) => en_lugar(l, p) || en_expr(v, p),
+                Expr::Incr(l, _, _) => en_lugar(l, p),
+                _ => false,
+            }
+        }
+        f.cuerpo.iter().any(|s| en_sent(s, p))
+    }
+
+    // ── getline ──────────────────────────────────────────────────────────────────────────────────
+
+    fn getline(&mut self, g: &Getline) -> Result<Valor, String> {
+        let rs = self.leer_var("RS").cadena("%.6g");
+        match g {
+            Getline::Simple(l) => {
+                match self.siguiente_registro() {
+                    None => Ok(Valor::Num(0.0)),
+                    Some(r) => {
+                        match l {
+                            None => { self.poner_registro(r); }
+                            Some(lug) => { let lug = (**lug).clone(); self.asignar(&lug, Valor::de_entrada(&r))?; }
+                        }
+                        Ok(Valor::Num(1.0))
+                    }
+                }
+            }
+            Getline::DeFichero(l, f) => {
+                let fv = self.eval(f)?;
+                let nombre = self.cadena_de(&fv);
+                if !self.entradas.contains_key(&nombre) {
+                    let lector: Box<dyn BufRead> = if nombre == "-" || nombre == "/dev/stdin" {
+                        Box::new(BufReader::new(std::io::stdin()))
+                    } else {
+                        match std::fs::File::open(&nombre) {
+                            Ok(f) => Box::new(BufReader::new(f)),
+                            // getline devuelve -1 cuando no puede abrir: es un valor, no un error.
+                            Err(_) => return Ok(Valor::Num(-1.0)),
+                        }
+                    };
+                    self.entradas.insert(nombre.clone(), Entrada { lector, hijo: None });
+                }
+                let ent = self.entradas.get_mut(&nombre).unwrap();
+                match Self::leer_registro(ent.lector.as_mut(), &rs) {
+                    None => Ok(Valor::Num(0.0)),
+                    Some(r) => {
+                        match l {
+                            // `getline < f` sin variable pone $0 y NF, pero NO toca NR.
+                            None => { self.poner_registro(r); }
+                            Some(lug) => { let lug = (**lug).clone(); self.asignar(&lug, Valor::de_entrada(&r))?; }
+                        }
+                        Ok(Valor::Num(1.0))
+                    }
+                }
+            }
+            Getline::DeTuberia(cmd, l) => {
+                let cv = self.eval(cmd)?;
+                let nombre = self.cadena_de(&cv);
+                if !self.entradas.contains_key(&nombre) {
+                    let _ = self.stdout.flush(); // que lo ya impreso salga antes que lo del hijo
+                    let mut hijo = std::process::Command::new("/bin/sh")
+                        .arg("-c").arg(&nombre)
+                        .stdout(std::process::Stdio::piped())
+                        .spawn().map_err(|e| format!("no pude lanzar `{nombre}`: {e}"))?;
+                    let salida = hijo.stdout.take().unwrap();
+                    self.entradas.insert(nombre.clone(), Entrada {
+                        lector: Box::new(BufReader::new(salida)), hijo: Some(hijo),
+                    });
+                }
+                let ent = self.entradas.get_mut(&nombre).unwrap();
+                match Self::leer_registro(ent.lector.as_mut(), &rs) {
+                    None => Ok(Valor::Num(0.0)),
+                    Some(r) => {
+                        // `"cmd" | getline` SÍ incrementa NR (POSIX lo distingue de `getline < f`).
+                        let nr = self.leer_var("NR").numero() + 1.0;
+                        self.poner_var("NR", Valor::Num(nr));
+                        match l {
+                            None => { self.poner_registro(r); }
+                            Some(lug) => { let lug = (**lug).clone(); self.asignar(&lug, Valor::de_entrada(&r))?; }
+                        }
+                        Ok(Valor::Num(1.0))
+                    }
+                }
+            }
+        }
+    }
+
+    // ── Funciones incorporadas ───────────────────────────────────────────────────────────────────
+
+    fn builtin(&mut self, n: &str, args: &[Expr]) -> Result<Valor, String> {
+        match n {
+            "length" => {
+                if args.is_empty() {
+                    let v = self.leer_campo(0);
+                    return Ok(Valor::Num(self.cadena_de(&v).chars().count() as f64));
+                }
+                // `length(a)` con un array da su número de elementos.
+                if let Expr::Var(vn) = &args[0] {
+                    if let Some(Celda::Array(a)) = self.celda(vn) {
+                        return Ok(Valor::Num(a.borrow().len() as f64));
+                    }
+                }
+                let v = self.eval(&args[0])?;
+                Ok(Valor::Num(self.cadena_de(&v).chars().count() as f64))
+            }
+            "substr" => {
+                let v = self.eval(&args[0])?;
+                let s: Vec<char> = self.cadena_de(&v).chars().collect();
+                let m = self.eval(&args[1])?.numero();
+                // ⚠ Los índices empiezan en 1, y los dos rincones de esta función se resolvieron
+                // MIDIENDO los dos oráculos, no leyendo el estándar — que acá es ambiguo:
+                //   · un inicio ≤ 0 se recorta a 1 y **el largo NO se ajusta**:
+                //     `substr("abcdef", -1, 3)` es `abc` en busybox y en gawk --posix, no `a`.
+                //     La lectura estricta de POSIX daría `a` (las posiciones -1,0,1); ningún awk
+                //     real hace eso, y un guión escrito contra ellos es lo que hay que ejecutar.
+                //   · los no enteros se TRUNCAN hacia cero: `substr("abcdef", 1.5, 2.5)` es `ab`.
+                let inicio = m.trunc() as i64;
+                let (desde, largo) = if args.len() > 2 {
+                    let l = self.eval(&args[2])?.numero().trunc() as i64;
+                    (inicio.max(1), l)
+                } else {
+                    let d = inicio.max(1);
+                    (d, s.len() as i64 - d + 1)
+                };
+                if largo <= 0 || desde > s.len() as i64 { return Ok(Valor::Cad(String::new())); }
+                let a = (desde - 1) as usize;
+                let b = ((desde - 1 + largo) as usize).min(s.len());
+                Ok(Valor::Cad(s[a..b].iter().collect()))
+            }
+            "index" => {
+                let a = self.eval(&args[0])?; let sa = self.cadena_de(&a);
+                let b = self.eval(&args[1])?; let sb = self.cadena_de(&b);
+                let ha: Vec<char> = sa.chars().collect();
+                let ag: Vec<char> = sb.chars().collect();
+                if ag.is_empty() { return Ok(Valor::Num(if ha.is_empty() { 0.0 } else { 1.0 })); }
+                for i in 0..ha.len().saturating_sub(ag.len() - 1) {
+                    if ha[i..i + ag.len()] == ag[..] { return Ok(Valor::Num((i + 1) as f64)); }
+                }
+                Ok(Valor::Num(0.0))
+            }
+            "split" => {
+                let v = self.eval(&args[0])?;
+                let s = self.cadena_de(&v);
+                let nombre = match &args[1] {
+                    Expr::Var(n) => n.clone(),
+                    Expr::Indice(n, _) => n.clone(),
+                    _ => return Err("split necesita un array como segundo argumento".into()),
+                };
+                let fs = if args.len() > 2 {
+                    match &args[2] {
+                        Expr::Ere(r) => r.fuente().to_string(),
+                        e => { let v = self.eval(e)?; self.cadena_de(&v) }
+                    }
+                } else { self.leer_var("FS").cadena("%.6g") };
+                let trozos = if s.is_empty() { Vec::new() } else {
+                    let mut cache: Vec<(String, Rc<Regex>)> = Vec::new();
+                    let t = Self::partir_con(&s, &fs, |p| {
+                        if let Some((_, r)) = cache.iter().find(|(k, _)| k == p) { return r.clone(); }
+                        let r = Rc::new(Regex::nueva(p).unwrap_or_else(|e| {
+                            eprintln!("qillqa: expresión regular inválida /{p}/: {e}");
+                            std::process::exit(2);
+                        }));
+                        cache.push((p.to_string(), r.clone()));
+                        r
+                    });
+                    t
+                };
+                let a = self.array(&nombre);
+                a.borrow_mut().clear();
+                for (i, t) in trozos.iter().enumerate() {
+                    a.borrow_mut().insert((i + 1).to_string(), Valor::de_entrada(t));
+                }
+                Ok(Valor::Num(trozos.len() as f64))
+            }
+            "sub" | "gsub" => {
+                let global = n == "gsub";
+                let re = self.ere_de(&args[0])?;
+                let rv = self.eval(&args[1])?;
+                let reemplazo = self.cadena_de(&rv);
+                let destino = if args.len() > 2 {
+                    match &args[2] {
+                        Expr::Var(v) => Lugar::Var(v.clone()),
+                        Expr::Campo(i) => Lugar::Campo(i.clone()),
+                        Expr::Indice(v, i) => Lugar::Indice(v.clone(), i.clone()),
+                        Expr::Grupo(g) => match &**g {
+                            Expr::Var(v) => Lugar::Var(v.clone()),
+                            Expr::Campo(i) => Lugar::Campo(i.clone()),
+                            Expr::Indice(v, i) => Lugar::Indice(v.clone(), i.clone()),
+                            _ => return Err("sub/gsub necesita algo asignable".into()),
+                        },
+                        _ => return Err("sub/gsub necesita algo asignable".into()),
+                    }
+                } else { Lugar::Campo(Box::new(Expr::Num(0.0))) };
+                let actual = self.leer_lugar(&destino)?;
+                let texto = self.cadena_de(&actual);
+                let (nuevo, cuantas) = sustituir(&re, &texto, &reemplazo, global);
+                if cuantas > 0 { self.asignar(&destino, Valor::Cad(nuevo))?; }
+                Ok(Valor::Num(cuantas as f64))
+            }
+            "match" => {
+                let v = self.eval(&args[0])?;
+                let s = self.cadena_de(&v);
+                let re = self.ere_de(&args[1])?;
+                let cs: Vec<char> = s.chars().collect();
+                match re.buscar(&cs, 0) {
+                    Some((a, b)) => {
+                        self.poner_var("RSTART", Valor::Num((a + 1) as f64));
+                        self.poner_var("RLENGTH", Valor::Num((b - a) as f64));
+                        Ok(Valor::Num((a + 1) as f64))
+                    }
+                    None => {
+                        self.poner_var("RSTART", Valor::Num(0.0));
+                        self.poner_var("RLENGTH", Valor::Num(-1.0));
+                        Ok(Valor::Num(0.0))
+                    }
+                }
+            }
+            "sprintf" => {
+                if args.is_empty() { return Ok(Valor::Cad(String::new())); }
+                let fv = self.eval(&args[0])?;
+                let fmt = self.cadena_de(&fv);
+                let mut vals = Vec::new();
+                for a in &args[1..] {
+                    let v = self.eval(a)?;
+                    vals.push(match v {
+                        Valor::Num(x) => Arg::Numero(x),
+                        Valor::Cad(s) => Arg::Texto(s),
+                        Valor::StrNum(s, x) => Arg::Entrada(s, x),
+                        Valor::Vacio => Arg::Texto(String::new()),
+                    });
+                }
+                Ok(Valor::Cad(formato::printf(&fmt, &vals, &self.convfmt())))
+            }
+            "sin" => Ok(Valor::Num(self.arg_num(args, 0)?.sin())),
+            "cos" => Ok(Valor::Num(self.arg_num(args, 0)?.cos())),
+            "atan2" => { let a = self.arg_num(args, 0)?; let b = self.arg_num(args, 1)?; Ok(Valor::Num(a.atan2(b))) }
+            "exp" => Ok(Valor::Num(self.arg_num(args, 0)?.exp())),
+            "log" => Ok(Valor::Num(self.arg_num(args, 0)?.ln())),
+            "sqrt" => Ok(Valor::Num(self.arg_num(args, 0)?.sqrt())),
+            "int" => Ok(Valor::Num(self.arg_num(args, 0)?.trunc())),
+            "rand" => {
+                // xorshift64*: determinista y suficiente. `rand` de awk NO promete calidad
+                // criptográfica; lo que sí promete es repetirse con la misma semilla.
+                self.semilla ^= self.semilla << 13;
+                self.semilla ^= self.semilla >> 7;
+                self.semilla ^= self.semilla << 17;
+                let x = self.semilla.wrapping_mul(0x2545F4914F6CDD1D);
+                Ok(Valor::Num((x >> 11) as f64 / (1u64 << 53) as f64))
+            }
+            "srand" => {
+                let previa = self.semilla_previa;
+                let nueva = if args.is_empty() { ahora_ms() as f64 } else { self.arg_num(args, 0)? };
+                self.semilla_previa = nueva;
+                self.semilla = (nueva as i64 as u64) | 1; // el 0 congelaría el xorshift
+                Ok(Valor::Num(previa))
+            }
+            "tolower" | "toupper" => {
+                let v = self.eval(&args[0])?;
+                let s = self.cadena_de(&v);
+                Ok(Valor::Cad(if n == "tolower" { s.to_lowercase() } else { s.to_uppercase() }))
+            }
+            "system" => {
+                let v = self.eval(&args[0])?;
+                let cmd = self.cadena_de(&v);
+                let _ = self.stdout.flush(); // POSIX: se vacía la salida ANTES de lanzar
+                let r = std::process::Command::new("/bin/sh").arg("-c").arg(&cmd).status();
+                Ok(Valor::Num(r.ok().and_then(|s| s.code()).unwrap_or(-1) as f64))
+            }
+            "close" => {
+                let v = self.eval(&args[0])?;
+                let nombre = self.cadena_de(&v);
+                Ok(Valor::Num(self.cerrar(&nombre) as f64))
+            }
+            "fflush" => {
+                if args.is_empty() { let _ = self.stdout.flush(); return Ok(Valor::Num(0.0)); }
+                let v = self.eval(&args[0])?;
+                let nombre = self.cadena_de(&v);
+                if let Some(s) = self.salidas.get_mut(&nombre) { let _ = s.escritor.flush(); }
+                else { let _ = self.stdout.flush(); }
+                Ok(Valor::Num(0.0))
+            }
+            otro => Err(format!("función incorporada desconocida: `{otro}`")),
+        }
+    }
+
+    fn arg_num(&mut self, args: &[Expr], i: usize) -> Result<f64, String> {
+        match args.get(i) { Some(e) => Ok(self.eval(e)?.numero()), None => Ok(0.0) }
+    }
+}
+
+fn redondear(x: f64) -> i64 {
+    // POSIX dice «truncado al entero más cercano», que es redondeo a la mitad hacia arriba.
+    (x + 0.5).floor() as i64
+}
+
+fn aritmetica(op: BinOp, a: f64, b: f64) -> f64 {
+    match op {
+        BinOp::Suma => a + b,
+        BinOp::Resta => a - b,
+        BinOp::Mult => a * b,
+        BinOp::Div => a / b,
+        // `%` de awk es `fmod`, no el resto entero: `5.5 % 2` es 1.5.
+        BinOp::Modulo => a % b,
+        BinOp::Potencia => a.powf(b),
+    }
+}
+
+/// `sub`/`gsub`. En el reemplazo, `&` es el texto que casó y `\&` es un `&` literal.
+///
+/// ⚠ La regla que decide el caso raro: **una coincidencia VACÍA pegada al final de la anterior no
+/// sustituye**. Sin ella, `gsub(/a*/,"-")` sobre `"xax"` da `-x--x-` con 4 sustituciones —que es lo
+/// que hace el awk de busybox— en vez de `-x-x-` con 3, que es lo que hacen gawk y el awk original
+/// y lo que se deduce de POSIX. El banco diferencial lo destapó como una discrepancia ENTRE los
+/// oráculos, que es justo para lo que sirve tener dos.
+fn sustituir(re: &Regex, texto: &str, reemplazo: &str, global: bool) -> (String, usize) {
+    let cs: Vec<char> = texto.chars().collect();
+    let mut salida = String::new();
+    let mut i = 0;
+    let mut cuantas = 0;
+    let mut ultimo_vacio_en: Option<usize> = None;
+    while i <= cs.len() {
+        match re.buscar(&cs, i) {
+            None => break,
+            Some((a, b)) => {
+                // Una coincidencia VACÍA justo donde terminó la anterior no cuenta: si no,
+                // `gsub(/x*/,"-")` sobre "xax" produciría guiones de más.
+                if a == b && ultimo_vacio_en == Some(a) {
+                    if a >= cs.len() { break; }
+                    salida.push(cs[a]);
+                    i = a + 1;
+                    continue;
+                }
+                salida.extend(&cs[i..a]);
+                let casado: String = cs[a..b].iter().collect();
+                let mut rc = reemplazo.chars().peekable();
+                while let Some(c) = rc.next() {
+                    match c {
+                        '&' => salida.push_str(&casado),
+                        '\\' => match rc.peek() {
+                            Some('&') => { salida.push('&'); rc.next(); }
+                            Some('\\') => { salida.push('\\'); rc.next(); }
+                            _ => salida.push('\\'),
+                        },
+                        otro => salida.push(otro),
+                    }
+                }
+                cuantas += 1;
+                if !global {
+                    salida.extend(&cs[b..]);
+                    return (salida, cuantas);
+                }
+                if a == b {
+                    ultimo_vacio_en = Some(a);
+                    if a >= cs.len() { i = a; break; }
+                    salida.push(cs[a]);
+                    i = a + 1;
+                } else {
+                    // Tras una coincidencia real, la posición donde termina queda VEDADA para una
+                    // vacía: es la regla de arriba, y es la única diferencia con busybox.
+                    ultimo_vacio_en = Some(b);
+                    i = b;
+                }
+            }
+        }
+    }
+    if i <= cs.len() { salida.extend(&cs[i.min(cs.len())..]); }
+    (salida, cuantas)
+}
+
+/// ¿`s` tiene forma `nombre=valor`? Devuelve la posición del `=`.
+pub fn posicion_asignacion(s: &str) -> Option<usize> {
+    let cs: Vec<char> = s.chars().collect();
+    if cs.is_empty() { return None; }
+    if !(cs[0].is_alphabetic() || cs[0] == '_') { return None; }
+    let mut i = 1;
+    while i < cs.len() && (cs[i].is_alphanumeric() || cs[i] == '_') { i += 1; }
+    if i < cs.len() && cs[i] == '=' {
+        return Some(s.char_indices().nth(i).map(|(p, _)| p).unwrap_or(i));
+    }
+    None
+}
+
+/// Los escapes de una asignación de la línea de órdenes se procesan como en una cadena.
+pub fn desescapar(s: &str) -> String {
+    let mut out = String::new();
+    let mut it = s.chars().peekable();
+    while let Some(c) = it.next() {
+        if c != '\\' { out.push(c); continue; }
+        match it.next() {
+            None => out.push('\\'),
+            Some('n') => out.push('\n'), Some('t') => out.push('\t'), Some('r') => out.push('\r'),
+            Some('\\') => out.push('\\'), Some('"') => out.push('"'), Some('/') => out.push('/'),
+            Some('a') => out.push('\x07'), Some('b') => out.push('\x08'),
+            Some('f') => out.push('\x0c'), Some('v') => out.push('\x0b'),
+            Some(d @ '0'..='7') => {
+                let mut v = d.to_digit(8).unwrap();
+                let mut n = 1;
+                while n < 3 {
+                    match it.peek() {
+                        Some(x) if x.is_digit(8) => { v = v * 8 + x.to_digit(8).unwrap(); it.next(); n += 1; }
+                        _ => break,
+                    }
+                }
+                out.push(char::from_u32(v).unwrap_or('\0'));
+            }
+            Some(o) => { out.push('\\'); out.push(o); }
+        }
+    }
+    out
+}
+
+impl Interprete {
+    /// Acceso al array desde `main` (ENVIRON, ARGV), que es el único sitio de fuera que los toca.
+    pub fn array_publico(&mut self, n: &str) -> Rc<RefCell<HashMap<String, Valor>>> { self.array(n) }
+}
diff --git a/crates/qillqa/src/lexer.rs b/crates/qillqa/src/lexer.rs
new file mode 100644
index 00000000..399641bf
--- /dev/null
+++ b/crates/qillqa/src/lexer.rs
@@ -0,0 +1,249 @@
+//! Analizador léxico de awk.
+//!
+//! Dos cosas hacen a este lexer distinto de uno corriente, y las dos vienen de la gramática de awk:
+//!
+//! 1. **`/` es ambiguo**: puede abrir una expresión regular (`/^foo/`) o ser una división (`a / b`).
+//!    No se puede decidir mirando sólo el carácter. La regla —la misma que usan awk, sed y los
+//!    lenguajes con literales de regex— es MIRAR EL TOKEN ANTERIOR: tras un valor (número, cadena,
+//!    identificador, `)`, `]`, `$x`, un incremento) una `/` es división; en cualquier otro sitio
+//!    abre una ERE. Por eso el lexer recuerda el último token emitido.
+//!
+//! 2. **El salto de línea es SIGNIFICATIVO**: termina una sentencia. Pero no siempre — tras `{`,
+//!    `&&`, `||`, `,`, `do`, `else` y un `)` de `if`/`for`/`while`, la línea continúa. El lexer
+//!    emite `Nueva` y es el parser quien decide cuándo ignorarla; mezclarlo acá haría falta un
+//!    lexer que entienda de sintaxis.
+
+#[derive(Debug, Clone, PartialEq)]
+pub enum Tok {
+    Num(f64),
+    Cad(String),
+    Ere(String),
+    Ident(String),
+    /// Nombre seguido de `(` SIN espacio: sólo así se llama a una función en awk.
+    Funcion(String),
+    PalabraClave(String),
+    /// Operador o signo de puntuación, tal cual.
+    Op(String),
+    Nueva,
+    Fin,
+}
+
+const CLAVES: &[&str] = &[
+    "BEGIN", "END", "function", "func", "if", "else", "while", "for", "do", "break", "continue",
+    "next", "nextfile", "exit", "return", "delete", "in", "getline", "print", "printf",
+];
+
+pub struct Lexer {
+    src: Vec<char>,
+    i: usize,
+    anterior: Option<Tok>,
+}
+
+impl Lexer {
+    pub fn nuevo(src: &str) -> Lexer {
+        Lexer { src: src.chars().collect(), i: 0, anterior: None }
+    }
+
+    fn ver(&self) -> Option<char> { self.src.get(self.i).copied() }
+    fn ver2(&self) -> Option<char> { self.src.get(self.i + 1).copied() }
+
+    /// ¿Una `/` aquí abre una ERE? Depende de lo último que se emitió.
+    fn toca_ere(&self) -> bool {
+        match &self.anterior {
+            None => true,
+            Some(Tok::Num(_)) | Some(Tok::Cad(_)) | Some(Tok::Ere(_)) | Some(Tok::Ident(_)) => false,
+            Some(Tok::Op(o)) => !matches!(o.as_str(), ")" | "]" | "++" | "--" | "$"),
+            Some(Tok::PalabraClave(k)) => !matches!(k.as_str(), "getline"),
+            _ => true,
+        }
+    }
+
+    fn espacios_y_comentarios(&mut self) {
+        loop {
+            match self.ver() {
+                Some(' ') | Some('\t') | Some('\r') => { self.i += 1; }
+                // `\` al final de línea: continuación explícita, no hay token de nueva línea.
+                Some('\\') if self.ver2() == Some('\n') => { self.i += 2; }
+                Some('\\') if self.ver2() == Some('\r') => {
+                    self.i += 2;
+                    if self.ver() == Some('\n') { self.i += 1; }
+                }
+                Some('#') => { while !matches!(self.ver(), None | Some('\n')) { self.i += 1; } }
+                _ => return,
+            }
+        }
+    }
+
+    pub fn siguiente(&mut self) -> Result<Tok, String> {
+        let t = self.siguiente_crudo()?;
+        self.anterior = Some(t.clone());
+        Ok(t)
+    }
+
+    fn siguiente_crudo(&mut self) -> Result<Tok, String> {
+        self.espacios_y_comentarios();
+        let c = match self.ver() { None => return Ok(Tok::Fin), Some(c) => c };
+        if c == '\n' { self.i += 1; return Ok(Tok::Nueva); }
+
+        if c.is_ascii_digit() || (c == '.' && matches!(self.ver2(), Some(d) if d.is_ascii_digit())) {
+            return self.numero();
+        }
+        if c == '"' { return self.cadena(); }
+        if c == '/' && self.toca_ere() { return self.ere(); }
+        if c.is_alphabetic() || c == '_' { return Ok(self.palabra()); }
+
+        // Operadores, los largos primero.
+        let tres = self.tramo(3);
+        if tres == "**=" { self.i += 3; return Ok(Tok::Op("^=".into())); } // `**` es el `^` histórico
+        let dos = self.tramo(2);
+        for op in ["==", "!=", "<=", ">=", "&&", "||", "++", "--", "+=", "-=", "*=", "/=", "%=", "^=", "!~", ">>"] {
+            if dos == *op { self.i += 2; return Ok(Tok::Op(op.to_string())); }
+        }
+        if dos == "**" { self.i += 2; return Ok(Tok::Op("^".into())); }
+        self.i += 1;
+        let uno = c.to_string();
+        if "{}()[];,+-*/%^<>=!?:~$|&".contains(c) { return Ok(Tok::Op(uno)); }
+        Err(format!("carácter inesperado: `{c}`"))
+    }
+
+    fn tramo(&self, n: usize) -> String {
+        self.src[self.i..(self.i + n).min(self.src.len())].iter().collect()
+    }
+
+    fn numero(&mut self) -> Result<Tok, String> {
+        let ini = self.i;
+        while matches!(self.ver(), Some(c) if c.is_ascii_digit()) { self.i += 1; }
+        if self.ver() == Some('.') {
+            self.i += 1;
+            while matches!(self.ver(), Some(c) if c.is_ascii_digit()) { self.i += 1; }
+        }
+        if matches!(self.ver(), Some('e') | Some('E')) {
+            let guardado = self.i;
+            self.i += 1;
+            if matches!(self.ver(), Some('+') | Some('-')) { self.i += 1; }
+            if matches!(self.ver(), Some(c) if c.is_ascii_digit()) {
+                while matches!(self.ver(), Some(c) if c.is_ascii_digit()) { self.i += 1; }
+            } else { self.i = guardado; }
+        }
+        let t: String = self.src[ini..self.i].iter().collect();
+        t.parse::<f64>().map(Tok::Num).map_err(|_| format!("número mal formado: `{t}`"))
+    }
+
+    fn cadena(&mut self) -> Result<Tok, String> {
+        self.i += 1; // la comilla de apertura
+        let mut s = String::new();
+        loop {
+            match self.ver() {
+                None | Some('\n') => return Err("cadena sin cerrar".into()),
+                Some('"') => { self.i += 1; return Ok(Tok::Cad(s)); }
+                Some('\\') => {
+                    self.i += 1;
+                    let e = self.ver().ok_or("`\\` al final de la cadena")?;
+                    self.i += 1;
+                    match e {
+                        'n' => s.push('\n'), 't' => s.push('\t'), 'r' => s.push('\r'),
+                        '\\' => s.push('\\'), '"' => s.push('"'), '/' => s.push('/'),
+                        'a' => s.push('\x07'), 'b' => s.push('\x08'), 'f' => s.push('\x0c'),
+                        'v' => s.push('\x0b'),
+                        // Octal: `\101` es `A`. Hasta tres dígitos.
+                        '0'..='7' => {
+                            let mut v = e.to_digit(8).unwrap();
+                            let mut n = 1;
+                            while n < 3 {
+                                match self.ver() {
+                                    Some(d) if d.is_digit(8) => { v = v * 8 + d.to_digit(8).unwrap(); self.i += 1; n += 1; }
+                                    _ => break,
+                                }
+                            }
+                            s.push(char::from_u32(v).unwrap_or('\0'));
+                        }
+                        // POSIX: una barra ante cualquier otro carácter queda INDEFINIDA. awk y gawk
+                        // conservan los dos caracteres, que es lo menos sorprendente.
+                        otro => { s.push('\\'); s.push(otro); }
+                    }
+                }
+                Some(c) => { s.push(c); self.i += 1; }
+            }
+        }
+    }
+
+    fn ere(&mut self) -> Result<Tok, String> {
+        self.i += 1; // la `/` de apertura
+        let mut s = String::new();
+        let mut en_clase = false;
+        loop {
+            match self.ver() {
+                None | Some('\n') => return Err("expresión regular sin cerrar".into()),
+                // Dentro de `[...]` una `/` no cierra: `/[/]/` es la ERE de una barra.
+                Some('[') if !en_clase => { en_clase = true; s.push('['); self.i += 1; }
+                Some(']') if en_clase => { en_clase = false; s.push(']'); self.i += 1; }
+                Some('/') if !en_clase => { self.i += 1; return Ok(Tok::Ere(s)); }
+                Some('\\') => {
+                    self.i += 1;
+                    let e = self.ver().ok_or("`\\` al final de la expresión regular")?;
+                    self.i += 1;
+                    // `\/` es una barra literal; el resto de escapes se los queda el motor ERE.
+                    if e == '/' { s.push('/'); } else { s.push('\\'); s.push(e); }
+                }
+                Some(c) => { s.push(c); self.i += 1; }
+            }
+        }
+    }
+
+    fn palabra(&mut self) -> Tok {
+        let ini = self.i;
+        while matches!(self.ver(), Some(c) if c.is_alphanumeric() || c == '_') { self.i += 1; }
+        let p: String = self.src[ini..self.i].iter().collect();
+        if CLAVES.contains(&p.as_str()) {
+            return Tok::PalabraClave(if p == "func" { "function".into() } else { p });
+        }
+        // Llamada a función: el `(` va PEGADO. `f (x)` con espacio es concatenación, no llamada —
+        // regla de POSIX que distingue a awk de casi todo lo demás.
+        if self.ver() == Some('(') { Tok::Funcion(p) } else { Tok::Ident(p) }
+    }
+}
+
+#[cfg(test)]
+mod pruebas {
+    use super::*;
+
+    fn toks(s: &str) -> Vec<Tok> {
+        let mut l = Lexer::nuevo(s);
+        let mut v = Vec::new();
+        loop {
+            let t = l.siguiente().unwrap();
+            if t == Tok::Fin { break; }
+            v.push(t);
+        }
+        v
+    }
+
+    #[test]
+    fn la_barra_es_division_tras_un_valor_y_ere_en_otro_sitio() {
+        assert_eq!(toks("a / b"), vec![Tok::Ident("a".into()), Tok::Op("/".into()), Tok::Ident("b".into())]);
+        assert_eq!(toks("$0 ~ /x/"), vec![
+            Tok::Op("$".into()), Tok::Num(0.0), Tok::Op("~".into()), Tok::Ere("x".into())]);
+    }
+
+    #[test]
+    fn una_barra_dentro_de_una_clase_no_cierra_la_ere() {
+        assert_eq!(toks("/[/]/"), vec![Tok::Ere("[/]".into())]);
+    }
+
+    #[test]
+    fn llamada_a_funcion_solo_con_el_parentesis_pegado() {
+        assert_eq!(toks("f(1)")[0], Tok::Funcion("f".into()));
+        assert_eq!(toks("f (1)")[0], Tok::Ident("f".into()));
+    }
+
+    #[test]
+    fn la_continuacion_de_linea_no_emite_nueva_linea() {
+        assert_eq!(toks("a \\\n b"), vec![Tok::Ident("a".into()), Tok::Ident("b".into())]);
+        assert_eq!(toks("a \n b"), vec![Tok::Ident("a".into()), Tok::Nueva, Tok::Ident("b".into())]);
+    }
+
+    #[test]
+    fn escapes_de_cadena() {
+        assert_eq!(toks(r#""a\tb\101""#), vec![Tok::Cad("a\tbA".into())]);
+    }
+}
diff --git a/crates/qillqa/src/main.rs b/crates/qillqa/src/main.rs
new file mode 100644
index 00000000..f797f556
--- /dev/null
+++ b/crates/qillqa/src/main.rs
@@ -0,0 +1,164 @@
+//! qillqa — el awk del producto.
+//!
+//! *qillqa* es «escritura» en quechua, y awk es la herramienta de escribir sobre texto. El nombre
+//! sigue el criterio del proyecto (los nombres propios van en quechua) y el precedente de `simi`:
+//! **el nombre lo registra este comentario y lo DECIDE un ADR**, igual que el del shell. Lo que no
+//! está sujeto a decisión es el nombre del comando instalado: la receta enlaza `awk`, porque eso es
+//! contrato y lo llaman los `configure` de medio corpus.
+//!
+//! Uso:  qillqa [-F sepc] [-v var=valor]… 'programa' [fichero | var=valor]…
+//!       qillqa [-F sepc] [-v var=valor]… -f fuente.awk [-f más.awk]… [fichero | var=valor]…
+
+mod ast;
+mod formato;
+mod interp;
+mod lexer;
+mod parser;
+mod regex;
+mod valor;
+
+use interp::{Flujo, Interprete};
+use std::io::Write;
+use valor::Valor;
+
+fn uso() -> ! {
+    eprintln!("uso: qillqa [-F sepc] [-v var=valor]... 'programa' [fichero...]");
+    eprintln!("     qillqa [-F sepc] [-v var=valor]... -f fuente.awk [fichero...]");
+    std::process::exit(2);
+}
+
+fn main() {
+    let argv: Vec<String> = std::env::args().collect();
+    let mut fuentes: Vec<String> = Vec::new();
+    let mut asignaciones: Vec<String> = Vec::new();
+    let mut fs: Option<String> = None;
+    let mut texto_programa: Option<String> = None;
+    let mut i = 1;
+
+    while i < argv.len() {
+        let a = &argv[i];
+        if a == "--" { i += 1; break; }
+        if a == "-" || !a.starts_with('-') { break; }
+        // Las opciones se pueden pegar al valor (`-F:`) o separar (`-F :`), como en todo POSIX.
+        let (op, pegado) = {
+            let cs: Vec<char> = a.chars().collect();
+            (cs[1], cs[2..].iter().collect::<String>())
+        };
+        let mut toma = |pegado: &str, i: &mut usize| -> String {
+            if !pegado.is_empty() { return pegado.to_string(); }
+            *i += 1;
+            argv.get(*i).cloned().unwrap_or_else(|| uso())
+        };
+        match op {
+            'F' => {
+                let v = toma(&pegado, &mut i);
+                // `-F '\t'` llega con la barra literal: hay que desescaparlo como una cadena.
+                // Y `-F t` es el tabulador por compatibilidad histórica con el awk original.
+                fs = Some(if v == "t" { "\t".to_string() } else { interp::desescapar(&v) });
+            }
+            'v' => asignaciones.push(toma(&pegado, &mut i)),
+            'f' => {
+                let v = toma(&pegado, &mut i);
+                match std::fs::read_to_string(&v) {
+                    Ok(s) => fuentes.push(s),
+                    Err(e) => { eprintln!("qillqa: no puedo leer `{v}`: {e}"); std::process::exit(2); }
+                }
+            }
+            _ => uso(),
+        }
+        i += 1;
+    }
+
+    if fuentes.is_empty() {
+        match argv.get(i) {
+            None => uso(),
+            Some(p) => { texto_programa = Some(p.clone()); i += 1; }
+        }
+    }
+    let fuente = match texto_programa { Some(t) => t, None => fuentes.join("\n") };
+
+    let prog = match parser::Parser::nuevo(&fuente).and_then(|mut p| p.programa()) {
+        Ok(p) => p,
+        Err(e) => { eprintln!("qillqa: {e}"); std::process::exit(2); }
+    };
+
+    let mut it = Interprete::nuevo(prog);
+
+    // ENVIRON, antes que nada: un BEGIN puede leerlo.
+    {
+        let env = it.array_publico("ENVIRON");
+        for (k, v) in std::env::vars() {
+            env.borrow_mut().insert(k, Valor::de_entrada(&v));
+        }
+    }
+    if let Some(f) = fs { it.poner_var("FS", Valor::Cad(f)); }
+    for a in &asignaciones {
+        match interp::posicion_asignacion(a) {
+            Some(p) => it.poner_var(&a[..p], Valor::de_entrada(&interp::desescapar(&a[p + 1..]))),
+            None => { eprintln!("qillqa: `-v {a}` no tiene forma var=valor"); std::process::exit(2); }
+        }
+    }
+
+    // ARGV/ARGC: ARGV[0] es el nombre del programa y los operandos van detrás.
+    {
+        let operandos: Vec<String> = argv[i..].to_vec();
+        let arr = it.array_publico("ARGV");
+        arr.borrow_mut().insert("0".into(), Valor::Cad("qillqa".into()));
+        for (n, o) in operandos.iter().enumerate() {
+            arr.borrow_mut().insert((n + 1).to_string(), Valor::de_entrada(o));
+        }
+        it.poner_var("ARGC", Valor::Num((operandos.len() + 1) as f64));
+    }
+
+    let mut codigo = 0;
+    let mut salir_ya = false;
+
+    match it.ejecutar_begin() {
+        Err(e) => { fin_con_error(&mut it, &e); }
+        Ok(Flujo::Sale(c)) => { codigo = c; salir_ya = true; }
+        Ok(_) => {}
+    }
+
+    // El bucle principal sólo corre si hay reglas que no sean BEGIN. Si el programa es *sólo* un
+    // BEGIN, awk NO lee la entrada — y eso importa: `awk 'BEGIN{print 1}'` no se cuelga esperando.
+    if !salir_ya && (it.hay_main() || it.hay_end()) {
+        'registros: loop {
+            let r = match it.siguiente_registro() { None => break, Some(r) => r };
+            it.poner_registro(r);
+            match it.ejecutar_reglas() {
+                Err(e) => { fin_con_error(&mut it, &e); }
+                Ok(Flujo::Sale(c)) => { codigo = c; salir_ya = true; break 'registros; }
+                Ok(Flujo::SiguienteFichero) => { it.cerrar_fuente_actual(); }
+                Ok(_) => {}
+            }
+        }
+    }
+
+    if !salir_ya || it.hay_end() {
+        // POSIX: un `exit` en BEGIN o en el cuerpo SÍ ejecuta los END, salvo que el propio END
+        // llame a `exit`. Por eso se entra acá incluso con `salir_ya`.
+        match it.ejecutar_end() {
+            Err(e) => { fin_con_error(&mut it, &e); }
+            Ok(Flujo::Sale(c)) => { codigo = c; }
+            Ok(_) => {}
+        }
+    }
+
+    it.cerrar_todo();
+    if it.codigo_salida != 0 && codigo == 0 { codigo = it.codigo_salida; }
+    std::process::exit(codigo);
+}
+
+fn fin_con_error(it: &mut Interprete, e: &str) -> ! {
+    // `@salir:N` es cómo viaja un `exit` desde dentro de una función: no es un error de verdad.
+    if let Some(n) = e.strip_prefix("@salir:") {
+        let c = n.parse().unwrap_or(0);
+        let _ = it.ejecutar_end();
+        it.cerrar_todo();
+        std::process::exit(c);
+    }
+    it.cerrar_todo();
+    let _ = std::io::stderr().flush();
+    eprintln!("qillqa: {e}");
+    std::process::exit(2);
+}
diff --git a/crates/qillqa/src/parser.rs b/crates/qillqa/src/parser.rs
new file mode 100644
index 00000000..3295e52d
--- /dev/null
+++ b/crates/qillqa/src/parser.rs
@@ -0,0 +1,589 @@
+//! Analizador sintáctico de awk: de tokens a `Programa`.
+//!
+//! La gramática de awk tiene tres rincones que obligan a escribir el parser de cierta forma, y los
+//! tres están marcados abajo donde aparecen:
+//!
+//! 1. **La concatenación no tiene operador.** `a b` es concatenar. Eso choca con el unario `-`:
+//!    en `a - b` hay una resta, no la concatenación de `a` con `-b`. Se resuelve dando a la
+//!    concatenación una precedencia propia, entre la comparación y la suma, y no dejándola empezar
+//!    por un token que pueda continuar la expresión de la izquierda.
+//! 2. **`>` es ambiguo dentro de `print`.** `print a > "f"` REDIRIGE; no compara. Por eso el parser
+//!    lleva una bandera `sin_mayor` mientras lee la lista de un `print`.
+//! 3. **`in` y `|` cambian de significado según el contexto**: `(i,j) in a`, `"cmd" | getline`.
+
+use crate::ast::*;
+use crate::lexer::{Lexer, Tok};
+use crate::regex::Regex;
+use std::rc::Rc;
+
+const BUILTINS: &[&str] = &[
+    "length", "substr", "index", "split", "sub", "gsub", "match", "sprintf", "sin", "cos", "atan2",
+    "exp", "log", "sqrt", "int", "rand", "srand", "tolower", "toupper", "system", "close", "fflush",
+];
+
+pub struct Parser {
+    toks: Vec<Tok>,
+    i: usize,
+    sin_mayor: bool,
+    rangos: usize,
+    funciones_vistas: Vec<String>,
+}
+
+impl Parser {
+    pub fn nuevo(src: &str) -> Result<Parser, String> {
+        let mut lx = Lexer::nuevo(src);
+        let mut toks = Vec::new();
+        loop {
+            let t = lx.siguiente()?;
+            let fin = t == Tok::Fin;
+            toks.push(t);
+            if fin { break; }
+        }
+        Ok(Parser { toks, i: 0, sin_mayor: false, rangos: 0, funciones_vistas: Vec::new() })
+    }
+
+    fn ver(&self) -> &Tok { &self.toks[self.i.min(self.toks.len() - 1)] }
+    fn avanza(&mut self) -> Tok { let t = self.ver().clone(); self.i += 1; t }
+    fn es_op(&self, o: &str) -> bool { matches!(self.ver(), Tok::Op(x) if x == o) }
+    fn es_clave(&self, k: &str) -> bool { matches!(self.ver(), Tok::PalabraClave(x) if x == k) }
+
+    fn come_op(&mut self, o: &str) -> bool { if self.es_op(o) { self.i += 1; true } else { false } }
+    fn come_clave(&mut self, k: &str) -> bool { if self.es_clave(k) { self.i += 1; true } else { false } }
+
+    fn exige_op(&mut self, o: &str) -> Result<(), String> {
+        if self.come_op(o) { Ok(()) } else { Err(format!("esperaba `{o}` y encontré {:?}", self.ver())) }
+    }
+
+    /// Se come los saltos de línea y los `;` que separan sentencias.
+    fn saltos(&mut self) {
+        while matches!(self.ver(), Tok::Nueva) || self.es_op(";") { self.i += 1; }
+    }
+    fn solo_saltos(&mut self) {
+        while matches!(self.ver(), Tok::Nueva) { self.i += 1; }
+    }
+
+    pub fn programa(&mut self) -> Result<Programa, String> {
+        let mut p = Programa::default();
+        self.saltos();
+        while !matches!(self.ver(), Tok::Fin) {
+            if self.es_clave("function") {
+                self.i += 1;
+                let nombre = match self.avanza() {
+                    Tok::Ident(n) | Tok::Funcion(n) => n,
+                    t => return Err(format!("nombre de función esperado, encontré {t:?}")),
+                };
+                self.exige_op("(")?;
+                let mut params = Vec::new();
+                if !self.es_op(")") {
+                    loop {
+                        match self.avanza() {
+                            Tok::Ident(n) => params.push(n),
+                            t => return Err(format!("parámetro esperado, encontré {t:?}")),
+                        }
+                        self.solo_saltos();
+                        if !self.come_op(",") { break; }
+                        self.solo_saltos();
+                    }
+                }
+                self.exige_op(")")?;
+                self.solo_saltos();
+                self.funciones_vistas.push(nombre.clone());
+                let cuerpo = self.bloque()?;
+                p.funciones.push(Funcion { nombre, params, cuerpo });
+            } else {
+                let regla = self.regla()?;
+                p.reglas.push(regla);
+            }
+            self.saltos();
+        }
+        p.rangos = self.rangos;
+        Ok(p)
+    }
+
+    fn regla(&mut self) -> Result<Regla, String> {
+        let mut rango_id = usize::MAX;
+        let patron = if self.come_clave("BEGIN") { Patron::Begin }
+            else if self.come_clave("END") { Patron::End }
+            else if self.es_op("{") { Patron::Siempre }
+            else {
+                let e1 = self.expr()?;
+                if self.come_op(",") {
+                    self.solo_saltos();
+                    let e2 = self.expr()?;
+                    rango_id = self.rangos;
+                    self.rangos += 1;
+                    Patron::Rango(e1, e2)
+                } else { Patron::Expr(e1) }
+            };
+        let accion = if self.es_op("{") { Some(self.bloque()?) } else { None };
+        Ok(Regla { patron, accion, rango_id })
+    }
+
+    fn bloque(&mut self) -> Result<Vec<Sent>, String> {
+        self.exige_op("{")?;
+        let mut v = Vec::new();
+        self.saltos();
+        while !self.es_op("}") {
+            if matches!(self.ver(), Tok::Fin) { return Err("falta `}`".into()); }
+            v.push(self.sentencia()?);
+            self.saltos();
+        }
+        self.exige_op("}")?;
+        Ok(v)
+    }
+
+    /// Una sentencia, que puede ser un bloque o una simple.
+    fn sentencia(&mut self) -> Result<Sent, String> {
+        if self.es_op("{") { return Ok(Sent::Bloque(self.bloque()?)); }
+        if self.come_op(";") { return Ok(Sent::Nada); }
+
+        if self.come_clave("if") {
+            self.exige_op("(")?;
+            let cond = self.expr()?;
+            self.exige_op(")")?;
+            self.solo_saltos();
+            let entonces = Box::new(self.sentencia()?);
+            // El `else` puede venir tras saltos de línea y tras `;`.
+            let guardado = self.i;
+            self.saltos();
+            let si_no = if self.come_clave("else") {
+                self.solo_saltos();
+                Some(Box::new(self.sentencia()?))
+            } else { self.i = guardado; None };
+            return Ok(Sent::Si(cond, entonces, si_no));
+        }
+        if self.come_clave("while") {
+            self.exige_op("(")?;
+            let cond = self.expr()?;
+            self.exige_op(")")?;
+            self.solo_saltos();
+            // `while (c) ;` es un bucle con cuerpo vacío.
+            if self.come_op(";") { return Ok(Sent::Mientras(cond, Box::new(Sent::Nada))); }
+            return Ok(Sent::Mientras(cond, Box::new(self.sentencia()?)));
+        }
+        if self.come_clave("do") {
+            self.solo_saltos();
+            let cuerpo = Box::new(self.sentencia()?);
+            self.saltos();
+            if !self.come_clave("while") { return Err("`do` sin `while`".into()); }
+            self.exige_op("(")?;
+            let cond = self.expr()?;
+            self.exige_op(")")?;
+            return Ok(Sent::HazMientras(cuerpo, cond));
+        }
+        if self.come_clave("for") { return self.para(); }
+        if self.come_clave("break") { return Ok(Sent::Corta); }
+        if self.come_clave("continue") { return Ok(Sent::Sigue); }
+        if self.come_clave("next") { return Ok(Sent::Siguiente); }
+        if self.come_clave("nextfile") { return Ok(Sent::SiguienteFichero); }
+        if self.come_clave("exit") {
+            let e = if self.fin_de_sentencia() { None } else { Some(self.expr()?) };
+            return Ok(Sent::Sale(e));
+        }
+        if self.come_clave("return") {
+            let e = if self.fin_de_sentencia() { None } else { Some(self.expr()?) };
+            return Ok(Sent::Retorna(e));
+        }
+        if self.come_clave("delete") {
+            let nombre = match self.avanza() {
+                Tok::Ident(n) | Tok::Funcion(n) => n,
+                t => return Err(format!("`delete` necesita un array, encontré {t:?}")),
+            };
+            if self.come_op("[") {
+                let mut idx = vec![self.expr()?];
+                while self.come_op(",") { idx.push(self.expr()?); }
+                self.exige_op("]")?;
+                return Ok(Sent::Borra(nombre, idx));
+            }
+            // `delete a` y `delete a()` (la forma con paréntesis que acepta gawk)
+            if self.come_op("(") { self.exige_op(")")?; }
+            return Ok(Sent::BorraTodo(nombre));
+        }
+        if self.es_clave("print") || self.es_clave("printf") {
+            let es_printf = self.es_clave("printf");
+            self.i += 1;
+            return self.print(es_printf);
+        }
+        Ok(Sent::Expr(self.expr()?))
+    }
+
+    fn fin_de_sentencia(&self) -> bool {
+        matches!(self.ver(), Tok::Nueva | Tok::Fin) || self.es_op(";") || self.es_op("}")
+    }
+
+    fn para(&mut self) -> Result<Sent, String> {
+        self.exige_op("(")?;
+        // `for (k in a)` — se distingue de `for (expr; …)` mirando dos tokens adelante.
+        if let Tok::Ident(k) = self.ver().clone() {
+            if matches!(&self.toks[self.i + 1], Tok::PalabraClave(p) if p == "in") {
+                self.i += 2;
+                let arr = match self.avanza() {
+                    Tok::Ident(a) | Tok::Funcion(a) => a,
+                    t => return Err(format!("`for (k in a)` necesita un array, encontré {t:?}")),
+                };
+                self.exige_op(")")?;
+                self.solo_saltos();
+                return Ok(Sent::ParaEn(k, arr, Box::new(self.sentencia()?)));
+            }
+        }
+        let init = if self.es_op(";") { None } else { Some(Box::new(self.sentencia()?)) };
+        self.exige_op(";")?;
+        self.solo_saltos();
+        let cond = if self.es_op(";") { None } else { Some(self.expr()?) };
+        self.exige_op(";")?;
+        self.solo_saltos();
+        let paso = if self.es_op(")") { None } else { Some(Box::new(self.sentencia()?)) };
+        self.exige_op(")")?;
+        self.solo_saltos();
+        if self.come_op(";") { return Ok(Sent::Para(init, cond, paso, Box::new(Sent::Nada))); }
+        Ok(Sent::Para(init, cond, paso, Box::new(self.sentencia()?)))
+    }
+
+    /// ⚠ RINCÓN 2: dentro de la lista de un `print`, un `>` REDIRIGE, no compara. `sin_mayor` lo
+    /// dice, y se apaga en cuanto entramos en un paréntesis —`print (a > b)` sí compara.
+    fn print(&mut self, es_printf: bool) -> Result<Sent, String> {
+        let mut args = Vec::new();
+        if !self.fin_de_sentencia() && !self.es_op(">") && !self.es_op(">>") && !self.es_op("|") {
+            let previo = self.sin_mayor;
+            self.sin_mayor = true;
+            args.push(self.expr()?);
+            while self.come_op(",") {
+                self.solo_saltos();
+                args.push(self.expr()?);
+            }
+            self.sin_mayor = previo;
+        }
+        // `print (a, b) > "f"`: una lista entre paréntesis es la lista de argumentos, no una
+        // expresión agrupada. Se detecta cuando el único argumento es un grupo con comas dentro —
+        // eso lo resuelve `expr_lista_agrupada` al construirlo.
+        let redir = if self.come_op(">") { Some(Redir::Fichero(Box::new(self.expr()?))) }
+            else if self.come_op(">>") { Some(Redir::Anexa(Box::new(self.expr()?))) }
+            else if self.come_op("|") { Some(Redir::Tuberia(Box::new(self.expr()?))) }
+            else { None };
+        Ok(if es_printf { Sent::Printf(args, redir) } else { Sent::Print(args, redir) })
+    }
+
+    // ── Expresiones, por precedencia de menor a mayor ────────────────────────────────────────────
+
+    pub fn expr(&mut self) -> Result<Expr, String> { self.ternario() }
+
+    fn ternario(&mut self) -> Result<Expr, String> {
+        let cond = self.o_logico()?;
+        if self.come_op("?") {
+            self.solo_saltos();
+            let a = self.ternario()?;
+            self.solo_saltos();
+            self.exige_op(":")?;
+            self.solo_saltos();
+            let b = self.ternario()?;
+            return Ok(Expr::Ternario(Box::new(cond), Box::new(a), Box::new(b)));
+        }
+        // La asignación se reconoce DESPUÉS: en awk `a = b ? c : d` asigna el ternario entero, y
+        // el lado izquierdo de una asignación siempre es un lugar simple.
+        if let Some(op) = self.op_asignacion() {
+            if let Some(lugar) = self.a_lugar(&cond) {
+                self.i += 1;
+                self.solo_saltos();
+                let val = self.ternario()?;
+                return Ok(match op.as_str() {
+                    "=" => Expr::Asigna(Box::new(lugar), Box::new(val)),
+                    "+=" => Expr::AsignaOp(Box::new(lugar), BinOp::Suma, Box::new(val)),
+                    "-=" => Expr::AsignaOp(Box::new(lugar), BinOp::Resta, Box::new(val)),
+                    "*=" => Expr::AsignaOp(Box::new(lugar), BinOp::Mult, Box::new(val)),
+                    "/=" => Expr::AsignaOp(Box::new(lugar), BinOp::Div, Box::new(val)),
+                    "%=" => Expr::AsignaOp(Box::new(lugar), BinOp::Modulo, Box::new(val)),
+                    "^=" => Expr::AsignaOp(Box::new(lugar), BinOp::Potencia, Box::new(val)),
+                    _ => unreachable!(),
+                });
+            }
+        }
+        Ok(cond)
+    }
+
+    fn op_asignacion(&self) -> Option<String> {
+        match self.ver() {
+            Tok::Op(o) if matches!(o.as_str(), "=" | "+=" | "-=" | "*=" | "/=" | "%=" | "^=") => Some(o.clone()),
+            _ => None,
+        }
+    }
+
+    fn a_lugar(&self, e: &Expr) -> Option<Lugar> {
+        match e {
+            Expr::Var(n) => Some(Lugar::Var(n.clone())),
+            Expr::Campo(i) => Some(Lugar::Campo(i.clone())),
+            Expr::Indice(n, idx) => Some(Lugar::Indice(n.clone(), idx.clone())),
+            _ => None,
+        }
+    }
+
+    fn o_logico(&mut self) -> Result<Expr, String> {
+        let mut izq = self.y_logico()?;
+        while self.come_op("||") {
+            self.solo_saltos();
+            let der = self.y_logico()?;
+            izq = Expr::O(Box::new(izq), Box::new(der));
+        }
+        Ok(izq)
+    }
+
+    fn y_logico(&mut self) -> Result<Expr, String> {
+        let mut izq = self.en_array()?;
+        while self.come_op("&&") {
+            self.solo_saltos();
+            let der = self.en_array()?;
+            izq = Expr::Y(Box::new(izq), Box::new(der));
+        }
+        Ok(izq)
+    }
+
+    fn en_array(&mut self) -> Result<Expr, String> {
+        let mut izq = self.coincidencia()?;
+        while self.es_clave("in") {
+            self.i += 1;
+            let arr = match self.avanza() {
+                Tok::Ident(a) | Tok::Funcion(a) => a,
+                t => return Err(format!("`in` necesita un array, encontré {t:?}")),
+            };
+            // `(i,j) in a` llega como un grupo con lista; `i in a`, como una expresión suelta.
+            izq = match izq {
+                Expr::Grupo(inner) => Expr::En(vec![*inner], arr),
+                otro => Expr::En(vec![otro], arr),
+            };
+        }
+        Ok(izq)
+    }
+
+    fn coincidencia(&mut self) -> Result<Expr, String> {
+        let mut izq = self.comparacion()?;
+        loop {
+            if self.come_op("~") {
+                let der = self.comparacion()?;
+                izq = Expr::Coincide(Box::new(izq), Box::new(der), false);
+            } else if self.come_op("!~") {
+                let der = self.comparacion()?;
+                izq = Expr::Coincide(Box::new(izq), Box::new(der), true);
+            } else { break; }
+        }
+        Ok(izq)
+    }
+
+    /// POSIX: los relacionales NO son asociativos (`a < b < c` es un error en la gramática), pero
+    /// todos los awk reales lo aceptan como `(a<b)<c`. Se acepta igual, que romper eso sólo rompe
+    /// guiones que ya funcionaban en otro lado.
+    fn comparacion(&mut self) -> Result<Expr, String> {
+        let izq = self.concatenacion()?;
+        let op = match self.ver() {
+            Tok::Op(o) => match o.as_str() {
+                "<" => CmpOp::Lt, "<=" => CmpOp::Le, "==" => CmpOp::Eq, "!=" => CmpOp::Ne,
+                ">=" if !self.sin_mayor => CmpOp::Ge,
+                ">" if !self.sin_mayor => CmpOp::Gt,
+                _ => return Ok(izq),
+            },
+            _ => return Ok(izq),
+        };
+        self.i += 1;
+        let der = self.concatenacion()?;
+        Ok(Expr::Cmp(op, Box::new(izq), Box::new(der)))
+    }
+
+    /// ⚠ RINCÓN 1: la concatenación no tiene operador. Continúa mientras lo que viene PUEDE empezar
+    /// una expresión unaria… salvo `+` y `-`, que se leen como binarios (`a - b` es una resta).
+    fn concatenacion(&mut self) -> Result<Expr, String> {
+        let mut izq = self.aditiva()?;
+        while self.empieza_valor() {
+            let der = self.aditiva()?;
+            izq = Expr::Concat(Box::new(izq), Box::new(der));
+        }
+        Ok(izq)
+    }
+
+    fn empieza_valor(&self) -> bool {
+        match self.ver() {
+            Tok::Num(_) | Tok::Cad(_) | Tok::Ere(_) | Tok::Ident(_) | Tok::Funcion(_) => true,
+            Tok::Op(o) => matches!(o.as_str(), "$" | "(" | "!" | "++" | "--"),
+            Tok::PalabraClave(k) => k == "getline",
+            _ => false,
+        }
+    }
+
+    fn aditiva(&mut self) -> Result<Expr, String> {
+        let mut izq = self.multiplicativa()?;
+        loop {
+            if self.come_op("+") { let d = self.multiplicativa()?; izq = Expr::Bin(BinOp::Suma, Box::new(izq), Box::new(d)); }
+            else if self.come_op("-") { let d = self.multiplicativa()?; izq = Expr::Bin(BinOp::Resta, Box::new(izq), Box::new(d)); }
+            else { break; }
+        }
+        Ok(izq)
+    }
+
+    fn multiplicativa(&mut self) -> Result<Expr, String> {
+        let mut izq = self.unaria()?;
+        loop {
+            if self.come_op("*") { let d = self.unaria()?; izq = Expr::Bin(BinOp::Mult, Box::new(izq), Box::new(d)); }
+            else if self.come_op("/") { let d = self.unaria()?; izq = Expr::Bin(BinOp::Div, Box::new(izq), Box::new(d)); }
+            else if self.come_op("%") { let d = self.unaria()?; izq = Expr::Bin(BinOp::Modulo, Box::new(izq), Box::new(d)); }
+            else { break; }
+        }
+        Ok(izq)
+    }
+
+    fn unaria(&mut self) -> Result<Expr, String> {
+        if self.come_op("!") { return Ok(Expr::No(Box::new(self.unaria()?))); }
+        if self.come_op("-") { return Ok(Expr::Neg(Box::new(self.unaria()?))); }
+        if self.come_op("+") { return Ok(Expr::Pos(Box::new(self.unaria()?))); }
+        self.potencia()
+    }
+
+    /// `^` asocia a la DERECHA (`2^3^2` es 512), y su operando derecho admite unario:
+    /// `2^-1` es válido.
+    fn potencia(&mut self) -> Result<Expr, String> {
+        let base = self.postfija()?;
+        if self.come_op("^") {
+            let exp = self.unaria()?;
+            return Ok(Expr::Bin(BinOp::Potencia, Box::new(base), Box::new(exp)));
+        }
+        Ok(base)
+    }
+
+    fn postfija(&mut self) -> Result<Expr, String> {
+        let e = self.primaria()?;
+        if self.es_op("++") || self.es_op("--") {
+            if let Some(l) = self.a_lugar(&e) {
+                let delta = if self.es_op("++") { 1.0 } else { -1.0 };
+                self.i += 1;
+                return Ok(Expr::Incr(Box::new(l), false, delta));
+            }
+        }
+        Ok(e)
+    }
+
+    fn primaria(&mut self) -> Result<Expr, String> {
+        // Pre-incremento.
+        if self.es_op("++") || self.es_op("--") {
+            let delta = if self.es_op("++") { 1.0 } else { -1.0 };
+            self.i += 1;
+            let obj = self.primaria()?;
+            let l = self.a_lugar(&obj).ok_or("`++` necesita una variable, un campo o un elemento")?;
+            return Ok(Expr::Incr(Box::new(l), true, delta));
+        }
+        if self.come_op("$") {
+            let idx = self.primaria()?;
+            let campo = Expr::Campo(Box::new(idx));
+            // `$1++` incrementa el campo.
+            if self.es_op("++") || self.es_op("--") {
+                let delta = if self.es_op("++") { 1.0 } else { -1.0 };
+                self.i += 1;
+                if let Some(l) = self.a_lugar(&campo) {
+                    return Ok(Expr::Incr(Box::new(l), false, delta));
+                }
+            }
+            return Ok(campo);
+        }
+        if self.come_op("(") {
+            let previo = self.sin_mayor;
+            self.sin_mayor = false; // dentro de paréntesis, `>` vuelve a comparar
+            self.solo_saltos();
+            let e = self.expr()?;
+            // `(a, b) in arr` y `print (a, b)`: una lista.
+            if self.es_op(",") {
+                let mut lista = vec![e];
+                while self.come_op(",") { self.solo_saltos(); lista.push(self.expr()?); }
+                self.exige_op(")")?;
+                self.sin_mayor = previo;
+                if self.es_clave("in") {
+                    self.i += 1;
+                    let arr = match self.avanza() {
+                        Tok::Ident(a) | Tok::Funcion(a) => a,
+                        t => return Err(format!("`in` necesita un array, encontré {t:?}")),
+                    };
+                    return Ok(Expr::En(lista, arr));
+                }
+                // Lista suelta: sólo tiene sentido como argumentos de print, y allí se desarma.
+                return Ok(Expr::Llamada("@lista".into(), lista));
+            }
+            self.exige_op(")")?;
+            self.sin_mayor = previo;
+            let g = Expr::Grupo(Box::new(e));
+            // `("cmd" | getline x)`
+            return self.quizas_tuberia(g);
+        }
+        match self.avanza() {
+            Tok::Num(n) => Ok(Expr::Num(n)),
+            Tok::Cad(s) => self.quizas_tuberia(Expr::Cad(s)),
+            Tok::Ere(r) => Ok(Expr::Ere(Rc::new(Regex::nueva(&r)?))),
+            // ⚠ `length` sin paréntesis es el ÚNICO builtin al que POSIX se lo permite, y tiene
+            // que ir ANTES del brazo general de identificador — si no, se lee como una variable
+            // llamada `length`, que siempre vale vacío. El banco lo cazó con `{print length}`.
+            Tok::Ident(n) if n == "length" && !self.es_op("[") => Ok(Expr::Builtin("length".into(), vec![])),
+            Tok::Ident(n) => {
+                if self.come_op("[") {
+                    let mut idx = vec![self.expr()?];
+                    while self.come_op(",") { idx.push(self.expr()?); }
+                    self.exige_op("]")?;
+                    return Ok(Expr::Indice(n, idx));
+                }
+                Ok(Expr::Var(n))
+            }
+            Tok::Funcion(n) => {
+                self.exige_op("(")?;
+                let previo = self.sin_mayor;
+                self.sin_mayor = false;
+                let mut args = Vec::new();
+                self.solo_saltos();
+                if !self.es_op(")") {
+                    loop {
+                        args.push(self.expr()?);
+                        self.solo_saltos();
+                        if !self.come_op(",") { break; }
+                        self.solo_saltos();
+                    }
+                }
+                self.exige_op(")")?;
+                self.sin_mayor = previo;
+                if BUILTINS.contains(&n.as_str()) { Ok(Expr::Builtin(n, args)) }
+                else { Ok(Expr::Llamada(n, args)) }
+            }
+            Tok::PalabraClave(k) if k == "getline" => {
+                // `getline`, `getline var`, `getline < f`, `getline var < f`
+                let lugar = self.lugar_opcional()?;
+                if self.come_op("<") {
+                    let f = self.concatenacion()?;
+                    return Ok(Expr::Getline(Getline::DeFichero(lugar, Box::new(f))));
+                }
+                Ok(Expr::Getline(Getline::Simple(lugar)))
+            }
+            t => Err(format!("expresión esperada, encontré {t:?}")),
+        }
+    }
+
+    /// `expr | getline [lugar]`
+    fn quizas_tuberia(&mut self, izq: Expr) -> Result<Expr, String> {
+        if self.es_op("|") && matches!(&self.toks[self.i + 1], Tok::PalabraClave(k) if k == "getline") {
+            self.i += 2;
+            let lugar = self.lugar_opcional()?;
+            return Ok(Expr::Getline(Getline::DeTuberia(Box::new(izq), lugar)));
+        }
+        Ok(izq)
+    }
+
+    fn lugar_opcional(&mut self) -> Result<Option<Box<Lugar>>, String> {
+        match self.ver().clone() {
+            Tok::Ident(n) => {
+                self.i += 1;
+                if self.come_op("[") {
+                    let mut idx = vec![self.expr()?];
+                    while self.come_op(",") { idx.push(self.expr()?); }
+                    self.exige_op("]")?;
+                    return Ok(Some(Box::new(Lugar::Indice(n, idx))));
+                }
+                Ok(Some(Box::new(Lugar::Var(n))))
+            }
+            Tok::Op(o) if o == "$" => {
+                self.i += 1;
+                let idx = self.primaria()?;
+                Ok(Some(Box::new(Lugar::Campo(Box::new(idx)))))
+            }
+            _ => Ok(None),
+        }
+    }
+}
diff --git a/crates/qillqa/src/regex.rs b/crates/qillqa/src/regex.rs
new file mode 100644
index 00000000..107a043e
--- /dev/null
+++ b/crates/qillqa/src/regex.rs
@@ -0,0 +1,427 @@
+//! Motor de expresiones regulares ERE (POSIX.1-2024 §9.4), propio.
+//!
+//! # Por qué propio y no un crate
+//!
+//! La semántica de POSIX es **leftmost-longest**: entre todas las coincidencias que empiezan en la
+//! posición más a la izquierda, gana la MÁS LARGA. La familia Perl —y con ella el crate `regex` en
+//! su modo por defecto— usa leftmost-first: para `a|ab` sobre `"ab"`, POSIX devuelve `ab` y Perl
+//! devuelve `a`. En un awk eso no es un detalle académico: decide qué borra un `sub()`, dónde parte
+//! un `split()` y qué valen `RSTART`/`RLENGTH`. Adoptar un motor ajeno sería adoptar su semántica.
+//!
+//! # Cómo funciona
+//!
+//! Construcción de Thompson: el patrón se compila a un programa de instrucciones y se simula con un
+//! CONJUNTO de hilos que avanzan a la vez, un carácter por paso. No hay retroceso, así que no hay
+//! explosión exponencial —`(a*)*b` contra sesenta `a` termina igual de rápido que cualquier otra
+//! cosa— y quedarse con el final más lejano alcanzado da el «más largo» de POSIX sin esfuerzo extra.
+//!
+//! Trabaja sobre `char`, no sobre bytes: en awk los índices de `substr`, `index`, `match` y
+//! `RSTART` se cuentan en caracteres, y con UTF-8 en el fichero de entrada contar bytes daría
+//! posiciones que no se pueden usar para cortar.
+
+/// Una instrucción del programa compilado.
+#[derive(Debug, Clone)]
+enum Inst {
+    /// Consume exactamente este carácter.
+    Char(char),
+    /// Consume cualquier carácter (`.`). En awk `.` SÍ casa con `\n` (no hay modo multilínea).
+    Any,
+    /// Consume un carácter si pertenece (o no, si `negada`) al conjunto.
+    Clase { partes: Vec<Parte>, negada: bool },
+    /// Bifurca: sigue por las dos ramas. El orden no importa porque no hay preferencia (longest).
+    Split(usize, usize),
+    /// Salta.
+    Jmp(usize),
+    /// Ancla de principio de cadena (`^`).
+    Bol,
+    /// Ancla de final de cadena (`$`).
+    Eol,
+    /// Aceptación.
+    Match,
+}
+
+/// Un trozo de una clase `[...]`.
+#[derive(Debug, Clone)]
+enum Parte {
+    Uno(char),
+    Rango(char, char),
+    /// `[:alpha:]` y compañía, resueltas por función para no materializar Unicode entero.
+    Nombrada(Nombrada),
+}
+
+#[derive(Debug, Clone, Copy, PartialEq)]
+enum Nombrada {
+    Alpha, Digit, Alnum, Upper, Lower, Space, Blank, Punct, Print, Graph, Cntrl, Xdigit,
+}
+
+impl Nombrada {
+    fn de(nombre: &str) -> Option<Nombrada> {
+        Some(match nombre {
+            "alpha" => Nombrada::Alpha, "digit" => Nombrada::Digit, "alnum" => Nombrada::Alnum,
+            "upper" => Nombrada::Upper, "lower" => Nombrada::Lower, "space" => Nombrada::Space,
+            "blank" => Nombrada::Blank, "punct" => Nombrada::Punct, "print" => Nombrada::Print,
+            "graph" => Nombrada::Graph, "cntrl" => Nombrada::Cntrl, "xdigit" => Nombrada::Xdigit,
+            _ => return None,
+        })
+    }
+    fn casa(self, c: char) -> bool {
+        match self {
+            Nombrada::Alpha => c.is_alphabetic(),
+            Nombrada::Digit => c.is_ascii_digit(),
+            Nombrada::Alnum => c.is_alphanumeric(),
+            Nombrada::Upper => c.is_uppercase(),
+            Nombrada::Lower => c.is_lowercase(),
+            Nombrada::Space => c.is_whitespace(),
+            Nombrada::Blank => c == ' ' || c == '\t',
+            Nombrada::Punct => c.is_ascii_punctuation(),
+            Nombrada::Print => !c.is_control(),
+            Nombrada::Graph => !c.is_control() && !c.is_whitespace(),
+            Nombrada::Cntrl => c.is_control(),
+            Nombrada::Xdigit => c.is_ascii_hexdigit(),
+        }
+    }
+}
+
+/// Árbol del patrón, paso intermedio entre el texto y el programa.
+#[derive(Debug, Clone)]
+enum Nodo {
+    Vacio,
+    Char(char),
+    Any,
+    Clase { partes: Vec<Parte>, negada: bool },
+    Bol,
+    Eol,
+    Cat(Box<Nodo>, Box<Nodo>),
+    Alt(Box<Nodo>, Box<Nodo>),
+    Estrella(Box<Nodo>),
+    Mas(Box<Nodo>),
+    Opcional(Box<Nodo>),
+    /// `{n,m}`: se expande al compilar. `hasta = None` es `{n,}`.
+    Repite(Box<Nodo>, u32, Option<u32>),
+}
+
+pub struct Regex {
+    prog: Vec<Inst>,
+    /// El patrón original, para mensajes de error y para `Regex::fuente`.
+    fuente: String,
+}
+
+struct Analizador<'a> {
+    p: &'a [char],
+    i: usize,
+}
+
+impl<'a> Analizador<'a> {
+    fn fin(&self) -> bool { self.i >= self.p.len() }
+    fn ver(&self) -> Option<char> { self.p.get(self.i).copied() }
+    fn tomar(&mut self) -> Option<char> { let c = self.ver(); if c.is_some() { self.i += 1; } c }
+
+    /// alternancia := concatenación ('|' concatenación)*
+    fn alternancia(&mut self) -> Result<Nodo, String> {
+        let mut izq = self.concatenacion()?;
+        while self.ver() == Some('|') {
+            self.i += 1;
+            let der = self.concatenacion()?;
+            izq = Nodo::Alt(Box::new(izq), Box::new(der));
+        }
+        Ok(izq)
+    }
+
+    /// concatenación := repetición*
+    fn concatenacion(&mut self) -> Result<Nodo, String> {
+        let mut acc: Option<Nodo> = None;
+        while !self.fin() && self.ver() != Some('|') && self.ver() != Some(')') {
+            let n = self.repeticion()?;
+            acc = Some(match acc {
+                None => n,
+                Some(a) => Nodo::Cat(Box::new(a), Box::new(n)),
+            });
+        }
+        Ok(acc.unwrap_or(Nodo::Vacio))
+    }
+
+    /// repetición := átomo ('*' | '+' | '?' | '{n,m}')*
+    fn repeticion(&mut self) -> Result<Nodo, String> {
+        let mut n = self.atomo()?;
+        loop {
+            match self.ver() {
+                Some('*') => { self.i += 1; n = Nodo::Estrella(Box::new(n)); }
+                Some('+') => { self.i += 1; n = Nodo::Mas(Box::new(n)); }
+                Some('?') => { self.i += 1; n = Nodo::Opcional(Box::new(n)); }
+                Some('{') => {
+                    // Sólo es intervalo si lo que sigue es un dígito: en ERE un `{` suelto es
+                    // literal, y hay guiones que escriben `/x{/` sin escapar.
+                    if !matches!(self.p.get(self.i + 1), Some(c) if c.is_ascii_digit()) { break; }
+                    let guardado = self.i;
+                    self.i += 1;
+                    match self.intervalo() {
+                        Ok((desde, hasta)) => n = Nodo::Repite(Box::new(n), desde, hasta),
+                        Err(_) => { self.i = guardado; break; }
+                    }
+                }
+                _ => break,
+            }
+        }
+        Ok(n)
+    }
+
+    fn intervalo(&mut self) -> Result<(u32, Option<u32>), String> {
+        let mut desde = String::new();
+        while matches!(self.ver(), Some(c) if c.is_ascii_digit()) { desde.push(self.tomar().unwrap()); }
+        if desde.is_empty() { return Err("intervalo sin mínimo".into()); }
+        let d: u32 = desde.parse().map_err(|_| "intervalo demasiado grande".to_string())?;
+        match self.ver() {
+            Some('}') => { self.i += 1; Ok((d, Some(d))) }
+            Some(',') => {
+                self.i += 1;
+                let mut hasta = String::new();
+                while matches!(self.ver(), Some(c) if c.is_ascii_digit()) { hasta.push(self.tomar().unwrap()); }
+                if self.ver() != Some('}') { return Err("intervalo sin cerrar".into()); }
+                self.i += 1;
+                if hasta.is_empty() { Ok((d, None)) }
+                else {
+                    let h: u32 = hasta.parse().map_err(|_| "intervalo demasiado grande".to_string())?;
+                    if h < d { return Err("intervalo con máximo menor que el mínimo".into()); }
+                    Ok((d, Some(h)))
+                }
+            }
+            _ => Err("intervalo mal formado".into()),
+        }
+    }
+
+    fn atomo(&mut self) -> Result<Nodo, String> {
+        match self.tomar() {
+            None => Ok(Nodo::Vacio),
+            Some('(') => {
+                let dentro = self.alternancia()?;
+                if self.tomar() != Some(')') { return Err("falta `)`".into()); }
+                Ok(dentro)
+            }
+            Some('[') => self.clase(),
+            Some('.') => Ok(Nodo::Any),
+            Some('^') => Ok(Nodo::Bol),
+            Some('$') => Ok(Nodo::Eol),
+            Some('\\') => match self.tomar() {
+                None => Err("`\\` al final del patrón".into()),
+                // Los escapes de C que awk reconoce dentro de una ERE.
+                Some('n') => Ok(Nodo::Char('\n')),
+                Some('t') => Ok(Nodo::Char('\t')),
+                Some('r') => Ok(Nodo::Char('\r')),
+                Some('f') => Ok(Nodo::Char('\x0c')),
+                Some('b') => Ok(Nodo::Char('\x08')),
+                Some('v') => Ok(Nodo::Char('\x0b')),
+                Some('a') => Ok(Nodo::Char('\x07')),
+                Some('/') => Ok(Nodo::Char('/')),
+                Some(c) => Ok(Nodo::Char(c)),
+            },
+            Some(c) => Ok(Nodo::Char(c)),
+        }
+    }
+
+    /// `[abc]`, `[^a-z]`, `[[:digit:]]`. Un `]` justo tras el `[` (o tras `[^`) es LITERAL.
+    fn clase(&mut self) -> Result<Nodo, String> {
+        let mut negada = false;
+        if self.ver() == Some('^') { negada = true; self.i += 1; }
+        let mut partes: Vec<Parte> = Vec::new();
+        let mut primero = true;
+        loop {
+            let c = match self.tomar() {
+                None => return Err("clase `[` sin cerrar".into()),
+                Some(']') if !primero => break,
+                Some(c) => c,
+            };
+            primero = false;
+            // `[:alpha:]` dentro de la clase.
+            if c == '[' && self.ver() == Some(':') {
+                let guardado = self.i;
+                self.i += 1;
+                let mut nombre = String::new();
+                while matches!(self.ver(), Some(c) if c != ':' && c != ']') { nombre.push(self.tomar().unwrap()); }
+                if self.ver() == Some(':') && self.p.get(self.i + 1) == Some(&']') {
+                    self.i += 2;
+                    match Nombrada::de(&nombre) {
+                        Some(n) => { partes.push(Parte::Nombrada(n)); continue; }
+                        None => return Err(format!("clase de caracteres desconocida: [:{nombre}:]")),
+                    }
+                }
+                self.i = guardado; // no era una clase nombrada: el `[` es literal
+            }
+            let c = if c == '\\' {
+                match self.tomar() {
+                    None => return Err("`\\` al final de una clase".into()),
+                    Some('n') => '\n', Some('t') => '\t', Some('r') => '\r',
+                    Some('f') => '\x0c', Some('b') => '\x08', Some('v') => '\x0b', Some('a') => '\x07',
+                    Some(o) => o,
+                }
+            } else { c };
+            // Rango `a-z`, salvo que el `-` sea el último carácter de la clase.
+            if self.ver() == Some('-') && self.p.get(self.i + 1).map_or(false, |&d| d != ']') {
+                self.i += 1;
+                let hasta = match self.tomar() {
+                    None => return Err("rango sin final".into()),
+                    Some('\\') => self.tomar().ok_or("`\\` al final de un rango")?,
+                    Some(d) => d,
+                };
+                partes.push(Parte::Rango(c, hasta));
+            } else {
+                partes.push(Parte::Uno(c));
+            }
+        }
+        Ok(Nodo::Clase { partes, negada })
+    }
+}
+
+/// Emite el programa. Devuelve el índice de la primera instrucción del fragmento.
+fn emitir(prog: &mut Vec<Inst>, n: &Nodo) {
+    match n {
+        Nodo::Vacio => {}
+        Nodo::Char(c) => prog.push(Inst::Char(*c)),
+        Nodo::Any => prog.push(Inst::Any),
+        Nodo::Bol => prog.push(Inst::Bol),
+        Nodo::Eol => prog.push(Inst::Eol),
+        Nodo::Clase { partes, negada } => prog.push(Inst::Clase { partes: partes.clone(), negada: *negada }),
+        Nodo::Cat(a, b) => { emitir(prog, a); emitir(prog, b); }
+        Nodo::Alt(a, b) => {
+            let split = prog.len();
+            prog.push(Inst::Jmp(0)); // reservado, se reescribe como Split
+            emitir(prog, a);
+            let jmp = prog.len();
+            prog.push(Inst::Jmp(0));
+            let inicio_b = prog.len();
+            emitir(prog, b);
+            let fin = prog.len();
+            prog[split] = Inst::Split(split + 1, inicio_b);
+            prog[jmp] = Inst::Jmp(fin);
+        }
+        Nodo::Estrella(a) => {
+            let split = prog.len();
+            prog.push(Inst::Jmp(0));
+            emitir(prog, a);
+            prog.push(Inst::Jmp(split));
+            let fin = prog.len();
+            prog[split] = Inst::Split(split + 1, fin);
+        }
+        Nodo::Mas(a) => {
+            let inicio = prog.len();
+            emitir(prog, a);
+            let split = prog.len();
+            prog.push(Inst::Jmp(0));
+            let fin = prog.len();
+            prog[split] = Inst::Split(inicio, fin);
+        }
+        Nodo::Opcional(a) => {
+            let split = prog.len();
+            prog.push(Inst::Jmp(0));
+            emitir(prog, a);
+            let fin = prog.len();
+            prog[split] = Inst::Split(split + 1, fin);
+        }
+        Nodo::Repite(a, desde, hasta) => {
+            // Se expande: `x{2,4}` es `xx(x(x)?)?`. Es lo que hace cualquier motor sin contadores, y
+            // el coste es lineal en `hasta`, que en la práctica es un número pequeño.
+            for _ in 0..*desde { emitir(prog, a); }
+            match hasta {
+                None => emitir(prog, &Nodo::Estrella(a.clone())),
+                Some(h) => {
+                    let opcionales = h.saturating_sub(*desde);
+                    let mut splits = Vec::new();
+                    for _ in 0..opcionales {
+                        let s = prog.len();
+                        prog.push(Inst::Jmp(0));
+                        splits.push(s);
+                        emitir(prog, a);
+                    }
+                    let fin = prog.len();
+                    for s in splits { prog[s] = Inst::Split(s + 1, fin); }
+                }
+            }
+        }
+    }
+}
+
+impl Regex {
+    pub fn nueva(patron: &str) -> Result<Regex, String> {
+        let chars: Vec<char> = patron.chars().collect();
+        let mut an = Analizador { p: &chars, i: 0 };
+        let nodo = an.alternancia()?;
+        if !an.fin() {
+            return Err(format!("basura al final del patrón: `{}`", chars[an.i..].iter().collect::<String>()));
+        }
+        let mut prog = Vec::new();
+        emitir(&mut prog, &nodo);
+        prog.push(Inst::Match);
+        Ok(Regex { prog, fuente: patron.to_string() })
+    }
+
+    pub fn fuente(&self) -> &str { &self.fuente }
+
+    /// Añade un hilo y todos los alcanzables sin consumir (cierre-epsilon).
+    fn agregar(&self, lista: &mut Vec<usize>, visto: &mut [bool], pc: usize, pos: usize, largo: usize) {
+        if visto[pc] { return; }
+        visto[pc] = true;
+        match self.prog[pc] {
+            Inst::Jmp(d) => self.agregar(lista, visto, d, pos, largo),
+            Inst::Split(a, b) => {
+                self.agregar(lista, visto, a, pos, largo);
+                self.agregar(lista, visto, b, pos, largo);
+            }
+            Inst::Bol => { if pos == 0 { self.agregar(lista, visto, pc + 1, pos, largo); } }
+            Inst::Eol => { if pos == largo { self.agregar(lista, visto, pc + 1, pos, largo); } }
+            _ => lista.push(pc),
+        }
+    }
+
+    /// El final del match MÁS LARGO que empieza exactamente en `desde`, si lo hay.
+    pub fn casa_en(&self, s: &[char], desde: usize) -> Option<usize> {
+        let largo = s.len();
+        let mut visto = vec![false; self.prog.len()];
+        let mut actual: Vec<usize> = Vec::new();
+        self.agregar(&mut actual, &mut visto, 0, desde, largo);
+        let mut mejor: Option<usize> = None;
+        let mut pos = desde;
+        loop {
+            // ¿Alguno acepta aquí? Nos quedamos con el más lejano: eso es «longest».
+            if actual.iter().any(|&pc| matches!(self.prog[pc], Inst::Match)) {
+                mejor = Some(pos);
+            }
+            if pos >= largo || actual.is_empty() { break; }
+            let c = s[pos];
+            let mut siguiente: Vec<usize> = Vec::new();
+            let mut visto2 = vec![false; self.prog.len()];
+            for &pc in &actual {
+                let avanza = match &self.prog[pc] {
+                    Inst::Char(x) => *x == c,
+                    Inst::Any => true,
+                    Inst::Clase { partes, negada } => {
+                        let dentro = partes.iter().any(|p| match p {
+                            Parte::Uno(u) => *u == c,
+                            Parte::Rango(a, b) => *a <= c && c <= *b,
+                            Parte::Nombrada(n) => n.casa(c),
+                        });
+                        dentro != *negada
+                    }
+                    _ => false,
+                };
+                if avanza { self.agregar(&mut siguiente, &mut visto2, pc + 1, pos + 1, largo); }
+            }
+            actual = siguiente;
+            pos += 1;
+        }
+        mejor
+    }
+
+    /// Primera coincidencia a partir de `desde`: la más a la izquierda y, entre ésas, la más larga.
+    /// Devuelve `(inicio, fin)` en índices de CARÁCTER, con `fin` exclusivo.
+    pub fn buscar(&self, s: &[char], desde: usize) -> Option<(usize, usize)> {
+        for i in desde..=s.len() {
+            if let Some(fin) = self.casa_en(s, i) { return Some((i, fin)); }
+        }
+        None
+    }
+
+    /// ¿Casa en algún sitio? Es lo que piden `~`, los patrones de regla y `split`.
+    pub fn casa(&self, s: &str) -> bool {
+        let v: Vec<char> = s.chars().collect();
+        self.buscar(&v, 0).is_some()
+    }
+}
diff --git a/crates/qillqa/src/valor.rs b/crates/qillqa/src/valor.rs
new file mode 100644
index 00000000..5a86867c
--- /dev/null
+++ b/crates/qillqa/src/valor.rs
@@ -0,0 +1,208 @@
+//! El modelo de valores de awk. Es la parte que más silenciosamente se equivoca.
+//!
+//! awk tiene TRES clases de valor, no dos (POSIX.1-2024 §2.6, «Expressions in awk»):
+//!
+//!   · **número**   — `1`, `x+0`, el resultado de cualquier aritmética.
+//!   · **cadena**   — un literal entre comillas, o el resultado de `substr`, `sprintf`…
+//!   · **strnum**   — lo que VIENE DE FUERA y *parece* un número: campos (`$1`), `getline`, los
+//!                    trozos de `split`, `ARGV`, `ENVIRON`, `FILENAME`. Se compara como número.
+//!
+//! La tercera es la que decide, y la diferencia es observable:
+//!
+//! ```text
+//! echo '10 9' | awk '{ print ($1 > $2) }'        ⇒ 1   (strnum: comparación NUMÉRICA)
+//! awk 'BEGIN { print ("10" > "9") }'             ⇒ 0   (cadenas: "1" < "9" alfabéticamente)
+//! ```
+//!
+//! Un awk que trate los campos como cadenas ordena mal cualquier tabla de números; uno que los trate
+//! siempre como números rompe `$1 == "0012"`. Por eso `Valor` lleva la procedencia y no sólo el dato.
+
+use std::fmt::Write as _;
+
+#[derive(Debug, Clone)]
+pub enum Valor {
+    Num(f64),
+    Cad(String),
+    /// Vino de la entrada. Guarda el texto TAL CUAL —`print $1` tiene que imprimir `0012`, no `12`—
+    /// y, si parece un número, también su valor.
+    StrNum(String, Option<f64>),
+    /// Variable nunca asignada: vale `""` y `0` a la vez, y compara igual con los dos.
+    Vacio,
+}
+
+/// ¿Este texto es un número en el sentido de awk? Admite espacios alrededor, signo, punto decimal y
+/// exponente; NO admite basura detrás (`"12x"` es cadena) ni hexadecimal (POSIX lo deja fuera, y
+/// gawk sólo lo acepta en modo no-POSIX).
+pub fn como_numero(s: &str) -> Option<f64> {
+    let t = s.trim_matches(|c: char| c == ' ' || c == '\t' || c == '\n');
+    if t.is_empty() { return None; }
+    let b: Vec<char> = t.chars().collect();
+    let mut i = 0;
+    if b[i] == '+' || b[i] == '-' { i += 1; }
+    let mut digitos = false;
+    while i < b.len() && b[i].is_ascii_digit() { i += 1; digitos = true; }
+    if i < b.len() && b[i] == '.' {
+        i += 1;
+        while i < b.len() && b[i].is_ascii_digit() { i += 1; digitos = true; }
+    }
+    if !digitos { return None; }
+    if i < b.len() && (b[i] == 'e' || b[i] == 'E') {
+        let guardado = i;
+        i += 1;
+        if i < b.len() && (b[i] == '+' || b[i] == '-') { i += 1; }
+        if i < b.len() && b[i].is_ascii_digit() {
+            while i < b.len() && b[i].is_ascii_digit() { i += 1; }
+        } else { i = guardado; }
+    }
+    if i != b.len() { return None; }
+    t.parse::<f64>().ok()
+}
+
+/// El prefijo numérico del texto, que es lo que usa la CONVERSIÓN explícita (`"12x"+0` es 12).
+/// Distinto de `como_numero`, que exige que no sobre nada: ahí `"12x"` no es un número.
+pub fn prefijo_numerico(s: &str) -> f64 {
+    let t = s.trim_start_matches(|c: char| c == ' ' || c == '\t' || c == '\n');
+    let b: Vec<char> = t.chars().collect();
+    let mut i = 0;
+    if i < b.len() && (b[i] == '+' || b[i] == '-') { i += 1; }
+    let mut fin = i;
+    while fin < b.len() && b[fin].is_ascii_digit() { fin += 1; }
+    if fin < b.len() && b[fin] == '.' {
+        fin += 1;
+        while fin < b.len() && b[fin].is_ascii_digit() { fin += 1; }
+    }
+    if fin < b.len() && (b[fin] == 'e' || b[fin] == 'E') {
+        let guardado = fin;
+        let mut j = fin + 1;
+        if j < b.len() && (b[j] == '+' || b[j] == '-') { j += 1; }
+        if j < b.len() && b[j].is_ascii_digit() {
+            while j < b.len() && b[j].is_ascii_digit() { j += 1; }
+            fin = j;
+        } else { fin = guardado; }
+    }
+    let t2: String = b[..fin].iter().collect();
+    t2.parse::<f64>().unwrap_or(0.0)
+}
+
+/// Formatea un número como lo hace awk al convertirlo a cadena.
+///
+/// La regla de POSIX: si el valor es **entero exacto**, se escribe como entero —`print 1/1` da `1`,
+/// no `1.000000`—; si no, con `CONVFMT` (por defecto `%.6g`) o con `OFMT` cuando lo imprime `print`.
+/// Ese «entero exacto» es por VALOR, no por tipo: `2.0` es entero y `1e17` también.
+pub fn numero_a_cadena(n: f64, fmt: &str) -> String {
+    if n.is_nan() { return "nan".into(); }
+    if n.is_infinite() { return if n > 0.0 { "inf".into() } else { "-inf".into() }; }
+    if n == n.trunc() && n.abs() < 1e17 {
+        let mut s = String::new();
+        let _ = write!(s, "{}", n as i64);
+        return s;
+    }
+    crate::formato::una_conversion(fmt, n)
+}
+
+impl Valor {
+    pub fn de_entrada(s: &str) -> Valor {
+        let n = como_numero(s);
+        Valor::StrNum(s.to_string(), n)
+    }
+
+    pub fn cadena(&self, convfmt: &str) -> String {
+        match self {
+            Valor::Num(n) => numero_a_cadena(*n, convfmt),
+            Valor::Cad(s) => s.clone(),
+            Valor::StrNum(s, _) => s.clone(),
+            Valor::Vacio => String::new(),
+        }
+    }
+
+    pub fn numero(&self) -> f64 {
+        match self {
+            Valor::Num(n) => *n,
+            Valor::Cad(s) => prefijo_numerico(s),
+            Valor::StrNum(_, Some(n)) => *n,
+            Valor::StrNum(s, None) => prefijo_numerico(s),
+            Valor::Vacio => 0.0,
+        }
+    }
+
+    /// Verdad de awk: un número es verdadero si no es 0; una CADENA, si no está vacía. Y un strnum
+    /// se juzga como número — `$1` valiendo `"0"` es FALSO, aunque la cadena no esté vacía.
+    pub fn verdad(&self) -> bool {
+        match self {
+            Valor::Num(n) => *n != 0.0,
+            Valor::Cad(s) => !s.is_empty(),
+            Valor::StrNum(s, n) => match n { Some(v) => *v != 0.0, None => !s.is_empty() },
+            Valor::Vacio => false,
+        }
+    }
+
+    /// ¿Se compara como número frente a otro? Sí si es número, si es un strnum que parece número, o
+    /// si nunca se asignó.
+    fn numerico(&self) -> bool {
+        matches!(self, Valor::Num(_) | Valor::Vacio | Valor::StrNum(_, Some(_)))
+    }
+
+    /// Comparación de awk: numérica si AMBOS lados lo admiten, de cadena en cuanto uno sea cadena.
+    pub fn comparar(&self, otro: &Valor, convfmt: &str) -> std::cmp::Ordering {
+        if self.numerico() && otro.numerico() {
+            let (a, b) = (self.numero(), otro.numero());
+            return a.partial_cmp(&b).unwrap_or(std::cmp::Ordering::Equal);
+        }
+        self.cadena(convfmt).cmp(&otro.cadena(convfmt))
+    }
+}
+
+#[cfg(test)]
+mod pruebas {
+    use super::*;
+
+    #[test]
+    fn strnum_compara_como_numero_y_la_cadena_no() {
+        let campo10 = Valor::de_entrada("10");
+        let campo9 = Valor::de_entrada("9");
+        assert_eq!(campo10.comparar(&campo9, "%.6g"), std::cmp::Ordering::Greater);
+        let cad10 = Valor::Cad("10".into());
+        let cad9 = Valor::Cad("9".into());
+        assert_eq!(cad10.comparar(&cad9, "%.6g"), std::cmp::Ordering::Less);
+    }
+
+    #[test]
+    fn un_campo_que_no_parece_numero_se_compara_como_cadena() {
+        let a = Valor::de_entrada("12x");
+        let b = Valor::de_entrada("9");
+        assert_eq!(a.comparar(&b, "%.6g"), std::cmp::Ordering::Less); // "12x" < "9"
+        assert_eq!(a.numero(), 12.0); // pero convertido a número es su prefijo
+    }
+
+    #[test]
+    fn el_texto_original_se_conserva() {
+        let v = Valor::de_entrada("0012");
+        assert_eq!(v.cadena("%.6g"), "0012");
+        assert_eq!(v.numero(), 12.0);
+    }
+
+    #[test]
+    fn enteros_exactos_no_llevan_decimales() {
+        assert_eq!(numero_a_cadena(1.0, "%.6g"), "1");
+        assert_eq!(numero_a_cadena(-0.0, "%.6g"), "0");
+        assert_eq!(numero_a_cadena(0.5, "%.6g"), "0.5");
+        assert_eq!(numero_a_cadena(1.0 / 3.0, "%.6g"), "0.333333");
+    }
+
+    #[test]
+    fn cero_en_un_campo_es_falso_aunque_la_cadena_no_este_vacia() {
+        assert!(!Valor::de_entrada("0").verdad());
+        assert!(Valor::Cad("0".into()).verdad());
+        assert!(!Valor::de_entrada("").verdad());
+    }
+
+    #[test]
+    fn que_es_numero_y_que_no() {
+        assert_eq!(como_numero(" 3.5e2 "), Some(350.0));
+        assert_eq!(como_numero("+.5"), Some(0.5));
+        assert_eq!(como_numero("12x"), None);
+        assert_eq!(como_numero("0x10"), None); // POSIX no admite hexadecimal
+        assert_eq!(como_numero(""), None);
+        assert_eq!(como_numero("."), None);
+    }
+}
diff --git a/crates/qillqa/tests/divergencias_medidas.rs b/crates/qillqa/tests/divergencias_medidas.rs
new file mode 100644
index 00000000..f2de4c13
--- /dev/null
+++ b/crates/qillqa/tests/divergencias_medidas.rs
@@ -0,0 +1,106 @@
+//! Las divergencias que el banco diferencial destapó contra `busybox awk` y `gawk --posix`.
+//!
+//! Cada una estuvo ROTA y daba un resultado plausible: ninguna reventaba, ninguna imprimía un
+//! error. Por eso están acá y no sólo en el banco — el banco compara contra oráculos que hay que
+//! tener en el store, y esto corre con `cargo test` en cualquier sitio.
+
+use std::process::{Command, Stdio};
+use std::io::Write;
+
+fn awk(prog: &str, entrada: &str) -> String {
+    let exe = env!("CARGO_BIN_EXE_qillqa");
+    let mut h = Command::new(exe).arg(prog)
+        .stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped())
+        .spawn().expect("no pude lanzar qillqa");
+    h.stdin.as_mut().unwrap().write_all(entrada.as_bytes()).unwrap();
+    let s = h.wait_with_output().unwrap();
+    String::from_utf8_lossy(&s.stdout).trim_end_matches('\n').to_string()
+}
+
+/// Leer NF tiene que forzar el partido en campos. Daba 0 hasta que alguien tocara un campo, porque
+/// el partido es perezoso — y eso es lo que hace barato asignar a `$0`.
+#[test]
+fn nf_se_lee_sin_tocar_un_campo() {
+    assert_eq!(awk("{print NF}", "   a    b   \n"), "2");
+    assert_eq!(awk("{print NF, $NF}", "a b c\n"), "3 c");
+}
+
+/// `length` sin paréntesis es el único builtin al que POSIX se lo permite, y se leía como una
+/// variable llamada `length`, que siempre vale vacío.
+#[test]
+fn length_sin_parentesis() {
+    assert_eq!(awk("{print length}", "abcd\n"), "4");
+    assert_eq!(awk("BEGIN{print length(\"hola\")}", ""), "4");
+}
+
+/// Un array se pasa POR REFERENCIA. Se decide mirando si la función usa el parámetro como array, y
+/// `a["k"] = 1` tiene el nombre en el LUGAR de la asignación, no en el valor.
+#[test]
+fn los_arrays_se_pasan_por_referencia() {
+    assert_eq!(awk(r#"function g(a){a["k"]="puesto"} BEGIN{g(A); print A["k"]}"#, ""), "puesto");
+    // Y los escalares siguen yendo por valor.
+    assert_eq!(awk("function f(x){x=99} BEGIN{y=1; f(y); print y}", ""), "1");
+}
+
+/// `substr` con inicio ≤ 0: se recorta a 1 y **el largo no se ajusta**. La lectura estricta de
+/// POSIX daría `a`; ni busybox ni gawk hacen eso, y lo que hay que ejecutar son los guiones
+/// escritos contra ellos.
+#[test]
+fn substr_con_indices_fuera_de_rango() {
+    assert_eq!(awk(r#"BEGIN{print substr("abcdef", 0, 3)}"#, ""), "abc");
+    assert_eq!(awk(r#"BEGIN{print substr("abcdef", -1, 3)}"#, ""), "abc");
+    assert_eq!(awk(r#"BEGIN{print "["substr("abc", 10)"]"}"#, ""), "[]");
+    // Los no enteros se truncan hacia cero, no se redondean.
+    assert_eq!(awk(r#"BEGIN{print substr("abcdef", 1.5, 2.5)}"#, ""), "ab");
+}
+
+/// Una coincidencia VACÍA pegada al final de la anterior no sustituye. Es la única diferencia
+/// medida entre gawk y el awk de busybox en todo el banco, y gawk es quien sigue a POSIX.
+#[test]
+fn gsub_no_sustituye_el_vacio_pegado_a_la_coincidencia_anterior() {
+    assert_eq!(awk(r#"BEGIN{s="xax"; n=gsub(/a*/,"-",s); print n, s}"#, ""), "3 -x-x-");
+    assert_eq!(awk(r#"BEGIN{s="abc"; n=gsub(/x*/,"-",s); print n, s}"#, ""), "4 -a-b-c-");
+}
+
+/// El campo que viene de la entrada se compara como número si lo parece, y como cadena si no.
+#[test]
+fn strnum() {
+    assert_eq!(awk("{print ($1 > $2)}", "10 9\n"), "1");
+    assert_eq!(awk(r#"BEGIN{print ("10" > "9")}"#, ""), "0");
+    assert_eq!(awk("{print $1}", "0012\n"), "0012");
+    assert_eq!(awk("{print $1+0}", "0012\n"), "12");
+}
+
+/// `^` asocia a la DERECHA. El awk de busybox lo hace al revés y da 64.
+#[test]
+fn la_potencia_asocia_a_la_derecha() {
+    assert_eq!(awk("BEGIN{print 2^3^2}", ""), "512");
+}
+
+/// La ERE es leftmost-LONGEST, no leftmost-first: para `a|ab` sobre `"ab"` gana `ab`.
+#[test]
+fn la_regex_es_leftmost_longest() {
+    assert_eq!(awk(r#"BEGIN{print match("ab", /a|ab/), RLENGTH}"#, ""), "1 2");
+    assert_eq!(awk(r#"BEGIN{s="ab"; sub(/a|ab/, "X", s); print s}"#, ""), "X");
+}
+
+/// Un `exit` en el cuerpo ejecuta los END; uno dentro de END, no.
+#[test]
+fn exit_ejecuta_los_end() {
+    assert_eq!(awk(r#"BEGIN{exit 0} END{print "end corre"}"#, ""), "end corre");
+}
+
+/// El `%c` de printf mira el TIPO del argumento.
+#[test]
+fn printf_c_mira_el_tipo() {
+    assert_eq!(awk(r#"BEGIN{printf "%c|%c\n", 65, "65"}"#, ""), "A|6");
+}
+
+/// Los campos se rehacen con OFS al tocar cualquiera, y `$0` se reparte al asignarlo.
+#[test]
+fn campos_y_registro_son_dos_caras_del_mismo_dato() {
+    assert_eq!(awk(r#"BEGIN{OFS="-"} {$1=$1; print}"#, "a b c\n"), "a-b-c");
+    assert_eq!(awk(r#"{$5="X"; print; print NF}"#, "a b\n"), "a b   X\n5");
+    assert_eq!(awk(r#"{$0="p q r"; print $2, NF}"#, "a b\n"), "q 3");
+    assert_eq!(awk(r#"{NF=2; print}"#, "a b c d\n"), "a b");
+}
diff --git a/docs/state/targets.toml b/docs/state/targets.toml
index 4cb5e37a..a9a26fdf 100644
--- a/docs/state/targets.toml
+++ b/docs/state/targets.toml
@@ -141,6 +141,16 @@ paquetes = [
   # `bc` es gavinhoward/bc: `bc` Y `dc` del mismo árbol, C99 sin dependencias, el que usan Alpine y
   # FreeBSD. La alternativa de GNU arrastra `ed` y `flex`, y su `dc` va en otro paquete.
   "psmisc", "bc",
+  # `ncurses-tools` (2026-09-22): `clear`, `reset`, `tput`, `tset`, `infocmp`, `tic`. La canónica
+  # `ncurses` instala `install.libs install.includes` y nada más ⇒ `clear` y `reset` en una imagen
+  # de takana eran el applet de busybox. Es el mismo caso que `xz-tools` y `zstd-cli`: variante con
+  # las herramientas, porque ampliar la canónica re-hashearía a sus 15 consumidores.
+  # ⚠ Instala los binarios y NO la base terminfo (~4000 entradas): `clear` y `tput` resuelven con
+  # los `--with-fallbacks` compilados dentro de la librería —linux, vt100, vt220, xterm,
+  # xterm-256color, screen, tmux, tmux-256color, ansi—, que cubren la consola del metal, QEMU y
+  # cualquier ssh moderno. Con un TERM exótico contestan «unknown terminal type» en vez de pintar
+  # basura. Corolario: `tic` e `infocmp` viajan pero no tienen base que consultar.
+  "ncurses-tools",
 ]
 
 [perfil.cli]
diff --git a/recipes/ncurses-tools.toml b/recipes/ncurses-tools.toml
new file mode 100644
index 00000000..f707c4f7
--- /dev/null
+++ b/recipes/ncurses-tools.toml
@@ -0,0 +1,67 @@
+# ncurses 6.5 — VARIANTE con los PROGRAMAS: `clear`, `reset`, `tput`, `tset`, `infocmp`, `tic`.
+# NO sustituye a `recipes/ncurses.toml`.
+#
+# ── POR QUÉ EXISTE ──────────────────────────────────────────────────────────────────────────────
+# La canónica instala `make install.libs install.includes`: librería y cabeceras, nada más. Es
+# deliberado —de ella cuelgan 15 recetas— pero el efecto medido es que `clear` y `reset` en una
+# imagen de takana eran **el applet de busybox**, con `ncurses` declarado en los 7 perfiles
+# (`scripts/busybox-censo-proveedores.py`, 2026-09-21). Tocar la canónica re-hashearía a sus 15
+# consumidores; va como variante, igual que `xz-tools` sobre `xz` y `zstd-cli` sobre `zstd`.
+#
+# ── LA DECISIÓN QUE HAY DETRÁS: terminfo ────────────────────────────────────────────────────────
+# `clear` y `reset` necesitan saber qué secuencias manda cada terminal, y eso sale de la base
+# terminfo (~4000 entradas, varios MB) o de los `--with-fallbacks` COMPILADOS DENTRO de la librería.
+# La canónica ya pinea nueve: linux, vt100, vt220, xterm, xterm-256color, screen, tmux,
+# tmux-256color, ansi. Esta variante instala `install.progs` y **no** `install.data`: los binarios
+# viajan, la base de datos no. Con eso, `clear` funciona para los nueve TERM que cubren la consola
+# del metal, QEMU y cualquier ssh desde un emulador moderno, y para un TERM exótico contesta
+# «unknown terminal type» en vez de pintar basura.
+#
+# El corolario, dicho para que nadie lo descubra a la mala: **`tic` e `infocmp` viajan pero no
+# tienen base que leer**. Sirven para compilar una entrada propia, no para consultar el sistema.
+
+name    = "ncurses-tools"
+version = "6.5"
+license = "MIT"
+
+[source]
+tarball = "https://ftp.gnu.org/gnu/ncurses/ncurses-6.5.tar.gz"
+sha256  = "136d91bc269a9a5785e5f9e980bc76ab57428f604ce3e5a5a90cebc767971cc6"
+
+[build]
+compiler = "zig-cc"
+target   = "x86_64-linux-musl"
+link     = "static"
+
+# Mismo `strip_debug` que la canónica: la variante no es sitio para cambiar de política.
+strip_debug = true
+
+[deps]
+build = ["binutils"]
+
+[build.phases]
+# `configure` IDÉNTICO al de la canónica —mismos fallbacks, mismo widec— porque los binarios tienen
+# que hablar la misma librería que ya viaja. Cambiar una perilla acá haría que `tput` resolviera
+# distinto que la `ncursesw` que enlazan las aplicaciones.
+configure = "./configure --prefix=/usr --without-shared --with-normal --enable-widec --without-debug --without-ada --without-tests --enable-pc-files --with-pkg-config-libdir=/usr/lib/pkgconfig --with-fallbacks=linux,vt100,vt220,xterm,xterm-256color,screen,tmux,tmux-256color,ansi"
+# ⚠ Los diez binarios salen DINÁMICOS aunque la receta diga `link = "static"` y el harness inyecte
+# `LDFLAGS=-static`, y la causa no es un flag que falte: es que ncurses usa `-static`/`-dynamic`
+# como **marcadores de tramo** alrededor de las librerías —su forma portable de
+# `-Wl,-Bstatic … -Wl,-Bdynamic`— y no como una decisión global. El Makefile generado trae:
+#
+#     LIBS_TIC   = -L../lib -static -lncursesw -lncursesw -dynamic
+#     LIBS_TINFO = -static -lncursesw -dynamic
+#
+# o sea «ncursesw estática, lo demás dinámico», que es exactamente lo que `--with-normal` promete.
+# El `-dynamic` llega al final de la línea de enlace y gana por orden. Se quita sobreescribiendo las
+# DOS variables en la línea de `make` (ahí sí: son variables del Makefile, no valores ya sustituidos
+# por el configure — la diferencia que hizo fallar el primer intento con `psmisc`).
+#
+# Es el tercer sabor del agujero que documenta `scripts/static-audit.sh`: cada sistema de build
+# decide dónde pone el flag, y `link = "static"` es una declaración que hay que ir a COMPROBAR al
+# binario. Aquí lo comprueba `file`; en el corpus, ese guión.
+compile   = 'make LIBS_TIC="-L../lib -static -lncursesw -lncursesw" LIBS_TINFO="-static -lncursesw"'
+# Sólo `install.progs`. Ni `install.libs` ni `install.includes`: los publica la canónica, y dos
+# copias de `libncursesw.a` disputándose la misma ruta hacen que quién gana dependa del orden de
+# hidratación — que es justo lo que no debe decidir nada. Mismo criterio que `xz-tools`.
+install   = 'make LIBS_TIC="-L../lib -static -lncursesw -lncursesw" LIBS_TINFO="-static -lncursesw" install.progs DESTDIR=/out'
diff --git a/scripts/awk-banco-diferencial.py b/scripts/awk-banco-diferencial.py
new file mode 100755
index 00000000..51a0f449
--- /dev/null
+++ b/scripts/awk-banco-diferencial.py
@@ -0,0 +1,221 @@
+#!/usr/bin/env python3
+"""Banco DIFERENCIAL de awk: corre los mismos casos en varios motores y compara.
+
+    scripts/awk-banco-diferencial.py                      # qillqa contra busybox awk y gawk --posix
+    scripts/awk-banco-diferencial.py --motor ruta/al/awk  # añade otro candidato
+
+Por qué diferencial y no de salidas esperadas escritas a mano: un banco escrito a mano mide lo que
+al autor se le ocurrió preguntar, y ahí es donde se cuela el sesgo — es la lección del §paso 3 de
+`docs/plan-botar-busybox.md`, donde brush pasó 56/57 casos POSIX y no construía una sola receta.
+Contra un oráculo que ya está en producción, cada divergencia es un hecho, no una opinión.
+
+⚠ Los oráculos NO coinciden entre sí en todo: busybox awk y gawk difieren en `substr` con índices
+raros, en `%c` y en el formato de algunos números. Por eso el veredicto no es «igual a X» sino
+«igual a LOS DOS», y cuando los dos difieren entre ellos el caso se marca `discrepan` y no cuenta
+como fallo de nadie — se mira a mano y se decide siguiendo POSIX.
+"""
+import argparse, json, os, subprocess, sys
+
+RAIZ = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+
+
+def del_store(receta, rel):
+    est = json.load(open(os.path.join(RAIZ, "docs/state/build-state.json")))["nodes"]
+    if receta not in est:
+        return None
+    p = "/store/" + est[receta]["hash"].split(":")[1] + "-" + receta + "/" + rel
+    return p if os.path.exists(p) else None
+
+
+# (nombre, programa, entrada)
+CASOS = [
+    # ── campos, separadores, registros ──────────────────────────────────────────────────────────
+    ("campos-simple",        '{print $2}',                         "a b c\nd e f\n"),
+    ("campos-nf",            '{print NF, $NF}',                    "a b c\nx\n"),
+    ("campos-vacio",         '{print "["$5"]"}',                   "a b\n"),
+    ("campos-cero",          '{print $0}',                         "  hola   mundo  \n"),
+    ("fs-espacios-rachas",   '{print NF}',                         "   a    b   \n"),
+    ("fs-coma",              '-F, {print $2}',                     "a,b,c\n"),
+    ("fs-tab",               '-F\t {print $2}',                    "a\tb\tc\n"),
+    ("fs-ere",               '-F[0-9]+ {print $2}',                "ab123cd\n"),
+    ("fs-punto-literal",     '-F. {print NF}',                     "a.b.c\n"),
+    ("asignar-campo",        '{$2="X"; print}',                    "a b c\n"),
+    ("asignar-campo-lejos",  '{$5="X"; print; print NF}',          "a b\n"),
+    ("asignar-cero",         '{$0="p q r"; print $2, NF}',         "a b\n"),
+    ("nf-recorta",           '{NF=2; print; print NF}',            "a b c d\n"),
+    ("ofs-al-rehacer",       'BEGIN{OFS="-"} {$1=$1; print}',      "a b c\n"),
+    ("registros-nr-fnr",     '{print NR, FNR}',                    "x\ny\n"),
+    # ── valores: la parte que más se equivoca ───────────────────────────────────────────────────
+    ("strnum-numerico",      '{print ($1 > $2)}',                  "10 9\n"),
+    ("cadena-alfabetica",    'BEGIN{print ("10" > "9")}',          ""),
+    ("strnum-cero-falso",    '{if ($1) print "si"; else print "no"}', "0\n"),
+    ("cadena-cero-verdad",   'BEGIN{ if ("0") print "si"; else print "no"}', ""),
+    ("texto-se-conserva",    '{print $1}',                         "0012\n"),
+    ("suma-con-basura",      '{print $1+0}',                       "12x\n"),
+    ("entero-sin-decimales", 'BEGIN{print 2/1, 1/3}',              ""),
+    ("convfmt",              'BEGIN{CONVFMT="%.2g"; x=1/3; print x ""}', ""),
+    ("ofmt",                 'BEGIN{OFMT="%.2f"; print 1/3}',      ""),
+    ("uninit-es-cero-y-vacio", 'BEGIN{print x+0, "["x"]", (x==0), (x=="")}', ""),
+    # ── aritmética y operadores ─────────────────────────────────────────────────────────────────
+    ("potencia-derecha",     'BEGIN{print 2^3^2}',                 ""),
+    ("modulo-real",          'BEGIN{print 5.5 % 2}',               ""),
+    ("unario-vs-concat",     'BEGIN{a=1;b=2;print a-b; print a" "-b}', ""),
+    ("incremento",           'BEGIN{i=5; print i++, i, ++i, i}',   ""),
+    ("asigna-op",            'BEGIN{x=10; x-=3; x*=2; print x}',   ""),
+    ("ternario",             'BEGIN{print (1?"a":"b"), (0?"a":"b")}', ""),
+    ("comparacion-encadena", 'BEGIN{print (1<2), (2<=2), (3!=3)}', ""),
+    ("concatenacion",        'BEGIN{print "a" 1+1 "b"}',           ""),
+    # ── expresiones regulares ───────────────────────────────────────────────────────────────────
+    ("ere-patron",           '/b/{print "hay"}',                   "abc\nxyz\n"),
+    ("ere-tilde",            '{print ($0 ~ /^a/), ($0 !~ /^a/)}',  "abc\nxbc\n"),
+    ("ere-alternancia-larga", 'BEGIN{print match("ab", /a|ab/), RLENGTH}', ""),
+    ("ere-clase",            'BEGIN{print match("foo123", /[[:digit:]]+/), RSTART, RLENGTH}', ""),
+    ("ere-intervalo",        'BEGIN{print match("aaaa", /a{2,3}/), RLENGTH}', ""),
+    ("ere-anclas",           'BEGIN{print ("abc" ~ /^abc$/), ("abc" ~ /^b/)}', ""),
+    ("ere-punto",            'BEGIN{print ("a.c" ~ /a\\.c/), ("abc" ~ /a\\.c/)}', ""),
+    ("ere-estrella-vacia",   'BEGIN{print match("bbb", /a*/), RLENGTH}', ""),
+    ("ere-clase-negada",     'BEGIN{print match("abc1", /[^a-z]/), RSTART}', ""),
+    # ── funciones de cadena ─────────────────────────────────────────────────────────────────────
+    ("substr-normal",        'BEGIN{print substr("abcdef", 2, 3)}', ""),
+    ("substr-sin-largo",     'BEGIN{print substr("abcdef", 3)}',   ""),
+    ("substr-inicio-cero",   'BEGIN{print "["substr("abcdef", 0, 3)"]"}', ""),
+    ("substr-negativo",      'BEGIN{print "["substr("abcdef", -1, 3)"]"}', ""),
+    ("substr-pasado",        'BEGIN{print "["substr("abc", 10)"]"}', ""),
+    ("substr-redondeo",      'BEGIN{print substr("abcdef", 1.5, 2.5)}', ""),
+    ("index",                'BEGIN{print index("abcabc","bc"), index("abc","z"), index("abc","")}', ""),
+    ("length",               'BEGIN{print length("hola"), length()}', ""),
+    ("length-campo",         '{print length}',                     "abcd\n"),
+    ("toupper-tolower",      'BEGIN{print toupper("aBc"), tolower("AbC")}', ""),
+    ("sub-simple",           'BEGIN{s="aaa"; n=sub(/a/,"b",s); print n, s}', ""),
+    ("gsub-simple",          'BEGIN{s="aaa"; n=gsub(/a/,"b",s); print n, s}', ""),
+    ("gsub-ampersand",       'BEGIN{s="abc"; gsub(/b/,"[&]",s); print s}', ""),
+    ("gsub-ampersand-escapado", 'BEGIN{s="abc"; gsub(/b/,"[\\\\&]",s); print s}', ""),
+    ("gsub-vacio",           'BEGIN{s="xax"; n=gsub(/a*/,"-",s); print n, s}', ""),
+    ("gsub-en-campo",        '{gsub(/o/,"0"); print}',             "foo boo\n"),
+    ("split-fs",             'BEGIN{n=split("a:b:c",A,":"); print n, A[1], A[3]}', ""),
+    ("split-ere",            'BEGIN{n=split("a1b22c",A,/[0-9]+/); print n, A[2], A[3]}', ""),
+    ("split-vacio",          'BEGIN{n=split("",A,":"); print n}',  ""),
+    ("split-default",        'BEGIN{n=split("  a  b ",A); print n, A[1], A[2]}', ""),
+    ("sprintf",              'BEGIN{print sprintf("%03d-%s", 7, "x")}', ""),
+    ("match-sin-exito",      'BEGIN{print match("abc",/z/), RSTART, RLENGTH}', ""),
+    # ── printf ──────────────────────────────────────────────────────────────────────────────────
+    ("printf-enteros",       'BEGIN{printf "%d|%5d|%-5d|%05d\\n", 42, 42, 42, 42}', ""),
+    ("printf-negativo",      'BEGIN{printf "%05d|%+d|% d\\n", -1, 7, 7}', ""),
+    ("printf-flotante",      'BEGIN{printf "%.2f|%e|%g\\n", 3.14159, 150, 0.0000123}', ""),
+    ("printf-cadena",        'BEGIN{printf "%s|%10s|%-10s|%.2s|\\n", "ab","ab","ab","abcd"}', ""),
+    ("printf-caracter-num",  'BEGIN{printf "%c\\n", 65}',          ""),
+    ("printf-hex",           'BEGIN{printf "%x|%X|%o\\n", 255, 255, 8}', ""),
+    ("printf-asterisco",     'BEGIN{printf "%*d|%.*f\\n", 5, 42, 2, 3.14159}', ""),
+    ("printf-porciento",     'BEGIN{printf "100%%\\n"}',           ""),
+    ("printf-trunca",        'BEGIN{printf "%d\\n", 3.99}',        ""),
+    # ── control de flujo ────────────────────────────────────────────────────────────────────────
+    ("if-else",              'BEGIN{ if (1) print "a"; else print "b" }', ""),
+    ("while",                'BEGIN{i=0; while(i<3){printf "%d", i; i++}; print ""}', ""),
+    ("do-while",             'BEGIN{i=0; do {printf "%d", i; i++} while(i<3); print ""}', ""),
+    ("for-clasico",          'BEGIN{for(i=0;i<3;i++) printf "%d", i; print ""}', ""),
+    ("for-in",               'BEGIN{a[1];a[2];a[3]; n=0; for(k in a) n++; print n}', ""),
+    ("break-continue",       'BEGIN{for(i=0;i<5;i++){if(i==1)continue; if(i==3)break; printf "%d",i}; print ""}', ""),
+    ("next",                 '/skip/{next} {print}',               "uno\nskip esto\ndos\n"),
+    ("exit-con-end",         'BEGIN{exit 0} END{print "end corre"}', ""),
+    ("patron-rango",         '/ini/,/fin/{print}',                 "a\nini\nx\nfin\nb\n"),
+    ("patron-expresion",     'NR==2{print}',                       "a\nb\nc\n"),
+    ("patron-sin-accion",    'NR==2',                              "a\nb\nc\n"),
+    # ── arrays ──────────────────────────────────────────────────────────────────────────────────
+    ("array-subsep",         'BEGIN{a[1,2]="x"; for(k in a){split(k,p,SUBSEP); print p[1],p[2]}}', ""),
+    ("array-in-crea",        'BEGIN{ if (a["k"]=="") print "vacio"; print ("k" in a) }', ""),
+    ("array-delete",         'BEGIN{a[1]=1;a[2]=2; delete a[1]; n=0; for(k in a)n++; print n}', ""),
+    ("array-delete-todo",    'BEGIN{a[1]=1;a[2]=2; delete a; n=0; for(k in a)n++; print n}', ""),
+    ("array-length",         'BEGIN{a[1];a[2]; print length(a)}',  ""),
+    # ── funciones de usuario ────────────────────────────────────────────────────────────────────
+    ("funcion-simple",       'function f(x){return x*2} BEGIN{print f(21)}', ""),
+    ("funcion-recursiva",    'function f(n){return n<=1?1:n*f(n-1)} BEGIN{print f(6)}', ""),
+    ("funcion-array-ref",    'function g(a){a["k"]="puesto"} BEGIN{g(A); print A["k"]}', ""),
+    ("funcion-local",        'function f(x,  t){t=x*2; return t} BEGIN{t=9; print f(2), t}', ""),
+    ("funcion-sin-return",   'function f(){} BEGIN{print "["f()"]"}', ""),
+    # ── entrada/salida ──────────────────────────────────────────────────────────────────────────
+    ("print-redirigido",     'BEGIN{print "x" > "/dev/stderr"}',   ""),
+    ("print-coma-ofs",       'BEGIN{OFS=":"; print "a","b"}',      ""),
+    ("print-ors",            'BEGIN{ORS="|"} {print}',             "a\nb\n"),
+    ("print-parentesis",     'BEGIN{print (1), (2)}',              ""),
+    ("getline-tuberia",      'BEGIN{ "echo hola" | getline x; print x }', ""),
+    ("getline-simple",       'NR==1{ getline; print "leido:" $0 }', "a\nb\nc\n"),
+    ("rs-parrafo",           'BEGIN{RS=""} {print NR": "$1"-"$2}', "a\nb\n\nc\nd\n"),
+    ("rs-caracter",          'BEGIN{RS=":"} {print NR, $0}',       "a:b:c"),
+    # ── rincones del parser ─────────────────────────────────────────────────────────────────────
+    ("barra-division",       'BEGIN{a=6; b=3; print a / b}',       ""),
+    ("print-mayor-redirige", 'BEGIN{print (1>2)}',                 ""),
+    ("comentario",           'BEGIN{ # esto es comentario\nprint "ok"}', ""),
+    ("punto-y-coma",         'BEGIN{;;print "ok";;}',              ""),
+    ("continuacion-linea",   'BEGIN{print "a" \\\n "b"}',          ""),
+    ("cadena-escapes",       'BEGIN{print "a\\tb\\\\c"}',          ""),
+    ("expresion-regex-valor", '{print /b/}',                       "abc\nxyz\n"),
+]
+
+
+def corre(motor, caso, entrada):
+    """Devuelve (salida+error, código)."""
+    args = list(motor)
+    prog = caso
+    # Los casos que empiezan por `-F` traen la opción pegada al programa.
+    if prog.startswith("-F"):
+        i = prog.index(" ")
+        args += [prog[:i]]
+        prog = prog[i + 1:]
+    args.append(prog)
+    try:
+        r = subprocess.run(args, input=entrada, capture_output=True, text=True, timeout=10)
+        return (r.stdout + r.stderr).rstrip("\n"), r.returncode
+    except subprocess.TimeoutExpired:
+        return "@TIMEOUT", -1
+    except Exception as e:
+        return f"@ERROR {e}", -1
+
+
+def main():
+    ap = argparse.ArgumentParser()
+    ap.add_argument("--motor", default=os.path.join(RAIZ, "target/release/qillqa"))
+    ap.add_argument("--verbose", action="store_true")
+    a = ap.parse_args()
+
+    bb = del_store("busybox", "usr/bin/awk")
+    gawk = del_store("gawk", "usr/bin/gawk")
+    oraculos = []
+    if bb: oraculos.append(("busybox", [bb]))
+    if gawk: oraculos.append(("gawk", [gawk, "--posix"]))
+    if not oraculos:
+        print("sin oráculos en el store: no se puede comparar", file=sys.stderr)
+        return 2
+
+    print(f"motor: {a.motor}")
+    print(f"oráculos: {', '.join(n for n, _ in oraculos)}\n")
+    igual = discrepan = distinto = 0
+    fallos = []
+    for nombre, prog, entrada in CASOS:
+        mio, _ = corre([a.motor], prog, entrada)
+        refs = [(n, corre(cmd, prog, entrada)[0]) for n, cmd in oraculos]
+        valores = {r for _, r in refs}
+        if len(valores) > 1:
+            discrepan += 1
+            if a.verbose:
+                print(f"  ~ {nombre}: los oráculos discrepan " +
+                      " · ".join(f"{n}=[{r}]" for n, r in refs) + f" · qillqa=[{mio}]")
+            continue
+        esperado = refs[0][1]
+        if mio == esperado:
+            igual += 1
+        else:
+            distinto += 1
+            fallos.append((nombre, prog, entrada, esperado, mio))
+
+    print(f"  iguales a los oráculos : {igual}")
+    print(f"  DIVERGEN               : {distinto}")
+    print(f"  los oráculos discrepan : {discrepan}  (no cuentan: se deciden por POSIX a mano)")
+    if fallos:
+        print("\n── divergencias ──")
+        for n, p, e, esp, mio in fallos:
+            print(f"\n  ✗ {n}\n      programa: {p}\n      entrada : {e!r}\n      oráculos: [{esp}]\n      qillqa  : [{mio}]")
+    return 1 if fallos else 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())