Skip to content
44 changes: 44 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,24 @@ Based on analysis of recent releases (2.1.30-2.1.38), these are the recurring pa

A recurring cleanup theme: never use `$type instanceof StringType` or similar. This misses union types, intersection types with accessory types, and other composite forms. Always use `$type->isString()->yes()` or `(new StringType())->isSuperTypeOf($type)`. Multiple PRs have systematically replaced `instanceof *Type` checks throughout the codebase.

### Type system: add methods to the Type interface instead of one-offing conditions

When a bug requires checking a type property across the codebase, the fix is often to add a new method to the `Type` interface rather than scattering `instanceof` checks or utility function calls throughout rules and extensions. This ensures every type implementation handles the query correctly (including union/intersection types which delegate to their inner types) and keeps the logic centralized.

Historical analysis of `Type.php` via `git blame` shows that new methods are added for several recurring reasons:

- **Replacing scattered `instanceof` checks (~30%)**: Methods like `isNull()`, `isTrue()`, `isFalse()`, `isString()`, `isInteger()`, `isFloat()`, `isBoolean()`, `isArray()`, `isScalar()`, `isObject()`, `isEnum()`, `getClassStringObjectType()`, `getObjectClassNames()`, `getObjectClassReflections()` were added to replace `$type instanceof ConstantBooleanType`, `$type instanceof StringType`, etc. Each type implements the method correctly — e.g., `UnionType::isNull()` returns `yes` only if all members are null, `maybe` if some are, `no` if none are. This is impossible to get right with a single `instanceof` check.

- **Moving logic from TypeUtils/extensions into Type (~35%)**: Methods like `toArrayKey()`, `toBoolean()`, `toNumber()`, `toFloat()`, `toInteger()`, `toString()`, `toArray()`, `flipArray()`, `getKeysArray()`, `getValuesArray()`, `popArray()`, `shiftArray()`, `shuffleArray()`, `reverseSortArray()`, `getEnumCases()`, `isCallable()`, `getCallableParametersAcceptors()`, `isList()` moved scattered utility logic into polymorphic dispatch. When logic lives in a utility function it typically uses a chain of `if ($type instanceof X) ... elseif ($type instanceof Y) ...` which breaks when new type classes are added or misses edge cases in composite types.

- **Supporting new type features (~15%)**: Methods like `isNonEmptyString()`, `isNonFalsyString()`, `isLiteralString()`, `isClassString()`, `isNonEmptyArray()`, `isIterableAtLeastOnce()` were added as PHPStan gained support for more refined types (accessory types in intersections). These enable rules to query refined properties without knowing how the refinement is represented internally.

- **Bug fixes through better polymorphism (~10%)**: Some bugs are directly fixed by adding a new Type method. For example, `isOffsetAccessLegal()` fixed false positives about illegal offset access by letting each type declare whether `$x[...]` is valid. `setExistingOffsetValueType()` (distinct from `setOffsetValueType()`) fixed array list type preservation bugs. `toCoercedArgumentType()` fixed parameter type contravariance issues during type coercion.

- **Richer return types (~5%)**: Methods that returned `TrinaryLogic` were changed to return `AcceptsResult` or `IsSuperTypeOfResult`, which carry human-readable reasons for why a type relationship holds or doesn't. This enabled better error messages without changing the call sites significantly.

When considering a bug fix that involves checking "is this type a Foo?", first check whether an appropriate method already exists on `Type`. If not, consider whether adding one would be the right fix — especially if the check is needed in more than one place or involves logic that varies by type class.

### MutatingScope: expression invalidation during scope merging

When two scopes are merged (e.g. after if/else branches), `MutatingScope::generalizeWith()` must invalidate dependent expressions. If variable `$i` changes, then `$locations[$i]` must be invalidated too. Bugs arise when stale `ExpressionTypeHolder` entries survive scope merges. Fix pattern: in `MutatingScope`, when a root expression changes, skip/invalidate all deep expressions that depend on it.
Expand Down Expand Up @@ -291,6 +309,32 @@ Recent work on PHP 8.5 support shows the pattern:
- **PhpVersion**: Add detection methods like `supportsPropertyHooks()`, `supportsPipeOperator()`, etc.
- **Stubs**: Update function/class stubs for new built-in functions and changed signatures

## Writing PHPDocs

When adding or editing PHPDoc comments in this codebase, follow these guidelines:

### What to document

