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
|
|
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
|
-
|
|
2628
|
+
fragment_tree = ast.parse(content)
|
|
2599
2629
|
except SyntaxError:
|
|
2600
2630
|
return []
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
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(
|
|
2638
|
+
if _module_body_declares_type_checking_gate(reference_tree):
|
|
2619
2639
|
return []
|
|
2620
|
-
|
|
2621
|
-
|
|
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
|
-
|
|
2624
|
-
|
|
2643
|
+
reference_tree,
|
|
2644
|
+
reference_import_ranges,
|
|
2625
2645
|
)
|
|
2626
2646
|
import_bindings: list[tuple[str, int, int | None]] = []
|
|
2627
|
-
for each_node in
|
|
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(
|
|
2636
|
-
if line_suppresses_unused_import_via_noqa(
|
|
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
|
-
|
|
2659
|
+
fragment_lines
|
|
2640
2660
|
):
|
|
2641
2661
|
if line_suppresses_unused_import_via_noqa(
|
|
2642
|
-
|
|
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(
|
|
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(
|
|
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"
|