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
@@ -1,15 +1,22 @@
1
1
  """Tests for magic value detection."""
2
2
 
3
3
  import ast
4
+ import tempfile
5
+ from pathlib import Path
4
6
 
5
7
  import pytest
6
8
 
7
9
  from magic_value_checks import (
8
10
  check_magic_values,
11
+ validate_file,
9
12
  )
10
13
  from validator_base import Violation
11
14
 
12
15
 
16
+ MAGIC_NUMBER_SOURCE = "x = 42\n"
17
+ NON_TEST_TEMPDIR_PREFIX = "src_"
18
+
19
+
13
20
  GOOD_NAMED_CONSTANTS = '''
14
21
  API_TIMEOUT_MS = 5000
15
22
  HASH_DELIMITER = "__"
@@ -29,8 +36,23 @@ ALLOWED_SMALL_NUMBERS = '''
29
36
  def process():
30
37
  count = 0
31
38
  increment = 1
32
- doubled = count * 2
33
- return count + 1
39
+ negative = -1
40
+ return count + increment + negative
41
+ '''
42
+
43
+ ALLOWED_NEGATIVE_ONE_IN_BINARY_EXPRESSION = '''
44
+ def process(total):
45
+ return total * -1
46
+ '''
47
+
48
+ ALLOWED_NEGATIVE_ONE_IN_RETURN = '''
49
+ def process():
50
+ return -1
51
+ '''
52
+
53
+ BAD_NEGATIVE_LITERAL_TWO = '''
54
+ def process():
55
+ return total * -2
34
56
  '''
35
57
 
36
58
  ALLOWED_EMPTY_STRING = '''
@@ -39,6 +61,114 @@ def process():
39
61
  return result
