# S128 — PHPStan for tests/. A SEPARATE config on purpose. # # ┌───────────────────────────────────────────────────────────────────────────────┐ # │S343 — src/ KEEPS LEVEL 9 WITH NO BASELINE, NO ignoreErrors AND EMPTY │ # │excludePaths. StaticAnalysisScopeTest asserts all of it against phpstan.neon. │ # │Nothing in this file touches that. Do not merge the two configs. │ # └───────────────────────────────────────────────────────────────────────────────┘ # # ⚠ THE TRAP THIS FILE EXISTS BECAUSE OF. Before S128, `phpstan.neon` said # `paths: [src]` — but that was never what made tests/ unanalysed, and "just add # tests to paths" would NOT have fixed it. The CI step is # # ./vendor/bin/phpstan analyze src/ --level=9 --no-progress --error-format=github # # and a path given on the COMMAND LINE overrides `parameters.paths` entirely. So # adding `tests` to phpstan.neon would have changed local runs and left CI analysing # exactly `src/`, with a green gate and a config that lied about its own scope. That # is why the fix is a second config plus a second CI step that names it, and why # tests/Unit/Support/StaticAnalysisScopeTest.php asserts the WORKFLOW, not the config. # # LEVEL — measured, never aspirational. The original ladder below was run against # `tests/` on origin/master @ 12bdea6c (phpstan 2.2.5, PHP 8.3.6, CI no-swoole ini set): # # level 0 -> 6 errors level 5 -> 138 # level 1 -> 9 level 6 -> 152 # level 2 -> 51 level 7 -> 214 # level 3 -> 52 level 8 -> 274 # level 4 -> 86 level 9 -> 532 # # S128 chose level 2 — the highest rung fixable at source in one atomic change (all 51 # fixed, zero config ignores, zero baseline): a test calling a production method that no # longer exists becomes a static error instead of a runtime surprise on the one branch # that reaches it. # # S186 raised the rung to LEVEL 4. The ladder moved under the plan's own feet: the # documented "level 4 adds 34" was a stale claim — re-derived at tip 5986b61d the level-4 # delta measured 85 errors across 61 files (identifiers: 18 method.alreadyNarrowedType, # 13 return.unusedType, 7 nullsafe.neverNull, 6 function.alreadyNarrowedType, 5 # staticMethod.alreadyNarrowedType, 4 method.impossibleType, 4 nullCoalesce.offset, 3 # catch.neverThrown, and 25 others). All 85 were triaged with per-error MUTATION evidence # and closed AT SOURCE: tautological assertions deleted where a live sibling already # pinned the fact (or made live via reflection/`constant()` seams where the fold was the # only reason they looked dead), test-double return types narrowed to what they actually # return, dead guards dropped, `@param-out` for by-ref contracts, and honest docblocks # where the old annotation over-promised. Three errors describe code that is intentional # and are covered by identifier-carrying INLINE suppressions only: two writes whose whole # purpose is proving PHP rejects a readonly write, and one fixture method whose existence # is the subject of a reflection-enumeration assertion. Each is self-clearing — # reportUnmatchedIgnoredErrors turns a stale suppression into a build failure. The # config-level ignore list below stays EMPTY by policy: it excuses by pattern and keeps # excusing new code. parameters: level: 4 paths: - tests bootstrapFiles: - vendor/autoload.php # ⚠ CI's phpstan job runs setup-php with `extensions: json` — NO swoole. This box has # it, so PHPStan resolved Swoole\Coroutine\WaitGroup by reflection and reported # nothing, while CI failed `Run PHPStan on tests/` with 10 class.notFound errors in # tests/Integration/Media/Transcoding/PooledConnectionConcurrencyTest.php. A local # green was CORRECT and still blind. # # phpstorm-stubs (bundled in phpstan.phar) ships 19 Swoole\Coroutine stubs and no # WaitGroup — because WaitGroup is a PHP class inside the extension's own library # (isUserDefined()=true, '@swoole/library/core/Coroutine/WaitGroup.php'), not an # extension class, so it is out of scope for a stub set that describes extensions. # Read that stub's header for the full reasoning and for the two alternatives # (avoidance, as src/Media/Library/MediaScanner.php:1054 does; excludePaths, as # phpstan.neon does) and why neither was chosen. # # ⚠ REPRODUCE THE CI CONDITION before touching this. -d flags are not inherited by # child processes and an extension cannot be unloaded, so copy the ini scan dir # without swoole and point PHP_INI_SCAN_DIR (env vars ARE inherited) at it: # for f in /etc/php/8.3/cli/conf.d/*.ini; do grep -qi swoole "$f" || cp "$f" DIR/; done # PHP_INI_SCAN_DIR=DIR php vendor/bin/phpstan analyse -c phpstan-tests.neon # Both configs must be clean WITH and WITHOUT the extension. # # ⚠ `stubFiles:` DOES NOT WORK for this and I measured it before settling on # scanFiles. A stub only OVERRIDES a class the reflection provider can already find; # it does not introduce an unknown one. With the identical file registered as a # stubFile, the no-swoole run still reported all 10 class.notFound errors. Two # controls, so this is not a guess about my own file: PHPStan is loud about a stub # path that does not exist ("Stub file … does not exist"), so the path resolved; and # a stripped-down copy with no docblock behaved identically, so the docblock was not # at fault. `scanFiles:` READS a file for the symbols it declares, which is exactly # what is needed here — the same mechanism, and the same reason, as the # bootstrap_env.php entry below. scanFiles: # The one Swoole class PHPStan cannot resolve without ext-swoole. Read that # file's header: it is a PHP class inside the extension's own library, so # phpstorm-stubs (which describes EXTENSION classes) structurally cannot ship it. - phpstan-stubs/Swoole/Coroutine/WaitGroup.stub # tests/Unit/Scripts/BootstrapEnvTest.php `require_once`s this script for its two # function definitions (phlix_parse_env_file, phlix_bootstrap_cli_env). PHPStan # does not follow a runtime require, so without this the three calls read as # calls to functions that do not exist — the same identifier a genuine typo would # produce. scanFiles (not bootstrapFiles) because these files must be READ for # symbols, never EXECUTED: including bootstrap_env.php would run its # auto-bootstrap during analysis. - scripts/bootstrap_env.php treatPhpDocTypesAsCertain: false # Empty, and it stays empty. A config-level ignore is invisible at the code it # excuses and matches by pattern, so it keeps excusing new code that happens to look # the same — that is how an ignore list becomes the baseline this repo forbids. If # this list is ever non-empty the level was wrong: lower the level and record the new # ladder position instead. # # SEVEN suppressions DO exist, all of them INLINE, each naming its identifier and its # reason at the line it excuses. They are the cases where PHP's type system genuinely # cannot describe a test double: # # 1 x method.notFound an anonymous subclass declaring an extra METHOD (a # AccountLinkIntegrationTest property can be expressed as an object-shape # intersection; a method cannot) # 3 x method.notFound Mockery::shouldReceive()'s # PluginLoaderTest ExpectationInterface|HigherOrderMessage union # (needs phpstan/phpstan-mockery, not a dependency) # 2 x method.notFound a closure that RECEIVES a PHPUnit MockObject but can # RequestAuthenticatorTest only be typed as the production interface # 1 x assign.propertyReadOnly a write to an object-SHAPE field, where the concrete # PooledMySQLConnectionTest type is an anonymous class built inside a factory # # `reportUnmatchedIgnoredErrors` is left at its default (true), which is the point: # each of the seven becomes an `ignore.unmatchedLine` error the moment it stops being # needed. Two DEAD suppressions were deleted in this same change for exactly that # reason — they had been silencing nothing, invisibly, because tests/ was unanalysed. ignoreErrors: []