Skip to content

Commit 49a9cd4

Browse files
committed
test(cursor): fix hook CI without committed .cursor/hooks.json
Test plugin manifest and install-project-hooks output instead of gitignored project hooks; add audit/gate coverage. Docs: hooks are generated locally, not committed.
1 parent 4d0a715 commit 49a9cd4

5 files changed

Lines changed: 65 additions & 17 deletions

File tree

docs/book/25-cursor-plugin.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -122,10 +122,12 @@ Cursor’s **Hooks** settings page lists hooks from `.cursor/hooks.json` (projec
122122
and `~/.cursor/hooks.json` (user) only. Hooks declared in the plugin manifest
123123
(`hooks/hooks.json` via `plugin.json`) are not included in that count.
124124

125-
Ship or install a **project** `.cursor/hooks.json` that invokes the plugin
126-
scripts (see `plugins/cursor-codeclone/scripts/install-project-hooks.py`). The
127-
CodeClone monorepo commits `.cursor/hooks.json` invoking
128-
`python …/hooks/run_hook.py` (cross-platform; no shell scripts).
125+
Install a **project** `.cursor/hooks.json` with
126+
`plugins/cursor-codeclone/scripts/install-project-hooks.py` (writes
127+
`.cursor/hooks.json` and `codeclone-hooks.json` under the target root). Do not
128+
commit generated hook files — they embed machine-local interpreter paths. This
129+
monorepo ignores `/.cursor/`; tests cover the plugin manifest `hooks/hooks.json`
130+
and the installer output.
129131

130132
Hooks follow these invariants:
131133

docs/cursor-plugin.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -145,9 +145,11 @@ The Hooks panel counts **project** and **user** hook files only:
145145
| User | `~/.cursor/hooks.json` | yes |
146146
| Plugin manifest | `hooks/hooks.json` in the plugin dir | **no** (may still run when the plugin is enabled; do not rely on the counter) |
147147

148-
For the CodeClone repository, commit `.cursor/hooks.json` (see
149-
`plugins/cursor-codeclone/scripts/install-project-hooks.py`). For other
150-
projects, run that script once per repo (`python`, not bash — Windows-safe).
148+
Generate `.cursor/hooks.json` per workspace with
149+
`plugins/cursor-codeclone/scripts/install-project-hooks.py` (uses the current
150+
interpreter; paths are machine-specific — **do not commit**). This monorepo
151+
ignores `/.cursor/` in `.gitignore`; CI tests the plugin manifest and installer
152+
instead of a checked-in project hook file.
151153

152154
### Hook events
153155

tests/test_audit_writer.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -523,6 +523,19 @@ def test_compact_payload_intent_checked() -> None:
523523
assert result["unexpected_files"] == 1
524524

525525

526+
def test_compact_payload_intent_queue_blocked() -> None:
527+
from codeclone.audit.events import (
528+
EVENT_INTENT_QUEUE_BLOCKED,
529+
compact_payload_for_event,
530+
)
531+
532+
result = compact_payload_for_event(
533+
event_type=EVENT_INTENT_QUEUE_BLOCKED,
534+
payload={"intent_id": "intent-abc", "blocking_count": 2},
535+
)
536+
assert result == {"intent_id": "intent-abc", "blocking_count": 2}
537+
538+
526539
def test_compact_payload_intent_cleared() -> None:
527540
"""Exercise intent cleared branch (lines 107-108)."""
528541
from codeclone.audit.events import EVENT_INTENT_CLEARED, compact_payload_for_event

tests/test_cursor_plugin_hooks.py

Lines changed: 40 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -453,18 +453,48 @@ def test_run_hook_dispatches_post_tool_use() -> None:
453453
assert "additional_context" in out
454454

455455

456-
def test_hooks_json_use_python_launcher_not_python3() -> None:
456+
def _assert_hook_commands_avoid_python3(payload: dict[str, object]) -> None:
457+
hooks = payload["hooks"]
458+
assert isinstance(hooks, dict)
459+
for entries in hooks.values():
460+
assert isinstance(entries, list)
461+
for entry in entries:
462+
assert isinstance(entry, dict)
463+
cmd = str(entry["command"])
464+
first = cmd.split()[0].strip('"')
465+
assert first not in {"python3", "python3.exe"}, cmd
466+
assert "run_hook.py" in cmd
467+
468+
469+
def test_plugin_hooks_json_use_python_launcher_not_python3() -> None:
470+
"""Plugin manifest hooks.json — not repo .cursor/hooks.json (local, gitignored)."""
457471
hooks_json = json.loads((_HOOKS_DIR / "hooks.json").read_text(encoding="utf-8"))
472+
_assert_hook_commands_avoid_python3(hooks_json)
473+
474+
475+
def test_install_project_hooks_writes_launcher_commands(tmp_path: Path) -> None:
476+
"""Project hooks are generated by install-project-hooks.py, not committed."""
477+
install_script = (
478+
_REPO_ROOT
479+
/ "plugins"
480+
/ "cursor-codeclone"
481+
/ "scripts"
482+
/ ("install-project-hooks.py")
483+
)
484+
result = subprocess.run(
485+
[sys.executable, str(install_script), str(tmp_path)],
486+
capture_output=True,
487+
text=True,
488+
timeout=10,
489+
)
490+
assert result.returncode == 0, result.stderr
458491
project_hooks = json.loads(
459-
(_REPO_ROOT / ".cursor" / "hooks.json").read_text(encoding="utf-8")
460-
)
461-
for payload in (hooks_json, project_hooks):
462-
for entries in payload["hooks"].values():
463-
for entry in entries:
464-
cmd = entry["command"]
465-
first = cmd.split()[0].strip('"')
466-
assert first not in {"python3", "python3.exe"}, cmd
467-
assert "run_hook.py" in cmd
492+
(tmp_path / ".cursor" / "hooks.json").read_text(encoding="utf-8")
493+
)
494+
_assert_hook_commands_avoid_python3(project_hooks)
495+
pre_cmd = project_hooks["hooks"]["preToolUse"][0]["command"]
496+
assert "pre-tool-use-gate" in pre_cmd
497+
assert "run_hook.py" in pre_cmd
468498

469499

470500
def test_hooks_no_bash_scripts_remain() -> None:

tests/test_workspace_intent_gate_errors.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ class _Config:
6868
)
6969

7070
assert gate_mod._record_from_payload(123) is None
71+
assert gate_mod._record_from_payload('{"version": 99}') is None
7172

7273

7374
def test_gate_decision_ignores_terminal_and_non_active_records() -> None:

0 commit comments

Comments
 (0)