Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
206 changes: 188 additions & 18 deletions src/Analyser/Scope.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,31 @@
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
{

/** @var list<string> PHP superglobal variable names that are always available */
public const SUPERGLOBAL_VARIABLES = [
'GLOBALS',
'_SERVER',
Expand All @@ -38,8 +59,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 +78,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 +94,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 +141,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
7 changes: 6 additions & 1 deletion src/Php/PhpVersion.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@
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 via `Scope::getPhpVersion()` (which returns
* PhpVersions, a range-aware wrapper) or by injecting PhpVersion directly.
*
* @api
*/
#[AutowiredService(factory: '@PHPStan\Php\PhpVersionFactory::create')]
Expand All @@ -19,7 +25,6 @@ final class PhpVersion

/**
* @api
*
* @param self::SOURCE_* $source
*/
public function __construct(private int $versionId, private int $source = self::SOURCE_UNKNOWN)
Expand Down
Loading
Loading