claude-dev-env 1.59.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.
- package/CLAUDE.md +4 -0
- package/audit-rubrics/category_rubrics/category-b-selector-engine-compat.md +1 -1
- package/audit-rubrics/category_rubrics/category-e-dead-code.md +1 -0
- package/audit-rubrics/category_rubrics/category-f-silent-failures.md +1 -1
- package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
- package/audit-rubrics/prompts/category-b-selector-engine-compat.md +2 -2
- package/audit-rubrics/prompts/category-e-dead-code.md +17 -4
- package/audit-rubrics/prompts/category-f-silent-failures.md +1 -0
- package/docs/CODE_RULES.md +2 -2
- package/hooks/blocking/code_rules_annotations_length.py +189 -10
- package/hooks/blocking/code_rules_dead_module_constant.py +321 -0
- package/hooks/blocking/code_rules_duplicate_body.py +152 -0
- package/hooks/blocking/code_rules_enforcer.py +38 -15
- package/hooks/blocking/code_rules_orphan_css_class.py +196 -0
- package/hooks/blocking/code_rules_typeddict_stub.py +172 -0
- package/hooks/blocking/config/__init__.py +5 -0
- package/hooks/blocking/config/verified_commit_constants.py +118 -0
- 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_cross_skill_duplicate.py +146 -0
- package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +188 -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_code_rules_enforcer_zero_payload_alias.py +415 -0
- package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +156 -0
- package/hooks/blocking/test_destructive_command_blocker.py +213 -0
- package/hooks/blocking/test_verdict_directory_write_blocker.py +720 -0
- package/hooks/blocking/test_verification_verdict_store.py +490 -0
- package/hooks/blocking/test_verified_commit_gate.py +495 -0
- package/hooks/blocking/test_verified_commit_message_accuracy_blocker.py +131 -0
- package/hooks/blocking/test_verifier_verdict_minter.py +193 -0
- package/hooks/blocking/verdict_directory_write_blocker.py +667 -0
- package/hooks/blocking/verification_verdict_store.py +686 -0
- package/hooks/blocking/verified_commit_gate.py +535 -0
- package/hooks/blocking/verified_commit_message_accuracy_blocker.py +152 -0
- package/hooks/blocking/verifier_verdict_minter.py +221 -0
- package/hooks/diagnostic/test_hook_log_extractor.py +3 -3
- package/hooks/hooks.json +43 -1
- package/hooks/hooks_constants/blocking_check_limits.py +1 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +6 -0
- package/hooks/hooks_constants/dead_module_constant_constants.py +20 -0
- package/hooks/hooks_constants/destructive_command_segment_constants.py +15 -0
- package/hooks/hooks_constants/duplicate_function_body_constants.py +22 -5
- package/hooks/hooks_constants/orphan_css_class_constants.py +40 -0
- package/hooks/hooks_constants/precommit_code_rules_gate_constants.py +1 -1
- package/hooks/validation/mypy_validator.py +59 -7
- package/hooks/validation/test_mypy_validator.py +94 -0
- package/package.json +1 -1
- package/rules/file-global-constants.md +7 -1
- package/rules/no-cross-skill-duplicate-helpers.md +29 -0
- package/rules/orphan-css-class.md +23 -0
- package/skills/_shared/pr-loop/scripts/preflight_worktree.py +392 -0
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/preflight_constants.py +70 -0
- package/skills/_shared/pr-loop/scripts/test_preflight_worktree.py +263 -0
- package/skills/autoconverge/SKILL.md +54 -17
- package/skills/autoconverge/reference/closing-report.md +59 -17
- package/skills/autoconverge/workflow/aggregate_runs.py +371 -0
- package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +192 -76
- package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +76 -0
- package/skills/autoconverge/workflow/converge.contract.test.mjs +395 -206
- package/skills/autoconverge/workflow/converge.mjs +520 -57
- package/skills/autoconverge/workflow/convergence_summary.py +110 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-ab1c2d3e4f5a6b7c8.jsonl +2 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/workflows/wf_881252e6-700.json +7 -0
- package/skills/autoconverge/workflow/render_report.py +488 -397
- package/skills/autoconverge/workflow/test_aggregate_runs.py +134 -0
- package/skills/autoconverge/workflow/test_convergence_summary.py +132 -0
- package/skills/autoconverge/workflow/test_render_report.py +518 -259
- package/skills/pr-converge/reference/per-tick.md +28 -8
- package/skills/rebase/SKILL.md +2 -4
- package/system-prompts/software-engineer.xml +2 -6
- package/hooks/blocking/content_search_to_zoekt_redirector.py +0 -59
- package/hooks/blocking/content_search_zoekt_bash_block_reason.py +0 -25
- package/hooks/blocking/content_search_zoekt_block_payload.py +0 -21
- package/hooks/blocking/content_search_zoekt_indexed_paths.py +0 -24
- package/hooks/blocking/content_search_zoekt_indexed_roots_config.py +0 -131
- package/hooks/blocking/content_search_zoekt_redirect_guidance.py +0 -52
- package/hooks/blocking/test_content_search_to_zoekt_redirector_integration.py +0 -61
- package/hooks/blocking/test_content_search_to_zoekt_redirector_unit.py +0 -92
- package/hooks/blocking/test_content_search_zoekt_indexed_roots_config.py +0 -102
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""SubagentStop hook: mint a commit-gate verdict when code-verifier finishes.
|
|
2
|
+
|
|
3
|
+
Only this hook writes verdict files — the main session is denied writes to
|
|
4
|
+
the verdict directory, so a session cannot fabricate a passing verdict. The
|
|
5
|
+
SubagentStop payload names the stopping subagent's own transcript
|
|
6
|
+
(``agent_transcript_path``), which sits beside a harness-written
|
|
7
|
+
``agent-<id>.meta.json`` sidecar naming the spawning ``agentType``. The hook
|
|
8
|
+
reads that type from the sidecar, so it resolves identically in interactive,
|
|
9
|
+
background, and worktree-switched sessions. When that type is
|
|
10
|
+
``code-verifier``, the hook pulls the verdict block out of the agent's own
|
|
11
|
+
transcript (``agent_transcript_path``); the main session writes neither that
|
|
12
|
+
transcript nor the sidecar, so text it prints can never mint — recomputes the
|
|
13
|
+
live change-surface hash for the session repository, and writes the verdict
|
|
14
|
+
bound to that hash. The companion ``verified_commit_gate.py`` (PreToolUse)
|
|
15
|
+
then allows ``git commit`` / ``git push`` only while the work tree still
|
|
16
|
+
matches the verified state.
|
|
17
|
+
|
|
18
|
+
The verifier's final message must end with a fenced block::
|
|
19
|
+
|
|
20
|
+
```verdict
|
|
21
|
+
{"all_pass": true, "findings": []}
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
A missing or unparseable block mints nothing, which leaves the gate closed.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import json
|
|
30
|
+
import re
|
|
31
|
+
import sys
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
blocking_directory = str(Path(__file__).resolve().parent)
|
|
35
|
+
if blocking_directory not in sys.path:
|
|
36
|
+
sys.path.insert(0, blocking_directory)
|
|
37
|
+
|
|
38
|
+
from config.verified_commit_constants import MINTING_AGENT_TYPE
|
|
39
|
+
from verification_verdict_store import (
|
|
40
|
+
branch_surface_manifest,
|
|
41
|
+
manifest_sha256,
|
|
42
|
+
resolve_merge_base,
|
|
43
|
+
resolve_repo_root,
|
|
44
|
+
write_verdict,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def assistant_text_blocks(transcript_path: str) -> list[str]:
|
|
49
|
+
"""Collect every assistant text block from a transcript JSONL file.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
transcript_path: Path to the subagent's transcript.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
The text of each assistant content block, in transcript order;
|
|
56
|
+
empty when the file is missing or holds no assistant text.
|
|
57
|
+
"""
|
|
58
|
+
collected_blocks: list[str] = []
|
|
59
|
+
try:
|
|
60
|
+
transcript_lines = (
|
|
61
|
+
Path(transcript_path).read_text(encoding="utf-8", errors="replace").splitlines()
|
|
62
|
+
)
|
|
63
|
+
except OSError:
|
|
64
|
+
return collected_blocks
|
|
65
|
+
for each_line in transcript_lines:
|
|
66
|
+
try:
|
|
67
|
+
transcript_entry = json.loads(each_line)
|
|
68
|
+
except json.JSONDecodeError:
|
|
69
|
+
continue
|
|
70
|
+
if not isinstance(transcript_entry, dict):
|
|
71
|
+
continue
|
|
72
|
+
if transcript_entry.get("type") != "assistant":
|
|
73
|
+
continue
|
|
74
|
+
message_body = transcript_entry.get("message")
|
|
75
|
+
if not isinstance(message_body, dict):
|
|
76
|
+
continue
|
|
77
|
+
content_blocks = message_body.get("content")
|
|
78
|
+
if not isinstance(content_blocks, list):
|
|
79
|
+
continue
|
|
80
|
+
for each_block in content_blocks:
|
|
81
|
+
if isinstance(each_block, dict) and each_block.get("type") == "text":
|
|
82
|
+
block_text = each_block.get("text")
|
|
83
|
+
if isinstance(block_text, str):
|
|
84
|
+
collected_blocks.append(block_text)
|
|
85
|
+
return collected_blocks
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def last_verdict_in_blocks(all_text_blocks: list[str]) -> dict | None:
|
|
89
|
+
"""Extract the final verdict fence from assistant text blocks.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
all_text_blocks: Assistant text blocks in transcript order.
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
The parsed verdict mapping carrying a boolean ``all_pass`` and a
|
|
96
|
+
list ``findings``, or None when no block holds a wellformed fence.
|
|
97
|
+
"""
|
|
98
|
+
verdict_fence_pattern = re.compile(r"```verdict\s*\n(.*?)```", re.DOTALL)
|
|
99
|
+
fence_bodies: list[str] = []
|
|
100
|
+
for each_block in all_text_blocks:
|
|
101
|
+
fence_bodies.extend(verdict_fence_pattern.findall(each_block))
|
|
102
|
+
for each_fence_body in reversed(fence_bodies):
|
|
103
|
+
try:
|
|
104
|
+
verdict_record = json.loads(each_fence_body)
|
|
105
|
+
except json.JSONDecodeError:
|
|
106
|
+
continue
|
|
107
|
+
if not isinstance(verdict_record, dict):
|
|
108
|
+
continue
|
|
109
|
+
if not isinstance(verdict_record.get("all_pass"), bool):
|
|
110
|
+
continue
|
|
111
|
+
if not isinstance(verdict_record.get("findings"), list):
|
|
112
|
+
continue
|
|
113
|
+
return verdict_record
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _agent_type_from_meta_sidecar(agent_transcript_path: str) -> str | None:
|
|
118
|
+
"""Read the spawning agentType from a subagent transcript's sidecar.
|
|
119
|
+
|
|
120
|
+
Each subagent transcript ``agent-<id>.jsonl`` sits beside a harness-written
|
|
121
|
+
``agent-<id>.meta.json`` naming the spawning ``agentType``. Reading the type
|
|
122
|
+
from this sidecar binds it to the stopping subagent itself, so it resolves
|
|
123
|
+
identically in interactive, background, and worktree-switched sessions and
|
|
124
|
+
needs no parent-transcript scan or flush retry.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
agent_transcript_path: The stopping subagent's own transcript path from
|
|
128
|
+
the SubagentStop payload.
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
The recorded ``agentType``, or None when the path is empty, the sidecar
|
|
132
|
+
is absent or cannot be read or parsed, it does not hold a JSON object,
|
|
133
|
+
or it names no string ``agentType``.
|
|
134
|
+
"""
|
|
135
|
+
if not agent_transcript_path:
|
|
136
|
+
return None
|
|
137
|
+
transcript_file = Path(agent_transcript_path)
|
|
138
|
+
sidecar_file = transcript_file.with_name(f"{transcript_file.stem}.meta.json")
|
|
139
|
+
try:
|
|
140
|
+
sidecar_record = json.loads(sidecar_file.read_text(encoding="utf-8"))
|
|
141
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
|
|
142
|
+
return None
|
|
143
|
+
if not isinstance(sidecar_record, dict):
|
|
144
|
+
return None
|
|
145
|
+
recorded_type = sidecar_record.get("agentType")
|
|
146
|
+
return recorded_type if isinstance(recorded_type, str) else None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def resolved_subagent_type(subagent_stop_payload: dict) -> str | None:
|
|
150
|
+
"""Recover the spawning agent type for a SubagentStop payload.
|
|
151
|
+
|
|
152
|
+
The stopping subagent's own transcript (``agent_transcript_path``) sits
|
|
153
|
+
beside a harness-written ``agent-<id>.meta.json`` sidecar naming its
|
|
154
|
+
``agentType``. Reading the type from that sidecar binds it to the subagent
|
|
155
|
+
itself, so it resolves the same across interactive, background, and
|
|
156
|
+
worktree-switched sessions.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
subagent_stop_payload: The SubagentStop hook payload.
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
The agent type this subagent was spawned with, or None when the
|
|
163
|
+
``agent_transcript_path`` is empty, the sidecar is absent or cannot be
|
|
164
|
+
read or parsed, it does not hold a JSON object, or it names no string
|
|
165
|
+
``agentType``.
|
|
166
|
+
"""
|
|
167
|
+
return _agent_type_from_meta_sidecar(
|
|
168
|
+
subagent_stop_payload.get("agent_transcript_path", "")
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def mint_for_payload(subagent_stop_payload: dict) -> Path | None:
|
|
173
|
+
"""Mint a verdict file for a code-verifier stop event.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
subagent_stop_payload: The SubagentStop hook payload.
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
The verdict file path when minted; None when the payload is not a
|
|
180
|
+
code-verifier stop, the transcript holds no verdict, or the
|
|
181
|
+
session directory is not a work tree with an upstream base.
|
|
182
|
+
"""
|
|
183
|
+
if resolved_subagent_type(subagent_stop_payload) != MINTING_AGENT_TYPE:
|
|
184
|
+
return None
|
|
185
|
+
agent_transcript_path = subagent_stop_payload.get("agent_transcript_path", "")
|
|
186
|
+
if not agent_transcript_path:
|
|
187
|
+
return None
|
|
188
|
+
verdict_record = last_verdict_in_blocks(assistant_text_blocks(agent_transcript_path))
|
|
189
|
+
if verdict_record is None:
|
|
190
|
+
return None
|
|
191
|
+
repo_root = resolve_repo_root(subagent_stop_payload.get("cwd", "."))
|
|
192
|
+
if repo_root is None:
|
|
193
|
+
return None
|
|
194
|
+
merge_base_sha = resolve_merge_base(repo_root)
|
|
195
|
+
if merge_base_sha is None:
|
|
196
|
+
return None
|
|
197
|
+
surface_manifest_text = branch_surface_manifest(repo_root, merge_base_sha)
|
|
198
|
+
if surface_manifest_text is None:
|
|
199
|
+
return None
|
|
200
|
+
return write_verdict(
|
|
201
|
+
repo_root,
|
|
202
|
+
manifest_sha256(surface_manifest_text),
|
|
203
|
+
verdict_record["all_pass"],
|
|
204
|
+
verdict_record["findings"],
|
|
205
|
+
str(subagent_stop_payload.get("agent_id", "")),
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def main() -> None:
|
|
210
|
+
"""Read the SubagentStop payload and mint a verdict when one applies."""
|
|
211
|
+
try:
|
|
212
|
+
subagent_stop_payload = json.load(sys.stdin)
|
|
213
|
+
except json.JSONDecodeError:
|
|
214
|
+
return
|
|
215
|
+
if not isinstance(subagent_stop_payload, dict):
|
|
216
|
+
return
|
|
217
|
+
mint_for_payload(subagent_stop_payload)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
if __name__ == "__main__":
|
|
221
|
+
main()
|
|
@@ -82,7 +82,7 @@ def _make_blocking_line(
|
|
|
82
82
|
hook_event: str = "PreToolUse",
|
|
83
83
|
tool_use_id: str = "toolu_002",
|
|
84
84
|
blocking_message: str = "blocked for reason",
|
|
85
|
-
command: str = "python C:/Users/jon/.claude/hooks/blocking/
|
|
85
|
+
command: str = "python C:/Users/jon/.claude/hooks/blocking/block_main_commit.py",
|
|
86
86
|
timestamp: str = "2026-04-24T13:32:54.293Z",
|
|
87
87
|
cwd: str = "Y:\\Projects\\repo",
|
|
88
88
|
git_branch: str = "main",
|
|
@@ -640,7 +640,7 @@ def test_run_summary_prints_table_when_rows_returned(
|
|
|
640
640
|
) -> None:
|
|
641
641
|
fake_cursor = MagicMock()
|
|
642
642
|
fake_cursor.fetchall.return_value = [
|
|
643
|
-
("
|
|
643
|
+
("block_main_commit.py", "blocking", 7, "Bash(git commit)"),
|
|
644
644
|
]
|
|
645
645
|
fake_connection = MagicMock()
|
|
646
646
|
fake_connection.cursor.return_value.__enter__.return_value = fake_cursor
|
|
@@ -652,7 +652,7 @@ def test_run_summary_prints_table_when_rows_returned(
|
|
|
652
652
|
|
|
653
653
|
captured = capsys.readouterr()
|
|
654
654
|
assert exit_code == 0
|
|
655
|
-
assert "
|
|
655
|
+
assert "block_main_commit.py" in captured.out
|
|
656
656
|
assert "blocking" in captured.out
|
|
657
657
|
assert "7" in captured.out
|
|
658
658
|
|
package/hooks/hooks.json
CHANGED
|
@@ -54,6 +54,11 @@
|
|
|
54
54
|
"type": "command",
|
|
55
55
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/hook_prose_detector_consistency.py",
|
|
56
56
|
"timeout": 10
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"type": "command",
|
|
60
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/verified_commit_message_accuracy_blocker.py",
|
|
61
|
+
"timeout": 10
|
|
57
62
|
}
|
|
58
63
|
]
|
|
59
64
|
},
|
|
@@ -118,7 +123,7 @@
|
|
|
118
123
|
{
|
|
119
124
|
"type": "command",
|
|
120
125
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/precommit_code_rules_gate.py",
|
|
121
|
-
"timeout":
|
|
126
|
+
"timeout": 150
|
|
122
127
|
},
|
|
123
128
|
{
|
|
124
129
|
"type": "command",
|
|
@@ -144,6 +149,31 @@
|
|
|
144
149
|
"type": "command",
|
|
145
150
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/gh_pr_author_enforcer.py",
|
|
146
151
|
"timeout": 30
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
"type": "command",
|
|
155
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/verified_commit_gate.py",
|
|
156
|
+
"timeout": 15
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
"type": "command",
|
|
160
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/verdict_directory_write_blocker.py",
|
|
161
|
+
"timeout": 10
|
|
162
|
+
}
|
|
163
|
+
]
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
"matcher": "PowerShell",
|
|
167
|
+
"hooks": [
|
|
168
|
+
{
|
|
169
|
+
"type": "command",
|
|
170
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/verified_commit_gate.py",
|
|
171
|
+
"timeout": 15
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
"type": "command",
|
|
175
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/verdict_directory_write_blocker.py",
|
|
176
|
+
"timeout": 10
|
|
147
177
|
}
|
|
148
178
|
]
|
|
149
179
|
},
|
|
@@ -247,6 +277,18 @@
|
|
|
247
277
|
]
|
|
248
278
|
}
|
|
249
279
|
],
|
|
280
|
+
"SubagentStop": [
|
|
281
|
+
{
|
|
282
|
+
"matcher": "",
|
|
283
|
+
"hooks": [
|
|
284
|
+
{
|
|
285
|
+
"type": "command",
|
|
286
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/verifier_verdict_minter.py",
|
|
287
|
+
"timeout": 30
|
|
288
|
+
}
|
|
289
|
+
]
|
|
290
|
+
}
|
|
291
|
+
],
|
|
250
292
|
"SessionEnd": [
|
|
251
293
|
{
|
|
252
294
|
"matcher": "",
|
|
@@ -20,6 +20,7 @@ MAX_DOCSTRING_ARGS_SIGNATURE_ISSUES: int = 5
|
|
|
20
20
|
MAX_IGNORED_MUST_CHECK_RETURN_ISSUES: int = 5
|
|
21
21
|
MAX_TYPE_ESCAPE_HATCH_ISSUES: int = 5
|
|
22
22
|
MAX_THIN_WRAPPER_ISSUES: int = 1
|
|
23
|
+
MAX_ZERO_PAYLOAD_ALIAS_ISSUES: int = 3
|
|
23
24
|
MAX_LOGGING_FSTRING_ISSUES: int = 3
|
|
24
25
|
MAX_WINDOWS_API_NONE_ISSUES: int = 3
|
|
25
26
|
MAX_E2E_TEST_NAMING_ISSUES: int = 3
|
|
@@ -124,6 +124,12 @@ KNOWN_PYTEST_FIXTURE_ANNOTATION_MESSAGE_SUFFIX: str = (
|
|
|
124
124
|
"(CODE_RULES §6; pytest builtin fixture reference "
|
|
125
125
|
"https://docs.pytest.org/en/stable/reference/fixtures.html)"
|
|
126
126
|
)
|
|
127
|
+
UNUSED_PYTEST_FIXTURE_PARAMETER_MESSAGE_SUFFIX: str = (
|
|
128
|
+
"known pytest fixture parameter is declared but never referenced in the "
|
|
129
|
+
"function body; pytest still materializes its setup, so drop the unused "
|
|
130
|
+
"parameter (pytest builtin fixture reference "
|
|
131
|
+
"https://docs.pytest.org/en/stable/reference/fixtures.html)"
|
|
132
|
+
)
|
|
127
133
|
ALL_LOOP_INDEX_LETTER_EXEMPTIONS: frozenset[str] = frozenset({"i", "j", "k", "_"})
|
|
128
134
|
EACH_PREFIX = "each_"
|
|
129
135
|
BARE_EACH_TOKEN = "each"
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Constants for the dead module-level constant detector in ``code_rules_enforcer``.
|
|
2
|
+
|
|
3
|
+
Lives under the hooks-tree ``hooks_constants`` package so module-level
|
|
4
|
+
UPPER_SNAKE constants satisfy the CODE_RULES "constants live in config"
|
|
5
|
+
requirement and share a home with the other hook-tree configuration.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
PYTHON_SOURCE_SUFFIX: str = ".py"
|
|
9
|
+
DUNDER_INIT_FILENAME: str = "__init__.py"
|
|
10
|
+
CONSTANTS_MODULE_SUFFIX: str = "_constants.py"
|
|
11
|
+
CONFIG_DIRECTORY_SEGMENT: str = "config"
|
|
12
|
+
DUNDER_ALL_NAME: str = "__all__"
|
|
13
|
+
MINIMUM_UPPER_SNAKE_LENGTH: int = 2
|
|
14
|
+
MAX_DEAD_MODULE_CONSTANT_ISSUES: int = 25
|
|
15
|
+
MAX_SCAN_ROOT_FILE_COUNT: int = 2000
|
|
16
|
+
DEAD_MODULE_CONSTANT_GUIDANCE: str = (
|
|
17
|
+
"module-level constant is defined here but never imported or read by any"
|
|
18
|
+
" module in the enclosing package tree - remove the constant, or reference it"
|
|
19
|
+
" where its value is needed (CODE_RULES §9.8)"
|
|
20
|
+
)
|
|
@@ -41,6 +41,7 @@ ALL_INTERPRETER_AND_WRAPPER_COMMANDS: frozenset[str] = frozenset(
|
|
|
41
41
|
"su",
|
|
42
42
|
"env",
|
|
43
43
|
"xargs",
|
|
44
|
+
"parallel",
|
|
44
45
|
"awk",
|
|
45
46
|
"gawk",
|
|
46
47
|
"mawk",
|
|
@@ -66,6 +67,12 @@ ALL_REMOTE_AND_PROGRAM_STRING_EXECUTORS: frozenset[str] = frozenset(
|
|
|
66
67
|
}
|
|
67
68
|
)
|
|
68
69
|
ALL_STRING_ARGUMENT_EXECUTION_FLAGS: frozenset[str] = frozenset({"-c", "-e"})
|
|
70
|
+
FIND_PROGRAM_NAME: str = "find"
|
|
71
|
+
ALL_FIND_EXEC_ACTION_FLAGS: frozenset[str] = frozenset({"-exec", "-execdir"})
|
|
72
|
+
ALL_FIND_EXEC_ACTION_TERMINATORS: frozenset[str] = frozenset({";", "+"})
|
|
73
|
+
ALL_FIND_GLOBAL_OPTION_FLAGS_WITHOUT_VALUE: frozenset[str] = frozenset({"-H", "-L", "-P"})
|
|
74
|
+
ALL_FIND_GLOBAL_OPTION_FLAGS_TAKING_A_VALUE: frozenset[str] = frozenset({"-D"})
|
|
75
|
+
FIND_OPTIMIZATION_LEVEL_OPTION_PREFIX: str = "-O"
|
|
69
76
|
ALL_BENIGN_COMPOUND_SEGMENT_COMMANDS: frozenset[str] = frozenset(
|
|
70
77
|
{
|
|
71
78
|
"echo",
|
|
@@ -176,3 +183,11 @@ ALL_LAUNCHER_OPTIONS_TAKING_SEPARATE_VALUE: frozenset[str] = frozenset(
|
|
|
176
183
|
}
|
|
177
184
|
)
|
|
178
185
|
ALL_SUBSHELL_GROUPING_CHARACTERS: str = "({"
|
|
186
|
+
ALL_KNOWN_TEMPORARY_ENVIRONMENT_VARIABLE_NAMES: frozenset[str] = frozenset(
|
|
187
|
+
{
|
|
188
|
+
"TEMP",
|
|
189
|
+
"TMP",
|
|
190
|
+
"TMPDIR",
|
|
191
|
+
"CLAUDE_JOB_DIR",
|
|
192
|
+
}
|
|
193
|
+
)
|
|
@@ -1,9 +1,14 @@
|
|
|
1
|
-
"""Constants for the
|
|
1
|
+
"""Constants for the duplicate-function-body scan in ``code_rules_enforcer``.
|
|
2
2
|
|
|
3
|
-
The scan flags a top-level function whose body is structurally identical
|
|
4
|
-
top-level function already defined in a sibling ``.py`` module in the same
|
|
5
|
-
directory. This catches the Reuse-before-create / DRY violation where a helper
|
|
6
|
-
|
|
3
|
+
The blocking scan flags a top-level function whose body is structurally identical
|
|
4
|
+
to a top-level function already defined in a sibling ``.py`` module in the same
|
|
5
|
+
directory. This catches the Reuse-before-create / DRY violation where a helper is
|
|
6
|
+
copy-pasted across several modules instead of imported from one shared home.
|
|
7
|
+
|
|
8
|
+
The ``CROSS_SKILL_*`` and ``SKILL*`` constants feed the non-blocking companion
|
|
9
|
+
advisory: a helper copied between two skills' ``scripts`` directories, where a
|
|
10
|
+
shared module would break independent install. That advisory names the source
|
|
11
|
+
skill on stderr rather than denying the write.
|
|
7
12
|
"""
|
|
8
13
|
|
|
9
14
|
MINIMUM_DUPLICATE_BODY_STATEMENTS: int = 3
|
|
@@ -15,3 +20,15 @@ DUPLICATE_BODY_GUIDANCE: str = (
|
|
|
15
20
|
"extract a single shared helper (for example in hooks_constants/) and "
|
|
16
21
|
"import it from both modules instead of copying it (Reuse before create / DRY)"
|
|
17
22
|
)
|
|
23
|
+
|
|
24
|
+
SKILLS_DIRECTORY_NAME: str = "skills"
|
|
25
|
+
SKILL_SCRIPTS_DIRECTORY_NAME: str = "scripts"
|
|
26
|
+
MAX_CROSS_SKILL_ADVISORY_ISSUES: int = 25
|
|
27
|
+
CROSS_SKILL_ADVISORY_PREFIX: str = "[CODE_RULES advisory]"
|
|
28
|
+
CROSS_SKILL_DUPLICATE_GUIDANCE: str = (
|
|
29
|
+
"two skill folders install on their own, so this copy is a defensible "
|
|
30
|
+
"skill-isolation tradeoff; a shared module would couple the skills and "
|
|
31
|
+
"break independent install. Confirm the copy is intentional, or for a "
|
|
32
|
+
"large or behavior-bearing body raise the choice through AskUserQuestion "
|
|
33
|
+
"(see the no-cross-skill-duplicate-helpers rule)"
|
|
34
|
+
)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Constants for the orphan-CSS-class check in code_rules_enforcer.
|
|
2
|
+
|
|
3
|
+
A Python module that builds HTML emits ``class="..."`` attributes in string
|
|
4
|
+
literals and pairs them with a ``<style>`` block whose selectors style those
|
|
5
|
+
classes. When a class appears in the markup but no selector defines it, the
|
|
6
|
+
markup carries a dead attribute or the style block is missing a rule. This
|
|
7
|
+
module holds the patterns that pair the two halves and the package-scan
|
|
8
|
+
budget that bounds the sibling read.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"CLASS_ATTRIBUTE_PATTERN",
|
|
15
|
+
"STYLE_BLOCK_PATTERN",
|
|
16
|
+
"CSS_CLASS_SELECTOR_PATTERN",
|
|
17
|
+
"PYTHON_MODULE_GLOB",
|
|
18
|
+
"MAX_ORPHAN_CSS_CLASS_ISSUES",
|
|
19
|
+
"MAX_SIBLING_MODULES_SCANNED",
|
|
20
|
+
"ORPHAN_CSS_CLASS_MESSAGE_SUFFIX",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
CLASS_ATTRIBUTE_PATTERN: re.Pattern[str] = re.compile(r"""class\s*=\s*["']([^"']+)["']""")
|
|
24
|
+
|
|
25
|
+
STYLE_BLOCK_PATTERN: re.Pattern[str] = re.compile(
|
|
26
|
+
r"<style[^>]*>(.*?)</style>", re.DOTALL | re.IGNORECASE
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
CSS_CLASS_SELECTOR_PATTERN: re.Pattern[str] = re.compile(r"\.(-?[_a-zA-Z][\w-]*)")
|
|
30
|
+
|
|
31
|
+
PYTHON_MODULE_GLOB: str = "*.py"
|
|
32
|
+
|
|
33
|
+
MAX_ORPHAN_CSS_CLASS_ISSUES: int = 10
|
|
34
|
+
|
|
35
|
+
MAX_SIBLING_MODULES_SCANNED: int = 60
|
|
36
|
+
|
|
37
|
+
ORPHAN_CSS_CLASS_MESSAGE_SUFFIX: str = (
|
|
38
|
+
"add a matching '.<class>' selector to the <style> block, "
|
|
39
|
+
"or drop the unused class attribute (CODE_RULES self-documenting markup)"
|
|
40
|
+
)
|
|
@@ -8,7 +8,7 @@ from pathlib import Path
|
|
|
8
8
|
|
|
9
9
|
GIT_DASH_C_COMMIT_PATTERN: str = r"git\s+-C\s+[\"']?[^\"';&|]+?[\"']?\s+commit\b"
|
|
10
10
|
GIT_COMMAND_TIMEOUT_SECONDS: int = 5
|
|
11
|
-
GATE_TIMEOUT_SECONDS: int =
|
|
11
|
+
GATE_TIMEOUT_SECONDS: int = 120
|
|
12
12
|
GATE_RELATIVE_PATH: Path = Path("_shared") / "pr-loop" / "scripts" / "code_rules_gate.py"
|
|
13
13
|
ALL_STAGED_PYTHON_FILES_COMMAND: tuple[str, ...] = (
|
|
14
14
|
"git",
|
|
@@ -69,13 +69,55 @@ def is_file_within_project(target_file: str, project_root: Path) -> bool:
|
|
|
69
69
|
return False
|
|
70
70
|
|
|
71
71
|
|
|
72
|
-
def
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
72
|
+
def discover_mypy_config(target_file: Path) -> Path | None:
|
|
73
|
+
"""Return the nearest ancestor ``pyproject.toml`` that configures mypy.
|
|
74
|
+
|
|
75
|
+
Mypy applies a project's ``[tool.mypy]`` settings only when the config file
|
|
76
|
+
is on its invocation path; handing the discovered config to mypy lets a
|
|
77
|
+
check run from the repository root still honor the project's own import
|
|
78
|
+
resolution settings (such as ``ignore_missing_imports``) for a module that
|
|
79
|
+
imports its siblings by name. Reuses the validators-package walk-up so the
|
|
80
|
+
discovery logic lives in one place.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
target_file: The Python file mypy will check.
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
The nearest ancestor ``pyproject.toml`` declaring a ``[tool.mypy]``
|
|
87
|
+
table, or None when none exists above the file or the walk-up helper
|
|
88
|
+
cannot be imported.
|
|
89
|
+
"""
|
|
90
|
+
validators_directory = os.path.join(
|
|
91
|
+
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "validators"
|
|
92
|
+
)
|
|
93
|
+
if validators_directory not in sys.path:
|
|
94
|
+
sys.path.insert(0, validators_directory)
|
|
95
|
+
try:
|
|
96
|
+
integration_module = importlib.import_module("mypy_integration")
|
|
97
|
+
except ImportError:
|
|
98
|
+
return None
|
|
99
|
+
discovered_config = integration_module.find_pyproject_with_mypy_config(target_file)
|
|
100
|
+
return discovered_config if isinstance(discovered_config, Path) else None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def build_mypy_command(relative_file_path: str, mypy_config_file: Path | None) -> list[str]:
|
|
104
|
+
"""Build the mypy command line for one file.
|
|
77
105
|
|
|
78
|
-
|
|
106
|
+
Args:
|
|
107
|
+
relative_file_path: The target file path relative to the project root.
|
|
108
|
+
mypy_config_file: The ``pyproject.toml`` to pass via ``--config-file``,
|
|
109
|
+
or None to let mypy fall back to its own config discovery.
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
The full mypy argument vector, including the interpreter prefix on
|
|
113
|
+
Windows and the config file when one was discovered.
|
|
114
|
+
"""
|
|
115
|
+
base_command = [sys.executable, "-m", "mypy"] if IS_WINDOWS else ["mypy"]
|
|
116
|
+
|
|
117
|
+
config_arguments = (
|
|
118
|
+
["--config-file", str(mypy_config_file)] if mypy_config_file is not None else []
|
|
119
|
+
)
|
|
120
|
+
return base_command + config_arguments + [
|
|
79
121
|
"--no-error-summary",
|
|
80
122
|
"--show-error-codes",
|
|
81
123
|
"--no-color",
|
|
@@ -84,8 +126,18 @@ def build_mypy_command(relative_file_path: str) -> list[str]:
|
|
|
84
126
|
|
|
85
127
|
|
|
86
128
|
def run_mypy(target_file: str, project_root: str) -> tuple[int, str]:
|
|
129
|
+
"""Run mypy on one file from the project root and return its result.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
target_file: The absolute path of the file to type-check.
|
|
133
|
+
project_root: The directory mypy runs from.
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
The mypy exit code paired with its combined stdout and stderr text.
|
|
137
|
+
"""
|
|
87
138
|
relative_file_path = os.path.relpath(target_file, project_root)
|
|
88
|
-
|
|
139
|
+
mypy_config_file = discover_mypy_config(Path(target_file))
|
|
140
|
+
mypy_command = build_mypy_command(relative_file_path, mypy_config_file)
|
|
89
141
|
|
|
90
142
|
completed_process = subprocess.run(
|
|
91
143
|
mypy_command,
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Behavior tests for the mypy_validator config-discovery fix.
|
|
2
|
+
|
|
3
|
+
The hook runs mypy from the project root, so without handing mypy the project's
|
|
4
|
+
own ``[tool.mypy]`` config a module that imports its siblings by name draws a
|
|
5
|
+
spurious ``import-not-found`` error. These tests drive the real production
|
|
6
|
+
functions: ``discover_mypy_config`` walks up to the nearest configuring
|
|
7
|
+
``pyproject.toml`` and ``run_mypy`` passes it through so the project's
|
|
8
|
+
``ignore_missing_imports`` setting applies.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import importlib.util
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from types import ModuleType
|
|
14
|
+
|
|
15
|
+
HOOK_PATH = Path(__file__).resolve().parent / "mypy_validator.py"
|
|
16
|
+
|
|
17
|
+
MODULE_WITH_SIBLING_IMPORT = (
|
|
18
|
+
"from sibling_only_resolvable_at_runtime import value\n\nx: int = value\n"
|
|
19
|
+
)
|
|
20
|
+
TOOL_MYPY_PYPROJECT = "[tool.mypy]\nignore_missing_imports = true\n"
|
|
21
|
+
NON_MYPY_PYPROJECT = "[tool.ruff]\nline-length = 100\n"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _load_validator() -> ModuleType:
|
|
25
|
+
spec = importlib.util.spec_from_file_location("mypy_validator_under_test", HOOK_PATH)
|
|
26
|
+
assert spec is not None and spec.loader is not None
|
|
27
|
+
module = importlib.util.module_from_spec(spec)
|
|
28
|
+
spec.loader.exec_module(module)
|
|
29
|
+
return module
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_discover_mypy_config_finds_nearest_tool_mypy_pyproject(tmp_path: Path) -> None:
|
|
33
|
+
validator = _load_validator()
|
|
34
|
+
(tmp_path / "pyproject.toml").write_text(TOOL_MYPY_PYPROJECT, encoding="utf-8")
|
|
35
|
+
nested_module = tmp_path / "package" / "module.py"
|
|
36
|
+
nested_module.parent.mkdir(parents=True)
|
|
37
|
+
nested_module.write_text("value: int = 1\n", encoding="utf-8")
|
|
38
|
+
|
|
39
|
+
discovered = validator.discover_mypy_config(nested_module)
|
|
40
|
+
|
|
41
|
+
assert discovered is not None
|
|
42
|
+
assert discovered.resolve() == (tmp_path / "pyproject.toml").resolve()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_discover_mypy_config_returns_none_without_tool_mypy(tmp_path: Path) -> None:
|
|
46
|
+
validator = _load_validator()
|
|
47
|
+
(tmp_path / "pyproject.toml").write_text(NON_MYPY_PYPROJECT, encoding="utf-8")
|
|
48
|
+
standalone_module = tmp_path / "module.py"
|
|
49
|
+
standalone_module.write_text("value: int = 1\n", encoding="utf-8")
|
|
50
|
+
|
|
51
|
+
assert validator.discover_mypy_config(standalone_module) is None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_build_mypy_command_includes_config_file_when_present(tmp_path: Path) -> None:
|
|
55
|
+
validator = _load_validator()
|
|
56
|
+
config_file = tmp_path / "pyproject.toml"
|
|
57
|
+
|
|
58
|
+
command = validator.build_mypy_command("package/module.py", config_file)
|
|
59
|
+
|
|
60
|
+
assert "--config-file" in command
|
|
61
|
+
assert command[command.index("--config-file") + 1] == str(config_file)
|
|
62
|
+
assert command[-1] == "package/module.py"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_build_mypy_command_omits_config_file_when_absent(tmp_path: Path) -> None:
|
|
66
|
+
validator = _load_validator()
|
|
67
|
+
|
|
68
|
+
command = validator.build_mypy_command("package/module.py", None)
|
|
69
|
+
|
|
70
|
+
assert "--config-file" not in command
|
|
71
|
+
assert command[-1] == "package/module.py"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_run_mypy_suppresses_sibling_import_error_with_tool_mypy_config(tmp_path: Path) -> None:
|
|
75
|
+
validator = _load_validator()
|
|
76
|
+
(tmp_path / "pyproject.toml").write_text(TOOL_MYPY_PYPROJECT, encoding="utf-8")
|
|
77
|
+
importer_module = tmp_path / "importer.py"
|
|
78
|
+
importer_module.write_text(MODULE_WITH_SIBLING_IMPORT, encoding="utf-8")
|
|
79
|
+
|
|
80
|
+
exit_code, output = validator.run_mypy(str(importer_module), str(tmp_path))
|
|
81
|
+
|
|
82
|
+
assert exit_code == 0, output
|
|
83
|
+
assert "import-not-found" not in output
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_run_mypy_reports_import_error_without_tool_mypy_config(tmp_path: Path) -> None:
|
|
87
|
+
validator = _load_validator()
|
|
88
|
+
importer_module = tmp_path / "importer.py"
|
|
89
|
+
importer_module.write_text(MODULE_WITH_SIBLING_IMPORT, encoding="utf-8")
|
|
90
|
+
|
|
91
|
+
exit_code, output = validator.run_mypy(str(importer_module), str(tmp_path))
|
|
92
|
+
|
|
93
|
+
assert exit_code != 0
|
|
94
|
+
assert "import-not-found" in output
|