epiphany/spikes/editor-toolkit/a11y-verifier/verify.py

1103 lines
52 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""AT-SPI2 accessibility verifier — Round 0 readback mode, and Round 2 check 5.
An AT-SPI client, independent of any candidate's own process, that walks the
live platform accessibility tree (via the AT-SPI2 registry over D-Bus). Two
modes, selected by which flags are given:
Round 0 mode — unchanged, byte-for-byte, from the version that produced
`round0-evidence/c1-egui-readback.txt` and `c2-vello-readback.txt`:
verify.py --role "push button" --name "EpiphanyProbeButton" [--app-name SUBSTR] [--max-depth N] [--timeout SECONDS]
Looks for one exact (role, name) match anywhere under the desktop (optionally
restricted to apps whose name contains --app-name). Exit 0 "READBACK: PASS",
exit 1 "READBACK: FAIL", exit 2 "READBACK: NOT RUN" (bus unreachable).
Round 2 check 5 mode — `spikes/editor-toolkit/ROUND2_TEXT_RECIPE.md` §8, an
accessibility oracle packet 2B-A precommits (`round2-a11y-oracle`):
verify.py --expectations round2-a11y-oracle/a11y_expectations.json --fixture F-A \
--expect-source-digest <round2_textkit::output::expected_artifact_digest()> \
--app-name SUBSTR [--timeout N] [--json PATH]
Scores one fixture's check 5 against the live tree under the candidate's
application (matched by --app-name, required in this mode). Exit 0 "CHECK5:
PASS", exit 1 "CHECK5: FAIL" (naming exactly one of
`round2_textkit::a11y::PROHIBITED_OUTCOMES`, or a role/composition-specific
diagnosis, when applicable), exit 2 "CHECK5: NOT RUN" — reserved *only* for
the AT-SPI bus itself being unreachable. A candidate that simply never built
an accessibility tree is `absent-from-tree`, which is a FAIL, not NOT RUN.
Uses gi.repository.Atspi, the official GObject-introspection binding for
AT-SPI2 (the same library backing Orca and Accerciser). Used in place of the
`atspi` Rust crate as an "equivalent AT-SPI client" (the contract's own
wording) — chosen because its API is stable, documented, and already
verified reachable on this machine, rather than reverse-engineering an
unfamiliar async zbus proxy API under this round's timebox. That substitution
is a named deviation, reported as such.
"""
import argparse
import json
import sys
import time
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Tuple
# ---------------------------------------------------------------------------
# Round 0 mode — unmodified from the version that produced the committed
# round0-evidence transcripts. Do not change this function's behaviour.
# ---------------------------------------------------------------------------
def walk(node, role, name, app_name_substr, max_depth, path, found, all_seen):
if node is None:
return
try:
node_name = node.get_name()
except Exception:
node_name = "<error>"
try:
node_role = node.get_role_name()
except Exception:
node_role = "<error>"
all_seen.append(" / ".join(path + [f"{node_role}:{node_name!r}"]))
if node_role == role and node_name == name:
found.append(list(path) + [f"{node_role}:{node_name!r}"])
return
if max_depth <= 0:
return
try:
n = node.get_child_count()
except Exception:
return
for i in range(n):
try:
child = node.get_child_at_index(i)
except Exception:
continue
walk(
child,
role,
name,
app_name_substr,
max_depth - 1,
path + [f"{node_role}:{node_name!r}"],
found,
all_seen,
)
def run_round0(args, Atspi):
try:
Atspi.init()
except Exception as exc:
print(f"READBACK: NOT RUN — Atspi.init() failed: {exc}")
sys.exit(2)
deadline = time.monotonic() + args.timeout
last_seen = []
attempt = 0
while time.monotonic() < deadline:
attempt += 1
try:
desktop = Atspi.get_desktop(0)
except Exception as exc:
print(f"READBACK: NOT RUN — Atspi.get_desktop(0) failed: {exc}")
sys.exit(2)
if desktop is None:
print("READBACK: NOT RUN — Atspi.get_desktop(0) returned None (no AT-SPI registry?)")
sys.exit(2)
found = []
all_seen = []
try:
n_apps = desktop.get_child_count()
except Exception as exc:
print(f"READBACK: NOT RUN — desktop.get_child_count() failed: {exc}")
sys.exit(2)
for i in range(n_apps):
try:
app = desktop.get_child_at_index(i)
except Exception:
continue
if app is None:
continue
try:
app_name = app.get_name()
except Exception:
app_name = "<error>"
if args.app_name and args.app_name not in app_name:
continue
walk(
app,
args.role,
args.name,
args.app_name,
args.max_depth,
[f"desktop"],
found,
all_seen,
)
last_seen = all_seen
if found:
print("READBACK: PASS")
print(f"attempt: {attempt}, elapsed: {args.timeout - (deadline - time.monotonic()):.2f}s")
print("path: " + " / ".join(found[0]))
print(f"apps enumerated: {n_apps}")
print("full tree (role:name) seen during the matching walk:")
for line in all_seen:
print(" " + line)
sys.exit(0)
time.sleep(args.poll_interval)
print("READBACK: FAIL")
print(f"no node with role={args.role!r} name={args.name!r} found within {args.timeout}s ({attempt} attempts)")
print("nodes actually seen (role:name), last attempt:")
if not last_seen:
print(" <none — desktop had 0 matching/enumerable apps>")
for line in last_seen:
print(" " + line)
sys.exit(1)
# ---------------------------------------------------------------------------
# Round 2 check 5 mode.
# ---------------------------------------------------------------------------
# A verifier-specific diagnostic name for the §8.1 composition trap — the
# concatenation matches `visual_order_name`, not `expected_name`. This is
# deliberately *not* one of `round2_textkit::a11y::PROHIBITED_OUTCOMES`: it is
# a structural composition failure (assembled in the wrong order), not one of
# the five name-transformation outcomes §8.3 pins. Naming it distinctly is
# the whole point of the requirement: "the report must name it rather than
# emit a generic mismatch."
VISUAL_ORDER_TRAP = "composed-in-visual-order"
# The platform row this verifier scores against — this machine's live AT
# client is AT-SPI2 (recipe §8.2, round0-evidence's precedent), matching
# `round2_a11y_oracle::PLATFORM`. Used by `validate_expectations_file` (B2)
# to refuse an expectations file generated for a different platform, rather
# than silently scoring against the wrong role vocabulary.
PLATFORM = "at-spi2"
# The exact five fixture ids the recipe names (ROUND2_TEXT_RECIPE.md §2),
# restated here — not read back out of the file being validated — the same
# discipline `round2_textkit::output::FixtureFile::validate`'s
# `EXPECTED_FIXTURES` uses, so a file missing one or carrying an extra id is
# caught against a literal, not against its own other contents.
EXPECTED_FIXTURE_IDS = frozenset({"F-A", "F-B", "F-C", "F-D", "F-E"})
def validate_expectations_file(
expectations_file: dict, *, expected_platform: str, expected_source_digest: str
) -> None:
"""B2/O1: fail closed on a malformed or stale oracle **before** any live
AT-SPI readback. Check 5 is disqualifying, so a defect in the oracle
artifact itself must never be silently absorbed into a candidate's
verdict — every check below raises `ValueError` (which the caller turns
into a usage error, exit 2, never a FAIL: a malformed oracle is not a
candidate defect) naming exactly what disagreed.
- `platform` must equal `expected_platform` — scoring F-A's at-spi2 role
vocabulary against a file generated for a different platform would
silently check the wrong roles.
- `source_fixtures_digest` must equal `expected_source_digest` — the
caller passes `round2_textkit::output::expected_artifact_digest()`
(via `--expect-source-digest`), so an oracle generated against a
*different* `fixtures.json` (stale, or regenerated on a machine with
different fonts — recipe §1) cannot score a candidate under the
pretense of being current.
- The fixture id set is exactly `EXPECTED_FIXTURE_IDS`: no duplicates, none
missing, none extra.
- Per fixture, `expected_name` / `expected_name_hex` / `expected_name_byte_len`
are mutually consistent — recipe §8.1 carries the name three ways
specifically so a divergence between them is detectable; this is what
detects it. (lowercase hex, per §8.1's own "lowercase hex" wording.)
- D1: `source_atoms` is a list of strings whose concatenation, in order,
equals `expected_name` — the same partition property
`round2_a11y_oracle::source_atoms`'s own doc comment claims and tests
on the generation side; this is the verifier-side half of that same
check, so a hand-edited or differently-generated file cannot silently
carry atoms that no longer add up to the name they are supposed to be
components of.
- O1: no two different outcome names in one fixture's `alternative_forms`
produce the same string (unchanged from the earlier fix, folded into
this same fail-closed entry point).
Does not touch AT-SPI or any live state, so it is testable without a bus,
the same as `classify`.
"""
actual_platform = expectations_file.get("platform")
if actual_platform != expected_platform:
raise ValueError(
f"platform is {actual_platform!r}, expected {expected_platform!r} — this oracle was "
"not generated for the platform being scored"
)
actual_digest = expectations_file.get("source_fixtures_digest")
if actual_digest != expected_source_digest:
raise ValueError(
f"source_fixtures_digest is {actual_digest!r}, expected {expected_source_digest!r} "
"(round2_textkit::output::expected_artifact_digest()) — this oracle may have been "
"generated against a different fixtures.json and must not score a candidate"
)
fixtures = expectations_file.get("fixtures", [])
ids = [fx.get("fixture_id") for fx in fixtures]
if len(ids) != len(set(ids)):
duplicates = sorted({i for i in ids if ids.count(i) > 1})
raise ValueError(f"duplicate fixture_id(s) in expectations file: {duplicates}")
id_set = set(ids)
missing = sorted(EXPECTED_FIXTURE_IDS - id_set)
extra = sorted(id_set - EXPECTED_FIXTURE_IDS)
if missing or extra:
raise ValueError(
f"fixture id set is {sorted(id_set)}, expected exactly {sorted(EXPECTED_FIXTURE_IDS)} "
f"(missing: {missing}, extra: {extra})"
)
for fx in fixtures:
fixture_id = fx.get("fixture_id", "<unknown>")
name = fx.get("expected_name")
name_hex = fx.get("expected_name_hex")
name_byte_len = fx.get("expected_name_byte_len")
if not isinstance(name, str):
raise ValueError(f"{fixture_id!r}: expected_name is not a string: {name!r}")
actual_name_bytes = name.encode("utf-8")
actual_hex = actual_name_bytes.hex() # Python's .hex() is always lowercase
if name_hex != actual_hex:
raise ValueError(
f"{fixture_id!r}: expected_name_hex is {name_hex!r}, but the lowercase hex of "
f"expected_name's UTF-8 bytes is {actual_hex!r} — the name and its hex have "
"diverged"
)
if name_byte_len != len(actual_name_bytes):
raise ValueError(
f"{fixture_id!r}: expected_name_byte_len is {name_byte_len!r}, but "
f"expected_name's UTF-8 byte length is {len(actual_name_bytes)}"
)
atoms = fx.get("source_atoms")
if not isinstance(atoms, list) or not all(isinstance(a, str) for a in atoms):
raise ValueError(f"{fixture_id!r}: source_atoms is not a list of strings: {atoms!r}")
joined_atoms = "".join(atoms)
if joined_atoms != name:
raise ValueError(
f"{fixture_id!r}: source_atoms {atoms!r} concatenate to {joined_atoms!r}, which "
f"does not equal expected_name {name!r} — the atoms no longer partition the name "
"they are supposed to be components of"
)
forms_by_outcome: Dict[str, List[str]] = fx.get("alternative_forms", {}) or {}
owner_of: Dict[str, str] = {}
for outcome, forms in forms_by_outcome.items():
for form in forms:
existing = owner_of.get(form)
if existing is not None and existing != outcome:
raise ValueError(
f"{fixture_id!r}: alternative forms {existing!r} and {outcome!r} both "
f"produce {form!r} — an oracle that returns two different "
"classifications for the same observed string must fail closed, not "
"let iteration order pick one"
)
owner_of[form] = outcome
@dataclass
class Verdict:
"""One check-5 scoring outcome. `verdict` is always exactly one of
"PASS" / "FAIL" (`prohibited_outcome` distinguishes NOT RUN, which is
handled by the caller before a `Verdict` is ever constructed — NOT RUN is
reserved for the AT-SPI bus itself being unreachable, never for a
classification the tree walk produced)."""
verdict: str
reason: str
observed_role: Optional[str] = None
observed_name: Optional[str] = None
prohibited_outcome: Optional[str] = None
@dataclass
class ObservedNode:
"""One node of a live AT-SPI subtree, as walked by `walk_for_check5` —
role, name, and children, preserving the structure `classify` needs to
score §8.1 composition per-subtree (B1). Deliberately holds nothing else
(no live AT-SPI object reference): once built, this is inert data, which
is what lets `classify` stay pure and bus-free."""
role: str
name: str
children: List["ObservedNode"] = field(default_factory=list)
def _iter_nodes(node: ObservedNode):
"""Every node in `node`'s subtree, `node` itself included, pre-order."""
yield node
for child in node.children:
yield from _iter_nodes(child)
def _iter_forest(roots: List[ObservedNode]):
"""Every node in every tree in `roots`, pre-order, roots first."""
for root in roots:
yield from _iter_nodes(root)
def _flatten_candidates(
roots: List[ObservedNode], accepted: set, prohibited: set
) -> List[Tuple[str, str]]:
"""Every `(role, name)` pair, for every node anywhere in the forest whose
role is a text-candidate (`accepted | prohibited`), in tree order.
Used for exactly one thing now: `classify`'s final `name-empty` vs.
`absent-from-tree` decision (user ruling), reached only after every
source-bearing scan (which considers *every* role, not just
`accepted | prohibited`) has found nothing. `name-empty` is specifically
about accepted/prohibited-role candidates existing with no name, so it
is the one remaining check that legitimately wants this narrower,
role-filtered list rather than the whole forest.
"""
return [
(n.role, n.name) for n in _iter_forest(roots) if n.role in accepted or n.role in prohibited
]
def _all_descendants(root: ObservedNode) -> List[Tuple[str, str]]:
"""Every `(role, name)` pair for **every** descendant of `root` **with a
non-empty name**, regardless of role — `root` itself excluded, since a
node's own name matching `expected_name` (or an alternative/visual-order
form) is the separate single-node case (§8.1's first alternative; this
is its second), in tree order.
Deliberately **not** filtered by role before the caller concatenates: a
non-accepted-role contributor's name is still part of what the
subtree's composition actually says, and dropping it before summing
would let a subtree "pass" by silently ignoring a contributor it
doesn't like — precisely the wrong fix for B1. The caller concatenates
first, checks role-acceptability only once the concatenation is already
confirmed to equal `expected_name` (or a precommitted alternative/
visual-order form).
**Empty-named nodes are excluded entirely, not merely ignored when
picking whom to blame.** An empty name contributes zero bytes to the
concatenation — including or excluding it never changes `subtree_concat`
— so the only thing including it can do is let a purely structural
wrapper (a `frame` or `panel` around the real contributors, exposing no
name of its own) be *named* as the offending contributor merely because
it happens to sort first in tree order, hiding the actual, non-empty,
possibly prohibited-role contributor that is the real §8.2 violation.
Excluding it here, at the source, fixes this the same way regardless of
which subtree in `classify`'s scan happens to be tried (and matched)
first — relying on the wrapper's own name to corrupt a *different*
subtree's concatenation would only fix the cases where that subtree
happened to be visited later.
Used by `classify`'s composition scan for **every** subtree, regardless
of which roles (if any) appear elsewhere in the tree — an earlier
version of this function only admitted unlisted-role contributors when
*no* accepted-or-prohibited-role node existed anywhere in the tree,
which is exactly the gating the "absent-from-tree vs. name-empty" fix
removed: a real application's window `label` must not prevent the run's
actual text, exposed under an unlisted role elsewhere in the same tree,
from being found.
"""
out: List[Tuple[str, str]] = []
for child in root.children:
for n in _iter_nodes(child):
if n.name != "":
out.append((n.role, n.name))
return out
def _is_source_bearing_fragment(name: str, targets) -> bool:
"""**One of two additive paths** (D1) `classify`'s fragment scan uses to
decide "the run's text is present, even if not composed correctly"
(user ruling, following C3) — this is the general, heuristic,
coincidence-guarded substring rule; `source_atoms` exact matching (see
`classify`'s fragment scan) is the other, precommitted, no-length-floor
path. To distinguish real (if misordered or incomplete) evidence of the
run from an unrelated node's text that happens to share a coincidental
substring, `name` counts as a source-bearing fragment of one of
`targets` (`expected_name`, or a precommitted alternative/visual-order
form) only if it is:
- non-empty and not whitespace-only (`name.strip()` is non-empty) — a
bare space is not evidence of anything, even though a space is
technically a substring of e.g. `"Coro "`;
- **at least two characters** after stripping — a single character is
not distinguishable from coincidence: almost any two unrelated
strings of ordinary language share *some* one character (a window
title and `"Coro אבג"` both very plausibly contain the letter `"o"`);
- a literal substring of at least one target, compared **as given** —
never normalized, and the containment test itself uses the raw
(unstripped) `name`, so incidental surrounding whitespace in `name`
that isn't present in the target correctly fails to match; only the
length/whitespace *gate* above is computed on the stripped form.
**Stated limit, not hidden — this rule deliberately under-detects, and
is deliberately never loosened to cover it.** A genuine run fragment
shorter than two characters — F-C's unresolved segment `ا` is exactly
this case, a single character — is never caught by *this* function, on
purpose: loosening the floor to catch it would risk exactly what
`SourceBearingFragmentGuards`' guard tests exist to catch — an
application's ordinary window title coincidentally sharing a short
substring with `expected_name` and permanently disabling
`absent-from-tree` for that fixture, "which is a worse failure than the
one being fixed" (the ruling's own words). F-C's single-character
segment is instead caught by the *other* path — an exact match against
a precommitted `source_atoms` entry, which needs no length floor at all
because it is a comparison against precommitted data, not a heuristic
guess from length alone. The two paths are independent; this function's
own contract does not change.
"""
stripped = name.strip()
if len(stripped) < 2:
return False
return any(name in target for target in targets)
def classify(expectation: dict, roots: List[ObservedNode]) -> Verdict:
"""The whole of check 5's scoring logic, and nothing else.
`expectation` is one fixture's entry from `a11y_expectations.json`
(`round2-a11y-oracle`) — a plain dict with `expected_name`,
`accepted_roles`, `prohibited_roles`, `alternative_forms` (an outcome
name mapped to a **list** of precommitted forms — O2: one outcome can
have more than one plausible rendering, e.g. `name-is-shaped-glyphs`
carries both a cluster-collapse form and a ligature presentation-form
substitution for F-A; matched if the observed name equals *any* entry),
and (optionally) `visual_order_name`. The caller must have already run
this file through `validate_expectations_file` (O1/B2) — `classify`
itself does not re-check the oracle's own integrity, since a malformed
oracle is exactly what validation exists to refuse before this function
ever runs.
`roots` is the forest of `ObservedNode` trees the live tree walk found
under the candidate's application (usually one tree, the matched app's
own node) — this function does not touch AT-SPI, D-Bus, or any live
state, which is what makes it testable without a bus.
**Shape (user ruling): source-bearing detection runs first, in full,
across every role, before any absence or empty-name determination —
never the other way around.** An earlier version of this function only
looked for the run's text under unlisted roles when *no*
accepted-or-prohibited-role node existed anywhere in the tree at all.
That gate was wrong: a real application always has *some* accepted-role
node (a window title `label`, at minimum), so the run's actual text,
exposed under an unlisted role *alongside* that unrelated label, was
never even looked for — the tree fell straight into the ordinary
(non-source-bearing) scoring path and reported whatever that path says
for "some accepted-role text exists, none of it matches," which used to
be a generic mismatch and is now (see below) `absent-from-tree`. So
every check in this section runs over the **whole forest, every role,
unconditionally** — never against just the first match, and never
gated on whether some *other*, unrelated node happens to carry an
accepted or prohibited role.
**PRECEDENCE (pinned, C1) — exact-name matches.** §8.1's rule — "the
run's own accessible name ... must equal the source string" — is
evaluated over **every** node in the forest, regardless of role, not the
first one found. If *any* node with an **accepted** role carries
`expected_name` byte-for-byte, the verdict is PASS, regardless of where
in the tree that node sits or whether some *other* node (prohibited- or
unlisted-role) also happens to carry it. Failing that, a **prohibited**-
role match is named preferentially over an **unlisted**-role one (more
specific, per §8.2's own vocabulary); failing that, an unlisted-role
match is named. This is a pinned rule, not an implementation shortcut: a
tree that lists a `canvas` node before the real `text` node is exactly
the same candidate as one that lists them in the other order, and must
score the same way. Do not "simplify" this back to returning on the
first exact-name match — that reintroduces order-dependence on a
disqualifying check.
**B1/C2: composition and its alternative-form/visual-order diagnoses are
all scored per subtree, never against a whole-application
concatenation, and admit every role as a contributor.** §8.1's second
alternative — "the names of its text descendants concatenated in
logical order" — is a statement about *one run's* subtree, and nothing
in §8.1 restricts which roles may compose it (an unlisted role composing
correctly is still wrong — see the fragment/role check below — but that
is a role failure to report, not a reason to exclude the node from the
concatenation in the first place). This function tries every node in the
forest as a candidate "this is the run" subtree root in turn, and for
each one:
- if that subtree's own descendants (any role) concatenate
byte-exactly to `expected_name` **and** every one of those
descendants has an accepted role, PASS;
- if they concatenate to `expected_name` but include a non-accepted-role
contributor, that is a FAIL naming that contributor specifically — an
otherwise-correct composition failed by one contributor's role,
**never** silently dropped from consideration or averaged away by
unrelated nodes elsewhere in the tree (B1's original bug: a stray
`canvas` node absorbed into a whole-application PASS; C2's bug on the
diagnosis side: a stray `label` node corrupting an F-D-style
visual-order composition into a generic mismatch instead of naming
`composed-in-visual-order`);
- if instead they concatenate to one of a `PROHIBITED_OUTCOMES`
alternative form, or to `visual_order_name`, that subtree's diagnosis
is recorded (not returned immediately — a PASS found in a *different*
subtree still wins, since a candidate that got it right anywhere in a
legitimate run subtree has satisfied §8.1).
A single node's own name is also checked against every alternative form
and `visual_order_name` (not only `expected_name`), regardless of role —
that has no subtree/aggregation ambiguity (one node's own name is
unambiguous regardless of tree position), so it stays a simple
whole-forest scan.
**PRECEDENCE (pinned, D2) — a byte-exact PASS outranks every
alternative-form or visual-order diagnosis, per-node or per-subtree.**
Both PASS checks above (exact-name, and composition) already scan the
*entire* forest before either can return a PASS, so evaluating them
first and in full is what makes this safe: nothing is skipped to get to
the diagnosis checks below them. Concretely, the single-node and
subtree-level alternative-form/visual-order checks run **only after**
both PASS checks have been exhausted with nothing found — never
interleaved with them. This is why F-C's legitimate two-node split
(`text:"Coro "` + `text:"ا"`, both accepted — exactly the "one text node
per direction run" composition §8.1 permits) PASSes even though
`"Coro "` alone happens to equal F-C's own precommitted
`name-drops-unresolved-codepoints` form: the composition check finds the
byte-exact two-node PASS first. Do not "simplify" this by moving an
alternative-form check earlier for convenience — doing so previously
turned a legitimate F-C composition into a false FAIL naming a
`PROHIBITED_OUTCOMES` name that did not apply.
**C3: fragments of the run's text present anywhere, under any role, even
out of the logical order §8.1 requires, are still evidence against
absence.** Failing an exact single-node or composition match above, any
node meeting the narrow `_is_source_bearing_fragment` definition (see
its own doc comment for the rule and its stated limits) is still
evidence the run's text is present, however it is arranged — this is
the case an exact-match/composition scan alone cannot see: text that is
genuinely present but misordered or incomplete.
**`absent-from-tree` vs. `name-empty` (user ruling, pinned) — decided
only after every check above has found nothing.** The distinction being
preserved: `name-empty` means an attempted **static-text exposure**
without a name (§8.3: "absence wearing a role"); `absent-from-tree`
covers a **drawing-only** tree or an **unrelated** one. Concretely:
- `name-empty` fires **only** when both hold: at least one
**accepted**-role candidate node exists somewhere in the tree, *and*
every accepted-or-prohibited-role candidate's name is empty. A lone
empty **prohibited**-role node (a canvas that drew nothing and
exposed nothing) is *not* "wearing a role" in §8.3's sense — it is
the draw-and-stop case §8.3 calls "the one this check will most
likely actually catch," and it is `absent-from-tree`.
- every other case that reaches this point — a genuinely empty tree, a
drawing-only tree, or a tree whose only text (under any role) bears no
relation to the run at all — is `absent-from-tree`.
The required regression lock for this exact distinction lives in
`AbsentFromTreeVsNameEmpty` (`test_verify.py`):
unrelated UI text only -> absent-from-tree
empty prohibited canvas only -> absent-from-tree
empty accepted text/label node -> name-empty
misordered source fragments -> composition/role failure, never absence
**Contributor order stays semantically significant everywhere in this
function** — only **non-contributor** permutations (an unrelated
sibling moving around the tree) are required to be verdict-invariant.
This function never "fixes" composition into an order-insensitive
match; that would defeat the entire point of the F-D visual-order trap
(§8.1).
Comparisons are always on the Python `str` (which is Unicode
codepoints), never bytes directly, but every string compared here is
already the exact source string on the Rust side (`str == str` is
codepoint-exact, which for valid UTF-8 is byte-exact) — the caller is
responsible for hex-encoding whatever `observed_name` this returns if a
byte-level report is needed (see `run_check5`).
"""
expected_name = expectation["expected_name"]
accepted = set(expectation["accepted_roles"])
prohibited = set(expectation["prohibited_roles"])
alt_forms: Dict[str, List[str]] = expectation.get("alternative_forms", {}) or {}
visual_order_name = expectation.get("visual_order_name")
# D1: precommitted per-segment source atoms (`round2-a11y-oracle`'s
# `source_atoms`), e.g. F-C's `["Coro ", "ا"]`. A node name exactly
# matching one is source-bearing regardless of length — this is what
# catches F-C's single-character unresolved segment `ا`, which the
# length-2 `_is_source_bearing_fragment` substring rule cannot (and must
# not be loosened to) catch on its own.
source_atoms = set(expectation.get("source_atoms", []) or [])
interesting_names = {expected_name}
for forms in alt_forms.values():
interesting_names.update(forms)
if visual_order_name is not None:
interesting_names.add(visual_order_name)
# Every node in the forest, any role — the source-bearing scans below
# are unconditional on role, per the user ruling: gating them on whether
# some *other*, unrelated node happens to carry an accepted/prohibited
# role is exactly the bug being fixed.
all_nodes: List[Tuple[str, str]] = [(n.role, n.name) for n in _iter_forest(roots)]
# 1. C1: exact-name matches, evaluated over the *entire* forest, every
# role, before deciding anything — never the first match found, and
# never gated on some other node's role.
exact_matches = [(role, name) for role, name in all_nodes if name == expected_name]
if exact_matches:
accepted_matches = [rn for rn in exact_matches if rn[0] in accepted]
if accepted_matches:
role, name = accepted_matches[0]
return Verdict(
"PASS",
f"a node with an accepted role ({role!r}) carries the accessible name "
"byte-for-byte",
observed_role=role,
observed_name=name,
)
prohibited_matches = [rn for rn in exact_matches if rn[0] in prohibited]
if prohibited_matches:
role, name = prohibited_matches[0]
return Verdict(
"FAIL",
f"a node's name matches expected_name byte-for-byte, but its role {role!r} "
"is in the at-spi2 prohibited set (no accepted-role node also carries it)",
observed_role=role,
observed_name=name,
)
# Every remaining match's role is in neither accepted nor prohibited.
role, name = exact_matches[0]
return Verdict(
"FAIL",
f"a node's name matches expected_name byte-for-byte, but its role {role!r} is "
"neither accepted nor prohibited for at-spi2 (no accepted- or prohibited-role node "
"also carries it)",
observed_role=role,
observed_name=name,
)
# 2. B1/C2: composition and its alternative-form/visual-order diagnoses,
# all scored per subtree in one pass, every role admitted as a
# contributor. Try every node in the forest as a candidate run-subtree
# root; every check below is decided by that node's own descendants
# alone, never by nodes outside it.
first_bad_composition: Optional[Verdict] = None
first_alt_form_fail: Optional[Verdict] = None
first_visual_order_fail: Optional[Verdict] = None
for candidate_root in _iter_forest(roots):
contributors = _all_descendants(candidate_root)
if not contributors:
continue
subtree_concat = "".join(name for _, name in contributors)
if subtree_concat == expected_name:
bad = [(role, name) for role, name in contributors if role not in accepted]
if not bad:
return Verdict(
"PASS",
"the descendants of one run subtree concatenate to expected_name "
"byte-for-byte, and every contributor's role is accepted",
observed_name=subtree_concat,
)
if first_bad_composition is None:
# Prefer naming a prohibited-role contributor over a merely
# unlisted one: prohibited is the specific, named §8.2
# divergence, and the report exists to say that, not the
# weaker "nobody listed this role" case — pick the first
# prohibited-role entry if any exists, else fall back to the
# first non-accepted entry (necessarily unlisted-role, since
# `bad` excludes accepted roles by construction).
prohibited_bad = [rn for rn in bad if rn[0] in prohibited]
bad_role, _bad_name = prohibited_bad[0] if prohibited_bad else bad[0]
classification = (
"prohibited" if bad_role in prohibited else "neither accepted nor prohibited"
)
other_count = len(bad) - 1
mention_others = (
f" ({other_count} other non-accepted contributor(s) also present)"
if other_count > 0
else ""
)
first_bad_composition = Verdict(
"FAIL",
"a run subtree's descendants concatenate to expected_name byte-for-byte, "
f"but contributor role {bad_role!r} is {classification} for at-spi2{mention_others} "
"— an otherwise-correct composition, failed by this contributor's role",
observed_role=bad_role,
observed_name=subtree_concat,
)
continue
if first_alt_form_fail is None:
for outcome, forms in alt_forms.items():
if subtree_concat in forms:
first_alt_form_fail = Verdict(
"FAIL",
"one run subtree's concatenated contributors match a precommitted "
f"{outcome!r} alternative form byte-for-byte",
observed_name=subtree_concat,
prohibited_outcome=outcome,
)
break
if (
first_visual_order_fail is None
and visual_order_name is not None
and subtree_concat == visual_order_name
):
first_visual_order_fail = Verdict(
"FAIL",
"one run subtree's concatenated contributors match visual_order_name, not "
"expected_name — the tree was assembled by walking the visual runs left to "
"right instead of logical order",
observed_name=subtree_concat,
prohibited_outcome=VISUAL_ORDER_TRAP,
)
if first_bad_composition is not None:
return first_bad_composition
# D2 (user ruling): a byte-exact PASS — single-node (step 1, above) or
# subtree composition (step 2, above) — outranks every alternative-form
# or visual-order diagnosis, per-node or per-subtree. Both PASS checks
# already scan the *entire* forest before this point is ever reached, so
# by construction nothing above this line has skipped a legitimate PASS
# to get here. Only now, with every PASS opportunity exhausted, do the
# alternative-form/visual-order diagnoses get a turn — starting with a
# single node's own name (no subtree ambiguity: one node's own name is
# unambiguous regardless of position or role, so this stays a flat,
# whole-forest scan), then the subtree-level matches the composition
# loop above already recorded.
#
# This ordering is why F-C's legitimate two-node split
# (`text:"Coro "` + `text:"ا"`, both accepted) now PASSes even though
# `"Coro "` alone is also F-C's precommitted `name-drops-unresolved-
# codepoints` form: the composition loop above finds the byte-exact PASS
# across both nodes and returns before this per-node check ever runs. A
# single `text:"Coro "` node with **no** second node still reaches this
# check (no composition to find), so the outcome stays named exactly as
# before — see `FCTwoSegmentComposition`'s regression group
# (`test_verify.py`) for both halves of that guarantee.
for role, name in all_nodes:
for outcome, forms in alt_forms.items():
if name in forms:
return Verdict(
"FAIL",
f"a node's name matches a precommitted {outcome!r} alternative form "
"byte-for-byte",
observed_role=role,
observed_name=name,
prohibited_outcome=outcome,
)
if visual_order_name is not None and name == visual_order_name:
return Verdict(
"FAIL",
"a node's name matches visual_order_name, not expected_name — the tree was "
"assembled by walking the visual runs left to right instead of logical order",
observed_role=role,
observed_name=name,
prohibited_outcome=VISUAL_ORDER_TRAP,
)
if first_alt_form_fail is not None:
return first_alt_form_fail
if first_visual_order_fail is not None:
return first_visual_order_fail
# 3. C3/D1: fragments of the run's text present anywhere, any role, even
# when they do not compose to any target string in the required
# logical order — the case an exact-match/composition scan alone
# cannot see: text that is genuinely present but misordered or
# incomplete. Whole-forest, not subtree-scoped: the safety valve here
# is the narrow fragment definition itself
# (`_is_source_bearing_fragment`), not tree structure — the policy is
# "any fragment anywhere is evidence against absence," which a
# subtree restriction would contradict.
#
# D1: a node counts as source-bearing via **either** of two additive
# paths — `_is_source_bearing_fragment`'s length-2-or-more substring
# rule, **or** an exact match against a precommitted `source_atoms`
# entry, regardless of length. The atom path is what catches F-C's
# unresolved segment `ا`: a single character, which the substring
# rule's coincidence guard correctly refuses (an unrelated stray "o"
# must never rescue a tree from absence) but which is nonetheless a
# real, precommitted, exact source component §8.3 requires to appear
# in the name. The two paths are independent and neither replaces the
# other — F-A (a single-segment run) has no atom shorter than its
# whole `expected_name`, so it depends entirely on the substring path,
# same as before D1.
fragments = [
(role, name)
for role, name in all_nodes
if _is_source_bearing_fragment(name, interesting_names) or name in source_atoms
]
if fragments:
roles = sorted({role for role, _ in fragments})
fragment_concat = "".join(name for _, name in fragments)
return Verdict(
"FAIL",
f"{len(fragments)} fragment(s) of the run's text are present under role(s) {roles}, "
"but do not compose to expected_name or a precommitted form in the required logical "
"order — a role/composition failure, not absent-from-tree",
observed_role=roles[0] if len(roles) == 1 else None,
observed_name=fragment_concat,
)
# 4. Nothing above found any source-bearing evidence anywhere, under any
# role, in any shape. Only one distinction remains (user ruling,
# pinned in the docstring above): `name-empty` requires an attempted
# *static-text* exposure — at least one accepted-role candidate node
# — with every accepted-or-prohibited-role candidate's name empty.
# Every other no-source-bearing case, including a lone empty
# prohibited-role node (draw-and-stop, §8.3's own headline case) and
# unrelated text under any role, is `absent-from-tree`.
flat_candidates = _flatten_candidates(roots, accepted, prohibited)
has_accepted_candidate = any(role in accepted for role, _ in flat_candidates)
if (
flat_candidates
and has_accepted_candidate
and all(name == "" for _, name in flat_candidates)
):
return Verdict(
"FAIL",
"an accepted-role candidate node is present, but every accepted- or prohibited-role "
"candidate's accessible name is empty — an attempted static-text exposure with no "
"name",
observed_name="",
prohibited_outcome="name-empty",
)
return Verdict(
"FAIL",
"no accessible-text-candidate node (accepted or prohibited role) found under the "
"candidate's application, on any single node, composed across any subtree, or as a "
"source-bearing fragment, under any role",
prohibited_outcome="absent-from-tree",
)
def walk_for_check5(node, path, all_seen, max_depth) -> Optional[ObservedNode]:
"""Recursively mirrors the live AT-SPI subtree under `node` into an
`ObservedNode` tree, and records every node's `role:name` into
`all_seen` for the human/JSON "full tree" report — the same diagnostic
output this produced before B1, alongside a tree instead of a flat list.
Unlike the pre-B1 version, this does **not** decide which nodes are
text-candidates — that decision now happens in `classify`, scoped per
subtree (B1): filtering roles *while* flattening the walk into a list is
exactly what threw away the subtree structure composition scoring needs.
"""
if node is None:
return None
try:
name = node.get_name()
except Exception:
name = "<error>"
try:
role = node.get_role_name()
except Exception:
role = "<error>"
all_seen.append(" / ".join(path + [f"{role}:{name!r}"]))
observed = ObservedNode(role=role, name=name)
if max_depth <= 0:
return observed
try:
n = node.get_child_count()
except Exception:
return observed
for i in range(n):
try:
child = node.get_child_at_index(i)
except Exception:
continue
child_observed = walk_for_check5(
child, path + [f"{role}:{name!r}"], all_seen, max_depth - 1
)
if child_observed is not None:
observed.children.append(child_observed)
return observed
def hex_lower(s: Optional[str]) -> Optional[str]:
if s is None:
return None
return s.encode("utf-8").hex()
def run_check5(args, Atspi):
try:
with open(args.expectations, "r", encoding="utf-8") as f:
expectations_file = json.load(f)
except Exception as exc:
print(f"CHECK5: usage error — could not read/parse {args.expectations!r}: {exc}")
sys.exit(2)
try:
validate_expectations_file(
expectations_file,
expected_platform=PLATFORM,
expected_source_digest=args.expect_source_digest,
)
except ValueError as exc:
print(f"CHECK5: usage error — {args.expectations!r} failed validation: {exc}")
sys.exit(2)
expectation = next(
(f for f in expectations_file.get("fixtures", []) if f.get("fixture_id") == args.fixture),
None,
)
if expectation is None:
print(
f"CHECK5: usage error — {args.fixture!r} is not a fixture in {args.expectations!r} "
f"(has: {[f.get('fixture_id') for f in expectations_file.get('fixtures', [])]})"
)
sys.exit(2)
try:
Atspi.init()
except Exception as exc:
print(f"CHECK5: NOT RUN — Atspi.init() failed: {exc}")
sys.exit(2)
deadline = time.monotonic() + args.timeout
verdict = None
all_seen: List[str] = []
attempt = 0
while time.monotonic() < deadline:
attempt += 1
try:
desktop = Atspi.get_desktop(0)
except Exception as exc:
print(f"CHECK5: NOT RUN — Atspi.get_desktop(0) failed: {exc}")
sys.exit(2)
if desktop is None:
print("CHECK5: NOT RUN — Atspi.get_desktop(0) returned None (no AT-SPI registry?)")
sys.exit(2)
roots: List[ObservedNode] = []
all_seen = []
try:
n_apps = desktop.get_child_count()
except Exception as exc:
print(f"CHECK5: NOT RUN — desktop.get_child_count() failed: {exc}")
sys.exit(2)
for i in range(n_apps):
try:
app = desktop.get_child_at_index(i)
except Exception:
continue
if app is None:
continue
try:
app_name = app.get_name()
except Exception:
app_name = "<error>"
if args.app_name not in app_name:
continue
app_observed = walk_for_check5(app, ["desktop"], all_seen, args.max_depth)
if app_observed is not None:
roots.append(app_observed)
verdict = classify(expectation, roots)
if verdict.verdict == "PASS":
break
time.sleep(args.poll_interval)
assert verdict is not None # the while loop above always runs at least once before a timeout
print(f"CHECK5: {verdict.verdict}")
print(f"fixture: {args.fixture}")
print(f"attempt: {attempt}, timeout: {args.timeout}s")
print(f"reason: {verdict.reason}")
if verdict.observed_role is not None:
print(f"observed role: {verdict.observed_role}")
if verdict.observed_name is not None:
print(f"observed name: {verdict.observed_name!r}")
print(f"observed name (hex): {hex_lower(verdict.observed_name)}")
if verdict.prohibited_outcome is not None:
print(f"prohibited outcome: {verdict.prohibited_outcome}")
print("full tree (role:name) seen during the last walk:")
if not all_seen:
print(" <none — no app matched --app-name, or it exposed no accessible children>")
for line in all_seen:
print(" " + line)
if args.json:
payload = {
"fixture_id": args.fixture,
"verdict": verdict.verdict,
"reason": verdict.reason,
"observed_role": verdict.observed_role,
"observed_name": verdict.observed_name,
"observed_name_hex": hex_lower(verdict.observed_name),
"prohibited_outcome": verdict.prohibited_outcome,
"walked_tree": all_seen,
}
with open(args.json, "w", encoding="utf-8") as f:
json.dump(payload, f, indent=2)
f.write("\n")
sys.exit({"PASS": 0, "FAIL": 1}[verdict.verdict])
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--role", default=None, help="Round 0 mode: exact role to match")
ap.add_argument("--name", default=None, help="Round 0 mode: exact name to match")
ap.add_argument(
"--expectations",
default=None,
help="Round 2 check 5 mode: path to round2-a11y-oracle's a11y_expectations.json",
)
ap.add_argument("--fixture", default=None, help="Round 2 check 5 mode: fixture id (e.g. F-A)")
ap.add_argument(
"--expect-source-digest",
default=None,
help="Round 2 check 5 mode (required): round2_textkit::output::expected_artifact_digest() "
"— refuses the expectations file (usage error, exit 2) if its source_fixtures_digest "
"disagrees, so a stale oracle cannot score a candidate (B2)",
)
ap.add_argument("--json", default=None, help="Round 2 check 5 mode: write the machine-readable verdict here")
ap.add_argument("--app-name", default=None, help="only descend into apps whose name contains this substring")
ap.add_argument("--max-depth", type=int, default=12)
ap.add_argument("--timeout", type=float, default=20.0)
ap.add_argument("--poll-interval", type=float, default=0.5)
args = ap.parse_args()
round0_mode = args.role is not None and args.name is not None
check5_mode = args.expectations is not None and args.fixture is not None
if round0_mode and check5_mode:
ap.error("--role/--name (Round 0 mode) and --expectations/--fixture (check 5 mode) are mutually exclusive")
if not round0_mode and not check5_mode:
ap.error("either --role and --name, or --expectations and --fixture, must be given")
if check5_mode and not args.app_name:
ap.error("--app-name is required in check 5 mode, to scope the walk to the candidate's application")
if check5_mode and not args.expect_source_digest:
ap.error(
"--expect-source-digest is required in check 5 mode (B2) — pass "
"round2_textkit::output::expected_artifact_digest()"
)
try:
import gi
gi.require_version("Atspi", "2.0")
from gi.repository import Atspi
except Exception as exc: # pragma: no cover - environment probe
label = "READBACK" if round0_mode else "CHECK5"
print(f"{label}: NOT RUN — could not import gi.repository.Atspi: {exc}")
sys.exit(2)
if round0_mode:
run_round0(args, Atspi)
else:
run_check5(args, Atspi)
if __name__ == "__main__":
main()