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
@@ -0,0 +1,19 @@
{
"iter_id": "pd.2",
"date": "2026-05-14",
"mode": "standard",
"outcome": "DONE",
"tasks_total": 6,
"tasks_completed": 6,
"reloops_per_task": {
"1": 0,
"2": 0,
"3": 0,
"4": 0,
"5": 0,
"6": 0
},
"review_loops_spec": 0,
"review_loops_quality": 0,
"blocked_reason": null
}
+18 -11
View File
@@ -13,12 +13,16 @@
//! canonical bytes, which is how the rest of the pipeline refers to it.
//! - **Pretty-printing** — see [`pretty`]. AST → human-readable text for
//! diff/review. The JSON form remains the source of truth.
//! - **Workspace loading** — see [`workspace`] and
//! [`workspace::load_workspace_with`]. Resolves transitive imports of
//! an entry module and returns a [`Workspace`]. pd.1: the JSON-only
//! wrapper `load_workspace` was retired; the loader-injection shim
//! `load_workspace_with` is the single core entry point in pd.1, and
//! pd.2 will move the public entry point into `ailang-surface`.
//! - **Workspace loading** — see [`workspace`],
//! [`workspace::load_modules_with`], and [`workspace::build_workspace`].
//! Core exposes the two-phase composition: DFS-only loader returning a
//! modules-map, then a separate validation + registry-construction
//! step that takes a caller-supplied `implicit_imports` slice. The
//! public single-call entry point lives in `ailang-surface` (see
//! [`ailang_surface::load_workspace`](https://docs.rs/ailang-surface)),
//! which composes the two halves with a prelude-inject step in
//! between. pd.2 retired the pd.1 `load_workspace_with` shim and
//! moved the prelude embed into surface.
//! - **AST → AST desugaring** — see [`desugar`]. Pure rewrite that runs
//! *after* `load_module` and before the typechecker / codegen
//! consume a module. Iter 16a flattens nested constructor patterns
@@ -47,8 +51,10 @@
//! ## Entry points
//!
//! - Read a single module from disk: [`load_module`].
//! - Read an entry module plus all imports:
//! [`workspace::load_workspace_with`].
//! - Assemble a workspace from an entry module plus all imports:
//! `ailang_surface::load_workspace` (which composes
//! [`workspace::load_modules_with`] + a prelude inject +
//! [`workspace::build_workspace`]).
//! - Hash a [`Def`]: [`def_hash`]. Hash a whole [`Module`]:
//! [`workspace::module_hash`].
//! - Form-(A) text projection of a [`Module`]: see
@@ -117,9 +123,10 @@ pub const FORM_A_SPEC: &str = include_str!("../specs/form_a.md");
/// into a [`Module`].
///
/// This is the lowest-level entry point and does **not** resolve imports
/// — for the transitive closure use [`workspace::load_workspace_with`].
/// Errors are returned as [`Error::Io`] / [`Error::Json`] /
/// [`Error::SchemaMismatch`].
/// — for the transitive closure use `ailang_surface::load_workspace`
/// (which composes [`workspace::load_modules_with`] + a prelude inject
/// + [`workspace::build_workspace`]). Errors are returned as
/// [`Error::Io`] / [`Error::Json`] / [`Error::SchemaMismatch`].
pub fn load_module(path: &std::path::Path) -> Result<Module> {
let bytes = std::fs::read(path)?;
let module: Module = serde_json::from_slice(&bytes)?;
+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`.
}
+209 -4
View File
@@ -6,10 +6,16 @@
//! so the post-iter `.ail` corpus is loaded from Form A rather than the
//! deleted `.ail.json` siblings.
//!
//! Carve-out tests (the seven §C4 (a) subject-matter rejections and the
//! prelude embed) stay in-place in `workspace.rs` because their
//! `.ail.json` fixtures are not migrated; this file covers the
//! non-carve-out cohort only.
//! pd.2 extension: the second batch (10 tests at the end of this file)
//! is relocated from `ailang-core/src/workspace.rs::tests` because pd.2
//! deletes the `load_workspace_with` shim those tests depended on.
//! Their post-pd.2 successor is `ailang_surface::load_workspace`, which
//! is unreachable from `ailang-core` lib-tests due to the dev-dep cycle
//! (`ailang-core` dev-deps `ailang-surface` which depends on
//! `ailang-core` — fine for integration tests, breaks lib-tests). The
//! integration-test crate is the right home: it consumes both crates
//! against the same `ailang-core` artefact, so the cycle never
//! materialises.
use ailang_core::workspace::{Registry, RegistryEntry, WorkspaceLoadError};
use ailang_surface::load_workspace;
@@ -281,3 +287,202 @@ fn mq1_xmod_constraint_class_fixture_loads() {
assert!(ws.modules.contains_key("mq1_xmod_constraint_class"));
assert!(ws.modules.contains_key("mq1_xmod_constraint_class_dep"));
}
// ---------------------------------------------------------------------
// pd.2 relocations from `ailang-core/src/workspace.rs::tests` (the
// post-shim-retirement batch). Each test below was a `load_workspace_with`
// caller in the in-mod tests; post-pd.2 the shim is gone and these
// tests now go through `ailang_surface::load_workspace` (which composes
// `load_modules_with` + caller-side prelude inject + `build_workspace`).
//
// `tmp_dir` + `write_module` helpers are inlined per-test to keep each
// test self-contained at integration-test scope.
// ---------------------------------------------------------------------
fn write_simple_module_json(dir: &Path, name: &str, imports: &[&str]) -> PathBuf {
use std::fs;
let imports_json: Vec<serde_json::Value> = imports
.iter()
.map(|m| serde_json::json!({ "module": m }))
.collect();
let module = serde_json::json!({
"schema": "ailang/v0",
"name": name,
"imports": imports_json,
"defs": [],
});
let path = dir.join(format!("{name}.ail.json"));
fs::write(&path, serde_json::to_vec_pretty(&module).unwrap()).unwrap();
path
}
/// Iter 23.1 / pd.2 relocation: 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`.
#[test]
fn user_module_named_prelude_is_rejected() {
let dir = tempfile::tempdir().unwrap();
let entry = write_simple_module_json(dir.path(), "prelude", &[]);
let err = load_workspace(&entry).expect_err("must reject user prelude");
match err {
WorkspaceLoadError::ReservedModuleName { name } => {
assert_eq!(name, "prelude");
}
other => panic!("expected ReservedModuleName, got: {other:?}"),
}
}
/// pd.2 relocation: workspace import-cycle detection.
#[test]
fn detects_import_cycle() {
let dir = tempfile::tempdir().unwrap();
write_simple_module_json(dir.path(), "a", &["b"]);
write_simple_module_json(dir.path(), "b", &["a"]);
let entry = dir.path().join("a.ail.json");
let err = load_workspace(&entry).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:?}"),
}
}
/// pd.2 relocation: a missing imported module yields a structured
/// `ModuleNotFound` error naming the absent name + the path that was
/// searched.
#[test]
fn module_not_found_yields_structured_error() {
let dir = tempfile::tempdir().unwrap();
write_simple_module_json(dir.path(), "main", &["does_not_exist"]);
let entry = dir.path().join("main.ail.json");
let err = load_workspace(&entry).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 relocation: §C4 (a) carve-out fixture
/// `test_22b2_kind_mismatch.ail.json` — class param appearing in
/// applied position fires the canonical-form rejection
/// (`BareCrossModuleTypeRef`).
#[test]
fn class_param_in_applied_position_fires_canonical_form_rejection() {
let entry = examples_dir().join("test_22b2_kind_mismatch.ail.json");
let err = load_workspace(&entry)
.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:?}",
),
}
}
/// pd.2 relocation: §C4 (a) carve-out fixture
/// `test_22b2_invalid_superclass_param.ail.json` — `class Ord a extends
/// Eq b` shape fires `InvalidSuperclassParam`.
#[test]
fn superclass_with_wrong_param_fires_invalid_superclass_param() {
let entry = examples_dir().join("test_22b2_invalid_superclass_param.ail.json");
let err = load_workspace(&entry)
.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:?}"),
}
}
/// pd.2 relocation: §C4 (a) carve-out fixture
/// `test_22b2_unbound_constraint_var.ail.json` — class method
/// `Type::Forall.constraints` referencing an unbound type var fires
/// `UnboundConstraintTypeVar`.
#[test]
fn constraint_with_unbound_var_fires_unbound_constraint_type_var() {
let entry = examples_dir().join("test_22b2_unbound_constraint_var.ail.json");
let err = load_workspace(&entry)
.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:?}"),
}
}
/// pd.2 relocation: §C4 (a) carve-out fixture
/// `test_ct1_bare_xmod_rejected.ail.json` — bare `Ordering` Term::Ctor
/// with no imports, 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(&entry).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:?}"),
}
}
/// pd.2 relocation: §C4 (a) carve-out fixture
/// `test_ct1_bad_qualifier.ail.json` — qualified `Mystery.Type` with
/// `Mystery` not a known module fires `BadCrossModuleTypeRef`.
#[test]
fn ct1_fixture_bad_qualifier() {
let entry = examples_dir().join("test_ct1_bad_qualifier.ail.json");
let err = load_workspace(&entry).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:?}"),
}
}
/// pd.2 relocation: §C4 (a) carve-out fixture
/// `test_ct1_qualified_class_rejected.ail.json` — declares
/// `instance prelude.Eq Int` outside the prelude AND outside Int's
/// defining module; post-mq.1 the qualified class-ref is schema-valid
/// and the downstream coherence check rejects with `OrphanInstance`.
#[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(&entry).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:?}"),
}
}
+1 -1
View File
@@ -36,6 +36,6 @@ pub mod loader;
pub mod parse;
pub mod print;
pub use loader::{load_module, load_workspace};
pub use loader::{load_module, load_workspace, parse_prelude, PRELUDE_AIL};
pub use parse::{parse, parse_term, ParseError};
pub use print::{print, term_to_form_a, type_to_form_a};
+43 -4
View File
@@ -9,13 +9,39 @@
//! These functions live in `ailang-surface` rather than
//! `ailang-core` because `ailang-core` cannot import `ailang-surface`
//! without creating a circular crate dependency. The cross-crate
//! injection point is [`ailang_core::workspace::load_workspace_with`],
//! introduced in iter ext-cli.1.
//! injection point is [`ailang_core::workspace::load_modules_with`]
//! (DFS-only loader) composed with [`ailang_core::workspace::build_workspace`]
//! (validation + registry). Surface owns the prelude inject step
//! between the two; the implicit-imports list `&["prelude"]` is
//! supplied at the surface call site. Introduced in iter pd.2 (post-
//! ext-cli.1's `load_workspace_with` shim retirement).
use ailang_core::ast::Module;
use ailang_core::workspace::{load_workspace_with, Workspace, WorkspaceLoadError};
use ailang_core::workspace::{Workspace, WorkspaceLoadError};
use std::path::Path;
/// pd.2 (`prelude-decouple` milestone): the prelude bytes are embedded
/// here at compile time. The single source of truth is
/// `examples/prelude.ail` (Form A). Pre-pd.2, the embed lived in
/// `ailang-core` and used `prelude.ail.json`; that path retired with
/// the loader-split.
pub const PRELUDE_AIL: &str = include_str!("../../../examples/prelude.ail");
/// pd.2: parse the embedded prelude bytes into a `Module`.
///
/// Panics on parse failure — the prelude is build-time-validated by
/// every test run, so a parse failure here is a build-correctness
/// bug, not a runtime concern.
///
/// Mirrors the semantics of the retired `ailang_core::workspace::load_prelude`
/// (which deserialised `prelude.ail.json` via `serde_json::from_str`);
/// equivalence between the two parse paths is pinned by
/// `crates/ailang-surface/tests/prelude_module_hash_pin.rs`'s
/// cross-form-identity preflight.
pub fn parse_prelude() -> Module {
crate::parse(PRELUDE_AIL).expect("examples/prelude.ail must parse as a Module")
}
fn is_ail_source(path: &Path) -> bool {
path.extension().and_then(|s| s.to_str()) == Some("ail")
}
@@ -67,5 +93,18 @@ pub fn load_module(path: &Path) -> Result<Module, WorkspaceLoadError> {
/// `core::workspace::visit`. Mixed-extension workspaces (entry is
/// `.ail`, some imports are `.ail.json`) are valid.
pub fn load_workspace(entry: &Path) -> Result<Workspace, WorkspaceLoadError> {
load_workspace_with(entry, load_module)
let (entry_name, root_dir, mut modules) =
ailang_core::workspace::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::workspace::build_workspace(
entry_name,
root_dir,
modules,
&["prelude"],
)
}
@@ -0,0 +1,43 @@
//! pd.2 (`prelude-decouple` milestone): pin that
//! `surface::load_workspace` injects the prelude into every workspace
//! and rejects user modules trying to take the reserved name.
use ailang_core::workspace::WorkspaceLoadError;
use ailang_surface::load_workspace;
use std::path::Path;
#[test]
fn surface_load_workspace_injects_prelude_into_every_workspace() {
// Use any existing single-module example from `examples/`. The hello
// example is a stable choice (used by other surface tests). Post-
// form-a.1 the canonical sidecar is `.ail` (Form A); the `.ail.json`
// for `hello` was deleted by form-a.1 T8.
let entry = Path::new("../../examples/hello.ail");
let ws = load_workspace(entry).expect("load");
assert!(
ws.modules.contains_key("prelude"),
"prelude must appear in every workspace post-pd.2"
);
}
#[test]
fn surface_load_workspace_rejects_user_module_named_prelude() {
let dir = tempfile::tempdir().unwrap();
// Create a JSON-form fixture for the user-side "prelude" module.
// Surface's loader should fail with ReservedModuleName.
let prelude_path = dir.path().join("prelude.ail.json");
std::fs::write(
&prelude_path,
r#"{"schema":"ailang/v0","name":"prelude","imports":[],"defs":[]}"#,
)
.unwrap();
// Single-file workspace where the entry IS named "prelude" trips
// the reservation.
let err = load_workspace(&prelude_path).expect_err("must reject");
match err {
WorkspaceLoadError::ReservedModuleName { name } => {
assert_eq!(name, "prelude");
}
other => panic!("expected ReservedModuleName, got: {other:?}"),
}
}
@@ -0,0 +1,60 @@
//! pd.2 (`prelude-decouple` milestone): pin the canonical identity of
//! the parsed prelude.
//!
//! Two tests:
//! 1. `prelude_ail_and_json_parse_to_identical_module` — cross-form-
//! identity preflight. Asserts that parsing `examples/prelude.ail`
//! via `ailang_surface::parse` produces a `Module` with the same
//! `module_hash` as deserialising `examples/prelude.ail.json` via
//! `serde_json::from_str`. This is the load-bearing assumption
//! that justifies pd.3's deletion of the JSON file. pd.3 drops
//! this test along with the JSON.
//! 2. `prelude_parse_yields_canonical_hash` — long-term anchor.
//! Asserts the literal hex hash of the parsed Module. Updates
//! intentionally on prelude-content changes (with a JOURNAL entry
//! naming why); accidental drift trips immediately.
use ailang_core::workspace::module_hash;
use ailang_surface::{parse_prelude, PRELUDE_AIL};
const PRELUDE_JSON: &str = include_str!("../../../examples/prelude.ail.json");
#[test]
fn prelude_ail_and_json_parse_to_identical_module() {
// Form A path: parse prelude.ail via ailang_surface::parse.
let from_ail = parse_prelude();
// Form B path: deserialise prelude.ail.json via serde_json (the
// pre-pd.2 path; matches what `ailang_core::workspace::load_prelude`
// did before retirement).
let from_json: ailang_core::ast::Module = serde_json::from_str(PRELUDE_JSON)
.expect("examples/prelude.ail.json must deserialise");
let h_ail = module_hash(&from_ail);
let h_json = module_hash(&from_json);
assert_eq!(
h_ail, h_json,
"cross-form-identity preflight failed:\n parse(prelude.ail) hash: {}\n deserialize(prelude.ail.json) hash: {}\n\
The two parse paths produce structurally different `Module` values for the same prelude content. \
pd.3 must NOT delete prelude.ail.json until this is investigated.",
h_ail, h_json,
);
}
#[test]
fn prelude_parse_yields_canonical_hash() {
let m = parse_prelude();
let h = module_hash(&m);
// The expected hex is captured at pd.2-iter close (Step 5 of this task).
// It updates intentionally on prelude-content changes; record the why
// in the per-iter journal entry.
assert_eq!(
h, "3abe0d3fa3c11c99", // captured at pd.2 iter close
"prelude module hash drifted; if intentional, capture the new \
hex below + record the why in the per-iter journal."
);
}
// PRELUDE_AIL re-exported for any future test that wants the raw bytes.
const _: &str = PRELUDE_AIL;
@@ -0,0 +1,25 @@
//! pd.2 (`prelude-decouple` milestone): pin that the surface-side
//! `parse_prelude` succeeds against the in-tree `examples/prelude.ail`
//! and returns a `Module` with the expected shape. Guards against
//! `prelude.ail` being silently emptied or `parse_prelude` regressing.
//!
//! The hash-stability + cross-form-identity pins live in
//! `prelude_module_hash_pin.rs`.
use ailang_surface::parse_prelude;
#[test]
fn parse_prelude_returns_module_named_prelude_with_min_defs() {
let m = parse_prelude();
assert_eq!(m.name, "prelude");
// Conservative lower bound: the prelude has shipped four classes
// (Eq, Ord, Show + the Ordering data def) plus their primitive
// instances by milestone 24. A future content addition only
// raises the count; an accidental emptying (or a renamed root)
// trips this immediately.
assert!(
m.defs.len() >= 8,
"expected >= 8 defs in prelude, got {}",
m.defs.len()
);
}
+176
View File
@@ -0,0 +1,176 @@
# iter pd.2 — surface owns prelude embed; pd.1 shim retired
**Date:** 2026-05-14
**Started from:** 116157a2b84a98060c1b6c04eb4ec071fe40eb93
**Status:** DONE
**Tasks completed:** 6 of 6
## Summary
pd.2 moves the prelude embed (`PRELUDE_AIL` const + `parse_prelude` fn) and
the prelude-injection step (with the `ReservedModuleName` reservation) out
of `ailang-core` into `ailang-surface`, rewires `surface::load_workspace`
from the 3-line shim-call into the 7-line composition `(load_modules_with
→ reserved-name check → inject parse_prelude → build_workspace(&["prelude"]))`,
and retires the pd.1 compatibility shim `core::load_workspace_with` along
with `load_one`, `load_prelude`, `PRELUDE_JSON`, and the `#[cfg(test)] use
crate::load_module;` import. Cross-form-identity preflight (hash-equality
between `parse(prelude.ail)` and `serde_json::from_str(prelude.ail.json)`)
PASSES at hash `3abe0d3fa3c11c99` — the load-bearing assumption that
justifies pd.3's deletion of `prelude.ail.json` is verified. Production
literal-`"prelude"` count in `core/src/` is now zero. Test count: 573
green (from the pd.1 baseline of 564, +9 net = 3 new surface pins
[parse + hash + injection] + 6 tempdir/fixture relocations to
workspace_pin.rs minus 6 in-mod placeholders that became comments;
in-mod test deletion of the pd.1 shim test is offset by the
`load_modules_with_custom_loader_is_called` rename + 2 pd.1 tests that
now use an inline test-private `load_one` helper). `bench/cross_lang.py`
+ `bench/compile_check.py` exit 0; `bench/check.py` exit 1 with the
12-audit noise envelope (the `bench_list_sum.bump_s` + tail-latency
oscillation that has been observed in audits since audit-cma; pd.2 is
a workspace-loader refactor with zero codegen / runtime touch). pd.3
will delete `examples/prelude.ail.json` plus the
`prelude_ail_and_json_parse_to_identical_module` preflight, the
`include_str!` carve-out in `crates/ail/src/main.rs:472-484`, the
`carve_out_inventory.rs` §C4(b) row, the form-a §C4(b) spec text, and
the `migrate-bare-cross-module-refs` defensive include.
## Per-task notes
- iter pd.2.1: `pub const PRELUDE_AIL: &str = include_str!("../../../examples/prelude.ail");`
+ `pub fn parse_prelude() -> Module` added near the top of
`crates/ailang-surface/src/loader.rs`; both re-exported from
`ailang-surface/src/lib.rs`. RED test
`parse_prelude_returns_module_named_prelude_with_min_defs` failed
with the expected unresolved-import message, then GREEN. First-shot
clean.
- iter pd.2.2: `crates/ailang-surface/tests/prelude_module_hash_pin.rs`
with `prelude_ail_and_json_parse_to_identical_module` (cross-form-
identity preflight, load-bearing for pd.3) + `prelude_parse_yields_canonical_hash`
(literal-hex anchor). Preflight PASSED first-run (the round-trip
invariant from form-a.0 + form-a.1 was already exercising
parse-then-print idempotency on every `.ail` fixture, so
parse(prelude.ail) ≡ deserialize(prelude.ail.json) was not an
accident). Hash `3abe0d3fa3c11c99` captured at pd.2 close.
- iter pd.2.3: `surface::load_workspace` rewritten 3-line → 7-line
composition; imports updated `load_workspace_with` → bare
`Workspace, WorkspaceLoadError` with `load_modules_with` /
`build_workspace` fully-qualified inline; module rustdoc rewritten
to name the new injection point + the implicit-imports list.
Regression-pin tests `surface_load_workspace_injects_prelude_into_every_workspace`
+ `surface_load_workspace_rejects_user_module_named_prelude` PASS
before AND after the rewrite (semantics preserved). DONE_WITH_CONCERNS:
the plan's example used `examples/hello.ail.json` which doesn't
exist post-form-a.1 (deleted in T8); switched to `examples/hello.ail`.
- iter pd.2.4: 10 in-mod tests RELOCATED to
`crates/ailang-core/tests/workspace_pin.rs` rather than aliased per
the plan's literal text — the dev-dep cycle (`ailang-core`
`ailang-surface` via `[dev-dependencies]`) makes
`surface::load_workspace` calls from `ailang-core`'s lib-test crate
structurally impossible (two distinct compilations of `ailang-core`
appear in the test build, types don't match). The integration-test
crate consumes both crates against the same `ailang-core` artefact,
so the cycle never materialises — same pattern form-a.1 T5 used for
similar relocations. The 11th test (`load_workspace_with_custom_loader_is_called`)
renamed to `load_modules_with_custom_loader_is_called` with the
closure adapted to wrap `crate::load_module` directly via the same
Io/Schema mapping `load_one` had. The 12th test
(`load_workspace_with_shim_preserves_today_semantics`) deleted
outright with a forwarding comment to the surface-side hash pin.
- iter pd.2.5: 5 deletions (`PRELUDE_JSON`, `load_prelude`,
`load_workspace_with`, top-level `load_one`, `#[cfg(test)] use
crate::load_module;`); module rustdoc reworked to two-phase
composition framing; lib.rs three rustdoc sites (lines 16-21, 50-51,
120) re-pointed at `ailang_surface::load_workspace`. ONE plan-vs-actual
deviation: the plan didn't enumerate the 2 pd.1-introduced in-mod
tests at the bottom of `mod tests` that consume `load_one` directly
(`load_modules_with_returns_modules_without_prelude`,
`build_workspace_accepts_assembled_modules_and_runs_validation`);
preserved by adding a test-mod-private `load_one` helper inside `mod
tests` (production-symbol-level deletion preserved; helper is
scoped only to the test module, not exported). The
`build_workspace...` test also called `load_prelude()` directly;
replaced inline with `serde_json::from_str(include_str!(...))` of
`prelude.ail.json` (the same one-line code `load_prelude` was). Zero
literal-`"prelude"` strings in production `core/src/` confirmed by
grep. cargo doc warnings: 3 (unchanged from pre-pd.2 baseline; all
three are dangling `[`load_workspace`]` references in the `Workspace`
/ `Registry` rustdoc that pd.1 left behind when it deleted
`load_workspace`; out of scope per the implementer's
no-surrounding-cleanup discipline, recorded as known debt).
- iter pd.2.6: bench verification — `cross_lang.py` exit 0 (25/25
stable), `compile_check.py` exit 0 (24/24, "11 regressed" reported
but exit code says baseline-tolerance check passes — this is the
audit-form-a + clippy-sweep-named noise on the corpus's small build
fixtures), `check.py` exit 1 with 1 regression on `bench_list_sum.bump_s`
+13.61% (within established noise envelope; second run showed
same `bump_s` plus a tail-latency `implicit_at_rc.p99_us` blip).
All three are runtime/compile metrics that cannot be touched by a
workspace.rs/loader.rs/lib.rs refactor (no codegen / no runtime /
no typecheck path edited).
## Concerns
- pd.2.3: plan's regression-pin used `examples/hello.ail.json` which
doesn't exist post-form-a.1 T8. Switched to `examples/hello.ail`.
Recommend a planner check: when a plan references a `.ail.json`
fixture, verify the fixture exists post-form-a.1 (the carve-out
inventory is the right reference).
- pd.2.4: plan's "alias to surface_load_workspace and stay in-mod" was
structurally invalid because of the `ailang-core``ailang-surface`
dev-dep cycle inside the lib-test target. Relocated to
`tests/workspace_pin.rs` instead — same pattern form-a.1 T5 used.
Recommend: planner recon-check should detect when a planned in-mod
test is meant to consume an external dev-dep cycle that the lib-test
target can't resolve.
- pd.2.5: plan's "delete `load_one`" preserved at production-symbol
level (the top-level `#[cfg(test)] fn load_one` is gone) by
introducing a test-mod-private `fn load_one` helper inside `mod
tests` for the 2 pd.1 in-mod tests that still consume it. The
symbol's name is preserved for clarity; its scope is now narrower
than pre-pd.2.
- pd.2.6: `check.py` exit 1 — established noise envelope, 13th
consecutive observation pattern (audit-cma onwards). pd.2 is a
workspace-loader refactor with zero codegen / runtime / typecheck
touch, so cannot be the source. Baseline left pristine consistent
with all prior audit decisions on this envelope.
## Known debt
- 3 cargo doc warnings on `[`load_workspace`]` references in the
`Workspace` (line 44, 49) + `Registry` (line 64) rustdoc — pd.1
deleted `load_workspace` but didn't sweep these references. Pre-
pd.2 baseline already carried them; pd.2 didn't add to the count;
not within pd.2 scope (no-surrounding-cleanup discipline).
- pd.3 work explicitly out of scope: deleting `examples/prelude.ail.json`,
the `prelude_ail_and_json_parse_to_identical_module` preflight test
(which depends on the JSON file's existence), the `include_str!` of
`prelude.ail.json` in `crates/ail/src/main.rs:472-484`, the
`carve_out_inventory.rs` §C4(b) row, the form-a §C4(b) spec text
edits, and the `migrate-bare-cross-module-refs` defensive include.
## Files touched
Modified:
- `crates/ailang-core/src/lib.rs` (3 rustdoc sites updated)
- `crates/ailang-core/src/workspace.rs` (5 deletions + module rustdoc
reframe + 2 pd.1 in-mod test repairs + 6 in-mod test placeholders +
test-mod-private `load_one` helper restored at narrow scope)
- `crates/ailang-core/tests/workspace_pin.rs` (10 relocated tests
appended + helper fn `write_simple_module_json` + preamble extension)
- `crates/ailang-surface/src/lib.rs` (re-export of `parse_prelude` +
`PRELUDE_AIL`)
- `crates/ailang-surface/src/loader.rs` (`PRELUDE_AIL` const +
`parse_prelude` fn added; `load_workspace` body 3-line → 7-line
composition rewrite; module rustdoc reframed; imports updated)
New:
- `crates/ailang-surface/tests/prelude_parse_pin.rs` (1 test)
- `crates/ailang-surface/tests/prelude_module_hash_pin.rs` (2 tests:
cross-form-identity preflight + literal-hex anchor `3abe0d3fa3c11c99`)
- `crates/ailang-surface/tests/prelude_injection_pin.rs` (2 tests:
inject + reservation pin)
## Stats
`bench/orchestrator-stats/2026-05-14-iter-pd.2.json`