claude-dev-env 1.84.0 → 1.85.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -1
  2. package/_shared/pr-loop/scripts/pr_loop_shared_constants/terminology_sweep_constants.py +8 -0
  3. package/_shared/pr-loop/scripts/terminology_sweep.py +85 -39
  4. package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +94 -4
  5. package/hooks/blocking/claude_md_orphan_file_blocker.py +1 -1
  6. package/hooks/blocking/code_rules_docstrings.py +17 -13
  7. package/hooks/blocking/code_rules_imports_logging.py +10 -6
  8. package/hooks/blocking/code_rules_naming_collection.py +5 -3
  9. package/hooks/blocking/code_rules_paired_test.py +3 -2
  10. package/hooks/blocking/code_rules_string_magic.py +1 -1
  11. package/hooks/blocking/code_rules_unused_imports.py +2 -2
  12. package/hooks/blocking/test_code_rules_enforcer_docstring_args_span_scope.py +29 -0
  13. package/hooks/blocking/test_code_rules_enforcer_naive_datetime.py +24 -0
  14. package/hooks/hooks_constants/blocking_check_limits.py +1 -0
  15. package/package.json +1 -1
  16. package/rules/docstring-prose-matches-implementation.md +1 -1
  17. package/rules/env-var-table-code-drift.md +1 -1
  18. package/rules/package-inventory-stale-entry.md +1 -1
  19. package/rules/paired-test-coverage.md +1 -1
  20. package/skills/_shared/pr-loop/CLAUDE.md +1 -0
  21. package/skills/_shared/pr-loop/scripts/CLAUDE.md +1 -0
  22. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/CLAUDE.md +1 -0
  23. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/handoff_constants.py +45 -0
  24. package/skills/_shared/pr-loop/scripts/test_write_handoff.py +201 -0
  25. package/skills/_shared/pr-loop/scripts/write_handoff.py +309 -0
  26. package/skills/autoconverge/SKILL.md +46 -3
  27. package/skills/autoconverge/workflow/converge.mjs +1 -1
  28. package/skills/pr-converge/SKILL.md +27 -1
  29. package/skills/pr-converge/reference/state-schema.md +12 -0
@@ -16,7 +16,7 @@ Named constants for every script in `_shared/pr-loop/scripts/`. Each module owns
16
16
  | `preflight_self_heal_constants.py` | `preflight_self_heal.py` — git config keys and local-scope detection strings |
17
17
  | `reviews_disabled_constants.py` | `reviews_disabled.py` — `CLAUDE_REVIEWS_DISABLED` token taxonomy |
18
18
  | `copilot_quota_constants.py` | `copilot_quota.py` — `COPILOT_QUOTA_ACCOUNT` env-var name, `copilot_internal/user` API path, the `premium_interactions` gating field names, the four skip/run exit codes, and the default `.env` path |
19
- | `terminology_sweep_constants.py` | `terminology_sweep.py` — identifier and prose-token regexes, diff-parsing prefixes, code-file extensions, and the finding-message template |
19
+ | `terminology_sweep_constants.py` | `terminology_sweep.py` — identifier and prose-token regexes, diff-parsing prefixes, code-file extensions, test-file detection patterns, and the finding-message template |
20
20
  | `reviewer_availability_constants.py` | `reviewer_availability.py` — the available/down exit codes for the unified reviewer-availability pre-check |
21
21
  | `__init__.py` | Empty package marker |
22
22
 
@@ -8,6 +8,8 @@ ALL_SWEEP_CODE_FILE_EXTENSIONS: frozenset[str] = frozenset(
8
8
 
9
9
  MARKDOWN_FILE_EXTENSION: str = ".md"
10
10
 
11
+ PYTHON_FILE_EXTENSION: str = ".py"
12
+
11
13
  SNAKE_CASE_IDENTIFIER_PATTERN: re.Pattern[str] = re.compile(
12
14
  r"\b[a-z][a-z0-9]*(?:_[a-z0-9]+)+\b"
13
15
  )
@@ -28,6 +30,12 @@ STRING_LITERAL_CONTENT_PATTERN: re.Pattern[str] = re.compile(
28
30
  r"\"([^\"]*)\"|'([^']*)'|`([^`]*)`"
29
31
  )
30
32
 
33
+ TEST_DIRECTORY_PATH_SEGMENT: str = "/tests/"
34
+
35
+ TEST_FILE_NAME_PREFIX: str = "test_"
36
+
37
+ ALL_TEST_FILE_NAME_INFIX_MARKERS: tuple[str, ...] = ("_test.", ".test.", ".spec.")
38
+
31
39
  DIFF_FILE_HEADER_PREFIX: str = "+++ "
32
40
 
33
41
  DIFF_HUNK_HEADER_PATTERN: re.Pattern[str] = re.compile(
@@ -1,40 +1,27 @@
1
- """Flag prose terms that near-miss an identifier a diff introduces.
2
-
3
- Picture a change that adds an API field named ``premium_interactions``. On the
4
- same branch, the docs call it the ``premium-request`` budget. The code and the
5
- prose now name one thing two ways. A reader who searches for one term never
6
- finds the other.
7
-
8
- This sweep reads a unified diff and collects every multi-word identifier added
9
- on code lines. Then it scans each added prose line — a Markdown line, or a
10
- comment, docstring, or string inside a code file. It flags a hyphenated term
11
- that shares an identifier's leading word but diverges in the tail. Each
12
- near-miss prints as one ``file:line`` finding, and the run exits non-zero when
13
- any finding remains, so a commit gate can block on it.
14
-
15
- Only hyphenated terms are candidates. A hyphen marks a deliberate compound
16
- the author bound the words into one term so a divergent tail there is a real
17
- naming drift. A bare spaced word run is ordinary prose: windowing every
18
- sentence that starts with an identifier's first word ("each attempt", "file
19
- exists") floods a vocabulary-dense change with false findings and gives the
20
- gate nothing actionable.
21
-
22
- A shared leading token alone is too weak a signal: ordinary English compounds
23
- such as ``read-only`` and ``data-driven`` collide with unrelated identifiers
24
- (``read_config``, ``data_source``) and would block a commit falsely. To keep the
25
- grounding case (``premium-request`` against ``premium_interactions``) firing
26
- while sparing those compounds, a candidate whose final token is a common English
27
- compound tail word (``only``, ``driven``, ``safe``, and the rest listed in
28
- ``ALL_COMMON_ENGLISH_COMPOUND_TAIL_WORDS``) is treated as ordinary prose, not a
29
- near-miss.
30
-
31
- Ordinary singular/plural prose is spared the same way. A candidate that differs
32
- from an identifier only by a singular/plural form of one or more tokens
33
- (``test files`` against ``test_file``, ``retry policies`` against
34
- ``retry_policy``) is treated as the same term, not a near-miss.
1
+ """Flag a prose term that near-misses a code identifier a diff introduces.
2
+
3
+ A change adds the field ``premium_interactions`` while the same branch's docs
4
+ call it the ``premium-request`` budget: two names for one thing, and a reader
5
+ who searches one never finds the other.
6
+
7
+ ::
8
+
9
+ code adds: premium_interactions (the identifier)
10
+ prose adds: "the premium-request budget" (a hyphenated term)
11
+ flag: premium-request vs premium_interactions -- shared first token, tail diverges
12
+ ok: premium-interactions -- exact hyphen form, no drift
13
+ ok: read-only, test files -- English compound or plural, not drift
14
+
15
+ Only hyphenated prose is a candidate, since a hyphen marks a deliberate compound
16
+ while a spaced word run is ordinary prose. Markdown scans in full; a JavaScript
17
+ or TypeScript line contributes its comments and string literals; a Python file
18
+ contributes identifiers only; a test code file is skipped whole. Each near-miss
19
+ prints one ``file:line`` finding and the run exits non-zero, so a commit gate
20
+ blocks on it.
35
21
  """
36
22
 
37
23
  import argparse
24
+ import os
38
25
  import subprocess
39
26
  import sys
40
27
  from pathlib import Path
@@ -48,6 +35,7 @@ from pr_loop_shared_constants.terminology_sweep_constants import ( # noqa: E402
48
35
  ALL_DIFF_FILE_PATH_STRIP_PREFIXES,
49
36
  ALL_GIT_DIFF_CACHED_UNIFIED_ZERO_COMMAND,
50
37
  ALL_SWEEP_CODE_FILE_EXTENSIONS,
38
+ ALL_TEST_FILE_NAME_INFIX_MARKERS,
51
39
  CAMEL_CASE_IDENTIFIER_PATTERN,
52
40
  CAMEL_CASE_WORD_PATTERN,
53
41
  DIFF_ADDED_LINE_PREFIX,
@@ -64,10 +52,13 @@ from pr_loop_shared_constants.terminology_sweep_constants import ( # noqa: E402
64
52
  MARKDOWN_FILE_EXTENSION,
65
53
  MINIMUM_IDENTIFIER_TOKEN_COUNT,
66
54
  PYTHON_COMMENT_MARKER,
55
+ PYTHON_FILE_EXTENSION,
67
56
  SNAKE_CASE_IDENTIFIER_PATTERN,
68
57
  STRING_LITERAL_CONTENT_PATTERN,
69
58
  TERMINOLOGY_FINDING_TEMPLATE,
70
59
  TERMINOLOGY_SWEEP_DESCRIPTION,
60
+ TEST_DIRECTORY_PATH_SEGMENT,
61
+ TEST_FILE_NAME_PREFIX,
71
62
  )
72
63
 
73
64
  IdentifierTuple = tuple[str, ...]
@@ -124,6 +115,35 @@ def _file_extension(file_path: str) -> str:
124
115
  return Path(file_path).suffix.lower()
125
116
 
126
117
 
118
+ def _is_test_file(file_path: str) -> bool:
119
+ """Return True when the diff path is a test code file the sweep should skip.
120
+
121
+ A test code file's prose is scaffolding: its docstrings and string fixtures
122
+ name the identifiers under test on purpose, so scanning it only adds noise. A
123
+ Markdown or other non-code file is prose worth scanning whatever its name, so
124
+ a ``test_plan.md`` design doc is scanned rather than skipped.
125
+
126
+ Args:
127
+ file_path: The repository-relative diff path.
128
+
129
+ Returns:
130
+ True for a code file under a ``tests`` directory, a ``test_`` prefixed
131
+ code file, or a code file whose name carries a
132
+ ``_test.``/``.test.``/``.spec.`` marker. False for any non-code file.
133
+ """
134
+ if _file_extension(file_path) not in ALL_SWEEP_CODE_FILE_EXTENSIONS:
135
+ return False
136
+ normalized_path = file_path.replace("\\", "/").lower()
137
+ if TEST_DIRECTORY_PATH_SEGMENT in f"/{normalized_path}":
138
+ return True
139
+ basename = normalized_path.rsplit("/", 1)[-1]
140
+ if basename.startswith(TEST_FILE_NAME_PREFIX):
141
+ return True
142
+ return any(
143
+ each_marker in basename for each_marker in ALL_TEST_FILE_NAME_INFIX_MARKERS
144
+ )
145
+
146
+
127
147
  def _identifier_token_tuple(identifier: str) -> IdentifierTuple:
128
148
  """Return the lowercase word tokens of a snake_case or camelCase identifier."""
129
149
  if "_" in identifier:
@@ -175,8 +195,11 @@ def _collect_introduced_identifiers(
175
195
  def _prose_fragments(file_path: str, line_text: str) -> list[str]:
176
196
  """Return the prose fragments of an added line worth scanning for terms.
177
197
 
178
- A Markdown line is prose in full. A code line contributes its comment tail,
179
- its JSDoc continuation text, and the contents of its string literals.
198
+ A Markdown line is prose in full. A JavaScript or TypeScript line contributes
199
+ its comment tail, its JSDoc continuation text, and the contents of its string
200
+ literals. A Python line contributes no prose: to avoid flagging a module's own
201
+ vocabulary as drift, the sweep ignores Python comments, docstrings, and string
202
+ literals and scans Python files for identifiers only.
180
203
 
181
204
  Args:
182
205
  file_path: The path the added line belongs to.
@@ -185,9 +208,12 @@ def _prose_fragments(file_path: str, line_text: str) -> list[str]:
185
208
  Returns:
186
209
  The prose fragments to scan for near-miss terms.
187
210
  """
188
- if _file_extension(file_path) == MARKDOWN_FILE_EXTENSION:
211
+ extension = _file_extension(file_path)
212
+ if extension == MARKDOWN_FILE_EXTENSION:
189
213
  return [line_text]
190
- if _file_extension(file_path) not in ALL_SWEEP_CODE_FILE_EXTENSIONS:
214
+ if extension not in ALL_SWEEP_CODE_FILE_EXTENSIONS:
215
+ return []
216
+ if extension == PYTHON_FILE_EXTENSION:
191
217
  return []
192
218
  all_fragments: list[str] = []
193
219
  stripped_line = line_text.strip()
@@ -352,7 +378,11 @@ def sweep_diff(diff_text: str) -> list[str]:
352
378
  Returns:
353
379
  One finding string per near-miss term on an added prose line.
354
380
  """
355
- all_added_lines = _parse_added_lines(diff_text)
381
+ all_added_lines = [
382
+ each_added_line
383
+ for each_added_line in _parse_added_lines(diff_text)
384
+ if not _is_test_file(each_added_line[0])
385
+ ]
356
386
  all_identifier_tuples, identifiers_by_first_token = _collect_introduced_identifiers(
357
387
  all_added_lines
358
388
  )
@@ -372,6 +402,21 @@ def sweep_diff(diff_text: str) -> list[str]:
372
402
  return all_findings
373
403
 
374
404
 
405
+ def _git_environment() -> dict[str, str]:
406
+ """Return the process environment with every GIT_ variable removed.
407
+
408
+ A git commit hook runs this sweep with GIT_INDEX_FILE pointed at the commit
409
+ index. Stripping every GIT_ variable makes ``git diff --cached`` read the
410
+ repository's own index, so the sweep sees the staged diff of the repository
411
+ it was handed rather than an ambient override.
412
+ """
413
+ return {
414
+ each_key: each_setting
415
+ for each_key, each_setting in os.environ.items()
416
+ if not each_key.startswith("GIT_")
417
+ }
418
+
419
+
375
420
  def staged_terminology_findings(repository_root: Path) -> list[str]:
376
421
  """Return terminology near-miss findings for a repository's staged diff.
377
422
 
@@ -391,6 +436,7 @@ def staged_terminology_findings(repository_root: Path) -> list[str]:
391
436
  errors="replace",
392
437
  timeout=GIT_DIFF_SUBPROCESS_TIMEOUT_SECONDS,
393
438
  check=False,
439
+ env=_git_environment(),
394
440
  )
395
441
  if diff_process.returncode != 0:
396
442
  return []
@@ -23,13 +23,22 @@ main = sweep_module.main
23
23
  parse_added_lines = sweep_module._parse_added_lines
24
24
 
25
25
 
26
+ _git_environment = sweep_module._git_environment
27
+
28
+
26
29
  def _init_git_repository(repository_path: Path) -> None:
27
30
  for each_command in (
28
31
  ["git", "init"],
29
32
  ["git", "config", "user.email", "test@example.com"],
30
33
  ["git", "config", "user.name", "Test"],
31
34
  ):
32
- subprocess.run(each_command, cwd=repository_path, check=True, capture_output=True)
35
+ subprocess.run(
36
+ each_command,
37
+ cwd=repository_path,
38
+ check=True,
39
+ capture_output=True,
40
+ env=_git_environment(),
41
+ )
33
42
 
34
43
 
35
44
  CODE_AND_PROSE_DIFF = (
@@ -220,17 +229,86 @@ def test_does_not_flag_ordinary_sentence_sharing_loop_variable_prefix() -> None:
220
229
 
221
230
 
222
231
  def test_flags_near_miss_inside_code_comment() -> None:
232
+ diff = (
233
+ "diff --git a/api/quota.mjs b/api/quota.mjs\n"
234
+ "--- a/api/quota.mjs\n"
235
+ "+++ b/api/quota.mjs\n"
236
+ "@@ -0,0 +1,2 @@\n"
237
+ "+const premium_interactions = 5\n"
238
+ "+// the premium-request path resets the counter\n"
239
+ )
240
+ findings = sweep_diff(diff)
241
+ assert len(findings) == 1
242
+ assert "premium-request" in findings[0]
243
+
244
+
245
+ def test_flags_near_miss_inside_code_string_literal() -> None:
246
+ diff = (
247
+ "diff --git a/api/quota.mjs b/api/quota.mjs\n"
248
+ "--- a/api/quota.mjs\n"
249
+ "+++ b/api/quota.mjs\n"
250
+ "@@ -0,0 +1,2 @@\n"
251
+ "+const premium_interactions = 5\n"
252
+ '+throw new Error("the premium-request budget")\n'
253
+ )
254
+ findings = sweep_diff(diff)
255
+ assert len(findings) == 1
256
+ assert "premium-request" in findings[0]
257
+
258
+
259
+ def test_does_not_flag_prose_inside_code_string_literal() -> None:
260
+ diff = (
261
+ "diff --git a/api/quota.py b/api/quota.py\n"
262
+ "--- a/api/quota.py\n"
263
+ "+++ b/api/quota.py\n"
264
+ "@@ -0,0 +1,2 @@\n"
265
+ "+premium_interactions = 5\n"
266
+ '+message = "the premium-request budget"\n'
267
+ )
268
+ assert sweep_diff(diff) == []
269
+
270
+
271
+ def test_does_not_flag_prose_inside_code_docstring() -> None:
223
272
  diff = (
224
273
  "diff --git a/api/quota.py b/api/quota.py\n"
225
274
  "--- a/api/quota.py\n"
226
275
  "+++ b/api/quota.py\n"
227
276
  "@@ -0,0 +1,2 @@\n"
228
277
  "+premium_interactions = 5\n"
229
- "+# the premium-request path resets the counter\n"
278
+ '+ """Gate the run on the premium-request budget."""\n'
279
+ )
280
+ assert sweep_diff(diff) == []
281
+
282
+
283
+ def test_does_not_flag_prose_in_test_file() -> None:
284
+ diff = (
285
+ "diff --git a/api/quota.test.mjs b/api/quota.test.mjs\n"
286
+ "--- a/api/quota.test.mjs\n"
287
+ "+++ b/api/quota.test.mjs\n"
288
+ "@@ -0,0 +1,2 @@\n"
289
+ "+const premium_interactions = 5\n"
290
+ "+// the premium-request path resets the counter\n"
291
+ )
292
+ assert sweep_diff(diff) == []
293
+
294
+
295
+ def test_flags_prose_in_test_named_markdown_doc() -> None:
296
+ diff = (
297
+ "diff --git a/api/quota.py b/api/quota.py\n"
298
+ "--- a/api/quota.py\n"
299
+ "+++ b/api/quota.py\n"
300
+ "@@ -0,0 +1,1 @@\n"
301
+ "+premium_interactions = 5\n"
302
+ "diff --git a/docs/test_plan.md b/docs/test_plan.md\n"
303
+ "--- a/docs/test_plan.md\n"
304
+ "+++ b/docs/test_plan.md\n"
305
+ "@@ -0,0 +1,1 @@\n"
306
+ "+The premium-request budget gates the run.\n"
230
307
  )
231
308
  findings = sweep_diff(diff)
232
309
  assert len(findings) == 1
233
310
  assert "premium-request" in findings[0]
311
+ assert "test_plan.md" in findings[0]
234
312
 
235
313
 
236
314
  def test_no_findings_when_no_multiword_identifier_introduced() -> None:
@@ -273,7 +351,13 @@ def test_staged_terminology_findings_flags_staged_prose(tmp_path: Path) -> None:
273
351
  (tmp_path / "README.md").write_text(
274
352
  "The premium-request budget gates the run.\n", encoding="utf-8"
275
353
  )
276
- subprocess.run(["git", "add", "-A"], cwd=tmp_path, check=True, capture_output=True)
354
+ subprocess.run(
355
+ ["git", "add", "-A"],
356
+ cwd=tmp_path,
357
+ check=True,
358
+ capture_output=True,
359
+ env=_git_environment(),
360
+ )
277
361
  findings = staged_terminology_findings(tmp_path)
278
362
  assert any("premium-request" in each_finding for each_finding in findings)
279
363
 
@@ -281,7 +365,13 @@ def test_staged_terminology_findings_flags_staged_prose(tmp_path: Path) -> None:
281
365
  def test_staged_terminology_findings_empty_when_clean(tmp_path: Path) -> None:
282
366
  _init_git_repository(tmp_path)
283
367
  (tmp_path / "README.md").write_text("Nothing notable here.\n", encoding="utf-8")
284
- subprocess.run(["git", "add", "-A"], cwd=tmp_path, check=True, capture_output=True)
368
+ subprocess.run(
369
+ ["git", "add", "-A"],
370
+ cwd=tmp_path,
371
+ check=True,
372
+ capture_output=True,
373
+ env=_git_environment(),
374
+ )
285
375
  assert staged_terminology_findings(tmp_path) == []
286
376
 
287
377
 
@@ -45,11 +45,11 @@ from hooks_constants.claude_md_orphan_file_blocker_constants import ( # noqa: E
45
45
  SEPARATOR_CELL_PATTERN,
46
46
  TABLE_ROW_PATTERN,
47
47
  )
48
+ from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
48
49
  from hooks_constants.multi_edit_reconstruction import ( # noqa: E402
49
50
  apply_edits,
50
51
  edits_for_tool,
51
52
  )
52
- from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
53
53
  from hooks_constants.pre_tool_use_stdin import ( # noqa: E402
54
54
  read_hook_input_dictionary_from_stdin,
55
55
  )
@@ -93,6 +93,7 @@ from hooks_constants.blocking_check_limits import ( # noqa: E402
93
93
  MINIMUM_NAMED_MARKS_FOR_PROSE_ENUMERATION,
94
94
  MINIMUM_PUBLIC_CHECKS_FOR_MODULE_DOCSTRING_ROSTER,
95
95
  MINIMUM_PUBLIC_METHODS_FOR_CLASS_DOCSTRING_BREADTH,
96
+ MINIMUM_RANGE_ARGS_FOR_SPAN,
96
97
  MINIMUM_SIBLING_OCCURRENCES_FOR_SHARED_TOKEN,
97
98
  MINIMUM_TOKENS_FOR_DISPATCH_CALLEE,
98
99
  MINIMUM_TUPLE_MEMBERS_FOR_DOCSTRING_ENUMERATION,
@@ -2019,13 +2020,14 @@ def _documentable_docstrings_with_line(parsed_tree: ast.Module) -> list[tuple[in
2019
2020
  def check_docstring_cardinal_count_matches_constant_family(
2020
2021
  content: str, file_path: str
2021
2022
  ) -> list[str]:
2022
- """Flag a docstring cardinal count that under-names a referenced constant family.
2023
+ """Flag a docstring cardinal count that differs from a referenced constant family.
2023
2024
 
2024
2025
  The drift this catches: a docstring states a cardinal count of an outcome
2025
2026
  family (``Covers the four outcome branches: ...``) and enumerates that
2026
- family's members in prose, while the module references more members of the
2027
- same ``UPPER_SNAKE`` constant family than the count names. The prose
2028
- under-describes the code a reader trusts the count and the enumeration to
2027
+ family's members in prose, while the module references a member set of the
2028
+ same ``UPPER_SNAKE`` constant family whose size differs from the stated
2029
+ countabove it and below it both trip. The prose
2030
+ misdescribes the code — a reader trusts the count and the enumeration to
2029
2031
  be the full set, but the module imports and exercises an outcome the summary
2030
2032
  omits. The check binds only when the docstring states a cardinal beside an
2031
2033
  outcome noun, names two or more members of one referenced family, leaves at
@@ -2038,7 +2040,8 @@ def check_docstring_cardinal_count_matches_constant_family(
2038
2040
 
2039
2041
  Args:
2040
2042
  content: The source text to inspect.
2041
- file_path: The path the source will be written to, used for exemptions.
2043
+ file_path: Accepted for the uniform check interface; the body does not
2044
+ read it.
2042
2045
 
2043
2046
  Returns:
2044
2047
  One issue per docstring whose cardinal count and enumeration omit a
@@ -2473,12 +2476,12 @@ def _argument_prose_scopes_a_single_line(argument_prose: str) -> bool:
2473
2476
  )
2474
2477
 
2475
2478
 
2476
- def _call_node_builds_two_argument_range(call_node: ast.Call) -> bool:
2479
+ def _call_node_builds_explicit_start_range(call_node: ast.Call) -> bool:
2477
2480
  callee = call_node.func
2478
2481
  return (
2479
2482
  isinstance(callee, ast.Name)
2480
2483
  and callee.id == "range"
2481
- and len(call_node.args) >= MINIMUM_TOKENS_FOR_DISPATCH_CALLEE
2484
+ and len(call_node.args) >= MINIMUM_RANGE_ARGS_FOR_SPAN
2482
2485
  )
2483
2486
 
2484
2487
 
@@ -2490,7 +2493,7 @@ def _function_body_scopes_a_span_by_intersection(
2490
2493
  for each_node in ast.walk(function_node):
2491
2494
  if not isinstance(each_node, ast.Call):
2492
2495
  continue
2493
- if _call_node_builds_two_argument_range(each_node):
2496
+ if _call_node_builds_explicit_start_range(each_node):
2494
2497
  builds_range_span = True
2495
2498
  if _call_callee_name(each_node) in ALL_DOCSTRING_SPAN_RANGE_BODY_CALLEE_NAMES:
2496
2499
  calls_span_scoper = True
@@ -2510,11 +2513,12 @@ def check_docstring_args_single_line_scope_vs_span(content: str, file_path: str)
2510
2513
  is the deterministic slice of Category O6 docstring-vs-implementation drift
2511
2514
  for an Args single-line scope claim disagreeing with a span-intersection body.
2512
2515
 
2513
- An entry is left alone when its prose says "any line of" / "any line in" its
2514
- span, since that wording matches the span body. The body is judged a
2515
- span-intersection scoper only when it both builds a two-argument ``range(...)``
2516
- and calls a known span scoper, so a body that scopes by a single line never
2517
- trips the check. Hook infrastructure is in scope here the import-sort gate
2516
+ An entry is left alone when its prose says "any line of" / "any line in" /
2517
+ "any of its lines" / "any span line" about its span, since that wording
2518
+ matches the span body. The body is judged a span-intersection scoper only
2519
+ when it both builds an explicit-start ``range(...)`` (two or more
2520
+ arguments) and calls a known span scoper, so a body that scopes by a
2521
+ single line never trips the check. Hook infrastructure is in scope here — the import-sort gate
2518
2522
  that carries this drift class is itself a hook — and test files are exempt.
2519
2523
 
2520
2524
  Args:
@@ -244,7 +244,8 @@ def check_import_block_sorted(
244
244
  give ruff the filename it needs for config discovery.
245
245
  all_changed_lines: Post-edit line numbers the current edit touched, or
246
246
  None to treat the whole file as in scope. When provided, a finding
247
- blocks only when its block-anchor line is among the changed lines.
247
+ blocks only when any line of its block span is among the changed
248
+ lines.
248
249
  defer_scope_to_caller: When True, return every finding so the commit/push
249
250
  gate's ``Line N:`` partitioning scopes by added line.
250
251
 
@@ -813,9 +814,9 @@ def _naive_datetime_remediation(constructor_name: str) -> str:
813
814
  """Return the fix guidance for a specific naive datetime constructor.
814
815
 
815
816
  ``utcnow`` and ``utcfromtimestamp`` accept no ``tz`` argument, so each points
816
- at its aware replacement (``datetime.now(tz=...)`` and
817
- ``datetime.fromtimestamp(..., tz=...)``); ``fromtimestamp`` keeps its shape and
818
- only needs a ``tz=`` argument.
817
+ at its aware replacement (``now(tz=...)`` and ``fromtimestamp(..., tz=...)``
818
+ on the same datetime class, whichever import form the call used);
819
+ ``fromtimestamp`` keeps its shape and only needs a ``tz=`` argument.
819
820
 
820
821
  Args:
821
822
  constructor_name: The naive constructor the call used.
@@ -824,9 +825,12 @@ def _naive_datetime_remediation(constructor_name: str) -> str:
824
825
  The remediation clause to embed in the emitted issue.
825
826
  """
826
827
  if constructor_name == NAIVE_DATETIME_UTCNOW_CONSTRUCTOR:
827
- return "use datetime.now(tz=timezone.utc)"
828
+ return "replace utcnow() with now(tz=timezone.utc) on the same datetime class"
828
829
  if constructor_name == NAIVE_DATETIME_UTCFROMTIMESTAMP_CONSTRUCTOR:
829
- return "use datetime.fromtimestamp(..., tz=timezone.utc)"
830
+ return (
831
+ "replace utcfromtimestamp(...) with fromtimestamp(..., tz=timezone.utc) "
832
+ "on the same datetime class"
833
+ )
830
834
  return "pass tz= (e.g. tz=timezone.utc)"
831
835
 
832
836
 
@@ -434,7 +434,7 @@ def _contradicting_polarity_pair(target_name: str, called_name: str) -> tuple[st
434
434
 
435
435
 
436
436
  def check_polarity_name_contradiction(content: str, file_path: str) -> list[str]:
437
- """Flag a boolean assignment whose target and callee assert opposite polarity.
437
+ """Flag an assignment whose target and callee assert opposite polarity.
438
438
 
439
439
  ::
440
440
 
@@ -445,8 +445,10 @@ def check_polarity_name_contradiction(content: str, file_path: str) -> list[str]
445
445
  flag: is_inside_allowed = _point_hits_any_forbidden(...)
446
446
 
447
447
  The target token and the callee token contradict each other, so the reader
448
- cannot tell which name states the truth. Rename the callee to a neutral form
449
- the two names agree on at every call site.
448
+ cannot tell which name states the truth. Any single-name assignment from a
449
+ call is inspected collections and counts included, not only booleans.
450
+ Rename the callee to a neutral form the two names agree on at every call
451
+ site.
450
452
  """
451
453
  if is_test_file(file_path):
452
454
  return []
@@ -164,8 +164,9 @@ def _stem_matched_test_path(module_path: Path) -> Path | None:
164
164
 
165
165
  Returns:
166
166
  The path of the first existing ``test_<stem>.py`` or ``<stem>_test.py``
167
- file beside the module or under an ancestor ``tests`` directory — or
168
- None when the module has no dedicated test file.
167
+ file beside the module, or ``test_<stem>.py`` under an ancestor
168
+ ``tests`` directory — or None when the module has no dedicated test
169
+ file.
169
170
  """
170
171
  module_directory = module_path.parent
171
172
  module_stem = module_path.stem
@@ -1,4 +1,4 @@
1
- """Bare string-literal magic, inline literal-collection, inline tuple string-magic, and whitespace-indentation magic checks."""
1
+ """Bare string-literal magic, join-separator string magic, inline literal-collection, inline tuple string-magic, and whitespace-indentation magic checks."""
2
2
 
3
3
  import ast
4
4
  import re
@@ -1,4 +1,4 @@
1
- """Unused module-level import check and its import-range and type-checking-gate helpers."""
1
+ """Unused module-level import check and its import-range helpers."""
2
2
 
3
3
  import ast
4
4
  import sys
@@ -143,7 +143,7 @@ def check_unused_module_level_imports(
143
143
 
144
144
  When ``full_file_content`` is provided, ``content`` is treated as an Edit
145
145
  fragment containing the imports being added or replaced, while the
146
- ``__all__`` / ``TYPE_CHECKING`` gate detection and reference scanning run
146
+ ``__all__`` gate detection and reference scanning run
147
147
  against ``full_file_content`` (the post-edit file as it will look once the
148
148
  Edit applies). This prevents false-positive flags on imports added in the
149
149
  same Edit as their consumers.
@@ -75,6 +75,35 @@ def test_flags_anchor_line_scope_against_span_intersection_body() -> None:
75
75
  assert "all_changed_lines" in issues[0]
76
76
 
77
77
 
78
+ def test_flags_single_line_scope_against_stepped_range_span_body() -> None:
79
+ content = (
80
+ "def check_import_block_sorted(\n"
81
+ " content: str, file_path: str, all_changed_lines: set[int] | None\n"
82
+ ") -> list[str]:\n"
83
+ ' """Flag an unsorted import block scoped to the changed lines.\n'
84
+ "\n"
85
+ " Args:\n"
86
+ " content: The full file content the write would leave on disk.\n"
87
+ " file_path: The destination path used to gate by extension.\n"
88
+ " all_changed_lines: Post-edit line numbers, or None. When provided,\n"
89
+ " a finding blocks only when its block-anchor line is among the\n"
90
+ " changed lines.\n"
91
+ "\n"
92
+ " Returns:\n"
93
+ " One issue string per detected finding.\n"
94
+ ' """\n'
95
+ " span_range = range(line_number, block_end_line_number + 1, 1)\n"
96
+ " all_violations.append((span_range, message))\n"
97
+ " return _scope_violations_to_changed_lines(\n"
98
+ " all_violations, all_changed_lines, defer_scope_to_caller\n"
99
+ " )\n"
100
+ )
101
+ issues = check_docstring_args_single_line_scope_vs_span(content, PRODUCTION_FILE_PATH)
102
+ assert len(issues) == 1
103
+ assert "check_import_block_sorted" in issues[0]
104
+ assert "all_changed_lines" in issues[0]
105
+
106
+
78
107
  def test_flags_the_line_is_among_phrasing() -> None:
79
108
  content = (
80
109
  "def check_block(\n"
@@ -181,6 +181,30 @@ def test_utcfromtimestamp_remediation_uses_generic_placeholder() -> None:
181
181
  assert "stamp, tz=" not in issues[0]
182
182
 
183
183
 
184
+ def test_utcnow_remediation_is_import_agnostic() -> None:
185
+ content = (
186
+ "import datetime\n"
187
+ "def stamp() -> object:\n"
188
+ " return datetime.datetime.utcnow()\n"
189
+ )
190
+ issues = check_naive_datetime_construction(content, PRODUCTION_FILE_PATH)
191
+ assert len(issues) == 1
192
+ assert "use datetime.now(" not in issues[0]
193
+ assert "on the same datetime class" in issues[0]
194
+
195
+
196
+ def test_utcfromtimestamp_remediation_is_import_agnostic() -> None:
197
+ content = (
198
+ "import datetime\n"
199
+ "def build(stamp: float) -> object:\n"
200
+ " return datetime.datetime.utcfromtimestamp(stamp)\n"
201
+ )
202
+ issues = check_naive_datetime_construction(content, PRODUCTION_FILE_PATH)
203
+ assert len(issues) == 1
204
+ assert "use datetime.fromtimestamp(" not in issues[0]
205
+ assert "on the same datetime class" in issues[0]
206
+
207
+
184
208
  def test_remediation_does_not_mandate_astimezone() -> None:
185
209
  content = (
186
210
  "from datetime import datetime\n"
@@ -99,6 +99,7 @@ ALL_PUNCTUATION_MARK_GLYPH_PROSE_NAMES: dict[str, tuple[str, ...]] = {
99
99
  MAX_DOCSTRING_STEP_DISPATCH_ISSUES: int = 5
100
100
  MINIMUM_NAMED_LINEAR_STEPS_FOR_DISPATCH_CHECK: int = 2
101
101
  MINIMUM_TOKENS_FOR_DISPATCH_CALLEE: int = 2
102
+ MINIMUM_RANGE_ARGS_FOR_SPAN: int = 2
102
103
  MAX_DOCSTRING_UNDEFINED_CONSTANT_ISSUES: int = 3
103
104
  MAX_DOCSTRING_RETURNS_PLURAL_CARDINALITY_ISSUES: int = 5
104
105
  SINGLE_DICT_KEY_COUNT_FOR_PLURAL_CARDINALITY_DRIFT: int = 1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "1.84.0",
3
+ "version": "1.85.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {