From a0dcae2061dc5be0a0450a2f6b221fd4c0ad15f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 13:13:17 +0000 Subject: [PATCH 1/2] fix: seed action provisional return type as Unknown so self-recursion type-checks (#590) A self-recursive action that used its own recursive result inside its body (e.g. indexed it) got a false `Cannot index into Nothing` diagnostic. The body is type-checked before the real return type is inferred (#575's ordering), and the provisional return type was seeded as `Nothing`, so a self-reference in the body resolved to `Nothing` and any use/indexing of it raised strict "found Nothing" errors. Seed the provisional return type as `Unknown` instead. After #588/#589 an `Unknown`-typed value degrades gracefully, so self-references resolve cleanly during the body check while post-body inference (#575) still records the concrete return type for external callers. Void actions are still recorded as `Nothing` externally, preserving existing behavior. Adds regression tests covering the reported repro and the Scribe `scribe_p_unary` shape that negates its recursive result. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018Qykg1eQ2bJKx2uoJNBGPj --- src/typechecker/mod.rs | 28 ++++--- tests/recursive_action_return_type_test.rs | 94 ++++++++++++++++++++++ 2 files changed, 113 insertions(+), 9 deletions(-) create mode 100644 tests/recursive_action_return_type_test.rs diff --git a/src/typechecker/mod.rs b/src/typechecker/mod.rs index 50024f5a..4ca3172b 100644 --- a/src/typechecker/mod.rs +++ b/src/typechecker/mod.rs @@ -719,14 +719,22 @@ impl TypeChecker { .collect::>(); // WFL has no return-type annotation syntax, so `return_type` is - // effectively always `None`; default to `Nothing` provisionally + // effectively always `None`; seed a provisional return type // (so recursive calls in the body resolve to a function type), // then refine it below by inferring from the body's `return` - // expressions (issue #569). Without this, every action-call - // result is typed `Nothing`, producing spurious "Expected Text - // but found Nothing" errors when the result feeds a builtin - // position that requires Text (e.g. `respond to req with ...`). - let return_type_value = return_type.as_ref().cloned().unwrap_or(Type::Nothing); + // expressions (issue #569). + // + // Seed with `Unknown` rather than `Nothing`. The body is + // type-checked before the real return type is inferred (#575's + // ordering), so a self-recursive call in the body resolves + // against this provisional type. `Nothing` made such a call — and + // any indexing/use of its result — raise strict "found Nothing" + // errors (issue #590). After #588/#589 an `Unknown`-typed value + // degrades gracefully, so `Unknown` resolves self-references + // cleanly while still avoiding the spurious "Expected Text but + // found Nothing" errors that motivated seeding a concrete type + // in builtin positions (e.g. `respond to req with ...`, #569). + let return_type_value = return_type.as_ref().cloned().unwrap_or(Type::Unknown); if let Some(symbol) = self.analyzer.get_symbol_mut(name) { symbol.symbol_type = Some(Type::Function { @@ -779,10 +787,12 @@ impl TypeChecker { self.analyzer.pop_scope(); // Update the action's symbol so call sites see the real result - // type instead of the provisional `Nothing`. Skip pure `Nothing` - // results (void actions) to preserve existing behavior. + // type instead of the provisional `Unknown` seed. Pure `Nothing` + // results (void actions) are recorded as `Nothing` — the seed is + // only `Unknown` so self-references resolve gracefully during the + // body check (#590); external callers still see the same `Nothing` + // return type void actions had before. if let Some(inferred) = inferred_return - && inferred != Type::Nothing && let Some(symbol) = self.analyzer.get_symbol_mut(name) { symbol.symbol_type = Some(Type::Function { diff --git a/tests/recursive_action_return_type_test.rs b/tests/recursive_action_return_type_test.rs new file mode 100644 index 00000000..0bbf2fef --- /dev/null +++ b/tests/recursive_action_return_type_test.rs @@ -0,0 +1,94 @@ +//! Regression tests for issue #590: a self-recursive action's result must not be +//! typed `Nothing` inside its own body. +//! +//! When an action calls itself and uses the recursive result inside its own body +//! (e.g. indexes it), the type checker used to seed the action's provisional +//! return type as `Nothing`. The body is type-checked *before* the real return +//! type is inferred (#575's ordering), so the self-reference resolved to +//! `Nothing`, producing a false `Cannot index into Nothing` diagnostic. Seeding +//! the provisional type as `Unknown` (which degrades gracefully after #589) fixes +//! this while post-body inference still records the concrete return type. + +use wfl::lexer::lex_wfl_with_positions; +use wfl::parser::Parser; +use wfl::typechecker::TypeChecker; + +/// Issue #590: self-recursive call whose result is indexed inside the body must +/// not raise a false "Cannot index into Nothing" diagnostic. +#[test] +fn test_self_recursive_action_result_not_typed_nothing() { + let code = r#" +define action called other with parameters n: + create map m: + "val" is n + end map + return m +end action + +define action called p_unary with parameters n: + check if n is greater than 0: + store r as p_unary of (n minus 1) + return other of (r["val"]) + end check + return other of n +end action + +display (p_unary of 3)["val"] +"#; + + let tokens = lex_wfl_with_positions(code); + let mut parser = Parser::new(&tokens); + let program = parser.parse().expect("Should parse"); + + let mut type_checker = TypeChecker::new(); + let result = type_checker.check_types(&program); + + if let Err(errors) = result { + assert!( + !errors + .iter() + .any(|e| e.message.contains("Cannot index into Nothing")), + "Self-recursive action result must not be typed Nothing; got: {errors:?}" + ); + } +} + +/// A self-recursive action that negates its recursive result (the Scribe +/// `scribe_p_unary` shape) must also type-check clean. +#[test] +fn test_self_recursive_action_negating_result_typechecks_clean() { + let code = r#" +define action called other with parameters n: + create map m: + "val" is n + end map + return m +end action + +define action called p_unary with parameters n: + check if n is greater than 0: + store r as p_unary of (n minus 1) + return other of (0 minus (r["val"])) + end check + return other of n +end action + +display (p_unary of 3)["val"] +"#; + + let tokens = lex_wfl_with_positions(code); + let mut parser = Parser::new(&tokens); + let program = parser.parse().expect("Should parse"); + + let mut type_checker = TypeChecker::new(); + let result = type_checker.check_types(&program); + + if let Err(errors) = result { + assert!( + !errors + .iter() + .any(|e| e.message.contains("Cannot index into Nothing")), + "Self-recursive action negating its result must type-check clean; got: {errors:?}" + ); + } +} From 030b0bfe4d0ab9a8fe283a63511b92bb6a051b05 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 13:19:43 +0000 Subject: [PATCH 2/2] test: consolidate recursive-action regression tests and tighten assertion (#590) Address review feedback on PR #591: extract the shared lex/parse/typecheck flow into `assert_typechecks_clean`, and assert the programs type-check with zero diagnostics (`result.is_ok()`) instead of only checking for the absence of one error substring. The tighter guard catches both a re-introduced "Cannot index into Nothing" error and any new spurious diagnostic on the recursive path. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018Qykg1eQ2bJKx2uoJNBGPj --- tests/recursive_action_return_type_test.rs | 71 ++++++++++------------ 1 file changed, 31 insertions(+), 40 deletions(-) diff --git a/tests/recursive_action_return_type_test.rs b/tests/recursive_action_return_type_test.rs index 0bbf2fef..c3240c65 100644 --- a/tests/recursive_action_return_type_test.rs +++ b/tests/recursive_action_return_type_test.rs @@ -13,11 +13,31 @@ use wfl::lexer::lex_wfl_with_positions; use wfl::parser::Parser; use wfl::typechecker::TypeChecker; -/// Issue #590: self-recursive call whose result is indexed inside the body must -/// not raise a false "Cannot index into Nothing" diagnostic. +/// Type-check `code` and assert it produces zero diagnostics. Asserting a fully +/// clean result (rather than only the absence of one error substring) is a +/// tighter regression guard: it catches both a re-introduced "Cannot index into +/// Nothing" error and any new spurious diagnostic on the same recursive path. +fn assert_typechecks_clean(code: &str) { + let tokens = lex_wfl_with_positions(code); + let mut parser = Parser::new(&tokens); + let program = parser.parse().expect("Should parse"); + + let mut type_checker = TypeChecker::new(); + let result = type_checker.check_types(&program); + + assert!( + result.is_ok(), + "Self-recursive action should type-check clean; got: {:?}", + result.err() + ); +} + +/// Issue #590: a self-recursive call whose result is indexed directly inside the +/// body must not raise a false "Cannot index into Nothing" diagnostic. #[test] fn test_self_recursive_action_result_not_typed_nothing() { - let code = r#" + assert_typechecks_clean( + r#" define action called other with parameters n: create map m: "val" is n @@ -34,30 +54,16 @@ define action called p_unary with parameters n: end action display (p_unary of 3)["val"] -"#; - - let tokens = lex_wfl_with_positions(code); - let mut parser = Parser::new(&tokens); - let program = parser.parse().expect("Should parse"); - - let mut type_checker = TypeChecker::new(); - let result = type_checker.check_types(&program); - - if let Err(errors) = result { - assert!( - !errors - .iter() - .any(|e| e.message.contains("Cannot index into Nothing")), - "Self-recursive action result must not be typed Nothing; got: {errors:?}" - ); - } +"#, + ); } -/// A self-recursive action that negates its recursive result (the Scribe -/// `scribe_p_unary` shape) must also type-check clean. +/// A self-recursive action that negates its recursive result before indexing +/// (the Scribe `scribe_p_unary` shape) must also type-check clean. #[test] fn test_self_recursive_action_negating_result_typechecks_clean() { - let code = r#" + assert_typechecks_clean( + r#" define action called other with parameters n: create map m: "val" is n @@ -74,21 +80,6 @@ define action called p_unary with parameters n: end action display (p_unary of 3)["val"] -"#; - - let tokens = lex_wfl_with_positions(code); - let mut parser = Parser::new(&tokens); - let program = parser.parse().expect("Should parse"); - - let mut type_checker = TypeChecker::new(); - let result = type_checker.check_types(&program); - - if let Err(errors) = result { - assert!( - !errors - .iter() - .any(|e| e.message.contains("Cannot index into Nothing")), - "Self-recursive action negating its result must type-check clean; got: {errors:?}" - ); - } +"#, + ); }