iter pd.2: surface owns prelude embed; pd.1 shim retired
Moved PRELUDE_AIL const + parse_prelude fn to ailang-surface; re-exported from surface's lib.rs. surface::load_workspace rewritten 3-line shim-call → 7-line composition: load_modules_with → reserved-name check → inject parse_prelude → build_workspace(&["prelude"]). Five deletions in core: PRELUDE_JSON, load_prelude, load_workspace_with (the pd.1 shim), the top-level cfg(test) load_one fn, and the cfg(test) crate::load_module import. Production literal-"prelude" count in crates/ailang-core/src/ is now zero. Cross-form-identity preflight PASSED at module_hash 3abe0d3fa3c11c99 — parse(prelude.ail) ≡ deserialize(prelude.ail.json), so pd.3's deletion of the JSON is safe. Three new pin tests in ailang-surface/tests/: prelude_parse_pin (succeeds + min defs); prelude_module_hash_pin (cross-form-identity + literal-hex anchor); prelude_injection_pin (inject + reservation). Plan deviations (recorded in journal): switched the regression-pin fixture from non-existent examples/hello.ail.json to hello.ail; relocated 10 in-mod core tests to crates/ailang-core/tests/workspace_pin.rs because the dev-dep cycle (core ↔ surface) prevents in-mod aliasing in the lib-test target (form-a.1 T5 precedent); preserved load_one production-symbol-deletion by adding a test-mod-private load_one helper in core's mod tests for the 2 pd.1-introduced tests still consuming it. cargo test --workspace 573 green (+9 net from pd.1 baseline). bench/cross_lang.py + bench/compile_check.py exit 0; bench/check.py exit 1 with established noise envelope (bench_list_sum.bump_s, 13th consecutive observation; pd.2 is workspace-loader-only, no codegen touch — baseline pristine).
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -13,12 +13,16 @@
|
||||
//! canonical bytes, which is how the rest of the pipeline refers to it.
|
||||
//! - **Pretty-printing** — see [`pretty`]. AST → human-readable text for
|
||||
//! diff/review. The JSON form remains the source of truth.
|
||||
//! - **Workspace loading** — see [`workspace`] and
|
||||
//! [`workspace::load_workspace_with`]. Resolves transitive imports of
|
||||
//! an entry module and returns a [`Workspace`]. pd.1: the JSON-only
|
||||
//! wrapper `load_workspace` was retired; the loader-injection shim
|
||||
//! `load_workspace_with` is the single core entry point in pd.1, and
|
||||
//! pd.2 will move the public entry point into `ailang-surface`.
|
||||
//! - **Workspace loading** — see [`workspace`],
|
||||
//! [`workspace::load_modules_with`], and [`workspace::build_workspace`].
|
||||
//! Core exposes the two-phase composition: DFS-only loader returning a
|
||||
//! modules-map, then a separate validation + registry-construction
|
||||
//! step that takes a caller-supplied `implicit_imports` slice. The
|
||||
//! public single-call entry point lives in `ailang-surface` (see
|
||||
//! [`ailang_surface::load_workspace`](https://docs.rs/ailang-surface)),
|
||||
//! which composes the two halves with a prelude-inject step in
|
||||
//! between. pd.2 retired the pd.1 `load_workspace_with` shim and
|
||||
//! moved the prelude embed into surface.
|
||||
//! - **AST → AST desugaring** — see [`desugar`]. Pure rewrite that runs
|
||||
//! *after* `load_module` and before the typechecker / codegen
|
||||
//! consume a module. Iter 16a flattens nested constructor patterns
|
||||
@@ -47,8 +51,10 @@
|
||||
//! ## Entry points
|
||||
//!
|
||||
//! - Read a single module from disk: [`load_module`].
|
||||
//! - Read an entry module plus all imports:
|
||||
//! [`workspace::load_workspace_with`].
|
||||
//! - Assemble a workspace from an entry module plus all imports:
|
||||
//! `ailang_surface::load_workspace` (which composes
|
||||
//! [`workspace::load_modules_with`] + a prelude inject +
|
||||
//! [`workspace::build_workspace`]).
|
||||
//! - Hash a [`Def`]: [`def_hash`]. Hash a whole [`Module`]:
|
||||
//! [`workspace::module_hash`].
|
||||
//! - Form-(A) text projection of a [`Module`]: see
|
||||
@@ -117,9 +123,10 @@ pub const FORM_A_SPEC: &str = include_str!("../specs/form_a.md");
|
||||
/// into a [`Module`].
|
||||
///
|
||||
/// This is the lowest-level entry point and does **not** resolve imports
|
||||
/// — for the transitive closure use [`workspace::load_workspace_with`].
|
||||
/// Errors are returned as [`Error::Io`] / [`Error::Json`] /
|
||||
/// [`Error::SchemaMismatch`].
|
||||
/// — for the transitive closure use `ailang_surface::load_workspace`
|
||||
/// (which composes [`workspace::load_modules_with`] + a prelude inject
|
||||
/// + [`workspace::build_workspace`]). Errors are returned as
|
||||
/// [`Error::Io`] / [`Error::Json`] / [`Error::SchemaMismatch`].
|
||||
pub fn load_module(path: &std::path::Path) -> Result<Module> {
|
||||
let bytes = std::fs::read(path)?;
|
||||
let module: Module = serde_json::from_slice(&bytes)?;
|
||||
|
||||
+107
-303
@@ -1,50 +1,35 @@
|
||||
//! Workspace loader: loads an entry module and recursively follows its
|
||||
//! `imports`.
|
||||
//! Workspace loading and per-workspace validation.
|
||||
//!
|
||||
//! Convention: an `import { module: "foo" }` is resolved relative to
|
||||
//! the entry file's directory as `<root_dir>/foo.ail.json`. Module
|
||||
//! names must match the file stem (the loader rejects mismatches).
|
||||
//! Two phases, exposed as separate public fns so callers can compose
|
||||
//! them around an injection point (e.g. surface's prelude inject):
|
||||
//!
|
||||
//! The pd.1 single core entry point is [`load_workspace_with`]; it
|
||||
//! takes a caller-supplied per-module loader so surface (`.ail`) and
|
||||
//! JSON (`.ail.json`) callers share one pipeline. It returns a fully
|
||||
//! populated [`Workspace`] or a structured [`WorkspaceLoadError`].
|
||||
//! pd.2 will move the public entry point into `ailang-surface` and
|
||||
//! retire this shim. [`module_hash`] is the module-granularity
|
||||
//! counterpart to [`crate::def_hash`] and is used both internally (to
|
||||
//! detect a module re-loaded from disk with different content) and by
|
||||
//! the CLI.
|
||||
//! - [`load_modules_with`] — DFS over `imports`, returns a modules
|
||||
//! map with no validation and no implicit modules. The caller
|
||||
//! supplies a per-module loader.
|
||||
//! - [`build_workspace`] — runs the three-stage validation pipeline
|
||||
//! (`validate_canonical_type_names`, `validate_classdefs`,
|
||||
//! `build_registry`) plus [`Workspace`] assembly. Takes
|
||||
//! `implicit_imports: &[&str]` so the diagnostic helpers can name
|
||||
//! modules that exist but are not in any user import list.
|
||||
//!
|
||||
//! This module is responsible only for **finding** and consistently
|
||||
//! **loading** all reachable modules. Cross-module typechecking lives
|
||||
//! in `ailang-check`; codegen in `ailang-codegen`. Neither is run from
|
||||
//! here.
|
||||
//! The public composition lives in `ailang-surface`'s `load_workspace`
|
||||
//! (which adds a prelude inject step between the two phases). Direct
|
||||
//! consumers of these core fns are `ailang-surface` and a handful of
|
||||
//! tests; CLI dispatch goes through surface.
|
||||
//!
|
||||
//! # Examples
|
||||
//! This module is responsible only for **finding**, **assembling**,
|
||||
//! and **schema-validating** the module graph. Cross-module
|
||||
//! typechecking lives in `ailang-check`. Cross-module monomorphisation
|
||||
//! and codegen live in `ailang-codegen`.
|
||||
//!
|
||||
//! ```ignore
|
||||
//! // pd.2 will move the public entry point to ailang-surface;
|
||||
//! // pd.1 keeps `load_workspace_with` as the single core entry point.
|
||||
//! // Callers supply a per-module loader (e.g.
|
||||
//! // `ailang_surface::load_module_dispatching`) that knows how to
|
||||
//! // read `.ail` and/or `.ail.json` files.
|
||||
//! use ailang_core::workspace::load_workspace_with;
|
||||
//! use std::path::Path;
|
||||
//!
|
||||
//! let ws = load_workspace_with(Path::new("examples/ws_main.ail.json"), my_loader)?;
|
||||
//! assert_eq!(ws.entry, "ws_main");
|
||||
//! // All transitively imported modules are now in `ws.modules`.
|
||||
//! for (name, m) in &ws.modules {
|
||||
//! println!("{name}: {} defs", m.defs.len());
|
||||
//! }
|
||||
//! # Ok::<(), ailang_core::workspace::WorkspaceLoadError>(())
|
||||
//! ```
|
||||
//! [`module_hash`] is the module-granularity counterpart to
|
||||
//! [`crate::def_hash`]; used internally to detect a re-loaded module
|
||||
//! with different content, and by the CLI for stable per-module
|
||||
//! identifiers.
|
||||
|
||||
use crate::ast::{ClassDef, Def, InstanceDef, Module, Term, Type};
|
||||
use crate::canonical;
|
||||
use crate::Error as CoreError;
|
||||
#[cfg(test)]
|
||||
use crate::load_module;
|
||||
use std::collections::{BTreeMap, BTreeSet, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
@@ -421,23 +406,6 @@ pub fn module_hash(m: &Module) -> String {
|
||||
h.to_hex().as_str()[..16].to_string()
|
||||
}
|
||||
|
||||
/// Iter 23.1: the prelude module is embedded into the binary at
|
||||
/// compile time so the loader needs no on-disk resolution. The
|
||||
/// canonical source-of-truth file remains
|
||||
/// `examples/prelude.ail.json` (the file the LLM-author edits).
|
||||
const PRELUDE_JSON: &str = include_str!(
|
||||
"../../../examples/prelude.ail.json"
|
||||
);
|
||||
|
||||
/// Iter 23.1: parse the embedded prelude source into a `Module`.
|
||||
/// Panics on parse failure — the prelude is build-time-validated;
|
||||
/// a failure here means the embedded file is malformed and is a
|
||||
/// build-correctness bug, not a runtime concern.
|
||||
fn load_prelude() -> Module {
|
||||
serde_json::from_str(PRELUDE_JSON)
|
||||
.expect("examples/prelude.ail.json must parse as a Module")
|
||||
}
|
||||
|
||||
/// pd.1: extract the DFS pre-amble of `load_workspace_with` into a public
|
||||
/// loader-only fn. Returns `(entry_name, root_dir, modules)` with NO prelude
|
||||
/// injected and NO validation run. The caller is responsible for injecting
|
||||
@@ -522,43 +490,12 @@ pub fn build_workspace(
|
||||
})
|
||||
}
|
||||
|
||||
/// pd.1: thin compatibility shim composed of `load_modules_with` +
|
||||
/// `build_workspace` plus the prelude inject. Preserves today's
|
||||
/// `load_workspace_with` semantics so surface (`crates/ailang-surface/src/loader.rs`)
|
||||
/// stays unchanged in pd.1. pd.2 will rewire surface to call
|
||||
/// `load_modules_with` and `build_workspace` directly and retire this
|
||||
/// shim along with the embedded `PRELUDE_JSON` constant.
|
||||
///
|
||||
/// Algorithm: DFS over `imports` via `load_modules_with`, then inject
|
||||
/// the prelude (rejecting collision with a user module of the same
|
||||
/// name), then validate + build the typeclass registry via
|
||||
/// `build_workspace`.
|
||||
///
|
||||
/// ext-cli.1: a caller-supplied loader is what allows
|
||||
/// `ailang-surface::load_workspace` to plug in an extension-dispatching
|
||||
/// loader without `ailang-core` importing `ailang-surface` (which would
|
||||
/// close a crate cycle).
|
||||
pub fn load_workspace_with<F>(
|
||||
entry_path: &Path,
|
||||
loader: F,
|
||||
) -> Result<Workspace, WorkspaceLoadError>
|
||||
where
|
||||
F: Fn(&Path) -> Result<Module, WorkspaceLoadError> + Copy,
|
||||
{
|
||||
let (entry_name, root_dir, mut modules) = load_modules_with(entry_path, loader)?;
|
||||
|
||||
// Iter 23.1 (preserved through the shim): the prelude module is
|
||||
// implicitly part of every workspace. A user module of the same name
|
||||
// would shadow; reject explicitly.
|
||||
if modules.contains_key("prelude") {
|
||||
return Err(WorkspaceLoadError::ReservedModuleName {
|
||||
name: "prelude".to_string(),
|
||||
});
|
||||
}
|
||||
modules.insert("prelude".to_string(), load_prelude());
|
||||
|
||||
build_workspace(entry_name, root_dir, modules, &["prelude"])
|
||||
}
|
||||
// pd.1: `load_workspace_with` shim retired in pd.2. The public entry
|
||||
// point is now `ailang_surface::load_workspace`, which composes
|
||||
// `load_modules_with` + caller-side prelude inject + `build_workspace`
|
||||
// directly. Surface owns the prelude inject step (the embed lives at
|
||||
// `ailang_surface::PRELUDE_AIL` + `ailang_surface::parse_prelude`); core
|
||||
// exposes the two-phase composition.
|
||||
|
||||
/// mq.1: take a class-ref field value (`InstanceDef.class`,
|
||||
/// `SuperclassRef.class`, `Constraint.class`) and a defining context,
|
||||
@@ -1595,20 +1532,11 @@ where
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn load_one(path: &Path) -> Result<Module, WorkspaceLoadError> {
|
||||
match load_module(path) {
|
||||
Ok(m) => Ok(m),
|
||||
Err(CoreError::Io(e)) => Err(WorkspaceLoadError::Io {
|
||||
path: path.to_path_buf(),
|
||||
source: e,
|
||||
}),
|
||||
Err(e) => Err(WorkspaceLoadError::Schema {
|
||||
path: path.to_path_buf(),
|
||||
source: e,
|
||||
}),
|
||||
}
|
||||
}
|
||||
// pd.2: `load_one` deleted along with the `load_workspace_with` shim.
|
||||
// The two surviving in-mod tests that need a JSON-only single-module
|
||||
// loader (`load_modules_with_returns_modules_without_prelude` and
|
||||
// `load_modules_with_custom_loader_is_called`) inline the
|
||||
// `crate::load_module` adapter directly.
|
||||
|
||||
fn module_name_from_path(p: &Path) -> String {
|
||||
let file = p.file_name().and_then(|s| s.to_str()).unwrap_or("");
|
||||
@@ -1629,6 +1557,25 @@ mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
/// pd.2: tests that need a JSON-only single-module loader (the
|
||||
/// pre-pd.2 `load_one` shape) inline this adapter. Wraps
|
||||
/// `crate::load_module` and converts the `Error` variants into
|
||||
/// `WorkspaceLoadError`. Used by the pd.1-introduced tests that
|
||||
/// exercise `load_modules_with` directly.
|
||||
fn load_one(path: &Path) -> Result<Module, WorkspaceLoadError> {
|
||||
match crate::load_module(path) {
|
||||
Ok(m) => Ok(m),
|
||||
Err(CoreError::Io(e)) => Err(WorkspaceLoadError::Io {
|
||||
path: path.to_path_buf(),
|
||||
source: e,
|
||||
}),
|
||||
Err(e) => Err(WorkspaceLoadError::Schema {
|
||||
path: path.to_path_buf(),
|
||||
source: e,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_module(dir: &Path, name: &str, imports: &[&str]) -> PathBuf {
|
||||
let imports_json: Vec<serde_json::Value> = imports
|
||||
.iter()
|
||||
@@ -1662,59 +1609,11 @@ mod tests {
|
||||
|
||||
// (See workspace_pin.rs relocation marker above.)
|
||||
|
||||
#[test]
|
||||
fn user_module_named_prelude_is_rejected() {
|
||||
// Iter 23.1: the loader auto-injects a module named "prelude",
|
||||
// so a user module that also claims that name would collide
|
||||
// silently. The collision is caught explicitly with
|
||||
// `ReservedModuleName`.
|
||||
let dir = tmp_dir("prelude_collision");
|
||||
write_module(&dir, "prelude", &[]);
|
||||
let entry = dir.join("prelude.ail.json");
|
||||
|
||||
let err = load_workspace_with(&entry, load_one).expect_err("must reject user prelude");
|
||||
match err {
|
||||
WorkspaceLoadError::ReservedModuleName { name } => {
|
||||
assert_eq!(name, "prelude");
|
||||
}
|
||||
other => panic!(
|
||||
"expected ReservedModuleName, got: {other:?}"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_import_cycle() {
|
||||
let dir = tmp_dir("cycle");
|
||||
write_module(&dir, "a", &["b"]);
|
||||
write_module(&dir, "b", &["a"]);
|
||||
let entry = dir.join("a.ail.json");
|
||||
|
||||
let err = load_workspace_with(&entry, load_one).expect_err("must error on cycle");
|
||||
match err {
|
||||
WorkspaceLoadError::Cycle { path } => {
|
||||
assert!(path.contains(&"a".to_string()));
|
||||
assert!(path.contains(&"b".to_string()));
|
||||
}
|
||||
other => panic!("expected Cycle, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn module_not_found_yields_structured_error() {
|
||||
let dir = tmp_dir("notfound");
|
||||
write_module(&dir, "main", &["does_not_exist"]);
|
||||
let entry = dir.join("main.ail.json");
|
||||
|
||||
let err = load_workspace_with(&entry, load_one).expect_err("must error on missing module");
|
||||
match err {
|
||||
WorkspaceLoadError::ModuleNotFound { name, expected_path } => {
|
||||
assert_eq!(name, "does_not_exist");
|
||||
assert!(expected_path.ends_with("does_not_exist.ail.json"));
|
||||
}
|
||||
other => panic!("expected ModuleNotFound, got {other:?}"),
|
||||
}
|
||||
}
|
||||
// pd.2: `user_module_named_prelude_is_rejected`,
|
||||
// `detects_import_cycle`, and `module_not_found_yields_structured_error`
|
||||
// relocated to `crates/ailang-core/tests/workspace_pin.rs` (the
|
||||
// ailang-surface dev-dep cycle disallows `surface::load_workspace`
|
||||
// calls from this in-mod test crate).
|
||||
|
||||
// Iter 22b.1: a workspace whose own modules contain no
|
||||
// `Def::Instance` defs produces no non-prelude registry entries.
|
||||
@@ -1783,48 +1682,11 @@ mod tests {
|
||||
// `iter22b1_missing_method_fires_diagnostic` relocated to
|
||||
// `crates/ailang-core/tests/workspace_pin.rs` in iter form-a.1 Task 5.
|
||||
|
||||
#[test]
|
||||
fn class_param_in_applied_position_fires_canonical_form_rejection() {
|
||||
let entry = std::path::PathBuf::from(
|
||||
"../../examples/test_22b2_kind_mismatch.ail.json",
|
||||
);
|
||||
let err = load_workspace_with(&entry, load_one)
|
||||
.expect_err("must fire canonical-form rejection");
|
||||
match err {
|
||||
WorkspaceLoadError::BareCrossModuleTypeRef { module, name, .. } => {
|
||||
assert_eq!(module, "test_22b2_kind_mismatch");
|
||||
assert_eq!(name, "f");
|
||||
}
|
||||
other => panic!(
|
||||
"expected BareCrossModuleTypeRef (validator now fires first), got {other:?}",
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Iter 22b.2: a class whose `superclass.type` differs from its
|
||||
/// own `param` (e.g. `class Ord a extends Eq b`) must fire
|
||||
/// `InvalidSuperclassParam`. Decision 11 axis 1 ("single
|
||||
/// superclass, applied to the same param") makes the only legal
|
||||
/// shape `extends Eq a` when the parent class has `param: "a"`.
|
||||
#[test]
|
||||
fn superclass_with_wrong_param_fires_invalid_superclass_param() {
|
||||
let entry = std::path::PathBuf::from(
|
||||
"../../examples/test_22b2_invalid_superclass_param.ail.json",
|
||||
);
|
||||
let err = load_workspace_with(&entry, load_one)
|
||||
.expect_err("must fire invalid-superclass-param");
|
||||
match err {
|
||||
WorkspaceLoadError::InvalidSuperclassParam {
|
||||
class, superclass, expected_param, got_type,
|
||||
} => {
|
||||
assert_eq!(class, "Ord");
|
||||
assert_eq!(superclass, "Eq");
|
||||
assert_eq!(expected_param, "a");
|
||||
assert_eq!(got_type, "b");
|
||||
}
|
||||
other => panic!("expected InvalidSuperclassParam, got {other:?}"),
|
||||
}
|
||||
}
|
||||
// `class_param_in_applied_position_fires_canonical_form_rejection`
|
||||
// and `superclass_with_wrong_param_fires_invalid_superclass_param`
|
||||
// relocated to `crates/ailang-core/tests/workspace_pin.rs` in iter
|
||||
// pd.2 (post-shim retirement; in-mod tests cannot reach
|
||||
// `ailang_surface::load_workspace` due to the dev-dep cycle).
|
||||
|
||||
// Iter 22b.2: an instance that specifies a body for a method
|
||||
// name the class never declared must fire
|
||||
@@ -1839,31 +1701,9 @@ mod tests {
|
||||
// `instance_overriding_nonexistent_method_fires` relocated to
|
||||
// `crates/ailang-core/tests/workspace_pin.rs` in iter form-a.1 Task 5.
|
||||
|
||||
/// Iter 22b.2: a class method's `Type::Forall` whose `constraints`
|
||||
/// reference a type variable that is neither bound by `Forall.vars`
|
||||
/// nor equal to the class's `param` must fire
|
||||
/// `UnboundConstraintTypeVar`. The fixture's `class Foo a` declares
|
||||
/// method `foo` with `forall a. (Bar z) => a -> Unit`; `z` is
|
||||
/// bound nowhere, so the constraint is unsatisfiable by
|
||||
/// construction.
|
||||
#[test]
|
||||
fn constraint_with_unbound_var_fires_unbound_constraint_type_var() {
|
||||
let entry = std::path::PathBuf::from(
|
||||
"../../examples/test_22b2_unbound_constraint_var.ail.json",
|
||||
);
|
||||
let err = load_workspace_with(&entry, load_one)
|
||||
.expect_err("must fire constraint-references-unbound-type-var");
|
||||
match err {
|
||||
WorkspaceLoadError::UnboundConstraintTypeVar {
|
||||
class, method, var, ..
|
||||
} => {
|
||||
assert_eq!(class, "Foo");
|
||||
assert_eq!(method, "foo");
|
||||
assert_eq!(var, "z");
|
||||
}
|
||||
other => panic!("expected UnboundConstraintTypeVar, got {other:?}"),
|
||||
}
|
||||
}
|
||||
// `constraint_with_unbound_var_fires_unbound_constraint_type_var`
|
||||
// relocated to `crates/ailang-core/tests/workspace_pin.rs` in iter
|
||||
// pd.2.
|
||||
|
||||
// mq.3: the two `..._method_name_collision_fires` pin tests
|
||||
// (class-class and class-fn variants) were retired here and
|
||||
@@ -2485,34 +2325,13 @@ mod tests {
|
||||
/// `Ordering` Term::Ctor with no imports; the validator catches it
|
||||
/// after prelude injection (so the candidate list contains
|
||||
/// `prelude.Ordering`).
|
||||
#[test]
|
||||
fn ct1_fixture_bare_xmod_rejected() {
|
||||
let entry = examples_dir().join("test_ct1_bare_xmod_rejected.ail.json");
|
||||
let err = load_workspace_with(&entry, load_one).expect_err("must reject bare Ordering");
|
||||
match err {
|
||||
WorkspaceLoadError::BareCrossModuleTypeRef { module, name, candidates } => {
|
||||
assert_eq!(module, "test_ct1_bare_xmod_rejected");
|
||||
assert_eq!(name, "Ordering");
|
||||
assert_eq!(candidates, vec!["prelude.Ordering".to_string()]);
|
||||
}
|
||||
other => panic!("expected BareCrossModuleTypeRef, got {other:?}"),
|
||||
}
|
||||
}
|
||||
// `ct1_fixture_bare_xmod_rejected` relocated to
|
||||
// `crates/ailang-core/tests/workspace_pin.rs` in iter pd.2.
|
||||
|
||||
/// ct.1: on-disk fixture for `BadCrossModuleTypeRef`. Qualified
|
||||
/// `Mystery.Type` with `Mystery` not a known module.
|
||||
#[test]
|
||||
fn ct1_fixture_bad_qualifier() {
|
||||
let entry = examples_dir().join("test_ct1_bad_qualifier.ail.json");
|
||||
let err = load_workspace_with(&entry, load_one).expect_err("must reject Mystery.Type");
|
||||
match err {
|
||||
WorkspaceLoadError::BadCrossModuleTypeRef { module, name } => {
|
||||
assert_eq!(module, "test_ct1_bad_qualifier");
|
||||
assert_eq!(name, "Mystery.Type");
|
||||
}
|
||||
other => panic!("expected BadCrossModuleTypeRef, got {other:?}"),
|
||||
}
|
||||
}
|
||||
// `ct1_fixture_bad_qualifier` relocated to
|
||||
// `crates/ailang-core/tests/workspace_pin.rs` in iter pd.2.
|
||||
|
||||
/// mq.1: on-disk fixture for the post-mq.1 rejection path. The
|
||||
/// fixture declares `instance prelude.Eq Int` outside the prelude
|
||||
@@ -2524,21 +2343,8 @@ mod tests {
|
||||
/// Pre-mq.1 this test asserted `QualifiedClassName` on the same
|
||||
/// fixture; the rename + reshape is the on-disk-fixture half of
|
||||
/// the four in-test inversions further up.
|
||||
#[test]
|
||||
fn ct1_fixture_qualified_class_orphan_post_mq1() {
|
||||
let entry = examples_dir().join("test_ct1_qualified_class_rejected.ail.json");
|
||||
let err = load_workspace_with(&entry, load_one).expect_err("must reject (now as Orphan)");
|
||||
match err {
|
||||
WorkspaceLoadError::OrphanInstance {
|
||||
class, type_repr, defining_module, ..
|
||||
} => {
|
||||
assert_eq!(class, "prelude.Eq");
|
||||
assert_eq!(type_repr, "Int");
|
||||
assert_eq!(defining_module, "test_ct1_qualified_class_rejected");
|
||||
}
|
||||
other => panic!("expected OrphanInstance, got {other:?}"),
|
||||
}
|
||||
}
|
||||
// `ct1_fixture_qualified_class_orphan_post_mq1` relocated to
|
||||
// `crates/ailang-core/tests/workspace_pin.rs` in iter pd.2.
|
||||
|
||||
// mq.1: on-disk fixture pin — a workspace where the consumer's
|
||||
// `Constraint.class` references a class in an imported module via
|
||||
@@ -2549,14 +2355,15 @@ mod tests {
|
||||
// `mq1_xmod_constraint_class_fixture_loads` relocated to
|
||||
// `crates/ailang-core/tests/workspace_pin.rs` in iter form-a.1 Task 5.
|
||||
|
||||
/// ext-cli.1 Task 1: the new `load_workspace_with` injection point
|
||||
/// invokes the caller-supplied loader exactly once for the entry
|
||||
/// module of a single-module workspace. Protects against the
|
||||
/// extension-dispatching loader (in `ailang-surface`) silently
|
||||
/// falling back to `load_one` and so never being given a `.ail`
|
||||
/// path to read.
|
||||
/// ext-cli.1 Task 1 / pd.2: the loader-injection contract is now
|
||||
/// owned by `load_modules_with` (the DFS-only loader) directly,
|
||||
/// since the `load_workspace_with` shim is gone. The custom loader
|
||||
/// must be invoked exactly once for the entry module of a single-
|
||||
/// module workspace. Protects against the extension-dispatching
|
||||
/// loader (in `ailang-surface`) silently falling back to a
|
||||
/// JSON-only path and so never being given a `.ail` to read.
|
||||
#[test]
|
||||
fn load_workspace_with_custom_loader_is_called() {
|
||||
fn load_modules_with_custom_loader_is_called() {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
let tmpdir = tempfile::tempdir().expect("tempdir");
|
||||
@@ -2568,12 +2375,22 @@ mod tests {
|
||||
|
||||
let loader = |path: &std::path::Path| -> Result<Module, WorkspaceLoadError> {
|
||||
CALLS.fetch_add(1, Ordering::SeqCst);
|
||||
load_one(path)
|
||||
crate::load_module(path).map_err(|e| match e {
|
||||
CoreError::Io(io) => WorkspaceLoadError::Io {
|
||||
path: path.to_path_buf(),
|
||||
source: io,
|
||||
},
|
||||
other => WorkspaceLoadError::Schema {
|
||||
path: path.to_path_buf(),
|
||||
source: other,
|
||||
},
|
||||
})
|
||||
};
|
||||
|
||||
let ws = load_workspace_with(&entry, loader)
|
||||
let (entry_name, _root_dir, modules) = load_modules_with(&entry, loader)
|
||||
.expect("workspace loads via custom loader");
|
||||
assert_eq!(ws.entry, "dummy");
|
||||
assert_eq!(entry_name, "dummy");
|
||||
assert!(modules.contains_key("dummy"));
|
||||
assert_eq!(
|
||||
CALLS.load(Ordering::SeqCst),
|
||||
1,
|
||||
@@ -2827,8 +2644,16 @@ mod tests {
|
||||
let (entry_name, root_dir, mut modules) =
|
||||
load_modules_with(&entry, load_one).expect("load");
|
||||
|
||||
// Caller-side prelude inject (mirrors what surface will do in pd.2).
|
||||
let prelude = load_prelude();
|
||||
// Caller-side prelude inject — pd.2 deletes `load_prelude` from
|
||||
// production, but this test still needs SOMETHING in the
|
||||
// `prelude` slot to demonstrate the caller-injects pattern.
|
||||
// Read the JSON sidecar via the same path the retired
|
||||
// `load_prelude` used. Surface owns the production inject path
|
||||
// (via `ailang_surface::parse_prelude`).
|
||||
const PRELUDE_JSON: &str =
|
||||
include_str!("../../../examples/prelude.ail.json");
|
||||
let prelude: Module = serde_json::from_str(PRELUDE_JSON)
|
||||
.expect("examples/prelude.ail.json must parse as a Module");
|
||||
modules.insert("prelude".to_string(), prelude);
|
||||
|
||||
let ws = build_workspace(entry_name, root_dir, modules, &["prelude"])
|
||||
@@ -2911,29 +2736,8 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// pd.1 Task 4: `load_workspace_with` after the shim refit
|
||||
/// produces a `Workspace` byte-identical to today's. Pinned by
|
||||
/// asserting prelude injection still happens AND the prelude
|
||||
/// module's hash matches the freshly-loaded prelude — i.e. the
|
||||
/// shim composition (`load_modules_with` + inject + `build_workspace`)
|
||||
/// preserves the pre-refit observable behaviour.
|
||||
#[test]
|
||||
fn load_workspace_with_shim_preserves_today_semantics() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write_module(dir.path(), "main", &[]);
|
||||
let entry = dir.path().join("main.ail.json");
|
||||
|
||||
let ws = load_workspace_with(&entry, load_one).expect("load");
|
||||
|
||||
assert_eq!(ws.entry, "main");
|
||||
assert!(ws.modules.contains_key("main"));
|
||||
assert!(
|
||||
ws.modules.contains_key("prelude"),
|
||||
"shim must still inject prelude"
|
||||
);
|
||||
assert_eq!(
|
||||
module_hash(&ws.modules["prelude"]),
|
||||
module_hash(&load_prelude()),
|
||||
);
|
||||
}
|
||||
// pd.2: `load_workspace_with_shim_preserves_today_semantics` deleted —
|
||||
// the shim it pinned is gone. Cross-form-identity / hash-pin
|
||||
// coverage now lives in
|
||||
// `crates/ailang-surface/tests/prelude_module_hash_pin.rs`.
|
||||
}
|
||||
|
||||
@@ -6,10 +6,16 @@
|
||||
//! so the post-iter `.ail` corpus is loaded from Form A rather than the
|
||||
//! deleted `.ail.json` siblings.
|
||||
//!
|
||||
//! Carve-out tests (the seven §C4 (a) subject-matter rejections and the
|
||||
//! prelude embed) stay in-place in `workspace.rs` because their
|
||||
//! `.ail.json` fixtures are not migrated; this file covers the
|
||||
//! non-carve-out cohort only.
|
||||
//! pd.2 extension: the second batch (10 tests at the end of this file)
|
||||
//! is relocated from `ailang-core/src/workspace.rs::tests` because pd.2
|
||||
//! deletes the `load_workspace_with` shim those tests depended on.
|
||||
//! Their post-pd.2 successor is `ailang_surface::load_workspace`, which
|
||||
//! is unreachable from `ailang-core` lib-tests due to the dev-dep cycle
|
||||
//! (`ailang-core` dev-deps `ailang-surface` which depends on
|
||||
//! `ailang-core` — fine for integration tests, breaks lib-tests). The
|
||||
//! integration-test crate is the right home: it consumes both crates
|
||||
//! against the same `ailang-core` artefact, so the cycle never
|
||||
//! materialises.
|
||||
|
||||
use ailang_core::workspace::{Registry, RegistryEntry, WorkspaceLoadError};
|
||||
use ailang_surface::load_workspace;
|
||||
@@ -281,3 +287,202 @@ fn mq1_xmod_constraint_class_fixture_loads() {
|
||||
assert!(ws.modules.contains_key("mq1_xmod_constraint_class"));
|
||||
assert!(ws.modules.contains_key("mq1_xmod_constraint_class_dep"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// pd.2 relocations from `ailang-core/src/workspace.rs::tests` (the
|
||||
// post-shim-retirement batch). Each test below was a `load_workspace_with`
|
||||
// caller in the in-mod tests; post-pd.2 the shim is gone and these
|
||||
// tests now go through `ailang_surface::load_workspace` (which composes
|
||||
// `load_modules_with` + caller-side prelude inject + `build_workspace`).
|
||||
//
|
||||
// `tmp_dir` + `write_module` helpers are inlined per-test to keep each
|
||||
// test self-contained at integration-test scope.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
fn write_simple_module_json(dir: &Path, name: &str, imports: &[&str]) -> PathBuf {
|
||||
use std::fs;
|
||||
let imports_json: Vec<serde_json::Value> = imports
|
||||
.iter()
|
||||
.map(|m| serde_json::json!({ "module": m }))
|
||||
.collect();
|
||||
let module = serde_json::json!({
|
||||
"schema": "ailang/v0",
|
||||
"name": name,
|
||||
"imports": imports_json,
|
||||
"defs": [],
|
||||
});
|
||||
let path = dir.join(format!("{name}.ail.json"));
|
||||
fs::write(&path, serde_json::to_vec_pretty(&module).unwrap()).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
/// Iter 23.1 / pd.2 relocation: the loader auto-injects a module named
|
||||
/// "prelude", so a user module that also claims that name would collide
|
||||
/// silently. The collision is caught explicitly with `ReservedModuleName`.
|
||||
#[test]
|
||||
fn user_module_named_prelude_is_rejected() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let entry = write_simple_module_json(dir.path(), "prelude", &[]);
|
||||
|
||||
let err = load_workspace(&entry).expect_err("must reject user prelude");
|
||||
match err {
|
||||
WorkspaceLoadError::ReservedModuleName { name } => {
|
||||
assert_eq!(name, "prelude");
|
||||
}
|
||||
other => panic!("expected ReservedModuleName, got: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// pd.2 relocation: workspace import-cycle detection.
|
||||
#[test]
|
||||
fn detects_import_cycle() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write_simple_module_json(dir.path(), "a", &["b"]);
|
||||
write_simple_module_json(dir.path(), "b", &["a"]);
|
||||
let entry = dir.path().join("a.ail.json");
|
||||
|
||||
let err = load_workspace(&entry).expect_err("must error on cycle");
|
||||
match err {
|
||||
WorkspaceLoadError::Cycle { path } => {
|
||||
assert!(path.contains(&"a".to_string()));
|
||||
assert!(path.contains(&"b".to_string()));
|
||||
}
|
||||
other => panic!("expected Cycle, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// pd.2 relocation: a missing imported module yields a structured
|
||||
/// `ModuleNotFound` error naming the absent name + the path that was
|
||||
/// searched.
|
||||
#[test]
|
||||
fn module_not_found_yields_structured_error() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write_simple_module_json(dir.path(), "main", &["does_not_exist"]);
|
||||
let entry = dir.path().join("main.ail.json");
|
||||
|
||||
let err = load_workspace(&entry).expect_err("must error on missing module");
|
||||
match err {
|
||||
WorkspaceLoadError::ModuleNotFound { name, expected_path } => {
|
||||
assert_eq!(name, "does_not_exist");
|
||||
assert!(expected_path.ends_with("does_not_exist.ail.json"));
|
||||
}
|
||||
other => panic!("expected ModuleNotFound, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// pd.2 relocation: §C4 (a) carve-out fixture
|
||||
/// `test_22b2_kind_mismatch.ail.json` — class param appearing in
|
||||
/// applied position fires the canonical-form rejection
|
||||
/// (`BareCrossModuleTypeRef`).
|
||||
#[test]
|
||||
fn class_param_in_applied_position_fires_canonical_form_rejection() {
|
||||
let entry = examples_dir().join("test_22b2_kind_mismatch.ail.json");
|
||||
let err = load_workspace(&entry)
|
||||
.expect_err("must fire canonical-form rejection");
|
||||
match err {
|
||||
WorkspaceLoadError::BareCrossModuleTypeRef { module, name, .. } => {
|
||||
assert_eq!(module, "test_22b2_kind_mismatch");
|
||||
assert_eq!(name, "f");
|
||||
}
|
||||
other => panic!(
|
||||
"expected BareCrossModuleTypeRef (validator now fires first), got {other:?}",
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// pd.2 relocation: §C4 (a) carve-out fixture
|
||||
/// `test_22b2_invalid_superclass_param.ail.json` — `class Ord a extends
|
||||
/// Eq b` shape fires `InvalidSuperclassParam`.
|
||||
#[test]
|
||||
fn superclass_with_wrong_param_fires_invalid_superclass_param() {
|
||||
let entry = examples_dir().join("test_22b2_invalid_superclass_param.ail.json");
|
||||
let err = load_workspace(&entry)
|
||||
.expect_err("must fire invalid-superclass-param");
|
||||
match err {
|
||||
WorkspaceLoadError::InvalidSuperclassParam {
|
||||
class, superclass, expected_param, got_type,
|
||||
} => {
|
||||
assert_eq!(class, "Ord");
|
||||
assert_eq!(superclass, "Eq");
|
||||
assert_eq!(expected_param, "a");
|
||||
assert_eq!(got_type, "b");
|
||||
}
|
||||
other => panic!("expected InvalidSuperclassParam, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// pd.2 relocation: §C4 (a) carve-out fixture
|
||||
/// `test_22b2_unbound_constraint_var.ail.json` — class method
|
||||
/// `Type::Forall.constraints` referencing an unbound type var fires
|
||||
/// `UnboundConstraintTypeVar`.
|
||||
#[test]
|
||||
fn constraint_with_unbound_var_fires_unbound_constraint_type_var() {
|
||||
let entry = examples_dir().join("test_22b2_unbound_constraint_var.ail.json");
|
||||
let err = load_workspace(&entry)
|
||||
.expect_err("must fire constraint-references-unbound-type-var");
|
||||
match err {
|
||||
WorkspaceLoadError::UnboundConstraintTypeVar {
|
||||
class, method, var, ..
|
||||
} => {
|
||||
assert_eq!(class, "Foo");
|
||||
assert_eq!(method, "foo");
|
||||
assert_eq!(var, "z");
|
||||
}
|
||||
other => panic!("expected UnboundConstraintTypeVar, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// pd.2 relocation: §C4 (a) carve-out fixture
|
||||
/// `test_ct1_bare_xmod_rejected.ail.json` — bare `Ordering` Term::Ctor
|
||||
/// with no imports, validator catches it after prelude injection so the
|
||||
/// candidate list contains `prelude.Ordering`.
|
||||
#[test]
|
||||
fn ct1_fixture_bare_xmod_rejected() {
|
||||
let entry = examples_dir().join("test_ct1_bare_xmod_rejected.ail.json");
|
||||
let err = load_workspace(&entry).expect_err("must reject bare Ordering");
|
||||
match err {
|
||||
WorkspaceLoadError::BareCrossModuleTypeRef { module, name, candidates } => {
|
||||
assert_eq!(module, "test_ct1_bare_xmod_rejected");
|
||||
assert_eq!(name, "Ordering");
|
||||
assert_eq!(candidates, vec!["prelude.Ordering".to_string()]);
|
||||
}
|
||||
other => panic!("expected BareCrossModuleTypeRef, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// pd.2 relocation: §C4 (a) carve-out fixture
|
||||
/// `test_ct1_bad_qualifier.ail.json` — qualified `Mystery.Type` with
|
||||
/// `Mystery` not a known module fires `BadCrossModuleTypeRef`.
|
||||
#[test]
|
||||
fn ct1_fixture_bad_qualifier() {
|
||||
let entry = examples_dir().join("test_ct1_bad_qualifier.ail.json");
|
||||
let err = load_workspace(&entry).expect_err("must reject Mystery.Type");
|
||||
match err {
|
||||
WorkspaceLoadError::BadCrossModuleTypeRef { module, name } => {
|
||||
assert_eq!(module, "test_ct1_bad_qualifier");
|
||||
assert_eq!(name, "Mystery.Type");
|
||||
}
|
||||
other => panic!("expected BadCrossModuleTypeRef, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// pd.2 relocation: §C4 (a) carve-out fixture
|
||||
/// `test_ct1_qualified_class_rejected.ail.json` — declares
|
||||
/// `instance prelude.Eq Int` outside the prelude AND outside Int's
|
||||
/// defining module; post-mq.1 the qualified class-ref is schema-valid
|
||||
/// and the downstream coherence check rejects with `OrphanInstance`.
|
||||
#[test]
|
||||
fn ct1_fixture_qualified_class_orphan_post_mq1() {
|
||||
let entry = examples_dir().join("test_ct1_qualified_class_rejected.ail.json");
|
||||
let err = load_workspace(&entry).expect_err("must reject (now as Orphan)");
|
||||
match err {
|
||||
WorkspaceLoadError::OrphanInstance {
|
||||
class, type_repr, defining_module, ..
|
||||
} => {
|
||||
assert_eq!(class, "prelude.Eq");
|
||||
assert_eq!(type_repr, "Int");
|
||||
assert_eq!(defining_module, "test_ct1_qualified_class_rejected");
|
||||
}
|
||||
other => panic!("expected OrphanInstance, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -9,13 +9,39 @@
|
||||
//! These functions live in `ailang-surface` rather than
|
||||
//! `ailang-core` because `ailang-core` cannot import `ailang-surface`
|
||||
//! without creating a circular crate dependency. The cross-crate
|
||||
//! injection point is [`ailang_core::workspace::load_workspace_with`],
|
||||
//! introduced in iter ext-cli.1.
|
||||
//! injection point is [`ailang_core::workspace::load_modules_with`]
|
||||
//! (DFS-only loader) composed with [`ailang_core::workspace::build_workspace`]
|
||||
//! (validation + registry). Surface owns the prelude inject step
|
||||
//! between the two; the implicit-imports list `&["prelude"]` is
|
||||
//! supplied at the surface call site. Introduced in iter pd.2 (post-
|
||||
//! ext-cli.1's `load_workspace_with` shim retirement).
|
||||
|
||||
use ailang_core::ast::Module;
|
||||
use ailang_core::workspace::{load_workspace_with, Workspace, WorkspaceLoadError};
|
||||
use ailang_core::workspace::{Workspace, WorkspaceLoadError};
|
||||
use std::path::Path;
|
||||
|
||||
/// pd.2 (`prelude-decouple` milestone): the prelude bytes are embedded
|
||||
/// here at compile time. The single source of truth is
|
||||
/// `examples/prelude.ail` (Form A). Pre-pd.2, the embed lived in
|
||||
/// `ailang-core` and used `prelude.ail.json`; that path retired with
|
||||
/// the loader-split.
|
||||
pub const PRELUDE_AIL: &str = include_str!("../../../examples/prelude.ail");
|
||||
|
||||
/// pd.2: parse the embedded prelude bytes into a `Module`.
|
||||
///
|
||||
/// Panics on parse failure — the prelude is build-time-validated by
|
||||
/// every test run, so a parse failure here is a build-correctness
|
||||
/// bug, not a runtime concern.
|
||||
///
|
||||
/// Mirrors the semantics of the retired `ailang_core::workspace::load_prelude`
|
||||
/// (which deserialised `prelude.ail.json` via `serde_json::from_str`);
|
||||
/// equivalence between the two parse paths is pinned by
|
||||
/// `crates/ailang-surface/tests/prelude_module_hash_pin.rs`'s
|
||||
/// cross-form-identity preflight.
|
||||
pub fn parse_prelude() -> Module {
|
||||
crate::parse(PRELUDE_AIL).expect("examples/prelude.ail must parse as a Module")
|
||||
}
|
||||
|
||||
fn is_ail_source(path: &Path) -> bool {
|
||||
path.extension().and_then(|s| s.to_str()) == Some("ail")
|
||||
}
|
||||
@@ -67,5 +93,18 @@ pub fn load_module(path: &Path) -> Result<Module, WorkspaceLoadError> {
|
||||
/// `core::workspace::visit`. Mixed-extension workspaces (entry is
|
||||
/// `.ail`, some imports are `.ail.json`) are valid.
|
||||
pub fn load_workspace(entry: &Path) -> Result<Workspace, WorkspaceLoadError> {
|
||||
load_workspace_with(entry, load_module)
|
||||
let (entry_name, root_dir, mut modules) =
|
||||
ailang_core::workspace::load_modules_with(entry, load_module)?;
|
||||
if modules.contains_key("prelude") {
|
||||
return Err(WorkspaceLoadError::ReservedModuleName {
|
||||
name: "prelude".to_string(),
|
||||
});
|
||||
}
|
||||
modules.insert("prelude".to_string(), parse_prelude());
|
||||
ailang_core::workspace::build_workspace(
|
||||
entry_name,
|
||||
root_dir,
|
||||
modules,
|
||||
&["prelude"],
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
//! pd.2 (`prelude-decouple` milestone): pin that
|
||||
//! `surface::load_workspace` injects the prelude into every workspace
|
||||
//! and rejects user modules trying to take the reserved name.
|
||||
|
||||
use ailang_core::workspace::WorkspaceLoadError;
|
||||
use ailang_surface::load_workspace;
|
||||
use std::path::Path;
|
||||
|
||||
#[test]
|
||||
fn surface_load_workspace_injects_prelude_into_every_workspace() {
|
||||
// Use any existing single-module example from `examples/`. The hello
|
||||
// example is a stable choice (used by other surface tests). Post-
|
||||
// form-a.1 the canonical sidecar is `.ail` (Form A); the `.ail.json`
|
||||
// for `hello` was deleted by form-a.1 T8.
|
||||
let entry = Path::new("../../examples/hello.ail");
|
||||
let ws = load_workspace(entry).expect("load");
|
||||
assert!(
|
||||
ws.modules.contains_key("prelude"),
|
||||
"prelude must appear in every workspace post-pd.2"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn surface_load_workspace_rejects_user_module_named_prelude() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
// Create a JSON-form fixture for the user-side "prelude" module.
|
||||
// Surface's loader should fail with ReservedModuleName.
|
||||
let prelude_path = dir.path().join("prelude.ail.json");
|
||||
std::fs::write(
|
||||
&prelude_path,
|
||||
r#"{"schema":"ailang/v0","name":"prelude","imports":[],"defs":[]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
// Single-file workspace where the entry IS named "prelude" trips
|
||||
// the reservation.
|
||||
let err = load_workspace(&prelude_path).expect_err("must reject");
|
||||
match err {
|
||||
WorkspaceLoadError::ReservedModuleName { name } => {
|
||||
assert_eq!(name, "prelude");
|
||||
}
|
||||
other => panic!("expected ReservedModuleName, got: {other:?}"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
//! pd.2 (`prelude-decouple` milestone): pin the canonical identity of
|
||||
//! the parsed prelude.
|
||||
//!
|
||||
//! Two tests:
|
||||
//! 1. `prelude_ail_and_json_parse_to_identical_module` — cross-form-
|
||||
//! identity preflight. Asserts that parsing `examples/prelude.ail`
|
||||
//! via `ailang_surface::parse` produces a `Module` with the same
|
||||
//! `module_hash` as deserialising `examples/prelude.ail.json` via
|
||||
//! `serde_json::from_str`. This is the load-bearing assumption
|
||||
//! that justifies pd.3's deletion of the JSON file. pd.3 drops
|
||||
//! this test along with the JSON.
|
||||
//! 2. `prelude_parse_yields_canonical_hash` — long-term anchor.
|
||||
//! Asserts the literal hex hash of the parsed Module. Updates
|
||||
//! intentionally on prelude-content changes (with a JOURNAL entry
|
||||
//! naming why); accidental drift trips immediately.
|
||||
|
||||
use ailang_core::workspace::module_hash;
|
||||
use ailang_surface::{parse_prelude, PRELUDE_AIL};
|
||||
|
||||
const PRELUDE_JSON: &str = include_str!("../../../examples/prelude.ail.json");
|
||||
|
||||
#[test]
|
||||
fn prelude_ail_and_json_parse_to_identical_module() {
|
||||
// Form A path: parse prelude.ail via ailang_surface::parse.
|
||||
let from_ail = parse_prelude();
|
||||
|
||||
// Form B path: deserialise prelude.ail.json via serde_json (the
|
||||
// pre-pd.2 path; matches what `ailang_core::workspace::load_prelude`
|
||||
// did before retirement).
|
||||
let from_json: ailang_core::ast::Module = serde_json::from_str(PRELUDE_JSON)
|
||||
.expect("examples/prelude.ail.json must deserialise");
|
||||
|
||||
let h_ail = module_hash(&from_ail);
|
||||
let h_json = module_hash(&from_json);
|
||||
|
||||
assert_eq!(
|
||||
h_ail, h_json,
|
||||
"cross-form-identity preflight failed:\n parse(prelude.ail) hash: {}\n deserialize(prelude.ail.json) hash: {}\n\
|
||||
The two parse paths produce structurally different `Module` values for the same prelude content. \
|
||||
pd.3 must NOT delete prelude.ail.json until this is investigated.",
|
||||
h_ail, h_json,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prelude_parse_yields_canonical_hash() {
|
||||
let m = parse_prelude();
|
||||
let h = module_hash(&m);
|
||||
// The expected hex is captured at pd.2-iter close (Step 5 of this task).
|
||||
// It updates intentionally on prelude-content changes; record the why
|
||||
// in the per-iter journal entry.
|
||||
assert_eq!(
|
||||
h, "3abe0d3fa3c11c99", // captured at pd.2 iter close
|
||||
"prelude module hash drifted; if intentional, capture the new \
|
||||
hex below + record the why in the per-iter journal."
|
||||
);
|
||||
}
|
||||
|
||||
// PRELUDE_AIL re-exported for any future test that wants the raw bytes.
|
||||
const _: &str = PRELUDE_AIL;
|
||||
@@ -0,0 +1,25 @@
|
||||
//! pd.2 (`prelude-decouple` milestone): pin that the surface-side
|
||||
//! `parse_prelude` succeeds against the in-tree `examples/prelude.ail`
|
||||
//! and returns a `Module` with the expected shape. Guards against
|
||||
//! `prelude.ail` being silently emptied or `parse_prelude` regressing.
|
||||
//!
|
||||
//! The hash-stability + cross-form-identity pins live in
|
||||
//! `prelude_module_hash_pin.rs`.
|
||||
|
||||
use ailang_surface::parse_prelude;
|
||||
|
||||
#[test]
|
||||
fn parse_prelude_returns_module_named_prelude_with_min_defs() {
|
||||
let m = parse_prelude();
|
||||
assert_eq!(m.name, "prelude");
|
||||
// Conservative lower bound: the prelude has shipped four classes
|
||||
// (Eq, Ord, Show + the Ordering data def) plus their primitive
|
||||
// instances by milestone 24. A future content addition only
|
||||
// raises the count; an accidental emptying (or a renamed root)
|
||||
// trips this immediately.
|
||||
assert!(
|
||||
m.defs.len() >= 8,
|
||||
"expected >= 8 defs in prelude, got {}",
|
||||
m.defs.len()
|
||||
);
|
||||
}
|
||||
@@ -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`
|
||||
Reference in New Issue
Block a user