//! Integration tests for `check_workspace` (Iter 5b). //! //! These tests drive the canonical `examples/ws_*.ail.json` files; //! the loader and the checker together form the pipeline that `ail check` //! runs in JSON mode against a workspace. use ailang_check::{check_workspace, Severity}; use ailang_surface::load_workspace; use std::path::Path; /// assert that the linearity check's `suggested_rewrites` /// payload is non-empty AND each `replacement` parses as a form-A AILang /// term. This is the contract the check promises to consumers of /// `ail check --json`: a machine can take the replacement string and /// substitute it back into the source without re-tokenising the term. fn assert_suggested_rewrites_well_formed(d: &ailang_check::Diagnostic) { assert!( !d.suggested_rewrites.is_empty(), "diagnostic {} (def={:?}) has no suggested_rewrites", d.code, d.def ); for r in &d.suggested_rewrites { ailang_surface::parse_term(&r.replacement).unwrap_or_else(|e| { panic!( "suggested rewrite for `{}` does not parse as form-A AILang: {:?}\nreplacement: {}", d.code, e, r.replacement ) }); } } fn examples_dir() -> std::path::PathBuf { let manifest = env!("CARGO_MANIFEST_DIR"); Path::new(manifest).parent().unwrap().parent().unwrap().join("examples") } #[test] fn happy_path_resolves_qualified_import() { // ws_main imports ws_lib and calls `ws_lib.add` — fully typed. // Expected: no diagnostics. let entry = examples_dir().join("ws_main.ail"); let ws = load_workspace(&entry).expect("load ws_main"); let diags = check_workspace(&ws); assert!( diags.is_empty(), "expected no diagnostics; got: {}", serde_json::to_string_pretty(&diags).unwrap() ); } #[test] fn unknown_import_is_reported() { // ws_broken references `ws_lib.bogus` — module is there, def isn't. let entry = examples_dir().join("ws_broken.ail"); let ws = load_workspace(&entry).expect("load ws_broken"); let diags = check_workspace(&ws); assert_eq!(diags.len(), 1, "got: {:?}", diags); assert!(matches!(diags[0].severity, Severity::Error)); assert_eq!(diags[0].code, "unknown-import"); // Context must point structurally to module + def name. assert_eq!( diags[0].ctx.get("module").and_then(|v| v.as_str()), Some("ws_lib") ); assert_eq!( diags[0].ctx.get("name").and_then(|v| v.as_str()), Some("bogus") ); } #[test] fn unknown_module_prefix_is_reported() { // ws_unknown_module has no imports but references `nope.x`. Under // prep.1's type-scoped resolution, `nope` is neither a known // TypeDef nor an imported module, so the diagnostic narrowed from // the legacy `unknown-module` to `type-scoped-receiver-not-a-type` // — a more precise wording for the same failure mode. let entry = examples_dir().join("ws_unknown_module.ail"); let ws = load_workspace(&entry).expect("load ws_unknown_module"); let diags = check_workspace(&ws); assert_eq!(diags.len(), 1, "got: {:?}", diags); assert!(matches!(diags[0].severity, Severity::Error)); assert_eq!(diags[0].code, "type-scoped-receiver-not-a-type"); assert_eq!( diags[0].ctx.get("name").and_then(|v| v.as_str()), Some("nope") ); } #[test] fn invalid_def_name_with_dot_is_reported() { // Synthetic: a module with a def whose name contains a dot. // We construct this as a Module directly and feed it into a // trivial workspace, because the canonical convention should not // let this through to disk in the first place. use ailang_core::ast::*; use std::collections::BTreeMap; let m = Module { schema: ailang_core::SCHEMA.into(), name: "t".into(), kernel: false, imports: vec![], defs: vec![Def::Const(ConstDef { name: "weird.name".into(), ty: Type::int(), value: Term::Lit { lit: Literal::Int { value: 0 }, }, doc: None, })], }; let mut modules = BTreeMap::new(); modules.insert(m.name.clone(), m.clone()); let ws = ailang_core::Workspace { entry: m.name.clone(), modules, root_dir: std::path::PathBuf::from("."), registry: ailang_core::workspace::Registry::default(), }; let diags = check_workspace(&ws); assert_eq!(diags.len(), 1, "got: {:?}", diags); assert_eq!(diags[0].code, "invalid-def-name"); assert_eq!( diags[0].ctx.get("reason").and_then(|v| v.as_str()), Some("contains-dot") ); } /// body-check is multi-diagnose. A module with two independent /// errors in different defs must produce two diagnostics — the first /// error must not short-circuit the second. #[test] fn body_errors_accumulate_across_defs() { use ailang_core::ast::*; use std::collections::BTreeMap; // Two fns, each with a different body error: // bad_a: arity mismatch — calls `+` with three arguments. // bad_b: references a name that does not exist anywhere. let bad_a = Def::Fn(FnDef { name: "bad_a".into(), ty: Type::Fn { params: vec![], ret: Box::new(Type::int()), effects: vec![], param_modes: vec![], ret_mode: ParamMode::Own, }, params: vec![], body: Term::App { callee: Box::new(Term::Var { name: "+".into() }), args: vec![ Term::Lit { lit: Literal::Int { value: 1 } }, Term::Lit { lit: Literal::Int { value: 2 } }, Term::Lit { lit: Literal::Int { value: 3 } }, ], tail: false, }, suppress: vec![], doc: None, export: None, }); let bad_b = Def::Fn(FnDef { name: "bad_b".into(), ty: Type::Fn { params: vec![], ret: Box::new(Type::int()), effects: vec![], param_modes: vec![], ret_mode: ParamMode::Own, }, params: vec![], body: Term::Var { name: "this_does_not_exist".into(), }, suppress: vec![], doc: None, export: None, }); let m = Module { schema: ailang_core::SCHEMA.into(), name: "two_errors".into(), kernel: false, imports: vec![], defs: vec![bad_a, bad_b], }; let mut modules = BTreeMap::new(); modules.insert(m.name.clone(), m.clone()); let ws = ailang_core::Workspace { entry: m.name.clone(), modules, root_dir: std::path::PathBuf::from("."), registry: ailang_core::workspace::Registry::default(), }; let diags = check_workspace(&ws); assert_eq!(diags.len(), 2, "want both errors, got: {:#?}", diags); // Each diagnostic carries the offending def name as `def`. let defs: Vec<&str> = diags .iter() .filter_map(|d| d.def.as_deref()) .collect(); assert!(defs.contains(&"bad_a"), "missing bad_a; got {defs:?}"); assert!(defs.contains(&"bad_b"), "missing bad_b; got {defs:?}"); } /// a fn with all-explicit modes that consumes its /// `(own (con List))` parameter twice should trigger /// `use-after-consume` on the second occurrence and ship a /// non-empty, well-formed `suggested_rewrites`. /// /// Body: /// `(seq (app sum_list xs) (app sum_list xs))` /// /// Both `xs` are in Own arg position; the second use is after consume. #[test] fn use_after_consume_on_own_param_is_reported() { use ailang_core::ast::*; use std::collections::BTreeMap; // Helper: a fn that consumes a (con List) and returns Int. let sum_list = Def::Fn(FnDef { name: "sum_list".into(), ty: Type::Fn { params: vec![Type::Con { name: "List".into(), args: vec![] }], param_modes: vec![ParamMode::Own], ret: Box::new(Type::int()), ret_mode: ParamMode::Own, effects: vec![], }, params: vec!["ys".into()], body: Term::Lit { lit: Literal::Int { value: 0 } }, suppress: vec![], doc: None, export: None, }); // The offending fn. Body is `(+ (sum_list xs) (sum_list xs))` — both // arg slots of `+` are Own/Consume, so `xs` is consumed twice // without intervening clone. The second occurrence triggers // `use-after-consume`. (Using `+` rather than `Seq` keeps the // function's return-type Int, which matches its declared signature.) let bad = Def::Fn(FnDef { name: "bad".into(), ty: Type::Fn { params: vec![Type::Con { name: "List".into(), args: vec![] }], param_modes: vec![ParamMode::Own], ret: Box::new(Type::int()), ret_mode: ParamMode::Own, effects: vec![], }, params: vec!["xs".into()], body: Term::App { callee: Box::new(Term::Var { name: "+".into() }), args: vec![ Term::App { callee: Box::new(Term::Var { name: "sum_list".into() }), args: vec![Term::Var { name: "xs".into() }], tail: false, }, Term::App { callee: Box::new(Term::Var { name: "sum_list".into() }), args: vec![Term::Var { name: "xs".into() }], tail: false, }, ], tail: false, }, suppress: vec![], doc: None, export: None, }); // List ADT (referenced by both fn types via `Type::Con`); a real // module needs the type to be in-scope for `check_type_well_formed`. let list_adt = Def::Type(TypeDef { name: "List".into(), vars: vec![], ctors: vec![Ctor { name: "Nil".into(), fields: vec![], }], doc: None, drop_iterative: false, param_in: BTreeMap::new(), }); let m = Module { schema: ailang_core::SCHEMA.into(), name: "lin_uac".into(), kernel: false, imports: vec![], defs: vec![list_adt, sum_list, bad], }; let mut modules = BTreeMap::new(); modules.insert(m.name.clone(), m.clone()); let ws = ailang_core::Workspace { entry: m.name.clone(), modules, root_dir: std::path::PathBuf::from("."), registry: ailang_core::workspace::Registry::default(), }; let diags = check_workspace(&ws); let lin: Vec<&ailang_check::Diagnostic> = diags .iter() .filter(|d| d.code == "use-after-consume") .collect(); assert_eq!( lin.len(), 1, "want exactly one use-after-consume; got: {:#?}", diags ); let d = lin[0]; assert!(matches!(d.severity, Severity::Error)); assert_eq!(d.def.as_deref(), Some("bad")); assert_eq!( d.ctx.get("binder").and_then(|v| v.as_str()), Some("xs"), "ctx should name the offending binder; got {:?}", d.ctx ); assert_suggested_rewrites_well_formed(d); } /// a fn whose body passes `xs` to a `Borrow` arg and then, /// in a SIBLING arg slot of the same call, consumes it via an `Own` /// arg, should trigger `consume-while-borrowed`. /// /// Body: /// `(app dual_fn xs xs)` /// where `dual_fn` has param_modes = [Borrow, Own]. #[test] fn consume_while_borrowed_in_sibling_arg_is_reported() { use ailang_core::ast::*; use std::collections::BTreeMap; let list_adt = Def::Type(TypeDef { name: "List".into(), vars: vec![], ctors: vec![Ctor { name: "Nil".into(), fields: vec![], }], doc: None, drop_iterative: false, param_in: BTreeMap::new(), }); // dual_fn: (borrow List) → (own List) → Int. let dual_fn = Def::Fn(FnDef { name: "dual_fn".into(), ty: Type::Fn { params: vec![ Type::Con { name: "List".into(), args: vec![] }, Type::Con { name: "List".into(), args: vec![] }, ], param_modes: vec![ParamMode::Borrow, ParamMode::Own], ret: Box::new(Type::int()), ret_mode: ParamMode::Own, effects: vec![], }, params: vec!["a".into(), "b".into()], body: Term::Lit { lit: Literal::Int { value: 0 } }, suppress: vec![], doc: None, export: None, }); let bad = Def::Fn(FnDef { name: "bad".into(), ty: Type::Fn { params: vec![Type::Con { name: "List".into(), args: vec![] }], param_modes: vec![ParamMode::Own], ret: Box::new(Type::int()), ret_mode: ParamMode::Own, effects: vec![], }, params: vec!["xs".into()], body: Term::App { callee: Box::new(Term::Var { name: "dual_fn".into() }), args: vec![ Term::Var { name: "xs".into() }, Term::Var { name: "xs".into() }, ], tail: false, }, suppress: vec![], doc: None, export: None, }); let m = Module { schema: ailang_core::SCHEMA.into(), name: "lin_cwb".into(), kernel: false, imports: vec![], defs: vec![list_adt, dual_fn, bad], }; let mut modules = BTreeMap::new(); modules.insert(m.name.clone(), m.clone()); let ws = ailang_core::Workspace { entry: m.name.clone(), modules, root_dir: std::path::PathBuf::from("."), registry: ailang_core::workspace::Registry::default(), }; let diags = check_workspace(&ws); let lin: Vec<&ailang_check::Diagnostic> = diags .iter() .filter(|d| d.code == "consume-while-borrowed") .collect(); assert_eq!( lin.len(), 1, "want exactly one consume-while-borrowed; got: {:#?}", diags ); let d = lin[0]; assert!(matches!(d.severity, Severity::Error)); assert_eq!(d.def.as_deref(), Some("bad")); assert_eq!( d.ctx.get("binder").and_then(|v| v.as_str()), Some("xs"), "ctx should name the offending binder; got {:?}", d.ctx ); assert_suggested_rewrites_well_formed(d); } /// happy-path `(reuse-as xs (term-ctor List Cons ...))` /// inside an all-explicit-mode `map_inc`-style fn must produce NO /// diagnostics — neither `reuse-as-non-allocating-body` (body is a /// Ctor) nor `reuse-as-source-not-bare-var` (source is `xs`) nor /// `use-after-consume` (xs is matched then consumed once via /// reuse-as in the Cons arm; the merge across arms is consistent). /// /// Body: /// `(match xs /// (case Nil (term-ctor List Nil)) /// (case (Cons h t) /// (reuse-as xs (term-ctor List Cons (+ h 1) (app map_inc t)))))` #[test] fn reuse_as_happy_path_in_map_inc_is_linearity_clean() { use ailang_core::ast::*; use std::collections::BTreeMap; let list_adt = Def::Type(TypeDef { name: "List".into(), vars: vec![], ctors: vec![ Ctor { name: "Nil".into(), fields: vec![] }, Ctor { name: "Cons".into(), fields: vec![ Type::Con { name: "Int".into(), args: vec![] }, Type::Con { name: "List".into(), args: vec![] }, ], }, ], doc: None, drop_iterative: false, param_in: BTreeMap::new(), }); let map_inc_ty = Type::Fn { params: vec![Type::Con { name: "List".into(), args: vec![] }], param_modes: vec![ParamMode::Own], ret: Box::new(Type::Con { name: "List".into(), args: vec![] }), ret_mode: ParamMode::Own, effects: vec![], }; // Cons arm body: // (reuse-as xs // (term-ctor List Cons (app + h 1) (app map_inc t))) let cons_arm_body = Term::ReuseAs { source: Box::new(Term::Var { name: "xs".into() }), body: Box::new(Term::Ctor { type_name: "List".into(), ctor: "Cons".into(), args: vec![ Term::App { callee: Box::new(Term::Var { name: "+".into() }), args: vec![ Term::Var { name: "h".into() }, Term::Lit { lit: Literal::Int { value: 1 } }, ], tail: false, }, Term::App { callee: Box::new(Term::Var { name: "map_inc".into() }), args: vec![Term::Var { name: "t".into() }], tail: false, }, ], }), }; let map_inc_body = Term::Match { scrutinee: Box::new(Term::Var { name: "xs".into() }), arms: vec![ Arm { pat: Pattern::Ctor { ctor: "Nil".into(), fields: vec![] }, body: Term::Ctor { type_name: "List".into(), ctor: "Nil".into(), args: vec![], }, }, Arm { pat: Pattern::Ctor { ctor: "Cons".into(), fields: vec![ Pattern::Var { name: "h".into() }, Pattern::Var { name: "t".into() }, ], }, body: cons_arm_body, }, ], }; let map_inc = Def::Fn(FnDef { name: "map_inc".into(), ty: map_inc_ty, params: vec!["xs".into()], body: map_inc_body, suppress: vec![], doc: None, export: None, }); let m = Module { schema: ailang_core::SCHEMA.into(), name: "reuse_as_happy".into(), kernel: false, imports: vec![], defs: vec![list_adt, map_inc], }; let mut modules = BTreeMap::new(); modules.insert(m.name.clone(), m.clone()); let ws = ailang_core::Workspace { entry: m.name.clone(), modules, root_dir: std::path::PathBuf::from("."), registry: ailang_core::workspace::Registry::default(), }; let diags = check_workspace(&ws); assert!( diags.is_empty(), "happy-path reuse-as must check clean; got: {:#?}", diags ); } /// a deliberately mismatched reuse-as — `(reuse-as xs /// (Nil))` inside the Cons arm of a match on `xs` — must surface as /// a `reuse-as-shape-mismatch` diagnostic. The source's path-ctor /// (Cons, 2 fields) and the body ctor (Nil, 0 fields) have different /// shapes; the in-place rewrite is unsafe; the build must fail. /// /// The diagnostic must: /// - carry code `reuse-as-shape-mismatch` /// - identify the offending def (`f`) /// - record `ctx.reason` as a stable kebab-case sub-code (here: /// `field-count-mismatch`) so JSON consumers can branch on the /// specific failure mode without prose-parsing /// - ship a non-empty `suggested_rewrites` whose replacement parses /// back as form-A AILang (the rewrite drops the wrapper and keeps /// the body alone) #[test] fn reuse_as_shape_mismatch_is_reported_on_cons_to_nil() { use ailang_core::ast::*; use std::collections::BTreeMap; let list_adt = Def::Type(TypeDef { name: "List".into(), vars: vec![], ctors: vec![ Ctor { name: "Nil".into(), fields: vec![] }, Ctor { name: "Cons".into(), fields: vec![ Type::Con { name: "Int".into(), args: vec![] }, Type::Con { name: "List".into(), args: vec![] }, ], }, ], doc: None, drop_iterative: false, param_in: BTreeMap::new(), }); let f_ty = Type::Fn { params: vec![Type::Con { name: "List".into(), args: vec![] }], param_modes: vec![ParamMode::Own], ret: Box::new(Type::Con { name: "List".into(), args: vec![] }), ret_mode: ParamMode::Own, effects: vec![], }; // Body: // (match xs // (Nil → Nil) // (Cons h t → (reuse-as xs Nil))) ; BAD: 2-field Cons → 0-field Nil. let cons_arm_body = Term::ReuseAs { source: Box::new(Term::Var { name: "xs".into() }), body: Box::new(Term::Ctor { type_name: "List".into(), ctor: "Nil".into(), args: vec![], }), }; let body = Term::Match { scrutinee: Box::new(Term::Var { name: "xs".into() }), arms: vec![ Arm { pat: Pattern::Ctor { ctor: "Nil".into(), fields: vec![] }, body: Term::Ctor { type_name: "List".into(), ctor: "Nil".into(), args: vec![], }, }, Arm { pat: Pattern::Ctor { ctor: "Cons".into(), fields: vec![ Pattern::Var { name: "h".into() }, Pattern::Var { name: "t".into() }, ], }, body: cons_arm_body, }, ], }; let f = Def::Fn(FnDef { name: "f".into(), ty: f_ty, params: vec!["xs".into()], body, suppress: vec![], doc: None, export: None, }); let m = Module { schema: ailang_core::SCHEMA.into(), name: "reuse_as_mismatch".into(), kernel: false, imports: vec![], defs: vec![list_adt, f], }; let mut modules = BTreeMap::new(); modules.insert(m.name.clone(), m.clone()); let ws = ailang_core::Workspace { entry: m.name.clone(), modules, root_dir: std::path::PathBuf::from("."), registry: ailang_core::workspace::Registry::default(), }; let diags = check_workspace(&ws); let shape_diags: Vec<&ailang_check::Diagnostic> = diags .iter() .filter(|d| d.code == "reuse-as-shape-mismatch") .collect(); assert_eq!( shape_diags.len(), 1, "expected exactly one reuse-as-shape-mismatch diagnostic; got: {diags:#?}" ); let d = shape_diags[0]; assert!(matches!(d.severity, Severity::Error)); assert_eq!(d.def.as_deref(), Some("f")); assert_eq!( d.ctx.get("reason").and_then(|v| v.as_str()), Some("field-count-mismatch"), "ctx.reason must be the stable sub-code; ctx was: {}", d.ctx ); assert_suggested_rewrites_well_formed(d); } /// positive control. The ON-DISK `borrow_own_demo` fixture /// (the only currently-shipping all-explicit-mode program) must remain /// linearity-clean. If this regresses, the check has become incorrect: /// `borrow_own_demo`'s `list_length` (borrow) and `sum_list` (own) /// are exactly the canonical accept shape. #[test] fn borrow_own_demo_is_linearity_clean() { let entry = examples_dir().join("borrow_own_demo.ail"); let ws = load_workspace(&entry).expect("load borrow_own_demo"); let diags = check_workspace(&ws); let lin: Vec<&ailang_check::Diagnostic> = diags .iter() .filter(|d| { d.code == "use-after-consume" || d.code == "consume-while-borrowed" }) .collect(); assert!( lin.is_empty(), "borrow_own_demo must stay linearity-clean; got: {:#?}", lin ); // Belt-and-braces: the *whole* check should be clean too — modes // are still metadata-only at the typechecker level (Iter 18a). assert!( diags.is_empty(), "borrow_own_demo must check clean; got: {:#?}", diags ); } /// #56 Fix 1+2: under universal activation the linearity analysis must /// not false-fire on value-type params or on applied function params. /// These three fixtures use explicit-mode signatures (so the analysis /// is active today) and were RED before the hardening (docs/specs/0063). #[test] fn harden_ownership_false_positives_are_clean() { for name in ["fp_value", "fp_hof", "fp_map", "c3_value_let", "c1_local_hof", "c2_let_alias", "c4_rewrite"] { let entry = examples_dir().join(format!("{name}.ail")); let ws = load_workspace(&entry).unwrap_or_else(|e| panic!("load {name}: {e:?}")); let diags = check_workspace(&ws); let lin: Vec<&ailang_check::Diagnostic> = diags .iter() .filter(|d| d.code == "use-after-consume" || d.code == "consume-while-borrowed") .collect(); assert!(lin.is_empty(), "{name} must be linearity-clean; got: {lin:#?}"); } } /// #56 type-gating + #57 class 4: a heap value consumed twice MUST still /// fire use-after-consume — `real_consume.dup` (`(term-ctor Pair Pair b /// b)`) and `c4_double_consume.both` (`rest` projected by both `fst` and /// `snd`, the genuine double-consume the partition_eithers rewrite /// removes). Proves the hardening did not blanket-silence genuine /// multi-consume. #[test] fn harden_ownership_heap_double_consume_still_errors() { for name in ["real_consume", "c4_double_consume"] { let entry = examples_dir().join(format!("{name}.ail")); let ws = load_workspace(&entry).unwrap_or_else(|e| panic!("load {name}: {e:?}")); let diags = check_workspace(&ws); assert!( diags.iter().any(|d| d.code == "use-after-consume"), "{name} must still fire use-after-consume; got: {diags:#?}" ); } } /// RED for fieldtest finding B1 (docs/specs/0058): reading a /// `borrow (RawBuf a)` *parameter* through a borrow-receiver op /// (`RawBuf.get` / `RawBuf.size`) must check clean. Both ops are /// declared `(borrow (RawBuf a))` in the receiver slot by the kernel /// `raw_buf` module, so reading the receiver through a borrow is the /// advertised use — the receiver is read, not consumed. /// /// The linearity walk (`crates/ailang-check/src/linearity.rs`) /// registers visible-module fns into its `globals` map under their /// bare def name (`get` / `size`), but the call site spells the /// type-scoped name `RawBuf.get` / `RawBuf.size`. `callee_arg_modes` /// looks up `"RawBuf.get"`, misses, returns an empty mode vec, and the /// receiver arg defaults to `Position::Consume` — consuming a binder /// whose `borrow_count == 1` and firing a spurious /// `consume-while-borrowed`. #[test] fn rawbuf_borrow_receiver_read_is_linearity_clean() { let entry = examples_dir().join("raw_buf_borrow_read.ail"); let ws = load_workspace(&entry).expect("load raw_buf_borrow_read"); let diags = check_workspace(&ws); let lin: Vec<&ailang_check::Diagnostic> = diags .iter() .filter(|d| { d.code == "use-after-consume" || d.code == "consume-while-borrowed" }) .collect(); assert!( lin.is_empty(), "reading a borrow-mode RawBuf param via RawBuf.get / RawBuf.size \ must not fire a linearity error; got: {:#?}", lin ); assert!( diags.is_empty(), "raw_buf_borrow_read must check clean; got: {:#?}", diags ); } /// RED for #50: a kernel-tier `RawBuf` stored in a *user ADT field* /// typed with the bare name `(con RawBuf (con Int))` must resolve to /// `raw_buf.RawBuf` — exactly as the auto-import already does in /// op/value positions (`(new RawBuf …)`, `RawBuf.get`). The property /// this protects: the kernel-tier auto-import covers the /// type-constructor position inside a user `data` field, not only /// op/value positions, so an author can store a `RawBuf` in their own /// ADT field without spelling the qualified `raw_buf.RawBuf`. /// /// Cause: `qualify_workspace_module` /// (`crates/ailang-check/src/lib.rs`) skips `Def::Type`, so a bare /// cross-module type-con in a consumer module's ADT field is never /// qualified; the `Term::Ctor` check arm only qualifies field types /// when the *owning* type is itself cross-module (`owning_module = /// Some`), not when a *local* type carries a cross-module field. The /// field stays `RawBuf` and fails to unify with the constructor /// arg's `raw_buf.RawBuf`. /// /// Control twin `raw_buf_adt_field_qualified.ail` (same module, field /// typed `(con raw_buf.RawBuf (con Int))`) checks clean today, which /// isolates the cause to bare-name resolution in the type-con /// position rather than any loader / syntax issue. #[test] fn rawbuf_in_user_adt_field_resolves_bare_name() { let entry = examples_dir().join("raw_buf_adt_field_bare.ail"); let ws = load_workspace(&entry).expect("load raw_buf_adt_field_bare"); let diags = check_workspace(&ws); assert!( diags.is_empty(), "a bare `RawBuf` ADT field type must resolve to raw_buf.RawBuf \ just like op/value positions do; got: {:#?}", diags ); }