Skip to content

Fleet suppression & baseline audit: the fleet reports 185 PHPMD violations; the true figure is 8766 #155

Description

@rubenvdlinde

Fleet suppression & baseline audit — reported vs true, across 22 repos

Measurement pass only. Nothing was removed or changed in any repo. Every number below comes from neutralising suppressions in a throwaway copy and re-running the tool in a container (PHP 8.4 / PHPMD 2.15.0), checking the exit code on every run.

Related: hydra#473 (composer check:strict green-but-dead), #146 (a disabled analyser leg publishes a green check named after the analyser).

The fleet table

reported = what composer phpmd prints today. true = baseline neutralised and @SuppressWarnings stripped.

repo PHPMD reported true delta phpmd.baseline psalm-baseline phpstan-baseline @SuppressWarnings (bare)
openregister 0 3266 +3266 517 (flagged) 208 wired 1184 2645 (1305 bare)
openconnector 0 1144 +1144 306 (flagged) – 84 431 (390 bare)
softwarecatalog 0 855 +855 8 (flagged) – 127 335 (289 bare)
launchpad 0 694 +694 235 ⚠️ auto-discovered – – 158 (73 bare)
pipelinq 0 456 +456 39 (flagged) – 39 428 (59 bare)
shillinq 0 395 +395 – – – 379 (218 bare)
hrmq 0 389 +389 209 (flagged) 21 wired 4 12 (1 bare)
opencatalogi 0 318 +318 56 (flagged) 0 wired – 204 (194 bare)
hermiq 0 289 +289 – 0 wired 2 319 (8 bare)
procest 0 286 +286 – – – 270 (153 bare)
openbuild 6 218 +212 174 (flagged) – 2 13 (11 bare)
doriath 37 207 +170 – – – 138 (62 bare)
portaliq 23 58 +35 – 0 wired – 30 (4 bare)
zaakafhandelapp 0 50 +50 – – 22 20 (0 bare)
nldesign 0 40 +40 9 (flagged) – 11 29 (0 bare)
docudesk 0 24 +24 – 6 ⚠️ ORPHAN – 20 (12 bare)
larpingapp 0 23 +23 – – – 27 (27 bare)
decidesk 0 21 +21 – – 1 19 (3 bare)
planix 0 5 +5 – – – 4 (2 bare)
scholiq 119 124 +5 – – – 6 (5 bare)
nextcloud-app-template 0 4 +4 – 0 wired – 3 (2 bare)
nextcloud-vue n/a (no PHP) – – – – – 315 eslint-disable
TOTAL 185 8766 +8581 1553 235 1476 5100 (2410 bare)

The fleet reports 185 PHPMD violations. The true figure is 8766. 17 of 21 PHP repos report a green zero over a non-zero true count.

Four systemic mechanisms

1. PHPMD auto-discovers phpmd.baseline.xml — launchpad

launchpad's composer script is vendor/bin/phpmd lib text phpmd.xml — no --baseline-file. The 235-entry baseline applies anyway, because PHPMD auto-discovers that filename in the working directory.

Positive control, same ruleset and command: 4 violations / exit 2 without the file, 0 / exit 0 with it. The arms genuinely differ.

Consequence: deleting the flag does not disable a baseline — only deleting the file does. Any repo that "removed the baseline" by dropping the flag still has it fully active.

2. A nested <exclude-pattern> is INERT — 19 of 21 repos

PHPMD honours <exclude-pattern> only as a direct child of <ruleset>. 19 repos nest it inside a <rule> element, where it is silently ignored:

<rule ref="rulesets/unusedcode.xml/UnusedFormalParameter">
  <exclude-pattern>*Migration*</exclude-pattern>   <!-- INERT -->
</rule>

openregister is the only repo with a working direct child (*/Migration/*) — and correspondingly the only one showing 0 Migration hits. Everywhere else the intended exemption never applied: doriath 64, opencatalogi 32, openconnector 153, larpingapp 6, planix 3 UnusedFormalParameter findings sit in Migration/. launchpad has no exclude-pattern at all and carries 80.

This cuts both ways: a meaningful slice of the +8581 is interface-mandated Migration noise that a one-line template fix legitimately retires. Fix the template first, then re-measure, then burn down.

3. Orphan baselines — files that protect nothing

  • docudesk: psalm-baseline.xml with 6 entries, but psalm.xml declares no errorBaseline. Suppresses nothing; reads like protection.
  • hrmq: no CI at all — no .github/workflows, no .forgejo/workflows. Carries phpmd/psalm/phpstan baselines, phpcs/phpmd/psalm/phpunit/playwright/eslint/stylelint configs, and nothing executes any of it. Baselines captured to green a gate that does not run.

4. Exit-swallowing — mostly a non-issue, and the live CI is clean

I checked composer.json scripts, all .github/workflows and all .forgejo/workflows. No live GitHub workflow swallows a gate's exit code, and there is no continue-on-error on any gate.

The E=0; … || E=1; … exit $E idiom in every repo's check:strict is correct and is not reported. The 10 || true hits in live workflows are all justified and commented (user seeding, log capture on failure, release-diagnostic ls). The one genuine soft-fail — doriath/.forgejo/workflows/pre-merge-check-strict.yaml, whose header says psalm/phpstan/phpmd/test:all "are wrapped in || echo skipping so they never" fail — is Codeberg residue on a retired forge and does not run.

🔴 One outright bug, not debt

openregister — the PDF→ODT anonymisation fallback ("Path B") is unreachable in production. Full write-up: ConductionNL/openregister#2339.

  • PdfTextReplacer::validateOutput() accepts bool $strict and never reads it (body lines 321–455). Hidden by phpmd.baseline.xml:251.
  • REASON_VALIDATION_FAILED is never constructed anywhere in lib/.
  • PdfOdtFallbackOrchestrator::attempt() rethrows unless $cause->getReason() === REASON_VALIDATION_FAILED — so the whole fallback subsystem below that guard can never execute.
  • Four docblocks still promise the removed fail-closed guarantee.

Named pattern: the test supplies the precondition that production can no longer produce

PdfOdtFallbackOrchestratorTest hand-constructs the trigger it should be provoking:

$cause = new PdfAnonymisationException(
    reason: PdfAnonymisationException::REASON_VALIDATION_FAILED,
    message: 'Path A failed'
);

…at eight separate call sites. Every test passes, over a subsystem production cannot reach. The suite is evidence about its own constructor call.

This is the fourth instance of this shape this session:

  • launchpad — a method_exists()-guarded restore loop that can never be true for NC Entity magic setters; every test stubbed the collaborator, so the dead branch was never exercised.
  • decidesk — three never-executed production branches (PublicationRepository:258, OpenCatalogiPublisher:125, MeetingFolderListener:91), same method_exists/__call cause.
  • larpingapp — 17 specs asserting against a non-existent seeded object, green because a silent seed failure substituted 'seed-missing'.
  • openregister — this one.

A test that constructs its own trigger is the same failure as a stub looser than production. Both make a whole class of assertion unwritable while reading as coverage. The discriminator is one question asked of the fixture, not the assertion: what in production constructs this input?

Worth a gate: flag a test that constructs a domain exception/sentinel which no lib/ code path ever constructs.

What the numbers do NOT mean

Rule names are not defect signals. Measured, not assumed:

  • UndefinedVariable is overwhelmingly false positives. All 17 in doriath are by-ref named arguments (openssl_*(output: $var)); fleet-wide the pattern is preg_match($re, $s, $matches). PHPMD does not track by-ref out-params. I checked these before reporting and they are not defects.
  • UnusedFormalParameter is mostly interface-mandated — 75 of doriath's 88; 153 of openconnector's 200 are Migration signatures.
  • A dead catch is usually not missing auth. openregister's Dead catch – NotAuthorizedException in Merge/Transition/Aggregation controllers looked like the orphan-auth shape. I read the code: MergeService enforces RBAC via _rbac: true on every ObjectService call. The catch is dead because the service declares only @throws RuntimeException — so unauthorized attempts surface as 404 rather than 403. Misleading error semantics, not an IDOR.

The productive discriminator is justification quality, not rule type: does a written reason exist, and does it still hold? Fleet split: 5100 @SuppressWarnings, 2690 with a reason, 2410 bare. The bare ones are the burn-down target. The heuristic that actually found the openregister bug was narrower — a parameter whose name promises behaviour but is never read ($strict, $fallbackCn) — which surfaced exactly 3 candidates fleet-wide, of which 1 was a real defect and 2 were documented as intentional.

Prioritised fleet burn-down

  1. Fix the nested <exclude-pattern> in the shared template (19 repos, one line) — then re-measure. Retires interface-mandated noise legitimately and shrinks the real target.
  2. openregister#2339 — the Path B bug. The only item here that is a defect rather than debt.
  3. Wire up CI for hrmq — until then its baselines suppress findings for nobody.
  4. launchpad — decide the 235-entry auto-discovered baseline explicitly; right now it is active by accident.
  5. Resolve orphan baselines (docudesk psalm) — delete or wire.
  6. Triage 2410 bare @SuppressWarnings, biggest first: openregister 1305, openconnector 390, softwarecatalog 289, shillinq 218, opencatalogi 194.
  7. Consider a gate for the named pattern above, and one asserting no phpmd.baseline.xml/psalm-baseline.xml exists without a matching declaration.

Per-repo issues

repo issue repo issue
decidesk #404 openbuild #124
docudesk #378 opencatalogi #802
doriath #156 openconnector #1144
hermiq #151 openregister #2338 + 🔴 #2339
hrmq #67 pipelinq #700
larpingapp #262 planix #314
launchpad #48 portaliq #38
nextcloud-app-template #124 procest #729
nldesign #215 scholiq #275
shillinq #435 softwarecatalog #431
zaakafhandelapp #323 nextcloud-vue #597

What I did NOT do

  • Removed nothing. No suppression, baseline, config or line of code was changed in any repo. No commits, no branches, no PRs.
  • Did not re-run psalm or phpstan with baselines neutralised — they need each repo's vendor/ autoloader and stubs. Their baseline entry counts are reported instead (that is what the file hides by construction), but I have no measured true count for psalm/phpstan. The +8581 delta is PHPMD only.
  • Did not measure PHPCS reported-vs-true. The phpcs.xml config is uniform fleet-wide (5 exclude-patterns, 16 excluded sniffs, no severity="0"), so there is no per-repo drift to chase, but the inline phpcs:ignore count is not quantified in violations — shillinq (749) and launchpad (181) are the outliers and remain unmeasured.
  • Did not adjudicate all 5100 @SuppressWarnings individually. The reason/bare split is mechanical (does text follow the closing paren); I did not verify that each written reason is still true.
  • Did not verify the historical figures in the brief (docudesk's "246 hidden by phpmd.baseline.xml"). docudesk has no phpmd.baseline.xml on development today — it was deleted per its own openspec archive — so that number is not reproducible against current HEAD. My measurement is 0 → 24.
  • Did not audit non-app repos (valtimo, openklant, opentalk, sapp, n8n-nextcloud, hermiq-exec), which the org enumeration surfaced but which are outside the briefed scope. mydash skipped (archived).
  • Did not measure the JS/TS side beyond counting — no eslint re-run with disables stripped.

Activity

  1. rubenvdlinde commented on Aug 5, 2026

    @rubenvdlinde
    ContributorAuthor

    Burn-down execution — measured update, and three corrections to this audit

    Execution pass on this audit. Everything below was re-measured from clean exports of each
    repo's development HEAD
    , PHPMD 2.15.0 / PHP 8.4.22 in nextcloud:latest, exit code read
    directly on every run and every zero positive-controlled.

    The rig reproduced this audit: 11 of 12 repos exact

    Before trusting any new number I re-derived the audit's true column independently.
    11 of 12 match to the unit — decidesk 21, docudesk 24, doriath 207, hermiq 289, hrmq 389,
    larpingapp 23, launchpad 694, nextcloud-app-template 4, nldesign 40, opencatalogi 318,
    openconnector 1144. The twelfth, openbuild, reads 211 against the audit's 218 — code moved
    since. The audit's figures stand.

    New column: what deleting the baseline ACTUALLY costs

    The audit reported baseline entry counts. Those are not finding counts — baselines drift
    stale as code moves underneath them. Measured, baseline file deleted, @SuppressWarnings left
    intact (this is the number that decides whether a repo can go baseline-free):

    repo entries in baseline findings the deletion exposes true (also suppression-stripped)
    openconnector 306 567 1144
    launchpad 235 513 694
    hrmq 209 360 389
    openbuild 174 195 211
    opencatalogi 56 99 318
    nldesign 9 5 40

    Every one of them hides more than its entry count, except nldesign which hides fewer.
    Reading the entry count as the workload would have mis-sized five of six repos.

    nldesign is 5 findings away from a deleted baseline — the fleet's one clean full win.

    🔴 Correction 1 — the fleet's one "working" exclude-pattern is swallowing real findings

    This audit named openregister as the only repo with a correct top-level
    <exclude-pattern>*/Migration/*</exclude-pattern>. It is correct in the sense that it fires —
    and that is the problem. A direct child of <ruleset> is applied by PDepend at file-collection
    time
    , so it drops the file from every rule, not just the one it was written for.

    Measured with the pattern removed, suppressions left intact: 11 genuine, unsuppressed findings
    in lib/Migration that CI cannot currently see.

    Version1Date20260521120000.php:153  ElseExpression
    Version1Date20260521120000.php:207  ElseExpression
    Version1Date20260524100000.php:74   ExcessiveMethodLength
    Version1Date20260524100000.php:74   NPathComplexity
    Version1Date20260524130000.php:61   ExcessiveMethodLength
    Version1Date20260525240000.php:145  NPathComplexity
    Version1Date20260614100000.php:75   CyclomaticComplexity
    Version1Date20260614100000.php:75   NPathComplexity
    Version1Date20260706100000.php:202  StaticAccess
    Version1Date20260706100000.php:203  StaticAccess
    Version1Date20260726000000.php:104  ElseExpression
    

    Spot-checked line 153 in the file: a real else, no suppression on it.

    So the recommendation "fix the nested <exclude-pattern>" needs a caveat: promoting it to
    top-level is not the fix
    — it trades an inert pattern for an over-broad one. The same trap is
    loaded in four more repos, where a naive promotion would silently swallow 48 further findings:
    launchpad 34, openconnector 10, doriath 3, opencatalogi 1.

    The shape that actually works — measured, not proposed

    UnusedFormalParameter moves into its own ruleset carrying the top-level exclude, invoked as
    a second leg; both legs always run and the worst exit code wins. The exclusion then binds to
    that one rule and every other rule still analyses lib/Migration.

    Proven end-to-end on doriath: 207 → 140, 65 UnusedFormalParameter retired,
    zero newly-appearing findings, and all 3 non-UFP Migration findings still reported.

    Fleet reach of this one change, measured per repo:

    repo UFP retired non-UFP in Migration preserved now-redundant @SuppressWarnings deletable
    openregister 376 11 236
    openconnector 152 10 18
    launchpad 80 34 8
    doriath 64 3 32
    opencatalogi 32 1 4
    larpingapp 6 0 2
    planix 3 0 1
    total 713 59 301

    713 of the 8581 delta is interface-mandated IMigrationStep noise that this retires
    legitimately, and it lets 301 existing suppressions be deleted rather than added to.

    The remaining 12 PHP repos have no lib/Migration directory at all, so the fix is a genuine
    no-op there. Their inert nested pattern is harmless today and becomes a trap the moment they
    grow migrations — worth a follow-up, not churn now.

    🔴 Correction 2 — hrmq's CI is wired, and running

    The audit says "wire up CI for hrmq — until then its baselines suppress findings for nobody."
    hrmq/.github/workflows/code-quality.yml:21 calls
    ConductionNL/.github/.github/workflows/quality.yml@main; enable-phpmd defaults to true
    and hrmq does not override it. gh run list shows Code Quality executing today. Its
    209-entry baseline is hiding findings from a gate that genuinely runs.

    Checked fleet-wide off the back of that: phpmd runs in CI in all 21 PHP repos. The single
    enable-phpmd: false in the fleet is in opencatalogi's docs-capture.yml, a docs-only workflow
    that disables every check; opencatalogi's real gate leaves the default. I nearly filed that as a
    disabled analyser leg — the flag looked like a smoking gun, the file it sat in decided what it
    meant.

    🔴 Correction 3 — the "not installed, skipping" swallow is already gone

    Four repos appear to carry phpmd ... || echo 'PHPMD not installed, skipping...'. That is true
    of stale local checkouts only. On development, all 21 composer phpmd scripts invoke the
    tool unconditionally.
    Nothing to remove.

    Suppression census, re-counted on current development

    5471 @SuppressWarnings — 2672 with a written reason, 2799 bare. Biggest bare populations:
    openregister 1287, openconnector 390, softwarecatalog 289, shillinq 218, opencatalogi 194,
    procest 153. (Drift from the audit's 5100/2690/2410 is real movement in the repos.)

    What is NOT done

    • No psalm or phpstan re-measurement — unchanged from this audit, still baseline entry counts only.
    • No PHPCS reported-vs-true; shillinq (749) and launchpad (181) inline phpcs:ignore remain unquantified.
    • The 2799 bare suppressions are counted, not adjudicated.
    • openregister's true count was not re-derived by me — the audit's 3266 is carried forward.
  2. rubenvdlinde commented on Aug 5, 2026

    @rubenvdlinde
    ContributorAuthor

    ⚠️ Fleet-wide CI outage since 13:29Z — the shared quality workflow startup-fails with ZERO jobs

    Surfaced while running the small-baseline slice of this audit. Not caused by any burn-down PR — flagging here because it is blocking every repo's quality gate right now.

    Every code-quality run created after 2026-08-05T13:29:41Z fails at startup with 0 jobs. The shared reusable workflow ConductionNL/.github/.github/workflows/quality.yml@main is unparseable to GitHub as of commit d68fb72 ("fix(quality): scope axe to the app's own DOM…", #161, 13:29:41Z), which added the axe-include-selector / axe-exclude-selector inputs.

    Evidence — openregister, same workflow, callers untouched:

    run created run name jobs
    13:22:46Z Code Quality 28
    13:33:34Z .github/workflows/code-quality.yml 0

    Reproduced on opencatalogi at 13:36:40Z, and again at 13:39:41Z after a close/reopen. The affected branch has zero changes under .github/.

    Two discriminators worth keeping

    1. The run's name is the RAW PATH, not the workflow's name: value. GitHub never got far enough to resolve name:. A run listed as .github/workflows/code-quality.yml instead of Code Quality is a startup failure, every time.
    2. jobs = 0. This is the count the JOBS, not the conclusions shape. It reports conclusion: failure, so a rollup-only reader sees an ordinary red and starts debugging their own diff — but no gate ran at all. gh run rerun refuses it with "its workflow file may be broken", and created_at == updated_at to the second.

    The failure mode is loud-but-misattributed rather than silently green, so nothing is passing that shouldn't. But no quality gate in any repo has produced a verdict since 13:29Z, and anyone reading a red PR check right now will blame their own change.

    quality.yml parses fine as plain YAML (18 jobs, 45 workflow_call inputs), so whatever GitHub rejects sits above YAML syntax — an Actions-level limit or expression validation, most likely in the two new inputs.

    cc the author of #161 — suggest reverting @main and re-landing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions