claude-dev-env 1.36.1 → 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 (101) hide show
  1. package/_shared/pr-loop/audit-contract.md +159 -0
  2. package/_shared/pr-loop/code-rules-gate.md +64 -0
  3. package/_shared/pr-loop/fix-protocol.md +37 -0
  4. package/_shared/pr-loop/gh-payloads.md +85 -0
  5. package/_shared/pr-loop/scripts/README.md +20 -0
  6. package/_shared/pr-loop/scripts/_claude_permissions_common.py +234 -0
  7. package/_shared/pr-loop/scripts/code_rules_gate.py +975 -0
  8. package/_shared/pr-loop/scripts/config/__init__.py +0 -0
  9. package/_shared/pr-loop/scripts/config/claude_permissions_constants.py +36 -0
  10. package/_shared/pr-loop/scripts/config/claude_settings_keys_constants.py +11 -0
  11. package/_shared/pr-loop/scripts/config/code_rules_gate_constants.py +56 -0
  12. package/_shared/pr-loop/scripts/config/fix_hookspath_constants.py +25 -0
  13. package/_shared/pr-loop/scripts/config/gh_util_constants.py +31 -0
  14. package/_shared/pr-loop/scripts/config/preflight_constants.py +68 -0
  15. package/_shared/pr-loop/scripts/fix_hookspath.py +260 -0
  16. package/_shared/pr-loop/scripts/gh_util.py +193 -0
  17. package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +130 -0
  18. package/_shared/pr-loop/scripts/preflight.py +449 -0
  19. package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +156 -0
  20. package/_shared/pr-loop/scripts/tests/conftest.py +51 -0
  21. package/_shared/pr-loop/scripts/tests/test__claude_permissions_common.py +135 -0
  22. package/_shared/pr-loop/scripts/tests/test_claude_permissions_common.py +169 -0
  23. package/_shared/pr-loop/scripts/tests/test_claude_permissions_constants.py +58 -0
  24. package/_shared/pr-loop/scripts/tests/test_claude_settings_keys_constants.py +50 -0
  25. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +917 -0
  26. package/_shared/pr-loop/scripts/tests/test_code_rules_gate_constants.py +102 -0
  27. package/_shared/pr-loop/scripts/tests/test_fix_hookspath.py +374 -0
  28. package/_shared/pr-loop/scripts/tests/test_fix_hookspath_constants.py +47 -0
  29. package/_shared/pr-loop/scripts/tests/test_gh_util.py +257 -0
  30. package/_shared/pr-loop/scripts/tests/test_gh_util_constants.py +61 -0
  31. package/_shared/pr-loop/scripts/tests/test_grant_project_claude_permissions.py +49 -0
  32. package/_shared/pr-loop/scripts/tests/test_preflight.py +670 -0
  33. package/_shared/pr-loop/scripts/tests/test_preflight_constants.py +77 -0
  34. package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +49 -0
  35. package/_shared/pr-loop/state-schema.md +81 -0
  36. package/hooks/blocking/code_rules_enforcer.py +269 -23
  37. package/hooks/blocking/test_code_rules_enforcer_unused_imports.py +157 -1
  38. package/hooks/config/test_unused_module_import_constants.py +48 -0
  39. package/hooks/config/unused_module_import_constants.py +41 -0
  40. package/package.json +2 -1
  41. package/skills/bg-agent/SKILL.md +69 -0
  42. package/skills/bugteam/CONSTRAINTS.md +10 -19
  43. package/skills/bugteam/PROMPTS.md +3 -3
  44. package/skills/bugteam/SKILL.md +103 -202
  45. package/skills/bugteam/SKILL_EVALS.md +75 -114
  46. package/skills/bugteam/reference/README.md +2 -4
  47. package/skills/bugteam/reference/design-rationale.md +3 -8
  48. package/skills/bugteam/reference/team-setup.md +11 -19
  49. package/skills/bugteam/reference/teardown-publish-permissions.md +2 -14
  50. package/skills/bugteam/scripts/config/__init__.py +0 -0
  51. package/skills/bugteam/scripts/config/reflow_skill_md_constants.py +12 -0
  52. package/skills/bugteam/scripts/reflow_skill_md.py +51 -47
  53. package/skills/bugteam/sources.md +1 -25
  54. package/skills/bugteam/test_skill_additions.py +4 -13
  55. package/skills/fresh-branch/SKILL.md +71 -0
  56. package/skills/gotcha/SKILL.md +73 -0
  57. package/skills/monitor-open-prs/SKILL.md +4 -37
  58. package/skills/monitor-open-prs/test_skill_contract.py +0 -5
  59. package/skills/pr-converge/SKILL.md +60 -1298
  60. package/skills/pr-converge/reference/convergence-gates.md +118 -0
  61. package/skills/pr-converge/reference/examples.md +76 -0
  62. package/skills/pr-converge/reference/fix-protocol.md +54 -0
  63. package/skills/pr-converge/reference/ground-rules.md +13 -0
  64. package/skills/pr-converge/reference/multi-pr-orchestration.md +204 -0
  65. package/skills/pr-converge/reference/per-tick.md +201 -0
  66. package/skills/pr-converge/reference/state-schema.md +19 -0
  67. package/skills/pr-converge/reference/stop-conditions.md +26 -0
  68. package/skills/pr-converge/scripts/README.md +36 -9
  69. package/skills/pr-converge/scripts/check_pr_mergeability.py +1 -2
  70. package/skills/pr-converge/scripts/config/pr_converge_constants.py +58 -5
  71. package/skills/pr-converge/scripts/config/reflow_skill_md_constants.py +13 -0
  72. package/skills/pr-converge/scripts/config/test_pr_converge_constants.py +0 -24
  73. package/skills/pr-converge/scripts/cursor-agents-continue.ahk +22 -2
  74. package/skills/pr-converge/scripts/fetch_bugbot_inline_comments.py +19 -59
  75. package/skills/pr-converge/scripts/fetch_bugbot_reviews.py +15 -61
  76. package/skills/pr-converge/scripts/fetch_claude_inline_comments.py +70 -0
  77. package/skills/pr-converge/scripts/fetch_claude_reviews.py +61 -0
  78. package/skills/pr-converge/scripts/fetch_copilot_inline_comments.py +19 -61
  79. package/skills/pr-converge/scripts/fetch_copilot_reviews.py +14 -74
  80. package/skills/pr-converge/scripts/reflow_skill_md.py +71 -50
  81. package/skills/pr-converge/scripts/reviewer_fetch_core.py +153 -0
  82. package/skills/pr-converge/scripts/reviewer_specs.py +98 -0
  83. package/skills/pr-converge/scripts/test_cursor_agents_continue.py +65 -0
  84. package/skills/pr-converge/scripts/test_fetch_bugbot_inline_comments.py +107 -6
  85. package/skills/pr-converge/scripts/test_fetch_bugbot_reviews.py +85 -6
  86. package/skills/pr-converge/scripts/test_fetch_claude_inline_comments.py +485 -0
  87. package/skills/pr-converge/scripts/test_fetch_claude_reviews.py +368 -0
  88. package/skills/pr-converge/scripts/test_fetch_copilot_inline_comments.py +74 -6
  89. package/skills/pr-converge/scripts/test_fetch_copilot_reviews.py +94 -8
  90. package/skills/pr-converge/scripts/test_reflow_skill_md.py +162 -0
  91. package/skills/pr-converge/scripts/test_reviewer_fetch_core.py +448 -0
  92. package/skills/pr-converge/scripts/test_reviewer_specs.py +107 -0
  93. package/skills/pr-converge/workflows/schedule-wakeup-loop.md +24 -22
  94. package/skills/bugteam/reference/workflow-path-a-orchestrated-teams.md +0 -113
  95. package/skills/bugteam/reference/workflow-path-b-task-harness.md +0 -48
  96. package/skills/bugteam/test_team_lifecycle.py +0 -103
  97. package/skills/monitor-open-prs/test_team_lifecycle.py +0 -46
  98. package/skills/pr-converge/scripts/open_followup_copilot_pr.py +0 -136
  99. package/skills/pr-converge/scripts/test_open_followup_copilot_pr.py +0 -236
  100. package/skills/pr-converge/test_team_lifecycle.py +0 -56
  101. package/skills/pr-converge/workflows/ahk-auto-continue-loop.md +0 -108
@@ -0,0 +1,77 @@
1
+ """Tests for preflight_constants.py extracted constant set."""
2
+
3
+ import importlib.util
4
+ from pathlib import Path
5
+ from types import ModuleType
6
+
7
+
8
+ def _load_constants_module() -> ModuleType:
9
+ module_path = Path(__file__).parent.parent / "config" / "preflight_constants.py"
10
+ specification = importlib.util.spec_from_file_location(
11
+ "config.preflight_constants", module_path
12
+ )
13
+ assert specification is not None
14
+ assert specification.loader is not None
15
+ module = importlib.util.module_from_spec(specification)
16
+ specification.loader.exec_module(module)
17
+ return module
18
+
19
+
20
+ constants_module = _load_constants_module()
21
+
22
+
23
+ def test_bugteam_preflight_skip_env_var_name() -> None:
24
+ assert (
25
+ constants_module.BUGTEAM_PREFLIGHT_SKIP_ENV_VAR_NAME == "BUGTEAM_PREFLIGHT_SKIP"
26
+ )
27
+
28
+
29
+ def test_bugteam_preflight_skip_enabled_value() -> None:
30
+ assert constants_module.BUGTEAM_PREFLIGHT_SKIP_ENABLED_VALUE == "1"
31
+
32
+
33
+ def test_git_directory_name() -> None:
34
+ assert constants_module.GIT_DIRECTORY_NAME == ".git"
35
+
36
+
37
+ def test_claude_directory_name() -> None:
38
+ assert constants_module.CLAUDE_DIRECTORY_NAME == ".claude"
39
+
40
+
41
+ def test_pytest_ini_filename() -> None:
42
+ assert constants_module.PYTEST_INI_FILENAME == "pytest.ini"
43
+
44
+
45
+ def test_pyproject_toml_filename() -> None:
46
+ assert constants_module.PYPROJECT_TOML_FILENAME == "pyproject.toml"
47
+
48
+
49
+ def test_pytest_toml_table_prefix() -> None:
50
+ assert constants_module.PYTEST_TOML_TABLE_PREFIX == "[tool.pytest"
51
+
52
+
53
+ def test_pre_commit_config_yaml_filename() -> None:
54
+ assert constants_module.PRE_COMMIT_CONFIG_YAML_FILENAME == ".pre-commit-config.yaml"
55
+
56
+
57
+ def test_all_git_ls_files_test_discovery_command() -> None:
58
+ assert constants_module.ALL_GIT_LS_FILES_TEST_DISCOVERY_SUBCOMMAND == (
59
+ "ls-files",
60
+ "--cached",
61
+ "--others",
62
+ "--exclude-standard",
63
+ "--",
64
+ "**/test_*.py",
65
+ "**/*_test.py",
66
+ )
67
+
68
+
69
+ def test_all_repository_root_marker_filenames() -> None:
70
+ assert constants_module.ALL_REPOSITORY_ROOT_MARKER_FILENAMES == (
71
+ constants_module.GIT_DIRECTORY_NAME,
72
+ constants_module.PYTEST_INI_FILENAME,
73
+ )
74
+
75
+
76
+ def test_pytest_no_tests_collected_exit_code() -> None:
77
+ assert constants_module.PYTEST_NO_TESTS_COLLECTED_EXIT_CODE == 5
@@ -0,0 +1,49 @@
1
+ """Smoke tests for revoke_project_claude_permissions wiring.
2
+
3
+ Confirms the module imports cleanly with the constants now sourced from
4
+ config/claude_permissions_constants.py and config/claude_settings_keys_constants.py.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import importlib.util
10
+ import sys
11
+ from pathlib import Path
12
+ from types import ModuleType
13
+
14
+
15
+ def _load_revoke_module() -> ModuleType:
16
+ scripts_directory = Path(__file__).parent.parent
17
+ parent_directory = str(scripts_directory.resolve())
18
+ if parent_directory not in sys.path:
19
+ sys.path.insert(0, parent_directory)
20
+ sys.modules.pop("config", None)
21
+ module_path = scripts_directory / "revoke_project_claude_permissions.py"
22
+ specification = importlib.util.spec_from_file_location(
23
+ "revoke_project_claude_permissions", module_path
24
+ )
25
+ assert specification is not None
26
+ assert specification.loader is not None
27
+ module = importlib.util.module_from_spec(specification)
28
+ specification.loader.exec_module(module)
29
+ return module
30
+
31
+
32
+ def test_module_imports_constants_from_config_modules() -> None:
33
+ revoke_module = _load_revoke_module()
34
+ assert revoke_module.ALL_PERMISSION_ALLOW_TOOLS == ("Edit", "Write", "Read")
35
+ assert "{project_path}" in revoke_module.AUTO_MODE_ENVIRONMENT_ENTRY_TEMPLATE
36
+ assert revoke_module.CLAUDE_SETTINGS_PERMISSIONS_KEY == "permissions"
37
+
38
+
39
+ def test_revoke_module_guards_sys_path_insert_against_duplicates() -> None:
40
+ """revoke_project_claude_permissions.py must guard its sys.path.insert with a
41
+ membership check so re-imports under test harnesses do not push duplicate
42
+ entries (consistent with sibling modules in the same directory)."""
43
+ module_source = (
44
+ Path(__file__).parent.parent / "revoke_project_claude_permissions.py"
45
+ ).read_text(encoding="utf-8")
46
+ assert "if str(Path(__file__).resolve().parent) not in sys.path:" in module_source, (
47
+ "revoke_project_claude_permissions.py must guard sys.path.insert against "
48
+ "duplicate entries on reload (consistent with sibling modules)"
49
+ )
@@ -0,0 +1,81 @@
1
+ # State schema
2
+
3
+ State each PR-loop workflow tracks across iterations. Workflows differ on persistence (in-memory vs files) and which fields they use; shapes overlap.
4
+
5
+ ## Common fields
6
+
7
+ | Field | Type | Purpose |
8
+ |---|---|---|
9
+ | `loop_count` | int | Iterations completed; bumps on each AUDIT or tick |
10
+ | `last_action` | enum | `fresh` \| `audited` \| `fixed` — drives next-step dispatch |
11
+ | `last_findings` | object | `{p0, p1, p2, total}` count of findings from most recent AUDIT |
12
+ | `audit_log` | list[str] | Per-iteration one-line summaries for the final report |
13
+ | `starting_sha` | str | `git rev-parse HEAD` at workflow start |
14
+ | `loop_comment_index` | dict | `{finding_id: {finding_comment_id, finding_comment_url, used_fallback, fix_status, ...}}` |
15
+
16
+ ## Workflow-specific extensions
17
+
18
+ ### bugteam
19
+
20
+ Adds:
21
+ - `team_name` — `bugteam-pr-<N>-<YYYYMMDDHHMMSS>` or `bugteam-<YYYYMMDDHHMMSS>` for multi-PR
22
+ - `team_temp_dir` — absolute path resolved from `tempfile.gettempdir()`
23
+ - `pre_fix_sha` — `git rev-parse HEAD` immediately before each FIX
24
+ - `gate_round_count` — consecutive pre-audit gate failures (cap: 5 → exit `error`)
25
+
26
+ State lives inline in the lead session (orchestrator). Cleared on TeamDelete.
27
+
28
+ ### qbug
29
+
30
+ Adds nothing beyond common. Single subagent loops internally and returns a final summary; orchestrator discards intermediate state. Subagent's loop counter and findings return in the exit payload (`{exit_reason, loop_count, final_commit_sha, audit_log, unresolved}`).
31
+
32
+ ### pr-converge
33
+
34
+ Adds the same **traffic** fields whether they live in **`state.json`** or in the **conversation state line**; only the **store** differs.
35
+
36
+ | Field | Type | Purpose |
37
+ |---|---|---|
38
+ | `phase` | enum | `BUGBOT` \| `BUGTEAM` — which reviewer the current tick drives |
39
+ | `current_head` | str | PR `headRefOid` / `git rev-parse` for the PR under work (each tick; from `view_pr_context.py` when no file store) |
40
+ | `bugbot_clean_at` | str \| null | HEAD SHA at which Cursor Bugbot last reported clean, or `null` (reset on every push) |
41
+ | `copilot_clean_at` | str \| null | HEAD SHA at which the GitHub Copilot reviewer (`copilot-pull-request-reviewer[bot]`) last reported clean (review `state == "APPROVED"`), or `null`. Reset on every push. Convergence gates require this equals `current_head` after bugbot+bugteam are clean (see `skills/pr-converge/SKILL.md` § Convergence gates). |
42
+ | `merge_state_status` | str \| null | Last-observed `mergeStateStatus` from `gh pr view --json mergeable,mergeStateStatus,headRefOid` (e.g., `CLEAN`, `DIRTY`, `BLOCKED`, `BEHIND`, `UNKNOWN`), or `null` before the first check. Reset on every push. `DIRTY` triggers the rebase invocation; non-`CLEAN` non-`DIRTY` is a hard blocker per pr-converge `Stop conditions`. |
43
+ | `inline_lag_streak` | int | Consecutive ticks where bugbot's review body claims findings but inline-comments API returns zero rows for `current_head` |
44
+ | `tick_count` | int | Observability only — **no ceiling**; loop ends on convergence or **Stop conditions** in `pr-converge` |
45
+
46
+ **Dual persistence** (normative: `skills/pr-converge/SKILL.md` § State across ticks, § Multi-PR orchestration model):
47
+
48
+ | Mode | When it applies | Source of truth | `tick_count` bump |
49
+ |---|---|---|---|
50
+ | **`state.json`** | File exists at `<TMPDIR>/pr-converge-<session_id>/state.json` (multi-PR orchestration or other file-backed session) | JSON: top-level `session_id`; per-PR objects under `prs[<number>]` with `owner`, `repo`, `branch`, `phase`, `current_head`, `bugbot_clean_at`, `inline_lag_streak`, `tick_count`, `last_action`, `status`, `last_updated`. Optional sibling `converged.log` (append-only; multi-PR only). Writes use lock + atomic replace per skill **Concurrency** | **Orchestrator only** at tick start (locked merge for every non-terminal PR); **never** bump `tick_count` in Step 1 when this file is in use |
51
+ | **Conversation state line** | **No** `state.json` (typical single-PR `/pr-converge` in Cursor) | Persist **`phase`**, **`bugbot_clean_at`**, **`inline_lag_streak`**, **`tick_count`** as **plain text** in each assistant turn; next tick reads them from the **most recent assistant message**. **`current_head` is not serialized in that line** — re-resolve each tick via `view_pr_context.py` (same contract as `skills/pr-converge/SKILL.md` § State across ticks). | **Step 1** increments `tick_count` in that line **only** when no `state.json` — must not double-count with any file-backed path |
52
+
53
+ **`status` (file-backed `prs[...]` only):** `fresh` \| `in_progress` \| `awaiting_bugbot` \| `awaiting_bugteam` \| `converged` \| `blocked`
54
+
55
+ ### monitor-many
56
+
57
+ Adds per-PR JSON state file at `~/.claude/skills/monitor-many/state/<owner>-<repo>-<pr_number>.json`:
58
+
59
+ | Field | Type | Description |
60
+ |---|---|---|
61
+ | `repo_name` | str | Full `owner/repo` |
62
+ | `pr_number` | int | PR number |
63
+ | `status` | enum | `open` \| `blocked_escalation` \| `fixing` \| `ready_candidate` \| `closed` |
64
+ | `copilot_review` | enum | `none` \| `requested` \| `pending` \| `commented` \| `approved` |
65
+ | `bugbot_review` | enum | Same vocabulary as `copilot_review` |
66
+ | `last_seen_comment_id` | int \| null | Highest processed review-comment id (incremental polling watermark) |
67
+ | `review_comments` | list[object] | Optional cache; `{id, author, path, line}` per entry |
68
+ | `escalation_queue` | list[object] | Pending human-judgment items: `{comment_id, summary, created_at}` |
69
+
70
+ ## Reset semantics
71
+
72
+ - bugteam: cleared on each new `/bugteam` invocation
73
+ - qbug: cleared on each new `/qbug` invocation
74
+ - pr-converge: `bugbot_clean_at`, `copilot_clean_at`, and `merge_state_status` all reset to `null` on every push (a new commit invalidates every reviewer's prior clean and the prior mergeability snapshot by definition); `phase` cycles each tick. With `state.json`, orchestrator reads that file at tick start; without it, rely on the prior conversation state line — **never** mix both increment rules for `tick_count` on the same run
75
+ - monitor-many: persists across orchestrator runs; only `last_seen_comment_id` advances monotonically
76
+
77
+ ## Convergence checks
78
+
79
+ - bugteam, qbug: `last_action == "audited"` AND `last_findings.total == 0` → `converged`
80
+ - pr-converge: `bugbot_clean_at == current_head` AND most-recent bugteam exit is `converged` AND no push during the bugteam tick AND no outstanding Copilot findings on `current_head` AND `merge_state_status == "CLEAN"` (per `skills/pr-converge/SKILL.md` § Convergence gates) → back-to-back clean → `gh pr ready` (read `current_head` / `bugbot_clean_at` / `copilot_clean_at` / `merge_state_status` from `state.json` when file-backed, else from the conversation state line and Step 1 `view_pr_context.py` output)
81
+ - monitor-many: no unresolved comments requiring code changes AND required checks green AND review policy satisfied → `gh pr ready`
@@ -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}"