# iter ext-cli.1 — CLI accepts `.ail` (Form A) source files alongside `.ail.json` **Date:** 2026-05-12 **Started from:** efecbaa **Status:** DONE **Tasks completed:** 4 of 4 ## Summary Closes the loop opened by this morning's `.ailx → .ail` rename. Every path-taking `ail` subcommand (`check`, `build`, `run`, `manifest`, `render`, `prose`, `merge-prose`, `describe`, `emit-ir`, `diff`, `workspace`, `deps`) now accepts a `.ail` (Form A) input as well as `.ail.json` (Form B). For `.ail` paths the subcommand parses through `ailang_surface::parse` in-line and proceeds with the same loaded `Module` value `.ail.json` would have produced; the binary semantics are extension-agnostic. Architecture: a new `ailang_surface::{load_module, load_workspace}` pair dispatches on file extension. A new injection point in `ailang_core::workspace::load_workspace_with` lets the surface crate plug in an extension-aware loader without `ailang-core` having to import `ailang-surface` (which would close a crate cycle). Import resolution in `workspace::visit` now prefers a `.ail` sibling over a `.ail.json` sibling of the same module name. The CLI binary's 18 `ailang_core::load_*` callsites all switched to the new `ailang_surface::load_*`. Surface parse failures route through the existing `workspace_error_to_diagnostic` channel as a new `SurfaceParse` variant of `WorkspaceLoadError`, so `ail check --json foo.ail` on a malformed `.ail` returns a parseable JSON diagnostic with code `surface_parse_error` instead of the pre-iter misleading `json: expected value at line 1 column 1` fall-through. ## Per-task notes - iter ext-cli.1.1: `WorkspaceLoadError::SurfaceParse { path, message }` variant added to `ailang_core::workspace`; `load_workspace` refactored into a thin wrapper around a new `pub fn load_workspace_with(entry, loader: F)` with `F: Fn(&Path) -> Result + Copy`; `visit`'s signature gained the loader parameter and its import-path construction now does `.ail`-first / `.ail.json`-fallback; new test `load_workspace_with_custom_loader_is_called` pins the injection. `tempfile` added as `ailang-core` dev-dependency. - iter ext-cli.1.2: new `crates/ailang-surface/src/loader.rs` with `pub fn load_module` (extension-dispatching: `.ail` → `parse`, else delegate to `ailang_core::load_module`) and `pub fn load_workspace` (calls `load_workspace_with(entry, load_module)`); `lib.rs` re-exports both. Three integration tests in `crates/ailang-surface/tests/loader.rs` pin the dispatch on each form individually + the mixed-extension workspace case. `tempfile` added as `ailang-surface` dev-dependency. - iter ext-cli.1.3: every `ailang_core::load_module` / `ailang_core::load_workspace` callsite in `crates/ail/src/main.rs` (18 sites total) replaced with `ailang_surface::load_module` / `ailang_surface::load_workspace`. Workspace-build placeholder `W::SurfaceParse { .. } => None` arm added to `workspace_error_to_diagnostic` (replaced in Task 4) so the exhaustive match compiles. Two new E2E tests in `crates/ail/tests/e2e.rs`: `ail_check_accepts_ail_source` and `ail_run_accepts_ail_source_with_same_stdout_as_ail_json`. - iter ext-cli.1.4: placeholder arm replaced with proper `W::SurfaceParse { path, message } => Some(Diagnostic::error( "surface_parse_error", message).with_ctx({"path": ...}))` — mirroring the `SchemaMismatch` arm's builder pattern. DESIGN.md §Decision 6 / "CLI" bullet gained the one-paragraph addendum (verbatim from the plan, dated). New ct1 test `ail_check_json_on_ail_with_syntax_error_returns_structured_diagnostic` pins the JSON-diagnostic shape. Plus one E2E coverage test added during Phase 3: `load_workspace_prefers_ail_when_both_extensions_exist` — protects the `.ail`-first precedence when both extensions are present for the same module (the existing 1.2 test only exercised the fallback half). ## Concerns - **Diagnostic code convention drift (Boss-corrected pre-commit).** The plan-specified code `surface_parse_error` used snake_case; every other diagnostic code in the codebase is kebab-case (`type-mismatch`, `unbound-var`, `over-strict-mode`, `reuse-as-shape-mismatch`, ...). Boss verified the convention via `git grep` over `crates/ailang-check/src` and `crates/ail/src` before commit and aligned the literal to `surface-parse-error` at three sites: the `SurfaceParse` arm in `workspace_error_to_diagnostic` (`crates/ail/src/main.rs`) and the two matching strings in `crates/ail/tests/ct1_check_cli.rs` (the rustdoc on the test + the `assert_eq!` against `first["code"]`). The plan file is left at the snake_case form as historical record; the source of truth is now the corrected code. - **bench/check.py first run noisy.** First execution showed 4 regressions on `latency.{explicit,implicit}_at_rc.{p99_9,max}_us` (tail-latency tier). Re-runs (×2) returned 0 regressed (one even showed 4 improvements beyond tolerance). This is the same `latency.explicit_at_rc.*` cluster audit-cma and audit-ms already documented as nondeterministic; the iter's actual code change introduces ONE extra `surface_path.is_file()` syscall per import-resolution step plus a single-byte extension check per load, neither of which can move us-scale tail latencies. Net: baseline left pristine, consistent with the last two audits. ## Known debt - The `ail parse` subcommand is still the only way to materialise a stable `.ail.json` snapshot from a `.ail` source. Now that every subcommand silently dispatches `.ail` through the parser, one might argue for a "did you mean `ail parse`?" hint on paths with non-`.ail`/non-`.ail.json` extensions. The roadmap note for ext-cli.1 mentions this as a possible same-iter or follow-up scope; nothing surfaced as load-bearing during impl, deferring. ## Files touched Code: - `crates/ailang-core/Cargo.toml` (+ tempfile dev-dep) - `crates/ailang-core/src/workspace.rs` (SurfaceParse variant, load_workspace_with, visit threading, .ail-first precedence, new unit test) - `crates/ailang-surface/Cargo.toml` (+ tempfile dev-dep) - `crates/ailang-surface/src/lib.rs` (mod loader + re-exports) - `crates/ailang-surface/src/loader.rs` (NEW) - `crates/ailang-surface/tests/loader.rs` (NEW; 4 tests) - `crates/ail/src/main.rs` (18 callsite rewires + SurfaceParse diagnostic arm) - `crates/ail/tests/e2e.rs` (2 new E2E tests) - `crates/ail/tests/ct1_check_cli.rs` (1 new diagnostic-shape test) Spec / metadata: - `docs/DESIGN.md` (§Decision 6 / CLI bullet addendum) - `Cargo.lock` (tempfile dev-dep transitive) ## Stats bench/orchestrator-stats/2026-05-12-iter-ext-cli.1.json