fix(check): honour the written element type-arg on polymorphic (new T …)

A `(new RawBuf (con Int) 3)` whose buffer is never observed by a later
get/set passed `ail check` but crashed `ail build` with
`RawBuf_new__Unit has no registered intercept`, and a forbidden
`(new RawBuf (con Unit) 3)` was wrongly accepted at check. Both legs
share one root: the `Term::New` desugar discarded the author's written
`NewArg::Type` before anything could use it.

This was never an inference problem. The element type is EXPLICITLY
known — the author wrote `(con Int)`. The defect was that the desugar
threw that known type away and relied on re-discovering it from
downstream use; with no use (size-only), `RawBuf.new`'s result-only
forall var stayed unpinned, mono Unit-defaulted it, and codegen aborted
on the unregistered `RawBuf_new__Unit` intercept. The fix USES the
written type instead of inferring it.

Mechanism:
- desugar.rs: a polymorphic `(new T <elem> …)` (carrying a written
  `NewArg::Type`) now SURVIVES into check instead of being lowered to
  `(app T.new …)`. A monomorphic `(new Counter 42)` (no type-arg) keeps
  the raw-buf.4 app-lowering unchanged.
- lib.rs synth `Term::New` arm: (a) validates the written element type
  against the constructed type's `param_in` set via
  `check_type_well_formed`, firing `ParamNotInRestrictedSet` naming the
  forbidden element (leg 2) — synth is the correct site because it has
  Env/registry access, unlike `pre_desugar_validation`; (b) binds the
  result-only forall var DIRECTLY to the written type (`?a := Int`, a
  trivial binding, no inference channel) by pushing a `FreeFnCall` whose
  metas are the written concrete types, so mono mints
  `RawBuf_new__<elem>` and never `__Unit`.
- mono.rs: `rewrite_mono_calls` and `interleave_slots` gain matching
  `Term::New` arms that each consume exactly one free-fn slot at a
  type-arg-bearing `Term::New` (mirroring synth's single observation,
  pushed before the value-args), and `rewrite_mono_calls` reduces the
  node to `(app <mangled> <value-args>)` so codegen never sees
  `Term::New` and the lib.rs:2200 `unreachable!` stays valid. The two
  walkers stay in lockstep.

The Unit-default policy in mono stays correct for genuinely-unobservable
vars (`is_empty(Nil)` etc.); only the case with a written `NewArg::Type`
is changed.

Alternatives rejected: an additive `type_args` field on `Term::App`
(~100 construction sites across 7 crates — a detour that builds a new
transport slot only to copy the already-known type into it); a runtime
type-witness parameter on `RawBuf.new` (invents a runtime value for a
pure type property); an identity-lambda wrapper carrying the element in
its param-type (hides a semantic property in a runtime construct). The
written type belongs where the author put it, carried to the one point
that consumes it.

Verification: both RED legs (committed 61b9d3f) now green; full
check/codegen/core/surface suites green; intercepts bijection and
hash-pin tests green; existing raw-buf int/float/bool fixtures still
build and run leak-clean (live=0).

Out of scope (pre-existing at HEAD, separate issue): a monomorphic
`(new Counter 42)` over a user ADT fails with `unknown variable:
Counter.new` — unrelated to the type-arg drop fixed here.

closes #51
This commit is contained in:
2026-05-31 03:03:14 +02:00
parent 61b9d3f513
commit ee4107c721
3 changed files with 163 additions and 37 deletions
+44 -23
View File
@@ -1051,29 +1051,50 @@ impl Desugarer {
in_full
}
Term::New { type_name, args } => {
// raw-buf.4: (new T <types…> <values…>) desugars to
// (app T.new <values…>) — a type-scoped call so synth's
// dotted-name branch resolves it through the
// TypeDef-first ladder and the raw-buf.3 scope threading
// mints `T_new__<elem>`. The leading NewArg::Type is
// dropped: the AST carries no type-args on App, and the
// element type is recovered by ordinary inference from
// how the result is used (spec § Term::New desugar).
// Runs before check, so no Term::New reaches the
// type-checker or codegen.
let value_args: Vec<Term> = args
.iter()
.filter_map(|arg| match arg {
NewArg::Value(v) => Some(self.desugar_term(v, scope)),
NewArg::Type(_) => None,
})
.collect();
Term::App {
callee: Box::new(Term::Var {
name: format!("{type_name}.new"),
}),
args: value_args,
tail: false,
// #51: honour the written element type-arg. When the
// `new` is polymorphic (the call carries a leading
// `NewArg::Type`, e.g. `(new RawBuf (con Int) 3)`), the
// written type is the binding declaration of the result's
// forall var — it must NOT be discarded. We keep
// `Term::New` surviving into synth, which binds that var
// directly to the written type (`?a := Int`) and pushes
// the matching mono observation so codegen mints
// `RawBuf_new__Int` (and enforces `param-in` on the
// written type). The value-args are desugared in place.
//
// When the `new` is monomorphic (no `NewArg::Type`, e.g.
// `(new Counter 42)`) there is no var to bind, so we keep
// the raw-buf.4 lowering to `(app T.new <values…>)` — a
// type-scoped call that synth's dotted-name branch
// resolves through the TypeDef-first ladder.
let has_type_arg = args.iter().any(|a| matches!(a, NewArg::Type(_)));
if has_type_arg {
let desugared_args: Vec<NewArg> = args
.iter()
.map(|arg| match arg {
NewArg::Value(v) => NewArg::Value(self.desugar_term(v, scope)),
NewArg::Type(ty) => NewArg::Type(ty.clone()),
})
.collect();
Term::New {
type_name: type_name.clone(),
args: desugared_args,
}
} else {
let value_args: Vec<Term> = args
.iter()
.filter_map(|arg| match arg {
NewArg::Value(v) => Some(self.desugar_term(v, scope)),
NewArg::Type(_) => None,
})
.collect();
Term::App {
callee: Box::new(Term::Var {
name: format!("{type_name}.new"),
}),
args: value_args,
tail: false,
}
}
}
Term::Intrinsic => t.clone(),