diff --git a/runtime-recomp/tests/agent_hooks_tests.py b/runtime-recomp/tests/agent_hooks_tests.py index 2da21258..bc766d6c 100644 --- a/runtime-recomp/tests/agent_hooks_tests.py +++ b/runtime-recomp/tests/agent_hooks_tests.py @@ -19,6 +19,10 @@ ROOT = Path(__file__).resolve().parents[2] HOOKS = ROOT / "scripts/agent_hooks" HOOK_SCRIPTS = {"session_start.py", "pre_edit_guard.py", "post_edit_check.py", "stop_check.py"} +# The hooks act only inside a git checkout. Packaged source copies (the macOS +# build kit) have no .git, so the tests that drive them against this repo skip. +IN_CHECKOUT = (ROOT / ".git").exists() +SKIP_OUTSIDE_CHECKOUT = unittest.skipUnless(IN_CHECKOUT, "needs a git checkout of the repository") SPEC = importlib.util.spec_from_file_location("hooklib", HOOKS / "hooklib.py") hooklib = importlib.util.module_from_spec(SPEC) sys.modules["hooklib"] = hooklib # dataclasses resolve their module by name @@ -59,6 +63,7 @@ def git(root, *args): ) +@SKIP_OUTSIDE_CHECKOUT class PreEditGuardTests(unittest.TestCase): def test_checkout_is_found_from_a_nested_directory(self): self.assertEqual(hooklib.checkout_of(HOOKS), ROOT) @@ -383,6 +388,7 @@ def test_claude_commands_are_rooted_on_the_project_dir(self): for command in commands: self.assertTrue(command.startswith('"$CLAUDE_PROJECT_DIR"/scripts/agent_hooks/run '), command) + @SKIP_OUTSIDE_CHECKOUT def test_session_state_is_gitignored(self): for path in (".claude/session-base", ".claude/settings.local.json"): result = subprocess.run(["git", "-C", str(ROOT), "check-ignore", "-q", path]) diff --git a/runtime-recomp/tests/developer_entrypoint_tests.py b/runtime-recomp/tests/developer_entrypoint_tests.py index 67a091d2..e0f422c8 100644 --- a/runtime-recomp/tests/developer_entrypoint_tests.py +++ b/runtime-recomp/tests/developer_entrypoint_tests.py @@ -94,12 +94,14 @@ def test_failed_build_kit_does_not_poison_retry(self): "Build-macOS.sh", "Setup-macOS.sh", "Run-macOS.sh", + ".claude/settings.json", + ".codex/hooks.json", ): path = self.root / name path.parent.mkdir(parents=True, exist_ok=True) path.write_text("1.0.5\n") - for name in ("assets", "patches", "runtime-recomp"): - (self.root / name).mkdir() + for name in ("assets", "patches", "runtime-recomp", "tests/fixtures/pattern-guard"): + (self.root / name).mkdir(parents=True) self.executable(self.bin / "rsync", "exit 0") self.executable(self.bin / "tar", 'echo partial > "$4"; exit "${DKR_TEST_EXIT:-9}"') self.env.update(DKR_RELEASE_VERSION="1.0.5", DKR_MAC_KIT_STAGE_ROOT=str(self.root / "stage")) diff --git a/runtime-recomp/tests/pattern_guard_tests.py b/runtime-recomp/tests/pattern_guard_tests.py index 0b727c1f..83f9cfb6 100644 --- a/runtime-recomp/tests/pattern_guard_tests.py +++ b/runtime-recomp/tests/pattern_guard_tests.py @@ -69,6 +69,20 @@ def test_every_rule_explains_itself(self): self.assertEqual(run_guard(ROOT, "--explain", "PG999")[0], 2) +class CheckContentTests(unittest.TestCase): + def test_a_patch_registered_before_it_is_written_is_still_checked(self): + # Registering first must not hide PG008: PG007 hashes the planned + # content instead of reading a file that doesn't exist yet. + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) / "repo" + shutil.copytree(FIXTURES / "PG008/positive", root) + patch = root / "patches/rt64/0001-sidebar.patch" + text = patch.read_text(encoding="utf-8") + patch.unlink() + findings = pattern_guard.check_content(root, "patches/rt64/0001-sidebar.patch", text) + self.assertIn("PG008", {finding.rule for finding in findings}) + + class CMakeTokenizerTests(unittest.TestCase): def test_comments_quotes_brackets_and_lines(self): commands = pattern_guard.parse_cmake( diff --git a/scripts/Package-macOS-Build-Kit.sh b/scripts/Package-macOS-Build-Kit.sh index 385ac7bb..6ccf1e50 100755 --- a/scripts/Package-macOS-Build-Kit.sh +++ b/scripts/Package-macOS-Build-Kit.sh @@ -41,6 +41,12 @@ for directory in assets packaging patches scripts; do done rsync -a --exclude=.git --exclude=run-data --exclude=__pycache__ \ "${project_root}/runtime-recomp/" "${stage}/runtime-recomp/" +# Inputs of the pattern-guard and agent-hook tests that Build-macOS.sh runs +# through CTest. +mkdir -p "${stage}/tests/fixtures" "${stage}/.claude" "${stage}/.codex" +rsync -a "${project_root}/tests/fixtures/pattern-guard/" "${stage}/tests/fixtures/pattern-guard/" +install -m 0644 "${project_root}/.claude/settings.json" "${stage}/.claude/settings.json" +install -m 0644 "${project_root}/.codex/hooks.json" "${stage}/.codex/hooks.json" # Preparation fetches the pinned Git checkouts from dependencies.lock.json. # Source copies without .git cannot be verified or patched by that pipeline. diff --git a/scripts/pattern_guard.py b/scripts/pattern_guard.py index ba8d796d..61aa1cdd 100644 --- a/scripts/pattern_guard.py +++ b/scripts/pattern_guard.py @@ -386,6 +386,12 @@ def is_scanned(self, path: str) -> bool: def has_file(self, path: str) -> bool: return path in self.overlay or (self.root / path).is_file() + def data(self, path: str) -> bytes: + """Raw file bytes, from the overlay (planned content) when present.""" + if path in self.overlay: + return self.overlay[path].encode("utf-8") + return (self.root / path).read_bytes() + @functools.cache def text(self, path: str) -> str | None: """Decoded file text, or None for binary or unreadable files.""" @@ -755,7 +761,7 @@ def unregistered_dependency_patch(tree: Tree, path: str) -> Iterator[Finding]: if path.endswith(".patch") and "/".join(path.split("/")[:2]) in tree.dependency_dirs: if path not in registered: yield Finding(path, 1, "PG007", "dependency patch is not in patches/manifest.json") - elif registered[path] != hashlib.sha256((tree.root / path).read_bytes()).hexdigest(): + elif registered[path] != hashlib.sha256(tree.data(path)).hexdigest(): yield Finding(path, 1, "PG007", "sha256 differs from patches/manifest.json") if is_cmake(path): for command in tree.cmake(path):