All 176 files in the four accumulating directories now use a zero-padded 4-digit counter prefix that reflects creation order (`NNNN-slug.md`). The counter is assigned per directory in strict git-log creation order; ties broken alphabetically by original name. The old `YYYY-MM-DD-` prefix on docs/specs/ and docs/plans/ files is dropped — the date is recoverable from git log and the counter carries the ordering. A file's counter is stable for the life of the file: never reassigned, never reused, never compacted. Deleted files retire their counter; subsequent files do not fill the gap. This is the property that lets cross-references stay literal — refs use the full filename including the counter (`design/contracts/0007-honesty-rule.md`) so they grep cleanly and resolve directly without a glob step. 313 cross-references updated across .md/.rs/.toml/.c/.json files (test pins, include_str! paths, design-INDEX entries, baseline notes, runtime C comments, inter-contract markdown links incl. bare basename and `../models/foo.md` forms). CLAUDE.md gets a new "File-naming convention" section spelling out the rule and rationale. skills/brainstorm/SKILL.md and skills/planner/SKILL.md updated so new spec/plan creation produces counter-prefixed names from the start. The full test suite (cargo test --workspace) passes.
19 KiB
emit-ir --emit=staticlib — Implementation Plan
Parent spec:
docs/specs/0041-fieldtest-embedding-abi-m1.md(finding [spec_gap]#2 "No public path emits the staticlib IR, though DESIGN.md Decision 5 makes IR readability a first-class LLM affordance"). Orchestrator resolution recorded indocs/roadmap.mdP1 entry "[feature] ail emit-ir --emit=staticlib": add the flag (symmetric withail build, reuse the M1-shippedTarget::StaticLibpath) — NOT narrow DESIGN.md. Scoped feature, no design fork — this is NOT a milestone (nodocs/specs/<milestone>.md).For agentic workers: REQUIRED SUB-SKILL: use
skills/implementto run this plan. Steps use- [ ]checkboxes for tracking.
Goal: ail emit-ir <module> --emit=staticlib prints a
main-free kernel's LLVM IR (the external @<sym> forwarders)
instead of the executable-path MissingEntryMain rejection, so an
author can read the IR M1 introduced (Decision 5 affordance).
Architecture: Reuse the M1-shipped-and-audited Target::StaticLib
codegen path. Add a one-line symmetric codegen convenience
lower_workspace_staticlib(ws) (mirrors the existing
lower_workspace(ws): --emit=exe↔lower_workspace,
--emit=staticlib↔lower_workspace_staticlib); add an --emit
clap field to Cmd::EmitIr symmetric with Cmd::Build's; branch
the emit-ir match arm on it with a zero-export guard byte-identical
to build_staticlib's. Recon resolved the integration risk negative:
both codegen entrypoints return Result<String> and the emit-ir
printer already consumes a String — no adapter. DESIGN.md gets a
present-tense current-state mirror (§"Embedding ABI (M1)" + the CLI
synopsis, which pre-existingly omitted M1's already-shipped
build --emit=staticlib too). No new regression pin: the E2E test
pins the behaviour; the architect's mandatory DESIGN.md read at
milestone close catches stale prose. Decision-5 prose
(DESIGN.md:224-225) is already present-tense correct — untouched.
Tech Stack: crates/ailang-codegen/src/lib.rs (one pub fn),
crates/ail/src/main.rs (clap field + arm branch + guard),
crates/ail/tests/emit_ir_staticlib_cli.rs (new E2E), docs/DESIGN.md
(current-state mirror).
Files this plan creates or modifies
- Modify:
crates/ailang-codegen/src/lib.rs:275-279— add the symmetriclower_workspace_staticlibconvenience betweenlower_workspace(275-277) andfn lower_workspace_inner(279). - Modify:
crates/ail/src/main.rs:114-119— add theemitclap field to theCmd::EmitIrvariant. - Modify:
crates/ail/src/main.rs:640+:690— destructureemitin theCmd::EmitIrarm; branch the lowering call on it with the zero-export guard. - Create:
crates/ail/tests/emit_ir_staticlib_cli.rs— 3 E2E tests (positive forwarder-IR, zero-export guard, default-exe still requires main). - Modify:
docs/DESIGN.md:2301-2304— one-sentence §"Embedding ABI (M1)" mirror after the canonical snippet. - Modify:
docs/DESIGN.md:2609-2610— CLI synopsis: bothemit-irandbuildlines gain[--emit=staticlib](build's was a pre-existing M1 synopsis omission — current-state honest).
Files that do NOT change (verified by recon): the build-side
build_staticlib + its guard (main.rs:2467-2473) and
crates/ail/tests/embed_staticlib_cli.rs (the new guard reuses the
identical fixture examples/embed_noentry_baseline.ail and bail
string — no fixture minting); Decision 5 prose
(DESIGN.md:214-225); any codegen lowering logic (the
Target::StaticLib forwarder emission lib.rs:588-625 is M1-audited
and reused unchanged).
Task 1: codegen — symmetric lower_workspace_staticlib convenience
Files:
-
Modify:
crates/ailang-codegen/src/lib.rs:275-279 -
Step 1: Add the convenience fn
Replace, verbatim (lines 275-279 — the lower_workspace fn, the
blank line, and the fn lower_workspace_inner opener):
old:
pub fn lower_workspace(ws: &Workspace) -> Result<String> {
lower_workspace_inner(ws, AllocStrategy::Gc, Target::Executable)
}
fn lower_workspace_inner(ws: &Workspace, alloc: AllocStrategy, target: Target) -> Result<String> {
new:
pub fn lower_workspace(ws: &Workspace) -> Result<String> {
lower_workspace_inner(ws, AllocStrategy::Gc, Target::Executable)
}
/// Embedding-ABI M1 single-call entry point symmetric with
/// [`lower_workspace`]: lowers a [`Workspace`] for the static-library
/// target with the default `AllocStrategy::Gc`. This is what
/// `ail emit-ir --emit=staticlib` calls so an author can read a
/// `main`-free kernel's IR (the external `@<sym>` forwarders, no
/// `@main`) — the Decision-5 IR-readability affordance for the
/// artefact M1 introduced. Equivalent to
/// `lower_workspace_staticlib_with_alloc(ws, AllocStrategy::Gc)`.
pub fn lower_workspace_staticlib(ws: &Workspace) -> Result<String> {
lower_workspace_inner(ws, AllocStrategy::Gc, Target::StaticLib)
}
fn lower_workspace_inner(ws: &Workspace, alloc: AllocStrategy, target: Target) -> Result<String> {
- Step 2: Compile gate
Run: cargo build -p ailang-codegen 2>&1 | tail -3
Expected: builds, 0 errors (the new fn is pub, delegates to the
existing private lower_workspace_inner with the existing
Target::StaticLib — no signature change, no caller threading; a
new unused-within-crate pub fn is not dead-code-warned).
Task 2: CLI — --emit on emit-ir + staticlib branch + zero-export guard
Files:
- Create:
crates/ail/tests/emit_ir_staticlib_cli.rs - Modify:
crates/ail/src/main.rs:114-119,:640,:690
Compile-unit note (planner Step-5 item 7): Steps 3 and 4 are a single compile unit. After Step 3 adds the
emitfield to the variant, the Step-4 arm'sCmd::EmitIr { path, out }destructure is a non-exhaustive struct pattern (error[E0027]) until Step 4 addsemitto it. Do NOT runcargo build/cargo testbetween Step 3 and Step 4 — the next build/test gate is Step 5, after both edits land.
- Step 1: Write the RED-first E2E test file
Create crates/ail/tests/emit_ir_staticlib_cli.rs with exactly:
//! Embedding-ABI M1 (M1 fieldtest spec_gap#2): `ail emit-ir
//! --emit=staticlib` prints a `main`-free kernel's LLVM IR (the
//! external `@<sym>` forwarders, no `@main`) instead of the
//! executable-path `MissingEntryMain` rejection — the Decision-5
//! IR-readability affordance for the artefact M1 introduced.
use std::path::{Path, PathBuf};
use std::process::Command;
fn ail_bin() -> &'static str { env!("CARGO_BIN_EXE_ail") }
fn ws_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.parent().unwrap().parent().unwrap().to_path_buf()
}
#[test]
fn emit_ir_staticlib_prints_kernel_forwarder_no_main() {
let fixture = ws_root().join("examples").join("embed_backtest_step.ail");
let out = Command::new(ail_bin())
.args(["emit-ir", fixture.to_str().unwrap(), "--emit=staticlib"])
.output().expect("ail emit-ir --emit=staticlib");
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(out.status.success(),
"emit-ir --emit=staticlib on a main-free kernel must succeed; stderr={stderr}");
assert!(!stderr.contains("has no `main` def"),
"must NOT hit the executable-path MissingEntryMain rejection; stderr={stderr}");
assert!(stdout.contains("@backtest_step("),
"kernel IR must contain the external `(export \"backtest_step\")` forwarder; stdout={stdout}");
assert!(stdout.contains("@ail_embed_backtest_step_step"),
"kernel IR must contain the internal mangled symbol the forwarder calls; stdout={stdout}");
assert!(!stdout.contains("@main("),
"staticlib kernel IR must NOT contain an @main trampoline; stdout={stdout}");
}
#[test]
fn emit_ir_staticlib_requires_an_export() {
// embed_noentry_baseline has no `(export …)` fn — symmetric with
// build's zero-export guard (embed_staticlib_cli.rs).
let fixture = ws_root().join("examples").join("embed_noentry_baseline.ail");
let out = Command::new(ail_bin())
.args(["emit-ir", fixture.to_str().unwrap(), "--emit=staticlib"])
.output().expect("ail emit-ir --emit=staticlib");
assert!(!out.status.success(),
"emit-ir --emit=staticlib with zero exports must fail");
assert!(String::from_utf8_lossy(&out.stderr)
.contains("at least one `(export"),
"expected the zero-export staticlib error; got {}",
String::from_utf8_lossy(&out.stderr));
}
#[test]
fn emit_ir_default_exe_still_requires_main() {
// Regression: --emit defaults to "exe"; emit-ir on a main-free
// module WITHOUT --emit=staticlib must still hit MissingEntryMain
// (the new staticlib branch must not alter the default path).
let fixture = ws_root().join("examples").join("embed_backtest_step.ail");
let out = Command::new(ail_bin())
.args(["emit-ir", fixture.to_str().unwrap()])
.output().expect("ail emit-ir");
assert!(!out.status.success(),
"default-exe emit-ir on a main-free kernel must still fail");
assert!(String::from_utf8_lossy(&out.stderr)
.contains("has no `main` def"),
"default path must still surface MissingEntryMain; got {}",
String::from_utf8_lossy(&out.stderr));
}
- Step 2: Run the new tests — RED
Run: cargo test -p ail --test emit_ir_staticlib_cli 2>&1 | tail -8
Expected: FAIL. emit_ir_staticlib_prints_kernel_forwarder_no_main
and emit_ir_staticlib_requires_an_export FAIL because ail emit-ir
has no --emit flag yet — clap rejects --emit=staticlib
("unexpected argument '--emit=staticlib'", non-zero exit), so
out.status.success() is false / the guard message is absent.
emit_ir_default_exe_still_requires_main PASSES (that is the current
behaviour — the spec_gap itself: default-exe emit-ir on a main-free
kernel already fails with has no \main` def`). Net: "2 failed; 1
passed". This is the RED.
- Step 3: Add the
emitclap field toCmd::EmitIr
Replace, verbatim (crates/ail/src/main.rs:114-119):
old:
/// Writes LLVM IR (.ll) for the module.
EmitIr {
path: PathBuf,
#[arg(short, long)]
out: Option<PathBuf>,
},
new:
/// Writes LLVM IR (.ll) for the module.
EmitIr {
path: PathBuf,
#[arg(short, long)]
out: Option<PathBuf>,
/// IR shape. `exe` (default): whole-program IR (requires an
/// entry `main`). `staticlib`: kernel IR for a `main`-free
/// module — one external C forwarder per `(export "<sym>")`
/// fn, no `@main` (Embedding ABI M1). Same `Target` the
/// `build --emit=staticlib` path uses; output is the IR text
/// (stdout, or `-o <file>`), not archives.
#[arg(long, default_value = "exe", value_parser = ["exe", "staticlib"])]
emit: String,
},
- Step 4: Destructure
emit+ branch the lowering call
Two verbatim replacements in the Cmd::EmitIr match arm. (Do not
run cargo between Step 3 and the end of Step 4 — see the
compile-unit note above.)
4a. The destructure line (crates/ail/src/main.rs:640):
old:
Cmd::EmitIr { path, out } => {
new:
Cmd::EmitIr { path, out, emit } => {
4b. The lowering call (crates/ail/src/main.rs:690):
old:
let ir = ailang_codegen::lower_workspace(&ws)?;
new:
let ir = if emit == "staticlib" {
let has_export = ws.modules.values().any(|m| m.defs.iter().any(|d|
matches!(d, ailang_core::Def::Fn(f) if f.export.is_some())));
if !has_export {
anyhow::bail!(
"staticlib target needs at least one `(export \"<sym>\")` fn"
);
}
ailang_codegen::lower_workspace_staticlib(&ws)?
} else {
ailang_codegen::lower_workspace(&ws)?
};
(The has_export predicate + anyhow::bail! string are
byte-identical to build_staticlib's guard at main.rs:2467-2473,
so the zero-export error message is symmetric across build and
emit-ir and the existing embed_staticlib_cli.rs:54 substring
at least one \(export` matches both paths.)
- Step 5: Build + run all gates — GREEN
Run: cargo build -p ail 2>&1 | tail -3
Expected: builds, 0 errors (variant field + arm destructure both
landed; lower_workspace_staticlib exists from Task 1).
Run: cargo test -p ail --test emit_ir_staticlib_cli 2>&1 | tail -6
Expected: PASS, "test result: ok. 3 passed; 0 failed".
Run: cargo test -p ail --test embed_staticlib_cli 2>&1 | tail -4
Expected: PASS, all build-side staticlib tests still green
(staticlib_emit_produces_two_archives,
staticlib_emit_requires_an_export, and any others in that file) —
the build path + its guard are untouched; this confirms symmetry,
not a regression risk.
Task 3: DESIGN.md — present-tense current-state mirror
Files:
-
Modify:
docs/DESIGN.md:2301-2304,:2609-2610 -
Step 1: §"Embedding ABI (M1)" — one sentence after the snippet
Replace, verbatim (docs/DESIGN.md:2301-2304 — the snippet's last
line, its closing fence, the blank line, and the ## Data model
heading):
old:
(app + state (app * sample sample))))
Data model
new:
(app + state (app * sample sample))))
`ail emit-ir <module> --emit=staticlib` prints this kernel's LLVM
IR (the external `@<sym>` forwarders, no `@main`) instead of the
executable-path `main`-required rejection — the Decision-5
IR-readability affordance for a `main`-free kernel.
## Data model
- Step 2: CLI synopsis — both lines gain the M1 flag
Replace, verbatim (docs/DESIGN.md:2609-2610):
old:
ail emit-ir <module> — writes .ll
ail build <module> — full pipeline → binary
new:
ail emit-ir <module> [--emit=staticlib] — writes .ll (staticlib: a main-free kernel's IR, no @main)
ail build <module> [--emit=staticlib] — full pipeline → binary (staticlib: lib<entry>.a + libailang_rt.a)
(The build line gains [--emit=staticlib] too: it shipped in M1
and the synopsis never reflected it — this corrects a pre-existing
current-state-honesty gap in the same edit, consistent with the
docs-as-current-state-mirror discipline.)
- Step 3: Confirm no doc-pin regressed
Run: cargo test -p ailang-core --test docs_honesty_pin 2>&1 | tail -3
Expected: PASS, "5 passed; 0 failed" — the docs_honesty_pin
suite (incl. the form-a carve-out pin) pins specific Wunschdenken/
present-tense anchors, none of which these two additive
present-tense edits touch. (This is a no-regression check, not a new
pin — per the architecture decision, this feature ships no doc pin.)
Task 4: Regression gate
Files: (none — verification only)
- Step 1: Full
ailcrate suite
Run: cargo test -p ail 2>&1 | grep -E "^test result:" | awk '{p+=$4; f+=$6} END {print "ail TOTAL: "p" passed, "f" failed"}'
Expected: ail TOTAL: <N> passed, 0 failed. The delta vs. pre-iter
is +3 (the new emit_ir_staticlib_cli.rs tests); every
pre-existing ail test (incl. embed_staticlib_cli,
embed_e2e, embed_missing_main_baseline, e2e, ir_snapshot)
still green.
- Step 2: Workspace-wide no-regression assertion
Run: cargo test --workspace 2>&1 | grep -E "test result:" | awk '{p+=$4} END {print "TOTAL_PASSED="p}'
Expected: TOTAL_PASSED= a number ≥ the pre-iter main-HEAD
count + 3 (the only new tests are the 3 in
emit_ir_staticlib_cli.rs; the codegen convenience fn and the CLI
branch add behaviour exercised by those 3, no other suite loses a
test). A total below pre-iter + 3 means a pre-existing test broke
— STOP and investigate. (Unfiltered suite + explicit count per
planner Step-5 item 8.)
- Step 3: Scope check — only the expected files changed
Run: git diff --stat 2>&1
Expected: exactly these modified/created paths (plus the
implement-skill journal + stats files): crates/ailang-codegen/src/lib.rs,
crates/ail/src/main.rs, crates/ail/tests/emit_ir_staticlib_cli.rs
(new), docs/DESIGN.md. No diff under crates/ailang-check,
crates/ailang-surface, crates/ailang-core, runtime/, or any
examples/*.ail (the new tests reuse existing public fixtures
embed_backtest_step.ail / embed_noentry_baseline.ail — no
fixture minting). Any path outside this set is out of scope — STOP.
Self-review (planner Step 5)
- Spec coverage: [spec_gap]#2 resolved — the
--emit=staticlibflag (Task 2) routes through the M1Target::StaticLibpath (Task 1's symmetric convenience), with a symmetric zero-export guard (Task 2 Step 4b) and the DESIGN.md current-state mirror (Task 3). Roadmap resolution ("add the flag, do not narrow DESIGN.md") honoured: DESIGN.md is widened to state the affordance, never narrowed. ✔ - Placeholder scan: no "TBD/TODO/implement later/similar to/add appropriate". Every code/text edit is given verbatim. ✔
- Type/name consistency:
lower_workspace_staticlibnamed identically in Task 1 Step 1 and Task 2 Step 4b; clap fieldemit: StringmatchesCmd::Build's; test fn names (emit_ir_staticlib_prints_kernel_forwarder_no_main,emit_ir_staticlib_requires_an_export,emit_ir_default_exe_still_requires_main) consistent across Task 2 + Task 4. Fixturesembed_backtest_step.ail/embed_noentry_baseline.ailare recon-verified existing paths. ✔ - Step granularity: each step is one verbatim replacement, one file create, or one command — 2-5 min. ✔
- No commit steps: none. ✔
- Pin/replacement substring contiguity: N/A — this feature
adds no presence-pin doc test (architecture decision: the
E2E pins behaviour, no
docs_honesty_pinentry). The E2E's IR substrings (@backtest_step(,@ail_embed_backtest_step_step,@main() are M1-audited codegen output (forwarder emissionlib.rs:588-625), not a verbatim-text-edit pair in this plan, so the "two authoritative artefacts" failure mode does not arise. Task 3 Step 3 is a no-regression check against the existing pin, not a new pin. ✔ - Compile-gate vs. deferred-caller ordering: Task 1 adds a new
pub fn(no signature change, no caller threading — its compile gate is satisfiable standalone). Task 2's only crate-wide compile coupling is the clap-variant field (Step 3) ↔ its single match arm destructure (Step 4a): both are inside Task 2, the compile/test gate is Step 5 after both, and an explicit compile-unit note forbids running cargo between Step 3 and Step 4 (theerror[E0027]window). Task 2's only caller oflower_workspace_staticlibis the arm itself; Task 1 lands the callee first. No deferred-caller contradiction. ✔ - Verification-command filter strings resolve:
--test emit_ir_staticlib_cliis the file Task 2 Step 1 creates;--test embed_staticlib_cliis recon-confirmed existing;--test docs_honesty_pinis the existing pin file (5 tests). Task 4 Steps 1-2 use the unfiltered-p ail/--workspacesuites with explicit pass-count assertions so "0 ran" cannot read as "0 regressed". No filter guessed from a feature word. ✔