Skip to content

fix(gates): 16 gates reported PASS when their helper never ran — 2 are authorization gates - #147

Merged
rubenvdlinde merged 1 commit into
mainfrom
fix/dead-gates-helper-absent-must-skip
Aug 4, 2026
Merged

fix(gates): 16 gates reported PASS when their helper never ran — 2 are authorization gates#147
rubenvdlinde merged 1 commit into
mainfrom
fix/dead-gates-helper-absent-must-skip

Conversation

@rubenvdlinde

Copy link
Copy Markdown
Contributor

The defect

Sixteen gates in hydra-gates/scripts/run-hydra-gates.sh enumerated their files, found work to do, discovered their helper script 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_lib_dir}/check_orphan_auth.py" "${_oa_files[@]}" >> "${_oa_log}" 2>/dev/null || true
else
    echo "[gate-6] WARN: check_orphan_auth.py not found ... — gate-6 skipped" >&2
fi
_oa_fail=$(wc -l < "${_oa_log}" 2>/dev/null || echo 0)
if [ "${_oa_fail}" -eq 0 ]; then
    _pass 6 "orphan-auth"        # <- runs even though the helper never did

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. _pass adds 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 the ALL N GATES GREEN banner, 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:

[gate-6] orphan-auth: PASS
[gate-7] no-admin-idor: FAIL — 11 method(s) with NoAdminRequired + no guard
[hydra-gates] COVERAGE: 61 of 63 declared gates reported a result.

Before, same tree, helper renamed away:

[gate-6] orphan-auth: PASS
[gate-7] no-admin-idor: PASS
[hydra-gates] COVERAGE: 61 of 63 declared gates reported a result.
[hydra-gates]   gate-24 integration-parity
[hydra-gates]   gate-33 axe-core

Eleven unguarded #[NoAdminRequired] endpoints became a PASS, the failure count went 18 → 17, and the coverage line was identical in both directions.

After, helper renamed away:

[gate-6] orphan-auth: SKIPPED — check_orphan_auth.py not found at … — 153 PHP file(s) were in scope and NONE were inspected; orphaned (defined-but-never-called) authorization methods are UNVERIFIED by this run.
[gate-7] no-admin-idor: SKIPPED — check_no_admin_idor.py not found at … — 40 controller file(s) were in scope and NONE were inspected; unguarded #[NoAdminRequired] endpoints (IDOR, OWASP A01:2021) are UNVERIFIED by this run.
[hydra-gates] COVERAGE: 59 of 63 declared gates reported a result.
[hydra-gates] GATES THAT DID NOT RUN — they inspected NOTHING, and their subject
[hydra-gates] matter is UNVERIFIED by this run:
[hydra-gates]   gate-6 orphan-auth
[hydra-gates]   gate-7 no-admin-idor
[hydra-gates]   gate-24 integration-parity
[hydra-gates]   gate-33 axe-core

After, helper present — byte-identical to before. No behaviour change when the helper runs.

The fix

Per gate: a _ran flag, a _skip <n> <name> <reason> call on the helper-absent path, and the existing pass/fail block guarded by that flag. _skip already 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-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

  • tests/test-hydra-gates-bin.sh gains a two-directional, attributable control: one fixture, 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 the correct contract.
suite before after
test-hydra-gates-bin.sh 19 passed / 0 failed 25 passed / 0 failed
run-helper-suites.sh 17 passed / 0 failed / 2 quarantined 17 passed / 0 failed / 2 quarantined

Notes for the reviewer

  • _filter_preexisting does 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_GATES is 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 enclosing if [ -d src ]-style prerequisite is false. Rewiring the report onto _SKIPPED_GATES would narrow it back to explicit skips only and silently reopen this hole.
  • Not fixed here (separate shape, flagged not changed): when a gate's scope is empty (no files in the diff), gates 6/7 still _pass without inspecting anything. The bin/hydra-gates wrapper does report SCOPE WAS EMPTY separately, so this one is stated rather than silent.

Risk

ConductionNL/.github is 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 false PASS to SKIPPED, 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.

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.
@rubenvdlinde

Copy link
Copy Markdown
Contributor Author

Admin-merging to main on the maintainer's explicit instruction for this specific PR.

Verified before merging, since this file runs in every repo's gate job:

checks 7 SUCCESS, 0 failing, 0 pending
state OPEN / MERGEABLE, not a draft
diff +316 / −97 across 3 files

The evidence that made this urgent, restated for the record — measured on a real checkout (decidesk 2b65908), not reasoned about:

gate-6 orphan-auth gate-7 no-admin-idor
helper present PASS FAIL — 11 methods with NoAdminRequired and no guard
helper renamed away, unpatched PASS PASS

Eleven real unguarded endpoints reported clean, and the run's own COVERAGE: 61 of 63 line printed identically in both directions — because _pass had already added the gate to _EMITTED_GATES, so the coverage accounting could not see the hole. After the fix: both report SKIPPED with a reason, coverage drops 61 → 59, and both are named under GATES THAT DID NOT RUN. Helper-present behaviour is byte-identical to before.

Gate suite: 19 → 25 passing, 0 failed. The new test was shown to fail against the unpatched runner (4 failures, including coverage went 35 → 35), so it is not green-by-construction.

Two things worth carrying forward:

  • A test had codified the defecttest_check_custom_widget_ratchet.py::test_helper_absent_warn_skips asserted PASS with the helper absent, on a fixture containing a real finding. It went red on this fix because it was pinning the bug.
  • _SKIPPED_GATES is deliberately left write-only. I had suggested wiring it into the summary; that would have been a regression, since coverage derives from declared − _EMITTED_GATES, which is strictly broader — it also catches gates that emit nothing when a prerequisite like if [ -d src ] is false.

Still open and not addressed here: gates 6/7 also _pass when the scope is empty (no files in the diff). That one is at least loud — bin/hydra-gates prints SCOPE WAS EMPTY.

@rubenvdlinde
rubenvdlinde merged commit 549ad1a into main Aug 4, 2026
7 checks passed
This was referenced Aug 4, 2026
rubenvdlinde added a commit to ConductionNL/decidesk that referenced this pull request Aug 5, 2026
…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
rubenvdlinde added a commit to ConductionNL/docudesk that referenced this pull request Aug 5, 2026
…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
rubenvdlinde added a commit to ConductionNL/launchpad that referenced this pull request Aug 5, 2026
…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
rubenvdlinde added a commit to ConductionNL/nextcloud-app-template that referenced this pull request Aug 5, 2026
…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
rubenvdlinde added a commit to ConductionNL/larpingapp that referenced this pull request Aug 5, 2026
…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
rubenvdlinde added a commit to ConductionNL/planix that referenced this pull request Aug 5, 2026
…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
rubenvdlinde added a commit to ConductionNL/shillinq that referenced this pull request Aug 5, 2026
…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
rubenvdlinde added a commit to ConductionNL/softwarecatalog that referenced this pull request Aug 5, 2026
…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
rubenvdlinde added a commit to ConductionNL/zaakafhandelapp that referenced this pull request Aug 5, 2026
…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
rubenvdlinde added a commit to ConductionNL/openregister that referenced this pull request Aug 5, 2026
…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
rubenvdlinde added a commit to ConductionNL/hermiq that referenced this pull request Aug 5, 2026
…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
rubenvdlinde added a commit to ConductionNL/openconnector that referenced this pull request Aug 5, 2026
…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
rubenvdlinde added a commit that referenced this pull request Aug 6, 2026
…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>
rubenvdlinde added a commit that referenced this pull request Aug 8, 2026
… 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.
rubenvdlinde added a commit that referenced this pull request Aug 8, 2026
…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
rubenvdlinde added a commit that referenced this pull request Aug 8, 2026
…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.
rubenvdlinde added a commit that referenced this pull request Aug 9, 2026
…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.
rubenvdlinde added a commit that referenced this pull request Aug 10, 2026
… 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>
rubenvdlinde added a commit that referenced this pull request Aug 10, 2026
…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.
rubenvdlinde added a commit that referenced this pull request Aug 13, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant