diff --git a/scripts/costruzione_grafo/hag_queries.py b/scripts/costruzione_grafo/hag_queries.py new file mode 100644 index 0000000..82f23fe --- /dev/null +++ b/scripts/costruzione_grafo/hag_queries.py @@ -0,0 +1,1161 @@ +from __future__ import annotations +""" +hag_queries.py +============== +Structural queries on the Heterogeneous Accessibility Graph (HAG) produced by +heterogeneous_ax_graph.py. + +Each query function: + • accepts a nx.MultiDiGraph (the HAG) + • returns a list of Issue dicts with fields: + node_id – graph node id(s) involved + wcag – list of WCAG success criteria (e.g. ["1.3.2", "2.4.3"]) + severity – "critical" | "serious" | "moderate" | "minor" + layer – which layer(s) the issue spans + title – short human-readable label + detail – explanation with node-specific values + +Queries are grouped by the "gap" they detect: + Δ0 – intra-layer DOM issues (missing labels, ARIA misuse) + Δ1 – AX - DOM gap (markup vs AX tree divergence) + Δ2 – AX - Visual gap (semantic order vs perceptual order) + Δ3 – DOM - Visual gap (structure vs layout) + +Usage +----- + from hag_queries import run_all_queries, print_report + issues = run_all_queries(HAG) + print_report(issues) + +Or from the command line (requires a saved JSON graph): + python hag_queries.py hag.json + python hag_queries.py hag.json --verbose + python hag_queries.py hag.json --query Q1.1 + python hag_queries.py hag.json --report issues.json +""" + +""" +Architettura del modulo +Ogni query è una funzione autonoma che restituisce una lista di Issue — un dataclass con query_id, title, wcag, severity, layer, node_id, detail e un dizionario extra per dati specifici. La severità è calcolata in modo contestuale (non fissa) — ad esempio Q1.2 diventa critical se il nodo visivo senza AX occupa più del 10% della viewport. +Le query sono organizzate in quattro gap, coerenti con la MVAR discussa prima: +Δ0 — intra-layer (Q0.x): problemi rilevabili dentro un singolo layer, senza confronto tra viste. Gerarchia heading (livelli saltati, h1 multipli, heading vuoti), immagini senza alt, input senza label, struttura landmark, elementi interattivi senza nome. Sono i criteri più "classici" ma qui vengono rilevati con il contesto del grafo — ad esempio Q0.5 (input senza label) verifica sia il nome AX che l'assenza di arco label_for nel DOM layer. +Δ1 — AX - DOM (Q1.x): divergenza tra markup dichiarato e albero AX effettivo. Q1.1 trova nodi AX senza visual (visually hidden non intenzionale), Q1.2 trova nodi visivi senza AX (invisibili agli screen reader), Q1.3 trova elementi interattivi del DOM senza alcun nodo AX (completamente opachi), Q1.4 trova nodi AX ignorati che però occupano spazio visivo significativo. +Δ2 — AX - Visual (Q2.x): mismatch di ordine tra sequenza semantica e sequenza percettiva. Q2.1 misura lo scarto di posizione normalizzato tra rank AX e rank visivo, con soglia di prominenza per filtrare falsi positivi. Q2.2 fa lo stesso ma solo per elementi focusable (tab order vs visual order). +Δ3 — DOM - Visual (Q3.x): elementi visivamente prominenti il cui DOM node non porta semantica (div/span senza ruolo ARIA). +""" + + + +import json +import sys +from dataclasses import dataclass, field, asdict +from typing import Any + +import networkx as nx + + +# ───────────────────────────────────────────────────────────────────────────── +# Data model +# ───────────────────────────────────────────────────────────────────────────── + +@dataclass +class Issue: + query_id : str + title : str + wcag : list[str] + severity : str # critical | serious | moderate | minor + layer : str # dom | ax | visual | cross + node_id : str # primary node involved + detail : str + extra : dict = field(default_factory=dict) # optional extra fields + + def as_dict(self) -> dict: + return asdict(self) + + +# ───────────────────────────────────────────────────────────────────────────── +# Graph traversal helpers +# ───────────────────────────────────────────────────────────────────────────── + +def _nodes_by_layer(HAG: nx.MultiDiGraph, layer: str) -> list[tuple[str, dict]]: + return [(n, d) for n, d in HAG.nodes(data=True) if d.get("layer") == layer] + + +def _neighbors_by_relation( + HAG: nx.MultiDiGraph, node: str, relation: str +) -> list[str]: + """Return all neighbours reachable via edges with the given relation.""" + result = [] + for _, v, data in HAG.out_edges(node, data=True): + if data.get("relation") == relation: + result.append(v) + for u, _, data in HAG.in_edges(node, data=True): + if data.get("relation") == relation: + result.append(u) + return result + + +def _ax_neighbors_via_cross(HAG: nx.MultiDiGraph, ax_node: str) -> list[str]: + """Return visual layer nodes linked to an AX node via ax_to_visual.""" + return _neighbors_by_relation(HAG, ax_node, "ax_to_visual") + + +def _dom_neighbors_via_cross(HAG: nx.MultiDiGraph, dom_node: str) -> list[str]: + """Return AX nodes linked to a DOM node via dom_to_ax.""" + return _neighbors_by_relation(HAG, dom_node, "dom_to_ax") + + +def _ax_reading_order(HAG: nx.MultiDiGraph) -> list[tuple[str, dict]]: + """ + Return non-ignored AX nodes in DOM tree order (breadth-first from root). + This approximates screen-reader reading order. + """ + roots = [ + n for n, d in HAG.nodes(data=True) + if d.get("layer") == "ax" + and d.get("role") in ("RootWebArea", "WebArea") + ] + if not roots: + # fallback: any AX node with no AX parent + ax_nodes = {n for n, d in HAG.nodes(data=True) if d.get("layer") == "ax"} + ax_children = set() + for u, v, d in HAG.edges(data=True): + if d.get("relation") == "hasChild" and d.get("layer") == "ax": + ax_children.add(v) + roots = list(ax_nodes - ax_children) + + visited, order = set(), [] + queue = list(roots) + while queue: + node = queue.pop(0) + if node in visited: + continue + visited.add(node) + attrs = HAG.nodes[node] + if not attrs.get("ignored"): + order.append((node, attrs)) + children = [ + v for _, v, d in HAG.out_edges(node, data=True) + if d.get("relation") == "hasChild" and d.get("layer") == "ax" + ] + queue.extend(children) + return order + + + +def _dom_reading_order(HAG: nx.MultiDiGraph) -> list[tuple[str, dict]]: + """ + Return non-ignored DOM nodes in DOM tree order (breadth-first from root). + This approximates TAB reading order. + """ + + #dom_nodes = {n for n, d in HAG.nodes(data=True) if (d.get("layer") == "dom" and d.get("node_type")==1)} + dom_nodes = {n for n, d in HAG.nodes(data=True) if d.get("layer") == "dom" } + print("dom_nodes:",dom_nodes) + dom_children = set() + for u, v, d in HAG.edges(data=True): + if d.get("relation") == "hasChild" and d.get("layer") == "dom": + dom_children.add(v) + roots = list(dom_nodes - dom_children) + print("roots:",roots)# va controllato filtro solo eleenti realmente interattivi (vedi plugin) + + + + visited, order = set(), [] + queue = list(roots) + while queue: + node = queue.pop(0) + if node in visited: + continue + visited.add(node) + attrs = HAG.nodes[node] + if not attrs.get("ignored"): + order.append((node, attrs)) + children = [ + v for _, v, d in HAG.out_edges(node, data=True) + if d.get("relation") == "hasChild" and d.get("layer") == "dom" + ] + queue.extend(children) + return order + + +def _visual_reading_order(HAG: nx.MultiDiGraph) -> list[tuple[str, dict]]: + """Return visual nodes sorted by reading_rank (top→bottom, left→right).""" + vis = [(n, d) for n, d in HAG.nodes(data=True) if d.get("layer") == "visual"] + return sorted(vis, key=lambda x: x[1].get("reading_rank", 0)) + + +def _ax_ancestors(HAG: nx.MultiDiGraph, node: str) -> list[str]: + """Walk up the AX child edges and return ancestor node ids.""" + ancestors = [] + current = node + for _ in range(50): # cap depth + parents = [ + u for u, _, d in HAG.in_edges(current, data=True) + if d.get("relation") == "hasChild" and d.get("layer") == "ax" + ] + if not parents: + break + current = parents[0] + ancestors.append(current) + return ancestors + + +def _dom_ancestors(HAG: nx.MultiDiGraph, node: str) -> list[str]: + ancestors = [] + current = node + for _ in range(80): + parents = [ + u for u, _, d in HAG.in_edges(current, data=True) + if d.get("relation") == "hasChild" and d.get("layer") == "dom" + ] + if not parents: + break + current = parents[0] + ancestors.append(current) + return ancestors + + +# ───────────────────────────────────────────────────────────────────────────── +# Δ1 queries – AX - DOM gap +# "what the markup declares" vs "what the AX tree exposes" +# ───────────────────────────────────────────────────────────────────────────── + +def q_ax_nodes_without_visual(HAG: nx.MultiDiGraph) -> list[Issue]: + """ + Δ1/Δ2 – Non-ignored AX nodes with no linked visual node. + + These elements exist in the semantic tree (screen reader sees them) but + have no rendered bounding box – meaning sighted users cannot perceive them + visually. This can be intentional (visually-hidden text for SR only) or + a bug (content accidentally off-screen / clipped). + + WCAG: 1.3.1 Info and Relationships (A) + #NL: qua dentro cadono anche i tag particolari come "main" o "list" tolta da grafo visivo (la lista di per se non viene vista ma solo i singoli elementi nel caso). ol e ul tolti anche perchè se le vede solo il testo + # ci cascano anche elemneti ax di tipo "generic" + """ + issues = [] + for nid, attrs in _nodes_by_layer(HAG, "ax"): + if attrs.get("ignored"): + continue + role = str(attrs.get("role") or "") + # skip purely structural roles that are never rendered + if role in ("RootWebArea", "WebArea", "InlineTextBox", + "none", "presentation", "ListMarker"): + continue + vis_neighbors = _ax_neighbors_via_cross(HAG, nid) + # filter to only visual-layer nodes + vis_neighbors = [v for v in vis_neighbors + if HAG.nodes[v].get("layer") == "visual"] + if not vis_neighbors: + name = attrs.get("name") or "" + issues.append(Issue( + query_id = "Q1.1", + title = "AX node without visual counterpart", + wcag = ["1.3.1"], + severity = "moderate", + layer = "cross:ax-visual", + node_id = nid, + detail = ( + f"AX node role='{role}' name='{name}' is present in the " + f"accessibility tree but has no rendered bounding box. " + f"May be visually hidden or off-screen." + ), + extra = {"role": role, "name": name}, + )) + return issues + + +def q_visual_nodes_without_ax(HAG: nx.MultiDiGraph) -> list[Issue]: + """ + Δ2 – Visual nodes with no linked non-ignored AX node. + + These elements are visible to sighted users but invisible to screen readers. + High prominence makes this more severe (large visible element = large barrier). + + WCAG: 1.1.1 Non-text Content (A), 4.1.2 Name Role Value (A) + #NL: non capito. sembra elemento visivo collegato a elementi ax rilevanti, con ignore=false + """ + issues = [] + for nid, attrs in _nodes_by_layer(HAG, "visual"): + tag = attrs.get("tag", "") + prominence = attrs.get("prominence", 0) + + # Find all AX neighbors + ax_neighbors = _neighbors_by_relation(HAG, nid, "ax_to_visual") + # Also check via dom→ax chain + dom_neighbors = _neighbors_by_relation(HAG, nid, "dom_to_visual") + for dom_nid in dom_neighbors: + ax_neighbors += _dom_neighbors_via_cross(HAG, dom_nid) + + # Filter: keep only non-ignored AX nodes + visible_ax = [ + v for v in ax_neighbors + if HAG.nodes[v].get("layer") == "ax" + and not HAG.nodes[v].get("ignored")#take "ignored"=false, take usefull ax nodes + ] + + if not visible_ax: + severity = ( + "critical" if prominence > 0.10 else + "serious" if prominence > 0.03 else + "moderate" + ) + issues.append(Issue( + query_id = "Q1.2", + title = "Visual node without AX counterpart", + wcag = ["1.1.1", "4.1.2"], + severity = severity, + layer = "cross:visual-ax", + node_id = nid, + detail = ( + f"<{tag}> element is rendered (prominence={prominence:.5f}) " + f"but relevant AX node (ignored=false; has no corresponding non-ignored AX node.) " + f"Screen reader users cannot perceive this content." + ), + extra = {"tag": tag, "prominence": prominence, + "reading_rank": attrs.get("reading_rank")}, + )) + return issues + + +def q_dom_nodes_without_ax(HAG: nx.MultiDiGraph) -> list[Issue]: + """ + Δ1 – Interactive DOM nodes (a, button, input, select, textarea) + that have no AX node at all (not even an ignored one). + # NL da verificare comportamneto su modali e drop-down menu dove elemento DOM esiste ma non su accessibility tree (compare solo se elemento attivato) + + WCAG: 4.1.2 Name, Role, Value (A) + """ + INTERACTIVE_TAGS = {"a", "button", "input", "select", "textarea", + "details", "summary"} + issues = [] + for nid, attrs in _nodes_by_layer(HAG, "dom"): + tag = attrs.get("tag", "") + if tag not in INTERACTIVE_TAGS: + continue + ax_neighbors = _dom_neighbors_via_cross(HAG, nid) + if not ax_neighbors: + issues.append(Issue( + query_id = "Q1.3", + title = "Interactive DOM node with no AX node", + wcag = ["4.1.2"], + severity = "serious", + layer = "cross:dom-ax", + node_id = nid, + detail = ( + f"<{tag}> element (DOM node {nid}) is interactive but " + f"does not appear in the accessibility tree at all. " + f"It is completely opaque to assistive technologies." + ), + extra = { + "tag": tag, + "attr_id": attrs.get("attr_id"), + "aria_hidden": attrs.get("aria_hidden"), + }, + )) + return issues + + +def q_ignored_ax_with_visual(HAG: nx.MultiDiGraph) -> list[Issue]: + """ + Δ1 – AX nodes that are marked 'ignored' (hidden from SR) but still + have a corresponding visual node with significant prominence. + + These are visually present but programmatically hidden – a mismatch + that can hide meaningful content from screen reader users. + + WCAG: 1.3.1 Info and Relationships (A), 4.1.2 Name, Role, Value (A) + """ + issues = [] + for nid, attrs in _nodes_by_layer(HAG, "ax"): + if not attrs.get("ignored"): + continue + role = str(attrs.get("role") or "") + # Only flag roles that normally carry meaning + if role in ("none", "presentation", "InlineTextBox", + "ListMarker", "ignored"): + continue + vis_neighbors = [ + v for v in _ax_neighbors_via_cross(HAG, nid) + if HAG.nodes[v].get("layer") == "visual" + ] + for vis_nid in vis_neighbors: + prom = HAG.nodes[vis_nid].get("prominence", 0) + if prom > 0.02: # at least 2% of viewport + issues.append(Issue( + query_id = "Q1.4", + title = "Ignored AX node with prominent visual counterpart", + wcag = ["1.3.1", "4.1.2"], + severity = "serious", + layer = "cross:ax-visual", + node_id = nid, + detail = ( + f"AX node role='{role}' is marked ignored " + f"(reason: {attrs.get('ignored_reasons','unknown')}) " + f"but its visual counterpart occupies " + f"{prom*100:.1f}% of the viewport. " + f"Content may be visible but inaccessible." + ), + extra = {"role": role, "prominence": prom, + "vis_node": vis_nid, + "ignored_reasons": attrs.get("ignored_reasons")}, + )) + return issues + + +# ───────────────────────────────────────────────────────────────────────────── +# Δ2 queries – AX - Visual order gap +# "what SR reads" vs "what sighted users perceive first" +# ───────────────────────────────────────────────────────────────────────────── + + +def q_focus_order_vs_visual(HAG: nx.MultiDiGraph) -> list[Issue]: + """ + Δ2 – Detect focusable AX nodes whose visual reading_rank differs greatly + from their position in the AX child traversal order. + + Focusable elements with a wrong visual position violate 2.4.3 Focus Order. + + WCAG: 2.4.3 Focus Order (A) + #NL: corrispettivo check disallineamento ordine visivo e screen reader / tab + """ + FOCUSABLE_ROLES = { + "link", "button", "textbox", "combobox", "listbox", + "checkbox", "radio", "menuitem", "tab", "slider", + "spinbutton", "searchbox", "switch", + } + ax_order = _ax_reading_order(HAG) + ax_rank = {nid: i for i, (nid, _) in enumerate(ax_order)} + max_ax = max(ax_rank.values(), default=1) or 1 + + issues = [] + focusable = [ + (nid, attrs) for nid, attrs in ax_order + if str(attrs.get("role") or "").lower() in FOCUSABLE_ROLES + or str(attrs.get("aria_focusable") or "") == "True" + ] + + for ax_nid, ax_attrs in focusable: + vis_neighbors = [ + v for v in _ax_neighbors_via_cross(HAG, ax_nid) + if HAG.nodes[v].get("layer") == "visual" + ] + if not vis_neighbors: + continue + + best_vis = max(vis_neighbors, + key=lambda v: HAG.nodes[v].get("prominence", 0)) + vis_attrs = HAG.nodes[best_vis] + vis_r = vis_attrs.get("reading_rank", 0) + max_vis = max( + (HAG.nodes[v].get("reading_rank", 0) + for v in HAG.nodes() + if HAG.nodes[v].get("layer") == "visual"), + default=1, + ) or 1 + + ax_r_norm = ax_rank[ax_nid] / max_ax + vis_r_norm = vis_r / max_vis + delta = int(abs(ax_r_norm - vis_r_norm) * max_ax) + + if delta >= 8: + role = str(ax_attrs.get("role") or "") + name = ax_attrs.get("name") or "" + issues.append(Issue( + query_id = "Q2.2", + title = "Focus order diverges from visual order", + wcag = ["2.4.3"], + severity = "serious", + layer = "cross:ax-visual", + node_id = ax_nid, + detail = ( + f"Focusable element role='{role}' name='{name}' " + f"is at tab-order position #{ax_rank[ax_nid]+1} " + f"but visual rank #{vis_r}. " + f"Keyboard users tabbing through the page will jump " + f"unexpectedly (circa {delta} positions off)." + ), + extra = { + "role": role, "name": name, + "ax_rank": ax_rank[ax_nid], "visual_rank": vis_r, + "delta": delta, + }, + )) + return issues + +def q_reading_order_mismatch( + HAG: nx.MultiDiGraph, + prominence_threshold: float = 0.05, + rank_distance_threshold: int = 5, +) -> list[Issue]: + """ + Δ2 – Detect elements whose AX reading order differs substantially from + their visual reading order (by rank_distance_threshold positions or more), + but only for elements with significant visual prominence. + + High prominence + large order mismatch = likely confusing for SR users + navigating a page they cannot see. + + WCAG: 1.3.2 Meaningful Sequence (A), 2.4.3 Focus Order (A) + #NL: corrispettivo check disallineamento ordine visivo e screen reader / tab + """ + ax_order = _ax_reading_order(HAG) # [(nid, attrs), …] + vis_order = _visual_reading_order(HAG) # [(nid, attrs), …] + + # Build rank lookups + ax_rank: dict[str, int] = {nid: i for i, (nid, _) in enumerate(ax_order)} + vis_rank: dict[str, int] = {nid: d.get("reading_rank", 0) + for nid, d in vis_order} + + # Build a mapping: AX node → its visual counterpart's reading_rank + ax_to_vis_rank: dict[str, int] = {} + for ax_nid, ax_attrs in ax_order: + vis_neighbors = [ + v for v in _ax_neighbors_via_cross(HAG, ax_nid) + if HAG.nodes[v].get("layer") == "visual" + ] + if vis_neighbors: + # pick the most prominent visual node + best_vis = max( + vis_neighbors, + key=lambda v: HAG.nodes[v].get("prominence", 0), + ) + ax_to_vis_rank[ax_nid] = vis_rank.get(best_vis, 0) + + # Normalize visual ranks to the same 0..N scale as AX ranks + max_vis = max(ax_to_vis_rank.values(), default=1) or 1 + max_ax = max(ax_rank.values(), default=1) or 1 + issues = [] + + for ax_nid, ax_attrs in ax_order: + if ax_nid not in ax_to_vis_rank: + continue + vis_r_raw = ax_to_vis_rank[ax_nid] + # Normalise both to 0..1 then compare + ax_r_norm = ax_rank[ax_nid] / max_ax + vis_r_norm = vis_r_raw / max_vis + delta_norm = ax_r_norm - vis_r_norm # positive = AX later than visual + + # Convert to "number of AX positions" for readability + delta_positions = int(abs(delta_norm) * max_ax) + + if delta_positions < rank_distance_threshold: + continue + + # Only flag if the visual element is prominent + vis_neighbors = [ + v for v in _ax_neighbors_via_cross(HAG, ax_nid) + if HAG.nodes[v].get("layer") == "visual" + ] + max_prom = max( + (HAG.nodes[v].get("prominence", 0) for v in vis_neighbors), + default=0, + ) + if max_prom < prominence_threshold: + continue + + role = str(ax_attrs.get("role") or "") + name = ax_attrs.get("name") or "" + direction = "after" if delta_norm > 0 else "before" + + issues.append(Issue( + query_id = "Q2.1", + title = "Reading order mismatch (AX vs visual)", + wcag = ["1.3.2", "2.4.3"], + severity = "serious" if delta_positions > 15 else "moderate", + layer = "cross:ax-visual", + node_id = ax_nid, + detail = ( + f"AX node role='{role}' name='{name}' appears at Screen Reader position " + f"#{ax_rank[ax_nid]+1} but at visual rank " + f"#{vis_r_raw} (circa {delta_positions} positions {direction} " + f"expected). Visual prominence={max_prom:.3f}. " + f"Screen reader users encounter this element " + f"{'much later' if direction=='after' else 'much earlier'} " + f"than sighted users." + ), + extra = { + "role": role, "name": name, + "ax_rank": ax_rank[ax_nid], + "visual_rank": vis_r_raw, + "delta_positions": delta_positions, + "prominence": max_prom, + }, + )) + + return issues + + +def q_heading_hierarchy(HAG: nx.MultiDiGraph) -> list[Issue]: + """ + Δ0/Δ2 – Detect broken heading hierarchy in the AX tree: + • heading level skipped (e.g. h1 → h3, missing h2) + • multiple h1 elements on the same page + • heading with no name / empty accessible name + + WCAG: 1.3.1 Info and Relationships (A), + 2.4.6 Headings and Labels (AA) + """ + issues = [] + headings = [ + (nid, attrs) + for nid, attrs in _nodes_by_layer(HAG, "ax") + if str(attrs.get("role") or "").lower() in ("heading",) + and not attrs.get("ignored") + ] + + # Sort headings by AX reading order + ax_order_map = { + nid: i + for i, (nid, _) in enumerate(_ax_reading_order(HAG)) + } + headings.sort(key=lambda x: ax_order_map.get(x[0], 9999)) + + prev_level = 0 + h1_count = 0 + + for nid, attrs in headings: + level = int(attrs.get("aria_level") or attrs.get("pw_level") or 0) + name = attrs.get("name") or "" + + # Empty heading + if not name.strip(): + issues.append(Issue( + query_id = "Q0.1", + title = "Heading with empty accessible name", + wcag = ["1.3.1", "2.4.6"], + severity = "serious", + layer = "ax", + node_id = nid, + detail = ( + f"h{level} element has no accessible name. " + f"Screen reader users cannot understand the section topic." + ), + extra = {"level": level}, + )) + + # Multiple h1 + if level == 1: + h1_count += 1 + if h1_count > 1: + issues.append(Issue( + query_id = "Q0.2", + title = "Multiple h1 headings on the page", + wcag = ["1.3.1", "2.4.6"], + severity = "moderate", + layer = "ax", + node_id = nid, + detail = ( + f"This is the {h1_count}. h1 heading found (name='{name}'). " + f"Pages should generally have a single h1 as the main title." + ), + extra = {"level": level, "name": name, "h1_count": h1_count}, + )) + + # Level skipped + if level > 0 and prev_level > 0 and level > prev_level + 1: + issues.append(Issue( + query_id = "Q0.3", + title = "Heading level skipped", + wcag = ["1.3.1", "2.4.6"], + severity = "moderate", + layer = "ax", + node_id = nid, + detail = ( + f"Heading hierarchy jumps from h{prev_level} to " + f"h{level} (name='{name}'). " + f"Skipping levels breaks the outline structure that " + f"screen reader users rely on for navigation." + ), + extra = {"prev_level": prev_level, "level": level, "name": name}, + )) + + if level > 0: + prev_level = level + + return issues + + + + + +# ───────────────────────────────────────────────────────────────────────────── +# Δ0 queries – intra-layer DOM / AX issues +# ───────────────────────────────────────────────────────────────────────────── + +def q_images_missing_alt(HAG: nx.MultiDiGraph) -> list[Issue]: + """ + Δ0 – Images (, role=img) with no accessible name. + + Checks both the DOM layer (alt attribute) and the AX layer + (accessible name computed by the browser). + + WCAG: 1.1.1 Non-text Content (A) + """ + issues = [] + + # AX layer check: img/image role with empty name + for nid, attrs in _nodes_by_layer(HAG, "ax"): + if attrs.get("ignored"): + continue + role = str(attrs.get("role") or "").lower() + if role not in ("img", "image"): + continue + name = (attrs.get("name") or "").strip() + if not name: + # Check if it's intentionally decorative (aria-hidden on DOM side) + dom_neighbors = _neighbors_by_relation(HAG, nid, "dom_to_ax") + aria_hidden = any( + str(HAG.nodes[d].get("aria_hidden") or "") == "true" + for d in dom_neighbors + if HAG.nodes[d].get("layer") == "dom" + ) + if not aria_hidden: + # Get prominence from visual layer + vis_nodes = _ax_neighbors_via_cross(HAG, nid) + prom = max( + (HAG.nodes[v].get("prominence", 0) for v in vis_nodes + if HAG.nodes[v].get("layer") == "visual"), + default=0, + ) + issues.append(Issue( + query_id = "Q0.4", + title = "Image missing accessible name", + wcag = ["1.1.1"], + severity = "critical" if prom > 0.05 else "serious", + layer = "ax", + node_id = nid, + detail = ( + f"Image (role='{role}') has no accessible name " + f"and is not marked as decorative. " + f"Visual prominence={prom:.3f}. " + f"Screen reader will announce it as 'image' with no context." + ), + extra = {"role": role, "prominence": prom}, + )) + return issues + + +def q_form_inputs_without_label(HAG: nx.MultiDiGraph) -> list[Issue]: + """ + Δ0/Δ1 – Form inputs (,