claude-dev-env 8.5.0 → 8.6.1
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/_shared/pr-loop/scripts/pr_loop_shared_constants/terminology_sweep_constants.py +4 -0
- package/_shared/pr-loop/scripts/terminology_sweep.py +154 -18
- package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +117 -0
- package/bin/cde.mjs +40 -6
- package/bin/cde.test.mjs +25 -0
- package/commands/fix-followups.md +15 -8
- package/hooks/blocking/state_description_blocker.py +230 -90
- package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +23 -25
- package/hooks/blocking/test_state_description_blocker.py +43 -0
- package/hooks/followup_ledger.py +109 -4
- package/hooks/hooks_constants/followup_ledger_constants.py +15 -0
- package/hooks/hooks_constants/state_description_blocker_constants.py +5 -1
- package/hooks/hooks_constants/test_state_description_blocker_constants.py +85 -0
- package/hooks/test_followup_ledger.py +92 -4
- package/package.json +1 -1
- package/rules/flag-non-breaking-findings.md +35 -2
- package/scripts/dev_env_scripts_constants/followup_constants.py +21 -4
- package/scripts/followup_cli.py +78 -4
- package/scripts/policy_lint/adapter_detectors.py +1 -1
- package/scripts/policy_lint/adapter_support.py +11 -15
- package/scripts/policy_lint/adapters.py +0 -1
- package/scripts/policy_lint/check_catalog.py +44 -0
- package/scripts/policy_lint/config/check_catalog_constants.py +59 -0
- package/scripts/policy_lint/model.py +4 -1
- package/scripts/test_followup_cli.py +116 -2
- package/scripts/test_validate_instruction_pairs.py +23 -1
- package/scripts/tests/test_policy_lint_rules_check_catalog.py +151 -0
- package/scripts/tests/test_policy_lint_rules_engine.py +4 -1
- package/scripts/validate_instruction_pairs.py +9 -2
|
@@ -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:
|
|
@@ -33,10 +34,13 @@ try:
|
|
|
33
34
|
ALL_HASH_AND_SLASH_EXTENSIONS,
|
|
34
35
|
ALL_HASH_ONLY_EXTENSIONS,
|
|
35
36
|
ALL_MARKDOWN_EXTENSIONS,
|
|
36
|
-
|
|
37
|
+
BLOCK_COMMENT_CLOSE_MARKER,
|
|
38
|
+
BLOCK_COMMENT_OPEN_MARKER,
|
|
39
|
+
DOUBLE_QUOTE_BODY_GROUP,
|
|
37
40
|
DOUBLE_QUOTED_SPAN_PATTERN,
|
|
38
41
|
INLINE_CODE_PATTERN,
|
|
39
42
|
PYTHON_EXTENSION,
|
|
43
|
+
SINGLE_QUOTE_BODY_GROUP,
|
|
40
44
|
TRIPLE_QUOTED_BLOCK_PATTERN,
|
|
41
45
|
)
|
|
42
46
|
except ImportError as import_error:
|
|
@@ -77,15 +81,56 @@ def _get_inline_markers(extension: str) -> tuple[str, ...]:
|
|
|
77
81
|
return ("//",)
|
|
78
82
|
|
|
79
83
|
|
|
80
|
-
def
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
+
def _consume_block_comment_open(
|
|
85
|
+
each_line_number: int, stripped: str
|
|
86
|
+
) -> tuple[list[tuple[int, str]], str | None, bool]:
|
|
87
|
+
"""Extract the comment span a ``/*``-opening line carries.
|
|
88
|
+
|
|
89
|
+
Returns the extracted entries, the leftover code text to keep scanning
|
|
90
|
+
on this same line (``None`` when the whole line was consumed), and
|
|
91
|
+
whether the comment is still open past this line.
|
|
92
|
+
"""
|
|
93
|
+
slash_star_index = stripped.find(BLOCK_COMMENT_OPEN_MARKER)
|
|
94
|
+
close_star_index = stripped.find(
|
|
95
|
+
BLOCK_COMMENT_CLOSE_MARKER, slash_star_index + len(BLOCK_COMMENT_OPEN_MARKER)
|
|
96
|
+
)
|
|
97
|
+
if close_star_index < 0:
|
|
98
|
+
return [(each_line_number, stripped[slash_star_index:])], None, True
|
|
99
|
+
close_end = close_star_index + len(BLOCK_COMMENT_CLOSE_MARKER)
|
|
100
|
+
entry = (each_line_number, stripped[slash_star_index:close_end])
|
|
101
|
+
after_close = stripped[close_end:].lstrip()
|
|
102
|
+
return [entry], (after_close or None), False
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _consume_block_comment_continuation(
|
|
106
|
+
each_line_number: int, stripped: str
|
|
107
|
+
) -> tuple[list[tuple[int, str]], bool, bool]:
|
|
108
|
+
"""Extract the comment span a line inside an already-open block carries.
|
|
109
|
+
|
|
110
|
+
Returns the extracted entries, whether the comment is still open past
|
|
111
|
+
this line, and whether the caller should move to the next source line.
|
|
112
|
+
"""
|
|
113
|
+
close_index = stripped.find(BLOCK_COMMENT_CLOSE_MARKER)
|
|
114
|
+
if close_index < 0:
|
|
115
|
+
return [(each_line_number, stripped)], True, True
|
|
116
|
+
close_end = close_index + len(BLOCK_COMMENT_CLOSE_MARKER)
|
|
117
|
+
return [(each_line_number, stripped[:close_end])], False, False
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _extract_comment_lines(text: str, extension: str = "") -> list[tuple[int, str]]:
|
|
121
|
+
"""Return each comment line as ``(source_line_number, comment_text)``.
|
|
122
|
+
|
|
123
|
+
Covers Python (``#``), JS/TS/C/Rust/Go (``//``), and block comments, and
|
|
124
|
+
tags every extracted line with the source line it came from so a
|
|
125
|
+
matching pattern can be pinned to that line rather than searched for
|
|
126
|
+
again.
|
|
127
|
+
"""
|
|
128
|
+
all_comment_lines: list[tuple[int, str]] = []
|
|
84
129
|
|
|
85
130
|
is_in_block_comment = False
|
|
86
131
|
has_block_comments = extension in ALL_BLOCK_COMMENT_EXTENSIONS
|
|
87
132
|
all_inline_markers = _get_inline_markers(extension)
|
|
88
|
-
for each_line in
|
|
133
|
+
for each_line_number, each_line in enumerate(text.splitlines(), 1):
|
|
89
134
|
stripped = each_line.strip()
|
|
90
135
|
|
|
91
136
|
if has_block_comments:
|
|
@@ -93,42 +138,33 @@ def _extract_comment_lines(text: str, extension: str = "") -> list[str]:
|
|
|
93
138
|
stripped.startswith(each_marker)
|
|
94
139
|
for each_marker in all_inline_markers
|
|
95
140
|
):
|
|
96
|
-
all_comment_lines.append(stripped)
|
|
141
|
+
all_comment_lines.append((each_line_number, stripped))
|
|
97
142
|
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:])
|
|
143
|
+
if BLOCK_COMMENT_OPEN_MARKER in stripped and not is_in_block_comment:
|
|
144
|
+
entries, leftover, is_in_block_comment = _consume_block_comment_open(
|
|
145
|
+
each_line_number, stripped
|
|
146
|
+
)
|
|
147
|
+
all_comment_lines.extend(entries)
|
|
148
|
+
if leftover is None:
|
|
113
149
|
continue
|
|
150
|
+
stripped = leftover
|
|
114
151
|
if is_in_block_comment:
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
all_comment_lines.append(stripped)
|
|
152
|
+
entries, is_in_block_comment, should_continue = (
|
|
153
|
+
_consume_block_comment_continuation(each_line_number, stripped)
|
|
154
|
+
)
|
|
155
|
+
all_comment_lines.extend(entries)
|
|
156
|
+
if should_continue:
|
|
121
157
|
continue
|
|
122
158
|
|
|
123
159
|
if any(
|
|
124
160
|
stripped.startswith(each_marker) for each_marker in all_inline_markers
|
|
125
161
|
):
|
|
126
|
-
all_comment_lines.append(stripped)
|
|
162
|
+
all_comment_lines.append((each_line_number, stripped))
|
|
127
163
|
continue
|
|
128
164
|
|
|
129
165
|
inline_index = _find_inline_comment_start(stripped, all_inline_markers)
|
|
130
166
|
if inline_index is not None and inline_index > 0:
|
|
131
|
-
all_comment_lines.append(stripped[inline_index:])
|
|
167
|
+
all_comment_lines.append((each_line_number, stripped[inline_index:]))
|
|
132
168
|
continue
|
|
133
169
|
|
|
134
170
|
return all_comment_lines
|
|
@@ -154,16 +190,18 @@ def _find_inline_comment_start(stripped: str, all_markers: tuple[str, ...]) -> i
|
|
|
154
190
|
return best_position
|
|
155
191
|
|
|
156
192
|
|
|
157
|
-
def _extract_parsed_docstrings(tree: ast.Module) -> list[str]:
|
|
158
|
-
"""
|
|
193
|
+
def _extract_parsed_docstrings(tree: ast.Module) -> list[tuple[int, str]]:
|
|
194
|
+
"""Return each docstring as ``(start_line, docstring_text)``.
|
|
159
195
|
|
|
160
196
|
Args:
|
|
161
197
|
tree: The parsed module tree to walk.
|
|
162
198
|
|
|
163
199
|
Returns:
|
|
164
|
-
|
|
200
|
+
One entry per docstring found on the module and its class/function
|
|
201
|
+
definitions, tagged with the source line the docstring's opening
|
|
202
|
+
quotes start on.
|
|
165
203
|
"""
|
|
166
|
-
all_found_docstrings: list[str] = []
|
|
204
|
+
all_found_docstrings: list[tuple[int, str]] = []
|
|
167
205
|
for each_node in ast.walk(tree):
|
|
168
206
|
if not isinstance(
|
|
169
207
|
each_node, (ast.Module, ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)
|
|
@@ -171,102 +209,204 @@ def _extract_parsed_docstrings(tree: ast.Module) -> list[str]:
|
|
|
171
209
|
continue
|
|
172
210
|
maybe_docstring = ast.get_docstring(each_node, clean=False)
|
|
173
211
|
if maybe_docstring:
|
|
174
|
-
|
|
212
|
+
docstring_statement = each_node.body[0]
|
|
213
|
+
all_found_docstrings.append((docstring_statement.lineno, maybe_docstring))
|
|
175
214
|
return all_found_docstrings
|
|
176
215
|
|
|
177
216
|
|
|
178
|
-
def
|
|
179
|
-
"""
|
|
217
|
+
def _is_docstring_position(leading_text: str) -> bool:
|
|
218
|
+
"""Decide whether the text before a triple-quoted block marks a docstring slot.
|
|
180
219
|
|
|
181
220
|
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
|
|
221
|
+
(only whitespace before it) or when the nearest preceding non-blank line
|
|
222
|
+
is a def/class header ending in a colon. A block assigned to a name or
|
|
223
|
+
sitting elsewhere in the fragment is data.
|
|
224
|
+
"""
|
|
225
|
+
if not leading_text.strip():
|
|
226
|
+
return True
|
|
227
|
+
if leading_text.rpartition("\n")[2].strip():
|
|
228
|
+
return False
|
|
229
|
+
all_preceding_lines = [
|
|
230
|
+
each_line for each_line in leading_text.splitlines() if each_line.strip()
|
|
231
|
+
]
|
|
232
|
+
if not all_preceding_lines:
|
|
233
|
+
return False
|
|
234
|
+
last_preceding_line = all_preceding_lines[-1].strip()
|
|
235
|
+
return last_preceding_line.startswith(
|
|
236
|
+
ALL_DEFINITION_HEADER_PREFIXES
|
|
237
|
+
) and last_preceding_line.endswith(":")
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _extract_fragment_docstrings(text: str) -> list[tuple[int, str]]:
|
|
241
|
+
"""Return docstring-positioned triple-quoted blocks from an unparseable fragment.
|
|
185
242
|
|
|
186
243
|
Args:
|
|
187
244
|
text: The Python source fragment that failed to parse.
|
|
188
245
|
|
|
189
246
|
Returns:
|
|
190
|
-
|
|
247
|
+
One ``(start_line, body_text)`` pair per docstring-positioned
|
|
248
|
+
triple-quoted block, tagged with the source line its opening quotes
|
|
249
|
+
start on.
|
|
191
250
|
"""
|
|
192
|
-
all_found_docstrings: list[str] = []
|
|
251
|
+
all_found_docstrings: list[tuple[int, str]] = []
|
|
193
252
|
for each_match in TRIPLE_QUOTED_BLOCK_PATTERN.finditer(text):
|
|
194
|
-
body =
|
|
253
|
+
body = (
|
|
254
|
+
each_match.group(DOUBLE_QUOTE_BODY_GROUP)
|
|
255
|
+
if each_match.group(DOUBLE_QUOTE_BODY_GROUP) is not None
|
|
256
|
+
else each_match.group(SINGLE_QUOTE_BODY_GROUP)
|
|
257
|
+
)
|
|
258
|
+
start_line = text.count("\n", 0, each_match.start()) + 1
|
|
195
259
|
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)
|
|
260
|
+
if _is_docstring_position(leading_text):
|
|
261
|
+
all_found_docstrings.append((start_line, body))
|
|
209
262
|
return all_found_docstrings
|
|
210
263
|
|
|
211
264
|
|
|
212
|
-
def
|
|
213
|
-
"""Return
|
|
265
|
+
def _docstring_scan_lines(text: str) -> list[tuple[int, str]]:
|
|
266
|
+
"""Return each docstring's prose as ``(source_line_number, line_text)`` pairs.
|
|
214
267
|
|
|
215
268
|
Parses the source when valid, or falls back to the docstring-positioned
|
|
216
269
|
triple-quoted blocks of a mid-edit fragment. Double-quoted and backticked
|
|
217
270
|
spans inside each docstring are mentions rather than uses, so both are
|
|
218
|
-
stripped
|
|
271
|
+
stripped before the docstring is split back into its own source lines.
|
|
219
272
|
|
|
220
273
|
Args:
|
|
221
274
|
text: The Python source or fragment under scan.
|
|
222
275
|
|
|
223
276
|
Returns:
|
|
224
|
-
|
|
277
|
+
One entry per physical line of docstring prose, numbered against the
|
|
278
|
+
source file so a pattern match on that line carries the line the
|
|
279
|
+
rule matched rather than an unrelated occurrence elsewhere in the
|
|
280
|
+
file.
|
|
225
281
|
"""
|
|
226
282
|
try:
|
|
227
283
|
all_found_docstrings = _extract_parsed_docstrings(ast.parse(text))
|
|
228
284
|
except (SyntaxError, ValueError):
|
|
229
285
|
all_found_docstrings = _extract_fragment_docstrings(text)
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
286
|
+
all_scan_lines: list[tuple[int, str]] = []
|
|
287
|
+
for each_start_line, each_docstring in all_found_docstrings:
|
|
288
|
+
prose = DOUBLE_QUOTED_SPAN_PATTERN.sub(
|
|
289
|
+
"", INLINE_CODE_PATTERN.sub("", each_docstring)
|
|
290
|
+
)
|
|
291
|
+
for each_offset, each_prose_line in enumerate(prose.split("\n")):
|
|
292
|
+
all_scan_lines.append((each_start_line + each_offset, each_prose_line))
|
|
293
|
+
return all_scan_lines
|
|
235
294
|
|
|
236
295
|
|
|
237
|
-
def
|
|
238
|
-
"""Return
|
|
296
|
+
def _markdown_scan_lines(text: str) -> list[tuple[int, str]]:
|
|
297
|
+
"""Return each Markdown source line as ``(line_number, scannable_text)``.
|
|
239
298
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
299
|
+
A fenced code block's content is data rather than prose, so a line
|
|
300
|
+
between a pair of ``` fence markers is skipped. An inline code span is a
|
|
301
|
+
mention rather than a use, so it is blanked out of the line it appears
|
|
302
|
+
on, the same way it is blanked from Python docstring prose.
|
|
303
|
+
|
|
304
|
+
Args:
|
|
305
|
+
text: The Markdown source to scan.
|
|
306
|
+
|
|
307
|
+
Returns:
|
|
308
|
+
One entry per source line outside a fenced code block, with its
|
|
309
|
+
inline code spans removed.
|
|
310
|
+
"""
|
|
311
|
+
all_scan_lines: list[tuple[int, str]] = []
|
|
312
|
+
is_inside_fence = False
|
|
313
|
+
for each_line_number, each_line in enumerate(text.splitlines(), 1):
|
|
314
|
+
if each_line.strip().startswith("```"):
|
|
315
|
+
is_inside_fence = not is_inside_fence
|
|
316
|
+
continue
|
|
317
|
+
if is_inside_fence:
|
|
318
|
+
continue
|
|
319
|
+
all_scan_lines.append(
|
|
320
|
+
(each_line_number, INLINE_CODE_PATTERN.sub("", each_line))
|
|
321
|
+
)
|
|
322
|
+
return all_scan_lines
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _first_pattern_match(
|
|
326
|
+
pattern: Pattern[str], all_scan_lines: list[tuple[int, str]]
|
|
327
|
+
) -> tuple[str, int] | None:
|
|
328
|
+
"""Return the first line a pattern matches, or None when it matches none."""
|
|
329
|
+
for each_line_number, each_line_text in all_scan_lines:
|
|
330
|
+
each_match = pattern.search(each_line_text)
|
|
331
|
+
if each_match is not None:
|
|
332
|
+
return (each_match.group(0).strip().lower(), each_line_number)
|
|
333
|
+
return None
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _scan_lines_for_file(
|
|
337
|
+
text: str, extension: str, file_path: str
|
|
338
|
+
) -> list[tuple[int, str]] | None:
|
|
339
|
+
"""Return the file's scannable lines, or ``None`` when its kind carries no prose.
|
|
340
|
+
|
|
341
|
+
A ``.md`` file scans every source line outside a fenced code block. A
|
|
342
|
+
code file scans its comment lines, and a Python file also scans its
|
|
343
|
+
module, class, and function docstrings.
|
|
243
344
|
"""
|
|
244
|
-
extension = _get_file_extension(file_path)
|
|
245
345
|
if is_markdown_file(file_path):
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
346
|
+
return _markdown_scan_lines(text)
|
|
347
|
+
if not is_comment_bearing_file(file_path):
|
|
348
|
+
return None
|
|
349
|
+
all_scan_lines = _extract_comment_lines(text, extension)
|
|
350
|
+
if extension == PYTHON_EXTENSION:
|
|
351
|
+
return all_scan_lines + _docstring_scan_lines(text)
|
|
352
|
+
return all_scan_lines
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _matched_transition_patterns(
|
|
356
|
+
all_scan_lines: list[tuple[int, str]],
|
|
357
|
+
) -> list[tuple[str, int]]:
|
|
358
|
+
"""Return each transition pattern the scan lines match, with its line."""
|
|
359
|
+
all_detected: list[tuple[str, int]] = []
|
|
360
|
+
for each_pattern in ALL_COMMENT_TRANSITION_PATTERNS:
|
|
361
|
+
matched_entry = _first_pattern_match(each_pattern, all_scan_lines)
|
|
362
|
+
if matched_entry is not None:
|
|
363
|
+
all_detected.append(matched_entry)
|
|
364
|
+
return all_detected
|
|
254
365
|
|
|
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
366
|
|
|
259
|
-
|
|
367
|
+
def find_violations_with_lines(text: str, file_path: str) -> list[tuple[str, int]]:
|
|
368
|
+
"""Return each violated pattern with the source line the rule matched.
|
|
369
|
+
|
|
370
|
+
::
|
|
371
|
+
|
|
372
|
+
# config.py
|
|
373
|
+
1 fixture_text = "the field was `previously` required"
|
|
374
|
+
4 def read_field():
|
|
375
|
+
5 \"\"\"Read the field.
|
|
376
|
+
7 `The field was previously required.`
|
|
377
|
+
8 \"\"\"
|
|
378
|
+
flag: ("previously", 7) and ("was previously", 7) -- one per pattern
|
|
379
|
+
ok: line 1 never reported -- a plain string literal is fixture data, not prose
|
|
380
|
+
|
|
381
|
+
Each match is paired with the line it matched on, not an earlier mention
|
|
382
|
+
of the same words.
|
|
383
|
+
|
|
384
|
+
Args:
|
|
385
|
+
text: The file content to scan.
|
|
386
|
+
file_path: The path that selects the scan strategy for the file.
|
|
387
|
+
|
|
388
|
+
Returns:
|
|
389
|
+
One ``(matched_phrase, source_line_number)`` pair per matched
|
|
390
|
+
pattern, in pattern-declaration order, at most one match each.
|
|
391
|
+
"""
|
|
392
|
+
extension = _get_file_extension(file_path)
|
|
393
|
+
all_scan_lines = _scan_lines_for_file(text, extension, file_path)
|
|
394
|
+
if all_scan_lines is None:
|
|
260
395
|
return []
|
|
396
|
+
return _matched_transition_patterns(all_scan_lines)
|
|
261
397
|
|
|
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
398
|
|
|
269
|
-
|
|
399
|
+
def find_violations(text: str, file_path: str) -> list[str]:
|
|
400
|
+
"""Return all violated patterns found in text for the given file.
|
|
401
|
+
|
|
402
|
+
For .md files, scans the entire text. For code files, scans comment lines,
|
|
403
|
+
and for Python files also scans module/class/function docstrings.
|
|
404
|
+
Returns a list of matched pattern source strings.
|
|
405
|
+
"""
|
|
406
|
+
return [
|
|
407
|
+
each_phrase
|
|
408
|
+
for each_phrase, _each_line_number in find_violations_with_lines(text, file_path)
|
|
409
|
+
]
|
|
270
410
|
|
|
271
411
|
|
|
272
412
|
def _build_deny_reason(file_path: str, all_detected_patterns: list[str]) -> str:
|
|
@@ -1,20 +1,21 @@
|
|
|
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
|
-
|
|
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.
|
|
9
11
|
|
|
10
12
|
Each test builds a temporary tree whose tail mirrors a production hook directory
|
|
11
13
|
(``packages/claude-dev-env/hooks/blocking``) so ``is_hook_infrastructure`` matches the
|
|
12
|
-
target path the same way it would for the
|
|
14
|
+
target path 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
20
|
import shutil
|
|
20
21
|
import sys
|
|
@@ -75,27 +76,22 @@ def _run_main_with_write_payload(
|
|
|
75
76
|
return captured_stdout
|
|
76
77
|
|
|
77
78
|
|
|
78
|
-
def
|
|
79
|
+
def test_write_of_copied_helper_into_hook_directory_does_not_deny(
|
|
79
80
|
hook_blocking_dir: pathlib.Path,
|
|
80
81
|
) -> None:
|
|
81
|
-
"""A Write that copies a sibling helper into a second hook file is denied.
|
|
82
|
+
"""A live Write that copies a sibling helper into a second hook file is not denied.
|
|
82
83
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
84
|
+
Enforces CODE_RULES.md §11.6: "A check that reads a file other than the
|
|
85
|
+
target runs on the full gate." The cross-file duplicate-body check reads a
|
|
86
|
+
sibling module, so it is full-gate only; the live PreToolUse Write that
|
|
87
|
+
``main()`` handles runs the edit lane, so it must not deny here even though
|
|
88
|
+
the copied helper is present."""
|
|
86
89
|
(hook_blocking_dir / "existing_blocker.py").write_text(SHARED_HELPER_SOURCE, encoding="utf-8")
|
|
87
90
|
new_file = hook_blocking_dir / "new_blocker.py"
|
|
88
91
|
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}"
|
|
92
|
+
assert stdout == "", (
|
|
93
|
+
"the edit lane must not run the cross-file duplicate-body check, "
|
|
94
|
+
f"got stdout: {stdout!r}"
|
|
99
95
|
)
|
|
100
96
|
|
|
101
97
|
|
|
@@ -124,9 +120,11 @@ def test_precheck_of_copied_helper_at_hook_target_exits_nonzero(
|
|
|
124
120
|
) -> None:
|
|
125
121
|
"""The pre-check CLI flags a copied helper judged at a hook-infrastructure target.
|
|
126
122
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
123
|
+
Enforces CODE_RULES.md §11.6: hook-infrastructure targets "run ... the
|
|
124
|
+
whole roster on the full gate." The pre-check CLI runs the full gate, so
|
|
125
|
+
driving the shipped ``--check`` argv path proves it still routes a hook
|
|
126
|
+
``.py`` target through the duplicate-body check rather than exiting clean
|
|
127
|
+
on the blanket hook-infrastructure exemption."""
|
|
130
128
|
(hook_blocking_dir / "existing_blocker.py").write_text(SHARED_HELPER_SOURCE, encoding="utf-8")
|
|
131
129
|
staging_directory = tmp_path
|
|
132
130
|
candidate_file = staging_directory / "candidate.py"
|
|
@@ -26,6 +26,7 @@ if _HOOKS_ROOT not in sys.path:
|
|
|
26
26
|
from pre_tool_use_dispatcher import NativeHook, run_native_hook # noqa: E402
|
|
27
27
|
from state_description_blocker import ( # noqa: E402
|
|
28
28
|
build_deny_payload,
|
|
29
|
+
find_violations_with_lines,
|
|
29
30
|
)
|
|
30
31
|
from state_description_blocker import evaluate as _evaluate_without_opt_in # noqa: E402
|
|
31
32
|
|
|
@@ -952,3 +953,45 @@ def test_native_dispatch_path_logs_the_block(tmp_path: Path) -> None:
|
|
|
952
953
|
logged_record = json.loads(all_records[0])
|
|
953
954
|
assert logged_record["hook"] == "state_description_blocker.py"
|
|
954
955
|
assert logged_record["event"] == "PreToolUse"
|
|
956
|
+
|
|
957
|
+
|
|
958
|
+
def test_find_violations_with_lines_names_the_docstring_line_not_the_fixture_line() -> None:
|
|
959
|
+
module_source = (
|
|
960
|
+
'fixture_text = "the field was previously required"\n'
|
|
961
|
+
"\n"
|
|
962
|
+
"\n"
|
|
963
|
+
"def read_field():\n"
|
|
964
|
+
' """Read the field.\n'
|
|
965
|
+
"\n"
|
|
966
|
+
" The field was previously required.\n"
|
|
967
|
+
' """\n'
|
|
968
|
+
" return 1\n"
|
|
969
|
+
)
|
|
970
|
+
|
|
971
|
+
all_violations = find_violations_with_lines(module_source, "src/module.py")
|
|
972
|
+
|
|
973
|
+
assert all_violations == [("previously", 7), ("was previously", 7)]
|
|
974
|
+
|
|
975
|
+
|
|
976
|
+
def test_find_violations_with_lines_reports_first_matching_comment_line() -> None:
|
|
977
|
+
module_source = "x = 1\n# no longer needed\ny = 2\n# no longer used\n"
|
|
978
|
+
|
|
979
|
+
all_violations = find_violations_with_lines(module_source, "src/module.py")
|
|
980
|
+
|
|
981
|
+
assert all_violations == [("no longer", 2)]
|
|
982
|
+
|
|
983
|
+
|
|
984
|
+
def test_find_violations_with_lines_reports_markdown_line_past_a_code_fence() -> None:
|
|
985
|
+
markdown_source = (
|
|
986
|
+
"# Title\n"
|
|
987
|
+
"\n"
|
|
988
|
+
"```\n"
|
|
989
|
+
"used to fail\n"
|
|
990
|
+
"```\n"
|
|
991
|
+
"\n"
|
|
992
|
+
"The API was previously public.\n"
|
|
993
|
+
)
|
|
994
|
+
|
|
995
|
+
all_violations = find_violations_with_lines(markdown_source, "docs/api.md")
|
|
996
|
+
|
|
997
|
+
assert all_violations == [("previously", 7), ("was previously", 7)]
|