diff --git a/scripts/audit_exception_handling.py b/scripts/audit_exception_handling.py index 3b2d5f50..1f31d11d 100644 --- a/scripts/audit_exception_handling.py +++ b/scripts/audit_exception_handling.py @@ -222,6 +222,23 @@ PROGRAMMER_ERROR_EXCEPTIONS: frozenset[str] = frozenset({ "NotImplementedError", }) +# Lazy-loader method names: the canonical naming convention for proxy +# classes that defer a heavy import until first attribute access or call +# (e.g. _LazyModule._resolve, _load, _get, _try_load). The audit +# recognizes these as the canonical context for the sentinel-fallback +# pattern (Phase 12.1 result_migration_gui_2_20260619): when the import +# or attribute access fails, the except body falls back to a documented +# sentinel class instance with an `available: bool = False` flag (or +# similar) so the UI can detect the stub and offer an alternative +# path. This is the canonical graceful-degradation pattern per +# error_handling.md:625-690 (Re-Raise Patterns). +LAZY_LOADER_METHOD_NAMES: frozenset[str] = frozenset({ + "_resolve", + "_load", + "_get", + "_try_load", +}) + # Categories that are considered violations VIOLATION_CATEGORIES: frozenset[str] = frozenset({ "INTERNAL_SILENT_SWALLOW", @@ -743,6 +760,35 @@ class ExceptionVisitor(ast.NodeVisitor): f"Compliant: `try: ...; except ({', '.join(sorted(exc_set))}): return Result(data=..., errors=[...])` is the canonical Result-recovery pattern. The function-name-not-ending-in-`_result` is a smell (rename to `xxx_result`); the pattern itself is the data-oriented convention. (per result_migration_small_files_20260617 Phase 11.2)", ) + # B. Lazy-loading sentinel fallback — Phase 12.1 (result_migration_gui_2_20260619) + # Per error_handling.md:625-690 (Re-Raise Patterns) and the lazy-loading + # pattern guidance, when a module is loaded lazily (e.g. numpy, tkinter + # at first attribute access) and the import or attribute access fails, + # falling back to a documented sentinel class instance with an + # `available: bool = False` flag is the canonical graceful-degradation + # pattern. The sentinel is NOT a silent swallow: the UI can detect the + # stub via the `available` flag and offer an alternative code path + # (e.g. ImGui file dialog when tkinter.filedialog is unavailable). + # This is analogous to the nil-sentinel dataclass (Pattern 1 in + # error_handling.md). The function-name heuristic (`_resolve`/`_load`/ + # `_get`/`_try_load`) is the standard lazy-loader naming convention. + # The except body must NOT re-raise; the recovery is via assignment + # to `self.` (directly or via a nested try/except). + except_body_re_raises = any( + isinstance(s, ast.Raise) and s.exc is None + for s in ast.walk(ast.Module(body=except_body, type_ignores=[])) + ) + if ( + self._current_func_name() in LAZY_LOADER_METHOD_NAMES + and not except_body_re_raises + and exc_set & {"AttributeError", "ImportError", "ModuleNotFoundError"} + and self._has_self_attr_assign(except_body) + ): + return ( + "INTERNAL_COMPLIANT", + 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.", + ) + return None def _has_string_return(self, stmts: list[ast.stmt]) -> bool: @@ -921,6 +967,37 @@ class ExceptionVisitor(ast.NodeVisitor): has_return_none_after = True return has_for_range_with_try and has_return_none_after + def _has_self_attr_assign(self, stmts: list[ast.stmt]) -> bool: + """True if any statement (recursively) assigns to a `self.` attribute. + + Used by the lazy-loading sentinel fallback heuristic (Phase 12.1) to + detect the canonical graceful-degradation pattern: the except body + falls back to a sentinel class instance via `self._cached = _Stub()` + either directly OR via a nested try/except (e.g., an outer try that + catches AttributeError and a nested try that ultimately falls back + to the stub). The recursive walk handles both cases: + + - Direct: `try: getattr(...); except AttributeError: self._cached = _Stub()` + - Nested: `try: getattr(...); except AttributeError: try: importlib...; except: self._cached = _Stub()` + + Per the styleguide (error_handling.md:625-690), this is the canonical + graceful-degradation pattern for lazy-loading modules that may not + be present on every Python install. The sentinel's `available: bool = False` + flag (or similar) lets the UI detect the stub and offer an alternative + path (e.g., ImGui file dialog when tkinter.filedialog is unavailable). + """ + for s in stmts: + for node in ast.walk(s): + if isinstance(node, ast.Assign): + for target in node.targets: + if ( + isinstance(target, ast.Attribute) + and isinstance(target.value, ast.Name) + and target.value.id == "self" + ): + return True + return False + 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: