claude-dev-env 1.60.0 → 1.62.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/CLAUDE.md +12 -0
- package/audit-rubrics/category_rubrics/category-f-silent-failures.md +1 -1
- package/audit-rubrics/prompts/category-e-dead-code.md +17 -4
- package/audit-rubrics/prompts/category-f-silent-failures.md +1 -0
- package/bin/install.mjs +1 -1
- package/docs/CODE_RULES.md +2 -2
- package/hooks/blocking/code_rules_annotations_length.py +189 -10
- package/hooks/blocking/code_rules_dead_config_field.py +321 -0
- package/hooks/blocking/code_rules_enforcer.py +14 -0
- package/hooks/blocking/code_rules_orphan_css_class.py +196 -0
- package/hooks/blocking/config/verified_commit_constants.py +15 -2
- package/hooks/blocking/destructive_command_blocker.py +483 -61
- package/hooks/blocking/test_code_rules_enforcer_annotations.py +240 -0
- package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +1 -0
- package/hooks/blocking/test_code_rules_enforcer_dead_config_field.py +432 -0
- package/hooks/blocking/test_code_rules_enforcer_dispatch_wiring.py +82 -0
- package/hooks/blocking/test_code_rules_enforcer_orphan_css_class.py +196 -0
- package/hooks/blocking/test_destructive_command_blocker.py +213 -0
- package/hooks/blocking/test_verification_verdict_store.py +212 -0
- package/hooks/blocking/test_verified_commit_gate.py +159 -0
- package/hooks/blocking/test_verifier_verdict_minter.py +74 -95
- package/hooks/blocking/verification_verdict_store.py +240 -0
- package/hooks/blocking/verified_commit_gate.py +31 -9
- package/hooks/blocking/verifier_verdict_minter.py +46 -124
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +6 -0
- package/hooks/hooks_constants/dead_config_field_constants.py +39 -0
- package/hooks/hooks_constants/destructive_command_segment_constants.py +15 -0
- package/hooks/hooks_constants/orphan_css_class_constants.py +40 -0
- package/hooks/validation/mypy_validator.py +59 -7
- package/hooks/validation/test_mypy_validator.py +94 -0
- package/package.json +1 -1
- package/rules/orphan-css-class.md +23 -0
- package/skills/autoconverge/reference/gotchas.md +11 -0
- package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +5 -1
- package/skills/autoconverge/workflow/converge.contract.test.mjs +202 -13
- package/skills/autoconverge/workflow/converge.mjs +392 -51
- package/skills/autoconverge/workflow/test_render_report.py +55 -0
- package/skills/doc-gist/SKILL.md +3 -2
- package/skills/doc-gist/references/examples/21-decision-signoff.html +546 -0
- package/skills/doc-gist/references/examples/README.md +2 -2
- package/skills/task-build/SKILL.md +31 -0
package/CLAUDE.md
CHANGED
|
@@ -57,6 +57,10 @@ Repair agents run only on reported findings; the verifier re-checks after each r
|
|
|
57
57
|
- **Tight edit scope:** Edit exactly what the task names — no whole-file rewrites, no renaming public method parameters, no changes beyond the stated task. When the user asks for a "lasting" or "reusable" fix, prefer the durable systemic fix over a one-off edit. When the task touches a pipeline, generator, or other repeated process, fix the process itself, not its individual outputs — even when the request does not say so; for one-off targets, a scoped patch remains the default.
|
|
58
58
|
- **GitHub MCP first:** The GitHub MCP (`mcp__plugin_github_github__*`) is the primary path for PR and review-thread inspection; raw `gh api` is the fallback, not the default — MCP calls work the same from any worktree.
|
|
59
59
|
|
|
60
|
+
## Destructive-command literals in Bash
|
|
61
|
+
|
|
62
|
+
Never put a destructive-command literal (`rm -rf`, `git reset --hard`, `dd`, `mkfs`) inside a Bash command string, even when the shell never runs it — a quoted `python -c` argument, a heredoc body, an echoed string, a commit or PR body. The `destructive_command_blocker` hook matches the raw text and asks for confirmation, which a background run cannot answer, so the call stalls. Run hook and deletion checks through the committed test suite (`python -m pytest <test_file>`), or a throwaway script under `$CLAUDE_JOB_DIR/tmp` run as `python <file>.py` — either way the command string carries no destructive text, so the hook stays silent. Group genuine cleanup deletions into one teardown step. See `~/.claude/rules/no-inline-destructive-literals.md`.
|
|
63
|
+
|
|
60
64
|
## Sub-agent Output Validation
|
|
61
65
|
|
|
62
66
|
After any sub-agent returns a PR description, file list, or counts, verify each claim against the actual diff and repo state before using it. Flag and correct any invented paths, fabricated counts, or out-of-scope changes before they land in commits or PR bodies.
|
|
@@ -69,6 +73,14 @@ When asked to sync git ("get X onto origin main", "update main"), fast-forward l
|
|
|
69
73
|
|
|
70
74
|
For scheduled/cron tasks, default to sub-hour intervals (30-minute); do not propose hourly cadences.
|
|
71
75
|
|
|
76
|
+
## Task Tracking
|
|
77
|
+
|
|
78
|
+
Track every task with the task tool, always — for all sessions and all tasks. Capture each task with `TaskCreate` as it arrives, mark it `in_progress` with `TaskUpdate` when you start, and `completed` when it is done. Run `/task-build` to gather any open tasks and add them to the list in one pass.
|
|
79
|
+
|
|
80
|
+
## Working in the claude-code-config Repo
|
|
81
|
+
|
|
82
|
+
When changing how skills, rules, or hooks install or sync in this repo (for example adding a skill), read `docs/references/skill-install-system.md` — it maps the install pipeline in `packages/claude-dev-env/bin/install.mjs`.
|
|
83
|
+
|
|
72
84
|
## Additional Non-overlapping Rules
|
|
73
85
|
|
|
74
86
|
- **task_scope:** Match every action to what was explicitly requested. When intent is ambiguous, research official docs and present options via AskUserQuestion before making any changes. Proceed with edits only on explicit instruction.
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
|---|---|---|
|
|
21
21
|
| F1 | Catch-all except clauses | `except:` (bare), `except Exception:`, `except BaseException:` followed by `pass` / `continue` / log-only. |
|
|
22
22
|
| F2 | Errors logged then swallowed | `logger.error(...)` followed by `return None` / `return default` without re-raise. |
|
|
23
|
-
| F3 | Default fallback values masking failure | `dict.get(key, default)` where the absence of the key is itself a bug; `or default` short-circuits hiding `None`. |
|
|
23
|
+
| F3 | Default fallback values masking failure | `dict.get(key, default)` where the absence of the key is itself a bug; `or default` short-circuits hiding `None`. Includes the stale-payload-key shape: a `payload.get("KEY", "")` (or `payload["KEY"]` wrapped in a fallback) read against an external-input dict whose contract the diff migrated — the rest of the module reads the payload through a different key set named in the docstring and bound to same-named variables, while this lone read targets a dropped key, so it resolves to the default on every real payload and silently records an empty value into the field it feeds. The audit teammate lists every string-literal key read from each `*_payload` / event / request dict, checks each key against the payload contract the module's docstring and other reads set up, and flags a key read at one site that no docstring, second read, or same-named binding anchors. A key consumed inline with a meaningful default (`resolve(payload.get("cwd", "."))`) is legitimate, not stale — the flag is the dropped key whose value the field needs but never receives. |
|
|
24
24
|
| F4 | Async task error swallowing | `asyncio.create_task(...)` without exception observation; `gather(..., return_exceptions=True)` consumed loosely. |
|
|
25
25
|
| F5 | Boolean / status returns identical on success and failure | A function returns `True` on the happy path and `True` on the catch-all error path. |
|
|
26
26
|
| F6 | Ignored return values from fallible calls | `subprocess.run` without `check=True` and unchecked `returncode`; `os.write` return value discarded. |
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
Audit [REPO/ARTIFACT] [TARGET_ID] for **Category E only** (dead code and unused imports). Skip A–D, F–P. Sub-bucket forced-exhaustion mode: Category E is decomposed into
|
|
1
|
+
Audit [REPO/ARTIFACT] [TARGET_ID] for **Category E only** (dead code and unused imports). Skip A–D, F–P. Sub-bucket forced-exhaustion mode: Category E is decomposed into 9 sub-buckets below. Each sub-bucket REQUIRES at least one Shape A finding OR exactly one Shape B proof-of-absence with **at least 3 adversarial probes** specific to that sub-bucket. A sub-bucket returning neither is a protocol gap.
|
|
2
2
|
|
|
3
3
|
[ARTIFACT METADATA]
|
|
4
4
|
- Repo / artifact: [REPO_OR_ARTIFACT_NAME]
|
|
@@ -69,6 +69,13 @@ Inline the artifact under this section using the section types defined in the ch
|
|
|
69
69
|
- Scaffolding bodies (`pass`, `...`, `raise NotImplementedError`, empty `else { }`, single-statement `return None` placeholders) without a `# TODO` comment ARE Category E findings under the project's "Document Temporary Code" rule.
|
|
70
70
|
- Adversarial probes for proof-of-absence: (a) any empty brace block in PowerShell / TypeScript / Go (`{ }` with no statements)? (b) any function whose entire body is `pass` / `return` / `return None`? (c) any branch that exits cleanly only because the surrounding loop is no-op for an empty input — is the no-op intentional or a placeholder?
|
|
71
71
|
|
|
72
|
+
**E9. Constants-module exports with no importer**
|
|
73
|
+
- For every module-level `UPPER_SNAKE` constant the artifact adds to a `*_constants.py` or `config/` module, grep the whole repo for the constant name and locate at least one importer (`from <module> import <NAME>`) or in-file reference.
|
|
74
|
+
- The file-global use-count gate exempts a constants module because every name it exports carries zero in-file references by design, so a genuinely dead export slips past the write-time gate; this sub-bucket is the audit-time backstop for that exemption.
|
|
75
|
+
- A sibling that a consumer module imports is live; a constant that no `from ... import` line and no in-file reference names anywhere in the repo is dead and must be removed (CODE_RULES 9.8).
|
|
76
|
+
- Constants reached only by string-form lookup (`getattr(config, name)`, settings registries) are live; name the dynamic consumer when you mark such a constant referenced.
|
|
77
|
+
- Adversarial probes for proof-of-absence: (a) does the artifact add any constant to a `*_constants.py` / `config/` module whose name returns zero hits outside its own definition line? (b) is any newly added constant shadowed by a same-named constant in a sibling module so the importer resolves the other one? (c) does any constant exist only as an `__all__` re-export with no downstream importer of that re-export?
|
|
78
|
+
|
|
72
79
|
## Cross-bucket questions to answer at the end
|
|
73
80
|
|
|
74
81
|
Q1: Are there imports unused locally but consumed by a re-export pattern in another file? Cite the cross-file pair if found, or state the hypothesis "none — neither file declares `__all__`" with the supporting evidence.
|
|
@@ -79,7 +86,7 @@ Q3: Which symbol most likely will *become* dead code after a near-future refacto
|
|
|
79
86
|
|
|
80
87
|
## Output
|
|
81
88
|
|
|
82
|
-
Lead: `Total: N (P0=N, P1=N, P2=N)`. For each sub-bucket E1-
|
|
89
|
+
Lead: `Total: N (P0=N, P1=N, P2=N)`. For each sub-bucket E1-E9, produce Shape A or Shape B (with ≥3 probes). Cross-bucket Q1-Q3 answers after the per-sub-bucket walk. Adversarial second pass: "assume your first pass missed at least 3 P2 dead-code instances across these 9 sub-buckets — find them." Open Questions section for ambiguities. Read-only. No edits, no commits.
|
|
83
90
|
|
|
84
91
|
Note: most Category E findings are P2 (style / cleanup) unless the dead code masks an actual bug; the adversarial-pass quota uses P2 here.
|
|
85
92
|
|
|
@@ -87,7 +94,7 @@ Note: most Category E findings are P2 (style / cleanup) unless the dead code mas
|
|
|
87
94
|
|
|
88
95
|
# Worked example: jl-cmd/claude-code-config PR #394
|
|
89
96
|
|
|
90
|
-
Audit jl-cmd/claude-code-config PR #394 for **Category E only** (dead code and unused imports). Skip A–D, F–N. Sub-bucket forced-exhaustion mode: Category E is decomposed into
|
|
97
|
+
Audit jl-cmd/claude-code-config PR #394 for **Category E only** (dead code and unused imports). Skip A–D, F–N. Sub-bucket forced-exhaustion mode: Category E is decomposed into 9 sub-buckets below. Each sub-bucket REQUIRES at least one Shape A finding OR exactly one Shape B proof-of-absence with **at least 3 adversarial probes** specific to that sub-bucket. A sub-bucket returning neither is a protocol gap.
|
|
91
98
|
|
|
92
99
|
PR: feat(scripts): add sweep-empty-dirs utility and scheduled-task installer
|
|
93
100
|
Head SHA: 62c9c169ee7a44824e5da25c4cf8b74fdca08a53
|
|
@@ -161,6 +168,12 @@ ID prefix: `find`.
|
|
|
161
168
|
- No `# TODO` markers in the diff — the project's own rule (`code-standards.md` → "Document Temporary Code") requires TODOs only for scaffolding/placeholder code. The two `pass`/`continue` bodies above are production behavior, not scaffolding.
|
|
162
169
|
- Adversarial probes for proof-of-absence: (a) does the PowerShell script have an empty `else { }` or empty branch body? — scan lines 14-71 for any `{ }` with no statements between the braces. (b) does any function body consist of a single `pass` or `return` with no work done? — every function body in this PR performs at least one statement. (c) does the `Status` branch (lines 14-31) exit cleanly even when `$task.Triggers` is empty? — the `foreach` loop at line 26 is a no-op for an empty collection, which is correct behavior, not a stub.
|
|
163
170
|
|
|
171
|
+
**E9. Constants-module exports with no importer**
|
|
172
|
+
- `config/sweep_config.py` is the only constants module this PR adds; it declares `DEFAULT_AGE_SECONDS` and `DEFAULT_POLL_INTERVAL` and imports nothing.
|
|
173
|
+
- `DEFAULT_AGE_SECONDS` — imported by `sweep_empty_dirs.py` line 10 (`from config.sweep_config import DEFAULT_AGE_SECONDS`) and read in `_build_parser`'s `--age` default. Live.
|
|
174
|
+
- `DEFAULT_POLL_INTERVAL` — imported by `sweep_empty_dirs.py` line 11 and read in `_build_parser`'s `--interval` default. Live.
|
|
175
|
+
- Adversarial probes for proof-of-absence: (a) does either constant return zero importers when grepped across the repo? — each has exactly one importer (`sweep_empty_dirs.py`), so neither is dead. (b) is either name shadowed by a same-named constant in a sibling module? — `sweep_config.py` is the only module that declares them. (c) does either constant exist only as an `__all__` re-export with no downstream consumer? — `sweep_config.py` declares no `__all__`; both are imported directly.
|
|
176
|
+
|
|
164
177
|
## Cross-bucket questions to answer at the end
|
|
165
178
|
|
|
166
179
|
Q1: Are there imports unused locally but consumed by a re-export pattern in another file? Cite the cross-file pair if found. (Hypothesis: none — neither `sweep_empty_dirs.py` nor `test_sweep_empty_dirs.py` defines `__all__`, so re-export is not in play. `config/sweep_config.py` declares two constants that ARE consumed by `sweep_empty_dirs.py` lines 10-11; this is normal cross-file consumption, not a re-export.)
|
|
@@ -169,7 +182,7 @@ Q3: Which symbol most likely will *become* dead code after a near-future refacto
|
|
|
169
182
|
|
|
170
183
|
## Output
|
|
171
184
|
|
|
172
|
-
Lead: `Total: N (P0=N, P1=N, P2=N)`. For each sub-bucket E1-
|
|
185
|
+
Lead: `Total: N (P0=N, P1=N, P2=N)`. For each sub-bucket E1-E9, produce Shape A or Shape B (with ≥3 probes). Cross-bucket Q1-Q3 answers after the per-sub-bucket walk. Adversarial second pass: "assume your first pass missed at least 3 P2 dead-code instances across these 9 sub-buckets — find them." Open Questions section for ambiguities. Read-only. No edits, no commits.
|
|
173
186
|
|
|
174
187
|
Note: most Category E findings are P2 (style / cleanup) unless the dead code masks an actual bug; the adversarial-pass quota uses P2 here.
|
|
175
188
|
|
|
@@ -37,6 +37,7 @@ Repeat for every section in scope.
|
|
|
37
37
|
|
|
38
38
|
**F3. Default fallback values masking failure**
|
|
39
39
|
- `dict.get(key, default)` where the absence of the key is itself a bug.
|
|
40
|
+
- Stale-payload-key shape: for each external-input dict (`*_payload`, event, request, parsed-JSON body), list every string-literal key read from it. Check each key against the contract the module's docstring and its other reads set up — a payload whose other reads bind to same-named variables and whose docstring names a key set is an established contract. Flag a lone read of a key outside that contract: it resolves to the `.get` default on every real payload and silently records an empty value into the field it feeds. A key consumed inline with a meaningful default (`resolve(payload.get("cwd", "."))`) is legitimate; the flag is the dropped key whose value the consuming field needs but never receives.
|
|
40
41
|
- `or default` short-circuits hiding `None` returns from fallible calls.
|
|
41
42
|
- `getattr(obj, attr, default)` masking `AttributeError` from the wrong object type.
|
|
42
43
|
- argparse `default=...` for values that should fail-loud when absent.
|
package/bin/install.mjs
CHANGED
|
@@ -149,7 +149,7 @@ const INSTALL_GROUPS = {
|
|
|
149
149
|
skills: [
|
|
150
150
|
'anthropic-plan', 'everything-search',
|
|
151
151
|
'pr-review-responder',
|
|
152
|
-
'recall', 'remember'
|
|
152
|
+
'recall', 'remember', 'task-build'
|
|
153
153
|
],
|
|
154
154
|
includeDirectories: ['rules', 'docs', 'commands', 'agents', 'audit-rubrics'],
|
|
155
155
|
includeAllHooks: true,
|
package/docs/CODE_RULES.md
CHANGED
|
@@ -23,9 +23,9 @@ Compact reference for agents. ⚡ marks rules enforced by `code_rules_enforcer.p
|
|
|
23
23
|
|
|
24
24
|
`code_rules_enforcer.py` blocks each of these at Write/Edit and explains the specific violation when it fires; exact patterns and exemption lists live in the hook:
|
|
25
25
|
|
|
26
|
-
no new comments · imports at top · logging format args (`log_*("...", arg)`) · no magic values in production bodies (0, 1, -1 exempt) · UPPER_SNAKE constants only in `config/` (exempt: `config/*`, `/migrations/`, workflow registries `/workflow/` + `_tab.py` + `/states.py` + `/modules.py`, test files) · no hardcoded user home paths · guarded `sys.path.insert` · no unused module-level imports · banned identifiers (`ctx`, `cfg`, `msg`, `btn`, `idx`, `cnt`, `tmp`, `elem`, `val`) · banned function prefixes (`handle_`, `process_`, `manage_`, `do_`) · no type escape hatches (`Any` import, `cast()`, inline `Any`) outside boundary files · no bare/broad `except` · no `Any` in signatures or class attributes · no stub bodies (`pass`/`...`/`raise NotImplementedError`) outside abstract/Protocol · TypedDict `_encode_*`/`_decode_*` companions in the same module · no test-mode branching in production (use dependency injection) · no thin wrapper modules · Google-style docstrings on public functions with `Args:` matching the signature · boolean names prefixed `is_`/`has_`/`should_`/`can_`/`was_`/`did_` (assignments AND bool-typed parameters) · must-check returns (`find_and_click`, `write_outcome`) assigned and checked · known pytest fixture parameters in test files annotated with their single documented type (`tmp_path: Path`, `monkeypatch: pytest.MonkeyPatch`, `capsys`, `caplog`, `request`, …)
|
|
26
|
+
no new comments · imports at top · logging format args (`log_*("...", arg)`) · no magic values in production bodies (0, 1, -1 exempt) · UPPER_SNAKE constants only in `config/` (exempt: `config/*`, `/migrations/`, workflow registries `/workflow/` + `_tab.py` + `/states.py` + `/modules.py`, test files) · no hardcoded user home paths · guarded `sys.path.insert` · no unused module-level imports · banned identifiers (`ctx`, `cfg`, `msg`, `btn`, `idx`, `cnt`, `tmp`, `elem`, `val`) · banned function prefixes (`handle_`, `process_`, `manage_`, `do_`) · no type escape hatches (`Any` import, `cast()`, inline `Any`) outside boundary files · no bare/broad `except` · no `Any` in signatures or class attributes · no stub bodies (`pass`/`...`/`raise NotImplementedError`) outside abstract/Protocol · TypedDict `_encode_*`/`_decode_*` companions in the same module · no test-mode branching in production (use dependency injection) · no thin wrapper modules · Google-style docstrings on public functions with `Args:` matching the signature · boolean names prefixed `is_`/`has_`/`should_`/`can_`/`was_`/`did_` (assignments AND bool-typed parameters) · must-check returns (`find_and_click`, `write_outcome`) assigned and checked · known pytest fixture parameters in test files annotated with their single documented type (`tmp_path: Path`, `monkeypatch: pytest.MonkeyPatch`, `capsys`, `caplog`, `request`, …) · known pytest fixture parameters a test function declares but never references (drop the unused parameter — pytest still pays its setup cost)
|
|
27
27
|
|
|
28
|
-
Test files are exempt from most checks. The one annotation the test-file exemption does NOT cover is a known pytest builtin fixture parameter: `tmp_path`, `monkeypatch`, `capsys`, `capfd`, `caplog`, `request`, and `tmp_path_factory` each have a single documented injected type, so the gate requires that annotation (`tmp_path: Path`) even inside a test file. Ordinary test parameters stay exempt. See also the file-global constants use-count rule: [`rules/file-global-constants.md`](../rules/file-global-constants.md).
|
|
28
|
+
Test files are exempt from most checks. The one annotation the test-file exemption does NOT cover is a known pytest builtin fixture parameter: `tmp_path`, `monkeypatch`, `capsys`, `capfd`, `caplog`, `request`, and `tmp_path_factory` each have a single documented injected type, so the gate requires that annotation (`tmp_path: Path`) even inside a test file. The same set of fixtures is also subject to a use check: a pytest-collected test function that declares one of these parameters and never references it in its body fails the gate, because pytest materializes the fixture's setup (the temp directory, the monkeypatch context, the output capture) on every run whether or not the body reads the value — drop the unused parameter. A parameter counts as referenced when its name is read, augmented-assigned, or deleted anywhere in the body, including inside a nested function or comprehension. Only pytest-collectable functions are inspected — those at module top level or defined directly in a class body; a function nested inside another function's body is a local helper pytest never collects, so its fixture-named parameter is exempt. A `@pytest.fixture`-decorated function is exempt from the use check, since injecting one fixture into another purely to order its setup is intentional. Ordinary test parameters stay exempt from both checks. See also the file-global constants use-count rule: [`rules/file-global-constants.md`](../rules/file-global-constants.md).
|
|
29
29
|
|
|
30
30
|
---
|
|
31
31
|
|
|
@@ -29,6 +29,7 @@ from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
|
|
|
29
29
|
FUNCTION_LENGTH_BLOCKING_MESSAGE_SUFFIX,
|
|
30
30
|
FUNCTION_LENGTH_BLOCKING_THRESHOLD,
|
|
31
31
|
KNOWN_PYTEST_FIXTURE_ANNOTATION_MESSAGE_SUFFIX,
|
|
32
|
+
UNUSED_PYTEST_FIXTURE_PARAMETER_MESSAGE_SUFFIX,
|
|
32
33
|
)
|
|
33
34
|
|
|
34
35
|
|
|
@@ -55,6 +56,36 @@ def check_parameter_annotations(content: str, file_path: str) -> list[str]:
|
|
|
55
56
|
return issues
|
|
56
57
|
|
|
57
58
|
|
|
59
|
+
def _has_pytest_fixture_decorator(
|
|
60
|
+
node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
61
|
+
) -> bool:
|
|
62
|
+
"""Return True when a function carries an ``@pytest.fixture`` decorator.
|
|
63
|
+
|
|
64
|
+
The decorator is recognized whether it is written as a dotted
|
|
65
|
+
``@pytest.fixture`` attribute or a bare ``@fixture`` name, and whether or not
|
|
66
|
+
it is called with arguments (``@pytest.fixture(scope="module")``). The call
|
|
67
|
+
form is unwrapped to its callee before the name is matched.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
node: The function definition AST node to inspect.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
True when any decorator on the node is a call or bare reference whose
|
|
74
|
+
final name is ``fixture``; False otherwise.
|
|
75
|
+
"""
|
|
76
|
+
for each_decorator in node.decorator_list:
|
|
77
|
+
unwrapped = (
|
|
78
|
+
each_decorator.func
|
|
79
|
+
if isinstance(each_decorator, ast.Call)
|
|
80
|
+
else each_decorator
|
|
81
|
+
)
|
|
82
|
+
if isinstance(unwrapped, ast.Name) and unwrapped.id == "fixture":
|
|
83
|
+
return True
|
|
84
|
+
if isinstance(unwrapped, ast.Attribute) and unwrapped.attr == "fixture":
|
|
85
|
+
return True
|
|
86
|
+
return False
|
|
87
|
+
|
|
88
|
+
|
|
58
89
|
def _is_pytest_fixture_injection_site(
|
|
59
90
|
node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
60
91
|
) -> bool:
|
|
@@ -77,13 +108,7 @@ def _is_pytest_fixture_injection_site(
|
|
|
77
108
|
"""
|
|
78
109
|
if node.name.startswith("test"):
|
|
79
110
|
return True
|
|
80
|
-
|
|
81
|
-
unwrapped = each_decorator.func if isinstance(each_decorator, ast.Call) else each_decorator
|
|
82
|
-
if isinstance(unwrapped, ast.Name) and unwrapped.id == "fixture":
|
|
83
|
-
return True
|
|
84
|
-
if isinstance(unwrapped, ast.Attribute) and unwrapped.attr == "fixture":
|
|
85
|
-
return True
|
|
86
|
-
return False
|
|
111
|
+
return _has_pytest_fixture_decorator(node)
|
|
87
112
|
|
|
88
113
|
|
|
89
114
|
def _normalize_fixture_annotation_text(annotation_text: str) -> str:
|
|
@@ -159,6 +184,11 @@ def check_known_pytest_fixture_annotations(content: str, file_path: str) -> list
|
|
|
159
184
|
fixture; and a ``*args`` or ``**kwargs`` parameter that happens to share a
|
|
160
185
|
fixture name is never a fixture injection.
|
|
161
186
|
|
|
187
|
+
Only pytest-collectable functions are inspected: functions at module top
|
|
188
|
+
level and methods defined directly in a class body. A fixture-named
|
|
189
|
+
parameter on a function nested inside another function's body is exempt,
|
|
190
|
+
because pytest never injects a fixture into a function-nested definition.
|
|
191
|
+
|
|
162
192
|
Args:
|
|
163
193
|
content: The Python source to analyze.
|
|
164
194
|
file_path: The path of the file being checked.
|
|
@@ -177,9 +207,7 @@ def check_known_pytest_fixture_annotations(content: str, file_path: str) -> list
|
|
|
177
207
|
except SyntaxError:
|
|
178
208
|
return []
|
|
179
209
|
issues: list[str] = []
|
|
180
|
-
for each_node in
|
|
181
|
-
if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
182
|
-
continue
|
|
210
|
+
for each_node in _collect_pytest_collectable_functions(tree):
|
|
183
211
|
if not _is_pytest_fixture_injection_site(each_node):
|
|
184
212
|
continue
|
|
185
213
|
for each_arg in _collect_fixture_injection_arguments(each_node):
|
|
@@ -205,6 +233,157 @@ def check_known_pytest_fixture_annotations(content: str, file_path: str) -> list
|
|
|
205
233
|
return issues
|
|
206
234
|
|
|
207
235
|
|
|
236
|
+
def _names_referenced_in_subtree(node: ast.AST) -> set[str]:
|
|
237
|
+
"""Return every identifier referenced anywhere within an AST subtree.
|
|
238
|
+
|
|
239
|
+
A name counts as referenced when it is read (an ``ast.Name`` in load
|
|
240
|
+
context), deleted (an ``ast.Name`` in delete context), or augmented-assigned
|
|
241
|
+
(the ``ast.Name`` target of an ``ast.AugAssign``, which reads the prior value
|
|
242
|
+
before storing). The walk reaches into nested function and comprehension
|
|
243
|
+
bodies, so a parameter referenced only inside an inner function still counts.
|
|
244
|
+
A name appearing solely as a plain assignment target — ``ast.Store`` context
|
|
245
|
+
without augmentation — is absent, because rebinding the name without reading
|
|
246
|
+
it leaves the fixture's setup genuinely unused.
|
|
247
|
+
|
|
248
|
+
Args:
|
|
249
|
+
node: The AST node whose subtree is scanned for referenced identifiers.
|
|
250
|
+
|
|
251
|
+
Returns:
|
|
252
|
+
The set of identifier strings read, deleted, or augmented-assigned at
|
|
253
|
+
least once within the subtree.
|
|
254
|
+
"""
|
|
255
|
+
referenced_names: set[str] = set()
|
|
256
|
+
for each_descendant in ast.walk(node):
|
|
257
|
+
if isinstance(each_descendant, ast.Name) and isinstance(
|
|
258
|
+
each_descendant.ctx, (ast.Load, ast.Del)
|
|
259
|
+
):
|
|
260
|
+
referenced_names.add(each_descendant.id)
|
|
261
|
+
if isinstance(each_descendant, ast.AugAssign) and isinstance(
|
|
262
|
+
each_descendant.target, ast.Name
|
|
263
|
+
):
|
|
264
|
+
referenced_names.add(each_descendant.target.id)
|
|
265
|
+
return referenced_names
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _is_pytest_test_function(
|
|
269
|
+
node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
270
|
+
) -> bool:
|
|
271
|
+
"""Return True when a function is a pytest-collected test function.
|
|
272
|
+
|
|
273
|
+
A test function is one whose name begins with the ``test`` prefix, matching
|
|
274
|
+
pytest's default ``python_functions = test*`` collection rule. A
|
|
275
|
+
``@pytest.fixture``-decorated function is deliberately excluded regardless of
|
|
276
|
+
its name: a fixture that injects another fixture only to compose its setup,
|
|
277
|
+
without reading the value, is an intentional pattern this check must not flag.
|
|
278
|
+
The decorator is recognized written as ``@pytest.fixture`` or a bare
|
|
279
|
+
``@fixture``, with or without call arguments.
|
|
280
|
+
|
|
281
|
+
Args:
|
|
282
|
+
node: The function definition AST node to inspect.
|
|
283
|
+
|
|
284
|
+
Returns:
|
|
285
|
+
True when the node's name begins with ``test`` and the node carries no
|
|
286
|
+
``@pytest.fixture`` decorator; False otherwise.
|
|
287
|
+
"""
|
|
288
|
+
if _has_pytest_fixture_decorator(node):
|
|
289
|
+
return False
|
|
290
|
+
return node.name.startswith("test")
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _collect_pytest_collectable_functions(
|
|
294
|
+
tree: ast.AST,
|
|
295
|
+
) -> "list[ast.FunctionDef | ast.AsyncFunctionDef]":
|
|
296
|
+
"""Return the function nodes pytest can collect as tests, in source order.
|
|
297
|
+
|
|
298
|
+
pytest collects a test function only at module top level or as a method
|
|
299
|
+
defined directly in a class body; a function nested inside another
|
|
300
|
+
function's body is never collected, so its parameters are ordinary local
|
|
301
|
+
arguments rather than injected fixtures. The walk descends through the
|
|
302
|
+
module body and through class bodies (including nested classes, so methods
|
|
303
|
+
of a nested class are reached), collects each ``FunctionDef`` /
|
|
304
|
+
``AsyncFunctionDef`` it encounters, and never descends into a function's own
|
|
305
|
+
body — function-nested definitions are excluded.
|
|
306
|
+
|
|
307
|
+
Args:
|
|
308
|
+
tree: The parsed module (or any node whose body holds the candidates).
|
|
309
|
+
|
|
310
|
+
Returns:
|
|
311
|
+
Module-level and class-method function definitions in source order;
|
|
312
|
+
never a function-nested definition.
|
|
313
|
+
"""
|
|
314
|
+
collectable_functions: list[ast.FunctionDef | ast.AsyncFunctionDef] = []
|
|
315
|
+
for each_statement in getattr(tree, "body", []):
|
|
316
|
+
if isinstance(each_statement, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
317
|
+
collectable_functions.append(each_statement)
|
|
318
|
+
continue
|
|
319
|
+
if isinstance(each_statement, ast.ClassDef):
|
|
320
|
+
collectable_functions.extend(
|
|
321
|
+
_collect_pytest_collectable_functions(each_statement)
|
|
322
|
+
)
|
|
323
|
+
return collectable_functions
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def check_unused_known_pytest_fixture_parameters(
|
|
327
|
+
content: str, file_path: str
|
|
328
|
+
) -> list[str]:
|
|
329
|
+
"""Flag well-known pytest fixture parameters a test declares but never reads.
|
|
330
|
+
|
|
331
|
+
A pytest test function that names a builtin fixture from
|
|
332
|
+
``ANNOTATION_BY_PYTEST_FIXTURE`` — ``tmp_path``, ``monkeypatch``,
|
|
333
|
+
``capsys``, and the rest — pays the fixture's setup cost on every run:
|
|
334
|
+
pytest materializes the temp directory, installs the monkeypatch context,
|
|
335
|
+
or captures output even when the body never touches the value. A parameter
|
|
336
|
+
the body never references is therefore dead weight, and most often a
|
|
337
|
+
copy-paste remnant from a sibling test that did use it. This check flags
|
|
338
|
+
each such parameter so the author drops it.
|
|
339
|
+
|
|
340
|
+
Only pytest-collected test functions are inspected: functions at module top
|
|
341
|
+
level and methods defined directly in a class body. A function nested inside
|
|
342
|
+
another function's body is excluded — pytest never collects it, so its
|
|
343
|
+
fixture-named parameter is an ordinary local argument. A
|
|
344
|
+
``@pytest.fixture``-decorated function is exempt because injecting a fixture
|
|
345
|
+
into another fixture purely to order its setup is an intentional pattern. A
|
|
346
|
+
parameter counts as used when its name is referenced anywhere in the function
|
|
347
|
+
body — read, augmented-assigned, or deleted — including inside a nested
|
|
348
|
+
function or comprehension; an attribute access such as
|
|
349
|
+
``monkeypatch.setenv(...)`` reads the name and so counts. Only the named
|
|
350
|
+
injection slots pytest fills — undefaulted positional-or-keyword and
|
|
351
|
+
keyword-only parameters — are considered, matching
|
|
352
|
+
``check_known_pytest_fixture_annotations``.
|
|
353
|
+
|
|
354
|
+
Args:
|
|
355
|
+
content: The Python source to analyze.
|
|
356
|
+
file_path: The path of the file being checked.
|
|
357
|
+
|
|
358
|
+
Returns:
|
|
359
|
+
One blocking issue per known fixture parameter declared on a test
|
|
360
|
+
function whose body never references it, naming the parameter.
|
|
361
|
+
"""
|
|
362
|
+
if not is_test_file(file_path):
|
|
363
|
+
return []
|
|
364
|
+
if is_workflow_registry_file(file_path) or is_migration_file(file_path):
|
|
365
|
+
return []
|
|
366
|
+
try:
|
|
367
|
+
tree = ast.parse(content)
|
|
368
|
+
except SyntaxError:
|
|
369
|
+
return []
|
|
370
|
+
issues: list[str] = []
|
|
371
|
+
for each_node in _collect_pytest_collectable_functions(tree):
|
|
372
|
+
if not _is_pytest_test_function(each_node):
|
|
373
|
+
continue
|
|
374
|
+
referenced_names = _names_referenced_in_subtree(each_node)
|
|
375
|
+
for each_arg in _collect_fixture_injection_arguments(each_node):
|
|
376
|
+
if each_arg.arg not in ANNOTATION_BY_PYTEST_FIXTURE:
|
|
377
|
+
continue
|
|
378
|
+
if each_arg.arg in referenced_names:
|
|
379
|
+
continue
|
|
380
|
+
issues.append(
|
|
381
|
+
f"Line {each_arg.lineno}: parameter {each_arg.arg!r} on "
|
|
382
|
+
f"{each_node.name!r} - {UNUSED_PYTEST_FIXTURE_PARAMETER_MESSAGE_SUFFIX}"
|
|
383
|
+
)
|
|
384
|
+
return issues
|
|
385
|
+
|
|
386
|
+
|
|
208
387
|
def check_return_annotations(content: str, file_path: str) -> list[str]:
|
|
209
388
|
if is_test_file(file_path):
|
|
210
389
|
return []
|