krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

b019b04f6449f8c924809f0746aeb86d1385ba6a

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-08-23T05:49:08Z

Verify accessibility labels in CI

Two checks, because neither covers the other.

scripts/check_accessibility.py reads every view file and fails on an icon-only
control with no accessibility label. Scoping is by brace span rather than line
proximity: a proximity check got both answers wrong on real files, missing a
label 14 lines up and crediting a control with a modifier belonging to the view
above it. Runs on Linux in seconds, no simulator, no credentials, and sees
screens no UI test navigates to.

HutchUITests is a new UI test target walking the controls on screen, failing on
one that announces nothing or announces an SF Symbol name. This catches what the
source check cannot — a label that compiles but never reaches the accessibility
tree.

Both were confirmed against a deliberately unlabelled button. The source check
caught it; the first UI sweep did not, because an unlabelled icon button does
not report an empty label — SwiftUI leaks the symbol name into the label and the
identifier both, and the draft tested for a dot, which "gearshape" does not have.
The sweep now compares label against identifier, which is exact.

The signed-in sweep needs a real token, since the app has no stub session, and
skips unless HUTCH_TEST_TOKEN is set. A launch argument faking the API would
close that gap; noted in ROADMAP.txt.
 .github/workflows/test.yml                         |  12 ++
 Hutch.xcodeproj/project.pbxproj                    | 140 ++++++++++++++++++++-
 .../xcshareddata/xcschemes/Hutch.xcscheme          |  11 ++
 HutchTests.xctestplan                              |  44 ++++---
 HutchUITests/AccessibilityUITests.swift            | 116 +++++++++++++++++
 ROADMAP.txt                                        |  25 +++-
 scripts/check_accessibility.py                     | 106 ++++++++++++++++
 7 files changed, 425 insertions(+), 29 deletions(-)

diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 62592d8..647149f 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -38,6 +38,18 @@ concurrency:
   cancel-in-progress: true
 
 jobs:
+  # Runs on Linux because it only reads source. Accessibility labels cannot be
+  # checked from a build — the app compiles either way — and a UI test only sees
+  # screens it can reach. This sees every view file, in seconds, with no
+  # simulator and no credentials.
+  accessibility:
+    name: accessibility labels
+    runs-on: ubuntu-latest
+
+    steps:
+      - uses: actions/checkout@v7
+      - run: python3 scripts/check_accessibility.py
+
   test:
     name: xcodebuild test
     # macos-latest still points at macOS 15, which lacks the iOS 26 SDK.
diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj
index 15f93a3..df08701 100644
--- a/Hutch.xcodeproj/project.pbxproj
+++ b/Hutch.xcodeproj/project.pbxproj
@@ -7,6 +7,8 @@
 	objects = {
 
 /* Begin PBXBuildFile section */
+		5BCB2F8D3CB7523D13629488 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 81B757B59FDC52273ADF0569 /* Foundation.framework */; };
+		85EF08B88008A7861BC573CD /* AccessibilityUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36512CB22293FDA01F42B239 /* AccessibilityUITests.swift */; };
 		8B2F89662F69DEB900FC0253 /* LICENSE in Resources */ = {isa = PBXBuildFile; fileRef = 8B2F89632F69DEB900FC0253 /* LICENSE */; };
 		8B2F89672F69DEB900FC0253 /* README.nfo in Resources */ = {isa = PBXBuildFile; fileRef = 8B2F89642F69DEB900FC0253 /* README.nfo */; };
 		8B2F89682F69DEB900FC0253 /* SECURITY.txt in Resources */ = {isa = PBXBuildFile; fileRef = 8B2F89652F69DEB900FC0253 /* SECURITY.txt */; };
@@ -24,6 +26,13 @@
 /* End PBXBuildFile section */
 
 /* Begin PBXContainerItemProxy section */
