Skip to content

chore(quality): migrate to PHPStan 2 and clear the 11 residual errors - #280

Merged
rubenvdlinde merged 1 commit into
developmentfrom
chore/phpstan-2
Aug 20, 2026
Merged

chore(quality): migrate to PHPStan 2 and clear the 11 residual errors#280
rubenvdlinde merged 1 commit into
developmentfrom
chore/phpstan-2

Conversation

@rubenvdlinde

Copy link
Copy Markdown
Contributor

Supersedes #236 (the bare dependabot bump), which turns PHPStan 2 on without fixing what it then reports.

What this does

Bumps phpstan/phpstan to ^2.0 and conduction/hydra-gates to ^1.8.2, then clears all 11 errors PHPStan 2 finds.

phpstan analyse: 11 errors → 0. 880 PHPUnit tests green (2807 assertions). phpcs: 0 errors (only pre-existing @spec warnings, which the shared config does not fail on).

hydra-gates v1.8.2 carries the shared phpstan-base.neon fixes — treatPhpDocTypesAsCertain: false and the ignore for PHPMD's @SuppressWarnings tripping phpDoc.parseError. Those were verified on both majors before release (2.2.8: 60→22 errors; 1.12.33: exit 0 with a positive control still failing a deliberately broken file), so this bump does not silently disarm the gate.

The five dead method_exists() probes

runAsSystem(), lockObject() and unlockObject() are all declared on OCA\OpenRegister\Contract\ObjectServiceInterface. I checked this against two independent sources that agree:

  • the canonical contract in openregister@development (lib/Contract/ObjectServiceInterface.php)
  • the copy vendored by hydra-gates v1.8.2, which is what OCA\OpenRegister\Contract\ actually resolves to during analysis here (vendor/composer/autoload_psr4.php)

The probes were written as back-compat for an OpenRegister release predating the elevation/locking API. That release cannot run this code at all: openregister@main (HEAD bce5b69c2, 2026-08-13) ships no Contract namespace whatsoever — git ls-tree -r origin/main | grep ObjectServiceInterface returns nothing. Every constructor here type-hints ObjectServiceInterface, so on such an install DI cannot resolve the dependency in the first place. Wherever this code executes, the contract declares all three methods and each fallback branch was unreachable.

I want to flag how easy this was to get wrong: my first check found the methods in shillinq/tests/stubs/OpenRegister/Contract/ObjectServiceInterface.php — a different app's hand-maintained stub, which proves nothing about openbuild. The conclusion above rests on the canonical contract plus the one this app's autoloader actually maps to.

Cascade in MigrateToVersionedModel

$hasSystemContext was always true, so migrateOne() never returned ROW_BLOCKED, $sawBlocked was never set, and STATE_BLOCKED was never persisted. The flag, the parameter, both dead branches and both constants are removed.

This is backwards compatible. The state value is only ever compared against STATE_DONE (and STATE_FAILED). A blocked value persisted by an older install is still neither, so the repair step retries exactly as it did before.

The six other always-true conditions

File Was Why it could never be false
Mcp/Handler/AbstractToolHandler.php if ($locked === true) in finally The lock is taken in a separate try/catch that throws on failure, so the second try is only entered with the lock held. The flag is gone.
Service/AppChannelApplier.php is_array($found) === true && findAll() returns an array.
Service/ApplicationVersionService.php if (is_object($created)) saveObject() returns ObjectEntityInterface.
Controller/AgentsController.php array_values($matching) Appended to with []= and usort()ed in place — already a list.
Service/ManifestResolverService.php array_values($filtered) Appended to with []= only — already a list.
Service/SettingsService.php $settings['registry_url'] ?? '' registry_url is in CONFIG_KEYS, so the loop above always sets it.

Every removal is annotated in place so the next reader does not reintroduce the guard.

Review note

The method_exists() removals are the ones worth a second opinion — they are the only changes that alter behaviour on a hypothetical old OpenRegister, and my argument is that such an install cannot load this app at all. If you disagree with that premise, say so and I will restore the five probes and carry a documented ignoreErrors entry instead; the other six changes stand on their own either way.

Bumps phpstan/phpstan to ^2.0 and conduction/hydra-gates to ^1.8.2 (which
carries the shared phpstan-base.neon fixes for `treatPhpDocTypesAsCertain`
and PHPMD's `@SuppressWarnings` phpDoc.parseError), then removes the code
PHPStan 2 correctly identifies as unreachable. `phpstan analyse` goes from
11 errors to 0; 880 PHPUnit tests stay green and phpcs reports 0 errors.

Dead `method_exists()` back-compat probes (5)
---------------------------------------------
`runAsSystem()`, `lockObject()` and `unlockObject()` are all declared on
`OCA\OpenRegister\Contract\ObjectServiceInterface` — verified against both
the canonical `openregister@development` contract and the copy vendored by
hydra-gates v1.8.2, which is what static analysis resolves here.

The probes were written for back-compat with an OpenRegister release that
predates the elevation/locking API. That release cannot run this code at
all: `openregister@main` ships no `Contract` namespace whatsoever, so the
constructors that type-hint `ObjectServiceInterface` cannot have their
dependencies resolved there. Wherever this code executes, the contract is
the one that declares all three methods, and every fallback branch behind
the probes was unreachable.

Removing them cascades in MigrateToVersionedModel: `$hasSystemContext` was
always true, so `ROW_BLOCKED` was never returned and `STATE_BLOCKED` was
never written. Both constants and the retry prose go with them. This is
backwards compatible — a `blocked` value persisted by an older install is
still not `STATE_DONE`, so the step retries exactly as before.

Other always-true conditions (6)
--------------------------------
- AbstractToolHandler: the lock is acquired in a *separate* try/catch that
  throws on failure, so the later `finally` can only run with the lock
  held; the `$locked` flag could never be false and is now gone.
- AppChannelApplier / ApplicationVersionService: `is_array()` on findAll()'s
  return and `is_object()` on saveObject()'s ObjectEntityInterface.
- AgentsController / ManifestResolverService: `array_values()` on arrays
  only ever appended to with `[]=` (and usort()ed in place) — no-ops.
- SettingsService: `registry_url` is one of CONFIG_KEYS and is always set
  by the loop above, so the `?? ''` was unreachable.

Every removal is annotated in place so the next reader does not
reintroduce the guard.
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/openbuild @ 5c89c84

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-manifest
test-l10n
check-gitignore
check-nc-floor
format
composer ✅ 106/106
npm ✅ 624/624
app:check-code ⏭️
info.xml
REUSE
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-20 22:13 UTC

Download the full PDF report from the workflow artifacts.

@rubenvdlinde
rubenvdlinde merged commit e6ac41c into development Aug 20, 2026
80 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant