Mago 1.21.0
Mago 1.21.0 is a features-and-fixes release focused on diagnostic accuracy, linter ergonomics, and formatter consistency. Highlights include a new @mago-expect category:code(3) count shorthand so you can say "expect three occurrences" once instead of repeating the code, detection of default values that don't match their declared type on parameters / properties / constants, a new prefer-explode-over-preg-split linter rule, an opt-in formatter setting that preserves author-written parentheses around logical sub-expressions, auto-fixes for the string-style rule in both directions, an array_flip return-type provider that preserves shape, and a batch of analyzer fixes covering narrowing across union array accesses, keyed-array append type preservation, interpolated strings built from union parts, closure parameter typing in incompatible callable contexts, and more. The formatter also gets member-access chain consistency across four different starting-expression shapes and a new rule that keeps chains with trailing comments broken even when they'd otherwise fit.
✨ Features
Analyzer
- Detect invalid default values for parameters, properties, and class constants: Three new checks —
invalid-parameter-default-value,invalid-property-default-value,invalid-constant-value— flag declarations whose default value doesn't match the declared type. Catches classics likefunction f(SomeClass $x = []),public string $s = 1, andpublic const int C = 'x', including narrower@var/@paramdocblock types such asnon-empty-string $x = ''orpositive-int $n = 0(#1652) array_flip()return-type provider:array_flip()now preserves known-shape information instead of falling back to the generic stub.array_flip(['foo' => 1])infersarray{1: 'foo'},array_flip(list<string>)infersarray<string, int>, andarray_flip(array<K, V>)infersarray<V, K>whenVis anarray-keysubtype. Narrowing underarray_key_existsnow works on flipped arrays (#1633)- Accept wider index types for narrow-keyed arrays: Indexing
array<1, T>with anintno longer reportsmismatched-array-index. The access is type-valid — the specific key may just not be present at runtime, which is a separate concern handled by the known-items path. Empty arrays (neverkey type) still reject any index (#1633)
Linter
prefer-explode-over-preg-splitrule: Flagspreg_split()calls whose pattern has no regex meta-characters and no modifiers, suggestingexplode()instead. The rule fires only when the pattern is a literal with symmetric delimiters (/ # ~ ! @ % , ; |), the inner content has no regex special chars, and theflagsargument (if present) is literal0. Ships with an auto-fix that rewrites the pattern as a plain string, renames the call toexplode, and drops a trailingflags = 0argument (#1554)- Auto-fix for
string-stylerule (both directions): Thestring-stylerule now offers auto-fixes for bothinterpolation(converting"Hello, " . $nameto"Hello, {$name}") andconcatenation(the reverse). The fix handles leading/trailing expressions, adjacent expressions, multi-line input, braced and unbraced interpolations, escape preservation, and expression-only strings. Single-quoted literals are intentionally not fixed to avoid changing escape semantics (#1640)
Formatter
preserve-redundant-logical-binary-expression-parenthesesoption: New opt-in formatter setting (defaultfalse) that preserves author-written parentheses around a logical binary sub-expression (&&,||,and,or,xor) when its enclosing binary is also logical, even though operator precedence would make them redundant. With the setting on,($a && $b) || $cstays as written instead of being reformatted to$a && $b || $c(#1367)
Collector
code(N)count shorthand for@mago-expect/@mago-ignore: Instead of repeating a code to suppress multiple occurrences, you can now write@mago-expect analysis:mixed-operand(3)to suppress up to three matching issues.code(1)is equivalent to a bare code. If fewer issues match than expected, the auto-fix suggests reducing the count (or dropping the suffix entirely when it falls to 1) rather than deleting the directive (#1644)- Unused-pragma auto-fix no longer deletes whole PHPDocs: When removing an unfulfilled
@mago-expectfrom a PHPDoc that carries other content (description lines,@param/@return/ other tags), the auto-fix now deletes only the pragma's own line instead of the entire comment. Single-line pragmas and pragma-only docblocks still get deleted whole as before.
Codex
CodebaseMetadata::extract_owned_keyshelper: New method that extracts only the symbol keys owned by a given file's metadata, used by the incremental analysis service to safely prune entries without touching shared/prelude symbols.
Names
- Identifier end offsets and new
ResolvedNamesAPIs:ResolvedNamesnow records the end offset of each identifier in addition to its start, enabling direct "what name is at this byte offset?" lookups without rescanning source. Addsat_offset(offset)for precise range-based lookup anditer()for allocation-free iteration over(start, end, fqn, imported)tuples. The existingall()method stays around but is now#[deprecated]in favor of the new API.
🐛 Bug Fixes
Analyzer
- Fixed false
mixed-operandin loops and preserved keyed-array shapes on append: Appending to a keyed array in a loop no longer produces spuriousmixed-operanderrors, and the keyed-array shape (array{x: int, y: int}) is preserved across appends instead of being widened to a genericarray<array-key, mixed>(#1639) - Produce literal union for interpolated strings with union parts: Interpolated strings whose parts are themselves unions (e.g.
"x=$a"with$a: 'foo'|'bar') now produce a literal union of concrete strings ('x=foo'|'x=bar') instead of a generalstring, letting downstream narrowing continue to work on the result (#1651) - Keep closure's declared parameter type when inferred type is incompatible: When a closure is passed to a parameter with an incompatible
callablesignature, the analyzer now preserves the closure's own declared parameter type instead of overwriting it with the incompatible target type. Previously this produced spurious errors inside the closure body even though the closure's own signature was fine — the signature-level mismatch error at the call site is already enough to surface the problem (#1641) - Narrow union array access to iterable branch when reconciling assertions: Inside
if (!empty($vd['key']))where$vdisfalse|array{key: string}, the narrowed$vd['key']type is now correctlynon-empty-stringrather thantruthy-mixed. The reconciler'sget_value_for_keyhelper no longer short-circuits tomixedwhen it sees a non-container atomic (e.g.false,null) in a union: those atomics are skipped so iterable atomics can still contribute their element types (#1653) - Handle non-terminal
defaultcases in switch fall-through analysis: The fall-through detection now correctly handlesdefaultcases that appear mid-switch instead of last, so switches that break out of a non-terminaldefaultno longer report spurious unreachable-arm diagnostics.
Formatter
- Align member-access chain breaking across base kinds and keep commented chains broken: Chains starting with a static method call (
Yii::app()->...), a function call (Y()->app()->...), a static property (Yiui::$app->...), or a plain variable ($obj->...) now all break consistently per access when the chain exceeds print width, instead of producing four different partial-break styles depending on the starting expression. A companion rule keeps chains with trailing comments between accesses broken regardless of line width, soWriter::default()->u8(1) // versionno longer collapses onto a single line when it technically fits (#1623) - Keep interpolated string chains inline: Interpolated strings that are part of a concatenation chain are no longer broken onto separate lines by the chain-breaking heuristics, matching user expectations for template-style code (#1643)
Codex
class-stringis not contained by known string literals: Fixed a containment check where a generalclass-stringtype was incorrectly treated as a subtype of a specific literal string, producing false positives on comparisons and narrowing (#1638)
Orchestrator
- Stop excluding original source paths when CLI overrides paths: When a user runs
mago <command> some/path.php, the originalsource.pathsfrom the config are moved into the context (so they still provide analysis context) but no longer added to the exclusion list, which was causing the overridden paths to be silently filtered out (#1648, #1650) - Deduplicate context paths against active source paths: Follow-up fix for the same CLI-override scenario. When a CLI-specified path matches an original config path exactly, the same pattern used to appear in both
pathsandincludes, causing the loader's specificity tiebreaker to reclassify the file as Vendored and hide it from Host-only tools. Context paths are now filtered against the active source paths before being added to includes (#1650) - Track owned keys in incremental analysis to avoid cross-file removals: The incremental analysis service now tracks which codex keys were contributed by which file, so re-analyzing one file can only remove that file's own symbols from the shared metadata. Previously a re-analysis could accidentally evict prelude or other-file symbols, leaving downstream analyses looking at partial codebase metadata.
Database
- Only bypass watcher exclusions for paths that match a glob themselves: Watcher-triggered incremental scans no longer bypass exclusion matching for every file in a glob-selected directory — only files that match an explicit glob pattern skip the exclusion pass, keeping vendor/ and generated paths out of the hot incremental loop as intended.
Playground
- Stop Vue from clobbering CodeJar's
contenteditable=plaintext-only: The playground editor no longer loses its plaintext-only editing mode on re-renders, keeping caret position stable while typing (#1646)
🏗️ Internal
- Migrate
mago ast --namestoResolvedNames::iter(): The CLI's name-dump command now walks resolved names via the new allocation-free iterator instead of cloning the full map, matching how the rest of the codebase should transition away from the deprecatedall().
🙏 Thank You
Contributors
A huge thank you to everyone who contributed code to this release:
Issue Reporters
Thank you to everyone who reported issues that shaped this release:
- @UweOhse — #1554, #1639, #1650, #1651, #1652, #1653
- @dotdash — #1623, #1641
- @zip-fa — #1367
- @Philosoft — #1633
- @adam-openplay — #1644
- @nikophil — #1640
- @ddanielou — #1648
Full Changelog: 1.20.1...1.21.0