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.
- package/_shared/pr-loop/scripts/code_rules_gate.py +18 -1
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -1
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/terminology_sweep_constants.py +143 -0
- package/_shared/pr-loop/scripts/terminology_sweep.py +306 -64
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +89 -0
- package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +200 -21
- package/hooks/blocking/CLAUDE.md +1 -0
- package/hooks/blocking/claude_md_orphan_file_blocker.py +1 -1
- package/hooks/blocking/code_rules_dead_module_constant.py +7 -4
- package/hooks/blocking/code_rules_docstrings.py +17 -13
- package/hooks/blocking/code_rules_imports_logging.py +10 -6
- package/hooks/blocking/code_rules_naming_collection.py +5 -3
- package/hooks/blocking/code_rules_paired_test.py +3 -2
- package/hooks/blocking/code_rules_string_magic.py +1 -1
- package/hooks/blocking/code_rules_unused_imports.py +2 -2
- package/hooks/blocking/session_edit_stage_gate.py +654 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_args_span_scope.py +29 -0
- package/hooks/blocking/test_code_rules_enforcer_naive_datetime.py +24 -0
- package/hooks/blocking/test_session_edit_stage_gate.py +537 -0
- package/hooks/hooks.json +30 -0
- package/hooks/hooks_constants/CLAUDE.md +2 -0
- package/hooks/hooks_constants/blocking_check_limits.py +1 -0
- package/hooks/hooks_constants/session_edit_stage_gate_constants.py +92 -0
- package/hooks/hooks_constants/task_list_loop_starter_constants.py +18 -0
- package/hooks/lifecycle/CLAUDE.md +1 -1
- package/hooks/observability/CLAUDE.md +2 -0
- package/hooks/observability/session_file_edit_tracker.py +224 -0
- package/hooks/observability/test_session_file_edit_tracker.py +174 -0
- package/hooks/session/CLAUDE.md +6 -2
- package/hooks/session/session_edit_tracker_cleanup.py +120 -0
- package/hooks/session/task_list_loop_starter.py +36 -0
- package/hooks/session/test_session_edit_tracker_cleanup.py +157 -0
- package/hooks/session/test_task_list_loop_starter.py +53 -0
- package/package.json +1 -1
- package/rules/CLAUDE.md +1 -0
- package/rules/docstring-prose-matches-implementation.md +1 -1
- package/rules/env-var-table-code-drift.md +1 -1
- package/rules/package-inventory-stale-entry.md +1 -1
- package/rules/paired-test-coverage.md +1 -1
- package/rules/re-stage-before-commit.md +31 -0
- package/skills/_shared/pr-loop/CLAUDE.md +1 -0
- package/skills/_shared/pr-loop/scripts/CLAUDE.md +1 -0
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/CLAUDE.md +1 -0
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/handoff_constants.py +45 -0
- package/skills/_shared/pr-loop/scripts/test_write_handoff.py +201 -0
- package/skills/_shared/pr-loop/scripts/write_handoff.py +309 -0
- package/skills/autoconverge/SKILL.md +46 -3
- package/skills/autoconverge/workflow/converge.mjs +1 -1
- package/skills/pr-converge/SKILL.md +27 -1
- 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(
|
|
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
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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)
|
|
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) == []
|
package/hooks/blocking/CLAUDE.md
CHANGED
|
@@ -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,
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
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
|
|
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
|
|
2027
|
-
same ``UPPER_SNAKE`` constant family
|
|
2028
|
-
|
|
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
|
+
count — above 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:
|
|
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
|
|
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) >=
|
|
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
|
|
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"
|
|
2514
|
-
|
|
2515
|
-
span
|
|
2516
|
-
|
|
2517
|
-
|
|
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
|
|
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 (``
|
|
817
|
-
|
|
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 "
|
|
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
|
|
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
|
|
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.
|
|
449
|
-
|
|
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
|
|
168
|
-
None when the module has no dedicated test
|
|
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
|
|
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__``
|
|
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.
|