fix(gates): 16 gates reported PASS when their helper never ran — 2 are authorization gates - #147
Conversation
Each of these 16 gates enumerated its files, found work to do, discovered
its Python/JS helper was missing, echoed a WARN to **stderr**, and then
fell through to `_pass` on **stdout**:
if [ -f "${_oa_lib_dir}/check_orphan_auth.py" ]; then
python3 ... >> "${_oa_log}" 2>/dev/null || true
else
echo "[gate-6] WARN: ... — gate-6 skipped" >&2
fi
_oa_fail=$(wc -l < "${_oa_log}" ...)
if [ "${_oa_fail}" -eq 0 ]; then
_pass 6 "orphan-auth" # <- ran even though the helper did not
An empty findings log because the helper never ran was byte-identical to
an empty log because there were no findings. Every consumer of this runner
anchors on `^\[gate-`, so the WARN that said so was invisible.
Worse: `_pass` adds the gate to `_EMITTED_GATES`, and the summary computes
"GATES THAT DID NOT RUN" as declared-minus-emitted. These 16 branches
therefore actively DEFEATED the coverage machinery built to catch exactly
this, and `--require-full-coverage` could not see the gap either.
Two of the 16 are authorization gates: gate-6 (orphan-auth) and gate-7
(no-admin-idor).
Measured on a real repo checkout (decidesk @ 2b65908):
helper present -> [gate-7] no-admin-idor: FAIL - 11 method(s) with
NoAdminRequired + no guard
helper renamed -> [gate-7] no-admin-idor: PASS
Same tree, same 11 unguarded endpoints, and coverage read "61 of 63
declared gates reported a result" in BOTH directions.
Fix: each site sets a per-gate `_ran` flag, calls `_skip <n> <name>
<reason>` when the helper is absent, and guards the existing pass/fail
block with it. The pass/fail logic is untouched for the case where the
helper does run. `_skip` records the gate WITHOUT adding it to
`_EMITTED_GATES`, so it lands in the DID-NOT-RUN list where it belongs.
Sites: gates 6, 7, 9, 15, 16, 18, 19, 25, 26, 27, 51, 52, 54, 55, 56, 57.
gate-18 keeps its deliberate advisory half: check (a) (legacy dialect,
hard fail) is helper-driven and now skips; check (b) (imperative dispatch,
WARNING, non-blocking) is pure bash, still runs, and is explicitly left
outside the `_ran` guard.
Tests:
- test-hydra-gates-bin.sh gains a two-directional, attributable control:
the SAME fixture against two package copies differing ONLY by the two
security helper files. Asserts SKIPPED, asserts NOT PASS, asserts both
gates are named in DID NOT RUN, and asserts coverage drops by exactly 2.
Verified failable: against the unpatched runner it reports 4 failures
including "coverage went 35 -> 35".
- test_check_custom_widget_ratchet.py::test_helper_absent_warn_skips had
CODIFIED the defect - it asserted "custom-widget-ratchet: PASS" with the
helper absent, on a fixture containing a real finding. Rewritten to
assert the correct contract.
Suites: entry-point 19 -> 25 passed / 0 failed; helper suites 17 passed /
0 failed / 2 quarantined, unchanged from baseline.
Also documents why the coverage summary must keep deriving from
`_EMITTED_GATES` rather than the write-only `_SKIPPED_GATES`: the former
also catches gates that emit nothing at all because a prerequisite was
false, and is therefore strictly broader.
|
Admin-merging to Verified before merging, since this file runs in every repo's gate job:
The evidence that made this urgent, restated for the record — measured on a real checkout (decidesk
Eleven real unguarded endpoints reported clean, and the run's own Gate suite: 19 → 25 passing, 0 failed. The new test was shown to fail against the unpatched runner (4 failures, including Two things worth carrying forward:
Still open and not addressed here: gates 6/7 also |
…failing CI (#412) v1.0.1 is `f4d9756` (2026-08-03) and predates three gate fixes, so every Hydra Gates run this repo has ever made executed a script in which 16 gates reported PASS when their helper never ran (ConductionNL/.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 in this repo's history shows it. That pin is now also RED, and the mechanism is worth writing down. quality.yml is referenced `@main` while this package is PINNED, so the two can desync. #164 flipped `hydra-gates-require-full-coverage` to default true in the shared workflow, and that flag requires a gate to DECLARE itself not-applicable. v1.0.1 contains ZERO `_skip` calls; v1.3.0 has 36. v1.0.1 has no vocabulary to declare, so every absent prerequisite became "DID NOT RUN" and failed the job — for gates the repo has no subject matter for. Measured on this branch, diff-scoped against origin/development exactly as CI scopes it, in a private mount namespace with a private tmpfs (the runner's ~50 /tmp/hydra-gate-*.log paths are shared state and two concurrent runs corrupt each other's counts, .github#158 item 6): v1.0.1 exit 98 FAIL — "GATES THAT DID NOT RUN: 24 33" v1.3.0 exit 0 PASS — those gates named NOT APPLICABLE, with reasons Independently confirmed end-to-end: doriath#160 changed this one line and nothing else, and its Hydra Gates job went failure -> success. v1.3.0 is `f7eaf2a` = .github@main at the time it was cut. Refs ConductionNL/.github#159
…failing CI (#387) v1.0.1 is `f4d9756` (2026-08-03) and predates three gate fixes, so every Hydra Gates run this repo has ever made executed a script in which 16 gates reported PASS when their helper never ran (ConductionNL/.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 in this repo's history shows it. That pin is now also RED, and the mechanism is worth writing down. quality.yml is referenced `@main` while this package is PINNED, so the two can desync. #164 flipped `hydra-gates-require-full-coverage` to default true in the shared workflow, and that flag requires a gate to DECLARE itself not-applicable. v1.0.1 contains ZERO `_skip` calls; v1.3.0 has 36. v1.0.1 has no vocabulary to declare, so every absent prerequisite became "DID NOT RUN" and failed the job — for gates the repo has no subject matter for. Measured on this branch, diff-scoped against origin/development exactly as CI scopes it, in a private mount namespace with a private tmpfs (the runner's ~50 /tmp/hydra-gate-*.log paths are shared state and two concurrent runs corrupt each other's counts, .github#158 item 6): v1.0.1 exit 98 FAIL — "GATES THAT DID NOT RUN: 24 33" v1.3.0 exit 0 PASS — those gates named NOT APPLICABLE, with reasons Independently confirmed end-to-end: doriath#160 changed this one line and nothing else, and its Hydra Gates job went failure -> success. v1.3.0 is `f7eaf2a` = .github@main at the time it was cut. Refs ConductionNL/.github#159
…failing CI (#55) v1.0.1 is `f4d9756` (2026-08-03) and predates three gate fixes, so every Hydra Gates run this repo has ever made executed a script in which 16 gates reported PASS when their helper never ran (ConductionNL/.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 in this repo's history shows it. That pin is now also RED, and the mechanism is worth writing down. quality.yml is referenced `@main` while this package is PINNED, so the two can desync. #164 flipped `hydra-gates-require-full-coverage` to default true in the shared workflow, and that flag requires a gate to DECLARE itself not-applicable. v1.0.1 contains ZERO `_skip` calls; v1.3.0 has 36. v1.0.1 has no vocabulary to declare, so every absent prerequisite became "DID NOT RUN" and failed the job — for gates the repo has no subject matter for. Measured on this branch, diff-scoped against origin/development exactly as CI scopes it, in a private mount namespace with a private tmpfs (the runner's ~50 /tmp/hydra-gate-*.log paths are shared state and two concurrent runs corrupt each other's counts, .github#158 item 6): v1.0.1 exit 98 FAIL — "GATES THAT DID NOT RUN: 24 33" v1.3.0 exit 0 PASS — those gates named NOT APPLICABLE, with reasons Independently confirmed end-to-end: doriath#160 changed this one line and nothing else, and its Hydra Gates job went failure -> success. v1.3.0 is `f7eaf2a` = .github@main at the time it was cut. Refs ConductionNL/.github#159
…failing CI (#130) v1.0.1 is `f4d9756` (2026-08-03) and predates three gate fixes, so every Hydra Gates run this repo has ever made executed a script in which 16 gates reported PASS when their helper never ran (ConductionNL/.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 in this repo's history shows it. That pin is now also RED, and the mechanism is worth writing down. quality.yml is referenced `@main` while this package is PINNED, so the two can desync. #164 flipped `hydra-gates-require-full-coverage` to default true in the shared workflow, and that flag requires a gate to DECLARE itself not-applicable. v1.0.1 contains ZERO `_skip` calls; v1.3.0 has 36. v1.0.1 has no vocabulary to declare, so every absent prerequisite became "DID NOT RUN" and failed the job — for gates the repo has no subject matter for. Measured on this branch, diff-scoped against origin/development exactly as CI scopes it, in a private mount namespace with a private tmpfs (the runner's ~50 /tmp/hydra-gate-*.log paths are shared state and two concurrent runs corrupt each other's counts, .github#158 item 6): v1.0.1 exit 98 FAIL — "GATES THAT DID NOT RUN: 24 33" v1.3.0 exit 0 PASS — those gates named NOT APPLICABLE, with reasons Independently confirmed end-to-end: doriath#160 changed this one line and nothing else, and its Hydra Gates job went failure -> success. v1.3.0 is `f7eaf2a` = .github@main at the time it was cut. Refs ConductionNL/.github#159
…failing CI (#269) v1.0.1 is `f4d9756` (2026-08-03) and predates three gate fixes, so every Hydra Gates run this repo has ever made executed a script in which 16 gates reported PASS when their helper never ran (ConductionNL/.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 in this repo's history shows it. That pin is now also RED, and the mechanism is worth writing down. quality.yml is referenced `@main` while this package is PINNED, so the two can desync. #164 flipped `hydra-gates-require-full-coverage` to default true in the shared workflow, and that flag requires a gate to DECLARE itself not-applicable. v1.0.1 contains ZERO `_skip` calls; v1.3.0 has 36. v1.0.1 has no vocabulary to declare, so every absent prerequisite became "DID NOT RUN" and failed the job — for gates the repo has no subject matter for. Measured on this branch, diff-scoped against origin/development exactly as CI scopes it, in a private mount namespace with a private tmpfs (the runner's ~50 /tmp/hydra-gate-*.log paths are shared state and two concurrent runs corrupt each other's counts, .github#158 item 6): v1.0.1 exit 98 FAIL — "GATES THAT DID NOT RUN: 24 33" v1.3.0 exit 0 PASS — those gates named NOT APPLICABLE, with reasons Independently confirmed end-to-end: doriath#160 changed this one line and nothing else, and its Hydra Gates job went failure -> success. v1.3.0 is `f7eaf2a` = .github@main at the time it was cut. Refs ConductionNL/.github#159
…failing CI (#320) v1.0.1 is `f4d9756` (2026-08-03) and predates three gate fixes, so every Hydra Gates run this repo has ever made executed a script in which 16 gates reported PASS when their helper never ran (ConductionNL/.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 in this repo's history shows it. That pin is now also RED, and the mechanism is worth writing down. quality.yml is referenced `@main` while this package is PINNED, so the two can desync. #164 flipped `hydra-gates-require-full-coverage` to default true in the shared workflow, and that flag requires a gate to DECLARE itself not-applicable. v1.0.1 contains ZERO `_skip` calls; v1.3.0 has 36. v1.0.1 has no vocabulary to declare, so every absent prerequisite became "DID NOT RUN" and failed the job — for gates the repo has no subject matter for. Measured on this branch, diff-scoped against origin/development exactly as CI scopes it, in a private mount namespace with a private tmpfs (the runner's ~50 /tmp/hydra-gate-*.log paths are shared state and two concurrent runs corrupt each other's counts, .github#158 item 6): v1.0.1 exit 98 FAIL — "GATES THAT DID NOT RUN: 24 33" v1.3.0 exit 0 PASS — those gates named NOT APPLICABLE, with reasons Independently confirmed end-to-end: doriath#160 changed this one line and nothing else, and its Hydra Gates job went failure -> success. v1.3.0 is `f7eaf2a` = .github@main at the time it was cut. Refs ConductionNL/.github#159
…failing CI (#443) v1.0.1 is `f4d9756` (2026-08-03) and predates three gate fixes, so every Hydra Gates run this repo has ever made executed a script in which 16 gates reported PASS when their helper never ran (ConductionNL/.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 in this repo's history shows it. That pin is now also RED, and the mechanism is worth writing down. quality.yml is referenced `@main` while this package is PINNED, so the two can desync. #164 flipped `hydra-gates-require-full-coverage` to default true in the shared workflow, and that flag requires a gate to DECLARE itself not-applicable. v1.0.1 contains ZERO `_skip` calls; v1.3.0 has 36. v1.0.1 has no vocabulary to declare, so every absent prerequisite became "DID NOT RUN" and failed the job — for gates the repo has no subject matter for. Measured on this branch, diff-scoped against origin/development exactly as CI scopes it, in a private mount namespace with a private tmpfs (the runner's ~50 /tmp/hydra-gate-*.log paths are shared state and two concurrent runs corrupt each other's counts, .github#158 item 6): v1.0.1 exit 98 FAIL — "GATES THAT DID NOT RUN: 24 33" v1.3.0 exit 0 PASS — those gates named NOT APPLICABLE, with reasons Independently confirmed end-to-end: doriath#160 changed this one line and nothing else, and its Hydra Gates job went failure -> success. v1.3.0 is `f7eaf2a` = .github@main at the time it was cut. Refs ConductionNL/.github#159
…failing CI (#436) v1.0.1 is `f4d9756` (2026-08-03) and predates three gate fixes, so every Hydra Gates run this repo has ever made executed a script in which 16 gates reported PASS when their helper never ran (ConductionNL/.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 in this repo's history shows it. That pin is now also RED, and the mechanism is worth writing down. quality.yml is referenced `@main` while this package is PINNED, so the two can desync. #164 flipped `hydra-gates-require-full-coverage` to default true in the shared workflow, and that flag requires a gate to DECLARE itself not-applicable. v1.0.1 contains ZERO `_skip` calls; v1.3.0 has 36. v1.0.1 has no vocabulary to declare, so every absent prerequisite became "DID NOT RUN" and failed the job — for gates the repo has no subject matter for. Measured on this branch, diff-scoped against origin/development exactly as CI scopes it, in a private mount namespace with a private tmpfs (the runner's ~50 /tmp/hydra-gate-*.log paths are shared state and two concurrent runs corrupt each other's counts, .github#158 item 6): v1.0.1 exit 98 FAIL — "GATES THAT DID NOT RUN: 24 33" v1.3.0 exit 0 PASS — those gates named NOT APPLICABLE, with reasons Independently confirmed end-to-end: doriath#160 changed this one line and nothing else, and its Hydra Gates job went failure -> success. v1.3.0 is `f7eaf2a` = .github@main at the time it was cut. Refs ConductionNL/.github#159
…failing CI (#327) v1.0.1 is `f4d9756` (2026-08-03) and predates three gate fixes, so every Hydra Gates run this repo has ever made executed a script in which 16 gates reported PASS when their helper never ran (ConductionNL/.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 in this repo's history shows it. That pin is now also RED, and the mechanism is worth writing down. quality.yml is referenced `@main` while this package is PINNED, so the two can desync. #164 flipped `hydra-gates-require-full-coverage` to default true in the shared workflow, and that flag requires a gate to DECLARE itself not-applicable. v1.0.1 contains ZERO `_skip` calls; v1.3.0 has 36. v1.0.1 has no vocabulary to declare, so every absent prerequisite became "DID NOT RUN" and failed the job — for gates the repo has no subject matter for. Measured on this branch, diff-scoped against origin/development exactly as CI scopes it, in a private mount namespace with a private tmpfs (the runner's ~50 /tmp/hydra-gate-*.log paths are shared state and two concurrent runs corrupt each other's counts, .github#158 item 6): v1.0.1 exit 98 FAIL — "GATES THAT DID NOT RUN: 24 33" v1.3.0 exit 0 PASS — those gates named NOT APPLICABLE, with reasons Independently confirmed end-to-end: doriath#160 changed this one line and nothing else, and its Hydra Gates job went failure -> success. v1.3.0 is `f7eaf2a` = .github@main at the time it was cut. Refs ConductionNL/.github#159
…failing CI (#2349) v1.0.1 is `f4d9756` (2026-08-03) and predates three gate fixes, so every Hydra Gates run this repo has ever made executed a script in which 16 gates reported PASS when their helper never ran (ConductionNL/.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 in this repo's history shows it. That pin is now also RED, and the mechanism is worth writing down. quality.yml is referenced `@main` while this package is PINNED, so the two can desync. #164 flipped `hydra-gates-require-full-coverage` to default true in the shared workflow, and that flag requires a gate to DECLARE itself not-applicable. v1.0.1 contains ZERO `_skip` calls; v1.3.0 has 36. v1.0.1 has no vocabulary to declare, so every absent prerequisite became "DID NOT RUN" and failed the job — for gates the repo has no subject matter for. Measured on this branch, diff-scoped against origin/development exactly as CI scopes it, in a private mount namespace with a private tmpfs (the runner's ~50 /tmp/hydra-gate-*.log paths are shared state and two concurrent runs corrupt each other's counts, .github#158 item 6): v1.0.1 exit 98 FAIL — "GATES THAT DID NOT RUN: 24 33" v1.3.0 exit 0 PASS — those gates named NOT APPLICABLE, with reasons Independently confirmed end-to-end: doriath#160 changed this one line and nothing else, and its Hydra Gates job went failure -> success. v1.3.0 is `f7eaf2a` = .github@main at the time it was cut. Refs ConductionNL/.github#159
…failing CI (#157) v1.0.1 is `f4d9756` (2026-08-03) and predates three gate fixes, so every Hydra Gates run this repo has ever made executed a script in which 16 gates reported PASS when their helper never ran (ConductionNL/.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 in this repo's history shows it. That pin is now also RED, and the mechanism is worth writing down. quality.yml is referenced `@main` while this package is PINNED, so the two can desync. #164 flipped `hydra-gates-require-full-coverage` to default true in the shared workflow, and that flag requires a gate to DECLARE itself not-applicable. v1.0.1 contains ZERO `_skip` calls; v1.3.0 has 36. v1.0.1 has no vocabulary to declare, so every absent prerequisite became "DID NOT RUN" and failed the job — for gates the repo has no subject matter for. Measured on this branch, diff-scoped against origin/development exactly as CI scopes it, in a private mount namespace with a private tmpfs (the runner's ~50 /tmp/hydra-gate-*.log paths are shared state and two concurrent runs corrupt each other's counts, .github#158 item 6): v1.0.1 exit 98 FAIL — "GATES THAT DID NOT RUN: 24 33" v1.3.0 exit 0 PASS — those gates named NOT APPLICABLE, with reasons Independently confirmed end-to-end: doriath#160 changed this one line and nothing else, and its Hydra Gates job went failure -> success. v1.3.0 is `f7eaf2a` = .github@main at the time it was cut. Refs ConductionNL/.github#159
…failing CI (#1153) v1.0.1 is `f4d9756` (2026-08-03) and predates three gate fixes, so every Hydra Gates run this repo has ever made executed a script in which 16 gates reported PASS when their helper never ran (ConductionNL/.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 in this repo's history shows it. That pin is now also RED, and the mechanism is worth writing down. quality.yml is referenced `@main` while this package is PINNED, so the two can desync. #164 flipped `hydra-gates-require-full-coverage` to default true in the shared workflow, and that flag requires a gate to DECLARE itself not-applicable. v1.0.1 contains ZERO `_skip` calls; v1.3.0 has 36. v1.0.1 has no vocabulary to declare, so every absent prerequisite became "DID NOT RUN" and failed the job — for gates the repo has no subject matter for. Measured on this branch, diff-scoped against origin/development exactly as CI scopes it, in a private mount namespace with a private tmpfs (the runner's ~50 /tmp/hydra-gate-*.log paths are shared state and two concurrent runs corrupt each other's counts, .github#158 item 6): v1.0.1 exit 98 FAIL — "GATES THAT DID NOT RUN: 24 33" v1.3.0 exit 0 PASS — those gates named NOT APPLICABLE, with reasons Independently confirmed end-to-end: doriath#160 changed this one line and nothing else, and its Hydra Gates job went failure -> success. v1.3.0 is `f7eaf2a` = .github@main at the time it was cut. Refs ConductionNL/.github#159
…d its own measurements (#175) * fix(gates): 46/40/28/9/7 were majority-false, and the runner corrupted its own measurements Two gates produced more false findings than real ones, which made a fleet burn-down UN-MERGEABLE: the gates are diff- AND file-scoped, so touching a file drags in its pre-existing findings, and when the residue is entirely false there is no honest way to green the PR. Two burn-down PRs (opencatalogi#808, docudesk#385) are stuck in exactly that state. Measured across 21 fleet repos at origin/development, against v1.4.0: gate-46 spec-anchor-existence 1,995 -> 918 (-54%) gate-40 form-label-association 1,211 -> 517 (-57%) gate-9 semantic-auth 45 -> 11 (-76%) gate-7 no-admin-idor 32 -> 26 (-19%, partial) Every relaxation ships with the true-positive case it must not swallow. Four new suites (gate-46, gate-40, gate-28, gate-9) and 8 new gate-7 cases, all discovered automatically by tests/run-helper-suites.sh. Each was mutation-checked: making the relaxed predicate always-true fails the suite, and so does making it always-false. gate-46 — 1,077 findings cleared, none of them evidence about a spec * the `:`-tail rule accepted only EQUALITY where the full-heading rule accepted a PREFIX, so `### Requirement: REQ-001: List zaken` rejected `#REQ-001` * a requirement id in trailing parens/brackets was invisible: `### Requirement: Payment Provider Adapter Interface (REQ-PAY-001)` rejected `#REQ-PAY-001`. Lifted tokens match by EQUALITY only, so `#REQ` and `#REQ-PAY` still do not resolve * an id before the colon — `#### Scenario REQ-BIE-004-01: Cron triggers` — likewise. Only tokens CONTAINING A DIGIT are lifted; without that every word of every heading becomes an anchor * `- [~]` and `- [-]` checkboxes were invisible to the task rule, which ALSO shifted every positional `#task-N` after them. A wrong positional resolution reports PASS against a different task — worse than the missing anchor it replaced * `openspec/specs/x.md` and `openspec/specs/x/spec.md` are the same spec * `#scenario`/`#requirement` no longer prefix-match every heading of that level; `#webhooks` against `## Webhooks (Task 2.9 of giant)` still resolves, by equality against the bracket-stripped heading gate-40 — 694 cleared, and its advice was an a11y REGRESSION The only way to satisfy it on `<NcCheckboxRadioSwitch>Installed apps only</NcCheckboxRadioSwitch>` was to add `aria-label`, which OVERRIDES the visible label and breaks speech-input users. 463 findings were that shape. Also: implicit `<label>` wrapping (268), bound `:id`/`:for` pairs matched by expression (56), and markup inside comments and <script> blocks (5). Replaces a flatten-the-newlines regex with a real tag walker, so nesting and slots are visible. A self-closed switch with no slot and no prop — docudesk Settings.vue:41 — is still reported. Also ~40x faster: one python process, not one per .vue file. gate-28 — the NUL byte is worse than filed (#171) Depending on the grep implementation, a raw 0x00 makes it either a false RED (GNU grep <=3.4 prints "Binary file X matches" on stdout, so `awk '{print $3}'` reads the FILE PATH as the licence) or a false GREEN (ugrep / GNU grep >=3.5 print nothing to stdout, so the gate `continue`s and NEVER CHECKS THE FILE). Verified: an `@license AGPL-3.0-or-later` hidden behind a NUL passed silently. Reading bytes in python removes the class. Also collects EVERY declaration — `@license` tags and `SPDX-License-Identifier:` lines alike — instead of the first `@license` only, which is how 174 files carried an AGPL claim behind a green gate. Identifiers inside string literals stay test data, not claims. gate-9 — its remediation would have INTRODUCED the vulnerability "remove #[PublicPage] or remove body auth check": the first breaks the endpoint (middleware rejects the remote caller before the controller runs), the second deletes its only authentication. 34 of 45 findings were webhook/portal/federation endpoints that correctly bypass session auth and authenticate from the REQUEST. Returning 401/403 is not, on its own, evidence of a session dependency. A #[PublicPage] method that tests the SESSION still fires, under a rule name that says so. gate-7 — it was ANTI-CORRELATED with the property it checks (#160) On a multi-tenant codebase a tenancy guard refuses with 404 ON PURPOSE, because a 403 leaks another tenant's object ids. gate-7 excluded bare throws, so it flagged exactly the code that got tenancy right — and FlowController::state() reported identically before and after its real IDOR was fixed. Adds a tenancy signal requiring BOTH a comparison against a session-derived scope AND a refusal. Partial: 6 of the ~17 openregister false positives clear; the rest need collaborator-hop work. THE RUNNER CORRUPTED ITS OWN MEASUREMENTS 61 gates wrote to hardcoded /tmp/hydra-gate-<name>.log and derived verdicts by `wc -l` on them. Exactly one used mktemp. Demonstrated with two concurrent runs on different repos at v1.4.0: petstore reported "gate-46 FAIL - 26 unresolved targets - see /tmp/hydra-gate-spec-anchor- existence.log" while that file contained ZERO lines, app-versions having truncated it; both repos' gate-40 verdicts (1 and 7) pointed at one file holding 7 lines. Had the truncation landed before the `wc -l`, petstore would have reported PASS over 26 real findings. Now: one private directory per invocation, printed once, TMPDIR honoured, and the run REFUSES (exit 97) rather than falling back to a shared path. Same fix in tests/run-helper-suites.sh, test_gate_route_auth.sh and test_check_manifest.sh, which had the same defect: route-auth reported 7 failures under the harness and 0 standalone. A RESOLVING DIFF BASE IS NOT A USABLE ONE shillinq's `development` run finished in 22 seconds, all green. Cause: on a push to a mainline branch `origin/development` IS HEAD, so the diff is empty by construction and every gate passes over nothing. Verified at c64e9fe — 52 gates PASS scoped, 18 FAIL unscoped. Now refused with exit 99, alongside a merge-base check for shallow checkouts, and the diff's exit code is read directly rather than through a `||` chain that cannot tell "no changes" from "could not run". The first draft of that block used `set +e`/`set -e`, which does not restore state — it enabled errexit for the remaining 3,700 lines and aborted every scoped run right after the scope line. Caught by test_gate_route_auth.sh. Replaced with `&& rc=0 || rc=$?`. ALSO * 19 helper lookups re-resolved `dirname "${BASH_SOURCE[0]}"` AFTER the `cd "${APP_DIR}"`, against the warning at the top of the file. One of them (gate-17) aborted the entire suite when the runner was invoked by a relative path — 46 gates never ran. * gates 46/40/28 now _skip(wiring) when their helper is missing, rather than passing over an unread file set (#147). * quality.yml floats on @main while the package it drives is PINNED, and it executes paths inside that package BY NAME. That interface broke three times in one day (#168). Pinning both halves from one tag is a human call; until then a preflight names the desync instead of letting it surface as an unexplained gate failure. * fix(gate-28): know both HTML comment terminators, not just `-->` CodeQL `py/bad-tag-filter`, high, on the terminator-trimming pattern introduced in the previous commit. Nothing here sanitises markup — it trims a comment terminator off a licence identifier — but a half-known comment syntax is still half-known: `<!--SPDX-License-Identifier: EUPL-1.2--!>` would have yielded `EUPL-1.2--!` as the licence and reported drift on a correct header. Tested both ways: the value is trimmed for `*/`, `-->` and `--!>`, and a genuinely wrong licence is still wrong after trimming. * fix(ci): the package's own evidence assertion depended on the shared /tmp path Two CI failures, both caused by this branch, both worth the detour. * `hydra-gates-package.yml` asserts that a gate reporting FAIL also WROTE the evidence naming the offending file — a good assertion, reading a hardcoded /tmp/hydra-gate-<name>.log. That is the defect this branch exists to remove, one level up: with a shared path the check could read a DIFFERENT run's log and pass, or a truncated one and fail, and neither outcome would say anything about the fixture. It now reads the directory the run announces on its own first line, and fails loudly if no such line appeared — because an unattributable verdict is not a verdict. Reproduced locally against the fixed runner: exit 2, both gates named, both injected files named in their own run's logs. * ShellCheck SC2086 x4 on the `${_TCM_LOG}` uses added to test_check_manifest.sh. Quoted. --------- Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
… not take the run down Adopts the gate-19 / #249 signalling convention for both new helpers. Both call sites started as `>> log 2>/dev/null || true`, which discards the traceback AND the failure. A crashed helper leaves an empty findings log, and an empty findings log is how these gates spell PASS — the #147 defect exactly. Exit code is now a STATUS, findings are STDOUT, stderr is KEPT in <log>.err, and a non-zero exit reports SKIPPED (wiring). Also wrapped in `set +e` with the caller's flag restored. gate-19's block turns errexit ON and leaves it on for every gate after it, though this script's header sets only `set -u`; with errexit live a failing helper never reaches its own `_skip` — it kills the whole runner mid-sweep. Measured on gate-38: 21 later gates silently unreported, the run ending on the abort guard, and the PASS lines above it reading exactly like a clean run. New suite scripts/lib/test_gate_a11y_helper_wiring.sh — 10 assertions: * POSITIVE CONTROL first: with both helpers intact, a fixture app built to fail both gates does fail both. Everything else is only meaningful because these fire. * helper MISSING -> SKIPPED, for each gate * helper CRASHING -> SKIPPED, for each gate * and, separately each time, that the run still reached its COVERAGE summary — "did not abort" cannot be folded into "said SKIPPED", because an aborted run's PASS lines are indistinguishable from a clean run's.
…224, #226, #230, #235, #236, #266) (#269) * fix(gates): nine checkers matched prose, not code — one shared scope, nine gates Every gate below decided a question about CODE by grepping the raw bytes of a file. Prose is made of the same bytes, so each one failed in BOTH directions at once — the shape first written down in #184: "a checker that greps a STRING LITERAL misses every constant and matches every comment." #191 gate-48 a REMOVED COMMENT naming `#[NoCSRFRequired]` read as a removed attribute. nldesign red for one rewritten docblock sentence. #196 gate-5 a docblock saying `#[NoAdminRequired]` is deliberately NOT used SATISFIED the auth gate. A false NEGATIVE on a security gate, and a pass leaves no log. #220 gate-31 an `<img>` in a JSDoc comment in <script> (launchpad). #235 gate-31 the same, 3 of 3 findings on openbuild. #224 gate-34 false RED on a comment AND false GREEN on window['confirm'](). #226 gate-3 a run() delegating to one helper read as a stub, and the gate was closable by an inert `$unused = 1;`. #230 gate-58 a comment WARNING AGAINST networkidle counted as a use of it. #236 gate-12 `<NcSelect[^>]*>` truncated at the `>` of `option =>`. #236 gate-32 a comment describing the `<div @click>` an element replaced scored as that `<div @click>`. #266 gate-41 a PHP comment mentioning `<html>` made a mount point a page root. ONE SCOPE, NOT NINE ------------------- scripts/lib/source_scope.py generalises the two precedents that already got this right — #184's PHP stripper (which knows `#` opens a comment but `#[` opens an attribute) and #249's gate-19 tokeniser (blank once, PRESERVE OFFSETS, keep string delimiters). Every mask returns a same-length string, so a gate can report a line number computed on the mask and read a suppression marker out of the ORIGINAL at that line — which matters because every suppression marker in this package lives in a comment. Gate-19 keeps its own copy of the JS tokeniser; a drift test asserts the two byte-identical over a corpus and over this package's own .js sources, and asserts the keyword sets equal — the corpus alone SURVIVED deleting "await" from one set, so the corpus alone was not enough. #196 SHIPS WITH A DECLARATION, NOT JUST A TIGHTENING ----------------------------------------------------- Admin-only is expressed in Nextcloud by the ABSENCE of an attribute, and absence is the only thing gate-5 reports. Closing the false negative alone would have converted it into a PERMANENT false positive on correct code, with no legitimate way to satisfy the gate. So `@auth admin-only <reason>` joins the `@spec exclude` family. Making bare absence sufficient was considered and rejected: it would empty the gate completely. MEASURED, NOT ASSUMED --------------------- - 3 fixtures from #226's table, the 4 arms from #224, the nldesign line from #191 and the larpingapp line from #230, all verbatim. - Every relaxation is paired with the true positive it must not swallow, and every wiring is covered both ways: a MISSING helper and a CRASHING helper must report SKIPPED, never PASS (#147, #245, #249). gate-5 additionally runs a positive control on the mask itself, because a mask that silently returns its input is invisible to `[ -f helper ]` and puts the gate straight back into the false negative. - A nested `<template #default>` slot regression was caught by measurement before landing: a lazy `(.*?)` ended the SFC template at the first slot close and deleted a real finding at openconnector EditMapping.vue:376. Boundaries are found by depth now, and there is a test. Closes #191, #196, #220, #224, #226, #230, #235, #266 Refs #236 (parts 1 and 2; part 3 was already fixed by #247) Supersedes #219, whose gate-12 helper is carried here with its 17 tests. * fix(gate-34,gate-48): a guard is not a second dialog, and an FQCN attribute is one Both found by MEASURING the fix rather than by reading the issues. gate-34 — 7 defects reported as 14 findings ------------------------------------------ The first cut accepted any `window.confirm` REFERENCE, called or not, so on openbuild every native dialog was reported twice: const ok = typeof window !== 'undefined' && window.confirm <- guard ? window.confirm(t('openbuild', 'Delete this automation?')) <- call A feature-detection guard is a truthiness test, not a second native dialog, and inflating a security-adjacent count is its own false report (#254: a count is not a defect count). A reference now counts only when it is an ALIAS — a binding whose call site is elsewhere and therefore invisible: const c = window.confirm counts const { confirm } = window counts x && window.confirm ? … : … does not openbuild: 7 before, 7 after, same seven lines. The anchor also lost a character it should never have had. Written `=\s*window\s*[.\[]` it CONSUMED the `window` that follows, and `finditer` returns non-overlapping matches — so `const r = window.confirm('x')` matched only the alias rule, failed it because a `(` follows, and reported NOTHING. A real call dropped by an anchor one character too greedy. It is a lookahead now, and there is a test. gate-48 — the old regex could not see a fully-qualified attribute ----------------------------------------------------------------- Running #191's arm 2 end-to-end through the runner reported PASS on a genuine removal of - #[\OCP\AppFramework\Http\Attribute\NoCSRFRequired] because the pre-fix pattern alternated on the literal `#[NoCSRFRequired]`. A false NEGATIVE hiding behind the false positive #191 reported — the same both-ways failure as every other gate in this change. The new bracket-bounded rule matches it. Refs #191, #224 * fix(source_scope): `</script bar>` ends a script, and the mask must know it CodeQL raised py/bad-tag-filter (HIGH) against this branch, and it is right. r'<script(\s[^>]*)?>(.*?)</script\s*>' does not match `</script bar>` or `</script\t\n foo>`, both of which an HTML parser treats as the end of the element. When the close is spelled that way the block regex fails to match AT ALL, the script body is never comment-masked, and a JSDoc `<img>` inside it is scanned as markup — #235 reintroduced by the mask written to fix it. `</style …>` had the same hole.⚠️ THE FIRST TEST FOR THIS SURVIVED THE MUTANT. It exercised `vue_markup_mask`, which keeps `<template>` spans and never goes through `_SCRIPT_BLOCK` at all, so reverting the regex changed nothing and the suite stayed green. The assertion now runs through `html_markup_mask` and `script_mask`, the two functions that actually use it, and the reverted regex kills both. A mutation test that does not kill is not evidence — it is a second thing to check. Refs #235
…o full of markup, and three reported PASS over a crashed checker (#272) * fix(gates 35,40,42,44): four a11y gates excused themselves from a repo full of markup, and three reported PASS over a crashed checker Measured at package sha cdfbd7a against opencatalogi (93 .vue) and nldesign (zero .vue, one PHP template), one textbook true positive planted per gate in BOTH — the asymmetry that made #225/#261 possible. All 11 gates in the 34-44 band fired and named the plant in both arms, and all returned to their exact prior verdict on removal. Two defects survive that. 1. FOUR GATES GO `na` ON A TEMPLATES-ONLY REPO Gates 35, 40, 42 and 44 still guarded on `[ -d src ]` while 34/36/37/39/43 had moved to `_a11y_has_markup_dir`, and the central applicability table listed the whole family under `[ -d src ]`. On a repo with a `templates/` full of markup and no `src/`, same run, same files: gate-34/36/37/38/39/41/43 ran; four of them FAILED on the plants gate-35/40/42/44 NOT APPLICABLE — "this repo ships no frontend, so there is no .vue/.js/.ts source for this gate to inspect" `na` is the one verdict that removes a gate from coverage accounting, and the reason was contradicted by the same run's own output three lines above it. No fleet app is templates-only today; nldesign is one `rm` away, since its `src/` holds a single `manifest.json` — the exact shape that made twelve gates pass over nothing in #225. The guards now call `_a11y_has_markup_dir`, and the applicability declaration calls THE SAME FUNCTION rather than restating it, so the two cannot drift again. No third scope definition was added. 2. A CRASHED CHECKER REPORTED PASS (#147 / #249) — gates 40, 42, 44 With a `python3` on PATH that exits 1 on every call, run against opencatalogi: gate-40 PASS gate-42 PASS gate-44 PASS <- the three inline ones gate-34/37/38/39/41/43 SKIPPED (wiring) <- the six behind a helper gate-40 printed PASS over the 13 real findings it had reported one run earlier. gate-40 discarded its status with `2>/dev/null || true`; 42 and 44 ran per-file inline heredocs and never had one. 42 and 44 move to scripts/lib/check_link_text.py and scripts/lib/check_autocomplete.py — one interpreter for the whole file set, findings on stdout, exit code as a status — and 40 gains the same return-code guard. FOUND WHILE WRITING THE TESTS * gate-44 judged an input on the FIRST of name/id/v-model and stopped, so `<input id="e" type="text" name="email">` — the plainest textbook case this gate has — passed. Fleet effect, measured across 15 repos: openregister 0 -> 1 (an OpenAI Organization ID field), pipelinq 4 -> 5 (a "Colleague email" field). Both genuine, nothing lost. * gates 35, 36 and 44 read attribute values out of DOUBLE QUOTES ONLY. `tabindex='5'`, `alt=''` and `name='telephone'` render identically and reported PASS in both arms. Zero occurrences in the fleet today, which is why they could sit there indefinitely. * `[^>]*` in gates 42 and 44: a `>` inside an attribute value is not the end of a tag — the parse that hid 19 buttons from gate-39 (#259, #198, #236). * gates 42 and 44 scanned RAW text, so a commented-out `<a>click here</a>` or `<input name="email">` counted. That is gate-64's defect (#184), the one gate-38 (#247) and gate-41 (#266) each shipped a fix for. MEASURED AFTER, NOT ONLY BEFORE * 15 repos, gates 34-44, before vs after: every verdict and every finding count identical except the two new gate-44 true positives above. The rewrites of 42 and 44 removed nothing. * opencatalogi and nldesign return to their exact pre-plant baselines. * ARM 4 of test_gate_a11y_markup_scope.sh was mutation-checked: reverting gate-42's guard to `[ -d src ]` turns it red with the finding it was written for. TESTS * scripts/lib/test_check_link_text.py, test_check_autocomplete.py — 32 assertions; every relaxation ships with the true positive it must not swallow, comment/script exclusions ship with their positive control, and each ends with the whole PRE-FIX checker replayed as the mutant, asserting it answers DIFFERENTLY on every fixture. * test_gate_a11y_helper_wiring.sh gains gates 39, 40, 42, 44 (39 was wired correctly but never listed, so nothing held it to that) — 70 assertions. * test_gate_a11y_markup_scope.sh gains ARM 4, the templates-only repo. * Full discovered suite: 49 passed, 0 failed, 2 pre-existing quarantines. tests/test-hydra-gates-bin.sh: 59 passed, 0 failed. * fix(test): SC2194 — the case word was the constant, not the subject `case " 38 45 " in *" ${_g} "*)` matches a constant against a pattern built from the variable, which is the comparison written backwards. It happened to work, and ShellCheck is right that it reads as a mistake. Verified with shellcheck 0.10.0 at full severity: clean.
…ver a dead interpreter, and three could not see the defect they exist for (#280) * fix(gates 45-55): eleven gates passed over an unopened scope, eight over a dead interpreter, and three could not see the defect they exist for Every gate in this band was given ONE textbook true positive of exactly what it exists to catch, planted in a real fleet repo, then removed again. Where a gate could not fail, it was repaired; where it could, the plant is now a regression test. Measured at package sha 34370f6. ## 1. All eleven reported PASS over a scope they never opened (#242/#240/#258/#268) On a README-only diff against larpingapp, gates 45-55 printed eleven PASS lines and the summary read "53 of 53 applicable gates ran". Not one of them had opened a file. Gates 4/6/7/19/25/28/62/63 have answered the identical situation with NOT APPLICABLE since #268; this band never adopted it. Gates 47 and 48 are the sharper case: they can only answer a question about a CHANGE SET, so on every builder full-repo run in the fleet — no base ref at all — they printed a co-change verdict they had not formed. ## 2. Eight reported PASS over a crashed interpreter (#147/#249/#262) A planted defect only fires when the gate runs, so no plant can see this. With a `python3` on PATH that exits 1 on every call, on a tree carrying real findings: gate-46 PASS — over the 277 unresolved @SPEC findings, across 104 distinct targets, it had reported one run earlier on the same files gate-47 PASS — on the same diff where it had just reported FAIL gate-45/49/50 PASS (`2>/dev/null` discarded status and traceback) gate-51/54/55 PASS (`|| true` discarded the status) gate-52 FAIL — "1 custom-widget finding(s)", a fabricated finding: the helper returned its COUNT as its exit status, the same channel Python uses for a traceback (#209). The count was also clamped to 99 to fit in a byte. It now prints `findings=N` on stdout and exits boolean; no `findings=` line means the helper died. gate-54 was the quietest: its advisory WARN half reads the same log, so a dead helper silenced both halves at once. ## 3. gate-45 was the residue of #272's fix (.github#274) #272 migrated gates 35/40/42/44 off `[ -d src ]` onto `_a11y_has_markup_dir` and left the twelfth member of the family behind. On a templates-only app gate-45 reported NOT APPLICABLE — "this repo ships no frontend" — over a `<style>` block with `transition:` and no reduced-motion fallback, in the same file gate-43 FAILED on in the same run. `na` is the one verdict that removes a gate from coverage accounting. The regression test was already written and gate-45 was excluded from it by name, with a comment explaining why. Removing the name from ARM 4's skip list in test_gate_a11y_markup_scope.sh IS the test; it fails against 34370f6. ## 4. gate-47: prose satisfied it, and a qualified attribute did not `_ANNOTATION_RE` was an unanchored alternation of string literals, and it was wrong in both directions from that one regex — the pairing #269 found in gate-48 and never carried to its sibling. FALSE POSITIVE rewording ONE docblock sentence that merely NAMES the annotation ("becomes `@NoAdminRequired` again, paired with a real ownership check") made the gate demand a test co-change. A gate satisfiable by prose manufactures the appearance of a security review (#191). FALSE NEGATIVE `#[\OCP\AppFramework\Http\Attribute\NoAdminRequired]` was invisible. A commit adding exactly that to a controller — opening an admin-only endpoint to every authenticated user — with no test in the diff reported PASS. Now position-anchored, by the same rule check_csrf_removal.py already used. ## 5. gate-50: a false positive and a false negative in the same regex FALSE NEGATIVE the app-id argument had to be a QUOTED STRING, so every read written the fleet-standard way — `getValueString( Application::APP_ID, 'listing_register', '')` — was invisible. Identical code with `'larpingapp'` FAILED. Same family as #184. 7 security-relevant reads across 5 repos sit behind a constant today. FALSE POSITIVE the empty-compare guard required a closing paren immediately after the empty string, so the correct compound guard `if ($reg === '' || $sch === '')` was reported as unguarded — twice, on code the gate was asking for. A guard that is a boolean `return` rather than an `if` was rejected too. Both directions are now asserted, including the opencatalogi#86 shape that mixes them: one read guarded, the next unguarded two lines later. ## 6. gate-53 did not block the PR that creates larpingapp#286 Reintroducing #286 exactly — the check-in tab deleted from src/manifest.json, `EventRoster` left registered in src/registry.js — reported PASS. Direction 1 of the registry cross-reference stays advisory for LEGACY orphans, correctly: the gate cannot tell "wire it" from "delete it". But when the DIFF ITSELF removed the last reference it can, and that finding now blocks. Pre-existing orphans are untouched (larpingapp carries one today), so this is prevention, not a burn-down list nobody can close. ## Verified working, repaired nothing gate-46 (dangling file, dangling fragment, valid anchor), gate-48 (short and fully-qualified attribute removal; a comment reword correctly stays green), gate-49, gate-51 (title, description and nested items.properties independently), gate-52's ratchet (growth fails, shrink passes), gate-54 (flat, nested and $ref-carrying), gate-55. ## Deliberately NOT enforced `title == key` on a schema property is a real gate-51 defect — the renderer uses `prop.title || key`, so the user sees the raw technical key. Measured across 10 repos: 148 occurrences, ALL of them in softwarecatalog, where they are VNG-standardised element names (`identifier`, `type`, `name`) that must not be renamed. Enforcing it would produce 148 findings with no legitimate end state in the one repo that has them. Reported rather than gated (#252). ## Divergence to reconcile gate-45 now answers an empty in-scope set with `na`; gate-40 answers it with PASS, by a deliberate choice in #272 that cited the invariant test this PR reworks. The invariant now discriminates on the REASON — the applicability table's own phrasing must not appear once its prerequisite holds — so both behaviours are expressible. The family should pick one. ## Testing New: hydra-gates/scripts/lib/test_gate_45_to_55_acceptance.sh — 31 arms across six families, discovered by run-helper-suites.sh. Against the package as merged on main it fails 20 of 31; the 11 that pass are exactly the anti-widening and no-regression controls. Every mutation asserts its anchor is present before it plants. Repos used, chosen for different shapes: larpingapp (register-owning, manifest-driven, ships registry.js), nldesign (PHP templates, no .vue, no register), doriath (ships no phpcs SpecTagSniff — the #246 control, held at 81 findings across 46 targets before and after the plant), openconnector (41 register files). Full package suite: 52 discovered suites pass, 2 quarantined as documented; 60/60 entry-point invariants. * fix(gate-50): the fail-mode window started where the call began, not where it ended The constant-app-id fix in the parent commit made procest's config reads visible for the first time and immediately produced 3 findings on lib/Service/AiService.php — all three false positives, and both causes are ordinary code the window could never have seen: multi-line call PHPCS formats each read across five lines. Two of them plus a blank line put the guard on the ELEVENTH line, one outside a window counted from the line the match BEGAN on. The guard being missed is a textbook `if (empty($registerId) === true || empty($schemaId) === true) { $this->logger->warning(...); return; }` (AiService.php:580, :967). same-line guard `'ai_api_key_set' => ...getValueString(APP_ID, 'ai_api_key', '') !== ''` handles the empty default ON the match line, and the window started after it (AiService.php:710). The window now anchors to the END of the call expression — parentheses balanced forward from the `(` — and includes the remainder of that line. A single-line read keeps exactly the ten lines it always had. Caught by a before/after sweep of 12 fleet repos: 26 of 121 verdicts changed, 25 of them PASS -> NOT APPLICABLE (the truthfulness correction), and this was the only one that changed to FAIL. procest is PASS again, correctly. Three arms added: the multi-line shape, the same-line shape, and the reverse control — the same multi-line shape with the guard DELETED must still FAIL, so the window cannot have been widened until the gate finds nothing. Also: shellcheck SC2181 in gate-45's new status check, and a file-scoped SC2016 suppression for the acceptance suite, whose PHP fixtures are single-quoted on purpose.
… 19, 13, 20 (#328) * fix(gate-45): read stylesheets — the gate had never opened a .css file Gate-45 (prefers-reduced-motion) scanned <style> blocks inside markup and nothing else, so every green it has ever produced is a statement about markup, not about CSS. In a Nextcloud app the app-wide motion lives in css/, because that is what Util::addStyle() loads. Measured before this commit: nldesign 3 stylesheets with motion, 0 guards -> gate-45 PASS openregister css/main.css, 7 motion decls, 0 guards -> gate-45 PASS The un-blinding is paired with four false-positive controls, because widening a gate fleet-wide is exactly the change that turns it into a noise generator: * the guard regex now recognises a full media prelude (@media screen and (prefers-reduced-motion: reduce)), which the old 'immediately followed by (' pattern could not have matched * comments are masked (#294's lesson), // only in SCSS dialects and never the // of a url(https://...) * transition/animation: none is how a fallback is WRITTEN, not motion * a repo-wide UNIVERSAL reset in one file guards every other file, so the gate accepts the fix people will actually write rather than reporting every other stylesheet the day that reset lands * generated output is skipped by CONTENT (a >500-char line), which catches webpack's css/main-<hash>.chunk.css that has no .min in its name Fleet-wide yield measured over 40 repos: 37 new findings, 14 of them in the GitHub fleet (nextcloud-vue 8, nldesign 3, opencatalogi 1, openconnector 1, openregister 1). The existing markup arm is unchanged at 143 findings, so nothing was widened by accident. test_gate_45_stylesheet_scope.sh proves both directions and goes 3 red against the pre-fix runner. Closes #287 * fix(gate-46): resolve @SPEC targets in tests/ — the enumerator skipped them Gate-46's scope was `find lib src`. It had never opened a test file, and tests/ is where a large share of the fleet's @SPEC tags live, because a test is the natural place to name the requirement it proves. Measured over the 21 apps carrying an openspec/: 272 unresolved targets under tests/, in 16 repos, that no run has ever reported. The textbook case is procest — tests/Unit/BackgroundJob/DsoDeadlineJobTest.php annotates openspec/changes/dso-omgevingsloket/tasks.md#T14 against a tasks.md numbering T01-T08 and V01-V10. There is no T14 and never was. The identical tag in lib/ would have failed this gate since #246. Landing as a HARD FAIL, matching the existing lib/src arm. The gate is diff-scoped under ADR-020, so pre-existing debt in an untouched test file never blocks a PR — the finding surfaces on the PR that edits that file, which is exactly when the tag should be re-checked. A named WARN would reproduce the failure mode this band exists to remove: a gate emitting something other than FAIL over a real defect. openspec/ is DELIBERATELY NOT added. Measured: 292 findings, dominated by documentation TEMPLATES that quote the syntax rather than use it (openspec/changes/{name}/tasks.md#task-N, <slug>, ...) in context-briefs and proposals across shillinq, pipelinq and others. Auditing them would bury real findings under placeholders. Correction recorded in the docblock: #322 as filed reports that tasks.md targets are never existence-checked (353 on doriath). That premise does not hold on this package. A planted openspec/changes/does-not-exist-at-all/tasks.md#task-1 IS reported as 'target file not found', and #task-99999 against a real tasks.md IS reported as 'anchor not found'. The 353 all resolve through build_archive_index, which exists for the archived-under-a-date-prefix case the issue describes, and the archived file exists at the very commit the issue measured. What produced PASS there was ADR-020 diff scoping. test_gate_46_tests_scope.sh proves both directions and goes 5 red against the pre-fix runner while its lib/ arm stays green. Refs #322 * fix(gate-19): a file no Playwright project runs cannot prove a scenario The gate counted every *.spec.ts under tests/e2e/** as a running test. Playwright does not: a file excluded by testIgnore, living outside testDir, or matched by no project's testMatch is never executed, and a scenario referenced only from such a file has no automated proof at all. The gate could already see describe.skip (#239) but not the config that does the same thing to a whole directory. Reproduced by planting an @e2e anchor in a CI-ignored directory: the uncovered count dropped 271 -> 270 with the scenario reported COVERED. The live fleet shape is openregister's tests/e2e/api-direct/search-views-presentation.spec.ts, under a **/api-direct/** that is excluded at top level AND repeated in every project, because a project-level testIgnore REPLACES the top-level one rather than merging with it. A GLOB LIST WOULD HAVE BEEN THE BUG. **/visual/** and **/docs-screenshots.spec.ts sit in a testIgnore in fourteen fleet configs and are pulled BACK IN by the visual / docs-capture projects via testMatch. Treating 'named in some testIgnore' as dead would have stripped coverage credit from every visual and docs spec in the fleet. A file is dead only when NO project would run it. Validated against all 21 real fleet configs: every one parses, and the only dead files anywhere are the api-direct trees excluded on purpose (openregister 25, openconnector 6). Visual and docs specs: 0 dead. Fleet-wide finding count: ZERO. Those api-direct files carry only prose mentions of @e2e plus one whole-spec tag with no slug, so no ref is currently claimed from an unrun file. The hole is real and proven by a planted true positive; nothing in the fleet is exploiting it today, so this lands with no churn. Conservative by construction: no config, an unparsable config, an extglob/brace pattern, a non-literal testMatch or testDir all resolve to LIVE, i.e. to the pre-existing behaviour. Comments are masked first — every fleet config explains the replace-not-merge rule in prose containing 'testIgnore:'. 11 new unit tests; the 4 that assert the fix go red against the pre-fix helper while the 7 anti-widening arms stay green. Suite: 105 -> 116. Closes #308 * fix(gate-13): see a dialog tag opened across several lines The test was grep -qE '<NcModal[ \t>/]|<NcDialog[ \t>/]'. grep matches line by line, so a tag with its props on following lines — which is how Vue components with more than a prop or two are actually written, and what every formatter produces — <NcDialog :open="showConfirm" name="Delete lead"> has nothing after <NcDialog on its own line. The character class cannot match end-of-line, so the tag was invisible. Measured on pipelinq: 0 of 9 real violations seen, while the gate passed its own planted true positive the whole time — a plant is written on one line and a real dialog is not. That is the trap: a minimal plant and a real defect differing in precisely the feature the regex depends on. The delimiter is WIDENED to include end-of-line, not dropped, so <NcDialogHeader> and <NcModalFooter> still do not match. Comments are masked first, which also removes a FALSE POSITIVE the old pattern had: it reported a <NcModal> written inside a /* */ block comment. Fleet-wide finding count: 8 -> 92 files (+84), across 10 repos — nextcloud-vue 50, procest 13, pipelinq 9, doriath 6, docudesk 4, openregister 3, softwarecatalog 3, decidesk 2, app-versions 1, hermiq 1. nextcloud-vue is the shared component library and accounts for over half; its findings are dialog components sitting outside src/dialogs/ rather than modals inlined in a parent. Gate-13 is diff-scoped under ADR-020, so none of this blocks a PR that does not touch the file. Landing as a hard FAIL, unchanged from what gate-13 already is. Comment masking suppresses zero findings across the fleet today; it is here so a <!-- <NcDialog … --> in a TODO cannot become one later, which is exactly how gate-20 acquired its commented-out call (#294). The checker now reports a crashed interpreter as wiring instead of leaving an empty log this gate would call clean (#147/#249/#262). test_gate_13_multiline_dialog.sh proves both directions and goes 3 red against the pre-fix runner while 4 anti-widening arms stay green. Closes #321 * fix(gate-20): mask comments before searching — a commented-out call is not a call The first thing the un-blinded gate reported in the fleet was not a call. It was openconnector lib/Service/SearchService.php:189: // $directory = $this->objectService->findObjects(filters: [...]); grep has no idea what a comment is, and a gate whose first live finding is false is a gate people learn to ignore. Applies the pass gate-5 received in #196: source_scope.py --mask php, which blanks //, # and /* */ while PRESERVING offsets and newlines, so the reported line number still addresses the real file. #[ is left alone — it opens a PHP 8 attribute, not a comment, and swallowing it would delete #[NoAdminRequired], the line these calls sit directly under. The log now prints the ORIGINAL source line rather than the blanked mask, so a reader sees the code that is actually at that line. The mask inherits this gate's own rule: if it cannot run, the gate reports wiring and NOT a pass. Falling back to raw text would silently restore the false positive; treating empty mask output as clean would make gate-20 green everywhere — the 2026-08-08 failure mode in a new costume. Fleet-wide finding count: 2 -> 1. The one removed is openconnector's commented-out line; the one kept is shillinq lib/Controller/BookingNotificationController.php calling findObject() on a container-resolved OCA\OpenRegister\Service\ObjectService, which is the real yield #271 identified. test_gate_20_comment_masking.sh proves both directions and goes 2 red against the pre-fix runner while all four true-positive arms — including #271's receiver anchoring and the #[Attribute] non-swallow — stay green. Closes #294 * style(test): fixed-string grep so the $ needs no escape (SC2016) * style(test): escape $ inside double quotes — SC2016 fires on single quotes --------- Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
…6, 7, 9 and 59-64 (#330) The gate-20 construct, and its mirror image. BAND 1 — `2>/dev/null || true` (gates 6, 7, 9) Three gates invoked their Python helper as `>> log 2>/dev/null || true` and then took the verdict from `wc -l` on the log. That discards the helper's message AND its exit status, so a helper that never started wrote nothing, the count was 0, and the gate printed PASS. It is the exact mechanism behind gate-20 having NEVER fired in any repo in its entire existence. Measured at package 71e01bf, one fixture, two runs: working python3 python3 that exits 1 [gate-6] orphan-auth: FAIL [gate-6] orphan-auth: PASS [gate-7] no-admin-idor: FAIL [gate-7] no-admin-idor: PASS [gate-9] semantic-auth: FAIL [gate-9] semantic-auth: PASS Three SECURITY gates — dead authorization code, IDOR, auth-attribute-vs-body mismatch — reporting a tree clean that they had not opened. gate-7 is the gate a MISSING helper had already blinded once over 11 real unguarded endpoints (#147); `2>/dev/null || true` left the second route to the same false green. All three helpers end `return 0 # exit 0 always — caller counts printed lines`, so a non-zero byte can only be a crash and never a finding count. stderr now goes to a `.err` sibling and a non-zero status produces a named `_skip <n> ... wiring` quoting the first lines of it. BAND 2 — a crash wearing an invented finding count (gates 59-64) The same disease in red. These six take a numeric exit protocol from their helper (0 pass / 1 findings / 3 empty scope / 4 n-a / 5 tooling missing) and sent every unrecognised code to `_fail`. A crash also exits 1, so it landed in the findings branch — and because a traceback carries no `FAIL` lines, `_count '^FAIL'` returned 0 and the next line read `[ "${_n}" -eq 0 ] && _n=1`, INVENTING a count of one for a check that had measured nothing. working python3 python3 that exits 1 [gate-59] unclosable-gate: PASS FAIL — config gate(s) read but never written [gate-62] store-plane: NOT APPLICABLE FAIL — 1 naming or discovery violation(s) [gate-63] settings-surface: NOT APPL. FAIL — 1 naming or discovery violation(s) 62 and 63 blocked a repo that does not have the subject matter at all. The exit byte cannot separate crash from finding, because 1 means both. What can is that every one of these helpers prints a TERMINAL SUMMARY LINE on each path that reaches a verdict, and a process that died before its own summary never printed it — the evidence gates 15/16/17 already take from `# count=` (#271), read from summaries these helpers already emit. `_helper_finished` holds the rule once. Gates 59 and 64 print no `FAIL`-prefixed lines at all, so their count was ALWAYS the invented 1. It now comes off the helper's own summary line. WHAT IS DELIBERATELY LEFT TOLERANT `|| true` is not deleted wholesale: this runner has no errexit (see the header) precisely so a gate returning non-zero is normal, and #243 showed that removing tolerance can abort a run and leave 38 gates unexecuted. Every `|| true` kept here is on infrastructure whose failure cannot manufacture a verdict — mktemp, mkdir, the provenance banner, `grep -c` on an empty stream — or on a filter whose failure direction is over-reporting, never under. PROOF, BOTH DIRECTIONS Each fix is exercised with the helper alive and with it exiting 1. The live verdicts over the same fixture are byte-identical to the pre-fix baseline, so nothing became a skip that was not one. A --require-full-coverage run on a fixture with planted true positives reports "34 of 34 applicable gates ran" — no new skip trips it. test_gate_crashed_checker_is_not_a_finding.sh gains gates 6/7/9 in its generic dead-interpreter arm, a two-arm arm for 59-64, an assertion that gate-59's count is measured, and a detector for the construct itself — which fires on exactly the three pre-fix sites and is clean afterwards.
…422) (#445) Two more #415-class false negatives, both in the RUNNER's own inline greps. Each was reproduced on origin/main before it was touched, through the gate, with a fixture whose only evidence is a comment. gate-14 route-reachability, invariant 1 ThingController::orphanReport(): JSONResponse, no route entry -> FAIL — rule=missing-route (correct) the same tree with routes.php gaining ONE LINE `// TODO: wire up 'thing#orphanReport' once the export lands.` -> PASS <- the defect gate-3 stub-scan, caller-identity arm public function authorize(string $uid, string $id): bool { … } with $uid never referenced -> FAIL — rule=caller-identity-ignored (correct) the same method with ONE LINE `// TODO: verify $uid actually owns this object before returning true.` -> PASS <- the defect gate-14's in-code note at that grep says comment hits are "vanishingly rare". It anticipated the false POSITIVE and missed the false NEGATIVE — the direction in which the endpoint 404s at runtime and the gate says the route exists. gate-3's arm exists BECAUSE the builder's fix-mode wrote methods that accept a caller identity and ignore it (decidesk#45); a stub that DOCUMENTS what it does not do was being reported finished. A THIRD one, found while writing the fixture and not in the survey: a commented-out `'resources' => [...]` block exempted a live controller's whole CRUD quintet from invariant 1. Written as a control, the revert says it FLIPS. MEASURED ON REAL REPOS, BOTH DIRECTIONS. Full runs over procest, opencatalogi, openregister, softwarecatalog, docudesk and larpingapp, before and after: EVERY COUNT IDENTICAL. procest g3=3 g14=0 docudesk g3=0 g14=12 opencatalogi g3=0 g14=0 larpingapp g3=0 g14=0 openregister g3=0 g14=0 softwarecatalog g3=0 g14=0 Agreement is what a dead rig looks like, so both gates were given a POSITIVE CONTROL on a real repository — a COPY of docudesk, the one repo with a non-zero gate-14 baseline, whose 12 genuine findings the copy reproduces: gate-14 one comment line added to appinfo/routes.php naming 'portalSigningReceiver#viewDocument' — one of its three real missing-route findings on a live controller origin/main 12 findings -> 11 (a real one hidden) fixed 12 -> 12 gate-3 a stub service method taking $uid and ignoring it origin/main FAIL — 1 fixed FAIL — 1 (both see it) + `// TODO: verify $uid actually owns this report…` origin/main PASS (blind) fixed FAIL — 1⚠️ The gate-3 control is reported in TWO steps on purpose. Step (a) — both arms FAIL — is not the measurement; it is what makes step (b) one. Read alone, "1 = 1" looks like "the change did nothing", which is exactly what an arm that never tested anything also looks like.⚠️ STRING CONTENTS SURVIVE IN BOTH, AND IN BOTH THEY ARE THE EVIDENCE. `php_mask` keeps literals by default and that is not an oversight here: gate-14 a route name IS the literal 'thing#index' and nothing else. Blanking literals would not widen this gate, it would delete every route in the file. gate-3 PHP INTERPOLATES `"no such user: $uid"` — that is a genuine use of the parameter. Blanking literals would report correct methods as unfinished stubs. The single-quoted '$uid' that is NOT a use stays a residual false negative and is #424's; it is the fail-safe direction for a gate that accuses a method of being unfinished. Arms 3 and 6 of the new suite go red the moment somebody generalises "strings are not evidence" across this file. A MASK THAT CANNOT BE PRODUCED IS NOT A LICENCE TO GRADE RAW TEXT. Both gates now decline — SKIPPED (wiring) — when source_scope.py is missing or fails its positive control, rather than falling back to the raw file. A silent fallback is precisely the false negative being closed and it leaves no log to notice; this package has found that shape four times (#147, #245, #276, #374). gate-3's guard fires on the ARM, not the file: without it the gate would print PASS with one of its four arms switched off. Arm 7 removes source_scope.py from a copy of the package and asserts both gates decline. The shared `_php_code_copy` helper carries its own positive control, once per run: of `// $uid` and `$uid`, exactly ONE must survive. A helper that echoed its input back would put every caller straight back into the false negative, which is gate-5's rule (#147, #245) applied to the two gates it had not reached. It is NOT a second copy of gate-5's `_ra_masked_copy` — folding gate-5 into it means re-proving gate-5's suite and belongs in its own change. Invariant 2 still reads routes.php RAW. Its exposure is the OPPOSITE direction (a commented-out route name manufacturing a phantom route) and belongs with the false-positive half, #423. Changing both here would put two independent verdict changes behind one measurement. 🔴 REPORTED, NOT FIXED: the runner's `_php_code_only` (~L919) is a line-PREFIX filter, so an unprefixed interior line of a `/* */` block survives it. Reproduced: a removal note reading "this app used to return \OCA\OpenRegister\AppHost\Routes::standard($extra) … It no longer does" sets _HYDRA_APPHOST_ROUTE_TABLE=1 and injects ten canonical route names, exempting all of them from invariant 1. The comment directly above that helper already records this class — it says the first cut was raw greps and that a fixture "was EXEMPTED BY ITS OWN EXPLANATION" — and it was then repaired with a filter that only handles the comment shape that fixture used. It gates exemptions read by gates 5, 14, 30 and 56, so it needs its own change and its own before/after. TESTS. New suite test_gate_3_14_comment_evidence.sh, 9 arms; discovered by tests/run-helper-suites.sh with no workflow edit. Reverted against origin/main — via a separate `git worktree` of origin/main, so SCRIPT_DIR resolves to that tree's own lib/ — three FLIP (arms 2, 4, 5) and arms 1, 3 and 6 pass either way and are labelled CONTROLS. Arm 7 is neither: on origin/main neither gate consults the helper at all, so it exists because the fix creates the dependency. Refs #422, #415. Sibling PRs cover the PHP call-site gates and the registry/config gates; gate-38 is BLOCKED on #424 (its fix needs markup masking, and routing it into source_scope.markup_mask today would close its comment half and leave the delimiter half armed). gate-30 and gate-1 are deliberately NOT in this sweep — see the findings note. Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
The defect
Sixteen gates in
hydra-gates/scripts/run-hydra-gates.shenumerated their files, found work to do, discovered their helper script was missing, echoed aWARNto stderr, and then fell through to_passon stdout.An empty findings log because the helper never ran is byte-identical to an empty log because there were no findings. Every consumer of this runner anchors on
^\[gate-, so the WARN was invisible to all of them.It also defeated the coverage machinery built to catch it.
_passadds the gate to_EMITTED_GATES, and the summary computes "GATES THAT DID NOT RUN" as declared minus emitted. So these sixteen branches made themselves invisible to the accounting, to theALL N GATES GREENbanner, and to--require-full-coverage(exit 98).Two of the sixteen are authorization gates: gate-6 (orphan-auth) and gate-7 (no-admin-idor).
Proof, on a real repo checkout (decidesk @
2b65908)Before, helper present:
Before, same tree, helper renamed away:
Eleven unguarded
#[NoAdminRequired]endpoints became aPASS, the failure count went 18 → 17, and the coverage line was identical in both directions.After, helper renamed away:
After, helper present — byte-identical to before. No behaviour change when the helper runs.
The fix
Per gate: a
_ranflag, a_skip <n> <name> <reason>call on the helper-absent path, and the existing pass/fail block guarded by that flag._skipalready existed and records the gate without adding it to_EMITTED_GATES.Sites: gates 6, 7, 9, 15, 16, 18, 19, 25, 26, 27, 51, 52, 54, 55, 56, 57.
gate-18keeps its deliberate advisory half. Check (a) — legacy dialect, hard fail — is helper-driven and now skips. Check (b) — imperative dispatch,WARNING, non-blocking — is pure bash, still runs, and is explicitly left outside the_ranguard.Tests
tests/test-hydra-gates-bin.shgains a two-directional, attributable control: one fixture, two package copies differing only by the two security helper files. AssertsSKIPPED, asserts notPASS, asserts both gates are named in DID-NOT-RUN, and asserts coverage drops by exactly 2.Verified failable — against the unpatched runner it reports 4 failures, including
coverage went 35 → 35.test_check_custom_widget_ratchet.py::test_helper_absent_warn_skipshad codified the defect: it assertedcustom-widget-ratchet: PASSwith the helper absent, on a fixture containing a real finding. Rewritten to the correct contract.test-hydra-gates-bin.shrun-helper-suites.shNotes for the reviewer
_filter_preexistingdoes NOT share this defect shape. It is silent when its helper is absent, but the consequence is inverted: it removes pre-existing findings from a log, so its absence leaves more findings in place. Fail-closed (noisier), not fail-open (blinder)._SKIPPED_GATESis genuinely write-only — written by_skip, read nowhere. It should stay that way, and this PR adds a comment saying why: the summary derives from declared minus_EMITTED_GATES, which is strictly broader, because it also catches gates that emit nothing at all when an enclosingif [ -d src ]-style prerequisite is false. Rewiring the report onto_SKIPPED_GATESwould narrow it back to explicit skips only and silently reopen this hole._passwithout inspecting anything. Thebin/hydra-gateswrapper does reportSCOPE WAS EMPTYseparately, so this one is stated rather than silent.Risk
ConductionNL/.githubis shared infrastructure for the whole fleet. The change is mechanical and additive: no gate is weakened, disabled, or made more permissive, and every pass/fail path is byte-for-byte unchanged when the helper is present. Where a gate's verdict changes it changes from a falsePASStoSKIPPED, which the coverage accounting then surfaces.Default branch is
main, so this is left open for a human to merge — not merged by the agent.