Skip to content

fix(quality): decompose the voting cluster (18 phpmd findings) - #385

Merged
rubenvdlinde merged 3 commits into
developmentfrom
quality/phpmd-zero-d1
Aug 4, 2026
Merged

fix(quality): decompose the voting cluster (18 phpmd findings)#385
rubenvdlinde merged 3 commits into
developmentfrom
quality/phpmd-zero-d1

Conversation

@rubenvdlinde

@rubenvdlinde rubenvdlinde commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

What

Clears the PHPMD findings remaining in the voting cluster by decomposition — no suppressions, no baseline entries, no threshold changes.

⚠️ Read this first: the baseline, and a stale base

The baseline. decidesk/phpmd.baseline.xml suppresses findings, and PHPMD auto-discovers a file of that name in the working directory whether or not --baseline-file is passed — so a plain phpmd run in the repo root reports a false clean. Every number below was measured with the baseline bypassed via an explicit empty baseline:

docker run --rm -v "$PWD":/app -v /tmp/pmdempty:/pmdempty:ro -w /app php:8.3-cli \
  php vendor/bin/phpmd lib text phpmd.xml --baseline-file /pmdempty/empty-baseline.xml

The stale base. This branch was originally cut from 36985fa6, which turned out not to be an ancestor of development: #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 current development; the superseded version is preserved in full on wip/preserve-20260804-d1-voting-facade.

So this PR is scoped to the 3 findings #380 left behind.

before after
repo-wide true findings (baseline bypassed) 7 (exit 2) 4 (exit 2)

A line-by-line diff of the before/after reports shows no new finding anywhere in lib/. phpmd.baseline.xml is untouched; with it in place the CI job is still exit 0.

Before → after, per finding

file finding before after
lib/Service/VotingService.php class CouplingBetweenObjects 20 / 13 9
lib/Service/VotingService.php closeVotingRound() BooleanArgumentFlag $anonymise present gone
lib/Controller/VotingController.php class CouplingBetweenObjects 19 / 13 11

Also, as a side effect of the facade: VotingService drops 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)

VotingService ExcessiveClassLength / ExcessiveClassComplexity, castVote() Cyclomatic + NPath 3,133,440 + length, both castVote ElseExpressions, closeVotingRound() Cyclomatic + NPath + length, getPublicState() Cyclomatic + NPath, and all three VotingBehaviourService::getStats() findings. 15 in total.

How

VotingService becomes 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 inside VotingService — 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)

  • VotingRoundOpenercheckQuorum() and openVotingRound(), the last two large methods still inline in VotingService.
  • VotingRoundResults — the ballot tally, the show-of-hands tally, and the rule-aware result computation they share.
  • ParticipantUuidLookup — Nextcloud UID → participant UUID.
  • SavedObjectNormaliser — the ObjectEntity → 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, VotingController needs no catch clauses at all, dropping its references to \RuntimeException, \InvalidArgumentException, \Throwable and LoggerInterface. Each endpoint keeps its exact previous status contract by choosing a method — badRequest(), badRequestOrNotFound(), invalidOrMissing(), internalError().
  • VotingOpenRequestHandler — the open-a-round request shape (parser + VotingRoundRules assembly + service call), removing VotingOpenRequestParser and VotingRoundRules from the controller.

VotingController also now takes VotingRoundGuard and ProxyDelegationService by injection instead of building them, which drops IGroupManager, IAppConfig, ParticipantResolver and ContainerInterface from 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's VotingRoundCloser::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, no phpmd.xml threshold 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.

gate command result
phpmd (baseline bypassed) phpmd lib text phpmd.xml --baseline-file <empty> exit 2, 7 → 4 findings, 0 bytes stderr
phpmd (committed baseline, CI's view) phpmd lib text phpmd.xml --baseline-file phpmd.baseline.xml exit 0, 0 findings
phpunit phpunit --no-coverage 800 tests, 0 failures, 37 skipped — baseline held
phpcs phpcs --standard=phpcs.xml exit 0
psalm psalm --threads=1 --no-cache exit 0
phpstan phpstan analyse --memory-limit=1G exit 0

Behaviour

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 VotingService unit-test construction sites and the VotingController one 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.php still carries 11 @spec anchors pointing at openspec/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 is openspec/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.php and lib/Service/BoardEvaluationResponseService.php carry comments referring to VotingService::voterTokenSecret(), which fix(quality): clear PHPMD architectural debt in decidesk (73 → 7) #380 moved to VoterTokenSecret::value(). Behaviour is unchanged (same app-config key, same HMAC); only the comments are stale.

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/.
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.
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/decidesk @ 926797e

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.

@rubenvdlinde

Copy link
Copy Markdown
Contributor Author

CI note: quality / Integration Tests (Newman) and its downstream quality / Quality Report fail on this PR — and they fail identically on development itself (runs 30868542272 and 30868539257, same two jobs, same 29-job total). Pre-existing, not caused by this change.

Every other job passes, including PHP Quality (phpmd), (phpcs), (psalm), (phpstan) and all four PHPUnit legs. Job count is 29 on both this PR and development, so the gates genuinely ran.

@rubenvdlinde
rubenvdlinde merged commit f2f65c8 into development Aug 4, 2026
31 of 33 checks passed
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>
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.

2 participants