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())