Private
Public Access
0
0

feat(audit): add lazy-loading sentinel fallback heuristic (Phase 12)

Adds a new heuristic to scripts/audit_exception_handling.py:_try_compliant_pattern
(heuristic B, after heuristic A) that recognizes the canonical lazy-loading
sentinel fallback pattern:

  def _resolve(self):
   try:
    self._cached = getattr(mod, attr_name)
   except AttributeError:
    sub_mod_name = f'{module_name}.{attr_name}'
    try:
     self._cached = importlib.import_module(sub_mod_name)
    except (ImportError, ModuleNotFoundError):
     self._cached = _FiledialogStub()

The heuristic fires when:
  - The enclosing function is in LAZY_LOADER_METHOD_NAMES
    ({_resolve, _load, _get, _try_load}) — the canonical naming
    convention for proxy classes that defer a heavy import
  - The except body does NOT re-raise
  - The except set is in {AttributeError, ImportError, ModuleNotFoundError}
  - The except body assigns to a self.<attr> (directly or via nested try)

Sites matching this pattern are classified INTERNAL_COMPLIANT (not
UNCLEAR). The sentinel is a documented graceful-degradation marker
with an 'available: bool = False' flag (or similar) that the UI can
check to detect the stub and offer an alternative path. This is
analogous to the nil-sentinel dataclass (Pattern 1 in error_handling.md).

Per error_handling.md:625-690 (Re-Raise Patterns) and the lazy-loading
pattern guidance, this is NOT silent-sliming. Reclassifies the 2
UNCLEAR sites in src/gui_2.py at L65 and L69 (_LazyModule._resolve).

Pre-Phase 12 baseline: 2 UNCLEAR sites. Post-Phase 12: 0 UNCLEAR.
gui_2.py: V=0, S=0, ?=0, C=56 (was V=0, S=0, ?=2, C=54).

Phase 12 result_migration_gui_2_20260619.
This commit is contained in:
2026-06-20 02:17:19 -04:00
parent 4edd6a9583
commit f996aa1066
+77
View File
@@ -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.<attr>` (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.<attr> = <sentinel>()` 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.<attr>` 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: