818177d835
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.
1623 lines
64 KiB
Markdown
1623 lines
64 KiB
Markdown
# 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:194–221` — 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:2290–2299` — 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:68–92` — add `(export STRING)?` production + prose (Task 3).
|
||
- Modify: `crates/ailang-surface/src/parse.rs:438–461,477–552` — `parse_export` helper + `parse_fn` modifier arm + unknown-attr string + `Ok(FnDef{…})` (Task 3).
|
||
- Modify: `crates/ailang-surface/src/print.rs:148–166` — `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,419–431,554–558` — `Target` enum, threaded param, gated regions, forwarder loop, new public wrapper (Task 5).
|
||
- Modify: `crates/ail/src/main.rs:121–140,691–693,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:2239–2294` 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 52–59/167–169) 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:194–221`
|
||
- Modify: ~95 `FnDef { … }` literal sites (compile-driven; 18 files)
|
||
- Modify: `docs/DESIGN.md:2290–2299`
|
||
- Modify: `crates/ailang-core/tests/design_schema_drift.rs:278–285`
|
||
- 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 2290–2299), 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:278–285` (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
|
||
`:389–394` 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:438–461,477–552`
|
||
- Modify: `crates/ailang-surface/src/print.rs:148–166`
|
||
- Modify: `crates/ailang-core/specs/form_a.md:68–92`
|
||
- 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:515–524`). 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 519–521) 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 544–551) 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 70–77) 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 79–80) 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:445–446,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:2239–2240`
|
||
> 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 1861–1863); `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 ¶m_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:2804–2806` 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,223–225,252–256,419–431,554–558`
|
||
- 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 419–431, 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
|
||
554–558 — 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:1087–1092`): 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:121–140,691–693,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
|
||
691–693), 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 2239–2294), 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:2239–2294 ----
|
||
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:2239–2294` (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`.
|