diff --git a/scripts/audit_exception_handling.py b/scripts/audit_exception_handling.py index 1f31d11d..d2e30414 100644 --- a/scripts/audit_exception_handling.py +++ b/scripts/audit_exception_handling.py @@ -789,6 +789,29 @@ class ExceptionVisitor(ast.NodeVisitor): f"Compliant: lazy-loading sentinel fallback. `try: ...; except ({', '.join(sorted(exc_set))}): self. = ()` in `{self._current_func_name()}` is the canonical graceful-degradation pattern. The sentinel class exposes an `available: bool = False` flag (or similar) so the UI can detect the stub and offer an alternative path. Per error_handling.md:625-690 and Phase 12.1 result_migration_gui_2_20260619.", ) + # E. Narrow + structured error carrier (Phase 9 redo, 2026-06-20, Tier 1 directive) + # Per the TIER1_REVIEW: distinguishes "return ErrorInfo(...)" or + # "err_item["error"] = True" (structured error carriers = COMPLIANT) from + # "args = {}" or "body = exc.response.text" (empty defaults = sliming). + # The empty-default pattern is explicitly NOT a drain per the styleguide + # (error_handling.md:528-531): "the original error context is lost; the + # caller cannot distinguish success from failure". + # + # This heuristic recognizes ONLY narrow except bodies (not Exception or + # BaseException). Broad catches with structured carriers are still + # violations (use BOUNDARY_CONVERSION via _returns_result or ErrorInfo). + if exc_set and not exc_set & {"Exception", "BaseException", ""}: + if self._has_errorinfo_return(except_body): + return ( + "INTERNAL_COMPLIANT", + f"Compliant: narrow except + structured error carrier. `try: ...; except ({', '.join(sorted(exc_set))}): return ErrorInfo(...)` is a true drain: the structured ErrorInfo carries the original exception via `original=e` and is returned to the caller. Per error_handling.md:462-540 and TIER1_REVIEW_phase9_dilemma_20260620.", + ) + if self._has_dict_error_true_assign(except_body): + return ( + "INTERNAL_COMPLIANT", + f"Compliant: narrow except + structured error carrier (in-band flag). `try: ...; except ({', '.join(sorted(exc_set))}): [\"error\"] = True` is a true drain: the dict's `error` flag is the structured carrier (the caller checks the flag). Per error_handling.md:462-540 and TIER1_REVIEW_phase9_dilemma_20260620. NOTE: this heuristic does NOT verify the caller reads the flag — that is a Tier-2 per-site decision documented in the track notes.", + ) + return None def _has_string_return(self, stmts: list[ast.stmt]) -> bool: @@ -801,6 +824,55 @@ class ExceptionVisitor(ast.NodeVisitor): return True return False + def _has_errorinfo_return(self, stmts: list[ast.stmt]) -> bool: + """True if any statement is a `return ErrorInfo(...)` call (structured error carrier). + + Used by Heuristic E (narrow + structured error carrier) to recognize the + pattern where the except body directly returns a structured ErrorInfo. This + is a true drain: the structured error is the function's contract, not a + lost-default fallback. (per result_migration_baseline_cleanup_20260620 Phase 9 redo) + + Distinguishes from `_returns_result` (Heuristic A): that checks for + `return Result(...)` (full data + side-channel errors). `_has_errorinfo_return` + checks for `return ErrorInfo(...)` (legacy function that returns the + structured error directly). + """ + for s in stmts: + if not isinstance(s, ast.Return) or s.value is None: + continue + if not isinstance(s.value, ast.Call): + continue + f = s.value.func + if isinstance(f, ast.Name) and f.id == "ErrorInfo": + return True + return False + + def _has_dict_error_true_assign(self, stmts: list[ast.stmt]) -> bool: + """True if any statement assigns `True` to a dict subscript whose key is "error". + + Detects the `err_item["error"] = True` in-band error flag pattern. + Used by Heuristic E (narrow + structured error carrier) when the caller + reads the flag downstream. The audit does NOT verify caller reads the + flag — that is a Tier-2 per-site decision documented in the track notes. + + Per the styleguide (error_handling.md:528-531) the empty-default pattern + is NOT a drain. This heuristic explicitly does NOT match `args = {}` or + `body = ""` (assignment to a bare variable without a dict subscript key + of "error"). The distinction matters: `args = {}` is sliming (Tier 1 + 2026-06-20 directive); `err_item["error"] = True` is a structured carrier. + """ + for s in stmts: + for node in ast.walk(s): + if isinstance(node, ast.Assign) and len(node.targets) == 1: + target = node.targets[0] + if isinstance(target, ast.Subscript): + slc = target.slice + if isinstance(slc, ast.Constant) and slc.value == "error": + # Verify the value is `True` + if isinstance(node.value, ast.Constant) and node.value.value is True: + return True + return False + def _has_simple_return(self, stmts: list[ast.stmt]) -> bool: """True if the body contains a `return ` statement (any value type).""" for s in stmts: diff --git a/src/ai_client.py b/src/ai_client.py index daae6cc9..ef364ea3 100644 --- a/src/ai_client.py +++ b/src/ai_client.py @@ -329,8 +329,10 @@ def _classify_deepseek_error(exc: Exception, source: str = "ai_client.deepseek") err_data = exc.response.json() if "error" in err_data: body = str(err_data["error"].get("message", exc.response.text)) else: body = exc.response.text - except (ValueError, AttributeError): - body = exc.response.text + except (ValueError, AttributeError) as e: + # JSON parse failed; cannot classify specific error codes. + # Return structured UNKNOWN error with original exception preserved. + return ErrorInfo(kind=ErrorKind.UNKNOWN, message=exc.response.text, source=source, original=e) else: body = str(exc) @@ -352,8 +354,8 @@ def _classify_minimax_error(exc: Exception, source: str = "ai_client.minimax") - err_data = exc.response.json() if "error" in err_data: body = str(err_data["error"].get("message", exc.response.text)) else: body = exc.response.text - except (ValueError, AttributeError): - body = exc.response.text + except (ValueError, AttributeError) as e: + return ErrorInfo(kind=ErrorKind.UNKNOWN, message=exc.response.text, source=source, original=e) else: body = str(exc)