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
.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(-) @@ -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. @@ -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 = ( @@ -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 @@ -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 } new file mode 100644 @@ -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" + ) + } + } +} @@ -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) new file mode 100755 @@ -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())