Skip to content

fix(gates): seven of gates 23-33 could not fail on their own subject matter - #276

Merged
rubenvdlinde merged 1 commit into
mainfrom
fix/gates-23-33-acceptance
Aug 8, 2026
Merged

fix(gates): seven of gates 23-33 could not fail on their own subject matter#276
rubenvdlinde merged 1 commit into
mainfrom
fix/gates-23-33-acceptance

Conversation

@rubenvdlinde

Copy link
Copy Markdown
Contributor

Acceptance test applied to every gate in the 23–33 band: plant ONE textbook true positive in a real fleet repo, run the gate, require it to FAIL and NAME the defect, remove the plant, require the prior verdict back, and keep a clean sibling passing so no repair is a widening. [hydra-gates] gate package: cdfbd7ab1e28d8619fafa6cf772b0013ca4f9595.

gate planted true positive repo fired before? repaired
23 or-abstraction lib/Service/TenantIsolationService.php doriath PASS over the finding yes
24 integration-parity registerIntegration id renamed away from its LeafDescriptor hermiq (fires) / openregister (does not) ⚠️ fires on hermiq; PASS over a self-declared skip on openregister yes
25 contract-coverage new #[NoAdminRequired] endpoint + route, no contract test openregister
26 visual-coverage new src/views/*.vue with no baseline openregister ⚠️ 1 yes, 256 → PASS yes
27 no-phantom-rpc ->getLeaf('docudesk.…') openregister ⚠️ yes; crash → PASS yes
28 license-triangle @license MIT vs composer EUPL-1.2 openregister
29 gitignore-then-commit /docs/ added to .gitignore over 633 tracked files openregister ⚠️ yes; PASS having read nothing when unscoped yes
30 public-monitoring #[PublicPage] deleted from GenericHealthController::index openregister / launchpad NOT APPLICABLE in the repo that owns the file yes
31 img-alt <img> with no alt after a nested <template #slot> openregister evidence line + na
32 semantic-controls <div @click> after a nested slot, below an HTML comment openregister evidence line + na
33 axe-core report with a critical violation / a {} report openregister ⚠️ yes; {} → PASS yes

The seven repairs

gate-23 — the linter is bake-in gated: in WARN mode it exits 0 whether it matched nothing or everything, and the gate read only the byte. Separately, its grep -v -i openregister exclusion reads the file path, which inside the openregister repository never contains that string — 33 findings there, every one a canonical OpenRegister class told to consume itself, and the linter starts BLOCKING on 2026-08-13. Now: identity from appinfo/info.xml <id>; the ADR-022 "consume the OR abstraction" rules do not apply to the repo that provides them (announced, never silent); the linter emits or_abstraction_findings=<n> and the gate states the count and surfaces the matched rules even when it cannot block. openregister goes 35 matched paths → 1 (PDOK, whose provider is openconnector — still applies, still fires).

gate-24 — openregister's wrapper prints canonical JS check not found locally — skipping and exits 0. The gate read that as a PASS. A self-declared skip is now classified on the gate's own subject matter (na with no leaves, structural with leaves).

gate-26check_visual_coverage.py returned the finding count as its exit status. Measured on openregister: 260 uncovered pages → exit 4 → FAIL — 4; 256 → exit 0 → [gate-26] visual-coverage: PASS, with the helper's own stdout reading FAIL — 256 on the same run. This is #209 in a second gate. Status vocabulary now matches gate-25's; the count travels on stdout; stderr is kept; empty scope is na; and it no longer defaults HYDRA_GATE_BASE_REF (#242), so full-repo mode reaches it — doriath has 10 uncovered page components CI has never seen.

gate-272>/dev/null || true around a helper whose only nonzero exit is a crash. Measured: 404 files in scope, none judged, verdict PASS. Now SKIPPED (wiring) with the traceback kept.

gate-29 — a bare [ fail -eq 0 ] && PASS reachable by both guards, so an unscoped run and a repo with no .gitignore printed the same PASS as a real inspection. Now na, with the checked rule count stated on a genuine pass. The ignore prefix is also escaped before being used as an ERE. (The gate does not call git check-ignore at all, so the missing---no-index trap does not apply — a comment now says so, for the next editor.)

gate-30AppHost\Controller\GenericHealth#index maps by PSR-4 to lib/Controller/AppHost/Controller/…, but openregister DI-binds it to lib/AppHost/Controller/GenericHealthController.php. The gate called the file "not present in this repository"inside the repository that contains it — so /api/health and /api/metrics were judged by this gate nowhere in the fleet. Resolved by basename under lib/ when exactly one tracked file matches. The launchpad carve-outs are unchanged and asserted in the suite: a per-object GET and a POST are still not scrape targets, because the only remedy for flagging them is publishing an outbound-ping oracle.

gate-33 — a {} report parsed as "no violations" and reported PASS. A JSON file with no violations key is not an axe result object; it is now a wiring skip, and a real report prints how many violations a PASS is a pass over.

Shared helper, affects gates 5/9/30filter_preexisting_methods.py compared method bodies starting at the function NAME( line, so deleting #[PublicPage] left the body byte-identical and the finding was moved into .preexisting. Measured: findings log empty, .preexisting holding the finding, gate-30 PASS. The comparison now includes the attribute/docblock region — a change that can only move entries back into the findings log.

Tests

All mutation-checked against the pre-fix files (whole pre-fix package as the mutant):

  • scripts/lib/test_gates_23_33_never_green_over_nothing.sh — 39 assertions over a planted/ + clean/ fixture pair plus a crashed-checker arm. 12 fail against main's checkers.
  • scripts/lib/test_check_visual_coverage.py — 10 assertions including the 256-case whose low byte is zero. 6 fail against main's helper. (The helper shipped with no tests at all.)
  • test_filter_preexisting_methods.py — attribute-removal case (fails on main) plus its anti-widening sibling.
  • tests/test-hydra-gates-bin.sh — its "src/ exists so every src-guarded gate runs" control now supplies real markup and a real page component. [ -d src ] was never gates 26/31/32's subject matter, and asserting PASS over a src/ holding one .js file is exactly the nldesign shape this work exists to stop.

Full suites: tests/run-helper-suites.sh 49 passed / 0 failed (2 pre-existing quarantines), tests/test-hydra-gates-bin.sh 59 passed / 0 failed.

Not changed, deliberately

  • gate-23 still does not block before its bake-in epoch (2026-08-13). That is policy, not a defect — but a WARN-mode green is no longer indistinguishable from a clean one. Note that on that date doriath fails on a pre-existing lib/Listener/AuditListener.php.
  • No gate was widened. Every repair is either "read the number the checker printed instead of the byte", "say na when nothing was opened", or "the provider of an abstraction is not a consumer of it".

…matter

Acceptance test applied to every gate in the band: plant ONE textbook true
positive in a real fleet repo, run the gate, require it to FAIL and NAME the
defect, remove the plant, require the prior verdict back, and keep a clean
sibling passing so no repair is a widening.

Eight gates already passed that test unchanged (25, 26*, 27*, 28, 29*, 31, 32,
33*) — the starred ones only for the shape that was planted. Seven could not:

gate-23  The linter is bake-in gated: in WARN mode it exits 0 whether it
         matched nothing or everything, and the gate read only the byte. A
         planted lib/Service/TenantIsolationService.php on doriath produced
         `[gate-23] …: PASS`, byte-identical to a clean repo. Worse, its
         `grep -v -i openregister` exclusion reads the FILE PATH, which inside
         the openregister repository never contains that string: 33 findings
         there, every one a canonical OpenRegister class being told to consume
         itself, and the linter starts BLOCKING on 2026-08-13.
         Fixed: identity by appinfo/info.xml `<id>`, not `basename $(pwd)`; the
         ADR-022 "consume the OR abstraction" rules do not apply to the repo
         that PROVIDES those abstractions (announced, never silent); the linter
         emits `or_abstraction_findings=<n>` and the gate STATES the count and
         surfaces the matched rules even when it cannot block. Measured on
         openregister: 35 matched paths -> 1 (the PDOK rule, whose provider is
         openconnector, still applies and still fires).

gate-24  openregister's parity wrapper exits 0 printing "canonical JS check not
         found locally — skipping". The gate read that 0 as a PASS, so the repo
         that owns the integration registry correlated nothing and said it was
         fine. A self-declared skip is now classified on the gate's own subject
         matter — na when no leaves are registered, structural when they are.
         (hermiq's wrapper is the honest shape and still fires: renaming its
         registerIntegration id produced 2 R2 id-correlation violations.)

gate-26  check_visual_coverage.py returned the FINDING COUNT as its exit status
         and the gate read that byte as the count. Measured on openregister:
         260 uncovered new page components -> exit 4 -> "FAIL — 4"; 256 ->
         exit 0 -> "[gate-26] visual-coverage: PASS", with the helper's own
         stdout reading "FAIL — 256" on the same run. This is .github#209 in a
         second gate. The helper now returns 0/1/2/3 like gate-25's and puts
         the count on stdout; the gate reads it there, keeps stderr, calls an
         empty scope `na` and a crash `wiring`. It also stopped defaulting
         HYDRA_GATE_BASE_REF (#242), so a full-repo run reaches it — doriath
         has 10 uncovered page components CI has never seen.

gate-27  `2>/dev/null || true` around a helper that returns 0 on every
         successful path: its only nonzero exit is a crash, and a crash gave an
         empty log and a PASS. Measured: 404 files in scope, none judged,
         verdict PASS. Now a wiring skip with the traceback kept. An empty file
         set is `na` rather than PASS.

gate-29  A bare `[ fail -eq 0 ] && PASS` reachable by both guards, so an
         unscoped run and a repo with no .gitignore printed the same PASS as a
         run that inspected a real .gitignore change. Now na, with the checked
         rule count stated on a genuine pass. The ignore prefix is also escaped
         before being used as an ERE — `.phpunit.cache` was a pattern, not data.

gate-30  A route named `AppHost\Controller\GenericHealth#index` maps by PSR-4
         to lib/Controller/AppHost/Controller/…, but openregister DI-binds it
         to lib/AppHost/Controller/GenericHealthController.php. The gate called
         the file "not present in this repository" — inside the repository that
         contains it — so /api/health and /api/metrics were judged nowhere in
         the fleet. Now resolved by basename under lib/ when, and only when,
         exactly one tracked file matches. The healthPing carve-outs are
         unchanged and asserted: a per-object GET and a POST are still not
         scrape targets, because the only remedy for flagging them is
         publishing an outbound-ping oracle.

gate-33  A report containing `{}` parsed as "no violations" and reported PASS,
         turning the loud missing-report skip into a silent false green. A JSON
         file with no `violations` key is not an axe result object — axe-core
         always emits it — and is now a wiring skip. A real report prints how
         many violations a PASS is a pass over.

Also, in the shared provenance filter that gates 5/9/30 depend on:
filter_preexisting_methods.py compared method BODIES starting at the
`function NAME(` line, so deleting `#[PublicPage]` left the body byte-identical
and the finding was moved out of the gate log. Measured on openregister with
the attribute removed from its own GenericHealthController in the diff under
test: findings log empty, `.preexisting` holding the finding, gate-30 PASS.
The comparison now includes the attribute/docblock region, which can only move
entries back INTO the findings log.

Gates 31 and 32 kept their rules; both now report `na` instead of PASS when no
markup file is in scope, and state how many files a PASS covers.

Tests (all mutation-checked against the pre-fix files):
  scripts/lib/test_gates_23_33_never_green_over_nothing.sh — 39 assertions over
    a planted/clean fixture pair plus a crashed-checker arm; 12 fail against
    main's checkers.
  scripts/lib/test_check_visual_coverage.py — 10 assertions incl. the 256-case
    whose low byte is zero; 6 fail against main's helper.
  test_filter_preexisting_methods.py — attribute-removal case (fails on main)
    plus its anti-widening sibling.
  tests/test-hydra-gates-bin.sh — its "src/ exists so every src-guarded gate
    runs" control now supplies real markup and a real page component, because
    `[ -d src ]` was never gates 26/31/32's subject matter and asserting PASS
    over a src/ holding one .js file is the nldesign shape it exists to stop.
@rubenvdlinde
rubenvdlinde merged commit 34370f6 into main Aug 8, 2026
31 checks passed
rubenvdlinde added a commit that referenced this pull request Aug 9, 2026
…o lib/Controller/

NC's RouteParser::buildControllerName() does not prefix the app namespace when
the route name already contains a backslash, so
`AppHost\Controller\GenericHealth#index` is looked up as the bare class
`AppHost\Controller\GenericHealthController`. PSR-4 maps `OCA\<App>\` onto
`lib/`, so that class lives at lib/AppHost/Controller/ — not under
lib/Controller/, which is the only place the resolver looked.

Reproduced against this package's own gates-23-33/planted fixture:

  lib/Controller/AppHost/Controller/GenericHealthController.php
    route='AppHost\Controller\GenericHealth#index'
    rule=controller-class-not-found

a path that cannot exist, reported as a missing class, INSIDE the repository
that ships the file. Same shape as the gate-30 finding in #276: the path the
gate derives is not the path the app uses.

The false FAIL was only half of it. Where a DI binding rescued the absence the
loop `continue`d, so the method-existence check never ran — measured on two new
fixtures differing by exactly one renamed method: at origin/main BOTH report
PASS, including the one whose route resolves to nothing. That is #265's defect
at a different address.

Both candidate paths are probed and the one that EXISTS wins; when neither does
the lib/Controller/ spelling is reported exactly as before, so a genuinely
missing controller reads the same as it always has.

Also: test_gate_route_registration.sh hardcoded its runner, so pointing
HYDRA_GATES_RUNNER_UNDER_TEST at a pre-fix tree silently kept running the FIXED
one and reported all-green. A mutation check that cannot fail is the same defect
as a gate that cannot fail. With the override honoured, the suite is 5 failures
red against origin/main and green after.

And test-hydra-gates-bin.sh's 'with src/ present, every src-guarded gate really
runs' loop had a stale premise: #276 gave that fixture two real .vue files
(because gates 26/31/32 now distinguish 'src/ exists' from 'src/ contains what I
read'), so gates 12 and 13 correctly run and PASS there. They go back in the
loop, and the loop now asserts its own premise first. The zero-.vue case is
asserted where its premise actually holds — the nldesign-shaped fixture in
test_gate_empty_scope_never_passes.sh.

52 helper suites pass, 60 bin assertions pass.
rubenvdlinde pushed a commit that referenced this pull request Aug 9, 2026
The branch was rebased from fef032b onto 34370f6 (#276) after main moved, which
rewrote its commits. `git cherry HEAD origin/<branch>` reports NO commit on the
remote that is missing from this history — the remote tip's two commits are the
pre-rebase spelling of the first two here — so this merge records the
reconciliation and keeps the rebased tree, which is a strict superset.
rubenvdlinde added a commit that referenced this pull request Aug 9, 2026
…to check, and gate-5 punted to a package it was standing in

Three additions after the first commit, all measured against gate package
34370f6 and reported by the runner's own `[hydra-gates] gate package:` line.

gate-10 DECLARED `na` OVER A REPO FULL OF ITS OWN SUBJECT MATTER
-----------------------------------------------------------------
The surface was `src/` only. nldesign's `src/` holds exactly one file —
`manifest.json` — and its entire hand-written frontend lives in `js/`. So
gate-10 announced "this repo ships no frontend" over a repo whose
`js/admin.js` does precisely what the gate exists to catch:

    var settingsEl = document.getElementById('nldesign-settings');
    var tokenSets  = JSON.parse(settingsEl.getAttribute('data-token-sets'));

The doriath AdminRoot defect, two-step form, in an ADMIN settings script. Four
real findings. `na` REMOVES A GATE FROM COVERAGE ACCOUNTING, so this did not
merely under-report — it left the denominator. The surface is now `src/` + `js/`
(Nextcloud's conventional shipped-script directory), minus `*.min.js`: a
committed bundle is not authored code. Fleet total 0 -> 4, all four in
nldesign, all four genuine.

AND A SCOPE-BLINDNESS BUG IN MY OWN TWO-STEP RULE
--------------------------------------------------
Widening the surface exposed it. The two-step rule matches by NAME across the
whole file. In `js/admin.js`, line 1114 binds
`var btn = document.getElementById('nldesign-save-btn')`, and that one line put
EVERY `btn` in a 1700-line file into scope — including three
`forEach(function (btn) {...})` callbacks whose `btn` is the clicked element,
i.e. the component's own markup. Four false positives from one binding. Rather
than implement JS scoping, an AMBIGUOUS NAME IS DROPPED: if an identifier is
ever a function parameter, the file cannot tell the two apart and declines to
guess. That can only under-report, which is the right direction for a rule
whose false positives would otherwise bury the real finding. 7 raw hits -> 4,
and the 4 that remain are the server-rendered reads.

gate-5 CALLED A FILE ABSENT FROM INSIDE THE REPOSITORY CONTAINING IT
---------------------------------------------------------------------
Nextcloud resolves a route name `A\B\C` against `OCA\<App>\A\B\C`, which PSR-4
maps to `lib/A/B/CController.php`. The resolver rooted every namespaced name at
`lib/Controller/` — correct for `Settings\FileSettings`, wrong for
`AppHost\Controller\GenericHealth`, whose file is
`lib/AppHost/Controller/GenericHealthController.php`.

Measured on openregister, the repository that SHIPS those classes. The derived
path did not exist, `_apphost_serves` then matched the name, and both entries
were filed as

    "served by the OpenRegister AppHost generic controller (ADR-040); its auth
     attribute lives in the openregister package and is NOT visible from this
     repository"

inside openregister. The gate punted to another package while standing in it,
so `AppHost\Controller\GenericHealth#index` and `GenericMetrics#index` had
their auth posture judged by nobody. A derived path is a GUESS; both roots are
tried and an existing one wins. Both now resolve, both are correctly annotated
(`#[PublicPage]` + `#[NoCSRFRequired]`), and openregister's gate-5 count is
unchanged at 10 — this surfaced two unjudged endpoints without inventing a
finding. The AppHost CONSUMER control still classifies its four absent generics
as ADR-040-unresolved.

TWO ACCEPTANCE ARMS A PLANTED DEFECT CANNOT REACH
--------------------------------------------------
A plant only fires when the gate RUNS, so two classes of defect are invisible
to it. Both are now asserted:

  ARM 4  an ATTRIBUTE-ONLY change is a change. `_filter_preexisting` (gates 6,
         7, 8 in this band) compares a method against the base ref and moves
         unchanged entries out of the verdict. Before #276 the comparison began
         at the `function NAME(` line, so ADDING `#[NoAdminRequired]` above an
         existing unguarded method left the body byte-identical and the finding
         was suppressed — the single edit that changes a method's auth posture
         was the one the filter could not see. Verified BOTH ways on one
         fixture: package cdfbd7a gives `[gate-7] no-admin-idor: PASS` with the
         finding in `.log.preexisting`; package 34370f6 gives FAIL. #276 fixed
         the shared helper; this keeps it fixed for gate-7, whose entire scope
         is decided by that attribute.

  ARM 5  a BROKEN INTERPRETER is `SKIPPED (wiring)`, never PASS. With python3
         exiting 1, gates 2, 3, 10 and 11 all skip. gate-3 is the control: on
         the same fixture it FAILS a real stub `run()` when python3 works, so
         the skip is the gate losing a verdict it demonstrably had.

TWO REPO SHAPES, NOT ONE
------------------------
gate-11's repair is proven on three: larpingapp (vue-router 3, `new VueRouter`),
scholiq (vue-router 4 `createRouter`, AppHost-adopting) and nldesign (no router
at all -> `na`, verified honest: no `createRouter`/`new VueRouter` anywhere in
the repo). gate-5's resolver is proven on openregister (ships the generics) and
an AppHost consumer (does not).

Full-tree verdicts vs cdfbd7a: larpingapp and scholiq IDENTICAL; docudesk
differs only by the intended gate-9 false-positive removal; nldesign gains
gate-10 FAIL (4 real findings) and gate-11 `na` (previously a green over zero
bytes).
rubenvdlinde added a commit that referenced this pull request Aug 9, 2026
… an empty scope, and gate-10 excused itself from four real findings (#275)

* fix(gates): gate-11 was dead fleet-wide, and eight gates passed over an empty scope

Every gate in the 1–11 band was put through one acceptance test: plant ONE
textbook true positive of exactly what the gate exists to catch, in a real
fleet repo, and require the gate to FAIL and NAME it; then remove the plant and
require the prior verdict back. Measured against gate package cdfbd7a.

gate-11 admin-router was DEAD
-----------------------------
It read four hard-coded paths — src/router/index.{js,ts}, src/router.{js,ts}.
ONE fleet app of fifteen (softwarecatalog) has a file at any of them; the other
fourteen build their router in src/main.js. Proof it was dead rather than
merely unexercised: the doriath c7c72e9 defect re-planted verbatim into
larpingapp's real router —

    routes.push({ path: '/settings', component: AdminRoot })

— reported PASS. The identical line in src/router.js reported FAIL. The
detection logic was fine; the gate never opened the file.

Routers are now discovered (anything under src/ that constructs one), the four
legacy paths are kept, and a repo with no router is `na`. The path rule moved
into check_admin_router.py, which resolves the enclosing route object: a bare
`path: '/settings'` grep would have flagged openconnector's ADR-079 hand-off —
the remediation — as the defect. Fleet churn: 0 findings across 16 repos.

EIGHT GATES REPORTED PASS OVER A SCOPE THEY NEVER OPENED
--------------------------------------------------------
A README-only commit run with --scope-to-diff produced PASS from gates 1, 2, 3,
5, 8, 9, 10 and 11 — four of them authorization gates. Gates 4/6/7 already said
NOT APPLICABLE for the identical situation, which is what made the other eight
readable as a result rather than an absence. All eight now say `na` with a
reason (`na`, not `structural`, per #268: an empty ADR-020 scope is not a gap
the author can close).

gate-9 semantic-auth flagged the ESCALATION BRANCH
--------------------------------------------------
The `!isAdmin` block was searched for a denial token AT ANY DEPTH, conflating
two opposite postures:

    if (!isAdmin) { return 403; }            admin IS required   -> true positive
    if (!isAdmin) { if (!owner) 403; }       admin NOT required   -> false positive

In the second a non-admin OWNER proceeds. Live on docudesk
SigningController::cancelRequest (its own docudesk#100 fix) and procest
InspectionChecklistController::submitResult. The remedy gate-9 printed for both
— remove @NoAdminRequired, or switch to #[AuthorizedAdminSetting] — would have
made per-user endpoints admin-only and deleted the owner checks' reason to
exist. Now only an UNCONDITIONAL denial counts. Fleet: 28 -> 26 findings; the
two removed are exactly those, and larpingapp's genuine
CharactersController::downloadPdf finding is preserved.

gate-2 forbidden-patterns failed in BOTH directions (#184)
-----------------------------------------------------------
Six raw-text greps. Invisible: `var_dump ($x)` (PHP allows whitespace before
the argument list), `die;` (a LANGUAGE CONSTRUCT, not a function), and `exit`
in every form — die's exact synonym, absent from the pattern list, so one name
was banned and the other left open. Reported: a comment saying "never use
var_dump( here", and the string literal "select dd(x)". Now judged over a
comment- and string-masked copy. `: never` exempts exit/die — a TYPE PHP itself
checks, deliberately not the @SuppressWarnings docblock sitting above the one
fleet instance, because a docblock is the load-bearing prose #196 was about.
Fleet churn: 1 -> 1.

gate-8 unsafe-auth-resolver used HARD-CODED INDENTATION
-------------------------------------------------------
Body extraction terminated on `/^    \}/`, the catch block on `/^        \}/`.
On a tab-indented file neither matches, so "the body" ran to end of file and a
resolver that correctly RETHROWS was reported as a fail-open because an
unrelated cache method further down returned null from its own catch. The
apparent detection of tab-indented fail-opens was the same over-capture by
luck. Now brace-walked over a comment-masked copy; the procest ZgwService
fail-closed shapes stay excluded by construction rather than by indentation.

gate-10 initial-state knew only the single-line shape
-----------------------------------------------------
`getElementById(...)...\.dataset` requires lookup and read on ONE line, so the
TWO-STEP form — what the doriath line becomes after any refactor — plus
querySelector and getAttribute('data-*') all reported PASS. Widened along that
one axis only; NOT to a bare `.dataset`, which would flag every legitimate
event.target.dataset in the fleet. Two measured exclusions, each a rule rather
than a waiver: a dataset key the same file also WRITES is the component's own
bookkeeping, and data-requesttoken is not IInitialState data (its accessor is
getRequestToken(), so gate-10's remedy would not apply). Fleet churn: 0 -> 0.

gate-7 no-admin-idor missed the FULLY-QUALIFIED attribute
----------------------------------------------------------
The look-back matched `#[NoAdminRequired` only. Under
`#[\OCP\AppFramework\Http\Attribute\NoAdminRequired]` a textbook IDOR fell out
of scope and reported PASS. No fleet file uses that spelling today, which is
why it needed closing deliberately — a false negative on a security gate leaves
no log.

WHAT WAS VERIFIED AND LEFT ALONE
--------------------------------
gate-3's #269 repair HOLDS in both directions: an inert `$unused = 1;` no
longer closes it, and canonical fail-safe delegation no longer trips it.
gate-5's #269 repair HOLDS: prose naming an attribute does not satisfy it, the
`@auth admin-only <reason>` declaration does, and a too-short reason does not.
gate-4 already distinguishes "no advisories" from "audit did not run".
gate-6 detects a zero-caller authorization method and goes quiet on one caller.
gate-7 is CORRECTLY correlated — .github#160's anti-correlation does not
reproduce: a leak FAILs and all four correct-fix shapes PASS. docudesk
EmlPreviewController::preview is a genuine IDOR (un-redacted EML by
caller-supplied fileId, no ownership check).

Regression tests land in run-helper-suites.sh's discovery path and are
mutation-checked against WHOLE pre-fix files, not single-site reverts: the
pre-fix runner fails 19 assertions of test_gate_1_11_empty_scope_is_na.sh
(including the gate-11 dead-gate case), the pre-fix check_semantic_auth.py
fails 3, the pre-fix check_no_admin_idor.py fails 1.

Full-tree verdicts before/after are IDENTICAL on larpingapp and openregister,
and differ on docudesk only by the intended gate-9 false positive.

* test(gates): gate-5's empty-diff verdict is `na`, matching the suite's own comment

test_gate_route_auth.sh asserted PASS for a package.json-only diff while its
own comment said "a PASS here is scoping, not absence" — a fact PASS does not
state and NOT APPLICABLE does. Reclassified per #268. 6b/6c remain the
controls: the identical finding must still FAIL once the diff touches the
controller or appinfo/routes.php.

* fix(gates 5,10): a gate declared `na` over the frontend it was built to check, and gate-5 punted to a package it was standing in

Three additions after the first commit, all measured against gate package
34370f6 and reported by the runner's own `[hydra-gates] gate package:` line.

gate-10 DECLARED `na` OVER A REPO FULL OF ITS OWN SUBJECT MATTER
-----------------------------------------------------------------
The surface was `src/` only. nldesign's `src/` holds exactly one file —
`manifest.json` — and its entire hand-written frontend lives in `js/`. So
gate-10 announced "this repo ships no frontend" over a repo whose
`js/admin.js` does precisely what the gate exists to catch:

    var settingsEl = document.getElementById('nldesign-settings');
    var tokenSets  = JSON.parse(settingsEl.getAttribute('data-token-sets'));

The doriath AdminRoot defect, two-step form, in an ADMIN settings script. Four
real findings. `na` REMOVES A GATE FROM COVERAGE ACCOUNTING, so this did not
merely under-report — it left the denominator. The surface is now `src/` + `js/`
(Nextcloud's conventional shipped-script directory), minus `*.min.js`: a
committed bundle is not authored code. Fleet total 0 -> 4, all four in
nldesign, all four genuine.

AND A SCOPE-BLINDNESS BUG IN MY OWN TWO-STEP RULE
--------------------------------------------------
Widening the surface exposed it. The two-step rule matches by NAME across the
whole file. In `js/admin.js`, line 1114 binds
`var btn = document.getElementById('nldesign-save-btn')`, and that one line put
EVERY `btn` in a 1700-line file into scope — including three
`forEach(function (btn) {...})` callbacks whose `btn` is the clicked element,
i.e. the component's own markup. Four false positives from one binding. Rather
than implement JS scoping, an AMBIGUOUS NAME IS DROPPED: if an identifier is
ever a function parameter, the file cannot tell the two apart and declines to
guess. That can only under-report, which is the right direction for a rule
whose false positives would otherwise bury the real finding. 7 raw hits -> 4,
and the 4 that remain are the server-rendered reads.

gate-5 CALLED A FILE ABSENT FROM INSIDE THE REPOSITORY CONTAINING IT
---------------------------------------------------------------------
Nextcloud resolves a route name `A\B\C` against `OCA\<App>\A\B\C`, which PSR-4
maps to `lib/A/B/CController.php`. The resolver rooted every namespaced name at
`lib/Controller/` — correct for `Settings\FileSettings`, wrong for
`AppHost\Controller\GenericHealth`, whose file is
`lib/AppHost/Controller/GenericHealthController.php`.

Measured on openregister, the repository that SHIPS those classes. The derived
path did not exist, `_apphost_serves` then matched the name, and both entries
were filed as

    "served by the OpenRegister AppHost generic controller (ADR-040); its auth
     attribute lives in the openregister package and is NOT visible from this
     repository"

inside openregister. The gate punted to another package while standing in it,
so `AppHost\Controller\GenericHealth#index` and `GenericMetrics#index` had
their auth posture judged by nobody. A derived path is a GUESS; both roots are
tried and an existing one wins. Both now resolve, both are correctly annotated
(`#[PublicPage]` + `#[NoCSRFRequired]`), and openregister's gate-5 count is
unchanged at 10 — this surfaced two unjudged endpoints without inventing a
finding. The AppHost CONSUMER control still classifies its four absent generics
as ADR-040-unresolved.

TWO ACCEPTANCE ARMS A PLANTED DEFECT CANNOT REACH
--------------------------------------------------
A plant only fires when the gate RUNS, so two classes of defect are invisible
to it. Both are now asserted:

  ARM 4  an ATTRIBUTE-ONLY change is a change. `_filter_preexisting` (gates 6,
         7, 8 in this band) compares a method against the base ref and moves
         unchanged entries out of the verdict. Before #276 the comparison began
         at the `function NAME(` line, so ADDING `#[NoAdminRequired]` above an
         existing unguarded method left the body byte-identical and the finding
         was suppressed — the single edit that changes a method's auth posture
         was the one the filter could not see. Verified BOTH ways on one
         fixture: package cdfbd7a gives `[gate-7] no-admin-idor: PASS` with the
         finding in `.log.preexisting`; package 34370f6 gives FAIL. #276 fixed
         the shared helper; this keeps it fixed for gate-7, whose entire scope
         is decided by that attribute.

  ARM 5  a BROKEN INTERPRETER is `SKIPPED (wiring)`, never PASS. With python3
         exiting 1, gates 2, 3, 10 and 11 all skip. gate-3 is the control: on
         the same fixture it FAILS a real stub `run()` when python3 works, so
         the skip is the gate losing a verdict it demonstrably had.

TWO REPO SHAPES, NOT ONE
------------------------
gate-11's repair is proven on three: larpingapp (vue-router 3, `new VueRouter`),
scholiq (vue-router 4 `createRouter`, AppHost-adopting) and nldesign (no router
at all -> `na`, verified honest: no `createRouter`/`new VueRouter` anywhere in
the repo). gate-5's resolver is proven on openregister (ships the generics) and
an AppHost consumer (does not).

Full-tree verdicts vs cdfbd7a: larpingapp and scholiq IDENTICAL; docudesk
differs only by the intended gate-9 false-positive removal; nldesign gains
gate-10 FAIL (4 real findings) and gate-11 `na` (previously a green over zero
bytes).

---------

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
rubenvdlinde added a commit that referenced this pull request Aug 9, 2026
…or, and gate-20 had never fired at all (#277)

* fix(gates 12-22): four gates could not detect the defect they exist for, and gate-20 had never fired at all

Every gate in the 12-22 band was given one textbook true positive in a real
fleet repo and asked to catch it. Measured against package sha cdfbd7a, and
re-measured on top of fef032b (#272), across openregister (207 .vue, 794 spec
scenarios), pipelinq (202 .vue, type:"dashboard" pages) and shillinq (an ADR-040
AppHost adopter that keeps its own SettingsController).

Seven fired, named the plant, and returned to their exact prior verdict when it
was removed: 12, 13, 14 (both invariants), 15, 16, 18, 19, 21, 22. Four did not.

1. GATE-20 HAD NEVER FIRED. NOT RARELY - NEVER.
   Its search was `grep -nE "->${_pat//(/\\(}" "${_file}"`, and the expanded
   pattern `->findObjects\(` begins with `-`, so grep parses it as OPTIONS:
   `grep: invalid option -- '>'`, exit 2, no output. `2>/dev/null || true`
   discarded the message and the status, every file came back with zero hits,
   and the gate printed PASS. Planting `$this->objectService->findObjects(...)`
   in openregister's ActionsController: PASS.

   Repairing the grep ALONE is not the fix. The receiver test was "the FILE
   mentions ObjectService somewhere", and with the grep working that yields 14
   findings on openregister and 5 on shillinq, ALL FALSE - `createFromArray()`
   is a real method on OpenRegister's *Mappers*. The receiver is now part of the
   pattern. Fleet measurement across the three repos afterwards: ONE finding,
   and it is real - shillinq's BookingNotificationController resolves
   OCA\OpenRegister\Service\ObjectService from the container inside its
   non-admin authorisation guard and calls findObject(), which does not exist
   on it (real surface: find/findAll/saveObject/createObject/updateObject/
   deleteObject). That is a BadMethodCallException in an auth guard, shipped.

2. GATE-17 WAS WRONG ABOUT THE SAME API, IN THE OTHER DIRECTION.
   Four of the six names in OBJECT_SERVICE_CRUD - findObjects, createFromArray,
   updateFromArray, deleteFromId - do not exist on ObjectService; they are
   precisely what gate-20 flags as fabricated. findAll/createObject/
   updateObject/deleteObject were absent. And `^\s*return\s+new\s+JSONResponse`
   sits in WRAPPER_NOISE_PATTERNS, which was tested BEFORE the ObjectService
   check - so the commonest pass-through spelling of all,
   `return new JSONResponse($this->objectService->findAll([]));`, was discarded
   as "response wrapping" with the call still inside it. It did not merely miss
   the shape: RESCUE_PATTERNS' `\$this->\w+Service->\w+\(` then matched it and
   returned False. The gate rescued its own subject.

   Blast radius of both changes across openregister/pipelinq/shillinq: 0 new
   findings. The CRUD-name filter and the `@spec exclude` escape hatch are
   untouched.

3. GATE-14 NEVER JUDGED THE TEN ROUTES AppHost SUPPLIES (#265, closed).
   #223 taught invariant 1 about `Routes::standard()`. Invariant 2 asks the
   opposite question and still read route names as literals out of the leaf's
   own appinfo/routes.php, where those ten names never appear. Deleting
   `SettingsController::update()` from shillinq - which keeps its own controller,
   as aliasControllerUnlessLeafDefinesIt explicitly allows - leaves
   PUT /api/settings resolving to nothing. That is not a 404: the router
   matches, ControllerMethodReflector reflects, the request dies 500. shillinq's
   own docblock on update() spells the hazard out. Gate-14's findings log came
   back EMPTY and the gate said PASS.

4. THREE GATES REPORTED PASS OVER A CRASHED CHECKER.
   With python3 replaced by a stub that always exits 1 - so not one file was
   inspected by any python-backed gate:

     gate-12 SKIPPED (wiring)   gate-15 PASS
     gate-17 SKIPPED (wiring)   gate-16 PASS
                                gate-18 PASS
                                gate-19 FAIL - "an unreported number of scenario(s)"

   15/16/18 wrote `2>/dev/null || true` and counted lines in an empty log.
   19 read exit 1 as EXIT_FAIL from a helper that never printed its own summary,
   producing a blocking verdict with a count nobody measured. The three helpers
   now print a terminal `# count=` marker and the runner requires it; gate-18's
   helper contract is "always exit 0", so any non-zero exit is a wiring skip;
   gate-19 treats "EXIT_FAIL with no FAIL - N summary line" as wiring.
   check_spec_coverage.py and check_dashboard_antipattern.py also stop returning
   their finding COUNT as an exit status (#209).

5. GATES 12 AND 13 PASSED OVER A src/ WITH ZERO .vue (#274).
   `[ -d src ]` is not `src/ contains a Vue component`. nldesign's src/ holds one
   manifest.json, and at fef032b both gates print PASS there - the shape that
   let twelve gates certify nldesign in #225, still current, not one `rm` away.
   Now `na`, with the judgement #274 asked for stated in the reason: NcSelect /
   NcModal / NcDialog are Vue SFC components, a PHP template cannot instantiate
   one, so these two stay .vue-only and gate-40 keeps the language-agnostic
   input-label rule for templates/.

6. GATE-22's VERDICT DEPENDED ON WHERE THE GATES WERE CHECKED OUT.
   `require('ajv/dist/2020')` resolves relative to check_manifest.js, never to
   the repo under test. Run from openregister's own root with node_modules/ajv
   PRESENT in that root, the validator printed "Ajv is not resolvable from this
   process (no node_modules, no NODE_PATH)" - false - and gate-22 went FAIL;
   exporting NODE_PATH to that same directory flipped it to PASS. Same tree,
   same package, two verdicts. Resolution is now anchored on the manifest's own
   repo root, then cwd, then this package, and the degradation names every
   directory it searched.

7. GATE-15's SLOT MATCHER HAD AN ATTRIBUTE-ORDER BLIND SPOT.
   `<template #widget-x class="wide">` and `<template #widget-x='{i}'>` did not
   match - the pattern assumed the slot binding was the last thing before `>`.
   Same class as gate-44's first-attribute stop in #272.

TESTS
   test_gate_or_objectservice_surface.sh   NEW - gate-20 and gate-17, each with
                                           the silent sibling that must stay
                                           silent (a mapper's real
                                           createFromArray; a domain-named
                                           method with an identical body), plus
                                           a direct assertion that a grep
                                           pattern starting with `-` is parsed
                                           as options
   test_gate_crashed_checker_is_not_a_finding.sh
                                           all six python-backed gates under a
                                           broken interpreter, generically, so a
                                           future gate inherits the assertion
   test_gate_empty_scope_never_passes.sh   gates 12/13 on an nldesign-shaped
                                           repo, with the one-.vue control
   test_gate_route_registration.sh         new fixture routes-standard-missing-
                                           update/, differing from its sibling
                                           by exactly the deleted method
   test_check_manifest.sh                  ajv resolved from the subject's
                                           node_modules with cwd and NODE_PATH
                                           pointing elsewhere
   test_check_dashboard_antipattern.py     four slot-tag spellings that were
                                           silent misses

   Every one mutation-checked against the pre-fix tree: the crashed-checker arm
   is 7 failures red at cdfbd7a, the 12/13 arm 3 failures red at fef032b, the
   slot-tag arm 4 subtests red, and the ajv anchor red - all green after.
   50 helper suites pass, 59 bin-level assertions pass.

* fix(tests): the bin suite asserted that src/ existing means gates 12/13 ran

test-hydra-gates-bin.sh looped 12 and 13 into 'with src/ present, every
src-guarded gate really runs'. That fixture's src/ holds one .js file and no
.vue, so the assertion encoded the belief this PR removes: that `[ -d src ]`
is the same as 'the gate has a subject'. Both gates printed PASS there — an
empty glob under an existing src/, nldesign's exact shape.

They are now asserted separately and more strictly: not 'did not say na', but
'said na and NAMED the empty .vue glob'. PASS is called out by name as the
regression it would be. 61 bin assertions pass, 50 helper suites pass.

* fix(gates 12-22): four gates could not detect the defect they exist for, and gate-20 had never fired at all

Every gate in the 12-22 band was given one textbook true positive in a real
fleet repo and asked to catch it. Measured against package sha cdfbd7a, and
re-measured on top of fef032b (#272), across openregister (207 .vue, 794 spec
scenarios), pipelinq (202 .vue, type:"dashboard" pages) and shillinq (an ADR-040
AppHost adopter that keeps its own SettingsController).

Seven fired, named the plant, and returned to their exact prior verdict when it
was removed: 12, 13, 14 (both invariants), 15, 16, 18, 19, 21, 22. Four did not.

1. GATE-20 HAD NEVER FIRED. NOT RARELY - NEVER.
   Its search was `grep -nE "->${_pat//(/\\(}" "${_file}"`, and the expanded
   pattern `->findObjects\(` begins with `-`, so grep parses it as OPTIONS:
   `grep: invalid option -- '>'`, exit 2, no output. `2>/dev/null || true`
   discarded the message and the status, every file came back with zero hits,
   and the gate printed PASS. Planting `$this->objectService->findObjects(...)`
   in openregister's ActionsController: PASS.

   Repairing the grep ALONE is not the fix. The receiver test was "the FILE
   mentions ObjectService somewhere", and with the grep working that yields 14
   findings on openregister and 5 on shillinq, ALL FALSE - `createFromArray()`
   is a real method on OpenRegister's *Mappers*. The receiver is now part of the
   pattern. Fleet measurement across the three repos afterwards: ONE finding,
   and it is real - shillinq's BookingNotificationController resolves
   OCA\OpenRegister\Service\ObjectService from the container inside its
   non-admin authorisation guard and calls findObject(), which does not exist
   on it (real surface: find/findAll/saveObject/createObject/updateObject/
   deleteObject). That is a BadMethodCallException in an auth guard, shipped.

2. GATE-17 WAS WRONG ABOUT THE SAME API, IN THE OTHER DIRECTION.
   Four of the six names in OBJECT_SERVICE_CRUD - findObjects, createFromArray,
   updateFromArray, deleteFromId - do not exist on ObjectService; they are
   precisely what gate-20 flags as fabricated. findAll/createObject/
   updateObject/deleteObject were absent. And `^\s*return\s+new\s+JSONResponse`
   sits in WRAPPER_NOISE_PATTERNS, which was tested BEFORE the ObjectService
   check - so the commonest pass-through spelling of all,
   `return new JSONResponse($this->objectService->findAll([]));`, was discarded
   as "response wrapping" with the call still inside it. It did not merely miss
   the shape: RESCUE_PATTERNS' `\$this->\w+Service->\w+\(` then matched it and
   returned False. The gate rescued its own subject.

   Blast radius of both changes across openregister/pipelinq/shillinq: 0 new
   findings. The CRUD-name filter and the `@spec exclude` escape hatch are
   untouched.

3. GATE-14 NEVER JUDGED THE TEN ROUTES AppHost SUPPLIES (#265, closed).
   #223 taught invariant 1 about `Routes::standard()`. Invariant 2 asks the
   opposite question and still read route names as literals out of the leaf's
   own appinfo/routes.php, where those ten names never appear. Deleting
   `SettingsController::update()` from shillinq - which keeps its own controller,
   as aliasControllerUnlessLeafDefinesIt explicitly allows - leaves
   PUT /api/settings resolving to nothing. That is not a 404: the router
   matches, ControllerMethodReflector reflects, the request dies 500. shillinq's
   own docblock on update() spells the hazard out. Gate-14's findings log came
   back EMPTY and the gate said PASS.

4. THREE GATES REPORTED PASS OVER A CRASHED CHECKER.
   With python3 replaced by a stub that always exits 1 - so not one file was
   inspected by any python-backed gate:

     gate-12 SKIPPED (wiring)   gate-15 PASS
     gate-17 SKIPPED (wiring)   gate-16 PASS
                                gate-18 PASS
                                gate-19 FAIL - "an unreported number of scenario(s)"

   15/16/18 wrote `2>/dev/null || true` and counted lines in an empty log.
   19 read exit 1 as EXIT_FAIL from a helper that never printed its own summary,
   producing a blocking verdict with a count nobody measured. The three helpers
   now print a terminal `# count=` marker and the runner requires it; gate-18's
   helper contract is "always exit 0", so any non-zero exit is a wiring skip;
   gate-19 treats "EXIT_FAIL with no FAIL - N summary line" as wiring.
   check_spec_coverage.py and check_dashboard_antipattern.py also stop returning
   their finding COUNT as an exit status (#209).

5. GATES 12 AND 13 PASSED OVER A src/ WITH ZERO .vue (#274).
   `[ -d src ]` is not `src/ contains a Vue component`. nldesign's src/ holds one
   manifest.json, and at fef032b both gates print PASS there - the shape that
   let twelve gates certify nldesign in #225, still current, not one `rm` away.
   Now `na`, with the judgement #274 asked for stated in the reason: NcSelect /
   NcModal / NcDialog are Vue SFC components, a PHP template cannot instantiate
   one, so these two stay .vue-only and gate-40 keeps the language-agnostic
   input-label rule for templates/.

6. GATE-22's VERDICT DEPENDED ON WHERE THE GATES WERE CHECKED OUT.
   `require('ajv/dist/2020')` resolves relative to check_manifest.js, never to
   the repo under test. Run from openregister's own root with node_modules/ajv
   PRESENT in that root, the validator printed "Ajv is not resolvable from this
   process (no node_modules, no NODE_PATH)" - false - and gate-22 went FAIL;
   exporting NODE_PATH to that same directory flipped it to PASS. Same tree,
   same package, two verdicts. Resolution is now anchored on the manifest's own
   repo root, then cwd, then this package, and the degradation names every
   directory it searched.

7. GATE-15's SLOT MATCHER HAD AN ATTRIBUTE-ORDER BLIND SPOT.
   `<template #widget-x class="wide">` and `<template #widget-x='{i}'>` did not
   match - the pattern assumed the slot binding was the last thing before `>`.
   Same class as gate-44's first-attribute stop in #272.

TESTS
   test_gate_or_objectservice_surface.sh   NEW - gate-20 and gate-17, each with
                                           the silent sibling that must stay
                                           silent (a mapper's real
                                           createFromArray; a domain-named
                                           method with an identical body), plus
                                           a direct assertion that a grep
                                           pattern starting with `-` is parsed
                                           as options
   test_gate_crashed_checker_is_not_a_finding.sh
                                           all six python-backed gates under a
                                           broken interpreter, generically, so a
                                           future gate inherits the assertion
   test_gate_empty_scope_never_passes.sh   gates 12/13 on an nldesign-shaped
                                           repo, with the one-.vue control
   test_gate_route_registration.sh         new fixture routes-standard-missing-
                                           update/, differing from its sibling
                                           by exactly the deleted method
   test_check_manifest.sh                  ajv resolved from the subject's
                                           node_modules with cwd and NODE_PATH
                                           pointing elsewhere
   test_check_dashboard_antipattern.py     four slot-tag spellings that were
                                           silent misses

   Every one mutation-checked against the pre-fix tree: the crashed-checker arm
   is 7 failures red at cdfbd7a, the 12/13 arm 3 failures red at fef032b, the
   slot-tag arm 4 subtests red, and the ajv anchor red - all green after.
   50 helper suites pass, 59 bin-level assertions pass.

* fix(tests): the bin suite asserted that src/ existing means gates 12/13 ran

test-hydra-gates-bin.sh looped 12 and 13 into 'with src/ present, every
src-guarded gate really runs'. That fixture's src/ holds one .js file and no
.vue, so the assertion encoded the belief this PR removes: that `[ -d src ]`
is the same as 'the gate has a subject'. Both gates printed PASS there — an
empty glob under an existing src/, nldesign's exact shape.

They are now asserted separately and more strictly: not 'did not say na', but
'said na and NAMED the empty .vue glob'. PASS is called out by name as the
regression it would be. 61 bin assertions pass, 50 helper suites pass.

* fix(gate-14): a namespaced route name resolves to the app root, not to lib/Controller/

NC's RouteParser::buildControllerName() does not prefix the app namespace when
the route name already contains a backslash, so
`AppHost\Controller\GenericHealth#index` is looked up as the bare class
`AppHost\Controller\GenericHealthController`. PSR-4 maps `OCA\<App>\` onto
`lib/`, so that class lives at lib/AppHost/Controller/ — not under
lib/Controller/, which is the only place the resolver looked.

Reproduced against this package's own gates-23-33/planted fixture:

  lib/Controller/AppHost/Controller/GenericHealthController.php
    route='AppHost\Controller\GenericHealth#index'
    rule=controller-class-not-found

a path that cannot exist, reported as a missing class, INSIDE the repository
that ships the file. Same shape as the gate-30 finding in #276: the path the
gate derives is not the path the app uses.

The false FAIL was only half of it. Where a DI binding rescued the absence the
loop `continue`d, so the method-existence check never ran — measured on two new
fixtures differing by exactly one renamed method: at origin/main BOTH report
PASS, including the one whose route resolves to nothing. That is #265's defect
at a different address.

Both candidate paths are probed and the one that EXISTS wins; when neither does
the lib/Controller/ spelling is reported exactly as before, so a genuinely
missing controller reads the same as it always has.

Also: test_gate_route_registration.sh hardcoded its runner, so pointing
HYDRA_GATES_RUNNER_UNDER_TEST at a pre-fix tree silently kept running the FIXED
one and reported all-green. A mutation check that cannot fail is the same defect
as a gate that cannot fail. With the override honoured, the suite is 5 failures
red against origin/main and green after.

And test-hydra-gates-bin.sh's 'with src/ present, every src-guarded gate really
runs' loop had a stale premise: #276 gave that fixture two real .vue files
(because gates 26/31/32 now distinguish 'src/ exists' from 'src/ contains what I
read'), so gates 12 and 13 correctly run and PASS there. They go back in the
loop, and the loop now asserts its own premise first. The zero-.vue case is
asserted where its premise actually holds — the nldesign-shaped fixture in
test_gate_empty_scope_never_passes.sh.

52 helper suites pass, 60 bin assertions pass.

* fix(gate-16): deleting an @SPEC tag was the one edit the gate could not see

`_overlaps` walks FORWARD from the declaration line through the method body.
The docblock sits ABOVE the declaration, and the docblock is the only place
`@spec` can live — so the scope window never contained the tag.

Measured on a two-method fixture: delete the `@spec openspec/...` line, leave
the body byte-identical, and the helper prints `# count=0` and exits 0. Every
@SPEC tag in a repository can be stripped and gate-16 stays green. `run_gate`
also skipped any file whose `added` set was empty, and a pure deletion produces
exactly that, so the file was never even opened.

Same family as filter_preexisting_methods.py filing an auth-attribute removal as
pre-existing: a body-shaped scope cannot see a change that is not in the body.
Gate-16 does NOT route through that helper — its four call sites are gates 6, 7,
8 and 30, and check_spec_coverage.py references it zero times — it arrives at
the same blind spot by its own path.

The fix keys on 'a tag was TAKEN AWAY', not on 'a docblock was touched'. A file
that lost a tag is evaluated twice — as it is, and as it was at the base, with
the same walkers and therefore the same exemptions — and only the DIFFERENCE is
reported. So:

  delete a tag                          -> the method that lost it, and only it
  typo-fix a legacy untagged docblock   -> clean (no inherited debt surfaced)
  add a new untagged method             -> still caught, unchanged

All three asserted, the second being the anti-widening control.

---------

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
rubenvdlinde added a commit that referenced this pull request Aug 9, 2026
The branch was rebased from cdfbd7a onto 48c88ba after four gate-package
releases landed mid-session (#272, #275, #276, #280/#282). The rebase gave
the same content a new history, which is a force-push, and force-push is
blocked on shared branches for good reason. The tree here is IDENTICAL to
the rebased HEAD — this commit only re-attaches the old tip as a second
parent so the push is a fast-forward.
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