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
87 changes: 87 additions & 0 deletions Dev diary/2026-07-03-include-type-inference-issue-553.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# Include Type Inference: Remaining RHS Forms (Issue #553)

**Date:** 2026-07-03

## What Changed

Issue #553 (follow-on to #551/#552) reported that four right-hand-side forms
still aborted with a fatal `Could not infer type for variable 'v'` when they
appeared inside an `include from` file:

```wfl
store v as parts[0] # list index
store v as rec["k"] # object index (parse_json result)
store v as a is equal to b # comparison result
store v as length of a # length of (already fixed by #552's builtin table)
```

Investigating end to end showed the failures were not include-specific at all.
The same forms failed inference in the **main file** too — but `main.rs`
reports type errors as non-fatal warnings and keeps running, while the include
pipeline turned the first type error into a fatal `RuntimeError`. That
asymmetry is what made includes look uniquely broken.

### Root causes and fixes

1. **The type checker had no scope for action bodies**
(`src/typechecker/mod.rs`). The analyzer creates a child scope for each
action body during analysis and then discards it, so when the type checker
later walked the AST, neither parameters nor body-local variables resolved
to any symbol. `store parts as string_split of a and "-"` inferred
`List<Text>` but had nowhere to record it, so `parts[0]` on the next line
inferred `Unknown`. The `ActionDefinition` branch now pushes a scope,
defines parameter symbols (declared type, or `Unknown` when untyped), and
the `VariableDeclaration` branch defines body-local symbols with their
inferred types so later statements can see them.

2. **Comparisons with `Unknown` operands inferred `Unknown`.** A comparison
yields a Boolean no matter what the operand types turn out to be; only
arithmetic results depend on the operand types. `BinaryOperation` inference
now returns `Boolean` for `Equals`/`NotEquals`/ordered comparisons/
`And`/`Or`/`Contains` even when an operand is `Unknown` or `Any`.

3. **Indexing an `Any` collection was a type error.** `parse_json` results
are typed `Any` (the shape is only known at runtime), but `IndexAccess`
had no `Any` arm and fell through to `Cannot index into Any`. Indexing
`Any` now yields `Any`, and `Unknown`/`Any` index values are tolerated
for list and text indexing.

4. **Include type errors are now non-fatal warnings**
(`src/interpreter/mod.rs`). `include from` executes in the parent scope —
the code is semantically part of the main program — so its type-check
findings are now reported exactly like the main file's: printed as
`Type checking warnings in included file '...'` and execution continues.
This closes the class of bug behind #551/#553 rather than the four
instances: any future inference gap degrades to a warning instead of a
show-stopping abort. Parse and semantic errors in included files remain
fatal.

5. **Bonus: false `ANALYZE-UNUSED` fix** (`src/analyzer/static_analyzer.rs`).
The issue's own repro flagged `Unused variable 'parts'` after
`store v as parts[0]` inside an action: `mark_used_variables` had no
`VariableDeclaration` arm, so right-hand sides of `store` statements inside
action/loop bodies never counted as uses. Added the arm.

### No new syntax

No new syntax was needed, so nothing was added to the language surface. The
change is purely semantic and aligns with the WFL foundation principles of
clear and actionable error reporting (principle 4) and type safety with
inference where practical (principle 5): inference now understands the
bread-and-butter forms, and what it cannot infer degrades to a readable
warning instead of a fatal abort.

## Tests

`tests/docs_parser_and_include_fixes_test.rs` gained regression tests: each
of the four RHS forms inside an included action called from a main program,
main-file inference guards for list-index and comparison results, and a guard
that a genuinely uninferable store (`a plus b` on untyped parameters) in an
included file runs to completion instead of aborting.

## Docs

- `Docs/04-advanced-features/modules.md`: documented include type-check
behavior (warnings, not fatal).
- `Docs/reference/error-codes.md`: refreshed the "Could not infer type"
entry.
11 changes: 11 additions & 0 deletions Docs/04-advanced-features/modules.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,17 @@ include from "containers.wfl"

This reads, parses, and executes the specified file in the parent scope, making all definitions available to the parent.

### 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.

```text
Type checking warnings in included file 'mod.wfl':
error[ERROR]: Could not infer type for variable 'v'
```

Parse errors and semantic errors (for example, an undefined variable) in an included file remain fatal.

Comment thread
coderabbitai[bot] marked this conversation as resolved.
## When to Use Each Approach

### Use `load module from` for:
Expand Down
6 changes: 4 additions & 2 deletions Docs/reference/error-codes.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ store result as abs of -5 // Numbers only

### "Could not infer type for variable"

**Cause:** Type checker can't determine type (usually in actions with parameters).
**Cause:** Type checker can't determine type (usually arithmetic on untyped action parameters).

**Example:**
```wfl
Expand All @@ -151,7 +151,9 @@ define action called process with parameters x:
end action
```

**Fix:** This is typically a warning, not an error. Code still works.
**Fix:** This is a warning, not an error — the code still runs. This applies everywhere, including files pulled in with `include from` (included files are never type-checked more strictly than the main program).

Note: comparison results (`a is equal to b`), list/object indexing (`parts[0]`, `rec["k"]`), `length of`, and builtin call results are all inferable and do not produce this warning.

---

Expand Down
8 changes: 8 additions & 0 deletions src/analyzer/static_analyzer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -552,6 +552,14 @@ impl Analyzer {

self.mark_used_in_expression(value, usages);
}
Statement::VariableDeclaration { value, .. } => {
// Variables referenced on the right-hand side of a `store`
// are uses — including inside action/loop bodies, which the
// top-level declaration pass does not reach (issue #553's
// repro flagged `parts` as unused after `store v as parts[0]`
// inside an action).
self.mark_used_in_expression(value, usages);
}
Statement::ActionDefinition { body, .. } => {
for stmt in body {
self.mark_used_variables(stmt, usages);
Expand Down
33 changes: 20 additions & 13 deletions src/interpreter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3522,23 +3522,30 @@ impl Interpreter {
));
}

// 7. Type check
// 7. Type check. Type errors are reported as non-fatal
// warnings, exactly like the main-file pipeline (main.rs
// prints them and continues): `include from` executes in the
// parent scope, so included code must never be checked more
// strictly than the same code written in the main program
// (issues #551/#553).
use crate::diagnostics::DiagnosticReporter;
use crate::typechecker::TypeChecker;

let mut tc = TypeChecker::with_analyzer(analyzer);
if let Err(type_errors) = tc.check_types(&program) {
let first_error = type_errors.first();
let (error_line, error_column) =
first_error.map(|e| (e.line, e.column)).unwrap_or((1, 1));
return Err(RuntimeError::new(
format!(
"Type error in included file '{}': {}",
resolved_path.display(),
first_error.map(|e| e.to_string()).unwrap_or_default()
),
error_line,
error_column,
));
eprintln!(
"Type checking warnings in included file '{}':",
resolved_path.display()
);
let mut reporter = DiagnosticReporter::new();
let file_id =
reporter.add_file(resolved_path.display().to_string(), content.clone());
for error in &type_errors {
let diagnostic = reporter.convert_type_error(file_id, error);
if reporter.report_diagnostic(file_id, &diagnostic).is_err() {
eprintln!("{error}");
}
}
}

// 8. Create guard for context tracking
Expand Down
104 changes: 100 additions & 4 deletions src/typechecker/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -579,7 +579,31 @@ impl TypeChecker {
&& let Some(symbol) = self.analyzer.get_symbol_mut(name)
&& symbol.symbol_type.is_none()
{
symbol.symbol_type = Some(inferred_type);
symbol.symbol_type = Some(inferred_type.clone());
}

// Locals declared inside an action body have no symbol left
// over from analysis (the analyzer discards body scopes), so
// record them in the type checker's re-created scope; later
// statements in the body can then see their inferred types
// (issue #553). Resolving through parent scopes is correct
// here because WFL forbids shadowing: a `store` reusing an
// outer variable's name is a fatal semantic error ("Use
// 'change x to <value>'"), so a resolved outer symbol can
// only mean the store refers to that same variable.
if self.analyzer.get_symbol(name).is_none() {
let recorded_type = if inferred_type == Type::Error {
Type::Unknown
} else {
inferred_type
};
let _ = self.analyzer.define_symbol(Symbol {
name: name.clone(),
kind: SymbolKind::Variable { mutable: true },
symbol_type: Some(recorded_type),
line: *_line,
column: *_column,
});
}
}
Statement::Assignment {
Expand Down Expand Up @@ -633,13 +657,36 @@ impl TypeChecker {
});
}

