Skip to content

fix(gates): nine checkers matched prose, not code (#191, #196, #220, #224, #226, #230, #235, #236, #266) - #269

Merged
rubenvdlinde merged 3 commits into
mainfrom
fix/gates-prose-not-code
Aug 8, 2026
Merged

fix(gates): nine checkers matched prose, not code (#191, #196, #220, #224, #226, #230, #235, #236, #266)#269
rubenvdlinde merged 3 commits into
mainfrom
fix/gates-prose-not-code

Conversation

@rubenvdlinde

Copy link
Copy Markdown
Contributor

Nine checkers decided a question about code by grepping the raw bytes of a file. Prose is made of the same bytes, so every one of them failed in both directions at once — the shape #184 named: "a checker that greps a STRING LITERAL misses every constant and matches every comment."

issue gate what the gate actually matched
#191 48 csrf-cochange a removed comment naming #[NoCSRFRequired] — nldesign red for one rewritten docblock sentence
#196 5 route-auth a docblock saying #[NoAdminRequired] is deliberately NOT used satisfied the auth gate
#220 31 img-alt an <img> in a JSDoc comment in <script> (launchpad)
#235 31 img-alt the same — 3 of 3 findings on openbuild
#224 34 window-confirm false RED on a comment and false GREEN on window['confirm']()
#226 3 stub-scan a run() delegating to one helper read as a stub; gate closable by $unused = 1;
#230 58 e2e-networkidle a comment warning against networkidle counted as a use of it
#236 12 nc-input-labels <NcSelect[^>]*> truncated at the > of option =>
#236 32 semantic-controls a comment describing the <div @click> an element replaced, scored as that element
#266 41 html-lang a PHP comment mentioning <html> made a mount point a page root

#236 part 3 (gate-38 on templates/settings/admin.php) needs no change — #247 already fixed it; measured PASS on softwarecatalog in both arms, because that template is a fragment.


One scope definition, not nine

scripts/lib/source_scope.py generalises the two precedents that already got this right, rather than inventing a third dialect:

Every mask returns a same-length string. That is what lets a gate 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 (moving 180 lines out from under the suite that proves it correct is a bad trade mid-change). A drift test asserts the two byte-identical over a corpus, 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. #[NoAdminRequired] widens; so does AuthorizedAdminSetting (to delegated admins). Closing the false negative alone would have converted a silent false negative into a permanent false positive on correct code — pipelinq alone would pull 17 routed methods into scope with no legitimate fix available. So the tightening lands with a declaration in the @spec exclude family:

/**
 * @auth admin-only writes billing state for every tenant; admin posture is the absence of NoAdminRequired
 */
public function update(string $id): JSONResponse

Making bare absence sufficient was considered and rejected: absence is the only thing gate-5 reports, so accepting it would empty the gate completely. gate-9 (semantic-auth) still owns whether the declared posture matches the body. Documented in hydra-gates/README.md.

⚠️ gate-5 is diff-scoped (ADR-020) — a routed method is judged only when the PR touches its controller or appinfo/routes.php — so the fleet impact is per-PR, not a one-off wave.


Evidence

Planted true positives, both directions, on the real files named in the issues. Read from the runner's stdout, never its exit byte.

repo gate before after what changed
larpingapp 58 FAIL 1 PASS the single finding was the comment saying the last live call was removed
openbuild 31 FAIL 3 PASS all three logged as the bare four characters <img> — the tell from #235
openbuild 58 FAIL 22 FAIL 14 8 dropped, each verified a comment explaining why networkidle is not used
openbuild 34 FAIL 7 FAIL 7 identical seven lines (see the second commit)
openconnector 12 FAIL 13 FAIL 10 3 dropped, each carrying input-label written after :reduce="(o) => …"
softwarecatalog 3 FAIL 2 PASS one delegating run(), one file with no run() at all
portaliq 3 FAIL 1 PASS NotificationDispatchJob — 530 lines, 11 private methods
softwarecatalog 32 FAIL 2 FAIL 3 +1 true positive recovered: :class="{ clickable: … total_errors > 0 }" — the > inside the attribute value truncated the tag, so @click was never seen
nldesign 31/32 NOT APPLICABLE PASS [ -d src ] gated them out of a templates-only repo; now _a11y_has_markup_dir (residual #225)
procest all unchanged

#196, end-to-end on the fixture built from openconnector's ProductSubscriptionsController: pre-fix main reports 1 finding (analytics); subscribe(), whose only difference is a docblock sentence naming #[NoAdminRequired], passes. Post-fix reports 2. The suite asserts the count, because FAIL alone would have been FAIL before the fix too.

#191, end-to-end through the runner, three arms:

arm pre-fix post-fix
the nldesign comment line removed, verbatim FAIL PASS
a real #[\OCP\…\NoCSRFRequired] attribute removed PASS ← a second, unreported false negative FAIL
a real * @NoCSRFRequired docblock tag removed FAIL FAIL

Tests

Caught by measurement before landing

  • A lazy <template…>(.*?)</template> ended the SFC template at the first nested slot close and deleted a real finding at openconnector EditMapping.vue:376. Boundaries are found by depth now.
  • gate-34 reported openbuild's 7 dialogs as 14 by counting the feature-detection guard separately.
  • The gate-34 anchor consumed one character too many and silently dropped const r = window.confirm('x').

Closes #191, #196, #220, #224, #226, #230, #235, #266
Refs #236 (parts 1 and 2; part 3 already fixed by #247)
Supersedes #219 — its gate-12 helper and 17 tests are carried here, rebased onto current main and converged onto the shared scope.

🤖 Generated with Claude Code

… 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.
…ribute 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
Comment thread hydra-gates/scripts/lib/source_scope.py Fixed
…now 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
rubenvdlinde merged commit cdfbd7a into main Aug 8, 2026
31 checks passed
This was referenced Aug 8, 2026
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.

gate-48 csrf-cochange matches a REMOVED COMMENT — nldesign is red for a docblock that only names the attribute

2 participants