+		104E023C02864EA3BCC1B924 /* PBXContainerItemProxy */ = {
+			isa = PBXContainerItemProxy;
+			containerPortal = 8B4B28C92F6704280045FA19 /* Project object */;
+			proxyType = 1;
+			remoteGlobalIDString = 8B4B28D02F6704280045FA19;
+			remoteInfo = Hutch;
+		};
 		8B8182BB2F6B73F3000AE049 /* PBXContainerItemProxy */ = {
 			isa = PBXContainerItemProxy;
 			containerPortal = 8B4B28C92F6704280045FA19 /* Project object */;
@@ -63,6 +72,8 @@
 /* End PBXCopyFilesBuildPhase section */
 
 /* Begin PBXFileReference section */
+		36512CB22293FDA01F42B239 /* AccessibilityUITests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AccessibilityUITests.swift; sourceTree = "<group>"; };
+		81B757B59FDC52273ADF0569 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS26.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; };
 		8B01C8012F955ACC00FCFB9A /* SyncedProducts.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = SyncedProducts.storekit; sourceTree = "<group>"; };
 		8B2F89632F69DEB900FC0253 /* LICENSE */ = {isa = PBXFileReference; lastKnownFileType = text; path = LICENSE; sourceTree = "<group>"; };
 		8B2F89642F69DEB900FC0253 /* README.nfo */ = {isa = PBXFileReference; lastKnownFileType = text; path = README.nfo; sourceTree = "<group>"; };
@@ -80,6 +91,7 @@
 		8BDCA5272F6B76B20066AA29 /* HutchTests.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = HutchTests.xctestplan; sourceTree = "<group>"; };
 		8BE081F62F80000100000001 /* HutchWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = HutchWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
 		8BE083032F81000100000001 /* HutchSafariExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = HutchSafariExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
+		E53BD5EFA4A8857C8E76E77C /* HutchUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = HutchUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
 /* End PBXFileReference section */
 
 /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@@ -117,11 +129,15 @@
 		};
 		8B8182B82F6B73F3000AE049 /* HutchTests */ = {
 			isa = PBXFileSystemSynchronizedRootGroup;
+			exceptions = (
+			);
 			path = HutchTests;
 			sourceTree = "<group>";
 		};
 		8BE081F42F80000100000001 /* Shared */ = {
 			isa = PBXFileSystemSynchronizedRootGroup;
+			exceptions = (
+			);
 			path = Shared;
 			sourceTree = "<group>";
 		};
@@ -144,6 +160,14 @@
 /* End PBXFileSystemSynchronizedRootGroup section */
 
 /* Begin PBXFrameworksBuildPhase section */
+		45764517796ACFDF6884B202 /* Frameworks */ = {
+			isa = PBXFrameworksBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+				5BCB2F8D3CB7523D13629488 /* Foundation.framework in Frameworks */,
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
 		8B4B28CE2F6704280045FA19 /* Frameworks */ = {
 			isa = PBXFrameworksBuildPhase;
 			buildActionMask = 2147483647;
@@ -177,6 +201,22 @@
 /* End PBXFrameworksBuildPhase section */
 
 /* Begin PBXGroup section */
+		53CE95BE8B13B8E6B57E4BBB /* Frameworks */ = {
+			isa = PBXGroup;
+			children = (
+				7631E8C6830506A8C2FD3DAF /* iOS */,
+			);
+			name = Frameworks;
+			sourceTree = "<group>";
+		};
+		7631E8C6830506A8C2FD3DAF /* iOS */ = {
+			isa = PBXGroup;
+			children = (
+				81B757B59FDC52273ADF0569 /* Foundation.framework */,
+			);
+			name = iOS;
+			sourceTree = "<group>";
+		};
 		8B2FA9CB2F7E211800E609E2 /* API */ = {
 			isa = PBXGroup;
 			children = (
@@ -215,6 +255,8 @@
 				8BE083052F81000100000001 /* HutchSafariExtension */,
 				8B8182B82F6B73F3000AE049 /* HutchTests */,
 				8B4B28D22F6704280045FA19 /* Products */,
+				53CE95BE8B13B8E6B57E4BBB /* Frameworks */,
+				CF2D242564EB2748C4FE43C7 /* HutchUITests */,
 			);
 			sourceTree = "<group>";
 		};
@@ -225,13 +267,41 @@
 				8B8182B72F6B73F3000AE049 /* HutchTests.xctest */,
 				8BE081F62F80000100000001 /* HutchWidgetExtension.appex */,
 				8BE083032F81000100000001 /* HutchSafariExtension.appex */,
+				E53BD5EFA4A8857C8E76E77C /* HutchUITests.xctest */,
 			);
 			name = Products;
 			sourceTree = "<group>";
 		};
