claude-dev-env 1.84.0 → 1.86.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 (50) hide show
  1. package/_shared/pr-loop/scripts/code_rules_gate.py +18 -1
  2. package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -1
  3. package/_shared/pr-loop/scripts/pr_loop_shared_constants/terminology_sweep_constants.py +143 -0
  4. package/_shared/pr-loop/scripts/terminology_sweep.py +306 -64
  5. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +89 -0
  6. package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +200 -21
  7. package/hooks/blocking/CLAUDE.md +1 -0
  8. package/hooks/blocking/claude_md_orphan_file_blocker.py +1 -1
  9. package/hooks/blocking/code_rules_dead_module_constant.py +7 -4
  10. package/hooks/blocking/code_rules_docstrings.py +17 -13
  11. package/hooks/blocking/code_rules_imports_logging.py +10 -6
  12. package/hooks/blocking/code_rules_naming_collection.py +5 -3
  13. package/hooks/blocking/code_rules_paired_test.py +3 -2
  14. package/hooks/blocking/code_rules_string_magic.py +1 -1
  15. package/hooks/blocking/code_rules_unused_imports.py +2 -2
  16. package/hooks/blocking/session_edit_stage_gate.py +654 -0
  17. package/hooks/blocking/test_code_rules_enforcer_docstring_args_span_scope.py +29 -0
  18. package/hooks/blocking/test_code_rules_enforcer_naive_datetime.py +24 -0
  19. package/hooks/blocking/test_session_edit_stage_gate.py +537 -0
  20. package/hooks/hooks.json +30 -0
  21. package/hooks/hooks_constants/CLAUDE.md +2 -0
  22. package/hooks/hooks_constants/blocking_check_limits.py +1 -0
  23. package/hooks/hooks_constants/session_edit_stage_gate_constants.py +92 -0
  24. package/hooks/hooks_constants/task_list_loop_starter_constants.py +18 -0
  25. package/hooks/lifecycle/CLAUDE.md +1 -1
  26. package/hooks/observability/CLAUDE.md +2 -0
  27. package/hooks/observability/session_file_edit_tracker.py +224 -0
  28. package/hooks/observability/test_session_file_edit_tracker.py +174 -0
  29. package/hooks/session/CLAUDE.md +6 -2
  30. package/hooks/session/session_edit_tracker_cleanup.py +120 -0
  31. package/hooks/session/task_list_loop_starter.py +36 -0
  32. package/hooks/session/test_session_edit_tracker_cleanup.py +157 -0
  33. package/hooks/session/test_task_list_loop_starter.py +53 -0
  34. package/package.json +1 -1
  35. package/rules/CLAUDE.md +1 -0
  36. package/rules/docstring-prose-matches-implementation.md +1 -1
  37. package/rules/env-var-table-code-drift.md +1 -1
  38. package/rules/package-inventory-stale-entry.md +1 -1
  39. package/rules/paired-test-coverage.md +1 -1
  40. package/rules/re-stage-before-commit.md +31 -0
  41. package/skills/_shared/pr-loop/CLAUDE.md +1 -0
  42. package/skills/_shared/pr-loop/scripts/CLAUDE.md +1 -0
  43. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/CLAUDE.md +1 -0
  44. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/handoff_constants.py +45 -0
  45. package/skills/_shared/pr-loop/scripts/test_write_handoff.py +201 -0
  46. package/skills/_shared/pr-loop/scripts/write_handoff.py +309 -0
  47. package/skills/autoconverge/SKILL.md +46 -3
  48. package/skills/autoconverge/workflow/converge.mjs +1 -1
  49. package/skills/pr-converge/SKILL.md +27 -1
  50. package/skills/pr-converge/reference/state-schema.md +12 -0
@@ -5,6 +5,8 @@ import subprocess
5
5
  from pathlib import Path
6
6
  from types import ModuleType
7
7
 
8
+ import pytest
9
+
8
10
 
9
11
  def _load_sweep_module() -> ModuleType:
10
12
  module_path = Path(__file__).parent.parent / "terminology_sweep.py"
@@ -23,13 +25,22 @@ main = sweep_module.main
23
25
  parse_added_lines = sweep_module._parse_added_lines
24
26
 
25
27
 
28
+ _hermetic_git_environment = sweep_module.repository_environment
29
+
30
+
26
31
  def _init_git_repository(repository_path: Path) -> None:
27
32
  for each_command in (
28
33
  ["git", "init"],
29
34
  ["git", "config", "user.email", "test@example.com"],
30
35
  ["git", "config", "user.name", "Test"],
31
36
  ):
32
- subprocess.run(each_command, cwd=repository_path, check=True, capture_output=True)
37
+ subprocess.run(
38
+ each_command,
39
+ cwd=repository_path,
40
+ check=True,
41
+ capture_output=True,
42
+ env=_hermetic_git_environment(),
43
+ )
33
44
 
