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:
+32
-18
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user