Skip to content

Commit 138bcf3

Browse files
committed
fix: add context-aware diagnostics for class-string<static>
Resolve self/static/$this/parent in parameter types to concrete class names before the type compatibility check, so that class-string<static> 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<static> into class-string<DeclaringClass>, letting the existing class-string covariance check flag provably invalid arguments (unrelated classes) while still accepting child classes and siblings. Closes #273
1 parent 62753f8 commit 138bcf3

3 files changed

Lines changed: 117 additions & 2 deletions

File tree

docs/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
6464
- **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.
6565
- **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.
6666
- **`class-string<static>` 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<static>`. Contributed by @calebdw.
67+
- **`class-string<static>` parameters now diagnose provably invalid class strings.** Passing an unrelated class to a `class-string<static>` 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.
6768
- **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.
6869
- **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.
6970
- **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.

src/diagnostics/type_errors/mod.rs

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -549,6 +549,33 @@ impl Backend {
549549
// mapping positional args to parameter indices.
550550
let mut positional_idx: usize = 0;
551551

552+
let call_context_class: Option<String> = {
553+
let class_part = if let Some(pos) = expr.find("::") {
554+
Some(&expr[..pos])
555+
} else if expr.starts_with("$this->") {
556+
Some("$this" as &str)
557+
} else {
558+
None
559+
};
560+
class_part.and_then(|cp| {
561+
let low = cp.to_ascii_lowercase();
562+
match low.as_str() {
563+
"self" | "static" | "$this" => {
564+
find_innermost_enclosing_class(&file_ctx.classes, call_site.args_start)
565+
.map(|c| c.fqn().to_string())
566+
}
567+
"parent" => {
568+
find_innermost_enclosing_class(&file_ctx.classes, call_site.args_start)
569+
.and_then(|c| c.parent_class.as_ref().map(|p| p.to_string()))
570+
}
571+
_ => class_loader(cp).map(|cls| cls.fqn().to_string()),
572+
}
573+
})
574+
};
575+
let ctx_parent_fqn: Option<String> = call_context_class.as_ref().and_then(|fqn| {
576+
class_loader(fqn).and_then(|cls| cls.parent_class.as_ref().map(|p| p.to_string()))
577+
});
578+
552579
// Check each argument against its parameter.
553580
for (arg_idx, resolved_arg) in resolved_args.args.iter().enumerate() {
554581
// Skip spread arguments.
@@ -606,8 +633,21 @@ impl Backend {
606633
continue;
607634
}
608635

636+
let resolved_param;
637+
let effective_param_type = if param_type.contains_self_ref() {
638+
if let Some(ref fqn) = call_context_class {
639+
resolved_param =
640+
param_type.resolve_self_refs(fqn, ctx_parent_fqn.as_deref());
641+
&resolved_param
642+
} else {
643+
param_type
644+
}
645+
} else {
646+
param_type
647+
};
648+
609649
// Check compatibility.
610-
if is_type_compatible(arg_type, param_type, &class_loader, strict_types) {
650+
if is_type_compatible(arg_type, effective_param_type, &class_loader, strict_types) {
611651
// Even when the array types are compatible, validate
612652
// string literals against model-property<Model> when
613653
// the param type is an array with model-property in
@@ -659,9 +699,21 @@ impl Backend {
659699
&& !alt_type.is_untyped()
660700
&& !alt_type.is_mixed()
661701
{
702+
let resolved_alt;
703+
let effective_alt = if alt_type.contains_self_ref() {
704+
if let Some(ref fqn) = call_context_class {
705+
resolved_alt = alt_type
706+
.resolve_self_refs(fqn, ctx_parent_fqn.as_deref());
707+
&resolved_alt
708+
} else {
709+
alt_type
710+
}
711+
} else {
712+
alt_type
713+
};
662714
return is_type_compatible(
663715
arg_type,
664-
alt_type,
716+
effective_alt,
665717
&class_loader,
666718
strict_types,
667719
);

tests/integration/diagnostics_type_errors.rs

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1918,6 +1918,68 @@ final class SClass3 extends AClass {}
19181918
);
19191919
}
19201920

1921+
#[test]
1922+
fn diagnose_unrelated_class_string_passed_to_static_bound() {
1923+
let php = r#"<?php
1924+
abstract class Base273 {
1925+
/** @param class-string<static> $class */
1926+
private static function make(string $class): void {}
1927+
1928+
public static function run(): void {
1929+
static::make(Unrelated273::class);
1930+
}
1931+
}
1932+
1933+
final class Child273 extends Base273 {}
1934+
final class Unrelated273 {}
1935+
"#;
1936+
let diags = collect(php);
1937+
assert!(
1938+
has_type_error(&diags),
1939+
"Should flag unrelated class-string passed to class-string<static>"
1940+
);
1941+
}
1942+
1943+
#[test]
1944+
fn no_diagnostic_for_child_class_string_via_explicit_call() {
1945+
let php = r#"<?php
1946+
abstract class Base273b {
1947+
/** @param class-string<static> $class */
1948+
public static function make(string $class): void {}
1949+
}
1950+
1951+
final class Child273b extends Base273b {}
1952+
1953+
function test273b(): void {
1954+
Base273b::make(Child273b::class);
1955+
}
1956+
"#;
1957+
let diags = collect(php);
1958+
assert!(
1959+
!has_type_error(&diags),
1960+
"Should not flag child class-string passed to class-string<static> via explicit call, got: {diags:?}"
1961+
);
1962+
}
1963+
1964+
#[test]
1965+
fn no_diagnostic_for_self_class_string_to_static_bound() {
1966+
let php = r#"<?php
1967+
abstract class Base273c {
1968+
/** @param class-string<static> $class */
1969+
private static function make(string $class): void {}
1970+
1971+
public static function run(): void {
1972+
self::make(static::class);
1973+
}
1974+
}
1975+
"#;
1976+
let diags = collect(php);
1977+
assert!(
1978+
!has_type_error(&diags),
1979+
"Should not flag static::class passed to class-string<static> via self:: call, got: {diags:?}"
1980+
);
1981+
}
1982+
19211983
// ═══════════════════════════════════════════════════════════════════════════
19221984
// New rules: iterable<...> accepts arrays
19231985
// ═══════════════════════════════════════════════════════════════════════════

0 commit comments

Comments
 (0)