claude-dev-env 1.23.1 → 1.25.0

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.
Files changed (25) hide show
  1. package/docs/CODE_RULES.md +14 -1
  2. package/hooks/blocking/_gh_body_arg_utils.py +171 -13
  3. package/hooks/blocking/code-rules-enforcer.py +490 -15
  4. package/hooks/blocking/gh-body-arg-blocker.py +27 -21
  5. package/hooks/blocking/pr-description-enforcer.py +247 -11
  6. package/hooks/blocking/tdd-enforcer.py +208 -13
  7. package/hooks/blocking/test_code_rules_enforcer_any_type_ignore.py +116 -0
  8. package/hooks/blocking/test_code_rules_enforcer_banned_identifier.py +231 -0
  9. package/hooks/blocking/test_code_rules_enforcer_conftest_anchor.py +51 -0
  10. package/hooks/blocking/test_code_rules_enforcer_dot_test_pattern.py +55 -0
  11. package/hooks/blocking/test_code_rules_enforcer_fstring_scan.py +144 -0
  12. package/hooks/blocking/test_code_rules_enforcer_logger_fstring.py +102 -0
  13. package/hooks/blocking/test_code_rules_enforcer_magic_allowlist.py +76 -0
  14. package/hooks/blocking/test_code_rules_enforcer_naming_pattern.py +176 -0
  15. package/hooks/blocking/test_code_rules_enforcer_type_checking_scope.py +112 -0
  16. package/hooks/blocking/test_gh_body_arg_blocker.py +229 -2
  17. package/hooks/blocking/test_pr_description_enforcer.py +193 -3
  18. package/hooks/blocking/test_tdd_enforcer.py +249 -0
  19. package/hooks/validators/exempt_paths.py +99 -0
  20. package/hooks/validators/magic_value_checks.py +126 -26
  21. package/hooks/validators/test_magic_value_checks.py +356 -2
  22. package/package.json +1 -1
  23. package/rules/gh-body-file.md +11 -2
  24. package/skills/bugteam/SKILL.md +111 -59
  25. package/skills/searching-obsidian-vault/SKILL.md +131 -0
@@ -0,0 +1,249 @@
1
+ """Tests for tdd-enforcer hook (blocking behavior)."""
2
+
3
+ import importlib.util
4
+ import json
5
+ import os
6
+ import subprocess
7
+ import sys
8
+ import time
9
+ from pathlib import Path
10
+
11
+
12
+ SCRIPT_PATH = Path(__file__).parent / "tdd-enforcer.py"
13
+
14
+
15
+ def _load_production_module():
16
+ module_spec = importlib.util.spec_from_file_location("tdd_enforcer_under_test", SCRIPT_PATH)
17
+ assert module_spec is not None and module_spec.loader is not None
18
+ loaded_module = importlib.util.module_from_spec(module_spec)
19
+ module_spec.loader.exec_module(loaded_module)
20
+ return loaded_module
21
+
22
+
23
+ _PRODUCTION_MODULE = _load_production_module()
24
+ FRESHNESS_SECONDS = _PRODUCTION_MODULE._freshness_seconds()
25
+ STALE_MTIME_OFFSET_SECONDS = FRESHNESS_SECONDS + 60
26
+
27
+
28
+ def _run_hook_with_payload(payload: dict) -> subprocess.CompletedProcess[str]:
29
+ return subprocess.run(
30
+ [sys.executable, str(SCRIPT_PATH)],
31
+ input=json.dumps(payload),
32
+ text=True,
33
+ capture_output=True,
34
+ check=False,
35
+ )
36
+
37
+
38
+ def _make_write_payload(file_path: Path, content: str = "") -> dict:
39
+ return {
40
+ "tool_name": "Write",
41
+ "tool_input": {"file_path": str(file_path), "content": content},
42
+ }
43
+
44
+
45
+ def _decision_from(completed: subprocess.CompletedProcess[str]) -> str | None:
46
+ if not completed.stdout:
47
+ return None
48
+ parsed = json.loads(completed.stdout)
49
+ hook_output = parsed.get("hookSpecificOutput", {})
50
+ return hook_output.get("permissionDecision")
51
+
52
+
53
+ def _sandbox(tmp_path: Path) -> Path:
54
+ isolated_root = tmp_path / "sandbox"
55
+ isolated_root.mkdir()
56
+ (isolated_root / ".git").mkdir()
57
+ return isolated_root
58
+
59
+
60
+ def test_should_allow_when_sibling_test_file_exists_and_recent(tmp_path: Path) -> None:
61
+ sandbox = _sandbox(tmp_path)
62
+ production_file = sandbox / "orders.py"
63
+ production_file.write_text("def fulfill(): pass\n")
64
+ sibling_test = sandbox / "test_orders.py"
65
+ sibling_test.write_text("def test_fulfill(): pass\n")
66
+
67
+ completed = _run_hook_with_payload(_make_write_payload(production_file))
68
+
69
+ assert _decision_from(completed) == "allow"
70
+
71
+
72
+ def test_should_deny_when_no_test_file_exists(tmp_path: Path) -> None:
73
+ sandbox = _sandbox(tmp_path)
74
+ production_file = sandbox / "orders.py"
75
+ production_file.write_text("def fulfill(): pass\n")
76
+
77
+ completed = _run_hook_with_payload(_make_write_payload(production_file))
78
+
79
+ assert _decision_from(completed) == "deny"
80
+ parsed = json.loads(completed.stdout)
81
+ reason = parsed["hookSpecificOutput"]["permissionDecisionReason"]
82
+ assert "test_orders.py" in reason
83
+
84
+
85
+ def test_should_deny_when_test_file_exists_but_is_stale(tmp_path: Path) -> None:
86
+ sandbox = _sandbox(tmp_path)
87
+ production_file = sandbox / "orders.py"
88
+ production_file.write_text("def fulfill(): pass\n")
89
+ sibling_test = sandbox / "test_orders.py"
90
+ sibling_test.write_text("def test_fulfill(): pass\n")
91
+ stale_timestamp = time.time() - STALE_MTIME_OFFSET_SECONDS
92
+ os.utime(sibling_test, (stale_timestamp, stale_timestamp))
93
+
94
+ completed = _run_hook_with_payload(_make_write_payload(production_file))
95
+
96
+ assert _decision_from(completed) == "deny"
97
+
98
+
99
+ def test_should_allow_when_bypass_sentinel_present_in_content(tmp_path: Path) -> None:
100
+ sandbox = _sandbox(tmp_path)
101
+ production_file = sandbox / "orders.py"
102
+ content_with_sentinel = "# pragma: no-tdd-gate\ndef fulfill(): pass\n"
103
+
104
+ completed = _run_hook_with_payload(
105
+ _make_write_payload(production_file, content_with_sentinel)
106
+ )
107
+
108
+ assert _decision_from(completed) == "allow"
109
+
110
+
111
+ def test_should_skip_markdown_files_entirely(tmp_path: Path) -> None:
112
+ markdown_file = tmp_path / "notes.md"
113
+
114
+ completed = _run_hook_with_payload(_make_write_payload(markdown_file, "# Notes\n"))
115
+
116
+ assert completed.returncode == 0
117
+ assert completed.stdout.strip() == ""
118
+
119
+
120
+ def test_should_skip_test_files_entirely(tmp_path: Path) -> None:
121
+ test_file = tmp_path / "test_orders.py"
122
+
123
+ completed = _run_hook_with_payload(
124
+ _make_write_payload(test_file, "def test_fulfill(): pass\n")
125
+ )
126
+
127
+ assert completed.returncode == 0
128
+ assert completed.stdout.strip() == ""
129
+
130
+
131
+ def test_should_allow_when_tests_directory_sibling_has_fresh_test(
132
+ tmp_path: Path,
133
+ ) -> None:
134
+ sandbox = _sandbox(tmp_path)
135
+ package_dir = sandbox / "source"
136
+ package_dir.mkdir()
137
+ tests_dir = sandbox / "tests"
138
+ tests_dir.mkdir()
139
+ production_file = package_dir / "orders.py"
140
+ production_file.write_text("def fulfill(): pass\n")
141
+ matching_test = tests_dir / "test_orders.py"
142
+ matching_test.write_text("def test_fulfill(): pass\n")
143
+
144
+ completed = _run_hook_with_payload(_make_write_payload(production_file))
145
+
146
+ assert _decision_from(completed) == "allow"
147
+
148
+
149
+ def test_should_allow_tsx_when_dot_test_sibling_exists(tmp_path: Path) -> None:
150
+ sandbox = _sandbox(tmp_path)
151
+ production_file = sandbox / "Button.tsx"
152
+ production_file.write_text("export const Button = () => null;\n")
153
+ sibling_test = sandbox / "Button.test.tsx"
154
+ sibling_test.write_text("test('renders', () => {});\n")
155
+
156
+ completed = _run_hook_with_payload(_make_write_payload(production_file))
157
+
158
+ assert _decision_from(completed) == "allow"
159
+
160
+
161
+ def test_should_deny_when_test_file_has_no_test_evidence(tmp_path: Path) -> None:
162
+ sandbox = _sandbox(tmp_path)
163
+ production_file = sandbox / "orders.py"
164
+ production_file.write_text("def fulfill(): pass\n")
165
+ sibling_test = sandbox / "test_orders.py"
166
+ sibling_test.write_text("x = 1\n")
167
+
168
+ completed = _run_hook_with_payload(_make_write_payload(production_file))
169
+
170
+ assert _decision_from(completed) == "deny"
171
+
172
+
173
+ def test_should_allow_edit_when_bypass_sentinel_present_in_new_string(
174
+ tmp_path: Path,
175
+ ) -> None:
176
+ sandbox = _sandbox(tmp_path)
177
+ production_file = sandbox / "orders.py"
178
+ payload = {
179
+ "tool_name": "Edit",
180
+ "tool_input": {
181
+ "file_path": str(production_file),
182
+ "old_string": "pass",
183
+ "new_string": "# pragma: no-tdd-gate\npass",
184
+ },
185
+ }
186
+
187
+ completed = _run_hook_with_payload(payload)
188
+
189
+ assert _decision_from(completed) == "allow"
190
+
191
+
192
+ def test_should_deny_production_file_inside_directory_containing_skip_substring(
193
+ tmp_path: Path,
194
+ ) -> None:
195
+ sandbox = _sandbox(tmp_path)
196
+ mockingbird_directory = sandbox / "mockingbird"
197
+ mockingbird_directory.mkdir()
198
+ production_file = mockingbird_directory / "orders.py"
199
+ production_file.write_text("def fulfill(): pass\n")
200
+
201
+ completed = _run_hook_with_payload(_make_write_payload(production_file))
202
+
203
+ assert _decision_from(completed) == "deny"
204
+
205
+
206
+ def test_should_deny_when_test_file_contains_only_production_stem_without_test_function(
207
+ tmp_path: Path,
208
+ ) -> None:
209
+ sandbox = _sandbox(tmp_path)
210
+ production_file = sandbox / "orders.py"
211
+ production_file.write_text("def fulfill(): pass\n")
212
+ sibling_test = sandbox / "test_orders.py"
213
+ sibling_test.write_text("orders = 'mentioned but no test function'\n")
214
+
215
+ completed = _run_hook_with_payload(_make_write_payload(production_file))
216
+
217
+ assert _decision_from(completed) == "deny"
218
+
219
+
220
+ def test_should_allow_when_large_test_file_has_valid_test_function_past_first_chunk(
221
+ tmp_path: Path,
222
+ ) -> None:
223
+ sandbox = _sandbox(tmp_path)
224
+ production_file = sandbox / "orders.py"
225
+ production_file.write_text("def fulfill(): pass\n")
226
+ sibling_test = sandbox / "test_orders.py"
227
+ padding_byte_count = 250_000
228
+ padding_comment = "# " + ("x" * padding_byte_count) + "\n"
229
+ sibling_test.write_text(padding_comment + "def test_fulfill(): pass\n")
230
+
231
+ completed = _run_hook_with_payload(_make_write_payload(production_file))
232
+
233
+ assert _decision_from(completed) == "allow"
234
+
235
+
236
+ def test_directory_skip_components_exactly_matches_pre_fc61a8b_hardcoded_set() -> None:
237
+ expected_directory_skip_components = frozenset({
238
+ "conftest", "fixture", "fixtures", "mock", "mocks", "stub", "stubs",
239
+ })
240
+
241
+ actual_directory_skip_components = _PRODUCTION_MODULE._directory_skip_components()
242
+
243
+ assert actual_directory_skip_components == expected_directory_skip_components
244
+
245
+
246
+ def test_directory_skip_components_excludes_pluralized_conftest() -> None:
247
+ actual_directory_skip_components = _PRODUCTION_MODULE._directory_skip_components()
248
+
249
+ assert "conftests" not in actual_directory_skip_components
@@ -0,0 +1,99 @@
1
+ """Canonical path-exemption helpers shared between validator hooks.
2
+
3
+ Single source of truth for CONFIG / TEST / HOOK-INFRASTRUCTURE /
4
+ WORKFLOW-REGISTRY / MIGRATION path pattern sets. Both Pre-Write
5
+ (``code-rules-enforcer.py``) and pre-push (``magic_value_checks.py``)
6
+ scanners must short-circuit on the same file categories; drift between
7
+ the two produced the "inconsistent verdicts" bug this module prevents.
8
+
9
+ Matching is case-insensitive so paths like ``Config/foo.py`` or
10
+ ``src/Tests/test_x.py`` are treated the same on case-preserving
11
+ filesystems (macOS default, Windows NTFS) as on case-sensitive ones.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+
17
+ CONFIG_PATH_PATTERNS: frozenset[str] = frozenset(
18
+ {
19
+ "config/",
20
+ "config\\",
21
+ "/config.",
22
+ "\\config.",
23
+ "settings.py",
24
+ }
25
+ )
26
+
27
+ TEST_PATH_PATTERNS: frozenset[str] = frozenset(
28
+ {
29
+ "test_",
30
+ "_test.",
31
+ ".spec.",
32
+ "conftest",
33
+ "/tests/",
34
+ "\\tests\\",
35
+ "/tests.py",
36
+ "\\tests.py",
37
+ }
38
+ )
39
+
40
+ HOOK_INFRASTRUCTURE_PATTERNS: frozenset[str] = frozenset(
41
+ {
42
+ "/.claude/hooks/",
43
+ "\\.claude\\hooks\\",
44
+ "\\.claude/hooks/",
45
+ }
46
+ )
47
+
48
+ WORKFLOW_REGISTRY_PATTERNS: frozenset[str] = frozenset(
49
+ {
50
+ "/workflow/",
51
+ "\\workflow\\",
52
+ "_tab.py",
53
+ "/states.py",
54
+ "\\states.py",
55
+ "/modules.py",
56
+ "\\modules.py",
57
+ }
58
+ )
59
+
60
+ MIGRATION_PATH_PATTERNS: frozenset[str] = frozenset(
61
+ {
62
+ "/migrations/",
63
+ "\\migrations\\",
64
+ }
65
+ )
66
+
67
+
68
+ def is_config_file(file_path: str) -> bool:
69
+ path_lower = file_path.lower()
70
+ return any(pattern in path_lower for pattern in CONFIG_PATH_PATTERNS)
71
+
72
+
73
+ def is_test_file(file_path: str) -> bool:
74
+ path_lower = file_path.lower()
75
+ return any(pattern in path_lower for pattern in TEST_PATH_PATTERNS)
76
+
77
+
78
+ def is_hook_infrastructure(file_path: str) -> bool:
79
+ path_normalized = file_path.lower().replace("\\", "/")
80
+ return any(
81
+ pattern.replace("\\", "/") in path_normalized
82
+ for pattern in HOOK_INFRASTRUCTURE_PATTERNS
83
+ )
84
+
85
+
86
+ def is_workflow_registry_file(file_path: str) -> bool:
87
+ path_normalized = file_path.lower().replace("\\", "/")
88
+ return any(
89
+ pattern.replace("\\", "/") in path_normalized
90
+ for pattern in WORKFLOW_REGISTRY_PATTERNS
91
+ )
92
+
93
+
94
+ def is_migration_file(file_path: str) -> bool:
95
+ path_normalized = file_path.lower().replace("\\", "/")
96
+ return any(
97
+ pattern.replace("\\", "/") in path_normalized
98
+ for pattern in MIGRATION_PATH_PATTERNS
99
+ )
@@ -7,56 +7,156 @@ Note: Only checks for magic numbers. Magic string detection is not implemented.
7
7
  """
8
8
 
9
9
  import ast
10
+ import re
10
11
  import sys
11
12
  from pathlib import Path
12
- from typing import List, Set
13
+ from typing import Dict, FrozenSet, List, Set, Tuple, Type
13
14
 
15
+ from exempt_paths import (
16
+ is_config_file,
17
+ is_test_file,
18
+ )
14
19
  from validator_base import Violation
15
20
 
16
21
 
17
- ALLOWED_NUMBERS: Set[int] = frozenset({-1, 0, 1, 2, 100})
22
+ ALLOWED_NUMBERS: FrozenSet[int] = frozenset({-1, 0, 1})
23
+
24
+ _UPPER_SNAKE_NAME_PATTERN = re.compile(r"^[A-Z][A-Z0-9_]*$")
25
+
26
+ _CONTAINER_LITERAL_TYPES: Tuple[Type[ast.AST], ...] = (
27
+ ast.Dict,
28
+ ast.List,
29
+ ast.Tuple,
30
+ ast.Set,
31
+ )
18
32
 
19
33
 
20
34
  def check_magic_values(tree: ast.AST, filename: str) -> List[Violation]:
21
35
  violations: List[Violation] = []
22
-
23
- constant_names: Set[str] = set()
24
- for node in ast.walk(tree):
25
- if isinstance(node, ast.Assign):
26
- for target in node.targets:
27
- if isinstance(target, ast.Name) and target.id.isupper():
28
- constant_names.add(target.id)
36
+ negated_literal_ids: Set[int] = set()
37
+ parent_by_child_id = _build_parent_map(tree)
29
38
 
