9339279181
Terminal iteration of the kernel-extension-mechanics milestone. Ships
the four language-level mechanisms named in the spec's § Goal:
Module.kernel + TypeDef.param-in schema, their Form-A surface,
flag-driven kernel-tier auto-injection, and generic param-in checker
enforcement with a new diagnostic.
Schema (Tasks 1+2). Module gains a `kernel: bool` field
(skip_serializing_if = is_false), TypeDef gains a
`param_in: BTreeMap<String, BTreeSet<String>>` field
(skip-if-empty, kebab-renamed to "param-in"). Both fields are
strictly additive — every pre-existing fixture's canonical-JSON hash
is bit-stable except `prelude.ail`, which intentionally gains
`(kernel)`. The struct-literal sweep covered ~104 Module sites and
~35 TypeDef sites across the workspace; the additive serde-default
covers JSON deserialise paths, only Rust struct literals broke.
Form-A surface (Tasks 3+4). `(kernel)` is a bare module-header
attribute; `(param-in (a Int Float) (b Str))` is one outer
TypeDef-body clause carrying one or more inner var-lists (OQ1
decision — mirrors `(ctors …)`, one parser arm, deterministic
BTreeMap iteration). Both round-trip Form-A → JSON → Form-A
bit-identical.
Workspace-load migration (Task 5). The hardcoded `&["prelude"]`
literal at loader.rs:108 became a `modules.values().filter(|m|
m.kernel)` derivation; `parse_prelude()` injection stays because
the prelude has no on-disk manifest in user workspaces. Prelude
now carries `(kernel)` in its source, so the new filter picks it
up automatically. Code-path migration only — observable behaviour
is identical (prelude_free_fns.rs stays green). prelude hash
re-pinned (af372f28c726f29f) with Honesty-Rule provenance comment.
WorkspaceLoadError::ReservedModuleName diagnostic prose
repurposed: any built-in kernel module name is reserved
(currently prelude + kernel_stub), not specifically prelude. CLI
mapping at main.rs updated in lockstep.
Stub crate (Task 6). New `crates/ailang-kernel-stub/` is a
zero-dependency leaf crate carrying only `pub const STUB_AIL:
&str` with the Form-A source of the kernel_stub module (one
parametric TypeDef with param-in, one ctor). The parse hop —
`parse_kernel_stub()` — lives in ailang-surface next to
parse_prelude, keeping the crate-dependency graph acyclic
(`ailang-surface → ailang-kernel-stub → ailang-core`, no
back-edge). The stub is injected unconditionally in all builds as
the ratifying fixture for the kernel-extension mechanism; future
base extensions may add more or retire the stub. Drift-pinned by
`kernel_stub_module_round_trips`.
Checker (Task 7). New `CheckError::ParamNotInRestrictedSet`
variant + code() + ctx() arms + enforcement in
`check_type_well_formed`'s Type::Con arm — generic, data-driven
from the TypeDef, mentions no specific extension type. Two
in-source tests pin both the rejection (`Str` outside `{Int,
Float}`) and the acceptance (`Int` inside) paths.
Workspace-load integration tests (Task 8). New
`workspace_kernel.rs` integration-test crate with three tests:
auto-import without explicit `(import …)` declaration, two
kernel-tier modules co-load, explicit-import-overrides-auto-
import precedence preserved. Loader is import-tree-only so the
auto-import tests use a bridge module that brings the kernel
module into the workspace via the import graph — docstring
captures the reachability nuance for future readers.
Doc-state transitions (Task 9). INDEX.md kernel-extensions row
annotation transitions from "design accepted 2026-05-28; impl in
progress" to "mechanisms milestone closed 2026-05-28; raw-buf and
series milestones pending". Whitepaper STATUS + auto-import +
param-in sections transitioned forward→present for shipped
mechanisms; forward-tense survives only in sections describing
the still-pending raw-buf/series milestones (per Honesty-Rule).
data-model contract gains anchor blocks for both new schema
fields.
Side-effect: every binary's IR snapshot now contains ~52 lines
for `drop_kernel_stub_StubT` because the stub is auto-injected
into every workspace load. Snapshots refreshed; e2e expects 4
modules per workspace (prelude + kernel_stub + entry + zero or
more user modules) instead of the previous 3.
Plan defects scrubbed in the implementation (folded back into
the planner template via the planner's self-review checklist
next time): Task 4 sample test src used fictional
`(ctors (MkT a))` list form (project grammar is per-`(ctor MkT
a)`); Task 6 original wiring would have created a cycle
ailang-surface → ailang-kernel-stub → ailang-surface (inverted —
stub crate is zero-dep, parse hop lives in surface); Task 7 in-
source tests referenced a fictional `check_type_in_module`
helper (used the existing Workspace + check_workspace
convention); Task 8 first integration test expected loader to
auto-load kernel modules from disk (loader is import-tree-only;
tests use a bridge module).
Concern-5 fix folded in pre-commit: workspace.rs ReservedModuleName
doc-prose initially said "in test/dev builds" for kernel_stub —
but stub is unconditionally injected in all builds. Doc copy
tightened to present-state per Honesty-Rule.
Stats: 0 spec-review-loops, 0 quality-review-loops, 2 sweep-script
retries on Task 2 (brace-depth bug on nested vec![Ctor{…}],
recovered via per-file checkout + rewritten anchor-on-existing-
field sweep), 1 e2e-snapshot refresh on Task 6.
296 lines
11 KiB
Markdown
296 lines
11 KiB
Markdown
# Data model
|
|
|
|
## Data model
|
|
|
|
The on-disk JSON-AST is what the toolchain hashes, typechecks, and
|
|
lowers. **This section is the canonical schema.** The Rust types in
|
|
`crates/ailang-core/src/ast.rs` are the in-memory projection of it;
|
|
when the two disagree, this section wins, and the drift test
|
|
`crates/ailang-core/tests/design_schema_drift.rs` fires. Every
|
|
additive field is declared with `skip_serializing_if` so pre-existing
|
|
fixtures keep bit-identical canonical-JSON hashes — that gating
|
|
contract is what makes growing the schema cheap.
|
|
|
|
### Module
|
|
|
|
```jsonc
|
|
{
|
|
"schema": "ailang/v0",
|
|
"name": "<id>",
|
|
"kernel": true, // optional; omitted when false (hash-stable when omitted). Kernel-tier modules are auto-imported by every consumer. See prep.3 of the kernel-extension-mechanics milestone.
|
|
"imports": [{ "module": "<id>", "as": "<id>" }],
|
|
"defs": [Def...]
|
|
}
|
|
```
|
|
|
|
### Def
|
|
|
|
`kind ∈ { "fn", "const", "type", "class", "instance" }`. All five
|
|
are real surface forms. Class and type cross-module references
|
|
(canonical-form rule, qualified `<module>.<Class>` /
|
|
`<module>.<TypeName>`) follow the scoping rule in
|
|
[memory model](0008-memory-model.md); the `class`/`instance` schema
|
|
narrative — defaults, superclasses, diagnostics — lives in
|
|
[typeclasses](0013-typeclasses.md). Exported `fn` defs interact with
|
|
[embedding ABI](0003-embedding-abi.md).
|
|
|
|
```jsonc
|
|
// fn (the unit that gets a content hash)
|
|
{ "kind": "fn",
|
|
"name": "<id>",
|
|
"type": Type, // typically Type::Fn, optionally wrapped in Forall
|
|
"params": ["<id>"...], // names bound in body, in type.params order
|
|
"body": Term,
|
|
"doc": "<optional string>",
|
|
"export": "<optional C symbol>", // omitted when absent (hash-stable when omitted); embedding-ABI surface — see prose below
|
|
"suppress": [Suppress...] // omitted when empty
|
|
}
|
|
|
|
// const (top-level value; codegen emits as a global; body must be pure)
|
|
{ "kind": "const",
|
|
"name": "<id>",
|
|
"type": Type,
|
|
"value": Term,
|
|
"doc": "<optional string>"
|
|
}
|
|
|
|
// type (algebraic data type; parameterised)
|
|
{ "kind": "type",
|
|
"name": "<id>",
|
|
"vars": ["<id>"...], // type parameters; omitted when empty (hash-stable when omitted)
|
|
"ctors": [
|
|
{ "name": "<id>", "fields": [Type...] } // nullary ctor: fields = []
|
|
...
|
|
],
|
|
"doc": "<optional string>",
|
|
"drop-iterative": true, // opt-in; omitted when false (hash-stable when omitted)
|
|
"param-in": { "<var>": ["<TypeName>", ...] } // closed-set restriction per type variable; omitted when empty (hash-stable when omitted). See prep.3 of the kernel-extension-mechanics milestone.
|
|
}
|
|
|
|
// class (typeclass declaration; narrative in contracts/0013-typeclasses.md)
|
|
{ "kind": "class",
|
|
"name": "<id>", // class name (e.g. "Show")
|
|
"param": "<id>", // single class parameter, kind *
|
|
"superclass": null, // or { "class": "<id>", "type": "<param>" } — "class": canonical form (bare for same-module, "<module>.<Class>" for cross-module)
|
|
"methods": [
|
|
{ "name": "<id>",
|
|
"type": Type, // FnSig over the class param
|
|
"default": Term // optional fallback body; null = abstract-required
|
|
}
|
|
...
|
|
],
|
|
"doc": "<optional string>"
|
|
}
|
|
|
|
// instance (typeclass instance; narrative in contracts/0013-typeclasses.md)
|
|
{ "kind": "instance",
|
|
"class": "<id>", // class being instantiated; canonical form (bare for same-module, "<module>.<Class>" for cross-module)
|
|
"type": Type, // concrete type expression (never the class param)
|
|
"methods": [
|
|
{ "name": "<id>", "body": Term }
|
|
...
|
|
],
|
|
"doc": "<optional string>"
|
|
}
|
|
```
|
|
|
|
**`Suppress`** (entry in `FnDef.suppress`):
|
|
|
|
```jsonc
|
|
{ "code": "<diagnostic-code>", // e.g. "over-strict-mode"
|
|
"because": "<author reason>" // must be non-empty;
|
|
// empty/whitespace fires `empty-suppress-reason` (Error)
|
|
}
|
|
```
|
|
|
|
### Term (expression)
|
|
|
|
```jsonc
|
|
{ "t": "lit", "lit": Literal }
|
|
{ "t": "var", "name": "<id>" }
|
|
|
|
// fn application; tail flag triggers musttail under codegen.
|
|
// `tail` is omitted when false (hash-stable when omitted).
|
|
// `args` may be empty: a nullary call is the surface form
|
|
// `(app f)` (resolution of Gitea #12). Read-tolerant: a JSON
|
|
// document omitting the `args` key deserialises to `[]`.
|
|
{ "t": "app", "fn": Term, "args": [Term...], "tail": false }
|
|
|
|
{ "t": "let", "name": "<id>", "value": Term, "body": Term }
|
|
|
|
// Local recursive let. Always fn-shaped. The desugar pass
|
|
// lifts most `letrec` to a synthetic top-level fn; `lift_letrecs`
|
|
// finishes the job after typecheck for the residue that captures
|
|
// let-bound names. Post-codegen, no `letrec` survives.
|
|
{ "t": "letrec",
|
|
"name": "<id>", "type": Type, "params": ["<id>"...],
|
|
"body": Term, "in": Term }
|
|
|
|
{ "t": "if", "cond": Term, "then": Term, "else": Term }
|
|
|
|
// Effect-op invocation. `op` is "<eff>/<op>" (e.g. "io/print_str").
|
|
// `tail` triggers musttail (omitted when false).
|
|
{ "t": "do", "op": "<eff>/<op>", "args": [Term...], "tail": false }
|
|
|
|
// Ctor application. `args` is always emitted on write (including
|
|
// as `"args": []` for niladic ctors); reads tolerate the key being
|
|
// absent and treat it as `[]`. This mirrors the read/write
|
|
// asymmetry on `Term::App.args` (see above).
|
|
{ "t": "ctor", "type": "<id>", "ctor": "<id>", "args": [Term...] }
|
|
|
|
{ "t": "match", "scrutinee": Term, "arms": [Arm...] }
|
|
|
|
// Anonymous fn value; free vars captured from enclosing scope.
|
|
{ "t": "lam",
|
|
"params": ["<id>"...],
|
|
"param-types": [Type...],
|
|
"ret-type": Type,
|
|
"effects": ["<id>"...],
|
|
"body": Term }
|
|
|
|
// Sequencing. Semantically `let _ = lhs in rhs`; lhs must be Unit.
|
|
{ "t": "seq", "lhs": Term, "rhs": Term }
|
|
|
|
// Explicit RC clone. Codegen lowers as
|
|
// `call void @ailang_rc_inc(ptr %v)` before returning %v under `--alloc=rc`.
|
|
{ "t": "clone", "value": Term }
|
|
|
|
// Explicit reuse-as hint. `body` must be allocating
|
|
// (typically `ctor` or `lam`); `source` must be a bare `var`. Codegen
|
|
// lowers as in-place rewrite under `--alloc=rc`.
|
|
{ "t": "reuse-as", "source": Term, "body": Term }
|
|
|
|
// loop: strict iteration block. `binders` declares
|
|
// one or more loop parameters (name, type, init), evaluated in
|
|
// order on loop entry; `body` is in scope of all binders. The
|
|
// loop's value is `body`'s value on the iteration that exits via a
|
|
// non-`recur` branch. Strictly additive (no `skip_serializing_if`;
|
|
// pre-existing fixtures hash bit-identically — none carry the tag).
|
|
// No totality claim — an infinite loop is legal. See
|
|
// `docs/specs/0034-loop-recur.md`.
|
|
{ "t": "loop",
|
|
"binders": [ { "name": "<id>", "type": Type, "init": Term }, ... ],
|
|
"body": Term }
|
|
|
|
// recur: re-enter the lexically innermost enclosing
|
|
// `loop`, rebinding its binders positionally to `args`. Transfers
|
|
// control (no fall-through); valid only in tail position of its
|
|
// enclosing loop (enforced at typecheck, `recur-not-in-tail-position`).
|
|
{ "t": "recur",
|
|
"args": [ Term, ... ] }
|
|
|
|
// new: functional construction. Resolves `type` via type-scoped
|
|
// lookup to its home module, then calls the home module's `new`
|
|
// def with the supplied args. Each arg is a `NewArg` (see below).
|
|
// Type-args (kind = "type") instantiate the `new` def's outer
|
|
// `Forall` vars in declaration order; Value-args (kind = "value")
|
|
// are checked against the (substituted) param types. Strictly
|
|
// additive (no `skip_serializing_if`; pre-existing fixtures hash
|
|
// bit-identically — none carry the tag). See prep.2 of the
|
|
// kernel-extension-mechanics milestone.
|
|
{ "t": "new",
|
|
"type": "<TypeName>",
|
|
"args": [ NewArg, ... ] }
|
|
```
|
|
|
|
**`NewArg`** (one positional arg to a `(new T args...)` call):
|
|
|
|
```jsonc
|
|
// Type-positional arg: instantiates one of `new`'s outer Forall
|
|
// vars. The inner `value` carries a full `Type` JSON object.
|
|
{ "kind": "type", "value": Type }
|
|
|
|
// Value-positional arg: a `Term` checked against the corresponding
|
|
// substituted param type of `new`. The inner `value` carries a
|
|
// full `Term` JSON object.
|
|
{ "kind": "value", "value": Term }
|
|
```
|
|
|
|
In the MVP, `do` is only a direct call to a built-in effect op (no
|
|
handler); the effect system is described in [effects](../models/0002-effects.md).
|
|
A `lam` term constructs an anonymous function value; free
|
|
variables of its body are captured from the enclosing scope.
|
|
|
|
Loop binders are alloca-resident: typecheck binds them in the
|
|
ordinary local scope plus a positional `loop_stack`, and codegen
|
|
lowers them as entry-block allocas. Capturing a `loop` binder into a
|
|
lambda body is rejected at typecheck via
|
|
`CheckError::LoopBinderCapturedByLambda`. See
|
|
`docs/specs/0034-loop-recur.md`.
|
|
|
|
**`Literal`**:
|
|
|
|
```jsonc
|
|
{ "kind": "int", "value": <i64> }
|
|
{ "kind": "bool", "value": <bool> }
|
|
{ "kind": "str", "value": "<utf-8>" }
|
|
{ "kind": "unit" }
|
|
{ "kind": "float", "bits": "<16-lowercase-hex>" }
|
|
```
|
|
|
|
**`Pattern`** (the `pat` field of an `Arm`; discriminator `p`):
|
|
|
|
```jsonc
|
|
{ "p": "wild" } // _
|
|
{ "p": "var", "name": "<id>" } // x — binds the value
|
|
{ "p": "lit", "lit": Literal }
|
|
{ "p": "ctor", "ctor": "<id>", "fields": [Pattern...] } // fields omitted when empty
|
|
```
|
|
|
|
Patterns are linear: each pattern variable may appear at most once.
|
|
|
|
### Type
|
|
|
|
The `Type::Con.name` canonical-form rule (bare for same-module /
|
|
primitives, qualified `<module>.<TypeName>` for cross-module) lives
|
|
in [memory model](0008-memory-model.md); `Type::Fn`'s parameter-mode
|
|
metadata is defined and gated there as well.
|
|
|
|
```jsonc
|
|
// Type-constructor application. `args` omitted when empty
|
|
// (hash-stable when omitted, for non-parameterised cases like Int, Bool, ...).
|
|
{ "k": "con", "name": "<id>", "args": [Type...] } // "name": canonical form (bare for same-module / primitives, "<module>.<TypeName>" for cross-module)
|
|
|
|
// Function type. paramModes/retMode are metadata on Type::Fn —
|
|
// they are NOT separate Type variants, so every existing match-arm
|
|
// in the typechecker (unify, occurs, apply) keeps working.
|
|
// `paramModes` omitted when every entry is "implicit"; `retMode`
|
|
// omitted when "implicit" (hash-stable when omitted). Full mode
|
|
// contract lives in contracts/0008-memory-model.md.
|
|
{ "k": "fn",
|
|
"params": [Type...],
|
|
"paramModes": [ParamMode...],
|
|
"ret": Type,
|
|
"retMode": ParamMode,
|
|
"effects": ["<id>"...] }
|
|
|
|
{ "k": "var", "name": "<id>" }
|
|
|
|
// Top-level polymorphism only. `constraints` carries class
|
|
// constraints (narrative in contracts/0013-typeclasses.md); omitted when
|
|
// empty (hash-stable when omitted).
|
|
{ "k": "forall",
|
|
"vars": ["<id>"...],
|
|
"constraints": [{ "class": "<id>", "type": "<id>" }, ...], // "class": canonical form (bare for same-module, "<module>.<Class>" for cross-module)
|
|
"body": Type }
|
|
```
|
|
|
|
**`ParamMode`** (full contract in
|
|
[memory model](0008-memory-model.md)):
|
|
|
|
```
|
|
"implicit" — unannotated / back-compat. Treated as `own` by the typechecker.
|
|
"own" — (own T) — caller transfers ownership; callee consumes.
|
|
"borrow" — (borrow T) — caller retains ownership; callee may not consume.
|
|
```
|
|
|
|
`implicit ≡ own` semantically; the distinction exists so existing
|
|
unannotated fixtures continue to serialize without the mode wrapper and keep their
|
|
canonical-JSON hash. The full mode contract (codegen consequences,
|
|
the over-strict-mode lint, the `Suppress` mechanism) lives in
|
|
[memory model](0008-memory-model.md); the four language-design
|
|
preconditions that make RC sound live in
|
|
[language constraints](0015-language-constraints.md).
|
|
|
|
Ratified by: `crates/ailang-core/tests/design_schema_drift.rs`.
|