fix(quality): decompose the voting cluster (18 phpmd findings) - #385
Merged
Conversation
VotingService was 1378 lines / complexity 148 with castVote() at NPath 3,133,440. It is now a thin facade: every operation delegates to a single-purpose collaborator. New collaborators (lib/Service/): - VoterTokenService, VoteRepository, VoteEligibilityGuard, VoteRecorder, VoteCastingService (the castVote path) - VotingRoundOpener, VotingRoundCloser, VotingSubjectOutcomeApplier, VotingRoundResults, VotingRoundProjection - ParticipantUuidLookup, SavedObjectNormaliser - VotingErrorResponder, VotingOpenRequestHandler (controller coupling) closeVotingRound(bool $anonymise = false) is replaced by two named methods, closeVotingRound() and closeVotingRoundAnonymised(), so the irreversible variant is explicit at every call site. VotingBehaviourService::getStats() is split into closedRoundsForBody(), tallyBehaviour(), participantVotes(), addVoteCounts(), countProxiesReceived(), isDelegatedBy() and participationRate(); its 11 dead @SPEC anchors (openspec/changes/p2-motion-and-voting-core-t2, an archived change) now point at openspec/specs/member-voting-behaviour-tracking. No suppressions, no baseline entries, no threshold changes. Measured with the baseline bypassed: 73 -> 55 true findings, exactly the 18 targeted, with no new finding anywhere in lib/.
rubenvdlinde
requested review from
WilcoLouwerse,
bbrands02 and
rjzondervan
as code owners
August 4, 2026 01:11
Rebased onto development after #380 landed. #380 already cleared 15 of the 18 findings in this cluster; this change clears the 3 it left and keeps #380's decomposition intact. Cleared (measured with the phpmd baseline BYPASSED, 7 -> 4 repo-wide): - VotingService CouplingBetweenObjects 20/13 -> 9 - VotingService::closeVotingRound BooleanArgumentFlag $anonymise -> gone - VotingController CouplingBetweenObjects 19/13 -> 11 VotingService is now a thin facade over six collaborators. New classes: - VotingRoundOpener — checkQuorum() + openVotingRound() - VotingRoundResults — tally, show-of-hands tally, rule-aware result - ParticipantUuidLookup — NC UID -> participant UUID - SavedObjectNormaliser — the ObjectEntity -> array normalisation - VotingErrorResponder — exception -> HTTP status, so the controller needs no catch clauses (and no RuntimeException / InvalidArgumentException / Throwable / LoggerInterface references) - VotingOpenRequestHandler — the open-a-round request shape closeVotingRound(bool $anonymise = false) is replaced by two named methods, closeVotingRound() and closeVotingRoundAnonymised(), both delegating to a shared private closeRound(); the load-bearing order (casting vote -> tally -> close) is preserved. VotingController::close() selects the variant from the request flag in one private method, so the HTTP contract is unchanged. No suppressions, no baseline entries, no threshold changes, and no new finding anywhere in lib/.
The first version of this branch was cut from 36985fa, which turned out NOT to be an ancestor of development: #380 landed in between and already decomposed the same voting code, clearing 15 of the 18 targeted findings. Merging that commit with -s ours keeps the rebased tree (built on top of #380) and discards the superseded rewrite, which would have reverted #380's work. The discarded version is preserved in full on wip/preserve-20260804-d1-voting-facade.
Contributor
Quality Report — ConductionNL/decidesk @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| composer | ✅ | ✅ 100/100 | |||
| npm | ✅ | ✅ 549/549 | |||
| PHPUnit | ✅ | ||||
| Newman | ❌ | ||||
| Playwright | ⏭️ |
Quality workflow — 2026-08-04 01:26 UTC
Download the full PDF report from the workflow artifacts.
Contributor
Author
|
CI note: Every other job passes, including |
rubenvdlinde
added a commit
that referenced
this pull request
Aug 4, 2026
…ine 4 entries -> 1 (#386) * chore(quality): delete the phpmd and phpstan baselines — both now suppress nothing decidesk's phpmd is at zero on its own after #380, #383, #384 and #385. phpmd.baseline.xml still carried 116 entries, none of which can fire any more, so the green the CI job reported was indistinguishable from a green the baseline was producing. Removed the file AND the --baseline-file flag together. That pairing is load-bearing in both directions: PHPMD auto-discovers a file named phpmd.baseline.xml in the working directory even without the flag, so dropping only the flag would have changed nothing; and deleting only the file while the flag stays makes phpmd exit 1 with no output at all. Verified in a PHP 8.4 container: * composer phpmd -> exit 0, no findings, with no baseline present * negative control: a probe class with an else-expression and a missing import makes it exit 2 naming both findings; deleting the probe returns it to exit 0. The zero is a real zero, not a tool that failed to start. phpstan-baseline.neon (4 entries) and its include go the same way — at least one entry, ApiController::SCOPE_MAP unused, names a constant the refactors have moved. phpstan cannot be run faithfully outside CI here (OCP is not resolvable in a bare container), so this half rests on the PHP Quality (phpstan) job; if it goes red the baseline comes back rather than being regenerated. * fix(quality): keep the phpstan baseline at one entry, and fix the one that was a real dead guard CI showed only 2 of the 4 baselined phpstan errors still fire, so: * MotionCoauthorService -- FIXED, not baselined. `isConcurrentForeignEdit` declared $history as array<int, array<string, mixed>> while it arrives as $motion['versionHistory'] straight off an OpenRegister object. That made the is_array() guard provably dead ('=== between true and false will always evaluate to false'). The docblock over-promised; widened to array<int, mixed> so the guard is live again rather than deleting it. * ApiController::SCOPE_MAP -- stays baselined, with the reason written into the baseline header. It is a slug -> required-OAuth-scope table that nothing reads, while the class docblock claims enforcement comes from the per-schema OAuthScope register entries. 'The authorisation table is unused' is the shape of a missing check; deleting the constant would delete the evidence. It needs a decision, not a silent removal. * ActionItemAnalyticsService and EmailLinkService entries dropped -- CI confirms neither fires any more. Baseline goes 4 entries -> 1. Not regenerated. --------- Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
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.
What
Clears the PHPMD findings remaining in the voting cluster by decomposition — no suppressions, no baseline entries, no threshold changes.
The baseline.
decidesk/phpmd.baseline.xmlsuppresses findings, and PHPMD auto-discovers a file of that name in the working directory whether or not--baseline-fileis passed — so a plainphpmdrun in the repo root reports a false clean. Every number below was measured with the baseline bypassed via an explicit empty baseline:The stale base. This branch was originally cut from
36985fa6, which turned out not to be an ancestor ofdevelopment: #380 landed in between and had already decomposed the same voting code. Of the 18 findings this branch was scoped to, #380 already cleared 15. The first version of this branch would have reverted #380's work wholesale (CONFLICTING, 307 files). It has been rebased onto currentdevelopment; the superseded version is preserved in full onwip/preserve-20260804-d1-voting-facade.So this PR is scoped to the 3 findings #380 left behind.
A line-by-line diff of the before/after reports shows no new finding anywhere in
lib/.phpmd.baseline.xmlis untouched; with it in place the CI job is still exit 0.Before → after, per finding
lib/Service/VotingService.phplib/Service/VotingService.phpcloseVotingRound()BooleanArgumentFlag$anonymiselib/Controller/VotingController.phpAlso, as a side effect of the facade:
VotingServicedrops from 732 to 372 lines and its overall complexity from 26 to 11.Already cleared by #380 (verified on current
development, not re-done here)VotingServiceExcessiveClassLength / ExcessiveClassComplexity,castVote()Cyclomatic + NPath 3,133,440 + length, bothcastVoteElseExpressions,closeVotingRound()Cyclomatic + NPath + length,getPublicState()Cyclomatic + NPath, and all threeVotingBehaviourService::getStats()findings. 15 in total.How
VotingServicebecomes a thin facade — every public operation delegates one line to a single-purpose collaborator. #380's collaborators (VoteCastingService,VotingRoundCloser,VotingRoundProjection,VoteCastGuard,VoteBallotFactory,VoterTokenSecret) are kept exactly as they are and are now injected rather than constructed insideVotingService— which is what actually removed the coupling. Nextcloud auto-wires all of them.New classes (all in
lib/Service/, all with the repo's full docblock + SPDX header)VotingRoundOpener—checkQuorum()andopenVotingRound(), the last two large methods still inline inVotingService.VotingRoundResults— the ballot tally, the show-of-hands tally, and the rule-aware result computation they share.ParticipantUuidLookup— Nextcloud UID → participant UUID.SavedObjectNormaliser— theObjectEntity→ array normalisation that was duplicated across the moved methods.VotingErrorResponder— the exception → HTTP status mapping. This is what takes the controller under the coupling threshold: with the mapping behind a callable,VotingControllerneeds nocatchclauses at all, dropping its references to\RuntimeException,\InvalidArgumentException,\ThrowableandLoggerInterface. Each endpoint keeps its exact previous status contract by choosing a method —badRequest(),badRequestOrNotFound(),invalidOrMissing(),internalError().VotingOpenRequestHandler— the open-a-round request shape (parser +VotingRoundRulesassembly + service call), removingVotingOpenRequestParserandVotingRoundRulesfrom the controller.VotingControlleralso now takesVotingRoundGuardandProxyDelegationServiceby injection instead of building them, which dropsIGroupManager,IAppConfig,ParticipantResolverandContainerInterfacefrom its constructor.BooleanArgumentFlag
closeVotingRound(string $id, bool $anonymise = false, ?string $chairCasting = null)is replaced by two named methods:closeVotingRound(string $id, ?string $chairCasting = null)closeVotingRoundAnonymised(string $id, ?string $chairCasting = null)Both delegate to a shared private
closeRound(), which preserves the load-bearing order (casting vote persisted → tally reads it → round stamped closed) and calls #380'sVotingRoundCloser::close()unchanged. Anonymisation is irreversible, so it is now explicit at every call site.VotingController::close()selects the variant from the request flag in one private method; the HTTP contract is unchanged.Inline suppressions
None. No
@SuppressWarnings, no baseline entries, no psalm/phpstan baseline, nophpmd.xmlthreshold change.Verification
Host PHP is 8.2 and
vendor/bin/*exits 255 printing nothing there, so every tool ran in a container; exit codes and stderr byte counts were checked on each run.phpmd lib text phpmd.xml --baseline-file <empty>phpmd lib text phpmd.xml --baseline-file phpmd.baseline.xmlphpunit --no-coveragephpcs --standard=phpcs.xmlpsalm --threads=1 --no-cachephpstan analyse --memory-limit=1GBehaviour
No behaviour change is intended. Every exception message, every ObjectService call order, every relation-filter sequence, every fail-soft/fail-closed decision and every response status is preserved verbatim. The 7
VotingServiceunit-test construction sites and theVotingControllerone were updated to assemble the collaborator graph that production gets from Nextcloud's auto-wiring.Not touched (outside this PR's file scope)
lib/Service/VotingBehaviourService.phpstill carries 11@specanchors pointing atopenspec/changes/p2-motion-and-voting-core-t2/, an archived change directory that no longer exists — every one of those anchors is dead. The canonical target isopenspec/specs/member-voting-behaviour-tracking/spec.md. Its PHPMD findings were cleared by fix(quality): clear PHPMD architectural debt in decidesk (73 → 7) #380, so the file is no longer in this PR's scope; flagging it for whoever owns it next.lib/Repair/InitializeSettings.phpandlib/Service/BoardEvaluationResponseService.phpcarry comments referring toVotingService::voterTokenSecret(), which fix(quality): clear PHPMD architectural debt in decidesk (73 → 7) #380 moved toVoterTokenSecret::value(). Behaviour is unchanged (same app-config key, same HMAC); only the comments are stale.