claude-dev-env 1.72.0 → 1.74.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 (99) hide show
  1. package/CLAUDE.md +2 -0
  2. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +2 -2
  3. package/bin/install.mjs +73 -5
  4. package/bin/install.test.mjs +360 -4
  5. package/hooks/blocking/CLAUDE.md +6 -1
  6. package/hooks/blocking/block_main_commit.py +14 -0
  7. package/hooks/blocking/bot_mention_comment_blocker.py +7 -0
  8. package/hooks/blocking/claude_md_orphan_file_blocker.py +19 -48
  9. package/hooks/blocking/code_rules_dead_config_field.py +69 -56
  10. package/hooks/blocking/code_rules_docstrings.py +839 -0
  11. package/hooks/blocking/code_rules_enforcer.py +38 -0
  12. package/hooks/blocking/code_rules_shared.py +19 -0
  13. package/hooks/blocking/code_verifier_spawn_preflight_gate.py +426 -0
  14. package/hooks/blocking/convergence_gate_blocker.py +17 -3
  15. package/hooks/blocking/destructive_command_blocker.py +7 -0
  16. package/hooks/blocking/docstring_rule_gate_count_blocker.py +321 -0
  17. package/hooks/blocking/gh_body_arg_blocker.py +8 -0
  18. package/hooks/blocking/gh_pr_author_enforcer.py +7 -0
  19. package/hooks/blocking/hedging_language_blocker.py +16 -10
  20. package/hooks/blocking/hook_prose_detector_consistency.py +7 -0
  21. package/hooks/blocking/intent_only_ending_blocker.py +17 -11
  22. package/hooks/blocking/md_to_html_blocker.py +17 -10
  23. package/hooks/blocking/open_questions_in_plans_blocker.py +15 -8
  24. package/hooks/blocking/package_inventory_stale_blocker.py +398 -0
  25. package/hooks/blocking/plain_language_blocker.py +57 -16
  26. package/hooks/blocking/pr_converge_bugteam_enforcer.py +11 -5
  27. package/hooks/blocking/pr_description_enforcer.py +6 -0
  28. package/hooks/blocking/pre_tool_use_dispatcher.py +545 -0
  29. package/hooks/blocking/precommit_code_rules_gate.py +10 -1
  30. package/hooks/blocking/pytest_testpaths_orphan_blocker.py +366 -0
  31. package/hooks/blocking/question_to_user_enforcer.py +18 -12
  32. package/hooks/blocking/send_user_file_open_locally_blocker.py +70 -0
  33. package/hooks/blocking/sensitive_file_protector.py +15 -1
  34. package/hooks/blocking/session_handoff_blocker.py +14 -8
  35. package/hooks/blocking/state_description_blocker.py +81 -36
  36. package/hooks/blocking/subprocess_budget_completeness.py +9 -3
  37. package/hooks/blocking/tdd_enforcer.py +6 -0
  38. package/hooks/blocking/test_code_rules_enforcer_dead_config_field.py +81 -0
  39. package/hooks/blocking/test_code_rules_enforcer_docstring_inline_literal_claim.py +93 -0
  40. package/hooks/blocking/test_code_rules_enforcer_docstring_returns_plural_cardinality.py +207 -0
  41. package/hooks/blocking/test_code_rules_enforcer_docstring_step_dispatch.py +262 -0
  42. package/hooks/blocking/test_code_rules_enforcer_docstring_undefined_constant.py +253 -0
  43. package/hooks/blocking/test_code_rules_enforcer_docstring_unguarded_payload.py +188 -0
  44. package/hooks/blocking/test_code_rules_enforcer_module_docstring_roster.py +279 -0
  45. package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +501 -0
  46. package/hooks/blocking/test_docstring_rule_gate_count_blocker.py +203 -0
  47. package/hooks/blocking/test_hook_block_logger_coverage.py +53 -0
  48. package/hooks/blocking/test_package_inventory_stale_blocker.py +329 -0
  49. package/hooks/blocking/test_plain_language_blocker.py +36 -0
  50. package/hooks/blocking/test_pre_tool_use_dispatcher.py +816 -0
  51. package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +341 -0
  52. package/hooks/blocking/test_pytest_testpaths_orphan_blocker.py +247 -0
  53. package/hooks/blocking/test_send_user_file_open_locally_blocker.py +114 -0
  54. package/hooks/blocking/test_shared_stdin_adoption.py +208 -0
  55. package/hooks/blocking/test_state_description_blocker.py +41 -0
  56. package/hooks/blocking/test_verdict_directory_write_blocker.py +49 -0
  57. package/hooks/blocking/test_workflow_substitution_slot_blocker.py +4 -19
  58. package/hooks/blocking/verdict_directory_write_blocker.py +21 -7
  59. package/hooks/blocking/verified_commit_gate.py +11 -0
  60. package/hooks/blocking/verified_commit_message_accuracy_blocker.py +16 -1
  61. package/hooks/blocking/windows_rmtree_blocker.py +7 -0
  62. package/hooks/blocking/workflow_substitution_slot_blocker.py +10 -5
  63. package/hooks/blocking/write_existing_file_blocker.py +16 -1
  64. package/hooks/hooks.json +19 -79
  65. package/hooks/hooks_constants/CLAUDE.md +7 -1
  66. package/hooks/hooks_constants/blocking_check_limits.py +74 -0
  67. package/hooks/hooks_constants/code_rules_enforcer_constants.py +9 -0
  68. package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +45 -0
  69. package/hooks/hooks_constants/dead_config_field_constants.py +5 -5
  70. package/hooks/hooks_constants/docstring_rule_gate_count_blocker_constants.py +90 -0
  71. package/hooks/hooks_constants/hook_block_logger.py +59 -0
  72. package/hooks/hooks_constants/multi_edit_reconstruction.py +56 -0
  73. package/hooks/hooks_constants/mypy_validator_cache_constants.py +36 -0
  74. package/hooks/hooks_constants/package_inventory_stale_blocker_constants.py +111 -0
  75. package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +68 -0
  76. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +143 -0
  77. package/hooks/hooks_constants/pytest_testpaths_orphan_blocker_constants.py +79 -0
  78. package/hooks/hooks_constants/send_user_file_open_locally_blocker_constants.py +18 -0
  79. package/hooks/hooks_constants/test_dispatcher_constants_docstrings.py +44 -0
  80. package/hooks/hooks_constants/test_hook_block_logger.py +159 -0
  81. package/hooks/lifecycle/config_change_guard.py +12 -0
  82. package/hooks/lifecycle/test_config_change_guard.py +23 -0
  83. package/hooks/validation/hook_format_validator.py +13 -0
  84. package/hooks/validation/mypy_validator.py +245 -18
  85. package/hooks/validation/post_tool_use_dispatcher.py +344 -0
  86. package/hooks/validation/test_hook_format_validator.py +64 -0
  87. package/hooks/validation/test_mypy_validator.py +206 -1
  88. package/hooks/validation/test_post_tool_use_dispatcher.py +610 -0
  89. package/hooks/workflow/test_auto_formatter.py +10 -9
  90. package/package.json +1 -1
  91. package/rules/CLAUDE.md +1 -0
  92. package/rules/docstring-prose-matches-implementation.md +4 -2
  93. package/rules/package-inventory-stale-entry.md +24 -0
  94. package/skills/autoconverge/SKILL.md +111 -1
  95. package/skills/autoconverge/workflow/converge.contract.test.mjs +106 -0
  96. package/skills/autoconverge/workflow/converge.mjs +29 -3
  97. package/skills/autoconverge/workflow/converge.path-aware.test.mjs +47 -0
  98. package/skills/autoconverge/workflow/converge_multi.mjs +161 -0
  99. package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +100 -0
@@ -7,7 +7,13 @@ Blocks if hooks use simple 'python3 ~/.claude/...' instead of the exec(open(...)
7
7
  import json
8
8
  import re
9
9
  import sys
10
+ from pathlib import Path
10
11
 
12
+ _hooks_dir = str(Path(__file__).resolve().parent.parent)
13
+ if _hooks_dir not in sys.path:
14
+ sys.path.insert(0, _hooks_dir)
15
+
16
+ from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
11
17
 
12
18
  SIMPLE_PATTERN = re.compile(
13
19
  r'python3?\s+~/\.claude/hooks/'
@@ -56,6 +62,13 @@ def main() -> None:
56
62
  "permissionDecisionReason": message
57
63
  }
58
64
  }
65
+ log_hook_block(
66
+ calling_hook_name="hook_format_validator.py",
67
+ hook_event="PreToolUse",
68
+ block_reason=message,
69
+ tool_name=tool_name,
70
+ offending_input_preview=file_path,
71
+ )
59
72
  print(json.dumps(result))
60
73
  sys.exit(0)
61
74
 
@@ -11,6 +11,7 @@ This catches:
11
11
  Works in both WSL and Windows for any Python project with a git root.
12
12
  Project root is discovered via CLAUDE_PROJECT_ROOT env var or git rev-parse.
