diff --git a/Dev diary/2026-07-18-issue-592-bare-zero-arg-include.md b/Dev diary/2026-07-18-issue-592-bare-zero-arg-include.md new file mode 100644 index 00000000..0b8cfd43 --- /dev/null +++ b/Dev diary/2026-07-18-issue-592-bare-zero-arg-include.md @@ -0,0 +1,95 @@ +# Dev Diary — 2026-07-18: Bare zero-arg include-exposed action reference (#592) + +## Context + +This is the first increment of **Phase 2 — Language correctness** under the +production-readiness tracker (#610), advancing the workstream *"Make main-file +and included-module semantics consistent"*. + +A zero-argument action exposed by an `include from` file, referenced by its +**bare name** (no `of`, no `call`), was a **fatal** analyze error: + +```wfl +// mod.wfl +define action called greet: + return "hello from greet" +end action + +// main.wfl +include from "mod.wfl" +store x as greet // error[ANALYZE-SEMANTIC]: Variable 'greet' is not defined +display x // never runs; exit 3 +``` + +The identical `store x as greet` runs fine in a *single* file, and both +`call greet` and `greet of "x"` (for an action taking an argument) already +worked across an include. So the same code was fatal or fine depending only on +whether the action came from an included file — exactly the main-vs-include +inconsistency Phase 2 is meant to remove. This was the third and last surviving +form of the #548 → #580 include-resolution family (#580 fixed the `of` and +`call` forms). + +## Root cause + +`greet of "x"` (a `FunctionCall` with a bare-`Variable` callee) and +`call greet with "x"` (an `ActionCall`) both route their unresolved-callee +handling through one include-aware helper, `warn_undefined_callee_if_includes` +(`src/analyzer/mod.rs`). When the program uses `include from`, that helper +downgrades the fatal error to a **non-fatal** `Undefined action ''` +warning (the action may be exposed by the included file at runtime, which the +analyzer never reads) and returns `true`; with no includes it emits nothing and +returns `false`, preserving the fatal path so genuine typos stay caught. + +A **bare** reference (`store x as greet`) lowers to `Expression::Variable` +with no call node, so it never reached that helper — the `Expression::Variable` +arm called `report_undefined_name` directly, which is fatal. The rest of the +pipeline already tolerated the bare reference: the type checker returns +`Type::Unknown` for an unknown name and defers to the analyzer, and the +interpreter already auto-calls a zero-argument action referenced by bare name +(which is why it worked in a single file and inside a `main loop`). The defect +was therefore purely the analyzer aborting first. + +## What changed + +One production change: the `Expression::Variable` arm in +`Analyzer::analyze_expression` now routes an unresolved, non-container-property +name through the **same** `warn_undefined_callee_if_includes` helper the +`of`/`call` forms use, and only falls back to the fatal `report_undefined_name` +when the helper returns `false` (i.e. no includes present). No new logic — it +reuses the exact relaxation #580 unified the other two forms onto, so the three +call forms cannot drift apart again. + +Behavior now (verified end-to-end on the release build): + +- bare reference **at top level** → non-fatal `Undefined action` warning, runs, exit 0; +- bare reference **inside an action body** → same; +- bare undefined name **without any include** → still fatal (`Variable '…' is not defined`, exit 3). + +## Regression protection + +- `src/analyzer/mod.rs` unit tests: `test_bare_undefined_name_relaxed_with_includes_issue_592` + (with an include → warning, not error) and + `test_bare_undefined_name_without_includes_stays_fatal_issue_592` (guardrail: still fatal). +- `tests/phase1_correctness_regression_test.rs`: the previously `#[ignore]`d + `issue_592_*` acceptance tests are now **active** guards (top level + action + body), plus a new `issue_592_bare_undefined_without_include_stays_fatal` + no-include guardrail. The Phase 1 coverage map in that file is updated from + "High (open) ⏳" to "High (fixed) ✅". +- End-to-end fixture: `TestPrograms/module_include_bare_zero_arg.wfl` (with its + helper `module_bare_zero_arg_helper.wfl`, skip-listed like `module_helper.wfl` + in both `run_integration_tests.sh` and `.ps1`). + +## Documentation + +`Docs/04-advanced-features/modules.md` gains a "Calling actions from an included +file" subsection documenting all three call forms — including that a +zero-argument included action is referenced by its bare name — and honestly +notes the non-fatal `Undefined action` warning the analyzer emits for a name it +cannot see statically. + +## Compatibility + +Backward compatible: the change only **relaxes** a currently-fatal error when +`include from` is present, and preserves the fatal path (and the `try`-body +warning downgrade) for every other case. No existing `TestPrograms/` behavior +changes. diff --git a/Docs/04-advanced-features/modules.md b/Docs/04-advanced-features/modules.md index a513ad5c..0f52ee80 100644 --- a/Docs/04-advanced-features/modules.md +++ b/Docs/04-advanced-features/modules.md @@ -53,6 +53,28 @@ include from "containers.wfl" This reads, parses, and executes the specified file in the parent scope, making all definitions available to the parent. +### Calling actions from an included file + +An action exposed by an included file is called exactly like an action defined in the current file — there is nothing extra to learn. An action that takes arguments uses the `of` form (or the equivalent `call ... with` form): + +```wfl +include from "greetings.wfl" # exposes a `greet` action taking a name + +store a as greet of "Bob" # the `of` form +store b as call greet with "Bob" # the equivalent `call ... with` form +``` + +A **zero-argument** action is referenced by its **bare name**, just like a variable — no `of` and no `call` needed: + +```wfl +include from "greetings.wfl" # also exposes a zero-argument `banner` action + +store line as banner # a bare name calls the zero-argument action +display line +``` + +All three forms work at the top level and inside your own action bodies. Because the analyzer does not read included files, it emits a **non-fatal** `Undefined action ''` note for a name it cannot see statically — the program still runs and the action resolves at runtime. + ### Type Checking in Included Files Included files go through the same pipeline as the main program (parse, analyze, type check). Because `include from` runs the file in the parent scope — as if the code were written in the main program — type-check findings in an included file are reported the same way as in the main file: as **non-fatal warnings**. The program still runs. diff --git a/TestPrograms/module_bare_zero_arg_helper.wfl b/TestPrograms/module_bare_zero_arg_helper.wfl new file mode 100644 index 00000000..1693d66c --- /dev/null +++ b/TestPrograms/module_bare_zero_arg_helper.wfl @@ -0,0 +1,7 @@ +// Helper module for module_include_bare_zero_arg.wfl (issue #592). +// Exposes a ZERO-ARGUMENT action so the main file can reference it by its +// bare name (`store greeting as greet`) across an `include from`. This file is +// a helper, not a standalone program (skipped by the integration harness). +define action called greet: + return "hello from greet" +end action diff --git a/TestPrograms/module_include_bare_zero_arg.wfl b/TestPrograms/module_include_bare_zero_arg.wfl new file mode 100644 index 00000000..a27de7c1 --- /dev/null +++ b/TestPrograms/module_include_bare_zero_arg.wfl @@ -0,0 +1,27 @@ +// Regression fixture for issue #592: a ZERO-ARGUMENT action exposed by an +// `include from` file, referenced by its BARE name (no `of`, no `call`). +// +// Before the fix, `store greeting as greet` was a FATAL analyze error +// (`Variable 'greet' is not defined`, exit 3) even though the runtime would +// auto-call the zero-arg action — because a bare reference lowers to +// `Expression::Variable` and never reached the include-aware relaxation the +// `of`/`call` forms already used. The analyzer now routes a bare unresolved +// name through that same relaxation, so this resolves and prints, exit 0. +// +// A non-fatal "Undefined action 'greet'" analyze warning is expected (the +// action is provided by the included module at runtime); the program still +// runs and exits 0. +include from "module_bare_zero_arg_helper.wfl" + +store greeting as greet +display greeting + +// Also exercise the bare reference INSIDE an action body (the other context +// that was fatal before the fix). +define action called run_it: + store inner as greet + return inner +end action + +store from_action as call run_it +display from_action diff --git a/scripts/run_integration_tests.ps1 b/scripts/run_integration_tests.ps1 index 6a15befd..a630f4f7 100644 --- a/scripts/run_integration_tests.ps1 +++ b/scripts/run_integration_tests.ps1 @@ -111,7 +111,8 @@ $SkipTests = @( "web_server_test.wfl", # Web server - needs HTTP client "websocket_test.wfl", # WebSocket - needs WS client "web_route_params_test.wfl", # Web server - tested via run_web_tests.ps1 - "module_helper.wfl" # Helper module, not a standalone program + "module_helper.wfl", # Helper module, not a standalone program + "module_bare_zero_arg_helper.wfl" # Helper module for #592 fixture, not standalone ) # Tests that intentionally end with an error; they pass when wfl exits nonzero diff --git a/scripts/run_integration_tests.sh b/scripts/run_integration_tests.sh index 450389db..29d7a555 100644 --- a/scripts/run_integration_tests.sh +++ b/scripts/run_integration_tests.sh @@ -86,6 +86,7 @@ SKIP_TESTS=( "websocket_test.wfl" # WebSocket - needs WS client "web_route_params_test.wfl" # Web server - tested via run_web_tests.sh "module_helper.wfl" # Helper module, not a standalone program + "module_bare_zero_arg_helper.wfl" # Helper module for #592 fixture, not standalone ) # Tests that intentionally end with an error; they pass when wfl exits nonzero diff --git a/src/analyzer/mod.rs b/src/analyzer/mod.rs index 1c491c79..0f2a1d47 100644 --- a/src/analyzer/mod.rs +++ b/src/analyzer/mod.rs @@ -2558,11 +2558,23 @@ impl Analyzer { }; if !is_container_property { - self.report_undefined_name( - format!("Variable '{name}' is not defined"), - *line, - *column, - ); + // A bare unresolved name may be a zero-argument action + // exposed by an `include from` file and referenced by + // its bare name (e.g. `store x as greet`), which lowers + // to `Expression::Variable` with no call node and so + // never reaches the `of`/`call` forms' include-aware + // relaxation. Route it through the same helper so all + // three call forms of the #548 -> #580 family behave + // consistently under `include from` (issue #592). With + // no includes present the helper emits nothing and + // returns false, so a genuine typo stays fatal. + if !self.warn_undefined_callee_if_includes(name, *line, *column) { + self.report_undefined_name( + format!("Variable '{name}' is not defined"), + *line, + *column, + ); + } } } } @@ -3073,6 +3085,77 @@ mod tests { assert!(errors[0].message.contains("not defined")); } + // Issue #592: a bare unresolved name in a program that uses `include from` + // may be a zero-argument action exposed by the included file at runtime, so + // the `Expression::Variable` arm must relax to a non-fatal warning (like the + // `of`/`call` forms) instead of aborting with a fatal undefined-name error. + #[test] + fn test_bare_undefined_name_relaxed_with_includes_issue_592() { + let program = Program { + statements: vec![ + Statement::IncludeStatement { + path: Expression::Literal(Literal::String(Arc::from("mod.wfl")), 1, 1), + line: 1, + column: 1, + }, + Statement::VariableDeclaration { + name: "x".to_string(), + value: Expression::Variable("greet".to_string(), 2, 12), + is_constant: false, + line: 2, + column: 1, + }, + ], + }; + + let mut analyzer = Analyzer::new(); + let result = analyzer.analyze(&program); + assert!( + result.is_ok(), + "bare include-exposed name must not be a fatal error (#592): {:?}", + analyzer.get_errors() + ); + assert!( + analyzer + .get_warnings() + .iter() + .any(|w| w.message.contains("Undefined action 'greet'")), + "should record a non-fatal 'Undefined action' warning (#592): {:?}", + analyzer.get_warnings() + ); + } + + // Issue #592 guardrail: the SAME bare reference WITHOUT any `include from` + // is a genuine typo and must stay fatal — the relaxation must not + // over-broaden into silencing real undefined-name errors. + #[test] + fn test_bare_undefined_name_without_includes_stays_fatal_issue_592() { + let program = Program { + statements: vec![Statement::VariableDeclaration { + name: "x".to_string(), + value: Expression::Variable("greet".to_string(), 1, 12), + is_constant: false, + line: 1, + column: 1, + }], + }; + + let mut analyzer = Analyzer::new(); + let result = analyzer.analyze(&program); + assert!( + result.is_err(), + "a bare undefined name without includes must stay fatal (#592 guardrail)" + ); + assert!( + analyzer + .get_errors() + .iter() + .any(|e| e.message.contains("Variable 'greet' is not defined")), + "should report the fatal undefined-variable error (#592 guardrail): {:?}", + analyzer.get_errors() + ); + } + #[test] fn test_function_definition_and_call() { let program = Program { diff --git a/tests/phase1_correctness_regression_test.rs b/tests/phase1_correctness_regression_test.rs index 70a1689a..65d79a22 100644 --- a/tests/phase1_correctness_regression_test.rs +++ b/tests/phase1_correctness_regression_test.rs @@ -40,7 +40,7 @@ //! | #583 | Medium (fixed) | ✅ | `github_issues_batch_test.rs::bracket_string_stays_text` | //! | #588 | Medium (fixed) | ✅ | `github_issues_batch_test.rs` (`store x as ` Unknown) | //! | #590 | Medium (fixed) | ✅ | `recursive_action_return_type_test.rs` (in-process type-checker guard) **+** this file: `issue_590_*` (CLI-level end-to-end guard) | -//! | #592 | **High (open)** | ⏳ | this file: `issue_592_*` (`#[ignore]`, top-level + action-body) | +//! | #592 | High (**fixed**) | ✅ | this file: `issue_592_*` (top-level + action-body, plus a no-include fatal guardrail) — analyzer routes a bare unresolved name through the `of`/`call` forms' include-aware relaxation | //! | #578 | **High (open, umbrella)** | ⏳ | this file: `issue_578_*` (`#[ignore]`) — see note below | //! | #573 | Medium (**fixed**) | ✅ | Binary read (`read binary from …`), binary write, lossless byte round-trip, and MIME helpers shipped in #574; guarded by `web_server_binary_test.rs`, `binary_io_test.rs`, and `binary_file_and_mime_test.wfl`. The issue's own latest verification recommends closing; it is open only pending the close click. | //! | #555 | Medium (open) | ⏳ | `TestPrograms/` `CI-SKIP` corpus (docs-in-CI gate) | @@ -303,16 +303,18 @@ fn issue_590_self_recursive_indexed_result_runs_cli() { // cargo test --test phase1_correctness_regression_test -- --ignored // =========================================================================== -// --- #592 ------------------------------------------------------------------ +// --- #592 (FIXED — Phase 2) ------------------------------------------------ // A zero-argument include-exposed action referenced by its BARE name (no `of`, -// no `call`) is fatal — both at top level AND inside an action body — with -// `Variable '…' is not defined` (exit 3), while `call greet` and the `of` form -// work. Desired: it resolves like the other call forms in BOTH contexts. -// Parameterized so a fix that only covers one context cannot make this green. +// no `call`) USED TO BE fatal — both at top level AND inside an action body — +// with `Variable '…' is not defined` (exit 3), while `call greet` and the `of` +// form worked. FIXED in Phase 2: the analyzer's `Expression::Variable` arm now +// routes a bare unresolved name through the same include-aware relaxation the +// `of`/`call` forms use, so it resolves like the other call forms in BOTH +// contexts. These tests are now ACTIVE guards (the `#[ignore]` was removed when +// the fix landed); the parameterization ensures a fix covering only one context +// cannot make them green. A no-include guardrail below pins that a genuine typo +// (no `include from`) still stays fatal. // https://github.com/WebFirstLanguage/wfl/issues/592 -// -// CURRENT (26.7.37): fatal `error[ANALYZE-SEMANTIC]: Variable 'greet' is not -// defined`, exit 3, in both contexts. const MOD_GREET: &str = "define action called greet:\n return \"hello from greet\"\nend action\n"; @@ -341,7 +343,6 @@ fn assert_greet_resolves(main_src: &str, context: &str) { } #[test] -#[ignore = "open defect #592: bare zero-arg included action is fatal at top level"] fn issue_592_bare_zero_arg_included_action_top_level() { assert_greet_resolves( "include from \"mod.wfl\"\nstore x as greet\ndisplay x\n", @@ -350,7 +351,6 @@ fn issue_592_bare_zero_arg_included_action_top_level() { } #[test] -#[ignore = "open defect #592: bare zero-arg included action is fatal inside an action body"] fn issue_592_bare_zero_arg_included_action_in_action_body() { assert_greet_resolves( // Invoke run_it with an explicit `call` (not a bare `display run_it`), @@ -365,6 +365,26 @@ fn issue_592_bare_zero_arg_included_action_in_action_body() { ); } +// Guardrail: the #592 relaxation must only fire when the program uses +// `include from`. A bare undefined name in a program with NO include is a +// genuine typo and must stay a FATAL analyze error (`Variable '…' is not +// defined`, exit 3) — the `warn_undefined_callee_if_includes` helper returns +// false without includes, so the fatal path is preserved. This pins that the +// fix did not over-broaden into silencing real undefined-name errors. +#[test] +fn issue_592_bare_undefined_without_include_stays_fatal() { + let (out, code) = run_files(&[("main.wfl", "store x as greet\ndisplay x\n")], "main.wfl"); + assert!( + out.contains("Variable 'greet' is not defined"), + "a bare undefined name without any include must stay fatal (#592 guardrail): {out}" + ); + assert_eq!( + code, + Some(3), + "should exit 3 (fatal analyze error) without includes (#592 guardrail): {out}" + ); +} + // --- #578 (reproducible confirmed functional bugs from the umbrella issue) -- // https://github.com/WebFirstLanguage/wfl/issues/578