832375f2ac
All 176 files in the four accumulating directories now use a zero-padded 4-digit counter prefix that reflects creation order (`NNNN-slug.md`). The counter is assigned per directory in strict git-log creation order; ties broken alphabetically by original name. The old `YYYY-MM-DD-` prefix on docs/specs/ and docs/plans/ files is dropped — the date is recoverable from git log and the counter carries the ordering. A file's counter is stable for the life of the file: never reassigned, never reused, never compacted. Deleted files retire their counter; subsequent files do not fill the gap. This is the property that lets cross-references stay literal — refs use the full filename including the counter (`design/contracts/0007-honesty-rule.md`) so they grep cleanly and resolve directly without a glob step. 313 cross-references updated across .md/.rs/.toml/.c/.json files (test pins, include_str! paths, design-INDEX entries, baseline notes, runtime C comments, inter-contract markdown links incl. bare basename and `../models/foo.md` forms). CLAUDE.md gets a new "File-naming convention" section spelling out the rule and rationale. skills/brainstorm/SKILL.md and skills/planner/SKILL.md updated so new spec/plan creation produces counter-prefixed names from the start. The full test suite (cargo test --workspace) passes.
1884 lines
68 KiB
Markdown
1884 lines
68 KiB
Markdown
# ct.1 — Canonical Type Names: Validator + Migration — Implementation Plan
|
|
|
|
> **Parent spec:** `docs/specs/0007-canonical-type-names.md` (Components row ct.1)
|
|
>
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: use `skills/implement`
|
|
> to run this plan. Steps use `- [ ]` checkboxes for tracking.
|
|
|
|
**Goal:** Land the `.ail.json` schema validator that enforces the
|
|
canonical-type-names rule (bare = primitive or local-to-this-file;
|
|
qualified = `<owner>.<TypeName>` for cross-module refs; qualified
|
|
class-name fields rejected), plus the one-shot migration tool that
|
|
rewrites the two stale `examples/` fixtures relying on the old
|
|
imports-fallback into the canonical form. After ct.1 the workspace
|
|
loader rejects bare cross-module Type::Con refs with a clear
|
|
diagnostic listing import-derived candidates.
|
|
|
|
**Architecture:** The validator lives in
|
|
`crates/ailang-core/src/workspace.rs` as `validate_canonical_type_names`
|
|
and runs at the end of `load_workspace`, after the prelude is injected
|
|
but before `validate_classdefs` / `build_registry` — so a stale bare
|
|
cross-module ref fires the *new* diagnostic instead of a downstream
|
|
one. Type::Con walking is shared with Term::Ctor.type_name (the
|
|
rules are identical: the field carries a Type::Con name). Class-name
|
|
fields use a simpler check (reject any `'.'`-containing string in
|
|
the 4 fields named by the spec).
|
|
|
|
The migration tool is a hidden CLI subcommand `ail migrate-canonical-types <dir>`
|
|
that walks every `*.ail.json` under `<dir>`, parses each as a Module,
|
|
and rewrites bare cross-module Type::Con names by scanning the import
|
|
graph in declaration order (mirroring iter 23.1.3's
|
|
`Term::Ctor`-synth imports-fallback so the rewrite never silently
|
|
shifts which type a fixture resolves to). The prelude is treated as
|
|
an implicit last-resort fallback (matches iter 23.2.4's implicit-
|
|
prelude behaviour). Idempotent: re-running over a canonical workspace
|
|
is a no-op. The tool is dev-only and not documented in
|
|
`docs/DESIGN.md` — it has one job, runs once at ct.1 close, and is
|
|
preserved as a regression aid (CI re-runs it and asserts no diff).
|
|
|
|
**Tech Stack:** `crates/ailang-core/src/workspace.rs` (validator +
|
|
3 new diagnostic variants), `crates/ail/src/main.rs` (migration
|
|
subcommand), `examples/*.ail.json` (2 fixtures rewritten, 3 new test
|
|
fixtures), `crates/ail/tests/` (one E2E test for migration
|
|
idempotence).
|
|
|
|
---
|
|
|
|
**Files this plan creates or modifies:**
|
|
|
|
- Modify: `crates/ailang-core/src/workspace.rs` — add 3 variants to
|
|
`WorkspaceLoadError` (`BareCrossModuleTypeRef`,
|
|
`BadCrossModuleTypeRef`, `QualifiedClassName`); add
|
|
`validate_canonical_type_names`; call from `load_workspace` after
|
|
prelude injection.
|
|
- Modify: `crates/ail/src/main.rs` — add `Cmd::MigrateCanonicalTypes`
|
|
subcommand handler.
|
|
- Migrate: `examples/ordering_match.ail.json` — `"type":"Ordering"`
|
|
→ `"type":"prelude.Ordering"` (1 occurrence in `Term::Ctor`).
|
|
- Migrate: `examples/test_22b1_dup_a.ail.json` —
|
|
`"name":"MyInt"` (in `Type::Con` inside `InstanceDef.type_`) →
|
|
`"name":"test_22b1_dup_b.MyInt"`.
|
|
- Modify: `examples/test_22b2_kind_mismatch.ail.json` — unchanged;
|
|
but the regression test that loads it must be updated (see Task 6).
|
|
- Create: `examples/test_ct1_bare_xmod_rejected.ail.json` — fixture
|
|
exercising the new diagnostic on a bare Ordering reference.
|
|
- Create: `examples/test_ct1_bad_qualifier.ail.json` — fixture
|
|
exercising `BadCrossModuleTypeRef` on `Mystery.Type`.
|
|
- Create: `examples/test_ct1_qualified_class_rejected.ail.json` —
|
|
fixture exercising `QualifiedClassName` on
|
|
`InstanceDef.class`.
|
|
- Modify: `crates/ailang-core/src/workspace.rs::mod tests` — new
|
|
unit tests per spec §Testing strategy; update the kind-mismatch
|
|
test to expect `BareCrossModuleTypeRef` instead of `KindMismatch`
|
|
(validator fires first).
|
|
|
|
---
|
|
|
|
## Task 1: New error variants + `validate_canonical_type_names` scaffolding + Type::Con validation
|
|
|
|
**Subject:** add 3 diagnostic variants and the validator function;
|
|
implement the Type::Con rule end-to-end. Term::Ctor and class-name
|
|
checks land in Tasks 2 and 3.
|
|
|
|
**Files:**
|
|
- Modify: `crates/ailang-core/src/workspace.rs` — error enum (after
|
|
line 291, the existing `ReservedModuleName` variant); fn body
|
|
appended at end of `impl`-block region (`~line 720` after
|
|
`walk_kind_mismatch`).
|
|
- Test: `crates/ailang-core/src/workspace.rs::mod tests` — six new
|
|
unit tests covering the cases enumerated in spec §Testing strategy.
|
|
|
|
- [ ] **Step 1: Write the failing tests**
|
|
|
|
Append to `crates/ailang-core/src/workspace.rs::mod tests`, after the
|
|
existing tests. These tests construct a synthetic Module in-memory
|
|
(via `serde_json::json!` → `serde_json::from_value`) and run the
|
|
validator directly so they do not depend on file-system fixtures:
|
|
|
|
```rust
|
|
/// ct.1: a `Type::Con` whose `name` is a primitive (`Int` / `Bool` /
|
|
/// `Str` / `Unit` / `Float`) must be accepted bare. The five
|
|
/// primitive names are the only legal bare-non-local Type::Con names
|
|
/// under the canonical-form rule.
|
|
#[test]
|
|
fn ct1_validator_accepts_primitive_type_cons() {
|
|
let modules = single_module_with_type_con("m", "Int");
|
|
validate_canonical_type_names(&modules).expect("Int must be accepted");
|
|
let modules = single_module_with_type_con("m", "Float");
|
|
validate_canonical_type_names(&modules).expect("Float must be accepted");
|
|
}
|
|
|
|
/// ct.1: a bare Type::Con whose `name` matches a local TypeDef in
|
|
/// the same module must be accepted (the canonical-form rule: bare =
|
|
/// local).
|
|
#[test]
|
|
fn ct1_validator_accepts_bare_local_type_con() {
|
|
let modules = single_module_with_local_type_and_ref("m", "Foo");
|
|
validate_canonical_type_names(&modules)
|
|
.expect("local Foo must be accepted");
|
|
}
|
|
|
|
/// ct.1: a bare Type::Con whose `name` is neither a primitive nor a
|
|
/// local TypeDef must fire `BareCrossModuleTypeRef`. With no imports,
|
|
/// the candidates list is empty.
|
|
#[test]
|
|
fn ct1_validator_rejects_bare_xmod_no_imports() {
|
|
let modules = single_module_with_type_con("m", "Ordering");
|
|
let err = validate_canonical_type_names(&modules)
|
|
.expect_err("Ordering must be rejected");
|
|
match err {
|
|
WorkspaceLoadError::BareCrossModuleTypeRef { module, name, candidates } => {
|
|
assert_eq!(module, "m");
|
|
assert_eq!(name, "Ordering");
|
|
assert!(candidates.is_empty(),
|
|
"no imports => no candidates; got {candidates:?}");
|
|
}
|
|
other => panic!("expected BareCrossModuleTypeRef, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
/// ct.1: a bare Type::Con whose `name` resolves to one imported
|
|
/// module's TypeDef must fire `BareCrossModuleTypeRef` with that
|
|
/// qualified form in `candidates` (so the diagnostic can suggest the
|
|
/// fix).
|
|
#[test]
|
|
fn ct1_validator_rejects_bare_xmod_with_import_candidate() {
|
|
let mut modules = BTreeMap::new();
|
|
modules.insert("other".to_string(), module_with_type_def("other", "Ordering"));
|
|
modules.insert("m".to_string(), module_with_import_and_type_con("m", "other", "Ordering"));
|
|
let err = validate_canonical_type_names(&modules)
|
|
.expect_err("Ordering must be rejected with candidate");
|
|
match err {
|
|
WorkspaceLoadError::BareCrossModuleTypeRef { name, candidates, .. } => {
|
|
assert_eq!(name, "Ordering");
|
|
assert_eq!(candidates, vec!["other.Ordering".to_string()]);
|
|
}
|
|
other => panic!("expected BareCrossModuleTypeRef, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
/// ct.1: a qualified Type::Con `<owner>.<type>` where `<owner>` is a
|
|
/// known module AND `<type>` is one of its TypeDefs must be accepted.
|
|
#[test]
|
|
fn ct1_validator_accepts_qualified_xmod_ref() {
|
|
let mut modules = BTreeMap::new();
|
|
modules.insert("other".to_string(), module_with_type_def("other", "Ordering"));
|
|
modules.insert("m".to_string(), module_with_import_and_type_con("m", "other", "other.Ordering"));
|
|
validate_canonical_type_names(&modules)
|
|
.expect("other.Ordering must be accepted");
|
|
}
|
|
|
|
/// ct.1: a qualified Type::Con `<owner>.<type>` where `<owner>` is
|
|
/// NOT a known module must fire `BadCrossModuleTypeRef`. Symmetric
|
|
/// case: `<owner>` known but no TypeDef `<type>` in it.
|
|
#[test]
|
|
fn ct1_validator_rejects_bad_qualified_ref() {
|
|
let modules = single_module_with_type_con("m", "Mystery.Type");
|
|
let err = validate_canonical_type_names(&modules)
|
|
.expect_err("Mystery.Type must be rejected");
|
|
match err {
|
|
WorkspaceLoadError::BadCrossModuleTypeRef { module, name } => {
|
|
assert_eq!(module, "m");
|
|
assert_eq!(name, "Mystery.Type");
|
|
}
|
|
other => panic!("expected BadCrossModuleTypeRef, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
/// ct.1: a Type::Con embedded inside a `Term::Lam.param_tys` is a
|
|
/// Type-position occurrence, just inside a Term tree. The validator
|
|
/// must walk into Lam-internal types so an LLM author can't smuggle
|
|
/// a bare cross-module ref past it by hiding it in a lambda
|
|
/// annotation.
|
|
#[test]
|
|
fn ct1_validator_walks_lam_embedded_types() {
|
|
let m: Module = serde_json::from_value(serde_json::json!({
|
|
"schema": crate::SCHEMA,
|
|
"name": "m",
|
|
"imports": [],
|
|
"defs": [{
|
|
"kind": "fn",
|
|
"name": "f",
|
|
"type": { "k": "fn", "params": [], "ret": { "k": "con", "name": "Unit" }, "effects": [] },
|
|
"params": [],
|
|
"body": {
|
|
"t": "lam",
|
|
"params": ["x"],
|
|
"paramTypes": [{ "k": "con", "name": "Ordering" }],
|
|
"retType": { "k": "con", "name": "Unit" },
|
|
"effects": [],
|
|
"body": { "t": "lit", "lit": { "kind": "unit" } }
|
|
}
|
|
}],
|
|
})).unwrap();
|
|
let mut modules = BTreeMap::new();
|
|
modules.insert("m".to_string(), m);
|
|
let err = validate_canonical_type_names(&modules)
|
|
.expect_err("Lam-embedded Ordering must be rejected");
|
|
match err {
|
|
WorkspaceLoadError::BareCrossModuleTypeRef { name, .. } => {
|
|
assert_eq!(name, "Ordering");
|
|
}
|
|
other => panic!("expected BareCrossModuleTypeRef, got {other:?}"),
|
|
}
|
|
}
|
|
```
|
|
|
|
Plus three small `fn` helpers in the same `mod tests`:
|
|
|
|
```rust
|
|
fn module_with_type_def(name: &str, type_name: &str) -> Module {
|
|
serde_json::from_value(serde_json::json!({
|
|
"schema": crate::SCHEMA,
|
|
"name": name,
|
|
"imports": [],
|
|
"defs": [
|
|
{ "kind": "type", "name": type_name, "ctors": [] }
|
|
],
|
|
})).unwrap()
|
|
}
|
|
|
|
fn single_module_with_type_con(name: &str, type_con: &str) -> BTreeMap<String, Module> {
|
|
// Module with a single fn whose return type is Type::Con { name: type_con }.
|
|
let m: Module = serde_json::from_value(serde_json::json!({
|
|
"schema": crate::SCHEMA,
|
|
"name": name,
|
|
"imports": [],
|
|
"defs": [{
|
|
"kind": "fn",
|
|
"name": "f",
|
|
"type": { "k": "fn", "params": [], "ret": { "k": "con", "name": type_con }, "effects": [] },
|
|
"params": [],
|
|
"body": { "t": "lit", "lit": { "kind": "unit" } }
|
|
}],
|
|
})).unwrap();
|
|
let mut map = BTreeMap::new();
|
|
map.insert(name.to_string(), m);
|
|
map
|
|
}
|
|
|
|
fn single_module_with_local_type_and_ref(name: &str, type_name: &str) -> BTreeMap<String, Module> {
|
|
let m: Module = serde_json::from_value(serde_json::json!({
|
|
"schema": crate::SCHEMA,
|
|
"name": name,
|
|
"imports": [],
|
|
"defs": [
|
|
{ "kind": "type", "name": type_name, "ctors": [] },
|
|
{ "kind": "fn", "name": "f",
|
|
"type": { "k": "fn", "params": [], "ret": { "k": "con", "name": type_name }, "effects": [] },
|
|
"params": [],
|
|
"body": { "t": "lit", "lit": { "kind": "unit" } } }
|
|
],
|
|
})).unwrap();
|
|
let mut map = BTreeMap::new();
|
|
map.insert(name.to_string(), m);
|
|
map
|
|
}
|
|
|
|
fn module_with_import_and_type_con(name: &str, import: &str, type_con: &str) -> Module {
|
|
serde_json::from_value(serde_json::json!({
|
|
"schema": crate::SCHEMA,
|
|
"name": name,
|
|
"imports": [{ "module": import }],
|
|
"defs": [{
|
|
"kind": "fn",
|
|
"name": "f",
|
|
"type": { "k": "fn", "params": [], "ret": { "k": "con", "name": type_con }, "effects": [] },
|
|
"params": [],
|
|
"body": { "t": "lit", "lit": { "kind": "unit" } }
|
|
}],
|
|
})).unwrap()
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run the tests to verify they fail**
|
|
|
|
Run: `cargo test --workspace -p ailang-core ct1_validator_`
|
|
Expected: FAIL — all six tests fail with "cannot find function `validate_canonical_type_names`".
|
|
|
|
- [ ] **Step 3: Add the three new error variants to `WorkspaceLoadError`**
|
|
|
|
In `crates/ailang-core/src/workspace.rs`, append after the
|
|
`ReservedModuleName` variant (line ~291):
|
|
|
|
```rust
|
|
/// ct.1 (canonical-type-names): a `Type::Con` whose `name` is
|
|
/// neither a primitive nor a local TypeDef of the owning module
|
|
/// was encountered. Under the canonical-form rule, bare =
|
|
/// local; a bare cross-module ref is a schema violation.
|
|
/// `candidates` lists the qualified forms found by scanning the
|
|
/// owning module's imports in declaration order.
|
|
#[error(
|
|
"module `{module}` contains bare type name `{name}` that does not resolve to a local type. \
|
|
AILang's `.ail.json` requires cross-module type references to be qualified. \
|
|
Candidates from imports: {candidates:?}. Run `ail migrate-canonical-types` to fix legacy fixtures."
|
|
)]
|
|
BareCrossModuleTypeRef {
|
|
module: String,
|
|
name: String,
|
|
candidates: Vec<String>,
|
|
},
|
|
|
|
/// ct.1: a qualified `Type::Con` of the form `<owner>.<type>`
|
|
/// was encountered, but `<owner>` is not a known module in the
|
|
/// workspace, or `<owner>` is known but declares no TypeDef
|
|
/// named `<type>`.
|
|
#[error(
|
|
"module `{module}` references qualified type `{name}` but the owner module is not known \
|
|
or does not declare a type by that name"
|
|
)]
|
|
BadCrossModuleTypeRef {
|
|
module: String,
|
|
name: String,
|
|
},
|
|
|
|
/// ct.1: a class-reference field (`InstanceDef.class`,
|
|
/// `SuperclassRef.class`, `Constraint.class`, or
|
|
/// `ClassDef.name`) contains a `.` — under this milestone class
|
|
/// names are NOT module-qualified (see DESIGN spec §"Out of
|
|
/// scope: Class names"). The schema rejects qualified forms so
|
|
/// half-migrated files cannot silently load.
|
|
#[error(
|
|
"module `{module}` contains qualified class name `{name}` in field `{field}`. \
|
|
Class names are not module-qualified in this milestone; \
|
|
keep the bare form."
|
|
)]
|
|
QualifiedClassName {
|
|
module: String,
|
|
name: String,
|
|
field: &'static str,
|
|
},
|
|
```
|
|
|
|
- [ ] **Step 4: Add the validator function (Type::Con + primitive rules only)**
|
|
|
|
First, extend the existing top-of-file `use` line in
|
|
`crates/ailang-core/src/workspace.rs` from
|
|
|
|
```rust
|
|
use crate::ast::{ClassDef, Def, InstanceDef, Module, Type};
|
|
```
|
|
|
|
to
|
|
|
|
```rust
|
|
use crate::ast::{ClassDef, Def, InstanceDef, Module, Term, Type};
|
|
```
|
|
|
|
(the new `Term` import is needed by `walk_term_embedded_types` below).
|
|
|
|
Then append at the end of `crates/ailang-core/src/workspace.rs`, after
|
|
`walk_kind_mismatch` and before `fn type_head_name`:
|
|
|
|
```rust
|
|
/// ct.1: the five primitive type names that are always bare under
|
|
/// the canonical-form rule. Kept in sync with `Type::int`,
|
|
/// `Type::bool_`, `Type::str_`, `Type::unit`, `Type::float` in
|
|
/// `crate::ast`.
|
|
fn is_primitive_type_name(name: &str) -> bool {
|
|
matches!(name, "Int" | "Bool" | "Str" | "Unit" | "Float")
|
|
}
|
|
|
|
/// ct.1: enforce the canonical-form rule on every `Type::Con` and
|
|
/// `Term::Ctor.type_name` reference in every loaded module. Runs
|
|
/// after prelude injection and before class-schema validation, so a
|
|
/// stale bare cross-module ref fires the canonical-form diagnostic
|
|
/// rather than a downstream one.
|
|
///
|
|
/// Rule per spec §Architecture:
|
|
/// 1. Qualified `<owner>.<type>`: `<owner>` must be a known module,
|
|
/// `<type>` must be one of its TypeDefs. Else `BadCrossModuleTypeRef`.
|
|
/// 2. Bare primitive (`Int`/`Bool`/`Str`/`Unit`/`Float`): accepted.
|
|
/// 3. Bare non-primitive: must be a TypeDef in the owning module.
|
|
/// Else `BareCrossModuleTypeRef` with `candidates` = qualified
|
|
/// forms found by scanning the owning module's imports.
|
|
pub(crate) fn validate_canonical_type_names(
|
|
modules: &BTreeMap<String, Module>,
|
|
) -> Result<(), WorkspaceLoadError> {
|
|
// Pre-pass: for each module, build a `BTreeSet<String>` of local
|
|
// TypeDef names. Used for both the owning-module local lookup
|
|
// and the per-import owner lookup.
|
|
let mut local_types: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
|
|
for (mod_name, m) in modules {
|
|
let mut s = BTreeSet::new();
|
|
for def in &m.defs {
|
|
if let Def::Type(t) = def {
|
|
s.insert(t.name.clone());
|
|
}
|
|
}
|
|
local_types.insert(mod_name.clone(), s);
|
|
}
|
|
|
|
for (mod_name, m) in modules {
|
|
// Imports in declaration order — used for both the
|
|
// qualified-`<owner>` known-module check and the bare-non-primitive
|
|
// candidates list.
|
|
let import_names: Vec<&str> = m.imports.iter().map(|i| i.module.as_str()).collect();
|
|
|
|
// Walk every Type in this module's defs and check each Type::Con name.
|
|
for def in &m.defs {
|
|
walk_def_types(def, &mut |t: &Type| {
|
|
if let Type::Con { name, .. } = t {
|
|
check_type_con_name(
|
|
name, mod_name, &local_types, &import_names,
|
|
)?;
|
|
}
|
|
Ok(())
|
|
})?;
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// ct.1: apply the canonical-form rule to one `Type::Con.name`
|
|
/// (also reused for `Term::Ctor.type_name` in Task 2).
|
|
fn check_type_con_name(
|
|
name: &str,
|
|
owning_module: &str,
|
|
local_types: &BTreeMap<String, BTreeSet<String>>,
|
|
import_names: &[&str],
|
|
) -> Result<(), WorkspaceLoadError> {
|
|
if let Some((prefix, suffix)) = name.split_once('.') {
|
|
// Rule 1: qualified.
|
|
let owner_types = local_types
|
|
.get(prefix)
|
|
.ok_or_else(|| WorkspaceLoadError::BadCrossModuleTypeRef {
|
|
module: owning_module.to_string(),
|
|
name: name.to_string(),
|
|
})?;
|
|
if !owner_types.contains(suffix) {
|
|
return Err(WorkspaceLoadError::BadCrossModuleTypeRef {
|
|
module: owning_module.to_string(),
|
|
name: name.to_string(),
|
|
});
|
|
}
|
|
return Ok(());
|
|
}
|
|
// Bare.
|
|
if is_primitive_type_name(name) {
|
|
return Ok(()); // Rule 2.
|
|
}
|
|
// Rule 3: must be local.
|
|
if local_types
|
|
.get(owning_module)
|
|
.map(|s| s.contains(name))
|
|
.unwrap_or(false)
|
|
{
|
|
return Ok(());
|
|
}
|
|
// Bare cross-module — collect candidates from imports in declaration order.
|
|
// Prelude is implicit: scan it last as a fallback candidate (mirrors
|
|
// iter 23.2.4's implicit-prelude behaviour in codegen).
|
|
let mut candidates: Vec<String> = Vec::new();
|
|
for imp in import_names {
|
|
if local_types
|
|
.get(*imp)
|
|
.map(|s| s.contains(name))
|
|
.unwrap_or(false)
|
|
{
|
|
candidates.push(format!("{imp}.{name}"));
|
|
}
|
|
}
|
|
if !import_names.contains(&"prelude")
|
|
&& local_types
|
|
.get("prelude")
|
|
.map(|s| s.contains(name))
|
|
.unwrap_or(false)
|
|
{
|
|
candidates.push(format!("prelude.{name}"));
|
|
}
|
|
Err(WorkspaceLoadError::BareCrossModuleTypeRef {
|
|
module: owning_module.to_string(),
|
|
name: name.to_string(),
|
|
candidates,
|
|
})
|
|
}
|
|
|
|
/// ct.1: walk every `Type` reachable from a single `Def`, calling
|
|
/// `f` on each. Recurses into `Type::Fn.params/ret`, `Type::Con.args`,
|
|
/// `Type::Forall.constraints/body`, plus the obvious top-level fields
|
|
/// of each `Def` variant AND every Type annotation embedded in a Term
|
|
/// (`Term::Lam.param_tys`, `Term::Lam.ret_ty`, `Term::LetRec.ty`) —
|
|
/// because those are Type-position occurrences too. Term::Ctor name
|
|
/// walking is a separate concern handled in Task 2 since that field
|
|
/// is a `String`, not a `Type`.
|
|
fn walk_def_types<F>(def: &Def, f: &mut F) -> Result<(), WorkspaceLoadError>
|
|
where
|
|
F: FnMut(&Type) -> Result<(), WorkspaceLoadError>,
|
|
{
|
|
match def {
|
|
Def::Fn(fd) => {
|
|
walk_type(&fd.ty, f)?;
|
|
walk_term_embedded_types(&fd.body, f)
|
|
}
|
|
Def::Const(cd) => {
|
|
walk_type(&cd.ty, f)?;
|
|
walk_term_embedded_types(&cd.value, f)
|
|
}
|
|
Def::Type(td) => {
|
|
for c in &td.ctors {
|
|
for fty in &c.fields {
|
|
walk_type(fty, f)?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
Def::Class(cd) => {
|
|
for cm in &cd.methods {
|
|
walk_type(&cm.ty, f)?;
|
|
if let Some(body) = &cm.default {
|
|
walk_term_embedded_types(body, f)?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
Def::Instance(id) => {
|
|
walk_type(&id.type_, f)?;
|
|
for im in &id.methods {
|
|
walk_term_embedded_types(&im.body, f)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
}
|
|
|
|
/// ct.1: walk a `Term` and call `f` on every Type annotation embedded
|
|
/// in it (Lam.param_tys, Lam.ret_ty, LetRec.ty). Recurses through
|
|
/// every Term sub-position. Does NOT fire on `Term::Ctor.type_name`
|
|
/// (that's a `String`, not a `Type`; handled by `walk_def_terms` in
|
|
/// Task 2).
|
|
fn walk_term_embedded_types<F>(t: &Term, f: &mut F) -> Result<(), WorkspaceLoadError>
|
|
where
|
|
F: FnMut(&Type) -> Result<(), WorkspaceLoadError>,
|
|
{
|
|
match t {
|
|
Term::Lit { .. } | Term::Var { .. } => Ok(()),
|
|
Term::App { callee, args, .. } => {
|
|
walk_term_embedded_types(callee, f)?;
|
|
for a in args { walk_term_embedded_types(a, f)?; }
|
|
Ok(())
|
|
}
|
|
Term::Let { value, body, .. } => {
|
|
walk_term_embedded_types(value, f)?;
|
|
walk_term_embedded_types(body, f)
|
|
}
|
|
Term::LetRec { ty, body, in_term, .. } => {
|
|
walk_type(ty, f)?;
|
|
walk_term_embedded_types(body, f)?;
|
|
walk_term_embedded_types(in_term, f)
|
|
}
|
|
Term::If { cond, then, else_ } => {
|
|
walk_term_embedded_types(cond, f)?;
|
|
walk_term_embedded_types(then, f)?;
|
|
walk_term_embedded_types(else_, f)
|
|
}
|
|
Term::Do { args, .. } => {
|
|
for a in args { walk_term_embedded_types(a, f)?; }
|
|
Ok(())
|
|
}
|
|
Term::Ctor { args, .. } => {
|
|
for a in args { walk_term_embedded_types(a, f)?; }
|
|
Ok(())
|
|
}
|
|
Term::Match { scrutinee, arms } => {
|
|
walk_term_embedded_types(scrutinee, f)?;
|
|
for arm in arms {
|
|
walk_term_embedded_types(&arm.body, f)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
Term::Lam { param_tys, ret_ty, body, .. } => {
|
|
for pt in param_tys { walk_type(pt, f)?; }
|
|
walk_type(ret_ty, f)?;
|
|
walk_term_embedded_types(body, f)
|
|
}
|
|
Term::Seq { lhs, rhs } => {
|
|
walk_term_embedded_types(lhs, f)?;
|
|
walk_term_embedded_types(rhs, f)
|
|
}
|
|
Term::Clone { value } => walk_term_embedded_types(value, f),
|
|
Term::ReuseAs { source, body } => {
|
|
walk_term_embedded_types(source, f)?;
|
|
walk_term_embedded_types(body, f)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// ct.1: recursive walk of a `Type`, calling `f` at every node.
|
|
fn walk_type<F>(t: &Type, f: &mut F) -> Result<(), WorkspaceLoadError>
|
|
where
|
|
F: FnMut(&Type) -> Result<(), WorkspaceLoadError>,
|
|
{
|
|
f(t)?;
|
|
match t {
|
|
Type::Con { args, .. } => {
|
|
for a in args {
|
|
walk_type(a, f)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
Type::Fn { params, ret, .. } => {
|
|
for p in params {
|
|
walk_type(p, f)?;
|
|
}
|
|
walk_type(ret, f)
|
|
}
|
|
Type::Forall { body, constraints, .. } => {
|
|
for c in constraints {
|
|
walk_type(&c.type_, f)?;
|
|
}
|
|
walk_type(body, f)
|
|
}
|
|
Type::Var { .. } => Ok(()),
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Run the tests to verify they pass**
|
|
|
|
Run: `cargo test --workspace -p ailang-core ct1_validator_`
|
|
Expected: PASS — all six tests green.
|
|
|
|
- [ ] **Step 6: Run the workspace's full test suite to confirm no regressions**
|
|
|
|
Run: `cargo build --workspace && cargo test --workspace -p ailang-core`
|
|
Expected: PASS. The validator is NOT yet wired into `load_workspace`,
|
|
so existing fixtures continue to load.
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
git add crates/ailang-core/src/workspace.rs
|
|
git commit -m "iter ct.1.1: validator scaffolding + Type::Con canonical-form rules"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: Extend the validator to walk `Term::Ctor.type_name`
|
|
|
|
**Subject:** the canonical-form rule on `Type::Con.name` is identical
|
|
to the rule on `Term::Ctor.type_name` — both fields name a TypeDef.
|
|
This task plumbs the existing `check_type_con_name` through Term
|
|
walking so a `Term::Ctor { type: "Ordering", ... }` inside a fn body
|
|
also fires the diagnostic.
|
|
|
|
**Files:**
|
|
- Modify: `crates/ailang-core/src/workspace.rs` — add `walk_def_terms`
|
|
+ `walk_term_types_and_ctor_names`; call from
|
|
`validate_canonical_type_names`.
|
|
- Test: append one new unit test exercising the Term::Ctor path.
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
Append to `crates/ailang-core/src/workspace.rs::mod tests`:
|
|
|
|
```rust
|
|
/// ct.1: a `Term::Ctor` whose `type_name` is a bare cross-module ref
|
|
/// must fire `BareCrossModuleTypeRef`. Symmetric to the Type::Con
|
|
/// rule but the field lives on Term, not Type.
|
|
#[test]
|
|
fn ct1_validator_rejects_bare_term_ctor_type_name() {
|
|
let mut modules = BTreeMap::new();
|
|
modules.insert("prelude".to_string(),
|
|
module_with_type_def("prelude", "Ordering"));
|
|
// Module `m` has no imports, no local Ordering, but a Term::Ctor
|
|
// referencing bare `Ordering`.
|
|
let m: Module = serde_json::from_value(serde_json::json!({
|
|
"schema": crate::SCHEMA,
|
|
"name": "m",
|
|
"imports": [],
|
|
"defs": [{
|
|
"kind": "fn",
|
|
"name": "f",
|
|
"type": { "k": "fn", "params": [], "ret": { "k": "con", "name": "Unit" }, "effects": [] },
|
|
"params": [],
|
|
"body": {
|
|
"t": "ctor",
|
|
"type": "Ordering",
|
|
"ctor": "LT",
|
|
"args": []
|
|
}
|
|
}],
|
|
})).unwrap();
|
|
modules.insert("m".to_string(), m);
|
|
let err = validate_canonical_type_names(&modules)
|
|
.expect_err("bare Term::Ctor type must be rejected");
|
|
match err {
|
|
WorkspaceLoadError::BareCrossModuleTypeRef { module, name, candidates } => {
|
|
assert_eq!(module, "m");
|
|
assert_eq!(name, "Ordering");
|
|
assert_eq!(candidates, vec!["prelude.Ordering".to_string()],
|
|
"prelude is implicit-fallback");
|
|
}
|
|
other => panic!("expected BareCrossModuleTypeRef, got {other:?}"),
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run the test to verify it fails**
|
|
|
|
Run: `cargo test --workspace -p ailang-core ct1_validator_rejects_bare_term_ctor`
|
|
Expected: FAIL — Term::Ctor walking is not yet implemented, so the
|
|
validator does not visit `body.ctor.type` and the call returns Ok(()).
|
|
|
|
- [ ] **Step 3: Add Term walking to the validator**
|
|
|
|
Extend `validate_canonical_type_names` in
|
|
`crates/ailang-core/src/workspace.rs` (immediately after the
|
|
`walk_def_types` call for Type-side coverage, in the per-def loop):
|
|
|
|
```rust
|
|
walk_def_terms(def, &mut |type_name: &str| {
|
|
check_type_con_name(
|
|
type_name, mod_name, &local_types, &import_names,
|
|
)
|
|
})?;
|
|
```
|
|
|
|
Add these helpers after `walk_type`:
|
|
|
|
```rust
|
|
/// ct.1: walk every `Term::Ctor.type_name` reachable from a single
|
|
/// `Def`, plus every `Type` embedded in `Term::Lam` annotations,
|
|
/// calling `f` on the type_name strings and (separately) on the
|
|
/// embedded types. Used to enforce the canonical-form rule on
|
|
/// term-side type references.
|
|
fn walk_def_terms<F>(def: &Def, f: &mut F) -> Result<(), WorkspaceLoadError>
|
|
where
|
|
F: FnMut(&str) -> Result<(), WorkspaceLoadError>,
|
|
{
|
|
match def {
|
|
Def::Fn(fd) => walk_term(&fd.body, f),
|
|
Def::Const(cd) => walk_term(&cd.value, f),
|
|
Def::Type(_) => Ok(()),
|
|
Def::Class(cd) => {
|
|
for cm in &cd.methods {
|
|
if let Some(body) = &cm.default {
|
|
walk_term(body, f)?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
Def::Instance(id) => {
|
|
for im in &id.methods {
|
|
walk_term(&im.body, f)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
}
|
|
|
|
/// ct.1: recursive walk of a `Term`, calling `f` on every
|
|
/// `Term::Ctor.type_name`. Embedded `Type` annotations (Lam param /
|
|
/// return types, LetRec types) ride the `walk_def_types` path via
|
|
/// the fn-signature's Type::Forall — but Lam-internal types live on
|
|
/// `Term`, not `Def`, so walk them here.
|
|
fn walk_term<F>(t: &Term, f: &mut F) -> Result<(), WorkspaceLoadError>
|
|
where
|
|
F: FnMut(&str) -> Result<(), WorkspaceLoadError>,
|
|
{
|
|
use crate::ast::{Pattern, Term};
|
|
match t {
|
|
Term::Lit { .. } | Term::Var { .. } => Ok(()),
|
|
Term::App { callee, args, .. } => {
|
|
walk_term(callee, f)?;
|
|
for a in args {
|
|
walk_term(a, f)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
Term::Let { value, body, .. } => {
|
|
walk_term(value, f)?;
|
|
walk_term(body, f)
|
|
}
|
|
Term::LetRec { body, in_term, .. } => {
|
|
walk_term(body, f)?;
|
|
walk_term(in_term, f)
|
|
}
|
|
Term::If { cond, then, else_ } => {
|
|
walk_term(cond, f)?;
|
|
walk_term(then, f)?;
|
|
walk_term(else_, f)
|
|
}
|
|
Term::Do { args, .. } => {
|
|
for a in args {
|
|
walk_term(a, f)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
Term::Ctor { type_name, args, .. } => {
|
|
f(type_name)?;
|
|
for a in args {
|
|
walk_term(a, f)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
Term::Match { scrutinee, arms } => {
|
|
walk_term(scrutinee, f)?;
|
|
for arm in arms {
|
|
walk_pattern(&arm.pat, f)?;
|
|
walk_term(&arm.body, f)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
Term::Lam { body, .. } => walk_term(body, f),
|
|
Term::Seq { lhs, rhs } => {
|
|
walk_term(lhs, f)?;
|
|
walk_term(rhs, f)
|
|
}
|
|
Term::Clone { value } => walk_term(value, f),
|
|
Term::ReuseAs { source, body } => {
|
|
walk_term(source, f)?;
|
|
walk_term(body, f)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// ct.1: Pattern::Ctor carries a `ctor` name (matches against a
|
|
/// scrutinee's TypeDef) but NOT a type_name field — the type is
|
|
/// inferred from the scrutinee. So Pattern walking only recurses;
|
|
/// no canonical-form check fires here.
|
|
fn walk_pattern<F>(p: &crate::ast::Pattern, f: &mut F) -> Result<(), WorkspaceLoadError>
|
|
where
|
|
F: FnMut(&str) -> Result<(), WorkspaceLoadError>,
|
|
{
|
|
use crate::ast::Pattern;
|
|
match p {
|
|
Pattern::Wild | Pattern::Var { .. } | Pattern::Lit { .. } => Ok(()),
|
|
Pattern::Ctor { fields, .. } => {
|
|
for sub in fields {
|
|
walk_pattern(sub, f)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
(The `Term` import at the top of `workspace.rs` was already added in
|
|
Task 1 Step 4; no further import changes are needed here.)
|
|
|
|
- [ ] **Step 4: Run the test to verify it passes**
|
|
|
|
Run: `cargo test --workspace -p ailang-core ct1_validator_rejects_bare_term_ctor`
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 5: Run the full crate test suite**
|
|
|
|
Run: `cargo test --workspace -p ailang-core`
|
|
Expected: PASS — the validator is still not wired into `load_workspace`,
|
|
so existing fixtures continue to load.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add crates/ailang-core/src/workspace.rs
|
|
git commit -m "iter ct.1.2: validator extends to Term::Ctor.type_name"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: Class-name field rejection (4 fields)
|
|
|
|
**Subject:** reject any `.` in `ClassDef.name`, `InstanceDef.class`,
|
|
`SuperclassRef.class`, and `Constraint.class`. Per spec §"Out of
|
|
scope: Class names", class names are NOT module-qualified in this
|
|
milestone; the validator catches half-migrated files where these
|
|
fields slipped through.
|
|
|
|
**Files:**
|
|
- Modify: `crates/ailang-core/src/workspace.rs` — extend
|
|
`validate_canonical_type_names` with a class-name pass.
|
|
- Test: append one unit test per field (4 tests).
|
|
|
|
- [ ] **Step 1: Write the failing tests**
|
|
|
|
Append to `crates/ailang-core/src/workspace.rs::mod tests`:
|
|
|
|
```rust
|
|
/// ct.1: a qualified `ClassDef.name` (e.g. `other.Eq`) must fire
|
|
/// `QualifiedClassName`. Class names stay bare in this milestone.
|
|
#[test]
|
|
fn ct1_validator_rejects_qualified_classdef_name() {
|
|
let m: Module = serde_json::from_value(serde_json::json!({
|
|
"schema": crate::SCHEMA,
|
|
"name": "m",
|
|
"imports": [],
|
|
"defs": [{
|
|
"kind": "class",
|
|
"name": "other.MyEq",
|
|
"param": "a",
|
|
"methods": []
|
|
}],
|
|
})).unwrap();
|
|
let mut modules = BTreeMap::new();
|
|
modules.insert("m".to_string(), m);
|
|
let err = validate_canonical_type_names(&modules)
|
|
.expect_err("qualified ClassDef.name must be rejected");
|
|
match err {
|
|
WorkspaceLoadError::QualifiedClassName { module, name, field } => {
|
|
assert_eq!(module, "m");
|
|
assert_eq!(name, "other.MyEq");
|
|
assert_eq!(field, "ClassDef.name");
|
|
}
|
|
other => panic!("expected QualifiedClassName, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
/// ct.1: a qualified `InstanceDef.class` must fire
|
|
/// `QualifiedClassName`.
|
|
#[test]
|
|
fn ct1_validator_rejects_qualified_instancedef_class() {
|
|
let m: Module = serde_json::from_value(serde_json::json!({
|
|
"schema": crate::SCHEMA,
|
|
"name": "m",
|
|
"imports": [],
|
|
"defs": [{
|
|
"kind": "instance",
|
|
"class": "other.MyEq",
|
|
"type": { "k": "con", "name": "Int" },
|
|
"methods": []
|
|
}],
|
|
})).unwrap();
|
|
let mut modules = BTreeMap::new();
|
|
modules.insert("m".to_string(), m);
|
|
let err = validate_canonical_type_names(&modules)
|
|
.expect_err("qualified InstanceDef.class must be rejected");
|
|
match err {
|
|
WorkspaceLoadError::QualifiedClassName { name, field, .. } => {
|
|
assert_eq!(name, "other.MyEq");
|
|
assert_eq!(field, "InstanceDef.class");
|
|
}
|
|
other => panic!("expected QualifiedClassName, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
/// ct.1: a qualified `SuperclassRef.class` must fire
|
|
/// `QualifiedClassName`.
|
|
#[test]
|
|
fn ct1_validator_rejects_qualified_superclassref_class() {
|
|
let m: Module = serde_json::from_value(serde_json::json!({
|
|
"schema": crate::SCHEMA,
|
|
"name": "m",
|
|
"imports": [],
|
|
"defs": [{
|
|
"kind": "class",
|
|
"name": "MyOrd",
|
|
"param": "a",
|
|
"superclass": { "class": "other.MyEq", "type": "a" },
|
|
"methods": []
|
|
}],
|
|
})).unwrap();
|
|
let mut modules = BTreeMap::new();
|
|
modules.insert("m".to_string(), m);
|
|
let err = validate_canonical_type_names(&modules)
|
|
.expect_err("qualified SuperclassRef.class must be rejected");
|
|
match err {
|
|
WorkspaceLoadError::QualifiedClassName { name, field, .. } => {
|
|
assert_eq!(name, "other.MyEq");
|
|
assert_eq!(field, "SuperclassRef.class");
|
|
}
|
|
other => panic!("expected QualifiedClassName, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
/// ct.1: a qualified `Constraint.class` (inside a `Type::Forall`)
|
|
/// must fire `QualifiedClassName`.
|
|
#[test]
|
|
fn ct1_validator_rejects_qualified_constraint_class() {
|
|
let m: Module = serde_json::from_value(serde_json::json!({
|
|
"schema": crate::SCHEMA,
|
|
"name": "m",
|
|
"imports": [],
|
|
"defs": [{
|
|
"kind": "fn",
|
|
"name": "f",
|
|
"type": {
|
|
"k": "forall",
|
|
"vars": ["a"],
|
|
"constraints": [
|
|
{ "class": "other.MyEq", "type": { "k": "var", "name": "a" } }
|
|
],
|
|
"body": {
|
|
"k": "fn", "params": [{ "k": "var", "name": "a" }],
|
|
"ret": { "k": "con", "name": "Unit" }, "effects": []
|
|
}
|
|
},
|
|
"params": ["x"],
|
|
"body": { "t": "lit", "lit": { "kind": "unit" } }
|
|
}],
|
|
})).unwrap();
|
|
let mut modules = BTreeMap::new();
|
|
modules.insert("m".to_string(), m);
|
|
let err = validate_canonical_type_names(&modules)
|
|
.expect_err("qualified Constraint.class must be rejected");
|
|
match err {
|
|
WorkspaceLoadError::QualifiedClassName { name, field, .. } => {
|
|
assert_eq!(name, "other.MyEq");
|
|
assert_eq!(field, "Constraint.class");
|
|
}
|
|
other => panic!("expected QualifiedClassName, got {other:?}"),
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run the tests to verify they fail**
|
|
|
|
Run: `cargo test --workspace -p ailang-core ct1_validator_rejects_qualified_`
|
|
Expected: FAIL — class-name fields are not yet checked; all four return Ok(()).
|
|
|
|
- [ ] **Step 3: Add class-name field check to the validator**
|
|
|
|
Extend `validate_canonical_type_names`'s per-def loop in
|
|
`crates/ailang-core/src/workspace.rs` (immediately after the
|
|
`walk_def_terms` call):
|
|
|
|
```rust
|
|
check_class_name_fields(def, mod_name)?;
|
|
```
|
|
|
|
And add the new fn after `walk_pattern`:
|
|
|
|
```rust
|
|
/// ct.1: reject any `.` in the four class-reference fields.
|
|
/// `ClassDef.name`, `InstanceDef.class`, `SuperclassRef.class`,
|
|
/// `Constraint.class`. Per spec §"Out of scope: Class names": class
|
|
/// names are NOT module-qualified in this milestone, so any
|
|
/// qualified form indicates a half-migrated file.
|
|
fn check_class_name_fields(
|
|
def: &Def,
|
|
owning_module: &str,
|
|
) -> Result<(), WorkspaceLoadError> {
|
|
fn fire(
|
|
owning_module: &str,
|
|
name: &str,
|
|
field: &'static str,
|
|
) -> WorkspaceLoadError {
|
|
WorkspaceLoadError::QualifiedClassName {
|
|
module: owning_module.to_string(),
|
|
name: name.to_string(),
|
|
field,
|
|
}
|
|
}
|
|
match def {
|
|
Def::Class(cd) => {
|
|
if cd.name.contains('.') {
|
|
return Err(fire(owning_module, &cd.name, "ClassDef.name"));
|
|
}
|
|
if let Some(sc) = &cd.superclass {
|
|
if sc.class.contains('.') {
|
|
return Err(fire(owning_module, &sc.class, "SuperclassRef.class"));
|
|
}
|
|
}
|
|
for m in &cd.methods {
|
|
if let Type::Forall { constraints, .. } = &m.ty {
|
|
for c in constraints {
|
|
if c.class.contains('.') {
|
|
return Err(fire(owning_module, &c.class, "Constraint.class"));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
Def::Instance(id) => {
|
|
if id.class.contains('.') {
|
|
return Err(fire(owning_module, &id.class, "InstanceDef.class"));
|
|
}
|
|
Ok(())
|
|
}
|
|
Def::Fn(fd) => {
|
|
if let Type::Forall { constraints, .. } = &fd.ty {
|
|
for c in constraints {
|
|
if c.class.contains('.') {
|
|
return Err(fire(owning_module, &c.class, "Constraint.class"));
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
Def::Const(_) | Def::Type(_) => Ok(()),
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run the tests to verify they pass**
|
|
|
|
Run: `cargo test --workspace -p ailang-core ct1_validator_rejects_qualified_`
|
|
Expected: PASS — all four tests green.
|
|
|
|
- [ ] **Step 5: Run the full crate test suite**
|
|
|
|
Run: `cargo test --workspace -p ailang-core`
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add crates/ailang-core/src/workspace.rs
|
|
git commit -m "iter ct.1.3: validator rejects qualified class-name fields"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: Migration subcommand `ail migrate-canonical-types <dir>`
|
|
|
|
**Subject:** add the dev-only CLI subcommand that walks `<dir>/*.ail.json`,
|
|
rewrites bare cross-module Type::Con and Term::Ctor.type_name
|
|
references to their qualified form using the first-match-in-imports-order
|
|
disambiguation rule (with prelude as implicit last-resort). Idempotent.
|
|
|
|
**Files:**
|
|
- Modify: `crates/ail/src/main.rs` — new variant `Cmd::MigrateCanonicalTypes { dir: PathBuf }`
|
|
+ handler.
|
|
- Modify: `crates/ailang-core/src/workspace.rs` — expose
|
|
`walk_def_types_mut` + `walk_def_terms_mut` as `pub(crate)` so the
|
|
CLI can mutate. (Implementer may instead choose to write the rewrite
|
|
inline in main.rs using `serde_json::Value` editing — both are valid;
|
|
the implementer picks the cleaner one. The plan prefers the AST-mutating
|
|
variant for type-safety.)
|
|
- Test: `crates/ail/tests/migrate_canonical_types.rs` (new) —
|
|
E2E test that builds a synthetic workspace in a tempdir, runs the
|
|
migration, and asserts the rewritten files are byte-stable on a
|
|
second run (idempotence).
|
|
|
|
- [ ] **Step 1: Write the failing E2E test**
|
|
|
|
Create `crates/ail/tests/migrate_canonical_types.rs`:
|
|
|
|
```rust
|
|
//! ct.1: E2E test for `ail migrate-canonical-types <dir>`.
|
|
//!
|
|
//! Builds a synthetic workspace in a tempdir with one fixture that
|
|
//! has a bare cross-module Type::Con ref, runs the migration, then
|
|
//! asserts: (a) the rewritten fixture's `Term::Ctor.type_name` is
|
|
//! now qualified; (b) running migration again produces no diff
|
|
//! (idempotence).
|
|
|
|
use std::fs;
|
|
use std::path::PathBuf;
|
|
use std::process::Command;
|
|
|
|
fn tmp_dir(tag: &str) -> PathBuf {
|
|
let d = std::env::temp_dir().join(format!(
|
|
"ailang_migrate_test_{tag}_{}",
|
|
std::process::id()
|
|
));
|
|
let _ = fs::remove_dir_all(&d);
|
|
fs::create_dir_all(&d).unwrap();
|
|
d
|
|
}
|
|
|
|
fn ail_bin() -> PathBuf {
|
|
// CARGO_BIN_EXE_<name> is set by cargo when building integration tests.
|
|
PathBuf::from(env!("CARGO_BIN_EXE_ail"))
|
|
}
|
|
|
|
#[test]
|
|
fn migrate_canonical_types_rewrites_bare_xmod_term_ctor() {
|
|
let dir = tmp_dir("rewrites_bare_xmod");
|
|
// Defining module: `other` declares `type Foo`.
|
|
let other_path = dir.join("other.ail.json");
|
|
fs::write(&other_path, serde_json::to_vec_pretty(&serde_json::json!({
|
|
"schema": "ailang/v0",
|
|
"name": "other",
|
|
"imports": [],
|
|
"defs": [{ "kind": "type", "name": "Foo", "ctors": [{ "name": "MkFoo", "fields": [] }] }],
|
|
})).unwrap()).unwrap();
|
|
// Consumer module: imports `other`, uses bare `Foo` Term::Ctor.
|
|
let main_path = dir.join("main.ail.json");
|
|
let bare_module = serde_json::json!({
|
|
"schema": "ailang/v0",
|
|
"name": "main",
|
|
"imports": [{ "module": "other" }],
|
|
"defs": [{
|
|
"kind": "fn", "name": "f",
|
|
"type": { "k": "fn", "params": [], "ret": { "k": "con", "name": "Unit" }, "effects": [] },
|
|
"params": [],
|
|
"body": { "t": "ctor", "type": "Foo", "ctor": "MkFoo", "args": [] }
|
|
}],
|
|
});
|
|
fs::write(&main_path, serde_json::to_vec_pretty(&bare_module).unwrap()).unwrap();
|
|
|
|
// Run migration.
|
|
let status = Command::new(ail_bin())
|
|
.args(["migrate-canonical-types", dir.to_str().unwrap()])
|
|
.status()
|
|
.expect("ail binary must launch");
|
|
assert!(status.success(), "migration exited non-zero");
|
|
|
|
// Inspect rewritten consumer.
|
|
let after: serde_json::Value = serde_json::from_slice(
|
|
&fs::read(&main_path).unwrap()
|
|
).unwrap();
|
|
let ctor_type = after["defs"][0]["body"]["type"].as_str().unwrap();
|
|
assert_eq!(ctor_type, "other.Foo",
|
|
"Term::Ctor.type must be qualified after migration");
|
|
|
|
// Idempotence: run migration again, capture bytes, compare.
|
|
let bytes_before = fs::read(&main_path).unwrap();
|
|
let status2 = Command::new(ail_bin())
|
|
.args(["migrate-canonical-types", dir.to_str().unwrap()])
|
|
.status()
|
|
.expect("ail binary must launch");
|
|
assert!(status2.success(), "second migration exited non-zero");
|
|
let bytes_after = fs::read(&main_path).unwrap();
|
|
assert_eq!(bytes_before, bytes_after, "migration must be idempotent");
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run the test to verify it fails**
|
|
|
|
Run: `cargo build --workspace && cargo test --workspace -p ail migrate_canonical_types_rewrites_bare_xmod_term_ctor`
|
|
Expected: FAIL — the `migrate-canonical-types` subcommand does not
|
|
yet exist; the binary will print an unrecognised-subcommand error.
|
|
|
|
- [ ] **Step 3: Add the subcommand**
|
|
|
|
In `crates/ail/src/main.rs`, add to the `enum Cmd` (anywhere is fine;
|
|
after `MergeProse` is conventional):
|
|
|
|
```rust
|
|
/// ct.1 (dev-only): rewrite every `*.ail.json` under `<dir>` so
|
|
/// that bare cross-module `Type::Con` and `Term::Ctor.type_name`
|
|
/// references gain their owning module's qualifier. Idempotent.
|
|
/// Uses the first-match-in-imports-order disambiguation rule
|
|
/// (matches iter 23.1.3's imports-fallback) so the rewrite never
|
|
/// silently changes which type a fixture resolves to. Prelude is
|
|
/// treated as an implicit last-resort fallback.
|
|
MigrateCanonicalTypes {
|
|
/// Directory containing `*.ail.json` to migrate (typically
|
|
/// `examples/`).
|
|
dir: PathBuf,
|
|
},
|
|
```
|
|
|
|
In the `match cli.cmd { ... }` block in `fn main`, add the handler.
|
|
The implementation walks every `*.ail.json` under `dir`, parses each
|
|
as a `Module`, applies the rewrite per-file, and writes back via
|
|
canonical JSON. Implementation skeleton:
|
|
|
|
```rust
|
|
Cmd::MigrateCanonicalTypes { dir } => {
|
|
use ailang_core::ast::{Def, Module, Term, Type};
|
|
use ailang_core::canonical;
|
|
use std::collections::{BTreeMap, BTreeSet};
|
|
use std::fs;
|
|
|
|
// Pass 1: load every *.ail.json in `dir` into a map by
|
|
// module name (NOT via load_workspace — these fixtures
|
|
// may currently fail validation).
|
|
let mut modules: BTreeMap<String, (PathBuf, Module)> = BTreeMap::new();
|
|
for entry in fs::read_dir(&dir)? {
|
|
let entry = entry?;
|
|
let path = entry.path();
|
|
if path.extension().and_then(|s| s.to_str()) != Some("json") {
|
|
continue;
|
|
}
|
|
let bytes = fs::read(&path)?;
|
|
let m: Module = match serde_json::from_slice(&bytes) {
|
|
Ok(m) => m,
|
|
Err(_) => continue, // not a Module (e.g. snapshot files)
|
|
};
|
|
modules.insert(m.name.clone(), (path, m));
|
|
}
|
|
|
|
// Include the embedded prelude (so the implicit fallback
|
|
// matches the loader's behaviour).
|
|
let prelude_json: &str = include_str!(
|
|
"../../../examples/prelude.ail.json"
|
|
);
|
|
let prelude: Module = serde_json::from_str(prelude_json)
|
|
.expect("embedded prelude must parse");
|
|
if !modules.contains_key("prelude") {
|
|
modules.insert("prelude".to_string(),
|
|
(PathBuf::new(), prelude));
|
|
}
|
|
|
|
// Pre-pass: per-module local-types index.
|
|
let mut local_types: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
|
|
for (name, (_, m)) in &modules {
|
|
let mut s = BTreeSet::new();
|
|
for d in &m.defs {
|
|
if let Def::Type(t) = d {
|
|
s.insert(t.name.clone());
|
|
}
|
|
}
|
|
local_types.insert(name.clone(), s);
|
|
}
|
|
|
|
// Pass 2: rewrite each module's bare cross-module refs.
|
|
// Skip the prelude entry (synthetic, no path to write to).
|
|
let mut rewritten = 0usize;
|
|
for (mod_name, (path, m)) in modules.iter_mut() {
|
|
if mod_name == "prelude" && path.as_os_str().is_empty() {
|
|
continue;
|
|
}
|
|
let import_names: Vec<String> =
|
|
m.imports.iter().map(|i| i.module.clone()).collect();
|
|
let owning = mod_name.clone();
|
|
|
|
// Walk types + terms and mutate Type::Con.name +
|
|
// Term::Ctor.type_name. The mutation rule mirrors
|
|
// the validator's `check_type_con_name` but, on bare
|
|
// non-primitive non-local hits, rewrites instead of
|
|
// erroring.
|
|
let mut changed = false;
|
|
for def in &mut m.defs {
|
|
rewrite_def(def, &owning, &local_types, &import_names, &mut changed);
|
|
}
|
|
if changed {
|
|
let bytes = serde_json::to_vec_pretty(m)?;
|
|
fs::write(path, bytes)?;
|
|
println!("migrated: {}", path.display());
|
|
rewritten += 1;
|
|
}
|
|
}
|
|
println!("done. {} file(s) rewritten.", rewritten);
|
|
Ok(())
|
|
}
|
|
```
|
|
|
|
Plus the helper `fn rewrite_def(...)` at module-bottom of
|
|
`crates/ail/src/main.rs`:
|
|
|
|
```rust
|
|
/// ct.1 migration helper: rewrite bare cross-module Type::Con names
|
|
/// and Term::Ctor.type_name to their qualified form. Sets
|
|
/// `*changed` to `true` if any rewrite happened.
|
|
fn rewrite_def(
|
|
def: &mut ailang_core::ast::Def,
|
|
owning_module: &str,
|
|
local_types: &std::collections::BTreeMap<String, std::collections::BTreeSet<String>>,
|
|
import_names: &[String],
|
|
changed: &mut bool,
|
|
) {
|
|
use ailang_core::ast::{Def, Type, Term};
|
|
|
|
fn rewrite_name(
|
|
name: &mut String,
|
|
owning_module: &str,
|
|
local_types: &std::collections::BTreeMap<String, std::collections::BTreeSet<String>>,
|
|
import_names: &[String],
|
|
changed: &mut bool,
|
|
) {
|
|
if name.contains('.') { return; }
|
|
if matches!(name.as_str(), "Int" | "Bool" | "Str" | "Unit" | "Float") { return; }
|
|
if local_types.get(owning_module).map(|s| s.contains(name)).unwrap_or(false) {
|
|
return;
|
|
}
|
|
// Bare cross-module — find owner via imports-in-order, with prelude as fallback.
|
|
let owner: Option<&str> = import_names.iter()
|
|
.map(|s| s.as_str())
|
|
.find(|imp| local_types.get(*imp).map(|s| s.contains(name)).unwrap_or(false))
|
|
.or_else(|| {
|
|
if local_types.get("prelude").map(|s| s.contains(name)).unwrap_or(false) {
|
|
Some("prelude")
|
|
} else {
|
|
None
|
|
}
|
|
});
|
|
if let Some(owner) = owner {
|
|
*name = format!("{owner}.{name}");
|
|
*changed = true;
|
|
}
|
|
// If no owner found, leave bare — validator will reject later.
|
|
}
|
|
|
|
fn rewrite_type(
|
|
t: &mut Type,
|
|
owning_module: &str,
|
|
local_types: &std::collections::BTreeMap<String, std::collections::BTreeSet<String>>,
|
|
import_names: &[String],
|
|
changed: &mut bool,
|
|
) {
|
|
match t {
|
|
Type::Con { name, args } => {
|
|
rewrite_name(name, owning_module, local_types, import_names, changed);
|
|
for a in args {
|
|
rewrite_type(a, owning_module, local_types, import_names, changed);
|
|
}
|
|
}
|
|
Type::Fn { params, ret, .. } => {
|
|
for p in params { rewrite_type(p, owning_module, local_types, import_names, changed); }
|
|
rewrite_type(ret, owning_module, local_types, import_names, changed);
|
|
}
|
|
Type::Forall { body, constraints, .. } => {
|
|
for c in constraints {
|
|
rewrite_type(&mut c.type_, owning_module, local_types, import_names, changed);
|
|
}
|
|
rewrite_type(body, owning_module, local_types, import_names, changed);
|
|
}
|
|
Type::Var { .. } => {}
|
|
}
|
|
}
|
|
|
|
fn rewrite_term(
|
|
t: &mut Term,
|
|
owning_module: &str,
|
|
local_types: &std::collections::BTreeMap<String, std::collections::BTreeSet<String>>,
|
|
import_names: &[String],
|
|
changed: &mut bool,
|
|
) {
|
|
match t {
|
|
Term::Lit { .. } | Term::Var { .. } => {}
|
|
Term::App { callee, args, .. } => {
|
|
rewrite_term(callee, owning_module, local_types, import_names, changed);
|
|
for a in args { rewrite_term(a, owning_module, local_types, import_names, changed); }
|
|
}
|
|
Term::Let { value, body, .. } => {
|
|
rewrite_term(value, owning_module, local_types, import_names, changed);
|
|
rewrite_term(body, owning_module, local_types, import_names, changed);
|
|
}
|
|
Term::LetRec { ty, body, in_term, .. } => {
|
|
rewrite_type(ty, owning_module, local_types, import_names, changed);
|
|
rewrite_term(body, owning_module, local_types, import_names, changed);
|
|
rewrite_term(in_term, owning_module, local_types, import_names, changed);
|
|
}
|
|
Term::If { cond, then, else_ } => {
|
|
rewrite_term(cond, owning_module, local_types, import_names, changed);
|
|
rewrite_term(then, owning_module, local_types, import_names, changed);
|
|
rewrite_term(else_, owning_module, local_types, import_names, changed);
|
|
}
|
|
Term::Do { args, .. } => {
|
|
for a in args { rewrite_term(a, owning_module, local_types, import_names, changed); }
|
|
}
|
|
Term::Ctor { type_name, args, .. } => {
|
|
rewrite_name(type_name, owning_module, local_types, import_names, changed);
|
|
for a in args { rewrite_term(a, owning_module, local_types, import_names, changed); }
|
|
}
|
|
Term::Match { scrutinee, arms } => {
|
|
rewrite_term(scrutinee, owning_module, local_types, import_names, changed);
|
|
for arm in arms {
|
|
rewrite_term(&mut arm.body, owning_module, local_types, import_names, changed);
|
|
}
|
|
}
|
|
Term::Lam { param_tys, ret_ty, body, .. } => {
|
|
for pt in param_tys { rewrite_type(pt, owning_module, local_types, import_names, changed); }
|
|
rewrite_type(ret_ty, owning_module, local_types, import_names, changed);
|
|
rewrite_term(body, owning_module, local_types, import_names, changed);
|
|
}
|
|
Term::Seq { lhs, rhs } => {
|
|
rewrite_term(lhs, owning_module, local_types, import_names, changed);
|
|
rewrite_term(rhs, owning_module, local_types, import_names, changed);
|
|
}
|
|
Term::Clone { value } => rewrite_term(value, owning_module, local_types, import_names, changed),
|
|
Term::ReuseAs { source, body } => {
|
|
rewrite_term(source, owning_module, local_types, import_names, changed);
|
|
rewrite_term(body, owning_module, local_types, import_names, changed);
|
|
}
|
|
}
|
|
}
|
|
|
|
match def {
|
|
Def::Fn(fd) => {
|
|
rewrite_type(&mut fd.ty, owning_module, local_types, import_names, changed);
|
|
rewrite_term(&mut fd.body, owning_module, local_types, import_names, changed);
|
|
}
|
|
Def::Const(cd) => {
|
|
rewrite_type(&mut cd.ty, owning_module, local_types, import_names, changed);
|
|
rewrite_term(&mut cd.value, owning_module, local_types, import_names, changed);
|
|
}
|
|
Def::Type(td) => {
|
|
for c in &mut td.ctors {
|
|
for fty in &mut c.fields {
|
|
rewrite_type(fty, owning_module, local_types, import_names, changed);
|
|
}
|
|
}
|
|
}
|
|
Def::Class(cd) => {
|
|
for cm in &mut cd.methods {
|
|
rewrite_type(&mut cm.ty, owning_module, local_types, import_names, changed);
|
|
if let Some(body) = &mut cm.default {
|
|
rewrite_term(body, owning_module, local_types, import_names, changed);
|
|
}
|
|
}
|
|
}
|
|
Def::Instance(id) => {
|
|
rewrite_type(&mut id.type_, owning_module, local_types, import_names, changed);
|
|
for im in &mut id.methods {
|
|
rewrite_term(&mut im.body, owning_module, local_types, import_names, changed);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run the test to verify it passes**
|
|
|
|
Run: `cargo test --workspace -p ail migrate_canonical_types_rewrites_bare_xmod_term_ctor`
|
|
Expected: PASS — the migration rewrites the bare ref, and the second
|
|
run leaves bytes unchanged.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add crates/ail/src/main.rs crates/ail/tests/migrate_canonical_types.rs
|
|
git commit -m "iter ct.1.4: dev-only ail migrate-canonical-types subcommand"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: Run migration on `examples/` — single bulk commit
|
|
|
|
**Subject:** apply the migration tool to the in-tree fixtures. The
|
|
fixture survey identified two files that today rely on the
|
|
imports-fallback for bare cross-module Type::Con / Term::Ctor refs:
|
|
|
|
- `examples/ordering_match.ail.json` — `Term::Ctor.type_name` =
|
|
`"Ordering"` (bare, owner is implicit prelude).
|
|
- `examples/test_22b1_dup_a.ail.json` — `Type::Con.name` = `"MyInt"`
|
|
(bare, owner is imported `test_22b1_dup_b`).
|
|
|
|
The migration tool rewrites both to their qualified form. No other
|
|
in-tree fixture should change (intra-module-only fixtures are
|
|
bit-stable per spec §Migration impact).
|
|
|
|
**Files:**
|
|
- Run: `cargo run --bin ail -- migrate-canonical-types examples/`
|
|
- Migrate (auto-rewritten): `examples/ordering_match.ail.json`,
|
|
`examples/test_22b1_dup_a.ail.json`.
|
|
|
|
- [ ] **Step 1: Run the migration on `examples/`**
|
|
|
|
Run: `cargo run --bin ail -- migrate-canonical-types examples/`
|
|
Expected stdout: two lines `migrated: examples/...`, followed by
|
|
`done. 2 file(s) rewritten.` If a different count appears, **stop
|
|
and diagnose**: extra rewrites mean the survey missed a fixture, or
|
|
the tool is over-eager.
|
|
|
|
- [ ] **Step 2: Inspect the diff**
|
|
|
|
Run: `git diff examples/`
|
|
Expected: exactly two files changed; each diff is a single-string
|
|
substitution:
|
|
|
|
- `ordering_match.ail.json`: `"type": "Ordering"` → `"type": "prelude.Ordering"`
|
|
(1 occurrence inside `Term::Ctor`).
|
|
- `test_22b1_dup_a.ail.json`: `"name": "MyInt"` → `"name": "test_22b1_dup_b.MyInt"`
|
|
(1 occurrence inside `Type::Con`).
|
|
|
|
Plus any reformatting `serde_json::to_vec_pretty` introduces. If the
|
|
reformatting introduces unrelated whitespace churn that obscures the
|
|
substantive change, the implementer may use `jq --indent 2` or a
|
|
minimal post-pass to preserve original formatting — but the rewrite
|
|
itself MUST round-trip the AST, not the bytes, so some byte-level
|
|
churn is acceptable.
|
|
|
|
- [ ] **Step 3: Verify both migrated fixtures still load + work**
|
|
|
|
Run: `cargo test --workspace`
|
|
Expected: PASS — the typechecker's existing imports-fallback paths
|
|
continue to handle the qualified form (they already do, per iter
|
|
23.1.3's documentation). The validator is NOT yet wired (that lands
|
|
in Task 6), so the migration is just a pre-emptive shape change at
|
|
this point.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add examples/ordering_match.ail.json examples/test_22b1_dup_a.ail.json
|
|
git commit -m "iter ct.1.5: migrate bare cross-module type refs in examples/"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: Wire validator into `load_workspace` + update kind-mismatch test expectation
|
|
|
|
**Subject:** wire `validate_canonical_type_names` into `load_workspace`
|
|
(immediately after prelude injection, before `validate_classdefs`).
|
|
Update the existing kind-mismatch regression test, whose fixture
|
|
uses `Type::Con { name: "f" }` as the class param — the new
|
|
validator now catches this shape as a `BareCrossModuleTypeRef`
|
|
BEFORE `validate_classdefs` runs. Verify full `cargo test
|
|
--workspace` green.
|
|
|
|
**Files:**
|
|
- Modify: `crates/ailang-core/src/workspace.rs::load_workspace` —
|
|
add a call to `validate_canonical_type_names` after the prelude
|
|
insertion, before `validate_classdefs`.
|
|
- Modify: `crates/ailang-core/src/workspace.rs::tests::class_param_in_applied_position_fires_kind_mismatch`
|
|
— update to expect `BareCrossModuleTypeRef { name: "f", .. }`
|
|
instead of `KindMismatch`. Rename the test to reflect what it
|
|
actually pins: `class_param_in_applied_position_fires_canonical_form_rejection`.
|
|
The `KindMismatch` diagnostic path becomes structurally
|
|
unreachable through well-formed schema; leave the existing
|
|
`validate_classdefs` code in place for now (cleanup is out of
|
|
scope for ct.1 — eligible for a follow-up tidy later).
|
|
|
|
- [ ] **Step 1: Wire the validator**
|
|
|
|
In `crates/ailang-core/src/workspace.rs::load_workspace`, after the
|
|
existing `modules.insert("prelude".to_string(), prelude);` and
|
|
before `validate_classdefs(&modules)?;`, add:
|
|
|
|
```rust
|
|
// ct.1: enforce the canonical-form rule on Type::Con and
|
|
// Term::Ctor.type_name. Runs before class-schema validation
|
|
// so a stale bare cross-module ref fires the canonical-form
|
|
// diagnostic instead of a downstream one.
|
|
validate_canonical_type_names(&modules)?;
|
|
```
|
|
|
|
- [ ] **Step 2: Update the kind-mismatch test expectation**
|
|
|
|
In `crates/ailang-core/src/workspace.rs::mod tests`, replace the
|
|
existing `class_param_in_applied_position_fires_kind_mismatch` test
|
|
with:
|
|
|
|
```rust
|
|
/// ct.1: a class whose parameter `f` appears as a `Type::Con`
|
|
/// name (the malformed-but-historically-test-fixture shape used
|
|
/// to trigger `KindMismatch` pre-ct.1) is now caught earlier by
|
|
/// the canonical-type-names validator: `f` is bare,
|
|
/// non-primitive, and not a TypeDef in the owning module, so
|
|
/// `BareCrossModuleTypeRef` fires before `validate_classdefs`
|
|
/// gets a chance to run. The `KindMismatch` path stays in the
|
|
/// codebase as dead-but-defensive code; a future tidy may
|
|
/// retire it.
|
|
#[test]
|
|
fn class_param_in_applied_position_fires_canonical_form_rejection() {
|
|
let entry = std::path::PathBuf::from(
|
|
"../../examples/test_22b2_kind_mismatch.ail.json",
|
|
);
|
|
let err = load_workspace(&entry)
|
|
.expect_err("must fire canonical-form rejection");
|
|
match err {
|
|
WorkspaceLoadError::BareCrossModuleTypeRef { module, name, .. } => {
|
|
assert_eq!(module, "test_22b2_kind_mismatch");
|
|
assert_eq!(name, "f");
|
|
}
|
|
other => panic!(
|
|
"expected BareCrossModuleTypeRef (validator now fires first), got {other:?}",
|
|
),
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Run the workspace test suite**
|
|
|
|
Run: `cargo test --workspace -p ailang-core`
|
|
Expected: PASS.
|
|
|
|
If any other test fails because its fixture now trips the validator,
|
|
the implementer must:
|
|
|
|
1. **Stop and survey.** A failing test outside the kind-mismatch one
|
|
is a signal that the migration in Task 5 missed a fixture.
|
|
2. **Don't adapt the test to the bug** — re-run the migration tool
|
|
on the failing fixture, confirm it picks the right qualifier, and
|
|
include the rewrite in this task's commit.
|
|
3. **If the migration tool can't fix it** (e.g. the bare ref names a
|
|
type that doesn't exist in any imported or prelude module), the
|
|
fixture is malformed schema; that's a separate diagnosis. Report
|
|
`BLOCKED` and escalate to the orchestrator.
|
|
|
|
- [ ] **Step 4: Run the full workspace test suite (all crates)**
|
|
|
|
Run: `cargo build --workspace && cargo test --workspace`
|
|
Expected: PASS — `ailang-check`, `ailang-codegen`, `ail`'s E2E suite,
|
|
all green.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add crates/ailang-core/src/workspace.rs
|
|
git commit -m "iter ct.1.6: wire validator into load_workspace; refresh kind-mismatch test"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: Three new fixture tests for the diagnostic surface
|
|
|
|
**Subject:** the unit tests in Tasks 1-3 use synthetic in-memory
|
|
modules. For the diagnostic shape to be observable via the `ail
|
|
check` CLI path (and for fieldtest-style end-to-end pinning), three
|
|
on-disk fixtures land here. Each fixture is paired with a test in
|
|
`crates/ailang-core/src/workspace.rs::mod tests`.
|
|
|
|
**Files:**
|
|
- Create: `examples/test_ct1_bare_xmod_rejected.ail.json` — bare
|
|
`Ordering` Term::Ctor without imports.
|
|
- Create: `examples/test_ct1_bad_qualifier.ail.json` — qualified
|
|
`Mystery.Type` with `Mystery` not a known module.
|
|
- Create: `examples/test_ct1_qualified_class_rejected.ail.json` —
|
|
qualified `prelude.Eq` in an `InstanceDef.class` field.
|
|
- Modify: `crates/ailang-core/src/workspace.rs::mod tests` — three
|
|
new fixture-loading tests.
|
|
|
|
- [ ] **Step 1: Create fixture 1 — bare cross-module ref**
|
|
|
|
Create `examples/test_ct1_bare_xmod_rejected.ail.json`:
|
|
|
|
```json
|
|
{
|
|
"schema": "ailang/v0",
|
|
"name": "test_ct1_bare_xmod_rejected",
|
|
"imports": [],
|
|
"defs": [
|
|
{
|
|
"kind": "fn",
|
|
"name": "main",
|
|
"type": {
|
|
"k": "fn",
|
|
"params": [],
|
|
"ret": { "k": "con", "name": "Unit" },
|
|
"effects": ["IO"]
|
|
},
|
|
"params": [],
|
|
"body": {
|
|
"t": "ctor",
|
|
"type": "Ordering",
|
|
"ctor": "LT",
|
|
"args": []
|
|
}
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Create fixture 2 — bad qualifier**
|
|
|
|
Create `examples/test_ct1_bad_qualifier.ail.json`:
|
|
|
|
```json
|
|
{
|
|
"schema": "ailang/v0",
|
|
"name": "test_ct1_bad_qualifier",
|
|
"imports": [],
|
|
"defs": [
|
|
{
|
|
"kind": "fn",
|
|
"name": "f",
|
|
"type": {
|
|
"k": "fn",
|
|
"params": [],
|
|
"ret": { "k": "con", "name": "Mystery.Type" },
|
|
"effects": []
|
|
},
|
|
"params": [],
|
|
"body": { "t": "lit", "lit": { "kind": "unit" } }
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Create fixture 3 — qualified class name**
|
|
|
|
Create `examples/test_ct1_qualified_class_rejected.ail.json`:
|
|
|
|
```json
|
|
{
|
|
"schema": "ailang/v0",
|
|
"name": "test_ct1_qualified_class_rejected",
|
|
"imports": [],
|
|
"defs": [
|
|
{
|
|
"kind": "instance",
|
|
"class": "prelude.Eq",
|
|
"type": { "k": "con", "name": "Int" },
|
|
"methods": []
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Add three on-disk fixture tests**
|
|
|
|
Append to `crates/ailang-core/src/workspace.rs::mod tests`:
|
|
|
|
```rust
|
|
/// ct.1: on-disk fixture for `BareCrossModuleTypeRef`. Bare
|
|
/// `Ordering` Term::Ctor with no imports; the validator catches it
|
|
/// after prelude injection (so the candidate list contains
|
|
/// `prelude.Ordering`).
|
|
#[test]
|
|
fn ct1_fixture_bare_xmod_rejected() {
|
|
let entry = examples_dir().join("test_ct1_bare_xmod_rejected.ail.json");
|
|
let err = load_workspace(&entry).expect_err("must reject bare Ordering");
|
|
match err {
|
|
WorkspaceLoadError::BareCrossModuleTypeRef { module, name, candidates } => {
|
|
assert_eq!(module, "test_ct1_bare_xmod_rejected");
|
|
assert_eq!(name, "Ordering");
|
|
assert_eq!(candidates, vec!["prelude.Ordering".to_string()]);
|
|
}
|
|
other => panic!("expected BareCrossModuleTypeRef, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
/// ct.1: on-disk fixture for `BadCrossModuleTypeRef`. Qualified
|
|
/// `Mystery.Type` with `Mystery` not a known module.
|
|
#[test]
|
|
fn ct1_fixture_bad_qualifier() {
|
|
let entry = examples_dir().join("test_ct1_bad_qualifier.ail.json");
|
|
let err = load_workspace(&entry).expect_err("must reject Mystery.Type");
|
|
match err {
|
|
WorkspaceLoadError::BadCrossModuleTypeRef { module, name } => {
|
|
assert_eq!(module, "test_ct1_bad_qualifier");
|
|
assert_eq!(name, "Mystery.Type");
|
|
}
|
|
other => panic!("expected BadCrossModuleTypeRef, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
/// ct.1: on-disk fixture for `QualifiedClassName`. Qualified
|
|
/// `prelude.Eq` in an `InstanceDef.class` field — the schema
|
|
/// rejects this so half-migrated files cannot silently load.
|
|
#[test]
|
|
fn ct1_fixture_qualified_class_rejected() {
|
|
let entry = examples_dir().join("test_ct1_qualified_class_rejected.ail.json");
|
|
let err = load_workspace(&entry).expect_err("must reject prelude.Eq");
|
|
match err {
|
|
WorkspaceLoadError::QualifiedClassName { module, name, field } => {
|
|
assert_eq!(module, "test_ct1_qualified_class_rejected");
|
|
assert_eq!(name, "prelude.Eq");
|
|
assert_eq!(field, "InstanceDef.class");
|
|
}
|
|
other => panic!("expected QualifiedClassName, got {other:?}"),
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Run the new fixture tests**
|
|
|
|
Run: `cargo test --workspace -p ailang-core ct1_fixture_`
|
|
Expected: PASS — all three on-disk fixture tests green.
|
|
|
|
- [ ] **Step 6: Run the full workspace test suite**
|
|
|
|
Run: `cargo build --workspace && cargo test --workspace`
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
git add crates/ailang-core/src/workspace.rs examples/test_ct1_*.ail.json
|
|
git commit -m "iter ct.1.7: on-disk fixture tests for canonical-form diagnostics"
|
|
```
|
|
|
|
---
|
|
|
|
## Coverage check (Step-5 self-review post-write)
|
|
|
|
| Spec § | Task |
|
|
|-------------------------|------|
|
|
| Validator rule 1 (qualified) | Task 1 (Type::Con) + Task 2 (Term::Ctor) |
|
|
| Validator rule 2 (primitive) | Task 1 |
|
|
| Validator rule 3 (bare local + bare-xmod rejection) | Task 1 + Task 2 |
|
|
| Class-name field rejection | Task 3 |
|
|
| Migration tool (imports-order disambiguation) | Task 4 |
|
|
| Migration on `examples/` (2 fixtures) | Task 5 |
|
|
| Wire into `load_workspace` + kind-mismatch test refresh | Task 6 |
|
|
| On-disk fixture tests | Task 7 |
|
|
| `cargo test --workspace` green | Task 6 + Task 7 (re-verified) |
|
|
|
|
Out of scope per spec, NOT in ct.1: removal of the 4 obsolete
|
|
mechanisms (Pattern::Ctor imports-fallback, Term::Ctor synth
|
|
imports-fallback, lookup_ctor_by_type fallback, mono ctor_index
|
|
overlay). Those land in ct.2 and ct.3. The validator gates the
|
|
canonical form; the cleanup follows once we know nothing depends
|
|
on the fallbacks at consumer sites.
|
|
|
|
DESIGN.md amendments are ct.4, not ct.1.
|