Skip to content

Commit 53d7967

Browse files
committed
Fix diagnostic cache poisoning by depth-limited variable resolution
1 parent 642cc62 commit 53d7967

4 files changed

Lines changed: 215 additions & 8 deletions

File tree

docs/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
6161
- **Closure parameter inference from function-level `@template` bindings.** Functions like `array_any`, `array_all`, and `array_find` that declare `@template` parameters bound through an array parameter and used in a `callable` parameter now infer concrete types for untyped closure arguments. For example, `array_any($this->items, fn($item) => $item->name !== '')` where `$this->items` is `array<int, Product>` now resolves `$item` as `Product`. Previously `$item` was unresolved, producing false-positive `unresolved_member_access` diagnostics. This also fixes a related issue where `@param` tags in phpstorm-stubs that omit the parameter name (e.g. `@param callable(TValue, TKey): bool` without `$callback`) failed to enrich the parameter's type hint, preventing callable parameter type extraction.
6262
- **Property chain arguments in template substitution.** Expressions like `$this->items` or `$obj->prop` passed as arguments to templated functions now resolve their type for template binding. Previously only bare variables (e.g. `$items`) were resolved, so `array_any($this->items, fn($x) => ...)` could not infer the element type even when the property had a fully substituted generic type.
6363
- **Variable reassignment inside `try` blocks now tracked by the analyzer.** When a variable was reassigned to a different type inside a `try` block, subsequent accesses within the same block still resolved against the original type, producing false-positive `unknown_member` diagnostics. The same applied to `catch` and `finally` blocks. Assignments preceding the cursor within the same block are now treated as sequential.
64+
- **Diagnostic subject cache no longer poisoned by depth-limited variable resolution.** When a variable was reassigned inside a nested `foreach` via a self-referential expression (e.g. `$total = $total->add(…)`), the recursive RHS resolution could reach the `MAX_VAR_RESOLUTION_DEPTH` safety limit and return empty. This empty result was cached in `DIAG_SUBJECT_CACHE`, causing later top-level lookups (at depth 0) to hit the poisoned cache entry and report false "type could not be resolved" diagnostics. The cache now skips storing empty results when the depth guard has fired, using a sticky thread-local flag that stays set until the outermost `resolve_variable_types` call returns.
6465
- **Types with `covariant` or `contravariant` variance annotations in generic args now parse correctly.** PHPStan/Larastan annotations like `BelongsTo<Category, covariant $this>` previously failed to parse, causing the entire type to become unresolvable. This broke Eloquent relationship property synthesis (virtual properties were not generated when the `@return` annotation used variance modifiers) and member lookup on relationship method return types (e.g. `$model->category()->associate()` reported `associate()` as not found). The variance keywords are now stripped from generic parameter positions before parsing.
6566
- **Diagnostics now work for vendor files open in the editor.** Projects using `--prefer-source` or monorepo setups where libraries are edited from the `vendor/` directory no longer have diagnostics suppressed. Previously all vendor files were unconditionally skipped, hiding syntax errors, deprecation warnings, and all other diagnostics. The `analyze` command also accepts vendor paths as explicit overrides (e.g. `phpantom_lsp analyze vendor/some-package/src/`).
6667
- **Full-line diagnostic suppression no longer requires a code mapping.** Full-line diagnostics (from tools like PHPStan that only report line numbers) are now suppressed whenever any precise diagnostic exists on the same line, regardless of error codes. Previously suppression relied on a hand-maintained mapping between error codes from different tools, which was incomplete and allowed redundant full-line underlines to obscure the precise location of the issue.

src/completion/resolver.rs

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -517,12 +517,23 @@ pub(crate) fn resolve_target_classes(
517517
let result = resolve_target_classes_expr(&expr, access_kind, ctx);
518518

519519
// ── Populate the cache if active ────────────────────────────
520-
DIAG_SUBJECT_CACHE.with(|cell| {
521-
let mut borrow = cell.borrow_mut();
522-
if let Some((map, _)) = borrow.as_mut() {
523-
map.insert(cache_key, result.clone());
524-
}
525-
});
520+
// Skip caching empty results when the variable resolution depth
521+
// guard has fired. A depth-limited empty result does not mean
522+
// the variable is genuinely unresolvable — it only means the
523+
// recursion was too deep *this time*. Caching such an empty
524+
// vec would poison the cache: a later top-level lookup (at
525+
// depth 0) would hit the cached empty entry and produce a
526+
// false "type could not be resolved" diagnostic.
527+
let skip_cache = result.is_empty()
528+
&& super::variable::resolution::is_var_resolution_depth_limited();
529+
if !skip_cache {
530+
DIAG_SUBJECT_CACHE.with(|cell| {
531+
let mut borrow = cell.borrow_mut();
532+
if let Some((map, _)) = borrow.as_mut() {
533+
map.insert(cache_key, result.clone());
534+
}
535+
});
536+
}
526537

527538
result
528539
}

src/completion/variable/resolution.rs

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,13 @@ use crate::completion::resolver::{Loaders, VarResolutionCtx};
5858
// directly (diagnostics, hover, foreach resolution).
5959
thread_local! {
6060
static VAR_RESOLUTION_DEPTH: Cell<u8> = const { Cell::new(0) };
61+
/// Sticky flag set when `resolve_variable_types` returns empty
62+
/// because the depth guard fired. The flag stays set until the
63+
/// outermost `resolve_variable_types` call returns, so callers
64+
/// up the stack (e.g. `resolve_target_classes`) can detect that
65+
/// an empty result was caused by the depth limit — not because
66+
/// the variable is genuinely unresolvable — and skip caching it.
67+
static VAR_RESOLUTION_DEPTH_LIMITED: Cell<bool> = const { Cell::new(false) };
6168
}
6269

6370
/// Maximum nesting depth for `resolve_variable_types` calls.
@@ -74,6 +81,18 @@ thread_local! {
7481
/// safety net for any remaining recursive patterns.
7582
const MAX_VAR_RESOLUTION_DEPTH: u8 = 3;
7683

84+
/// Returns `true` when any `resolve_variable_types` call on the
85+
/// current stack has returned empty because the depth guard fired.
86+
///
87+
/// The flag is sticky: once set, it stays `true` until the outermost
88+
/// `resolve_variable_types` call returns and clears it. This lets
89+
/// callers further up the stack (e.g. `resolve_target_classes`) detect
90+
/// that an empty result was caused by the depth limit — not because
91+
/// the variable is genuinely unresolvable — and skip caching it.
92+
pub(crate) fn is_var_resolution_depth_limited() -> bool {
93+
VAR_RESOLUTION_DEPTH_LIMITED.with(|f| f.get())
94+
}
95+
7796
/// Build a [`VarClassStringResolver`] closure from a [`VarResolutionCtx`].
7897
///
7998
/// The returned closure resolves a variable name (e.g. `"$requestType"`)
@@ -174,6 +193,7 @@ pub(crate) fn resolve_variable_types(
174193
});
175194
if depth >= MAX_VAR_RESOLUTION_DEPTH {
176195
VAR_RESOLUTION_DEPTH.with(|d| d.set(d.get().saturating_sub(1)));
196+
VAR_RESOLUTION_DEPTH_LIMITED.with(|f| f.set(true));
177197
return vec![];
178198
}
179199

@@ -194,7 +214,17 @@ pub(crate) fn resolve_variable_types(
194214
resolve_variable_in_statements(program.statements.iter(), &ctx)
195215
});
196216

197-
VAR_RESOLUTION_DEPTH.with(|d| d.set(d.get().saturating_sub(1)));
217+
let new_depth = VAR_RESOLUTION_DEPTH.with(|d| {
218+
let v = d.get().saturating_sub(1);
219+
d.set(v);
220+
v
221+
});
222+
// Clear the sticky depth-limited flag when the outermost call
223+
// returns. Inner calls leave it set so that every caller in
224+
// the stack can observe it.
225+
if new_depth == 0 {
226+
VAR_RESOLUTION_DEPTH_LIMITED.with(|f| f.set(false));
227+
}
198228
result
199229
}
200230

@@ -222,6 +252,7 @@ pub(crate) fn resolve_variable_types_branch_aware(
222252
});
223253
if depth >= MAX_VAR_RESOLUTION_DEPTH {
224254
VAR_RESOLUTION_DEPTH.with(|d| d.set(d.get().saturating_sub(1)));
255+
VAR_RESOLUTION_DEPTH_LIMITED.with(|f| f.set(true));
225256
return vec![];
226257
}
227258

@@ -246,7 +277,14 @@ pub(crate) fn resolve_variable_types_branch_aware(
246277
},
247278
);
248279

249-
VAR_RESOLUTION_DEPTH.with(|d| d.set(d.get().saturating_sub(1)));
280+
let new_depth = VAR_RESOLUTION_DEPTH.with(|d| {
281+
let v = d.get().saturating_sub(1);
282+
d.set(v);
283+
v
284+
});
285+
if new_depth == 0 {
286+
VAR_RESOLUTION_DEPTH_LIMITED.with(|f| f.set(false));
287+
}
250288
result
251289
}
252290

src/diagnostics/unknown_members.rs

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5098,4 +5098,161 @@ final class PurchaseFileProductCollection extends Collection {
50985098
"expected no diagnostics for $this->items on generic Collection subclass, got: {diags:?}"
50995099
);
51005100
}
5101+
5102+
#[test]
5103+
fn no_false_positive_when_variable_reassigned_inside_try_inside_foreach() {
5104+
// B3 follow-up: when a variable is assigned before a foreach,
5105+
// then reassigned inside a try block nested inside the foreach
5106+
// body, the type should still resolve for accesses after the
5107+
// reassignment (still inside the try).
5108+
//
5109+
// Real-world pattern from OrderService:137:
5110+
// $remaining = $order->amount; // Decimal via @property
5111+
// foreach ($payments as $payment) {
5112+
// try {
5113+
// $remaining = $remaining->sub($toCapture); // ← should resolve
5114+
// } catch (...) {}
5115+
// }
5116+
let php = r#"<?php
5117+
class Decimal {
5118+
public function sub(string $v): self { return new self(); }
5119+
public function isZero(): bool { return true; }
5120+
public function isNegative(): bool { return true; }
5121+
public function isPositive(): bool { return true; }
5122+
public function toFixed(int $places): string { return ''; }
5123+
}
5124+
5125+
/**
5126+
* @property Decimal $amount
5127+
* @property string $state
5128+
*/
5129+
class Payment {
5130+
}
5131+
5132+
/**
5133+
* @property Decimal $amount
5134+
*/
5135+
class Order {
5136+
}
5137+
5138+
class CaptureException extends \Exception {}
5139+
class InvalidStateException extends \Exception {}
5140+
class CaptureService {
5141+
public function captureReservedPayment(Payment $p, Decimal $amount): void {}
5142+
}
5143+
5144+
class OrderService {
5145+
/** @param list<Payment> $payments */
5146+
public function capture(Order $order, array $payments): void {
5147+
$remaining = $order->amount;
5148+
foreach ($payments as $payment) {
5149+
if ($payment->state === 'paid') {
5150+
$remaining = $remaining->sub('1');
5151+
}
5152+
}
5153+
5154+
$svc = new CaptureService();
5155+
foreach ($payments as $payment) {
5156+
if ($payment->state !== 'reserved') {
5157+
continue;
5158+
}
5159+
5160+
$toCapture = $remaining->isPositive() ? $payment->amount : $remaining;
5161+
if ($toCapture->isZero() || $toCapture->isNegative()) {
5162+
break;
5163+
}
5164+
5165+
try {
5166+
$svc->captureReservedPayment($payment, $toCapture);
5167+
$remaining = $remaining->sub('1');
5168+
} catch (CaptureException|InvalidStateException $e) {
5169+
}
5170+
}
5171+
5172+
if ($remaining->isPositive() && !$remaining->isZero()) {
5173+
throw new \RuntimeException('remaining: ' . $remaining->toFixed(2));
5174+
}
5175+
}
5176+
}
5177+
"#;
5178+
let backend = Backend::new_test();
5179+
backend.config.lock().diagnostics.unresolved_member_access = Some(true);
5180+
let diags = collect(&backend, "file:///test.php", php);
5181+
assert!(
5182+
diags.is_empty(),
5183+
"expected no diagnostics for variable reassigned inside try-inside-foreach, got: {diags:?}"
5184+
);
5185+
}
5186+
5187+
#[test]
5188+
fn no_false_positive_when_variable_reassigned_inside_nested_foreach() {
5189+
// Regression test for cache poisoning by depth-limited variable
5190+
// resolution. When `$orderCostPrice` is reassigned inside a
5191+
// nested foreach via `$orderCostPrice = $orderCostPrice->add(…)`,
5192+
// the self-referential RHS triggers recursive calls to
5193+
// resolve_variable_types. With two levels of foreach nesting
5194+
// the recursion reaches MAX_VAR_RESOLUTION_DEPTH, producing an
5195+
// empty result. If that empty result is cached in
5196+
// DIAG_SUBJECT_CACHE, the later top-level resolution (at
5197+
// depth 0) for the *outer* foreach access hits the poisoned
5198+
// cache entry and reports "type could not be resolved".
5199+
//
5200+
// Real-world pattern from OrderService:618:
5201+
// $zero = new Decimal('0');
5202+
// $orderCostPrice = $zero;
5203+
// foreach ($order->getOrderProducts() as $line) {
5204+
// if ($product->isBundle()) {
5205+
// foreach ($bundleProducts as $bp) {
5206+
// $productCostPrice = $bp->supplier_price_dkk ?? $zero;
5207+
// $orderCostPrice = $orderCostPrice->add($productCostPrice->mul($qty));
5208+
// }
5209+
// continue;
5210+
// }
5211+
// $productCostPrice = $product->supplier_price_dkk ?? $zero;
5212+
// $orderCostPrice = $orderCostPrice->add($productCostPrice->mul($qty));
5213+
// }
5214+
// return $orderCostPrice->mul($rate);
5215+
let php = r#"<?php
5216+
class Decimal {
5217+
public function add(string $v): self { return new self(); }
5218+
public function mul(string $v): self { return new self(); }
5219+
}
5220+
5221+
class Item {
5222+
public Decimal $cost;
5223+
public function isBundle(): bool { return false; }
5224+
/** @return list<Item> */
5225+
public function getChildren(): array { return []; }
5226+
}
5227+
5228+
class OrderService {
5229+
/** @param list<Item> $items */
5230+
public function calculateCost(array $items): Decimal {
5231+
$zero = new Decimal();
5232+
$result = $zero;
5233+
foreach ($items as $item) {
5234+
if ($item->isBundle()) {
5235+
$children = $item->getChildren();
5236+
foreach ($children as $child) {
5237+
$result = $result->add($child->cost->mul('1'));
5238+
}
5239+
5240+
continue;
5241+
}
5242+
5243+
$result = $result->add($item->cost->mul('1'));
5244+
}
5245+
5246+
return $result->mul('1');
5247+
}
5248+
}
5249+
"#;
5250+
let backend = Backend::new_test();
5251+
backend.config.lock().diagnostics.unresolved_member_access = Some(true);
5252+
let diags = collect(&backend, "file:///test.php", php);
5253+
assert!(
5254+
diags.is_empty(),
5255+
"expected no diagnostics for variable reassigned inside nested foreach loops, got: {diags:?}"
5256+
);
5257+
}
51015258
}

0 commit comments

Comments
 (0)