claude-dev-env 1.38.0 → 1.38.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.
@@ -2580,7 +2580,30 @@ def _collect_load_names_outside_import_ranges(
2580
2580
  return referenced_names
2581
2581
 
2582
2582
 
2583
- def check_unused_module_level_imports(content: str, file_path: str) -> list[str]:
2583
+ def _module_declares_dunder_all(tree: ast.Module) -> bool:
2584
+ """Return True when the module body assigns or annotates ``__all__``."""
2585
+ return any(
2586
+ (
2587
+ isinstance(each_node, ast.Assign)
2588
+ and any(
2589
+ isinstance(each_target, ast.Name) and each_target.id == "__all__"
2590
+ for each_target in each_node.targets
2591
+ )
2592
+ )
2593
+ or (
2594
+ isinstance(each_node, ast.AnnAssign)
2595
+ and isinstance(each_node.target, ast.Name)
2596
+ and each_node.target.id == "__all__"
2597
+ )
2598
+ for each_node in tree.body
2599
+ )
2600
+
2601
+
2602
+ def check_unused_module_level_imports(
2603
+ content: str,
2604
+ file_path: str,
2605
+ full_file_content: str | None = None,
2606
+ ) -> list[str]:
2584
2607
  """Flag module-level imports that are never referenced in the rest of the file.
2585
2608
 
2586
2609
  References are detected from AST ``Name`` / ``Attribute`` loads outside import
@@ -2589,42 +2612,39 @@ def check_unused_module_level_imports(content: str, file_path: str) -> list[str]
2589
2612
  whose module body includes ``if TYPE_CHECKING:`` (or
2590
2613
  ``typing[._extensions].TYPE_CHECKING``) are skipped. Suppression honors bare
2591
2614
  ``# noqa`` or an explicit ``F401`` code in the noqa list only.
2615
+
2616
+ When ``full_file_content`` is provided, ``content`` is treated as an Edit
2617
+ fragment containing the imports being added or replaced, while the
2618
+ ``__all__`` / ``TYPE_CHECKING`` gate detection and reference scanning run
2619
+ against ``full_file_content`` (the post-edit file as it will look once the
2620
+ Edit applies). This prevents false-positive flags on imports added in the
2621
+ same Edit as their consumers.
2592
2622
  """
2593
2623
  if is_test_file(file_path):
2594
2624
  return []
2595
2625
  if is_workflow_registry_file(file_path) or is_migration_file(file_path):
2596
2626
  return []
2597
2627
  try:
2598
- tree = ast.parse(content)
2628
+ fragment_tree = ast.parse(content)
2599
2629
  except SyntaxError:
2600
2630
  return []
2601
- file_declares_dunder_all = any(
2602
- (
2603
- isinstance(each_node, ast.Assign)
2604
- and any(
2605
- isinstance(each_target, ast.Name) and each_target.id == "__all__"
2606
- for each_target in each_node.targets
2607
- )
2608
- )
2609
- or (
2610
- isinstance(each_node, ast.AnnAssign)
2611
- and isinstance(each_node.target, ast.Name)
2612
- and each_node.target.id == "__all__"
2613
- )
2614
- for each_node in tree.body
2615
- )
2616
- if file_declares_dunder_all:
2631
+ reference_source = full_file_content if full_file_content is not None else content
2632
+ try:
2633
+ reference_tree = ast.parse(reference_source)
2634
+ except SyntaxError:
2635
+ return []
2636
+ if _module_declares_dunder_all(reference_tree):
2617
2637
  return []
2618
- if _module_body_declares_type_checking_gate(tree):
2638
+ if _module_body_declares_type_checking_gate(reference_tree):
2619
2639
  return []
2620
- content_lines = content.splitlines()
2621
- import_line_ranges = _import_statement_line_ranges(tree)
2640
+ fragment_lines = content.splitlines()
2641
+ reference_import_ranges = _import_statement_line_ranges(reference_tree)
2622
2642
  referenced_names = _collect_load_names_outside_import_ranges(
2623
- tree,
2624
- import_line_ranges,
2643
+ reference_tree,
2644
+ reference_import_ranges,
2625
2645
  )
2626
2646
  import_bindings: list[tuple[str, int, int | None]] = []
2627
- for each_node in tree.body:
2647
+ for each_node in fragment_tree.body:
2628
2648
  if isinstance(each_node, (ast.Import, ast.ImportFrom)):
2629
2649
  if isinstance(each_node, ast.ImportFrom) and each_node.module == "__future__":
2630
2650
  continue
@@ -2632,14 +2652,14 @@ def check_unused_module_level_imports(content: str, file_path: str) -> list[str]
2632
2652
  import_bindings.append(each_binding)
2633
2653
  issues: list[str] = []
2634
2654
  for each_name, each_line_number, each_from_keyword_line in import_bindings:
2635
- if 1 <= each_line_number <= len(content_lines):
2636
- if line_suppresses_unused_import_via_noqa(content_lines[each_line_number - 1]):
2655
+ if 1 <= each_line_number <= len(fragment_lines):
2656
+ if line_suppresses_unused_import_via_noqa(fragment_lines[each_line_number - 1]):
2637
2657
  continue
2638
2658
  if each_from_keyword_line is not None and 1 <= each_from_keyword_line <= len(
2639
- content_lines
2659
+ fragment_lines
2640
2660
  ):
2641
2661
  if line_suppresses_unused_import_via_noqa(
2642
- content_lines[each_from_keyword_line - 1]
2662
+ fragment_lines[each_from_keyword_line - 1]
2643
2663
  ):
2644
2664
  continue
2645
2665
  if each_name in referenced_names:
@@ -2904,14 +2924,25 @@ def check_return_annotations(content: str, file_path: str) -> list[str]:
2904
2924
  return issues
2905
2925
 
2906
2926
 
2907
- def validate_content(content: str, file_path: str, old_content: str = "") -> list[str]:
2927
+ def validate_content(
2928
+ content: str,
2929
+ file_path: str,
2930
+ old_content: str = "",
2931
+ full_file_content: str | None = None,
2932
+ ) -> list[str]:
2908
2933
  """Run all applicable validators on content.
2909
2934
 
2910
2935
  Args:
2911
- content: The new content being written.
2936
+ content: The new content being written. For Edit, this is the
2937
+ ``new_string`` fragment; for Write, the entire new file body.
2912
2938
  file_path: Path to the file.
2913
2939
  old_content: Previous content (old_string for Edit, existing file for Write).
2914
2940
  Used to detect comment additions/removals instead of flagging all comments.
2941
+ full_file_content: For Edit operations, the reconstructed post-edit
2942
+ content of the entire file (existing file with ``old_string`` replaced
2943
+ by ``new_string``). Whole-file checks such as the unused-import
2944
+ scanner use this to evaluate references across the file rather than
2945
+ just within the inserted fragment.
2915
2946
  """
2916
2947
  extension = get_file_extension(file_path)
2917
2948
  all_issues = []
@@ -2938,7 +2969,9 @@ def validate_content(content: str, file_path: str, old_content: str = "") -> lis
2938
2969
  all_issues.extend(check_stuttering_collection_prefix(content, file_path))
2939
2970
  all_issues.extend(check_hardcoded_user_paths(content, file_path))
2940
2971
  all_issues.extend(check_sys_path_insert_deduplication_guard(content, file_path))
2941
- all_issues.extend(check_unused_module_level_imports(content, file_path))
2972
+ all_issues.extend(
2973
+ check_unused_module_level_imports(content, file_path, full_file_content)
2974
+ )
2942
2975
  all_issues.extend(check_library_print(content, file_path))
2943
2976
  all_issues.extend(check_parameter_annotations(content, file_path))
2944
2977
  all_issues.extend(check_return_annotations(content, file_path))
@@ -2959,6 +2992,30 @@ def validate_content(content: str, file_path: str, old_content: str = "") -> lis
2959
2992
  return all_issues
2960
2993
 
2961
2994
 
2995
+ def _reconstruct_post_edit_file_content(
2996
+ file_path: str, old_string: str, new_string: str,
2997
+ ) -> str | None:
2998
+ """Return the file content as it will look after the Edit applies, or None.
2999
+
3000
+ Reads ``file_path`` from disk and replaces the first occurrence of
3001
+ ``old_string`` with ``new_string``, mirroring how the Edit tool itself
3002
+ applies a single replacement. Returns None when the file cannot be read,
3003
+ ``old_string`` is empty, or ``old_string`` is not present in the existing
3004
+ file (which means the Edit will fail or has already been applied — neither
3005
+ case yields a well-defined post-edit view).
3006
+ """
3007
+ if not old_string:
3008
+ return None
3009
+ try:
3010
+ with open(file_path, "r", encoding="utf-8") as existing_file:
3011
+ existing_content = existing_file.read()
3012
+ except (FileNotFoundError, OSError, UnicodeDecodeError):
3013
+ return None
3014
+ if old_string not in existing_content:
3015
+ return None
3016
+ return existing_content.replace(old_string, new_string, 1)
3017
+
3018
+
2962
3019
  def main() -> None:
2963
3020
  try:
2964
3021
  input_data = json.load(sys.stdin)
@@ -2980,9 +3037,13 @@ def main() -> None:
2980
3037
  sys.exit(0)
2981
3038
 
2982
3039
  old_content = ""
3040
+ full_file_content_after_edit: str | None = None
2983
3041
  if tool_name == "Edit":
2984
3042
  content = tool_input.get("new_string", "")
2985
3043
  old_content = tool_input.get("old_string", "")
3044
+ full_file_content_after_edit = _reconstruct_post_edit_file_content(
3045
+ file_path, old_content, content,
3046
+ )
2986
3047
  else:
2987
3048
  content = tool_input.get("content", "") or tool_input.get("new_string", "")
2988
3049
  try:
@@ -2997,7 +3058,7 @@ def main() -> None:
2997
3058
  if not content:
2998
3059
  sys.exit(0)
2999
3060
 
3000
- issues = validate_content(content, file_path, old_content)
3061
+ issues = validate_content(content, file_path, old_content, full_file_content_after_edit)
3001
3062
 
3002
3063
  if issues:
3003
3064
  issue_list = "; ".join(issues[:10])
@@ -30,6 +30,7 @@ assert _hook_spec.loader is not None
30
30
  _hook_module = importlib.util.module_from_spec(_hook_spec)
31
31
  _hook_spec.loader.exec_module(_hook_module)
32
32
  check_unused_module_level_imports = _hook_module.check_unused_module_level_imports
33
+ _reconstruct_post_edit_file_content = _hook_module._reconstruct_post_edit_file_content
33
34
 
34
35
 
35
36
  PRODUCTION_FILE_PATH = "packages/app/services/loader.py"
@@ -297,6 +298,163 @@ def test_should_skip_when_noqa_is_bare() -> None:
297
298
  assert issues == [], f"Bare noqa must suppress unused import, got: {issues}"
298
299
 
299
300
 
301
+ def test_should_not_flag_imports_referenced_only_in_full_file_content() -> None:
302
+ fragment = "from config.constants import NEW_NAME\n"
303
+ full_file = (
304
+ "from config.constants import NEW_NAME\n"
305
+ "\n"
306
+ "def existing_function() -> str:\n"
307
+ " return NEW_NAME\n"
308
+ )
309
+ issues = check_unused_module_level_imports(
310
+ fragment, PRODUCTION_FILE_PATH, full_file_content=full_file,
311
+ )
312
+ assert issues == [], (
313
+ f"When the post-edit file references the import, it must not flag, got: {issues}"
314
+ )
315
+
316
+
317
+ def test_should_flag_imports_unused_in_full_file_content() -> None:
318
+ fragment = "from config.constants import TRULY_UNUSED\n"
319
+ full_file = (
320
+ "from config.constants import TRULY_UNUSED\n"
321
+ "\n"
322
+ "def existing_function() -> None:\n"
323
+ " return None\n"
324
+ )
325
+ issues = check_unused_module_level_imports(
326
+ fragment, PRODUCTION_FILE_PATH, full_file_content=full_file,
327
+ )
328
+ assert any("TRULY_UNUSED" in each_issue for each_issue in issues), (
329
+ f"Imports unused in the post-edit file must still flag, got: {issues}"
330
+ )
331
+
332
+
333
+ def test_should_only_flag_imports_in_fragment_not_full_file() -> None:
334
+ fragment = "from config.constants import FRAGMENT_IMPORT\n"
335
+ full_file = (
336
+ "from config.other import PRE_EXISTING_UNUSED\n"
337
+ "from config.constants import FRAGMENT_IMPORT\n"
338
+ "\n"
339
+ "def existing_function() -> str:\n"
340
+ " return FRAGMENT_IMPORT\n"
341
+ )
342
+ issues = check_unused_module_level_imports(
343
+ fragment, PRODUCTION_FILE_PATH, full_file_content=full_file,
344
+ )
345
+ assert issues == [], (
346
+ "Pre-existing imports outside the fragment must not be flagged on Edit; "
347
+ f"got: {issues}"
348
+ )
349
+
350
+
351
+ def test_should_skip_when_full_file_declares_dunder_all() -> None:
352
+ fragment = "from config.constants import NEW_NAME\n"
353
+ full_file = (
354
+ "from config.constants import NEW_NAME\n"
355
+ "\n"
356
+ "__all__ = ['NEW_NAME']\n"
357
+ )
358
+ issues = check_unused_module_level_imports(
359
+ fragment, PRODUCTION_FILE_PATH, full_file_content=full_file,
360
+ )
361
+ assert issues == [], (
362
+ "__all__ in the post-edit file must skip the scan even when the fragment "
363
+ f"itself does not contain __all__, got: {issues}"
364
+ )
365
+
366
+
367
+ def test_should_skip_when_full_file_uses_type_checking_gate() -> None:
368
+ fragment = "from config.constants import NEW_NAME\n"
369
+ full_file = (
370
+ "from typing import TYPE_CHECKING\n"
371
+ "from config.constants import NEW_NAME\n"
372
+ "\n"
373
+ "if TYPE_CHECKING:\n"
374
+ " from somewhere import OtherName\n"
375
+ "\n"
376
+ "def run() -> None:\n"
377
+ " return None\n"
378
+ )
379
+ issues = check_unused_module_level_imports(
380
+ fragment, PRODUCTION_FILE_PATH, full_file_content=full_file,
381
+ )
382
+ assert issues == [], (
383
+ "TYPE_CHECKING gate in the post-edit file must skip the scan, "
384
+ f"got: {issues}"
385
+ )
386
+
387
+
388
+ def test_should_fall_back_to_content_when_full_file_content_is_none() -> None:
389
+ source = (
390
+ "from config.constants import VENV_DIRECTORY_NAME\n"
391
+ "\n"
392
+ "def run() -> None:\n"
393
+ " return None\n"
394
+ )
395
+ issues = check_unused_module_level_imports(source, PRODUCTION_FILE_PATH, full_file_content=None)
396
+ assert any("VENV_DIRECTORY_NAME" in each_issue for each_issue in issues), (
397
+ f"Backward-compat: with full_file_content=None, behavior must match "
398
+ f"the existing single-argument scan, got: {issues}"
399
+ )
400
+
401
+
402
+ def test_should_fall_back_when_full_file_content_has_syntax_error() -> None:
403
+ fragment = "from config.constants import NEW_NAME\n"
404
+ full_file_with_syntax_error = "from config import (\n not python\n"
405
+ issues = check_unused_module_level_imports(
406
+ fragment, PRODUCTION_FILE_PATH, full_file_content=full_file_with_syntax_error,
407
+ )
408
+ assert issues == [], (
409
+ "When the reconstructed post-edit content cannot be parsed, return empty "
410
+ f"rather than raising, got: {issues}"
411
+ )
412
+
413
+
414
+ def test_reconstruct_post_edit_returns_replaced_content(tmp_path: pathlib.Path) -> None:
415
+ target_file = tmp_path / "module.py"
416
+ target_file.write_text("ALPHA = 1\nBETA = 2\n", encoding="utf-8")
417
+ post_edit = _reconstruct_post_edit_file_content(
418
+ str(target_file), "BETA = 2", "BETA = 22\nGAMMA = 3",
419
+ )
420
+ assert post_edit == "ALPHA = 1\nBETA = 22\nGAMMA = 3\n", (
421
+ f"Helper must return the file body with the first occurrence replaced, got: {post_edit!r}"
422
+ )
423
+
424
+
425
+ def test_reconstruct_post_edit_returns_none_when_file_missing(tmp_path: pathlib.Path) -> None:
426
+ missing_file = tmp_path / "does_not_exist.py"
427
+ post_edit = _reconstruct_post_edit_file_content(
428
+ str(missing_file), "any_old", "any_new",
429
+ )
430
+ assert post_edit is None, (
431
+ f"Missing file must yield None so the caller treats it as 'no full-file context', got: {post_edit!r}"
432
+ )
433
+
434
+
435
+ def test_reconstruct_post_edit_returns_none_when_old_string_absent(tmp_path: pathlib.Path) -> None:
436
+ target_file = tmp_path / "module.py"
437
+ target_file.write_text("ALPHA = 1\n", encoding="utf-8")
438
+ post_edit = _reconstruct_post_edit_file_content(
439
+ str(target_file), "ZETA = 9", "OMEGA = 0",
440
+ )
441
+ assert post_edit is None, (
442
+ f"Absent old_string means the Edit will not apply cleanly — return None, got: {post_edit!r}"
443
+ )
444
+
445
+
446
+ def test_reconstruct_post_edit_replaces_only_first_occurrence(tmp_path: pathlib.Path) -> None:
447
+ target_file = tmp_path / "module.py"
448
+ target_file.write_text("X = 1\nX = 1\n", encoding="utf-8")
449
+ post_edit = _reconstruct_post_edit_file_content(
450
+ str(target_file), "X = 1", "X = 2",
451
+ )
452
+ assert post_edit == "X = 2\nX = 1\n", (
453
+ "Edit replaces only the first occurrence; helper must mirror that, got: "
454
+ f"{post_edit!r}"
455
+ )
456
+
457
+
300
458
  def test_should_flag_import_when_only_shadowed_local_name_is_loaded() -> None:
301
459
  source = (
302
460
  "import json\n"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "1.38.0",
3
+ "version": "1.38.1",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {