Implement Rec - Phase 1
Quality Gate / gate (push) Successful in 2m31s

This commit is contained in:
Jim Lancaster
2026-09-02 15:59:56 -05:00
parent 02dca888d2
commit d5e798825d
10 changed files with 258 additions and 17 deletions
+145
View File
@@ -0,0 +1,145 @@
"""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() == {}
+39
View File
@@ -3,6 +3,7 @@ import pytest
from transcription.errors import AppError
from transcription.errors import ErrorCategory
from transcription.ui.components.error_presenter import display_error_category
from transcription.ui.components.error_presenter import display_failure_detail
from transcription.ui.components.error_presenter import run_ui_action
@@ -17,6 +18,44 @@ def test_display_error_category_uses_canonical_taxonomy():
assert display_error_category(AppError("x", category=ErrorCategory.INFRA_PERSISTENT)) == "internal"
def test_display_failure_detail_returns_none_for_missing_detail():
assert display_failure_detail(None) is None
@pytest.mark.parametrize(
("raw_detail", "expected_basename"),
[
(
(
"[internal_unexpected_error] Unexpected error during worker.process_job. | "
"detail=PermissionError: [Errno 13] Permission denied: '/app/uploads/documents/abc/page-1.jpg' | "
"suggestion=Retry once. | error_id=deadbeef"
),
"page-1.jpg",
),
(
(
"[infrastructure_persistent_error] Prompt file not found: transcribe_document.md | "
"detail=FileNotFoundError: [Errno 2] No such file or directory: "
r"'C:\app\prompts\transcribe_document.md' | "
"suggestion=Verify PROMPT_DIR. | error_id=feedface"
),
"transcribe_document.md",
),
],
)
def test_display_failure_detail_strips_absolute_paths_from_detail_segment(raw_detail, expected_basename):
rendered = display_failure_detail(raw_detail)
assert rendered is not None
assert rendered.startswith("[")
assert "suggestion=" in rendered
assert "error_id=" in rendered
assert expected_basename in rendered
assert "/app/uploads/documents/abc/page-1.jpg" not in rendered
assert r"C:\app\prompts\transcribe_document.md" not in rendered
@pytest.mark.asyncio
async def test_run_ui_action_returns_success_value():
outcome = await run_ui_action(