recx-cli 0.0.3__tar.gz → 0.0.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {recx_cli-0.0.3 → recx_cli-0.0.4}/PKG-INFO +1 -1
- {recx_cli-0.0.3 → recx_cli-0.0.4}/pyproject.toml +1 -1
- {recx_cli-0.0.3 → recx_cli-0.0.4}/src/recx_cli/cli.py +9 -2
- recx_cli-0.0.4/src/recx_cli/drift.py +178 -0
- {recx_cli-0.0.3 → recx_cli-0.0.4}/src/recx_cli/hook.py +67 -21
- recx_cli-0.0.4/tests/test_drift.py +424 -0
- {recx_cli-0.0.3 → recx_cli-0.0.4}/.gitignore +0 -0
- {recx_cli-0.0.3 → recx_cli-0.0.4}/LICENSE +0 -0
- {recx_cli-0.0.3 → recx_cli-0.0.4}/README.md +0 -0
- {recx_cli-0.0.3 → recx_cli-0.0.4}/spec/SPEC.md +0 -0
- {recx_cli-0.0.3 → recx_cli-0.0.4}/spec/examples/invalid/evidence-without-run.yaml +0 -0
- {recx_cli-0.0.3 → recx_cli-0.0.4}/spec/examples/invalid/extension-value-not-scalar.yaml +0 -0
- {recx_cli-0.0.3 → recx_cli-0.0.4}/spec/examples/invalid/http-url.yaml +0 -0
- {recx_cli-0.0.3 → recx_cli-0.0.4}/spec/examples/invalid/license-not-permissive.yaml +0 -0
- {recx_cli-0.0.3 → recx_cli-0.0.4}/spec/examples/invalid/missing-capabilities.yaml +0 -0
- {recx_cli-0.0.3 → recx_cli-0.0.4}/spec/examples/invalid/multiline-summary.yaml +0 -0
- {recx_cli-0.0.3 → recx_cli-0.0.4}/spec/examples/invalid/package-without-purl.yaml +0 -0
- {recx_cli-0.0.3 → recx_cli-0.0.4}/spec/examples/invalid/path-escapes-repo.yaml +0 -0
- {recx_cli-0.0.3 → recx_cli-0.0.4}/spec/examples/invalid/unknown-key.yaml +0 -0
- {recx_cli-0.0.3 → recx_cli-0.0.4}/spec/examples/invalid/unknown-version.yaml +0 -0
- {recx_cli-0.0.3 → recx_cli-0.0.4}/spec/examples/invalid-rules/rule1-duplicate-evidence-id.yaml +0 -0
- {recx_cli-0.0.3 → recx_cli-0.0.4}/spec/examples/invalid-rules/rule1-duplicate-module-id.yaml +0 -0
- {recx_cli-0.0.3 → recx_cli-0.0.4}/spec/examples/invalid-rules/rule2-covers-unknown-module.yaml +0 -0
- {recx_cli-0.0.3 → recx_cli-0.0.4}/spec/examples/invalid-rules/rule2-requires-unknown-module.yaml +0 -0
- {recx_cli-0.0.3 → recx_cli-0.0.4}/spec/examples/invalid-rules/rule5-over-64-kib.yaml +0 -0
- {recx_cli-0.0.3 → recx_cli-0.0.4}/spec/examples/invalid-rules/rule6-anchor-alias.yaml +0 -0
- {recx_cli-0.0.3 → recx_cli-0.0.4}/spec/examples/invalid-rules/rule6-duplicate-key.yaml +0 -0
- {recx_cli-0.0.3 → recx_cli-0.0.4}/spec/examples/invalid-rules/rule6-merge-key.yaml +0 -0
- {recx_cli-0.0.3 → recx_cli-0.0.4}/spec/examples/valid/full.yaml +0 -0
- {recx_cli-0.0.3 → recx_cli-0.0.4}/spec/examples/valid/minimal.yaml +0 -0
- {recx_cli-0.0.3 → recx_cli-0.0.4}/spec/recx.schema.json +0 -0
- {recx_cli-0.0.3 → recx_cli-0.0.4}/src/recx_cli/__init__.py +0 -0
- {recx_cli-0.0.3 → recx_cli-0.0.4}/src/recx_cli/__main__.py +0 -0
- {recx_cli-0.0.3 → recx_cli-0.0.4}/src/recx_cli/check.py +0 -0
- {recx_cli-0.0.3 → recx_cli-0.0.4}/src/recx_cli/evidence.py +0 -0
- {recx_cli-0.0.3 → recx_cli-0.0.4}/tests/test_check.py +0 -0
- {recx_cli-0.0.3 → recx_cli-0.0.4}/tests/test_evidence.py +0 -0
- {recx_cli-0.0.3 → recx_cli-0.0.4}/tests/test_hook.py +0 -0
|
@@ -76,15 +76,22 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
76
76
|
"RECX_STOP_HOOK=block (default) blocks the stop while RECX.yaml is invalid, "
|
|
77
77
|
"warn only gives feedback, off does nothing.",
|
|
78
78
|
)
|
|
79
|
+
hook_sub.add_parser(
|
|
80
|
+
"session-start",
|
|
81
|
+
help="SessionStart hook: record HEAD so the Stop hook can remind about manifest drift",
|
|
82
|
+
description="Read Claude Code's SessionStart hook JSON on stdin and record the "
|
|
83
|
+
"repository's HEAD for the session. Always exits 0 with no output. "
|
|
84
|
+
"RECX_DRIFT_REMINDER=off or RECX_STOP_HOOK=off does nothing.",
|
|
85
|
+
)
|
|
79
86
|
return parser
|
|
80
87
|
|
|
81
88
|
|
|
82
89
|
def main(argv: list[str] | None = None) -> int:
|
|
83
90
|
args = build_parser().parse_args(argv)
|
|
84
91
|
if args.command == "hook":
|
|
85
|
-
from recx_cli.hook import stop
|
|
92
|
+
from recx_cli.hook import session_start, stop
|
|
86
93
|
|
|
87
|
-
return stop()
|
|
94
|
+
return session_start() if args.hook_command == "session-start" else stop()
|
|
88
95
|
if args.command == "evidence":
|
|
89
96
|
return evidence(args.path, args.list, args.only)
|
|
90
97
|
target = Path(args.path)
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Drift reminder: a manifest can be valid and stale.
|
|
2
|
+
|
|
3
|
+
`recx hook session-start` records the repository's HEAD for the session.
|
|
4
|
+
`recx hook stop` then asks which modules have changed files since, and, when
|
|
5
|
+
RECX.yaml is not among them, reminds once per set of modules. The reminder is
|
|
6
|
+
only ever feedback, never a block. Callers swallow every exception raised here.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import subprocess
|
|
15
|
+
import tempfile
|
|
16
|
+
import time
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from recx_cli.check import parse
|
|
20
|
+
|
|
21
|
+
MANIFEST = "RECX.yaml"
|
|
22
|
+
SKILL = "recx:manifest"
|
|
23
|
+
_HEAD = re.compile(r"[0-9a-fA-F]{7,64}")
|
|
24
|
+
_SAFE_ID = re.compile(r"[A-Za-z0-9_-]{1,128}")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def enabled() -> bool:
|
|
28
|
+
return os.environ.get("RECX_DRIFT_REMINDER", "").strip().lower() != "off"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def state_dir() -> Path:
|
|
32
|
+
plugin_data = os.environ.get("CLAUDE_PLUGIN_DATA")
|
|
33
|
+
if plugin_data:
|
|
34
|
+
return Path(plugin_data)
|
|
35
|
+
base = os.environ.get("XDG_STATE_HOME") or str(Path.home() / ".local" / "state")
|
|
36
|
+
return Path(base) / "recx"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _state_file(session_id: str) -> Path | None:
|
|
40
|
+
if not isinstance(session_id, str) or not _SAFE_ID.fullmatch(session_id):
|
|
41
|
+
return None
|
|
42
|
+
return state_dir() / f"{session_id}.json"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def read_state(session_id: str) -> dict:
|
|
46
|
+
"""The session's state, or {} when absent, unreadable or corrupt."""
|
|
47
|
+
try:
|
|
48
|
+
path = _state_file(session_id)
|
|
49
|
+
if path is None:
|
|
50
|
+
return {}
|
|
51
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
52
|
+
if not isinstance(data, dict):
|
|
53
|
+
return {}
|
|
54
|
+
state: dict = {}
|
|
55
|
+
head = data.get("head")
|
|
56
|
+
if isinstance(head, str) and _HEAD.fullmatch(head):
|
|
57
|
+
state["head"] = head
|
|
58
|
+
reminded = data.get("reminded")
|
|
59
|
+
if isinstance(reminded, list):
|
|
60
|
+
state["reminded"] = [m for m in reminded if isinstance(m, str)]
|
|
61
|
+
return state
|
|
62
|
+
except Exception: # noqa: BLE001 - a bad state file is ignored
|
|
63
|
+
return {}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def write_state(session_id: str, state: dict) -> None:
|
|
67
|
+
path = _state_file(session_id)
|
|
68
|
+
if path is None:
|
|
69
|
+
return
|
|
70
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=".tmp-", suffix=".json")
|
|
72
|
+
try:
|
|
73
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
74
|
+
json.dump(state, fh)
|
|
75
|
+
os.replace(tmp, path)
|
|
76
|
+
except BaseException:
|
|
77
|
+
try:
|
|
78
|
+
os.unlink(tmp)
|
|
79
|
+
except OSError:
|
|
80
|
+
pass
|
|
81
|
+
raise
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _git(top: Path, *args: str) -> str:
|
|
85
|
+
out = subprocess.run(
|
|
86
|
+
["git", "-C", str(top), *args], capture_output=True, text=True, timeout=20, check=True
|
|
87
|
+
)
|
|
88
|
+
return out.stdout
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def head_of(top: Path) -> str | None:
|
|
92
|
+
value = _git(top, "rev-parse", "HEAD").strip()
|
|
93
|
+
return value if _HEAD.fullmatch(value) else None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
STATE_MAX_AGE_DAYS = 30
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def prune_state(max_age_days: int = STATE_MAX_AGE_DAYS) -> None:
|
|
100
|
+
"""Delete session state files older than max_age_days; one is written per session."""
|
|
101
|
+
try:
|
|
102
|
+
cutoff = time.time() - max_age_days * 86400
|
|
103
|
+
for path in state_dir().glob("*.json"):
|
|
104
|
+
try:
|
|
105
|
+
if path.stat().st_mtime < cutoff:
|
|
106
|
+
path.unlink()
|
|
107
|
+
except OSError:
|
|
108
|
+
continue
|
|
109
|
+
except OSError:
|
|
110
|
+
pass
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def session_start(session_id: str, top: Path) -> None:
|
|
114
|
+
"""Record HEAD for the session. Skips when there is no manifest or the reminder is off."""
|
|
115
|
+
if not enabled() or not (top / MANIFEST).is_file():
|
|
116
|
+
return
|
|
117
|
+
prune_state()
|
|
118
|
+
head = head_of(top)
|
|
119
|
+
if head:
|
|
120
|
+
write_state(session_id, {"head": head, "reminded": []})
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def changed_files(top: Path, head: str | None) -> set[str]:
|
|
124
|
+
files: set[str] = set()
|
|
125
|
+
for base in [head, "HEAD"] if head else ["HEAD"]:
|
|
126
|
+
try:
|
|
127
|
+
files.update(_git(top, "diff", "--name-only", "-z", base, "--").split("\0"))
|
|
128
|
+
break
|
|
129
|
+
except (OSError, subprocess.SubprocessError):
|
|
130
|
+
continue
|
|
131
|
+
files.update(_git(top, "ls-files", "--others", "--exclude-standard", "-z").split("\0"))
|
|
132
|
+
files.discard("")
|
|
133
|
+
return files
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def affected_modules(manifest: Path, files: set[str]) -> list[str]:
|
|
137
|
+
data = parse(manifest.read_text(encoding="utf-8"))
|
|
138
|
+
modules = data.get("modules") if isinstance(data, dict) else None
|
|
139
|
+
found: list[str] = []
|
|
140
|
+
for mod in modules if isinstance(modules, list) else []:
|
|
141
|
+
if not isinstance(mod, dict):
|
|
142
|
+
continue
|
|
143
|
+
mid, path = mod.get("id"), mod.get("path")
|
|
144
|
+
if not isinstance(mid, str) or not isinstance(path, str):
|
|
145
|
+
continue
|
|
146
|
+
if path.endswith("/"):
|
|
147
|
+
hit = any(f.startswith(path) for f in files)
|
|
148
|
+
else:
|
|
149
|
+
hit = path in files
|
|
150
|
+
if hit and mid not in found:
|
|
151
|
+
found.append(mid)
|
|
152
|
+
return found
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def reminder(session_id: str | None, top: Path) -> str | None:
|
|
156
|
+
"""The reminder text to emit now, or None. Records what it reminded about."""
|
|
157
|
+
if not enabled():
|
|
158
|
+
return None
|
|
159
|
+
state = read_state(session_id) if session_id else {}
|
|
160
|
+
files = changed_files(top, state.get("head"))
|
|
161
|
+
if MANIFEST in files:
|
|
162
|
+
return None
|
|
163
|
+
affected = affected_modules(top / MANIFEST, files)
|
|
164
|
+
already = set(state.get("reminded", []))
|
|
165
|
+
if not affected or set(affected) <= already:
|
|
166
|
+
return None
|
|
167
|
+
if session_id:
|
|
168
|
+
try:
|
|
169
|
+
write_state(session_id, {**state, "reminded": sorted(already | set(affected))})
|
|
170
|
+
except Exception: # noqa: BLE001 - failing to record must not lose the reminder
|
|
171
|
+
pass
|
|
172
|
+
names = ", ".join(affected)
|
|
173
|
+
return (
|
|
174
|
+
f"Reminder, not an error: files under these {MANIFEST} modules changed this session "
|
|
175
|
+
f"while {MANIFEST} did not: {names}. Check whether the manifest still describes them, "
|
|
176
|
+
f"using the {SKILL} skill, and update it if it does not. If it is still accurate, "
|
|
177
|
+
f"nothing needs doing."
|
|
178
|
+
)
|
|
@@ -13,6 +13,7 @@ import subprocess
|
|
|
13
13
|
import sys
|
|
14
14
|
from pathlib import Path
|
|
15
15
|
|
|
16
|
+
from recx_cli import drift
|
|
16
17
|
from recx_cli.check import Unreadable, check_file, format_problem
|
|
17
18
|
|
|
18
19
|
MANIFEST = "RECX.yaml"
|
|
@@ -41,44 +42,89 @@ def _manifest_dir(cwd: str) -> Path:
|
|
|
41
42
|
return start
|
|
42
43
|
|
|
43
44
|
|
|
44
|
-
def
|
|
45
|
-
if _mode() == "off":
|
|
46
|
-
return
|
|
45
|
+
def _read_event() -> tuple[dict, Path] | None:
|
|
47
46
|
raw = sys.stdin.read()
|
|
48
47
|
if not raw.strip():
|
|
49
|
-
return
|
|
48
|
+
return None
|
|
50
49
|
event = json.loads(raw)
|
|
51
50
|
if not isinstance(event, dict):
|
|
52
|
-
return
|
|
51
|
+
return None
|
|
53
52
|
cwd = event.get("cwd")
|
|
54
53
|
if not isinstance(cwd, str) or not cwd:
|
|
55
|
-
return
|
|
56
|
-
|
|
54
|
+
return None
|
|
55
|
+
return event, _manifest_dir(cwd)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _validity(event: dict, top: Path) -> tuple[bool, str | None]:
|
|
59
|
+
"""(manifest is valid, output to print). Knows nothing about drift."""
|
|
60
|
+
manifest = top / MANIFEST
|
|
57
61
|
if not manifest.is_file():
|
|
58
|
-
return
|
|
62
|
+
return False, None
|
|
59
63
|
try:
|
|
60
64
|
problems = check_file(manifest, repo=True)
|
|
61
65
|
except Unreadable:
|
|
62
|
-
return
|
|
66
|
+
return False, None
|
|
63
67
|
if not problems:
|
|
64
|
-
return
|
|
68
|
+
return True, None
|
|
65
69
|
lines = "\n".join(format_problem(MANIFEST, p) for p in problems)
|
|
66
70
|
if _mode() == "block" and event.get("stop_hook_active") is not True:
|
|
67
71
|
reason = (
|
|
68
72
|
f"{MANIFEST} is invalid. Fix every problem below, using the {SKILL} skill, "
|
|
69
73
|
f"then check it with `recx check` (or `uvx --from recx-cli recx check`):\n{lines}"
|
|
70
74
|
)
|
|
71
|
-
|
|
72
|
-
else
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
)
|
|
75
|
+
return False, json.dumps({"decision": "block", "reason": reason})
|
|
76
|
+
still = " still" if event.get("stop_hook_active") is True else ""
|
|
77
|
+
context = f"{MANIFEST} is{still} invalid. The {SKILL} skill explains how to fix it:\n{lines}"
|
|
78
|
+
return False, json.dumps(
|
|
79
|
+
{"hookSpecificOutput": {"hookEventName": "Stop", "additionalContext": context}}
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _drift(event: dict, top: Path) -> str | None:
|
|
84
|
+
try:
|
|
85
|
+
session = event.get("session_id")
|
|
86
|
+
text = drift.reminder(session if isinstance(session, str) else None, top)
|
|
87
|
+
except BaseException: # noqa: BLE001 - the drift path must never block or raise
|
|
88
|
+
return None
|
|
89
|
+
if text is None:
|
|
90
|
+
return None
|
|
91
|
+
return json.dumps({"hookSpecificOutput": {"hookEventName": "Stop", "additionalContext": text}})
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _run() -> None:
|
|
95
|
+
if _mode() == "off":
|
|
96
|
+
return
|
|
97
|
+
parsed = _read_event()
|
|
98
|
+
if parsed is None:
|
|
99
|
+
return
|
|
100
|
+
event, top = parsed
|
|
101
|
+
valid, output = _validity(event, top)
|
|
102
|
+
if output is not None:
|
|
103
|
+
print(output)
|
|
104
|
+
elif valid:
|
|
105
|
+
extra = _drift(event, top)
|
|
106
|
+
if extra is not None:
|
|
107
|
+
print(extra)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _session_start() -> None:
|
|
111
|
+
if _mode() == "off" or not drift.enabled():
|
|
112
|
+
return
|
|
113
|
+
parsed = _read_event()
|
|
114
|
+
if parsed is None:
|
|
115
|
+
return
|
|
116
|
+
event, top = parsed
|
|
117
|
+
session = event.get("session_id")
|
|
118
|
+
if isinstance(session, str):
|
|
119
|
+
drift.session_start(session, top)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def session_start() -> int:
|
|
123
|
+
try:
|
|
124
|
+
_session_start()
|
|
125
|
+
except BaseException: # noqa: BLE001 - a hook failure must not block the session
|
|
126
|
+
pass
|
|
127
|
+
return 0
|
|
82
128
|
|
|
83
129
|
|
|
84
130
|
def stop() -> int:
|
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from recx_cli import drift, hook
|
|
11
|
+
from recx_cli.cli import main
|
|
12
|
+
|
|
13
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
14
|
+
STOP_SCRIPT = ROOT / "plugin" / "scripts" / "stop-hook.sh"
|
|
15
|
+
START_SCRIPT = ROOT / "plugin" / "scripts" / "session-start-hook.sh"
|
|
16
|
+
|
|
17
|
+
MANIFEST = """recx: "0.1"
|
|
18
|
+
name: example
|
|
19
|
+
summary: An example project.
|
|
20
|
+
license: MIT
|
|
21
|
+
status: experimental
|
|
22
|
+
capabilities:
|
|
23
|
+
- do one thing
|
|
24
|
+
modules:
|
|
25
|
+
- id: lib
|
|
26
|
+
path: src/lib/
|
|
27
|
+
summary: The library.
|
|
28
|
+
interface:
|
|
29
|
+
type: library
|
|
30
|
+
entrypoints:
|
|
31
|
+
- "import lib"
|
|
32
|
+
extract:
|
|
33
|
+
method: copy
|
|
34
|
+
- id: tool
|
|
35
|
+
path: tool.py
|
|
36
|
+
summary: The tool.
|
|
37
|
+
interface:
|
|
38
|
+
type: cli
|
|
39
|
+
entrypoints:
|
|
40
|
+
- "python tool.py"
|
|
41
|
+
extract:
|
|
42
|
+
method: copy
|
|
43
|
+
"""
|
|
44
|
+
INVALID = MANIFEST.replace("status: experimental\n", "status: bogus\n")
|
|
45
|
+
GIT_ENV = {
|
|
46
|
+
"GIT_AUTHOR_NAME": "t",
|
|
47
|
+
"GIT_AUTHOR_EMAIL": "t@example.com",
|
|
48
|
+
"GIT_COMMITTER_NAME": "t",
|
|
49
|
+
"GIT_COMMITTER_EMAIL": "t@example.com",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@pytest.fixture(autouse=True)
|
|
54
|
+
def _env(monkeypatch, tmp_path):
|
|
55
|
+
for k in ("RECX_STOP_HOOK", "RECX_DRIFT_REMINDER"):
|
|
56
|
+
monkeypatch.delenv(k, raising=False)
|
|
57
|
+
monkeypatch.setenv("CLAUDE_PLUGIN_DATA", str(tmp_path / "data"))
|
|
58
|
+
for k, v in GIT_ENV.items():
|
|
59
|
+
monkeypatch.setenv(k, v)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def git(repo, *args):
|
|
63
|
+
return subprocess.run(["git", "-C", str(repo), *args], check=True, capture_output=True, text=True).stdout
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@pytest.fixture
|
|
67
|
+
def repo(tmp_path):
|
|
68
|
+
r = tmp_path / "repo"
|
|
69
|
+
(r / "src" / "lib").mkdir(parents=True)
|
|
70
|
+
(r / "src" / "lib" / "a.py").write_text("a = 1\n")
|
|
71
|
+
(r / "tool.py").write_text("print(1)\n")
|
|
72
|
+
(r / "RECX.yaml").write_text(MANIFEST)
|
|
73
|
+
subprocess.run(["git", "init", "-q", str(r)], check=True)
|
|
74
|
+
git(r, "add", "-A")
|
|
75
|
+
git(r, "commit", "-qm", "init")
|
|
76
|
+
return r
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def run(capsys, monkeypatch, sub, repo, session="s1", **kw):
|
|
80
|
+
ev = {"session_id": session, "cwd": str(repo), "stop_hook_active": False, **kw}
|
|
81
|
+
monkeypatch.setattr(sys, "stdin", io.StringIO(json.dumps(ev)))
|
|
82
|
+
code = main(["hook", sub])
|
|
83
|
+
return code, capsys.readouterr().out
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def start(capsys, monkeypatch, repo, **kw):
|
|
87
|
+
assert run(capsys, monkeypatch, "session-start", repo, **kw) == (0, "")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def stop(capsys, monkeypatch, repo, **kw):
|
|
91
|
+
return run(capsys, monkeypatch, "stop", repo, **kw)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def state_file(tmp_path, session="s1"):
|
|
95
|
+
return tmp_path / "data" / f"{session}.json"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def edit_lib(repo):
|
|
99
|
+
(repo / "src" / "lib" / "a.py").write_text("a = 2\n")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def reminder_of(out):
|
|
103
|
+
data = json.loads(out)
|
|
104
|
+
assert "decision" not in data
|
|
105
|
+
spec = data["hookSpecificOutput"]
|
|
106
|
+
assert spec["hookEventName"] == "Stop"
|
|
107
|
+
return spec["additionalContext"]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# -- session-start ---------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_session_start_records_head(capsys, monkeypatch, repo, tmp_path):
|
|
114
|
+
start(capsys, monkeypatch, repo)
|
|
115
|
+
state = json.loads(state_file(tmp_path).read_text())
|
|
116
|
+
assert state["head"] == git(repo, "rev-parse", "HEAD").strip()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_session_start_default_state_dir(capsys, monkeypatch, repo, tmp_path):
|
|
120
|
+
monkeypatch.delenv("CLAUDE_PLUGIN_DATA")
|
|
121
|
+
monkeypatch.setenv("XDG_STATE_HOME", str(tmp_path / "xdg"))
|
|
122
|
+
start(capsys, monkeypatch, repo)
|
|
123
|
+
assert (tmp_path / "xdg" / "recx" / "s1.json").is_file()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_session_start_skips_without_manifest(capsys, monkeypatch, repo, tmp_path):
|
|
127
|
+
(repo / "RECX.yaml").unlink()
|
|
128
|
+
start(capsys, monkeypatch, repo)
|
|
129
|
+
assert not (tmp_path / "data").exists()
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@pytest.mark.parametrize("var", ["RECX_DRIFT_REMINDER", "RECX_STOP_HOOK"])
|
|
133
|
+
def test_session_start_skips_when_off(capsys, monkeypatch, repo, tmp_path, var):
|
|
134
|
+
monkeypatch.setenv(var, "off")
|
|
135
|
+
start(capsys, monkeypatch, repo)
|
|
136
|
+
assert not (tmp_path / "data").exists()
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@pytest.mark.parametrize("stdin", ["", "{nope", "[]", '{"cwd": 3}', '{"session_id": "x"}'])
|
|
140
|
+
def test_session_start_bad_input_is_silent(capsys, monkeypatch, stdin):
|
|
141
|
+
monkeypatch.setattr(sys, "stdin", io.StringIO(stdin))
|
|
142
|
+
assert main(["hook", "session-start"]) == 0
|
|
143
|
+
assert capsys.readouterr().out == ""
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_session_start_unsafe_session_id_writes_nothing(capsys, monkeypatch, repo, tmp_path):
|
|
147
|
+
start(capsys, monkeypatch, repo, session="../evil")
|
|
148
|
+
assert not (tmp_path / "data").exists()
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def test_session_start_outside_git_is_silent(capsys, monkeypatch, tmp_path):
|
|
152
|
+
d = tmp_path / "plain"
|
|
153
|
+
d.mkdir()
|
|
154
|
+
(d / "RECX.yaml").write_text(MANIFEST)
|
|
155
|
+
start(capsys, monkeypatch, d)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# -- stop: the reminder ----------------------------------------------------
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def test_uncommitted_change_in_module_dir_reminds(capsys, monkeypatch, repo):
|
|
162
|
+
start(capsys, monkeypatch, repo)
|
|
163
|
+
edit_lib(repo)
|
|
164
|
+
code, out = stop(capsys, monkeypatch, repo)
|
|
165
|
+
text = reminder_of(out)
|
|
166
|
+
assert code == 0 and "lib" in text and "tool" not in text.split(":")[-1]
|
|
167
|
+
assert "recx:manifest" in text and "not an error" in text
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def test_file_module_path_matches_exactly(capsys, monkeypatch, repo):
|
|
171
|
+
start(capsys, monkeypatch, repo)
|
|
172
|
+
(repo / "tool.py").write_text("print(2)\n")
|
|
173
|
+
text = reminder_of(stop(capsys, monkeypatch, repo)[1])
|
|
174
|
+
assert text.rstrip(".").split("did not: ")[1].split(".")[0] == "tool"
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def test_sibling_with_shared_prefix_is_not_under_module(capsys, monkeypatch, repo):
|
|
178
|
+
start(capsys, monkeypatch, repo)
|
|
179
|
+
(repo / "src" / "library.py").write_text("x = 1\n")
|
|
180
|
+
(repo / "tool.py.bak").write_text("x\n")
|
|
181
|
+
assert stop(capsys, monkeypatch, repo) == (0, "")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def test_untracked_file_under_module_reminds(capsys, monkeypatch, repo):
|
|
185
|
+
start(capsys, monkeypatch, repo)
|
|
186
|
+
(repo / "src" / "lib" / "new.py").write_text("b = 1\n")
|
|
187
|
+
assert "lib" in reminder_of(stop(capsys, monkeypatch, repo)[1])
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def test_commits_since_recorded_head_count(capsys, monkeypatch, repo):
|
|
191
|
+
start(capsys, monkeypatch, repo)
|
|
192
|
+
edit_lib(repo)
|
|
193
|
+
git(repo, "commit", "-qam", "change lib")
|
|
194
|
+
assert git(repo, "status", "--porcelain") == ""
|
|
195
|
+
assert "lib" in reminder_of(stop(capsys, monkeypatch, repo)[1])
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def test_without_recorded_head_only_uncommitted_counts(capsys, monkeypatch, repo):
|
|
199
|
+
edit_lib(repo)
|
|
200
|
+
git(repo, "commit", "-qam", "committed before, no record")
|
|
201
|
+
assert stop(capsys, monkeypatch, repo) == (0, "")
|
|
202
|
+
(repo / "tool.py").write_text("print(3)\n")
|
|
203
|
+
assert "tool" in reminder_of(stop(capsys, monkeypatch, repo)[1])
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def test_corrupt_state_is_ignored_like_no_record(capsys, monkeypatch, repo, tmp_path):
|
|
207
|
+
state_file(tmp_path).parent.mkdir(parents=True)
|
|
208
|
+
state_file(tmp_path).write_text("{corrupt")
|
|
209
|
+
(repo / "tool.py").write_text("print(3)\n")
|
|
210
|
+
assert "tool" in reminder_of(stop(capsys, monkeypatch, repo)[1])
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def test_manifest_changed_means_no_reminder(capsys, monkeypatch, repo):
|
|
214
|
+
start(capsys, monkeypatch, repo)
|
|
215
|
+
edit_lib(repo)
|
|
216
|
+
(repo / "RECX.yaml").write_text(MANIFEST + "tags: [x]\n")
|
|
217
|
+
assert stop(capsys, monkeypatch, repo) == (0, "")
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def test_no_changes_no_reminder(capsys, monkeypatch, repo):
|
|
221
|
+
start(capsys, monkeypatch, repo)
|
|
222
|
+
assert stop(capsys, monkeypatch, repo) == (0, "")
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def test_reminds_once_then_only_when_set_grows(capsys, monkeypatch, repo):
|
|
226
|
+
start(capsys, monkeypatch, repo)
|
|
227
|
+
edit_lib(repo)
|
|
228
|
+
assert "lib" in reminder_of(stop(capsys, monkeypatch, repo)[1])
|
|
229
|
+
assert stop(capsys, monkeypatch, repo) == (0, "")
|
|
230
|
+
assert stop(capsys, monkeypatch, repo) == (0, "")
|
|
231
|
+
(repo / "tool.py").write_text("print(4)\n")
|
|
232
|
+
text = reminder_of(stop(capsys, monkeypatch, repo)[1])
|
|
233
|
+
assert "lib" in text and "tool" in text
|
|
234
|
+
assert stop(capsys, monkeypatch, repo) == (0, "")
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def test_other_session_is_reminded_separately(capsys, monkeypatch, repo):
|
|
238
|
+
start(capsys, monkeypatch, repo, session="a")
|
|
239
|
+
start(capsys, monkeypatch, repo, session="b")
|
|
240
|
+
edit_lib(repo)
|
|
241
|
+
assert reminder_of(stop(capsys, monkeypatch, repo, session="a")[1])
|
|
242
|
+
assert reminder_of(stop(capsys, monkeypatch, repo, session="b")[1])
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def test_reminder_keeps_recorded_head(capsys, monkeypatch, repo, tmp_path):
|
|
246
|
+
start(capsys, monkeypatch, repo)
|
|
247
|
+
before = json.loads(state_file(tmp_path).read_text())["head"]
|
|
248
|
+
edit_lib(repo)
|
|
249
|
+
stop(capsys, monkeypatch, repo)
|
|
250
|
+
state = json.loads(state_file(tmp_path).read_text())
|
|
251
|
+
assert state["head"] == before and state["reminded"] == ["lib"]
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
# -- switches and validity -------------------------------------------------
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def test_drift_off_silences_reminder_only(capsys, monkeypatch, repo):
|
|
258
|
+
start(capsys, monkeypatch, repo)
|
|
259
|
+
edit_lib(repo)
|
|
260
|
+
monkeypatch.setenv("RECX_DRIFT_REMINDER", "off")
|
|
261
|
+
assert stop(capsys, monkeypatch, repo) == (0, "")
|
|
262
|
+
(repo / "RECX.yaml").write_text(INVALID)
|
|
263
|
+
out = stop(capsys, monkeypatch, repo)[1]
|
|
264
|
+
assert json.loads(out)["decision"] == "block"
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def test_stop_hook_off_disables_everything(capsys, monkeypatch, repo):
|
|
268
|
+
start(capsys, monkeypatch, repo)
|
|
269
|
+
edit_lib(repo)
|
|
270
|
+
monkeypatch.setenv("RECX_STOP_HOOK", "off")
|
|
271
|
+
assert stop(capsys, monkeypatch, repo) == (0, "")
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
@pytest.mark.parametrize("mode", ["block", "warn"])
|
|
275
|
+
def test_invalid_manifest_gets_only_validity_output(capsys, monkeypatch, repo, mode):
|
|
276
|
+
start(capsys, monkeypatch, repo)
|
|
277
|
+
edit_lib(repo)
|
|
278
|
+
(repo / "RECX.yaml").write_text(INVALID)
|
|
279
|
+
monkeypatch.setenv("RECX_STOP_HOOK", mode)
|
|
280
|
+
code, out = stop(capsys, monkeypatch, repo)
|
|
281
|
+
assert code == 0 and len(out.strip().splitlines()) == 1
|
|
282
|
+
data = json.loads(out)
|
|
283
|
+
if mode == "block":
|
|
284
|
+
assert data["decision"] == "block"
|
|
285
|
+
else:
|
|
286
|
+
assert "invalid" in data["hookSpecificOutput"]["additionalContext"]
|
|
287
|
+
assert "Reminder" not in out
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def test_invalid_manifest_with_active_stop_gets_no_reminder(capsys, monkeypatch, repo):
|
|
291
|
+
start(capsys, monkeypatch, repo)
|
|
292
|
+
edit_lib(repo)
|
|
293
|
+
(repo / "RECX.yaml").write_text(INVALID)
|
|
294
|
+
out = stop(capsys, monkeypatch, repo, stop_hook_active=True)[1]
|
|
295
|
+
assert "Reminder" not in out and "invalid" in out
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
# -- failure isolation -----------------------------------------------------
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def test_no_git_never_blocks(capsys, monkeypatch, repo):
|
|
302
|
+
start(capsys, monkeypatch, repo)
|
|
303
|
+
edit_lib(repo)
|
|
304
|
+
monkeypatch.setenv("PATH", str(repo))
|
|
305
|
+
assert stop(capsys, monkeypatch, repo) == (0, "")
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def test_non_git_directory_is_silent(capsys, monkeypatch, tmp_path):
|
|
309
|
+
d = tmp_path / "plain"
|
|
310
|
+
(d / "src" / "lib").mkdir(parents=True)
|
|
311
|
+
(d / "tool.py").write_text("x\n")
|
|
312
|
+
(d / "RECX.yaml").write_text(MANIFEST)
|
|
313
|
+
assert stop(capsys, monkeypatch, d) == (0, "")
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def test_drift_exception_does_not_alter_validity_output(capsys, monkeypatch, repo):
|
|
317
|
+
def boom(*a, **k):
|
|
318
|
+
raise RuntimeError("boom")
|
|
319
|
+
|
|
320
|
+
monkeypatch.setattr(drift, "reminder", boom)
|
|
321
|
+
assert stop(capsys, monkeypatch, repo) == (0, "")
|
|
322
|
+
(repo / "RECX.yaml").write_text(INVALID)
|
|
323
|
+
assert json.loads(stop(capsys, monkeypatch, repo)[1])["decision"] == "block"
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def test_unwritable_state_dir_still_reminds(capsys, monkeypatch, repo, tmp_path):
|
|
327
|
+
start(capsys, monkeypatch, repo)
|
|
328
|
+
shutil_target = tmp_path / "data"
|
|
329
|
+
for p in shutil_target.iterdir():
|
|
330
|
+
p.unlink()
|
|
331
|
+
shutil_target.rmdir()
|
|
332
|
+
shutil_target.write_text("a file, not a directory")
|
|
333
|
+
edit_lib(repo)
|
|
334
|
+
assert "lib" in reminder_of(stop(capsys, monkeypatch, repo)[1])
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def test_missing_state_dir_is_fine(capsys, monkeypatch, repo):
|
|
338
|
+
edit_lib(repo)
|
|
339
|
+
assert "lib" in reminder_of(stop(capsys, monkeypatch, repo)[1])
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def test_unknown_recorded_head_falls_back_to_working_tree(capsys, monkeypatch, repo, tmp_path):
|
|
343
|
+
state_file(tmp_path).parent.mkdir(parents=True)
|
|
344
|
+
state_file(tmp_path).write_text(json.dumps({"head": "deadbeef" * 5, "reminded": []}))
|
|
345
|
+
edit_lib(repo)
|
|
346
|
+
assert "lib" in reminder_of(stop(capsys, monkeypatch, repo)[1])
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def test_hook_module_exports(monkeypatch):
|
|
350
|
+
assert callable(hook.session_start) and callable(hook.stop)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
# -- scripts ---------------------------------------------------------------
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def test_scripts_are_executable_in_git():
|
|
357
|
+
out = subprocess.run(
|
|
358
|
+
["git", "ls-files", "-s", "plugin/scripts"], cwd=ROOT, capture_output=True, text=True
|
|
359
|
+
).stdout
|
|
360
|
+
modes = {line.split()[-1]: line.split()[0] for line in out.splitlines()}
|
|
361
|
+
assert modes["plugin/scripts/session-start-hook.sh"] == "100755"
|
|
362
|
+
assert modes["plugin/scripts/stop-hook.sh"] == "100755"
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def test_hooks_json_registers_session_start():
|
|
366
|
+
data = json.loads((ROOT / "plugin" / "hooks" / "hooks.json").read_text())
|
|
367
|
+
cmd = data["hooks"]["SessionStart"][0]["hooks"][0]["command"]
|
|
368
|
+
assert cmd == '"${CLAUDE_PLUGIN_ROOT}"/scripts/session-start-hook.sh'
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def test_scripts_require_0_0_4():
|
|
372
|
+
for script in (START_SCRIPT, STOP_SCRIPT):
|
|
373
|
+
assert "recx-cli>=0.0.4" in script.read_text()
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def sh(script, stdin, env):
|
|
377
|
+
return subprocess.run(["/bin/sh", str(script)], input=stdin, capture_output=True, text=True, env=env)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def test_scripts_end_to_end(repo, tmp_path):
|
|
381
|
+
recx_dir = Path(sys.executable).parent
|
|
382
|
+
assert (recx_dir / "recx").exists()
|
|
383
|
+
env = {k: v for k, v in os.environ.items() if k not in ("VIRTUAL_ENV", "RECX_STOP_HOOK", "RECX_DRIFT_REMINDER")}
|
|
384
|
+
env["PATH"] = f"{recx_dir}{os.pathsep}{env['PATH']}"
|
|
385
|
+
env["CLAUDE_PLUGIN_DATA"] = str(tmp_path / "data")
|
|
386
|
+
env.update(GIT_ENV)
|
|
387
|
+
ev = json.dumps({"session_id": "e2e", "cwd": str(repo), "stop_hook_active": False})
|
|
388
|
+
|
|
389
|
+
r = sh(START_SCRIPT, ev, env)
|
|
390
|
+
assert (r.returncode, r.stdout) == (0, "")
|
|
391
|
+
assert json.loads((tmp_path / "data" / "e2e.json").read_text())["head"] == git(repo, "rev-parse", "HEAD").strip()
|
|
392
|
+
|
|
393
|
+
assert sh(STOP_SCRIPT, ev, env).stdout == ""
|
|
394
|
+
edit_lib(repo)
|
|
395
|
+
git(repo, "commit", "-qam", "change lib")
|
|
396
|
+
r = sh(STOP_SCRIPT, ev, env)
|
|
397
|
+
assert r.returncode == 0, r.stderr
|
|
398
|
+
assert "lib" in reminder_of(r.stdout) and "recx:manifest" in reminder_of(r.stdout)
|
|
399
|
+
assert sh(STOP_SCRIPT, ev, env).stdout == ""
|
|
400
|
+
|
|
401
|
+
env["RECX_DRIFT_REMINDER"] = "off"
|
|
402
|
+
(repo / "tool.py").write_text("print(9)\n")
|
|
403
|
+
assert sh(STOP_SCRIPT, ev, env).stdout == ""
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def test_session_start_script_is_silent_and_zero_without_recx(repo, tmp_path):
|
|
407
|
+
bindir = tmp_path / "bin"
|
|
408
|
+
bindir.mkdir()
|
|
409
|
+
env = {"PATH": str(bindir), "CLAUDE_PLUGIN_DATA": str(tmp_path / "data")}
|
|
410
|
+
r = sh(START_SCRIPT, json.dumps({"session_id": "x", "cwd": str(repo)}), env)
|
|
411
|
+
assert (r.returncode, r.stdout) == (0, "")
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def test_session_start_prunes_old_state_files(capsys, monkeypatch, tmp_path, repo):
|
|
415
|
+
data = tmp_path / "data"
|
|
416
|
+
data.mkdir(parents=True, exist_ok=True)
|
|
417
|
+
old, recent = data / "old-session.json", data / "recent-session.json"
|
|
418
|
+
for f in (old, recent):
|
|
419
|
+
f.write_text('{"head": "abcdef1", "reminded": []}')
|
|
420
|
+
month_ago = __import__("time").time() - 31 * 86400
|
|
421
|
+
os.utime(old, (month_ago, month_ago))
|
|
422
|
+
start(capsys, monkeypatch, repo)
|
|
423
|
+
assert not old.exists()
|
|
424
|
+
assert recent.exists() and state_file(tmp_path).exists()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{recx_cli-0.0.3 → recx_cli-0.0.4}/spec/examples/invalid-rules/rule1-duplicate-evidence-id.yaml
RENAMED
|
File without changes
|
{recx_cli-0.0.3 → recx_cli-0.0.4}/spec/examples/invalid-rules/rule1-duplicate-module-id.yaml
RENAMED
|
File without changes
|
{recx_cli-0.0.3 → recx_cli-0.0.4}/spec/examples/invalid-rules/rule2-covers-unknown-module.yaml
RENAMED
|
File without changes
|
{recx_cli-0.0.3 → recx_cli-0.0.4}/spec/examples/invalid-rules/rule2-requires-unknown-module.yaml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|