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.
- package/_shared/pr-loop/audit-contract.md +159 -0
- package/_shared/pr-loop/code-rules-gate.md +64 -0
- package/_shared/pr-loop/fix-protocol.md +37 -0
- package/_shared/pr-loop/gh-payloads.md +85 -0
- package/_shared/pr-loop/scripts/README.md +20 -0
- package/_shared/pr-loop/scripts/_claude_permissions_common.py +234 -0
- package/_shared/pr-loop/scripts/code_rules_gate.py +975 -0
- package/_shared/pr-loop/scripts/config/__init__.py +0 -0
- package/_shared/pr-loop/scripts/config/claude_permissions_constants.py +36 -0
- package/_shared/pr-loop/scripts/config/claude_settings_keys_constants.py +11 -0
- package/_shared/pr-loop/scripts/config/code_rules_gate_constants.py +56 -0
- package/_shared/pr-loop/scripts/config/fix_hookspath_constants.py +25 -0
- package/_shared/pr-loop/scripts/config/gh_util_constants.py +31 -0
- package/_shared/pr-loop/scripts/config/preflight_constants.py +68 -0
- package/_shared/pr-loop/scripts/fix_hookspath.py +260 -0
- package/_shared/pr-loop/scripts/gh_util.py +193 -0
- package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +130 -0
- package/_shared/pr-loop/scripts/preflight.py +449 -0
- package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +156 -0
- package/_shared/pr-loop/scripts/tests/conftest.py +51 -0
- package/_shared/pr-loop/scripts/tests/test__claude_permissions_common.py +135 -0
- package/_shared/pr-loop/scripts/tests/test_claude_permissions_common.py +169 -0
- package/_shared/pr-loop/scripts/tests/test_claude_permissions_constants.py +58 -0
- package/_shared/pr-loop/scripts/tests/test_claude_settings_keys_constants.py +50 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +917 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate_constants.py +102 -0
- package/_shared/pr-loop/scripts/tests/test_fix_hookspath.py +374 -0
- package/_shared/pr-loop/scripts/tests/test_fix_hookspath_constants.py +47 -0
- package/_shared/pr-loop/scripts/tests/test_gh_util.py +257 -0
- package/_shared/pr-loop/scripts/tests/test_gh_util_constants.py +61 -0
- package/_shared/pr-loop/scripts/tests/test_grant_project_claude_permissions.py +49 -0
- package/_shared/pr-loop/scripts/tests/test_preflight.py +670 -0
- package/_shared/pr-loop/scripts/tests/test_preflight_constants.py +77 -0
- package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +49 -0
- package/_shared/pr-loop/state-schema.md +81 -0
- 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 +2 -1
- package/skills/bg-agent/SKILL.md +69 -0
- package/skills/bugteam/CONSTRAINTS.md +10 -19
- package/skills/bugteam/PROMPTS.md +3 -3
- package/skills/bugteam/SKILL.md +103 -202
- package/skills/bugteam/SKILL_EVALS.md +75 -114
- package/skills/bugteam/reference/README.md +2 -4
- 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 +118 -0
- package/skills/pr-converge/reference/examples.md +76 -0
- package/skills/pr-converge/reference/fix-protocol.md +54 -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 +201 -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 +58 -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/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
|
@@ -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
|
|
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}"
|