+		CF2D242564EB2748C4FE43C7 /* HutchUITests */ = {
+			isa = PBXGroup;
+			children = (
+				36512CB22293FDA01F42B239 /* AccessibilityUITests.swift */,
+			);
+			name = HutchUITests;
+			path = HutchUITests;
+			sourceTree = SOURCE_ROOT;
+		};
 /* End PBXGroup section */
 
 /* Begin PBXNativeTarget section */
+		55C58E61B9D46807C2524ABE /* HutchUITests */ = {
+			isa = PBXNativeTarget;
+			buildConfigurationList = 17E0153B64F83AB0AB28E4EC /* Build configuration list for PBXNativeTarget "HutchUITests" */;
+			buildPhases = (
+				7D4FCDD80891AAAA803FF5A8 /* Sources */,
+				45764517796ACFDF6884B202 /* Frameworks */,
+				6693997E5CA01B62C500224F /* Resources */,
+			);
+			buildRules = (
+			);
+			dependencies = (
+				425D21A6305B6A50B714F42D /* PBXTargetDependency */,
+			);
+			name = HutchUITests;
+			productName = HutchUITests;
+			productReference = E53BD5EFA4A8857C8E76E77C /* HutchUITests.xctest */;
+			productType = "com.apple.product-type.bundle.ui-testing";
+		};
 		8B4B28D02F6704280045FA19 /* Hutch */ = {
 			isa = PBXNativeTarget;
 			buildConfigurationList = 8B4B28DC2F6704290045FA19 /* Build configuration list for PBXNativeTarget "Hutch" */;
@@ -277,8 +347,6 @@
 				8B8182B82F6B73F3000AE049 /* HutchTests */,
 			);
 			name = HutchTests;
-			packageProductDependencies = (
-			);
 			productName = HutchTests;
 			productReference = 8B8182B72F6B73F3000AE049 /* HutchTests.xctest */;
 			productType = "com.apple.product-type.bundle.unit-test";
@@ -300,8 +368,6 @@
 				8BE081F52F80000100000001 /* HutchWidgetExtension */,
 			);
 			name = HutchWidgetExtension;
-			packageProductDependencies = (
-			);
 			productName = HutchWidgetExtension;
 			productReference = 8BE081F62F80000100000001 /* HutchWidgetExtension.appex */;
 			productType = "com.apple.product-type.app-extension";
@@ -322,8 +388,6 @@
 				8BE083052F81000100000001 /* HutchSafariExtension */,
 			);
 			name = HutchSafariExtension;
-			packageProductDependencies = (
-			);
 			productName = HutchSafariExtension;
 			productReference = 8BE083032F81000100000001 /* HutchSafariExtension.appex */;
 			productType = "com.apple.product-type.app-extension";
@@ -372,11 +436,19 @@
 				8BE081F72F80000100000001 /* HutchWidgetExtension */,
 				8BE083092F81000100000001 /* HutchSafariExtension */,
 				8B8182B62F6B73F3000AE049 /* HutchTests */,
+				55C58E61B9D46807C2524ABE /* HutchUITests */,
 			);
 		};
 /* End PBXProject section */
 
 /* Begin PBXResourcesBuildPhase section */
+		6693997E5CA01B62C500224F /* Resources */ = {
+			isa = PBXResourcesBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
 		8B4B28CF2F6704280045FA19 /* Resources */ = {
 			isa = PBXResourcesBuildPhase;
 			buildActionMask = 2147483647;
@@ -418,6 +490,14 @@
 /* End PBXResourcesBuildPhase section */
 
 /* Begin PBXSourcesBuildPhase section */
+		7D4FCDD80891AAAA803FF5A8 /* Sources */ = {
+			isa = PBXSourcesBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+				85EF08B88008A7861BC573CD /* AccessibilityUITests.swift in Sources */,
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
 		8B4B28CD2F6704280045FA19 /* Sources */ = {
 			isa = PBXSourcesBuildPhase;
 			buildActionMask = 2147483647;
@@ -449,6 +529,12 @@
 /* End PBXSourcesBuildPhase section */
 
 /* Begin PBXTargetDependency section */
+		425D21A6305B6A50B714F42D /* PBXTargetDependency */ = {
+			isa = PBXTargetDependency;
+			name = Hutch;
+			target = 8B4B28D02F6704280045FA19 /* Hutch */;
+			targetProxy = 104E023C02864EA3BCC1B924 /* PBXContainerItemProxy */;
+		};
 		8B8182BC2F6B73F3000AE049 /* PBXTargetDependency */ = {
 			isa = PBXTargetDependency;
 			target = 8B4B28D02F6704280045FA19 /* Hutch */;
@@ -467,6 +553,23 @@
 /* End PBXTargetDependency section */
 
 /* Begin XCBuildConfiguration section */
+		0695DD4B2F10503E9E898572 /* Release */ = {
+			isa = XCBuildConfiguration;
+			buildSettings = {
+				CLANG_ENABLE_OBJC_WEAK = NO;
+				CODE_SIGNING_ALLOWED = NO;
+				GENERATE_INFOPLIST_FILE = YES;
+				IPHONEOS_DEPLOYMENT_TARGET = 17.6;
+				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.HutchUITests;
+				PRODUCT_NAME = "$(TARGET_NAME)";
+				SDKROOT = iphoneos;
+				SWIFT_VERSION = 6.0;
+				TARGETED_DEVICE_FAMILY = "1,2";
+				TEST_TARGET_NAME = Hutch;
+				VALIDATE_PRODUCT = YES;
+			};
+			name = Release;
+		};
 		8B4B28DA2F6704290045FA19 /* Debug */ = {
 			isa = XCBuildConfiguration;
 			buildSettings = {
@@ -824,9 +927,34 @@
 			};
 			name = Release;
 		};
+		CFEEB56B9E52A6AA5A030489 /* Debug */ = {
+			isa = XCBuildConfiguration;
+			buildSettings = {
+				CLANG_ENABLE_OBJC_WEAK = NO;
+				CODE_SIGNING_ALLOWED = NO;
+				GENERATE_INFOPLIST_FILE = YES;
+				IPHONEOS_DEPLOYMENT_TARGET = 17.6;
+				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.HutchUITests;
+				PRODUCT_NAME = "$(TARGET_NAME)";
+				SDKROOT = iphoneos;
+				SWIFT_VERSION = 6.0;
+				TARGETED_DEVICE_FAMILY = "1,2";
+				TEST_TARGET_NAME = Hutch;
+			};
+			name = Debug;
+		};
 /* End XCBuildConfiguration section */
 
 /* Begin XCConfigurationList section */
+		17E0153B64F83AB0AB28E4EC /* Build configuration list for PBXNativeTarget "HutchUITests" */ = {
+			isa = XCConfigurationList;
+			buildConfigurations = (
+				0695DD4B2F10503E9E898572 /* Release */,
+				CFEEB56B9E52A6AA5A030489 /* Debug */,
+			);
+			defaultConfigurationIsVisible = 0;
+			defaultConfigurationName = Release;
+		};
 		8B4B28CC2F6704280045FA19 /* Build configuration list for PBXProject "Hutch" */ = {
 			isa = XCConfigurationList;
 			buildConfigurations = (
diff --git a/Hutch.xcodeproj/xcshareddata/xcschemes/Hutch.xcscheme b/Hutch.xcodeproj/xcshareddata/xcschemes/Hutch.xcscheme
index b273afb..7dafdfa 100644
--- a/Hutch.xcodeproj/xcshareddata/xcschemes/Hutch.xcscheme
+++ b/Hutch.xcodeproj/xcshareddata/xcschemes/Hutch.xcscheme
@@ -46,6 +46,17 @@
                ReferencedContainer = "container:Hutch.xcodeproj">
             </BuildableReference>
          </TestableReference>
+         <TestableReference
+            skipped = "NO"
+            parallelizable = "NO">
+            <BuildableReference
+               BuildableIdentifier = "primary"
+               BlueprintIdentifier = "55C58E61B9D46807C2524ABE"
+               BuildableName = "HutchUITests.xctest"
+               BlueprintName = "HutchUITests"
+               ReferencedContainer = "container:Hutch.xcodeproj">
+            </BuildableReference>
+         </TestableReference>
       </Testables>
    </TestAction>
    <LaunchAction
diff --git a/HutchTests.xctestplan b/HutchTests.xctestplan
index 8af3d1b..c61e221 100644
--- a/HutchTests.xctestplan
+++ b/HutchTests.xctestplan
@@ -1,30 +1,36 @@
 {
-  "configurations" : [
+  "configurations": [
     {
-      "id" : "06A589A5-80C7-41FE-911F-03518252CBDD",
-      "name" : "Test Scheme Action",
-      "options" : {
-
-      }
+      "id": "06A589A5-80C7-41FE-911F-03518252CBDD",
+      "name": "Test Scheme Action",
+      "options": {}
     }
   ],
-  "defaultOptions" : {
-    "performanceAntipatternCheckerEnabled" : true,
-    "targetForVariableExpansion" : {
-      "containerPath" : "container:Hutch.xcodeproj",
-      "identifier" : "8B4B28D02F6704280045FA19",
-      "name" : "Hutch"
+  "defaultOptions": {
+    "performanceAntipatternCheckerEnabled": true,
+    "targetForVariableExpansion": {
+      "containerPath": "container:Hutch.xcodeproj",
+      "identifier": "8B4B28D02F6704280045FA19",
+      "name": "Hutch"
     }
   },
-  "testTargets" : [
+  "testTargets": [
+    {
+      "parallelizable": true,
+      "target": {
+        "containerPath": "container:Hutch.xcodeproj",
+        "identifier": "8B8182B62F6B73F3000AE049",
+        "name": "HutchTests"
+      }
+    },
     {
-      "parallelizable" : true,
-      "target" : {
-        "containerPath" : "container:Hutch.xcodeproj",
-        "identifier" : "8B8182B62F6B73F3000AE049",
-        "name" : "HutchTests"
+      "parallelizable": false,
+      "target": {
+        "containerPath": "container:Hutch.xcodeproj",
+        "identifier": "55C58E61B9D46807C2524ABE",
+        "name": "HutchUITests"
       }
     }
   ],
-  "version" : 1
+  "version": 1
 }
diff --git a/HutchUITests/AccessibilityUITests.swift b/HutchUITests/AccessibilityUITests.swift
new file mode 100644
index 0000000..005910e
--- /dev/null
+++ b/HutchUITests/AccessibilityUITests.swift
@@ -0,0 +1,116 @@
+//  Verifies what a build cannot: that controls reach VoiceOver with something to say.
+//
+//  `scripts/check_accessibility.py` proves no icon-only control is missing a label in
+//  *source*. It cannot prove the label survives to the accessibility tree — a modifier
+//  on the wrong side of a `.buttonStyle`, or a container that flattens its children,
+//  compiles and lints clean and still announces nothing. That is what this asserts.
+//
+//  The sweep is deliberately generic rather than a list of expected labels. A hardcoded
+//  list goes stale the moment a screen changes and tests only what someone remembered to
+//  add; walking whatever is on screen catches controls nobody thought about.
+
+import XCTest
+
+// XCUIApplication is MainActor-isolated, and this project builds in Swift 6 language
+// mode, so the whole case is annotated rather than each call hopping actors.
+@MainActor
+final class AccessibilityUITests: XCTestCase {
+
+    override func setUp() async throws {
+        try await super.setUp()
+        continueAfterFailure = false
+    }
+
+    // MARK: - Reachable without credentials
+
+    /// Every control on the auth screen announces itself.
+    ///
+    /// This is the one screen reachable with no token, so it is the only part of the
+    /// sweep that runs unconditionally. It is a thin slice of the app, and the point of
+    /// `authenticatedSessionHasNoSilentControls` is to cover the rest.
+    func testAuthScreenHasNoSilentControls() {
+        let app = XCUIApplication()
+        app.launch()
+
+        XCTAssertTrue(
+            app.buttons.firstMatch.waitForExistence(timeout: 10),
+            "the auth screen never appeared, so nothing was verified"
+        )
+        assertNoSilentControls(in: app, screen: "auth")
+    }
+
+    // MARK: - Requires a token
+
+    /// The same sweep across the signed-in tabs.
+    ///
+    /// Skipped unless `HUTCH_TEST_TOKEN` is set, because the app has no stub session:
+    /// there is no launch argument that fakes an API, so reaching a signed-in screen
+    /// means really signing in. Supply a SourceHut personal access token to run it:
+    ///
+    ///     HUTCH_TEST_TOKEN=… xcodebuild test -scheme Hutch -testPlan HutchUITests …
+    ///
+    /// The token is read from the environment and never written to the repository.
+    func testAuthenticatedSessionHasNoSilentControls() throws {
+        let token = ProcessInfo.processInfo.environment["HUTCH_TEST_TOKEN"]
+        try XCTSkipIf(
+            token?.isEmpty ?? true,
+            "set HUTCH_TEST_TOKEN to sweep the signed-in screens"
+        )
+
+        let app = XCUIApplication()
+        app.launch()
+
+        let field = app.secureTextFields.firstMatch.exists
+            ? app.secureTextFields.firstMatch
+            : app.textFields.firstMatch
+        XCTAssertTrue(field.waitForExistence(timeout: 10), "no token field on the auth screen")
+        field.tap()
+        field.typeText(token!)
+
+        app.buttons["Connect"].tap()
+
+        // Home is the landing tab; its tab bar is the signal that sign-in completed.
+        XCTAssertTrue(
+            app.tabBars.firstMatch.waitForExistence(timeout: 30),
+            "sign-in did not reach the tab bar — check the token"
+        )
+
+        for tab in app.tabBars.buttons.allElementsBoundByIndex {
+            guard tab.isHittable else { continue }
+            let name = tab.label
+            tab.tap()
+            _ = app.staticTexts.firstMatch.waitForExistence(timeout: 10)
+            assertNoSilentControls(in: app, screen: name)
+        }
+    }
+
+    // MARK: - The sweep
+
+    /// Fail for any hittable control VoiceOver would reach with no usable label.
+    ///
+    /// An unlabelled `Button { Image(systemName: "gearshape") }` does not surface as an
+    /// empty label — SwiftUI leaks the symbol name into *both* the label and the
+    /// identifier, so VoiceOver announces "gearshape". Comparing the two is what detects
+    /// it, and it is exact rather than a guess at what a symbol name looks like: an
+    /// earlier version tested for a dot and sailed straight past "gearshape".
+    ///
+    /// This works because the app sets no `accessibilityIdentifier` anywhere, so a
+    /// non-empty identifier can only have come from a symbol. Should one ever be set
+    /// deliberately, this needs to exclude it.
+    private func assertNoSilentControls(in app: XCUIApplication, screen: String) {
+        for button in app.buttons.allElementsBoundByIndex {
+            guard button.isHittable else { continue }
+
+            let label = button.label.trimmingCharacters(in: .whitespacesAndNewlines)
+            XCTAssertFalse(
+                label.isEmpty,
+                "\(screen): a button announces nothing at \(button.frame)"
+            )
+            XCTAssertFalse(
+                !button.identifier.isEmpty && button.identifier == label,
+                "\(screen): a button announces the SF Symbol name \"\(label)\" — "
+                    + "it needs an .accessibilityLabel"
+            )
+        }
+    }
+}
diff --git a/ROADMAP.txt b/ROADMAP.txt
index e656c63..155b5c6 100644
--- a/ROADMAP.txt
+++ b/ROADMAP.txt
@@ -232,10 +232,27 @@ Decorative chevrons inside a control that already carries text were left alone.
 An unlabelled SF Symbol contributes nothing to a combined label, so hiding them
 would be churn with no announced difference.
 
-Still open: **this is not device-verified.** It builds and the suite is green,
-but neither proves a VoiceOver pass. What is verified is that no icon-only
-control is left unlabelled — a static check over the enclosing control's brace
-span, not a guess at line proximity.
+Two checks now hold this, because either alone is insufficient:
+
+- `scripts/check_accessibility.py` reads every view file and fails on an
+  icon-only control with no label. It runs on Linux in seconds, needs no
+  simulator and no credentials, and covers screens no test navigates to. What it
+  cannot see is whether a label survives to the accessibility tree.
+- `HutchUITests/AccessibilityUITests.swift` walks the controls actually on
+  screen and fails on one that announces nothing, or that announces an SF Symbol
+  name. Both checks were confirmed against a deliberately unlabelled button; the
+  first draft of the UI sweep passed it, which is why the check now compares the
+  label against the identifier rather than guessing at symbol-name shape.
+
+The UI sweep covers the auth screen unconditionally. The signed-in tabs need a
+real token — the app has no stub session, so there is no way to reach them
+offline — and that test skips unless `HUTCH_TEST_TOKEN` is set. Adding a launch
+argument that fakes the API would remove that gap and is worth doing before the
+next accessibility pass.
+
+Still open: **this is not device-verified.** Neither check is VoiceOver. They
+prove a control has something to announce, not that the announcement is
+sensible in order, with rotor navigation, at Dynamic Type sizes.
 
 ### SonarCloud backlog — done in code (v3.8.1)
 
diff --git a/scripts/check_accessibility.py b/scripts/check_accessibility.py
new file mode 100755
index 0000000..9d2e72d
--- /dev/null
+++ b/scripts/check_accessibility.py
@@ -0,0 +1,106 @@
+#!/usr/bin/env python3
+"""Fail if an icon-only control ships without an accessibility label.
+
+VoiceOver reaches a ``Button { Image(systemName: "plus") }`` with nothing to
+announce: an SF Symbol carries no label of its own, so the control is reported
+as a bare button. The same button with a ``Text`` beside it is fine, because the
+text becomes the combined label — which is why this only flags controls whose
+label view is icons all the way down.
+
+Scoping is by brace span, not line proximity. An earlier proximity check got
+both answers wrong on real files: it missed a label 14 lines up and it credited
+a control with an unrelated modifier from the view above it.
+
+Run locally with ``python3 scripts/check_accessibility.py``; exits non-zero and
+lists offenders, so the fix is always "label it, or say why it needs none".
+"""
+import re
+import subprocess
+import sys
+from pathlib import Path
+
+# Views only. Networking and model files have no controls to label.
+SOURCE_GLOBS = ["Hutch/**/*.swift", "Shared/*.swift", "HutchWidgetExtension/*.swift"]
+
+CONTROL = re.compile(r"\b(Button|NavigationLink|Menu)\b")
+ACCESSIBILITY = re.compile(r"accessibility(Label|Hidden|Hint|Value|AddTraits)")
+# A visible text view inside the control's label supplies the announcement.
+TEXTUAL = re.compile(r"\bText\(|\bLabel\(|Pill\(")
+
+# How far back a control opener may sit, and how long its body may run. Both are
+# generous for SwiftUI; a control longer than this is worth splitting anyway.
+LOOKBACK = 25
+MAX_BODY = 80
+
+
+def enclosing_control(lines: list[str], index: int) -> tuple[int, int] | None:
+    """Brace span of the nearest control whose body contains ``index``.
+
+    Returns the span including the trailing modifier chain, since
+    ``.accessibilityLabel`` attaches there rather than inside the label closure.
+    """
+    for start in range(index, max(-1, index - LOOKBACK), -1):
+        if not CONTROL.search(lines[start]):
+            continue
+        depth = 0
+        opened = False
+        end = None
+        for j in range(start, min(len(lines), start + MAX_BODY)):
+            depth += lines[j].count("{") - lines[j].count("}")
+            if "{" in lines[j]:
+                opened = True
+            if opened and depth <= 0:
+                end = j
+                break
+        if end is None or end < index:
+            continue
+        after = end + 1
+        while after < len(lines) and re.match(r"\s*\.\w+", lines[after]):
+            after += 1
+        return start, after
+    return None
+
+
+def offenders() -> list[tuple[str, int, str]]:
+    files = subprocess.run(
+        ["git", "ls-files", *SOURCE_GLOBS],
+        capture_output=True,
+        text=True,
+        check=True,
+    ).stdout.split()
+
+    found = []
+    for path in files:
+        lines = Path(path).read_text().splitlines()
+        for i, line in enumerate(lines):
+            if "Image(systemName:" not in line:
+                continue
+            span = enclosing_control(lines, i)
+            if span is None:
+                continue  # a decorative image, not a control's label
+            body = "\n".join(lines[span[0] : span[1]])
+            if ACCESSIBILITY.search(body) or TEXTUAL.search(body):
+                continue
+            found.append((path, i + 1, line.strip()))
+    return found
+
+
+def main() -> int:
+    found = offenders()
+    if not found:
+        print("No unlabelled icon-only controls.")
+        return 0
+
+    print(f"{len(found)} icon-only control(s) reach VoiceOver with no label:\n")
+    for path, line, source in found:
+        print(f"  {path}:{line}")
+        print(f"      {source}")
+    print(
+        "\nAdd .accessibilityLabel(\"...\") to the control, or .accessibilityHidden(true)"
+        "\nif something else already announces it."
+    )
+    return 1
+
+
+if __name__ == "__main__":
+    sys.exit(main())