Skip to content

fix(quality): clear 100 real PHPMD findings and drop the 29 baseline entries that covered them - #2347

Open
rubenvdlinde wants to merge 4 commits into
developmentfrom
fix/phpmd-burndown-missing-imports
Open

fix(quality): clear 100 real PHPMD findings and drop the 29 baseline entries that covered them#2347
rubenvdlinde wants to merge 4 commits into
developmentfrom
fix/phpmd-burndown-missing-imports

Conversation

@rubenvdlinde

Copy link
Copy Markdown
Contributor

The measurement

Second slice of the fleet suppression audit (ConductionNL/.github#155), tracked here by #2338.
Measured with PHPMD 2.15.0 / PHP 8.4.22, positive-controlled.

main leg (phpmd.xml) second leg (phpmd-unusedparams.xml)
reported by composer phpmd today 0 0
true, with phpmd.baseline.xml deleted 749 16
after this PR 649 16

phpmd.baseline.xml carries 517 entries but suppresses 749 findings. That gap is
granularity, not staleness: a PHPMD baseline entry is scoped to a (rule, file) pair —
optionally a method — and never to a line. One entry can therefore cover many findings,
and every future violation of that rule in that file as well. A file-scoped entry is an
open licence, which is the real reason to delete entries rather than shrink them.

What this PR clears — 100 findings, four rules to zero

rule findings files
MissingImport 92 22
UnusedLocalVariable 5 3
UnusedPrivateMethod 2 1
ShortMethodName 1 1

MissingImport — every one was a new \Fully\Qualified\Name(...) written inline. Each got
a use statement plus the short name at the call site. One genuine collision: Application.php
already imports Service\Integration\Providers\TimeProvider, so Service\Integration\TimeProvider
is imported as IntegrationTimeProvider rather than shadowing it. Importing the optional
\Imagick / \ImagickPixel extension classes in HexIconService is safe — a use is
compile-time aliasing and autoloads nothing.

Shortening two class names in Application.php made CustomSniffs.Functions.NamedParameters
recognise the HexIconService and SchemaImportService constructor calls as internal for the
first time; both now use named parameters.

UnusedLocalVariable — the interesting half of this rule is knowing when the assignment is
dead but the call is not:

  • DbalObjectSourceProvider (3×): all three were the $source slot of
    [$source, $connection, …] = $this->writeContext(…). writeContext() checks write
    authorisation, opens the connection and can throw — so the call stays and only the dead
    binding is dropped, via list-skip syntax [, $connection, …].
  • SaveObjects: $defaultRegister was never read, but the call warms the shared static
    register cache that per-row code reads through loadRegisterWithCache(). Call kept as a
    statement, assignment dropped.
  • MapsOverviewService: $type was a plain ?? array read with no side effect — line removed.

UnusedPrivateMethodAuditHandler::extractSchemaId() and extractSchemaSlug() have no
caller anywhere in lib/ or tests/, and the class does no dynamic dispatch. Deleted.

ShortMethodNameFilesController::t()translate() at its 54 call sites. It is private,
so nothing outside the class is affected. The alternative was adding t to the rule's
exceptions list, which is a threshold relaxation and out of scope for this audit.

Baseline: 517 → 488 entries, nothing added

The 29 removed entries are exactly the ones covering the findings fixed above, plus one that
was already stale — MissingImport on lib/Service/Flow/FlowActionService.php, a file that
has had no MissingImport finding for some time and was being covered anyway. That stale
entry is the open-licence problem in miniature.

The baseline is deliberately RETAINED. 649 + 16 findings remain and openregister cannot
reach zero in one pass; a half-deleted baseline that reddens CI helps nobody. What remains:

rule count why it is still here
StaticAccess 177 architectural — each is a call site that needs an injected collaborator
ElseExpression 97 mechanical but not complexity-neutral; clearing an else adds cyclomatic complexity, so it has to be done alongside the CC work
CyclomaticComplexity 94 real refactoring
NPathComplexity 60 real refactoring
ExcessiveMethodLength 33 real refactoring
ExcessiveClassComplexity 31 needs collaborator extraction
BooleanArgumentFlag 29 public API changes
LongVariable / ShortVariable 26 / 24 renames across call sites
CouplingBetweenObjects 26 needs collaborator extraction
ErrorControlOperator 15 removing @ is a behaviour change, not a refactor
ExcessiveParameterList 14 signature changes
ExcessiveClassLength 6 real refactoring
TooManyFields 4 structurally unreachable on Db\* entities — one protected property is one database column, and Nextcloud derives the column list, magic accessors and dirty-tracking from them. Only a destructive migration could satisfy the rule; that needs a scoped-ruleset decision, not a refactor.
TooManyMethods / TooManyPublicMethods / ExcessivePublicCount 3 / 2 / 2 needs collaborator extraction
Superglobals 3 behaviour-sensitive
CountInLoopExpression 3 small, but each needs its loop re-read
UnusedFormalParameter (second leg) 16 mostly interface-mandated (IEventListener::handle(), QueuedJob::run($argument), provider contracts). One is not: lib/Service/File/Pdf/PdfTextReplacer.php:320 is the $strict dead-parameter defect tracked by #2339, and the baseline entry UnusedFormalParameter → PdfTextReplacer.php is what has been hiding it. Fixing that belongs to #2339, not here.

No @SuppressWarnings was added, no threshold was changed, no test was skipped or weakened.

Verification

  • With the baseline deleted: main leg 749 → 649, second leg 16 → 16; MissingImport,
    UnusedLocalVariable, UnusedPrivateMethod and ShortMethodName all report zero.
  • phpcs clean over all 1400 files in lib/ (serial run, exit 0).
  • Unit suite unchanged: 16007 tests / 35916 assertions, same exit status, before and after.

Refs ConductionNL/.github#155. Part of #2338.

…entries that covered them

Second slice of the suppression audit (ConductionNL/.github#155, #2338). With
phpmd.baseline.xml deleted, this repo's true PHPMD count was 749 in the main leg
plus 16 UnusedFormalParameter in the second leg. This PR clears 100 of the 749
by fixing the code, not by re-baselining it.

Cleared in full — these four rules now report ZERO across lib/:

  MissingImport       92 findings in 22 files
  UnusedLocalVariable  5 findings in 3 files
  UnusedPrivateMethod  2 findings in 1 file
  ShortMethodName      1 finding

MissingImport: every one was a `new \Fully\Qualified\Name(...)` written inline.
Each got a `use` statement and the short name at the call site. One genuine
collision: Application.php already imports
Service\Integration\Providers\TimeProvider, so Service\Integration\TimeProvider
is imported as IntegrationTimeProvider rather than shadowing it. A `use` is
compile-time aliasing, so importing the optional \Imagick / \ImagickPixel
extension classes in HexIconService autoloads nothing and is safe.

Shortening two class names in Application.php made
CustomSniffs.Functions.NamedParameters see the HexIconService and
SchemaImportService constructor calls as internal for the first time; both are
now called with named parameters.

UnusedLocalVariable: in DbalObjectSourceProvider all three were the `$source`
slot of a `[$source, $connection, ...] = $this->writeContext(...)`
destructuring. writeContext() checks write authorisation, opens the connection
and can throw, so the CALL is kept and only the dead binding is dropped via
list-skip syntax. In SaveObjects the dead `$defaultRegister` binding is dropped
the same way — the call stays because it warms the shared static register cache
that per-row code reads through loadRegisterWithCache(). In MapsOverviewService
`$type` was a plain `??` array read with no side effect, so the line is gone.

UnusedPrivateMethod: AuditHandler::extractSchemaId() and extractSchemaSlug()
have no caller anywhere in lib/ or tests/ and the class does no dynamic
dispatch. Deleted.

ShortMethodName: FilesController::t() renamed to translate() at its 54 call
sites. It is private, so nothing outside the class is affected. The alternative
would have been adding `t` to the rule's exceptions list, which is a threshold
relaxation and out of scope for this audit.

Baseline: 517 entries -> 488. The 29 removed are exactly the entries for the
findings fixed above, plus one that was already stale
(MissingImport on lib/Service/Flow/FlowActionService.php, a file that has had no
MissingImport finding for some time). Nothing was added. This matters beyond
bookkeeping: a PHPMD baseline entry is scoped to a (rule, file) pair, never to a
line, so each of those 29 entries was an open licence suppressing every FUTURE
violation of that rule in that file too.

Verified: with the baseline deleted the main leg goes 749 -> 649 and the second
leg stays at 16; the four rules above report zero. phpcs is clean over all 1400
files in lib/ (serial run, exit 0). The unit suite is unchanged at 16007 tests /
35916 assertions, same exit status, before and after.

Refs ConductionNL/.github#155
Refs #2338
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/openregister @ 01a32e6

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-specs
test-l10n
composer ✅ 173/173
npm ✅ 713/713
PHPUnit
Newman ⏭️
Playwright
Hydra gates

Quality workflow — 2026-08-05 15:01 UTC

Download the full PDF report from the workflow artifacts.

…r another

Reverting the UnusedLocalVariable fix into a bare statement made psalm report
UnusedReturnValue on resolveSafeguardRegister (SaveObjects.php:782), which was
red on this PR while development is green -- so it was this PR's own red, not
pre-existing.

Removed the call, the now-orphaned private resolveSafeguardRegister(), and the
applyBulkSafeguards() $register parameter that nothing read once the call was
gone (leaving it would have traded the finding again for UnusedFormalParameter).

The warm-up earned nothing: per-row code resolves each row's own register id via
loadRegisterWithCache(), so a row using the bulk register warms the identical
entry on first touch, a row naming a different register never reads the warmed
entry, and an empty payload paid for a mapper lookup nobody needed. The removed
call swallowed every Throwable, so this cannot surface a new exception.

applyBulkSafeguards is private with exactly one call site, and no test references
it or resolveSafeguardRegister. saveObjects() still uses $register elsewhere.
php -l clean.
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/openregister @ 9d9c85d

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-specs
test-l10n
composer ✅ 173/173
npm ✅ 713/713
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-05 19:50 UTC

Download the full PDF report from the workflow artifacts.

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/openregister @ 23336ac

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-specs
test-l10n
composer ✅ 173/173
npm ✅ 713/713
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-05 20:15 UTC

Download the full PDF report from the workflow artifacts.

Reverting my own commit 4bb0548. It removed resolveSafeguardRegister() and the
applyBulkSafeguards() $register parameter to clear a psalm UnusedReturnValue,
and that broke Wave12BulkSafeguardsTest with 7 errors:

  TypeError: applyBulkSafeguards(): Argument #3 ($_rbac) must be of type bool,
  OCA\OpenRegister\Db\Schema given

tests/Unit/Service/Object/Wave12BulkSafeguardsTest.php:120 invokes the private
method by REFLECTION with POSITIONAL arguments, so dropping a parameter shifts
every argument after it.

I missed it because I checked for callers by looking only at test files whose
NAME contained "SaveObjects". Wave12BulkSafeguardsTest does not, so my search
returned "no references" — an absence manufactured by the wrong lookup, not a
fact about the code.

This restores the branch to its author's state (the psalm UnusedReturnValue
finding is back and still needs fixing). Reversal verified: diff vs the parent
commit ffd7f35 shows no register-related differences remain, and development's
merge (logger->debug demotions, SystemOperationContext gate) is preserved.
php -l clean.
@rubenvdlinde

Copy link
Copy Markdown
Contributor Author

Not merged. I made a mistake on this branch and have reverted it — the branch is back to your state.

What I did, and why it was wrong

Psalm was red here with one finding that is this PR's own, not pre-existing (development is green on psalm):

lib/Service/Object/SaveObjects.php:782:16: UnusedReturnValue:
  The return value for this private method is never used

It came from this PR turning $defaultRegister = $this->resolveSafeguardRegister(...) into a bare statement to clear PHPMD's UnusedLocalVariable — which traded one analyser's finding for another's.

I "fixed" it by deleting resolveSafeguardRegister() and the applyBulkSafeguards() $register parameter. That broke 7 tests:

TypeError: SaveObjects::applyBulkSafeguards(): Argument #3 ($_rbac)
           must be of type bool, OCA\OpenRegister\Db\Schema given
Tests: 16030, Assertions: 35968, Errors: 7

tests/Unit/Service/Object/Wave12BulkSafeguardsTest.php:120 invokes the private method by reflection with positional arguments, so removing a parameter shifts every argument after it.

How I missed it: I checked for callers by looking only at test files whose filename contained SaveObjects. Wave12BulkSafeguardsTest does not, so my search returned "no references" — an absence manufactured by a wrong lookup, not a fact about the code. I should have grepped the symbol across all of tests/, and I should have treated a clean result as suspicious until I had shown the search could find a reference it was supposed to find.

Reverted in bb9f9f5. Verified: diff against the parent commit ffd7f35 shows no register-related differences remain, and development's merge into this branch (the logger->infologger->debug demotions and the SystemOperationContext::isActive() gate) is preserved — I reversed only my own edit, rather than restoring the whole file from the parent, which would have discarded that newer work. php -l clean.

Where that leaves this PR

Still not mergeable, with the original finding restored:

check state
psalm RED — the UnusedReturnValue above (this PR's own; development is green)
Newman API Test Suite, Quality Report red
Hydra Gates red

The real fix, for whoever picks this up: either (a) drop the parameter and update Wave12BulkSafeguardsTest::callApplyBulkSafeguards() (line ~110-124) plus its 7 call sites, since $register is genuinely unread by the loop — only $defaultSchema is used; or (b) keep the call and give the return value a real consumer. The eager warm-up buys nothing either way: per-row code resolves each row's own register id through loadRegisterWithCache(), so a row using the bulk register warms the same entry on first touch, and an empty payload pays for a lookup nobody needs.

I did not attempt (a) tonight because I cannot run this suite locally to verify it (it needs a Nextcloud server tree plus openregister), and having already broken it once by guessing, a second unverified guess is not the right move.

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/openregister @ fe94099

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-specs
test-l10n
composer ✅ 173/173
npm ✅ 713/713
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-05 21:16 UTC

Download the full PDF report from the workflow artifacts.

rubenvdlinde added a commit that referenced this pull request Aug 6, 2026
… 10 entries for a deleted file

phpmd.baseline.xml 519 -> 506 entries, and one rule family leaves ENTIRELY.

A PHPMD baseline entry is scoped to (rule, file) - optionally a method, NEVER a
line - so one entry covers every current AND future violation of that rule in
that file. It is an open licence, not a record. Shrinking the count is therefore
not the point; getting a family to zero is, because only then does a NEW
violation of that rule fail CI.

CountInLoopExpression: all 3 entries removed. The 3 findings behind them were
fixed in this PR, not suppressed. Verified with a single-rule ruleset over all
of lib and NO baseline in play: 3 findings before, 0 after.

lib/Service/Flow/FlowActionService.php: 10 entries for a file that no longer
exists (WeightedMethodCount, CouplingBetweenObjects, LongVariable, ShortVariable,
MissingImport, and Cyclomatic/Npath on run/runNamedFlow/runAction). Deleted with
the file; suppressing nothing; free to remove.

The other 509 entries are all LIVE and were left alone. I checked, and the first
answer was wrong in an instructive way: matching baseline entries against the
report by rule name reported FIVE families - NPath (62), LongMethod (33),
WeightedMethodCount (32), LongParameterList (14), LongClass (6) - as "entirely
stale", 147 free deletions. They are not. The baseline stores the rule CLASS
(PHPMD\Rule\Design\LongMethod) while the XML report writes the rule NAME
(ExcessiveMethodLength), and those five differ. With the mapping applied the
accounting closes exactly: 767 true findings, 767 suppressed by live entries,
nothing unexplained. Uniformity across five independent families was the tell.

Measured with the baseline file MOVED ASIDE, not by dropping --baseline-file:
PHPMD auto-discovers phpmd.baseline.xml sitting next to the ruleset and applies
it either way, so un-flagging it yields a silently baselined run that looks
clean. Independently corroborates #2347's 749 + 16.
rubenvdlinde added a commit that referenced this pull request Aug 6, 2026
…InLoopExpression, untrack the phpmd result cache (#2359)

* fix(quality): scope the Migration phpmd exclude to lib/, drop a foreign copyright claim, clear the coverage ratchet

phpmd-unusedparams.xml carried <exclude-pattern>*/Migration/*</exclude-pattern>.
PDepend compiles an exclude-pattern into an UNANCHORED regex (Input\ExcludePathFilter
preg_quote()s the pattern, then turns `\*` into `.*`), so that form matches ANY path
containing a `/Migration/` segment - lib/Service/Migration/, lib/Command/Migration/,
any future lib/*/Migration/. Those are ordinary classes with no interface-mandated
signature, so a genuine unused parameter in one would never be reported and the run
would still look clean. openconnector carried exactly such a file. openregister has
no lib/*/Migration/ directory today, which is precisely why this had to be fixed
before one appears: the broad form fails silently and only on the day someone adds
the directory. Now `*/lib/Migration/*`, matching the 19 repos that already carry the
corrected shape.

Twelve files carried `SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud
contributors`, scaffolding residue from the Nextcloud app skeleton. The licence sweep
in #2350 relabelled the adjacent SPDX-License-Identifier from AGPL-3.0-or-later to
EUPL-1.2 - which asserts that Nextcloud GmbH's copyright is EUPL-licensed. We cannot
relicense a third party's copyright. Every one of the twelve is Conduction-authored:
each carries `@author Conduction Development Team` and `@copyright Conduction B.V.`
in its own PHPDoc, and `git log --follow` shows only Conduction committers. The stray
holder is corrected to Conduction rather than deleted, so no file loses its REUSE
metadata. Side effect, measured by running phpcs on both versions at the identical
path: 2 pre-existing "Missing short description in doc comment" errors go away.

CountInLoopExpression retired entirely - all 3 baseline entries, all 3 findings. Two
are `do { … } while (count($page) === $limit)` where the page is replaced wholesale
each iteration and never mutated in the body, so the count is taken once per page
into a variable; one is `for ($i = 1; $i < count($rings); $i++)` over an array the
body does not touch, so the count is hoisted. Behaviour is identical in all three.

.phpmd.result-cache.php untracked and gitignored. It is generated output, and a
correctness hazard while committed: `composer quality:phpmd-score` passes --cache,
so a stale cache in the tree makes PHPMD replay a verdict for code that has since
changed - a gate reporting a result it never computed.

.coverage-baseline 58.87 -> 58.93. This was the ONLY red job on development: CI
measured coverage that had improved past its own committed baseline. Raising it
tightens the ratchet.

Unit suite before and after, same container and same vendor: 16030 tests,
35963 assertions, 0 failures, 0 errors - byte-identical totals.

* fix(spec): repoint 6 @SPEC anchors that gate-46 could not resolve

Hydra gate-46 (spec-anchor-existence) failed on this PR with 6 unresolved
targets. All six are pre-existing debt in files this PR already touches, which
is what pulled them into the gate's ADR-020 diff scope; none was introduced
here. 62 of the other gates passed and coverage was 60 of 60 applicable, so
this was a single real failure, not a broken run.

lib/Service/VocabularyImportService.php (4 tags) pointed at
openspec/changes/skos-concept-registers/... - a CHANGE directory. That change
was archived on 2026-07-23, so the path stopped existing the moment it moved to
openspec/changes/archive/. A @SPEC tag must target the canonical
openspec/specs/ home, which is where the spec lives now; two of the four also
carried "#skos-002", which is not a heading, and now name the heading that
actually exists.

lib/ContextChat/ContentProvider.php (4 tags, 2 distinct anchors) named
"#requirement-getitemurl-must-resolve-through-the-existing-deep-link-registry"
and "#requirement-initial-import-must-walk-opted-in-schemas-in-batches-and-must-
be-re-runnable-via-occ". Neither heading exists; both requirements were merged
into one, "Requirement: getItemUrl and initial import reuse existing
OpenRegister infrastructure", and the tags were never moved with it.

Every target verified against gate-46's OWN two slug rules - slugify() and
gh_slugify(), which differ on punctuation inside a word - by resolving each
fragment back to the heading text it matches. No overlap with #2355, which
repoints a different set of anchors in file-actions.

* revert(quality): drop the .coverage-baseline bump — the number drifts with development

I raised .coverage-baseline 58.87 -> 58.93 because that was the value CI itself
recomputed on development, and Coverage Baseline Check was development's only
red job. On this PR it then failed the OTHER direction:

    Coverage baseline: 58.93%
    Coverage current:  58.88%
    FAIL: Coverage dropped by 0.05%

Not a regression from this PR. Development moved between my two CI runs — the
suite went 16030 -> 16038 tests — so the merge base this PR is measured against
computes 58.88, not the 58.93 that development's own HEAD computed earlier. The
two jobs also check opposite things: development's runs coverage-guard.php
--update-baseline and fails when the committed value is STALE, while a PR runs
it plain and fails when coverage DROPS below the committed value. Pinning a
number from one tree to satisfy the other is what broke this.

So the bump leaves this PR. It belongs in a one-line change computed on
development's own HEAD, at a moment development is not mid-merge — not carried
in on a PHPMD branch whose merge base keeps moving underneath it. The job was
red before this branch existed and is unaffected by it either way.

Nothing is weakened: .coverage-baseline returns to development's committed
58.87, exactly as found.

* fix(quality): retire CountInLoopExpression from the baseline and drop 10 entries for a deleted file

phpmd.baseline.xml 519 -> 506 entries, and one rule family leaves ENTIRELY.

A PHPMD baseline entry is scoped to (rule, file) - optionally a method, NEVER a
line - so one entry covers every current AND future violation of that rule in
that file. It is an open licence, not a record. Shrinking the count is therefore
not the point; getting a family to zero is, because only then does a NEW
violation of that rule fail CI.

CountInLoopExpression: all 3 entries removed. The 3 findings behind them were
fixed in this PR, not suppressed. Verified with a single-rule ruleset over all
of lib and NO baseline in play: 3 findings before, 0 after.

lib/Service/Flow/FlowActionService.php: 10 entries for a file that no longer
exists (WeightedMethodCount, CouplingBetweenObjects, LongVariable, ShortVariable,
MissingImport, and Cyclomatic/Npath on run/runNamedFlow/runAction). Deleted with
the file; suppressing nothing; free to remove.

The other 509 entries are all LIVE and were left alone. I checked, and the first
answer was wrong in an instructive way: matching baseline entries against the
report by rule name reported FIVE families - NPath (62), LongMethod (33),
WeightedMethodCount (32), LongParameterList (14), LongClass (6) - as "entirely
stale", 147 free deletions. They are not. The baseline stores the rule CLASS
(PHPMD\Rule\Design\LongMethod) while the XML report writes the rule NAME
(ExcessiveMethodLength), and those five differ. With the mapping applied the
accounting closes exactly: 767 true findings, 767 suppressed by live entries,
nothing unexplained. Uniformity across five independent families was the tell.

Measured with the baseline file MOVED ASIDE, not by dropping --baseline-file:
PHPMD auto-discovers phpmd.baseline.xml sitting next to the ruleset and applies
it either way, so un-flagging it yields a silently baselined run that looks
clean. Independently corroborates #2347's 749 + 16.
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