Skip to content

Commit 32fb4b9

Browse files
committed
Guard clause narrowing across instanceof branches
1 parent 1abdb06 commit 32fb4b9

8 files changed

Lines changed: 226 additions & 99 deletions

File tree

docs/CHANGELOG.md

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

1010
### Fixed
1111

12+
- **Guard clause narrowing across instanceof branches.** After `if ($x instanceof Y) { return; }`, subsequent `instanceof` checks on the same variable no longer incorrectly resolve to `Y`. Previously the diagnostic cache reused the narrowed type from inside the first branch for all later accesses to the same variable, producing false-positive "not found" warnings (e.g. "Property 'value' not found on class 'Stringable'" inside a `BackedEnum` branch).
1213
- **`instanceof self/static/parent` narrowing.** Type narrowing with `instanceof self`, `instanceof static`, and `instanceof parent` now works correctly in all contexts (assert, if-blocks, guard clauses, compound conditions). Previously these keywords were ignored because the AST parser produces dedicated node types rather than identifiers, and `parent` was not recognized as a type hint during resolution.
1314
- **Variable assignments inside foreach loops.** Variables conditionally reassigned inside a `foreach` body are now visible after the loop. Previously, `$x = null; foreach (...) { $x = new Foo(); } $x->method();` lost the `Foo` type because the foreach body was only scanned when the cursor was inside it. The `while`, `for`, and `do-while` loops were not affected.
1415
- **Variable-to-variable type propagation.** Assignments like `$found = $pen` now resolve `$found` to the type of `$pen`. Previously, bare variable references on the right-hand side of an assignment were ignored, so patterns like `foreach ($pens as $pen) { $found = $pen; }` left `$found` untyped. This also eliminates false-positive `scalar_member_access` diagnostics when the initial assignment was `$found = null` — the type from the later reassignment now takes precedence.

docs/todo/bugs.md

Lines changed: 1 addition & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -15,41 +15,4 @@ within the same impact tier.
1515

1616
---
1717

18-
#### B10. Negative narrowing after early return not applied
19-
20-
| | |
21-
|---|---|
22-
| **Impact** | Low-Medium |
23-
| **Effort** | Medium |
24-
25-
After `if ($x instanceof Y) { return; }`, the variable `$x` should be
26-
narrowed to exclude `Y` for all subsequent code in the same scope.
27-
PHPantom does not apply this negative narrowing via early return.
28-
29-
**Reproduce:**
30-
31-
```php
32-
public static function toString(mixed $value): string
33-
{
34-
if ($value instanceof Stringable) {
35-
return $value->__toString();
36-
}
37-
if ($value instanceof BackedEnum) {
38-
$value = $value->value; // PHPantom resolves $value as Stringable here
39-
}
40-
}
41-
```
42-
43-
The diagnostic reports `Property 'value' not found on class 'Stringable'`
44-
because `$value` is still resolved as `Stringable` inside the
45-
`BackedEnum` branch, even though that branch is only reachable when
46-
`$value` is NOT `Stringable`.
47-
48-
Guard-clause narrowing already works for `if (!$x instanceof Y) { return; }`
49-
(positive narrowing after negated check). This is the inverse: positive
50-
check with exit should produce negative narrowing for subsequent code.
51-
52-
**Triage count:** ~2 diagnostics directly, but a general correctness
53-
issue that affects any code using the early-return-after-instanceof
54-
pattern.
55-
18+
No outstanding items.

example.php

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,18 @@ public function demo(): void
316316
if (!$unknown instanceof Rock) return; // single-statement guard (no braces)
317317
$unknown->crush(); // narrowed to Rock
318318
}
319+
320+
/** Positive instanceof + early return on a mixed parameter. */
321+
public function mixedGuard(mixed $value): void
322+
{
323+
if ($value instanceof Banana) {
324+
return; // $value is Banana → exit
325+
}
326+
// After the guard, $value is NOT Banana.
327+
if ($value instanceof Rock) {
328+
$value->crush(); // narrowed to Rock (not Banana)
329+
}
330+
}
319331
}
320332

321333

