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
79 changes: 77 additions & 2 deletions Docs/04-advanced-features/modules.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,78 @@ 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.

### Including the same file more than once (diamond includes)

Larger programs often end up including a shared file from more than one
place. The classic shape is a **diamond**: two library files both include the
same helper file, and the main program includes both libraries.

```text
util.wfl
/ \
auth.wfl render.wfl
\ /
main.wfl
```

```wfl
# util.wfl
define action called shout with parameters msg:
give back msg with "!"
end action

# auth.wfl
include from "util.wfl"
define action called auth_check with parameters who:
give back shout of who
end action

# render.wfl
include from "util.wfl"
define action called render_page with parameters title:
give back shout of title
end action

# main.wfl
include from "auth.wfl"
include from "render.wfl"
display auth_check of "alice" # alice!
display render_page of "home" # home!
```
Comment thread
coderabbitai[bot] marked this conversation as resolved.

This works because a file's **definitions are included once per scope**.
`auth.wfl` brings `util.wfl` into the main program's scope. When
`render.wfl` then asks for `util.wfl` again, its definitions are already
visible in that scope, so the second `include from` does nothing — it does
not run the file a second time, and it does not raise an "already defined"
error. Each file can therefore honestly declare what it depends on, and the
order in which the main program includes its libraries does not matter.

Three details worth knowing:

