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.
- package/CLAUDE.md +3 -2
- package/agents/CLAUDE.md +1 -0
- package/agents/code-verifier.md +3 -3
- package/agents/skill-writer-agent.md +84 -0
- package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +141 -41
- package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +29 -13
- package/docs/CLAUDE.md +1 -0
- package/docs/references/CLAUDE.md +1 -0
- package/docs/references/code-review-enforcement.md +97 -0
- package/docs/wsl-docker-cowork-starter-matrix.md +89 -0
- package/hooks/blocking/CLAUDE.md +8 -1
- package/hooks/blocking/code_review_enforcement_config_bootstrap.py +53 -0
- package/hooks/blocking/code_review_gate_deny.py +74 -0
- package/hooks/blocking/code_review_pr_create_gate.py +194 -0
- package/hooks/blocking/code_review_push_gate.py +140 -0
- package/hooks/blocking/code_review_stamp_directory_write_blocker.py +340 -0
- package/hooks/blocking/code_review_stamp_store.py +233 -0
- package/hooks/blocking/code_review_stamp_write_blocker_parts/__init__.py +7 -0
- package/hooks/blocking/code_review_stamp_write_blocker_parts/conftest.py +15 -0
- package/hooks/blocking/code_review_stamp_write_blocker_parts/obfuscated_stamp_path_reference.py +212 -0
- package/hooks/blocking/code_review_stamp_write_blocker_parts/split_directory_change_into_stamp.py +138 -0
- package/hooks/blocking/code_review_stamp_write_blocker_parts/test_obfuscated_stamp_path_reference.py +49 -0
- package/hooks/blocking/code_review_stamp_write_blocker_parts/test_split_directory_change_into_stamp.py +38 -0
- package/hooks/blocking/code_verifier_spawn_preflight_gate.py +39 -27
- package/hooks/blocking/config/code_review_enforcement_constants.py +110 -0
- package/hooks/blocking/config/test_code_review_enforcement_constants.py +108 -0
- package/hooks/blocking/config/verified_commit_constants.py +13 -9
- package/hooks/blocking/conftest.py +2 -0
- package/hooks/blocking/convergence_gate_blocker.py +112 -23
- package/hooks/blocking/destructive_command_blocker.py +19 -6
- package/hooks/blocking/pr_description_proof_of_work.py +52 -34
- package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +4 -1
- package/hooks/blocking/test_code_review_enforcement_config_bootstrap.py +62 -0
- package/hooks/blocking/test_code_review_gate_deny.py +54 -0
- package/hooks/blocking/test_code_review_pr_create_gate.py +185 -0
- package/hooks/blocking/test_code_review_push_gate.py +189 -0
- package/hooks/blocking/test_code_review_stamp_directory_write_blocker.py +180 -0
- package/hooks/blocking/test_code_review_stamp_store.py +205 -0
- package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +124 -2
- package/hooks/blocking/test_convergence_gate_blocker.py +153 -5
- package/hooks/blocking/test_destructive_command_blocker.py +1 -1
- package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +45 -0
- package/hooks/blocking/test_pr_description_proof_of_work.py +151 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +8 -8
- package/hooks/blocking/test_verification_verdict_store.py +920 -903
- package/hooks/blocking/test_volatile_path_in_post_blocker.py +114 -2
- package/hooks/blocking/verification_verdict_store.py +27 -5
- package/hooks/blocking/verified_commit_gate_parts/gated_invocations.py +29 -17
- package/hooks/blocking/verified_commit_gate_parts/tests/test_gated_invocations.py +35 -0
- package/hooks/blocking/volatile_path_in_post_blocker.py +69 -8
- package/hooks/git-hooks/git_hooks_constants/__init__.py +6 -0
- package/hooks/git-hooks/pre_push.py +89 -2
- package/hooks/git-hooks/test_pre_push.py +103 -0
- package/hooks/hooks.json +16 -1
- package/hooks/hooks_constants/CLAUDE.md +1 -0
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +8 -0
- package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -0
- package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +26 -11
- package/hooks/hooks_constants/convergence_gate_blocker_constants.py +20 -3
- package/hooks/hooks_constants/destructive_command_segment_constants.py +3 -1
- package/hooks/hooks_constants/pr_description_proof_of_work_constants.py +0 -4
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
- package/hooks/hooks_constants/pyproject_config_discovery_constants.py +16 -0
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +24 -0
- package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +6 -0
- package/hooks/hooks_constants/volatile_path_in_post_blocker_constants.py +8 -1
- package/hooks/validators/CLAUDE.md +1 -0
- package/hooks/validators/mypy_integration.py +63 -50
- package/hooks/validators/pyproject_config_discovery.py +101 -0
- package/hooks/validators/ruff_integration.py +211 -23
- package/hooks/validators/run_all_validators.py +21 -14
- package/hooks/validators/test_mypy_integration.py +32 -0
- package/hooks/validators/test_pyproject_config_discovery.py +94 -0
- package/hooks/validators/test_ruff_integration.py +68 -1
- package/hooks/validators/test_run_all_validators.py +25 -0
- package/hooks/validators/test_run_all_validators_config_discovery.py +123 -0
- package/package.json +1 -1
- package/rules/docstring-prose-matches-implementation.md +20 -43
- package/rules/durable-post-artifacts.md +7 -0
- package/scripts/codec_forwarding_test_support.py +83 -0
- package/scripts/conftest.py +8 -0
- package/scripts/dev_env_scripts_constants/CLAUDE.md +1 -1
- package/scripts/dev_env_scripts_constants/claude_chain_constants.py +44 -1
- package/scripts/dev_env_scripts_constants/code_review_constants.py +129 -12
- package/scripts/dev_env_scripts_constants/test_code_review_constants.py +55 -0
- package/scripts/invoke_code_review.py +550 -38
- package/scripts/resolve_worker_spawn.py +8 -1
- package/scripts/test_invoke_code_review.py +298 -4
- package/scripts/test_invoke_code_review_codec.py +77 -0
- package/scripts/test_resolve_worker_spawn_codec.py +101 -0
- package/skills/autoconverge/SKILL.md +9 -3
- package/skills/autoconverge/reference/convergence.md +2 -1
- package/skills/autoconverge/reference/multi-pr.md +6 -1
- package/skills/autoconverge/reference/stop-conditions.md +16 -10
- package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +1 -1
- package/skills/autoconverge/workflow/converge.codex-gate.test.mjs +175 -3
- package/skills/autoconverge/workflow/converge.contract.test.mjs +19 -0
- package/skills/autoconverge/workflow/converge.mjs +24 -8
- package/skills/autoconverge/workflow/converge_multi.mjs +7 -3
- package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +5 -0
- package/skills/fresh-branch/CLAUDE.md +2 -0
- package/skills/fresh-branch/SKILL.md +2 -0
- package/skills/fresh-branch/scripts/create_fresh_branch.py +78 -180
- package/skills/fresh-branch/scripts/fresh_branch_git_commands.py +285 -0
- package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +1 -0
- package/skills/fresh-branch/scripts/pytest.ini +4 -0
- package/skills/fresh-branch/scripts/test_create_fresh_branch.py +98 -0
- package/skills/fresh-branch/scripts/test_fresh_branch_git_commands.py +310 -0
- package/skills/prototype/SKILL.md +86 -0
- package/skills/prototype/reference/honest-limitations.md +23 -0
- package/skills/prototype/reference/promotion-tasks.md +23 -0
- package/skills/prototype/scripts/build_sandbox_settings.py +249 -0
- package/skills/prototype/scripts/conftest.py +15 -0
- package/skills/prototype/scripts/launch_sandbox.py +205 -0
- package/skills/prototype/scripts/probe_sandbox_safety.py +311 -0
- package/skills/prototype/scripts/prototype_scripts_constants/__init__.py +1 -0
- package/skills/prototype/scripts/prototype_scripts_constants/config/__init__.py +0 -0
- package/skills/prototype/scripts/prototype_scripts_constants/config/build_sandbox_settings_constants.py +41 -0
- package/skills/prototype/scripts/prototype_scripts_constants/config/launch_sandbox_constants.py +23 -0
- package/skills/prototype/scripts/prototype_scripts_constants/config/probe_sandbox_safety_constants.py +45 -0
- package/skills/prototype/scripts/prototype_scripts_constants/config/prototype_common_constants.py +10 -0
- package/skills/prototype/scripts/test_build_sandbox_settings.py +275 -0
- package/skills/prototype/scripts/test_launch_sandbox.py +303 -0
- package/skills/prototype/scripts/test_probe_sandbox_safety.py +284 -0
- package/skills/prototype/workflows/promotion.md +27 -0
- package/skills/prototype/workflows/sandbox.md +35 -0
- package/skills/skill-builder/CLAUDE.md +3 -3
- package/skills/skill-builder/SKILL.md +5 -5
- package/skills/skill-builder/references/CLAUDE.md +1 -1
- package/skills/skill-builder/references/delegation-map.md +3 -3
- package/skills/skill-builder/references/description-field.md +1 -1
- package/skills/skill-builder/references/skill-modularity.md +2 -3
- package/skills/skill-builder/workflows/CLAUDE.md +1 -1
- package/skills/skill-builder/workflows/improve-skill.md +1 -1
- package/skills/skill-builder/workflows/new-skill.md +2 -2
- 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
|
|
30
|
-
"""
|
|
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
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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=
|
|
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(
|
|
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(
|
|
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(
|
|
731
|
-
|
|
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
|
|
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 —
|
|
738
|
-
|
|
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
|