Plan to gradually pay down the static-analysis tech debt currently suppressed by
mago-analyze-baseline.toml. CI stays green at every step; we only ever shrink
the baseline, never grow it.
Generated 2026-06-29 against mago 1.42.0.
composer analyze:all (raw mago analyze, no baseline) reports 7,198 issues
(4,792 errors, 2,235 warnings, 171 help). CI is nonetheless green because
.github/workflows/build.yml runs analyze with the baseline:
./phpmyfaq/src/libs/bin/mago analyze --baseline=mago-analyze-baseline.toml --reporting-format=github
The baseline (mago-analyze-baseline.toml, 3,377 entries — each with a count
field, summing to the 7,198 occurrences) records every existing issue as accepted
debt. CI only fails on new issues introduced beyond the baseline.
"Reduce the errors and warnings" therefore means: fix real issues and remove the corresponding entries from the baseline, so the ledger shrinks toward empty.
composer analyze— baseline-aware, mirrors CI (green when no new issues).composer analyze:all— raw run, shows the full 7,198-issue debt.composer analyze:baseline— regenerate the baseline after fixing code: removes outdated entries (fixed issues) without hiding new ones, keeps a.bkp.
The lint side has the analogous mago-lint-baseline.toml; the same approach
applies to composer lint if/when we choose to mirror it.
For every batch of fixes:
- Fix the code in a small, reviewable PR (one file or a small group).
composer analyze:baseline— drops the now-fixed entries from the baseline.- Commit the shrunken
mago-analyze-baseline.tomlalongside the code change. - CI (
--baseline) stays green; the diff on the baseline file shows progress.
Guardrails to consider adding to CI:
--verify-baseline/--fail-on-out-of-sync-baselineso the ledger cannot silently drift out of sync with the code.
Overwhelmingly type-safety findings. Counts are occurrences from analyze:all.
| Cluster | ~Count | Root cause | Fix pattern |
|---|---|---|---|
mixed-* — argument (1,245), assignment (1,440), property-access (527), operand (387), array-access (272), method-access (253), return (195) |
~4,700 | Untyped data from request input (Filter::filterVar, json_decode objects), DB rows, and untyped properties |
Type the boundaries: typed request accessors/DTOs, typed DB-row shapes, annotate properties & returns |
Null-safety — possible-method-access-on-null (348), possibly-null-argument (253), possibly-null-operand (155), nullable-return-statement (56), possibly-null-property-access (20) |
~900 | Missing null guards; nullable returns not declared | Guard clauses, ?->, accurate ?T return types |
non-existent-method (100), -property (80), -constant (76), -function (16) |
~270 | Likely real bugs or missing type info on dynamic objects | Inspect each: fix genuine defects; annotate magic __get/__call with @property/@method |
less-specific-* — return, nested-argument, argument, nested-return |
~225 | Generics/array shapes wider than declared | Tighten @param/@return shapes & generics |
Redundant / dead / unused (help) — redundant-comparison/-logical-operation/-null-coalesce/-cast, unused-property |
~170 | Cleanups | Mostly auto-fixable |
| Directory | ~Issues |
|---|---|
Controller/Administration/Api |
1,120 |
Controller/Administration |
377 |
Controller/Frontend |
318 |
Database |
298 |
Faq |
255 |
Controller/Frontend/Api |
252 |
Setup |
229 |
Administration, User, Auth, Helper |
~200 each |
The controllers dominate because request input is mixed at its source — typing a
handful of input boundaries cascades into removing dozens of downstream mixed-*
errors each.
- Baseline-aware
composer analyze+analyze:all+analyze:baselinescripts. - This document records the fix → shrink workflow.
- TODO (optional): add
--verify-baselineto CI.
Important finding: do NOT trust
mago analyze --fixblindly here. On mago 1.42.0 the "8 safe fixes" included changes that break the code:Translation.phpgot$x = &self::$translation?->loadedLanguages;— taking a reference to a nullsafe chain is a fatal parse error ("Cannot take reference of a nullsafe chain", confirmed viaphp -l). Others removed(string)casts feeding directly into SQLescape()onmixeddata (reducing safety) and added pointless?->to inner index expressions whose outer access stayed->. All 8 were reverted. **Every auto-fix must be reviewed individually withphp -l
- the full test suite; the "safe" classification is not reliable.**
Hand-reviewed cleanups instead. Done (commit-ready, all 6586 tests green, lint clean):
| Change | File(s) | Baseline entries removed |
|---|---|---|
De-promote unused $tableName property (param still used) |
Configuration/ConfigurationRepository.php |
1 |
Remove dead injected Ldap dep + redundant constructor |
Controller/Administration/Api/LdapController.php |
1 |
Remove dead injected Comments dep + unused import |
Controller/Administration/NewsController.php |
1 |
Remove dead injected Configuration dep + import; update call site + test |
Service/McpServer/McpSdkRuntime.php, PhpMyFaqMcpServer.php, McpSdkRuntimeTest.php |
1 |
Baseline: 3377 → 3373 entries. Verified each property was genuinely unused
(no $this-> access, no trait/reflection usage, call sites/tests updated).
Deferred (need deeper review before touching):
RouteCollectionBuilder::$configuration— unused, but removal ripples intoKernel::loadRoutes()(the$configurationfetch + closure capture become dead) andRouteCollectionBuilderTest; routing is critical-path.Configuration.php$config/$logger/$pluginManager— central class; confirm no dynamic/trait/reflection access before removing.
Remaining Phase 1 candidates: the redundant-* help cluster — but treat each
with suspicion: a "redundant condition" often reflects mago's mistaken type
inference, and removing it can drop a real defensive check. Review case-by-case.
Finding: the ~270
non-existent-*are NOT real bugs. Triage showed they are all analyzer-modeling gaps — missing definition files, an unstubbed extension, and traits analyzed in isolation. The code is correct at runtime. The "likely real bugs" assumption in the original plan was wrong.
Root causes and remediation:
| Sub-category | Count | Root cause | Action |
|---|---|---|---|
non-existent-function |
16 | All sqlsrv_* — mago ships no stubs for the SQL Server extension |
Left baselined (ignored for now, by request — a sqlsrv stub was prototyped then reverted; revisit if/when the SQL Server driver gets attention) |
non-existent-constant |
76 → 1 | PMF_*/AAD_OAUTH_* defined in files outside mago's analysis path |
Done: added the 3 tracked constant files + phpmyfaq/src/stubs/azure.php to includes. 1 left (PMF_MULTI_INSTANCE_CONFIG_DIR, a runtime multisite define() behind a defined() guard — left baselined) |
non-existent-method |
100 | Traits analyzed standalone (member lives on host class), and containers typed as a base class (Auth) when methods live on the driver interface (AuthDriverInterface) |
Deferred — needs trait annotations / tighter type hints (see below) |
non-existent-property |
80 | Same trait-standalone gap (e.g. ConfigurationMethodsTrait::$config is Configuration::$config) |
Deferred — trait annotations |
Phase 2 results so far: 7194 → 7086 issues (108 false positives cleared),
baseline 3373 → 3301 entries. Zero logic changes, zero runtime risk — only
mago.toml config and one analyzer-only stub file (src/stubs/azure.php, never
autoloaded; PSR-4 maps phpMyFAQ\ to phpmyfaq/src/phpMyFAQ only, so
src/stubs/* is invisible at runtime). The sqlsrv_* findings (18) are left
baselined by request. CI green throughout.
This validated the Phase 1 decision to keep
Configuration::$config. It was flaggedunused-propertyonly becauseConfigurationMethodsTrait(which reads$this->config) is analyzed in isolation — the property is very much used.
Trait & interface typing (180 findings) — spiked; recommend leaving baselined. A spike (2026-07-01) evaluated every remediation and found none is worth the risk/effort. Details:
- mago has no trait-context docblock mechanism. Tested on
ConfigurationMethodsTrait(72 findings):@mixin Configurationand@propertyare both ignored — mago analyzes traits standalone. The only tool is the inline@mago-ignore analysis:<rule>pragma. - A blanket pragma is worse than the baseline. The baseline suppresses each
finding by count, so a genuinely new
non-existent-*(a real typo) in the trait still surfaces. A trait-level@mago-ignoresuppresses the rule for the whole trait forever, hiding future real bugs. Net regression in bug-detection. - The only true fix for traits is a structural move (relocate host property
declarations into the 1:1 trait) — touches central classes (
Configuration,CurrentUser) for zero runtime benefit. - "Missing interface methods" can't just be added to the interface.
PermissionInterface(50 findings) declares onlyhasPermission(), but the called group methods (addGroup,getGroupName,getAllGroupsOptions,grantGroupRight,deleteGroup, …) exist only onMediumPermission, notBasicPermission. Adding them to the interface would forceBasicPermissionto implement group methods it deliberately lacks. The correct fix is a newGroupPermissionInterface+ retyped call sites — a design change to the security-sensitive permission subsystem. Same shape forAuthDriverInterfaceand theAuthcontainer. - External-lib interfaces (
SessionInterface,Http\Promise\Promise) can't be fixed from here at all.
Conclusion: the baseline already suppresses these false positives precisely and safely (CI green, no real bugs). Clearing them means either risky refactors of central/security code or a net loss in bug-detection. Leave baselined and spend effort on Phase 3, where fixes are additive and genuinely improve safety. Revisit the permission/auth interface design only if it's being reworked for other reasons.
- Order:
Controller/Administration/Api→ otherController/*→Database→Faq. - Introduce typed accessors for request input and typed DB-row shapes so
mixedcollapses at the source rather than fixing 4,700 symptoms individually. - One PR per file / small group; regenerate baseline per PR for measurable progress.
- This phase accounts for the bulk (~4,700) of the ledger.
Proof-of-pattern slice (2026-07-01): CommentController::delete. Established
AbstractController::getJsonObject(Request): \stdClass — decodes the JSON body
with JSON_THROW_ON_ERROR and validates it is an object, so callers stop leaking
mixed from json_decode() (also a real robustness fix: the old code would fatal
on malformed input at $data->data->…). Result: the method's error-level findings
(mixed-property-access, mixed-argument, possible-method-access-on-null,
less-specific-nested-argument-type) were eliminated; ~10 findings cleared,
baseline 3301 → 3294, all 43 CommentController tests green.
What the slice taught about Phase 3 economics — read before scaling:
getJsonObject()fixes property-access errors, butmixedvalues still flow from JSON leaves. Getting a method to literal zero needs per-field casting, and some casts ((array) $mixed) mago itself rejects (invalid-type-cast). Chasing the lastmixed-assignmentwarnings is low-value; target the errors.- Use native
filter_var($x, FILTER_VALIDATE_INT)(returnsint|false) overFilter::filterVar()when you need a typed scalar — mago narrows it cleanly. - The single inherent
mixedboundary (thejson_decodecall) is documented with/* @mago-expect analysis:mixed-assignment - … */rather than baselined. - At ~10 findings per method by hand, the ~4,700
mixed-*cluster is genuinely multi-month unless a boundary-typing multiplier is applied first — see below.
Filter::filterVar() conditional-return spike (2026-07-03) — CONFIRMED viable,
high value. Filter::filterVar(): mixed is called ~470× with a constant filter
(161× FILTER_VALIDATE_INT, 281× FILTER_SANITIZE_SPECIAL_CHARS, 19× BOOLEAN),
so its mixed return is a primary multiplier of mixed-*. Findings:
- mago does NOT honor named-constant conditional types —
@return ($filter is FILTER_VALIDATE_INT ? …)makes mago parseFILTER_VALIDATE_INTas a class name (non-existent-class-like). - mago DOES honor literal-int conditional types. With
@return ($filter is 515 ? string|null : ($filter is 257 ? int|null : ($filter is 258 ? bool|null : mixed)))(515/257/258 = the SANITIZE_SPECIAL_CHARS / VALIDATE_INT / VALIDATE_BOOLEAN values), a single 1-line annotation moved the totals:mixed-argument1224→842,mixed-assignment1436→1064, total 7076→6591 (−485). - The trade-off is a feature, not a cost: it surfaces ~377 findings the
mixedtype was masking, and they are higher-signal than what they replace — 261possibly-null-argument(real:filterVarreturnsnullon invalid input, so passing it to anintparam unguarded is a latent bug) and 14impossible-type-comparisonthat expose dead/wrong guards (e.g.VotingController.php:79checks$vote === false, but the value isint|nulland can never befalse).
Rollout — DONE (2026-07-03).
- Added the conditional return type to
Filter::filterVar(), refined to@template TDefault+@return ($filter is 515 ? string|TDefault : ($filter is 257 ? int|TDefault : ($filter is 258 ? bool|TDefault : mixed))). TheTDefaulttemplate models the$defaultargument, so default-providing call sites (e.g.Filter::filterVar($x, …, [])) type correctly instead of over-reporting null — this alone removed ~92 false positives vs. the naive|nullform. Documented as an interim measure linking mago #1117; migrate to nativefilter_var()when that inference matures. - Fixed the real dead/incorrect guards the new type exposed (~18): all
impossible-type-comparison(=== falseonint|null→=== null) across Bookmark/Voting/Attachment/News/Pdf/Setup/User controllers, plus a redundant ternary (ChatSseController), an always-true guard (Tracking), and anarray_filterpredicate returningint|nullinstead ofbool(TagsHelper). - Regenerated the baseline for the remainder.
Net: total findings 7076 → 6494 (−582); baseline 3294 → 3128 entries (−166).
The ~270 findings the change surfaced (225 possibly-null-argument, 24
possibly-null-operand, 15 possibly-invalid-argument, misc) are baselined as
Phase 4 work — they are the precise form of the null-safety debt that was
previously hidden inside mixed-*, not new risk. None are guaranteed-null; the
null path is generally reachable only via malformed requests (route \d+
requirements guard the happy path). Fixing them (guards / non-null defaults) is
Phase 4.
Discovered pre-existing bug (out of scope, flagged for follow-up):
UserController::updateUserRights does Filter::filterVar($data->userRights, FILTER_SANITIZE_SPECIAL_CHARS, []) — filter_var() on an array returns false,
so this always yields []; the subsequent is_array() normalisation then keeps
[], meaning submitted rights may never be applied. Needs its own investigation
with tests (security-sensitive); not touched here.
- Add guard clauses / nullsafe operators and accurate nullable return types (~900).
- Tighten
less-specific-*array shapes and generics (~225).
- As each category reaches zero, optionally promote it to a hard gate so it cannot regress, and continue shrinking toward a baseline-free state.
Phases 1–2 are low-risk, high-signal, and surface real bugs quickly. Phase 3 is
where most of the volume actually lives, because it is a small number of mixed
sources, not 4,700 independent fixes — typing each boundary pays down many
downstream findings at once.
composer analyze:all now reports 6,132 issues (was 7,198), baseline 2,889
entries. Done so far: Filter::filterVar conditional return type (−485), the
full controller null-safety sweep (~50 files), and two core classes. CI is green
via the baseline; every step kept the full test suite passing.
| Cluster | ~Count | Root cause |
|---|---|---|
mixed-* (assignment 1060, argument 839, property-access 512, operand 464, array-access 272, method-access 253, return 192, coercion 59) |
~3,650 | Untyped database rows. DatabaseDriver::fetchObject(): mixed, query(): mixed, fetchRow(): mixed, fetchArray(): array|false|null — every row object/array is untyped, so all downstream access is mixed. Plus $faq->faqRecord[...], config values, and residual request input. |
ambiguous-object-property-access |
180 | 177 are "generic object" — the objects returned by fetchObject(). |
null-safety (possible-method-access-on-null 347, possibly-null-argument 244, possibly-null-operand 124, nullable-return 56) |
~770 | Missing guards; nullable returns not declared. |
non-existent-method/-property |
180 | Traits analyzed standalone + base-vs-interface typing. Established false positives — leave baselined. |
return-type accuracy (invalid-return 96, less-specific-return/-argument 123, mixed-property-type-coercion 59) |
~280 | Return/param annotations wider or narrower than reality. |
possibly-false-argument 75, possibly-invalid-argument 173, invalid-iterator 51, misc |
~300 | Assorted. |
mixed-* is ~60% of the debt and, like the filterVar cluster, comes from a
small number of untyped sources, not thousands of independent fixes. The
highest-ROI move is to type the DatabaseDriver fetch API:
fetchObject(mixed $result): ?\stdClass(instead of: mixed) — resolves the 180ambiguous-object-property-accessand a large share of the 512mixed-property-accessat once (row property reads becomestdClassaccess; leaf values staymixedbut the access is valid).fetchArray(): ?array<string, scalar|null>andfetchRow()/fetchAll()shapes — collapsesmixed-array-access/mixed-assignmenton row arrays.- For hot tables (faqdata, faquser, faqcategories, faqconfig) consider a typed
row value-object /
@returnarray shape so leaf values are typed too.
Expect this to surface more precise null-safety findings (as the filterVar
change did) — that is a feature. Do it incrementally, one driver/consumer at a
time, regenerating the baseline per PR.
- Phase 5 — DB-row typing (the big lever). Tighten
DatabaseDriverfetch return types; migrate hot consumers (Faq,User,Search,Category,Setup). Biggest single reduction; do it carefully — the DB layer is core. - Phase 6 — null-safety. Work the
possible-method-access-on-null(347) and remainingpossibly-null-*with guards /?->/ accurate?Treturns (partly surfaced by Phase 5). - Phase 7 — return-type accuracy.
invalid-return,nullable-return,less-specific-*,mixed-property-type-coercion. - Leave baselined: trait-standalone + base-vs-interface
non-existent-*(~180) and thesqlsrvextension findings — analyzer-modeling gaps, not bugs.
- The unit suite does NOT build the DI container or run the app. Phase 1's
"remove unused dependency" edits passed all 6,586 unit tests and mago, yet
broke
services.phpDI wiring — which only surfaced in the nightly e2e (empty seed) and was fixed infix(di): correct stale service arguments. Any change that alters a constructor signature or control flow (null→default, guards) MUST be validated against: the full unit suite + a DI container-instantiation smoke test + the e2e seed/browse flow (bin/e2e local). - Add a container smoke test that iterates
services.phpdefinitions and->get()s each service — it would have caught the DI regression in CI. - Keep using the baseline shrink workflow (
composer analyze:baseline); one file / small group per PR so each step is reviewable and revertible. - Watch behavior, not just types: a
null → '' / 0default can change a control branch that keyed on=== null. Prefer guards that mirror the method's existing invalid-input handling over blanket defaults on public page controllers.
mixed-* (~3,650) is genuinely a multi-week effort even with the DB-layer lever,
because each typed source cascades but also surfaces follow-on findings to
triage. Realistic target: Phase 5 first (largest, highest-leverage), then 6 and 7.
Trait/interface and extension findings stay baselined indefinitely.