fix(hydra-gates): a not-applicable gate is not a coverage gap — fix the accounting, then flip the flag - #164
Merged
Conversation
added 2 commits
August 5, 2026 15:33
…he accounting, then flip the flag
--require-full-coverage could not be switched on anywhere, and the reason was
the accounting, not the fleet. "Did not run" collapsed three different facts
into one and the flag failed on all three.
Measured before the change, on a Tier-0 fixture with nothing wrong with it: 30
of 63 gates emitted NOTHING AT ALL — no line, no reason, no trace — and the flag
exited 98. Twenty-five of those thirty were silent only because they are wrapped
in a bare `if [ -d src ]` on a repo with no frontend. A control that red-flags a
repo for gates it has no surface for is a control that gets switched off.
The runner now distinguishes three states, and only two of them fail:
NOT APPLICABLE subject matter absent from this repo or this diff. Named
in the coverage block, excluded from it, does not fail.
SKIPPED (structural) the subject matter EXISTS and nothing produced the gate's
input. Fails.
SKIPPED (wiring) the gate's own helper or tool is missing. Fails — this is
the shape that once let gate-7 report PASS over 11 real
unguarded IDOR endpoints.
The three gates named by the product owner, classified explicitly:
gate-4 no composer.json -> na; composer.json unchanged in a diff-scoped run
-> na (ADR-020); composer NOT ON PATH -> wiring. That third case had
no output at all before: a dependency tree that exists and was never
audited, indistinguishable from a clean audit.
gate-24 split on the gate's OWN subject matter, not on the presence of its
checker: no `new LeafDescriptor(` and no `registerIntegration(` -> na
(there is no pair to correlate); either present without a parity
script -> structural.
gate-33 no src/ -> na; src/ but the caller never declared enable-axe -> na,
because that choice is a visible line in the caller's workflow rather
than a gap hidden here; enable-axe declared and no report -> structural.
quality.yml now passes --axe-enabled so the runner can tell those apart
instead of guessing "unverified" for both.
Two properties stop `na` becoming a mute. Silence still counts AGAINST coverage
by default — a gate stops counting only by declaring itself not-applicable out
loud. And the category is validated: na|structural|wiring are the only accepted
values and anything else is a hard gate failure, so a gate cannot stop counting
by misspelling its reason. Both are asserted in the suite.
The 25 silent gates are handled by applicability declarations that restate each
gate group's own `if` guard, negated. They cannot mask a live gate: _declare_na
refuses to touch a gate that already reported, and each condition fires only
when the prerequisite is absent — so a gate that stays silent while its subject
exists remains a coverage gap exactly as before. A positive control asserts that
with src/ present, all sixteen src-guarded gates really run.
bin/hydra-gates recomputes coverage independently and had to learn the new
verdict: a NOT APPLICABLE line contains no ': SKIPPED', so the old filter would
have counted every not-applicable gate as having REPORTED A RESULT. It also no
longer prints "ALL 63 GATES PASSED — and all 63 of them ran" over a run where 30
did not.
Proved both directions, since either alone is satisfiable by a broken flag — one
that never fails passes the first, one that always fails passes the second:
a run whose only gaps are not-applicable exits 0; the same fixture with an
integration leaf registered and no parity check exits 98. Suite 25 -> 31 tests,
0 failing.
Measured with the flag on across five fleet repos: openregister, opencatalogi
and docudesk have ZERO gates that did not run. hermiq and openconnector have
exactly one each — gate-24, and it is a genuine structural gap: both register
integration leaves and neither ships a parity check.
…— and the CI control was measuring a proxy Two things the coverage split exposed. 1. gate-22 and gate-53 reported a missing `node` as a finding about the app. With node off PATH, `node check_manifest.js` exits 127, falls through to the catch-all branch, and gate-22 says "1 schema violation(s) in src/manifest.json"; gate-53's ajv probe is itself a `node -e`, so it says "ajv not resolvable" and sends a reader to install a library when the interpreter is what is missing. Both now report SKIPPED (wiring) and name the tool. Same shape as gate-4 reporting "CVEs or advisories" for a composer that could not run. Measured with an exhaustively-shadowed PATH — and the instrument was controlled first: the obvious approach of stripping node's directory from PATH left a SECOND node still resolvable, so that run would have proved nothing. 2. The package workflow's reverse-direction control asserted `grep -q "GATES THAT DID NOT RUN:"` on the reasoning that node is deliberately absent there. That reasoning was wrong and the assertion was a proxy: the only gates that need node are guarded by `[ -f src/manifest.json ]`, and that fixture has no manifest, so node's absence never reached them. What actually produced the line was the `git rm` above it emptying src/ — the a11y gates went silent for a reason unrelated to node, and the control passed on a coincidence. It stopped passing the moment those gates began declaring themselves NOT APPLICABLE, which is the correct verdict for a repo with no frontend. Rather than relax it, it now asserts what it was reaching for: ran + notApplicable + didNotRun == declared, so no gate can fall out of the tally silently whatever the reason, plus every counted gate must have said so on its own line. Verified to HOLD on real runs and to BREAK on doctored output. Also fixes a pre-existing actionlint finding in the same file: `github.head_ref` was interpolated directly into an inline script, where a fork-PR branch name is attacker-controlled source text. Passed through the environment instead.
rubenvdlinde
added a commit
to ConductionNL/app-versions
that referenced
this pull request
Aug 5, 2026
…he shared quality.yml The first commit here was measured against quality.yml as it stood this morning. It has since changed underneath the pin, and that is the second half of the same defect. ConductionNL/.github#164 (merged 13:52Z) flipped `hydra-gates-require-full-coverage` to default TRUE in the SHARED quality.yml — which every repo references at `@main`. The accounting that makes that flag survivable, NOT APPLICABLE as distinct from a structural or a wiring gap, ships in the PINNED package. So a repo on any pin older than that change fails the coverage assertion for gates it has no subject matter for. Not a prediction. Run 31012647428 on this branch, at v1.2.0, 26 jobs, Hydra Gates FAILED: RESULT: ALL GATES PASSED — EXCEPT GATES 4 5 6 7 14 15 22 24 25 33 53, WHICH DID NOT RUN. --require-full-coverage was set: treating incomplete coverage as failure. Re-measured on this branch, diff-scoped against origin/development exactly as CI scopes it, each run in its own mount namespace with a private tmpfs: v1.0.1 exit 98 FAIL v1.2.0 exit 98 FAIL <- confirmed by the real CI run above v1.3.0 exit 0 PASS 52 applicable gates ran and passed; 11 named NOT APPLICABLE v1.3.0 is `f7eaf2a` = .github@main, tagged so the ref is reproducible. Refs ConductionNL/.github#159
rubenvdlinde
added a commit
to ConductionNL/app-versions
that referenced
this pull request
Aug 5, 2026
…failing CI (#124) * ci(quality): move hydra-gates-ref off v1.0.1, which predates 16 live dead gates v1.0.1 is `f4d9756` (2026-08-03). Three gate fixes merged after it, so every Hydra Gates run this repo has ever made executed a script in which 16 gates reported PASS when their helper never ran (.github#147), gate-33 had no axe report to read and never said so (#148), and gates 6 and 7 reported PASS on an empty scope (#149). The tick was identical either way, which is why nothing here shows it. Measured before opening this PR — full tree, private mount namespace and tmpfs so the runner's hardcoded /tmp/hydra-gate-*.log paths cannot be contaminated by another run on the same host (.github#158 item 6): v1.0.1 4 gates fail: 38 skip-link, 40 form-label-association, 43 table-headers, 45 prefers-reduced-motion v1.2.0 the SAME 4 fail; no new failure, and no failure disappears What does change is the accounting: 61 declared gates become 63, and gates 24 and 33 now print SKIPPED with a named reason instead of being absent from the summary. Refs ConductionNL/.github#159 * ci(quality): correct the target ref to v1.3.0 — v1.2.0 is RED under the shared quality.yml The first commit here was measured against quality.yml as it stood this morning. It has since changed underneath the pin, and that is the second half of the same defect. ConductionNL/.github#164 (merged 13:52Z) flipped `hydra-gates-require-full-coverage` to default TRUE in the SHARED quality.yml — which every repo references at `@main`. The accounting that makes that flag survivable, NOT APPLICABLE as distinct from a structural or a wiring gap, ships in the PINNED package. So a repo on any pin older than that change fails the coverage assertion for gates it has no subject matter for. Not a prediction. Run 31012647428 on this branch, at v1.2.0, 26 jobs, Hydra Gates FAILED: RESULT: ALL GATES PASSED — EXCEPT GATES 4 5 6 7 14 15 22 24 25 33 53, WHICH DID NOT RUN. --require-full-coverage was set: treating incomplete coverage as failure. Re-measured on this branch, diff-scoped against origin/development exactly as CI scopes it, each run in its own mount namespace with a private tmpfs: v1.0.1 exit 98 FAIL v1.2.0 exit 98 FAIL <- confirmed by the real CI run above v1.3.0 exit 0 PASS 52 applicable gates ran and passed; 11 named NOT APPLICABLE v1.3.0 is `f7eaf2a` = .github@main, tagged so the ref is reproducible. Refs ConductionNL/.github#159
rubenvdlinde
added a commit
that referenced
this pull request
Aug 5, 2026
…ed runners — decidesk and doriath went red on gates 4, 24 and 33 (#173) `hydra-gates-require-full-coverage` was flipped to default TRUE (#164) once the runner could tell a not-applicable gate from a real gap. But this workflow is consumed at @main while the gate package is consumed at the caller's own `hydra-gates-ref`, so the new default was handed to runners that predate the accounting entirely — and those count EVERY gate they did not run as a gap. Measured, not inferred, both after the flip and both failing on nothing else: decidesk job 92432089644 ref v1.0.1 ALL 61 GATES GREEN COVERAGE: 58 of 61 / DID NOT RUN: 4 24 33 -> exit 98 doriath job 92363547732 ref v1.0.1 same three gates, same verdict Gates 4, 24 and 33 are the exact trio the product owner named as legitimately not applicable. So the flip was producing the outcome it was made to prevent, in every repo whose pin had not moved — and pins do not move on in-flight branches. The flag is now handed only to a package that can honour it. The probe reads the package's own accounting (`_NA_GATES` in the runner, the `NOT APPLICABLE` verdict in the wrapper) rather than comparing versions, because a pin may be a tag, a branch, a SHA or a fork. Verified against every published tag: v1.0.0/v1.0.1/v1.1.0/v1.2.0 -> withheld, v1.3.0/v1.4.0 -> applied. A checkout missing either file is a broken checkout, not an old one, and fails. Withholding is stated as a `::warning::` naming the pin and the fix. A control that switches itself off quietly is a control that has stopped existing. `--axe-enabled` is gated on the same probe. The old wrapper's parser ends in `*) APP_DIR="$1"`, so an unrecognised flag is swallowed as a POSITIONAL and clobbers the app dir set moments earlier: a v1.0.1 pin handed it aborts with `FATAL: --app-dir '--axe-enabled' is not a directory. No gate ran.` — exit 99, every gate lost, for a flag the caller never typed. Same ref skew, second instance: axe-run.cjs landed in v1.4.0 and every caller is pinned v1.3.0, so `enable-axe: true` would die on `Cannot find module` and then present as "the axe report never arrived" — pointing at Playwright, the one place the fault is not. Asserted at the checkout with a named error instead. The two probe literals are a CONTRACT: renaming either would silently disable coverage enforcement fleet-wide, with a warning blaming the caller's pin. The package's own suite now asserts both, plus a reverse control proving the probe does not match the pre-v1.3.0 shape of either file. Positive-controlled: with both literals renamed in a copy, those assertions go red. Four-way measurement on one fixture, one variable at a time: v1.0.1 + --require-full-coverage exit 98 <- the observed failure v1.0.1, flag withheld (this change) exit 0 v1.4.0 + flag, only (a) gaps exit 0 33 of 33 applicable gates ran v1.4.0 + flag + a structural gap exit 98 [gate-24] SKIPPED (structural) Also removes a stale comment left by the #161/#166/#168 cycle that instructed future readers to embed the axe runner inline — the thing that made the whole workflow unresolvable fleet-wide. Suite 31 -> 34 tests, 0 failing. actionlint clean (positive-controlled: a typo'd step id is reported). Largest `run:` step 8,533 B, far below the ~19 KB that makes a workflow unresolvable. Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
rubenvdlinde
added a commit
that referenced
this pull request
Aug 6, 2026
…-020 working (#182) gate-28 turned RED on every PR in the fleet whose diff does not touch lib/**/*.php, which is most of them. It reported the DIFF's shape as the REPOSITORY's defect. `_lt_files` empty had two causes, and the final `else` stated them as one: a) the repo has lib/**/*.php but this diff touches none of them — ADR-020 diff-scoping working, the same state gate-4 reports NOT APPLICABLE for the same diff; b) lib/ exists but holds no tracked .php at all. Neither is (c) "files WERE in scope and none carried a declaration", the genuine structural gap that branch describes. All three printed (c), and for (a) that message is FALSE: with zero files in scope, "0 in-scope file carried a declaration" is true only vacuously. Not cosmetic. hydra-gates-require-full-coverage defaults to TRUE (#164) and a structural gap fails the run with exit 98, so this was a fleet-wide falsely-RED gate — the mirror of the falsely-GREEN shape #172 built this taxonomy to kill. It surfaced the moment a repo unpinned to main, because gate-28 does not exist at v1.3.0. Measured on nldesign against the real runner, three arms, before and after: diff = .github/workflows/code-quality.yml before SKIPPED(structural) -> exit 98 after NOT APPLICABLE diff = 3 files under lib/ before PASS after PASS diff = one lib/*.php declaring AGPL while composer.json declares EUPL-1.2 after FAIL — 1 file(s) The third arm is the one that matters: turning a red into a not-applicable is exactly how a gate gets muted, so the mismatch it exists to catch was injected and confirmed still caught. The (c) message now also states the in-scope count, so a reader can tell it apart from the case it used to be conflated with. Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
rubenvdlinde
added a commit
that referenced
this pull request
Aug 8, 2026
…s missing (#248) #178 (gate-28 fails every PHP-free PR in a PHP repo) was already FIXED by #182 on 2026-08-06, released in v1.5.2 and v1.6.0. The issue was simply never closed. Measured, not assumed — all three states behave correctly on main today. This commit adds no runtime change; it adds the acceptance evidence and closes three real gaps in what the suites actually asserted. WHAT WAS NOT BEING ASSERTED 1. The suite never ran --require-full-coverage, so it proved "gate-28 prints NOT APPLICABLE" and INFERRED "the run is not failed". Those are different claims and have come apart before (#164 defaulted the flag TRUE in the workflow while every local suite ran without it). 2. Nothing exercised the _fail 28 branch end to end. A licence MISMATCH is the gate's actual purpose; only the weaker "no file carried a tag" control existed. The helper->log->wc -l->_fail wiring was untested. 3. check_license_triangle.py's declared_file_count() had NO test in either direction, despite being the single number the runner's PASS-vs-structural decision rests on, and nothing pinned the stdout/stderr split it travels over. A TRAP FOUND WHILE WRITING THIS The coverage-failure branch lives inside `if [ "${_FAILED}" -eq 0 ]`. Any other failing gate suppresses the sentence entirely, so "the coverage sentence is absent" passes automatically on a fixture that trips an unrelated gate — including on a fully reverted runner. The first draft wrote its Vue file to src/App.vue, which gate-38 fails; both runs exited 1, not 98, and both new assertions were measuring gate-38. Fixtures are now otherwise green (src/components/Widget.vue), the pass case reads the POSITIVE "ALL N APPLICABLE GATES GREEN" sentence, and the control uses a composer.json with no license field rather than untagged PHP (which also fails gate-1, making the coverage branch unreachable). MUTATION-CHECKED — every new assertion shown capable of failing: M1 empty diff scope -> structural (pre-#182) -> 2 new shell assertions RED, status 98 M2 structural -> na (gate stops failing) -> 2 new shell assertions RED M3 _fail 28 disabled -> 2 new shell assertions RED (drift became PASS) M4 declared_file_count -> len(files) -> 3 new python tests RED M5 count printed to stdout -> 4 new python tests RED; clean repo turns FAIL M6 empty scope invents a finding + a count -> 3 new python tests RED M7 declared_file_count -> 0 -> 5 new python tests RED; PASS became structural Counts: shell 5 -> 11 assertions, python 19 -> 29 tests. Both appended to, never overwritten. Full helper suite: 27 passed, 0 failed, 2 quarantined. No runtime file is touched: the diff is the two test files only. Closes #178 Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
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.
What the current code actually does — measured, not inferred
_skipdeliberately does not add to_EMITTED_GATES, so every skip counts against coverage. And most gates never even reach a_skip: they are wrapped in a bareif [ -d src ]with noelse, so when the prerequisite is false they emit nothing at all — no line, no reason, no trace.Measured on a Tier-0 fixture with nothing wrong with it:
30 of 63 gates did not report; 25 of them only because the repo has no
src/. Of the 30, just 4 emitted aSKIPPEDline at all — the other 26 were pure silence. So yes: category (a) currently fails the run, and the flag was unusable fleet-wide.Three states, only two fail
NOT APPLICABLESKIPPED (structural)SKIPPED (wiring)wiringis called out separately because it is the worst shape: a missing helper once let gate-7 report PASS over 11 real unguarded IDOR endpoints.Gates 4, 24 and 33 — classified explicitly
gate-4 had three ways to emit nothing, all byte-identical to success:
composer.json→ na (no PHP dependency tree exists)composer.json/.locknot in a diff-scoped run → na (ADR-020 — the case the PO named)composernot on PATH → wiring. A dependency tree that exists and was never audited. This one had no output whatsoever before.gate-24 is split on the gate's own subject matter, not on the presence of its checker — the only test that cannot drift from what the gate correlates:
new LeafDescriptor(inlib/and noregisterIntegration(insrc/→ na (there is no pair to correlate)Deciding from
[ -f scripts/check-integration-parity.sh ]alone is why every repo in the fleet reported the same skip regardless of whether it had any leaves.gate-33's input is the one thing the runner cannot make for itself:
src/→ nasrc/, caller never declaredenable-axe→ na — that choice is a visible line in the caller's workflow, not a gap hidden in the runnerenable-axedeclared, report absent → structural, and it failsquality.yml now passes
--axe-enabledfrominputs.enable-axeso the runner can tell those apart instead of guessing "unverified" for both — which is what made the flag unusable everywhere.Two things stop
NOT APPLICABLEbecoming a mutena|structural|wiringonly; anything else is a hard gate failure, never a default. A gate cannot stop counting by misspelling its reason. Asserted in the suite — with a reverse control proving the unpatched runner does not emit that error.The 25 silent gates are covered by applicability declarations restating each group's own
ifguard, negated. They cannot mask a live gate:_declare_narefuses to touch a gate that already reported, and each condition fires only when the prerequisite is absent — so a gate that stays silent while its subject exists remains a gap exactly as before. A positive control asserts that withsrc/present, all 16 src-guarded gates really run, so table-vs-guard drift is detectable.Both directions proven
Either alone is satisfiable by a broken flag — one that never fails passes the first, one that always fails passes the second.
33 of 33 applicable gates ran[gate-24] SKIPPED (structural)check_no_admin_idor.pyremoved (c)[gate-7] SKIPPED (wiring)--axe-enabledwith no report (b)[gate-33] SKIPPED (structural)nqFAIL — … not one of na|structural|wiringSuite 25 → 31 tests, 0 failing.
bin/hydra-gateshad to learn the new verdictIt recomputes coverage independently. A
NOT APPLICABLEline contains no': SKIPPED', so the old filter would have counted every not-applicable gate as having REPORTED A RESULT — miscounted silently, in the direction that always reads better than the truth. It also no longer printsALL 63 GATES PASSED — and all 63 of them ranover a run where 30 did not.Blast radius of the flip — measured on real repos, flag ON
Three of five have complete coverage. The two that don't fail on a genuine, named, actionable gap: both register integration leaves and neither ships a parity check. That is the flag working, not the flag misfiring. Only repos with
enable-hydra-gates: trueare affected at all.Verification
actionlintpositive-controlled (exit 1 with 2 real findings on a deliberately broken workflow); this branch exit 0.default: trueand the arg wiring verified from the parsed YAML, not the draft:DEFAULT from parsed YAML : True bool.mainafter fix(quality): scope axe to the app's own DOM, and prove the scope is not a mute #161 merged.🤖 Generated with Claude Code