claude-dev-env 1.76.0 → 1.78.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/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -0
- package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +8 -4
- package/hooks/blocking/CLAUDE.md +3 -1
- package/hooks/blocking/code_rules_dead_module_constant.py +215 -59
- package/hooks/blocking/code_rules_dead_split_branch.py +225 -0
- package/hooks/blocking/code_rules_docstrings.py +730 -0
- package/hooks/blocking/code_rules_enforcer.py +39 -0
- package/hooks/blocking/code_rules_paired_test.py +299 -0
- package/hooks/blocking/code_rules_string_magic.py +71 -1
- package/hooks/blocking/convergence_gate_blocker.py +24 -15
- package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +89 -2
- package/hooks/blocking/test_code_rules_enforcer_dead_split_branch.py +105 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_args_span_scope.py +174 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_cardinal_family.py +176 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_mark_glyph_enumeration.py +262 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_raises_largezipfile.py +226 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_runon_sentence.py +267 -0
- package/hooks/blocking/test_code_rules_enforcer_paired_test.py +149 -0
- package/hooks/blocking/test_code_rules_enforcer_whitespace_indentation_magic.py +74 -0
- package/hooks/blocking/test_convergence_gate_blocker.py +71 -0
- package/hooks/hooks_constants/CLAUDE.md +1 -0
- package/hooks/hooks_constants/blocking_check_limits.py +51 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +49 -0
- package/hooks/hooks_constants/paired_test_coverage_constants.py +27 -0
- package/package.json +1 -1
- package/rules/CLAUDE.md +2 -0
- package/rules/docstring-prose-matches-implementation.md +5 -1
- package/rules/file-global-constants.md +2 -2
- package/rules/package-inventory-stale-entry.md +8 -0
- package/rules/paired-test-coverage.md +28 -0
- package/rules/plain-illustrative-docstrings.md +56 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""Tests for check_docstring_runon_sentence — the plain-illustrative-docstrings backstop.
|
|
2
|
+
|
|
3
|
+
A readable docstring breaks its narrative into short sentences a general developer
|
|
4
|
+
follows on first read. The one mechanical mark of a dense wall is a single run-on
|
|
5
|
+
sentence: many words strung together with an em-dash or a semicolon. This check
|
|
6
|
+
flags that mark in module, class, and public-function docstring narrative prose,
|
|
7
|
+
and leaves the "is it illustrative" judgment to the audit lane.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import importlib.util
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from types import ModuleType
|
|
16
|
+
|
|
17
|
+
_HOOKS_DIRECTORY = str(Path(__file__).resolve().parent.parent)
|
|
18
|
+
if _HOOKS_DIRECTORY not in sys.path:
|
|
19
|
+
sys.path.insert(0, _HOOKS_DIRECTORY)
|
|
20
|
+
|
|
21
|
+
from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
|
|
22
|
+
ALL_DOCSTRING_ARGS_SECTION_HEADERS,
|
|
23
|
+
ALL_DOCSTRING_TERMINATING_SECTION_HEADERS,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _load_enforcer_module() -> ModuleType:
|
|
28
|
+
module_path = Path(__file__).parent / "code_rules_enforcer.py"
|
|
29
|
+
spec = importlib.util.spec_from_file_location("code_rules_enforcer", module_path)
|
|
30
|
+
assert spec is not None
|
|
31
|
+
assert spec.loader is not None
|
|
32
|
+
module = importlib.util.module_from_spec(spec)
|
|
33
|
+
spec.loader.exec_module(module)
|
|
34
|
+
return module
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
code_rules_enforcer = _load_enforcer_module()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def check_docstring_runon_sentence(content: str, file_path: str) -> list[str]:
|
|
41
|
+
return code_rules_enforcer.check_docstring_runon_sentence(content, file_path)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def validate_content(content: str, file_path: str, old_content: str) -> list[str]:
|
|
45
|
+
return code_rules_enforcer.validate_content(content, file_path, old_content)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
PRODUCTION_FILE_PATH = "/project/src/run_lifecycle.py"
|
|
49
|
+
TEST_FILE_PATH = "/project/src/test_run_lifecycle.py"
|
|
50
|
+
HOOK_INFRASTRUCTURE_PATH = "/home/user/.claude/hooks/blocking/example.py"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _run_lifecycle_module() -> str:
|
|
54
|
+
return (
|
|
55
|
+
'"""Generic run-lifecycle plumbing for the STP version promoter run.\n'
|
|
56
|
+
"\n"
|
|
57
|
+
"Owns the SIGINT install/restore/installability check, the atexit terminal-record\n"
|
|
58
|
+
"registration, and the interrupted-run finalizer — the non-promoter-specific\n"
|
|
59
|
+
"machinery that brackets a run so the JSONL artifact always carries a terminal\n"
|
|
60
|
+
"record and an in-flight theme record on interrupt.\n"
|
|
61
|
+
'"""\n'
|
|
62
|
+
"\n"
|
|
63
|
+
"def install_signal_handler() -> None:\n"
|
|
64
|
+
" return None\n"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _approved_bar_module() -> str:
|
|
69
|
+
return (
|
|
70
|
+
'"""Make sure a run\'s log always records how it ended.\n'
|
|
71
|
+
"\n"
|
|
72
|
+
"So when you reopen the report, the last line tells you the truth: the run\n"
|
|
73
|
+
"finished cleanly, or you hit Ctrl-C while theme 42 was processing, or it died\n"
|
|
74
|
+
"on an unexpected error. Without this, a killed run looks identical to a clean\n"
|
|
75
|
+
"one — and you're debugging blind.\n"
|
|
76
|
+
'"""\n'
|
|
77
|
+
"\n"
|
|
78
|
+
"def describe_outcome() -> None:\n"
|
|
79
|
+
" return None\n"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _class_docstring_runon() -> str:
|
|
84
|
+
return (
|
|
85
|
+
"class RunRecorder:\n"
|
|
86
|
+
' """Owns the SIGINT install and restore step, the atexit record hook, and the\n'
|
|
87
|
+
" interrupted-run finalizer — the plumbing that brackets a run so the artifact\n"
|
|
88
|
+
" always carries a terminal record and an in-flight theme record on interrupt.\n"
|
|
89
|
+
' """\n'
|
|
90
|
+
"\n"
|
|
91
|
+
" def record(self) -> None:\n"
|
|
92
|
+
" return None\n"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _normal_short_docstring() -> str:
|
|
97
|
+
return (
|
|
98
|
+
"def summarize_run() -> str:\n"
|
|
99
|
+
' """Write the run summary.\n'
|
|
100
|
+
"\n"
|
|
101
|
+
" Each line names one theme and its final outcome.\n"
|
|
102
|
+
' """\n'
|
|
103
|
+
' return ""\n'
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _long_args_line_outside_narrative() -> str:
|
|
108
|
+
return (
|
|
109
|
+
"def configure_run(option_name: str) -> None:\n"
|
|
110
|
+
' """Set one option for the run.\n'
|
|
111
|
+
"\n"
|
|
112
|
+
" Args:\n"
|
|
113
|
+
" option_name: the name of the option to set, described here in a needlessly\n"
|
|
114
|
+
" long sentence that runs far past thirty words and even carries an\n"
|
|
115
|
+
" em-dash — yet the check stays silent because every word here sits after\n"
|
|
116
|
+
" the Args header and therefore outside the inspected narrative entirely.\n"
|
|
117
|
+
' """\n'
|
|
118
|
+
" return None\n"
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _private_function_runon() -> str:
|
|
123
|
+
return (
|
|
124
|
+
"def _install_handlers() -> None:\n"
|
|
125
|
+
' """Owns the SIGINT install and restore step, the atexit record hook, and the\n'
|
|
126
|
+
" interrupted-run finalizer — the plumbing that brackets a run so the artifact\n"
|
|
127
|
+
" always carries a terminal record and an in-flight theme record on interrupt.\n"
|
|
128
|
+
' """\n'
|
|
129
|
+
" return None\n"
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _property_method_runon() -> str:
|
|
134
|
+
return (
|
|
135
|
+
"class Widget:\n"
|
|
136
|
+
" @property\n"
|
|
137
|
+
" def label(self) -> str:\n"
|
|
138
|
+
' """Owns the SIGINT install and restore step, the atexit record hook, and the\n'
|
|
139
|
+
" interrupted-run finalizer — the plumbing that brackets a run so the artifact\n"
|
|
140
|
+
" always carries a terminal record and an in-flight theme record on interrupt.\n"
|
|
141
|
+
' """\n'
|
|
142
|
+
' return "label"\n'
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_should_flag_run_lifecycle_module_docstring_wall() -> None:
|
|
147
|
+
issues = check_docstring_runon_sentence(_run_lifecycle_module(), PRODUCTION_FILE_PATH)
|
|
148
|
+
assert any("run-on" in each for each in issues), (
|
|
149
|
+
f"Expected the module-docstring wall to flag, got: {issues!r}"
|
|
150
|
+
)
|
|
151
|
+
assert any("module" in each for each in issues)
|
|
152
|
+
assert len(issues) == 1
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def test_should_not_flag_approved_bar_module_docstring() -> None:
|
|
156
|
+
issues = check_docstring_runon_sentence(_approved_bar_module(), PRODUCTION_FILE_PATH)
|
|
157
|
+
assert issues == [], f"The approved bar must pass clean, got: {issues!r}"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def test_should_flag_class_docstring_wall() -> None:
|
|
161
|
+
issues = check_docstring_runon_sentence(_class_docstring_runon(), PRODUCTION_FILE_PATH)
|
|
162
|
+
assert any("RunRecorder" in each for each in issues), (
|
|
163
|
+
f"Expected the class-docstring wall to flag, got: {issues!r}"
|
|
164
|
+
)
|
|
165
|
+
assert len(issues) == 1
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def test_should_not_flag_normal_short_docstring() -> None:
|
|
169
|
+
issues = check_docstring_runon_sentence(_normal_short_docstring(), PRODUCTION_FILE_PATH)
|
|
170
|
+
assert issues == [], f"A short multi-sentence docstring must not flag, got: {issues!r}"
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def test_should_not_flag_long_args_line_outside_narrative() -> None:
|
|
174
|
+
issues = check_docstring_runon_sentence(
|
|
175
|
+
_long_args_line_outside_narrative(), PRODUCTION_FILE_PATH
|
|
176
|
+
)
|
|
177
|
+
assert issues == [], f"The Args section is outside the narrative, got: {issues!r}"
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def test_should_skip_private_function() -> None:
|
|
181
|
+
issues = check_docstring_runon_sentence(_private_function_runon(), PRODUCTION_FILE_PATH)
|
|
182
|
+
assert issues == [], f"Private functions are out of scope, got: {issues!r}"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def test_should_skip_property_method() -> None:
|
|
186
|
+
issues = check_docstring_runon_sentence(_property_method_runon(), PRODUCTION_FILE_PATH)
|
|
187
|
+
assert issues == [], f"@property methods are exempt, got: {issues!r}"
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def test_should_skip_test_file() -> None:
|
|
191
|
+
issues = check_docstring_runon_sentence(_run_lifecycle_module(), TEST_FILE_PATH)
|
|
192
|
+
assert issues == [], f"Test files exempt, got: {issues!r}"
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def test_should_skip_hook_infrastructure() -> None:
|
|
196
|
+
issues = check_docstring_runon_sentence(_run_lifecycle_module(), HOOK_INFRASTRUCTURE_PATH)
|
|
197
|
+
assert issues == [], f"Hook infrastructure exempt, got: {issues!r}"
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def test_should_handle_syntax_error_gracefully() -> None:
|
|
201
|
+
issues = check_docstring_runon_sentence("def broken(\n", PRODUCTION_FILE_PATH)
|
|
202
|
+
assert issues == [], f"Syntax error must yield no issues, got: {issues!r}"
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def test_validate_content_surfaces_docstring_runon_wall() -> None:
|
|
206
|
+
issues = validate_content(_run_lifecycle_module(), PRODUCTION_FILE_PATH, old_content="")
|
|
207
|
+
matching_issues = [each for each in issues if "run-on" in each]
|
|
208
|
+
assert matching_issues, f"Expected validate_content to surface the run-on wall, got: {issues!r}"
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _attached_cli_flag_double_hyphen() -> str:
|
|
212
|
+
return (
|
|
213
|
+
"def gather_remote_reviews() -> None:\n"
|
|
214
|
+
' """Read every review the bot left across all the pages it produced and\n'
|
|
215
|
+
" gather them into one ordered list by paging through the endpoint with the\n"
|
|
216
|
+
" --paginate --slurp flags so the newest review across the whole set rises to\n"
|
|
217
|
+
" the top of the report for the reader.\n"
|
|
218
|
+
' """\n'
|
|
219
|
+
" return None\n"
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _spaced_double_hyphen_joiner() -> str:
|
|
224
|
+
return (
|
|
225
|
+
"def gather_remote_reviews() -> None:\n"
|
|
226
|
+
' """Read every review the bot left across all the pages it produced and\n'
|
|
227
|
+
" gather them into one ordered list so the newest review rises to the top of\n"
|
|
228
|
+
" the report -- the single line a tired reader scans first to learn how the\n"
|
|
229
|
+
" whole run actually ended today.\n"
|
|
230
|
+
' """\n'
|
|
231
|
+
" return None\n"
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def test_should_not_flag_attached_cli_flag_double_hyphen() -> None:
|
|
236
|
+
issues = check_docstring_runon_sentence(
|
|
237
|
+
_attached_cli_flag_double_hyphen(), PRODUCTION_FILE_PATH
|
|
238
|
+
)
|
|
239
|
+
assert issues == [], (
|
|
240
|
+
"An attached --flag token is not a clause joiner and must not flag, "
|
|
241
|
+
f"got: {issues!r}"
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def test_should_flag_spaced_double_hyphen_joiner() -> None:
|
|
246
|
+
issues = check_docstring_runon_sentence(
|
|
247
|
+
_spaced_double_hyphen_joiner(), PRODUCTION_FILE_PATH
|
|
248
|
+
)
|
|
249
|
+
assert any("run-on" in each for each in issues), (
|
|
250
|
+
f"A spaced double-hyphen joiner past the word limit must flag, got: {issues!r}"
|
|
251
|
+
)
|
|
252
|
+
assert len(issues) == 1
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def test_docstring_names_every_inspected_section_header() -> None:
|
|
256
|
+
docstring = code_rules_enforcer.check_docstring_runon_sentence.__doc__
|
|
257
|
+
assert docstring is not None
|
|
258
|
+
inspected_headers = set(ALL_DOCSTRING_ARGS_SECTION_HEADERS) | set(
|
|
259
|
+
ALL_DOCSTRING_TERMINATING_SECTION_HEADERS
|
|
260
|
+
)
|
|
261
|
+
missing_headers = sorted(
|
|
262
|
+
each_header for each_header in inspected_headers if each_header not in docstring
|
|
263
|
+
)
|
|
264
|
+
assert missing_headers == [], (
|
|
265
|
+
"The docstring must name every section header the body cuts the narrative on; "
|
|
266
|
+
f"missing: {missing_headers!r}"
|
|
267
|
+
)
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Behavioral tests for the public-function paired-test coverage check.
|
|
2
|
+
|
|
3
|
+
Drives ``check_public_function_missing_paired_test`` over a real temporary
|
|
4
|
+
filesystem whose path carries no ``test``/``hooks``/``config`` segment, so the
|
|
5
|
+
module under test is classified as ordinary production code while its on-disk
|
|
6
|
+
paired test files are read exactly as they would be in a real package.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import sys
|
|
12
|
+
import tempfile
|
|
13
|
+
from collections.abc import Iterator
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import pytest
|
|
17
|
+
|
|
18
|
+
_BLOCKING_DIRECTORY = Path(__file__).resolve().parent
|
|
19
|
+
if str(_BLOCKING_DIRECTORY) not in sys.path:
|
|
20
|
+
sys.path.insert(0, str(_BLOCKING_DIRECTORY))
|
|
21
|
+
|
|
22
|
+
from code_rules_paired_test import ( # noqa: E402
|
|
23
|
+
check_public_function_missing_paired_test,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
_TWO_PUBLIC_FUNCTIONS = "def alpha():\n return 1\n\n\ndef beta():\n return 2\n"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@pytest.fixture
|
|
30
|
+
def neutral_package_directory() -> Iterator[Path]:
|
|
31
|
+
"""Yield a ``pkg`` directory under a neutral temp root with a ``tests`` child.
|
|
32
|
+
|
|
33
|
+
The root prefix carries no ``test`` segment, so the module path the check
|
|
34
|
+
receives is classified as production code rather than a test file.
|
|
35
|
+
"""
|
|
36
|
+
with tempfile.TemporaryDirectory(prefix="paircov_") as root_name:
|
|
37
|
+
package_directory = Path(root_name) / "pkg"
|
|
38
|
+
(package_directory / "tests").mkdir(parents=True)
|
|
39
|
+
yield package_directory
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _write_test_file(package_directory: Path, filename: str, body: str) -> None:
|
|
43
|
+
(package_directory / "tests" / filename).write_text(body, encoding="utf-8")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_flags_public_function_absent_from_established_suite(
|
|
47
|
+
neutral_package_directory: Path,
|
|
48
|
+
) -> None:
|
|
49
|
+
_write_test_file(
|
|
50
|
+
neutral_package_directory,
|
|
51
|
+
"test_mod.py",
|
|
52
|
+
"from pkg.mod import alpha\n\ndef test_alpha():\n assert alpha() == 1\n",
|
|
53
|
+
)
|
|
54
|
+
all_issues = check_public_function_missing_paired_test(
|
|
55
|
+
_TWO_PUBLIC_FUNCTIONS, str(neutral_package_directory / "mod.py")
|
|
56
|
+
)
|
|
57
|
+
assert len(all_issues) == 1
|
|
58
|
+
assert "beta" in all_issues[0]
|
|
59
|
+
assert "paired test suite" in all_issues[0]
|
|
60
|
+
assert "alpha" not in all_issues[0]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_clean_when_suite_covers_every_public_function(
|
|
64
|
+
neutral_package_directory: Path,
|
|
65
|
+
) -> None:
|
|
66
|
+
_write_test_file(
|
|
67
|
+
neutral_package_directory,
|
|
68
|
+
"test_mod.py",
|
|
69
|
+
"from pkg.mod import alpha, beta\n\ndef test_both():\n assert alpha() and beta()\n",
|
|
70
|
+
)
|
|
71
|
+
all_issues = check_public_function_missing_paired_test(
|
|
72
|
+
_TWO_PUBLIC_FUNCTIONS, str(neutral_package_directory / "mod.py")
|
|
73
|
+
)
|
|
74
|
+
assert all_issues == []
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_skips_module_without_dedicated_test_file(
|
|
78
|
+
neutral_package_directory: Path,
|
|
79
|
+
) -> None:
|
|
80
|
+
all_issues = check_public_function_missing_paired_test(
|
|
81
|
+
_TWO_PUBLIC_FUNCTIONS, str(neutral_package_directory / "mod.py")
|
|
82
|
+
)
|
|
83
|
+
assert all_issues == []
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_skips_when_suite_covers_no_public_function(
|
|
87
|
+
neutral_package_directory: Path,
|
|
88
|
+
) -> None:
|
|
89
|
+
_write_test_file(
|
|
90
|
+
neutral_package_directory,
|
|
91
|
+
"test_mod.py",
|
|
92
|
+
"def test_unrelated():\n assert 1 + 1 == 2\n",
|
|
93
|
+
)
|
|
94
|
+
all_issues = check_public_function_missing_paired_test(
|
|
95
|
+
_TWO_PUBLIC_FUNCTIONS, str(neutral_package_directory / "mod.py")
|
|
96
|
+
)
|
|
97
|
+
assert all_issues == []
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_counts_coverage_across_sibling_test_files(
|
|
101
|
+
neutral_package_directory: Path,
|
|
102
|
+
) -> None:
|
|
103
|
+
_write_test_file(
|
|
104
|
+
neutral_package_directory,
|
|
105
|
+
"test_mod.py",
|
|
106
|
+
"from pkg.mod import alpha\n\ndef test_alpha():\n assert alpha() == 1\n",
|
|
107
|
+
)
|
|
108
|
+
_write_test_file(
|
|
109
|
+
neutral_package_directory,
|
|
110
|
+
"test_extra.py",
|
|
111
|
+
"from pkg.mod import beta\n\ndef test_beta():\n assert beta() == 2\n",
|
|
112
|
+
)
|
|
113
|
+
all_issues = check_public_function_missing_paired_test(
|
|
114
|
+
_TWO_PUBLIC_FUNCTIONS, str(neutral_package_directory / "mod.py")
|
|
115
|
+
)
|
|
116
|
+
assert all_issues == []
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_main_and_private_functions_are_never_required(
|
|
120
|
+
neutral_package_directory: Path,
|
|
121
|
+
) -> None:
|
|
122
|
+
module_source = (
|
|
123
|
+
"def alpha():\n return 1\n\n\n"
|
|
124
|
+
"def main():\n return 0\n\n\n"
|
|
125
|
+
"def _helper():\n return 2\n"
|
|
126
|
+
)
|
|
127
|
+
_write_test_file(
|
|
128
|
+
neutral_package_directory,
|
|
129
|
+
"test_mod.py",
|
|
130
|
+
"from pkg.mod import alpha\n\ndef test_alpha():\n assert alpha() == 1\n",
|
|
131
|
+
)
|
|
132
|
+
all_issues = check_public_function_missing_paired_test(
|
|
133
|
+
module_source, str(neutral_package_directory / "mod.py")
|
|
134
|
+
)
|
|
135
|
+
assert all_issues == []
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def test_exempts_hook_infrastructure_and_test_paths(
|
|
139
|
+
neutral_package_directory: Path,
|
|
140
|
+
) -> None:
|
|
141
|
+
_write_test_file(
|
|
142
|
+
neutral_package_directory,
|
|
143
|
+
"test_mod.py",
|
|
144
|
+
"from pkg.mod import alpha\n\ndef test_alpha():\n assert alpha() == 1\n",
|
|
145
|
+
)
|
|
146
|
+
hook_path = "/repo/hooks/blocking/code_rules_paired_test.py"
|
|
147
|
+
test_path = str(neutral_package_directory / "tests" / "test_mod.py")
|
|
148
|
+
assert check_public_function_missing_paired_test(_TWO_PUBLIC_FUNCTIONS, hook_path) == []
|
|
149
|
+
assert check_public_function_missing_paired_test(_TWO_PUBLIC_FUNCTIONS, test_path) == []
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
ENFORCER_PATH = Path(__file__).resolve().parent / "code_rules_enforcer.py"
|
|
7
|
+
specification = importlib.util.spec_from_file_location("code_rules_enforcer", ENFORCER_PATH)
|
|
8
|
+
assert specification is not None and specification.loader is not None
|
|
9
|
+
code_rules_enforcer = importlib.util.module_from_spec(specification)
|
|
10
|
+
specification.loader.exec_module(code_rules_enforcer)
|
|
11
|
+
|
|
12
|
+
PRODUCTION_PATH = "C:/project/pkg/menu_info_colors.py"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _check(source: str) -> list[str]:
|
|
16
|
+
return code_rules_enforcer.check_whitespace_indentation_magic(source, PRODUCTION_PATH)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_flags_twelve_space_indent_constant() -> None:
|
|
20
|
+
source = 'def fallback_indent() -> str:\n return " "\n'
|
|
21
|
+
issues = _check(source)
|
|
22
|
+
assert len(issues) == 1
|
|
23
|
+
assert "Line 2" in issues[0]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_flags_indent_fragment_inside_fstring() -> None:
|
|
27
|
+
source = 'def build(value: str) -> str:\n return value + f"\\n {value}"\n'
|
|
28
|
+
issues = _check(source)
|
|
29
|
+
assert len(issues) == 1
|
|
30
|
+
assert "Line 2" in issues[0]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_flags_tab_indent_constant() -> None:
|
|
34
|
+
source = 'def tabbed() -> str:\n return "\\t\\t"\n'
|
|
35
|
+
assert len(_check(source)) == 1
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_does_not_flag_single_tab_delimiter() -> None:
|
|
39
|
+
source = 'def split_columns(text: str) -> list[str]:\n return text.split("\\t")\n'
|
|
40
|
+
assert _check(source) == []
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_does_not_flag_single_tab_join_delimiter() -> None:
|
|
44
|
+
source = 'def join_rows(rows: list[str]) -> str:\n return "\\t".join(rows)\n'
|
|
45
|
+
assert _check(source) == []
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_does_not_flag_single_space() -> None:
|
|
49
|
+
source = 'def join_words(left: str, right: str) -> str:\n return left + " " + right\n'
|
|
50
|
+
assert _check(source) == []
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_does_not_flag_newline_only_fragment() -> None:
|
|
54
|
+
source = 'def build(value: str) -> str:\n return f"\\n{value}"\n'
|
|
55
|
+
assert _check(source) == []
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_does_not_flag_docstring_with_spaced_words() -> None:
|
|
59
|
+
source = (
|
|
60
|
+
"def documented() -> str:\n"
|
|
61
|
+
' """A docstring with spaced words."""\n'
|
|
62
|
+
' return documented.__doc__ or ""\n'
|
|
63
|
+
)
|
|
64
|
+
assert _check(source) == []
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_does_not_flag_in_config_file() -> None:
|
|
68
|
+
source = 'def fallback_indent() -> str:\n return " "\n'
|
|
69
|
+
assert (
|
|
70
|
+
code_rules_enforcer.check_whitespace_indentation_magic(
|
|
71
|
+
source, "C:/project/pkg/config/indents.py"
|
|
72
|
+
)
|
|
73
|
+
== []
|
|
74
|
+
)
|
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
"""Unit tests for convergence-gate-blocker PreToolUse hook."""
|
|
2
2
|
|
|
3
3
|
import importlib.util
|
|
4
|
+
import io
|
|
5
|
+
import json
|
|
4
6
|
import pathlib
|
|
7
|
+
import subprocess
|
|
5
8
|
import sys
|
|
6
9
|
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
7
12
|
_HOOK_DIR = pathlib.Path(__file__).parent
|
|
8
13
|
if str(_HOOK_DIR) not in sys.path:
|
|
9
14
|
sys.path.insert(0, str(_HOOK_DIR))
|
|
@@ -61,3 +66,69 @@ def test_returns_none_when_no_number_and_no_repo() -> None:
|
|
|
61
66
|
|
|
62
67
|
def test_matches_gh_pr_ready_in_compound_command() -> None:
|
|
63
68
|
assert not _GH_PR_READY_PATTERN.search("gh pr ready --undo && gh pr create")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_run_convergence_check_forwards_cwd_to_subprocess(
|
|
72
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
73
|
+
) -> None:
|
|
74
|
+
captured_cwd: list[object] = []
|
|
75
|
+
|
|
76
|
+
def fake_run(*_run_args: object, **run_keywords: object) -> subprocess.CompletedProcess[str]:
|
|
77
|
+
captured_cwd.append(run_keywords.get("cwd"))
|
|
78
|
+
return subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr="")
|
|
79
|
+
|
|
80
|
+
monkeypatch.setattr(hook_module.subprocess, "run", fake_run)
|
|
81
|
+
hook_module._run_convergence_check(
|
|
82
|
+
"check_convergence.py", "owner", "repo", 783, "C:/worktrees/pr-783"
|
|
83
|
+
)
|
|
84
|
+
assert captured_cwd == ["C:/worktrees/pr-783"]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_run_convergence_check_forwards_none_cwd_as_none(
|
|
88
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
89
|
+
) -> None:
|
|
90
|
+
captured_cwd: list[object] = []
|
|
91
|
+
|
|
92
|
+
def fake_run(*_run_args: object, **run_keywords: object) -> subprocess.CompletedProcess[str]:
|
|
93
|
+
captured_cwd.append(run_keywords.get("cwd"))
|
|
94
|
+
return subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr="")
|
|
95
|
+
|
|
96
|
+
monkeypatch.setattr(hook_module.subprocess, "run", fake_run)
|
|
97
|
+
hook_module._run_convergence_check("check_convergence.py", "owner", "repo", 783, None)
|
|
98
|
+
assert captured_cwd == [None]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_main_reads_cwd_from_top_level_payload(
|
|
102
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
|
|
103
|
+
) -> None:
|
|
104
|
+
convergence_script = (
|
|
105
|
+
tmp_path / ".claude" / "skills" / "pr-converge" / "scripts" / "check_convergence.py"
|
|
106
|
+
)
|
|
107
|
+
convergence_script.parent.mkdir(parents=True)
|
|
108
|
+
convergence_script.write_text("")
|
|
109
|
+
monkeypatch.setattr(hook_module.Path, "home", classmethod(lambda _cls: tmp_path))
|
|
110
|
+
|
|
111
|
+
worktree_path = str(tmp_path / "worktrees" / "pr-783")
|
|
112
|
+
monkeypatch.setattr(hook_module, "_resolve_owner_repo", lambda _cwd: ("jl-cmd", "repo"))
|
|
113
|
+
|
|
114
|
+
captured_cwd: list[object] = []
|
|
115
|
+
|
|
116
|
+
def fake_check(
|
|
117
|
+
_script: str, _owner: str, _repo: str, _pr_number: int, cwd: str | None
|
|
118
|
+
) -> subprocess.CompletedProcess[str]:
|
|
119
|
+
captured_cwd.append(cwd)
|
|
120
|
+
return subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr="")
|
|
121
|
+
|
|
122
|
+
monkeypatch.setattr(hook_module, "_run_convergence_check", fake_check)
|
|
123
|
+
|
|
124
|
+
payload = {
|
|
125
|
+
"tool_name": "Bash",
|
|
126
|
+
"cwd": worktree_path,
|
|
127
|
+
"tool_input": {"command": "gh pr ready 783", "cwd": "C:/wrong/session/dir"},
|
|
128
|
+
}
|
|
129
|
+
monkeypatch.setattr(sys, "stdin", io.StringIO(json.dumps(payload)))
|
|
130
|
+
|
|
131
|
+
with pytest.raises(SystemExit):
|
|
132
|
+
hook_module.main()
|
|
133
|
+
|
|
134
|
+
assert captured_cwd == [worktree_path]
|
|
@@ -39,6 +39,7 @@ Shared constant modules imported by hooks throughout the `hooks/` tree. Each fil
|
|
|
39
39
|
| `open_questions_in_plans_blocker_constants.py` | Patterns for detecting unresolved open questions in plan documents |
|
|
40
40
|
| `orphan_css_class_constants.py` | Scan radius and selector patterns for the orphan-CSS-class check |
|
|
41
41
|
| `package_inventory_stale_blocker_constants.py` | Inventory document names, production code extensions, backtick token pattern, smallest inventory size, exempt names, scan budget, and block-message text for the package-inventory stale-entry blocker |
|
|
42
|
+
| `paired_test_coverage_constants.py` | Test-directory name, stem-test filename affixes, test-file globs, exempt public-function names, scan budget, coverage threshold, and guidance text for the public-function paired-test coverage check |
|
|
42
43
|
| `path_rewriter_constants.py` | Path rewriting patterns for the Everything-search path rewriter |
|
|
43
44
|
| `plain_language_blocker_constants.py` | The list of heavy words and their everyday replacements |
|
|
44
45
|
| `pr_converge_bugteam_enforcer_constants.py` | State keys and timing config for the bugteam-parallel enforcer |
|
|
@@ -7,6 +7,8 @@ under the file-global-constants use-count rule (CODE_RULES §file-global-constan
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
+
import re
|
|
11
|
+
|
|
10
12
|
MAX_BANNED_PREFIX_ISSUES: int = 3
|
|
11
13
|
MAX_STUB_IMPLEMENTATION_ISSUES: int = 3
|
|
12
14
|
MAX_TYPED_DICT_PAIR_ISSUES: int = 3
|
|
@@ -55,12 +57,57 @@ MAX_MODULE_DOCSTRING_CHECK_ROSTER_ISSUES: int = 5
|
|
|
55
57
|
MINIMUM_PUBLIC_CHECKS_FOR_MODULE_DOCSTRING_ROSTER: int = 2
|
|
56
58
|
MAX_DOCSTRING_TUPLE_ENUMERATION_ISSUES: int = 5
|
|
57
59
|
MINIMUM_TUPLE_MEMBERS_FOR_DOCSTRING_ENUMERATION: int = 2
|
|
60
|
+
MAX_DOCSTRING_MARK_GLYPH_ENUMERATION_ISSUES: int = 5
|
|
61
|
+
MINIMUM_NAMED_MARKS_FOR_PROSE_ENUMERATION: int = 2
|
|
62
|
+
MAX_COMPANION_MODULE_RESOLUTION_DEPTH: int = 6
|
|
63
|
+
PYTHON_MODULE_FILE_SUFFIX: str = ".py"
|
|
64
|
+
WORD_BOUNDARY_REGEX: str = r"\b"
|
|
65
|
+
ALL_PUNCTUATION_MARK_GLYPH_PROSE_NAMES: dict[str, tuple[str, ...]] = {
|
|
66
|
+
"—": ("em-dash", "em dash"),
|
|
67
|
+
"–": ("en-dash", "en dash"),
|
|
68
|
+
"--": ("double-hyphen", "double hyphen", "spaced double-hyphen"),
|
|
69
|
+
";": ("semicolon",),
|
|
70
|
+
":": ("colon",),
|
|
71
|
+
",": ("comma",),
|
|
72
|
+
"/": ("slash", "forward slash"),
|
|
73
|
+
"|": ("pipe", "vertical bar"),
|
|
74
|
+
"&": ("ampersand",),
|
|
75
|
+
}
|
|
58
76
|
MAX_DOCSTRING_STEP_DISPATCH_ISSUES: int = 5
|
|
59
77
|
MINIMUM_NAMED_LINEAR_STEPS_FOR_DISPATCH_CHECK: int = 2
|
|
60
78
|
MINIMUM_TOKENS_FOR_DISPATCH_CALLEE: int = 2
|
|
61
79
|
MAX_DOCSTRING_UNDEFINED_CONSTANT_ISSUES: int = 3
|
|
62
80
|
MAX_DOCSTRING_RETURNS_PLURAL_CARDINALITY_ISSUES: int = 5
|
|
63
81
|
SINGLE_DICT_KEY_COUNT_FOR_PLURAL_CARDINALITY_DRIFT: int = 1
|
|
82
|
+
MAX_DOCSTRING_RAISES_LARGEZIPFILE_ISSUES: int = 5
|
|
83
|
+
DOCSTRING_LARGE_ZIP_FILE_EXCEPTION_NAME: str = "LargeZipFile"
|
|
84
|
+
ZIPFILE_WRITER_CLASS_NAME: str = "ZipFile"
|
|
85
|
+
ZIPFILE_MODE_KEYWORD: str = "mode"
|
|
86
|
+
ZIPFILE_MODE_POSITIONAL_INDEX: int = 1
|
|
87
|
+
ZIPFILE_ALLOW_ZIP64_KEYWORD: str = "allowZip64"
|
|
88
|
+
ZIPFILE_ALLOW_ZIP64_POSITIONAL_INDEX: int = 3
|
|
89
|
+
ALL_ZIPFILE_WRITE_MODE_VALUES: frozenset[str] = frozenset({"w", "a", "x"})
|
|
90
|
+
MAX_DOCSTRING_ARGS_SPAN_SCOPE_ISSUES: int = 3
|
|
91
|
+
ALL_DOCSTRING_SINGLE_LINE_SCOPE_PHRASES: tuple[str, ...] = (
|
|
92
|
+
"anchor line is among the changed lines",
|
|
93
|
+
"anchor line is among the edited lines",
|
|
94
|
+
"anchor line is among the changed",
|
|
95
|
+
"first line is among the changed lines",
|
|
96
|
+
"start line is among the changed lines",
|
|
97
|
+
)
|
|
98
|
+
ALL_DOCSTRING_SPAN_SCOPE_OVERRIDE_PHRASES: tuple[str, ...] = (
|
|
99
|
+
"any line of",
|
|
100
|
+
"any line in",
|
|
101
|
+
"any of its lines",
|
|
102
|
+
"any span line",
|
|
103
|
+
)
|
|
104
|
+
ALL_DOCSTRING_SPAN_RANGE_BODY_CALLEE_NAMES: tuple[str, ...] = (
|
|
105
|
+
"_scope_violations_to_changed_lines",
|
|
106
|
+
"_scope_violations",
|
|
107
|
+
)
|
|
108
|
+
MAX_DOCSTRING_CARDINAL_FAMILY_ISSUES: int = 5
|
|
109
|
+
MINIMUM_CONSTANT_FAMILY_MEMBERS_FOR_CARDINAL_CHECK: int = 2
|
|
110
|
+
MINIMUM_DOCSTRING_FAMILY_OVERLAP_FOR_CARDINAL_CHECK: int = 2
|
|
64
111
|
ALL_NAMING_CONVENTION_DESCRIPTOR_TOKENS: frozenset[str] = frozenset(
|
|
65
112
|
{
|
|
66
113
|
"UPPER_SNAKE_CASE",
|
|
@@ -114,6 +161,10 @@ ALL_FORMAT_LOGGER_FUNCTION_NAMES: frozenset[str] = frozenset(
|
|
|
114
161
|
"log_background",
|
|
115
162
|
}
|
|
116
163
|
)
|
|
164
|
+
DOCSTRING_RUNON_SENTENCE_WORD_LIMIT: int = 30
|
|
165
|
+
MAX_DOCSTRING_RUNON_SENTENCE_ISSUES: int = 5
|
|
166
|
+
ALL_DOCSTRING_RUNON_JOINER_MARKERS: tuple[str, ...] = ("—", " -- ", ";")
|
|
167
|
+
DOCSTRING_RUNON_SENTENCE_BOUNDARY_PATTERN: re.Pattern[str] = re.compile(r"(?<=[.!?])\s+")
|
|
117
168
|
|
|
118
169
|
ALL_DOCSTRING_NO_CONSUMER_CLAIM_PHRASES: tuple[str, ...] = (
|
|
119
170
|
"no consumer reads",
|