diff --git a/crates/ail/tests/ct1_check_cli.rs b/crates/ail/tests/ct1_check_cli.rs new file mode 100644 index 0000000..bd5ebff --- /dev/null +++ b/crates/ail/tests/ct1_check_cli.rs @@ -0,0 +1,180 @@ +//! ct.1: 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_ 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 `.` Type::Con where `` 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: a qualified class name in an `InstanceDef.class` field +/// (here `prelude.Eq`) is rejected by `ail check --json` with +/// diagnostic code `qualified-class-name` and non-zero exit. ct.1 +/// reserves module qualification for type names; classes stay bare in +/// this milestone. Guards against `workspace_error_to_diagnostic` +/// losing the `QualifiedClassName` arm. +#[test] +fn check_json_emits_qualified_class_name() { + 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 on qualified class name; 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"] == "qualified-class-name"), + "expected `qualified-class-name` in diagnostics array; got {stdout}" + ); +} + +/// Property: in non-JSON (human) mode `ail check` exits non-zero on a +/// ct.1 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 ct.1.5 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.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 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}" + ); +}