claude-dev-env 1.36.2 → 1.37.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 (70) hide show
  1. package/_shared/pr-loop/scripts/config/preflight_constants.py +29 -8
  2. package/_shared/pr-loop/scripts/preflight.py +242 -20
  3. package/_shared/pr-loop/scripts/tests/test_preflight.py +362 -25
  4. package/_shared/pr-loop/scripts/tests/test_preflight_constants.py +9 -14
  5. package/hooks/blocking/code_rules_enforcer.py +269 -23
  6. package/hooks/blocking/test_code_rules_enforcer_unused_imports.py +157 -1
  7. package/hooks/config/test_unused_module_import_constants.py +48 -0
  8. package/hooks/config/unused_module_import_constants.py +41 -0
  9. package/package.json +1 -1
  10. package/skills/bg-agent/SKILL.md +69 -0
  11. package/skills/bugteam/CONSTRAINTS.md +10 -19
  12. package/skills/bugteam/PROMPTS.md +3 -3
  13. package/skills/bugteam/SKILL.md +103 -202
  14. package/skills/bugteam/SKILL_EVALS.md +75 -114
  15. package/skills/bugteam/reference/README.md +2 -4
  16. package/skills/bugteam/reference/design-rationale.md +3 -8
  17. package/skills/bugteam/reference/team-setup.md +11 -19
  18. package/skills/bugteam/reference/teardown-publish-permissions.md +2 -14
  19. package/skills/bugteam/scripts/config/__init__.py +0 -0
  20. package/skills/bugteam/scripts/config/reflow_skill_md_constants.py +12 -0
  21. package/skills/bugteam/scripts/reflow_skill_md.py +51 -47
  22. package/skills/bugteam/sources.md +1 -25
  23. package/skills/bugteam/test_skill_additions.py +4 -13
  24. package/skills/fresh-branch/SKILL.md +71 -0
  25. package/skills/gotcha/SKILL.md +73 -0
  26. package/skills/monitor-open-prs/SKILL.md +4 -37
  27. package/skills/monitor-open-prs/test_skill_contract.py +0 -5
  28. package/skills/pr-converge/SKILL.md +60 -1298
  29. package/skills/pr-converge/reference/convergence-gates.md +118 -0
  30. package/skills/pr-converge/reference/examples.md +76 -0
  31. package/skills/pr-converge/reference/fix-protocol.md +54 -0
  32. package/skills/pr-converge/reference/ground-rules.md +13 -0
  33. package/skills/pr-converge/reference/multi-pr-orchestration.md +204 -0
  34. package/skills/pr-converge/reference/per-tick.md +201 -0
  35. package/skills/pr-converge/reference/state-schema.md +19 -0
  36. package/skills/pr-converge/reference/stop-conditions.md +26 -0
  37. package/skills/pr-converge/scripts/README.md +36 -9
  38. package/skills/pr-converge/scripts/check_pr_mergeability.py +1 -2
  39. package/skills/pr-converge/scripts/config/pr_converge_constants.py +58 -5
  40. package/skills/pr-converge/scripts/config/reflow_skill_md_constants.py +13 -0
  41. package/skills/pr-converge/scripts/config/test_pr_converge_constants.py +0 -24
  42. package/skills/pr-converge/scripts/cursor-agents-continue.ahk +22 -2
  43. package/skills/pr-converge/scripts/fetch_bugbot_inline_comments.py +19 -59
  44. package/skills/pr-converge/scripts/fetch_bugbot_reviews.py +15 -61
  45. package/skills/pr-converge/scripts/fetch_claude_inline_comments.py +70 -0
  46. package/skills/pr-converge/scripts/fetch_claude_reviews.py +61 -0
  47. package/skills/pr-converge/scripts/fetch_copilot_inline_comments.py +19 -61
  48. package/skills/pr-converge/scripts/fetch_copilot_reviews.py +14 -74
  49. package/skills/pr-converge/scripts/reflow_skill_md.py +71 -50
  50. package/skills/pr-converge/scripts/reviewer_fetch_core.py +153 -0
  51. package/skills/pr-converge/scripts/reviewer_specs.py +98 -0
  52. package/skills/pr-converge/scripts/test_cursor_agents_continue.py +65 -0
  53. package/skills/pr-converge/scripts/test_fetch_bugbot_inline_comments.py +107 -6
  54. package/skills/pr-converge/scripts/test_fetch_bugbot_reviews.py +85 -6
  55. package/skills/pr-converge/scripts/test_fetch_claude_inline_comments.py +485 -0
  56. package/skills/pr-converge/scripts/test_fetch_claude_reviews.py +368 -0
  57. package/skills/pr-converge/scripts/test_fetch_copilot_inline_comments.py +74 -6
  58. package/skills/pr-converge/scripts/test_fetch_copilot_reviews.py +94 -8
  59. package/skills/pr-converge/scripts/test_reflow_skill_md.py +162 -0
  60. package/skills/pr-converge/scripts/test_reviewer_fetch_core.py +448 -0
  61. package/skills/pr-converge/scripts/test_reviewer_specs.py +107 -0
  62. package/skills/pr-converge/workflows/schedule-wakeup-loop.md +24 -22
  63. package/skills/bugteam/reference/workflow-path-a-orchestrated-teams.md +0 -113
  64. package/skills/bugteam/reference/workflow-path-b-task-harness.md +0 -48
  65. package/skills/bugteam/test_team_lifecycle.py +0 -103
  66. package/skills/monitor-open-prs/test_team_lifecycle.py +0 -46
  67. package/skills/pr-converge/scripts/open_followup_copilot_pr.py +0 -136
  68. package/skills/pr-converge/scripts/test_open_followup_copilot_pr.py +0 -236
  69. package/skills/pr-converge/test_team_lifecycle.py +0 -56
  70. 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 _name_appears_outside_imports(
2328
- all_content_lines: list[str],
2329
- all_import_line_numbers: set[int],
2330
- name: str,
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
- name_pattern = re.compile(rf"\b{re.escape(name)}\b")
2333
- for each_line_index, each_line in enumerate(all_content_lines, start=1):
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 _line_carries_noqa_marker(line_text: str) -> bool:
2342
- return "# noqa" in line_text or "#noqa" in line_text
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
- The rule is intentionally conservative files declaring __all__ or
2349
- using TYPE_CHECKING are skipped to avoid false positives on
2350
- re-exports and annotation-only imports.
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 TYPE_CHECKING_IDENTIFIER in content:
2618
+ if _module_body_declares_type_checking_gate(tree):
2378
2619
  return []
2379
2620
  content_lines = content.splitlines()
2380
- import_line_numbers: set[int] = set()
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 _line_carries_noqa_marker(content_lines[each_line_number - 1]):
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(content_lines):
2397
- if _line_carries_noqa_marker(content_lines[each_from_keyword_line - 1]):
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 _name_appears_outside_imports(content_lines, import_line_numbers, each_name):
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 test_should_not_flag_when_referenced_in_string_annotation() -> None:
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "1.36.2",
3
+ "version": "1.37.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,69 @@
1
+ ---
2
+ name: bg-agent
3
+ description: Delegates a task to a background agent. Invoked as "bg-agent [task to do]". Claude picks a suitable agent type from the available agents list and spawns it via Agent with run_in_background: true. Triggers on "/bg-agent", "bg-agent", "background agent for this".
4
+ ---
5
+
6
+ # bg-agent
7
+
8
+ ## Overview
9
+
10
+ Delegates a task to a background agent so the main session can continue without waiting. This is the programmatic invocation path for background work — other skills (e.g. gotcha) and the user can both invoke it.
11
+
12
+ **Announce at start:** "Delegating to a background agent: `<one-line summary of task>`."
13
+
14
+ ## Instructions
15
+
16
+ ### Step 1 — Parse the task
17
+
18
+ The user (or calling skill) provides a task description after `bg-agent`. Example:
19
+
20
+ ```
21
+ bg-agent add a gotcha to the rebase skill about force-push lease format
22
+ ```
23
+
24
+ Extract the full task description from the arguments.
25
+
26
+ ### Step 2 — Select the right agent
27
+
28
+ Review the available agent types (listed in the system prompt's Agent tool description) and pick the most suitable one for the task:
29
+
30
+ - **Read-only tasks** (research, search, exploring code) → Explore agent or general-purpose agent.
31
+ - **Code authoring tasks** (writing/editing skill files, creating PRs) → general-purpose agent with `run_in_background: true`.
32
+ - **Specialized tasks** → pick the agent whose description best matches the task. For example, use `pr-description-writer` for PR descriptions, `git-commit-crafter` for commits.
33
+
34
+ If no specialized agent fits, use the general-purpose agent.
35
+
36
+ ### Step 3 — Spawn the background agent
37
+
38
+ Use the `Agent` tool with `run_in_background: true`. Write a self-contained prompt that:
39
+
40
+ - States the exact goal and expected output.
41
+ - Lists the files or directories involved (from the caller's context).
42
+ - Includes any constraints (do not create a PR, do not push, etc.).
43
+ - Specifies what success looks like.
44
+
45
+ Example for a gotcha-adding task:
46
+
47
+ ```
48
+ Agent({
49
+ description: "Add gotcha to skill file",
50
+ prompt: "Add a gotcha entry to packages/claude-dev-env/skills/rebase/SKILL.md. The gotcha is: 'force-push --force-with-lease requires the full <branch>:<sha> format, not just the branch name.' Add it under the ## Gotchas section. If no ## Gotchas section exists, create one at the bottom of the file.",
51
+ subagent_type: "general-purpose",
52
+ run_in_background: true
53
+ })
54
+ ```
55
+
56
+ ### Step 4 — Report spawn
57
+
58
+ Confirm the agent was spawned and state its task in one sentence. The caller does not need to wait for completion — background agents notify on completion automatically.
59
+
60
+ ## Constraints
61
+
62
+ - Always use `run_in_background: true`. This skill is specifically for background delegation.
63
+ - Never run the task inline in the main session. The point is to offload it.
64
+ - If the task requires a PR, the spawned agent handles the full flow (branch → commit → push → PR).
65
+ - Return control to the caller immediately after spawning. Do not poll for completion.
66
+
67
+ ## Gotchas
68
+
69
+ See the gotcha reference at the bottom of this file. When a new gotcha is discovered during use, invoke `/gotcha` to add it here.