Skip to content

Commit c919f8d

Browse files
committed
A plain function's @return docblock is checked against its body
1 parent 758e90f commit c919f8d

5 files changed

Lines changed: 147 additions & 67 deletions

File tree

docs/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
124124

125125
- **The type engine itself now knows that a class is an `object` and that a `Traversable` is `iterable`.** Those facts, together with the equivalence between the ways one array type can be written (`array<int, Cat>`, `list<Cat>`, and `Cat[]` all describe the same values), used to be reachable only from the argument-type check, so everything else reasoned without them: to completion filtering and hover a `Collection<User>` was not an `object` and an `ArrayIterator` was not `iterable`, and an array whose element type only matched through a parent class did not match at all once the two sides were spelled at different arities. They live in the shared subtype check now, so every feature reads the same answer. This does tighten one case: a value that may be `null` no longer satisfies an `object` or `iterable` parameter, matching both what PHP does at runtime and how a nullable value has always been judged against every other parameter type.
126126
- **A ternary inside a `throw` narrows its branches.** `throw new RuntimeException($model ? get_class($model) : '')` still read `$model` as nullable inside the arm the check proves it is not, so whatever that arm passed on was reported against the wider type. The identical ternary in a `return`, an assignment, or a call argument narrowed correctly. A thrown value is walked like any other expression now.
127+
- **A plain function's `@return` docblock is checked against its body.** `/** @return array<string, int> */ function bad(): array { return ['a' => 'x']; }` went unreported, and so did every other return that satisfied the native hint but not the docblock behind it. The check read the `array` written in the signature and stopped there, so the type that says what the array actually holds was never compared to anything, while the same mistake passed to a parameter was reported as it should be. The docblock and the hint are now merged the way they are everywhere else, so a function is held to the type it documents rather than the weaker one it declares. Methods were never affected, which is what made the gap look like an array-shape problem rather than a function one.
127128
- **A generic call keeps the alternatives its argument's return type declares.** `takesCarbon(passthrough(Carbon::create(2024)))` was reported as fine even though `Carbon::create()` returns `?Carbon`. The pass that works out what a `@template` binds to reads its argument as text, and that reading answers with the classes an expression can be, so the `null` arm was dropped and the template bound a plain `Carbon`. Every use of the substituted type then claimed the value could never be null: a missed report where the result is consumed, and an invented one where a parameter is checked against it. Writing the call to a variable first bound the nullable correctly, which is what made the two forms disagree. The alternatives a call's return type declares and the class walk cannot name, `null` and scalars alike, are now put back, so both forms bind the same thing. A call a check has already narrowed keeps the narrowed type rather than having the declared one restored over it.
128129
- **A one-line function no longer inherits the previous function's `@param`.** `function g3(array $s): void { foreach ($s as $x) { doThing($x); } }` opens and closes its body on the signature line, and the backward scan that looks for an enclosing docblock relied on watching brace depth rise and fall to spot where a sibling function ends; a body written entirely on one line never moves that depth, so the scan walked straight past `g3` into its docblock and handed `g4(Status $s)` the `@param array<Status> $s` written for a different function entirely, reporting a type error against a type the parameter never had. The scan now also recognises a `function` keyword sitting at a depth it has already fully backed out of, which catches a body collapsed onto one line the same as one spread across several.
129130
- **A guard on a call narrows the same call written again.** `if (currentUser()) { render(currentUser()); }` is the shape a nullable accessor is written for, and it was reported as passing a `?User`. A check on `$holder->get()` already carried to the next `$holder->get()`; a plain function call and a static call did not, because the scope had no entry for either to narrow. Both are now recorded under the call's own text like a method call is, so a repeated `currentUser()` or `Session::current()` inside the guard reads what the guard proved. A call that takes arguments is keyed with them, so checking one call still says nothing about a different one.

docs/todo.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,6 @@ contributor even though it's short.
3838
| B78 | [A standalone `@var` cast above `return` is ignored](todo/bugs.md#b78-a-standalone-var-cast-above-return-is-ignored) | Medium | Medium |
3939
| B82 | [A nullsafe chain compared `===` to a non-nullable value does not narrow the receiver](todo/bugs.md#b82-a-nullsafe-chain-compared--to-a-non-nullable-value-does-not-narrow-the-receiver) | Medium | Medium |
4040
| B83 | [A `match (true)` arm's condition does not narrow inside the arm's result](todo/bugs.md#b83-a-match-true-arms-condition-does-not-narrow-inside-the-arms-result) | Medium | Medium |
41-
| B84 | [Return-position compatibility ignores an array shape's value types](todo/bugs.md#b84-return-position-compatibility-ignores-an-array-shapes-value-types) | Medium | Medium |
4241
| B79 | [`array_filter()` without a callback keeps `null` on values that share the array with a `?bool`](todo/bugs.md#b79-array_filter-without-a-callback-keeps-null-on-values-that-share-the-array-with-a-bool) | Low-Medium | Medium |
4342
| B81 | [Foreach element extraction widens `false` to `bool`](todo/bugs.md#b81-foreach-element-extraction-widens-false-to-bool) | Low-Medium | Medium |
4443
| B76 | [Blade variables typed from a component class are immune to condition narrowing](todo/bugs.md#b76-blade-variables-typed-from-a-component-class-are-immune-to-condition-narrowing) | Medium | Medium-High |

docs/todo/bugs.md

Lines changed: 2 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,7 @@ block; several sit at the same source sites as fixed bugs from the
2222
previous sweep (B50, B54, B59, B62), where the coarse defect was fixed
2323
and a finer one behind it became visible. **B83** was filed the same
2424
day from the follow-up re-run at `c618c8aa`, whose compatibility
25-
tightening surfaced one previously-swallowed site, and **B84** from a
26-
probe at `66a524bc` showing the return-position side of that
27-
tightening is still missing.
25+
tightening surfaced one previously-swallowed site.
2826

2927
## Crashes
3028

@@ -73,45 +71,6 @@ sample projects first: a file that declares `strict_types=1` and
7371
leans on `__toString()` would start reporting, and those reports are
7472
correct.
7573

76-
### B84. Return-position compatibility ignores an array shape's value types
77-
78-
**Impact: Medium · Complexity: Medium**
79-
80-
```php
81-
/** @return array<string, int> */
82-
function bad(): array {
83-
return ['a' => 'x']; // not reported
84-
}
85-
86-
/** @param array<string, int> $m */
87-
function takesIntMap(array $m): void {}
88-
89-
function alsoBad(): void {
90-
takesIntMap(['a' => 'x']); // reported, as expected
91-
}
92-
```
93-
94-
Needs investigation: `type_mismatch_argument` correctly reports an
95-
array shape whose values do not satisfy the declared map or list value
96-
type (`array{a: 'x'}` vs `array<string, int>`, `array{'x', 'y'}` vs
97-
`list<int>`), but `type_mismatch_return` accepts the identical
98-
mismatch silently. The return side is not skipping shapes entirely —
99-
a nullability mismatch in a shape value (`array{a: ?bool}` vs
100-
`array<string, int>`) is reported in return position — so the two
101-
diagnostics are reaching different verdicts for the same shape-vs-map
102-
comparison somewhere below the nullability check. `array<string,
103-
never>` as the declared type is the extreme case: any all-optional-keys
104-
shape (e.g. an `array_filter()` result) passes against it, which is
105-
what kept masking scratch probes during the 2026-08-15 sweeps.
106-
107-
Found by probe at `66a524bc`, after the argument-side tightening
108-
landed; no sample-project site currently hits it.
109-
110-
**Fix:** find where the return-position compatibility path diverges
111-
from the argument-position path for shape-to-map value checks and
112-
unify them; the recently tightened argument behaviour is the correct
113-
one.
114-
11574
## Standard-library return types
11675

11776
### B80. `max()`/`min()` argument-dependent return type is a malformed union
@@ -372,8 +331,7 @@ both keep their `null` arms.
372331
Filed 2026-08-15, after the evening sweep: the argument-compatibility
373332
tightening in `c618c8aa` surfaced it — the resulting shape mismatch
374333
was previously swallowed by the diagnostic layer's array leniency
375-
(the argument side has since been tightened further; the return-side
376-
remainder is B84).
334+
(the argument side has since been tightened further).
377335

378336
Sample site: `luxplus-website app/Contexts/Api/Resources/ProductResource.php:210`
379337
(discount label `$textArgs` built from `?int` properties the arm

src/diagnostics/return_type_errors.rs

Lines changed: 53 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,7 @@ impl Backend {
182182
for stmt in program.statements.iter() {
183183
process_top_level_statement(
184184
stmt,
185+
uri,
185186
content,
186187
&file_ctx,
187188
&class_loader,
@@ -375,10 +376,48 @@ fn resolve_return_and_push(
375376
}
376377
}
377378

379+
/// The return type the index holds for the function `uri` declares at
380+
/// `func_offset`, with the `@return` docblock already merged over the native
381+
/// hint.
382+
///
383+
/// Only this file's own declaration will do. A same-named function in another
384+
/// file has a docblock that says nothing about the body being checked, so the
385+
/// index entry is accepted only when it was contributed by `uri`, and a name
386+
/// another file won the race for is looked up among the runners-up instead.
387+
fn indexed_return_type(
388+
backend: &Backend,
389+
file_ctx: &crate::types::FileContext,
390+
uri: &str,
391+
func_name: &str,
392+
func_offset: u32,
393+
) -> Option<PhpType> {
394+
let fqn = file_ctx.resolve_name_at(func_name, func_offset);
395+
396+
{
397+
let fmap = backend.global_functions().read();
398+
for name in [fqn.as_str(), func_name] {
399+
if let Some((decl_uri, fi)) = fmap.get(name)
400+
&& decl_uri == uri
401+
{
402+
return fi.return_type.clone();
403+
}
404+
}
405+
}
406+
407+
let dups = backend.symbols.duplicate_functions.read();
408+
for name in [fqn.as_str(), func_name] {
409+
if let Some(fi) = dups.get(name).and_then(|by_uri| by_uri.get(uri)) {
410+
return fi.return_type.clone();
411+
}
412+
}
413+
None
414+
}
415+
378416
#[allow(clippy::too_many_arguments)]
379417
/// Walk a top-level statement looking for function/class declarations.
380418
fn process_top_level_statement(
381419
stmt: &Statement<'_>,
420+
uri: &str,
382421
content: &str,
383422
file_ctx: &crate::types::FileContext,
384423
class_loader: &dyn Fn(&str) -> Option<Arc<ClassInfo>>,
@@ -392,6 +431,7 @@ fn process_top_level_statement(
392431
for inner in ns.statements().iter() {
393432
process_top_level_statement(
394433
inner,
434+
uri,
395435
content,
396436
file_ctx,
397437
class_loader,
@@ -462,28 +502,17 @@ fn process_top_level_statement(
462502
let func_name = bytes_to_str(func.name.value);
463503
let func_offset = func.name.span.start.offset;
464504

465-
// Extract the declared return type. Prefer the AST's native
466-
// return type hint (always available for the current file),
467-
// then fall back to the global function index (which may
468-
// carry a richer docblock-enriched type).
469-
let declared_return = func
470-
.return_type_hint
471-
.as_ref()
472-
.map(|rth| crate::parser::extract_hint_type(&rth.hint))
473-
.or_else(|| {
474-
let fqn = file_ctx.resolve_name_at(func_name, func_offset);
475-
backend
476-
.global_functions()
477-
.read()
478-
.get(&fqn)
479-
.and_then(|(_, fi)| fi.return_type.clone())
480-
.or_else(|| {
481-
backend
482-
.global_functions()
483-
.read()
484-
.get(func_name)
485-
.and_then(|(_, fi)| fi.return_type.clone())
486-
})
505+
// Extract the declared return type. Prefer the indexed
506+
// `FunctionInfo`, where the parser has already merged the
507+
// `@return` docblock over the native hint, so a body is checked
508+
// against `array<string, int>` rather than bare `array`. Falling
509+
// back to the AST hint covers a function the index has not caught
510+
// up with yet.
511+
let declared_return =
512+
indexed_return_type(backend, file_ctx, uri, func_name, func_offset).or_else(|| {
513+
func.return_type_hint
514+
.as_ref()
515+
.map(|rth| crate::parser::extract_hint_type(&rth.hint))
487516
});
488517

489518
let declared_return = match declared_return {
@@ -556,6 +585,7 @@ fn process_top_level_statement(
556585
DeclareBody::Statement(inner) => {
557586
process_top_level_statement(
558587
inner,
588+
uri,
559589
content,
560590
file_ctx,
561591
class_loader,
@@ -569,6 +599,7 @@ fn process_top_level_statement(
569599
for s in body.statements.iter() {
570600
process_top_level_statement(
571601
s,
602+
uri,
572603
content,
573604
file_ctx,
574605
class_loader,

tests/integration/diagnostics_return_type_errors.rs

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3920,3 +3920,94 @@ function gate(?string $grade): string {
39203920
return_error_messages(&collect(php)).join("; ")
39213921
);
39223922
}
3923+
3924+
// ── A function's `@return` docblock refines its native `array` hint ──────────
3925+
3926+
#[test]
3927+
fn flags_shape_value_mismatch_against_docblock_map_on_plain_function() {
3928+
let php = r#"<?php
3929+
/** @return array<string, int> */
3930+
function bad(): array {
3931+
return ['a' => 'x'];
3932+
}
3933+
"#;
3934+
let diags = collect(php);
3935+
let msgs = return_error_messages(&diags);
3936+
assert!(
3937+
msgs.iter().any(|m| m.contains("array<string, int>")),
3938+
"Expected the docblock map type to be checked, not the bare `array` hint, got: {msgs:?}"
3939+
);
3940+
}
3941+
3942+
#[test]
3943+
fn flags_shape_value_mismatch_against_docblock_list_on_plain_function() {
3944+
let php = r#"<?php
3945+
/** @return list<int> */
3946+
function bad(): array {
3947+
return ['x', 'y'];
3948+
}
3949+
"#;
3950+
let diags = collect(php);
3951+
let msgs = return_error_messages(&diags);
3952+
assert!(
3953+
msgs.iter().any(|m| m.contains("list<int>")),
3954+
"Expected the docblock list type to be checked, got: {msgs:?}"
3955+
);
3956+
}
3957+
3958+
#[test]
3959+
fn no_diagnostic_when_shape_satisfies_docblock_map_on_plain_function() {
3960+
let php = r#"<?php
3961+
/** @return array<string, int> */
3962+
function good(): array {
3963+
return ['a' => 1];
3964+
}
3965+
3966+
/** @return list<int> */
3967+
function goodList(): array {
3968+
return [1, 2];
3969+
}
3970+
3971+
/** @return array<string, int> */
3972+
function goodEmpty(): array {
3973+
return [];
3974+
}
3975+
"#;
3976+
let diags = collect(php);
3977+
assert!(
3978+
!has_return_error(&diags),
3979+
"Expected no return type error, got: {:?}",
3980+
return_error_messages(&diags)
3981+
);
3982+
}
3983+
3984+
#[test]
3985+
fn docblock_return_type_of_another_files_function_is_not_borrowed() {
3986+
let backend = create_test_backend();
3987+
let other = "file:///other.php";
3988+
backend.update_ast(
3989+
other,
3990+
r#"<?php
3991+
/** @return array<string, int> */
3992+
function dup(): array {
3993+
return [];
3994+
}
3995+
"#,
3996+
);
3997+
3998+
let uri = "file:///test.php";
3999+
let php = r#"<?php
4000+
function dup(): array {
4001+
return ['a' => 'x'];
4002+
}
4003+
"#;
4004+
backend.update_ast(uri, php);
4005+
let mut diags = Vec::new();
4006+
backend.collect_return_type_diagnostics(uri, php, &mut diags);
4007+
assert!(
4008+
!has_return_error(&diags),
4009+
"This file declares `dup(): array` with no docblock, so the other file's \
4010+
`@return array<string, int>` must not be checked against this body, got: {:?}",
4011+
return_error_messages(&diags)
4012+
);
4013+
}

0 commit comments

Comments
 (0)