claude-dev-env 2.3.0 → 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 (136) hide show
  1. package/CLAUDE.md +3 -2
  2. package/agents/CLAUDE.md +1 -0
  3. package/agents/code-verifier.md +3 -3
  4. package/agents/skill-writer-agent.md +84 -0
  5. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +141 -41
  6. package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +29 -13
  7. package/docs/CLAUDE.md +1 -0
  8. package/docs/references/CLAUDE.md +1 -0
  9. package/docs/references/code-review-enforcement.md +97 -0
  10. package/docs/wsl-docker-cowork-starter-matrix.md +89 -0
  11. package/hooks/blocking/CLAUDE.md +8 -1
  12. package/hooks/blocking/code_review_enforcement_config_bootstrap.py +53 -0
  13. package/hooks/blocking/code_review_gate_deny.py +74 -0
  14. package/hooks/blocking/code_review_pr_create_gate.py +194 -0
  15. package/hooks/blocking/code_review_push_gate.py +140 -0
  16. package/hooks/blocking/code_review_stamp_directory_write_blocker.py +340 -0
  17. package/hooks/blocking/code_review_stamp_store.py +233 -0
  18. package/hooks/blocking/code_review_stamp_write_blocker_parts/__init__.py +7 -0
  19. package/hooks/blocking/code_review_stamp_write_blocker_parts/conftest.py +15 -0
  20. package/hooks/blocking/code_review_stamp_write_blocker_parts/obfuscated_stamp_path_reference.py +212 -0
  21. package/hooks/blocking/code_review_stamp_write_blocker_parts/split_directory_change_into_stamp.py +138 -0
  22. package/hooks/blocking/code_review_stamp_write_blocker_parts/test_obfuscated_stamp_path_reference.py +49 -0
  23. package/hooks/blocking/code_review_stamp_write_blocker_parts/test_split_directory_change_into_stamp.py +38 -0
  24. package/hooks/blocking/code_verifier_spawn_preflight_gate.py +39 -27
  25. package/hooks/blocking/config/code_review_enforcement_constants.py +110 -0
  26. package/hooks/blocking/config/test_code_review_enforcement_constants.py +108 -0
  27. package/hooks/blocking/config/verified_commit_constants.py +13 -9
  28. package/hooks/blocking/conftest.py +2 -0
  29. package/hooks/blocking/convergence_gate_blocker.py +112 -23
  30. package/hooks/blocking/destructive_command_blocker.py +19 -6
  31. package/hooks/blocking/pr_description_proof_of_work.py +52 -34
  32. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +4 -1
  33. package/hooks/blocking/test_code_review_enforcement_config_bootstrap.py +62 -0
  34. package/hooks/blocking/test_code_review_gate_deny.py +54 -0
  35. package/hooks/blocking/test_code_review_pr_create_gate.py +185 -0
  36. package/hooks/blocking/test_code_review_push_gate.py +189 -0
  37. package/hooks/blocking/test_code_review_stamp_directory_write_blocker.py +180 -0
  38. package/hooks/blocking/test_code_review_stamp_store.py +205 -0
  39. package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +124 -2
  40. package/hooks/blocking/test_convergence_gate_blocker.py +153 -5
  41. package/hooks/blocking/test_destructive_command_blocker.py +1 -1
  42. package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +45 -0
  43. package/hooks/blocking/test_pr_description_proof_of_work.py +151 -0
  44. package/hooks/blocking/test_pre_tool_use_dispatcher.py +8 -8
  45. package/hooks/blocking/test_verification_verdict_store.py +920 -903
  46. package/hooks/blocking/test_volatile_path_in_post_blocker.py +114 -2
  47. package/hooks/blocking/verification_verdict_store.py +27 -5
  48. package/hooks/blocking/verified_commit_gate_parts/gated_invocations.py +29 -17
  49. package/hooks/blocking/verified_commit_gate_parts/tests/test_gated_invocations.py +35 -0
  50. package/hooks/blocking/volatile_path_in_post_blocker.py +69 -8
  51. package/hooks/git-hooks/git_hooks_constants/__init__.py +6 -0
  52. package/hooks/git-hooks/pre_push.py +89 -2
  53. package/hooks/git-hooks/test_pre_push.py +103 -0
  54. package/hooks/hooks.json +16 -1
  55. package/hooks/hooks_constants/CLAUDE.md +1 -0
  56. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +8 -0
  57. package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -0
  58. package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +26 -11
  59. package/hooks/hooks_constants/convergence_gate_blocker_constants.py +20 -3
  60. package/hooks/hooks_constants/destructive_command_segment_constants.py +3 -1
  61. package/hooks/hooks_constants/pr_description_proof_of_work_constants.py +0 -4
  62. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
  63. package/hooks/hooks_constants/pyproject_config_discovery_constants.py +16 -0
  64. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +24 -0
  65. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +6 -0
  66. package/hooks/hooks_constants/volatile_path_in_post_blocker_constants.py +8 -1
  67. package/hooks/validators/CLAUDE.md +1 -0
  68. package/hooks/validators/mypy_integration.py +63 -50
  69. package/hooks/validators/pyproject_config_discovery.py +101 -0
  70. package/hooks/validators/ruff_integration.py +211 -23
  71. package/hooks/validators/run_all_validators.py +21 -14
  72. package/hooks/validators/test_mypy_integration.py +32 -0
  73. package/hooks/validators/test_pyproject_config_discovery.py +94 -0
  74. package/hooks/validators/test_ruff_integration.py +68 -1
  75. package/hooks/validators/test_run_all_validators.py +25 -0
  76. package/hooks/validators/test_run_all_validators_config_discovery.py +123 -0
  77. package/package.json +1 -1
  78. package/rules/docstring-prose-matches-implementation.md +20 -43
  79. package/rules/durable-post-artifacts.md +7 -0
  80. package/scripts/codec_forwarding_test_support.py +83 -0
  81. package/scripts/conftest.py +8 -0
  82. package/scripts/dev_env_scripts_constants/CLAUDE.md +1 -1
  83. package/scripts/dev_env_scripts_constants/claude_chain_constants.py +44 -1
  84. package/scripts/dev_env_scripts_constants/code_review_constants.py +129 -12
  85. package/scripts/dev_env_scripts_constants/test_code_review_constants.py +55 -0
  86. package/scripts/invoke_code_review.py +550 -38
  87. package/scripts/resolve_worker_spawn.py +8 -1
  88. package/scripts/test_invoke_code_review.py +298 -4
  89. package/scripts/test_invoke_code_review_codec.py +77 -0
  90. package/scripts/test_resolve_worker_spawn_codec.py +101 -0
  91. package/skills/autoconverge/SKILL.md +9 -3
  92. package/skills/autoconverge/reference/convergence.md +2 -1
  93. package/skills/autoconverge/reference/multi-pr.md +6 -1
  94. package/skills/autoconverge/reference/stop-conditions.md +16 -10
  95. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +1 -1
  96. package/skills/autoconverge/workflow/converge.codex-gate.test.mjs +175 -3
  97. package/skills/autoconverge/workflow/converge.contract.test.mjs +19 -0
  98. package/skills/autoconverge/workflow/converge.mjs +24 -8
  99. package/skills/autoconverge/workflow/converge_multi.mjs +7 -3
  100. package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +5 -0
  101. package/skills/fresh-branch/CLAUDE.md +2 -0
  102. package/skills/fresh-branch/SKILL.md +2 -0
  103. package/skills/fresh-branch/scripts/create_fresh_branch.py +78 -180
  104. package/skills/fresh-branch/scripts/fresh_branch_git_commands.py +285 -0
  105. package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +1 -0
  106. package/skills/fresh-branch/scripts/pytest.ini +4 -0
  107. package/skills/fresh-branch/scripts/test_create_fresh_branch.py +98 -0
  108. package/skills/fresh-branch/scripts/test_fresh_branch_git_commands.py +310 -0
  109. package/skills/prototype/SKILL.md +86 -0
  110. package/skills/prototype/reference/honest-limitations.md +23 -0
  111. package/skills/prototype/reference/promotion-tasks.md +23 -0
  112. package/skills/prototype/scripts/build_sandbox_settings.py +249 -0
  113. package/skills/prototype/scripts/conftest.py +15 -0
  114. package/skills/prototype/scripts/launch_sandbox.py +205 -0
  115. package/skills/prototype/scripts/probe_sandbox_safety.py +311 -0
  116. package/skills/prototype/scripts/prototype_scripts_constants/__init__.py +1 -0
  117. package/skills/prototype/scripts/prototype_scripts_constants/config/__init__.py +0 -0
  118. package/skills/prototype/scripts/prototype_scripts_constants/config/build_sandbox_settings_constants.py +41 -0
  119. package/skills/prototype/scripts/prototype_scripts_constants/config/launch_sandbox_constants.py +23 -0
  120. package/skills/prototype/scripts/prototype_scripts_constants/config/probe_sandbox_safety_constants.py +45 -0
  121. package/skills/prototype/scripts/prototype_scripts_constants/config/prototype_common_constants.py +10 -0
  122. package/skills/prototype/scripts/test_build_sandbox_settings.py +275 -0
  123. package/skills/prototype/scripts/test_launch_sandbox.py +303 -0
  124. package/skills/prototype/scripts/test_probe_sandbox_safety.py +284 -0
  125. package/skills/prototype/workflows/promotion.md +27 -0
  126. package/skills/prototype/workflows/sandbox.md +35 -0
  127. package/skills/skill-builder/CLAUDE.md +3 -3
  128. package/skills/skill-builder/SKILL.md +5 -5
  129. package/skills/skill-builder/references/CLAUDE.md +1 -1
  130. package/skills/skill-builder/references/delegation-map.md +3 -3
  131. package/skills/skill-builder/references/description-field.md +1 -1
  132. package/skills/skill-builder/references/skill-modularity.md +2 -3
  133. package/skills/skill-builder/workflows/CLAUDE.md +1 -1
  134. package/skills/skill-builder/workflows/improve-skill.md +1 -1
  135. package/skills/skill-builder/workflows/new-skill.md +2 -2
  136. package/skills/team-advisor/SKILL.md +2 -2
@@ -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:
@@ -165,6 +165,37 @@ def test_run_mypy_check_accepts_relative_path_under_nested_root(
165
165
  assert mypy_result.passed, mypy_result.output
166
166
 
167
167
 
168
+ def test_run_mypy_check_applies_config_resolved_from_config_source_path(
169
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
170
+ ) -> None:
171
+ """A staged file is checked under the config resolved from the original path.
172
+
173
+ A strict ``[tool.mypy]`` config flags an untyped def only when
174
+ ``config_source_path`` resolves it; native discovery from the detached
175
+ file finds no config and passes.
176
+ """
177
+ strict_repo = tmp_path / "strict_repo"
178
+ strict_repo.mkdir()
179
+ (strict_repo / "pyproject.toml").write_text(
180
+ "[tool.mypy]\ndisallow_untyped_defs = true\n", encoding="utf-8"
181
+ )
182
+ detached_file = tmp_path / "detached" / "untyped.py"
183
+ detached_file.parent.mkdir(parents=True)
184
+ detached_file.write_text(
185
+ "def annotate_nothing(value):\n return value\n", encoding="utf-8"
186
+ )
187
+
188
+ monkeypatch.chdir(tmp_path)
189
+
190
+ without_source = run_mypy_check([detached_file])
191
+ with_source = run_mypy_check(
192
+ [detached_file], config_source_path=strict_repo / "pyproject.toml"
193
+ )
194
+
195
+ assert without_source.passed, without_source.output
196
+ assert not with_source.passed
197
+
198
+
168
199
  def test_mypy_result_dataclass() -> None:
169
200
  """Test MypyResult dataclass creation."""
170
201
  result = MypyResult(passed=True, output="test", error_count=0)
@@ -183,6 +214,7 @@ def test_run_mypy_check_returns_passed_for_empty_files() -> None:
183
214
  """Test that run_mypy_check passes with no files."""
184
215
  result = run_mypy_check([])
185
216
  assert result.passed is True
217
+ assert result.error_count == 0
186
218
  assert "No files" in result.output
187
219
 
188
220
 
@@ -0,0 +1,94 @@
1
+ """Tests for the shared pyproject config-discovery walk-up primitive.
2
+
3
+ The primitive walks up from a starting path and returns the first pyproject.toml
4
+ whose ``[tool.<name>]`` table exists, so each tool matches only a config that
5
+ actually configures it — a mypy-only pyproject is no ruff config.
6
+ """
7
+
8
+ from pathlib import Path
9
+
10
+ from hooks_constants.pyproject_config_discovery_constants import (
11
+ MYPY_TOOL_TABLE_NAME,
12
+ RUFF_TOOL_TABLE_NAME,
13
+ )
14
+
15
+ from .pyproject_config_discovery import (
16
+ ancestor_directories,
17
+ find_pyproject_configuring_tool,
18
+ )
19
+
20
+
21
+ def test_ancestor_directories_lists_directory_then_parents_nearest_first(
22
+ tmp_path: Path,
23
+ ) -> None:
24
+ nested_directory = tmp_path / "outer" / "inner"
25
+ nested_directory.mkdir(parents=True)
26
+ nested_file = nested_directory / "module.py"
27
+ nested_file.write_text("sample_number: int = 1\n", encoding="utf-8")
28
+
29
+ walked = ancestor_directories(nested_file)
30
+
31
+ assert walked[0] == nested_directory.resolve()
32
+ assert walked[1] == (tmp_path / "outer").resolve()
33
+
34
+
35
+ def test_find_pyproject_configuring_tool_returns_table_owning_pyproject(
36
+ tmp_path: Path,
37
+ ) -> None:
38
+ project_root = tmp_path / "project"
39
+ nested_directory = project_root / "src" / "deep"
40
+ nested_directory.mkdir(parents=True)
41
+ owning_pyproject = project_root / "pyproject.toml"
42
+ owning_pyproject.write_text("[tool.ruff.lint]\nselect = ['B']\n", encoding="utf-8")
43
+ target_file = nested_directory / "module.py"
44
+
45
+ found = find_pyproject_configuring_tool(target_file, RUFF_TOOL_TABLE_NAME)
46
+
47
+ assert found == owning_pyproject
48
+
49
+
50
+ def test_find_pyproject_configuring_tool_skips_pyproject_without_the_table(
51
+ tmp_path: Path,
52
+ ) -> None:
53
+ outer_root = tmp_path / "outer"
54
+ inner_root = outer_root / "inner"
55
+ inner_root.mkdir(parents=True)
56
+ outer_pyproject = outer_root / "pyproject.toml"
57
+ outer_pyproject.write_text("[tool.mypy]\nignore_missing_imports = true\n", encoding="utf-8")
58
+ (inner_root / "pyproject.toml").write_text("[project]\nname = 'inner'\n", encoding="utf-8")
59
+ target_file = inner_root / "module.py"
60
+
61
+ found = find_pyproject_configuring_tool(target_file, MYPY_TOOL_TABLE_NAME)
62
+
63
+ assert found == outer_pyproject
64
+
65
+
66
+ def test_find_pyproject_configuring_tool_is_table_specific(tmp_path: Path) -> None:
67
+ project_root = tmp_path / "mypy_only"
68
+ project_root.mkdir()
69
+ (project_root / "pyproject.toml").write_text(
70
+ "[tool.mypy]\nignore_missing_imports = true\n", encoding="utf-8"
71
+ )
72
+ target_file = project_root / "module.py"
73
+
74
+ assert find_pyproject_configuring_tool(target_file, MYPY_TOOL_TABLE_NAME) is not None
75
+ assert find_pyproject_configuring_tool(target_file, RUFF_TOOL_TABLE_NAME) is None
76
+
77
+
78
+ def test_find_pyproject_configuring_tool_returns_none_when_no_match(tmp_path: Path) -> None:
79
+ isolated_directory = tmp_path / "isolated"
80
+ isolated_directory.mkdir()
81
+ target_file = isolated_directory / "module.py"
82
+
83
+ assert find_pyproject_configuring_tool(target_file, RUFF_TOOL_TABLE_NAME) is None
84
+
85
+
86
+ def test_find_pyproject_configuring_tool_treats_malformed_toml_as_no_match(
87
+ tmp_path: Path,
88
+ ) -> None:
89
+ project_root = tmp_path / "broken"
90
+ project_root.mkdir()
91
+ (project_root / "pyproject.toml").write_text("[tool.ruff\nbroken = true\n", encoding="utf-8")
92
+ target_file = project_root / "module.py"
93
+
94
+ assert find_pyproject_configuring_tool(target_file, RUFF_TOOL_TABLE_NAME) is None