claude-dev-env 2.2.1 → 2.4.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 (170) hide show
  1. package/CLAUDE.md +3 -2
  2. package/_shared/advisor/advisor-protocol.md +2 -2
  3. package/_shared/pr-loop/scripts/CLAUDE.md +1 -0
  4. package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +306 -284
  5. package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -0
  6. package/_shared/pr-loop/scripts/pr_loop_shared_constants/claude_permissions_constants.py +3 -3
  7. package/_shared/pr-loop/scripts/pr_loop_shared_constants/stale_worktree_rule_sweep_constants.py +107 -0
  8. package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +5 -0
  9. package/_shared/pr-loop/scripts/stale_worktree_rule_sweep.py +107 -0
  10. package/_shared/pr-loop/scripts/tests/CLAUDE.md +2 -0
  11. package/_shared/pr-loop/scripts/tests/test_agent_config_carveout.py +55 -73
  12. package/_shared/pr-loop/scripts/tests/test_claude_permissions_constants.py +9 -15
  13. package/_shared/pr-loop/scripts/tests/test_grant_project_claude_permissions.py +91 -1
  14. package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +88 -1
  15. package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep.py +301 -0
  16. package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep_constants.py +85 -0
  17. package/_shared/pr-loop/worker-spawn.md +1 -1
  18. package/agents/CLAUDE.md +1 -0
  19. package/agents/code-verifier.md +4 -4
  20. package/agents/skill-writer-agent.md +84 -0
  21. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +141 -41
  22. package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +29 -13
  23. package/docs/CLAUDE.md +1 -0
  24. package/docs/references/CLAUDE.md +1 -0
  25. package/docs/references/code-review-enforcement.md +97 -0
  26. package/docs/wsl-docker-cowork-starter-matrix.md +89 -0
  27. package/hooks/blocking/CLAUDE.md +8 -1
  28. package/hooks/blocking/code_review_enforcement_config_bootstrap.py +53 -0
  29. package/hooks/blocking/code_review_gate_deny.py +74 -0
  30. package/hooks/blocking/code_review_pr_create_gate.py +194 -0
  31. package/hooks/blocking/code_review_push_gate.py +140 -0
  32. package/hooks/blocking/code_review_stamp_directory_write_blocker.py +340 -0
  33. package/hooks/blocking/code_review_stamp_store.py +233 -0
  34. package/hooks/blocking/code_review_stamp_write_blocker_parts/__init__.py +7 -0
  35. package/hooks/blocking/code_review_stamp_write_blocker_parts/conftest.py +15 -0
  36. package/hooks/blocking/code_review_stamp_write_blocker_parts/obfuscated_stamp_path_reference.py +212 -0
  37. package/hooks/blocking/code_review_stamp_write_blocker_parts/split_directory_change_into_stamp.py +138 -0
  38. package/hooks/blocking/code_review_stamp_write_blocker_parts/test_obfuscated_stamp_path_reference.py +49 -0
  39. package/hooks/blocking/code_review_stamp_write_blocker_parts/test_split_directory_change_into_stamp.py +38 -0
  40. package/hooks/blocking/code_verifier_spawn_preflight_gate.py +39 -27
  41. package/hooks/blocking/config/code_review_enforcement_constants.py +110 -0
  42. package/hooks/blocking/config/test_code_review_enforcement_constants.py +108 -0
  43. package/hooks/blocking/config/verified_commit_constants.py +24 -9
  44. package/hooks/blocking/conftest.py +2 -0
  45. package/hooks/blocking/convergence_gate_blocker.py +112 -23
  46. package/hooks/blocking/destructive_command_blocker.py +19 -6
  47. package/hooks/blocking/pr_description_proof_of_work.py +52 -34
  48. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +4 -1
  49. package/hooks/blocking/test_code_review_enforcement_config_bootstrap.py +62 -0
  50. package/hooks/blocking/test_code_review_gate_deny.py +54 -0
  51. package/hooks/blocking/test_code_review_pr_create_gate.py +185 -0
  52. package/hooks/blocking/test_code_review_push_gate.py +189 -0
  53. package/hooks/blocking/test_code_review_stamp_directory_write_blocker.py +180 -0
  54. package/hooks/blocking/test_code_review_stamp_store.py +205 -0
  55. package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +124 -2
  56. package/hooks/blocking/test_code_verifier_tools_contract.py +28 -0
  57. package/hooks/blocking/test_convergence_gate_blocker.py +153 -5
  58. package/hooks/blocking/test_destructive_command_blocker.py +1 -1
  59. package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +45 -0
  60. package/hooks/blocking/test_pr_description_proof_of_work.py +151 -0
  61. package/hooks/blocking/test_pre_tool_use_dispatcher.py +8 -8
  62. package/hooks/blocking/test_verification_verdict_store.py +920 -810
  63. package/hooks/blocking/test_volatile_path_in_post_blocker.py +114 -2
  64. package/hooks/blocking/verification_verdict_store.py +118 -5
  65. package/hooks/blocking/verified_commit_gate_parts/gated_invocations.py +29 -17
  66. package/hooks/blocking/verified_commit_gate_parts/tests/test_gated_invocations.py +35 -0
  67. package/hooks/blocking/volatile_path_in_post_blocker.py +69 -8
  68. package/hooks/git-hooks/git_hooks_constants/__init__.py +6 -0
  69. package/hooks/git-hooks/pre_push.py +89 -2
  70. package/hooks/git-hooks/test_pre_push.py +103 -0
  71. package/hooks/hooks.json +16 -1
  72. package/hooks/hooks_constants/CLAUDE.md +1 -0
  73. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +8 -0
  74. package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -0
  75. package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +26 -11
  76. package/hooks/hooks_constants/convergence_gate_blocker_constants.py +20 -3
  77. package/hooks/hooks_constants/destructive_command_segment_constants.py +3 -1
  78. package/hooks/hooks_constants/pr_description_proof_of_work_constants.py +0 -4
  79. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
  80. package/hooks/hooks_constants/pyproject_config_discovery_constants.py +16 -0
  81. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +24 -0
  82. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +6 -0
  83. package/hooks/hooks_constants/volatile_path_in_post_blocker_constants.py +8 -1
  84. package/hooks/validators/CLAUDE.md +1 -0
  85. package/hooks/validators/mypy_integration.py +63 -50
  86. package/hooks/validators/pyproject_config_discovery.py +101 -0
  87. package/hooks/validators/ruff_integration.py +211 -23
  88. package/hooks/validators/run_all_validators.py +21 -14
  89. package/hooks/validators/test_mypy_integration.py +32 -0
  90. package/hooks/validators/test_pyproject_config_discovery.py +94 -0
  91. package/hooks/validators/test_ruff_integration.py +68 -1
  92. package/hooks/validators/test_run_all_validators.py +25 -0
  93. package/hooks/validators/test_run_all_validators_config_discovery.py +123 -0
  94. package/package.json +1 -1
  95. package/rules/docstring-prose-matches-implementation.md +20 -43
  96. package/rules/durable-post-artifacts.md +7 -0
  97. package/scripts/CLAUDE.md +2 -1
  98. package/scripts/claude-chain.example.json +15 -3
  99. package/scripts/claude_chain_runner.py +130 -27
  100. package/scripts/claude_chain_usage.py +346 -0
  101. package/scripts/codec_forwarding_test_support.py +83 -0
  102. package/scripts/conftest.py +8 -0
  103. package/scripts/dev_env_scripts_constants/CLAUDE.md +4 -3
  104. package/scripts/dev_env_scripts_constants/claude_chain_constants.py +57 -2
  105. package/scripts/dev_env_scripts_constants/claude_chain_usage_constants.py +58 -0
  106. package/scripts/dev_env_scripts_constants/code_review_constants.py +129 -12
  107. package/scripts/dev_env_scripts_constants/test_code_review_constants.py +55 -0
  108. package/scripts/dev_env_scripts_constants/timing.py +1 -1
  109. package/scripts/invoke_code_review.py +550 -38
  110. package/scripts/resolve_worker_spawn.py +8 -1
  111. package/scripts/test_claude_chain_runner.py +412 -6
  112. package/scripts/test_claude_chain_usage.py +534 -0
  113. package/scripts/test_invoke_code_review.py +298 -4
  114. package/scripts/test_invoke_code_review_codec.py +77 -0
  115. package/scripts/test_resolve_worker_spawn_codec.py +101 -0
  116. package/skills/autoconverge/SKILL.md +9 -3
  117. package/skills/autoconverge/reference/convergence.md +2 -1
  118. package/skills/autoconverge/reference/multi-pr.md +6 -1
  119. package/skills/autoconverge/reference/stop-conditions.md +16 -10
  120. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +1 -1
  121. package/skills/autoconverge/workflow/converge.codex-gate.test.mjs +175 -3
  122. package/skills/autoconverge/workflow/converge.contract.test.mjs +19 -0
  123. package/skills/autoconverge/workflow/converge.mjs +24 -8
  124. package/skills/autoconverge/workflow/converge_multi.mjs +7 -3
  125. package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +5 -0
  126. package/skills/fresh-branch/CLAUDE.md +2 -0
  127. package/skills/fresh-branch/SKILL.md +2 -0
  128. package/skills/fresh-branch/scripts/create_fresh_branch.py +78 -180
  129. package/skills/fresh-branch/scripts/fresh_branch_git_commands.py +285 -0
  130. package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +1 -0
  131. package/skills/fresh-branch/scripts/pytest.ini +4 -0
  132. package/skills/fresh-branch/scripts/test_create_fresh_branch.py +98 -0
  133. package/skills/fresh-branch/scripts/test_fresh_branch_git_commands.py +310 -0
  134. package/skills/orchestrator/SKILL.md +1 -20
  135. package/skills/orchestrator-refresh/SKILL.md +1 -1
  136. package/skills/pr-converge/SKILL.md +3 -3
  137. package/skills/pr-converge/reference/examples.md +3 -3
  138. package/skills/pr-converge/reference/fix-protocol.md +1 -1
  139. package/skills/pr-converge/reference/ground-rules.md +3 -3
  140. package/skills/pr-converge/reference/per-tick.md +5 -5
  141. package/skills/pr-converge/reference/progress-checklist.md +1 -1
  142. package/skills/pr-converge/test_step5_host_branch.py +1 -1
  143. package/skills/prototype/SKILL.md +86 -0
  144. package/skills/prototype/reference/honest-limitations.md +23 -0
  145. package/skills/prototype/reference/promotion-tasks.md +23 -0
  146. package/skills/prototype/scripts/build_sandbox_settings.py +249 -0
  147. package/skills/prototype/scripts/conftest.py +15 -0
  148. package/skills/prototype/scripts/launch_sandbox.py +205 -0
  149. package/skills/prototype/scripts/probe_sandbox_safety.py +311 -0
  150. package/skills/prototype/scripts/prototype_scripts_constants/__init__.py +1 -0
  151. package/skills/prototype/scripts/prototype_scripts_constants/config/__init__.py +0 -0
  152. package/skills/prototype/scripts/prototype_scripts_constants/config/build_sandbox_settings_constants.py +41 -0
  153. package/skills/prototype/scripts/prototype_scripts_constants/config/launch_sandbox_constants.py +23 -0
  154. package/skills/prototype/scripts/prototype_scripts_constants/config/probe_sandbox_safety_constants.py +45 -0
  155. package/skills/prototype/scripts/prototype_scripts_constants/config/prototype_common_constants.py +10 -0
  156. package/skills/prototype/scripts/test_build_sandbox_settings.py +275 -0
  157. package/skills/prototype/scripts/test_launch_sandbox.py +303 -0
  158. package/skills/prototype/scripts/test_probe_sandbox_safety.py +284 -0
  159. package/skills/prototype/workflows/promotion.md +27 -0
  160. package/skills/prototype/workflows/sandbox.md +35 -0
  161. package/skills/skill-builder/CLAUDE.md +3 -3
  162. package/skills/skill-builder/SKILL.md +5 -5
  163. package/skills/skill-builder/references/CLAUDE.md +1 -1
  164. package/skills/skill-builder/references/delegation-map.md +3 -3
  165. package/skills/skill-builder/references/description-field.md +1 -1
  166. package/skills/skill-builder/references/skill-modularity.md +2 -3
  167. package/skills/skill-builder/workflows/CLAUDE.md +1 -1
  168. package/skills/skill-builder/workflows/improve-skill.md +1 -1
  169. package/skills/skill-builder/workflows/new-skill.md +2 -2
  170. package/skills/team-advisor/SKILL.md +5 -4
@@ -4,19 +4,33 @@ import contextlib
4
4
  import subprocess
5
5
  import sys
6
6
  import tempfile
7
- import tomllib
8
7
  from collections.abc import Iterator
9
8
  from dataclasses import dataclass
10
9
  from pathlib import Path
11
10
 
11
+ _validators_directory = str(Path(__file__).resolve().parent)
12
12
  _hooks_directory = str(Path(__file__).resolve().parent.parent)
13
13
 
14
+ try:
15
+ from pyproject_config_discovery import (
16
+ ancestor_directories,
17
+ find_pyproject_configuring_tool,
18
+ )
19
+ except ModuleNotFoundError:
20
+ if _validators_directory not in sys.path:
21
+ sys.path.insert(0, _validators_directory)
22
+ from pyproject_config_discovery import (
23
+ ancestor_directories,
24
+ find_pyproject_configuring_tool,
25
+ )
26
+
14
27
  try:
15
28
  from hooks_constants.mypy_integration_constants import (
16
29
  GIT_DIRECTORY_NAME,
17
30
  PYPROJECT_FILENAME,
18
31
  PYTHON_SOURCE_SUFFIX,
19
32
  )
33
+ from hooks_constants.pyproject_config_discovery_constants import MYPY_TOOL_TABLE_NAME
20
34
  except ModuleNotFoundError:
21
35
  if _hooks_directory not in sys.path:
22
36
  sys.path.insert(0, _hooks_directory)
@@ -25,6 +39,7 @@ except ModuleNotFoundError:
25
39
  PYPROJECT_FILENAME,
26
40
  PYTHON_SOURCE_SUFFIX,
27
41
  )
42
+ from hooks_constants.pyproject_config_discovery_constants import MYPY_TOOL_TABLE_NAME
28
43
 
29
44
 
30
45
  @dataclass
@@ -48,38 +63,6 @@ def check_mypy_available() -> bool:
48
63
  return False
49
64
 
50
65
 
51
- def _pyproject_contains_tool_mypy(pyproject_path: Path) -> bool:
52
- try:
53
- with pyproject_path.open("rb") as readable_handle:
54
- parsed_toml = tomllib.load(readable_handle)
55
- except (OSError, tomllib.TOMLDecodeError):
56
- return False
57
- tool_table = parsed_toml.get("tool", {})
58
- return isinstance(tool_table, dict) and "mypy" in tool_table
59
-
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
-
83
66
  def find_pyproject_with_mypy_config(starting_file: Path) -> Path | None:
84
67
  """Walk up from a starting file to locate a pyproject.toml that configures mypy.
85
68
 
@@ -97,12 +80,7 @@ def find_pyproject_with_mypy_config(starting_file: Path) -> Path | None:
97
80
  table, or ``None`` when no such file exists between ``starting_file``
98
81
  and the filesystem root.
99
82
  """
100
- pyproject_filename_for_lookup = PYPROJECT_FILENAME
101
- for each_candidate_directory in _ancestor_directories(starting_file):
102
- candidate_pyproject = each_candidate_directory / pyproject_filename_for_lookup
103
- if candidate_pyproject.is_file() and _pyproject_contains_tool_mypy(candidate_pyproject):
104
- return candidate_pyproject
105
- return None
83
+ return find_pyproject_configuring_tool(starting_file, MYPY_TOOL_TABLE_NAME)
106
84
 
107
85
 
108
86
  def find_module_resolution_root(starting_file: Path) -> Path | None:
@@ -127,7 +105,7 @@ def find_module_resolution_root(starting_file: Path) -> Path | None:
127
105
  """
128
106
  git_entry_name = GIT_DIRECTORY_NAME
129
107
  pyproject_filename = PYPROJECT_FILENAME
130
- for each_candidate_directory in _ancestor_directories(starting_file):
108
+ for each_candidate_directory in ancestor_directories(starting_file):
131
109
  has_git_entry = (each_candidate_directory / git_entry_name).exists()
132
110
  has_pyproject = (each_candidate_directory / pyproject_filename).is_file()
133
111
  if has_git_entry or has_pyproject:
@@ -171,7 +149,7 @@ def mypy_working_directory(all_py_files: list[str]) -> Iterator[str]:
171
149
  yield isolated_directory
172
150
 
173
151
 
174
- def _mypy_config_argument(all_py_files: list[str]) -> list[str]:
152
+ def _native_mypy_config_argument(all_py_files: list[str]) -> list[str]:
175
153
  """Return the ``--config-file`` argument for the first file with a mypy config."""
176
154
  for each_py_file in all_py_files:
177
155
  discovered_pyproject = find_pyproject_with_mypy_config(Path(each_py_file))
@@ -180,9 +158,39 @@ def _mypy_config_argument(all_py_files: list[str]) -> list[str]:
180
158
  return []
181
159
 
182
160
 
183
- def _run_mypy_subprocess(all_py_files: list[str]) -> subprocess.CompletedProcess[str]:
161
+ def _mypy_config_argument(
162
+ all_py_files: list[str], config_source_path: Path | None
163
+ ) -> list[str]:
164
+ """Return the ``--config-file`` argument, resolved from the original path when given.
165
+
166
+ ::
167
+
168
+ config_source_path resolves .../hooks/pyproject.toml
169
+ -> ["--config-file", ".../hooks/pyproject.toml"]
170
+ config_source_path given, no [tool.mypy] up-tree -> [] (native discovery)
171
+ config_source_path None -> native per-file discovery over all_py_files
172
+
173
+ Args:
174
+ all_py_files: The resolved paths mypy will check.
175
+ config_source_path: The original target path the staged copy stands in
176
+ for, or ``None`` for a native multi-file run.
177
+
178
+ Returns:
179
+ The ``--config-file`` argument vector, empty when no config resolves.
180
+ """
181
+ if config_source_path is None:
182
+ return _native_mypy_config_argument(all_py_files)
183
+ resolved_pyproject = find_pyproject_with_mypy_config(config_source_path)
184
+ if resolved_pyproject is not None:
185
+ return ["--config-file", str(resolved_pyproject)]
186
+ return _native_mypy_config_argument(all_py_files)
187
+
188
+
189
+ def _run_mypy_subprocess(
190
+ all_py_files: list[str], config_source_path: Path | None
191
+ ) -> subprocess.CompletedProcess[str]:
184
192
  """Run mypy over *all_py_files* from each file's own project root."""
185
- config_argument = _mypy_config_argument(all_py_files)
193
+ config_argument = _mypy_config_argument(all_py_files, config_source_path)
186
194
  with mypy_working_directory(all_py_files) as working_directory:
187
195
  return subprocess.run(
188
196
  ["mypy", *config_argument, "--ignore-missing-imports", "--no-error-summary"]
@@ -194,24 +202,29 @@ def _run_mypy_subprocess(all_py_files: list[str]) -> subprocess.CompletedProcess
194
202
  )
195
203
 
196
204
 
197
- def run_mypy_check(files: list[Path]) -> MypyResult:
198
- """Run mypy on files."""
199
- if not files:
205
+ def run_mypy_check(
206
+ all_files: list[Path], config_source_path: Path | None = None
207
+ ) -> MypyResult:
208
+ """Run mypy on files, resolving config from *config_source_path* when given.
209
+
210
+ A given ``config_source_path`` walks ``--config-file`` up from the original
211
+ target rather than the staged copy's own ancestors.
212
+ """
213
+ if not all_files:
200
214
  return MypyResult(passed=True, output="No files to check", error_count=0)
201
215
 
202
216
  if not check_mypy_available():
203
217
  return MypyResult(passed=True, output="Mypy not installed - skipping", error_count=0)
204
218
 
205
- python_source_suffix = PYTHON_SOURCE_SUFFIX
206
219
  all_py_files = [
207
220
  str(each_file.resolve())
208
- for each_file in files
209
- if each_file.suffix == python_source_suffix
221
+ for each_file in all_files
222
+ if each_file.suffix == PYTHON_SOURCE_SUFFIX
210
223
  ]
211
224
  if not all_py_files:
212
225
  return MypyResult(passed=True, output="No Python files", error_count=0)
213
226
 
214
- completed_process = _run_mypy_subprocess(all_py_files)
227
+ completed_process = _run_mypy_subprocess(all_py_files, config_source_path)
215
228
  error_count = completed_process.stdout.count(": error:")
216
229
 