@@ -4936,7 +4948,19 @@ function runDemoAssertions(): void
49364948
assert($guardSubject instanceof Rock, 'Guard: not Banana must be Rock');
49374949
} else {
49384950
assert($guardSubject instanceof Banana, 'Guard: else must be Banana');
4939-
}
4951+
}
4952+
4953+
// ── Guard clause: positive instanceof + early return on mixed ────
4954+
// After `if ($x instanceof Y) { return; }`, $x is NOT Y.
4955+
$mixedGuardVal = rand(0, 1) ? new Rock() : 'scalar';
4956+
if ($mixedGuardVal instanceof Banana) {
4957+
// would return in real code
4958+
assert(false, 'Guard: should not reach here (Banana branch)');
4959+
}
4960+
// $mixedGuardVal is NOT Banana after the guard
4961+
if ($mixedGuardVal instanceof Rock) {
4962+
assert(is_string($mixedGuardVal->crush()), 'Guard: mixed narrowed to Rock');
4963+
}
49404964

49414965
// ── Ternary narrowing ───────────────────────────────────────────────
49424966
$ternaryThing = pickRockOrBanana();

src/analyse.rs

Lines changed: 79 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -238,65 +238,87 @@ pub async fn run(options: AnalyseOptions) -> i32 {
238238
}
239239

240240
let mut raw = Vec::new();
241-
let file_start = std::time::Instant::now();
242-
243-
macro_rules! timed_collect {
244-
($name:expr, $call:expr) => {{
245-
let t0 = std::time::Instant::now();
246-
$call;
247-
(t0.elapsed(), $name)
248-
}};
241+
242+
// In debug builds, time each collector and warn
243+
// about slow files. In release builds, just call
244+
// the collectors directly.
245+
#[cfg(debug_assertions)]
246+
{
247+
macro_rules! timed_collect {
248+
($name:expr, $call:expr) => {{
249+
let t0 = std::time::Instant::now();
250+
$call;
251+
(t0.elapsed(), $name)
252+
}};
253+
}
254+
255+
let file_start = std::time::Instant::now();
256+
let timings = [
257+
timed_collect!(
258+
"fast",
259+
backend.collect_fast_diagnostics(uri, content, &mut raw)
260+
),
261+
timed_collect!(
262+
"unknown_class",
263+
backend
264+
.collect_unknown_class_diagnostics(uri, content, &mut raw)
265+
),
266+
timed_collect!(
267+
"unknown_member",
268+
backend
269+
.collect_unknown_member_diagnostics(uri, content, &mut raw)
270+
),
271+
timed_collect!(
272+
"unknown_function",
273+
backend.collect_unknown_function_diagnostics(
274+
uri, content, &mut raw,
275+
)
276+
),
277+
timed_collect!(
278+
"argument_count",
279+
backend
280+
.collect_argument_count_diagnostics(uri, content, &mut raw)
281+
),
282+
timed_collect!(
283+
"implementation",
284+
backend.collect_implementation_error_diagnostics(
285+
uri, content, &mut raw,
286+
)
287+
),
288+
timed_collect!(
289+
"deprecated",
290+
backend.collect_deprecated_diagnostics(uri, content, &mut raw)
291+
),
292+
];
293+
294+
let file_elapsed = file_start.elapsed();
295+
if file_elapsed.as_secs() >= 5 {
296+
let display =
297+
files[i].strip_prefix(root).unwrap_or(&files[i]).display();
298+
let breakdown: Vec<String> = timings
299+
.iter()
300+
.filter(|(d, _)| d.as_millis() > 0)
301+
.map(|(d, name)| format!("{}={:.1}s", name, d.as_secs_f64()))
302+
.collect();
303+
eprintln!(
304+
"\n \u{26a0} slow file ({:.1}s): {}\n {}",
305+
file_elapsed.as_secs_f64(),
306+
display,
307+
breakdown.join(", "),
308+
);
309+
}
249310
}
250311

251-
let timings = [
252-
timed_collect!(
253-
"fast",
254-
backend.collect_fast_diagnostics(uri, content, &mut raw)
255-
),
256-
timed_collect!(
257-
"unknown_class",
258-
backend.collect_unknown_class_diagnostics(uri, content, &mut raw)
259-
),
260-
timed_collect!(
261-
"unknown_member",
262-
backend.collect_unknown_member_diagnostics(uri, content, &mut raw)
263-
),
264-
timed_collect!(
265-
"unknown_function",
266-
backend
267-
.collect_unknown_function_diagnostics(uri, content, &mut raw)
268-
),
269-
timed_collect!(
270-
"argument_count",
271-
backend.collect_argument_count_diagnostics(uri, content, &mut raw)
272-
),
273-
timed_collect!(
274-
"implementation",
275-
backend.collect_implementation_error_diagnostics(
276-
uri, content, &mut raw
277-
)
278-
),
279-
timed_collect!(
280-
"deprecated",
281-
backend.collect_deprecated_diagnostics(uri, content, &mut raw)
282-
),
283-
];
284-
285-
let file_elapsed = file_start.elapsed();
286-
if file_elapsed.as_secs() >= 5 {
287-
let display =
288-
files[i].strip_prefix(root).unwrap_or(&files[i]).display();
289-
let breakdown: Vec<String> = timings
290-
.iter()
291-
.filter(|(d, _)| d.as_millis() > 0)
292-
.map(|(d, name)| format!("{}={:.1}s", name, d.as_secs_f64()))
293-
.collect();
294-
eprintln!(
295-
"\n ⚠ slow file ({:.1}s): {}\n {}",
296-
file_elapsed.as_secs_f64(),
297-
display,
298-
breakdown.join(", "),
299-
);
312+
#[cfg(not(debug_assertions))]
313+
{
314+
backend.collect_fast_diagnostics(uri, content, &mut raw);
315+
backend.collect_unknown_class_diagnostics(uri, content, &mut raw);
316+
backend.collect_unknown_member_diagnostics(uri, content, &mut raw);
317+
backend.collect_unknown_function_diagnostics(uri, content, &mut raw);
318+
backend.collect_argument_count_diagnostics(uri, content, &mut raw);
319+
backend
320+
.collect_implementation_error_diagnostics(uri, content, &mut raw);
321+
backend.collect_deprecated_diagnostics(uri, content, &mut raw);
300322
}
301323

302324
let mut filtered: Vec<FileDiagnostic> = raw

src/completion/resolver.rs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ impl<'a> VarResolutionCtx<'a> {
167167
///
168168
/// Scope boundaries and variable definitions are stored alongside the
169169
/// cache and set by [`set_diagnostic_subject_cache_scopes`].
170-
type DiagSubjectCache = HashMap<(String, AccessKind, u32, u32), Vec<Arc<ClassInfo>>>;
170+
type DiagSubjectCache = HashMap<(String, AccessKind, u32, u32, u32), Vec<Arc<ClassInfo>>>;
171171

172172
/// File-level data stored alongside the diagnostic subject cache so
173173
/// that [`resolve_target_classes`] can compute the enclosing scope and
@@ -351,11 +351,23 @@ pub(crate) fn resolve_target_classes(
351351
// ── Fast path: check the thread-local diagnostic cache ──────
352352
let scope_start = diag_cache_enclosing_scope(ctx.cursor_offset);
353353
let var_def_offset = diag_cache_var_def_offset(subject, ctx.cursor_offset);
354+
// For variable subjects (excluding $this), include the cursor
355+
// offset in the cache key so that accesses inside different
356+
// instanceof-narrowing contexts (e.g. different if-bodies) get
357+
// independent cache entries. Without this, the first access
358+
// caches a narrowed type and subsequent accesses in a different
359+
// narrowing context reuse the wrong result.
360+
let narrowing_offset = if subject.starts_with('$') && !subject.starts_with("$this") {
361+
ctx.cursor_offset
362+
} else {
363+
0
364+
};
354365
let cache_key = (
355366
subject.to_string(),
356367
access_kind,
357368
scope_start,
358369
var_def_offset,
370+
narrowing_offset,
359371
);
360372
let cached = DIAG_SUBJECT_CACHE.with(|cell| {
361373
let borrow = cell.borrow();

src/diagnostics/deprecated.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -310,7 +310,7 @@ fn deprecated_diagnostic(
310310
Diagnostic {
311311
range,
312312
severity: Some(DiagnosticSeverity::HINT),
313-
code: None,
313+
code: Some(NumberOrString::String("deprecated".to_string())),
314314
code_description: None,
315315
source: Some("phpantom".to_string()),
316316
message,

0 commit comments

Comments
 (0)