|
| 1 | +# Static Analysis Debt Reduction Plan (Mago `analyze`) |
| 2 | + |
| 3 | +Plan to gradually pay down the static-analysis tech debt currently suppressed by |
| 4 | +`mago-analyze-baseline.toml`. CI stays green at every step; we only ever shrink |
| 5 | +the baseline, never grow it. |
| 6 | + |
| 7 | +_Generated 2026-06-29 against mago 1.42.0._ |
| 8 | + |
| 9 | +## Background |
| 10 | + |
| 11 | +`composer analyze:all` (raw `mago analyze`, no baseline) reports **7,198 issues** |
| 12 | +(4,792 errors, 2,235 warnings, 171 help). CI is nonetheless green because |
| 13 | +`.github/workflows/build.yml` runs analyze **with** the baseline: |
| 14 | + |
| 15 | +``` |
| 16 | +./phpmyfaq/src/libs/bin/mago analyze --baseline=mago-analyze-baseline.toml --reporting-format=github |
| 17 | +``` |
| 18 | + |
| 19 | +The baseline (`mago-analyze-baseline.toml`, 3,377 entries — each with a `count` |
| 20 | +field, summing to the 7,198 occurrences) records every existing issue as accepted |
| 21 | +debt. CI only fails on **new** issues introduced beyond the baseline. |
| 22 | + |
| 23 | +"Reduce the errors and warnings" therefore means: **fix real issues and remove the |
| 24 | +corresponding entries from the baseline**, so the ledger shrinks toward empty. |
| 25 | + |
| 26 | +### Composer scripts (Phase 0 — done) |
| 27 | + |
| 28 | +- `composer analyze` — baseline-aware, mirrors CI (green when no new issues). |
| 29 | +- `composer analyze:all` — raw run, shows the full 7,198-issue debt. |
| 30 | +- `composer analyze:baseline` — regenerate the baseline after fixing code: |
| 31 | + removes outdated entries (fixed issues) without hiding new ones, keeps a `.bkp`. |
| 32 | + |
| 33 | +The lint side has the analogous `mago-lint-baseline.toml`; the same approach |
| 34 | +applies to `composer lint` if/when we choose to mirror it. |
| 35 | + |
| 36 | +## The fix → shrink workflow |
| 37 | + |
| 38 | +For every batch of fixes: |
| 39 | + |
| 40 | +1. Fix the code in a small, reviewable PR (one file or a small group). |
| 41 | +2. `composer analyze:baseline` — drops the now-fixed entries from the baseline. |
| 42 | +3. Commit the shrunken `mago-analyze-baseline.toml` alongside the code change. |
| 43 | +4. CI (`--baseline`) stays green; the diff on the baseline file shows progress. |
| 44 | + |
| 45 | +Guardrails to consider adding to CI: |
| 46 | + |
| 47 | +- `--verify-baseline` / `--fail-on-out-of-sync-baseline` so the ledger cannot |
| 48 | + silently drift out of sync with the code. |
| 49 | + |
| 50 | +## What the 7,198 issues are (by root cause) |
| 51 | + |
| 52 | +Overwhelmingly **type-safety** findings. Counts are occurrences from `analyze:all`. |
| 53 | + |
| 54 | +| Cluster | ~Count | Root cause | Fix pattern | |
| 55 | +|---|---:|---|---| |
| 56 | +| `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 | |
| 57 | +| 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 | |
| 58 | +| `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` | |
| 59 | +| `less-specific-*` — return, nested-argument, argument, nested-return | **~225** | Generics/array shapes wider than declared | Tighten `@param`/`@return` shapes & generics | |
| 60 | +| Redundant / dead / unused (help) — `redundant-comparison/-logical-operation/-null-coalesce/-cast`, `unused-property` | **~170** | Cleanups | Mostly auto-fixable | |
| 61 | + |
| 62 | +### Hot spots by directory |
| 63 | + |
| 64 | +| Directory | ~Issues | |
| 65 | +|---|---:| |
| 66 | +| `Controller/Administration/Api` | 1,120 | |
| 67 | +| `Controller/Administration` | 377 | |
| 68 | +| `Controller/Frontend` | 318 | |
| 69 | +| `Database` | 298 | |
| 70 | +| `Faq` | 255 | |
| 71 | +| `Controller/Frontend/Api` | 252 | |
| 72 | +| `Setup` | 229 | |
| 73 | +| `Administration`, `User`, `Auth`, `Helper` | ~200 each | |
| 74 | + |
| 75 | +The controllers dominate because request input is `mixed` at its source — typing a |
| 76 | +handful of input boundaries cascades into removing dozens of downstream `mixed-*` |
| 77 | +errors each. |
| 78 | + |
| 79 | +## Phased plan |
| 80 | + |
| 81 | +### Phase 0 — Make debt visible & safe to chip at — DONE |
| 82 | +- Baseline-aware `composer analyze` + `analyze:all` + `analyze:baseline` scripts. |
| 83 | +- This document records the fix → shrink workflow. |
| 84 | +- TODO (optional): add `--verify-baseline` to CI. |
| 85 | + |
| 86 | +### Phase 1 — Free wins (in progress) |
| 87 | + |
| 88 | +> **Important finding: do NOT trust `mago analyze --fix` blindly here.** |
| 89 | +> On mago 1.42.0 the "8 safe fixes" included changes that **break the code**: |
| 90 | +> `Translation.php` got `$x = &self::$translation?->loadedLanguages;` — taking a |
| 91 | +> reference to a nullsafe chain is a **fatal parse error** ("Cannot take reference |
| 92 | +> of a nullsafe chain", confirmed via `php -l`). Others removed `(string)` casts |
| 93 | +> feeding directly into SQL `escape()` on `mixed` data (reducing safety) and added |
| 94 | +> pointless `?->` to inner index expressions whose outer access stayed `->`. |
| 95 | +> All 8 were reverted. **Every auto-fix must be reviewed individually with `php -l` |
| 96 | +> + the full test suite; the "safe" classification is not reliable.** |
| 97 | +
|
| 98 | +Hand-reviewed cleanups instead. **Done (commit-ready, all 6586 tests green, lint clean):** |
| 99 | + |
| 100 | +| Change | File(s) | Baseline entries removed | |
| 101 | +|---|---|---| |
| 102 | +| De-promote unused `$tableName` property (param still used) | `Configuration/ConfigurationRepository.php` | 1 | |
| 103 | +| Remove dead injected `Ldap` dep + redundant constructor | `Controller/Administration/Api/LdapController.php` | 1 | |
| 104 | +| Remove dead injected `Comments` dep + unused import | `Controller/Administration/NewsController.php` | 1 | |
| 105 | +| Remove dead injected `Configuration` dep + import; update call site + test | `Service/McpServer/McpSdkRuntime.php`, `PhpMyFaqMcpServer.php`, `McpSdkRuntimeTest.php` | 1 | |
| 106 | + |
| 107 | +Baseline: 3377 → 3373 entries. Verified each property was genuinely unused |
| 108 | +(no `$this->` access, no trait/reflection usage, call sites/tests updated). |
| 109 | + |
| 110 | +**Deferred (need deeper review before touching):** |
| 111 | +- `RouteCollectionBuilder::$configuration` — unused, but removal ripples into |
| 112 | + `Kernel::loadRoutes()` (the `$configuration` fetch + closure capture become |
| 113 | + dead) and `RouteCollectionBuilderTest`; routing is critical-path. |
| 114 | +- `Configuration.php` `$config` / `$logger` / `$pluginManager` — central class; |
| 115 | + confirm no dynamic/trait/reflection access before removing. |
| 116 | + |
| 117 | +**Remaining Phase 1 candidates:** the `redundant-*` help cluster — but treat each |
| 118 | +with suspicion: a "redundant condition" often reflects mago's mistaken type |
| 119 | +inference, and removing it can drop a real defensive check. Review case-by-case. |
| 120 | + |
| 121 | +### Phase 2 — `non-existent-*` triage (in progress) |
| 122 | + |
| 123 | +> **Finding: the ~270 `non-existent-*` are NOT real bugs.** Triage showed they are |
| 124 | +> all **analyzer-modeling gaps** — missing definition files, an unstubbed |
| 125 | +> extension, and traits analyzed in isolation. The code is correct at runtime. |
| 126 | +> The "likely real bugs" assumption in the original plan was wrong. |
| 127 | +
|
| 128 | +Root causes and remediation: |
| 129 | + |
| 130 | +| Sub-category | Count | Root cause | Action | |
| 131 | +|---|---:|---|---| |
| 132 | +| `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) | |
| 133 | +| `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) | |
| 134 | +| `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) | |
| 135 | +| `non-existent-property` | 80 | Same trait-standalone gap (e.g. `ConfigurationMethodsTrait::$config` is `Configuration::$config`) | **Deferred** — trait annotations | |
| 136 | + |
| 137 | +**Phase 2 results so far:** 7194 → 7086 issues (**108 false positives cleared**), |
| 138 | +baseline 3373 → 3301 entries. **Zero logic changes, zero runtime risk** — only |
| 139 | +`mago.toml` config and one analyzer-only stub file (`src/stubs/azure.php`, never |
| 140 | +autoloaded; PSR-4 maps `phpMyFAQ\` to `phpmyfaq/src/phpMyFAQ` only, so |
| 141 | +`src/stubs/*` is invisible at runtime). The `sqlsrv_*` findings (18) are left |
| 142 | +baselined by request. CI green throughout. |
| 143 | + |
| 144 | +> **This validated the Phase 1 decision to keep `Configuration::$config`.** It was |
| 145 | +> flagged `unused-property` only because `ConfigurationMethodsTrait` (which reads |
| 146 | +> `$this->config`) is analyzed in isolation — the property is very much used. |
| 147 | +
|
| 148 | +**Deferred: trait & interface typing (180 findings).** This is the real remaining |
| 149 | +work and needs care, not a stub: |
| 150 | +- **Traits** (`ConfigurationMethodsTrait`, `CurrentUserSessionLookupTrait`, |
| 151 | + `CurrentUserAccountStateTrait`, …): tell the analyzer what the host provides — |
| 152 | + e.g. a `@phpstan-require-extends`/`@phpstan-require-implements` bound or |
| 153 | + `@property`/`@method` docblocks on the trait. Verify mago honors the chosen |
| 154 | + mechanism on a single trait before rolling out. |
| 155 | +- **Base-vs-interface typing**: e.g. `CurrentUser::$authContainer` is typed |
| 156 | + `Auth` but the code calls `isValidLogin()`/`checkCredentials()` which live on |
| 157 | + `AuthDriverInterface`. Tighten the property/return type to the interface (or a |
| 158 | + union). A genuine type-precision improvement, but touches auth-critical code — |
| 159 | + do it deliberately with the full suite as the gate. |
| 160 | + |
| 161 | +### Phase 3 — Type the request boundary (the big lever, multi-week, file-by-file) |
| 162 | +- Order: `Controller/Administration/Api` → other `Controller/*` → `Database` → `Faq`. |
| 163 | +- Introduce typed accessors for request input and typed DB-row shapes so `mixed` |
| 164 | + collapses at the source rather than fixing 4,700 symptoms individually. |
| 165 | +- One PR per file / small group; regenerate baseline per PR for measurable progress. |
| 166 | +- This phase accounts for the bulk (~4,700) of the ledger. |
| 167 | + |
| 168 | +### Phase 4 — Null-safety & specificity (ongoing) |
| 169 | +- Add guard clauses / nullsafe operators and accurate nullable return types (~900). |
| 170 | +- Tighten `less-specific-*` array shapes and generics (~225). |
| 171 | + |
| 172 | +### Phase 5 — Ratchet |
| 173 | +- As each category reaches zero, optionally promote it to a hard gate so it cannot |
| 174 | + regress, and continue shrinking toward a baseline-free state. |
| 175 | + |
| 176 | +## Sequencing rationale |
| 177 | + |
| 178 | +Phases 1–2 are low-risk, high-signal, and surface real bugs quickly. Phase 3 is |
| 179 | +where most of the volume actually lives, because it is a small number of `mixed` |
| 180 | +*sources*, not 4,700 independent fixes — typing each boundary pays down many |
| 181 | +downstream findings at once. |
0 commit comments