claude-dev-env 7.4.1 → 7.6.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 (178) hide show
  1. package/.agents/skills/everything-search/scripts/everything_search.py +11 -0
  2. package/.agents/skills/everything-search/scripts/test_everything_search.py +1 -0
  3. package/.agents/skills/fresh-branch/SKILL.md +1 -0
  4. package/.agents/skills/fresh-branch/scripts/fresh_branch_git_commands.py +3 -0
  5. package/.agents/skills/fresh-branch/scripts/subprocess_window_access.py +43 -0
  6. package/.agents/skills/syncing-submodules/SKILL.md +1 -0
  7. package/.agents/skills/syncing-submodules/scripts/submodule_sync.py +4 -0
  8. package/.agents/skills/syncing-submodules/scripts/subprocess_window_access.py +43 -0
  9. package/.agents/skills/test-runner/SKILL.md +1 -0
  10. package/.agents/skills/test-runner/scripts/preflight_checks.py +5 -0
  11. package/.agents/skills/test-runner/scripts/run_tests.py +11 -0
  12. package/.agents/skills/test-runner/scripts/subprocess_window_access.py +43 -0
  13. package/.agents/skills/test-runner/scripts/test_run_tests.py +9 -7
  14. package/_shared/pr-loop/scripts/check_bugbot_ci.py +3 -0
  15. package/_shared/pr-loop/scripts/check_convergence_gates.py +3 -0
  16. package/_shared/pr-loop/scripts/check_pending_reviews.py +3 -0
  17. package/_shared/pr-loop/scripts/code_rules_gate_parts/added_line_maps.py +3 -0
  18. package/_shared/pr-loop/scripts/code_rules_gate_parts/baseline_import_isolation.py +3 -0
  19. package/_shared/pr-loop/scripts/code_rules_gate_parts/git_blob_readers.py +5 -0
  20. package/_shared/pr-loop/scripts/code_rules_gate_parts/git_file_sets.py +4 -0
  21. package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_regression.py +3 -0
  22. package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_running.py +3 -0
  23. package/_shared/pr-loop/scripts/codex_usage_probe.py +3 -0
  24. package/_shared/pr-loop/scripts/copilot_quota.py +3 -0
  25. package/_shared/pr-loop/scripts/fetch_copilot_reviews.py +3 -0
  26. package/_shared/pr-loop/scripts/fix_hookspath.py +7 -0
  27. package/_shared/pr-loop/scripts/post_fix_reply.py +4 -0
  28. package/_shared/pr-loop/scripts/preflight.py +7 -0
  29. package/_shared/pr-loop/scripts/preflight_self_heal.py +5 -0
  30. package/_shared/pr-loop/scripts/run_codex_review.py +3 -0
  31. package/_shared/pr-loop/scripts/subprocess_window_access.py +41 -0
  32. package/_shared/pr-loop/scripts/terminology_sweep.py +5 -0
  33. package/_shared/process-tree/scripts/process_tree_kill.py +3 -0
  34. package/_shared/process-tree/scripts/subprocess_window_access.py +41 -0
  35. package/bin/install-constants.mjs +4 -2
  36. package/bin/install.cursor-rules.test.mjs +57 -15
  37. package/bin/install.mjs +10 -16
  38. package/bin/install.policy-lint.test.mjs +1 -2
  39. package/bin/install.test.mjs +0 -1
  40. package/bin/pstack-model-preferences.claude.json +23 -0
  41. package/hooks/advisory/msys_path_conversion_advisor.py +200 -0
  42. package/hooks/advisory/pr_done_reminder.py +14 -13
  43. package/hooks/advisory/refactor_guard.py +8 -0
  44. package/hooks/advisory/test_msys_path_conversion_advisor.py +223 -0
  45. package/hooks/advisory/test_pr_done_reminder.py +22 -0
  46. package/hooks/advisory/test_refactor_guard.py +40 -0
  47. package/hooks/blocking/bash_post_call_dispatcher.py +6 -11
  48. package/hooks/blocking/block_main_commit.py +4 -0
  49. package/hooks/blocking/code_rules_comments.py +39 -5
  50. package/hooks/blocking/msys_rev_path_rewriter.py +192 -0
  51. package/hooks/blocking/pii_prevention_blocker_parts/repository_exemption.py +3 -0
  52. package/hooks/blocking/precommit_code_rules_gate.py +3 -0
  53. package/hooks/blocking/tdd_enforcer_parts/content_analysis.py +19 -3
  54. package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +4 -0
  55. package/hooks/blocking/tdd_enforcer_parts/tests/test_content_analysis.py +25 -0
  56. package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +16 -3
  57. package/hooks/blocking/test_bash_post_call_dispatcher.py +2 -0
  58. package/hooks/blocking/test_code_rules_enforcer_comment_string_awareness.py +27 -0
  59. package/hooks/blocking/test_code_rules_enforcer_exempt_marker_chained.py +10 -0
  60. package/hooks/blocking/test_msys_rev_path_rewriter.py +141 -0
  61. package/hooks/git-hooks/git_hooks_constants/verification_notice_constants.py +0 -2
  62. package/hooks/git-hooks/pull_request_handoff.py +3 -0
  63. package/hooks/git-hooks/subprocess_window_access.py +41 -0
  64. package/hooks/git-hooks/test_verification_start.py +4 -0
  65. package/hooks/git-hooks/verification_notice_context.py +3 -0
  66. package/hooks/git-hooks/verification_start.py +7 -8
  67. package/hooks/hooks.json +10 -0
  68. package/hooks/hooks_constants/bash_post_call_dispatcher_constants.py +9 -0
  69. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +6 -1
  70. package/hooks/hooks_constants/code_rules_enforcer_constants.py +2 -0
  71. package/hooks/hooks_constants/msys_path_conversion_advisor_constants.py +48 -0
  72. package/hooks/hooks_constants/msys_rev_path_rewriter_constants.py +37 -0
  73. package/hooks/hooks_constants/post_tool_use_context.py +34 -0
  74. package/hooks/hooks_constants/pr_done_reminder_constants.py +0 -2
  75. package/hooks/hooks_constants/pre_tool_use_allow_output.py +48 -0
  76. package/hooks/hooks_constants/subprocess_window.py +141 -0
  77. package/hooks/hooks_constants/subprocess_window_constants.py +9 -0
  78. package/hooks/hooks_constants/test_bash_post_call_dispatcher_constants.py +9 -2
  79. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +9 -2
  80. package/hooks/hooks_constants/test_code_rules_enforcer_constants.py +6 -0
  81. package/hooks/hooks_constants/test_failure_recorder_constants.py +1 -2
  82. package/hooks/hooks_constants/test_post_tool_use_context.py +22 -0
  83. package/hooks/hooks_constants/test_pre_tool_use_allow_output.py +27 -0
  84. package/hooks/hooks_constants/test_subprocess_window.py +128 -0
  85. package/hooks/lifecycle/enter_worktree_origin_prefetch.py +3 -0
  86. package/hooks/observability/test_failure_recorder.py +3 -1
  87. package/hooks/validation/mypy_validator.py +4 -0
  88. package/hooks/validators/README.md +1 -0
  89. package/hooks/validators/git_checks.py +4 -0
  90. package/hooks/validators/health_check.py +3 -0
  91. package/hooks/validators/hook_timing_harness.py +3 -0
  92. package/hooks/validators/mypy_integration.py +4 -0
  93. package/hooks/validators/ruff_integration.py +6 -0
  94. package/hooks/validators/run_all_validators.py +7 -0
  95. package/hooks/validators/subprocess_window_access.py +41 -0
  96. package/hooks/workflow/auto_formatter.py +8 -0
  97. package/hooks/workflow/test_auto_formatter.py +39 -0
  98. package/package.json +1 -1
  99. package/rules/ask-user-question-required.md +3 -3
  100. package/rules/code-standards.md +3 -2
  101. package/rules/confirm-implementation-forks.md +2 -0
  102. package/rules/destructive-commands.md +19 -19
  103. package/rules/doc-inventory-integrity.md +4 -4
  104. package/rules/docstring-prose-matches-implementation.md +2 -2
  105. package/rules/failure-blast-radius.md +4 -2
  106. package/rules/filesystem-search.md +1 -1
  107. package/rules/git-workflow.md +34 -0
  108. package/rules/long-horizon-autonomy.md +2 -0
  109. package/rules/nas-ssh-invocation.md +3 -3
  110. package/rules/no-cross-skill-duplicate-helpers.md +6 -6
  111. package/rules/orphan-css-class.md +5 -5
  112. package/rules/paired-test-coverage.md +6 -6
  113. package/rules/plain-illustrative-docstrings.md +3 -3
  114. package/rules/pstack-models.md +4 -3
  115. package/rules/re-stage-before-commit.md +7 -7
  116. package/rules/retired-hook-prose.md +16 -0
  117. package/rules/shell-invocation.md +17 -1
  118. package/rules/testing.md +3 -5
  119. package/rules/verify-runtime-state.md +1 -1
  120. package/rules/windows-filesystem-safe.md +2 -2
  121. package/rules/workflow-substitution-slots.md +1 -1
  122. package/scripts/automatic_advisory/checkout.py +8 -1
  123. package/scripts/automatic_advisory/cli.py +9 -3
  124. package/scripts/automatic_advisory/config/constants.py +0 -2
  125. package/scripts/automatic_advisory/execution.py +7 -1
  126. package/scripts/automatic_advisory/git.py +9 -1
  127. package/scripts/automatic_advisory/process_host.py +3 -0
  128. package/scripts/claude_chain_runner.py +3 -0
  129. package/scripts/durable_post_lint_config/config/constants.py +0 -3
  130. package/scripts/durable_post_lint_config/config/test_constants.py +5 -0
  131. package/scripts/gh_artifact_upload.py +3 -0
  132. package/scripts/grok_headless_runner.py +3 -0
  133. package/scripts/grok_patch_artifacts.py +4 -0
  134. package/scripts/grok_worker_preflight.py +3 -0
  135. package/scripts/invoke_code_review.py +3 -0
  136. package/scripts/local_verification/command_runner.py +3 -0
  137. package/scripts/local_verification/git_state.py +3 -0
  138. package/scripts/policy_lint/adapter_configuration.py +24 -0
  139. package/scripts/policy_lint/adapter_detectors.py +48 -2
  140. package/scripts/policy_lint/adapter_pairing.py +150 -2
  141. package/scripts/policy_lint/adapter_retired_hook_prose.py +201 -0
  142. package/scripts/policy_lint/adapters.py +82 -5
  143. package/scripts/policy_lint/config/approved_test_pairs.py +25 -9
  144. package/scripts/policy_lint/config/constants.py +77 -0
  145. package/scripts/policy_lint/registry.py +6 -1
  146. package/scripts/policy_lint/selection.py +37 -18
  147. package/scripts/policy_lint/selection_git.py +17 -2
  148. package/scripts/repository_checks/config/constants.py +5 -0
  149. package/scripts/repository_checks/config/policy_document.py +19 -0
  150. package/scripts/repository_checks/email_exemptions.py +14 -71
  151. package/scripts/repository_checks/path_exemptions.py +35 -0
  152. package/scripts/repository_checks/policy_document.py +120 -0
  153. package/scripts/repository_checks/tracked_secrets.py +27 -4
  154. package/scripts/setup_project_paths.py +3 -0
  155. package/scripts/spawn_grok_batch.py +3 -0
  156. package/scripts/subprocess_window_access.py +41 -0
  157. package/scripts/test_dispatcher_profile_import.py +4 -0
  158. package/scripts/test_durable_post_lint.py +42 -9
  159. package/scripts/tests/test_adapter_configuration.py +8 -0
  160. package/scripts/tests/test_automatic_advisory_window_flags.py +17 -11
  161. package/scripts/tests/test_email_exemptions.py +37 -0
  162. package/scripts/tests/test_path_exemptions.py +164 -0
  163. package/scripts/tests/test_policy_document.py +104 -0
  164. package/scripts/tests/test_policy_lint_rules_function_length_scope.py +101 -0
  165. package/scripts/tests/test_policy_lint_rules_pairing.py +375 -0
  166. package/scripts/tests/test_policy_lint_rules_registry.py +116 -0
  167. package/scripts/tests/test_policy_lint_rules_retired_hook_prose.py +197 -0
  168. package/scripts/tests/test_policy_lint_selection.py +141 -0
  169. package/scripts/tests/test_subprocess_window_access.py +118 -0
  170. package/scripts/tests/test_tracked_secrets.py +79 -0
  171. package/scripts/validate_instruction_pairs.py +3 -0
  172. package/scripts/verify_installable_package.py +5 -0
  173. package/hooks/hooks_constants/convergence_branch_constants.py +0 -8
  174. package/hooks/hooks_constants/destructive_command_environment_constants.py +0 -18
  175. package/hooks/hooks_constants/test_text_stripping.py +0 -39
  176. package/hooks/hooks_constants/text_stripping.py +0 -36
  177. package/scripts/automatic_advisory/window_flags.py +0 -41
  178. package/scripts/repository_checks/config/email_exemptions.py +0 -10
@@ -0,0 +1,104 @@
1
+ """The shared policy-document reader and entry validator."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ from pathlib import Path
8
+
9
+ import pytest
10
+ from repository_checks.policy_document import (
11
+ entry_path_and_digest,
12
+ read_policy_entries,
13
+ )
14
+
15
+ RELATIVE_PATH = "evidence/manifest.json"
16
+ DIGEST = hashlib.sha256(b"frozen bytes").hexdigest()
17
+ CONFIG_RELATIVE_PATH = "config/repository-policy.json"
18
+
19
+
20
+ def _write_config(root: Path, document: object) -> None:
21
+ config_path = root / CONFIG_RELATIVE_PATH
22
+ config_path.parent.mkdir(parents=True, exist_ok=True)
23
+ config_path.write_text(json.dumps(document), encoding="utf-8")
24
+
25
+
26
+ def _entry(**changes: object) -> dict[str, object]:
27
+ return {
28
+ "path": RELATIVE_PATH,
29
+ "sha256": DIGEST,
30
+ "reason": "Frozen evidence file pinned by its consumers",
31
+ **changes,
32
+ }
33
+
34
+
35
+ def test_reader_returns_the_recorded_entries_without_validating_them(
36
+ tmp_path: Path,
37
+ ) -> None:
38
+ """The reader validates the document, and the caller validates each entry."""
39
+ _write_config(tmp_path, {"version": 1, "path_exemptions": [{"anything": 1}]})
40
+ assert read_policy_entries(tmp_path, "path_exemptions") == [{"anything": 1}]
41
+
42
+
43
+ def test_reader_returns_no_entries_for_a_field_the_document_omits(
44
+ tmp_path: Path,
45
+ ) -> None:
46
+ _write_config(tmp_path, {"version": 1, "path_exemptions": [_entry()]})
47
+ assert read_policy_entries(tmp_path, "email_exemptions") == []
48
+
49
+
50
+ def test_reader_rejects_a_configuration_symlinked_outside_the_repository(
51
+ tmp_path: Path,
52
+ ) -> None:
53
+ outside_path = tmp_path / "outside-policy.json"
54
+ outside_path.write_text(json.dumps({"version": 1}), encoding="utf-8")
55
+ repository_root = tmp_path / "repo"
56
+ (repository_root / "config").mkdir(parents=True)
57
+ (repository_root / CONFIG_RELATIVE_PATH).symlink_to(outside_path)
58
+ with pytest.raises(ValueError) as rejection:
59
+ read_policy_entries(repository_root, "path_exemptions")
60
+ assert (
61
+ str(rejection.value)
62
+ == "Repository policy configuration must remain inside the repository"
63
+ )
64
+
65
+
66
+ def test_entry_validator_returns_the_path_and_digest_without_the_reason() -> None:
67
+ assert entry_path_and_digest(_entry(), "Path exemption") == (
68
+ RELATIVE_PATH,
69
+ DIGEST,
70
+ )
71
+
72
+
73
+ @pytest.mark.parametrize(
74
+ ("entry", "expected_message"),
75
+ [
76
+ ({}, "Path exemptions require path, sha256, and reason"),
77
+ (_entry(path=1), "Path exemption path must name one repository file"),
78
+ (
79
+ _entry(path="../manifest.json"),
80
+ "Path exemption path must be a literal relative file path",
81
+ ),
82
+ (
83
+ _entry(sha256="F" * 64),
84
+ "Path exemption sha256 must contain 64 lowercase hex digits",
85
+ ),
86
+ (
87
+ _entry(reason=" "),
88
+ "Path exemption reason must explain why the exception is owned",
89
+ ),
90
+ ],
91
+ )
92
+ def test_entry_validator_names_the_subject_in_every_rejection(
93
+ entry: object, expected_message: str
94
+ ) -> None:
95
+ """One validator serves both families, so each message names its subject."""
96
+ with pytest.raises(ValueError) as rejection:
97
+ entry_path_and_digest(entry, "Path exemption")
98
+ assert str(rejection.value) == expected_message
99
+
100
+
101
+ def test_entry_validator_uses_the_subject_it_is_given() -> None:
102
+ with pytest.raises(ValueError) as rejection:
103
+ entry_path_and_digest({}, "Email exemption")
104
+ assert str(rejection.value) == "Email exemptions require path, sha256, and reason"
@@ -0,0 +1,101 @@
1
+ """Function-length diagnostics report only functions the change is responsible for.
2
+
3
+ The code-rule adapter runs the enforcer with ``defer_scope_to_caller`` set, so
4
+ the enforcer hands back every over-long function in the document and the caller
5
+ owns the scope decision. The engine's own changed-line filter keys on a
6
+ diagnostic's start line, and a function-length message carries no ``Line N:``
7
+ prefix, so before this scope pass a pre-existing long function blocked any
8
+ change that touched its file.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from pathlib import Path, PurePosixPath
14
+
15
+ from policy_lint import adapters
16
+ from policy_lint.model import ContentOrigin, Diagnostic, Document
17
+ from policy_lint.selection import _changed_lines
18
+
19
+ FUNCTION_LENGTH_MESSAGE_FRAGMENT = "exceeds blocking threshold"
20
+ PRODUCTION_RELATIVE_PATH = "packages/claude-dev-env/scripts/sample_long_module.py"
21
+ OVER_THRESHOLD_BODY_LINE_COUNT = 70
22
+ UNDER_THRESHOLD_BODY_LINE_COUNT = 40
23
+ REPOSITORY_ROOT = Path(__file__).resolve().parents[4]
24
+
25
+
26
+ def _counting_function_source(function_name: str, body_line_count: int) -> str:
27
+ all_body_lines = [
28
+ f" running_total = running_total + {each_index}"
29
+ for each_index in range(body_line_count)
30
+ ]
31
+ signature_lines = [f"def {function_name}(running_total: int) -> int:"]
32
+ return (
33
+ "\n".join(signature_lines + all_body_lines + [" return running_total"])
34
+ + "\n"
35
+ )
36
+
37
+
38
+ def _messages_for_document(document: Document) -> tuple[str, ...]:
39
+ all_diagnostics: tuple[Diagnostic, ...] = adapters.code_rule_diagnostics(
40
+ document, REPOSITORY_ROOT
41
+ )
42
+ return tuple(
43
+ each_diagnostic.message
44
+ for each_diagnostic in all_diagnostics
45
+ if FUNCTION_LENGTH_MESSAGE_FRAGMENT in each_diagnostic.message
46
+ )
47
+
48
+
49
+ def _function_length_messages(
50
+ prior_text: str | None, current_text: str
51
+ ) -> tuple[str, ...]:
52
+ return _messages_for_document(
53
+ Document(
54
+ PurePosixPath(PRODUCTION_RELATIVE_PATH),
55
+ current_text,
56
+ prior_text,
57
+ _changed_lines(prior_text, current_text),
58
+ ContentOrigin.REVISION_DIFF,
59
+ )
60
+ )
61
+
62
+
63
+ def test_should_leave_an_untouched_long_function_alone() -> None:
64
+ prior_text = _counting_function_source("main", OVER_THRESHOLD_BODY_LINE_COUNT)
65
+ current_text = f"import os\nimport sys\n{prior_text}"
66
+ assert _function_length_messages(prior_text, current_text) == ()
67
+
68
+
69
+ def test_should_report_a_function_the_change_grew_past_the_threshold() -> None:
70
+ prior_text = _counting_function_source("main", UNDER_THRESHOLD_BODY_LINE_COUNT)
71
+ current_text = _counting_function_source("main", OVER_THRESHOLD_BODY_LINE_COUNT)
72
+ assert _function_length_messages(prior_text, current_text) != ()
73
+
74
+
75
+ def test_should_report_an_over_long_function_the_change_adds() -> None:
76
+ prior_text = _counting_function_source("existing", UNDER_THRESHOLD_BODY_LINE_COUNT)
77
+ added_text = _counting_function_source("added", OVER_THRESHOLD_BODY_LINE_COUNT)
78
+ assert _function_length_messages(prior_text, f"{prior_text}\n{added_text}") != ()
79
+
80
+
81
+ def test_should_report_every_over_long_function_in_a_wholly_new_document() -> None:
82
+ current_text = _counting_function_source("main", OVER_THRESHOLD_BODY_LINE_COUNT)
83
+ assert _function_length_messages(None, current_text) != ()
84
+
85
+
86
+ def test_should_leave_a_long_function_alone_when_the_change_only_deletes() -> None:
87
+ current_text = _counting_function_source("main", OVER_THRESHOLD_BODY_LINE_COUNT)
88
+ prior_text = f"REMOVED_CONSTANT = 1\n{current_text}"
89
+ assert _function_length_messages(prior_text, current_text) == ()
90
+
91
+
92
+ def test_should_report_a_long_function_when_the_selection_names_no_changed_lines() -> None:
93
+ current_text = _counting_function_source("main", OVER_THRESHOLD_BODY_LINE_COUNT)
94
+ document = Document(
95
+ PurePosixPath(PRODUCTION_RELATIVE_PATH),
96
+ current_text,
97
+ None,
98
+ None,
99
+ ContentOrigin.WORKTREE,
100
+ )
101
+ assert _messages_for_document(document) != ()
@@ -70,6 +70,37 @@ def test_approved_pairs_name_files_that_exist() -> None:
70
70
  assert (repository_root / each_test_path).is_file(), each_test_path
71
71
 
72
72
 
73
+ def test_every_approved_pair_actually_waives_its_production_file(tmp_path: Path) -> None:
74
+ """Each mapping entry must silence the rule when its production file changes.
75
+
76
+ File existence alone leaves an entry inert when its key carries a suffix the
77
+ rule skips, or when its value is not a name the rule reads as a test.
78
+ """
79
+ for (
80
+ production_path,
81
+ all_test_paths,
82
+ ) in APPROVED_TEST_PATHS_BY_PRODUCTION_PATH.items():
83
+ production_document = Document(
84
+ production_path,
85
+ _BODY_AFTER,
86
+ _BODY_BEFORE,
87
+ frozenset({1}),
88
+ ContentOrigin.REVISION_DIFF,
89
+ )
90
+ for each_test_path in all_test_paths:
91
+ test_document = Document(
92
+ each_test_path,
93
+ _BODY_AFTER,
94
+ _BODY_BEFORE,
95
+ frozenset({1}),
96
+ ContentOrigin.REVISION_DIFF,
97
+ )
98
+ document_set = DocumentSet(
99
+ (production_document, test_document), SelectionKind.BASE, tmp_path
100
+ )
101
+ assert adapters.test_pairing_diagnostics(document_set) == (), production_path
102
+
103
+
73
104
  def test_pairing_ignores_python_docstring_only_changes(tmp_path: Path) -> None:
74
105
  all_diagnostics = _pairing_diagnostics(
75
106
  tmp_path,
@@ -161,3 +192,347 @@ def test_pairing_rejects_an_unrelated_module_beside_a_changed_approved_suite(
161
192
  _body_change_at(_APPROVED_TEST_PATH),
162
193
  )
163
194
  assert all_paths == (_UNRELATED_PRODUCTION_PATH,)
195
+
196
+
197
+ _MECHANICAL_BEFORE = '''"""Before."""
198
+
199
+ import subprocess
200
+
201
+ TIMEOUT_SECONDS = 5
202
+
203
+
204
+ def work(command: list[str]) -> int:
205
+ """Run one command."""
206
+ if TIMEOUT_SECONDS > 0:
207
+ completed = subprocess.run(command, timeout=TIMEOUT_SECONDS, check=False)
208
+ return completed.returncode
209
+ return 1
210
+ '''
211
+ _KEYWORD_ADDED = '''"""Before."""
212
+
213
+ import subprocess
214
+
215
+ TIMEOUT_SECONDS = 5
216
+
217
+
218
+ def work(command: list[str]) -> int:
219
+ """Run one command."""
220
+ if TIMEOUT_SECONDS > 0:
221
+ completed = subprocess.run(
222
+ command, timeout=TIMEOUT_SECONDS, check=False, creationflags=0
223
+ )
224
+ return completed.returncode
225
+ return 1
226
+ '''
227
+ _IMPORT_AND_KEYWORD_ADDED = '''"""Before."""
228
+
229
+ import os
230
+ import subprocess
231
+
232
+ TIMEOUT_SECONDS = 5
233
+
234
+
235
+ def work(command: list[str]) -> int:
236
+ """Run one command."""
237
+ if TIMEOUT_SECONDS > 0:
238
+ completed = subprocess.run(
239
+ command, timeout=TIMEOUT_SECONDS, check=False, creationflags=os.O_RDONLY
240
+ )
241
+ return completed.returncode
242
+ return 1
243
+ '''
244
+ _DOCSTRING_AND_KEYWORD_CHANGED = '''"""After."""
245
+
246
+ import subprocess
247
+
248
+ TIMEOUT_SECONDS = 5
249
+
250
+
251
+ def work(command: list[str]) -> int:
252
+ """Run one command and report its exit status."""
253
+ if TIMEOUT_SECONDS > 0:
254
+ completed = subprocess.run(
255
+ command, timeout=TIMEOUT_SECONDS, check=False, creationflags=0
256
+ )
257
+ return completed.returncode
258
+ return 1
259
+ '''
260
+ _KEYWORD_ADDED_AND_LITERAL_CHANGED = '''"""Before."""
261
+
262
+ import subprocess
263
+
264
+ TIMEOUT_SECONDS = 6
265
+
266
+
267
+ def work(command: list[str]) -> int:
268
+ """Run one command."""
269
+ if TIMEOUT_SECONDS > 0:
270
+ completed = subprocess.run(
271
+ command, timeout=TIMEOUT_SECONDS, check=False, creationflags=0
272
+ )
273
+ return completed.returncode
274
+ return 1
275
+ '''
276
+ _KEYWORD_VALUE_CHANGED = '''"""Before."""
277
+
278
+ import subprocess
279
+
280
+ TIMEOUT_SECONDS = 5
281
+
282
+
283
+ def work(command: list[str]) -> int:
284
+ """Run one command."""
285
+ if TIMEOUT_SECONDS > 0:
286
+ completed = subprocess.run(command, timeout=TIMEOUT_SECONDS, check=True)
287
+ return completed.returncode
288
+ return 1
289
+ '''
290
+ _KEYWORD_VALUE_TYPE_CHANGED = '''"""Before."""
291
+
292
+ import subprocess
293
+
294
+ TIMEOUT_SECONDS = 5
295
+
296
+
297
+ def work(command: list[str]) -> int:
298
+ """Run one command."""
299
+ if TIMEOUT_SECONDS > 0:
300
+ completed = subprocess.run(command, timeout=TIMEOUT_SECONDS, check=0)
301
+ return completed.returncode
302
+ return 1
303
+ '''
304
+ _KEYWORD_REMOVED = '''"""Before."""
305
+
306
+ import subprocess
307
+
308
+ TIMEOUT_SECONDS = 5
309
+
310
+
311
+ def work(command: list[str]) -> int:
312
+ """Run one command."""
313
+ if TIMEOUT_SECONDS > 0:
314
+ completed = subprocess.run(command, timeout=TIMEOUT_SECONDS)
315
+ return completed.returncode
316
+ return 1
317
+ '''
318
+ _POSITIONAL_CHANGED = '''"""Before."""
319
+
320
+ import subprocess
321
+
322
+ TIMEOUT_SECONDS = 5
323
+
324
+
325
+ def work(command: list[str]) -> int:
326
+ """Run one command."""
327
+ if TIMEOUT_SECONDS > 0:
328
+ completed = subprocess.run(
329
+ command[:1], timeout=TIMEOUT_SECONDS, check=False, creationflags=0
330
+ )
331
+ return completed.returncode
332
+ return 1
333
+ '''
334
+ _STATEMENT_ADDED_IN_BODY = '''"""Before."""
335
+
336
+ import subprocess
337
+
338
+ TIMEOUT_SECONDS = 5
339
+
340
+
341
+ def work(command: list[str]) -> int:
342
+ """Run one command."""
343
+ if TIMEOUT_SECONDS > 0:
344
+ completed = subprocess.run(
345
+ command, timeout=TIMEOUT_SECONDS, check=False, creationflags=0
346
+ )
347
+ completed.check_returncode()
348
+ return completed.returncode
349
+ return 1
350
+ '''
351
+ _CONDITION_CHANGED = '''"""Before."""
352
+
353
+ import subprocess
354
+
355
+ TIMEOUT_SECONDS = 5
356
+
357
+
358
+ def work(command: list[str]) -> int:
359
+ """Run one command."""
360
+ if TIMEOUT_SECONDS > 1:
361
+ completed = subprocess.run(
362
+ command, timeout=TIMEOUT_SECONDS, check=False, creationflags=0
363
+ )
364
+ return completed.returncode
365
+ return 1
366
+ '''
367
+ _IMPORT_REMOVED = '''"""Before."""
368
+
369
+ TIMEOUT_SECONDS = 5
370
+
371
+
372
+ def work(command: list[str]) -> int:
373
+ """Run one command."""
374
+ if TIMEOUT_SECONDS > 0:
375
+ completed = subprocess.run(
376
+ command, timeout=TIMEOUT_SECONDS, check=False, creationflags=0
377
+ )
378
+ return completed.returncode
379
+ return 1
380
+ '''
381
+ _UNPARSEABLE = "def work(:\n return None\n"
382
+
383
+
384
+ def _mechanical_diagnostics(
385
+ temporary_path: Path, current_source: str, suffix: str = ".py"
386
+ ) -> tuple[Diagnostic, ...]:
387
+ return _pairing_diagnostics(
388
+ temporary_path,
389
+ _changed_document(current_source, _MECHANICAL_BEFORE, suffix),
390
+ )
391
+
392
+
393
+ def test_pairing_waives_one_added_keyword_argument(tmp_path: Path) -> None:
394
+ assert _mechanical_diagnostics(tmp_path, _KEYWORD_ADDED) == ()
395
+
396
+
397
+ def test_pairing_waives_an_added_import_beside_an_added_keyword(
398
+ tmp_path: Path,
399
+ ) -> None:
400
+ assert _mechanical_diagnostics(tmp_path, _IMPORT_AND_KEYWORD_ADDED) == ()
401
+
402
+
403
+ def test_pairing_waives_a_docstring_edit_beside_an_added_keyword(
404
+ tmp_path: Path,
405
+ ) -> None:
406
+ assert _mechanical_diagnostics(tmp_path, _DOCSTRING_AND_KEYWORD_CHANGED) == ()
407
+
408
+
409
+ def test_pairing_reports_an_added_keyword_beside_a_changed_literal(
410
+ tmp_path: Path,
411
+ ) -> None:
412
+ assert (
413
+ len(_mechanical_diagnostics(tmp_path, _KEYWORD_ADDED_AND_LITERAL_CHANGED)) == 1
414
+ )
415
+
416
+
417
+ def test_pairing_reports_a_changed_keyword_value(tmp_path: Path) -> None:
418
+ assert len(_mechanical_diagnostics(tmp_path, _KEYWORD_VALUE_CHANGED)) == 1
419
+
420
+
421
+ def test_pairing_reports_a_keyword_value_that_changed_literal_type(
422
+ tmp_path: Path,
423
+ ) -> None:
424
+ assert len(_mechanical_diagnostics(tmp_path, _KEYWORD_VALUE_TYPE_CHANGED)) == 1
425
+
426
+
427
+ def test_pairing_reports_a_removed_keyword(tmp_path: Path) -> None:
428
+ assert len(_mechanical_diagnostics(tmp_path, _KEYWORD_REMOVED)) == 1
429
+
430
+
431
+ def test_pairing_reports_a_changed_positional_argument(tmp_path: Path) -> None:
432
+ assert len(_mechanical_diagnostics(tmp_path, _POSITIONAL_CHANGED)) == 1
433
+
434
+
435
+ def test_pairing_reports_a_statement_added_inside_a_function(tmp_path: Path) -> None:
436
+ assert len(_mechanical_diagnostics(tmp_path, _STATEMENT_ADDED_IN_BODY)) == 1
437
+
438
+
439
+ def test_pairing_reports_a_changed_if_condition(tmp_path: Path) -> None:
440
+ assert len(_mechanical_diagnostics(tmp_path, _CONDITION_CHANGED)) == 1
441
+
442
+
443
+ def test_pairing_reports_a_removed_import(tmp_path: Path) -> None:
444
+ assert len(_mechanical_diagnostics(tmp_path, _IMPORT_REMOVED)) == 1
445
+
446
+
447
+ def test_pairing_reports_a_non_python_document_with_the_same_shape(
448
+ tmp_path: Path,
449
+ ) -> None:
450
+ assert len(_mechanical_diagnostics(tmp_path, _KEYWORD_ADDED, ".ts")) == 1
451
+
452
+
453
+ def test_pairing_reports_unparseable_current_source(tmp_path: Path) -> None:
454
+ assert len(_mechanical_diagnostics(tmp_path, _UNPARSEABLE)) == 1
455
+
456
+
457
+ def test_pairing_reports_unparseable_prior_source(tmp_path: Path) -> None:
458
+ all_diagnostics = _pairing_diagnostics(
459
+ tmp_path, _changed_document(_KEYWORD_ADDED, _UNPARSEABLE)
460
+ )
461
+ assert len(all_diagnostics) == 1
462
+
463
+
464
+ def test_pairing_reports_an_added_document_without_prior_text(tmp_path: Path) -> None:
465
+ added_document = Document(
466
+ PurePosixPath("src/feature.py"),
467
+ _KEYWORD_ADDED,
468
+ None,
469
+ frozenset({1}),
470
+ ContentOrigin.REVISION_DIFF,
471
+ )
472
+ assert len(_pairing_diagnostics(tmp_path, added_document)) == 1
473
+
474
+
475
+ _MAPPING_UNPACKING_ADDED = '''"""Before."""
476
+
477
+ import subprocess
478
+
479
+ TIMEOUT_SECONDS = 5
480
+
481
+
482
+ def work(command: list[str]) -> int:
483
+ """Run one command."""
484
+ if TIMEOUT_SECONDS > 0:
485
+ completed = subprocess.run(
486
+ command, timeout=TIMEOUT_SECONDS, check=False, **{"creationflags": 0}
487
+ )
488
+ return completed.returncode
489
+ return 1
490
+ '''
491
+
492
+
493
+ def test_pairing_reports_added_mapping_unpacking_on_a_call(tmp_path: Path) -> None:
494
+ assert len(_mechanical_diagnostics(tmp_path, _MAPPING_UNPACKING_ADDED)) == 1
495
+ _INSTALLER_PRODUCTION_PATHS = (
496
+ PurePosixPath("packages/claude-dev-env/bin/install-constants.mjs"),
497
+ PurePosixPath("packages/claude-dev-env/bin/install.mjs"),
498
+ )
499
+ _INSTALLER_SUITE_PATH = PurePosixPath(
500
+ "packages/claude-dev-env/bin/install.cursor-rules.test.mjs"
501
+ )
502
+
503
+
504
+ def test_pairing_accepts_installer_modules_with_the_cursor_rules_suite(
505
+ tmp_path: Path,
506
+ ) -> None:
507
+ all_paths = _diagnostic_paths(
508
+ tmp_path,
509
+ *(_body_change_at(each_path) for each_path in _INSTALLER_PRODUCTION_PATHS),
510
+ _body_change_at(_INSTALLER_SUITE_PATH),
511
+ )
512
+ assert all_paths == ()
513
+
514
+
515
+ _COMMENT_RULES_PRODUCTION_PATH = PurePosixPath(
516
+ "packages/claude-dev-env/hooks/blocking/code_rules_comments.py"
517
+ )
518
+ _COMMENT_RULES_SUITE_PATH = PurePosixPath(
519
+ "packages/claude-dev-env/hooks/blocking/test_code_rules_enforcer_comment_string_awareness.py"
520
+ )
521
+
522
+
523
+ def test_pairing_accepts_comment_rules_module_with_one_approved_suite(
524
+ tmp_path: Path,
525
+ ) -> None:
526
+ all_paths = _diagnostic_paths(
527
+ tmp_path,
528
+ _body_change_at(_COMMENT_RULES_PRODUCTION_PATH),
529
+ _body_change_at(_COMMENT_RULES_SUITE_PATH),
530
+ )
531
+ assert all_paths == ()
532
+
533
+
534
+ def test_pairing_rejects_comment_rules_module_without_any_approved_suite(
535
+ tmp_path: Path,
536
+ ) -> None:
537
+ all_paths = _diagnostic_paths(tmp_path, _body_change_at(_COMMENT_RULES_PRODUCTION_PATH))
538
+ assert all_paths == (_COMMENT_RULES_PRODUCTION_PATH,)