iter embedding-abi-m1.1 (DONE 7/7): Embedding-ABI M1 — scalar AILang fn callable from C/Rust

Tasks 4-7 on the Boss-Repaired plan, completing the M1 iteration:

- Task 4: check-side export-signature gate — CheckError variants
  ExportNonScalarSignature / ExportHasEffects (codes
  export-non-scalar-signature / export-has-effects), code() arms,
  check_fn gate (params->ret->effects, first-violation-wins,
  unconditional on f.export.is_some()). The clause-3 discriminator
  in code: effectful/non-scalar exports fail to typecheck. 6/6.
- Task 5: codegen Target::StaticLib — Target enum, threaded param,
  @main/MissingEntryMain gated behind Target::Executable, one
  external @<sym> forwarder per export to @ail_<module>_<fn>
  (fn_scalar_sig/llvm_scalar). In-source missing_entry_main_is_error
  byte-unchanged. Lowering pin 2/2.
- Task 6: CLI ail build --emit=staticlib — clap field, Cmd::Build
  branch, build_staticlib (verbatim build_to prefix + has_export
  guard + two-archive clang -c/ar rcs tail) + run_cmd. CLI pin 2/2.
- Task 7: C-host E2E coherent-stop proof (cc links
  libembed_backtest_step.a + libailang_rt.a, backtest_step(0,3)=9 /
  (9,4)=25, s==25, exit 0) + DESIGN.md §"Embedding ABI (M1)"
  (scalar C ABI, provisional until M3). E2E pin 1/1.

Independent Boss verification: E2E 1/1, gate 6/6, full workspace
0 failures, roundtrip_cli + design_schema_drift + spec_drift green,
Invariant 1 holds (no new dep in core/codegen/runtime). 3 behaviour-
neutral plan pseudo-vs-reality concerns in the journal. Default-exe
codegen/runtime path byte-unchanged (check.py firing = tracked-P2
known-noise, audit-adjudicated). INDEX line added.

Completes the M1 iteration (impl). Milestone-close audit + fieldtest
(surface-touching) are the next pipeline steps.
This commit is contained in:
2026-05-18 14:50:27 +02:00
parent 818177d835
commit e406d07d81
17 changed files with 804 additions and 81 deletions
+120 -3
View File
@@ -137,6 +137,14 @@ enum Cmd {
/// every allocation by design.
#[arg(long, default_value = "rc", value_parser = ["gc", "bump", "rc"])]
alloc: String,
/// 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,
},
/// Build into a tempdir and execute. Exits with the binary's exit code.
/// Convenience wrapper around `build` + invocation of the resulting
@@ -688,10 +696,15 @@ fn main() -> Result<()> {
None => print!("{ir}"),
}
}
Cmd::Build { path, out, opt, alloc } => {
Cmd::Build { path, out, opt, alloc, emit } => {
let strategy = parse_alloc_strategy(&alloc)?;
let bin = build_to(&path, out, &opt, strategy)?;
eprintln!("built {}", bin.display());
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());
}
}
Cmd::Run { path, opt, alloc, args } => {
// Iter 9b: build into a fresh tempdir per run, exec, propagate
@@ -2404,6 +2417,110 @@ fn build_to(
Ok(out_bin)
}
/// 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 → libailang_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(())
}
/// ct.1 migration helper: rewrite bare cross-module Type::Con names
/// and Term::Ctor.type_name to their qualified form. Sets
/// `*changed` to `true` if any rewrite happened. Uses the