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.
This commit is contained in:
2026-05-18 16:08:33 +02:00
parent 03493c9b31
commit bcfe554686
7 changed files with 194 additions and 4 deletions
@@ -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
}
+21 -2
View File
@@ -116,6 +116,14 @@ enum Cmd {
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,
},
/// 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 \"<sym>\")` fn"
);
}
ailang_codegen::lower_workspace_staticlib(&ws)?
} else {
ailang_codegen::lower_workspace(&ws)?
};
match out {
Some(p) => {
std::fs::write(&p, ir)?;
+67
View File
@@ -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 `@<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));
}
+12
View File
@@ -276,6 +276,18 @@ 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> {
// Iter 16a: desugar every module before any lowering work runs.
// The pass is idempotent and structurally identical to what
+7 -2
View File
@@ -2301,6 +2301,11 @@ canonical M1 export shape:
(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
The on-disk JSON-AST is what the toolchain hashes, typechecks, and
@@ -2606,8 +2611,8 @@ ail workspace <entry.ail.json> — 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 <module> — writes .ll
ail build <module> — full pipeline → binary
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)
ail run <module> — build + execute (tempdir), passthrough exit code
```
@@ -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
+1
View File
@@ -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; `@<sym>` forwarder additive raw-IR *beside* unchanged `@ail_<module>_<fn>` 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 +1214%; `explicit_at_rc`/`implicit_at_rc` `p99_9`/`max_us` +36124%). 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 "<sym>")` 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 "<sym>")` + `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 M2M5 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 `@<sym>` 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<String>`, 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