iter ext-cli.1: CLI accepts .ail (Form A) sources alongside .ail.json

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; binary semantics are extension-agnostic.

Architecture: a new ailang_surface::{load_module, load_workspace}
pair dispatches on file extension. A new injection point
ailang_core::workspace::load_workspace_with<F> lets the surface
crate plug in an extension-aware loader without core having to
import surface (which would close a crate cycle). Import resolution
in workspace::visit now prefers a .ail sibling over a .ail.json
sibling. The CLI binary's 18 callsites switched to the surface
loaders. Surface parse failures route through
workspace_error_to_diagnostic 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 misleading
`json: expected value at line 1 column 1` fall-through.

Boss pre-commit correction: the implementer followed the plan
verbatim and used snake_case `surface_parse_error`, but every
existing diagnostic code in the codebase is kebab-case (verified
via `git grep` over crates/ailang-check/src and crates/ail/src);
realigned to `surface-parse-error` at three sites (the diagnostic
arm + two ct1 test strings). Journal Concerns section records
the correction.

Tests: cargo test --workspace 487 green (+7 from this iter:
1 core unit, 4 surface integration, 1 ail E2E pair, 1 ct1 CLI
diagnostic-shape). bench/check.py, compile_check.py, cross_lang.py
clean on re-run; the latency.{explicit,implicit}_at_rc tail
cluster occasionally flagged on first runs is the same
nondeterministic cluster the previous two audits documented;
baseline left pristine for the third consecutive iter.

Roadmap follow-up "ail check/build/run accept .ail extension" (P2)
removed.

DESIGN.md §Decision 6 / "CLI" bullet gained the symmetric upward
sentence: both .ail and .ail.json are first-class CLI inputs.
This commit is contained in:
2026-05-12 14:45:03 +02:00
parent efecbaa3e6
commit 79cc78507b
15 changed files with 611 additions and 35 deletions
Generated
+2
View File
@@ -48,6 +48,7 @@ dependencies = [
"indexmap",
"serde",
"serde_json",
"tempfile",
"thiserror",
]
@@ -64,6 +65,7 @@ version = "0.0.1"
dependencies = [
"ailang-core",
"serde_json",
"tempfile",
"thiserror",
]
@@ -0,0 +1,17 @@
{
"iter_id": "ext-cli.1",
"date": "2026-05-12",
"mode": "standard",
"outcome": "DONE",
"tasks_total": 4,
"tasks_completed": 4,
"reloops_per_task": {
"1": 0,
"2": 0,
"3": 0,
"4": 0
},
"review_loops_spec": 0,
"review_loops_quality": 0,
"blocked_reason": null
}
+32 -18
View File
@@ -336,7 +336,7 @@ fn main() -> Result<()> {
if workspace {
// Workspace mode: load all modules and emit their defs
// together, alphabetically by (module, name).
let ws = ailang_core::load_workspace(&path)?;
let ws = ailang_surface::load_workspace(&path)?;
let mut entries: Vec<(String, &ailang_core::Def)> = Vec::new();
for (mod_name, m) in &ws.modules {
for d in &m.defs {
@@ -394,7 +394,7 @@ fn main() -> Result<()> {
}
}
} else {
let m = ailang_core::load_module(&path)?;
let m = ailang_surface::load_module(&path)?;
if json {
let entries: Vec<_> = m
.defs
@@ -422,14 +422,14 @@ fn main() -> Result<()> {
}
}
Cmd::Render { path } => {
let m = ailang_core::load_module(&path)?;
let m = ailang_surface::load_module(&path)?;
print!("{}", ailang_surface::print(&m));
}
Cmd::Prose { path } => {
// Iter 20a: load via `load_module` (single-module mode,
// matching how `render` / `parse` work). Workspace-wide
// prose is not in scope for 20a.
let m = ailang_core::load_module(&path)?;
let m = ailang_surface::load_module(&path)?;
print!("{}", ailang_prose::module_to_prose(&m));
}
Cmd::MergeProse { original, edited } => {
@@ -439,7 +439,7 @@ fn main() -> Result<()> {
// Form-A is the form an LLM should produce; JSON is the
// hashable artefact, not the writing surface. The prose
// file is embedded byte-for-byte.
let module = ailang_core::load_module(&original)
let module = ailang_surface::load_module(&original)
.with_context(|| format!("loading {}", original.display()))?;
let original_form_a = ailang_surface::print(&module);
let prose = std::fs::read_to_string(&edited)
@@ -527,7 +527,7 @@ fn main() -> Result<()> {
}
Cmd::Describe { path, name, json, workspace } => {
if workspace {
let ws = ailang_core::load_workspace(&path)?;
let ws = ailang_surface::load_workspace(&path)?;
let (mod_name, def) = resolve_describe_name(&ws, &name)?;
if json {
// We pass the def itself through and add the
@@ -558,7 +558,7 @@ fn main() -> Result<()> {
print!("{}", ailang_surface::print(&one));
}
} else {
let m = ailang_core::load_module(&path)?;
let m = ailang_surface::load_module(&path)?;
let def = m
.defs
.iter()
@@ -593,7 +593,7 @@ fn main() -> Result<()> {
// diagnostics (codes `module-not-found`,
// `module-cycle`, `module-name-mismatch`, `schema-mismatch`).
// Real I/O errors on the entry file remain fatal.
let diags = match ailang_core::load_workspace(&path) {
let diags = match ailang_surface::load_workspace(&path) {
Ok(ws) => ailang_check::check_workspace(&ws),
Err(e) => match workspace_error_to_diagnostic(&e) {
Some(d) => vec![d],
@@ -608,7 +608,7 @@ fn main() -> Result<()> {
std::process::exit(1);
}
} else {
let ws = ailang_core::load_workspace(&path)?;
let ws = ailang_surface::load_workspace(&path)?;
let diags = ailang_check::check_workspace(&ws);
if !diags.is_empty() {
for d in &diags {
@@ -648,7 +648,7 @@ fn main() -> Result<()> {
Cmd::EmitIr { path, out } => {
// Iter 5c: workspace lowering. For single-module programs the
// workspace is effectively a trivial workspace with one module.
let ws = ailang_core::load_workspace(&path)?;
let ws = ailang_surface::load_workspace(&path)?;
let diags = ailang_check::check_workspace(&ws);
if !diags.is_empty() {
for d in &diags {
@@ -743,8 +743,8 @@ fn main() -> Result<()> {
}
Cmd::Diff { a, b, json, workspace } => {
if workspace {
let ws_a = ailang_core::load_workspace(&a)?;
let ws_b = ailang_core::load_workspace(&b)?;
let ws_a = ailang_surface::load_workspace(&a)?;
let ws_b = ailang_surface::load_workspace(&b)?;
let report = build_workspace_diff(&ws_a, &ws_b);
if json {
let v = workspace_diff_report_to_json(&report);
@@ -756,8 +756,8 @@ fn main() -> Result<()> {
std::process::exit(1);
}
} else {
let ma = ailang_core::load_module(&a)?;
let mb = ailang_core::load_module(&b)?;
let ma = ailang_surface::load_module(&a)?;
let mb = ailang_surface::load_module(&b)?;
let report = build_diff(&ma, &mb);
if json {
let v = diff_report_to_json(&report);
@@ -771,7 +771,7 @@ fn main() -> Result<()> {
}
}
Cmd::Workspace { entry, json } => {
let ws = ailang_core::load_workspace(&entry)?;
let ws = ailang_surface::load_workspace(&entry)?;
// Iterate alphabetically over module names (BTreeMap order
// is already sorted; assert it explicitly).
let mut entries: Vec<(String, String, usize)> = ws
@@ -852,7 +852,7 @@ fn main() -> Result<()> {
}
Cmd::Deps { path, of, json, workspace } => {
if workspace {
let ws = ailang_core::load_workspace(&path)?;
let ws = ailang_surface::load_workspace(&path)?;
// `--of NAME`: optional, accepts dot notation
// (`<module>.<def>`) or a bare name (matches in all
@@ -978,7 +978,7 @@ fn main() -> Result<()> {
}
}
} else {
let m = ailang_core::load_module(&path)?;
let m = ailang_surface::load_module(&path)?;
let mut entries = Vec::new();
for d in &m.defs {
if let Some(filter) = &of {
@@ -1316,6 +1316,20 @@ fn workspace_error_to_diagnostic(
"field": field,
})),
),
// ext-cli.1: surface-side parse failure on a `.ail` (Form A)
// source file. Routed through the same `Diagnostic` channel as
// schema/coherence errors so `ail check --json foo.ail` on a
// broken `.ail` returns a parseable JSON array instead of the
// misleading JSON-parse fall-through.
W::SurfaceParse { path, message } => Some(
ailang_check::Diagnostic::error(
"surface-parse-error",
message.clone(),
)
.with_ctx(serde_json::json!({
"path": path.display().to_string(),
})),
),
}
}
@@ -2190,7 +2204,7 @@ fn build_to(
opt: &str,
alloc: ailang_codegen::AllocStrategy,
) -> Result<PathBuf> {
let ws = ailang_core::load_workspace(path)?;
let ws = ailang_surface::load_workspace(path)?;
let diags = ailang_check::check_workspace(&ws);
if !diags.is_empty() {
for d in &diags {
+44
View File
@@ -178,3 +178,47 @@ fn check_ordering_match_post_migration_is_clean() {
"expected zero diagnostics on migrated fixture; got {stdout}"
);
}
/// Property: `ail check --json` on a `.ail` (Form A) source file with
/// a syntax error returns a structured `surface-parse-error`
/// diagnostic (non-empty diagnostics array, exit code != 0), rather
/// than crashing with the misleading JSON-parse fall-through that
/// ext-cli.1 was built to eliminate. Guards against
/// `workspace_error_to_diagnostic` losing the `SurfaceParse` arm or
/// the surface dispatcher silently swallowing the parse error.
#[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 = 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; stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
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"));
// The offending path goes into the ctx payload (matching the
// shape every other workspace-error diagnostic uses, e.g.
// `schema-mismatch` puts `expected`/`actual` there).
assert!(first["ctx"]["path"].is_string(), "ctx.path is the file path");
assert!(first["message"].is_string(), "message is the formatted ParseError");
}
+76
View File
@@ -2483,3 +2483,79 @@ fn merge_prose_prints_framed_prompt() {
"prompt missing FORM-A SPECIFICATION section"
);
}
// ---------- ext-cli.1: CLI accepts `.ail` (Form A) source files ----------
fn workspace_root() -> std::path::PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap()
.to_path_buf()
}
/// Property: `ail check` accepts a Form-A `.ail` source file (not just
/// the Form-B `.ail.json` canonical form) and exits zero on a
/// well-formed program. Guards against the post-rename misleading-JSON
/// fall-through that motivated iter ext-cli.1: before the rewiring,
/// `ail check examples/hello.ail` produced
/// `json: expected value at line 1 column 1` and exited non-zero.
#[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 = 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),
);
}
/// Property: a program built from `.ail` (Form A) produces the same
/// stdout as the same program built from its `.ail.json` (Form B)
/// counterpart. Guards against the surface→core dispatch silently
/// rewriting the loaded `Module` (e.g. losing imports, dropping defs,
/// re-ordering fields) — the binary semantics must be form-agnostic.
#[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_capture = |src: &Path| -> Vec<u8> {
let tmp = std::env::temp_dir().join(format!(
"ailang_ext_cli_e2e_{}_{}",
src.file_name().unwrap().to_string_lossy().replace('.', "_"),
std::process::id()
));
std::fs::create_dir_all(&tmp).unwrap();
let out = tmp.join("bin");
let status = Command::new(ail_bin())
.args(["build", src.to_str().unwrap(), "-o"])
.arg(&out)
.status()
.expect("ail build failed to run");
assert!(status.success(), "ail build failed for {}", src.display());
Command::new(&out)
.output()
.expect("execute binary")
.stdout
};
let stdout_ail = build_and_capture(&ail_path);
let stdout_json = build_and_capture(&json_path);
assert_eq!(
stdout_ail, stdout_json,
"both forms must produce identical stdout"
);
}
+3
View File
@@ -10,3 +10,6 @@ serde_json.workspace = true
blake3.workspace = true
thiserror.workspace = true
indexmap.workspace = true
[dev-dependencies]
tempfile.workspace = true
+95 -7
View File
@@ -157,6 +157,18 @@ pub enum WorkspaceLoadError {
source: CoreError,
},
/// 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,
},
/// An `import { module: "foo" }` did not resolve to an existing
/// file at the expected path.
#[error("module `{name}` not found (expected at {expected_path})")]
@@ -409,6 +421,30 @@ fn load_prelude() -> Module {
/// - `visiting`: stack of modules whose DFS descent is still running.
/// A hit here = cycle.
pub fn load_workspace(entry_path: &Path) -> Result<Workspace, WorkspaceLoadError> {
load_workspace_with(entry_path, load_one)
}
/// Load a workspace using a caller-supplied per-module loader.
///
/// The standard entry point [`load_workspace`] is a thin wrapper that
/// passes [`load_one`] as the loader and so only accepts `.ail.json`
/// files. Pass a different loader (for example, an extension-dispatching
/// loader from `ailang-surface`) to accept `.ail` source files as well.
///
/// The loader is invoked for the entry module and for every transitive
/// import. It is responsible for reading bytes, parsing, and returning
/// the in-memory [`Module`].
///
/// ext-cli.1: introduced so `ailang-surface::load_workspace` can plug
/// in an extension-dispatching loader without `ailang-core` importing
/// `ailang-surface` (which would close a crate cycle).
pub fn load_workspace_with<F>(
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()
@@ -416,7 +452,7 @@ pub fn load_workspace(entry_path: &Path) -> Result<Workspace, WorkspaceLoadError
.unwrap_or_else(|| PathBuf::from("."));
// Load entry module + check name<->filename convention.
let entry_module = load_one(&entry_path)?;
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 {
@@ -437,6 +473,7 @@ pub fn load_workspace(entry_path: &Path) -> Result<Workspace, WorkspaceLoadError
&mut modules,
&mut visiting,
&mut visiting_set,
loader,
)?;
// Iter 23.1: inject the prelude module. It is implicitly
@@ -1350,13 +1387,17 @@ fn type_head_name(t: &Type) -> String {
}
}
fn visit(
fn visit<F>(
module: Module,
root_dir: &Path,
modules: &mut BTreeMap<String, Module>,
visiting: &mut Vec<String>,
visiting_set: &mut HashSet<String>,
) -> Result<(), WorkspaceLoadError> {
loader: F,
) -> Result<(), WorkspaceLoadError>
where
F: Fn(&Path) -> Result<Module, WorkspaceLoadError> + Copy,
{
let name = module.name.clone();
// Already fully loaded? Then do nothing. Hash consistency is checked
@@ -1378,12 +1419,23 @@ fn visit(
// Process imports recursively.
let imports = module.imports.clone();
for imp in &imports {
let imp_path = root_dir.join(format!("{}.ail.json", imp.module));
// ext-cli.1: prefer a sibling `<module>.ail` (Form A) over
// `<module>.ail.json` (Form B). The injected `loader` is
// responsible for reading whichever extension wins.
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
}
};
if let Some(existing) = modules.get(&imp.module) {
// Already fully loaded — check hash consistency, in case the
// file on disk has changed.
let on_disk = match load_one(&imp_path) {
let on_disk = match loader(&imp_path) {
Ok(m) => m,
Err(WorkspaceLoadError::Io { .. }) => continue,
Err(e) => return Err(e),
@@ -1409,14 +1461,14 @@ fn visit(
});
}
let imported = load_one(&imp_path)?;
let imported = loader(&imp_path)?;
if imported.name != imp.module {
return Err(WorkspaceLoadError::ModuleNameMismatch {
name_in_file: imported.name,
name_from_path: imp.module.clone(),
});
}
visit(imported, root_dir, modules, visiting, visiting_set)?;
visit(imported, root_dir, modules, visiting, visiting_set, loader)?;
}
visiting.pop();
@@ -2608,4 +2660,40 @@ mod tests {
other => panic!("expected QualifiedClassName, got {other:?}"),
}
}
/// 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.
#[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":[]}}"#)
}
}
+3
View File
@@ -8,3 +8,6 @@ license.workspace = true
ailang-core.workspace = true
serde_json.workspace = true
thiserror.workspace = true
[dev-dependencies]
tempfile.workspace = true
+2
View File
@@ -32,8 +32,10 @@
//! consume `Module` values regardless of which front-end produced them.
pub mod lex;
pub mod loader;
pub mod parse;
pub mod print;
pub use loader::{load_module, load_workspace};
pub use parse::{parse, parse_term, ParseError};
pub use print::{print, term_to_form_a, type_to_form_a};
+71
View File
@@ -0,0 +1,71 @@
//! 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. The cross-crate
//! injection point is [`ailang_core::workspace::load_workspace_with`],
//! introduced in iter ext-cli.1.
use ailang_core::ast::Module;
use ailang_core::workspace::{load_workspace_with, Workspace, WorkspaceLoadError};
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)
}
+118
View File
@@ -0,0 +1,118 @@
//! 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;
/// Property: `load_module` on a `.ail` (Form A) source file dispatches
/// to `ailang_surface::parse` and returns the parsed module. Guards
/// against the dispatch silently falling through to the JSON loader
/// (which would fail on Form-A bytes with the misleading
/// `json: expected value at line 1 column 1` error the rest of this
/// iter exists to eliminate).
#[test]
fn load_module_dispatches_ail_to_surface_parse() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join("hello.ail");
// Smallest valid Form-A module: header + no defs.
fs::write(&path, "(module hello)\n").expect("write");
let module = load_module(&path).expect("surface parses .ail");
assert_eq!(module.name, "hello");
}
/// Property: `load_module` on a `.ail.json` (Form B) source file
/// delegates to `ailang_core::load_module` and produces the same
/// `Module` value the core entry point would. Guards against the
/// surface loader silently shadowing or rewriting the JSON path.
#[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);
}
/// Property: workspace import resolution accepts mixed extensions —
/// a `.ail` entry module can import a sibling provided as `.ail.json`,
/// and both modules end up in the loaded workspace. Guards against
/// the `.ail`-first / `.ail.json`-fallback precedence rule in
/// `workspace::visit` regressing back to JSON-only resolution.
#[test]
fn load_workspace_resolves_ail_imports_first_then_ail_json() {
let tmp = tempfile::tempdir().expect("tempdir");
let entry = tmp.path().join("main.ail");
// Surface grammar uses one `(import <name>)` clause per import.
fs::write(&entry, "(module main (import 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"));
}
/// Property: when BOTH `<module>.ail` (Form A) and `<module>.ail.json`
/// (Form B) exist in the workspace root, the `.ail` sibling wins —
/// the loaded module's content reflects Form A, not Form B. Pins the
/// `surface_path.is_file()` precedence branch in
/// `workspace::visit`'s import-path construction. Regression would
/// look like the loader silently shadowing a freshly-edited `.ail`
/// with a stale `.ail.json` snapshot of the same module name.
#[test]
fn load_workspace_prefers_ail_when_both_extensions_exist() {
let tmp = tempfile::tempdir().expect("tempdir");
// Entry imports `leaf`.
let entry = tmp.path().join("main.ail");
fs::write(&entry, "(module main (import leaf))\n").expect("write entry");
// Both leaf.ail and leaf.ail.json exist, with different in-source content.
// The `.ail` carries a `(const winner ...)`; the `.ail.json` carries a
// `(const loser ...)`. The loader must pick `winner`.
let leaf_ail = tmp.path().join("leaf.ail");
fs::write(
&leaf_ail,
"(module leaf (const winner (type (con Int)) (body 1)))\n",
)
.expect("write leaf.ail");
let leaf_json = tmp.path().join("leaf.ail.json");
fs::write(
&leaf_json,
r#"{"schema":"ailang/v0","name":"leaf","imports":[],"defs":[{"kind":"const","name":"loser","type":{"k":"con","name":"Int"},"value":{"t":"lit","lit":{"kind":"int","value":2}}}]}"#,
)
.expect("write leaf.ail.json");
let ws = load_workspace(&entry).expect("workspace loads with both extensions present");
let leaf = &ws.modules["leaf"];
let names: Vec<&str> = leaf
.defs
.iter()
.map(|d| ailang_core::def_name(d))
.collect();
assert!(
names.contains(&"winner"),
"expected `.ail` content (def `winner`); got defs: {names:?}"
);
assert!(
!names.contains(&"loser"),
"must NOT have loaded `.ail.json` content (def `loser`); got defs: {names:?}"
);
}
+10
View File
@@ -445,6 +445,16 @@ on the shelf for a future iter only if both fail.
existing `ail render`. **`.ail.json` remains a first-class input**
to every existing subcommand; the parser is a producer, not a
gatekeeper.
**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.
- Stdlib (`std_list`, `std_maybe`, ...) authored in
form (A) from day one **because that is the AI authoring
projection**, not because JSON authoring is forbidden. The
+137
View File
@@ -0,0 +1,137 @@
# 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<F>` 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<F>(entry, loader: F)` with `F:
Fn(&Path) -> Result<Module, WorkspaceLoadError> + 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
+1
View File
@@ -29,3 +29,4 @@
- 2026-05-12 — iter ms.2: Qwen3-Coder-Next retroactive re-run + first CodeLlama-13b-Instruct run via IONOS; DESIGN.md §Decision-6 addendum extended from 2-col single-subject to 4-col two-subject; both subjects agree on direction (AILX cheaper + ≥ green); roadmap P3 multi-subject entry removed → 2026-05-12-iter-ms.2.md
- 2026-05-12 — audit-ms: milestone close (Multi-subject Authoring-Form Test — CodeLlama Replication) — architect clean, bench all-green (same 5-metric latency.explicit_at_rc improvement cluster as audit-cma earlier today, baseline left pristine for second consecutive audit) → 2026-05-12-audit-ms.md
- 2026-05-12 — iter ext-rename: `.ailx``.ail` extension rename across the live toolchain (61 example renames + 35 content edits + experiment-crate cohort rename `ailx → ail`); historical docs (journals, archive, specs, plans, frozen experiment runs) deliberately untouched; cargo+bench all-green; opens follow-up `.ail`-CLI-acceptance iter → 2026-05-12-iter-ext-rename.md
- 2026-05-12 — iter ext-cli.1: `ail check`/build/run/...all 12 path-taking subcommands now accept `.ail` (Form A) inputs via `ailang_surface::{load_module, load_workspace}` (extension-dispatching loaders + injection point `core::load_workspace_with<F>`); workspace imports prefer `.ail` sibling over `.ail.json`; new `WorkspaceLoadError::SurfaceParse` variant routes surface parse errors as `surface-parse-error` structured diagnostics through `ail check --json`; DESIGN.md §Decision 6 CLI addendum; +7 new tests; closes the loop opened by iter ext-rename → 2026-05-12-iter-ext-cli.1.md
-10
View File
@@ -182,16 +182,6 @@ context. Pick the next milestone from P1.)_
- context: fieldtest 2026-05-11 — fieldtest fixtures could not be
placed under `examples/fieldtest/` because of this; predates the
canonical-type-names milestone but surfaces every time.
- [ ] **\[todo\]** `ail check`/`build`/`run` accept `.ail` extension —
today `ail check foo.ail` produces a misleading JSON-parse error
(`json: expected value at line 1 column 1`) because the loader only
accepts `.ail.json`. Either teach the CLI subcommands to auto-parse
`.ail` internally, or detect the extension and emit a
"did you mean `ail parse foo.ail`?" hint. The LLM-author's natural
first command should not be the one that produces a misleading
diagnostic.
- context: fieldtest 2026-05-11 — orthogonal to canonical-type-names
but every fieldtest invocation hits this on its first command.
- [ ] **\[todo\]** `check_in_workspace` per-module overlay narrowing —
`crates/ailang-check/src/lib.rs:1234` still clears+rebuilds
`env.ctor_index` per-module; ct.3.2 narrowed the analogous mono