#!/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 \ --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 = "" try: node_role = node.get_role_name() except Exception: node_role = "" 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 = "" 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(" ") 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", "") 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 = "" try: role = node.get_role_name() except Exception: role = "" 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 = "" 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(" ") 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()