diff --git a/crates/ail/src/main.rs b/crates/ail/src/main.rs index 5852131..2ecce70 100644 --- a/crates/ail/src/main.rs +++ b/crates/ail/src/main.rs @@ -228,10 +228,21 @@ enum Cmd { /// ct.1 (dev-only): rewrite every `*.ail.json` under `` 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. + /// + /// Disambiguation: the first import (in declaration order) that + /// owns a matching type wins. Ties are NOT diagnosed — the + /// migration silently picks the first match. Prelude is consulted + /// as an implicit last-resort fallback when no listed import owns + /// the name. + /// + /// Note: this is one-shot dev tooling and intentionally weaker + /// than the typecheck-time `ambiguous-type` diagnostic shipped in + /// iter 23.1.3, which fires through the type-checker on multi- + /// import collisions. The two are different rules at different + /// layers; do not assume parity. The expected usage is to run + /// the migration once on a fixture corpus that does not exhibit + /// genuine multi-import ambiguity, then let the typechecker + /// enforce the stricter rule going forward. MigrateCanonicalTypes { /// Directory containing `*.ail.json` to migrate (typically /// `examples/`). diff --git a/crates/ail/tests/migrate_canonical_types.rs b/crates/ail/tests/migrate_canonical_types.rs index 9e8f9e9..8ecd092 100644 --- a/crates/ail/tests/migrate_canonical_types.rs +++ b/crates/ail/tests/migrate_canonical_types.rs @@ -81,3 +81,64 @@ fn migrate_canonical_types_rewrites_bare_xmod_term_ctor() { let bytes_after = fs::read(&main_path).unwrap(); assert_eq!(bytes_before, bytes_after, "migration must be idempotent"); } + +/// Symmetric to `migrate_canonical_types_rewrites_bare_xmod_term_ctor`, +/// but the only bare cross-module reference in the consumer is a +/// `Type::Con` in a fn return type — the body carries no `Term::Ctor` +/// at all. This pins the Type::Con rewrite branch of `rewrite_def` as +/// a property: a regression that broke that branch (e.g. accidentally +/// scoping `rewrite_type` to only fn params) would leave the Term::Ctor +/// test green and ship the bug. +#[test] +fn migrate_canonical_types_rewrites_bare_xmod_type_con() { + let dir = tmp_dir("rewrites_bare_type_con"); + // 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`, body is a unit literal (no + // Term::Ctor) — the only bare cross-module reference is the + // Type::Con `Foo` in the fn return type. + 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": "Foo" }, "effects": [] }, + "params": [], + "body": { "t": "lit", "lit": { "kind": "unit" } } + }], + }); + 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 ret_name = after["defs"][0]["type"]["ret"]["name"].as_str().unwrap(); + assert_eq!(ret_name, "other.Foo", + "fn-return Type::Con.name 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"); +}