fix(quality): phpcs errors must fail the gate; warnings still pass [DRAFT — do not merge] - #483
Draft
rubenvdlinde wants to merge 1 commit into
Draft
fix(quality): phpcs errors must fail the gate; warnings still pass [DRAFT — do not merge]#483rubenvdlinde wants to merge 1 commit into
rubenvdlinde wants to merge 1 commit into
Conversation
The phpcs case asserted "Exit code 0 = clean, 1 = warnings only, 2 = errors
found" and mapped exit 1 to success on that basis. The assumption is false
for this fleet's phpcs configuration, so the gate has been passing real
errors fleet-wide.
Measured, not inferred. php_codesniffer 3.13.6 `Runner::runPHPCS()` with the
shared hydra-gates ruleset (which sets `ignore_warnings_on_exit=1`):
0 no ERRORS — warnings never reach the exit code
1 ERRORS present, none phpcbf-fixable
2 ERRORS present, at least one phpcbf-fixable
3 phpcs could not run
Exit 1 never meant "warnings only". It meant "errors, none auto-fixable".
The failure is near-total rather than occasional because the shared ruleset
deliberately removed every auto-fixable formatting sniff, which makes exit 2
nearly unreachable and exit 1 the ordinary way for errors to present. Two
individually reasonable decisions that are jointly catastrophic.
Measured across all 18 core repos on `development`, 2026-08-17: 12 repos
reporting `success` while carrying 707 phpcs errors between them. All 12
returned exit 1; all 6 genuinely clean repos returned 0; no repo returned 2.
The special case is not merely wrong, it is unnecessary: phpcs already
returns 0 for warnings, so passing the code straight through both fixes the
hole and simplifies the block. `--runtime-set ignore_warnings_on_exit 1` is
appended so "warnings do not fail the build" holds by construction at the
gate rather than resting on one line in one vendored ruleset.
Behaviour change is exactly one cell (verified with a shell control against
both the old and new logic):
phpcs exit | old step exit | new step exit
0 | 0 | 0
1 | 0 <— | 1 <—
2 | 2 | 2
3 | 3 | 3
DRAFT — do not merge. `.github@main` is consumed live by every repo, so
merging flips enforcement fleet-wide instantly. It must land only after the
707-error debt is cleared, and the flip is Ruben's call.
This was referenced Aug 17, 2026
Draft
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.
🚧 DRAFT — DO NOT MERGE
.github@mainis consumed live by every repo in the fleet, so merging this flips phpcs enforcement everywhere the instant it lands. It must not merge until the existing error debt is cleared, and the flip is Ruben's call. Debt-clearing PRs are listed at the bottom.The defect
.github/workflows/quality.yml, thephpcscase:That comment is false for this fleet's phpcs configuration. Established empirically — by reading
php_codesniffer3.13.6's ownRunner.phpand by running the real thing in a PHP 8.3 container, not from docs:Runner::run()computes$return = totalErrors + totalWarnings, then subtracts warnings because the shared hydra-gates ruleset sets<config name="ignore_warnings_on_exit" value="1"/>.Runner::runPHPCS()then maps that count:Exit 1 never meant "warnings only". It meant "errors, none auto-fixable" — and the gate mapped every one of them to success.
Why it is near-total rather than occasional
The shared ruleset deliberately removed every auto-fixable formatting sniff (its own header documents why: 111,747 of 111,932 findings were the ruleset arguing with php-cs-fixer). That makes exit 2 nearly unreachable, so exit 1 becomes the ordinary way for errors to present — and the special case swallows essentially the entire fleet's error debt.
Two individually reasonable decisions — purge fixable sniffs; treat warnings as non-blocking — that are jointly catastrophic. Confirmed by the data below: no repo returned exit 2. Not one.
Measurement — all 18 core repos,
development, 2026-08-17Method: latest
quality / PHP Quality (phpcs)check-run per repo, restricted to push-event check suites (the standingdevelopment → betaPR attaches rows to the same sha). Job log read via the API, Actions' echoed step source stripped first (^\S+ \x1b\[36;1m), then the authoritative per-fileFOUND n ERRORS AND m WARNINGSsummary lines summed.707 errors across 12 repos, every one of them reported green.
The last column is the control: the "passing" line appears exactly when errors exist and never when they don't — 12/12 and 6/6, no exceptions. That correlation is what proves the mechanism rather than merely correlating with it.
Exit-code semantics, measured directly
phpcsexitThe third row is a positive control proving exit 2 is reachable at all — otherwise "no repo returned 2" would be unfalsifiable.
Fixablecount on all 12 affected repos: 0.The fix
Pass the exit code straight through. Warnings staying non-blocking needs no special case, because phpcs already returns 0 for them — so this is simultaneously the correct rule and the simpler one.
--runtime-set ignore_warnings_on_exit 1is appended so that "warnings do not fail the build" holds by construction at the gate rather than by inheritance. Today it rests on one line in one vendored ruleset: all 18 repos referencevendor/conduction/hydra-gates/quality-config/phpcs.xmland none sets the flag itself — too thin a thread to hang an enforcement flip on. (Verified that composer forwards-- <args>to the script's last command, and that the flag leaves both fixtures' verdicts unchanged.)Behaviour change is exactly one cell
Verified with a shell control run against both the old and the new logic:
phpcsexitErrors now fail. Warnings still pass. Nothing else moves.
Merge order
This must land after the debt is clear. Debt-clearing PRs (one per repo, base
development, errors only — warnings deliberately untouched):Recommended: re-run the 18-repo sweep immediately before merging and confirm all 18 read 0 errors. A repo that regains an error between now and then would go red on its next push.
This is one of three defects of the same shape in this file
phpcscasecomposer install … 2>/dev/null || trueapp:enable … || echo "::warning::…"Three places where the shared workflow reports success over a failure it had already observed.
This PR is the only one of the three with a debt precondition. #484 and #355 do not turn any existing green cell red — they only stop a failed step from reporting success, so they can be sequenced independently and sooner. This one flips enforcement and must wait for the 707 errors to clear.
Found while clearing hermiq's 16 errors: the fix went green on phpcs and red on the coverage ratchet, from the guard step that runs after a fully passing suite (
OK (1738 tests, 5376 assertions)).+14 statements, only +6 covered. All 8 uncovered ones were measured with local pcov against the merge base, and each is a mechanical consequence of a sniff this gate will make mandatory:
CustomSniffs.Functions.NamedParametersifSquiz.PHP.DisallowInlineIfThe first row is the one to note: a pure formatting change multiplied one uncovered statement by seven. Statement count is not invariant under reformatting. And 396 of the 707 fleet-wide errors are the named-parameters sniff — every one is a call site that may go multi-line.
This is a third inversion of the ratchet alongside deletions and decomposition (DECISION-1,
.github#480): it now also taxes phpcs remediation.So the two gates are in direct opposition, and this PR makes one of them mandatory. Recommended before un-drafting:
.github#480's asymmetric rule normalises statements added by reformatting, not only deletion/decomposition — otherwise a repo can be unable to satisfy both gates at once.hermiq was resolved without filler tests — the coverage was bought back with one real test of a genuinely untested safety property. That is the pattern to copy, but it is effort the flip should budget for rather than discover.
✅ THE DEBT IS CLEARED — complete 18-repo table
All 12 affected repos now have an open PR against
developmenttaking them to 0 phpcs errors. Every "after" figure below is verified by re-reading that PR's own CI log with the same instrument used for the baseline sweep — not from any implementer's self-report.maindevelopmentdevelopmentmainmainmaindevelopmentdevelopmentdevelopmentdevelopmentmainmainmainmainmaindevelopmentdevelopmentdevelopment707 → 0.
The control holds in both directions. The
::warning::PHPCS found warnings but no errorsline appeared on exactly the 12 repos with errors and none of the 6 without; it is now absent from all 12. A clean control that shows the marker appearing only when errors exist, and disappearing when they are fixed.How the debt was cleared
No
phpcbf, no--fix, anywhere. That is not merely policy — for these repos it was a no-op by construction: exit 1 meanstotalFixable === 0, so all 707 violations were non-auto-fixable. Also zerophpcs:ignore, zero<exclude>rules, zero ruleset or baseline edits, and no scripted code edits. Each PR carries a per-toolcheck:strictbaseline-vs-after and PHPUnit compared by failing-test name, not just totals.Error mix: 396
NamedParameters, 176MissingParamTag, 58WrongStyle, 32LineLength, ~28ParamNameNoMatch, 9DisallowInlineIf, plus a handful of others.Two things the cleanup discovered that bear on this PR
1. The
@speccoverage gate has been under-reporting. FixingWrongStyleremoved ~33SpecTagwarnings across the fleet — because a stray//comment between docblock and function was hiding real@spectags from the sniff. Proven at source (opencatalogi'sSearchController::index()carries two@spectags the sniff called missing), and confirmed by two independent instruments:grep -c '@spec'byte-identical before/after, and launchpad's separatelint:spec-annotationsscript agreeing. A comment in the wrong position made a different gate blind, and that gate reported the blindness as debt against the code.2.
cancelledis not a pass, and it nearly entered this table as one. Two PRs carriedcompleted/cancelledphpcs rows from push runs superseded when the PR opened. A cancelled job's log is truncated, so parsing it yields a zero error count indistinguishable from a clean run. Worse, a still-running row hascompleted_at = null, which sorts before a finished one — so "take the latest row" selected the stale run. Both holes were closed only because live data forced them.Recommended merge order
.github#480handles statements added by reformatting (see the sequencing risk above — 396 of 707 errors are the named-parameters sniff, whose fix can trip the coverage ratchet).Step 3 is not ceremony: a repo that regains a single error between now and the flip goes red on its next push.