claude-dev-env 2.18.0 → 2.20.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.
Files changed (133) hide show
  1. package/.agents/skills/AGENTS.md +4 -4
  2. package/.agents/skills/_shared/AGENTS.md +1 -0
  3. package/.agents/skills/_shared/pr-loop/AGENTS.md +1 -0
  4. package/.agents/skills/_shared/pr-loop/preflight-proposal.contract.test.mjs +119 -0
  5. package/.agents/skills/_shared/pr-loop/preflight-proposal.md +5 -0
  6. package/.agents/skills/e-code-review/SKILL.md +12 -1
  7. package/.agents/skills/e-code-review/reference/fix.md +5 -1
  8. package/.agents/skills/e-code-review/reference/loop.md +4 -0
  9. package/.agents/skills/e-code-review/reference/mode-contract.test.mjs +66 -0
  10. package/.agents/skills/e-code-review/reference/preflight-proposal.md +40 -0
  11. package/.agents/skills/e-code-review/reference/runner-selection.md +1 -0
  12. package/.agents/skills/pr-cleanup/SKILL.md +105 -75
  13. package/.agents/skills/pr-cleanup/reference/process-inventory.md +15 -0
  14. package/.agents/skills/pr-cleanup/reference/task-seeds.md +20 -0
  15. package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/SKILL.md +18 -12
  16. package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/offense-examples.md +1 -1
  17. package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/task-seeds.md +3 -2
  18. package/.agents/skills/pr-refinement/SKILL.md +31 -0
  19. package/.agents/skills/{shared-extraction-audit → pr-shared-extraction}/SKILL.md +24 -7
  20. package/.agents/skills/{small-cl → pr-small-cl}/SKILL.md +1 -1
  21. package/.agents/skills/prototype/scripts/test_probe_sandbox_safety.py +33 -5
  22. package/.agents/skills/skill-builder/SKILL.md +1 -0
  23. package/.agents/skills-archived/bugteam/SKILL.md +1 -1
  24. package/.agents/skills-archived/bugteam/test_skill_additions.py +1 -1
  25. package/.agents/skills-archived/comments/SKILL.md +1 -1
  26. package/.agents/skills-archived/descriptions/SKILL.md +1 -1
  27. package/.agents/skills-archived/reviews/SKILL.md +1 -1
  28. package/AGENTS.md +1 -1
  29. package/_shared/pr-loop/AGENTS.md +2 -1
  30. package/_shared/pr-loop/preflight-proposal.md +56 -0
  31. package/_shared/pr-loop/scripts/code_rules_gate.py +29 -6
  32. package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_arguments.py +15 -3
  33. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_gate_running.py +47 -4
  34. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +4 -0
  35. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +47 -0
  36. package/bin/ever-shipped-skills.mjs +14 -0
  37. package/bin/install-constants.mjs +5 -0
  38. package/bin/install.agents-home.test.mjs +23 -0
  39. package/bin/install.codex-rules.test.mjs +61 -6
  40. package/bin/install.mjs +57 -9
  41. package/bin/install.prune.test.mjs +65 -3
  42. package/bin/install.test.mjs +202 -2
  43. package/docs/CODE_RULES.md +6 -0
  44. package/hooks/advisory/conftest.py +10 -0
  45. package/hooks/advisory/migration_safety_advisor.py +14 -14
  46. package/hooks/advisory/refactor_guard.py +250 -144
  47. package/hooks/advisory/refactor_guard_test_support.py +46 -0
  48. package/hooks/advisory/test_migration_safety_advisor.py +120 -0
  49. package/hooks/advisory/test_refactor_guard_advisory.py +171 -0
  50. package/hooks/advisory/test_refactor_guard_eligibility.py +166 -0
  51. package/hooks/atomic_file_writer.py +81 -0
  52. package/hooks/blocking/AGENTS.md +2 -1
  53. package/hooks/blocking/block_main_commit.py +66 -33
  54. package/hooks/blocking/code_rules_banned_identifiers.py +55 -4
  55. package/hooks/blocking/code_rules_blast_radius.py +194 -0
  56. package/hooks/blocking/code_rules_enforcer.py +12 -0
  57. package/hooks/blocking/code_rules_enforcer_test_support.py +92 -0
  58. package/hooks/blocking/destructive_command_blocker.py +8 -7
  59. package/hooks/blocking/pre_tool_use_dispatcher.py +11 -7
  60. package/hooks/blocking/precommit_code_rules_gate.py +14 -139
  61. package/hooks/blocking/sensitive_file_protector.py +8 -6
  62. package/hooks/blocking/session_edit_stage_gate.py +31 -1
  63. package/hooks/blocking/test_block_main_commit.py +145 -0
  64. package/hooks/blocking/test_claude_md_orphan_file_blocker.py +171 -1
  65. package/hooks/blocking/test_code_rules_blast_radius.py +161 -0
  66. package/hooks/blocking/test_code_rules_enforcer_agent_home_tooling.py +37 -12
  67. package/hooks/blocking/test_code_rules_enforcer_banned_noun_word.py +54 -0
  68. package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +15 -53
  69. package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +13 -62
  70. package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +309 -0
  71. package/hooks/blocking/test_code_rules_enforcer_precheck_forecast.py +14 -71
  72. package/hooks/blocking/test_code_rules_enforcer_scratchpad.py +14 -16
  73. package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +31 -46
  74. package/hooks/blocking/test_code_rules_enforcer_stage.py +192 -0
  75. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +13 -59
  76. package/hooks/blocking/test_destructive_command_blocker.py +154 -138
  77. package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +85 -25
  78. package/hooks/blocking/test_destructive_command_blocker_no_verify.py +58 -38
  79. package/hooks/blocking/test_destructive_command_blocker_patterns.py +133 -0
  80. package/hooks/blocking/test_docstring_rule_gate_count_blocker.py +113 -1
  81. package/hooks/blocking/test_env_var_table_code_drift_blocker.py +70 -3
  82. package/hooks/blocking/test_hook_subprocess_support.py +296 -0
  83. package/hooks/blocking/test_package_inventory_stale_blocker.py +1 -1
  84. package/hooks/blocking/test_pii_scanner.py +81 -0
  85. package/hooks/blocking/test_pre_tool_use_dispatcher.py +32 -3
  86. package/hooks/blocking/test_precommit_code_rules_gate.py +11 -16
  87. package/hooks/blocking/test_precommit_code_rules_gate_native_owner.py +234 -0
  88. package/hooks/blocking/test_pytest_testpaths_orphan_blocker.py +25 -18
  89. package/hooks/blocking/test_sensitive_file_protector.py +145 -5
  90. package/hooks/blocking/test_session_edit_stage_gate_staging.py +64 -0
  91. package/hooks/blocking/test_session_edit_stage_gate_support.py +204 -0
  92. package/hooks/blocking/test_session_edit_stage_gate_tracker.py +116 -0
  93. package/hooks/blocking/test_shared_stdin_adoption.py +31 -20
  94. package/hooks/blocking/test_test_preflight_check.py +80 -0
  95. package/hooks/git-hooks/AGENTS.md +1 -1
  96. package/hooks/git-hooks/git_hooks_constants/__init__.py +1 -0
  97. package/hooks/git-hooks/post_commit.py +160 -51
  98. package/hooks/git-hooks/pre_commit.py +9 -5
  99. package/hooks/git-hooks/test_post_commit.py +203 -0
  100. package/hooks/git-hooks/test_pre_commit.py +2 -2
  101. package/hooks/git-hooks/test_pre_push.py +57 -0
  102. package/hooks/hooks_constants/AGENTS.md +3 -1
  103. package/hooks/hooks_constants/atomic_file_writer_constants.py +4 -0
  104. package/hooks/hooks_constants/banned_identifiers_constants.py +1 -0
  105. package/hooks/hooks_constants/blast_radius_constants.py +14 -0
  106. package/hooks/hooks_constants/destructive_command_environment_constants.py +18 -0
  107. package/hooks/hooks_constants/destructive_command_segment_constants.py +1 -11
  108. package/hooks/hooks_constants/hardcoded_user_path_constants.py +9 -3
  109. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +1 -1
  110. package/hooks/hooks_constants/refactor_guard_constants.py +75 -0
  111. package/hooks/hooks_constants/sensitive_file_protector_constants.py +2 -4
  112. package/hooks/hooks_constants/test_refactor_guard_constants.py +21 -0
  113. package/hooks/json_file_reader.py +22 -0
  114. package/hooks/observability/test_instructions_loaded_logger.py +54 -0
  115. package/hooks/pending_sidecars.py +33 -0
  116. package/hooks/session/test_plugin_data_dir_cleanup.py +70 -0
  117. package/hooks/session/test_session_edit_tracker_cleanup.py +16 -3
  118. package/hooks/test_atomic_file_writer.py +73 -0
  119. package/hooks/test_json_file_reader.py +24 -0
  120. package/hooks/test_pending_sidecars.py +25 -0
  121. package/hooks/validation/mypy_validator.py +213 -80
  122. package/hooks/validation/test_mypy_validator.py +288 -13
  123. package/hooks/workflow/auto_formatter.py +225 -93
  124. package/hooks/workflow/investigation_tracker_reset.py +2 -0
  125. package/hooks/workflow/test_auto_formatter.py +261 -12
  126. package/hooks/workflow/test_investigation_tracker_reset.py +90 -0
  127. package/package.json +1 -1
  128. package/rules/failure-blast-radius.md +126 -0
  129. /package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/fetch-commands.md +0 -0
  130. /package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/report-template.md +0 -0
  131. /package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/rule-checklist.md +0 -0
  132. /package/.agents/skills/{shared-extraction-audit → pr-shared-extraction}/reference/examples.md +0 -0
  133. /package/.agents/skills/{shared-extraction-audit → pr-shared-extraction}/reference/offense-taxonomy.md +0 -0
@@ -2,12 +2,10 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import io
6
5
  import json
7
6
  import subprocess
8
7
  import sys
9
8
  from pathlib import Path
10
- from types import SimpleNamespace
11
9
 
12
10
  _BLOCKING_DIRECTORY = str(Path(__file__).resolve().parent)
13
11
  _HOOKS_DIRECTORY = str(Path(__file__).resolve().parent.parent)
@@ -20,11 +18,10 @@ from code_rules_enforcer import ( # noqa: E402
20
18
  main,
21
19
  validate_content,
22
20
  )
23
-
24
- code_rules_enforcer = SimpleNamespace(
25
- main=main,
26
- sys=sys,
27
- validate_content=validate_content,
21
+ from code_rules_enforcer_test_support import ( # noqa: E402
22
+ run_enforcer_cli,
23
+ run_precheck,
24
+ run_serialized_payload_entrypoint,
28
25
  )
29
26
 
30
27
  _ENFORCER_SCRIPT_PATH = Path(__file__).resolve().parent / "code_rules_enforcer.py"
@@ -57,13 +54,7 @@ def _run_enforcer_cli(
57
54
  Returns:
58
55
  The completed process carrying stdout, stderr, and the exit code.
59
56
  """
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
- )
57
+ return run_enforcer_cli(_ENFORCER_SCRIPT_PATH, all_cli_arguments, None)
67
58
 
68
59
 
69
60
  def _run_precheck(
@@ -80,28 +71,15 @@ def _run_precheck(
80
71
  Returns:
81
72
  The completed process carrying the pre-check stdout, stderr, and exit code.
82
73
  """
83
- return _run_enforcer_cli(["--check", candidate_path, "--as", target_path])
74
+ return run_precheck(Path(candidate_path), target_path, None)
84
75
 
85
76
 
86
77
  def _run_main_with_edit_payload(
87
78
  file_path: str,
88
79
  old_string: str,
89
80
  new_string: str,
90
- monkeypatch: object,
91
- capsys: object,
92
81
  ) -> 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
- """
82
+ """Drive ``main()`` through its stdin entry point for an Edit."""
105
83
  edit_payload = json.dumps(
106
84
  {
107
85
  "tool_name": "Edit",
@@ -112,13 +90,8 @@ def _run_main_with_edit_payload(
112
90
  },
113
91
  }
114
92
  )
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
93
+ captured_stdout, _exit_code = run_serialized_payload_entrypoint(main, edit_payload)
94
+ return captured_stdout
122
95
 
123
96
 
124
97
  def test_precheck_stream_parameter_carries_no_banned_noun() -> None:
@@ -130,9 +103,7 @@ def test_precheck_stream_parameter_carries_no_banned_noun() -> None:
130
103
  banned-noun check fires. Scanning the enforcer source at a production path
131
104
  proves the introduced stream parameter is free of that violation."""
132
105
  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
- )
106
+ issues = validate_content(enforcer_source, "/project/src/code_rules_enforcer.py")
136
107
  banned_noun_issues = [
137
108
  each_issue
138
109
  for each_issue in issues
@@ -234,8 +205,6 @@ def test_precheck_missing_candidate_errors_on_stderr_without_traceback(
234
205
 
235
206
  def test_edit_deny_reason_includes_forecast_and_precheck_hint(
236
207
  tmp_path_factory: object,
237
- monkeypatch: object,
238
- capsys: object,
239
208
  ) -> None:
240
209
  """An Edit whose new_string introduces a violation on a file that already
241
210
  contains a separate violation elsewhere blocks on the fragment violation and
@@ -244,9 +213,7 @@ def test_edit_deny_reason_includes_forecast_and_precheck_hint(
244
213
  production_directory = getattr(tmp_path_factory, "mktemp")("production_pkg")
245
214
  untouched_print_violation = _UNTOUCHED_PRINT_VIOLATION_SOURCE
246
215
  clean_before = _CLEAN_FRAGMENT_BEFORE
247
- introduces_banned_noun_after = (
248
- "def short_helper() -> int:\n output = 0\n return output\n"
249
- )
216
+ introduces_banned_noun_after = "def short_helper() -> int:\n output = 0\n return output\n"
250
217
  on_disk_before = untouched_print_violation + "\n" + clean_before
251
218
  source_file = production_directory / "production_module.py"
252
219
  source_file.write_text(on_disk_before, encoding="utf-8")
@@ -254,8 +221,6 @@ def test_edit_deny_reason_includes_forecast_and_precheck_hint(
254
221
  str(source_file),
255
222
  clean_before,
256
223
  introduces_banned_noun_after,
257
- monkeypatch,
258
- capsys,
259
224
  )
260
225
  deny_payload = json.loads(stdout)
261
226
  reason = deny_payload["hookSpecificOutput"]["permissionDecisionReason"]
@@ -263,9 +228,7 @@ def test_edit_deny_reason_includes_forecast_and_precheck_hint(
263
228
  assert "output" in blocking_section, (
264
229
  f"fragment-introduced banned-noun must block, got reason: {reason!r}"
265
230
  )
266
- assert forecast_section != "", (
267
- f"forecast section must be present, got reason: {reason!r}"
268
- )
231
+ assert forecast_section != "", f"forecast section must be present, got reason: {reason!r}"
269
232
  assert "Library print()" in forecast_section, (
270
233
  f"forecast must name the untouched print violation, got reason: {reason!r}"
271
234
  )
@@ -274,8 +237,6 @@ def test_edit_deny_reason_includes_forecast_and_precheck_hint(
274
237
 
275
238
  def test_edit_clean_fragment_on_dirty_file_produces_no_deny_payload(
276
239
  tmp_path_factory: object,
277
- monkeypatch: object,
278
- capsys: object,
279
240
  ) -> None:
280
241
  """A clean Edit fragment on a file that is dirty elsewhere must not block —
281
242
  the forecast never converts a clean-fragment edit into a deny."""
@@ -290,8 +251,6 @@ def test_edit_clean_fragment_on_dirty_file_produces_no_deny_payload(
290
251
  str(source_file),
291
252
  clean_before,
292
253
  clean_after,
293
- monkeypatch,
294
- capsys,
295
254
  )
296
255
  assert stdout == "", (
297
256
  f"a clean fragment on a dirty file must not produce a deny payload, got stdout: {stdout!r}"
@@ -300,8 +259,6 @@ def test_edit_clean_fragment_on_dirty_file_produces_no_deny_payload(
300
259
 
301
260
  def test_every_deny_reason_carries_the_precheck_hint(
302
261
  tmp_path_factory: object,
303
- monkeypatch: object,
304
- capsys: object,
305
262
  ) -> None:
306
263
  """A deny with no forecast still appends the pre-check hint to the reason."""
307
264
  production_directory = getattr(tmp_path_factory, "mktemp")("production_pkg")
@@ -313,8 +270,6 @@ def test_every_deny_reason_carries_the_precheck_hint(
313
270
  str(source_file),
314
271
  clean_before,
315
272
  introduces_violation_after,
316
- monkeypatch,
317
- capsys,
318
273
  )
319
274
  deny_payload = json.loads(stdout)
320
275
  reason = deny_payload["hookSpecificOutput"]["permissionDecisionReason"]
@@ -333,8 +288,6 @@ def test_every_deny_reason_carries_the_precheck_hint(
333
288
 
334
289
  def test_forecast_skipped_when_edit_prior_is_unreconstructable(
335
290
  tmp_path_factory: object,
336
- monkeypatch: object,
337
- capsys: object,
338
291
  ) -> None:
339
292
  """An Edit whose old_string is absent from the file has no reliable prior to
340
293
  diff against, so the full-file forecast must not run: a pre-existing inline
@@ -350,8 +303,6 @@ def test_forecast_skipped_when_edit_prior_is_unreconstructable(
350
303
  str(source_file),
351
304
  absent_old,
352
305
  introduces_print_new,
353
- monkeypatch,
354
- capsys,
355
306
  )
356
307
  deny_payload = json.loads(stdout)
357
308
  reason = deny_payload["hookSpecificOutput"]["permissionDecisionReason"]
@@ -363,8 +314,6 @@ def test_forecast_skipped_when_edit_prior_is_unreconstructable(
363
314
 
364
315
  def test_forecast_omits_the_fragment_introduced_violation(
365
316
  tmp_path_factory: object,
366
- monkeypatch: object,
367
- capsys: object,
368
317
  ) -> None:
369
318
  """An Edit that introduces the file's only print() blocks on it without the
370
319
  forecast re-listing that same violation under its full-file line number."""
@@ -378,8 +327,6 @@ def test_forecast_omits_the_fragment_introduced_violation(
378
327
  str(source_file),
379
328
  _CLEAN_FRAGMENT_BEFORE,
380
329
  introduces_print_after,
381
- monkeypatch,
382
- capsys,
383
330
  )
384
331
  deny_payload = json.loads(stdout)
385
332
  reason = deny_payload["hookSpecificOutput"]["permissionDecisionReason"]
@@ -454,9 +401,7 @@ def test_precheck_strips_candidate_byte_order_mark_before_validation(
454
401
  staging_directory = getattr(tmp_path_factory, "mktemp")("staging")
455
402
  production_directory = getattr(tmp_path_factory, "mktemp")("production_pkg")
456
403
  candidate_file = staging_directory / "candidate.py"
457
- candidate_file.write_text(
458
- "\ufeff" + _VIOLATING_PRODUCTION_SOURCE, encoding="utf-8"
459
- )
404
+ candidate_file.write_text("\ufeff" + _VIOLATING_PRODUCTION_SOURCE, encoding="utf-8")
460
405
  target_path = str(production_directory / "production_module.py")
461
406
  completed = _run_precheck(str(candidate_file), target_path)
462
407
  assert completed.returncode == 1, (
@@ -478,9 +423,7 @@ def test_precheck_strips_every_leading_byte_order_mark_before_validation(
478
423
  staging_directory = getattr(tmp_path_factory, "mktemp")("staging")
479
424
  production_directory = getattr(tmp_path_factory, "mktemp")("production_pkg")
480
425
  candidate_file = staging_directory / "candidate.py"
481
- candidate_file.write_text(
482
- "\ufeff\ufeff" + _VIOLATING_PRODUCTION_SOURCE, encoding="utf-8"
483
- )
426
+ candidate_file.write_text("\ufeff\ufeff" + _VIOLATING_PRODUCTION_SOURCE, encoding="utf-8")
484
427
  target_path = str(production_directory / "production_module.py")
485
428
  completed = _run_precheck(str(candidate_file), target_path)
486
429
  assert completed.returncode == 1, (
@@ -1,7 +1,6 @@
1
1
  """Tests for the session-scratchpad exemption wired into code_rules_enforcer.main."""
2
2
 
3
3
  import importlib.util
4
- import io
5
4
  import json
6
5
  import os
7
6
  import sys
@@ -11,6 +10,13 @@ from types import ModuleType
11
10
 
12
11
  import pytest
13
12
 
13
+ _HOOKS_DIRECTORY = str(Path(__file__).resolve().parent.parent)
14
+ if _HOOKS_DIRECTORY not in sys.path:
15
+ sys.path.insert(0, _HOOKS_DIRECTORY)
16
+ from blocking import _path_setup # noqa: F401
17
+
18
+ from code_rules_enforcer_test_support import run_serialized_payload_entrypoint
19
+
14
20
  ENFORCER_PATH = Path(__file__).parent / "code_rules_enforcer.py"
15
21
  FIXED_USER_ID = 6070
16
22
  WORKING_DIRECTORY = "/home/user/project"
@@ -55,19 +61,11 @@ def _write_payload(target: Path) -> dict[str, object]:
55
61
  }
56
62
 
57
63
 
58
- def _run_main(
59
- monkeypatch: pytest.MonkeyPatch, payload: dict[str, object]
60
- ) -> tuple[int | None, str]:
61
- monkeypatch.setattr(sys, "stdin", io.StringIO(json.dumps(payload)))
62
- captured_stdout = io.StringIO()
63
- monkeypatch.setattr(sys, "stdout", captured_stdout)
64
- exit_code: int | None = None
65
- try:
66
- _ENFORCER.main([])
67
- except SystemExit as raised_exit:
68
- raw_code = raised_exit.code
69
- exit_code = raw_code if isinstance(raw_code, int) else None
70
- return exit_code, captured_stdout.getvalue()
64
+ def _run_main(payload_by_key: dict[str, object]) -> tuple[int | None, str]:
65
+ captured_stdout, exit_code = run_serialized_payload_entrypoint(
66
+ _ENFORCER.main, json.dumps(payload_by_key)
67
+ )
68
+ return exit_code, captured_stdout
71
69
 
72
70
 
73
71
  def _decision_from(stdout_text: str) -> str | None:
@@ -84,7 +82,7 @@ def test_scratchpad_write_is_exempt_from_code_rules(
84
82
  monkeypatch.setenv("CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT", "1")
85
83
  throwaway_script = scratchpad_directory / "one_off_probe.py"
86
84
 
87
- exit_code, stdout_text = _run_main(monkeypatch, _write_payload(throwaway_script))
85
+ exit_code, stdout_text = _run_main(_write_payload(throwaway_script))
88
86
 
89
87
  assert exit_code == 0
90
88
  assert _decision_from(stdout_text) != "deny"
@@ -99,7 +97,7 @@ def test_identical_non_scratchpad_write_is_still_blocked(
99
97
  outside_directory.mkdir(parents=True)
100
98
  production_module = outside_directory / "one_off_probe.py"
101
99
 
102
- exit_code, stdout_text = _run_main(monkeypatch, _write_payload(production_module))
100
+ exit_code, stdout_text = _run_main(_write_payload(production_module))
103
101
 
104
102
  assert exit_code == 0
105
103
  assert _decision_from(stdout_text) == "deny"
@@ -2,12 +2,13 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import io
6
5
  import json
7
6
  import sys
8
7
  from pathlib import Path
9
8
  from types import SimpleNamespace
10
9
 
10
+ import pytest
11
+
11
12
  _BLOCKING_DIRECTORY = str(Path(__file__).resolve().parent)
12
13
  _HOOKS_DIRECTORY = str(Path(__file__).resolve().parent.parent)
13
14
  if _BLOCKING_DIRECTORY not in sys.path:
@@ -23,6 +24,9 @@ from code_rules_enforcer import ( # noqa: E402
23
24
  prior_and_post_edit_content,
24
25
  validate_content,
25
26
  )
27
+ from code_rules_enforcer_test_support import ( # noqa: E402
28
+ run_serialized_payload_entrypoint,
29
+ )
26
30
 
27
31
  code_rules_enforcer = SimpleNamespace(
28
32
  FUNCTION_LENGTH_BLOCKING_THRESHOLD=FUNCTION_LENGTH_BLOCKING_THRESHOLD,
@@ -35,9 +39,7 @@ code_rules_enforcer = SimpleNamespace(
35
39
 
36
40
  def _oversized_function_source(name: str) -> str:
37
41
  body_line_count = code_rules_enforcer.FUNCTION_LENGTH_BLOCKING_THRESHOLD - 1
38
- body_lines = [
39
- f" bound_{each_index} = {each_index}" for each_index in range(body_line_count)
40
- ]
42
+ body_lines = [f" bound_{each_index} = {each_index}" for each_index in range(body_line_count)]
41
43
  return f"def {name}() -> None:\n" + "\n".join(body_lines) + "\n"
42
44
 
43
45
 
@@ -45,21 +47,8 @@ def _run_main_with_edit_payload(
45
47
  file_path: str,
46
48
  old_string: str,
47
49
  new_string: str,
48
- monkeypatch: object,
49
- capsys: object,
50
50
  ) -> str:
51
- """Drive ``main()`` through its stdin entry point for an Edit and return stdout.
52
-
53
- Args:
54
- file_path: The on-disk path the Edit targets.
55
- old_string: The Edit's ``old_string`` fragment.
56
- new_string: The Edit's ``new_string`` fragment.
57
- monkeypatch: The pytest fixture used to redirect ``sys.stdin``.
58
- capsys: The pytest fixture used to capture the deny payload on stdout.
59
-
60
- Returns:
61
- The captured stdout, which holds the deny payload when violations fire.
62
- """
51
+ """Drive ``main()`` through its stdin entry point for an Edit."""
63
52
  edit_payload = json.dumps(
64
53
  {
65
54
  "tool_name": "Edit",
@@ -70,13 +59,10 @@ def _run_main_with_edit_payload(
70
59
  },
71
60
  }
72
61
  )
73
- getattr(monkeypatch, "setattr")(code_rules_enforcer.sys, "stdin", io.StringIO(edit_payload))
74
- try:
75
- code_rules_enforcer.main([])
76
- except SystemExit:
77
- pass
78
- captured = getattr(capsys, "readouterr")()
79
- return captured.out
62
+ captured_stdout, _exit_code = run_serialized_payload_entrypoint(
63
+ code_rules_enforcer.main, edit_payload
64
+ )
65
+ return captured_stdout
80
66
 
81
67
 
82
68
  def test_banned_noun_word_keeps_in_scope_binding_among_untouched_ones() -> None:
@@ -85,8 +71,7 @@ def test_banned_noun_word_keeps_in_scope_binding_among_untouched_ones() -> None:
85
71
  binding while leaving the untouched bindings out of scope."""
86
72
  leading_count = 5
87
73
  leading_bindings = "".join(
88
- f"LEADING_{each_index}_RESULT_PATH = {each_index}\n"
89
- for each_index in range(leading_count)
74
+ f"LEADING_{each_index}_RESULT_PATH = {each_index}\n" for each_index in range(leading_count)
90
75
  )
91
76
  target_before = "PLACEHOLDER_NAME = 0\n"
92
77
  target_after = "INTRODUCED_RESULT_PATH = 0\n"
@@ -99,9 +84,9 @@ def test_banned_noun_word_keeps_in_scope_binding_among_untouched_ones() -> None:
99
84
  full_file_content=post_edit_full_file,
100
85
  prior_full_file_content=prior_full_file,
101
86
  )
102
- assert any(
103
- "INTRODUCED_RESULT_PATH" in each_issue for each_issue in issues
104
- ), f"in-scope banned-noun past the cap window must still block, got: {issues!r}"
87
+ assert any("INTRODUCED_RESULT_PATH" in each_issue for each_issue in issues), (
88
+ f"in-scope banned-noun past the cap window must still block, got: {issues!r}"
89
+ )
105
90
 
106
91
 
107
92
  def test_banned_noun_edit_drops_untouched_out_of_scope_binding() -> None:
@@ -122,9 +107,9 @@ def test_banned_noun_edit_drops_untouched_out_of_scope_binding() -> None:
122
107
  full_file_content=post_edit_full_file,
123
108
  prior_full_file_content=prior_full_file,
124
109
  )
125
- assert not any(
126
- "RESULT_PATH" in each_issue for each_issue in issues
127
- ), f"untouched banned-noun bindings must stay out of scope, got: {issues!r}"
110
+ assert not any("RESULT_PATH" in each_issue for each_issue in issues), (
111
+ f"untouched banned-noun bindings must stay out of scope, got: {issues!r}"
112
+ )
128
113
 
129
114
 
130
115
  def test_banned_noun_edit_keeps_touched_binding_in_scope() -> None:
@@ -144,9 +129,9 @@ def test_banned_noun_edit_keeps_touched_binding_in_scope() -> None:
144
129
  full_file_content=post_edit_full_file,
145
130
  prior_full_file_content=prior_full_file,
146
131
  )
147
- assert any(
148
- "INTRODUCED_RESULT_PATH" in each_issue for each_issue in issues
149
- ), f"introduced banned-noun binding must block, got: {issues!r}"
132
+ assert any("INTRODUCED_RESULT_PATH" in each_issue for each_issue in issues), (
133
+ f"introduced banned-noun binding must block, got: {issues!r}"
134
+ )
150
135
 
151
136
 
152
137
  def test_banned_noun_edit_does_not_reflag_param_when_unrelated_body_line_changes() -> None:
@@ -171,9 +156,9 @@ def test_banned_noun_edit_does_not_reflag_param_when_unrelated_body_line_changes
171
156
  full_file_content=post_edit_full_file,
172
157
  prior_full_file_content=prior_full_file,
173
158
  )
174
- assert not any(
175
- "canned_results" in each_issue for each_issue in issues
176
- ), f"pre-existing param must not re-flag on unrelated body edit, got: {issues!r}"
159
+ assert not any("canned_results" in each_issue for each_issue in issues), (
160
+ f"pre-existing param must not re-flag on unrelated body edit, got: {issues!r}"
161
+ )
177
162
 
178
163
 
179
164
  def test_unreadable_prior_yields_no_prior_and_no_reconstruction() -> None:
@@ -191,13 +176,13 @@ def test_unreadable_prior_yields_no_prior_and_no_reconstruction() -> None:
191
176
 
192
177
 
193
178
  def test_edit_with_missing_old_string_runs_whole_file_against_on_disk_content(
194
- tmp_path_factory: object, monkeypatch: object, capsys: object,
179
+ tmp_path_factory: pytest.TempPathFactory,
195
180
  ) -> None:
196
181
  """When an Edit's old_string is absent from the file, ``prior_and_post_edit_content``
197
182
  yields ``(None, None)``; ``main()`` must analyze the real on-disk file whole-file
198
183
  rather than the new_string fragment, so an oversized function elsewhere in the
199
184
  file is still reported with its true line numbers."""
200
- production_directory = getattr(tmp_path_factory, "mktemp")("production_pkg")
185
+ production_directory = tmp_path_factory.mktemp("production_pkg")
201
186
  untouched_long_function = _oversized_function_source("untouched_long")
202
187
  short_helper = "def short_helper() -> int:\n return 1\n"
203
188
  on_disk_content = untouched_long_function + "\n" + short_helper
@@ -206,7 +191,9 @@ def test_edit_with_missing_old_string_runs_whole_file_against_on_disk_content(
206
191
  absent_fragment_old = "def absent_function() -> int:\n return 0\n"
207
192
  short_fragment_new = "def absent_function() -> int:\n return 2\n"
208
193
  stdout = _run_main_with_edit_payload(
209
- str(source_file), absent_fragment_old, short_fragment_new, monkeypatch, capsys,
194
+ str(source_file),
195
+ absent_fragment_old,
196
+ short_fragment_new,
210
197
  )
211
198
  assert "untouched_long" in stdout, (
212
199
  "an unreconstructable Edit must fall back to whole-file on-disk analysis, "
@@ -215,13 +202,13 @@ def test_edit_with_missing_old_string_runs_whole_file_against_on_disk_content(
215
202
 
216
203
 
217
204
  def test_edit_with_unreadable_file_does_not_analyze_fragment_as_whole_file(
218
- tmp_path_factory: object, monkeypatch: object, capsys: object,
205
+ tmp_path_factory: pytest.TempPathFactory,
219
206
  ) -> None:
220
207
  """When the on-disk file cannot be read, no well-defined post-edit content
221
208
  exists; ``main()`` must exit cleanly rather than analyze the new_string
222
209
  fragment as if it were the whole file, so the fragment's own function-length
223
210
  violation does not surface as a deny payload."""
224
- production_directory = getattr(tmp_path_factory, "mktemp")("production_pkg")
211
+ production_directory = tmp_path_factory.mktemp("production_pkg")
225
212
  missing_path = str(production_directory / "never_created.py")
226
213
  oversized_fragment_old = "def grows() -> int:\n return 0\n"
227
214
  oversized_fragment_new = _oversized_function_source("grows")
@@ -229,8 +216,6 @@ def test_edit_with_unreadable_file_does_not_analyze_fragment_as_whole_file(
229
216
  missing_path,
230
217
  oversized_fragment_old,
231
218
  oversized_fragment_new,
232
- monkeypatch,
233
- capsys,
234
219
  )
235
220
  assert stdout == "", (
236
221
  "an unreadable Edit target has no well-defined whole-file content, so the "
@@ -0,0 +1,192 @@
1
+ """Test stage ownership for the code-rules enforcer entrypoint."""
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
+
11
+ import pytest
12
+
13
+ _BLOCKING_DIRECTORY = str(Path(__file__).resolve().parent)
14
+ _HOOKS_DIRECTORY = str(Path(__file__).resolve().parent.parent)
15
+ _PR_LOOP_SCRIPTS_DIRECTORY = str(
16
+ Path(__file__).resolve().parents[2] / "_shared" / "pr-loop" / "scripts"
17
+ )
18
+ if _BLOCKING_DIRECTORY not in sys.path:
19
+ sys.path.insert(0, _BLOCKING_DIRECTORY)
20
+ if _HOOKS_DIRECTORY not in sys.path:
21
+ sys.path.insert(0, _HOOKS_DIRECTORY)
22
+ if _PR_LOOP_SCRIPTS_DIRECTORY not in sys.path:
23
+ sys.path.insert(0, _PR_LOOP_SCRIPTS_DIRECTORY)
24
+
25
+ from code_rules_enforcer import ( # noqa: E402
26
+ _fragment_or_deferred_check,
27
+ main,
28
+ validate_content,
29
+ )
30
+ from code_rules_gate_parts import gate_running # noqa: E402
31
+ from code_rules_gate_parts.tests._repo_test_helpers import ( # noqa: E402
32
+ init_repository,
33
+ write_commit_and_stage_change,
34
+ )
35
+
36
+
37
+ PRODUCTION_FILE_PATH = "packages/app/services.py"
38
+ ENFORCER_SCRIPT_PATH = Path(__file__).with_name("code_rules_enforcer.py")
39
+
40
+
41
+ def _write_payload(file_path: str, source: str) -> str:
42
+ return json.dumps(
43
+ {
44
+ "tool_name": "Write",
45
+ "tool_input": {"file_path": file_path, "content": source},
46
+ }
47
+ )
48
+
49
+
50
+ def _run_write_stage(
51
+ file_path: str,
52
+ source: str,
53
+ monkeypatch: pytest.MonkeyPatch,
54
+ capsys: pytest.CaptureFixture[str],
55
+ ) -> str:
56
+ payload = _write_payload(file_path, source)
57
+ monkeypatch.setattr(sys, "stdin", io.StringIO(payload))
58
+ try:
59
+ main([])
60
+ except SystemExit:
61
+ pass
62
+ return capsys.readouterr().out
63
+ def _run_precheck_stage(candidate_path: Path, target_path: str) -> subprocess.CompletedProcess[str]:
64
+ return subprocess.run(
65
+ [
66
+ sys.executable,
67
+ str(ENFORCER_SCRIPT_PATH),
68
+ "--check",
69
+ str(candidate_path),
70
+ "--as",
71
+ target_path,
72
+ ],
73
+ check=False,
74
+ capture_output=True,
75
+ text=True,
76
+ )
77
+ def test_edit_stage_baselines_the_new_fragment_against_the_old_fragment() -> None:
78
+ all_seen_contents: list[str] = []
79
+
80
+ def record_check(content: str, file_path: str) -> list[str]:
81
+ all_seen_contents.append(content)
82
+ if "introduced" in content:
83
+ return [f"finding in {file_path}"]
84
+ return []
85
+
86
+ all_issues = _fragment_or_deferred_check(
87
+ record_check,
88
+ "existing fragment",
89
+ "introduced fragment",
90
+ PRODUCTION_FILE_PATH,
91
+ defer_scope_to_caller=False,
92
+ )
93
+
94
+ assert all_seen_contents == ["introduced fragment", "existing fragment"]
95
+ assert all_issues == [f"finding in {PRODUCTION_FILE_PATH}"]
96
+
97
+
98
+ def test_commit_stage_owns_scope_and_scans_the_complete_candidate_once() -> None:
99
+ all_seen_contents: list[str] = []
100
+
101
+ def record_check(content: str, file_path: str) -> list[str]:
102
+ all_seen_contents.append(content)
103
+ return [f"finding in {file_path}"]
104
+
105
+ all_issues = _fragment_or_deferred_check(
106
+ record_check,
107
+ "HEAD content",
108
+ "staged candidate",
109
+ PRODUCTION_FILE_PATH,
110
+ defer_scope_to_caller=True,
111
+ )
112
+
113
+ assert all_seen_contents == ["staged candidate"]
114
+ assert all_issues == [f"finding in {PRODUCTION_FILE_PATH}"]
115
+
116
+
117
+ def test_write_stage_runs_the_real_entrypoint_and_reports_a_deny_payload(
118
+ tmp_path_factory: pytest.TempPathFactory,
119
+ monkeypatch: pytest.MonkeyPatch,
120
+ capsys: pytest.CaptureFixture[str],
121
+ ) -> None:
122
+ staging_directory = tmp_path_factory.mktemp("stage")
123
+ target_path = str(staging_directory / "service.py")
124
+ source = "def process_data() -> None:\n print('payload')\n"
125
+
126
+ captured_stdout = _run_write_stage(target_path, source, monkeypatch, capsys)
127
+
128
+ deny_payload = json.loads(captured_stdout)
129
+ deny_reason = deny_payload["hookSpecificOutput"]["permissionDecisionReason"]
130
+ assert deny_payload["hookSpecificOutput"]["permissionDecision"] == "deny"
131
+ assert "process_data" in deny_reason
132
+
133
+
134
+ def test_write_stage_allows_a_clean_candidate_through_the_real_entrypoint(
135
+ tmp_path_factory: pytest.TempPathFactory,
136
+ monkeypatch: pytest.MonkeyPatch,
137
+ capsys: pytest.CaptureFixture[str],
138
+ ) -> None:
139
+ staging_directory = tmp_path_factory.mktemp("stage")
140
+ target_path = str(staging_directory / "service.py")
141
+ source = "def calculate_total() -> int:\n return 0\n"
142
+
143
+ captured_stdout = _run_write_stage(target_path, source, monkeypatch, capsys)
144
+
145
+ assert captured_stdout == ""
146
+
147
+
148
+ def test_precheck_stage_runs_the_cli_and_uses_the_declared_target_path(
149
+ tmp_path_factory: pytest.TempPathFactory,
150
+ ) -> None:
151
+ staging_directory = tmp_path_factory.mktemp("stage")
152
+ candidate_path = staging_directory / "candidate.py"
153
+ candidate_path.write_text(
154
+ "def process_data() -> None:\n print('payload')\n",
155
+ encoding="utf-8",
156
+ )
157
+ target_path = str(staging_directory / "service.py")
158
+
159
+ completed_process = _run_precheck_stage(candidate_path, target_path)
160
+
161
+ assert completed_process.returncode == 1
162
+ assert "process_data" in completed_process.stdout
163
+ def test_gate_caller_scans_staged_content_with_commit_stage_scope(
164
+ tmp_path: Path,
165
+ ) -> None:
166
+ repository_root = tmp_path / "repository"
167
+ repository_root.mkdir()
168
+ init_repository(repository_root)
169
+ committed_source = "def calculate_total() -> int:\n return 0\n"
170
+ staged_source = (
171
+ "def calculate_total() -> int:\n"
172
+ " total = 9999\n"
173
+ " return total\n"
174
+ )
175
+ file_path = write_commit_and_stage_change(
176
+ repository_root,
177
+ "service.py",
178
+ committed_source,
179
+ staged_source,
180
+ )
181
+
182
+ all_partitioned_violations = gate_running._scoped_violations_for_file(
183
+ validate_content=validate_content,
184
+ resolved_path=file_path,
185
+ repository_root=repository_root,
186
+ all_added_lines_for_file={2, 3},
187
+ should_read_staged_content=True,
188
+ )
189
+
190
+ assert all_partitioned_violations is not None
191
+ all_blocking_violations, _all_advisory_violations = all_partitioned_violations
192
+ assert any("9999" in each_issue for each_issue in all_blocking_violations)