feat(check): mir.1a — typed-MIR types + post-mono lower_to_mir + elaborate_workspace (additive)

First half of spec iteration mir.1 (docs/specs/0060-typed-mir.md,
plan docs/plans/0114-mir.1a-mir-infrastructure.md). Purely additive: a
new leaf crate plus a post-mono lowering walk plus the front-end entry.
Nothing consumes MIR yet — codegen and the CLI are untouched — so the
whole existing suite stays green (102 test-result-ok lines, 0 failed;
the one ignored is the #49 pin, which lifts at mir.4). mir.1b flips
codegen to consume MIR and deletes the synth_with_extras mirror.

What lands:
- `ailang-mir` (new leaf crate, depends only on ailang-core): MTerm /
  MArg / MArm / MLoopBinder / MNewArg / Callee / Mode / StrRep / MirDef
  / MirModule / MirWorkspace, structurally complete over all 17 Term
  variants plus the dedicated Str split (MTerm::Str{lit,rep}). Every
  later-iteration annotation field exists now at a mir.1 default
  (App.callee = Indirect, MArg.mode/consume_count = Owned/1, Str.rep =
  Static, New.elem = None); the struct shape will not change across
  mir.2–mir.5, only the values. MTerm::ty() reads the carried type.
- `ailang-check::lower_to_mir`: the post-mono walk. Carries no second
  type engine — it calls the canonical `synth` per node with fresh
  throwaway inference scaffolding (subst/counter/residuals/…), applies
  the substitution, and threads only the lexical locals/loop_stack,
  maintained by the same push/pop rules synth uses (Let, Loop, Lam,
  LetRec, and Match via synth's own type_check_pattern). This is the
  "one engine, not two" property the milestone buys, on the producer
  side.
- `ailang-check::elaborate_workspace`: the single front-end entry —
  check (diagnostics gate) → desugar+lift → monomorphise → lower_to_mir
  — transcribed from the CLI build path. check_workspace stays for the
  diagnostics-only callers.
- Three ty-fill pins (crates/ailang-check/tests/lower_to_mir_ty.rs)
  load the #51/#53/#49 witnesses as fixtures and elaborate the whole
  workspace, so lower_to_mir is exercised over the full prelude+kernel,
  not just the tiny witness. Two new witness fixtures
  (examples/new_{rawbuf_size_only,counter_user_adt}.ail); #49 reuses the
  existing loop_recur_str_binder_no_leak_pin.ail.

Deviations from the plan, all forced by ground truth and verified
against the live code before commit:
- FnDef.export is Option<String>, not bool — dropped from MirDef (the
  plan's note allowed this fallback). Codegen reads FnDef.export
  directly, unaffected.
- fn_param_types did not exist — lifted the param-type extraction into
  a shared pub(crate) fn and rewired both mono.rs sites
  (collect_mono_targets, collect_rewrite_targets) to it, so the
  param-type source cannot drift between mono and lower_to_mir. The
  helper unwraps a top-level Forall then reads Type::Fn.params —
  identical to the inner_ty extraction it replaces; the early-return
  non-fn guard is preserved at both sites. Behaviour-preserving (whole
  suite green; this is the only edit to existing mono behaviour).
- lower_module mirrors two more mono.rs scaffolding rules the plan
  under-transcribed: skip intrinsic-bodied fns (they are
  signature-only; lowering them would hit synth's Term::Intrinsic
  guard) and seed env.current_module + env.globals + env.imports per
  module so synth's Var lookup resolves post-mono specialisations
  (e.g. compare__Int) in the prelude.
- The #53 pin asserts the lowered (new Counter 42) init carries ty
  Counter, not that it is an MTerm::New: a monomorphic `new` over a
  user ADT is desugared to a dotted call `(app Counter.new 42)` before
  lower_to_mir runs (desugar.rs:1078-1098), so it lowers to MTerm::App.
  MTerm::New survives only for a polymorphic `new` carrying a written
  NewArg::Type (the #51 RawBuf path, covered by the rawbuf pin). The
  pin's intent — ty-fill correctness — is preserved; the structural
  variant is mir.2/mir.5 territory.
- ailang-mir uses workspace-inherited Cargo fields for consistency with
  the existing crates (added it to [workspace.dependencies]).

refs #49
This commit is contained in:
2026-05-31 14:04:03 +02:00
parent 438a009b83
commit 135f4ceed7
11 changed files with 712 additions and 11 deletions
@@ -0,0 +1,99 @@
//! mir.1a: `lower_to_mir` fills `ty` on every node from the canonical
//! `synth`, and routes string literals to `MTerm::Str { rep: Static }`.
//! Loads the boundary witnesses as workspace fixtures (prelude +
//! kernel injected by `load_workspace`) and elaborates the whole
//! workspace, so the walk is exercised over the full prelude. These
//! pins protect the producer; codegen consumption arrives in mir.1b.
use ailang_check::elaborate_workspace;
use ailang_mir::{MTerm, MirWorkspace, StrRep};
use ailang_surface::load_workspace;
use std::path::{Path, PathBuf};
/// `<repo>/examples` — resolved from this crate's manifest dir,
/// cwd-independent (same pattern as `method_collision_pin.rs`).
fn examples_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.parent().expect("crates/ailang-check has parent crates/")
.parent().expect("crates/ has parent repo root")
.join("examples")
}
/// Load a witness fixture (prelude injected) and elaborate it to MIR.
fn elaborate_fixture(module: &str) -> MirWorkspace {
let entry = examples_dir().join(format!("{module}.ail"));
let ws = load_workspace(&entry).expect("fixture loads (prelude injected)");
elaborate_workspace(&ws).expect("witness elaborates to MIR")
}
/// A def's lowered body in the elaborated workspace.
fn body<'a>(mir: &'a MirWorkspace, module: &str, def: &str) -> &'a MTerm {
&mir.modules[module]
.defs
.iter()
.find(|d| d.name == def)
.expect("def present")
.body
}
#[test]
fn str_literal_lowers_to_static_str_node() {
// #49 witness: the loop seed "x" is a Str literal → MTerm::Str,
// rep = Static at mir.1 (mir.4 flips loop seeds to Heap).
let m = "loop_recur_str_binder_no_leak_pin";
let mir = elaborate_fixture(m);
assert!(
find_static_str_seed(body(&mir, m, "main")),
"loop seed \"x\" must lower to MTerm::Str {{ rep: Static }}"
);
}
#[test]
fn new_over_user_adt_carries_node_types() {
// #53 witness: every node has a filled `ty`; the lowered
// `(new Counter 42)` node's type is the user ADT `Counter`.
//
// A *monomorphic* `new` (no leading type-arg) is desugared to a
// type-scoped call `(app Counter.new 42)` before lower_to_mir runs
// (desugar.rs:1083-1098) — so the let-init lowers to `MTerm::App`,
// not `MTerm::New`. `MTerm::New` survives only for a *polymorphic*
// `new` carrying a written `NewArg::Type` (the #51 RawBuf case).
// What this pin protects is the ty-fill: the lowered init node
// carries the checker-proved result type `Counter`.
let m = "new_counter_user_adt";
let mir = elaborate_fixture(m);
let MTerm::Let { init, .. } = body(&mir, m, "main") else {
panic!("main body is a let");
};
let ty_str = ailang_core::pretty::type_to_string(&init.ty());
assert!(
ty_str.contains("Counter"),
"lowered (new Counter 42) init node ty is Counter, got {ty_str}"
);
}
#[test]
fn rawbuf_size_only_elaborates() {
// #51 witness: a RawBuf read only for size — must elaborate clean
// (the element type carried only by the author's annotation does
// not block lowering).
let mir = elaborate_fixture("new_rawbuf_size_only");
assert!(mir.modules.contains_key("new_rawbuf_size_only"));
}
/// Recursively search for a `MTerm::Str { rep: Static }` reachable
/// from a loop binder init (the seed).
fn find_static_str_seed(t: &MTerm) -> bool {
match t {
MTerm::Loop { binders, body, .. } => {
binders.iter().any(|b| matches!(
&b.init,
MTerm::Str { rep: StrRep::Static, .. }
)) || find_static_str_seed(body)
}
MTerm::Let { init, body, .. } => {
find_static_str_seed(init) || find_static_str_seed(body)
}
_ => false,
}
}