// The analyzer's action-body scope is discarded when analysis
// finishes, so re-create one here: parameters and body-local
// variables must be resolvable while checking the body,
// otherwise expressions that depend on a local's type (e.g.
// `parts[0]` after `store parts as ...`) infer Unknown
// (issue #553).
self.analyzer.push_scope();
for param in parameters {
let param_symbol = Symbol {
name: param.name.clone(),
kind: SymbolKind::Variable { mutable: false },
// Untyped parameters get an explicit Unknown so
// references resolve without a "cannot determine
// type" diagnostic, matching prior behavior.
symbol_type: param.param_type.clone().or(Some(Type::Unknown)),
line: param.line,
column: param.column,
};
let _ = self.analyzer.define_symbol(param_symbol);
}

for stmt in body {
self.check_statement_types(stmt);
}

if let Some(ret_type) = return_type {
self.check_return_statements(body, ret_type, *_line, *_column);
}

self.analyzer.pop_scope();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Statement::IfStatement {
condition,
Expand Down Expand Up @@ -2211,7 +2258,39 @@ impl TypeChecker {
}

if left_type == Type::Unknown || right_type == Type::Unknown {
return Type::Unknown;
// Comparisons and logical operations always produce a
// Boolean, no matter what the operand types turn out to
// be at runtime — only arithmetic results depend on the
// operand types (issue #553).
return match operator {
Operator::Equals
| Operator::NotEquals
| Operator::GreaterThan
| Operator::LessThan
| Operator::GreaterThanOrEqual
| Operator::LessThanOrEqual
| Operator::And
| Operator::Or
| Operator::Contains => Type::Boolean,
_ => Type::Unknown,
};
}

// Dynamically-typed (Any) operands are checked at runtime;
// comparisons on them still produce a Boolean.
if left_type == Type::Any || right_type == Type::Any {
match operator {
Operator::Equals
| Operator::NotEquals
| Operator::GreaterThan
| Operator::LessThan
| Operator::GreaterThanOrEqual
| Operator::LessThanOrEqual
| Operator::And
| Operator::Or
| Operator::Contains => return Type::Boolean,
_ => {}
}
}

match operator {
Expand Down Expand Up @@ -2542,7 +2621,10 @@ impl TypeChecker {

match collection_type {
Type::List(item_type) => {
if index_type != Type::Number {
if index_type != Type::Number
&& index_type != Type::Unknown
&& index_type != Type::Any
{
self.type_error(
format!("List index must be a number, got {index_type}"),
Some(Type::Number),
Expand Down Expand Up @@ -2570,7 +2652,10 @@ impl TypeChecker {
}
}
Type::Text => {
if index_type != Type::Number {
if index_type != Type::Number
&& index_type != Type::Unknown
&& index_type != Type::Any
{
self.type_error(
format!("Text index must be a number, got {index_type}"),
Some(Type::Number),
Expand All @@ -2584,6 +2669,10 @@ impl TypeChecker {
}
}
Type::Unknown => Type::Unknown,
// A dynamically-typed collection (e.g. a parse_json
// result) is indexable; the element type is only known
// at runtime (issue #553).
Type::Any => Type::Any,
_ => {
self.type_error(
format!("Cannot index into {collection_type}"),
Expand Down Expand Up @@ -3351,7 +3440,14 @@ impl TypeChecker {
column,
} => {
if let Some(expr) = value {
// The body pass (check_statement_types) has already
// inferred every return expression and reported any
// diagnostics inside it; re-inferring here is only to
// learn the type for the compatibility check, so drop
// the duplicate expression diagnostics it produces.
let errors_before = self.errors.len();
let return_type = self.infer_expression_type(expr);
self.errors.truncate(errors_before);
if !self.are_types_compatible(expected_type, &return_type) {
self.type_error(
"Return statement has incorrect type".to_string(),
Expand Down
Loading
Loading