Files
AILang/docs/plans/embedding-abi-m1.1.md
T
Brummel 818177d835 iter embedding-abi-m1.1 (PARTIAL 3/7): schema + surface + baseline prerequisites; plan Repair
Tasks 1-3 of docs/plans/embedding-abi-m1.1.md, a verified known-good
subset (cargo build --workspace exit 0; full workspace green; the
roundtrip_cli all-examples gate green):

- Task 1: CLI build-path MissingEntryMain baseline pin
  (examples/embed_noentry_baseline.ail + embed_missing_main_baseline.rs;
  triple-assertion, layered on the green codegen-unit pin lib.rs:3314).
- Task 2: additive FnDef.export: Option<String> (serde default +
  skip_serializing_if, modelled on FnDef.doc); compile-driven thread
  of export: None across ~85 FnDef/AstFnDef literals / 18 files incl.
  all in-source #[cfg(test)] mod tests + drift/hash/spec-drift
  literals + the codegen lib.rs:3320 in-source pin; hash-stability
  golden pin; DESIGN.md fn-JSON "export" line. DONE_WITH_CONCERNS
  (plan symbol content_hash_fn -> def_hash per hash_pin.rs, plan-
  pseudo-vs-reality class, plan corrected).
- Task 3: Form-A (export "<sym>") modifier — parse_export +
  parse_fn thread + write_fn_def emission + form_a.md grammar;
  byte-identical round-trip.

Task 4 BLOCKED (Boss plan-defect: fixtures declared module != file
stem; AILang's loader hard-enforces module==stem at workspace.rs:438,
so ail check panicked on load before the gate). Boss resolution
(Option 2, overriding the orchestrator's Option 1, rationale in the
journal + plan Design-decision 6): keep descriptive embed_* filenames,
rename fixture MODULES to their stems — the C symbol backtest_step
stays via (export ...), demonstrating spec Decision 2's mangling-
decoupling rather than violating it; archive becomes
libembed_backtest_step.a, internal symbol @ail_embed_backtest_step_step,
host.c unchanged. Plan fixed (Design-decision 6 + Task-4 module==stem
+ Task-5/6/7 dependent strings + the no-*. Float-head + content_hash_fn
concerns folded). Headline fixture corrected; blocked-Task-4 WIP
discarded (recreated by the [4,7] re-dispatch from the fixed plan).

PARTIAL commit (not the iter's final commit): the implement Repair
path mandates committing the known-good subset so the [4,7]
re-dispatch starts from a clean tree that includes Tasks 1-3 (Tasks
4-7 structurally depend on the schema field + surface). INDEX line
added when the full iter lands post-re-dispatch.
2026-05-18 14:32:39 +02:00

1623 lines
64 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Embedding ABI — M1.1 — Implementation Plan
> **Parent spec:** `docs/specs/2026-05-18-embedding-abi-m1.md`
>
> **For agentic workers:** REQUIRED SUB-SKILL: use `skills/implement`
> to run this plan. Steps use `- [ ]` checkboxes for tracking.
**Goal:** Make a compiled AILang scalar `fn` callable in-process from a
C/Rust host: an author marks one `fn` with `(export "<c-symbol>")`,
`ail build --emit=staticlib` produces `lib<entry>.a` + a separate
`libailang_rt.a`, and a C host links both and calls the marked function
with scalar args and a typed return.
**Architecture:** Five pipeline layers change in order — schema
(`FnDef.export: Option<String>`, additive/hash-stable, modelled
byte-for-byte on `FnDef.doc`) → surface (`(export "<sym>")` Form-A
modifier, round-trip-gated) → check (scalar-only + effect-free export
gate, two new `CheckError` Error variants — the clause-3 discriminator
*in code*) → codegen (a `Target::StaticLib` mode that suppresses the
`@main` trampoline + the `MissingEntryMain` shape-check and emits an
external `@<sym>` forwarder to `@ail_<module>_<fn>`) → CLI
(`--emit=staticlib`: `clang -c` + `ar rcs` two-archive emit). A
test-only C host harness proves the coherent stop. None of
`ailang-core` / `ailang-codegen` / `runtime/` gains any
finance/`data-server` knowledge or dependency (Invariant 1).
**Tech Stack:** `ailang-core` (AST/serde), `ailang-surface`
(lex/parse/print), `ailang-check` (`CheckError`/`check_fn`),
`ailang-codegen` (LLVM-IR text emission), `crates/ail` (clap CLI,
`clang`/`ar`), C (host harness), `docs/DESIGN.md` +
`crates/ailang-core/specs/form_a.md` (canonical-doc lockstep).
---
## Files this plan creates or modifies
- Create: `examples/embed_noentry_baseline.ail` — main-free module (Task 1 RED fixture).
- Create: `crates/ail/tests/embed_missing_main_baseline.rs` — CLI build-path baseline pin (Task 1).
- Modify: `crates/ailang-core/src/ast.rs:194221` — add `export: Option<String>` to `FnDef` after `doc`.
- Modify: ~95 `FnDef { … }` literal sites across 18 files (compile-driven enumeration, Task 2) — incl. in-source `#[cfg(test)] mod tests`, `crates/ailang-core/tests/design_schema_drift.rs:278`, `crates/ailang-core/tests/spec_drift.rs:256`, `crates/ailang-core/tests/hash_pin.rs`, `crates/ailang-codegen/src/lib.rs:3320`.
- Modify: `docs/DESIGN.md:22902299` — add `"export"` line to the fn JSON block (Task 2); new §"Embedding ABI (M1)" subsection after §"Mangling scheme" (Task 7).
- Modify: `crates/ailang-core/specs/form_a.md:6892` — add `(export STRING)?` production + prose (Task 3).
- Modify: `crates/ailang-surface/src/parse.rs:438461,477552``parse_export` helper + `parse_fn` modifier arm + unknown-attr string + `Ok(FnDef{…})` (Task 3).
- Modify: `crates/ailang-surface/src/print.rs:148166``write_fn_def` export emission (Task 3).
- Modify: `crates/ailang-check/src/lib.rs:445,697,1851+` — two `CheckError` variants + `code()` arms + the export gate in `check_fn` (Task 4).
- Modify: `crates/ailang-codegen/src/lib.rs:158,223,252,256,419431,554558``Target` enum, threaded param, gated regions, forwarder loop, new public wrapper (Task 5).
- Modify: `crates/ail/src/main.rs:121140,691693,2233+``emit` clap field, dispatch branch, `build_staticlib` (Task 6).
- Create: `examples/embed_backtest_step.ail` — headline positive `(Int,Int)->Int` export (Tasks 3/4/6/7).
- Create: `examples/embed_export_str_param_rejected.ail`, `examples/embed_export_adt_ret_rejected.ail`, `examples/embed_export_io_rejected.ail`, `examples/embed_export_effectful_rejected.ail`, `examples/embed_export_float_ok.ail` — check-gate fixtures (Task 4).
- Create: `crates/ailang-check/tests/embed_export_gate.rs` — export-gate check fixtures pin (Task 4).
- Create: `crates/ailang-codegen/tests/embed_staticlib_lowering.rs` — staticlib IR-shape pin (Task 5).
- Create: `crates/ail/tests/embed_staticlib_cli.rs``--emit=staticlib` archive-emit pin (Task 6).
- Create: `crates/ail/tests/embed/host.c` — C host harness (Task 7).
- Create: `crates/ail/tests/embed_e2e.rs` — E2E coherent-stop proof (Task 7).
---
## Design decisions resolved in this plan (Boss judgement)
1. **Explicit threading at all ~95 `FnDef` literal sites, one task
(Task 2).** Recon found zero functional-record-update / `..` /
`Default` anywhere in the workspace — every `FnDef { … }` is an
exhaustive field list, and the prior additive field `suppress`
(Iter 19b) was threaded explicitly at 16/16 `desugar.rs` sites.
Substantive reason (not effort): `FnDef` construction is *uniform*
across the workspace — an LLM reading any `FnDef { … }` sees the
complete field set; introducing FRU for one field would hide
`export` at most sites. Per planner self-review item 7 the
field-add + all-caller threading + the crate-wide `cargo build`
0-errors gate are therefore one task.
2. **Export gate is unconditional at `ail check`, independent of emit
mode.** Spec §"Error handling" "`(export)` present in default
executable mode: accepted and ignored … no diagnostic" governs the
*codegen* forwarder (not emitted in exe mode) and the *absence of a
"you have an export but didn't pick staticlib" warning* — it does
**not** relax the *signature gate*. The scalar-only + effect-free
gate fires at `ail check` whenever `f.export.is_some()`,
unconditionally; that is precisely what makes it the clause-3
discriminator ("wrong code *fails to typecheck*", period — `ail
check` has no emit mode).
3. **Gate order: params → ret → effects, first violation wins.** So
the spec-named combined fixture `embed_export_effectful_rejected.ail`
(`Str` param **and** `!IO`) deterministically fails with
`export-non-scalar-signature` (the `Str` param is the first
violation). The per-clause fixtures isolate each arm.
4. **`build_staticlib` is a sibling fn, not a refactor of
`build_to`.** It re-runs the same load/check/lift/mono prefix
(`main.rs:22392294` shape) then a staticlib-specific
lower+archive tail. Substantive reason: the default-executable
path is the load-bearing proven path; the two diverge anyway at
M2 (ctx threading enters staticlib only). Contained duplication
beats destabilising the exe path.
5. **DESIGN.md split:** the fn-JSON `"export"` line is schema
lockstep → Task 2 (lands with the field). The prose §"Embedding
ABI (M1)" subsection documents a capability that only fully exists
after codegen+CLI+E2E → Task 7 (DESIGN.md = current-state mirror:
document the ABI once it demonstrably works).
6. **Fixture module name == file stem (Option 2; revised
post-Task-4-BLOCK 2026-05-18).** AILang's workspace loader hard-
enforces `module-name == file-stem`
(`crates/ailang-core/src/workspace.rs:438`,
`WorkspaceLoadError::ModuleNameMismatch`, no exception) — so every
fixture's `(module …)` MUST equal its filename stem or `ail check`
panics on load before any gate runs. The original plan's headline
used `(module backtest)` in `embed_backtest_step.ail` (and
`bad`/`bad_io`/… in the gate fixtures) — malformed under that
invariant; the implement-orchestrator correctly BLOCKED Task 4 on
it. Resolution: **rename the fixture *modules* to their file
stems, keep the descriptive `embed_*` filenames** (NOT the
orchestrator-suggested Option 1 of renaming files to bare module
stems). Rationale, from semantics not effort: (a) the spec's
load-bearing contract (Decision 2, spec lines 5259/167169) is
that the exported C symbol is *author-chosen and decoupled from
`ail_<module>` mangling* — `(export "backtest_step")` is the
contract; the host asserts on `backtest_step`, which is invariant
under module rename. So spec-faithfulness does not discriminate;
`libbacktest.a`/`@ail_backtest_step` are *illustrative* (build-
output filename + internal mangling — the latter is exactly what
the spec decouples from), not frozen-ABI contracts. (b) `examples/`
is a flat shared corpus auto-enrolled in whole-corpus tests;
Option 1's `examples/bad.ail`/`backtest.ail` pollute it with
collision-prone non-self-describing names — Option 2 keeps the
self-describing grouped `embed_*` set. (c) Option 2 *is* a
concrete demonstration of the spec's own Decision-2 decoupling
thesis (module `embed_backtest_step`, C symbol `backtest_step`),
not a violation of it. The spec's `(module backtest)`/`(module
bad)` blocks are shape-illustrative (planner contract: spec owns
shape, not bytes — same as the spec naming a filename while the
contract is the `(export …)` symbol). Consequences threaded below:
entry module `embed_backtest_step` ⇒ archive
`libembed_backtest_step.a`, internal symbol
`@ail_embed_backtest_step_step`; the external C symbol
`backtest_step` and `host.c` are UNCHANGED. **Architect-at-close
note:** a fixture module name differing from the spec's
illustrative block is expected and correct under the module==stem
invariant + Decision 2 — not drift.
---
## Task 1: CLI build-path `MissingEntryMain` baseline pin (FIXED FIRST)
> Spec Testing-strategy item 0 + §"Iteration sequencing": this task is
> fixed as task 1 and ships **before any staticlib code**. The
> codegen-unit layer is already pinned green
> (`crates/ailang-codegen/src/lib.rs:3314 missing_entry_main_is_error`);
> this adds the **CLI build-path-layer** pin the staticlib mode
> actually branches on (defence in depth at the layer the suppression
> is introduced). No production code changes in this task — it pins
> existing behaviour so the later suppression rests on a pinned
> baseline. "RED-first" here = the pin is demonstrated to exercise the
> real build-path rejection (Step 3 confirms it is not a no-op).
**Files:**
- Create: `examples/embed_noentry_baseline.ail`
- Create: `crates/ail/tests/embed_missing_main_baseline.rs`
- [ ] **Step 1: Write the main-free fixture**
Create `examples/embed_noentry_baseline.ail`:
```
(module embed_noentry_baseline
(fn helper
(type
(fn-type
(params (con Int))
(ret (con Int))))
(params x)
(body
(app * x x))))
```
(Parses + round-trips — `roundtrip_cli.rs` will enroll it; `ail check`
passes it, `main` is only required at codegen.)
- [ ] **Step 2: Write the CLI build-path pin**
Create `crates/ail/tests/embed_missing_main_baseline.rs`:
```rust
//! Baseline pin (Embedding-ABI M1, spec Testing-strategy item 0):
//! `ail build` (default executable mode) on a `main`-free module
//! surfaces `CodegenError::MissingEntryMain` at the CLI build-path
//! layer. The staticlib emit-mode (Task 5/6) is *defined* as
//! suppressing exactly this rejection; this pin guards the baseline
//! at the layer the suppression branches on. The codegen-unit layer
//! is independently pinned by
//! `crates/ailang-codegen/src/lib.rs` `missing_entry_main_is_error`.
//!
//! Pure reader: writes nothing to the repo.
use std::path::{Path, PathBuf};
use std::process::Command;
fn ail_bin() -> &'static str {
env!("CARGO_BIN_EXE_ail")
}
fn workspace_root() -> PathBuf {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
Path::new(manifest_dir).parent().unwrap().parent().unwrap().to_path_buf()
}
#[test]
fn ail_build_on_main_free_module_is_rejected_at_cli() {
let fixture = workspace_root()
.join("examples")
.join("embed_noentry_baseline.ail");
assert!(fixture.exists(), "missing fixture {fixture:?}");
let out_bin = std::env::temp_dir().join(format!(
"ailang-embed-baseline-{}", std::process::id()
));
let output = Command::new(ail_bin())
.args([
"build",
fixture.to_str().unwrap(),
"-o",
out_bin.to_str().unwrap(),
])
.output()
.expect("run `ail build`");
assert!(
!output.status.success(),
"ail build of a main-free module unexpectedly succeeded; \
stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("has no `main` def"),
"expected the MissingEntryMain message \
(`entry module ... has no `main` def`); got stderr: {stderr}",
);
assert!(
!out_bin.exists(),
"no binary should be produced for a main-free module",
);
}
```
- [ ] **Step 3: Run the pin — verify GREEN and that it is not a no-op**
Run: `cargo test -p ail --test embed_missing_main_baseline`
Expected: PASS (1 passed). The assertions are non-trivial: the
non-zero-exit + `has no \`main\` def` substring + absent-binary triple
fails if any of (a) `MissingEntryMain` stops firing, (b) the message
text drifts, (c) a binary is emitted anyway. The verbatim substring
`has no \`main\` def` is the exact `CodegenError::MissingEntryMain`
Display (`crates/ailang-codegen/src/lib.rs:129`,
`#[error("entry module \`{0}\` has no \`main\` def")]`).
- [ ] **Step 4: Confirm the round-trip gate still green with the new fixture**
Run: `cargo test -p ail --test roundtrip_cli`
Expected: PASS (the new `examples/embed_noentry_baseline.ail` is
auto-enrolled by `list_ail_fixtures`'s `read_dir`; it parses and
round-trips cleanly).
---
## Task 2: Additive `FnDef.export` schema field + workspace-wide threading
**Files:**
- Modify: `crates/ailang-core/src/ast.rs:194221`
- Modify: ~95 `FnDef { … }` literal sites (compile-driven; 18 files)
- Modify: `docs/DESIGN.md:22902299`
- Modify: `crates/ailang-core/tests/design_schema_drift.rs:278285`
- Create: `crates/ailang-core/tests/embed_export_hash_stable.rs`
- [ ] **Step 1: Write the hash-stability RED pin**
Create `crates/ailang-core/tests/embed_export_hash_stable.rs`:
```rust
//! Additive-field hash stability (Embedding-ABI M1): adding
//! `FnDef.export: Option<String>` with
//! `#[serde(default, skip_serializing_if = "Option::is_none")]`
//! must leave every pre-M1 canonical-JSON hash bit-identical when
//! `export` is `None`. A round-trip-able fixture WITHOUT `export`
//! hashes identically to a hand-frozen pre-M1 golden.
use ailang_core::ast::{FnDef, Term, Type, Literal};
use ailang_core::hash::content_hash_fn;
#[test]
fn fn_without_export_hash_is_unchanged() {
// A minimal pre-M1-shaped fn (no export). Its content hash must
// equal the value captured before the field existed.
let f = FnDef {
name: "f".into(),
ty: Type::fn_implicit(vec![Type::int()], Type::int(), vec![]),
params: vec!["x".into()],
body: Term::Var { name: "x".into() },
doc: None,
suppress: vec![],
export: None,
};
// GOLDEN: filled in Step 4 from the pre-field hash (RED until then).
let golden = "<<GOLDEN-PLACEHOLDER>>";
assert_eq!(content_hash_fn(&f), golden,
"pre-M1 fn hash drifted — additive-field invariant violated");
}
```
> NOTE (corrected post-iter-1): there is **no**
> `ailang_core::hash::content_hash_fn`. The real entry point is
> `def_hash(&Def) -> String` — wrap the `FnDef` in `Def::Fn(...)`
> and call `def_hash`, exactly as `crates/ailang-core/tests/hash_pin.rs`
> does. (Task 2 already executed this correctly per this NOTE's
> original "mirror hash_pin.rs" license; the symbol name above is
> left only so the diff to the as-executed test is legible.)
> `Type::fn_implicit` / `Term::Var` constructors are the ones
> already used by `hash_pin.rs` and
> `design_schema_drift.rs:282`; the implementer mirrors the exact
> hashing entry point that `hash_pin.rs` uses if the name differs.
> The `<<GOLDEN-PLACEHOLDER>>` is resolved deterministically in
> Step 4 (capture-then-pin); it is not a design TBD.
- [ ] **Step 2: Capture the pre-field golden hash**
Run (BEFORE adding the field), the equivalent of `hash_pin.rs`'s
hashing on the same `FnDef` shape minus `export`:
`cargo test -p ailang-core --test hash_pin -- --nocapture`
Read the existing pinned fn-hash for the `f : (Int)->Int / body=x`
shape (or add a one-line `eprintln!(content_hash_fn(&f))` to the new
test, run it, copy the printed hash). Record that string.
- [ ] **Step 3: Add the field to `FnDef`**
In `crates/ailang-core/src/ast.rs`, in `pub struct FnDef`, insert
immediately after the `doc` field (after line 207, before the
`suppress` doc-comment at 208):
```rust
/// Iter embedding-abi-m1: when `Some(sym)`, this fn is the
/// embedding boundary. Codegen's `Target::StaticLib` mode
/// additionally emits an externally-visible C entrypoint
/// `@<sym>` (scalar signature, provisional until M3) forwarding
/// to `@ail_<module>_<fn>`. The symbol is author-chosen and
/// decoupled from the `ail_<module>_<def>` mangling so the
/// M3-frozen ABI survives module/fn refactors.
///
/// Serialised `skip_serializing_if = "Option::is_none"` so every
/// pre-M1 fixture's canonical-JSON hash stays bit-identical —
/// the same additive-schema pattern as [`FnDef::doc`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub export: Option<String>,
```
- [ ] **Step 4: Pin the golden, run the hash test GREEN**
Replace `<<GOLDEN-PLACEHOLDER>>` in
`crates/ailang-core/tests/embed_export_hash_stable.rs` with the
string captured in Step 2.
Run: `cargo test -p ailang-core --test embed_export_hash_stable`
Expected: PASS — `None` export omitted from serialisation ⇒ hash
unchanged.
- [ ] **Step 5: Compile-driven enumeration of all `FnDef` literal sites**
Run: `cargo build --workspace 2>&1 | grep -E "missing.*\`export\`|missing field \`export\`"`
For **every** reported `FnDef { … }` literal (production AND in-source
`#[cfg(test)] mod tests` AND integration tests), add the line
`export: None,` to that literal. Known non-production sites that MUST
be threaded (the in-source-test blind spot — see
`feedback_grounding_check_misses_insource_tests`):
`crates/ailang-codegen/src/lib.rs:3320` (`missing_entry_main_is_error`),
`crates/ailang-core/tests/design_schema_drift.rs:278`,
`crates/ailang-core/tests/spec_drift.rs:256`,
`crates/ailang-core/tests/hash_pin.rs` (its `FnDef` literal(s)),
plus the ~50 in-source `mod tests` literals recon enumerated
(`mono.rs` uses the alias `AstFnDef = FnDef`).
Repeat until: `cargo build --workspace` exits 0.
- [ ] **Step 6: Crate-wide compile gate**
Run: `cargo build --workspace`
Expected: exits 0, zero `error[E0063]`/`missing field` diagnostics.
(This gate is the planner-self-review-item-7 contract: the field-add
+ ALL caller threading complete in this one task.)
- [ ] **Step 7: DESIGN.md fn-JSON schema lockstep**
In `docs/DESIGN.md`, in the `// fn (the unit that gets a content
hash)` JSON block (lines 22902299), insert after the `"doc"` line
(2297) and before the `"suppress"` line (2298):
```
"export": "<optional C symbol>", // omitted when absent (hash-stable when omitted); see §"Embedding ABI (M1)"
```
- [ ] **Step 8: Thread the drift-test `FnDef` literal**
`crates/ailang-core/tests/design_schema_drift.rs:278285` (the
`design_md_anchors_every_def_kind` literal) already gets `export: None,`
from Step 5. Confirm the **drift assertion itself** stays green:
Run: `cargo test -p ailang-core --test design_schema_drift`
Expected: PASS. (Recon Q2: the nested-struct-key anchor list at
`:389394` does not contain `"doc"`/`"suppress"`, so a new `"export"`
anchor is NOT forced there — consistency says do not add one; the
DESIGN.md fn-JSON line from Step 7 is the honesty lockstep, and the
def-kind anchor for `fn` is already present. If the test reports a
missing anchor, STOP — that is a spec/lockstep surprise to escalate,
not an inline guess.)
- [ ] **Step 9: Full workspace test gate**
Run: `cargo test --workspace 2>&1 | tail -20`
Expected: 0 failures (delta vs pre-task = the 1 new
`embed_export_hash_stable` test; every threaded literal still
constructs/serialises identically because `export: None` is omitted).
---
## Task 3: Form-A `(export "<sym>")` surface modifier
**Files:**
- Modify: `crates/ailang-surface/src/parse.rs:438461,477552`
- Modify: `crates/ailang-surface/src/print.rs:148166`
- Modify: `crates/ailang-core/specs/form_a.md:6892`
- Create: `examples/embed_backtest_step.ail`
- [ ] **Step 1: Write the headline round-trip RED fixture**
Create `examples/embed_backtest_step.ail` (the spec headline; module
name == file stem per Design-decision 6 — the C symbol stays
`backtest_step` via the `(export …)` string, demonstrating spec
Decision 2's mangling-decoupling):
```
(module embed_backtest_step
(fn step
(export "backtest_step")
(type
(fn-type
(params (con Int) (con Int))
(ret (con Int))))
(params state sample)
(body
(app + state (app * sample sample))))
(fn helper
(type
(fn-type
(params (con Int))
(ret (con Int))))
(params x)
(body
(app * x x))))
```
- [ ] **Step 2: Run the round-trip gate — verify RED**
Run: `cargo test -p ail --test roundtrip_cli`
Expected: FAIL — `ail parse` rejects `(export "backtest_step")` with
`unknown fn attribute \`export\`` (current `parse_fn` modifier loop,
`parse.rs:515524`). This proves the fixture exercises the new
surface.
- [ ] **Step 3: Add the `parse_export` helper**
In `crates/ailang-surface/src/parse.rs`, after `parse_doc` (ends
line 461), add (mirrors `parse_doc` exactly, only the keyword
differs):
```rust
fn parse_export(&mut self) -> Result<String, ParseError> {
self.expect_lparen("export-attr")?;
self.expect_keyword("export")?;
let s = match self.peek().cloned() {
Some(Token { tok: Tok::Str(s), .. }) => {
self.cur += 1;
s
}
Some(t) => {
return Err(ParseError::Unexpected {
expected: "string literal (export C symbol)".into(),
got: tok_label(&t.tok),
pos: t.span.start,
});
}
None => {
return Err(ParseError::UnexpectedEof {
expected: "string literal (export C symbol)".into(),
});
}
};
self.expect_rparen("export-attr")?;
Ok(s)
}
```
- [ ] **Step 4: Thread `export` through `parse_fn`**
In `crates/ailang-surface/src/parse.rs` `fn parse_fn`:
(a) After `let mut doc: Option<String> = None;` (line 481) add:
```rust
let mut export: Option<String> = None;
```
(b) In the modifier `loop`, after the `Some("doc") => { … }` arm
(ends line 495), add a new arm (modelled on the `doc` arm):
```rust
Some("export") => {
if export.is_some() {
return Err(self.duplicate_clause_err("fn-def", &format!("fn `{name}`"), "export"));
}
export = Some(self.parse_export()?);
}
```
(c) In the unknown-attribute error string (lines 519521) change:
```rust
"unknown fn attribute `{other}`; expected `doc`, `suppress`, `type`, `params`, or `body`"
```
to:
```rust
"unknown fn attribute `{other}`; expected `doc`, `export`, `suppress`, `type`, `params`, or `body`"
```
(d) In the final `Ok(FnDef { … })` literal (lines 544551) add
`export,` after `doc,`:
```rust
Ok(FnDef {
name,
ty,
params,
body,
doc,
export,
suppress,
})
```
- [ ] **Step 5: Thread `export` through `write_fn_def` (print side)**
In `crates/ailang-surface/src/print.rs` `fn write_fn_def`, after the
`doc` emission block (ends line 158) and before the `suppress` loop
(line 159 comment / 162 `for s in &fd.suppress`), insert (mirrors the
`doc` block):
```rust
if let Some(export) = &fd.export {
out.push('\n');
indent(out, level + 1);
out.push_str("(export ");
write_string_lit(out, export);
out.push(')');
}
```
(Grammar order: `doc` → `export` → `suppress` → `type`. This matches
the headline fixture, where `(export …)` precedes `(type …)` with no
`(doc …)`.)
- [ ] **Step 6: Run the round-trip gate — verify GREEN**
Run: `cargo test -p ail --test roundtrip_cli`
Expected: PASS — `examples/embed_backtest_step.ail` survives
`ail parse | ail render | ail parse` byte-identically; `(export
"backtest_step")` is preserved both directions.
- [ ] **Step 7: Run the surface crate-internal round-trip tests**
Run: `cargo test -p ailang-surface`
Expected: PASS (the in-crate `round_trip.rs` over `examples/*.ail`
also picks up the new fixture; print↔parse lockstep holds).
- [ ] **Step 8: form_a.md grammar production lockstep**
In `crates/ailang-core/specs/form_a.md`, in the `### Function —
\`(fn ...)\`` block, change the production (lines 7077) to add
`(export STRING)?` after the `(doc STRING)?` line (line 72):
```
(fn NAME
(doc STRING)?
(export STRING)?
(suppress (code STRING) (because STRING))*
(type FN-TYPE)
(params NAME*)
(body TERM))
```
After the `doc` prose paragraph (line 7980) add a new paragraph:
```
`export` is optional. When present, the string is the C symbol under
which codegen's `--emit=staticlib` mode exposes this fn as an
externally-callable entrypoint (Embedding ABI M1). The symbol is
author-chosen and decoupled from the internal `ail_<module>_<def>`
mangling. M1 requires the fn's parameter and return types to be
scalar (`Int`/`Float`) and its effect set to be empty — see
DESIGN.md §"Embedding ABI (M1)".
```
- [ ] **Step 9: Confirm no spec-drift regression from the form_a.md edit**
Run: `cargo test -p ailang-core`
Expected: PASS (form_a.md is not hashed; this confirms no
`design_schema_drift`/`spec_drift` test reads the changed lines).
---
## Task 4: Check-side export-signature gate (clause-3 discriminator)
**Files:**
- Modify: `crates/ailang-check/src/lib.rs:445446,697,1851+`
- Create: `examples/embed_export_str_param_rejected.ail`
- Create: `examples/embed_export_adt_ret_rejected.ail`
- Create: `examples/embed_export_io_rejected.ail`
- Create: `examples/embed_export_effectful_rejected.ail`
- Create: `examples/embed_export_float_ok.ail`
- Create: `crates/ailang-check/tests/embed_export_gate.rs`
- [ ] **Step 1: Write the five gate fixtures**
`examples/embed_export_str_param_rejected.ail` (Str param, else
scalar+pure → `export-non-scalar-signature`):
```
(module embed_export_str_param_rejected
(fn f
(export "f")
(type
(fn-type
(params (con Int) (con Str))
(ret (con Int))))
(params n label)
(body n)))
```
`examples/embed_export_adt_ret_rejected.ail` (ADT return →
`export-non-scalar-signature`):
```
(module embed_export_adt_ret_rejected
(data Pair
(ctor Pair (con Int) (con Int)))
(fn f
(export "f")
(type
(fn-type
(params (con Int))
(ret (con Pair))))
(params n)
(body (app Pair n n))))
```
`examples/embed_export_io_rejected.ail` (scalar params+ret but `!IO`
→ `export-has-effects`):
```
(module embed_export_io_rejected
(fn f
(export "f")
(type
(fn-type
(params (con Int))
(ret (con Int))
(effects IO)))
(params n)
(body
(seq (do io/print_str "x") n))))
```
`examples/embed_export_effectful_rejected.ail` (spec-named combined:
Str param **and** `!IO`; gate-order params→ret→effects ⇒ fails
`export-non-scalar-signature`):
```
(module embed_export_effectful_rejected
(fn log_step
(export "log_step")
(type
(fn-type
(params (con Int) (con Str))
(ret (con Int))
(effects IO)))
(params state label)
(body
(seq (do io/print_str label) state))))
```
`examples/embed_export_float_ok.ail` (positive — `(Float,Float)->Float`,
pure; the second scalar type beyond the Int headline):
```
(module embed_export_float_ok
(fn scale
(export "embed_scale")
(type
(fn-type
(params (con Float) (con Float))
(ret (con Float))))
(params a b)
(body
(app * a b))))
```
> NOTE (folded from the iter-1 BLOCK concern): AILang has **no**
> `*.` Float-multiply head — Float arithmetic uses the same
> typeclass-generic head as Int (`examples/floats.ail` uses
> `(app + …)`; codegen lowers to `fmul double` by type). The
> fixture above already uses `(app * a b)`. The fixture's *role* is
> "scalar+pure Float export passes the gate".
- [ ] **Step 2: Write the gate pin — verify RED**
Create `crates/ailang-check/tests/embed_export_gate.rs`:
```rust
//! Embedding-ABI M1 export-signature gate (spec §3, clause-3
//! discriminator): an `(export …)` fn whose params/ret are not all
//! `Int`/`Float`, or whose effect set is non-empty, MUST fail
//! `ail check` with the new diagnostic. A scalar+pure export passes.
use std::path::PathBuf;
fn examples_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent().unwrap().parent().unwrap()
.join("examples")
}
fn check_codes(file: &str) -> Vec<String> {
let path = examples_dir().join(file);
let ws = ailang_surface::load_workspace(&path)
.unwrap_or_else(|e| panic!("load {file}: {e}"));
ailang_check::check_workspace(&ws)
.into_iter()
.map(|d| d.code)
.collect()
}
#[test]
fn str_param_export_rejected() {
assert!(check_codes("embed_export_str_param_rejected.ail")
.iter().any(|c| c == "export-non-scalar-signature"));
}
#[test]
fn adt_ret_export_rejected() {
assert!(check_codes("embed_export_adt_ret_rejected.ail")
.iter().any(|c| c == "export-non-scalar-signature"));
}
#[test]
fn io_export_rejected() {
assert!(check_codes("embed_export_io_rejected.ail")
.iter().any(|c| c == "export-has-effects"));
}
#[test]
fn combined_effectful_export_rejected_on_signature_first() {
let codes = check_codes("embed_export_effectful_rejected.ail");
assert!(codes.iter().any(|c| c == "export-non-scalar-signature"),
"combined fixture must fail signature-first; got {codes:?}");
}
#[test]
fn scalar_pure_export_passes() {
let codes = check_codes("embed_export_float_ok.ail");
assert!(!codes.iter().any(|c|
c == "export-non-scalar-signature" || c == "export-has-effects"),
"scalar+pure export must pass the gate; got {codes:?}");
}
#[test]
fn headline_int_export_passes() {
let codes = check_codes("embed_backtest_step.ail");
assert!(!codes.iter().any(|c|
c == "export-non-scalar-signature" || c == "export-has-effects"),
"headline export must pass the gate; got {codes:?}");
}
```
> The implementer confirms `ailang_surface::load_workspace` +
> `ailang_check::check_workspace` + `Diagnostic.code` are the exact
> public symbols (they are the ones `crates/ail/src/main.rs:22392240`
> uses; mirror that call shape).
Run: `cargo test -p ailang-check --test embed_export_gate`
Expected: FAIL — no `export-non-scalar-signature` /
`export-has-effects` code exists yet; the rejected fixtures currently
`ail check`-pass (the body is well-formed), so every `*_rejected`
test fails.
- [ ] **Step 3: Add the two `CheckError` variants**
In `crates/ailang-check/src/lib.rs`, after the `ConstHasEffects`
variant (ends line 445), add:
```rust
/// An `(export …)` fn has a parameter or return type that is not
/// a C-scalar (`Int`/`Float`). M1's embedding ABI is scalar-only;
/// `Bool`/`Str`/every ADT is rejected at typecheck (the
/// feature-acceptance clause-3 discriminator, in code).
/// Code: `export-non-scalar-signature`.
#[error("export `{0}`: M1 embedding ABI is scalar-only — {1} type `{2}` is not `Int`/`Float`")]
ExportNonScalarSignature(String, &'static str, String),
/// An `(export …)` fn has a non-empty effect set. An embedding
/// kernel must be pure (a `(State,Chunk)->State` fold has no
/// effects). Code: `export-has-effects`.
#[error("export `{0}` must be pure — declared effects !{1:?} are not allowed on an embedding boundary")]
ExportHasEffects(String, Vec<String>),
```
- [ ] **Step 4: Add the two `code()` arms**
In `crates/ailang-check/src/lib.rs` `fn code`, after the
`ConstHasEffects` arm (line 697), add:
```rust
CheckError::ExportNonScalarSignature(..) => "export-non-scalar-signature",
CheckError::ExportHasEffects(..) => "export-has-effects",
```
(No `ctx()`/`message()`/`def()`/`inner()` arms needed: `ctx()` has a
`_ =>` catch-all (`lib.rs:796`), `message()` is `format!("{}",
self.inner())` (thiserror Display), `def()`/`inner()` have catch-alls.
`to_diagnostic` wraps every `CheckError` as `Severity::Error` — so
both new diagnostics are Error severity, as the spec requires.)
- [ ] **Step 5: Implement the gate in `check_fn`**
In `crates/ailang-check/src/lib.rs` `fn check_fn`, after the
parameter/return well-formedness checks (after
`check_type_well_formed(&ret_ty, &env)?;`, line 1895), insert the
gate. `param_tys`, `ret_ty`, `declared_effs` are already bound
(lines 18611863); `f.export` / `f.name` are on the `&FnDef`:
```rust
// Embedding-ABI M1: an `(export …)` fn is the C call boundary.
// Its signature must be scalar-only (`Int`/`Float`) and pure.
// Gate order: params → ret → effects, first violation wins
// (so a combined Str+IO export deterministically fails on the
// signature). Unconditional at check-time — independent of the
// codegen emit mode — this IS the clause-3 discriminator.
if f.export.is_some() {
fn is_c_scalar(t: &Type) -> bool {
matches!(t, Type::Con { name, args, .. }
if args.is_empty() && (name == "Int" || name == "Float"))
}
for p in &param_tys {
if !is_c_scalar(p) {
return Err(CheckError::ExportNonScalarSignature(
f.name.clone(),
"parameter",
ailang_core::pretty::type_to_string(p),
));
}
}
if !is_c_scalar(&ret_ty) {
return Err(CheckError::ExportNonScalarSignature(
f.name.clone(),
"return",
ailang_core::pretty::type_to_string(&ret_ty),
));
}
if !declared_effs.is_empty() {
return Err(CheckError::ExportHasEffects(
f.name.clone(),
declared_effs.clone(),
));
}
}
```
> The implementer confirms `Type::Con`'s field set (`name`, `args`,
> and any mode field) against `ast.rs`; the `matches!` guard rejects
> parameterised cons (`args` non-empty) and any non-`Int`/`Float`
> con. `declared_effs` element type is whatever `Type::Fn.effects`
> holds (string-ish); the `ExportHasEffects(_, Vec<String>)` shape
> mirrors `ConstHasEffects(String, Vec<String>)` — match its
> `.into_iter().collect()` conversion at `lib.rs:28042806` if
> `declared_effs` is not already `Vec<String>`.
- [ ] **Step 6: Run the gate pin — verify GREEN**
Run: `cargo test -p ailang-check --test embed_export_gate`
Expected: PASS (6 passed): the three per-clause rejects fire the
right code, the combined fixture fails signature-first, both positive
fixtures pass the gate.
- [ ] **Step 7: Round-trip + workspace regression**
Run: `cargo test -p ail --test roundtrip_cli && cargo test --workspace 2>&1 | tail -15`
Expected: PASS — all five new `examples/*.ail` parse + round-trip
(they fail at *check*, not parse, so `roundtrip_cli` stays green);
workspace delta = the new gate tests only.
---
## Task 5: Codegen `Target::StaticLib` — suppress `@main`/`MissingEntryMain`, emit `@<sym>` forwarder
**Files:**
- Modify: `crates/ailang-codegen/src/lib.rs:158,223225,252256,419431,554558`
- Create: `crates/ailang-codegen/tests/embed_staticlib_lowering.rs`
- [ ] **Step 1: Write the staticlib IR-shape pin — verify RED**
Create `crates/ailang-codegen/tests/embed_staticlib_lowering.rs`:
```rust
//! Embedding-ABI M1 codegen (spec §4): `Target::StaticLib` lowering
//! emits NO `@main` trampoline, NO `MissingEntryMain`, and one
//! external `define i64 @backtest_step(...)` (the author-chosen C
//! symbol from `(export "backtest_step")`) forwarding to the
//! internal `@ail_embed_backtest_step_step` (module
//! `embed_backtest_step` == file stem, per Design-decision 6).
//! Executable-target lowering is byte-unchanged (the
//! `missing_entry_main_is_error` in-source pin still holds).
use ailang_core::Workspace;
use std::path::PathBuf;
fn load(file: &str) -> Workspace {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent().unwrap().parent().unwrap()
.join("examples").join(file);
ailang_surface::load_workspace(&path).unwrap()
}
#[test]
fn staticlib_emits_forwarder_no_main() {
let ws = load("embed_backtest_step.ail");
let ir = ailang_codegen::lower_workspace_staticlib_with_alloc(
&ws, ailang_codegen::AllocStrategy::Rc).unwrap();
assert!(!ir.contains("define i32 @main()"),
"staticlib IR must not emit the @main trampoline");
assert!(ir.contains("@backtest_step("),
"staticlib IR must emit the external @backtest_step entrypoint \
(the author-chosen C symbol — invariant under module rename)");
assert!(ir.contains("@ail_embed_backtest_step_step("),
"the forwarder must call the internal \
@ail_embed_backtest_step_step (module embed_backtest_step)");
}
#[test]
fn executable_target_still_emits_main() {
let ws = load("embed_backtest_step.ail");
// Default executable lowering is unaffected by the new field;
// backtest has no `main`, so exe-target lowering still rejects.
let err = ailang_codegen::lower_workspace_with_alloc(
&ws, ailang_codegen::AllocStrategy::Rc).unwrap_err();
assert!(format!("{err}").contains("has no `main` def"),
"exe target must still surface MissingEntryMain; got {err}");
}
```
Run: `cargo test -p ailang-codegen --test embed_staticlib_lowering`
Expected: FAIL — `lower_workspace_staticlib_with_alloc` does not
exist (compile error). This is the RED.
- [ ] **Step 2: Add the `Target` enum**
In `crates/ailang-codegen/src/lib.rs`, near the `AllocStrategy` enum
(line 158), add:
```rust
/// Embedding-ABI M1: codegen output shape.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Target {
/// Default: emit the `@main` trampoline; require an entry `main`
/// (`MissingEntryMain` if absent). Whole-program executable.
Executable,
/// `--emit=staticlib`: suppress the `@main` trampoline AND the
/// `MissingEntryMain` shape-check (a kernel module is a library);
/// emit one external C entrypoint per `(export …)` fn forwarding
/// to `@ail_<module>_<fn>`. Provisional signature until M3.
StaticLib,
}
```
- [ ] **Step 3: Thread `target` through `lower_workspace_inner` and add the public wrapper**
(a) Change `fn lower_workspace_inner(ws: &Workspace, alloc:
AllocStrategy)` (line 256) to
`fn lower_workspace_inner(ws: &Workspace, alloc: AllocStrategy,
target: Target)`.
(b) Update the three existing internal callers to pass
`Target::Executable` (compile-forced — all in `lib.rs`, this same
task per self-review item 7):
- `lower_workspace_with_alloc` body (line 224):
`lower_workspace_inner(ws, alloc, Target::Executable)`
- `lower_workspace` body (line 253):
`lower_workspace_inner(ws, AllocStrategy::Gc, Target::Executable)`
- `emit_ir` (line 197): its internal `lower_workspace_inner` call
(the implementer locates it within `emit_ir`'s body) passes
`Target::Executable` — so the in-source
`missing_entry_main_is_error` pin (which calls `emit_ir`) is
byte-unchanged.
(c) Add the public staticlib wrapper next to
`lower_workspace_with_alloc` (after line 225):
```rust
/// Embedding-ABI M1 entry point. Lowers a [`Workspace`] for the
/// static-library target: no `@main`, no `MissingEntryMain`, one
/// external `@<sym>` forwarder per `(export …)` fn.
pub fn lower_workspace_staticlib_with_alloc(
ws: &Workspace,
alloc: AllocStrategy,
) -> Result<String> {
lower_workspace_inner(ws, alloc, Target::StaticLib)
}
```
- [ ] **Step 4: Gate the `MissingEntryMain` shape-check**
In `crates/ailang-codegen/src/lib.rs`, lines 419431, wrap the
has-main check so it only runs for the executable target:
```rust
// Trampoline: verify that the entry module has a
// `main : () -> Unit !IO`. Suppressed for the staticlib target —
// a kernel module is a library and has no `main`.
if target == Target::Executable {
let entry_module = ws
.modules
.get(&ws.entry)
.ok_or_else(|| CodegenError::Internal(format!("entry module `{}` not in workspace", ws.entry)))?;
let has_main = entry_module
.defs
.iter()
.any(|d| matches!(d, Def::Fn(f) if f.name == "main" && main_is_void(&f.ty)));
if !has_main {
return Err(CodegenError::MissingEntryMain(ws.entry.clone()));
}
}
```
- [ ] **Step 5: Gate the `@main` trampoline and emit forwarders**
In `crates/ailang-codegen/src/lib.rs`, the trampoline push at lines
554558 — replace with a `match target`:
```rust
match target {
Target::Executable => {
// Trampoline @main → @ail_<entry>_main.
out.push_str(&format!(
"\ndefine i32 @main() {{\n call i8 @ail_{}_main()\n ret i32 0\n}}\n",
ws.entry
));
}
Target::StaticLib => {
// One external C entrypoint per `(export …)` fn,
// forwarding to the internal `@ail_<module>_<fn>`.
// The check-side gate (Task 4) guarantees every param
// and the ret are `Int`/`Float`; map Int→i64,
// Float→double. M1 scalar fns have no captured env, so
// the internal callee takes the scalars positionally
// (confirmed against `emit_fn`'s signature emission,
// `lib.rs:1087`).
for (mname, m) in &ws.modules {
for d in &m.defs {
if let Def::Fn(f) = d {
if let Some(sym) = &f.export {
let (param_tys, ret_ty) = match fn_scalar_sig(&f.ty) {
Some(sig) => sig,
None => return Err(CodegenError::Internal(format!(
"export `{sym}`: non-scalar signature reached codegen \
(check-side gate should have rejected it)"))),
};
let cret = llvm_scalar(&ret_ty);
let params: Vec<String> = param_tys.iter().enumerate()
.map(|(i, t)| format!("{} %a{i}", llvm_scalar(t)))
.collect();
let args: Vec<String> = param_tys.iter().enumerate()
.map(|(i, t)| format!("{} %a{i}", llvm_scalar(t)))
.collect();
out.push_str(&format!(
"\ndefine {cret} @{sym}({}) {{\n \
%r = call {cret} @ail_{mname}_{}({})\n \
ret {cret} %r\n}}\n",
params.join(", "),
f.name,
args.join(", "),
));
}
}
}
}
}
}
```
Add two private helpers near `main_is_void` (after line 569):
```rust
/// Scalar-signature view of an export fn's type. `None` if not the
/// flat `Type::Fn` of scalars the M1 gate guarantees.
fn fn_scalar_sig(t: &Type) -> Option<(Vec<Type>, Type)> {
let inner = match t {
Type::Forall { body, .. } => body.as_ref(),
other => other,
};
match inner {
Type::Fn { params, ret, .. } =>
Some((params.clone(), (**ret).clone())),
_ => None,
}
}
/// `Int` → `i64`, `Float` → `double` (the only two M1 scalars; the
/// check-side gate rejects everything else before codegen).
fn llvm_scalar(t: &Type) -> &'static str {
match t {
Type::Con { name, .. } if name == "Float" => "double",
_ => "i64",
}
}
```
> The implementer cross-checks `llvm_scalar` against the existing
> codegen type lowering (`synth.rs` `llvm_type` / `emit_fn`'s
> signature string at `lib.rs:10871092`): the forwarder's `{cret}`
> and arg types MUST equal what `@ail_<mname>_<fn>` was emitted with,
> or the `call` is mistyped. If `emit_fn` lowers `Int` to anything
> other than `i64` / `Float` to other than `double`, `llvm_scalar`
> mirrors that exactly. The RED→GREEN IR assertion in Step 6 + the
> Task-7 E2E (real `cc` link + run) jointly catch a signature
> mismatch.
- [ ] **Step 6: Run the codegen pin — verify GREEN**
Run: `cargo test -p ailang-codegen --test embed_staticlib_lowering`
Expected: PASS (2 passed): staticlib IR has no `@main`, has external
`@backtest_step` (C symbol), forwards to the internal
`@ail_embed_backtest_step_step`; exe target still returns
`MissingEntryMain`.
- [ ] **Step 7: Codegen in-source baseline pin still green**
Run: `cargo test -p ailang-codegen missing_entry_main_is_error`
Expected: PASS — `emit_ir` passes `Target::Executable`, so the
codegen-unit `MissingEntryMain` baseline (spec Testing item 0,
codegen layer) is byte-unchanged.
- [ ] **Step 8: Codegen crate gate**
Run: `cargo test -p ailang-codegen 2>&1 | tail -15`
Expected: 0 failures (delta = the new lowering test; the threaded
`target` param touched only `lib.rs` callers, all updated this task).
---
## Task 6: CLI `ail build --emit=staticlib`
**Files:**
- Modify: `crates/ail/src/main.rs:121140,691693,2233+`
- Create: `crates/ail/tests/embed_staticlib_cli.rs`
- [ ] **Step 1: Write the staticlib-emit CLI pin — verify RED**
Create `crates/ail/tests/embed_staticlib_cli.rs`:
```rust
//! Embedding-ABI M1 CLI (spec §5): `ail build --emit=staticlib`
//! produces `lib<entry>.a` (program-only) + a separate
//! `libailang_rt.a`, and emits NO executable / NO `@main`.
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 staticlib_emit_produces_two_archives() {
let fixture = ws_root().join("examples").join("embed_backtest_step.ail");
let outdir = std::env::temp_dir()
.join(format!("ail-embed-cli-{}", std::process::id()));
std::fs::create_dir_all(&outdir).unwrap();
let out = Command::new(ail_bin())
.args([
"build", fixture.to_str().unwrap(),
"--emit=staticlib",
"-o", outdir.to_str().unwrap(),
])
.output().expect("ail build --emit=staticlib");
assert!(out.status.success(),
"staticlib build failed: stdout={} stderr={}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr));
assert!(outdir.join("libembed_backtest_step.a").exists(),
"expected libembed_backtest_step.a in {outdir:?}");
assert!(outdir.join("libailang_rt.a").exists(),
"expected separate libailang_rt.a in {outdir:?}");
}
#[test]
fn staticlib_emit_requires_an_export() {
// embed_noentry_baseline has no `(export …)` fn.
let fixture = ws_root().join("examples")
.join("embed_noentry_baseline.ail");
let outdir = std::env::temp_dir()
.join(format!("ail-embed-noexp-{}", std::process::id()));
std::fs::create_dir_all(&outdir).unwrap();
let out = Command::new(ail_bin())
.args(["build", fixture.to_str().unwrap(),
"--emit=staticlib", "-o", outdir.to_str().unwrap()])
.output().expect("ail build");
assert!(!out.status.success(),
"staticlib build 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));
}
```
Run: `cargo test -p ail --test embed_staticlib_cli`
Expected: FAIL — `--emit` is not a known flag (clap rejects it;
`grep -n "emit" crates/ail/src/main.rs` confirms absent).
- [ ] **Step 2: Add the `--emit` clap field**
In `crates/ail/src/main.rs`, in the `Build` subcommand struct (the
variant ending at line 140, after the `alloc` field), add:
```rust
/// Output shape. `exe` (default): whole-program executable
/// (requires an entry `main`). `staticlib`: a relocatable
/// `lib<entry>.a` (program objects only) plus a separate
/// `libailang_rt.a` (RC runtime), with one external C
/// entrypoint per `(export "<sym>")` fn and no `@main`
/// (Embedding ABI M1). With `staticlib`, `-o` is a directory.
#[arg(long, default_value = "exe", value_parser = ["exe", "staticlib"])]
emit: String,
```
- [ ] **Step 3: Branch the `Cmd::Build` dispatch**
In `crates/ail/src/main.rs`, the `Cmd::Build { … }` arm (lines
691693), replace with:
```rust
Cmd::Build { path, out, opt, alloc, emit } => {
let strategy = parse_alloc_strategy(&alloc)?;
if emit == "staticlib" {
let (lib, rt) = build_staticlib(&path, out, &opt, strategy)?;
eprintln!("built {} + {}", lib.display(), rt.display());
} else {
let bin = build_to(&path, out, &opt, strategy)?;
eprintln!("built {}", bin.display());
}
}
```
- [ ] **Step 4: Implement `build_staticlib`**
In `crates/ail/src/main.rs`, add a sibling fn after `build_to`
(after its closing brace; `build_to` starts line 2233). It reuses
`build_to`'s proven load→check→lift→mono prefix verbatim (the body of
`build_to` lines 22392294), then diverges to the staticlib
lower+archive tail:
```rust
/// Embedding-ABI M1: `ail build --emit=staticlib`. Produces
/// `<outdir>/lib<entry>.a` (program objects only) and a separate
/// `<outdir>/libailang_rt.a` (RC runtime: rc.c + str.c). No
/// executable, no `@main`. `out` is the output directory.
fn build_staticlib(
path: &Path,
out: Option<PathBuf>,
opt: &str,
alloc: ailang_codegen::AllocStrategy,
) -> Result<(PathBuf, PathBuf)> {
// ---- shared front matter: identical to build_to:22392294 ----
let ws = load_workspace_human(path)?;
let diags = ailang_check::check_workspace(&ws);
if !diags.is_empty() {
for d in &diags {
eprintln!(
"{}: [{}] {}{}",
match d.severity {
ailang_check::Severity::Error => "error",
ailang_check::Severity::Warning => "warning",
},
d.code,
d.def.as_ref().map(|n| format!("{n}: ")).unwrap_or_default(),
d.message,
);
}
if diags.iter().any(|d| matches!(d.severity, ailang_check::Severity::Error)) {
std::process::exit(1);
}
}
let mut lifted_modules = std::collections::BTreeMap::new();
for (mname, m) in &ws.modules {
let desugared = ailang_core::desugar::desugar_module(m);
let lifted = ailang_check::lift_letrecs(&desugared)
.map_err(|e| anyhow::anyhow!("lift_letrecs in module `{mname}`: {e}"))?;
lifted_modules.insert(mname.clone(), lifted);
}
let ws = ailang_core::Workspace {
entry: ws.entry.clone(),
modules: lifted_modules,
root_dir: ws.root_dir.clone(),
registry: ws.registry.clone(),
};
let ws = ailang_check::monomorphise_workspace(&ws)
.map_err(|e| anyhow::anyhow!("monomorphise_workspace: {e}"))?;
// ---- staticlib-specific: require ≥1 export, then lower ----
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"
);
}
let ir = ailang_codegen::lower_workspace_staticlib_with_alloc(&ws, alloc)?;
let tmpdir = std::env::temp_dir().join(format!("ailang-{}", std::process::id()));
std::fs::create_dir_all(&tmpdir)?;
let ll_path = tmpdir.join(format!("{}.ll", ws.entry));
std::fs::write(&ll_path, &ir)?;
let outdir = out.unwrap_or_else(|| PathBuf::from("."));
std::fs::create_dir_all(&outdir)?;
// program object → lib<entry>.a (program objects ONLY — no rt)
let prog_obj = tmpdir.join(format!("{}.o", ws.entry));
run_cmd("clang", &[opt, "-c", "-o",
prog_obj.to_str().unwrap(), ll_path.to_str().unwrap()],
"compiling program .ll")?;
let prog_archive = outdir.join(format!("lib{}.a", ws.entry));
let _ = std::fs::remove_file(&prog_archive);
run_cmd("ar", &["rcs", prog_archive.to_str().unwrap(),
prog_obj.to_str().unwrap()], "archiving lib<entry>.a")?;
// RC runtime → libairlang_rt.a (rc.c + str.c), separate archive
let rc_src = locate_rc_runtime()?;
let str_src = locate_str_runtime()?;
let rc_obj = tmpdir.join("rc.o");
let str_obj = tmpdir.join("str.o");
run_cmd("clang", &["-O2", "-c", "-o",
rc_obj.to_str().unwrap(), rc_src.to_str().unwrap()],
"compiling runtime/rc.c")?;
run_cmd("clang", &["-O2", "-c", "-o",
str_obj.to_str().unwrap(), str_src.to_str().unwrap()],
"compiling runtime/str.c")?;
let rt_archive = outdir.join("libailang_rt.a");
let _ = std::fs::remove_file(&rt_archive);
run_cmd("ar", &["rcs", rt_archive.to_str().unwrap(),
rc_obj.to_str().unwrap(), str_obj.to_str().unwrap()],
"archiving libailang_rt.a")?;
Ok((prog_archive, rt_archive))
}
/// Run an external command, bailing with stderr on non-zero exit.
fn run_cmd(bin: &str, args: &[&str], ctx: &str) -> Result<()> {
let status = std::process::Command::new(bin)
.args(args).status().context(ctx.to_string())?;
if !status.success() {
anyhow::bail!("{ctx}: `{bin}` exited {status}");
}
Ok(())
}
```
> The implementer reuses the EXACT shared-prefix code from
> `build_to:22392294` (copy, not re-derive) so the two paths share a
> proven front matter; `locate_rc_runtime`/`locate_str_runtime` are
> the existing locators (`main.rs:2158`/`:2189`). `run_cmd` is added
> only if no equivalent helper already exists in `main.rs` — if one
> does, use it (the implementer greps `fn .*Command::new` first).
> `ailang_core::Def` import path mirrors codegen's usage.
- [ ] **Step 5: Run the CLI pin — verify GREEN**
Run: `cargo test -p ail --test embed_staticlib_cli`
Expected: PASS (2 passed): the headline produces
`libembed_backtest_step.a` + `libailang_rt.a`; the export-less
module is rejected with the
zero-export error.
- [ ] **Step 6: Default-exe path regression**
Run: `cargo test -p ail --test floats_e2e --test embed_missing_main_baseline`
Expected: PASS — `--emit` defaults to `exe`; `build_to` is reached
unchanged for the default path (Task-1 baseline pin + the floats E2E
both still green).
---
## Task 7: E2E host harness + DESIGN.md §"Embedding ABI (M1)"
**Files:**
- Create: `crates/ail/tests/embed/host.c`
- Create: `crates/ail/tests/embed_e2e.rs`
- Modify: `docs/DESIGN.md` (new §"Embedding ABI (M1)" after §"Mangling scheme", before §"Data model" at line 2263)
- [ ] **Step 1: Write the C host harness**
Create `crates/ail/tests/embed/host.c` (the spec headline host,
verbatim):
```c
#include <stdint.h>
#include <assert.h>
extern int64_t backtest_step(int64_t state, int64_t sample);
int main(void) {
int64_t s = 0;
s = backtest_step(s, 3); /* 0 + 9 */
s = backtest_step(s, 4); /* 9 + 16 */
assert(s == 25);
return 0;
}
```
- [ ] **Step 2: Write the E2E coherent-stop pin — verify RED**
Create `crates/ail/tests/embed_e2e.rs`:
```rust
//! Embedding-ABI M1 coherent-stop proof (spec §"Testing strategy" 5
//! + Acceptance criteria): build `examples/embed_backtest_step.ail`
//! as a staticlib, link the C host against `libembed_backtest_step.a`
//! + `libailang_rt.a`, run it, assert the `s == 25` assertion holds
//! (exit 0). This is the whole-milestone existence proof.
use std::path::{Path, PathBuf};
use std::process::Command;
fn ail_bin() -> &'static str { env!("CARGO_BIN_EXE_ail") }
fn manifest() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) }
fn ws_root() -> PathBuf {
manifest().parent().unwrap().parent().unwrap().to_path_buf()
}
#[test]
fn c_host_calls_exported_scalar_kernel() {
let fixture = ws_root().join("examples").join("embed_backtest_step.ail");
let host_c = manifest().join("tests").join("embed").join("host.c");
let outdir = std::env::temp_dir()
.join(format!("ail-embed-e2e-{}", std::process::id()));
std::fs::create_dir_all(&outdir).unwrap();
let build = Command::new(ail_bin())
.args(["build", fixture.to_str().unwrap(),
"--emit=staticlib", "-o", outdir.to_str().unwrap()])
.output().expect("ail build --emit=staticlib");
assert!(build.status.success(),
"staticlib build failed: {}",
String::from_utf8_lossy(&build.stderr));
let host_bin = outdir.join("host");
let cc = Command::new("cc")
.arg(&host_c)
.arg(outdir.join("libembed_backtest_step.a"))
.arg(outdir.join("libailang_rt.a"))
.arg("-o").arg(&host_bin)
.output().expect("cc host link");
assert!(cc.status.success(),
"cc link failed: {}", String::from_utf8_lossy(&cc.stderr));
let run = Command::new(&host_bin).output().expect("run host");
assert!(run.status.success(),
"host assertion `s == 25` failed (exit {:?}); stderr={}",
run.status.code(), String::from_utf8_lossy(&run.stderr));
}
```
Run: `cargo test -p ail --test embed_e2e`
Expected: FAIL until Tasks 5+6 are green (here they are; if run in
order this is the final GREEN — but it still RED-demonstrates: revert
Task 5's forwarder loop and the link is undefined-symbol). Then PASS.
- [ ] **Step 3: Run the E2E — verify GREEN**
Run: `cargo test -p ail --test embed_e2e`
Expected: PASS — `libembed_backtest_step.a` + `libailang_rt.a` link
with the C host; `backtest_step(0,3)=9`, `backtest_step(9,4)=25`;
assertion
holds; exit 0. This is the coherent stop.
- [ ] **Step 4: DESIGN.md §"Embedding ABI (M1)" — current-state honesty**
In `docs/DESIGN.md`, immediately after the §"Mangling scheme"
section ends (it begins at line 2206) and before the §"Data model"
header (line 2263), insert a new subsection at the same heading
level as §"Mangling scheme":
```markdown
### Embedding ABI (M1)
`ail build --emit=staticlib` compiles a module to a relocatable
`lib<entry>.a` (program objects only) plus a separate
`libailang_rt.a` (the RC runtime: `rc.c` + `str.c`), with **no**
`@main` trampoline and **no** `MissingEntryMain` requirement — a
kernel module is a library.
Each `fn` carrying `(export "<sym>")` (schema: `FnDef.export`) is
emitted as an externally-visible C entrypoint `@<sym>` forwarding to
the internal `@ail_<module>_<fn>`. The symbol is author-chosen and
decoupled from the `ail_<module>_<def>` mangling so a module/fn
rename does not move the C symbol.
The M1 ABI is **scalar-only and provisional until M3**: every
parameter and the return type must be `Int` (lowered `i64`) or
`Float` (lowered `double`), and the fn's effect set must be empty.
These are enforced at `ail check` (`export-non-scalar-signature`,
`export-has-effects`) — an effectful or non-scalar export *fails to
typecheck*. M1 ships no runtime lifecycle API: the per-thread
context parameter that M2 threads through every exported call will
change this C signature; do not treat the M1 signature as frozen.
The value/record layout freeze is M3.
```
- [ ] **Step 5: DESIGN.md honesty + drift gate**
Run: `cargo test -p ailang-core --test design_schema_drift --test spec_drift && bash bench/architect_sweeps.sh 2>&1 | tail -5`
Expected: drift tests PASS (the new prose subsection is honest
present-tense — it documents what now demonstrably works, per the
DESIGN.md current-state-mirror rule; the fn-JSON `"export"` line
landed in Task 2). `architect_sweeps.sh` residue unchanged modulo
the (advisory) new dated-free subsection — no new Wunschdenken /
post-mortem phrasing introduced (the "provisional until M3" /
"M2 will change" clauses are reserved-unimplemented labelling, which
the honesty rule explicitly permits when labelled).
- [ ] **Step 6: Full workspace + round-trip + bench-trio carry-on**
Run: `cargo test --workspace 2>&1 | tail -20`
Expected: 0 failures.
Run: `cargo test -p ail --test roundtrip_cli`
Expected: PASS (all new `examples/embed_*.ail` round-trip).
Run: `python3 bench/check.py; python3 bench/compile_check.py; python3 bench/cross_lang.py; echo "exit: $?"`
Expected: the default-executable codegen/runtime path is byte-
unchanged (the `target` param defaults every existing caller to
`Executable`); bench trio carry-on per spec Testing item 7. Any
`check.py` firing is adjudicated at milestone-close `audit` against
the tracked P2 `*.bump_s` / `*.max_us` known-noise items — not a
plan-level gate.
---
## Self-review (planner Step 5)
1. **Spec coverage.** Schema §1 → Task 2. Surface §2 → Task 3.
Check §3 → Task 4. Codegen §4 → Task 5. CLI §5 → Task 6. Host
harness §6 → Task 7. Testing item 0 → Task 1 (fixed first, per
spec §"Iteration sequencing"). Testing 1 (round-trip/hash) →
Tasks 2+3. Testing 2 (schema drift) → Task 2 Step 8. Testing 3
(check RED, 3 must-fail + positive) → Task 4. Testing 4 (codegen)
→ Task 5. Testing 5 (E2E) → Task 7. Testing 6 (clean-core
invariant) → architect at close (no new dep added by any task;
asserted by Task-7 Step-6 having no `Cargo.toml` edit anywhere in
this plan). Testing 7 (bench trio) → Task 7 Step 6. Acceptance
§1/§3 → the worked `examples/embed_backtest_step.ail` + the
must-fail fixtures (in code, not prose). Every spec section maps
to a task.
2. **Placeholder scan.** No "TBD"/"implement later"/"similar to". The
two `<<GOLDEN-PLACEHOLDER>>` (Task 2) and the `*.` Float-head /
exact-public-symbol notes are *deterministic capture-then-pin* or
*named cross-checks against a cited line*, not design holes — each
has an exact resolution step. The compile-driven enumeration
(Task 2 Step 5) is a deterministic loop with an exact terminating
gate (`cargo build --workspace` exits 0), not "fill in later".
3. **Type/name consistency.** `FnDef.export` /
`lower_workspace_staticlib_with_alloc` / `Target::StaticLib` /
`ExportNonScalarSignature` / `export-non-scalar-signature` /
`ExportHasEffects` / `export-has-effects` / `build_staticlib` /
`libembed_backtest_step.a` / `libailang_rt.a` / `backtest_step`
(the C symbol) / `@ail_embed_backtest_step_step` (internal) —
used identically across every task that references them. Module
== file stem `embed_backtest_step` ⇒ entry `embed_backtest_step`
`lib embed_backtest_step.a` = `libembed_backtest_step.a`
(Design-decision 6; the C symbol `backtest_step` is decoupled
and invariant per spec Decision 2).
4. **Step granularity.** Every step is one action ≤5 min; the only
long one (Task 2 Step 5, the ~95-site thread) is a single
compile-driven mechanical loop with a hard terminating gate — it
is one logical action, not a hidden multi-decision step.
5. **No commit steps.** None present.
6. **Pin/replacement substring contiguity.** Task 1 asserts
`contains("has no \`main\` def")` — that substring is the
*existing* `CodegenError::MissingEntryMain` Display
(`codegen lib.rs:129`), not produced by any verbatim replacement
in this plan; no soft-wrap split. Task 6's
`contains("at least one \`(export")` is matched against the
`build_staticlib` `anyhow::bail!` literal in Task 6 Step 4, which
contains `at least one \`(export "<sym>")\` fn` contiguously on
one line. Task 7 §"Embedding ABI (M1)" carries no presence-pin.
Clean.
7. **Compile-gate vs deferred-caller ordering.** Task 2 (the
`FnDef`-field add — crate-wide compile break) threads ALL ~95
caller sites inside the same task and only then runs the
`cargo build --workspace` 0-errors gate (Step 6). Task 5 (the
`lower_workspace_inner` signature change) updates all three
internal callers + the public wrapper inside the same task before
its `cargo test -p ailang-codegen` gate (Step 8). No signature
change defers a caller past its own compile gate.
8. **Verification-command filter strings resolve.** `cargo test -p
ail --test embed_missing_main_baseline` / `--test roundtrip_cli`
/ `--test embed_staticlib_cli` / `--test embed_e2e` / `--test
floats_e2e`, `cargo test -p ailang-check --test embed_export_gate`,
`cargo test -p ailang-codegen --test embed_staticlib_lowering`,
`cargo test -p ailang-core --test embed_export_hash_stable` /
`--test design_schema_drift` / `--test spec_drift` — each `--test
<name>` targets a file this plan **creates** (or an existing one:
`roundtrip_cli`, `floats_e2e`, `design_schema_drift`, `spec_drift`,
`hash_pin` verified present by recon). `cargo test -p
ailang-codegen missing_entry_main_is_error` is the verified
in-source test name (`lib.rs:3314`). No filter resolves to zero
tests masquerading as success.
Plan ready for `implement`.