iter pd.1: core API split — load_modules_with + build_workspace + implicit_imports threading

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.
This commit is contained in:
2026-05-14 12:25:12 +02:00
parent 61167a4ef0
commit ffa80326a3
4 changed files with 439 additions and 105 deletions
@@ -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
}
+12 -7
View File
@@ -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<Module> {
let bytes = std::fs::read(path)?;
let module: Module = serde_json::from_slice(&bytes)?;
+288 -98
View File
@@ -5,11 +5,15 @@
//! the entry file's directory as `<root_dir>/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<Workspace, WorkspaceLoadError> {
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<F>(
pub fn load_modules_with<F>(
entry_path: &Path,
loader: F,
) -> Result<Workspace, WorkspaceLoadError>
) -> Result<(String, PathBuf, BTreeMap<String, Module>), WorkspaceLoadError>
where
F: Fn(&Path) -> Result<Module, WorkspaceLoadError> + 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<String, Module>,
implicit_imports: &[&str],
) -> Result<Workspace, WorkspaceLoadError> {
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<F>(
entry_path: &Path,
loader: F,
) -> Result<Workspace, WorkspaceLoadError>
where
F: Fn(&Path) -> Result<Module, WorkspaceLoadError> + Copy,
{
let (entry_name, root_dir, mut modules) = load_modules_with(entry_path, loader)?;
// Iter 23.1 (preserved through the shim): the prelude module is
// implicitly part of every workspace. A user module of the same name
// would shadow; reject explicitly.
if modules.contains_key("prelude") {
return Err(WorkspaceLoadError::ReservedModuleName {
name: "prelude".to_string(),
});
}
modules.insert("prelude".to_string(), load_prelude());
build_workspace(entry_name, root_dir, modules, &["prelude"])
}
/// 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<String, Module>,
implicit_imports: &[&str],
) -> Result<(), WorkspaceLoadError> {
// Pre-pass: for each module, build a `BTreeSet<String>` 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<String, BTreeSet<String>>,
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<String, BTreeSet<String>>,
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<Module, WorkspaceLoadError> {
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()),
);
}
}
+120
View File
@@ -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`