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
@@ -22,6 +22,27 @@ from policy_lint.model import (
22
22
 
23
23
  _FUNCTION_SOURCE = "def work() -> None:\n pass\n"
24
24
  _CHANGED_RULE_SETS = frozenset({"changed"})
25
+ _UNDERCOUNTED_SUBPROCESS_BUDGET_TEXT = (
26
+ "import subprocess\n"
27
+ "PYTHON_FORMAT_TIMEOUT_SECONDS = 12\n"
28
+ "GIT_CHECK_TIMEOUT_SECONDS = 5\n"
29
+ "def worst_case_python_format_seconds() -> int:\n"
30
+ " return PYTHON_FORMAT_TIMEOUT_SECONDS + PYTHON_FORMAT_TIMEOUT_SECONDS\n"
31
+ "def is_untracked_in_git(file_path: str) -> bool:\n"
32
+ " git_check = subprocess.run(['git', 'ls-files', file_path], "
33
+ "timeout=GIT_CHECK_TIMEOUT_SECONDS)\n"
34
+ " return git_check.returncode != 0\n"
35
+ "def run_format(file_path: str) -> None:\n"
36
+ " subprocess.run(['ruff', 'format', file_path], "
37
+ "timeout=PYTHON_FORMAT_TIMEOUT_SECONDS)\n"
38
+ "def main(file_path: str) -> None:\n"
39
+ " if is_untracked_in_git(file_path):\n"
40
+ " return\n"
41
+ " run_format(file_path)\n"
42
+ )
43
+ _GENERATED_CHANGELOG_TEXT = (
44
+ "## 7.5.0\n\n* **rules:** correct hook claims for gates that " + "no" + " longer run\n"
45
+ )
25
46
 
26
47
 
27
48
  def _rule_named(rule_id: str) -> DocumentRule:
@@ -266,6 +287,51 @@ def test_hook_format_accepts_claude_settings() -> None:
266
287
  assert configuration_rule.accepts(settings_document) is False
267
288
 
268
289
 
290
+ def test_prose_rules_skip_a_generated_changelog() -> None:
291
+ changelog_document = Document.from_text(
292
+ "packages/claude-dev-env/CHANGELOG.md", _GENERATED_CHANGELOG_TEXT
293
+ )
294
+ handwritten_document = Document.from_text("docs/notes.md", _GENERATED_CHANGELOG_TEXT)
295
+ stored_prompt_changelog = Document.from_text("rules/CHANGELOG.md", _GENERATED_CHANGELOG_TEXT)
296
+ stored_prompt_document = Document.from_text("rules/notes.md", _GENERATED_CHANGELOG_TEXT)
297
+ state_rule = _rule_named("state-description")
298
+ plain_language_rule = _rule_named("plain-language")
299
+ assert state_rule.accepts(changelog_document) is False
300
+ assert state_rule.accepts(handwritten_document) is True
301
+ assert plain_language_rule.accepts(stored_prompt_changelog) is False
302
+ assert plain_language_rule.accepts(stored_prompt_document) is True
303
+
304
+
305
+ def test_lint_reports_the_changelog_phrase_only_in_the_handwritten_document(
306
+ tmp_path: Path,
307
+ ) -> None:
308
+ _ensure_git_repository(tmp_path)
309
+ document_set = DocumentSet(
310
+ (
311
+ Document.from_text("packages/claude-dev-env/CHANGELOG.md", _GENERATED_CHANGELOG_TEXT),
312
+ Document.from_text("docs/notes.md", _GENERATED_CHANGELOG_TEXT),
313
+ ),
314
+ SelectionKind.TEXT,
315
+ tmp_path,
316
+ )
317
+ lint_report = lint(
318
+ LintRequest(tmp_path, document_set), all_registry=(_rule_named("state-description"),)
319
+ )
320
+ all_reported_paths = {
321
+ each_diagnostic.location.path.as_posix()
322
+ for each_diagnostic in lint_report.diagnostics
323
+ if each_diagnostic.location is not None
324
+ }
325
+ assert all_reported_paths == {"docs/notes.md"}
326
+
327
+
328
+ def test_non_prose_markdown_rules_still_run_on_a_generated_changelog() -> None:
329
+ changelog_document = Document.from_text(
330
+ "packages/claude-dev-env/CHANGELOG.md", _GENERATED_CHANGELOG_TEXT
331
+ )
332
+ assert _rule_named("docstring-gate-count").accepts(changelog_document) is True
333
+
334
+
269
335
  def test_workflow_substitution_accepts_workflow_js_suffix() -> None:
270
336
  workflow_document = Document.from_text("packages/foo/bar.workflow.js", "loop")
271
337
  directory_document = Document.from_text("docs/workflow/readme.md", "loop")
@@ -581,3 +647,53 @@ def test_lint_keeps_skipped_rules_out_of_executed_rules(tmp_path: Path) -> None:
581
647
  all_rules_report = lint(request, all_registry=(skipped_rule,))
582
648
  assert all_rules_report.executed_rules == ()
583
649
  assert all_rules_report.skipped_rules == ("skipped",)
650
+
651
+
652
+ def test_subprocess_budget_skips_a_test_module_and_keeps_production() -> None:
653
+ subprocess_budget_rule = _rule_named("subprocess-budget")
654
+ prefixed_test_document = Document.from_text(
655
+ "packages/claude-dev-env/hooks/workflow/test_auto_formatter.py",
656
+ _UNDERCOUNTED_SUBPROCESS_BUDGET_TEXT,
657
+ )
658
+ repository_root_test_document = Document.from_text(
659
+ "tests/timing.py", _UNDERCOUNTED_SUBPROCESS_BUDGET_TEXT
660
+ )
661
+ production_document = Document.from_text(
662
+ "src/timing.py", _UNDERCOUNTED_SUBPROCESS_BUDGET_TEXT
663
+ )
664
+ assert subprocess_budget_rule.accepts(prefixed_test_document) is False
665
+ assert subprocess_budget_rule.accepts(repository_root_test_document) is False
666
+ assert subprocess_budget_rule.accepts(production_document) is True
667
+
668
+
669
+ def _subprocess_budget_rule_identifiers(
670
+ repository_root: Path, document_path: str
671
+ ) -> tuple[str, ...]:
672
+ document_set = DocumentSet(
673
+ (Document.from_text(document_path, _UNDERCOUNTED_SUBPROCESS_BUDGET_TEXT),),
674
+ SelectionKind.TEXT,
675
+ repository_root,
676
+ )
677
+ lint_report = lint(
678
+ LintRequest(repository_root, document_set),
679
+ all_registry=(_rule_named("subprocess-budget"),),
680
+ )
681
+ return tuple(
682
+ each_diagnostic.rule_id for each_diagnostic in lint_report.diagnostics
683
+ )
684
+
685
+
686
+ def test_lint_reports_an_undercounted_budget_only_in_the_production_module(
687
+ tmp_path: Path,
688
+ ) -> None:
689
+ _ensure_git_repository(tmp_path)
690
+ assert _subprocess_budget_rule_identifiers(tmp_path, "src/timing.py") == (
691
+ "subprocess-budget",
692
+ )
693
+ assert (
694
+ _subprocess_budget_rule_identifiers(
695
+ tmp_path, "packages/claude-dev-env/hooks/workflow/test_auto_formatter.py"
696
+ )
697
+ == ()
698
+ )
699
+ assert _subprocess_budget_rule_identifiers(tmp_path, "tests/timing.py") == ()
@@ -0,0 +1,197 @@
1
+ """Tests for the retired-hook-prose rule over rules Markdown."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path, PurePosixPath
6
+
7
+ from policy_lint import adapter_retired_hook_prose, registry
8
+ from policy_lint.model import ContentOrigin, Document, DocumentRule
9
+
10
+ _RULE_ID = "retired-hook-prose"
11
+ _RULES_DOCUMENT_PATH = "packages/claude-dev-env/rules/example.md"
12
+ _INSTALLER_SOURCE = """
13
+ export const RETIRED_HOOK_REGISTRATION_RELATIVE_PATHS = new Set([
14
+ 'blocking/destructive_command_blocker.py',
15
+ ]);
16
+ """
17
+ _HOOKS_CONFIGURATION = """
18
+ {
19
+ "hooks": {
20
+ "PostToolUse": [
21
+ {"hooks": [{"command": "python hooks/observability/session_file_edit_tracker.py"}]}
22
+ ]
23
+ }
24
+ }
25
+ """
26
+ _DISPATCHER_ROSTER_SOURCE = """
27
+ ALL_HOSTED_HOOK_ENTRIES = (
28
+ HostedHookEntry("advisory/refactor_guard.py", is_blocking=False),
29
+ )
30
+ """
31
+
32
+
33
+ def _build_package(repository_root: Path) -> None:
34
+ package_root = repository_root / "packages" / "claude-dev-env"
35
+ hooks_root = package_root / "hooks"
36
+ (hooks_root / "blocking").mkdir(parents=True)
37
+ (hooks_root / "advisory").mkdir(parents=True)
38
+ (hooks_root / "observability").mkdir(parents=True)
39
+ (hooks_root / "hooks_constants").mkdir(parents=True)
40
+ (package_root / "bin").mkdir(parents=True)
41
+ (package_root / "rules").mkdir(parents=True)
42
+ (hooks_root / "blocking" / "windows_rmtree_blocker.py").write_text("", "utf-8")
43
+ (hooks_root / "advisory" / "refactor_guard.py").write_text("", "utf-8")
44
+ (hooks_root / "observability" / "session_file_edit_tracker.py").write_text(
45
+ "", "utf-8"
46
+ )
47
+ (hooks_root / "blocking" / "content_hash_store.py").write_text("", "utf-8")
48
+ (hooks_root / "hooks.json").write_text(_HOOKS_CONFIGURATION, "utf-8")
49
+ (
50
+ hooks_root / "hooks_constants" / "pre_tool_use_dispatcher_constants.py"
51
+ ).write_text(_DISPATCHER_ROSTER_SOURCE, "utf-8")
52
+ (package_root / "bin" / "install.mjs").write_text(_INSTALLER_SOURCE, "utf-8")
53
+
54
+
55
+ def _diagnostics_for(repository_root: Path, prose: str) -> tuple[str, ...]:
56
+ document = Document(
57
+ PurePosixPath(_RULES_DOCUMENT_PATH),
58
+ prose,
59
+ None,
60
+ None,
61
+ ContentOrigin.WORKTREE,
62
+ )
63
+ all_diagnostics = adapter_retired_hook_prose.retired_hook_prose_diagnostics(
64
+ document, repository_root
65
+ )
66
+ return tuple(each_diagnostic.message for each_diagnostic in all_diagnostics)
67
+
68
+
69
+ def test_a_live_claim_for_a_surviving_unregistered_hook_is_reported(
70
+ tmp_path: Path,
71
+ ) -> None:
72
+ _build_package(tmp_path)
73
+ all_messages = _diagnostics_for(
74
+ tmp_path,
75
+ "The `windows_rmtree_blocker.py` PreToolUse hook blocks the unsafe pattern.\n",
76
+ )
77
+ assert len(all_messages) == 1
78
+ assert "windows_rmtree_blocker" in all_messages[0]
79
+
80
+
81
+ def test_a_live_claim_for_a_roster_hook_absent_from_disk_is_reported(
82
+ tmp_path: Path,
83
+ ) -> None:
84
+ _build_package(tmp_path)
85
+ all_messages = _diagnostics_for(
86
+ tmp_path,
87
+ "The `destructive_command_blocker` hook watches every Bash-tool command.\n",
88
+ )
89
+ assert len(all_messages) == 1
90
+ assert "destructive_command_blocker" in all_messages[0]
91
+
92
+
93
+ def test_a_registered_hook_described_in_the_present_tense_passes(
94
+ tmp_path: Path,
95
+ ) -> None:
96
+ _build_package(tmp_path)
97
+ all_messages = _diagnostics_for(
98
+ tmp_path,
99
+ "`session_file_edit_tracker` records each Write path, and `refactor_guard` reports a risk.\n",
100
+ )
101
+ assert all_messages == ()
102
+
103
+
104
+ def test_a_past_tense_retirement_sentence_passes(tmp_path: Path) -> None:
105
+ _build_package(tmp_path)
106
+ all_messages = _diagnostics_for(
107
+ tmp_path,
108
+ "Commit `0f21faf8e` retired the blocking hooks. "
109
+ "`destructive_command_blocker` was one of them.\n",
110
+ )
111
+ assert all_messages == ()
112
+
113
+
114
+ def test_a_sentence_crediting_the_staged_policy_lint_passes(tmp_path: Path) -> None:
115
+ _build_package(tmp_path)
116
+ all_messages = _diagnostics_for(
117
+ tmp_path,
118
+ "`windows_rmtree_blocker.py` reports the unsafe pattern, "
119
+ "and the staged policy lint runs it over each changed file.\n",
120
+ )
121
+ assert all_messages == ()
122
+
123
+
124
+ def test_a_hook_directory_support_module_passes(tmp_path: Path) -> None:
125
+ _build_package(tmp_path)
126
+ all_messages = _diagnostics_for(
127
+ tmp_path,
128
+ "`content_hash_store.py` holds the full contract in its module docstring.\n",
129
+ )
130
+ assert all_messages == ()
131
+
132
+
133
+ def test_a_verb_before_the_hook_name_passes(tmp_path: Path) -> None:
134
+ _build_package(tmp_path)
135
+ all_messages = _diagnostics_for(
136
+ tmp_path,
137
+ "The staged lint runs `windows_rmtree_blocker.py` over each changed file.\n",
138
+ )
139
+ assert all_messages == ()
140
+
141
+
142
+ def test_a_code_span_between_the_name_and_its_verb_does_not_hide_the_claim(
143
+ tmp_path: Path,
144
+ ) -> None:
145
+ _build_package(tmp_path)
146
+ all_messages = _diagnostics_for(
147
+ tmp_path,
148
+ "`destructive_command_blocker` (PreToolUse on Bash `git commit`) "
149
+ "denies a commit that drops session edits.",
150
+ )
151
+ assert len(all_messages) == 1
152
+ assert "destructive_command_blocker" in all_messages[0]
153
+
154
+
155
+ def test_a_second_hook_name_bounds_the_first_claim(tmp_path: Path) -> None:
156
+ _build_package(tmp_path)
157
+ all_messages = _diagnostics_for(
158
+ tmp_path,
159
+ "`destructive_command_blocker` was retired, and `refactor_guard` reports a risk.",
160
+ )
161
+ assert all_messages == ()
162
+
163
+
164
+ def test_the_rule_is_registered_for_changed_and_repository_selections() -> None:
165
+ all_matching_rules = tuple(
166
+ each_rule
167
+ for each_rule in registry.default_registry()
168
+ if each_rule.rule_id == _RULE_ID
169
+ )
170
+ assert len(all_matching_rules) == 1
171
+ registered_rule = all_matching_rules[0]
172
+ assert isinstance(registered_rule, DocumentRule)
173
+ assert registered_rule.rule_sets == frozenset({"changed", "repository"})
174
+
175
+
176
+ def test_the_committed_rules_directory_names_no_gate_that_stopped_running() -> None:
177
+ repository_root = Path(__file__).resolve().parents[4]
178
+ rules_root = repository_root / "packages" / "claude-dev-env" / "rules"
179
+ all_messages: list[str] = []
180
+ for each_path in sorted(rules_root.glob("*.md")):
181
+ relative_path = each_path.relative_to(repository_root).as_posix()
182
+ document = Document(
183
+ PurePosixPath(relative_path),
184
+ each_path.read_text(encoding="utf-8"),
185
+ None,
186
+ None,
187
+ ContentOrigin.WORKTREE,
188
+ )
189
+ all_messages.extend(
190
+ f"{relative_path}:{each_diagnostic.location.start_line}: {each_diagnostic.message}"
191
+ for each_diagnostic in (
192
+ adapter_retired_hook_prose.retired_hook_prose_diagnostics(
193
+ document, repository_root
194
+ )
195
+ )
196
+ )
197
+ assert all_messages == []
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import os
6
6
  import subprocess
7
+ import sys
7
8
  from pathlib import Path, PurePosixPath
8
9
 
9
10
  import pytest
@@ -19,6 +20,13 @@ from policy_lint.model import (
19
20
  )
20
21
  from policy_lint.selection import SelectionRunFatal, select_documents
21
22
 
23
+ WINDOWS_NO_WINDOW_FLAG = 0x08000000
24
+ EXPECTED_HIDDEN_WINDOW_FLAGS = WINDOWS_NO_WINDOW_FLAG if sys.platform == "win32" else 0
25
+
26
+ NON_UTF8_PNG_BYTES = bytes(
27
+ (0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0xFF, 0xFE, 0xFF)
28
+ )
29
+
22
30
 
23
31
  def _git_environment() -> dict[str, str]:
24
32
  return {
@@ -66,6 +74,22 @@ def _commit_text(repository_root: Path, relative_path: str, text: str) -> None:
66
74
  _run_git(repository_root, "commit", "--quiet", "-m", "selection fixture")
67
75
 
68
76
 
77
+ def _write_bytes(repository_root: Path, relative_path: str, raw_bytes: bytes) -> None:
78
+ file_path = repository_root / relative_path
79
+ file_path.parent.mkdir(parents=True, exist_ok=True)
80
+ file_path.write_bytes(raw_bytes)
81
+
82
+
83
+ def _commit_bytes(repository_root: Path, relative_path: str, raw_bytes: bytes) -> None:
84
+ _write_bytes(repository_root, relative_path, raw_bytes)
85
+ _run_git(repository_root, "add", "--", relative_path)
86
+ _run_git(repository_root, "commit", "--quiet", "-m", "selection binary fixture")
87
+
88
+
89
+ def _all_selected_paths(document_set: DocumentSet) -> set[str]:
90
+ return {each_document.path.as_posix() for each_document in document_set.documents}
91
+
92
+
69
93
  def _document_for(document_set: DocumentSet, relative_path: str) -> Document:
70
94
  for each_document in document_set.documents:
71
95
  if each_document.path.as_posix() == relative_path:
@@ -394,3 +418,120 @@ def test_tracked_worktree_document_preserves_git_path_spelling(
394
418
  tmp_path, "MiXeD.py"
395
419
  )
396
420
  assert selected_document.path == PurePosixPath("MiXeD.py")
421
+
422
+
423
+ def test_base_added_binary_file_should_be_left_out_of_the_selection(
424
+ tmp_path: Path,
425
+ ) -> None:
426
+ repository_root = _initialize_repository(tmp_path)
427
+ _commit_text(repository_root, "file.py", "base-text\n")
428
+ _run_git(repository_root, "checkout", "--quiet", "-b", "feature")
429
+ _commit_text(repository_root, "file.py", "feature-text\n")
430
+ _commit_bytes(repository_root, "approved_design_overlay.png", NON_UTF8_PNG_BYTES)
431
+
432
+ document_set = select_documents(LintRequest.base(repository_root, "main"))
433
+
434
+ assert _all_selected_paths(document_set) == {"file.py"}
435
+ assert _document_for(document_set, "file.py").text == "feature-text\n"
436
+ assert document_set.deleted_paths == ()
437
+
438
+
439
+ def test_base_modified_binary_file_should_be_left_out_of_the_selection(
440
+ tmp_path: Path,
441
+ ) -> None:
442
+ repository_root = _initialize_repository(tmp_path)
443
+ _commit_bytes(repository_root, "approved_design_overlay.png", NON_UTF8_PNG_BYTES)
444
+ _run_git(repository_root, "checkout", "--quiet", "-b", "feature")
445
+ _commit_bytes(
446
+ repository_root,
447
+ "approved_design_overlay.png",
448
+ NON_UTF8_PNG_BYTES + bytes((0xFF, 0x00, 0xFE)),
449
+ )
450
+ _commit_text(repository_root, "file.py", "feature-text\n")
451
+
452
+ document_set = select_documents(LintRequest.base(repository_root, "main"))
453
+
454
+ assert _all_selected_paths(document_set) == {"file.py"}
455
+ assert _document_for(document_set, "file.py").text == "feature-text\n"
456
+ assert document_set.deleted_paths == ()
457
+
458
+
459
+ def test_staged_binary_file_should_be_left_out_of_the_selection(tmp_path: Path) -> None:
460
+ repository_root = _initialize_repository(tmp_path)
461
+ _commit_text(repository_root, "file.py", "committed\n")
462
+ _write_bytes(repository_root, "approved_design_overlay.png", NON_UTF8_PNG_BYTES)
463
+ _write_text(repository_root, "file.py", "staged\n")
464
+ _run_git(repository_root, "add", "--all", "--", ".")
465
+
466
+ document_set = select_documents(LintRequest.staged(repository_root))
467
+
468
+ assert _all_selected_paths(document_set) == {"file.py"}
469
+ assert _document_for(document_set, "file.py").text == "staged\n"
470
+ assert document_set.deleted_paths == ()
471
+
472
+
473
+ def test_repository_source_should_leave_binary_tracked_files_out(
474
+ tmp_path: Path,
475
+ ) -> None:
476
+ repository_root = _initialize_repository(tmp_path)
477
+ _commit_text(repository_root, "tracked.py", "tracked\n")
478
+ _commit_bytes(repository_root, "approved_design_overlay.png", NON_UTF8_PNG_BYTES)
479
+
480
+ document_set = select_documents(LintRequest.repository(repository_root))
481
+
482
+ assert _all_selected_paths(document_set) == {"tracked.py"}
483
+ assert _document_for(document_set, "tracked.py").text == "tracked\n"
484
+
485
+
486
+ def test_files_source_should_leave_a_named_binary_path_out(tmp_path: Path) -> None:
487
+ repository_root = _initialize_repository(tmp_path)
488
+ _commit_text(repository_root, "file.py", "committed\n")
489
+ _write_bytes(repository_root, "approved_design_overlay.png", NON_UTF8_PNG_BYTES)
490
+
491
+ document_set = select_documents(
492
+ LintRequest.files(
493
+ repository_root,
494
+ [Path("file.py"), Path("approved_design_overlay.png")],
495
+ )
496
+ )
497
+
498
+ assert _all_selected_paths(document_set) == {"file.py"}
499
+ assert _document_for(document_set, "file.py").text == "committed\n"
500
+
501
+
502
+ def test_files_missing_path_should_still_raise(tmp_path: Path) -> None:
503
+ repository_root = _initialize_repository(tmp_path)
504
+ _commit_text(repository_root, "file.py", "committed\n")
505
+
506
+ with pytest.raises(SelectionRunFatal, match="does not exist"):
507
+ select_documents(LintRequest.files(repository_root, [Path("missing.py")]))
508
+
509
+
510
+ def _record_git_creation_flags(monkeypatch: pytest.MonkeyPatch) -> dict[str, object]:
511
+ all_recorded_keyword_arguments: dict[str, object] = {}
512
+
513
+ def _record(*args: object, **kwargs: object) -> subprocess.CompletedProcess[bytes]:
514
+ del args
515
+ all_recorded_keyword_arguments.update(kwargs)
516
+ return subprocess.CompletedProcess([], 0, stdout=b"abc123\n", stderr=b"")
517
+
518
+ monkeypatch.setattr(selection_git.subprocess, "run", _record)
519
+ return all_recorded_keyword_arguments
520
+
521
+
522
+ def test_git_bytes_for_should_start_without_a_console_window(
523
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
524
+ ) -> None:
525
+ all_recorded_keyword_arguments = _record_git_creation_flags(monkeypatch)
526
+
527
+ assert selection_git.git_bytes_for(tmp_path, ("status",)) == b"abc123\n"
528
+ assert all_recorded_keyword_arguments["creationflags"] == EXPECTED_HIDDEN_WINDOW_FLAGS
529
+
530
+
531
+ def test_head_revision_should_start_without_a_console_window(
532
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
533
+ ) -> None:
534
+ all_recorded_keyword_arguments = _record_git_creation_flags(monkeypatch)
535
+
536
+ assert selection_git.head_revision(tmp_path) == "abc123"
537
+ assert all_recorded_keyword_arguments["creationflags"] == EXPECTED_HIDDEN_WINDOW_FLAGS
@@ -0,0 +1,118 @@
1
+ """Prove every deployed subprocess-window shim reaches the packaged helpers.
2
+
3
+ Each shim sits beside the scripts of one deployed directory. A deployed
4
+ profile carries no installed Python distribution, and the repository runs its
5
+ scripts under ``python -S -E``, which hides installed packages from the child.
6
+ So each check below drives a real ``-S -E`` child, imports the shim by the name
7
+ its callers use, and reads back where the helper came from.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import subprocess
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ import pytest
18
+
19
+ _PACKAGE_ROOT = Path(__file__).resolve().parents[2]
20
+ _SHIM_MODULE_NAME = "subprocess_window_access"
21
+ _SHIM_FILE_NAME = f"{_SHIM_MODULE_NAME}.py"
22
+ _PACKAGED_HELPER_PATH = (
23
+ _PACKAGE_ROOT / "hooks" / "hooks_constants" / "subprocess_window.py"
24
+ )
25
+ _ALL_EXPORTED_HELPER_NAMES = (
26
+ "HiddenWindowStartupInfo",
27
+ "detached_hidden_window_creation_flags",
28
+ "hidden_window_creation_flags",
29
+ "inherited_stream_startup_info",
30
+ )
31
+ _ALL_SHIM_DIRECTORY_PATHS = (
32
+ "scripts",
33
+ "_shared/pr-loop/scripts",
34
+ "_shared/process-tree/scripts",
35
+ "hooks/git-hooks",
36
+ "hooks/validators",
37
+ ".agents/skills/fresh-branch/scripts",
38
+ ".agents/skills/syncing-submodules/scripts",
39
+ ".agents/skills/test-runner/scripts",
40
+ )
41
+
42
+ _IMPORT_PROBE_SOURCE = "\n".join(
43
+ (
44
+ "import json",
45
+ "import sys",
46
+ "shim_directory = sys.argv[1]",
47
+ "sys.path.insert(0, shim_directory)",
48
+ "head_before_import = sys.path[0]",
49
+ f"import {_SHIM_MODULE_NAME} as shim",
50
+ "print(json.dumps({",
51
+ " 'helper_path': sys.modules[",
52
+ " shim.hidden_window_creation_flags.__module__",
53
+ " ].__file__,",
54
+ " 'head_after_import': sys.path[0],",
55
+ " 'head_before_import': head_before_import,",
56
+ " 'exported_names': sorted(shim.__all__),",
57
+ "}))",
58
+ )
59
+ )
60
+
61
+
62
+ def _import_probe_results(shim_directory: Path) -> dict[str, object]:
63
+ """Import one shim in a ``-S -E`` child and read back what it resolved."""
64
+ completed_process = subprocess.run(
65
+ [
66
+ sys.executable,
67
+ "-S",
68
+ "-E",
69
+ "-c",
70
+ _IMPORT_PROBE_SOURCE,
71
+ str(shim_directory),
72
+ ],
73
+ cwd=str(_PACKAGE_ROOT),
74
+ capture_output=True,
75
+ text=True,
76
+ check=False,
77
+ )
78
+ assert completed_process.returncode == 0, completed_process.stderr
79
+ return json.loads(completed_process.stdout)
80
+
81
+
82
+ @pytest.mark.parametrize("shim_directory_path", _ALL_SHIM_DIRECTORY_PATHS)
83
+ def test_every_deployed_directory_carries_a_shim(shim_directory_path: str) -> None:
84
+ """Each directory whose scripts import the shim also ships one."""
85
+ assert (_PACKAGE_ROOT / shim_directory_path / _SHIM_FILE_NAME).is_file()
86
+
87
+
88
+ @pytest.mark.parametrize("shim_directory_path", _ALL_SHIM_DIRECTORY_PATHS)
89
+ def test_shim_resolves_the_packaged_helper_without_an_installed_package(
90
+ shim_directory_path: str,
91
+ ) -> None:
92
+ """A ``-S -E`` child reaches the packaged helper through the shim alone."""
93
+ all_probe_results = _import_probe_results(_PACKAGE_ROOT / shim_directory_path)
94
+
95
+ assert Path(str(all_probe_results["helper_path"])) == _PACKAGED_HELPER_PATH
96
+
97
+
98
+ @pytest.mark.parametrize("shim_directory_path", _ALL_SHIM_DIRECTORY_PATHS)
99
+ def test_shim_leaves_the_head_of_the_search_path_alone(
100
+ shim_directory_path: str,
101
+ ) -> None:
102
+ """A caller that prepended its own roots keeps them ahead of the hooks tree."""
103
+ all_probe_results = _import_probe_results(_PACKAGE_ROOT / shim_directory_path)
104
+
105
+ assert (
106
+ all_probe_results["head_after_import"]
107
+ == (all_probe_results["head_before_import"])
108
+ )
109
+
110
+
111
+ @pytest.mark.parametrize("shim_directory_path", _ALL_SHIM_DIRECTORY_PATHS)
112
+ def test_shim_re_exports_every_hidden_window_helper(
113
+ shim_directory_path: str,
114
+ ) -> None:
115
+ """Every caller finds the name it needs on the shim it imports."""
116
+ all_probe_results = _import_probe_results(_PACKAGE_ROOT / shim_directory_path)
117
+
118
+ assert all_probe_results["exported_names"] == sorted(_ALL_EXPORTED_HELPER_NAMES)