From 8a6bb28a7b8f5c47114cd307feb5f2267f1244ee Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 16:13:40 +0000 Subject: [PATCH 1/2] fix: resolve include-exposed actions called via the `of` form (#580) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #548 relaxed the fatal "Undefined action" for include-exposed actions, but only for the `call with ` form. The idiomatic ` of ` form parses to `Expression::FunctionCall` whose callee is a bare `Variable`, a path that never reached #548's relaxation — so it stayed fatal at top level and inside action bodies (while incidentally "working" inside `main loop`/`describe`/`test`, whose bodies the analyzer does not descend into). This made natural multi-file libraries unusable through their idiomatic top-level API. Analyzer (`src/analyzer/mod.rs`): in the `FunctionCall` arm, stop recursing into a bare-`Variable` callee (which reported it as fatal before the block could relax it) and resolve it inline. When the callee is unresolved, not a builtin/parameter, and the program uses `include from`, emit the same non-fatal "Undefined action" warning as the `ActionCall` path; otherwise fall back to the pre-existing `report_undefined_name` behavior (preserving the `try_depth` downgrade and the no-include fatal). Type checker (`src/typechecker/mod.rs`): mirror the `ActionCall` relaxation in the `FunctionCall` arm — an unresolved-`Variable` callee in an include-using program yields `Type::Any` instead of `Unknown`, avoiding the spurious "could not infer type" cascade the `call` form already avoids. The same change fixes the #547-class nested-include case: a file that references (via `of`) an action from a file it itself includes now passes its isolated analysis, because it has a top-level `include` and so takes the relaxation. Adds `tests/include_of_form_resolution_test.rs`: the `of` form across top-level / action body / container action / main loop / describe-test, multi-arg and chained `of`, the nested-include scenario, plus guard tests (undefined-without-include stays fatal, typo-with-include is a warning, undefined argument still reported, `call ... with` unregressed) and a snapshot pinning that main-loop bodies are currently not statically analyzed (recommended follow-up). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Kj9BVovsRf5DdNWkeuf3wd --- src/analyzer/mod.rs | 51 +++- src/typechecker/mod.rs | 20 ++ tests/include_of_form_resolution_test.rs | 332 +++++++++++++++++++++++ 3 files changed, 400 insertions(+), 3 deletions(-) create mode 100644 tests/include_of_form_resolution_test.rs diff --git a/src/analyzer/mod.rs b/src/analyzer/mod.rs index bfecb706..e136de0b 100644 --- a/src/analyzer/mod.rs +++ b/src/analyzer/mod.rs @@ -2259,7 +2259,17 @@ impl Analyzer { line, column, } => { - self.analyze_expression(function); + // A bare-`Variable` callee is the idiomatic `of` call form + // (e.g. `greet of "bob"`, parsed as FunctionCall { function: + // Variable("greet"), .. }). It is resolved explicitly in the + // block below — including the include-aware relaxation — so + // analyzing it here would recurse into the Variable arm and + // report it as a *fatal* undefined variable before the block + // can relax it (issue #580). Only analyze the callee directly + // when it is a more complex expression. + if !matches!(&**function, Expression::Variable(_, _, _)) { + self.analyze_expression(function); + } if let Expression::Variable(name, _, _) = &**function { if let Some(symbol) = self.current_scope.resolve(name) { @@ -2320,12 +2330,47 @@ impl Analyzer { } } } - } else if Self::is_builtin_function(name) { - // A known builtin that isn't present as a scope symbol is + } else if Self::is_builtin_function(name) + || self.action_parameters.contains(name) + || name == "count" + { + // A known builtin, an action parameter, or the count + // loop variable isn't present as a scope symbol but is // still callable (its arguments are still analyzed). for arg in arguments { self.analyze_expression(&arg.value); } + } else if self.has_includes { + // Callee not in scope and not a builtin, but the program + // uses `include from`. The action may be exposed by an + // included file at runtime (which the analyzer cannot + // see), so treating this as a fatal error would abort the + // program before the include runs. This is the `of`-form + // counterpart of the ActionCall relaxation below (issues + // #580 / #548); it may also be a genuine typo, so it is a + // non-fatal warning rather than being fully suppressed. + self.warnings.push(SemanticError::new( + format!("Undefined action '{name}'"), + *line, + *column, + )); + for arg in arguments { + self.analyze_expression(&arg.value); + } + } else { + // No includes: preserve the pre-existing fatal behavior + // (and the try_depth > 0 warning downgrade) for a + // genuinely undefined callee — this is exactly what the + // unconditional `analyze_expression(function)` above used + // to produce for a bare-Variable callee. + self.report_undefined_name( + format!("Variable '{name}' is not defined"), + *line, + *column, + ); + for arg in arguments { + self.analyze_expression(&arg.value); + } } } else { for arg in arguments { diff --git a/src/typechecker/mod.rs b/src/typechecker/mod.rs index 68f4f380..8e28a81a 100644 --- a/src/typechecker/mod.rs +++ b/src/typechecker/mod.rs @@ -2659,6 +2659,26 @@ impl TypeChecker { line, column, } => { + // The idiomatic `of` call form (`greet of "bob"`) parses as a + // FunctionCall whose callee is a bare Variable. When that callee + // is not resolvable statically but the program uses `include + // from`, the action may be exposed by an included file at + // runtime, so its result type is unknowable — treat it as Any to + // avoid cascading "could not infer type" errors, mirroring the + // ActionCall path (issues #580 / #548). Arguments are still + // inferred so type errors inside them are not missed. + if let Expression::Variable(callee, _, _) = &**function { + let is_known = self.analyzer.get_symbol(callee).is_some() + || Analyzer::is_builtin_function(callee) + || self.analyzer.get_action_parameters().contains(callee); + if !is_known && self.has_includes { + for arg in arguments { + let _ = self.infer_expression_type(&arg.value); + } + return Type::Any; + } + } + let function_type = self.infer_expression_type(function); match function_type { diff --git a/tests/include_of_form_resolution_test.rs b/tests/include_of_form_resolution_test.rs new file mode 100644 index 00000000..210dc400 --- /dev/null +++ b/tests/include_of_form_resolution_test.rs @@ -0,0 +1,332 @@ +//! Regression + robustness tests for issue #580 (and the #547-class nested +//! include it shares a root cause with). +//! +//! #548 made an include-exposed action callable through the `call with +//! ` form from a top-level statement (relaxing the fatal "Undefined action" +//! to a non-fatal warning whenever the program uses `include from`). #580 is the +//! same bug surfacing through the *idiomatic* ` of ` form, which +//! parses to `Expression::FunctionCall { function: Variable(..), .. }` and never +//! reached #548's relaxation — so it stayed fatal at top level and inside action +//! bodies, while incidentally "working" inside `main loop`/`describe`/`test` +//! (whose bodies the analyzer never descends into). +//! +//! This suite pins the `of` form across every call context, exercises the +//! nested-include case, and guards the boundary so genuinely undefined names are +//! still surfaced (fatal without includes, warning with includes). + +use std::fs; +use std::process::Command; +use tempfile::TempDir; + +fn wfl_exe() -> &'static str { + if cfg!(target_os = "windows") { + "target/release/wfl.exe" + } else { + "target/release/wfl" + } +} + +/// Run a WFL file inside `dir` (so `include from` resolves sibling modules), +/// returning (combined stdout+stderr, exit code). +fn run_file_status(dir: &TempDir, name: &str, extra_args: &[&str]) -> (String, Option) { + let path = dir.path().join(name); + let output = Command::new(wfl_exe()) + .args(extra_args) + .arg(&path) + .output() + .expect("Failed to execute WFL"); + let combined = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + (combined, output.status.code()) +} + +/// Write a `mod.wfl` exposing a one-arg `greet` action next to a `main.wfl` +/// whose body is `main_body`, then run `main.wfl`. +fn with_greet_module(main_body: &str) -> (String, Option) { + let dir = TempDir::new().expect("tempdir"); + fs::write( + dir.path().join("mod.wfl"), + "define action called greet with parameters s:\n return \"HI-\" with s\nend action\n", + ) + .unwrap(); + fs::write(dir.path().join("main.wfl"), main_body).unwrap(); + let out = run_file_status(&dir, "main.wfl", &[]); + // keep the tempdir alive until after the run + drop(dir); + out +} + +/// Assert the run produced no *fatal* undefined-name diagnostic. The relaxed, +/// non-fatal warning's explanatory note reads "This action is not defined in +/// this file ...", so we match only the fatal form `' is not defined` +/// (emitted as `Variable 'greet' is not defined`) to avoid a false positive. +fn assert_no_undefined_fatal(out: &str) { + assert!( + !out.contains("' is not defined"), + "should not report a fatal \"'... is not defined\": {out}" + ); +} + +// --------------------------------------------------------------------------- +// #580 — the `of` form must resolve include-exposed actions in every context +// --------------------------------------------------------------------------- + +/// The exact repro from the issue: `of` form in a top-level statement. +#[test] +fn of_form_top_level_statement() { + let (out, code) = with_greet_module( + "include from \"mod.wfl\"\ndisplay \"BEFORE\"\nstore g as greet of \"bob\"\ndisplay \"AFTER=\" with g\n", + ); + assert!(out.contains("BEFORE"), "BEFORE should print: {out}"); + assert!(out.contains("AFTER=HI-bob"), "expected AFTER=HI-bob: {out}"); + assert_no_undefined_fatal(&out); + // The type checker must treat the include-exposed `of` result as Any (issue + // #580), matching the `call` form — no spurious inference warning. + assert!( + !out.contains("Could not infer type"), + "of-form result should be typed as Any: {out}" + ); + assert_eq!(code, Some(0), "program should exit 0: {out}"); +} + +/// `of` form inside a user-defined action body. +#[test] +fn of_form_inside_action_body() { + let (out, code) = with_greet_module( + "include from \"mod.wfl\"\n\ + define action called wrap with parameters s:\n return greet of s\nend action\n\ + store r as wrap of \"bob\"\ndisplay \"R=\" with r\n", + ); + assert!(out.contains("R=HI-bob"), "expected R=HI-bob: {out}"); + assert_no_undefined_fatal(&out); + assert_eq!(code, Some(0), "program should exit 0: {out}"); +} + +/// `of` form inside a container action (method) body. +#[test] +fn of_form_inside_container_action_body() { + let (out, code) = with_greet_module( + "include from \"mod.wfl\"\n\ + create container Greeter:\n action run needs s: Text:\n return greet of s\n end\nend\n\ + create new Greeter as gtr:\nend\n\ + display \"C=\" with gtr.run(\"bob\")\n", + ); + assert!(out.contains("C=HI-bob"), "expected C=HI-bob: {out}"); + assert_no_undefined_fatal(&out); + assert_eq!(code, Some(0), "program should exit 0: {out}"); +} + +/// `of` form inside a `main loop` body (was already non-fatal — regression guard). +#[test] +fn of_form_inside_main_loop() { + let (out, code) = with_greet_module( + "include from \"mod.wfl\"\nmain loop:\n store g as greet of \"bob\"\n display \"L=\" with g\n break\nend loop\n", + ); + assert!(out.contains("L=HI-bob"), "expected L=HI-bob: {out}"); + assert_no_undefined_fatal(&out); + assert_eq!(code, Some(0), "program should exit 0: {out}"); +} + +/// `of` form inside a `describe`/`test` block, run with `--test`. +#[test] +fn of_form_inside_describe_test() { + let dir = TempDir::new().expect("tempdir"); + fs::write( + dir.path().join("mod.wfl"), + "define action called greet with parameters s:\n return \"HI-\" with s\nend action\n", + ) + .unwrap(); + fs::write( + dir.path().join("main.wfl"), + "include from \"mod.wfl\"\n\ + describe \"greeting\":\n test \"greet works\":\n store g as greet of \"bob\"\n expect g to equal \"HI-bob\"\n end test\nend describe\n", + ) + .unwrap(); + let (out, code) = run_file_status(&dir, "main.wfl", &["--test"]); + assert_no_undefined_fatal(&out); + assert!( + out.contains("Passed: 1") && out.contains("Failed: 0"), + "the include-exposed `of` test should pass: {out}" + ); + // The `of` form must not emit the spurious "could not infer type" warning + // that the `call` form avoids (type checker relaxation, issue #580). + assert!( + !out.contains("Could not infer type"), + "of-form result should be typed as Any, not trigger inference errors: {out}" + ); + assert_eq!(code, Some(0), "test run should exit 0: {out}"); +} + +/// Multi-argument `of` form: `render of tmpl and ctx` at top level. +#[test] +fn of_form_multi_argument() { + let dir = TempDir::new().expect("tempdir"); + fs::write( + dir.path().join("mod.wfl"), + "define action called render with parameters tmpl and ctx:\n return tmpl with \"|\" with ctx\nend action\n", + ) + .unwrap(); + fs::write( + dir.path().join("main.wfl"), + "include from \"mod.wfl\"\nstore out as render of \"T\" and \"C\"\ndisplay \"M=\" with out\n", + ) + .unwrap(); + let (out, code) = run_file_status(&dir, "main.wfl", &[]); + assert!(out.contains("M=T|C"), "expected M=T|C: {out}"); + assert_no_undefined_fatal(&out); + assert_eq!(code, Some(0), "program should exit 0: {out}"); +} + +/// Nested/chained `of`: `greet of (greet of "x")` — the argument is itself an +/// include-exposed `of` call. +#[test] +fn of_form_nested_chained() { + let (out, code) = with_greet_module( + "include from \"mod.wfl\"\nstore g as greet of (greet of \"x\")\ndisplay \"N=\" with g\n", + ); + assert!(out.contains("N=HI-HI-x"), "expected N=HI-HI-x: {out}"); + assert_no_undefined_fatal(&out); + assert_eq!(code, Some(0), "program should exit 0: {out}"); +} + +// --------------------------------------------------------------------------- +// #547-class — nested includes: a file referencing an action from a file IT +// includes must analyze (in isolation) without a fatal undefined-name error. +// --------------------------------------------------------------------------- + +#[test] +fn nested_include_of_form_in_container_action() { + let dir = TempDir::new().expect("tempdir"); + fs::write( + dir.path().join("base.wfl"), + "define action called base_op with parameters x:\n return \"base(\" with x with \")\"\nend action\n", + ) + .unwrap(); + fs::write( + dir.path().join("mid.wfl"), + "include from \"base.wfl\"\n\ + create container Engine:\n action run needs x: Text:\n return base_op of x\n end\nend\n", + ) + .unwrap(); + fs::write( + dir.path().join("app.wfl"), + "include from \"mid.wfl\"\ncreate new Engine as e:\nend\ndisplay e.run(\"hi\")\n", + ) + .unwrap(); + let (out, code) = run_file_status(&dir, "app.wfl", &[]); + assert!(out.contains("base(hi)"), "expected base(hi): {out}"); + assert!( + !out.contains("Semantic error in included file"), + "nested include should not fail isolated analysis: {out}" + ); + assert_no_undefined_fatal(&out); + assert_eq!(code, Some(0), "program should exit 0: {out}"); +} + +// --------------------------------------------------------------------------- +// Guard rails — the relaxation must not mask genuinely undefined names. +// --------------------------------------------------------------------------- + +/// Without any `include from`, an `of` call to an undefined callee stays fatal. +#[test] +fn of_form_undefined_without_include_still_fatal() { + let dir = TempDir::new().expect("tempdir"); + fs::write( + dir.path().join("main.wfl"), + "display \"BEFORE\"\nstore g as missing_action of \"x\"\ndisplay g\n", + ) + .unwrap(); + let (out, code) = run_file_status(&dir, "main.wfl", &[]); + assert!( + out.contains("is not defined"), + "undefined callee without includes must stay fatal: {out}" + ); + assert_eq!(code, Some(3), "should exit 3 (fatal analyze error): {out}"); +} + +/// With an include present, a typo'd `of` callee is surfaced as a non-fatal +/// warning under `--analyze` (not silently dropped, not fatal). +#[test] +fn of_form_typo_with_include_is_warning() { + let dir = TempDir::new().expect("tempdir"); + fs::write( + dir.path().join("mod.wfl"), + "define action called greet with parameters s:\n return \"HI-\" with s\nend action\n", + ) + .unwrap(); + // `grret` is a typo for `greet`. + fs::write( + dir.path().join("main.wfl"), + "include from \"mod.wfl\"\nstore g as grret of \"bob\"\ndisplay g\n", + ) + .unwrap(); + let (out, _code) = run_file_status(&dir, "main.wfl", &["--analyze"]); + assert!( + out.contains("Undefined action") && out.contains("grret"), + "typo'd `of` callee should be surfaced with includes present: {out}" + ); + assert!( + out.to_lowercase().contains("warning"), + "should be reported at warning severity, not fatal: {out}" + ); +} + +/// The `call ... with` form (issue #548's fix) still works when `of`-form +/// siblings are present — no regression. +#[test] +fn call_with_form_still_works() { + let (out, code) = with_greet_module( + "include from \"mod.wfl\"\nstore g as call greet with \"bob\"\ndisplay \"K=\" with g\n", + ); + assert!(out.contains("K=HI-bob"), "expected K=HI-bob: {out}"); + assert_eq!(code, Some(0), "program should exit 0: {out}"); +} + +/// An undefined *argument* to an `of` call (no includes) is still reported — the +/// relaxation must not stop the analyzer from descending into arguments. +#[test] +fn of_form_undefined_argument_without_include_is_reported() { + // `touppercase` is a real builtin, so the callee resolves; the argument + // `nope` is undefined and must still be flagged. + let dir = TempDir::new().expect("tempdir"); + fs::write( + dir.path().join("main.wfl"), + "store g as touppercase of nope\ndisplay g\n", + ) + .unwrap(); + let (out, code) = run_file_status(&dir, "main.wfl", &[]); + assert!( + out.contains("is not defined") && out.contains("nope"), + "undefined argument should still be reported: {out}" + ); + assert_eq!(code, Some(3), "should exit 3: {out}"); +} + +// --------------------------------------------------------------------------- +// Shaken loose (documented, NOT fixed by #580): the analyzer never descends +// into `main loop` / `describe` / `test` bodies (catch-all `_ => {}`), so a +// genuine typo there is neither an error nor a warning at analyze time. This +// test pins that CURRENT behavior; if a future change adds analyzer coverage of +// those blocks (recommended follow-up), update this expectation deliberately. +// --------------------------------------------------------------------------- + +#[test] +fn main_loop_body_is_currently_not_statically_analyzed() { + // No `include from`: a bare undefined reference inside a main loop body is + // currently not reported by `--analyze` (the body is skipped entirely). + let dir = TempDir::new().expect("tempdir"); + fs::write( + dir.path().join("main.wfl"), + "main loop:\n display totally_bogus_ref\n break\nend loop\n", + ) + .unwrap(); + let (out, _code) = run_file_status(&dir, "main.wfl", &["--analyze"]); + // Snapshot of today's behavior: the analyzer does not flag the reference. + assert!( + !out.contains("totally_bogus_ref"), + "main-loop bodies are currently not analyzed; update this test if that changes: {out}" + ); +} From b82076085bf68ed4e6c7d292e7900c706c74c4a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 16:39:38 +0000 Subject: [PATCH 2/2] refactor: share include-aware callee handling between of/call paths (#580) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses CodeRabbit review feedback on PR #581. Issue #580 was caused by the `of` form (`FunctionCall`) and `call ... with` form (`ActionCall`) maintaining separate copies of the include-aware undefined-callee logic, so #548's relaxation reached only one of them. Consolidate the shared decision so the two paths cannot drift apart again. No behavior change. Analyzer (`src/analyzer/mod.rs`): extract `warn_undefined_callee_if_includes`, which owns the identical part — the `has_includes` check and the non-fatal "Undefined action" warning — and returns whether the caller must still emit its own fatal diagnostic. Both the `FunctionCall` bare-Variable-callee tail and the `ActionCall` tail route through it; each keeps its distinct fatal path (the `of` form's try_depth-aware `report_undefined_name` "Variable '…' is not defined" vs. the `call` form's "Undefined action" error). Type checker (`src/typechecker/mod.rs`): extract `is_callable_without_symbol` (builtins, action parameters, internal test stubs) and use it in both the `FunctionCall` and `ActionCall` "is this callee statically known?" checks. Verified unchanged: analyzer/typechecker lib unit tests (465), `docs_parser_and_include_fixes_test` (#547/#548/#551/#553), `include_of_form_resolution_test` (13), full suite, clippy -D warnings, fmt, and 102/102 TestPrograms integration. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Kj9BVovsRf5DdNWkeuf3wd --- src/analyzer/mod.rs | 93 +++++++++++++++++++++++------------------- src/typechecker/mod.rs | 21 ++++++---- 2 files changed, 65 insertions(+), 49 deletions(-) diff --git a/src/analyzer/mod.rs b/src/analyzer/mod.rs index e136de0b..fe4e0dde 100644 --- a/src/analyzer/mod.rs +++ b/src/analyzer/mod.rs @@ -488,6 +488,37 @@ impl Analyzer { } } + /// Include-aware relaxation for a callee that is not in scope and not a + /// builtin. When the program uses `include from`, the callee may be an + /// action exposed by an included file at runtime (which the analyzer cannot + /// see), so a fatal error would abort before the include runs — instead a + /// non-fatal `Undefined action` warning is emitted and `true` is returned. + /// When there are no includes, nothing is emitted and `false` is returned, + /// signalling the caller to apply its own fatal handling. + /// + /// Both the `of` form (`FunctionCall` with a bare-`Variable` callee) and the + /// `call ... with` form (`ActionCall`) route through this single method so + /// the relaxation cannot drift apart between the two paths again — the exact + /// divergence that was issue #580 (the `of` form never received #548's + /// `ActionCall`-only relaxation). + fn warn_undefined_callee_if_includes( + &mut self, + name: &str, + line: usize, + column: usize, + ) -> bool { + if self.has_includes { + self.warnings.push(SemanticError::new( + format!("Undefined action '{name}'"), + line, + column, + )); + true + } else { + false + } + } + fn analyze_statement(&mut self, statement: &Statement) { match statement { Statement::VariableDeclaration { @@ -2340,34 +2371,22 @@ impl Analyzer { for arg in arguments { self.analyze_expression(&arg.value); } - } else if self.has_includes { - // Callee not in scope and not a builtin, but the program - // uses `include from`. The action may be exposed by an - // included file at runtime (which the analyzer cannot - // see), so treating this as a fatal error would abort the - // program before the include runs. This is the `of`-form - // counterpart of the ActionCall relaxation below (issues - // #580 / #548); it may also be a genuine typo, so it is a - // non-fatal warning rather than being fully suppressed. - self.warnings.push(SemanticError::new( - format!("Undefined action '{name}'"), - *line, - *column, - )); - for arg in arguments { - self.analyze_expression(&arg.value); - } } else { - // No includes: preserve the pre-existing fatal behavior - // (and the try_depth > 0 warning downgrade) for a - // genuinely undefined callee — this is exactly what the - // unconditional `analyze_expression(function)` above used - // to produce for a bare-Variable callee. - self.report_undefined_name( - format!("Variable '{name}' is not defined"), - *line, - *column, - ); + // Callee not in scope and not a builtin. Under `include + // from` this is the `of`-form counterpart of the + // ActionCall relaxation (issues #580 / #548): a non-fatal + // warning, since the callee may be include-exposed at + // runtime. Otherwise preserve the pre-existing fatal + // behavior (and the try_depth > 0 warning downgrade) that + // the unconditional `analyze_expression(function)` above + // used to produce for a bare-Variable callee. + if !self.warn_undefined_callee_if_includes(name, *line, *column) { + self.report_undefined_name( + format!("Variable '{name}' is not defined"), + *line, + *column, + ); + } for arg in arguments { self.analyze_expression(&arg.value); } @@ -2590,21 +2609,11 @@ impl Analyzer { )); } } - } else if self.has_includes { - // Action not found in scope and not a builtin, but the program - // uses `include from`. The action may be exposed by an - // included file at runtime (which the analyzer cannot see), so - // treating this as a fatal error would abort the program - // before the include runs (see issue #548). It may also be a - // genuine typo, so it is reported as a non-fatal warning rather - // than being fully suppressed. - self.warnings.push(SemanticError::new( - format!("Undefined action '{}'", name), - *line, - *column, - )); - } else { - // Action not found in scope and not a builtin. + } else if !self.warn_undefined_callee_if_includes(name, *line, *column) { + // Action not found in scope and not a builtin, and the program + // does not use `include from`, so it cannot be include-exposed + // at runtime — a genuine fatal error (see issues #548 / #580 + // for the include-aware relaxation applied above). self.errors.push(SemanticError::new( format!("Undefined action '{}'", name), *line, diff --git a/src/typechecker/mod.rs b/src/typechecker/mod.rs index 8e28a81a..284bc714 100644 --- a/src/typechecker/mod.rs +++ b/src/typechecker/mod.rs @@ -2310,6 +2310,18 @@ impl TypeChecker { } } + /// Names that are callable even when they do not resolve to a scope symbol: + /// builtin stdlib functions, action parameters, and the internal test stubs. + /// Shared by the `FunctionCall` (`of` form) and `ActionCall` (`call ... with`) + /// inference paths so the "is this callee statically known?" decision cannot + /// drift apart between them again (the class of divergence behind issue #580). + fn is_callable_without_symbol(&self, name: &str) -> bool { + Analyzer::is_builtin_function(name) + || self.analyzer.get_action_parameters().contains(name) + || name == "helper_function" + || name == "nested_function" + } + fn infer_expression_type(&mut self, expression: &Expression) -> Type { match expression { Expression::Literal(literal, _, _) => match literal { @@ -2669,8 +2681,7 @@ impl TypeChecker { // inferred so type errors inside them are not missed. if let Expression::Variable(callee, _, _) = &**function { let is_known = self.analyzer.get_symbol(callee).is_some() - || Analyzer::is_builtin_function(callee) - || self.analyzer.get_action_parameters().contains(callee); + || self.is_callable_without_symbol(callee); if !is_known && self.has_includes { for arg in arguments { let _ = self.infer_expression_type(&arg.value); @@ -3063,11 +3074,7 @@ impl TypeChecker { if symbol_opt.is_none() { // Check if this is an action parameter, builtin function, or special function name before reporting it as undefined - if self.analyzer.get_action_parameters().contains(name) - || Analyzer::is_builtin_function(name) - || name == "helper_function" - || name == "nested_function" - { + if self.is_callable_without_symbol(name) { // It's an action parameter or a special function name, so don't report an error // For builtin functions, return their proper type if Analyzer::is_builtin_function(name) {