Skip to content

Commit de97305

Browse files
committed
feat(check): diagnose lambda return body vs declared mismatch
Phase 7-and-a-half added lambda return-type inference but silently honored the declared annotation when present: an `fn(x) -> bool: return "hi"` passed every check, because the returning expression was never compared against the bool the user promised. The mismatch surfaced only at the call site (if at all), and pointed at the wrong span. Extend the returnStack frame to carry an `expected` return type alongside the collected types. push/pop already happens per fn / lambda body; the expected slot is populated from `Typing.ReturnT` at push time. recordReturn now checks against `expected` at the return-statement's own span when the type is concrete (not Dynamic / ErrorType - those would cascade false-positives). walkFnDef participates too: a hoisted fn declared `-> bool` whose body returns "hi" now fires at the return, not at every later call site. Mirror behavior for nested fns - they push their own frame, so an inner fn's returns check against the inner declaration, not the outer's. The NestedFnReturnIsolated snapshot pins this. Multiple returns are each checked independently. A fn that returns int from one branch and str from another, declared `-> bool`, fires twice with two distinct spans - the user can fix them one at a time. Severity is Hint, matching the rest of the assignability checks. Promotion to Error happens in the future severity migration once literal types fill the remaining fidelity gaps. Five new snapshot cases in fn_value/declared_return.snap plus the renamed/repurposed LambdaDeclaredReturnMismatchFiresAtReturn case in structural_match.snap (was Honored, marked the gap; now closes it).
1 parent 451af1d commit de97305

3 files changed

Lines changed: 170 additions & 34 deletions

File tree

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
### TITLE ###
2+
HoistedFnDeclaredReturnMismatchFires
3+
### DESCRIPTION ###
4+
A hoisted fn with a declared return type gets each `return E`
5+
checked against it. Mismatch fires at the return statement.
6+
### INPUT ###
7+
fn cb(x: int) -> bool:
8+
return "hi"
9+
### STDOUT ###
10+
# Identifier types
11+
(none)
12+
13+
# Symbol types
14+
cb (fn): fn(int) -> bool
15+
16+
# Diagnostics
17+
[hint] RAD30001 @ 2:5 - Return value of type 'str' is not assignable to declared return type 'bool'
18+
### TITLE ###
19+
HoistedFnMultipleReturnsEachChecked
20+
### DESCRIPTION ###
21+
Every return statement is checked independently. Both the int
22+
and the str disagree with the declared bool; both fire.
23+
### INPUT ###
24+
fn cb(x: int) -> bool:
25+
if x > 0:
26+
return 1
27+
return "hi"
28+
### STDOUT ###
29+
# Identifier types
30+
x @ 2:8 -> int
31+
32+
# Symbol types
33+
cb (fn): fn(int) -> bool
34+
x (param): int
35+
36+
# Diagnostics
37+
[hint] RAD30001 @ 3:9 - Return value of type 'int' is not assignable to declared return type 'bool'
38+
[hint] RAD30001 @ 4:5 - Return value of type 'str' is not assignable to declared return type 'bool'
39+
### TITLE ###
40+
HoistedFnReturnMatchesDeclared
41+
### DESCRIPTION ###
42+
When the return value matches the declaration, no diagnostic.
43+
This is the happy path.
44+
### INPUT ###
45+
fn cb(x: int) -> bool:
46+
return x > 0
47+
### STDOUT ###
48+
# Identifier types
49+
x @ 2:12 -> int
50+
51+
# Symbol types
52+
cb (fn): fn(int) -> bool
53+
x (param): int
54+
55+
# Diagnostics
56+
(none)
57+
### TITLE ###
58+
HoistedFnDynamicReturnSuppresses
59+
### DESCRIPTION ###
60+
A return value that synth'd to Dynamic doesn't fire - we can't
61+
prove a mismatch from a "we don't know" type. Gradual-typing
62+
escape hatch.
63+
### INPUT ###
64+
fn dyn() -> any:
65+
return 1
66+
67+
fn cb(x: int) -> bool:
68+
return dyn()
69+
### STDOUT ###
70+
# Identifier types
71+
dyn @ 5:12 -> fn() -> any
72+
73+
# Symbol types
74+
cb (fn): fn(int) -> bool
75+
dyn (fn): fn() -> any
76+
77+
# Diagnostics
78+
(none)
79+
### TITLE ###
80+
NestedFnReturnIsolated
81+
### DESCRIPTION ###
82+
A return inside a nested fn belongs to the nested fn, not the
83+
enclosing one. Here the outer fn declares `-> bool` but never
84+
returns; only the inner fn returns - it returns int, matching
85+
the inner declaration, so no diagnostic.
86+
### INPUT ###
87+
fn outer() -> bool:
88+
fn inner() -> int:
89+
return 5
90+
### STDOUT ###
91+
# Identifier types
92+
(none)
93+
94+
# Symbol types
95+
inner (fn): fn() -> int
96+
outer (fn): fn() -> bool
97+
98+
# Diagnostics
99+
(none)

rts/check/snapshots/fn_value/structural_match.snap

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -274,15 +274,13 @@ process(fn(x):
274274
# Diagnostics
275275
[hint] RAD30001 @ 4:9 - Argument type 'fn(any) -> void' is not assignable to expected type 'fn(int) -> bool'
276276
### TITLE ###
277-
LambdaWithDeclaredReturnHonored
277+
LambdaDeclaredReturnMismatchFiresAtReturn
278278
### DESCRIPTION ###
279-
When the user writes a return annotation (`-> bool`) we use it
280-
verbatim instead of inferring. Here the body returns "hi" (str),
281-
which doesn't match the declared bool - that's the right place
282-
for the diagnostic to fire (typed-local-style mismatch on the
283-
return, not at the call site). Today we don't yet enforce the
284-
body-vs-declared-return check, so the call still passes - this
285-
snapshot pins the current behavior and marks the gap.
279+
When the user writes a return annotation (`-> bool`) we honor it
280+
AND check each `return E` against it at the return statement's
281+
span (not at the call site). Here the body returns "hi" (str)
282+
where bool was promised - the diagnostic lands on the return,
283+
which is the right place to point the user.
286284
### INPUT ###
287285
fn process(callback: fn(int) -> bool):
288286
callback(5)
@@ -298,4 +296,4 @@ process(fn(x) -> bool: return "hi")
298296
process (fn): fn(fn(int) -> bool)
299297

300298
# Diagnostics
301-
(none)
299+
[hint] RAD30001 @ 4:31 - Return value of type 'str' is not assignable to declared return type 'bool'

rts/check/type_check.go

Lines changed: 64 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -99,38 +99,59 @@ type typeChecker struct {
9999
resolved *Resolved
100100
info *TypeInfo
101101
frame *Frame
102-
// returnStack collects return-statement value types per
103-
// enclosing function/lambda scope. Each entry is the running
104-
// list of types observed inside one fn body. Lambdas use it to
105-
// synth a proper TypingFnT return type instead of falling back
106-
// to Dynamic. Pushed on lambda/fn entry, popped on exit. Stack
107-
// depth N means we're inside N nested fn bodies; a return at
108-
// depth N belongs to the innermost (top) frame, never the
109-
// outer ones - matching the runtime where returns target the
110-
// nearest enclosing fn.
111-
returnStack [][]rl.TypingT
102+
// returnStack carries one frame per enclosing function/lambda
103+
// scope. `collected` accumulates return-value types for return
104+
// inference (used by synthLambda; reserved for hoisted-fn
105+
// inference). `expected` is the declared return type, used to
106+
// check each `return E` at the return-statement's own span;
107+
// nil means "no declared return, only inference applies."
108+
// Pushed on lambda/fn entry, popped on exit.
109+
returnStack []returnFrame
112110
}
113111

114-
func (tc *typeChecker) pushReturnFrame() {
115-
tc.returnStack = append(tc.returnStack, nil)
112+
type returnFrame struct {
113+
collected []rl.TypingT
114+
expected rl.TypingT
115+
}
116+
117+
func (tc *typeChecker) pushReturnFrame(expected rl.TypingT) {
118+
tc.returnStack = append(tc.returnStack, returnFrame{expected: expected})
116119
}
117120

118121
func (tc *typeChecker) popReturnFrame() []rl.TypingT {
119122
n := len(tc.returnStack)
120-
out := tc.returnStack[n-1]
123+
out := tc.returnStack[n-1].collected
121124
tc.returnStack = tc.returnStack[:n-1]
122125
return out
123126
}
124127

125128
// recordReturn appends a return-value type to the innermost fn
126-
// scope's accumulator. A return outside any fn (which is itself a
127-
// validation error caught elsewhere) is a no-op here.
128-
func (tc *typeChecker) recordReturn(t rl.TypingT) {
129+
// scope's accumulator and, if that scope has a declared return
130+
// type, checks the value against it at the return's own span. A
131+
// return outside any fn (validation-error elsewhere) is a no-op.
132+
func (tc *typeChecker) recordReturn(t rl.TypingT, retNode rl.Node) {
129133
n := len(tc.returnStack)
130134
if n == 0 {
131135
return
132136
}
133-
tc.returnStack[n-1] = append(tc.returnStack[n-1], t)
137+
tc.returnStack[n-1].collected = append(tc.returnStack[n-1].collected, t)
138+
expected := tc.returnStack[n-1].expected
139+
if expected == nil || retNode == nil || t == nil {
140+
return
141+
}
142+
if isErrorType(t) || isDynamicLike(t) {
143+
return
144+
}
145+
if expected.IsAssignableFrom(t) {
146+
return
147+
}
148+
tc.info.Issues = append(tc.info.Issues, BindIssue{
149+
Span: retNode.Span(),
150+
Severity: IssueHint,
151+
Code: rl.ErrTypeMismatch,
152+
Message: fmt.Sprintf("Return value of type '%s' is not assignable to declared return type '%s'",
153+
t.Name(), expected.Name()),
154+
})
134155
}
135156

136157
func (tc *typeChecker) walkFile(file *rl.SourceFile) {
@@ -183,7 +204,7 @@ func (tc *typeChecker) walkStmt(n rl.Node) {
183204
}
184205
t = rl.NewTupleType(elems...)
185206
}
186-
tc.recordReturn(t)
207+
tc.recordReturn(t, v)
187208
default:
188209
// Generic descent. Later sub-commits replace these with
189210
// kind-specific handlers (for loops, switch, return, etc.).
@@ -293,8 +314,14 @@ func (tc *typeChecker) walkFnDef(n *rl.FnDef) {
293314
// collected types here yet - hoisted-fn return inference is a
294315
// separate commit gated on SCC handling for mutual recursion.
295316
// Pushing now is the cheap half: it keeps lambdas correct when
296-
// a named fn is declared inside them.
297-
tc.pushReturnFrame()
317+
// a named fn is declared inside them. We feed the declared
318+
// return (if any) so body returns get checked against it at
319+
// their own span, same as lambdas.
320+
var declaredReturn rl.TypingT
321+
if n.Typing != nil && n.Typing.ReturnT != nil {
322+
declaredReturn = *n.Typing.ReturnT
323+
}
324+
tc.pushReturnFrame(declaredReturn)
298325
tc.walkStmts(n.Body)
299326
_ = tc.popReturnFrame()
300327
tc.frame = saved
@@ -2172,20 +2199,32 @@ func (tc *typeChecker) synthLambda(n *rl.Lambda) rl.TypingT {
21722199
// frame'\''s narrowings - that'\''s closure semantics. The
21732200
// reassignment-after-definition lookahead that would invalidate
21742201
// stale narrowings is still deferred (see docstring).
2175-
tc.pushReturnFrame()
2202+
//
2203+
// Feed the declared return (if any) into the return frame so
2204+
// each `return E` in the body gets checked against the
2205+
// declaration at its own span. Lambdas without a declared
2206+
// return have nothing to check against; the inferred return
2207+
// flows out via popReturnFrame.
2208+
var declaredReturn rl.TypingT
2209+
if n.Typing != nil && n.Typing.ReturnT != nil {
2210+
declaredReturn = *n.Typing.ReturnT
2211+
}
2212+
tc.pushReturnFrame(declaredReturn)
21762213

21772214
// Expression-form lambdas (`fn(x) x + 1`) put the expression
21782215
// node directly in Body (verified against the converter output -
21792216
// it does not wrap in ExprStmt). Each body entry is the value to
2180-
// return; synth it to feed the inferred return type. Block-form
2181-
// lambdas walk via walkStmts and rely on the `*rl.Return` case
2182-
// in walkStmt to populate returnStack.
2217+
// return; synth it to feed the inferred return type. We pass
2218+
// the body node itself as the diagnostic span - it'\''s the
2219+
// implicit return for these. Block-form lambdas walk via
2220+
// walkStmts and rely on the `*rl.Return` case to populate
2221+
// returnStack.
21832222
if !n.IsBlock {
21842223
for _, stmt := range n.Body {
21852224
if stmt == nil {
21862225
continue
21872226
}
2188-
tc.recordReturn(tc.synth(stmt))
2227+
tc.recordReturn(tc.synth(stmt), stmt)
21892228
}
21902229
} else {
21912230
tc.walkStmts(n.Body)

0 commit comments

Comments
 (0)