13
13
  """
14
+ import hashlib
14
15
  import importlib
15
16
  import json
16
17
  import os
@@ -20,10 +21,30 @@ import sys
20
21
  from pathlib import Path
21
22
  from types import ModuleType
22
23
 
23
- NOTIFICATION_UTILS_DIRECTORY = os.path.join(
24
- os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "notification"
24
+ _hooks_directory = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
25
+
26
+ _notification_utils_directory = os.path.join(_hooks_directory, "notification")
27
+ sys.path.insert(0, _notification_utils_directory)
28
+
29
+ _validators_directory = os.path.join(_hooks_directory, "validators")
30
+ if _validators_directory not in sys.path:
31
+ sys.path.insert(0, _validators_directory)
32
+
33
+ if _hooks_directory not in sys.path:
34
+ sys.path.insert(0, _hooks_directory)
35
+
36
+ from mypy_integration import find_pyproject_with_mypy_config # noqa: E402
37
+
38
+ from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
39
+ from hooks_constants.mypy_validator_cache_constants import ( # noqa: E402
40
+ CACHE_FILE_ENCODING,
41
+ CONTENT_HASH_CACHE_PASSING_EXIT_CODE,
42
+ HOOK_STATE_CACHE_DIRECTORY,
43
+ MYPY_CONFIG_CACHE_FILENAME,
44
+ MYPY_CONTENT_HASH_CACHE_FILENAME,
45
+ SESSION_ID_ENVIRONMENT_VARIABLE,
46
+ UNKNOWN_SESSION_IDENTIFIER,
25
47
  )
26
- sys.path.insert(0, NOTIFICATION_UTILS_DIRECTORY)
27
48
 
28
49
 
29
50
  def load_notification_utils() -> ModuleType | None:
@@ -69,6 +90,85 @@ def is_file_within_project(target_file: str, project_root: Path) -> bool:
69
90
  return False
70
91
 
71
92
 
93
+ _session_config_cache_by_target_directory: dict[str, str | None] = {}
94
+
95
+
96
+ def reset_session_config_cache() -> None:
97
+ """Clear the in-process config-walk cache so the next walk runs fresh.
98
+
99
+ The cache is normally seeded once per target directory per session; tests
100
+ call this between scenarios so a redirected cache directory starts empty.
101
+ """
102
+ _session_config_cache_by_target_directory.clear()
103
+
104
+
105
+ def resolve_session_identifier() -> str:
106
+ """Return the current session identifier for keying per-session caches.
107
+
108
+ Returns:
109
+ The ``CLAUDE_CODE_SESSION_ID`` environment value, or a fixed unknown
110
+ marker when the variable is unset or empty so the cache still has a
111
+ stable key within a single run.
112
+ """
113
+ session_identifier = os.environ.get(SESSION_ID_ENVIRONMENT_VARIABLE, "")
114
+ return session_identifier or UNKNOWN_SESSION_IDENTIFIER
115
+
116
+
117
+ def _session_cache_path(cache_filename: str) -> Path:
118
+ session_identifier = resolve_session_identifier()
119
+ return Path(HOOK_STATE_CACHE_DIRECTORY) / session_identifier / cache_filename
120
+
121
+
122
+ def _read_cache_file(cache_path: Path) -> dict[str, object]:
123
+ if not cache_path.is_file():
124
+ return {}
125
+ try:
126
+ raw_text = cache_path.read_text(encoding=CACHE_FILE_ENCODING)
127
+ except OSError:
128
+ return {}
129
+ if not raw_text.strip():
130
+ return {}
131
+ try:
132
+ parsed_cache = json.loads(raw_text)
133
+ except json.JSONDecodeError:
134
+ return {}
135
+ return parsed_cache if isinstance(parsed_cache, dict) else {}
136
+
137
+
138
+ def _write_cache_file(cache_path: Path, cache_by_key: dict[str, object]) -> None:
139
+ try:
140
+ cache_path.parent.mkdir(parents=True, exist_ok=True)
141
+ cache_path.write_text(
142
+ json.dumps(cache_by_key), encoding=CACHE_FILE_ENCODING
143
+ )
144
+ except OSError:
145
+ return
146
+
147
+
148
+ def _walk_mypy_config(target_file: Path) -> Path | None:
149
+ discovered_config = find_pyproject_with_mypy_config(target_file)
150
+ return discovered_config if isinstance(discovered_config, Path) else None
151
+
152
+
153
+ def _config_cache_key_for(target_file: Path) -> str:
154
+ """Return the cache key for one file's config walk.
155
+
156
+ The walk climbs the ancestors of the target file's own directory, so its
157
+ result is determined by that directory, not by the shared project root. Two
158
+ files in sibling subtrees under one git root each carry their own nearer
159
+ ``[tool.mypy]`` config; keying the cache by the resolved target directory
160
+ keeps each file's walk distinct so the first file checked does not seed the
161
+ second file's config.
162
+
163
+ Args:
164
+ target_file: The Python file mypy will check.
165
+
166
+ Returns:
167
+ The resolved directory of the target file as the config-walk cache key.
168
+ """
169
+ return str(target_file.resolve().parent)
170
+
171
+
72
172
  def discover_mypy_config(target_file: Path) -> Path | None:
73
173
  """Return the nearest ancestor ``pyproject.toml`` that configures mypy.
74
174
 
@@ -76,28 +176,97 @@ def discover_mypy_config(target_file: Path) -> Path | None:
76
176
  is on its invocation path; handing the discovered config to mypy lets a
77
177
  check run from the repository root still honor the project's own import
78
178
  resolution settings (such as ``ignore_missing_imports``) for a module that
79
- imports its siblings by name. Reuses the validators-package walk-up so the
80
- discovery logic lives in one place.
179
+ imports its siblings by name. The discovered config is cached per target
180
+ directory for the session, in process and in a session cache file, so a
181
+ later edit of a file in the same directory reuses the result rather than
182
+ walking ancestors again.
81
183
 
82
184
  Args:
83
- target_file: The Python file mypy will check.
185
+ target_file: The Python file mypy will check; its directory keys the walk.
84
186
 
85
187
  Returns:
86
188
  The nearest ancestor ``pyproject.toml`` declaring a ``[tool.mypy]``
87
- table, or None when none exists above the file or the walk-up helper
88
- cannot be imported.
189
+ table, or None when none exists above the file.
89
190
  """
90
- validators_directory = os.path.join(
91
- os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "validators"
92
- )
93
- if validators_directory not in sys.path:
94
- sys.path.insert(0, validators_directory)
191
+ cache_key = _config_cache_key_for(target_file)
192
+ if cache_key in _session_config_cache_by_target_directory:
193
+ cached_value = _session_config_cache_by_target_directory[cache_key]
194
+ return Path(cached_value) if cached_value is not None else None
195
+
196
+ config_cache_path = _session_cache_path(MYPY_CONFIG_CACHE_FILENAME)
197
+ persisted_cache = _read_cache_file(config_cache_path)
198
+ if cache_key in persisted_cache:
199
+ persisted_value = persisted_cache[cache_key]
200
+ resolved_persisted = persisted_value if isinstance(persisted_value, str) else None
201
+ _session_config_cache_by_target_directory[cache_key] = resolved_persisted
202
+ return Path(resolved_persisted) if resolved_persisted is not None else None
203
+
204
+ discovered_config = _walk_mypy_config(target_file)
205
+ discovered_value = str(discovered_config) if discovered_config is not None else None
206
+ _session_config_cache_by_target_directory[cache_key] = discovered_value
207
+ persisted_cache[cache_key] = discovered_value
208
+ _write_cache_file(config_cache_path, persisted_cache)
209
+ return discovered_config
210
+
211
+
212
+ def _config_signature(mypy_config_file: Path | None) -> bytes:
213
+ """Return a byte signature of the discovered mypy config's current contents.
214
+
215
+ The signature folds the config file's own bytes into the content-hash cache
216
+ key so a change to the project's ``[tool.mypy]`` settings invalidates a
217
+ previously recorded passing hash: when the file's bytes are restored to a
218
+ prior passing version under a tightened config, the composite hash differs
219
+ and mypy re-runs rather than returning a stale pass. An absent config
220
+ contributes a fixed empty signature.
221
+
222
+ Args:
223
+ mypy_config_file: The discovered config path, or None when none exists.
224
+
225
+ Returns:
226
+ The config file's bytes, or an empty signature when there is no config
227
+ or it cannot be read.
228
+ """
229
+ if mypy_config_file is None:
230
+ return b""
95
231
  try:
96
- integration_module = importlib.import_module("mypy_integration")
97
- except ImportError:
232
+ return mypy_config_file.read_bytes()
233
+ except OSError:
234
+ return b""
235
+
236
+
237
+ def _composite_content_hash(target_file: str, mypy_config_file: Path | None) -> str | None:
238
+ """Return a hash over the target file's bytes and its mypy config's bytes.
239
+
240
+ Args:
241
+ target_file: The absolute path of the file to type-check.
242
+ mypy_config_file: The discovered mypy config path, or None.
243
+
244
+ Returns:
245
+ The combined hash, or None when the target file cannot be read.
246
+ """
247
+ try:
248
+ file_bytes = Path(target_file).read_bytes()
249
+ except OSError:
98
250
  return None
99
- discovered_config = integration_module.find_pyproject_with_mypy_config(target_file)
100
- return discovered_config if isinstance(discovered_config, Path) else None
251
+ hasher = hashlib.sha256()
252
+ hasher.update(file_bytes)
253
+ hasher.update(_config_signature(mypy_config_file))
254
+ return hasher.hexdigest()
255
+
256
+
257
+ def _read_cached_passing_hash(target_file: str) -> str | None:
258
+ content_hash_cache = _read_cache_file(
259
+ _session_cache_path(MYPY_CONTENT_HASH_CACHE_FILENAME)
260
+ )
261
+ cached_hash = content_hash_cache.get(target_file)
262
+ return cached_hash if isinstance(cached_hash, str) else None
263
+
264
+
265
+ def _record_passing_hash(target_file: str, content_hash: str) -> None:
266
+ content_hash_cache_path = _session_cache_path(MYPY_CONTENT_HASH_CACHE_FILENAME)
267
+ content_hash_cache = _read_cache_file(content_hash_cache_path)
268
+ content_hash_cache[target_file] = content_hash
269
+ _write_cache_file(content_hash_cache_path, content_hash_cache)
101
270
 
102
271
 
103
272
  def build_mypy_command(relative_file_path: str, mypy_config_file: Path | None) -> list[str]:
@@ -125,9 +294,53 @@ def build_mypy_command(relative_file_path: str, mypy_config_file: Path | None) -
125
294
  ]
126
295
 
127
296
 
297
+ def project_relative_path(target_file: str, project_root: str) -> str:
298
+ """Return *target_file* relative to *project_root*, or its absolute path.
299
+
300
+ On Windows ``os.path.relpath`` raises ``ValueError`` when the two paths sit
301
+ on different mounts (for example a ``Y:`` drive file against a project root
302
+ that resolved to its backing UNC share), so no relative path can span them.
303
+ The absolute target path is then handed to mypy unchanged, which accepts it.
304
+
305
+ Args:
306
+ target_file: The absolute path of the file to type-check.
307
+ project_root: The directory mypy runs from.
308
+
309
+ Returns:
310
+ The path relative to *project_root* when one exists, otherwise the
311
+ absolute path of *target_file*.
312
+ """
313
+ try:
314
+ return os.path.relpath(target_file, project_root)
315
+ except ValueError:
316
+ return os.path.abspath(target_file)
317
+
318
+
128
319
  def run_mypy(target_file: str, project_root: str) -> tuple[int, str]:
129
320
  """Run mypy on one file from the project root and return its result.
