fix(quality): phpcs errors must fail the gate; warnings still pass [DRAFT — do not merge] - #483
Draft
rubenvdlinde wants to merge 2 commits into
Draft
fix(quality): phpcs errors must fail the gate; warnings still pass [DRAFT — do not merge]#483rubenvdlinde wants to merge 2 commits into
rubenvdlinde wants to merge 2 commits 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
Merged
This was referenced Aug 19, 2026
Merged
rubenvdlinde
added a commit
that referenced
this pull request
Aug 19, 2026
…ss (#484) A required sibling app that fails to install currently emits a warning and the run continues WITHOUT the dependency the repository under test is built on. The absence then resurfaces as application failures, which is indistinguishable from the app itself being broken — and that is the expensive part: the run reports a defect in the wrong place. Approved by Ruben 2026-08-19. Unlike the companion phpcs PR (#483) this has no debt precondition: it turns no existing green cell red, it only stops a FAILED step from reporting success.
rubenvdlinde
added a commit
to ConductionNL/nldesign
that referenced
this pull request
Aug 19, 2026
…n) (#360) Green on the live run 32275687077: 36 jobs, 32 success, 0 failures. The PR also carries a FAILURE row for 'quality / Quality Report' from run 32275680508 — a run started 4 seconds earlier on the same commit and cancelled by concurrency. Its E2E, PHPUnit and Hydra Gates jobs were all cancelled, and the report correctly refused to pass on absent verdicts rather than guessing. That is the aggregator working, not a defect in this change; the row is a stale artefact of the superseded run. Precondition for ConductionNL/.github#483. Measured with a control: before, A TOTAL OF 1 ERROR AND 0 WARNINGS; after, phpcs exits 0 having scanned 63/63 files.
rubenvdlinde
added a commit
to ConductionNL/openconnector
that referenced
this pull request
Aug 19, 2026
…gs) (#1295) Fully green: 37 jobs, 33 success, 4 skipped, 0 failures — including phpcs, E2E and Hydra Gates. Precondition for ConductionNL/.github#483. All five were l10n->t() help strings on one line at 155-189 chars against a 150 limit, now split with concatenation inside the t() call — the convention this repo already uses in SourcePaginateNode.php. A translatable string is a KEY, not just text: splitting one is only safe if the pieces rejoin byte-identically, or every existing translation for that msgid silently stops matching. Verified by evaluating the new concatenated literals in PHP against the originals from git HEAD — all 5 byte-identical.
rubenvdlinde
added a commit
to ConductionNL/scholiq
that referenced
this pull request
Aug 19, 2026
…450) Green: 35 jobs, 30 success, 5 skipped, 0 failures — phpcs, the stable34 PHPUnit cell, E2E and Hydra Gates all pass. Precondition for ConductionNL/.github#483. All ten were formatting: eight were @return docblock continuation lines padded to align under a very long type (159-202 chars against a 150 limit), two were missing @PARAM lines for constructor arguments. Measured with a control on the same tree: 10 ERRORS AND 110 WARNINGS before, 0 ERRORS AND 110 WARNINGS after — the warning count identical on purpose. Also carries an ADR-078 justification for AssessmentDrawResolver. gate-61 is diff-scoped and pulls the fleet along one listener at a time, so touching that file's docblock brought its registration into scope. Deferral is the gate's preferred answer and is wrong here: the handler writes drawnItemRefs, the item set the learner is about to be served, and TakeAssessmentView.vue POSTs the AssessmentResult then reads it straight back — deferring would serve an assessment with no questions. Hence , with the reason carried rather than a bare marker. The one intermediate failure was a GitHub API 504 half-installing the sibling app openregister; .github#484 correctly refused to continue rather than blaming scholiq for openregister's missing classes. Verified the 504 had cleared (three fetches of that zipball, all HTTP 200) before re-running.
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.