Skip to content

fix(gate-38): a mount point is not a page root, and the remedy it demanded regressed a11y (#214, #216, #227) - #247

Merged
rubenvdlinde merged 6 commits into
mainfrom
fix/gate-38-mount-point-vs-page-root
Aug 8, 2026
Merged

fix(gate-38): a mount point is not a page root, and the remedy it demanded regressed a11y (#214, #216, #227)#247
rubenvdlinde merged 6 commits into
mainfrom
fix/gate-38-mount-point-vs-page-root

Conversation

@rubenvdlinde

Copy link
Copy Markdown
Contributor

Closes #214. Closes #216. Closes #227.

The defect

WCAG 2.4.1 (Bypass Blocks) is a property of a document. gate-38 was asking it of two shapes that own no document:

shape why it owns no document
templates/**/*.php NC's Template renderer substitutes the template into core's page. Measured: all 30 app templates in the fleet are fragments — not one emits <html>, <head> or <body>. The typical body is literally <div id="procest-settings"></div>.
AdminRoot.vue rendered into core's Settings page, inside the section core already built. A <CnAdminSettingsShell> is a stack of <NcSettingsSection>s, not an app shell (#227).

8 templates across 6 apps failed this way (nldesign, opencatalogi, docudesk, larpingapp, procest ×2, openbuild), plus 2 AdminRoot.vue.

The remedy was an a11y regression

The only way to close the finding was to add a second "skip to content" anchor pointing into the middle of a page that already has one — announced to every screen-reader user ahead of core's real one. A WCAG 2.4.1 regression demanded by a WCAG 2.4.1 gate. That is why these findings could never be closed honestly.

What still fails

  • src/App.vue / src/views/App.vue with a bespoke shell
  • **/*Root.vue that is not a settings surface
  • a PHP template that does own the document and has no bypass mechanism

⚠️ This widens the PHP arm: it used to look only at templates/settings/, and now asks every app template the question. A standalone/PublicPage template is covered where before it was not.

The classifier is not a grep, and that is load-bearing

The first cut was grep -iE '<(html|body)\b', and the first fixture it met defeated it: the fixture's own explanatory comment contained <html>, so a bare mount point classified as a page root and the fix silently did nothing. Same defect as gate-64 — a checker that greps a string literal misses every constant and matches every comment, failing both ways at once.

scripts/lib/php_template_scope.py classifies emitted markup, with PHP regions and HTML comments removed. Both comment shapes are pinned in tests.

The same trap bit the fixture, twice

The AdminRoot.vue fixture's comment spelled out the wrapper element it does not have. The gate matched the comment, accepted the file, and the "#227 is fixed" assertion passed identically with the fix reverted. Caught by mutation-checking, not by reading.

Mutation check — all three go red

mutation assertion that fails
classifier forced to "owns document" (old behaviour) fragment assertions
settings-surface exclusion removed #227 assertion
PHP arm removed standalone.php true positive is lost

Tests

  • 4 new fixtures: a document-owning template with and without a bypass anchor, a mount point, a settings surface
  • 4 new assertions in test_gate_monitoring_and_skiplink.sh16/16 green
  • new test_php_template_scope.py15 cases
  • full helper suite: 28 passed, 0 failed (2 pre-existing quarantines)

Every "not reported" assertion runs in the accidental/ tree, where the gate is demonstrably firing in the same run, so an absence is a decision and never an empty scope.

A missing helper now reports SKIP-wiring, not PASS (#147).

rubenvdlinde and others added 2 commits August 8, 2026 15:27
…anded regressed a11y (#214, #216, #227)

WCAG 2.4.1 is a property of a DOCUMENT. gate-38 was asking it of two shapes
that own no document, and reporting all of them.

  templates/**/*.php   Nextcloud's Template renderer SUBSTITUTES an app
                       template into core's page. Measured across the fleet:
                       all 30 app templates are fragments, NOT ONE emits
                       <html>, <head> or <body>. The typical body is literally
                       `<div id="procest-settings"></div>`. 8 templates across
                       6 apps failed this way.

  AdminRoot.vue        rendered INTO core's Settings page, inside the section
                       core already built. A <CnAdminSettingsShell> is a stack
                       of <NcSettingsSection>s, not an app shell (#227).

In both cases the only way to close the finding was to add a SECOND "skip to
content" anchor, pointing into the middle of a page that already has one and
announced to every screen-reader user ahead of core's real one — a WCAG 2.4.1
regression demanded by a WCAG 2.4.1 gate.

WHAT STILL FAILS
  - src/App.vue / src/views/App.vue with a bespoke shell
  - **/*Root.vue that is not a settings surface
  - a PHP template that DOES own the document and has no bypass mechanism.
    This WIDENS the PHP arm: it used to look only at templates/settings/, and
    now asks every app template the question.

The classifier is scripts/lib/php_template_scope.py, not a grep, and that is
load-bearing. The first cut WAS a grep, and the first fixture it met defeated
it: the fixture's own explanatory comment contained `<html>`, so a bare mount
point classified as a page root and the fix silently did nothing. Same defect
as gate-64 — a checker that greps a string literal misses every constant and
matches every comment, failing both ways at once. Classification now runs on
emitted markup with PHP regions and HTML comments removed, and both comment
shapes are pinned in test_php_template_scope.py.

The same trap bit the FIXTURE twice: the AdminRoot.vue fixture's comment
spelled out the wrapper element it does not have, the gate matched the
COMMENT and accepted the file, and the "#227 is fixed" assertion passed
IDENTICALLY with the fix reverted. Caught by mutation-checking, not by
reading. All three mutations now go red:
  * classifier forced to "owns document"  -> fragment assertions fail
  * settings-surface exclusion removed    -> #227 assertion fails
  * PHP arm removed                       -> the standalone.php TP is lost

Tests: 4 new fixtures (a document-owning template with and without a bypass
anchor; a mount point; a settings surface), 4 new assertions in
test_gate_monitoring_and_skiplink.sh (16/16 green), and a new 15-case
test_php_template_scope.py. The "not reported" assertions run in the
`accidental/` tree, where the gate is demonstrably firing in the same run, so
an absence is a decision and never an empty scope.

A missing helper now reports SKIP-wiring, not PASS (#147): without the
classifier every template reads as a fragment and the whole PHP arm would
evaporate into a green.

Refs #214, #216, #227
…e-19's leaked `set -e`

Adopts the gate-19 / #249 signalling convention, and fixes two ways the PHP
arm could have gone falsely green.

1. THE EXIT BYTE WAS THE ANSWER, AND A CRASH SHARES ITS VALUE.
   `--owns-document` returned 0=owns / 1=fragment. A helper that CRASHES also
   exits 1. Every template would have classified as a fragment, the whole PHP
   arm would have evaporated, and gate-38 would have reported PASS having
   inspected nothing. The answer now comes from STDOUT — one `--classify` call
   for the whole set, printing `<path>: page-root|fragment` — and a non-zero
   exit is a wiring failure, reported as SKIPPED with stderr KEPT rather than
   discarded. Also one python process instead of one per template.

2. A LATENT BUG IN main(), found by the assertion above.
   gate-19's block (line ~1901) turns `set -e` ON and leaves it on for every
   gate after it, though this script's header sets only `set -u`. The first
   run of the crash test did not report a falsely-green gate — it reported NO
   GATE AT ALL: the non-zero helper killed the entire runner mid-sweep, 21
   later gates silently unreported, and the run ended on the abort guard. The
   call is now wrapped in `set +e` with the caller's flag restored.

Tests: 2 new wiring assertions (classifier MISSING -> SKIPPED; classifier
CRASHING -> SKIPPED), 18/18 green. The crash assertion is the one that found
the `set -e` leak; without it that failure mode is invisible, because an
aborted run's PASS lines read exactly like a clean run's.

Merged origin/main rather than rebasing: the branch is shared and the fleet
force-push guard is right to refuse a history rewrite.
Conduction Release Bot and others added 3 commits August 8, 2026 17:09
…adopt the errexit invariant

TWO SCOPE DEFINITIONS BECOME ONE (#225 / #261).
This arm had its own `find templates appinfo/templates -name '*.php'`, which
is `_a11y_markup_files` minus its exclusions — so a generated `phpmetrics/`
or a `vendor/` template would have been audited HERE and nowhere else in the
a11y family. gate-38 now filters the shared enumeration to `*.php`. There is
deliberately no third definition.

ERREXIT (#243). Dropped the `case $- in *e*) … set -e` restore dance. main
established that errexit is OFF for the whole script and nothing may turn it
on — a trailing `set -e` is an unconditional ENABLE, not a restore. My
workaround was written before that landed and is now obsolete; the underlying
bug is fixed upstream, and test_gate_errexit_discipline.sh enforces the rule.

Verified after the merge: skip-link control pairs 18/18, errexit discipline
ALL PASS.
@rubenvdlinde
rubenvdlinde merged commit 6430233 into main Aug 8, 2026
31 checks passed
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment