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.
- package/bin/install.mjs +0 -1
- package/bin/install.policy-lint.test.mjs +1 -2
- package/bin/install.test.mjs +0 -1
- package/hooks/advisory/msys_path_conversion_advisor.py +200 -0
- package/hooks/advisory/pr_done_reminder.py +7 -13
- package/hooks/advisory/test_msys_path_conversion_advisor.py +223 -0
- package/hooks/advisory/test_pr_done_reminder.py +1 -0
- package/hooks/blocking/bash_post_call_dispatcher.py +6 -11
- package/hooks/blocking/msys_rev_path_rewriter.py +192 -0
- package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +16 -3
- package/hooks/blocking/test_bash_post_call_dispatcher.py +2 -0
- package/hooks/blocking/test_msys_rev_path_rewriter.py +141 -0
- package/hooks/hooks.json +10 -0
- package/hooks/hooks_constants/bash_post_call_dispatcher_constants.py +9 -0
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +6 -1
- package/hooks/hooks_constants/msys_path_conversion_advisor_constants.py +48 -0
- package/hooks/hooks_constants/msys_rev_path_rewriter_constants.py +37 -0
- package/hooks/hooks_constants/post_tool_use_context.py +34 -0
- package/hooks/hooks_constants/pr_done_reminder_constants.py +0 -2
- package/hooks/hooks_constants/pre_tool_use_allow_output.py +48 -0
- package/hooks/hooks_constants/test_bash_post_call_dispatcher_constants.py +9 -2
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +9 -2
- package/hooks/hooks_constants/test_failure_recorder_constants.py +1 -2
- package/hooks/hooks_constants/test_post_tool_use_context.py +22 -0
- package/hooks/hooks_constants/test_pre_tool_use_allow_output.py +27 -0
- package/hooks/observability/test_failure_recorder.py +3 -1
- package/package.json +1 -1
- package/rules/ask-user-question-required.md +3 -3
- package/rules/code-standards.md +3 -2
- package/rules/confirm-implementation-forks.md +2 -0
- package/rules/destructive-commands.md +19 -19
- package/rules/doc-inventory-integrity.md +4 -4
- package/rules/docstring-prose-matches-implementation.md +2 -2
- package/rules/failure-blast-radius.md +4 -2
- package/rules/filesystem-search.md +1 -1
- package/rules/git-workflow.md +34 -0
- package/rules/long-horizon-autonomy.md +2 -0
- package/rules/nas-ssh-invocation.md +3 -3
- package/rules/no-cross-skill-duplicate-helpers.md +6 -6
- package/rules/orphan-css-class.md +5 -5
- package/rules/paired-test-coverage.md +6 -6
- package/rules/plain-illustrative-docstrings.md +3 -3
- package/rules/re-stage-before-commit.md +7 -7
- package/rules/retired-hook-prose.md +16 -0
- package/rules/shell-invocation.md +17 -1
- package/rules/testing.md +3 -5
- package/rules/verify-runtime-state.md +1 -1
- package/rules/windows-filesystem-safe.md +2 -2
- package/rules/workflow-substitution-slots.md +1 -1
- package/scripts/durable_post_lint_config/config/constants.py +0 -3
- package/scripts/durable_post_lint_config/config/test_constants.py +5 -0
- package/scripts/policy_lint/adapter_configuration.py +24 -0
- package/scripts/policy_lint/adapter_retired_hook_prose.py +201 -0
- package/scripts/policy_lint/adapters.py +36 -1
- package/scripts/policy_lint/config/constants.py +74 -0
- package/scripts/policy_lint/registry.py +5 -0
- package/scripts/policy_lint/selection.py +37 -18
- package/scripts/policy_lint/selection_git.py +6 -2
- package/scripts/test_durable_post_lint.py +42 -9
- package/scripts/tests/test_adapter_configuration.py +8 -0
- package/scripts/tests/test_policy_lint_rules_retired_hook_prose.py +197 -0
- package/scripts/tests/test_policy_lint_selection.py +107 -0
- package/hooks/hooks_constants/convergence_branch_constants.py +0 -8
- package/hooks/hooks_constants/destructive_command_environment_constants.py +0 -18
- package/hooks/hooks_constants/test_text_stripping.py +0 -39
- 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 `
|
|
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
|
|
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
|
-
`
|
|
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
|
|
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 =
|
|
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 =
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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 = """##
|
|
18
|
+
VALID_PR_BODY = """## Why
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
Why text.
|
|
21
21
|
|
|
22
|
-
##
|
|
22
|
+
## Scope
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
Scope text.
|
|
25
25
|
|
|
26
|
-
##
|
|
26
|
+
## Tradeoffs
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
Tradeoffs text.
|
|
29
|
+
|
|
30
|
+
## Blast Radius
|
|
31
|
+
|
|
32
|
+
Blast radius text.
|
|
33
|
+
|
|
34
|
+
## Verification
|
|
29
35
|
|
|
30
|
-
|
|
36
|
+
Verification text.
|
|
37
|
+
"""
|
|
38
|
+
MINIMAL_PR_BODY = """## Why
|
|
31
39
|
|
|
32
|
-
|
|
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
|
-
|
|
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"],
|