Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions Dev diary/2026-07-18-issue-592-bare-zero-arg-include.md
Original file line number Diff line number Diff line change
@@ -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 '<name>'`
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.
Comment on lines +39 to +41

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.
22 changes: 22 additions & 0 deletions Docs/04-advanced-features/modules.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 '<name>'` 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.
Expand Down
7 changes: 7 additions & 0 deletions TestPrograms/module_bare_zero_arg_helper.wfl
Original file line number Diff line number Diff line change
@@ -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
27 changes: 27 additions & 0 deletions TestPrograms/module_include_bare_zero_arg.wfl
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion scripts/run_integration_tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions scripts/run_integration_tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
93 changes: 88 additions & 5 deletions src/analyzer/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep include relaxation from covering all bare variables

When a program contains any include from, this branch now downgrades every unresolved bare variable expression to a non-fatal Undefined action warning, not just the intended zero-arg action reference. In an include-using file, a genuine typo such as check if no: display misspelled_name end check now only warns and exits 0 because the branch is skipped at runtime, whereas it previously failed analysis with Variable 'misspelled_name' is not defined; this can let undefined-variable bugs pass CI in any program that happens to include a module.

Useful? React with 👍 / 👎.

self.report_undefined_name(
format!("Variable '{name}' is not defined"),
Comment on lines +2569 to +2573
*line,
*column,
);
}
}
}
}
Expand Down Expand Up @@ -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 {
Expand Down
42 changes: 31 additions & 11 deletions tests/phase1_correctness_regression_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <call>` 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) |
Expand Down Expand Up @@ -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";

Expand Down Expand Up @@ -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",
Expand All @@ -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`),
Expand All @@ -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

Expand Down
Loading