217
230
  return MypyResult(
@@ -0,0 +1,101 @@
1
+ """Resolve a validator's config file by walking up from an original path.
2
+
3
+ A staged temp copy has no config in its own ancestors, so ruff and mypy would
4
+ discover nothing there. This walk starts from the ORIGINAL target path and
5
+ returns the first pyproject.toml whose ``[tool.<name>]`` table exists.
6
+
7
+ ::
8
+
9
+ tool_table_name="ruff", start .../hooks/validators/module.py
10
+ -> climbs to .../hooks/pyproject.toml, which declares [tool.ruff] -> that path
11
+ tool_table_name="ruff", start .../advisor/scripts/module.py
12
+ -> that pyproject declares [tool.mypy] only, no [tool.ruff] -> None
13
+ """
14
+
15
+ import sys
16
+ import tomllib
17
+ from pathlib import Path
18
+
19
+ _hooks_directory = str(Path(__file__).resolve().parent.parent)
20
+
21
+ try:
22
+ from hooks_constants.mypy_integration_constants import PYPROJECT_FILENAME
23
+ from hooks_constants.pyproject_config_discovery_constants import TOOL_TABLE_KEY
24
+ except ModuleNotFoundError:
25
+ if _hooks_directory not in sys.path:
26
+ sys.path.insert(0, _hooks_directory)
27
+ from hooks_constants.mypy_integration_constants import PYPROJECT_FILENAME
28
+ from hooks_constants.pyproject_config_discovery_constants import TOOL_TABLE_KEY
29
+
30
+
31
+ def ancestor_directories(starting_file: Path) -> list[Path]:
32
+ """Return *starting_file*'s directory and every parent, nearest first.
33
+
34
+ ::
35
+
36
+ repo/pkg/mod.py -> [repo/pkg, repo, ... , filesystem root]
37
+ repo/pkg/ -> [repo/pkg, repo, ... , filesystem root]
38
+
39
+ A file resolves to its containing directory; a directory resolves to itself,
40
+ so both callers start the walk from the same first candidate.
41
+
42
+ Args:
43
+ starting_file: The file (or directory) the walk begins from.
44
+
45
+ Returns:
46
+ The resolved starting directory followed by each of its parents.
47
+ """
48
+ resolved_starting_file = starting_file.resolve()
49
+ walk_origin = (
50
+ resolved_starting_file.parent
51
+ if resolved_starting_file.is_file()
52
+ else resolved_starting_file
53
+ )
54
+ return [walk_origin, *walk_origin.parents]
55
+
56
+
57
+ def _pyproject_configures_tool(pyproject_path: Path, tool_table_name: str) -> bool:
58
+ """Return whether *pyproject_path* declares a ``[tool.<tool_table_name>]`` table.
59
+
60
+ A malformed or unreadable file counts as no match, so a broken pyproject
61
+ never shadows a valid config further up the tree.
62
+
63
+ Args:
64
+ pyproject_path: The candidate pyproject.toml to parse.
65
+ tool_table_name: The tool's table name below ``[tool]`` (for example ``ruff``).
66
+
67
+ Returns:
68
+ True when the parsed file holds ``[tool.<tool_table_name>]``, else False.
69
+ """
70
+ try:
71
+ with pyproject_path.open("rb") as readable_handle:
72
+ parsed_toml = tomllib.load(readable_handle)
73
+ except (OSError, tomllib.TOMLDecodeError):
74
+ return False
75
+ tool_table = parsed_toml.get(TOOL_TABLE_KEY, {})
76
+ return isinstance(tool_table, dict) and tool_table_name in tool_table
77
+
78
+
79
+ def find_pyproject_configuring_tool(starting_file: Path, tool_table_name: str) -> Path | None:
80
+ """Walk up from *starting_file* to the first pyproject.toml configuring a tool.
81
+
82
+ The walk skips a pyproject.toml that does not declare the tool's table, so an
83
+ unrelated package config does not shadow a config that actually configures
84
+ the tool further up the tree.
85
+
86
+ Args:
87
+ starting_file: The file (or directory) the walk begins from — the
88
+ original target path, not the staged temp copy.
89
+ tool_table_name: The tool's table name below ``[tool]`` (for example ``ruff``).
90
+
91
+ Returns:
92
+ The first ``pyproject.toml`` Path that declares ``[tool.<tool_table_name>]``,
93
+ or ``None`` when no such file exists up to the filesystem root.
94
+ """
95
+ for each_candidate_directory in ancestor_directories(starting_file):
96
+ candidate_pyproject = each_candidate_directory / PYPROJECT_FILENAME
97
+ if candidate_pyproject.is_file() and _pyproject_configures_tool(
98
+ candidate_pyproject, tool_table_name
99
+ ):
100
+ return candidate_pyproject
101
+ return None
@@ -1,9 +1,29 @@
1
1
  """Ruff integration for fast Python linting."""
2
2
 
3
3
  import subprocess
4
+ import sys
4
5
  from dataclasses import dataclass
5
6
  from pathlib import Path
6
7
 
8
+ _validators_directory = str(Path(__file__).resolve().parent)
9
+ _hooks_directory = str(Path(__file__).resolve().parent.parent)
10
+
11
+ try:
12
+ from pyproject_config_discovery import find_pyproject_configuring_tool
13
+ except ModuleNotFoundError:
14
+ if _validators_directory not in sys.path:
15
+ sys.path.insert(0, _validators_directory)
16
+ from pyproject_config_discovery import find_pyproject_configuring_tool
17
+
18
+ try:
19
+ from hooks_constants.mypy_integration_constants import PYTHON_SOURCE_SUFFIX
20
+ from hooks_constants.pyproject_config_discovery_constants import RUFF_TOOL_TABLE_NAME
21
+ except ModuleNotFoundError:
22
+ if _hooks_directory not in sys.path:
23
+ sys.path.insert(0, _hooks_directory)
24
+ from hooks_constants.mypy_integration_constants import PYTHON_SOURCE_SUFFIX
25
+ from hooks_constants.pyproject_config_discovery_constants import RUFF_TOOL_TABLE_NAME
26
+
7
27
 
8
28
  @dataclass
9
29
  class RuffResult:
@@ -26,26 +46,165 @@ def check_ruff_available() -> bool:
26
46
  return False
27
47
 
28
48
 
29
- def run_ruff_check(files: list[Path]) -> RuffResult:
30
- """Run ruff check on files."""
31
- if not files:
32
- return RuffResult(passed=True, output="No files to check", fixed_count=0)
49
+ def find_pyproject_with_ruff_config(starting_file: Path) -> Path | None:
50
+ """Walk up from a starting file to a pyproject.toml that configures ruff.
33
51
 
34
- if not check_ruff_available():
35
- return RuffResult(passed=True, output="Ruff not installed - skipping", fixed_count=0)
52
+ The walk skips a pyproject.toml with no [tool.ruff] table, so a mypy-only
53
+ config is not handed to ``ruff --config``, which errors on the missing table.
54
+
55
+ Args:
56
+ starting_file: The file (or directory) the walk begins from.
57
+
58
+ Returns:
59
+ The first ``pyproject.toml`` Path declaring a ``[tool.ruff]`` table, or
60
+ ``None`` when none exists up to the filesystem root.
61
+ """
62
+ return find_pyproject_configuring_tool(starting_file, RUFF_TOOL_TABLE_NAME)
63
+
64
+
65
+ def _ruff_config_argument(config_source_path: Path | None) -> list[str]:
66
+ """Return the ``--config`` argument resolved from the original path, or empty.
67
+
68
+ ::
69
+
70
+ config_source_path resolves .../hooks/pyproject.toml
71
+ -> ["--config", ".../hooks/pyproject.toml"]
72
+ config_source_path given, no [tool.ruff] up-tree -> [] (native discovery)
73
+ config_source_path None -> [] (native discovery)
74
+
75
+ Args:
76
+ config_source_path: The original target path the staged copy stands in
77
+ for, or ``None`` for a native run.
78
+
79
+ Returns:
80
+ The ``--config`` argument vector, empty when no ruff config resolves.
81
+ """
82
+ if config_source_path is None:
83
+ return []
84
+ resolved_pyproject = find_pyproject_with_ruff_config(config_source_path)
85
+ if resolved_pyproject is None:
86
+ return []
87
+ return ["--config", str(resolved_pyproject)]
36
88
 
37
- py_files = [str(f) for f in files if f.suffix == ".py"]
38
- if not py_files:
39
- return RuffResult(passed=True, output="No Python files", fixed_count=0)
40
89
 
90
+ def _config_relative_stdin_filename(target_path: Path, config_directory: Path) -> str:
91
+ """Return *target_path* expressed relative to the ruff config directory.
92
+
93
+ ::
94
+
95
+ target .../hooks/validators/run_all_validators.py, config dir .../hooks
96
+ -> "validators/run_all_validators.py" (path-scoped ignore matches)
97
+ target off-tree from the config dir -> the bare basename
98
+
99
+ Ruff matches ``[tool.ruff.lint.per-file-ignores]`` keys against the path a
100
+ file presents relative to ruff's working directory, so a path-scoped key
101
+ matches only when the staged copy is graded under that same relative name.
102
+
103
+ Args:
104
+ target_path: The real target path the staged copy stands in for.
105
+ config_directory: The directory holding the resolved ruff pyproject.
106
+
107
+ Returns:
108
+ The config-relative POSIX path when the target sits under the config
109
+ directory, else the target's basename.
110
+ """
111
+ resolved_target = target_path.resolve()
112
+ try:
113
+ return resolved_target.relative_to(config_directory).as_posix()
114
+ except ValueError:
115
+ return resolved_target.name
116
+
117
+
118
+ def _read_staged_content(staged_file: Path) -> str | None:
119
+ """Return the staged copy's text content, or None when it cannot be read."""
120
+ try:
121
+ return staged_file.read_text(encoding="utf-8")
122
+ except OSError:
123
+ return None
124
+
125
+
126
+ def _single_staged_python_file(all_files: list[Path]) -> Path | None:
127
+ """Return the sole staged Python file, or None unless there is exactly one."""
128
+ all_python_files = [
129
+ each_file for each_file in all_files if each_file.suffix == PYTHON_SOURCE_SUFFIX
130
+ ]
131
+ if len(all_python_files) != 1:
132
+ return None
133
+ return all_python_files[0]
134
+
135
+
136
+ def _staged_ruff_command(resolved_pyproject: Path, stdin_filename: str) -> list[str]:
137
+ """Return the ruff argv that grades stdin under a config-relative name."""
41
138
  concise_output_arguments = ["--output-format", "concise"]
139
+ return [
140
+ "ruff",
141
+ "check",
142
+ "--config",
143
+ str(resolved_pyproject),
144
+ *concise_output_arguments,
145
+ "--stdin-filename",
146
+ stdin_filename,
147
+ "-",
148
+ ]
149
+
150
+
151
+ def _staged_ruff_result(
152
+ resolved_pyproject: Path, stdin_filename: str, staged_content: str
153
+ ) -> RuffResult:
154
+ """Pipe *staged_content* to ruff from the config directory and wrap the result."""
42
155
  result = subprocess.run(
43
- ["ruff", "check", *concise_output_arguments] + py_files,
156
+ _staged_ruff_command(resolved_pyproject, stdin_filename),
44
157
  check=False,
45
158
  capture_output=True,
46
159
  text=True,
160
+ input=staged_content,
161
+ cwd=str(resolved_pyproject.parent),
47
162
  )
163
+ return RuffResult(
164
+ passed=result.returncode == 0,
165
+ output=result.stdout or result.stderr or "No issues found",
166
+ fixed_count=0,
167
+ )
168
+
169
+
170
+ def _run_staged_ruff_check(
171
+ all_files: list[Path], config_source_path: Path
172
+ ) -> RuffResult | None:
173
+ """Grade one staged file under the config resolved from its original path.
174
+
175
+ The staged content is piped to ruff under ``--stdin-filename`` set to the
176
+ original target's config-relative name, so ruff applies path-scoped
177
+ per-file-ignores that never match the temporary copy's own location.
178
+ Returns None when the staged shape does not apply: no resolvable ruff
179
+ config, not exactly one Python file, or an unreadable staged copy.
180
+ """
181
+ resolved_pyproject = find_pyproject_with_ruff_config(config_source_path)
182
+ if resolved_pyproject is None:
183
+ return None
184
+ staged_file = _single_staged_python_file(all_files)
185
+ if staged_file is None:
186
+ return None
187
+ staged_content = _read_staged_content(staged_file)
188
+ if staged_content is None:
189
+ return None
190
+ stdin_filename = _config_relative_stdin_filename(
191
+ config_source_path, resolved_pyproject.parent
192
+ )
193
+ return _staged_ruff_result(resolved_pyproject, stdin_filename, staged_content)
194
+
48
195
 
196
+ def _run_native_ruff_check(
197
+ all_python_files: list[str], config_source_path: Path | None
198
+ ) -> RuffResult:
199
+ """Run ruff over *all_python_files* directly, resolving ``--config`` from the path."""
200
+ config_argument = _ruff_config_argument(config_source_path)
201
+ concise_output_arguments = ["--output-format", "concise"]
202
+ result = subprocess.run(
203
+ ["ruff", "check", *config_argument, *concise_output_arguments] + all_python_files,
204
+ check=False,
205
+ capture_output=True,
206
+ text=True,
207
+ )
49
208
  return RuffResult(
50
209
  passed=result.returncode == 0,
51
210
  output=result.stdout or result.stderr or "No issues found",
@@ -53,32 +212,61 @@ def run_ruff_check(files: list[Path]) -> RuffResult:
53
212
  )
54
213
 
55
214
 
215
+ def run_ruff_check(
216
+ all_files: list[Path], config_source_path: Path | None = None
217
+ ) -> RuffResult:
218
+ """Run ruff check on *all_files*, resolving config from *config_source_path*.
219
+
220
+ A given ``config_source_path`` grades the single staged copy under the project
221
+ config resolved from the original target, applying its path-scoped
222
+ per-file-ignores. Without it, the files are checked natively."""
223
+ if not all_files:
224
+ return RuffResult(passed=True, output="No files to check", fixed_count=0)
225
+
226
+ if not check_ruff_available():
227
+ return RuffResult(passed=True, output="Ruff not installed - skipping", fixed_count=0)
228
+
229
+ all_python_files = [
230
+ str(each_file) for each_file in all_files if each_file.suffix == PYTHON_SOURCE_SUFFIX
231
+ ]
232
+ if not all_python_files:
233
+ return RuffResult(passed=True, output="No Python files", fixed_count=0)
234
+ if config_source_path is not None:
235
+ staged_result = _run_staged_ruff_check(all_files, config_source_path)
236
+ if staged_result is not None:
237
+ return staged_result
238
+ return _run_native_ruff_check(all_python_files, config_source_path)
239
+
240
+
241
+ def _parse_fixed_count(fix_output: str) -> int:
242
+ """Return the count ruff reports on its ``Fixed N`` line, or 0 when absent."""
243
+ for each_line in fix_output.split("\n"):
244
+ if "Fixed" not in each_line:
245
+ continue
246
+ try:
247
+ return int(each_line.split()[1])
248
+ except (IndexError, ValueError):
249
+ return 0
250
+ return 0
251
+
252
+
56
253
  def run_ruff_fix(files: list[Path]) -> RuffResult:
57
254
  """Run ruff with --fix to auto-fix violations."""
58
255
  if not check_ruff_available():
59
256
  return RuffResult(passed=True, output="Ruff not installed", fixed_count=0)
60
-
61
- py_files = [str(f) for f in files if f.suffix == ".py"]
257
+ py_files = [
258
+ str(each_file) for each_file in files if each_file.suffix == PYTHON_SOURCE_SUFFIX
259
+ ]
62
260
  if not py_files:
63
261
  return RuffResult(passed=True, output="No Python files", fixed_count=0)
64
-
65
262
  result = subprocess.run(
66
263
  ["ruff", "check", "--fix"] + py_files,
67
264
  check=False,
68
265
  capture_output=True,
69
266
  text=True,
70
267
  )
71
-
72
- fixed_count = 0
73
- for line in result.stdout.split("\n"):
74
- if "Fixed" in line:
75
- try:
76
- fixed_count = int(line.split()[1])
77
- except (IndexError, ValueError):
78
- pass
79
-
80
268
  return RuffResult(
81
269
  passed=result.returncode == 0,
82
270
  output=result.stdout or "No fixes applied",
83
- fixed_count=fixed_count,
271
+ fixed_count=_parse_fixed_count(result.stdout),
84
272
  )
@@ -413,7 +413,9 @@ def run_comment_checks(files: List[Path]) -> ValidatorResult:
413
413
  )
414
414
 
415
415
 
416
- def run_ruff_checks(files: List[Path]) -> ValidatorResult:
416
+ def run_ruff_checks(
417
+ files: List[Path], config_source_path: Optional[Path] = None
418
+ ) -> ValidatorResult:
417
419
  """Run ruff for fast Python linting."""
418
420
  if not check_ruff_available():
419
421
  return ValidatorResult(
@@ -423,7 +425,7 @@ def run_ruff_checks(files: List[Path]) -> ValidatorResult:
423
425
  output="Ruff not installed - skipping",
424
426
  )
425
427
 
426
- result = run_ruff_check(files)
428
+ result = run_ruff_check(files, config_source_path)
427
429
 
428
430
  return ValidatorResult(
429
431
  name="Ruff",
@@ -433,7 +435,9 @@ def run_ruff_checks(files: List[Path]) -> ValidatorResult:
433
435
  )
434
436
 
435
437
 
436
- def run_mypy_checks(files: List[Path]) -> ValidatorResult:
438
+ def run_mypy_checks(
439
+ files: List[Path], config_source_path: Optional[Path] = None
440
+ ) -> ValidatorResult:
437
441
  """Run mypy for static type checking."""
438
442
  if not check_mypy_available():
439
443
  return ValidatorResult(
@@ -443,7 +447,7 @@ def run_mypy_checks(files: List[Path]) -> ValidatorResult:
443
447
  output="Mypy not installed - skipping",
444
448
  )
445
449
 
446
- result = run_mypy_check(files)
450
+ result = run_mypy_check(files, config_source_path)
447
451
 
448
452
  return ValidatorResult(
449
453
  name="Mypy",
@@ -727,15 +731,16 @@ def reconstruct_proposed_content(
727
731
  return apply_edits(existing_content, edits_for_tool(tool_name, dict(tool_input)))
728
732
 
729
733
 
730
- def run_file_scoped_validators(all_files: List[Path]) -> List[ValidatorResult]:
731
- """Run every validator scoped to individual files against *all_files*.
734
+ def run_file_scoped_validators(
735
+ all_files: List[Path], config_source_path: Optional[Path] = None
736
+ ) -> List[ValidatorResult]:
737
+ """Run every file-scoped validator against *all_files*.
732
738
 
733
- Excludes the branch-scoped File Structure and Git validators, which grade
734
- the whole project rather than a single proposed file.
739
+ Excludes the branch-scoped File Structure and Git validators.
735
740
 
736
741
  Args:
737
- all_files: The files under validation — a single reconstructed file in
738
- gate mode.
742
+ all_files: The files under validation — one reconstructed file in gate mode.
743
+ config_source_path: Original path ruff and mypy resolve their config from.
739
744
 
740
745
  Returns:
741
746
  One ValidatorResult per file-scoped validator, in run order.
@@ -744,8 +749,8 @@ def run_file_scoped_validators(all_files: List[Path]) -> List[ValidatorResult]:
744
749
  run_python_style_checks(all_files),
745
750
  run_test_safety_checks(all_files),
746
751
  run_react_checks(all_files),
747
- run_ruff_checks(all_files),
748
- run_mypy_checks(all_files),
752
+ run_ruff_checks(all_files, config_source_path),
753
+ run_mypy_checks(all_files, config_source_path),
749
754
  run_abbreviation_checks(all_files),
750
755
  run_pr_reference_checks(all_files),
751
756
  run_magic_value_checks(all_files),
@@ -765,7 +770,9 @@ def validate_proposed_file(
765
770
 
766
771
  Writes the content to a temporary file that carries the target's basename so
767
772
  suffix-based and test-name-based validator filtering matches the real path,
768
- then runs the file-scoped validators against it.
773
+ then runs the file-scoped validators against it. Ruff and mypy resolve their
774
+ config by walking up from *file_path*, so the staged copy is graded under the
775
+ project config the real path sits in rather than the temp directory's.
769
776
 
770
777
  Args:
771
778
  file_path: The destination path the write or edit targets.
@@ -778,7 +785,7 @@ def validate_proposed_file(
778
785
  with tempfile.TemporaryDirectory() as temporary_directory:
779
786
  temporary_file = Path(temporary_directory) / base_name
780
787
  temporary_file.write_text(proposed_content, encoding="utf-8")
781
- return run_file_scoped_validators([temporary_file])
788
+ return run_file_scoped_validators([temporary_file], Path(file_path))
782
789
 
783
790
 
784
791
  def _validator_summaries(results: List[ValidatorResult]) -> str: