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.
[0.71.0] - 2026-08-13
Section titled “[0.71.0] - 2026-08-13”Changed
Section titled “Changed”- 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.
[0.70.1] - 2026-08-06
Section titled “[0.70.1] - 2026-08-06”- 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
rustfmtcheck.
[0.70.0] - 2026-08-05
Section titled “[0.70.0] - 2026-08-05”AnalysisSession::ancestors_ofexposed: every ancestor of a class (extended class, implemented interfaces, used traits, transitively), most-derived first, self excluded. Wraps the already-trackedclass_ancestors_by_fqcnprimitive so a host can resolve a supertype chain without duplicating its own inheritance-edge index.AnalysisSession::function_signatureexposed: fullFunctionDef(params, return type, purity, etc.) for a global function resolved by FQN, mirroring the existingfind_functionprimitive at the session level.FunctionDefis now re-exported from the crate root alongsideDeclaredParam/TemplateParam/Visibility.WorkspaceSymbolIndex::class_like_by_short_nameexposed, andAnalysisSession::classes_named: short (unqualified) class/interface/ trait/enum name → every FQCN sharing it, incrementally maintained in lockstep with the existing FQCN-keyedclass_likemap (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 nouse-import/namespace match (the residual case after proper resolution — e.g. Laravel’s many same-namedFactory/Requestclasses) 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_likeitself, 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_classesmemoized per text revision, but subtype edges and anonymous-classimpl: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).
Changed
Section titled “Changed”- 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_indexfallback 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_filekeepslru = 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 underconcurrent_reference_cancel.
[0.69.0] - 2026-08-04
Section titled “[0.69.0] - 2026-08-04”AnalysisSession::files_mentioning_classexposed: lets a host reuse the persistent class-mention index (previously internal-only, used byindexed_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_anyexposed: multi-needle form offiles_mentioning_classfor a host resolving several candidate names at once (e.g. an owner FQN plus its subtype closure) in one shared pass.ClassMentionIndexsupports raw (no-word-bound) needles:add_raw_names/add_raw_mention_needlesadmit 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.
Changed
Section titled “Changed”-
The mention index is now the single implementation of the reference- and subtype-gate textual predicate;
IdentifierNeedlesis 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/::__constructraw 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 oneindexed_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 coldindexed_subtype_classeson 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, aprepare_file_for_analysiscall, and each bulk-registration window (set_workspace_files,set_vendor_files,index_batchchunks) 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_classesre-walked every candidate file on every call, even a byte-for-byte repeat. Same shape as theindexed_references_tofix 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_tore-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 owncurrent_revision— not a hand-rolled counter, which a host writing text directly viaSourceFile::set_text(bypassingingest_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).
[0.68.0] - 2026-08-04
Section titled “[0.68.0] - 2026-08-04”is_builtin_constantexposed: same shape as the existingis_builtin_function, letting a consumer (e.g.php-lsp) narrowtextDocument/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
uriextension (PHP 8.5).
assert()/ifnull-check now narrows array-offset access:assert($arr['k'] !== null)andif ($arr['k'] === null) { return; }never narrowed the offset’s own value the way the equivalentisset()/property-access checks did, so a later$arr['k']read stayed nullable and misfiredPossiblyNullArgument.- 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 bogusRedundantConditionon 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'] = von a by-ref parameter mutates caller-visible state through the reference, but the write path never marked the base as read, flaggingUnusedParamon out-params only ever written via a nested offset assignment. **(Pow) now types asint|float, not int-preserving: PHP’s**genuinely overflows int to float at runtime (2 ** 63is 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 anint|floatresult to barefloat.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-keyarray{...}shape instead of a bareint[];Throwable/Exception::getTrace()gets the real per-frame shape;get_declared_classes()returnslist<class-string>instead ofstring[];realpath()returnsnon-empty-string|falseinstead ofstring|false. - Trait property’s explicit default no longer ignored: trait property
collection hardcoded
default: Nonefor every non-promoted property, ignoring the AST’s actual default-value expression, so a class composing only defaulted trait properties was flaggedMissingConstructoreven though PHP never leaves them uninitialized. instanceof/is_subclass_ofnow narrowcallablelikeTObject/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.Closureis 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 nativeint|falsehint right below it — an isolated typo (the siblinggetFileName()already got this right).trait-string/enum-stringrecognized as docblock type keywords: the parser had arms forclass-string/interface-stringbut not these two, falling through to the named-class catch-all and flaggingUndefinedDocblockClass. Also added to the separate gate that keeps a type keyword from being namespace-qualified as if it were a class name.@throws voidno longer stored as a bogus throw class: both the free-function and method collectors namespace-qualified every@throwsentry before checking whether it named a pseudo-type (void,never,self, …), so a namespaced file’s bare@throws voidbecame{namespace}\void, which no longer matched the pseudo-type check and was stored as a real throwable class instead of being dropped.- Bare-
$thisassert-if-true now narrows the receiver: the assertion handler’s special case only matched@psalm-assert Type $this->prop; a bare$thisassertion (no->) fell through to a by-name param lookup that can never match, so it was silently never applied. class/interface/callable/enum/trait-stringnow satisfynon-empty-string: none of these atoms can ever hold the empty string in real PHP, mirroring the existingnumeric-stringcase.idn_to_ascii/idn_to_utf8’s$idna_infois now a pure out-param: the stub declared its 4th by-ref param as plain, non-nullablearray, so passing a nullable/uninitialized by-ref variable purely to receive the output flaggedPossiblyNullArgumentagainst a type that’s never actually read.@var callable(...): Rkeeps its return type across a space: the docblock parser gave up at the first top-level whitespace not preceded by a union/intersection continuation, socallable(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 tomixedfor the result type instead of consulting__invoke()’s declared return type, and an invokable object never satisfied acallable(...): R/Closure(...): Rtarget 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-functionmethod_exists()/property_exists()guards were previously recognized, not Reflection’s own instance API. MissingThrowsDocblocknow respects a covering localtry/catch: both the inter-procedural call check and the direct-throw check compared only against the enclosing function’s own@throws, never a localtry/catchthat already catches the exception before it can escape.@phpstan-typeresolves without Psalm’s=syntax: real PHPStan’s@phpstan-type Name Exprhas no=(unlike@psalm-type Name = Expr), but both tags shared the samesplit_once('=')parse, so every no-=@phpstan-typealias silently failed and cascaded intomixedeverywhere 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 commonnamespace Foo; function json_encode() { return \json_encode(...); }deprecated-wrapper idiom.- Excluding
''narrowsstringtonon-empty-string: the exclusion branch only stripped an exact-matching literal-string atom; excluding""specifically now also upgrades a barestringatom, covering$x === ''/$x !== ''/assert($x !== '')guards. Also fixes a dependent gap: string-offset access only recognizedstring/literal strings, falling back tomixedfor every other string subtype once one could actually reach that position. MissingPropertyTypenow honors a@var/@paramdocblock 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 flaggedInvalidArgument/InvalidPropertyAssignmentagainst a param/property typed as an interface the enum implements (including implicitUnitEnum/BackedEnum) or bareobject. getenv()no longer merges its arg-count overloads:getenv($name)with a non-null$namewas typedarray|string|false— the array-of-all-vars branch only applies when$nameis omitted/null.PHP_OS_FAMILYno longer widened past its stub literal: the environment-dependent-constant widening coveredPHP_OS/PHP_SAPI/DIRECTORY_SEPARATOR/PHP_INT_SIZEbut missed this one, soPHP_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 falseUndefinedMethod. - De Morgan narrowing for ANDed negated
instanceofchains: the&&/andnarrowing 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. foreachkey/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 apreg_split()===falsefallback’s foreach key to a literal0instead of widening to plainint.- Docblock builtin leniency reconciled against a confirmed local
shadow:
MismatchingDocblockReturnType/ParamTypecompared 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_filterinterprocedural 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/Generatornames (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 asUndefinedMethod. - Psalm-only suppress kind names now alias to mir’s own
IssueKind: a@psalm-suppressnaming a Psalm-only check mir models under a different name (PossiblyNullReference,PropertyNotSetInConstructor) neither suppressed the underlying issue nor counted as used, flaggingUnusedSuppresson top of the original diagnostic. - Type-omitted
@param $namedocblock line no longer parsed as a type: the parser required whitespace before$nameto 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/bindToscope 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/$valuestub declarations were dropped entirely — any receiver typed as one of these interfaces lost property access, flaggingNoInterfacePropertiesand widening the result tomixed. - Namespace-relative qualified docblock class names resolve
correctly: a docblock class name containing
\was used verbatim instead of having the current namespace prepended, soWarning\Warninginsidenamespace App;stayed the literal (nonexistent)Warning\Warninginstead of resolving toApp\Warning\Warning.
[0.67.0] - 2026-08-03
Section titled “[0.67.0] - 2026-08-03”Changed
Section titled “Changed”warm_start_filesnow 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 synchronousanalyze_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 toreanalyze_files_cancellableon a background thread (same pattern asprefetch_imports) so the cost lands during idle time after boot instead of on the user’s first request.
[0.66.1] - 2026-07-31
Section titled “[0.66.1] - 2026-07-31”<ignoreFiles>/<projectFiles>directory matching works whencanonicalize()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 plainPathBuf::joinfrom a config-relative entry never goes through, sostarts_with/==comparisons silently failed on Windows even after stripping the\\?\verbatim prefix. Every such comparison now routes through a sharednormalize_for_compare(canonicalize-with-fallback + strip-prefix) helper.
[0.66.0] - 2026-07-31
Section titled “[0.66.0] - 2026-07-31”new $var(...)accepts an object receiver:is_valid_class_name_typerejected any object-typed value, butnew $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 toconfig_baseliterally and matched viastarts_with, so a*segment never matched anything — an ignored/wildcarded directory was silently analyzed anyway.*now expands the same way Psalm’s ownglob()-based resolution does: matches within one path segment, never across/.-cwith a bare relative config path resolves correctly:Path::parent()on a bare relative filename (-c mir.xml) returnsSome(""), notNone, so thecwdfallback 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_pathcanonicalizes 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 sharedstrip_verbatim_prefixhelper and applied it at every such comparison.<projectFiles>directories are honored in the composer flow:config.project_dirswas parsed but never consulted — a whole-project run always analyzed everyPsr4Map::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, andamqp/memcache/imap/ldap/snmp/ssh2/xdebugPECL stubs:PhpStormStubsMap.phpalready listed every entry for these extensions, but theirstubs/directories were never vendored, sobuild.rs’s stub-dir set skipped them and every symbol was reported undefined. - Refined string atoms are subtypes of
scalar:atomic_subtypehad a(refined-int-family | TLiteralString, TScalar)arm but nothing forTNonEmptyString/TNumericString/TClassString/TInterfaceString/TCallableString/TEnumString/TTraitString— every one of these is still just a string, hence a scalar, at runtime. T[]docblock shorthand keys onarray-key, notint:parse_type_string’sType[]shorthand hardcoded an int key, but Psalm/PHPStan document this asarray<array-key, Type>— a string-keyed array (array_column()output, a PSR-3$contextarray,class_implements()/class_parents()’s own class-string-keyed result) into amixed[]/string[]-docblocked param falsely flagged.- Trait-declared private/protected properties are accessible in the
consuming class:
property_inaccessiblecomparedself_fqcnagainst 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, andDIRECTORY_SEPARATORcarry the bundled stub’s singledefine()literal, so every cross-platform/SAPI guard comparing against a different literal was flaggedImpossibleIdenticalComparison. elseifconditions chain instead of re-deriving from the primaryif: eachelseif’s pre-condition context re-branched from the outerif’s own context and re-narrowed only the primary condition, discarding every earlierelseif’s condition outright — both its assignments and its type narrowing.- A body that always throws infers
never, notvoid:merge_return_typesreturnedvoidunconditionally 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')andisset($obj->x)recorded no fact at all, so a later$obj->xread inside the guarded branch still flaggedUndefinedPropertyeven though the guard just proved it.- A global
\Foointersection 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_traitsonly listed a class’s directly-used traits, so a trait composed only via another trait still looked like a real ancestor, flaggingFinalMethodOverriddencomparing 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 emittedInaccessiblePropertyunconditionally, with no check for the magic-get fallback. - Offset write/unset on a readonly
ArrayAccess-object property is legal:$this->prop[$k] = $vandunset($this->prop[$k])dispatch tooffsetSet/offsetUnseton 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 anArrayAccess-typed property. - A property
@vardocblock type preserves native nullability: a@varrefining a nullable native property hint but omitting|nullerased that nullability, the property analogue of the already-fixed param-side gap. is_callable()narrowing makes a bare object satisfycallable: narrowing only filtered atoms, never transformed one into something acallable-typed target accepts — a bareobjectvalue stayed typed asobjectafter 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$resultvalue — 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 afunc_get_args()-using method exists only to let call sites pass extra positional args without a falseTooManyArguments— 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-typealias named afterself/static/parentresolves as the alias:parse_type_stringresolves 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.
Changed
Section titled “Changed”- PHP parser suite (
php-rs-parser,php-ast,php-lexer,phpdoc-parser) upgraded to 0.19.0:php-astnow represents PHP 8.6’s partial-application placeholder (?/...in a call argument) asArg::value: Option<Expr>instead of assuming a value is always present. Any placeholder argument currently raises a hardParseError(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.
[0.65.0] - 2026-07-31
Section titled “[0.65.0] - 2026-07-31”- Promoted constructor properties honor docblock refinements like ordinary
params: a promoted property only let a
@paramdocblock override the native hint when the hint was exactly plainarray/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 keptnullin the result unchanged, leaving a written-to nullable array nullable forever after. @paramkeeps 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/?arraynative hint paired with a non-nullable@paramsilently lost its nullability, producing falseImpossibleIdenticalComparisonandNullArgumentdiagnostics.- Backed enum
->valuerecognized in an exhaustivematch:Kind::Foo->valuetyped 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 typedconst arrayhint needed a second, related fix so the hint didn’t discard the inferred literal shape. @psalm-assert Type $this->propertynow applies at call sites: the assertion resolver only ever matched against a declared parameter name, so a$this->propertytarget (written from the asserting method’s own perspective) could never match on any method, regardless of its arg count.elseifno longer re-narrows its own already-narrowed condition: theelseifbranch 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
TooManyArgumentswas affected —TooFewArgumentsstill 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_filtercallbacks 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.
MissingConstructorno 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():Ttemplate double-binding: a bare template alternative no longer also absorbs acallable():T/closure argument already bound through its return type.literal-int/literal-stringrecognized as docblock keywords: previously fell through to a bogus named-class bound, always failing a genuinely-satisfying literal argument.
Performance
Section titled “Performance”indexed_references_toskips 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.
[0.64.0] - 2026-07-30
Section titled “[0.64.0] - 2026-07-30”- Warm start seeds the workspace symbol index singleton:
warm_start_filesnow projects per-file declarations from the disk definition slices it already reads for subtype-edge replay (a shareddecls_from_sliceprojection, byte-identical to the tracked query’s) and seeds theWorkspaceSymbolIndexSingleton— 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_indexwalk, onecollect_file_definitionsslice 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 (theindex_batchcontract) so later lazy stub loads cannot leave the seeded singleton incomplete. - Pending-set reconciliation for mirror-only writes: plain
upsert_source_file_with_durabilitycalls (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 ofindexed_references_to,indexed_subtype_classes,indexed_use_import_locations,subtype_files,class_issues,reanalyze_files_cancellable, andFileAnalyzer::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_readyandworkspace_index_walks(executions of the tracked fallback walk) let hosts assert warm-started sessions never pay the O(all-files) rebuild.
self/static/parentinsideclass-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 falseUndefinedMethodon 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: aClassConstAccesssubject 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
__callsatisfiesUndefinedMethod: aReal|TestDoubleunion (a mocking-library idiom, e.g. Prophecy) flaggedUndefinedMethodwhen the real class lacked a method only the test-double sibling declares. A sibling atom having a catch-all__callnow suppresses the check on atoms that lack both the method and their own__call. - Atomic
cache.binwrite:flush()wrote the cache directly in place viastd::fs::write, so a crash mid-write left a truncated cache silently discarded on the next boot. Switched to tempfile-in-same-dir + rename, matchingstub_cache.rs’s pattern. - Vendor the missing
ast/ast.phpstub:PhpStormStubsMap.phpalready listed all 201ast\*(nikic/php-ast) entries pointing at it, but thestubs/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/includeoutside 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, skippingvendor/. 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
ReflectionParameterand aReflectionProperty, so an attribute restricted toTARGET_PROPERTYalone should be accepted on it. mir only checkedTARGET_PARAMETER, false-flagging every such attribute. NoInterfacePropertiesfires 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-propertiesor 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 phantomUnusedSuppress. 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, forprotected) went unreported, hiding a runtime-fatal error. Checked for both instance ($obj->prop/$obj?->prop) and static (Class::$prop/self::$prop) property reads.
Performance
Section titled “Performance”- Parallel
warm_start_filesdisk-slice reads: the per-file loop read theAnalysisCacheandStubSliceCacheserially (~0.8-0.9s of a 3.9s warm boot at 15.4K files). Split into a rayon-parallel read phase (mirroringindex_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.
[0.63.0] - 2026-07-27
Section titled “[0.63.0] - 2026-07-27”-
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(), inheritedSub::m(),self::/static::/parent::m(), aliasedAlias::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 —FunctionDefhad 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_keyheld 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_definitions’lru = 4096cap 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, sinceparse_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::putserialized 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()/Dropjoin 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_declarationssharedcollect_file_definitions’lru = 4096cap, butworkspace_symbol_indexwalks every source file through it on each rebuild — and rebuilds after everyworkspace_revisionbump, 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 heavyweightcollect_file_definitionskeeps its LRU. -
Constructor gate admits explicit re-init call sites: the
__constructreference gate (owner short name only, sincenew 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). -
AbstractMethodCallno 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. Onlyself::,parent::, an explicit class name, and aclass-stringreceiver (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. -
__calldispatch honors its own declared return type: a magic-method call always collapsed tomixed, discarding__call’s own@returndocblock — e.g. a fluent test-double stub typed@return static. Falls back tomixedonly when__callitself has no declared return type. -
A function-level suppression now covers its whole body:
@psalm-suppress/@mir-ignore/@suppresswritten 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’sStaticMethodCallarm only resolved a literal class name (orself/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-outwrite-back now resolves a named-arg-reordered target: the write-back loops inmethod.rs/function.rs/static_call.rsindexedcall.argsby 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 singleArgis 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_innerhad no arm forTCallable/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_suppressionsonly 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 reportedUnusedSuppress. -
Taint now propagates through the error-suppression operator:
is_expr_taintedhad 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-freeoverride 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_namesnow propagates into a closure’suse(&$x)capture: a write to a by-ref-captured by-ref parameter inside a closure body was invisible tocheck_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 $varwrite is now flagged under mutation-free contracts: unlikeglobal $x, a static variable’s write had no write-time tracking at all — only the one-time@puredeclaration 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, unlikenew 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@puregated a free-function call at all — passing$thisor a parameter into a not-provably-safe callee went unchecked, unlikenew 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@purecaught it, since these two tags deliberately permit reads. -
A negated
trueliteral or intersection assertion target now narrows:negate_assertion_typehad arms forTNull/TFalse/ named-object atoms but none forTTrueorTIntersection—!true $vand!(A&B) $vboth 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 andmethod_call_receiver_fqcnboth only matched a bare 1-hop receiver, silently no-oping the whole assertion for a 2+-hop chain like$c->box->inneror$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_introducerstopped 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 toNextLinescope, missing the attribute’s own diagnostic (plus reporting a spuriousUnusedSuppress). -
Chained-receiver resolution now handles a static hop:
resolve_chained_receiver_typehad 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()andFactory::repo() ->get()both fell through toNone, so a@taint-sourcemethod reached through either static hop stayed silently untainted. -
@if-this-isnow 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_fixpointruns exactlyaliases.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-istemplate substitution now also applies to static calls: the same gap fixed for instance-call syntax, for a method reached throughself::/static::or an object-typed variable’s static call syntax. -
@if-this-isnow 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@templatealways 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@purefunction’snewcalls 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-outnow supports a chained 2-hop receiver:$this->a->b->method()previously silently no-oped a self-out write-back, sinceextract_any_prop_accessonly matched a bare-variable object. Adds a synthetic “base->mid_prop” key to the existing flatprop_refinedmap, and extends invalidation to strip stale chain-prefixed entries too. -
A variable class-string receiver now resolves for static property writes:
$cls::$prop = x(aclass-string<Foo>-typed variable receiver) silently bypassed purity/readonly/taint tracking across every caller ofresolve_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 = xsilently bypassed the pure/external-mutation-free/immutable write checks entirely, since the property-name resolver returnsNonefor 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_typehad 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-sourcemethod 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 newhas_dynamic_tainted_var_defscope flag marks any otherwise-untracked variable as possibly tainted once a tainted-sourceextract()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$idwas 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 — echoingsprintf’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 $xwas a total no-op —negate_assertion_typebailed 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:!emptyis itself a multi-atom falsy union, so excluding it fromboolnow correctly narrows totrue. -
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-typealias referenced from a method’s@paramor a constant’s@varsilently 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@varand an interface constant’s@varnever expanded the declaring class-like’s own type alias, unlike@param/@returnand 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] = $valswrites 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. -
$$namenow resolves its real type and taint when$nameis a literal string:analyze_variable_variablealways returned baremixed, andis_expr_taintedhad 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-assertnow routes through the shared assertion applier:function.rs,method.rs, andstatic_call.rseach hand-duplicated their own var/prop/static-prop application loop for an unconditional assert call — none of which readassertion.param_key(silently corrupting the whole parameter’s type for an array-key- targeted assertion), handled a variadic parameter, or resolved a named argument. Extractedapply_one_assertionfrom 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_varfor the new type but never touched its taint bit, so a value likepreg_match’s$matchesstayed untainted even when derived from a tainted subject. -
The backtick shell-exec operator is now checked for tainted input: the
ShellExecarm discarded its interpolated parts entirely — never analyzed, never taint-checked — unlike its functional twinshell_exec()/exec(), which already ran throughis_expr_tainted. -
A method call on an untyped
mixedparameter is now flagged under@external-mutation-free: the unresolvable-receiver blanket check only gated onis_in_pure_fn, unlike the resolved-callee checks just below it which also coveris_in_external_mutation_free_methodfor the same parameter-receiver shape. -
Chained taint-source resolution now handles an array-index hop:
resolve_chained_receiver_typehad noArrayAccessarm, unlike its siblingroot_receiver_var, so a chain like$this->repos['main']->getParam()broke off withNonebefore the@taint-sourcecheck ever ran. -
Same-file type aliases now expand in an interface/trait template bound:
interface.rs/trait.rsresolved a@template T of Aliasbound 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_fqcnonly triedextract_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:
ImpureByRefAssignmentonly fired for a plain=/compound-arithmetic write to a by-ref parameter. A sharedcheck_var_write_puritynow also covers.=,++/--, an array-index write,unset(),foreach(&$v), and passing the variable further by reference to a builtin likesort()— each of these mutation shapes previously bypassed the check via its own code path.
[0.62.0] - 2026-07-24
Section titled “[0.62.0] - 2026-07-24”- 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 byArcidentity (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.
[0.61.0] - 2026-07-22
Section titled “[0.61.0] - 2026-07-22”Changed
Section titled “Changed”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 fornew 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 byindexed_subtype_classes) is now ASCII-case-insensitive, matching PHP’s case-insensitive class/function/method name semantics —extends baris no longer invisible to a cold subtype scan forBar.
Removed
Section titled “Removed”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_batchoffered 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-agnosticmethname: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_togate needles: constructor (__construct) queries no longer include the bare method name as a gate needle, only the owner class’s short name.__constructappears 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_toPhase 1 cancellation: the serial warm-up loop now catchessalsa::Cancelledand 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.
[0.60.0] - 2026-07-19
Section titled “[0.60.0] - 2026-07-19”- Narrowing:
is_countable()/is_iterable()’s false branch now excludes afinalnon-implementing class atom (when its own hierarchy doesn’t already implementCountable/Traversable), mirroring the existing final-class exact-exclusion soundness gate. filter_var(): infers the real result type from a literalFILTER_VALIDATE_*filter constant (int/float/bool/regexp/url/email/ip/mac/domain) instead of the stub’s blanketmixed; falls back to the stub whenever a 3rd (options) argument is present.- Narrowing:
class_implements()/class_parents()combined witharray_key_exists()now narrow likeinstanceof, 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()/::classnow 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 likecount()/sizeof()/strlen(). - Narrowing:
($this->prop ?? FALLBACK) === FALLBACKnow 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::Caseand$obj->prop instanceof Xnow also prove$objitself non-null, matching the existing nullsafe/null-check arms.
- Narrowing:
array_is_list()now recognizesTKeyedArrayshapes — previously any array literal or docblock shape was narrowed as if it could never be a list, regardless of its ownis_listflag. @varannotations: a free function’s own@psalm-type/@phpstan-typealias is now expanded in a bare@var Result $xannotation, 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 ifis_null()were called). - Narrowing: int-comparison narrowing (
$x > PHP_INT_MAX,$x < PHP_INT_MIN) no longer treats thei64::MIN/MAXboundary as unconstrained — the comparison is now recognized as impossible instead of leaving a dead branch reachable.
[0.59.2] - 2026-07-18
Section titled “[0.59.2] - 2026-07-18”- CI: 0.59.1’s crates.io publish still failed on
mir-plugin, since a first-time crate publish needs thepublish-newtoken scope that the CI token doesn’t have;mir-analyzerandmir-phpwere never reached.mir-pluginhas 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.
[0.59.1] - 2026-07-18
Section titled “[0.59.1] - 2026-07-18”- CI: the release workflow never published
mir-plugin, somir-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.
Performance
Section titled “Performance”@varalias expansion:extract_var_annotation_fromdid a freshfind_class_likelookup 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)onStatementsAnalyzer.
[0.59.0] - 2026-07-18
Section titled “[0.59.0] - 2026-07-18”- Plugin system (new
mir-plugincrate), modeled on Psalm’s plugin API:- Rust plugins implement the
MirPlugintrait 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, andafter_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),FunctionReturnTypeProviderInterfaceandMethodReturnTypeProviderInterface(best effort, cached per call signature); other hook registrations are reported and skipped. - Plugins emit custom issues (
PluginIssue, codeMIR1509) that respect@mir-suppress <Name>,<issueHandlers>, and baselines under their own issue names. - Class-property providers (Psalm’s
PropertiesProviderInterfaceshape): a plugin declares marker classes viaclass_property_classes()and types otherwise-undeclared properties fromclass_property(). Dispatch is ancestor-aware — a marker on a framework base class covers every subclass — and theClassPropertyProviderEventexposes the receiver’s array-literal property defaults (e.g. Eloquent$casts) so the plugin needs no AST access. Fires on a property-access miss beforeUndefinedPropertyis reported.MIR_PLUGIN_API_VERSIONbumped to 2.
- Rust plugins implement the
- Narrowing:
$this->prop instanceof A || $this->prop instanceof B(OR-disjunctinstanceof) now narrows property receivers, not just plain variables. - Narrowing: literal
bool/int/stringcomparisons 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 newReferenceKind::Receiver. Scoped to property access (instance + static, includingself/$cls::); method-call receivers already had an equivalent chain-gap answer via theirexpr_spanfallback.array_map/array_reduce: the element/result type now resolves through an opaque, unrefinedcallableparameter 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/selfnested 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 tomixed. - Generics: docblock return types on template-free methods now
namespace-qualify class names in generic positions — previously
@return Builder<static>stored a bare relativeBuilder(only methods declaring their own@templategot qualification) and the class was never found again. - Generics:
@template-extends Base<U>/@template-implementstype 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>extendingBuilder<TRelated>) resolve their return templates to the concrete bound type. - Properties:
$obj->prop’s inferred type now widens to includenullwhen$objitself 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) || RHSno longer leaksRHS’sdivergesflag into the surrounding scope. - Narrowing:
array_key_exists()no longer stripsnullfrom an already-proven key’s type. - Narrowing: fixed a false-positive
RedundantConditionon 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 asInvalidDocblockinstead of silently falling back tomixedor producing a misleading “unclosed generic type” message. @varannotations: a bare@var Result $xvariable annotation now expands@psalm-type/@phpstan-typealiases declared on the enclosing class/interface/trait/enum’s own docblock, matching how@param/@returnreferences to the same alias already resolved. A global function’s own@psalm-type(not tied to a class) remains out of scope, as before.
[0.58.0] - 2026-07-17
Section titled “[0.58.0] - 2026-07-17”- 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 againstfalseon call results (e.g.strpos($h, $n) != false) now narrow like the strict===/!== falsearm 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 — aclass-string<Bar>atom unrelated toFoois dropped from the true branch (and kept in the false branch), matching the existing object-side behavior.
Changed
Section titled “Changed”- Updated
salsafrom0.27.0to0.28.0; picks up php-rs-parser/php-ast/php-lexer/phpdoc-parser patch bumps transitively.
[0.57.0] - 2026-07-16
Section titled “[0.57.0] - 2026-07-16”- Narrowing:
$arr === []narrows to the empty collection (the!== []direction was already handled), and$obj::classcomparisons narrow likeget_class().
use:postings for unresolvable imports:useitems whose target class/function/constant doesn’t resolve (vendor-only, not yet loaded, or genuinely missing) previously recorded nouse: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 leavesTIntegralFloatin the negative branch.
[0.56.0] - 2026-07-16
Section titled “[0.56.0] - 2026-07-16”- Reference postings persist from LSP sessions: the session’s posting-commit sites — the parallel re-analysis sweep (
reanalyze_dependents/reanalyze_files_cancellable) andindexed_references_to’s on-demand freshness pass — now write each committed file’s reference locations into the attachedAnalysisCache, keyed by content hash with a surface fingerprint, exactly like the CLI batch pipeline. A returning session’swarm_start_filestherefore 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. NewAnalysisSession::flush_analysis_cache()persists the staged entries — hosts should call it after their warm sweep completes and on shutdown.
[0.55.1] - 2026-07-16
Section titled “[0.55.1] - 2026-07-16”- 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 beforeSvcexisted) 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.
Changed
Section titled “Changed”- 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.
[0.55.0] - 2026-07-15
Section titled “[0.55.0] - 2026-07-15”- Delta-maintained inverted subtype index (
SubtypeIndex): resolved parent FQCN → direct children, updated per file commit instead of scanned per query. New session queriesindexed_subtype_classes(transitive subtypes with declaration name ranges, short-name-lenient roots, anonymous-classimpl:postings) andindexed_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_declarationcontributes 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}::__constructatnewsites, 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. SubtypeClassSitepublic 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 (viaAnalysisCacheand theStubSliceCachestub-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::UseImportand ause:-prefixed index posting foruse Foo\Bar;/use function/use constimport name tokens, plusAnalysisSession::indexed_use_import_locationsto read them back scoped to a file set. Deliberately not folded into the plaincls:/fn:/gcnst:key, since an import alone isn’t a usage.
Changed
Section titled “Changed”- The reference index is now always maintained with replace-per-file
semantics (
FileAnalyzercommits viaset_file_reference_locationsand 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_fileunconditionally clears the file’s old definitions and reference locations before re-ingesting.
collect_definitions(the vendor-tree walker) andanalyze_paths(the CLI batch pipeline) never fed the subtype index from theStubSlicethey already collect, unlike the single-file LSP edit path (ingest_file) — an implementor living only invendor/, 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. Addspropdecl:/cnstdecl:postings, plus amethdecl:posting for interface methods (which have no body/params to anchor the existing name-span heuristic on).
Removed
Section titled “Removed”-
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, andreferences_to_in_files_cancellable— superseded byindexed_references_to. The scan-basedclass_subtype_filestracked query is also gone;subtype_files()keeps its public signature, now backed byindexed_subtype_classes.BREAKING CHANGE: callers of the removed
references_to*methods must migrate toindexed_references_to(symbol, files, include_declaration, should_cancel).
[0.54.0] - 2026-07-14
Section titled “[0.54.0] - 2026-07-14”- 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, andInvalidReadonlyPropertyDeclarationfor the property-default case; the untyped-readonly-property half of that check is already caught by the parser itself as aParseError, so it isn’t duplicated. array|Traversablecollapses toiterablein type display: PHP’siterableis defined as exactlyarray|Traversable, so a union containing a matchingTArray{key,value}+Traversablepair now prints asiterable/iterable<K, V>instead of the decomposed form. A bare (unparameterized)Traversableonly collapses against the fully-generic array — pairing it with a more specific array is left alone, since the bareTraversablemakes no key/value guarantee and collapsing would overclaim precision.- Defaulted
mixedtype parameters collapse in display:array<mixed, mixed>/array<array-key, mixed>now print asarray(same fornon-empty-array),list<mixed>/non-empty-list<mixed>aslist, andTraversable<mixed, mixed>-style named objects as the bare class name when every param is a literal, unconstrainedmixed. Template params bounded bymixedare left untouched since they carry real signature info. Also fixes the root cause for the array case: a barearraydocblock keyword was building its key asTMixedinstead of the true PHP array-key domain (int|string), now shared viaType::array_key().
vsprintf()didn’t infer anon-empty-stringreturn type likesprintf():sprintf_return_typeonly ever consults the format-string argument (index 0), whichvsprintfshares withsprintfverbatim — extending the special case tovsprintfcloses the same precision gap already fixed forarray_reduce.- Hover symbol missing on plain variable-assignment write targets:
assign_to_target’sExprKind::Variablearm only updated flow-state variable tracking, never callingrecord_symbol— unlike the read path (analyze_variable) and the already-fixed property/static-property write siblings. Hovering$xat its own$x = 5;site (or anylist()/array-destructuring target) resolved nothing. - Class-constant type not inferred from a same-file
ClassConstAccessinitializer:const DEFAULT = Suit::Hearts;(no native hint or@vardocblock) collapsed to baremixed, sinceinfer_const_valuehad noClassConstAccessarm — 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 tomixedas before. Enum cases infer a plainTNamedObjectto matchfind_class_constant_in_class’s own representation, notTLiteralEnumCase, 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, unlikeisset()’scollect_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. Addsnarrow_shape_path_key_exists, parallel tonarrow_shape_pathbut applyingarray_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 plaininstanceof’snarrow_prop_instanceof—is_a($this->item, Foo::class)andis_subclass_of($this->item, Foo::class)silently no-op’d on a property receiver, missing realUndefinedMethod/PossiblyNullMethodCallbugs after a proving guard. Addsnarrow_prop_is_a/narrow_prop_is_subclass_ofmirroring the existing variable-based semantics, sharing a newapply_prop_narrowedhelper withnarrow_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 (OuterusesInner, class usesOuter) never matched thetraituse:exemption marker recorded underInner’s FQCN — falseUnusedMethod/UnusedProperty. Now walks the already-transitiveclass_ancestors_by_fqcnand keeps the trait entries instead.array_udiff/array_uintersectfamily (and their*_ukey/*_uassocsiblings) rejected the comparator callback: phpstorm-stubs types the PHP-8.0+ trailing variadic...$restas@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 spuriousInvalidArgument. Retypes the docblock slot asmixed.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 aSomeClass|arrayunion this emptied the type and marked the else-branch unreachable, hiding real bugs inside it and raising a falseRedundantConditionon the check itself. Now only the atom known for certain to satisfy the check (a plain array) is excluded.- Generator
returnchecked against the wholeGeneratortype instead ofTReturn:return <expr>;inside a generator setsGenerator::getReturn()’s value (theTReturn/4th type-param slot), not the generator object itself. Comparing it against the whole declaredGenerator<K,V,S,R>type raised a false-positiveInvalidReturnTypeon the textbook-correct return-value idiom. - Redundant intersection parts printed verbatim:
TIntersectionhad no de-dup at all, so a redundantFoo&Fooprinted verbatim instead of collapsing toFoo. 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|falsedidn’t merge intoboolduring union construction:Type::add_typealready collapsedTTrue/TFalseinto an existingTBool, but never merged the two literals intoTBoolwhen both showed up without one already present (e.g. inferring the return type of a function with onlyreturn true;/return false;branches). Lossless, since PHP’sboolis defined as exactlytrue|false.iterable’s array branch keyed onmixedinstead ofarray-key: same root-cause bug as the earlier bare-array fix — parsing bareiterableand single-paramiterable<V>built the array branch’s key as a literalTMixedinstead of the true PHP array-key domain (int|string), via the sharedType::array_key()constructor. This misrepresented the key type and defeated thearray<mixed,mixed>-style display collapse foriterable’s array member.- Malformed
InvalidOperandmessage 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-typecouldn’t import from an interface/trait/enum:InterfaceDef/TraitDef/EnumDefhad notype_aliasesfield at all, and@psalm-import-type’s same-file resolution only searchedself.slice.classes— so a@psalm-typealias declared on an interface, trait, or enum could never be imported, even from within the same file. Adds the field (mirroringClassDef) 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:
ParseCachehashed 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/@removedfiltering, different#[LanguageLevelTypeAware]resolution)StubSlicefrom an earlier version. - Trait/enum method parameter default expressions never analyzed: trait and enum method scopes hardcoded
analyze_param_defaults: falseon 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_subtypehad no arm for two pure-intersection types, falling to thesub == supfast path only — a false-positiveMethodSignatureMismatchon valid covariant-return/contravariant-param intersection-type overrides (e.g. wideningCountable&ArrayAccess&Iteratordown toCountable&ArrayAccesson 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@deprecateddocblock 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 calledemit_docblock_issuesorversion_allowson 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@varon an enum case/const went unflagged, and@since/@removedversion-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 publicanalyze_sourceentry 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, andcheck_duplicate_declarations, all already present inanalyze_bodies(the real batch/LSP pipeline).@vardocblock ignored on trait properties: trait property collection only ever used the native type hint, unlike the equivalent class property, which lets an@vardocblock refine it. A trait property typed only asmixednatively (a common generics workaround) with a more specific@vardocblock lost the refinement everywhere the trait is used.- Structural dependency edges missing for enum/trait declarations:
file_structural_depsnever iterateddefs.slice.enumsat all, and the trait branch only walkedt.traits(neverown_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 theown_properties(@propertydocblock) walk it was missing. InvalidOperandmissing on prefix++/--: prefix++/--skipped the bool/non-empty-stringInvalidOperandcheck that postfix++/--already had one function away — the same PHP warning/deprecation fires for both forms.#[LanguageLevelTypeAware]ignored on property declarations: property collection inclass.rs/trait.rsnever 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/$linelost their PHP-8.1+ refined string/int type and fell back tomixed.- Abstract-method check didn’t recurse into trait-of-trait:
check_abstract_methods_implementedwalked the legacyself.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-recursiveclass_ancestors_by_fqcnused everywhere else in this file. UnusedSuppressdropped onre_analyze_file’s cache-miss path: the non-cache-hit branch (definition collection + body analysis) never calledapply_suppressions_and_emit_unused, unlike the cache-hit branch andanalyze_paths— every non-cached re-analysis (the actual incremental/LSP-edit pipeline) silently droppedUnusedSuppress.InvalidOperandmissing 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 PHPTypeErrorand went unflagged.ImplicitToStringCastmissed non-Stringableenum cases: all three implicit-to-string checks (concat, echo, print/interpolation) matched onlyAtomic::TNamedObject, so a non-Stringableenum 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_locationandrecord_callable_string_refwalked the plainfind_method_in_chain, which never consults a class’strait_aliasesand has noinsteadofexclusion. Go-to-def on a call resolved through a trait alias (use T { foo as bar; }) hard-failed withNotFoundeven though the call itself type-checks fine, and a callable-string reference ('Class::method') on a trait conflict could credit theinsteadof-losing trait instead of the real target, risking a falseUnusedMethodon the winner. Both now resolve throughfind_method_respecting_precedence, the same walker call resolution already uses. - Override checks ignored trait-composed methods/properties:
check_overridesand the property visibility-reduction check only ever looked atown_methods()/own_properties()(literally declared in the class body), so a method or property a class only has viause 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 soinsteadof/asconflicts pick the right winner. Also rebindsself/staticin 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 incheck_interface_methods_implemented, sincecheck_overridescovers it more thoroughly and was producing a duplicate diagnostic. - Final/static/visibility override checks only compared against the first ancestor:
check_overridescompared final-ness, static-ness, and visibility againstall_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. ReadonlyPropertyAssignmentnamed 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 constimports: the use-import name token was a dead zone for hover/go-to-definition onuse function/use const, unlike the already-fixeduse ClassName;case. array_reduce()return type never inferred from its callback: unlikearray_map/array_filter/array_key_first/array_key_last,array_reducehad no return-type-inference arm — the stub’s baremixedreturn type made its result opaque to downstream type checks even when the callback and initial value are both fully typed.UndefinedMethodnot flagged on intersection-typed receivers: theTIntersectionarm inanalyze_method_callsilently fell back tomixedwhen no part of the intersection had the method, unlike theTNamedObjectbranch, which flags a concrete class’s missing method.UndefinedPropertynot flagged on intersection-typed receivers:resolve_property_typehad noTIntersectionarm, so property access on an intersection-typed receiver silently returnedmixedwith 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 newproperty_in_own_compositionhelper that never crosses anextendsboundary. - 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 raisedInvalidReturnType. - Typed-callable diagnostics used placeholder names instead of the real call site:
check_typed_callable_arghardcodedparam: "callback"andfn_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 underInvalidCast: 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.rsonly 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 viaInvalidTraitUsesince traits don’t have their own “inheritance” kind. TClosure-vs-TClosuresubtyping 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_argvalidated arity and per-parameter contravariance but discarded the expected callable’s return type before the call, socallable(int):stringaccepting a callback returningintwent unflagged. Closes a pre-existing empty-expect fixture (detect_implicit_void_return) that documented exactly this gap. PossiblyInvalidArrayAccessmissedarray|TIntegralFloatunions:is_invalid_for_access(the mixed-union “possibly invalid” case) omittedTIntegralFloat, unlike the definite-invalid list right above it — an array unioned withfloor()/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 plainproperties.get(k)lookup without checkingprop.optional, unlike plain array access — so['a' => $a] = $arrinferredTinstead ofT|nullforarray{a?: T}, missing a downstream null-argument check. (string)cast flagged even on a scalar-safe mixed union:CastKind::String’s array-check emittedInvalidCaston 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-broadstring|array|bool|nullreturn type).#[Deprecated]not recognized on interface/trait/enum declarations:interface.rsandtrait.rsonly read the@deprecateddocblock tag, missing the#[Deprecated]attribute fallbackclass.rsalready has;enum.rsgets the same fallback for its newly-addeddeprecatedfield. Factored the shared docblock-tag-or-attribute logic intodeprecated_from_doc_or_attrs.- Enum-level docblock never validated or version-gated:
collect_enumnever calledemit_docblock_issuesorversion_allowson its own decl docblock, unlike class/trait/interface: a malformed tag never raisedInvalidDocblock, and@since/@removedversion 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. @deprecatednot recognized on enum declarations:EnumDefhad nodeprecatedfield at all, unlikeClassDef/InterfaceDef/TraitDef, soClassLike::deprecated()hardcodedNonefor enums and everyDeprecatedClass-equivalent check site could never flag a deprecated enum.- Bare docblock
callable(T):Rnever checked arity or argument types:Atomic::TCallable{params: Some(...)}had no arm inextract_all_callable_candidatesortyped_params_from_callee, so a bare (non-Closure, non-intersection)callable(int):voidannotation got zero arity or argument-type checking. Also fixesparse_callable_syntax, which hardcodedis_optional/is_variadicto false for every param regardless of a trailing=or leading...— latent since thecallable/Closuredocblock 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_constraintsonly walked a class’s direct trait list, so@psalm-require-extends/-implementson a trait reached only via another trait (class C { use A; }whereAuses the constrained trait) was never validated atC. Reuses the already-transitiveclass_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 andInvalidExtendClasswent unchecked fornew 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 = xwrites 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_identifierresolved global constants for typing but never calledrecord_ref/record_symbol, soreferences_to/symbol_atcould never find a usage site for a global constant. - Docblock type parsing wasn’t quote-aware:
validate_type_str‘s blanket@-check andsplit_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 tomixed, silently disabling argument-type checking for both. - Suppression kind names matched case-sensitively:
@mir-ignore undefinedclasssilently failed to suppressUndefinedClasssinceKindSet::matchesdid a raw case-sensitive hash lookup. Now compares case-insensitively while still storing (and displaying inUnusedSuppressmessages) 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 checkechoalready does. Fixing it surfaced a deeper bug:is_expr_taintedhad no arm forExprKind::Parenthesized, soprint’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 aClosure(int,int,int)falsely reportedTooFewArguments(expected 3, got 1). Reuses thearity_unknownsignal the fullcheck_argspath already threads for the same reason. traituse:marker not credited forself::/static::calls inside traits:method.rsalready recordstraituse:{fqcn}::{method}for an unresolved$this->call()inside a trait, soDeadCodeAnalyzercredits whichever composing class ends up providing the method.static_call.rs’s identicalself::/static::fallback never recorded this marker, so a private static method reached only that way was falsely flaggedUnusedMethod.method_exists()guards ignored for static calls:Foo::bar()never consultedctx.method_exists_guards, unlike$obj->bar(), soif (method_exists(Foo::class, 'bar')) { Foo::bar(); }still raised a falseUndefinedMethod. Extendedextract_expr_guard_keyto also keyFoo::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 likewhile(true): only the literal booleantruewas 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 everybreakas possibly-undefined after the loop.continueinside aswitchtreated as loop continuation instead ofbreak:switchcounts as one loop-nesting level forbreak/continuein PHP, so a barecontinue;(or anycontinue Nwhose Nth enclosing construct is a switch) exits the switch — it doesn’t continue an outer loop. The analyzer treated everycontinueas an unconditional divergence with no context saved, causing a hardUndefinedVariable(instead ofPossiblyUndefined, likebreak) and falseUnreachableCodeafter switches inside loops. Now tracks whichbreak_ctx_stacklevels are loops vs switches socontinuecan target the right one.- Match exhaustiveness didn’t fold
nullinto 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
usedeclarations never validated or located:trait A { use B; }never rancheck_trait_constraintsat all (only classes and enums did) —Brecorded no find-refs location and noUndefinedTrait/InvalidTraitUse/readonly-property check ever ran for it. AddsTraitDef::trait_use_locations(mirroringClassDef/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/implementsclass names:check_name_class(_for_extends)recorded a find-refs location but never aResolvedSymbol, 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 proceduralmysqli_query()style was checked, missing the dominant modern PHP idiom. @paramname parsing stopped at the tag’s first physical line: a wrapped multi-linearray{...}/array<...>shape had its$nameon a later physical line, soparse_param_linefound nothing and the parameter was silently dropped from checking entirely.- Unreachable code not flagged when property
instanceofnarrows to empty:$h->prop instanceof A && $h->prop instanceof Bnever flagged unreachable for unrelated final classes A/B, unlike the already-fixed plain-variable case, sincenarrow_prop_instanceof/narrow_static_prop_instanceofnever setctx.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_symbolused the whole#[Attr(...)]span (name and args) instead ofattr.name.span, so a find-references hit reported the full attribute and a cursor anywhere inside the argument list falsely resolved to theClassReferencesymbol. UnusedVariablefalse positive after a dynamiccompact():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 dedicatedhas_dynamic_var_readflag (mirroringhas_dynamic_var_def’s merge plumbing) rather than reusinghas_dynamic_var_defitself, which is also set for$$varassignments where unused-write checking must still apply.- Small bounded
int<min,max>ranges not expanded for match exhaustiveness: a bounded range likeint<0, 2>is just as finite/enumerable as a literal-int union, but it never reached Case 1b (which only collectedTLiteralIntatoms) 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 classStatusbut never for the specific constant/caseStatus::Active, so find-references from the declaration missed attribute-only usages. [Foo::class, 'method']array-callables not validated: the callable-array validator only matchedTNamedObjectfor the first element, so[Foo::class, 'method'](TClassString) skipped theUndefinedMethodcheck that[$obj, 'method']already got.UndefinedMethodnot reported for first-class-callable syntax:$obj->undefined(...)andFoo::undefined(...)silently fell back to an untyped callable instead of reportingUndefinedMethodlike the ordinary call form, missing real bugs. Mirrorscall/method.rsandcall/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::$propandself::$prop(from within a subclass) for a$propdeclared on a parent recordedprop:Child::prop/prop:Self::propinstead 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_typereturned the@propertytype without ever callingrecord_ref/settingdeclaring_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_keyonly matched a singleArrayAccessnode whose array was a bare variable, unlike itsisset()sibling, which recurses throughcollect_array_access_path— so!empty()on a nested shape key narrowed nothing at any level. $obj?->prop instanceof Xdidn’t narrow like$obj->prop: theInstanceofnarrowing arm only recognizedextract_var_name/extract_prop_access/extract_static_prop_access, never the nullsafe-access extractor, so a proving$obj?->prop instanceof Xguard 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 reportedUnhandledMatchCondition. - 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 spuriousUnusedMethod. - Match exhaustiveness exempted backed and nullable enum subjects:
check_match_exhaustiveness’s enum branch was gated onscalar_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 sametypes.len() == 1gate that aTNullatom defeats; now a leadingTNullis stripped before that check, and an uncovered null case is reported unless a null arm or default is present. $obj?->propignored prior narrowing and always widened to nullable:analyze_nullsafe_property_accessnever checkedget_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 addedTNullto 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 onobj_ty.is_nullable().- Element classes inside array/intersection docblock shapes never checked or tracked:
Foo[],array<int, Foo>,list<Foo>, andFoo&Bardocblock 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 existingcollect_named_object_fqcnshelper (previously only recursing into aTNamedObject’s own type-argument list) to also recurse intoTArray/TListelement+key types andTIntersectionmembers, and reuses it fromfunctions.rs’s@param/@returnchecks. Also fixesresolve_named_objects_in_union, which only namespace-resolved a union’s top-levelTNamedObjectand left names nested in type-argument lists/arrays/intersections unresolved against use imports. - Non-interpolated heredoc/nowdoc widened to
TStringinstead ofTLiteralString: heredoc/nowdoc always resolved to plainTString, unlike an equivalent quoted string literal — silently disabling callable-string usage tracking, class-string reflection, narrowing, and match/switch dedup (all of which key offTLiteralString) whenever written as heredoc/nowdoc. A heredoc with actual interpolated parts still widens toTString. - Class-constant references keyed by receiver instead of declaring class: every
ClassName::CONST/self::CONST/static::CONST/parent::CONST/$obj::CONSTaccess path recorded itscnst:reference andConstantAccesssymbol against the literal receiver class, discarding the owner already resolved byfind_class_constant_in_chain. SinceTrait::CONSTis 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(...), andClass::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 $clsvia class-string didn’t record a class reference: only theExprKind::Identifierbranch of theInstanceofcheck recorded acls:reference. A dynamicinstanceofcheck ($cls = Foo::class; $x instanceof $cls;) analyzed the variable only to mark it consumed, never creditingFooas used — a false-positiveUnusedClassand no go-to-definition from the check site.new $class()via class-string didn’t record a class reference: only the bare-Identifier branch ofanalyze_newrecorded acls:reference and go-to-definition symbol. Instantiating through a class-string variable ($cls = Foo::class; new $cls();) recorded nothing, falsely flaggingFooas unused and breaking go-to-definition from the call site.$cls::method()through a class-string variable skipped resolution entirely:extract_object_fqcnhad noTClassStringarm, 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 forTClassString. False positiveUnusedMethod/UnusedClass, and a call to a genuinely missing method or nonexistent class went unnoticed.@throws A|Bunion 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 bogusUndefinedDocblockClassand 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_implementedonly looked atown_methods, reporting a false-positiveUnimplementedInterfaceMethodwhenever an enum satisfied an interface via a used trait.class_ancestors_by_fqcnalready walks an enum’s traits, so it can reuse the sameis_method_concretely_implementedcheck 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->xjumped to the constructor and was indistinguishable from any sibling promoted property. __invokevia$obj(...)didn’t record a reference:analyze_function_call’s non-identifier callee branch resolved aTNamedObjectreceiver’s__invoke()for arity/type checking (typed_params_from_callee) but never calledrecord_ref/record_symbolon it, unlike every other call form (method calls, static calls, function calls) — find-references and go-to-definition on__invokemissed every call site reached only via$obj(...).- Hover symbol missing on a
useimport’s own class name:check_use_decl_casingonly checked case mismatches — the imported class name’s own token in theusestatement (as opposed to its usage sites elsewhere in the file) had no symbol at all, so hover/go-to-definition onBarinuse App\Models\Bar;resolved nothing. Records only a symbol, not acls:ref, since an import alone still must not count as a usage. - Hover symbol missing on property write targets:
assign_to_target’sPropertyAccessbranch never calledrecord_symbol, unlike the read side (analyze_property_access) — hover/go-to-definition worked on$this->propreads but not on a plain-assignment write ($this->prop = ...). - Hover symbol missing on attribute class names:
check_attribute_listrecorded a reference for#[MyAttr](find-references/dead-code) but never a symbol, unlike every other class-name position — the same gap already fixed once forFoo::class. Threads an optionalall_symbolsparam throughcheck_attribute_listand its 6 public wrappers; call sites with no symbol vec in scope (interface method attributes) passNone. - Hover symbol missing on native type-hint class names:
check_and_record_type_hint_classesrecorded a reference (for find-references/dead-code) but never a symbol, unlike the identicalcheck_type_hintused 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 optionalall_symbolsparam through all 13 call sites. UndefinedPropertyfalsely flagged onunset(), unlikeisset/empty:analyze_unset_stmtanalyzed its target directly instead of throughwith_existence_check, unlikeisset/empty/??— sounset()on a dynamic or magic-__get-only property falsely reportedUndefinedPropertywhere the identicalisset()check on the same property does not.$cls::$prop/$cls::CONSTvia class-string variable unresolved:analyze_static_property_accesshad no branch at all for a variable class receiver (onlyExprKind::Identifier), so$cls::$propfell straight toType::mixed()with no existence/visibility check and no usage recorded — a static property reachable only this way was falsely flaggedUnusedProperty.analyze_class_const_access’s variable branch only matchednamed_object_fqcn()(object instances), missingTClassString, so$cls::CONSTvia a class-string variable had the identical gap for constants. Also fixes the root cause blocking a real test of this:self::class(andstatic::/parent::class) assigned to a variable resolved to the literal unresolvableclass-string<self>instead of the actual enclosing class, because the::classbranch returned the raw pseudo-name instead of resolving it throughFlowState.- 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 forClassLike::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 flaggedUnusedClass, and an undefined one silently passed. Generalizes the function to switch on Class/Interface/Trait and wires it into both trait decl variants andanalyze_interface_decl. UndefinedDocblockClass/UnusedClassnot checked for a method’s@paramclasses: free functions already gotUndefinedDocblockClassplus acls:usage reference for a docblock-only@paramclass (no native hint); methods never did. A class named only in a method’s@paramtag was silently unchecked and, if otherwise unreferenced, falsely flaggedUnusedClass. Reuses the method’s already-resolved stored param type (same source the@returncheck reads) rather than re-parsing the raw docblock, so@template/@psalm-typeare 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 flaggedUnusedClass. Now recursively records acls:reference for everyClassConstAccessreachable 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) calledrecord_callable_string_ref. Any other callable-typed parameter —register_shutdown_function,set_error_handler,spl_autoload_register, or a user function declaredcallable $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::classbranch calledrecord_ref(so the class showed up in find-references) but neverrecord_symbol, unlike every other class-name position (new Foo,instanceof Foo,Foo::method()). A cursor on the class name insideFoo::classresolved nothing viasymbol_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 populatedtrait_aliases, sowalk_method_with_precedencefell 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::bindwas special-cased butClosure::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 identicalcall_user_func('name')form.- Anonymous class
extends/implements/usetargets never recorded a reference: an anonymous class’sextends/implements/use-trait targets were only run through theUndefinedClass/UndefinedTraitdiagnostic 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::Concatonly handledextract_simple_var; a property/array-access target ($this->log .= 'x') skippedanalyze()/assign_to_targetentirely, so its reference never got recorded (false positiveUnusedProperty) and its flow-tracked type went stale instead of reflecting the concatenation.$obj::CONSTvia an object-instance variable skipped resolution:analyze_class_const_accessfell toType::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::classhandling 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(...)andFoo::$name(...)(first-class-callable syntax with a dynamic method name) never calledrecord_dynamic_member_access, unlike the identical$obj->$name()/Foo::$name()ordinary dynamic call — a private method reachable only through the FCC form was falsely flaggedUnusedMethod. - 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_reduceresolved a bare string callback only to extract its arity, never recording a reference — a function/method reachable only this way was falsely flaggedUnusedFunction/UnusedMethod.
[0.53.1] - 2026-07-12
Section titled “[0.53.1] - 2026-07-12”$thisinside a free-standing closure/arrow function falsely flaggedInvalidScope: a closure declared outside any class can legitimately reference$thisif it’s later rebound to an object viaClosure::bind()/bindTo()/call()— a common macro/PHPUnit-style idiom.$thiswas only seeded into a closure’s flow-state when it was lexically inside a method; non-static closures and arrow functions now seed$thisas a generic object instead of leaving it undefined.- View-template path detection missed mixed path separators:
is_view_template_pathmatched only pure/resources/views/or\resources\views\substrings, so it missed paths mixing both separators — whichPathBuf::joinproduces on Windows when the joined-in component already contains forward slashes — silently suppressing no diagnostics for such paths and failing fixture tests onwindows-latestCI. Detection now splits on either separator instead of substring-matching. - Nested
@psalm-type/@phpstan-typealiases only expanded one level deep: an alias whose body referenced another same-file alias (@psalm-type UserId = IdwhereIdis 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_atmissed 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_atalready 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.
[0.53.0] - 2026-07-12
Section titled “[0.53.0] - 2026-07-12”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.
Changed
Section titled “Changed”- Breaking:
mir-codebase’sstoragemodule is renamed todefinitions, andFnParamis renamed toDeclaredParam— it collided in name with the unrelatedmir_types::atomic::FnParam, forcing call sites using both to alias one locally. Updatemir_codebase::storage::*imports tomir_codebase::definitions::*, andFnParamtoDeclaredParam. - Breaking:
Issue’simpl fmt::Displayis removed frommir-issues; colored text rendering moved tomir-cliasformat_issue, alongside the crate’s other renderers (junit, sarif). Library consumers formatting anIssuevia{}/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 stalecache.bin/stub-cache entry could desync the length-prefixed decoding and attempt to allocate a garbage multi-gigabyte collection before ever returning anErr— 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::baras a property, a method, and a class constant shared the identical unprefixed reference-index key, soreferences_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(...), andfunc(...)never recorded a reference to the callee. The static-method form also never checked class existence, soUndefinedClass::baz(...)silently produced a generic callable instead of reportingUndefinedClasslike the equivalent direct-call form does. - Anonymous classes’
extends/implements/usetargets were never validated: anonymous classes aren’t collected into the codebase’s class definitions, sonew class extends Missing {},new class implements Missing {}, and a nonexistent trait used inside one all silently passed. They now get the sameUndefinedClass/UndefinedTraitchecks 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(), andClass::$$nameresolve 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 spuriousUnusedVariable. UndefinedDocblockClassnot checked for a method’s own@returntype: 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
$thisaccess credited to the wrong side: a trait body’s$thisis typed as the trait itself, so$this->helper()/$this->secretinside 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 flaggedUnusedMethod/UnusedPropertyon the composing class. - Qualified class names and Pass-1 type hints resolved against
useimports case-sensitively: a qualified name’s leading segment (e.g.deep\Serviceafteruse MyApp\Deep;) was matched exact-case only, producing a spuriousUndefinedClass; param/return/property types stored at Pass-1 collection time had the identical gap. use function/use constaliases 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, andcall_user_func('name')never resolved: theClass::methodstring 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 everycall_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 asUnusedClass. Enum declarations and their cases didn’t run attribute validation at all. @param/@return/@var/@throwsdocblock-only class types never recorded as references: local@var, property@var, function/method@throws, and@param/@returntags existence-checked the named class but never recorded it as used, falsely flagging a class named only in a docblock tag asUnusedClass.@mixin/@property/@method/@psalm-import-type/@phpstan-import-typedocblock 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 flaggedUnusedClass. Fixing@property/@methodalso surfaced a namespace-qualification gap: their types resolved through a path that deliberately leaves bare class names unqualified, so@property Foo $xin a namespaced file stored the literal nameFooinstead 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 Boundonly had their outer name checked, not nested type arguments (including nested lists likeBox<Wrapper<Foo>>) — a typo’d type arg passed silently, and a class named only inside one was falsely flaggedUnusedClass. - Class usage from reflection-like builtins under-recorded:
class_alias();class_implements()/class_parents()/class_uses()/get_class_methods(); andclass_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 asUnusedClass. extends/implements/traitusenever recorded a class reference: existence was validated but no reference was recorded, soreferences_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 acatch (SomeException $e)type orFoo::$bar/self::$bar/parent::$bar/static::$baraccess, unlike instance$obj->propaccess. --threadssizing failures silently swallowed: every other config-failure path in the CLI printed a diagnostic; a--threadsvalue rejected by rayon (e.g. the global pool already built) ran silently with the default thread count instead.
Performance
Section titled “Performance”Typeshrunk from 176 to 96 bytes (Atomic80 → 40) by boxing theTKeyedArrayproperty 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 usesFxHashSetfor 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
Stringallocations); text and GitHub Actions issue output is batched through one buffered writer instead of a syscall per line. Atomicshrunk further from 40 to 32 bytes (Type96 → 80) by boxing theTClosure/TConditionalpayloads; 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, sofind_method_in_classis a single hashed get. Member maps, the globalTypeinterner, 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.
ResolvedSymbolrecording — a deepTypeclone 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
Vecon every hash;substitute_templatesreturns 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.
[0.52.0] - 2026-07-11
Section titled “[0.52.0] - 2026-07-11”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_cancellablekeeps 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_preparednow 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 imperativeRefIndexon the incremental paths entirely for sessions that read references exclusively through the memoizedreferences_to_in_filespath, cutting a lock acquisition per edit.
- Unbounded memory growth in long editing sessions: the FQN-keyed
infer_scope/infer_functionmemo tables now carry an LRU bound (4096, matchingcollect_file_definitions) instead of growing forever as renames mint new memo keys. The process-global lowercase-Namecache now clears itself past 65,536 entries instead of growing unbounded across a rename storm. - A wedged editing suite under concurrent salsa writes:
class_issuestook 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
ArrayAccessitem types:foreachover aGenerator, a class implementingIterator/IteratorAggregate(via its own@implementstype args orcurrent()/getIterator()’s resolved return types), or a receiver whose own static type isIterator/IteratorAggregate/Traversablenow infers real key/value types instead of always falling back tomixed/mixed.$obj[$idx]on anArrayAccess-implementing receiver now resolves the value type from an@implements ArrayAccess<TKey, TValue>annotation oroffsetGet()’s return type, and skips the plain-array “must be an array-key” offset check (e.g. SPL’s object-keyedWeakMapno longer tripsInvalidArrayOffset). - Narrowing on shape/array keys:
array_key_exists()now clears optional/null on a key that’s already declared but optional or nullable, matchingisset().isset($a['x']['y'])narrows every level of a nested access instead of bailing out at the firstArrayAccessbase, and no longer misfiresPossiblyNullArrayAccesson its own condition expression.!empty($arr['key'])/empty($arr['key'])now narrow the key’s own value type (truthy/falsy) the same wayisset()/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$xnow narrows to the RHS type instead of widening tomixed.instanceofnow narrowsself::$prop/static::$prop/Class::$prop, not just instance properties.$this->prop === EnumCasenow narrows the property (previously only plain variables were recognized), so a guardedmatchon 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 mixedinstanceof/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) || RHSno longer lets a narrowing produced by evaluatingRHS(e.g. aninstanceofon some other variable) leak into the merged true-branch on the “$xunset” path, where it never held. in_array(): a cross-category needle (e.g. anint|stringagainst 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 requiringstrict.is_numeric(): the truthy branch now narrowsmixed/scalar inputs toint|float|numeric-string, matching howis_string()/is_int()/etc. already narrow.- Assert annotations:
@psalm-assert/@phpstan-assert(and their-if-true/-if-falsevariants) now recognize the negated!Typeform (!null,!Foo), subtracting the asserted type instead of parsing!Typeas a bogus unrelated type and overwriting the variable with it. - Generics — template resolution:
@template T = Defaultis now parsed and used as the fallback for an unboundT, instead of always falling back tomixed. 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 spuriousInvalidTemplateParamand silently swapped inferred types on calls using named arguments.InvalidTemplateParamnow 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 staticis 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 owncallable(T): R-shaped parameter no longer corrupts the class’s ownTbinding 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 byinstanceof IntBoxkeepsint, soIntBox’s own@return Tmethods 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/RedundantConditionreports 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@vardocblock 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)RefIndexduring reverse-dependency cache upkeep on every edit. - Reference-index key collisions between a method, property, and class constant of the same name:
Foo::baras both a property and a method shared one reference-index entry, soreferences_tomerged their locations together and a truly-dead property could hide behind a same-named method’s usage — a false negative inUnusedProperty. 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 reportedUnusedMethod/UnusedFunction. A static-method first-class callable on an undefined class (MissingClass::baz(...)) also silently produced a generic callable instead of reportingUndefinedClass, unlike the equivalent direct-call form. - Anonymous classes never validated
extends/implements/use:new class extends Missing {},new class implements Missing {}, and auseof 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 sameUndefinedClass/UndefinedTraitchecks a named class does, including respectingclass_exists/interface_exists/trait_existsguards. - The
[$this, 'method']/['ClassName', 'method']array-callable literal never recorded a reference: a private method reachable only this way (directly invoked, or passed tocall_user_func/Closure::fromCallable) was falsely reportedUnusedMethod. - 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 reportedUnusedMethod/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 inClass::$method()) is now also analyzed, so a variable used only there no longer triggers a spuriousUnusedVariable. UndefinedDocblockClassnever checked a method’s own@returndocblock 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-typeedge 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, socase 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::CONSTwas treated exactly like a normal class constant fetch — accessing a trait’s constant directly (rather than through a class thatuses 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 traitusealias (use A { A::missing as alias; }, or an unqualifiedmissing 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
$thisis typed as the trait itself, so$this->helper()/$this->secretinside the trait (satisfied by whatever class ends upuseing it) never recorded a reference against the composing class’s own private member. Each such trait-body access now records a per-trait marker thatDeadCodeAnalyzercredits to any class using that trait.
[0.51.0] - 2026-07-10
Section titled “[0.51.0] - 2026-07-10”@psalm-self-out/@phpstan-self-outon 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-isand@return, applied throughparent::/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 ownyields: a generator with no return-type declaration previously inferredvoid/mixedfrom itsreturnstatements alone. Eachyield/yield fromnow contributes to an inferredGenerator<...>type; an explicit@returnor 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”. Everyinterface-stringis a validclass-string, bound-checked against the codebase’s inheritance graph, and excluded fromnew $x()targets (an interface name can never be instantiated). AddsNotAnInterface(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 plainint.@psalm-template/@phpstan-templatealiases on classes and methods, matching the prefixed-alias support@param/@return/@assert/@if-this-is/@self-outalready had.@psalm-var/@psalm-pure/@psalm-readonlyaliases, recognized alongside the existing@psalm-templatealias 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{...}, andclass-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@purefunction 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/,%, andintdiv(), 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 acatchclause whose type is a subtype of (or identical to) one already caught by an earlier clause on the sametry.- 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 andswitch(true)/plainswitchfallthrough 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 Bon two unrelated interfaces now narrows toA&Binstead of discardingA; a provably-impossible doubleinstanceofnow correctly propagates as an empty (unreachable) type instead of maskingRedundantCondition. Scalar type-check disjuncts (is_int($x) || is_string($x)) are now unioned the same wayinstanceofdisjuncts already were.EnumName::CaseNamenarrowing is now recognized from its realClassConstAccessAST shape (it was previously unreachable).$xis now narrowed onget_class($x) === Foo::class, matching the existing=== 'Foo'string-literal form. An intersection union member no longer gets duplicated across OR-instanceofdisjuncts, and a priorinstanceofnarrowing is no longer dropped by a second, unrelatedinstanceofcheck. - 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/@extendstype args (including through an interface’s own@extendschain, previously untracked entirely). A receiver’s own type params now propagate through: staticreturn types, into property types, into first-class-callable closures, and into a static factory’s class-level template.@template-covariant/-contravariantis now honored across inheritance chains, not just between two instantiations of the same class. - Generics — bound checking: class-level
@template T of Boundis now enforced onnew, on a static method’s own bound, and on@implements/@extendstype args against the target’s declared bound. A template fully explained by a union alternative (T|nullcalled withnull) is no longer bound-checked againstT’s own bound. Overrides now check against a concretely-bound class template on both the parent and child side, and a duplicate@templatename declaration is checked only against its first bound. - Generics — misc narrowing/parsing: a bare template param now survives
is_*(), truthy/falsy, andinstanceofnarrowing (narrowing toT&Classinstead of being discarded via amixedconflation), including through property-access narrowing.@param-out,self/static, and templates are now substituted correctly across the function, method, and static-call@param-outwrite-back paths.array<K,V>/list<T>template params now bind across list<->array shape mismatches. A template used inside aClosure(...)/callable(...)type now resolves instead of staying an unresolved named type. - Typed-callable arguments: a union of closures with different arities,
@return/@paramdocblocks 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 toarray<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 tomixed. Spread elements in array literals ([...$x, ...$y]) now merge the source’s key/value types instead of collapsing toarray<mixed, mixed>. - Purity & taint tracking: impure static method calls, closures, and arrow functions inside
@pure/@psalm-immutable/@psalm-external-mutation-freescopes are now checked (purity/taint scope previously wasn’t propagated into closure or arrow-function bodies).$GLOBALS[...]access inside a@purefunction is now flagged likeglobal $x;already was. Taint tracking now covers(int)/(float)/(bool)casts as sanitizing, and propagates throughmatchexpression arms, array literal elements, and single-hop instance property access. - Control flow:
finallyblock variable reassignments now propagate to code after thetrystatement.break Nnow targets the loop/switch N levels out instead of always the innermost one. Variable assignments insidematch/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-whileloop conditions are now checked for docblock contradictions andRedundantCondition, 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/aliastrait 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|nullvsnull|int). - Docblock/native type interplay: a
@param/@returndocblock type that partially conflicts with the native hint no longer widens the body type with the incompatible atom; a provably-impossible@varnarrowing is now flagged asDocblockTypeContradiction; a bare enum type is now expanded to its full case set before excluding a negated case, so exhaustivematches 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 assumingmixed; a static property read now resolves to its declared type instead of alwaysmixed; interface and enum class constants now resolve their real type instead of alwaysmixed;match()exhaustiveness now extends to plain scalar andboolsubjects (excluding thematch(true)/match(false)chained-condition idiom);array_map/array_keysnow 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.
Dependencies
Section titled “Dependencies”- Bumped
crossbeam-epochto0.9.20to resolveRUSTSEC-2026-0204(invalid pointer dereference infmt::PointerforAtomic/Shared). - Ran
cargo updateto bring all crates within their existing semver ranges up to date and deduplicate several transitive dependencies (hashbrown,wit-bindgen,log,semver, and others).
[0.50.2] - 2026-07-02
Section titled “[0.50.2] - 2026-07-02”- Process abort (SIGABRT) under concurrent workspace indexing: Fetching the workspace revision epoch (
index_generation) and deriving a file’s defined symbols duringingest_fileran 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-assertionunreachable_uncheckedthat aborted the whole process (in release builds it would silently corrupt state). The revision epoch is now read from an off-salsa atomic mirror, andingest_filederives its symbol set from theFileDefinitionsit already computed — neither touches salsa on the shared handle.
[0.50.1] - 2026-07-02
Section titled “[0.50.1] - 2026-07-02”Changed
Section titled “Changed”- Warm-up skip cache for repeated reference queries:
references_to_in_filesandreanalyze_dependentsno 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_cancellableadds 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 thesalsa::Cancelledretry loop.
[0.50.0] - 2026-06-30
Section titled “[0.50.0] - 2026-06-30”@psalm-type/@phpstan-typelocal type aliases on functions:@psalm-type Alias = ...and@phpstan-type Alias = ...docblock tags on standalone functions are now parsed intoParsedDocblock.type_aliasesand resolved locally within the function body.@psalm-import-type Alias from ClassNameis also supported for importing class-level aliases into a function’s scope.@psalm-mutation-freeper-method immutability enforcement (P5-b): Methods annotated with@psalm-mutation-free,@phpstan-mutation-free, or the short form@mutation-freeare now enforced: any$this->prop = …inside such a method emitsImmutablePropertyModification(MIR1705, Warning). Applies to individual methods without requiring@psalm-immutableon the whole class. Constructors are exempt.MethodDef.is_mutation_freestored; stub cache FORMAT_VERSION bumped 7→8.ImpureMethodCallin immutable / mutation-free contexts (P5-c): Calling a non-mutation-free$thismethod inside a@psalm-immutableclass method or a@psalm-mutation-freemethod now emitsImpureMethodCall(MIR1701, Warning). Calls to@pureor@mutation-freemethods are exempt.ResolvedMethodgainsis_pureandis_mutation_freefields; static methods and constructor calls are always exempt.@psalm-external-mutation-freemethod annotation (P5-d): Methods annotated with@psalm-external-mutation-freeare now parsed, stored, and enforced. Inside such a method,ImpurePropertyAssignmentfires for property writes to external parameter objects andImpureMethodCallfor calls to non-pure/non-mutation-free methods on those parameters.$thisproperty writes remain permitted.MethodDef.is_external_mutation_freestored; stub cache FORMAT_VERSION bumped 8→9.- Enum interface contract enforcement (P6-c): Enums that implement user-defined interfaces are now checked:
UnimplementedInterfaceMethodis emitted for any interface method not found in the enum’sown_methods. The full transitive interface chain (viaclass_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. ImpossibleLooseComparisonfor categorically disjoint types (P1 residual):==/!=between types that can never be loosely equal now emitImpossibleLooseComparison(MIR0409, Warning). Covers: object vsnull|false|int|float|string|array, array vsnull|int|float|string|object, and non-empty array vsfalse. Conservative: open atomics (mixed,scalar, callable, template params) are never flagged.ImpossibleLooseComparisonfor non-numeric string vs int/float (P1 residual): In PHP 8.0+, a non-numeric literal string compared loosely (==/!=) to anintorfloatis alwaysfalse.ImpossibleLooseComparison(MIR0409, Warning) is now emitted for these cases. PHP version is checked: in PHP < 8.0 the rule is narrower — a non-zeroTLiteralIntvs a non-numeric string is still impossible, but== 0is 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 plainint. Inputs must be non-negative power-of-twoi64values; out-of-range values fall back toTInt. The expansion uses aBTreeSet<i64>for deduplication and a heuristic cap to prevent combinatorial explosion.- Exhaustiveness check for integer literal unions:
check_match_exhaustivenessnow handlesmatchon a union of integer literals (e.g.@param 1|2|3 $nor anint-mask<…>expansion):UnhandledMatchConditionis emitted for any literal value not covered by an arm. Negative literals and default arms are handled correctly.
ImplicitFloatToIntCastfalse positive forfloor/ceil/roundresults: IntroducesAtomic::TIntegralFloat— a float subtype whose value is always whole.floor,ceil, andround(with zero precision) now returnTIntegralFloatinstead ofTFloat. PassingTIntegralFloatto anintparameter in non-strict mode is lossless, soImplicitFloatToIntCastno longer fires. Strict mode still emitsInvalidArgument.- Untyped promoted constructor properties now detected:
MissingPropertyTypeis now emitted for promoted constructor parameters without a type hint (e.g.public function __construct(public $x) {}). Previously,check_property_memberonly walkedClassMemberKind::Propertynodes and silently skipped promoted params. __unserializeexempted fromDirectConstructorCall:__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__wakeupand__clone.@deprecatedpropagation to interface/trait constants;self::/static::/parent::accesses checked:@deprecatedon constants declared in interfaces or traits was silently discarded. The fix readsconst_doc.deprecatedthe same way the class collector does.self::CONST,static::CONST, andparent::CONSTaccesses skipped the deprecation check; those early-return branches now emit usingcca.member.span.MixedPropertyFetchfalse positives for template-param receivers:MixedPropertyFetchis no longer emitted when the receiver is aTTemplateParam— an unconstrained template parameter is intentionally parameterised, not a case of lost type information.MixedAssignmentfalse positives for template-param variables:MixedAssignmentis no longer emitted when the right-hand side resolves to aTTemplateParam. Both the foreach value binding (stmt/control_flow.rs) and direct assignment (expr/assignment.rs) sites now useType::is_mixed_not_template()instead ofType::is_mixed().PREG_OFFSET_CAPTUREflag-awarepreg_match$matchesshapes: Whenpreg_matchis called withPREG_OFFSET_CAPTURE,$matches[n]is now inferred asarray{0: string, 1: int}rather than plainstring. The flagged and unflagged code paths are modelled separately;PREG_SET_ORDERis unaffected.self::/static::/parent::class constants resolve to declared type:self::CONST,static::CONST, andparent::CONSTalways returnedType::mixed(), causingMixedArrayOffsetand lost type information in return-type checks.collector/class.rsnow stores initializer types viainfer_const_value()inConstantDef::ty;expr/objects.rsreplaces the existence-only lookup withfind_class_constant_in_chainand returnsc.ty.clone().InvalidArgumentsuppressed alongsideImplicitFloatToIntCastin non-strict mode: Emitting bothImplicitFloatToIntCastandInvalidArgumentfor the same float→int argument was a double-report.ImplicitFloatToIntCastnow gates on!ea.strict_typesand returns early; strict mode falls through toInvalidArgumentas before.$this->__construct()in__wakeup/__cloneexempted fromDirectConstructorCall: Calling__construct()inside__wakeup(deserialization re-initialization) or__clone(post-clone setup) is a documented PHP pattern.FlowStategainscurrent_method_name;call/method.rsskipsDirectConstructorCallwhen the receiver is$this,self_fqcnmatches, and the enclosing method is__wakeupor__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. ImplicitToStringCastsuppressed for objects with__toString: Objects that define__toStringare no longer flagged forImplicitToStringCastin non-strict mode when passed to astringparameter — PHP’s implicit__toStringinvocation is valid.InvalidArgumentfor objects lacking both__toStringand\Stringableis unaffected;strict_types=1remains an error.
[0.49.0] - 2026-06-24
Section titled “[0.49.0] - 2026-06-24”AnalysisSession::subtype_files(class_fqn)and theclass_subtype_filestracked query: the resolved inverse ofclass_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 aprotectedmember’s reference search to its class hierarchy without reconstructing that hierarchy from declaration text.extension_loaded()guards suppressUndefinedClass(FP-A):extension_loaded('name')calls are now tracked inFlowState(a newextension_loaded_guardsfield parallel toclass_exists_guards).UndefinedClassis suppressed for any class reference inside the guarded block — both the directif (extension_loaded(…)) { }form and the negative early-exit patternif (!extension_loaded(…)) { throw; }. Guard sets are intersected across branches at merge points.@param-out/@psalm-param-outout-parameter write-back (P4):@param-out,@psalm-param-out, and@phpstan-param-outdocblock tags are now parsed intoParsedDocblock.out_paramsand stored asout_tyonFnParam. After a call the out-type is written back to the caller’s variable;premark_byref_arg_varsalso prefersout_tyso 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_tyis carried throughTClosureso it is not lost when a function or method is captured viafoo(…),$obj->method(…), orCls::method(…).stub_cacheFORMAT_VERSIONbumped to 5 to invalidate cachedFnParamentries serialised without the new field.- First-class callable method/static-method typed as
Closure(P3):$obj->method(…)andCls::method(…)now resolve the target throughresolve_method_from_dband synthesise aTClosurecarrying the full parameter list and return type, matching the existing behaviour for free-function callables.self::/parent::/static::are resolved againstFlowStatecontext; nullsafe method callables produce a nullableClosure; unknown methods fall back toTCallablewithout a false positive. @psalm-immutableenforcement (P5):@psalm-immutableand@immutableclass annotations are now parsed and propagated toClassDef.is_immutable. Non-constructor methods of an immutable class gainFlowState.is_in_immutable_method = true; any$this->prop = …assignment inside such a method emits the newImmutablePropertyModificationdiagnostic (MIR1705, Warning). Constructor bodies remain exempt; static methods are implicitly exempt;@suppress ImmutablePropertyModificationworks as expected.- Backed-enum
from()/tryFrom()return types (P6b): Synthesisedfrom()/tryFrom()methods on backed enums now return the enum type instead ofmixed.from()returnsEnumType;tryFrom()returnsEnumType|null.UnitEnum/IntBackedEnum/StringBackedEnumare also injected into each enum’s implicit interface list at collection time.substitute_static_in_return()now recurses intoTList,TNonEmptyList,TArray, andTNonEmptyArrayso@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 inEnumCaseDef::valueinstead ofmixed, enabling downstream consumers to inspect actual case value types. Non-literal case values fall back tomixedand are not flagged. ImpossibleIdenticalComparisonfor categorically disjoint types (P1):===/!==between types that can never be strictly equal now emitImpossibleIdenticalComparison(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.@inheritdocpropagation (P7): Methods annotated with@inheritdocor{@inheritdoc}now inherit@return,@param,@throws, and@templatefrom the nearest ancestor that carries docblock annotations. Onlymixed(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 inclass/overrides.rsalongside 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.
TCallableStringinis_callable()false branch (N5):callable-stringis definitionally callable, butAtomic::is_callable()only matchedTCallableandTClosure. The false-branch filter now correctly removesTCallableStringatoms and marks the branch as diverging when that is the only type — e.g.!is_callable(callable-string $x)now emitsRedundantCondition. The true branch is unchanged:TCallableStringwas already kept viat.is_string().: neverbodies that fall through: A function declared: nevermust throw, callexit, or otherwise diverge on every code path.return_requires_valuepreviously exemptedneveralongsidevoid/mixed; removing that exemption causescheck_missing_returnto emitInvalidReturnTypewhen the body does not always diverge. Explicitreturn $value;and barereturn;inside: neverbodies 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.ldddetection now usesldd --version 2>/dev/null: whenlddis absent the suppressed error leaves stdout empty, which is treated as musl rather than falling back to gnu — a safer default for minimal containers.
Changed
Section titled “Changed”- Cache surface firewall for dependents:
CacheEntrygains asurface_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 andcache.binrewrite. - O(n)
RefIndex::set_file_refs:set_file_refsnow deduplicates within the committed batch instead of scanning every existing location of each symbol.clear_filealready 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_versionparsing memoised project-wide: Three tracked queries (infer_scope,infer_function,collect_file_definitions_uncached) were callingPhpVersion::from_strdirectly instead of the memoiseddb_php_versionquery. All three now calldb_php_version(db), which memoises the parse result project-wide and correctly tracks theanalyze_configsalsa dependency, ensuring memos are invalidated on PHP version changes.
[0.48.0] - 2026-06-23
Section titled “[0.48.0] - 2026-06-23”AnalysisSession::references_to_in_files(symbol, files): returns every recorded reference tosymbolthat originates in the given file set, computed directly from memoizedanalyze_filequeries. Unlikereferences_to, this analyzes files on demand (no prioringest_filerequired), 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 onsalsa::Cancelled.
Changed
Section titled “Changed”- ~18% faster full analysis: Three hot-path improvements land together — (1)
php_ident_lowercasereplaces Unicode-awareto_lowercase()withto_ascii_lowercase()at ~25 identifier call sites across collector, call, class, db, narrowing, and expr modules; (2)bytes().any(is_ascii_uppercase)replaces the invertedchars().all(!uppercase)guards, short-circuiting on the first uppercase byte; (3)db_php_versionwrapsPhpVersion::from_strin 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).
[0.47.0] - 2026-06-23
Section titled “[0.47.0] - 2026-06-23”AnalysisSessionnow exposes database accessors (upsert_source_file,lookup_source_file,remove_source_file_input,with_db_mut,with_db_ref) so a host can shareMirDbStorageas a single Salsa database and drive its inputs directly.last_ingested_symbolsis tracked per file so rename/deletion diffs work correctly when a host updates inputs eagerly before callingingest_file.
class_existsguard on interface extends:interface Foo extends GuardedIface {}after aninterface_exists(…)throw-guard no longer emitsUndefinedClass. Mirrors the fix already applied to classextends/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) alongsidecallable/Closure.- PHP 8.3 typed class constants (N3):
const int FOO = 1declarations are now resolved to their declared type instead of always returningmixed. Accessing typed constants now produces correctInvalidArgument/ArgumentTypeCoerciondiagnostics at call sites. is_a()with$allow_string=true(N2):is_a($x, 'Foo', true)true branch now keepsstring/class-stringatoms 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 keepsFooin the true-branch type. False branch applies no narrowing, since the exact class is a valid false-branch value.int-range →floatcoercion (N4):positive-int,negative-int,non-negative-int, andint<a,b>are now accepted asfloatsubtypes in the per-pair subtype check, matching the existing union-level coercion table.- Abstract method calls via
self::/parent:::self::method()andparent::method()calls on abstract methods now emitAbstractMethodCall. Onlystatic::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). Badinstalled.jsonor unreadable stub files now emit amir: warning:instead of silently returning empty results (A2).mb_convert_encoding,iconv,preg_replace,preg_replace_callback, andsubstr_replaceno longer include|false/|nullin their return type when the subject argument is a string (A3). int / intyieldsint|float: PHP’s/operator returnsfloatwhen the division is inexact. The previous fallback always returnedint, causing falseRedundantCaston(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 tofloat:TIntRangeis now in the float-widening list inatomic_subtype, fixing falseInvalidArgumentfor expressions likelog(strlen($s)).false === $xnarrowing: The symmetricfalse === $xandfalse === ($x = expr)forms now narrow the variable in the true branch, fixingInvalidPropertyAssignmentFPs innormalizer_normalize/iconvguard idioms.class_exists-guardedextends/implements: The optional-dependency pattern (aclass_exists(…)throw-guard before a class declaration) no longer emitsUndefinedClassfor the guarded parent or interface name.- Encoding builtin return types:
mb_convert_encoding,iconv, andgrapheme_strlenstubs no longer include|false/|nullin normal call paths, eliminating pervasiveInvalidPropertyAssignmentandNullableReturnStatementFPs. int + bool/nullcoercion:$count + true,$n + null, and similar expressions now inferintinstead ofint|float— PHP coercesbool/nulltointin arithmetic.
[0.46.0] - 2026-06-21
Section titled “[0.46.0] - 2026-06-21”DeprecatedTraitis now reported when a traituses another deprecated trait.DeprecatedInterfaceis now reported when an interface extends a deprecated interface, or an enum implements one.key-of<T>andvalue-of<T>now resolve to the real key and value types in return-type checks; valid returns are accepted without falseInvalidReturnType. 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@returnno longer emitUndefinedClass.
- Method overrides (G4/G5): Return-covariance violations in mixed
object|scalarunions are now caught (e.g. wideningstring|Cattostring|Animalis flagged). Covariance-legal narrowing (e.g.string|Animaltostring|Cat) and parameter widening are still accepted. Template@return T of Boundmethods no longer emit falseInvalidReturnTypewhen the returned value satisfies the bound. - FP-H (
@method staticreturn type):@method static name()is now correctly parsed as a non-static method returningstatic, not a static modifier. Carbon-style fluent docblocks no longer causeMethodSignatureMismatchon concrete overrides in subclasses. - FP-J (
@finaldocblock): Classes annotated with@finalvia docblock (not the PHPfinalkeyword) no longer emitInvalidExtendClasswhen extended. The@finalconvention is an IDE hint only. - FP-E (trait
$thisaccess):$this->propdeclared in the same trait now resolves to the declared type inside trait methods, eliminating falseUndefinedPropertyandmixedinference on self-contained trait properties. - FP-K (
DatePeriodoverloads):new DatePeriod('R5/…')(ISO 8601 one-argument form) no longer emitsTooFewArguments. The arity minimum is computed across all declared constructor overloads. - FP-B (property refinement): Assigning a
mixedor incompatible type to a property now clears any prior refinement, preventing stale narrowed types from producingNullableReturnStatementfalse positives downstream. - FP-M (
int/boolcoercion): Passingintto afloatparameter no longer emitsInvalidArgument— PHP implicitly coerces. Bitwise operators onbooloperands no longer emitInvalidOperand— PHP coercesbooltoint. - FP-O/N (negative type guards and nullable property throws):
if (!is_string($x)) { throw …; }andif (!is_int($n)) { return; }now narrow$x/$nin the fallthrough branch. Nullable properties guarded by!== nullbefore athrow(if ($this->ex !== null) { throw $this->ex; }) are also narrowed correctly, eliminatingInvalidThrowfalse positives. - FP-I (
use … asalias and#[\Override]):use Foo as Barimport aliases are now resolved before override checks. Classes that extend an aliased parent no longer emitInvalidOverridefor methods that exist on the aliased class. - FP-L (reference assignment):
$b = &$a,$ref = &$arr[0], and$ref = &$obj->propno longer emitUnsupportedReferenceUsage. By-reference method out-parameters also define the argument variable, suppressingUndefinedVariable. - FP-C (
preg_replacestub):preg_replace()return type corrected —|nullremoved from the string-subject overload. Returningpreg_replace()directly from astring-returning function no longer emitsNullableReturnStatement. @internalscoping:@internalis now scoped to the root namespace of the declaring package. Callers in sub-namespaces of the declaring package (e.g.Symfony\Component\Console\HelpercallingSymfony\Component\Console\Output::doWrite()) are no longer flagged.- Open-file session: The open-file pre-loader now collects
extends/implementsreferences, ensuring parent classes and implemented interfaces are loaded before analysis of the opened file. extract()and variable-variable assignments:extract($arr)no longer emitsUndefinedVariablefor variables populated at runtime. Variable-variable assignments ($$key = …) likewise suppressUndefinedVariablefor later reads.- Empty array + generic types: An empty array literal (
[]) now satisfies alist<T>,array<K,V>, or any other generic collection type argument, including in generic wrapper classes (new Wrap([])).
Changed
Section titled “Changed”- Updated php-rs-parser, php-ast, php-lexer, and phpdoc-parser from 0.18.0 to 0.18.1.
[0.45.0] - 2026-06-18
Section titled “[0.45.0] - 2026-06-18”- Comparison-driven integer-range narrowing:
<,<=,>,>=,===, and!==against literal bounds now tightenint<a,b>ranges (and named subtypes likepositive-int,non-negative-int) in each branch, narrowing toTLiteralInton 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, andmin()/max()over all-integer arguments now produce boundedint<min,max>results.rand(),mt_rand(), andrandom_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_strlenandcount()on a sealed keyed-array shape return exact literalints;strlen/mb_strlenreturnint<1,max>fornon-empty-stringarguments. non-empty-stringpreservation and inference across string operations: case-conversion and encoding functions,(string)casts ofint/float/true,sprintfwith literal format chars,number_format,str_repeat,date/gmdate/date_format, and concatenation all preserve or producenon-empty-string.str_contains/str_starts_with/str_ends_withnarrow the haystack tonon-empty-stringin 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), andarray_key_first/array_key_last(non-null on non-empty).explode,str_split,implode,preg_split, andrange()produce typed(non-empty-)listresults.array_valuesis now@template-annotated and returnslist<TValue>. - Collection narrowing:
array_is_list,count/strlencomparisons,$arr !== [](narrows to non-empty), truthy checks on arrays/lists (narrow to non-empty variant), andin_array($needle, [...])(narrows to the literal union; the false-branch removes matched literals from a finite union). array_searchnarrows its return key type from the haystack.
- Truthy/falsy narrowing corrected across scalar types:
boolnarrows to thetrue/falseliteral (including on=== true/=== false),stringnarrows the string type,int/floatfalsy checks narrow to the zero literal, andintranges 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-listare never falsy and a closed emptyarray{}is never truthy, fixingcan_be_falsy/can_be_truthyfor these and forTNumericString,TNonNegativeInt, and zero-inclusiveTIntRange.- 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 missingTNumeric/TScalar/TFloatsubtype entries,DocblockTypeContradictiondetection,impossible_comparisonwith negative literals, andRedundantConditionon always-true named-int comparisons. +=/-=and++/--preserve integer-range bounds.is_numericandis_scalar/narrow_to_scalarnow handle all string and integer subtypes (including literal strings) correctly.remove_falseonTBoolyieldsTTrue(not empty), and return-type checks guard against an emptyremove_falseresult.- Static-call and method-call diagnostics:
PossiblyNullMethodCallis now suppressed againstmixedreceivers. MissingThrowsDocblockis suppressed for@template T of Exceptionparameters.TKeyedArrayproperty keys are validated against a genericarray<K,V>.Foo::classexpressions no longer emitUndefinedClass.- A PHP type hint is now preferred over a conflicting scalar
@paramdocblock. - In non-strict-mode files,
int/false→boolis no longer flagged asInvalidReturnType, and scalarint/float→stringis reclassified fromInvalidArgumenttoArgumentTypeCoercion. - A
mixed|nullargument is treated asmixed, not possibly-null. - The
analyze_sourcefile is now registered in the workspace index.
[0.44.0] - 2026-06-17
Section titled “[0.44.0] - 2026-06-17”int-rangesub-ranges are now correctly recognized as subtypes of containing int-ranges.positive-intis now a subtype ofscalar,numeric, andint<min,max>when the range contains all positive integers.list<T>subtype check forarray<K,V>now verifiesint <: K(acceptsarray-key-keyed arrays). Keyed array shapes likearray{0:Child,1:Child}now satisfylist<Base>whenChild extends Base.do-whilebodies are now known to execute at least once: variables introduced in the body are stripped ofpossibly_undefined/possibly_assignedafter the first pass, matching PHP’s guaranteed-first-iteration semantics.- Property type narrowing via
!== null/=== nullguards and direct assignment:$this->prop !== nullnow refines the property type in the true-branch, and$this->prop = $valrecords the assigned type for subsequent accesses within the same scope. $this->prop instanceof ClassNamenow narrows the property’s type in the true-branch, preventing falseInvalidArgumentandTypeMismatchdiagnostics 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 inwhileconditions (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 emitUnusedParamfor parameters that are unused in the overriding body.- PHP
ext-bz2stubs added:bzcompress,bzdecompress,bzopen,bzread,bzwrite,bzclose,bzflush,bzerrno,bzerror,bzerrstrno longer emitUndefinedFunction. - Trait method aliases (
use Trait { method as alias; }) are now resolved before insteadof precedence, fixingUndefinedMethodfalse positives on aliased method calls. iterable<K,V>now expands toarray<K,V>|Traversable<K,V>, forwarding the key type to both sides. Previously the key was dropped, causing falseInvalidArgumentdiagnostics when aTraversableimplementation was passed to aniterable<K,V>parameter.non-empty-list<T>is now a subtype ofarray<K,V>andnon-empty-array<K,V>.- String literals that cannot be class names (e.g.
'string[]') no longer triggerUndefinedClassorInvalidArgumentwhen passed toclass-stringparameters. A complementaryTLiteralString → TClassStringsubtype rule prevents the redundantInvalidArgumentpath. array_key_exists('k', $arr)in a truthy guard now adds'k'as a non-optional entry in every sealedTKeyedArrayshape of the variable’s type, suppressing subsequentNonExistentArrayOffsetdiagnostics. Works for both plain variables and property accesses ($this->prop).Color::{$name}(dynamic enum case / const access) no longer emitsUndefinedConstant. The class sub-expression is only analyzed when it is a variable; plain identifiers are skipped, matching the existingClassConstAccessguard.
[0.43.0] - 2026-06-16
Section titled “[0.43.0] - 2026-06-16”intvalues passed tostringparameters in non-strict-mode files (withoutdeclare(strict_types=1)) are no longer flagged asInvalidArgument. PHP’s coercive typing silently casts integers to strings in this context.- Batch analysis path (
analyze_paths) now callsensure_vendor_eager_functions(), ensuring Composerautoload.filesglobals (e.g. Laravel Prompts helpers:confirm,select,suggest) are indexed before body analysis. Previously, 61 spuriousUndefinedFunctiondiagnostics were emitted on the Laravel corpus. foreach ($arr as &$val)by-reference variables no longer emitUnusedVariableor 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 falseUnusedVariableandUnusedForeachValuediagnostics. - Variables assigned before a
tryblock and read only in thefinallyblock are no longer reported as unused. - Union-typed arguments (e.g.
Arrayable|Stringable|array|string) to matching parameters no longer emit falseImplicitToStringCastdiagnostics. catch (Exception $e)variables are never reported as unused, including when nested insideif/elseortry/catchchains.- 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 = $iteminsideforeach) no longer re-arm consumed writes spuriously, while$a += $ipatterns retain dead-write detection when$ais never read after the loop. $var::classand$var::CONSTaccesses now correctly mark the variable as consumed.require/includemarks all in-scope variables as consumed, since the included file can read any variable in the calling scope.- Variables assigned before a
tryblock, overwritten inside thetrybody, and read in thefinallyblock are no longer flagged as dead writes — the pre-try write is live on the exception path. UnusedVariableandUndefinedVariablediagnostics are suppressed in Blade templates (.blade.php) and PHP files underresources/views/, where variables are injected by the template engine rather than assigned in PHP.method_exists($obj, 'method')guards now suppressUndefinedMethoddiagnostics inside the guardedifbranch, including guards on property accesses.Closureobjects and keyed-array callables (e.g.[object, "method"]) are now valid callable subtypes, fixing falseInvalidReturnTypeandInvalidArgumentdiagnostics.@internalmethods called on$this(own class or via traits) no longer emitInternalMethodfalse positives.new $classStringVarwhere the variable holdsclass-string<AbstractClass>no longer emitsAbstractInstantiation— 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 emitInvalidCast.- Assignment expressions inside
is_null()/is_string()/etc. guards (if (!is_null($model = $this->first(...)))) now narrow the assigned variable in the then-branch, fixingNullableReturnStatementfalse positives infirstOrFail-style methods. iterablepseudo-type now correctly expands toarray|Traversablein both the docblock parser and the AST type-hint parser. Previously it was mapped to plainarray, causingInvalidArgumentandInvalidReturnTypefalse positives whereverTraversableimplementations were used.- Absolute FQCNs in docblocks (e.g.
\Carbon\CarbonImmutable) are now preserved through alias resolution, preventing mis-resolution viauseimports 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, andpreg_filternow returnstring|nullwhen$subjectis a string andarray<int,string>|nullwhen it is an array.var_export($val, true)now returnsstringinstead ofstring|null.
[0.42.0] - 2026-06-15
Section titled “[0.42.0] - 2026-06-15”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 bysymbol_at+definition_of.- Vendor
autoload.filesglobals (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-positiveUndefinedFunctiondiagnostics 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 tomixedwhen the class had not yet been eagerly indexed.
[0.41.0] - 2026-06-15
Section titled “[0.41.0] - 2026-06-15”IfThisIsMismatch(MIR0902) — emitted when a method’s@if-this-istype 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 aswitch/matchstatement ongettype($x)contains arms thatgettype()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 amixedvalue (e.g.,array_pop()in astring-returning function).- Integer range types now tracked for
count()/sizeof()→int<0, max>(orint<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()andarray_filter()now infer precise result element types from their callbacks instead of returning barearray.- Vendored Redis and Memcached phpstorm-stubs extension directories, fixing ~1,400
UndefinedClassfalse 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|falsereturns 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 inTaintedFilesystemandTaintedUnserializationissue 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
objectparameter types vianamed_object_subtypechecking. [object, "method"]array literals are now recognized as valid callables, fixing falseInvalidArgumentdiagnostics.- Docblock-only properties (declared via
@propertyannotations) are now correctly typed as nullable and not flagged as uninitialized. - Property
@vardocblock annotations now resolve class-level template parameters, enabling precise typing for generic class properties. - Surplus arguments to closure calls no longer emit false
TooManyArgumentsdiagnostics 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 narrowmixedtoobjectin conditional branches.unset($arr[$key])now counts as a read of the variable, fixing falseUnusedVariablediagnostics.- 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()andfunction_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
matcharm conditions now correctly define the variable for use in the arm body. self,static,parent, and$thisresolution in trait bodies now correctly targets the consuming class instead of the trait.new staticis now allowed in abstract classes, delegating to concrete subclasses at runtime.stdClassnow permits dynamic property access and assignment without emittingUndefinedPropertydiagnostics.- Fully-qualified attribute names in
#[...]are now honored in attribute resolution. NumericandResourceare no longer treated as reserved class names in the parser.--clear-cachenow 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.
Changed
Section titled “Changed”mir-analyzermodule structure refactored for maintainability:batch.rs,class.rs,parser/docblock.rs,session.rs, andbody_analysis.rssplit into dedicated submodules.- PHP parser suite (
php-rs-parser,php-ast,php-lexer,phpdoc-parser) upgraded to 0.18.0 for improved parsing robustness.
Performance
Section titled “Performance”- Large false-positive reduction on the Laravel reference corpus: the vendored Redis/Memcached stubs and version attributes support reduce
UndefinedClassfrom 617 to 114 (an 82% reduction on the reference benchmark).
[0.40.0] - 2026-06-13
Section titled “[0.40.0] - 2026-06-13”TypeDoesNotContainType— impossibleswitchcase values (literal cannot intersect the switch subject type) and impossiblematcharm conditions (same scalar/literal intersection check) are now reported.MixedAssignmentis now also emitted when aforeachvalue 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) —finalclass declared but never directly referenced. Restricted tofinalclasses 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-severityInvalidPropertyAssignment; 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 $paramdocblock tags; sink params are stored onFunctionDef/MethodDef.UnusedSuppress(MIR0508, Info) — emitted when a@psalm-suppress,@suppress, or@mir-suppressannotation 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-propertiesbut 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@returndocblock (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 emitDeprecatedMethodinstead ofDeprecatedMethodCall, reservingDeprecatedMethodCallfor static calls and__clonedispatch.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 astringfunction).- 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|falsereturns forexplode()/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
UndefinedClassforRedis/Memcachedin 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-versionchange, or stub set update. --clear-cachenow 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 acache.jsonthat no longer exists (the format iscache.bin), making it a functional operation for normal project runs.
[0.39.0] - 2026-06-12
Section titled “[0.39.0] - 2026-06-12”UnnecessaryVarAnnotation(Info) — a@varannotation 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 stringon$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/@paramdocblock that contradicts the native type hint on a top-level function is now reported. Refinements never fire: the comparison uses PHP type families (withint → floatcoercion andcallable’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,::classrefs, 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_dependentsno 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); aclass-string<T>union alternative consumes class-string arguments so a sibling bareTno longer absorbs them; method-level@templateshadows a same-named class template during argument checking; and a docblock description following a@templateline is no longer misparsed as a bound. Removes ~1350 FPs (6148 → 4781), dominated by Mockery intersection mocks. InvalidStringClass:new $xwhere$xismixedor a template param no longer fires —mixedis aMixed*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 — closureuse()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); andswitchwith adefaultarm 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); andclass-string<T>binding coerces class-name-shaped string literals such asm::mock('Foo\Bar')without::class.InvalidArgument: an array passed to acallable|array|nullparam matches the array alternative instead of being forced into the[object, "method"]callable shape. Removes 169 FPs (618 → 449).
- Template binding: trailing variadic params now bind every remaining argument (unwrapping
[0.38.0] - 2026-06-12
Section titled “[0.38.0] - 2026-06-12”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).
Changed
Section titled “Changed”- 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 andClassReferencesymbols, so find-references and hover cover closure usages. $x instanceof Foonow records aClassReferencesymbol at the class-name span, unblocking hover and thesymbol_at→references_toround-trip for instanceof sites.- Property references and symbols now key on the declaring class (as
find_property_in_chainreturns it) instead of the receiver type, fixing find-references andsymbol_atfor inherited properties accessed through a subtype.
[0.37.0] - 2026-06-11
Section titled “[0.37.0] - 2026-06-11”- 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_symbolsfor performance optimization in batch analysis runs.
Changed
Section titled “Changed”analyze_filenow assembles results from per-scope memos instead of a single whole-file analysis walk, improving incremental re-analysis efficiency.- Reference locations architecture refactored:
RefIndexconsolidates three independent reference maps (reference_locations,file_references,symbol_referencers) into a single tracked structure. - Dependent re-analysis now drives through the
analyze_filequery 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.
[0.36.0] - 2026-06-11
Section titled “[0.36.0] - 2026-06-11”MissingReturnType(MIR1201) andMissingParamType(MIR1200) — emitted for interface methods that lack@returnor@paramdocblock annotations when not otherwise typed.MixedArgument(MIR0221) andMixedAssignment(MIR0222) — emitted when amixed-typed value is passed to a parameter expecting a concrete type, or assigned to a typed property.MixedArrayAccess(MIR0223),MixedArrayOffset(MIR0224),MixedPropertyFetch(MIR0225), andMixedPropertyAssignment(MIR0226) — emitted whenmixedis used in array/property access contexts.MissingPropertyType(MIR1202) — emitted for untyped class and trait properties whenfind_dead_codeis enabled.ForbiddenCode(MIR1301) — detects code marked with#[Forbidden]attribute; use#[Forbidden("reason")]on methods/functions to flag uses as errors.@tracedocblock annotation — mark variables and expressions with/** @trace $var */to emit an@traceinformational 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
TClosureand__invokemethod calls: generic template parameters are now resolved at call sites, enabling precise type narrowing on closure return values. @no-named-argumentsenforcement: methods/functions marked with this attribute now emitInvalidArgumentwhen invoked with named arguments.- Duplicate declaration detection:
DuplicateClass,DuplicateInterface,DuplicateTrait,DuplicateFunction, andDuplicateConstantnow 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:
UnusedParamandUnusedVariablefalse positives eliminated for promoted properties accessed through property-assignment or constructor side effects. if-condition variable assignment detection: variables assigned inifcondition expressions (e.g.,if ($x = foo())) are no longer incorrectly flagged as unused.- Negated
instanceofguard narrowing: type refinement now correctly applies at receiver position ($obj instanceof Xand!$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.
TLiteralStringsubtype narrowing: numeric literal strings now correctly matchTNumericStringbounds.- 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 falseInvalidReturnTypediagnostics. try-body divergence is now preserved when allcatchblocks also diverge, preventing unreachable-code false positives.ImplicitToStringCastsuppression for classes implementing\Stringableand when argument union contains non-string arms.@paramdocblock generic type hints now take precedence over plain array hints for promoted properties.
Changed
Section titled “Changed”- All 1843 fixture tests now pass without ignores, improving test coverage visibility and closing known gaps in Psalm parity.
[0.35.1] - 2026-06-10
Section titled “[0.35.1] - 2026-06-10”DuplicateClassno longer fires when two classes share the same name in separate unbraced namespace blocks.abs(int)now returnsintinstead offloat|int.- Symbol lookup now records parameter declaration sites as
Variablesymbols, enabling go-to-definition on function/method parameters. - Symbol lookup now resolves gap cursors in method chains via
expr_spanfallback, fixing missed definitions in chained calls.
Changed
Section titled “Changed”- PHP parser and phpdoc-parser updated to 0.17.0.
[0.35.0] - 2026-06-09
Section titled “[0.35.0] - 2026-06-09”UnhandledMatchCondition— emitted when amatchexpression 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.AbstractMethodCallnow fires when an abstract static method is called by explicit class name (e.g.Base::bar()wherebar()is abstract). Self/static/parent calls remain exempt.InvalidDocblocknow 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 ofint|string; and@methodannotations that are empty, contain invalid characters, or declare by-reference parameters.InvalidDocblockis now also emitted for@templateannotations 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 andMethodSignatureMismatchis emitted for incompatible signatures. - Trait
insteadofconflict resolution is now applied during method lookup (go-to-definition and call resolution resolve to the winning trait instead of whichever was indexed first).
__getreturn type is now propagated to magic property-access inference: accesses that fall through to__getcarry the declared return type instead of always resolving tomixed.enum::cases()now synthesizeslist<EnumType>instead ofmixed, allowingforeachloop variables to be typed as the specific enum and enablingUnhandledMatchConditionto fire on enum matches.SourceFiletext is now freed on removal: theArc<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 oncollect_file_definitions, preventing unbounded memo accumulation for removed files. deleted_filestracking added toMirDbStorageso removed files are explicitly auditable and provide the foundation for future tracked-struct GC.
Performance
Section titled “Performance”- Variable types stored in
FlowStateandInferredFileTypesare now deduplicated viawrap_var_type, backed by the existingintern_or_wrappool. Common scalars hit an O(1) fast path; merged types that equal a prior type are also deduplicated, makingArc::ptr_eqshortcuts 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-builtArcstatics are shared via COW, saving ~140 MiB of allocation churn on the project-only analysis pass (measured on Laravel).TemplateParam.boundchanged fromOption<Type>(176 B inline) toOption<Arc<Type>>viaintern_or_wrap, saving ~36 MiB of allocation churn on the project-only analysis pass.
[0.34.0] - 2026-06-08
Section titled “[0.34.0] - 2026-06-08”WrongCaseClass(MIR1009),WrongCaseFunction(MIR1010),WrongCaseMethod(MIR1011) — new Info-severity diagnostics for case-sensitive identifier references (PHP 8.6 RFC). Coversnewexpressions, static calls,instanceof, type hints,catchclauses,extends/implements/use-trait declarations, built-in and user-defined functions, instance and static method calls, anduseimport declarations.WrongCaseMethodnow fires when a magic method is defined with wrong casing (e.g.__CONSTRUCTinstead 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 whenparent::is used (static call, constant access, property fetch, orparent::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).InvalidClonenow also fires when cloning a named object whose__clone()method isprivateand the caller does not have access.@finaldocblock annotation is now treated as equivalent to the nativefinalkeyword forInvalidExtendClassdetection.
ATTR_TARGET_ALLcorrected from 127 to 63 (the correct sum of the sixTARGET_*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.NonStaticSelfCallno longer suppresses the diagnostic when the class defines__callStatic.__callStaticonly intercepts undefined static methods, not explicitly-defined non-static ones.$thisno longer leaks into static arrow functions when resolving captured outer scope.
Changed
Section titled “Changed”FinalClassExtendedrenamed toInvalidExtendClassto align with Psalm’s naming. Update any inline@mir-suppress FinalClassExtendedannotations to@mir-suppress InvalidExtendClass.
[0.33.0] - 2026-06-05
Section titled “[0.33.0] - 2026-06-05”- Eager + background vendor indexing with configurable chunk size and memory targets (controlled via
--vendor-memoryflag; defaults to 128 MiB chunks).
- Fixed exponential memory growth when analyzing files with nested conditional branches and repeated dead-write tracking.
FlowState::merge_branchesnow 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.
Changed
Section titled “Changed”- Vendor indexing now uses the chunked indexing engine for more predictable memory usage and streaming behavior.
Performance
Section titled “Performance”- 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.
PropertyDeftype fields changed fromOption<Type>toOption<Arc<Type>>, reducing per-property overhead by 168 bytes.lazy_load_missing_classesingest loop is now parallelized, speeding up vendor class loading in batch mode.
[0.32.0] - 2026-06-04
Section titled “[0.32.0] - 2026-06-04”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$thisis 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 whenself::/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 whennewis used directly on an interface.DeprecatedProperty(MIR1005) is now emitted when a property marked with@deprecatedor#[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, andDeprecatedCalldetection expanded:#[Deprecated]is now recognised on user-defined methods and functions; deprecated classes are caught in static calls, constant access, and type hints.DeprecatedMethodCallis now emitted when cloning an object whose__clone()method is deprecated.InvalidCastis now emitted when(string)is applied to a concrete class that does not implement__toString().InvalidCatch(MIR1503) is now emitted when acatchclause names a type that does not extendThrowable.ImplicitToStringCast(MIR1501) is now emitted when aStringableobject is passed where astringis 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%.UnusedForeachValueis now emitted when the value variable in aforeachloop is never read.UnusedVariabledead-write detection: a variable that is assigned and then overwritten before being read is now flagged.UnusedVariableis 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 isprivate.MethodSignatureMismatchnow 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 infersBox<int>by binding class@templateparameters 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
@returnannotation. @readonlydocblock annotation on properties is now treated the same as the nativereadonlykeyword for theReadonlyPropertyAssignmentcheck.
@mixinproperty resolution: properties declared on@mixinclasses are now found via the full inheritance chain, eliminatingUndefinedPropertyfalse positives for mixin-based patterns.- Narrowing false positive: possibly-undefined variables no longer cause the
else/elseifbranch to be incorrectly marked as unreachable. - Narrowing in
elseif/elsechains: each failedelseifcondition is now applied as a negative narrowing to theelsebranch. UnusedVariablefalse positives in loops: pre-loop writes are cleared after the loop body iterates, preventing them from being re-introduced through the else path.UnusedVariablefalse positives for variables passed tocompact(): 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) — theNonediscriminant byte was omitted on write while deserialization still expected it, causing misaligned reads and a runaway allocation. Removedskip_serializing_iffrom thedeprecatedfield onPropertyDef,ConstantDef,InterfaceDef,TraitDef, andEnumCaseDef. Stub cache format version bumped to 4 to invalidate stale on-disk entries.
[0.31.0] - 2026-06-01
Section titled “[0.31.0] - 2026-06-01”- Inline issue suppression via source comments: add
// @mir-suppress DiagnosticNameon 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 inswitchcases andmatcharms, 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,608UndefinedFunctionfalse positives on a standard Laravel project. - All issue locations now carry
line_end/col_endin addition to the existing start position, enabling tighter diagnostic ranges in SARIF, LSP, and playground consumers.
UnusedVariablefalse positives for variables used as dynamic property or method names ($this->$var,$this->{$var},$this->$method()).UndefinedClassfalse positives for class names used as the argument toclass_exists(),interface_exists(), ortrait_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 falseInvalidArgumenterrors. - 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&&). ReducesPossiblyUndefinedVariablefalse positives in the Laravel benchmark from 31 to 7. - Composer root detection now skips
vendor/<org>/<pkg>/composer.jsonmanifests and walks up to the true project root, eliminating ~1,552UndefinedClassfalse positives on standard Laravel projects. strtr($str, $pairs)(2-argument array form) no longer firesTooFewArguments.TooManyArgumentsfalse positives eliminated when a union type contains a barecallable(unknown arity) alongside a typedTClosure.UnusedVariablefalse positives eliminated for variables read only inside afinallyblock (the save-restore pattern).- Nested
TConditionalreturn 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. UndefinedPropertyfalse positives eliminated for property accesses guarded by??orisset(e.g.$this->prop ?? null).PossiblyUndefinedVariablefalse positives eliminated for variables used as the left operand of??when the coalesced result is immediately compared against the fallback literal.- A bare
Closuretype now satisfies a typedClosure(): Tparameter, eliminating falseInvalidArgumenterrors. ingest_filenow evicts dependents’ cached analysis when a file’s content changes, preventing stale results from being replayed across incremental re-analysis.Enum::Caseand class constant accesses now resolve to the correct type instead ofmixed.TooManyArgumentsfalse positives eliminated for functions and methods that usefunc_get_args()/func_num_args()/func_get_arg()in their bodies.InvalidArgumentfalse positives eliminated forStringableobjects passed asstringparameters in files withoutdeclare(strict_types=1).array_keys(array<K, V>)now returnslist<K>instead oflist<mixed>.preg_match$matchesparameter is now typed asarray<int, string>via by-ref write-back.str_replace/str_ireplacereturn type is narrowed tostringwhen the subject is a scalar.hrtime()return is narrowed toint|floatwhen$as_numberistrue.NonExistentArrayOffsetis suppressed inside existence-check contexts (isset,??,empty).- Template parameters in supertype position are now treated as wildcards in
atomic_subtype, eliminating falseInvalidTemplateParamdiagnostics for union-sub against union bounds. list<T>is now inferred for the$arr[] = $vpush notation instead ofarray<mixed, T>.$obj::classpassed as aclass-string<T>argument no longer firesInvalidArgument.- 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/ClassNameconstant accesses, and inherited method calls use the declaring class. - PHP version filtering is now wired into the salsa database so
FileAnalyzerhonours--php-versioncorrectly. - Parser now strips quotes from array shape keys in PHPDoc (
array{'key': T}parses correctly). mysqli_init()PHP 8.0 overload (returningmysqli) added to stubs.
Performance
Section titled “Performance”- Peak cold-start memory reduced by ~22 MiB:
MethodDef/FunctionDefinferred return types changed fromOption<Type>(176 B) toOption<Arc<Type>>(8 B); class analysis no longer materializes vendor/stub classes during the analyzed-file decomposition;mimallocinstalled as the global allocator.
[0.30.0] - 2026-05-28
Section titled “[0.30.0] - 2026-05-28”$argvand$argcare now seeded as predefined globals, eliminatingUndefinedVariablefalse 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. PossiblyUndefinedVariablefalse positives eliminated for variables assigned insidewhile(true)andfor(;;)loops before everybreak. Infinite loops no longer treat the “loop never executes” path as reachable.UnusedVariableandUnusedParamfalse positives eliminated for variables read only inside a diverging if-branch (one that alwaysreturns orthrows).
Changed
Section titled “Changed”- Upgraded
php-rs-parser,php-ast,php-lexer, andphpdoc-parserto 0.15.0. Function and closure bodies are now wrapped in aBlocktype; class/enum/interface/trait members are behindClassBody/EnumBodywrappers.
[0.29.0] - 2026-05-27
Section titled “[0.29.0] - 2026-05-27”- 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-cacheto opt out. @mir-checkinline type assertion directive: annotate a variable with/** @mir-check $x is SomeType */in a test fixture to emitTypeCheckMismatchif the inferred type does not match, enabling regression tests for type inference.- Short-circuit
isset/!issetnarrowing in&&and||expressions:isset($x) && $x->method()now correctly narrows$xto non-null inside the right-hand side. InvalidStringClassdiagnostic: emitted instead ofUndefinedClasswhen a dynamic class expression (new $var,$var::method()) is not a validclass-string. String literal arguments toclass-stringparameters are now validated.TCallableStringatomic 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
InvalidTemplateParamandInvalidArgumentdiagnostics 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,@varand 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 toX|Yinstead of emitting a false positive. - Intersection types: intersection-typed values are now recognized as subtypes of their parts and of
object, eliminating companionInvalidArgumentfalse positives for functions likeget_class().InvalidArgumentis also suppressed when a parameter type contains templates within an intersection. - Template inference:
Tis now correctly inferred fromclass-string<T>arguments,Closure,callable, and intersection-typed parameters. Template bounds now check inheritance chains. Array-key pseudo-type andTKeyedArrayare 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] = $valassignments, fixing ~62 false-positiveInvalidReturnTypediagnostics. Mutual-reference array loops no longer cause an infinite hang during inference. - PHP built-ins:
array_walk,array_walk_recursive3rd parameter is now optional;mt_rand/randparameters are now optional. Fixes ~30TooFewArgumentsfalse positives.array_mapwith 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, eliminatingTooManyArgumentsfalse positives. - Narrowing:
UndefinedVariableis no longer emitted for variables on the left-hand side of??and??=.assigned_varsis now correctly restored afterisset-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::CONSTreferences in method parameter defaults now correctly emitUndefinedConstantwhen the constant does not exist.- First-class callable syntax (
SomeClass::method(...)) now resolves to a typedTClosureinstead of an untyped callable. InvalidStringClassfalse positives eliminated for object expressions on the left of::(e.g.$obj::CONST).
Changed
Section titled “Changed”ProjectAnalyzeris replaced byAnalysisSessionin 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.
[0.28.0] - 2026-05-17
Section titled “[0.28.0] - 2026-05-17”- Composer plugin type:
composer require jorgsowa/mirnow triggers the binary download automatically without requiring manual script wiring. Thecomposer.jsontype field is set tocomposer-plugin, and aPluginclass 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_openfailures 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, andDeprecatedMethodCallissue pages. Added missingUndefinedTrait(MIR0009) documentation page.
[0.27.0] - 2026-05-17
Section titled “[0.27.0] - 2026-05-17”- Stable
MIR####error codes for every issue variant, organized into 16 category bands. Codes surface inDisplayoutput in rustc style:error[MIR0005] UndefinedClass: .... Thename()method is unchanged and remains the suppression and SARIF rule key. UndefinedTrait(MIR0009) diagnostic: emitted when ausestatement references a name that does not exist in the codebase.InvalidTraitUsenow also emitted when the used name resolves to a class, interface, or enum instead of a trait. Per-use-statement source locations are stored inClassStorageandClassNodeso diagnostics point at the trait name in theusestatement.- php-rs-parser 0.13.0: parse errors now carry precise source locations via
err.span()instead of hardcoded line 1 col 0;ForbiddenWarningdiagnostics emit atSeverity::Warningand do not block semantic analysis.
- Literal integer (
1,42,-3) and quoted-string ('foo',"bar") types in docblock annotations now parse asTLiteralInt/TLiteralStringinstead ofTNamedObject, making@return 2|3and similar annotations work correctly. @return/@paramdocblocks written on the line preceding a standalone function declaration (rather than attached as an ASTdoc_comment) are now applied, matching the existing behavior for class methods.@methoddocblocks on traits, interfaces, and enums are now honored. Previouslyadd_docblock_memberswas only called for classes, silently dropping virtual method declarations on other symbol kinds.@method-added methods carryis_virtual: trueand are excluded fromUnimplementedInterfaceMethodchecks.UnusedVariablenow reports the correct source location for variables first assigned via array push ($arr[] = value),static $var, orglobal $var(previously fell back to line 1, col 0).global $varassignments are now treated as externally observable side effects (matching by-reference parameter semantics), eliminating false-positiveUnusedVariablediagnostics on global variable writes.Union::intersect_withnow returnsnever()when no types overlap between the subject and the arm condition, preventing false-positive method/property errors in match arm bodies.Union::add_typenow absorbsneverinto non-empty unions (T | never = T).- Pending reference locations are now drained into
RefLocAccumulatorinsideanalyze_file(Salsa), fixing reference tracking in the incremental analysis path.
Changed
Section titled “Changed”MissingThrowsDocblockis now suppressed by default forRuntimeExceptionandLogicExceptiondescendants (PHP’s “unchecked” exceptions). Both directthrowstatements and transitive@throwspropagation are filtered. The suppression list is configurable via the newsuppressed_issue_kindsAPI.find_dead_code: boolonProjectAnalyzerreplaced withsuppressed_issue_kinds: HashSet<String>and a centralizedapply_issue_suppressions()post-filter applied on every analysis path including the cache-hit path.- Removed the
instanceofoperator-precedence workaround fromnarrowing.rs; php-rs-parser 0.13.0 correctly parses!$x instanceof Cas!($x instanceof C).
Dependencies
Section titled “Dependencies”- Bumped php-rs-parser, php-ast, php-lexer, phpdoc-parser
0.12.1→0.13.0.
[0.26.0] - 2026-05-15
Section titled “[0.26.0] - 2026-05-15”Performance
Section titled “Performance”- Persistent Pass-1 cache (
StubSliceCache): when a cache directory is configured (ProjectAnalyzer::with_cache,AnalysisSession::with_cache_dir, or--cache-dir), each file’sStubSliceis stashed in<cache_dir>/stubs/<hh>/<full_hash>.binusing 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 byCARGO_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_fileviaSharedDb::collect_and_ingest_file) consult the cache. Measured onlaravel/framework v11.44.7(10,188 vendor files, M-series Mac), independently verified hit counters (10,185 hits / 0 misseson 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_filestorm viaAnalysisSession: 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}andAnalysisSession::{with_cache,with_cache_dir}nowdebug_assertthey are called before any file is ingested — late attachment would silently reset the shared database and discard prior Pass-1 work.
Dependencies
Section titled “Dependencies”- Bumped all transitive crates within their compatible semver ranges (
cargo update), including thephp-rs-parser/php-ast/php-lexer/phpdoc-parserstack from0.12.0→0.12.1. - Bumped
quick-xml0.39→0.40inmir-analyzer. - Replaced
postcardwithbincode 1.3.3for theStubSliceCacheon-disk format.postcardpulledheapless→atomic-polyfill(RUSTSEC-2023-0089);bincode v2was tried next but is itself flagged unmaintained (RUSTSEC-2025-0141).bincode 1.3.3carries 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.
[0.25.0] - 2026-05-15
Section titled “[0.25.0] - 2026-05-15”Performance
Section titled “Performance”- Pass 2 reference-location recording now uses per-worker staging buffers (
PendingRefLocs) instead of writing directly to sharedArc<Mutex<...>>maps. Workers accumulate locations in an isolatedparking_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 becausedependency_graph()routed edges throughsymbol_defining_file(), which returnsNonefor deleted symbols. Three coordinated fixes: afile_to_defined_symbolsforward index for O(1) definition lookup on removal; asymbol_referencersreverse index that survives symbol deletion; and astale_defined_symbolsaccumulator inAnalysisSessionthat feeds deleted symbols’ referencers back into the BFS.
[0.24.0] - 2026-05-15
Section titled “[0.24.0] - 2026-05-15”Performance
Section titled “Performance”- 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_referencesforward index added toMirDb: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 andanalyze_dependents_of().
[0.23.0] - 2026-05-14
Section titled “[0.23.0] - 2026-05-14”- 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::classcomparisons, 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 soanalyze_dependents_of()returns files referencing classes via unqualified absolute paths.
Changed
Section titled “Changed”- Refactored database module structure:
source_filesmap moved from SharedDb tuple into MirDb for clearer ownership. - Lazy-load optimization: avoid redundant full scans of class inheritance chains when loading missing classes.
[0.22.0] - 2026-05-12
Section titled “[0.22.0] - 2026-05-12”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 alongsideanalyze_dependents_of()without accessingClassAnalyzerdirectly.
[0.21.2] - 2026-05-12
Section titled “[0.21.2] - 2026-05-12”@template T as Boundsyntax now parsed correctly (previously only@template T of Boundwas recognized), enabling proper type narrowing for templates declared with theaskeyword.- Callable/closure return types in
@returnannotations (e.g.,@return \Closure(): T) now correctly capture the return type after the colon, fixing falseMixedMethodCalldiagnostics when template parameters were used as closure return types.
[0.21.1] - 2026-05-09
Section titled “[0.21.1] - 2026-05-09”cargo-denyconfiguration format migration to version 2.
[0.21.0] - 2026-05-09
Section titled “[0.21.0] - 2026-05-09”- Tier 1 & 2 parser optimizations: pre-sized arena allocators and parallel user stub discovery for improved cold-start performance (25-40% improvement expected).
cargo-denyconfiguration 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.
Changed
Section titled “Changed”- 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.
Performance
Section titled “Performance”- Parallelized fixture discovery in build script.
[0.20.0] - 2026-05-08
Section titled “[0.20.0] - 2026-05-08”- 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 parserSpan(byte-offset range) to the crate’sLocationtype (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/UndefinedClassfor 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. MixedCloneissue type: detectsclone/clone withexpressions onmixed-typed values inExpressionAnalyzer.
@varannotation narrowing now applies to global-scope statements, not just function bodies. Previouslyanalyze_stmt()(used for top-level statements) skipped the pre/post narrowing thatanalyze_stmts()performed for function bodies, so@varhad no effect at global scope. Fixesglobal_with_var_no_indent,function_with_var, andinvalid_mixed_clonefixtures.
Changed
Section titled “Changed”- Analyzer boilerplate simplifications:
Union::core_type()collapses 10+ chainedremove_null().remove_false()call sites in type-checking logic.DefinitionCollector::parse_docblock_from_node_or_preceding()consolidates the “checkdoc_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.
[0.19.0] - 2026-05-07
Section titled “[0.19.0] - 2026-05-07”- 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.
[0.18.0] - 2026-05-06
Section titled “[0.18.0] - 2026-05-06”AbstractInstantiationdiagnostic to detect attempts to instantiate abstract classes vianew ClassName().
- Closure
use()clause validation: now detects undefined variables referenced in closure use() clauses. Example:use ($i)will reportUndefinedVariableif$iis not defined in the parent scope. - Mixin method resolution with generics: docblock
@mixin Foo<T>annotations now correctly resolve to classFooinstead of attempting to look up a non-existent class namedFoo<T>. - All 17
undefined_variablefixture tests now pass with correct line/column/message expectations. - All 15
undefined_constantfixture tests now pass with correct line/column/message expectations.
[0.17.3] - 2026-05-05
Section titled “[0.17.3] - 2026-05-05”Performance
Section titled “Performance”- 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
SimpleTypefor 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
StubSlicein Salsa during vendor collection, improving vendor ingestion performance.
[0.17.2] - 2026-05-04
Section titled “[0.17.2] - 2026-05-04”- The published
mir-analyzercrate is no longer shipped with an empty stub set. Thestubs/directory lived at the workspace root, outside the package, socargo packageexcluded it; downstream consumers (e.g.php-lsp) sawSTUB_FILES = &[]and every PHP built-in resolved asUndefinedFunction/UndefinedClass. Stubs now live inside the crate atcrates/mir-analyzer/stubs/and are included in the published artifact.build.rspanics if the directory is missing, and a newtests/packaging.rstest assertscargo package --listincludesstubs/Core/Core.phpplus 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([]), andnew ARRAYOBJECT([])no longer produce false-positiveUndefinedFunction/UndefinedClassdiagnostics. Implemented as side indices onMirDb(function_node_keys_lower,class_node_keys_lower) so the canonical-FQN storage thatactive_*_node_fqns,function_count,type_count, andclear_file_referencesdepend on is unchanged. Constants remain case-sensitive (PHP semantics).
[0.17.1] - 2026-05-03
Section titled “[0.17.1] - 2026-05-03”- 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_dbwas incorrectly extending it to classes, masking realUndefinedClassbugs. - Composer autoload parsing now covers
psr-0,classmap, andfilesin addition topsr-4, for both projectcomposer.jsonand each package invendor/composer/installed.json. Vendor packages that expose global helpers viaautoload.files(Symfony polyfills, Laravel helpers, ramsey/uuid bootstrap, etc.) and classmap-only packages no longer produce false-positiveUndefinedFunction/UndefinedClassdiagnostics.
[0.17.0] - 2026-05-03
Section titled “[0.17.0] - 2026-05-03”Removed
Section titled “Removed”mir_codebase::Codebasestruct,CodebaseBuilder,codebase_from_parts, and the internalInternermodule. 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. Themir-codebasecrate now exports only the serializable storage types (StubSlice,*Storage,FnParam,TemplateParam,Visibility,Location). Breaking for library consumers that importedmir_codebase::Codebase.ProjectAnalyzer::codebase()accessor (already removed in 0.16.x perf work; the Codebase deletion completes the cleanup).mir-codebaseno longer pulls indashmaporthiserror.
Performance
Section titled “Performance”- 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 inclass_ancestors/lookup_method_in_chain/method_is_concretely_implementednow useFxHashMap/FxHashSetinstead of stdHashMap/HashSet. Eliminates the per-ancestorStringallocation inclass_ancestors(now reuses the existingArc<str>). ~7% reduction in user CPU time on the Laravelsrc/benchmark.
[0.16.1] - 2026-05-01
Section titled “[0.16.1] - 2026-05-01”- 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.phpcan resolve project PSR-4 namespaces instead of reporting false-positiveUndefinedClassdiagnostics.
[0.16.0] - 2026-04-28
Section titled “[0.16.0] - 2026-04-28”- 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_typefor every symbol without recording reference locations. Callers no longer seemixedfor 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
OnceLockfinalization (Phase 3 item 6):ensure_finalized(fqcn)lazily computes and memoizes each class’s ancestor chain on first access viaDashMap<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.
Performance
Section titled “Performance”- 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 eachall_parentsread 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_parentsat snapshot time, causingrestore_all_parentsto silently restore empty ancestor chains on the LSP fast path.file_structural_snapshotnow callsensure_finalizedfor each symbol before capturing it.
[0.15.0] - 2026-04-28
Section titled “[0.15.0] - 2026-04-28”- Return type covariance for named-object overrides:
ClassAnalyzernow delegates tonamed_object_return_compatiblewhen 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 ofinstanceofis$this, it is resolved to the current class FQCN before narrowing, eliminating false-positiveMixedMethodCallandUndefinedPropertydiagnostics onif (!$other instanceof $this)guards. (#144)
Changed
Section titled “Changed”stmt.rssplit intostmt/sub-module (mod.rs,loops.rs,return_type.rs), following the same pattern ascall/. No behavior change.
[0.14.0] - 2026-04-28
Section titled “[0.14.0] - 2026-04-28”- Generic template substitution extended to array shapes (
TKeyedArray,TNonEmptyArray,TNonEmptyList), callable/closure types, conditional types, and intersection types. Variable calls ($fn()) onTClosure/TCallablenow resolve the correct return type instead ofmixed.TIntersectionmethod calls resolve against the part that owns the method. Docblock parser gainsarray{key: T}shape syntax andcallable(T): R/Closure(T): Rparsing. ParsedDocblock::is_inherit_docflag: 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_slicenow populatesfile_namespacesandfile_importsin the codebase, fixing false-positiveUndefinedClassdiagnostics foruse-aliased classes after any incremental re-analysis triggered byre_analyze_file.
Changed
Section titled “Changed”Locationtype unified inmir-types; internal codebase storage switched from byte offsets to(line, col_start, col_end). Allmark_*_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_callpath todocs.ymlso the deployment runs under a branch-authorized context instead of directly from a tag, fixing GitHub Pages environment protection failures.
[0.13.0] - 2026-04-28
Section titled “[0.13.0] - 2026-04-28”- 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)
Changed
Section titled “Changed”- 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).
[0.12.0] - 2026-04-27
Section titled “[0.12.0] - 2026-04-27”PossiblyInvalidArgumentissue: emitted when afalse|Tunion value is passed to a parameter that does not acceptfalse, surfacing potential type mismatches that were previously silently widened tomixed.- Backed enum
->valueand->nameaccess now returns a precise inferred type (TLiteralString/TLiteralIntfor->value,TLiteralStringfor->name) instead ofmixed. call_user_funcandcall_user_func_arraystring callables (e.g.'ClassName::methodName') are now tracked as real call references, fixing false-positive stub warnings on those forms.
- Infinite recursion on circular
@mixinreferences: 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-minutesadded to all workflow jobs and a concurrency group added to the CI workflow to cancel superseded runs.
[0.11.1] - 2026-04-26
Section titled “[0.11.1] - 2026-04-26”- Release CI: GitHub Release is now created from the CHANGELOG before binaries are uploaded, fixing a race condition where
upload-rust-binary-actionfailed with “release not found”.
[0.11.0] - 2026-04-26
Section titled “[0.11.0] - 2026-04-26”InvalidDocblockissue: 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 inmir.xml/psalm.xmlload additional stub paths before analysis; stub files are not themselves analyzed for errors. (#285) phpVersioncan 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)
Changed
Section titled “Changed”- phpstorm-stubs is now vendored directly in
stubs/(tracked in git) instead of a git submodule. External contributors no longer need to rungit 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.
[0.10.0] - 2026-04-26
Section titled “[0.10.0] - 2026-04-26”- Composer package
miropen/mir-php. Apost-install-cmd/post-update-cmdhook downloads the prebuiltmirbinary matching the installed version and host platform from GitHub Releases, verifies the SHA-256 sidecar, and exposesvendor/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. ReleaseGitHub Actions workflow building and uploading per-target archives + sha256 sidecars onv*tags.NullArgumentissue: emitted when a literalnullis passed to a non-nullable parameter (previously subsumed byInvalidArgument). Severity: warning.UnusedFunctionissue: emitted for free functions that are never called whenfind_dead_codeis enabled.InvalidPropertyAssignmentissue: emitted when a value of an incompatible type is assigned to a typed property. Handles class inheritance via the codebase.
cargo install mir-clireferences in README and docs corrected tomir-php(the actual crate name).- Panic in docblock extraction when source text before a declaration contains multibyte characters (e.g.,
→).find_preceding_docblocknow correctly advances past multibyte chars when scanning for word boundaries.
[0.9.1] - 2026-04-26
Section titled “[0.9.1] - 2026-04-26”Location.line_endfield — all issues now carry an end line number, enabling multi-line range highlighting in editors and code scanning tools. (#270)- SARIF output:
region.endLinepopulated fromline_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-falsetype narrowing. (#267) - Psalm docblock parity:
@psalm-import-typetype alias imports. (#267) - Psalm docblock parity:
@psalm-paramand@psalm-returntype narrowing annotations. (#267)
- SARIF output:
startColumn/endColumnare now correctly 1-based per SARIF 2.1.0 §3.30.5 (previously off by one). (#270) - SARIF output: rules now include
defaultConfiguration.levelso 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 suppressingUndefinedMethodon missing static methods. (#271) - Magic method dead-code exclusion now uses lowercase keys matching
own_methodsstorage, so__callStatic,__toString, and__debugInfoare correctly exempted fromUnusedMethodreports. (#271) __unserializeadded toMAGIC_METHODS_WITH_RUNTIME_PARAMS, preventing its$dataparameter 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-extendsand@psalm-require-implementsare correctly detected. (#267)
Changed
Section titled “Changed”- Bumped blake3, php-ast, php-lexer, and php-rs-parser to latest. (#272)
[0.9.0] - 2026-04-26
Section titled “[0.9.0] - 2026-04-26”- Trait method bodies are now analyzed in Pass 2; diagnostics (
UndefinedFunction,UndefinedMethod, unused variables, etc.) are emitted for code inside traits. (#264) UnreachableCodeissue — 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)
PossiblyUndefinedVariablepromoted toWarningseverity, making it visible at the default error level and matching Psalm’s behavior. (#261)- 10 false-positive
UndefinedMethodreports 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)
[0.8.0] - 2026-04-25
Section titled “[0.8.0] - 2026-04-25”PhpVersion::LATESTconstant (currently8.5) — used as the default when no explicit version is configured.ProjectAnalyzer::with_php_versionbuilder method to set the target PHP version.@deprecatedtag messages are now included inDeprecatedissue descriptions.php_versionis now propagated throughStatementsAnalyzerandExpressionAnalyzerfor version-gated checks.
UndefinedClassis 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 ininclude_str!.
Changed
Section titled “Changed”ProjectAnalyzer::php_versionfield is nowOption<PhpVersion>(None= usePhpVersion::LATEST); previously it was a barePhpVersiondefaulting to 8.4.- Bumped
php-rs-parser,php-ast, andphp-lexerto 0.9.2.
Performance
Section titled “Performance”IssueBuffer::adddeduplication changed from an O(n) scan to aHashSetlookup.
[0.7.3] - 2026-04-25
Section titled “[0.7.3] - 2026-04-25”- Cross-file
.phptfixture format with===file:Name.php===sections and optionalcomposer.jsonfor PSR-4 lazy-loading scenarios; 21 new cross-file fixtures added. ===config===section in.phptfixtures 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 coveringstdClass,preg_match,sscanf,array_mapnull callback, andarray_keysoptional filter. - Correctness tests for
inject_stub_slicecovering symbol overwrite,symbol_to_fileupdates,global_varscleanup onremove_file_definitions, andStubVfsroundtrip navigability.
Changed
Section titled “Changed”- Switched stubs from generated Rust files (
mir-stubs-gen) to phpstorm-stubs loaded at build time viaCUSTOM_STUB_FILES; themir-stubs-gencrate is removed. - Unified single-file and multi-file
.phptfixture parsers into a singleparse_phptfunction; existing===source===markers renamed to===file===.
UnimplementedAbstractMethodandUnimplementedInterfaceMethoderrors now report the method name with its original declared casing instead of the lowercase-normalized form.
[0.7.2] - 2026-04-24
Section titled “[0.7.2] - 2026-04-24”Changed
Section titled “Changed”- Bumped
php-rs-parser,php-ast, andphp-lexerto 0.9.1.
[0.7.1] - 2026-04-22
Section titled “[0.7.1] - 2026-04-22”StubSlice::fileandStubSlice::global_varsfields so a slice can describe the source file it came from and the@var-annotated globals it declares.CodebaseBuilderandcodebase_from_partsinmir-codebase— compose a finalizedCodebasefrom per-fileStubSlices without mutating shared state during collection.DefinitionCollector::new_for_sliceandDefinitionCollector::collect_slice— a pure-function entry point that returns aStubSliceinstead of writing to aCodebase. Enables downstream consumers (e.g. salsa queries) to treat Pass 1 as a pure computation.
Changed
Section titled “Changed”DefinitionCollectornow builds aStubSliceinternally; the existingnew+collectAPI is preserved as a shim that injects the slice on completion.Codebase::inject_stub_slicenow populatessymbol_to_fileandglobal_varswhen the slice has afileset.
[0.7.0] - 2026-04-21
Section titled “[0.7.0] - 2026-04-21”- PHP-first stub pipeline — stubs are now authored as PHP source files under
stubs/{ext}/withstub.tomlmanifests and transformed into Rust via the newmir-stubs-gencodegen tool, replacing the monolithic hand-writtenstubs.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)
UndefinedConstantissue — the analyzer now emitsUndefinedConstantfor 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)
Changed
Section titled “Changed”- Upgraded php-rs-parser and php-ast to 0.9; upgraded toml, quick-xml, and criterion to latest. (#245)
Performance
Section titled “Performance”- BLAKE3 for cache hashing — replaced SHA-256 with BLAKE3 for the incremental cache and deduplicated per-file hashing. (#244)
- Leading backslash in
useimports — fully qualified use-imports (use \Foo\Bar;) now resolve correctly by stripping the leading backslash. (#247) composer.jsondetection from path argument — when invoked with a path argument, mir now walks up from that path to locatecomposer.jsoninstead 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)
[0.6.0] - 2026-04-19
Section titled “[0.6.0] - 2026-04-19”- 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)
UndefinedClassforextends/implements— emitUndefinedClasswhen a class extends or implements a type that does not exist in the codebase or stubs. (#224)InvalidScopefor$thisin invalid context — emitInvalidScopewhen$thisis 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 hints —
type_from_hintnow correctly resolves intersection types (A&B), fixing false positives in type-narrowing and parameter checks. (#221)
[0.5.2] - 2026-04-19
Section titled “[0.5.2] - 2026-04-19”StaticDynMethodCallsupport — dynamic static dispatch (Foo::$method()) is now handled as a distinct AST variant; evaluates arguments for taint propagation and returnsmixed. (#216)
Changed
Section titled “Changed”- Upgraded php-rs-parser and php-ast to 0.8; migrated
FileParsertoParserContextfor O(1) arena reset on repeated parses. (#216)
Performance
Section titled “Performance”MethodStoragestored asArc—own_methodsin all storage types now holdsArc<MethodStorage>, making method lookups an atomic refcount bump instead of a deep clone. (#213)- Skip re-analysis on unchanged content —
re_analyze_filereturns cached results immediately when the file content hash matches, avoiding all four analysis phases on repeated LSP saves. (#204) - Skip
finalize()on body-only changes —re_analyze_filecaptures a structural snapshot before removal; if inheritance fields are unchanged after Pass 1, restoresall_parentsdirectly and skips the full class-hierarchy walk. (#205)
- Trait-of-trait method resolution —
get_method()now walks the full transitive trait chain with a cycle guard, eliminating falseUnimplementedInterfaceMethoderrors for methods contributed by indirectly used traits. (#209) elseifnarrowing and branch merge — elseif branches now correctly narrow on the parentifcondition being false, and all elseif branches are folded into the post-if merge (previously only the last branch survived). (#211)TKeyedArrayforeach key type —infer_foreach_typesnow derivesTLiteralString/TLiteralIntkeys fromArrayKeyentries instead of always returningTMixed. (#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)
[0.5.1] - 2026-04-18
Section titled “[0.5.1] - 2026-04-18”Performance
Section titled “Performance”- Reference index memory reduction — intern reference keys with a lock-free
u32interner, store all references in a flatVec<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)
[0.5.0] - 2026-04-17
Section titled “[0.5.0] - 2026-04-17”issues_by_file()onAnalysisResult— group analysis issues by their source file path for easier per-file reporting. (#154)- Symbol reference location tracking —
AnalysisResult::symbol_atresolves the symbol under a given position, enabling LSP go-to-definition and find-references. (#185) ResolvedSymbol::fileandcodebase_key— extended resolved symbol information with the source file and codebase key for cross-file navigation. (#185)
Changed
Section titled “Changed”- 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)
$thisis now injected into method context so$this->method()calls are correctly resolved bysymbol_at. (#193)
[0.4.1] - 2026-04-12
Section titled “[0.4.1] - 2026-04-12”- Diagnostic column offsets — fixed
col_endalways being equal tocol_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)
[0.4.0] - 2026-04-12
Section titled “[0.4.0] - 2026-04-12”- 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
@varannotation support for tracking globally-scoped variables declared outside of function/class scope. Reduces false positives inUndefinedVariablechecks. (#160)
Changed
Section titled “Changed”- Dependency updates — upgraded php-rs-parser and php-ast to v0.6.0 for improved parsing robustness and performance.
is_builtin_functionnow uses the full loaded stubs to properly detect built-in functions across all extensions.
[0.3.0] - 2026-04-10
Section titled “[0.3.0] - 2026-04-10”- Generic type covariance and contravariance — full support for
@templatetype parameter variance annotations in classes and methods. (#109) - Circular inheritance detection — emit
CircularInheritanceerror 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)
Changed
Section titled “Changed”- AST doc_comment refactor — switched from manual docblock discovery to using AST
doc_commentfields for more reliable comment association. (#107) - Removed
mir-test-utilscrate 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 parameters —
UnusedParamchecks now exclude magic method parameters (__construct,__get, etc.). (#108)
[0.2.1] - 2026-04-09
Section titled “[0.2.1] - 2026-04-09”Changed
Section titled “Changed”- Upgraded php-ast and php-rs-parser to v0.5.0.
- Proper source mapping threading from
ParseResultthrough the analysis pipeline.
[0.2.0] - 2026-04-08
Section titled “[0.2.0] - 2026-04-08”- 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
UnusedVariablefalse positives from 405 to 127 through improved read tracking in closures and assignment contexts.
[0.1.0] - 2026-03-15
Section titled “[0.1.0] - 2026-03-15”- 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.
