diff --git a/bench/orchestrator-stats/2026-05-14-iter-pd.2.json b/bench/orchestrator-stats/2026-05-14-iter-pd.2.json new file mode 100644 index 0000000..edbb4a9 --- /dev/null +++ b/bench/orchestrator-stats/2026-05-14-iter-pd.2.json @@ -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 +} diff --git a/crates/ailang-core/src/lib.rs b/crates/ailang-core/src/lib.rs index 6f4744a..1c4020c 100644 --- a/crates/ailang-core/src/lib.rs +++ b/crates/ailang-core/src/lib.rs @@ -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 { let bytes = std::fs::read(path)?; let module: Module = serde_json::from_slice(&bytes)?; diff --git a/crates/ailang-core/src/workspace.rs b/crates/ailang-core/src/workspace.rs index 3d8a6ba..369a23f 100644 --- a/crates/ailang-core/src/workspace.rs +++ b/crates/ailang-core/src/workspace.rs @@ -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 `/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( - entry_path: &Path, - loader: F, -) -> Result -where - F: Fn(&Path) -> Result + 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 { - 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 { + 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 = 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 { 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`. } diff --git a/crates/ailang-core/tests/workspace_pin.rs b/crates/ailang-core/tests/workspace_pin.rs index d4cac73..21df9f5 100644 --- a/crates/ailang-core/tests/workspace_pin.rs +++ b/crates/ailang-core/tests/workspace_pin.rs @@ -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 = 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:?}"), + } +} diff --git a/crates/ailang-surface/src/lib.rs b/crates/ailang-surface/src/lib.rs index 756acec..afabb68 100644 --- a/crates/ailang-surface/src/lib.rs +++ b/crates/ailang-surface/src/lib.rs @@ -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}; diff --git a/crates/ailang-surface/src/loader.rs b/crates/ailang-surface/src/loader.rs index c7e734d..0f8d455 100644 --- a/crates/ailang-surface/src/loader.rs +++ b/crates/ailang-surface/src/loader.rs @@ -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 { /// `core::workspace::visit`. Mixed-extension workspaces (entry is /// `.ail`, some imports are `.ail.json`) are valid. pub fn load_workspace(entry: &Path) -> Result { - 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"], + ) } diff --git a/crates/ailang-surface/tests/prelude_injection_pin.rs b/crates/ailang-surface/tests/prelude_injection_pin.rs new file mode 100644 index 0000000..983b127 --- /dev/null +++ b/crates/ailang-surface/tests/prelude_injection_pin.rs @@ -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:?}"), + } +} diff --git a/crates/ailang-surface/tests/prelude_module_hash_pin.rs b/crates/ailang-surface/tests/prelude_module_hash_pin.rs new file mode 100644 index 0000000..00f9f45 --- /dev/null +++ b/crates/ailang-surface/tests/prelude_module_hash_pin.rs @@ -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; diff --git a/crates/ailang-surface/tests/prelude_parse_pin.rs b/crates/ailang-surface/tests/prelude_parse_pin.rs new file mode 100644 index 0000000..1685f7a --- /dev/null +++ b/crates/ailang-surface/tests/prelude_parse_pin.rs @@ -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() + ); +} diff --git a/docs/journals/2026-05-14-iter-pd.2.md b/docs/journals/2026-05-14-iter-pd.2.md new file mode 100644 index 0000000..d440683 --- /dev/null +++ b/docs/journals/2026-05-14-iter-pd.2.md @@ -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`