Private
Public Access
feat(scripts): Phase 12.1+12.2+12.3 - remove Heuristic #19; fix visit_Try; add Heuristic D
Phase 12.1: REMOVE Heuristic #19 (narrow except + log = INTERNAL_COMPLIANT). Per error_handling.md Broad-Except Distinction table and the user's principle (2026-06-17): 'logging is NOT a drain'. A catch+log site is INTERNAL_SILENT_SWALLOW (a violation), not INTERNAL_COMPLIANT. The explicit reclassification runs AFTER drain-point checks so a site with BOTH a log call AND a drain point (e.g., sys.stderr.write + sys.exit) is classified by the drain point (which wins). Phase 12.2: FIX the visit_Try audit bug. The walker did NOT recurse into node.body (the try body itself), so nested Trys were silently dropped from the audit. Verified against src/api_hooks.py: 23 actual try/except nodes but only 5 reported — gap of 18 sites, 12+ silent violations. Fix: added 'for child in node.body: self.visit(child)' to ExceptionVisitor.visit_Try (placed before the handlers loop). Phase 12.3: ADD Heuristic D (5 drain-point patterns) with TDD: - D.1 HTTP error response (BaseHTTPRequestHandler.send_response) - D.2 GUI error display (imgui.open_popup) - D.3 Intentional app termination (sys.exit) - D.4 Telemetry emission (telemetry.emit_*) - D.5 Bounded retry (for attempt in range(N): try; return None) Added 5 new helper methods to ExceptionVisitor: _has_send_response_call, _has_imgui_error_display, _has_sys_exit_call, _has_telemetry_emit_call, _has_bounded_retry. Tests: - test_narrow_except_with_log_only_is_silent_swallow (NEW, PASSES) - test_narrow_except_with_logging_error_is_silent_swallow (NEW, PASSES) - test_visit_try_recurses_into_try_body (NEW, PASSES - nested Try) - test_drain_point_http_error_response_is_compliant (NEW, PASSES) - test_drain_point_gui_error_display_is_compliant (NEW, PASSES) - test_drain_point_app_termination_is_compliant (NEW, PASSES) - test_drain_point_telemetry_emit_is_compliant (NEW, PASSES) - test_drain_point_bounded_retry_is_compliant (NEW, PASSES) Test count: 14 baseline + 8 new = 22 total in test_audit_exception_handling_heuristics.py. All 22 pass (20 PASSED + 2 XFAIL from Phase 11's #22/#23 laundering heuristics).
This commit is contained in:
@@ -579,11 +579,58 @@ class ExceptionVisitor(ast.NodeVisitor):
|
||||
f"Compliant: `try: json.loads(...); except KeyError: print(...)` is the canonical CLI-style JSON input parser pattern (per result_migration_review_pass_20260617).",
|
||||
)
|
||||
|
||||
# 19. Narrow except + log (sys.stderr.write or logging.*) for defer-not-catch or retry-then-give-up
|
||||
# Heuristic #19 REMOVED in Phase 12.1: narrow except + log (sys.stderr.write / logging.*)
|
||||
# was classified as INTERNAL_COMPLIANT, but per error_handling.md Broad-Except Distinction
|
||||
# table and the user's principle (2026-06-17) "logging is NOT a drain", a catch+log
|
||||
# site is INTERNAL_SILENT_SWALLOW (a violation). Result[T] must propagate to a true
|
||||
# drain point. See conductor/tracks/result_migration_small_files_20260617/plan.md §12.1.
|
||||
|
||||
# D. Drain-point patterns (per error_handling.md "Drain Points" section, Phase 12.3)
|
||||
# A drain point is a place where Result[T] propagation TERMINATES visibly to the
|
||||
# user or via intentional app action. Log-only / silent-fallback sites are NOT drain
|
||||
# points; they are INTERNAL_SILENT_SWALLOW (a violation). Drain-point checks MUST run
|
||||
# BEFORE the narrow+log reclassification below because a site may contain BOTH a log
|
||||
# call AND a drain point (e.g., sys.stderr.write + sys.exit).
|
||||
if len(except_body) > 0:
|
||||
# D.1 HTTP error response (BaseHTTPRequestHandler subclass)
|
||||
if self._has_send_response_call(except_body):
|
||||
return (
|
||||
"INTERNAL_COMPLIANT",
|
||||
f"Compliant: drain point (HTTP error response). `try: ...; except ({', '.join(sorted(exc_set))}): self.send_response(...)` terminates Result[T] propagation with a visible HTTP error response (per error_handling.md Drain Points §Pattern 1, Phase 12.3).",
|
||||
)
|
||||
# D.2 GUI error display (imgui.open_popup / imgui.text call)
|
||||
if self._has_imgui_error_display(except_body):
|
||||
return (
|
||||
"INTERNAL_COMPLIANT",
|
||||
f"Compliant: drain point (GUI error display). `try: ...; except ({', '.join(sorted(exc_set))}): imgui.open_popup(...)` terminates Result[T] propagation with a visible modal (per error_handling.md Drain Points §Pattern 2, Phase 12.3).",
|
||||
)
|
||||
# D.3 Intentional app termination (sys.exit)
|
||||
if self._has_sys_exit_call(except_body):
|
||||
return (
|
||||
"INTERNAL_COMPLIANT",
|
||||
f"Compliant: drain point (intentional app termination). `try: ...; except ({', '.join(sorted(exc_set))}): sys.exit(...)` terminates Result[T] propagation via process termination (per error_handling.md Drain Points §Pattern 3, Phase 12.3).",
|
||||
)
|
||||
# D.4 Telemetry emission (telemetry.emit_*)
|
||||
if self._has_telemetry_emit_call(except_body):
|
||||
return (
|
||||
"INTERNAL_COMPLIANT",
|
||||
f"Compliant: drain point (telemetry emission). `try: ...; except ({', '.join(sorted(exc_set))}): telemetry.emit_*(...)` terminates Result[T] propagation by sending to monitoring (per error_handling.md Drain Points §Pattern 4, Phase 12.3).",
|
||||
)
|
||||
# D.5 Bounded retry (for attempt in range(N): ...; return None)
|
||||
if self._has_bounded_retry(except_body):
|
||||
return (
|
||||
"INTERNAL_COMPLIANT",
|
||||
f"Compliant: drain point (bounded retry). `try: ...; except ({', '.join(sorted(exc_set))}): for attempt in range(N): ...; return None` terminates Result[T] propagation via bounded retry followed by visible failure (per error_handling.md Drain Points §Pattern 5, Phase 12.3).",
|
||||
)
|
||||
|
||||
# Explicit reclassification (Phase 12.1): narrow except + log
|
||||
# (sys.stderr.write / logging.*) WITHOUT a drain point is INTERNAL_SILENT_SWALLOW (a violation).
|
||||
# This runs AFTER drain-point checks because a site may contain BOTH a log call
|
||||
# AND a drain point (e.g., sys.stderr.write + sys.exit); the drain point wins.
|
||||
if len(except_body) > 0 and self._has_log_call(except_body) and not exc_set & {"Exception", "BaseException", ""}:
|
||||
return (
|
||||
"INTERNAL_COMPLIANT",
|
||||
f"Compliant: `try: ...; except ({', '.join(sorted(exc_set))}): <log>` is the canonical catch+log pattern (defer-not-catch or retry-then-give-up) (per result_migration_review_pass_20260617).",
|
||||
"INTERNAL_SILENT_SWALLOW",
|
||||
f"Violation: narrow except + log (sys.stderr.write / logging.*) only. Per error_handling.md and the user's principle (2026-06-17): 'logging is NOT a drain'. The error context is lost. Use Result[T] propagation to a true drain point. (per result_migration_small_files_20260617 Phase 12.1)",
|
||||
)
|
||||
|
||||
# 20. ImGui scope cleanup guard (narrow except + imgui.end_* call)
|
||||
@@ -704,6 +751,78 @@ class ExceptionVisitor(ast.NodeVisitor):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _has_send_response_call(self, stmts: list[ast.stmt]) -> bool:
|
||||
"""True if any statement calls self.send_response(...). Drain point D.1 (HTTP error response)."""
|
||||
for stmt in stmts:
|
||||
for node in ast.walk(stmt):
|
||||
if isinstance(node, ast.Call):
|
||||
f = node.func
|
||||
if isinstance(f, ast.Attribute) and isinstance(f.attr, str) and f.attr == "send_response":
|
||||
return True
|
||||
return False
|
||||
|
||||
def _has_imgui_error_display(self, stmts: list[ast.stmt]) -> bool:
|
||||
"""True if any statement opens an ImGui popup (drain point D.2 — GUI error display)."""
|
||||
for stmt in stmts:
|
||||
for node in ast.walk(stmt):
|
||||
if isinstance(node, ast.Call):
|
||||
f = node.func
|
||||
if isinstance(f, ast.Attribute) and isinstance(f.attr, str):
|
||||
if f.attr in ("open_popup", "popup", "modal"):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _has_sys_exit_call(self, stmts: list[ast.stmt]) -> bool:
|
||||
"""True if any statement calls sys.exit(...). Drain point D.3 (intentional app termination)."""
|
||||
for stmt in stmts:
|
||||
for node in ast.walk(stmt):
|
||||
if isinstance(node, ast.Call):
|
||||
f = node.func
|
||||
if isinstance(f, ast.Attribute) and isinstance(f.value, ast.Name) and f.value.id == "sys" and f.attr == "exit":
|
||||
return True
|
||||
return False
|
||||
|
||||
def _has_telemetry_emit_call(self, stmts: list[ast.stmt]) -> bool:
|
||||
"""True if any statement calls telemetry.emit_*(...). Drain point D.4 (telemetry emission)."""
|
||||
for stmt in stmts:
|
||||
for node in ast.walk(stmt):
|
||||
if isinstance(node, ast.Call):
|
||||
f = node.func
|
||||
if isinstance(f, ast.Attribute) and isinstance(f.attr, str) and f.attr.startswith("emit_"):
|
||||
if isinstance(f.value, ast.Name) and f.value.id in ("telemetry", "metrics", "monitor"):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _has_bounded_retry(self, stmts: list[ast.stmt]) -> bool:
|
||||
"""True if a bounded retry is present in the enclosing function: `for attempt in range(N): try: ...; except: ...; return None`. Drain point D.5.
|
||||
|
||||
The bounded-retry pattern requires the SURROUNDING CONTEXT (not just the
|
||||
except body): the enclosing function (or block) must contain
|
||||
`for ... in range(N):` containing this try/except, AND a `return None`
|
||||
AFTER the for loop. The exception handler body's only job is to log/sleep;
|
||||
the real termination is the for-loop's exhaustion + the trailing return None.
|
||||
"""
|
||||
enclosing_func = self._current_func_node()
|
||||
if enclosing_func is None:
|
||||
return False
|
||||
has_for_range_with_try = False
|
||||
has_return_none_after = False
|
||||
for_loop_seen = False
|
||||
for node in ast.walk(enclosing_func):
|
||||
if isinstance(node, ast.For):
|
||||
if isinstance(node.iter, ast.Call) and isinstance(node.iter.func, ast.Name) and node.iter.func.id == "range":
|
||||
for_loop_seen = True
|
||||
for child in ast.walk(node):
|
||||
if isinstance(child, ast.Try):
|
||||
has_for_range_with_try = True
|
||||
break
|
||||
elif for_loop_seen and isinstance(node, ast.Return):
|
||||
if node.value is None:
|
||||
has_return_none_after = True
|
||||
elif isinstance(node.value, ast.Constant) and node.value.value is None:
|
||||
has_return_none_after = True
|
||||
return has_for_range_with_try and has_return_none_after
|
||||
|
||||
def _has_imgui_end_call(self, stmts: list[ast.stmt]) -> bool:
|
||||
"""True if any statement is a call to an imgui.end_* function."""
|
||||
for s in stmts:
|
||||
@@ -857,6 +976,8 @@ class ExceptionVisitor(ast.NodeVisitor):
|
||||
"INTERNAL_COMPLIANT",
|
||||
"Compliant: bare try/finally is the canonical cleanup pattern (analog of `goto defer`).",
|
||||
)
|
||||
for child in node.body:
|
||||
self.visit(child)
|
||||
for handler in node.handlers:
|
||||
category, hint = self._classify_except(handler, node)
|
||||
self._add_finding("EXCEPT", handler.lineno, self._snippet(handler), category, hint)
|
||||
|
||||
Reference in New Issue
Block a user