diff --git a/crates/ail/src/main.rs b/crates/ail/src/main.rs index 55cd434..01cca82 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_surface::load_workspace(&path)?; + let ws = load_workspace_human(&path)?; let mut entries: Vec<(String, &ailang_core::Def)> = Vec::new(); for (mod_name, m) in &ws.modules { for d in &m.defs { @@ -527,7 +527,7 @@ fn main() -> Result<()> { } Cmd::Describe { path, name, json, workspace } => { if workspace { - let ws = ailang_surface::load_workspace(&path)?; + let ws = load_workspace_human(&path)?; let (mod_name, def) = resolve_describe_name(&ws, &name)?; if json { // We pass the def itself through and add the @@ -608,7 +608,7 @@ fn main() -> Result<()> { std::process::exit(1); } } else { - let ws = ailang_surface::load_workspace(&path)?; + let ws = load_workspace_human(&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_surface::load_workspace(&path)?; + let ws = load_workspace_human(&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_surface::load_workspace(&a)?; - let ws_b = ailang_surface::load_workspace(&b)?; + let ws_a = load_workspace_human(&a)?; + let ws_b = load_workspace_human(&b)?; let report = build_workspace_diff(&ws_a, &ws_b); if json { let v = workspace_diff_report_to_json(&report); @@ -771,7 +771,7 @@ fn main() -> Result<()> { } } Cmd::Workspace { entry, json } => { - let ws = ailang_surface::load_workspace(&entry)?; + let ws = load_workspace_human(&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_surface::load_workspace(&path)?; + let ws = load_workspace_human(&path)?; // `--of NAME`: optional, accepts dot notation // (`.`) or a bare name (matches in all @@ -1237,7 +1237,8 @@ fn workspace_error_to_diagnostic( "bare-cross-module-type-ref", format!( "module `{module}` contains bare type name `{name}` that does not resolve to a local type; \ - candidates from imports: {candidates:?}" + candidates from imports: {candidates:?}. \ + Run `ail migrate-canonical-types` to fix legacy fixtures." ), ) .with_ctx(serde_json::json!({ @@ -1326,6 +1327,42 @@ fn workspace_error_to_diagnostic( } } +/// Iter cli-diag-human (2026-05-14): loads a workspace and, on +/// `WorkspaceLoadError`, routes through `workspace_error_to_diagnostic` +/// to print a stderr line matching the JSON path's `[code]`-bracketed +/// format before exiting non-zero. Pure I/O errors have no diagnostic +/// equivalent (see `workspace_error_to_diagnostic` returning `None` +/// for `W::Io`) and still propagate as anyhow errors so the caller's +/// `?`-flow stays well-behaved. +/// +/// Replaces the bare `ailang_surface::load_workspace()?` shape +/// in every non-JSON CLI subcommand. The JSON path of `ail check` +/// (which builds a structured diagnostics array on stdout) is the +/// one site that keeps the explicit `match` against the loader +/// result — it does not call this helper. +fn load_workspace_human( + path: &Path, +) -> Result { + match ailang_surface::load_workspace(path) { + Ok(ws) => Ok(ws), + Err(e) => match workspace_error_to_diagnostic(&e) { + Some(d) => { + eprintln!( + "error: [{}] {}{}", + d.code, + d.def + .as_ref() + .map(|n| format!("{n}: ")) + .unwrap_or_default(), + d.message, + ); + std::process::exit(1); + } + None => Err(anyhow::anyhow!(e)), + }, + } +} + /// Collects the *external* references of a definition: everything its body /// depends on that lives outside the def itself. Filtered out: /// @@ -2197,7 +2234,7 @@ fn build_to( opt: &str, alloc: ailang_codegen::AllocStrategy, ) -> Result { - let ws = ailang_surface::load_workspace(path)?; + let ws = load_workspace_human(path)?; let diags = ailang_check::check_workspace(&ws); if !diags.is_empty() { for d in &diags { diff --git a/crates/ail/tests/cli_diag_human_workspace_load_error.rs b/crates/ail/tests/cli_diag_human_workspace_load_error.rs new file mode 100644 index 0000000..5590290 --- /dev/null +++ b/crates/ail/tests/cli_diag_human_workspace_load_error.rs @@ -0,0 +1,73 @@ +//! Iter cli-diag-human (2026-05-14): non-JSON `ail check` and +//! sibling subcommands surface `WorkspaceLoadError` with the same +//! bracketed `[code]` prefix that the JSON path emits. +//! +//! Before this iter, non-JSON CLI subcommands that called +//! `ailang_surface::load_workspace(&path)?` propagated the error +//! through `anyhow`'s Display, producing `Error: ` — +//! missing the bracketed diagnostic code that `--json` consumers +//! and humans alike use to triage. Fix: a `load_workspace_human` +//! helper routes through `workspace_error_to_diagnostic` and +//! prints `error: [] : ` before exiting. +//! +//! These tests pin the bracketed-code surface on a representative +//! workspace-load error (`module-not-found`). They are tempdir- +//! based: the fixture is authored at test time and removed at +//! drop, so no committed fixture is needed (the surface under +//! test is the CLI formatting, not a permanent corpus entry). + +use std::io::Write; +use std::path::PathBuf; +use std::process::Command; + +fn ail_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_ail")) +} + +/// Build a fresh tempdir containing one .ail module that imports a +/// non-existent sibling module. Returns the entry path. The tempdir +/// is leaked deliberately — tests are single-shot and tempdir +/// cleanup is the test harness's concern. +fn tempdir_with_missing_import() -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "ail_cli_diag_human_{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("create tempdir"); + let entry = dir.join("test_missing_import.ail"); + let mut f = std::fs::File::create(&entry).expect("create entry .ail"); + writeln!( + f, + "(module test_missing_import\n (import this_module_does_not_exist)\n (fn main\n (type (fn-type (params) (ret (con Unit)) (effects IO)))\n (params)\n (body (do io/print_str \"hello\"))))" + ).expect("write fixture"); + entry +} + +/// Property: `ail check ` (non-JSON path) on a workspace +/// with a missing import emits a stderr line of shape +/// `error: [module-not-found] ...` and exits non-zero. Pre-iter +/// behaviour was `Error: module ...` without the bracketed code; +/// this is the regression guard against re-introducing that. +#[test] +fn check_non_json_emits_bracketed_module_not_found() { + let fixture = tempdir_with_missing_import(); + let output = Command::new(ail_bin()) + .args(["check", fixture.to_str().unwrap()]) + .output() + .expect("ail binary must launch"); + assert!( + !output.status.success(), + "ail check must exit non-zero on missing import; stderr={}", + String::from_utf8_lossy(&output.stderr), + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("[module-not-found]"), + "expected `[module-not-found]` in stderr, got: {stderr}" + ); + assert!( + stderr.starts_with("error: "), + "expected stderr to start with `error: ` (lowercase, matching the JSON-mode wording), got: {stderr}" + ); +} diff --git a/docs/journals/2026-05-14-iter-cli-diag-human.md b/docs/journals/2026-05-14-iter-cli-diag-human.md new file mode 100644 index 0000000..40c381a --- /dev/null +++ b/docs/journals/2026-05-14-iter-cli-diag-human.md @@ -0,0 +1,72 @@ +# iter cli-diag-human — non-JSON CLI surfaces `WorkspaceLoadError` with bracketed `[code]` + +**Date:** 2026-05-14 +**Started from:** 6755060 (post-rpe.1.tidy) +**Status:** DONE + +## Summary + +Closes the P2 todo "CLI human-mode diagnostic surface for +`WorkspaceLoadError`" surfaced by the ct.1.8 tester (2026-05-11). +Pre-iter, the non-JSON path of every CLI subcommand that called +`ailang_surface::load_workspace(&path)?` propagated the error via +anyhow's Display — producing `Error: ` +on stderr — while the JSON path of `ail check --json` correctly +routed through `workspace_error_to_diagnostic` and emitted the +bracketed `[code]` prefix. The asymmetry made the human-mode +output harder to triage and less consistent across the toolchain. + +Fix: new private helper `load_workspace_human(path) -> Result` +in `crates/ail/src/main.rs` that wraps `ailang_surface::load_workspace`, +threads any `WorkspaceLoadError` through the existing +`workspace_error_to_diagnostic`, formats the resulting `Diagnostic` +into a single stderr line of shape +`error: [] : `, and exits non-zero. Pure I/O +errors (where `workspace_error_to_diagnostic` returns `None` per +the loader's own contract) still propagate as anyhow errors so the +caller's `?`-flow stays well-behaved. + +Nine call sites in `main.rs` swapped from +`ailang_surface::load_workspace()?` to +`load_workspace_human()?`: the `ail check` (non-JSON arm), +`build`, `run`, `emit-ir`, `prose`, `describe`, `deps`, `diff`, +`manifest` paths. The JSON arm of `ail check` (line 596) kept its +explicit `match` shape — it builds a structured diagnostics array +on stdout, which is a different output contract. + +The `BareCrossModuleTypeRef` arm of `workspace_error_to_diagnostic` +gained the existing migration-command hint +("Run `ail migrate-canonical-types` to fix legacy fixtures.") so +that the ct.1.8 actionable-hint test +(`check_human_mode_emits_actionable_message_to_stderr`) stays +green. The hint was previously carried by `WorkspaceLoadError`'s +thiserror Display impl; making `workspace_error_to_diagnostic` the +single source for both JSON and human paths required moving it +into the Diagnostic message body. + +## Files touched + +- `crates/ail/src/main.rs` — new `load_workspace_human` helper + (~25 lines, including doc comment); 9 call-site swaps; one + Diagnostic message gained the migration-hint suffix. +- `crates/ail/tests/cli_diag_human_workspace_load_error.rs` — + new RED-pin test (tempdir-based fixture asserting + `[module-not-found]` in stderr on a missing-import workspace). +- `docs/roadmap.md` — entry struck through. + +## Verification + +- RED test `check_non_json_emits_bracketed_module_not_found` + flipped from RED to GREEN. +- `cargo test --workspace` → 565 / 0 / 3 (was 564 pre-iter; +1 + from this iter's RED pin). +- `cargo clippy --workspace --all-targets` → 0 warnings. +- `cargo doc --workspace --no-deps` → 0 warnings. + +## Concerns + +- (none) + +## Known debt + +- (none) diff --git a/docs/roadmap.md b/docs/roadmap.md index f65aed8..9bbc698 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -214,13 +214,14 @@ context. Pick the next milestone from P1.)_ into one overlay, or stay split. Surfaced during the env-construction unify audit. - context: JOURNAL 2026-05-10 ("Audit close: env-construction unify"); closed by iter ctt.1 — DESIGN.md §"Env construction" anchors the split decision. -- [ ] **\[todo\]** CLI human-mode diagnostic surface for `WorkspaceLoadError` — - non-JSON `ail check` routes ct.1 errors via `anyhow`/`thiserror` - Display (`Error: `) instead of going through - `workspace_error_to_diagnostic`, so the bracketed `[code]` prefix - the JSON path carries is missing. Plausibly applies to every CLI - subcommand that calls `load_workspace(&path)?` directly. - - context: JOURNAL 2026-05-11 ("Iteration ct.1") — surfaced by ct.1.8 tester. +- [x] **\[todo\]** CLI human-mode diagnostic surface for `WorkspaceLoadError`. + Shipped 2026-05-14 as iter cli-diag-human — a new `load_workspace_human` + helper in `crates/ail/src/main.rs` routes 9 non-JSON + `ailang_surface::load_workspace(&path)?` call sites through + `workspace_error_to_diagnostic`, so the bracketed `[code]` prefix is + preserved across `ail check`, `build`, `run`, `emit-ir`, `prose`, + `describe`, `deps`, `diff`, `manifest`. + - context: per-iter journal `docs/journals/2026-05-14-iter-cli-diag-human.md`. - [x] **\[todo\]** Retire dead `KindMismatch` arm — `validate_classdefs`'s `walk_kind_mismatch` path is structurally unreachable through well-formed schema post-ct.1 (the canonical-form validator catches