gioser

tawasuyu/brahman

Rust 476TOML 190Markdown 50JSON 176602 Definiciones

feat(charka): charka-codegen — emisión de Rust desde el IR

La etapa final del transpilador. generate(&Ir) -> String produce un
fuente Rust (un main.rs) que, compilado contra charka-runtime, ejecuta
la lógica del programa COBOL.

- struct Program con un campo Num/Text por dato elemental; new() lo
  inicializa desde las cláusulas VALUE.
- Un método p_<párrafo> por párrafo del PROCEDURE; run() los encadena
  en orden (el «caer» de COBOL); main() construye y corre.
- Cada Stmt -> código Rust: MOVE->.store/.fill, DISPLAY->println!,
  COMPUTE y aritmética -> expresiones Decimal, IF->if/else,
  PERFORM-> llamada / for / while, STOP RUN->process::exit.
- Tolerante: lo no transpilable (Stmt::Unknown, dato sin resolver, **)
  se emite como comentario // charka: — el código generado compila.
- Saneado de identificadores COBOL->Rust (choques con keywords).
- Verificado de punta a punta: un programa COBOL demo transpila a Rust
  que compila contra charka-runtime y produce la salida esperada.
- Módulos: emit / sym / expr / stmt. 14 tests; fmt + clippy limpios.

El pipeline COBOL->Rust corre de punta a punta. Falta sólo
charka-shadow (validador en sombra).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

e52b3fb572cec07d35c55d87519ecc8e0bd0505a · por sergio · (hace 5 meses) · padre 85156c150 · ver el árbol en este commit

Delta semántico · 73

crates/modules/charka/charka-codegen/src/emit.rs

crates/modules/charka/charka-codegen/src/expr.rs

crates/modules/charka/charka-codegen/src/stmt.rs

crates/modules/charka/charka-codegen/src/sym.rs

10 archivos · +1237 −4

diff --git a/Cargo.lock b/Cargo.lock
index b7b8723..7d6017e 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2315,6 +2315,16 @@ dependencies = [
  "thiserror 2.0.18",
 ]
 
+[[package]]
+name = "charka-codegen"
+version = "0.1.0"
+dependencies = [
+ "charka-bcd",
+ "charka-ir",
+ "charka-lexer",
+ "charka-parser",
+]
+
 [[package]]
 name = "charka-ir"
 version = "0.1.0"
diff --git a/Cargo.toml b/Cargo.toml
index 5c76c2f..8385186 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -161,6 +161,7 @@ members = [
     "crates/modules/charka/charka-parser",
     "crates/modules/charka/charka-ir",
     "crates/modules/charka/charka-runtime",
+    "crates/modules/charka/charka-codegen",
 
     # ============================================================
     # modules/mirada/ — Compositor Wayland
diff --git a/crates/modules/charka/SDD.md b/crates/modules/charka/SDD.md
index f83fa41..9f661fe 100644
--- a/crates/modules/charka/SDD.md
+++ b/crates/modules/charka/SDD.md
@@ -15,6 +15,7 @@ embebido, dialectos IBM Enterprise) es un esfuerzo multi-mes.
 | `charka-parser`  | lib  | Parser COBOL'85 (subconjunto): tokens → AST (`Program`) |
 | `charka-ir`      | lib  | Representación intermedia: el AST con los statements del PROCEDURE ya tipados |
 | `charka-runtime` | lib  | Soporte de ejecución de los programas transpilados: campos `Num` y `Text` |
+| `charka-codegen` | lib  | Emisión de Rust: IR → fuente Rust sobre `charka-runtime` |
 
 ## charka-bcd
 
@@ -113,16 +114,37 @@ los listaba al revés): el codegen emite llamadas contra esta API, así
 que el runtime debe existir primero — y es un crate autocontenido,
 verificable sin depender del código emitido.
 
+## charka-codegen
+
+La etapa final: `generate(&Ir) -> String` produce un fuente Rust (un
+`main.rs`) que, compilado contra `charka-runtime`, ejecuta la lógica
+del programa COBOL.
+
+- Un `struct Program` con un campo por cada dato elemental (`Num` /
+  `Text`); `Program::new()` lo inicializa desde las cláusulas `VALUE`.
+- Un método `p_<párrafo>(&mut self)` por párrafo; `run()` los encadena
+  en orden (el «caer» de COBOL); `main()` construye y corre.
+- Cada `Stmt` → líneas Rust: `MOVE`→`.store`, `DISPLAY`→`println!`,
+  `COMPUTE`/aritmética → expresiones `Decimal`, `IF`→`if`, `PERFORM`→
+  llamada / `for` / `while`, `STOP RUN`→`process::exit`.
+- **Tolerante**: lo no transpilable (`Stmt::Unknown`, dato sin
+  resolver, `**`) se emite como comentario `// charka:` — el código
+  generado siempre compila.
+- Verificado de punta a punta: un programa COBOL de demostración
+  transpila a Rust que compila contra `charka-runtime` y produce la
+  salida correcta.
+- Fuera de alcance v1: grupos como campo propio, `REDEFINES`,
+  `OCCURS`/tablas, `PERFORM ... THRU` como rango, E/S de ficheros.
+
 ## Estado
 
 `charka-bcd` (22 tests), `charka-lexer` (17 tests), `charka-parser`
-(15 tests), `charka-ir` (17 tests) y `charka-runtime` (17 tests)
-implementados y verdes. **Pendiente** — el resto del transpilador
-(Fase D del plan macro):
+(15 tests), `charka-ir` (17 tests), `charka-runtime` (17 tests) y
+`charka-codegen` (14 tests) implementados y verdes. El pipeline
+COBOL→Rust corre de punta a punta. **Pendiente** — el último crate:
 
 | crate pendiente   | rol                                                  |
 | ----------------- | ---------------------------------------------------- |
-| `charka-codegen`  | emisión de Rust (IR → fuente Rust sobre el runtime)  |
 | `charka-shadow`   | validador en sombra (original vs transpilado)        |
 
 Hito intermedio sugerido: subconjunto COBOL'85 puro antes de CICS/SQL.
