832375f2ac
All 176 files in the four accumulating directories now use a zero-padded 4-digit counter prefix that reflects creation order (`NNNN-slug.md`). The counter is assigned per directory in strict git-log creation order; ties broken alphabetically by original name. The old `YYYY-MM-DD-` prefix on docs/specs/ and docs/plans/ files is dropped — the date is recoverable from git log and the counter carries the ordering. A file's counter is stable for the life of the file: never reassigned, never reused, never compacted. Deleted files retire their counter; subsequent files do not fill the gap. This is the property that lets cross-references stay literal — refs use the full filename including the counter (`design/contracts/0007-honesty-rule.md`) so they grep cleanly and resolve directly without a glob step. 313 cross-references updated across .md/.rs/.toml/.c/.json files (test pins, include_str! paths, design-INDEX entries, baseline notes, runtime C comments, inter-contract markdown links incl. bare basename and `../models/foo.md` forms). CLAUDE.md gets a new "File-naming convention" section spelling out the rule and rationale. skills/brainstorm/SKILL.md and skills/planner/SKILL.md updated so new spec/plan creation produces counter-prefixed names from the start. The full test suite (cargo test --workspace) passes.
447 lines
20 KiB
Markdown
447 lines
20 KiB
Markdown
# Prelude / core decoupling — Design Spec
|
|
|
|
**Date:** 2026-05-14
|
|
**Status:** Draft — awaiting user spec review
|
|
**Authors:** Brummel (orchestrator) + Claude
|
|
|
|
## Goal
|
|
|
|
Retire `examples/prelude.ail.json` from the working tree and remove
|
|
all knowledge of the literal module name `"prelude"` from
|
|
`ailang-core`. After this milestone:
|
|
|
|
1. The single source of truth for the prelude is
|
|
`examples/prelude.ail` (Form A). The compiler reads it via
|
|
`include_str!` in `ailang-surface` and parses it with
|
|
`ailang_surface::parse` at compile time of the embedding crate.
|
|
2. `ailang-core` no longer embeds prelude bytes, no longer defines
|
|
the `ReservedModuleName` error, no longer auto-injects a module
|
|
named `"prelude"`, and no longer hardcodes the string `"prelude"`
|
|
in cross-module-reference diagnostics. core's `Workspace` loader
|
|
becomes a pure module-graph + schema-validator with no language-
|
|
content awareness.
|
|
3. The §C4 (b) compile-time-embed carve-out in
|
|
`docs/specs/0025-form-a-default-authoring.md` becomes empty
|
|
and is struck. The seven §C4 (a) subject-matter-rejection
|
|
fixtures remain the only `.ail.json` files in the corpus.
|
|
|
|
The motivation has two parts. First, the **doctrine inconsistency**:
|
|
CLAUDE.md states "authors write `.ail`; the build derives the JSON-
|
|
AST in-process via `ailang_surface::parse`". The prelude is the
|
|
single most-read AILang file in the corpus; that it is the *one*
|
|
file embedded into the compiler as JSON contradicts the doctrine.
|
|
Second, the **layering inconsistency**: `ailang-core`'s declared
|
|
role per CLAUDE.md is "AST, canonicalisation, desugaring, workspace
|
|
types, hash, pretty" — neither shipping language content nor
|
|
encoding the identity of a privileged module. The literal-`"prelude"`
|
|
references in core (8 occurrences across the inject step, the
|
|
diagnostic candidate sites, and the test cases) are the symptom of
|
|
that drift; this milestone removes them.
|
|
|
|
Out of scope:
|
|
|
|
- The implicit-prelude resolution in `ailang-check` (`crates/ailang-check/src/lib.rs:143`-area)
|
|
and the codegen-side implicit-prelude behaviour (iter 23.2.4).
|
|
Both already consume the prelude module by name from a populated
|
|
workspace; they do not embed prelude bytes and do not own the
|
|
reservation. They keep working unchanged because surface still
|
|
populates the prelude slot in every workspace.
|
|
- The `migrate-bare-cross-module-refs` subcommand's defensive
|
|
`include_str!` of `prelude.ail.json` at `crates/ail/src/main.rs:475`
|
|
— the inserted prelude is immediately skipped in the rewrite loop
|
|
(`if mod_name == "prelude" && path.as_os_str().is_empty()
|
|
{ continue; }`), so this is a no-op relic. Iter 1 deletes it
|
|
along with the core-side embed.
|
|
|
|
## Architecture
|
|
|
|
The cut is the β.2 shape from the brainstorm: core becomes fully
|
|
prelude-agnostic. The DFS-visitor and the schema-validation pipeline
|
|
that today live as one block inside `core::workspace::load_workspace_with`
|
|
split into two publicly composable phases. Surface composes them
|
|
with a prelude-injection step in between.
|
|
|
|
### Phase split
|
|
|
|
**Today (in core):**
|
|
|
|
```
|
|
load_workspace_with(entry, loader)
|
|
DFS visit (loader-driven)
|
|
insert prelude module (hardcoded inject)
|
|
validate_canonical_type_names
|
|
validate_classdefs
|
|
build_registry
|
|
-> Workspace
|
|
```
|
|
|
|
**After the milestone:**
|
|
|
|
```
|
|
core::load_modules_with(entry, loader)
|
|
DFS visit (loader-driven)
|
|
-> (entry_name, root_dir, BTreeMap<String, Module>)
|
|
|
|
surface::load_workspace(entry)
|
|
let (entry_name, root_dir, mut modules) = core::load_modules_with(entry, load_module)
|
|
if modules.contains_key("prelude") return Err(ReservedModuleName)
|
|
modules.insert("prelude", parse_prelude())
|
|
core::build_workspace(entry_name, root_dir, modules, &["prelude"])
|
|
|
|
core::build_workspace(entry_name, root_dir, modules, implicit_imports)
|
|
validate_canonical_type_names(modules, implicit_imports)
|
|
validate_classdefs(modules, implicit_imports)
|
|
build_registry(modules)
|
|
-> Workspace
|
|
```
|
|
|
|
### Why two phases, not one
|
|
|
|
A single `core::build_workspace` that takes already-assembled
|
|
modules works because every consumer of the validation pipeline
|
|
(canonical-type-names, classdefs, registry) needs the modules map
|
|
and the workspace shape — they do not need to know how those modules
|
|
got there. Splitting at the loader/validator seam lets surface
|
|
inject a special module without core knowing what makes it special.
|
|
|
|
### Why `implicit_imports: &[&str]` parameter
|
|
|
|
The diagnostic sites in `check_class_ref_qualifier` (line 1084-area)
|
|
and `check_type_con_name` (line 1148-area) currently scan
|
|
`local_classes.get("prelude")` / `local_types.get("prelude")` as a
|
|
fallback candidate when bare cross-module-refs fail to resolve. The
|
|
hardcoded `"prelude"` is the last sprach-konzept-as-string in core's
|
|
schema-validator path.
|
|
|
|
Replacing it with an `implicit_imports: &[&str]` parameter on
|
|
`validate_canonical_type_names` and `validate_classdefs` parameterises
|
|
the concept. core walks both the per-module declared imports AND
|
|
the caller-supplied implicit imports without knowing what name
|
|
makes one implicit. Surface passes `&["prelude"]`. A future caller
|
|
could pass a different set without core needing to change.
|
|
|
|
This is not speculative generality (rejected in the brainstorm for
|
|
β.3): it is the single mechanism that lets core stop hardcoding
|
|
`"prelude"`. Without parameterisation, the diagnostic sites would
|
|
either keep the hardcoded string (failing the milestone goal) or
|
|
the diagnostic candidate-list would lose its prelude-fallback
|
|
suggestion (a regression in error quality).
|
|
|
|
### Why `ReservedModuleName` moves to surface
|
|
|
|
The reservation `"prelude"` is an implementation choice of the
|
|
prelude-owning crate, not of the workspace loader. core has no
|
|
opinion on what module names are reserved; it discovers a graph
|
|
of named modules and validates their schema. Surface, which
|
|
injects a module named `"prelude"`, owns the reservation that
|
|
makes the injection safe (a user module of the same name would
|
|
shadow). The `WorkspaceLoadError` enum keeps the variant for ABI
|
|
stability; the variant moves to `ailang-surface` (or a new error
|
|
type composed with core's) — the exact placement is a Step-7
|
|
detail, see Components.
|
|
|
|
## Components
|
|
|
|
The change touches exactly three crates: `ailang-core`,
|
|
`ailang-surface`, `ail` (CLI). Tests across the workspace adapt
|
|
mechanically.
|
|
|
|
core's `src/` retains zero literal-`"prelude"` strings post-milestone.
|
|
The `WorkspaceLoadError::ReservedModuleName` variant declaration is
|
|
generic (`{ name: String }`) and does not name `"prelude"`; the
|
|
literal lives only at the single surface-side construction site.
|
|
This is the binding meaning of "core becomes prelude-agnostic" in
|
|
this spec.
|
|
|
|
### `ailang-core` changes
|
|
|
|
- `crates/ailang-core/src/workspace.rs`:
|
|
- **Remove**: `const PRELUDE_JSON` (line 417), `fn load_prelude`
|
|
(line 425), the inject step + reserved-name check inside
|
|
`load_workspace_with` (lines 493-504), the literal-`"prelude"`
|
|
fallbacks in `check_class_ref_qualifier` (lines 1084-1091) and
|
|
`check_type_con_name` (lines 1148-1155).
|
|
- **Replace** `pub fn load_workspace_with<F>(entry, loader)` with
|
|
two new public fns:
|
|
- `pub fn load_modules_with<F>(entry, loader) -> Result<(String, PathBuf, BTreeMap<String, Module>), WorkspaceLoadError>`
|
|
— returns `(entry_name, root_dir, modules)` with no
|
|
validation and no prelude.
|
|
- `pub fn build_workspace(entry_name, root_dir, modules, implicit_imports: &[&str]) -> Result<Workspace, WorkspaceLoadError>`
|
|
— runs the three-stage validation + registry construction.
|
|
`implicit_imports` is forwarded to `validate_canonical_type_names`;
|
|
the other two pipeline stages do not use it.
|
|
- **Retire** `pub fn load_workspace(entry)` (the JSON-only legacy
|
|
wrapper at line 437). It has no production callers; the few
|
|
in-core tests that use it migrate to either `surface::load_workspace`
|
|
via the existing dev-dep edge or to direct `load_modules_with`
|
|
+ `build_workspace` composition.
|
|
- **Move** the *production* of `WorkspaceLoadError::ReservedModuleName`
|
|
(line 315) out of core. The variant declaration stays in core's
|
|
`WorkspaceLoadError` enum — its name and shape
|
|
(`ReservedModuleName { name: String }`) reference no literal
|
|
`"prelude"`, so it does not by itself violate the
|
|
decoupling. core's `src/` contains zero call sites that
|
|
*construct* the variant after this milestone; surface's
|
|
`load_workspace` is the sole producer, supplying
|
|
`name: "prelude".to_string()`. Downstream match-arms in
|
|
CLI-diagnostic code are unaffected because the variant tag and
|
|
field shape are unchanged.
|
|
- **Update** `validate_canonical_type_names`'s signature to take
|
|
`implicit_imports: &[&str]`. Internal threading propagates the
|
|
slice through the per-def walk to the two diagnostic-site
|
|
helpers (`check_class_ref_qualifier` ~ line 1084 area,
|
|
`check_type_con_name` ~ line 1148 area). `validate_classdefs`
|
|
is **not** modified — it does not call those helpers and so has
|
|
no consumer of the literal-`"prelude"` fallback. (Spec's first
|
|
draft over-stated the threading scope; corrected here per the
|
|
grounding-check agent's finding.)
|
|
|
|
- `crates/ailang-core/tests/workspace_pin.rs`: the in-tree DFS
|
|
expectations that today assume prelude is auto-injected get
|
|
rewritten to use `surface::load_workspace` (already imported via
|
|
dev-dep) — same expected outputs, different entry point.
|
|
|
|
- `crates/ailang-core/tests/carve_out_inventory.rs`: remove the
|
|
`prelude.ail.json` entry from the §C4 (b) list (line 25). The
|
|
comment block at line 8 (`§C4 (b) compile-time-embed: 1 fixture
|
|
(prelude.ail.json).`) gets updated to "0 fixtures" and notes the
|
|
retirement.
|
|
|
|
### `ailang-surface` changes
|
|
|
|
- `crates/ailang-surface/src/loader.rs`:
|
|
- **Add** `const PRELUDE_AIL: &str = include_str!("../../../examples/prelude.ail");`
|
|
- **Add** `fn parse_prelude() -> Module` returning the parsed
|
|
prelude. Like the old `core::load_prelude`, this panics on
|
|
parse failure (build-time-validated).
|
|
- **Rewrite** `load_workspace(entry)` to compose the new core
|
|
primitives:
|
|
```rust
|
|
pub fn load_workspace(entry: &Path) -> Result<Workspace, WorkspaceLoadError> {
|
|
let (entry_name, root_dir, mut modules) =
|
|
ailang_core::load_modules_with(entry, load_module)?;
|
|
if modules.contains_key("prelude") {
|
|
return Err(WorkspaceLoadError::ReservedModuleName {
|
|
name: "prelude".to_string(),
|
|
});
|
|
}
|
|
modules.insert("prelude".to_string(), parse_prelude());
|
|
ailang_core::build_workspace(entry_name, root_dir, modules, &["prelude"])
|
|
}
|
|
```
|
|
- The `load_module` extension-dispatcher (line 37) is unchanged.
|
|
|
|
### `ail` (CLI) changes
|
|
|
|
- `crates/ail/src/main.rs`: delete the defensive prelude-include
|
|
block in the `migrate-bare-cross-module-refs` subcommand
|
|
(lines 472-484). The synthetic prelude entry was inserted only
|
|
to satisfy the loop's per-module iteration, then unconditionally
|
|
skipped in the rewrite. Removing both the include and the skip
|
|
branch is net-zero behaviour with one fewer dependency on
|
|
`prelude.ail.json`.
|
|
|
|
### Working-tree changes
|
|
|
|
- **Delete** `examples/prelude.ail.json`. After this commit the
|
|
prelude exists on disk only as `examples/prelude.ail`.
|
|
- After form-a.1 (T9), the per-pair `every_ail_fixture_matches_its_json_counterpart`
|
|
test was retired (no production `.ail`/`.ail.json` pairs remain
|
|
in the corpus except prelude itself). The deterministic-parse
|
|
property (`parse_is_deterministic_over_every_ail_fixture` and
|
|
`parse_then_print_then_parse_is_idempotent_on_every_ail_fixture`
|
|
in `crates/ailang-surface/tests/round_trip.rs`) stays as the
|
|
binding invariant for `prelude.ail`'s parse stability.
|
|
- **Cross-form-identity preflight** (mandatory before pd.2 swaps the
|
|
embed source — see Testing Strategy below for the mechanism):
|
|
capture `module_hash(serde_json::from_str(prelude.ail.json))` in
|
|
pd.2 BEFORE removing the JSON embed, and assert that
|
|
`module_hash(parse(prelude.ail))` equals it. Without this
|
|
preflight, the embed swap could silently change the canonical
|
|
prelude `Module` (different parse-path producing structurally
|
|
different AST) and propagate to every workspace's prelude
|
|
identity.
|
|
|
|
## Data flow
|
|
|
|
The runtime data flow does not change for any consumer. After the
|
|
milestone:
|
|
|
|
```
|
|
[examples/prelude.ail]
|
|
|
|
|
| include_str! (compile-time, into ailang-surface)
|
|
v
|
|
[PRELUDE_AIL: &'static str]
|
|
|
|
|
| parse_prelude() (called at every workspace load)
|
|
v
|
|
[Module]
|
|
|
|
|
v
|
|
surface::load_workspace
|
|
|
|
|
+--> core::load_modules_with # DFS + per-module load
|
|
| |
|
|
| v
|
|
| BTreeMap<String, Module> # no prelude yet
|
|
| |
|
|
| [check ReservedModuleName, insert prelude]
|
|
| |
|
|
| v
|
|
+--> core::build_workspace # validate + registry
|
|
|
|
|
v
|
|
Workspace # ready for check / codegen
|
|
```
|
|
|
|
`parse_prelude()` is called per workspace load (not memoised).
|
|
The prelude is ~120 lines; its parse cost is below the noise floor
|
|
of the rest of workspace loading (DFS reads, JSON deserialisation
|
|
of user modules, validation). If a future profiling pass shows it
|
|
as a hot spot, surface can introduce a `OnceLock<Module>` cache
|
|
locally without any API change.
|
|
|
|
## Error handling
|
|
|
|
### Existing error semantics — preserved
|
|
|
|
- `WorkspaceLoadError::ReservedModuleName { name: "prelude" }` —
|
|
same shape, fired by surface instead of core. Match-arms in
|
|
diagnostic-rendering code are unaffected because the enum variant
|
|
itself is unchanged.
|
|
- `WorkspaceLoadError::BareCrossModuleClassRef` /
|
|
`BareCrossModuleTypeRef` candidate lists — same shape, same
|
|
contents. The prelude-fallback candidate is now produced by the
|
|
generic `implicit_imports` walk, not by the hardcoded string.
|
|
|
|
### Build-time error: malformed `prelude.ail`
|
|
|
|
If `examples/prelude.ail` ever becomes unparseable, `parse_prelude()`
|
|
panics — same failure mode as the old `load_prelude()`'s
|
|
`expect("must parse as a Module")`. This is acceptable because the
|
|
prelude is build-time-validated by every test run; a malformed
|
|
prelude is a build-correctness bug, not a runtime concern.
|
|
|
|
### Migration error: stale callers of removed APIs
|
|
|
|
The retired `core::load_workspace` and the old `core::load_workspace_with`
|
|
signature are not behind any deprecation shim. Out-of-tree callers
|
|
(if any existed) would break at compile time with a clear "function
|
|
not found" / "wrong arity" error, not silently. Per the brainstorm
|
|
this milestone is internal-API-only; no stability promise to preserve.
|
|
|
|
## Testing strategy
|
|
|
|
Three new pin tests, all in `crates/ailang-surface/tests/`:
|
|
|
|
1. **`prelude_parse_pin.rs`** — pins that `parse_prelude()` succeeds
|
|
and produces a `Module` with `name == "prelude"` and an expected
|
|
minimum def-count (e.g. `>= 8` covering the four classes shipped
|
|
through milestones 22-24 plus their primitive instances). Guards
|
|
against `prelude.ail` being silently emptied.
|
|
|
|
2. **`prelude_module_hash_pin.rs`** — pins the `module_hash` of the
|
|
parsed prelude against a literal hex string captured at iter
|
|
close. This is the round-trip-stability anchor: any change to
|
|
`prelude.ail` that does not also re-parse to the same canonical
|
|
`Module` value flips this test red. The hex updates intentionally
|
|
on prelude-content changes, with a JOURNAL entry naming why.
|
|
|
|
**Cross-form-identity preflight** (added to the SAME test file as
|
|
the hash pin, in pd.2): before deleting the JSON embed source,
|
|
compute `let expected = module_hash(serde_json::from_str(PRELUDE_JSON))`
|
|
from the still-present `prelude.ail.json` bytes, then assert
|
|
`module_hash(parse(prelude.ail)) == expected`. This pins the
|
|
single load-bearing assumption that pd.2 introduces — that the
|
|
`.ail` parse path produces a `Module` structurally identical to
|
|
what the `.ail.json` deserialize path produces for the *same
|
|
prelude content*. The retired
|
|
`every_ail_fixture_matches_its_json_counterpart` test asserted
|
|
this for the entire corpus; this pin asserts it for the one file
|
|
that survives the corpus flip. The pin runs once (during pd.2),
|
|
then the JSON-half of the comparison disappears at pd.3 along
|
|
with the JSON file. The pin file itself transitions to
|
|
parse-only after pd.3.
|
|
|
|
3. **`prelude_decouple_carve_out_pin.rs`** — asserts that the file
|
|
`examples/prelude.ail.json` does NOT exist. Pins the milestone's
|
|
stated artefact retirement; rewriting it back into the tree
|
|
surfaces immediately.
|
|
|
|
Existing tests that continue to pin behaviour:
|
|
|
|
- `crates/ailang-core/tests/workspace_pin.rs` — DFS + module-graph
|
|
shape pins. Updated to call `surface::load_workspace`; outputs
|
|
identical.
|
|
- `crates/ailang-core/tests/carve_out_inventory.rs` — list updated
|
|
to reflect §C4 (b) becoming empty.
|
|
- The ~22 `ailang_surface::load_workspace` callers across the
|
|
workspace (production code in `crates/ail/src/main.rs` plus tests
|
|
in `ailang-check/`, `ailang-codegen/`, `ail/tests/`,
|
|
`ailang-core/tests/`) — public entry point unchanged, so no test
|
|
body or assertion changes. Verified by per-crate
|
|
`cargo test --workspace` green at unchanged baselines.
|
|
|
|
Regression baselines (`bench/check.py`, `bench/compile_check.py`,
|
|
`bench/cross_lang.py`) must stay green. None of them are expected
|
|
to regress because the runtime cost is identical (one parse per
|
|
workspace load, against a 120-line file).
|
|
|
|
## Acceptance criteria
|
|
|
|
The milestone closes when ALL of the following hold:
|
|
|
|
1. `examples/prelude.ail.json` is removed from the working tree.
|
|
2. `cargo build --workspace` succeeds with no `include_str!`
|
|
reference to `prelude.ail.json` anywhere in the workspace.
|
|
Verified by `git grep -l 'prelude\.ail\.json' -- '*.rs'`
|
|
returning only test/comment hits, not `include_str!` sites.
|
|
3. `git grep -l '"prelude"' crates/ailang-core/src/` returns no
|
|
matches (zero literal-`"prelude"` strings in core's `src/`).
|
|
4. `cargo test --workspace` is green, including the three new
|
|
pins above.
|
|
5. `bench/check.py`, `bench/compile_check.py`, `bench/cross_lang.py`
|
|
are green at unchanged baselines.
|
|
6. `docs/specs/0025-form-a-default-authoring.md` §C4 (b) is
|
|
updated: the carve-out list is empty; the section either
|
|
deletes the (b) sub-bullet or notes "retired by
|
|
prelude-decouple milestone, 2026-05-14".
|
|
7. CLAUDE.md and `docs/DESIGN.md` reviewed: no statement still
|
|
asserts that core embeds the prelude. (Spot check — the
|
|
relevant CLAUDE.md sentence is the "authors write `.ail`" line,
|
|
which already aligns with the post-milestone state.)
|
|
|
|
## Iteration sketch
|
|
|
|
Tentative iter split (refined by `planner`):
|
|
|
|
- **iter 1 — `pd.1` core API split.** Introduce `load_modules_with`
|
|
and `build_workspace`; refit `load_workspace_with` as a deprecated
|
|
alias if needed during the transition (or delete outright).
|
|
Thread `implicit_imports: &[&str]` through the two validation
|
|
functions and their internal helpers. Move `ReservedModuleName`
|
|
off the core construction path. core changes only; no surface
|
|
changes yet. Tests stay green using the old wiring (surface
|
|
unchanged).
|
|
|
|
- **iter 2 — `pd.2` surface assumes ownership.** Add `PRELUDE_AIL`
|
|
+ `parse_prelude` to `ailang-surface`. Rewrite
|
|
`surface::load_workspace` to compose the new core primitives and
|
|
inject the parsed prelude. Remove the prelude embed from core.
|
|
Add the three new pins. **Critical preflight**: install the
|
|
cross-form-identity test (see Testing Strategy above) BEFORE
|
|
deleting the core-side JSON embed. The test must be green with
|
|
both forms still present, so the swap is justified by an active
|
|
green pin and not by hope.
|
|
|
|
- **iter 3 — `pd.3` retire `prelude.ail.json` + carve-out cleanup.**
|
|
Delete `examples/prelude.ail.json`. Delete the defensive include
|
|
in the migrate-subcommand. Update `carve_out_inventory.rs` and
|
|
the form-a-default-authoring spec §C4 (b). Run all benches.
|
|
|
|
The split is for plan visibility; the orchestrator may collapse to
|
|
two iters if pd.1 and pd.2 turn out small enough that one cohesive
|
|
commit covers both. pd.3 is its own iter so the artefact retirement
|
|
is auditable in isolation.
|