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");
+}