Files
AILang/docs/plans/0065-iter-pd.2.md
T
Brummel 832375f2ac convention: counter-prefix file naming across docs/specs/, docs/plans/, design/contracts/, design/models/
All 176 files in the four accumulating directories now use a
zero-padded 4-digit counter prefix that reflects creation order
(`NNNN-slug.md`). The counter is assigned per directory in strict
git-log creation order; ties broken alphabetically by original name.
The old `YYYY-MM-DD-` prefix on docs/specs/ and docs/plans/ files is
dropped — the date is recoverable from git log and the counter
carries the ordering.

A file's counter is stable for the life of the file: never reassigned,
never reused, never compacted. Deleted files retire their counter;
subsequent files do not fill the gap. This is the property that lets
cross-references stay literal — refs use the full filename including
the counter (`design/contracts/0007-honesty-rule.md`) so they grep
cleanly and resolve directly without a glob step.

313 cross-references updated across .md/.rs/.toml/.c/.json files
(test pins, include_str! paths, design-INDEX entries, baseline notes,
runtime C comments, inter-contract markdown links incl. bare basename
and `../models/foo.md` forms).

CLAUDE.md gets a new "File-naming convention" section spelling out
the rule and rationale. skills/brainstorm/SKILL.md and
skills/planner/SKILL.md updated so new spec/plan creation produces
counter-prefixed names from the start.

The full test suite (cargo test --workspace) passes.
2026-05-28 13:31:31 +02:00

30 KiB

pd.2 — surface assumes prelude ownership; core shim retired — Implementation Plan

Parent spec: docs/specs/0028-prelude-decouple.md

For agentic workers: REQUIRED SUB-SKILL: use skills/implement to run this plan. Steps use - [ ] checkboxes for tracking.

Goal: Move the prelude embed (PRELUDE_JSON const + load_prelude fn) and the prelude-injection step (with the ReservedModuleName reservation) out of ailang-core into ailang-surface. Rewrite surface::load_workspace to call core::load_modules_with + caller-side prelude inject + core::build_workspace directly. Retire the pd.1 compatibility shim core::load_workspace_with along with load_one and the load_module test-only import. Install the cross-form-identity preflight test BEFORE the JSON file gets deleted in pd.3, so the embed-source swap is justified by an active green pin and not by hope.

Architecture: post-pd.2 the prelude lives entirely in surface: the bytes come from examples/prelude.ail via include_str!, parsing goes through ailang_surface::parse, the reserved-name check (ReservedModuleName) fires from surface, the implicit-imports list &["prelude"] is supplied at the surface call site. Core's src/ retains zero literal-"prelude" strings; the WorkspaceLoadError::ReservedModuleName variant declaration stays in core's enum (generic shape, no literal in the name) so downstream match-arms (notably crates/ail/src/main.rs:1220-1229) keep working.

Tech Stack: ailang-surface (loader.rs + new tests/), ailang-core (workspace.rs deletions + lib.rs rustdoc updates + test-mod re-migration). No ail CLI changes. No codegen / check changes.


Files this plan creates or modifies:

  • Create: crates/ailang-surface/tests/prelude_parse_pin.rs — pins parse_prelude() succeeds; module name is "prelude"; def-count meets a minimum bound (guards against empty prelude).
  • Create: crates/ailang-surface/tests/prelude_module_hash_pin.rs — two #[test] fns: (a) prelude_parse_yields_canonical_hash pins module_hash(parse_prelude()) against a literal hex captured at iter close; (b) prelude_ail_and_json_parse_to_identical_module is the cross-form-identity preflight that asserts module_hash(serde_json::from_str(prelude.ail.json bytes)) == module_hash(parse(prelude.ail bytes)). Both are green at pd.2 close; pd.3 drops the (b) fn along with the JSON file.
  • Modify: crates/ailang-surface/src/loader.rs:1-71 — add PRELUDE_AIL const + parse_prelude fn; rewrite load_workspace body from the current 3-line load_workspace_with(entry, load_module) shim-call to the 7-line composition (load_modules_with → reserved-name check → inject parse_prelude → build_workspace(&["prelude"])). Update import line at line 16 (load_workspace_with swaps to load_modules_with, build_workspace). Update module rustdoc at lines 9-13 (the load_workspace_with cross-reference is stale).
  • Modify: crates/ailang-core/src/workspace.rs — delete PRELUDE_JSON const (~lines 428-430), load_prelude fn (~lines 432-439), load_workspace_with shim (~lines 525-561 including the doc-comment at 525-540). Re-migrate 10 in-mod test-fns from load_workspace_with(&entry, load_one) to surface::load_workspace, re-migrate 1 in-mod test from load_workspace_with to load_modules_with directly with an inline-closure loader, delete 1 in-mod test (load_workspace_with_shim_preserves_today_semantics — subject gone). Delete the now-orphaned #[cfg(test)] fn load_one and the #[cfg(test)] use crate::load_module; import. Update the module rustdoc at lines 1-32 (replaces pd.1's "single core entry point" framing with "core exposes the two-phase composition; surface owns the public entry point").
  • Modify: crates/ailang-core/src/lib.rs — three rustdoc sites that reference workspace::load_workspace_with (lines 16-21, 50-51, 120 per recon) update to point at workspace::load_modules_with + workspace::build_workspace, with intra-doc cross-references to ailang_surface::load_workspace for the public composition. The pub use workspace::{module_hash, Workspace, WorkspaceLoadError}; re-export at line 73 is unchanged.

Task 1: Add PRELUDE_AIL const + parse_prelude fn to surface

Files:

  • Modify: crates/ailang-surface/src/loader.rs — add const + fn near the top of the file, above the existing is_ail_source helper.

  • Step 1: Write the failing test

Add a new file crates/ailang-surface/tests/prelude_parse_pin.rs. The test asserts parse_prelude() is callable from outside the crate (integration-test reach), succeeds, returns a Module named "prelude", and has a minimum number of defs.

//! 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()
    );
}
  • Step 2: Run test to verify it fails

Run: cargo test -p ailang-surface --test prelude_parse_pin Expected: FAIL with "cannot find function parse_prelude in module ailang_surface" (and "no item named parse_prelude").

  • Step 3: Add PRELUDE_AIL const + parse_prelude fn to loader.rs

Edit crates/ailang-surface/src/loader.rs. Add immediately above the existing fn is_ail_source (line 19 area):

/// 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() -> ailang_core::ast::Module {
    crate::parse(PRELUDE_AIL).expect("examples/prelude.ail must parse as a Module")
}

pub is required for parse_prelude because the integration test (and later, any out-of-crate consumer) needs to call it. PRELUDE_AIL is also pub for the cross-form-identity preflight test to reach the same bytes.

  • Step 4: Run test to verify it passes

Run: cargo test -p ailang-surface --test prelude_parse_pin Expected: PASS.

  • Step 5: Run the surface crate test suite

Run: cargo test -p ailang-surface Expected: all tests green; the new parse_prelude is additive.


Task 2: Write the cross-form-identity preflight + module-hash pin

Files:

  • Create: crates/ailang-surface/tests/prelude_module_hash_pin.rs — two #[test] fns (cross-form-identity + hash pin).

This task captures the load-bearing assumption that swapping the embed source from prelude.ail.json to prelude.ail does not change the canonical Module. Both files are still present at pd.2-time, so the preflight can compare them directly. pd.3 drops the JSON-side fn along with the JSON file.

  • Step 1: Write the failing tests

Create crates/ailang-surface/tests/prelude_module_hash_pin.rs:

//! 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, "PLACEHOLDER_TO_BE_REPLACED_AT_STEP_5",
        "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;

