recx-cli 0.0.1__tar.gz → 0.0.2__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.1 → recx_cli-0.0.2}/PKG-INFO +18 -1
- {recx_cli-0.0.1 → recx_cli-0.0.2}/README.md +17 -0
- {recx_cli-0.0.1 → recx_cli-0.0.2}/pyproject.toml +1 -1
- {recx_cli-0.0.1 → recx_cli-0.0.2}/src/recx_cli/check.py +5 -0
- {recx_cli-0.0.1 → recx_cli-0.0.2}/src/recx_cli/cli.py +19 -2
- recx_cli-0.0.2/src/recx_cli/hook.py +89 -0
- {recx_cli-0.0.1 → recx_cli-0.0.2}/tests/test_check.py +2 -2
- recx_cli-0.0.2/tests/test_hook.py +219 -0
- {recx_cli-0.0.1 → recx_cli-0.0.2}/.gitignore +0 -0
- {recx_cli-0.0.1 → recx_cli-0.0.2}/LICENSE +0 -0
- {recx_cli-0.0.1 → recx_cli-0.0.2}/spec/SPEC.md +0 -0
- {recx_cli-0.0.1 → recx_cli-0.0.2}/spec/examples/invalid/evidence-without-run.yaml +0 -0
- {recx_cli-0.0.1 → recx_cli-0.0.2}/spec/examples/invalid/extension-value-not-scalar.yaml +0 -0
- {recx_cli-0.0.1 → recx_cli-0.0.2}/spec/examples/invalid/http-url.yaml +0 -0
- {recx_cli-0.0.1 → recx_cli-0.0.2}/spec/examples/invalid/license-not-permissive.yaml +0 -0
- {recx_cli-0.0.1 → recx_cli-0.0.2}/spec/examples/invalid/missing-capabilities.yaml +0 -0
- {recx_cli-0.0.1 → recx_cli-0.0.2}/spec/examples/invalid/multiline-summary.yaml +0 -0
- {recx_cli-0.0.1 → recx_cli-0.0.2}/spec/examples/invalid/package-without-purl.yaml +0 -0
- {recx_cli-0.0.1 → recx_cli-0.0.2}/spec/examples/invalid/path-escapes-repo.yaml +0 -0
- {recx_cli-0.0.1 → recx_cli-0.0.2}/spec/examples/invalid/unknown-key.yaml +0 -0
- {recx_cli-0.0.1 → recx_cli-0.0.2}/spec/examples/invalid/unknown-version.yaml +0 -0
- {recx_cli-0.0.1 → recx_cli-0.0.2}/spec/examples/invalid-rules/rule1-duplicate-evidence-id.yaml +0 -0
- {recx_cli-0.0.1 → recx_cli-0.0.2}/spec/examples/invalid-rules/rule1-duplicate-module-id.yaml +0 -0
- {recx_cli-0.0.1 → recx_cli-0.0.2}/spec/examples/invalid-rules/rule2-covers-unknown-module.yaml +0 -0
- {recx_cli-0.0.1 → recx_cli-0.0.2}/spec/examples/invalid-rules/rule2-requires-unknown-module.yaml +0 -0
- {recx_cli-0.0.1 → recx_cli-0.0.2}/spec/examples/invalid-rules/rule5-over-64-kib.yaml +0 -0
- {recx_cli-0.0.1 → recx_cli-0.0.2}/spec/examples/invalid-rules/rule6-anchor-alias.yaml +0 -0
- {recx_cli-0.0.1 → recx_cli-0.0.2}/spec/examples/invalid-rules/rule6-duplicate-key.yaml +0 -0
- {recx_cli-0.0.1 → recx_cli-0.0.2}/spec/examples/invalid-rules/rule6-merge-key.yaml +0 -0
- {recx_cli-0.0.1 → recx_cli-0.0.2}/spec/examples/valid/full.yaml +0 -0
- {recx_cli-0.0.1 → recx_cli-0.0.2}/spec/examples/valid/minimal.yaml +0 -0
- {recx_cli-0.0.1 → recx_cli-0.0.2}/spec/recx.schema.json +0 -0
- {recx_cli-0.0.1 → recx_cli-0.0.2}/src/recx_cli/__init__.py +0 -0
- {recx_cli-0.0.1 → recx_cli-0.0.2}/src/recx_cli/__main__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: recx-cli
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.2
|
|
4
4
|
Summary: Validator for RECX.yaml manifests: the recx check command.
|
|
5
5
|
Project-URL: Homepage, https://github.com/jbmv/recx
|
|
6
6
|
Project-URL: Repository, https://github.com/jbmv/recx
|
|
@@ -35,6 +35,23 @@ One line per problem, `<file>: <json-pointer>: <message>`, or `<file>: valid`. E
|
|
|
35
35
|
means valid, 1 invalid, 2 a usage error or an unreadable file. `recx check` does not yet
|
|
36
36
|
check that the license file agrees with `license` (rule 4 of the spec).
|
|
37
37
|
|
|
38
|
+
## The Claude Code plugin
|
|
39
|
+
|
|
40
|
+
The `recx` plugin adds the `/recx:manifest` skill, for writing and maintaining a `RECX.yaml`,
|
|
41
|
+
and a Stop hook that keeps the manifest valid as a session ends.
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
/plugin marketplace add jbmv/recx
|
|
45
|
+
/plugin install recx@recx
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
On Claude Code 2.1.275 or later, `/plugin install recx --marketplace jbmv/recx` does both in one
|
|
49
|
+
step. The hook runs `recx hook stop`, from `recx` on your PATH or else through `uvx`; it needs
|
|
50
|
+
recx-cli 0.0.2 or later, and a missing `recx` never blocks a session. `RECX_STOP_HOOK` sets it:
|
|
51
|
+
`block` (the default), `warn` for feedback only, or `off`.
|
|
52
|
+
|
|
53
|
+
To adopt recx in a repository with any coding agent, see [ADOPT.md](ADOPT.md).
|
|
54
|
+
|
|
38
55
|
The npm package `@jbmv/recx` ships the schema: `require('@jbmv/recx')` returns it.
|
|
39
56
|
|
|
40
57
|
This README is an interim one. The full README is still to come.
|
|
@@ -21,6 +21,23 @@ One line per problem, `<file>: <json-pointer>: <message>`, or `<file>: valid`. E
|
|
|
21
21
|
means valid, 1 invalid, 2 a usage error or an unreadable file. `recx check` does not yet
|
|
22
22
|
check that the license file agrees with `license` (rule 4 of the spec).
|
|
23
23
|
|
|
24
|
+
## The Claude Code plugin
|
|
25
|
+
|
|
26
|
+
The `recx` plugin adds the `/recx:manifest` skill, for writing and maintaining a `RECX.yaml`,
|
|
27
|
+
and a Stop hook that keeps the manifest valid as a session ends.
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
/plugin marketplace add jbmv/recx
|
|
31
|
+
/plugin install recx@recx
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
On Claude Code 2.1.275 or later, `/plugin install recx --marketplace jbmv/recx` does both in one
|
|
35
|
+
step. The hook runs `recx hook stop`, from `recx` on your PATH or else through `uvx`; it needs
|
|
36
|
+
recx-cli 0.0.2 or later, and a missing `recx` never blocks a session. `RECX_STOP_HOOK` sets it:
|
|
37
|
+
`block` (the default), `warn` for feedback only, or `off`.
|
|
38
|
+
|
|
39
|
+
To adopt recx in a repository with any coding agent, see [ADOPT.md](ADOPT.md).
|
|
40
|
+
|
|
24
41
|
The npm package `@jbmv/recx` ships the schema: `require('@jbmv/recx')` returns it.
|
|
25
42
|
|
|
26
43
|
This README is an interim one. The full README is still to come.
|
|
@@ -29,6 +29,11 @@ class Problem:
|
|
|
29
29
|
message: str
|
|
30
30
|
|
|
31
31
|
|
|
32
|
+
def format_problem(label: str, problem: Problem) -> str:
|
|
33
|
+
"""One problem as `recx check` prints it."""
|
|
34
|
+
return f"{label}: {problem.pointer}: {problem.message}"
|
|
35
|
+
|
|
36
|
+
|
|
32
37
|
# -- schema ---------------------------------------------------------------
|
|
33
38
|
|
|
34
39
|
|
|
@@ -7,7 +7,7 @@ import sys
|
|
|
7
7
|
from importlib import metadata
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
|
|
10
|
-
from recx_cli.check import Unreadable, check_file
|
|
10
|
+
from recx_cli.check import Unreadable, check_file, format_problem
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
def _version() -> str:
|
|
@@ -45,11 +45,28 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
45
45
|
action="store_true",
|
|
46
46
|
help="skip the checks that look at the repository (rule 3: module paths exist)",
|
|
47
47
|
)
|
|
48
|
+
hook = sub.add_parser(
|
|
49
|
+
"hook",
|
|
50
|
+
help="entry points for Claude Code hooks",
|
|
51
|
+
description="Commands that Claude Code hooks run. They read the hook's JSON on stdin.",
|
|
52
|
+
)
|
|
53
|
+
hook_sub = hook.add_subparsers(dest="hook_command", required=True, parser_class=_Parser)
|
|
54
|
+
hook_sub.add_parser(
|
|
55
|
+
"stop",
|
|
56
|
+
help="Stop hook: keep the repository's RECX.yaml valid as a session ends",
|
|
57
|
+
description="Read Claude Code's Stop hook JSON on stdin. Always exits 0. "
|
|
58
|
+
"RECX_STOP_HOOK=block (default) blocks the stop while RECX.yaml is invalid, "
|
|
59
|
+
"warn only gives feedback, off does nothing.",
|
|
60
|
+
)
|
|
48
61
|
return parser
|
|
49
62
|
|
|
50
63
|
|
|
51
64
|
def main(argv: list[str] | None = None) -> int:
|
|
52
65
|
args = build_parser().parse_args(argv)
|
|
66
|
+
if args.command == "hook":
|
|
67
|
+
from recx_cli.hook import stop
|
|
68
|
+
|
|
69
|
+
return stop()
|
|
53
70
|
target = Path(args.path)
|
|
54
71
|
if target.is_dir():
|
|
55
72
|
target = target / "RECX.yaml"
|
|
@@ -63,5 +80,5 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
63
80
|
print(f"{label}: valid")
|
|
64
81
|
return 0
|
|
65
82
|
for p in problems:
|
|
66
|
-
print(
|
|
83
|
+
print(format_problem(label, p))
|
|
67
84
|
return 1
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""`recx hook stop`: Claude Code's Stop hook, keeping a RECX.yaml valid as a session ends.
|
|
2
|
+
|
|
3
|
+
The hook must never trap a session on its own failure, so every error path
|
|
4
|
+
exits 0 without output. Only an invalid manifest produces output, and it is
|
|
5
|
+
always JSON on stdout with exit status 0.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from recx_cli.check import Unreadable, check_file, format_problem
|
|
17
|
+
|
|
18
|
+
MANIFEST = "RECX.yaml"
|
|
19
|
+
SKILL = "recx:manifest"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _mode() -> str:
|
|
23
|
+
value = os.environ.get("RECX_STOP_HOOK", "block").strip().lower()
|
|
24
|
+
return value if value in ("block", "warn", "off") else "block"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _manifest_dir(cwd: str) -> Path:
|
|
28
|
+
start = Path(cwd)
|
|
29
|
+
try:
|
|
30
|
+
out = subprocess.run(
|
|
31
|
+
["git", "-C", str(start), "rev-parse", "--show-toplevel"],
|
|
32
|
+
capture_output=True,
|
|
33
|
+
text=True,
|
|
34
|
+
timeout=10,
|
|
35
|
+
)
|
|
36
|
+
top = out.stdout.strip()
|
|
37
|
+
if out.returncode == 0 and top:
|
|
38
|
+
return Path(top)
|
|
39
|
+
except (OSError, subprocess.SubprocessError):
|
|
40
|
+
pass
|
|
41
|
+
return start
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _run() -> None:
|
|
45
|
+
if _mode() == "off":
|
|
46
|
+
return
|
|
47
|
+
raw = sys.stdin.read()
|
|
48
|
+
if not raw.strip():
|
|
49
|
+
return
|
|
50
|
+
event = json.loads(raw)
|
|
51
|
+
if not isinstance(event, dict):
|
|
52
|
+
return
|
|
53
|
+
cwd = event.get("cwd")
|
|
54
|
+
if not isinstance(cwd, str) or not cwd:
|
|
55
|
+
return
|
|
56
|
+
manifest = _manifest_dir(cwd) / MANIFEST
|
|
57
|
+
if not manifest.is_file():
|
|
58
|
+
return
|
|
59
|
+
try:
|
|
60
|
+
problems = check_file(manifest, repo=True)
|
|
61
|
+
except Unreadable:
|
|
62
|
+
return
|
|
63
|
+
if not problems:
|
|
64
|
+
return
|
|
65
|
+
lines = "\n".join(format_problem(MANIFEST, p) for p in problems)
|
|
66
|
+
if _mode() == "block" and event.get("stop_hook_active") is not True:
|
|
67
|
+
reason = (
|
|
68
|
+
f"{MANIFEST} is invalid. Fix every problem below, using the {SKILL} skill, "
|
|
69
|
+
f"then check it with `recx check` (or `uvx --from recx-cli recx check`):\n{lines}"
|
|
70
|
+
)
|
|
71
|
+
print(json.dumps({"decision": "block", "reason": reason}))
|
|
72
|
+
else:
|
|
73
|
+
still = " still" if event.get("stop_hook_active") is True else ""
|
|
74
|
+
context = (
|
|
75
|
+
f"{MANIFEST} is{still} invalid. The {SKILL} skill explains how to fix it:\n{lines}"
|
|
76
|
+
)
|
|
77
|
+
print(
|
|
78
|
+
json.dumps(
|
|
79
|
+
{"hookSpecificOutput": {"hookEventName": "Stop", "additionalContext": context}}
|
|
80
|
+
)
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def stop() -> int:
|
|
85
|
+
try:
|
|
86
|
+
_run()
|
|
87
|
+
except BaseException: # noqa: BLE001 - a hook failure must not block the session
|
|
88
|
+
pass
|
|
89
|
+
return 0
|
|
@@ -238,8 +238,8 @@ def test_build_produces_sdist_and_wheel(tmp_path):
|
|
|
238
238
|
out = tmp_path / "dist"
|
|
239
239
|
env = {k: v for k, v in __import__("os").environ.items() if k != "VIRTUAL_ENV"}
|
|
240
240
|
subprocess.run(["uv", "build", "--out-dir", str(out)], cwd=ROOT, check=True, capture_output=True, env=env)
|
|
241
|
-
wheels = list(out.glob("recx_cli-0.0.
|
|
242
|
-
sdists = list(out.glob("recx_cli-0.0.
|
|
241
|
+
wheels = list(out.glob("recx_cli-0.0.2-*.whl"))
|
|
242
|
+
sdists = list(out.glob("recx_cli-0.0.2.tar.gz"))
|
|
243
243
|
assert len(wheels) == 1 and len(sdists) == 1
|
|
244
244
|
with zipfile.ZipFile(wheels[0]) as z:
|
|
245
245
|
names = z.namelist()
|
|
@@ -0,0 +1,219 @@
|
|
|
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 hook
|
|
11
|
+
from recx_cli.cli import main
|
|
12
|
+
|
|
13
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
14
|
+
SCRIPT = ROOT / "plugin" / "scripts" / "stop-hook.sh"
|
|
15
|
+
|
|
16
|
+
VALID = """recx: "0.1"
|
|
17
|
+
name: example
|
|
18
|
+
summary: An example project.
|
|
19
|
+
license: MIT
|
|
20
|
+
status: experimental
|
|
21
|
+
capabilities:
|
|
22
|
+
- do one thing
|
|
23
|
+
"""
|
|
24
|
+
INVALID = VALID.replace("status: experimental\n", "status: bogus\n")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@pytest.fixture(autouse=True)
|
|
28
|
+
def _no_env(monkeypatch):
|
|
29
|
+
monkeypatch.delenv("RECX_STOP_HOOK", raising=False)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def stop(capsys, monkeypatch, stdin):
|
|
33
|
+
if not isinstance(stdin, str):
|
|
34
|
+
stdin = json.dumps(stdin)
|
|
35
|
+
monkeypatch.setattr(sys, "stdin", io.StringIO(stdin))
|
|
36
|
+
code = main(["hook", "stop"])
|
|
37
|
+
out = capsys.readouterr()
|
|
38
|
+
return code, out.out
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def event(cwd, **kw):
|
|
42
|
+
return {"hook_event_name": "Stop", "cwd": str(cwd), "stop_hook_active": False, **kw}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def git_init(path):
|
|
46
|
+
subprocess.run(["git", "init", "-q", str(path)], check=True)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_no_manifest_is_silent(capsys, monkeypatch, tmp_path):
|
|
50
|
+
assert stop(capsys, monkeypatch, event(tmp_path)) == (0, "")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_valid_manifest_is_silent(capsys, monkeypatch, tmp_path):
|
|
54
|
+
(tmp_path / "RECX.yaml").write_text(VALID)
|
|
55
|
+
assert stop(capsys, monkeypatch, event(tmp_path)) == (0, "")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_invalid_blocks_and_matches_recx_check(capsys, monkeypatch, tmp_path):
|
|
59
|
+
(tmp_path / "RECX.yaml").write_text(INVALID)
|
|
60
|
+
code, out = stop(capsys, monkeypatch, event(tmp_path))
|
|
61
|
+
assert code == 0
|
|
62
|
+
data = json.loads(out)
|
|
63
|
+
assert data["decision"] == "block" and "recx:manifest" in data["reason"]
|
|
64
|
+
assert main(["check", str(tmp_path / "RECX.yaml")]) == 1
|
|
65
|
+
check_lines = capsys.readouterr().out.splitlines()
|
|
66
|
+
assert check_lines
|
|
67
|
+
for line in check_lines:
|
|
68
|
+
assert line.split(": ", 1)[1] in data["reason"]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_manifest_at_git_toplevel_from_subdirectory(capsys, monkeypatch, tmp_path):
|
|
72
|
+
git_init(tmp_path)
|
|
73
|
+
(tmp_path / "RECX.yaml").write_text(INVALID)
|
|
74
|
+
sub = tmp_path / "a" / "b"
|
|
75
|
+
sub.mkdir(parents=True)
|
|
76
|
+
code, out = stop(capsys, monkeypatch, event(sub))
|
|
77
|
+
assert code == 0 and json.loads(out)["decision"] == "block"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_no_manifest_in_subdirectory_outside_git(capsys, monkeypatch, tmp_path):
|
|
81
|
+
sub = tmp_path / "sub"
|
|
82
|
+
sub.mkdir()
|
|
83
|
+
(tmp_path / "RECX.yaml").write_text(INVALID)
|
|
84
|
+
assert stop(capsys, monkeypatch, event(sub)) == (0, "")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@pytest.mark.parametrize("how", ["active", "warn"])
|
|
88
|
+
def test_feedback_without_block(capsys, monkeypatch, tmp_path, how):
|
|
89
|
+
(tmp_path / "RECX.yaml").write_text(INVALID)
|
|
90
|
+
if how == "warn":
|
|
91
|
+
monkeypatch.setenv("RECX_STOP_HOOK", "warn")
|
|
92
|
+
ev = event(tmp_path)
|
|
93
|
+
else:
|
|
94
|
+
ev = event(tmp_path, stop_hook_active=True)
|
|
95
|
+
code, out = stop(capsys, monkeypatch, ev)
|
|
96
|
+
data = json.loads(out)
|
|
97
|
+
assert code == 0 and "decision" not in data
|
|
98
|
+
spec = data["hookSpecificOutput"]
|
|
99
|
+
assert spec["hookEventName"] == "Stop" and "status" in spec["additionalContext"]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_off_does_not_read_anything(capsys, monkeypatch, tmp_path):
|
|
103
|
+
(tmp_path / "RECX.yaml").write_text(INVALID)
|
|
104
|
+
monkeypatch.setenv("RECX_STOP_HOOK", "off")
|
|
105
|
+
|
|
106
|
+
class Boom:
|
|
107
|
+
def read(self):
|
|
108
|
+
raise AssertionError("stdin read")
|
|
109
|
+
|
|
110
|
+
monkeypatch.setattr(sys, "stdin", Boom())
|
|
111
|
+
monkeypatch.setattr(hook, "check_file", lambda *a, **k: pytest.fail("manifest read"))
|
|
112
|
+
assert main(["hook", "stop"]) == 0
|
|
113
|
+
assert capsys.readouterr().out == ""
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_unrecognised_value_behaves_as_block(capsys, monkeypatch, tmp_path):
|
|
117
|
+
(tmp_path / "RECX.yaml").write_text(INVALID)
|
|
118
|
+
monkeypatch.setenv("RECX_STOP_HOOK", "sometimes")
|
|
119
|
+
_, out = stop(capsys, monkeypatch, event(tmp_path))
|
|
120
|
+
assert json.loads(out)["decision"] == "block"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@pytest.mark.parametrize(
|
|
124
|
+
"stdin", ["", " \n", "{not json", "[1, 2]", "null", '{"hook_event_name": "Stop"}', '{"cwd": 5}']
|
|
125
|
+
)
|
|
126
|
+
def test_bad_input_never_blocks(capsys, monkeypatch, stdin):
|
|
127
|
+
assert stop(capsys, monkeypatch, stdin) == (0, "")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_internal_error_never_blocks(capsys, monkeypatch, tmp_path):
|
|
131
|
+
(tmp_path / "RECX.yaml").write_text(INVALID)
|
|
132
|
+
|
|
133
|
+
def boom(*a, **k):
|
|
134
|
+
raise RuntimeError("boom")
|
|
135
|
+
|
|
136
|
+
monkeypatch.setattr(hook, "check_file", boom)
|
|
137
|
+
assert stop(capsys, monkeypatch, event(tmp_path)) == (0, "")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_unreadable_manifest_never_blocks(capsys, monkeypatch, tmp_path):
|
|
141
|
+
(tmp_path / "RECX.yaml").mkdir()
|
|
142
|
+
assert stop(capsys, monkeypatch, event(tmp_path)) == (0, "")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# -- the entry script ------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def run_script(stdin, env_extra=None, path=None):
|
|
149
|
+
env = {k: v for k, v in os.environ.items() if k not in ("VIRTUAL_ENV", "RECX_STOP_HOOK")}
|
|
150
|
+
if path is not None:
|
|
151
|
+
env["PATH"] = path
|
|
152
|
+
env.update(env_extra or {})
|
|
153
|
+
return subprocess.run(["/bin/sh", str(SCRIPT)], input=stdin, capture_output=True, text=True, env=env)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_script_is_executable_in_git():
|
|
157
|
+
out = subprocess.run(
|
|
158
|
+
["git", "ls-files", "-s", "plugin/scripts/stop-hook.sh"], cwd=ROOT, capture_output=True, text=True
|
|
159
|
+
).stdout
|
|
160
|
+
assert out.startswith("100755")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_hooks_json_runs_the_script():
|
|
164
|
+
data = json.loads((ROOT / "plugin" / "hooks" / "hooks.json").read_text())
|
|
165
|
+
cmd = data["hooks"]["Stop"][0]["hooks"][0]["command"]
|
|
166
|
+
assert cmd == '"${CLAUDE_PLUGIN_ROOT}"/scripts/stop-hook.sh'
|
|
167
|
+
assert "matcher" not in data["hooks"]["Stop"][0]
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def test_script_end_to_end_blocks_in_git_repo(tmp_path):
|
|
171
|
+
git_init(tmp_path)
|
|
172
|
+
(tmp_path / "RECX.yaml").write_text(INVALID)
|
|
173
|
+
sub = tmp_path / "src"
|
|
174
|
+
sub.mkdir()
|
|
175
|
+
recx_dir = Path(sys.executable).parent
|
|
176
|
+
assert (recx_dir / "recx").exists()
|
|
177
|
+
path = f"{recx_dir}{os.pathsep}{os.environ['PATH']}"
|
|
178
|
+
r = run_script(json.dumps(event(sub)), path=path)
|
|
179
|
+
assert r.returncode == 0, r.stderr
|
|
180
|
+
data = json.loads(r.stdout)
|
|
181
|
+
assert data["decision"] == "block" and "recx:manifest" in data["reason"]
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def test_script_off_exits_at_once(tmp_path):
|
|
185
|
+
(tmp_path / "RECX.yaml").write_text(INVALID)
|
|
186
|
+
r = run_script(json.dumps(event(tmp_path)), {"RECX_STOP_HOOK": "off"})
|
|
187
|
+
assert (r.returncode, r.stdout) == (0, "")
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def test_script_without_recx_or_uvx_is_silent(tmp_path):
|
|
191
|
+
(tmp_path / "RECX.yaml").write_text(INVALID)
|
|
192
|
+
bindir = tmp_path / "bin"
|
|
193
|
+
bindir.mkdir()
|
|
194
|
+
r = run_script(json.dumps(event(tmp_path)), path=str(bindir))
|
|
195
|
+
assert (r.returncode, r.stdout) == (0, "")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def test_script_with_outdated_recx_falls_through(tmp_path):
|
|
199
|
+
(tmp_path / "RECX.yaml").write_text(INVALID)
|
|
200
|
+
bindir = tmp_path / "bin"
|
|
201
|
+
bindir.mkdir()
|
|
202
|
+
old = bindir / "recx"
|
|
203
|
+
old.write_text("#!/bin/sh\nexit 2\n")
|
|
204
|
+
old.chmod(0o755)
|
|
205
|
+
r = run_script(json.dumps(event(tmp_path)), path=str(bindir))
|
|
206
|
+
assert (r.returncode, r.stdout) == (0, "")
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def test_script_never_passes_a_failing_status_through(tmp_path):
|
|
210
|
+
# Claude Code reads exit status 2 from a Stop hook as a block; uv exits 2 on
|
|
211
|
+
# some failures, so a failing uvx must not become a blocked session.
|
|
212
|
+
(tmp_path / "RECX.yaml").write_text(INVALID)
|
|
213
|
+
bindir = tmp_path / "bin"
|
|
214
|
+
bindir.mkdir()
|
|
215
|
+
uvx = bindir / "uvx"
|
|
216
|
+
uvx.write_text("#!/bin/sh\necho 'error: offline' >&2\nexit 2\n")
|
|
217
|
+
uvx.chmod(0o755)
|
|
218
|
+
r = run_script(json.dumps(event(tmp_path)), path=str(bindir))
|
|
219
|
+
assert (r.returncode, r.stdout) == (0, "")
|
|
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.1 → recx_cli-0.0.2}/spec/examples/invalid-rules/rule1-duplicate-evidence-id.yaml
RENAMED
|
File without changes
|
{recx_cli-0.0.1 → recx_cli-0.0.2}/spec/examples/invalid-rules/rule1-duplicate-module-id.yaml
RENAMED
|
File without changes
|
{recx_cli-0.0.1 → recx_cli-0.0.2}/spec/examples/invalid-rules/rule2-covers-unknown-module.yaml
RENAMED
|
File without changes
|
{recx_cli-0.0.1 → recx_cli-0.0.2}/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
|