iter pd.2: surface owns prelude embed; pd.1 shim retired

Moved PRELUDE_AIL const + parse_prelude fn to ailang-surface;
re-exported from surface's lib.rs. surface::load_workspace rewritten
3-line shim-call → 7-line composition: load_modules_with → reserved-name
check → inject parse_prelude → build_workspace(&["prelude"]).

Five deletions in core: PRELUDE_JSON, load_prelude, load_workspace_with
(the pd.1 shim), the top-level cfg(test) load_one fn, and the
cfg(test) crate::load_module import. Production literal-"prelude" count
in crates/ailang-core/src/ is now zero. Cross-form-identity preflight
PASSED at module_hash 3abe0d3fa3c11c99 — parse(prelude.ail) ≡
deserialize(prelude.ail.json), so pd.3's deletion of the JSON is safe.

Three new pin tests in ailang-surface/tests/: prelude_parse_pin (succeeds
+ min defs); prelude_module_hash_pin (cross-form-identity + literal-hex
anchor); prelude_injection_pin (inject + reservation).

Plan deviations (recorded in journal): switched the regression-pin
fixture from non-existent examples/hello.ail.json to hello.ail; relocated
10 in-mod core tests to crates/ailang-core/tests/workspace_pin.rs
because the dev-dep cycle (core ↔ surface) prevents in-mod aliasing in
the lib-test target (form-a.1 T5 precedent); preserved load_one
production-symbol-deletion by adding a test-mod-private load_one helper
in core's mod tests for the 2 pd.1-introduced tests still consuming it.

cargo test --workspace 573 green (+9 net from pd.1 baseline).
bench/cross_lang.py + bench/compile_check.py exit 0; bench/check.py
exit 1 with established noise envelope (bench_list_sum.bump_s, 13th
consecutive observation; pd.2 is workspace-loader-only, no codegen
touch — baseline pristine).
This commit is contained in:
2026-05-14 12:57:27 +02:00
parent 116157a2b8
commit 008d18bb18
10 changed files with 701 additions and 323 deletions
+107 -303
View File
@@ -1,50 +1,35 @@
//! Workspace loader: loads an entry module and recursively follows its
//! `imports`.
//! Workspace loading and per-workspace validation.
//!
//! Convention: an `import { module: "foo" }` is resolved relative to
//! the entry file's directory as `<root_dir>/foo.ail.json`. Module
//! names must match the file stem (the loader rejects mismatches).
//! Two phases, exposed as separate public fns so callers can compose
//! them around an injection point (e.g. surface's prelude inject):
//!
//! The pd.1 single core entry point is [`load_workspace_with`]; it
//! takes a caller-supplied per-module loader so surface (`.ail`) and
//! JSON (`.ail.json`) callers share one pipeline. It returns a fully
//! populated [`Workspace`] or a structured [`WorkspaceLoadError`].
//! pd.2 will move the public entry point into `ailang-surface` and
//! retire this shim. [`module_hash`] is the module-granularity
//! counterpart to [`crate::def_hash`] and is used both internally (to
//! detect a module re-loaded from disk with different content) and by
//! the CLI.
//! - [`load_modules_with`] — DFS over `imports`, returns a modules
//! map with no validation and no implicit modules. The caller
//! supplies a per-module loader.
//! - [`build_workspace`] — runs the three-stage validation pipeline
//! (`validate_canonical_type_names`, `validate_classdefs`,
//! `build_registry`) plus [`Workspace`] assembly. Takes
//! `implicit_imports: &[&str]` so the diagnostic helpers can name
//! modules that exist but are not in any user import list.
//!
//! This module is responsible only for **finding** and consistently
//! **loading** all reachable modules. Cross-module typechecking lives
//! in `ailang-check`; codegen in `ailang-codegen`. Neither is run from
//! here.
//! The public composition lives in `ailang-surface`'s `load_workspace`
//! (which adds a prelude inject step between the two phases). Direct
//! consumers of these core fns are `ailang-surface` and a handful of
//! tests; CLI dispatch goes through surface.
//!
//! # Examples
//! This module is responsible only for **finding**, **assembling**,
//! and **schema-validating** the module graph. Cross-module
//! typechecking lives in `ailang-check`. Cross-module monomorphisation
//! and codegen live in `ailang-codegen`.
//!
//! ```ignore
//! // pd.2 will move the public entry point to ailang-surface;
//! // pd.1 keeps `load_workspace_with` as the single core entry point.
//! // Callers supply a per-module loader (e.g.
//! // `ailang_surface::load_module_dispatching`) that knows how to
//! // read `.ail` and/or `.ail.json` files.
//! use ailang_core::workspace::load_workspace_with;
//! use std::path::Path;
//!
//! let ws = load_workspace_with(Path::new("examples/ws_main.ail.json"), my_loader)?;
//! assert_eq!(ws.entry, "ws_main");
//! // All transitively imported modules are now in `ws.modules`.
//! for (name, m) in &ws.modules {
//! println!("{name}: {} defs", m.defs.len());
//! }
//! # Ok::<(), ailang_core::workspace::WorkspaceLoadError>(())
//! ```
//! [`module_hash`] is the module-granularity counterpart to
//! [`crate::def_hash`]; used internally to detect a re-loaded module
//! with different content, and by the CLI for stable per-module
//! identifiers.
use crate::ast::{ClassDef, Def, InstanceDef, Module, Term, Type};
use crate::canonical;
use crate::Error as CoreError;
#[cfg(test)]
use crate::load_module;
use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::path::{Path, PathBuf};
@@ -421,23 +406,6 @@ pub fn module_hash(m: &Module) -> String {
h.to_hex().as_str()[..16].to_string()
}
/// Iter 23.1: the prelude module is embedded into the binary at
/// compile time so the loader needs no on-disk resolution. The
/// canonical source-of-truth file remains
/// `examples/prelude.ail.json` (the file the LLM-author edits).
const PRELUDE_JSON: &str = include_str!(
"../../../examples/prelude.ail.json"
);
/// Iter 23.1: parse the embedded prelude source into a `Module`.
/// Panics on parse failure — the prelude is build-time-validated;
/// a failure here means the embedded file is malformed and is a
/// build-correctness bug, not a runtime concern.
fn load_prelude() -> Module {
serde_json::from_str(PRELUDE_JSON)
.expect("examples/prelude.ail.json must parse as a Module")
}
/// pd.1: extract the DFS pre-amble of `load_workspace_with` into a public
/// loader-only fn. Returns `(entry_name, root_dir, modules)` with NO prelude
/// injected and NO validation run. The caller is responsible for injecting
@@ -522,43 +490,12 @@ pub fn build_workspace(
})
}
/// pd.1: thin compatibility shim composed of `load_modules_with` +
/// `build_workspace` plus the prelude inject. Preserves today's
/// `load_workspace_with` semantics so surface (`crates/ailang-surface/src/loader.rs`)
/// stays unchanged in pd.1. pd.2 will rewire surface to call
/// `load_modules_with` and `build_workspace` directly and retire this
/// shim along with the embedded `PRELUDE_JSON` constant.
///
/// Algorithm: DFS over `imports` via `load_modules_with`, then inject
/// the prelude (rejecting collision with a user module of the same
/// name), then validate + build the typeclass registry via
/// `build_workspace`.
///
/// ext-cli.1: a caller-supplied loader is what allows
/// `ailang-surface::load_workspace` to plug in an extension-dispatching
/// loader without `ailang-core` importing `ailang-surface` (which would
/// close a crate cycle).
pub fn load_workspace_with<F>(
entry_path: &Path,
loader: F,
) -> Result<Workspace, WorkspaceLoadError>
where
F: Fn(&Path) -> Result<Module, WorkspaceLoadError> + Copy,
{
let (entry_name, root_dir, mut modules) = load_modules_with(entry_path, loader)?;
// Iter 23.1 (preserved through the shim): the prelude module is
// implicitly part of every workspace. A user module of the same name
// would shadow; reject explicitly.
if modules.contains_key("prelude") {
return Err(WorkspaceLoadError::ReservedModuleName {
name: "prelude".to_string(),
});
}
modules.insert("prelude".to_string(), load_prelude());
build_workspace(entry_name, root_dir, modules, &["prelude"])
}
// pd.1: `load_workspace_with` shim retired in pd.2. The public entry
// point is now `ailang_surface::load_workspace`, which composes
// `load_modules_with` + caller-side prelude inject + `build_workspace`
// directly. Surface owns the prelude inject step (the embed lives at
// `ailang_surface::PRELUDE_AIL` + `ailang_surface::parse_prelude`); core
// exposes the two-phase composition.
/// mq.1: take a class-ref field value (`InstanceDef.class`,
/// `SuperclassRef.class`, `Constraint.class`) and a defining context,
@@ -1595,20 +1532,11 @@ where
Ok(())
}
#[cfg(test)]
fn load_one(path: &Path) -> Result<Module, WorkspaceLoadError> {
match load_module(path) {
Ok(m) => Ok(m),
Err(CoreError::Io(e)) => Err(WorkspaceLoadError::Io {
path: path.to_path_buf(),
source: e,
}),
Err(e) => Err(WorkspaceLoadError::Schema {
path: path.to_path_buf(),
source: e,
}),
}
}
// pd.2: `load_one` deleted along with the `load_workspace_with` shim.
// The two surviving in-mod tests that need a JSON-only single-module
// loader (`load_modules_with_returns_modules_without_prelude` and
// `load_modules_with_custom_loader_is_called`) inline the
// `crate::load_module` adapter directly.
fn module_name_from_path(p: &Path) -> String {
let file = p.file_name().and_then(|s| s.to_str()).unwrap_or("");
@@ -1629,6 +1557,25 @@ mod tests {
use super::*;
use std::fs;
/// pd.2: tests that need a JSON-only single-module loader (the
/// pre-pd.2 `load_one` shape) inline this adapter. Wraps
/// `crate::load_module` and converts the `Error` variants into
/// `WorkspaceLoadError`. Used by the pd.1-introduced tests that
/// exercise `load_modules_with` directly.
fn load_one(path: &Path) -> Result<Module, WorkspaceLoadError> {
match crate::load_module(path) {
Ok(m) => Ok(m),
Err(CoreError::Io(e)) => Err(WorkspaceLoadError::Io {
path: path.to_path_buf(),
source: e,
}),
Err(e) => Err(WorkspaceLoadError::Schema {
path: path.to_path_buf(),
source: e,
}),
}
}
fn write_module(dir: &Path, name: &str, imports: &[&str]) -> PathBuf {
let imports_json: Vec<serde_json::Value> = imports
.iter()
@@ -1662,59 +1609,11 @@ mod tests {
// (See workspace_pin.rs relocation marker above.)
#[test]
fn user_module_named_prelude_is_rejected() {
// Iter 23.1: the loader auto-injects a module named "prelude",
// so a user module that also claims that name would collide
// silently. The collision is caught explicitly with
// `ReservedModuleName`.
let dir = tmp_dir("prelude_collision");
write_module(&dir, "prelude", &[]);
let entry = dir.join("prelude.ail.json");
let err = load_workspace_with(&entry, load_one).expect_err("must reject user prelude");
match err {
WorkspaceLoadError::ReservedModuleName { name } => {
assert_eq!(name, "prelude");
}
other => panic!(
"expected ReservedModuleName, got: {other:?}"
),
}
}
#[test]
fn detects_import_cycle() {
let dir = tmp_dir("cycle");
write_module(&dir, "a", &["b"]);
write_module(&dir, "b", &["a"]);
let entry = dir.join("a.ail.json");
let err = load_workspace_with(&entry, load_one).expect_err("must error on cycle");
match err {
WorkspaceLoadError::Cycle { path } => {
assert!(path.contains(&"a".to_string()));
assert!(path.contains(&"b".to_string()));
}
other => panic!("expected Cycle, got {other:?}"),
}
}
#[test]
fn module_not_found_yields_structured_error() {
let dir = tmp_dir("notfound");
write_module(&dir, "main", &["does_not_exist"]);
let entry = dir.join("main.ail.json");
let err = load_workspace_with(&entry, load_one).expect_err("must error on missing module");
match err {
WorkspaceLoadError::ModuleNotFound { name, expected_path } => {
assert_eq!(name, "does_not_exist");
assert!(expected_path.ends_with("does_not_exist.ail.json"));
}
other => panic!("expected ModuleNotFound, got {other:?}"),
}
}
// pd.2: `user_module_named_prelude_is_rejected`,
// `detects_import_cycle`, and `module_not_found_yields_structured_error`
// relocated to `crates/ailang-core/tests/workspace_pin.rs` (the
// ailang-surface dev-dep cycle disallows `surface::load_workspace`
// calls from this in-mod test crate).
// Iter 22b.1: a workspace whose own modules contain no
// `Def::Instance` defs produces no non-prelude registry entries.
@@ -1783,48 +1682,11 @@ mod tests {
// `iter22b1_missing_method_fires_diagnostic` relocated to
// `crates/ailang-core/tests/workspace_pin.rs` in iter form-a.1 Task 5.
#[test]
fn class_param_in_applied_position_fires_canonical_form_rejection() {
let entry = std::path::PathBuf::from(
"../../examples/test_22b2_kind_mismatch.ail.json",
);
let err = load_workspace_with(&entry, load_one)
.expect_err("must fire canonical-form rejection");
match err {
WorkspaceLoadError::BareCrossModuleTypeRef { module, name, .. } => {
assert_eq!(module, "test_22b2_kind_mismatch");
assert_eq!(name, "f");
}
other => panic!(
"expected BareCrossModuleTypeRef (validator now fires first), got {other:?}",
),
}
}
/// Iter 22b.2: a class whose `superclass.type` differs from its
/// own `param` (e.g. `class Ord a extends Eq b`) must fire
/// `InvalidSuperclassParam`. Decision 11 axis 1 ("single
/// superclass, applied to the same param") makes the only legal
/// shape `extends Eq a` when the parent class has `param: "a"`.
#[test]
fn superclass_with_wrong_param_fires_invalid_superclass_param() {
let entry = std::path::PathBuf::from(
"../../examples/test_22b2_invalid_superclass_param.ail.json",
);
let err = load_workspace_with(&entry, load_one)
.expect_err("must fire invalid-superclass-param");
match err {
WorkspaceLoadError::InvalidSuperclassParam {
class, superclass, expected_param, got_type,
} => {
assert_eq!(class, "Ord");
assert_eq!(superclass, "Eq");
assert_eq!(expected_param, "a");
assert_eq!(got_type, "b");
}
other => panic!("expected InvalidSuperclassParam, got {other:?}"),
}
}
// `class_param_in_applied_position_fires_canonical_form_rejection`
// and `superclass_with_wrong_param_fires_invalid_superclass_param`
// relocated to `crates/ailang-core/tests/workspace_pin.rs` in iter
// pd.2 (post-shim retirement; in-mod tests cannot reach
// `ailang_surface::load_workspace` due to the dev-dep cycle).
// Iter 22b.2: an instance that specifies a body for a method
// name the class never declared must fire
@@ -1839,31 +1701,9 @@ mod tests {
// `instance_overriding_nonexistent_method_fires` relocated to
// `crates/ailang-core/tests/workspace_pin.rs` in iter form-a.1 Task 5.
/// Iter 22b.2: a class method's `Type::Forall` whose `constraints`
/// reference a type variable that is neither bound by `Forall.vars`
/// nor equal to the class's `param` must fire
/// `UnboundConstraintTypeVar`. The fixture's `class Foo a` declares
/// method `foo` with `forall a. (Bar z) => a -> Unit`; `z` is
/// bound nowhere, so the constraint is unsatisfiable by
/// construction.
#[test]
fn constraint_with_unbound_var_fires_unbound_constraint_type_var() {
let entry = std::path::PathBuf::from(
"../../examples/test_22b2_unbound_constraint_var.ail.json",
);
let err = load_workspace_with(&entry, load_one)
.expect_err("must fire constraint-references-unbound-type-var");
match err {
WorkspaceLoadError::UnboundConstraintTypeVar {
class, method, var, ..
} => {
assert_eq!(class, "Foo");
assert_eq!(method, "foo");
assert_eq!(var, "z");
}
other => panic!("expected UnboundConstraintTypeVar, got {other:?}"),
}
}
// `constraint_with_unbound_var_fires_unbound_constraint_type_var`
// relocated to `crates/ailang-core/tests/workspace_pin.rs` in iter
// pd.2.
// mq.3: the two `..._method_name_collision_fires` pin tests
// (class-class and class-fn variants) were retired here and
@@ -2485,34 +2325,13 @@ mod tests {
/// `Ordering` Term::Ctor with no imports; the validator catches it
/// after prelude injection (so the candidate list contains
/// `prelude.Ordering`).
#[test]
fn ct1_fixture_bare_xmod_rejected() {
let entry = examples_dir().join("test_ct1_bare_xmod_rejected.ail.json");
let err = load_workspace_with(&entry, load_one).expect_err("must reject bare Ordering");
match err {
WorkspaceLoadError::BareCrossModuleTypeRef { module, name, candidates } => {
assert_eq!(module, "test_ct1_bare_xmod_rejected");
assert_eq!(name, "Ordering");
assert_eq!(candidates, vec!["prelude.Ordering".to_string()]);
}
other => panic!("expected BareCrossModuleTypeRef, got {other:?}"),
}
}
// `ct1_fixture_bare_xmod_rejected` relocated to
// `crates/ailang-core/tests/workspace_pin.rs` in iter pd.2.
/// ct.1: on-disk fixture for `BadCrossModuleTypeRef`. Qualified
/// `Mystery.Type` with `Mystery` not a known module.
#[test]
fn ct1_fixture_bad_qualifier() {
let entry = examples_dir().join("test_ct1_bad_qualifier.ail.json");
let err = load_workspace_with(&entry, load_one).expect_err("must reject Mystery.Type");
match err {
WorkspaceLoadError::BadCrossModuleTypeRef { module, name } => {
assert_eq!(module, "test_ct1_bad_qualifier");
assert_eq!(name, "Mystery.Type");
}
other => panic!("expected BadCrossModuleTypeRef, got {other:?}"),
}
}
// `ct1_fixture_bad_qualifier` relocated to
// `crates/ailang-core/tests/workspace_pin.rs` in iter pd.2.
/// mq.1: on-disk fixture for the post-mq.1 rejection path. The
/// fixture declares `instance prelude.Eq Int` outside the prelude
@@ -2524,21 +2343,8 @@ mod tests {
/// Pre-mq.1 this test asserted `QualifiedClassName` on the same
/// fixture; the rename + reshape is the on-disk-fixture half of
/// the four in-test inversions further up.
#[test]
fn ct1_fixture_qualified_class_orphan_post_mq1() {
let entry = examples_dir().join("test_ct1_qualified_class_rejected.ail.json");
let err = load_workspace_with(&entry, load_one).expect_err("must reject (now as Orphan)");
match err {
WorkspaceLoadError::OrphanInstance {
class, type_repr, defining_module, ..
} => {
assert_eq!(class, "prelude.Eq");
assert_eq!(type_repr, "Int");
assert_eq!(defining_module, "test_ct1_qualified_class_rejected");
}
other => panic!("expected OrphanInstance, got {other:?}"),
}
}
// `ct1_fixture_qualified_class_orphan_post_mq1` relocated to
// `crates/ailang-core/tests/workspace_pin.rs` in iter pd.2.
// mq.1: on-disk fixture pin — a workspace where the consumer's
// `Constraint.class` references a class in an imported module via
@@ -2549,14 +2355,15 @@ mod tests {
// `mq1_xmod_constraint_class_fixture_loads` relocated to
// `crates/ailang-core/tests/workspace_pin.rs` in iter form-a.1 Task 5.
/// ext-cli.1 Task 1: the new `load_workspace_with` injection point
/// invokes the caller-supplied loader exactly once for the entry
/// module of a single-module workspace. Protects against the
/// extension-dispatching loader (in `ailang-surface`) silently
/// falling back to `load_one` and so never being given a `.ail`
/// path to read.
/// ext-cli.1 Task 1 / pd.2: the loader-injection contract is now
/// owned by `load_modules_with` (the DFS-only loader) directly,
/// since the `load_workspace_with` shim is gone. The custom loader
/// must be invoked exactly once for the entry module of a single-
/// module workspace. Protects against the extension-dispatching
/// loader (in `ailang-surface`) silently falling back to a
/// JSON-only path and so never being given a `.ail` to read.
#[test]
fn load_workspace_with_custom_loader_is_called() {
fn load_modules_with_custom_loader_is_called() {
use std::sync::atomic::{AtomicUsize, Ordering};
let tmpdir = tempfile::tempdir().expect("tempdir");
@@ -2568,12 +2375,22 @@ mod tests {
let loader = |path: &std::path::Path| -> Result<Module, WorkspaceLoadError> {
CALLS.fetch_add(1, Ordering::SeqCst);
load_one(path)
crate::load_module(path).map_err(|e| match e {
CoreError::Io(io) => WorkspaceLoadError::Io {
path: path.to_path_buf(),
source: io,
},
other => WorkspaceLoadError::Schema {
path: path.to_path_buf(),
source: other,
},
})
};
let ws = load_workspace_with(&entry, loader)
let (entry_name, _root_dir, modules) = load_modules_with(&entry, loader)
.expect("workspace loads via custom loader");
assert_eq!(ws.entry, "dummy");
assert_eq!(entry_name, "dummy");
assert!(modules.contains_key("dummy"));
assert_eq!(
CALLS.load(Ordering::SeqCst),
1,
@@ -2827,8 +2644,16 @@ mod tests {
let (entry_name, root_dir, mut modules) =
load_modules_with(&entry, load_one).expect("load");
// Caller-side prelude inject (mirrors what surface will do in pd.2).
let prelude = load_prelude();
// Caller-side prelude inject — pd.2 deletes `load_prelude` from
// production, but this test still needs SOMETHING in the
// `prelude` slot to demonstrate the caller-injects pattern.
// Read the JSON sidecar via the same path the retired
// `load_prelude` used. Surface owns the production inject path
// (via `ailang_surface::parse_prelude`).
const PRELUDE_JSON: &str =
include_str!("../../../examples/prelude.ail.json");
let prelude: Module = serde_json::from_str(PRELUDE_JSON)
.expect("examples/prelude.ail.json must parse as a Module");
modules.insert("prelude".to_string(), prelude);
let ws = build_workspace(entry_name, root_dir, modules, &["prelude"])
@@ -2911,29 +2736,8 @@ mod tests {
}
}
/// pd.1 Task 4: `load_workspace_with` after the shim refit
/// produces a `Workspace` byte-identical to today's. Pinned by
/// asserting prelude injection still happens AND the prelude
/// module's hash matches the freshly-loaded prelude — i.e. the
/// shim composition (`load_modules_with` + inject + `build_workspace`)
/// preserves the pre-refit observable behaviour.
#[test]
fn load_workspace_with_shim_preserves_today_semantics() {
let dir = tempfile::tempdir().unwrap();
write_module(dir.path(), "main", &[]);
let entry = dir.path().join("main.ail.json");
let ws = load_workspace_with(&entry, load_one).expect("load");
assert_eq!(ws.entry, "main");
assert!(ws.modules.contains_key("main"));
assert!(
ws.modules.contains_key("prelude"),
"shim must still inject prelude"
);
assert_eq!(
module_hash(&ws.modules["prelude"]),
module_hash(&load_prelude()),
);
}
// pd.2: `load_workspace_with_shim_preserves_today_semantics` deleted —
// the shim it pinned is gone. Cross-form-identity / hash-pin
// coverage now lives in
// `crates/ailang-surface/tests/prelude_module_hash_pin.rs`.
}