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.
@@ -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")()