Files
hermes-agent/tests/scripts/test_code_health_identity.py
Teknium 6cc7d4a570 fix(code-health): TS recursive rename ignores nested-scope bindings
bodyWithoutSelf treated a declaration of the function's name at any depth as a rebind,
so a renamed recursive function containing an unchanged `.map((legacy) => legacy)` kept
its old self-call text, no longer matched its base body, and lost its cap ("CC 22 >
target 20 (new code)"). Only the function's own scope (parameters, a named function
expression's name, declarations outside nested functions and classes) now stops the
normalization, and nested functions that bind the name keep their own reads, as the
Python measurer already does.
2026-10-04 22:35:16 -07:00

269 lines
14 KiB
Python

"""Unit identity in the code-health ratchet: moves between files, repeated names, TypeScript.
Real git repos, the pinned ruff and the pinned TypeScript, like test_code_health.py. Each
positive case sits next to the control that must keep failing (copies never inherit debt).
"""
from __future__ import annotations
import json
import shutil
import subprocess
from pathlib import Path
import pytest
from scripts.code_health.ts_measure import pinned_typescript, resolve_typescript
from tests.scripts.test_code_health import _LEGACY, _SWALLOW, REPO, _commit, _git, _repo, _verdict
_EDITED = _LEGACY.replace("return 7\n", "return 77\n") # one constant changed, CC still 22
# Same name and CC as _LEGACY, unrelated body: a new function reusing a deleted name.
_UNRELATED = "def legacy(x):\n" + "".join(
f" while x > {i}:\n x = x // 2 - {i}\n" for i in range(21)) + " return x\n"
_MODULE_SWALLOW = "try:\n import json\nexcept Exception:\n pass\n"
def _base(tmp_path: Path, files: dict[str, str | None]) -> tuple[Path, str]:
repo, _ = _repo(tmp_path)
return repo, _commit(repo, files)
# --- M1: module-level code moved to another file keeps its existing hits -----------------
_M1_BASE: dict[str, str | None] = {"pkg/a.py": _LEGACY + "\n\n" + _SWALLOW + "\n\n" + _MODULE_SWALLOW}
@pytest.mark.parametrize("files, blocks", [
# A0 (control): the block stays, a function is added
({"pkg/a.py": _M1_BASE["pkg/a.py"] + "\n\ndef added():\n return 1\n"}, False),
# A1: the same block moved unchanged to a new module
({"pkg/a.py": _LEGACY + "\n\n" + _SWALLOW, "pkg/a_compat.py": _MODULE_SWALLOW}, False),
# moved out of a deleted file
({"pkg/a.py": None, "pkg/a_legacy.py": _LEGACY + "\n\n" + _SWALLOW,
"pkg/a_compat.py": "X = 1\n\n" + _MODULE_SWALLOW}, False),
# a copy (the origin keeps it) is new
({"pkg/a_compat.py": _MODULE_SWALLOW}, True),
# one departed occurrence pays for one arrival, never two
({"pkg/a.py": _LEGACY + "\n\n" + _SWALLOW, "pkg/a_compat.py": _MODULE_SWALLOW + "\n" + _MODULE_SWALLOW}, True),
# dropping one guard never pays for a different one elsewhere: a move is the whole statement
({"pkg/a.py": _LEGACY + "\n\n" + _SWALLOW, "pkg/a_compat.py": _MODULE_SWALLOW.replace("json", "yaml")}, True),
# a module-level hit never pays for one inside a function elsewhere
({"pkg/a.py": _LEGACY + "\n\n" + _SWALLOW,
"pkg/a_compat.py": "def load():\n" + "".join(" " + ln + "\n" for ln in _MODULE_SWALLOW.splitlines())}, True),
])
def test_module_level_hits_follow_code_moved_to_another_file(tmp_path, capsys, files, blocks):
repo, base = _base(tmp_path, _M1_BASE)
code, out = _verdict(repo, base, files, capsys)
assert code == (1 if blocks else 0), out
_ENV_CONST = "import os\n\nCACHED = os.getenv('PATH')\n"
@pytest.mark.parametrize("files, blocks", [
({"pkg/c.py": "import os\n", "pkg/d.py": _ENV_CONST}, False), # a one-line constant moved
({"pkg/c.py": "import os\n", "pkg/d.py": _ENV_CONST.replace("PATH", "HOME")}, True), # not a move
])
def test_module_level_constant_moved_to_another_file(tmp_path, capsys, files, blocks):
repo, base = _base(tmp_path, {"pkg/c.py": _ENV_CONST})
code, out = _verdict(repo, base, files, capsys)
assert code == (1 if blocks else 0), out
# --- M2: a function moved to another file AND edited keeps its cap ----------------------
_LEGACY_SW = _LEGACY.replace("def legacy(x):\n", "def legacy(x):\n" + _SWALLOW.split("\n", 1)[1])
@pytest.mark.parametrize("extra_base, files, blocks", [
# P1: moved with one constant changed (CC still 22)
({}, {"pkg/a.py": _SWALLOW, "pkg/a_legacy.py": _EDITED}, False),
# P2 (control): the same edit in place
({}, {"pkg/a.py": _EDITED + "\n\n" + _SWALLOW}, False),
# moved, edited and grown: the cap is the old value, not a pass
({}, {"pkg/a.py": _SWALLOW, "pkg/a_legacy.py": _EDITED + " if x == 99:\n return 99\n"}, True),
# a copy (the origin still has it) is new code
({}, {"pkg/a_legacy.py": _EDITED}, True),
# an unrelated function that reuses the deleted name is new code
({}, {"pkg/a.py": _SWALLOW, "pkg/a_legacy.py": _UNRELATED}, True),
# two arrivals with that name: neither is provably the moved one
({}, {"pkg/a.py": _SWALLOW, "pkg/b.py": _EDITED, "pkg/c.py": _EDITED}, True),
# the moved+edited function keeps its existing swallow, but not a second one
({"pkg/a.py": _LEGACY_SW + "\n\n" + _SWALLOW},
{"pkg/a.py": _SWALLOW, "pkg/a_legacy.py": _LEGACY_SW.replace("return 7\n", "return 77\n")}, False),
({"pkg/a.py": _LEGACY_SW + "\n\n" + _SWALLOW},
{"pkg/a.py": _SWALLOW, "pkg/a_legacy.py": _LEGACY_SW.replace("return 7\n", "return 77\n")
+ " try:\n pass\n except Exception:\n pass\n"}, True),
# a swallow relocated within the moved function is new, as it is in place
({"pkg/a.py": _LEGACY_SW + "\n\n" + _SWALLOW},
{"pkg/a.py": _SWALLOW, "pkg/a_legacy.py": _EDITED + " try:\n pass\n except Exception:\n pass\n"},
True),
({"pkg/a.py": _LEGACY_SW + "\n\n" + _SWALLOW},
{"pkg/a.py": _EDITED + " try:\n pass\n except Exception:\n pass\n\n\n" + _SWALLOW}, True),
])
def test_function_moved_and_edited_keeps_its_cap(tmp_path, capsys, extra_base, files, blocks):
repo, base = _base(tmp_path, extra_base) if extra_base else _repo(tmp_path)
code, out = _verdict(repo, base, files, capsys)
assert code == (1 if blocks else 0), out
# --- B3: repeated names pair by body, not by ordinal (banditburai) ------------------------
_DECORATOR = "def method(name):\n return lambda f: f\n"
def _handler(route: str, branches: int, fmt: str = " if x == {i}:\n return {i}\n") -> str:
return f'\n\n@method("{route}")\ndef _(x):\n' + "".join(fmt.format(i=i) for i in range(branches)) + " return x\n"
@pytest.mark.parametrize("files, blocks", [
# a new CC-22 `_` above the old one (trimmed to 20) is new code
({"pkg/h.py": _DECORATOR + _handler("new", 21, ' if x == "s{i}":\n return "s{i}" * 2\n')
+ _handler("old", 19)}, True),
# a small `_` above the old one, whose CC-22 body only changes a literal, keeps its cap
({"pkg/h.py": _DECORATOR + _handler("new", 0) + _handler("old", 21).replace("return 7\n", "return 77\n")}, False),
# control: the old handler grows; its cap is 22 wherever it sits
({"pkg/h.py": _DECORATOR + _handler("new", 0) + _handler("old", 22)}, True),
])
def test_repeated_python_names_pair_by_body(tmp_path, capsys, files, blocks):
repo, base = _base(tmp_path, {"pkg/h.py": _DECORATOR + _handler("old", 21)})
code, out = _verdict(repo, base, files, capsys)
assert code == (1 if blocks else 0), out
# --- TypeScript identity ----------------------------------------------------------------
def _ts_base(tmp_path: Path, files: dict[str, str | None]) -> tuple[Path, str]:
if shutil.which("node") is None:
pytest.skip("node is not installed")
repo, _ = _repo(tmp_path)
# The repo's pin; the measurer resolves (or installs) it into the user cache.
lock = {"packages": {"node_modules/typescript": {"version": pinned_typescript(REPO)}}}
return repo, _commit(repo, {**files, "package-lock.json": json.dumps(lock)})
def _ifs(n: int, k: int = 0, indent: str = " ") -> str:
return "".join(f"{indent}if (x === {i + k}) return {i + k}\n" for i in range(n))
def _obj(name: str, branches: int, k: int = 0) -> str:
return (f"export const {name} = {{\n f(x: number): number {{\n{_ifs(branches, k, ' ')}"
f" return -1\n }},\n}}\n")
@pytest.mark.parametrize("files, blocks", [
# V9a: objects swapped; a.f CC 31 -> 20, b.f CC 3 -> 23: b.f must not inherit a.f's cap
({"web/o.ts": _obj("b", 22, 100) + _obj("a", 19)}, True),
# V9b (control): the same edits in the original order
({"web/o.ts": _obj("a", 19) + _obj("b", 22, 100)}, True),
# control: swapped, only a.f trimmed
({"web/o.ts": _obj("b", 2, 100) + _obj("a", 19)}, False),
])
def test_ts_object_literal_methods_are_named_by_their_object(tmp_path, capsys, files, blocks):
repo, base = _ts_base(tmp_path, {"web/o.ts": _obj("a", 30) + _obj("b", 2, 100)})
code, out = _verdict(repo, base, files, capsys)
assert code == (1 if blocks else 0), out
_SIGS = "export function old(x: number): number\nexport function old(x: string): number\n"
_NEW_SIG = "export function old(x: boolean): number\n"
_IMPL = "export function old(x: any): number {\n" + _ifs(21) + " return -1\n}\n"
_IMPL_EDITED = _IMPL.replace("return 7\n", "return 77\n")
@pytest.mark.parametrize("files, blocks", [
({"web/v.ts": _SIGS + _NEW_SIG + _IMPL_EDITED}, False), # V10a: new overload + edited impl
({"web/v.ts": _SIGS + _IMPL_EDITED}, False), # V10b (control): no new overload
({"web/v.ts": _SIGS + _NEW_SIG + _IMPL}, False), # V10c (control): impl untouched
({"web/v.ts": _SIGS + _NEW_SIG + _IMPL.replace(" return -1", " if (x === 99) return 99\n return -1")}, True),
])
def test_ts_overload_signatures_are_not_units(tmp_path, capsys, files, blocks):
repo, base = _ts_base(tmp_path, {"web/v.ts": _SIGS + _IMPL})
code, out = _verdict(repo, base, files, capsys)
assert code == (1 if blocks else 0), out
def test_ts_syntax_error_fails_closed(tmp_path, capsys):
repo, base = _ts_base(tmp_path, {"web/ok.ts": "export const x = 1\n"})
code, out = _verdict(repo, base, {"web/bad.ts": "export function f(x: number {\n return x\n}\n"}, capsys)
assert code == 1 and "MEASURE" in out and "does not parse" in out, out
code, out = _verdict(repo, base, {"web/bad.ts": "export function f(x: number) {\n return x\n}\n"}, capsys)
assert code == 0, out
_TS_LEGACY = "export function legacy(x: number): number {\n" + _ifs(21, indent=" ") + " return -1\n}\n"
@pytest.mark.parametrize("files, blocks", [
# renamed, plus one comment line: comments are not code, so it is the same function
({"web/l.ts": _TS_LEGACY.replace("legacy", "walk").replace(" return -1", " // fell through\n return -1")}, False),
({"web/l.ts": _TS_LEGACY.replace("legacy", "walk")}, False), # control: rename alone
({"web/l.ts": _TS_LEGACY + _TS_LEGACY.replace("legacy", "copied")}, True), # control: a copy
])
def test_ts_identity_ignores_comments(tmp_path, capsys, files, blocks):
repo, base = _ts_base(tmp_path, {"web/l.ts": _TS_LEGACY})
code, out = _verdict(repo, base, files, capsys)
assert code == (1 if blocks else 0), out
# A nested callback's own `legacy` parameter is not the outer function rebinding its name.
_TS_RECURSIVE = ("export function legacy(x: number): number {\n const ys = [x].map((legacy) => legacy)\n"
+ _ifs(21, indent=" ") + " return legacy(ys[0] - 1)\n}\n")
@pytest.mark.parametrize("files, blocks", [
({"web/r.ts": _TS_RECURSIVE.replace("function legacy", "function walk").replace("return legacy(", "return walk(")},
False),
# control: a rename that leaves the old self-call behind is different code
({"web/r.ts": _TS_RECURSIVE.replace("function legacy", "function walk")}, True),
])
def test_ts_recursive_rename_ignores_nested_bindings(tmp_path, capsys, files, blocks):
repo, base = _ts_base(tmp_path, {"web/r.ts": _TS_RECURSIVE})
code, out = _verdict(repo, base, files, capsys)
assert code == (1 if blocks else 0), out
_RUN_DECL = "declare function run(cb: (x: number) => number): void\n"
_SMALL_CB = "run((x: number) => {\n return x + 1\n})\n"
_BIG_CB = "run((x: number) => {\n" + _ifs(21, indent=" ") + " return -1\n})\n"
@pytest.mark.parametrize("files, blocks", [
# a new callback above shifts every `<anon>#n`; the edited CC-22 callback keeps its cap
({"web/c.ts": _RUN_DECL + "run((x: number) => {\n return x * 2\n})\n" + _SMALL_CB
+ _BIG_CB.replace("return 7\n", "return 77\n")}, False),
# a new CC-22 callback above, the old one trimmed to 20: the new one is new code
({"web/c.ts": _RUN_DECL + "run((x: number) => {\n" + "".join(
f" while (x > {i}) x = x / 2 - {i}\n" for i in range(21)) + " return x\n})\n"
+ _SMALL_CB + _BIG_CB.replace(_ifs(2, 19, " "), "")}, True),
])
def test_ts_anonymous_callbacks_pair_by_body(tmp_path, capsys, files, blocks):
repo, base = _ts_base(tmp_path, {"web/c.ts": _RUN_DECL + _SMALL_CB + _BIG_CB})
code, out = _verdict(repo, base, files, capsys)
assert code == (1 if blocks else 0), out
# --- m5: the TypeScript install honours the repo's .npmrc and fails as a RuntimeError -----
def test_typescript_install_uses_repo_npmrc_and_fails_cleanly(tmp_path, monkeypatch):
npm = shutil.which("npm")
if npm is None:
pytest.skip("npm is not installed")
repo = tmp_path / "repo"
repo.mkdir()
_git(repo, "init", "-q", "-b", "main")
(repo / "package-lock.json").write_text(
json.dumps({"packages": {"node_modules/typescript": {"version": pinned_typescript(REPO)}}}),
encoding="utf-8")
# An unreachable registry and an empty npm cache: only the repo's .npmrc can say so.
(repo / ".npmrc").write_text(
f"registry=http://127.0.0.1:9/\nfetch-retries=0\ncache={tmp_path / 'npm-cache'}\nmin-release-age=14\n",
encoding="utf-8")
monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path / "cache"))
with pytest.raises(RuntimeError, match="typescript"):
resolve_typescript(repo)
prefix = next((tmp_path / "cache" / "hermes-code-health").iterdir())
got = subprocess.run([npm, "config", "get", "min-release-age", "--prefix", str(prefix)],
cwd=prefix, capture_output=True, text=True, timeout=60, check=True).stdout.strip()
assert got == "14"