claude-dev-env 2.20.0 → 2.21.1

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.
@@ -0,0 +1,173 @@
1
+ """Tests for the Codex apply_patch adapter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import io
7
+ import json
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ import pytest
12
+
13
+ _HOOK_DIRECTORY = Path(__file__).resolve().parent
14
+ _HOOKS_PARENT = _HOOK_DIRECTORY.parent
15
+ if str(_HOOK_DIRECTORY) not in sys.path:
16
+ sys.path.insert(0, str(_HOOK_DIRECTORY))
17
+ if str(_HOOKS_PARENT) not in sys.path:
18
+ sys.path.insert(0, str(_HOOKS_PARENT))
19
+
20
+ import code_rules_enforcer
21
+ import codex_apply_patch
22
+
23
+
24
+ def _run_codex_payload(
25
+ payload: dict[str, object],
26
+ monkeypatch: pytest.MonkeyPatch,
27
+ capsys: pytest.CaptureFixture[str],
28
+ ) -> str:
29
+ """Run the real enforcer entry point and return its stdout."""
30
+ monkeypatch.setattr(code_rules_enforcer.sys, "stdin", io.StringIO(json.dumps(payload)))
31
+ with contextlib.suppress(SystemExit):
32
+ code_rules_enforcer.main([])
33
+ return capsys.readouterr().out
34
+
35
+
36
+ def _production_directory(tmp_path: Path) -> Path:
37
+ """Return a temporary directory whose path carries production semantics."""
38
+ production_directory = tmp_path.parent / "codex-prod"
39
+ production_directory.mkdir(exist_ok=True)
40
+ return production_directory
41
+
42
+
43
+ def test_parse_codex_apply_patch_projects_every_multi_file_operation(
44
+ tmp_path: Path,
45
+ ) -> None:
46
+ """The parser returns pre-edit and post-edit content for update, add, and delete."""
47
+ updated_path = tmp_path / "updated.py"
48
+ deleted_path = tmp_path / "deleted.py"
49
+ updated_path.write_text("before\nkeep\n", encoding="utf-8")
50
+ deleted_path.write_text("remove\n", encoding="utf-8")
51
+ patch = (
52
+ "*** Begin Patch\n"
53
+ "*** Update File: updated.py\n"
54
+ "@@\n"
55
+ "-before\n"
56
+ "+after\n"
57
+ " keep\n"
58
+ "*** Add File: added.py\n"
59
+ "+new\n"
60
+ "*** Delete File: deleted.py\n"
61
+ "*** End Patch"
62
+ )
63
+
64
+ all_patch_files = codex_apply_patch.parse_codex_apply_patch(patch, str(tmp_path))
65
+
66
+ views_by_name = {
67
+ Path(each_patch.file_path).name: each_patch for each_patch in all_patch_files
68
+ }
69
+ assert views_by_name["updated.py"].prior_content == "before\nkeep\n"
70
+ assert views_by_name["updated.py"].post_content == "after\nkeep\n"
71
+ assert views_by_name["added.py"].prior_content == ""
72
+ assert views_by_name["added.py"].post_content == "new\n"
73
+ assert views_by_name["deleted.py"].prior_content == "remove\n"
74
+ assert views_by_name["deleted.py"].post_content == ""
75
+
76
+
77
+ def test_codex_payload_allows_declared_blast_radius(
78
+ tmp_path: Path,
79
+ monkeypatch: pytest.MonkeyPatch,
80
+ capsys: pytest.CaptureFixture[str],
81
+ ) -> None:
82
+ """A loop raise with a declared stopping scope passes the Codex hook."""
83
+ production_directory = _production_directory(tmp_path)
84
+ payload = {
85
+ "tool_name": "apply_patch",
86
+ "cwd": str(production_directory),
87
+ "tool_input": {
88
+ "command": (
89
+ "*** Begin Patch\n"
90
+ "*** Add File: module.py\n"
91
+ "+for each_member in all_members:\n"
92
+ "+ raise AssetItemBlocked()\n"
93
+ "*** End Patch"
94
+ )
95
+ },
96
+ }
97
+
98
+ stdout = _run_codex_payload(payload, monkeypatch, capsys)
99
+
100
+ assert stdout == ""
101
+
102
+
103
+ def test_codex_payload_blocks_undeclared_blast_radius(
104
+ tmp_path: Path,
105
+ monkeypatch: pytest.MonkeyPatch,
106
+ capsys: pytest.CaptureFixture[str],
107
+ ) -> None:
108
+ """A loop raise requires a stopping-scope declaration for acceptance."""
109
+ production_directory = _production_directory(tmp_path)
110
+ payload = {
111
+ "tool_name": "apply_patch",
112
+ "cwd": str(production_directory),
113
+ "tool_input": {
114
+ "command": (
115
+ "*** Begin Patch\n"
116
+ "*** Add File: module.py\n"
117
+ "+for each_member in all_members:\n"
118
+ "+ raise RuntimeError()\n"
119
+ "*** End Patch"
120
+ )
121
+ },
122
+ }
123
+
124
+ stdout = _run_codex_payload(payload, monkeypatch, capsys)
125
+
126
+ deny_payload = json.loads(stdout)
127
+ assert deny_payload["hookSpecificOutput"]["permissionDecision"] == "deny"
128
+ assert "blast radius" in deny_payload["hookSpecificOutput"]["permissionDecisionReason"]
129
+
130
+
131
+ def test_codex_payload_blocks_malformed_patch(
132
+ tmp_path: Path,
133
+ monkeypatch: pytest.MonkeyPatch,
134
+ capsys: pytest.CaptureFixture[str],
135
+ ) -> None:
136
+ """A malformed Codex patch returns a blocking diagnostic."""
137
+ production_directory = _production_directory(tmp_path)
138
+ payload = {
139
+ "tool_name": "apply_patch",
140
+ "cwd": str(production_directory),
141
+ "tool_input": {"command": "*** Begin Patch\n*** End Patch"},
142
+ }
143
+
144
+ stdout = _run_codex_payload(payload, monkeypatch, capsys)
145
+
146
+ deny_payload = json.loads(stdout)
147
+ assert deny_payload["hookSpecificOutput"]["permissionDecision"] == "deny"
148
+ assert "payload requires accepted patch markers" in deny_payload["hookSpecificOutput"]["permissionDecisionReason"]
149
+ def test_codex_payload_blocks_a_nul_containing_patch_path(
150
+ tmp_path: Path,
151
+ monkeypatch: pytest.MonkeyPatch,
152
+ capsys: pytest.CaptureFixture[str],
153
+ ) -> None:
154
+ """A path containing NUL is converted into the standard JSON deny response."""
155
+ production_directory = _production_directory(tmp_path)
156
+ payload = {
157
+ "tool_name": "apply_patch",
158
+ "cwd": str(production_directory),
159
+ "tool_input": {
160
+ "command": (
161
+ "*** Begin Patch\n"
162
+ "*** Add File: unsafe\x00.py\n"
163
+ "+raise RuntimeError()\n"
164
+ "*** End Patch"
165
+ )
166
+ },
167
+ }
168
+
169
+ stdout = _run_codex_payload(payload, monkeypatch, capsys)
170
+
171
+ deny_payload = json.loads(stdout)
172
+ assert deny_payload["hookSpecificOutput"]["permissionDecision"] == "deny"
173
+ assert "payload requires accepted patch markers" in deny_payload["hookSpecificOutput"]["permissionDecisionReason"]
@@ -0,0 +1,119 @@
1
+ """Production-path tests for the AskUserQuestion plain-language blocker."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import subprocess
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ import pytest
12
+
13
+ HOOK_PATH = Path(__file__).with_name("plain_language_blocker.py")
14
+
15
+
16
+ def _run_hook(
17
+ payload: dict[str, object], *, enabled: bool
18
+ ) -> subprocess.CompletedProcess[str]:
19
+ environment = os.environ.copy()
20
+ if enabled:
21
+ environment["CLAUDE_PROSE_STYLE_ENFORCEMENT"] = "1"
22
+ else:
23
+ environment.pop("CLAUDE_PROSE_STYLE_ENFORCEMENT", None)
24
+ return subprocess.run(
25
+ [sys.executable, str(HOOK_PATH)],
26
+ input=json.dumps(payload),
27
+ capture_output=True,
28
+ check=False,
29
+ env=environment,
30
+ text=True,
31
+ )
32
+
33
+
34
+ def _decision(result: subprocess.CompletedProcess[str]) -> str | None:
35
+ if not result.stdout:
36
+ return None
37
+ parsed_payload = json.loads(result.stdout)
38
+ return parsed_payload.get("hookSpecificOutput", {}).get("permissionDecision")
39
+
40
+
41
+ def test_default_off_allows_formal_question_word() -> None:
42
+ result = _run_hook(
43
+ {
44
+ "tool_name": "AskUserQuestion",
45
+ "tool_input": {
46
+ "questions": [{"question": "Should we utilize this path?"}]
47
+ },
48
+ },
49
+ enabled=False,
50
+ )
51
+ assert result.returncode == 0
52
+ assert result.stdout == ""
53
+
54
+
55
+ def test_enabled_blocks_formal_question_word() -> None:
56
+ result = _run_hook(
57
+ {
58
+ "tool_name": "AskUserQuestion",
59
+ "tool_input": {
60
+ "questions": [
61
+ {
62
+ "question": "Should we utilize this path?",
63
+ "options": [{"description": "Initiate the migration."}],
64
+ }
65
+ ]
66
+ },
67
+ },
68
+ enabled=True,
69
+ )
70
+ assert result.returncode == 0
71
+ assert _decision(result) == "deny"
72
+ assert "utilize -> use" in result.stdout
73
+ assert "initiate -> start" in result.stdout
74
+
75
+
76
+ def test_exact_code_url_and_path_text_is_exempt() -> None:
77
+ result = _run_hook(
78
+ {
79
+ "tool_name": "AskUserQuestion",
80
+ "tool_input": {
81
+ "questions": [
82
+ {
83
+ "question": (
84
+ "Use `utilize` in src/initiate.py or "
85
+ "https://example.test/utilize."
86
+ )
87
+ }
88
+ ]
89
+ },
90
+ },
91
+ enabled=True,
92
+ )
93
+ assert result.returncode == 0
94
+ assert result.stdout == ""
95
+
96
+
97
+ def test_other_tools_are_ignored() -> None:
98
+ result = _run_hook(
99
+ {
100
+ "tool_name": "Write",
101
+ "tool_input": {"content": "Utilize the existing helper."},
102
+ },
103
+ enabled=True,
104
+ )
105
+ assert result.returncode == 0
106
+ assert result.stdout == ""
107
+
108
+
109
+ @pytest.mark.parametrize("raw_input", ["", "[]", "not json"])
110
+ def test_invalid_input_is_fail_open(raw_input: str) -> None:
111
+ result = subprocess.run(
112
+ [sys.executable, str(HOOK_PATH)],
113
+ input=raw_input,
114
+ capture_output=True,
115
+ check=False,
116
+ text=True,
117
+ )
118
+ assert result.returncode == 0
119
+ assert result.stdout == ""
package/hooks/hooks.json CHANGED
@@ -22,6 +22,16 @@
22
22
  }
23
23
  ]
24
24
  },
25
+ {
26
+ "matcher": "apply_patch",
27
+ "hooks": [
28
+ {
29
+ "type": "command",
30
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/code_rules_enforcer.py",
31
+ "timeout": 60
32
+ }
33
+ ]
34
+ },
25
35
  {
26
36
  "matcher": "Bash|PowerShell",
27
37
  "hooks": [
@@ -80,6 +90,11 @@
80
90
  {
81
91
  "matcher": "AskUserQuestion",
82
92
  "hooks": [
93
+ {
94
+ "type": "command",
95
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/plain_language_blocker.py",
96
+ "timeout": 10
97
+ },
83
98
  {
84
99
  "type": "command",
85
100
  "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/ask_user_question_shape_blocker.py",
@@ -36,6 +36,7 @@ ADVISORY_LINE_THRESHOLD_SOFT = 400
36
36
  ADVISORY_LINE_THRESHOLD_HARD = 1000
37
37
 
38
38
  DENY_REASON_ISSUE_PREVIEW_COUNT = 10
39
+ VIOLATION_SEPARATOR = "; "
39
40
 
40
41
  ALL_BOOLEAN_NAME_PREFIXES: tuple[str, ...] = ("is_", "has_", "should_", "can_", "was_", "did_")
41
42
  UPPER_SNAKE_CONSTANT_PATTERN = re.compile(r"^[A-Z][A-Z0-9_]*$")
@@ -0,0 +1,26 @@
1
+ """Configuration for the AskUserQuestion plain-language blocker."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ ALL_PLAIN_LANGUAGE_TERM_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
8
+ (re.compile(r"\butilize\b", re.IGNORECASE), "use"),
9
+ (re.compile(r"\binitiate\b", re.IGNORECASE), "start"),
10
+ (re.compile(r"\bsufficient\b", re.IGNORECASE), "enough"),
11
+ (re.compile(r"\bprior to\b", re.IGNORECASE), "before"),
12
+ (re.compile(r"\bin order to\b", re.IGNORECASE), "to"),
13
+ )
14
+
15
+ FENCED_CODE_PATTERN = re.compile(r"```[\s\S]*?```")
16
+ INLINE_CODE_PATTERN = re.compile(r"`[^`\n]+`")
17
+ URL_PATTERN = re.compile(r"https?://\S+", re.IGNORECASE)
18
+ FILE_PATH_PATTERN = re.compile(
19
+ r"(?<!\w)(?:[A-Za-z]:[\\/]|\.\.?[\\/])?[\w.-]+(?:[\\/][\w.-]+)+"
20
+ )
21
+
22
+ PLAIN_LANGUAGE_BLOCK_PREFIX = "BLOCKED: [PLAIN_LANGUAGE] Use familiar words: "
23
+ PLAIN_LANGUAGE_TERM_SEPARATOR = "; "
24
+ PLAIN_LANGUAGE_NOTICE = (
25
+ "Plain-language check: use familiar words in the question and its options."
26
+ )
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "2.20.0",
3
+ "version": "2.21.1",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {