diff --git a/crates/ail/src/main.rs b/crates/ail/src/main.rs
index 8695c8d..5852131 100644
--- a/crates/ail/src/main.rs
+++ b/crates/ail/src/main.rs
@@ -225,6 +225,18 @@ enum Cmd {
/// Edited `.prose.txt` (carries the human's intent).
edited: PathBuf,
},
+ /// 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.
+ MigrateCanonicalTypes {
+ /// Directory containing `*.ail.json` to migrate (typically
+ /// `examples/`).
+ dir: PathBuf,
+ },
}
/// Composes the prose-round-trip prompt described in
@@ -432,6 +444,85 @@ fn main() -> Result<()> {
.with_context(|| format!("reading {}", edited.display()))?;
print!("{}", compose_merge_prose_prompt(&original_form_a, &prose));
}
+ Cmd::MigrateCanonicalTypes { dir } => {
+ use ailang_core::ast::{Def, Module};
+ 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 = 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> =
+ 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 synthetic prelude entry (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 =
+ m.imports.iter().map(|i| i.module.clone()).collect();
+ let owning = mod_name.clone();
+ 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);
+ }
Cmd::Describe { path, name, json, workspace } => {
if workspace {
let ws = ailang_core::load_workspace(&path)?;
@@ -2238,6 +2329,429 @@ fn build_to(
Ok(out_bin)
}
+/// 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. Uses the
+/// first-match-in-imports-order disambiguation rule with prelude as
+/// implicit last-resort fallback, matching iter 23.1.3's typecheck
+/// imports-fallback so the rewrite never silently re-aims a fixture.
+fn rewrite_def(
+ def: &mut ailang_core::ast::Def,
+ owning_module: &str,
+ local_types: &std::collections::BTreeMap>,
+ import_names: &[String],
+ changed: &mut bool,
+) {
+ use ailang_core::ast::{Def, Term, Type};
+
+ fn rewrite_name(
+ name: &mut String,
+ owning_module: &str,
+ local_types: &std::collections::BTreeMap>,
+ 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 last-resort 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>,
+ 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>,
+ 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,
+ );
+ }
+ }
+ }
+}
+
#[cfg(test)]
mod tests {
use super::compose_merge_prose_prompt;
diff --git a/crates/ail/tests/migrate_canonical_types.rs b/crates/ail/tests/migrate_canonical_types.rs
new file mode 100644
index 0000000..9e8f9e9
--- /dev/null
+++ b/crates/ail/tests/migrate_canonical_types.rs
@@ -0,0 +1,83 @@
+//! ct.1: E2E test for `ail migrate-canonical-types `.
+//!
+//! 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).
+//!
+//! Property protected: `ail migrate-canonical-types` rewrites bare
+//! cross-module Type::Con / Term::Ctor.type_name refs to their
+//! qualified form using first-match-in-imports-order, AND a second
+//! run on the already-canonical workspace is a byte-stable no-op.
+
+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_ 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");
+}