claude-dev-env 1.77.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/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 +352 -0
- package/hooks/blocking/code_rules_enforcer.py +27 -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_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_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 +24 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +14 -0
- package/hooks/hooks_constants/paired_test_coverage_constants.py +27 -0
- package/package.json +1 -1
- package/rules/CLAUDE.md +1 -0
- package/rules/docstring-prose-matches-implementation.md +2 -1
- package/rules/file-global-constants.md +2 -2
- package/rules/paired-test-coverage.md +28 -0
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""Tests for check_docstring_punctuation_mark_enumeration_coverage — O6 glyph drift.
|
|
2
|
+
|
|
3
|
+
A module defines a tuple of punctuation-mark glyphs (an em-dash, a spaced
|
|
4
|
+
double-hyphen, a semicolon) and a docstring enumerates those marks by their
|
|
5
|
+
English names while omitting one the tuple holds. The prose names two marks but
|
|
6
|
+
the detection set holds three, so a reader who trusts the enumeration believes a
|
|
7
|
+
mark that is active never triggers the check. This is the deterministic
|
|
8
|
+
glyph-prose slice of Category O6 docstring-prose-vs-implementation drift, the
|
|
9
|
+
companion to check_docstring_tuple_enumeration_match for glyph members named in
|
|
10
|
+
prose rather than identifier members named in inline code.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import importlib.util
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from types import ModuleType
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _load_enforcer_module() -> ModuleType:
|
|
21
|
+
module_path = Path(__file__).parent / "code_rules_enforcer.py"
|
|
22
|
+
spec = importlib.util.spec_from_file_location("code_rules_enforcer", module_path)
|
|
23
|
+
assert spec is not None
|
|
24
|
+
assert spec.loader is not None
|
|
25
|
+
module = importlib.util.module_from_spec(spec)
|
|
26
|
+
spec.loader.exec_module(module)
|
|
27
|
+
return module
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
code_rules_enforcer = _load_enforcer_module()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def check_docstring_punctuation_mark_enumeration_coverage(
|
|
34
|
+
content: str, file_path: str
|
|
35
|
+
) -> list[str]:
|
|
36
|
+
return code_rules_enforcer.check_docstring_punctuation_mark_enumeration_coverage(
|
|
37
|
+
content, file_path
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def validate_content(content: str, file_path: str, old_content: str) -> list[str]:
|
|
42
|
+
return code_rules_enforcer.validate_content(content, file_path, old_content)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
PRODUCTION_FILE_PATH = "/project/src/runon_detector.py"
|
|
46
|
+
TEST_FILE_PATH = "/project/src/test_runon_detector.py"
|
|
47
|
+
HOOK_INFRASTRUCTURE_PATH = "/home/user/.claude/hooks/blocking/code_rules_docstrings.py"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _drifted_two_marks_named_function() -> str:
|
|
51
|
+
return (
|
|
52
|
+
'ALL_RUNON_JOINER_MARKERS = ("—", " -- ", ";")\n'
|
|
53
|
+
"\n"
|
|
54
|
+
"\n"
|
|
55
|
+
"def _sentence_carries_joiner(sentence_text: str) -> bool:\n"
|
|
56
|
+
' """Report whether the sentence chains clauses with a joiner mark.\n'
|
|
57
|
+
"\n"
|
|
58
|
+
" The run-on marks are an em-dash or a semicolon, the two glyphs that\n"
|
|
59
|
+
" fuse independent clauses into one dense sentence.\n"
|
|
60
|
+
' """\n'
|
|
61
|
+
" return any(each in sentence_text for each in ALL_RUNON_JOINER_MARKERS)\n"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _complete_three_marks_named_function() -> str:
|
|
66
|
+
return (
|
|
67
|
+
'ALL_RUNON_JOINER_MARKERS = ("—", " -- ", ";")\n'
|
|
68
|
+
"\n"
|
|
69
|
+
"\n"
|
|
70
|
+
"def _sentence_carries_joiner(sentence_text: str) -> bool:\n"
|
|
71
|
+
' """Report whether the sentence chains clauses with a joiner mark.\n'
|
|
72
|
+
"\n"
|
|
73
|
+
" The run-on marks are an em-dash, a spaced double-hyphen, or a\n"
|
|
74
|
+
" semicolon, the glyphs that fuse independent clauses into one\n"
|
|
75
|
+
" dense sentence.\n"
|
|
76
|
+
' """\n'
|
|
77
|
+
" return any(each in sentence_text for each in ALL_RUNON_JOINER_MARKERS)\n"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _single_mark_named_function() -> str:
|
|
82
|
+
return (
|
|
83
|
+
'ALL_RUNON_JOINER_MARKERS = ("—", " -- ", ";")\n'
|
|
84
|
+
"\n"
|
|
85
|
+
"\n"
|
|
86
|
+
"def _sentence_carries_joiner(sentence_text: str) -> bool:\n"
|
|
87
|
+
' """Report whether the sentence chains clauses with an em-dash.\n'
|
|
88
|
+
"\n"
|
|
89
|
+
" The em-dash is the glyph that fuses clauses into one sentence.\n"
|
|
90
|
+
' """\n'
|
|
91
|
+
" return any(each in sentence_text for each in ALL_RUNON_JOINER_MARKERS)\n"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _non_glyph_tuple_function() -> str:
|
|
96
|
+
return (
|
|
97
|
+
'ALL_OUTCOME_LABELS = ("alpha", "beta", "gamma")\n'
|
|
98
|
+
"\n"
|
|
99
|
+
"\n"
|
|
100
|
+
"def describe_outcome(label_text: str) -> bool:\n"
|
|
101
|
+
' """Report the outcome named by an em-dash or a semicolon label.\n'
|
|
102
|
+
"\n"
|
|
103
|
+
" The labels carry a semicolon between the segments of each name.\n"
|
|
104
|
+
' """\n'
|
|
105
|
+
" return label_text in ALL_OUTCOME_LABELS\n"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_should_flag_omitted_double_hyphen_mark() -> None:
|
|
110
|
+
issues = check_docstring_punctuation_mark_enumeration_coverage(
|
|
111
|
+
_drifted_two_marks_named_function(), PRODUCTION_FILE_PATH
|
|
112
|
+
)
|
|
113
|
+
assert any("--" in each for each in issues), (
|
|
114
|
+
f"The docstring naming em-dash and semicolon but omitting the double-hyphen "
|
|
115
|
+
f"that ALL_RUNON_JOINER_MARKERS holds must be flagged, got: {issues!r}"
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_should_report_category_o6_in_the_message() -> None:
|
|
120
|
+
issues = check_docstring_punctuation_mark_enumeration_coverage(
|
|
121
|
+
_drifted_two_marks_named_function(), PRODUCTION_FILE_PATH
|
|
122
|
+
)
|
|
123
|
+
assert any("O6" in each for each in issues), (
|
|
124
|
+
f"Expected the Category O6 label in the message, got: {issues!r}"
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_should_not_flag_complete_enumeration() -> None:
|
|
129
|
+
issues = check_docstring_punctuation_mark_enumeration_coverage(
|
|
130
|
+
_complete_three_marks_named_function(), PRODUCTION_FILE_PATH
|
|
131
|
+
)
|
|
132
|
+
assert issues == [], f"A docstring naming all three marks must not be flagged, got: {issues!r}"
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_should_not_flag_single_named_mark_below_threshold() -> None:
|
|
136
|
+
issues = check_docstring_punctuation_mark_enumeration_coverage(
|
|
137
|
+
_single_mark_named_function(), PRODUCTION_FILE_PATH
|
|
138
|
+
)
|
|
139
|
+
assert issues == [], f"A docstring naming a single mark is not an enumeration, got: {issues!r}"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_should_not_flag_non_glyph_tuple() -> None:
|
|
143
|
+
issues = check_docstring_punctuation_mark_enumeration_coverage(
|
|
144
|
+
_non_glyph_tuple_function(), PRODUCTION_FILE_PATH
|
|
145
|
+
)
|
|
146
|
+
assert issues == [], f"A tuple of non-punctuation members must not be flagged, got: {issues!r}"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_should_flag_on_hook_infrastructure_where_the_drift_lives() -> None:
|
|
150
|
+
issues = check_docstring_punctuation_mark_enumeration_coverage(
|
|
151
|
+
_drifted_two_marks_named_function(), HOOK_INFRASTRUCTURE_PATH
|
|
152
|
+
)
|
|
153
|
+
assert any("--" in each for each in issues), (
|
|
154
|
+
f"The drift lives in hook modules, so the gate must run there, got: {issues!r}"
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_should_skip_test_file() -> None:
|
|
159
|
+
issues = check_docstring_punctuation_mark_enumeration_coverage(
|
|
160
|
+
_drifted_two_marks_named_function(), TEST_FILE_PATH
|
|
161
|
+
)
|
|
162
|
+
assert issues == [], f"Test files exempt, got: {issues!r}"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def test_should_handle_syntax_error_gracefully() -> None:
|
|
166
|
+
unparseable_naming_marks = 'def fetch(\n """An em-dash and a semicolon."""\n'
|
|
167
|
+
issues = check_docstring_punctuation_mark_enumeration_coverage(
|
|
168
|
+
unparseable_naming_marks, PRODUCTION_FILE_PATH
|
|
169
|
+
)
|
|
170
|
+
assert issues == [], f"Syntax error must yield no issues, got: {issues!r}"
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _drifted_annotated_tuple_function() -> str:
|
|
174
|
+
return (
|
|
175
|
+
'ALL_RUNON_JOINER_MARKERS: tuple[str, ...] = ("—", " -- ", ";")\n'
|
|
176
|
+
"\n"
|
|
177
|
+
"\n"
|
|
178
|
+
"def _sentence_carries_joiner(sentence_text: str) -> bool:\n"
|
|
179
|
+
' """Report whether the sentence chains clauses with a joiner mark.\n'
|
|
180
|
+
"\n"
|
|
181
|
+
" The run-on marks are an em-dash or a semicolon.\n"
|
|
182
|
+
' """\n'
|
|
183
|
+
" return any(each in sentence_text for each in ALL_RUNON_JOINER_MARKERS)\n"
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def test_should_flag_annotated_same_module_tuple_drift() -> None:
|
|
188
|
+
issues = check_docstring_punctuation_mark_enumeration_coverage(
|
|
189
|
+
_drifted_annotated_tuple_function(), PRODUCTION_FILE_PATH
|
|
190
|
+
)
|
|
191
|
+
assert any("--" in each for each in issues), (
|
|
192
|
+
f"An annotated marker tuple in the same module must be checked, got: {issues!r}"
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def test_should_flag_drift_against_imported_companion_tuple(tmp_path: Path) -> None:
|
|
197
|
+
companion_directory = tmp_path / "marks_package"
|
|
198
|
+
companion_directory.mkdir()
|
|
199
|
+
(companion_directory / "joiner_marks.py").write_text(
|
|
200
|
+
'ALL_RUNON_JOINER_MARKERS: tuple[str, ...] = ("—", " -- ", ";")\n',
|
|
201
|
+
encoding="utf-8",
|
|
202
|
+
)
|
|
203
|
+
consumer_directory = tmp_path / "blocking"
|
|
204
|
+
consumer_directory.mkdir()
|
|
205
|
+
consumer_source = (
|
|
206
|
+
"from marks_package.joiner_marks import ALL_RUNON_JOINER_MARKERS\n"
|
|
207
|
+
"\n"
|
|
208
|
+
"\n"
|
|
209
|
+
"def _sentence_carries_joiner(sentence_text: str) -> bool:\n"
|
|
210
|
+
' """Report whether the sentence chains clauses with a joiner mark.\n'
|
|
211
|
+
"\n"
|
|
212
|
+
" The run-on marks are an em-dash or a semicolon, the two glyphs that\n"
|
|
213
|
+
" fuse independent clauses into one dense sentence.\n"
|
|
214
|
+
' """\n'
|
|
215
|
+
" return any(each in sentence_text for each in ALL_RUNON_JOINER_MARKERS)\n"
|
|
216
|
+
)
|
|
217
|
+
consumer_path = consumer_directory / "runon_consumer.py"
|
|
218
|
+
consumer_path.write_text(consumer_source, encoding="utf-8")
|
|
219
|
+
issues = check_docstring_punctuation_mark_enumeration_coverage(
|
|
220
|
+
consumer_source, str(consumer_path)
|
|
221
|
+
)
|
|
222
|
+
assert any("--" in each for each in issues), (
|
|
223
|
+
f"A docstring drifting from an imported companion marker tuple must be "
|
|
224
|
+
f"flagged so the split-file shape is caught, got: {issues!r}"
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def test_should_not_resolve_companion_when_no_marks_named(tmp_path: Path) -> None:
|
|
229
|
+
companion_directory = tmp_path / "marks_package"
|
|
230
|
+
companion_directory.mkdir()
|
|
231
|
+
(companion_directory / "joiner_marks.py").write_text(
|
|
232
|
+
'ALL_RUNON_JOINER_MARKERS: tuple[str, ...] = ("—", " -- ", ";")\n',
|
|
233
|
+
encoding="utf-8",
|
|
234
|
+
)
|
|
235
|
+
consumer_directory = tmp_path / "blocking"
|
|
236
|
+
consumer_directory.mkdir()
|
|
237
|
+
consumer_source = (
|
|
238
|
+
"from marks_package.joiner_marks import ALL_RUNON_JOINER_MARKERS\n"
|
|
239
|
+
"\n"
|
|
240
|
+
"\n"
|
|
241
|
+
"def _sentence_carries_joiner(sentence_text: str) -> bool:\n"
|
|
242
|
+
' """Report whether the sentence carries any joiner glyph."""\n'
|
|
243
|
+
" return any(each in sentence_text for each in ALL_RUNON_JOINER_MARKERS)\n"
|
|
244
|
+
)
|
|
245
|
+
consumer_path = consumer_directory / "runon_consumer.py"
|
|
246
|
+
consumer_path.write_text(consumer_source, encoding="utf-8")
|
|
247
|
+
issues = check_docstring_punctuation_mark_enumeration_coverage(
|
|
248
|
+
consumer_source, str(consumer_path)
|
|
249
|
+
)
|
|
250
|
+
assert issues == [], (
|
|
251
|
+
f"A docstring naming no marks must not be flagged, got: {issues!r}"
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def test_validate_content_surfaces_mark_glyph_drift() -> None:
|
|
256
|
+
issues = validate_content(
|
|
257
|
+
_drifted_two_marks_named_function(), PRODUCTION_FILE_PATH, old_content=""
|
|
258
|
+
)
|
|
259
|
+
matching_issues = [each for each in issues if "--" in each and "O6" in each]
|
|
260
|
+
assert matching_issues, (
|
|
261
|
+
f"Expected validate_content to surface the O6 mark-glyph drift, got: {issues!r}"
|
|
262
|
+
)
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Tests for check_docstring_raises_unraisable_largezipfile — O6 Raises drift.
|
|
2
|
+
|
|
3
|
+
A function's docstring Raises clause lists ``zipfile.LargeZipFile`` while the
|
|
4
|
+
function opens its ``zipfile.ZipFile`` writer with ZIP64 permitted (``allowZip64``
|
|
5
|
+
left at its default of True). The stdlib raises ``LargeZipFile`` only when an
|
|
6
|
+
entry needs ZIP64 AND ``allowZip64`` is False; with ZIP64 permitted the writer
|
|
7
|
+
transparently uses it and never raises. The Raises entry therefore documents an
|
|
8
|
+
exception the body cannot produce — the deterministic slice of Category O6
|
|
9
|
+
docstring-prose-vs-implementation drift where a writer-opened-with-default-ZIP64
|
|
10
|
+
disagrees with a LargeZipFile Raises clause.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import importlib.util
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from types import ModuleType
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _load_enforcer_module() -> ModuleType:
|
|
21
|
+
module_path = Path(__file__).parent / "code_rules_enforcer.py"
|
|
22
|
+
spec = importlib.util.spec_from_file_location("code_rules_enforcer", module_path)
|
|
23
|
+
assert spec is not None
|
|
24
|
+
assert spec.loader is not None
|
|
25
|
+
module = importlib.util.module_from_spec(spec)
|
|
26
|
+
spec.loader.exec_module(module)
|
|
27
|
+
return module
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
code_rules_enforcer = _load_enforcer_module()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def check_docstring_raises_unraisable_largezipfile(content: str, file_path: str) -> list[str]:
|
|
34
|
+
return code_rules_enforcer.check_docstring_raises_unraisable_largezipfile(content, file_path)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def validate_content(content: str, file_path: str, old_content: str) -> list[str]:
|
|
38
|
+
return code_rules_enforcer.validate_content(content, file_path, old_content)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
PRODUCTION_FILE_PATH = "/project/src/stp_archive.py"
|
|
42
|
+
TEST_FILE_PATH = "/project/src/test_stp_archive.py"
|
|
43
|
+
HOOK_INFRASTRUCTURE_PATH = "/home/user/.claude/hooks/blocking/example.py"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _default_zip64_writer_documenting_largezipfile() -> str:
|
|
47
|
+
return (
|
|
48
|
+
"import zipfile\n"
|
|
49
|
+
"from pathlib import Path\n"
|
|
50
|
+
"\n"
|
|
51
|
+
"def rewrite_stp_member_atomically(stp_path: Path, member_bytes: bytes) -> None:\n"
|
|
52
|
+
' """Rewrite one member of an STP in place via a temp sibling.\n'
|
|
53
|
+
"\n"
|
|
54
|
+
" Raises:\n"
|
|
55
|
+
" OSError: When the streaming write or the rename fails.\n"
|
|
56
|
+
" zipfile.BadZipFile: When the source archive is not a valid ZIP.\n"
|
|
57
|
+
" zipfile.LargeZipFile: When an entry needs ZIP64 the writer forbids.\n"
|
|
58
|
+
' """\n'
|
|
59
|
+
" with zipfile.ZipFile(stp_path, 'r') as source_archive:\n"
|
|
60
|
+
" with zipfile.ZipFile(stp_path, 'w', zipfile.ZIP_DEFLATED) as target_archive:\n"
|
|
61
|
+
" target_archive.writestr('IM/a.png', member_bytes)\n"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_should_flag_largezipfile_over_a_default_zip64_writer() -> None:
|
|
66
|
+
issues = check_docstring_raises_unraisable_largezipfile(
|
|
67
|
+
_default_zip64_writer_documenting_largezipfile(), PRODUCTION_FILE_PATH
|
|
68
|
+
)
|
|
69
|
+
assert any("LargeZipFile" in each for each in issues), (
|
|
70
|
+
f"A LargeZipFile Raises entry over a default-ZIP64 writer must flag, got: {issues!r}"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_should_report_category_o6_in_the_message() -> None:
|
|
75
|
+
issues = check_docstring_raises_unraisable_largezipfile(
|
|
76
|
+
_default_zip64_writer_documenting_largezipfile(), PRODUCTION_FILE_PATH
|
|
77
|
+
)
|
|
78
|
+
assert any("O6" in each for each in issues), (
|
|
79
|
+
f"Expected the Category O6 label in the message, got: {issues!r}"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_should_not_flag_when_writer_forbids_zip64_via_keyword() -> None:
|
|
84
|
+
source = (
|
|
85
|
+
"import zipfile\n"
|
|
86
|
+
"from pathlib import Path\n"
|
|
87
|
+
"\n"
|
|
88
|
+
"def rewrite(stp_path: Path, member_bytes: bytes) -> None:\n"
|
|
89
|
+
' """Rewrite one member of an STP, forbidding ZIP64.\n'
|
|
90
|
+
"\n"
|
|
91
|
+
" Raises:\n"
|
|
92
|
+
" zipfile.LargeZipFile: When an entry needs ZIP64 the writer forbids.\n"
|
|
93
|
+
' """\n'
|
|
94
|
+
" with zipfile.ZipFile(stp_path, 'w', allowZip64=False) as target_archive:\n"
|
|
95
|
+
" target_archive.writestr('IM/a.png', member_bytes)\n"
|
|
96
|
+
)
|
|
97
|
+
issues = check_docstring_raises_unraisable_largezipfile(source, PRODUCTION_FILE_PATH)
|
|
98
|
+
assert issues == [], (
|
|
99
|
+
f"A writer that forbids ZIP64 can raise LargeZipFile, so the clause is valid, got: {issues!r}"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_should_not_flag_when_writer_forbids_zip64_positionally() -> None:
|
|
104
|
+
source = (
|
|
105
|
+
"import zipfile\n"
|
|
106
|
+
"from pathlib import Path\n"
|
|
107
|
+
"\n"
|
|
108
|
+
"def rewrite(stp_path: Path, member_bytes: bytes) -> None:\n"
|
|
109
|
+
' """Rewrite one member of an STP, forbidding ZIP64.\n'
|
|
110
|
+
"\n"
|
|
111
|
+
" Raises:\n"
|
|
112
|
+
" zipfile.LargeZipFile: When an entry needs ZIP64 the writer forbids.\n"
|
|
113
|
+
' """\n'
|
|
114
|
+
" with zipfile.ZipFile(stp_path, 'w', zipfile.ZIP_DEFLATED, False) as target_archive:\n"
|
|
115
|
+
" target_archive.writestr('IM/a.png', member_bytes)\n"
|
|
116
|
+
)
|
|
117
|
+
issues = check_docstring_raises_unraisable_largezipfile(source, PRODUCTION_FILE_PATH)
|
|
118
|
+
assert issues == [], f"A positional allowZip64=False keeps the clause valid, got: {issues!r}"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_should_not_flag_when_function_opens_no_zip_writer() -> None:
|
|
122
|
+
source = (
|
|
123
|
+
"import zipfile\n"
|
|
124
|
+
"from pathlib import Path\n"
|
|
125
|
+
"\n"
|
|
126
|
+
"def patch_via_helper(stp_path: Path, member_bytes: bytes) -> None:\n"
|
|
127
|
+
' """Patch one member through a helper writer.\n'
|
|
128
|
+
"\n"
|
|
129
|
+
" Raises:\n"
|
|
130
|
+
" zipfile.LargeZipFile: When the helper writer forbids ZIP64.\n"
|
|
131
|
+
' """\n'
|
|
132
|
+
" write_member(stp_path, member_bytes)\n"
|
|
133
|
+
)
|
|
134
|
+
issues = check_docstring_raises_unraisable_largezipfile(source, PRODUCTION_FILE_PATH)
|
|
135
|
+
assert issues == [], (
|
|
136
|
+
f"With no visible writer the exception may propagate from a callee, got: {issues!r}"
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_should_not_flag_read_only_open() -> None:
|
|
141
|
+
source = (
|
|
142
|
+
"import zipfile\n"
|
|
143
|
+
"from pathlib import Path\n"
|
|
144
|
+
"\n"
|
|
145
|
+
"def read_member(stp_path: Path) -> bytes:\n"
|
|
146
|
+
' """Read one member of an STP.\n'
|
|
147
|
+
"\n"
|
|
148
|
+
" Raises:\n"
|
|
149
|
+
" zipfile.LargeZipFile: When an entry needs ZIP64 the writer forbids.\n"
|
|
150
|
+
' """\n'
|
|
151
|
+
" with zipfile.ZipFile(stp_path) as source_archive:\n"
|
|
152
|
+
" return source_archive.read('IM/a.png')\n"
|
|
153
|
+
)
|
|
154
|
+
issues = check_docstring_raises_unraisable_largezipfile(source, PRODUCTION_FILE_PATH)
|
|
155
|
+
assert issues == [], (
|
|
156
|
+
f"A read-only open has no write-mode writer, so it is left alone, got: {issues!r}"
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def test_should_not_flag_when_raises_omits_largezipfile() -> None:
|
|
161
|
+
source = (
|
|
162
|
+
"import zipfile\n"
|
|
163
|
+
"from pathlib import Path\n"
|
|
164
|
+
"\n"
|
|
165
|
+
"def rewrite(stp_path: Path, member_bytes: bytes) -> None:\n"
|
|
166
|
+
' """Rewrite one member of an STP in place.\n'
|
|
167
|
+
"\n"
|
|
168
|
+
" Raises:\n"
|
|
169
|
+
" OSError: When the streaming write or the rename fails.\n"
|
|
170
|
+
' """\n'
|
|
171
|
+
" with zipfile.ZipFile(stp_path, 'w', zipfile.ZIP_DEFLATED) as target_archive:\n"
|
|
172
|
+
" target_archive.writestr('IM/a.png', member_bytes)\n"
|
|
173
|
+
)
|
|
174
|
+
issues = check_docstring_raises_unraisable_largezipfile(source, PRODUCTION_FILE_PATH)
|
|
175
|
+
assert issues == [], f"A Raises clause without LargeZipFile must not flag, got: {issues!r}"
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def test_should_flag_when_mode_passed_by_keyword() -> None:
|
|
179
|
+
source = (
|
|
180
|
+
"import zipfile\n"
|
|
181
|
+
"from pathlib import Path\n"
|
|
182
|
+
"\n"
|
|
183
|
+
"def rewrite(stp_path: Path, member_bytes: bytes) -> None:\n"
|
|
184
|
+
' """Rewrite one member of an STP in place.\n'
|
|
185
|
+
"\n"
|
|
186
|
+
" Raises:\n"
|
|
187
|
+
" zipfile.LargeZipFile: When an entry needs ZIP64 the writer forbids.\n"
|
|
188
|
+
' """\n'
|
|
189
|
+
" with zipfile.ZipFile(stp_path, mode='w') as target_archive:\n"
|
|
190
|
+
" target_archive.writestr('IM/a.png', member_bytes)\n"
|
|
191
|
+
)
|
|
192
|
+
issues = check_docstring_raises_unraisable_largezipfile(source, PRODUCTION_FILE_PATH)
|
|
193
|
+
assert any("LargeZipFile" in each for each in issues), (
|
|
194
|
+
f"A keyword mode='w' writer with default ZIP64 must flag, got: {issues!r}"
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def test_should_skip_test_file() -> None:
|
|
199
|
+
issues = check_docstring_raises_unraisable_largezipfile(
|
|
200
|
+
_default_zip64_writer_documenting_largezipfile(), TEST_FILE_PATH
|
|
201
|
+
)
|
|
202
|
+
assert issues == [], f"Test files exempt, got: {issues!r}"
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def test_should_skip_hook_infrastructure() -> None:
|
|
206
|
+
issues = check_docstring_raises_unraisable_largezipfile(
|
|
207
|
+
_default_zip64_writer_documenting_largezipfile(), HOOK_INFRASTRUCTURE_PATH
|
|
208
|
+
)
|
|
209
|
+
assert issues == [], f"Hook infrastructure exempt, got: {issues!r}"
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def test_should_handle_syntax_error_gracefully() -> None:
|
|
213
|
+
issues = check_docstring_raises_unraisable_largezipfile("def rewrite(\n", PRODUCTION_FILE_PATH)
|
|
214
|
+
assert issues == [], f"Syntax error must yield no issues, got: {issues!r}"
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def test_validate_content_surfaces_largezipfile_drift() -> None:
|
|
218
|
+
issues = validate_content(
|
|
219
|
+
_default_zip64_writer_documenting_largezipfile(),
|
|
220
|
+
PRODUCTION_FILE_PATH,
|
|
221
|
+
old_content="",
|
|
222
|
+
)
|
|
223
|
+
matching_issues = [each for each in issues if "LargeZipFile" in each and "O6" in each]
|
|
224
|
+
assert matching_issues, (
|
|
225
|
+
f"Expected validate_content to surface the O6 LargeZipFile drift, got: {issues!r}"
|
|
226
|
+
)
|
|
@@ -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) == []
|