Files
hermes-agent/tests/scripts/test_check_public_surface.py
Teknium 09f2a7c036 fix(ci): check_public_surface refuses unresolvable refs and does not flag methods extracted into an in-module base class
Independent review: a nonexistent base ref under --strict reported zero
drops and exited 0 (a mis-fetched CI job would look clean); the script now
verifies both refs and the merge-base and exits 2 otherwise. And a method
moved from a class into a mixin/base defined in the same module that the
class still derives from was reported as removed although the attribute
still resolves; public_methods now collects the methods REACHABLE on each
class through its in-module bases. Replay of #102117 at open: 1,703 names /
341 modules and 126 test defs / 52 files unchanged; methods 1,000 -> 951
(the 49 were in-module mixin extractions, i.e. the false positives).

Test: unresolvable ref -> exit 2 in both modes; a method extracted into an
in-module base is not reported.
2026-09-06 13:27:48 -07:00

92 lines
4.2 KiB
Python

"""scripts/ci/check_public_surface.py detects silently dropped public names, methods and test defs.
Replayed against the Sep 2026 refactor PR at open it reports 1,703 public names dropped in 341 modules
(the figure reviewers had to find ~30 of by hand); here the contract is pinned on a throwaway repo.
"""
import importlib.util
import subprocess
import textwrap
from pathlib import Path
SCRIPT = Path(__file__).resolve().parents[2] / "scripts" / "ci" / "check_public_surface.py"
def _load():
spec = importlib.util.spec_from_file_location("check_public_surface", SCRIPT)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def _git(repo, *args):
subprocess.run(["git", "-C", str(repo), *args], check=True, capture_output=True,
env={"GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t", "GIT_COMMITTER_NAME": "t",
"GIT_COMMITTER_EMAIL": "t@t", "PATH": "/usr/bin:/bin:/usr/local/bin"})
def test_dropped_public_names_methods_and_test_defs_are_reported_and_moves_are_not(tmp_path, monkeypatch):
repo = tmp_path
(repo / "agent").mkdir(); (repo / "tests").mkdir(); (repo / "docs").mkdir()
(repo / "agent" / "mod.py").write_text(textwrap.dedent('''
import os
from json import loads as parse
LIMIT = 3
def keep(): ...
def gone(): ...
def _private(): ...
class K:
def pub(self): ...
def __len__(self): return 0
def _hidden(self): ...
class Big:
def stays(self): ...
def extracted(self): ...
'''), encoding="utf-8")
(repo / "agent" / "moved.py").write_text("def relocated(): ...\n", encoding="utf-8")
(repo / "tests" / "test_x.py").write_text("def test_a(): ...\ndef test_b(): ...\nasync def test_c(): ...\n", encoding="utf-8")
(repo / "docs" / "notes.py").write_text("def doc_helper(): ...\n", encoding="utf-8")
_git(repo, "init", "-q", "-b", "main"); _git(repo, "add", "."); _git(repo, "commit", "-qm", "base")
# HEAD: drop `gone`, `parse`, `K.pub`, `K.__len__`; drop `_private`/`K._hidden` (private: fine);
# move `relocated` into mod with a re-export left behind (fine); lose one test def; change a
# non-source module (ignored).
(repo / "agent" / "mod.py").write_text(textwrap.dedent('''
import os
from agent.moved import relocated # re-export
LIMIT = 3
def keep(): ...
class K: ...
class _BigMixin:
def extracted(self): ... # moved into an in-module base: still reachable on Big
class Big(_BigMixin):
def stays(self): ...
'''), encoding="utf-8")
(repo / "agent" / "moved.py").write_text("def relocated(): ...\ndef relocated2(): ...\n", encoding="utf-8")
(repo / "tests" / "test_x.py").write_text("def test_a(): ...\nasync def test_c(): ...\n", encoding="utf-8")
(repo / "docs" / "notes.py").write_text("def other(): ...\n", encoding="utf-8")
_git(repo, "add", "."); _git(repo, "commit", "-qm", "head")
monkeypatch.chdir(repo)
mod = _load()
report = mod.diff_surface("HEAD~1", "HEAD")
assert report["dropped_names"] == {"agent/mod.py": ["gone", "parse"]}
assert report["dropped_methods"] == {"agent/mod.py": ["K.__len__", "K.pub"]}
assert report["test_drops"] == {"tests/test_x.py": (3, 2)}
assert mod.main(["--base", "HEAD~1", "--head", "HEAD"]) == 0 # advisory
assert mod.main(["--base", "HEAD~1", "--head", "HEAD", "--strict"]) == 1
assert mod.main(["--base", "HEAD", "--head", "HEAD", "--strict"]) == 0 # nothing dropped
def test_unresolvable_refs_are_an_error_not_a_clean_report(tmp_path, monkeypatch):
"""Independent-review witness: a nonexistent base ref under --strict reported zero drops and exited 0,
which would make a mis-fetched CI job look clean."""
repo = tmp_path
(repo / "a.py").write_text("x = 1\n", encoding="utf-8")
_git(repo, "init", "-q", "-b", "main"); _git(repo, "add", "."); _git(repo, "commit", "-qm", "base")
monkeypatch.chdir(repo)
mod = _load()
assert mod.main(["--base", "origin/does-not-exist", "--head", "HEAD", "--strict"]) == 2
assert mod.main(["--base", "origin/does-not-exist", "--head", "HEAD"]) == 2