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
@@ -22,7 +22,7 @@ Before stating a runtime claim, gather the matching live signal:
22
22
  | The config is in effect | Print the loaded config at runtime and read the value. |
23
23
  | The server is up | Probe the port; a refused connection means it is down. |
24
24
  | The process is running | List processes and match the name or PID. |
25
- | The change took effect | Drive the flow and watch the new behavior. |
25
+ | The change took effect | Drive the flow and watch the new behavior. A script that proves a branch's behavior names the module file it loaded, as its first step, because an installed copy of the same package shadows the checkout you meant to test. |
26
26
  | The dependency is reachable | Send one real request and read the response. |
27
27
  | The release or deploy shipped | Read what it makes: the tag, the published version, the file on disk. |
28
28
  | The pipeline did the work | Read each job's own result. A run reports success while a job inside it is skipped. |
@@ -12,6 +12,6 @@ Never call `shutil.rmtree` with `ignore_errors=True` — Windows `ReadOnly` file
12
12
 
13
13
  In Node, call `mkdirSync(targetPath, { recursive: true })` on possibly-existing paths — `ReadOnly` directories break the non-recursive form. When the call must be non-recursive, strip the attribute first (`(Get-Item $path -Force).Attributes = "Directory"` / `os.chmod(path, stat.S_IWRITE)`).
14
14
 
15
- The `windows_rmtree_blocker.py` PreToolUse hook (Write/Edit/Bash) blocks the unsafe rmtree pattern and returns the full `force_rmtree` safe-pattern code.
15
+ The staged policy lint carries this check as its `rmtree-safety` rule. It reports the unsafe rmtree pattern and returns the full `force_rmtree` safe-pattern code. Run `python packages/claude-dev-env/scripts/cde_lint.py --staged` before you commit. CI runs the same lint against the merge base.
16
16
 
17
- Define the safe handler trio (`_strip_read_only_and_retry`, `_force_remove_tree` / `force_rmtree`, and the `inspect.signature` onexc/onerror guard) once in a shared Windows-filesystem utility module, and import it from every call site. A second local copy drifts from the first — a fix lands in one and the other keeps the bug (CODE_RULES.md, CORE PRINCIPLES — "Reuse before create"). The `duplicate_rmtree_helper_blocker.py` PreToolUse hook (Write/Edit) blocks a local re-definition of any trio member outside the shared home and points the writer at the import. This complements the same-directory `check_duplicate_function_body_across_files` gate, which a copy between two distant packages slips past.
17
+ Define the safe handler trio (`_strip_read_only_and_retry`, `_force_remove_tree` / `force_rmtree`, and the `inspect.signature` onexc/onerror guard) once in a shared Windows-filesystem utility module, and import it from every call site. A second local copy drifts from the first, so a fix lands in one and the other keeps the bug (CODE_RULES.md, CORE PRINCIPLES, "Reuse before create"). The same `rmtree-safety` lint rule reports a local re-definition of any trio member outside the shared home and points the writer at the import. This complements the same-directory `check_duplicate_function_body_across_files` check, which a copy between two distant packages slips past.
@@ -9,4 +9,4 @@ In a `.workflow.js` agent-prompt template, every per-call or per-iteration value
9
9
 
10
10
  When a loop builds a per-iteration path or output key, write the index as a slot — `cand_<i>` — or spell out `replace <i> with the iteration index 0, 1, 2` in the step text. Every per-call value in a `.workflow.js` template carries angle brackets so an agent fills in a fresh value per call.
11
11
 
12
- `workflow_substitution_slot_blocker.py` (PreToolUse on Write/Edit) blocks a `.workflow.js` write whose looped content carries a bare `<word>_<i|j|k>` token as a per-iteration path segment, and returns the corrective message.
12
+ The staged policy lint carries this check as its `workflow-substitution` rule. It reports a `.workflow.js` file whose looped content holds a bare `<word>_<i|j|k>` token as a per-iteration path segment. Run `python packages/claude-dev-env/scripts/cde_lint.py --staged` before you commit. CI runs the same lint against the merge base. No write-time hook reports this, so a bare token stays on disk until the lint runs.
@@ -55,10 +55,7 @@ CONVENTIONAL_TITLE_PATTERN: re.Pattern[str] = re.compile(
55
55
  rf"^(?:{'|'.join(ALL_CONVENTIONAL_TITLE_TYPES)})(?:\([^)\r\n]+\))?!?: [^\s].*$"
56
56
  )
57
57
  ALL_REQUIRED_PR_DESCRIPTION_HEADINGS: tuple[str, ...] = (
58
- "Summary",
59
- "Description",
60
58
  "Why",
61
- "How",
62
59
  "Verification",
63
60
  )
64
61
  ALL_PATH_ANCHORED_VOLATILE_PATH_MARKERS: tuple[str, ...] = (
@@ -4,6 +4,7 @@ from durable_post_lint_config.config.constants import (
4
4
  ALL_CONVENTIONAL_TITLE_TYPES,
5
5
  ALL_POST_ACTIONS,
6
6
  ALL_RELEASE_BODY_MARKERS,
7
+ ALL_REQUIRED_PR_DESCRIPTION_HEADINGS,
7
8
  CONVENTIONAL_TITLE_PATTERN,
8
9
  RELEASE_BRANCH_PREFIX,
9
10
  )
@@ -36,3 +37,7 @@ def should_carry_both_markers_release_automation_writes() -> None:
36
37
 
37
38
  def should_name_the_branch_prefix_release_automation_pushes() -> None:
38
39
  assert RELEASE_BRANCH_PREFIX == "release-please--branches--"
40
+
41
+
42
+ def should_require_only_the_headings_both_pull_request_rule_sets_name() -> None:
43
+ assert ALL_REQUIRED_PR_DESCRIPTION_HEADINGS == ("Why", "Verification")
@@ -121,7 +121,31 @@ def _registered_nested_strings(each_entry: object) -> tuple[str, ...]:
121
121
  return tuple(all_registered_strings)
122
122
 
123
123
 
124
+ def _names_exempt_registration_path(registered_string: str) -> bool:
125
+ """Return True when the registration names a chain that decides no policy.
126
+
127
+ ::
128
+
129
+ hooks/blocking/bash_pre_tool_use_dispatcher.py -> exempt
130
+ hooks/blocking/some_new_blocker.py -> flagged
131
+
132
+ The Bash PreToolUse dispatcher sits under ``blocking/`` for layout reasons
133
+ while its roster hosts one allow-and-rewrite hook. Its path segment reads as
134
+ a policy boundary that the chain never carries.
135
+
136
+ Args:
137
+ registered_string: One command, path, script, or entrypoint string.
138
+ """
139
+ path_shaped_string = registered_string.lower().replace("\\", "/")
140
+ return any(
141
+ each_exempt_path in path_shaped_string
142
+ for each_exempt_path in constants.ALL_ACTION_BOUNDARY_EXEMPT_REGISTRATION_PATHS
143
+ )
144
+
145
+
124
146
  def _contains_action_boundary_marker(registered_string: str) -> bool:
147
+ if _names_exempt_registration_path(registered_string):
148
+ return False
125
149
  normalized_string = registered_string.lower().replace("\\", "/")
126
150
  normalized_string = normalized_string.replace(".", "/").replace("-", "/")
127
151
  return any(
@@ -0,0 +1,201 @@
1
+ """Detect rules prose that credits a hook module which runs nowhere.
2
+
3
+ A hook module runs when ``hooks/hooks.json`` registers it or a dispatcher
4
+ roster hosts it. A module that survives on disk while appearing in neither
5
+ one reaches no tool call, so prose that gives it a present-tense action
6
+ describes a gate the reader never faces.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from pathlib import Path, PurePosixPath
13
+
14
+ from .config import constants
15
+ from .model import Diagnostic, Document, Location, Severity
16
+
17
+
18
+ def accepts_rules_markdown(document: Document) -> bool:
19
+ """Return whether the document is Markdown in a rules directory.
20
+
21
+ Args:
22
+ document: Candidate document.
23
+
24
+ Returns:
25
+ True for a Markdown file directly under a ``rules`` directory.
26
+ """
27
+ return (
28
+ document.path.suffix.lower() in constants.ALL_MARKDOWN_SUFFIXES
29
+ and document.path.parent.name == constants.RULES_DIRECTORY_NAME
30
+ )
31
+
32
+
33
+ def _package_root(repository_root: Path, document: Document) -> Path:
34
+ return repository_root / document.path.parent.parent.as_posix()
35
+
36
+
37
+ def _read_text(path: Path) -> str:
38
+ if not path.is_file():
39
+ return ""
40
+ return path.read_text(
41
+ encoding="utf-8", errors=constants.REPLACEMENT_CHARACTER_ERRORS
42
+ )
43
+
44
+
45
+ def _registered_hook_stems(hooks_root: Path) -> frozenset[str]:
46
+ all_stems = set(
47
+ re.findall(
48
+ constants.HOOKS_JSON_PATH_PATTERN,
49
+ _read_text(hooks_root / constants.HOOKS_CONFIGURATION_FILE_NAME),
50
+ )
51
+ )
52
+ constants_root = hooks_root / constants.HOOK_CONSTANTS_DIRECTORY_NAME
53
+ if constants_root.is_dir():
54
+ for each_path in sorted(
55
+ constants_root.glob(constants.DISPATCHER_CONSTANTS_GLOB)
56
+ ):
57
+ all_stems.update(
58
+ PurePosixPath(each_relative_path).stem
59
+ for each_relative_path in re.findall(
60
+ constants.QUOTED_HOOK_PATH_PATTERN, _read_text(each_path)
61
+ )
62
+ )
63
+ return frozenset(all_stems)
64
+
65
+
66
+ def _present_hook_stems(hooks_root: Path) -> frozenset[str]:
67
+ if not hooks_root.is_dir():
68
+ return frozenset()
69
+ return frozenset(
70
+ each_path.stem for each_path in hooks_root.rglob(constants.PYTHON_GLOB)
71
+ )
72
+
73
+
74
+ def _retired_roster_stems(package_root: Path) -> frozenset[str]:
75
+ installer_path = package_root.joinpath(*constants.ALL_INSTALLER_PATH_SEGMENTS)
76
+ maybe_roster = re.search(
77
+ constants.RETIRED_ROSTER_PATTERN, _read_text(installer_path)
78
+ )
79
+ if maybe_roster is None:
80
+ return frozenset()
81
+ return frozenset(
82
+ PurePosixPath(each_relative_path).stem
83
+ for each_relative_path in re.findall(
84
+ constants.QUOTED_HOOK_PATH_PATTERN, maybe_roster.group(1)
85
+ )
86
+ )
87
+
88
+
89
+ def _hook_stem_in_span(span_text: str) -> str | None:
90
+ candidate_name = (
91
+ span_text.strip()
92
+ .rsplit(constants.PATH_SEPARATOR, 1)[-1]
93
+ .removesuffix(constants.PYTHON_SUFFIX)
94
+ )
95
+ if not re.fullmatch(constants.HOOK_MODULE_NAME_PATTERN, candidate_name):
96
+ return None
97
+ if not candidate_name.endswith(constants.ALL_HOOK_MODULE_NAME_SUFFIXES):
98
+ return None
99
+ return candidate_name
100
+
101
+
102
+ def _names_a_live_action(segment_text: str) -> bool:
103
+ return any(
104
+ each_word in constants.ALL_LIVE_CLAIM_VERBS
105
+ for each_word in re.findall(
106
+ constants.PROSE_WORD_PATTERN, segment_text.lower()
107
+ )
108
+ )
109
+
110
+
111
+ def _describes_a_replacement(line_text: str) -> bool:
112
+ normalized_line = line_text.lower()
113
+ return any(
114
+ each_phrase in normalized_line
115
+ for each_phrase in constants.ALL_REPLACEMENT_DELIVERY_PHRASES
116
+ )
117
+
118
+
119
+ def _claim_segment_end(
120
+ line_text: str, all_spans: tuple[re.Match[str], ...], span_index: int
121
+ ) -> int:
122
+ maybe_sentence_end = re.compile(constants.SENTENCE_BOUNDARY_PATTERN).search(
123
+ line_text, all_spans[span_index].end()
124
+ )
125
+ segment_end = (
126
+ len(line_text) if maybe_sentence_end is None else maybe_sentence_end.start()
127
+ )
128
+ for each_span in all_spans[span_index + 1 :]:
129
+ if each_span.start() >= segment_end:
130
+ break
131
+ if _hook_stem_in_span(each_span.group(1)) is not None:
132
+ return each_span.start()
133
+ return segment_end
134
+
135
+
136
+ def _line_diagnostics(
137
+ document: Document,
138
+ line_text: str,
139
+ line_number: int,
140
+ all_known_stems: frozenset[str],
141
+ all_registered_stems: frozenset[str],
142
+ ) -> tuple[Diagnostic, ...]:
143
+ all_spans = tuple(re.finditer(constants.INLINE_CODE_PATTERN, line_text))
144
+ all_diagnostics: list[Diagnostic] = []
145
+ for each_index, each_span in enumerate(all_spans):
146
+ maybe_stem = _hook_stem_in_span(each_span.group(1))
147
+ if maybe_stem is None or maybe_stem not in all_known_stems:
148
+ continue
149
+ if maybe_stem in all_registered_stems:
150
+ continue
151
+ claim_end = _claim_segment_end(line_text, all_spans, each_index)
152
+ if not _names_a_live_action(line_text[each_span.end() : claim_end]):
153
+ continue
154
+ all_diagnostics.append(
155
+ Diagnostic(
156
+ constants.RETIRED_HOOK_PROSE_RULE_ID,
157
+ Severity.ERROR,
158
+ constants.RETIRED_HOOK_PROSE_MESSAGE.format(hook_name=maybe_stem),
159
+ Location(document.path, line_number, each_span.start() + 1),
160
+ )
161
+ )
162
+ return tuple(all_diagnostics)
163
+
164
+
165
+ def _hook_stem_sets(package_root: Path) -> tuple[frozenset[str], frozenset[str]]:
166
+ hooks_root = package_root / constants.HOOKS_DIRECTORY_NAME
167
+ all_known_stems = _present_hook_stems(hooks_root) | _retired_roster_stems(
168
+ package_root
169
+ )
170
+ return all_known_stems, _registered_hook_stems(hooks_root)
171
+
172
+
173
+ def retired_hook_prose_diagnostics(
174
+ document: Document, repository_root: Path
175
+ ) -> tuple[Diagnostic, ...]:
176
+ """Report rules prose that credits an unregistered hook with a live action.
177
+
178
+ Args:
179
+ document: Current rules Markdown text and path.
180
+ repository_root: Request repository root for package resolution.
181
+
182
+ Returns:
183
+ One diagnostic for each live claim about a hook that runs nowhere.
184
+ """
185
+ all_known_stems, all_registered_stems = _hook_stem_sets(
186
+ _package_root(repository_root, document)
187
+ )
188
+ all_diagnostics: list[Diagnostic] = []
189
+ for each_line_number, each_line in enumerate(document.text.splitlines(), 1):
190
+ if _describes_a_replacement(each_line):
191
+ continue
192
+ all_diagnostics.extend(
193
+ _line_diagnostics(
194
+ document,
195
+ each_line,
196
+ each_line_number,
197
+ all_known_stems,
198
+ all_registered_stems,
199
+ )
200
+ )
201
+ return tuple(all_diagnostics)
@@ -8,7 +8,13 @@ from types import ModuleType
8
8
 
9
9
  from shared_tree_paths import resolve_shared_scripts_directory
10
10
 
11
- from . import adapter_configuration, adapter_detectors, adapter_pairing, adapter_support
11
+ from . import (
12
+ adapter_configuration,
13
+ adapter_detectors,
14
+ adapter_pairing,
15
+ adapter_retired_hook_prose,
16
+ adapter_support,
17
+ )
12
18
  from .config import constants
13
19
  from .model import Diagnostic, Document, DocumentSet
14
20
 
@@ -377,3 +383,32 @@ def accepts_plans(document: Document) -> bool:
377
383
  )
378
384
  )
379
385
  )
386
+
387
+
388
+ def accepts_rules_markdown(document: Document) -> bool:
389
+ """Return whether the document is Markdown in a rules directory.
390
+
391
+ Args:
392
+ document: Candidate document.
393
+
394
+ Returns:
395
+ True for a Markdown file directly under a ``rules`` directory.
396
+ """
397
+ return adapter_retired_hook_prose.accepts_rules_markdown(document)
398
+
399
+
400
+ def retired_hook_prose_diagnostics(
401
+ document: Document, repository_root: Path
402
+ ) -> tuple[Diagnostic, ...]:
403
+ """Report rules prose that credits an unregistered hook with a live action.
404
+
405
+ Args:
406
+ document: Current rules Markdown text and path.
407
+ repository_root: Request repository root for package resolution.
408
+
409
+ Returns:
410
+ Retired-hook prose diagnostics.
411
+ """
412
+ return adapter_retired_hook_prose.retired_hook_prose_diagnostics(
413
+ document, repository_root
414
+ )
@@ -57,6 +57,9 @@ ALL_ACTION_BOUNDARY_SEGMENTS = frozenset(
57
57
  }
58
58
  )
59
59
  ALL_ACTION_BOUNDARY_PREFIXES = ("deny_", "block_", "ask_")
60
+ ALL_ACTION_BOUNDARY_EXEMPT_REGISTRATION_PATHS = (
61
+ "blocking/bash_pre_tool_use_dispatcher.py",
62
+ )
60
63
  ALL_TEST_DIRECTORY_NAMES = frozenset({"tests"})
61
64
  ALL_TEST_FILE_PREFIXES = ("test_",)
62
65
  ALL_TEST_FILE_SUFFIXES = ("_test.py",)
@@ -80,3 +83,74 @@ TERMINOLOGY_MESSAGE_GROUP = "message"
80
83
 
81
84
  PR_LOOP_DIRECTORY_NAME = "pr-loop"
82
85
  SHARED_ROOT_PARENT_INDEX = 2
86
+
87
+ RETIRED_HOOK_PROSE_RULE_ID = "retired-hook-prose"
88
+ RULES_DIRECTORY_NAME = "rules"
89
+ HOOKS_DIRECTORY_NAME = "hooks"
90
+ HOOKS_CONFIGURATION_FILE_NAME = "hooks.json"
91
+ HOOK_CONSTANTS_DIRECTORY_NAME = "hooks_constants"
92
+ DISPATCHER_CONSTANTS_GLOB = "*dispatcher_constants.py"
93
+ ALL_INSTALLER_PATH_SEGMENTS = ("bin", "install.mjs")
94
+ PYTHON_GLOB = "*.py"
95
+ REPLACEMENT_CHARACTER_ERRORS = "replace"
96
+ ALL_HOOK_MODULE_NAME_SUFFIXES = (
97
+ "_advisor",
98
+ "_blocker",
99
+ "_dispatcher",
100
+ "_enforcer",
101
+ "_gate",
102
+ "_guard",
103
+ "_recorder",
104
+ "_rewriter",
105
+ "_tracker",
106
+ "_validator",
107
+ )
108
+ ALL_LIVE_CLAIM_VERBS = frozenset(
109
+ {
110
+ "allows",
111
+ "applies",
112
+ "blocks",
113
+ "catches",
114
+ "checks",
115
+ "denies",
116
+ "enforces",
117
+ "fires",
118
+ "flags",
119
+ "gates",
120
+ "hosts",
121
+ "matches",
122
+ "prevents",
123
+ "reads",
124
+ "records",
125
+ "refuses",
126
+ "rejects",
127
+ "reports",
128
+ "requires",
129
+ "runs",
130
+ "scans",
131
+ "stops",
132
+ "watches",
133
+ }
134
+ )
135
+ ALL_REPLACEMENT_DELIVERY_PHRASES = (
136
+ "staged policy lint",
137
+ "policy lint",
138
+ "cde_lint",
139
+ "repository_checks/",
140
+ "repository_policy.py",
141
+ "repository check",
142
+ )
143
+ INLINE_CODE_PATTERN = r"`([^`\n]+)`"
144
+ HOOK_MODULE_NAME_PATTERN = r"[a-z_][a-z0-9_]*"
145
+ PROSE_WORD_PATTERN = r"[a-z]+"
146
+ RETIRED_ROSTER_PATTERN = (
147
+ r"(?s)RETIRED_HOOK_REGISTRATION_RELATIVE_PATHS\s*=\s*new Set\(\[(.*?)\]\)"
148
+ )
149
+ QUOTED_HOOK_PATH_PATTERN = r"['\"]([a-z_]+/[a-z_0-9]+\.py)['\"]"
150
+ HOOKS_JSON_PATH_PATTERN = r"hooks/[a-z_]+/([a-z_0-9]+)\.py"
151
+ SENTENCE_BOUNDARY_PATTERN = r"[.!?](?:\s|$)"
152
+ RETIRED_HOOK_PROSE_MESSAGE = (
153
+ "{hook_name} is named by no hooks.json entry and no dispatcher roster, so it "
154
+ "reaches no tool call. Say what the reader faces now, or drop the "
155
+ "present-tense action."
156
+ )
@@ -104,6 +104,11 @@ def _text_document_rules() -> tuple[model.Rule, ...]:
104
104
  adapters.accepts_workflow,
105
105
  adapters.workflow_substitution_diagnostics,
106
106
  ),
107
+ build_document_rule(
108
+ "retired-hook-prose",
109
+ adapters.accepts_rules_markdown,
110
+ adapters.retired_hook_prose_diagnostics,
111
+ ),
107
112
  )
108
113
 
109
114
 
@@ -18,6 +18,7 @@ from .model import (
18
18
  TextDocument,
19
19
  )
20
20
  from .selection_git import (
21
+ GitBlobItemBlocked,
21
22
  GitSelectionError,
22
23
  git_bytes_for,
23
24
  head_revision,
@@ -30,6 +31,10 @@ class SelectionRunFatal(ValueError):
30
31
  """Raised when a source selection cannot be resolved safely."""
31
32
 
32
33
 
34
+ class SelectionItemBlocked(ValueError):
35
+ """Raised when one file holds bytes that are not a policy document."""
36
+
37
+
33
38
  SelectionError = SelectionRunFatal
34
39
 
35
40
 
@@ -54,9 +59,7 @@ def select_documents(request: LintRequest) -> DocumentSet:
54
59
 
55
60
  def _select_source(repository_root: Path, source: object) -> DocumentSet:
56
61
  if isinstance(source, ExplicitFiles):
57
- all_documents = tuple(
58
- _worktree_document(repository_root, each_path) for each_path in source.paths
59
- )
62
+ all_documents = _explicit_documents(repository_root, source.paths)
60
63
  return DocumentSet(all_documents, SelectionKind.FILES, repository_root)
61
64
  if isinstance(source, StagedChanges):
62
65
  return _select_changes(repository_root, True, None)
@@ -89,13 +92,28 @@ def _validated_repository_root(repository_root: Path) -> Path:
89
92
  return resolved_root
90
93
 
91
94
 
95
+ def _explicit_documents(
96
+ repository_root: Path, all_paths: tuple[Path, ...]
97
+ ) -> tuple[Document, ...]:
98
+ all_documents: list[Document] = []
99
+ for each_path in all_paths:
100
+ try:
101
+ all_documents.append(_worktree_document(repository_root, each_path))
102
+ except SelectionItemBlocked:
103
+ continue
104
+ return tuple(all_documents)
105
+
106
+
92
107
  def _select_repository_tree(repository_root: Path) -> DocumentSet:
93
- all_documents = tuple(
94
- _tracked_worktree_document(repository_root, each_path)
95
- for each_path in _tracked_paths(repository_root)
96
- if (repository_root / each_path).is_file()
97
- )
98
- return DocumentSet(all_documents, SelectionKind.REPOSITORY, repository_root)
108
+ all_documents: list[Document] = []
109
+ for each_path in _tracked_paths(repository_root):
110
+ if not (repository_root / each_path).is_file():
111
+ continue
112
+ try:
113
+ all_documents.append(_tracked_worktree_document(repository_root, each_path))
114
+ except SelectionItemBlocked:
115
+ continue
116
+ return DocumentSet(tuple(all_documents), SelectionKind.REPOSITORY, repository_root)
99
117
 
100
118
 
101
119
  def _tracked_worktree_document(repository_root: Path, raw_path: str) -> Document:
@@ -189,13 +207,16 @@ def _append_change(
189
207
  old_path, new_path = all_paths if status[:1] in {"R", "C"} else (all_paths[0], all_paths[0])
190
208
  normalized_old_path = _normalize_path(repository_root, old_path)
191
209
  normalized_new_path = _normalize_path(repository_root, new_path)
192
- document = _change_document(
193
- repository_root, old_path, new_path, normalized_old_path,
194
- normalized_new_path, is_staged, base_revision, status,
195
- )
196
- if document is None:
210
+ if status == "D":
197
211
  all_deleted_paths.append(normalized_old_path)
198
212
  return
213
+ try:
214
+ document = _change_document(
215
+ repository_root, old_path, new_path, normalized_old_path,
216
+ normalized_new_path, is_staged, base_revision, status,
217
+ )
218
+ except (SelectionItemBlocked, GitBlobItemBlocked):
219
+ return
199
220
  all_documents.append(document)
200
221
  if document.prior_path is not None:
201
222
  all_renamed_paths.append((normalized_old_path, normalized_new_path))
@@ -210,9 +231,7 @@ def _change_document(
210
231
  is_staged: bool,
211
232
  base_revision: str | None,
212
233
  status: str,
213
- ) -> Document | None:
214
- if status == "D":
215
- return None
234
+ ) -> Document:
216
235
  is_copy = status[:1] == "C"
217
236
  is_rename = status[:1] == "R"
218
237
  prior_text = _change_prior_text(
@@ -274,7 +293,7 @@ def _read_worktree_text(file_path: Path) -> str:
274
293
  try:
275
294
  return file_path.read_bytes().decode(constants.UTF8_ENCODING)
276
295
  except UnicodeDecodeError as error:
277
- raise SelectionRunFatal(f"File is not UTF-8: {file_path}") from error
296
+ raise SelectionItemBlocked(f"File is not UTF-8: {file_path}") from error
278
297
  except OSError as error:
279
298
  raise SelectionRunFatal(f"File cannot be read: {file_path}") from error
280
299
 
@@ -21,6 +21,10 @@ class GitSelectionError(ValueError):
21
21
  """Raised when Git cannot provide the requested source bytes."""
22
22
 
23
23
 
24
+ class GitBlobItemBlocked(GitSelectionError):
25
+ """Raised when one Git blob holds bytes that are not UTF-8 text."""
26
+
27
+
24
28
  def git_bytes_for(repository_root: Path, all_arguments: tuple[str, ...]) -> bytes:
25
29
  """Run a fixed Git argument vector and return raw stdout bytes.
26
30
 
@@ -89,7 +93,7 @@ def read_blob(repository_root: Path, revision: str, relative_path: str) -> str |
89
93
  Decoded blob text, or None when the blob is absent.
90
94
 
91
95
  Raises:
92
- GitSelectionError: If the blob is not UTF-8.
96
+ GitBlobItemBlocked: If the blob is not UTF-8.
93
97
  """
94
98
  blob_bytes = _read_blob_bytes(repository_root, revision, relative_path)
95
99
  if blob_bytes is None:
@@ -97,7 +101,7 @@ def read_blob(repository_root: Path, revision: str, relative_path: str) -> str |
97
101
  try:
98
102
  return blob_bytes.decode(UTF8_ENCODING)
99
103
  except UnicodeDecodeError as error:
100
- raise GitSelectionError(f"Git blob is not UTF-8: {relative_path}") from error
104
+ raise GitBlobItemBlocked(f"Git blob is not UTF-8: {relative_path}") from error
101
105
 
102
106
 
103
107
  def _git_subprocess_environment() -> dict[str, str]:
@@ -15,21 +15,29 @@ if str(_SCRIPTS_DIRECTORY) not in sys.path:
15
15
  import durable_post_lint
16
16
 
17
17
  SCRIPT_PATH = _SCRIPTS_DIRECTORY / "durable_post_lint.py"
18
- VALID_PR_BODY = """## Summary
18
+ VALID_PR_BODY = """## Why
19
19
 
20
- Summary text.
20
+ Why text.
21
21
 
22
- ## Description
22
+ ## Scope
23
23
 
24
- Description text.
24
+ Scope text.
25
25
 
26
- ## Why
26
+ ## Tradeoffs
27
27
 
28
- Why text.
28
+ Tradeoffs text.
29
+
30
+ ## Blast Radius
31
+
32
+ Blast radius text.
33
+
34
+ ## Verification
29
35
 
30
- ## How
36
+ Verification text.
37
+ """
38
+ MINIMAL_PR_BODY = """## Why
31
39
 
32
- How text.
40
+ Why text.
33
41
 
34
42
  ## Verification
35
43
 
@@ -156,7 +164,7 @@ def test_pr_titles_reject_nonconventional_forms(title: str) -> None:
156
164
 
157
165
  @pytest.mark.parametrize(
158
166
  "missing_heading",
159
- ["Summary", "Description", "Why", "How", "Verification"],
167
+ durable_post_lint.ALL_REQUIRED_PR_DESCRIPTION_HEADINGS,
160
168
  )
161
169
  def test_pr_body_reports_each_missing_heading(missing_heading: str) -> None:
162
170
  body_text = VALID_PR_BODY.replace(f"## {missing_heading}\n", "")
@@ -170,6 +178,31 @@ def test_pr_body_reports_each_missing_heading(missing_heading: str) -> None:
170
178
  ]
171
179
 
172
180
 
181
+ @pytest.mark.parametrize("action", ["pr-create", "pr-edit"])
182
+ def should_accept_a_body_carrying_only_the_two_required_headings(action: str) -> None:
183
+ all_findings = durable_post_lint.lint_durable_post(
184
+ action=action,
185
+ title="fix(cli): tighten input handling",
186
+ body_text=MINIMAL_PR_BODY,
187
+ )
188
+ assert all_findings == ()
189
+
190
+
191
+ def should_still_accept_a_body_published_under_the_superseded_heading_set() -> None:
192
+ all_findings = durable_post_lint.lint_durable_post(
193
+ action="pr-edit",
194
+ title=None,
195
+ body_text=(
196
+ "## Summary\n\nSummary text.\n\n"
197
+ "## Description\n\nDescription text.\n\n"
198
+ "## Why\n\nWhy text.\n\n"
199
+ "## How\n\nHow text.\n\n"
200
+ "## Verification\n\nVerification text.\n"
201
+ ),
202
+ )
203
+ assert all_findings == ()
204
+
205
+
173
206
  def test_non_pr_body_needs_no_pr_headings() -> None:
174
207
  all_findings = durable_post_lint.lint_durable_post(
175
208
  action="issue-comment",
@@ -42,6 +42,14 @@ def test_incremental_blocker_removal_is_clean(tmp_path: Path) -> None:
42
42
  assert adapters.hook_configuration_diagnostics(current_document, tmp_path) == ()
43
43
 
44
44
 
45
+ def test_new_bash_pre_tool_use_dispatcher_registration_is_clean(tmp_path: Path) -> None:
46
+ current_document = _hook_document(
47
+ ["hooks/blocking/bash_pre_tool_use_dispatcher.py"],
48
+ '{"hooks": {}}',
49
+ )
50
+ assert adapters.hook_configuration_diagnostics(current_document, tmp_path) == ()
51
+
52
+
45
53
  def test_new_blocker_in_staged_change_is_rejected(tmp_path: Path) -> None:
46
54
  current_document = _hook_document(
47
55
  ["hooks/blocking/new.py"],