plan: ext-cli.1 — CLI accepts .ail extension, 4 tasks
Boss-direct plan (no parent spec — this is a P2-roadmap-todo, not a
milestone iter). Option 1 picked from the roadmap fork: auto-parse
.ail internally so `ail check foo.ail` works the same as
`ail check foo.ail.json` (modulo the surface-parse step). Option 2
(hint-only) was a workaround that would have forced LLM-authors to
prepend `ail parse` before every check/build/run.
Architecture: extension dispatch lives in ailang-surface (which
already depends on core); core gains a `load_workspace_with` injection
point so the dispatch reaches transitive imports; surface parse
errors get a new WorkspaceLoadError::SurfaceParse variant so
`workspace_error_to_diagnostic` can route them in --json mode.
Tasks:
1. core::load_workspace_with + WorkspaceLoadError::SurfaceParse
+ .ail-first import resolution
2. surface::load_module + surface::load_workspace
3. crates/ail/src/main.rs rewire (~17 callsites) + 2 e2e tests
4. workspace_error_to_diagnostic SurfaceParse arm + DESIGN.md
§Decision-6 CLI addendum + ct1_check_cli diagnostic test
This commit is contained in:
@@ -0,0 +1,819 @@
|
||||
# ext-cli.1 — CLI accepts `.ail` extension — Implementation Plan
|
||||
|
||||
> **Parent spec:** none. This iteration executes the P2-todo at
|
||||
> `docs/roadmap.md:185-194`. The todo is one of the
|
||||
> roadmap-convention `[todo]` entries — "concrete task that can run
|
||||
> without a brainstorm".
|
||||
>
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: use `skills/implement`
|
||||
> to run this plan. Steps use `- [ ]` checkboxes for tracking.
|
||||
|
||||
**Goal:** After the today-morning `.ailx → .ail` extension rename,
|
||||
`.ail` is the canonical LLM-author Form A but the CLI still rejects
|
||||
`.ail` paths with a misleading JSON-parse error. Teach every
|
||||
path-taking CLI subcommand to also accept `.ail` source files, by
|
||||
in-place-parsing them through the surface crate, so `ail check
|
||||
foo.ail` works the same as `ail check foo.ail.json` (modulo the
|
||||
surface-parse step). `.ail.json` keeps working unchanged.
|
||||
|
||||
**Architecture:** A new pair of public functions in
|
||||
`ailang-surface` — `load_module` and `load_workspace` — dispatch on
|
||||
file extension. For `.ail.json` they delegate to the existing
|
||||
`ailang_core` loaders. For `.ail` they read the source text, parse
|
||||
through `ailang_surface::parse`, and hand the resulting
|
||||
`ailang_core::ast::Module` back to the caller. Workspace import
|
||||
resolution gains a `.ail`-first / `.ail.json`-fallback search.
|
||||
`ailang-core` cannot import surface (would cycle), so the
|
||||
extension dispatch is injected via a new
|
||||
`core::load_workspace_with` entry point that takes a module-loader
|
||||
closure; `core::load_workspace` becomes a thin wrapper passing the
|
||||
JSON-only loader. All `crates/ail/src/main.rs` callsites that today
|
||||
call `ailang_core::load_module` / `load_workspace` switch to
|
||||
`ailang_surface::load_module` / `load_workspace`. A new
|
||||
`WorkspaceLoadError::SurfaceParse { path, message }` variant routes
|
||||
surface parse failures through the existing
|
||||
`workspace_error_to_diagnostic` channel so `ail check --json
|
||||
foo.ail` produces a structured diagnostic rather than crashing.
|
||||
DESIGN.md §Decision-6 "CLI" gets a one-paragraph addendum noting
|
||||
that `.ail` is now first-class input to every existing subcommand.
|
||||
|
||||
**Tech Stack:** Rust workspace crates `ailang-core`,
|
||||
`ailang-surface`, binary crate `ail`. No new external deps.
|
||||
|
||||
---
|
||||
|
||||
## Files this plan creates or modifies
|
||||
|
||||
- **Modify:** `crates/ailang-core/src/workspace.rs:140-388` — add
|
||||
new `WorkspaceLoadError::SurfaceParse { path: PathBuf, message:
|
||||
String }` variant; the `String` body keeps the variant from
|
||||
pulling `ailang_surface::ParseError` into core (would create a
|
||||
cycle).
|
||||
- **Modify:** `crates/ailang-core/src/workspace.rs:411-478` —
|
||||
refactor `load_workspace` into a thin wrapper around a new
|
||||
`pub fn load_workspace_with<F>(entry_path: &Path, loader: F)
|
||||
-> Result<Workspace, WorkspaceLoadError>` where
|
||||
`F: Fn(&Path) -> Result<Module, WorkspaceLoadError> + Copy`.
|
||||
The body of the current `load_workspace` moves into
|
||||
`load_workspace_with`, with `load_one(...)` calls replaced by
|
||||
`loader(...)`. The thin wrapper `pub fn load_workspace(entry:
|
||||
&Path) -> Result<...>` calls `load_workspace_with(entry,
|
||||
load_one)` so existing `core::load_workspace` callers see no
|
||||
signature change.
|
||||
- **Modify:** `crates/ailang-core/src/workspace.rs:1378-1420` —
|
||||
inside `visit`, the import-path construction at line 1381 tries
|
||||
`<module>.ail` first, falls back to `<module>.ail.json`. The
|
||||
`loader` closure (passed through from `load_workspace_with`) is
|
||||
responsible for parsing whichever extension wins. `visit`'s
|
||||
signature gains the loader closure as a parameter.
|
||||
- **Modify:** `crates/ailang-surface/src/lib.rs:34-39` — re-export
|
||||
the two new functions alongside `parse`, `print`, etc.
|
||||
- **Create:** `crates/ailang-surface/src/loader.rs` — new module
|
||||
with `pub fn load_module(path: &Path) -> Result<Module,
|
||||
WorkspaceLoadError>` and `pub fn load_workspace(entry: &Path)
|
||||
-> Result<Workspace, WorkspaceLoadError>`. Module names
|
||||
match the existing `ailang-surface::parse`/`print` pattern.
|
||||
- **Modify:** `crates/ailang-surface/src/lib.rs:1-40` — add
|
||||
`mod loader;` and the two `pub use loader::{load_module,
|
||||
load_workspace};` re-exports.
|
||||
- **Modify:** `crates/ail/src/main.rs:332-1009` — every callsite
|
||||
that today says `ailang_core::load_module(&path)` or
|
||||
`ailang_core::load_workspace(&path)` switches to
|
||||
`ailang_surface::load_module(&path)` /
|
||||
`ailang_surface::load_workspace(&path)`. Sites enumerated by the
|
||||
plan-recon: 335 (Manifest workspace), 397 (Manifest single), 425
|
||||
(Render), 432 (Prose), 442 (MergeProse), 530 (Describe
|
||||
workspace), 561 (Describe single), 596 + 611 (Check), 651
|
||||
(EmitIr), 746 + 747 (Diff workspace), 759 + 760 (Diff single),
|
||||
774 (Workspace), 855 (Deps workspace), 981 (Deps single), 2193
|
||||
(build_to inside Build + Run). Total: ~17 callsites.
|
||||
- **Modify:** `crates/ail/src/main.rs:1018-1106`
|
||||
(`workspace_error_to_diagnostic`) — add an arm for the new
|
||||
`WorkspaceLoadError::SurfaceParse { path, message }` variant
|
||||
that produces a structured diagnostic with code
|
||||
`surface_parse_error`. Path and message fields go in the
|
||||
diagnostic so `ail check --json foo.ail` (with a syntax error
|
||||
in `foo.ail`) returns a parseable JSON diagnostic instead of
|
||||
the `_ => None` fall-through that bails fatally.
|
||||
- **Modify:** `docs/DESIGN.md:444-463` (§Decision 6 — CLI) — add
|
||||
a paragraph noting that `.ail` and `.ail.json` are both
|
||||
first-class inputs to every existing path-taking subcommand
|
||||
(the prior text already names `.ail.json` as such; this is the
|
||||
symmetric upward sentence for `.ail`).
|
||||
- **Test:** `crates/ailang-core/src/workspace.rs:1457+` — extend
|
||||
the existing `mod tests` with `load_workspace_with_custom_loader_is_called`
|
||||
exercising the closure injection on a single-module workspace.
|
||||
- **Test:** `crates/ailang-surface/tests/loader.rs` — new file with
|
||||
three tests:
|
||||
1. `load_module_dispatches_ail_to_surface_parse` — write a
|
||||
`.ail` text fixture in a tempdir, assert `surface::load_module`
|
||||
returns a `Module` whose name matches the source's
|
||||
`(module …)` header.
|
||||
2. `load_module_dispatches_ail_json_to_core_loader` — copy
|
||||
an existing `examples/*.ail.json` into a tempdir, assert
|
||||
`surface::load_module` returns the same `Module` as
|
||||
`core::load_module` does on the same bytes.
|
||||
3. `load_workspace_resolves_ail_imports_first_then_ail_json`
|
||||
— multi-module tempdir with one `.ail` entry that imports
|
||||
a sibling provided as `.ail.json`; assert success and that
|
||||
the resolved workspace contains both modules.
|
||||
- **Test:** `crates/ail/tests/e2e.rs:1-` (append a new test
|
||||
function): `ail_check_accepts_ail_source` — invokes the
|
||||
`ail` binary on `examples/hello.ail` with the `check`
|
||||
subcommand; assert exit code 0 and empty stderr.
|
||||
- **Test:** `crates/ail/tests/e2e.rs:1-` (append a second new
|
||||
test function): `ail_run_accepts_ail_source` — invokes `ail
|
||||
run examples/hello.ail` and asserts the stdout matches the
|
||||
same binary's output when invoked on `examples/hello.ail.json`
|
||||
(the existing E2E `build_and_run` helper at lines 13-38 is the
|
||||
template).
|
||||
- **Test:** `crates/ail/tests/ct1_check_cli.rs:1-` (append) —
|
||||
`ail_check_json_on_ail_with_syntax_error_returns_structured_diagnostic`:
|
||||
writes a deliberately broken `.ail` source (e.g., missing
|
||||
closing paren), invokes `ail check --json` on it, asserts
|
||||
stdout parses as JSON, contains a diagnostic with code
|
||||
`surface_parse_error`, and exit code is non-zero.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: `WorkspaceLoadError::SurfaceParse` variant + `core::load_workspace_with` injection point
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/ailang-core/src/workspace.rs:140-388` (enum)
|
||||
- Modify: `crates/ailang-core/src/workspace.rs:411-478`
|
||||
(`load_workspace`) + `:1378-1420` (`visit`)
|
||||
- Test: `crates/ailang-core/src/workspace.rs:1457+`
|
||||
(the `mod tests` block at the bottom)
|
||||
|
||||
- [ ] **Step 1.1: Write the failing test**
|
||||
|
||||
Append to the `mod tests` block at the bottom of `workspace.rs`:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn load_workspace_with_custom_loader_is_called() {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
let tmpdir = tempfile::tempdir().expect("tempdir");
|
||||
let entry = tmpdir.path().join("dummy.ail.json");
|
||||
std::fs::write(&entry, fixture_module_json("dummy")).expect("write");
|
||||
|
||||
static CALLS: AtomicUsize = AtomicUsize::new(0);
|
||||
CALLS.store(0, Ordering::SeqCst);
|
||||
|
||||
let loader = |path: &std::path::Path| -> Result<Module, WorkspaceLoadError> {
|
||||
CALLS.fetch_add(1, Ordering::SeqCst);
|
||||
load_one(path)
|
||||
};
|
||||
|
||||
let ws = load_workspace_with(&entry, loader)
|
||||
.expect("workspace loads via custom loader");
|
||||
assert_eq!(ws.entry, "dummy");
|
||||
assert_eq!(CALLS.load(Ordering::SeqCst), 1, "custom loader called once for single-module workspace");
|
||||
}
|
||||
|
||||
fn fixture_module_json(name: &str) -> String {
|
||||
format!(r#"{{"schema":"ailang/v0","name":"{name}","imports":[],"defs":[]}}"#)
|
||||
}
|
||||
```
|
||||
|
||||
If `tempfile` is not already a `dev-dependency` of `ailang-core`,
|
||||
add it (it is used by other tests in the workspace; check
|
||||
`Cargo.toml` first).
|
||||
|
||||
- [ ] **Step 1.2: Run test to verify it fails**
|
||||
|
||||
Run: `cargo test -p ailang-core --lib load_workspace_with_custom_loader_is_called`
|
||||
|
||||
Expected: FAIL with compile error
|
||||
"cannot find function `load_workspace_with` in this scope".
|
||||
|
||||
- [ ] **Step 1.3: Add the `SurfaceParse` variant to `WorkspaceLoadError`**
|
||||
|
||||
Insert as a new arm of the `WorkspaceLoadError` enum (in the same
|
||||
file, around line 158 alongside `Schema`):
|
||||
|
||||
```rust
|
||||
/// Source file failed to parse via the surface crate.
|
||||
///
|
||||
/// Used when a `.ail` (Form A) input is given to a path-taking
|
||||
/// subcommand. The message is the formatted `ailang_surface::ParseError`
|
||||
/// — held as a `String` here because pulling the surface error type
|
||||
/// into core would create a circular crate dependency.
|
||||
#[error("surface parse error in {path}: {message}")]
|
||||
SurfaceParse {
|
||||
path: std::path::PathBuf,
|
||||
message: String,
|
||||
},
|
||||
```
|
||||
|
||||
- [ ] **Step 1.4: Refactor `load_workspace` into `load_workspace_with` + thin wrapper**
|
||||
|
||||
Replace the current `pub fn load_workspace(entry_path: &Path) ->
|
||||
Result<Workspace, WorkspaceLoadError>` body (lines 411-478) with
|
||||
the following two functions:
|
||||
|
||||
```rust
|
||||
/// 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`].
|
||||
pub fn load_workspace_with<F>(
|
||||
entry_path: &Path,
|
||||
loader: F,
|
||||
) -> Result<Workspace, WorkspaceLoadError>
|
||||
where
|
||||
F: Fn(&Path) -> Result<Module, WorkspaceLoadError> + Copy,
|
||||
{
|
||||
let entry_path = entry_path.to_path_buf();
|
||||
let root_dir = entry_path
|
||||
.parent()
|
||||
.map(Path::to_path_buf)
|
||||
.unwrap_or_else(|| PathBuf::from("."));
|
||||
|
||||
let entry_module = loader(&entry_path)?;
|
||||
let expected_entry_name = module_name_from_path(&entry_path);
|
||||
if entry_module.name != expected_entry_name {
|
||||
return Err(WorkspaceLoadError::ModuleNameMismatch {
|
||||
name_in_file: entry_module.name.clone(),
|
||||
name_from_path: expected_entry_name,
|
||||
});
|
||||
}
|
||||
|
||||
let entry_name = entry_module.name.clone();
|
||||
|
||||
let mut modules: BTreeMap<String, Module> = BTreeMap::new();
|
||||
let mut visiting: Vec<String> = Vec::new();
|
||||
let mut visiting_set: HashSet<String> = HashSet::new();
|
||||
|
||||
visit(
|
||||
entry_module,
|
||||
&root_dir,
|
||||
&mut modules,
|
||||
&mut visiting,
|
||||
&mut visiting_set,
|
||||
loader,
|
||||
)?;
|
||||
|
||||
if modules.contains_key("prelude") {
|
||||
return Err(WorkspaceLoadError::ReservedModuleName {
|
||||
name: "prelude".to_string(),
|
||||
});
|
||||
}
|
||||
let prelude = load_prelude();
|
||||
modules.insert("prelude".to_string(), prelude);
|
||||
|
||||
validate_canonical_type_names(&modules)?;
|
||||
validate_classdefs(&modules)?;
|
||||
let registry = build_registry(&modules)?;
|
||||
|
||||
Ok(Workspace {
|
||||
entry: entry_name,
|
||||
modules,
|
||||
root_dir,
|
||||
registry,
|
||||
})
|
||||
}
|
||||
|
||||
/// Backward-compatible entry point: same as
|
||||
/// [`load_workspace_with`] with the JSON-only [`load_one`] loader.
|
||||
///
|
||||
/// `.ail` Form-A files are not accepted by this entry point. To
|
||||
/// accept both extensions, callers should use
|
||||
/// `ailang_surface::load_workspace`, which calls
|
||||
/// [`load_workspace_with`] under an extension-dispatching loader.
|
||||
pub fn load_workspace(entry_path: &Path) -> Result<Workspace, WorkspaceLoadError> {
|
||||
load_workspace_with(entry_path, load_one)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 1.5: Thread the loader through `visit` + add `.ail`-first import resolution**
|
||||
|
||||
In `visit` (line 1336 onward — find the current signature), add the
|
||||
loader parameter and use it everywhere `load_one` is called. Also
|
||||
change the import path construction so a `.ail` sibling wins over a
|
||||
`.ail.json` sibling.
|
||||
|
||||
Update `visit`'s signature to:
|
||||
|
||||
```rust
|
||||
fn visit<F>(
|
||||
module: Module,
|
||||
root_dir: &Path,
|
||||
modules: &mut BTreeMap<String, Module>,
|
||||
visiting: &mut Vec<String>,
|
||||
visiting_set: &mut HashSet<String>,
|
||||
loader: F,
|
||||
) -> Result<(), WorkspaceLoadError>
|
||||
where
|
||||
F: Fn(&Path) -> Result<Module, WorkspaceLoadError> + Copy,
|
||||
```
|
||||
|
||||
Inside `visit`, replace the import path construction at line 1381
|
||||
with:
|
||||
|
||||
```rust
|
||||
let imp_path = {
|
||||
let surface_path = root_dir.join(format!("{}.ail", imp.module));
|
||||
let json_path = root_dir.join(format!("{}.ail.json", imp.module));
|
||||
if surface_path.is_file() {
|
||||
surface_path
|
||||
} else {
|
||||
json_path
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
And replace the two `load_one(&imp_path)` calls at lines 1386 and
|
||||
1412 with `loader(&imp_path)`. Pass `loader` through the recursive
|
||||
`visit(...)` call at line 1419.
|
||||
|
||||
- [ ] **Step 1.6: Run the test to verify it passes**
|
||||
|
||||
Run: `cargo test -p ailang-core --lib load_workspace_with_custom_loader_is_called`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 1.7: Run the full core test suite**
|
||||
|
||||
Run: `cargo test -p ailang-core`
|
||||
|
||||
Expected: all existing tests still pass (no regression from the
|
||||
refactor).
|
||||
|
||||
---
|
||||
|
||||
## Task 2: `ailang_surface::load_module` + `load_workspace`
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/ailang-surface/src/loader.rs`
|
||||
- Modify: `crates/ailang-surface/src/lib.rs:1-40` — `mod loader;`
|
||||
and `pub use loader::{load_module, load_workspace};`
|
||||
- Test: `crates/ailang-surface/tests/loader.rs`
|
||||
|
||||
- [ ] **Step 2.1: Write the failing surface-side tests**
|
||||
|
||||
Create `crates/ailang-surface/tests/loader.rs`:
|
||||
|
||||
```rust
|
||||
//! Surface-side loader: dispatches by file extension.
|
||||
//!
|
||||
//! `.ail` → `surface::parse` (Form A); `.ail.json` → `core::load_module`
|
||||
//! (Form B). Workspace import resolution prefers `.ail` over `.ail.json`.
|
||||
|
||||
use ailang_surface::{load_module, load_workspace};
|
||||
use std::fs;
|
||||
|
||||
#[test]
|
||||
fn load_module_dispatches_ail_to_surface_parse() {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let path = tmp.path().join("hello.ail");
|
||||
// Minimal valid Form-A source — adjust to whatever parse(&str) accepts
|
||||
// for an empty module after the canonical-type-names milestone.
|
||||
fs::write(&path, "(module hello)\n").expect("write");
|
||||
|
||||
let module = load_module(&path).expect("surface parses .ail");
|
||||
assert_eq!(module.name, "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_module_dispatches_ail_json_to_core_loader() {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let path = tmp.path().join("dummy.ail.json");
|
||||
fs::write(
|
||||
&path,
|
||||
r#"{"schema":"ailang/v0","name":"dummy","imports":[],"defs":[]}"#,
|
||||
)
|
||||
.expect("write");
|
||||
|
||||
let surface_loaded = load_module(&path).expect("surface dispatches .ail.json to core");
|
||||
let core_loaded = ailang_core::load_module(&path).expect("core loads directly");
|
||||
|
||||
assert_eq!(surface_loaded.name, core_loaded.name);
|
||||
assert_eq!(surface_loaded.schema, core_loaded.schema);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_workspace_resolves_ail_imports_first_then_ail_json() {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let entry = tmp.path().join("main.ail");
|
||||
fs::write(&entry, "(module main (imports leaf))\n").expect("write entry");
|
||||
|
||||
let leaf = tmp.path().join("leaf.ail.json");
|
||||
fs::write(
|
||||
&leaf,
|
||||
r#"{"schema":"ailang/v0","name":"leaf","imports":[],"defs":[]}"#,
|
||||
)
|
||||
.expect("write leaf");
|
||||
|
||||
let ws = load_workspace(&entry).expect("workspace loads with mixed extensions");
|
||||
assert!(ws.modules.contains_key("main"));
|
||||
assert!(ws.modules.contains_key("leaf"));
|
||||
}
|
||||
```
|
||||
|
||||
Adjust the empty-module surface source in test 1 to whatever the
|
||||
current `ailang_surface::parse` accepts as a no-defs module. Read
|
||||
`crates/ailang-surface/tests/round_trip.rs` to pick the smallest
|
||||
valid source if `(module hello)` is rejected.
|
||||
|
||||
If `tempfile` is not already a `dev-dependency` of
|
||||
`ailang-surface`, add it.
|
||||
|
||||
- [ ] **Step 2.2: Run tests to verify they fail**
|
||||
|
||||
Run: `cargo test -p ailang-surface --test loader`
|
||||
|
||||
Expected: FAIL with compile error
|
||||
"unresolved imports: ailang_surface::load_module,
|
||||
ailang_surface::load_workspace".
|
||||
|
||||
- [ ] **Step 2.3: Create the loader module**
|
||||
|
||||
Create `crates/ailang-surface/src/loader.rs`:
|
||||
|
||||
```rust
|
||||
//! Extension-dispatching loader for AILang source files.
|
||||
//!
|
||||
//! AILang has two file forms today: Form A (`.ail`, the LLM
|
||||
//! authoring surface) and Form B (`.ail.json`, the canonical
|
||||
//! JSON-AST). These loaders accept both: for `.ail` they read the
|
||||
//! source text and parse it via [`crate::parse`]; for `.ail.json`
|
||||
//! they delegate to [`ailang_core::load_module`].
|
||||
//!
|
||||
//! These functions live in `ailang-surface` rather than
|
||||
//! `ailang-core` because `ailang-core` cannot import `ailang-surface`
|
||||
//! without creating a circular crate dependency.
|
||||
|
||||
use ailang_core::ast::Module;
|
||||
use ailang_core::workspace::{load_workspace_with, WorkspaceLoadError, Workspace};
|
||||
use std::path::Path;
|
||||
|
||||
fn is_ail_source(path: &Path) -> bool {
|
||||
path.extension().and_then(|s| s.to_str()) == Some("ail")
|
||||
}
|
||||
|
||||
/// Load a single module from either `.ail` or `.ail.json`.
|
||||
///
|
||||
/// Dispatches on file extension:
|
||||
///
|
||||
/// - `.ail` — read source text, parse via [`crate::parse`], return
|
||||
/// the in-memory [`Module`].
|
||||
/// - anything else — delegate to [`ailang_core::load_module`] and
|
||||
/// convert its `Error` into the corresponding
|
||||
/// [`WorkspaceLoadError`] variant.
|
||||
///
|
||||
/// Errors are returned as [`WorkspaceLoadError`] so that single-
|
||||
/// module callers and workspace callers can share the same error
|
||||
/// type (and `workspace_error_to_diagnostic` in the binary can
|
||||
/// route them through one channel).
|
||||
pub fn load_module(path: &Path) -> Result<Module, WorkspaceLoadError> {
|
||||
if is_ail_source(path) {
|
||||
let src = std::fs::read_to_string(path).map_err(|e| WorkspaceLoadError::Io {
|
||||
path: path.to_path_buf(),
|
||||
source: e,
|
||||
})?;
|
||||
crate::parse(&src).map_err(|e| WorkspaceLoadError::SurfaceParse {
|
||||
path: path.to_path_buf(),
|
||||
message: format!("{e}"),
|
||||
})
|
||||
} else {
|
||||
match ailang_core::load_module(path) {
|
||||
Ok(m) => Ok(m),
|
||||
Err(ailang_core::Error::Io(e)) => Err(WorkspaceLoadError::Io {
|
||||
path: path.to_path_buf(),
|
||||
source: e,
|
||||
}),
|
||||
Err(e) => Err(WorkspaceLoadError::Schema {
|
||||
path: path.to_path_buf(),
|
||||
source: e,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Load a workspace from an entry path, accepting either `.ail` or
|
||||
/// `.ail.json` for the entry and for every transitive import.
|
||||
///
|
||||
/// Import resolution prefers a sibling `<module>.ail` over
|
||||
/// `<module>.ail.json`; this is the new precedence rule baked into
|
||||
/// `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)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2.4: Wire the new module into the surface crate's `lib.rs`**
|
||||
|
||||
In `crates/ailang-surface/src/lib.rs`, add a `mod loader;` line
|
||||
where the other `mod` declarations live (near the existing `mod
|
||||
parse;`/`mod print;`), and the corresponding `pub use
|
||||
loader::{load_module, load_workspace};` re-export near the
|
||||
existing `pub use parse::{...}`.
|
||||
|
||||
- [ ] **Step 2.5: Run tests to verify they pass**
|
||||
|
||||
Run: `cargo test -p ailang-surface --test loader`
|
||||
|
||||
Expected: PASS for all three tests.
|
||||
|
||||
- [ ] **Step 2.6: Run the full surface test suite**
|
||||
|
||||
Run: `cargo test -p ailang-surface`
|
||||
|
||||
Expected: existing tests (especially `round_trip.rs`) still pass.
|
||||
|
||||
---
|
||||
|
||||
## Task 3: CLI rewiring — every path-taking subcommand routes through `ailang_surface::*`
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/ail/src/main.rs:332-2360` — every `ailang_core::load_module`
|
||||
/ `ailang_core::load_workspace` callsite enumerated in the
|
||||
Files section above. Approximately 17 sites.
|
||||
- Test: `crates/ail/tests/e2e.rs` — append two new test
|
||||
functions.
|
||||
|
||||
- [ ] **Step 3.1: Write the failing E2E test**
|
||||
|
||||
Append to `crates/ail/tests/e2e.rs`:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn ail_check_accepts_ail_source() {
|
||||
let example = workspace_root().join("examples/hello.ail");
|
||||
assert!(example.is_file(), "fixture exists at {}", example.display());
|
||||
|
||||
let output = std::process::Command::new(ail_bin())
|
||||
.args(["check", example.to_str().unwrap()])
|
||||
.output()
|
||||
.expect("run ail check");
|
||||
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"ail check exited non-zero on .ail source:\n stdout: {}\n stderr: {}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ail_run_accepts_ail_source_with_same_stdout_as_ail_json() {
|
||||
let ail_path = workspace_root().join("examples/hello.ail");
|
||||
let json_path = workspace_root().join("examples/hello.ail.json");
|
||||
assert!(ail_path.is_file());
|
||||
assert!(json_path.is_file());
|
||||
|
||||
let build_and_run = |src: &std::path::Path| -> Vec<u8> {
|
||||
let out = tempfile::NamedTempFile::new().expect("tempfile").into_temp_path();
|
||||
std::process::Command::new(ail_bin())
|
||||
.args(["build", src.to_str().unwrap(), "-o", out.to_str().unwrap()])
|
||||
.status()
|
||||
.expect("ail build")
|
||||
.success()
|
||||
.then_some(())
|
||||
.expect("build success");
|
||||
std::process::Command::new(&out)
|
||||
.output()
|
||||
.expect("run binary")
|
||||
.stdout
|
||||
};
|
||||
|
||||
let stdout_ail = build_and_run(&ail_path);
|
||||
let stdout_json = build_and_run(&json_path);
|
||||
|
||||
assert_eq!(stdout_ail, stdout_json, "both forms produce identical stdout");
|
||||
}
|
||||
```
|
||||
|
||||
`workspace_root()` is the existing helper at the top of
|
||||
`e2e.rs`; reuse it.
|
||||
|
||||
- [ ] **Step 3.2: Run the test to verify it fails**
|
||||
|
||||
Run: `cargo test -p ail --test e2e ail_check_accepts_ail_source`
|
||||
|
||||
Expected: FAIL — `ail check` exits non-zero with the misleading
|
||||
`json: expected value at line 1 column 1` error in stderr.
|
||||
|
||||
- [ ] **Step 3.3: Rewire callsites in `main.rs`**
|
||||
|
||||
For every callsite in `crates/ail/src/main.rs` enumerated in the
|
||||
Files section above (about 17 occurrences), replace:
|
||||
|
||||
- `ailang_core::load_module(...)` → `ailang_surface::load_module(...)`
|
||||
- `ailang_core::load_workspace(...)` → `ailang_surface::load_workspace(...)`
|
||||
|
||||
A useful safety check after the edits:
|
||||
|
||||
```bash
|
||||
git grep -n 'ailang_core::load_\(module\|workspace\)' crates/ail/src/main.rs
|
||||
```
|
||||
|
||||
Expected: empty (or only inside type signatures / doc comments that
|
||||
intentionally still reference the core entry points).
|
||||
|
||||
Add `ailang-surface.workspace = true` to `crates/ail/Cargo.toml`
|
||||
under `[dependencies]` if it is not already there.
|
||||
|
||||
Some callsites today call `load_module` and then convert the
|
||||
`ailang_core::Error` with `.map_err(...)`. After the switch they
|
||||
will get `WorkspaceLoadError` directly, so the per-site `map_err`
|
||||
becomes simpler (or unnecessary). Audit each site individually
|
||||
when editing.
|
||||
|
||||
- [ ] **Step 3.4: Run the workspace build and test**
|
||||
|
||||
Run: `cargo build --workspace`
|
||||
Expected: clean compile.
|
||||
|
||||
Run: `cargo test --workspace`
|
||||
Expected: all previously-green tests stay green. Two NEW e2e tests
|
||||
(`ail_check_accepts_ail_source`,
|
||||
`ail_run_accepts_ail_source_with_same_stdout_as_ail_json`) pass.
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Diagnostic routing for surface parse errors + DESIGN.md addendum
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/ail/src/main.rs:1018-1106`
|
||||
(`workspace_error_to_diagnostic`)
|
||||
- Modify: `docs/DESIGN.md:444-463` (§Decision 6 — CLI)
|
||||
- Test: `crates/ail/tests/ct1_check_cli.rs` — append one test
|
||||
|
||||
- [ ] **Step 4.1: Write the failing diagnostic test**
|
||||
|
||||
Append to `crates/ail/tests/ct1_check_cli.rs`:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn ail_check_json_on_ail_with_syntax_error_returns_structured_diagnostic() {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let bad = tmp.path().join("bad.ail");
|
||||
// Deliberately broken — missing closing paren in the module header.
|
||||
std::fs::write(&bad, "(module bad\n").expect("write");
|
||||
|
||||
let output = std::process::Command::new(ail_bin())
|
||||
.args(["check", "--json", bad.to_str().unwrap()])
|
||||
.output()
|
||||
.expect("run ail check --json");
|
||||
|
||||
// ail check --json on a bad .ail should NOT crash with the misleading
|
||||
// JSON-parse fall-through; it should return exit code != 0 and emit a
|
||||
// parseable JSON diagnostic on stdout.
|
||||
assert!(!output.status.success(), "expected non-zero exit on bad source");
|
||||
|
||||
let stdout = String::from_utf8(output.stdout).expect("stdout is UTF-8");
|
||||
let parsed: serde_json::Value = serde_json::from_str(&stdout)
|
||||
.unwrap_or_else(|e| panic!("stdout not valid JSON: {e}\ngot:\n{stdout}"));
|
||||
|
||||
let diags = parsed.as_array().expect("diagnostics array");
|
||||
assert!(!diags.is_empty(), "at least one diagnostic emitted");
|
||||
let first = &diags[0];
|
||||
assert_eq!(first["code"].as_str(), Some("surface_parse_error"));
|
||||
assert!(first["path"].is_string());
|
||||
assert!(first["message"].is_string());
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4.2: Run the test to verify it fails**
|
||||
|
||||
Run: `cargo test -p ail --test ct1_check_cli ail_check_json_on_ail_with_syntax_error_returns_structured_diagnostic`
|
||||
|
||||
Expected: FAIL — `ail check --json` either crashes fatally
|
||||
(`workspace_error_to_diagnostic` returned `None` for the new
|
||||
`SurfaceParse` variant and the caller does
|
||||
`Err(anyhow::anyhow!(e))`) or returns an empty diagnostic array.
|
||||
|
||||
- [ ] **Step 4.3: Add the `SurfaceParse` arm to `workspace_error_to_diagnostic`**
|
||||
|
||||
In `crates/ail/src/main.rs:1018-1106`, the existing function has
|
||||
this shape:
|
||||
|
||||
```rust
|
||||
fn workspace_error_to_diagnostic(e: &ailang_core::WorkspaceLoadError) -> Option<ailang_check::Diagnostic> {
|
||||
use ailang_core::WorkspaceLoadError as W;
|
||||
match e {
|
||||
W::Schema { source, .. } => match source {
|
||||
ailang_core::Error::SchemaMismatch { .. } => Some(...),
|
||||
_ => None,
|
||||
},
|
||||
// ...
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Add an arm for the new variant before the catch-all `_ => None`:
|
||||
|
||||
```rust
|
||||
W::SurfaceParse { path, message } => Some(ailang_check::Diagnostic {
|
||||
code: "surface_parse_error".into(),
|
||||
message: message.clone(),
|
||||
path: Some(path.display().to_string()),
|
||||
// populate other fields (span, severity, etc.) with sensible defaults
|
||||
// — match the shape used by the existing SchemaMismatch arm above.
|
||||
..Default::default()
|
||||
}),
|
||||
```
|
||||
|
||||
The exact field set on `ailang_check::Diagnostic` may differ —
|
||||
adapt by reading the existing `SchemaMismatch` arm a few lines
|
||||
above and mirroring its field population.
|
||||
|
||||
- [ ] **Step 4.4: Run the diagnostic test to verify it passes**
|
||||
|
||||
Run: `cargo test -p ail --test ct1_check_cli ail_check_json_on_ail_with_syntax_error_returns_structured_diagnostic`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 4.5: Add the DESIGN.md addendum**
|
||||
|
||||
In `docs/DESIGN.md:444-463` (§Decision 6, the "CLI" paragraph),
|
||||
the current text describes the directional pair `ail parse` /
|
||||
`ail render` and asserts `.ail.json` is the first-class input to
|
||||
every existing subcommand. Add a one-paragraph addendum after
|
||||
that sentence (do not rewrite the existing text):
|
||||
|
||||
```markdown
|
||||
**Both extensions accepted.** Since iter ext-cli.1 (2026-05-12),
|
||||
every path-taking CLI subcommand accepts either `.ail` (Form A)
|
||||
or `.ail.json` (Form B) as input. For `.ail` paths the subcommand
|
||||
parses through `ailang_surface::parse` in-line and then proceeds
|
||||
with the same loaded `Module` value that `.ail.json` would have
|
||||
produced. `ail parse` remains the explicit converter — it does
|
||||
nothing the implicit dispatch in the other subcommands does not,
|
||||
but it is the supported way to materialise a stable `.ail.json`
|
||||
snapshot from a `.ail` source for diff / hash / cache purposes.
|
||||
```
|
||||
|
||||
- [ ] **Step 4.6: Run the full workspace test suite**
|
||||
|
||||
Run: `cargo test --workspace`
|
||||
Expected: all green. The schema-drift test in
|
||||
`crates/ailang-core/tests/design_schema_drift.rs` scans
|
||||
DESIGN.md for anchors; adding a paragraph inside Decision 6
|
||||
should not surface any new false positives, but if it does, the
|
||||
test's anchor-presence check will flag exactly which anchor is
|
||||
missing.
|
||||
|
||||
- [ ] **Step 4.7: Run the three regression scripts**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
python3 bench/check.py
|
||||
python3 bench/compile_check.py
|
||||
python3 bench/cross_lang.py
|
||||
```
|
||||
|
||||
Expected: 0 regressed across all three. The change is functionally
|
||||
inert at the bench layer — the bench corpus all uses `.ail.json`
|
||||
files, so the new code path is not exercised; but the rewiring
|
||||
must not have broken the existing `.ail.json` path.
|
||||
|
||||
---
|
||||
|
||||
## Roadmap close
|
||||
|
||||
When all four tasks have landed and the working tree is clean of
|
||||
red tests:
|
||||
|
||||
- Remove the P2 todo "`ail check`/`build`/`run` accept `.ail`
|
||||
extension" from `docs/roadmap.md:185-194`. The natural follow-up
|
||||
(a "did you mean `ail parse`?" hint for non-`.ail`/.ail.json`
|
||||
paths) is small enough to fit under the same iter's commit; if
|
||||
it surfaces during impl, decide at the time whether to roll it
|
||||
into ext-cli.1 or queue a follow-up `ext-cli.2`.
|
||||
- Append a one-line entry to `docs/journals/INDEX.md`.
|
||||
- Write the iter journal `docs/journals/2026-05-12-iter-ext-cli.1.md`
|
||||
summarising scope, design (Option 1 chosen), what shipped, and
|
||||
any open follow-ups.
|
||||
|
||||
## Self-review (Boss, pre-handoff)
|
||||
|
||||
1. **Spec coverage:** every roadmap-todo intent is covered —
|
||||
subcommands accept `.ail`, `ail check foo.ail` returns a
|
||||
parseable diagnostic in JSON mode. (Task 4 covers the
|
||||
diagnostic path; Tasks 1-3 cover the load path.)
|
||||
2. **Placeholder scan:** TBD / TODO / "similar to" / "implement
|
||||
later" / "add appropriate error handling" — none. The DESIGN.md
|
||||
addendum text in Step 4.5 is concrete copy ready to paste; the
|
||||
`ailang_check::Diagnostic` field-population in Step 4.3 is
|
||||
bounded by "mirror the SchemaMismatch arm a few lines above"
|
||||
which is concrete (the implementer reads the file and follows
|
||||
the pattern). No execution-time design judgement deferred.
|
||||
3. **Type consistency:** `load_workspace_with`, `load_module`,
|
||||
`load_workspace`, `SurfaceParse`, `surface_parse_error`,
|
||||
`is_ail_source` — match across all four tasks.
|
||||
4. **Step granularity:** every step is 2-5 minutes (the largest
|
||||
step, 3.3, is rote substitution across 17 sites and is bounded
|
||||
by the explicit site list).
|
||||
5. **No commit steps:** none. Boss commits the iter at the end.
|
||||
Reference in New Issue
Block a user