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
+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"
);
}