diff --git a/crates/modules/charka/charka-codegen/Cargo.toml b/crates/modules/charka/charka-codegen/Cargo.toml
new file mode 100644
index 0000000..53c919b
--- /dev/null
+++ b/crates/modules/charka/charka-codegen/Cargo.toml
@@ -0,0 +1,17 @@
+[package]
+name = "charka-codegen"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "charka-codegen — emisión de Rust desde el IR de charka: el modelo de datos COBOL y los statements del PROCEDURE a código que enlaza con charka-runtime."
+
+[dependencies]
+charka-ir = { path = "../charka-ir" }
+charka-bcd = { path = "../charka-bcd" }
+
+[dev-dependencies]
+charka-parser = { path = "../charka-parser" }
+charka-lexer = { path = "../charka-lexer" }
diff --git a/crates/modules/charka/charka-codegen/src/emit.rs b/crates/modules/charka/charka-codegen/src/emit.rs
new file mode 100644
index 0000000..2e9032a
--- /dev/null
+++ b/crates/modules/charka/charka-codegen/src/emit.rs
@@ -0,0 +1,45 @@
+//! `Emitter` — un acumulador de código Rust con control de sangría.
+
+/// Acumula líneas de código Rust generado, llevando la sangría actual.
+pub(crate) struct Emitter {
+    out: String,
+    depth: usize,
+}
+
+impl Emitter {
+    pub(crate) fn new() -> Self {
+        Self {
+            out: String::new(),
+            depth: 0,
+        }
+    }
+
+    /// Escribe una línea con la sangría actual.
+    pub(crate) fn line(&mut self, s: &str) {
+        for _ in 0..self.depth {
+            self.out.push_str("    ");
+        }
+        self.out.push_str(s);
+        self.out.push('\n');
+    }
+
+    /// Una línea en blanco.
+    pub(crate) fn blank(&mut self) {
+        self.out.push('\n');
+    }
+
+    /// Aumenta un nivel de sangría.
+    pub(crate) fn indent(&mut self) {
+        self.depth += 1;
+    }
+
+    /// Reduce un nivel de sangría.
+    pub(crate) fn dedent(&mut self) {
+        self.depth = self.depth.saturating_sub(1);
+    }
+
+    /// Entrega el código acumulado.
+    pub(crate) fn finish(self) -> String {
+        self.out
+    }
+}
diff --git a/crates/modules/charka/charka-codegen/src/expr.rs b/crates/modules/charka/charka-codegen/src/expr.rs
new file mode 100644
index 0000000..4d99c83
--- /dev/null
+++ b/crates/modules/charka/charka-codegen/src/expr.rs
@@ -0,0 +1,170 @@
+//! Emisión de expresiones y condiciones: cada nodo del IR se convierte
+//! en un fragmento de código Rust (un `String`).
+
+use charka_ir::{BinOp, CmpOp, Cond, Expr, Figurative, Operand};
+
+use crate::sym::{FieldKind, Symbols};
+
+/// Un literal de texto Rust con las comillas y los escapes adecuados.
+pub(crate) fn rust_str(s: &str) -> String {
+    let mut out = String::from("\"");
+    for c in s.chars() {
+        match c {
+            '"' => out.push_str("\\\""),
+            '\\' => out.push_str("\\\\"),
+            '\n' => out.push_str("\\n"),
+            '\r' => out.push_str("\\r"),
+            '\t' => out.push_str("\\t"),
+            _ => out.push(c),
+        }
+    }
+    out.push('"');
+    out
+}
+
+/// El texto que representa una constante figurativa.
+pub(crate) fn figurative_text(f: Figurative) -> &'static str {
+    match f {
+        Figurative::Zero => "0",
+        Figurative::Space => " ",
+        Figurative::Quote => "\"",
+        Figurative::HighValue | Figurative::LowValue | Figurative::Null => "",
+    }
+}
+
+/// El carácter de relleno de una figurativa, para `Text::fill`.
+pub(crate) fn figurative_fill(f: Figurative) -> char {
+    match f {
+        Figurative::Zero => '0',
+        Figurative::Quote => '"',
+        _ => ' ',
+    }
+}
+
+/// Un operando como expresión de tipo `Decimal`.
+pub(crate) fn operand_decimal(sym: &Symbols, op: &Operand) -> String {
+    match op {
+        Operand::Num(n) => format!("dec({})", rust_str(n)),
+        Operand::Str(s) => format!(
+            "Decimal::parse({}).unwrap_or_else(|_| Decimal::zero())",
+            rust_str(s)
+        ),
+        Operand::Figurative(_) => "Decimal::zero()".to_string(),
+        Operand::Data(name) => match sym.lookup(name) {
+            Some(f) => match f.kind {
+                FieldKind::Num { .. } => format!("self.{}.value()", f.ident),
+                FieldKind::Text { .. } => format!(
+                    "Decimal::parse(self.{}.display().trim()).unwrap_or_else(|_| Decimal::zero())",
+                    f.ident
+                ),
+            },
+            None => format!("Decimal::zero() /* charka: dato no resuelto {name} */"),
+        },
+    }
+}
+
+/// Un operando como expresión de tipo `&str` (para texto).
+pub(crate) fn operand_str(sym: &Symbols, op: &Operand) -> String {
+    match op {
+        Operand::Str(s) => rust_str(s),
+        Operand::Num(n) => rust_str(n),
+        Operand::Figurative(f) => rust_str(figurative_text(*f)),
+        Operand::Data(name) => match sym.lookup(name) {
+            Some(f) => format!("self.{}.display().as_str()", f.ident),
+            None => format!("\"\" /* charka: dato no resuelto {name} */"),
+        },
+    }
+}
+
+/// Un operando como expresión que implementa `Display` (para `DISPLAY`).
+pub(crate) fn operand_display(sym: &Symbols, op: &Operand) -> String {
+    match op {
+        Operand::Str(s) => rust_str(s),
+        Operand::Num(n) => rust_str(n),
+        Operand::Figurative(f) => rust_str(figurative_text(*f)),
+        Operand::Data(name) => match sym.lookup(name) {
+            Some(f) => format!("self.{}.display()", f.ident),
+            None => format!("\"\" /* charka: dato no resuelto {name} */"),
+        },
+    }
+}
+
+/// Emite una expresión aritmética como código Rust de tipo `Decimal`.
+pub(crate) fn emit_expr(sym: &Symbols, e: &Expr) -> String {
+    match e {
+        Expr::Operand(op) => operand_decimal(sym, op),
+        Expr::Neg(inner) => format!("Decimal::zero().sub(&({}))", emit_expr(sym, inner)),
+        Expr::Binary { op, lhs, rhs } => {
+            let l = emit_expr(sym, lhs);
+            let r = emit_expr(sym, rhs);
+            match op {
+                BinOp::Add => format!("({l}).add(&({r}))"),
+                BinOp::Sub => format!("({l}).sub(&({r}))"),
+                BinOp::Mul => format!("({l}).mul(&({r}))"),
+                BinOp::Div => format!(
+                    "({l}).div(&({r}), 9, Rounding::Truncate).unwrap_or_else(|_| Decimal::zero())"
+                ),
+                BinOp::Pow => "Decimal::zero() /* charka: ** no soportado */".to_string(),
+            }
+        }
+    }
+}
+
+/// Emite una condición como código Rust de tipo `bool`.
+pub(crate) fn emit_cond(sym: &Symbols, c: &Cond) -> String {
+    match c {
+        Cond::Compare { lhs, op, rhs } => emit_compare(sym, lhs, *op, rhs),
+        Cond::Named(name) => {
+            format!("false /* charka: condición 88 no soportada: {name} */")
+        }
+        Cond::Not(inner) => format!("!({})", emit_cond(sym, inner)),
+        Cond::And(a, b) => format!("({}) && ({})", emit_cond(sym, a), emit_cond(sym, b)),
+        Cond::Or(a, b) => format!("({}) || ({})", emit_cond(sym, a), emit_cond(sym, b)),
+    }
+}
+
+/// Emite una comparación: numérica si ambos lados lo son, alfanumérica
+/// si alguno es texto.
+fn emit_compare(sym: &Symbols, lhs: &Operand, op: CmpOp, rhs: &Operand) -> String {
+    if is_text_operand(sym, lhs) || is_text_operand(sym, rhs) {
+        let method = match op {
+            CmpOp::Eq => "is_eq",
+            CmpOp::Ne => "is_ne",
+            CmpOp::Lt => "is_lt",
+            CmpOp::Gt => "is_gt",
+            CmpOp::Le => "is_le",
+            CmpOp::Ge => "is_ge",
+        };
+        format!(
+            "cobol_text_cmp({}, {}).{method}()",
+            operand_str(sym, lhs),
+            operand_str(sym, rhs)
+        )
+    } else {
+        let rust_op = match op {
+            CmpOp::Eq => "==",
+            CmpOp::Ne => "!=",
+            CmpOp::Lt => "<",
+            CmpOp::Gt => ">",
+            CmpOp::Le => "<=",
+            CmpOp::Ge => ">=",
+        };
+        format!(
+            "({}) {rust_op} ({})",
+            operand_decimal(sym, lhs),
+            operand_decimal(sym, rhs)
+        )
+    }
+}
+
+/// ¿El operando es alfanumérico?
+fn is_text_operand(sym: &Symbols, op: &Operand) -> bool {
+    match op {
+        Operand::Str(_) => true,
+        Operand::Data(name) => matches!(
+            sym.lookup(name).map(|f| &f.kind),
+            Some(FieldKind::Text { .. })
+        ),
+        _ => false,
+    }
+}
diff --git a/crates/modules/charka/charka-codegen/src/lib.rs b/crates/modules/charka/charka-codegen/src/lib.rs
new file mode 100644
index 0000000..e7a151c
--- /dev/null
+++ b/crates/modules/charka/charka-codegen/src/lib.rs
@@ -0,0 +1,344 @@
+//! `charka-codegen` — emisión de Rust desde el IR del transpilador.
+//!
+//! Etapa final del pipeline COBOL→Rust: toma el [`Ir`] de `charka-ir`
+//! y produce un fuente Rust (un `String`) que, compilado contra
+//! `charka-runtime`, ejecuta la lógica del programa COBOL original.
+//!
+//! La forma del código emitido:
+//!
+//! - Un `struct Program` con un campo por cada dato elemental — `Num`
+//!   para los numéricos, `Text` para los alfanuméricos.
+//! - `Program::new()` inicializa los campos desde sus cláusulas `VALUE`.
+//! - Un método `p_<párrafo>(&mut self)` por cada párrafo del PROCEDURE.
+//! - `run()` los encadena en orden (el «caer» de COBOL); `main()`
+//!   construye el `Program` y lo corre.
+//!
+//! Es **tolerante**: lo que no sabe transpilar (un `Stmt::Unknown`, un
+//! dato sin resolver, `**`) se emite como un comentario `// charka:` —
+//! el código generado siempre compila.
+//!
+//! Alcance v1 — fuera: grupos como campo propio, `REDEFINES`,
+//! `OCCURS`/tablas, `PERFORM ... THRU` como rango, E/S de ficheros,
+//! `EVALUATE`, CICS y SQL embebido.
+
+#![forbid(unsafe_code)]
+
+mod emit;
+mod expr;
+mod stmt;
+mod sym;
+
+use std::collections::HashMap;
+
+use charka_ir::{Ir, Procedure};
+
+use emit::Emitter;
+use expr::rust_str;
+use stmt::emit_stmt;
+use sym::{paragraph_method, Field, FieldKind, Symbols};
+
+/// Transpila un [`Ir`] a un fuente Rust completo (un `main.rs`).
+pub fn generate(ir: &Ir) -> String {
+    let sym = Symbols::build(&ir.data);
+    let mut em = Emitter::new();
+    emit_header(&mut em);
+    emit_struct(&mut em, &sym);
+    emit_impl(&mut em, &sym, ir);
+    emit_main(&mut em);
+    em.finish()
+}
+
+/// El preámbulo: doc, `allow`s, el `use` del runtime y el helper `dec`.
+fn emit_header(em: &mut Emitter) {
+    em.line("//! Generado por charka — transpilador COBOL → Rust.");
+    em.line("//! No editar a mano: regenerar desde el fuente COBOL.");
+    em.blank();
+    em.line(
+        "#![allow(dead_code, unused_mut, unused_variables, unused_parens, \
+unreachable_code, clippy::all)]",
+    );
+    em.blank();
+    em.line("use charka_runtime::*;");
+    em.blank();
+    em.line("/// Construye un `Decimal` desde un literal numérico COBOL.");
+    em.line("fn dec(s: &str) -> Decimal {");
+    em.line("    Decimal::parse(s).expect(\"charka: literal numérico inválido\")");
+    em.line("}");
+    em.blank();
+}
+
+/// El `struct Program` con un campo por dato elemental.
+fn emit_struct(em: &mut Emitter, sym: &Symbols) {
+    em.line("/// El estado del programa: un campo por cada dato elemental.");
+    em.line("struct Program {");
+    em.indent();
+    for f in &sym.fields {
+        let ty = match f.kind {
+            FieldKind::Num { .. } => "Num",
+            FieldKind::Text { .. } => "Text",
+        };
+        em.line(&format!("{}: {ty},", f.ident));
+    }
+    em.dedent();
+    em.line("}");
+    em.blank();
+}
+
+/// El bloque `impl Program`: `new`, los párrafos y `run`.
+fn emit_impl(em: &mut Emitter, sym: &Symbols, ir: &Ir) {
+    em.line("impl Program {");
+    em.indent();
+
+    // new()
+    em.line("fn new() -> Self {");
+    em.indent();
+    em.line("Self {");
+    em.indent();
+    for f in &sym.fields {
+        em.line(&format!("{}: {},", f.ident, field_init(f)));
+    }
+    em.dedent();
+    em.line("}");
+    em.dedent();
+    em.line("}");
+    em.blank();
+
+    // Un método por párrafo.
+    let methods = paragraph_methods(ir);
+    for (name, proc) in &methods {
+        em.line(&format!("fn {name}(&mut self) {{"));
+        em.indent();
+        for s in &proc.body {
+            emit_stmt(em, sym, s);
+        }
+        em.dedent();
+        em.line("}");
+        em.blank();
+    }
+
+    // run() — encadena los párrafos en orden.
+    em.line("fn run(&mut self) {");
+    em.indent();
+    if methods.is_empty() {
+        em.line("// programa sin PROCEDURE division");
+    }
+    for (name, _) in &methods {
+        em.line(&format!("self.{name}();"));
+    }
+    em.dedent();
+    em.line("}");
+
+    em.dedent();
+    em.line("}");
+    em.blank();
+}
+
+/// El `fn main`.
+fn emit_main(em: &mut Emitter) {
+    em.line("fn main() {");
+    em.indent();
+    em.line("Program::new().run();");
+    em.dedent();
+    em.line("}");
+}
+
+/// El inicializador de un campo, a partir de su cláusula `VALUE`.
+fn field_init(f: &Field) -> String {
+    match &f.kind {
+        FieldKind::Num { int, frac, signed } => format!(
+            "Num::with_value(Picture::new({int}, {frac}, {signed}), {})",
+            rust_str(&numeric_value(f.value.as_deref()))
+        ),
+        FieldKind::Text { len } => format!(
+            "Text::with_value({len}, {})",
+            rust_str(&text_value(f.value.as_deref()))
+        ),
+    }
+}
+
+/// Normaliza el `VALUE` de un campo numérico a un literal parseable.
+fn numeric_value(v: Option<&str>) -> String {
+    let Some(raw) = v else {
+        return "0".to_string();
+    };
+    let up = raw.to_uppercase();
+    if matches!(up.as_str(), "ZERO" | "ZEROS" | "ZEROES") {
+        return "0".to_string();
+    }
+    if charka_bcd::Decimal::parse(raw).is_ok() {
+        raw.to_string()
+    } else {
+        "0".to_string()
+    }
+}
+
+/// Normaliza el `VALUE` de un campo de texto. El parser envuelve los
+/// literales de texto en comillas simples; aquí se desenvuelven.
+fn text_value(v: Option<&str>) -> String {
+    let Some(raw) = v else {
+        return String::new();
+    };
+    let up = raw.to_uppercase();
+    if matches!(up.as_str(), "SPACE" | "SPACES") {
+        return String::new();
+    }
+    if matches!(up.as_str(), "ZERO" | "ZEROS" | "ZEROES") {
+        return "0".to_string();
+    }
+    if raw.len() >= 2 && raw.starts_with('\'') && raw.ends_with('\'') {
+        raw[1..raw.len() - 1].to_string()
+    } else {
+        raw.to_string()
+    }
+}
+
+/// Asigna a cada párrafo un nombre de método único.
+fn paragraph_methods(ir: &Ir) -> Vec<(String, &Procedure)> {
+    let mut seen: HashMap<String, u32> = HashMap::new();
+    let mut out = Vec::new();
+    for proc in &ir.procedures {
+        let base = paragraph_method(&proc.name);
+        let n = seen.entry(base.clone()).or_insert(0);
+        let name = if *n > 0 { format!("{base}_{n}") } else { base };
+        *n += 1;
+        out.push((name, proc));
+    }
+    out
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    /// Helper: lexa, parsea, baja a IR y transpila un fuente COBOL.
+    fn gen(src: &str) -> String {
+        let toks = charka_lexer::lex(src, charka_lexer::SourceFormat::Free).expect("lex");
+        let prog = charka_parser::parse(&toks).expect("parse");
+        let ir = charka_ir::lower(&prog);
+        generate(&ir)
+    }
+
+    /// Un programa COBOL de demostración, razonablemente completo.
+    const DEMO: &str = "IDENTIFICATION DIVISION.\n\
+         PROGRAM-ID. DEMO.\n\
+         DATA DIVISION.\n\
+         WORKING-STORAGE SECTION.\n\
+         01 WS-A    PIC 9(3) VALUE 10.\n\
+         01 WS-B    PIC 9(3).\n\
+         01 WS-NAME PIC X(8) VALUE 'BOB'.\n\
+         PROCEDURE DIVISION.\n\
+         MAIN-PARA.\n\
+             MOVE 5 TO WS-B.\n\
+             COMPUTE WS-B = WS-A + WS-B.\n\
+             DISPLAY 'B=' WS-B.\n\
+             IF WS-B > 0 DISPLAY 'POS' END-IF.\n\
+             PERFORM SUB-PARA.\n\
+             STOP RUN.\n\
+         SUB-PARA.\n\
+             DISPLAY WS-NAME.\n";
+
+    #[test]
+    fn header_and_main_are_emitted() {
+        let out = gen(DEMO);
+        assert!(out.contains("use charka_runtime::*;"));
+        assert!(out.contains("fn dec(s: &str) -> Decimal {"));
+        assert!(out.contains("fn main() {"));
+        assert!(out.contains("Program::new().run();"));
+    }
+
+    #[test]
+    fn numeric_field_becomes_num() {
+        let out = gen(DEMO);
+        assert!(out.contains("ws_a: Num,"));
+        assert!(out.contains("Num::with_value(Picture::new(3, 0, false), \"10\")"));
+    }
+
+    #[test]
+    fn alphanumeric_field_becomes_text() {
+        let out = gen(DEMO);
+        assert!(out.contains("ws_name: Text,"));
+        assert!(out.contains("Text::with_value(8, \"BOB\")"));
+    }
+
+    #[test]
+    fn move_emits_a_store() {
+        assert!(gen(DEMO).contains("self.ws_b.store(dec(\"5\"));"));
+    }
+
+    #[test]
+    fn compute_emits_the_expression() {
+        let out = gen(DEMO);
+        assert!(out.contains("self.ws_b.store((self.ws_a.value()).add(&(self.ws_b.value())));"));
+    }
+
+    #[test]
+    fn display_emits_a_println() {
+        let out = gen(DEMO);
+        assert!(out.contains("println!(\"{}{}\", \"B=\", self.ws_b.display());"));
+    }
+
+    #[test]
+    fn if_emits_a_rust_if() {
+        let out = gen(DEMO);
+        assert!(out.contains("if (self.ws_b.value()) > (dec(\"0\")) {"));
+    }
+
+    #[test]
+    fn paragraphs_become_methods_and_run_chains_them() {
+        let out = gen(DEMO);
+        assert!(out.contains("fn p_main_para(&mut self) {"));
+        assert!(out.contains("fn p_sub_para(&mut self) {"));
+        assert!(out.contains("fn run(&mut self) {"));
+        assert!(out.contains("self.p_main_para();"));
+        assert!(out.contains("self.p_sub_para();"));
+    }
+
+    #[test]
+    fn perform_calls_the_paragraph_method() {
+        assert!(gen(DEMO).contains("self.p_sub_para();"));
+    }
+
+    #[test]
+    fn stop_run_exits() {
+        assert!(gen(DEMO).contains("std::process::exit(0);"));
+    }
+
+    #[test]
+    fn unknown_verb_becomes_a_comment() {
+        let out = gen("PROCEDURE DIVISION.\n\
+             MAIN.\n\
+                 INSPECT WS-X TALLYING WS-N FOR ALL ' '.\n");
+        assert!(out.contains("// charka: verbo no transpilado — INSPECT"));
+    }
+
+    #[test]
+    fn add_giving_emits_a_sum() {
+        let out = gen("DATA DIVISION.\n\
+             WORKING-STORAGE SECTION.\n\
+             01 A PIC 9(3).\n\
+             01 B PIC 9(3).\n\
+             01 C PIC 9(3).\n\
+             PROCEDURE DIVISION.\n\
+             MAIN.\n\
+                 ADD A B GIVING C.\n");
+        assert!(out.contains("self.c.store((self.a.value()).add(&(self.b.value())));"));
+    }
+
+    #[test]
+    fn perform_times_emits_a_loop() {
+        let out = gen("PROCEDURE DIVISION.\n\
+             MAIN.\n\
+                 PERFORM SUB 3 TIMES.\n\
+             SUB.\n\
+                 CONTINUE.\n");
+        assert!(out.contains("for _ in 0..3usize {"));
+    }
+
+    #[test]
+    fn empty_program_still_compiles_shape() {
+        let out = gen("");
+        assert!(out.contains("struct Program {"));
+        assert!(out.contains("fn main() {"));
+        assert!(out.contains("fn run(&mut self) {"));
+    }
+}
diff --git a/crates/modules/charka/charka-codegen/src/stmt.rs b/crates/modules/charka/charka-codegen/src/stmt.rs
new file mode 100644
index 0000000..13d6803
--- /dev/null
+++ b/crates/modules/charka/charka-codegen/src/stmt.rs
@@ -0,0 +1,364 @@
+//! Emisión de los statements del PROCEDURE: cada [`Stmt`] se traduce a
+//! una o varias líneas de código Rust sobre `charka-runtime`.
+
+use charka_ir::{Operand, Perform, PerformControl, PerformTarget, Stmt};
+
+use crate::emit::Emitter;
+use crate::expr::{
+    emit_cond, emit_expr, figurative_fill, operand_decimal, operand_display, operand_str,
+};
+use crate::sym::{paragraph_method, FieldKind, Symbols};
+
+/// Emite un statement.
+pub(crate) fn emit_stmt(em: &mut Emitter, sym: &Symbols, stmt: &Stmt) {
+    match stmt {
+        Stmt::Move { from, to } => emit_move(em, sym, from, to),
+        Stmt::Display { items } => emit_display(em, sym, items),
+        Stmt::Accept { into } => {
+            em.line(&format!(
+                "// charka: ACCEPT {into} — entrada interactiva no soportada en v1"
+            ));
+        }
+        Stmt::Compute {
+            targets,
+            rounded,
+            expr,
+        } => {
+            let value = emit_expr(sym, expr);
+            for t in targets {
+                emit_store(em, sym, t, &value, *rounded);
+            }
+        }
+        Stmt::Add {
+            addends,
+            to,
+            giving,
+            rounded,
+        } => emit_add(em, sym, addends, to, giving, *rounded),
+        Stmt::Subtract {
+            amounts,
+            from,
+            giving,
+            rounded,
+        } => emit_subtract(em, sym, amounts, from, giving, *rounded),
+        Stmt::Multiply {
+            left,
+            by,
+            giving,
+            rounded,
+        } => emit_multiply(em, sym, left, by, giving, *rounded),
+        Stmt::Divide {
+            left,
+            right,
+            by_form,
+            giving,
+            rounded,
+        } => emit_divide(em, sym, left, right, *by_form, giving, *rounded),
+        Stmt::If {
+            cond,
+            then_branch,
+            else_branch,
+        } => {
+            em.line(&format!("if {} {{", emit_cond(sym, cond)));
+            em.indent();
+            emit_block(em, sym, then_branch);
+            em.dedent();
+            if else_branch.is_empty() {
+                em.line("}");
+            } else {
+                em.line("} else {");
+                em.indent();
+                emit_block(em, sym, else_branch);
+                em.dedent();
+                em.line("}");
+            }
+        }
+        Stmt::Perform(p) => emit_perform(em, sym, p),
+        Stmt::GoTo { target } => {
+            em.line(&format!(
+                "self.{}(); return; // charka: GO TO (aproximado)",
+                paragraph_method(target)
+            ));
+        }
+        Stmt::StopRun | Stmt::Goback => em.line("std::process::exit(0);"),
+        Stmt::Exit => em.line("return;"),
+        Stmt::Continue => em.line("// CONTINUE"),
+        Stmt::Unknown { verb, .. } => {
+            em.line(&format!("// charka: verbo no transpilado — {verb}"));
+        }
+    }
+}
+
+/// Emite una secuencia de statements (un cuerpo de bloque).
+fn emit_block(em: &mut Emitter, sym: &Symbols, stmts: &[Stmt]) {
+    for s in stmts {
+        emit_stmt(em, sym, s);
+    }
+}
+
+/// Almacena un valor `Decimal` (texto de expresión) en un campo.
+fn emit_store(em: &mut Emitter, sym: &Symbols, name: &str, value: &str, rounded: bool) {
+    match sym.lookup(name) {
+        Some(f) => match f.kind {
+            FieldKind::Num { .. } => {
+                let method = if rounded { "store_rounded" } else { "store" };
+                em.line(&format!("self.{}.{method}({value});", f.ident));
+            }
+            FieldKind::Text { .. } => {
+                em.line(&format!(
+                    "self.{}.store(({value}).to_string().as_str());",
+                    f.ident
+                ));
+            }
+        },
+        None => em.line(&format!("// charka: destino no resuelto — {name}")),
+    }
+}
+
+fn emit_move(em: &mut Emitter, sym: &Symbols, from: &Operand, to: &[String]) {
+    for t in to {
+        match sym.lookup(t) {
+            Some(f) => match f.kind {
+                FieldKind::Num { .. } => {
+                    em.line(&format!(
+                        "self.{}.store({});",
+                        f.ident,
+                        operand_decimal(sym, from)
+                    ));
+                }
+                FieldKind::Text { .. } => {
+                    if let Operand::Figurative(fig) = from {
+                        em.line(&format!(
+                            "self.{}.fill('{}');",
+                            f.ident,
+                            figurative_fill(*fig)
+                        ));
+                    } else {
+                        em.line(&format!(
+                            "self.{}.store({});",
+                            f.ident,
+                            operand_str(sym, from)
+                        ));
+                    }
+                }
+            },
+            None => em.line(&format!("// charka: destino MOVE no resuelto — {t}")),
+        }
+    }
+}
+
+fn emit_display(em: &mut Emitter, sym: &Symbols, items: &[Operand]) {
+    if items.is_empty() {
+        em.line("println!();");
+        return;
+    }
+    let placeholders = "{}".repeat(items.len());
+    let args: Vec<String> = items.iter().map(|o| operand_display(sym, o)).collect();
+    em.line(&format!(
+        "println!(\"{placeholders}\", {});",
+        args.join(", ")
+    ));
+}
+
+/// La suma de una lista de operandos, encadenando `.add`.
+fn fold_sum(sym: &Symbols, ops: &[Operand]) -> String {
+    let mut it = ops.iter();
+    let Some(first) = it.next() else {
+        return "Decimal::zero()".to_string();
+    };
+    let mut acc = operand_decimal(sym, first);
+    for o in it {
+        acc = format!("({acc}).add(&({}))", operand_decimal(sym, o));
+    }
+    acc
+}
+
+fn emit_add(
+    em: &mut Emitter,
+    sym: &Symbols,
+    addends: &[Operand],
+    to: &[String],
+    giving: &[String],
+    rounded: bool,
+) {
+    let sum = fold_sum(sym, addends);
+    if !giving.is_empty() {
+        let base = match to.first() {
+            Some(first) => format!(
+                "({sum}).add(&({}))",
+                operand_decimal(sym, &Operand::Data(first.clone()))
+            ),
+            None => sum,
+        };
+        for g in giving {
+            emit_store(em, sym, g, &base, rounded);
+        }
+    } else {
+        for t in to {
+            emit_inplace(em, sym, t, "add", &sum, rounded);
+        }
+    }
+}
+
+fn emit_subtract(
+    em: &mut Emitter,
+    sym: &Symbols,
+    amounts: &[Operand],
+    from: &[String],
+    giving: &[String],
+    rounded: bool,
+) {
+    let sum = fold_sum(sym, amounts);
+    if !giving.is_empty() {
+        let minuend = from
+            .first()
+            .map(|f| operand_decimal(sym, &Operand::Data(f.clone())))
+            .unwrap_or_else(|| "Decimal::zero()".to_string());
+        let value = format!("({minuend}).sub(&({sum}))");
+        for g in giving {
+            emit_store(em, sym, g, &value, rounded);
+        }
+    } else {
+        for t in from {
+            emit_inplace(em, sym, t, "sub", &sum, rounded);
+        }
+    }
+}
+
+fn emit_multiply(
+    em: &mut Emitter,
+    sym: &Symbols,
+    left: &Operand,
+    by: &Operand,
+    giving: &[String],
+    rounded: bool,
+) {
+    let l = operand_decimal(sym, left);
+    if !giving.is_empty() {
+        let value = format!("({l}).mul(&({}))", operand_decimal(sym, by));
+        for g in giving {
+            emit_store(em, sym, g, &value, rounded);
+        }
+    } else if let Operand::Data(name) = by {
+        // `MULTIPLY a BY b` sin GIVING: b queda con a*b.
+        emit_inplace(em, sym, name, "mul", &l, rounded);
+    } else {
+        em.line("// charka: MULTIPLY sin destino claro");
+    }
+}
+
+fn emit_divide(
+    em: &mut Emitter,
+    sym: &Symbols,
+    left: &Operand,
+    right: &Operand,
+    by_form: bool,
+    giving: &[String],
+    rounded: bool,
+) {
+    // `a BY b` → a/b; `a INTO b` → b/a.
+    let (num, den) = if by_form {
+        (operand_decimal(sym, left), operand_decimal(sym, right))
+    } else {
+        (operand_decimal(sym, right), operand_decimal(sym, left))
+    };
+    if !giving.is_empty() {
+        for g in giving {
+            let value = format!(
+                "({num}).div(&({den}), {}, Rounding::Truncate).unwrap_or_else(|_| Decimal::zero())",
+                target_scale(sym, g)
+            );
+            emit_store(em, sym, g, &value, rounded);
+        }
+    } else if let Operand::Data(name) = right {
+        // `DIVIDE a INTO b` sin GIVING: b queda con b/a.
+        let value = format!(
+            "({num}).div(&({den}), {}, Rounding::Truncate).unwrap_or_else(|_| Decimal::zero())",
+            target_scale(sym, name)
+        );
+        emit_store(em, sym, name, &value, rounded);
+    } else {
+        em.line("// charka: DIVIDE sin destino claro");
+    }
+}
+
+/// Emite una operación aritmética en el lugar: `t = t <op> rhs`.
+fn emit_inplace(em: &mut Emitter, sym: &Symbols, name: &str, op: &str, rhs: &str, rounded: bool) {
+    match sym.lookup(name) {
+        Some(f) if matches!(f.kind, FieldKind::Num { .. }) => {
+            let method = if rounded { "store_rounded" } else { "store" };
+            em.line(&format!(
+                "self.{0}.{method}(self.{0}.value().{op}(&({rhs})));",
+                f.ident
+            ));
+        }
+        _ => em.line(&format!(
+            "// charka: destino aritmético no resuelto — {name}"
+        )),
+    }
+}
+
+/// La escala de redondeo de un destino numérico (sus dígitos
+/// fraccionarios), o 4 por defecto.
+fn target_scale(sym: &Symbols, name: &str) -> u8 {
+    match sym.lookup(name).map(|f| &f.kind) {
+        Some(FieldKind::Num { frac, .. }) => *frac,
+        _ => 4,
+    }
+}
+
+/// Una expresión `usize` para el número de repeticiones de un `PERFORM`.
+fn count_expr(sym: &Symbols, op: &Operand) -> String {
+    match op {
+        Operand::Num(n) => match n.trim_start_matches('+').parse::<i128>() {
+            Ok(v) if v >= 0 => format!("{v}usize"),
+            _ => "0usize".to_string(),
+        },
+        _ => format!(
+            "(({}).rescale(0, Rounding::Truncate).mantissa().max(0) as usize)",
+            operand_decimal(sym, op)
+        ),
+    }
+}
+
+fn emit_perform(em: &mut Emitter, sym: &Symbols, p: &Perform) {
+    // Emite el "cuerpo": la llamada al párrafo o el bloque en línea.
+    let emit_body = |em: &mut Emitter, sym: &Symbols| match &p.target {
+        PerformTarget::Paragraph { name, thru } => {
+            let note = thru
+                .as_ref()
+                .map(|t| format!(" // charka: THRU {t} — rango no soportado"))
+                .unwrap_or_default();
+            em.line(&format!("self.{}();{note}", paragraph_method(name)));
+        }
+        PerformTarget::Inline(body) => emit_block(em, sym, body),
+    };
+
+    match &p.control {
+        PerformControl::Once => {
+            if matches!(p.target, PerformTarget::Inline(_)) {
+                em.line("{");
+                em.indent();
+                emit_body(em, sym);
+                em.dedent();
+                em.line("}");
+            } else {
+                emit_body(em, sym);
+            }
+        }
+        PerformControl::Times(n) => {
+            em.line(&format!("for _ in 0..{} {{", count_expr(sym, n)));
+            em.indent();
+            emit_body(em, sym);
+            em.dedent();
+            em.line("}");
+        }
+        PerformControl::Until(cond) => {
+            em.line(&format!("while !({}) {{", emit_cond(sym, cond)));
+            em.indent();
+            emit_body(em, sym);
+            em.dedent();
+            em.line("}");
+        }
+    }
+}
diff --git a/crates/modules/charka/charka-codegen/src/sym.rs b/crates/modules/charka/charka-codegen/src/sym.rs
new file mode 100644
index 0000000..cf530b3
--- /dev/null
+++ b/crates/modules/charka/charka-codegen/src/sym.rs
@@ -0,0 +1,233 @@
+//! Tabla de símbolos: el modelo de datos COBOL traducido a campos Rust.
+
+use std::collections::HashMap;
+
+use charka_ir::DataItem;
+
+/// Un campo del struct `Program` generado.
+pub(crate) struct Field {
+    /// Nombre COBOL en mayúsculas.
+    pub cobol: String,
+    /// Identificador Rust saneado y único.
+    pub ident: String,
+    /// Numérico o alfanumérico.
+    pub kind: FieldKind,
+    /// La cláusula `VALUE`, si la hay.
+    pub value: Option<String>,
+}
+
+/// El tipo de un campo elemental.
+pub(crate) enum FieldKind {
+    /// Campo numérico — se emite como `Num`.
+    Num { int: u8, frac: u8, signed: bool },
+    /// Campo alfanumérico — se emite como `Text`.
+    Text { len: usize },
+}
+
+/// El conjunto de campos del programa, indexado por nombre COBOL.
+pub(crate) struct Symbols {
+    pub fields: Vec<Field>,
+    by_name: HashMap<String, usize>,
+}
+
+impl Symbols {
+    /// Construye la tabla recorriendo el árbol de datos.
+    pub(crate) fn build(data: &[DataItem]) -> Self {
+        let mut fields = Vec::new();
+        collect(data, &mut fields);
+        dedup_idents(&mut fields);
+        let by_name = fields
+            .iter()
+            .enumerate()
+            .map(|(i, f)| (f.cobol.clone(), i))
+            .collect();
+        Self { fields, by_name }
+    }
+
+    /// Busca un campo por su nombre COBOL (sin distinguir mayúsculas).
+    pub(crate) fn lookup(&self, cobol: &str) -> Option<&Field> {
+        self.by_name
+            .get(&cobol.to_uppercase())
+            .map(|&i| &self.fields[i])
+    }
+}
+
+/// Recoge los datos elementales del árbol. Los grupos no son campos —
+/// se recurre en sus hijos. Se saltan niveles 88/66 y los `FILLER`.
+fn collect(items: &[DataItem], out: &mut Vec<Field>) {
+    for it in items {
+        if it.level == 88 || it.level == 66 {
+            continue;
+        }
+        if !it.children.is_empty() {
+            collect(&it.children, out);
+            continue;
+        }
+        if it.name == "FILLER" {
+            continue;
+        }
+        let Some(kind) = classify(it.picture.as_deref()) else {
+            continue;
+        };
+        out.push(Field {
+            cobol: it.name.clone(),
+            ident: sanitize_ident(&it.name),
+            kind,
+            value: it.value.clone(),
+        });
+    }
+}
+
+/// Dos datos pueden compartir nombre (COBOL los califica); aquí, como
+/// son campos de un struct, sus identificadores deben ser únicos.
+fn dedup_idents(fields: &mut [Field]) {
+    let mut seen: HashMap<String, u32> = HashMap::new();
+    for f in fields.iter_mut() {
+        let n = seen.entry(f.ident.clone()).or_insert(0);
+        if *n > 0 {
+            f.ident = format!("{}_{}", f.ident, n);
+        }
+        *n += 1;
+    }
+}
+
+/// Clasifica una cláusula PICTURE: alfanumérica si tiene `X`/`A`,
+/// numérica si `charka-bcd` la parsea; una PICTURE de edición se trata
+/// como texto de presentación.
+fn classify(pic: Option<&str>) -> Option<FieldKind> {
+    let up = pic?.to_uppercase();
+    if up.contains('X') || up.contains('A') {
+        return Some(FieldKind::Text {
+            len: pic_width(&up).max(1),
+        });
+    }
+    if let Ok(p) = charka_bcd::Picture::parse(&up) {
+        return Some(FieldKind::Num {
+            int: p.integer_digits,
+            frac: p.fraction_digits,
+            signed: p.signed,
+        });
+    }
+    Some(FieldKind::Text {
+        len: pic_width(&up).max(1),
+    })
+}
+
+/// Cuenta las posiciones de presentación de una PICTURE, expandiendo
+/// la repetición `C(n)`. `S` y `V` no ocupan posición.
+fn pic_width(up: &str) -> usize {
+    let chars: Vec<char> = up.chars().collect();
+    let mut i = 0;
+    let mut total = 0usize;
+    while i < chars.len() {
+        let c = chars[i];
+        i += 1;
+        if c == 'S' || c == 'V' {
+            continue;
+        }
+        let mut count = 1usize;
+        if chars.get(i) == Some(&'(') {
+            i += 1;
+            let start = i;
+            while i < chars.len() && chars[i].is_ascii_digit() {
+                i += 1;
+            }
+            if let Ok(n) = chars[start..i].iter().collect::<String>().parse::<usize>() {
+                count = n;
+            }
+            if chars.get(i) == Some(&')') {
+                i += 1;
+            }
+        }
+        total += count;
+    }
+    total
+}
+
+/// Convierte un nombre COBOL en un identificador Rust válido.
+fn sanitize_ident(name: &str) -> String {
+    let mut s: String = name
+        .chars()
+        .map(|c| {
+            if c == '-' {
+                '_'
+            } else {
+                c.to_ascii_lowercase()
+            }
+        })
+        .filter(|c| c.is_ascii_alphanumeric() || *c == '_')
+        .collect();
+    if s.is_empty() || s.starts_with(|c: char| c.is_ascii_digit()) {
+        s = format!("f_{s}");
+    }
+    if is_rust_keyword(&s) {
+        s.push('_');
+    }
+    s
+}
+
+/// El nombre del método de un párrafo. El párrafo implícito ("") es
+/// `p_start`; el resto lleva el prefijo `p_`.
+pub(crate) fn paragraph_method(name: &str) -> String {
+    if name.is_empty() {
+        return "p_start".to_string();
+    }
+    let body: String = name
+        .chars()
+        .map(|c| {
+            if c == '-' {
+                '_'
+            } else {
+                c.to_ascii_lowercase()
+            }
+        })
+        .filter(|c| c.is_ascii_alphanumeric() || *c == '_')
+        .collect();
+    format!("p_{body}")
+}
+
+/// ¿Es `s` una palabra reservada de Rust? (Para no chocar al nombrar
+/// campos — un dato COBOL `MOVE` se volvería el keyword `move`.)
+fn is_rust_keyword(s: &str) -> bool {
+    matches!(
+        s,
+        "as" | "break"
+            | "const"
+            | "continue"
+            | "crate"
+            | "dyn"
+            | "else"
+            | "enum"
+            | "extern"
+            | "false"
+            | "fn"
+            | "for"
+            | "if"
+            | "impl"
+            | "in"
+            | "let"
+            | "loop"
+            | "match"
+            | "mod"
+            | "move"
+            | "mut"
+            | "pub"
+            | "ref"
+            | "return"
+            | "self"
+            | "static"
+            | "struct"
+            | "super"
+            | "trait"
+            | "true"
+            | "type"
+            | "unsafe"
+            | "use"
+            | "where"
+            | "while"
+            | "async"
+            | "await"
+            | "box"
+            | "yield"
+    )
+}
diff --git a/docs/changelog/charka.md b/docs/changelog/charka.md
index 9702cf9..457aacb 100644
--- a/docs/changelog/charka.md
+++ b/docs/changelog/charka.md
@@ -3,6 +3,33 @@
 Transpilador COBOL → Rust. El módulo más grande del ecosistema (Fase D
 del plan macro) — el parser COBOL completo es un esfuerzo multi-mes.
 
