Files
hermes-agent/tests/hermes_cli/test_update_post_pull_syntax_guard.py
Teknium e92cec9dff fix(update): startup syntax is judged by the target's Python over the whole entry closure (review G3, N15)
What: the critical inventory now holds every module the launcher and hermes_bootstrap import
before application code (hermes_bootstrap.py, _parser, _launchers, the recovery closure,
update_handoff, venv_sync, pm/environments), pinned by a test that derives that set from the
real launcher text and bootstrap imports. One startup_syntax_error() serves the preflight, the
post-pull guard and the ZIP gate: a target whose requires-python excludes this interpreter is
compiled by an installed interpreter it admits (uv python find, then python3.N on PATH); with
none installed this interpreter still compiles it, and a refusal names the Python to install.
The preflight reads pyproject + the inventory in one `git cat-file --batch`.
Why: a broken bootstrap passed both guards and failed at the first import (G3); a Python bump
reduced the check to a conflict-marker scan, so `def broken(:` landed (N15, the class the
00f0e40b6ce marker scan left open); preflight cost one git spawn per file (kshitijk4poor).
Changed test: test_syntax_guards_skip_a_target_that_requires_a_newer_python pinned the N15 bug
(`def newer_syntax(:` admitted under >=3.99); it is replaced by the no-interpreter refusal and
the admitted-interpreter verdict tests.
ZIP gate (update_cmd_zip.py, czip's file): its compile loop is replaced by the shared helper.
2026-10-06 06:21:58 -07:00

316 lines
16 KiB
Python

"""Tests for the post-pull syntax guard in ``hermes update``.
When a bad commit lands on ``main`` with a syntax error in a critical file
(e.g. orphan merge-conflict markers in ``hermes_cli/config.py``), the CLI
becomes unbootable — every ``hermes`` invocation imports those files at
startup. The guard validates them after ``git pull`` and rolls back to the
pre-pull SHA on failure so the user's install stays runnable.
Reference incident: PR #28452 (May 18, 2026) shipped unresolved conflict
markers in ``hermes_cli/config.py``; users who ran ``hermes update`` in
the 7-minute window before #28458 landed could not run any ``hermes``
command afterward.
"""
from __future__ import annotations
from hermes_cli import update_cmd
from hermes_cli import main
import pytest
import subprocess
import sys
from pathlib import Path
# ---------------------------------------------------------------------------
# _validate_critical_files_syntax
# ---------------------------------------------------------------------------
def test_validate_critical_files_syntax_tolerates_missing_files(tmp_path):
"""A refactor may legitimately remove one of the critical files — the
guard should skip missing files, not falsely flag the install as broken."""
# Populate everything except hermes_constants.py
for relpath in update_cmd._UPDATE_CRITICAL_FILES:
if relpath == "hermes_constants.py":
continue
path = tmp_path / relpath
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("# stub\n", encoding="utf-8")
ok, failing_path, error = update_cmd._validate_critical_files_syntax(tmp_path)
assert ok is True
assert failing_path is None
assert error is None
def test_pull_rolls_back_broken_critical_file_and_accepts_corrected_retry(tmp_path, monkeypatch, capsys):
def git(*args):
return subprocess.run(["git", *args], cwd=tmp_path, check=True, capture_output=True, text=True, encoding="utf-8").stdout.strip()
git("init", "-b", "main")
git("config", "user.email", "test@example.invalid")
git("config", "user.name", "Test")
source = tmp_path / "hermes_constants.py"
source.write_text("print('runnable')\n", encoding="utf-8")
git("add", ".")
git("commit", "-m", "working")
previous = git("rev-parse", "HEAD")
monkeypatch.setattr(main, "PROJECT_ROOT", tmp_path)
assert update_cmd._capture_head_sha(["git"], tmp_path) == previous
# Missing critical files are legitimate after refactors, not syntax failures.
assert update_cmd._validate_critical_files_syntax(tmp_path) == (True, None, None)
source.write_text("<<<<<<< HEAD\n", encoding="utf-8")
git("commit", "-am", "broken upstream")
git("update-ref", "refs/remotes/origin/main", "HEAD")
git("reset", "--hard", previous)
def pull():
return update_cmd._pull_updates(
["git"], "main", None, prompt_for_restore=False, gw_input_fn=None,
discard_local_changes=False, keep_stash=False,
)
with pytest.raises(SystemExit) as failure:
pull()
assert failure.value.code == 1
assert "syntax error" in capsys.readouterr().out
assert git("rev-parse", "HEAD") == previous
assert subprocess.run([sys.executable, str(source)], capture_output=True, text=True, encoding="utf-8", check=True).stdout == "runnable\n"
git("reset", "--hard", "origin/main")
source.write_text("print('corrected')\n", encoding="utf-8")
git("commit", "-am", "corrected upstream")
corrected = git("rev-parse", "HEAD")
git("update-ref", "refs/remotes/origin/main", corrected)
git("reset", "--hard", previous)
assert pull() == previous
assert git("rev-parse", "HEAD") == corrected
assert subprocess.run([sys.executable, str(source)], capture_output=True, text=True, encoding="utf-8", check=True).stdout == "corrected\n"
def _python_bump_repo(tmp_path, broken_source: str):
"""Commits: ``newer`` (requires-python >=3.99, ``hermes_constants.py`` = broken_source) and ``same``
(the same file, a requires-python this interpreter satisfies)."""
def git(*args):
return subprocess.run(["git", *args], cwd=tmp_path, check=True, capture_output=True, text=True, encoding="utf-8").stdout.strip()
git("init", "-b", "main")
git("config", "user.email", "test@example.invalid")
git("config", "user.name", "Test")
(tmp_path / "hermes_constants.py").write_text(broken_source, encoding="utf-8")
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text('[project]\nname = "x"\nrequires-python = ">=3.99"\n', encoding="utf-8")
git("add", ".")
git("commit", "-m", "requires a future python")
newer = git("rev-parse", "HEAD")
pyproject.write_text('[project]\nname = "x"\nrequires-python = ">=3.8"\n', encoding="utf-8")
git("commit", "-am", "same file, this python")
return git, newer, git("rev-parse", "HEAD")
def test_a_python_bump_with_no_admitted_interpreter_is_still_syntax_checked(tmp_path, monkeypatch):
"""A release that raises requires-python past this interpreter used to be held only to a
conflict-marker scan, so ``def broken(:`` (broken under every Python) passed preflight and the
post-pull guard (review N15). With no installed interpreter the target admits, this one judges,
and the refusal names the Python to install (real git object store, real worktree)."""
from hermes_cli import update_cmd_commit as commit
monkeypatch.setattr(commit, "_admitted_python", lambda spec: None)
git, newer, same = _python_bump_repo(tmp_path, "def broken(:\n")
critical = ["hermes_constants.py"]
refused = commit.target_syntax_error(["git"], tmp_path, newer, critical)
assert refused is not None and refused[0] == "hermes_constants.py"
assert "uv python install '>=3.99'" in refused[1]
assert commit.target_syntax_error(["git"], tmp_path, same, critical)[0] == "hermes_constants.py"
# The post-pull backstop judges the worktree the same way: rolled back, exit 1.
monkeypatch.setattr(main, "PROJECT_ROOT", tmp_path)
git("checkout", "-q", newer)
with pytest.raises(SystemExit):
update_cmd._rollback_if_pulled_syntax_error(["git"], same)
assert git("rev-parse", "HEAD") == same
@pytest.mark.platforms("posix")
@pytest.mark.parametrize("verdict", ["null", '["hermes_constants.py", "SyntaxError: judged by the target"]'])
def test_a_python_bump_is_judged_by_an_interpreter_the_target_admits(tmp_path, monkeypatch, verdict):
"""Newer grammar this interpreter rejects is admitted when the target's own Python accepts it,
and refused when that Python rejects it: its verdict, not this compile(), decides (N15)."""
from hermes_cli import update_cmd_commit as commit
target_python = tmp_path / "python3.99"
target_python.write_text(f"#!/bin/sh\ncat > /dev/null\necho '{verdict}'\n", encoding="utf-8")
target_python.chmod(0o755)
monkeypatch.setattr(commit, "_admitted_python", lambda spec: str(target_python))
repo = tmp_path / "repo"
repo.mkdir()
_git, newer, _same = _python_bump_repo(repo, "def newer_syntax(:\n")
judged = commit.target_syntax_error(["git"], repo, newer, ["hermes_constants.py"])
if verdict == "null":
assert judged is None
else:
assert judged == ("hermes_constants.py", f"SyntaxError: judged by the target (judged by {target_python})")
def test_the_critical_inventory_covers_every_module_the_entry_paths_import_first():
"""``hermes_bootstrap.py`` was absent from the inventory, so a target that broke only the
bootstrap passed preflight and the post-pull guard and failed at the first import (review G3).
Derived from the real launcher text, ``hermes_bootstrap``'s imports and the recovery closure."""
import ast
from hermes_cli import _launchers
from hermes_cli._early_recovery import RECOVERY_CLOSURE
root = Path(update_cmd.__file__).resolve().parents[1]
def files(source: str) -> set[str]:
names = set()
for node in ast.walk(ast.parse(source)):
if isinstance(node, ast.Import):
names |= {alias.name for alias in node.names}
elif isinstance(node, ast.ImportFrom) and node.module and not node.level:
names |= {node.module, *(f"{node.module}.{alias.name}" for alias in node.names)}
found = set()
for name in names:
parts = name.split(".")
for i in range(1, len(parts) + 1):
stem = "/".join(parts[:i])
found |= {rel for rel in (f"{stem}.py", f"{stem}/__init__.py") if (root / rel).is_file()}
return found
launcher = _launchers._launcher_script("hermes", root, None)
entry = files(launcher) | files((root / "hermes_bootstrap.py").read_text(encoding="utf-8"))
entry |= {"hermes_bootstrap.py", *RECOVERY_CLOSURE}
assert "hermes_bootstrap.py" in entry and "hermes_cli/main.py" in entry
assert entry <= set(update_cmd._UPDATE_CRITICAL_FILES), sorted(entry - set(update_cmd._UPDATE_CRITICAL_FILES))
def test_a_malformed_bootstrap_is_refused_before_the_move(tmp_path):
"""The preflight reads the real inventory from the target commit: a broken bootstrap refuses."""
from hermes_cli import update_cmd_commit as commit
def git(*args):
return subprocess.run(["git", *args], cwd=tmp_path, check=True, capture_output=True, text=True, encoding="utf-8").stdout.strip()
git("init", "-b", "main")
git("config", "user.email", "test@example.invalid")
git("config", "user.name", "Test")
(tmp_path / "hermes_bootstrap.py").write_text("def broken(:\n", encoding="utf-8")
(tmp_path / "hermes_constants.py").write_text("ok = 1\n", encoding="utf-8")
git("add", ".")
git("commit", "-m", "broken bootstrap")
refused = commit.target_syntax_error(["git"], tmp_path, "HEAD", update_cmd._UPDATE_CRITICAL_FILES)
assert refused is not None and refused[0] == "hermes_bootstrap.py"
def test_the_preflight_reads_every_critical_file_in_one_git_spawn(tmp_path, monkeypatch):
"""One ``git cat-file --batch`` for pyproject + the whole inventory, not a ``git show`` each."""
from hermes_cli import update_cmd_commit as commit
def git(*args):
return subprocess.run(["git", *args], cwd=tmp_path, check=True, capture_output=True, text=True, encoding="utf-8").stdout.strip()
git("init", "-b", "main")
git("config", "user.email", "test@example.invalid")
git("config", "user.name", "Test")
for rel in ("hermes_constants.py", "cli.py"):
(tmp_path / rel).write_text("ok = 1\n", encoding="utf-8")
(tmp_path / "hermes_cli").mkdir()
(tmp_path / "hermes_cli" / "main.py").write_text("x = (\n", encoding="utf-8")
git("add", ".")
git("commit", "-m", "c")
spawns = []
real = commit.run_git
monkeypatch.setattr(commit, "run_git", lambda git_cmd, args, **kw: spawns.append(args) or real(git_cmd, args, **kw))
refused = commit.target_syntax_error(["git"], tmp_path, "HEAD", update_cmd._UPDATE_CRITICAL_FILES)
assert refused is not None and refused[0] == "hermes_cli/main.py"
assert spawns == [["cat-file", "--batch"]]
def test_the_tree_move_marker_records_the_absolute_git_the_repair_reruns(tmp_path, monkeypatch):
"""The next launch's repair cannot ask ``pm`` for the store git when the move tore a module ``pm``
imports, so the marker carries the absolute git this run resolved (PATH or ``expose_pm_git``)."""
from hermes_cli import _early_recovery
from hermes_cli import update_cmd_commit as commit
(tmp_path / ".git").mkdir()
fake = tmp_path / "store" / "git" / "cmd" / ("git.exe" if sys.platform == "win32" else "git")
fake.parent.mkdir(parents=True)
fake.write_text("", encoding="utf-8")
monkeypatch.setattr("shutil.which", lambda name: str(fake) if name == "git" else None)
marker = commit.arm_tree_move(["git", "-c", "gc.autoDetach=false"], tmp_path, pre="a" * 40,
target="b" * 40, stash=None)
fields = dict(line.partition("=")[::2] for line in marker.read_text(encoding="utf-8").splitlines())
assert fields["git"] == str(fake)
# ...and the repair prefers it to PATH (empty here) and to the pm lookup, once it answers as a
# git (m5: this fake is an empty file; the version probe is what decides).
monkeypatch.setattr("shutil.which", lambda name: None)
monkeypatch.setattr(_early_recovery, "_is_git", lambda path: path == str(fake))
assert _early_recovery._git_executable(fields["git"]) == str(fake)
assert _early_recovery._git_executable(str(tmp_path / "gone")) != str(tmp_path / "gone")
def test_a_python_bump_never_admits_a_conflict_marker(tmp_path, monkeypatch):
"""A release that raises requires-python past this interpreter is excused only syntax a newer
Python may parse: a merge-conflict marker breaks every Python, before and after the move (N15)."""
from hermes_cli import update_cmd_commit as commit
def git(*args):
return subprocess.run(["git", *args], cwd=tmp_path, check=True, capture_output=True, text=True, encoding="utf-8").stdout.strip()
git("init", "-b", "main")
git("config", "user.email", "test@example.invalid")
git("config", "user.name", "Test")
(tmp_path / "hermes_constants.py").write_text("good = True\n", encoding="utf-8")
(tmp_path / "pyproject.toml").write_text('[project]\nname = "x"\nrequires-python = ">=3.8"\n', encoding="utf-8")
git("add", ".")
git("commit", "-m", "pre")
pre = git("rev-parse", "HEAD")
(tmp_path / "hermes_constants.py").write_text("<<<<<<< HEAD\na = 1\n=======\na = 2\n>>>>>>> topic\n", encoding="utf-8")
(tmp_path / "pyproject.toml").write_text('[project]\nname = "x"\nrequires-python = ">=3.99"\n', encoding="utf-8")
git("commit", "-am", "conflicted, and bumps python")
broken = git("rev-parse", "HEAD")
assert commit.target_syntax_error(["git"], tmp_path, broken, ["hermes_constants.py"]) is not None
monkeypatch.setattr(main, "PROJECT_ROOT", tmp_path)
with pytest.raises(SystemExit):
update_cmd._rollback_if_pulled_syntax_error(["git"], pre)
assert git("rev-parse", "HEAD") == pre
def test_a_marker_arm_that_times_out_still_rolls_the_broken_pull_back(tmp_path, monkeypatch, capsys):
"""arm_tree_move's ``symbolic-ref`` can raise subprocess.TimeoutExpired; the rollback caught only
OSError, so a timeout aborted it before any reset and left the broken release checked out
(review C10). It must roll back unmarked, as for an unwritable marker."""
from hermes_cli import update_cmd_commit
def git(*args):
return subprocess.run(["git", *args], cwd=tmp_path, check=True, capture_output=True, text=True,
encoding="utf-8").stdout.strip()
git("init", "-b", "main")
git("config", "user.email", "test@example.invalid")
git("config", "user.name", "Test")
source = tmp_path / "hermes_constants.py"
source.write_text("print('runnable')\n", encoding="utf-8")
git("add", ".")
git("commit", "-m", "working")
previous = git("rev-parse", "HEAD")
source.write_text("<<<<<<< HEAD\n", encoding="utf-8")
git("commit", "-am", "broken upstream")
monkeypatch.setattr(main, "PROJECT_ROOT", tmp_path)
def times_out(*_args, **_kwargs):
raise subprocess.TimeoutExpired(["git", "symbolic-ref", "-q", "HEAD"], 60)
monkeypatch.setattr(update_cmd_commit, "arm_tree_move", times_out)
with pytest.raises(SystemExit) as failure:
update_cmd._rollback_if_pulled_syntax_error(["git"], previous)
assert failure.value.code == 1
assert "Rollback complete" in capsys.readouterr().out
assert git("rev-parse", "HEAD") == previous
assert source.read_text(encoding="utf-8") == "print('runnable')\n"