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
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env python3
2
- """Deny a Write or Edit whose target filename names a secret or a lock file.
2
+ """Deny a Write, Edit, or MultiEdit whose target filename names a secret or a lock file.
3
3
 
4
4
  ::
5
5
 
@@ -35,10 +35,12 @@ import sys
35
35
  import _path_setup # noqa: F401
36
36
 
37
37
  from hooks_constants.hook_block_logger import log_hook_block
38
+ from hooks_constants.pre_tool_use_dispatcher_constants import (
39
+ ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
40
+ )
38
41
  from hooks_constants.sensitive_file_protector_constants import (
39
42
  ALL_SENSITIVE_PATTERNS,
40
43
  ALL_TEMPLATE_SUFFIXES,
41
- ALL_WRITE_EDIT_TOOLS,
42
44
  DENY_DECISION,
43
45
  DENY_REASON_TEMPLATE,
44
46
  HOOK_EVENT_NAME,
@@ -57,7 +59,7 @@ def is_template_filename(filename: str) -> bool:
57
59
  .env.local -> False
58
60
 
59
61
  Args:
60
- filename: The basename of the file a Write or Edit targets.
62
+ filename: The basename of the file a Write, Edit, or MultiEdit targets.
61
63
 
62
64
  Returns:
63
65
  True when the basename's final suffix is a template suffix.
@@ -69,7 +71,7 @@ def is_sensitive_file(file_path: str) -> str | None:
69
71
  """Return the sensitive pattern a path's basename matches, or None.
70
72
 
71
73
  Args:
72
- file_path: The path the Write or Edit targets.
74
+ file_path: The path the Write, Edit, or MultiEdit targets.
73
75
 
74
76
  Returns:
75
77
  The matched pattern, or None when the basename names a template or
@@ -106,7 +108,7 @@ def deny_write(file_path: str, matched_pattern: str) -> None:
106
108
  """Record the block in the hook-blocks log and emit the deny decision.
107
109
 
108
110
  Args:
109
- file_path: The path the Write or Edit targets.
111
+ file_path: The path the Write, Edit, or MultiEdit targets.
110
112
  matched_pattern: The sensitive pattern the basename matched.
111
113
  """
112
114
  deny_reason = DENY_REASON_TEMPLATE.format(
@@ -132,7 +134,7 @@ def main() -> None:
132
134
  tool_name = hook_input.get("tool_name", "")
133
135
  tool_input = hook_input.get("tool_input", {})
134
136
 
135
- if tool_name not in ALL_WRITE_EDIT_TOOLS:
137
+ if tool_name not in ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES:
136
138
  sys.exit(0)
137
139
 
138
140
  file_path = tool_input.get("file_path", "")
@@ -18,6 +18,7 @@ blocks on infrastructure trouble.
18
18
  from __future__ import annotations
19
19
 
20
20
  import json
21
+ import os
21
22
  import shlex
22
23
  import subprocess
23
24
  import sys
@@ -601,6 +602,35 @@ def _build_denial(all_offending_paths: list[str]) -> dict:
601
602
  }
602
603
 
603
604
 
605
+ def _resolve_hook_working_directory(
606
+ bash_command: str,
607
+ all_hook_payload: dict[str, object],
608
+ ) -> str | None:
609
+ """Resolve the commit directory from the command or its event payload.
610
+
611
+ Args:
612
+ bash_command: Bash command whose explicit directory takes precedence.
613
+ all_hook_payload: PreToolUse payload carrying the event ``cwd``.
614
+
615
+ Returns:
616
+ The validated commit directory, or None for the hook process directory.
617
+ """
618
+ command_directory = extract_git_working_directory(bash_command)
619
+ event_directory = all_hook_payload.get("cwd")
620
+ if not isinstance(event_directory, str) or not event_directory:
621
+ return resolve_directory(command_directory)
622
+ validated_event_directory = resolve_directory(event_directory)
623
+ if command_directory is None:
624
+ return validated_event_directory
625
+ expanded_command_directory = os.path.expanduser(command_directory)
626
+ command_path = Path(expanded_command_directory)
627
+ if command_path.is_absolute():
628
+ return resolve_directory(str(command_path))
629
+ if validated_event_directory is None:
630
+ return None
631
+ return resolve_directory(str(Path(validated_event_directory) / command_path))
632
+
633
+
604
634
  def main() -> None:
605
635
  """Deny a git commit that would drop a tracked file that was edited yet left unstaged."""
606
636
  hook_payload = read_hook_input_dictionary_from_stdin()
@@ -620,7 +650,7 @@ def main() -> None:
620
650
  session_edited_keys = _session_edited_keys(session_id)
621
651
  if not session_edited_keys:
622
652
  sys.exit(0)
623
- working_directory = resolve_directory(extract_git_working_directory(bash_command))
653
+ working_directory = _resolve_hook_working_directory(bash_command, hook_payload)
624
654
  repository_root = resolve_repository_root(working_directory)
625
655
  if repository_root is None:
626
656
  sys.exit(0)
@@ -0,0 +1,145 @@
1
+ """Production-path tests for direct commit branch protection."""
2
+
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ import subprocess
7
+ import sys
8
+
9
+
10
+ def _run_git(from_directory: Path, *arguments: str) -> None:
11
+ subprocess.run(
12
+ ["git", *arguments],
13
+ cwd=from_directory,
14
+ check=True,
15
+ capture_output=True,
16
+ text=True,
17
+ )
18
+
19
+
20
+ def _create_repository(from_directory: Path, branch_name: str) -> Path:
21
+ repository = from_directory / branch_name
22
+ repository.mkdir()
23
+ _run_git(repository, "init", "--initial-branch", "main")
24
+ hook_directory = repository / ".git" / "test-hooks"
25
+ hook_directory.mkdir()
26
+ _run_git(repository, "config", "core.hooksPath", str(hook_directory))
27
+ _run_git(repository, "config", "user.email", "test@example.com")
28
+ _run_git(repository, "config", "user.name", "Test User")
29
+ (repository / "tracked.txt").write_text("tracked\n", encoding="utf-8")
30
+ _run_git(repository, "add", "tracked.txt")
31
+ _run_git(repository, "-c", "commit.gpgsign=false", "commit", "-m", "initial")
32
+ if branch_name != "main":
33
+ _run_git(repository, "switch", "-c", branch_name)
34
+ return repository
35
+
36
+
37
+ def _run_commit_gate(
38
+ from_directory: Path,
39
+ process_directory: Path,
40
+ command: str = "git commit -m test",
41
+ include_event_cwd: bool = True,
42
+ ) -> subprocess.CompletedProcess[str]:
43
+ hook_script = Path(__file__).with_name("block_main_commit.py")
44
+ home_directory = from_directory.parent / "test-home"
45
+ home_directory.mkdir(exist_ok=True)
46
+ environment = os.environ.copy()
47
+ environment.update({"HOME": str(home_directory), "USERPROFILE": str(home_directory)})
48
+ hook_payload: dict[str, object] = {
49
+ "tool_name": "Bash",
50
+ "tool_input": {"command": command},
51
+ }
52
+ if include_event_cwd:
53
+ hook_payload["cwd"] = str(from_directory)
54
+ return subprocess.run(
55
+ [sys.executable, str(hook_script)],
56
+ cwd=process_directory,
57
+ input=json.dumps(hook_payload),
58
+ check=False,
59
+ capture_output=True,
60
+ text=True,
61
+ env=environment,
62
+ )
63
+
64
+
65
+ def test_blocks_commit_on_protected_branch_from_hook_event_cwd(tmp_path: Path) -> None:
66
+ repository = _create_repository(tmp_path, "main")
67
+ process_repository = _create_repository(tmp_path, "agent-owned-change")
68
+
69
+ completed_process = _run_commit_gate(repository, process_repository)
70
+
71
+ assert completed_process.returncode == 0
72
+ hook_response = json.loads(completed_process.stdout)
73
+ assert hook_response["hookSpecificOutput"]["permissionDecision"] == "deny"
74
+ assert str(repository) in hook_response["hookSpecificOutput"]["permissionDecisionReason"]
75
+
76
+
77
+ def test_allows_commit_on_owned_branch_from_hook_event_cwd(tmp_path: Path) -> None:
78
+ repository = _create_repository(tmp_path, "agent-owned-change")
79
+ process_repository = _create_repository(tmp_path, "main")
80
+
81
+ completed_process = _run_commit_gate(repository, process_repository)
82
+
83
+ assert completed_process.returncode == 0
84
+ assert completed_process.stdout == ""
85
+
86
+
87
+ def test_resolves_relative_git_c_from_hook_event_cwd(tmp_path: Path) -> None:
88
+ repository = _create_repository(tmp_path, "main")
89
+ process_repository = _create_repository(tmp_path, "agent-owned-change")
90
+
91
+ completed_process = _run_commit_gate(
92
+ tmp_path,
93
+ process_repository,
94
+ command="git -C main commit -m test",
95
+ )
96
+
97
+ assert completed_process.returncode == 0
98
+ hook_response = json.loads(completed_process.stdout)
99
+ assert hook_response["hookSpecificOutput"]["permissionDecision"] == "deny"
100
+ assert str(repository) in hook_response["hookSpecificOutput"]["permissionDecisionReason"]
101
+
102
+
103
+ def test_preserves_dispatcher_ownership_without_event_cwd(tmp_path: Path) -> None:
104
+ repository = _create_repository(tmp_path, "main")
105
+ process_repository = _create_repository(tmp_path, "agent-owned-change")
106
+
107
+ completed_process = _run_commit_gate(
108
+ tmp_path,
109
+ process_repository,
110
+ command=f'git -C "{repository}" commit -m test',
111
+ include_event_cwd=False,
112
+ )
113
+
114
+ assert completed_process.returncode == 0
115
+ assert completed_process.stdout == ""
116
+
117
+
118
+ def test_matches_case_insensitive_shell_git_command(tmp_path: Path) -> None:
119
+ repository = _create_repository(tmp_path, "main")
120
+ process_repository = _create_repository(tmp_path, "agent-owned-change")
121
+
122
+ completed_process = _run_commit_gate(
123
+ tmp_path,
124
+ process_repository,
125
+ command="CD main && GIT COMMIT -m test",
126
+ )
127
+
128
+ assert completed_process.returncode == 0
129
+ hook_response = json.loads(completed_process.stdout)
130
+ assert hook_response["hookSpecificOutput"]["permissionDecision"] == "deny"
131
+ assert str(repository) in hook_response["hookSpecificOutput"]["permissionDecisionReason"]
132
+
133
+
134
+ def test_ignores_unrelated_command_containing_git_commit(tmp_path: Path) -> None:
135
+ repository = _create_repository(tmp_path, "main")
136
+ process_repository = _create_repository(tmp_path, "agent-owned-change")
137
+
138
+ completed_process = _run_commit_gate(
139
+ repository,
140
+ process_repository,
141
+ command="echo git commit",
142
+ )
143
+
144
+ assert completed_process.returncode == 0
145
+ assert completed_process.stdout == ""
@@ -1,6 +1,7 @@
1
1
  """Tests for claude_md_orphan_file_blocker hook."""
2
2
 
3
3
  import json
4
+ import importlib
4
5
  import os
5
6
  import subprocess
6
7
  import sys
@@ -9,7 +10,57 @@ from pathlib import Path
9
10
 
10
11
  import pytest
11
12
 
12
- sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
13
+ _test_file_path = Path(__file__).resolve()
14
+ _blocking_directory = str(_test_file_path.parent)
15
+ _hooks_directory = str(_test_file_path.parent.parent)
16
+ _blocking_directory_key = os.path.normcase(os.path.abspath(_blocking_directory))
17
+ _hooks_directory_key = os.path.normcase(os.path.abspath(_hooks_directory))
18
+ _hooks_directory_origin_key = _hooks_directory_key
19
+ sys.path[:] = [_blocking_directory, _hooks_directory] + [
20
+ each_path
21
+ for each_path in sys.path
22
+ if not isinstance(each_path, str)
23
+ or os.path.normcase(os.path.abspath(each_path))
24
+ not in (_blocking_directory_key, _hooks_directory_key)
25
+ ]
26
+ importlib.invalidate_caches()
27
+
28
+ _all_cached_module_prefixes = (
29
+ "claude_md_orphan_file_blocker",
30
+ "claude_md_orphan_file_blocker_parts",
31
+ "inventory_intent_records",
32
+ "hooks_constants",
33
+ "code_rules_annotations_length",
34
+ "code_rules_boolean_mustcheck",
35
+ "code_rules_naming_collection",
36
+ "code_rules_path_utils",
37
+ "code_rules_shared",
38
+ )
39
+
40
+
41
+ for each_module_name, each_module in tuple(sys.modules.items()):
42
+ if not any(
43
+ each_module_name == each_prefix or each_module_name.startswith(f"{each_prefix}.")
44
+ for each_prefix in _all_cached_module_prefixes
45
+ ):
46
+ continue
47
+ module_file = getattr(each_module, "__file__", None)
48
+ is_cached_module_in_this_checkout = False
49
+ if isinstance(module_file, str):
50
+ module_file_key = os.path.normcase(str(Path(module_file).resolve()))
51
+ try:
52
+ is_cached_module_in_this_checkout = (
53
+ os.path.commonpath((_hooks_directory_origin_key, module_file_key))
54
+ == _hooks_directory_origin_key
55
+ )
56
+ except ValueError:
57
+ pass
58
+ if is_cached_module_in_this_checkout:
59
+ continue
60
+ raise ImportError(
61
+ "test_claude_md_orphan_file_blocker: cached module outside this checkout: "
62
+ f"{each_module_name} ({module_file!r})"
63
+ )
13
64
 
14
65
  import claude_md_orphan_file_blocker as blocker_module
15
66
  from claude_md_orphan_file_blocker import (
@@ -82,6 +133,125 @@ def _isolated_claude_md_path(tmp_path: Path) -> Path:
82
133
  return isolated_directory / "CLAUDE.md"
83
134
 
84
135
 
136
+ def _run_isolated_test_module_import(
137
+ driver_setup: str, driver_assertions: str
138
+ ) -> subprocess.CompletedProcess:
139
+ """Import this test module in an isolated interpreter with caller-supplied state."""
140
+ test_module_path = _test_file_path
141
+ driver = (
142
+ "import importlib.util\n"
143
+ "import os\n"
144
+ "import site\n"
145
+ "import sys\n"
146
+ "from pathlib import Path\n"
147
+ f"test_module_path = Path({str(test_module_path)!r})\n"
148
+ "sys.path.append(site.getusersitepackages())\n"
149
+ f"{driver_setup}"
150
+ "spec = importlib.util.spec_from_file_location('bootstrap_target_test', test_module_path)\n"
151
+ "module = importlib.util.module_from_spec(spec)\n"
152
+ "spec.loader.exec_module(module)\n"
153
+ f"{driver_assertions}"
154
+ )
155
+ return subprocess.run(
156
+ [sys.executable, "-I", "-c", driver],
157
+ check=False,
158
+ capture_output=True,
159
+ text=True,
160
+ )
161
+
162
+
163
+ def test_import_bootstrap_prioritizes_this_checkout(tmp_path: Path) -> None:
164
+ foreign_hooks_directory = tmp_path / "foreign_hooks"
165
+ foreign_blocking_directory = foreign_hooks_directory / "blocking"
166
+ foreign_constants_directory = foreign_hooks_directory / "hooks_constants"
167
+ foreign_blocking_directory.mkdir(parents=True)
168
+ foreign_constants_directory.mkdir()
169
+ (foreign_blocking_directory / "claude_md_orphan_file_blocker.py").write_text(
170
+ "raise RuntimeError('foreign blocker loaded')\n", encoding="utf-8"
171
+ )
172
+ (foreign_constants_directory / "__init__.py").write_text(
173
+ "raise RuntimeError('foreign constants loaded')\n", encoding="utf-8"
174
+ )
175
+ target_blocking_directory = Path(__file__).resolve().parent
176
+ target_hooks_directory = target_blocking_directory.parent
177
+ driver_setup = (
178
+ f"foreign_blocking_directory = Path({str(foreign_blocking_directory)!r})\n"
179
+ f"foreign_hooks_directory = Path({str(foreign_hooks_directory)!r})\n"
180
+ f"target_blocking_directory = Path({str(target_blocking_directory)!r})\n"
181
+ f"target_hooks_directory = Path({str(target_hooks_directory)!r})\n"
182
+ "sys.path[:0] = [\n"
183
+ " str(foreign_blocking_directory), str(foreign_hooks_directory),\n"
184
+ " str(target_blocking_directory), str(target_hooks_directory),\n"
185
+ "]\n"
186
+ )
187
+ driver_assertions = (
188
+ "import claude_md_orphan_file_blocker\n"
189
+ "import hooks_constants\n"
190
+ "assert Path(claude_md_orphan_file_blocker.__file__).resolve().parent "
191
+ "== target_blocking_directory\n"
192
+ "assert Path(hooks_constants.__file__).resolve().parent.parent "
193
+ "== target_hooks_directory\n"
194
+ "all_uncovered_module_names = [\n"
195
+ " each_module_name\n"
196
+ " for each_module_name, each_loaded_module in sys.modules.items()\n"
197
+ " if each_loaded_module is not module\n"
198
+ " and isinstance(getattr(each_loaded_module, '__file__', None), str)\n"
199
+ " and Path(each_loaded_module.__file__).resolve().is_relative_to(\n"
200
+ " target_hooks_directory\n"
201
+ " )\n"
202
+ " and not any(\n"
203
+ " each_module_name == each_prefix\n"
204
+ " or each_module_name.startswith(f'{each_prefix}.')\n"
205
+ " for each_prefix in module._all_cached_module_prefixes\n"
206
+ " )\n"
207
+ "]\n"
208
+ "assert not all_uncovered_module_names, all_uncovered_module_names\n"
209
+ )
210
+ completed = _run_isolated_test_module_import(driver_setup, driver_assertions)
211
+ assert completed.returncode == 0, completed.stderr
212
+
213
+
214
+ def test_import_bootstrap_preserves_non_string_path_entries() -> None:
215
+ completed = _run_isolated_test_module_import(
216
+ "sys.path.insert(0, None)\n", "assert None in sys.path\n"
217
+ )
218
+ assert completed.returncode == 0, completed.stderr
219
+
220
+
221
+ def test_import_bootstrap_invalidates_stale_importer_cache() -> None:
222
+ target_blocking_directory = Path(__file__).resolve().parent
223
+ driver_setup = (
224
+ f"target_blocking_directory = Path({str(target_blocking_directory)!r})\n"
225
+ "sys.path_importer_cache[str(target_blocking_directory)] = None\n"
226
+ )
227
+ completed = _run_isolated_test_module_import(driver_setup, "")
228
+ assert completed.returncode == 0, completed.stderr
229
+
230
+
231
+ @pytest.mark.parametrize(
232
+ "cached_module_name",
233
+ (
234
+ "hooks_constants",
235
+ "claude_md_orphan_file_blocker",
236
+ "code_rules_path_utils",
237
+ ),
238
+ )
239
+ def test_import_bootstrap_rejects_foreign_cached_modules(
240
+ tmp_path: Path, cached_module_name: str
241
+ ) -> None:
242
+ foreign_module_path = tmp_path / "foreign_hooks" / f"{cached_module_name}.py"
243
+ driver_setup = (
244
+ "import types\n"
245
+ f"cached_module_name = {cached_module_name!r}\n"
246
+ "cached_module = types.ModuleType(cached_module_name)\n"
247
+ f"cached_module.__file__ = {str(foreign_module_path)!r}\n"
248
+ "sys.modules[cached_module_name] = cached_module\n"
249
+ )
250
+ completed = _run_isolated_test_module_import(driver_setup, "")
251
+ assert completed.returncode != 0
252
+ assert f"cached module outside this checkout: {cached_module_name}" in completed.stderr
253
+
254
+
85
255
  def test_blocks_write_naming_absent_python_file(tmp_path: Path):
86
256
  claude_md_path = _isolated_claude_md_path(tmp_path)
87
257
  result = _run_hook(
@@ -0,0 +1,161 @@
1
+ """Tests for the blast-radius declaration check."""
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+
8
+ _blocking_directory = str(Path(__file__).resolve().parent)
9
+ if _blocking_directory not in sys.path:
10
+ sys.path.insert(0, _blocking_directory)
11
+
12
+ from code_rules_blast_radius import check_blast_radius_declared # noqa: E402
13
+
14
+ PRODUCTION_PATH = "pipeline/asset_run.py"
15
+
16
+
17
+ def test_should_report_a_loop_raise_with_pending_blast_radius_declaration() -> None:
18
+ """A loop-body raise gets an advisory requesting its blast-radius declaration."""
19
+ content = (
20
+ "def run(all_members):\n"
21
+ " for each_member in all_members:\n"
22
+ " if each_member.requires_preparation:\n"
23
+ " raise AssetError('member requires preparation')\n"
24
+ )
25
+
26
+ all_issues = check_blast_radius_declared(content, PRODUCTION_PATH)
27
+
28
+ assert len(all_issues) == 1
29
+ assert "Line 4" in all_issues[0]
30
+
31
+
32
+ def test_should_accept_a_run_fatal_raise_inside_a_loop() -> None:
33
+ """A RunFatal type declares that the whole run ends."""
34
+ content = (
35
+ "def run(all_members):\n"
36
+ " for each_member in all_members:\n"
37
+ " if each_member.digest_differs:\n"
38
+ " raise AssetRunFatal('source bytes changed')\n"
39
+ )
40
+
41
+ assert check_blast_radius_declared(content, PRODUCTION_PATH) == []
42
+
43
+
44
+ def test_should_accept_an_item_blocked_raise_inside_a_loop() -> None:
45
+ """An ItemBlocked type declares a member-scoped stop."""
46
+ content = (
47
+ "def run(all_members):\n"
48
+ " for each_member in all_members:\n"
49
+ " if each_member.requires_resize:\n"
50
+ " raise AssetItemBlocked('member requires resizing')\n"
51
+ )
52
+
53
+ assert check_blast_radius_declared(content, PRODUCTION_PATH) == []
54
+
55
+
56
+ def test_should_accept_a_raise_wrapped_by_a_blast_radius_boundary() -> None:
57
+ """A per-member boundary catches a declared type and parks the failure."""
58
+ content = (
59
+ "def run(all_members):\n"
60
+ " for each_member in all_members:\n"
61
+ " try:\n"
62
+ " if each_member.requires_resize:\n"
63
+ " raise AssetItemBlocked('member requires resizing')\n"
64
+ " except AssetItemBlocked as failure:\n"
65
+ " park(each_member, failure)\n"
66
+ )
67
+
68
+ assert check_blast_radius_declared(content, PRODUCTION_PATH) == []
69
+
70
+
71
+ def test_should_report_runtime_error_with_a_different_declared_handler() -> None:
72
+ """A different declared handler leaves a runtime crash requiring a name."""
73
+ content = (
74
+ "def run(all_members):\n"
75
+ " for each_member in all_members:\n"
76
+ " try:\n"
77
+ " raise RuntimeError('code defect')\n"
78
+ " except AssetItemBlocked:\n"
79
+ " park(each_member)\n"
80
+ )
81
+
82
+ assert len(check_blast_radius_declared(content, PRODUCTION_PATH)) == 1
83
+
84
+
85
+ @pytest.mark.parametrize("raised_type", ["RuntimeError", "TypeError", "AttributeError", "ValueError"])
86
+ def test_should_require_corresponding_handler_for_each_explicit_raise(
87
+ raised_type: str,
88
+ ) -> None:
89
+ """Each explicit raise needs a handler naming that same type."""
90
+ content = (
91
+ "def run(all_members):\n"
92
+ " for each_member in all_members:\n"
93
+ " try:\n"
94
+ f" raise {raised_type}('code defect')\n"
95
+ " except AssetItemBlocked:\n"
96
+ " park(each_member)\n"
97
+ )
98
+
99
+ assert len(check_blast_radius_declared(content, PRODUCTION_PATH)) == 1
100
+
101
+
102
+ def test_should_accept_a_run_level_raise_during_manifest_preparation() -> None:
103
+ """A run-level raise belongs to manifest preparation."""
104
+ content = (
105
+ "def prepare(manifest):\n"
106
+ " if manifest.requires_preparation:\n"
107
+ " raise AssetError('manifest requires preparation')\n"
108
+ )
109
+
110
+ assert check_blast_radius_declared(content, PRODUCTION_PATH) == []
111
+
112
+
113
+ def test_should_accept_a_bare_reraise_inside_a_loop() -> None:
114
+ """A bare re-raise propagates an error carrying a declared radius."""
115
+ content = (
116
+ "def run(all_members):\n"
117
+ " for each_member in all_members:\n"
118
+ " try:\n"
119
+ " process(each_member)\n"
120
+ " except AssetItemBlocked:\n"
121
+ " raise\n"
122
+ )
123
+
124
+ assert check_blast_radius_declared(content, PRODUCTION_PATH) == []
125
+
126
+
127
+ def test_should_accept_test_files() -> None:
128
+ """Test modules raise freely during scenario coverage."""
129
+ content = (
130
+ "def test_records_member_failure(all_members):\n"
131
+ " for each_member in all_members:\n"
132
+ " raise AssetError('member failure')\n"
133
+ )
134
+
135
+ assert check_blast_radius_declared(content, "pipeline/test_asset_run.py") == []
136
+
137
+
138
+ def test_should_report_each_loop_raise_with_pending_blast_radius_declaration() -> None:
139
+ """Each loop-body raise gets an advisory requesting its blast-radius declaration."""
140
+ content = (
141
+ "def run(all_members):\n"
142
+ " for each_member in all_members:\n"
143
+ " if each_member.requires_source:\n"
144
+ " raise AssetError('member requires a source')\n"
145
+ " if each_member.requires_resize:\n"
146
+ " raise AssetError('member requires resizing')\n"
147
+ )
148
+
149
+ assert len(check_blast_radius_declared(content, PRODUCTION_PATH)) == 2
150
+
151
+
152
+ def test_should_ignore_raise_in_nested_helper_body() -> None:
153
+ """A nested helper body is checked at its own call boundary."""
154
+ content = (
155
+ "def run(all_members):\n"
156
+ " for each_member in all_members:\n"
157
+ " def validate_member():\n"
158
+ " raise AssetError('member validation failed')\n"
159
+ )
160
+
161
+ assert check_blast_radius_declared(content, PRODUCTION_PATH) == []
@@ -12,20 +12,21 @@ so the repository code rules do not govern it::
12
12
  from __future__ import annotations
13
13
 
14
14
  import os
15
- import subprocess
16
- import sys
15
+ from subprocess import CompletedProcess
17
16
  import tempfile
18
17
  from pathlib import Path
19
18
 
19
+ import pytest
20
+
20
21
  from blocking import _path_setup # noqa: F401 (pins this checkout ahead of sibling worktrees)
22
+ import code_rules_enforcer_test_support
21
23
  from blocking.code_rules_shared import is_agent_home_tooling, is_ephemeral_path
22
-
23
- _ENFORCER_SCRIPT = Path(__file__).resolve().parent / "code_rules_enforcer.py"
24
+ from code_rules_enforcer_test_support import run_precheck
24
25
 
25
26
  _VIOLATING_PRODUCTION_SOURCE = "def process_data(payload: str) -> None:\n print(payload)\n"
26
27
 
27
28
 
28
- def _check_as(candidate_source: str, target_path: str) -> subprocess.CompletedProcess[str]:
29
+ def _check_as(candidate_source: str, target_path: str) -> CompletedProcess[str]:
29
30
  """Run the enforcer's pre-check on a candidate judged as another path.
30
31
 
31
32
  Args:
@@ -38,13 +39,37 @@ def _check_as(candidate_source: str, target_path: str) -> subprocess.CompletedPr
38
39
  with tempfile.TemporaryDirectory() as scratch:
39
40
  candidate = Path(scratch) / "candidate.py"
40
41
  candidate.write_text(candidate_source, encoding="utf-8")
41
- return subprocess.run(
42
- [sys.executable, str(_ENFORCER_SCRIPT), "--check", str(candidate), "--as", target_path],
43
- input="",
44
- capture_output=True,
45
- text=True,
46
- check=False,
47
- )
42
+ return run_precheck(candidate, target_path, None)
43
+
44
+
45
+ def test_run_precheck_forwards_environment_mapping(
46
+ monkeypatch: pytest.MonkeyPatch,
47
+ ) -> None:
48
+ expected_environment_by_name = {"CLAUDE_JOB_DIR": "/agent/jobs"}
49
+ captured_environment_by_name: dict[str, str] | None = None
50
+
51
+ def capture_run_enforcer_cli(
52
+ _script_path: Path,
53
+ _all_arguments: list[str],
54
+ extra_environment_by_name: dict[str, str] | None,
55
+ ) -> CompletedProcess[str]:
56
+ nonlocal captured_environment_by_name
57
+ captured_environment_by_name = extra_environment_by_name
58
+ return CompletedProcess(args=[], returncode=0, stdout="", stderr="")
59
+
60
+ monkeypatch.setattr(
61
+ code_rules_enforcer_test_support,
62
+ "run_enforcer_cli",
63
+ capture_run_enforcer_cli,
64
+ )
65
+ completed = run_precheck(
66
+ Path("candidate.py"),
67
+ "target.py",
68
+ expected_environment_by_name,
69
+ )
70
+
71
+ assert completed.returncode == 0
72
+ assert captured_environment_by_name == {"CLAUDE_JOB_DIR": "/agent/jobs"}
48
73
 
49
74
 
50
75
  def test_posix_agent_home_path_is_agent_tooling() -> None: