claude-dev-env 1.77.0 → 1.79.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 (46) hide show
  1. package/audit-rubrics/category_rubrics/category-k-codebase-conflicts.md +1 -0
  2. package/bin/install.mjs +1 -0
  3. package/bin/install.test.mjs +3 -2
  4. package/hooks/blocking/CLAUDE.md +5 -2
  5. package/hooks/blocking/code_rules_dead_module_constant.py +215 -59
  6. package/hooks/blocking/code_rules_dead_split_branch.py +225 -0
  7. package/hooks/blocking/code_rules_docstrings.py +951 -6
  8. package/hooks/blocking/code_rules_enforcer.py +64 -0
  9. package/hooks/blocking/code_rules_naming_collection.py +76 -1
  10. package/hooks/blocking/code_rules_paired_test.py +517 -0
  11. package/hooks/blocking/code_rules_string_magic.py +71 -1
  12. package/hooks/blocking/code_rules_test_assertions.py +159 -1
  13. package/hooks/blocking/convergence_gate_blocker.py +24 -15
  14. package/hooks/blocking/env_var_table_code_drift_blocker.py +475 -0
  15. package/hooks/blocking/package_inventory_stale_blocker.py +54 -15
  16. package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +89 -2
  17. package/hooks/blocking/test_code_rules_enforcer_dead_split_branch.py +105 -0
  18. package/hooks/blocking/test_code_rules_enforcer_docstring_field_runmode_outcome.py +129 -0
  19. package/hooks/blocking/test_code_rules_enforcer_docstring_length_constant_superlative.py +198 -0
  20. package/hooks/blocking/test_code_rules_enforcer_docstring_mark_glyph_enumeration.py +262 -0
  21. package/hooks/blocking/test_code_rules_enforcer_docstring_no_network.py +115 -0
  22. package/hooks/blocking/test_code_rules_enforcer_docstring_raises_largezipfile.py +226 -0
  23. package/hooks/blocking/test_code_rules_enforcer_docstring_unreferenced_param.py +160 -0
  24. package/hooks/blocking/test_code_rules_enforcer_module_docstring_data_schema_scope.py +82 -0
  25. package/hooks/blocking/test_code_rules_enforcer_paired_test.py +339 -0
  26. package/hooks/blocking/test_code_rules_enforcer_polarity_name_contradiction.py +76 -0
  27. package/hooks/blocking/test_code_rules_enforcer_vacuous_cleanup_assertion.py +132 -0
  28. package/hooks/blocking/test_code_rules_enforcer_whitespace_indentation_magic.py +74 -0
  29. package/hooks/blocking/test_convergence_gate_blocker.py +71 -0
  30. package/hooks/blocking/test_env_var_table_code_drift_blocker.py +94 -0
  31. package/hooks/blocking/test_package_inventory_stale_blocker.py +46 -0
  32. package/hooks/blocking/test_pre_tool_use_dispatcher.py +7 -7
  33. package/hooks/hooks_constants/CLAUDE.md +2 -0
  34. package/hooks/hooks_constants/blocking_check_limits.py +102 -0
  35. package/hooks/hooks_constants/code_rules_enforcer_constants.py +28 -0
  36. package/hooks/hooks_constants/env_var_table_code_drift_constants.py +64 -0
  37. package/hooks/hooks_constants/package_inventory_stale_blocker_constants.py +20 -9
  38. package/hooks/hooks_constants/paired_test_coverage_constants.py +35 -0
  39. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
  40. package/package.json +1 -1
  41. package/rules/CLAUDE.md +2 -0
  42. package/rules/docstring-prose-matches-implementation.md +56 -53
  43. package/rules/env-var-table-code-drift.md +24 -0
  44. package/rules/file-global-constants.md +2 -2
  45. package/rules/package-inventory-stale-entry.md +4 -4
  46. package/rules/paired-test-coverage.md +35 -0
@@ -1,4 +1,4 @@
1
- """Skip-decorator, existence-only, constant-equality, stale-test-name, and flag-gated scenario test-quality checks."""
1
+ """Skip-decorator, existence-only, constant-equality, stale-test-name, flag-gated scenario, and vacuous cleanup-assertion test-quality checks."""
2
2
 
3
3
  import ast
4
4
  import sys
@@ -6,6 +6,37 @@ from pathlib import Path
6
6
 
7
7
  _SCENARIO_NAME_CLAUSES = ("_when_", "_passes", "_succeeds", "_on_clean")
8
8
  _MINIMUM_SIBLING_PATCH_COUNT = 2
9
+ _CLEANUP_REMOVAL_NAME_TOKENS = (
10
+ "remove",
11
+ "removes",
12
+ "removed",
13
+ "cleanup",
14
+ "clean_up",
15
+ "deletes",
16
+ "discards",
17
+ "no_tmp",
18
+ "no_temp",
19
+ "no_leftover",
20
+ "leftover",
21
+ )
22
+ _FAILURE_CONDITION_NAME_TOKENS = (
23
+ "on_failure",
24
+ "_failure",
25
+ "on_error",
26
+ "_fails",
27
+ "_failed",
28
+ "raises",
29
+ "bad_source",
30
+ )
31
+ _ARRANGEMENT_METHOD_NAMES = (
32
+ "setattr",
33
+ "mkdir",
34
+ "write_text",
35
+ "write_bytes",
36
+ "touch",
37
+ "symlink_to",
38
+ "exists",
39
+ )
9
40
 
10
41
  _BLOCKING_DIRECTORY = str(Path(__file__).resolve().parent)
11
42
  _HOOKS_DIRECTORY = str(Path(__file__).resolve().parent.parent)
@@ -233,6 +264,133 @@ def check_constant_equality_tests(content: str, file_path: str) -> list[str]:
233
264
  return issues
234
265
 
235
266
 
267
+ def _name_signals_cleanup_on_failure(test_name: str) -> bool:
268
+ """Return True when a test name pairs a removal token with a failure token."""
269
+ lowered_name = test_name.lower()
270
+ has_removal_token = any(
271
+ each_token in lowered_name for each_token in _CLEANUP_REMOVAL_NAME_TOKENS
272
+ )
273
+ has_failure_token = any(
274
+ each_token in lowered_name for each_token in _FAILURE_CONDITION_NAME_TOKENS
275
+ )
276
+ return has_removal_token and has_failure_token
277
+
278
+
279
+ def _is_absence_assertion(assert_node: ast.Assert) -> bool:
280
+ """Return True when an assertion only checks that a collection is empty."""
281
+ test_expr = assert_node.test
282
+ if isinstance(test_expr, ast.UnaryOp) and isinstance(test_expr.op, ast.Not):
283
+ return True
284
+ if not isinstance(test_expr, ast.Compare):
285
+ return False
286
+ if len(test_expr.ops) != 1 or not isinstance(test_expr.ops[0], ast.Eq):
287
+ return False
288
+ comparator = test_expr.comparators[0]
289
+ if (
290
+ isinstance(comparator, ast.Constant)
291
+ and isinstance(comparator.value, int)
292
+ and not isinstance(comparator.value, bool)
293
+ and comparator.value == 0
294
+ ):
295
+ return True
296
+ return isinstance(comparator, ast.List) and not comparator.elts
297
+
298
+
299
+ def _body_calls_attribute(
300
+ function_node: ast.FunctionDef | ast.AsyncFunctionDef, attribute_name: str
301
+ ) -> bool:
302
+ """Return True when the function body calls a method with the given attribute name."""
303
+ for each_node in ast.walk(function_node):
304
+ if not isinstance(each_node, ast.Call):
305
+ continue
306
+ callee = each_node.func
307
+ if isinstance(callee, ast.Attribute) and callee.attr == attribute_name:
308
+ return True
309
+ return False
310
+
311
+
312
+ def _body_arranges_temp_existence(
313
+ function_node: ast.FunctionDef | ast.AsyncFunctionDef,
314
+ ) -> bool:
315
+ """Return True when the body creates a temp file or arranges a post-creation failure.
316
+
317
+ A sound cleanup-on-failure test proves the temp existed before asserting its
318
+ removal: it monkeypatches the operation to fail after the temp is created, or
319
+ it creates the temp directly and checks its presence. Either signal means the
320
+ leftover-absence assertion is not vacuous.
321
+
322
+ Args:
323
+ function_node: The test function whose body is inspected.
324
+
325
+ Returns:
326
+ True when the body references ``monkeypatch`` or calls a file-creation or
327
+ existence method that establishes the temp file before the cleanup runs.
328
+ """
329
+ for each_node in ast.walk(function_node):
330
+ if isinstance(each_node, ast.Name) and each_node.id == "monkeypatch":
331
+ return True
332
+ if not isinstance(each_node, ast.Call):
333
+ continue
334
+ callee = each_node.func
335
+ if isinstance(callee, ast.Attribute) and callee.attr in _ARRANGEMENT_METHOD_NAMES:
336
+ return True
337
+ return False
338
+
339
+
340
+ def check_vacuous_cleanup_assertion_tests(content: str, file_path: str) -> list[str]:
341
+ """Flag a cleanup-on-failure test whose leftover-absence assertion is vacuous.
342
+
343
+ A test named for removal on failure that globs a directory and asserts the
344
+ result is empty, yet never creates the temp file nor arranges a failure after
345
+ the temp exists, passes even when the on-failure cleanup is entirely broken —
346
+ the leftover set is empty because nothing ever staged a leftover. The gate
347
+ fires when the test name pairs a removal token with a failure token, the body
348
+ globs a directory, every assertion only checks emptiness, and the body neither
349
+ monkeypatches a post-creation failure nor creates and checks the temp first.
350
+ Only applies to test files; production files are exempt.
351
+
352
+ Args:
353
+ content: The file body under validation.
354
+ file_path: Path to the file, used for the test-file gate.
355
+
356
+ Returns:
357
+ One issue per test whose cleanup-on-failure assertion passes vacuously.
358
+ """
359
+ if not is_test_file(file_path):
360
+ return []
361
+ try:
362
+ syntax_tree = ast.parse(content)
363
+ except SyntaxError:
364
+ return []
365
+
366
+ issues: list[str] = []
367
+ for each_node in ast.walk(syntax_tree):
368
+ if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
369
+ continue
370
+ if not each_node.name.startswith("test"):
371
+ continue
372
+ if not _name_signals_cleanup_on_failure(each_node.name):
373
+ continue
374
+ if not _body_calls_attribute(each_node, "glob"):
375
+ continue
376
+ if _body_arranges_temp_existence(each_node):
377
+ continue
378
+ body_assertions = _collect_body_assertions(each_node.body)
379
+ if not body_assertions:
380
+ continue
381
+ if not all(_is_absence_assertion(each_assert) for each_assert in body_assertions):
382
+ continue
383
+ issues.append(
384
+ f"Line {each_node.lineno}: vacuous cleanup-on-failure test"
385
+ f" — asserts no leftover temp file but never proves the temp was created,"
386
+ f" so it passes even when cleanup is broken; arrange a post-creation"
387
+ f" failure (e.g. monkeypatch os.replace to raise after the temp exists)"
388
+ f" then assert the temp was removed"
389
+ )
390
+
391
+ return issues
392
+
393
+
236
394
  def _flag_symbol_from_setattr_target(target_node: ast.expr) -> str | None:
237
395
  """Return the UPPER_SNAKE flag symbol a monkeypatch.setattr target names.
238
396
 
@@ -68,6 +68,27 @@ def _resolve_owner_repo(cwd: str | None) -> tuple[str, str] | None:
68
68
  return parts[0], parts[1]
69
69
 
70
70
 
71
+ def _run_convergence_check(
72
+ script: str, owner: str, repo: str, pr_number: int, cwd: str | None
73
+ ) -> subprocess.CompletedProcess[str]:
74
+ return subprocess.run(
75
+ [
76
+ sys.executable,
77
+ script,
78
+ "--owner",
79
+ owner,
80
+ "--repo",
81
+ repo,
82
+ "--pr-number",
83
+ str(pr_number),
84
+ ],
85
+ capture_output=True,
86
+ text=True,
87
+ cwd=cwd or None,
88
+ check=False,
89
+ )
90
+
91
+
71
92
  def main() -> None:
72
93
  check_convergence_script = str(
73
94
  Path.home() / ".claude/skills/pr-converge/scripts/check_convergence.py"
@@ -90,7 +111,7 @@ def main() -> None:
90
111
  if not gh_pr_ready_pattern.search(command):
91
112
  sys.exit(0)
92
113
 
93
- cwd = hook_input.get("tool_input", {}).get("cwd")
114
+ cwd = hook_input.get("cwd")
94
115
  pr_number = _resolve_pr_number(command, cwd)
95
116
  if pr_number is None:
96
117
  sys.exit(0)
@@ -100,20 +121,8 @@ def main() -> None:
100
121
  sys.exit(0)
101
122
  owner, repo = owner_repo
102
123
 
103
- completed_process = subprocess.run(
104
- [
105
- sys.executable,
106
- check_convergence_script,
107
- "--owner",
108
- owner,
109
- "--repo",
110
- repo,
111
- "--pr-number",
112
- str(pr_number),
113
- ],
114
- capture_output=True,
115
- text=True,
116
- check=False,
124
+ completed_process = _run_convergence_check(
125
+ check_convergence_script, owner, repo, pr_number, cwd
117
126
  )
118
127
 
119
128
  if completed_process.returncode in (0, 2):