"""AST guards for user-facing error safety and UI failure-detail rendering.""" from __future__ import annotations import ast from pathlib import Path PROJECT_ROOT = Path(__file__).resolve().parents[1] SOURCE_DIR = PROJECT_ROOT / "src" / "transcription" UI_DIR = SOURCE_DIR / "ui" _RAW_DETAIL_ATTRIBUTES = frozenset({"error_detail", "latest_error_detail"}) _SUSPICIOUS_FORMATTED_NAMES = frozenset({"path", "root", "dir", "exc", "err", "e"}) def _python_files(root: Path) -> list[Path]: return sorted(root.rglob("*.py")) def _parent_map(tree: ast.AST) -> dict[ast.AST, ast.AST]: parents: dict[ast.AST, ast.AST] = {} for node in ast.walk(tree): for child in ast.iter_child_nodes(node): parents[child] = node return parents def _is_wrapped_in_display_failure_detail(node: ast.AST, parents: dict[ast.AST, ast.AST]) -> bool: current = node while current in parents: current = parents[current] if not isinstance(current, ast.Call): continue func = current.func if isinstance(func, ast.Name) and func.id == "display_failure_detail": return True return False def _ui_raw_detail_reads() -> dict[str, list[int]]: violations: dict[str, list[int]] = {} for path in _python_files(UI_DIR): tree = ast.parse(path.read_text(encoding="utf-8")) parents = _parent_map(tree) found = sorted( node.lineno for node in ast.walk(tree) if isinstance(node, ast.Attribute) and node.attr in _RAW_DETAIL_ATTRIBUTES and not _is_wrapped_in_display_failure_detail(node, parents) ) if found: violations[str(path.relative_to(PROJECT_ROOT)).replace("\\", "/")] = found return violations def _class_bases_by_name() -> dict[str, set[str]]: bases: dict[str, set[str]] = {} for path in _python_files(SOURCE_DIR): tree = ast.parse(path.read_text(encoding="utf-8")) for node in tree.body: if not isinstance(node, ast.ClassDef): continue inherited = set() for base in node.bases: if isinstance(base, ast.Name): inherited.add(base.id) elif isinstance(base, ast.Attribute): inherited.add(base.attr) bases[node.name] = inherited return bases def _app_error_subclasses() -> set[str]: bases = _class_bases_by_name() subclasses = {"AppError"} changed = True while changed: changed = False for name, inherited in bases.items(): if name in subclasses: continue if inherited & subclasses: subclasses.add(name) changed = True subclasses.remove("AppError") return subclasses def _formatted_name_ids(node: ast.AST) -> set[str]: return {child.id for child in ast.walk(node) if isinstance(child, ast.Name)} def _is_safe_basename_projection(node: ast.AST) -> bool: return isinstance(node, ast.Attribute) and node.attr == "name" def _is_suspicious_name(name: str) -> bool: if name in _SUSPICIOUS_FORMATTED_NAMES: return True return any(name.endswith(f"_{suffix}") for suffix in _SUSPICIOUS_FORMATTED_NAMES - {"e"}) def _user_message_interpolation_violations() -> dict[str, list[str]]: violations: dict[str, list[str]] = {} error_types = _app_error_subclasses() for path in _python_files(SOURCE_DIR): tree = ast.parse(path.read_text(encoding="utf-8")) found: list[str] = [] for node in ast.walk(tree): if not isinstance(node, ast.Raise): continue if not isinstance(node.exc, ast.Call): continue func = node.exc.func if not isinstance(func, ast.Name) or func.id not in error_types: continue if not node.exc.args: continue message = node.exc.args[0] if not isinstance(message, ast.JoinedStr): continue formatted_names = { name for value in message.values if isinstance(value, ast.FormattedValue) if not _is_safe_basename_projection(value.value) for name in _formatted_name_ids(value.value) } suspicious = sorted(name for name in formatted_names if _is_suspicious_name(name)) if suspicious: found.append(f"L{node.lineno}: {', '.join(suspicious)}") if found: violations[str(path.relative_to(PROJECT_ROOT)).replace("\\", "/")] = found return violations def test_ui_modules_only_render_failure_detail_through_projection(): """HIGH-01: UI must sanitize persisted failure detail before rendering it.""" assert _ui_raw_detail_reads() == {} def test_user_facing_app_error_messages_do_not_interpolate_paths_or_exceptions(): """HIGH-02 / MED-07: keep paths and exception text out of AppError.message.""" assert _user_message_interpolation_violations() == {}