claude-dev-env 8.6.0 → 8.7.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/.agents/skills/privacy-hygiene/reference/sweep-procedure.md +1 -1
- package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +1 -0
- package/_shared/advisor/scripts/model_tier_run_validator.py +3 -2
- package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +35 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/terminology_sweep_constants.py +4 -0
- package/_shared/pr-loop/scripts/terminology_sweep.py +155 -18
- package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +130 -0
- package/bin/install.test.mjs +25 -0
- package/docs/high-trust-agent-delivery.md +132 -0
- package/hooks/advisory/refactor_guard.py +2 -2
- package/hooks/advisory/test_refactor_guard.py +21 -0
- package/hooks/blocking/code_rules_enforcer_test_support.py +10 -0
- package/hooks/blocking/code_rules_shared.py +4 -7
- package/hooks/blocking/conftest.py +22 -0
- package/hooks/blocking/state_description_blocker.py +232 -90
- package/hooks/blocking/test_code_rules_enforcer_duplicate_body.py +2 -11
- package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +27 -54
- package/hooks/blocking/test_code_rules_enforcer_test_support.py +21 -0
- package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +3 -19
- package/hooks/blocking/test_code_rules_shared.py +18 -0
- package/hooks/blocking/test_conftest.py +35 -0
- package/hooks/blocking/test_state_description_blocker.py +59 -0
- package/hooks/git-hooks/git_hooks_constants/__init__.py +1 -14
- package/hooks/git-hooks/pull_request_handoff.py +2 -2
- package/hooks/git-hooks/test_config.py +10 -3
- package/hooks/git-hooks/test_pull_request_handoff.py +49 -0
- package/hooks/git-hooks/test_verification_notice_state.py +50 -0
- package/hooks/git-hooks/verification_notice_state.py +3 -1
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +13 -5
- package/hooks/hooks_constants/refactor_guard_constants.py +2 -0
- package/hooks/hooks_constants/session_edit_stage_gate_constants.py +3 -4
- package/hooks/hooks_constants/state_description_blocker_constants.py +2 -1
- package/hooks/hooks_constants/test_state_description_blocker_constants.py +87 -0
- package/hooks/validators/exempt_paths.py +3 -18
- package/hooks/validators/test_exempt_paths.py +20 -0
- package/hooks/validators/test_python_antipattern_checks.py +14 -0
- package/package.json +1 -1
- package/rules/agent-merges-its-own-green-pull-request.md +54 -0
- package/rules/archiving-agent-config.md +10 -0
- package/rules/code-standards.md +1 -0
- package/rules/correction-lens.md +134 -0
- package/rules/flag-non-breaking-findings.md +11 -0
- package/rules/no-contrast-framing.md +68 -0
- package/rules/review-closure-is-a-check.md +44 -0
- package/scripts/Invoke-PesterSuites.ps1 +42 -0
- package/scripts/agent_merge_check.py +381 -0
- package/scripts/check.ps1 +12 -1
- package/scripts/ci/install-playtest.mjs +291 -0
- package/scripts/ci/install-playtest.test.mjs +84 -0
- package/scripts/ci/installer-lifecycle.mjs +11 -30
- package/scripts/ci/installer-lifecycle.test.mjs +14 -0
- package/scripts/ci/pstack-command-shim.test.mjs +33 -0
- package/scripts/ci/scratch-home-install.mjs +73 -0
- package/scripts/ci/scratch-home-install.test.mjs +49 -0
- package/scripts/codex_compat_materializer.py +33 -16
- package/scripts/contrast_framing.py +101 -0
- package/scripts/dev_env_scripts_constants/agent_merge_check_constants.py +121 -0
- package/scripts/dev_env_scripts_constants/contrast_framing_constants.py +82 -0
- package/scripts/dev_env_scripts_constants/merge_gate_check_constants.py +23 -0
- package/scripts/dev_env_scripts_constants/review_closure_constants.py +241 -0
- package/scripts/dev_env_scripts_constants/test_code_review_constants.py +12 -0
- package/scripts/dev_env_scripts_constants/test_contrast_framing_constants.py +60 -0
- package/scripts/durable_post_lint.py +30 -4
- package/scripts/followup_cli.py +6 -10
- package/scripts/merge_gate_checks.py +236 -0
- package/scripts/policy_lint/adapter_contrast_framing.py +59 -0
- package/scripts/policy_lint/adapter_detectors.py +1 -1
- package/scripts/policy_lint/adapter_support.py +3 -14
- package/scripts/policy_lint/adapters.py +30 -1
- package/scripts/policy_lint/config/constants.py +2 -0
- package/scripts/policy_lint/registry.py +5 -0
- package/scripts/repository_checks/config/constants.py +12 -0
- package/scripts/repository_checks/config/policy_document.py +28 -2
- package/scripts/repository_checks/followups.py +54 -0
- package/scripts/repository_checks/match_exemptions.py +67 -0
- package/scripts/repository_checks/models.py +46 -2
- package/scripts/repository_checks/reporting.py +23 -7
- package/scripts/repository_checks/tracked_secrets.py +2 -2
- package/scripts/repository_policy.py +4 -2
- package/scripts/review_closure.py +134 -0
- package/scripts/review_closure_github.py +345 -0
- package/scripts/review_closure_model.py +360 -0
- package/scripts/test_agent_merge_check.py +317 -0
- package/scripts/test_durable_post_lint.py +46 -0
- package/scripts/test_followup_cli.py +40 -0
- package/scripts/test_merge_gate_checks.py +92 -0
- package/scripts/tests/test_code_review_constants.py +78 -0
- package/scripts/tests/test_codex_compat_materializer.py +48 -4
- package/scripts/tests/test_contrast_framing.py +117 -0
- package/scripts/tests/test_followups.py +88 -0
- package/scripts/tests/test_match_exemptions.py +291 -0
- package/scripts/tests/test_models.py +67 -2
- package/scripts/tests/test_package_inventory.py +6 -4
- package/scripts/tests/test_pester_suites_run.py +53 -0
- package/scripts/tests/test_policy_document.py +39 -1
- package/scripts/tests/test_policy_lint_rules_contrast_framing.py +68 -0
- package/scripts/tests/test_policy_lint_rules_engine.py +4 -1
- package/scripts/tests/test_reporting.py +18 -0
- package/scripts/tests/test_repository_policy.py +43 -1
- package/scripts/tests/test_review_closure.py +126 -0
- package/scripts/tests/test_review_closure_github.py +240 -0
- package/scripts/tests/test_review_closure_model.py +299 -0
- package/scripts/tests/test_tracked_secrets.py +42 -0
- package/hooks/hooks_constants/precommit_code_rules_gate_constants.py +0 -12
- package/scripts/repository_checks/email_exemptions.py +0 -39
- package/scripts/tests/test_email_exemptions.py +0 -158
|
@@ -13,6 +13,7 @@ import json
|
|
|
13
13
|
import os
|
|
14
14
|
import sys
|
|
15
15
|
from pathlib import Path
|
|
16
|
+
from re import Pattern
|
|
16
17
|
from typing import TextIO
|
|
17
18
|
|
|
18
19
|
try:
|
|
@@ -21,6 +22,10 @@ try:
|
|
|
21
22
|
sys.path.insert(0, _hooks_dir)
|
|
22
23
|
|
|
23
24
|
from blocking.code_rules_shared import is_ephemeral_path
|
|
25
|
+
from hooks_constants.code_rules_enforcer_constants import (
|
|
26
|
+
JAVASCRIPT_BLOCK_COMMENT_CLOSER,
|
|
27
|
+
JAVASCRIPT_BLOCK_COMMENT_OPENER,
|
|
28
|
+
)
|
|
24
29
|
from hooks_constants.hook_block_logger import log_hook_block
|
|
25
30
|
from hooks_constants.multi_edit_reconstruction import joined_new_strings
|
|
26
31
|
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
|
|
@@ -33,10 +38,11 @@ try:
|
|
|
33
38
|
ALL_HASH_AND_SLASH_EXTENSIONS,
|
|
34
39
|
ALL_HASH_ONLY_EXTENSIONS,
|
|
35
40
|
ALL_MARKDOWN_EXTENSIONS,
|
|
36
|
-
|
|
41
|
+
DOUBLE_QUOTE_BODY_GROUP,
|
|
37
42
|
DOUBLE_QUOTED_SPAN_PATTERN,
|
|
38
43
|
INLINE_CODE_PATTERN,
|
|
39
44
|
PYTHON_EXTENSION,
|
|
45
|
+
SINGLE_QUOTE_BODY_GROUP,
|
|
40
46
|
TRIPLE_QUOTED_BLOCK_PATTERN,
|
|
41
47
|
)
|
|
42
48
|
except ImportError as import_error:
|
|
@@ -77,15 +83,56 @@ def _get_inline_markers(extension: str) -> tuple[str, ...]:
|
|
|
77
83
|
return ("//",)
|
|
78
84
|
|
|
79
85
|
|
|
80
|
-
def
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
86
|
+
def _consume_block_comment_open(
|
|
87
|
+
each_line_number: int, stripped: str
|
|
88
|
+
) -> tuple[list[tuple[int, str]], str | None, bool]:
|
|
89
|
+
"""Extract the comment span a ``/*``-opening line carries.
|
|
90
|
+
|
|
91
|
+
Returns the extracted entries, the leftover code text to keep scanning
|
|
92
|
+
on this same line (``None`` when the whole line was consumed), and
|
|
93
|
+
whether the comment is still open past this line.
|
|
94
|
+
"""
|
|
95
|
+
slash_star_index = stripped.find(JAVASCRIPT_BLOCK_COMMENT_OPENER)
|
|
96
|
+
close_star_index = stripped.find(
|
|
97
|
+
JAVASCRIPT_BLOCK_COMMENT_CLOSER, slash_star_index + len(JAVASCRIPT_BLOCK_COMMENT_OPENER)
|
|
98
|
+
)
|
|
99
|
+
if close_star_index < 0:
|
|
100
|
+
return [(each_line_number, stripped[slash_star_index:])], None, True
|
|
101
|
+
close_end = close_star_index + len(JAVASCRIPT_BLOCK_COMMENT_CLOSER)
|
|
102
|
+
entry = (each_line_number, stripped[slash_star_index:close_end])
|
|
103
|
+
after_close = stripped[close_end:].lstrip()
|
|
104
|
+
return [entry], (after_close or None), False
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _consume_block_comment_continuation(
|
|
108
|
+
each_line_number: int, stripped: str
|
|
109
|
+
) -> tuple[list[tuple[int, str]], bool, bool]:
|
|
110
|
+
"""Extract the comment span a line inside an already-open block carries.
|
|
111
|
+
|
|
112
|
+
Returns the extracted entries, whether the comment is still open past
|
|
113
|
+
this line, and whether the caller should move to the next source line.
|
|
114
|
+
"""
|
|
115
|
+
close_index = stripped.find(JAVASCRIPT_BLOCK_COMMENT_CLOSER)
|
|
116
|
+
if close_index < 0:
|
|
117
|
+
return [(each_line_number, stripped)], True, True
|
|
118
|
+
close_end = close_index + len(JAVASCRIPT_BLOCK_COMMENT_CLOSER)
|
|
119
|
+
return [(each_line_number, stripped[:close_end])], False, False
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _extract_comment_lines(text: str, extension: str = "") -> list[tuple[int, str]]:
|
|
123
|
+
"""Return each comment line as ``(source_line_number, comment_text)``.
|
|
124
|
+
|
|
125
|
+
Covers Python (``#``), JS/TS/C/Rust/Go (``//``), and block comments, and
|
|
126
|
+
tags every extracted line with the source line it came from so a
|
|
127
|
+
matching pattern can be pinned to that line rather than searched for
|
|
128
|
+
again.
|
|
129
|
+
"""
|
|
130
|
+
all_comment_lines: list[tuple[int, str]] = []
|
|
84
131
|
|
|
85
132
|
is_in_block_comment = False
|
|
86
133
|
has_block_comments = extension in ALL_BLOCK_COMMENT_EXTENSIONS
|
|
87
134
|
all_inline_markers = _get_inline_markers(extension)
|
|
88
|
-
for each_line in
|
|
135
|
+
for each_line_number, each_line in enumerate(text.splitlines(), 1):
|
|
89
136
|
stripped = each_line.strip()
|
|
90
137
|
|
|
91
138
|
if has_block_comments:
|
|
@@ -93,42 +140,33 @@ def _extract_comment_lines(text: str, extension: str = "") -> list[str]:
|
|
|
93
140
|
stripped.startswith(each_marker)
|
|
94
141
|
for each_marker in all_inline_markers
|
|
95
142
|
):
|
|
96
|
-
all_comment_lines.append(stripped)
|
|
143
|
+
all_comment_lines.append((each_line_number, stripped))
|
|
97
144
|
continue
|
|
98
|
-
if
|
|
99
|
-
is_in_block_comment =
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
stripped[slash_star_index : close_star_index + 2]
|
|
105
|
-
)
|
|
106
|
-
is_in_block_comment = False
|
|
107
|
-
after_close = stripped[close_star_index + 2:].lstrip()
|
|
108
|
-
if not after_close:
|
|
109
|
-
continue
|
|
110
|
-
stripped = after_close
|
|
111
|
-
else:
|
|
112
|
-
all_comment_lines.append(stripped[slash_star_index:])
|
|
145
|
+
if JAVASCRIPT_BLOCK_COMMENT_OPENER in stripped and not is_in_block_comment:
|
|
146
|
+
entries, leftover, is_in_block_comment = _consume_block_comment_open(
|
|
147
|
+
each_line_number, stripped
|
|
148
|
+
)
|
|
149
|
+
all_comment_lines.extend(entries)
|
|
150
|
+
if leftover is None:
|
|
113
151
|
continue
|
|
152
|
+
stripped = leftover
|
|
114
153
|
if is_in_block_comment:
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
all_comment_lines.append(stripped)
|
|
154
|
+
entries, is_in_block_comment, should_continue = (
|
|
155
|
+
_consume_block_comment_continuation(each_line_number, stripped)
|
|
156
|
+
)
|
|
157
|
+
all_comment_lines.extend(entries)
|
|
158
|
+
if should_continue:
|
|
121
159
|
continue
|
|
122
160
|
|
|
123
161
|
if any(
|
|
124
162
|
stripped.startswith(each_marker) for each_marker in all_inline_markers
|
|
125
163
|
):
|
|
126
|
-
all_comment_lines.append(stripped)
|
|
164
|
+
all_comment_lines.append((each_line_number, stripped))
|
|
127
165
|
continue
|
|
128
166
|
|
|
129
167
|
inline_index = _find_inline_comment_start(stripped, all_inline_markers)
|
|
130
168
|
if inline_index is not None and inline_index > 0:
|
|
131
|
-
all_comment_lines.append(stripped[inline_index:])
|
|
169
|
+
all_comment_lines.append((each_line_number, stripped[inline_index:]))
|
|
132
170
|
continue
|
|
133
171
|
|
|
134
172
|
return all_comment_lines
|
|
@@ -154,16 +192,18 @@ def _find_inline_comment_start(stripped: str, all_markers: tuple[str, ...]) -> i
|
|
|
154
192
|
return best_position
|
|
155
193
|
|
|
156
194
|
|
|
157
|
-
def _extract_parsed_docstrings(tree: ast.Module) -> list[str]:
|
|
158
|
-
"""
|
|
195
|
+
def _extract_parsed_docstrings(tree: ast.Module) -> list[tuple[int, str]]:
|
|
196
|
+
"""Return each docstring as ``(start_line, docstring_text)``.
|
|
159
197
|
|
|
160
198
|
Args:
|
|
161
199
|
tree: The parsed module tree to walk.
|
|
162
200
|
|
|
163
201
|
Returns:
|
|
164
|
-
|
|
202
|
+
One entry per docstring found on the module and its class/function
|
|
203
|
+
definitions, tagged with the source line the docstring's opening
|
|
204
|
+
quotes start on.
|
|
165
205
|
"""
|
|
166
|
-
all_found_docstrings: list[str] = []
|
|
206
|
+
all_found_docstrings: list[tuple[int, str]] = []
|
|
167
207
|
for each_node in ast.walk(tree):
|
|
168
208
|
if not isinstance(
|
|
169
209
|
each_node, (ast.Module, ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)
|
|
@@ -171,102 +211,204 @@ def _extract_parsed_docstrings(tree: ast.Module) -> list[str]:
|
|
|
171
211
|
continue
|
|
172
212
|
maybe_docstring = ast.get_docstring(each_node, clean=False)
|
|
173
213
|
if maybe_docstring:
|
|
174
|
-
|
|
214
|
+
docstring_statement = each_node.body[0]
|
|
215
|
+
all_found_docstrings.append((docstring_statement.lineno, maybe_docstring))
|
|
175
216
|
return all_found_docstrings
|
|
176
217
|
|
|
177
218
|
|
|
178
|
-
def
|
|
179
|
-
"""
|
|
219
|
+
def _is_docstring_position(leading_text: str) -> bool:
|
|
220
|
+
"""Decide whether the text before a triple-quoted block marks a docstring slot.
|
|
180
221
|
|
|
181
222
|
A triple-quoted block counts as a docstring when it opens the fragment
|
|
182
|
-
(only whitespace before it) or when the nearest preceding non-blank line
|
|
183
|
-
a def/class header ending in a colon. A block assigned to a name or
|
|
184
|
-
elsewhere in the fragment is data
|
|
223
|
+
(only whitespace before it) or when the nearest preceding non-blank line
|
|
224
|
+
is a def/class header ending in a colon. A block assigned to a name or
|
|
225
|
+
sitting elsewhere in the fragment is data.
|
|
226
|
+
"""
|
|
227
|
+
if not leading_text.strip():
|
|
228
|
+
return True
|
|
229
|
+
if leading_text.rpartition("\n")[2].strip():
|
|
230
|
+
return False
|
|
231
|
+
all_preceding_lines = [
|
|
232
|
+
each_line for each_line in leading_text.splitlines() if each_line.strip()
|
|
233
|
+
]
|
|
234
|
+
if not all_preceding_lines:
|
|
235
|
+
return False
|
|
236
|
+
last_preceding_line = all_preceding_lines[-1].strip()
|
|
237
|
+
return last_preceding_line.startswith(
|
|
238
|
+
ALL_DEFINITION_HEADER_PREFIXES
|
|
239
|
+
) and last_preceding_line.endswith(":")
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _extract_fragment_docstrings(text: str) -> list[tuple[int, str]]:
|
|
243
|
+
"""Return docstring-positioned triple-quoted blocks from an unparseable fragment.
|
|
185
244
|
|
|
186
245
|
Args:
|
|
187
246
|
text: The Python source fragment that failed to parse.
|
|
188
247
|
|
|
189
248
|
Returns:
|
|
190
|
-
|
|
249
|
+
One ``(start_line, body_text)`` pair per docstring-positioned
|
|
250
|
+
triple-quoted block, tagged with the source line its opening quotes
|
|
251
|
+
start on.
|
|
191
252
|
"""
|
|
192
|
-
all_found_docstrings: list[str] = []
|
|
253
|
+
all_found_docstrings: list[tuple[int, str]] = []
|
|
193
254
|
for each_match in TRIPLE_QUOTED_BLOCK_PATTERN.finditer(text):
|
|
194
|
-
body =
|
|
255
|
+
body = (
|
|
256
|
+
each_match.group(DOUBLE_QUOTE_BODY_GROUP)
|
|
257
|
+
if each_match.group(DOUBLE_QUOTE_BODY_GROUP) is not None
|
|
258
|
+
else each_match.group(SINGLE_QUOTE_BODY_GROUP)
|
|
259
|
+
)
|
|
260
|
+
start_line = text.count("\n", 0, each_match.start()) + 1
|
|
195
261
|
leading_text = text[: each_match.start()]
|
|
196
|
-
if
|
|
197
|
-
all_found_docstrings.append(body)
|
|
198
|
-
continue
|
|
199
|
-
if leading_text.rpartition("\n")[2].strip():
|
|
200
|
-
continue
|
|
201
|
-
all_preceding_lines = [
|
|
202
|
-
each_line for each_line in leading_text.splitlines() if each_line.strip()
|
|
203
|
-
]
|
|
204
|
-
last_preceding_line = all_preceding_lines[-1].strip()
|
|
205
|
-
if last_preceding_line.startswith(
|
|
206
|
-
ALL_DEFINITION_HEADER_PREFIXES
|
|
207
|
-
) and last_preceding_line.endswith(":"):
|
|
208
|
-
all_found_docstrings.append(body)
|
|
262
|
+
if _is_docstring_position(leading_text):
|
|
263
|
+
all_found_docstrings.append((start_line, body))
|
|
209
264
|
return all_found_docstrings
|
|
210
265
|
|
|
211
266
|
|
|
212
|
-
def
|
|
213
|
-
"""Return
|
|
267
|
+
def _docstring_scan_lines(text: str) -> list[tuple[int, str]]:
|
|
268
|
+
"""Return each docstring's prose as ``(source_line_number, line_text)`` pairs.
|
|
214
269
|
|
|
215
270
|
Parses the source when valid, or falls back to the docstring-positioned
|
|
216
271
|
triple-quoted blocks of a mid-edit fragment. Double-quoted and backticked
|
|
217
272
|
spans inside each docstring are mentions rather than uses, so both are
|
|
218
|
-
stripped
|
|
273
|
+
stripped before the docstring is split back into its own source lines.
|
|
219
274
|
|
|
220
275
|
Args:
|
|
221
276
|
text: The Python source or fragment under scan.
|
|
222
277
|
|
|
223
278
|
Returns:
|
|
224
|
-
|
|
279
|
+
One entry per physical line of docstring prose, numbered against the
|
|
280
|
+
source file so a pattern match on that line carries the line the
|
|
281
|
+
rule matched rather than an unrelated occurrence elsewhere in the
|
|
282
|
+
file.
|
|
225
283
|
"""
|
|
226
284
|
try:
|
|
227
285
|
all_found_docstrings = _extract_parsed_docstrings(ast.parse(text))
|
|
228
286
|
except (SyntaxError, ValueError):
|
|
229
287
|
all_found_docstrings = _extract_fragment_docstrings(text)
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
288
|
+
all_scan_lines: list[tuple[int, str]] = []
|
|
289
|
+
for each_start_line, each_docstring in all_found_docstrings:
|
|
290
|
+
prose = DOUBLE_QUOTED_SPAN_PATTERN.sub(
|
|
291
|
+
"", INLINE_CODE_PATTERN.sub("", each_docstring)
|
|
292
|
+
)
|
|
293
|
+
for each_offset, each_prose_line in enumerate(prose.split("\n")):
|
|
294
|
+
all_scan_lines.append((each_start_line + each_offset, each_prose_line))
|
|
295
|
+
return all_scan_lines
|
|
235
296
|
|
|
236
297
|
|
|
237
|
-
def
|
|
238
|
-
"""Return
|
|
298
|
+
def _markdown_scan_lines(text: str) -> list[tuple[int, str]]:
|
|
299
|
+
"""Return each Markdown source line as ``(line_number, scannable_text)``.
|
|
239
300
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
301
|
+
A fenced code block's content is data rather than prose, so a line
|
|
302
|
+
between a pair of ``` fence markers is skipped. An inline code span is a
|
|
303
|
+
mention rather than a use, so it is blanked out of the line it appears
|
|
304
|
+
on, the same way it is blanked from Python docstring prose.
|
|
305
|
+
|
|
306
|
+
Args:
|
|
307
|
+
text: The Markdown source to scan.
|
|
308
|
+
|
|
309
|
+
Returns:
|
|
310
|
+
One entry per source line outside a fenced code block, with its
|
|
311
|
+
inline code spans removed.
|
|
312
|
+
"""
|
|
313
|
+
all_scan_lines: list[tuple[int, str]] = []
|
|
314
|
+
is_inside_fence = False
|
|
315
|
+
for each_line_number, each_line in enumerate(text.splitlines(), 1):
|
|
316
|
+
if each_line.strip().startswith("```"):
|
|
317
|
+
is_inside_fence = not is_inside_fence
|
|
318
|
+
continue
|
|
319
|
+
if is_inside_fence:
|
|
320
|
+
continue
|
|
321
|
+
all_scan_lines.append(
|
|
322
|
+
(each_line_number, INLINE_CODE_PATTERN.sub("", each_line))
|
|
323
|
+
)
|
|
324
|
+
return all_scan_lines
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _first_pattern_match(
|
|
328
|
+
pattern: Pattern[str], all_scan_lines: list[tuple[int, str]]
|
|
329
|
+
) -> tuple[str, int] | None:
|
|
330
|
+
"""Return the first line a pattern matches, or None when it matches none."""
|
|
331
|
+
for each_line_number, each_line_text in all_scan_lines:
|
|
332
|
+
each_match = pattern.search(each_line_text)
|
|
333
|
+
if each_match is not None:
|
|
334
|
+
return (each_match.group(0).strip().lower(), each_line_number)
|
|
335
|
+
return None
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _scan_lines_for_file(
|
|
339
|
+
text: str, extension: str, file_path: str
|
|
340
|
+
) -> list[tuple[int, str]] | None:
|
|
341
|
+
"""Return the file's scannable lines, or ``None`` when its kind carries no prose.
|
|
342
|
+
|
|
343
|
+
A ``.md`` file scans every source line outside a fenced code block. A
|
|
344
|
+
code file scans its comment lines, and a Python file also scans its
|
|
345
|
+
module, class, and function docstrings.
|
|
243
346
|
"""
|
|
244
|
-
extension = _get_file_extension(file_path)
|
|
245
347
|
if is_markdown_file(file_path):
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
348
|
+
return _markdown_scan_lines(text)
|
|
349
|
+
if not is_comment_bearing_file(file_path):
|
|
350
|
+
return None
|
|
351
|
+
all_scan_lines = _extract_comment_lines(text, extension)
|
|
352
|
+
if extension == PYTHON_EXTENSION:
|
|
353
|
+
return all_scan_lines + _docstring_scan_lines(text)
|
|
354
|
+
return all_scan_lines
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _matched_transition_patterns(
|
|
358
|
+
all_scan_lines: list[tuple[int, str]],
|
|
359
|
+
) -> list[tuple[str, int]]:
|
|
360
|
+
"""Return each transition pattern the scan lines match, with its line."""
|
|
361
|
+
all_detected: list[tuple[str, int]] = []
|
|
362
|
+
for each_pattern in ALL_COMMENT_TRANSITION_PATTERNS:
|
|
363
|
+
matched_entry = _first_pattern_match(each_pattern, all_scan_lines)
|
|
364
|
+
if matched_entry is not None:
|
|
365
|
+
all_detected.append(matched_entry)
|
|
366
|
+
return all_detected
|
|
254
367
|
|
|
255
|
-
if is_markdown_file(file_path):
|
|
256
|
-
scan_text = CODE_FENCE_PATTERN.sub("", scan_text)
|
|
257
|
-
scan_text = INLINE_CODE_PATTERN.sub("", scan_text)
|
|
258
368
|
|
|
259
|
-
|
|
369
|
+
def find_violations_with_lines(text: str, file_path: str) -> list[tuple[str, int]]:
|
|
370
|
+
"""Return each violated pattern with the source line the rule matched.
|
|
371
|
+
|
|
372
|
+
::
|
|
373
|
+
|
|
374
|
+
# config.py
|
|
375
|
+
1 fixture_text = "the field was `previously` required"
|
|
376
|
+
4 def read_field():
|
|
377
|
+
5 \"\"\"Read the field.
|
|
378
|
+
7 `The field was previously required.`
|
|
379
|
+
8 \"\"\"
|
|
380
|
+
flag: ("previously", 7) and ("was previously", 7) -- one per pattern
|
|
381
|
+
ok: line 1 never reported -- a plain string literal is fixture data, not prose
|
|
382
|
+
|
|
383
|
+
Each match is paired with the line it matched on, not an earlier mention
|
|
384
|
+
of the same words.
|
|
385
|
+
|
|
386
|
+
Args:
|
|
387
|
+
text: The file content to scan.
|
|
388
|
+
file_path: The path that selects the scan strategy for the file.
|
|
389
|
+
|
|
390
|
+
Returns:
|
|
391
|
+
One ``(matched_phrase, source_line_number)`` pair per matched
|
|
392
|
+
pattern, in pattern-declaration order, at most one match each.
|
|
393
|
+
"""
|
|
394
|
+
extension = _get_file_extension(file_path)
|
|
395
|
+
all_scan_lines = _scan_lines_for_file(text, extension, file_path)
|
|
396
|
+
if all_scan_lines is None:
|
|
260
397
|
return []
|
|
398
|
+
return _matched_transition_patterns(all_scan_lines)
|
|
261
399
|
|
|
262
|
-
all_detected: list[str] = []
|
|
263
|
-
all_transition_patterns = ALL_COMMENT_TRANSITION_PATTERNS
|
|
264
|
-
for each_pattern in all_transition_patterns:
|
|
265
|
-
all_matches = each_pattern.findall(scan_text)
|
|
266
|
-
if all_matches:
|
|
267
|
-
all_detected.append(all_matches[0].strip().lower())
|
|
268
400
|
|
|
269
|
-
|
|
401
|
+
def find_violations(text: str, file_path: str) -> list[str]:
|
|
402
|
+
"""Return all violated patterns found in text for the given file.
|
|
403
|
+
|
|
404
|
+
For .md files, scans the entire text. For code files, scans comment lines,
|
|
405
|
+
and for Python files also scans module/class/function docstrings.
|
|
406
|
+
Returns a list of matched pattern source strings.
|
|
407
|
+
"""
|
|
408
|
+
return [
|
|
409
|
+
each_phrase
|
|
410
|
+
for each_phrase, _each_line_number in find_violations_with_lines(text, file_path)
|
|
411
|
+
]
|
|
270
412
|
|
|
271
413
|
|
|
272
414
|
def _build_deny_reason(file_path: str, all_detected_patterns: list[str]) -> str:
|
|
@@ -24,6 +24,8 @@ from collections.abc import Iterator
|
|
|
24
24
|
|
|
25
25
|
import pytest
|
|
26
26
|
|
|
27
|
+
from code_rules_enforcer_test_support import STRIP_CODE_AND_QUOTES_SOURCE as SHARED_HELPER_SOURCE
|
|
28
|
+
|
|
27
29
|
_HOOK_DIRECTORY = pathlib.Path(__file__).parent
|
|
28
30
|
if str(_HOOK_DIRECTORY) not in sys.path:
|
|
29
31
|
sys.path.insert(0, str(_HOOK_DIRECTORY))
|
|
@@ -39,17 +41,6 @@ _hook_spec.loader.exec_module(_hook_module)
|
|
|
39
41
|
check_duplicate_function_body_across_files = _hook_module.check_duplicate_function_body_across_files
|
|
40
42
|
|
|
41
43
|
|
|
42
|
-
SHARED_HELPER_SOURCE = (
|
|
43
|
-
"import re\n"
|
|
44
|
-
"\n"
|
|
45
|
-
"def strip_code_and_quotes(text: str) -> str:\n"
|
|
46
|
-
" without_fences = re.sub(r'```.*?```', '', text, flags=re.DOTALL)\n"
|
|
47
|
-
" without_inline = re.sub(r'`[^`]*`', '', without_fences)\n"
|
|
48
|
-
" without_quotes = re.sub(r'(?m)^>.*$', '', without_inline)\n"
|
|
49
|
-
" return without_quotes.strip()\n"
|
|
50
|
-
)
|
|
51
|
-
|
|
52
|
-
|
|
53
44
|
@pytest.fixture
|
|
54
45
|
def module_dir() -> Iterator[pathlib.Path]:
|
|
55
46
|
base_directory = pathlib.Path(tempfile.mkdtemp())
|
|
@@ -1,25 +1,23 @@
|
|
|
1
|
-
"""Entry-point tests proving the duplicate-body check
|
|
1
|
+
"""Entry-point tests proving the duplicate-body check's lane assignment for hook files.
|
|
2
2
|
|
|
3
3
|
The cross-file duplicate-body check exists to catch a helper copied across sibling
|
|
4
4
|
modules in the ``blocking/`` hook directory itself — the exact directory the rest of
|
|
5
|
-
the code-rules suite exempts.
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
5
|
+
the code-rules suite exempts. CODE_RULES.md §11.6 assigns it by scope: it reads a
|
|
6
|
+
sibling file, so it runs on the full gate only, and a hook-infrastructure target's
|
|
7
|
+
live PreToolUse Write stays on the edit lane. These tests drive the shipped entry
|
|
8
|
+
points (the ``main()`` stdin path and the pre-check CLI) with a hook-infrastructure
|
|
9
|
+
target, so each lane's behavior is proven on the same path a live Write or a
|
|
10
|
+
pre-check run would take, rather than by calling the check function directly.
|
|
11
|
+
|
|
12
|
+
Each test takes the ``hook_blocking_dir`` fixture from ``conftest.py``, which builds
|
|
13
|
+
a temporary tree matching the production hook directory so ``is_hook_infrastructure``
|
|
14
|
+
routes a target inside it the same way it would for the production directory.
|
|
13
15
|
"""
|
|
14
16
|
|
|
15
17
|
from __future__ import annotations
|
|
16
18
|
|
|
17
|
-
import json
|
|
18
19
|
import pathlib
|
|
19
|
-
import shutil
|
|
20
20
|
import sys
|
|
21
|
-
import tempfile
|
|
22
|
-
from collections.abc import Iterator
|
|
23
21
|
|
|
24
22
|
import pytest
|
|
25
23
|
|
|
@@ -32,35 +30,13 @@ if str(_HOOKS_PARENT) not in sys.path:
|
|
|
32
30
|
|
|
33
31
|
from code_rules_enforcer import main # noqa: E402
|
|
34
32
|
from code_rules_enforcer_test_support import ( # noqa: E402
|
|
33
|
+
STRIP_CODE_AND_QUOTES_SOURCE as SHARED_HELPER_SOURCE,
|
|
35
34
|
run_precheck,
|
|
36
35
|
run_write_entrypoint,
|
|
37
36
|
)
|
|
38
37
|
|
|
39
38
|
pytestmark = pytest.mark.usefixtures("ephemeral_exempt_off")
|
|
40
39
|
|
|
41
|
-
SHARED_HELPER_SOURCE = (
|
|
42
|
-
"import re\n"
|
|
43
|
-
"\n"
|
|
44
|
-
"def strip_code_and_quotes(text: str) -> str:\n"
|
|
45
|
-
" without_fences = re.sub(r'```.*?```', '', text, flags=re.DOTALL)\n"
|
|
46
|
-
" without_inline = re.sub(r'`[^`]*`', '', without_fences)\n"
|
|
47
|
-
" without_quotes = re.sub(r'(?m)^>.*$', '', without_inline)\n"
|
|
48
|
-
" return without_quotes.strip()\n"
|
|
49
|
-
)
|
|
50
|
-
|
|
51
|
-
_HOOK_INFRASTRUCTURE_TAIL = pathlib.Path("packages") / "claude-dev-env" / "hooks" / "blocking"
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
@pytest.fixture
|
|
55
|
-
def hook_blocking_dir() -> Iterator[pathlib.Path]:
|
|
56
|
-
base_directory = pathlib.Path(tempfile.mkdtemp())
|
|
57
|
-
blocking_directory = base_directory / _HOOK_INFRASTRUCTURE_TAIL
|
|
58
|
-
blocking_directory.mkdir(parents=True)
|
|
59
|
-
try:
|
|
60
|
-
yield blocking_directory
|
|
61
|
-
finally:
|
|
62
|
-
shutil.rmtree(base_directory, ignore_errors=False)
|
|
63
|
-
|
|
64
40
|
|
|
65
41
|
def _run_main_with_write_payload(
|
|
66
42
|
file_path: str,
|
|
@@ -75,27 +51,22 @@ def _run_main_with_write_payload(
|
|
|
75
51
|
return captured_stdout
|
|
76
52
|
|
|
77
53
|
|
|
78
|
-
def
|
|
54
|
+
def test_write_of_copied_helper_into_hook_directory_does_not_deny(
|
|
79
55
|
hook_blocking_dir: pathlib.Path,
|
|
80
56
|
) -> None:
|
|
81
|
-
"""A Write that copies a sibling helper into a second hook file is denied.
|
|
57
|
+
"""A live Write that copies a sibling helper into a second hook file is not denied.
|
|
82
58
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
59
|
+
Enforces CODE_RULES.md §11.6: "A check that reads a file other than the
|
|
60
|
+
target runs on the full gate." The cross-file duplicate-body check reads a
|
|
61
|
+
sibling module, so it is full-gate only; the live PreToolUse Write that
|
|
62
|
+
``main()`` handles runs the edit lane, so it must not deny here even though
|
|
63
|
+
the copied helper is present."""
|
|
86
64
|
(hook_blocking_dir / "existing_blocker.py").write_text(SHARED_HELPER_SOURCE, encoding="utf-8")
|
|
87
65
|
new_file = hook_blocking_dir / "new_blocker.py"
|
|
88
66
|
stdout = _run_main_with_write_payload(str(new_file), SHARED_HELPER_SOURCE)
|
|
89
|
-
assert stdout
|
|
90
|
-
"
|
|
91
|
-
"
|
|
92
|
-
)
|
|
93
|
-
deny_payload = json.loads(stdout)
|
|
94
|
-
decision = deny_payload["hookSpecificOutput"]["permissionDecision"]
|
|
95
|
-
reason = deny_payload["hookSpecificOutput"]["permissionDecisionReason"]
|
|
96
|
-
assert decision == "deny", f"expected deny, got: {decision!r}"
|
|
97
|
-
assert "strip_code_and_quotes" in reason, (
|
|
98
|
-
f"the deny reason must name the duplicated helper, got: {reason!r}"
|
|
67
|
+
assert stdout == "", (
|
|
68
|
+
"the edit lane must not run the cross-file duplicate-body check, "
|
|
69
|
+
f"got stdout: {stdout!r}"
|
|
99
70
|
)
|
|
100
71
|
|
|
101
72
|
|
|
@@ -124,9 +95,11 @@ def test_precheck_of_copied_helper_at_hook_target_exits_nonzero(
|
|
|
124
95
|
) -> None:
|
|
125
96
|
"""The pre-check CLI flags a copied helper judged at a hook-infrastructure target.
|
|
126
97
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
98
|
+
Enforces CODE_RULES.md §11.6: hook-infrastructure targets "run ... the
|
|
99
|
+
whole roster on the full gate." The pre-check CLI runs the full gate, so
|
|
100
|
+
driving the shipped ``--check`` argv path proves it still routes a hook
|
|
101
|
+
``.py`` target through the duplicate-body check rather than exiting clean
|
|
102
|
+
on the blanket hook-infrastructure exemption."""
|
|
130
103
|
(hook_blocking_dir / "existing_blocker.py").write_text(SHARED_HELPER_SOURCE, encoding="utf-8")
|
|
131
104
|
staging_directory = tmp_path
|
|
132
105
|
candidate_file = staging_directory / "candidate.py"
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Tests for the shared constants this directory's test-support module provides."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from typing import cast
|
|
7
|
+
|
|
8
|
+
from code_rules_enforcer_test_support import STRIP_CODE_AND_QUOTES_SOURCE
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def should_define_a_strip_code_and_quotes_function_that_removes_fenced_code() -> None:
|
|
12
|
+
module_namespace: dict[str, object] = {}
|
|
13
|
+
exec(STRIP_CODE_AND_QUOTES_SOURCE, module_namespace)
|
|
14
|
+
strip_code_and_quotes = cast("Callable[[str], str]", module_namespace["strip_code_and_quotes"])
|
|
15
|
+
|
|
16
|
+
stripped = strip_code_and_quotes("keep this\n```\ncode block\n```\n> a quote\nkeep this too")
|
|
17
|
+
|
|
18
|
+
assert "code block" not in stripped
|
|
19
|
+
assert "a quote" not in stripped
|
|
20
|
+
assert "keep this" in stripped
|
|
21
|
+
assert "keep this too" in stripped
|
|
@@ -7,19 +7,16 @@ through ``validate_content_for_edit_lane``, which hook files never reach at PreT
|
|
|
7
7
|
tests drive the real ``main()`` stdin entry point and the pre-check CLI with a
|
|
8
8
|
hook-infrastructure target.
|
|
9
9
|
|
|
10
|
-
Each test
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
Each test takes the ``hook_blocking_dir`` fixture from ``conftest.py``, which builds
|
|
11
|
+
a temporary tree matching the production hook directory so ``is_hook_infrastructure``
|
|
12
|
+
routes a target inside it the same way it would for the production directory.
|
|
13
13
|
"""
|
|
14
14
|
|
|
15
15
|
from __future__ import annotations
|
|
16
16
|
|
|
17
17
|
import json
|
|
18
18
|
import pathlib
|
|
19
|
-
import shutil
|
|
20
19
|
import sys
|
|
21
|
-
import tempfile
|
|
22
|
-
from collections.abc import Iterator
|
|
23
20
|
|
|
24
21
|
import pytest
|
|
25
22
|
|
|
@@ -46,19 +43,6 @@ PASS_THROUGH_ALIAS_SOURCE = (
|
|
|
46
43
|
" return find_bare_path_segments(content)\n"
|
|
47
44
|
)
|
|
48
45
|
|
|
49
|
-
_HOOK_INFRASTRUCTURE_TAIL = pathlib.Path("packages") / "claude-dev-env" / "hooks" / "blocking"
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
@pytest.fixture
|
|
53
|
-
def hook_blocking_dir() -> Iterator[pathlib.Path]:
|
|
54
|
-
base_directory = pathlib.Path(tempfile.mkdtemp())
|
|
55
|
-
blocking_directory = base_directory / _HOOK_INFRASTRUCTURE_TAIL
|
|
56
|
-
blocking_directory.mkdir(parents=True)
|
|
57
|
-
try:
|
|
58
|
-
yield blocking_directory
|
|
59
|
-
finally:
|
|
60
|
-
shutil.rmtree(base_directory, ignore_errors=False)
|
|
61
|
-
|
|
62
46
|
|
|
63
47
|
def test_write_of_pass_through_alias_into_hook_directory_denies(
|
|
64
48
|
hook_blocking_dir: pathlib.Path,
|