From 249cb9668a8dd783bd77ff96f7c9a71b622939f4 Mon Sep 17 00:00:00 2001 From: Caleb White Date: Sat, 25 Jul 2026 18:17:58 -0500 Subject: [PATCH] fix: add context-aware diagnostics for class-string Resolve self/static/$this/parent in parameter types to concrete class names before the type compatibility check, so that class-string is properly validated instead of blanket- suppressed. The call-site context class is extracted from the call expression: ClassName::method uses the named class, static::/self::/$this-> uses the enclosing class, and parent:: uses its parent. This turns class-string into class-string, letting the existing class-string covariance check flag provably invalid arguments (unrelated classes) while still accepting child classes and siblings. Closes #273 --- docs/CHANGELOG.md | 1 + src/diagnostics/type_errors/mod.rs | 56 +++++++++++++++++- tests/integration/diagnostics_type_errors.rs | 62 ++++++++++++++++++++ 3 files changed, 117 insertions(+), 2 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index fc2af8fc..588366de 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -64,6 +64,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Static method calls resolve return types as accurately as instance calls.** `Foo::bar()` previously missed inference that `$foo->bar()` already had: return types behind a `@phpstan-type` alias, inherited return types substituted through a generic interface or trait, and the `__callStatic()` magic-method fallback. These now resolve the same way for both call styles. - **Facade static calls keep concrete method return types.** Static calls on Laravel-style facades now resolve missing methods through `getFacadeAccessor()` and facade `@mixin` targets before falling back to `__callStatic()`, so values like `Driver::details()` keep the concrete provider method return type instead of degrading to the facade's broad magic-call return. Contributed by @calebdw. - **`class-string` parameters no longer reject sibling subclass constants.** Static helper calls from a shared base class that pass concrete `::class` constants for sibling subclasses no longer report false argument-type mismatches against `class-string`. Contributed by @calebdw. +- **`class-string` parameters now diagnose provably invalid class strings.** Passing an unrelated class to a `class-string` parameter is now flagged as a type mismatch instead of being silently accepted. The diagnostic resolves `static` to the declaring class at the call site and checks whether the argument class is in the inheritance hierarchy, so child classes and siblings are still accepted while unrelated classes are rejected. Contributed by @calebdw. - **Built-in PHP classes shadowed by vendor polyfills resolve to the real definition.** When an installed package ships a polyfill for a PHP built-in (for example symfony/polyfill-php84's `RoundingMode`), resolution sometimes picked the polyfill's legacy pre-enum declaration instead of the built-in, turning enum cases into plain int constants and reporting false "expects RoundingMode, got int" argument mismatches. Which declaration won could change from one run to the next, making whole-project analysis results nondeterministic. Global names of built-in classes now always resolve to the bundled PHP definition, and classes discovered inside phar archives are indexed in a stable order. - **Member name positions no longer suggest classes.** Typing a name after `function`, `const`, or enum `case` (for example `protected function getC`) no longer offers unrelated class names from the project. Property names were already safe because they start with `$`. Contributed by @calebdw. - **Null-initialized variables reassigned in an untyped foreach are not stuck as `null`.** When the iterable has no known element type (for example an untyped parameter), the loop value is now treated as `mixed`, so `$x = $value` after `$x = null` participates in post-loop merge and `is_null` early-return narrowing instead of leaving a false `null` type at later call sites. Contributed by @calebdw. diff --git a/src/diagnostics/type_errors/mod.rs b/src/diagnostics/type_errors/mod.rs index c8368ae4..cfa4e87f 100644 --- a/src/diagnostics/type_errors/mod.rs +++ b/src/diagnostics/type_errors/mod.rs @@ -549,6 +549,33 @@ impl Backend { // mapping positional args to parameter indices. let mut positional_idx: usize = 0; + let call_context_class: Option = { + let class_part = if let Some(pos) = expr.find("::") { + Some(&expr[..pos]) + } else if expr.starts_with("$this->") { + Some("$this" as &str) + } else { + None + }; + class_part.and_then(|cp| { + let low = cp.to_ascii_lowercase(); + match low.as_str() { + "self" | "static" | "$this" => { + find_innermost_enclosing_class(&file_ctx.classes, call_site.args_start) + .map(|c| c.fqn().to_string()) + } + "parent" => { + find_innermost_enclosing_class(&file_ctx.classes, call_site.args_start) + .and_then(|c| c.parent_class.as_ref().map(|p| p.to_string())) + } + _ => class_loader(cp).map(|cls| cls.fqn().to_string()), + } + }) + }; + let ctx_parent_fqn: Option = call_context_class.as_ref().and_then(|fqn| { + class_loader(fqn).and_then(|cls| cls.parent_class.as_ref().map(|p| p.to_string())) + }); + // Check each argument against its parameter. for (arg_idx, resolved_arg) in resolved_args.args.iter().enumerate() { // Skip spread arguments. @@ -606,8 +633,21 @@ impl Backend { continue; } + let resolved_param; + let effective_param_type = if param_type.contains_self_ref() { + if let Some(ref fqn) = call_context_class { + resolved_param = + param_type.resolve_self_refs(fqn, ctx_parent_fqn.as_deref()); + &resolved_param + } else { + param_type + } + } else { + param_type + }; + // Check compatibility. - if is_type_compatible(arg_type, param_type, &class_loader, strict_types) { + if is_type_compatible(arg_type, effective_param_type, &class_loader, strict_types) { // Even when the array types are compatible, validate // string literals against model-property when // the param type is an array with model-property in @@ -659,9 +699,21 @@ impl Backend { && !alt_type.is_untyped() && !alt_type.is_mixed() { + let resolved_alt; + let effective_alt = if alt_type.contains_self_ref() { + if let Some(ref fqn) = call_context_class { + resolved_alt = alt_type + .resolve_self_refs(fqn, ctx_parent_fqn.as_deref()); + &resolved_alt + } else { + alt_type + } + } else { + alt_type + }; return is_type_compatible( arg_type, - alt_type, + effective_alt, &class_loader, strict_types, ); diff --git a/tests/integration/diagnostics_type_errors.rs b/tests/integration/diagnostics_type_errors.rs index bb825070..8f0cc9a2 100644 --- a/tests/integration/diagnostics_type_errors.rs +++ b/tests/integration/diagnostics_type_errors.rs @@ -1918,6 +1918,68 @@ final class SClass3 extends AClass {} ); } +#[test] +fn diagnose_unrelated_class_string_passed_to_static_bound() { + let php = r#" $class */ + private static function make(string $class): void {} + + public static function run(): void { + static::make(Unrelated273::class); + } +} + +final class Child273 extends Base273 {} +final class Unrelated273 {} +"#; + let diags = collect(php); + assert!( + has_type_error(&diags), + "Should flag unrelated class-string passed to class-string" + ); +} + +#[test] +fn no_diagnostic_for_child_class_string_via_explicit_call() { + let php = r#" $class */ + public static function make(string $class): void {} +} + +final class Child273b extends Base273b {} + +function test273b(): void { + Base273b::make(Child273b::class); +} +"#; + let diags = collect(php); + assert!( + !has_type_error(&diags), + "Should not flag child class-string passed to class-string via explicit call, got: {diags:?}" + ); +} + +#[test] +fn no_diagnostic_for_self_class_string_to_static_bound() { + let php = r#" $class */ + private static function make(string $class): void {} + + public static function run(): void { + self::make(static::class); + } +} +"#; + let diags = collect(php); + assert!( + !has_type_error(&diags), + "Should not flag static::class passed to class-string via self:: call, got: {diags:?}" + ); +} + // ═══════════════════════════════════════════════════════════════════════════ // New rules: iterable<...> accepts arrays // ═══════════════════════════════════════════════════════════════════════════