"""Audit legacy wrapper patterns in src/. A legacy wrapper is `def _x(...):` that delegates to `__result(...).data`, dropping the .ok check and error context. This is a false drain: per error_handling.md the wrapper defeats the entire Result[T] migration. Patterns scanned: P1 `def _x(...):` whose body is `return _x_result(...).data` (the primary false-drain; .data extraction drops .ok + .errors) P2 `def _x(...):` whose body checks `.ok` but does `pass` or only logs (softer false drain) P3 `def _x(...):` whose body is `return _x_result(...)` (returns the Result; caller doesn't get the ok check at the wrapper) """ import ast import sys from pathlib import Path def is_legacy_wrapper(func: ast.FunctionDef) -> tuple[bool, str]: """Return (is_legacy_wrapper, pattern_name) for a function. A legacy wrapper is `def _x(...):` (NOT ending in `_result`) whose body delegates to its sibling `_x_result(...)` helper. The wrapper defeats the Result[T] migration by unwrapping .data (P1) or returning the Result unchanged (P3, extra layer of indirection but propagates errors). Functions whose own name ends in `_result` are NOT wrappers — they ARE the Result-returning helpers. They may legitimately call OTHER helpers (also ending in `_result`), but that's a call graph not a wrapper pattern. """ name = func.name if not name.startswith("_"): return False, "" if name.endswith("_result"): return False, "" if "return " not in ast.unparse(func): return False, "" sibling = name + "_result" body_str = ast.unparse(func) if sibling + "(" not in body_str: return False, "" if ".data" in body_str: return True, "P1_drop_errors_via_dot_data" if ".ok" in body_str and "pass" in body_str: return True, "P2_pass_in_except_block" return True, "P3_returns_result_unchanged" def find_callers(func_name: str, src_dir: str = "src") -> list[tuple[str, int]]: """Find all in-site callers of `func_name` in src/.""" import re callers = [] pattern = re.compile(rf"\b{re.escape(func_name)}\(") for py_file in Path(src_dir).glob("*.py"): try: text = py_file.read_text(encoding="utf-8") except (OSError, UnicodeDecodeError): continue for m in pattern.finditer(text): line_no = text[:m.start()].count("\n") + 1 callers.append((str(py_file), line_no)) return callers def audit_directory(src_dir: str = "src") -> list[dict]: findings = [] for py_file in Path(src_dir).glob("*.py"): try: source = py_file.read_text(encoding="utf-8") except (OSError, UnicodeDecodeError): continue try: tree = ast.parse(source) except SyntaxError as e: continue for node in ast.walk(tree): if not isinstance(node, ast.FunctionDef): continue is_wrapper, pattern = is_legacy_wrapper(node) if not is_wrapper: continue findings.append({ "file": str(py_file), "line": node.lineno, "name": node.name, "pattern": pattern, }) return findings def main(): findings = audit_directory("src") print(f"Found {len(findings)} legacy wrappers in src/:") print() for f in findings: print(f" {f['file']}:{f['line']:>5} {f['name']:<40} [{f['pattern']}]") print() print("Caller scan:") for f in findings: callers = find_callers(f["name"]) print(f" {f['name']} ({len(callers)} in-site callers):") for cfile, cline in callers: print(f" {cfile}:{cline}") sys.exit(0) if __name__ == "__main__": main()