claude-dev-env 1.51.0 → 1.52.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/code_rules_enforcer.py +367 -42
- package/hooks/blocking/tdd_enforcer.py +211 -19
- package/hooks/blocking/test_code_rules_enforcer_precheck_forecast.py +519 -0
- package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +1 -1
- package/hooks/blocking/test_tdd_enforcer.py +399 -0
- package/hooks/hooks.json +0 -15
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +5 -0
- package/package.json +1 -1
- package/skills/update/SKILL.md +143 -0
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
"""Behavior tests for the code_rules_enforcer pre-check mode and full-file forecast."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
import json
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from types import SimpleNamespace
|
|
11
|
+
|
|
12
|
+
_BLOCKING_DIRECTORY = str(Path(__file__).resolve().parent)
|
|
13
|
+
_HOOKS_DIRECTORY = str(Path(__file__).resolve().parent.parent)
|
|
14
|
+
if _BLOCKING_DIRECTORY not in sys.path:
|
|
15
|
+
sys.path.insert(0, _BLOCKING_DIRECTORY)
|
|
16
|
+
if _HOOKS_DIRECTORY not in sys.path:
|
|
17
|
+
sys.path.insert(0, _HOOKS_DIRECTORY)
|
|
18
|
+
|
|
19
|
+
from code_rules_enforcer import ( # noqa: E402
|
|
20
|
+
main,
|
|
21
|
+
validate_content,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
code_rules_enforcer = SimpleNamespace(
|
|
25
|
+
main=main,
|
|
26
|
+
sys=sys,
|
|
27
|
+
validate_content=validate_content,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
_ENFORCER_SCRIPT_PATH = Path(__file__).resolve().parent / "code_rules_enforcer.py"
|
|
31
|
+
|
|
32
|
+
_VIOLATING_PRODUCTION_SOURCE = "def process_data(payload: str) -> None:\n print(payload)\n"
|
|
33
|
+
|
|
34
|
+
_CLEAN_CLI_SOURCE = (
|
|
35
|
+
"def announce_payload(payload: str) -> None:\n"
|
|
36
|
+
' """Log the payload.\n'
|
|
37
|
+
"\n"
|
|
38
|
+
" Args:\n"
|
|
39
|
+
" payload: The text to record.\n"
|
|
40
|
+
' """\n'
|
|
41
|
+
" print(payload)\n"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
_UNTOUCHED_PRINT_VIOLATION_SOURCE = "def emit_audit_line() -> None:\n print(99)\n"
|
|
45
|
+
|
|
46
|
+
_CLEAN_FRAGMENT_BEFORE = "def short_helper() -> int:\n return 1\n"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _run_enforcer_cli(
|
|
50
|
+
all_cli_arguments: list[str],
|
|
51
|
+
) -> subprocess.CompletedProcess[str]:
|
|
52
|
+
"""Drive the enforcer script through its real argv entry point.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
all_cli_arguments: The argument vector appended after the script path.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
The completed process carrying stdout, stderr, and the exit code.
|
|
59
|
+
"""
|
|
60
|
+
return subprocess.run(
|
|
61
|
+
[sys.executable, str(_ENFORCER_SCRIPT_PATH), *all_cli_arguments],
|
|
62
|
+
input="",
|
|
63
|
+
capture_output=True,
|
|
64
|
+
text=True,
|
|
65
|
+
check=False,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _run_precheck(
|
|
70
|
+
candidate_path: str,
|
|
71
|
+
target_path: str,
|
|
72
|
+
) -> subprocess.CompletedProcess[str]:
|
|
73
|
+
"""Drive the enforcer script in pre-check mode through its real argv entry point.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
candidate_path: The path of the complete candidate file to validate.
|
|
77
|
+
target_path: The destination path used for all classification, passed
|
|
78
|
+
through ``--as``.
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
The completed process carrying the pre-check stdout, stderr, and exit code.
|
|
82
|
+
"""
|
|
83
|
+
return _run_enforcer_cli(["--check", candidate_path, "--as", target_path])
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _run_main_with_edit_payload(
|
|
87
|
+
file_path: str,
|
|
88
|
+
old_string: str,
|
|
89
|
+
new_string: str,
|
|
90
|
+
monkeypatch: object,
|
|
91
|
+
capsys: object,
|
|
92
|
+
) -> str:
|
|
93
|
+
"""Drive ``main()`` through its stdin entry point for an Edit and return stdout.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
file_path: The on-disk path the Edit targets.
|
|
97
|
+
old_string: The Edit's ``old_string`` fragment.
|
|
98
|
+
new_string: The Edit's ``new_string`` fragment.
|
|
99
|
+
monkeypatch: The pytest fixture used to redirect ``sys.stdin``.
|
|
100
|
+
capsys: The pytest fixture used to capture the deny payload on stdout.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
The captured stdout, which holds the deny payload when violations fire.
|
|
104
|
+
"""
|
|
105
|
+
edit_payload = json.dumps(
|
|
106
|
+
{
|
|
107
|
+
"tool_name": "Edit",
|
|
108
|
+
"tool_input": {
|
|
109
|
+
"file_path": file_path,
|
|
110
|
+
"old_string": old_string,
|
|
111
|
+
"new_string": new_string,
|
|
112
|
+
},
|
|
113
|
+
}
|
|
114
|
+
)
|
|
115
|
+
getattr(monkeypatch, "setattr")(code_rules_enforcer.sys, "stdin", io.StringIO(edit_payload))
|
|
116
|
+
try:
|
|
117
|
+
code_rules_enforcer.main([])
|
|
118
|
+
except SystemExit:
|
|
119
|
+
pass
|
|
120
|
+
captured = getattr(capsys, "readouterr")()
|
|
121
|
+
return captured.out
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_precheck_stream_parameter_carries_no_banned_noun() -> None:
|
|
125
|
+
"""The pre-check helper's stream parameter must not carry a banned noun.
|
|
126
|
+
|
|
127
|
+
A parameter such as ``output_stream`` carries the banned noun ``output``;
|
|
128
|
+
the hook-infrastructure exemption hides it on a direct edit, but the pull
|
|
129
|
+
request gate and a reviewer judge this source at a production path, where the
|
|
130
|
+
banned-noun check fires. Scanning the enforcer source at a production path
|
|
131
|
+
proves the introduced stream parameter is free of that violation."""
|
|
132
|
+
enforcer_source = _ENFORCER_SCRIPT_PATH.read_text(encoding="utf-8")
|
|
133
|
+
issues = code_rules_enforcer.validate_content(
|
|
134
|
+
enforcer_source, "/project/src/code_rules_enforcer.py"
|
|
135
|
+
)
|
|
136
|
+
banned_noun_issues = [
|
|
137
|
+
each_issue
|
|
138
|
+
for each_issue in issues
|
|
139
|
+
if "banned noun" in each_issue.lower() and "stream" in each_issue
|
|
140
|
+
]
|
|
141
|
+
assert banned_noun_issues == [], (
|
|
142
|
+
f"the stream parameter must carry no banned noun, got: {banned_noun_issues!r}"
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_precheck_reports_violations_and_exits_nonzero_for_production_candidate(
|
|
147
|
+
tmp_path_factory: object,
|
|
148
|
+
) -> None:
|
|
149
|
+
"""A violating candidate judged at a production target prints each violation to
|
|
150
|
+
stdout and exits 1."""
|
|
151
|
+
staging_directory = getattr(tmp_path_factory, "mktemp")("staging")
|
|
152
|
+
production_directory = getattr(tmp_path_factory, "mktemp")("production_pkg")
|
|
153
|
+
candidate_file = staging_directory / "candidate.py"
|
|
154
|
+
candidate_file.write_text(_VIOLATING_PRODUCTION_SOURCE, encoding="utf-8")
|
|
155
|
+
target_path = str(production_directory / "production_module.py")
|
|
156
|
+
completed = _run_precheck(str(candidate_file), target_path)
|
|
157
|
+
assert completed.returncode == 1, (
|
|
158
|
+
f"violating candidate must exit nonzero, got: {completed.returncode}, "
|
|
159
|
+
f"stdout: {completed.stdout!r}, stderr: {completed.stderr!r}"
|
|
160
|
+
)
|
|
161
|
+
assert "process_data" in completed.stdout, (
|
|
162
|
+
f"banned-prefix violation must appear on stdout, got: {completed.stdout!r}"
|
|
163
|
+
)
|
|
164
|
+
assert "print" in completed.stdout, (
|
|
165
|
+
f"library-print violation must appear on stdout, got: {completed.stdout!r}"
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def test_precheck_emits_no_output_and_exits_zero_for_clean_candidate(
|
|
170
|
+
tmp_path_factory: object,
|
|
171
|
+
) -> None:
|
|
172
|
+
"""A clean candidate produces no stdout and exits 0."""
|
|
173
|
+
staging_directory = getattr(tmp_path_factory, "mktemp")("staging")
|
|
174
|
+
production_directory = getattr(tmp_path_factory, "mktemp")("scripts")
|
|
175
|
+
candidate_file = staging_directory / "candidate.py"
|
|
176
|
+
candidate_file.write_text(_CLEAN_CLI_SOURCE, encoding="utf-8")
|
|
177
|
+
target_path = str(production_directory / "announce_cli.py")
|
|
178
|
+
completed = _run_precheck(str(candidate_file), target_path)
|
|
179
|
+
assert completed.returncode == 0, (
|
|
180
|
+
f"clean candidate must exit 0, got: {completed.returncode}, "
|
|
181
|
+
f"stdout: {completed.stdout!r}, stderr: {completed.stderr!r}"
|
|
182
|
+
)
|
|
183
|
+
assert completed.stdout == "", f"clean candidate must emit no stdout, got: {completed.stdout!r}"
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def test_precheck_target_path_drives_test_file_exemptions(
|
|
187
|
+
tmp_path_factory: object,
|
|
188
|
+
) -> None:
|
|
189
|
+
"""Production-shaped content judged against a ``test_*.py`` target passes
|
|
190
|
+
because test-file exemptions apply through the target path."""
|
|
191
|
+
staging_directory = getattr(tmp_path_factory, "mktemp")("staging")
|
|
192
|
+
production_directory = getattr(tmp_path_factory, "mktemp")("production_pkg")
|
|
193
|
+
candidate_file = staging_directory / "candidate.py"
|
|
194
|
+
candidate_file.write_text(_VIOLATING_PRODUCTION_SOURCE, encoding="utf-8")
|
|
195
|
+
target_path = str(production_directory / "test_orders.py")
|
|
196
|
+
completed = _run_precheck(str(candidate_file), target_path)
|
|
197
|
+
assert completed.returncode == 0, (
|
|
198
|
+
"test-file exemptions must apply through the target path, "
|
|
199
|
+
f"got exit: {completed.returncode}, stdout: {completed.stdout!r}"
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def test_precheck_noncode_target_exits_zero_with_no_output(
|
|
204
|
+
tmp_path_factory: object,
|
|
205
|
+
) -> None:
|
|
206
|
+
"""A non-code target extension is exempt: the pre-check exits 0 with no output."""
|
|
207
|
+
staging_directory = getattr(tmp_path_factory, "mktemp")("staging")
|
|
208
|
+
production_directory = getattr(tmp_path_factory, "mktemp")("production_pkg")
|
|
209
|
+
candidate_file = staging_directory / "candidate.py"
|
|
210
|
+
candidate_file.write_text(_VIOLATING_PRODUCTION_SOURCE, encoding="utf-8")
|
|
211
|
+
target_path = str(production_directory / "notes.txt")
|
|
212
|
+
completed = _run_precheck(str(candidate_file), target_path)
|
|
213
|
+
assert completed.returncode == 0, f"non-code target must exit 0, got: {completed.returncode}"
|
|
214
|
+
assert completed.stdout == "", f"non-code target must emit no stdout, got: {completed.stdout!r}"
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def test_precheck_missing_candidate_errors_on_stderr_without_traceback(
|
|
218
|
+
tmp_path_factory: object,
|
|
219
|
+
) -> None:
|
|
220
|
+
"""A nonexistent candidate path prints a one-line stderr error and exits
|
|
221
|
+
nonzero without a Python traceback."""
|
|
222
|
+
production_directory = getattr(tmp_path_factory, "mktemp")("production_pkg")
|
|
223
|
+
missing_candidate = str(production_directory / "nonexistent_candidate.py")
|
|
224
|
+
target_path = str(production_directory / "production_module.py")
|
|
225
|
+
completed = _run_precheck(missing_candidate, target_path)
|
|
226
|
+
assert completed.returncode != 0, (
|
|
227
|
+
f"missing candidate must exit nonzero, got: {completed.returncode}"
|
|
228
|
+
)
|
|
229
|
+
assert "Traceback" not in completed.stderr, (
|
|
230
|
+
f"missing candidate must not raise a traceback, got: {completed.stderr!r}"
|
|
231
|
+
)
|
|
232
|
+
assert completed.stderr.strip() != "", "missing candidate must produce a stderr error message"
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def test_edit_deny_reason_includes_forecast_and_precheck_hint(
|
|
236
|
+
tmp_path_factory: object,
|
|
237
|
+
monkeypatch: object,
|
|
238
|
+
capsys: object,
|
|
239
|
+
) -> None:
|
|
240
|
+
"""An Edit whose new_string introduces a violation on a file that already
|
|
241
|
+
contains a separate violation elsewhere blocks on the fragment violation and
|
|
242
|
+
appends a full-file forecast naming the untouched violation plus the
|
|
243
|
+
pre-check hint."""
|
|
244
|
+
production_directory = getattr(tmp_path_factory, "mktemp")("production_pkg")
|
|
245
|
+
untouched_print_violation = _UNTOUCHED_PRINT_VIOLATION_SOURCE
|
|
246
|
+
clean_before = _CLEAN_FRAGMENT_BEFORE
|
|
247
|
+
introduces_banned_noun_after = (
|
|
248
|
+
"def short_helper() -> int:\n output = 0\n return output\n"
|
|
249
|
+
)
|
|
250
|
+
on_disk_before = untouched_print_violation + "\n" + clean_before
|
|
251
|
+
source_file = production_directory / "production_module.py"
|
|
252
|
+
source_file.write_text(on_disk_before, encoding="utf-8")
|
|
253
|
+
stdout = _run_main_with_edit_payload(
|
|
254
|
+
str(source_file),
|
|
255
|
+
clean_before,
|
|
256
|
+
introduces_banned_noun_after,
|
|
257
|
+
monkeypatch,
|
|
258
|
+
capsys,
|
|
259
|
+
)
|
|
260
|
+
deny_payload = json.loads(stdout)
|
|
261
|
+
reason = deny_payload["hookSpecificOutput"]["permissionDecisionReason"]
|
|
262
|
+
blocking_section, _, forecast_section = reason.partition("FULL-FILE FORECAST")
|
|
263
|
+
assert "output" in blocking_section, (
|
|
264
|
+
f"fragment-introduced banned-noun must block, got reason: {reason!r}"
|
|
265
|
+
)
|
|
266
|
+
assert forecast_section != "", (
|
|
267
|
+
f"forecast section must be present, got reason: {reason!r}"
|
|
268
|
+
)
|
|
269
|
+
assert "Library print()" in forecast_section, (
|
|
270
|
+
f"forecast must name the untouched print violation, got reason: {reason!r}"
|
|
271
|
+
)
|
|
272
|
+
assert "--check" in reason, f"pre-check hint must be present, got reason: {reason!r}"
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def test_edit_clean_fragment_on_dirty_file_produces_no_deny_payload(
|
|
276
|
+
tmp_path_factory: object,
|
|
277
|
+
monkeypatch: object,
|
|
278
|
+
capsys: object,
|
|
279
|
+
) -> None:
|
|
280
|
+
"""A clean Edit fragment on a file that is dirty elsewhere must not block —
|
|
281
|
+
the forecast never converts a clean-fragment edit into a deny."""
|
|
282
|
+
production_directory = getattr(tmp_path_factory, "mktemp")("production_pkg")
|
|
283
|
+
untouched_print_violation = _UNTOUCHED_PRINT_VIOLATION_SOURCE
|
|
284
|
+
clean_before = _CLEAN_FRAGMENT_BEFORE
|
|
285
|
+
clean_after = "def short_helper() -> int:\n return 0\n"
|
|
286
|
+
on_disk_before = untouched_print_violation + "\n" + clean_before
|
|
287
|
+
source_file = production_directory / "production_module.py"
|
|
288
|
+
source_file.write_text(on_disk_before, encoding="utf-8")
|
|
289
|
+
stdout = _run_main_with_edit_payload(
|
|
290
|
+
str(source_file),
|
|
291
|
+
clean_before,
|
|
292
|
+
clean_after,
|
|
293
|
+
monkeypatch,
|
|
294
|
+
capsys,
|
|
295
|
+
)
|
|
296
|
+
assert stdout == "", (
|
|
297
|
+
f"a clean fragment on a dirty file must not produce a deny payload, got stdout: {stdout!r}"
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def test_every_deny_reason_carries_the_precheck_hint(
|
|
302
|
+
tmp_path_factory: object,
|
|
303
|
+
monkeypatch: object,
|
|
304
|
+
capsys: object,
|
|
305
|
+
) -> None:
|
|
306
|
+
"""A deny with no forecast still appends the pre-check hint to the reason."""
|
|
307
|
+
production_directory = getattr(tmp_path_factory, "mktemp")("production_pkg")
|
|
308
|
+
clean_before = _CLEAN_FRAGMENT_BEFORE
|
|
309
|
+
introduces_violation_after = "def short_helper() -> int:\n print(1)\n return 1\n"
|
|
310
|
+
source_file = production_directory / "production_module.py"
|
|
311
|
+
source_file.write_text(clean_before, encoding="utf-8")
|
|
312
|
+
stdout = _run_main_with_edit_payload(
|
|
313
|
+
str(source_file),
|
|
314
|
+
clean_before,
|
|
315
|
+
introduces_violation_after,
|
|
316
|
+
monkeypatch,
|
|
317
|
+
capsys,
|
|
318
|
+
)
|
|
319
|
+
deny_payload = json.loads(stdout)
|
|
320
|
+
reason = deny_payload["hookSpecificOutput"]["permissionDecisionReason"]
|
|
321
|
+
assert "--check" in reason, (
|
|
322
|
+
f"every deny reason must carry the pre-check hint, got reason: {reason!r}"
|
|
323
|
+
)
|
|
324
|
+
quoted_script_path = f'"{_ENFORCER_SCRIPT_PATH.resolve()}"'
|
|
325
|
+
assert quoted_script_path in reason, (
|
|
326
|
+
f"hint must quote the script path for space-safe copy-paste, got reason: {reason!r}"
|
|
327
|
+
)
|
|
328
|
+
quoted_interpreter_path = f'"{sys.executable}"'
|
|
329
|
+
assert quoted_interpreter_path in reason, (
|
|
330
|
+
f"hint must name the running interpreter, quoted, got reason: {reason!r}"
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def test_forecast_skipped_when_edit_prior_is_unreconstructable(
|
|
335
|
+
tmp_path_factory: object,
|
|
336
|
+
monkeypatch: object,
|
|
337
|
+
capsys: object,
|
|
338
|
+
) -> None:
|
|
339
|
+
"""An Edit whose old_string is absent from the file has no reliable prior to
|
|
340
|
+
diff against, so the full-file forecast must not run: a pre-existing inline
|
|
341
|
+
comment must never surface as an 'inline comment added' forecast entry that
|
|
342
|
+
falsely claims an untouched comment will block future edits."""
|
|
343
|
+
production_directory = getattr(tmp_path_factory, "mktemp")("production_pkg")
|
|
344
|
+
commented_on_disk = "tally = 1 # pre-existing inline note\n"
|
|
345
|
+
source_file = production_directory / "production_module.py"
|
|
346
|
+
source_file.write_text(commented_on_disk, encoding="utf-8")
|
|
347
|
+
absent_old = "def absent_function() -> int:\n return 0\n"
|
|
348
|
+
introduces_print_new = "def absent_function() -> int:\n print(1)\n return 2\n"
|
|
349
|
+
stdout = _run_main_with_edit_payload(
|
|
350
|
+
str(source_file),
|
|
351
|
+
absent_old,
|
|
352
|
+
introduces_print_new,
|
|
353
|
+
monkeypatch,
|
|
354
|
+
capsys,
|
|
355
|
+
)
|
|
356
|
+
deny_payload = json.loads(stdout)
|
|
357
|
+
reason = deny_payload["hookSpecificOutput"]["permissionDecisionReason"]
|
|
358
|
+
assert "pre-existing inline note" not in reason, (
|
|
359
|
+
"an unreconstructable-prior Edit must not forecast a pre-existing comment "
|
|
360
|
+
f"as a future blocker, got reason: {reason!r}"
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def test_forecast_omits_the_fragment_introduced_violation(
|
|
365
|
+
tmp_path_factory: object,
|
|
366
|
+
monkeypatch: object,
|
|
367
|
+
capsys: object,
|
|
368
|
+
) -> None:
|
|
369
|
+
"""An Edit that introduces the file's only print() blocks on it without the
|
|
370
|
+
forecast re-listing that same violation under its full-file line number."""
|
|
371
|
+
production_directory = getattr(tmp_path_factory, "mktemp")("production_pkg")
|
|
372
|
+
padding_helper = "def first_helper() -> int:\n return 1\n"
|
|
373
|
+
introduces_print_after = "def short_helper() -> int:\n print(1)\n return 1\n"
|
|
374
|
+
on_disk_before = padding_helper + "\n" + _CLEAN_FRAGMENT_BEFORE
|
|
375
|
+
source_file = production_directory / "production_module.py"
|
|
376
|
+
source_file.write_text(on_disk_before, encoding="utf-8")
|
|
377
|
+
stdout = _run_main_with_edit_payload(
|
|
378
|
+
str(source_file),
|
|
379
|
+
_CLEAN_FRAGMENT_BEFORE,
|
|
380
|
+
introduces_print_after,
|
|
381
|
+
monkeypatch,
|
|
382
|
+
capsys,
|
|
383
|
+
)
|
|
384
|
+
deny_payload = json.loads(stdout)
|
|
385
|
+
reason = deny_payload["hookSpecificOutput"]["permissionDecisionReason"]
|
|
386
|
+
blocking_section, _, forecast_section = reason.partition("FULL-FILE FORECAST")
|
|
387
|
+
assert "Library print()" in blocking_section, (
|
|
388
|
+
f"the fragment-introduced print must block, got reason: {reason!r}"
|
|
389
|
+
)
|
|
390
|
+
assert "Library print()" not in forecast_section, (
|
|
391
|
+
f"the forecast must not re-list the fragment's own violation, got reason: {reason!r}"
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def test_precheck_missing_candidate_value_exits_two_with_usage() -> None:
|
|
396
|
+
"""A ``--check`` flag with no candidate path is a usage error: exit 2 with a
|
|
397
|
+
usage line on stderr, never a silent clean verdict."""
|
|
398
|
+
completed = _run_enforcer_cli(["--check"])
|
|
399
|
+
assert completed.returncode == 2, (
|
|
400
|
+
f"missing candidate value must exit 2, got: {completed.returncode}, "
|
|
401
|
+
f"stdout: {completed.stdout!r}, stderr: {completed.stderr!r}"
|
|
402
|
+
)
|
|
403
|
+
assert "usage:" in completed.stderr, (
|
|
404
|
+
f"missing candidate value must print usage on stderr, got: {completed.stderr!r}"
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def test_precheck_flag_shaped_candidate_value_exits_two_with_usage(
|
|
409
|
+
tmp_path_factory: object,
|
|
410
|
+
) -> None:
|
|
411
|
+
"""``--check`` immediately followed by ``--as`` is a usage error rather than
|
|
412
|
+
an attempt to read a file literally named ``--as``."""
|
|
413
|
+
production_directory = getattr(tmp_path_factory, "mktemp")("production_pkg")
|
|
414
|
+
target_path = str(production_directory / "production_module.py")
|
|
415
|
+
completed = _run_enforcer_cli(["--check", "--as", target_path])
|
|
416
|
+
assert completed.returncode == 2, (
|
|
417
|
+
f"flag-shaped candidate value must exit 2, got: {completed.returncode}, "
|
|
418
|
+
f"stdout: {completed.stdout!r}, stderr: {completed.stderr!r}"
|
|
419
|
+
)
|
|
420
|
+
assert "usage:" in completed.stderr, (
|
|
421
|
+
f"flag-shaped candidate value must print usage on stderr, got: {completed.stderr!r}"
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def test_precheck_rejects_unrecognized_trailing_token_with_usage(
|
|
426
|
+
tmp_path_factory: object,
|
|
427
|
+
) -> None:
|
|
428
|
+
"""A pre-check vector carrying a token beyond the supported
|
|
429
|
+
``--check <candidate> [--as <target>]`` shape is a usage error: an extra
|
|
430
|
+
trailing token never silently passes as a clean verdict on the candidate."""
|
|
431
|
+
staging_directory = getattr(tmp_path_factory, "mktemp")("staging")
|
|
432
|
+
production_directory = getattr(tmp_path_factory, "mktemp")("scripts")
|
|
433
|
+
candidate_file = staging_directory / "candidate.py"
|
|
434
|
+
candidate_file.write_text(_CLEAN_CLI_SOURCE, encoding="utf-8")
|
|
435
|
+
target_path = str(production_directory / "announce_cli.py")
|
|
436
|
+
completed = _run_enforcer_cli(
|
|
437
|
+
["--check", str(candidate_file), "--as", target_path, "--unexpected"]
|
|
438
|
+
)
|
|
439
|
+
assert completed.returncode == 2, (
|
|
440
|
+
f"a trailing unrecognized token must exit 2, got: {completed.returncode}, "
|
|
441
|
+
f"stdout: {completed.stdout!r}, stderr: {completed.stderr!r}"
|
|
442
|
+
)
|
|
443
|
+
assert "usage:" in completed.stderr, (
|
|
444
|
+
f"a trailing unrecognized token must print usage on stderr, got: {completed.stderr!r}"
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def test_precheck_strips_candidate_byte_order_mark_before_validation(
|
|
449
|
+
tmp_path_factory: object,
|
|
450
|
+
) -> None:
|
|
451
|
+
"""A UTF-8 byte-order mark on the candidate must not hide AST-based
|
|
452
|
+
violations: the candidate is judged exactly as its decoded content would
|
|
453
|
+
arrive in a live tool payload."""
|
|
454
|
+
staging_directory = getattr(tmp_path_factory, "mktemp")("staging")
|
|
455
|
+
production_directory = getattr(tmp_path_factory, "mktemp")("production_pkg")
|
|
456
|
+
candidate_file = staging_directory / "candidate.py"
|
|
457
|
+
candidate_file.write_text(
|
|
458
|
+
"\ufeff" + _VIOLATING_PRODUCTION_SOURCE, encoding="utf-8"
|
|
459
|
+
)
|
|
460
|
+
target_path = str(production_directory / "production_module.py")
|
|
461
|
+
completed = _run_precheck(str(candidate_file), target_path)
|
|
462
|
+
assert completed.returncode == 1, (
|
|
463
|
+
f"a BOM candidate must fail exactly like its no-BOM twin, got: "
|
|
464
|
+
f"{completed.returncode}, stdout: {completed.stdout!r}, "
|
|
465
|
+
f"stderr: {completed.stderr!r}"
|
|
466
|
+
)
|
|
467
|
+
assert "process_data" in completed.stdout, (
|
|
468
|
+
f"banned-prefix violation must appear on stdout, got: {completed.stdout!r}"
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def test_precheck_strips_every_leading_byte_order_mark_before_validation(
|
|
473
|
+
tmp_path_factory: object,
|
|
474
|
+
) -> None:
|
|
475
|
+
"""Stacked byte-order marks must not hide AST-based violations: every
|
|
476
|
+
leading mark is stripped, so a double-BOM candidate fails exactly like its
|
|
477
|
+
clean-prefixed twin rather than silently skipping AST parsing."""
|
|
478
|
+
staging_directory = getattr(tmp_path_factory, "mktemp")("staging")
|
|
479
|
+
production_directory = getattr(tmp_path_factory, "mktemp")("production_pkg")
|
|
480
|
+
candidate_file = staging_directory / "candidate.py"
|
|
481
|
+
candidate_file.write_text(
|
|
482
|
+
"\ufeff\ufeff" + _VIOLATING_PRODUCTION_SOURCE, encoding="utf-8"
|
|
483
|
+
)
|
|
484
|
+
target_path = str(production_directory / "production_module.py")
|
|
485
|
+
completed = _run_precheck(str(candidate_file), target_path)
|
|
486
|
+
assert completed.returncode == 1, (
|
|
487
|
+
f"a double-BOM candidate must fail exactly like its no-BOM twin, got: "
|
|
488
|
+
f"{completed.returncode}, stdout: {completed.stdout!r}, "
|
|
489
|
+
f"stderr: {completed.stderr!r}"
|
|
490
|
+
)
|
|
491
|
+
assert "process_data" in completed.stdout, (
|
|
492
|
+
f"banned-prefix violation must appear on stdout, got: {completed.stdout!r}"
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def test_precheck_duplicate_flags_exit_two_with_usage() -> None:
|
|
497
|
+
"""A repeated ``--check`` or ``--as`` flag is an ambiguous vector: the
|
|
498
|
+
pre-check rejects it with the usage exit code before reading any file,
|
|
499
|
+
never silently judging only the first occurrence."""
|
|
500
|
+
duplicate_check = _run_enforcer_cli(
|
|
501
|
+
["--check", "first_candidate.py", "--check", "second_candidate.py"]
|
|
502
|
+
)
|
|
503
|
+
assert duplicate_check.returncode == 2, (
|
|
504
|
+
f"duplicate --check must exit 2, got: {duplicate_check.returncode}, "
|
|
505
|
+
f"stdout: {duplicate_check.stdout!r}, stderr: {duplicate_check.stderr!r}"
|
|
506
|
+
)
|
|
507
|
+
assert "usage:" in duplicate_check.stderr, (
|
|
508
|
+
f"duplicate --check must print usage on stderr, got: {duplicate_check.stderr!r}"
|
|
509
|
+
)
|
|
510
|
+
duplicate_as = _run_enforcer_cli(
|
|
511
|
+
["--check", "candidate.py", "--as", "first_target.py", "--as", "second_target.py"]
|
|
512
|
+
)
|
|
513
|
+
assert duplicate_as.returncode == 2, (
|
|
514
|
+
f"duplicate --as must exit 2, got: {duplicate_as.returncode}, "
|
|
515
|
+
f"stdout: {duplicate_as.stdout!r}, stderr: {duplicate_as.stderr!r}"
|
|
516
|
+
)
|
|
517
|
+
assert "usage:" in duplicate_as.stderr, (
|
|
518
|
+
f"duplicate --as must print usage on stderr, got: {duplicate_as.stderr!r}"
|
|
519
|
+
)
|
|
@@ -72,7 +72,7 @@ def _run_main_with_edit_payload(
|
|
|
72
72
|
)
|
|
73
73
|
getattr(monkeypatch, "setattr")(code_rules_enforcer.sys, "stdin", io.StringIO(edit_payload))
|
|
74
74
|
try:
|
|
75
|
-
code_rules_enforcer.main()
|
|
75
|
+
code_rules_enforcer.main([])
|
|
76
76
|
except SystemExit:
|
|
77
77
|
pass
|
|
78
78
|
captured = getattr(capsys, "readouterr")()
|