b586999e81
First iteration of the kernel-extension-mechanics milestone. Ships
the type-scoped `<TypeName>.<member>` resolution path as the
canonical form for type-associated operations, narrows the
`BareCrossModuleTypeRef` / `BadCrossModuleTypeRef` diagnostics from
"bare = strictly local" to "bare = in-scope by any path", migrates
12 std-library example fixtures, and introduces a workspace-wide
normalisation pre-pass `prepare_workspace_for_check` shared between
`check_workspace` and `monomorphise_workspace`.
Architectural discovery during implementation: the plan covered the
`Term::Var` dot-qualified resolver layer plus the workspace
validator's bare-name acceptance, but the migration of bare-form
fixtures exposed five sites where bare vs. qualified type-names
needed symmetric treatment — `Term::Ctor` resolution, `Type::Con`
well-formedness, mono's poly-free-fn name/constraint-count
enumeration, codegen's `lookup_ctor_by_type` bare-name path, and
the upstream desugar-then-qualify composition. Rather than
scattering TypeDef-first ladders across each site, the implementer
centralised the work into one pre-pass that walks every consumer
module's `Type::Con.name` and `Term::Ctor.type_name`, rewriting
bare cross-module references to their qualified `<home>.<Type>`
form. This is symmetric to the pre-existing `qualify_local_types`
(owner-side); the new pre-pass is the consumer-side mirror.
Downstream passes see qualified Types regardless of authoring form.
The TypeDef-first ladder still lives in `synth`'s `Term::Var` arm
because `<TypeName>.<member>` is term-position-only — `Maybe.from_maybe`
is a Var, not a Type expression, and the pre-pass does not rewrite
Var names.
Alternatives considered:
(a) Add TypeDef-first ladder at every resolution site separately
(the plan's implicit assumption). Rejected: O(N) extension
sites, each carrying the same workspace-walking logic; the
pre-pass version is O(1) — one pass, every downstream consumer
benefits.
(b) BLOCKED + spec re-brainstorm. Rejected: the architecture
extension is consistent with prep.1's thesis (bare type-name
resolves to the workspace-wide TypeDef) and forward-compatible
with prep.2 (Term::New.type_name falls under the same rewrite)
and prep.3 (kernel-tier TypeDefs enter the workspace map
automatically). No design regression to bounce back over.
Spec updated to document the realisation mechanism honestly: the
"Realisation mechanism — workspace pre-pass" subsection clarifies
that the resolver-level semantics described in "Implementation
shape" are the user-facing contract, and the actual code path is
the pre-pass.
Verification:
- `cargo test --workspace`: ALL GREEN. 87 e2e + every crate's unit
+ integration tests pass with no regressions.
- Three NEW in-source tests pin Task 1's resolver paths:
`type_scoped_member_resolves`, `type_scoped_member_not_found`,
`type_scoped_receiver_not_a_type`.
- One NEW workspace test pins the narrowed validator:
`ct1_validator_accepts_bare_with_explicit_import`.
- One renamed-and-flipped existing test:
`ct1_validator_rejects_bare_xmod_with_import_candidate` →
`ct1_validator_accepts_bare_xmod_with_import_candidate` (the
bare-with-import path is now ACCEPTED).
- One NEW companion test for the workspace-wide ctor lookup:
`ct2_term_ctor_bare_cross_module_via_workspace_resolves`.
- Two pre-existing tests' assertions updated for the new error
wording: `ct1_check_cli::check_human_mode_emits_actionable_message_to_stderr`
and `crates/ailang-check/tests/workspace.rs::unknown_module_prefix_is_reported`.
- 12 migrated `.ail` fixtures verified via the existing e2e
suite (each fixture is the test runner's target for an existing
`build_and_run` assertion).
- Negative fixture `ct_2_bare_cross_module.ail` semantically
preserved: dropped its `(import std_maybe)` so bare `Maybe` is
out-of-scope under the narrowed rule and still fires
`BareCrossModuleTypeRef`.
Concerns:
- The pre-pass introduces a new architectural layer (consumer-side
qualification) that the spec did not originally anticipate. Spec
amendment in this commit documents the layer. Future iterations
reference `prepare_workspace_for_check` as established
infrastructure.
- `examples/test_ct1_bare_xmod_rejected.ail.json` switched its
offending name from bare `Ordering` (which under the prep.1
semantics may now resolve via implicit prelude) to a still-
unresolvable `Mystery_Type`. The CLI test's intent (assert that
a human-mode `ail check` exits non-zero on a still-RED case) is
preserved.
Milestone status: kernel-extension-mechanics (Gitea #6) advances
1/3 iters. Next: prep.2 (`Term::New` construct) issue #32.
756 lines
32 KiB
Markdown
756 lines
32 KiB
Markdown
# Kernel extension mechanics — Design Spec
|
|
|
|
**Date:** 2026-05-28
|
|
**Status:** Draft — awaiting user spec review
|
|
**Authors:** Brummel (orchestrator) + Claude
|
|
**Reference:** `design/models/0007-kernel-extensions.md` for the
|
|
architectural whitepaper. This spec covers the *first* of three
|
|
milestones implementing the kernel-extensions design: the
|
|
language-level mechanisms (this milestone), the `raw-buf` base
|
|
extension (Rust intercepts emitting LLVM IR — the only tier that
|
|
needs implementation outside AILang itself), and the `series`
|
|
library extension (pure AILang code wrapping RawBuf). The two
|
|
follow-up milestones will be brainstormed after this one closes.
|
|
|
|
## Goal
|
|
|
|
Ship the four language-level mechanisms that the kernel-extensions
|
|
design requires, with zero domain-specific code (no Series, no
|
|
Matrix, nothing per-extension). After this milestone, the language
|
|
supports:
|
|
|
|
1. Type-scoped namespacing — `<TypeName>.<member>` resolves to the
|
|
member's def in the type's home module.
|
|
2. The `new` term construct — `(new T arg1 arg2 ...)` calls the
|
|
`new` def in T's home module.
|
|
3. Kernel-tier modules — `Module.kernel: bool` flag with
|
|
auto-import of all top-level defs and types.
|
|
4. `param-in` — closed-set type-parameter restriction on TypeDefs.
|
|
|
|
The milestone closes when all four mechanisms are on main, their
|
|
diagnostics are formulated and tested, the schema-drift pin
|
|
ratifies the new tags, and a stub kernel-tier module exists
|
|
purely as a test fixture to exercise the mechanism end-to-end
|
|
(no domain operations, just a marker type with a `new`).
|
|
|
|
## Architecture
|
|
|
|
The four mechanisms live in different layers:
|
|
|
|
- **Schema layer** (`ailang-core`): `Module.kernel`, `TypeDef.param-in`,
|
|
`Term::New`. Three additive fields/variants, each `skip_serializing_if`
|
|
so existing fixtures hash-stable.
|
|
- **Surface layer** (`ailang-surface`): new keyword `new`,
|
|
new module header attribute `(kernel)`. Print symmetry preserved.
|
|
- **Resolver / Checker layer** (`ailang-check`): type-scoped member
|
|
resolution, `param-in` enforcement, `Term::New` arg-kind checking.
|
|
- **Workspace-load layer** (`ailang-core::workspace`): kernel-tier
|
|
auto-import.
|
|
|
|
Codegen layer is *not* touched in this milestone. `Term::New` with
|
|
a kernel-tier consumer would need codegen support, but we ship the
|
|
mechanisms first and exercise them with non-codegen tests
|
|
(`ail check` clean is the bar). Codegen integration for kernel
|
|
extensions happens in the Series milestone, where the first real
|
|
intercept-registry use case forces the codegen-side mechanism.
|
|
|
|
A stub kernel module (`crates/ailang-kernel-stub/`) ships as part
|
|
of this milestone purely to ratify the mechanism. It contains:
|
|
|
|
- One TypeDef with `param-in` set to a small allowed set.
|
|
- One `new` def with a deliberately constrained signature.
|
|
- The `kernel: true` flag.
|
|
|
|
The stub never reaches codegen — `ail check` is the verification
|
|
endpoint. When the Series milestone follows, the stub may be
|
|
retired or repositioned as a regression fixture.
|
|
|
|
## Concrete code shapes
|
|
|
|
### Iteration prep.1 — Type-scoped namespacing
|
|
|
|
**Worked author example (this is what the LLM writes after prep.1
|
|
lands):**
|
|
|
|
```
|
|
(module consumer
|
|
(import std_maybe)
|
|
|
|
(fn classify
|
|
(type (fn-type
|
|
(params (con Int))
|
|
(ret (con Maybe (con Int)))))
|
|
(params x)
|
|
(body
|
|
(if (app eq x 0)
|
|
(term-ctor Maybe Nothing)
|
|
(term-ctor Maybe Just x))))
|
|
|
|
(fn main
|
|
(type (fn-type (params) (ret (con Unit)) (effects IO)))
|
|
(params)
|
|
(body
|
|
(app print (app Maybe.from_maybe 99 (app classify 7))))))
|
|
```
|
|
|
|
Key shifts from today's surface:
|
|
|
|
- `(con Maybe (con Int))` — bare type, no `std_maybe.` prefix in
|
|
the type position. (Today this is `(con std_maybe.Maybe (con Int))`.)
|
|
- `(app Maybe.from_maybe 99 ...)` — type-scoped op access. (Today:
|
|
`(app std_maybe.from_maybe 99 ...)`.)
|
|
- `(term-ctor Maybe Just x)` — bare type name in `term-ctor`. (Today:
|
|
`(term-ctor std_maybe.Maybe Just x)`.)
|
|
- The `(import std_maybe)` declaration stays — it brings the
|
|
`Maybe` type into the consumer's type namespace. (Future
|
|
alternative `(import Maybe from std_maybe)` is out of scope.)
|
|
|
|
**Implementation shape (before → after).**
|
|
|
|
`ailang-check`'s resolver: today, when seeing `Term::Var { name: "std_maybe.from_maybe" }`,
|
|
it splits at `.`, looks up `std_maybe` as a module, finds
|
|
`from_maybe` in that module's defs. After: the same call site
|
|
with `Term::Var { name: "Maybe.from_maybe" }` first tries to
|
|
resolve `Maybe` as a type (look up TypeDef in the workspace),
|
|
then if a TypeDef is found, locates the home module, then looks
|
|
up `from_maybe` in the home module's defs. If the receiver is
|
|
neither a module nor a type, emit `TypeScopedReceiverNotAType`.
|
|
If the receiver is a known type but the member is not in its
|
|
home module, emit `TypeScopedMemberNotFound`.
|
|
|
|
The module-as-receiver path remains supported for free-standing
|
|
defs (not associated with a single type) but is no longer the
|
|
canonical form for type-associated operations.
|
|
|
|
**Type-position resolution.** `Type::Con { name: "Maybe", args }`
|
|
in a signature: today the workspace-load rejects bare cross-module
|
|
type refs via `BareCrossModuleTypeRef`. After prep.1, the resolver
|
|
first checks whether `Maybe` is in scope (imported by name); if
|
|
yes, it resolves to the imported type. The `BareCrossModuleTypeRef`
|
|
diagnostic narrows to: "type name not in scope (no matching import,
|
|
no kernel-tier provider)". Kernel-tier types are in scope without
|
|
imports (prep.3).
|
|
|
|
**Canonical form decision.** Type-scoped form is canonical for
|
|
type-associated operations. Module-scoped form is retired for
|
|
this use case; it remains valid only for module-free defs (defs
|
|
that do not take a specific type as receiver) and as a workspace
|
|
collision disambiguator (no current collisions exist).
|
|
|
|
**Realisation mechanism — workspace pre-pass.** What the
|
|
"Implementation shape" paragraphs describe at the resolver level
|
|
is *the user-facing semantics*. The actual realisation in code is
|
|
a workspace-wide normalisation step `prepare_workspace_for_check`,
|
|
shared by `check_workspace` and `monomorphise_workspace`, that
|
|
desugars every module and then rewrites every bare cross-module
|
|
`Type::Con.name` and `Term::Ctor.type_name` to its qualified
|
|
`<home>.<Type>` form. This is symmetric to the pre-existing
|
|
`qualify_local_types` helper, which already qualifies bare
|
|
type-refs on the *owner* side; the new pre-pass extends the same
|
|
qualification to the *consumer* side. Downstream passes (checker
|
|
synth, mono, codegen) therefore see qualified Types regardless of
|
|
how the author wrote them. The TypeDef-first ladder still lives
|
|
in `synth`'s `Term::Var` arm, because the `<TypeName>.<member>`
|
|
form is term-position-only — `Maybe.from_maybe` is a Var, not a
|
|
type expression, and the pre-pass does not rewrite Var names.
|
|
The two new diagnostics (`TypeScopedReceiverNotAType`,
|
|
`TypeScopedMemberNotFound`) fire from the resolver as planned.
|
|
The pre-pass design generalises naturally to later iterations:
|
|
prep.2's `Term::New.type_name` falls under the same `type_name`
|
|
rewrite when the pre-pass walks the `Term` tree, and prep.3's
|
|
kernel-tier modules' TypeDefs enter the workspace-wide TypeDef
|
|
map automatically (no pre-pass change needed for kernel-tier).
|
|
|
|
**Blast radius.** Per `grep -rE 'std_(maybe|pair|list|either)\.' examples/ crates/`:
|
|
|
|
- **`.ail` example fixtures** (12 with cross-module refs, plus the
|
|
three defining modules with no refs): `ct_2_bare_cross_module.ail`,
|
|
`ct_3b_bad_qualified_known_module.ail`, `nested_pat.ail`,
|
|
`std_either_demo.ail`, `std_either_list.ail`,
|
|
`std_either_list_demo.ail`, `std_list.ail`, `std_list_demo.ail`,
|
|
`std_list_more_demo.ail`, `std_list_stress.ail`,
|
|
`std_maybe_demo.ail`, `std_pair_demo.ail`. Each rewritten from
|
|
`std_X.Y` to `Y` (type-scoped) where Y is type-associated. The
|
|
three defining modules `std_either.ail`, `std_maybe.ail`,
|
|
`std_pair.ail` contain no cross-module references and need no
|
|
body changes (intra-module refs are bare today already).
|
|
`ct_2_bare_cross_module.ail`'s role flips semantically: it stays
|
|
RED (the unimported bare type case still fires
|
|
`BareCrossModuleTypeRef` under the narrowed rule), but the
|
|
diagnostic *message* it asserts is the new one.
|
|
- **No corresponding `.ail.json` fixtures.** None of the 15
|
|
spec-named `.ail` files have a sibling `.ail.json` round-trip
|
|
pin in the tree.
|
|
- **Source-side reference** in `crates/ailang-{check,codegen,surface}/src/`
|
|
and `crates/ail/tests/e2e.rs` (7 files): doc-comments and
|
|
test-helper code mentioning `std_X.Y` vocabulary update to
|
|
type-scoped vocabulary. Production type-resolution logic is the
|
|
new code. The specific sites are enumerated in the prep.1 plan.
|
|
- **CLI diagnostic renderer + assertion update**:
|
|
`crates/ail/src/main.rs:1249-1278` (the `BareCrossModuleTypeRef`
|
|
and `BadCrossModuleTypeRef` translation arms) and
|
|
`crates/ail/tests/ct1_check_cli.rs:154` (an assertion on the old
|
|
"ail migrate-canonical-types" hint string) move with the
|
|
diagnostic-message narrowing.
|
|
|
|
**No hash-pin or schema-drift work in prep.1.** The hash-pin audit
|
|
across every test crate (per the `hash-pin blast-radius audit`
|
|
lesson from `schema-camelcase-fix`) is mandatory and was performed
|
|
during prep.1's plan recon. Result: `crates/ailang-core/tests/hash_pin.rs`
|
|
pins `sum.ail`, `list.ail`, `ordering_match.ail`,
|
|
`test_22b1_dup_a.ail`, `test_22b1_dup_classmod.ail` — none of which
|
|
contain `std_X.Y` references; `crates/ailang-surface/tests/prelude_module_hash_pin.rs`
|
|
pins `prelude.ail` — also no `std_X.Y` references. **No pin refresh
|
|
applies to prep.1.** The `prelude_module_hash_pin.rs` refresh
|
|
happens in prep.3 when `kernel: true` lands on the prelude module.
|
|
Schema-drift pins in `crates/ailang-core/tests/design_schema_drift.rs`
|
|
also do not apply: type-scoped resolution is a checker change with
|
|
no new JSON tags, no new enum variants, no AST shape changes. The
|
|
drift pins for this milestone land in prep.2 (new `t: "new"` tag,
|
|
new `NewArg` JSON) and prep.3 (new `kernel`, `param-in` schema
|
|
attrs).
|
|
|
|
**Integration with existing mechanisms.**
|
|
|
|
- `BareCrossModuleTypeRef` / `BadCrossModuleTypeRef` diagnostics
|
|
are repurposed: they fire when a type name is not in scope by
|
|
any path (import, kernel-tier, type-scoped). The class of
|
|
errors they detect narrows, the underlying user-facing message
|
|
becomes more helpful (it can suggest the import or the
|
|
type-scoped form).
|
|
- Module-scoped access remains the path for free-standing fns
|
|
(e.g. a hypothetical `std_math.factorial` that doesn't have a
|
|
receiver type). No retirement of the module-scope mechanism
|
|
itself.
|
|
- Class method dispatch (see [method-dispatch](../../design/contracts/0016-method-dispatch.md))
|
|
is orthogonal. `(app show x)` continues to type-dispatch via the
|
|
Show instance; this is not type-scoped namespacing.
|
|
- `term-ctor` and `pat-ctor` continue to use bare ctor names
|
|
inside the scrutinee — `(term-ctor Maybe Just x)` works
|
|
because `Maybe` is now in scope via type-scoped resolution.
|
|
|
|
### Iteration prep.2 — `new` term construct
|
|
|
|
**Worked author example (after prep.2; uses a non-kernel test
|
|
type to exercise the construct):**
|
|
|
|
```
|
|
(module new_demo
|
|
(data Counter
|
|
(ctor MkCounter (con Int)))
|
|
|
|
(fn new
|
|
(doc "Build a Counter initialised to the given value.")
|
|
(type (fn-type (params (con Int)) (ret (con Counter))))
|
|
(params n)
|
|
(body (term-ctor Counter MkCounter n)))
|
|
|
|
(fn main
|
|
(type (fn-type (params) (ret (con Unit)) (effects IO)))
|
|
(params)
|
|
(body
|
|
(let c (new Counter 42)
|
|
(match c
|
|
(case (pat-ctor MkCounter x) (app print x)))))))
|
|
```
|
|
|
|
The `(new Counter 42)` desugars to: look up `Counter`'s home
|
|
module (this very module), find the `new` def, call it with `42`.
|
|
|
|
For a future kernel-tier consumer with a `new` taking a Type arg:
|
|
|
|
```
|
|
; (Future Series, exercised in milestone 2.)
|
|
(let s (new Series (con Float) 3)
|
|
...)
|
|
```
|
|
|
|
Here the first arg `(con Float)` is parsed as a Type (per the
|
|
`NewArg::Type` discriminator), the second `3` as a Term.
|
|
|
|
**Implementation shape.**
|
|
|
|
New AST variant in `ailang-core/src/ast.rs`:
|
|
|
|
```rust
|
|
pub enum Term {
|
|
// ...
|
|
New {
|
|
type_name: TypeName,
|
|
args: Vec<NewArg>,
|
|
},
|
|
}
|
|
|
|
pub enum NewArg {
|
|
Type(Type),
|
|
Value(Term),
|
|
}
|
|
```
|
|
|
|
Serialised JSON:
|
|
|
|
```jsonc
|
|
{ "t": "new",
|
|
"type": "Series",
|
|
"args": [
|
|
{ "kind": "type", "value": { ... type expr ... } },
|
|
{ "kind": "value", "value": { ... term expr ... } }
|
|
]
|
|
}
|
|
```
|
|
|
|
`type_name` carries no module qualifier — it is resolved via
|
|
type-scoped lookup at check time (depends on prep.1).
|
|
|
|
**Form-A surface.** Lex/Parse adds `new` as a keyword. Production:
|
|
|
|
```
|
|
⟨new⟩ ::= '(' 'new' ⟨TypeName⟩ ⟨NewArg⟩+ ')'
|
|
⟨NewArg⟩ ::= ⟨Type⟩ ; matches if token sequence parses as a Type production
|
|
| ⟨Term⟩ ; otherwise
|
|
```
|
|
|
|
The disambiguation between Type and Term args is by syntactic
|
|
form: a Type starts with `(con …)`, `(fn-type …)`, `(borrow …)`,
|
|
`(own …)`, etc. — the existing Type-production keywords. A Term
|
|
in arg position is anything else.
|
|
|
|
**Checker.** When elaborating `Term::New { type_name, args }`:
|
|
|
|
1. Look up `type_name` via type-scoped resolution → home module.
|
|
2. Find `new` def in that home module. If missing, emit
|
|
`NewTypeNotConstructible`.
|
|
3. Check arg count against `new`'s signature.
|
|
4. For each arg position: the signature's param kind (Type or
|
|
Value) must match the `NewArg` kind. Mismatch emits
|
|
`NewArgKindMismatch`.
|
|
5. If `new`'s signature includes a `forall` over a type variable
|
|
instantiated by a Type-positional arg, bind that variable to
|
|
the supplied type. Apply `param-in` restriction (prep.3) if
|
|
declared.
|
|
|
|
**Canonical form decision.** `new` is the canonical construction
|
|
form when construction is *functional* (calls a function). It
|
|
does not replace `term-ctor`, which remains canonical for
|
|
named-data-ctor construction in ADTs.
|
|
|
|
**Blast radius.** Small. No existing fixture uses a `new` keyword
|
|
or pattern, since the keyword does not exist today. The schema
|
|
additions are new variants and new JSON tags; existing fixtures
|
|
emit unchanged bytes (no `t: "new"` appears anywhere today).
|
|
A new in-source test in `ailang-check/src/lib.rs`'s
|
|
`#[cfg(test)]` module exercises the resolution path on the
|
|
`new_demo` example fixture above.
|
|
|
|
**Integration with existing mechanisms.**
|
|
|
|
- `term-ctor` continues to construct ADT values via named data
|
|
ctors. The two constructs answer different design needs (see
|
|
whitepaper).
|
|
- The `new` keyword does not collide with any existing identifier
|
|
(no def named `new` exists in current workspace per `grep`).
|
|
If a user-defined module ever wanted to call something `new`,
|
|
that name is now reserved as a Form-A keyword — they have to
|
|
pick a different name. Pre-production stage, no breakage risk.
|
|
- Type-scoped resolution (prep.1) is the dependency: `Term::New`
|
|
uses it to find the home module of `type_name`.
|
|
|
|
### Iteration prep.3 — Kernel-tier modules + `param-in`
|
|
|
|
This iteration ships two coupled mechanisms in one atomic step,
|
|
because the `param-in` restriction is the first checker behavior
|
|
that depends on the workspace knowing about kernel-tier modules
|
|
(the stub kernel module is the test fixture for both).
|
|
|
|
**Worked author example — kernel-tier stub module (this is the
|
|
test fixture, not user-facing code):**
|
|
|
|
```
|
|
; crates/ailang-kernel-stub/module.ail.json (or programmatic form)
|
|
; The stub module exists only to exercise the mechanism — both
|
|
; `kernel: true` auto-import and `param-in` enforcement.
|
|
(module kernel_stub (kernel)
|
|
|
|
(type StubT (vars a)
|
|
(param-in (a Int Float))
|
|
(ctors (Stub a)))
|
|
|
|
(fn new
|
|
(doc "Construct StubT<Int> — exercises Term::New end-to-end.")
|
|
(type (fn-type (params (con Int)) (ret (con StubT (con Int)))))
|
|
(params x)
|
|
(body (term-ctor StubT Stub x))))
|
|
```
|
|
|
|
**Worked consumer (no `(import kernel_stub)` needed — kernel-tier):**
|
|
|
|
```
|
|
(module stub_consumer
|
|
; No import line. kernel_stub is auto-imported.
|
|
|
|
(fn main
|
|
(type (fn-type (params) (ret (con Unit)) (effects IO)))
|
|
(params)
|
|
(body
|
|
(let s (new StubT 42)
|
|
(match s
|
|
(case (pat-ctor Stub x) (app print x)))))))
|
|
```
|
|
|
|
The `(new StubT 42)` form names the unapplied type constructor
|
|
`StubT`; the resolver looks up `StubT`'s home module (`kernel_stub`),
|
|
finds `new` there, types the call against `new`'s signature
|
|
`(Int) -> StubT Int`. The result type `StubT Int` triggers a
|
|
`param-in` check on the type-arg `Int` — `Int ∈ {Int, Float}` so
|
|
it passes. A pattern match on `(pat-ctor Stub x)` extracts and
|
|
prints the wrapped Int.
|
|
|
|
**Worked rejection — `param-in` violation:**
|
|
|
|
```
|
|
(fn bad
|
|
(type (fn-type
|
|
(params (con StubT (con Str))) ; Str not in {Int, Float}
|
|
(ret (con Unit))))
|
|
(params s)
|
|
(body unit))
|
|
; expected: ParamNotInRestrictedSet on `(con StubT (con Str))`
|
|
```
|
|
|
|
**Implementation shape.**
|
|
|
|
Schema additions in `ailang-core/src/ast.rs`:
|
|
|
|
```rust
|
|
pub struct Module {
|
|
// ...
|
|
#[serde(default, skip_serializing_if = "is_false")]
|
|
pub kernel: bool,
|
|
}
|
|
|
|
pub struct TypeDef {
|
|
// ...
|
|
#[serde(default, skip_serializing_if = "BTreeMap::is_empty",
|
|
rename = "param-in")]
|
|
pub param_in: BTreeMap<String, BTreeSet<TypeName>>,
|
|
}
|
|
```
|
|
|
|
Both `skip_serializing_if` so existing TypeDefs and Modules hash-
|
|
identical.
|
|
|
|
**Form-A surface.** Module header gains `(kernel)` attribute:
|
|
|
|
```
|
|
(module foo (kernel) ...)
|
|
```
|
|
|
|
TypeDef body gains optional `(param-in (var type1 type2 ...))` block:
|
|
|
|
```
|
|
(type T (vars a)
|
|
(param-in (a Int Float))
|
|
(ctors))
|
|
```
|
|
|
|
**Workspace-load.** Today, the loader hardcodes a single auto-
|
|
injected module: `crates/ailang-surface/src/loader.rs:98-108`
|
|
unconditionally calls `parse_prelude()` and threads the literal
|
|
`&["prelude"]` slice into `ailang_core::workspace::build_workspace`
|
|
as the implicit-imports list. The reservation of `prelude` as
|
|
an auto-injected name is documented at `workspace.rs:308-311`,
|
|
and the build contract at `workspace.rs:467`. This is the
|
|
mechanism that makes the prelude's 12 free fns (`ne`, `lt`,
|
|
`le`, `gt`, `ge`, `print`, `float_eq`...`float_ge`) callable bare
|
|
in every consumer module today (ratified by
|
|
`crates/ail/tests/prelude_free_fns.rs`).
|
|
|
|
prep.3 *generalises* this single-name auto-injection into a
|
|
flag-driven multi-module mechanism. After prep.3:
|
|
|
|
- `workspace::build_workspace`'s implicit-imports parameter is
|
|
derived from "all modules in the workspace with `kernel: true`",
|
|
not from a hardcoded literal.
|
|
- `loader.rs`'s `parse_prelude()` injection remains (prelude is a
|
|
built-in module — it has no on-disk manifest in user
|
|
workspaces and must be injected from the compiler's own
|
|
resources), but the *naming* of `prelude` as the implicit
|
|
import is no longer hardcoded; it follows from prelude's
|
|
`kernel: true` flag.
|
|
- Other kernel-tier modules (e.g. the stub crate, future Series
|
|
crate) are loaded through the normal workspace discovery and
|
|
picked up by the same `kernel: true` filter.
|
|
|
|
The name-resolution precedence rule (explicit imports beat
|
|
auto-import) is the existing behaviour, retained.
|
|
|
|
**Checker.** When resolving `Type::Con { name, args }`:
|
|
|
|
1. After standard TypeDef lookup, fetch `param_in` from the
|
|
TypeDef.
|
|
2. For each entry `(var, allowed_set)`:
|
|
- Locate the positional `args[i]` corresponding to `var` (by
|
|
name lookup in TypeDef's `vars`).
|
|
- Walk `args[i]` to its outermost type-name (peel through
|
|
mode wrappers).
|
|
- If not a primitive type name in `allowed_set`, emit
|
|
`ParamNotInRestrictedSet { type_name, var, found, allowed }`.
|
|
|
|
The check is *generic*: nothing in the checker mentions Series
|
|
or any specific extension type. The restriction is data-driven
|
|
from the TypeDef.
|
|
|
|
**Canonical form decision.** `param-in` is the canonical form for
|
|
closed-set type-parameter restrictions. Marker classes (a class
|
|
with no methods, used solely for type-tagging) are not
|
|
introduced as a parallel mechanism.
|
|
|
|
**Blast radius.**
|
|
|
|
- The `prelude` module gains `kernel: true`. **This is a code-
|
|
path migration, not a behaviour change.** Prelude has 12 free
|
|
fns today (`examples/prelude.ail:85-148`: `ne`, `lt`, `le`,
|
|
`gt`, `ge`, `print`, `float_eq`, `float_ne`, `float_lt`,
|
|
`float_le`, `float_gt`, `float_ge`) plus its three classes.
|
|
All 12 free fns are already callable bare in consumer modules
|
|
via the hardcoded auto-injection at `loader.rs:98-108`. After
|
|
prep.3, the same 12 fns remain callable bare — but the
|
|
reachability path is the generic `kernel: true` filter, not
|
|
the hardcoded `&["prelude"]` literal. Consumer-observable
|
|
behaviour: identical. Code-path: rewritten. The hash-pin
|
|
refresh in `crates/ailang-surface/tests/prelude_module_hash_pin.rs`
|
|
is required because the canonical-JSON of the prelude module
|
|
now contains `"kernel": true`, which changes the module hash;
|
|
the test file gets the new hash plus an Honesty-Rule
|
|
provenance comment quoting prep.3.
|
|
- `crates/ailang-surface/src/loader.rs` and
|
|
`crates/ailang-core/src/workspace.rs` lose the hardcoded
|
|
prelude-specific paths; they gain the flag-driven equivalent.
|
|
Detailed sites: `loader.rs:98-108`, `workspace.rs:308-311,
|
|
467, 2655`. The `WorkspaceLoadError::ReservedModuleName`
|
|
diagnostic (currently fires when a user workspace contains a
|
|
module named `prelude`) is repositioned: any module named the
|
|
same as an auto-injected built-in kernel module is reserved,
|
|
not specifically `prelude`. For this milestone the list is
|
|
still just `prelude` + the new stub.
|
|
- The new `crates/ailang-kernel-stub/` crate is created with
|
|
its `module.ail.json` and the build wiring to feed it into
|
|
the workspace load.
|
|
- The schema-drift pin in `crates/ailang-core/tests/design_schema_drift.rs`
|
|
gains entries for `kernel`, `param-in`, and a basic stub
|
|
module round-trip.
|
|
- `crates/ail/tests/prelude_free_fns.rs` continues to pass
|
|
unchanged — that is the explicit regression-protection that
|
|
the prelude code-path migration is behaviour-equivalent.
|
|
- Workspace-load tests under `crates/ailang-core/tests/` gain
|
|
a new fixture exercising auto-import of the stub kernel
|
|
module from a consumer with no `(import ...)` declaration.
|
|
|
|
**Integration with existing mechanisms.**
|
|
|
|
- The prelude module's role in the workspace loader is unified
|
|
with the kernel-tier mechanism. The hardcoded prelude-name
|
|
paths today exist concretely at `loader.rs:98-108` (the
|
|
`parse_prelude()` injection + `&["prelude"]` literal) and
|
|
`workspace.rs:308-311, 467, 2655` (reservation + contract
|
|
docs). They are migrated to read `module.kernel` from each
|
|
loaded module's schema and build the implicit-imports list
|
|
from the resulting set. Single named mechanism for
|
|
"module is auto-imported"; no module name is hardcoded as
|
|
special.
|
|
- Class constraints (existing) and `param-in` (new) coexist —
|
|
see the whitepaper's "marker class" discussion.
|
|
- `Term::New` (prep.2) uses `param-in` via the standard checker
|
|
path — when `new`'s return type is `T<a>` with `a` restricted,
|
|
the type-arg supplied at the new-site is validated.
|
|
- The existing primitive-instance codegen intercept
|
|
(`try_emit_primitive_instance_body` in `ailang-codegen`) is
|
|
*not* touched in this milestone. Its migration to a plugin
|
|
registry happens in the Series milestone, where there is a
|
|
real second consumer for the registry. Single-consumer
|
|
registries are premature mechanism.
|
|
|
|
## Components
|
|
|
|
| Component | Crate | What changes |
|
|
|-----------|-------|-------------|
|
|
| AST schema | `ailang-core` | `Module.kernel`, `TypeDef.param-in`, `Term::New`, `NewArg` |
|
|
| Form-A surface | `ailang-surface` | `new` keyword, `(kernel)` module attr, `(param-in ...)` typedef attr |
|
|
| Workspace-load | `ailang-core::workspace` | Kernel-tier auto-import scope |
|
|
| Resolver | `ailang-check` | Type-scoped member resolution, `Term::New` arg-kind check, `param-in` enforcement |
|
|
| Drift pin | `ailang-core/tests/design_schema_drift.rs` | New schema tags ratified |
|
|
| Hash pins | `ailang-core/tests/hash_pin.rs`, `ailang-surface/tests/prelude_module_hash_pin.rs` | Refreshed for the rewrites |
|
|
| Std fixture migration | `examples/*.ail` (~14 files) | `std_X.Y` → type-scoped form |
|
|
| Stub kernel crate | `crates/ailang-kernel-stub/` | New, minimal — exercises the mechanism |
|
|
|
|
## Data flow
|
|
|
|
A `.ail` consumer module that uses a kernel-tier type:
|
|
|
|
1. **Workspace load.** Each module's manifest is read. Kernel-tier
|
|
modules are collected; their type names and def names enter
|
|
the auto-import scope.
|
|
2. **Parse.** The consumer's `(con Series ...)` (or `(con StubT ...)`
|
|
in this milestone's stub) parses to `Type::Con { name: "Series" }`.
|
|
`(new Series ...)` parses to `Term::New { type_name: "Series", args: [...] }`.
|
|
`(app Series.at s i)` parses to `Term::App { callee: Term::Var { name: "Series.at" }, args }`.
|
|
3. **Resolve.** Type-scoped resolver looks up `Series` in the
|
|
workspace TypeDef registry (auto-imported via kernel-tier).
|
|
`Series.at` resolves to the `at` def in Series's home module
|
|
(the stub or, in milestone 2, the Series crate).
|
|
4. **Type-check.** `param-in` enforcement runs on `Type::Con {
|
|
name: "Series", args }` — element type must be in the allowed
|
|
set. `Term::New` arg-kind check runs on the args. Standard
|
|
type-check otherwise.
|
|
5. **Done at `ail check`.** Codegen is out of scope this milestone.
|
|
|
|
## Error handling
|
|
|
|
New diagnostics introduced this milestone (all in `ailang-check`):
|
|
|
|
| Diagnostic | Trigger | Iteration |
|
|
|------------|---------|-----------|
|
|
| `TypeScopedMemberNotFound` | `T.x` where T is a type but `x` not in T's home module | prep.1 |
|
|
| `TypeScopedReceiverNotAType` | `X.y` where X is neither a known type nor a known module | prep.1 |
|
|
| `NewTypeNotConstructible` | `(new T args)` where T has no `new` def in its home module | prep.2 |
|
|
| `NewArgKindMismatch` | `(new T args)` where an arg is Type-positional where Value expected (or vice versa) | prep.2 |
|
|
| `ParamNotInRestrictedSet` | `Type::Con { name, args }` where `args[i]` violates the TypeDef's `param-in` for the corresponding var | prep.3 |
|
|
|
|
`BareCrossModuleTypeRef` and `BadCrossModuleTypeRef` (pre-existing)
|
|
are repurposed in prep.1: they now fire when a type name resolves
|
|
through neither type-scoped lookup, nor an explicit import, nor a
|
|
kernel-tier auto-import. The semantic class of error remains "type
|
|
name not resolvable", but the suggested fix in the message
|
|
changes.
|
|
|
|
## Testing strategy
|
|
|
|
**Layer 1 — Schema drift** (`crates/ailang-core/tests/design_schema_drift.rs`):
|
|
- `module_kernel_flag_round_trips` — pins `Module.kernel = true`
|
|
serialises and deserialises with bit-identical bytes; `kernel:
|
|
false` is omitted from output.
|
|
- `typedef_param_in_round_trips` — pins `TypeDef.param_in` map
|
|
serialises with the `param-in` key (kebab); empty map omitted.
|
|
- `term_new_round_trips` — pins `Term::New { type_name, args }`
|
|
serialises with `t: "new"`, each arg with `kind: "type"|"value"`.
|
|
|
|
**Layer 2 — Round-trip surface** (`crates/ailang-surface/tests/round_trip.rs`):
|
|
- Fixture for `(kernel)` in module header.
|
|
- Fixture for `(new T ...)` with mixed-kind args.
|
|
- Fixture for `(param-in (var t1 t2))` in TypeDef.
|
|
- All fixtures round-trip Form-A → JSON → Form-A bit-identical.
|
|
|
|
**Layer 3 — Checker** (`crates/ailang-check/src/*.rs` in-source tests):
|
|
- `type_scoped_member_resolves` — `Maybe.from_maybe` resolves to
|
|
`std_maybe.from_maybe`'s def.
|
|
- `type_scoped_member_not_found` — `Maybe.bogus` emits diagnostic.
|
|
- `type_scoped_receiver_not_a_type` — `NotAType.x` emits diagnostic.
|
|
- `new_resolves_via_type_scope` — `(new Counter 42)` finds local
|
|
`new` def.
|
|
- `new_type_not_constructible` — `(new IntList ...)` (no `new` def
|
|
in IntList's home module) emits diagnostic.
|
|
- `new_arg_kind_mismatch_value_where_type` — `(new Series 3 (con Float))`
|
|
emits diagnostic.
|
|
- `param_in_accepts_allowed_type` — `(con StubT (con Int))`
|
|
type-checks.
|
|
- `param_in_rejects_disallowed_type` — `(con StubT (con Str))`
|
|
emits `ParamNotInRestrictedSet`.
|
|
|
|
**Layer 4 — Workspace load** (`crates/ailang-core/tests/workspace_kernel.rs`,
|
|
new):
|
|
- Kernel-tier module's types are visible to a consumer without
|
|
any `(import ...)` declaration.
|
|
- Two kernel-tier modules with non-colliding names co-load.
|
|
- Explicit import overrides auto-import (precedence test).
|
|
|
|
**Layer 5 — End-to-end via `ail check`** (`crates/ail/tests/e2e.rs`):
|
|
- The stub kernel module + a stub consumer checks clean.
|
|
- The `param-in` violation example produces the expected
|
|
diagnostic.
|
|
|
|
**Layer 6 — Migrated std-library examples**:
|
|
- All migrated `.ail` examples (std_maybe_demo, std_list_demo,
|
|
etc.) check clean. Their `.ail.json` round-trip pins refreshed.
|
|
|
|
## Acceptance criteria
|
|
|
|
Milestone closes when:
|
|
|
|
1. All four mechanisms (type-scoped namespacing, `Term::New`,
|
|
kernel-tier modules + auto-import, `param-in`) are implemented
|
|
and merged to main.
|
|
2. All five new diagnostics fire on their RED fixtures and do
|
|
not fire on green code.
|
|
3. The schema-drift pin ratifies `kernel`, `param-in`, `new`,
|
|
`NewArg` JSON tags.
|
|
4. The round-trip test exercises all new surface forms.
|
|
5. The stub kernel crate's module checks clean and is auto-
|
|
imported by a consumer module with no explicit import.
|
|
6. All 14-ish migrated `.ail` examples in `examples/` check
|
|
clean and round-trip to byte-identical Form-A.
|
|
7. Hash pins refreshed in both `crates/ailang-core/tests/hash_pin.rs`
|
|
and `crates/ailang-surface/tests/prelude_module_hash_pin.rs`,
|
|
each with an Honesty-Rule provenance comment.
|
|
8. The whitepaper `design/models/0007-kernel-extensions.md` is
|
|
updated: sections describing prep.1, prep.2, prep.3 transition
|
|
from forward-looking ("will resolve…") to present-state
|
|
("resolves…") where appropriate; STATUS header updated to
|
|
"Mechanisms milestone closed YYYY-MM-DD; Series milestone
|
|
pending."
|
|
9. The `design/INDEX.md` ledger entry's "design accepted
|
|
2026-05-28; impl in progress" annotation is updated to reflect
|
|
the milestone close.
|
|
10. `bench/check.py` clean — no performance regression on the
|
|
existing benches (this milestone touches resolver / parser /
|
|
schema, not codegen or runtime; regression is unexpected
|
|
but bench is part of the close-out per audit discipline).
|
|
|
|
## Out of scope (explicit)
|
|
|
|
- The `RawBuf` base extension — kernel-tier module with Rust
|
|
codegen intercepts emitting LLVM IR for `new`/`get`/`set`/`size`,
|
|
element-type restriction via `param-in`. Future Gitea milestone
|
|
`raw-buf`.
|
|
- The `Series` library extension — kernel-tier `.ail` module
|
|
defining the Series ADT and its operations (push/at/len/
|
|
total_count) in AILang itself, on top of RawBuf. Future Gitea
|
|
milestone `series`.
|
|
- Migration of `try_emit_primitive_instance_body` into a plugin
|
|
intercept registry. Happens in the `raw-buf` milestone when
|
|
the first base-extension consumer exists.
|
|
- LSP / MCP integration for `ail describe Series`. Out of scope
|
|
for all three milestones; future axis.
|
|
- Higher-kinded `param-in` (the restriction is currently
|
|
flat — type variable to set of named types; future variants
|
|
might restrict to "any type satisfying some structural
|
|
predicate", but that is not specified here).
|
|
- Type-scoped access on user types ≠ home-module defs (e.g.
|
|
cross-module-extension methods). Out of scope; the mechanism
|
|
is strictly "type T's home module's defs are accessible as
|
|
T.x".
|
|
- An auto-import precedence override syntax (the explicit-import
|
|
wins behaviour is implementation; no new author syntax is added
|
|
for it).
|
|
- Record-element support in RawBuf (the struct-of-arrays layout
|
|
for `Series Record`). The architecture accommodates it (see
|
|
whitepaper § "Forward axis: SoA for records") but it is a
|
|
later RawBuf-internal change that does not touch Series's
|
|
AILang code; out of scope for the three named milestones.
|
|
|
|
## Iteration scope
|
|
|
|
The plan that follows this spec carves the work into three
|
|
iterations:
|
|
|
|
- **prep.1** — Type-scoped namespacing. Resolver change + 12
|
|
example rewrites + diagnostic-message narrowing + ct1_check_cli
|
|
assertion update. No hash-pin or drift-test work (audit cleared
|
|
during plan recon; pins live in prep.2/3).
|
|
- **prep.2** — `Term::New`. AST + surface + checker arg-kind
|
|
enforcement.
|
|
- **prep.3** — Kernel-tier modules + `param-in`. Schema +
|
|
workspace-load + checker enforcement + stub crate.
|
|
|
|
Each iteration is independently shippable; main is green at every
|
|
iteration boundary.
|