diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 746fa78..9c5d46d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,3 +46,8 @@ jobs: # have shipped before. Warning severity skips style nitpicks. - name: Shellcheck hooks run: shellcheck --severity=warning .claude/hooks/*.sh + + # Runs each hook script for real against crafted payloads. shellcheck + # catches shell mistakes; these catch wrong decisions. + - name: Test hook behaviour + run: uv run pytest diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cf3e3e..8baa7cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added +- **Tests** (`tests/`): 65 tests covering the six hook scripts. Each runs the real script in a subprocess against a crafted payload and asserts on the decision it emits — nothing is mocked, since "the hook silently stopped firing" is the regression worth catching. Both hook bugs this project has shipped were **logic** errors, not shell errors, and neither `shellcheck` nor `validate_config.py` can see that class of fault. Validated by mutation: reintroducing the pre-v1.3.0 `\b` boundary in `protect-main.sh` turns 8 tests red in both directions — `rm -rf /`, `rm -rf .`, `rm -rf ~` slipping through, and `rm -rf .git`, `rm -rf ~/tmp-dir` wrongly blocked. Two paths are deliberately uncovered and documented as such: `verify.sh`'s ruff/pytest body (running it from inside pytest would recurse) and `auto-lint.sh`'s formatting body (its outcome depends on the surrounding project's ruff `include`) - **CI** (`.github/workflows/ci.yml`): a GitHub Actions workflow on pull requests and pushes to `main`. Until now nothing verified this repository at all — every PR merged without a single automated check. It runs ruff (lint + format), `shellcheck --severity=warning` over the hook scripts, and a new configuration validator - **Scripts** (`scripts/validate_config.py`): validates that the configuration this template ships is internally coherent — `settings.json` and the MCP configs parse; hook scripts referenced by `settings.json` exist on disk; `CLAUDE.md`'s `@`-imports resolve; every skill's `SKILL.md` still declares `name` and `description`. All but the first fail **silently** at runtime: a hook whose script was renamed just stops firing, a broken `@`-import drops that rule from Claude's context, and a skill missing frontmatter becomes undiscoverable — none of which surfaces an error. Verified by injecting each fault and confirming a non-zero exit diff --git a/README.md b/README.md index 1ad827e..36428e5 100644 --- a/README.md +++ b/README.md @@ -105,8 +105,8 @@ claude-code-python-setup/ │ │ ├── protect-main.sh # Block force push, direct push to main, broad rm -rf │ │ ├── auto-lint.sh # Auto-format Python files with ruff after edits │ │ └── verify.sh # Run ruff + pytest on Stop; block until green -│ ├── rules/ # Modular coding standards -│ │ ├── api-patterns.md # FastAPI/Pydantic (path-scoped) +│ ├── rules/ # Modular coding standards +│ │ ├── api-patterns.md # FastAPI/Pydantic (path-scoped) │ │ ├── architecture.md │ │ ├── compaction.md │ │ ├── documentation.md @@ -118,7 +118,7 @@ claude-code-python-setup/ │ │ └── testing.md │ ├── settings.json # Project-level hooks, permissions, status line │ ├── statusline.py # Status line script (Python, cross-platform) -│ └── skills/ # Reference docs and scripts +│ └── skills/ # Reference docs and scripts │ ├── api-design/ │ ├── claude-api/ │ ├── claude-automation-recommender/ @@ -149,6 +149,10 @@ claude-code-python-setup/ │ └── windows.mcp.json # MCP server config (Windows) ├── scripts/ │ └── validate_config.py # Checks this template's own config is coherent +├── tests/ +│ ├── conftest.py # Fixtures that run a hook against a payload +│ ├── hook_harness.py # HookResult and payload builders +│ └── unit/ # One file per hook, asserting its decisions ├── .env.example # Environment variables template ├── .gitattributes # Force *.sh to LF so hooks run on Windows ├── .gitignore @@ -550,15 +554,36 @@ The script is written in Python and works cross-platform: Windows, macOS, and Li |-------|---------| | `ruff check` / `ruff format --check` | Lint and formatting on `.claude/statusline.py` and `scripts/` (`src/**/*.py` covers projects built from this template) | | `scripts/validate_config.py` | Unparseable `settings.json` or MCP config; hooks pointing at scripts that no longer exist; `CLAUDE.md` `@`-imports that don't resolve; skills whose `SKILL.md` lost its `name`/`description` frontmatter | -| `shellcheck --severity=warning` | Quoting and logic bugs in the hook scripts | +| `shellcheck --severity=warning` | Shell quoting and syntax bugs in the hook scripts | +| `pytest` | Hooks reaching the **wrong decision** — see below | -The middle three failures are all silent at runtime: a hook whose script was renamed simply stops firing, and a broken `@`-import drops that rule from Claude's context without an error. Run the same checks locally with: +The `validate_config.py` failures are all silent at runtime: a hook whose script was renamed simply stops firing, and a broken `@`-import drops that rule from Claude's context without an error. Run everything locally with: ```bash -uv run ruff check . && uv run ruff format --check . && uv run python scripts/validate_config.py +uv run ruff check . && uv run ruff format --check . && uv run python scripts/validate_config.py && uv run pytest ``` -There is no test job: this repository ships configuration, not application code, so `pyproject.toml`'s pytest setup is there for the projects you build from it. +### Hook tests (`tests/`) + +The hooks are the only enforced guardrails in this setup, and every past bug in them has been a **logic** error rather than a shell error — a condition that silently disabled a hook, a regex boundary that let `rm -rf /` through while blocking `rm -rf .git`. shellcheck cannot see either. + +So each test runs the real script in a subprocess with a crafted payload and asserts on the decision it emits. Nothing is mocked, because "the hook stopped firing" is precisely the regression worth catching. + +| File | Asserts | +|------|---------| +| `test_protect_main_hook.py` | Force pushes, pushes to main, `reset --hard` and broad `rm -rf` are denied — while `--force-with-lease`, `rm -rf .git` and `rm -rf ~/tmp-dir` still pass | +| `test_enforce_uv_hook.py` | Bare `python`/`pytest`/`ruff` are rewritten to `uv run ...`; `pip` and compound commands are denied; anything already using uv is left alone | +| `test_guard_secrets_hook.py` | Credential-shaped prompts are blocked, prose *about* credentials is not | +| `test_session_start_hook.py` | Missing `.venv` or a stale `uv.lock` is reported; a healthy project stays silent | +| `test_verify_hook.py` | The `stop_hook_active` loop guard and the non-Python-project exit | +| `test_auto_lint_hook.py` | Non-Python files, deleted files and payloads without a path are ignored | + +Two gaps are deliberate and worth knowing: + +- **`verify.sh`'s ruff/pytest execution path is not covered.** Running it from inside the suite would invoke pytest recursively, and a throwaway uv project would need a network install on every CI run. Only its guard clauses are tested. +- **`auto-lint.sh`'s formatting path is not covered**, because whether ruff acts on a given file depends on the surrounding project's `include` configuration. Only the conditions under which the hook must do nothing are tested. + +Neither the tests nor `validate_config.py` check the **hook wiring** in `settings.json` — that a hook's `matcher` and `if` condition actually route the events you expect. That wiring has broken before, and it remains verifiable only by running Claude Code. ## Contributing diff --git a/pyproject.toml b/pyproject.toml index 82692ad..bd794fa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,7 +56,13 @@ line-length = 99 src = ["src"] # src/** is for projects built from this template; the other two are the Python # this repo actually ships. Vendored .claude/skills/ scripts stay out of scope. -include = ["pyproject.toml", "src/**/*.py", "scripts/**/*.py", ".claude/statusline.py"] +include = [ + "pyproject.toml", + "src/**/*.py", + "tests/**/*.py", + "scripts/**/*.py", + ".claude/statusline.py", +] [tool.ruff.lint] extend-select = ["I"] # Add import sorting @@ -64,7 +70,7 @@ select = ["E", "F", "W", "I", "UP"] ignore = [] [tool.ruff.lint.isort] -known-first-party = ["src"] +known-first-party = ["src", "tests"] force-sort-within-sections = true [tool.ruff.format] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..95f4292 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Test suite for the .claude configuration.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..64b1f63 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,73 @@ +"""Fixtures for running the hook scripts against crafted payloads.""" + +from __future__ import annotations + +import json +from pathlib import Path +import shutil +import subprocess +from typing import Any + +import pytest + +from tests.hook_harness import HOOK_TIMEOUT_SECONDS, HOOKS_DIR, REPO_ROOT, HookResult, RunHook + + +def _require(executable: str) -> str: + """Return the path to `executable`, skipping the test when it is absent.""" + resolved = shutil.which(executable) + if resolved is None: + pytest.skip(f"{executable} is required to run the hook scripts") + return resolved + + +@pytest.fixture(scope="session") +def bash_executable() -> str: + """Path to bash, which interprets every hook script.""" + return _require("bash") + + +@pytest.fixture(scope="session") +def jq_executable() -> str: + """Path to jq, which every hook uses to read its payload and build output.""" + return _require("jq") + + +@pytest.fixture(scope="session") +def uv_executable() -> str: + """Path to uv, needed by the hooks that inspect or drive the toolchain.""" + return _require("uv") + + +@pytest.fixture +def run_hook(bash_executable: str, jq_executable: str) -> RunHook: + """Return a callable that runs a hook script against a payload. + + Returns + ------- + RunHook + `run_hook(hook_name, payload, cwd=None)` -> `HookResult`. `hook_name` is + the script's stem, for example `"protect-main"`. `cwd` defaults to the + repository root; pass a temp directory for hooks that inspect the + working directory. + """ + + def _run( + hook_name: str, + payload: dict[str, Any], + cwd: Path | None = None, + ) -> HookResult: + script_path = HOOKS_DIR / f"{hook_name}.sh" + assert script_path.is_file(), f"hook script not found: {script_path}" + + completed = subprocess.run( + [bash_executable, str(script_path)], + input=json.dumps(payload), + capture_output=True, + text=True, + cwd=str(cwd or REPO_ROOT), + timeout=HOOK_TIMEOUT_SECONDS, + ) + return HookResult(exit_code=completed.returncode, stdout=completed.stdout) + + return _run diff --git a/tests/hook_harness.py b/tests/hook_harness.py new file mode 100644 index 0000000..0e703d7 --- /dev/null +++ b/tests/hook_harness.py @@ -0,0 +1,110 @@ +"""Helpers for driving the hook scripts in `.claude/hooks/`. + +The hooks are shell scripts that read a JSON payload on stdin and answer on +stdout, so the tests run each real script in a subprocess and assert on the +decision it emits. Nothing is mocked: a hook that silently stops firing is +exactly the regression these tests exist to catch. +""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +import json +from pathlib import Path +from typing import Any + +REPO_ROOT = Path(__file__).resolve().parent.parent +HOOKS_DIR = REPO_ROOT / ".claude" / "hooks" +HOOK_TIMEOUT_SECONDS = 60 + + +@dataclass(frozen=True) +class HookResult: + """Outcome of running a hook script. + + Attributes + ---------- + exit_code + Process exit status. Hooks exit 0 even when they deny an action: the + decision travels in the JSON payload, not in the status code. + stdout + Raw text the hook wrote to stdout. + """ + + exit_code: int + stdout: str + + @property + def payload(self) -> dict[str, Any]: + """Parsed hook output, or an empty dict when the hook stayed silent.""" + if not self.stdout.strip(): + return {} + return json.loads(self.stdout) + + @property + def is_silent(self) -> bool: + """True when the hook emitted nothing, meaning it took no position.""" + return not self.stdout.strip() + + @property + def permission_decision(self) -> str | None: + """`permissionDecision` from a `PreToolUse` hook, if it emitted one.""" + return self.payload.get("hookSpecificOutput", {}).get("permissionDecision") + + @property + def updated_command(self) -> str | None: + """Command a `PreToolUse` hook rewrote the call to, if it rewrote one.""" + hook_output = self.payload.get("hookSpecificOutput", {}) + return hook_output.get("updatedInput", {}).get("command") + + @property + def decision(self) -> str | None: + """Top-level `decision`, used by `UserPromptSubmit` and `Stop` hooks.""" + return self.payload.get("decision") + + @property + def reason(self) -> str: + """Explanation the hook gave, from whichever field carries it.""" + hook_output = self.payload.get("hookSpecificOutput", {}) + return hook_output.get("permissionDecisionReason") or self.payload.get("reason", "") + + @property + def additional_context(self) -> str: + """Context a `SessionStart` hook injected into the session.""" + return self.payload.get("hookSpecificOutput", {}).get("additionalContext", "") + + +RunHook = Callable[..., HookResult] + + +def bash_payload(command: str) -> dict[str, Any]: + """Build the `PreToolUse` payload Claude Code sends for a Bash call. + + Parameters + ---------- + command + The shell command Claude proposed to run. + + Returns + ------- + dict[str, Any] + Payload shaped like the real hook input. + """ + return {"tool_name": "Bash", "tool_input": {"command": command}} + + +def edit_payload(file_path: str) -> dict[str, Any]: + """Build the `PostToolUse` payload Claude Code sends after an Edit or Write. + + Parameters + ---------- + file_path + Path of the file the tool touched. + + Returns + ------- + dict[str, Any] + Payload shaped like the real hook input. + """ + return {"tool_name": "Edit", "tool_input": {"file_path": file_path}} diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..87f69b9 --- /dev/null +++ b/tests/unit/__init__.py @@ -0,0 +1 @@ +"""Unit tests for the hook scripts.""" diff --git a/tests/unit/test_auto_lint_hook.py b/tests/unit/test_auto_lint_hook.py new file mode 100644 index 0000000..278b20e --- /dev/null +++ b/tests/unit/test_auto_lint_hook.py @@ -0,0 +1,37 @@ +"""Guard clauses of the `auto-lint` PostToolUse hook. + +The formatting path itself runs `uv run ruff` against the edited file, which +depends on the surrounding project's ruff configuration deciding whether that +path is in scope. That coupling makes it unsuitable for an assertion here, so +these tests cover the conditions under which the hook must do nothing at all — +the cases where running ruff would be wrong or impossible. +""" + +from pathlib import Path + +from tests.hook_harness import RunHook, edit_payload + + +def test_auto_lint_ignores_non_python_file(run_hook: RunHook, tmp_path: Path) -> None: + readme = tmp_path / "README.md" + readme.write_text("# Title\n", encoding="utf-8") + + result = run_hook("auto-lint", edit_payload(str(readme))) + + assert result.is_silent + assert result.exit_code == 0 + + +def test_auto_lint_ignores_deleted_file(run_hook: RunHook, tmp_path: Path) -> None: + """A file edited then removed must not make the hook fail the tool call.""" + result = run_hook("auto-lint", edit_payload(str(tmp_path / "gone.py"))) + + assert result.is_silent + assert result.exit_code == 0 + + +def test_auto_lint_ignores_payload_without_file_path(run_hook: RunHook) -> None: + result = run_hook("auto-lint", {"tool_name": "Edit", "tool_input": {}}) + + assert result.is_silent + assert result.exit_code == 0 diff --git a/tests/unit/test_enforce_uv_hook.py b/tests/unit/test_enforce_uv_hook.py new file mode 100644 index 0000000..9980611 --- /dev/null +++ b/tests/unit/test_enforce_uv_hook.py @@ -0,0 +1,72 @@ +"""Behaviour of the `enforce-uv` PreToolUse hook.""" + +import pytest + +from tests.hook_harness import RunHook, bash_payload + +# A single bare invocation is rewritten to run under uv rather than blocked. +REWRITTEN_COMMANDS = [ + ("python script.py", "uv run python script.py"), + ("python3 -c 'print(1)'", "uv run python3 -c 'print(1)'"), + ("pytest -x tests/", "uv run pytest -x tests/"), + ("ruff check .", "uv run ruff check ."), + ("mypy src", "uv run mypy src"), + ("bandit -r src", "uv run bandit -r src"), + # Leading whitespace is trimmed so the rewrite stays well formed. + (" pytest", "uv run pytest"), +] + +# pip has no mechanical uv equivalent, and compound commands are ambiguous to +# rewrite, so both are denied with guidance instead. +DENIED_COMMANDS = [ + "pip install requests", + "pip3 install -e .", + "pip list", + "python -m pip install requests", + "echo building && python setup.py", + "cat notes.txt | pytest", +] + +# Already correct, or nothing to do with the Python toolchain. +IGNORED_COMMANDS = [ + "uv run pytest", + "uv sync", + "uv add requests", + "uvx ruff check .", + "uv pip list", + "git status", + "ls -la", +] + + +@pytest.mark.parametrize(("command", "expected"), REWRITTEN_COMMANDS) +def test_enforce_uv_rewrites_bare_invocation( + run_hook: RunHook, command: str, expected: str +) -> None: + result = run_hook("enforce-uv", bash_payload(command)) + + assert result.permission_decision == "allow" + assert result.updated_command == expected + + +@pytest.mark.parametrize("command", DENIED_COMMANDS) +def test_enforce_uv_denies_unrewritable_command(run_hook: RunHook, command: str) -> None: + result = run_hook("enforce-uv", bash_payload(command)) + + assert result.permission_decision == "deny", ( + f"{command!r} should be denied, hook emitted: {result.stdout!r}" + ) + + +@pytest.mark.parametrize("command", IGNORED_COMMANDS) +def test_enforce_uv_ignores_command(run_hook: RunHook, command: str) -> None: + result = run_hook("enforce-uv", bash_payload(command)) + + assert result.is_silent, f"{command!r} should pass through, hook emitted: {result.stdout!r}" + assert result.exit_code == 0 + + +def test_enforce_uv_pip_reason_points_at_uv_add(run_hook: RunHook) -> None: + result = run_hook("enforce-uv", bash_payload("pip install requests")) + + assert "uv add" in result.reason diff --git a/tests/unit/test_guard_secrets_hook.py b/tests/unit/test_guard_secrets_hook.py new file mode 100644 index 0000000..6b85d47 --- /dev/null +++ b/tests/unit/test_guard_secrets_hook.py @@ -0,0 +1,65 @@ +"""Behaviour of the `guard-secrets` UserPromptSubmit hook. + +The credentials here are syntactically valid but deliberately fake. They are +also assembled at runtime from fragments rather than written out literally, so +the repository never contains a contiguous string matching a real credential +format — which would otherwise trip GitHub push protection and secret scanners +on a file whose whole purpose is to carry credential-shaped text. +""" + +import pytest + +from tests.hook_harness import RunHook + +_FILLER_36 = "A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8" +_ZEROS_24 = "0" * 24 + +PROMPTS_WITH_SECRETS = [ + # AWS publishes this exact key as its documentation example. + ("aws access key", "Use AKIA" + "IOSFODNN7EXAMPLE for the deploy"), + ("github pat", "token is gh" + "p_" + _FILLER_36), + ("github oauth token", "here: gh" + "o_" + _FILLER_36), + ("anthropic key", "export ANTHROPIC_API_KEY=sk-" + "ant-api03-" + _ZEROS_24), + ("google api key", "AIza" + "Sy" + "A" + "0" * 34), + ("slack token", "xox" + "b-0000000000-0000000000-abcdefghijklmnop"), + ("pem private key", "-----BEGIN RSA PRIVATE KEY" + "-----\nMIIE...\n"), +] + +BENIGN_PROMPTS = [ + "Refactor the auth service to use dependency injection", + "The API key lives in .env, load it with pydantic-settings", + "Why does AKIA appear in the AWS docs?", + "Explain what sk-ant keys are used for", + "What prefix do GitHub personal access tokens use?", +] + + +@pytest.mark.parametrize( + ("label", "prompt"), + PROMPTS_WITH_SECRETS, + ids=[label for label, _ in PROMPTS_WITH_SECRETS], +) +def test_guard_secrets_blocks_prompt_with_credential( + run_hook: RunHook, label: str, prompt: str +) -> None: + result = run_hook("guard-secrets", {"prompt": prompt}) + + assert result.decision == "block", ( + f"{label} should be blocked, hook emitted: {result.stdout!r}" + ) + assert "secret" in result.reason.lower() + + +@pytest.mark.parametrize("prompt", BENIGN_PROMPTS) +def test_guard_secrets_allows_benign_prompt(run_hook: RunHook, prompt: str) -> None: + result = run_hook("guard-secrets", {"prompt": prompt}) + + assert result.is_silent, f"{prompt!r} should pass through, hook emitted: {result.stdout!r}" + assert result.exit_code == 0 + + +def test_guard_secrets_ignores_empty_prompt(run_hook: RunHook) -> None: + result = run_hook("guard-secrets", {"prompt": ""}) + + assert result.is_silent + assert result.exit_code == 0 diff --git a/tests/unit/test_protect_main_hook.py b/tests/unit/test_protect_main_hook.py new file mode 100644 index 0000000..2eee44d --- /dev/null +++ b/tests/unit/test_protect_main_hook.py @@ -0,0 +1,63 @@ +"""Behaviour of the `protect-main` PreToolUse hook.""" + +import pytest + +from tests.hook_harness import RunHook, bash_payload + +DANGEROUS_COMMANDS = [ + "git push --force origin feature", + "git push -f origin feature", + "git push origin main", + "git push upstream master", + "git reset --hard HEAD~1", + "rm -rf /", + "rm -rf ~", + "rm -rf ~/", + "rm -rf .", + "rm -rf ..", + "rm -fr .", +] + +SAFE_COMMANDS = [ + # --force-with-lease is the sanctioned way to overwrite remote history. + "git push --force-with-lease origin feature", + "git push origin feature/some-work", + "git reset --soft HEAD~1", + # Specific targets that merely start with a guarded token. + "rm -rf node_modules/", + "rm -rf .git", + "rm -rf ~/tmp-build-dir", + "rm -rf ./build", + "ls -la", + "git status", +] + + +@pytest.mark.parametrize("command", DANGEROUS_COMMANDS) +def test_protect_main_denies_dangerous_command(run_hook: RunHook, command: str) -> None: + result = run_hook("protect-main", bash_payload(command)) + + assert result.permission_decision == "deny", ( + f"{command!r} should be blocked, hook emitted: {result.stdout!r}" + ) + + +@pytest.mark.parametrize("command", SAFE_COMMANDS) +def test_protect_main_allows_safe_command(run_hook: RunHook, command: str) -> None: + result = run_hook("protect-main", bash_payload(command)) + + assert result.is_silent, f"{command!r} should pass through, hook emitted: {result.stdout!r}" + assert result.exit_code == 0 + + +def test_protect_main_deny_reason_names_the_alternative(run_hook: RunHook) -> None: + result = run_hook("protect-main", bash_payload("git push --force origin main")) + + assert "--force-with-lease" in result.reason + + +def test_protect_main_ignores_payload_without_command(run_hook: RunHook) -> None: + result = run_hook("protect-main", {"tool_name": "Bash", "tool_input": {}}) + + assert result.is_silent + assert result.exit_code == 0 diff --git a/tests/unit/test_session_start_hook.py b/tests/unit/test_session_start_hook.py new file mode 100644 index 0000000..73e4bdb --- /dev/null +++ b/tests/unit/test_session_start_hook.py @@ -0,0 +1,65 @@ +"""Behaviour of the `session-start` SessionStart hook.""" + +import os +from pathlib import Path + +from tests.hook_harness import RunHook + +PAYLOAD = {"session_id": "test-session", "source": "startup"} +PYPROJECT_STUB = '[project]\nname = "demo"\nversion = "0.1.0"\n' + + +def _make_project(directory: Path) -> Path: + """Create a minimal uv-style project layout and return its pyproject path.""" + pyproject = directory / "pyproject.toml" + pyproject.write_text(PYPROJECT_STUB, encoding="utf-8") + return pyproject + + +def test_session_start_ignores_non_python_project(run_hook: RunHook, tmp_path: Path) -> None: + result = run_hook("session-start", PAYLOAD, cwd=tmp_path) + + assert result.is_silent + assert result.exit_code == 0 + + +def test_session_start_reports_missing_environment( + run_hook: RunHook, uv_executable: str, tmp_path: Path +) -> None: + _make_project(tmp_path) + + result = run_hook("session-start", PAYLOAD, cwd=tmp_path) + + assert ".venv" in result.additional_context + assert "uv.lock" in result.additional_context + + +def test_session_start_stays_silent_when_environment_is_ready( + run_hook: RunHook, uv_executable: str, tmp_path: Path +) -> None: + pyproject = _make_project(tmp_path) + (tmp_path / ".venv").mkdir() + lockfile = tmp_path / "uv.lock" + lockfile.write_text("", encoding="utf-8") + # The hook compares mtimes, so make the lockfile decisively newer. + fresh = pyproject.stat().st_mtime + 10 + os.utime(lockfile, (fresh, fresh)) + + result = run_hook("session-start", PAYLOAD, cwd=tmp_path) + + assert result.is_silent, f"expected no notes, hook emitted: {result.stdout!r}" + + +def test_session_start_flags_stale_lockfile( + run_hook: RunHook, uv_executable: str, tmp_path: Path +) -> None: + pyproject = _make_project(tmp_path) + (tmp_path / ".venv").mkdir() + lockfile = tmp_path / "uv.lock" + lockfile.write_text("", encoding="utf-8") + stale = pyproject.stat().st_mtime - 10 + os.utime(lockfile, (stale, stale)) + + result = run_hook("session-start", PAYLOAD, cwd=tmp_path) + + assert "uv sync" in result.additional_context diff --git a/tests/unit/test_verify_hook.py b/tests/unit/test_verify_hook.py new file mode 100644 index 0000000..a0c619a --- /dev/null +++ b/tests/unit/test_verify_hook.py @@ -0,0 +1,31 @@ +"""Guard clauses of the `verify` Stop hook. + +Only the early-exit paths are covered. The hook's main body shells out to +`uv run ruff check .` and `uv run pytest`, and exercising that from inside the +test suite would have it invoke pytest recursively. Provisioning a throwaway uv +project to run it in would need a network install on every CI run, so the +execution path is verified by hand rather than here. +""" + +from pathlib import Path + +from tests.hook_harness import RunHook + + +def test_verify_respects_the_loop_guard(run_hook: RunHook) -> None: + """A Stop triggered by this hook's own block must be allowed to end. + + Without this guard the hook would block every stop it caused, leaving the + session unable to finish a turn. + """ + result = run_hook("verify", {"stop_hook_active": True}) + + assert result.is_silent + assert result.exit_code == 0 + + +def test_verify_ignores_non_python_project(run_hook: RunHook, tmp_path: Path) -> None: + result = run_hook("verify", {"stop_hook_active": False}, cwd=tmp_path) + + assert result.is_silent + assert result.exit_code == 0