github carthage-software/mago 1.20.0
Mago 1.20.0

latest release: 1.20.1
9 hours ago

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> and template-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, and template-type<O, C, N> extracts a named template parameter from a given object/class, matching the PHPStan semantics (#1217)
  • find-overly-wide-return-types check: 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 new OverlyWideReturnType issue when declared branches are never produced. Skipped for generators, abstract/unchecked methods, overriding methods, and mixed/void/never/templated returns (#1446, #1553)
  • @param docblock narrowing check: Flags when a @param docblock type silently drops a branch of the native parameter union (e.g. annotating int|string as @param int), which would otherwise collapse the parameter to never inside the body (#1487)
  • Side-effects-in-condition check: New side-effects-in-condition diagnostic and matching allow-side-effects-in-conditions setting (on by default) that warns when an if/while/for/ternary/match condition calls a function or method that isn't marked @pure, @mutation-free, or @external-mutation-free (#1604)
  • Glob patterns in ignore.in: The analyzer's ignore configuration now accepts full glob patterns (e.g. src/**/*.php, modules/*/Generated/*.php) in in = [...] alongside plain directory/file prefixes, routed through the shared ExclusionMatcher (#1619)

Linter

  • no-literal-namespace-string rule: Flags string literals that look like fully-qualified PHP class names and suggests ::class notation. Disabled by default, warning level (#1386)
  • no-null-property-init rule: Flags untyped public $foo = null; property declarations, since untyped properties already default to null. Ships with an auto-fix to drop the redundant initializer. Disabled by default, help level (#1315)
  • no-side-effects-with-declarations rule: Flags files that mix top-level declarations (class, interface, trait, enum, function) with side-effecting statements. Configurable to allow class_alias, class_exists top-level calls, and conditional declarations (#1560)
  • no-service-state-mutation rule: Flags in-place mutation of injected service state so long-lived workers (RoadRunner, FrankenPHP, Swoole) don't accidentally leak state between requests (#1582)
  • string-style rule: Enforces a configurable preferred string style (interpolation vs concatenation), powered by a new ancestors tracking API on the lint context (get_parent, get_parent_kind, get_nth_parent, get_nth_parent_kind) (#1614)
  • disallowed-type-instantiation rule: New security rule that flags direct new Foo() on classes listed in the rule configuration, intended to enforce factory/provider patterns. The types array 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 excludes configuration now accepts full glob syntax via a new mago_database::matcher::ExclusionMatcher, classifying each pattern as either a glob or a plain directory prefix (#1453, #1619)

Formatter

  • align-parameters and align-named-arguments options: Two new opt-in alignment settings (both default false) 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-line option: When enabled alongside preserve-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-parentheses and omit-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 --staged works with unstaged changes: mago format --staged no longer refuses to run on files with partial staging. It now reads each staged blob via git cat-file, formats it in memory, and writes the formatted version back to the index via git hash-object -w + git update-index --cacheinfo, leaving unstaged worktree changes untouched (#1629)

CLI

  • Project version pinning: New top-level version key in mago.toml (exact, major.minor, or major) is validated on every run — patch or minor drift logs a warning, major drift fails hard. A matching mago self-update --to-project-version flag reads the pin and installs the exact tag (or the latest release matching the constraint) (#1618)

Orchestrator

  • Trace-gated hang watcher: When MAGO_LOG=trace is set, a dedicated watcher thread tracks in-flight analyzer workers and emits tracing::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/orchestrator telemetry 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 a measure! 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/impossible diagnostics on already-narrowed types (#1627)
  • Preserved narrowing through redundant ?->: For a null-safe access $obj?->prop where $obj is already known non-null, the analyzer now looks up the equivalent non-null-safe property id in block_context.locals and 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_array narrowing: When in_array(..., strict: true) returns true, the synthesized needle assertion is now Assertion::IsIdentical rather than Assertion::IsEqual, letting the analyzer narrow the needle to the haystack's element type (e.g. string|int|null to string against a string[] haystack). The stub also gained a @template T on 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_narrowings now threads through the receiver variable and only wipes $this->... narrowings when the method call is actually on $this. Additionally, narrowings for readonly $this properties are preserved across self-calls and @suspends-fiber calls (#1625)
  • Fixed stack overflow on self-referencing class constants: The codex type expander now detects cycles like const int B = self::B via a thread-local EXPANDING_CONSTANTS set and RAII guard, pushing TAtomic::Never on re-entry. The class-constant analyzer surfaces this as a new UnresolvableClassConstant issue (#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 get hook and contravariance on properties with only a set hook, 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 (where f takes $x by reference) now correctly drops the stale narrowing on $x from the left side by propagating parent_conflicting_clause_variables through 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 => expr shorthand treated as virtual: A property hook like public string $test { set => strtolower($value); } was wrongly classified as virtual (and therefore write-only), because the shorthand walker only looked for explicit $this->propertyName references and missed the implicit $this->prop = expr desugaring. The walker now also tracks whether any assignment appears in the hook body: a shorthand set with 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_externals is false (the default for lint and format), configured includes patterns are now also compiled into excludes and merged with the user-configured excludes, so directories like vendor/ nested under a source path are correctly skipped (#1630, #913)

Database

  • Exclude globs match workspace-relative paths: dir_prune_globs and file-level exclude globs now match against the path relative to the canonical workspace rather than the absolute path, so workspace-anchored patterns such as src/*/Test/** and vendor/**/tests/** actually exclude matching files. Includes a new workspace_relative_str helper 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=0 honored by the issue reporter: The color detection used by the issue reporter now treats any non-empty NO_COLOR value (including the literal "0") as disabling colors, and treats FORCE_COLOR=0 as 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.workspace at 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: The reset() stub's @param-out now preserves non-empty-list/non-empty-array shapes and its @return narrows to V (dropping false) when the array is statically known non-empty, instead of always returning V|false (#1611)
  • array_replace and array_replace_recursive template parameters: Added @template K of array-key and @template V to the stubs so they return array<K, V> matching the inputs instead of a lossy array<array-key, mixed> (#1605)
  • DateTime::getLastErrors() array shape: Replaced the loose array<string, int|array>|false return type with a precise array{warning_count, warnings, error_count, errors}|false shape on both DateTime and DateTimeImmutable (#1607)
  • DateTimeZone stub improvements: Filled in the real bitmask values for the timezone constants (AFRICA = 1 through PER_COUNTRY = 4096), tightened return types for getTransitions, getLocation, listAbbreviations, and listIdentifiers with non-empty-string/list<...>/concrete array{...} 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: Threaded Rc<TUnion> through assignment, binary logical, unary, variable access, invocation, loop handling, and static-statement analysis, alongside new add_optional_union_type_rc / combine_union_types_rc helpers in the codex ttype module that reuse Rc pointers when inputs are pointer-equal. Cuts redundant TUnion clones by roughly a third on Psl.
  • Loop fixed-point depth cap + bounded keyed-array combining: New loop-assignment-depth-threshold setting (default 1) caps how deep get_assignment_map_depth will recurse through loop-carried assignment chains, short-circuiting fixed-point iteration that used to re-analyse loop bodies many times. The codex combiner's DEFAULT_ARRAY_COMBINATION_THRESHOLD was also lowered from 128 to 32 so very wide keyed-array unions collapse to a general shape sooner.
  • Incremental match-arm reconciliation: analyze_expression_arm now returns only the clauses newly negated by that arm, and the match driver feeds only those into find_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 in PartialEq: Replaced the derived PartialEq on TAtomic and 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. TUnion atoms 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_parameters also short-circuits entries whose key type cannot match, preventing quadratic blowup when unioning many mixed shapes (#1610)

Algebra

  • saturate_clauses fast paths for single-literal clauses: Pre-builds a (var, possibility-hash) HashSet index 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 GlobSet of directory-level prune patterns from the user's file-level globs (by stripping trailing /*, /**, /**/*) and applies it (plus the path-excludes) inside WalkDir::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_children method drives the same match logic as Node::children but delivers each child to a caller-supplied closure instead of allocating a Vec<Node>. The linter walk, filter_map_internal, prefer_static_closure's contains_this_reference, and the cyclomatic_complexity/halstead/kan_defect metric rules all migrated to it; the linter walk is also rewritten to use an explicit Op stack instead of recursion, avoiding stack overflows on deeply nested ASTs (#1606)

Config

  • Dropped the defaults seed from Config::builder: Removed the eager Configuration::from_workspace seed that was being serialized and re-fed into Config::builder(), letting the final try_deserialize::<Configuration> fall back to serde defaults. SourceConfiguration gained #[serde(default)] plus a Default impl 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 init template now includes the missing workspace = "." line so the generated mago.toml matches the schema expected by the command's tests.

🙏 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.19.0...1.20.0

Don't miss a new mago release

NewReleases is sending notifications on new releases.