gioser

tawasuyu/brahman

Rust 582TOML 222Markdown 73JSON 198635 Definiciones

feat(charka): charka-ir — representación intermedia con statements tipados

Tercera etapa del transpilador: Program -> Ir. El PROCEDURE division
pasa de sentencias con tokens crudos a un árbol de instrucciones
tipadas.

- lower(&Program) -> Ir: total y tolerante, nunca falla. La DATA
  division pasa tal cual y sirve de tabla de símbolos.
- Stmt cubre MOVE, DISPLAY, ACCEPT, COMPUTE, ADD, SUBTRACT, MULTIPLY,
  DIVIDE, IF/ELSE/END-IF, PERFORM (fuera de línea, en línea, TIMES,
  UNTIL), GO TO, STOP RUN, GOBACK, EXIT, CONTINUE.
- Expresiones de COMPUTE con precedencia y paréntesis (Pratt).
  Condiciones con comparadores símbolo/palabra, AND/OR/NOT y nombres
  de condición (nivel 88).
- Delimita statements por palabras frontera (COBOL no los separa con
  un símbolo). Verbo no soportado -> Stmt::Unknown con tokens crudos.
- Módulos: ast / kw / cursor / expr / stmt. 17 tests; fmt + clippy
  limpios.

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

71a4068d12e81d0be7b08d0717114dd5b10868fe · por sergio · (hace 5 meses) · padre b95383b01 · ver el árbol en este commit

Delta semántico · 93

crates/modules/charka/charka-ir/src/ast.rs

crates/modules/charka/charka-ir/src/cursor.rs

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

crates/modules/charka/charka-ir/src/kw.rs

crates/modules/charka/charka-ir/src/lib.rs

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

11 archivos · +1462 −4

diff --git a/Cargo.lock b/Cargo.lock
index 7a9fdaa..543eb95 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2315,6 +2315,14 @@ dependencies = [
  "thiserror 2.0.18",
 ]
 
+[[package]]
+name = "charka-ir"
+version = "0.1.0"
+dependencies = [
+ "charka-lexer",
+ "charka-parser",
+]
+
 [[package]]
 name = "charka-lexer"
 version = "0.1.0"
diff --git a/Cargo.toml b/Cargo.toml
index 9006621..4756036 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -159,6 +159,7 @@ members = [
     "crates/modules/charka/charka-bcd",
     "crates/modules/charka/charka-lexer",
     "crates/modules/charka/charka-parser",
+    "crates/modules/charka/charka-ir",
 
     # ============================================================
     # modules/mirada/ — Compositor Wayland
diff --git a/crates/modules/charka/SDD.md b/crates/modules/charka/SDD.md
index 722d541..8e8c52c 100644
--- a/crates/modules/charka/SDD.md
+++ b/crates/modules/charka/SDD.md
@@ -13,6 +13,7 @@ embebido, dialectos IBM Enterprise) es un esfuerzo multi-mes.
 | `charka-bcd`    | lib  | Aritmética decimal de punto fijo con semántica COBOL: `Picture`, `Decimal`, redondeo, `ON SIZE ERROR` |
 | `charka-lexer`  | lib  | Tokenizador COBOL: formato fijo (tarjeta de 80 columnas) y libre |
 | `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-bcd
 
@@ -64,15 +65,38 @@ Segunda etapa: tokens → AST. Alcance v1 — el **esqueleto del programa**.
 - Limitación v1: no parsea statements, ni la ENVIRONMENT division, ni
   CICS / SQL embebido / dialectos IBM.
 
+## charka-ir
+
+Tercera etapa: `Program` → `Ir`. Aquí se parsea cada `Sentence` cruda
+(tokens) a statements tipados — el PROCEDURE division pasa de tokens a
+árbol de instrucciones.
+
+- `lower(&Program) -> Ir` — **total y tolerante**, nunca falla.
+- `Ir { program_id, data: Vec<DataItem>, procedures: Vec<Procedure> }`.
+  El modelo de datos pasa tal cual (sirve de tabla de símbolos).
+- `Procedure { name, body: Vec<Stmt> }`. `Stmt` cubre `Move`,
+  `Display`, `Accept`, `Compute`, `Add`/`Subtract`/`Multiply`/`Divide`,
+  `If`, `Perform`, `GoTo`, `StopRun`, `Goback`, `Exit`, `Continue`.
+- `Expr` — expresiones aritméticas con precedencia y paréntesis (Pratt:
+  `+ -` < `* /` < `**` der.). `Cond` — comparaciones (símbolo o forma
+  palabra) unidas por `AND`/`OR`/`NOT`, más nombres de condición (88).
+- Un verbo no soportado se conserva como `Stmt::Unknown { verb,
+  tokens }` — el lowering jamás aborta.
+- COBOL no separa statements con un símbolo: cada uno corta donde
+  empieza el verbo del siguiente. El parser usa palabras "frontera"
+  (verbos + terminadores `END-*`/`ELSE` + conectores `TO`/`GIVING`...)
+  para delimitar listas de operandos.
+- Fuera de alcance v1: `EVALUATE`, `STRING`/`UNSTRING`, E/S de
+  ficheros, `PERFORM VARYING`, CICS, SQL embebido.
+
 ## Estado
 
-`charka-bcd` (22 tests), `charka-lexer` (17 tests) y `charka-parser`
-(15 tests) implementados y verdes. **Pendiente** — el resto del
-transpilador (Fase D del plan macro):
+`charka-bcd` (22 tests), `charka-lexer` (17 tests), `charka-parser`
+(15 tests) y `charka-ir` (17 tests) implementados y verdes.
+**Pendiente** — el resto del transpilador (Fase D del plan macro):
 
 | crate pendiente   | rol                                                  |
 | ----------------- | ---------------------------------------------------- |
-| `charka-ir`       | representación intermedia (parseo de statements)     |
 | `charka-codegen`  | emisión de Rust                                      |
 | `charka-shadow`   | validador en sombra (original vs transpilado)        |
 | `charka-runtime`  | runtime determinista (sobre `charka-bcd`)            |
diff --git a/crates/modules/charka/charka-ir/Cargo.toml b/crates/modules/charka/charka-ir/Cargo.toml
new file mode 100644
index 0000000..fabdade
--- /dev/null
+++ b/crates/modules/charka/charka-ir/Cargo.toml
@@ -0,0 +1,15 @@
+[package]
+name = "charka-ir"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "charka-ir — representación intermedia: el AST de charka-parser con los statements del PROCEDURE ya parseados a instrucciones tipadas (MOVE, IF, PERFORM, COMPUTE...)."
+
+[dependencies]
+charka-parser = { path = "../charka-parser" }
+
+[dev-dependencies]
+charka-lexer = { path = "../charka-lexer" }
diff --git a/crates/modules/charka/charka-ir/src/ast.rs b/crates/modules/charka/charka-ir/src/ast.rs
new file mode 100644
index 0000000..5ea48b0
--- /dev/null
+++ b/crates/modules/charka/charka-ir/src/ast.rs
@@ -0,0 +1,200 @@
+//! Los tipos del IR: el programa COBOL con su PROCEDURE division ya
+//! parseada a instrucciones tipadas.
+
+pub use charka_parser::{DataItem, Token};
+
+/// Un programa COBOL en representación intermedia.
+#[derive(Debug, Clone, PartialEq, Default)]
+pub struct Ir {
+    /// El `PROGRAM-ID` ("" si el programa no lo declara).
+    pub program_id: String,
+    /// El modelo de datos — el árbol de [`DataItem`] tal cual lo
+    /// produjo `charka-parser`. Sirve de tabla de símbolos.
+    pub data: Vec<DataItem>,
+    /// Los párrafos del PROCEDURE, con sus statements ya tipados.
+    pub procedures: Vec<Procedure>,
+}
+
+/// Un párrafo del PROCEDURE: un nombre y un cuerpo de statements.
+#[derive(Debug, Clone, PartialEq, Default)]
+pub struct Procedure {
+    /// Nombre del párrafo en mayúsculas; "" para el párrafo implícito.
+    pub name: String,
+    /// Los statements del párrafo, en orden.
+    pub body: Vec<Stmt>,
+}
+
+/// Un operando: lo que puede ir donde se espera un valor.
+#[derive(Debug, Clone, PartialEq)]
+pub enum Operand {
+    /// Referencia a un dato, por nombre (en mayúsculas).
+    Data(String),
+    /// Literal numérico (texto, posiblemente con signo).
+    Num(String),
+    /// Literal de texto.
+    Str(String),
+    /// Constante figurativa (`ZERO`, `SPACE`...).
+    Figurative(Figurative),
+}
+
+/// Las constantes figurativas de COBOL.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Figurative {
+    Zero,
+    Space,
+    HighValue,
+    LowValue,
+    Quote,
+    Null,
+}
+
+/// Una expresión aritmética (la parte derecha de un `COMPUTE`).
+#[derive(Debug, Clone, PartialEq)]
+pub enum Expr {
+    /// Un operando hoja.
+    Operand(Operand),
+    /// Negación unaria.
+    Neg(Box<Expr>),
+    /// Operación binaria.
+    Binary {
+        op: BinOp,
+        lhs: Box<Expr>,
+        rhs: Box<Expr>,
+    },
+}
+
+/// Operador aritmético binario.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum BinOp {
+    Add,
+    Sub,
+    Mul,
+    Div,
+    /// Exponenciación (`**`).
+    Pow,
+}
+
+/// Una condición (la guarda de un `IF` o de un `PERFORM UNTIL`).
+#[derive(Debug, Clone, PartialEq)]
+pub enum Cond {
+    /// Comparación relacional `lhs op rhs`.
+    Compare {
+        lhs: Operand,
+        op: CmpOp,
+        rhs: Operand,
+    },
+    /// Un nombre de condición (un dato de nivel 88) usado solo.
+    Named(String),
+    /// Negación lógica.
+    Not(Box<Cond>),
+    /// Conjunción lógica.
+    And(Box<Cond>, Box<Cond>),
+    /// Disyunción lógica.
+    Or(Box<Cond>, Box<Cond>),
+}
+
+/// Operador relacional.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum CmpOp {
+    Eq,
+    Ne,
+    Lt,
+    Gt,
+    Le,
+    Ge,
+}
+
+/// Un statement del PROCEDURE division.
+#[derive(Debug, Clone, PartialEq)]
+pub enum Stmt {
+    /// `MOVE from TO to...`
+    Move { from: Operand, to: Vec<String> },
+    /// `DISPLAY items...`
+    Display { items: Vec<Operand> },
+    /// `ACCEPT into`
+    Accept { into: String },
+    /// `COMPUTE targets... [ROUNDED] = expr`
+    Compute {
+        targets: Vec<String>,
+        rounded: bool,
+        expr: Expr,
+    },
+    /// `ADD addends... TO to... [GIVING giving...]`
+    Add {
+        addends: Vec<Operand>,
+        to: Vec<String>,
+        giving: Vec<String>,
+        rounded: bool,
+    },
+    /// `SUBTRACT amounts... FROM from... [GIVING giving...]`
+    Subtract {
+        amounts: Vec<Operand>,
+        from: Vec<String>,
+        giving: Vec<String>,
+        rounded: bool,
+    },
+    /// `MULTIPLY left BY by [GIVING giving...]`
+    Multiply {
+        left: Operand,
+        by: Operand,
+        giving: Vec<String>,
+        rounded: bool,
+    },
+    /// `DIVIDE left {BY|INTO} right [GIVING giving...]`. `by_form` es
+    /// true para `BY` (`left/right`), false para `INTO` (`right/left`).
+    Divide {
+        left: Operand,
+        right: Operand,
+        by_form: bool,
+        giving: Vec<String>,
+        rounded: bool,
+    },
+    /// `IF cond [THEN] then_branch [ELSE else_branch] [END-IF]`
+    If {
+        cond: Cond,
+        then_branch: Vec<Stmt>,
+        else_branch: Vec<Stmt>,
+    },
+    /// `PERFORM ...` — ver [`Perform`].
+    Perform(Perform),
+    /// `GO TO target`
+    GoTo { target: String },
+    /// `STOP RUN`
+    StopRun,
+    /// `GOBACK`
+    Goback,
+    /// `EXIT` (y sus variantes `EXIT PROGRAM`/`PARAGRAPH`...).
+    Exit,
+    /// `CONTINUE` — el no-op de COBOL.
+    Continue,
+    /// Un verbo que la v1 no parsea: se conserva crudo para que las
+    /// etapas siguientes (o un humano) lo revisen.
+    Unknown { verb: String, tokens: Vec<Token> },
+}
+
+/// Un statement `PERFORM`: a quién ejecuta y cuántas veces.
+#[derive(Debug, Clone, PartialEq)]
+pub struct Perform {
+    pub target: PerformTarget,
+    pub control: PerformControl,
+}
+
+/// El cuerpo que un `PERFORM` ejecuta.
+#[derive(Debug, Clone, PartialEq)]
+pub enum PerformTarget {
+    /// `PERFORM PARA [THRU PARA2]` — ejecuta uno o un rango de párrafos.
+    Paragraph { name: String, thru: Option<String> },
+    /// `PERFORM ... statements ... END-PERFORM` — cuerpo en línea.
+    Inline(Vec<Stmt>),
+}
+
+/// Cuántas veces se ejecuta el cuerpo de un `PERFORM`.
+#[derive(Debug, Clone, PartialEq)]
+pub enum PerformControl {
+    /// Una sola vez.
+    Once,
+    /// `n TIMES`.
+    Times(Operand),
+    /// `UNTIL cond`.
+    Until(Cond),
+}
diff --git a/crates/modules/charka/charka-ir/src/cursor.rs b/crates/modules/charka/charka-ir/src/cursor.rs
new file mode 100644
index 0000000..51ac5a2
--- /dev/null
+++ b/crates/modules/charka/charka-ir/src/cursor.rs
@@ -0,0 +1,139 @@
+//! `Cursor` — un cursor de avance sobre la lista de tokens de una
+//! sentencia, más las primitivas para leer un operando.
+
+use charka_parser::{Token, TokenKind};
+
+use crate::ast::{Figurative, Operand};
+
+/// Cursor sobre los tokens de una sentencia. `pos` es público dentro
+/// del crate para que el parser de condiciones pueda rebobinar.
+pub(crate) struct Cursor<'a> {
+    pub(crate) toks: &'a [Token],
+    pub(crate) pos: usize,
+}
+
+impl<'a> Cursor<'a> {
+    pub(crate) fn new(toks: &'a [Token]) -> Self {
+        Self { toks, pos: 0 }
+    }
+
+    /// ¿Se agotaron los tokens?
+    pub(crate) fn done(&self) -> bool {
+        self.pos >= self.toks.len()
+    }
+
+    /// El token actual, sin consumirlo.
+    pub(crate) fn peek(&self) -> Option<&Token> {
+        self.toks.get(self.pos)
+    }
+
+    /// El token `n` posiciones adelante, sin consumirlo.
+    pub(crate) fn peek_at(&self, n: usize) -> Option<&Token> {
+        self.toks.get(self.pos + n)
+    }
+
+    /// Consume y devuelve el token actual.
+    pub(crate) fn bump(&mut self) -> Option<Token> {
+        let t = self.toks.get(self.pos).cloned();
+        if t.is_some() {
+            self.pos += 1;
+        }
+        t
+    }
+
+    /// El token actual, si es una palabra, en mayúsculas.
+    pub(crate) fn peek_word(&self) -> Option<String> {
+        word_of(self.peek())
+    }
+
+    /// La palabra `n` posiciones adelante, en mayúsculas.
+    pub(crate) fn word_at(&self, n: usize) -> Option<String> {
+        word_of(self.peek_at(n))
+    }
+
+    /// ¿El token actual es la palabra `kw`?
+    pub(crate) fn at_word(&self, kw: &str) -> bool {
+        self.peek_word().as_deref() == Some(kw)
+    }
+
+    /// Consume el token actual si es la palabra `kw`.
+    pub(crate) fn eat_word(&mut self, kw: &str) -> bool {
+        if self.at_word(kw) {
+            self.pos += 1;
+            true
+        } else {
+            false
+        }
+    }
+
+    /// ¿El token actual es el símbolo `s`?
+    pub(crate) fn at_sym(&self, s: &str) -> bool {
+        matches!(self.peek(), Some(t) if t.kind == TokenKind::Symbol && t.text == s)
+    }
+
+    /// Consume el token actual si es el símbolo `s`.
+    pub(crate) fn eat_sym(&mut self, s: &str) -> bool {
+        if self.at_sym(s) {
+            self.pos += 1;
+            true
+        } else {
+            false
+        }
+    }
+}
+
+/// Si el token es una palabra, su texto en mayúsculas.
+fn word_of(t: Option<&Token>) -> Option<String> {
+    match t {
+        Some(t) if t.kind == TokenKind::Word => Some(t.text.to_uppercase()),
+        _ => None,
+    }
+}
+
+/// Lee un operando: un literal con signo opcional, o un token suelto.
+pub(crate) fn parse_operand(c: &mut Cursor) -> Operand {
+    // Signo delante de un literal numérico (`-5`, `+3`).
+    if (c.at_sym("-") || c.at_sym("+")) && c.peek_at(1).map(|t| t.kind) == Some(TokenKind::Number) {
+        let neg = c.at_sym("-");
+        c.bump();
+        let num = c.bump().expect("número tras el signo");
+        return Operand::Num(if neg {
+            format!("-{}", num.text)
+        } else {
+            num.text
+        });
+    }
+    match c.bump() {
+        Some(t) => token_to_operand(&t),
+        None => Operand::Num("0".into()),
+    }
+}
+
+/// Clasifica un token suelto como operando.
+pub(crate) fn token_to_operand(t: &Token) -> Operand {
+    match t.kind {
+        TokenKind::Number => Operand::Num(t.text.clone()),
+        TokenKind::String => Operand::Str(t.text.clone()),
+        TokenKind::Word => {
+            let u = t.text.to_uppercase();
+            match figurative(&u) {
+                Some(f) => Operand::Figurative(f),
+                None => Operand::Data(u),
+            }
+        }
+        TokenKind::Period | TokenKind::Symbol => Operand::Data(t.text.clone()),
+    }
+}
+
+/// Reconoce una constante figurativa por su nombre en mayúsculas.
+fn figurative(w: &str) -> Option<Figurative> {
+    Some(match w {
+        "ZERO" | "ZEROS" | "ZEROES" => Figurative::Zero,
+        "SPACE" | "SPACES" => Figurative::Space,
+        "HIGH-VALUE" | "HIGH-VALUES" => Figurative::HighValue,
+        "LOW-VALUE" | "LOW-VALUES" => Figurative::LowValue,
+        "QUOTE" | "QUOTES" => Figurative::Quote,
+        "NULL" | "NULLS" => Figurative::Null,
+        _ => return None,
+    })
+}
diff --git a/crates/modules/charka/charka-ir/src/expr.rs b/crates/modules/charka/charka-ir/src/expr.rs
new file mode 100644
index 0000000..477290a
--- /dev/null
+++ b/crates/modules/charka/charka-ir/src/expr.rs
@@ -0,0 +1,208 @@
+//! Parseo de expresiones aritméticas (`COMPUTE`) y de condiciones
+//! (`IF`, `PERFORM UNTIL`).
+
+use charka_parser::TokenKind;
+
+use crate::ast::{BinOp, CmpOp, Cond, Expr, Operand};
+use crate::cursor::{parse_operand, Cursor};
+use crate::kw::is_boundary;
+
+// ── Expresiones ───────────────────────────────────────────────────
+
+/// Parsea una expresión aritmética con precedencia y paréntesis.
+pub(crate) fn parse_expr(c: &mut Cursor) -> Expr {
+    parse_bin(c, 0)
+}
+
+/// Trepa por precedencia: `min_prec` es la mínima precedencia que este
+/// nivel acepta seguir consumiendo.
+fn parse_bin(c: &mut Cursor, min_prec: u8) -> Expr {
+    let mut lhs = parse_unary(c);
+    while let Some((op, prec, right_assoc)) = peek_binop(c) {
+        if prec < min_prec {
+            break;
+        }
+        c.bump();
+        let next_min = if right_assoc { prec } else { prec + 1 };
+        let rhs = parse_bin(c, next_min);
+        lhs = Expr::Binary {
+            op,
+            lhs: Box::new(lhs),
+            rhs: Box::new(rhs),
+        };
+    }
+    lhs
+}
+
+/// Negación o signo unario delante de un primario.
+fn parse_unary(c: &mut Cursor) -> Expr {
+    if c.eat_sym("-") {
+        return Expr::Neg(Box::new(parse_unary(c)));
+    }
+    if c.eat_sym("+") {
+        return parse_unary(c);
+    }
+    parse_primary(c)
+}
+
+/// Un primario: un paréntesis o un operando hoja.
+fn parse_primary(c: &mut Cursor) -> Expr {
+    if c.eat_sym("(") {
+        let e = parse_bin(c, 0);
+        c.eat_sym(")");
+        return e;
+    }
+    // No consumir un verbo o conector: la expresión terminó.
+    if c.done() || c.peek_word().map(|w| is_boundary(&w)).unwrap_or(false) {
+        return Expr::Operand(Operand::Num("0".into()));
+    }
+    Expr::Operand(parse_operand(c))
+}
+
+/// El operador binario en el token actual, con su precedencia y si es
+/// asociativo a derecha. `**` es la única potencia, asociativa a der.
+fn peek_binop(c: &Cursor) -> Option<(BinOp, u8, bool)> {
+    let t = c.peek()?;
+    if t.kind != TokenKind::Symbol {
+        return None;
+    }
+    match t.text.as_str() {
+        "+" => Some((BinOp::Add, 1, false)),
+        "-" => Some((BinOp::Sub, 1, false)),
+        "*" => Some((BinOp::Mul, 2, false)),
+        "/" => Some((BinOp::Div, 2, false)),
+        "**" => Some((BinOp::Pow, 3, true)),
+        _ => None,
+    }
+}
+
+// ── Condiciones ───────────────────────────────────────────────────
+
+/// Parsea una condición: comparaciones unidas por `AND`/`OR`/`NOT`.
+pub(crate) fn parse_cond(c: &mut Cursor) -> Cond {
+    parse_or(c)
+}
+
+fn parse_or(c: &mut Cursor) -> Cond {
+    let mut lhs = parse_and(c);
+    while c.eat_word("OR") {
+        let rhs = parse_and(c);
+        lhs = Cond::Or(Box::new(lhs), Box::new(rhs));
+    }
+    lhs
+}
+
+fn parse_and(c: &mut Cursor) -> Cond {
+    let mut lhs = parse_not(c);
+    while c.eat_word("AND") {
+        let rhs = parse_not(c);
+        lhs = Cond::And(Box::new(lhs), Box::new(rhs));
+    }
+    lhs
+}
+
+fn parse_not(c: &mut Cursor) -> Cond {
+    if c.eat_word("NOT") {
+        return Cond::Not(Box::new(parse_not(c)));
+    }
+    parse_cond_primary(c)
+}
+
+/// Un primario de condición: un paréntesis, una comparación, o un dato
+/// suelto (un nombre de condición de nivel 88).
+fn parse_cond_primary(c: &mut Cursor) -> Cond {
+    if c.eat_sym("(") {
+        let inner = parse_or(c);
+        c.eat_sym(")");
+        return inner;
+    }
+    if c.done() || c.peek_word().map(|w| is_boundary(&w)).unwrap_or(false) {
+        return Cond::Named(String::new());
+    }
+    let lhs = parse_operand(c);
+    match parse_cmp_op(c) {
+        Some(op) => {
+            let rhs = parse_operand(c);
+            Cond::Compare { lhs, op, rhs }
+        }
+        None => match lhs {
+            Operand::Data(n) => Cond::Named(n),
+            // Un literal solo como condición es raro; se degrada a "≠ 0".
+            other => Cond::Compare {
+                lhs: other,
+                op: CmpOp::Ne,
+                rhs: Operand::Num("0".into()),
+            },
+        },
+    }
+}
+
+/// Lee un operador relacional (forma símbolo o forma palabra). Si no
+/// hay ninguno, rebobina el cursor y devuelve `None`.
+fn parse_cmp_op(c: &mut Cursor) -> Option<CmpOp> {
+    let save = c.pos;
+    c.eat_word("IS");
+    let negated = c.eat_word("NOT");
+    if let Some(op) = cmp_core(c) {
+        return Some(if negated { negate(op) } else { op });
+    }
+    c.pos = save;
+    None
+}
+
+/// El núcleo del comparador, sin el `IS`/`NOT` opcionales.
+fn cmp_core(c: &mut Cursor) -> Option<CmpOp> {
+    if c.eat_sym("<>") {
+        return Some(CmpOp::Ne);
+    }
+    if c.eat_sym("<=") {
+        return Some(CmpOp::Le);
+    }
+    if c.eat_sym(">=") {
+        return Some(CmpOp::Ge);
+    }
+    if c.eat_sym("=") {
+        return Some(CmpOp::Eq);
+    }
+    if c.eat_sym("<") {
+        return Some(CmpOp::Lt);
+    }
+    if c.eat_sym(">") {
+        return Some(CmpOp::Gt);
+    }
+    if c.eat_word("EQUAL") || c.eat_word("EQUALS") {
+        c.eat_word("TO");
+        return Some(CmpOp::Eq);
+    }
+    if c.eat_word("GREATER") {
+        c.eat_word("THAN");
+        if c.eat_word("OR") {
+            c.eat_word("EQUAL");
+            c.eat_word("TO");
+            return Some(CmpOp::Ge);
+        }
+        return Some(CmpOp::Gt);
+    }
+    if c.eat_word("LESS") {
+        c.eat_word("THAN");
+        if c.eat_word("OR") {
+            c.eat_word("EQUAL");
+            c.eat_word("TO");
+            return Some(CmpOp::Le);
+        }
+        return Some(CmpOp::Lt);
+    }
+    None
+}
+
+/// El comparador opuesto — para resolver `NOT` delante de un relacional.
+fn negate(op: CmpOp) -> CmpOp {
+    match op {
+        CmpOp::Eq => CmpOp::Ne,
+        CmpOp::Ne => CmpOp::Eq,
+        CmpOp::Lt => CmpOp::Ge,
+        CmpOp::Ge => CmpOp::Lt,
+        CmpOp::Gt => CmpOp::Le,
+        CmpOp::Le => CmpOp::Gt,
+    }
+}
diff --git a/crates/modules/charka/charka-ir/src/kw.rs b/crates/modules/charka/charka-ir/src/kw.rs
new file mode 100644
index 0000000..b0542ca
--- /dev/null
+++ b/crates/modules/charka/charka-ir/src/kw.rs
@@ -0,0 +1,101 @@
+//! Predicados sobre las palabras clave de COBOL. COBOL no termina los
+//! statements con un símbolo: una sentencia es una secuencia de
+//! statements que se delimitan por la aparición del siguiente verbo.
+//! Estos predicados le dicen al parser dónde corta un statement.
+
+/// ¿Es `w` un verbo que inicia un statement? (En mayúsculas.)
+pub(crate) fn is_verb(w: &str) -> bool {
+    matches!(
+        w,
+        "ACCEPT"
+            | "ADD"
+            | "ALTER"
+            | "CALL"
+            | "CANCEL"
+            | "CLOSE"
+            | "COMPUTE"
+            | "CONTINUE"
+            | "DELETE"
+            | "DISPLAY"
+            | "DIVIDE"
+            | "EVALUATE"
+            | "EXIT"
+            | "GO"
+            | "GOBACK"
+            | "IF"
+            | "INITIALIZE"
+            | "INSPECT"
+            | "MERGE"
+            | "MOVE"
+            | "MULTIPLY"
+            | "OPEN"
+            | "PERFORM"
+            | "READ"
+            | "RELEASE"
+            | "RETURN"
+            | "REWRITE"
+            | "SEARCH"
+            | "SET"
+            | "SORT"
+            | "START"
+            | "STOP"
+            | "STRING"
+            | "SUBTRACT"
+            | "UNSTRING"
+            | "WRITE"
+    )
+}
+
+/// ¿Es `w` un terminador de ámbito (`END-IF`, `ELSE`, `WHEN`...)?
+pub(crate) fn is_terminator(w: &str) -> bool {
+    matches!(
+        w,
+        "ELSE"
+            | "WHEN"
+            | "END-IF"
+            | "END-PERFORM"
+            | "END-EVALUATE"
+            | "END-ADD"
+            | "END-SUBTRACT"
+            | "END-MULTIPLY"
+            | "END-DIVIDE"
+            | "END-COMPUTE"
+            | "END-READ"
+            | "END-WRITE"
+            | "END-CALL"
+            | "END-STRING"
+            | "END-UNSTRING"
+            | "END-SEARCH"
+            | "END-START"
+            | "END-DELETE"
+            | "END-REWRITE"
+            | "END-RETURN"
+    )
+}
+
+/// ¿Es `w` una palabra de conexión de una cláusula (`TO`, `GIVING`...)?
+fn is_connector(w: &str) -> bool {
+    matches!(
+        w,
+        "TO" | "FROM"
+            | "GIVING"
+            | "BY"
+            | "INTO"
+            | "THRU"
+            | "THROUGH"
+            | "UNTIL"
+            | "TIMES"
+            | "ROUNDED"
+            | "THEN"
+            | "WITH"
+            | "UPON"
+            | "REMAINDER"
+            | "VARYING"
+    )
+}
+
+/// ¿Marca `w` el final de una lista de operandos o de nombres? Es así
+/// para todo verbo, terminador o conector — ninguno puede ser un dato.
+pub(crate) fn is_boundary(w: &str) -> bool {
+    is_verb(w) || is_terminator(w) || is_connector(w)
+}
diff --git a/crates/modules/charka/charka-ir/src/lib.rs b/crates/modules/charka/charka-ir/src/lib.rs
new file mode 100644
index 0000000..9bccad4
--- /dev/null
+++ b/crates/modules/charka/charka-ir/src/lib.rs
@@ -0,0 +1,329 @@
+//! `charka-ir` — la representación intermedia del transpilador.
+//!
+//! Tercera etapa del pipeline COBOL→Rust: toma el [`Program`] de
+//! `charka-parser` (cuyo PROCEDURE division es una lista de sentencias
+//! con tokens crudos) y produce un [`Ir`] donde cada sentencia ya es un
+//! árbol de [`Stmt`] tipados: `MOVE`, `IF`, `PERFORM`, `COMPUTE`, los
+//! verbos aritméticos, etc.
+//!
+//! El modelo de datos (`DATA division`) pasa tal cual — el árbol de
+//! [`DataItem`] que ya armó el parser sirve de tabla de símbolos.
+//!
+//! El lowering es **tolerante y total**: nunca falla. Un verbo que la
+//! v1 no parsea se conserva como [`Stmt::Unknown`] con sus tokens
+//! crudos, listo para que el codegen (o un humano) lo revise.
+//!
+//! Alcance v1 — los verbos parseados a fondo: `MOVE`, `DISPLAY`,
+//! `ACCEPT`, `COMPUTE` (con expresiones con precedencia), `ADD`,
+//! `SUBTRACT`, `MULTIPLY`, `DIVIDE`, `IF`/`ELSE`/`END-IF` (con
+//! condiciones `AND`/`OR`/`NOT`), `PERFORM` (fuera de línea, en línea,
+//! `TIMES`, `UNTIL`), `GO TO`, `STOP RUN`, `GOBACK`, `EXIT`,
+//! `CONTINUE`. Fuera de alcance: `EVALUATE`, `STRING`/`UNSTRING`, E/S
+//! de ficheros, `PERFORM VARYING`, CICS y SQL embebido.
+
+#![forbid(unsafe_code)]
+
+mod ast;
+mod cursor;
+mod expr;
+mod kw;
+mod stmt;
+
+pub use ast::*;
+pub use charka_parser::Program;
+
+use cursor::Cursor;
+
+/// Baja un [`Program`] parseado a la representación intermedia.
+pub fn lower(program: &Program) -> Ir {
+    let procedures = program
+        .paragraphs
+        .iter()
+        .map(|p| {
+            let mut body = Vec::new();
+            for sentence in &p.sentences {
+                let mut cur = Cursor::new(&sentence.tokens);
+                body.extend(stmt::parse_statements(&mut cur, &[]));
+            }
+            Procedure {
+                name: p.name.clone(),
+                body,
+            }
+        })
+        .collect();
+
+    Ir {
+        program_id: program.program_id.clone().unwrap_or_default(),
+        data: program.data.clone(),
+        procedures,
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use charka_lexer::{lex, SourceFormat};
+
+    /// Helper: lexa, parsea y baja a IR un fuente en formato libre.
+    fn ir(src: &str) -> Ir {
+        let toks = lex(src, SourceFormat::Free).expect("lex OK");
+        let program = charka_parser::parse(&toks).expect("parse OK");
+        lower(&program)
+    }
+
+    /// Helper: el cuerpo del primer (y normalmente único) párrafo.
+    fn body(src: &str) -> Vec<Stmt> {
+        let prog = format!("PROCEDURE DIVISION.\nMAIN.\n{src}\n");
+        ir(&prog).procedures.into_iter().next().unwrap().body
+    }
+
+    #[test]
+    fn empty_program_lowers_to_default() {
+        let got = lower(&charka_parser::parse(&[]).unwrap());
+        assert_eq!(got, Ir::default());
+    }
+
+    #[test]
+    fn move_simple() {
+        let b = body("MOVE 5 TO WS-X.");
+        assert_eq!(
+            b,
+            vec![Stmt::Move {
+                from: Operand::Num("5".into()),
+                to: vec!["WS-X".into()],
+            }]
+        );
+    }
+
+    #[test]
+    fn move_to_several_targets() {
+        let b = body("MOVE WS-A TO WS-B WS-C.");
+        assert_eq!(
+            b,
+            vec![Stmt::Move {
+                from: Operand::Data("WS-A".into()),
+                to: vec!["WS-B".into(), "WS-C".into()],
+            }]
+        );
+    }
+
+    #[test]
+    fn display_items_and_figurative() {
+        let b = body("DISPLAY 'TOTAL: ' WS-TOTAL SPACES.");
+        assert_eq!(
+            b,
+            vec![Stmt::Display {
+                items: vec![
+                    Operand::Str("TOTAL: ".into()),
+                    Operand::Data("WS-TOTAL".into()),
+                    Operand::Figurative(Figurative::Space),
+                ],
+            }]
+        );
+    }
+
+    #[test]
+    fn compute_respects_precedence() {
+        let b = body("COMPUTE WS-T = WS-A + WS-B * 2.");
+        let expr = match &b[0] {
+            Stmt::Compute { targets, expr, .. } => {
+                assert_eq!(targets, &vec!["WS-T".to_string()]);
+                expr.clone()
+            }
+            other => panic!("se esperaba COMPUTE, vino {other:?}"),
+        };
+        // WS-A + (WS-B * 2)
+        assert_eq!(
+            expr,
+            Expr::Binary {
+                op: BinOp::Add,
+                lhs: Box::new(Expr::Operand(Operand::Data("WS-A".into()))),
+                rhs: Box::new(Expr::Binary {
+                    op: BinOp::Mul,
+                    lhs: Box::new(Expr::Operand(Operand::Data("WS-B".into()))),
+                    rhs: Box::new(Expr::Operand(Operand::Num("2".into()))),
+                }),
+            }
+        );
+    }
+
+    #[test]
+    fn compute_rounded_flag() {
+        let b = body("COMPUTE WS-T ROUNDED = WS-A / 3.");
+        assert!(matches!(&b[0], Stmt::Compute { rounded: true, .. }));
+    }
+
+    #[test]
+    fn add_in_place_and_giving() {
+        assert_eq!(
+            body("ADD 1 TO WS-CT."),
+            vec![Stmt::Add {
+                addends: vec![Operand::Num("1".into())],
+                to: vec!["WS-CT".into()],
+                giving: vec![],
+                rounded: false,
+            }]
+        );
+        assert_eq!(
+            body("ADD WS-A WS-B GIVING WS-C."),
+            vec![Stmt::Add {
+                addends: vec![Operand::Data("WS-A".into()), Operand::Data("WS-B".into()),],
+                to: vec![],
+                giving: vec!["WS-C".into()],
+                rounded: false,
+            }]
+        );
+    }
+
+    #[test]
+    fn subtract_from_giving() {
+        assert_eq!(
+            body("SUBTRACT WS-TAX FROM WS-GROSS GIVING WS-NET."),
+            vec![Stmt::Subtract {
+                amounts: vec![Operand::Data("WS-TAX".into())],
+                from: vec!["WS-GROSS".into()],
+                giving: vec!["WS-NET".into()],
+                rounded: false,
+            }]
+        );
+    }
+
+    #[test]
+    fn divide_by_and_into() {
+        assert!(matches!(
+            &body("DIVIDE WS-A BY WS-B GIVING WS-C.")[0],
+            Stmt::Divide { by_form: true, .. }
+        ));
+        assert!(matches!(
+            &body("DIVIDE WS-A INTO WS-B.")[0],
+            Stmt::Divide { by_form: false, .. }
+        ));
+    }
+
+    #[test]
+    fn if_else_end_if() {
+        let b = body("IF WS-X > 0 DISPLAY 'POS' ELSE DISPLAY 'NEG' END-IF.");
+        match &b[0] {
+            Stmt::If {
+                cond,
+                then_branch,
+                else_branch,
+            } => {
+                assert_eq!(
+                    cond,
+                    &Cond::Compare {
+                        lhs: Operand::Data("WS-X".into()),
+                        op: CmpOp::Gt,
+                        rhs: Operand::Num("0".into()),
+                    }
+                );
+                assert_eq!(then_branch.len(), 1);
+                assert_eq!(else_branch.len(), 1);
+            }
+            other => panic!("se esperaba IF, vino {other:?}"),
+        }
+    }
+
+    #[test]
+    fn if_condition_with_and() {
+        let b = body("IF A = 1 AND B = 2 CONTINUE END-IF.");
+        match &b[0] {
+            Stmt::If { cond, .. } => {
+                assert!(matches!(cond, Cond::And(_, _)));
+            }
+            other => panic!("se esperaba IF, vino {other:?}"),
+        }
+    }
+
+    #[test]
+    fn if_named_condition() {
+        // Un dato suelto en la condición es un nombre de condición (88).
+        let b = body("IF FLAG-IS-OK MOVE 1 TO X END-IF.");
+        match &b[0] {
+            Stmt::If { cond, .. } => {
+                assert_eq!(cond, &Cond::Named("FLAG-IS-OK".into()));
+            }
+            other => panic!("se esperaba IF, vino {other:?}"),
+        }
+    }
+
+    #[test]
+    fn perform_paragraph_and_times() {
+        assert_eq!(
+            body("PERFORM SUB-PARA."),
+            vec![Stmt::Perform(Perform {
+                target: PerformTarget::Paragraph {
+                    name: "SUB-PARA".into(),
+                    thru: None,
+                },
+                control: PerformControl::Once,
+            })]
+        );
+        assert_eq!(
+            body("PERFORM SUB-PARA 3 TIMES."),
+            vec![Stmt::Perform(Perform {
+                target: PerformTarget::Paragraph {
+                    name: "SUB-PARA".into(),
+                    thru: None,
+                },
+                control: PerformControl::Times(Operand::Num("3".into())),
+            })]
+        );
+    }
+
+    #[test]
+    fn perform_inline_until() {
+        let b = body("PERFORM UNTIL WS-DONE = 1 ADD 1 TO WS-CT END-PERFORM.");
+        match &b[0] {
+            Stmt::Perform(p) => {
+                assert!(matches!(p.control, PerformControl::Until(_)));
+                match &p.target {
+                    PerformTarget::Inline(body) => assert_eq!(body.len(), 1),
+                    other => panic!("se esperaba cuerpo en línea, vino {other:?}"),
+                }
+            }
+            other => panic!("se esperaba PERFORM, vino {other:?}"),
+        }
+    }
+
+    #[test]
+    fn several_statements_in_one_sentence() {
+        let b = body("MOVE 1 TO X DISPLAY X STOP RUN.");
+        assert_eq!(b.len(), 3);
+        assert!(matches!(b[0], Stmt::Move { .. }));
+        assert!(matches!(b[1], Stmt::Display { .. }));
+        assert_eq!(b[2], Stmt::StopRun);
+    }
+
+    #[test]
+    fn unrecognized_verb_becomes_unknown() {
+        let b = body("INSPECT WS-X TALLYING WS-N FOR ALL ' '.");
+        match &b[0] {
+            Stmt::Unknown { verb, tokens } => {
+                assert_eq!(verb, "INSPECT");
+                assert!(!tokens.is_empty());
+            }
+            other => panic!("se esperaba Unknown, vino {other:?}"),
+        }
+    }
+
+    #[test]
+    fn full_program_lowers() {
+        let program = ir("IDENTIFICATION DIVISION.\n\
+             PROGRAM-ID. ADDER.\n\
+             DATA DIVISION.\n\
+             WORKING-STORAGE SECTION.\n\
+             01 WS-A PIC 9(3) VALUE 10.\n\
+             01 WS-T PIC 9(4).\n\
+             PROCEDURE DIVISION.\n\
+             MAIN-PARA.\n\
+                 COMPUTE WS-T = WS-A + 5.\n\
+                 DISPLAY WS-T.\n\
+                 STOP RUN.\n");
+        assert_eq!(program.program_id, "ADDER");
+        assert_eq!(program.data.len(), 2);
+        assert_eq!(program.procedures.len(), 1);
+        assert_eq!(program.procedures[0].name, "MAIN-PARA");
+        assert_eq!(program.procedures[0].body.len(), 3);
+    }
+}
diff --git a/crates/modules/charka/charka-ir/src/stmt.rs b/crates/modules/charka/charka-ir/src/stmt.rs
new file mode 100644
index 0000000..4dff640
--- /dev/null
+++ b/crates/modules/charka/charka-ir/src/stmt.rs
@@ -0,0 +1,402 @@
+//! Parseo de los statements del PROCEDURE division. COBOL no separa
+//! statements con un símbolo: cada uno termina donde empieza el verbo
+//! del siguiente, por eso las listas de operandos se cortan al ver una
+//! palabra "frontera" (ver [`crate::kw`]).
+
+use charka_parser::TokenKind;
+
+use crate::ast::{Operand, Perform, PerformControl, PerformTarget, Stmt};
+use crate::cursor::{parse_operand, Cursor};
+use crate::expr::{parse_cond, parse_expr};
+use crate::kw::{is_boundary, is_terminator, is_verb};
+
+/// Parsea statements hasta agotar los tokens o toparse con una palabra
+/// de `stops` (los terminadores del bloque que llama).
+pub(crate) fn parse_statements(c: &mut Cursor, stops: &[&str]) -> Vec<Stmt> {
+    let mut out = Vec::new();
+    while !c.done() {
+        if let Some(w) = c.peek_word() {
+            if stops.contains(&w.as_str()) {
+                break;
+            }
+        }
+        out.push(parse_one_stmt(c, stops));
+    }
+    out
+}
+
+/// Parsea un statement: despacha por el verbo. Todo parser consume al
+/// menos un token, así que el bucle de [`parse_statements`] progresa.
+fn parse_one_stmt(c: &mut Cursor, stops: &[&str]) -> Stmt {
+    match c.peek_word().unwrap_or_default().as_str() {
+        "MOVE" => parse_move(c),
+        "DISPLAY" => parse_display(c),
+        "ACCEPT" => parse_accept(c),
+        "COMPUTE" => parse_compute(c),
+        "ADD" => parse_add(c),
+        "SUBTRACT" => parse_subtract(c),
+        "MULTIPLY" => parse_multiply(c),
+        "DIVIDE" => parse_divide(c),
+        "IF" => parse_if(c),
+        "PERFORM" => parse_perform(c),
+        "GO" => parse_goto(c),
+        "STOP" => parse_stop(c),
+        "GOBACK" => {
+            c.bump();
+            Stmt::Goback
+        }
+        "EXIT" => parse_exit(c),
+        "CONTINUE" => {
+            c.bump();
+            Stmt::Continue
+        }
+        _ => parse_unknown(c, stops),
+    }
+}
+
+// ── Listas ────────────────────────────────────────────────────────
+
+/// Lee una lista de nombres de dato (separados por comas opcionales),
+/// hasta una palabra frontera. Consume las apariciones de `ROUNDED`.
+fn parse_name_list(c: &mut Cursor, rounded: &mut bool) -> Vec<String> {
+    let mut names = Vec::new();
+    loop {
+        c.eat_sym(",");
+        if c.eat_word("ROUNDED") {
+            *rounded = true;
+            continue;
+        }
+        match c.peek_word() {
+            Some(w) if !is_boundary(&w) => {
+                c.bump();
+                names.push(w);
+            }
+            _ => break,
+        }
+    }
+    names
+}
+
+/// Lee una lista de operandos hasta una palabra frontera.
+fn parse_operand_list(c: &mut Cursor) -> Vec<Operand> {
+    let mut ops = Vec::new();
+    loop {
+        c.eat_sym(",");
+        if c.done() {
+            break;
+        }
+        if let Some(w) = c.peek_word() {
+            if is_boundary(&w) {
+                break;
+            }
+        }
+        let is_start = matches!(
+            c.peek().map(|t| t.kind),
+            Some(TokenKind::Number | TokenKind::String | TokenKind::Word)
+        ) || c.at_sym("-")
+            || c.at_sym("+");
+        if !is_start {
+            break;
+        }
+        ops.push(parse_operand(c));
+    }
+    ops
+}
+
+/// Salta los tokens de una cláusula que la v1 no modela, hasta el
+/// siguiente verbo o terminador de ámbito.
+fn skip_to_stmt_boundary(c: &mut Cursor) {
+    while !c.done() {
+        if let Some(w) = c.peek_word() {
+            if is_verb(&w) || is_terminator(&w) {
+                break;
+            }
+        }
+        c.bump();
+    }
+}
+
+/// Lee un único nombre de dato, si lo hay y no es una palabra frontera.
+fn parse_one_name(c: &mut Cursor) -> Option<String> {
+    match c.peek_word() {
+        Some(w) if !is_boundary(&w) => {
+            c.bump();
+            Some(w)
+        }
+        _ => None,
+    }
+}
+
+// ── Statements ────────────────────────────────────────────────────
+
+fn parse_move(c: &mut Cursor) -> Stmt {
+    c.bump(); // MOVE
+    c.eat_word("CORRESPONDING");
+    c.eat_word("CORR");
+    let from = parse_operand(c);
+    c.eat_word("TO");
+    let mut rounded = false;
+    let to = parse_name_list(c, &mut rounded);
+    Stmt::Move { from, to }
+}
+
+fn parse_display(c: &mut Cursor) -> Stmt {
+    c.bump(); // DISPLAY
+    let items = parse_operand_list(c);
+    skip_to_stmt_boundary(c); // p. ej. `WITH NO ADVANCING`, `UPON ...`
+    Stmt::Display { items }
+}
+
+fn parse_accept(c: &mut Cursor) -> Stmt {
+    c.bump(); // ACCEPT
+    let into = parse_one_name(c).unwrap_or_default();
+    skip_to_stmt_boundary(c); // p. ej. `FROM DATE`
+    Stmt::Accept { into }
+}
+
+fn parse_compute(c: &mut Cursor) -> Stmt {
+    c.bump(); // COMPUTE
+    let mut rounded = false;
+    let targets = parse_name_list(c, &mut rounded);
+    if !c.eat_sym("=") {
+        c.eat_word("EQUAL");
+    }
+    let expr = parse_expr(c);
+    c.eat_word("END-COMPUTE");
+    Stmt::Compute {
+        targets,
+        rounded,
+        expr,
+    }
+}
+
+fn parse_add(c: &mut Cursor) -> Stmt {
+    c.bump(); // ADD
+    c.eat_word("CORRESPONDING");
+    c.eat_word("CORR");
+    let addends = parse_operand_list(c);
+    let mut rounded = false;
+    let mut to = Vec::new();
+    let mut giving = Vec::new();
+    if c.eat_word("TO") {
+        to = parse_name_list(c, &mut rounded);
+    }
+    if c.eat_word("GIVING") {
+        giving = parse_name_list(c, &mut rounded);
+    }
+    c.eat_word("END-ADD");
+    Stmt::Add {
+        addends,
+        to,
+        giving,
+        rounded,
+    }
+}
+
+fn parse_subtract(c: &mut Cursor) -> Stmt {
+    c.bump(); // SUBTRACT
+    c.eat_word("CORRESPONDING");
+    c.eat_word("CORR");
+    let amounts = parse_operand_list(c);
+    let mut rounded = false;
+    let mut from = Vec::new();
+    let mut giving = Vec::new();
+    if c.eat_word("FROM") {
+        from = parse_name_list(c, &mut rounded);
+    }
+    if c.eat_word("GIVING") {
+        giving = parse_name_list(c, &mut rounded);
+    }
+    c.eat_word("END-SUBTRACT");
+    Stmt::Subtract {
+        amounts,
+        from,
+        giving,
+        rounded,
+    }
+}
+
+fn parse_multiply(c: &mut Cursor) -> Stmt {
+    c.bump(); // MULTIPLY
+    let left = parse_operand(c);
+    c.eat_word("BY");
+    let by = parse_operand(c);
+    let mut rounded = false;
+    let mut giving = Vec::new();
+    if c.eat_word("GIVING") {
+        giving = parse_name_list(c, &mut rounded);
+    } else if c.eat_word("ROUNDED") {
+        rounded = true;
+    }
+    c.eat_word("END-MULTIPLY");
+    Stmt::Multiply {
+        left,
+        by,
+        giving,
+        rounded,
+    }
+}
+
+fn parse_divide(c: &mut Cursor) -> Stmt {
+    c.bump(); // DIVIDE
+    let left = parse_operand(c);
+    let by_form = if c.eat_word("BY") {
+        true
+    } else {
+        c.eat_word("INTO");
+        false
+    };
+    let right = parse_operand(c);
+    let mut rounded = false;
+    let mut giving = Vec::new();
+    if c.eat_word("GIVING") {
+        giving = parse_name_list(c, &mut rounded);
+    } else if c.eat_word("ROUNDED") {
+        rounded = true;
+    }
+    if c.eat_word("REMAINDER") {
+        let _ = parse_name_list(c, &mut rounded);
+    }
+    c.eat_word("END-DIVIDE");
+    Stmt::Divide {
+        left,
+        right,
+        by_form,
+        giving,
+        rounded,
+    }
+}
+
+fn parse_if(c: &mut Cursor) -> Stmt {
+    c.bump(); // IF
+    let cond = parse_cond(c);
+    c.eat_word("THEN");
+    let then_branch = parse_statements(c, &["ELSE", "END-IF"]);
+    let else_branch = if c.eat_word("ELSE") {
+        parse_statements(c, &["END-IF"])
+    } else {
+        Vec::new()
+    };
+    c.eat_word("END-IF");
+    Stmt::If {
+        cond,
+        then_branch,
+        else_branch,
+    }
+}
+
+fn parse_perform(c: &mut Cursor) -> Stmt {
+    c.bump(); // PERFORM
+
+    // `PERFORM UNTIL cond ... END-PERFORM` — cuerpo en línea.
+    if c.eat_word("UNTIL") {
+        let cond = parse_cond(c);
+        let body = parse_statements(c, &["END-PERFORM"]);
+        c.eat_word("END-PERFORM");
+        return inline_perform(body, PerformControl::Until(cond));
+    }
+
+    // `PERFORM n TIMES ... END-PERFORM` — cuerpo en línea.
+    if matches!(c.peek().map(|t| t.kind), Some(TokenKind::Number)) {
+        let n = parse_operand(c);
+        c.eat_word("TIMES");
+        let body = parse_statements(c, &["END-PERFORM"]);
+        c.eat_word("END-PERFORM");
+        return inline_perform(body, PerformControl::Times(n));
+    }
+
+    // `PERFORM ... END-PERFORM` — cuerpo en línea, una vez.
+    if c.peek_word().map(|w| is_verb(&w)).unwrap_or(false) {
+        let body = parse_statements(c, &["END-PERFORM"]);
+        c.eat_word("END-PERFORM");
+        return inline_perform(body, PerformControl::Once);
+    }
+
+    // `PERFORM PARA [THRU PARA2] [n TIMES | UNTIL cond]` — fuera de línea.
+    let Some(name) = parse_one_name(c) else {
+        // Forma no soportada (p. ej. `PERFORM VARYING`): perform vacío.
+        return inline_perform(Vec::new(), PerformControl::Once);
+    };
+    let thru = if c.eat_word("THRU") || c.eat_word("THROUGH") {
+        parse_one_name(c)
+    } else {
+        None
+    };
+    let control = if c.eat_word("UNTIL") {
+        PerformControl::Until(parse_cond(c))
+    } else if at_count(c) {
+        let n = parse_operand(c);
+        c.eat_word("TIMES");
+        PerformControl::Times(n)
+    } else {
+        PerformControl::Once
+    };
+    Stmt::Perform(Perform {
+        target: PerformTarget::Paragraph { name, thru },
+        control,
+    })
+}
+
+/// Arma un `PERFORM` con cuerpo en línea.
+fn inline_perform(body: Vec<Stmt>, control: PerformControl) -> Stmt {
+    Stmt::Perform(Perform {
+        target: PerformTarget::Inline(body),
+        control,
+    })
+}
+
+/// ¿El cursor está sobre `<operando> TIMES`?
+fn at_count(c: &Cursor) -> bool {
+    match c.peek().map(|t| t.kind) {
+        Some(TokenKind::Number) => true,
+        Some(TokenKind::Word) => {
+            let w = c.peek_word().unwrap_or_default();
+            !is_boundary(&w) && c.word_at(1).as_deref() == Some("TIMES")
+        }
+        _ => false,
+    }
+}
+
+fn parse_goto(c: &mut Cursor) -> Stmt {
+    c.bump(); // GO
+    c.eat_word("TO");
+    Stmt::GoTo {
+        target: parse_one_name(c).unwrap_or_default(),
+    }
+}
+
+fn parse_stop(c: &mut Cursor) -> Stmt {
+    c.bump(); // STOP
+    c.eat_word("RUN");
+    Stmt::StopRun
+}
+
+fn parse_exit(c: &mut Cursor) -> Stmt {
+    c.bump(); // EXIT
+    c.eat_word("PROGRAM");
+    c.eat_word("PARAGRAPH");
+    c.eat_word("PERFORM");
+    c.eat_word("SECTION");
+    Stmt::Exit
+}
+
+/// Verbo no soportado: conserva el verbo y sus tokens hasta el próximo
+/// statement (otro verbo), terminador de ámbito o tope del bloque.
+fn parse_unknown(c: &mut Cursor, stops: &[&str]) -> Stmt {
+    let verb = c.peek_word().unwrap_or_default();
+    let mut tokens = Vec::new();
+    if let Some(t) = c.bump() {
+        tokens.push(t);
+    }
+    while !c.done() {
+        if let Some(w) = c.peek_word() {
+            if stops.contains(&w.as_str()) || is_verb(&w) || is_terminator(&w) {
+                break;
+            }
+        }
+        if let Some(t) = c.bump() {
+            tokens.push(t);
+        }
+    }
+    Stmt::Unknown { verb, tokens }
+}
diff --git a/docs/changelog/charka.md b/docs/changelog/charka.md
index 7de7539..4c6d898 100644
--- a/docs/changelog/charka.md
+++ b/docs/changelog/charka.md
@@ -3,6 +3,37 @@
 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-ir): representación intermedia — statements tipados
+
+Crate nuevo `crates/modules/charka/charka-ir` — la tercera etapa del
+pipeline: `Program` → `Ir`. El PROCEDURE division pasa de sentencias
+con tokens crudos a un árbol de instrucciones tipadas.
+
+- `lower(&Program) -> Ir` — total y tolerante: nunca falla.
+- `Ir { program_id, data, procedures }`. El modelo de datos (la DATA
+  division) pasa tal cual — sirve de tabla de símbolos.
+- `Stmt` cubre `MOVE`, `DISPLAY`, `ACCEPT`, `COMPUTE`, `ADD`,
+  `SUBTRACT`, `MULTIPLY`, `DIVIDE`, `IF`/`ELSE`/`END-IF`, `PERFORM`
+  (fuera de línea, en línea, `TIMES`, `UNTIL`), `GO TO`, `STOP RUN`,
+  `GOBACK`, `EXIT`, `CONTINUE`.
+- Expresiones de `COMPUTE` con precedencia y paréntesis (`+ -` <
+  `* /` < `**` asociativo a derecha). Condiciones de `IF`/`UNTIL` con
+  comparadores en forma símbolo (`= < > <= >= <>`) o palabra
+  (`EQUAL TO`, `GREATER THAN`...), `AND`/`OR`/`NOT` y nombres de
+  condición (datos de nivel 88).
+- COBOL no termina los statements con un símbolo: el parser delimita
+  las listas de operandos con palabras "frontera" (verbos,
+  terminadores `END-*`/`ELSE`, conectores `TO`/`GIVING`/`BY`...).
+- Un verbo no soportado se conserva como `Stmt::Unknown { verb,
+  tokens }` — el lowering nunca aborta.
+- Fuera de alcance v1: `EVALUATE`, `STRING`/`UNSTRING`, E/S de
+  ficheros, `PERFORM VARYING`, CICS, SQL embebido.
+- 17 tests: MOVE simple y multi-destino, DISPLAY con figurativas,
+  precedencia de COMPUTE, flag ROUNDED, ADD in-place vs GIVING,
+  SUBTRACT, DIVIDE BY/INTO, IF/ELSE, condiciones con AND, nombre de
+  condición, PERFORM párrafo/TIMES/UNTIL en línea, varios statements
+  en una sentencia, verbo desconocido, programa completo.
+
 ### feat(charka-parser): parser COBOL'85 → AST
 
 Crate nuevo `crates/modules/charka/charka-parser` — la segunda etapa del