fix(quality): empty procest's phpstan baseline — 10 dead injected dependencies removed - #713
Merged
Merged
Conversation
…endencies removed `composer check:strict` was exiting 0 on the phpstan leg while 10 real errors were suppressed by `phpstan-baseline.neon`. Measured by replacing the baseline's body with an empty `parameters: ignoreErrors:` and re-running: exit 1, "Found 10 errors". The other 4 of its 14 entries had already rotted into no-ops — the underlying code was fixed without anyone pruning the file. All 10 were the same shape: `Property X is never read, only written` — a constructor-injected dependency nothing uses. Each was reviewed for whether it was leftover DI or a check that was designed and never wired. All 10 are leftover DI; none removes an enforcement path. Notably: - RoleGuard::$settingsService — evaluate() is complete and falls through to passed:false (fail-closed). SettingsService exposes no authorization API at all, so an unused one can never be a missing access check. - AdvisoryCommitteeService::$transitions — the advice lifecycle is enforced locally and fail-closed (`ALLOWED_TRANSITIONS[$from] ?? []` denies unknown states), plus independence and advice-content guards. The FSM was only ever an optional parent-case integration. - BeroepService::$userSession — StatusTransitionService::execute() resolves the actor itself via resolveUserId(), and OpenRegister stamps the acting user on its own audit trail. The stale class docblock claiming identity is "ALWAYS derived from IUserSession" is corrected to match reality. `phpstan-baseline.neon` is deleted and its `includes:` removed from phpstan.neon. Verified a missing include fails loudly (exit 1, "File ... is missing or is not readable") rather than silently continuing, and verified by positive control that the edited phpstan.neon still analyses all 524 files and still fails on this exact rule. README and openspec/specs/quality-gates/spec.md updated: the spec requirement changes from "baseline is documented and minimal" to "PHPStan runs with no baseline", matching the PHPMD requirement directly above it. Gates: phpstan exit 0 (was 0-with-10-hidden) · phpunit 1684 tests, 0 failures, 5 skipped · phpcs exit 0 · psalm exit 0 · phpmd 25 findings (unchanged).
rubenvdlinde
requested review from
Rem-Dam,
WilcoLouwerse,
bbrands02 and
rjzondervan
as code owners
August 4, 2026 01:08
Contributor
Quality Report — ConductionNL/procest @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ❌ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| composer | ✅ | ✅ 100/100 | |||
| npm | ✅ | ✅ 550/550 | |||
| PHPUnit | ❌ | ||||
| Newman | ⏭️ | ||||
| Playwright | ⏭️ |
Quality workflow — 2026-08-04 01:12 UTC
Download the full PDF report from the workflow artifacts.
rubenvdlinde
pushed a commit
that referenced
this pull request
Aug 4, 2026
… pairs The first PR run was still red — 223 risky had dropped to 49, not 0 — and the reason was a stale base, not a wrong fix. A `pull_request` workflow builds `refs/pull/N/merge`, i.e. this branch merged into the CURRENT development. Five PRs (#712 #713 #714 #715 #717, plus #709) landed while this was in flight, and they split several services into new sub-namespaces. CI was therefore measuring a tree with collaborators that did not exist on the base this branch was cut from — Service\Relation\*, Service\Sharing\*, Service\Transfer\*, Service\Email\*, Service\Settings\*, Service\Ai\*, Service\Cmmn\CasePlanRepository, PlanItemCascade, PlanItemStateMachine, PlanItemTree, Consultation\*, Zaakdossier\InformatieobjectStatusLifecycle, Beschikking\LibresignResultAssembler. Rebased onto 53670a0 and re-measured from scratch. 41 residual pairs across 21 test files, declared here. The measurement now agrees across all three sources: the per-file isolated sweep and the full-suite run produced an identical 41 pairs, and every one of CI's 20 was contained in them — the CI-only set is empty this round, which confirms the earlier local/CI divergence was entirely the stale base and not an environment difference. Tests: 1686, Assertions: 5632, Skipped: 5, Risky: 82 -> 0 locally. Exit 0.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
composer check:strictwas reporting green on procest's phpstan leg while 10 real phpstan errors were hidden byphpstan-baseline.neon. The gate's exit code was decoupled from the codebase's actual state — the classic dead-gate shape, where a check's suppression looks exactly like its success.This PR fixes all 10 at source, deletes the baseline file, and removes it from
phpstan.neon'sincludes:.Before / after measurement
All runs in
php:8.3-cli(host PHP is 8.2 and silently exits 255 on this repo, printing nothing).development@ 70e1e06, baseline as shipped (14 entries)[OK] No errorsparameters:\n\tignoreErrors:Found 10 errorsincludes:left in placeFile '/app/phpstan-baseline.neon' is missing or is not readable.includes:both gone[OK] No errorsRun 1 vs 2 is the whole point: the same code, analysed by the same tool, is green or red depending only on the suppression file. 10 of the 14 entries were load-bearing. The other 4 had already rotted into no-ops — the underlying code had been fixed and nobody pruned the file:
AiController::$loggerStufMessageBuilder::$loggerWorkflowDefinitionService::$userSessionParaferingAuditAppendOnlyValidator@implementsgeneric-type entry (already covered by a documentedignoreErrorspattern inphpstan.neonitself, so it was double-suppressed)Run 3 is the check that deletion is safe: a missing include fails loudly, it does not silently continue. So the
includes:entry had to go too, and its absence cannot itself become a new silent pass.Positive control on the final config. A baseline PR that leaves phpstan no-opping would be solving one dead gate by creating another. Before committing I added a deliberate unused promoted property to
RoleGuardand re-ran the finalphpstan.neon: exit 1,524/524files analysed, and the error raised was the exact rule these 14 entries had suppressed (Property ... is never read, only written). The canary was then reverted and exit 0 reconfirmed. The green in run 4 is bought by the source.Case (a) / case (b) verdict per file
"Never read, only written" on an injected dependency has two causes: (a) leftover DI from a refactor, safe to remove; or (b) a check that was designed and never wired, where deleting the property hides the bug. Each of the 10 was read in full before touching it.
All 10 are case (a). None removes an enforcement path.
lib/Controller/LhsController.php$settingsServicerecommend,override,lookup) authenticate viaIUserSessionand return 401 on null;override()additionally gates the manager role throughIGroupManager::isAdmin().SettingsServiceis an OpenRegister/config bridge with no authorization API — see note below.lib/Controller/TemplateController.php$loggeruserSession->getUser() === null→ 401. A logger is observability, never a check.lib/Listener/BezwaarLifecycleListener.php$settingsServiceStatusTransitionService.execute()and there is intentionally no transition logic here. Nothing to enforce.lib/Service/Bezwaar/AdvisoryCommitteeService.php$transitionsassertTransitionAllowed()readsALLOWED_TRANSITIONS[$from] ?? [], so an unknown source state denies every target; plus aVALID_STATUSESwhitelist, the Awb 7:13(3) independence guard onassigned → in-deliberation, and required advice fields with a fail-closed decidesk delegation on→ advice-issued.$transitionswas documented as an optional parent-case integration and no method ever advances the parent case. Reachability confirms it:transitionAdviceStatus()andassignToCommittee()have no external callers, so there is no unguarded HTTP path either way.lib/Service/Bezwaar/BeroepService.php$userSessionStatusTransitionService::execute()resolves the actor itself (resolveUserId()) and runsassertTransitionAllowed()with it, and every write lands through OpenRegister, which stamps the acting user on its own audit trail. The caller does not need its own session. The class docblock claimed identity is "ALWAYS derived fromIUserSession" — that was already untrue, and this PR corrects the prose. See finding 1 below: this whole class is unwired.lib/Service/CaseDefinitionImportService.php$appConfiggetFromName()/getNameIndex(), neverextractTo(), so there is no zip-slip path a config-driven limit would have guarded. See finding 2 below:importComponent()is a stub.lib/Service/Pdok/PdokBagService.php$settingsService$appConfig, read at 4 call sites for endpoint/source/TTL.lib/Service/Pdok/PdokLocatieserverService.php$settingsService$appConfigis read at 7 call sites.lib/Service/TenantService.php$settingsService$appManager+$container, a parallel path toSettingsService::getOpenRegisterClass(). Its one auth-flavoured method,isPlatformAdmin(), delegates toIGroupManager::isAdmin()and does not consult settings.lib/Service/Transitions/RoleGuard.php$settingsServiceevaluate()is complete:allowedRolescheck, direct case-role match, Nextcloud group fallback, then falls through topassed: false— fail-closed. Nothing is missing.A structural note that makes six of these verdicts cheap to trust:
SettingsService's entire public API is OpenRegister resolution plus config get/set (isOpenRegisterAvailable,getObjectService,getOpenRegisterClass,getConfigValue,setConfigValue,reconcileSchemaConfig, …). It exposes no authorization, role, or permission method at all. An unused$settingsServicetherefore cannot be an unwired access check in any of these classes — the check it would have performed does not exist on the type.Two findings this review surfaced (not fixed here — both are product decisions)
Neither is caused or hidden by this PR; both are reported because looking at the 10 properties is what exposed them.
1.
BeroepServiceis ~590 lines of entirely unwired service. Its only mention anywhere outside its own file is a prose sentence in the register schema,lib/Settings/procest_register.json:4287: "Immutability after appellantFilingDate is enforced by BeroepService." That claim is false twice over — the class implements no such immutability check, and it has no callers, so nothing runs. A schema description is documentation, not enforcement. Deleting the class is a bigger call than this PR should make; flagging it instead.2.
CaseDefinitionImportService::importComponent()is a stub that reports success. It parses each component's JSON and then returnsstatus: 'success',"Component '<x>' imported successfully"while persisting nothing — the body carries the comment "In a full implementation, this would create/update OpenRegister objects."importCaseDefinition()aggregates those into"Import completed successfully". The removed$appConfigwas plainly the intended register/schema accessor for that unwritten persistence. Blast radius is limited:CaseDefinitionControllerdeclares no#[NoAdminRequired], so the endpoint is admin-only. Removing the unused property does not make this less visible — the explicit stub comment stays in the code, andhydra-gate-stub-scantargets exactly this shape.Changes
@paramdocblock lines and theuseimports that became orphaned.tests/Unit/Service/Transitions/RoleGuardTest.php— the only construction site of any of these classes outside the DI container (2 ×new RoleGuard(settingsService: …), named args). Every other class is autowired. Test count is unchanged at 1684.lib/Service/Bezwaar/BeroepService.php— class docblock corrected to describe how identity is actually resolved.lib/Service/CaseDefinitionImportService.php— also drops a pre-existing unuseduse OCA\Procest\AppInfo\Application;.phpstan-baseline.neondeleted;includes:removed fromphpstan.neonand replaced with a comment saying why it must not come back.openspec/specs/quality-gates/spec.md— the requirement changes from "PHPStan baseline is documented and minimal" to "PHPStan runs with no baseline", mirroring the PHPMD requirement immediately above it, and adds a scenario asserting that emptying the suppression config leaves the result unchanged.README.md— corrected; it still advertised the baseline as a virtue.Gate results (this branch, exit codes printed, stderr byte counts checked)
php:8.3-cli … phpstan analyse --memory-limit=1G[OK] No errors, 524/524 files, no baselinenextcloud:latest … phpunit --no-coveragedevelopmentphp:8.3-cli … phpcs --standard=phpcs.xml@specwarnings only, unchanged)nextcloud:latest … psalm --threads=1 --no-cachephp:8.3-cli … phpmd lib text phpmd.xmlext-zip. Underphp:8.3-cliit exits 2 with 25UndefinedClass: ZipArchiveerrors that are pure environment artefacts — I verified all 25 areZipArchiveand that the same commit is clean undernextcloud:latest. The same caveat already applies to phpunit. Aphp:8.3-clipsalm run would look like this PR broke 25 things; it did not.Follow-ups worth an issue
.github/CODEOWNERS:15still has aphpstan-baseline.neonrule that now matches nothing. Left alone deliberately to keep CODEOWNERS out of a quality PR.includes:comment claimed the block is "byte-identical across the fleet" and that debt-free apps ship an empty baseline file. If that convention is real, the other apps should drop theirs too — an empty baseline file is a standing invitation to refill it, and this repo is the demonstration of what happens next.BeroepServicedead class;CaseDefinitionImportServicesuccess-reporting stub).