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
@@ -107,6 +107,60 @@ def test_should_not_flag_exact_match_banned_identifier() -> None:
107
107
  assert issues == []
108
108
 
109
109
 
110
+ def test_should_allow_annotated_public_definition_name_and_parameters() -> None:
111
+ source = (
112
+ "def validate_nine_patch_output_path(output_path: str) -> str: # pragma: no-banned-noun\n"
113
+ " local_output_path = output_path\n"
114
+ " return local_output_path\n"
115
+ )
116
+ issues = check_banned_noun_word_boundary(source, PRODUCTION_FILE_PATH)
117
+ assert any("local_output_path" in each_issue for each_issue in issues)
118
+ assert not any(
119
+ "Identifier 'validate_nine_patch_output_path'" in each_issue
120
+ or "Identifier 'output_path'" in each_issue
121
+ for each_issue in issues
122
+ )
123
+
124
+
125
+ def test_enforcer_keeps_annotation_scope_on_definition_signature() -> None:
126
+ source = (
127
+ "def validate_nine_patch_output_path(output_path: str) -> str: # pragma: no-banned-noun\n"
128
+ " local_output_path = output_path\n"
129
+ " return local_output_path\n"
130
+ )
131
+ issues = validate_content(source, PRODUCTION_FILE_PATH)
132
+ assert any("Identifier 'local_output_path'" in each_issue for each_issue in issues)
133
+ assert not any(
134
+ "Identifier 'validate_nine_patch_output_path'" in each_issue
135
+ or "Identifier 'output_path'" in each_issue
136
+ for each_issue in issues
137
+ )
138
+
139
+
140
+ def test_should_keep_annotation_scoped_to_other_definitions_and_near_matches() -> None:
141
+ source = (
142
+ "def validate_nine_patch_output_paths(output_path: str) -> str:\n"
143
+ " return output_path\n"
144
+ "def other_handler(other_output_path: str) -> str:\n"
145
+ " return other_output_path\n"
146
+ )
147
+ issues = check_banned_noun_word_boundary(source, PRODUCTION_FILE_PATH)
148
+ assert any(
149
+ "validate_nine_patch_output_paths" in each_issue for each_issue in issues
150
+ )
151
+ assert any("other_output_path" in each_issue for each_issue in issues)
152
+
153
+
154
+ def test_should_require_the_exact_compatibility_annotation() -> None:
155
+ source = (
156
+ "def validate_nine_patch_output_path(output_path: str) -> str: # pragma: no-banned-nouns\n"
157
+ " return output_path\n"
158
+ )
159
+ issues = check_banned_noun_word_boundary(source, PRODUCTION_FILE_PATH)
160
+ assert any("validate_nine_patch_output_path" in each_issue for each_issue in issues)
161
+ assert any("output_path" in each_issue for each_issue in issues)
162
+
163
+
110
164
  def test_should_not_flag_dunder_method_with_banned_word() -> None:
111
165
  source = "class Foo:\n def __init_data__(self) -> None: pass\n"
112
166
  issues = check_banned_noun_word_boundary(source, PRODUCTION_FILE_PATH)
@@ -14,15 +14,12 @@ target path the same way it would for the real directory.
14
14
 
15
15
  from __future__ import annotations
16
16
 
17
- import io
18
17
  import json
19
18
  import pathlib
20
19
  import shutil
21
- import subprocess
22
20
  import sys
23
21
  import tempfile
24
22
  from collections.abc import Iterator
25
- from types import SimpleNamespace
26
23
 
27
24
  import pytest
28
25
 
@@ -34,10 +31,10 @@ if str(_HOOKS_PARENT) not in sys.path:
34
31
  sys.path.insert(0, str(_HOOKS_PARENT))
35
32
 
36
33
  from code_rules_enforcer import main # noqa: E402
