From ffa80326a30db4fa219ea6ee41859d3657917c26 Mon Sep 17 00:00:00 2001 From: Brummel Date: Thu, 14 May 2026 12:25:12 +0200 Subject: [PATCH] =?UTF-8?q?iter=20pd.1:=20core=20API=20split=20=E2=80=94?= =?UTF-8?q?=20load=5Fmodules=5Fwith=20+=20build=5Fworkspace=20+=20implicit?= =?UTF-8?q?=5Fimports=20threading?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Carved load_workspace_with's inline DFS+inject+validate block into two new public fns: load_modules_with (DFS only) and build_workspace (validation + registry, takes implicit_imports: &[&str]). Threaded the new parameter through validate_canonical_type_names + check_class_ref + check_type_con_name; the four hardcoded literal-"prelude" fallback blocks in the diagnostic helpers became iter-loops over implicit_imports. load_workspace_with survives as a 3-line shim composing the new fns + the still-in-place prelude inject, so surface (ailang-surface) and all downstream crates compile + test unchanged. Retired pub fn load_workspace (zero production callers; 9 in-mod test callers migrated to load_workspace_with(&entry, load_one); load_one + the load_module import gated under cfg(test) since they're now test-only). Production literal-"prelude" count in workspace.rs dropped 8 -> 4 (all 4 in the shim's inject path; none in the diagnostic helpers, which was the spec's pd.1 target). pd.2 will move the prelude embed + the inject step into ailang-surface and retire the shim; pd.3 will delete examples/prelude.ail.json. cargo test --workspace green; bench/check.py + compile_check.py + cross_lang.py all exit 0. --- .../2026-05-14-iter-pd.1.json | 19 + crates/ailang-core/src/lib.rs | 19 +- crates/ailang-core/src/workspace.rs | 386 +++++++++++++----- docs/journals/2026-05-14-iter-pd.1.md | 120 ++++++ 4 files changed, 439 insertions(+), 105 deletions(-) create mode 100644 bench/orchestrator-stats/2026-05-14-iter-pd.1.json create mode 100644 docs/journals/2026-05-14-iter-pd.1.md diff --git a/bench/orchestrator-stats/2026-05-14-iter-pd.1.json b/bench/orchestrator-stats/2026-05-14-iter-pd.1.json new file mode 100644 index 0000000..f6ab39f --- /dev/null +++ b/bench/orchestrator-stats/2026-05-14-iter-pd.1.json @@ -0,0 +1,19 @@ +{ + "iter_id": "pd.1", + "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 9471f6b..6f4744a 100644 --- a/crates/ailang-core/src/lib.rs +++ b/crates/ailang-core/src/lib.rs @@ -13,9 +13,12 @@ //! 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 [`load_workspace`]. -//! Resolves transitive imports of an entry module and returns a -//! [`Workspace`]. +//! - **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`. //! - **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 @@ -44,7 +47,8 @@ //! ## Entry points //! //! - Read a single module from disk: [`load_module`]. -//! - Read an entry module plus all imports: [`load_workspace`]. +//! - Read an entry module plus all imports: +//! [`workspace::load_workspace_with`]. //! - Hash a [`Def`]: [`def_hash`]. Hash a whole [`Module`]: //! [`workspace::module_hash`]. //! - Form-(A) text projection of a [`Module`]: see @@ -66,7 +70,7 @@ pub use ast::{ def_kind, def_name, ConstDef, Def, FnDef, Import, Literal, Module, Term, Type, }; pub use hash::def_hash; -pub use workspace::{load_workspace, module_hash, Workspace, WorkspaceLoadError}; +pub use workspace::{module_hash, Workspace, WorkspaceLoadError}; /// Errors raised while loading a single module file. /// @@ -113,8 +117,9 @@ 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 [`load_workspace`]. Errors are -/// returned as [`Error::Io`] / [`Error::Json`] / [`Error::SchemaMismatch`]. +/// — for the transitive closure use [`workspace::load_workspace_with`]. +/// 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 59b0826..3d8a6ba 100644 --- a/crates/ailang-core/src/workspace.rs +++ b/crates/ailang-core/src/workspace.rs @@ -5,11 +5,15 @@ //! the entry file's directory as `/foo.ail.json`. Module //! names must match the file stem (the loader rejects mismatches). //! -//! The single entry point is [`load_workspace`]; it returns a fully +//! 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`]. -//! [`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. +//! 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. //! //! This module is responsible only for **finding** and consistently //! **loading** all reachable modules. Cross-module typechecking lives @@ -19,10 +23,15 @@ //! # Examples //! //! ```ignore -//! use ailang_core::load_workspace; +//! // 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(Path::new("examples/ws_main.ail.json"))?; +//! 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 { @@ -33,7 +42,9 @@ use crate::ast::{ClassDef, Def, InstanceDef, Module, Term, Type}; use crate::canonical; -use crate::{load_module, Error as CoreError}; +use crate::Error as CoreError; +#[cfg(test)] +use crate::load_module; use std::collections::{BTreeMap, BTreeSet, HashSet}; use std::path::{Path, PathBuf}; @@ -427,35 +438,24 @@ fn load_prelude() -> Module { .expect("examples/prelude.ail.json must parse as a Module") } -/// Load entry module plus all transitively reachable modules. +/// 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 +/// any implicit modules (e.g. prelude) and then handing the result to +/// `build_workspace` for validation + registry construction. +/// +/// Surface composes this with a prelude-injection step in pd.2; pd.1 keeps +/// the shim `load_workspace_with` callable so surface is unchanged. /// /// Algorithm: DFS over `imports`, with two sets: /// - `loaded` (= `modules` map): modules whose subtree is already fully /// processed. On a re-hit only hash consistency is checked. /// - `visiting`: stack of modules whose DFS descent is still running. /// A hit here = cycle. -pub fn load_workspace(entry_path: &Path) -> Result { - load_workspace_with(entry_path, load_one) -} - -/// Load a workspace using a caller-supplied per-module loader. -/// -/// The standard entry point [`load_workspace`] is a thin wrapper that -/// passes `load_one` as the loader and so only accepts `.ail.json` -/// files. Pass a different loader (for example, an extension-dispatching -/// loader from `ailang-surface`) to accept `.ail` source files as well. -/// -/// The loader is invoked for the entry module and for every transitive -/// import. It is responsible for reading bytes, parsing, and returning -/// the in-memory [`Module`]. -/// -/// ext-cli.1: introduced so `ailang-surface::load_workspace` can plug -/// in an extension-dispatching loader without `ailang-core` importing -/// `ailang-surface` (which would close a crate cycle). -pub fn load_workspace_with( +pub fn load_modules_with( entry_path: &Path, loader: F, -) -> Result +) -> Result<(String, PathBuf, BTreeMap), WorkspaceLoadError> where F: Fn(&Path) -> Result + Copy, { @@ -465,7 +465,6 @@ where .map(Path::to_path_buf) .unwrap_or_else(|| PathBuf::from(".")); - // Load entry module + check name<->filename convention. let entry_module = loader(&entry_path)?; let expected_entry_name = module_name_from_path(&entry_path); if entry_module.name != expected_entry_name { @@ -490,34 +489,29 @@ where loader, )?; - // Iter 23.1: inject the prelude module. It is implicitly - // part of every workspace, so the user's import graph - // does not name it. A user module that happens to be named - // "prelude" would collide here; reject explicitly so the - // failure is informative rather than silent. - if modules.contains_key("prelude") { - return Err(WorkspaceLoadError::ReservedModuleName { - name: "prelude".to_string(), - }); - } - let prelude = load_prelude(); - modules.insert("prelude".to_string(), prelude); + Ok((entry_name, root_dir, modules)) +} - // ct.1: enforce the canonical-form rule on Type::Con and - // Term::Ctor.type_name. Runs before class-schema validation - // so a stale bare cross-module ref fires the canonical-form - // diagnostic instead of a downstream one. - validate_canonical_type_names(&modules)?; - - // Iter 22b.2: class-schema validation runs before registry - // construction so that ill-kinded class definitions are rejected - // before any instance against them is registered. +/// pd.1: extract the validation + registry-construction tail of +/// `load_workspace_with` into a public fn that operates on a pre-assembled +/// modules map. The caller (typically `surface::load_workspace`, post-pd.2) +/// is responsible for injecting any implicit modules (e.g. prelude) into +/// `modules` BEFORE calling this fn, AND for naming those implicit modules +/// in `implicit_imports` so the diagnostic helpers (`check_class_ref`, +/// `check_type_con_name`) include them as fallback candidates. +/// +/// Runs the three-stage pipeline: +/// 1. `validate_canonical_type_names` (with `implicit_imports`) +/// 2. `validate_classdefs` (no `implicit_imports` — does not consume it) +/// 3. `build_registry` +pub fn build_workspace( + entry_name: String, + root_dir: PathBuf, + modules: BTreeMap, + implicit_imports: &[&str], +) -> Result { + validate_canonical_type_names(&modules, implicit_imports)?; validate_classdefs(&modules)?; - - // Iter 22b.1: build the workspace-global instance registry. Three - // coherence checks fire here (Orphan / Duplicate / Missing-method); - // any violation is surfaced as a `WorkspaceLoadError`, not as a - // per-call-site diagnostic. let registry = build_registry(&modules)?; Ok(Workspace { @@ -528,6 +522,44 @@ where }) } +/// 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"]) +} + /// mq.1: take a class-ref field value (`InstanceDef.class`, /// `SuperclassRef.class`, `Constraint.class`) and a defining context, /// and produce the qualified workspace key. Bare ⇒ prepend the @@ -937,6 +969,7 @@ fn normalize_type_for_registry( /// forms found by scanning the owning module's imports. pub(crate) fn validate_canonical_type_names( modules: &BTreeMap, + implicit_imports: &[&str], ) -> Result<(), WorkspaceLoadError> { // Pre-pass: for each module, build a `BTreeSet` of local // TypeDef names. Used for both the owning-module local lookup @@ -979,14 +1012,14 @@ pub(crate) fn validate_canonical_type_names( walk_def_types(def, &mut |t: &Type| { if let Type::Con { name, .. } = t { check_type_con_name( - name, mod_name, &local_types, &import_names, + name, mod_name, &local_types, &import_names, implicit_imports, )?; } Ok(()) })?; walk_def_terms(def, &mut |type_name: &str| { check_type_con_name( - type_name, mod_name, &local_types, &import_names, + type_name, mod_name, &local_types, &import_names, implicit_imports, ) })?; check_class_name_fields(def, mod_name)?; @@ -995,16 +1028,16 @@ pub(crate) fn validate_canonical_type_names( // migrated class-ref fields. match def { Def::Instance(id) => { - check_class_ref(&id.class, mod_name, &local_classes, &import_names)?; + check_class_ref(&id.class, mod_name, &local_classes, &import_names, implicit_imports)?; } Def::Class(cd) => { if let Some(sc) = &cd.superclass { - check_class_ref(&sc.class, mod_name, &local_classes, &import_names)?; + check_class_ref(&sc.class, mod_name, &local_classes, &import_names, implicit_imports)?; } for cmth in &cd.methods { if let Type::Forall { constraints, .. } = &cmth.ty { for c in constraints { - check_class_ref(&c.class, mod_name, &local_classes, &import_names)?; + check_class_ref(&c.class, mod_name, &local_classes, &import_names, implicit_imports)?; } } } @@ -1012,7 +1045,7 @@ pub(crate) fn validate_canonical_type_names( Def::Fn(fd) => { if let Type::Forall { constraints, .. } = &fd.ty { for c in constraints { - check_class_ref(&c.class, mod_name, &local_classes, &import_names)?; + check_class_ref(&c.class, mod_name, &local_classes, &import_names, implicit_imports)?; } } } @@ -1043,6 +1076,7 @@ fn check_class_ref( owning_module: &str, local_classes: &BTreeMap>, import_names: &[&str], + implicit_imports: &[&str], ) -> Result<(), WorkspaceLoadError> { if let Some((owner, bare)) = class_ref.split_once('.') { // Rule 1: qualified. @@ -1081,13 +1115,17 @@ fn check_class_ref( candidates.push(format!("{imp}.{class_ref}")); } } - if !import_names.contains(&"prelude") - && local_classes - .get("prelude") + for implicit in implicit_imports { + if import_names.contains(implicit) { + continue; // already added above + } + if local_classes + .get(*implicit) .map(|s| s.contains(class_ref)) .unwrap_or(false) - { - candidates.push(format!("prelude.{class_ref}")); + { + candidates.push(format!("{implicit}.{class_ref}")); + } } Err(WorkspaceLoadError::BareCrossModuleClassRef { module: owning_module.to_string(), @@ -1103,6 +1141,7 @@ fn check_type_con_name( owning_module: &str, local_types: &BTreeMap>, import_names: &[&str], + implicit_imports: &[&str], ) -> Result<(), WorkspaceLoadError> { if let Some((prefix, suffix)) = name.split_once('.') { // Rule 1: qualified. @@ -1145,13 +1184,17 @@ fn check_type_con_name( candidates.push(format!("{imp}.{name}")); } } - if !import_names.contains(&"prelude") - && local_types - .get("prelude") + for implicit in implicit_imports { + if import_names.contains(implicit) { + continue; // already added above + } + if local_types + .get(*implicit) .map(|s| s.contains(name)) .unwrap_or(false) - { - candidates.push(format!("prelude.{name}")); + { + candidates.push(format!("{implicit}.{name}")); + } } Err(WorkspaceLoadError::BareCrossModuleTypeRef { module: owning_module.to_string(), @@ -1552,6 +1595,7 @@ where Ok(()) } +#[cfg(test)] fn load_one(path: &Path) -> Result { match load_module(path) { Ok(m) => Ok(m), @@ -1628,7 +1672,7 @@ mod tests { write_module(&dir, "prelude", &[]); let entry = dir.join("prelude.ail.json"); - let err = load_workspace(&entry).expect_err("must reject user prelude"); + let err = load_workspace_with(&entry, load_one).expect_err("must reject user prelude"); match err { WorkspaceLoadError::ReservedModuleName { name } => { assert_eq!(name, "prelude"); @@ -1646,7 +1690,7 @@ mod tests { write_module(&dir, "b", &["a"]); let entry = dir.join("a.ail.json"); - let err = load_workspace(&entry).expect_err("must error on cycle"); + 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())); @@ -1662,7 +1706,7 @@ mod tests { write_module(&dir, "main", &["does_not_exist"]); let entry = dir.join("main.ail.json"); - let err = load_workspace(&entry).expect_err("must error on missing module"); + 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"); @@ -1744,7 +1788,7 @@ mod tests { let entry = std::path::PathBuf::from( "../../examples/test_22b2_kind_mismatch.ail.json", ); - let err = load_workspace(&entry) + let err = load_workspace_with(&entry, load_one) .expect_err("must fire canonical-form rejection"); match err { WorkspaceLoadError::BareCrossModuleTypeRef { module, name, .. } => { @@ -1767,7 +1811,7 @@ mod tests { let entry = std::path::PathBuf::from( "../../examples/test_22b2_invalid_superclass_param.ail.json", ); - let err = load_workspace(&entry) + let err = load_workspace_with(&entry, load_one) .expect_err("must fire invalid-superclass-param"); match err { WorkspaceLoadError::InvalidSuperclassParam { @@ -1807,7 +1851,7 @@ mod tests { let entry = std::path::PathBuf::from( "../../examples/test_22b2_unbound_constraint_var.ail.json", ); - let err = load_workspace(&entry) + let err = load_workspace_with(&entry, load_one) .expect_err("must fire constraint-references-unbound-type-var"); match err { WorkspaceLoadError::UnboundConstraintTypeVar { @@ -1910,9 +1954,9 @@ mod tests { #[test] fn ct1_validator_accepts_primitive_type_cons() { let modules = single_module_with_type_con("m", "Int"); - validate_canonical_type_names(&modules).expect("Int must be accepted"); + validate_canonical_type_names(&modules, &["prelude"]).expect("Int must be accepted"); let modules = single_module_with_type_con("m", "Float"); - validate_canonical_type_names(&modules).expect("Float must be accepted"); + validate_canonical_type_names(&modules, &["prelude"]).expect("Float must be accepted"); } /// ct.1: a bare Type::Con whose `name` matches a local TypeDef in @@ -1921,7 +1965,7 @@ mod tests { #[test] fn ct1_validator_accepts_bare_local_type_con() { let modules = single_module_with_local_type_and_ref("m", "Foo"); - validate_canonical_type_names(&modules) + validate_canonical_type_names(&modules, &["prelude"]) .expect("local Foo must be accepted"); } @@ -1931,7 +1975,7 @@ mod tests { #[test] fn ct1_validator_rejects_bare_xmod_no_imports() { let modules = single_module_with_type_con("m", "Ordering"); - let err = validate_canonical_type_names(&modules) + let err = validate_canonical_type_names(&modules, &["prelude"]) .expect_err("Ordering must be rejected"); match err { WorkspaceLoadError::BareCrossModuleTypeRef { module, name, candidates } => { @@ -1953,7 +1997,7 @@ mod tests { let mut modules = BTreeMap::new(); modules.insert("other".to_string(), module_with_type_def("other", "Ordering")); modules.insert("m".to_string(), module_with_import_and_type_con("m", "other", "Ordering")); - let err = validate_canonical_type_names(&modules) + let err = validate_canonical_type_names(&modules, &["prelude"]) .expect_err("Ordering must be rejected with candidate"); match err { WorkspaceLoadError::BareCrossModuleTypeRef { name, candidates, .. } => { @@ -1971,7 +2015,7 @@ mod tests { let mut modules = BTreeMap::new(); modules.insert("other".to_string(), module_with_type_def("other", "Ordering")); modules.insert("m".to_string(), module_with_import_and_type_con("m", "other", "other.Ordering")); - validate_canonical_type_names(&modules) + validate_canonical_type_names(&modules, &["prelude"]) .expect("other.Ordering must be accepted"); } @@ -1981,7 +2025,7 @@ mod tests { #[test] fn ct1_validator_rejects_bad_qualified_ref() { let modules = single_module_with_type_con("m", "Mystery.Type"); - let err = validate_canonical_type_names(&modules) + let err = validate_canonical_type_names(&modules, &["prelude"]) .expect_err("Mystery.Type must be rejected"); match err { WorkspaceLoadError::BadCrossModuleTypeRef { module, name } => { @@ -2020,7 +2064,7 @@ mod tests { })).unwrap(); let mut modules = BTreeMap::new(); modules.insert("m".to_string(), m); - let err = validate_canonical_type_names(&modules) + let err = validate_canonical_type_names(&modules, &["prelude"]) .expect_err("Lam-embedded Ordering must be rejected"); match err { WorkspaceLoadError::BareCrossModuleTypeRef { name, .. } => { @@ -2058,7 +2102,7 @@ mod tests { }], })).unwrap(); modules.insert("m".to_string(), m); - let err = validate_canonical_type_names(&modules) + let err = validate_canonical_type_names(&modules, &["prelude"]) .expect_err("bare Term::Ctor type must be rejected"); match err { WorkspaceLoadError::BareCrossModuleTypeRef { module, name, candidates } => { @@ -2088,7 +2132,7 @@ mod tests { })).unwrap(); let mut modules = BTreeMap::new(); modules.insert("m".to_string(), m); - let err = validate_canonical_type_names(&modules) + let err = validate_canonical_type_names(&modules, &["prelude"]) .expect_err("qualified ClassDef.name must be rejected"); match err { WorkspaceLoadError::QualifiedClassName { module, name, field } => { @@ -2132,7 +2176,7 @@ mod tests { let mut modules = BTreeMap::new(); modules.insert("other".to_string(), other); modules.insert("m".to_string(), m); - validate_canonical_type_names(&modules) + validate_canonical_type_names(&modules, &["prelude"]) .expect("qualified InstanceDef.class is the canonical form post-mq.1"); } @@ -2168,7 +2212,7 @@ mod tests { let mut modules = BTreeMap::new(); modules.insert("other".to_string(), other); modules.insert("m".to_string(), m); - validate_canonical_type_names(&modules) + validate_canonical_type_names(&modules, &["prelude"]) .expect("qualified SuperclassRef.class is the canonical form post-mq.1"); } @@ -2214,7 +2258,7 @@ mod tests { let mut modules = BTreeMap::new(); modules.insert("other".to_string(), other); modules.insert("m".to_string(), m); - validate_canonical_type_names(&modules) + validate_canonical_type_names(&modules, &["prelude"]) .expect("qualified Constraint.class is the canonical form post-mq.1"); } @@ -2433,7 +2477,7 @@ mod tests { let mut modules = BTreeMap::new(); modules.insert("other".to_string(), other); modules.insert("m".to_string(), m); - validate_canonical_type_names(&modules) + validate_canonical_type_names(&modules, &["prelude"]) .expect("qualified Constraint.class in ClassDef-method Forall is the canonical form post-mq.1"); } @@ -2444,7 +2488,7 @@ mod tests { #[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"); + 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"); @@ -2460,7 +2504,7 @@ mod tests { #[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"); + 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"); @@ -2483,7 +2527,7 @@ mod tests { #[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)"); + let err = load_workspace_with(&entry, load_one).expect_err("must reject (now as Orphan)"); match err { WorkspaceLoadError::OrphanInstance { class, type_repr, defining_module, .. @@ -2608,7 +2652,7 @@ mod tests { let mut modules = BTreeMap::new(); modules.insert("A".to_string(), a); modules.insert("B".to_string(), b); - let err = validate_canonical_type_names(&modules).expect_err("expected reject"); + let err = validate_canonical_type_names(&modules, &["prelude"]).expect_err("expected reject"); match err { WorkspaceLoadError::BareCrossModuleClassRef { module, name, candidates } => { assert_eq!(module, "B"); @@ -2662,7 +2706,7 @@ mod tests { let mut modules = BTreeMap::new(); modules.insert("A".to_string(), a); modules.insert("B".to_string(), b); - let err = validate_canonical_type_names(&modules).expect_err("expected reject"); + let err = validate_canonical_type_names(&modules, &["prelude"]).expect_err("expected reject"); match err { WorkspaceLoadError::BareCrossModuleClassRef { module, name, .. } => { assert_eq!(module, "B"); @@ -2703,7 +2747,7 @@ mod tests { let mut modules = BTreeMap::new(); modules.insert("A".to_string(), a); modules.insert("B".to_string(), b); - let err = validate_canonical_type_names(&modules).expect_err("expected reject"); + let err = validate_canonical_type_names(&modules, &["prelude"]).expect_err("expected reject"); match err { WorkspaceLoadError::BareCrossModuleClassRef { module, name, .. } => { assert_eq!(module, "B"); @@ -2744,6 +2788,152 @@ mod tests { let mut modules = BTreeMap::new(); modules.insert("A".to_string(), a); modules.insert("B".to_string(), b); - validate_canonical_type_names(&modules).expect("qualified A.Show must be accepted"); + validate_canonical_type_names(&modules, &["prelude"]).expect("qualified A.Show must be accepted"); + } + + /// pd.1 Task 1: `load_modules_with` returns modules without injecting + /// prelude. The prelude inject is the caller's responsibility (the + /// shim `load_workspace_with` does it in pd.1; surface owns it from + /// pd.2 onward). + #[test] + fn load_modules_with_returns_modules_without_prelude() { + let dir = tempfile::tempdir().unwrap(); + write_module(dir.path(), "main", &[]); + let entry = dir.path().join("main.ail.json"); + + let (entry_name, root_dir, modules) = + load_modules_with(&entry, load_one).expect("load"); + + assert_eq!(entry_name, "main"); + assert_eq!(root_dir, dir.path().to_path_buf()); + assert!( + !modules.contains_key("prelude"), + "load_modules_with must not auto-inject prelude (that is build_workspace's caller's job)" + ); + assert!(modules.contains_key("main")); + } + + /// pd.1 Task 2: `build_workspace` accepts a pre-assembled modules + /// map (with the caller having already injected any implicit + /// modules) and runs the three-stage validation pipeline. The + /// `implicit_imports` slice is what the diagnostic helpers consult + /// for fallback candidate suggestions (Task 3 wires it through). + #[test] + fn build_workspace_accepts_assembled_modules_and_runs_validation() { + let dir = tempfile::tempdir().unwrap(); + write_module(dir.path(), "main", &[]); + let entry = dir.path().join("main.ail.json"); + + 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(); + modules.insert("prelude".to_string(), prelude); + + let ws = build_workspace(entry_name, root_dir, modules, &["prelude"]) + .expect("build"); + + assert_eq!(ws.entry, "main"); + assert!(ws.modules.contains_key("main")); + assert!(ws.modules.contains_key("prelude")); + } + + fn module_with_bare_classref(name: &str, class_ref: &str) -> Module { + // A module containing a single Def::Instance whose `class` field is + // a bare class reference. The instance's `type` field is a local + // ADT defined in the same module to keep ct.1 happy. + serde_json::from_value(serde_json::json!({ + "schema": crate::SCHEMA, + "name": name, + "imports": [], + "defs": [ + { "kind": "type", "name": "Foo", "ctors": [] }, + { + "kind": "instance", + "class": class_ref, + "type": { "k": "con", "name": "Foo" }, + "methods": [] + } + ], + })).unwrap() + } + + fn module_with_class_def(name: &str, class_name: &str, param: &str) -> Module { + serde_json::from_value(serde_json::json!({ + "schema": crate::SCHEMA, + "name": name, + "imports": [], + "defs": [ + { + "kind": "class", + "name": class_name, + "param": param, + "methods": [] + } + ], + })).unwrap() + } + + /// pd.1 Task 3: `implicit_imports` drives whether the diagnostic + /// helpers offer fallback candidate suggestions for the named + /// implicit modules. With `&["prelude"]` the today-behaviour + /// `prelude.Eq` candidate fires; with `&[]` it does not. + #[test] + fn implicit_imports_arg_drives_prelude_fallback_in_diagnostics() { + let mut modules = BTreeMap::new(); + modules.insert("main".to_string(), module_with_bare_classref("main", "Eq")); + modules.insert( + "prelude".to_string(), + module_with_class_def("prelude", "Eq", "a"), + ); + + let err_with = validate_canonical_type_names(&modules, &["prelude"]).unwrap_err(); + match err_with { + WorkspaceLoadError::BareCrossModuleClassRef { ref candidates, .. } => { + assert!( + candidates.iter().any(|c| c == "prelude.Eq"), + "expected prelude.Eq in candidates, got {:?}", candidates + ); + } + other => panic!("expected BareCrossModuleClassRef, got {other:?}"), + } + + let err_without = validate_canonical_type_names(&modules, &[]).unwrap_err(); + match err_without { + WorkspaceLoadError::BareCrossModuleClassRef { ref candidates, .. } => { + assert!( + !candidates.iter().any(|c| c == "prelude.Eq"), + "expected NO prelude.Eq in candidates, got {:?}", candidates + ); + } + other => panic!("expected BareCrossModuleClassRef, got {other:?}"), + } + } + + /// 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()), + ); } } diff --git a/docs/journals/2026-05-14-iter-pd.1.md b/docs/journals/2026-05-14-iter-pd.1.md new file mode 100644 index 0000000..5e27eef --- /dev/null +++ b/docs/journals/2026-05-14-iter-pd.1.md @@ -0,0 +1,120 @@ +# iter pd.1 — core API split (load_modules_with + build_workspace) — surface unchanged via shim + +**Date:** 2026-05-14 +**Started from:** 61167a4ef0ade5dd65dadfbdde1a44b65c6693ca +**Status:** DONE +**Tasks completed:** 6 of 6 + +## Summary + +pd.1 carved out two new public fns in `crates/ailang-core/src/workspace.rs` +(`load_modules_with` for DFS-only loading, `build_workspace` for validation + +registry construction with a caller-supplied `implicit_imports: &[&str]`), +threaded the new parameter through `validate_canonical_type_names` / +`check_class_ref` / `check_type_con_name`, and replaced the four +hardcoded literal-`"prelude"` fallback blocks in the diagnostic helpers +with a parameterised iter-loop over `implicit_imports`. The surviving +`load_workspace_with` collapses from a ~70-line inline DFS+inject+validate +block to a 3-line composition (`load_modules_with` → caller-side prelude +inject → `build_workspace`). The legacy JSON-only wrapper `pub fn +load_workspace` retired with zero production callers (only doc-comment +references and 9 in-mod test callers; the latter migrated to +`load_workspace_with(&entry, load_one)`). Surface (`ailang-surface`) and +all downstream crates compile + test unchanged because the shim +preserves today's `load_workspace_with` semantics; the regression-pin +test asserts the prelude module hash matches `load_prelude()` post-refit. +Production-code literal-`"prelude"` count in workspace.rs dropped from 8 +to exactly the 4 expected sites (all in the shim, none in the +diagnostic helpers). pd.2 will move the prelude-embed + the inject step +into `ailang-surface` and retire the shim; pd.3 will delete +`examples/prelude.ail.json`. + +## Per-task notes + +- iter pd.1.1: added `pub fn load_modules_with` above `load_workspace_with` + (extracted DFS pre-amble verbatim from the old inline body); RED test + `load_modules_with_returns_modules_without_prelude` proves the new fn + returns modules without auto-injecting prelude. First-shot GREEN. +- iter pd.1.2: added `pub fn build_workspace` next to `load_modules_with` + (validation + registry construction; takes pre-assembled modules-map + and `implicit_imports: &[&str]`). RED test + `build_workspace_accepts_assembled_modules_and_runs_validation` ends + the task RED-on-purpose per the plan (the inner call to + `validate_canonical_type_names` doesn't have the parameter yet); + Task 3 lands the green. +- iter pd.1.3: added `implicit_imports: &[&str]` parameter to + `validate_canonical_type_names`, `check_class_ref`, + `check_type_con_name`; threaded it through 6 internal call sites in + `validate_canonical_type_names` (2 in the type-walk + 4 in the + class-ref match-arms); rewrote the two literal-`"prelude"` fallback + blocks in the diagnostic helpers as iter-loops over `implicit_imports`. + RED test `implicit_imports_arg_drives_prelude_fallback_in_diagnostics` + exercises both branches (with-prelude includes `prelude.Eq` candidate; + without-prelude excludes it). Migrated 18 in-mod test callers + 1 + production callsite (the still-inline shim body) to + `&["prelude"]`. Task 2's test now passes. New helpers + `module_with_bare_classref` and `module_with_class_def` use the + existing `serde_json::from_value(json!(...))` pattern (the rest of + mod tests' convention) rather than the plan's struct-literal style + (which would have failed against actual `ClassDef.superclass: + Option<...>` field shape). +- iter pd.1.4: replaced `load_workspace_with`'s ~70-line body with the + 3-line shim composition; doc-comment rewritten to describe the shim + role + retain the ext-cli.1 cycle-avoidance rationale on the + loader-injection point. Pin test + `load_workspace_with_shim_preserves_today_semantics` asserts + prelude-module hash equality with `load_prelude()` post-refit. +- iter pd.1.5: deleted `pub fn load_workspace`; removed it from + `lib.rs` re-export; rewrote the workspace.rs module rustdoc example + + 3 lib.rs `//!` references to point at `load_workspace_with`; + hoisted the orphaned "Algorithm: DFS over imports" doc-comment block + onto `load_modules_with` (where it now belongs). Migrated 9 in-mod + test callers from `load_workspace(&entry)` to + `load_workspace_with(&entry, load_one)`. Required `#[cfg(test)]` + gating of `fn load_one` and the `load_module` import (now consumed + only by the test mod) to silence dead-code warnings. +- iter pd.1.6: verification — `cargo test --workspace` all green + (every crate's tests pass); `bench/check.py` + `bench/compile_check.py` + + `bench/cross_lang.py` all exit 0; production literal-`"prelude"` + count in workspace.rs is exactly 4 (the four expected shim sites at + lines 553/555/558/560 — all in `load_workspace_with` body — none in + the diagnostic helpers). + +## Concerns + +- pd.1.2 ends RED-on-purpose per the plan. This is intentional and + documented in the plan, but if a future iter ever bisects through + this plan the workspace WILL NOT compile at the Task 2 boundary — + Task 3 must land in the same Boss commit. The Boss-side commit + shape for pd.1 is therefore "one cohesive iter commit" not "per-task + commits". +- pd.1.5 needed an unanticipated `#[cfg(test)]` gating step on the + `load_one` fn + the `load_module` import. The plan didn't anticipate + this because it framed the `load_workspace` retirement as "doc + lib + cleanup only", but `load_one` was the only consumer of those imports + in production. Recording so a future planner adds an "if removing a + caller, check whether its callees become test-only" check. + +## Known debt + +- pd.2 will move prelude-embed (`PRELUDE_JSON` const + `load_prelude` fn) + into `ailang-surface` and rewire surface's `load_workspace` to call + `load_modules_with` + `build_workspace` directly, retiring the shim. + Out of scope for pd.1 by spec. +- pd.3 will delete `examples/prelude.ail.json`. Out of scope for pd.1. +- The latency.implicit_at_rc improvement cluster appearing in + `bench/check.py` (4 metrics regressed, 2 improved) reappears for + the 4th audit in a row; cause not localised. pd.1 is a workspace.rs + refactor with zero codegen / runtime touch, so cannot be the source. + Baseline left pristine (consistent with audit-eob and earlier + decisions). + +## Files touched + +- `crates/ailang-core/src/workspace.rs` (extraction + threading + + shim refit + dead-fn delete + test-mod migration) +- `crates/ailang-core/src/lib.rs` (re-export drop + 3 rustdoc updates) + +## Stats + +`bench/orchestrator-stats/2026-05-14-iter-pd.1.json`