claude-dev-env 1.60.0 → 1.61.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 (32) hide show
  1. package/CLAUDE.md +4 -0
  2. package/audit-rubrics/category_rubrics/category-f-silent-failures.md +1 -1
  3. package/audit-rubrics/prompts/category-e-dead-code.md +17 -4
  4. package/audit-rubrics/prompts/category-f-silent-failures.md +1 -0
  5. package/docs/CODE_RULES.md +2 -2
  6. package/hooks/blocking/code_rules_annotations_length.py +189 -10
  7. package/hooks/blocking/code_rules_enforcer.py +8 -0
  8. package/hooks/blocking/code_rules_orphan_css_class.py +196 -0
  9. package/hooks/blocking/config/verified_commit_constants.py +14 -2
  10. package/hooks/blocking/destructive_command_blocker.py +483 -61
  11. package/hooks/blocking/test_code_rules_enforcer_annotations.py +240 -0
  12. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +1 -0
  13. package/hooks/blocking/test_code_rules_enforcer_dispatch_wiring.py +82 -0
  14. package/hooks/blocking/test_code_rules_enforcer_orphan_css_class.py +196 -0
  15. package/hooks/blocking/test_destructive_command_blocker.py +213 -0
  16. package/hooks/blocking/test_verification_verdict_store.py +212 -0
  17. package/hooks/blocking/test_verified_commit_gate.py +127 -0
  18. package/hooks/blocking/test_verifier_verdict_minter.py +74 -95
  19. package/hooks/blocking/verification_verdict_store.py +240 -0
  20. package/hooks/blocking/verified_commit_gate.py +20 -8
  21. package/hooks/blocking/verifier_verdict_minter.py +46 -124
  22. package/hooks/hooks_constants/code_rules_enforcer_constants.py +6 -0
  23. package/hooks/hooks_constants/destructive_command_segment_constants.py +15 -0
  24. package/hooks/hooks_constants/orphan_css_class_constants.py +40 -0
  25. package/hooks/validation/mypy_validator.py +59 -7
  26. package/hooks/validation/test_mypy_validator.py +94 -0
  27. package/package.json +1 -1
  28. package/rules/orphan-css-class.md +23 -0
  29. package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +0 -1
  30. package/skills/autoconverge/workflow/converge.contract.test.mjs +202 -13
  31. package/skills/autoconverge/workflow/converge.mjs +392 -51
  32. package/skills/autoconverge/workflow/test_render_report.py +30 -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.
@@ -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 8 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.
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-E8, 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 8 sub-buckets — find them." Open Questions section for ambiguities. Read-only. No edits, no commits.
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 8 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.
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-E8, 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 8 sub-buckets — find them." Open Questions section for ambiguities. Read-only. No edits, no commits.
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.
@@ -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
- for each_decorator in node.decorator_list:
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 ast.walk(tree):
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 []
@@ -33,6 +33,7 @@ from code_rules_annotations_length import ( # noqa: E402
33
33
  check_known_pytest_fixture_annotations,
34
34
  check_parameter_annotations,
35
35
  check_return_annotations,
36
+ check_unused_known_pytest_fixture_parameters,
36
37
  )
37
38
  from code_rules_banned_identifiers import ( # noqa: E402
38
39
  check_banned_identifiers,
@@ -89,6 +90,9 @@ from code_rules_optional_params import ( # noqa: E402
89
90
  check_duplicated_format_patterns,
90
91
  check_unused_optional_parameters,
91
92
  )
93
+ from code_rules_orphan_css_class import ( # noqa: E402
94
+ check_orphan_css_classes,
95
+ )
92
96
  from code_rules_paths_syspath import ( # noqa: E402
93
97
  check_hardcoded_user_paths,
94
98
  check_sys_path_insert_deduplication_guard,
@@ -281,6 +285,9 @@ def validate_content(
281
285
  all_issues.extend(check_library_print(content, file_path))
282
286
  all_issues.extend(check_parameter_annotations(content, file_path))
283
287
  all_issues.extend(check_known_pytest_fixture_annotations(content, file_path))
288
+ all_issues.extend(
289
+ check_unused_known_pytest_fixture_parameters(content, file_path)
290
+ )
284
291
  all_issues.extend(check_return_annotations(content, file_path))
285
292
  all_issues.extend(
286
293
  check_function_length(
@@ -294,6 +301,7 @@ def validate_content(
294
301
  all_issues.extend(check_inline_literal_collections(content, file_path))
295
302
  all_issues.extend(check_inline_tuple_string_magic(content, file_path))
296
303
  all_issues.extend(check_string_literal_magic(content, file_path))
304
+ all_issues.extend(check_orphan_css_classes(effective_content, file_path))
297
305
  check_incomplete_mocks(content, file_path)
298
306
  check_duplicated_format_patterns(content, file_path)
299
307
  advise_cross_skill_duplicate_helper(effective_content, file_path)
@@ -0,0 +1,196 @@
1
+ """Orphan-CSS-class check: class attributes in markup with no matching selector."""
2
+
3
+ import ast
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ _blocking_directory = str(Path(__file__).resolve().parent)
8
+ _hooks_directory = str(Path(__file__).resolve().parent.parent)
9
+ if _blocking_directory not in sys.path:
10
+ sys.path.insert(0, _blocking_directory)
11
+ if _hooks_directory not in sys.path:
12
+ sys.path.insert(0, _hooks_directory)
13
+
14
+ from code_rules_shared import ( # noqa: E402
15
+ is_test_file,
16
+ )
17
+
18
+ from hooks_constants.orphan_css_class_constants import ( # noqa: E402
19
+ CLASS_ATTRIBUTE_PATTERN,
20
+ CSS_CLASS_SELECTOR_PATTERN,
21
+ MAX_ORPHAN_CSS_CLASS_ISSUES,
22
+ MAX_SIBLING_MODULES_SCANNED,
23
+ ORPHAN_CSS_CLASS_MESSAGE_SUFFIX,
24
+ PYTHON_MODULE_GLOB,
25
+ STYLE_BLOCK_PATTERN,
26
+ )
27
+
28
+
29
+ def _string_literals_with_lines(tree: ast.Module) -> list[tuple[str, int]]:
30
+ """Return every string-constant value in the tree paired with its line number.
31
+
32
+ Args:
33
+ tree: The parsed module to walk.
34
+
35
+ Returns:
36
+ A list of ``(string_value, line_number)`` pairs, one per string constant.
37
+ """
38
+ literals: list[tuple[str, int]] = []
39
+ for each_node in ast.walk(tree):
40
+ if isinstance(each_node, ast.Constant) and isinstance(each_node.value, str):
41
+ literals.append((each_node.value, each_node.lineno))
42
+ return literals
43
+
44
+
45
+ def _class_names_in_attribute(attribute_text: str) -> list[str]:
46
+ """Return the individual class names in a single ``class="..."`` attribute.
47
+
48
+ Args:
49
+ attribute_text: The whitespace-separated class list from one attribute.
50
+
51
+ Returns:
52
+ Each non-empty class token, in order.
53
+ """
54
+ return [each_token for each_token in attribute_text.split() if each_token]
55
+
56
+
57
+ def _class_references_with_lines(
58
+ all_string_literals: list[tuple[str, int]],
59
+ ) -> list[tuple[str, int]]:
60
+ """Return every class name referenced in a ``class="..."`` attribute.
61
+
62
+ Args:
63
+ all_string_literals: The ``(literal_text, line_number)`` constants to scan.
64
+
65
+ Returns:
66
+ A list of ``(class_name, line_number)`` pairs, one per referenced class.
67
+ """
68
+ references: list[tuple[str, int]] = []
69
+ for each_text, each_line in all_string_literals:
70
+ for each_match in CLASS_ATTRIBUTE_PATTERN.finditer(each_text):
71
+ for each_class_name in _class_names_in_attribute(each_match.group(1)):
72
+ references.append((each_class_name, each_line))
73
+ return references
74
+
75
+
76
+ def _defined_class_selectors(all_string_literals: list[tuple[str, int]]) -> set[str]:
77
+ """Return every CSS class name defined by a selector inside a ``<style>`` block.
78
+
79
+ Args:
80
+ all_string_literals: The ``(literal_text, line_number)`` constants to scan.
81
+
82
+ Returns:
83
+ The set of class names that carry a matching ``.<class>`` selector.
84
+ """
85
+ defined: set[str] = set()
86
+ for each_text, _ in all_string_literals:
87
+ for each_style_match in STYLE_BLOCK_PATTERN.finditer(each_text):
88
+ for each_selector in CSS_CLASS_SELECTOR_PATTERN.finditer(
89
+ each_style_match.group(1)
90
+ ):
91
+ defined.add(each_selector.group(1))
92
+ return defined
93
+
94
+
95
+ def _sibling_module_paths(file_path: str) -> list[Path]:
96
+ """Return the importable sibling Python modules near *file_path*.
97
+
98
+ Scans the file's own directory and its immediate child directories, since a
99
+ markup module commonly imports its ``<style>`` constant from a companion
100
+ package directory beside it. The scan is bounded so a large tree never
101
+ stalls a write.
102
+
103
+ Args:
104
+ file_path: The absolute path of the file under validation.
105
+
106
+ Returns:
107
+ The sibling ``.py`` paths to read for cross-module selector resolution,
108
+ excluding the file itself, capped at the scan budget.
109
+ """
110
+ target = Path(file_path)
111
+ base_directory = target.parent
112
+ if not base_directory.is_dir():
113
+ return []
114
+ siblings: list[Path] = []
115
+ for each_path in sorted(base_directory.rglob(PYTHON_MODULE_GLOB)):
116
+ if each_path.resolve() == target.resolve():
117
+ continue
118
+ siblings.append(each_path)
119
+ if len(siblings) >= MAX_SIBLING_MODULES_SCANNED:
120
+ break
121
+ return siblings
122
+
123
+
124
+ def _selectors_from_sibling_modules(file_path: str) -> set[str]:
125
+ """Return CSS class selectors defined in ``<style>`` blocks of sibling modules.
126
+
127
+ Args:
128
+ file_path: The absolute path of the file under validation.
129
+
130
+ Returns:
131
+ The union of class names whose selectors appear in any readable sibling
132
+ module's string literals.
133
+ """
134
+ selectors: set[str] = set()
135
+ for each_sibling in _sibling_module_paths(file_path):
136
+ try:
137
+ sibling_source = each_sibling.read_text(encoding="utf-8")
138
+ except (OSError, UnicodeDecodeError):
139
+ continue
140
+ try:
141
+ sibling_tree = ast.parse(sibling_source)
142
+ except SyntaxError:
143
+ continue
144
+ selectors |= _defined_class_selectors(_string_literals_with_lines(sibling_tree))
145
+ return selectors
146
+
147
+
148
+ def check_orphan_css_classes(content: str, file_path: str) -> list[str]:
149
+ """Flag ``class="..."`` markup whose class has no matching CSS selector.
150
+
151
+ A module that emits HTML names each class it references with a matching
152
+ ``.<class>`` selector, either in a ``<style>`` block in the same file or in
153
+ a companion module beside it. A referenced class with no selector anywhere
154
+ is a dead attribute (or a missing rule), so this flags it. The check only
155
+ fires for a file that itself emits markup, and only after a ``<style>``
156
+ block exists in the file or a sibling — a file with markup but no style
157
+ source nearby is left alone, since its stylesheet lives outside the scan.
158
+ Test files are exempt, since a fixture may carry intentional orphan markup.
159
+
160
+ Args:
161
+ content: The new or whole-file content being written.
162
+ file_path: The destination path of the write or edit.
163
+
164
+ Returns:
165
+ One issue per orphan class reference, capped at the issue budget.
166
+ """
167
+ if is_test_file(file_path):
168
+ return []
169
+ try:
170
+ tree = ast.parse(content)
171
+ except SyntaxError:
172
+ return []
173
+ all_string_literals = _string_literals_with_lines(tree)
174
+ class_references = _class_references_with_lines(all_string_literals)
175
+ if not class_references:
176
+ return []
177
+ defined_selectors = _defined_class_selectors(all_string_literals)
178
+ defined_selectors |= _selectors_from_sibling_modules(file_path)
179
+ if not defined_selectors:
180
+ return []
181
+ issues: list[str] = []
182
+ reported_classes: set[str] = set()
183
+ for each_class_name, each_line in class_references:
184
+ if each_class_name in defined_selectors:
185
+ continue
186
+ if each_class_name in reported_classes:
187
+ continue
188
+ reported_classes.add(each_class_name)
189
+ issues.append(
190
+ f"Line {each_line}: CSS class {each_class_name!r} used in markup"
191
+ f" has no matching '.{each_class_name}' selector - "
192
+ f"{ORPHAN_CSS_CLASS_MESSAGE_SUFFIX}"
193
+ )
194
+ if len(issues) >= MAX_ORPHAN_CSS_CLASS_ISSUES:
195
+ break
196
+ return issues
@@ -47,6 +47,20 @@ WRITE_CALL_REGION_PATTERN = (
47
47
  )
48
48
  VERDICT_KEY_ALL_PASS = "all_pass"
49
49
  VERDICT_KEY_MANIFEST_SHA256 = "manifest_sha256"
50
+ VERDICT_KEY_FINDINGS = "findings"
51
+ SUBAGENTS_DIRECTORY_NAME = "subagents"
52
+ AGENT_TRANSCRIPT_GLOB = "agent-*.jsonl"
53
+ AGENT_META_SIDECAR_SUFFIX = ".meta.json"
54
+ AGENT_META_TYPE_KEY = "agentType"
55
+ TRANSCRIPT_ENTRY_TYPE_KEY = "type"
56
+ TRANSCRIPT_ASSISTANT_ENTRY_TYPE = "assistant"
57
+ TRANSCRIPT_MESSAGE_KEY = "message"
58
+ TRANSCRIPT_CONTENT_KEY = "content"
59
+ TRANSCRIPT_CONTENT_TYPE_KEY = "type"
60
+ TRANSCRIPT_TEXT_CONTENT_TYPE = "text"
61
+ TRANSCRIPT_TEXT_KEY = "text"
62
+ VERDICT_FENCE_PATTERN = r"```verdict\s*\n(.*?)```"
63
+ MANIFEST_HASH_CLI_FLAG = "--manifest-hash"
50
64
  DOCS_ONLY_EXTENSIONS = frozenset(
51
65
  {".md", ".txt", ".rst", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".ico"}
52
66
  )
@@ -83,8 +97,6 @@ OPTION_WITH_VALUE_STEP = 2
83
97
  ALL_GATED_TOOL_NAMES = ("Bash", "PowerShell")
84
98
  HASH_PREVIEW_LENGTH = 16
85
99
  MINTING_AGENT_TYPE = "code-verifier"
86
- SPAWN_LOOKUP_ATTEMPT_COUNT = 3
87
- SPAWN_LOOKUP_RETRY_DELAY_SECONDS = 0.1
88
100
  VERDICT_DIRECTORY_GUARD_MESSAGE = (
89
101
  "BLOCKED: [VERDICT_DIRECTORY_GUARD] Shell access to the verification "
90
102
  "verdict directory (~/.claude/verification/) is denied. Only the "