+### feat(charka-codegen): emisión de Rust desde el IR
+
+Crate nuevo `crates/modules/charka/charka-codegen` — la etapa final del
+pipeline. `generate(&Ir) -> String` produce un fuente Rust (un
+`main.rs`) que, compilado contra `charka-runtime`, ejecuta la lógica
+del programa COBOL.
+
+- Un `struct Program` con un campo por cada dato elemental — `Num`
+  para los numéricos, `Text` para los alfanuméricos; `Program::new()`
+  los inicializa desde sus cláusulas `VALUE`.
+- Un método `p_<párrafo>(&mut self)` por cada párrafo del PROCEDURE;
+  `run()` los encadena en orden (el «caer» de COBOL); `main()`
+  construye el `Program` y lo corre.
+- Cada `Stmt` → código Rust: `MOVE`→`.store`/`.fill`,
+  `DISPLAY`→`println!`, `COMPUTE` y la aritmética → expresiones
+  `Decimal`, `IF`→`if`/`else`, `PERFORM`→ llamada de método / `for` /
+  `while`, `GO TO`→ llamada + `return`, `STOP RUN`→`process::exit`.
+- Tolerante: lo no transpilable (`Stmt::Unknown`, un dato sin resolver,
+  el operador `**`) se emite como comentario `// charka:`; el código
+  generado siempre compila.
+- Saneado de identificadores COBOL→Rust (incl. choques con keywords).
+- Verificado de punta a punta: un programa COBOL de demostración
+  transpila a Rust que compila contra `charka-runtime` y produce la
+  salida esperada (`COMPUTE`, `IF`, `PERFORM`, `DISPLAY`).
+- 14 tests del fuente emitido; fuera de alcance v1: grupos como campo,
+  `REDEFINES`, `OCCURS`, `PERFORM ... THRU` como rango, E/S.
+
 ### feat(charka-runtime): soporte de ejecución — campos Num y Text
 
 Crate nuevo `crates/modules/charka/charka-runtime` — el soporte que los