37
-
38
- code_rules_enforcer = SimpleNamespace(main=main, sys=sys)
39
-
40
- _ENFORCER_SCRIPT_PATH = _HOOK_DIRECTORY / "code_rules_enforcer.py"
34
+ from code_rules_enforcer_test_support import ( # noqa: E402
35
+ run_precheck,
36
+ run_write_entrypoint,
37
+ )
41
38
 
42
39
  SHARED_HELPER_SOURCE = (
43
40
  "import re\n"
@@ -66,38 +63,18 @@ def hook_blocking_dir() -> Iterator[pathlib.Path]:
66
63
  def _run_main_with_write_payload(
67
64
  file_path: str,
68
65
  content: str,
69
- monkeypatch: pytest.MonkeyPatch,
70
- capsys: pytest.CaptureFixture[str],
71
66
  ) -> str:
72
- """Drive ``main()`` through its stdin entry point for a Write and return stdout.
73
-
74
- Args:
75
- file_path: The on-disk path the Write targets.
76
- content: The whole-file body the Write would create.
77
- monkeypatch: The fixture used to redirect ``sys.stdin``.
78
- capsys: The fixture used to capture the deny payload on stdout.
79
-
80
- Returns:
81
- The captured stdout, which holds the deny payload when violations fire.
82
- """
83
- write_payload = json.dumps(
84
- {
85
- "tool_name": "Write",
86
- "tool_input": {"file_path": file_path, "content": content},
87
- }
67
+ """Drive ``main()`` through its stdin entry point for a Write."""
68
+ captured_stdout, _exit_code = run_write_entrypoint(
69
+ main,
70
+ file_path,
71
+ content,
88
72
  )
89
- monkeypatch.setattr(code_rules_enforcer.sys, "stdin", io.StringIO(write_payload))
90
- try:
91
- code_rules_enforcer.main([])
92
- except SystemExit:
93
- pass
94
- return capsys.readouterr().out
73
+ return captured_stdout
95
74
 
96
75
 
97
76
  def test_write_of_copied_helper_into_hook_directory_denies(
98
77
  hook_blocking_dir: pathlib.Path,
99
- monkeypatch: pytest.MonkeyPatch,
100
- capsys: pytest.CaptureFixture[str],
101
78
  ) -> None:
102
79
  """A Write that copies a sibling helper into a second hook file is denied.
103
80
 
@@ -106,7 +83,7 @@ def test_write_of_copied_helper_into_hook_directory_denies(
106
83
  directory its module docstring names as the primary target."""
107
84
  (hook_blocking_dir / "existing_blocker.py").write_text(SHARED_HELPER_SOURCE, encoding="utf-8")
108
85
  new_file = hook_blocking_dir / "new_blocker.py"
109
- stdout = _run_main_with_write_payload(str(new_file), SHARED_HELPER_SOURCE, monkeypatch, capsys)
86
+ stdout = _run_main_with_write_payload(str(new_file), SHARED_HELPER_SOURCE)
110
87
  assert stdout != "", (
111
88
  "A copied helper written into a second hook-infrastructure file must "
112
89
  "produce a deny payload, got empty stdout"
@@ -122,8 +99,6 @@ def test_write_of_copied_helper_into_hook_directory_denies(
122
99
 
123
100
  def test_write_of_unique_helper_into_hook_directory_allows(
124
101
  hook_blocking_dir: pathlib.Path,
125
- monkeypatch: pytest.MonkeyPatch,
126
- capsys: pytest.CaptureFixture[str],
127
102
  ) -> None:
128
103
  """A Write of a hook file with no sibling duplicate produces no deny payload.
129
104
 
@@ -137,13 +112,13 @@ def test_write_of_unique_helper_into_hook_directory_allows(
137
112
  " return widened\n"
138
113
  )
139
114
  new_file = hook_blocking_dir / "unique_blocker.py"
140
- stdout = _run_main_with_write_payload(str(new_file), unique_helper_source, monkeypatch, capsys)
115
+ stdout = _run_main_with_write_payload(str(new_file), unique_helper_source)
141
116
  assert stdout == "", f"a unique hook helper must not be denied, got stdout: {stdout!r}"
142
117
 
143
118
 
144
119
  def test_precheck_of_copied_helper_at_hook_target_exits_nonzero(
145
120
  hook_blocking_dir: pathlib.Path,
146
- tmp_path_factory: pytest.TempPathFactory,
121
+ tmp_path: pathlib.Path,
147
122
  ) -> None:
148
123
  """The pre-check CLI flags a copied helper judged at a hook-infrastructure target.
149
124
 
@@ -151,24 +126,11 @@ def test_precheck_of_copied_helper_at_hook_target_exits_nonzero(
151
126
  routes a hook ``.py`` target through the duplicate-body check rather than
152
127
  exiting clean on the blanket hook-infrastructure exemption."""
153
128
  (hook_blocking_dir / "existing_blocker.py").write_text(SHARED_HELPER_SOURCE, encoding="utf-8")
154
- staging_directory = tmp_path_factory.mktemp("staging")
129
+ staging_directory = tmp_path
155
130
  candidate_file = staging_directory / "candidate.py"
156
131
  candidate_file.write_text(SHARED_HELPER_SOURCE, encoding="utf-8")
157
132
  target_path = str(hook_blocking_dir / "new_blocker.py")
158
- completed = subprocess.run(
159
- [
160
- sys.executable,
161
- str(_ENFORCER_SCRIPT_PATH),
162
- "--check",
163
- str(candidate_file),
164
- "--as",
165
- target_path,
166
- ],
167
- input="",
168
- capture_output=True,
169
- text=True,
170
- check=False,
171
- )
133
+ completed = run_precheck(candidate_file, target_path, None)
172
134
  assert completed.returncode == 1, (
173
135
  "a copied helper at a hook target must exit nonzero, got: "
174
136
  f"{completed.returncode}, stdout: {completed.stdout!r}, "
@@ -2,14 +2,11 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import io
6
5
  import json
7
- import os
8
6
  import subprocess
9
7
  import sys
10
8
  import tempfile
11
9
  from pathlib import Path
12
- from types import SimpleNamespace
13
10
 
14
11
  import pytest
15
12
 
@@ -22,70 +19,40 @@ if _HOOKS_DIRECTORY not in sys.path:
22
19
 
23
20
  from code_rules_enforcer import main as enforcer_main # noqa: E402
24
21
  from code_rules_shared import is_ephemeral_script_path # noqa: E402
22
+ from code_rules_enforcer_test_support import ( # noqa: E402
23
+ build_write_payload,
24
+ run_enforcer_cli,
25
+ run_write_entrypoint,
26
+ )
25
27
 
26
28
  _ENFORCER_SCRIPT = Path(__file__).resolve().parent / "code_rules_enforcer.py"
27
29
  _TDD_SCRIPT = Path(__file__).resolve().parent / "tdd_enforcer.py"
28
30
 
29
31
  _VIOLATING_PRODUCTION_SOURCE = "def process_data(payload: str) -> None:\n print(payload)\n"
30
32
 
31
- code_rules_enforcer_module = SimpleNamespace(main=enforcer_main, sys=sys)
32
-
33
33
 
34
34
  def _run_enforcer_cli(
35
35
  all_cli_arguments: list[str],
36
- extra_env: dict[str, str],
36
+ extra_environment_by_name: dict[str, str] | None = None,
37
37
  ) -> subprocess.CompletedProcess[str]:
38
38
  """Drive the enforcer script through its real argv entry point.
39
39
 
40
40
  Args:
41
41
  all_cli_arguments: The argument vector appended after the script path.
42
- extra_env: Additional environment variables merged into the subprocess env.
42
+ extra_environment_by_name: Additional environment variables merged into the subprocess environment.
43
43
 
44
44
  Returns:
45
45
  The completed process carrying stdout, stderr, and the exit code.
46
46
  """
47
- subprocess_env = {**os.environ, **extra_env}
48
- return subprocess.run(
49
- [sys.executable, str(_ENFORCER_SCRIPT), *all_cli_arguments],
50
- input="",
51
- capture_output=True,
52
- text=True,
53
- check=False,
54
- env=subprocess_env,
55
- )
47
+ return run_enforcer_cli(_ENFORCER_SCRIPT, all_cli_arguments, extra_environment_by_name)
56
48
 
57
49
 
58
50
  def _run_main_with_write_payload(
59
51
  file_path: str,
60
52
  content: str,
61
- monkeypatch: pytest.MonkeyPatch,
62
- capsys: pytest.CaptureFixture[str],
63
53
  ) -> tuple[str, int]:
64
- """Drive enforcer_main through its stdin entry point for a Write payload.
65
-
66
- Args:
67
- file_path: The destination path the Write targets.
68
- content: The content of the Write.
69
- monkeypatch: The pytest fixture used to redirect sys.stdin.
70
- capsys: The pytest fixture used to capture the deny payload on stdout.
71
-
72
- Returns:
73
- A tuple of (captured_stdout, exit_code).
74
- """
75
- write_payload = json.dumps(
76
- {
77
- "tool_name": "Write",
78
- "tool_input": {"file_path": file_path, "content": content},
79
- }
80
- )
81
- monkeypatch.setattr(code_rules_enforcer_module.sys, "stdin", io.StringIO(write_payload))
82
- exit_code = 0
83
- try:
84
- code_rules_enforcer_module.main([])
85
- except SystemExit as each_exit:
86
- exit_code = int(each_exit.code or 0)
87
- captured = capsys.readouterr()
88
- return captured.out, exit_code
54
+ """Drive enforcer_main through its stdin entry point for a Write payload."""
55
+ return run_write_entrypoint(enforcer_main, file_path, content)
89
56
 
90
57
 
91
58
  def _run_tdd_with_write_payload(
@@ -101,18 +68,12 @@ def _run_tdd_with_write_payload(
101
68
  Returns:
102
69
  The completed process carrying stdout, stderr, and the exit code.
103
70
  """
104
- write_payload = json.dumps(
105
- {
106
- "tool_name": "Write",
107
- "tool_input": {"file_path": file_path, "content": content},
108
- }
109
- )
71
+ write_payload = build_write_payload(file_path, content)
110
72
  return subprocess.run(
111
73
  [sys.executable, str(_TDD_SCRIPT)],
112
74
  input=write_payload,
113
75
  capture_output=True,
114
76
  text=True,
115
- check=False,
116
77
  )
117
78
 
118
79
 
@@ -279,7 +240,6 @@ def test_should_return_false_for_empty_path() -> None:
279
240
  def test_should_exit_zero_for_ephemeral_pretooluse_target(
280
241
  tmp_path: Path,
281
242
  monkeypatch: pytest.MonkeyPatch,
282
- capsys: pytest.CaptureFixture[str],
283
243
  ) -> None:
284
244
  """B11: enforcer main exits 0 with no deny payload for an ephemeral file_path."""
285
245
  monkeypatch.setenv("CLAUDE_JOB_DIR", str(tmp_path))
@@ -287,8 +247,6 @@ def test_should_exit_zero_for_ephemeral_pretooluse_target(
287
247
  captured_stdout, exit_code = _run_main_with_write_payload(
288
248
  ephemeral_path,
289
249
  _VIOLATING_PRODUCTION_SOURCE,
290
- monkeypatch,
291
- capsys,
292
250
  )
293
251
  assert exit_code == 0
294
252
  assert "deny" not in captured_stdout.lower()
@@ -297,7 +255,6 @@ def test_should_exit_zero_for_ephemeral_pretooluse_target(
297
255
  def test_should_exit_zero_for_ephemeral_path_with_hooks_substring(
298
256
  tmp_path: Path,
299
257
  monkeypatch: pytest.MonkeyPatch,
300
- capsys: pytest.CaptureFixture[str],
301
258
  ) -> None:
302
259
  """B12: an ephemeral path carrying /hooks/ exits 0 (hook-infra route short-circuited)."""
303
260
  monkeypatch.setenv("CLAUDE_JOB_DIR", str(tmp_path))
@@ -305,8 +262,6 @@ def test_should_exit_zero_for_ephemeral_path_with_hooks_substring(
305
262
  captured_stdout, exit_code = _run_main_with_write_payload(
306
263
  ephemeral_hooks_path,
307
264
  _VIOLATING_PRODUCTION_SOURCE,
308
- monkeypatch,
309
- capsys,
310
265
  )
311
266
  assert exit_code == 0
312
267
  assert "deny" not in captured_stdout.lower()
@@ -319,7 +274,7 @@ def test_should_return_zero_from_precheck_for_ephemeral_target(
319
274
  candidate_file = tmp_path / "candidate.py"
320
275
  candidate_file.write_text(_VIOLATING_PRODUCTION_SOURCE, encoding="utf-8")
321
276
  ephemeral_target = "/tmp/target.py"
322
- completed = _run_enforcer_cli(["--check", str(candidate_file), "--as", ephemeral_target], extra_env={})
277
+ completed = _run_enforcer_cli(["--check", str(candidate_file), "--as", ephemeral_target])
323
278
  assert completed.returncode == 0
324
279
 
325
280
 
@@ -332,7 +287,6 @@ def test_should_run_full_suite_for_non_ephemeral_target(
332
287
  non_ephemeral_target = "/repo/src/orders.py"
333
288
  completed = _run_enforcer_cli(
334
289
  ["--check", str(candidate_file), "--as", non_ephemeral_target],
335
- extra_env={},
336
290
  )
337
291
  assert completed.returncode == 1
338
292
 
@@ -346,7 +300,7 @@ def test_should_run_full_suite_when_override_truthy(
346
300
  ephemeral_target = "/tmp/scratch.py"
347
301
  completed = _run_enforcer_cli(
348
302
  ["--check", str(candidate_file), "--as", ephemeral_target],
349
- extra_env={"CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT": "1"},
303
+ extra_environment_by_name={"CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT": "1"},
350
304
  )
351
305
  assert completed.returncode == 1
352
306
 
@@ -354,7 +308,6 @@ def test_should_run_full_suite_when_override_truthy(
354
308
  def test_should_exempt_same_path_set_on_both_gates(
355
309
  tmp_path: Path,
356
310
  monkeypatch: pytest.MonkeyPatch,
357
- capsys: pytest.CaptureFixture[str],
358
311
  ) -> None:
359
312
  """B21: every ephemeral path exits 0 on both enforcer main and TDD enforcer main."""
360
313
  monkeypatch.setenv("CLAUDE_JOB_DIR", str(tmp_path))
@@ -367,8 +320,6 @@ def test_should_exempt_same_path_set_on_both_gates(
367
320
  captured_stdout, exit_code = _run_main_with_write_payload(
368
321
  each_ephemeral_path,
369
322
  _VIOLATING_PRODUCTION_SOURCE,
370
- monkeypatch,
371
- capsys,
372
323
  )
373
324
  assert exit_code == 0, (
374
325
  f"enforcer must exit 0 for ephemeral path {each_ephemeral_path!r}, "
@@ -0,0 +1,309 @@
1
+ """Test narrow Edit classification and required rule selection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ import io
7
+ import inspect
8
+ import json
9
+ import sys
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+
13
+ import pytest
14
+ _BLOCKING_DIRECTORY = str(Path(__file__).resolve().parent)
15
+ _HOOKS_DIRECTORY = str(Path(__file__).resolve().parent.parent)
16
+ if _BLOCKING_DIRECTORY not in sys.path:
17
+ sys.path.insert(0, _BLOCKING_DIRECTORY)
18
+ if _HOOKS_DIRECTORY not in sys.path:
19
+ sys.path.insert(0, _HOOKS_DIRECTORY)
20
+
21
+ from code_rules_enforcer import ( # noqa: E402
22
+ main,
23
+ validate_content,
24
+ )
25
+
26
+
27
+ PRODUCTION_FILE_PATH = "packages/app/services.py"
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class NarrowEditFixture:
32
+ """One accepted Edit family with a clean prior fragment and a violation."""
33
+
34
+ old_fragment: str
35
+ new_fragment: str
36
+ expected_marker: str
37
+ rule_name: str
38
+
39
+ ALL_SCOPE_AWARE_RULE_NAMES = frozenset(
40
+ {
41
+ "check_blast_radius_declared",
42
+ "check_import_block_sorted",
43
+ "check_magic_values",
44
+ "check_duplicate_function_body_across_files",
45
+ "check_same_file_inline_duplicate_body",
46
+ "check_banned_identifiers",
47
+ "check_banned_noun_word_boundary",
48
+ "check_docstring_runon_sentence",
49
+ "check_docstring_prose_wall_without_illustration",
50
+ "check_boolean_naming",
51
+ "check_ignored_must_check_return",
52
+ "check_tests_use_isolated_filesystem_paths",
53
+ "check_return_annotations",
54
+ "check_function_length",
55
+ "check_public_function_missing_paired_test",
56
+ "check_join_separator_string_magic",
57
+ "check_string_literal_magic",
58
+ "check_js_boolean_naming",
59
+ "check_js_banned_identifiers",
60
+ "check_js_bare_flag_return_directive",
61
+ }
62
+ )
63
+
64
+
65
+ SCOPE_AWARE_RULE_NAMES = ALL_SCOPE_AWARE_RULE_NAMES
66
+ NARROW_EDIT_ACCEPTED_RULE_NAMES = frozenset(
67
+ {
68
+ "check_string_literal_magic",
69
+ "check_join_separator_string_magic",
70
+ "check_banned_noun_word_boundary",
71
+ "check_boolean_naming",
72
+ "check_ignored_must_check_return",
73
+ }
74
+ )
75
+
76
+
77
+ EXPECTED_RULE_TEXT_BY_NAME = {
78
+ "check_string_literal_magic": "string magic value",
79
+ "check_join_separator_string_magic": "string separator",
80
+ "check_banned_noun_word_boundary": "Identifier",
81
+ "check_boolean_naming": "Boolean",
82
+ "check_ignored_must_check_return": "return value",
83
+ }
84
+
85
+
86
+ ALL_REQUIRED_RULE_FIXTURES = (
87
+ NarrowEditFixture(
88
+ old_fragment=(
89
+ "import os\n\n"
90
+ "def fetch_secret() -> str:\n"
91
+ " return os.environ.get('missing', '')\n"
92
+ ),
93
+ new_fragment=(
94
+ "import os\n\n"
95
+ "def fetch_secret() -> str:\n"
96
+ " return os.environ['STRIPE_SECRET']\n"
97
+ ),
98
+ expected_marker="STRIPE_SECRET",
99
+ rule_name="check_string_literal_magic",
100
+ ),
101
+ NarrowEditFixture(
102
+ old_fragment=(
103
+ "def render_paths(all_paths: list[str]) -> str:\n"
104
+ " return JOIN_DELIMITER.join(all_paths)\n"
105
+ ),
106
+ new_fragment=(
107
+ "def render_paths(all_paths: list[str]) -> str:\n"
108
+ " return ', '.join(all_paths)\n"
109
+ ),
110
+ expected_marker="join",
111
+ rule_name="check_join_separator_string_magic",
112
+ ),
113
+ NarrowEditFixture(
114
+ old_fragment=(
115
+ "def read_count() -> int:\n"
116
+ " clean_count = 0\n"
117
+ " return clean_count\n"
118
+ ),
119
+ new_fragment=(
120
+ "def read_count() -> int:\n"
121
+ " result_count = 0\n"
122
+ " return result_count\n"
123
+ ),
124
+ expected_marker="result_count",
125
+ rule_name="check_banned_noun_word_boundary",
126
+ ),
127
+ NarrowEditFixture(
128
+ old_fragment=(
129
+ "def check_ready() -> bool:\n"
130
+ " is_ready = True\n"
131
+ " return is_ready\n"
132
+ ),
133
+ new_fragment=(
134
+ "def check_ready() -> bool:\n"
135
+ " ready = True\n"
136
+ " return ready\n"
137
+ ),
138
+ expected_marker="ready",
139
+ rule_name="check_boolean_naming",
140
+ ),
141
+ NarrowEditFixture(
142
+ old_fragment=(
143
+ "def submit_form() -> None:\n"
144
+ " if find_and_click('#submit'):\n"
145
+ " return\n"
146
+ ),
147
+ new_fragment=(
148
+ "def submit_form() -> None:\n"
149
+ " find_and_click('#submit')\n"
150
+ ),
151
+ expected_marker="find_and_click",
152
+ rule_name="check_ignored_must_check_return",
153
+ ),
154
+ )
155
+
156
+
157
+ def _validate_narrow_edit(narrow_edit_fixture: NarrowEditFixture) -> list[str]:
158
+ prior_full_file = narrow_edit_fixture.old_fragment
159
+ post_edit_full_file = narrow_edit_fixture.new_fragment
160
+ return validate_content(
161
+ narrow_edit_fixture.new_fragment,
162
+ PRODUCTION_FILE_PATH,
163
+ old_content=narrow_edit_fixture.old_fragment,
164
+ full_file_content=post_edit_full_file,
165
+ prior_full_file_content=prior_full_file,
166
+ )
167
+
168
+
169
+ def _scope_aware_rule_names_from_source() -> frozenset[str]:
170
+ source_tree = ast.parse(inspect.getsource(validate_content))
171
+ all_rule_names: set[str] = set()
172
+ for each_call in ast.walk(source_tree):
173
+ if not isinstance(each_call, ast.Call):
174
+ continue
175
+ if not isinstance(each_call.func, ast.Name):
176
+ continue
177
+ if each_call.func.id == "_fragment_or_deferred_check":
178
+ all_argument_names = {
179
+ each_name.id
180
+ for each_argument in each_call.args[1:]
181
+ for each_name in ast.walk(each_argument)
182
+ if isinstance(each_name, ast.Name)
183
+ }
184
+ if (
185
+ "defer_scope_to_caller" in all_argument_names
186
+ and each_call.args
187
+ and isinstance(each_call.args[0], ast.Name)
188
+ ):
189
+ all_rule_names.add(each_call.args[0].id)
190
+ continue
191
+ if not each_call.func.id.startswith("check_"):
192
+ continue
193
+ all_argument_names = {
194
+ each_name.id
195
+ for each_argument in each_call.args
196
+ for each_name in ast.walk(each_argument)
197
+ if isinstance(each_name, ast.Name)
198
+ }
199
+ all_argument_names.update(
200
+ each_name.id
201
+ for each_keyword in each_call.keywords
202
+ for each_name in ast.walk(each_keyword.value)
203
+ if isinstance(each_name, ast.Name)
204
+ )
205
+ if (
206
+ "all_changed_lines" in all_argument_names
207
+ or "defer_scope_to_caller" in all_argument_names
208
+ ):
209
+ all_rule_names.add(each_call.func.id)
210
+ return frozenset(all_rule_names)
211
+
212
+
213
+ @pytest.mark.parametrize("narrow_edit_fixture", ALL_REQUIRED_RULE_FIXTURES)
214
+ def test_narrow_edit_selects_each_accepted_rule(
215
+ narrow_edit_fixture: NarrowEditFixture,
216
+ ) -> None:
217
+ all_issues = _validate_narrow_edit(narrow_edit_fixture)
218
+ expected_rule_text = EXPECTED_RULE_TEXT_BY_NAME[narrow_edit_fixture.rule_name]
219
+
220
+ assert any(
221
+ narrow_edit_fixture.expected_marker in each_issue
222
+ and expected_rule_text in each_issue
223
+ for each_issue in all_issues
224
+ ), (
225
+ "the changed fragment must select its checker and report its diagnostic marker "
226
+ f"{narrow_edit_fixture.expected_marker!r}, got {all_issues!r}"
227
+ )
228
+
229
+
230
+ def test_scope_aware_inventory_matches_enforcer_dispatch() -> None:
231
+ assert _scope_aware_rule_names_from_source() == ALL_SCOPE_AWARE_RULE_NAMES
232
+ def test_narrow_edit_acceptance_set_is_source_backed_and_complete() -> None:
233
+ all_fixture_rule_names = {
234
+ each_fixture.rule_name for each_fixture in ALL_REQUIRED_RULE_FIXTURES
235
+ }
236
+
237
+ assert all_fixture_rule_names == NARROW_EDIT_ACCEPTED_RULE_NAMES
238
+ assert NARROW_EDIT_ACCEPTED_RULE_NAMES <= ALL_SCOPE_AWARE_RULE_NAMES
239
+
240
+ def test_narrow_edit_drops_an_untouched_banned_noun() -> None:
241
+ untouched_source = "OLD_RESULT_PATH = 0\n"
242
+ old_fragment = "PLACEHOLDER_NAME = 0\n"
243
+ new_fragment = "NEW_RESULT_PATH = 0\n"
244
+ issues = validate_content(
245
+ new_fragment,
246
+ PRODUCTION_FILE_PATH,
247
+ old_content=old_fragment,
248
+ full_file_content=untouched_source + new_fragment,
249
+ prior_full_file_content=untouched_source + old_fragment,
250
+ )
251
+
252
+ assert any("NEW_RESULT_PATH" in each_issue for each_issue in issues)
253
+ assert not any("OLD_RESULT_PATH" in each_issue for each_issue in issues)
254
+
255
+
256
+ def test_narrow_edit_skips_python_rules_for_a_non_python_target() -> None:
257
+ source = "def process_data() -> None:\n print('payload')\n"
258
+
259
+ issues = validate_content(source, "packages/app/services.txt", old_content="")
260
+
261
+ assert issues == []
262
+ def _run_edit_stage(
263
+ file_path: Path,
264
+ old_fragment: str,
265
+ new_fragment: str,
266
+ monkeypatch: pytest.MonkeyPatch,
267
+ capsys: pytest.CaptureFixture[str],
268
+ ) -> str:
269
+ payload = json.dumps(
270
+ {
271
+ "tool_name": "Edit",
272
+ "tool_input": {
273
+ "file_path": str(file_path),
274
+ "old_string": old_fragment,
275
+ "new_string": new_fragment,
276
+ },
277
+ }
278
+ )
279
+ monkeypatch.setattr(sys, "stdin", io.StringIO(payload))
280
+ try:
281
+ main([])
282
+ except SystemExit:
283
+ pass
284
+ return capsys.readouterr().out
285
+
286
+
287
+ def test_edit_entrypoint_reports_an_accepted_rule_for_the_changed_fragment(
288
+ tmp_path_factory: pytest.TempPathFactory,
289
+ monkeypatch: pytest.MonkeyPatch,
290
+ capsys: pytest.CaptureFixture[str],
291
+ ) -> None:
292
+ staging_directory = tmp_path_factory.mktemp("narrow_edit")
293
+ source_file = staging_directory / "service.py"
294
+ old_fragment = "def read_count() -> int:\n clean_count = 0\n return clean_count\n"
295
+ new_fragment = "def read_count() -> int:\n result_count = 0\n return result_count\n"
296
+ source_file.write_text(old_fragment, encoding="utf-8")
297
+
298
+ captured_stdout = _run_edit_stage(
299
+ source_file,
300
+ old_fragment,
301
+ new_fragment,
302
+ monkeypatch,
303
+ capsys,
304
+ )
305
+
306
+ deny_payload = json.loads(captured_stdout)
307
+ deny_reason = deny_payload["hookSpecificOutput"]["permissionDecisionReason"]
308
+ assert deny_payload["hookSpecificOutput"]["permissionDecision"] == "deny"
309
+ assert "result_count" in deny_reason