From bcfe554686b4c7afe737f30dd83e3619a93b0557 Mon Sep 17 00:00:00 2001 From: Brummel Date: Mon, 18 May 2026 16:08:33 +0200 Subject: [PATCH] iter emit-ir-staticlib: ail emit-ir --emit=staticlib (M1 fieldtest spec_gap#2) Restores the Decision-5 IR-readability affordance for main-free kernels: ail emit-ir gains --emit=staticlib, symmetric with ail build, reusing the M1-audited Target::StaticLib path via a new one-line lower_workspace_staticlib convenience. Zero-export guard byte-identical to build_staticlib's. DESIGN.md widened (not narrowed) + a pre-existing M1 synopsis omission corrected. 3 new E2E tests; no fixture minted, no doc pin (E2E is the coverage). Plan 03493c9. --- .../2026-05-18-iter-emit-ir-staticlib.json | 12 +++ crates/ail/src/main.rs | 23 +++++- crates/ail/tests/emit_ir_staticlib_cli.rs | 67 +++++++++++++++++ crates/ailang-codegen/src/lib.rs | 12 +++ docs/DESIGN.md | 9 ++- .../2026-05-18-iter-emit-ir-staticlib.md | 74 +++++++++++++++++++ docs/journals/INDEX.md | 1 + 7 files changed, 194 insertions(+), 4 deletions(-) create mode 100644 bench/orchestrator-stats/2026-05-18-iter-emit-ir-staticlib.json create mode 100644 crates/ail/tests/emit_ir_staticlib_cli.rs create mode 100644 docs/journals/2026-05-18-iter-emit-ir-staticlib.md diff --git a/bench/orchestrator-stats/2026-05-18-iter-emit-ir-staticlib.json b/bench/orchestrator-stats/2026-05-18-iter-emit-ir-staticlib.json new file mode 100644 index 0000000..9d53c3e --- /dev/null +++ b/bench/orchestrator-stats/2026-05-18-iter-emit-ir-staticlib.json @@ -0,0 +1,12 @@ +{ + "iter_id": "emit-ir-staticlib", + "date": "2026-05-18", + "mode": "standard", + "outcome": "DONE", + "tasks_total": 4, + "tasks_completed": 4, + "reloops_per_task": { "1": 0, "2": 0, "3": 0, "4": 0 }, + "review_loops_spec": 0, + "review_loops_quality": 0, + "blocked_reason": null +} diff --git a/crates/ail/src/main.rs b/crates/ail/src/main.rs index e1dc0c8..9d55240 100644 --- a/crates/ail/src/main.rs +++ b/crates/ail/src/main.rs @@ -116,6 +116,14 @@ enum Cmd { 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, }, /// Full pipeline: check + emit-ir + clang -> binary. Build { @@ -637,7 +645,7 @@ fn main() -> Result<()> { ); } } - Cmd::EmitIr { path, out } => { + Cmd::EmitIr { path, out, emit } => { // Iter 5c: workspace lowering. For single-module programs the // workspace is effectively a trivial workspace with one module. let ws = load_workspace_human(&path)?; @@ -687,7 +695,18 @@ fn main() -> Result<()> { }; let ws = ailang_check::monomorphise_workspace(&ws) .map_err(|e| anyhow::anyhow!("monomorphise_workspace: {e}"))?; - let ir = ailang_codegen::lower_workspace(&ws)?; + 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)? + }; match out { Some(p) => { std::fs::write(&p, ir)?; diff --git a/crates/ail/tests/emit_ir_staticlib_cli.rs b/crates/ail/tests/emit_ir_staticlib_cli.rs new file mode 100644 index 0000000..579842f --- /dev/null +++ b/crates/ail/tests/emit_ir_staticlib_cli.rs @@ -0,0 +1,67 @@ +//! 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)); +} diff --git a/crates/ailang-codegen/src/lib.rs b/crates/ailang-codegen/src/lib.rs index 5bb3c56..2352074 100644 --- a/crates/ailang-codegen/src/lib.rs +++ b/crates/ailang-codegen/src/lib.rs @@ -276,6 +276,18 @@ 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 { // Iter 16a: desugar every module before any lowering work runs. // The pass is idempotent and structurally identical to what diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 6c8acce..fc79697 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -2301,6 +2301,11 @@ canonical M1 export shape: (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 The on-disk JSON-AST is what the toolchain hashes, typechecks, and @@ -2606,8 +2611,8 @@ ail workspace — list all modules transitively reach `manifest --workspace` and `diff --workspace` extend single-module subcommands to workspaces) ail builtins — list built-in fns and effect ops -ail emit-ir — writes .ll -ail build — full pipeline → binary +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) ail run — build + execute (tempdir), passthrough exit code ``` diff --git a/docs/journals/2026-05-18-iter-emit-ir-staticlib.md b/docs/journals/2026-05-18-iter-emit-ir-staticlib.md new file mode 100644 index 0000000..66639cc --- /dev/null +++ b/docs/journals/2026-05-18-iter-emit-ir-staticlib.md @@ -0,0 +1,74 @@ +# iter emit-ir-staticlib — `ail emit-ir --emit=staticlib` (M1 fieldtest spec_gap#2) + +**Date:** 2026-05-18 +**Started from:** 03493c9b316038f6bfe7f86678457bfa223451a5 +**Status:** DONE +**Tasks completed:** 4 of 4 + +## Summary + +Resolves M1 fieldtest [spec_gap]#2 ("no public path emits the +staticlib IR though Decision 5 makes IR readability a first-class +LLM affordance") by adding `--emit=staticlib` to `ail emit-ir`, +symmetric with the M1-shipped `ail build --emit=staticlib`. A +one-line codegen convenience `lower_workspace_staticlib(ws)` +(mirroring `lower_workspace`) routes through the M1-audited, +unchanged `Target::StaticLib` forwarder-emission path; the CLI +arm gains an `emit: String` clap field (symmetric with +`Cmd::Build`'s) and a zero-export guard byte-identical to +`build_staticlib`'s. DESIGN.md is *widened* (never narrowed) with +a present-tense affordance sentence in §"Embedding ABI (M1)" and a +synopsis correction that also fixes a pre-existing M1 omission for +`ail build --emit=staticlib`. No new doc pin (architecture +decision): the new E2E file pins the behaviour; the existing +`docs_honesty_pin` (5 tests) confirmed non-regressed. + +## Per-task notes + +- iter emit-ir-staticlib.1: codegen — added `pub fn + lower_workspace_staticlib` in `ailang-codegen/src/lib.rs` between + `lower_workspace` and `lower_workspace_inner`; delegates to + `lower_workspace_inner(ws, Gc, Target::StaticLib)`. `cargo build + -p ailang-codegen` clean. +- iter emit-ir-staticlib.2: CLI — RED-first E2E file + `crates/ail/tests/emit_ir_staticlib_cli.rs` (3 tests), observed + RED "1 passed; 2 failed" exactly as predicted; added `emit` + clap field to `Cmd::EmitIr`, destructured it in the arm, branched + the lowering call with the byte-identical zero-export guard + (Steps 3+4 honoured as a single compile unit — no cargo between + the E0027 window). GREEN: 3 passed; `embed_staticlib_cli` 2 + passed (build-side symmetry, no regression). +- iter emit-ir-staticlib.3: DESIGN.md — added the present-tense + `--emit=staticlib` affordance sentence after the M1 export + snippet; synopsis: both `emit-ir` and `build` lines gained + `[--emit=staticlib]` (the `build` correction discharges a + pre-existing M1 current-state-honesty gap). `docs_honesty_pin` + 5/5 green — no new pin, no regression. +- iter emit-ir-staticlib.4: regression gate — `ail` suite 186 + passed / 0 failed; workspace `TOTAL_PASSED=626` = pre-iter 623 + + 3 (exactly the new tests, no pre-existing test lost); scope + check clean (only the 4 in-scope paths). + +## Concerns + +(none) + +## Known debt + +(none — scoped feature fully closed; no fixture minted, no doc +pin added, all by explicit plan decision.) + +## Blocked detail + +(none) + +## Files touched + +- `crates/ailang-codegen/src/lib.rs` — `lower_workspace_staticlib` convenience +- `crates/ail/src/main.rs` — `Cmd::EmitIr` `emit` clap field + arm branch + zero-export guard +- `crates/ail/tests/emit_ir_staticlib_cli.rs` — new, 3 E2E tests (positive forwarder-IR / zero-export guard / default-exe regression) +- `docs/DESIGN.md` — §"Embedding ABI (M1)" affordance sentence + CLI synopsis (both lines) + +## Stats + +bench/orchestrator-stats/2026-05-18-iter-emit-ir-staticlib.json diff --git a/docs/journals/INDEX.md b/docs/journals/INDEX.md index ea58618..962b72d 100644 --- a/docs/journals/INDEX.md +++ b/docs/journals/INDEX.md @@ -97,3 +97,4 @@ - 2026-05-18 — audit embedding-abi-m1 (milestone close, CLEAN — carry-on, NO ratify): scope `064599e..e406d07`. Architect `clean` — Invariant 1 (clean core) holds semantically (no finance/`data-server` vocab/logic/dep in `ailang-core`/`ailang-codegen`/`runtime`; `backtest_step` is an opaque author string through generic `FnDef.export`; AILang↔host meeting point correctly M5-deferred), DESIGN.md honesty satisfied (§"Embedding ABI (M1)" + fn-JSON `"export"` + field rustdoc present-tense; "provisional until M3"/"M2 changes the C sig" correctly *labelled* reserved-unimplemented), lockstep intact (`FnDef`↔fn-JSON↔drift/spec-drift carry the field; `@` forwarder additive raw-IR *beside* unchanged `@ail__` mangling — not through `lower_app`; schema exhaustively-constructed, no `_=>`/`..Default`; in-source `missing_entry_main_is_error` byte-unchanged; `_adapter`/`_clos` untouched), Design-decision 6 confirmed correct (fixture module==stem differs from the spec's *illustrative* `(module backtest)`/`(module bad)` by the loader invariant + spec Decision 2 — the fixtures *demonstrate* the C-symbol decoupling, not violate the spec). One `[low]` — ~85 `export: None,` lines brace-column-indented (rustfmt-divergent) — **orchestrator-adjudicated carry-on on evidence** (not pending, not ignored): `cargo fmt --all --check` shows **1137 fmt-divergent locations tree-wide predating M1**, so rustfmt is demonstrably not a project convention and the architect's "cargo fmt would churn later" premise is moot; selectively aligning 85/1137 would impose an absent standard (noise, not tidy). Bench: `compile_check.py` 0/24 + `cross_lang.py` 0/25 EXIT 0; `check.py` EXIT 1, 7 firings (`*.bump_s` trio +12–14%; `explicit_at_rc`/`implicit_at_rc` `p99_9`/`max_us` +36–124%). Bencher causal exoneration **decisive**: 21/21 bench binaries `cmp`-byte-identical `064599e`↔`e406d07` (`bench_list_sum_bump` sha `dce18c288904c9f0` both) — M1's `Target::Executable` default leaves every bench fixture's emitted machine code unchanged ⇒ zero causal mechanism; RC-latency tails reproduce *on the byte-identical oracle itself* (3× run-to-run swing, median/p99 steady = `-n 5` single-sample jitter); fresh re-run collapsed 6/7 firings to ok. Both families map (confirmed) onto the two pre-existing roadmap-P2 tracked items (~4th zero-runtime-change milestone they fire+collapse on). **EXONERATED, NO baseline ratify** (Iron Law forbids ratifying noise against a zero-default-path-byte iter; spec forecloses a zero-codegen baseline bump); the two P2 bench-harness-recalibration items stay orchestrator-owned, NOT bundled (bencher recommendation honoured). M1 milestone audit **CLEAN**; `fieldtest` (surface-touching — `(export "")` is new authoring surface) is the next pipeline step before close → 2026-05-18-audit-embedding-abi-m1.md - 2026-05-18 — fieldtest embedding-abi-m1 (milestone CLOSE, surface-touch — thesis substantiated, 3 non-blocking follow-ups routed): post-audit downstream-LLM-author field test of M1's `(export "")` + `ail build --emit=staticlib` + scalar/effect-free gate, DESIGN.md + public examples only (never compiler source). 4 fresh programs: Int fixed-point EMA `(State,Sample)->State` fold + Float leaky integrator (both author→`--emit=staticlib`→C-host link→typed scalar return, `s==67`/`s==1.875`, exit 0 — **clause-1 confirmed first-try**), IntList-param export (correctly rejected `export-non-scalar-signature`), `!IO` scalar-clean export (correctly rejected `export-has-effects`) — **clause-3 confirmed, both diagnostics precise+self-correcting**. **0 bugs**; the M1 capability + gate are sound and the milestone thesis ("an LLM author makes a scalar fold callable") is empirically substantiated. 3 working + 1 friction + 2 spec_gap. The friction + spec_gap#1 share a root: `crates/ailang-core/specs/form_a.md` "Schema invariants" item 1 + Pitfalls state an *unconditional* "every fn param MUST be `(own/borrow T)`" but scalars require **bare** `(con Int)` (`(own (con Int))`→`use-after-consume`, `(borrow)`→`consume-while-borrowed`); the doc the LLM reads first misdirects M1's headline task into a body-pointing linearity diagnostic — a **pre-existing form_a.md docs-honesty defect M1 made acute, not introduced** (the positive examples only succeeded because the fieldtester imitated the public `embed_*` corpus over the form_a.md prose rule). spec_gap#2: no public `emit-ir --emit=staticlib` though Decision 5 makes kernel-IR readability load-bearing. Boss independently re-verified via the public CLI (positive E2E builds both archives; both rejections fire the documented codes). Orchestrator routing (own judgement, not the fieldtester's recommended-action column): friction+spec_gap#1 → ONE P1 `[todo]` docs-honesty tidy (form_a.md scalar-param carve-out symmetric with the existing return-type carve-out + DESIGN.md mirror; behaviour settled → tidy, no brainstorm; ahead of M2 because it undermines the clause-1 ergonomics of the surface M2–M5 extend); spec_gap#2 → P1 `[feature]` add `--emit=staticlib` to `ail emit-ir` (restore the affordance, NOT narrow DESIGN.md). None are bugs (no debug); none M1-blocking. **embedding-abi-m1 fully ratified and CLOSED**: spec (grounding-check PASS) + plan (Boss-Repaired) + iter (`818177d` partial+Repair / `e406d07` DONE) + audit `425c4eb` CLEAN + fieldtest thesis-substantiated; roadmap P1 `[~]`→`[x]`, 2 follow-ups added P1-ahead-of-M2. Next *milestone* is Embedding ABI — M2 (no spec) = a `/boss` new-milestone bounce-back (not auto-started); the 2 P1 follow-ups are autonomous-eligible tidies/feature but the session is context-deep — surfaced to the user for the session-shape/next-item call. → 2026-05-18-fieldtest-embedding-abi-m1.md - 2026-05-18 — iter form-a-scalar-param-mode-carveout (M1-fieldtest follow-up #1, docs-honesty tidy in the docs-honesty-lint class, DONE): resolved the M1 fieldtest `[friction]` + `[spec_gap]#1` shared root. `crates/ailang-core/specs/form_a.md` stated an *unconditional* "every `(fn ...)` param MUST carry an `(own/borrow)` mode" rule in four places (`### Function` prose L97, grammar-block comment L230, "Schema invariants" item 1 L347, Pitfalls bullet L382) that contradicts shipped checker behaviour — scalar `Int`/`Bool`/`Unit`/`Str` params take **and require** bare `(con Int)` (a mode on a scalar makes the linearity pass hold the primitive to linear discipline → body-pointing `use-after-consume`/`consume-while-borrowed`, the exact fieldtest misdirection). All four sites rewritten symmetric to the pre-existing return-type carve-out; site 5 (L521 few-shot annotation) already correct, left verbatim by design (carve-out reuses its heap-shaped/primitive vocabulary for end-to-end internal consistency). `docs/DESIGN.md` §"Embedding ABI (M1)" gained the bare-scalar export-param rule + a corpus-grounded `step` Form-A snippet (byte-identical to `examples/embed_backtest_step.ail:3-11`). RED-first anti-regrowth pin `form_a_scalar_param_carveout_present_and_old_rule_absent` in `crates/ailang-core/tests/docs_honesty_pin.rs` (reads the canonical `ailang_core::FORM_A_SPEC` `include_str!` const — single-source, compile-checked — + `read("docs/DESIGN.md")`; `norm()` whitespace-collapse structurally discharges the grep/contains line-wrap family; 4 ABSENT + 4 PRESENT form_a + 1 PRESENT DESIGN). Boss scope calls: 4 form_a sites not 2 (internal-consistency, on the merits — a tidy whose thesis is "consistent current-state mirror" cannot fix some and leave others contradicting); pin idiom = `FORM_A_SPEC` const (semantic single-source, not effort). `spec_drift.rs` recon-confirmed NOT a lockstep partner (anchors are short keyword tokens; `(own`/`(borrow` survive an additive carve-out). Zero language/checker/codegen change by construction; independent Boss verification: docs_honesty_pin 5/0, spec_drift 8/8, ailang-core 112/0, workspace 622→623 (+1 = the pin), zero `crates/**/src/**` diff. spec carrier `docs/specs/2026-05-18-fieldtest-embedding-abi-m1.md` + roadmap P1 entry → plan `docs/plans/form-a-scalar-param-mode-carveout.md` (`4c266a6`). Pure docs+pin tidy — no audit/fieldtest gate (zero authoring-behaviour change; the pin IS the regression coverage). → 2026-05-18-iter-form-a-scalar-param-mode-carveout.md +- 2026-05-18 — iter emit-ir-staticlib (M1-fieldtest follow-up #2, scoped [feature], DONE): resolved M1 fieldtest [spec_gap]#2 — `ail emit-ir` had no `--emit=staticlib` (only `ail build` did) and on a `main`-free kernel hit the executable-path `MissingEntryMain` rejection, so an author could not read the generated `@` forwarder for an exported kernel — the exact Decision-5 IR-readability affordance, for the exact artefact M1 introduced. Added a one-line symmetric codegen convenience `lower_workspace_staticlib(ws)` (mirrors `lower_workspace`: `--emit=exe`↔`lower_workspace`, `--emit=staticlib`↔`lower_workspace_staticlib`; delegates to the M1-audited unchanged `Target::StaticLib` forwarder path), an `emit: String` clap field on `Cmd::EmitIr` symmetric with `Cmd::Build`'s, and a zero-export guard byte-identical to `build_staticlib`'s (so the error is symmetric across both subcommands and the existing `embed_staticlib_cli.rs` substring assertion holds for both). Recon resolved the only integration risk negative (both codegen entrypoints return `Result`, the emit-ir printer already consumes `String` — no adapter). DESIGN.md *widened* not narrowed (roadmap resolution honoured): a present-tense affordance sentence in §"Embedding ABI (M1)" + a CLI-synopsis correction that also discharges a pre-existing M1 omission (`ail build --emit=staticlib` was never in the synopsis). Boss editorial calls: synopsis fixes BOTH lines (docs-as-current-state-mirror consistency, on the merits); NO new docs pin (the E2E pins behaviour; the architect's mandatory DESIGN.md read at milestone close catches stale prose — pinning every feature sentence is noise); Decision-5 prose left untouched (already present-tense correct). New E2E `crates/ail/tests/emit_ir_staticlib_cli.rs` (3 tests: positive — IR contains external `@backtest_step(` + internal `@ail_embed_backtest_step_step`, NOT `@main(`, NOT `has no main def`; zero-export guard symmetric with build's, reusing the identical `embed_noentry_baseline.ail` fixture; default-exe regression — `--emit` defaults to `exe`, a `main`-free kernel without the flag still hits `MissingEntryMain`, so the new branch cannot silently reroute the default path). RED-first observed exactly ("1 passed; 2 failed" pre-edit — the default-exe test passes because that IS the spec_gap behaviour). Independent Boss verification: the 3 new tests 3/0, `embed_staticlib_cli` 2/0 (build-side untouched), `docs_honesty_pin` 5/0 (no regression), `ail` suite 186/0, workspace 623→626 (+3 = exactly the new tests), zero diff outside the 4 in-scope paths, no fixture minted. Functional spot-check from the user-facing CLI: `ail emit-ir examples/embed_backtest_step.ail --emit=staticlib` emits `define i64 @backtest_step(...)` (the readable external forwarder) + `@ail_embed_backtest_step_step` (internal), no `@main` — the affordance is genuinely delivered. spec carrier `docs/specs/2026-05-18-fieldtest-embedding-abi-m1.md` [spec_gap]#2 + roadmap P1 entry → plan `docs/plans/emit-ir-staticlib.md` (`03493c9`) → iter this commit. Scoped feature, no audit/fieldtest gate (the E2E is the coverage; no authoring-surface or invariant change — reuses the M1-audited codegen path). → 2026-05-18-iter-emit-ir-staticlib.md