diff --git a/.gitignore b/.gitignore index 6fbe4b4..c2324cd 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,4 @@ # Python bytecode __pycache__/ *.py[cod] +.pytest_cache/ diff --git a/hooks/_lib.py b/hooks/_lib.py index 1d0555a..e8fa9a2 100755 --- a/hooks/_lib.py +++ b/hooks/_lib.py @@ -4,9 +4,47 @@ Hooks receive JSON on stdin per the Claude Code hook spec, and may emit JSON on stdout or exit with code 2 to block. This module wraps both. """ import json +import os import sys +def get_repo_root(hook_file: str) -> str: + """Return the repo root given a hook's __file__ path. + + The plugin is installed at /hooks/.py. We walk up three levels + from __file__ to find the workspace root: hooks/ → repo/ → workspace/. + When __file__ is absolute (Claude Code invokes hooks with absolute paths), + the naive dirname chain overshoots by one level. + + Distinguishes the production layout (plugin installed as + //) from the dev layout (plugin IS the workspace) + by checking whether the hook path places 'hooks/' as a direct + subdirectory of the workspace. In dev layout, the hook is at + /hooks/.py so the relative path from workspace + to hook starts with 'hooks/'. In production, the relative path + starts with '/hooks/' so does NOT start with 'hooks/'. + + - Production layout: hook = //hooks/.py + hook_relative = /hooks/.py (doesn't start with 'hooks/') + → plugin repo is the workspace root → return repo. + - Dev layout: hook = /hooks/.py + hook_relative = hooks/.py (starts with 'hooks/') + → workspace IS the repo → return workspace. + """ + abs_hook = os.path.abspath(hook_file) + parent = os.path.dirname(abs_hook) # hooks/ + repo = os.path.dirname(parent) # repo/ + workspace = os.path.dirname(repo) # workspace/ (parent of repo) + + # Detect: is the workspace the repo? If the hook's relative path from + # workspace starts with 'hooks/', the workspace IS the repo (dev layout). + # Otherwise the plugin is a subdirectory of the workspace (production layout). + hook_relative = os.path.relpath(abs_hook, workspace) + if hook_relative.startswith("hooks/"): + return workspace # dev layout: workspace IS the repo + return repo # production layout: plugin is nested inside workspace + + def read_input() -> dict: """Parse stdin JSON. Empty input → empty dict.""" raw = sys.stdin.read().strip() diff --git a/hooks/session-start-context.py b/hooks/session-start-context.py index 8f418f6..c706fdc 100755 --- a/hooks/session-start-context.py +++ b/hooks/session-start-context.py @@ -6,9 +6,9 @@ import os import subprocess import sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from _lib import add_context, warn_to_stderr # noqa +from _lib import add_context, get_repo_root, warn_to_stderr # noqa -REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +REPO = get_repo_root(__file__) LEARNINGS = os.path.expanduser( "~/.claude/projects/-Users-hongming-Documents-GitHub-molecule-monorepo/memory/cron-learnings.jsonl" ) diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..88809f3 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,34 @@ +# Test Rationale — molecule-session-context + +## What this plugin does + +`molecule-session-context` provides a SessionStart hook (`session-start-context.py`) +that injects operational context into Claude's context window at session start: +recent cron-learnings from JSONL, freeze-file status, and open PR/issue counts +from GitHub. + +The hook reads `~/.claude/projects/.../memory/cron-learnings.jsonl` (written by +`molecule-cron-learnings`) and emits a JSON `additionalContext` payload to stdout. +It also queries `gh` for repo state and handles graceful degradation when +`gh` is unavailable or files are missing. + +## What is tested + +- `hooks/_lib.py` helpers: `read_input`, `emit`, `deny_pretooluse`, `add_context`, + `warn_to_stderr`, `get_repo_root` +- `hooks/session-start-context.py` logic: `tail()` JSONL reader, `gh_count()` + subprocess wrapper, full `main()` integration with mocked gh + tempfile learnings +- Hook error handling (exits 0 on exception, not crash) + +## What is NOT unit-tested (and why) + +- Real `gh` API calls — tested with `unittest.mock` subprocess interception +- Actual freeze-file reading — tested with mocked file I/O +- Integration with the workspace runtime's SessionStart hook harness — + requires a real Claude Code session; covered by smoke + integration tests + +## Running tests + +```bash +python -m pytest tests/ -v +``` diff --git a/tests/test_lib.py b/tests/test_lib.py index 9e0701c..2d1dba7 100644 --- a/tests/test_lib.py +++ b/tests/test_lib.py @@ -113,4 +113,39 @@ class TestAddContext: stdout2 = io.StringIO() with mock.patch("sys.stdout", stdout2): add_context(" ") - assert stdout2.getvalue() == "" \ No newline at end of file + assert stdout2.getvalue() == "" + + +class TestGetRepoRoot: + """Tests for get_repo_root helper (imported from _lib).""" + + def test_production_layout_returns_repo(self): + """Production: hook at //hooks/hook.py → return the plugin repo.""" + from _lib import get_repo_root + + # /tmp/my-repo/hooks/hook.py → workspace=/tmp, relpath="my-repo/hooks/hook.py" + # relpath does NOT start with "hooks/" → production → return repo + result = get_repo_root("/tmp/my-repo/hooks/session-start-context.py") + assert result == "/tmp/my-repo" + + def test_dev_layout_returns_workspace(self): + """Dev layout: hook at /hooks/hook.py → workspace IS the repo.""" + from _lib import get_repo_root + + # /workspace/hooks/hook.py → workspace=/workspace, relpath="hooks/hook.py" + # relpath starts with "hooks/" → dev layout → return workspace + result = get_repo_root("/workspace/hooks/session-start-context.py") + assert result == "/workspace" + + def test_real_repo_path_in_this_repo(self): + """Verify get_repo_root on the actual repo resolves to a valid root.""" + from _lib import get_repo_root + + # This test file is at: /tests/test_lib.py + # The hook is at: /hooks/session-start-context.py + hooks_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "hooks") + hook_file = os.path.join(hooks_dir, "session-start-context.py") + result = get_repo_root(hook_file) + # Sanity: result is an absolute path and is a directory + assert os.path.isabs(result), f"result {result!r} is not absolute" + assert os.path.isdir(result), f"result {result!r} is not a directory" \ No newline at end of file