claude-dev-env 1.51.0 → 1.52.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.
@@ -11,6 +11,7 @@ import json
11
11
  import re
12
12
  import sys
13
13
  import time
14
+ from collections import Counter
14
15
  from pathlib import Path
15
16
 
16
17
 
@@ -124,42 +125,213 @@ def _is_constants_only_python_content(content: str) -> bool:
124
125
  return True
125
126
 
126
127
 
127
- def _apply_edit_to_content(existing_content: str, old_str: str, new_str: str) -> str:
128
- """Replace the first occurrence of old_str with new_str in the content."""
128
+ def _apply_edit_to_content(
129
+ existing_content: str, old_str: str, new_str: str, should_replace_all: bool
130
+ ) -> str:
131
+ """Apply an edit's replacement to content the way the Edit tool would.
132
+
133
+ Args:
134
+ existing_content: The text being edited.
135
+ old_str: The substring the edit replaces.
136
+ new_str: The replacement substring.
137
+ should_replace_all: Replace every occurrence when True (matching the
138
+ Edit tool's ``replace_all`` flag), otherwise only the first.
139
+
140
+ Returns:
141
+ The post-edit content.
142
+ """
143
+ if should_replace_all:
144
+ return existing_content.replace(old_str, new_str)
129
145
  return existing_content.replace(old_str, new_str, 1)
130
146
 
131
147
 
148
+ def _future_module_name() -> str:
149
+ return "__future__"
150
+
151
+
152
+ def _is_future_import(node: ast.stmt) -> bool:
153
+ """Return whether a statement is a ``from __future__`` import.
154
+
155
+ Args:
156
+ node: A top-level module statement.
157
+
158
+ Returns:
159
+ True when the statement imports from ``__future__``, whose presence
160
+ affects module-wide compilation semantics.
161
+ """
162
+ return isinstance(node, ast.ImportFrom) and node.module == _future_module_name()
163
+
164
+
165
+ def _is_removable_import(node: ast.stmt) -> bool:
166
+ """Return whether a statement is an import that removes or reorders cleanly.
167
+
168
+ Args:
169
+ node: A top-level module statement.
170
+
171
+ Returns:
172
+ True for plain ``import`` and ``from`` imports. ``from __future__``
173
+ imports return False because their presence affects module-wide
174
+ compilation semantics, so the gate treats them as behavior-bearing
175
+ statements rather than removable imports; every non-import statement
176
+ also returns False.
177
+ """
178
+ if isinstance(node, ast.Import):
179
+ return True
180
+ if isinstance(node, ast.ImportFrom):
181
+ return not _is_future_import(node)
182
+ return False
183
+
184
+
185
+ def _future_import_signatures(content: str) -> list[str] | None:
186
+ """Return the ``ast.dump`` signatures of a module's ``from __future__`` imports.
187
+
188
+ Args:
189
+ content: Python source text to parse.
190
+
191
+ Returns:
192
+ The future-import signatures in source order, or ``None`` when the
193
+ content does not parse.
194
+ """
195
+ try:
196
+ parsed_tree = ast.parse(content)
197
+ except SyntaxError:
198
+ return None
199
+ return [ast.dump(each_node) for each_node in parsed_tree.body if _is_future_import(each_node)]
200
+
201
+
132
202
  def _is_post_edit_constants_only(existing_content: str, tool_name: str, tool_input: dict) -> bool:
133
203
  """Check if post-edit content remains constants-only after Edit or MultiEdit.
134
204
 
135
205
  Both the existing content and the post-edit result must be constants-only
136
- to prevent edits on files with behavior from bypassing the TDD gate.
206
+ to prevent edits on files with behavior from bypassing the TDD gate. Editing
207
+ a ``from __future__`` import also fails this check, so a future-import edit
208
+ on a constants-only file faces the gate rather than slipping through the
209
+ constants exemption.
137
210
  """
138
211
  if not _is_constants_only_python_content(existing_content):
139
212
  return False
140
213
 
141
214
  if tool_name == "Edit":
142
- old_str = tool_input.get("old_string", "")
215
+ old_str = tool_input.get("old_string", "") or ""
143
216
  new_str = tool_input.get("new_string", "") or ""
144
217
  if not old_str:
145
218
  return False
146
- post_edit_content = _apply_edit_to_content(existing_content, old_str, new_str)
147
- return _is_constants_only_python_content(post_edit_content)
219
+ should_replace_all = bool(tool_input.get("replace_all", False))
220
+ post_edit_content = _apply_edit_to_content(existing_content, old_str, new_str, should_replace_all)
221
+ elif tool_name == "MultiEdit":
222
+ all_edits = tool_input.get("edits", []) or []
223
+ post_edit_content = existing_content
224
+ for each_edit in all_edits:
225
+ if not isinstance(each_edit, dict):
226
+ return False
227
+ each_old = each_edit.get("old_string", "") or ""
228
+ each_new = each_edit.get("new_string", "") or ""
229
+ if not each_old:
230
+ return False
231
+ should_replace_all = bool(each_edit.get("replace_all", False))
232
+ post_edit_content = _apply_edit_to_content(
233
+ post_edit_content, each_old, each_new, should_replace_all
234
+ )
235
+ else:
236
+ return False
148
237
 
149
- if tool_name == "MultiEdit":
238
+ if _future_import_signatures(existing_content) != _future_import_signatures(post_edit_content):
239
+ return False
240
+ return _is_constants_only_python_content(post_edit_content)
241
+
242
+
243
+ def _top_level_signatures(content: str) -> tuple[list[str], list[str]] | None:
244
+ """Split a module's top-level statements into removable-import and other signatures.
245
+
246
+ Args:
247
+ content: Python source text to parse.
248
+
249
+ Returns:
250
+ A pair ``(import_signatures, non_import_signatures)`` of ``ast.dump``
251
+ strings in source order, or ``None`` when the content does not parse.
252
+ Plain imports populate the first list; ``from __future__`` imports and
253
+ every non-import statement populate the second, so editing a future
254
+ import reads as a behavior edit rather than a removable-import edit.
255
+ Signatures omit line and column attributes, so statements that only
256
+ shift position compare equal.
257
+ """
258
+ try:
259
+ parsed_tree = ast.parse(content)
260
+ except SyntaxError:
261
+ return None
262
+ import_signatures: list[str] = []
263
+ non_import_signatures: list[str] = []
264
+ for each_node in parsed_tree.body:
265
+ if _is_removable_import(each_node):
266
+ import_signatures.append(ast.dump(each_node))
267
+ else:
268
+ non_import_signatures.append(ast.dump(each_node))
269
+ return import_signatures, non_import_signatures
270
+
271
+
272
+ def _is_post_edit_import_only(existing_content: str, tool_name: str, tool_input: dict) -> bool:
273
+ """Check whether an Edit or MultiEdit only removes or reorders imports.
274
+
275
+ The top-level statements are split into imports and the rest, before and
276
+ after applying the edit. The edit is exempt only when the non-import
277
+ statements are unchanged and every post-edit import statement already
278
+ appears among the pre-edit imports, so removing or reordering imports is
279
+ exempt while adding, swapping, or retargeting an import stays gated: those
280
+ can change behavior through a new symbol in scope, a different
281
+ implementation bound to the same name, or `from __future__` semantics. An
282
+ edit that leaves the parsed module identical (a comment- or whitespace-only
283
+ change) also stays gated. Reading the resulting file rather than the edit
284
+ fragments keeps the exemption from firing on import text inside a string
285
+ literal, and lets it fire on edits whose old string carries surrounding
286
+ context for uniqueness.
287
+
288
+ Args:
289
+ existing_content: Current text of the file under edit.
290
+ tool_name: The intercepted tool (Edit or MultiEdit).
291
+ tool_input: The intercepted tool's input payload.
292
+
293
+ Returns:
294
+ True when the edit leaves the non-import statements unchanged and the
295
+ post-edit imports are a reordering or removal of the pre-edit imports.
296
+ """
297
+ existing_signatures = _top_level_signatures(existing_content)
298
+ if existing_signatures is None:
299
+ return False
300
+
301
+ if tool_name == "Edit":
302
+ old_str = tool_input.get("old_string", "") or ""
303
+ new_str = tool_input.get("new_string", "") or ""
304
+ if not old_str:
305
+ return False
306
+ should_replace_all = bool(tool_input.get("replace_all", False))
307
+ post_edit_content = _apply_edit_to_content(existing_content, old_str, new_str, should_replace_all)
308
+ elif tool_name == "MultiEdit":
150
309
  all_edits = tool_input.get("edits", []) or []
310
+ if not all_edits:
311
+ return False
151
312
  post_edit_content = existing_content
152
313
  for each_edit in all_edits:
153
314
  if not isinstance(each_edit, dict):
154
315
  return False
155
- each_old = each_edit.get("old_string", "")
316
+ each_old = each_edit.get("old_string", "") or ""
156
317
  each_new = each_edit.get("new_string", "") or ""
157
318
  if not each_old:
158
319
  return False
159
- post_edit_content = _apply_edit_to_content(post_edit_content, each_old, each_new)
160
- return _is_constants_only_python_content(post_edit_content)
320
+ should_replace_all = bool(each_edit.get("replace_all", False))
321
+ post_edit_content = _apply_edit_to_content(
322
+ post_edit_content, each_old, each_new, should_replace_all
323
+ )
324
+ else:
325
+ return False
161
326
 
162
- return False
327
+ post_edit_signatures = _top_level_signatures(post_edit_content)
328
+ if post_edit_signatures is None:
329
+ return False
330
+ existing_imports, existing_rest = existing_signatures
331
+ post_imports, post_rest = post_edit_signatures
332
+ if post_rest != existing_rest or post_imports == existing_imports:
333
+ return False
334
+ return Counter(post_imports) <= Counter(existing_imports)
163
335
 
164
336
 
165
337
  def _tests_directory_name() -> str:
@@ -194,18 +366,27 @@ def _is_repo_boundary(candidate_directory: Path) -> bool:
194
366
  return False
195
367
 
196
368
 
197
- def find_nearest_tests_directory(start_directory: Path) -> Path | None:
369
+ def _ancestor_tests_directories(start_directory: Path) -> list[tuple[Path, Path]]:
370
+ """Collect each ancestor's sibling tests directory up to the repo boundary.
371
+
372
+ Args:
373
+ start_directory: Directory of the production file under edit.
374
+
375
+ Returns:
376
+ Ordered (ancestor, tests_directory) pairs; nearer ancestors first.
377
+ """
378
+ all_pairs: list[tuple[Path, Path]] = []
198
379
  current_directory = start_directory
199
380
  for _ in range(_parent_walk_limit()):
200
381
  sibling_tests = current_directory / _tests_directory_name()
201
382
  if sibling_tests.is_dir():
202
- return sibling_tests
383
+ all_pairs.append((current_directory, sibling_tests))
203
384
  if _is_repo_boundary(current_directory):
204
- return None
385
+ break
205
386
  if current_directory.parent == current_directory:
206
- return None
387
+ break
207
388
  current_directory = current_directory.parent
208
- return None
389
+ return all_pairs
209
390
 
210
391
 
211
392
  def _split_module_stem_prefix() -> str:
@@ -225,6 +406,11 @@ def _split_family_candidates(directory: Path, stem: str) -> list[Path]:
225
406
  def candidate_test_paths_for(production_path: Path) -> list[Path]:
226
407
  """Return the test files whose freshness can satisfy the gate for a production file.
227
408
 
409
+ Every ancestor ``tests`` directory contributes a flat candidate
410
+ (``tests/test_<stem>.py``) and package-mirroring nested candidates
411
+ (``tests/<subpackage path>/test_<stem>.py``), so repos that keep tests
412
+ either beside the package or in a category tree both resolve.
413
+
228
414
  For ``code_rules_*`` Python modules the candidate list is extended with the
229
415
  sibling split test family (``test_code_rules_enforcer_*.py``), because that
230
416
  family collectively covers the split check modules; a fresh edit to any
@@ -247,9 +433,12 @@ def candidate_test_paths_for(production_path: Path) -> list[Path]:
247
433
  if extension == ".py":
248
434
  all_candidates.append(directory / f"test_{stem}.py")
249
435
  all_candidates.append(directory / f"{stem}_test.py")
250
- nearest_tests_directory = find_nearest_tests_directory(directory)
251
- if nearest_tests_directory is not None:
252
- all_candidates.append(nearest_tests_directory / f"test_{stem}.py")
436
+ for each_ancestor, each_tests_directory in _ancestor_tests_directories(directory):
437
+ all_candidates.append(each_tests_directory / f"test_{stem}.py")
438
+ nested_directory = each_tests_directory
439
+ for each_relative_part in directory.relative_to(each_ancestor).parts:
440
+ nested_directory = nested_directory / each_relative_part
441
+ all_candidates.append(nested_directory / f"test_{stem}.py")
253
442
  all_candidates.extend(_split_family_candidates(directory, stem))
254
443
  return all_candidates
255
444
 
@@ -423,6 +612,9 @@ def main() -> None:
423
612
  if tool_name in ("Edit", "MultiEdit") and ext == ".py" and path.exists():
424
613
  existing_content = _read_candidate_text(path)
425
614
  if existing_content is not None:
615
+ if _is_post_edit_import_only(existing_content, tool_name, tool_input):
616
+ emit_allow()
617
+ sys.exit(0)
426
618
  if _is_post_edit_constants_only(existing_content, tool_name, tool_input):
427
619
  emit_allow()
428
620
  sys.exit(0)