From 72f3f6541b3e41f2d4a7706dcad5bd784ec07d81 Mon Sep 17 00:00:00 2001 From: Brummel Date: Wed, 13 May 2026 11:59:49 +0200 Subject: [PATCH] debug: RED-pin for instance-body unbound-var bug ail check returns ok for a program that references an unbound identifier inside an instance method's lambda body. The error surfaces later at ail build as a degraded mono-pass diagnostic (monomorphise_workspace: unknown identifier) without source location, symbol kind, or "did you mean" candidates. Cause: check_def at crates/ailang-check/src/lib.rs:1593 early- returns Ok(()) for Def::Instance without walking method bodies. The arm's comment promises instance-body typechecking from iter 22b.2; that wiring was never implemented. Only the workspace- load coherence checks (Orphan/Duplicate/MissingMethod) touch instance defs, and they inspect schema only, not the method- body identifier graph. This commit lands RED only. GREEN follows via implement mini- mode. Fixture: examples/bug_unbound_in_instance_method.ail RED test: crates/ail/tests/unbound_in_instance_method_pin.rs - check_fires_unbound_var_for_str_concat_in_instance_method_body - check_json_unbound_var_in_instance_method_body --- .../tests/unbound_in_instance_method_pin.rs | 136 ++++++++++++++++++ examples/bug_unbound_in_instance_method.ail | 18 +++ 2 files changed, 154 insertions(+) create mode 100644 crates/ail/tests/unbound_in_instance_method_pin.rs create mode 100644 examples/bug_unbound_in_instance_method.ail diff --git a/crates/ail/tests/unbound_in_instance_method_pin.rs b/crates/ail/tests/unbound_in_instance_method_pin.rs new file mode 100644 index 0000000..4049286 --- /dev/null +++ b/crates/ail/tests/unbound_in_instance_method_pin.rs @@ -0,0 +1,136 @@ +//! 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: \`str_concat\``. +//! +//! Post-fix expected behaviour: +//! - `ail check` exits 1 with an `[unbound-var]` error naming +//! `str_concat`. 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 `str_concat` (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 `str_concat` 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 `str_concat`, +/// 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_str_concat_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("str_concat"), + "diagnostic must name the unbound identifier `str_concat`; \ + 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("str_concat") + }), + "expected an error diagnostic with code `unbound-var` naming \ + `str_concat`; got: {stdout}" + ); +} diff --git a/examples/bug_unbound_in_instance_method.ail b/examples/bug_unbound_in_instance_method.ail new file mode 100644 index 0000000..efe9a86 --- /dev/null +++ b/examples/bug_unbound_in_instance_method.ail @@ -0,0 +1,18 @@ +(module bug_unbound_in_instance_method + (class Describe + (param a) + (method describe + (type (fn-type (params (borrow a)) (ret (con Str)))))) + (instance + (class Describe) + (type (con Int)) + (method describe + (body + (lam + (params (typed n (con Int))) + (ret (con Str)) + (body (app str_concat "n=" (app int_to_str n))))))) + (fn main + (type (fn-type (params) (ret (con Unit)) (effects IO))) + (params) + (body (do io/print_str (app describe 5)))))