claude-dev-env 2.0.2 → 2.2.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 +1 -1
- package/hooks/blocking/CLAUDE.md +1 -0
- package/hooks/blocking/code_rules_shared.py +126 -47
- package/hooks/blocking/config/CLAUDE.md +2 -0
- package/hooks/blocking/config/verified_commit_context_constants.py +21 -0
- package/hooks/blocking/config/verified_commit_gate_output_constants.py +14 -0
- package/hooks/blocking/conftest.py +36 -30
- package/hooks/blocking/convergence_gate_blocker.py +76 -8
- package/hooks/blocking/plain_language_blocker.py +8 -0
- package/hooks/blocking/state_description_blocker.py +4 -1
- package/hooks/blocking/test_code_rules_shared.py +120 -20
- package/hooks/blocking/test_convergence_gate_blocker.py +146 -0
- package/hooks/blocking/test_plain_language_blocker.py +15 -0
- package/hooks/blocking/test_state_description_blocker.py +15 -0
- package/hooks/blocking/test_verified_commit_config_bootstrap.py +18 -0
- package/hooks/blocking/test_verified_commit_gate_additional_context.py +134 -0
- package/hooks/blocking/tests/test_verified_commit_gate.py +41 -0
- package/hooks/blocking/verified_commit_config_bootstrap.py +22 -10
- package/hooks/blocking/verified_commit_gate.py +113 -568
- package/hooks/blocking/verified_commit_gate_parts/CLAUDE.md +28 -0
- package/hooks/blocking/verified_commit_gate_parts/__init__.py +1 -0
- package/hooks/blocking/verified_commit_gate_parts/command_tokenization.py +174 -0
- package/hooks/blocking/verified_commit_gate_parts/deny_payload.py +53 -0
- package/hooks/blocking/verified_commit_gate_parts/deny_reason.py +80 -0
- package/hooks/blocking/verified_commit_gate_parts/directory_resolution.py +170 -0
- package/hooks/blocking/verified_commit_gate_parts/gated_invocations.py +205 -0
- package/hooks/blocking/verified_commit_gate_parts/tests/conftest.py +10 -0
- package/hooks/blocking/verified_commit_gate_parts/tests/test_command_tokenization.py +94 -0
- package/hooks/blocking/verified_commit_gate_parts/tests/test_deny_payload.py +17 -0
- package/hooks/blocking/verified_commit_gate_parts/tests/test_deny_reason.py +38 -0
- package/hooks/blocking/verified_commit_gate_parts/tests/test_directory_resolution.py +71 -0
- package/hooks/blocking/verified_commit_gate_parts/tests/test_gated_invocations.py +61 -0
- package/hooks/hooks_constants/CLAUDE.md +4 -1
- package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -0
- package/hooks/hooks_constants/convergence_gate_blocker_constants.py +36 -0
- package/hooks/hooks_constants/harness_scratchpad_constants.py +10 -9
- package/hooks/hooks_constants/mypy_integration_constants.py +16 -0
- package/hooks/validators/mypy_integration.py +145 -24
- package/hooks/validators/python_antipattern_checks.py +16 -0
- package/hooks/validators/run_all_validators.py +9 -3
- package/hooks/validators/test_mypy_integration.py +154 -0
- package/hooks/validators/test_python_antipattern_checks.py +112 -1
- package/hooks/validators/test_run_all_validators_pretooluse.py +16 -0
- package/package.json +1 -1
- package/rules/CLAUDE.md +1 -0
- package/rules/verified-commit-gate-skip.md +28 -0
- package/skills/_shared/pr-loop/CLAUDE.md +18 -13
- package/skills/_shared/pr-loop/portable-driver.md +150 -0
- package/skills/_shared/pr-loop/scripts/CLAUDE.md +3 -0
- package/skills/_shared/pr-loop/scripts/build_converge_task_list.py +310 -0
- package/skills/_shared/pr-loop/scripts/portable_converge_driver.py +1637 -0
- package/skills/_shared/pr-loop/scripts/select_converge_pacer.py +215 -0
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/CLAUDE.md +3 -0
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/converge_task_list_constants.py +56 -0
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/pacer_constants.py +47 -0
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/portable_driver_constants.py +181 -0
- package/skills/_shared/pr-loop/scripts/test_build_converge_task_list.py +140 -0
- package/skills/_shared/pr-loop/scripts/test_portable_converge_driver.py +1119 -0
- package/skills/_shared/pr-loop/scripts/test_select_converge_pacer.py +207 -0
- package/skills/auditing-claude-config/CLAUDE.md +2 -1
- package/skills/auditing-claude-config/SKILL.md +114 -176
- package/skills/auditing-claude-config/reference/probe-hook.md +74 -0
- package/skills/autoconverge/CLAUDE.md +6 -3
- package/skills/autoconverge/SKILL.md +421 -346
- package/skills/autoconverge/reference/CLAUDE.md +1 -0
- package/skills/autoconverge/reference/convergence.md +5 -5
- package/skills/autoconverge/reference/copilot-findings.md +51 -0
- package/skills/autoconverge/reference/multi-pr.md +33 -2
- package/skills/autoconverge/reference/stop-conditions.md +9 -6
- package/skills/autoconverge/test_portable_pacer_gate.py +54 -0
- package/skills/closeout/SKILL.md +7 -9
- package/skills/copilot-finding-triage/SKILL.md +21 -11
- package/skills/copilot-review/CLAUDE.md +3 -2
- package/skills/copilot-review/SKILL.md +119 -155
- package/skills/copilot-review/templates/subagent-prompt.md +49 -0
- package/skills/fresh-branch/CLAUDE.md +6 -9
- package/skills/fresh-branch/SKILL.md +84 -33
- package/skills/fresh-branch/scripts/create_fresh_branch.py +445 -0
- package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/__init__.py +1 -0
- package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +74 -0
- package/skills/fresh-branch/scripts/test_create_fresh_branch.py +670 -0
- package/skills/pr-converge/CLAUDE.md +4 -3
- package/skills/pr-converge/SKILL.md +469 -422
- package/skills/pr-converge/reference/CLAUDE.md +1 -0
- package/skills/pr-converge/reference/multi-pr-orchestration.md +5 -1
- package/skills/pr-converge/reference/per-tick.md +51 -24
- package/skills/pr-converge/reference/progress-checklist.md +168 -0
- package/skills/pr-converge/test_portable_pacer_gate.py +67 -0
- package/skills/pr-converge/test_step5_host_branch.py +8 -6
- package/skills/pr-converge/workflows/schedule-wakeup-loop.md +5 -3
- package/skills/pr-loop-cloud-transport/SKILL.md +1 -1
- package/skills/privacy-hygiene/SKILL.md +68 -115
- package/skills/privacy-hygiene/reference/sweep-procedure.md +62 -0
- package/skills/session-log/CLAUDE.md +2 -1
- package/skills/session-log/SKILL.md +4 -26
- package/skills/session-log/templates/frontmatter.md +40 -0
- package/skills/skill-builder/CLAUDE.md +8 -7
- package/skills/skill-builder/SKILL.md +26 -11
- package/skills/skill-builder/references/CLAUDE.md +3 -1
- package/skills/skill-builder/references/delegation-map.md +21 -12
- package/skills/skill-builder/references/description-field.md +9 -11
- package/skills/skill-builder/references/deterministic-elements.md +218 -0
- package/skills/skill-builder/references/self-audit-checklist.md +62 -45
- package/skills/skill-builder/references/skill-modularity.md +8 -9
- package/skills/skill-builder/templates/CLAUDE.md +2 -2
- package/skills/skill-builder/templates/gap-analysis.md +15 -0
- package/skills/skill-builder/workflows/CLAUDE.md +5 -5
- package/skills/skill-builder/workflows/improve-skill.md +18 -9
- package/skills/skill-builder/workflows/new-skill.md +23 -15
- package/skills/skill-builder/workflows/polish-skill.md +28 -21
|
@@ -1,10 +1,31 @@
|
|
|
1
1
|
"""Mypy integration for static type checking."""
|
|
2
2
|
|
|
3
|
+
import contextlib
|
|
3
4
|
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
import tempfile
|
|
4
7
|
import tomllib
|
|
8
|
+
from collections.abc import Iterator
|
|
5
9
|
from dataclasses import dataclass
|
|
6
10
|
from pathlib import Path
|
|
7
11
|
|
|
12
|
+
_hooks_directory = str(Path(__file__).resolve().parent.parent)
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
from hooks_constants.mypy_integration_constants import (
|
|
16
|
+
GIT_DIRECTORY_NAME,
|
|
17
|
+
PYPROJECT_FILENAME,
|
|
18
|
+
PYTHON_SOURCE_SUFFIX,
|
|
19
|
+
)
|
|
20
|
+
except ModuleNotFoundError:
|
|
21
|
+
if _hooks_directory not in sys.path:
|
|
22
|
+
sys.path.insert(0, _hooks_directory)
|
|
23
|
+
from hooks_constants.mypy_integration_constants import (
|
|
24
|
+
GIT_DIRECTORY_NAME,
|
|
25
|
+
PYPROJECT_FILENAME,
|
|
26
|
+
PYTHON_SOURCE_SUFFIX,
|
|
27
|
+
)
|
|
28
|
+
|
|
8
29
|
|
|
9
30
|
@dataclass
|
|
10
31
|
class MypyResult:
|
|
@@ -37,6 +58,28 @@ def _pyproject_contains_tool_mypy(pyproject_path: Path) -> bool:
|
|
|
37
58
|
return isinstance(tool_table, dict) and "mypy" in tool_table
|
|
38
59
|
|
|
39
60
|
|
|
61
|
+
def _ancestor_directories(starting_file: Path) -> list[Path]:
|
|
62
|
+
"""Return *starting_file*'s directory and every parent, nearest first.
|
|
63
|
+
|
|
64
|
+
::
|
|
65
|
+
|
|
66
|
+
repo/pkg/mod.py -> [repo/pkg, repo, ... , filesystem root]
|
|
67
|
+
repo/pkg/ -> [repo/pkg, repo, ... , filesystem root]
|
|
68
|
+
|
|
69
|
+
A file resolves to its containing directory; a directory resolves to
|
|
70
|
+
itself, so both ancestor walks below start from the same first candidate.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
starting_file: The file (or directory) the walk begins from.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
The resolved starting directory followed by each of its parents.
|
|
77
|
+
"""
|
|
78
|
+
resolved_starting_file = starting_file.resolve()
|
|
79
|
+
walk_origin = resolved_starting_file.parent if resolved_starting_file.is_file() else resolved_starting_file
|
|
80
|
+
return [walk_origin, *walk_origin.parents]
|
|
81
|
+
|
|
82
|
+
|
|
40
83
|
def find_pyproject_with_mypy_config(starting_file: Path) -> Path | None:
|
|
41
84
|
"""Walk up from a starting file to locate a pyproject.toml that configures mypy.
|
|
42
85
|
|
|
@@ -54,16 +97,103 @@ def find_pyproject_with_mypy_config(starting_file: Path) -> Path | None:
|
|
|
54
97
|
table, or ``None`` when no such file exists between ``starting_file``
|
|
55
98
|
and the filesystem root.
|
|
56
99
|
"""
|
|
57
|
-
pyproject_filename_for_lookup =
|
|
58
|
-
|
|
59
|
-
current_directory = resolved_starting_file.parent if resolved_starting_file.is_file() else resolved_starting_file
|
|
60
|
-
for each_candidate_directory in [current_directory, *current_directory.parents]:
|
|
100
|
+
pyproject_filename_for_lookup = PYPROJECT_FILENAME
|
|
101
|
+
for each_candidate_directory in _ancestor_directories(starting_file):
|
|
61
102
|
candidate_pyproject = each_candidate_directory / pyproject_filename_for_lookup
|
|
62
103
|
if candidate_pyproject.is_file() and _pyproject_contains_tool_mypy(candidate_pyproject):
|
|
63
104
|
return candidate_pyproject
|
|
64
105
|
return None
|
|
65
106
|
|
|
66
107
|
|
|
108
|
+
def find_module_resolution_root(starting_file: Path) -> Path | None:
|
|
109
|
+
"""Return the nearest ancestor directory that roots a project, else None.
|
|
110
|
+
|
|
111
|
+
A project root is the first ancestor holding a ``.git`` entry or a
|
|
112
|
+
``pyproject.toml``. Mypy resolves a first-party import against its working
|
|
113
|
+
directory, so anchoring there binds ``config.*`` to the target file's own
|
|
114
|
+
project and keeps a foreign ``config`` in the caller's directory out of scope.
|
|
115
|
+
|
|
116
|
+
::
|
|
117
|
+
|
|
118
|
+
target_repo/.git + target_repo/tools/x.py -> target_repo
|
|
119
|
+
/tmp/detached/x.py (no marker up-tree) -> None
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
starting_file: The file (or directory) the walk begins from.
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
The nearest ancestor Path that holds ``.git`` or ``pyproject.toml``,
|
|
126
|
+
or ``None`` when no such ancestor exists.
|
|
127
|
+
"""
|
|
128
|
+
git_entry_name = GIT_DIRECTORY_NAME
|
|
129
|
+
pyproject_filename = PYPROJECT_FILENAME
|
|
130
|
+
for each_candidate_directory in _ancestor_directories(starting_file):
|
|
131
|
+
has_git_entry = (each_candidate_directory / git_entry_name).exists()
|
|
132
|
+
has_pyproject = (each_candidate_directory / pyproject_filename).is_file()
|
|
133
|
+
if has_git_entry or has_pyproject:
|
|
134
|
+
return each_candidate_directory
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _first_module_resolution_root(all_py_files: list[str]) -> Path | None:
|
|
139
|
+
"""Return the project root of the first rooted target file, or None."""
|
|
140
|
+
for each_py_file in all_py_files:
|
|
141
|
+
resolution_root = find_module_resolution_root(Path(each_py_file))
|
|
142
|
+
if resolution_root is not None:
|
|
143
|
+
return resolution_root
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@contextlib.contextmanager
|
|
148
|
+
def mypy_working_directory(all_py_files: list[str]) -> Iterator[str]:
|
|
149
|
+
"""Yield the working directory mypy resolves first-party imports from.
|
|
150
|
+
|
|
151
|
+
::
|
|
152
|
+
|
|
153
|
+
target_repo/tools/serialize_tool.py -> yields target_repo
|
|
154
|
+
/tmp/detached/serialize_tool.py -> yields a fresh empty temp dir
|
|
155
|
+
|
|
156
|
+
A rooted file yields its project root so ``config.constants`` binds to the
|
|
157
|
+
target repo's own package; a detached file yields an isolated directory so
|
|
158
|
+
no foreign top-level package leaks in.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
all_py_files: Absolute or relative paths of the Python files under check.
|
|
162
|
+
|
|
163
|
+
Yields:
|
|
164
|
+
A directory path string mypy should use as its working directory.
|
|
165
|
+
"""
|
|
166
|
+
resolution_root = _first_module_resolution_root(all_py_files)
|
|
167
|
+
if resolution_root is not None:
|
|
168
|
+
yield str(resolution_root)
|
|
169
|
+
return
|
|
170
|
+
with tempfile.TemporaryDirectory() as isolated_directory:
|
|
171
|
+
yield isolated_directory
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _mypy_config_argument(all_py_files: list[str]) -> list[str]:
|
|
175
|
+
"""Return the ``--config-file`` argument for the first file with a mypy config."""
|
|
176
|
+
for each_py_file in all_py_files:
|
|
177
|
+
discovered_pyproject = find_pyproject_with_mypy_config(Path(each_py_file))
|
|
178
|
+
if discovered_pyproject is not None:
|
|
179
|
+
return ["--config-file", str(discovered_pyproject)]
|
|
180
|
+
return []
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _run_mypy_subprocess(all_py_files: list[str]) -> subprocess.CompletedProcess[str]:
|
|
184
|
+
"""Run mypy over *all_py_files* from each file's own project root."""
|
|
185
|
+
config_argument = _mypy_config_argument(all_py_files)
|
|
186
|
+
with mypy_working_directory(all_py_files) as working_directory:
|
|
187
|
+
return subprocess.run(
|
|
188
|
+
["mypy", *config_argument, "--ignore-missing-imports", "--no-error-summary"]
|
|
189
|
+
+ all_py_files,
|
|
190
|
+
check=False,
|
|
191
|
+
capture_output=True,
|
|
192
|
+
text=True,
|
|
193
|
+
cwd=working_directory,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
67
197
|
def run_mypy_check(files: list[Path]) -> MypyResult:
|
|
68
198
|
"""Run mypy on files."""
|
|
69
199
|
if not files:
|
|
@@ -72,29 +202,20 @@ def run_mypy_check(files: list[Path]) -> MypyResult:
|
|
|
72
202
|
if not check_mypy_available():
|
|
73
203
|
return MypyResult(passed=True, output="Mypy not installed - skipping", error_count=0)
|
|
74
204
|
|
|
75
|
-
|
|
76
|
-
|
|
205
|
+
python_source_suffix = PYTHON_SOURCE_SUFFIX
|
|
206
|
+
all_py_files = [
|
|
207
|
+
str(each_file.resolve())
|
|
208
|
+
for each_file in files
|
|
209
|
+
if each_file.suffix == python_source_suffix
|
|
210
|
+
]
|
|
211
|
+
if not all_py_files:
|
|
77
212
|
return MypyResult(passed=True, output="No Python files", error_count=0)
|
|
78
213
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
discovered_pyproject = find_pyproject_with_mypy_config(Path(each_py_file))
|
|
82
|
-
if discovered_pyproject is not None:
|
|
83
|
-
config_argument = ["--config-file", str(discovered_pyproject)]
|
|
84
|
-
break
|
|
85
|
-
|
|
86
|
-
result = subprocess.run(
|
|
87
|
-
["mypy", *config_argument, "--ignore-missing-imports", "--no-error-summary"]
|
|
88
|
-
+ py_files,
|
|
89
|
-
check=False,
|
|
90
|
-
capture_output=True,
|
|
91
|
-
text=True,
|
|
92
|
-
)
|
|
93
|
-
|
|
94
|
-
error_count = result.stdout.count(": error:")
|
|
214
|
+
completed_process = _run_mypy_subprocess(all_py_files)
|
|
215
|
+
error_count = completed_process.stdout.count(": error:")
|
|
95
216
|
|
|
96
217
|
return MypyResult(
|
|
97
|
-
passed=
|
|
98
|
-
output=
|
|
218
|
+
passed=completed_process.returncode == 0,
|
|
219
|
+
output=completed_process.stdout or completed_process.stderr or "No type errors",
|
|
99
220
|
error_count=error_count,
|
|
100
221
|
)
|
|
@@ -13,6 +13,15 @@ from typing import List
|
|
|
13
13
|
|
|
14
14
|
from .validator_base import Violation
|
|
15
15
|
|
|
16
|
+
_hooks_directory = str(Path(__file__).resolve().parent.parent)
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
from hooks_constants.code_rules_enforcer_constants import ALL_CLI_FILE_PATH_MARKERS
|
|
20
|
+
except ModuleNotFoundError:
|
|
21
|
+
if _hooks_directory not in sys.path:
|
|
22
|
+
sys.path.insert(0, _hooks_directory)
|
|
23
|
+
from hooks_constants.code_rules_enforcer_constants import ALL_CLI_FILE_PATH_MARKERS
|
|
24
|
+
|
|
16
25
|
|
|
17
26
|
def check_mutable_default_args(tree: ast.AST, filename: str) -> List[Violation]:
|
|
18
27
|
violations: List[Violation] = []
|
|
@@ -55,6 +64,13 @@ def check_print_in_production(tree: ast.AST, filename: str) -> List[Violation]:
|
|
|
55
64
|
if "test" in filename.lower():
|
|
56
65
|
return violations
|
|
57
66
|
|
|
67
|
+
normalized_path = filename.lower().replace("\\", "/")
|
|
68
|
+
is_cli_entry_point = any(
|
|
69
|
+
marker.replace("\\", "/") in normalized_path for marker in ALL_CLI_FILE_PATH_MARKERS
|
|
70
|
+
)
|
|
71
|
+
if is_cli_entry_point:
|
|
72
|
+
return violations
|
|
73
|
+
|
|
58
74
|
for node in ast.walk(tree):
|
|
59
75
|
if isinstance(node, ast.Call):
|
|
60
76
|
if isinstance(node.func, ast.Name) and node.func.id == "print":
|
|
@@ -32,6 +32,7 @@ _hooks_directory_on_path = str(hooks_dir.resolve())
|
|
|
32
32
|
if _hooks_directory_on_path not in sys.path:
|
|
33
33
|
sys.path.insert(0, _hooks_directory_on_path)
|
|
34
34
|
|
|
35
|
+
from blocking.code_rules_shared import is_ephemeral_path # noqa: E402
|
|
35
36
|
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
36
37
|
from hooks_constants.multi_edit_reconstruction import ( # noqa: E402
|
|
37
38
|
apply_edits,
|
|
@@ -821,9 +822,12 @@ def _evaluate_pre_tool_use_payload() -> None:
|
|
|
821
822
|
"""Read the PreToolUse payload from stdin and deny a violating file write.
|
|
822
823
|
|
|
823
824
|
Reconstructs the proposed post-edit content of the one target file and runs
|
|
824
|
-
the file-scoped validators against it.
|
|
825
|
-
|
|
826
|
-
|
|
825
|
+
the file-scoped validators against it. The path-based exemption decision runs
|
|
826
|
+
against the real target path from the payload, so an ephemeral scratch or
|
|
827
|
+
session scratchpad target passes without validation even though the content
|
|
828
|
+
is validated through a temporary copy. Emits a deny decision naming each
|
|
829
|
+
failing validator when any fires; writes nothing for a clean file, an exempt
|
|
830
|
+
target, an unparseable payload, or a payload no validator covers.
|
|
827
831
|
"""
|
|
828
832
|
pre_tool_use_payload = json.load(sys.stdin)
|
|
829
833
|
if not isinstance(pre_tool_use_payload, dict):
|
|
@@ -835,6 +839,8 @@ def _evaluate_pre_tool_use_payload() -> None:
|
|
|
835
839
|
file_path = tool_input.get("file_path", "")
|
|
836
840
|
if not isinstance(file_path, str) or not file_path:
|
|
837
841
|
return
|
|
842
|
+
if is_ephemeral_path(file_path, pre_tool_use_payload):
|
|
843
|
+
return
|
|
838
844
|
proposed_content = reconstruct_proposed_content(tool_name, tool_input)
|
|
839
845
|
if not proposed_content:
|
|
840
846
|
return
|
|
@@ -3,14 +3,168 @@
|
|
|
3
3
|
from pathlib import Path
|
|
4
4
|
from unittest.mock import patch
|
|
5
5
|
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
6
8
|
from .mypy_integration import (
|
|
7
9
|
MypyResult,
|
|
8
10
|
check_mypy_available,
|
|
11
|
+
find_module_resolution_root,
|
|
9
12
|
find_pyproject_with_mypy_config,
|
|
10
13
|
run_mypy_check,
|
|
11
14
|
)
|
|
12
15
|
|
|
13
16
|
|
|
17
|
+
def _write_config_package(package_parent: Path, constants_body: str) -> None:
|
|
18
|
+
config_package = package_parent / "config"
|
|
19
|
+
config_package.mkdir(parents=True)
|
|
20
|
+
(config_package / "__init__.py").write_text("", encoding="utf-8")
|
|
21
|
+
(config_package / "constants.py").write_text(constants_body, encoding="utf-8")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _write_config_importer(script_path: Path) -> None:
|
|
25
|
+
script_path.parent.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
script_path.write_text(
|
|
27
|
+
"from config.constants import SERIALIZED_INDENT_WIDTH\n\n"
|
|
28
|
+
"indent_width: int = SERIALIZED_INDENT_WIDTH\n",
|
|
29
|
+
encoding="utf-8",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_find_module_resolution_root_returns_git_marked_ancestor(tmp_path: Path) -> None:
|
|
34
|
+
target_repo = tmp_path / "git_repo"
|
|
35
|
+
nested_directory = target_repo / "src" / "deep"
|
|
36
|
+
nested_directory.mkdir(parents=True)
|
|
37
|
+
(target_repo / ".git").mkdir()
|
|
38
|
+
nested_file = nested_directory / "module.py"
|
|
39
|
+
nested_file.write_text("sample_number: int = 1\n", encoding="utf-8")
|
|
40
|
+
assert find_module_resolution_root(nested_file) == target_repo
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_find_module_resolution_root_returns_nearest_pyproject_ancestor(
|
|
44
|
+
tmp_path: Path,
|
|
45
|
+
) -> None:
|
|
46
|
+
outer_repo = tmp_path / "outer"
|
|
47
|
+
inner_project = outer_repo / "inner"
|
|
48
|
+
inner_project.mkdir(parents=True)
|
|
49
|
+
(outer_repo / ".git").mkdir()
|
|
50
|
+
(inner_project / "pyproject.toml").write_text(
|
|
51
|
+
"[project]\nname = 'inner'\n", encoding="utf-8"
|
|
52
|
+
)
|
|
53
|
+
nested_file = inner_project / "module.py"
|
|
54
|
+
nested_file.write_text("sample_number: int = 1\n", encoding="utf-8")
|
|
55
|
+
assert find_module_resolution_root(nested_file) == inner_project
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_run_mypy_check_resolves_config_against_target_repo_not_session_cwd(
|
|
59
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
60
|
+
) -> None:
|
|
61
|
+
"""A target file's ``config`` import binds to its own repo, not the session cwd.
|
|
62
|
+
|
|
63
|
+
::
|
|
64
|
+
|
|
65
|
+
cwd=session_worktree, whose config/ lacks SERIALIZED_INDENT_WIDTH
|
|
66
|
+
target_repo/tools/serialize_tool.py imports it from target_repo/config/
|
|
67
|
+
flag (defect): binds config to session_worktree -> attr-defined denial
|
|
68
|
+
ok (fixed): binds config to target_repo -> passes
|
|
69
|
+
"""
|
|
70
|
+
session_worktree = tmp_path / "session_worktree"
|
|
71
|
+
_write_config_package(session_worktree, "UNRELATED_SESSION_SETTING: int = 9\n")
|
|
72
|
+
|
|
73
|
+
target_repo = tmp_path / "target_repo"
|
|
74
|
+
_write_config_package(target_repo, "SERIALIZED_INDENT_WIDTH: int = 4\n")
|
|
75
|
+
(target_repo / "pyproject.toml").write_text(
|
|
76
|
+
"[project]\nname = 'synthetic-target'\n", encoding="utf-8"
|
|
77
|
+
)
|
|
78
|
+
target_script = target_repo / "tools" / "serialize_tool.py"
|
|
79
|
+
_write_config_importer(target_script)
|
|
80
|
+
|
|
81
|
+
monkeypatch.chdir(session_worktree)
|
|
82
|
+
mypy_result = run_mypy_check([target_script])
|
|
83
|
+
|
|
84
|
+
assert mypy_result.passed, mypy_result.output
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_run_mypy_check_ignores_session_config_for_detached_gate_file(
|
|
88
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
89
|
+
) -> None:
|
|
90
|
+
"""A detached gate temp file does not bind ``config`` to the session cwd.
|
|
91
|
+
|
|
92
|
+
::
|
|
93
|
+
|
|
94
|
+
cwd=session_worktree, whose config/ lacks SERIALIZED_INDENT_WIDTH
|
|
95
|
+
detached_gate_dir/serialize_tool.py (no repo ancestor) imports it
|
|
96
|
+
flag (defect): binds config to session_worktree -> attr-defined denial
|
|
97
|
+
ok (fixed): isolated cwd -> import ignored -> passes
|
|
98
|
+
"""
|
|
99
|
+
session_worktree = tmp_path / "session_worktree"
|
|
100
|
+
_write_config_package(session_worktree, "UNRELATED_SESSION_SETTING: int = 9\n")
|
|
101
|
+
|
|
102
|
+
detached_file = tmp_path / "detached_gate_dir" / "serialize_tool.py"
|
|
103
|
+
_write_config_importer(detached_file)
|
|
104
|
+
|
|
105
|
+
monkeypatch.chdir(session_worktree)
|
|
106
|
+
mypy_result = run_mypy_check([detached_file])
|
|
107
|
+
|
|
108
|
+
assert mypy_result.passed, mypy_result.output
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_run_mypy_check_still_flags_genuine_type_error(
|
|
112
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
113
|
+
) -> None:
|
|
114
|
+
"""A real type error inside the target file is still reported.
|
|
115
|
+
|
|
116
|
+
::
|
|
117
|
+
|
|
118
|
+
typed_repo/pkg/widths.py -> wrong_width: int = 'not an integer'
|
|
119
|
+
flag: assigning str to an int annotation -> mypy error, passed is False
|
|
120
|
+
"""
|
|
121
|
+
typed_repo = tmp_path / "typed_repo"
|
|
122
|
+
package_directory = typed_repo / "pkg"
|
|
123
|
+
package_directory.mkdir(parents=True)
|
|
124
|
+
(typed_repo / "pyproject.toml").write_text(
|
|
125
|
+
"[project]\nname = 'typed-target'\n", encoding="utf-8"
|
|
126
|
+
)
|
|
127
|
+
faulty_script = package_directory / "widths.py"
|
|
128
|
+
faulty_script.write_text("wrong_width: int = 'not an integer'\n", encoding="utf-8")
|
|
129
|
+
|
|
130
|
+
monkeypatch.chdir(tmp_path)
|
|
131
|
+
mypy_result = run_mypy_check([faulty_script])
|
|
132
|
+
|
|
133
|
+
assert not mypy_result.passed
|
|
134
|
+
assert mypy_result.error_count >= 1
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def test_run_mypy_check_accepts_relative_path_under_nested_root(
|
|
138
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
139
|
+
) -> None:
|
|
140
|
+
"""A repo-relative target under a nested project root is still readable.
|
|
141
|
+
|
|
142
|
+
::
|
|
143
|
+
|
|
144
|
+
cwd=repo_root; changed file listed as repo-relative pkg/tool.py
|
|
145
|
+
pkg/ holds its own pyproject.toml -> resolution root is repo_root/pkg
|
|
146
|
+
flag (defect): mypy runs from repo_root/pkg with the repo-relative arg
|
|
147
|
+
-> "can't read file", spurious failure
|
|
148
|
+
ok (fixed): arg resolved to an absolute path -> mypy reads it
|
|
149
|
+
"""
|
|
150
|
+
repo_root = tmp_path / "repo_root"
|
|
151
|
+
nested_package = repo_root / "pkg"
|
|
152
|
+
nested_package.mkdir(parents=True)
|
|
153
|
+
(repo_root / ".git").mkdir()
|
|
154
|
+
(nested_package / "pyproject.toml").write_text(
|
|
155
|
+
"[project]\nname = 'nested'\n", encoding="utf-8"
|
|
156
|
+
)
|
|
157
|
+
(nested_package / "module.py").write_text(
|
|
158
|
+
"sample_number: int = 1\n", encoding="utf-8"
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
monkeypatch.chdir(repo_root)
|
|
162
|
+
relative_target = Path("pkg") / "module.py"
|
|
163
|
+
mypy_result = run_mypy_check([relative_target])
|
|
164
|
+
|
|
165
|
+
assert mypy_result.passed, mypy_result.output
|
|
166
|
+
|
|
167
|
+
|
|
14
168
|
def test_mypy_result_dataclass() -> None:
|
|
15
169
|
"""Test MypyResult dataclass creation."""
|
|
16
170
|
result = MypyResult(passed=True, output="test", error_count=0)
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
"""Tests for Python anti-pattern detection."""
|
|
2
2
|
|
|
3
3
|
import ast
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
4
6
|
|
|
5
7
|
import pytest
|
|
6
8
|
|
|
@@ -8,8 +10,17 @@ from .python_antipattern_checks import (
|
|
|
8
10
|
check_mutable_default_args,
|
|
9
11
|
check_bare_except,
|
|
10
12
|
check_print_in_production,
|
|
13
|
+
validate_file,
|
|
11
14
|
)
|
|
12
|
-
from .
|
|
15
|
+
from .run_all_validators import ValidatorResult, validate_proposed_file
|
|
16
|
+
|
|
17
|
+
_HOOKS_DIRECTORY = str(Path(__file__).resolve().parent.parent)
|
|
18
|
+
_BLOCKING_DIRECTORY = str(Path(__file__).resolve().parent.parent / "blocking")
|
|
19
|
+
for each_candidate_directory in (_HOOKS_DIRECTORY, _BLOCKING_DIRECTORY):
|
|
20
|
+
if each_candidate_directory not in sys.path:
|
|
21
|
+
sys.path.insert(0, each_candidate_directory)
|
|
22
|
+
|
|
23
|
+
from code_rules_imports_logging import check_library_print # noqa: E402
|
|
13
24
|
|
|
14
25
|
|
|
15
26
|
GOOD_NONE_DEFAULT = '''
|
|
@@ -111,3 +122,103 @@ class TestPrintInProduction:
|
|
|
111
122
|
tree = ast.parse(TEST_FILE_WITH_PRINT)
|
|
112
123
|
violations = check_print_in_production(tree, "test_utils.py")
|
|
113
124
|
assert violations == []
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
CLI_ENTRY_POINT_WITH_PRINTS = '''
|
|
128
|
+
def main():
|
|
129
|
+
print("line one")
|
|
130
|
+
print("line two")
|
|
131
|
+
print("line three")
|
|
132
|
+
print("line four")
|
|
133
|
+
print("line five")
|
|
134
|
+
'''
|
|
135
|
+
|
|
136
|
+
NEAR_MISS_WITH_PRINT = '''
|
|
137
|
+
def render():
|
|
138
|
+
print("output")
|
|
139
|
+
'''
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class TestPrintInCliEntryPoint:
|
|
143
|
+
def test_cli_py_path_allows_print(self) -> None:
|
|
144
|
+
tree = ast.parse(CLI_ENTRY_POINT_WITH_PRINTS)
|
|
145
|
+
assert check_print_in_production(tree, "stp_preview/cli.py") == []
|
|
146
|
+
|
|
147
|
+
def test_cli_suffix_path_allows_print(self) -> None:
|
|
148
|
+
tree = ast.parse(CLI_ENTRY_POINT_WITH_PRINTS)
|
|
149
|
+
assert check_print_in_production(tree, "stp_preview/report_cli.py") == []
|
|
150
|
+
|
|
151
|
+
def test_scripts_path_allows_print(self) -> None:
|
|
152
|
+
tree = ast.parse(CLI_ENTRY_POINT_WITH_PRINTS)
|
|
153
|
+
assert check_print_in_production(tree, "packages/foo/scripts/run_job.py") == []
|
|
154
|
+
|
|
155
|
+
def test_near_miss_name_still_flagged(self) -> None:
|
|
156
|
+
tree = ast.parse(NEAR_MISS_WITH_PRINT)
|
|
157
|
+
assert len(check_print_in_production(tree, "publicli.py")) == 1
|
|
158
|
+
|
|
159
|
+
def test_non_cli_production_file_still_flagged(self) -> None:
|
|
160
|
+
tree = ast.parse(NEAR_MISS_WITH_PRINT)
|
|
161
|
+
assert len(check_print_in_production(tree, "stp_preview/renderer.py")) == 1
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class TestValidateFile:
|
|
165
|
+
def test_validate_file_flags_bare_except(self, tmp_path: Path) -> None:
|
|
166
|
+
source_file = tmp_path / "worker.py"
|
|
167
|
+
source_file.write_text(
|
|
168
|
+
"def run():\n try:\n do_work()\n except:\n pass\n",
|
|
169
|
+
encoding="utf-8",
|
|
170
|
+
)
|
|
171
|
+
violations = validate_file(source_file)
|
|
172
|
+
assert len(violations) == 1
|
|
173
|
+
assert "except" in violations[0].message.lower()
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class TestCrossSurfaceConsistency:
|
|
177
|
+
def test_matches_library_print_surface(self) -> None:
|
|
178
|
+
source = "def main():\n print('x')\n"
|
|
179
|
+
tree = ast.parse(source)
|
|
180
|
+
for path in (
|
|
181
|
+
"stp_preview/cli.py",
|
|
182
|
+
"stp_preview/renderer.py",
|
|
183
|
+
"publicli.py",
|
|
184
|
+
"packages/foo/scripts/run_job.py",
|
|
185
|
+
):
|
|
186
|
+
production_allows = check_print_in_production(tree, path) == []
|
|
187
|
+
library_allows = check_library_print(source, path) == []
|
|
188
|
+
assert production_allows == library_allows
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
CLI_BASENAME = "cli.py"
|
|
192
|
+
ANTIPATTERN_VALIDATOR_NAME = "Python Anti-patterns"
|
|
193
|
+
PRINT_FINDING_FRAGMENT = "print() in production"
|
|
194
|
+
|
|
195
|
+
PIPELINE_PRINT_SOURCE = '''
|
|
196
|
+
def emit_status(message):
|
|
197
|
+
print(message)
|
|
198
|
+
'''
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _antipattern_result_for(file_path: str, content: str) -> ValidatorResult:
|
|
202
|
+
"""Return the Python Anti-patterns result for content proposed at file_path."""
|
|
203
|
+
all_results = validate_proposed_file(file_path, content)
|
|
204
|
+
return next(
|
|
205
|
+
each_result
|
|
206
|
+
for each_result in all_results
|
|
207
|
+
if each_result.name == ANTIPATTERN_VALIDATOR_NAME
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class TestPrintCliExemptionThroughPipeline:
|
|
212
|
+
"""Pin the CLI-marker print exemption through the validate_proposed_file pipeline.
|
|
213
|
+
|
|
214
|
+
A basename of cli.py lands in the system temp dir as .../cli.py, so the
|
|
215
|
+
marker /cli.py matches and the print check stays silent. Near-miss basenames
|
|
216
|
+
(publicli.py) are covered at the unit surface; the pipeline stages under a
|
|
217
|
+
temp root that often contains the substring 'test', which short-circuits the
|
|
218
|
+
print check before the CLI-marker path runs.
|
|
219
|
+
"""
|
|
220
|
+
|
|
221
|
+
def test_pins_cli_basename_allows_print_through_pipeline(self) -> None:
|
|
222
|
+
antipattern_result = _antipattern_result_for(CLI_BASENAME, PIPELINE_PRINT_SOURCE)
|
|
223
|
+
assert antipattern_result.passed
|
|
224
|
+
assert PRINT_FINDING_FRAGMENT not in antipattern_result.output
|
|
@@ -44,6 +44,22 @@ class TestPreToolUseGate:
|
|
|
44
44
|
assert '"permissionDecision": "deny"' in completed.stdout
|
|
45
45
|
assert "Magic Values" in completed.stdout
|
|
46
46
|
|
|
47
|
+
def test_write_violating_content_to_ephemeral_scratch_path_is_allowed(
|
|
48
|
+
self, monkeypatch: pytest.MonkeyPatch
|
|
49
|
+
) -> None:
|
|
50
|
+
monkeypatch.delenv("CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT", raising=False)
|
|
51
|
+
completed = run_gate(
|
|
52
|
+
{
|
|
53
|
+
"tool_name": "Write",
|
|
54
|
+
"tool_input": {
|
|
55
|
+
"file_path": "/tmp/scratch_calculate.py",
|
|
56
|
+
"content": VIOLATING_PYTHON_SOURCE,
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
)
|
|
60
|
+
assert completed.returncode == 0, completed.stderr
|
|
61
|
+
assert "deny" not in completed.stdout
|
|
62
|
+
|
|
47
63
|
def test_write_with_clean_content_allows(self) -> None:
|
|
48
64
|
completed = run_gate(
|
|
49
65
|
{
|
package/package.json
CHANGED
package/rules/CLAUDE.md
CHANGED
|
@@ -53,6 +53,7 @@ Rule files installed into `~/.claude/rules/` by `bin/install.mjs`. A rule withou
|
|
|
53
53
|
| `tdd.md` | Test-driven development: red → green → refactor, no production code before a failing test |
|
|
54
54
|
| `testing.md` | Test quality and infrastructure standards |
|
|
55
55
|
| `vault-context.md` | Search Obsidian vault for prior sessions and decisions before substantive project work |
|
|
56
|
+
| `verified-commit-gate-skip.md` | The `# verify-skip` marker on a blocked commit/push is allowed only when the branch surface is the same code a code-verifier already passed clean; any real change since that verdict runs a fresh verification |
|
|
56
57
|
| `verify-before-asking.md` | Answer questions by inspecting files or running tools before asking; recalled facts expire until re-checked this session |
|
|
57
58
|
| `verify-runtime-state.md` | A "component is fine / not at fault" verdict rests on a live probe this session, never code reading or prior-session memory |
|
|
58
59
|
| `windows-filesystem-safe.md` | Use safe `rmtree` patterns on Windows; `mkdirSync` with `recursive: true` on possibly-existing paths |
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Verified-Commit-Gate Skip Marker
|
|
2
|
+
|
|
3
|
+
**When this applies:** A `git commit` or `git push` through the Bash tool is blocked by the `verified_commit_gate` hook.
|
|
4
|
+
|
|
5
|
+
## The marker
|
|
6
|
+
|
|
7
|
+
Appending the comment `# verify-skip` as a trailing shell comment to the Bash command exempts that single commit or push from the gate. The hook (`hooks/blocking/verified_commit_gate.py`, marker constant `VERIFICATION_BYPASS_MARKER`) recognizes the marker only when its leading `#` sits at a word boundary and outside every quoted region — a genuine comment, not a data-only mention inside a quoted commit message or `gh` body — and lets the command run without a minted verdict.
|
|
8
|
+
|
|
9
|
+
## When the marker is allowed
|
|
10
|
+
|
|
11
|
+
Use it only when both hold:
|
|
12
|
+
|
|
13
|
+
1. The gate is blocking the command, and
|
|
14
|
+
2. The branch surface content is the same code a `code-verifier` already passed clean — nothing effectively changed since that clean verdict.
|
|
15
|
+
|
|
16
|
+
That situation arises when the verdict fails to cover the current surface even though the code is the same: a clean verdict that never minted (the verifier's fenced block ran in a resumed turn, so the SubagentStop minter never fired), a surface hash perturbed by index-only staging churn, or a concurrent worktree write that was fully reverted back to the verified content. Before using the marker, confirm the match yourself: the test suite the verifier ran still passes, and the diff holds no content beyond what the clean verdict covered.
|
|
17
|
+
|
|
18
|
+
## When the marker is not allowed
|
|
19
|
+
|
|
20
|
+
Every other case runs the verification: spawn the `code-verifier` agent and let the SubagentStop hook mint the verdict. In particular, never use the marker to:
|
|
21
|
+
|
|
22
|
+
- Skip a branch's first verification.
|
|
23
|
+
- Commit or push after any real code change since the last clean verdict — one changed line means a fresh verification.
|
|
24
|
+
- Work around a verifier that reported findings; findings get repaired and re-verified.
|
|
25
|
+
|
|
26
|
+
## One command, one exemption
|
|
27
|
+
|
|
28
|
+
The marker exempts only the command that carries it. The next commit or push on the branch faces the gate again, so a follow-up change still verifies before it lands.
|