b586999e81
First iteration of the kernel-extension-mechanics milestone. Ships
the type-scoped `<TypeName>.<member>` resolution path as the
canonical form for type-associated operations, narrows the
`BareCrossModuleTypeRef` / `BadCrossModuleTypeRef` diagnostics from
"bare = strictly local" to "bare = in-scope by any path", migrates
12 std-library example fixtures, and introduces a workspace-wide
normalisation pre-pass `prepare_workspace_for_check` shared between
`check_workspace` and `monomorphise_workspace`.
Architectural discovery during implementation: the plan covered the
`Term::Var` dot-qualified resolver layer plus the workspace
validator's bare-name acceptance, but the migration of bare-form
fixtures exposed five sites where bare vs. qualified type-names
needed symmetric treatment — `Term::Ctor` resolution, `Type::Con`
well-formedness, mono's poly-free-fn name/constraint-count
enumeration, codegen's `lookup_ctor_by_type` bare-name path, and
the upstream desugar-then-qualify composition. Rather than
scattering TypeDef-first ladders across each site, the implementer
centralised the work into one pre-pass that walks every consumer
module's `Type::Con.name` and `Term::Ctor.type_name`, rewriting
bare cross-module references to their qualified `<home>.<Type>`
form. This is symmetric to the pre-existing `qualify_local_types`
(owner-side); the new pre-pass is the consumer-side mirror.
Downstream passes see qualified Types regardless of authoring form.
The TypeDef-first ladder still lives in `synth`'s `Term::Var` arm
because `<TypeName>.<member>` is term-position-only — `Maybe.from_maybe`
is a Var, not a Type expression, and the pre-pass does not rewrite
Var names.
Alternatives considered:
(a) Add TypeDef-first ladder at every resolution site separately
(the plan's implicit assumption). Rejected: O(N) extension
sites, each carrying the same workspace-walking logic; the
pre-pass version is O(1) — one pass, every downstream consumer
benefits.
(b) BLOCKED + spec re-brainstorm. Rejected: the architecture
extension is consistent with prep.1's thesis (bare type-name
resolves to the workspace-wide TypeDef) and forward-compatible
with prep.2 (Term::New.type_name falls under the same rewrite)
and prep.3 (kernel-tier TypeDefs enter the workspace map
automatically). No design regression to bounce back over.
Spec updated to document the realisation mechanism honestly: the
"Realisation mechanism — workspace pre-pass" subsection clarifies
that the resolver-level semantics described in "Implementation
shape" are the user-facing contract, and the actual code path is
the pre-pass.
Verification:
- `cargo test --workspace`: ALL GREEN. 87 e2e + every crate's unit
+ integration tests pass with no regressions.
- Three NEW in-source tests pin Task 1's resolver paths:
`type_scoped_member_resolves`, `type_scoped_member_not_found`,
`type_scoped_receiver_not_a_type`.
- One NEW workspace test pins the narrowed validator:
`ct1_validator_accepts_bare_with_explicit_import`.
- One renamed-and-flipped existing test:
`ct1_validator_rejects_bare_xmod_with_import_candidate` →
`ct1_validator_accepts_bare_xmod_with_import_candidate` (the
bare-with-import path is now ACCEPTED).
- One NEW companion test for the workspace-wide ctor lookup:
`ct2_term_ctor_bare_cross_module_via_workspace_resolves`.
- Two pre-existing tests' assertions updated for the new error
wording: `ct1_check_cli::check_human_mode_emits_actionable_message_to_stderr`
and `crates/ailang-check/tests/workspace.rs::unknown_module_prefix_is_reported`.
- 12 migrated `.ail` fixtures verified via the existing e2e
suite (each fixture is the test runner's target for an existing
`build_and_run` assertion).
- Negative fixture `ct_2_bare_cross_module.ail` semantically
preserved: dropped its `(import std_maybe)` so bare `Maybe` is
out-of-scope under the narrowed rule and still fires
`BareCrossModuleTypeRef`.
Concerns:
- The pre-pass introduces a new architectural layer (consumer-side
qualification) that the spec did not originally anticipate. Spec
amendment in this commit documents the layer. Future iterations
reference `prepare_workspace_for_check` as established
infrastructure.
- `examples/test_ct1_bare_xmod_rejected.ail.json` switched its
offending name from bare `Ordering` (which under the prep.1
semantics may now resolve via implicit prelude) to a still-
unresolvable `Mystery_Type`. The CLI test's intent (assert that
a human-mode `ail check` exits non-zero on a still-RED case) is
preserved.
Milestone status: kernel-extension-mechanics (Gitea #6) advances
1/3 iters. Next: prep.2 (`Term::New` construct) issue #32.
317 lines
14 KiB
Rust
317 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 `Mystery_Type`,
|
|
/// a name no module in the workspace declares (post-prep.1: in-scope
|
|
/// bare names are accepted, so the fixture uses an unresolvable name
|
|
/// to keep firing the diagnostic) — 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("Mystery_Type"),
|
|
"expected the offending type name in stderr; got {stderr}"
|
|
);
|
|
assert!(
|
|
stderr.contains("not in scope"),
|
|
"expected the narrowed not-in-scope wording 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");
|
|
}
|