iter cli-diag-human: route WorkspaceLoadError through diagnostic in non-JSON path
Closes P2 todo "CLI human-mode diagnostic surface for
WorkspaceLoadError" surfaced by ct.1.8 tester (2026-05-11).
Pre-iter, non-JSON `ail check` (+8 sibling subcommands) propagated
loader errors via anyhow's Display, producing
`Error: <message>` without the bracketed `[code]` prefix the JSON
path emits.
Fix: new private helper `load_workspace_human(path)` in
crates/ail/src/main.rs that wraps ailang_surface::load_workspace,
threads any WorkspaceLoadError through workspace_error_to_diagnostic,
formats the resulting Diagnostic into a single stderr line
`error: [<code>] <def>: <message>`, and exits non-zero. Pure I/O
errors still propagate as anyhow errors.
Nine call sites swapped to the helper (check non-JSON arm, build,
run, emit-ir, prose, describe, deps, diff, manifest). The JSON arm
of `ail check` keeps its explicit `match` — different output
contract (structured stdout array).
The BareCrossModuleTypeRef Diagnostic message gained the existing
migration-command hint ("Run `ail migrate-canonical-types` to fix
legacy fixtures.") so the ct.1.8 actionable-hint test stays green.
The hint previously rode on WorkspaceLoadError's thiserror Display;
making workspace_error_to_diagnostic the single source for both
JSON and human paths required moving it into the Diagnostic body.
RED test crates/ail/tests/cli_diag_human_workspace_load_error.rs
pins the bracketed-code surface on a missing-import workspace
(tempdir-based fixture; no committed corpus addition).
cargo test --workspace: 565/0/3. clippy + doc: zero warnings.
Roadmap entry struck.
This commit is contained in:
+47
-10
@@ -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
|
||||
// (`<module>.<def>`) 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(<path>)?` 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<ailang_core::Workspace> {
|
||||
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<PathBuf> {
|
||||
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 {
|
||||
|
||||
@@ -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: <message>` —
|
||||
//! 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: [<code>] <def>: <message>` 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 <fixture>` (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}"
|
||||
);
|
||||
}
|
||||
@@ -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: <thiserror-formatted-message>`
|
||||
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<Workspace>`
|
||||
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: [<code>] <def>: <message>`, 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(<expr>)?` to
|
||||
`load_workspace_human(<expr>)?`: 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)
|
||||
+8
-7
@@ -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: <message>`) 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
|
||||
|
||||
Reference in New Issue
Block a user