Skip to content

Changelog

All notable changes to this project will be documented in this file.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

  • Compact symbol indexes: workspace declaration, subtype, and referencer storage are compacted (CSR-style) across the batch path, cutting duplicate projection and per-call query-setup churn.
  • Query setup deduplication: repeated find-symbol lookups, per-file analysis setups, definition walkers, and reverse dependency projections share interned symbols instead of rebuilding them.
  • #[Pure] stub honors: purity analysis now respects a #[Pure] attribute on user stubs.
  • readonly / enum mutation-free receivers: enum receiver accessors, readonly native getters, and native readonly class methods are treated as mutation-free by construction.
  • stub shape for ini_get_all() documented.
  • Symfony salsa query regression coverage: CI can now download the Symfony fixture and exercise analyzer queries against a full framework corpus, covering symbol indexes, declarations, definitions, inheritance, scopes, inference, and path resolution.
  • Release lint formatting: Rust sources added for the Symfony query regression suite now pass the workspace rustfmt check.
  • AnalysisSession::ancestors_of exposed: every ancestor of a class (extended class, implemented interfaces, used traits, transitively), most-derived first, self excluded. Wraps the already-tracked class_ancestors_by_fqcn primitive so a host can resolve a supertype chain without duplicating its own inheritance-edge index.
  • AnalysisSession::function_signature exposed: full FunctionDef (params, return type, purity, etc.) for a global function resolved by FQN, mirroring the existing find_function primitive at the session level. FunctionDef is now re-exported from the crate root alongside DeclaredParam/TemplateParam/Visibility.
  • WorkspaceSymbolIndex::class_like_by_short_name exposed, and AnalysisSession::classes_named: short (unqualified) class/interface/ trait/enum name → every FQCN sharing it, incrementally maintained in lockstep with the existing FQCN-keyed class_like map (no rebuild-from-scratch on edit, no per-call scan). Fills a real gap: mir’s own indexes were FQCN-keyed only, so a host resolving a bare name from PHP source with no use-import/namespace match (the residual case after proper resolution — e.g. Laravel’s many same-named Factory/Request classes) had no way to enumerate candidates short of maintaining its own duplicate name index or falling back to a text scan across the whole workspace on every query. class_like itself, and FQCN-based resolution in general, remain the standard, unambiguous path — this is additive, not a replacement.
  • Query-memo staleness across off-salsa subtype-edge commits: indexed_references_to / indexed_subtype_classes memoized per text revision, but subtype edges and anonymous-class impl: postings are committed off-salsa — a member-references result cached before a subtype query committed a gate-invisible file’s edge kept serving the smaller hierarchy fan-out for the rest of the revision. Both cache keys now carry a subtype-edge epoch that bumps only on real edge changes (unchanged recommits from background sweeps compare equal and don’t churn it).
  • Query memo caches evict dead generations eagerly: old-generation keys (heap strings, unreachable once the text revision or edge epoch moves) previously accumulated until the value-cap overflow clear; the first insert at a newer generation now drops them wholesale.
  • One workspace-symbol-index precedence rule: the tracked workspace_symbol_index fallback now drives the same tier-aware insert helpers as the imperative rebuild/seed/incremental-merge paths, instead of a parallel 3-pass implementation that had to be kept equivalent by hand.
  • parse_file keeps lru = 256, now with receipts: measured on the Laravel fixture, raising the cap to 65536 moved neither wall time nor peak RSS on the cold reference query or the cold CLI batch; the small cap bounds steady-state memory for free. The Phase-1 warm-up loop also stays serial: re-tested with coalesced revision bumps in place, the rayon variant still deadlocks under concurrent_reference_cancel.
  • AnalysisSession::files_mentioning_class exposed: lets a host reuse the persistent class-mention index (previously internal-only, used by indexed_references_to’s own reference-query gate) instead of maintaining an equivalent from-scratch text scanner for its own reachability narrowing. Answers from a per-file cached mention set when possible; a never-scanned or since-edited file is scanned once, and that scan is recorded for every needle already known to the universe, not just the one queried.
  • AnalysisSession::files_mentioning_any exposed: multi-needle form of files_mentioning_class for a host resolving several candidate names at once (e.g. an owner FQN plus its subtype closure) in one shared pass.
  • ClassMentionIndex supports raw (no-word-bound) needles: add_raw_names/add_raw_mention_needles admit a needle that isn’t itself a whole identifier (e.g. a call token like ->__construct, whose preceding byte in real usage — $obj->__construct() — is an identifier character and would otherwise fail the boundary check that protects a normal needle). Shares the same universe, scanner, and per-file cache as bounded needles — a file scanned for one answers the other for free.
  • The mention index is now the single implementation of the reference- and subtype-gate textual predicate; IdentifierNeedles is deleted. Member/function/constant reference gates (previously raw-scanned on every query — the mention path only covered single known class-name needles) admit their needles at query time, and constructor gates route their ->__construct/::__construct raw tokens through the same universe instead of building a per-query automaton. Cost model on a 12.7k-file corpus: identical results and RSS; universe grows by one entry per distinct queried symbol name (+2 raw tokens per session, ~16 bytes each plus the interned string); a needle new to the universe pays one recording pass over uncovered candidates (66ms, transient allocation churn — the old gate paid its scan on every query instead), after which repeats are lookup-only (2.6ms) and the recordings also answer the subtype-BFS gate.

  • The subtype-BFS defs gate re-scanned every never-committed file’s raw text per BFS round on every query. commit_defs_for_matching’s textual gate (“does this file mention a frontier class name”) now answers from the persistent per-file mention index — the same one indexed_references_to’s gate populates — so a file scanned by either consumer answers the other with a set lookup. A file the cache can’t answer for is scanned once against the whole name universe and recorded. On the Laravel fixture (11.7k registered, symbol-indexed, never-analyzed files), a second cold indexed_subtype_classes on a different hierarchy drops from a full workspace re-scan every round to zero text passes (0.020s → 0.012s); the first-ever scan pays the whole-universe automaton once (0.025s → 0.052s) and pre-pays every later subtype and references gate check. Results are identical.

  • A warm-up pass bumped the workspace revision once per lazily-loaded class, cancelling every in-flight salsa reader each time. A cold indexed_references_to / reanalyze_* pass that faults in N vendor classes performed N salsa input writes, each one restarting any concurrent request’s parallel analysis phase (410 bumps on a Laravel-fixture cold references query with unregistered vendor). Bumps now coalesce per pass — the warm-up loop, a prepare_file_for_analysis call, and each bulk-registration window (set_workspace_files, set_vendor_files, index_batch chunks) flush one bump at scope end (49 on the same query, all from the pre-index seed window). Deferral only engages while the workspace symbol-index singleton exists; without one, class lookups fall back to the revision-keyed tracked walk, which must observe every load immediately. Results and issue output are byte-identical; generation-stamped freshness semantics are unchanged (commits are stamped after the pass’s flush, as before).

  • indexed_subtype_classes re-walked every candidate file on every call, even a byte-for-byte repeat. Same shape as the indexed_references_to fix below: commit_defs_for_matching’s freshness pass costs O(candidates) regardless of outcome, so a host resolving a protected/static method’s reference scope on every code-lens refresh paid that cost every time. Now memoized per (class_fqn, include_trait_users, files, text_revision), capped by total cached sites (not entry count).

  • indexed_references_to re-scanned every candidate on every call, even a byte-for-byte repeat query against unchanged state. The freshness pass has to check each candidate’s commit status regardless of outcome, so a caller re-running the same query (e.g. a host recomputing reference counts on every code-lens refresh) paid that O(candidates) scan on every call. Now memoized per (symbol, files, include_declaration, revision), keyed on salsa’s own current_revision — not a hand-rolled counter, which a host writing text directly via SourceFile::set_text (bypassing ingest_file/set_file_text) would silently bypass. Capped by total cached locations (not entry count, which doesn’t bound memory when one entry’s result size varies from a handful to thousands of locations for a hot symbol).

  • is_builtin_constant exposed: same shape as the existing is_builtin_function, letting a consumer (e.g. php-lsp) narrow textDocument/references’s candidate scope for builtin constants (PHP_EOL, PHP_VERSION, …) the same way it already does for builtin classes and functions.
  • phpstorm-stubs synced with upstream: new symbols merged in across curl, intl, mysqli, openssl, PDO, pgsql, Phar, redis, Reflection, sockets, sodium, SPL, standard, tidy, xsl, and zip, plus the new uri extension (PHP 8.5).
  • assert()/if null-check now narrows array-offset access: assert($arr['k'] !== null) and if ($arr['k'] === null) { return; } never narrowed the offset’s own value the way the equivalent isset()/property-access checks did, so a later $arr['k'] read stayed nullable and misfired PossiblyNullArgument.
  • By-ref closure capture no longer widened away like the undefined-var case: use (&$var) binds the same variable across every invocation, so seeding it with the literal type at the closure-literal site falsely claimed it could never change, firing bogus RedundantCondition on a toggled bool flag. Scalar literals now widen to their base type (true/false to bool) on capture; by-value captures are unaffected.
  • By-ref array-offset write now marks the parameter as used: $arr['k'] = v on a by-ref parameter mutates caller-visible state through the reference, but the write path never marked the base as read, flagging UnusedParam on out-params only ever written via a nested offset assignment.
  • ** (Pow) now types as int|float, not int-preserving: PHP’s ** genuinely overflows int to float at runtime (2 ** 63 is float); it’s now routed through the same overflow-aware path as / instead of the int-preserving path used for +/-/*. Also fixes a latent bug this exposed: arithmetic/division treated an operand as “is float” whenever its union merely contained a float atom, wrongly collapsing an int|float result to bare float.
  • gc_status()’s PHP 8.3 keys are now version-gated: the bool/float keys (running, protected, full, buffer_size, application_time, collector_time, destructor_time, free_time) were only added in PHP 8.3 — targeting an older version now correctly keeps the original 4-key int-only shape.
  • 4 imprecise builtin stub return shapes tightened: gc_status() gets a real per-key array{...} shape instead of a bare int[]; Throwable/Exception::getTrace() gets the real per-frame shape; get_declared_classes() returns list<class-string> instead of string[]; realpath() returns non-empty-string|false instead of string|false.
  • Trait property’s explicit default no longer ignored: trait property collection hardcoded default: None for every non-promoted property, ignoring the AST’s actual default-value expression, so a class composing only defaulted trait properties was flagged MissingConstructor even though PHP never leaves them uninitialized.
  • instanceof/is_subclass_of now narrow callable like TObject/TMixed: neither had an arm for a callable atom, silently dropping it and treating the true branch as unreachable — but callable legitimately includes an invokable object of any user class. Closure is deliberately excluded: it’s a final PHP class with no user-declared ancestors, so checking it against an unrelated class stays genuinely impossible.
  • ReflectionClass::getStartLine() stub keeps |false: its docblock said @return int, contradicting the native int|false hint right below it — an isolated typo (the sibling getFileName() already got this right).
  • trait-string/enum-string recognized as docblock type keywords: the parser had arms for class-string/interface-string but not these two, falling through to the named-class catch-all and flagging UndefinedDocblockClass. Also added to the separate gate that keeps a type keyword from being namespace-qualified as if it were a class name.
  • @throws void no longer stored as a bogus throw class: both the free-function and method collectors namespace-qualified every @throws entry before checking whether it named a pseudo-type (void, never, self, …), so a namespaced file’s bare @throws void became {namespace}\void, which no longer matched the pseudo-type check and was stored as a real throwable class instead of being dropped.
  • Bare-$this assert-if-true now narrows the receiver: the assertion handler’s special case only matched @psalm-assert Type $this->prop; a bare $this assertion (no ->) fell through to a by-name param lookup that can never match, so it was silently never applied.
  • class/interface/callable/enum/trait-string now satisfy non-empty-string: none of these atoms can ever hold the empty string in real PHP, mirroring the existing numeric-string case.
  • idn_to_ascii/idn_to_utf8’s $idna_info is now a pure out-param: the stub declared its 4th by-ref param as plain, non-nullable array, so passing a nullable/uninitialized by-ref variable purely to receive the output flagged PossiblyNullArgument against a type that’s never actually read.
  • @var callable(...): R keeps its return type across a space: the docblock parser gave up at the first top-level whitespace not preceded by a union/intersection continuation, so callable(int): string’s space before the return type truncated it to an empty/mixed type. callable(int):string (no space) already worked and still does.
  • Invokable objects now resolve __invoke()’s real signature: invoking an object value ($obj(...)) always fell back to mixed for the result type instead of consulting __invoke()’s declared return type, and an invokable object never satisfied a callable(...): R / Closure(...): R target for subtyping or return-type checking (only for argument checking).
  • Reflection existence-guard instance methods now cover MissingThrowsDocblock: $refl->hasMethod($n) / $param->isDefaultValueAvailable() prove the twin throwing call (getMethod()/getDefaultValue()) on the same receiver can’t throw — only the free-function method_exists()/property_exists() guards were previously recognized, not Reflection’s own instance API.
  • MissingThrowsDocblock now respects a covering local try/catch: both the inter-procedural call check and the direct-throw check compared only against the enclosing function’s own @throws, never a local try/catch that already catches the exception before it can escape.
  • @phpstan-type resolves without Psalm’s = syntax: real PHPStan’s @phpstan-type Name Expr has no = (unlike @psalm-type Name = Expr), but both tags shared the same split_once('=') parse, so every no-= @phpstan-type alias silently failed and cascaded into mixed everywhere it was referenced.
  • \foo() no longer resolves to a same-named in-namespace function: the leading backslash was stripped before the qualify-and-exists-check ran, so a bare global call couldn’t be told apart from a namespace-relative one, breaking the common namespace Foo; function json_encode() { return \json_encode(...); } deprecated-wrapper idiom.
  • Excluding '' narrows string to non-empty-string: the exclusion branch only stripped an exact-matching literal-string atom; excluding "" specifically now also upgrades a bare string atom, covering $x === ''/$x !== ''/assert($x !== '') guards. Also fixes a dependent gap: string-offset access only recognized string/literal strings, falling back to mixed for every other string subtype once one could actually reach that position.
  • MissingPropertyType now honors a @var/@param docblock type: property/promoted-param checking emitted the issue whenever there was no native type hint, without checking whether a docblock already resolved one.
  • Enum-case literal now satisfies interfaces its enum implements: a prior fix only added the exact bare-enum-FQCN subtype arm; an enum-case literal narrowed via === still flagged InvalidArgument/ InvalidPropertyAssignment against a param/property typed as an interface the enum implements (including implicit UnitEnum/ BackedEnum) or bare object.
  • getenv() no longer merges its arg-count overloads: getenv($name) with a non-null $name was typed array|string|false — the array-of-all-vars branch only applies when $name is omitted/null.
  • PHP_OS_FAMILY no longer widened past its stub literal: the environment-dependent-constant widening covered PHP_OS/PHP_SAPI/ DIRECTORY_SEPARATOR/PHP_INT_SIZE but missed this one, so PHP_OS_FAMILY === 'Windows' flagged as always-false.
  • Docblock-shadowed builtin now wins over the native hint for storage: the docblock-vs-native param/return/property merge preferred the docblock’s leniently-resolved bare builtin name (Generator, Closure, …) whenever there was no scalar-family conflict, so a same-namespace class shadowing a builtin got the wrong bare name persisted, reproducing a false UndefinedMethod.
  • De Morgan narrowing for ANDed negated instanceof chains: the &&/and narrowing arm only handled the is-true case; a guard-clause fall-through (if (!$x instanceof A && !$x instanceof B) return;) applied zero narrowing, so union members never named in the chain still got checked against the full, unnarrowed union.
  • foreach key/value now merges across all union atoms: foreach-type inference returned on the first matching array-like atom in a union instead of merging across all of them, e.g. collapsing a preg_split()===false fallback’s foreach key to a literal 0 instead of widening to plain int.
  • Docblock builtin leniency reconciled against a confirmed local shadow: MismatchingDocblockReturnType/ParamType compared a strictly-resolved native hint against a leniently-resolved docblock type, so a same-namespace class shadowing a builtin (Generator, Iterator, …) produced a false mismatch when both sides bare-named it. A bare builtin-leniency name in the docblock is now rewritten to its namespace-qualified form first, but only when that qualified name names a real, existing local class.
  • Same builtin-shadow bug fixed in a second resolver family: the docblock-leniency fix above had an identical twin affecting several genuinely-native type-hint call sites: array_map/array_filter interprocedural callback-return inference, a closure/arrow-fn’s own return-type hint and param hints, and return-statement checking against the native hint.
  • Same-namespace class shadowing a builtin iterator name now resolves to its own FQCN: builtin leniency for bare Closure/Traversable/ Iterator/IteratorAggregate/Generator names (meant for docblocks only) was also applied to native type-hint resolution, so a same-namespace class reusing one of those names had every property/param/return type hint resolved to the builtin instead of the local FQCN, flagging every real method as UndefinedMethod.
  • Psalm-only suppress kind names now alias to mir’s own IssueKind: a @psalm-suppress naming a Psalm-only check mir models under a different name (PossiblyNullReference, PropertyNotSetInConstructor) neither suppressed the underlying issue nor counted as used, flagging UnusedSuppress on top of the original diagnostic.
  • Type-omitted @param $name docblock line no longer parsed as a type: the parser required whitespace before $name to split type from name; a body starting directly with $name (no type, valid PHPDoc grammar) fell back to validating the whole body as a type and flagged the variable as being in type position.
  • Closure::bind/bindTo scope arg now feeds the closure-body visibility check: the rebind’s new scope was previously discarded entirely, so private-method calls inside a rebound closure body were checked against the closure’s lexically enclosing class instead of the rebound scope. Only literal-class-name scopes are resolved; dynamic scopes are left unhandled.
  • Interface property members no longer silently dropped: interface member collection had no arm for properties, so UnitEnum/ BackedEnum’s native readonly $name/$value stub declarations were dropped entirely — any receiver typed as one of these interfaces lost property access, flagging NoInterfaceProperties and widening the result to mixed.
  • Namespace-relative qualified docblock class names resolve correctly: a docblock class name containing \ was used verbatim instead of having the current namespace prepended, so Warning\Warning inside namespace App; stayed the literal (nonexistent) Warning\Warning instead of resolving to App\Warning\Warning.
  • warm_start_files now returns the files it couldn’t fully trust: a replayed reference-posting commit whose cached issue set has an unresolved name is never immune to workspace-growth invalidation, so the first live query to touch such a file pays a full synchronous analyze_file (measured ~1.3-1.5s on a distinctive static method in a 15K-file workspace). That subset is already known at warm-start time; callers can now hand the returned list to reanalyze_files_cancellable on a background thread (same pattern as prefetch_imports) so the cost lands during idle time after boot instead of on the user’s first request.
  • <ignoreFiles>/<projectFiles> directory matching works when canonicalize() changes the path shape: canonicalize() (used for the composer root and target path) can resolve symlinks, 8.3 short names, or on-disk casing that a plain PathBuf::join from a config-relative entry never goes through, so starts_with/== comparisons silently failed on Windows even after stripping the \\?\ verbatim prefix. Every such comparison now routes through a shared normalize_for_compare (canonicalize-with-fallback + strip-prefix) helper.
  • new $var(...) accepts an object receiver: is_valid_class_name_type rejected any object-typed value, but new $obj(...) is valid PHP — it constructs a fresh instance of $obj’s own runtime class, a common “rethrow with a richer exception” idiom.
  • <projectFiles>/<ignoreFiles> directory wildcards now glob-expand: <directory name="src/*/Tests"/> was joined to config_base literally and matched via starts_with, so a * segment never matched anything — an ignored/wildcarded directory was silently analyzed anyway. * now expands the same way Psalm’s own glob()-based resolution does: matches within one path segment, never across /.
  • -c with a bare relative config path resolves correctly: Path::parent() on a bare relative filename (-c mir.xml) returns Some(""), not None, so the cwd fallback never triggered and every relative <ignoreFiles>/<projectFiles> directory resolved against an empty base instead.
  • <ignoreFiles>/<projectFiles> directory matching works on Windows: find_composer_root_for_path canonicalizes its input, which on Windows returns a \\?\-prefixed verbatim path; config directory entries are never canonicalized, so the two path forms compared unequal component-for-component and every ignore/project directory check silently failed. Added a shared strip_verbatim_prefix helper and applied it at every such comparison.
  • <projectFiles> directories are honored in the composer flow: config.project_dirs was parsed but never consulted — a whole-project run always analyzed every Psr4Map::project_files() entry regardless of <projectFiles>. Discovery is now intersected with the configured directories.
  • Transitive requires from files-autoload entries are followed: a files-autoload bootstrap that only dispatches to a version-gated sibling implementation (the common polyfill idiom) never had that sibling indexed, leaving its functions/constants invisible in the default lazy-vendor mode.
  • Vendored the missing mongodb, ds/imagick/relay/zookeeper, and amqp/memcache/imap/ldap/snmp/ssh2/xdebug PECL stubs: PhpStormStubsMap.php already listed every entry for these extensions, but their stubs/ directories were never vendored, so build.rs’s stub-dir set skipped them and every symbol was reported undefined.
  • Refined string atoms are subtypes of scalar: atomic_subtype had a (refined-int-family | TLiteralString, TScalar) arm but nothing for TNonEmptyString/TNumericString/TClassString/ TInterfaceString/TCallableString/TEnumString/TTraitString — every one of these is still just a string, hence a scalar, at runtime.
  • T[] docblock shorthand keys on array-key, not int: parse_type_string’s Type[] shorthand hardcoded an int key, but Psalm/PHPStan document this as array<array-key, Type> — a string-keyed array (array_column() output, a PSR-3 $context array, class_implements()/class_parents()’s own class-string-keyed result) into a mixed[]/string[]-docblocked param falsely flagged.
  • Trait-declared private/protected properties are accessible in the consuming class: property_inaccessible compared self_fqcn against the trait’s own FQCN, with no composition awareness — a private/protected property declared on a trait was flagged inaccessible from every class that uses it, even though PHP copy-pastes trait members into the consuming class.
  • Platform-dependent constants widen to their base type: PHP_OS, PHP_SAPI, PHP_INT_SIZE, and DIRECTORY_SEPARATOR carry the bundled stub’s single define() literal, so every cross-platform/SAPI guard comparing against a different literal was flagged ImpossibleIdenticalComparison.
  • elseif conditions chain instead of re-deriving from the primary if: each elseif’s pre-condition context re-branched from the outer if’s own context and re-narrowed only the primary condition, discarding every earlier elseif’s condition outright — both its assignments and its type narrowing.
  • A body that always throws infers never, not void: merge_return_types returned void unconditionally whenever a function/method/closure had no return statement, regardless of whether the body could actually fall off the end.
  • property_exists()/isset() prove a dynamic property exists: property_exists($obj, 'x') and isset($obj->x) recorded no fact at all, so a later $obj->x read inside the guarded branch still flagged UndefinedProperty even though the guard just proved it.
  • A global \Foo intersection member resolves correctly in a namespaced file: receiver-resolution re-ran raw-source-text rules on an already-canonical fqcn, so a fully-qualified global class used as an intersection member got the current file’s namespace wrongly re-prepended.
  • Protected property/constant access allows the owner-extends-caller direction: PHP’s protected-visibility check is symmetric over the class hierarchy, but the checks only tested caller-extends-owner, denying an ancestor class access to a protected member declared only on a descendant.
  • Implementing a trait’s abstract method may narrow its visibility: unlike an interface method or an abstract class’s abstract method, narrowing the visibility when implementing a directly-used trait’s abstract method raises no error in PHP.
  • A transitively-composed trait’s method isn’t a real override parent: own_traits only listed a class’s directly-used traits, so a trait composed only via another trait still looked like a real ancestor, flagging FinalMethodOverridden comparing a flattened final method against its own copy.
  • An inaccessible property read routes through a declared __get: PHP invokes __get() instead of erroring when a private/protected property is read from outside its accessible scope; property resolution emitted InaccessibleProperty unconditionally, with no check for the magic-get fallback.
  • Offset write/unset on a readonly ArrayAccess-object property is legal: $this->prop[$k] = $v and unset($this->prop[$k]) dispatch to offsetSet/offsetUnset on the object the readonly property already holds, not a reassignment of the property binding — the readonly-write check now only exempts array-index writes through an ArrayAccess-typed property.
  • A property @var docblock type preserves native nullability: a @var refining a nullable native property hint but omitting |null erased that nullability, the property analogue of the already-fixed param-side gap.
  • is_callable() narrowing makes a bare object satisfy callable: narrowing only filtered atoms, never transformed one into something a callable-typed target accepts — a bare object value stayed typed as object after the guard.
  • parse_str()’s out-param no longer checks the incoming type: parse_str’s real signature places no type constraint on the incoming $result value — reusing the same variable (parse_str($s, $s)) is legal PHP. Switched to @param-out.
  • func_get_args()’s synthetic param no longer skews override checks: the synthetic ... param injected for a func_get_args()-using method exists only to let call sites pass extra positional args without a false TooManyArguments — counting it in override param-count/by-ref comparisons flagged a child with an identical real signature as having fewer parameters than the parent.
  • A @psalm-type alias named after self/static/parent resolves as the alias: parse_type_string resolves these bare words to keyword sentinels before any alias table exists to consult, so an alias named after one of them (@psalm-type Parent = Foo) never reached the alias-expansion arm and stayed the keyword.
  • PHP parser suite (php-rs-parser, php-ast, php-lexer, phpdoc-parser) upgraded to 0.19.0: php-ast now represents PHP 8.6’s partial-application placeholder (?/... in a call argument) as Arg::value: Option<Expr> instead of assuming a value is always present. Any placeholder argument currently raises a hard ParseError (mir’s parser always targets 8.5 internally, decoupled from --php-version), which suppresses body-level analysis for the whole file rather than crashing or misparsing.
  • Promoted constructor properties honor docblock refinements like ordinary params: a promoted property only let a @param docblock override the native hint when the hint was exactly plain array/mixed; any other native scalar hint (e.g. string) discarded a literal-union docblock refinement outright. Reuses the same priority scheme as an ordinary @param (docblock wins by default, guarded against a scalar-family conflict and a dropped native nullability).
  • Array-index write auto-vivification on a null base: $data['key'] = $v (and push notation $data[] = $v) on a nullable array auto-vivifies to a fresh array at runtime, but the widening helpers kept null in the result unchanged, leaving a written-to nullable array nullable forever after.
  • @param keeps a nullable native hint’s nullability: the only existing conflict guard against a docblock type overriding the native hint covered scalar-family mismatches, never nullability, and never non-scalar hints (object/array) at all — a ?object/?array native hint paired with a non-nullable @param silently lost its nullability, producing false ImpossibleIdenticalComparison and NullArgument diagnostics.
  • Backed enum ->value recognized in an exhaustive match: Kind::Foo->value typed as the bare backing scalar instead of its own case literal, and match-exhaustiveness checking had no case at all for a ->value-shaped subject.
  • Class-constant array literals keep their inferred element/key shape: a class-constant array literal had no inference arm at all and widened to a bare array; a PHP 8.3 typed const array hint needed a second, related fix so the hint didn’t discard the inferred literal shape.
  • @psalm-assert Type $this->property now applies at call sites: the assertion resolver only ever matched against a declared parameter name, so a $this->property target (written from the asserting method’s own perspective) could never match on any method, regardless of its arg count.
  • elseif no longer re-narrows its own already-narrowed condition: the elseif branch analyzed its condition against a context already narrowed by that same condition, so an earlier && operand’s narrowing turned a real null-check further right into a false tautology.
  • Method-override return-type covariance no longer over-compares against a concretely-bound template: a child that doesn’t restate a generic ancestor’s docblock refinement (a common, tool-accepted idiom) is now compared against the template’s own declared bound instead.
  • array_multisort’s lenient by-ref semantics modeled: sort-key arrays need not be lvalues, and its trailing flag/order arguments aren’t actually passed by reference despite sharing the stub’s variadic by-ref slot.
  • Abstract/interface method arity no longer flags valid override overloads: a concrete override may add extra optional params beyond an abstract/interface declaration’s own signature; only TooManyArguments was affected — TooFewArguments still enforces the shared required-param floor.
  • Union-typed test doubles: arity check no longer independently flags a __call-satisfied sibling atom, reusing the existing arity-suppression mechanism.
  • array_map/array_filter callbacks may declare fewer params than are passed: PHP always allows a callback to ignore extra invocation arguments.
  • Typed class/interface/enum constants keep literal narrowing: a native type hint on a PHP 8.3 typed constant previously discarded literal inference (positive-int, non-empty-string, etc.) entirely.
  • Arrow-function bodies are checked against their declared return type: regular closures already got this check; arrow functions never did.
  • Private/protected property access from an invalid scope is now reported (InaccessibleProperty, MIR0014) — method and class-constant access already enforced this; property access had no equivalent check.
  • Typed catch-all remainder recognized as an open array shape: array{key?: T, ...array<K, V>}’s typed remainder fell through to the auto-indexed-key branch instead of marking the shape open.
  • Docblock callable arity check no longer inverts the optional-trailing-param marker: the = suffix marks a param the implementing closure may omit, not a promise that call sites always omit it.
  • Private ancestor properties exempted from redeclaration checks: a private property isn’t inherited in PHP, so a same-named subclass property is an independent, unrelated declaration.
  • MissingConstructor no longer trusts an ancestor constructor that predates a subclass’s own property: only suppressed when the resolved constructor’s declaring class is at or above the property-declaring class in the ancestor chain.
  • T|callable():T template double-binding: a bare template alternative no longer also absorbs a callable():T/closure argument already bound through its return type.
  • literal-int/literal-string recognized as docblock keywords: previously fell through to a bogus named-class bound, always failing a genuinely-satisfying literal argument.
  • indexed_references_to skips re-analyzing files whose text can’t mention the queried symbol, even after a generation bump marked a previously-committed file stale — the same needle/mention gate never-committed files already got. ~370ms → ~3ms on a 1410-file benchmark where every file carries one unresolved reference.
  • Warm start seeds the workspace symbol index singleton: warm_start_files now projects per-file declarations from the disk definition slices it already reads for subtype-edge replay (a shared decls_from_slice projection, byte-identical to the tracked query’s) and seeds the WorkspaceSymbolIndexSingleton — so a returning session’s first query answers symbol lookups from the O(1) map instead of running the tracked O(all-files) workspace_symbol_index walk, one collect_file_definitions slice deserialization per file re-validated after every prepare-loop revision bump (~4s per process at 15K files). Files without a content-valid slice are collected in parallel off-lock; seeding is skipped when the coverage gap exceeds max(1024, 25%) so a first-ever boot keeps the lazy path. All bundled stubs are registered up front (the index_batch contract) so later lazy stub loads cannot leave the seeded singleton incomplete.
  • Pending-set reconciliation for mirror-only writes: plain upsert_source_file_with_durability calls (an LSP host mirroring watcher-driven external edits or new files) now enqueue the file while a singleton exists; AnalysisSession::settle_workspace_index — invoked at the head of indexed_references_to, indexed_subtype_classes, indexed_use_import_locations, subtype_files, class_issues, reanalyze_files_cancellable, and FileAnalyzer::analyze — pre-warms declaration memos on a snapshot and merges them under a short write lock, so the seeded singleton is never consulted stale.
  • Index-walk diagnostics: AnalysisSession::workspace_symbol_index_ready and workspace_index_walks (executions of the tracked fallback walk) let hosts assert warm-started sessions never pay the O(all-files) rebuild.
  • self/static/parent inside class-string<...>/interface-string<...> generic arguments now substitute to the enclosing class type.
  • define() calls inside function and method bodies are collected as global constant definitions.
  • psr-0 classes resolve by FQCN: psr-0 autoload entries were only used for bulk file-list scanning, never added to the FQCN-keyed resolution map lazy class loading relies on — a class reachable solely via psr-0 (a legacy/isolated-vendor pattern) always false-flagged as UndefinedClass. psr-0 now gets its own prefix-keyed entry list, consulted after psr-4 and before classmap.
  • Property refinement narrows through a nullable RHS: comparing the whole RHS (T|null) against a wider declared property type failed the subtype check and discarded the refinement entirely, causing a false UndefinedMethod on the next read. Now compares only the non-null part while still storing the full refinement, on both the instance- and static-property assignment paths.
  • match($x::class) narrows its subject per arm: a ClassConstAccess subject was never recognized by the match-arm narrowing intersection at all. Adds a dedicated dispatch, plus union-narrowing helpers for comma-separated (OR-semantics) arm conditions.
  • Union-sibling __call satisfies UndefinedMethod: a Real|TestDouble union (a mocking-library idiom, e.g. Prophecy) flagged UndefinedMethod when the real class lacked a method only the test-double sibling declares. A sibling atom having a catch-all __call now suppresses the check on atoms that lack both the method and their own __call.
  • Atomic cache.bin write: flush() wrote the cache directly in place via std::fs::write, so a crash mid-write left a truncated cache silently discarded on the next boot. Switched to tempfile-in-same-dir + rename, matching stub_cache.rs’s pattern.
  • Vendor the missing ast/ast.php stub: PhpStormStubsMap.php already listed all 201 ast\* (nikic/php-ast) entries pointing at it, but the stubs/ast/ directory was never vendored, so the whole extension (Node, Metadata, AST_*/flags\* constants, parse_code, etc.) was reported undefined.
  • Follow require/include targets outside every autoload root: the whole-project file list came from composer.json’s autoload sections only, so a file reached solely via a manual require_once/include outside every psr-4/psr-0 root (a common isolated-legacy-bootstrap pattern) was never indexed. Now follows statically-resolvable include targets (literal strings, and __DIR__/dirname(__FILE__) concatenated with a literal) recursively from every discovered project file, skipping vendor/. On Windows, __DIR__ concatenated with a ..-containing literal produced a verbatim (\\?\-prefixed) path the OS won’t resolve .. in, so the target was never found; ../. are now collapsed lexically before the filesystem check.
  • Property-target attributes on promoted params: a promoted constructor parameter is reflectable as both a ReflectionParameter and a ReflectionProperty, so an attribute restricted to TARGET_PROPERTY alone should be accepted on it. mir only checked TARGET_PARAMETER, false-flagging every such attribute.
  • NoInterfaceProperties fires regardless of @seal-properties: real PHP/Psalm semantics treat any property access through a plain interface type as suspect unless declared via @property, whether the interface opts into @seal-properties or not. Sealing only ever narrowed which unknown accesses got rejected, not what legitimized a known one.
  • Suppress kind-list parsing stops at trailing prose: a comma inside a suppress directive’s free-text explanation (e.g. “@psalm-suppress Foo because of X, not fully typed”) was mistaken for the start of a new kind name, producing a phantom UnusedSuppress. Stop scanning for further kinds once a segment has trailing words after its kind name.
  • Private/protected property access from an invalid scope (new InaccessibleProperty, MIR0014): equivalent method and class-constant access was already rejected, but reading a private or protected property from outside the declaring class (or its subclasses, for protected) went unreported, hiding a runtime-fatal error. Checked for both instance ($obj->prop/$obj?->prop) and static (Class::$prop/self::$prop) property reads.
  • Parallel warm_start_files disk-slice reads: the per-file loop read the AnalysisCache and StubSliceCache serially (~0.8-0.9s of a 3.9s warm boot at 15.4K files). Split into a rayon-parallel read phase (mirroring index_batch’s existing pattern) that only touches disk-cache lookups off any lock, and a sequential apply phase for the cheap salsa input writes and map merges.
  • Member-name-only gate for static-only member references: indexed_references_to’s candidate gate now recognizes when a queried method resolves to a static-only declaration and, in that case, gates never-committed files on the member name alone — dropping the owner short name the general gate ORs in, which on common owner names (Color, Asset, …) admits most of a large workspace for analysis. Sound because every posting-producing static reference spells the member token — Owner::m(), inherited Sub::m(), self::/static::/parent::m(), aliased Alias::m(), instance-receiver $obj::m(), callable strings 'Owner::m' — while dynamic member names (Owner::$m()) produce no posting at all. (An earlier unreleased design gated on member AND owner-or-subtype name instead; it was scrapped for dropping $obj::m() sites whose file never names the class.) Falls back to the general OR-gate whenever the method can’t be resolved to a definite static declaration.

  • Free-function parameter mutation now checked against @mutation-free/@external-mutation-free: these tags previously did nothing on a free function — FunctionDef had no fields for either, so a documented mutation-free function whose body wrote to a passed-in object’s property went unflagged. A free function has no $this, so both tags behave identically here and now reuse the existing method-shaped enforcement.

  • getallheaders()/apache_request_headers() recognized as taint sources: both return raw, attacker-controlled HTTP request headers but neither was modeled as tainted, so header-derived values reaching a sink produced no diagnostic. getenv() is deliberately left untainted — env vars are typically server-controlled configuration, not attacker input.

  • An assertion tag now resolves a nested array-key path target: Assertion.param_key held a single array key, so a second bracket ($arr['a']['b']) made the whole assertion a silent no-op. Widened to a path of keys; the existing shape-path plumbing already supported it.

  • First index build no longer re-collects definitions ~3× per file: collect_file_definitionslru = 4096 cap sat below real workspace sizes, so the whole-workspace walks of the first subtype/defs build (and the ancestor resolution behind it) re-executed the evicted majority — re-parsing included, since parse_file’s own LRU is far smaller — within a single cold query. Raised to 65536, covering the LSP consumer’s 50K-file scan ceiling while still bounding transiently-loaded vendor slices.

  • Stub-slice cache writes moved off the caller’s critical path: StubSliceCache::put serialized and wrote each entry synchronously, so the first whole-workspace definition collection paid ~15K disk writes inside whatever query triggered it (measured +3.7s on a 15K-file workspace). Writes now queue to a background writer thread; flush()/Drop join it, so a clean shutdown still loses nothing. Same-session reads are served by Salsa memos, so the delay is only observable across sessions.

  • Cold reference queries no longer degrade into O(prepared-files × workspace): collect_file_declarations shared collect_file_definitionslru = 4096 cap, but workspace_symbol_index walks every source file through it on each rebuild — and rebuilds after every workspace_revision bump, i.e. after each file a reference query’s prepare loop ingests. On a 15K-file workspace each walk re-executed the ~11K evicted memos (re-parsing included), turning one cold query into ~90 full re-walks — measured 28s wall, 4.2s after the fix. The declarations result is a few name/loc pairs per file, so the memo table is now uncapped; the heavyweight collect_file_definitions keeps its LRU.

  • Constructor gate admits explicit re-init call sites: the __construct reference gate (owner short name only, since new Cls( sites never spell the member name) dropped $obj->__construct() re-init calls living in files that never name the class — e.g. a receiver typed by a parent-declared property. The gate now also admits files containing the raw call tokens ->__construct / ::__construct (plain substring, so files merely declaring a constructor are still excluded).

  • AbstractMethodCall no longer flags an object receiver’s late-static-binding call: $this::method()/$var::method() always dispatch to the runtime class, which is guaranteed concrete since an abstract class can never be instantiated. Only self::, parent::, an explicit class name, and a class-string receiver (which can hold the literal abstract name itself) still lack that guarantee and remain flagged.

  • A directly-used trait is no longer treated as a real override parent: a class using a trait, or replacing a same-named concrete trait method with its own, isn’t overriding anything — PHP performs no final/static/visibility/signature compatibility check either way. Only a genuine subclass override, or a trait’s abstract-method contract, is still enforced.

  • __call dispatch honors its own declared return type: a magic-method call always collapsed to mixed, discarding __call’s own @return docblock — e.g. a fluent test-double stub typed @return static. Falls back to mixed only when __call itself has no declared return type.

  • A function-level suppression now covers its whole body: @psalm-suppress/@mir-ignore/@suppress written above a function or method only covered the signature line, never the body, unlike real Psalm/PHPStan semantics.

  • A variable class-string static call is now recognized as a taint source: is_expr_tainted’s StaticMethodCall arm only resolved a literal class name (or self/static/parent) — $class::getQuery() through a variable holding a known class-string fell through unhandled, unlike the instance-method arm beside it.

  • By-ref/@param-out write-back now resolves a named-arg-reordered target: the write-back loops in method.rs/function.rs/ static_call.rs indexed call.args by the parameter’s declared position, so a by-ref parameter passed out of order by name checked the wrong argument or missed the real target entirely.

  • A literal spread call now expands before a docblock assertion is applied: f(...[$x, Foo::class])’s single spread argument resolved as one positional argument for every parameter index instead of the individual elements, corrupting both the narrowing target and template-binding inference from a sibling argument.

  • A spread variadic call no longer corrupts the spread array’s own type: a variadic assertion’s argument filter excluded named arguments but not a spread argument (f(...$list)) — since a spread call’s single Arg is the whole array, not a scalar element, narrowing it overwrote the array variable’s type with the assertion’s per-element type.

  • Class names inside a Closure()/callable() signature now resolve: resolve_atomic_inner had no arm for TCallable/TClosure, so a class name nested in one of these signatures skipped use-import/ namespace resolution entirely — a correctly-typed argument false-positived against the signature’s own unresolved bare class name.

  • A named suppression on a multi-line signature is no longer flagged as unused: named_suppressions only recorded a directive’s first target line, so a per-parameter issue on a later continuation line of the same signature — where the directive genuinely applies — still reported UnusedSuppress.

  • Taint now propagates through the error-suppression operator: is_expr_tainted had no arm for @expr, so @$_GET['x'] silently bypassed every taint-sink check — @ only silences a notice, it doesn’t sanitize the value.

  • A plain method-call argument is now checked against @psalm-immutable/mutation-free: new X(...), free-function calls, and static calls already checked a by-value argument reachable from $this/a parameter against a not-provably-safe callee — an ordinary instance method call ($logger->record($this->box)) only checked its own receiver, never its arguments.

  • A @mutation-free/@external-mutation-free override must now re-declare the tag: the same unsoundness the existing @pure-override check already closed — since a call resolves purity against the receiver’s statically-declared type, silently dropping the tag on an override made enforcement unsound.

  • byref_param_names now propagates into a closure’s use(&$x) capture: a write to a by-ref-captured by-ref parameter inside a closure body was invisible to check_var_write_purity/ assign_to_target, both keyed off that set, even though it mutates the same caller-visible reference a direct write in the enclosing scope would.

  • A static $var write is now flagged under mutation-free contracts: unlike global $x, a static variable’s write had no write-time tracking at all — only the one-time @pure declaration check saw it. Both a compound-op/++/--/unset() write and a plain overwrite are now checked against @mutation-free/@external-mutation-free.

  • A static call’s arguments are now checked against @psalm-immutable/mutation-free: a genuinely static call (Foo::bar($this)) or a variable-class-string call ($cls::method($this)) passing an object argument reachable from $this/a parameter went unflagged, unlike new X(...) and free-function calls which already had the identical check.

  • Free-function calls are now checked against @psalm-immutable/@psalm-external-mutation-free: only @pure gated a free-function call at all — passing $this or a parameter into a not-provably-safe callee went unchecked, unlike new X(...) and method calls.

  • A global-declared variable’s write is now flagged under mutation-free contracts: a whole-variable overwrite, or a property write through a global-held object, was invisible to @mutation-free/@external-mutation-free — only @pure caught it, since these two tags deliberately permit reads.

  • A negated true literal or intersection assertion target now narrows: negate_assertion_type had arms for TNull/TFalse/ named-object atoms but none for TTrue or TIntersection!true $v and !(A&B) $v both fell to the catch-all and left the type unchanged.

  • Assertion narrowing now reaches beyond a 1-hop property chain: apply_one_assertion’s property arm and method_call_receiver_fqcn both only matched a bare 1-hop receiver, silently no-oping the whole assertion for a 2+-hop chain like $c->box->inner or $this->service->validator->isInt().

  • Taint now propagates through json_decode(): the decoded result of a tainted JSON payload — a common shape for JSON-body web APIs — stayed untainted regardless of the subject argument.

  • Taint now propagates through array_map/array_filter/ array_reduce: none of the three checked the source array argument’s taint, so an attacker-controlled array run through any of them stayed silently untainted regardless of what the callback did.

  • Taint now propagates through non-sanitizing string builtins: str_replace, trim, explode/implode, preg_replace, and similar transforms returned untainted regardless of their arguments, even though none of them strip attacker-controlled content. Genuine sanitizers/encoders (htmlspecialchars, urlencode, …) are deliberately left excluded.

  • A #[Attribute]’s own # is no longer treated as a comment introducer: find_comment_introducer stopped at the leading # of #[Attr] the same as a plain # line comment, so a trailing directive on the attribute’s own line (#[BadAttrClass] // @mir-ignore UndefinedAttributeClass) was swallowed into one giant same-line “comment” and silently defaulted to NextLine scope, missing the attribute’s own diagnostic (plus reporting a spurious UnusedSuppress).

  • Chained-receiver resolution now handles a static hop: resolve_chained_receiver_type had arms for a property/array-index/ instance-method-call hop mid-chain but none for a static-property or static-method-call hop — self::$param->get() and Factory::repo() ->get() both fell through to None, so a @taint-source method reached through either static hop stayed silently untainted.

  • @if-this-is now checks against the full intersection receiver: resolve_method_return’s check rebuilt the receiver from only the declaring intersection part’s own atom, discarding sibling parts — a constraint naming a part only the call-site intersection provides could both false-positive a satisfying receiver and miss a genuinely non-satisfying one.

  • A recursive type alias’s cyclic residue now resolves to mixed: expand_type_aliases_fixpoint runs exactly aliases.len() passes — enough for a finite chain but never enough to fully expand a genuine self- or mutually-referential alias. Any alias-name atom still present after that many passes is, by construction, cyclic residue, and previously stayed a dangling reference to a nonexistent class.

  • @if-this-is template substitution now also applies to static calls: the same gap fixed for instance-call syntax, for a method reached through self::/static:: or an object-typed variable’s static call syntax.

  • @if-this-is now substitutes a method’s own template before checking: the check previously ran before the call’s own inferred template bindings existed, so a constraint referencing the method’s own @template always compared against a bare, unsubstituted atom and could never actually contradict. Moved the check to run after bindings are inferred.

  • new X(...) is now checked against @psalm-immutable/ @external-mutation-free: only a plain @pure function’s new calls were checked against the constructor’s own purity — passing $this/a parameter into a constructor that isn’t proven pure/mutation-free lets it store and later mutate that object, the same risk an impure method call already caught. A plain value read off $this (the standard immutable “wither” idiom) stays exempt.

  • @psalm-self-out now supports a chained 2-hop receiver: $this->a->b->method() previously silently no-oped a self-out write-back, since extract_any_prop_access only matched a bare-variable object. Adds a synthetic “base->mid_prop” key to the existing flat prop_refined map, and extends invalidation to strip stale chain-prefixed entries too.

  • A variable class-string receiver now resolves for static property writes: $cls::$prop = x (a class-string<Foo>-typed variable receiver) silently bypassed purity/readonly/taint tracking across every caller of resolve_static_prop_target, since it only matched a literal class-name identifier. Also deduplicates 6 sibling inline copies of this resolution logic onto the one shared, now-fixed helper.

  • A dynamic property write now falls back to source text for its purity check: $this->$prop = x / $other->$prop = x silently bypassed the pure/external-mutation-free/immutable write checks entirely, since the property-name resolver returns None for a variable name and both call sites guarded the check behind an exact name match. Falls back to the property expression’s own source text as a display name; the readonly-write check has the same gap but needs a different fix, left for a follow-up.

  • Chained-receiver resolution now handles an intermediate method-call hop: resolve_chained_receiver_type had arms for a property, nullsafe property, and array-index hop, but none for an intermediate method-call hop — $http->params()->get('id') died at the intermediate call, so a @taint-source method reached this way was never recognized.

  • extract() of a tainted array is now treated as a taint source: extract() defines variables whose names are only known at runtime, from the keys of its source array — a tainted source array never made any of those variables taint-tracked. A new has_dynamic_tainted_var_def scope flag marks any otherwise-untracked variable as possibly tainted once a tainted-source extract() is seen on the path.

  • Taint now propagates through compact(): compact('id') copies $id’s current value into the returned array, but the taint check never consulted the named variable’s taint state, so echoing the result gave no diagnostic even when $id was tainted.

  • sprintf()/vsprintf() are now treated as taint pass-throughs: both interpolate every argument straight into the returned string, but neither was modeled as tainting its result — echoing sprintf’s output with a tainted argument produced no diagnostic at all.

  • A negated multi-atom assertion target now subtracts each atom: @psalm-assert !A|B $x was a total no-op — negate_assertion_type bailed out entirely whenever the asserted type had more than one atom, instead of subtracting each recognized atom in turn. Also flips a pre-existing fixture to its now-correct diagnostic: !empty is itself a multi-atom falsy union, so excluding it from bool now correctly narrows to true.

  • An enum’s own type alias now reaches its methods and constants: enum methods were always collected with aliases: None (unlike class/ interface/trait), and the enum’s own alias table was built after the member loop had already processed everything — so a same-file @psalm-type alias referenced from a method’s @param or a constant’s @var silently resolved to a nonexistent class instead of its expansion.

  • A class-like’s own type alias now expands in a property/const @var: a class/trait property’s @var and an interface constant’s @var never expanded the declaring class-like’s own type alias, unlike @param/@return and an inline local @var — the member resolved to the literal, nonexistent alias name instead of its expansion.

  • Suppression coverage now extends across a multi-line signature: a directive above a declaration whose signature spans several physical lines (one parameter per line) only ever recorded the first line as covered — a per-parameter diagnostic on a later line escaped suppression entirely. Tracks paren depth across continuation lines the same way an in-progress multi-line attribute already does.

  • Each array-destructure target now gets its own diagnostic span: [$this->x, $this->y] = $vals writes to multiple guarded properties in one statement, but every element reused the outer statement’s span — a second violation collided with the first’s dedup key and was silently discarded.

  • A fresh clone is now exempt from the immutable cross-class write check: a write through a variable directly holding clone $this — the standard immutable “wither” idiom: clone, mutate the clone, return it — was flagged as an external mutation the same as a write through a real parameter, even though the clone is a fresh, unaliased object nothing else can observe yet.

  • Taint now propagates through an immediately-invoked, argument-less arrow function: (fn() => $_GET['x'])() wasn’t recognized as tainted at all. Narrower than the general call-result taint pass-through this module deliberately doesn’t model: only an arrow function’s single-expression body with no parameters is checked directly against the same context; a closure or an arrow function taking arguments is still not covered.

  • $$name now resolves its real type and taint when $name is a literal string: analyze_variable_variable always returned bare mixed, and is_expr_tainted had no arm for it at all. When the name is a known literal string (or union of them), the referenced variable’s actual type and taint state are now resolved instead of treating the access as fully opaque.

  • A bare-statement @psalm-assert now routes through the shared assertion applier: function.rs, method.rs, and static_call.rs each hand-duplicated their own var/prop/static-prop application loop for an unconditional assert call — none of which read assertion.param_key (silently corrupting the whole parameter’s type for an array-key- targeted assertion), handled a variadic parameter, or resolved a named argument. Extracted apply_one_assertion from the conditional if-true/if-false dispatch and reused it at all three call sites.

  • Taint now propagates through a by-ref output parameter’s write-back: every by-ref output write-back site (free functions, closures, methods, static methods) called ctx.set_var for the new type but never touched its taint bit, so a value like preg_match’s $matches stayed untainted even when derived from a tainted subject.

  • The backtick shell-exec operator is now checked for tainted input: the ShellExec arm discarded its interpolated parts entirely — never analyzed, never taint-checked — unlike its functional twin shell_exec()/exec(), which already ran through is_expr_tainted.

  • A method call on an untyped mixed parameter is now flagged under @external-mutation-free: the unresolvable-receiver blanket check only gated on is_in_pure_fn, unlike the resolved-callee checks just below it which also cover is_in_external_mutation_free_method for the same parameter-receiver shape.

  • Chained taint-source resolution now handles an array-index hop: resolve_chained_receiver_type had no ArrayAccess arm, unlike its sibling root_receiver_var, so a chain like $this->repos['main']->getParam() broke off with None before the @taint-source check ever ran.

  • Same-file type aliases now expand in an interface/trait template bound: interface.rs/trait.rs resolved a @template T of Alias bound directly, and built their type-aliases map only after already using it, so the alias name was namespace-qualified as an unresolvable class instead of expanding to its real type.

  • An assert-if-true/-if-false receiver now resolves through a nullsafe property: method_call_receiver_fqcn only tried extract_prop_access (plain -> only) for a property-access receiver, silently no-oping the whole assertion when reached through a nullsafe property chain instead.

  • By-ref-parameter purity checking now extends beyond plain assignment: ImpureByRefAssignment only fired for a plain =/compound-arithmetic write to a by-ref parameter. A shared check_var_write_purity now also covers .=, ++/--, an array-index write, unset(), foreach(&$v), and passing the variable further by reference to a builtin like sort() — each of these mutation shapes previously bypassed the check via its own code path.

  • Class-mention index: the reference-query gate’s textual predicate (“does this never-committed file mention the needle?”) is now memoized per file. Single-needle queries — classes and __construct — answer from recorded mention sets instead of rescanning every candidate’s raw text; sets are recorded by the gate’s own fallback scan and by analysis sweeps, so coverage is self-populating. Entries are keyed to their source text by Arc identity (edits self-invalidate) and per-name universe epochs make classes declared after a scan fall back to the raw scan — the index can narrow work, never hide a reference. Repeat queries stop pulling the whole workspace’s text through the CPU per request (~5 MB of index instead of ~100 MB of text at Laravel scale). AnalysisSession::class_mention_stats() exposes coverage/size counters for host metrics and memory bounds.
  • indexed_references_to: never-committed candidate files are now gated on their raw text mentioning the symbol’s name (whole-identifier, ASCII-case-insensitive; member symbols also admit the owner class’s short name for new Cls( constructor sites) before any analysis runs — the same discipline the defs index already used. Hosts can pass the whole workspace as the candidate scope and drop their own text prefilters; a cold query on a common name no longer analyzes files that cannot reference the symbol. The freshness pass now also runs in parallel.
  • mentions_identifier: the completeness-pass text gate (also used by indexed_subtype_classes) is now ASCII-case-insensitive, matching PHP’s case-insensitive class/function/method name semantics — extends bar is no longer invisible to a cold subtype scan for Bar.
  • BatchFileAnalyzer / ParsedFile: removed. The API had no consumers — interactive hosts get memoized, cancellable bulk analysis from the session sweeps (reanalyze_files_cancellable, indexed_references_to), and the CLI has its own batch pipeline with lazy-load rounds and disk caching. analyze_batch offered neither (unmemoized, uncancellable, no per-file PSR-4 priority preload), so every caller was better served elsewhere.
  • Static-call reference fallback: a static/parent::/self:: call whose receiver class can’t be resolved (e.g. an external/vendor symbol) no longer falls back to a class-agnostic methname: posting — it now scopes to the concrete (if unresolved) receiver FQN. Previously this collided with any unrelated class’s same-named method: parent::__construct() through an unresolved base class could wrongly show up as a reference to a completely different class’s constructor.
  • indexed_references_to gate needles: constructor (__construct) queries no longer include the bare method name as a gate needle, only the owner class’s short name. __construct appears in nearly every real-world file, so OR-ing it in admitted almost the entire workspace as “must re-analyze” on a cold query, defeating the gate’s purpose for constructors specifically. Every real constructor call site already names the class textually, so this loses no true positives.
  • indexed_references_to Phase 1 cancellation: the serial warm-up loop now catches salsa::Cancelled and retries just the interrupted file, instead of letting the panic unwind and force the whole query — including the freshness pass and every already-warmed file — to restart from scratch.
  • Narrowing: is_countable()/is_iterable()’s false branch now excludes a final non-implementing class atom (when its own hierarchy doesn’t already implement Countable/Traversable), mirroring the existing final-class exact-exclusion soundness gate.
  • filter_var(): infers the real result type from a literal FILTER_VALIDATE_* filter constant (int/float/bool/regexp/url/email/ip/mac/domain) instead of the stub’s blanket mixed; falls back to the stub whenever a 3rd (options) argument is present.
  • Narrowing: class_implements()/class_parents() combined with array_key_exists() now narrow like instanceof, for both plain-variable and property receivers.
  • Narrowing: get_parent_class() === 'ClassName' (either comparison order/strictness) now narrows to a strict subclass instance, for both plain-variable and property receivers.
  • Narrowing: array_key_exists() on a nested path (array_key_exists('b', $arr['a'])) now narrows the false branch too, excluding shape alternatives that guarantee the key.
  • Narrowing: the match(true)/switch(true) is_TYPE() disjunct merger now narrows property receivers, not just variables.
  • Narrowing: gettype()/get_debug_type() literal comparisons now narrow property receivers.
  • Narrowing: the is_string()/is_array()/ctype_*()/array_is_list()/ method_exists()/property_exists() family now narrows property receivers, not just variables.
  • Narrowing: array_key_exists()/key_exists() now narrows the false branch on shape unions, excluding alternatives that declare the key mandatory.
  • Narrowing: in_array()’s needle argument now narrows a property-access receiver, for both the true and false branch.
  • Narrowing: get_class()/gettype()/get_debug_type()/::class now narrow on loose ==/!=, not just strict ===/!==.
  • Narrowing: str_contains() and its sibling functions now resolve a variable already narrowed to a single string literal as the needle, not just an inline literal.
  • Narrowing: iterator_count() now narrows like count()/sizeof()/strlen().
  • Narrowing: ($this->prop ?? FALLBACK) === FALLBACK now narrows the property receiver, matching the existing plain-variable arm.
  • Narrowing: $this->prop === []/!== [] (strict and loose) now narrows property receivers, matching the existing plain-variable arm.
  • Narrowing: $this->prop < N/N < $this->prop (and <=/>/>=) now narrows property receivers, matching the existing plain-variable arm.
  • Narrowing: $obj->prop === true/42/'x'/EnumCase::Case and $obj->prop instanceof X now also prove $obj itself non-null, matching the existing nullsafe/null-check arms.
  • Narrowing: array_is_list() now recognizes TKeyedArray shapes — previously any array literal or docblock shape was narrowed as if it could never be a list, regardless of its own is_list flag.
  • @var annotations: a free function’s own @psalm-type/@phpstan-type alias is now expanded in a bare @var Result $x annotation, matching how class-scoped aliases already resolved.
  • Narrowing: a dynamic $fn() call (a variable holding a callable) no longer coincidentally matches a builtin of the same name as the variable’s own identifier (e.g. $is_null(...) no longer narrows as if is_null() were called).
  • Narrowing: int-comparison narrowing ($x > PHP_INT_MAX, $x < PHP_INT_MIN) no longer treats the i64::MIN/MAX boundary as unconstrained — the comparison is now recognized as impossible instead of leaving a dead branch reachable.
  • CI: 0.59.1’s crates.io publish still failed on mir-plugin, since a first-time crate publish needs the publish-new token scope that the CI token doesn’t have; mir-analyzer and mir-php were never reached. mir-plugin has now been published manually so the crate exists on the index, and this release carries no other changes — it exists so the remaining crates land on crates.io at a version CI can publish end to end.
  • CI: the release workflow never published mir-plugin, so mir-analyzer (which depends on it) failed to publish and broke the 0.59.0 release partway through. Each publish step now also skips crates/versions already uploaded, so a rerun after a partial failure doesn’t error out on the ones that already succeeded.
  • @var alias expansion: extract_var_annotation_from did a fresh find_class_like lookup for every @var-annotated statement, even when consecutive statements in the same method/class share the same enclosing class — a ~13% single-threaded regression on the full Laravel corpus benchmark introduced in 0.59.0. The lookup is now memoized per (fqcn, ClassLike) on StatementsAnalyzer.
  • Plugin system (new mir-plugin crate), modeled on Psalm’s plugin API:
    • Rust plugins implement the MirPlugin trait with Psalm-style hooks — after_expression_analysis, after_statement_analysis, after_function_call_analysis, after_method_call_analysis, function/method return-type providers, before_add_issue, and after_codebase_populated — and are either compiled in or loaded from a cdylib declared via <plugins><rustPlugin path="..."/></plugins> (mir_plugin::export_plugin!; same-toolchain builds required).
    • Existing Psalm PHP plugins are reused through <plugins><pluginClass class="..."/></plugins> (psalm.xml syntax): mir spawns a PHP host that boots the project’s composer autoloader, runs each plugin’s entry point, and bridges over JSON-RPC. Supported in this first cut: addStubFile (full), FunctionReturnTypeProviderInterface and MethodReturnTypeProviderInterface (best effort, cached per call signature); other hook registrations are reported and skipped.
    • Plugins emit custom issues (PluginIssue, code MIR1509) that respect @mir-suppress <Name>, <issueHandlers>, and baselines under their own issue names.
    • Class-property providers (Psalm’s PropertiesProviderInterface shape): a plugin declares marker classes via class_property_classes() and types otherwise-undeclared properties from class_property(). Dispatch is ancestor-aware — a marker on a framework base class covers every subclass — and the ClassPropertyProviderEvent exposes the receiver’s array-literal property defaults (e.g. Eloquent $casts) so the plugin needs no AST access. Fires on a property-access miss before UndefinedProperty is reported. MIR_PLUGIN_API_VERSION bumped to 2.
  • Narrowing: $this->prop instanceof A || $this->prop instanceof B (OR-disjunct instanceof) now narrows property receivers, not just plain variables.
  • Narrowing: literal bool/int/string comparisons now narrow properties, not just variables.
  • Narrowing: loose ==/!= [] array-emptiness comparisons now narrow, mirroring the existing strict-comparison handling.
  • @mir-check: extended to arbitrary expressions, not just a bare variable.
  • symbol_at: a cursor sitting in the ->/?->/:: gap between a property-access receiver and the member name (right after typing the operator, where member completion fires) now resolves to the receiver’s type via a new ReferenceKind::Receiver. Scoped to property access (instance + static, including self/$cls::); method-call receivers already had an equivalent chain-gap answer via their expr_span fallback.
  • array_map/array_reduce: the element/result type now resolves through an opaque, unrefined callable parameter of the enclosing function by looking at how that function’s own callers actually invoke it — the concrete closure/named-function passed at each call site is resolved and unioned across the workspace. Scoped to plain function parameters (not method receivers, which would need flow-sensitive resolution this pass deliberately avoids) and to callback arguments that are themselves statically resolvable (an inline closure/arrow function with an explicit return type, a named-function reference, or a first-class callable) — an unresolvable caller simply contributes nothing rather than poisoning the result.
  • Generics: static/self nested inside generic docblock arguments (@return Builder<static>) now resolve to the receiver class like the top-level forms, instead of leaking an unresolved atom that degraded every downstream template binding to mixed.
  • Generics: docblock return types on template-free methods now namespace-qualify class names in generic positions — previously @return Builder<static> stored a bare relative Builder (only methods declaring their own @template got qualification) and the class was never found again.
  • Generics: @template-extends Base<U> / @template-implements type args referencing the class’s own template params are now stored as template params instead of being namespace-qualified into phantom classes (NS\U), so methods inherited through a parameterized parent chain (HasMany<Post> extending Builder<TRelated>) resolve their return templates to the concrete bound type.
  • Properties: $obj->prop’s inferred type now widens to include null when $obj itself is nullable, matching the already-correct ?-> behavior.
  • Narrowing: get_class()/::class/class-string !== comparisons no longer over-eagerly drop a same-named class atom from the false branch — it’s exact-class equality, not subtype equality, so the false branch must stay subclass-safe.
  • Properties: unified plain vs. nullsafe property-null narrowing logic.
  • Control flow: !isset($x) || RHS no longer leaks RHS’s diverges flag into the surrounding scope.
  • Narrowing: array_key_exists() no longer strips null from an already-proven key’s type.
  • Narrowing: fixed a false-positive RedundantCondition on nullsafe property null-checks.
  • Narrowing: property-narrowing contradictions are now correctly marked unreachable.
  • Parser: fixed a mid-codepoint panic in the subtype-scan identifier prefilter (mentions_identifier) when a searched class short-name begins with a non-ASCII byte.
  • Docblock parser: a lone unmatched quote in a docblock type (@var ') no longer panics; unterminated string literals — top-level, inside a union, or nested in array-shape keys/generics — are now reported as InvalidDocblock instead of silently falling back to mixed or producing a misleading “unclosed generic type” message.
  • @var annotations: a bare @var Result $x variable annotation now expands @psalm-type/@phpstan-type aliases declared on the enclosing class/interface/trait/enum’s own docblock, matching how @param/@return references to the same alias already resolved. A global function’s own @psalm-type (not tied to a class) remains out of scope, as before.
  • Narrowing: count()/strlen() comparisons that prove an exact-zero length (=== 0, < 1, <= 0, etc.) now narrow to the empty collection/string, mirroring the non-empty direction (> 0, !== 0, …) that was already handled.
  • Narrowing: loose ==/!= comparisons against false on call results (e.g. strpos($h, $n) != false) now narrow like the strict ===/!== false arm already did.
  • Narrowing: array_key_exists()/key_exists() now resolve a variable holding an already-narrowed literal key ($key = 'name'; array_key_exists($key, $arr)), not just an inline string/int literal, so shape-narrowing applies to this common pattern.
  • Narrowing: is_a($x, 'Foo', true) now checks the class-string’s subtype relationship instead of keeping every string/class-string atom unconditionally in the true branch — a class-string<Bar> atom unrelated to Foo is dropped from the true branch (and kept in the false branch), matching the existing object-side behavior.
  • Updated salsa from 0.27.0 to 0.28.0; picks up php-rs-parser/php-ast/php-lexer/phpdoc-parser patch bumps transitively.
  • Narrowing: $arr === [] narrows to the empty collection (the !== [] direction was already handled), and $obj::class comparisons narrow like get_class().
  • use: postings for unresolvable imports: use items whose target class/function/constant doesn’t resolve (vendor-only, not yet loaded, or genuinely missing) previously recorded no use: posting, so an index-based rename couldn’t find the import line. The miss path now records the posting keyed by the written FQN.
  • Narrowing: !is_float()/!is_double() no longer leaves TIntegralFloat in the negative branch.
  • Reference postings persist from LSP sessions: the session’s posting-commit sites — the parallel re-analysis sweep (reanalyze_dependents/reanalyze_files_cancellable) and indexed_references_to’s on-demand freshness pass — now write each committed file’s reference locations into the attached AnalysisCache, keyed by content hash with a surface fingerprint, exactly like the CLI batch pipeline. A returning session’s warm_start_files therefore replays both inverted indexes from disk, so the first find-references query after a relaunch is answered index-warm with no analysis sweep. Entries already valid for a file’s current content (e.g. batch-written) are never clobbered, and a no-op re-sweep stages no cache work. New AnalysisSession::flush_analysis_cache() persists the staged entries — hosts should call it after their warm sweep completes and on shutdown.
  • Reference postings committed before a dependency existed stayed “fresh” forever: a file’s committed find-references postings were trusted for as long as its own source text was unchanged, so a file analyzed before a class/function it references was defined elsewhere (e.g. $this->svc->run() committed before Svc existed) kept serving its incomplete postings indefinitely — whether the definition later arrived via a newly-registered file, an edit to an already-registered file, or the postings were replayed from a previous session’s disk cache (warm_start_files). Commits are now stamped with the workspace generation — which advances on file adds/removes and, newly, when an ingest defines symbols in an existing file — and are re-verified once it has moved on. The stamp is captured before the analysis snapshot, so a registration racing an in-flight analysis leaves the commit stale (self-healing on the next query) rather than wrongly fresh.
  • Freshness re-verification after workspace growth is scoped so warm find-references stays an O(results) lookup: commits whose analysis resolved every referenced name are immune to generation bumps (later definitions cannot change their postings), and re-verified files whose analysis memo came back pointer-identical re-stamp their freshness mark without rewriting posting lists. The resolved flag is derived from each commit’s own issue set, so the open-file (FileAnalyzer) and disk-cache warm-start paths participate too — a returning session’s replayed postings survive the background indexing and lazy vendor loads that follow warm-up. Background growth therefore no longer forces whole-scope posting rebuilds on the next query.
  • Delta-maintained inverted subtype index (SubtypeIndex): resolved parent FQCN → direct children, updated per file commit instead of scanned per query. New session queries indexed_subtype_classes (transitive subtypes with declaration name ranges, short-name-lenient roots, anonymous-class impl: postings) and indexed_method_implementations (concrete overrides across subtypes).
  • AnalysisSession::indexed_references_to: posting-list find-references with an on-demand freshness/completeness pass — committed-fresh files answer from the index in O(results); stale/uncommitted candidates analyze once and commit. Member queries fan out across the resolved class hierarchy and fall back to name-keyed postings (methname:/propname:) when nothing typed resolves; include_declaration contributes per-hierarchy-class declaration name tokens (methdecl: postings for unknown owners).
  • AnalysisSession::declaration_name_range: a symbol’s declaration site narrowed to its name token (case-insensitive fallback for lowercased method names; textual lookup for global constants).
  • Reference recording coverage: meth:{fqcn}::__construct at new sites, free-function parameter defaults, class/trait property initializers, braced-namespace top-level statements (uniform-namespace files only), static/instance property writes through array subscripts, and name-keyed fallbacks for calls/accesses on unresolvable receivers.
  • SubtypeClassSite public type; db::{SubtypeIndex, SubtypeEntry, SubtypeSite, ClassLikeKind} exports.
  • AnalysisSession::warm_start_files: replays a returning session’s on-disk cached reference-location postings and subtype edges (via AnalysisCache and the StubSliceCache stub-cache sidecar) instead of rebuilding them through the on-demand analysis sweep on first query. A cache miss falls through to the existing lazy paths unchanged.
  • ReferenceKind::UseImport and a use:-prefixed index posting for use Foo\Bar;/use function/use const import name tokens, plus AnalysisSession::indexed_use_import_locations to read them back scoped to a file set. Deliberately not folded into the plain cls:/fn:/gcnst: key, since an import alone isn’t a usage.
  • The reference index is now always maintained with replace-per-file semantics (FileAnalyzer commits via set_file_reference_locations and marks per-file freshness); reanalyze_* sweeps also commit subtype edges.
  • Property reference spans are normalized to the bare name (static accesses previously included the $ sigil); global-constant spans narrow to the final path segment.
  • ingest_file unconditionally clears the file’s old definitions and reference locations before re-ingesting.
  • collect_definitions (the vendor-tree walker) and analyze_paths (the CLI batch pipeline) never fed the subtype index from the StubSlice they already collect, unlike the single-file LSP edit path (ingest_file) — an implementor living only in vendor/, or seen only through a batch run, was invisible to goto-implementation until something else individually touched its file.
  • Property and class-constant declarations had no name-only fallback posting (unlike methdecl: for methods), so an unknown-owner find-references query surfaced fallback usages but never the declaration itself. Adds propdecl:/cnstdecl: postings, plus a methdecl: posting for interface methods (which have no body/params to anchor the existing name-span heuristic on).
  • AnalysisSession::without_reference_index — the opt-out existed because per-request recomputation made the index dead weight; with delta maintenance and posting-list reads it is the primary read path.

  • AnalysisSession::references_to, references_to_in_files, and references_to_in_files_cancellable — superseded by indexed_references_to. The scan-based class_subtype_files tracked query is also gone; subtype_files() keeps its public signature, now backed by indexed_subtype_classes.

    BREAKING CHANGE: callers of the removed references_to* methods must migrate to indexed_references_to(symbol, files, include_declaration, should_cancel).

  • Readonly-class extends parity and readonly-property defaults: PHP requires a readonly class to extend only another readonly class (and vice versa), and forbids a default value on a native readonly property — both hard fatals mir previously missed entirely. Adds ReadonlyClassExtendsMismatch, alongside the existing final-class-extension check, and InvalidReadonlyPropertyDeclaration for the property-default case; the untyped-readonly-property half of that check is already caught by the parser itself as a ParseError, so it isn’t duplicated.
  • array|Traversable collapses to iterable in type display: PHP’s iterable is defined as exactly array|Traversable, so a union containing a matching TArray{key,value} + Traversable pair now prints as iterable/iterable<K, V> instead of the decomposed form. A bare (unparameterized) Traversable only collapses against the fully-generic array — pairing it with a more specific array is left alone, since the bare Traversable makes no key/value guarantee and collapsing would overclaim precision.
  • Defaulted mixed type parameters collapse in display: array<mixed, mixed>/array<array-key, mixed> now print as array (same for non-empty-array), list<mixed>/non-empty-list<mixed> as list, and Traversable<mixed, mixed>-style named objects as the bare class name when every param is a literal, unconstrained mixed. Template params bounded by mixed are left untouched since they carry real signature info. Also fixes the root cause for the array case: a bare array docblock keyword was building its key as TMixed instead of the true PHP array-key domain (int|string), now shared via Type::array_key().
  • vsprintf() didn’t infer a non-empty-string return type like sprintf(): sprintf_return_type only ever consults the format-string argument (index 0), which vsprintf shares with sprintf verbatim — extending the special case to vsprintf closes the same precision gap already fixed for array_reduce.
  • Hover symbol missing on plain variable-assignment write targets: assign_to_target’s ExprKind::Variable arm only updated flow-state variable tracking, never calling record_symbol — unlike the read path (analyze_variable) and the already-fixed property/static-property write siblings. Hovering $x at its own $x = 5; site (or any list()/array-destructuring target) resolved nothing.
  • Class-constant type not inferred from a same-file ClassConstAccess initializer: const DEFAULT = Suit::Hearts; (no native hint or @var docblock) collapsed to bare mixed, since infer_const_value had no ClassConstAccess arm — the overwhelmingly common case for undocumented constants, blinding every downstream type-check/narrowing keyed on such a constant. The collector has no DB/cross-file access, so resolution is scoped to an already-collected same-file class-like; unresolvable references still fall back to mixed as before. Enum cases infer a plain TNamedObject to match find_class_constant_in_class’s own representation, not TLiteralEnumCase, which broke subtyping against the plain enum type.
  • array_key_exists() didn’t narrow nested shape-key paths: array_key_exists('b', $arr) only resolved a plain variable or single-level property array argument, unlike isset()’s collect_array_access_path/narrow_shape_path, which walks arbitrary nested paths. array_key_exists('b', $arr['a']) left $arr['a']['b']’s optionality/nullability unnarrowed, missing a proven-present key. Adds narrow_shape_path_key_exists, parallel to narrow_shape_path but applying array_key_exists’s own key-presence semantics at the container.
  • is_a()/is_subclass_of() didn’t narrow property-access receivers: both checks only handled a plain variable receiver (extract_var_name), unlike plain instanceof’s narrow_prop_instanceofis_a($this->item, Foo::class) and is_subclass_of($this->item, Foo::class) silently no-op’d on a property receiver, missing real UndefinedMethod/PossiblyNullMethodCall bugs after a proving guard. Adds narrow_prop_is_a/narrow_prop_is_subclass_of mirroring the existing variable-based semantics, sharing a new apply_prop_narrowed helper with narrow_prop_instanceof.
  • traituse: dead-code marker didn’t credit transitive trait composition: class_traits() only returns a class’s direct use list, so a private member called/read from a trait reached transitively (Outer uses Inner, class uses Outer) never matched the traituse: exemption marker recorded under Inner’s FQCN — false UnusedMethod/UnusedProperty. Now walks the already-transitive class_ancestors_by_fqcn and keeps the trait entries instead.
  • array_udiff/array_uintersect family (and their *_ukey/*_uassoc siblings) rejected the comparator callback: phpstorm-stubs types the PHP-8.0+ trailing variadic ...$rest as @param array, but the actual runtime argument in that slot is always the comparator callback, not an array — every valid call with a closure/string-callable raised a spurious InvalidArgument. Retypes the docblock slot as mixed.
  • is_iterable()/is_countable() false branch stripped every object atom: the false branch unconditionally removed every object atom, unlike the deliberately conservative true branch. For a SomeClass|array union this emptied the type and marked the else-branch unreachable, hiding real bugs inside it and raising a false RedundantCondition on the check itself. Now only the atom known for certain to satisfy the check (a plain array) is excluded.
  • Generator return checked against the whole Generator type instead of TReturn: return <expr>; inside a generator sets Generator::getReturn()’s value (the TReturn/4th type-param slot), not the generator object itself. Comparing it against the whole declared Generator<K,V,S,R> type raised a false-positive InvalidReturnType on the textbook-correct return-value idiom.
  • Redundant intersection parts printed verbatim: TIntersection had no de-dup at all, so a redundant Foo&Foo printed verbatim instead of collapsing to Foo. Only drops parts that are structurally identical to an earlier one — hierarchy-aware redundancy (e.g. Iterator&Traversable) needs class info this crate doesn’t have, so it’s intentionally left alone.
  • true|false didn’t merge into bool during union construction: Type::add_type already collapsed TTrue/TFalse into an existing TBool, but never merged the two literals into TBool when both showed up without one already present (e.g. inferring the return type of a function with only return true;/return false; branches). Lossless, since PHP’s bool is defined as exactly true|false.
  • iterable’s array branch keyed on mixed instead of array-key: same root-cause bug as the earlier bare-array fix — parsing bare iterable and single-param iterable<V> built the array branch’s key as a literal TMixed instead of the true PHP array-key domain (int|string), via the shared Type::array_key() constructor. This misrepresented the key type and defeated the array<mixed,mixed>-style display collapse for iterable’s array member.
  • Malformed InvalidOperand message for unary operators: unary operand checks (negate, unary +, ~, pre/post ++/--) reused the binary operator’s “between ‘X’ and ‘Y’” format with an empty right operand, producing a malformed “between ‘X’ and ‘’” message.
  • @psalm-import-type couldn’t import from an interface/trait/enum: InterfaceDef/TraitDef/EnumDef had no type_aliases field at all, and @psalm-import-type’s same-file resolution only searched self.slice.classes — so a @psalm-type alias declared on an interface, trait, or enum could never be imported, even from within the same file. Adds the field (mirroring ClassDef) and populates it in each collector; the same-file import lookup now searches all four class-like kinds. Cross-file import resolution (pending_import_types) is still class-only, left for a follow-up.
  • In-process parse cache ignored PHP version: ParseCache hashed file content only, unlike the on-disk stub cache one layer down (keyed on content + PHP version). A session retargeted to a different PHP version mid-lifetime (BatchOptions::with_php_version) could replay a byte-identical file’s stale, differently-collected (different @since/@removed filtering, different #[LanguageLevelTypeAware] resolution) StubSlice from an earlier version.
  • Trait/enum method parameter default expressions never analyzed: trait and enum method scopes hardcoded analyze_param_defaults: false on every path (typed and untyped), unlike class methods — so a param default expression referencing an undefined global constant (or any other diagnostic that expression analysis would catch) went completely unanalyzed for trait/enum methods.
  • Missing (TIntersection, TIntersection) structural subtype arm: atomic_subtype had no arm for two pure-intersection types, falling to the sub == sup fast path only — a false-positive MethodSignatureMismatch on valid covariant-return/contravariant-param intersection-type overrides (e.g. widening Countable&ArrayAccess&Iterator down to Countable&ArrayAccess on override, which is legal: more conjuncts is the more specific subtype). Same “structural check has no arm for X” pattern already fixed for arrays and closures.
  • #[Deprecated] not recognized on interface/trait/enum constants: per-constant (and trait-property) #[Deprecated] attribute fallback was missing on interface, trait, and enum constants, and trait properties — only the @deprecated docblock tag worked, unlike class constants and properties, which already checked both.
  • Enum case/constant docblocks never validated or version-gated: collect_enum’s per-member loop never called emit_docblock_issues or version_allows on a case’s or constant’s own docblock, unlike the enum’s own decl docblock (already fixed) and class/interface/trait member docblocks. A malformed @var on an enum case/const went unflagged, and @since/@removed version-gating on a member was ignored.
  • analyze_source’s typed body-analysis path drifted out of sync with the batch pipeline: analyze_bodies_typed (reached only via the public analyze_source entry point) was missing attribute-placement checks (check_class_attributes, check_trait_attributes, check_function_attributes, check_parent_in_class_attrs), a function param’s default-value undefined-class check, and check_duplicate_declarations, all already present in analyze_bodies (the real batch/LSP pipeline).
  • @var docblock ignored on trait properties: trait property collection only ever used the native type hint, unlike the equivalent class property, which lets an @var docblock refine it. A trait property typed only as mixed natively (a common generics workaround) with a more specific @var docblock lost the refinement everywhere the trait is used.
  • Structural dependency edges missing for enum/trait declarations: file_structural_deps never iterated defs.slice.enums at all, and the trait branch only walked t.traits (never own_methods/own_properties), unlike the class branch. An enum’s implements/use/method type hints, and a trait’s own member type hints, created zero structural dependency edges — a change to a type used only in one of these signatures wouldn’t invalidate/re-analyze the dependent file. The interface branch also gained the own_properties (@property docblock) walk it was missing.
  • InvalidOperand missing on prefix ++/--: prefix ++/-- skipped the bool/non-empty-string InvalidOperand check that postfix ++/-- already had one function away — the same PHP warning/deprecation fires for both forms.
  • #[LanguageLevelTypeAware] ignored on property declarations: property collection in class.rs/trait.rs never consulted the phpstorm-stubs #[LanguageLevelTypeAware] attribute, unlike params and return types — so a version-specific stub property type override was silently ignored. Confirmed live against the embedded stubs: Exception::$file/$line lost their PHP-8.1+ refined string/int type and fell back to mixed.
  • Abstract-method check didn’t recurse into trait-of-trait: check_abstract_methods_implemented walked the legacy self.ancestors()/class_ancestors, whose trait branch doesn’t recurse into a trait’s own transitively-used traits. An abstract method declared only in a trait-of-a-trait (trait Mid { use Leaf; }) was never even considered, so a class missing its implementation went unflagged. Switched to the already-fully-recursive class_ancestors_by_fqcn used everywhere else in this file.
  • UnusedSuppress dropped on re_analyze_file’s cache-miss path: the non-cache-hit branch (definition collection + body analysis) never called apply_suppressions_and_emit_unused, unlike the cache-hit branch and analyze_paths — every non-cached re-analysis (the actual incremental/LSP-edit pipeline) silently dropped UnusedSuppress.
  • InvalidOperand missing on unary +/- non-numeric operands: unary prefix +/- never checked their operand, unlike binary arithmetic (operand_is_non_numeric) and unary ~ (operand_is_non_bitwise) one function away. -[1,2], -SomeEnum::Case, and +"abc" all throw a real PHP TypeError and went unflagged.
  • ImplicitToStringCast missed non-Stringable enum cases: all three implicit-to-string checks (concat, echo, print/interpolation) matched only Atomic::TNamedObject, so a non-Stringable enum case (echo Suit::Hearts) went unflagged despite being a guaranteed PHP fatal (“Object of class X could not be converted to string”).
  • Go-to-def/callable-refs didn’t resolve through trait precedence: member_location and record_callable_string_ref walked the plain find_method_in_chain, which never consults a class’s trait_aliases and has no insteadof exclusion. Go-to-def on a call resolved through a trait alias (use T { foo as bar; }) hard-failed with NotFound even though the call itself type-checks fine, and a callable-string reference ('Class::method') on a trait conflict could credit the insteadof-losing trait instead of the real target, risking a false UnusedMethod on the winner. Both now resolve through find_method_respecting_precedence, the same walker call resolution already uses.
  • Override checks ignored trait-composed methods/properties: check_overrides and the property visibility-reduction check only ever looked at own_methods()/own_properties() (literally declared in the class body), so a method or property a class only has via use Trait; was invisible to every override check: final-override, static mismatch, visibility reduction, return-type covariance, and param narrowing. Trait-composed members are now treated as this class’s own for these checks, resolved through the precedence-aware walker so insteadof/as conflicts pick the right winner. Also rebinds self/static in a composed method’s signature from the trait’s own FQCN to the composing class before comparing, and removes the now-redundant trait-satisfies-interface special case in check_interface_methods_implemented, since check_overrides covers it more thoroughly and was producing a duplicate diagnostic.
  • Final/static/visibility override checks only compared against the first ancestor: check_overrides compared final-ness, static-ness, and visibility against all_parent_methods.first() only, while the return-type and param checks in the same function already looped every ancestor. Since traits are always ordered before the real parent class, a trait’s compatible copy of a method could shadow a genuine conflict against the parent or a later interface.
  • ReadonlyPropertyAssignment named the wrong declaring class: the diagnostic reported the receiver’s static class instead of the class that actually declares the readonly property, so a subclass writing to an inherited readonly property named the subclass rather than the parent — e.g. “Child::$name” where real PHP’s own error says “Base::$name”. Verified against PHP 8.1’s actual runtime error message.
  • Hover/go-to-def missing on use function/use const imports: the use-import name token was a dead zone for hover/go-to-definition on use function/use const, unlike the already-fixed use ClassName; case.
  • array_reduce() return type never inferred from its callback: unlike array_map/array_filter/array_key_first/array_key_last, array_reduce had no return-type-inference arm — the stub’s bare mixed return type made its result opaque to downstream type checks even when the callback and initial value are both fully typed.
  • UndefinedMethod not flagged on intersection-typed receivers: the TIntersection arm in analyze_method_call silently fell back to mixed when no part of the intersection had the method, unlike the TNamedObject branch, which flags a concrete class’s missing method.
  • UndefinedProperty not flagged on intersection-typed receivers: resolve_property_type had no TIntersection arm, so property access on an intersection-typed receiver silently returned mixed with zero diagnostics and no find-refs reference, worse than the sibling method-call gap.
  • Readonly constructor bypass check not scoped to the declaring class: a subclass’s own constructor writing directly to a readonly property declared on the parent (bypassing parent::__construct()) went unflagged, since the constructor bypass check ignored declaring scope entirely — a real PHP fatal error. Scoping it required teaching the check that a trait-contributed property belongs to the consuming class’s own scope (PHP copy-paste semantics), not the trait’s, via a new property_in_own_composition helper that never crosses an extends boundary.
  • Missing (TKeyedArray, TKeyedArray) structural subtype arm: nested array shape values (array<K, array{...}>, list<array{...}>) had no direct shape-vs-shape comparison and only matched via exact equality, so a valid nested shape spuriously raised InvalidReturnType.
  • Typed-callable diagnostics used placeholder names instead of the real call site: check_typed_callable_arg hardcoded param: "callback" and fn_name: "typed_callable" as diagnostic placeholders, even though the actual call site (check_one) already has the real function and parameter names available. Messages like “Argument $callback of typed_callable() expects ’callable returning ‘Exception’’, got ’callable returning ‘void’’” pointed nowhere a developer could find and double-quoted the nested type name. Now threads the real names through and drops the redundant inner quotes, e.g. “Argument $c of takesClosureReturningException() expects callable returning Exception, got callable returning void”.
  • (string) cast on an array wrongly suppressed under InvalidCast: unlike (int)/(float), casting an array to string always raises PHP’s “Array to string conversion” warning and yields the useless literal “Array”, regardless of what other scalar-safe atoms share the union. The scalar-safe suppression guard borrowed from the int/float siblings doesn’t apply to string casts and was hiding a real bug class.
  • Circular trait composition not detected: class/cycles.rs only walked classes and interfaces for cycle detection — a trait-only cycle (trait A { use B; } trait B { use A; }) never fired any diagnostic. Mirrors the existing interface-cycle DFS, reported via InvalidTraitUse since traits don’t have their own “inheritance” kind.
  • TClosure-vs-TClosure subtyping ignored signatures entirely: atomic_subtype’s (TClosure, TClosure) arm was unconditionally true (“structural compatibility simplified”), so any closure satisfied any other regardless of signature. Now checks arity, per-parameter contravariance, and return covariance for scalar/array-shaped types, where a purely structural check is reliable. Named-class params/returns are deliberately skipped (treated as compatible), since this checker has no database access to resolve real inheritance and a naive structural comparison would flag legitimate subclass/superclass substitutions as false positives far more often than it would catch real violations.
  • Typed callable arguments didn’t check return-type covariance: check_typed_callable_arg validated arity and per-parameter contravariance but discarded the expected callable’s return type before the call, so callable(int):string accepting a callback returning int went unflagged. Closes a pre-existing empty-expect fixture (detect_implicit_void_return) that documented exactly this gap.
  • PossiblyInvalidArrayAccess missed array|TIntegralFloat unions: is_invalid_for_access (the mixed-union “possibly invalid” case) omitted TIntegralFloat, unlike the definite-invalid list right above it — an array unioned with floor()/ceil()’s return type silently skipped the check.
  • Destructuring didn’t widen an optional shape key with null: keyed/list destructuring resolved an optional shape key’s type via a plain properties.get(k) lookup without checking prop.optional, unlike plain array access — so ['a' => $a] = $arr inferred T instead of T|null for array{a?: T}, missing a downstream null-argument check.
  • (string) cast flagged even on a scalar-safe mixed union: CastKind::String’s array-check emitted InvalidCast on any union containing an array atom, unlike the (int)/(float) siblings, which both skip the warning when the union also has a scalar branch (e.g. an over-broad string|array|bool|null return type).
  • #[Deprecated] not recognized on interface/trait/enum declarations: interface.rs and trait.rs only read the @deprecated docblock tag, missing the #[Deprecated] attribute fallback class.rs already has; enum.rs gets the same fallback for its newly-added deprecated field. Factored the shared docblock-tag-or-attribute logic into deprecated_from_doc_or_attrs.
  • Enum-level docblock never validated or version-gated: collect_enum never called emit_docblock_issues or version_allows on its own decl docblock, unlike class/trait/interface: a malformed tag never raised InvalidDocblock, and @since/@removed version gating on an enum stub (e.g. PropertyHookType, @since 8.4) was ignored entirely — the enum was collected regardless of the configured target PHP version.
  • @deprecated not recognized on enum declarations: EnumDef had no deprecated field at all, unlike ClassDef/InterfaceDef/TraitDef, so ClassLike::deprecated() hardcoded None for enums and every DeprecatedClass-equivalent check site could never flag a deprecated enum.
  • Bare docblock callable(T):R never checked arity or argument types: Atomic::TCallable{params: Some(...)} had no arm in extract_all_callable_candidates or typed_params_from_callee, so a bare (non-Closure, non-intersection) callable(int):void annotation got zero arity or argument-type checking. Also fixes parse_callable_syntax, which hardcoded is_optional/is_variadic to false for every param regardless of a trailing = or leading ... — latent since the callable/Closure docblock forms share this parser, surfaced by three existing fixtures once real checking started running against them.
  • Trait constraints not checked transitively through composed traits: check_trait_constraints only walked a class’s direct trait list, so @psalm-require-extends/-implements on a trait reached only via another trait (class C { use A; } where A uses the constrained trait) was never validated at C. Reuses the already-transitive class_ancestors_by_fqcn.
  • Anonymous/nested classes extending a final class went unflagged: anonymous classes and named classes nested in a function/if-block are never collected into the codebase, so the batch check in class/mod.rs (which only walks collected classes) never sees them and InvalidExtendClass went unchecked for new class extends FinalBase {} and similar. The rest of override/abstract-method/interface-method checking for these class shapes still needs full collector support and is tracked as a larger follow-up.
  • Hover/find-refs missing on static property write targets: Foo::$prop = x/self::$prop = x/static::$prop = x writes recorded neither a reference nor a hover symbol at all, unlike the read path and unlike instance-property writes (already fixed separately).
  • Find-refs missing on instance property write targets: property write targets ($this->prop = ...) recorded a hover symbol but never a reference, so find-all-references only ever found reads.
  • Find-refs/hover missing on global constant usages: analyze_identifier resolved global constants for typing but never called record_ref/record_symbol, so references_to/symbol_at could never find a usage site for a global constant.
  • Docblock type parsing wasn’t quote-aware: validate_type_str‘s blanket @-check and split_union/is_inside_generics’ bracket-depth tracking ignored quoted literal-string content, so 'admin@example.com'|'guest@example.com' was flagged as a malformed type and 'a|b'|'c' was split mid-literal and collapsed to mixed, silently disabling argument-type checking for both.
  • Suppression kind names matched case-sensitively: @mir-ignore undefinedclass silently failed to suppress UndefinedClass since KindSet::matches did a raw case-sensitive hash lookup. Now compares case-insensitively while still storing (and displaying in UnusedSuppress messages) the author’s original casing.
  • print() not checked as an HTML taint sink, and parenthesized expressions broke taint propagation: print($_GET['x']) never ran the taint check echo already does. Fixing it surfaced a deeper bug: is_expr_tainted had no arm for ExprKind::Parenthesized, so print’s parenthesized argument (and any parenthesized tainted subexpression reaching any sink) silently broke taint propagation.
  • Spread arguments to a callable-typed value miscounted arity: the arity-only fallback for callable-typed dynamic invocation used raw call.args.len(), ignoring spread args entirely — $fn(...$threeElemArray) against a Closure(int,int,int) falsely reported TooFewArguments (expected 3, got 1). Reuses the arity_unknown signal the full check_args path already threads for the same reason.
  • traituse: marker not credited for self::/static:: calls inside traits: method.rs already records traituse:{fqcn}::{method} for an unresolved $this->call() inside a trait, so DeadCodeAnalyzer credits whichever composing class ends up providing the method. static_call.rs’s identical self::/static:: fallback never recorded this marker, so a private static method reached only that way was falsely flagged UnusedMethod.
  • method_exists() guards ignored for static calls: Foo::bar() never consulted ctx.method_exists_guards, unlike $obj->bar(), so if (method_exists(Foo::class, 'bar')) { Foo::bar(); } still raised a false UndefinedMethod. Extended extract_expr_guard_key to also key Foo::class (resolved FQCN, prefixed to stay disjoint from variable-name keys), covering both the literal-class-name and $cls::bar() dynamic class-string forms.
  • while(1) not recognized as an infinite loop like while(true): only the literal boolean true was treated as an infinite-loop condition; while(1) — a common older-PHP idiom — went through the ordinary possibly-zero-iterations merge, spuriously flagging variables assigned before every break as possibly-undefined after the loop.
  • continue inside a switch treated as loop continuation instead of break: switch counts as one loop-nesting level for break/continue in PHP, so a bare continue; (or any continue N whose Nth enclosing construct is a switch) exits the switch — it doesn’t continue an outer loop. The analyzer treated every continue as an unconditional divergence with no context saved, causing a hard UndefinedVariable (instead of PossiblyUndefined, like break) and false UnreachableCode after switches inside loops. Now tracks which break_ctx_stack levels are loops vs switches so continue can target the right one.
  • Match exhaustiveness didn’t fold null into literal string/int coverage: Case 1/1b required every subject atom to be a literal, so a nullable literal union ("a"|"b"|null) skipped exhaustiveness checking entirely instead of requiring a null arm, unlike the already-fixed nullable-enum case.
  • Trait-in-trait use declarations never validated or located: trait A { use B; } never ran check_trait_constraints at all (only classes and enums did) — B recorded no find-refs location and no UndefinedTrait/InvalidTraitUse/readonly-property check ever ran for it. Adds TraitDef::trait_use_locations (mirroring ClassDef/EnumDef) and wires the check into trait declarations, guarding the require-extends/-implements checks against a trait consumer.
  • Hover/go-to-def missing on extends/implements class names: check_name_class(_for_extends) recorded a find-refs location but never a ResolvedSymbol, unlike every other class-name usage site (type hints, new, instanceof, use-imports) — hovering/go-to-def on a parent/interface name in a class/enum/interface declaration resolved nothing.
  • SQL taint sinks missed OOP DB wrapper methods: $pdo->query()/->exec()/->prepare() and the mysqli/SQLite3 object APIs were invisible to taint tracking — only the procedural mysqli_query() style was checked, missing the dominant modern PHP idiom.
  • @param name parsing stopped at the tag’s first physical line: a wrapped multi-line array{...}/array<...> shape had its $name on a later physical line, so parse_param_line found nothing and the parameter was silently dropped from checking entirely.
  • Unreachable code not flagged when property instanceof narrows to empty: $h->prop instanceof A && $h->prop instanceof B never flagged unreachable for unrelated final classes A/B, unlike the already-fixed plain-variable case, since narrow_prop_instanceof/narrow_static_prop_instanceof never set ctx.diverges.
  • Static calls didn’t check method visibility: Foo::secret() never validated visibility, unlike $foo->secret(), so private/protected static methods were callable from anywhere.
  • Attribute class reference keyed by the whole #[Attr(...)] span: record_ref/record_symbol used the whole #[Attr(...)] span (name and args) instead of attr.name.span, so a find-references hit reported the full attribute and a cursor anywhere inside the argument list falsely resolved to the ClassReference symbol.
  • UnusedVariable false positive after a dynamic compact(): compact($namesArray)/compact(...$names) reads an unknowable set of variable names, unlike the literal-string-args form which marks each name read individually. Adds a dedicated has_dynamic_var_read flag (mirroring has_dynamic_var_def’s merge plumbing) rather than reusing has_dynamic_var_def itself, which is also set for $$var assignments where unused-write checking must still apply.
  • Small bounded int<min,max> ranges not expanded for match exhaustiveness: a bounded range like int<0, 2> is just as finite/enumerable as a literal-int union, but it never reached Case 1b (which only collected TLiteralInt atoms) and fell into Case 4’s unconditional “possibly-unmatched” bucket.
  • Attribute arguments didn’t record a constant/enum-case reference: #[Attr(Status::Active)] recorded a usage for the class Status but never for the specific constant/case Status::Active, so find-references from the declaration missed attribute-only usages.
  • [Foo::class, 'method'] array-callables not validated: the callable-array validator only matched TNamedObject for the first element, so [Foo::class, 'method'] (TClassString) skipped the UndefinedMethod check that [$obj, 'method'] already got.
  • UndefinedMethod not reported for first-class-callable syntax: $obj->undefined(...) and Foo::undefined(...) silently fell back to an untyped callable instead of reporting UndefinedMethod like the ordinary call form, missing real bugs. Mirrors call/method.rs and call/static_call.rs’s suppression rules (interface/abstract/trait receivers, __call/__callStatic, method_exists() guards).
  • Inherited static-property references keyed by receiver instead of declaring class: Child::$prop and self::$prop (from within a subclass) for a $prop declared on a parent recorded prop:Child::prop/prop:Self::prop instead of the owner, so find-references from the declaration missed usages reached only through a subclass name — matching the constant/instance-property fix.
  • Interface-declared property access never recorded a reference: the interface branch of resolve_property_type returned the @property type without ever calling record_ref/setting declaring_class, unlike the class and trait branches — property access through an interface-typed receiver was invisible to find-references.
  • Nested !empty($base['a']['b']) didn’t narrow any shape-key level: narrow_empty_shape_key only matched a single ArrayAccess node whose array was a bare variable, unlike its isset() sibling, which recurses through collect_array_access_path — so !empty() on a nested shape key narrowed nothing at any level.
  • $obj?->prop instanceof X didn’t narrow like $obj->prop: the Instanceof narrowing arm only recognized extract_var_name/extract_prop_access/extract_static_prop_access, never the nullsafe-access extractor, so a proving $obj?->prop instanceof X guard left the property at its pre-check nullable type.
  • Match exhaustiveness didn’t fold class-constant arm values: match($x) { C::A => ..., C::B => ... } arms were never resolved to their constant’s literal value, so a fully-covered match against class constants still reported UnhandledMatchCondition.
  • PHPUnit @dataProvider/#[DataProvider] methods falsely flagged unused: private data-provider methods are invoked by PHPUnit via reflection off the docblock tag or attribute argument, never through an ordinary call site, so every PHPUnit test suite with data providers hit a spurious UnusedMethod.
  • Match exhaustiveness exempted backed and nullable enum subjects: check_match_exhaustiveness’s enum branch was gated on scalar_type.is_none(), unconditionally exempting backed enums even though a backed enum’s case set is just as finite and enumerable as a pure enum’s — the backing scalar is irrelevant to exhaustiveness over case names. A nullable enum subject (?Status) was exempted too, via the same types.len() == 1 gate that a TNull atom defeats; now a leading TNull is stripped before that check, and an uncovered null case is reported unless a null arm or default is present.
  • $obj?->prop ignored prior narrowing and always widened to nullable: analyze_nullsafe_property_access never checked get_prop_refined, so a prior narrowing condition on $obj->prop (null check, instanceof, etc.) was silently dropped for the ?-> form even though the identical -> expression in the same position picked it up. It also added TNull to the result unconditionally regardless of whether the receiver itself could be null, which independently clobbered a narrowed non-null property type back into a nullable one — now gated on obj_ty.is_nullable().
  • Element classes inside array/intersection docblock shapes never checked or tracked: Foo[], array<int, Foo>, list<Foo>, and Foo&Bar docblock shapes only had their top-level atomic checked/recorded at every consumer site (@var, @param, @return, @property, @method) — the element/member class was never existence-checked or reference-recorded. Extends the existing collect_named_object_fqcns helper (previously only recursing into a TNamedObject’s own type-argument list) to also recurse into TArray/TList element+key types and TIntersection members, and reuses it from functions.rs’s @param/@return checks. Also fixes resolve_named_objects_in_union, which only namespace-resolved a union’s top-level TNamedObject and left names nested in type-argument lists/arrays/intersections unresolved against use imports.
  • Non-interpolated heredoc/nowdoc widened to TString instead of TLiteralString: heredoc/nowdoc always resolved to plain TString, unlike an equivalent quoted string literal — silently disabling callable-string usage tracking, class-string reflection, narrowing, and match/switch dedup (all of which key off TLiteralString) whenever written as heredoc/nowdoc. A heredoc with actual interpolated parts still widens to TString.
  • Class-constant references keyed by receiver instead of declaring class: every ClassName::CONST/self::CONST/static::CONST/parent::CONST/$obj::CONST access path recorded its cnst: reference and ConstantAccess symbol against the literal receiver class, discarding the owner already resolved by find_class_constant_in_chain. Since Trait::CONST is never a legal access target, a trait-declared constant is only ever read through a consuming class — so find-references/go-to-definition from the trait’s own constant declaration was structurally broken.
  • Hover symbol missing on first-class-callable syntax: foo(...), $obj->method(...), and Class::method(...) all recorded a reference (so find-references/dead-code worked) but never a hover symbol for any of the three forms, unlike their direct-call equivalents — go-to-definition on the callee name inside (...) silently resolved nothing.
  • instanceof $cls via class-string didn’t record a class reference: only the ExprKind::Identifier branch of the Instanceof check recorded a cls: reference. A dynamic instanceof check ($cls = Foo::class; $x instanceof $cls;) analyzed the variable only to mark it consumed, never crediting Foo as used — a false-positive UnusedClass and no go-to-definition from the check site.
  • new $class() via class-string didn’t record a class reference: only the bare-Identifier branch of analyze_new recorded a cls: reference and go-to-definition symbol. Instantiating through a class-string variable ($cls = Foo::class; new $cls();) recorded nothing, falsely flagging Foo as unused and breaking go-to-definition from the call site.
  • $cls::method() through a class-string variable skipped resolution entirely: extract_object_fqcn had no TClassString arm, so a static call through a class-string variable ($cls = Foo::class; $cls::bar();) skipped method resolution entirely — no existence check, no reference/symbol recorded, and even the generic dynamic-usage safety net (record_dynamic_member_access) no-op’d for TClassString. False positive UnusedMethod/UnusedClass, and a call to a genuinely missing method or nonexistent class went unnoticed.
  • @throws A|B union collapsed into one garbled class name: the “throws” tag handler kept only the first whitespace-split token, so a pipe-separated union collapsed into one garbled string that never matched a real class — producing a bogus UndefinedDocblockClass and recording no reference for either real exception type. Now splits the union into separate exception classes.
  • Enum interface checks ignored trait-provided methods: check_enum_interface_methods_implemented only looked at own_methods, reporting a false-positive UnimplementedInterfaceMethod whenever an enum satisfied an interface via a used trait. class_ancestors_by_fqcn already walks an enum’s traits, so it can reuse the same is_method_concretely_implemented check the class path uses.
  • Promoted property go-to-def/hover pointed at the whole constructor: every constructor-promoted property (public readonly int $x) got the whole __construct(...) member span as its location instead of the param’s own span, so go-to-definition/hover from $this->x jumped to the constructor and was indistinguishable from any sibling promoted property.
  • __invoke via $obj(...) didn’t record a reference: analyze_function_call’s non-identifier callee branch resolved a TNamedObject receiver’s __invoke() for arity/type checking (typed_params_from_callee) but never called record_ref/record_symbol on it, unlike every other call form (method calls, static calls, function calls) — find-references and go-to-definition on __invoke missed every call site reached only via $obj(...).
  • Hover symbol missing on a use import’s own class name: check_use_decl_casing only checked case mismatches — the imported class name’s own token in the use statement (as opposed to its usage sites elsewhere in the file) had no symbol at all, so hover/go-to-definition on Bar in use App\Models\Bar; resolved nothing. Records only a symbol, not a cls: ref, since an import alone still must not count as a usage.
  • Hover symbol missing on property write targets: assign_to_target’s PropertyAccess branch never called record_symbol, unlike the read side (analyze_property_access) — hover/go-to-definition worked on $this->prop reads but not on a plain-assignment write ($this->prop = ...).
  • Hover symbol missing on attribute class names: check_attribute_list recorded a reference for #[MyAttr] (find-references/dead-code) but never a symbol, unlike every other class-name position — the same gap already fixed once for Foo::class. Threads an optional all_symbols param through check_attribute_list and its 6 public wrappers; call sites with no symbol vec in scope (interface method attributes) pass None.
  • Hover symbol missing on native type-hint class names: check_and_record_type_hint_classes recorded a reference (for find-references/dead-code) but never a symbol, unlike the identical check_type_hint used for closures/arrow-functions — so hover/go-to-definition on a class name in an ordinary function or method signature (the single most common symbol position in the codebase) resolved nothing. Threads an optional all_symbols param through all 13 call sites.
  • UndefinedProperty falsely flagged on unset(), unlike isset/empty: analyze_unset_stmt analyzed its target directly instead of through with_existence_check, unlike isset/empty/?? — so unset() on a dynamic or magic-__get-only property falsely reported UndefinedProperty where the identical isset() check on the same property does not.
  • $cls::$prop/$cls::CONST via class-string variable unresolved: analyze_static_property_access had no branch at all for a variable class receiver (only ExprKind::Identifier), so $cls::$prop fell straight to Type::mixed() with no existence/visibility check and no usage recorded — a static property reachable only this way was falsely flagged UnusedProperty. analyze_class_const_access’s variable branch only matched named_object_fqcn() (object instances), missing TClassString, so $cls::CONST via a class-string variable had the identical gap for constants. Also fixes the root cause blocking a real test of this: self::class (and static::/parent::class) assigned to a variable resolved to the literal unresolvable class-string<self> instead of the actual enclosing class, because the ::class branch returned the raw pseudo-name instead of resolving it through FlowState.
  • Generic type-args checked in a class’s own docblock, but not an interface’s or trait’s: check_class_generic_type_args (@template T of Bound/@extends Iface<Arg> checking + usage recording) only ran for ClassLike::Class — interfaces and traits declare the identical docblock shapes but were never checked, so a bound or type-arg class reachable only that way was falsely flagged UnusedClass, and an undefined one silently passed. Generalizes the function to switch on Class/Interface/Trait and wires it into both trait decl variants and analyze_interface_decl.
  • UndefinedDocblockClass/UnusedClass not checked for a method’s @param classes: free functions already got UndefinedDocblockClass plus a cls: usage reference for a docblock-only @param class (no native hint); methods never did. A class named only in a method’s @param tag was silently unchecked and, if otherwise unreferenced, falsely flagged UnusedClass. Reuses the method’s already-resolved stored param type (same source the @return check reads) rather than re-parsing the raw docblock, so @template/@psalm-type are already substituted.
  • Classes named in attribute constructor arguments never recorded a reference: attribute argument expressions were never walked by the expression analyzer at all — only the attribute’s own class name got a usage reference. A class reachable only through #[Route(Target::class)] (or an enum case like #[Route(Suit::Hearts)]) was falsely flagged UnusedClass. Now recursively records a cls: reference for every ClassConstAccess reachable from an attribute’s arguments, plus New/Array/Binary/Ternary/UnaryPrefix so a class name nested inside those forms is still found.
  • Bare string callback usage untracked for most callable-typed parameters: only 4 hardcoded builtins (array_map/array_filter/the min-arity family/Closure::fromCallable) called record_callable_string_ref. Any other callable-typed parameter — register_shutdown_function, set_error_handler, spl_autoload_register, or a user function declared callable $cb — never recorded the referenced function/method as used, falsely flagging it dead.
  • Go-to-definition missing on Foo::class: analyze_class_const_access’s ::class branch called record_ref (so the class showed up in find-references) but never record_symbol, unlike every other class-name position (new Foo, instanceof Foo, Foo::method()). A cursor on the class name inside Foo::class resolved nothing via symbol_at.
  • Trait visibility-only adaptation (as protected, no rename) silently dropped: use T { foo as protected; } (visibility change, no rename) was silently dropped by the collector — only a rename populated trait_aliases, so walk_method_with_precedence fell through to the trait’s own declared visibility. A method demoted to private/protected this way stayed callable from outside its declaring class/hierarchy with no diagnostic.
  • Closure::fromCallable('name') string argument never recorded a reference: Closure::bind was special-cased but Closure::fromCallable('helper')/Closure::fromCallable('Foo::bar') fell straight through to plain stub-based method resolution — the bare string callable argument was never resolved or recorded as a reference, unlike the identical call_user_func('name') form.
  • Anonymous class extends/implements/use targets never recorded a reference: an anonymous class’s extends/implements/use-trait targets were only run through the UndefinedClass/UndefinedTrait diagnostic checks, which never recorded a usage reference — unlike a top-level class, whose collector-based checks do. A class/interface/trait reachable only through an anonymous class’s clause was invisible to find-references and dead-code detection.
  • .= on a non-variable target skipped analysis entirely: AssignOp::Concat only handled extract_simple_var; a property/array-access target ($this->log .= 'x') skipped analyze()/assign_to_target entirely, so its reference never got recorded (false positive UnusedProperty) and its flow-tracked type went stale instead of reflecting the concatenation.
  • $obj::CONST via an object-instance variable skipped resolution: analyze_class_const_access fell to Type::mixed() for any non-identifier class receiver, so constant access through an object-instance variable ($obj::CONST) skipped existence/visibility/deprecation checks entirely and never recorded a usage reference. Mirrors the $obj::class handling already in place: derives candidate FQCNs from the receiver’s inferred type and runs the same lookup.
  • Dynamic first-class-callable methods falsely flagged unused: $obj->$name(...) and Foo::$name(...) (first-class-callable syntax with a dynamic method name) never called record_dynamic_member_access, unlike the identical $obj->$name()/Foo::$name() ordinary dynamic call — a private method reachable only through the FCC form was falsely flagged UnusedMethod.
  • String-literal callbacks to array_map/usort/etc. never recorded a reference: array_map/array_filter/usort/uasort/uksort/array_walk/array_walk_recursive/array_reduce resolved a bare string callback only to extract its arity, never recording a reference — a function/method reachable only this way was falsely flagged UnusedFunction/UnusedMethod.
  • $this inside a free-standing closure/arrow function falsely flagged InvalidScope: a closure declared outside any class can legitimately reference $this if it’s later rebound to an object via Closure::bind()/bindTo()/call() — a common macro/PHPUnit-style idiom. $this was only seeded into a closure’s flow-state when it was lexically inside a method; non-static closures and arrow functions now seed $this as a generic object instead of leaving it undefined.
  • View-template path detection missed mixed path separators: is_view_template_path matched only pure /resources/views/ or \resources\views\ substrings, so it missed paths mixing both separators — which PathBuf::join produces on Windows when the joined-in component already contains forward slashes — silently suppressing no diagnostics for such paths and failing fixture tests on windows-latest CI. Detection now splits on either separator instead of substring-matching.
  • Nested @psalm-type/@phpstan-type aliases only expanded one level deep: an alias whose body referenced another same-file alias (@psalm-type UserId = Id where Id is itself an alias) resolved to the unexpanded alias name instead of its final type. Alias expansion is now re-run to a fixpoint, bounded so a cyclic alias definition converges to a stable self-reference instead of looping.
  • Single-file symbol_at missed chained-call cursor positions: FileAnalysis::symbol_at — the per-file/open-document query path used by editor integrations for hover, go-to-definition, and completion — only matched a byte offset against a symbol’s identifier span, so a cursor sitting in a chained call’s gap (e.g. right after -> following $f->bar()->) resolved to nothing. BatchAnalysis::symbol_at already had a fallback to the call’s full expression span for exactly this case; the single-file path had fallen out of sync with it and now uses the same fallback.
  • TraitConstantAccessedDirectly (MIR0012): directly accessing a trait’s constant (SomeTrait::CONST) is now flagged. A trait is never a valid constant-access target — this is a hard PHP fatal regardless of whether the constant exists — but it was previously resolved exactly like a normal class constant fetch.
  • UndefinedTraitAliasMethod (MIR0013): a trait alias naming no method any used trait declares (use A { A::missing as alias; }, or an unqualified alias with no match) is now validated. This is a PHP fatal at class-declaration time; the sibling “trait doesn’t exist” and “insteadof-excluded trait” checks already covered adjacent cases but not this one.
  • Breaking: mir-codebase’s storage module is renamed to definitions, and FnParam is renamed to DeclaredParam — it collided in name with the unrelated mir_types::atomic::FnParam, forcing call sites using both to alias one locally. Update mir_codebase::storage::* imports to mir_codebase::definitions::*, and FnParam to DeclaredParam.
  • Breaking: Issue’s impl fmt::Display is removed from mir-issues; colored text rendering moved to mir-cli as format_issue, alongside the crate’s other renderers (junit, sarif). Library consumers formatting an Issue via {}/to_string() need their own formatter.
  • Unbounded memory growth / OOM on a corrupted or stale disk cache: bincode’s plain deserialize_from()/deserialize() has no allocation limit, so a bit-flipped or stale cache.bin/stub-cache entry could desync the length-prefixed decoding and attempt to allocate a garbage multi-gigabyte collection before ever returning an Err — reproduced locally as a single mismatched stub-cache entry driving one process to a 110GB RSS footprint and a SIGKILL. Every disk-backed bincode read is now bounded to the entry’s own byte length, so a format mismatch fails fast as a cache miss instead of paging the machine to death.
  • Reference-index key collisions between same-named members: Foo::bar as a property, a method, and a class constant shared the identical unprefixed reference-index key, so references_to() merged their locations together and dead-code detection could hide a truly-dead property behind a same-named method’s usage. Keys are now prefixed by kind (cls:/fn:/meth:/prop:/cnst:/gcnst:).
  • First-class callables falsely flagged UnusedMethod/UnusedFunction: $this->method(...), self::method(...)/Class::method(...), and func(...) never recorded a reference to the callee. The static-method form also never checked class existence, so UndefinedClass::baz(...) silently produced a generic callable instead of reporting UndefinedClass like the equivalent direct-call form does.
  • Anonymous classes’ extends/implements/use targets were never validated: anonymous classes aren’t collected into the codebase’s class definitions, so new class extends Missing {}, new class implements Missing {}, and a nonexistent trait used inside one all silently passed. They now get the same UndefinedClass/UndefinedTrait checks a named class does.
  • Array-callable literals falsely flagged UnusedMethod/UnusedClass: [$this, 'method'], ['ClassName', 'method'], and [Foo::class, 'method'] never resolved or recorded a reference for the named method, and even the string-literal receiver form never recorded a reference for the class itself. All three receiver shapes (string literal, ::class, object type) are now handled.
  • Dynamic member access falsely flagged UnusedMethod/UnusedProperty: $obj->$name, $obj->$name(), and Class::$$name resolve their target at runtime, so a private member reached only this way — the common companion to __get/__set-style patterns — is now exempted once any dynamic access on the class is seen anywhere. The dynamic-target expressions are also now analyzed themselves, closing a matching spurious UnusedVariable.
  • UndefinedDocblockClass not checked for a method’s own @return type: only free functions got this check; /** @return UndefinedClass */ on a method silently passed.
  • Enum case value expressions never analyzed: the enum-analysis loop only walked methods, so case Active = SomeClass::VALUE; was never checked by the expression analyzer — a real PHP fatal on first touch of the enum went completely unflagged.
  • Trait-body $this access credited to the wrong side: a trait body’s $this is typed as the trait itself, so $this->helper()/$this->secret inside the trait — satisfied by whatever class ends up using it — recorded no reference at all. A private method/property supplied only for a trait to call was falsely flagged UnusedMethod/UnusedProperty on the composing class.
  • Qualified class names and Pass-1 type hints resolved against use imports case-sensitively: a qualified name’s leading segment (e.g. deep\Service after use MyApp\Deep;) was matched exact-case only, producing a spurious UndefinedClass; param/return/property types stored at Pass-1 collection time had the identical gap.
  • use function/use const aliases could shadow class-name resolution: these aliases were stored in the same map class-name resolution consults, so an unrelated class/type-hint reference sharing that short name could incorrectly resolve to the function/constant’s FQN instead of falling back to the current namespace. Fixing this also surfaced a deeper bug: a class/interface/trait/enum’s own FQCN was computed by running its short name through that same alias-resolving lookup instead of plain namespace concatenation, so a declaration whose short name matched such an alias registered under the wrong FQCN entirely.
  • call_user_func('Class::method') untracked, and call_user_func('name') never resolved: the Class::method string form was never parsed at all, and the plain bare-name form prepended a literal \ before the function-index lookup even though the index is never keyed with a leading backslash — so every call_user_func() reference silently failed to record, regardless of shape.
  • Attribute usages (#[MyAttr(...)]) never recorded as class references: attribute classes were validated (existence, target mask, repeatability) but never fed into the reference index, falsely flagging a class used only via attribute annotation as UnusedClass. Enum declarations and their cases didn’t run attribute validation at all.
  • @param/@return/@var/@throws docblock-only class types never recorded as references: local @var, property @var, function/method @throws, and @param/@return tags existence-checked the named class but never recorded it as used, falsely flagging a class named only in a docblock tag as UnusedClass.
  • @mixin/@property/@method/@psalm-import-type/@phpstan-import-type docblock tags never validated or tracked: these class-level magic tags don’t correspond to a native AST member, so they were skipped entirely — a nonexistent class named in one passed silently, and a class referenced only through one was falsely flagged UnusedClass. Fixing @property/@method also surfaced a namespace-qualification gap: their types resolved through a path that deliberately leaves bare class names unqualified, so @property Foo $x in a namespaced file stored the literal name Foo instead of the real FQCN.
  • Class names inside generic type-argument lists never validated or tracked: @extends Base<Arg>, @implements Iface<Arg>, and a class’s own @template T of Bound only had their outer name checked, not nested type arguments (including nested lists like Box<Wrapper<Foo>>) — a typo’d type arg passed silently, and a class named only inside one was falsely flagged UnusedClass.
  • Class usage from reflection-like builtins under-recorded: class_alias(); class_implements()/class_parents()/class_uses()/get_class_methods(); and class_exists()/interface_exists()/trait_exists()/enum_exists()/is_a()/is_subclass_of()/method_exists() all take a class name as a runtime string, but none recorded it as a reference — falsely flagging classes reflected on only this way as UnusedClass.
  • extends/implements/trait use never recorded a class reference: existence was validated but no reference was recorded, so references_to()/find-usages silently missed every subclass, implementor, or trait-user of a class.
  • Caught exception types and static property access never recorded a ResolvedSymbol: hover/go-to-definition silently did nothing for a catch (SomeException $e) type or Foo::$bar/self::$bar/parent::$bar/static::$bar access, unlike instance $obj->prop access.
  • --threads sizing failures silently swallowed: every other config-failure path in the CLI printed a diagnostic; a --threads value rejected by rayon (e.g. the global pool already built) ran silently with the default thread count instead.
  • Type shrunk from 176 to 96 bytes (Atomic 80 → 40) by boxing the TKeyedArray property map — the rare shape variant no longer sets the size every scalar type pays for, nearly halving the copy volume of type clones across the analyzer.
  • Warm runs no longer deep-clone cache entries to test freshness, hash each source file three times, or clone the whole entry map when persisting the cache; the content-changed pass reuses the pass-1 digest.
  • Structural subtype checks compare atomics directly instead of allocating temporary single-atomic unions in the O(n×m) union pair loops; generic inference uses FxHashSet for template-name lookups.
  • Flow-state assignment tracking and diverging-branch merges no longer clone write-location collections to satisfy the borrow checker.
  • Type display renders straight into the formatter (no per-element String allocations); text and GitHub Actions issue output is batched through one buffered writer instead of a syscall per line.
  • Atomic shrunk further from 40 to 32 bytes (Type 96 → 80) by boxing the TClosure/TConditional payloads; a size regression test now guards both bounds.
  • Class member lookup no longer scans every method with eq_ignore_ascii_case — keys are lowercase-normalized at collection time, so find_method_in_class is a single hashed get. Member maps, the global Type interner, and the lowercase-name cache all moved from SipHash to FxHash.
  • Cache hits Arc-share the stored issues and reference locations instead of deep-cloning them under the global cache lock, which serialized the parallel body pass on warm runs; reference replay reuses interned Arc<str> symbol keys instead of allocating each symbol string twice per run.
  • Variable reads (the hottest expression kind) intern their name once instead of four times per read, and no longer allocate a normalized copy of the file path per read for the view-template check.
  • ResolvedSymbol recording — a deep Type clone per reference — is skipped entirely in walks whose symbol buffer is discarded: the CLI batch pass and pure inference walks.
  • Keyed-array (shape) hashing combines per-entry hashes commutatively instead of allocating and sorting a Vec on every hash; substitute_templates returns a plain clone when no atomic can reference a template.
  • The unused-suppression pass buckets issues by file once instead of scanning (and cloning) the whole issue list per file with named suppressions.
  • reanalyze_files_cancellable: re-analyzes a caller-supplied set of files instead of the edited file’s transitive dependents. Matches the rust-analyzer LSP model — the host passes the files it publishes diagnostics for (its open editors), so per-edit cost is O(open files) instead of O(all-ingested-files), independent of workspace size. reanalyze_dependents_cancellable keeps its existing behavior; both now share the same warm-up / parallel-analyze / ref-loc-commit implementation.
  • Write-path warm-up and an opt-out of the legacy reference index for LSP hosts: ingest_file_prepared now runs a file’s Phase-1 warm-up (resolve + lazy-load its direct class references) at write time, so subsequent reference lookups and re-analysis reads find every candidate already prepared. without_reference_index() lets a host stop maintaining the imperative RefIndex on the incremental paths entirely for sessions that read references exclusively through the memoized references_to_in_files path, cutting a lock acquisition per edit.
  • Unbounded memory growth in long editing sessions: the FQN-keyed infer_scope/infer_function memo tables now carry an LRU bound (4096, matching collect_file_definitions) instead of growing forever as renames mint new memo keys. The process-global lowercase-Name cache now clears itself past 65,536 entries instead of growing unbounded across a rename storm.
  • A wedged editing suite under concurrent salsa writes: class_issues took a database snapshot and then re-entered the session lock per file to read sources, which could deadlock against a concurrent writer. Sources are now read through the snapshot already in hand — also one lock acquisition per call instead of one per file.
  • Foreach and ArrayAccess item types: foreach over a Generator, a class implementing Iterator/IteratorAggregate (via its own @implements type args or current()/getIterator()’s resolved return types), or a receiver whose own static type is Iterator/IteratorAggregate/Traversable now infers real key/value types instead of always falling back to mixed/mixed. $obj[$idx] on an ArrayAccess-implementing receiver now resolves the value type from an @implements ArrayAccess<TKey, TValue> annotation or offsetGet()’s return type, and skips the plain-array “must be an array-key” offset check (e.g. SPL’s object-keyed WeakMap no longer trips InvalidArrayOffset).
  • Narrowing on shape/array keys: array_key_exists() now clears optional/null on a key that’s already declared but optional or nullable, matching isset(). isset($a['x']['y']) narrows every level of a nested access instead of bailing out at the first ArrayAccess base, and no longer misfires PossiblyNullArrayAccess on its own condition expression. !empty($arr['key'])/empty($arr['key']) now narrow the key’s own value type (truthy/falsy) the same way isset()/array_key_exists() already do, including dropping closed-shape union arms that can’t satisfy !empty().
  • Property and static-property narrowing: ??= on a property or static-property target now updates the flow-state type via the same path a plain assignment uses, instead of leaving a stale nullable type behind; $x ??= 'y' on an undefined $x now narrows to the RHS type instead of widening to mixed. instanceof now narrows self::$prop/static::$prop/Class::$prop, not just instance properties. $this->prop === EnumCase now narrows the property (previously only plain variables were recognized), so a guarded match on that property is no longer falsely flagged as missing the just-proven case. Readonly property narrowing now survives into closures and arrow functions defined after the guard, since a readonly property can’t change after construction.
  • Nullsafe and disjunct narrowing: $b?->value !== null (and === null) now recognizes nullsafe (?->) property access, narrowing both the property and, on the non-null branch, the receiver itself. $x instanceof A || is_string($x) and other mixed instanceof/type-check-function OR-disjuncts now narrow by unioning each disjunct’s independent result, instead of narrowing nothing when the disjuncts aren’t all the same kind. !isset($x) || RHS no longer lets a narrowing produced by evaluating RHS (e.g. an instanceof on some other variable) leak into the merged true-branch on the “$x unset” path, where it never held.
  • in_array(): a cross-category needle (e.g. an int|string against an all-int haystack) is no longer narrowed down to the haystack’s literal union unless the 3rd (strict) argument is truthy, since PHP’s default loose comparison lets values outside that union match. Same-category haystacks (all-string or all-int) are still narrowed without requiring strict.
  • is_numeric(): the truthy branch now narrows mixed/scalar inputs to int|float|numeric-string, matching how is_string()/is_int()/etc. already narrow.
  • Assert annotations: @psalm-assert/@phpstan-assert (and their -if-true/-if-false variants) now recognize the negated !Type form (!null, !Foo), subtracting the asserted type instead of parsing !Type as a bogus unrelated type and overwriting the variable with it.
  • Generics — template resolution: @template T = Default is now parsed and used as the fallback for an unbound T, instead of always falling back to mixed. Template params are now bound by matching each call argument to the parameter it actually binds to (honoring named-argument reordering) rather than by syntactic position, fixing both spurious InvalidTemplateParam and silently swapped inferred types on calls using named arguments. InvalidTemplateParam now reports the resolved bound (with other template params already substituted in) instead of the raw, unsubstituted one.
  • Generics — bound checking and narrowing: @template T of static is now checked against the call site’s actual late-static-bound receiver instead of the class that declares the template, so a subclass call can no longer be satisfied by a bare instance of the declaring class. A first-class-callable (Foo::make(...), $obj->method(...), process(...)) now bound-checks its own template params against their declared bound when built, since calling through the resulting closure value bypasses the normal per-call inference entirely. A method’s own callable(T): R-shaped parameter no longer corrupts the class’s own T binding when the method declares an unrelated same-named template. instanceof-to-subclass narrowing now projects the receiver’s own bound type params onto the narrowed subclass instead of discarding them (e.g. Box<int> narrowed by instanceof IntBox keeps int, so IntBox’s own @return T methods resolve correctly).
  • Loop analysis: the fixed-point widening pass used to converge on loop variable types no longer leaks diagnostics from its earlier, unstabilized passes — only the final, converged pass’s diagnostics are kept, fixing false ImpossibleIdenticalComparison/RedundantCondition reports on loop counters and toggle flags that only reach their real value on a later iteration.
  • PropertyTypeRedeclarationMismatch: a redeclared typed property is now compared by its native type hint alone, matching the PHP runtime rule; a differing @var docblock refinement (e.g. a narrower array value type) on an otherwise-identical native hint is no longer falsely flagged.
  • Reference-index bookkeeping: sessions built with without_reference_index() no longer read from the (intentionally empty) RefIndex during reverse-dependency cache upkeep on every edit.
  • Reference-index key collisions between a method, property, and class constant of the same name: Foo::bar as both a property and a method shared one reference-index entry, so references_to merged their locations together and a truly-dead property could hide behind a same-named method’s usage — a false negative in UnusedProperty. Reference-index keys are now kind-prefixed (cls:/fn:/meth:/prop:/cnst:/gcnst:).
  • First-class callables ($this->method(...), self::method(...)/Class::method(...), func(...)) no longer record a reference: a private method/function used only by taking it as a first-class callable was falsely reported UnusedMethod/UnusedFunction. A static-method first-class callable on an undefined class (MissingClass::baz(...)) also silently produced a generic callable instead of reporting UndefinedClass, unlike the equivalent direct-call form.
  • Anonymous classes never validated extends/implements/use: new class extends Missing {}, new class implements Missing {}, and a use of a nonexistent trait inside an anonymous class body reported nothing, because anonymous classes (unlike named ones) are never collected into the codebase’s class definitions and nothing else checked their declaration header. They now get the same UndefinedClass/UndefinedTrait checks a named class does, including respecting class_exists/interface_exists/trait_exists guards.
  • The [$this, 'method'] / ['ClassName', 'method'] array-callable literal never recorded a reference: a private method reachable only this way (directly invoked, or passed to call_user_func/Closure::fromCallable) was falsely reported UnusedMethod.
  • Dynamic member access ($obj->$name, $obj->$name(), Class::$$name) never marked its class as dynamically accessed: a private method/property reachable only through a variable-named access elsewhere on the same class — the common companion to __get/__set-style patterns — was falsely reported UnusedMethod/UnusedProperty, since the exact member touched can’t be known statically. Such a class’s private members are no longer flagged at all once any dynamic access on it is seen anywhere in the codebase. The dynamic-target expression itself (Class::$$name’s class, and the method-name expression in Class::$method()) is now also analyzed, so a variable used only there no longer triggers a spurious UnusedVariable.
  • UndefinedDocblockClass never checked a method’s own @return docblock type: only free functions got this check, so /** @return UndefinedClass */ on a method silently passed. Reuses the method’s already-resolved (template-substituted, alias-expanded) return type rather than re-parsing the docblock, so @template/@psalm-type edge cases that already work for the native-hint path aren’t reopened.
  • Enum case value expressions were never analyzed at all: the enum-analysis loop matched only EnumMemberKind::Method, so case Active = UndefinedClass::VALUE; (a real PHP fatal on first touch of the enum) went completely unflagged. Each case’s value expression is now analyzed against the enum itself.
  • TraitConstantAccessedDirectly (MIR0012): SomeTrait::CONST was treated exactly like a normal class constant fetch — accessing a trait’s constant directly (rather than through a class that uses it) is a hard PHP fatal error regardless of whether the constant exists, since a trait is never a valid constant-access target.
  • UndefinedTraitAliasMethod (MIR0013): a trait use alias (use A { A::missing as alias; }, or an unqualified missing as alias; naming no method any used trait declares) was never validated — a PHP fatal error at class-declaration time that previously passed silently.
  • A private method/property called only from a trait’s own method body was falsely reported unused: a trait body’s $this is typed as the trait itself, so $this->helper()/$this->secret inside the trait (satisfied by whatever class ends up useing it) never recorded a reference against the composing class’s own private member. Each such trait-body access now records a per-trait marker that DeadCodeAnalyzer credits to any class using that trait.
  • @psalm-self-out / @phpstan-self-out on methods: lets a method declare how the receiver’s type changes after the call returns (e.g. a builder narrowing itself as it’s configured). Resolved through the same self/static/template machinery as @if-this-is and @return, applied through parent::/self::/static:: calls, unioned correctly across a union receiver, and inherited via @inheritDoc.
  • Generator<K,V,S,R> inferred from a function’s, method’s, or closure’s own yields: a generator with no return-type declaration previously inferred void/mixed from its return statements alone. Each yield/yield from now contributes to an inferred Generator<...> type; an explicit @return or native type hint still wins.
  • interface-string / interface-string<T> as a real pseudo-type: previously silently misparsed as a reference to a literal class named “interface-string”. Every interface-string is a valid class-string, bound-checked against the codebase’s inheritance graph, and excluded from new $x() targets (an interface name can never be instantiated). Adds NotAnInterface (MIR0228) for a string naming a real class/trait that isn’t an interface.
  • int-mask-of<self::*> / int-mask-of<static::*> resolve against the declaring class’s own int constants, including bit-shift/bitwise-expression constants (1 << 0) and enum/interface constants, instead of always falling back to plain int.
  • @psalm-template/@phpstan-template aliases on classes and methods, matching the prefixed-alias support @param/@return/@assert/@if-this-is/@self-out already had.
  • @psalm-var/@psalm-pure/@psalm-readonly aliases, recognized alongside the existing @psalm-template alias support — real-world vendored code (e.g. league/commonmark, nikic/php-parser) uses these prefixed forms exclusively.
  • Negated conditional docblock types (($x is not T ? A : B)), PHPStan/Psalm’s documented sugar for swapping the true/false branches.
  • empty, pure-callable/pure-Closure, object{...}, and class-string-map<T[, V]> pseudo-types are now parsed instead of silently misparsed as bogus named classes.
  • ReadonlyPropertyRedeclarationMismatch (MIR0714): a child class redeclaring a property and flipping its native readonly-ness in either direction is a PHP fatal error; only visibility and type invariance were checked before.
  • StaticPropertyRedeclarationMismatch (MIR0715): redeclaring a property with a different static-ness (static -> instance or vice versa) than its parent is a PHP fatal error, checked the same way method static/instance mismatches already were.
  • ImpureStaticPropertyAssignment (MIR1706): writing a static property (self::$count = ...) inside a @pure function is always impure, unlike an instance property write, which is only impure through a specific parameter/captured receiver.
  • DivisionByZero (MIR0229) for a literal-zero divisor on /, %, and intdiv(), alongside the existing possibly-null-operand check.
  • DuplicateArrayKey (MIR0303) for a repeated key in an array literal (['a' => 1, 'a' => 2], or an explicit int key colliding with an auto-incremented position), almost always a copy-paste mistake.
  • UnreachableCatch (MIR1508) for a catch clause whose type is a subtype of (or identical to) one already caught by an earlier clause on the same try.
  • Trait use on enums: enums using traits now resolve trait methods through the ancestor chain like classes do, and a trait declaring a non-static property is rejected (enums can’t carry state beyond their cases). Readonly classes using a trait with a non-readonly property are also now rejected, matching the equivalent PHP fatal error.
  • Match/switch/instanceof narrowing: comma-separated match(true) conditions and switch(true)/plain switch fallthrough bodies are now narrowed as the OR/union of every condition or label that can reach them, instead of collapsing to the last one (or the bare declared type). $x instanceof A && $x instanceof B on two unrelated interfaces now narrows to A&B instead of discarding A; a provably-impossible double instanceof now correctly propagates as an empty (unreachable) type instead of masking RedundantCondition. Scalar type-check disjuncts (is_int($x) || is_string($x)) are now unioned the same way instanceof disjuncts already were. EnumName::CaseName narrowing is now recognized from its real ClassConstAccess AST shape (it was previously unreachable). $x is now narrowed on get_class($x) === Foo::class, matching the existing === 'Foo' string-literal form. An intersection union member no longer gets duplicated across OR-instanceof disjuncts, and a prior instanceof narrowing is no longer dropped by a second, unrelated instanceof check.
  • Generics — binding & inference: template bindings now infer through an argument’s own inheritance chain, a plain (non-redeclaring) subclass’s implicit template slot, and a class’s own @implements/@extends type args (including through an interface’s own @extends chain, previously untracked entirely). A receiver’s own type params now propagate through : static return types, into property types, into first-class-callable closures, and into a static factory’s class-level template. @template-covariant/-contravariant is now honored across inheritance chains, not just between two instantiations of the same class.
  • Generics — bound checking: class-level @template T of Bound is now enforced on new, on a static method’s own bound, and on @implements/@extends type args against the target’s declared bound. A template fully explained by a union alternative (T|null called with null) is no longer bound-checked against T’s own bound. Overrides now check against a concretely-bound class template on both the parent and child side, and a duplicate @template name declaration is checked only against its first bound.
  • Generics — misc narrowing/parsing: a bare template param now survives is_*(), truthy/falsy, and instanceof narrowing (narrowing to T&Class instead of being discarded via a mixed conflation), including through property-access narrowing. @param-out, self/static, and templates are now substituted correctly across the function, method, and static-call @param-out write-back paths. array<K,V>/list<T> template params now bind across list<->array shape mismatches. A template used inside a Closure(...)/callable(...) type now resolves instead of staying an unresolved named type.
  • Typed-callable arguments: a union of closures with different arities, @return/@param docblocks on arrow functions, and parameter types themselves (not just counts) are now checked. False positives fixed for float->int coercion, closures with a default parameter, and templates nested inside a container/intersection/generic-argument/closure-signature type or shadowed by a same-named real class.
  • Array shapes: a union of shapes (array{a: int}|array{a: string}) now merges a key access across every arm instead of returning only the first match; a write to one key of a shape no longer widens every other key to array<K,V>; a 3+ level chained write ($a['x']['y']['z'] = $v) no longer transposes the two innermost keys; a nested key write now updates just that property instead of collapsing the whole outer shape. isset()/array_key_exists() on a union of closed shapes now excludes arms that can never satisfy the check, and narrows the matched key’s own value (stripping null) instead of only the base variable. An optional shape key (array{b?: string}) now reads as nullable. Numeric string array keys ("0", "42") are canonicalized to int keys, matching PHP runtime semantics. Destructuring (['a' => $a] = $arr, list($a, $b) = $arr, foreach ($arr as [$a, $b])) now resolves each target’s real type from a shape source instead of falling back to mixed. Spread elements in array literals ([...$x, ...$y]) now merge the source’s key/value types instead of collapsing to array<mixed, mixed>.
  • Purity & taint tracking: impure static method calls, closures, and arrow functions inside @pure/@psalm-immutable/@psalm-external-mutation-free scopes are now checked (purity/taint scope previously wasn’t propagated into closure or arrow-function bodies). $GLOBALS[...] access inside a @pure function is now flagged like global $x; already was. Taint tracking now covers (int)/(float)/(bool) casts as sanitizing, and propagates through match expression arms, array literal elements, and single-hop instance property access.
  • Control flow: finally block variable reassignments now propagate to code after the try statement. break N now targets the loop/switch N levels out instead of always the innermost one. Variable assignments inside match/ternary arms now propagate out of the arm. Named arguments are now honored when pre-marking by-ref out-parameters (previously assumed positional binding). A sole spread call argument (f(...$pair)) now checks every parameter individually instead of merging everything into the first. ??/?: fallback expressions are now analyzed as conditionally executed, not unconditional writes. while/for/do-while loop conditions are now checked for docblock contradictions and RedundantCondition, and the post-loop state is now narrowed by the negated loop condition.
  • Overrides: param-side override checks (required-count, fewer-params, byref, narrowing) now check every ancestor instead of only the first; insteadof/alias trait precedence is now honored (an excluded trait no longer counts as an ancestor, and an alias-only method is now checked); enum methods implementing an interface now have their full signature checked, not just their name; redeclared property types are now compared structurally instead of by union atom order (int|null vs null|int).
  • Docblock/native type interplay: a @param/@return docblock type that partially conflicts with the native hint no longer widens the body type with the incompatible atom; a provably-impossible @var narrowing is now flagged as DocblockTypeContradiction; a bare enum type is now expanded to its full case set before excluding a negated case, so exhaustive matches over the remainder are checked correctly.
  • Misc analyzer fixes: unset($arr['key']) now narrows the tracked shape instead of leaving a stale type; static $x = <expr>; now analyzes its initializer instead of assuming mixed; a static property read now resolves to its declared type instead of always mixed; interface and enum class constants now resolve their real type instead of always mixed; match() exhaustiveness now extends to plain scalar and bool subjects (excluding the match(true)/match(false) chained-condition idiom); array_map/array_keys now recognize array-literal list-ness; class-string/interface-string relatedness is now applied consistently across narrowing, return-type checking, and template bound checks, and every literal-string branch of a union argument is validated, not just the first.
  • Diagnostic spans: function-name span lookup no longer collapses to a synthesized 1-byte span when a long attribute list or doc comment pushes the name out of a fixed search window; a shared column-clamp helper no longer widens a span that crosses multiple lines, which could report an end column past the actual content of the last line.
  • Bumped crossbeam-epoch to 0.9.20 to resolve RUSTSEC-2026-0204 (invalid pointer dereference in fmt::Pointer for Atomic/Shared).
  • Ran cargo update to bring all crates within their existing semver ranges up to date and deduplicate several transitive dependencies (hashbrown, wit-bindgen, log, semver, and others).
  • Process abort (SIGABRT) under concurrent workspace indexing: Fetching the workspace revision epoch (index_generation) and deriving a file’s defined symbols during ingest_file ran salsa queries on the shared, non-snapshot database handle. Two threads doing so at once raced its single thread-local query stack, tripping a debug-assertion unreachable_unchecked that aborted the whole process (in release builds it would silently corrupt state). The revision epoch is now read from an off-salsa atomic mirror, and ingest_file derives its symbol set from the FileDefinitions it already computed — neither touches salsa on the shared handle.
  • Warm-up skip cache for repeated reference queries: references_to_in_files and reanalyze_dependents no longer re-run the serial parse + AST warm-up walk for a file whose lazy-load state hasn’t changed since the last prepare. A per-file (text, generation) entry lets a repeat query against unchanged text skip straight to the parallel analysis phase; a text edit or a declaration-level invalidation (invalidate_file, symbol deletions) invalidates the entry so it re-runs. references_to_in_files_cancellable adds a cancellable variant polled at Phase-1 file boundaries and between Phase-2 retries, so a caller under a sustained write stream can abandon a stale request instead of spinning in the salsa::Cancelled retry loop.
  • @psalm-type / @phpstan-type local type aliases on functions: @psalm-type Alias = ... and @phpstan-type Alias = ... docblock tags on standalone functions are now parsed into ParsedDocblock.type_aliases and resolved locally within the function body. @psalm-import-type Alias from ClassName is also supported for importing class-level aliases into a function’s scope.
  • @psalm-mutation-free per-method immutability enforcement (P5-b): Methods annotated with @psalm-mutation-free, @phpstan-mutation-free, or the short form @mutation-free are now enforced: any $this->prop = … inside such a method emits ImmutablePropertyModification (MIR1705, Warning). Applies to individual methods without requiring @psalm-immutable on the whole class. Constructors are exempt. MethodDef.is_mutation_free stored; stub cache FORMAT_VERSION bumped 7→8.
  • ImpureMethodCall in immutable / mutation-free contexts (P5-c): Calling a non-mutation-free $this method inside a @psalm-immutable class method or a @psalm-mutation-free method now emits ImpureMethodCall (MIR1701, Warning). Calls to @pure or @mutation-free methods are exempt. ResolvedMethod gains is_pure and is_mutation_free fields; static methods and constructor calls are always exempt.
  • @psalm-external-mutation-free method annotation (P5-d): Methods annotated with @psalm-external-mutation-free are now parsed, stored, and enforced. Inside such a method, ImpurePropertyAssignment fires for property writes to external parameter objects and ImpureMethodCall for calls to non-pure/non-mutation-free methods on those parameters. $this property writes remain permitted. MethodDef.is_external_mutation_free stored; stub cache FORMAT_VERSION bumped 8→9.
  • Enum interface contract enforcement (P6-c): Enums that implement user-defined interfaces are now checked: UnimplementedInterfaceMethod is emitted for any interface method not found in the enum’s own_methods. The full transitive interface chain (via class_ancestors_by_fqcn) is walked. Built-in PHP enum interfaces (UnitEnum, BackedEnum, IntBackedEnum, StringBackedEnum) are exempt since their methods are synthesised by the runtime. Known limit: an enum satisfying an interface via a trait may produce a false positive until trait support lands.
  • ImpossibleLooseComparison for categorically disjoint types (P1 residual): == / != between types that can never be loosely equal now emit ImpossibleLooseComparison (MIR0409, Warning). Covers: object vs null|false|int|float|string|array, array vs null|int|float|string|object, and non-empty array vs false. Conservative: open atomics (mixed, scalar, callable, template params) are never flagged.
  • ImpossibleLooseComparison for non-numeric string vs int/float (P1 residual): In PHP 8.0+, a non-numeric literal string compared loosely (== / !=) to an int or float is always false. ImpossibleLooseComparison (MIR0409, Warning) is now emitted for these cases. PHP version is checked: in PHP < 8.0 the rule is narrower — a non-zero TLiteralInt vs a non-numeric string is still impossible, but == 0 is not flagged.
  • int-mask<V1, V2, …> expands to full OR-combination literal union (P8): int-mask<1, 2, 4> is now expanded to the complete set of bitwise OR-combinations (0|1|2|3|4|5|6|7) rather than falling back to plain int. Inputs must be non-negative power-of-two i64 values; out-of-range values fall back to TInt. The expansion uses a BTreeSet<i64> for deduplication and a heuristic cap to prevent combinatorial explosion.
  • Exhaustiveness check for integer literal unions: check_match_exhaustiveness now handles match on a union of integer literals (e.g. @param 1|2|3 $n or an int-mask<…> expansion): UnhandledMatchCondition is emitted for any literal value not covered by an arm. Negative literals and default arms are handled correctly.
  • ImplicitFloatToIntCast false positive for floor/ceil/round results: Introduces Atomic::TIntegralFloat — a float subtype whose value is always whole. floor, ceil, and round (with zero precision) now return TIntegralFloat instead of TFloat. Passing TIntegralFloat to an int parameter in non-strict mode is lossless, so ImplicitFloatToIntCast no longer fires. Strict mode still emits InvalidArgument.
  • Untyped promoted constructor properties now detected: MissingPropertyType is now emitted for promoted constructor parameters without a type hint (e.g. public function __construct(public $x) {}). Previously, check_property_member only walked ClassMemberKind::Property nodes and silently skipped promoted params.
  • __unserialize exempted from DirectConstructorCall: __unserialize() is the PHP 8.0+ successor to __wakeup() and the same $this->__construct() re-initialization pattern is legitimate inside it. Added to the lifecycle method exemption alongside __wakeup and __clone.
  • @deprecated propagation to interface/trait constants; self::/static::/parent:: accesses checked: @deprecated on constants declared in interfaces or traits was silently discarded. The fix reads const_doc.deprecated the same way the class collector does. self::CONST, static::CONST, and parent::CONST accesses skipped the deprecation check; those early-return branches now emit using cca.member.span.
  • MixedPropertyFetch false positives for template-param receivers: MixedPropertyFetch is no longer emitted when the receiver is a TTemplateParam — an unconstrained template parameter is intentionally parameterised, not a case of lost type information.
  • MixedAssignment false positives for template-param variables: MixedAssignment is no longer emitted when the right-hand side resolves to a TTemplateParam. Both the foreach value binding (stmt/control_flow.rs) and direct assignment (expr/assignment.rs) sites now use Type::is_mixed_not_template() instead of Type::is_mixed().
  • PREG_OFFSET_CAPTURE flag-aware preg_match $matches shapes: When preg_match is called with PREG_OFFSET_CAPTURE, $matches[n] is now inferred as array{0: string, 1: int} rather than plain string. The flagged and unflagged code paths are modelled separately; PREG_SET_ORDER is unaffected.
  • self::/static::/parent:: class constants resolve to declared type: self::CONST, static::CONST, and parent::CONST always returned Type::mixed(), causing MixedArrayOffset and lost type information in return-type checks. collector/class.rs now stores initializer types via infer_const_value() in ConstantDef::ty; expr/objects.rs replaces the existence-only lookup with find_class_constant_in_chain and returns c.ty.clone().
  • InvalidArgument suppressed alongside ImplicitFloatToIntCast in non-strict mode: Emitting both ImplicitFloatToIntCast and InvalidArgument for the same float→int argument was a double-report. ImplicitFloatToIntCast now gates on !ea.strict_types and returns early; strict mode falls through to InvalidArgument as before.
  • $this->__construct() in __wakeup / __clone exempted from DirectConstructorCall: Calling __construct() inside __wakeup (deserialization re-initialization) or __clone (post-clone setup) is a documented PHP pattern. FlowState gains current_method_name; call/method.rs skips DirectConstructorCall when the receiver is $this, self_fqcn matches, and the enclosing method is __wakeup or __clone.
  • Depth-tracking first-match in parse_param_line: Nested generic type annotations in docblocks (e.g. @param array<string, Foo<Bar>> $x) were incorrectly truncated at the first inner >. A depth counter now ensures only the outermost closing delimiter terminates the type expression.
  • ImplicitToStringCast suppressed for objects with __toString: Objects that define __toString are no longer flagged for ImplicitToStringCast in non-strict mode when passed to a string parameter — PHP’s implicit __toString invocation is valid. InvalidArgument for objects lacking both __toString and \Stringable is unaffected; strict_types=1 remains an error.
  • AnalysisSession::subtype_files(class_fqn) and the class_subtype_files tracked query: the resolved inverse of class_ancestors_by_fqcn, returning the files that declare every transitive subclass of a class. Because subclasses are matched by resolved FQCN, qualified (extends \Ns\Base) and aliased (use Ns\Base as X; class C extends X) forms are all found. Lets a host scope a protected member’s reference search to its class hierarchy without reconstructing that hierarchy from declaration text.
  • extension_loaded() guards suppress UndefinedClass (FP-A): extension_loaded('name') calls are now tracked in FlowState (a new extension_loaded_guards field parallel to class_exists_guards). UndefinedClass is suppressed for any class reference inside the guarded block — both the direct if (extension_loaded(…)) { } form and the negative early-exit pattern if (!extension_loaded(…)) { throw; }. Guard sets are intersected across branches at merge points.
  • @param-out / @psalm-param-out out-parameter write-back (P4): @param-out, @psalm-param-out, and @phpstan-param-out docblock tags are now parsed into ParsedDocblock.out_params and stored as out_ty on FnParam. After a call the out-type is written back to the caller’s variable; premark_byref_arg_vars also prefers out_ty so fresh variables passed to out-params are pre-defined with the correct type before the call. Supported for function calls, method calls, static calls, variable callables ($fn(…), __invoke), and first-class callables — out_ty is carried through TClosure so it is not lost when a function or method is captured via foo(…), $obj->method(…), or Cls::method(…). stub_cache FORMAT_VERSION bumped to 5 to invalidate cached FnParam entries serialised without the new field.
  • First-class callable method/static-method typed as Closure (P3): $obj->method(…) and Cls::method(…) now resolve the target through resolve_method_from_db and synthesise a TClosure carrying the full parameter list and return type, matching the existing behaviour for free-function callables. self::/parent::/static:: are resolved against FlowState context; nullsafe method callables produce a nullable Closure; unknown methods fall back to TCallable without a false positive.
  • @psalm-immutable enforcement (P5): @psalm-immutable and @immutable class annotations are now parsed and propagated to ClassDef.is_immutable. Non-constructor methods of an immutable class gain FlowState.is_in_immutable_method = true; any $this->prop = … assignment inside such a method emits the new ImmutablePropertyModification diagnostic (MIR1705, Warning). Constructor bodies remain exempt; static methods are implicitly exempt; @suppress ImmutablePropertyModification works as expected.
  • Backed-enum from()/tryFrom() return types (P6b): Synthesised from()/tryFrom() methods on backed enums now return the enum type instead of mixed. from() returns EnumType; tryFrom() returns EnumType|null. UnitEnum / IntBackedEnum / StringBackedEnum are also injected into each enum’s implicit interface list at collection time. substitute_static_in_return() now recurses into TList, TNonEmptyList, TArray, and TNonEmptyArray so @return static[] from stubs resolves correctly through container types.
  • Backed-enum case value type validation (P6a): BackedEnumCaseTypeMismatch (MIR0713, Error) is emitted when a case value expression’s inferred literal type disagrees with the declared backing scalar type (e.g. enum Status: string { case Active = 1; }). The inferred literal type is stored in EnumCaseDef::value instead of mixed, enabling downstream consumers to inspect actual case value types. Non-literal case values fall back to mixed and are not flagged.
  • ImpossibleIdenticalComparison for categorically disjoint types (P1): === / !== between types that can never be strictly equal now emit ImpossibleIdenticalComparison (MIR0408, Warning). Covers cross-family comparisons (int === string, bool === null, object === array) and same-family literal disjointness (TTrue !== TFalse, TLiteralInt(5) !== TLiteralInt(6)). Open or unknown atomics (mixed, scalar, callable, template params) are conservatively treated as possibly equal.
  • @inheritdoc propagation (P7): Methods annotated with @inheritdoc or {@inheritdoc} now inherit @return, @param, @throws, and @template from the nearest ancestor that carries docblock annotations. Only mixed (or absent) child param types are replaced — concrete native type hints are preserved to avoid false positives from widening overrides. Multi-level and trait-based inheritance chains are both handled.
  • Property-type invariance across inheritance (P9): PHP 8.0+ requires that redeclared typed properties keep the same type as the parent class — a mismatch is a fatal runtime error. PropertyTypeRedeclarationMismatch (MIR0712, Error) is now checked in class/overrides.rs alongside the existing visibility check. Only native type hints are compared; docblock-only changes are not flagged. The check walks the full ancestor chain so grandchild redeclarations are caught.
  • TCallableString in is_callable() false branch (N5): callable-string is definitionally callable, but Atomic::is_callable() only matched TCallable and TClosure. The false-branch filter now correctly removes TCallableString atoms and marks the branch as diverging when that is the only type — e.g. !is_callable(callable-string $x) now emits RedundantCondition. The true branch is unchanged: TCallableString was already kept via t.is_string().
  • : never bodies that fall through: A function declared : never must throw, call exit, or otherwise diverge on every code path. return_requires_value previously exempted never alongside void/mixed; removing that exemption causes check_missing_return to emit InvalidReturnType when the body does not always diverge. Explicit return $value; and bare return; inside : never bodies remain parser-enforced PHP parse errors.
  • Composer binary self-heal on platform mismatch: The shim now loads the Composer autoloader and calls Installer::run() before exec-ing the binary. On a mismatch (e.g. a macOS-built darwin binary inside an Alpine container) it re-downloads the correct binary for the current platform instead of silently exiting 126; the marker check makes this a no-op when the binary is already correct. ldd detection now uses ldd --version 2>/dev/null: when ldd is absent the suppressed error leaves stdout empty, which is treated as musl rather than falling back to gnu — a safer default for minimal containers.
  • Cache surface firewall for dependents: CacheEntry gains a surface_hash — a BLAKE3 of the source with declared-return call bodies stripped. A body-only edit to a declared-return function or method no longer cascades re-analysis to dependents, since the only body-derived fact observable across files is the inferred return type used solely as a fallback when no return type is declared. Untyped functions and constructor bodies are kept. No-change runs also skip the reverse-dep rebuild and cache.bin rewrite.
  • O(n) RefIndex::set_file_refs: set_file_refs now deduplicates within the committed batch instead of scanning every existing location of each symbol. clear_file already removed the file’s prior tuples and other files carry distinct interned ids, so no incoming tuple can collide. Removes an O(n²) blowup when many files reference one hot symbol (e.g. an inherited base-class method); runs on every invocation, speeding up all incremental runs.
  • php_version parsing memoised project-wide: Three tracked queries (infer_scope, infer_function, collect_file_definitions_uncached) were calling PhpVersion::from_str directly instead of the memoised db_php_version query. All three now call db_php_version(db), which memoises the parse result project-wide and correctly tracks the analyze_config salsa dependency, ensuring memos are invalidated on PHP version changes.
  • AnalysisSession::references_to_in_files(symbol, files): returns every recorded reference to symbol that originates in the given file set, computed directly from memoized analyze_file queries. Unlike references_to, this analyzes files on demand (no prior ingest_file required), never mutates the shared reverse index, and is safe under concurrent background indexing — a serial warm-up phase faults in class references, then a parallel read phase retries on salsa::Cancelled.
  • ~18% faster full analysis: Three hot-path improvements land together — (1) php_ident_lowercase replaces Unicode-aware to_lowercase() with to_ascii_lowercase() at ~25 identifier call sites across collector, call, class, db, narrowing, and expr modules; (2) bytes().any(is_ascii_uppercase) replaces the inverted chars().all(!uppercase) guards, short-circuiting on the first uppercase byte; (3) db_php_version wraps PhpVersion::from_str in a #[salsa::tracked] query so the parse executes once per session rather than once per file. Measured on the Laravel benchmark at 1 thread: 6.76 s → 5.54 s (−18%, p < 0.05).
  • AnalysisSession now exposes database accessors (upsert_source_file, lookup_source_file, remove_source_file_input, with_db_mut, with_db_ref) so a host can share MirDbStorage as a single Salsa database and drive its inputs directly. last_ingested_symbols is tracked per file so rename/deletion diffs work correctly when a host updates inputs eagerly before calling ingest_file.
  • class_exists guard on interface extends: interface Foo extends GuardedIface {} after an interface_exists(…) throw-guard no longer emits UndefinedClass. Mirrors the fix already applied to class extends/implements.
  • is_callable() narrowing (N5): The true branch now preserves string and array atoms (PHP accepts function-name strings and ['Class', 'method'] arrays as valid callables) alongside callable/Closure.
  • PHP 8.3 typed class constants (N3): const int FOO = 1 declarations are now resolved to their declared type instead of always returning mixed. Accessing typed constants now produces correct InvalidArgument/ArgumentTypeCoercion diagnostics at call sites.
  • is_a() with $allow_string=true (N2): is_a($x, 'Foo', true) true branch now keeps string/class-string atoms unchanged instead of replacing them with the named object type.
  • is_subclass_of() strict semantics (N1): True branch now excludes the exact class itself — is_subclass_of($x, Foo::class) no longer keeps Foo in the true-branch type. False branch applies no narrowing, since the exact class is a valid false-branch value.
  • int-range → float coercion (N4): positive-int, negative-int, non-negative-int, and int<a,b> are now accepted as float subtypes in the per-pair subtype check, matching the existing union-level coercion table.
  • Abstract method calls via self::/parent::: self::method() and parent::method() calls on abstract methods now emit AbstractMethodCall. Only static:: is exempt (it uses LSB and resolves to the concrete subclass at runtime).
  • Crash / silent failure fixes (A1/A2/A3): Unicode type names in docblocks (e.g. Ⱥrray<…>) no longer panic on char-boundary slicing (A1). Bad installed.json or unreadable stub files now emit a mir: warning: instead of silently returning empty results (A2). mb_convert_encoding, iconv, preg_replace, preg_replace_callback, and substr_replace no longer include |false/|null in their return type when the subject argument is a string (A3).
  • int / int yields int|float: PHP’s / operator returns float when the division is inexact. The previous fallback always returned int, causing false RedundantCast on (int)($a / $b).
  • && condition assignments in array subscripts: A variable assigned inside an array subscript (e.g. $arr[$n = count($arr) - 1]) is now promoted to definitely-assigned in the true branch of a && condition.
  • int<a,b> widens to float: TIntRange is now in the float-widening list in atomic_subtype, fixing false InvalidArgument for expressions like log(strlen($s)).
  • false === $x narrowing: The symmetric false === $x and false === ($x = expr) forms now narrow the variable in the true branch, fixing InvalidPropertyAssignment FPs in normalizer_normalize / iconv guard idioms.
  • class_exists-guarded extends/implements: The optional-dependency pattern (a class_exists(…) throw-guard before a class declaration) no longer emits UndefinedClass for the guarded parent or interface name.
  • Encoding builtin return types: mb_convert_encoding, iconv, and grapheme_strlen stubs no longer include |false/|null in normal call paths, eliminating pervasive InvalidPropertyAssignment and NullableReturnStatement FPs.
  • int + bool/null coercion: $count + true, $n + null, and similar expressions now infer int instead of int|float — PHP coerces bool/null to int in arithmetic.
  • DeprecatedTrait is now reported when a trait uses another deprecated trait.
  • DeprecatedInterface is now reported when an interface extends a deprecated interface, or an enum implements one.
  • key-of<T> and value-of<T> now resolve to the real key and value types in return-type checks; valid returns are accepted without false InvalidReturnType. Float-literal docblock types (e.g. @return 3.14) now parse and accept a matching float return. Psalm pseudo-types (truthy-string, int-mask<…>, non-falsy-string) in @return no longer emit UndefinedClass.
  • Method overrides (G4/G5): Return-covariance violations in mixed object|scalar unions are now caught (e.g. widening string|Cat to string|Animal is flagged). Covariance-legal narrowing (e.g. string|Animal to string|Cat) and parameter widening are still accepted. Template @return T of Bound methods no longer emit false InvalidReturnType when the returned value satisfies the bound.
  • FP-H (@method static return type): @method static name() is now correctly parsed as a non-static method returning static, not a static modifier. Carbon-style fluent docblocks no longer cause MethodSignatureMismatch on concrete overrides in subclasses.
  • FP-J (@final docblock): Classes annotated with @final via docblock (not the PHP final keyword) no longer emit InvalidExtendClass when extended. The @final convention is an IDE hint only.
  • FP-E (trait $this access): $this->prop declared in the same trait now resolves to the declared type inside trait methods, eliminating false UndefinedProperty and mixed inference on self-contained trait properties.
  • FP-K (DatePeriod overloads): new DatePeriod('R5/…') (ISO 8601 one-argument form) no longer emits TooFewArguments. The arity minimum is computed across all declared constructor overloads.
  • FP-B (property refinement): Assigning a mixed or incompatible type to a property now clears any prior refinement, preventing stale narrowed types from producing NullableReturnStatement false positives downstream.
  • FP-M (int/bool coercion): Passing int to a float parameter no longer emits InvalidArgument — PHP implicitly coerces. Bitwise operators on bool operands no longer emit InvalidOperand — PHP coerces bool to int.
  • FP-O/N (negative type guards and nullable property throws): if (!is_string($x)) { throw …; } and if (!is_int($n)) { return; } now narrow $x/$n in the fallthrough branch. Nullable properties guarded by !== null before a throw (if ($this->ex !== null) { throw $this->ex; }) are also narrowed correctly, eliminating InvalidThrow false positives.
  • FP-I (use … as alias and #[\Override]): use Foo as Bar import aliases are now resolved before override checks. Classes that extend an aliased parent no longer emit InvalidOverride for methods that exist on the aliased class.
  • FP-L (reference assignment): $b = &$a, $ref = &$arr[0], and $ref = &$obj->prop no longer emit UnsupportedReferenceUsage. By-reference method out-parameters also define the argument variable, suppressing UndefinedVariable.
  • FP-C (preg_replace stub): preg_replace() return type corrected — |null removed from the string-subject overload. Returning preg_replace() directly from a string-returning function no longer emits NullableReturnStatement.
  • @internal scoping: @internal is now scoped to the root namespace of the declaring package. Callers in sub-namespaces of the declaring package (e.g. Symfony\Component\Console\Helper calling Symfony\Component\Console\Output::doWrite()) are no longer flagged.
  • Open-file session: The open-file pre-loader now collects extends/implements references, ensuring parent classes and implemented interfaces are loaded before analysis of the opened file.
  • extract() and variable-variable assignments: extract($arr) no longer emits UndefinedVariable for variables populated at runtime. Variable-variable assignments ($$key = …) likewise suppress UndefinedVariable for later reads.
  • Empty array + generic types: An empty array literal ([]) now satisfies a list<T>, array<K,V>, or any other generic collection type argument, including in generic wrapper classes (new Wrap([])).
  • Updated php-rs-parser, php-ast, php-lexer, and phpdoc-parser from 0.18.0 to 0.18.1.
  • Comparison-driven integer-range narrowing: <, <=, >, >=, ===, and !== against literal bounds now tighten int<a,b> ranges (and named subtypes like positive-int, non-negative-int) in each branch, narrowing to TLiteralInt on a single-point match.
  • Integer-range inference for arithmetic and built-ins: unary negate and abs(), modulo with a positive literal divisor, multiplication of non-negative ranges, bitwise-AND masks and right-shifts, intdiv() on non-negative dividends, and min()/max() over all-integer arguments now produce bounded int<min,max> results. rand(), mt_rand(), and random_int() infer their range from literal bounds.
  • Literal folding at analysis time: integer arithmetic, casts, and string concatenation (including .=) of literal operands now fold to exact literal values. strlen/mb_strlen and count() on a sealed keyed-array shape return exact literal ints; strlen/mb_strlen return int<1,max> for non-empty-string arguments.
  • non-empty-string preservation and inference across string operations: case-conversion and encoding functions, (string) casts of int/float/true, sprintf with literal format chars, number_format, str_repeat, date/gmdate/date_format, and concatenation all preserve or produce non-empty-string. str_contains/str_starts_with/str_ends_with narrow the haystack to non-empty-string in the true-branch.
  • Array element- and key-type-preserving inference for array_slice, array_map, array_merge, array_unique, array_fill, array_fill_keys, array_keys, array_reverse, array_chunk (list<list<T>>), sort/rsort/usort/shuffle, array_push/array_unshift (by-ref), array_pop/array_shift (value type), and array_key_first/array_key_last (non-null on non-empty). explode, str_split, implode, preg_split, and range() produce typed (non-empty-)list results. array_values is now @template-annotated and returns list<TValue>.
  • Collection narrowing: array_is_list, count/strlen comparisons, $arr !== [] (narrows to non-empty), truthy checks on arrays/lists (narrow to non-empty variant), and in_array($needle, [...]) (narrows to the literal union; the false-branch removes matched literals from a finite union).
  • array_search narrows its return key type from the haystack.
  • Truthy/falsy narrowing corrected across scalar types: bool narrows to the true/false literal (including on === true/=== false), string narrows the string type, int/float falsy checks narrow to the zero literal, and int ranges tighten their bounds around zero (int<min,0>, zero-inclusive ranges, single-point exclusion now marks branch divergence). !== / === on an int-range edge tightens the bound.
  • non-empty-array/non-empty-list are never falsy and a closed empty array{} is never truthy, fixing can_be_falsy/can_be_truthy for these and for TNumericString, TNonNegativeInt, and zero-inclusive TIntRange.
  • Named integer subtypes (positive-int, non-negative-int, etc.) now carry their implicit bounds through arithmetic and comparisons, intersect correctly on comparison, and have correct subtype/contradiction handling — fixing missing TNumeric/TScalar/TFloat subtype entries, DocblockTypeContradiction detection, impossible_comparison with negative literals, and RedundantCondition on always-true named-int comparisons.
  • +=/-= and ++/-- preserve integer-range bounds.
  • is_numeric and is_scalar/narrow_to_scalar now handle all string and integer subtypes (including literal strings) correctly.
  • remove_false on TBool yields TTrue (not empty), and return-type checks guard against an empty remove_false result.
  • Static-call and method-call diagnostics: PossiblyNullMethodCall is now suppressed against mixed receivers.
  • MissingThrowsDocblock is suppressed for @template T of Exception parameters.
  • TKeyedArray property keys are validated against a generic array<K,V>.
  • Foo::class expressions no longer emit UndefinedClass.
  • A PHP type hint is now preferred over a conflicting scalar @param docblock.
  • In non-strict-mode files, int/falsebool is no longer flagged as InvalidReturnType, and scalar int/floatstring is reclassified from InvalidArgument to ArgumentTypeCoercion.
  • A mixed|null argument is treated as mixed, not possibly-null.
  • The analyze_source file is now registered in the workspace index.
  • int-range sub-ranges are now correctly recognized as subtypes of containing int-ranges. positive-int is now a subtype of scalar, numeric, and int<min,max> when the range contains all positive integers.
  • list<T> subtype check for array<K,V> now verifies int <: K (accepts array-key-keyed arrays). Keyed array shapes like array{0:Child,1:Child} now satisfy list<Base> when Child extends Base.
  • do-while bodies are now known to execute at least once: variables introduced in the body are stripped of possibly_undefined / possibly_assigned after the first pass, matching PHP’s guaranteed-first-iteration semantics.
  • Property type narrowing via !== null / === null guards and direct assignment: $this->prop !== null now refines the property type in the true-branch, and $this->prop = $val records the assigned type for subsequent accesses within the same scope.
  • $this->prop instanceof ClassName now narrows the property’s type in the true-branch, preventing false InvalidArgument and TypeMismatch diagnostics on the narrowed access.
  • By-ref output parameters (e.g. preg_match’s $matches) are now promoted from possibly-assigned to definitely-assigned in the true-branch of && conditions. Assignment expressions in while conditions (e.g. while ($line = fgets($r))) stay definitely-assigned after loop-body widening.
  • @inheritDoc-annotated methods with parameter widening (contravariant-legal in PHP) no longer emit UnusedParam for parameters that are unused in the overriding body.
  • PHP ext-bz2 stubs added: bzcompress, bzdecompress, bzopen, bzread, bzwrite, bzclose, bzflush, bzerrno, bzerror, bzerrstr no longer emit UndefinedFunction.
  • Trait method aliases (use Trait { method as alias; }) are now resolved before insteadof precedence, fixing UndefinedMethod false positives on aliased method calls.
  • iterable<K,V> now expands to array<K,V>|Traversable<K,V>, forwarding the key type to both sides. Previously the key was dropped, causing false InvalidArgument diagnostics when a Traversable implementation was passed to an iterable<K,V> parameter.
  • non-empty-list<T> is now a subtype of array<K,V> and non-empty-array<K,V>.
  • String literals that cannot be class names (e.g. 'string[]') no longer trigger UndefinedClass or InvalidArgument when passed to class-string parameters. A complementary TLiteralString → TClassString subtype rule prevents the redundant InvalidArgument path.
  • array_key_exists('k', $arr) in a truthy guard now adds 'k' as a non-optional entry in every sealed TKeyedArray shape of the variable’s type, suppressing subsequent NonExistentArrayOffset diagnostics. Works for both plain variables and property accesses ($this->prop).
  • Color::{$name} (dynamic enum case / const access) no longer emits UndefinedConstant. The class sub-expression is only analyzed when it is a variable; plain identifiers are skipped, matching the existing ClassConstAccess guard.
  • int values passed to string parameters in non-strict-mode files (without declare(strict_types=1)) are no longer flagged as InvalidArgument. PHP’s coercive typing silently casts integers to strings in this context.
  • Batch analysis path (analyze_paths) now calls ensure_vendor_eager_functions(), ensuring Composer autoload.files globals (e.g. Laravel Prompts helpers: confirm, select, suggest) are indexed before body analysis. Previously, 61 spurious UndefinedFunction diagnostics were emitted on the Laravel corpus.
  • foreach ($arr as &$val) by-reference variables no longer emit UnusedVariable or dead-write diagnostics. Writes through a reference mutate the source array and are never dead.
  • Dynamic method call arguments ($obj->{$method}($arg1, $arg2)) are now analyzed so variables used only in those arguments are marked as consumed, fixing false UnusedVariable and UnusedForeachValue diagnostics.
  • Variables assigned before a try block and read only in the finally block are no longer reported as unused.
  • Union-typed arguments (e.g. Arrayable|Stringable|array|string) to matching parameters no longer emit false ImplicitToStringCast diagnostics.
  • catch (Exception $e) variables are never reported as unused, including when nested inside if/else or try/catch chains.
  • Concat-assign ($x .= "…") marks the prior write consumed before recording the new write, preventing false dead-write reports on the initial assignment.
  • Carry-forward loop variables ($prev = $item inside foreach) no longer re-arm consumed writes spuriously, while $a += $i patterns retain dead-write detection when $a is never read after the loop.
  • $var::class and $var::CONST accesses now correctly mark the variable as consumed.
  • require/include marks all in-scope variables as consumed, since the included file can read any variable in the calling scope.
  • Variables assigned before a try block, overwritten inside the try body, and read in the finally block are no longer flagged as dead writes — the pre-try write is live on the exception path.
  • UnusedVariable and UndefinedVariable diagnostics are suppressed in Blade templates (.blade.php) and PHP files under resources/views/, where variables are injected by the template engine rather than assigned in PHP.
  • method_exists($obj, 'method') guards now suppress UndefinedMethod diagnostics inside the guarded if branch, including guards on property accesses.
  • Closure objects and keyed-array callables (e.g. [object, "method"]) are now valid callable subtypes, fixing false InvalidReturnType and InvalidArgument diagnostics.
  • @internal methods called on $this (own class or via traits) no longer emit InternalMethod false positives.
  • new $classStringVar where the variable holds class-string<AbstractClass> no longer emits AbstractInstantiation — the class-string constraint guarantees a concrete subclass at the call site.
  • (int) and (float) casts on unions that contain scalar-safe atoms (string, bool, null) no longer emit InvalidCast.
  • Assignment expressions inside is_null()/is_string()/etc. guards (if (!is_null($model = $this->first(...)))) now narrow the assigned variable in the then-branch, fixing NullableReturnStatement false positives in firstOrFail-style methods.
  • iterable pseudo-type now correctly expands to array|Traversable in both the docblock parser and the AST type-hint parser. Previously it was mapped to plain array, causing InvalidArgument and InvalidReturnType false positives wherever Traversable implementations were used.
  • Absolute FQCNs in docblocks (e.g. \Carbon\CarbonImmutable) are now preserved through alias resolution, preventing mis-resolution via use imports that share a prefix.
  • Types nested inside keyed array properties (e.g. array{"class": class-string<T>}) are now properly resolved through the file’s namespace and import context.
  • preg_replace, preg_replace_callback, preg_replace_callback_array, and preg_filter now return string|null when $subject is a string and array<int,string>|null when it is an array.
  • var_export($val, true) now returns string instead of string|null.
  • AnalysisSession::class_imports(file)Vec<(alias, fqcn)> — returns the use-import alias map for a file as (short_name, fully_qualified_name) pairs. Completion handlers can use this to expand a short class name written before :: into its FQN before looking up static members, mirroring the alias expansion already performed by symbol_at + definition_of.
  • Vendor autoload.files globals (e.g. Laravel helper functions) are now lazy-loaded automatically on first analysis. Previously callers had to invoke a manual eager-index step; any consumer that omitted it received false-positive UndefinedFunction diagnostics for every call to those globals.
  • Diagnostic column numbers are now 0-based throughout, matching the LSP UTF-32 convention documented in mir_types::Location. Body-analysis diagnostics were previously emitting 1-indexed columns, inconsistent with collector-stored diagnostics (which were already 0-indexed).
  • Classes referenced only in docblock annotations (@param, @return, @var, @extends, @implements) are now pre-loaded during AST prioritization. Previously such classes were invisible to the pre-loader; method and property checks on the annotated variable would silently degrade to mixed when the class had not yet been eagerly indexed.
  • IfThisIsMismatch (MIR0902) — emitted when a method’s @if-this-is type constraint is violated at a call site. Template-aware constraint checking enables precise type narrowing for receiver type refinements.
  • DocblockTypeContradiction (MIR0406) — emitted when a comparison operator (===, <, <=, >, >=) is used with values that cannot satisfy the condition given their inferred types. Detects impossible assertions and dead code in conditionals.
  • UnevaluatedCode (MIR0407) — emitted when a switch/match statement on gettype($x) contains arms that gettype() never returns (e.g., "int" when the actual return is "integer"), or when the argument’s inferred type cannot produce those values.
  • MixedReturnStatement (MIR1212) — emitted when a function with a declared non-void return type returns a mixed value (e.g., array_pop() in a string-returning function).
  • Integer range types now tracked for count()/sizeof()int<0, max> (or int<1, max> for non-empty), strlen()/mb_strlen()int<0, max>, and arithmetic operations preserve range bounds. Comparison-driven narrowing (e.g., if ($i < count($a))) now refines loop variables to their valid index ranges.
  • array_map() and array_filter() now infer precise result element types from their callbacks instead of returning bare array.
  • Vendored Redis and Memcached phpstorm-stubs extension directories, fixing ~1,400 UndefinedClass false positives on Laravel codebases that use these PECL extensions.
  • phpstorm-stubs #[LanguageLevelTypeAware] and #[PhpStormStubsElementAvailable] attributes are now resolved against the configured target PHP version. This honors ~2,400 previously-dropped declaration sites and eliminates spurious |false returns for version-specific function signatures (e.g., explode()/pack() on PHP 8.x).
  • Filesystem and unserialize taint sinks: file_get_contents(), file_put_contents(), unserialize() and related functions now propagate taint in TaintedFilesystem and TaintedUnserialization issue kinds.
  • Symbol reference recording for static-call class name tokens, enabling go-to-definition and find-references on ClassName::method() expressions.
  • Class-level template parameters are now correctly resolved in method parameter types during generic method binding.
  • Named-object arguments now satisfy bare object parameter types via named_object_subtype checking.
  • [object, "method"] array literals are now recognized as valid callables, fixing false InvalidArgument diagnostics.
  • Docblock-only properties (declared via @property annotations) are now correctly typed as nullable and not flagged as uninitialized.
  • Property @var docblock annotations now resolve class-level template parameters, enabling precise typing for generic class properties.
  • Surplus arguments to closure calls no longer emit false TooManyArguments diagnostics when the closure arity is unknown.
  • Array-access narrowing: isset($arr[$key]) and ?? operators now narrow both the array base and key existence.
  • is_object() type guards now correctly narrow mixed to object in conditional branches.
  • unset($arr[$key]) now counts as a read of the variable, fixing false UnusedVariable diagnostics.
  • Static property reads (self::$prop) now correctly count as property uses.
  • Bare return; statements are now valid in functions with nullable or void-union return types.
  • defined() and function_exists() guards now narrow constant/function references in conditional branches.
  • By-reference closure captures (use (&$var)) now auto-create the captured variable if it doesn’t exist.
  • Variable assignments inside match arm conditions now correctly define the variable for use in the arm body.
  • self, static, parent, and $this resolution in trait bodies now correctly targets the consuming class instead of the trait.
  • new static is now allowed in abstract classes, delegating to concrete subclasses at runtime.
  • stdClass now permits dynamic property access and assignment without emitting UndefinedProperty diagnostics.
  • Fully-qualified attribute names in #[...] are now honored in attribute resolution.
  • Numeric and Resource are no longer treated as reserved class names in the parser.
  • --clear-cache now correctly targets the project-local cache directory (.mir/cache) instead of always searching the platform default cache dir.
  • Result cache now invalidates when the running binary, target PHP version, or user-configured stubs change.
  • Wrong-case checks now extend to full FQCN namespace segments, catching case mismatches in any part of the class name.
  • mir-analyzer module structure refactored for maintainability: batch.rs, class.rs, parser/docblock.rs, session.rs, and body_analysis.rs split into dedicated submodules.
  • PHP parser suite (php-rs-parser, php-ast, php-lexer, phpdoc-parser) upgraded to 0.18.0 for improved parsing robustness.
  • Large false-positive reduction on the Laravel reference corpus: the vendored Redis/Memcached stubs and version attributes support reduce UndefinedClass from 617 to 114 (an 82% reduction on the reference benchmark).
  • TypeDoesNotContainType — impossible switch case values (literal cannot intersect the switch subject type) and impossible match arm conditions (same scalar/literal intersection check) are now reported. MixedAssignment is now also emitted when a foreach value variable is bound from a mixed-typed iterable (previously this path bypassed the mixed check).
  • Purity enforcement: ImpurePropertyAssignment (MIR1700), ImpureMethodCall (MIR1701), ImpureGlobalVariable (MIR1702), ImpureStaticVariable (MIR1703) — emitted when a @pure/@psalm-mutation-free-annotated function mutates a parameter’s property, calls an impure method on a parameter, or accesses a global/static variable.
  • ImpureFunctionCall (MIR1704, Warning) — emitted when a @pure-annotated function calls a named function not itself marked @pure.
  • UnusedClass (MIR0507, Info) — final class declared but never directly referenced. Restricted to final classes to avoid false positives from subclassing or type-hint uses.
  • ArgumentTypeCoercion (MIR0225, Info) — emitted when an argument is a supertype (parent class) of the expected parameter type. Previously these calls were silently accepted.
  • PropertyTypeCoercion (MIR0226, Info) — emitted when a property assignment uses a supertype of the declared property type. Previously emitted as the higher-severity InvalidPropertyAssignment; correctly distinguished as a lower-severity coercion case.
  • TaintedLlmPrompt (MIR0804, Error) — emitted when a value derived from tainted user input reaches a parameter annotated with @taint-sink llm_prompt. Parser now recognises @taint-sink kind $param docblock tags; sink params are stored on FunctionDef/MethodDef.
  • UnusedSuppress (MIR0508, Info) — emitted when a @psalm-suppress, @suppress, or @mir-suppress annotation does not match any actual issue in the analysed file. Self-suppression (@suppress UnusedSuppress) silences its own warning.
  • UnsupportedReferenceUsage (MIR1506, Warning) — emitted when a PHP reference assignment ($b = &$x) is used.
  • NoInterfaceProperties (MIR1504, Info) — emitted when a property is read or written on an interface annotated with @seal-properties/@psalm-seal-properties but not declared via @property/@property-read/@property-write.
  • MissingConstructor (MIR1507, Info) — emitted when a concrete class has at least one non-nullable uninitialized property anywhere in its ancestor chain but defines no constructor.
  • MixedFunctionCall (MIR1211, Info) — emitted when a variable of mixed type is invoked as a function via a dynamic call expression.
  • MissingClosureReturnType (MIR1105, Info) — emitted when a closure has no native return type and no preceding @return docblock (Full mode only).
  • InvalidArrayOffset (MIR0300, Error) — emitted when an object, array, or closure is used as an array subscript key (types PHP cannot coerce to a valid array key).
  • PossiblyInvalidArrayAccess (MIR0227, Info) — emitted when a union type contains some members that support [] and some that do not.
  • DeprecatedMethod (MIR1002) — instance deprecated method calls now emit DeprecatedMethod instead of DeprecatedMethodCall, reserving DeprecatedMethodCall for static calls and __clone dispatch.
  • MixedReturnStatement (MIR1212, Info) — emitted when a function with a declared non-void, non-mixed return type returns a value that infers to mixed (e.g. array_pop() returned from a string function).
  • phpstorm-stubs #[LanguageLevelTypeAware] and #[PhpStormStubsElementAvailable] attributes are now resolved against the configured target PHP version. This correctly models ~2,400 previously-dropped declaration sites across the stub corpus and eliminates spurious |false returns for explode()/pack() on PHP 8.x (those functions throw rather than return false since 8.0).
  • Vendored Redis and Memcached phpstorm-stubs extension directories. These PECL extensions were previously excluded and had no fallback resolution path after the submodule loader was removed, causing false UndefinedClass for Redis/Memcached in Laravel codebases.
  • Result cache now invalidates when the running binary, target PHP version, or user-configured stubs change. Previously the cache keyed validity on file content hash only, leaving unchanged files serving stale diagnostics after a version upgrade, --php-version change, or stub set update.
  • --clear-cache now correctly targets the project-local cache directory ({composer_root}/.mir/cache) instead of always looking at the platform default cache dir and attempting to remove a cache.json that no longer exists (the format is cache.bin), making it a functional operation for normal project runs.
  • UnnecessaryVarAnnotation (Info) — a @var annotation on a simple assignment whose declared type exactly matches the inferred type is flagged as redundant. The comparison is exact, with no literal widening: @var string on $s = 'hello' changes the type (literal → base) and is therefore not reported. Narrowing annotations, mixed-typed right-hand sides, and non-assignment statements stay silent.
  • MismatchingDocblockReturnType / MismatchingDocblockParamType (Info) — a @return/@param docblock that contradicts the native type hint on a top-level function is now reported. Refinements never fire: the comparison uses PHP type families (with int → float coercion and callable’s string/array/object forms modeled), so e.g. literal-string/non-empty-list<…> against their base hints stay silent. Object-vs-object falls back to an inheritance-aware subtype check when every named class is known; unresolved names (templates, ::class refs, unmodeled refinement syntax) stay silent.
  • MissingReturnType / MissingParamType (Info) — top-level functions with neither a native hint nor a docblock type are now reported (previously only interface methods were checked), on all three analysis paths (per-scope salsa, batch typed, pure per-function).
  • reanalyze_dependents no longer deadlocks on workspaces with high dependent fan-out. The per-dependent warm-up (prepare_ast_for_analysis, introduced in 0.37.0) loads classes by mutating shared salsa inputs, and salsa input mutation blocks until every other database handle is released. Running the warm-up inside the parallel rayon worker meant a worker mutated the storage while sibling workers held live snapshots mid-analyze_file, so the write blocked on them forever — hanging indefinitely on high-fan-out workspaces. Warm-up now runs before the parallel read-only analyze loop, with each iteration holding only a scoped snapshot that is dropped before any input write, restoring the “no input writes while a snapshot is live” invariant. Covered by a regression test (reanalyze_dependents_lazy_load_warmup_does_not_deadlock).
  • Large false-positive reduction on the Laravel reference corpus across several diagnostic kinds (each fix ships with regression fixtures, including the negative cases):
    • Template binding: trailing variadic params now bind every remaining argument (unwrapping array<X> docblock types to their element type per argument); a class-string<T> union alternative consumes class-string arguments so a sibling bare T no longer absorbs them; method-level @template shadows a same-named class template during argument checking; and a docblock description following a @template line is no longer misparsed as a bound. Removes ~1350 FPs (6148 → 4781), dominated by Mockery intersection mocks.
    • InvalidStringClass: new $x where $x is mixed or a template param no longer fires — mixed is a Mixed* concern, and a template bound may be a class-string. Removes 501 FPs (4781 → 4280).
    • UndefinedMethod: $this->m() / static::m() inside a trait body is suppressed (the consuming class may provide the method, so traits join interfaces/abstract classes), and inaccessible protected/private calls dispatched through __call (e.g. Macroable, Mockery partial mocks) no longer error. 756 → 168.
    • UnusedVariable / UnusedForeachValue: path-accurate liveness — closure use() captures and closure/arrow-body reads consume the outer write; branch merges no longer resurrect a write consumed on one path; multiple pending write locations per variable are tracked (pre-loop and loop-body writes); and switch with a default arm no longer merges the impossible no-match path. 938 → 376 and 106 → 54.
    • UnusedVariable: an assignment in argument position (f($x = expr), ->andReturn($mock = m::mock(...))) now counts as a use across all call shapes (function, dynamic-callee, method, static, new). 376 → 243.
    • UndefinedFunction / InvalidTemplateParam: a string passed to a union param with non-callable alternatives is no longer validated as a function name (157 → 3); template bounds are not checked against bindings that still contain unresolved placeholders (self/static/parent, template params) (103 → 42); and class-string<T> binding coerces class-name-shaped string literals such as m::mock('Foo\Bar') without ::class.
    • InvalidArgument: an array passed to a callable|array|null param matches the array alternative instead of being forced into the [object, "method"] callable shape. Removes 169 FPs (618 → 449).
  • IssueKind::default_severity_for_code — reverse lookup from a stable error code (e.g. "MIR0005") to its default severity, for callers holding a bare code string (config files, suppression annotations, serialised diagnostics).
  • Property-access and method-call symbol recording now reuses the declaring class from type resolution instead of re-walking the inheritance chain, removing a redundant ancestor-chain walk per property access and per method call.
  • Closure and arrow-function parameter/return type hints (function (Foo $x) {}, fn (Foo $x) => ...) now contribute reference-index entries and ClassReference symbols, so find-references and hover cover closure usages.
  • $x instanceof Foo now records a ClassReference symbol at the class-name span, unblocking hover and the symbol_atreferences_to round-trip for instanceof sites.
  • Property references and symbols now key on the declaring class (as find_property_in_chain returns it) instead of the receiver type, fixing find-references and symbol_at for inherited properties accessed through a subtype.
  • Per-scope tracked inference queries (file_scopes, infer_scope) for granular type inference memoization at function/class declaration and file-frame scope levels.
  • Batch-mode symbol collection opt-out via BatchOptions::skip_symbols for performance optimization in batch analysis runs.
  • analyze_file now assembles results from per-scope memos instead of a single whole-file analysis walk, improving incremental re-analysis efficiency.
  • Reference locations architecture refactored: RefIndex consolidates three independent reference maps (reference_locations, file_references, symbol_referencers) into a single tracked structure.
  • Dependent re-analysis now drives through the analyze_file query for salsa-validated memoization, replacing per-file re-parsing and full re-analysis on every edit.
  • Reverse dependencies now derived from a tracked query (file_structural_deps) instead of an in-memory map, improving incremental consistency.
  • Reference-location synchronization drift eliminated by consolidating three independent maps into RefIndex.
  • MissingReturnType (MIR1201) and MissingParamType (MIR1200) — emitted for interface methods that lack @return or @param docblock annotations when not otherwise typed.
  • MixedArgument (MIR0221) and MixedAssignment (MIR0222) — emitted when a mixed-typed value is passed to a parameter expecting a concrete type, or assigned to a typed property.
  • MixedArrayAccess (MIR0223), MixedArrayOffset (MIR0224), MixedPropertyFetch (MIR0225), and MixedPropertyAssignment (MIR0226) — emitted when mixed is used in array/property access contexts.
  • MissingPropertyType (MIR1202) — emitted for untyped class and trait properties when find_dead_code is enabled.
  • ForbiddenCode (MIR1301) — detects code marked with #[Forbidden] attribute; use #[Forbidden("reason")] on methods/functions to flag uses as errors.
  • @trace docblock annotation — mark variables and expressions with /** @trace $var */ to emit an @trace informational diagnostic, aiding debugging without leaving analyzer artifacts in code. Useful for development and CI integration.
  • PossiblyInvalidArgument (MIR0205) — enhanced to flag partial type-union overlaps, not just complete mismatches. Emitted when a union contains only some valid argument types.
  • Type-checking for TClosure and __invoke method calls: generic template parameters are now resolved at call sites, enabling precise type narrowing on closure return values.
  • @no-named-arguments enforcement: methods/functions marked with this attribute now emit InvalidArgument when invoked with named arguments.
  • Duplicate declaration detection: DuplicateClass, DuplicateInterface, DuplicateTrait, DuplicateFunction, and DuplicateConstant now detect and report redeclarations across the entire codebase.
  • Psalm compatibility: all 1843 fixture tests now pass, including un-ignoring 120+ Psalm-specific test cases covering edge-case behaviors.
  • Constructor-promoted property handling: UnusedParam and UnusedVariable false positives eliminated for promoted properties accessed through property-assignment or constructor side effects.
  • if-condition variable assignment detection: variables assigned in if condition expressions (e.g., if ($x = foo())) are no longer incorrectly flagged as unused.
  • Negated instanceof guard narrowing: type refinement now correctly applies at receiver position ($obj instanceof X and !$other instanceof $this).
  • User-defined stub registration now uses Salsa Durability::HIGH, improving incremental re-analysis performance when stubs are unchanged.
  • Readonly promoted properties and compound-assignment edge cases in destructuring contexts.
  • Operand and iteration gaps now match Psalm parity across type-checking and narrowing behaviors.
  • TLiteralString subtype narrowing: numeric literal strings now correctly match TNumericString bounds.
  • Globally-qualified type hints (\Closure, \Generator, etc.) in namespaced files now resolve correctly without prepending the current namespace.
  • Generator bare return; statements no longer emit false InvalidReturnType diagnostics.
  • try-body divergence is now preserved when all catch blocks also diverge, preventing unreachable-code false positives.
  • ImplicitToStringCast suppression for classes implementing \Stringable and when argument union contains non-string arms.
  • @param docblock generic type hints now take precedence over plain array hints for promoted properties.
  • All 1843 fixture tests now pass without ignores, improving test coverage visibility and closing known gaps in Psalm parity.
  • DuplicateClass no longer fires when two classes share the same name in separate unbraced namespace blocks.
  • abs(int) now returns int instead of float|int.
  • Symbol lookup now records parameter declaration sites as Variable symbols, enabling go-to-definition on function/method parameters.
  • Symbol lookup now resolves gap cursors in method chains via expr_span fallback, fixing missed definitions in chained calls.
  • PHP parser and phpdoc-parser updated to 0.17.0.
  • UnhandledMatchCondition — emitted when a match expression is non-exhaustive: empty match (no arms), string literal union subject with uncovered values, or pure (non-backed) enum subject with missing cases. Enum method bodies are now included in the body-analysis pipeline, enabling exhaustiveness detection inside enum methods.
  • AbstractMethodCall now fires when an abstract static method is called by explicit class name (e.g. Base::bar() where bar() is abstract). Self/static/parent calls remain exempt.
  • InvalidDocblock now covers three additional categories: int<min,max> ranges with invalid boundaries or wrong ordering; array<K,V> with a key type that is not a subtype of int|string; and @method annotations that are empty, contain invalid characters, or declare by-reference parameters.
  • InvalidDocblock is now also emitted for @template annotations on closure and arrow-function expressions, where they have no effect.
  • Trait method signatures are now checked against interface requirements: when a class implements an interface via use T, the trait method’s signature is compared against the interface declaration and MethodSignatureMismatch is emitted for incompatible signatures.
  • Trait insteadof conflict resolution is now applied during method lookup (go-to-definition and call resolution resolve to the winning trait instead of whichever was indexed first).
  • __get return type is now propagated to magic property-access inference: accesses that fall through to __get carry the declared return type instead of always resolving to mixed.
  • enum::cases() now synthesizes list<EnumType> instead of mixed, allowing foreach loop variables to be typed as the specific enum and enabling UnhandledMatchCondition to fire on enum matches.
  • SourceFile text is now freed on removal: the Arc<str> content is nulled immediately after workspace index cleanup, releasing file content memory that was previously retained indefinitely due to Salsa 0.27 lacking a delete API.
  • Salsa LRU cap added to collect_file_declarations (lru = 4096), matching the existing cap on collect_file_definitions, preventing unbounded memo accumulation for removed files.
  • deleted_files tracking added to MirDbStorage so removed files are explicitly auditable and provide the foundation for future tracked-struct GC.
  • Variable types stored in FlowState and InferredFileTypes are now deduplicated via wrap_var_type, backed by the existing intern_or_wrap pool. Common scalars hit an O(1) fast path; merged types that equal a prior type are also deduplicated, making Arc::ptr_eq shortcuts in merge code fire more often.
  • FlowState::new() no longer allocates a fresh map for the 11 PHP superglobals on every function/method scope entry. Pre-built Arc statics are shared via COW, saving ~140 MiB of allocation churn on the project-only analysis pass (measured on Laravel).
  • TemplateParam.bound changed from Option<Type> (176 B inline) to Option<Arc<Type>> via intern_or_wrap, saving ~36 MiB of allocation churn on the project-only analysis pass.
  • WrongCaseClass (MIR1009), WrongCaseFunction (MIR1010), WrongCaseMethod (MIR1011) — new Info-severity diagnostics for case-sensitive identifier references (PHP 8.6 RFC). Covers new expressions, static calls, instanceof, type hints, catch clauses, extends/implements/use-trait declarations, built-in and user-defined functions, instance and static method calls, and use import declarations.
  • WrongCaseMethod now fires when a magic method is defined with wrong casing (e.g. __CONSTRUCT instead of __construct).
  • InvalidAttribute (MIR1600) — detects invalid #[Attribute] usages: applying #[Attribute] to a function, method, property, or parameter; abstract, interface, or trait classes marked as #[Attribute]; attribute classes with a private constructor; classes used as attributes without the #[Attribute] annotation; attributes applied to elements not matching their declared target; and non-repeatable attributes used more than once on the same element.
  • UndefinedAttributeClass — emitted when an attribute references a class that does not exist in the codebase.
  • InaccessibleClassConstant (MIR0011) — emitted when a private or protected class constant is accessed from a context that does not have visibility.
  • DuplicateClass (MIR1602) — emitted when the same class name is declared more than once within a file, including across braced namespace blocks.
  • ParentNotFound (MIR0010) — emitted when parent:: is used (static call, constant access, property fetch, or parent::class) inside a class that has no declared parent.
  • OverriddenPropertyAccess — emitted when a subclass reduces the visibility of an inherited property (public→protected, public→private, protected→private).
  • NullableReturnStatement — emitted when a function whose return type is non-nullable has a return path that could be null (the non-null part is otherwise compatible with the declared type).
  • InvalidClone now also fires when cloning a named object whose __clone() method is private and the caller does not have access.
  • @final docblock annotation is now treated as equivalent to the native final keyword for InvalidExtendClass detection.
  • ATTR_TARGET_ALL corrected from 127 to 63 (the correct sum of the six TARGET_* flags). The wrong value accidentally set bit 6 (IS_REPEATABLE = 64), making every #[Attribute] class without explicit target flags appear repeatable and silently suppressing the “not repeatable” diagnostic.
  • NonStaticSelfCall no longer suppresses the diagnostic when the class defines __callStatic. __callStatic only intercepts undefined static methods, not explicitly-defined non-static ones.
  • $this no longer leaks into static arrow functions when resolving captured outer scope.
  • FinalClassExtended renamed to InvalidExtendClass to align with Psalm’s naming. Update any inline @mir-suppress FinalClassExtended annotations to @mir-suppress InvalidExtendClass.
  • Eager + background vendor indexing with configurable chunk size and memory targets (controlled via --vendor-memory flag; defaults to 128 MiB chunks).
  • Fixed exponential memory growth when analyzing files with nested conditional branches and repeated dead-write tracking. FlowState::merge_branches now deduplicates dead writes instead of concatenating, preventing allocation of gigabytes of memory on large projects like Laravel (NotificationSender.php was OOM-ing at 20GB; now uses 33MB).
  • Fixed workspace index singleton cache refresh when analyzing project and lazy-loaded classes, ensuring proper resolution in batch analysis.
  • Vendor indexing now uses the chunked indexing engine for more predictable memory usage and streaming behavior.
  • Subtype-check results are now cached per pass (rather than globally) in the body analysis pass, improving cache locality for concurrent analyses.
  • Workspace index is now borrowed frozen during body pass analysis, eliminating write-lock contention.
  • PropertyDef type fields changed from Option<Type> to Option<Arc<Type>>, reducing per-property overhead by 168 bytes.
  • lazy_load_missing_classes ingest loop is now parallelized, speeding up vendor class loading in batch mode.
  • TooManyArguments (MIR0203) is now emitted when arguments are passed to a class that has no explicit __construct() method (the implicit constructor accepts zero arguments).
  • InvalidScope (MIR0001) is now emitted when $this is assigned a value outside a class context.
  • InvalidArrayAssignment (MIR0220) is now emitted when a subscript assignment ($x[] = … or $x[k] = …) is performed on a scalar type (int, bool, float).
  • InvalidArrayAccess (MIR0219) is now emitted when subscript access is performed on a scalar type. String subscript indexing ($str[0]) remains valid.
  • InvalidPropertyFetch (MIR0218) is now emitted when a property is accessed on a scalar or non-object type.
  • DirectConstructorCall (MIR0217) is now emitted for explicit $obj->__construct() calls on object instances.
  • NonStaticSelfCall (MIR0216) is now emitted when self::/static:: is used to call a non-static method in a static context.
  • InvalidStaticInvocation (MIR0215) is now emitted when a non-static method is called with a concrete class name (ClassName::method()) and the class has no __callStatic.
  • InterfaceInstantiation (MIR0709) is now emitted when new is used directly on an interface.
  • DeprecatedProperty (MIR1005) is now emitted when a property marked with @deprecated or #[Deprecated] is read or written.
  • DeprecatedInterface (MIR1006) is now emitted when a deprecated interface is implemented.
  • DeprecatedTrait (MIR1007) is now emitted when a deprecated trait is used.
  • DeprecatedConstant (MIR1008) is now emitted when a deprecated class constant or enum case is accessed.
  • DeprecatedClass, DeprecatedMethod, and DeprecatedCall detection expanded: #[Deprecated] is now recognised on user-defined methods and functions; deprecated classes are caught in static calls, constant access, and type hints.
  • DeprecatedMethodCall is now emitted when cloning an object whose __clone() method is deprecated.
  • InvalidCast is now emitted when (string) is applied to a concrete class that does not implement __toString().
  • InvalidCatch (MIR1503) is now emitted when a catch clause names a type that does not extend Throwable.
  • ImplicitToStringCast (MIR1501) is now emitted when a Stringable object is passed where a string is expected, making the implicit __toString() call visible.
  • InvalidOperand (MIR0213) now covers: arithmetic on non-numeric operands, bitwise operations on objects and arrays, boolean operands in bitwise expressions, boolean increment ($b++), and array members in string concatenation.
  • PossiblyNullOperand (MIR0214) is now emitted when a null value is used as a divisor in / or %.
  • UnusedForeachValue is now emitted when the value variable in a foreach loop is never read.
  • UnusedVariable dead-write detection: a variable that is assigned and then overwritten before being read is now flagged.
  • UnusedVariable is now detected in top-level PHP scripts, not only inside functions and methods.
  • InvalidOverride (MIR0708) is now emitted when #[Override] is applied to a method that has no overridable parent, or whose parent method is private.
  • MethodSignatureMismatch now catches: abstract re-declaration of a concrete method, multi-interface return-type conflicts, by-reference parameter mismatch, overrides that drop parent parameters, and static/non-static mismatch.
  • Generic type inference at instantiation: new Box(5) now infers Box<int> by binding class @template parameters from constructor argument types.
  • Unannotated generic method returns: methods whose parameters carry template types now resolve concrete return types at call sites without an explicit @return annotation.
  • @readonly docblock annotation on properties is now treated the same as the native readonly keyword for the ReadonlyPropertyAssignment check.
  • @mixin property resolution: properties declared on @mixin classes are now found via the full inheritance chain, eliminating UndefinedProperty false positives for mixin-based patterns.
  • Narrowing false positive: possibly-undefined variables no longer cause the else/elseif branch to be incorrectly marked as unreachable.
  • Narrowing in elseif/else chains: each failed elseif condition is now applied as a negative narrowing to the else branch.
  • UnusedVariable false positives in loops: pre-loop writes are cleared after the loop body iterates, preventing them from being re-introduced through the else path.
  • UnusedVariable false positives for variables passed to compact(): those variables are now marked as consumed.
  • Return type checking now applies inside anonymous-class methods.
  • Stub cache corruption on the second analysis run: #[serde(skip_serializing_if = "Option::is_none")] is unsafe with bincode (a non-self-describing format) — the None discriminant byte was omitted on write while deserialization still expected it, causing misaligned reads and a runaway allocation. Removed skip_serializing_if from the deprecated field on PropertyDef, ConstantDef, InterfaceDef, TraitDef, and EnumCaseDef. Stub cache format version bumped to 4 to invalidate stale on-disk entries.
  • Inline issue suppression via source comments: add // @mir-suppress DiagnosticName on the offending line (or the line above) to silence a specific diagnostic without affecting others.
  • NonExistentArrayOffset (MIR0301) is now emitted when a literal string or integer key is accessed on a closed keyed array (array{foo: int}) and the key is absent.
  • ParadoxicalCondition (MIR0404, Warning) is now emitted for duplicate literal values in switch cases and match arms, where the repeated branch can never be reached.
  • Conditionally-declared functions and classes — the if (!function_exists('foo')) { function foo() {} } guard pattern used by Laravel helpers, Symfony polyfills, and WordPress pluggable functions — are now indexed. Resolves ~1,608 UndefinedFunction false positives on a standard Laravel project.
  • All issue locations now carry line_end/col_end in addition to the existing start position, enabling tighter diagnostic ranges in SARIF, LSP, and playground consumers.
  • UnusedVariable false positives for variables used as dynamic property or method names ($this->$var, $this->{$var}, $this->$method()).
  • UndefinedClass false positives for class names used as the argument to class_exists(), interface_exists(), or trait_exists(), and for usages of optional classes inside the guarded true-branch.
  • Conditional return types (@return ($T is null ? X : Y)) are now resolved at static method call sites, eliminating false InvalidArgument errors.
  • Short-circuit &&/|| assignments are promoted from possibly-assigned to definitely-assigned when the branch is known to have executed (e.g. the true-branch of &&). Reduces PossiblyUndefinedVariable false positives in the Laravel benchmark from 31 to 7.
  • Composer root detection now skips vendor/<org>/<pkg>/composer.json manifests and walks up to the true project root, eliminating ~1,552 UndefinedClass false positives on standard Laravel projects.
  • strtr($str, $pairs) (2-argument array form) no longer fires TooFewArguments.
  • TooManyArguments false positives eliminated when a union type contains a bare callable (unknown arity) alongside a typed TClosure.
  • UnusedVariable false positives eliminated for variables read only inside a finally block (the save-restore pattern).
  • Nested TConditional return types (e.g. ($v is null ? array{} : ($v is array ? array<K,V> : array{V}))) are now recursively resolved rather than returned as opaque conditional types.
  • UndefinedProperty false positives eliminated for property accesses guarded by ?? or isset (e.g. $this->prop ?? null).
  • PossiblyUndefinedVariable false positives eliminated for variables used as the left operand of ?? when the coalesced result is immediately compared against the fallback literal.
  • A bare Closure type now satisfies a typed Closure(): T parameter, eliminating false InvalidArgument errors.
  • ingest_file now evicts dependents’ cached analysis when a file’s content changes, preventing stale results from being replayed across incremental re-analysis.
  • Enum::Case and class constant accesses now resolve to the correct type instead of mixed.
  • TooManyArguments false positives eliminated for functions and methods that use func_get_args()/func_num_args()/func_get_arg() in their bodies.
  • InvalidArgument false positives eliminated for Stringable objects passed as string parameters in files without declare(strict_types=1).
  • array_keys(array<K, V>) now returns list<K> instead of list<mixed>.
  • preg_match $matches parameter is now typed as array<int, string> via by-ref write-back.
  • str_replace/str_ireplace return type is narrowed to string when the subject is a scalar.
  • hrtime() return is narrowed to int|float when $as_number is true.
  • NonExistentArrayOffset is suppressed inside existence-check contexts (isset, ??, empty).
  • Template parameters in supertype position are now treated as wildcards in atomic_subtype, eliminating false InvalidTemplateParam diagnostics for union-sub against union bounds.
  • list<T> is now inferred for the $arr[] = $v push notation instead of array<mixed, T>.
  • $obj::class passed as a class-string<T> argument no longer fires InvalidArgument.
  • Nested array assignment ($arr[$k][] = $v) now correctly propagates the innermost key type.
  • Template parameters inside array types in generic method returns are now correctly resolved.
  • Reference index gaps closed: class references recorded at the class identifier in static calls, self/static/parent/ClassName constant accesses, and inherited method calls use the declaring class.
  • PHP version filtering is now wired into the salsa database so FileAnalyzer honours --php-version correctly.
  • Parser now strips quotes from array shape keys in PHPDoc (array{'key': T} parses correctly).
  • mysqli_init() PHP 8.0 overload (returning mysqli) added to stubs.
  • Peak cold-start memory reduced by ~22 MiB: MethodDef/FunctionDef inferred return types changed from Option<Type> (176 B) to Option<Arc<Type>> (8 B); class analysis no longer materializes vendor/stub classes during the analyzed-file decomposition; mimalloc installed as the global allocator.
  • $argv and $argc are now seeded as predefined globals, eliminating UndefinedVariable false positives in CLI scripts.
  • Single-star /* @var $this */ annotations (the form PhpStorm generates for Yii2 view templates) are now recognized in addition to /** PHPDoc blocks. Fixes #290.
  • PossiblyUndefinedVariable false positives eliminated for variables assigned inside while(true) and for(;;) loops before every break. Infinite loops no longer treat the “loop never executes” path as reachable.
  • UnusedVariable and UnusedParam false positives eliminated for variables read only inside a diverging if-branch (one that always returns or throws).
  • Upgraded php-rs-parser, php-ast, php-lexer, and phpdoc-parser to 0.15.0. Function and closure bodies are now wrapped in a Block type; class/enum/interface/trait members are behind ClassBody/EnumBody wrappers.
  • Cache is now enabled by default without --cache-dir. Composer projects cache to <project-root>/.mir/cache; other scans use the platform cache directory. Pass --no-cache to opt out.
  • @mir-check inline type assertion directive: annotate a variable with /** @mir-check $x is SomeType */ in a test fixture to emit TypeCheckMismatch if the inferred type does not match, enabling regression tests for type inference.
  • Short-circuit isset/!isset narrowing in && and || expressions: isset($x) && $x->method() now correctly narrows $x to non-null inside the right-hand side.
  • InvalidStringClass diagnostic: emitted instead of UndefinedClass when a dynamic class expression (new $var, $var::method()) is not a valid class-string. String literal arguments to class-string parameters are now validated.
  • TCallableString atomic type for proper callable-string validation.
  • Variance checking for generic return types: a method return type that widens its parent’s generic parameter now emits a diagnostic.
  • Template bounds (FQN resolution): eliminated ~2,100 false-positive InvalidTemplateParam and InvalidArgument diagnostics caused by bare class names in @template T of … bounds not being namespace-qualified. Fixes cover all definition collectors (class, interface, trait, function, method), intersection bounds, @var and property type annotations, and generic type arguments.
  • Template conditional returns: @return (T is null ? X : Y) now parses and resolves correctly at call sites. When T is already bound in the substitution, the conditional collapses to the correct branch. When the discriminator is nullable-but-not-only-null, the conditional widens to X|Y instead of emitting a false positive.
  • Intersection types: intersection-typed values are now recognized as subtypes of their parts and of object, eliminating companion InvalidArgument false positives for functions like get_class(). InvalidArgument is also suppressed when a parameter type contains templates within an intersection.
  • Template inference: T is now correctly inferred from class-string<T> arguments, Closure, callable, and intersection-typed parameters. Template bounds now check inheritance chains. Array-key pseudo-type and TKeyedArray are recognized in template binding.
  • Array types: empty keyed arrays (array{}) are folded into matching generic arrays in unions, eliminating |array{} noise from loop-built arrays. Array key types are now preserved in $arr[$key] = $val assignments, fixing ~62 false-positive InvalidReturnType diagnostics. Mutual-reference array loops no longer cause an infinite hang during inference.
  • PHP built-ins: array_walk, array_walk_recursive 3rd parameter is now optional; mt_rand/rand parameters are now optional. Fixes ~30 TooFewArguments false positives. array_map with multiple arrays now accepts a callback with matching arity instead of requiring arity 1, fixing ~62 false positives.
  • Enum built-ins: from()/tryFrom() are now synthesized with one parameter, eliminating TooManyArguments false positives.
  • Narrowing: UndefinedVariable is no longer emitted for variables on the left-hand side of ?? and ??=. assigned_vars is now correctly restored after isset-narrowed branches.
  • Column numbers: diagnostic column numbers are now 1-indexed (previously 0-indexed). Any tooling that parses mir output should update accordingly.
  • Stubs: user-defined files now consistently override native stub definitions in the symbol index, eliminating non-deterministic false positives when shadowing PHP built-in names.
  • self::CONST references in method parameter defaults now correctly emit UndefinedConstant when the constant does not exist.
  • First-class callable syntax (SomeClass::method(...)) now resolves to a typed TClosure instead of an untyped callable.
  • InvalidStringClass false positives eliminated for object expressions on the left of :: (e.g. $obj::CONST).
  • ProjectAnalyzer is replaced by AnalysisSession in the public API. The new type consolidates project setup and analysis into a single entry point.
  • Stub loading is now fully lazy: stubs for a PHP version are loaded on first reference rather than at startup, reducing cold-start memory for projects that use only a subset of built-ins.
  • Composer plugin type: composer require jorgsowa/mir now triggers the binary download automatically without requiring manual script wiring. The composer.json type field is set to composer-plugin, and a Plugin class registers the install/update event handler.
  • Composer installer now embeds the target triple in the version marker, preventing a binary installed on one platform (e.g. macOS) from being reused on a different one (e.g. Linux in Docker). The shim error message for proc_open failures now mentions a possible architecture mismatch.
  • Broken relative links in the error codes reference table (./../) that caused 404s when navigating from the codes page to individual issue pages.
  • Documentation corrections for ImplicitToStringCast, InvalidCast, UndefinedClass, InvalidScope, DeprecatedMethod, and DeprecatedMethodCall issue pages. Added missing UndefinedTrait (MIR0009) documentation page.
  • Stable MIR#### error codes for every issue variant, organized into 16 category bands. Codes surface in Display output in rustc style: error[MIR0005] UndefinedClass: .... The name() method is unchanged and remains the suppression and SARIF rule key.
  • UndefinedTrait (MIR0009) diagnostic: emitted when a use statement references a name that does not exist in the codebase.
  • InvalidTraitUse now also emitted when the used name resolves to a class, interface, or enum instead of a trait. Per-use-statement source locations are stored in ClassStorage and ClassNode so diagnostics point at the trait name in the use statement.
  • php-rs-parser 0.13.0: parse errors now carry precise source locations via err.span() instead of hardcoded line 1 col 0; ForbiddenWarning diagnostics emit at Severity::Warning and do not block semantic analysis.
  • Literal integer (1, 42, -3) and quoted-string ('foo', "bar") types in docblock annotations now parse as TLiteralInt / TLiteralString instead of TNamedObject, making @return 2|3 and similar annotations work correctly.
  • @return / @param docblocks written on the line preceding a standalone function declaration (rather than attached as an AST doc_comment) are now applied, matching the existing behavior for class methods.
  • @method docblocks on traits, interfaces, and enums are now honored. Previously add_docblock_members was only called for classes, silently dropping virtual method declarations on other symbol kinds. @method-added methods carry is_virtual: true and are excluded from UnimplementedInterfaceMethod checks.
  • UnusedVariable now reports the correct source location for variables first assigned via array push ($arr[] = value), static $var, or global $var (previously fell back to line 1, col 0).
  • global $var assignments are now treated as externally observable side effects (matching by-reference parameter semantics), eliminating false-positive UnusedVariable diagnostics on global variable writes.
  • Union::intersect_with now returns never() when no types overlap between the subject and the arm condition, preventing false-positive method/property errors in match arm bodies. Union::add_type now absorbs never into non-empty unions (T | never = T).
  • Pending reference locations are now drained into RefLocAccumulator inside analyze_file (Salsa), fixing reference tracking in the incremental analysis path.
  • MissingThrowsDocblock is now suppressed by default for RuntimeException and LogicException descendants (PHP’s “unchecked” exceptions). Both direct throw statements and transitive @throws propagation are filtered. The suppression list is configurable via the new suppressed_issue_kinds API.
  • find_dead_code: bool on ProjectAnalyzer replaced with suppressed_issue_kinds: HashSet<String> and a centralized apply_issue_suppressions() post-filter applied on every analysis path including the cache-hit path.
  • Removed the instanceof operator-precedence workaround from narrowing.rs; php-rs-parser 0.13.0 correctly parses !$x instanceof C as !($x instanceof C).
  • Bumped php-rs-parser, php-ast, php-lexer, phpdoc-parser 0.12.10.13.0.
  • Persistent Pass-1 cache (StubSliceCache): when a cache directory is configured (ProjectAnalyzer::with_cache, AnalysisSession::with_cache_dir, or --cache-dir), each file’s StubSlice is stashed in <cache_dir>/stubs/<hh>/<full_hash>.bin using a content-hash key, a bincode binary encoding, and atomic tempfile-and-rename writes. On a warm cache, files skip parse and definition collection (≈95% of the per-file cost on Laravel) and the cached slice is ingested directly. Cache header is version-gated by CARGO_PKG_VERSION, the on-disk format version, and the target PHP version, so cached data is automatically invalidated across mir or PHP-version upgrades.
  • Both the batch path (ProjectAnalyzer::collect_types_only, exercised by the CLI for vendor warmup) and the per-file LSP path (AnalysisSession::ingest_file via SharedDb::collect_and_ingest_file) consult the cache. Measured on laravel/framework v11.44.7 (10,188 vendor files, M-series Mac), independently verified hit counters (10,185 hits / 0 misses on warm, the 3-file delta is files mir skips for parse errors and is excluded from caching):
    • Vendor batch collection: cold 2,224 ms / 2,822 MiB churn → warm 1,440 ms / 525 MiB churn (−35% wall, −81% churn). Repeated runs land in a −30% to −46% wall-time band depending on the OS page-cache state of the underlying vendor tree.
    • LSP-style serial ingest_file storm via AnalysisSession: cold 5,476 ms → warm 3,720 ms (−32% wall). The serial path is bottlenecked by Salsa write-lock + ingest cost the cache doesn’t address.
  • Cache misses (or files with parse / collector errors) skip the write-back so future runs re-parse them; cache hits restore the file path field from the lookup argument so the on-disk encoding never carries a machine-specific absolute path.
  • ProjectAnalyzer::{with_cache_dir,set_cache_dir} and AnalysisSession::{with_cache,with_cache_dir} now debug_assert they are called before any file is ingested — late attachment would silently reset the shared database and discard prior Pass-1 work.
  • Bumped all transitive crates within their compatible semver ranges (cargo update), including the php-rs-parser / php-ast / php-lexer / phpdoc-parser stack from 0.12.00.12.1.
  • Bumped quick-xml 0.390.40 in mir-analyzer.
  • Replaced postcard with bincode 1.3.3 for the StubSliceCache on-disk format. postcard pulled heaplessatomic-polyfill (RUSTSEC-2023-0089); bincode v2 was tried next but is itself flagged unmaintained (RUSTSEC-2025-0141). bincode 1.3.3 carries no advisory and is explicitly called “complete” by its authors. Cache on-disk format version bumped to 2 so existing v2-encoded entries are treated as misses.
  • Pass 2 reference-location recording now uses per-worker staging buffers (PendingRefLocs) instead of writing directly to shared Arc<Mutex<...>> maps. Workers accumulate locations in an isolated parking_lot::Mutex<Vec<RefLoc>> and a single serial commit drains them with one lock acquisition per map. Pass 2 wall-clock variance reduced from 28–240 ms (8×) to 43–56 ms (±25%) on 12 threads.
  • analyze_dependents_of() now returns the correct dependent set after a symbol is deleted or renamed. Previously, files referencing a now-gone symbol were silently dropped because dependency_graph() routed edges through symbol_defining_file(), which returns None for deleted symbols. Three coordinated fixes: a file_to_defined_symbols forward index for O(1) definition lookup on removal; a symbol_referencers reverse index that survives symbol deletion; and a stale_defined_symbols accumulator in AnalysisSession that feeds deleted symbols’ referencers back into the BFS.
  • O(1) parameter deduplication: replaced linear Vec scan with FxHashMap for ~20% faster stub ingestion on large vendor sets. Deduplication now runs in parallel within rayon Pass 1 instead of serializing the collector.
  • RwLock-based atomic counter writes for Salsa db updates, reducing lock contention during batch analysis and improving 12-thread scaling.
  • file_references forward index added to MirDb: dependency_graph() cost reduced from O(S×R) to O(E) (files × edges), eliminating full-table scans during incremental re-analysis.
  • In-memory always-on reverse dependency map (structural_dependents_of) for O(D) BFS over structural dependencies (imports, class hierarchy, type hints) without requiring disk cache.
  • Reference location recording now complete at all five previously-missing call sites: instanceof, catch, ::class, ::CONST, and type-hint declarations. Files referencing a class only via these constructs are now correctly visible to the incremental dependency graph and analyze_dependents_of().
  • Type narrowing for get_class($obj) === 'ClassName' comparisons, enabling precise type refinement when class identity is verified.
  • is_resource() type guard for completeness in the type narrowing system.
  • Parallel Salsa pre-sweep inference pass in batch path, replacing sequential Pass 2 driver with direct rayon-based inference for improved throughput.
  • Type narrowing for $var === SomeClass::class comparisons, refining object types when matched against class constants.
  • Bare-FQN references (e.g., new \Service(), \Helper::go()) now correctly wired into the incremental dependency graph so analyze_dependents_of() returns files referencing classes via unqualified absolute paths.
  • Refactored database module structure: source_files map moved from SharedDb tuple into MirDb for clearer ownership.
  • Lazy-load optimization: avoid redundant full scans of class inheritance chains when loading missing classes.
  • AnalysisSession::class_issues_for(): exposes cross-file class diagnostics (abstract-method gaps, override violations, circular inheritance) so LSP consumers can retrieve the complete diagnostic picture alongside analyze_dependents_of() without accessing ClassAnalyzer directly.
  • @template T as Bound syntax now parsed correctly (previously only @template T of Bound was recognized), enabling proper type narrowing for templates declared with the as keyword.
  • Callable/closure return types in @return annotations (e.g., @return \Closure(): T) now correctly capture the return type after the colon, fixing false MixedMethodCall diagnostics when template parameters were used as closure return types.
  • cargo-deny configuration format migration to version 2.
  • Tier 1 & 2 parser optimizations: pre-sized arena allocators and parallel user stub discovery for improved cold-start performance (25-40% improvement expected).
  • cargo-deny configuration format corrected to use proper advisories section syntax.
  • Security audit findings: eliminated unwrap calls and unsafe UTF-8 conversions.
  • Panic on empty generic type parameters in docblock parsing.
  • Outdated lock poison .expect() calls replaced with proper error handling.
  • Template parameter bounds preservation and improved generic type narrowing.
  • MixedClone detection for unconstrained template parameters.
  • Missing stubs directory safety check in build.rs.
  • Soft stub fallback version-gating for both functions and classes.
  • Refactored AST-based stub discovery in FileAnalyzer for clarity and performance.
  • Split db.rs into focused sub-modules for maintainability.
  • Improved code quality with centralized test utilities.
  • Eliminated HashMap/HashSet clones in cache flush hot paths.
  • Reduced string clone allocations in hot paths.
  • Replaced std::sync::Mutex with parking_lot::Mutex to eliminate poison panics.
  • Parallelized fixture discovery in build script.
  • Session-based per-file analysis API (AnalysisSession + FileAnalyzer) for incremental, file-scoped analysis suitable for LSP-style consumers.
  • mir_analyzer::location_from_span(span, file, source, source_map) -> Location: public free function that converts a parser Span (byte-offset range) to the crate’s Location type (1-based lines, 0-based codepoint columns), so consumers can translate Pass-2 spans to their own protocol’s position format without re-implementing column math.
  • Soft fallback for unknown stubs: when Pass 2 would emit UndefinedFunction / UndefinedClass for a name the build-time stub index recognises as a real PHP built-in, the diagnostic is suppressed. Defends against lazy-stub timing races (auto-discovery scanner false negatives, essentials-only sessions without auto-discovery, mid-ingest reads). Genuinely unknown names still emit.
  • Concurrent-read benchmark: N reader threads call definition_of() in a tight loop while a writer continuously re-ingests a fixture, reporting wall time per fixed-size batch for 1 / 4 / 8 readers. Surfaces real contention characteristics under flat-out write pressure (per-read latency: 324ns @ 1 reader, 1.4µs @ 4, 1.9µs @ 8); realistic LSP edit cadence stays at the 324ns figure.
  • MixedClone issue type: detects clone / clone with expressions on mixed-typed values in ExpressionAnalyzer.
  • @var annotation narrowing now applies to global-scope statements, not just function bodies. Previously analyze_stmt() (used for top-level statements) skipped the pre/post narrowing that analyze_stmts() performed for function bodies, so @var had no effect at global scope. Fixes global_with_var_no_indent, function_with_var, and invalid_mixed_clone fixtures.
  • Analyzer boilerplate simplifications:
    • Union::core_type() collapses 10+ chained remove_null().remove_false() call sites in type-checking logic.
    • DefinitionCollector::parse_docblock_from_node_or_preceding() consolidates the “check doc_comment, fall back to preceding docblock” pattern repeated 11+ times across class/trait/interface collectors.
    • StatementsAnalyzer::span_to_location() replaces 7 instances of verbose span-to-location computation in flow analysis.
  • Trait method undefined function detection: diagnostics now detect when trait methods reference undefined functions, improving visibility into broken trait implementations.
  • Enhanced inheritance chain checking for magic methods (__get, __invoke): full ancestor chain is now properly examined, catching edge cases where magic methods are defined in distant parent classes.
  • Magic method resolution (__get, __invoke) now checks the complete ancestor chain instead of stopping at the immediate parent, fixing false negatives where inherited magic methods were not detected.
  • Unused method tests now properly handle collateral errors, improving test reliability and reducing false positives in fixture validation.
  • AbstractInstantiation diagnostic to detect attempts to instantiate abstract classes via new ClassName().
  • Closure use() clause validation: now detects undefined variables referenced in closure use() clauses. Example: use ($i) will report UndefinedVariable if $i is not defined in the parent scope.
  • Mixin method resolution with generics: docblock @mixin Foo<T> annotations now correctly resolve to class Foo instead of attempting to look up a non-existent class named Foo<T>.
  • All 17 undefined_variable fixture tests now pass with correct line/column/message expectations.
  • All 15 undefined_constant fixture tests now pass with correct line/column/message expectations.
  • Deduplicate parameter types across all function/method signatures via Arc<Union> interning, eliminating redundant type allocations.
  • Resolve function node once per call site instead of twice, reducing redundant database lookups.
  • Use SimpleType for atomic function parameters, reducing type envelope overhead.
  • Deduplicate return types via Arc<Union> interning for all callables.
  • Deduplicate parameter lists across vendor method signatures, further reducing memory footprint.
  • Skip re-caching StubSlice in Salsa during vendor collection, improving vendor ingestion performance.
  • The published mir-analyzer crate is no longer shipped with an empty stub set. The stubs/ directory lived at the workspace root, outside the package, so cargo package excluded it; downstream consumers (e.g. php-lsp) saw STUB_FILES = &[] and every PHP built-in resolved as UndefinedFunction / UndefinedClass. Stubs now live inside the crate at crates/mir-analyzer/stubs/ and are included in the published artifact. build.rs panics if the directory is missing, and a new tests/packaging.rs test asserts cargo package --list includes stubs/Core/Core.php plus the rest of the stub set — closing the publish-time gap.
  • Built-in function and class lookups are now case-insensitive, matching PHP semantics. Restore_Error_Handler(), RESTORE_ERROR_HANDLER(), new arrayobject([]), and new ARRAYOBJECT([]) no longer produce false-positive UndefinedFunction / UndefinedClass diagnostics. Implemented as side indices on MirDb (function_node_keys_lower, class_node_keys_lower) so the canonical-FQN storage that active_*_node_fqns, function_count, type_count, and clear_file_references depend on is unchanged. Constants remain case-sensitive (PHP semantics).
  • Unqualified class names in namespaced files no longer silently fall back to the global namespace when the namespaced class is missing. PHP only does that fallback for functions and constants; mir’s resolve_name_via_db was incorrectly extending it to classes, masking real UndefinedClass bugs.
  • Composer autoload parsing now covers psr-0, classmap, and files in addition to psr-4, for both project composer.json and each package in vendor/composer/installed.json. Vendor packages that expose global helpers via autoload.files (Symfony polyfills, Laravel helpers, ramsey/uuid bootstrap, etc.) and classmap-only packages no longer produce false-positive UndefinedFunction / UndefinedClass diagnostics.
  • mir_codebase::Codebase struct, CodebaseBuilder, codebase_from_parts, and the internal Interner module. The salsa db (MirDb) is the single source of truth for class/method/property/constant metadata, per-file imports/namespaces, global vars, and reference tracking. The mir-codebase crate now exports only the serializable storage types (StubSlice, *Storage, FnParam, TemplateParam, Visibility, Location). Breaking for library consumers that imported mir_codebase::Codebase.
  • ProjectAnalyzer::codebase() accessor (already removed in 0.16.x perf work; the Codebase deletion completes the cleanup).
  • mir-codebase no longer pulls in dashmap or thiserror.
  • Hot-path Salsa db lookup tables (class_nodes, function_nodes, method_nodes, property_nodes, class_constant_nodes, global_constant_nodes, file_namespaces, file_imports, global_vars, symbol_to_file, reference_locations) and the ancestor-walk visited sets in class_ancestors / lookup_method_in_chain / method_is_concretely_implemented now use FxHashMap / FxHashSet instead of std HashMap / HashSet. Eliminates the per-ancestor String allocation in class_ancestors (now reuses the existing Arc<str>). ~7% reduction in user CPU time on the Laravel src/ benchmark.
  • CLI Composer detection now walks up from a single explicit file path to find the nearest composer.json, so root config files such as .php-cs-fixer.php can resolve project PSR-4 namespaces instead of reporting false-positive UndefinedClass diagnostics.
  • Cross-file inferred return types (G6): a type-inference priming pass now runs all function and method bodies in parallel before the issue-emitting Pass 2, writing inferred_return_type for every symbol without recording reference locations. Callers no longer see mixed for callees whose Pass 2 had not yet completed. Covers the common depth-1 case; depth-N chains are addressed by Phase 4 (Salsa).
  • Per-class OnceLock finalization (Phase 3 item 6): ensure_finalized(fqcn) lazily computes and memoizes each class’s ancestor chain on first access via DashMap<Arc<str>, OnceLock<Arc<[Arc<str>]>>> with thread-local cycle detection. finalize() is now a warm-all wrapper; remove_file_definitions() evicts only the affected entries granularly.
  • Lazy finalization removes the pass barrier (Phase 3 item 7): the eager finalize() barrier that blocked all of Pass 2 until every ancestor chain was warm is removed. ensure_finalized() is now called at each all_parents read site (get_method_inner, get_property_inner, get_class_constant, extends_or_implements, has_unknown_ancestor, collect_members_for_fqcn, ClassAnalyzer::analyze_all, check_trait_constraints, argument_type_satisfies_param). Phase 3 is now complete.
  • LSP incremental re-analysis: classes defined in an analyzed file but never referenced during Pass 2 had empty all_parents at snapshot time, causing restore_all_parents to silently restore empty ancestor chains on the LSP fast path. file_structural_snapshot now calls ensure_finalized for each symbol before capturing it.
  • Return type covariance for named-object overrides: ClassAnalyzer now delegates to named_object_return_compatible when checking overriding methods, catching cases where a child class returns an unrelated type instead of the declared parent return type. Mixed scalar+object unions still skip the check to avoid false positives.
  • Type narrowing after instanceof $this: when the right-hand side of instanceof is $this, it is resolved to the current class FQCN before narrowing, eliminating false-positive MixedMethodCall and UndefinedProperty diagnostics on if (!$other instanceof $this) guards. (#144)
  • stmt.rs split into stmt/ sub-module (mod.rs, loops.rs, return_type.rs), following the same pattern as call/. No behavior change.
  • Generic template substitution extended to array shapes (TKeyedArray, TNonEmptyArray, TNonEmptyList), callable/closure types, conditional types, and intersection types. Variable calls ($fn()) on TClosure/TCallable now resolve the correct return type instead of mixed. TIntersection method calls resolve against the part that owns the method. Docblock parser gains array{key: T} shape syntax and callable(T): R / Closure(T): R parsing.
  • ParsedDocblock::is_inherit_doc flag: set when @inheritDoc, @inheritdoc, or {@inheritDoc} is present in a docblock, enabling LSP clients to walk the inheritance chain for hover and completion without implementing resolution in mir itself.
  • LSP / incremental re-analysis: inject_stub_slice now populates file_namespaces and file_imports in the codebase, fixing false-positive UndefinedClass diagnostics for use-aliased classes after any incremental re-analysis triggered by re_analyze_file.
  • Location type unified in mir-types; internal codebase storage switched from byte offsets to (line, col_start, col_end). All mark_*_referenced_at() methods now accept line/column instead of byte offsets. Columns use 0-based Unicode code-point counts (LSP UTF-32 encoding); UTF-16 conversion happens at the LSP boundary for clients that do not advertise UTF-32 support. Existing on-disk caches silently rebuild on the next run.
  • Docs deploy now invokes a reusable workflow_call path to docs.yml so the deployment runs under a branch-authorized context instead of directly from a tag, fixing GitHub Pages environment protection failures.
  • Interactive WASM playground embedded in the docs site: select PHP version (8.1–8.5), type PHP code, and see live diagnostics with underline overlays and severity-colored cards. (#287)
  • Docs site logo added to README and top bar; branding updated.
  • php-ast and php-rs-parser bumped to 0.9.6.
  • Node.js version in docs deploy workflows raised from 20 to 22 (Astro now requires >=22.12.0).
  • PossiblyInvalidArgument issue: emitted when a false|T union value is passed to a parameter that does not accept false, surfacing potential type mismatches that were previously silently widened to mixed.
  • Backed enum ->value and ->name access now returns a precise inferred type (TLiteralString / TLiteralInt for ->value, TLiteralString for ->name) instead of mixed.
  • call_user_func and call_user_func_array string callables (e.g. 'ClassName::methodName') are now tracked as real call references, fixing false-positive stub warnings on those forms.
  • Infinite recursion on circular @mixin references: the mixin resolver now carries a seen-set and breaks cycles instead of stack-overflowing.
  • Benchmark harness: rayon stack size raised to 16 MiB and the global thread pool is initialised explicitly, preventing stack overflows on deeply recursive PHP files during benchmarking.
  • timeout-minutes added to all workflow jobs and a concurrency group added to the CI workflow to cancel superseded runs.
  • Release CI: GitHub Release is now created from the CHANGELOG before binaries are uploaded, fixing a race condition where upload-rust-binary-action failed with “release not found”.
  • InvalidDocblock issue: emitted when a type annotation in a docblock cannot be parsed (malformed syntax). (#282)
  • Injectable user stubs: <stubs><file name="..."/> and <stubs><directory name="..."/> elements in mir.xml / psalm.xml load additional stub paths before analysis; stub files are not themselves analyzed for errors. (#285)
  • phpVersion can now be set as an XML attribute on the root <mir> or <psalm> element (e.g. <mir phpVersion="8.2">), matching Psalm’s config syntax, in addition to the existing child-element form. (#285)
  • phpstorm-stubs is now vendored directly in stubs/ (tracked in git) instead of a git submodule. External contributors no longer need to run git submodule update --init. (#283)
  • Documentation site migrated from mdBook to Astro Starlight; issue-kind reference pages are now split into individual pages grouped by category.
  • Composer package miropen/mir-php. A post-install-cmd / post-update-cmd hook downloads the prebuilt mir binary matching the installed version and host platform from GitHub Releases, verifies the SHA-256 sidecar, and exposes vendor/bin/mir. Single-entry extraction with strict path-traversal and symlink rejection. Supported targets: x86_64-unknown-linux-gnu, aarch64-unknown-linux-gnu, x86_64-apple-darwin, aarch64-apple-darwin, x86_64-pc-windows-msvc.
  • Release GitHub Actions workflow building and uploading per-target archives + sha256 sidecars on v* tags.
  • NullArgument issue: emitted when a literal null is passed to a non-nullable parameter (previously subsumed by InvalidArgument). Severity: warning.
  • UnusedFunction issue: emitted for free functions that are never called when find_dead_code is enabled.
  • InvalidPropertyAssignment issue: emitted when a value of an incompatible type is assigned to a typed property. Handles class inheritance via the codebase.
  • cargo install mir-cli references in README and docs corrected to mir-php (the actual crate name).
  • Panic in docblock extraction when source text before a declaration contains multibyte characters (e.g., ). find_preceding_docblock now correctly advances past multibyte chars when scanning for word boundaries.
  • Location.line_end field — all issues now carry an end line number, enabling multi-line range highlighting in editors and code scanning tools. (#270)
  • SARIF output: region.endLine populated from line_end. (#270)
  • SARIF output: results now include rank (Error → 90, Warning → 95, Info → 99) matching Psalm’s scoring range. (#270)
  • SARIF output: rules now include properties.tags ("security" for taint issues, "maintainability" for all others). (#270)
  • Psalm docblock parity: @psalm-assert-if-false type narrowing. (#267)
  • Psalm docblock parity: @psalm-import-type type alias imports. (#267)
  • Psalm docblock parity: @psalm-param and @psalm-return type narrowing annotations. (#267)
  • SARIF output: startColumn/endColumn are now correctly 1-based per SARIF 2.1.0 §3.30.5 (previously off by one). (#270)
  • SARIF output: rules now include defaultConfiguration.level so the GitHub Code Scanning rules panel shows severity. (#270)
  • SARIF output: results now include partialFingerprints.primaryLocationLineHash (FNV-1a of rule name + snippet) so GitHub Code Scanning can track findings across commits. (#270)
  • Static calls now correctly check for __callStatic (not __call) when suppressing UndefinedMethod on missing static methods. (#271)
  • Magic method dead-code exclusion now uses lowercase keys matching own_methods storage, so __callStatic, __toString, and __debugInfo are correctly exempted from UnusedMethod reports. (#271)
  • __unserialize added to MAGIC_METHODS_WITH_RUNTIME_PARAMS, preventing its $data parameter from being flagged as unused. (#271)
  • Trait docblock parsing now falls back to raw-source lookup when php-rs-parser absorbs the trait-level docblock, ensuring @psalm-require-extends and @psalm-require-implements are correctly detected. (#267)
  • Bumped blake3, php-ast, php-lexer, and php-rs-parser to latest. (#272)
  • Trait method bodies are now analyzed in Pass 2; diagnostics (UndefinedFunction, UndefinedMethod, unused variables, etc.) are emitted for code inside traits. (#264)
  • UnreachableCode issue — statements following a terminator (return, throw, exit, die) in the same block are now flagged; nested closures are analyzed with a fresh context and are not affected by divergence in the outer block. (#262)
  • PossiblyUndefinedVariable promoted to Warning severity, making it visible at the default error level and matching Psalm’s behavior. (#261)
  • 10 false-positive UndefinedMethod reports eliminated: dynamic method calls via variable expressions ($obj->{$var}()) no longer trigger a spurious lookup, and private trait methods are now correctly accessible from classes that use the trait. (#260)
  • Improved Psalm docblock parity. (#265, #266)
  • PhpVersion::LATEST constant (currently 8.5) — used as the default when no explicit version is configured.
  • ProjectAnalyzer::with_php_version builder method to set the target PHP version.
  • @deprecated tag messages are now included in Deprecated issue descriptions.
  • php_version is now propagated through StatementsAnalyzer and ExpressionAnalyzer for version-gated checks.
  • UndefinedClass is now detected in 7 previously-silent code paths.
  • Static method call spans now use the parser span for the method name rather than manual offset arithmetic.
  • Windows build: canonicalize() returns \\?\-prefixed UNC paths on Windows; the build script now strips that prefix before embedding stub paths in include_str!.
  • ProjectAnalyzer::php_version field is now Option<PhpVersion> (None = use PhpVersion::LATEST); previously it was a bare PhpVersion defaulting to 8.4.
  • Bumped php-rs-parser, php-ast, and php-lexer to 0.9.2.
  • IssueBuffer::add deduplication changed from an O(n) scan to a HashSet lookup.
  • Cross-file .phpt fixture format with ===file:Name.php=== sections and optional composer.json for PSR-4 lazy-loading scenarios; 21 new cross-file fixtures added.
  • ===config=== section in .phpt fixtures for per-fixture settings (php_version, find_dead_code); dead-code fixtures now declare this in config instead of relying on a hard-coded category list.
  • New stub_behavior/ fixtures covering stdClass, preg_match, sscanf, array_map null callback, and array_keys optional filter.
  • Correctness tests for inject_stub_slice covering symbol overwrite, symbol_to_file updates, global_vars cleanup on remove_file_definitions, and StubVfs roundtrip navigability.
  • Switched stubs from generated Rust files (mir-stubs-gen) to phpstorm-stubs loaded at build time via CUSTOM_STUB_FILES; the mir-stubs-gen crate is removed.
  • Unified single-file and multi-file .phpt fixture parsers into a single parse_phpt function; existing ===source=== markers renamed to ===file===.
  • UnimplementedAbstractMethod and UnimplementedInterfaceMethod errors now report the method name with its original declared casing instead of the lowercase-normalized form.
  • Bumped php-rs-parser, php-ast, and php-lexer to 0.9.1.
  • StubSlice::file and StubSlice::global_vars fields so a slice can describe the source file it came from and the @var-annotated globals it declares.
  • CodebaseBuilder and codebase_from_parts in mir-codebase — compose a finalized Codebase from per-file StubSlices without mutating shared state during collection.
  • DefinitionCollector::new_for_slice and DefinitionCollector::collect_slice — a pure-function entry point that returns a StubSlice instead of writing to a Codebase. Enables downstream consumers (e.g. salsa queries) to treat Pass 1 as a pure computation.
  • DefinitionCollector now builds a StubSlice internally; the existing new + collect API is preserved as a shim that injects the slice on completion.
  • Codebase::inject_stub_slice now populates symbol_to_file and global_vars when the slice has a file set.
  • PHP-first stub pipeline — stubs are now authored as PHP source files under stubs/{ext}/ with stub.toml manifests and transformed into Rust via the new mir-stubs-gen codegen tool, replacing the monolithic hand-written stubs.rs. (#243)
  • First-party stubs for 30 PHP extensions — bundled stubs cover common extensions (curl, pdo, json, mbstring, etc.), loaded into the codebase at startup. (#246)
  • 19 additional bundled-with-PHP extensions — calendar, exif, ftp, gd, gettext, opcache, pgsql, phar, readline, shmop, soap, sqlite3, sysvmsg, sysvsem, sysvshm, tidy, xmlreader, xmlwriter, xsl. (#251)
  • UndefinedConstant issue — the analyzer now emits UndefinedConstant for references to undefined global and class constants. (#242)
  • Target PHP version plumbed into ProjectAnalyzer — the analyzer accepts a target PHP version to gate version-specific behavior. (#249)
  • Upgraded php-rs-parser and php-ast to 0.9; upgraded toml, quick-xml, and criterion to latest. (#245)
  • BLAKE3 for cache hashing — replaced SHA-256 with BLAKE3 for the incremental cache and deduplicated per-file hashing. (#244)
  • Leading backslash in use imports — fully qualified use-imports (use \Foo\Bar;) now resolve correctly by stripping the leading backslash. (#247)
  • composer.json detection from path argument — when invoked with a path argument, mir now walks up from that path to locate composer.json instead of only checking the CWD. (#247)
  • Jobs are now gated (lint → stubs-up-to-date → test) and a dedicated step verifies that regenerated stubs match the committed generated files. (#250)
  • Recurse into nested function and class bodies — the analyzer now descends into nested function declarations and class definitions inside method/function bodies, catching issues in inner scopes that were previously invisible. (#223)
  • UndefinedClass for extends/implements — emit UndefinedClass when a class extends or implements a type that does not exist in the codebase or stubs. (#224)
  • InvalidScope for $this in invalid context — emit InvalidScope when $this is used outside of an object method (e.g., in a static method or free function). (#220)
  • Real-world Criterion benchmark suite — added a benchmark that runs analysis over a realistic PHP codebase for continuous performance regression tracking. (#219)
  • Intersection type hintstype_from_hint now correctly resolves intersection types (A&B), fixing false positives in type-narrowing and parameter checks. (#221)
  • StaticDynMethodCall support — dynamic static dispatch (Foo::$method()) is now handled as a distinct AST variant; evaluates arguments for taint propagation and returns mixed. (#216)
  • Upgraded php-rs-parser and php-ast to 0.8; migrated FileParser to ParserContext for O(1) arena reset on repeated parses. (#216)
  • MethodStorage stored as Arcown_methods in all storage types now holds Arc<MethodStorage>, making method lookups an atomic refcount bump instead of a deep clone. (#213)
  • Skip re-analysis on unchanged contentre_analyze_file returns cached results immediately when the file content hash matches, avoiding all four analysis phases on repeated LSP saves. (#204)
  • Skip finalize() on body-only changesre_analyze_file captures a structural snapshot before removal; if inheritance fields are unchanged after Pass 1, restores all_parents directly and skips the full class-hierarchy walk. (#205)
  • Trait-of-trait method resolutionget_method() now walks the full transitive trait chain with a cycle guard, eliminating false UnimplementedInterfaceMethod errors for methods contributed by indirectly used traits. (#209)
  • elseif narrowing and branch merge — elseif branches now correctly narrow on the parent if condition being false, and all elseif branches are folded into the post-if merge (previously only the last branch survived). (#211)
  • TKeyedArray foreach key typeinfer_foreach_types now derives TLiteralString / TLiteralInt keys from ArrayKey entries instead of always returning TMixed. (#211)
  • Switch fallthrough contexts — non-diverging case contexts are now collected and merged into the post-switch type environment; chain-fallthrough into a diverging case is correctly propagated. (#212)
  • Reference index memory reduction — intern reference keys with a lock-free u32 interner, store all references in a flat Vec<Ref>, and compact into two CSR index arrays after Pass 2. Expected ~5× reduction in reference index memory. (#202)
  • Single-pass definition collection — merged the pre-index and definition collection sub-passes into one parallel par_iter, eliminating the second parse of every file and removing the sequential serialisation barrier. (#196)
  • Column offsets in diagnostics now use Unicode character counts consistently throughout mir-core. (#201)
  • issues_by_file() on AnalysisResult — group analysis issues by their source file path for easier per-file reporting. (#154)
  • Symbol reference location trackingAnalysisResult::symbol_at resolves the symbol under a given position, enabling LSP go-to-definition and find-references. (#185)
  • ResolvedSymbol::file and codebase_key — extended resolved symbol information with the source file and codebase key for cross-file navigation. (#185)
  • Upgraded php-rs-parser and php-ast to 0.7. (#195)
  • Property access symbols now use the identifier span and nullsafe accesses (?->) are tracked. (#189)
  • Function, method, and static call symbols now use the identifier span rather than the full call expression span. (#192)
  • $this is now injected into method context so $this->method() calls are correctly resolved by symbol_at. (#193)
  • Diagnostic column offsets — fixed col_end always being equal to col_start (resulting in zero-width diagnostic ranges) and column offsets being raw UTF-8 byte positions instead of character counts. Diagnostics now correctly highlight the full variable/expression range with proper multi-byte character handling. (#182)
  • JetBrains phpstorm-stubs integration — mir now uses the authoritative phpstorm-stubs repository as the source for PHP built-in definitions. This provides comprehensive coverage of 500+ functions, 100+ classes, and 200+ constants across 33 PHP extensions. (#181)
  • Global variable registry — new @var annotation support for tracking globally-scoped variables declared outside of function/class scope. Reduces false positives in UndefinedVariable checks. (#160)
  • Dependency updates — upgraded php-rs-parser and php-ast to v0.6.0 for improved parsing robustness and performance.
  • is_builtin_function now uses the full loaded stubs to properly detect built-in functions across all extensions.
  • Generic type covariance and contravariance — full support for @template type parameter variance annotations in classes and methods. (#109)
  • Circular inheritance detection — emit CircularInheritance error when classes form circular inheritance chains. (#110)
  • Test fixture infrastructure — 22 new test fixtures covering previously uncovered rule categories, bringing fixture test count to 119. (#98)
  • AST doc_comment refactor — switched from manual docblock discovery to using AST doc_comment fields for more reliable comment association. (#107)
  • Removed mir-test-utils crate to eliminate circular dependency structure. (#106)
  • Class-level issue reporting — proper source locations (line/column in storage::Location) and code snippets now emit correctly for class-level issues. (#105)
  • Magic method parametersUnusedParam checks now exclude magic method parameters (__construct, __get, etc.). (#108)
  • Upgraded php-ast and php-rs-parser to v0.5.0.
  • Proper source mapping threading from ParseResult through the analysis pipeline.
  • SymbolTable adoption — parallel pre-indexing of file imports, namespaces, and known symbols for better scalability.
  • SourceMap and CommentMap — adopted from php-ast for reliable line/column resolution and comment association.
  • Test fixture infrastructure with 96 fixture-based tests across 10 rule categories.
  • Reduced UnusedVariable false positives from 405 to 127 through improved read tracking in closures and assignment contexts.
  • Initial release of mir, a fast incremental PHP static analyzer written in Rust.
  • Core features: type system, type inference, call checking, class analysis, dead code detection, taint analysis, incremental caching, parallel analysis.
  • Comprehensive built-in PHP function and class coverage.