diet-guard/scripts/check_patch_targets.py
Krzysztof kuhy Rudnicki c7538f691b Enforce the shared 250-line file cap
Replace this repo's stale file-length hook with the shared gate every other
repo will use. The old hook capped at 500 lines, called a repo-local
scripts/check_file_length.py, and filtered to types_or: [python, shell] --
so it never looked at the Flutter app in app/ or at any prose. Two caps that
disagree is worse than one, so the old one is deleted rather than kept.

The new hook checks only the files in the commit and exits 1; CI runs --all
as the backstop, since a fresh clone that never ran `pre-commit install` has
no hooks at all. The exemption list lives with the checker in
~/utils/file_length/config.py so the gate and the cross-repo survey cannot
disagree about what "too long" means.

Two things this repo has that ~/todo (the reference implementation) did not:

- A pre-commit CI job running --all-files, where $HOME/utils does not exist.
  The shared gate is checked out to .utils and reached via UTILS_ROOT, or the
  hook fails on a missing checker rather than on anything in this repo.

- Tests that isolate live user state with dotted-string patches
  (`patch("diet_guard._state.FOOD_LOG_FILE", ...)`). unittest.mock.patch fails
  OPEN: once a refactor moves a name, the old path still imports, the patch
  redirects nothing, the test still passes -- and the suite writes to the real
  ~/.local/share/diet_guard, which diet-guard-sync.timer publishes within 15
  minutes. check_patch_targets.py resolves all 30 patch targets statically so
  that failure is loud instead of silent. It is landed before any split for
  exactly that reason.

The gate is red at 52 files; the splits that follow drive it to zero.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MK3HCZrM75sRHL1gL8usve
2026-08-15 16:35:01 +02:00

102 lines
3.4 KiB
Python
Executable File

#!/usr/bin/env python3
"""Fail if a test patches a dotted path that no longer resolves.
``unittest.mock.patch("diet_guard._state.FOOD_LOG_FILE", ...)`` fails *open*.
Once a refactor moves ``FOOD_LOG_FILE`` to another module, the old path still
imports, the patch redirects nothing, and the test keeps passing -- while the
suite writes to the real ``~/.local/share/diet_guard``, which
``diet-guard-sync.timer`` then publishes to every other device.
That is why this gate exists rather than a note in CLAUDE.md: the failure it
guards against is silent and green, so only a static check catches it.
Usage:
python3 scripts/check_patch_targets.py
"""
from __future__ import annotations
import ast
import importlib
import pathlib
import sys
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Iterator
#: Only paths into this package are checked; a patch of `requests` or `tk` is
#: not ours to validate.
PACKAGE = "diet_guard"
TESTS_ROOT = pathlib.Path(PACKAGE) / "tests"
def patch_targets(root: pathlib.Path) -> Iterator[tuple[pathlib.Path, int, str]]:
"""Yield ``(file, lineno, dotted_target)`` for every literal patch target."""
for path in sorted(root.rglob("*.py")):
tree = ast.parse(path.read_text(), str(path))
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
name = getattr(node.func, "attr", None) or getattr(node.func, "id", None)
if name != "patch" or not node.args:
continue
first = node.args[0]
if (
isinstance(first, ast.Constant)
and isinstance(first.value, str)
and first.value.startswith(PACKAGE)
):
yield path, node.lineno, first.value
def resolves(dotted: str) -> bool:
"""Whether ``dotted`` names a real attribute reachable from a real module.
Tries the longest importable module prefix first, then walks the remaining
segments as attributes -- the same resolution order ``mock.patch`` uses.
"""
parts = dotted.split(".")
for split in range(len(parts), 0, -1):
try:
module = importlib.import_module(".".join(parts[:split]))
except ImportError:
continue
obj: object = module
for attr in parts[split:]:
if not hasattr(obj, attr):
return False
obj = getattr(obj, attr)
return True
return False
def main() -> int:
"""Report every unresolvable patch target; return 1 if any were found."""
if not TESTS_ROOT.is_dir():
sys.stderr.write(f"Error: {TESTS_ROOT} not found; run from the repo root.\n")
return 1
stale = [
(path, lineno, target)
for path, lineno, target in patch_targets(TESTS_ROOT)
if not resolves(target)
]
for path, lineno, target in stale:
sys.stderr.write(f"{path}:{lineno}: patch target does not resolve: {target}\n")
if stale:
plural = "" if len(stale) == 1 else "s"
sys.stderr.write(
f"\n{len(stale)} stale patch target{plural}. A patch that does not "
f"resolve silently redirects nothing -- the test passes while writing "
f"to real user state. Update the path to where the name now lives.\n"
)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())