claude-dev-env 1.36.2 → 1.37.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.
- package/_shared/pr-loop/scripts/config/preflight_constants.py +29 -8
- package/_shared/pr-loop/scripts/preflight.py +242 -20
- package/_shared/pr-loop/scripts/tests/test_preflight.py +362 -25
- package/_shared/pr-loop/scripts/tests/test_preflight_constants.py +9 -14
- package/hooks/blocking/code_rules_enforcer.py +269 -23
- package/hooks/blocking/test_code_rules_enforcer_unused_imports.py +157 -1
- package/hooks/config/test_unused_module_import_constants.py +48 -0
- package/hooks/config/unused_module_import_constants.py +41 -0
- package/package.json +1 -1
- package/rules/gh-paginate.md +4 -50
- package/rules/no-historical-clutter.md +36 -0
- package/skills/bg-agent/SKILL.md +69 -0
- package/skills/bugteam/CONSTRAINTS.md +10 -19
- package/skills/bugteam/PROMPTS.md +21 -14
- package/skills/bugteam/SKILL.md +122 -208
- package/skills/bugteam/SKILL_EVALS.md +75 -114
- package/skills/bugteam/reference/README.md +2 -4
- package/skills/bugteam/reference/audit-and-teammates.md +21 -48
- package/skills/bugteam/reference/audit-contract.md +7 -7
- package/skills/bugteam/reference/design-rationale.md +3 -8
- package/skills/bugteam/reference/team-setup.md +11 -19
- package/skills/bugteam/reference/teardown-publish-permissions.md +2 -14
- package/skills/bugteam/scripts/config/__init__.py +0 -0
- package/skills/bugteam/scripts/config/reflow_skill_md_constants.py +12 -0
- package/skills/bugteam/scripts/reflow_skill_md.py +51 -47
- package/skills/bugteam/sources.md +1 -25
- package/skills/bugteam/test_skill_additions.py +4 -13
- package/skills/fresh-branch/SKILL.md +71 -0
- package/skills/gotcha/SKILL.md +73 -0
- package/skills/monitor-open-prs/SKILL.md +4 -37
- package/skills/monitor-open-prs/test_skill_contract.py +0 -5
- package/skills/pr-converge/SKILL.md +60 -1298
- package/skills/pr-converge/reference/convergence-gates.md +122 -0
- package/skills/pr-converge/reference/examples.md +76 -0
- package/skills/pr-converge/reference/fix-protocol.md +56 -0
- package/skills/pr-converge/reference/ground-rules.md +13 -0
- package/skills/pr-converge/reference/multi-pr-orchestration.md +204 -0
- package/skills/pr-converge/reference/per-tick.md +204 -0
- package/skills/pr-converge/reference/state-schema.md +19 -0
- package/skills/pr-converge/reference/stop-conditions.md +26 -0
- package/skills/pr-converge/scripts/README.md +36 -9
- package/skills/pr-converge/scripts/check_pr_mergeability.py +1 -2
- package/skills/pr-converge/scripts/config/pr_converge_constants.py +74 -5
- package/skills/pr-converge/scripts/config/reflow_skill_md_constants.py +13 -0
- package/skills/pr-converge/scripts/config/test_pr_converge_constants.py +0 -24
- package/skills/pr-converge/scripts/cursor-agents-continue.ahk +22 -2
- package/skills/pr-converge/scripts/fetch_bugbot_inline_comments.py +19 -59
- package/skills/pr-converge/scripts/fetch_bugbot_reviews.py +15 -61
- package/skills/pr-converge/scripts/fetch_claude_inline_comments.py +70 -0
- package/skills/pr-converge/scripts/fetch_claude_reviews.py +61 -0
- package/skills/pr-converge/scripts/fetch_copilot_inline_comments.py +19 -61
- package/skills/pr-converge/scripts/fetch_copilot_reviews.py +14 -74
- package/skills/pr-converge/scripts/reflow_skill_md.py +71 -50
- package/skills/pr-converge/scripts/reviewer_fetch_core.py +153 -0
- package/skills/pr-converge/scripts/reviewer_specs.py +98 -0
- package/skills/pr-converge/scripts/test_cursor_agents_continue.py +65 -0
- package/skills/pr-converge/scripts/test_fetch_bugbot_inline_comments.py +107 -6
- package/skills/pr-converge/scripts/test_fetch_bugbot_reviews.py +85 -6
- package/skills/pr-converge/scripts/test_fetch_claude_inline_comments.py +485 -0
- package/skills/pr-converge/scripts/test_fetch_claude_reviews.py +368 -0
- package/skills/pr-converge/scripts/test_fetch_copilot_inline_comments.py +74 -6
- package/skills/pr-converge/scripts/test_fetch_copilot_reviews.py +94 -8
- package/skills/pr-converge/scripts/test_reflow_skill_md.py +162 -0
- package/skills/pr-converge/scripts/test_reviewer_fetch_core.py +448 -0
- package/skills/pr-converge/scripts/test_reviewer_specs.py +107 -0
- package/skills/pr-converge/scripts/test_view_pr_context.py +44 -0
- package/skills/pr-converge/scripts/view_pr_context.py +35 -4
- package/skills/pr-converge/workflows/schedule-wakeup-loop.md +24 -22
- package/skills/bugteam/reference/workflow-path-a-orchestrated-teams.md +0 -113
- package/skills/bugteam/reference/workflow-path-b-task-harness.md +0 -48
- package/skills/bugteam/test_team_lifecycle.py +0 -103
- package/skills/monitor-open-prs/test_team_lifecycle.py +0 -46
- package/skills/pr-converge/scripts/open_followup_copilot_pr.py +0 -136
- package/skills/pr-converge/scripts/test_open_followup_copilot_pr.py +0 -236
- package/skills/pr-converge/test_team_lifecycle.py +0 -56
- package/skills/pr-converge/workflows/ahk-auto-continue-loop.md +0 -108
|
@@ -57,9 +57,11 @@ from config.stuttering_check_config import ( # noqa: E402
|
|
|
57
57
|
)
|
|
58
58
|
from config.sys_path_insert_constants import MAX_SYS_PATH_INSERT_ISSUES, SYS_PATH_INSERT_GUIDANCE # noqa: E402
|
|
59
59
|
from config.unused_module_import_constants import ( # noqa: E402
|
|
60
|
+
ALL_TYPING_MODULE_NAMES,
|
|
60
61
|
MAX_UNUSED_IMPORT_ISSUES,
|
|
61
62
|
TYPE_CHECKING_IDENTIFIER,
|
|
62
63
|
UNUSED_IMPORT_GUIDANCE,
|
|
64
|
+
line_suppresses_unused_import_via_noqa,
|
|
63
65
|
)
|
|
64
66
|
from config.stuttering_import_binding_constants import ( # noqa: E402
|
|
65
67
|
AST_LINENO_ATTRIBUTE,
|
|
@@ -2324,30 +2326,269 @@ def _import_alias_pairs(
|
|
|
2324
2326
|
return bindings
|
|
2325
2327
|
|
|
2326
2328
|
|
|
2327
|
-
def
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2329
|
+
def _import_statement_line_ranges(tree: ast.Module) -> list[tuple[int, int]]:
|
|
2330
|
+
ranges: list[tuple[int, int]] = []
|
|
2331
|
+
for each_node in tree.body:
|
|
2332
|
+
if isinstance(each_node, (ast.Import, ast.ImportFrom)):
|
|
2333
|
+
start_line = each_node.lineno
|
|
2334
|
+
end_line = each_node.end_lineno or each_node.lineno
|
|
2335
|
+
ranges.append((start_line, end_line))
|
|
2336
|
+
return ranges
|
|
2337
|
+
|
|
2338
|
+
|
|
2339
|
+
def _line_number_falls_in_import_ranges(
|
|
2340
|
+
line_number: int,
|
|
2341
|
+
all_import_line_ranges: list[tuple[int, int]],
|
|
2331
2342
|
) -> bool:
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
if each_line_index in all_import_line_numbers:
|
|
2335
|
-
continue
|
|
2336
|
-
if name_pattern.search(each_line):
|
|
2343
|
+
for each_start, each_end in all_import_line_ranges:
|
|
2344
|
+
if each_start <= line_number <= each_end:
|
|
2337
2345
|
return True
|
|
2338
2346
|
return False
|
|
2339
2347
|
|
|
2340
2348
|
|
|
2341
|
-
def
|
|
2342
|
-
|
|
2349
|
+
def _type_checking_guard_aliases(tree: ast.Module) -> tuple[set[str], set[str]]:
|
|
2350
|
+
all_type_checking_names = {TYPE_CHECKING_IDENTIFIER}
|
|
2351
|
+
all_type_checking_module_aliases = set(ALL_TYPING_MODULE_NAMES)
|
|
2352
|
+
for each_statement in tree.body:
|
|
2353
|
+
if isinstance(each_statement, ast.Import):
|
|
2354
|
+
for each_alias in each_statement.names:
|
|
2355
|
+
if each_alias.name in ALL_TYPING_MODULE_NAMES:
|
|
2356
|
+
all_type_checking_module_aliases.add(
|
|
2357
|
+
each_alias.asname or each_alias.name
|
|
2358
|
+
)
|
|
2359
|
+
elif isinstance(each_statement, ast.ImportFrom):
|
|
2360
|
+
if each_statement.module not in ALL_TYPING_MODULE_NAMES:
|
|
2361
|
+
continue
|
|
2362
|
+
for each_alias in each_statement.names:
|
|
2363
|
+
if each_alias.name == TYPE_CHECKING_IDENTIFIER:
|
|
2364
|
+
all_type_checking_names.add(each_alias.asname or each_alias.name)
|
|
2365
|
+
return all_type_checking_names, all_type_checking_module_aliases
|
|
2366
|
+
|
|
2367
|
+
|
|
2368
|
+
def _expression_guards_type_checking_block(
|
|
2369
|
+
test_expression: ast.expr,
|
|
2370
|
+
all_type_checking_names: set[str],
|
|
2371
|
+
all_type_checking_module_aliases: set[str],
|
|
2372
|
+
) -> bool:
|
|
2373
|
+
if isinstance(test_expression, ast.Name):
|
|
2374
|
+
return test_expression.id in all_type_checking_names
|
|
2375
|
+
if isinstance(test_expression, ast.Attribute):
|
|
2376
|
+
if test_expression.attr != TYPE_CHECKING_IDENTIFIER:
|
|
2377
|
+
return False
|
|
2378
|
+
receiver = test_expression.value
|
|
2379
|
+
return (
|
|
2380
|
+
isinstance(receiver, ast.Name)
|
|
2381
|
+
and receiver.id in all_type_checking_module_aliases
|
|
2382
|
+
)
|
|
2383
|
+
return False
|
|
2384
|
+
|
|
2385
|
+
|
|
2386
|
+
def _module_body_declares_type_checking_gate(tree: ast.Module) -> bool:
|
|
2387
|
+
(
|
|
2388
|
+
all_type_checking_names,
|
|
2389
|
+
all_type_checking_module_aliases,
|
|
2390
|
+
) = _type_checking_guard_aliases(tree)
|
|
2391
|
+
return any(
|
|
2392
|
+
isinstance(each_statement, ast.If)
|
|
2393
|
+
and _expression_guards_type_checking_block(
|
|
2394
|
+
each_statement.test,
|
|
2395
|
+
all_type_checking_names,
|
|
2396
|
+
all_type_checking_module_aliases,
|
|
2397
|
+
)
|
|
2398
|
+
for each_statement in tree.body
|
|
2399
|
+
)
|
|
2400
|
+
|
|
2401
|
+
|
|
2402
|
+
def _attribute_root_name_if_loaded(attribute_node: ast.Attribute) -> ast.Name | None:
|
|
2403
|
+
current: ast.expr = attribute_node
|
|
2404
|
+
while isinstance(current, ast.Attribute):
|
|
2405
|
+
current = current.value
|
|
2406
|
+
if isinstance(current, ast.Name) and isinstance(current.ctx, ast.Load):
|
|
2407
|
+
return current
|
|
2408
|
+
return None
|
|
2409
|
+
|
|
2410
|
+
|
|
2411
|
+
class _ScopeBindingCollector(ast.NodeVisitor):
|
|
2412
|
+
def __init__(self) -> None:
|
|
2413
|
+
self.binding_names: set[str] = set()
|
|
2414
|
+
self.global_names: set[str] = set()
|
|
2415
|
+
|
|
2416
|
+
def collect_arguments(self, arguments: ast.arguments) -> None:
|
|
2417
|
+
for each_argument in (
|
|
2418
|
+
arguments.posonlyargs
|
|
2419
|
+
+ arguments.args
|
|
2420
|
+
+ arguments.kwonlyargs
|
|
2421
|
+
):
|
|
2422
|
+
self.binding_names.add(each_argument.arg)
|
|
2423
|
+
if arguments.vararg is not None:
|
|
2424
|
+
self.binding_names.add(arguments.vararg.arg)
|
|
2425
|
+
if arguments.kwarg is not None:
|
|
2426
|
+
self.binding_names.add(arguments.kwarg.arg)
|
|
2427
|
+
|
|
2428
|
+
def visit_Global(self, node: ast.Global) -> None:
|
|
2429
|
+
self.global_names.update(node.names)
|
|
2430
|
+
|
|
2431
|
+
def visit_Nonlocal(self, node: ast.Nonlocal) -> None:
|
|
2432
|
+
self.binding_names.update(node.names)
|
|
2433
|
+
|
|
2434
|
+
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
|
2435
|
+
self.binding_names.add(node.name)
|
|
2436
|
+
|
|
2437
|
+
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
|
|
2438
|
+
self.binding_names.add(node.name)
|
|
2439
|
+
|
|
2440
|
+
def visit_ClassDef(self, node: ast.ClassDef) -> None:
|
|
2441
|
+
self.binding_names.add(node.name)
|
|
2442
|
+
|
|
2443
|
+
def visit_Lambda(self, node: ast.Lambda) -> None:
|
|
2444
|
+
return None
|
|
2445
|
+
|
|
2446
|
+
def visit_Name(self, node: ast.Name) -> None:
|
|
2447
|
+
if isinstance(node.ctx, ast.Store):
|
|
2448
|
+
self.binding_names.add(node.id)
|
|
2449
|
+
|
|
2450
|
+
def visit_Import(self, node: ast.Import) -> None:
|
|
2451
|
+
for each_alias in node.names:
|
|
2452
|
+
self.binding_names.add(each_alias.asname or each_alias.name.split(".")[0])
|
|
2453
|
+
|
|
2454
|
+
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
|
|
2455
|
+
for each_alias in node.names:
|
|
2456
|
+
if each_alias.name != WILDCARD_IMPORT_SENTINEL:
|
|
2457
|
+
self.binding_names.add(each_alias.asname or each_alias.name)
|
|
2458
|
+
|
|
2459
|
+
def visit_ListComp(self, node: ast.ListComp) -> None:
|
|
2460
|
+
return None
|
|
2461
|
+
|
|
2462
|
+
def visit_SetComp(self, node: ast.SetComp) -> None:
|
|
2463
|
+
return None
|
|
2464
|
+
|
|
2465
|
+
def visit_DictComp(self, node: ast.DictComp) -> None:
|
|
2466
|
+
return None
|
|
2467
|
+
|
|
2468
|
+
def visit_GeneratorExp(self, node: ast.GeneratorExp) -> None:
|
|
2469
|
+
return None
|
|
2470
|
+
|
|
2471
|
+
def visit_ExceptHandler(self, node: ast.ExceptHandler) -> None:
|
|
2472
|
+
if node.name is not None:
|
|
2473
|
+
self.binding_names.add(node.name)
|
|
2474
|
+
self.generic_visit(node)
|
|
2475
|
+
|
|
2476
|
+
|
|
2477
|
+
def _scope_binding_names(scope_node: ast.AST) -> tuple[set[str], set[str]]:
|
|
2478
|
+
collector = _ScopeBindingCollector()
|
|
2479
|
+
if isinstance(scope_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
2480
|
+
collector.collect_arguments(scope_node.args)
|
|
2481
|
+
for each_statement in scope_node.body:
|
|
2482
|
+
collector.visit(each_statement)
|
|
2483
|
+
elif isinstance(scope_node, ast.Lambda):
|
|
2484
|
+
collector.collect_arguments(scope_node.args)
|
|
2485
|
+
collector.visit(scope_node.body)
|
|
2486
|
+
elif isinstance(scope_node, ast.ClassDef):
|
|
2487
|
+
for each_statement in scope_node.body:
|
|
2488
|
+
collector.visit(each_statement)
|
|
2489
|
+
return collector.binding_names, collector.global_names
|
|
2490
|
+
|
|
2491
|
+
|
|
2492
|
+
def _load_name_is_shadowed(
|
|
2493
|
+
load_node: ast.AST,
|
|
2494
|
+
name: str,
|
|
2495
|
+
parent_by_node_id: dict[int, ast.AST],
|
|
2496
|
+
) -> bool:
|
|
2497
|
+
current = parent_by_node_id.get(id(load_node))
|
|
2498
|
+
has_passed_function_scope = False
|
|
2499
|
+
while current is not None:
|
|
2500
|
+
if isinstance(current, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)):
|
|
2501
|
+
has_passed_function_scope = True
|
|
2502
|
+
binding_names, global_names = _scope_binding_names(current)
|
|
2503
|
+
if name in global_names:
|
|
2504
|
+
return False
|
|
2505
|
+
if name in binding_names:
|
|
2506
|
+
return True
|
|
2507
|
+
elif isinstance(current, ast.ClassDef) and not has_passed_function_scope:
|
|
2508
|
+
# Class body bindings are order-dependent (name resolution is
|
|
2509
|
+
# dynamic, unlike function locals). A load before an assignment
|
|
2510
|
+
# still resolves to the module-level name, so conservatively
|
|
2511
|
+
# skip class-body shadow detection to avoid false positives.
|
|
2512
|
+
pass
|
|
2513
|
+
current = parent_by_node_id.get(id(current))
|
|
2514
|
+
return False
|
|
2515
|
+
|
|
2516
|
+
|
|
2517
|
+
def _names_from_annotation_text(annotation_text: str) -> set[str]:
|
|
2518
|
+
try:
|
|
2519
|
+
annotation_tree = ast.parse(annotation_text, mode="eval")
|
|
2520
|
+
except SyntaxError:
|
|
2521
|
+
return set()
|
|
2522
|
+
referenced_names: set[str] = set()
|
|
2523
|
+
for each_node in ast.walk(annotation_tree):
|
|
2524
|
+
if isinstance(each_node, ast.Name):
|
|
2525
|
+
referenced_names.add(each_node.id)
|
|
2526
|
+
elif isinstance(each_node, ast.Attribute):
|
|
2527
|
+
root_name = _attribute_root_name_if_loaded(each_node)
|
|
2528
|
+
if root_name is not None:
|
|
2529
|
+
referenced_names.add(root_name.id)
|
|
2530
|
+
return referenced_names
|
|
2531
|
+
|
|
2532
|
+
|
|
2533
|
+
def _collect_string_annotation_names(tree: ast.Module) -> set[str]:
|
|
2534
|
+
referenced_names: set[str] = set()
|
|
2535
|
+
for each_node in ast.walk(tree):
|
|
2536
|
+
annotation = None
|
|
2537
|
+
if isinstance(each_node, ast.arg):
|
|
2538
|
+
annotation = each_node.annotation
|
|
2539
|
+
elif isinstance(each_node, (ast.AnnAssign, ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
2540
|
+
annotation = each_node.annotation if isinstance(each_node, ast.AnnAssign) else each_node.returns
|
|
2541
|
+
if isinstance(annotation, ast.Constant) and isinstance(annotation.value, str):
|
|
2542
|
+
referenced_names.update(_names_from_annotation_text(annotation.value))
|
|
2543
|
+
return referenced_names
|
|
2544
|
+
|
|
2545
|
+
|
|
2546
|
+
def _collect_load_names_outside_import_ranges(
|
|
2547
|
+
tree: ast.Module,
|
|
2548
|
+
all_import_line_ranges: list[tuple[int, int]],
|
|
2549
|
+
) -> set[str]:
|
|
2550
|
+
parent_by_node_id = _build_parent_map(tree)
|
|
2551
|
+
referenced_names: set[str] = set()
|
|
2552
|
+
for each_node in ast.walk(tree):
|
|
2553
|
+
if isinstance(each_node, ast.Name) and isinstance(each_node.ctx, ast.Load):
|
|
2554
|
+
line_number = each_node.lineno
|
|
2555
|
+
if line_number is None or _line_number_falls_in_import_ranges(
|
|
2556
|
+
line_number,
|
|
2557
|
+
all_import_line_ranges,
|
|
2558
|
+
):
|
|
2559
|
+
continue
|
|
2560
|
+
if _load_name_is_shadowed(each_node, each_node.id, parent_by_node_id):
|
|
2561
|
+
continue
|
|
2562
|
+
referenced_names.add(each_node.id)
|
|
2563
|
+
elif isinstance(each_node, ast.Attribute) and isinstance(
|
|
2564
|
+
each_node.ctx, ast.Load
|
|
2565
|
+
):
|
|
2566
|
+
line_number = each_node.lineno
|
|
2567
|
+
if line_number is None or _line_number_falls_in_import_ranges(
|
|
2568
|
+
line_number,
|
|
2569
|
+
all_import_line_ranges,
|
|
2570
|
+
):
|
|
2571
|
+
continue
|
|
2572
|
+
root_name = _attribute_root_name_if_loaded(each_node)
|
|
2573
|
+
if root_name is not None and not _load_name_is_shadowed(
|
|
2574
|
+
root_name,
|
|
2575
|
+
root_name.id,
|
|
2576
|
+
parent_by_node_id,
|
|
2577
|
+
):
|
|
2578
|
+
referenced_names.add(root_name.id)
|
|
2579
|
+
referenced_names.update(_collect_string_annotation_names(tree))
|
|
2580
|
+
return referenced_names
|
|
2343
2581
|
|
|
2344
2582
|
|
|
2345
2583
|
def check_unused_module_level_imports(content: str, file_path: str) -> list[str]:
|
|
2346
2584
|
"""Flag module-level imports that are never referenced in the rest of the file.
|
|
2347
2585
|
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2586
|
+
References are detected from AST ``Name`` / ``Attribute`` loads outside import
|
|
2587
|
+
statements so mentions in comments or string literals do not count. Files
|
|
2588
|
+
declaring ``__all__`` (including annotated assignments) are skipped. Files
|
|
2589
|
+
whose module body includes ``if TYPE_CHECKING:`` (or
|
|
2590
|
+
``typing[._extensions].TYPE_CHECKING``) are skipped. Suppression honors bare
|
|
2591
|
+
``# noqa`` or an explicit ``F401`` code in the noqa list only.
|
|
2351
2592
|
"""
|
|
2352
2593
|
if is_test_file(file_path):
|
|
2353
2594
|
return []
|
|
@@ -2374,16 +2615,17 @@ def check_unused_module_level_imports(content: str, file_path: str) -> list[str]
|
|
|
2374
2615
|
)
|
|
2375
2616
|
if file_declares_dunder_all:
|
|
2376
2617
|
return []
|
|
2377
|
-
if
|
|
2618
|
+
if _module_body_declares_type_checking_gate(tree):
|
|
2378
2619
|
return []
|
|
2379
2620
|
content_lines = content.splitlines()
|
|
2380
|
-
|
|
2621
|
+
import_line_ranges = _import_statement_line_ranges(tree)
|
|
2622
|
+
referenced_names = _collect_load_names_outside_import_ranges(
|
|
2623
|
+
tree,
|
|
2624
|
+
import_line_ranges,
|
|
2625
|
+
)
|
|
2381
2626
|
import_bindings: list[tuple[str, int, int | None]] = []
|
|
2382
2627
|
for each_node in tree.body:
|
|
2383
2628
|
if isinstance(each_node, (ast.Import, ast.ImportFrom)):
|
|
2384
|
-
import_line_numbers.add(each_node.lineno)
|
|
2385
|
-
for each_alias in each_node.names:
|
|
2386
|
-
import_line_numbers.add(each_alias.lineno or each_node.lineno)
|
|
2387
2629
|
if isinstance(each_node, ast.ImportFrom) and each_node.module == "__future__":
|
|
2388
2630
|
continue
|
|
2389
2631
|
for each_binding in _import_alias_pairs(each_node):
|
|
@@ -2391,12 +2633,16 @@ def check_unused_module_level_imports(content: str, file_path: str) -> list[str]
|
|
|
2391
2633
|
issues: list[str] = []
|
|
2392
2634
|
for each_name, each_line_number, each_from_keyword_line in import_bindings:
|
|
2393
2635
|
if 1 <= each_line_number <= len(content_lines):
|
|
2394
|
-
if
|
|
2636
|
+
if line_suppresses_unused_import_via_noqa(content_lines[each_line_number - 1]):
|
|
2395
2637
|
continue
|
|
2396
|
-
if each_from_keyword_line is not None and 1 <= each_from_keyword_line <= len(
|
|
2397
|
-
|
|
2638
|
+
if each_from_keyword_line is not None and 1 <= each_from_keyword_line <= len(
|
|
2639
|
+
content_lines
|
|
2640
|
+
):
|
|
2641
|
+
if line_suppresses_unused_import_via_noqa(
|
|
2642
|
+
content_lines[each_from_keyword_line - 1]
|
|
2643
|
+
):
|
|
2398
2644
|
continue
|
|
2399
|
-
if
|
|
2645
|
+
if each_name in referenced_names:
|
|
2400
2646
|
continue
|
|
2401
2647
|
issues.append(
|
|
2402
2648
|
f"Line {each_line_number}: unused module-level import {each_name!r}"
|
|
@@ -165,7 +165,7 @@ def test_should_flag_each_unused_in_multi_import() -> None:
|
|
|
165
165
|
)
|
|
166
166
|
|
|
167
167
|
|
|
168
|
-
def
|
|
168
|
+
def test_should_not_flag_when_referenced_in_annotation() -> None:
|
|
169
169
|
source = (
|
|
170
170
|
"from typing import List\n\ndef run(xs: List[int]) -> None:\n return None\n"
|
|
171
171
|
)
|
|
@@ -242,3 +242,159 @@ def test_should_skip_star_import() -> None:
|
|
|
242
242
|
assert issues == [], (
|
|
243
243
|
f"Star imports cannot be meaningfully tracked - skip to avoid false positives, got: {issues}"
|
|
244
244
|
)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def test_should_flag_when_name_only_appears_in_comment() -> None:
|
|
248
|
+
source = (
|
|
249
|
+
"import json\n"
|
|
250
|
+
"\n"
|
|
251
|
+
"# json reserved for later\n"
|
|
252
|
+
"def run() -> None:\n"
|
|
253
|
+
" return None\n"
|
|
254
|
+
)
|
|
255
|
+
issues = check_unused_module_level_imports(source, PRODUCTION_FILE_PATH)
|
|
256
|
+
assert any("json" in each_issue for each_issue in issues), (
|
|
257
|
+
f"Mentions in comments must not count as references, got: {issues}"
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def test_should_not_skip_when_type_checking_only_in_string_constant() -> None:
|
|
262
|
+
source = (
|
|
263
|
+
'from config.constants import UNUSED_NAME\n'
|
|
264
|
+
'\n'
|
|
265
|
+
'HELP_TEXT = "See TYPE_CHECKING docs"\n'
|
|
266
|
+
'\n'
|
|
267
|
+
"def run() -> None:\n"
|
|
268
|
+
" return None\n"
|
|
269
|
+
)
|
|
270
|
+
issues = check_unused_module_level_imports(source, PRODUCTION_FILE_PATH)
|
|
271
|
+
assert any("UNUSED_NAME" in each_issue for each_issue in issues), (
|
|
272
|
+
f"Substring TYPE_CHECKING in prose must not skip the scan, got: {issues}"
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def test_should_flag_when_noqa_lists_only_non_f401_codes() -> None:
|
|
277
|
+
source = (
|
|
278
|
+
"from config.constants import UNUSED # noqa: E402\n"
|
|
279
|
+
"\n"
|
|
280
|
+
"def run() -> None:\n"
|
|
281
|
+
" return None\n"
|
|
282
|
+
)
|
|
283
|
+
issues = check_unused_module_level_imports(source, PRODUCTION_FILE_PATH)
|
|
284
|
+
assert any("UNUSED" in each_issue for each_issue in issues), (
|
|
285
|
+
f"E402-only noqa must not suppress unused-import findings, got: {issues}"
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def test_should_skip_when_noqa_is_bare() -> None:
|
|
290
|
+
source = (
|
|
291
|
+
"from config.constants import UNUSED # noqa\n"
|
|
292
|
+
"\n"
|
|
293
|
+
"def run() -> None:\n"
|
|
294
|
+
" return None\n"
|
|
295
|
+
)
|
|
296
|
+
issues = check_unused_module_level_imports(source, PRODUCTION_FILE_PATH)
|
|
297
|
+
assert issues == [], f"Bare noqa must suppress unused import, got: {issues}"
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def test_should_flag_import_when_only_shadowed_local_name_is_loaded() -> None:
|
|
301
|
+
source = (
|
|
302
|
+
"import json\n"
|
|
303
|
+
"\n"
|
|
304
|
+
"def run(json: object) -> object:\n"
|
|
305
|
+
" return json\n"
|
|
306
|
+
)
|
|
307
|
+
issues = check_unused_module_level_imports(source, PRODUCTION_FILE_PATH)
|
|
308
|
+
assert any("json" in each_issue for each_issue in issues), (
|
|
309
|
+
f"Local shadow bindings must not count as import references, got: {issues}"
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def test_should_skip_when_type_checking_uses_imported_alias() -> None:
|
|
314
|
+
source = (
|
|
315
|
+
"from typing import TYPE_CHECKING as IS_TYPE_CHECKING\n"
|
|
316
|
+
"from config.constants import UNUSED_NAME\n"
|
|
317
|
+
"\n"
|
|
318
|
+
"if IS_TYPE_CHECKING:\n"
|
|
319
|
+
" from somewhere import OtherName\n"
|
|
320
|
+
"\n"
|
|
321
|
+
"def run() -> None:\n"
|
|
322
|
+
" return None\n"
|
|
323
|
+
)
|
|
324
|
+
issues = check_unused_module_level_imports(source, PRODUCTION_FILE_PATH)
|
|
325
|
+
assert issues == [], (
|
|
326
|
+
f"TYPE_CHECKING imported aliases must skip annotation-only files, got: {issues}"
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def test_should_skip_when_type_checking_uses_module_alias() -> None:
|
|
331
|
+
source = (
|
|
332
|
+
"import typing as t\n"
|
|
333
|
+
"from config.constants import UNUSED_NAME\n"
|
|
334
|
+
"\n"
|
|
335
|
+
"if t.TYPE_CHECKING:\n"
|
|
336
|
+
" from somewhere import OtherName\n"
|
|
337
|
+
"\n"
|
|
338
|
+
"def run() -> None:\n"
|
|
339
|
+
" return None\n"
|
|
340
|
+
)
|
|
341
|
+
issues = check_unused_module_level_imports(source, PRODUCTION_FILE_PATH)
|
|
342
|
+
assert issues == [], (
|
|
343
|
+
f"TYPE_CHECKING module aliases must skip annotation-only files, got: {issues}"
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def test_should_not_flag_when_referenced_in_quoted_annotation() -> None:
|
|
348
|
+
source = (
|
|
349
|
+
"from typing import List\n"
|
|
350
|
+
"\n"
|
|
351
|
+
"def run(xs: \"List[int]\") -> None:\n"
|
|
352
|
+
" return None\n"
|
|
353
|
+
)
|
|
354
|
+
issues = check_unused_module_level_imports(source, PRODUCTION_FILE_PATH)
|
|
355
|
+
assert issues == [], (
|
|
356
|
+
f"Quoted annotations must count as import references, got: {issues}"
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def test_should_flag_when_noqa_only_appears_inside_string_literal() -> None:
|
|
361
|
+
source = (
|
|
362
|
+
"from config.constants import UNUSED; MARKER = '# noqa: F401'\n"
|
|
363
|
+
"\n"
|
|
364
|
+
"def run() -> None:\n"
|
|
365
|
+
" return None\n"
|
|
366
|
+
)
|
|
367
|
+
issues = check_unused_module_level_imports(source, PRODUCTION_FILE_PATH)
|
|
368
|
+
assert any("UNUSED" in each_issue for each_issue in issues), (
|
|
369
|
+
f"String literal noqa text must not suppress unused imports, got: {issues}"
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def test_should_not_flag_when_class_body_binding_matches_import_used_in_method() -> None:
|
|
374
|
+
source = (
|
|
375
|
+
"import os\n"
|
|
376
|
+
"\n"
|
|
377
|
+
"class Foo:\n"
|
|
378
|
+
" os = 'linux'\n"
|
|
379
|
+
"\n"
|
|
380
|
+
" def bar(self) -> str:\n"
|
|
381
|
+
" return os.path.join('a', 'b')\n"
|
|
382
|
+
)
|
|
383
|
+
issues = check_unused_module_level_imports(source, PRODUCTION_FILE_PATH)
|
|
384
|
+
assert issues == [], (
|
|
385
|
+
f"Class body bindings must not shadow module-level imports inside methods, got: {issues}"
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def test_should_not_flag_when_comprehension_variable_matches_import_used_after() -> None:
|
|
390
|
+
source = (
|
|
391
|
+
"import os\n"
|
|
392
|
+
"\n"
|
|
393
|
+
"def run() -> str:\n"
|
|
394
|
+
" result = [x for os in [1, 2, 3]]\n"
|
|
395
|
+
" return os.path.join('a', 'b')\n"
|
|
396
|
+
)
|
|
397
|
+
issues = check_unused_module_level_imports(source, PRODUCTION_FILE_PATH)
|
|
398
|
+
assert issues == [], (
|
|
399
|
+
f"Comprehension iteration variables must not shadow enclosing scope bindings, got: {issues}"
|
|
400
|
+
)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Tests for unused module import constants."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
_HOOKS_ROOT = Path(__file__).resolve().parent.parent
|
|
9
|
+
if str(_HOOKS_ROOT) not in sys.path:
|
|
10
|
+
sys.path.insert(0, str(_HOOKS_ROOT))
|
|
11
|
+
|
|
12
|
+
from config.unused_module_import_constants import line_suppresses_unused_import_via_noqa
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_line_suppresses_bare_noqa() -> None:
|
|
16
|
+
assert line_suppresses_unused_import_via_noqa(
|
|
17
|
+
"from x import y # noqa"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_line_suppresses_noqa_with_f401_code() -> None:
|
|
22
|
+
assert line_suppresses_unused_import_via_noqa(
|
|
23
|
+
"from x import y # noqa: F401"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_line_suppresses_noqa_with_mixed_codes_including_f401() -> None:
|
|
28
|
+
assert line_suppresses_unused_import_via_noqa(
|
|
29
|
+
"from x import y # noqa: E402, F401"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_line_does_not_suppress_noqa_with_only_non_f401_codes() -> None:
|
|
34
|
+
assert not line_suppresses_unused_import_via_noqa(
|
|
35
|
+
"from x import y # noqa: E402"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_line_does_not_suppress_without_noqa() -> None:
|
|
40
|
+
assert not line_suppresses_unused_import_via_noqa(
|
|
41
|
+
"from x import y # type: ignore"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_line_does_not_suppress_noqa_inside_string_literal() -> None:
|
|
46
|
+
assert not line_suppresses_unused_import_via_noqa(
|
|
47
|
+
"from x import y; marker = '# noqa: F401'"
|
|
48
|
+
)
|
|
@@ -1,7 +1,48 @@
|
|
|
1
1
|
"""Constants for the unused module-level import scan in ``code_rules_enforcer``."""
|
|
2
2
|
|
|
3
|
+
import io
|
|
4
|
+
import re
|
|
5
|
+
import tokenize
|
|
6
|
+
|
|
7
|
+
PYFLAKES_UNUSED_IMPORT_RULE_CODE: str = "F401"
|
|
8
|
+
NOQA_DIRECTIVE_PATTERN: re.Pattern[str] = re.compile(
|
|
9
|
+
r"#\s*noqa\b(?:\s*:\s*([^\n#]+))?",
|
|
10
|
+
re.IGNORECASE,
|
|
11
|
+
)
|
|
3
12
|
MAX_UNUSED_IMPORT_ISSUES: int = 25
|
|
4
13
|
UNUSED_IMPORT_GUIDANCE: str = (
|
|
5
14
|
"remove unused import; if kept for side effects, mark with `# noqa: F401`"
|
|
6
15
|
)
|
|
7
16
|
TYPE_CHECKING_IDENTIFIER: str = "TYPE_CHECKING"
|
|
17
|
+
ALL_TYPING_MODULE_NAMES: frozenset[str] = frozenset({"typing", "typing_extensions"})
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _comment_text_from_line(line_text: str) -> str | None:
|
|
21
|
+
try:
|
|
22
|
+
for each_token in tokenize.generate_tokens(io.StringIO(line_text).readline):
|
|
23
|
+
if each_token.type == tokenize.COMMENT:
|
|
24
|
+
return each_token.string
|
|
25
|
+
except tokenize.TokenError:
|
|
26
|
+
return None
|
|
27
|
+
return None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def line_suppresses_unused_import_via_noqa(line_text: str) -> bool:
|
|
31
|
+
"""Return True only for bare ``# noqa`` / ``#noqa`` or a code list that includes F401."""
|
|
32
|
+
comment_text = _comment_text_from_line(line_text)
|
|
33
|
+
if comment_text is None:
|
|
34
|
+
return False
|
|
35
|
+
match = NOQA_DIRECTIVE_PATTERN.search(comment_text)
|
|
36
|
+
if match is None:
|
|
37
|
+
return False
|
|
38
|
+
codes_part = match.group(1)
|
|
39
|
+
if codes_part is None or not codes_part.strip():
|
|
40
|
+
return True
|
|
41
|
+
for each_fragment in codes_part.split(","):
|
|
42
|
+
stripped = each_fragment.strip()
|
|
43
|
+
if not stripped:
|
|
44
|
+
continue
|
|
45
|
+
first_token = stripped.split()[0]
|
|
46
|
+
if first_token.upper() == PYFLAKES_UNUSED_IMPORT_RULE_CODE:
|
|
47
|
+
return True
|
|
48
|
+
return False
|