- **Class-level docs on interfaces and key abstractions**: Explain the role of the interface, what implements it, and how it fits into the architecture. Mention non-obvious patterns like double-dispatch (CompoundType), the intersection-with-base-type requirement (AccessoryType), or the instanceof-avoidance rule (TypeWithClassName).
- **Non-obvious behavior**: Document when a method's behavior differs from what its name suggests, or when there are subtle contracts. For example: `getDeclaringClass()` returning the declaring class even for inherited members, `setExistingOffsetValueType()` vs `setOffsetValueType()` preserving list types differently, or `getWritableType()` potentially differing from `getReadableType()` due to asymmetric visibility.
- **`@api` tags**: Keep these — they mark the public API for extension developers.
- **`@phpstan-assert` tags**: Keep these — they provide type narrowing information that PHPStan uses.
- **`@return`, `@param`, `@template` tags**: Keep when they provide type information not expressible in native PHP types (e.g. `@return self::SOURCE_*`, `@param array<string, Type>`).

### What NOT to document

- **Obvious from the method name**: Do not write "Returns the name" above `getName()`, "Returns the value type" above `getValueType()`, or "Returns whether deprecated" above `isDeprecated()`. If the method name says it all, add no description.
- **Obvious to experienced PHP developers**: Do not explain standard visibility rules ("public methods are always callable, protected methods are callable from subclasses..."), standard PHP semantics, or basic design patterns.
- **Obvious from tags**: Do not add prose that restates what `@return`, `@phpstan-assert`, or `@param` tags already say. If `@return non-empty-string|null` is present, do not also write "Returns a non-empty string or null".
- **Factory method descriptions that repeat the class-level doc**: If the class doc already explains the levels/variants (like VerbosityLevel or GeneralizePrecision), don't repeat those descriptions on each factory method. A bare `@api` tag is sufficient.
- **Getter/setter/query methods on value objects**: Methods like `isInvariant()`, `isCovariant()`, `isEmpty()`, `count()`, `getType()`, `hasType()` on simple value objects need no PHPDoc.

### Style

- Keep descriptions concise — one or two sentences for method docs when needed.
- Use imperative voice without "Returns the..." preambles when a brief note suffices. Prefer `/** Replaces unresolved TemplateTypes with their bounds. */` over a multi-line block.
- Preserve `@api` and type tags on their own lines, with no redundant description alongside them.

## Important dependencies

- `nikic/php-parser` ^5.7.0 - PHP AST parsing
Expand Down
205 changes: 187 additions & 18 deletions src/Analyser/Scope.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,27 @@
use PHPStan\Type\Type;
use PHPStan\Type\TypeWithClassName;

/** @api */
/**
* Represents the state of the analyser at a specific position in the AST.
*
* The Scope tracks everything PHPStan knows at a given point in code: variable types,
* the current class/function/method context, whether strict_types is enabled, and more.
* It is the primary interface through which rules and extensions query information
* about the analysed code.
*
* The Scope is passed as a parameter to:
* - Custom rules (2nd parameter of processNode())
* - Dynamic return type extensions (last parameter of getTypeFrom*Call())
* - Dynamic throw type extensions
* - Type-specifying extensions (3rd parameter of specifyTypes())
*
* The Scope is immutable from the extension's perspective. Each AST node gets
* its own Scope reflecting the analysis state at that point. For example, after
* an `if ($x instanceof Foo)` check, the Scope inside the if-branch knows that
* $x is of type Foo.
*
* @api
*/
interface Scope extends ClassMemberAccessAnswerer, NamespaceAnswerer
{

Expand All @@ -38,8 +58,16 @@ interface Scope extends ClassMemberAccessAnswerer, NamespaceAnswerer
'_ENV',
];

/**
* When analysing a trait, returns the file where the trait is used,
* not the trait file itself. Use getFileDescription() for the trait file path.
*/
public function getFile(): string;

/**
* For traits, returns the trait file path with the using class context,
* e.g. "TraitFile.php (in context of class MyClass)".
*/
public function getFileDescription(): string;

public function isDeclareStrictTypes(): bool;
Expand All @@ -49,6 +77,10 @@ public function isDeclareStrictTypes(): bool;
*/
public function isInTrait(): bool;

/**
* Returns the trait itself, not the class using the trait.
* Use getClassReflection() for the using class.
*/
public function getTraitReflection(): ?ClassReflection;

public function getFunction(): ?PhpFunctionFromParserNodeReflection;
Expand All @@ -61,21 +93,27 @@ public function hasVariableType(string $variableName): TrinaryLogic;

public function getVariableType(string $variableName): Type;

public function canAnyVariableExist(): bool;

