claude-dev-env 2.0.2 → 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.
Files changed (88) hide show
  1. package/CLAUDE.md +1 -1
  2. package/hooks/blocking/CLAUDE.md +1 -0
  3. package/hooks/blocking/code_rules_shared.py +126 -47
  4. package/hooks/blocking/config/CLAUDE.md +2 -0
  5. package/hooks/blocking/config/verified_commit_context_constants.py +21 -0
  6. package/hooks/blocking/config/verified_commit_gate_output_constants.py +14 -0
  7. package/hooks/blocking/conftest.py +36 -30
  8. package/hooks/blocking/convergence_gate_blocker.py +76 -8
  9. package/hooks/blocking/plain_language_blocker.py +8 -0
  10. package/hooks/blocking/state_description_blocker.py +4 -1
  11. package/hooks/blocking/test_code_rules_shared.py +120 -20
  12. package/hooks/blocking/test_convergence_gate_blocker.py +146 -0
  13. package/hooks/blocking/test_plain_language_blocker.py +15 -0
  14. package/hooks/blocking/test_state_description_blocker.py +15 -0
  15. package/hooks/blocking/test_verified_commit_config_bootstrap.py +18 -0
  16. package/hooks/blocking/test_verified_commit_gate_additional_context.py +134 -0
  17. package/hooks/blocking/tests/test_verified_commit_gate.py +41 -0
  18. package/hooks/blocking/verified_commit_config_bootstrap.py +22 -10
  19. package/hooks/blocking/verified_commit_gate.py +113 -568
  20. package/hooks/blocking/verified_commit_gate_parts/CLAUDE.md +28 -0
  21. package/hooks/blocking/verified_commit_gate_parts/__init__.py +1 -0
  22. package/hooks/blocking/verified_commit_gate_parts/command_tokenization.py +174 -0
  23. package/hooks/blocking/verified_commit_gate_parts/deny_payload.py +53 -0
  24. package/hooks/blocking/verified_commit_gate_parts/deny_reason.py +80 -0
  25. package/hooks/blocking/verified_commit_gate_parts/directory_resolution.py +170 -0
  26. package/hooks/blocking/verified_commit_gate_parts/gated_invocations.py +205 -0
  27. package/hooks/blocking/verified_commit_gate_parts/tests/conftest.py +10 -0
  28. package/hooks/blocking/verified_commit_gate_parts/tests/test_command_tokenization.py +94 -0
  29. package/hooks/blocking/verified_commit_gate_parts/tests/test_deny_payload.py +17 -0
  30. package/hooks/blocking/verified_commit_gate_parts/tests/test_deny_reason.py +38 -0
  31. package/hooks/blocking/verified_commit_gate_parts/tests/test_directory_resolution.py +71 -0
  32. package/hooks/blocking/verified_commit_gate_parts/tests/test_gated_invocations.py +61 -0
  33. package/hooks/hooks_constants/CLAUDE.md +4 -1
  34. package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -0
  35. package/hooks/hooks_constants/convergence_gate_blocker_constants.py +36 -0
  36. package/hooks/hooks_constants/harness_scratchpad_constants.py +10 -9
  37. package/hooks/hooks_constants/mypy_integration_constants.py +16 -0
  38. package/hooks/validators/mypy_integration.py +145 -24
  39. package/hooks/validators/python_antipattern_checks.py +16 -0
  40. package/hooks/validators/run_all_validators.py +9 -3
  41. package/hooks/validators/test_mypy_integration.py +154 -0
  42. package/hooks/validators/test_python_antipattern_checks.py +112 -1
  43. package/hooks/validators/test_run_all_validators_pretooluse.py +16 -0
  44. package/package.json +1 -1
  45. package/rules/CLAUDE.md +1 -0
  46. package/rules/verified-commit-gate-skip.md +28 -0
  47. package/skills/auditing-claude-config/CLAUDE.md +2 -1
  48. package/skills/auditing-claude-config/SKILL.md +114 -176
  49. package/skills/auditing-claude-config/reference/probe-hook.md +74 -0
  50. package/skills/autoconverge/CLAUDE.md +1 -0
  51. package/skills/autoconverge/SKILL.md +310 -346
  52. package/skills/autoconverge/reference/CLAUDE.md +1 -0
  53. package/skills/autoconverge/reference/copilot-findings.md +51 -0
  54. package/skills/closeout/SKILL.md +7 -9
  55. package/skills/copilot-finding-triage/SKILL.md +5 -7
  56. package/skills/copilot-review/CLAUDE.md +3 -2
  57. package/skills/copilot-review/SKILL.md +119 -155
  58. package/skills/copilot-review/templates/subagent-prompt.md +49 -0
  59. package/skills/fresh-branch/CLAUDE.md +6 -9
  60. package/skills/fresh-branch/SKILL.md +84 -33
  61. package/skills/fresh-branch/scripts/create_fresh_branch.py +445 -0
  62. package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/__init__.py +1 -0
  63. package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +74 -0
  64. package/skills/fresh-branch/scripts/test_create_fresh_branch.py +670 -0
  65. package/skills/pr-converge/SKILL.md +277 -422
  66. package/skills/pr-converge/reference/CLAUDE.md +1 -0
  67. package/skills/pr-converge/reference/progress-checklist.md +168 -0
  68. package/skills/pr-converge/test_step5_host_branch.py +8 -6
  69. package/skills/pr-loop-cloud-transport/SKILL.md +1 -1
  70. package/skills/privacy-hygiene/SKILL.md +68 -115
  71. package/skills/privacy-hygiene/reference/sweep-procedure.md +62 -0
  72. package/skills/session-log/CLAUDE.md +2 -1
  73. package/skills/session-log/SKILL.md +4 -26
  74. package/skills/session-log/templates/frontmatter.md +40 -0
  75. package/skills/skill-builder/CLAUDE.md +8 -7
  76. package/skills/skill-builder/SKILL.md +26 -11
  77. package/skills/skill-builder/references/CLAUDE.md +3 -1
  78. package/skills/skill-builder/references/delegation-map.md +21 -12
  79. package/skills/skill-builder/references/description-field.md +9 -11
  80. package/skills/skill-builder/references/deterministic-elements.md +218 -0
  81. package/skills/skill-builder/references/self-audit-checklist.md +62 -45
  82. package/skills/skill-builder/references/skill-modularity.md +8 -9
  83. package/skills/skill-builder/templates/CLAUDE.md +2 -2
  84. package/skills/skill-builder/templates/gap-analysis.md +15 -0
  85. package/skills/skill-builder/workflows/CLAUDE.md +5 -5
  86. package/skills/skill-builder/workflows/improve-skill.md +18 -9
  87. package/skills/skill-builder/workflows/new-skill.md +23 -15
  88. 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 = "pyproject.toml"
58
- resolved_starting_file = starting_file.resolve()
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
- py_files = [str(f) for f in files if f.suffix == ".py"]
76
- if not py_files:
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
- config_argument: list[str] = []
80
- for each_py_file in py_files:
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=result.returncode == 0,
98
- output=result.stdout or result.stderr or "No type errors",
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. Emits a deny decision naming each
825
- failing validator when any fires; writes nothing for a clean file, an
826
- unparseable payload, or a payload no validator covers.
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 .validator_base import Violation
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "2.0.2",
3
+ "version": "2.1.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
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.
@@ -9,6 +9,7 @@ Audits a Claude Code setup — user `CLAUDE.md`, `~/.claude/rules/`, project `.c
9
9
  | File | Role |
10
10
  |---|---|
11
11
  | `SKILL.md` | Full audit protocol: inventory the always-loaded set, find duplicate imports, classify each rule, produce the migration table, stage recommendations by risk, and verify lazy-load behavior with an optional probe hook. |
12
+ | `reference/probe-hook.md` | The optional lazy-load probe hook: hook script, `settings.json` registration, and test protocol. |
12
13
 
13
14
  ## What the skill produces
14
15
 
@@ -17,4 +18,4 @@ Audits a Claude Code setup — user `CLAUDE.md`, `~/.claude/rules/`, project `.c
17
18
  3. **Recommended next step** — the single highest-leverage change.
18
19
  4. **Open questions** — anything not verified by the probe hook.
19
20
 
20
- No scripts or workflow files. The skill body holds all logic.
21
+ No runnable scripts or workflow files. The audit logic lives in the skill body; the optional lazy-load probe hook lives in `reference/probe-hook.md` as copy-ready text.