krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
main: scripts/check_accessibility.py · raw
1#!/usr/bin/env python3
2"""Fail if an icon-only control ships without an accessibility label.
3
4VoiceOver reaches a ``Button { Image(systemName: "plus") }`` with nothing to
5announce: an SF Symbol carries no label of its own, so the control is reported
6as a bare button. The same button with a ``Text`` beside it is fine, because the
7text becomes the combined label — which is why this only flags controls whose
8label view is icons all the way down.
9
10Scoping is by brace span, not line proximity. An earlier proximity check got
11both answers wrong on real files: it missed a label 14 lines up and it credited
12a control with an unrelated modifier from the view above it.
13
14Run locally with ``python3 scripts/check_accessibility.py``; exits non-zero and
15lists offenders, so the fix is always "label it, or say why it needs none".
16"""
17import re
18import subprocess
19import sys
20from pathlib import Path
21
22# Views only. Networking and model files have no controls to label.
23SOURCE_GLOBS = ["Hutch/**/*.swift", "Shared/*.swift", "HutchWidgetExtension/*.swift"]
24
25CONTROL = re.compile(r"\b(Button|NavigationLink|Menu)\b")
26ACCESSIBILITY = re.compile(r"accessibility(Label|Hidden|Hint|Value|AddTraits)")
27# A visible text view inside the control's label supplies the announcement.
28TEXTUAL = re.compile(r"\bText\(|\bLabel\(|Pill\(")
29
30# How far back a control opener may sit, and how long its body may run. Both are
31# generous for SwiftUI; a control longer than this is worth splitting anyway.
32LOOKBACK = 25
33MAX_BODY = 80
34
35
36def enclosing_control(lines: list[str], index: int) -> tuple[int, int] | None:
37 """Brace span of the nearest control whose body contains ``index``.
38
39 Returns the span including the trailing modifier chain, since
40 ``.accessibilityLabel`` attaches there rather than inside the label closure.
41 """
42 for start in range(index, max(-1, index - LOOKBACK), -1):
43 if not CONTROL.search(lines[start]):
44 continue
45 depth = 0
46 opened = False
47 end = None
48 for j in range(start, min(len(lines), start + MAX_BODY)):
49 depth += lines[j].count("{") - lines[j].count("}")
50 if "{" in lines[j]:
51 opened = True
52 if opened and depth <= 0:
53 end = j
54 break
55 if end is None or end < index:
56 continue
57 after = end + 1
58 while after < len(lines) and re.match(r"\s*\.\w+", lines[after]):
59 after += 1
60 return start, after
61 return None
62
63
64def offenders() -> list[tuple[str, int, str]]:
65 files = subprocess.run(
66 ["git", "ls-files", *SOURCE_GLOBS],
67 capture_output=True,
68 text=True,
69 check=True,
70 ).stdout.split()
71
72 found = []
73 for path in files:
74 lines = Path(path).read_text().splitlines()
75 for i, line in enumerate(lines):
76 if "Image(systemName:" not in line:
77 continue
78 span = enclosing_control(lines, i)
79 if span is None:
80 continue # a decorative image, not a control's label
81 body = "\n".join(lines[span[0] : span[1]])
82 if ACCESSIBILITY.search(body) or TEXTUAL.search(body):
83 continue
84 found.append((path, i + 1, line.strip()))
85 return found
86
87
88def main() -> int:
89 found = offenders()
90 if not found:
91 print("No unlabelled icon-only controls.")
92 return 0
93
94 print(f"{len(found)} icon-only control(s) reach VoiceOver with no label:\n")
95 for path, line, source in found:
96 print(f" {path}:{line}")
97 print(f" {source}")
98 print(
99 "\nAdd .accessibilityLabel(\"...\") to the control, or .accessibilityHidden(true)"
100 "\nif something else already announces it."
101 )
102 return 1
103
104
105if __name__ == "__main__":
106 sys.exit(main())