/**
* @return array<int, string>
* True at the top level of a file or after extract() — contexts where
* arbitrary variables may exist.
*/
public function canAnyVariableExist(): bool;

/** @return array<int, string> */
public function getDefinedVariables(): array;

/**
* Variables with TrinaryLogic::Maybe certainty — defined in some code paths but not others.
*
* @return array<int, string>
*/
public function getMaybeDefinedVariables(): array;

public function hasConstant(Name $name): bool;

/** @deprecated Use getInstancePropertyReflection or getStaticPropertyReflection instead */
/**
* @deprecated Use getInstancePropertyReflection or getStaticPropertyReflection instead
*/
public function getPropertyReflection(Type $typeWithProperty, string $propertyName): ?ExtendedPropertyReflection;

public function getInstancePropertyReflection(Type $typeWithProperty, string $propertyName): ?ExtendedPropertyReflection;
Expand All @@ -102,69 +140,200 @@ public function getAnonymousFunctionReflection(): ?ClosureType;

public function getAnonymousFunctionReturnType(): ?Type;

/**
* Returns the PHPDoc-enhanced type. Use getNativeType() for native types only.
*/
public function getType(Expr $node): Type;

/**
* Returns only what PHP's native type system knows, ignoring PHPDoc.
*/
public function getNativeType(Expr $expr): Type;

/**
* Like getType(), but preserves void for function/method calls
* (normally getType() replaces void with null).
*/
public function getKeepVoidType(Expr $node): Type;

/**
* The `getType()` method along with FNSR enabled
* waits for the Expr analysis to be completed
* in order to evaluate the type at the right place in the code.
*
* This prevents tricky bugs when reasoning about code like
* `doFoo($a = 1, $a)`.
*
* Sometimes this is counter-productive because we actually want
* to use the current Scope object contents to resolve the Expr type.
*
* In these cases use `getScopeType()`.
* Unlike getType() which may defer evaluation, this uses the scope's
* current state immediately.
*/
public function getScopeType(Expr $expr): Type;

public function getScopeNativeType(Expr $expr): Type;

/**
* Resolves a Name AST node to a fully qualified class name string.
*
* Handles special names: `self` and `static` resolve to the current class,
* `parent` resolves to the parent class. Other names are returned as-is
* (they should already be fully qualified by the PHP parser's name resolver).
*
* Inside a Closure::bind() context, `self`/`static` resolve to the bound class.
*/
public function resolveName(Name $name): string;

/**
* Resolves a Name AST node to a TypeWithClassName.
*
* Unlike resolveName() which returns a plain string, this returns a proper
* Type object that preserves late-static-binding information:
* - `static` returns a StaticType (preserves LSB in subclasses)
* - `self` returns a ThisType when inside the same class hierarchy
* - Other names return an ObjectType
*/
public function resolveTypeByName(Name $name): TypeWithClassName;

/**
* Returns the PHPStan Type representing a given PHP value.
*
* Converts runtime PHP values to their corresponding constant types:
* integers become ConstantIntegerType, strings become ConstantStringType,
* arrays become ConstantArrayType (if small enough), etc.
*
* @param mixed $value
*/
public function getTypeFromValue($value): Type;

/**
* Returns whether an expression has a tracked type in this scope.
*
* Returns TrinaryLogic::Yes if the expression's type is definitely known,
* TrinaryLogic::Maybe if it might be known, and TrinaryLogic::No if there
* is no type information for it.
*
* This checks the scope's expression type map without computing the type
* (unlike getType() which always computes a type).
*/
public function hasExpressionType(Expr $node): TrinaryLogic;

/**
* Returns whether the given class name is being checked inside a
* class_exists(), interface_exists(), or trait_exists() call.
*
* When true, rules should suppress "class not found" errors because
* the code is explicitly checking for the class's existence.
*/
public function isInClassExists(string $className): bool;

/**
* Returns whether the given function name is being checked inside a
* function_exists() call.
*
* When true, rules should suppress "function not found" errors because
* the code is explicitly checking for the function's existence.
*/
public function isInFunctionExists(string $functionName): bool;

/**
* Returns whether the current analysis context is inside a Closure::bind()
* or Closure::bindTo() call.
*
* When true, the closure's $this and self/static may refer to a different
* class than the one where the closure was defined.
*/
public function isInClosureBind(): bool;

/** @return list<FunctionReflection|MethodReflection> */
/**
* Returns the stack of function/method calls that are currently being analysed.
*
* When analysing arguments of a function call, this returns the chain of
* enclosing calls. Used by extensions that need to know the calling context,
* such as type-specifying extensions for functions like class_exists().
*
* @return list<FunctionReflection|MethodReflection>
*/
public function getFunctionCallStack(): array;

/** @return list<array{FunctionReflection|MethodReflection, ParameterReflection|null}> */
/**
* Like getFunctionCallStack(), but also includes the parameter being passed to.
*
* Each entry is a tuple of the function/method reflection and the parameter
* reflection for the argument position being analysed (or null if unknown).
*
* @return list<array{FunctionReflection|MethodReflection, ParameterReflection|null}>
*/
public function getFunctionCallStackWithParameters(): array;

/**
* Returns whether a function parameter has a default value of null.
*
* Checks the parameter's default value AST node to determine if
* `= null` was specified. Used by function definition checks.
*/
public function isParameterValueNullable(Param $parameter): bool;

/**
* Resolves a type AST node (from a parameter/return type declaration) to a Type.
*
* Handles named types, identifier types (int, string, etc.), union types,
* intersection types, and nullable types. The $isNullable flag adds null
* to the type, and $isVariadic wraps the type in an array.
*
* @param Node\Name|Node\Identifier|Node\ComplexType|null $type
*/
public function getFunctionType($type, bool $isNullable, bool $isVariadic): Type;

/**
* Returns whether the given expression is currently being assigned to.
*
* Returns true during the analysis of the right-hand side of an assignment
* to this expression. For example, when analysing `$a = expr`, this returns
* true for the $a variable during the analysis of `expr`.
*
* Used to prevent infinite recursion when resolving types during assignment.
*/
public function isInExpressionAssign(Expr $expr): bool;

/**
* Returns whether accessing the given expression in an undefined state is allowed.
*
* Returns true when the expression is on the left-hand side of an assignment
* or in similar contexts where it's valid for the expression to be undefined
* (e.g. `$a['key'] = value` where $a['key'] doesn't need to exist yet).
*/
public function isUndefinedExpressionAllowed(Expr $expr): bool;

/**
* Returns a new Scope with types narrowed by assuming the expression is truthy.
*
* Given an expression like `$x instanceof Foo`, returns a scope where
* $x is known to be of type Foo. This is the scope used inside the
* if-branch of `if ($x instanceof Foo)`.
*
* Uses the TypeSpecifier internally to determine type narrowing.
*/
public function filterByTruthyValue(Expr $expr): self;

/**
* Returns a new Scope with types narrowed by assuming the expression is falsy.
*
* The opposite of filterByTruthyValue(). Given `$x instanceof Foo`, returns
* a scope where $x is known NOT to be of type Foo. This is the scope used
* in the else-branch of `if ($x instanceof Foo)`.
*/
public function filterByFalseyValue(Expr $expr): self;

/**
* Returns whether the current statement is a "first-level" statement.
*
* A first-level statement is one that is directly inside a function/method
* body, not nested inside control structures like if/else, loops, or
* try/catch. Used to determine whether certain checks should be more
* or less strict.
*/
public function isInFirstLevelStatement(): bool;

/**
* Returns the PHP version(s) being analysed against.
*
* Returns a PhpVersions object that can represent a range of PHP versions
* (when the exact version is not known). Use its methods like
* supportsEnums(), supportsReadonlyProperties(), etc. to check for
* version-specific features.
*/
public function getPhpVersion(): PhpVersions;

/** @internal */
Expand Down
6 changes: 5 additions & 1 deletion src/Php/PhpVersion.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@
use function floor;

/**
* Represents a specific PHP version for version-dependent analysis behavior.
*
* The version is stored as PHP_VERSION_ID format (e.g. 80100 for PHP 8.1.0).
* Extension developers can access it by injecting PhpVersion via constructor injection.
*
* @api
*/
#[AutowiredService(factory: '@PHPStan\Php\PhpVersionFactory::create')]
Expand All @@ -19,7 +24,6 @@ final class PhpVersion

/**
* @api
*
* @param self::SOURCE_* $source
*/
public function __construct(private int $versionId, private int $source = self::SOURCE_UNKNOWN)
Expand Down
8 changes: 8 additions & 0 deletions src/Php/PhpVersions.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@
use PHPStan\Type\Type;

/**
* Range-aware PHP version check that handles version uncertainty.
*
* Unlike PhpVersion (which represents a single known version), PhpVersions wraps
* a Type representing the possible PHP versions. When the exact version is known,
* queries return Yes/No. When a range of versions is possible, queries return Maybe.
*
* This is the return type of Scope::getPhpVersion().
*
* @api
*/
final class PhpVersions
Expand Down
Loading
Loading