github carthage-software/mago 1.21.0

9 hours ago

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 like function f(SomeClass $x = []), public string $s = 1, and public const int C = 'x', including narrower @var/@param docblock types such as non-empty-string $x = '' or positive-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]) infers array{1: 'foo'}, array_flip(list<string>) infers array<string, int>, and array_flip(array<K, V>) infers array<V, K> when V is an array-key subtype. Narrowing under array_key_exists now works on flipped arrays (#1633)
  • Accept wider index types for narrow-keyed arrays: Indexing array<1, T> with an int no longer reports mismatched-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 (never key type) still reject any index (#1633)

Linter

  • prefer-explode-over-preg-split rule: Flags preg_split() calls whose pattern has no regex meta-characters and no modifiers, suggesting explode() instead. The rule fires only when the pattern is a literal with symmetric delimiters (/ # ~ ! @ % , ; |), the inner content has no regex special chars, and the flags argument (if present) is literal 0. Ships with an auto-fix that rewrites the pattern as a plain string, renames the call to explode, and drops a trailing flags = 0 argument (#1554)
  • Auto-fix for string-style rule (both directions): The string-style rule now offers auto-fixes for both interpolation (converting "Hello, " . $name to "Hello, {$name}") and concatenation (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-parentheses option: New opt-in formatter setting (default false) 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) || $c stays 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-expect from 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_keys helper: 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 ResolvedNames APIs: ResolvedNames now 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. Adds at_offset(offset) for precise range-based lookup and iter() for allocation-free iteration over (start, end, fqn, imported) tuples. The existing all() method stays around but is now #[deprecated] in favor of the new API.

🐛 Bug Fixes

Analyzer

  • Fixed false mixed-operand in loops and preserved keyed-array shapes on append: Appending to a keyed array in a loop no longer produces spurious mixed-operand errors, and the keyed-array shape (array{x: int, y: int}) is preserved across appends instead of being widened to a generic array<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 general string, 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 callable signature, 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 $vd is false|array{key: string}, the narrowed $vd['key'] type is now correctly non-empty-string rather than truthy-mixed. The reconciler's get_value_for_key helper no longer short-circuits to mixed when 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 default cases in switch fall-through analysis: The fall-through detection now correctly handles default cases that appear mid-switch instead of last, so switches that break out of a non-terminal default no 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, so Writer::default()->u8(1) // version no 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-string is not contained by known string literals: Fixed a containment check where a general class-string type 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 original source.paths from 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 paths and includes, 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 --names to ResolvedNames::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 deprecated all().

🙏 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:

Full Changelog: 1.20.1...1.21.0

Don't miss a new mago release

NewReleases is sending notifications on new releases.