- **The rule is about definitions.** A file that defines actions,
containers, or variables is brought in once per scope. A file that
defines nothing — it only displays something, writes a file, or changes
variables that already exist — has nothing a later include could collide
with, so it runs every time it is included, exactly as it always has.
(`load module from` remains the clearest way to say "run this file for
its side effects".)
- **"Same scope" includes scopes you can see.** An include inside an action
body is skipped when the file was already included by an enclosing scope
(its definitions are already reachable). A file included only inside an
action body or a loop body runs again on each call or iteration, because
each one starts with a fresh local scope that has not seen it.
- **A failed include is not remembered.** If the included file stops with
an error, a later include of it runs it again. Anything it defined before
the error stays in the scope, as it did before.

The four files above are kept under `TestPrograms/docs_examples/modules/diamond/`
and validated with the rest of the documentation examples.

A genuine cycle — `a.wfl` includes `b.wfl`, which includes `a.wfl` — is
still reported as a circular dependency (see
[Circular Dependency Protection](#circular-dependency-protection)).

### 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 Expand Up @@ -615,9 +687,12 @@ export constant VERSION
- Must load entire module
- Future: `load function1, function2 from "x.wfl"` planned

3. **No Module Caching**
3. **No Module Caching for `load module`**
- Each `load module` re-parses and re-executes
- Multiple loads of same file execute multiple times
- (`include from` is different: it brings a file's definitions in once
per scope, so diamond includes are safe — see
[Including the same file more than once](#including-the-same-file-more-than-once-diamond-includes))
- Future: Optional caching planned

4. **Export Foundation Only**
Expand Down Expand Up @@ -850,7 +925,7 @@ load module from "expensive.wfl" # Uses cached version
WFL's hybrid module system provides flexible code organization:

- **`load module from "path.wfl"`** - Isolated execution for initialization and side effects
- **`include from "path.wfl"`** - Parent scope execution for shared libraries and containers
- **`include from "path.wfl"`** - Parent scope execution for shared libraries and containers; a file's definitions are included once per scope, so diamond includes are safe
- **`export container/action/constant NAME`** - Foundation for future namespace system
- Paths resolve relative to the including file
- Circular dependencies are automatically detected
Expand Down
91 changes: 91 additions & 0 deletions History/dev-diary/2026/2026-09-04-diamond-includes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# 2026-09-04 — Diamond includes: the second branch always broke

## Symptom

A multi-file program where two library files both `include from` the same
shared file could not run:

```text
util.wfl
/ \
auth.wfl render.wfl
\ /
main.wfl
```

```text
error[ERROR]: Semantic error in included file 'render.wfl':
Semantic error at line 3, column 21: 'shout' is not a function
```

The first branch (`auth.wfl`) worked; the second (`render.wfl`) failed while
being *analyzed*, before a single line of it ran. Verified on wfl 26.9.2.

A downstream project had noticed this and concluded that "WFL includes form a
tree, and diamonds break", working around it by chaining every file into one
long line (`util <- db <- auth <- render <- site_ext <- main`). The chain works
only by accident: with every `include from` at the top of its file, nothing
is defined yet when each file is analyzed, so unknown names are downgraded to
warnings and resolve at runtime.

## Root cause

Two separate defects, one hiding the other.

1. **Parent-scope actions were seeded into an included file's analyzer as
plain variables.** `extract_parent_variables` turned every runtime binding
into `SymbolKind::Variable`, actions included. Calling one of those
(`shout of title`) then hit the analyzer's "'shout' is not a function"
error, which is fatal for included files. This broke any second file that
used an action an earlier include had defined — the diamond, but also the
plain sibling order where `render.wfl` does not include `util.wfl` itself.

2. **Re-including a file re-ran it into the same scope.** Once (1) is fixed,
the second arrival at `util.wfl` executes `store util_loads as 1` and
`define action called shout` again in a scope that already has both:
"Variable 'shout' has already been defined at line 0". The cycle check
only knew about files *currently* loading, not files already finished.

## Fix

- The interpreter now snapshots the enclosing scope as typed variables
**and** action signatures (`snapshot_parent_scope`), and the analyzer gets
the actions through `register_parent_actions` as real function symbols
with their true parameter lists. A same-name definition in the analyzed
file is treated as an overload under the existing distinctness rules,
which matches what the runtime already did.
- `Environment` records the canonical paths `include from` has completed in
that scope **and that installed at least one definition there**. An
include whose file is already recorded for the current scope (or an
ancestor it can see) is a no-op, decided before the import-depth ceiling
is charged. Recycled loop scopes clear the record along with their
values. A file that defines nothing is never recorded, so a side-effect-
only file keeps running on every include — no existing program changes
behavior (anything with a definition already failed). A failed include is
not recorded either; what it defined before failing stays in the scope,
as it always did.
- `load module` still runs in an isolated child scope. Its analyzer now
sees outer actions as callable functions (so calls resolve) but rejects a
same-name definition up front — the runtime would reject it anyway, and
the rejection must land before the module's earlier statements run.

## Evidence

- Red: `tests/include_diamond_test.rs` (9 tests, four added from review
findings: loop-scope recycling, side-effect-only re-include, the
import-depth boundary, and `load module` outer-action rejection) and
`TestPrograms/modules/include_diamond.wfl` fail on the unmodified
interpreter with the errors quoted above.
- Green: same tests pass after the change; `cargo test --workspace`,
`cargo clippy --all-targets --all-features -- -D warnings`, and the
gated `TestPrograms/` run are clean.
- Risk class R3 (backward compatibility). Negative paths covered: a genuine
include cycle is still rejected; an include inside an action body still
runs per call when nothing enclosing has included the file.

## Residual

The type checker does not follow includes, so a container defined in a
shared file still produces non-fatal "Container type 'X' not found" warnings
in a sibling file that instantiates it. That predates this change and the
program runs correctly; it is a separate issue.
66 changes: 66 additions & 0 deletions TestPrograms/docs_examples/_meta/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -673,5 +673,71 @@
"properties"
],
"doc_purpose": "Demonstrates reading instance properties with object.property"
},
"docs_examples/modules/diamond/util.wfl": {
"doc_section": "Docs/04-advanced-features/modules.md",
"type": "executable",
"validate_layers": [
1,
2,
3,
4,
5
],
"expected_exit_code": 0,
"tags": [
"modules",
"include",
"diamond"
],
"doc_purpose": "Shared leaf file of the diamond include example"
},
"docs_examples/modules/diamond/auth.wfl": {
"doc_section": "Docs/04-advanced-features/modules.md",
"type": "executable",
"validate_layers": [
1,
4,
5
],
"expected_exit_code": 0,
"tags": [
"modules",
"include",
"diamond"
],
"doc_purpose": "First branch of the diamond include example. Layers 2-3 are skipped: it calls an action that lives in a file it includes, and single-file analysis reports that as a non-fatal warning with a nonzero exit; layer 5 runs the real multi-file program."
},
"docs_examples/modules/diamond/render.wfl": {
"doc_section": "Docs/04-advanced-features/modules.md",
"type": "executable",
"validate_layers": [
1,
4,
5
],
"expected_exit_code": 0,
"tags": [
"modules",
"include",
"diamond"
],
"doc_purpose": "Second branch of the diamond include example. Layers 2-3 are skipped: it calls an action that lives in a file it includes, and single-file analysis reports that as a non-fatal warning with a nonzero exit; layer 5 runs the real multi-file program."
},
"docs_examples/modules/diamond/main.wfl": {
"doc_section": "Docs/04-advanced-features/modules.md",
"type": "executable",
"validate_layers": [
1,
4,
5
],
"expected_exit_code": 0,
"tags": [
"modules",
"include",
"diamond"
],
"doc_purpose": "Top of the diamond include example: both branches include util.wfl once. Layers 2-3 are skipped: it calls an action that lives in a file it includes, and single-file analysis reports that as a non-fatal warning with a nonzero exit; layer 5 runs the real multi-file program."
}
}
5 changes: 5 additions & 0 deletions TestPrograms/docs_examples/modules/diamond/auth.wfl
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// One branch of the diamond: depends on util.wfl directly.
include from "util.wfl"
define action called auth_check with parameters who:
give back shout of who
end action
5 changes: 5 additions & 0 deletions TestPrograms/docs_examples/modules/diamond/main.wfl
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Top of the diamond: both branches bring in util.wfl; it is included once.
include from "auth.wfl"
include from "render.wfl"
display auth_check of "alice" # alice!
display render_page of "home" # home!
5 changes: 5 additions & 0 deletions TestPrograms/docs_examples/modules/diamond/render.wfl
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// The other branch of the diamond: also depends on util.wfl directly.
include from "util.wfl"
define action called render_page with parameters title:
give back shout of title
end action
5 changes: 5 additions & 0 deletions TestPrograms/docs_examples/modules/diamond/util.wfl
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Shared leaf of the include diamond documented in
// Docs/04-advanced-features/modules.md ("Including the same file more than once").
define action called shout with parameters msg:
give back msg with "!"
end action
16 changes: 16 additions & 0 deletions TestPrograms/modules/include_diamond.wfl
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Diamond include: `auth.wfl` and `render.wfl` both `include from "util.wfl"`.
// The second arrival at `util.wfl` must be a no-op (its definitions are
// already in this scope), not an "already defined" failure, and both
// branches must be able to call the shared action.
include from "../../tests/fixtures/modules/diamond/auth.wfl"
include from "../../tests/fixtures/modules/diamond/render.wfl"

describe "diamond include":
test "both branches see the shared action":
expect auth_check of "alice" to equal "alice!"
expect render_page of "home" to equal "home!"
end test
test "the shared file ran exactly once":
expect util_loads to equal 1
end test
end describe
Loading
Loading