NOTE: the literal hex in prelude_parse_yields_canonical_hash is a placeholder that gets replaced at Step 5 of this task once the test is green. The cross-form-identity test does NOT contain a literal — it asserts equality between two computed values, so it doesn't need a captured hex.

  • Step 2: Run tests to observe failure modes

Run: cargo test -p ailang-surface --test prelude_module_hash_pin Expected:

  • prelude_ail_and_json_parse_to_identical_module may PASS or FAIL depending on whether the two parse paths actually agree today. If it PASSES, we are clear to ship. If it FAILS, STOP and report — pd.3 cannot proceed; the spec's load-bearing assumption is violated and the milestone needs a brainstorm-level rethink.

  • prelude_parse_yields_canonical_hash FAILS with the placeholder comparison (placeholder != real hash).

  • Step 3: If the cross-form-identity test PASSED in Step 2, capture the canonical hex hash

Read the failure message of prelude_parse_yields_canonical_hash to get the actual computed hash; the assert prints both expected and actual. Record the actual hex string.

If the test message is unclear, run a one-shot helper:

cargo test -p ailang-surface --test prelude_module_hash_pin prelude_parse_yields_canonical_hash -- --nocapture 2>&1 | grep -E '^( *left:| *right:)'

The "right" line carries the actual computed hash.

  • Step 4: If the cross-form-identity test FAILED in Step 2, STOP

Do NOT proceed with Task 3+. Write a per-iter journal entry under docs/journals/2026-05-14-iter-pd.2.md capturing:

  • The two computed hashes that disagree.
  • Which Module fields differ (run a manual diff via reading both Modules into Rust and pretty-printing — or use git grep on the relevant serde_json::from_str Display output to localise the divergence).
  • A statement that the milestone is BLOCKED pending a brainstorm-level decision on whether to retire the JSON or keep it.

This branch is unlikely (the round-trip CI through form-a.1 has been exercising parse-then-print idempotency on every .ail fixture), but the test exists precisely to catch the case where the assumption fails.

  • Step 5: Replace the placeholder hex with the real hash

Edit prelude_module_hash_pin.rs:

    assert_eq!(
        h, "<actual hex from Step 3>",  // captured at pd.2 iter close
        ...
    );
  • Step 6: Re-run tests, expect both green

Run: cargo test -p ailang-surface --test prelude_module_hash_pin Expected: BOTH tests PASS.


Task 3: Rewrite surface::load_workspace body

Files:

  • Modify: crates/ailang-surface/src/loader.rs:69-71 — replace the 3-line shim-call with the 7-line composition.

  • Step 1: Write the regression-pin test for the new composition

Surface's existing tests/ already contain end-to-end tests of load_workspace. Add ONE new test specifically pinning that a fresh workspace contains the prelude module, and that a user module trying to take the name prelude is rejected with ReservedModuleName.

Append to an appropriate existing test file in crates/ailang-surface/tests/, or create a new file crates/ailang-surface/tests/prelude_injection_pin.rs:

//! 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).
    let entry = Path::new("../../examples/hello.ail.json");
    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#"{"name":"prelude","imports":[],"defs":[]}"#,
    )
    .unwrap();
    // Entry is a separate module named "main" that imports the user's
    // prelude — actually, since prelude is implicit, just point the
    // entry at prelude itself: surface checks the entry-module name
    // against the file stem first, so a 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:?}"),
    }
}

If a similar test already exists in crates/ailang-surface/tests/, extend it instead of duplicating. The recon did not enumerate the existing surface test files; check with ls crates/ailang-surface/tests/ before creating.

  • Step 2: Run the new tests against today's implementation (still going through the shim)

Run: cargo test -p ailang-surface --test prelude_injection_pin Expected: BOTH tests PASS — the shim is still wired today and behaves correctly. This is the baseline before the rewrite.

  • Step 3: Rewrite surface::load_workspace

Edit crates/ailang-surface/src/loader.rs. The current body at lines 69-71:

pub fn load_workspace(entry: &Path) -> Result<Workspace, WorkspaceLoadError> {
    load_workspace_with(entry, load_module)
}

becomes:

pub fn load_workspace(entry: &Path) -> Result<Workspace, WorkspaceLoadError> {
    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"],
    )
}

Update the imports at line 16 from:

use ailang_core::workspace::{load_workspace_with, Workspace, WorkspaceLoadError};

to:

use ailang_core::workspace::{Workspace, WorkspaceLoadError};

(The load_modules_with and build_workspace calls use the fully- qualified path ailang_core::workspace::* for clarity at the call site — the body is the only consumer in this file.)

Update the module rustdoc at lines 9-13:

//! 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_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).
  • Step 4: Run the regression-pin tests

Run: cargo test -p ailang-surface --test prelude_injection_pin Expected: BOTH tests still PASS after the rewrite (semantics preserved).

  • Step 5: Run the full surface crate test suite

Run: cargo test -p ailang-surface Expected: all tests green. The rewrite is semantically invariant — the shim and the new composition produce identical results.


Task 4: Re-migrate the 11 in-mod core tests + delete 1

Files:

  • Modify: crates/ailang-core/src/workspace.rs's #[cfg(test)] mod tests block — re-migrate 10 tests to surface::load_workspace, re-migrate 1 test to load_modules_with directly, delete 1 test.

The shim disappears in Task 5; the in-mod test callers must already have moved off it before the deletion.

  • Step 1: Re-migrate the 9 fixture-style tests to surface::load_workspace

For each of the following 9 tests (lines per recon), replace load_workspace_with(&entry, load_one) with ailang_surface::load_workspace(&entry):

Line Test fn
1693 detects_import_cycle
1709 module_not_found_yields_structured_error
1791 class_param_in_applied_position_fires_canonical_form_rejection
1814 superclass_with_wrong_param_fires_invalid_superclass_param
1854 constraint_with_unbound_var_fires_unbound_constraint_type_var
2491 ct1_fixture_bare_xmod_rejected
2507 ct1_fixture_bad_qualifier
2530 ct1_fixture_qualified_class_orphan_post_mq1

Plus the reservation test at line 1675 (user_module_named_prelude_is_rejected) — its assertion is unchanged because the variant tag preserves; only the call site changes.

Add at the top of the mod tests block (or near other use lines):

#[cfg(test)]
use ailang_surface::load_workspace as surface_load_workspace;

(Aliasing because load_workspace no longer exists in core::workspace and an unaliased import would shadow nothing useful but reduces clarity about which crate is being called.)

For each migration, the literal change pattern is:

Old:

let result = load_workspace_with(&entry, load_one);

New:

let result = surface_load_workspace(&entry);

The result type and assertion logic are unchanged.

  • Step 2: Run tests after migration

Run: cargo test -p ailang-core Expected: all tests green. The 9 + 1 migrated tests still PASS because surface::load_workspace's behaviour is identical to the shim's by Task 3 Step 4.

  • Step 3: Re-migrate load_workspace_with_custom_loader_is_called to load_modules_with directly

Test at line 2559. Its purpose: verify the loader-injection contract (custom Fn(&Path) -> Result<Module, _> is invoked). The contract post-pd.2 belongs to load_modules_with's loader parameter (not the disappearing load_workspace_with). Migrate by:

Old shape (approximate — verify actual at the line):

let called = std::cell::Cell::new(false);
let custom = |p: &Path| {
    called.set(true);
    load_one(p)
};
let _ = load_workspace_with(&entry, custom);
assert!(called.get());

New shape:

let called = std::cell::Cell::new(false);
let custom = |p: &Path| {
    called.set(true);
    crate::load_module(p).map_err(|e| match e {
        crate::Error::Io(io) => WorkspaceLoadError::Io {
            path: p.to_path_buf(),
            source: io,
        },
        other => WorkspaceLoadError::Schema {
            path: p.to_path_buf(),
            source: other,
        },
    })
};
let _ = load_modules_with(&entry, &custom);
assert!(called.get());

The inline closure replaces load_one; the closure body wraps crate::load_module (the JSON-only single-module loader, still public in core) and adapts its error type.

Test renames from load_workspace_with_custom_loader_is_called to load_modules_with_custom_loader_is_called and the assertion changes target accordingly. Same semantic intent (loader-injection contract is honoured).

  • Step 4: Delete load_workspace_with_shim_preserves_today_semantics

Test at line 2926. Its subject (the pd.1 shim) is being deleted in Task 5. The test has no remaining purpose; delete it outright.

If the test is bracketed by // --- separators or similar comment markers, delete the markers too.

  • Step 5: Run tests after both migrations + deletion

Run: cargo test -p ailang-core Expected: all tests green. 11 in-mod test callers either moved or removed; the rest of the test suite is unaffected.


Task 5: Delete PRELUDE_JSON, load_prelude, load_workspace_with, load_one, load_module test-only import

Files:

  • Modify: crates/ailang-core/src/workspace.rs — five deletions (constant, fn, fn, fn, import).

After Tasks 1-4, the only consumers of these symbols are gone. This task is the destructive cleanup.

  • Step 1: Verify no remaining callers

Run each of these greps; each must return nothing:

# PRELUDE_JSON in production:
git grep -n 'PRELUDE_JSON' crates/ailang-core/src/

# load_prelude callers (production):
git grep -n 'load_prelude' crates/ailang-core/src/

# load_workspace_with callers (production OR test):
git grep -n 'load_workspace_with' --

# load_one callers:
git grep -n 'load_one' crates/ailang-core/

# crate::load_module test-only import (the one gated by cfg(test) in
# pd.1.5):
git grep -n '#\[cfg(test)\]' crates/ailang-core/src/workspace.rs | head -20

If any production hit appears for the first three (excluding the definition site itself in crates/ailang-core/src/workspace.rs), STOP — Task 4 Step 1-2 missed a callsite, find it, migrate, re-run the grep.

  • Step 2: Delete PRELUDE_JSON constant

Delete the const declaration block in crates/ailang-core/src/workspace.rs (post-pd.1 location ~lines 425-430 — verify with grep). Includes the doc-comment paragraph immediately above.

  • Step 3: Delete load_prelude fn

Delete the fn block (post-pd.1 location ~lines 432-439 — verify with grep). Includes the doc-comment paragraph immediately above.

  • Step 4: Delete load_workspace_with shim fn

Delete the entire fn block (post-pd.1 location ~lines 525-561 — verify with grep). Includes the doc-comment block at lines 525-540 (the pd.1 shim documentation).

  • Step 5: Delete the now-orphaned load_one fn

Find #[cfg(test)] fn load_one in workspace.rs (~line 1598-1611 per recon). Delete the entire fn including its #[cfg(test)] attribute and any preceding doc-comment.

  • Step 6: Delete the now-orphaned crate::load_module test-import

Find the #[cfg(test)] use crate::load_module; line at the top of workspace.rs (added in pd.1.5). Delete it — it has no remaining test consumer because the migrations in Task 4 Step 3 use crate::load_module via fully-qualified path inside the test body, not via top-level import.

If a test added in Task 4 Step 3 still uses an unqualified load_module, either re-add the import (gated #[cfg(test)]) or change the test to fully-qualified crate::load_module. Prefer the latter for locality.

  • Step 7: Run the full workspace test suite

Run: cargo test --workspace Expected: all tests green. If a compile error surfaces a missed callsite, find it via the error's file:line, re-migrate.

  • Step 8: Verify zero literal-"prelude" strings in core/src/

Run: git grep '"prelude"' crates/ailang-core/src/ Expected: NO matches. (The variant declaration at line 321-326 uses no literal because the name field is String — it carries no hardcoded string.)

If any match appears, identify what it is and decide:

  • Test fixture string (in mod tests) — fine to keep, it's not production code; verify it's inside #[cfg(test)].

  • Production-code reference — STOP and find why; this violates the milestone's primary acceptance criterion.

  • Step 9: Update core's module rustdoc

Edit crates/ailang-core/src/workspace.rs:1-32. The pd.1 framing ("the pd.1 single core entry point is load_workspace_with") is now stale. Replace with:

//! Workspace loading and per-workspace validation.
//!
//! Two phases, exposed as separate public fns so callers can compose
//! them around an injection point (e.g. surface's prelude inject):
//!
//! - [`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.
//!
//! 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.
//!
//! 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`.
//!
//! [`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.
  • Step 10: Update lib.rs rustdoc references

Edit crates/ailang-core/src/lib.rs. Three sites (per recon):

  • Lines 16-21 (module rustdoc preamble) — same shift in framing as workspace.rs above. Reword to name the two-phase composition and point at ailang_surface::load_workspace for the public entry.
  • Lines 50-51 (further module rustdoc) — replace workspace::load_workspace_with reference with the appropriate successor (load_modules_with for "transitive loader" semantics or build_workspace for "workspace assembly" — read the surrounding paragraph to pick).
  • Line 120 (doc-comment on pub fn load_module) — the for the transitive closure use workspace::load_workspace_with hint becomes for the transitive closure use [ailang_surface::load_workspace] (which composes workspace::load_modules_with+ a prelude inject +workspace::build_workspace).

These are mechanical reword edits; no semantic change.

  • Step 11: Re-run cargo test --workspace + cargo doc

Run: cargo test --workspace && cargo doc --no-deps -p ailang-core -p ailang-surface 2>&1 | grep -E '(warning|error)' | head -20 Expected: tests green; doc warnings either nil or unchanged from the pre-pd.2 baseline (16 pre-existing warnings per the roadmap; pd.2 should not add new ones).


Task 6: Bench regressions + final acceptance

Files: none directly.

  • Step 1: Run regression baselines

Run: python bench/check.py && python bench/compile_check.py && python bench/cross_lang.py Expected: all three exit 0. pd.2 changes are inside the workspace-loader pipeline only; no codegen, no runtime, no typecheck-result deltas.

If any baseline regresses, investigate before declaring DONE — the shape of the change should not move bench numbers.

  • Step 2: Confirm pd.2 acceptance

Per the parent spec (§Acceptance criteria), pd.2's portion is:

  • Item 3 (git grep '"prelude"' crates/ailang-core/src/ returns no matches) — verified at Task 5 Step 8.
  • Item 4 (cargo test --workspace green, including the new pins) — verified at Task 5 Step 7 + Task 6 Step 1.
  • Item 5 (bench baselines green) — verified at Task 6 Step 1.

Items 1, 2, 6, 7 belong to pd.3 (delete prelude.ail.json, the include_str! audit, the carve_out_inventory + form-a §C4 (b) spec edits, and the CLAUDE.md / DESIGN.md spot-check).


Out of scope for pd.2

The following spec items are deferred to pd.3 and MUST NOT be touched in pd.2:

  • Deleting examples/prelude.ail.json. The cross-form-identity preflight test in Task 2 explicitly depends on the JSON file being present.
  • Deleting the prelude_ail_and_json_parse_to_identical_module test fn from prelude_module_hash_pin.rs (pd.3 work, after the JSON file is gone).
  • Deleting the defensive include_str! of prelude.ail.json in crates/ail/src/main.rs:472-484 (pd.3 work).
  • Updating crates/ailang-core/tests/carve_out_inventory.rs to reflect §C4 (b) becoming empty (pd.3 work).
  • Updating docs/specs/0025-form-a-default-authoring.md §C4 (b) (pd.3 work).
  • Creating prelude_decouple_carve_out_pin.rs (pd.3 work — asserts the JSON file does not exist; pd.2-time the file IS still present).

A pd.2 implementer about to edit any of the above MUST stop and re-read the parent spec's iteration sketch.