130
321
 
322
+ The mypy run is skipped when a composite hash over the target file's bytes
323
+ and its discovered mypy config's bytes matches the hash recorded the last
324
+ time mypy passed for that file; that recorded skip can only return a pass, so
325
+ a content change always re-runs mypy and a file edited to introduce a type
326
+ error still blocks. Folding the config bytes into the hash invalidates the
327
+ skip when the project's ``[tool.mypy]`` settings change, so a file whose
328
+ bytes are restored to a prior passing version under a tightened config
329
+ re-runs rather than returning a stale pass. The discovered config is reused
330
+ from the per-session cache keyed by the target file's own directory, so two
331
+ files in sibling subtrees under one project root each resolve their own
332
+ nearer config.
333
+
334
+ The composite hash covers the target file's own bytes and its config's
335
+ bytes only, so the skip is blind to a cross-file change in a dependency:
336
+ when a dependency is edited in a way that breaks this file's call site and
337
+ this file is later rewritten to its prior passing content, the cached pass
338
+ returns without re-running mypy. The post-write hook already type-checks only
339
+ the single edited file, so a dependent is never re-checked on the
340
+ dependency's own edit regardless of the cache; the cache adds only the
341
+ identical-rewrite-under-unchanged-config skip on top of that existing
342
+ single-file scope.
343
+
131
344
  Args:
132
345
  target_file: The absolute path of the file to type-check.
133
346
  project_root: The directory mypy runs from.
@@ -135,8 +348,13 @@ def run_mypy(target_file: str, project_root: str) -> tuple[int, str]:
135
348
  Returns:
136
349
  The mypy exit code paired with its combined stdout and stderr text.
137
350
  """
138
- relative_file_path = os.path.relpath(target_file, project_root)
351
+ relative_file_path = project_relative_path(target_file, project_root)
139
352
  mypy_config_file = discover_mypy_config(Path(target_file))
353
+
354
+ content_hash = _composite_content_hash(target_file, mypy_config_file)
355
+ if content_hash is not None and content_hash == _read_cached_passing_hash(target_file):
356
+ return CONTENT_HASH_CACHE_PASSING_EXIT_CODE, ""
357
+
140
358
  mypy_command = build_mypy_command(relative_file_path, mypy_config_file)
141
359
 
142
360
  completed_process = subprocess.run(
@@ -152,6 +370,9 @@ def run_mypy(target_file: str, project_root: str) -> tuple[int, str]:
152
370
  stderr_output = completed_process.stderr.strip()
153
371
  combined_output = f"{stdout_output}\n{stderr_output}".strip() if stderr_output else stdout_output
154
372
 
373
+ if completed_process.returncode == CONTENT_HASH_CACHE_PASSING_EXIT_CODE and content_hash is not None:
374
+ _record_passing_hash(target_file, content_hash)
375
+
155
376
  return completed_process.returncode, combined_output
156
377
 
157
378
 
@@ -259,6 +480,12 @@ def main() -> None:
259
480
  error_summary = format_error_summary(all_error_lines)
260
481
  send_block_notification(error_summary)
261
482
  block_response = build_block_response(error_summary)
483
+ log_hook_block(
484
+ calling_hook_name="mypy_validator.py",
485
+ hook_event="PostToolUse",
486
+ block_reason=f"[MYPY] Type errors: {error_summary}",
487
+ offending_input_preview=target_file_path,
488
+ )
262
489
  print(json.dumps(block_response))
263
490
  sys.exit(0)
264
491