diff --git a/docs/plans/emit-ir-staticlib.md b/docs/plans/emit-ir-staticlib.md new file mode 100644 index 0000000..b40f262 --- /dev/null +++ b/docs/plans/emit-ir-staticlib.md @@ -0,0 +1,471 @@ +# emit-ir --emit=staticlib — Implementation Plan + +> **Parent spec:** `docs/specs/2026-05-18-fieldtest-embedding-abi-m1.md` +> (finding **[spec_gap]#2** "No public path emits the staticlib IR, +> though DESIGN.md Decision 5 makes IR readability a first-class LLM +> affordance"). Orchestrator resolution recorded in `docs/roadmap.md` +> P1 entry "[feature] ail emit-ir --emit=staticlib": **add** the flag +> (symmetric with `ail build`, reuse the M1-shipped `Target::StaticLib` +> path) — NOT narrow DESIGN.md. Scoped feature, no design fork — this +> is NOT a milestone (no `docs/specs/.md`). +> +> **For agentic workers:** REQUIRED SUB-SKILL: use `skills/implement` +> to run this plan. Steps use `- [ ]` checkboxes for tracking. + +**Goal:** `ail emit-ir --emit=staticlib` prints a +`main`-free kernel's LLVM IR (the external `@` forwarders) +instead of the executable-path `MissingEntryMain` rejection, so an +author can read the IR M1 introduced (Decision 5 affordance). + +**Architecture:** Reuse the M1-shipped-and-audited `Target::StaticLib` +codegen path. Add a one-line symmetric codegen convenience +`lower_workspace_staticlib(ws)` (mirrors the existing +`lower_workspace(ws)`: `--emit=exe`↔`lower_workspace`, +`--emit=staticlib`↔`lower_workspace_staticlib`); add an `--emit` +clap field to `Cmd::EmitIr` symmetric with `Cmd::Build`'s; branch +the `emit-ir` match arm on it with a zero-export guard byte-identical +to `build_staticlib`'s. Recon resolved the integration risk negative: +both codegen entrypoints return `Result` and the emit-ir +printer already consumes a `String` — no adapter. DESIGN.md gets a +present-tense current-state mirror (§"Embedding ABI (M1)" + the CLI +synopsis, which pre-existingly omitted M1's already-shipped +`build --emit=staticlib` too). No new regression pin: the E2E test +pins the behaviour; the architect's mandatory DESIGN.md read at +milestone close catches stale prose. Decision-5 prose +(`DESIGN.md:224-225`) is already present-tense correct — untouched. + +**Tech Stack:** `crates/ailang-codegen/src/lib.rs` (one pub fn), +`crates/ail/src/main.rs` (clap field + arm branch + guard), +`crates/ail/tests/emit_ir_staticlib_cli.rs` (new E2E), `docs/DESIGN.md` +(current-state mirror). + +--- + +## Files this plan creates or modifies + +- Modify: `crates/ailang-codegen/src/lib.rs:275-279` — add the + symmetric `lower_workspace_staticlib` convenience between + `lower_workspace` (275-277) and `fn lower_workspace_inner` (279). +- Modify: `crates/ail/src/main.rs:114-119` — add the `emit` clap + field to the `Cmd::EmitIr` variant. +- Modify: `crates/ail/src/main.rs:640` + `:690` — destructure `emit` + in the `Cmd::EmitIr` arm; branch the lowering call on it with the + zero-export guard. +- Create: `crates/ail/tests/emit_ir_staticlib_cli.rs` — 3 E2E tests + (positive forwarder-IR, zero-export guard, default-exe still + requires main). +- Modify: `docs/DESIGN.md:2301-2304` — one-sentence §"Embedding ABI + (M1)" mirror after the canonical snippet. +- Modify: `docs/DESIGN.md:2609-2610` — CLI synopsis: both `emit-ir` + and `build` lines gain `[--emit=staticlib]` (build's was a + pre-existing M1 synopsis omission — current-state honest). + +Files that do NOT change (verified by recon): the build-side +`build_staticlib` + its guard (`main.rs:2467-2473`) and +`crates/ail/tests/embed_staticlib_cli.rs` (the new guard reuses the +identical fixture `examples/embed_noentry_baseline.ail` and bail +string — no fixture minting); `Decision 5` prose +(`DESIGN.md:214-225`); any codegen lowering logic (the +`Target::StaticLib` forwarder emission `lib.rs:588-625` is M1-audited +and reused unchanged). + +--- + +## Task 1: codegen — symmetric `lower_workspace_staticlib` convenience + +**Files:** +- Modify: `crates/ailang-codegen/src/lib.rs:275-279` + +- [ ] **Step 1: Add the convenience fn** + +Replace, verbatim (lines 275-279 — the `lower_workspace` fn, the +blank line, and the `fn lower_workspace_inner` opener): + +old: +```rust +pub fn lower_workspace(ws: &Workspace) -> Result { + lower_workspace_inner(ws, AllocStrategy::Gc, Target::Executable) +} + +fn lower_workspace_inner(ws: &Workspace, alloc: AllocStrategy, target: Target) -> Result { +``` + +new: +```rust +pub fn lower_workspace(ws: &Workspace) -> Result { + lower_workspace_inner(ws, AllocStrategy::Gc, Target::Executable) +} + +/// Embedding-ABI M1 single-call entry point symmetric with +/// [`lower_workspace`]: lowers a [`Workspace`] for the static-library +/// target with the default `AllocStrategy::Gc`. This is what +/// `ail emit-ir --emit=staticlib` calls so an author can read a +/// `main`-free kernel's IR (the external `@` forwarders, no +/// `@main`) — the Decision-5 IR-readability affordance for the +/// artefact M1 introduced. Equivalent to +/// `lower_workspace_staticlib_with_alloc(ws, AllocStrategy::Gc)`. +pub fn lower_workspace_staticlib(ws: &Workspace) -> Result { + lower_workspace_inner(ws, AllocStrategy::Gc, Target::StaticLib) +} + +fn lower_workspace_inner(ws: &Workspace, alloc: AllocStrategy, target: Target) -> Result { +``` + +- [ ] **Step 2: Compile gate** + +Run: `cargo build -p ailang-codegen 2>&1 | tail -3` +Expected: builds, 0 errors (the new fn is `pub`, delegates to the +existing private `lower_workspace_inner` with the existing +`Target::StaticLib` — no signature change, no caller threading; a +new unused-within-crate `pub fn` is not dead-code-warned). + +--- + +## Task 2: CLI — `--emit` on `emit-ir` + staticlib branch + zero-export guard + +**Files:** +- Create: `crates/ail/tests/emit_ir_staticlib_cli.rs` +- Modify: `crates/ail/src/main.rs:114-119`, `:640`, `:690` + +> **Compile-unit note (planner Step-5 item 7):** Steps 3 and 4 are a +> single compile unit. After Step 3 adds the `emit` field to the +> variant, the Step-4 arm's `Cmd::EmitIr { path, out }` destructure +> is a non-exhaustive struct pattern (`error[E0027]`) until Step 4 +> adds `emit` to it. Do NOT run `cargo build`/`cargo test` between +> Step 3 and Step 4 — the next build/test gate is Step 5, after both +> edits land. + +- [ ] **Step 1: Write the RED-first E2E test file** + +Create `crates/ail/tests/emit_ir_staticlib_cli.rs` with exactly: + +```rust +//! Embedding-ABI M1 (M1 fieldtest spec_gap#2): `ail emit-ir +//! --emit=staticlib` prints a `main`-free kernel's LLVM IR (the +//! external `@` forwarders, no `@main`) instead of the +//! executable-path `MissingEntryMain` rejection — the Decision-5 +//! IR-readability affordance for the artefact M1 introduced. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +fn ail_bin() -> &'static str { env!("CARGO_BIN_EXE_ail") } +fn ws_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent().unwrap().parent().unwrap().to_path_buf() +} + +#[test] +fn emit_ir_staticlib_prints_kernel_forwarder_no_main() { + let fixture = ws_root().join("examples").join("embed_backtest_step.ail"); + let out = Command::new(ail_bin()) + .args(["emit-ir", fixture.to_str().unwrap(), "--emit=staticlib"]) + .output().expect("ail emit-ir --emit=staticlib"); + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(out.status.success(), + "emit-ir --emit=staticlib on a main-free kernel must succeed; stderr={stderr}"); + assert!(!stderr.contains("has no `main` def"), + "must NOT hit the executable-path MissingEntryMain rejection; stderr={stderr}"); + assert!(stdout.contains("@backtest_step("), + "kernel IR must contain the external `(export \"backtest_step\")` forwarder; stdout={stdout}"); + assert!(stdout.contains("@ail_embed_backtest_step_step"), + "kernel IR must contain the internal mangled symbol the forwarder calls; stdout={stdout}"); + assert!(!stdout.contains("@main("), + "staticlib kernel IR must NOT contain an @main trampoline; stdout={stdout}"); +} + +#[test] +fn emit_ir_staticlib_requires_an_export() { + // embed_noentry_baseline has no `(export …)` fn — symmetric with + // build's zero-export guard (embed_staticlib_cli.rs). + let fixture = ws_root().join("examples").join("embed_noentry_baseline.ail"); + let out = Command::new(ail_bin()) + .args(["emit-ir", fixture.to_str().unwrap(), "--emit=staticlib"]) + .output().expect("ail emit-ir --emit=staticlib"); + assert!(!out.status.success(), + "emit-ir --emit=staticlib with zero exports must fail"); + assert!(String::from_utf8_lossy(&out.stderr) + .contains("at least one `(export"), + "expected the zero-export staticlib error; got {}", + String::from_utf8_lossy(&out.stderr)); +} + +#[test] +fn emit_ir_default_exe_still_requires_main() { + // Regression: --emit defaults to "exe"; emit-ir on a main-free + // module WITHOUT --emit=staticlib must still hit MissingEntryMain + // (the new staticlib branch must not alter the default path). + let fixture = ws_root().join("examples").join("embed_backtest_step.ail"); + let out = Command::new(ail_bin()) + .args(["emit-ir", fixture.to_str().unwrap()]) + .output().expect("ail emit-ir"); + assert!(!out.status.success(), + "default-exe emit-ir on a main-free kernel must still fail"); + assert!(String::from_utf8_lossy(&out.stderr) + .contains("has no `main` def"), + "default path must still surface MissingEntryMain; got {}", + String::from_utf8_lossy(&out.stderr)); +} +``` + +- [ ] **Step 2: Run the new tests — RED** + +Run: `cargo test -p ail --test emit_ir_staticlib_cli 2>&1 | tail -8` +Expected: **FAIL**. `emit_ir_staticlib_prints_kernel_forwarder_no_main` +and `emit_ir_staticlib_requires_an_export` FAIL because `ail emit-ir` +has no `--emit` flag yet — clap rejects `--emit=staticlib` +("unexpected argument '--emit=staticlib'", non-zero exit), so +`out.status.success()` is false / the guard message is absent. +`emit_ir_default_exe_still_requires_main` PASSES (that is the current +behaviour — the spec_gap itself: default-exe emit-ir on a main-free +kernel already fails with `has no \`main\` def`). Net: "2 failed; 1 +passed". This is the RED. + +- [ ] **Step 3: Add the `emit` clap field to `Cmd::EmitIr`** + +Replace, verbatim (`crates/ail/src/main.rs:114-119`): + +old: +```rust + /// Writes LLVM IR (.ll) for the module. + EmitIr { + path: PathBuf, + #[arg(short, long)] + out: Option, + }, +``` + +new: +```rust + /// Writes LLVM IR (.ll) for the module. + EmitIr { + path: PathBuf, + #[arg(short, long)] + out: Option, + /// IR shape. `exe` (default): whole-program IR (requires an + /// entry `main`). `staticlib`: kernel IR for a `main`-free + /// module — one external C forwarder per `(export "")` + /// fn, no `@main` (Embedding ABI M1). Same `Target` the + /// `build --emit=staticlib` path uses; output is the IR text + /// (stdout, or `-o `), not archives. + #[arg(long, default_value = "exe", value_parser = ["exe", "staticlib"])] + emit: String, + }, +``` + +- [ ] **Step 4: Destructure `emit` + branch the lowering call** + +Two verbatim replacements in the `Cmd::EmitIr` match arm. (Do not +run cargo between Step 3 and the end of Step 4 — see the +compile-unit note above.) + +4a. The destructure line (`crates/ail/src/main.rs:640`): + +old: +```rust + Cmd::EmitIr { path, out } => { +``` + +new: +```rust + Cmd::EmitIr { path, out, emit } => { +``` + +4b. The lowering call (`crates/ail/src/main.rs:690`): + +old: +```rust + let ir = ailang_codegen::lower_workspace(&ws)?; +``` + +new: +```rust + let ir = if emit == "staticlib" { + let has_export = ws.modules.values().any(|m| m.defs.iter().any(|d| + matches!(d, ailang_core::Def::Fn(f) if f.export.is_some()))); + if !has_export { + anyhow::bail!( + "staticlib target needs at least one `(export \"\")` fn" + ); + } + ailang_codegen::lower_workspace_staticlib(&ws)? + } else { + ailang_codegen::lower_workspace(&ws)? + }; +``` + +(The `has_export` predicate + `anyhow::bail!` string are +byte-identical to `build_staticlib`'s guard at `main.rs:2467-2473`, +so the zero-export error message is symmetric across `build` and +`emit-ir` and the existing `embed_staticlib_cli.rs:54` substring +`at least one \`(export` matches both paths.) + +- [ ] **Step 5: Build + run all gates — GREEN** + +Run: `cargo build -p ail 2>&1 | tail -3` +Expected: builds, 0 errors (variant field + arm destructure both +landed; `lower_workspace_staticlib` exists from Task 1). + +Run: `cargo test -p ail --test emit_ir_staticlib_cli 2>&1 | tail -6` +Expected: **PASS**, "test result: ok. 3 passed; 0 failed". + +Run: `cargo test -p ail --test embed_staticlib_cli 2>&1 | tail -4` +Expected: **PASS**, all build-side staticlib tests still green +(`staticlib_emit_produces_two_archives`, +`staticlib_emit_requires_an_export`, and any others in that file) — +the build path + its guard are untouched; this confirms symmetry, +not a regression risk. + +--- + +## Task 3: DESIGN.md — present-tense current-state mirror + +**Files:** +- Modify: `docs/DESIGN.md:2301-2304`, `:2609-2610` + +- [ ] **Step 1: §"Embedding ABI (M1)" — one sentence after the snippet** + +Replace, verbatim (`docs/DESIGN.md:2301-2304` — the snippet's last +line, its closing fence, the blank line, and the `## Data model` +heading): + +old: +``` + (app + state (app * sample sample)))) +``` + +## Data model +``` + +new: +``` + (app + state (app * sample sample)))) +``` + +`ail emit-ir --emit=staticlib` prints this kernel's LLVM +IR (the external `@` forwarders, no `@main`) instead of the +executable-path `main`-required rejection — the Decision-5 +IR-readability affordance for a `main`-free kernel. + +## Data model +``` + +- [ ] **Step 2: CLI synopsis — both lines gain the M1 flag** + +Replace, verbatim (`docs/DESIGN.md:2609-2610`): + +old: +``` +ail emit-ir — writes .ll +ail build — full pipeline → binary +``` + +new: +``` +ail emit-ir [--emit=staticlib] — writes .ll (staticlib: a main-free kernel's IR, no @main) +ail build [--emit=staticlib] — full pipeline → binary (staticlib: lib.a + libailang_rt.a) +``` + +(The `build` line gains `[--emit=staticlib]` too: it shipped in M1 +and the synopsis never reflected it — this corrects a pre-existing +current-state-honesty gap in the same edit, consistent with the +docs-as-current-state-mirror discipline.) + +- [ ] **Step 3: Confirm no doc-pin regressed** + +Run: `cargo test -p ailang-core --test docs_honesty_pin 2>&1 | tail -3` +Expected: **PASS**, "5 passed; 0 failed" — the `docs_honesty_pin` +suite (incl. the form-a carve-out pin) pins specific Wunschdenken/ +present-tense anchors, none of which these two additive +present-tense edits touch. (This is a no-regression check, not a new +pin — per the architecture decision, this feature ships no doc pin.) + +--- + +## Task 4: Regression gate + +**Files:** (none — verification only) + +- [ ] **Step 1: Full `ail` crate suite** + +Run: `cargo test -p ail 2>&1 | grep -E "^test result:" | awk '{p+=$4; f+=$6} END {print "ail TOTAL: "p" passed, "f" failed"}'` +Expected: `ail TOTAL: passed, 0 failed`. The delta vs. pre-iter +is **+3** (the new `emit_ir_staticlib_cli.rs` tests); every +pre-existing `ail` test (incl. `embed_staticlib_cli`, +`embed_e2e`, `embed_missing_main_baseline`, `e2e`, `ir_snapshot`) +still green. + +- [ ] **Step 2: Workspace-wide no-regression assertion** + +Run: `cargo test --workspace 2>&1 | grep -E "test result:" | awk '{p+=$4} END {print "TOTAL_PASSED="p}'` +Expected: `TOTAL_PASSED=` a number **≥ the pre-iter `main`-HEAD +count + 3** (the only new tests are the 3 in +`emit_ir_staticlib_cli.rs`; the codegen convenience fn and the CLI +branch add behaviour exercised by those 3, no other suite loses a +test). A total below `pre-iter + 3` means a pre-existing test broke +— STOP and investigate. (Unfiltered suite + explicit count per +planner Step-5 item 8.) + +- [ ] **Step 3: Scope check — only the expected files changed** + +Run: `git diff --stat 2>&1` +Expected: exactly these modified/created paths (plus the +implement-skill journal + stats files): `crates/ailang-codegen/src/lib.rs`, +`crates/ail/src/main.rs`, `crates/ail/tests/emit_ir_staticlib_cli.rs` +(new), `docs/DESIGN.md`. No diff under `crates/ailang-check`, +`crates/ailang-surface`, `crates/ailang-core`, `runtime/`, or any +`examples/*.ail` (the new tests reuse existing public fixtures +`embed_backtest_step.ail` / `embed_noentry_baseline.ail` — no +fixture minting). Any path outside this set is out of scope — STOP. + +--- + +## Self-review (planner Step 5) + +1. **Spec coverage:** [spec_gap]#2 resolved — the `--emit=staticlib` + flag (Task 2) routes through the M1 `Target::StaticLib` path + (Task 1's symmetric convenience), with a symmetric zero-export + guard (Task 2 Step 4b) and the DESIGN.md current-state mirror + (Task 3). Roadmap resolution ("add the flag, do not narrow + DESIGN.md") honoured: DESIGN.md is *widened* to state the + affordance, never narrowed. ✔ +2. **Placeholder scan:** no "TBD/TODO/implement later/similar to/add + appropriate". Every code/text edit is given verbatim. ✔ +3. **Type/name consistency:** `lower_workspace_staticlib` named + identically in Task 1 Step 1 and Task 2 Step 4b; clap field + `emit: String` matches `Cmd::Build`'s; test fn names + (`emit_ir_staticlib_prints_kernel_forwarder_no_main`, + `emit_ir_staticlib_requires_an_export`, + `emit_ir_default_exe_still_requires_main`) consistent across + Task 2 + Task 4. Fixtures `embed_backtest_step.ail` / + `embed_noentry_baseline.ail` are recon-verified existing paths. ✔ +4. **Step granularity:** each step is one verbatim replacement, one + file create, or one command — 2-5 min. ✔ +5. **No commit steps:** none. ✔ +6. **Pin/replacement substring contiguity:** N/A — this feature + adds **no** presence-pin doc test (architecture decision: the + E2E pins behaviour, no `docs_honesty_pin` entry). The E2E's IR + substrings (`@backtest_step(`, `@ail_embed_backtest_step_step`, + `@main(`) are M1-audited codegen *output* (forwarder emission + `lib.rs:588-625`), not a verbatim-text-edit pair in this plan, so + the "two authoritative artefacts" failure mode does not arise. + Task 3 Step 3 is a no-regression check against the *existing* + pin, not a new pin. ✔ +7. **Compile-gate vs. deferred-caller ordering:** Task 1 adds a new + `pub fn` (no signature change, no caller threading — its compile + gate is satisfiable standalone). Task 2's only crate-wide compile + coupling is the clap-variant field (Step 3) ↔ its single match + arm destructure (Step 4a): both are inside Task 2, the + compile/test gate is Step 5 *after both*, and an explicit + compile-unit note forbids running cargo between Step 3 and Step 4 + (the `error[E0027]` window). Task 2's only caller of + `lower_workspace_staticlib` is the arm itself; Task 1 lands the + callee first. No deferred-caller contradiction. ✔ +8. **Verification-command filter strings resolve:** `--test + emit_ir_staticlib_cli` is the file Task 2 Step 1 creates; `--test + embed_staticlib_cli` is recon-confirmed existing; `--test + docs_honesty_pin` is the existing pin file (5 tests). Task 4 + Steps 1-2 use the **unfiltered** `-p ail` / `--workspace` suites + with explicit pass-count assertions so "0 ran" cannot read as "0 + regressed". No filter guessed from a feature word. ✔