30
39
  for node in ast.walk(tree):
31
- if isinstance(node, ast.Constant):
32
- if isinstance(node.value, int) and node.value not in ALLOWED_NUMBERS:
33
- if not _is_in_constant_definition(node, tree):
34
- violations.append(
35
- Violation(
36
- filename,
37
- node.lineno,
38
- f"Magic number {node.value} - use named constant",
39
- )
40
+ if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.USub):
41
+ operand = node.operand
42
+ if isinstance(operand, ast.UnaryOp):
43
+ continue
44
+ if (
45
+ isinstance(operand, ast.Constant)
46
+ and isinstance(operand.value, int)
47
+ and not isinstance(operand.value, bool)
48
+ ):
49
+ negated_literal_ids.add(id(operand))
50
+ outermost_unary = _walk_up_unary_minus(node, parent_by_child_id)
51
+ negation_depth = _count_unary_minus_depth(outermost_unary)
52
+ unsigned_value = operand.value
53
+ signed_value = -unsigned_value if negation_depth % 2 == 1 else unsigned_value
54
+ if signed_value in ALLOWED_NUMBERS:
55
+ continue
56
+ if _is_in_constant_definition(outermost_unary, parent_by_child_id):
57
+ continue
58
+ violations.append(
59
+ Violation(
60
+ filename,
61
+ node.lineno,
62
+ f"Magic number {signed_value} - use named constant",
40
63
  )
64
+ )
65
+ continue
66
+ if isinstance(node, ast.Constant):
67
+ if not isinstance(node.value, int):
68
+ continue
69
+ if isinstance(node.value, bool):
70
+ continue
71
+ if id(node) in negated_literal_ids:
72
+ continue
73
+ if node.value in ALLOWED_NUMBERS:
74
+ continue
75
+ if _is_in_constant_definition(node, parent_by_child_id):
76
+ continue
77
+ violations.append(
78
+ Violation(
79
+ filename,
80
+ node.lineno,
81
+ f"Magic number {node.value} - use named constant",
82
+ )
83
+ )
41
84
 
42
85
  return violations
43
86
 
44
87
 
45
- def _is_in_constant_definition(node: ast.Constant, tree: ast.AST) -> bool:
88
+ def _build_parent_map(tree: ast.AST) -> Dict[int, ast.AST]:
89
+ parent_by_child_id: Dict[int, ast.AST] = {}
46
90
  for parent in ast.walk(tree):
47
- if isinstance(parent, ast.Assign):
48
- for target in parent.targets:
49
- if isinstance(target, ast.Name) and target.id.isupper():
50
- if parent.value is node:
51
- return True
91
+ for child in ast.iter_child_nodes(parent):
92
+ parent_by_child_id[id(child)] = parent
93
+ return parent_by_child_id
94
+
95
+
96
+ def _walk_up_unary_minus(
97
+ node: ast.UnaryOp,
98
+ parent_by_child_id: Dict[int, ast.AST],
99
+ ) -> ast.UnaryOp:
100
+ current: ast.UnaryOp = node
101
+ while True:
102
+ parent = parent_by_child_id.get(id(current))
103
+ if isinstance(parent, ast.UnaryOp) and isinstance(parent.op, ast.USub):
104
+ current = parent
105
+ continue
106
+ return current
107
+
108
+
109
+ def _count_unary_minus_depth(node: ast.UnaryOp) -> int:
110
+ depth = 0
111
+ current: ast.AST = node
112
+ while isinstance(current, ast.UnaryOp) and isinstance(current.op, ast.USub):
113
+ depth += 1
114
+ current = current.operand
115
+ return depth
116
+
117
+
118
+ def _is_in_constant_definition(
119
+ node: ast.AST,
120
+ parent_by_child_id: Dict[int, ast.AST],
121
+ ) -> bool:
122
+ current_node: ast.AST = node
123
+ while id(current_node) in parent_by_child_id:
124
+ parent = parent_by_child_id[id(current_node)]
125
+ if _is_upper_snake_constant_assignment(parent):
126
+ return True
127
+ if not isinstance(parent, _CONTAINER_LITERAL_TYPES):
128
+ return False
129
+ current_node = parent
52
130
  return False
53
131
 
54
132
 
133
+ def _is_upper_snake_constant_assignment(node: ast.AST) -> bool:
134
+ if isinstance(node, ast.Assign):
135
+ for target in node.targets:
136
+ if isinstance(target, ast.Name) and _is_upper_snake_name(target.id):
137
+ return True
138
+ return False
139
+ if isinstance(node, ast.AnnAssign):
140
+ target = node.target
141
+ return isinstance(target, ast.Name) and _is_upper_snake_name(target.id)
142
+ return False
143
+
144
+
145
+ def _is_upper_snake_name(name: str) -> bool:
146
+ return bool(_UPPER_SNAKE_NAME_PATTERN.match(name))
147
+
148
+
149
+ def _is_exempt_path(file_path: str) -> bool:
150
+ return is_test_file(file_path) or is_config_file(file_path)
151
+
152
+
55
153
  def validate_file(file_path: Path) -> List[Violation]:
56
154
  filename = str(file_path)
155
+ if _is_exempt_path(filename):
156
+ return []
57
157
  try:
58
- source = file_path.read_text(encoding="utf-8")
59
- tree = ast.parse(source)
158
+ source_bytes = file_path.read_bytes()
159
+ tree = ast.parse(source_bytes)
60
160
  except Exception as error:
61
161
  return [Violation(filename, 0, f"Error: {error}")]
62
162