From 79cc78507baafde83eb9c8aed746d2dbca9e3c6f Mon Sep 17 00:00:00 2001 From: Brummel Date: Tue, 12 May 2026 14:45:03 +0200 Subject: [PATCH] iter ext-cli.1: CLI accepts .ail (Form A) sources alongside .ail.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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. --- Cargo.lock | 2 + .../2026-05-12-iter-ext-cli.1.json | 17 +++ crates/ail/src/main.rs | 50 ++++--- crates/ail/tests/ct1_check_cli.rs | 44 ++++++ crates/ail/tests/e2e.rs | 76 ++++++++++ crates/ailang-core/Cargo.toml | 3 + crates/ailang-core/src/workspace.rs | 102 ++++++++++++- crates/ailang-surface/Cargo.toml | 3 + crates/ailang-surface/src/lib.rs | 2 + crates/ailang-surface/src/loader.rs | 71 +++++++++ crates/ailang-surface/tests/loader.rs | 118 +++++++++++++++ docs/DESIGN.md | 10 ++ docs/journals/2026-05-12-iter-ext-cli.1.md | 137 ++++++++++++++++++ docs/journals/INDEX.md | 1 + docs/roadmap.md | 10 -- 15 files changed, 611 insertions(+), 35 deletions(-) create mode 100644 bench/orchestrator-stats/2026-05-12-iter-ext-cli.1.json create mode 100644 crates/ailang-surface/src/loader.rs create mode 100644 crates/ailang-surface/tests/loader.rs create mode 100644 docs/journals/2026-05-12-iter-ext-cli.1.md diff --git a/Cargo.lock b/Cargo.lock index d4d4d55..2c0a966 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", ] diff --git a/bench/orchestrator-stats/2026-05-12-iter-ext-cli.1.json b/bench/orchestrator-stats/2026-05-12-iter-ext-cli.1.json new file mode 100644 index 0000000..a384101 --- /dev/null +++ b/bench/orchestrator-stats/2026-05-12-iter-ext-cli.1.json @@ -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 +} diff --git a/crates/ail/src/main.rs b/crates/ail/src/main.rs index dcd1a75..64979f6 100644 --- a/crates/ail/src/main.rs +++ b/crates/ail/src/main.rs @@ -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 // (`.`) 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 { - 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 { diff --git a/crates/ail/tests/ct1_check_cli.rs b/crates/ail/tests/ct1_check_cli.rs index bd5ebff..9ba295c 100644 --- a/crates/ail/tests/ct1_check_cli.rs +++ b/crates/ail/tests/ct1_check_cli.rs @@ -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"); +} diff --git a/crates/ail/tests/e2e.rs b/crates/ail/tests/e2e.rs index 02932a5..38a5b31 100644 --- a/crates/ail/tests/e2e.rs +++ b/crates/ail/tests/e2e.rs @@ -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 { + 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" + ); +} diff --git a/crates/ailang-core/Cargo.toml b/crates/ailang-core/Cargo.toml index 344cb1e..d1c678f 100644 --- a/crates/ailang-core/Cargo.toml +++ b/crates/ailang-core/Cargo.toml @@ -10,3 +10,6 @@ serde_json.workspace = true blake3.workspace = true thiserror.workspace = true indexmap.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/ailang-core/src/workspace.rs b/crates/ailang-core/src/workspace.rs index 6877183..945ed51 100644 --- a/crates/ailang-core/src/workspace.rs +++ b/crates/ailang-core/src/workspace.rs @@ -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 { + 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( + entry_path: &Path, + loader: F, +) -> Result +where + F: Fn(&Path) -> Result + 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) -> Resultfilename 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 String { } } -fn visit( +fn visit( module: Module, root_dir: &Path, modules: &mut BTreeMap, visiting: &mut Vec, visiting_set: &mut HashSet, -) -> Result<(), WorkspaceLoadError> { + loader: F, +) -> Result<(), WorkspaceLoadError> +where + F: Fn(&Path) -> Result + 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 `.ail` (Form A) over + // `.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 { + 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":[]}}"#) + } } diff --git a/crates/ailang-surface/Cargo.toml b/crates/ailang-surface/Cargo.toml index 38a802d..76536d1 100644 --- a/crates/ailang-surface/Cargo.toml +++ b/crates/ailang-surface/Cargo.toml @@ -8,3 +8,6 @@ license.workspace = true ailang-core.workspace = true serde_json.workspace = true thiserror.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/ailang-surface/src/lib.rs b/crates/ailang-surface/src/lib.rs index eaedd20..756acec 100644 --- a/crates/ailang-surface/src/lib.rs +++ b/crates/ailang-surface/src/lib.rs @@ -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}; diff --git a/crates/ailang-surface/src/loader.rs b/crates/ailang-surface/src/loader.rs new file mode 100644 index 0000000..c0fe6be --- /dev/null +++ b/crates/ailang-surface/src/loader.rs @@ -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 { + 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 `.ail` over +/// `.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 { + load_workspace_with(entry, load_module) +} diff --git a/crates/ailang-surface/tests/loader.rs b/crates/ailang-surface/tests/loader.rs new file mode 100644 index 0000000..b750c28 --- /dev/null +++ b/crates/ailang-surface/tests/loader.rs @@ -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 )` 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 `.ail` (Form A) and `.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:?}" + ); +} diff --git a/docs/DESIGN.md b/docs/DESIGN.md index d621ae8..cfee449 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -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 diff --git a/docs/journals/2026-05-12-iter-ext-cli.1.md b/docs/journals/2026-05-12-iter-ext-cli.1.md new file mode 100644 index 0000000..ccadd5f --- /dev/null +++ b/docs/journals/2026-05-12-iter-ext-cli.1.md @@ -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` 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 diff --git a/docs/journals/INDEX.md b/docs/journals/INDEX.md index aefc679..0e92cdd 100644 --- a/docs/journals/INDEX.md +++ b/docs/journals/INDEX.md @@ -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`); 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 diff --git a/docs/roadmap.md b/docs/roadmap.md index 253eb14..1411d27 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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