Mago 1.20.0
Mago 1.20.0 is a big release focused on analysis accuracy, speed, and diagnostics. Highlights include new new<X> and template-type<Object, ClassName, TemplateName> utility types, detection of @param docblocks that silently drop native union branches, a new find-overly-wide-return-types analyzer check, six new linter rules, glob-pattern support across both analyzer ignore.in and per-rule linter excludes, a side-effects-in-condition check, a trace-gated hang watcher for diagnosing pathological inputs, and sweeping performance work — loop fixed-point depth capping, walkdir directory pruning, ptr_eq fast paths on every TType impl, Rc<TUnion> plumbing through the analyzer hot path, saturate_clauses fast paths for single-literal clauses, sealed-keyed-array bounding in the combiner, and a zero-alloc AST visitor — together dropping end-to-end psl analysis by more than 3x. On the fix side, this release ships a pile of reconciler correctness work covering chained AND-clauses, narrowing through redundant ?->, strict in_array narrowing, property-hook variance, self-referencing class constants, and more.
✨ Features
Analyzer
new<X>andtemplate-type<Object, ClassName, TemplateName>utility types: Two new type-syntax utilities.new<X>resolves a class-string expression to the object type that would result from instantiating it, andtemplate-type<O, C, N>extracts a named template parameter from a given object/class, matching the PHPStan semantics (#1217)find-overly-wide-return-typescheck: New opt-in analyzer pass that compares a function's declared return type against the union of types actually produced by its return statements, reporting a newOverlyWideReturnTypeissue when declared branches are never produced. Skipped for generators, abstract/unchecked methods, overriding methods, and mixed/void/never/templated returns (#1446, #1553)@paramdocblock narrowing check: Flags when a@paramdocblock type silently drops a branch of the native parameter union (e.g. annotatingint|stringas@param int), which would otherwise collapse the parameter toneverinside the body (#1487)- Side-effects-in-condition check: New
side-effects-in-conditiondiagnostic and matchingallow-side-effects-in-conditionssetting (on by default) that warns when anif/while/for/ternary/matchcondition calls a function or method that isn't marked@pure,@mutation-free, or@external-mutation-free(#1604) - Glob patterns in
ignore.in: The analyzer'signoreconfiguration now accepts full glob patterns (e.g.src/**/*.php,modules/*/Generated/*.php) inin = [...]alongside plain directory/file prefixes, routed through the sharedExclusionMatcher(#1619)
Linter
no-literal-namespace-stringrule: Flags string literals that look like fully-qualified PHP class names and suggests::classnotation. Disabled by default, warning level (#1386)no-null-property-initrule: Flags untypedpublic $foo = null;property declarations, since untyped properties already default tonull. Ships with an auto-fix to drop the redundant initializer. Disabled by default, help level (#1315)no-side-effects-with-declarationsrule: Flags files that mix top-level declarations (class, interface, trait, enum, function) with side-effecting statements. Configurable to allowclass_alias,class_existstop-level calls, and conditional declarations (#1560)no-service-state-mutationrule: Flags in-place mutation of injected service state so long-lived workers (RoadRunner, FrankenPHP, Swoole) don't accidentally leak state between requests (#1582)string-stylerule: Enforces a configurable preferred string style (interpolation vs concatenation), powered by a newancestorstracking API on the lint context (get_parent,get_parent_kind,get_nth_parent,get_nth_parent_kind) (#1614)disallowed-type-instantiationrule: New security rule that flags directnew Foo()on classes listed in the rule configuration, intended to enforce factory/provider patterns. Thetypesarray accepts either plain strings or{name, help}objects so users can attach custom help text per disallowed class (#1621)- Glob patterns in per-rule exclude lists: The linter's per-rule
excludesconfiguration now accepts full glob syntax via a newmago_database::matcher::ExclusionMatcher, classifying each pattern as either a glob or a plain directory prefix (#1453, #1619)
Formatter
align-parametersandalign-named-argumentsoptions: Two new opt-in alignment settings (both defaultfalse) that, on multiline parameter and argument lists, align columns — named arguments by their colon and parameters by the variable column (especially useful for promoted constructor properties) (#1307)preserve-breaking-member-access-chain-first-method-on-same-lineoption: When enabled alongsidepreserve-breaking-member-access-chain, keeps the first->method()call on the same line as its receiver instead of moving the receiver onto its own line (#1319)- Opt-in arithmetic and bitwise paren omission: Two new settings,
omit-redundant-arithmetic-binary-expression-parenthesesandomit-redundant-bitwise-binary-expression-parentheses, that drop parentheses around arithmetic/bitwise children under comparison and null-coalesce operators when PHP precedence already preserves meaning (e.g.$i === $retries - 1) (#1620) format --stagedworks with unstaged changes:mago format --stagedno longer refuses to run on files with partial staging. It now reads each staged blob viagit cat-file, formats it in memory, and writes the formatted version back to the index viagit hash-object -w+git update-index --cacheinfo, leaving unstaged worktree changes untouched (#1629)
CLI
- Project version pinning: New top-level
versionkey inmago.toml(exact, major.minor, or major) is validated on every run — patch or minor drift logs a warning, major drift fails hard. A matchingmago self-update --to-project-versionflag reads the pin and installs the exact tag (or the latest release matching the constraint) (#1618)
Orchestrator
- Trace-gated hang watcher: When
MAGO_LOG=traceis set, a dedicated watcher thread tracks in-flight analyzer workers and emitstracing::trace!messages when a file has been analyzing for more than ~5 seconds, with guidance on filing a bug report and anonymizing private code. Zero overhead when tracing is off (#1227)
Telemetry
- Detailed trace-level pipeline diagnostics: New
analyzer/orchestratortelemetry modules emit per-phase durations (clap parse, logger/config/rayon init, prelude decode, orchestrator init, database load, compile, codex merge/populate, parallel map/reduce, reduce, analyzer setup/statements/finalize) and report the 20 slowest files seen during the parallel analyze phase, all via ameasure!macro that compiles to a branch-predicted zero-cost path when trace is off.
🐛 Bug Fixes
Analyzer
- Fixed spurious impossibility on chained AND-clauses: When evaluating a conjunction of assertions against a running narrowed type, the reconciler now re-probes the original pre-narrowing type before reporting an assertion as impossible, so later clauses in a chain no longer fire false
redundant/impossiblediagnostics on already-narrowed types (#1627) - Preserved narrowing through redundant
?->: For a null-safe access$obj?->propwhere$objis already known non-null, the analyzer now looks up the equivalent non-null-safe property id inblock_context.localsand reuses its narrowed type, so the result of the?->retains the earlier narrowing instead of falling back to the widest declared type (#1627) - Strict
in_arraynarrowing: Whenin_array(..., strict: true)returnstrue, the synthesized needle assertion is nowAssertion::IsIdenticalrather thanAssertion::IsEqual, letting the analyzer narrow the needle to the haystack's element type (e.g.string|int|nulltostringagainst astring[]haystack). The stub also gained a@template Ton the needle so its type is independent of the haystack value type (#1628) - Narrowing preserved across
->sub->method()calls and readonly props:clear_object_property_narrowingsnow threads through the receiver variable and only wipes$this->...narrowings when the method call is actually on$this. Additionally, narrowings for readonly$thisproperties are preserved across self-calls and@suspends-fibercalls (#1625) - Fixed stack overflow on self-referencing class constants: The codex type expander now detects cycles like
const int B = self::Bvia a thread-localEXPANDING_CONSTANTSset and RAII guard, pushingTAtomic::Neveron re-entry. The class-constant analyzer surfaces this as a newUnresolvableClassConstantissue (#1624) - Variance allowed for hook-only property types: Replaced the strict invariance check for overridden property types with a rule that permits covariance on virtual properties with only a
gethook and contravariance on properties with only asethook, so read-only/write-only hooked properties no longer emit false type-incompatibility errors in subclasses (#1615) - Invalidated narrowing after by-reference mutations in
&&:$x === 0 && f($x) && $x !== 0(whereftakes$xby reference) now correctly drops the stale narrowing on$xfrom the left side by propagatingparent_conflicting_clause_variablesthrough both branches (#1608) - Skipped docblock-parameter-narrowing on overrides: The new
@param-narrows-native-type check now skips methods that override a parent, since the docblock narrowing is constrained by the parent signature and would otherwise produce false positives.
Codex
- Fixed
set => exprshorthand treated as virtual: A property hook likepublic string $test { set => strtolower($value); }was wrongly classified as virtual (and therefore write-only), because the shorthand walker only looked for explicit$this->propertyNamereferences and missed the implicit$this->prop = exprdesugaring. The walker now also tracks whether any assignment appears in the hook body: a shorthandsetwith no assignment in its body is correctly recognized as referencing the backing field (#1632)
Orchestrator
- Vendor paths under source dirs no longer linted/formatted: When
include_externalsis false (the default forlintandformat), configuredincludespatterns are now also compiled into excludes and merged with the user-configured excludes, so directories likevendor/nested under a source path are correctly skipped (#1630, #913)
Database
- Exclude globs match workspace-relative paths:
dir_prune_globsand file-level exclude globs now match against the path relative to the canonical workspace rather than the absolute path, so workspace-anchored patterns such assrc/*/Test/**andvendor/**/tests/**actually exclude matching files. Includes a newworkspace_relative_strhelper with Windows backslash normalization (#1143) - Legacy absolute-prefix exclude patterns still work: Both file and directory exclude matching now try the absolute path in addition to the relative path, so existing patterns like
*/packages/**/vendor/*that anchor on the workspace's absolute prefix continue to function.
Reporting
NO_COLOR=0honored by the issue reporter: The color detection used by the issue reporter now treats any non-emptyNO_COLORvalue (including the literal"0") as disabling colors, and treatsFORCE_COLOR=0as explicitly disabling colors, aligning with the logger and the no-color.org spec (#1599)
Config
- Relative baseline paths resolved against workspace: Relative baseline paths configured for the analyzer, linter, and guard are now joined against
source.workspaceat normalization time, so running mago from a subdirectory no longer resolves the baseline file relative to the current working directory (#1289)
Prelude / Stubs
reset()return type preserves non-empty shapes: Thereset()stub's@param-outnow preservesnon-empty-list/non-empty-arrayshapes and its@returnnarrows toV(droppingfalse) when the array is statically known non-empty, instead of always returningV|false(#1611)array_replaceandarray_replace_recursivetemplate parameters: Added@template K of array-keyand@template Vto the stubs so they returnarray<K, V>matching the inputs instead of a lossyarray<array-key, mixed>(#1605)DateTime::getLastErrors()array shape: Replaced the loosearray<string, int|array>|falsereturn type with a precisearray{warning_count, warnings, error_count, errors}|falseshape on bothDateTimeandDateTimeImmutable(#1607)DateTimeZonestub improvements: Filled in the real bitmask values for the timezone constants (AFRICA = 1throughPER_COUNTRY = 4096), tightened return types forgetTransitions,getLocation,listAbbreviations, andlistIdentifierswithnon-empty-string/list<...>/concretearray{...}shapes, and annotated the constructor with@throws DateInvalidTimeZoneException(#1612)
⚡ Performance
End-to-end analysis of the Psl monorepo dropped from ~700 ms to ~160 ms in this release thanks to the combined effect of the following changes.
Analyzer
Rc<TUnion>in the hot path: ThreadedRc<TUnion>through assignment, binary logical, unary, variable access, invocation, loop handling, and static-statement analysis, alongside newadd_optional_union_type_rc/combine_union_types_rchelpers in the codex ttype module that reuseRcpointers when inputs are pointer-equal. Cuts redundantTUnionclones by roughly a third on Psl.- Loop fixed-point depth cap + bounded keyed-array combining: New
loop-assignment-depth-thresholdsetting (default1) caps how deepget_assignment_map_depthwill recurse through loop-carried assignment chains, short-circuiting fixed-point iteration that used to re-analyse loop bodies many times. The codex combiner'sDEFAULT_ARRAY_COMBINATION_THRESHOLDwas also lowered from 128 to 32 so very wide keyed-array unions collapse to a general shape sooner. - Incremental match-arm reconciliation:
analyze_expression_armnow returns only the clauses newly negated by that arm, and the match driver feeds only those intofind_satisfying_assignments/reconcile_keyed_types, rather than re-saturating and re-reconciling the entire running else-context on every arm.
Codex
ptr_eq/cheap-field fast paths inPartialEq: Replaced the derivedPartialEqonTAtomicand on the array / keyed / list variants with a hand-written impl that short-circuits on pointer equality and dispatches only to the matching variant's cheap-field compare.TUnionatoms are also sorted into canonical order in the constructor so unions built from the same set of atoms take the ordered slice-equality path instead of the O(N²) subset fallback.- Bounded sealed keyed arrays in the combiner: Once the combiner accumulates more sealed keyed arrays than
array-combination-threshold, the excess is flushed into a single merged keyed-array entry instead of tracking each shape separately.adjust_keyed_array_parametersalso short-circuits entries whose key type cannot match, preventing quadratic blowup when unioning many mixed shapes (#1610)
Algebra
saturate_clausesfast paths for single-literal clauses: Pre-builds a(var, possibility-hash)HashSetindex of every literal in the input so unit propagation can skip the inner O(N) scan whenever no clause contains the negation of a given literal, and adds an all-size-one fast path that skips the strict-subset redundancy walk and the consensus-rule pass entirely when every clause has a single variable. Drops exhaustive match analysis from roughly cubic to linear in arm count.
Database
- Walkdir directory pruning: The loader now derives a secondary
GlobSetof directory-level prune patterns from the user's file-level globs (by stripping trailing/*,/**,/**/*) and applies it (plus the path-excludes) insideWalkDir::filter_entry, so excluded subtrees (vendor/,node_modules/, …) are never descended into. On the psl monorepo the walker now yields 2,975 entries instead of 144,222.
Syntax
- Zero-alloc AST visitor: New
Node::visit_childrenmethod drives the same match logic asNode::childrenbut delivers each child to a caller-supplied closure instead of allocating aVec<Node>. The linter walk,filter_map_internal,prefer_static_closure'scontains_this_reference, and thecyclomatic_complexity/halstead/kan_defectmetric rules all migrated to it; the linter walk is also rewritten to use an explicitOpstack instead of recursion, avoiding stack overflows on deeply nested ASTs (#1606)
Config
- Dropped the defaults seed from
Config::builder: Removed the eagerConfiguration::from_workspaceseed that was being serialized and re-fed intoConfig::builder(), letting the finaltry_deserialize::<Configuration>fall back to serde defaults.SourceConfigurationgained#[serde(default)]plus aDefaultimpl so the workspace still resolves correctly. Saves roughly 20 ms on startup.
📖 Documentation
- Diagnosing slow runs: New analyzer configuration-reference section that explains the "if Mago takes more than 30 seconds to analyze your project, something is wrong" rule of thumb, documents
MAGO_LOG=trace, and covers what the hang watcher, slowest-files report, and per-phase durations give you. - Removed consulting section: Stripped the consulting/services marketing block from the homepage and the corresponding footer link.
🏗️ Internal
- Init template fix: The
mago inittemplate now includes the missingworkspace = "."line so the generatedmago.tomlmatches the schema expected by the command's tests.
🙏 Thank You
Contributors
A huge thank you to everyone who contributed code to this release:
- @psihius — #1307, #1319, #1620
- @dotdash — #1606, #1629
- @gsteel — #1607, #1612
- @WalterWoshid — #1289
- @ostark — #1315
- @peterjaap — #1386
- @Swahjak — #1582
- @Bleksak — #1611
- @sgoo — #1621
Issue Reporters
Thank you to everyone who reported issues that shaped this release:
- @UweOhse — #1487, #1553, #1604, #1608
- @bendavies — #1446, #1625
- @WalterWoshid — #1627, #1628
- @wryk — #1217
- @leonardfischer — #1227
- @innocenzi — #1453
- @JohnnyWalkerDigital — #1560
- @llaville — #1599
- @zip-fa — #1605
- @mytskine — #1610
- @nikophil — #1614
- @KorvinSzanto — #1615
- @karoun — #1618
- @Absolute93 — #1624
- @lugoues — #1630
- @ddanielou — #1632
- @ulrichsg — #913
- @ADmad — #1143
Full Changelog: 1.19.0...1.20.0