Skip to content

Include-exposed variables can be read but not changed (ANALYZE-SEMANTIC "not defined", exit 3), while the runtime already supports the mutation #708

Description

@logbie

Summary

An include from module's variable can be read (warning only, works) and can be overwritten with store (no diagnostic, works, and really does mutate the shared binding) — but change-ing it is a fatal analyzer error that stops the program before it runs. The include-aware relaxation added by #548/#580/#592 covers callee/name references only and was never extended to assignment targets, so the one spelling WFL tells users to use everywhere else is the one spelling that fails.

Reproduction

inc_lib.wfl:

store shared_flag as no

l3_include_readonly.wfl:

include from "inc_lib.wfl"
display shared_flag
change shared_flag to yes
display shared_flag

Command:

wfl l3_include_readonly.wfl

Expected

no then yes, exit 0.

Docs/04-advanced-features/modules.md:98 lists, under "Use include from for":
"Configuration - Constants and shared variables", and the Comparison Example
at :125-127 shows an included store utility_value as "shared utility" being
read from the includer under the heading "Variables are also available". The
docs never state that included variables are read-only, and include from is
explicitly the mechanism that "actually shares" definitions (contrasted with
load module at :23-26).

Actual

warning[ANALYZE-SEMANTIC]: Undefined action 'shared_flag'
 = This action is not defined in this file; it may be provided by an included module at runtime, otherwise this is likely a typo.

warning[ANALYZE-SEMANTIC]: Undefined action 'shared_flag'
 = This action is not defined in this file; it may be provided by an included module at runtime, otherwise this is likely a typo.

error[ANALYZE-SEMANTIC]: Variable 'shared_flag' is not defined

Exit code: 3. Nothing runs — not even the first display.

Removing only the change line leaves a warning and a working program that
prints no, exit 0. So reading is permitted and writing is fatal.

The asymmetry is an oversight, not a design decision — three pieces of evidence

1. The runtime already supports exactly what the analyzer forbids. An action
defined in the included file can mutate the module variable, and the caller sees
it:

// inc_lib2.wfl
store counter_val as 0
define action called bump_counter:
    change counter_val to counter_val plus 1
end action
include from "inc_lib2.wfl"
call bump_counter
call bump_counter
display counter_val        // prints 2, exit 0

The shared binding is genuinely mutable at runtime. Only the static check blocks
the direct spelling.

2. store on the same name is accepted and does the same thing. It is not a
shadow — the module's own action observes the new value:

include from "inc_lib3.wfl"           // store shared_val as "original"
display "caller sees: " with shared_val    // original
store shared_val as "overwritten"
display "caller sees: " with shared_val    // overwritten
call show_shared                           // module sees: overwritten

Exit 0, no error. So the fatal path can be bypassed by using the spelling WFL
normally rejects — in any other context the analyzer insists on the opposite:

error[ANALYZE-SEMANTIC]: Variable 'counter' has already been defined in an outer
scope. Use 'change counter to <value>' to modify it.

Following that guidance across an include from boundary produces a hard error.
The advice is exactly inverted, and a user who learns the rule correctly is the
one who gets stuck.

3. The relaxation in the source is explicitly scoped to callees. The only
include-aware downgrade is:

// src/analyzer/mod.rs:897-913
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 }
}

gated on has_includes, whose own doc comment says
"undefined-action errors are downgraded to warnings" (src/analyzer/mod.rs:392-395).
Assignment-target resolution never calls it. Nothing anywhere expresses an intent
that included variables be immutable — there is no "included variables are
read-only" diagnostic, just the generic "is not defined".

Secondary: the read-path message is wrong

Reading a variable reports Undefined action 'shared_flag' with a note about
actions. For a variable reference that is misleading on its face, and it is the
message a user sees on the path that actually works.

Deduplication / lineage

This is the next uncovered case in an existing sequence, not a duplicate:

Searched include from variable change, include shared variable,
linter false positive, and the new #698-#705 — no existing issue covers the
write path.

Interaction with #701

#701 (container actions cannot call sibling actions) and this issue together mean
there is no supported way to share mutable state across files: containers
cannot host cooperating methods, and an included module's variable cannot be
written from the includer. The documented workaround for this issue — put a setter
action in the module and call it — works (evidence 1 above) and is what a port
would use, but it is a workaround for a check that should not fire.

Environment

  • wfl --version: WebFirst Language (WFL) version 26.8.4
  • binary: system install C:\Program Files\wfl\bin\wfl.exe
  • commit: c277d8f
  • OS: Windows 11 Pro 10.0.26200
  • build: release

Also reproduces identically on the repo build G:\repos\wfl\target\release\wfl.exe
(version 26.8.2, same fatal error, exit 3) — not a regression.
No .wflcfg in scope for the repro.

Context

Found while porting G:/repos/JShrink/src/JShrink/Minifier.php (a 738-line PHP
JavaScript minifier) to WFL. The port holds the minifier's ten mutable state
fields as module-level variables — the architecture forced by #701 — so splitting
that state into an included module is the natural way to break up a 780-line file.

This did not block the port: it was kept in one file, so nothing was stubbed.
It is filed because the failure mode is bad for a beginner — the error text
("Variable 'shared_flag' is not defined") points at a name that demonstrably is
defined and readable two lines earlier, gives no hint that the include boundary is
involved, and is contradicted by the same analyzer's usual advice to use change.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions