From 082aa042491fa9a46cc6e0442f21a73808c7e07f Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 8 Aug 2026 18:33:44 +0200 Subject: [PATCH 1/3] =?UTF-8?q?fix(gates):=20nine=20checkers=20matched=20p?= =?UTF-8?q?rose,=20not=20code=20=E2=80=94=20one=20shared=20scope,=20nine?= =?UTF-8?q?=20gates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `` in a JSDoc comment in +""" + ARM4 = ARM3.replace("window['confirm']", "window.confirm") + + def test_arm1_comment_only_is_not_a_finding(self): + self.assertEqual(dialog(self.ARM1), []) + + def test_arm2_control_the_same_file_without_the_comment(self): + """If arm 2 ever produced a finding, arm 1's zero would mean nothing.""" + self.assertEqual(dialog(self.ARM2), []) + + def test_arm3_bracket_access_is_a_native_dialog(self): + """The false GREEN. On doriath this hid a cascading delete.""" + found = dialog(self.ARM3) + self.assertEqual(len(found), 1, found) + self.assertIn("window['confirm']", found[0]) + + def test_arm4_control_the_dotted_spelling_still_fires(self): + self.assertEqual(len(dialog(self.ARM4)), 1) + + def test_arm1_plus_a_real_call_reports_only_the_call(self): + """The two halves together: the comment stays silent, the code does not.""" + src = self.ARM1 + self.ARM3 + found = dialog(src) + self.assertEqual(len(found), 1, found) + self.assertIn("confirm", found[0]) + + +class TestNativeDialogSpellings(unittest.TestCase): + def test_destructuring_from_window(self): + src = "" + self.assertEqual(len(dialog(src)), 1) + + def test_double_quoted_bracket_access(self): + src = '' + self.assertEqual(len(dialog(src)), 1) + + def test_alias_without_a_call_is_still_the_native_api(self): + src = "" + self.assertEqual(len(dialog(src)), 1) + + def test_a_component_method_named_confirm_is_not_reported(self): + """The gate's own remedy is a `confirm()` method on an NcDialog + wrapper. Reporting it would make the gate unclosable.""" + src = "" + self.assertEqual(dialog(src), []) + + def test_a_string_containing_the_spelling_is_not_a_call(self): + src = "" + self.assertEqual(dialog(src), []) + + def test_inline_handler_in_a_template_is_a_call(self): + src = '' + self.assertEqual(len(dialog(src)), 1) + + def test_a_php_template_inline_script_is_in_scope(self): + """#225: a native dialog from a PHP template breaks theming too.""" + src = "\n\n" + found = dialog(src, "templates/settings/admin.php") + self.assertEqual(len(found), 1, found) + + def test_a_php_comment_naming_it_is_not_a_call(self): + src = "\n
\n" + self.assertEqual(dialog(src, "templates/settings/admin.php"), []) + + def test_prose_in_a_template_text_node_is_not_a_call(self): + """A text node is not an expression; only attribute values are.""" + src = "" + self.assertEqual(dialog(src), []) + + def test_line_number_addresses_the_original_file(self): + src = "\n" + self.assertTrue(dialog(src)[0].startswith("src/components/X.vue:4:")) + + def test_one_construct_is_reported_once(self): + """`window[` matches the anchor once; a double count would inflate a + security-adjacent number, which is how gate-22 shipped 3 findings for + one defect (#254).""" + src = "" + self.assertEqual(len(dialog(src)), 1) + + +class TestNetworkidle(unittest.TestCase): + LARPINGAPP = """// ADR-074 rule 4: `networkidle` never settles on Nextcloud — the +// notification poll keeps the network permanently busy. This was the LAST +// live `waitForLoadState('networkidle')` in the suite; every other mention +// is a comment warning against it. +await page.waitForLoadState('domcontentloaded') +""" + + def test_the_larpingapp_comment_block_reports_nothing(self): + self.assertEqual(idle(self.LARPINGAPP), []) + + def test_the_same_file_with_one_live_call_reports_it(self): + """Anti-widening control on the real file shape.""" + src = self.LARPINGAPP + "await page.waitForLoadState('networkidle')\n" + found = idle(src) + self.assertEqual(len(found), 1, found) + self.assertIn("networkidle", found[0]) + + def test_a_live_call_with_a_trailing_comment_still_matches(self): + """A line-position filter (the cheap fix #230 sketched) loses this.""" + src = "await page.waitForLoadState('networkidle') // TODO: remove\n" + self.assertEqual(len(idle(src)), 1) + + def test_a_mention_after_code_on_the_same_line_is_not_a_call(self): + src = "const x = 1 // waitForLoadState('networkidle') is banned\n" + self.assertEqual(idle(src), []) + + def test_a_block_comment_interior_line_is_not_a_call(self): + """This line starts with a letter, so `grep -v '^[0-9]+:\\s*(//|\\*)'` + would have counted it.""" + src = "/*\nwaitForLoadState('networkidle') must never be used.\n*/\nawait f()\n" + self.assertEqual(idle(src), []) + + def test_wait_until_form_is_matched(self): + src = "await page.goto(u, { waitUntil: 'networkidle' })\n" + self.assertEqual(len(idle(src)), 1) + + def test_the_exclude_marker_still_suppresses(self): + src = "await page.waitForLoadState('networkidle') // e2e-networkidle exclude legacy upload probe\n" + self.assertEqual(idle(src), []) + + def test_the_exclude_marker_on_a_neighbouring_line_does_not_suppress(self): + src = ("// e2e-networkidle exclude something else entirely\n" + "await page.waitForLoadState('networkidle')\n") + self.assertEqual(len(idle(src)), 1) + + def test_the_pattern_inside_a_string_literal_is_not_a_call(self): + """A helper that documents the banned call in a message string.""" + src = "throw new Error(\"waitForLoadState('networkidle') is banned by ADR-074\")\n" + self.assertEqual(idle(src), []) + + def test_line_number_addresses_the_original_file(self): + src = "\n\n\nawait page.waitForLoadState('networkidle')\n" + self.assertTrue(idle(src)[0].startswith("tests/e2e/a.spec.ts:4:")) + + +class TestCli(unittest.TestCase): + def test_unknown_rule_is_an_error_not_an_empty_answer(self): + buf = io.StringIO() + with redirect_stdout(buf): + rc = gate.main(["check_js_call_sites.py", "--rule", "nope", "x.ts"]) + self.assertEqual(rc, 2) + self.assertEqual(buf.getvalue(), "") + + def test_an_unreadable_file_is_skipped_not_fatal(self): + buf = io.StringIO() + with redirect_stdout(buf): + rc = gate.main(["check_js_call_sites.py", "--rule", "networkidle", "/nope.ts"]) + self.assertEqual(rc, 0) + self.assertEqual(buf.getvalue(), "") + + +if __name__ == "__main__": + unittest.main() diff --git a/hydra-gates/scripts/lib/test_check_markup_a11y.py b/hydra-gates/scripts/lib/test_check_markup_a11y.py new file mode 100644 index 0000000..8a0f241 --- /dev/null +++ b/hydra-gates/scripts/lib/test_check_markup_a11y.py @@ -0,0 +1,235 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: EUPL-1.2 +"""Tests for check_markup_a11y (gate-31 img-alt, gate-32 semantic-controls). + +Run with: python3 scripts/lib/test_check_markup_a11y.py + +Every case that must NOT fire has a neighbour built from the same fixture, one +edit apart, that MUST. A gate that has only ever been observed passing is +indistinguishable from a gate that cannot fail — and the whole reason these +two gates needed repair is that they were firing on prose, which is exactly +the failure a careless relaxation converts into firing on nothing. +""" +from __future__ import annotations + +import io +import os +import sys +import unittest +from contextlib import redirect_stdout + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import check_markup_a11y as gate # noqa: E402 + + +def scan(rule: str, src: str, path: str = "src/components/X.vue") -> list[str]: + return gate.scan_source(rule, path, src) + + +# The launchpad file that produced `[gate-31] img-alt: FAIL — 1 tag(s)` +# with no `` anywhere in the component (#220). +LAUNCHPAD = """ + + +""" + +# openbuild's IconUploadSection shape (#235): two REAL images that already +# carry `:alt`, plus two JSDoc mentions that were reported as images. +OPENBUILD = """ + + +""" + + +class TestImgAlt(unittest.TestCase): + def test_launchpad_component_reports_nothing(self): + self.assertEqual(scan("img-alt", LAUNCHPAD), []) + + def test_launchpad_component_with_one_real_bad_image_reports_one(self): + """Anti-widening control. Same file, one tag added.""" + src = LAUNCHPAD.replace( + '{{ item.label }}', + '{{ item.label }}') + found = scan("img-alt", src) + self.assertEqual(len(found), 1, found) + self.assertIn("/badge.png", found[0]) + + def test_openbuild_three_findings_become_zero(self): + self.assertEqual(scan("img-alt", OPENBUILD, "src/dialogs/IconUploadSection.vue"), []) + + def test_openbuild_with_alt_deleted_from_one_image_reports_that_one(self): + """The measurement that separates 'fixed' from 'switched off'.""" + src = OPENBUILD.replace(' :alt="t(\'openbuild\', \'Dark icon\')"', '') + found = scan("img-alt", src, "src/dialogs/IconUploadSection.vue") + self.assertEqual(len(found), 1, found) + self.assertIn("iconDarkUrl", found[0]) + + def test_a_finding_names_the_tag_with_its_attributes(self): + """The bare `` in a log was the tell that a comment was scored.""" + src = '' + found = scan("img-alt", src) + self.assertEqual(len(found), 1) + self.assertIn('src="/a.png"', found[0]) + self.assertNotEqual(found[0].split(": ", 1)[1], "") + + def test_alt_written_after_an_arrow_function_is_seen(self): + """`[^>]*` ended the tag at the arrow and lost every later prop.""" + src = ('') + self.assertEqual(scan("img-alt", src), []) + + def test_the_same_tag_without_alt_still_fires(self): + src = '' + self.assertEqual(len(scan("img-alt", src)), 1) + + def test_commented_out_image_is_not_an_image(self): + src = '' + self.assertEqual(scan("img-alt", src), []) + + def test_php_template_image_is_in_scope(self): + """#225: WCAG does not care which templating language made the DOM.""" + src = " when set ?>\n\n" + found = scan("img-alt", src, "templates/settings/admin.php") + self.assertEqual(len(found), 1, found) + self.assertIn("/logo.png", found[0]) + + def test_php_comment_mentioning_img_is_not_an_image(self): + src = " at all ?>\n
\n" + self.assertEqual(scan("img-alt", src, "templates/settings/admin.php"), []) + + def test_alt_empty_is_accepted(self): + """Decorative images are declared with alt=\"\" — unchanged rule.""" + self.assertEqual(scan("img-alt", ''), []) + + def test_line_number_addresses_the_original_file(self): + src = '\n' + self.assertTrue(scan("img-alt", src)[0].startswith("src/components/X.vue:3:")) + + +class TestSemanticControls(unittest.TestCase): + # softwarecatalog's repaired element plus the comment that described what + # it replaced. Pre-fix, gate-32 scored the COMMENT and rewording it + # cleared the gate with the markup byte-identical (#236). + REPAIRED = """ +""" + + def test_the_repaired_element_reports_nothing(self): + self.assertEqual(scan("semantic-controls", self.REPAIRED), []) + + def test_the_comment_alone_reports_nothing(self): + src = "\n" + self.assertEqual(scan("semantic-controls", src), []) + + def test_a_real_bare_click_div_below_that_comment_still_fires(self): + """Anti-widening control, and the direction that matters most: a bad + element must not be explainable away by a comment above it.""" + src = ("\n") + found = scan("semantic-controls", src) + self.assertEqual(len(found), 1, found) + self.assertIn("role=", found[0]) + self.assertIn("tabindex=", found[0]) + self.assertIn("@keydown", found[0]) + + def test_removing_one_of_the_trio_from_the_repaired_element_fires(self): + src = self.REPAIRED.replace(' tabindex="0"\n', "") + found = scan("semantic-controls", src) + self.assertEqual(len(found), 1, found) + self.assertIn("missing[tabindex=]", found[0]) + + def test_anchor_with_href_is_exempt(self): + src = '' + self.assertEqual(scan("semantic-controls", src), []) + + def test_anchor_without_href_is_not_exempt(self): + src = '' + self.assertEqual(len(scan("semantic-controls", src)), 1) + + def test_click_stop_with_no_handler_is_event_management(self): + src = "" + self.assertEqual(scan("semantic-controls", src), []) + + def test_click_stop_with_a_real_handler_is_not_exempt(self): + src = '' + self.assertEqual(len(scan("semantic-controls", src)), 1) + + def test_click_written_after_an_arrow_function_is_seen(self): + """The truncation hid violations too — this is a finding the pre-fix + gate could not report at all.""" + src = ('') + self.assertEqual(len(scan("semantic-controls", src)), 1) + + def test_component_wrappers_are_out_of_scope(self): + src = '' + self.assertEqual(scan("semantic-controls", src), []) + + +class TestCli(unittest.TestCase): + def test_unknown_rule_is_an_error_not_an_empty_answer(self): + buf = io.StringIO() + with redirect_stdout(buf): + rc = gate.main(["check_markup_a11y.py", "--rule", "nonsense", "x.vue"]) + self.assertEqual(rc, 2) + self.assertEqual(buf.getvalue(), "") + + def test_missing_arguments_is_an_error(self): + self.assertEqual(gate.main(["check_markup_a11y.py"]), 2) + + def test_an_unreadable_file_is_skipped_not_fatal(self): + buf = io.StringIO() + with redirect_stdout(buf): + rc = gate.main(["check_markup_a11y.py", "--rule", "img-alt", "/nope/x.vue"]) + self.assertEqual(rc, 0) + self.assertEqual(buf.getvalue(), "") + + +if __name__ == "__main__": + unittest.main() diff --git a/hydra-gates/scripts/lib/test_check_nc_select_labels.py b/hydra-gates/scripts/lib/test_check_nc_select_labels.py new file mode 100644 index 0000000..3f68861 --- /dev/null +++ b/hydra-gates/scripts/lib/test_check_nc_select_labels.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: EUPL-1.2 +"""Tests for check_nc_select_labels (gate-12). Run with: + + python3 scripts/lib/test_check_nc_select_labels.py + +BOTH WAYS, EVERY TIME +--------------------- +The bug this helper replaces made the gate ANTI-CORRELATED with its own +subject: an `NcSelect` that named itself after a `:reduce` prop was +reported, and deleting the label prop changed nothing while deleting +`:reduce` cleared it. So the arrow-function fixtures below are paired, +element for element, with the same markup minus the label prop — which +must still be reported. A relaxation that cannot be shown to still fire is +indistinguishable from switching the gate off. + +Fixtures are the real fleet markup: scholiq's ConferenceScheduleBoard +(`:reduce` then `id`+`:input-label`), its LessonComposer (`:reduce` then +both label props), and a genuinely unnamed select next to a hand-written +`