34
45
 
35
46
  CODE_AND_PROSE_DIFF = (
@@ -191,7 +202,7 @@ def test_does_not_flag_y_to_ies_plural_variant() -> None:
191
202
  assert sweep_diff(diff) == []
192
203
 
193
204
 
194
- def test_does_not_flag_spaced_prose_sharing_identifier_prefix() -> None:
205
+ def test_still_flags_non_plural_divergent_tail() -> None:
195
206
  diff = (
196
207
  "diff --git a/api/quota.py b/api/quota.py\n"
197
208
  "--- a/api/quota.py\n"
@@ -204,19 +215,9 @@ def test_does_not_flag_spaced_prose_sharing_identifier_prefix() -> None:
204
215
  "@@ -0,0 +1,1 @@\n"
205
216
  "+The premium request budget gates the run.\n"
206
217
  )
207
- assert sweep_diff(diff) == []
208
-
209
-
210
- def test_does_not_flag_ordinary_sentence_sharing_loop_variable_prefix() -> None:
211
- diff = (
212
- "diff --git a/api/scan.py b/api/scan.py\n"
213
- "--- a/api/scan.py\n"
214
- "+++ b/api/scan.py\n"
215
- "@@ -0,0 +1,2 @@\n"
216
- "+each_node = walk(tree)\n"
217
- '+DESCRIPTION = "Each attempt polls the review endpoint once."\n'
218
- )
219
- assert sweep_diff(diff) == []
218
+ findings = sweep_diff(diff)
219
+ assert len(findings) == 1
220
+ assert "premium request" in findings[0]
220
221
 
221
222
 
222
223
  def test_flags_near_miss_inside_code_comment() -> None:
@@ -267,13 +268,17 @@ def test_main_exits_zero_when_clean(tmp_path: Path) -> None:
267
268
 
268
269
  def test_staged_terminology_findings_flags_staged_prose(tmp_path: Path) -> None:
269
270
  _init_git_repository(tmp_path)
270
- (tmp_path / "quota.py").write_text(
271
- "premium_interactions = 5\n", encoding="utf-8"
272
- )
271
+ (tmp_path / "quota.py").write_text("premium_interactions = 5\n", encoding="utf-8")
273
272
  (tmp_path / "README.md").write_text(
274
273
  "The premium-request budget gates the run.\n", encoding="utf-8"
275
274
  )
276
- subprocess.run(["git", "add", "-A"], cwd=tmp_path, check=True, capture_output=True)
275
+ subprocess.run(
276
+ ["git", "add", "-A"],
277
+ cwd=tmp_path,
278
+ check=True,
279
+ capture_output=True,
280
+ env=_hermetic_git_environment(),
281
+ )
277
282
  findings = staged_terminology_findings(tmp_path)
278
283
  assert any("premium-request" in each_finding for each_finding in findings)
279
284
 
@@ -281,7 +286,13 @@ def test_staged_terminology_findings_flags_staged_prose(tmp_path: Path) -> None:
281
286
  def test_staged_terminology_findings_empty_when_clean(tmp_path: Path) -> None:
282
287
  _init_git_repository(tmp_path)
283
288
  (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)
289
+ subprocess.run(
290
+ ["git", "add", "-A"],
291
+ cwd=tmp_path,
292
+ check=True,
293
+ capture_output=True,
294
+ env=_hermetic_git_environment(),
295
+ )
285
296
  assert staged_terminology_findings(tmp_path) == []
286
297
 
287
298
 
@@ -299,9 +310,177 @@ def test_parse_added_lines_counts_pre_increment_content_as_added_line() -> None:
299
310
  all_added_lines = parse_added_lines(diff_with_pre_increment)
300
311
 
301
312
  assert [
302
- (each_line_number, each_text) for _, each_line_number, each_text in all_added_lines
313
+ (each_line_number, each_text)
314
+ for _, each_line_number, each_text in all_added_lines
303
315
  ] == [
304
316
  (1, "++counter;"),
305
317
  (2, "let first_total = read_first_value();"),
306
318
  (3, "let second_total = read_second_value();"),
307
319
  ]
320
+
321
+
322
+ def test_does_not_flag_window_containing_a_stopword() -> None:
323
+ diff = (
324
+ "diff --git a/api/quota.py b/api/quota.py\n"
325
+ "--- a/api/quota.py\n"
326
+ "+++ b/api/quota.py\n"
327
+ "@@ -0,0 +1,1 @@\n"
328
+ "+to_path = destination\n"
329
+ "diff --git a/docs/README.md b/docs/README.md\n"
330
+ "--- a/docs/README.md\n"
331
+ "+++ b/docs/README.md\n"
332
+ "@@ -0,0 +1,1 @@\n"
333
+ "+Writes the report to a fresh location.\n"
334
+ )
335
+ assert sweep_diff(diff) == []
336
+
337
+
338
+ def test_does_not_flag_tail_word_that_is_identifier_vocabulary() -> None:
339
+ diff = (
340
+ "diff --git a/api/layout.py b/api/layout.py\n"
341
+ "--- a/api/layout.py\n"
342
+ "+++ b/api/layout.py\n"
343
+ "@@ -0,0 +1,1 @@\n"
344
+ "+target_width = box_height\n"
345
+ "diff --git a/docs/README.md b/docs/README.md\n"
346
+ "--- a/docs/README.md\n"
347
+ "+++ b/docs/README.md\n"
348
+ "@@ -0,0 +1,1 @@\n"
349
+ "+Scale the art until it covers the target box.\n"
350
+ )
351
+ assert sweep_diff(diff) == []
352
+
353
+
354
+ def test_staged_skips_identifier_already_in_base_tree(tmp_path: Path) -> None:
355
+ _init_git_repository(tmp_path)
356
+ (tmp_path / "api.py").write_text("box_height = 4\n", encoding="utf-8")
357
+ subprocess.run(
358
+ ["git", "add", "."],
359
+ cwd=tmp_path,
360
+ check=True,
361
+ capture_output=True,
362
+ env=_hermetic_git_environment(),
363
+ )
364
+ subprocess.run(
365
+ ["git", "commit", "-m", "base"],
366
+ cwd=tmp_path,
367
+ check=True,
368
+ capture_output=True,
369
+ env=_hermetic_git_environment(),
370
+ )
371
+ all_api_lines = ("box_height = 4", "box_frame_total = box_height")
372
+ (tmp_path / "api.py").write_text(chr(10).join(all_api_lines), encoding="utf-8")
373
+ (tmp_path / "README.md").write_text(
374
+ "The box is read from the capture.", encoding="utf-8"
375
+ )
376
+ subprocess.run(
377
+ ["git", "add", "."],
378
+ cwd=tmp_path,
379
+ check=True,
380
+ capture_output=True,
381
+ env=_hermetic_git_environment(),
382
+ )
383
+ assert staged_terminology_findings(tmp_path) == []
384
+
385
+
386
+ def test_does_not_flag_escape_sequence_letter_as_prose() -> None:
387
+ diff = (
388
+ "diff --git a/api/quota.py b/api/quota.py\n"
389
+ "--- a/api/quota.py\n"
390
+ "+++ b/api/quota.py\n"
391
+ "@@ -0,0 +1,2 @@\n"
392
+ "+box_frame_total = 5\n"
393
+ '+content = "box_height = 4\\nbox_frame_total = 5\\n"\n'
394
+ )
395
+ assert sweep_diff(diff) == []
396
+
397
+
398
+ def test_skips_string_literal_fragments_in_test_files() -> None:
399
+ diff = (
400
+ "diff --git a/api/quota.py b/api/quota.py\n"
401
+ "--- a/api/quota.py\n"
402
+ "+++ b/api/quota.py\n"
403
+ "@@ -0,0 +1,1 @@\n"
404
+ "+premium_interactions = 5\n"
405
+ "diff --git a/tests/test_quota.py b/tests/test_quota.py\n"
406
+ "--- a/tests/test_quota.py\n"
407
+ "+++ b/tests/test_quota.py\n"
408
+ "@@ -0,0 +1,2 @@\n"
409
+ '+fixture_text = "the premium-request budget gates the run"\n'
410
+ "+# the premium-request path resets the counter\n"
411
+ )
412
+ findings = sweep_diff(diff)
413
+ assert len(findings) == 1
414
+ assert "test_quota.py:2" in findings[0]
415
+
416
+
417
+ def test_repository_environment_strips_every_git_variable(
418
+ monkeypatch: pytest.MonkeyPatch,
419
+ ) -> None:
420
+ monkeypatch.setenv("GIT_DIR", "hostile-git-dir")
421
+ monkeypatch.setenv("PRESERVED_SETTING", "kept")
422
+
423
+ scrubbed_environment = sweep_module.repository_environment()
424
+
425
+ assert "GIT_DIR" not in scrubbed_environment
426
+ assert scrubbed_environment["PRESERVED_SETTING"] == "kept"
427
+
428
+
429
+ def test_skips_markdown_inline_code_spans() -> None:
430
+ diff = (
431
+ "diff --git a/api/quota.py b/api/quota.py\n"
432
+ "--- a/api/quota.py\n"
433
+ "+++ b/api/quota.py\n"
434
+ "@@ -0,0 +1,1 @@\n"
435
+ "+scale_expression_reference = 1\n"
436
+ "diff --git a/docs/guide.md b/docs/guide.md\n"
437
+ "--- a/docs/guide.md\n"
438
+ "+++ b/docs/guide.md\n"
439
+ "@@ -0,0 +1,1 @@\n"
440
+ "+Its `scale_expression.py` evaluates the layer's `scale` prop here.\n"
441
+ )
442
+ assert sweep_diff(diff) == []
443
+
444
+
445
+ def test_skips_identifier_shaped_string_literals() -> None:
446
+ diff = (
447
+ "diff --git a/api/quota.py b/api/quota.py\n"
448
+ "--- a/api/quota.py\n"
449
+ "+++ b/api/quota.py\n"
450
+ "@@ -0,0 +1,2 @@\n"
451
+ "+icon_entry = 1\n"
452
+ '+MASTER_UID = "HOMESCREEN_APPICONS_APP_ICON_IMAGE"\n'
453
+ )
454
+ assert sweep_diff(diff) == []
455
+
456
+
457
+ def test_skips_string_literal_fragments_in_dot_test_named_files() -> None:
458
+ diff = (
459
+ "diff --git a/api/quota.py b/api/quota.py\n"
460
+ "--- a/api/quota.py\n"
461
+ "+++ b/api/quota.py\n"
462
+ "@@ -0,0 +1,1 @@\n"
463
+ "+premium_interactions = 5\n"
464
+ "diff --git a/api/quota.test.mjs b/api/quota.test.mjs\n"
465
+ "--- a/api/quota.test.mjs\n"
466
+ "+++ b/api/quota.test.mjs\n"
467
+ "@@ -0,0 +1,1 @@\n"
468
+ '+const fixture_line = "the premium-request budget gates the run"\n'
469
+ )
470
+ assert sweep_diff(diff) == []
471
+
472
+
473
+ def test_skips_string_literal_fragments_in_tests_directory_files() -> None:
474
+ diff = (
475
+ "diff --git a/api/quota.py b/api/quota.py\n"
476
+ "--- a/api/quota.py\n"
477
+ "+++ b/api/quota.py\n"
478
+ "@@ -0,0 +1,1 @@\n"
479
+ "+premium_interactions = 5\n"
480
+ "diff --git a/tests/fixtures.py b/tests/fixtures.py\n"
481
+ "--- a/tests/fixtures.py\n"
482
+ "+++ b/tests/fixtures.py\n"
483
+ "@@ -0,0 +1,1 @@\n"
484
+ '+sample_line = "the premium-request budget gates the run"\n'
485
+ )
486
+ assert sweep_diff(diff) == []
@@ -88,6 +88,7 @@ The check modules it calls are the `code_rules_<concern>.py` files below.
88
88
  | `reviewer_spawn_gate.py` | PreToolUse (Bash) | A sentinel-marked autoconverge reviewer-spawn command (Copilot review request, Bugbot rerun comment) run while `reviewer_availability.py` reports that reviewer down or out of quota |
89
89
  | `send_user_file_open_locally_blocker.py` | PreToolUse (SendUserFile) | A desk-side file attach (`SendUserFile` with `status` not `proactive`); points to opening the file locally via `Show-Asset.ps1` |
90
90
  | `sensitive_file_protector.py` | PreToolUse (Write/Edit) | Writes to sensitive credential or config files |
91
+ | `session_edit_stage_gate.py` | PreToolUse (Bash) | A `git commit` that would drop files edited this session because they are tracked but left unstaged |
91
92
  | `session_handoff_blocker.py` | Stop | Responses suggesting a new session mid-task |
92
93
  | `stale_comment_reference_blocker.py` | PreToolUse (Edit) | An Edit that rewrites a Python code line while keeping the standalone comment directly above it, when that comment names an identifier the rewrite removes from the line |
93
94
  | `state_description_blocker.py` | PreToolUse (Write/Edit) | Historical/comparative language in documentation |
@@ -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
  )
@@ -501,13 +501,16 @@ def check_dead_module_constants(
501
501
 
502
502
  Returns:
503
503
  One violation message per dead module-level constant, capped at the
504
- configured maximum. Returns an empty list when the file is exempt, no
505
- constant is in scope (none defined, or none exported when ``__all__`` is
506
- declared), the scan exceeds the file cap, or a SyntaxError prevents
507
- parsing.
504
+ configured maximum. Returns an empty list when the file is exempt, the
505
+ path is relative (the scan root cannot be resolved against a known
506
+ base, so the check cannot prove a constant dead), no constant is in
507
+ scope (none defined, or none exported when ``__all__`` is declared),
508
+ the scan exceeds the file cap, or a SyntaxError prevents parsing.
508
509
  """
509
510
  if _module_is_exempt_from_constant_check(file_path):
510
511
  return []
512
+ if not Path(file_path).is_absolute():
513
+ return []
511
514
  effective_content = content if full_file_content is None else full_file_content
512
515
  try:
513
516
  tree = ast.parse(effective_content)
@@ -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.