40
62
  '''
41
63
 
64
+ BAD_LITERAL_TWO = '''
65
+ def process():
66
+ doubled = something * 2
67
+ return doubled
68
+ '''
69
+
70
+ BAD_LITERAL_ONE_HUNDRED = '''
71
+ def process():
72
+ percentage = fraction * 100
73
+ return percentage
74
+ '''
75
+
76
+ ALLOWED_NEGATIVE_UPPER_CONSTANT_ASSIGNMENT = '''
77
+ LIMIT = -100
78
+ OFFSET = -5
79
+ '''
80
+
81
+ ALLOWED_TYPED_NEGATIVE_UPPER_CONSTANT = '''
82
+ LIMIT: int = -100
83
+ OFFSET: int = -5
84
+ '''
85
+
86
+ DOUBLE_NEGATED_LITERAL = '''
87
+ def process():
88
+ return --5
89
+ '''
90
+
91
+ DICT_VALUED_CONSTANT = '''
92
+ SETTINGS = {"timeout": 30, "retries": 5}
93
+ '''
94
+
95
+ TUPLE_VALUED_CONSTANT = '''
96
+ RETRY_DELAYS = (2, 4, 8)
97
+ '''
98
+
99
+ LIST_VALUED_CONSTANT = '''
100
+ PORTS = [8080, 8443, 9000]
101
+ '''
102
+
103
+ NESTED_DICT_VALUED_CONSTANT = '''
104
+ CONFIG = {"db": {"port": 5432}}
105
+ '''
106
+
107
+ ANNOTATED_SCALAR_CONSTANT = '''
108
+ TIMEOUT_MS: int = 5000
109
+ '''
110
+
111
+ ANNOTATED_DICT_VALUED_CONSTANT = '''
112
+ SETTINGS: dict[str, int] = {"timeout": 30}
113
+ '''
114
+
115
+ NON_CONSTANT_ASSIGNMENT_IN_FUNCTION = '''
116
+ def configure():
117
+ delay = 30
118
+ return delay
119
+ '''
120
+
121
+ SCALAR_MAGIC_NUMBER_OUTSIDE_CONSTANT = '''
122
+ def compute():
123
+ return 30 + 5000
124
+ '''
125
+
126
+ LEADING_UNDERSCORE_TARGET = '''
127
+ _PRIVATE = {"timeout": 30}
128
+ '''
129
+
130
+ DOUBLE_UNDERSCORE_TARGET = '''
131
+ __PRIVATE = {"timeout": 30}
132
+ '''
133
+
134
+ NON_CONTAINER_RHS_CALL = '''
135
+ CONFIG = some_factory(30, retries=5)
136
+ '''
137
+
138
+ NON_CONTAINER_RHS_BINOP = '''
139
+ WINDOW = base + 5000
140
+ '''
141
+
142
+ TUPLE_TARGET_ASSIGNMENT = '''
143
+ A, B = 1, 30
144
+ '''
145
+
146
+ AUG_ASSIGN_UPPER_SNAKE = '''
147
+ COUNTER = 0
148
+ COUNTER += 30
149
+ '''
150
+
151
+ CLASS_BODY_UPPER_SNAKE_CONSTANT = '''
152
+ class Foo:
153
+ TIMEOUT = 30
154
+ '''
155
+
156
+ ANNOTATED_LITERAL_TYPE = '''
157
+ X: Literal[42] = 42
158
+ '''
159
+
160
+ TRUE_LITERAL_IN_FUNCTION = '''
161
+ def configure():
162
+ is_enabled = True
163
+ return is_enabled
164
+ '''
165
+
166
+ FALSE_LITERAL_IN_FUNCTION = '''
167
+ def configure():
168
+ is_disabled = False
169
+ return is_disabled
170
+ '''
171
+
42
172
 
43
173
  class TestMagicValues:
44
174
  def test_named_constants_pass(self) -> None:
@@ -57,7 +187,231 @@ class TestMagicValues:
57
187
  violations = check_magic_values(tree, "test.py")
58
188
  assert violations == []
59
189
 
190
+ def test_negative_one_allowed_in_binary_expression(self) -> None:
191
+ tree = ast.parse(ALLOWED_NEGATIVE_ONE_IN_BINARY_EXPRESSION)
192
+ violations = check_magic_values(tree, "test.py")
193
+ assert violations == []
194
+
195
+ def test_negative_one_allowed_in_return_expression(self) -> None:
196
+ tree = ast.parse(ALLOWED_NEGATIVE_ONE_IN_RETURN)
197
+ violations = check_magic_values(tree, "test.py")
198
+ assert violations == []
199
+
200
+ def test_negative_literal_two_is_flagged_with_signed_value(self) -> None:
201
+ tree = ast.parse(BAD_NEGATIVE_LITERAL_TWO)
202
+ violations = check_magic_values(tree, "test.py")
203
+ assert len(violations) == 1
204
+ assert "-2" in violations[0].message
205
+
60
206
  def test_empty_string_allowed(self) -> None:
61
207
  tree = ast.parse(ALLOWED_EMPTY_STRING)
62
208
  violations = check_magic_values(tree, "test.py")
63
209
  assert violations == []
210
+
211
+ def test_check_magic_values_should_flag_literal_two_in_function_body(self) -> None:
212
+ tree = ast.parse(BAD_LITERAL_TWO)
213
+ violations = check_magic_values(tree, "test.py")
214
+ assert len(violations) == 1
215
+ assert "2" in violations[0].message
216
+
217
+ def test_check_magic_values_should_flag_literal_one_hundred_in_function_body(self) -> None:
218
+ tree = ast.parse(BAD_LITERAL_ONE_HUNDRED)
219
+ violations = check_magic_values(tree, "test.py")
220
+ assert len(violations) == 1
221
+ assert "100" in violations[0].message
222
+
223
+ def test_negative_upper_constant_assignment_allowed(self) -> None:
224
+ tree = ast.parse(ALLOWED_NEGATIVE_UPPER_CONSTANT_ASSIGNMENT)
225
+ violations = check_magic_values(tree, "test.py")
226
+ assert violations == []
227
+
228
+ def test_typed_negative_upper_constant_allowed(self) -> None:
229
+ tree = ast.parse(ALLOWED_TYPED_NEGATIVE_UPPER_CONSTANT)
230
+ violations = check_magic_values(tree, "test.py")
231
+ assert violations == []
232
+
233
+ def test_double_negation_reports_collapsed_positive_value(self) -> None:
234
+ tree = ast.parse(DOUBLE_NEGATED_LITERAL)
235
+ violations = check_magic_values(tree, "test.py")
236
+ assert len(violations) == 1
237
+ assert "5" in violations[0].message
238
+ assert "-5" not in violations[0].message
239
+
240
+ def test_should_exempt_numbers_inside_dict_valued_constant(self) -> None:
241
+ tree = ast.parse(DICT_VALUED_CONSTANT)
242
+ violations = check_magic_values(tree, "test.py")
243
+ assert violations == []
244
+
245
+ def test_should_exempt_numbers_inside_tuple_valued_constant(self) -> None:
246
+ tree = ast.parse(TUPLE_VALUED_CONSTANT)
247
+ violations = check_magic_values(tree, "test.py")
248
+ assert violations == []
249
+
250
+ def test_should_exempt_numbers_inside_list_valued_constant(self) -> None:
251
+ tree = ast.parse(LIST_VALUED_CONSTANT)
252
+ violations = check_magic_values(tree, "test.py")
253
+ assert violations == []
254
+
255
+ def test_should_exempt_numbers_inside_nested_dict_valued_constant(self) -> None:
256
+ tree = ast.parse(NESTED_DICT_VALUED_CONSTANT)
257
+ violations = check_magic_values(tree, "test.py")
258
+ assert violations == []
259
+
260
+ def test_should_exempt_numbers_inside_annotated_upper_snake_constant(self) -> None:
261
+ tree = ast.parse(ANNOTATED_SCALAR_CONSTANT)
262
+ violations = check_magic_values(tree, "test.py")
263
+ assert violations == []
264
+
265
+ def test_should_exempt_numbers_inside_annotated_dict_valued_constant(self) -> None:
266
+ tree = ast.parse(ANNOTATED_DICT_VALUED_CONSTANT)
267
+ violations = check_magic_values(tree, "test.py")
268
+ assert violations == []
269
+
270
+ def test_should_still_flag_numbers_in_function_body_assignments(self) -> None:
271
+ tree = ast.parse(NON_CONSTANT_ASSIGNMENT_IN_FUNCTION)
272
+ violations = check_magic_values(tree, "test.py")
273
+ assert len(violations) == 1
274
+ assert "30" in violations[0].message
275
+
276
+ def test_should_still_flag_scalar_magic_number_outside_constant(self) -> None:
277
+ tree = ast.parse(SCALAR_MAGIC_NUMBER_OUTSIDE_CONSTANT)
278
+ violations = check_magic_values(tree, "test.py")
279
+ flagged_numbers = {violation.message for violation in violations}
280
+ assert any("30" in message for message in flagged_numbers)
281
+ assert any("5000" in message for message in flagged_numbers)
282
+
283
+ def test_should_flag_literal_under_single_leading_underscore_target(self) -> None:
284
+ tree = ast.parse(LEADING_UNDERSCORE_TARGET)
285
+ violations = check_magic_values(tree, "test.py")
286
+ assert any("30" in violation.message for violation in violations)
287
+
288
+ def test_should_flag_literal_under_double_leading_underscore_target(self) -> None:
289
+ tree = ast.parse(DOUBLE_UNDERSCORE_TARGET)
290
+ violations = check_magic_values(tree, "test.py")
291
+ assert any("30" in violation.message for violation in violations)
292
+
293
+ def test_should_flag_literal_under_non_container_call_rhs(self) -> None:
294
+ tree = ast.parse(NON_CONTAINER_RHS_CALL)
295
+ violations = check_magic_values(tree, "test.py")
296
+ flagged_numbers = {violation.message for violation in violations}
297
+ assert any("30" in message for message in flagged_numbers)
298
+ assert any("5" in message for message in flagged_numbers)
299
+
300
+ def test_should_flag_literal_under_non_container_binop_rhs(self) -> None:
301
+ tree = ast.parse(NON_CONTAINER_RHS_BINOP)
302
+ violations = check_magic_values(tree, "test.py")
303
+ assert any("5000" in violation.message for violation in violations)
304
+
305
+ def test_tuple_target_assignment_flags_literal_when_no_upper_snake_target(
306
+ self,
307
+ ) -> None:
308
+ tree = ast.parse(TUPLE_TARGET_ASSIGNMENT)
309
+ violations = check_magic_values(tree, "test.py")
310
+ assert any("30" in violation.message for violation in violations)
311
+
312
+ def test_aug_assign_on_upper_snake_target_is_not_exempt(self) -> None:
313
+ tree = ast.parse(AUG_ASSIGN_UPPER_SNAKE)
314
+ violations = check_magic_values(tree, "test.py")
315
+ assert any("30" in violation.message for violation in violations)
316
+
317
+ def test_class_body_upper_snake_constant_is_exempt(self) -> None:
318
+ tree = ast.parse(CLASS_BODY_UPPER_SNAKE_CONSTANT)
319
+ violations = check_magic_values(tree, "test.py")
320
+ assert violations == []
321
+
322
+ def test_annotated_literal_type_flags_literal_inside_subscript_annotation(
323
+ self,
324
+ ) -> None:
325
+ tree = ast.parse(ANNOTATED_LITERAL_TYPE)
326
+ violations = check_magic_values(tree, "test.py")
327
+ assert any("42" in violation.message for violation in violations)
328
+
329
+ def test_check_magic_values_should_not_flag_True_literal(self) -> None:
330
+ tree = ast.parse(TRUE_LITERAL_IN_FUNCTION)
331
+ violations = check_magic_values(tree, "test.py")
332
+ assert violations == []
333
+
334
+ def test_check_magic_values_should_not_flag_False_literal(self) -> None:
335
+ tree = ast.parse(FALSE_LITERAL_IN_FUNCTION)
336
+ violations = check_magic_values(tree, "test.py")
337
+ assert violations == []
338
+
339
+
340
+ class TestValidateFilePathExemptions:
341
+ def test_validate_file_should_skip_test_underscore_py_files(
342
+ self, tmp_path: Path
343
+ ) -> None:
344
+ tests_directory = tmp_path / "tests"
345
+ tests_directory.mkdir()
346
+ test_module_path = tests_directory / "test_foo.py"
347
+ test_module_path.write_text(MAGIC_NUMBER_SOURCE, encoding="utf-8")
348
+
349
+ assert validate_file(test_module_path) == []
350
+
351
+ def test_validate_file_should_skip_config_directory(
352
+ self, tmp_path: Path
353
+ ) -> None:
354
+ config_directory = tmp_path / "config"
355
+ config_directory.mkdir()
356
+ constants_path = config_directory / "constants.py"
357
+ constants_path.write_text(MAGIC_NUMBER_SOURCE, encoding="utf-8")
358
+
359
+ assert validate_file(constants_path) == []
360
+
361
+ def test_validate_file_should_skip_settings_py(self, tmp_path: Path) -> None:
362
+ settings_path = tmp_path / "settings.py"
363
+ settings_path.write_text(MAGIC_NUMBER_SOURCE, encoding="utf-8")
364
+
365
+ assert validate_file(settings_path) == []
366
+
367
+ def test_validate_file_should_skip_uppercase_config_directory(
368
+ self, tmp_path: Path
369
+ ) -> None:
370
+ """Case-insensitive match so Config/ on case-preserving filesystems skips."""
371
+ uppercase_config_directory = tmp_path / "Config"
372
+ uppercase_config_directory.mkdir()
373
+ constants_path = uppercase_config_directory / "constants.py"
374
+ constants_path.write_text(MAGIC_NUMBER_SOURCE, encoding="utf-8")
375
+
376
+ assert validate_file(constants_path) == []
377
+
378
+ def test_validate_file_should_skip_mixed_case_tests_directory(
379
+ self, tmp_path: Path
380
+ ) -> None:
381
+ """Case-insensitive match so src/Tests/ short-circuits the same way src/tests/ does."""
382
+ mixed_case_tests_directory = tmp_path / "Tests"
383
+ mixed_case_tests_directory.mkdir()
384
+ test_module_path = mixed_case_tests_directory / "test_foo.py"
385
+ test_module_path.write_text(MAGIC_NUMBER_SOURCE, encoding="utf-8")
386
+
387
+ assert validate_file(test_module_path) == []
388
+
389
+ def test_validate_file_should_skip_conftest_helpers(
390
+ self, tmp_path: Path
391
+ ) -> None:
392
+ """Naked ``conftest`` substring matches conftest_helpers.py and similar."""
393
+ conftest_helpers_path = tmp_path / "conftest_helpers.py"
394
+ conftest_helpers_path.write_text(MAGIC_NUMBER_SOURCE, encoding="utf-8")
395
+
396
+ assert validate_file(conftest_helpers_path) == []
397
+
398
+ def test_validate_file_should_still_scan_production_py_files(self) -> None:
399
+ """Use tempfile with a non-test prefix instead of the pytest tmp_path fixture.
400
+
401
+ pytest's tmp_path produces directories like ``pytest-of-<user>/pytest-N/
402
+ test_validate_file_should_still_scan_production_py_files0/`` whose names
403
+ contain ``test_`` and ``pytest-`` -- both match TEST_PATH_PATTERNS, so
404
+ validate_file would short-circuit via is_test_file and make this
405
+ regression a false green.
406
+ """
407
+ with tempfile.TemporaryDirectory(
408
+ prefix=NON_TEST_TEMPDIR_PREFIX
409
+ ) as temporary_root:
410
+ source_directory = Path(temporary_root) / "src"
411
+ source_directory.mkdir()
412
+ production_path = source_directory / "app.py"
413
+ production_path.write_text(MAGIC_NUMBER_SOURCE, encoding="utf-8")
414
+
415
+ violations = validate_file(production_path)
416
+ assert len(violations) == 1
417
+ assert "42" in violations[0].message
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "1.23.1",
3
+ "version": "1.25.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -33,17 +33,26 @@ subprocess.run(["gh", "pr", "create", "--title", "My PR", "--body-file", body_pa
33
33
 
34
34
  ### PowerShell (Windows — preferred for this repo)
35
35
 
36
+ `Set-Content -Encoding utf8` writes UTF-8-**with-BOM** on Windows PowerShell 5.1,
37
+ which causes `gh` to treat the leading BOM as part of the first heading character
38
+ and can corrupt rendering. Use the BOM-free pattern below — it works on both
39
+ Windows PowerShell 5.1 and PowerShell 7+.
40
+
36
41
  ```powershell
37
42
  $bodyPath = [System.IO.Path]::ChangeExtension((New-TemporaryFile).FullName, '.md')
38
- @'
43
+ $body = @'
39
44
  ## Summary
40
45
 
41
46
  Fixes `foo` by updating `bar`.
42
- '@ | Set-Content -Path $bodyPath -Encoding utf8
47
+ '@
48
+ [IO.File]::WriteAllText($bodyPath, $body, [Text.UTF8Encoding]::new($false))
43
49
 
44
50
  gh pr create --title "My PR" --body-file $bodyPath
45
51
  ```
46
52
 
53
+ On PowerShell 7+ you can alternatively use `Set-Content -Encoding utf8NoBOM`,
54
+ but the `[IO.File]::WriteAllText` pattern above is version-agnostic.
55
+
47
56
  ### Bash
48
57
 
49
58
  Safe Bash patterns are intentionally omitted from this rule file. This repo