//! RED-pin for the instance-method-body unbound-var bug observed in //! the 2026-05-13 fieldtest of milestone-24 (Form-A authoring). //! //! Property protected: `check_def` walks `Def::Instance` method bodies //! through the same identifier-resolution path as `Def::Fn` bodies, so //! an unbound identifier inside an instance-method lambda body fires //! `[unbound-var]` at `ail check`, with exit code 1, BEFORE the //! monomorphisation pass runs. Without this, an unbound identifier //! inside an instance-method body slips past `ail check` (false-OK) //! and surfaces later at `ail build` as a degraded internal-error //! diagnostic ("monomorphise_workspace: unknown identifier: "), //! with no source location, no symbol kind, and no "did you mean ...?". //! //! Pre-fix observed behaviour (the bug): //! - `ail check` exits 0 with `ok (23 symbols across 2 modules)`. //! - `ail build` exits 1 with //! `Error: monomorphise_workspace: unknown identifier: \`format_label\``. //! //! Post-fix expected behaviour: //! - `ail check` exits 1 with an `[unbound-var]` error naming //! `format_label`. The mono pass never runs because check fails first. //! //! Root cause (from debugger Phase 1-2): //! `crates/ailang-check/src/lib.rs::check_def` early-returns `Ok(())` //! for `Def::Class | Def::Instance` (the comment claims body //! typechecking landed in iter 22b.2, but the body-walk was never //! wired). Only the workspace-load coherence checks //! (Orphan/Duplicate/MissingMethod) in `workspace::build_registry` //! touch instance defs, and those only inspect the schema, not the //! method-body identifier graph. //! //! Fixture: `examples/bug_unbound_in_instance_method.ail`. The fixture //! uses `format_label` (NOT a builtin) inside an instance-method lambda; //! `int_to_str` IS a builtin and is correctly resolved. The fixture //! parses cleanly — the only error is the unbound `format_label` in the //! method body. use std::path::Path; use std::process::Command; fn ail_bin() -> &'static str { env!("CARGO_BIN_EXE_ail") } /// RED today: `ail check` on `bug_unbound_in_instance_method.ail` /// must exit non-zero and emit `[unbound-var]` naming `format_label`, /// matching the diagnostic shape produced at fn-body level. /// /// Pre-fix, this test fails because `ail check` exits 0 and prints /// `ok (23 symbols across 2 modules)`. #[test] fn check_fires_unbound_var_for_format_label_in_instance_method_body() { let manifest_dir = env!("CARGO_MANIFEST_DIR"); let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap(); let src = workspace .join("examples") .join("bug_unbound_in_instance_method.ail"); assert!( src.exists(), "fixture missing: {} — RED test requires the .ail fixture in working tree", src.display() ); let output = Command::new(ail_bin()) .args(["check", src.to_str().unwrap()]) .output() .expect("ail check failed to spawn"); let code = output.status.code().expect("process terminated by signal"); let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); let combined = format!("STDOUT:\n{stdout}\nSTDERR:\n{stderr}"); assert_ne!( code, 0, "ail check must exit non-zero for an unbound identifier inside \ an instance-method body; got exit 0. {combined}" ); assert!( combined.contains("unbound-var"), "ail check must emit diagnostic code `unbound-var`; \ got: {combined}" ); assert!( combined.contains("format_label"), "diagnostic must name the unbound identifier `format_label`; \ got: {combined}" ); assert!( !combined.contains("monomorphise_workspace"), "the degraded mono-pass diagnostic must NOT surface — \ `ail check` should reject the program before mono runs; \ got: {combined}" ); } /// Companion RED: also assert the `--json` shape. The fn-body level /// already emits `{"severity":"error","code":"unbound-var"}` via /// `check_json_unbound_var` in `e2e.rs`; the instance-method-body /// path must produce the same structured shape. #[test] fn check_json_unbound_var_in_instance_method_body() { let manifest_dir = env!("CARGO_MANIFEST_DIR"); let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap(); let src = workspace .join("examples") .join("bug_unbound_in_instance_method.ail"); let output = Command::new(ail_bin()) .args(["check", src.to_str().unwrap(), "--json"]) .output() .expect("ail check --json failed to spawn"); let code = output.status.code().expect("process terminated by signal"); let stdout = String::from_utf8_lossy(&output.stdout).into_owned(); let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); let combined = format!("STDOUT:\n{stdout}\nSTDERR:\n{stderr}"); assert_eq!( code, 1, "ail check --json must exit 1; got {code}. {combined}" ); // Stdout must be a JSON array containing at least one // unbound-var error diagnostic. let diags: serde_json::Value = serde_json::from_str(stdout.trim()) .unwrap_or_else(|e| panic!("stdout must be JSON; parse error: {e}; got: {stdout}")); let arr = diags.as_array().expect("diagnostics must be a JSON array"); assert!( arr.iter().any(|d| { d.get("severity").and_then(|v| v.as_str()) == Some("error") && d.get("code").and_then(|v| v.as_str()) == Some("unbound-var") && d.to_string().contains("format_label") }), "expected an error diagnostic with code `unbound-var` naming \ `format_label`; got: {stdout}" ); }