From 4e1d1000a8062173f59b3e648a1d747346144d61 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 15:35:09 +0000 Subject: [PATCH 1/3] fix: resolve five language/typechecker bugs (#583, #582, #566, #557, #567) - #583: stop coercing the Text value "[]" to an empty List in the VariableDeclaration handler; a quoted string keeps its Text type. - #582: bind action/event parameters with define_direct so a parameter shadows a same-named global instead of the global overriding the arg. - #566: add KeywordStarts/KeywordEnds tokens and an infix parse desugaring `X starts with Y` / `X ends with Y` to the starts_with/ends_with builtins, so they work end-to-end (not just under --analyze); update route arms. - #557: skip native builtins in extract_parent_variables so an included file can use date-unit words (year/month/day/hour/minute/second) as action-local variables, matching main-file behavior (non-fatal). - #567: accept Any/Unknown in the add/split/binary-arithmetic typechecker rules and stop erroring on untyped-parameter references (gradual typing). Adds tests/github_issues_batch_test.rs (13 regression tests). All existing TestPrograms continue to pass. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01HYUZcFy2vaiKcT9YzxVZYo --- ...github-issues-batch-583-582-566-557-567.md | 108 ++++++ src/interpreter/mod.rs | 34 +- src/lexer/token.rs | 6 + src/parser/expr/binary.rs | 47 +++ src/parser/stmt/route.rs | 23 +- src/typechecker/mod.rs | 53 ++- tests/github_issues_batch_test.rs | 315 ++++++++++++++++++ 7 files changed, 546 insertions(+), 40 deletions(-) create mode 100644 Dev diary/2026-07-06-github-issues-batch-583-582-566-557-567.md create mode 100644 tests/github_issues_batch_test.rs diff --git a/Dev diary/2026-07-06-github-issues-batch-583-582-566-557-567.md b/Dev diary/2026-07-06-github-issues-batch-583-582-566-557-567.md new file mode 100644 index 00000000..ba4e02c4 --- /dev/null +++ b/Dev diary/2026-07-06-github-issues-batch-583-582-566-557-567.md @@ -0,0 +1,108 @@ +# GitHub Issues Batch: String/Param/Operator/Scope/Typing Fixes + +**Date:** 2026-07-06 + +## Overview + +An open-issues review turned up five genuine, still-reproducible bugs on +`main` (verified against a fresh `cargo build --release`) alongside several +issues that had already been fixed by earlier PRs but never closed. This +entry covers the five fixes landed here; the already-resolved issues are +noted at the end for the record. + +## Fixed here + +### #583 — a quoted `"[]"` string was coerced to an empty list + +The `VariableDeclaration` execution arm hard-coded a special case that turned +any `Text` value equal to the two characters `[]` into an empty `List`. A +quoted string's characters must never decide its type, so the coercion block +was deleted (`src/interpreter/mod.rs`). `store a as "[]"` now keeps `a` as +`Text`, including when the value is built at runtime (`substring`, `with` +concatenation, an action return) and flows through a `store`. No +`TestPrograms` relied on the old behavior. + +### #582 — an action parameter was overridden by a same-named global + +Parameters were bound with `Environment::define()`, which *rejects* a name +already present in a parent scope. The call site discarded that error, so +when a global of the same name existed the parameter was never bound and the +body resolved to the global instead — a silent correctness bug (e.g. a +templating helper with a `t`/`v` parameter clobbered by a caller's `t`/`v` +globals). Parameters are now bound with `define_direct()` (current-scope +only), so a parameter unconditionally shadows any outer/global binding. Fixed +for both the action-call path (`call_function`) and event-handler params. + +### #566 — `X starts with Y` / `X ends with Y` swallowed as identifiers + +There were no lexer tokens for `starts`/`ends`, so the lexer's multi-word +identifier accumulator absorbed `path ends` into a single identifier and the +trailing `with "…"` dangled, failing semantic analysis with +`Variable 'path ends' is not defined`. This silently broke prefix/suffix +matching in web-server routing (and several demo programs "passed" only +under `--analyze`, which is lenient here). + +Fix: added `KeywordStarts` / `KeywordEnds` tokens (contextual, so they can +still be used as ordinary names) and an infix parse in `binary.rs` at +comparison precedence that desugars to the existing `starts_with` / +`ends_with` builtins — no new interpreter or type-checker operator needed. +The `route` construct's `when starts with` / `when ends with` arms were +updated to match the new tokens. All existing uses in the codebase were +already the operator form, so this is backward compatible and fixes the +previously-broken demos. + +### #557 — date-unit locals were fatal inside included files + +The six singular date-unit words (`year`, `month`, `day`, `hour`, `minute`, +`second`) are registered as global native functions. When an *included* file +was analyzed, the interpreter seeded **all** global env values — including +these native functions — as parent "variables" for the include analyzer, so +an action-local `store year as …` fatally conflicted with the builtin's +outer-scope binding. The same code in the main file only warns and runs, +because the main-file analyzer never has builtins as symbols. + +Fix: `extract_parent_variables` now skips `Value::NativeFunction` entries. +Builtins are already resolved through `is_builtin_function`, so seeding them +as shadowable variables only made includes stricter than main. Included +files now behave like the main file: an action-local shadows the builtin +(non-fatal), matching the documented main-file behavior. + +### #567 — `Any` / `Unknown` values rejected by strict type-checker rules + +Values whose static type is `Any` (list-index results) or `Unknown` (untyped +parameters) were rejected by several ERROR-level rules even though the +program runs correctly. Under gradual typing these mean "statically +unknown", not "known incompatible". Fixed the type checker to accept them: + +- `add X to ` and `add X to ` accept `Any`/`Unknown`. +- Binary arithmetic on an `Any` operand degrades gracefully (mirrors the + existing `Unknown` handling) instead of erroring; comparisons still yield + `Boolean`, `Plus` with a `Text` operand still yields `Text`. +- `split X by Y` accepts `Any`/`Unknown` for both operands. +- Referencing a binding with no recorded type (most commonly an untyped + parameter) now yields `Unknown` silently instead of raising + `Cannot determine type of variable`. + +(The sibling issues #560 and #569 — unannotated action return types typed as +`Nothing` — were already fixed by PR #575; verified with fresh repros.) + +## Tests + +`tests/github_issues_batch_test.rs` — 13 self-contained regression tests +covering all five issues (literal and runtime-built `"[]"`, single- and +multi-parameter shadowing, `starts`/`ends with` positive/negative/stored- +boolean forms, date-unit locals across all six words in an include, and +`Any`/`Unknown` flowing into `add`/arithmetic/`split`). All `TestPrograms` +continue to pass (no regressions), including the `route` and web-server +programs that exercise the new `starts`/`ends with` tokens. + +## Already resolved on `main` (verified, no code change) + +- **#580** — `of` form for include-exposed actions: fixed by PR #581. +- **#560 / #569** — action return-type inference: fixed by PR #575. +- **#573** — binary web content + MIME: fixed by PR #574. +- **#571** (core items: precedence, `/`, `finally`, `between`, + `is above`/`is below`, `modulo`, error binding): fixed by PR #577. The + remaining #571 items overlap with the #578 follow-up basket + (`repeat N times`, text→number conversion, pattern-VM and filesystem-glob + gaps) and are left for dedicated work. diff --git a/src/interpreter/mod.rs b/src/interpreter/mod.rs index a4bc32bd..4bac44d4 100644 --- a/src/interpreter/mod.rs +++ b/src/interpreter/mod.rs @@ -1560,6 +1560,16 @@ impl Interpreter { let env_borrowed = env.borrow(); for (name, value) in &env_borrowed.values { + // Skip native builtins (e.g. `year`, `month`, `day`, `length`, ...). + // The analyzer already resolves these through `is_builtin_function`, + // so seeding them as parent *variables* only makes an included file + // stricter than the main file: an action-local `store year as ...` + // would fatally conflict with the builtin's outer-scope binding even + // though the same code runs fine in a main program (#557). Leaving + // them out lets locals shadow builtins consistently in both paths. + if matches!(value, Value::NativeFunction(_, _)) { + continue; + } let inferred_type = Self::infer_type_from_value(value); // Check if this variable is a constant (immutable) let is_mutable = !env_borrowed.constants.contains(name); @@ -2264,13 +2274,7 @@ impl Interpreter { line: _line, column: _column, } => { - let mut evaluated_value = self.evaluate_expression(value, Rc::clone(&env)).await?; - - if let Value::Text(text) = &evaluated_value - && text.as_ref() == "[]" - { - evaluated_value = Value::List(Rc::new(RefCell::new(Vec::new()))); - } + let evaluated_value = self.evaluate_expression(value, Rc::clone(&env)).await?; #[cfg(debug_assertions)] exec_var_declare!(name, &evaluated_value); @@ -4882,14 +4886,18 @@ impl Interpreter { // Create a new environment for the handler let handler_env = Environment::new_child_env(&env); - // Bind arguments to parameters + // Bind arguments to parameters. Use define_direct so a + // parameter shadows any same-named global rather than being + // rejected as already-defined-in-outer-scope (#582). for (i, param_name) in event.params.iter().enumerate() { if i < arg_values.len() { let _ = handler_env .borrow_mut() - .define(param_name, arg_values[i].clone()); + .define_direct(param_name, arg_values[i].clone()); } else { - let _ = handler_env.borrow_mut().define(param_name, Value::Null); + let _ = handler_env + .borrow_mut() + .define_direct(param_name, Value::Null); } } @@ -8478,7 +8486,11 @@ impl Interpreter { #[cfg(debug_assertions)] exec_var_declare!(param, &arg); - let _ = call_env.borrow_mut().define(param, arg.clone()); + // Bind parameters directly in the call scope so they shadow any + // same-named global/outer binding. `define` (which rejects names + // present in a parent scope) would otherwise leave the parameter + // unbound and let the body resolve to the global instead (#582). + let _ = call_env.borrow_mut().define_direct(param, arg.clone()); } let frame = CallFrame::new( diff --git a/src/lexer/token.rs b/src/lexer/token.rs index 6dca08eb..c41ff203 100644 --- a/src/lexer/token.rs +++ b/src/lexer/token.rs @@ -239,6 +239,10 @@ pub enum Token { KeywordModulo, // word form of the '%' operator #[token("contains")] KeywordContains, + #[token("starts")] + KeywordStarts, // e.g., "path starts with \"/api\"" + #[token("ends")] + KeywordEnds, // e.g., "file ends with \".css\"" #[token("pattern")] KeywordPattern, #[token("matches")] @@ -643,6 +647,8 @@ impl Token { | Token::KeywordExtension | Token::KeywordExtensions | Token::KeywordContains // Can be a function name + | Token::KeywordStarts // Operator in 'X starts with Y'; else a name + | Token::KeywordEnds // Operator in 'X ends with Y'; else a name | Token::KeywordList // Only reserved in type/create context | Token::KeywordMap // Only reserved in type/create context | Token::KeywordText // Only reserved in type context diff --git a/src/parser/expr/binary.rs b/src/parser/expr/binary.rs index c414dea1..c7192457 100644 --- a/src/parser/expr/binary.rs +++ b/src/parser/expr/binary.rs @@ -588,6 +588,53 @@ impl<'a> BinaryExprParser<'a> for Parser<'a> { )); } } + Token::KeywordStarts | Token::KeywordEnds => { + // `X starts with Y` / `X ends with Y` are substring predicates + // at comparison precedence (1). They desugar to the + // `starts_with` / `ends_with` builtins so no new interpreter + // or type-checker operator is needed (#566). Before this, + // `starts`/`ends` had no token and the lexer's multi-word + // identifier accumulator swallowed `path ends` into one name. + if 1 < precedence { + break; + } + let is_starts = matches!(token, Token::KeywordStarts); + // Only an operator when directly followed by `with`; otherwise + // it is a plain (contextual) identifier — leave it in place. + if !self + .cursor + .peek_next() + .is_some_and(|t| t.token == Token::KeywordWith) + { + break; + } + self.bump_sync(); // Consume "starts"/"ends" + self.bump_sync(); // Consume "with" + // RHS binds at precedence 2 (tighter than comparison), matching + // how `contains`/`is` parse their right-hand side. + let right = self.parse_binary_expression(2)?; + let fn_name = if is_starts { + "starts_with" + } else { + "ends_with" + }; + left = Expression::FunctionCall { + function: Box::new(Expression::Variable(fn_name.to_string(), line, column)), + arguments: vec![ + Argument { + name: None, + value: left, + }, + Argument { + name: None, + value: right, + }, + ], + line, + column, + }; + continue; + } Token::KeywordContains => { // 'contains' is a comparison operator at precedence 1. if 1 < precedence { diff --git a/src/parser/stmt/route.rs b/src/parser/stmt/route.rs index aafca3f7..a30336e1 100644 --- a/src/parser/stmt/route.rs +++ b/src/parser/stmt/route.rs @@ -257,12 +257,12 @@ impl<'a> Parser<'a> { )) } // `when starts with V` → starts_with of subject and V - Token::Identifier(id) - if id == "starts" - && self - .cursor - .peek_next() - .is_some_and(|t| t.token == Token::KeywordWith) => + // (`starts`/`ends` are now KeywordStarts/KeywordEnds tokens — #566.) + Token::KeywordStarts + if self + .cursor + .peek_next() + .is_some_and(|t| t.token == Token::KeywordWith) => { self.bump_sync(); // Consume "starts" self.bump_sync(); // Consume "with" @@ -276,12 +276,11 @@ impl<'a> Parser<'a> { )) } // `when ends with V` → ends_with of subject and V - Token::Identifier(id) - if id == "ends" - && self - .cursor - .peek_next() - .is_some_and(|t| t.token == Token::KeywordWith) => + Token::KeywordEnds + if self + .cursor + .peek_next() + .is_some_and(|t| t.token == Token::KeywordWith) => { self.bump_sync(); // Consume "ends" self.bump_sync(); // Consume "with" diff --git a/src/typechecker/mod.rs b/src/typechecker/mod.rs index 284bc714..32ec14ff 100644 --- a/src/typechecker/mod.rs +++ b/src/typechecker/mod.rs @@ -1555,6 +1555,7 @@ impl TypeChecker { if **element_type != Type::Unknown && **element_type != value_type && value_type != Type::Unknown + && value_type != Type::Any { self.type_error( format!( @@ -1568,8 +1569,13 @@ impl TypeChecker { } } Some(Type::Number) => { - // This is arithmetic add - if value_type != Type::Number && value_type != Type::Unknown { + // This is arithmetic add. Accept Unknown/Any operands + // (statically unknown, verified at runtime) rather than + // emitting a false ERROR — gradual typing, issue #567. + if value_type != Type::Number + && value_type != Type::Unknown + && value_type != Type::Any + { self.type_error( "Cannot add non-numeric value to number".to_string(), Some(Type::Number), @@ -2360,13 +2366,12 @@ impl TypeChecker { if let Some(var_type) = &symbol.symbol_type { var_type.clone() } else { - self.type_error( - format!("Cannot determine type of variable '{name}'"), - None, - None, - *_line, - *_column, - ); + // A binding with no recorded type — most commonly an + // untyped action parameter — is statically unknown, not + // provably wrong. Treat it as Unknown (gradual typing) + // instead of emitting a false ERROR at every reference + // (issue #567). Genuine type mismatches are still caught + // where a concrete type is required at runtime. Type::Unknown } } else { @@ -2434,10 +2439,15 @@ impl TypeChecker { }; } - // Dynamically-typed (Any) operands are checked at runtime; - // comparisons on them still produce a Boolean. + // Dynamically-typed (Any) operands are checked at runtime, so the + // static result type can't be known. Degrade gracefully instead + // of raising a false ERROR — an `Any` (e.g. a list-index result) + // represents "statically unknown", not "known incompatible" + // (gradual typing — issue #567). This mirrors the `Unknown` + // handling above: comparisons still yield Boolean, arithmetic + // yields Any (or Text when the other Plus operand is Text). if left_type == Type::Any || right_type == Type::Any { - match operator { + return match operator { Operator::Equals | Operator::NotEquals | Operator::GreaterThan @@ -2446,9 +2456,12 @@ impl TypeChecker { | Operator::LessThanOrEqual | Operator::And | Operator::Or - | Operator::Contains => return Type::Boolean, - _ => {} - } + | Operator::Contains => Type::Boolean, + Operator::Plus if left_type == Type::Text || right_type == Type::Text => { + Type::Text + } + _ => Type::Any, + }; } match operator { @@ -3016,7 +3029,10 @@ impl TypeChecker { let text_type = self.infer_expression_type(text); let delimiter_type = self.infer_expression_type(delimiter); - if text_type != Type::Text { + // Accept statically-unknown operands (Unknown from untyped params, + // Any from list-index/map results) without a false ERROR — they are + // verified at runtime (gradual typing, issue #567). + if text_type != Type::Text && text_type != Type::Unknown && text_type != Type::Any { self.type_error( format!("Expected Text for string splitting, got {text_type}"), Some(Type::Text), @@ -3026,7 +3042,10 @@ impl TypeChecker { ); } - if delimiter_type != Type::Text { + if delimiter_type != Type::Text + && delimiter_type != Type::Unknown + && delimiter_type != Type::Any + { self.type_error( format!("Expected Text for delimiter, got {delimiter_type}"), Some(Type::Text), diff --git a/tests/github_issues_batch_test.rs b/tests/github_issues_batch_test.rs new file mode 100644 index 00000000..bc94c898 --- /dev/null +++ b/tests/github_issues_batch_test.rs @@ -0,0 +1,315 @@ +//! Regression tests for a batch of GitHub issues fixed together: +//! +//! * #583 — a string whose value is `"[]"` must stay `Text`, not be coerced to +//! an empty `List`. +//! * #582 — an action parameter must shadow a same-named global instead of the +//! global overwriting the passed argument. +//! * #566 — `X starts with Y` / `X ends with Y` must work as operators at +//! statement level (previously swallowed as the multi-word identifier +//! `"X starts"` / `"X ends"`). +//! * #557 — the date-unit words (`year`, `month`, `day`, `hour`, `minute`, +//! `second`) used as action-local variables inside an *included* file must +//! not be a fatal "already defined in an outer scope" error; includes must +//! behave like the main file (non-fatal, runs). +//! * #567 — `Any`/`Unknown` values (list-index results, untyped parameters) +//! must be accepted by the `add`/`split`/arithmetic type-checker rules rather +//! than producing false ERROR-level diagnostics (gradual typing). + +use std::fs; +use std::process::Command; +use tempfile::TempDir; + +fn wfl_exe() -> &'static str { + if cfg!(target_os = "windows") { + "target/release/wfl.exe" + } else { + "target/release/wfl" + } +} + +/// Run inline WFL source in a fresh temp dir, returning (stdout+stderr, exit code). +fn run_src(src: &str) -> (String, Option) { + let dir = TempDir::new().expect("tempdir"); + let path = dir.path().join("main.wfl"); + fs::write(&path, src).unwrap(); + let output = Command::new(wfl_exe()) + .arg(&path) + .output() + .expect("failed to execute WFL"); + let combined = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + drop(dir); + (combined, output.status.code()) +} + +// --------------------------------------------------------------------------- +// #583 — a quoted "[]" string stays Text +// --------------------------------------------------------------------------- + +#[test] +fn bracket_string_stays_text() { + let (out, code) = run_src( + "store a as \"[]\"\ndisplay typeof of a\n\ + store b as \"[1, 2]\"\ndisplay typeof of b\n", + ); + assert!( + out.contains("Text"), + "typeof of \"[]\" should be Text: {out}" + ); + assert!( + !out.contains("List"), + "\"[]\" must not be coerced to a List: {out}" + ); + assert_eq!(code, Some(0), "program should exit 0: {out}"); +} + +#[test] +fn bracket_string_built_at_runtime_stays_text() { + // `"" with "[" with "" with "]"` builds the two characters "[]" at runtime; + // it must remain Text just like the literal form. + let (out, code) = + run_src("store s as \"\" with \"[\" with \"\" with \"]\"\ndisplay typeof of s\n"); + assert!( + out.contains("Text"), + "runtime-built \"[]\" should be Text: {out}" + ); + assert!( + !out.contains("List"), + "runtime \"[]\" must not become a List: {out}" + ); + assert_eq!(code, Some(0), "program should exit 0: {out}"); +} + +// --------------------------------------------------------------------------- +// #582 — a parameter shadows a same-named global +// --------------------------------------------------------------------------- + +#[test] +fn parameter_shadows_same_named_global() { + let (out, code) = run_src( + "define action called takes_p with parameters p:\n return p\nend action\n\ + store p as \"GLOBAL-VALUE\"\n\ + display \"got: \" with takes_p of \"arg\"\n", + ); + assert!( + out.contains("got: arg"), + "parameter must shadow the global (expected 'got: arg'): {out}" + ); + assert!( + !out.contains("GLOBAL-VALUE"), + "the global must not override the passed argument: {out}" + ); + // Referencing an untyped parameter must not emit a false type diagnostic + // (gradual typing — related #567 cleanup). + assert!( + !out.contains("Cannot determine type of variable"), + "untyped parameter reference should not raise a type error: {out}" + ); + assert_eq!(code, Some(0), "program should exit 0: {out}"); +} + +#[test] +fn multiple_params_shadow_multiple_globals() { + // Common single-letter helper params (t, v) shadowing globals of the same + // name — the exact templating-engine failure mode from the issue. + let (out, code) = run_src( + "define action called combine with parameters t and v:\n return t with \"|\" with v\nend action\n\ + store t as \"GT\"\nstore v as \"GV\"\n\ + display \"R=\" with combine of \"a\" and \"b\"\n", + ); + assert!( + out.contains("R=a|b"), + "params t,v must shadow globals: {out}" + ); + assert_eq!(code, Some(0), "program should exit 0: {out}"); +} + +// --------------------------------------------------------------------------- +// #566 — `starts with` / `ends with` operators at statement level +// --------------------------------------------------------------------------- + +#[test] +fn ends_with_operator_in_check() { + let (out, code) = run_src( + "store path as \"/style.css\"\n\ + check if path ends with \".css\":\n display \"is css\"\notherwise:\n display \"not css\"\nend check\n", + ); + assert!(out.contains("is css"), "`ends with` should match: {out}"); + assert!( + !out.contains("is not defined"), + "`path ends` must not be read as an identifier: {out}" + ); + assert_eq!(code, Some(0), "program should exit 0: {out}"); +} + +#[test] +fn starts_with_operator_in_check() { + let (out, code) = run_src( + "store path as \"/api/users\"\n\ + check if path starts with \"/api\":\n display \"api route\"\notherwise:\n display \"other\"\nend check\n", + ); + assert!( + out.contains("api route"), + "`starts with` should match: {out}" + ); + assert!( + !out.contains("is not defined"), + "`path starts` must parse as operator: {out}" + ); + assert_eq!(code, Some(0), "program should exit 0: {out}"); +} + +#[test] +fn starts_ends_with_negative_cases() { + let (out, code) = run_src( + "store p as \"hello.txt\"\n\ + check if p starts with \"world\":\n display \"BAD-starts\"\notherwise:\n display \"ok-starts\"\nend check\n\ + check if p ends with \".md\":\n display \"BAD-ends\"\notherwise:\n display \"ok-ends\"\nend check\n", + ); + assert!( + out.contains("ok-starts") && out.contains("ok-ends"), + "negatives should not match: {out}" + ); + assert!( + !out.contains("BAD"), + "non-matching prefix/suffix must be false: {out}" + ); + assert_eq!(code, Some(0), "program should exit 0: {out}"); +} + +#[test] +fn ends_with_stores_boolean_result() { + // The desugared form must be a real boolean value usable outside `check if`. + let (out, code) = run_src( + "store name as \"report.pdf\"\nstore is_pdf as name ends with \".pdf\"\ndisplay is_pdf\n", + ); + assert!( + out.contains("yes") || out.contains("true"), + "result should be truthy boolean: {out}" + ); + assert_eq!(code, Some(0), "program should exit 0: {out}"); +} + +// --------------------------------------------------------------------------- +// #557 — date-unit local variables inside an included file are not fatal +// --------------------------------------------------------------------------- + +/// Run `app.wfl` inside a temp dir that also holds `mod.wfl`. +fn run_include(mod_src: &str, app_src: &str) -> (String, Option) { + let dir = TempDir::new().expect("tempdir"); + fs::write(dir.path().join("mod.wfl"), mod_src).unwrap(); + fs::write(dir.path().join("app.wfl"), app_src).unwrap(); + let output = Command::new(wfl_exe()) + .arg(dir.path().join("app.wfl")) + .output() + .expect("failed to execute WFL"); + let combined = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + drop(dir); + (combined, output.status.code()) +} + +#[test] +fn date_unit_local_in_included_file_not_fatal() { + let (out, code) = run_include( + "define action called mk with parameters n:\n store year as n plus 1\n return year\nend action\n", + "include from \"mod.wfl\"\n\ + store a as call mk with 1\nstore b as call mk with 2\n\ + display \"R=\" with a with \",\" with b\n", + ); + assert!( + out.contains("R=2,3"), + "included date-unit local must run: {out}" + ); + assert!( + !out.contains("has already been defined in an outer scope"), + "date-unit local in an include must not be fatal (#557): {out}" + ); + assert!( + !out.contains("Semantic error in included file"), + "included file must analyze without a fatal error: {out}" + ); + assert_eq!(code, Some(0), "program should exit 0: {out}"); +} + +#[test] +fn all_date_unit_words_usable_as_include_locals() { + // Each of the six singular date-unit words, one per action-local store. + let mod_src = "\ +define action called f1 with parameters n:\n store year as n\n return year\nend action\n\ +define action called f2 with parameters n:\n store month as n\n return month\nend action\n\ +define action called f3 with parameters n:\n store day as n\n return day\nend action\n\ +define action called f4 with parameters n:\n store hour as n\n return hour\nend action\n\ +define action called f5 with parameters n:\n store minute as n\n return minute\nend action\n\ +define action called f6 with parameters n:\n store second as n\n return second\nend action\n"; + let (out, code) = run_include( + mod_src, + "include from \"mod.wfl\"\ndisplay \"OK=\" with f1 of 1 with f6 of 6\n", + ); + assert!( + !out.contains("has already been defined in an outer scope"), + "no date-unit word may be a fatal outer-scope conflict in an include: {out}" + ); + assert!(out.contains("OK="), "the include must run: {out}"); + assert_eq!(code, Some(0), "program should exit 0: {out}"); +} + +// --------------------------------------------------------------------------- +// #567 — Any/Unknown values accepted by strict typechecker rules +// --------------------------------------------------------------------------- + +/// The type checker prints its ERROR-level diagnostics under this banner; a +/// clean run must not contain it. +const TYPE_WARN_BANNER: &str = "Type checking warnings"; + +#[test] +fn any_from_list_index_accepted_by_add() { + let (out, code) = run_src( + "store rows as [[10 and 20]]\nstore total as 0\n\ + for each r in rows:\n store t as r[1]\n add t to total\nend for\ndisplay total\n", + ); + assert!(out.contains("20"), "program should compute 20: {out}"); + assert!( + !out.contains("Cannot add non-numeric value to number"), + "Any-from-index must be accepted by `add ... to` (#567): {out}" + ); + assert!( + !out.contains(TYPE_WARN_BANNER), + "no false type warnings expected: {out}" + ); + assert_eq!(code, Some(0), "program should exit 0: {out}"); +} + +#[test] +fn any_from_list_index_accepted_by_arithmetic() { + let (out, code) = run_src( + "store rows as [[10 and 20]]\n\ + for each r in rows:\n store t as r[1]\n store d as 100 minus t\n display d\nend for\n", + ); + assert!(out.contains("80"), "program should compute 80: {out}"); + assert!( + !out.contains("Cannot perform"), + "Any operand must be accepted by arithmetic (#567): {out}" + ); + assert_eq!(code, Some(0), "program should exit 0: {out}"); +} + +#[test] +fn unknown_param_accepted_by_split() { + let (out, code) = run_src( + "define action called split_words with parameters p:\n store parts as split p by \" \"\n return parts\nend action\n\ + store ws as call split_words with \"a b c\"\ndisplay \"done\"\n", + ); + assert!(out.contains("done"), "program should run: {out}"); + assert!( + !out.contains("Expected Text for string splitting"), + "Unknown param must be accepted by `split ... by` (#567): {out}" + ); + assert_eq!(code, Some(0), "program should exit 0: {out}"); +} From eff3411a4ced21e4a13a57dff3454d73c58c5305 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 16:31:18 +0000 Subject: [PATCH 2/3] test: use CARGO_BIN_EXE_wfl for the test binary path Address CodeRabbit review on PR #587: the batch test helper hardcoded `target/release/wfl`, which is working-directory dependent and can pick up a stale or missing build. Use Cargo's `env!("CARGO_BIN_EXE_wfl")`, which resolves to the exact binary built for this integration-test run in any profile. Verified: all 13 tests pass under plain `cargo test` (debug). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01HYUZcFy2vaiKcT9YzxVZYo --- tests/github_issues_batch_test.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/github_issues_batch_test.rs b/tests/github_issues_batch_test.rs index bc94c898..64b7bf9c 100644 --- a/tests/github_issues_batch_test.rs +++ b/tests/github_issues_batch_test.rs @@ -19,12 +19,12 @@ use std::fs; use std::process::Command; use tempfile::TempDir; +/// Absolute path to the `wfl` binary built for this integration-test run. +/// `CARGO_BIN_EXE_wfl` is injected by Cargo, so it always points at the +/// freshly-built binary regardless of profile or working directory — no stale +/// `target/release` build and no cwd assumption. fn wfl_exe() -> &'static str { - if cfg!(target_os = "windows") { - "target/release/wfl.exe" - } else { - "target/release/wfl" - } + env!("CARGO_BIN_EXE_wfl") } /// Run inline WFL source in a fresh temp dir, returning (stdout+stderr, exit code). From 2ff9790600bec39d593e044ecc75590131c39b11 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 16:57:05 +0000 Subject: [PATCH 3/3] fix(typechecker): accept concrete values into a List(Any) (#567) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the #567 gradual-typing relaxation: the `add X to ` rule still rejected adding a concrete value to a list whose element type is `Any` (e.g. a `[1, 2]` literal, typed `List(Any)`), emitting a false "Cannot add Text to list of Any". Treat an `Any` element type as permissive alongside the existing `Unknown` handling — a list of statically-unknown element type accepts any value. Adds a regression test. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01HYUZcFy2vaiKcT9YzxVZYo --- src/typechecker/mod.rs | 6 ++++++ tests/github_issues_batch_test.rs | 20 ++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/src/typechecker/mod.rs b/src/typechecker/mod.rs index 32ec14ff..b115cabc 100644 --- a/src/typechecker/mod.rs +++ b/src/typechecker/mod.rs @@ -1552,7 +1552,13 @@ impl TypeChecker { if let Some(symbol) = self.analyzer.get_symbol(list_name) { match &symbol.symbol_type { Some(Type::List(element_type)) => { + // A `List(Any)`/`List(Unknown)` is a list of statically + // unknown element type (e.g. a `[1, 2]` literal or an + // untyped-parameter list), so adding any concrete value + // is valid — only flag a concrete element type that is + // provably incompatible (gradual typing, issue #567). if **element_type != Type::Unknown + && **element_type != Type::Any && **element_type != value_type && value_type != Type::Unknown && value_type != Type::Any diff --git a/tests/github_issues_batch_test.rs b/tests/github_issues_batch_test.rs index 64b7bf9c..917007c0 100644 --- a/tests/github_issues_batch_test.rs +++ b/tests/github_issues_batch_test.rs @@ -300,6 +300,26 @@ fn any_from_list_index_accepted_by_arithmetic() { assert_eq!(code, Some(0), "program should exit 0: {out}"); } +#[test] +fn concrete_value_accepted_into_list_of_any() { + // A list literal is typed `List(Any)`; adding any concrete value must be + // accepted (a list of statically-unknown element type takes anything) — + // it must not raise `Cannot add Text to list of Any` (#567). + let (out, code) = run_src( + "store xs as [10 and 20]\nadd \"hello\" to xs\nadd 30 to xs\ndisplay length of xs\n", + ); + assert!(out.contains("4"), "list should have 4 elements: {out}"); + assert!( + !out.contains("Cannot add"), + "a concrete value must be accepted into a List(Any) (#567): {out}" + ); + assert!( + !out.contains(TYPE_WARN_BANNER), + "no false type warnings expected: {out}" + ); + assert_eq!(code, Some(0), "program should exit 0: {out}"); +} + #[test] fn unknown_param_accepted_by_split() { let (out, code) = run_src(