Files
AILang/crates/ail/tests/ct1_check_cli.rs
T
Brummel ac4d545570 source: scrub iter-code / Decision-N residue from inline comments
Follow-up to bcd4181: the remaining ~530 inline `//` and `///`
comments still carrying opaque shorthand are now reformulated to
their content phrases. The only surviving `iter-<code>` reference
in source is the literal filename
`docs/journals/2026-05-13-iter-mq.3.md` (a real journal file).

Sweep covered:
  - `// Iter X.Y: <text>` prefixes (Iter 13a / 14a / 14e / 15g-aux /
    16b.x / 16d / 16e / 18b / 18c.x / 18d.x / 18e / 18g.x / 19a /
    19a.1 / 19b / 20a / 20f / 22-floats.x / 22b.x / 22c / 23.x /
    24.1 / cli-diag-human / hs.x / str-concat / etc.) — fully
    removed; the descriptive text that followed each prefix stays.
  - `// (Decision N)` and `per Decision N` and `Decision N axis 3` —
    replaced with the content phrase plus the relevant contract
    file (`design/contracts/tail-calls.md` for Decision 8,
    `design/contracts/memory-model.md` for Decision 10,
    `design/contracts/typeclasses.md` and `design/models/typeclasses.md`
    for Decision 11, `design/contracts/authoring-surface.md` for
    Decision 6, "the transitional dual-allocator" for Decision 9,
    "Effect prose" for Decision 3).
  - `// mq.X / mq.X (Task N) / mq.X journal / mq.X invariant` ->
    "the canonical-class-form rule / Class-class repurpose /
    method-dispatch-refactor journal / canonical-class-form
    invariant".
  - `// ct.X / ct.X (canonical-type-names) / ct.1.5a + ctt.2 /
    ct.2 Task N` -> "the canonical-form rule for type references /
    the canonical-form normalisation step / canonical-type-lookup
    refactor".
  - `// eob.X` -> "heap-Str-ABI" / "the Str carve-out".
  - `// rpe.X` -> "the per-type-print-op retirement".
  - `// post-mq.X / Pre-ct.X / pre-mq.X` -> "post-canonical-class-form" /
    "Pre-canonical-type-form" etc.
  - `/// Iter X regression: / /// Iter X.Y: / /// Iter A arm-close /
    /// Iter ct.4 (...): / /// Iter rpe.1 ...` -> descriptive
    phrases.

The journal filename in `crates/ailang-core/src/workspace.rs:573`
stays verbatim because it points at an actual file under
`docs/journals/`.

Tests: full `cargo test --workspace` green (80/80 test-result blocks
clean, no FAILED line). design_index_pin 5/5 + docs_honesty_pin 5/5
gating tests pass.
2026-05-20 09:58:04 +02:00

315 lines
14 KiB
Rust

//! E2E coverage for the CLI surface of the canonical-type-names
//! validator. The unit tests in `workspace.rs` already prove the
//! validator fires; these tests prove the diagnostic survives the
//! `WorkspaceLoadError -> Diagnostic` translation in
//! `crates/ail/src/main.rs::workspace_error_to_diagnostic` AND is
//! observable on the CLI in both `--json` and human modes (exit code
//! + diagnostic code in the output stream).
//!
//! Companion to the happy-path test below: post-migration
//! `examples/ordering_match.ail.json` must `ail check` cleanly. If
//! someone reverts the migration (or breaks the canonical-form
//! acceptance path in the registry), that test goes red.
use std::path::PathBuf;
use std::process::Command;
fn ail_bin() -> PathBuf {
// CARGO_BIN_EXE_<name> is set by cargo when building integration tests.
PathBuf::from(env!("CARGO_BIN_EXE_ail"))
}
fn examples_dir() -> PathBuf {
// CARGO_MANIFEST_DIR points at `crates/ail`; examples/ sits at the
// workspace root, two levels up.
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("examples")
}
/// Property: a workspace whose entry module contains a bare
/// cross-module Type::Con / Term::Ctor reference — here `Ordering`,
/// satisfiable only via the auto-injected `prelude` — is rejected by
/// `ail check --json` with diagnostic code `bare-cross-module-type-ref`
/// and non-zero exit. Guards against `workspace_error_to_diagnostic`
/// losing the `BareCrossModuleTypeRef` arm or the validator no longer
/// firing through the CLI loader path.
#[test]
fn check_json_emits_bare_cross_module_type_ref() {
let fixture = examples_dir().join("test_ct1_bare_xmod_rejected.ail.json");
let output = Command::new(ail_bin())
.args(["check", "--json", fixture.to_str().unwrap()])
.output()
.expect("ail binary must launch");
assert!(
!output.status.success(),
"ail check must fail on bare cross-module ref; 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 diags: serde_json::Value =
serde_json::from_str(&stdout).expect("--json mode emits a JSON array on stdout");
let arr = diags.as_array().expect("diagnostics is a JSON array");
assert!(
arr.iter().any(|d| d["code"] == "bare-cross-module-type-ref"),
"expected `bare-cross-module-type-ref` in diagnostics array; got {stdout}"
);
}
/// Property: a qualified `<owner>.<name>` Type::Con where `<owner>` is
/// not a known module is rejected by `ail check --json` with
/// diagnostic code `bad-cross-module-type-ref` and non-zero exit.
/// Guards against `workspace_error_to_diagnostic` losing the
/// `BadCrossModuleTypeRef` arm.
#[test]
fn check_json_emits_bad_cross_module_type_ref() {
let fixture = examples_dir().join("test_ct1_bad_qualifier.ail.json");
let output = Command::new(ail_bin())
.args(["check", "--json", fixture.to_str().unwrap()])
.output()
.expect("ail binary must launch");
assert!(
!output.status.success(),
"ail check must fail on unknown qualifier; 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 diags: serde_json::Value =
serde_json::from_str(&stdout).expect("--json mode emits a JSON array on stdout");
let arr = diags.as_array().expect("diagnostics is a JSON array");
assert!(
arr.iter().any(|d| d["code"] == "bad-cross-module-type-ref"),
"expected `bad-cross-module-type-ref` in diagnostics array; got {stdout}"
);
}
/// Property: with class references in canonical form, a qualified class name in an `InstanceDef.class`
/// field is the canonical form, not a rejection. The
/// `test_ct1_qualified_class_rejected` fixture (declares
/// `instance prelude.Eq Int` outside prelude and outside Int's
/// defining module) is now rejected by the downstream coherence
/// check with `orphan-instance` instead of the pre-canonical-class-form
/// `qualified-class-name`. Guards against
/// `workspace_error_to_diagnostic` losing the OrphanInstance arm
/// AND against any regression that would reintroduce
/// `qualified-class-name` on a referencing field.
#[test]
fn check_json_emits_orphan_instance_on_xmod_class_without_coherence_post_mq1() {
let fixture = examples_dir().join("test_ct1_qualified_class_rejected.ail.json");
let output = Command::new(ail_bin())
.args(["check", "--json", fixture.to_str().unwrap()])
.output()
.expect("ail binary must launch");
assert!(
!output.status.success(),
"ail check must fail; 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 diags: serde_json::Value =
serde_json::from_str(&stdout).expect("--json mode emits a JSON array on stdout");
let arr = diags.as_array().expect("diagnostics is a JSON array");
assert!(
arr.iter().any(|d| d["code"] == "orphan-instance"),
"expected `orphan-instance` in diagnostics array; got {stdout}"
);
assert!(
!arr.iter().any(|d| d["code"] == "qualified-class-name"),
"must NOT fire `qualified-class-name` on a referencing field post-canonical-class-form; got {stdout}"
);
}
/// Property: in non-JSON (human) mode `ail check` exits non-zero on a
/// canonical-type-form validator failure and writes an actionable error message to
/// stderr — naming both the offending type and the migration command
/// the author should run. Pinning the human-mode path separately
/// because in this mode the loader error short-circuits via `anyhow`
/// (no diagnostic-code prefix) and is formatted by the
/// `WorkspaceLoadError`'s `thiserror` Display impl rather than by
/// `workspace_error_to_diagnostic`. A regression that left `--json`
/// working but stripped the actionable hint from the human path
/// would otherwise ship unnoticed. The bare-cross-module fixture is
/// the representative case; the property — actionable hint in the
/// Display impl — is named per-variant.
#[test]
fn check_human_mode_emits_actionable_message_to_stderr() {
let fixture = examples_dir().join("test_ct1_bare_xmod_rejected.ail.json");
let output = Command::new(ail_bin())
.args(["check", fixture.to_str().unwrap()])
.output()
.expect("ail binary must launch");
assert!(
!output.status.success(),
"ail check (human mode) must fail on bare cross-module ref"
);
let stderr = String::from_utf8(output.stderr).expect("stderr is utf-8");
assert!(
stderr.contains("Ordering"),
"expected the offending type name in stderr; got {stderr}"
);
assert!(
stderr.contains("ail migrate-canonical-types"),
"expected the migration-command hint in stderr; got {stderr}"
);
}
/// Property: the post-migration `examples/ordering_match.ail.json`
/// (Term::Ctor `Ordering` -> `prelude.Ordering`) typechecks cleanly
/// through the CLI — `ail check` exits 0 with no diagnostics.
/// Guards against (a) a revert of the canonical-form migration, (b) a
/// regression in `Registry::normalize_type_for_lookup` that would make
/// the canonical (qualified) form fail to dispatch where the bare
/// form used to succeed.
#[test]
fn check_ordering_match_post_migration_is_clean() {
let fixture = examples_dir().join("ordering_match.ail");
let output = Command::new(ail_bin())
.args(["check", "--json", fixture.to_str().unwrap()])
.output()
.expect("ail binary must launch");
assert!(
output.status.success(),
"ail check must succeed on migrated ordering_match; 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 diags: serde_json::Value =
serde_json::from_str(&stdout).expect("--json mode emits a JSON array on stdout");
let arr = diags.as_array().expect("diagnostics is a JSON array");
assert!(
arr.is_empty(),
"expected zero diagnostics on migrated fixture; got {stdout}"
);
}
/// Property: in non-JSON (human) mode, the human-stderr formatter
/// prepends the diagnostic code exactly once as the canonical
/// `[code]` bracket prefix (the cli-diag-human format). The
/// `loop-binder-captured-by-lambda` `CheckError` Display body must
/// NOT also embed `[code] ` inside its `thiserror` Display body,
/// which would render the bracketed code TWICE in the user-visible
/// stderr line (`error: [code] fn: [code] message`).
///
/// This pins the *observable* doubling on the real CLI human path
/// (`crates/ail/src/main.rs` non-JSON `Cmd::Check` arm), not the raw
/// Display string of the variant in isolation: it counts occurrences
/// of the literal `[<code>]` token in the rendered stderr and
/// requires exactly one (the formatter supplies it; the Display body
/// must not also embed it).
#[test]
fn check_human_mode_renders_loop_binder_diagnostic_code_exactly_once() {
let cases = [
(
"test_loop_binder_captured_by_lambda.ail.json",
"loop-binder-captured-by-lambda",
),
];
for (fixture_name, code) in cases {
let fixture = examples_dir().join(fixture_name);
let output = Command::new(ail_bin())
.args(["check", fixture.to_str().unwrap()])
.output()
.expect("ail binary must launch");
assert!(
!output.status.success(),
"ail check (human mode) must fail on {fixture_name}"
);
let stderr = String::from_utf8(output.stderr).expect("stderr is utf-8");
// The canonical cli-diag-human prefix is `[<code>]`. It must
// appear exactly once in the rendered human diagnostic — the
// formatter supplies it; the Display body must not also embed it.
let needle = format!("[{code}]");
let occurrences = stderr.matches(&needle).count();
assert_eq!(
occurrences, 1,
"expected `[{code}]` exactly once in human stderr, found {occurrences}; \
full stderr:\n{stderr}"
);
}
}
/// loop-recur iter 2: the four `Recur*` Display bodies must be
/// bracket-`[code]`-free (F2 convention) — the human-mode
/// formatter supplies `[<code>]` exactly once. Same observable
/// property as the mut sibling, over the four recur negatives.
#[test]
fn check_human_mode_renders_recur_diagnostic_code_exactly_once() {
let cases = [
("test_recur_outside_loop.ail.json", "recur-outside-loop"),
("test_recur_arity_mismatch.ail.json", "recur-arity-mismatch"),
("test_recur_type_mismatch.ail.json", "recur-type-mismatch"),
(
"test_recur_not_in_tail_position.ail.json",
"recur-not-in-tail-position",
),
];
for (fixture_name, code) in cases {
let fixture = examples_dir().join(fixture_name);
let output = Command::new(ail_bin())
.args(["check", fixture.to_str().unwrap()])
.output()
.expect("ail binary must launch");
assert!(
!output.status.success(),
"ail check (human mode) must fail on {fixture_name}"
);
let stderr = String::from_utf8(output.stderr).expect("stderr is utf-8");
let needle = format!("[{code}]");
let occurrences = stderr.matches(&needle).count();
assert_eq!(
occurrences, 1,
"expected `[{code}]` exactly once in human stderr, found {occurrences}; \
full stderr:\n{stderr}"
);
}
}
/// 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");
}