claude-dev-env 2.0.1 → 2.1.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/agents/clean-coder.md +31 -1
- package/audit-rubrics/prompts/category-e-dead-code.md +2 -2
- package/bin/install.test.mjs +2 -2
- package/docs/CLAUDE.md +6 -3
- package/docs/CODE_RULES.md +5 -1
- package/docs/agent-spawn-protocol.md +39 -0
- package/docs/nas-ssh-invocation.md +23 -0
- package/docs/worker-completion-gate.md +33 -0
- package/hooks/blocking/CLAUDE.md +1 -0
- package/hooks/blocking/code_rules_dead_module_constant.py +11 -5
- package/hooks/blocking/code_rules_shared.py +126 -47
- package/hooks/blocking/config/CLAUDE.md +2 -0
- package/hooks/blocking/config/verified_commit_constants.py +8 -1
- 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_enforcer_dead_module_constant.py +58 -0
- package/hooks/blocking/test_code_rules_enforcer_dead_module_constant_alias.py +133 -0
- 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_verification_verdict_store.py +12 -0
- package/hooks/blocking/test_verified_commit_config_bootstrap.py +18 -0
- package/hooks/blocking/test_verified_commit_docs_delta.py +176 -0
- package/hooks/blocking/test_verified_commit_gate_additional_context.py +134 -0
- package/hooks/blocking/tests/test_pii_prevention_blocker.py +0 -1
- 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/dead_module_constant_constants.py +8 -0
- package/hooks/hooks_constants/harness_scratchpad_constants.py +10 -9
- package/hooks/hooks_constants/mypy_integration_constants.py +16 -0
- package/hooks/hooks_constants/pii_prevention_constants.py +1 -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 +3 -3
- package/rules/agent-spawn-protocol.md +5 -43
- package/rules/code-standards.md +1 -36
- package/rules/env-var-table-code-drift.md +2 -21
- package/rules/hook-prose-matches-detector.md +5 -16
- package/rules/nas-ssh-invocation.md +3 -15
- package/rules/no-historical-clutter.md +7 -49
- package/rules/no-inline-destructive-literals.md +3 -5
- package/rules/package-inventory-stale-entry.md +7 -32
- package/rules/re-stage-before-commit.md +6 -23
- package/rules/shell-invocation-policy.md +1 -1
- package/rules/vault-context.md +3 -3
- package/rules/verified-commit-gate-skip.md +28 -0
- package/rules/workers-done-before-complete.md +2 -30
- package/scripts/claude_chain_runner.py +39 -3
- package/scripts/dev_env_scripts_constants/claude_chain_constants.py +3 -0
- package/scripts/test_claude_chain_runner.py +112 -0
- package/scripts/test_grok_headless_runner.py +0 -1
- 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 +1 -0
- package/skills/autoconverge/SKILL.md +310 -346
- package/skills/autoconverge/reference/CLAUDE.md +1 -0
- package/skills/autoconverge/reference/copilot-findings.md +51 -0
- package/skills/closeout/SKILL.md +7 -9
- package/skills/copilot-finding-triage/SKILL.md +5 -7
- 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/everything-search/SKILL.md +7 -1
- 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/orchestrator/SKILL.md +161 -147
- package/skills/pr-converge/SKILL.md +277 -422
- package/skills/pr-converge/reference/CLAUDE.md +1 -0
- package/skills/pr-converge/reference/progress-checklist.md +168 -0
- package/skills/pr-converge/test_step5_host_branch.py +8 -6
- 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
- package/system-prompts/CLAUDE.md +3 -3
- package/docs/agents-md-alignment-plan.md +0 -123
- package/docs/emotion-informed-prompt-design.md +0 -362
- package/rules/es-exe-file-search.md +0 -17
|
@@ -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
|
@@ -5,7 +5,7 @@ paths:
|
|
|
5
5
|
|
|
6
6
|
# rules
|
|
7
7
|
|
|
8
|
-
Rule files installed into `~/.claude/rules/` by `bin/install.mjs`.
|
|
8
|
+
Rule files installed into `~/.claude/rules/` by `bin/install.mjs`. A rule without `paths:` frontmatter loads at the start of every session; a rule with `paths:` frontmatter loads only when the session works with a file its globs match. The `InstructionsLoaded` log records that match as a `path_glob_match` event. Each `.md` file covers one named rule; hook-enforced rules are also backed by a Python hook in `hooks/`.
|
|
9
9
|
|
|
10
10
|
## Files
|
|
11
11
|
|
|
@@ -37,7 +37,6 @@ Rule files installed into `~/.claude/rules/` by `bin/install.mjs`. Claude Code l
|
|
|
37
37
|
| `no-inline-destructive-literals.md` | No destructive-command literals in Bash tool command strings, even as data |
|
|
38
38
|
| `no-justification-noise.md` | Markdown states facts a reader can act on; cut a present-tense sentence that only justifies a stated choice or restates a gain the reader already works out from the behavior or from a rule enforced elsewhere |
|
|
39
39
|
| `env-var-table-code-drift.md` | Every env-var summary table row in a `.md` file names a code file whose source references the variable |
|
|
40
|
-
| `es-exe-file-search.md` | File search on Windows routes through the `es.exe` CLI with a scoped query; the Everything HTTP server stays off |
|
|
41
40
|
| `orphan-css-class.md` | Every `class="..."` attribute in Python-generated markup has a matching selector in the `<style>` block |
|
|
42
41
|
| `package-inventory-stale-entry.md` | A new production code file added to a directory carries an entry in that directory's `README.md`/`CLAUDE.md` file inventory |
|
|
43
42
|
| `paired-test-coverage.md` | A public function omitted by a module's established paired test suite must get a behavioral test |
|
|
@@ -50,10 +49,11 @@ Rule files installed into `~/.claude/rules/` by `bin/install.mjs`. Claude Code l
|
|
|
50
49
|
| `research-mode.md` | Three anti-hallucination constraints: say "I don't know", verify with citations, quote for factual grounding |
|
|
51
50
|
| `right-sized-engineering.md` | Simple over clever; functions over classes; concrete over abstract |
|
|
52
51
|
| `self-contained-docs.md` | Every document is fully self-contained; no references to the conversation that produced it |
|
|
53
|
-
| `shell-invocation-policy.md` | All Windows shell commands use `pwsh`; `
|
|
52
|
+
| `shell-invocation-policy.md` | All Windows shell commands use `pwsh`; `Audit-ShellPolicy.ps1` reports the non-`pwsh` forms in the `settings.json` permission rules and `Migrate-ShellPolicy.ps1` rewrites them to `pwsh`, both run on demand, not as a live gate |
|
|
54
53
|
| `tdd.md` | Test-driven development: red → green → refactor, no production code before a failing test |
|
|
55
54
|
| `testing.md` | Test quality and infrastructure standards |
|
|
56
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 |
|
|
57
57
|
| `verify-before-asking.md` | Answer questions by inspecting files or running tools before asking; recalled facts expire until re-checked this session |
|
|
58
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 |
|
|
59
59
|
| `windows-filesystem-safe.md` | Use safe `rmtree` patterns on Windows; `mkdirSync` with `recursive: true` on possibly-existing paths |
|