//! 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: mq.1 — 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-mq.1 /// `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-mq.1; 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"); 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 `[]` 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 `[]`. 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 `[]` 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"); }