From a565f6e3167828e63ad457cf0ce043211445bb2f Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 8 Aug 2026 15:36:56 +0200 Subject: [PATCH 1/2] fix(gate-37,gate-43): tabindex="-1" is NOT focusable, and one scope= does not green a table (#222) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gates whose remediation advice regressed the thing they measure. GATE-37 — `tabindex="-1"` counted as focusable --------------------------------------------- The check was `grep -qE '(:?tabindex|v-bind:tabindex)='` — ANY value. But `tabindex="-1"` is the attribute that REMOVES an element from the tab order: the one value proving the opposite of what the gate concluded. The gate's own subject ("hidden from AT and STILL keyboard-reachable") was inverted for every element that had already been fixed. The canonical hidden-file-input pattern trips it exactly: The advice was to remove `aria-hidden` (exposing an unnamed control to screen readers) or to remove `tabindex="-1"` (putting a control screen readers cannot see BACK in the tab order — the very defect this gate exists to catch). Both remediations regress accessibility. Measured: nextcloud-vue 2 findings -> 0. Both were `tabindex="-1"` (CnFilesWidget's file input, CnCalendarEventCreate's submit bridge), both 100% false. Still flagged: `tabindex="0"`/positive, native focusables with no negative tabindex, interactive `role=`, and a BOUND `:tabindex="expr"` whose value cannot be read — unknown is not "safe", so the old behaviour is kept there. GATE-43 — one `scope=` greened the whole table ---------------------------------------------- if re.search(r']*\bscope\s*=', body): continue # whole table OK A SINGLE `scope=` anywhere accepted every other header in the table. Negative control: removing exactly one `scope=` from a passing table still reported PASS. The rule is now "any unscoped NAMED header fails the table". That experiment is now an assertion — test_removing_exactly_one_scope_flips_the_verdict. A HEADER WITH NO NAME IS NOT A HEADER. Tightening to per-header immediately produced 8 findings in openconnector, a repo the old rule passed, and ALL EIGHT were the same false positive: the empty spacer column carrying a drag handle or a row-actions menu (``, ``). `scope=` declares which cells a header NAMES; a header with no name names nothing, so the attribute is inert and demanding it is remediation theatre — the same trap as gate-40's `aria-label` advice. Exempt: `aria-hidden="true"`, `role="presentation"`/`none`, and empty content. A header WITH text and no `scope=` still fails. openconnector: back to 0. COUNTING: one finding per TABLE, not per ``, with `unscoped=N/M` on the line. The number stays a count of defect sites and comparable with what this gate reported before. A finding count is not a defect count, and the two must not be silently swapped mid-repair. Fleet effect of both changes together, measured over 10 repos: gate-43 numbers IDENTICAL before and after (opencatalogi 15, docudesk 11, procest 28, openbuild 8, openregister 56, pipelinq 43, doriath 14, openconnector 0). The tightening and the exemption are not cancelling each other — the tests prove each arm independently; the fleet simply has no partially-scoped table whose unscoped header is named. Both implementations moved out of the runner into tested helpers: scripts/lib/check_aria_hidden_focusable.py + 24 tests scripts/lib/check_table_headers.py + 23 tests Both are quote-aware now: the old `[^>]*` attribute runs ended a tag at the `>` inside `:title="a > b"`, hiding attributes that followed. Both strip comments and script/style blocks — markup that does not ship is not a control. MUTATION-CHECKED. Every fix reverted in turn, and the suites go red: * gate-37 "any tabindex is focusable" -> 6 failures * gate-43 "at-least-one scope" -> 3 failures (5 with exemption) * gate-43 unnamed-header exemption widened -> 7 failures Also caught by mutation, not reading: an optional `(?:(.*?))?` body group let a self-closed `` reach past itself to the FIRST `` in the file and swallow the next header whole — two headers counted as one. A missing helper now reports SKIP-wiring, not PASS, for both gates (#147). Suites: 29 helper suites pass, 59 entry-point tests pass. Refs #222 --- .../lib/check_aria_hidden_focusable.py | 155 ++++++++++++ .../scripts/lib/check_table_headers.py | 163 ++++++++++++ .../lib/test_check_aria_hidden_focusable.py | 157 ++++++++++++ .../scripts/lib/test_check_table_headers.py | 236 ++++++++++++++++++ hydra-gates/scripts/run-hydra-gates.sh | 159 ++++++------ 5 files changed, 797 insertions(+), 73 deletions(-) create mode 100644 hydra-gates/scripts/lib/check_aria_hidden_focusable.py create mode 100644 hydra-gates/scripts/lib/check_table_headers.py create mode 100644 hydra-gates/scripts/lib/test_check_aria_hidden_focusable.py create mode 100644 hydra-gates/scripts/lib/test_check_table_headers.py diff --git a/hydra-gates/scripts/lib/check_aria_hidden_focusable.py b/hydra-gates/scripts/lib/check_aria_hidden_focusable.py new file mode 100644 index 0000000..d27a900 --- /dev/null +++ b/hydra-gates/scripts/lib/check_aria_hidden_focusable.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: EUPL-1.2 +r"""Gate-37 aria-hidden-focusable — an element hidden from assistive tech must +not also be in the tab order (WCAG 2.2 AA SC 4.1.2; axe-core `aria-hidden-focus`). + +The failure this catches is real and serious: keyboard focus lands on a +control that screen readers do not announce, so the user is on "nothing". + +WHY THIS WAS REWRITTEN +---------------------- +The previous implementation treated `tabindex` **with any value** as proof of +focusability: + + echo "${tag}" | grep -qE '(^|[[:space:]])(:?tabindex|v-bind:tabindex)[[:space:]]*=' && _focusable=1 + +`tabindex="-1"` is the attribute that **REMOVES** an element from the tab +order. It is the one value that proves the opposite of what the gate concluded. +So the gate's own subject — "hidden from AT *and still reachable by keyboard*" +— was inverted for every element that had already been fixed. + +The canonical hidden-file-input pattern trips it exactly: + + (nc-vue CnFilesWidget) + +That input is correct. It is hidden from AT, removed from the tab order, and +driven by a visible `