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.
Files changed (29) hide show
  1. package/_shared/pr-loop/scripts/pr_loop_shared_constants/terminology_sweep_constants.py +4 -0
  2. package/_shared/pr-loop/scripts/terminology_sweep.py +154 -18
  3. package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +117 -0
  4. package/bin/cde.mjs +40 -6
  5. package/bin/cde.test.mjs +25 -0
  6. package/commands/fix-followups.md +15 -8
  7. package/hooks/blocking/state_description_blocker.py +230 -90
  8. package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +23 -25
  9. package/hooks/blocking/test_state_description_blocker.py +43 -0
  10. package/hooks/followup_ledger.py +109 -4
  11. package/hooks/hooks_constants/followup_ledger_constants.py +15 -0
  12. package/hooks/hooks_constants/state_description_blocker_constants.py +5 -1
  13. package/hooks/hooks_constants/test_state_description_blocker_constants.py +85 -0
  14. package/hooks/test_followup_ledger.py +92 -4
  15. package/package.json +1 -1
  16. package/rules/flag-non-breaking-findings.md +35 -2
  17. package/scripts/dev_env_scripts_constants/followup_constants.py +21 -4
  18. package/scripts/followup_cli.py +78 -4
  19. package/scripts/policy_lint/adapter_detectors.py +1 -1
  20. package/scripts/policy_lint/adapter_support.py +11 -15
  21. package/scripts/policy_lint/adapters.py +0 -1
  22. package/scripts/policy_lint/check_catalog.py +44 -0
  23. package/scripts/policy_lint/config/check_catalog_constants.py +59 -0
  24. package/scripts/policy_lint/model.py +4 -1
  25. package/scripts/test_followup_cli.py +116 -2
  26. package/scripts/test_validate_instruction_pairs.py +23 -1
  27. package/scripts/tests/test_policy_lint_rules_check_catalog.py +151 -0
  28. package/scripts/tests/test_policy_lint_rules_engine.py +4 -1
  29. 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
- CODE_FENCE_PATTERN,
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 _extract_comment_lines(text: str, extension: str = "") -> list[str]:
81
- """Extract comment lines from source code — Python (#), JS/TS/C/Rust/Go (//), and block comments."""
82
- all_comment_lines: list[str] = []
83
- all_lines = text.splitlines()
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 all_lines:
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 "/*" in stripped and not is_in_block_comment:
99
- is_in_block_comment = True
100
- slash_star_index = stripped.find("/*")
101
- close_star_index = stripped.find("*/", slash_star_index + len("/*"))
102
- if close_star_index >= 0:
103
- all_comment_lines.append(
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
- close_index = stripped.find("*/")
116
- if close_index >= 0:
117
- all_comment_lines.append(stripped[: close_index + 2])
118
- is_in_block_comment = False
119
- else:
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
- """Collect the module, class, and function docstrings from a parsed tree.
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
- Every docstring found on the module and its class/function definitions.
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
- all_found_docstrings.append(maybe_docstring)
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 _extract_fragment_docstrings(text: str) -> list[str]:
179
- """Collect docstring-positioned triple-quoted blocks from an unparseable fragment.
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 is
183
- a def/class header ending in a colon. A block assigned to a name or sitting
184
- elsewhere in the fragment is data and is skipped.
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
- The body text of each docstring-positioned triple-quoted block.
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 = each_match.group(1) if each_match.group(1) is not None else each_match.group(2)
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 not leading_text.strip():
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 _extract_python_docstring_text(text: str) -> str:
213
- """Return the scannable docstring prose from Python source.
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 from the returned prose.
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
- The docstring prose joined into one scannable string.
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
- all_docstring_prose = [
231
- DOUBLE_QUOTED_SPAN_PATTERN.sub("", INLINE_CODE_PATTERN.sub("", each_docstring))
232
- for each_docstring in all_found_docstrings
233
- ]
234
- return "\n".join(all_docstring_prose)
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 find_violations(text: str, file_path: str) -> list[str]:
238
- """Return all violated patterns found in text for the given file.
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
- For .md files, scans the entire text. For code files, scans comment lines,
241
- and for Python files also scans module/class/function docstrings.
242
- Returns a list of matched pattern source strings.
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
- scan_text = text
247
- elif is_comment_bearing_file(file_path):
248
- all_comment_lines = _extract_comment_lines(text, extension)
249
- scan_text = "\n".join(all_comment_lines)
250
- if extension == PYTHON_EXTENSION:
251
- scan_text = "\n".join([scan_text, _extract_python_docstring_text(text)])
252
- else:
253
- return []
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
- if not scan_text.strip():
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
- return all_detected
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 guards hook-infrastructure files.
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. These tests drive the real entry points (the ``main()``
6
- stdin path and the pre-check CLI) with a hook-infrastructure target so the deny fires
7
- on the same path a live Write would take, rather than calling the check function
8
- directly.
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 real directory.
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 test_write_of_copied_helper_into_hook_directory_denies(
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
- The target lives under a hook-infrastructure path the rest of the code-rules
84
- suite exempts, so this proves the duplicate-body check still guards the exact
85
- directory its module docstring names as the primary target."""
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
- "A copied helper written into a second hook-infrastructure file must "
91
- "produce a deny payload, got empty stdout"
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
- Driving the real ``--check`` argv path proves the gate's pre-check mode also
128
- routes a hook ``.py`` target through the duplicate-body check rather than
129
- exiting clean on the blanket hook-infrastructure exemption."""
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)]