claude-dev-env 7.4.1 → 7.5.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 (66) hide show
  1. package/bin/install.mjs +0 -1
  2. package/bin/install.policy-lint.test.mjs +1 -2
  3. package/bin/install.test.mjs +0 -1
  4. package/hooks/advisory/msys_path_conversion_advisor.py +200 -0
  5. package/hooks/advisory/pr_done_reminder.py +7 -13
  6. package/hooks/advisory/test_msys_path_conversion_advisor.py +223 -0
  7. package/hooks/advisory/test_pr_done_reminder.py +1 -0
  8. package/hooks/blocking/bash_post_call_dispatcher.py +6 -11
  9. package/hooks/blocking/msys_rev_path_rewriter.py +192 -0
  10. package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +16 -3
  11. package/hooks/blocking/test_bash_post_call_dispatcher.py +2 -0
  12. package/hooks/blocking/test_msys_rev_path_rewriter.py +141 -0
  13. package/hooks/hooks.json +10 -0
  14. package/hooks/hooks_constants/bash_post_call_dispatcher_constants.py +9 -0
  15. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +6 -1
  16. package/hooks/hooks_constants/msys_path_conversion_advisor_constants.py +48 -0
  17. package/hooks/hooks_constants/msys_rev_path_rewriter_constants.py +37 -0
  18. package/hooks/hooks_constants/post_tool_use_context.py +34 -0
  19. package/hooks/hooks_constants/pr_done_reminder_constants.py +0 -2
  20. package/hooks/hooks_constants/pre_tool_use_allow_output.py +48 -0
  21. package/hooks/hooks_constants/test_bash_post_call_dispatcher_constants.py +9 -2
  22. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +9 -2
  23. package/hooks/hooks_constants/test_failure_recorder_constants.py +1 -2
  24. package/hooks/hooks_constants/test_post_tool_use_context.py +22 -0
  25. package/hooks/hooks_constants/test_pre_tool_use_allow_output.py +27 -0
  26. package/hooks/observability/test_failure_recorder.py +3 -1
  27. package/package.json +1 -1
  28. package/rules/ask-user-question-required.md +3 -3
  29. package/rules/code-standards.md +3 -2
  30. package/rules/confirm-implementation-forks.md +2 -0
  31. package/rules/destructive-commands.md +19 -19
  32. package/rules/doc-inventory-integrity.md +4 -4
  33. package/rules/docstring-prose-matches-implementation.md +2 -2
  34. package/rules/failure-blast-radius.md +4 -2
  35. package/rules/filesystem-search.md +1 -1
  36. package/rules/git-workflow.md +34 -0
  37. package/rules/long-horizon-autonomy.md +2 -0
  38. package/rules/nas-ssh-invocation.md +3 -3
  39. package/rules/no-cross-skill-duplicate-helpers.md +6 -6
  40. package/rules/orphan-css-class.md +5 -5
  41. package/rules/paired-test-coverage.md +6 -6
  42. package/rules/plain-illustrative-docstrings.md +3 -3
  43. package/rules/re-stage-before-commit.md +7 -7
  44. package/rules/retired-hook-prose.md +16 -0
  45. package/rules/shell-invocation.md +17 -1
  46. package/rules/testing.md +3 -5
  47. package/rules/verify-runtime-state.md +1 -1
  48. package/rules/windows-filesystem-safe.md +2 -2
  49. package/rules/workflow-substitution-slots.md +1 -1
  50. package/scripts/durable_post_lint_config/config/constants.py +0 -3
  51. package/scripts/durable_post_lint_config/config/test_constants.py +5 -0
  52. package/scripts/policy_lint/adapter_configuration.py +24 -0
  53. package/scripts/policy_lint/adapter_retired_hook_prose.py +201 -0
  54. package/scripts/policy_lint/adapters.py +36 -1
  55. package/scripts/policy_lint/config/constants.py +74 -0
  56. package/scripts/policy_lint/registry.py +5 -0
  57. package/scripts/policy_lint/selection.py +37 -18
  58. package/scripts/policy_lint/selection_git.py +6 -2
  59. package/scripts/test_durable_post_lint.py +42 -9
  60. package/scripts/tests/test_adapter_configuration.py +8 -0
  61. package/scripts/tests/test_policy_lint_rules_retired_hook_prose.py +197 -0
  62. package/scripts/tests/test_policy_lint_selection.py +107 -0
  63. package/hooks/hooks_constants/convergence_branch_constants.py +0 -8
  64. package/hooks/hooks_constants/destructive_command_environment_constants.py +0 -18
  65. package/hooks/hooks_constants/test_text_stripping.py +0 -39
  66. package/hooks/hooks_constants/text_stripping.py +0 -36
@@ -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 == []
@@ -19,6 +19,10 @@ from policy_lint.model import (
19
19
  )
20
20
  from policy_lint.selection import SelectionRunFatal, select_documents
21
21
 
22
+ NON_UTF8_PNG_BYTES = bytes(
23
+ (0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0xFF, 0xFE, 0xFF)
24
+ )
25
+
22
26
 
23
27
  def _git_environment() -> dict[str, str]:
24
28
  return {
@@ -66,6 +70,22 @@ def _commit_text(repository_root: Path, relative_path: str, text: str) -> None:
66
70
  _run_git(repository_root, "commit", "--quiet", "-m", "selection fixture")
67
71
 
68
72
 
73
+ def _write_bytes(repository_root: Path, relative_path: str, raw_bytes: bytes) -> None:
74
+ file_path = repository_root / relative_path
75
+ file_path.parent.mkdir(parents=True, exist_ok=True)
76
+ file_path.write_bytes(raw_bytes)
77
+
78
+
79
+ def _commit_bytes(repository_root: Path, relative_path: str, raw_bytes: bytes) -> None:
80
+ _write_bytes(repository_root, relative_path, raw_bytes)
81
+ _run_git(repository_root, "add", "--", relative_path)
82
+ _run_git(repository_root, "commit", "--quiet", "-m", "selection binary fixture")
83
+
84
+
85
+ def _all_selected_paths(document_set: DocumentSet) -> set[str]:
86
+ return {each_document.path.as_posix() for each_document in document_set.documents}
87
+
88
+
69
89
  def _document_for(document_set: DocumentSet, relative_path: str) -> Document:
70
90
  for each_document in document_set.documents:
71
91
  if each_document.path.as_posix() == relative_path:
@@ -394,3 +414,90 @@ def test_tracked_worktree_document_preserves_git_path_spelling(
394
414
  tmp_path, "MiXeD.py"
395
415
  )
396
416
  assert selected_document.path == PurePosixPath("MiXeD.py")
417
+
418
+
419
+ def test_base_added_binary_file_should_be_left_out_of_the_selection(
420
+ tmp_path: Path,
421
+ ) -> None:
422
+ repository_root = _initialize_repository(tmp_path)
423
+ _commit_text(repository_root, "file.py", "base-text\n")
424
+ _run_git(repository_root, "checkout", "--quiet", "-b", "feature")
425
+ _commit_text(repository_root, "file.py", "feature-text\n")
426
+ _commit_bytes(repository_root, "approved_design_overlay.png", NON_UTF8_PNG_BYTES)
427
+
428
+ document_set = select_documents(LintRequest.base(repository_root, "main"))
429
+
430
+ assert _all_selected_paths(document_set) == {"file.py"}
431
+ assert _document_for(document_set, "file.py").text == "feature-text\n"
432
+ assert document_set.deleted_paths == ()
433
+
434
+
435
+ def test_base_modified_binary_file_should_be_left_out_of_the_selection(
436
+ tmp_path: Path,
437
+ ) -> None:
438
+ repository_root = _initialize_repository(tmp_path)
439
+ _commit_bytes(repository_root, "approved_design_overlay.png", NON_UTF8_PNG_BYTES)
440
+ _run_git(repository_root, "checkout", "--quiet", "-b", "feature")
441
+ _commit_bytes(
442
+ repository_root,
443
+ "approved_design_overlay.png",
444
+ NON_UTF8_PNG_BYTES + bytes((0xFF, 0x00, 0xFE)),
445
+ )
446
+ _commit_text(repository_root, "file.py", "feature-text\n")
447
+
448
+ document_set = select_documents(LintRequest.base(repository_root, "main"))
449
+
450
+ assert _all_selected_paths(document_set) == {"file.py"}
451
+ assert _document_for(document_set, "file.py").text == "feature-text\n"
452
+ assert document_set.deleted_paths == ()
453
+
454
+
455
+ def test_staged_binary_file_should_be_left_out_of_the_selection(tmp_path: Path) -> None:
456
+ repository_root = _initialize_repository(tmp_path)
457
+ _commit_text(repository_root, "file.py", "committed\n")
458
+ _write_bytes(repository_root, "approved_design_overlay.png", NON_UTF8_PNG_BYTES)
459
+ _write_text(repository_root, "file.py", "staged\n")
460
+ _run_git(repository_root, "add", "--all", "--", ".")
461
+
462
+ document_set = select_documents(LintRequest.staged(repository_root))
463
+
464
+ assert _all_selected_paths(document_set) == {"file.py"}
465
+ assert _document_for(document_set, "file.py").text == "staged\n"
466
+ assert document_set.deleted_paths == ()
467
+
468
+
469
+ def test_repository_source_should_leave_binary_tracked_files_out(
470
+ tmp_path: Path,
471
+ ) -> None:
472
+ repository_root = _initialize_repository(tmp_path)
473
+ _commit_text(repository_root, "tracked.py", "tracked\n")
474
+ _commit_bytes(repository_root, "approved_design_overlay.png", NON_UTF8_PNG_BYTES)
475
+
476
+ document_set = select_documents(LintRequest.repository(repository_root))
477
+
478
+ assert _all_selected_paths(document_set) == {"tracked.py"}
479
+ assert _document_for(document_set, "tracked.py").text == "tracked\n"
480
+
481
+
482
+ def test_files_source_should_leave_a_named_binary_path_out(tmp_path: Path) -> None:
483
+ repository_root = _initialize_repository(tmp_path)
484
+ _commit_text(repository_root, "file.py", "committed\n")
485
+ _write_bytes(repository_root, "approved_design_overlay.png", NON_UTF8_PNG_BYTES)
486
+
487
+ document_set = select_documents(
488
+ LintRequest.files(
489
+ repository_root,
490
+ [Path("file.py"), Path("approved_design_overlay.png")],
491
+ )
492
+ )
493
+
494
+ assert _all_selected_paths(document_set) == {"file.py"}
495
+ assert _document_for(document_set, "file.py").text == "committed\n"
496
+
497
+
498
+ def test_files_missing_path_should_still_raise(tmp_path: Path) -> None:
499
+ repository_root = _initialize_repository(tmp_path)
500
+ _commit_text(repository_root, "file.py", "committed\n")
501
+
502
+ with pytest.raises(SelectionRunFatal, match="does not exist"):
503
+ select_documents(LintRequest.files(repository_root, [Path("missing.py")]))
@@ -1,8 +0,0 @@
1
- """Convergence branch naming policy constants.
2
-
3
- Shared by destructive_command_blocker convergence-branch exemptions
4
- """
5
-
6
- ALL_CONVERGENCE_BRANCH_PREFIXES: tuple[str, ...] = ("claude/", "worktree-")
7
- CONVERGENCE_BRANCH_SUFFIX_PATTERN: str = r"pr-.*-converge$"
8
- CONVERGENCE_FORCE_PUSH_DETECTION_PATTERN: str = r"\bgit\s+push\b\s+(.*(?:--force|-f)\b.*)"
@@ -1,18 +0,0 @@
1
- """Environment keys and values used by destructive-command hooks."""
2
-
3
- from __future__ import annotations
4
-
5
- ALL_KNOWN_TEMPORARY_ENVIRONMENT_VARIABLE_NAMES: frozenset[str] = frozenset(
6
- {
7
- "TEMP",
8
- "TMP",
9
- "TMPDIR",
10
- "CLAUDE_JOB_DIR",
11
- }
12
- )
13
- ALL_TRUTHY_ENV_VALUES: frozenset[str] = frozenset({"1", "true", "yes", "on"})
14
- DESTRUCTIVE_DENY_MODE_ENV_VAR: str = "CLAUDE_DESTRUCTIVE_DENY_MODE"
15
- EPHEMERAL_AUTO_ALLOW_DISABLE_ENV_VAR: str = (
16
- "CLAUDE_DESTRUCTIVE_DISABLE_EPHEMERAL_AUTO_ALLOW"
17
- )
18
- GH_REDIRECT_ACTIVE_ENV_VAR: str = "CLAUDE_GH_REDIRECT_ACTIVE"
@@ -1,39 +0,0 @@
1
- """Tests for the shared strip_code_and_quotes helper."""
2
-
3
- import pathlib
4
- import sys
5
-
6
- _HOOKS_ROOT = pathlib.Path(__file__).resolve().parent.parent
7
- if str(_HOOKS_ROOT) not in sys.path:
8
- sys.path.insert(0, str(_HOOKS_ROOT))
9
-
10
- from hooks_constants.text_stripping import strip_code_and_quotes
11
-
12
-
13
- def test_removes_fenced_code_block() -> None:
14
- text = "before\n```python\nshould I run this?\n```\nafter"
15
- stripped = strip_code_and_quotes(text)
16
- assert "should I run this?" not in stripped
17
- assert "before" in stripped
18
- assert "after" in stripped
19
-
20
-
21
- def test_removes_inline_code_span() -> None:
22
- text = "the function `would you like` is named oddly"
23
- stripped = strip_code_and_quotes(text)
24
- assert "would you like" not in stripped
25
- assert "the function" in stripped
26
- assert "is named oddly" in stripped
27
-
28
-
29
- def test_removes_leading_blockquote_lines() -> None:
30
- text = "real line\n> should I proceed?\nfinal line"
31
- stripped = strip_code_and_quotes(text)
32
- assert "should I proceed?" not in stripped
33
- assert "real line" in stripped
34
- assert "final line" in stripped
35
-
36
-
37
- def test_leaves_plain_prose_unchanged() -> None:
38
- text = "This sentence carries no code or quotes."
39
- assert strip_code_and_quotes(text) == text
@@ -1,36 +0,0 @@
1
- """Shared text-stripping helper for the Stop-hook prose blockers.
2
-
3
- Several Stop hooks judge the prose of an assistant message and must ignore
4
- fenced code blocks, inline code spans, and leading blockquotes so a phrase that
5
- appears only inside code or a quote never trips the detector. The stripping
6
- logic is identical across those blockers, so it lives here once and is imported
7
- from each.
8
- """
9
-
10
- from __future__ import annotations
11
-
12
- import re
13
-
14
- __all__ = [
15
- "strip_code_and_quotes",
16
- ]
17
-
18
- CODE_BLOCK_PATTERN = re.compile(r"```[\s\S]*?```", re.MULTILINE)
19
- INLINE_CODE_PATTERN = re.compile(r"`[^`]+`")
20
- QUOTED_BLOCK_PATTERN = re.compile(r"^>.*$", re.MULTILINE)
21
-
22
-
23
- def strip_code_and_quotes(text: str) -> str:
24
- """Remove fenced code blocks, inline code, and blockquotes from prose.
25
-
26
- Args:
27
- text: The raw assistant message to clean of code and quoted lines.
28
-
29
- Returns:
30
- The text with every fenced code block, inline code span, and leading
31
- blockquote line removed, so only the prose a reader sees remains.
32
- """
33
- text = CODE_BLOCK_PATTERN.sub("", text)
34
- text = INLINE_CODE_PATTERN.sub("", text)
35
- text = QUOTED_BLOCK_PATTERN.sub("", text)
36
- return text