Files
AILang/docs/plans/emit-ir-staticlib.md
T

19 KiB

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/<milestone>.md).

For agentic workers: REQUIRED SUB-SKILL: use skills/implement to run this plan. Steps use - [ ] checkboxes for tracking.

Goal: ail emit-ir <module> --emit=staticlib prints a main-free kernel's LLVM IR (the external @<sym> 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=exelower_workspace, --emit=staticliblower_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<String> 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:

pub fn lower_workspace(ws: &Workspace) -> Result<String> {
    lower_workspace_inner(ws, AllocStrategy::Gc, Target::Executable)
}

fn lower_workspace_inner(ws: &Workspace, alloc: AllocStrategy, target: Target) -> Result<String> {

new:

pub fn lower_workspace(ws: &Workspace) -> Result<String> {
    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 `@<sym>` 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<String> {
    lower_workspace_inner(ws, AllocStrategy::Gc, Target::StaticLib)
}

fn lower_workspace_inner(ws: &Workspace, alloc: AllocStrategy, target: Target) -> Result<String> {
  • 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:

//! Embedding-ABI M1 (M1 fieldtest spec_gap#2): `ail emit-ir
//! --emit=staticlib` prints a `main`-free kernel's LLVM IR (the
//! external `@<sym>` 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:

    /// Writes LLVM IR (.ll) for the module.
    EmitIr {
        path: PathBuf,
        #[arg(short, long)]
        out: Option<PathBuf>,
    },

new:

    /// Writes LLVM IR (.ll) for the module.
    EmitIr {
        path: PathBuf,
        #[arg(short, long)]
        out: Option<PathBuf>,
        /// 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 "<sym>")`
        /// fn, no `@main` (Embedding ABI M1). Same `Target` the
        /// `build --emit=staticlib` path uses; output is the IR text
        /// (stdout, or `-o <file>`), 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:

        Cmd::EmitIr { path, out } => {

new:

        Cmd::EmitIr { path, out, emit } => {

4b. The lowering call (crates/ail/src/main.rs:690):

old:

            let ir = ailang_codegen::lower_workspace(&ws)?;

new:

            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 \"<sym>\")` 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 <module> --emit=staticlib` prints this kernel's LLVM
IR (the external `@<sym>` 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   <module>                   — writes .ll
ail build     <module>                   — full pipeline → binary

new:

ail emit-ir   <module> [--emit=staticlib]  — writes .ll (staticlib: a main-free kernel's IR, no @main)
ail build     <module> [--emit=staticlib]  — full pipeline → binary (staticlib: lib<entry>.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: <N> 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. ✔