claude-dev-env 1.78.0 → 1.80.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/CLAUDE.md +1 -0
- package/_shared/pr-loop/scripts/copilot_quota.py +360 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/copilot_quota_constants.py +24 -0
- package/_shared/pr-loop/scripts/tests/CLAUDE.md +7 -0
- package/_shared/pr-loop/scripts/tests/fixtures/copilot_internal_user_jonecho.json +76 -0
- package/_shared/pr-loop/scripts/tests/test_copilot_quota.py +242 -0
- package/_shared/pr-loop/scripts/tests/test_copilot_quota_constants.py +63 -0
- package/audit-rubrics/category_rubrics/category-k-codebase-conflicts.md +1 -0
- package/bin/install.mjs +1 -0
- package/bin/install.test.mjs +3 -2
- package/hooks/blocking/CLAUDE.md +3 -2
- package/hooks/blocking/code_rules_docstrings.py +609 -16
- package/hooks/blocking/code_rules_enforcer.py +41 -0
- package/hooks/blocking/code_rules_imports_logging.py +136 -1
- package/hooks/blocking/code_rules_naming_collection.py +76 -1
- package/hooks/blocking/code_rules_paired_test.py +240 -22
- package/hooks/blocking/code_rules_test_assertions.py +159 -1
- package/hooks/blocking/code_rules_unused_imports.py +7 -63
- package/hooks/blocking/env_var_table_code_drift_blocker.py +475 -0
- package/hooks/blocking/package_inventory_stale_blocker.py +54 -15
- package/hooks/blocking/test_code_rules_enforcer_docstring_field_runmode_outcome.py +129 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_length_constant_superlative.py +198 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_no_network.py +115 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_unreferenced_param.py +160 -0
- package/hooks/blocking/test_code_rules_enforcer_js_returns_object.py +72 -0
- package/hooks/blocking/test_code_rules_enforcer_module_docstring_data_schema_scope.py +82 -0
- package/hooks/blocking/test_code_rules_enforcer_paired_test.py +190 -0
- package/hooks/blocking/test_code_rules_enforcer_polarity_name_contradiction.py +76 -0
- package/hooks/blocking/test_code_rules_enforcer_unused_imports.py +96 -15
- package/hooks/blocking/test_code_rules_enforcer_vacuous_cleanup_assertion.py +132 -0
- package/hooks/blocking/test_code_rules_js_returns_object_schemaless.py +167 -0
- package/hooks/blocking/test_env_var_table_code_drift_blocker.py +94 -0
- package/hooks/blocking/test_package_inventory_stale_blocker.py +46 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +7 -7
- package/hooks/hooks_constants/CLAUDE.md +1 -0
- package/hooks/hooks_constants/blocking_check_limits.py +79 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +28 -0
- package/hooks/hooks_constants/env_var_table_code_drift_constants.py +64 -0
- package/hooks/hooks_constants/package_inventory_stale_blocker_constants.py +20 -9
- package/hooks/hooks_constants/paired_test_coverage_constants.py +11 -3
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
- package/hooks/hooks_constants/test_code_rules_enforcer_constants.py +93 -0
- package/hooks/hooks_constants/unused_module_import_constants.py +0 -1
- package/package.json +1 -1
- package/rules/CLAUDE.md +1 -0
- package/rules/docstring-prose-matches-implementation.md +57 -54
- package/rules/env-var-table-code-drift.md +24 -0
- package/rules/package-inventory-stale-entry.md +4 -4
- package/rules/paired-test-coverage.md +12 -5
- package/skills/autoconverge/SKILL.md +26 -5
- package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +4 -4
- package/skills/autoconverge/workflow/converge.contract.test.mjs +56 -120
- package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +45 -5
- package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +50 -46
- package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +6 -6
- package/skills/autoconverge/workflow/converge.mjs +110 -228
- package/skills/autoconverge/workflow/converge.run-input.test.mjs +11 -0
- package/skills/autoconverge/workflow/converge_multi.mjs +23 -7
- package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +28 -2
- package/skills/pr-converge/SKILL.md +28 -2
- package/skills/pr-converge/reference/convergence-gates.md +13 -1
- package/skills/pr-converge/reference/state-schema.md +11 -0
|
@@ -3,29 +3,48 @@
|
|
|
3
3
|
The TDD gate ``tdd_enforcer.py`` requires a fresh test file to exist before a
|
|
4
4
|
production module is written, but it judges coverage at file granularity: a
|
|
5
5
|
module whose dedicated test file exercises some public functions while leaving
|
|
6
|
-
one untested still satisfies it. This check closes that gap
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
6
|
+
one untested still satisfies it. This check closes that gap from both write
|
|
7
|
+
orders, so a forgotten function gets a behavioral test before the
|
|
8
|
+
partially-covered module lands.
|
|
9
|
+
|
|
10
|
+
``check_public_function_missing_paired_test`` fires on the production-module
|
|
11
|
+
write: when a module has an established paired test suite — a stem-matched
|
|
12
|
+
``test_<stem>.py`` that already exercises the module, either by covering at least
|
|
13
|
+
one of its public functions or by referencing one of its private helpers — a
|
|
14
|
+
public function that suite references nowhere is flagged.
|
|
15
|
+
|
|
16
|
+
``check_test_file_omits_module_public_function`` fires on the stem-matched
|
|
17
|
+
test-file write, covering the reverse order in which the production module is
|
|
18
|
+
written before its test file exists. It resolves the production module the
|
|
19
|
+
written ``test_<stem>.py`` or ``<stem>_test.py`` file pairs with and flags every
|
|
20
|
+
public function the post-edit suite omits, so the gap that the production-side
|
|
21
|
+
check cannot see — because no suite existed when the module was written — is
|
|
22
|
+
caught when the test file lands.
|
|
23
|
+
|
|
24
|
+
Both checks stay conservative to keep false positives near zero:
|
|
25
|
+
|
|
26
|
+
- The production-side check runs only on a production module whose stem-matched
|
|
27
|
+
test file already exists; a module with no dedicated test file is out of scope
|
|
28
|
+
and left to the file-level TDD gate. The test-side check runs only on a
|
|
29
|
+
stem-matched test file whose paired production module exists on disk.
|
|
30
|
+
- The production-side check fires only when the suite already exercises the
|
|
31
|
+
module — covering at least one of its public functions, or referencing one of
|
|
32
|
+
its private helpers by name — the signature of a maintained per-module suite
|
|
33
|
+
rather than an unrelated or placeholder test file. A suite that exercises only
|
|
34
|
+
a private helper while omitting the public surface is the exact shape the
|
|
35
|
+
private-helper establishment catches. The test-side check fires only when the
|
|
36
|
+
post-edit suite already covers at least one public function of the paired
|
|
37
|
+
module.
|
|
38
|
+
- A public function counts as covered when its name appears in any test file in
|
|
39
|
+
the suite directory — imported, called, or named — so a function exercised by
|
|
40
|
+
a differently-named sibling test still counts.
|
|
23
41
|
- ``main`` and underscore-prefixed functions are never required to carry a test.
|
|
24
42
|
- Test modules, hook infrastructure, config modules, migrations, workflow
|
|
25
|
-
registries, and ``__init__`` modules are exempt.
|
|
43
|
+
registries, and ``__init__`` modules are exempt as production targets.
|
|
26
44
|
"""
|
|
27
45
|
|
|
28
46
|
import ast
|
|
47
|
+
import os
|
|
29
48
|
import sys
|
|
30
49
|
from pathlib import Path
|
|
31
50
|
|
|
@@ -59,6 +78,7 @@ from hooks_constants.paired_test_coverage_constants import ( # noqa: E402
|
|
|
59
78
|
PYTHON_SOURCE_SUFFIX,
|
|
60
79
|
STEM_TEST_FILENAME_PREFIX,
|
|
61
80
|
STEM_TEST_FILENAME_SUFFIX,
|
|
81
|
+
TEST_SUITE_OMITS_FUNCTION_GUIDANCE,
|
|
62
82
|
TESTS_DIRECTORY_NAME,
|
|
63
83
|
)
|
|
64
84
|
|
|
@@ -97,6 +117,26 @@ def _public_function_definitions(tree: ast.Module) -> list[tuple[str, int]]:
|
|
|
97
117
|
return all_definitions
|
|
98
118
|
|
|
99
119
|
|
|
120
|
+
def _private_function_names(tree: ast.Module) -> set[str]:
|
|
121
|
+
"""Return the names of module-scope underscore-prefixed function definitions.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
tree: The parsed production module.
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
Each module-scope function name that begins with an underscore - the
|
|
128
|
+
private helpers a paired test suite reaches when it exercises the
|
|
129
|
+
module's internals.
|
|
130
|
+
"""
|
|
131
|
+
all_private_names: set[str] = set()
|
|
132
|
+
for each_statement in tree.body:
|
|
133
|
+
if not isinstance(each_statement, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
134
|
+
continue
|
|
135
|
+
if each_statement.name.startswith("_"):
|
|
136
|
+
all_private_names.add(each_statement.name)
|
|
137
|
+
return all_private_names
|
|
138
|
+
|
|
139
|
+
|
|
100
140
|
def _ancestor_tests_directories(start_directory: Path) -> list[Path]:
|
|
101
141
|
"""Return each ancestor's ``tests`` directory, nearest ancestor first.
|
|
102
142
|
|
|
@@ -235,7 +275,8 @@ def check_public_function_missing_paired_test(
|
|
|
235
275
|
"""Flag a public function the module's established paired test suite omits.
|
|
236
276
|
|
|
237
277
|
Runs only on a production module whose stem-matched ``test_<stem>.py`` already
|
|
238
|
-
exists and already exercises at least one of
|
|
278
|
+
exists and already exercises the module — by covering at least one of its
|
|
279
|
+
public functions, or by referencing one of its private helpers by name.
|
|
239
280
|
Under that established-suite precondition, a public function the suite
|
|
240
281
|
references nowhere is flagged, so the forgotten function gets a behavioral
|
|
241
282
|
test before the partially-covered module lands. ``main`` and
|
|
@@ -255,8 +296,9 @@ def check_public_function_missing_paired_test(
|
|
|
255
296
|
Returns:
|
|
256
297
|
One violation per uncovered public function, capped at the configured
|
|
257
298
|
maximum, scoped to the changed lines unless deferred or unscoped. Empty
|
|
258
|
-
when the module is exempt, has no dedicated test file, the suite
|
|
259
|
-
|
|
299
|
+
when the module is exempt, has no dedicated test file, the suite neither
|
|
300
|
+
covers a public function nor references a private helper of the module,
|
|
301
|
+
or the content fails to parse.
|
|
260
302
|
"""
|
|
261
303
|
if _module_is_exempt(file_path):
|
|
262
304
|
return []
|
|
@@ -276,7 +318,14 @@ def check_public_function_missing_paired_test(
|
|
|
276
318
|
for each_name, _each_line in all_public_definitions
|
|
277
319
|
if each_name in all_referenced_identifiers
|
|
278
320
|
)
|
|
279
|
-
|
|
321
|
+
suite_references_private_helper = bool(
|
|
322
|
+
_private_function_names(tree) & all_referenced_identifiers
|
|
323
|
+
)
|
|
324
|
+
suite_is_established = (
|
|
325
|
+
covered_function_count >= MINIMUM_COVERED_PUBLIC_FUNCTIONS
|
|
326
|
+
or suite_references_private_helper
|
|
327
|
+
)
|
|
328
|
+
if not suite_is_established:
|
|
280
329
|
return []
|
|
281
330
|
all_violations_in_walk_order: list[tuple[range, str]] = []
|
|
282
331
|
for each_name, each_definition_line in all_public_definitions:
|
|
@@ -297,3 +346,172 @@ def check_public_function_missing_paired_test(
|
|
|
297
346
|
defer_scope_to_caller,
|
|
298
347
|
)
|
|
299
348
|
|
|
349
|
+
|
|
350
|
+
def _production_stem_from_test_filename(test_filename: str) -> str | None:
|
|
351
|
+
"""Return the production-module stem a stem-matched test filename pairs with.
|
|
352
|
+
|
|
353
|
+
Args:
|
|
354
|
+
test_filename: The basename of the written file.
|
|
355
|
+
|
|
356
|
+
Returns:
|
|
357
|
+
The production stem for a ``test_<stem>.py`` or ``<stem>_test.py`` name,
|
|
358
|
+
or None when the filename is not stem-matched or its stem is empty.
|
|
359
|
+
"""
|
|
360
|
+
if test_filename.endswith(STEM_TEST_FILENAME_SUFFIX):
|
|
361
|
+
production_stem = test_filename[: -len(STEM_TEST_FILENAME_SUFFIX)]
|
|
362
|
+
return production_stem or None
|
|
363
|
+
if test_filename.startswith(STEM_TEST_FILENAME_PREFIX) and test_filename.endswith(
|
|
364
|
+
PYTHON_SOURCE_SUFFIX
|
|
365
|
+
):
|
|
366
|
+
production_stem = test_filename[
|
|
367
|
+
len(STEM_TEST_FILENAME_PREFIX) : -len(PYTHON_SOURCE_SUFFIX)
|
|
368
|
+
]
|
|
369
|
+
return production_stem or None
|
|
370
|
+
return None
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _paired_production_module_path(
|
|
374
|
+
test_path: Path, production_stem: str
|
|
375
|
+
) -> Path | None:
|
|
376
|
+
"""Return the production module a stem-matched test file pairs with, or None.
|
|
377
|
+
|
|
378
|
+
Mirrors ``_stem_matched_test_path`` in reverse for the two common layouts: a
|
|
379
|
+
module beside the test file, and a module in the parent of the ``tests``
|
|
380
|
+
directory that directly holds the test file.
|
|
381
|
+
|
|
382
|
+
Args:
|
|
383
|
+
test_path: The resolved path of the written stem-matched test file.
|
|
384
|
+
production_stem: The production-module stem the test filename names.
|
|
385
|
+
|
|
386
|
+
Returns:
|
|
387
|
+
The first candidate production module path that exists on disk, or None
|
|
388
|
+
when no candidate module file exists.
|
|
389
|
+
"""
|
|
390
|
+
production_filename = production_stem + PYTHON_SOURCE_SUFFIX
|
|
391
|
+
test_directory = test_path.parent
|
|
392
|
+
all_candidate_paths = [test_directory / production_filename]
|
|
393
|
+
if test_directory.name == TESTS_DIRECTORY_NAME:
|
|
394
|
+
all_candidate_paths.append(test_directory.parent / production_filename)
|
|
395
|
+
for each_candidate_path in all_candidate_paths:
|
|
396
|
+
if each_candidate_path.is_file():
|
|
397
|
+
return each_candidate_path
|
|
398
|
+
return None
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _post_edit_suite_referenced_identifiers(
|
|
402
|
+
suite_directory: Path,
|
|
403
|
+
written_test_path: Path,
|
|
404
|
+
written_test_content: str,
|
|
405
|
+
) -> set[str]:
|
|
406
|
+
"""Return suite identifiers, reading the written test's post-edit content.
|
|
407
|
+
|
|
408
|
+
Scans every ``test_*.py`` and ``*_test.py`` file in the suite directory,
|
|
409
|
+
bounded by the scan cap, and unions the identifiers each references. The
|
|
410
|
+
written test file contributes ``written_test_content`` rather than its stale
|
|
411
|
+
on-disk text, so the post-edit suite is judged.
|
|
412
|
+
|
|
413
|
+
Args:
|
|
414
|
+
suite_directory: The directory holding the suite's test files.
|
|
415
|
+
written_test_path: The resolved path of the test file being written.
|
|
416
|
+
written_test_content: The post-edit text of the written test file.
|
|
417
|
+
|
|
418
|
+
Returns:
|
|
419
|
+
The union of referenced identifiers across the post-edit suite.
|
|
420
|
+
"""
|
|
421
|
+
all_test_file_paths: list[Path] = []
|
|
422
|
+
for each_glob in ALL_TEST_FILENAME_GLOBS:
|
|
423
|
+
all_test_file_paths.extend(sorted(suite_directory.glob(each_glob)))
|
|
424
|
+
written_path_key = os.path.normcase(str(written_test_path))
|
|
425
|
+
all_referenced_identifiers = _collect_referenced_identifiers(written_test_content)
|
|
426
|
+
for each_test_file_path in all_test_file_paths[:MAX_TEST_FILES_SCANNED]:
|
|
427
|
+
if os.path.normcase(str(each_test_file_path.resolve())) == written_path_key:
|
|
428
|
+
continue
|
|
429
|
+
try:
|
|
430
|
+
test_source = each_test_file_path.read_text(encoding="utf-8")
|
|
431
|
+
except (OSError, UnicodeDecodeError):
|
|
432
|
+
continue
|
|
433
|
+
all_referenced_identifiers |= _collect_referenced_identifiers(test_source)
|
|
434
|
+
return all_referenced_identifiers
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def check_test_file_omits_module_public_function(
|
|
438
|
+
content: str,
|
|
439
|
+
file_path: str,
|
|
440
|
+
) -> list[str]:
|
|
441
|
+
"""Flag a public function the written test file's paired suite omits.
|
|
442
|
+
|
|
443
|
+
The companion of ``check_public_function_missing_paired_test`` for the
|
|
444
|
+
reverse write order: when a production module is written before its test
|
|
445
|
+
file, the production-side check sees no suite yet and the omission slips
|
|
446
|
+
through, so this check fires on the stem-matched test-file write instead. It
|
|
447
|
+
resolves the production module the written ``test_<stem>.py`` or
|
|
448
|
+
``<stem>_test.py`` file pairs with, and when the post-edit suite already
|
|
449
|
+
covers at least one of that module's public functions, flags every public
|
|
450
|
+
function the suite references nowhere. ``main`` and underscore-prefixed
|
|
451
|
+
functions are never required to carry a test, and a production module that is
|
|
452
|
+
itself exempt — a test module, hook infrastructure, config module, migration,
|
|
453
|
+
workflow registry, or ``__init__`` module — is skipped.
|
|
454
|
+
|
|
455
|
+
A stem-matched test-file write authors the whole pairing relationship, so
|
|
456
|
+
every omission is reported unscoped: the gap lives in the test file under
|
|
457
|
+
edit, while the omitted function's definition line lives in the production
|
|
458
|
+
module — a different file whose line numbers are not addressable against the
|
|
459
|
+
test file's changed lines. Diff-line intersection would compare the two
|
|
460
|
+
unrelated coordinate spaces, so this check returns each omission directly
|
|
461
|
+
and the commit/push gate treats it as blocking.
|
|
462
|
+
|
|
463
|
+
Args:
|
|
464
|
+
content: The reconstructed post-edit whole-file content of the test file.
|
|
465
|
+
file_path: The destination path of the written test file, used to resolve
|
|
466
|
+
its paired production module on disk.
|
|
467
|
+
|
|
468
|
+
Returns:
|
|
469
|
+
One violation per public function the suite omits, capped at the
|
|
470
|
+
configured maximum. Empty when the file is not a stem-matched test file,
|
|
471
|
+
no paired production module exists on disk, the production module is
|
|
472
|
+
exempt or defines no public function, or the suite covers none of the
|
|
473
|
+
module's public functions.
|
|
474
|
+
"""
|
|
475
|
+
production_stem = _production_stem_from_test_filename(Path(file_path).name)
|
|
476
|
+
if production_stem is None:
|
|
477
|
+
return []
|
|
478
|
+
test_path = Path(file_path).resolve()
|
|
479
|
+
production_module_path = _paired_production_module_path(test_path, production_stem)
|
|
480
|
+
if production_module_path is None:
|
|
481
|
+
return []
|
|
482
|
+
if _module_is_exempt(str(production_module_path)):
|
|
483
|
+
return []
|
|
484
|
+
try:
|
|
485
|
+
production_source = production_module_path.read_text(encoding="utf-8")
|
|
486
|
+
except (OSError, UnicodeDecodeError):
|
|
487
|
+
return []
|
|
488
|
+
try:
|
|
489
|
+
production_tree = ast.parse(production_source)
|
|
490
|
+
except SyntaxError:
|
|
491
|
+
return []
|
|
492
|
+
all_public_definitions = _public_function_definitions(production_tree)
|
|
493
|
+
if not all_public_definitions:
|
|
494
|
+
return []
|
|
495
|
+
all_referenced_identifiers = _post_edit_suite_referenced_identifiers(
|
|
496
|
+
test_path.parent, test_path, content
|
|
497
|
+
)
|
|
498
|
+
covered_function_count = sum(
|
|
499
|
+
1
|
|
500
|
+
for each_name, _each_line in all_public_definitions
|
|
501
|
+
if each_name in all_referenced_identifiers
|
|
502
|
+
)
|
|
503
|
+
if covered_function_count < MINIMUM_COVERED_PUBLIC_FUNCTIONS:
|
|
504
|
+
return []
|
|
505
|
+
production_module_name = production_module_path.name
|
|
506
|
+
all_omission_violations: list[str] = []
|
|
507
|
+
for each_name, _each_definition_line in all_public_definitions:
|
|
508
|
+
if each_name in all_referenced_identifiers:
|
|
509
|
+
continue
|
|
510
|
+
all_omission_violations.append(
|
|
511
|
+
f"{production_module_name} public function "
|
|
512
|
+
f"{each_name!r} {TEST_SUITE_OMITS_FUNCTION_GUIDANCE}"
|
|
513
|
+
)
|
|
514
|
+
if len(all_omission_violations) >= MAX_PAIRED_TEST_COVERAGE_ISSUES:
|
|
515
|
+
break
|
|
516
|
+
return all_omission_violations
|
|
517
|
+
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Skip-decorator, existence-only, constant-equality, stale-test-name,
|
|
1
|
+
"""Skip-decorator, existence-only, constant-equality, stale-test-name, flag-gated scenario, and vacuous cleanup-assertion test-quality checks."""
|
|
2
2
|
|
|
3
3
|
import ast
|
|
4
4
|
import sys
|
|
@@ -6,6 +6,37 @@ from pathlib import Path
|
|
|
6
6
|
|
|
7
7
|
_SCENARIO_NAME_CLAUSES = ("_when_", "_passes", "_succeeds", "_on_clean")
|
|
8
8
|
_MINIMUM_SIBLING_PATCH_COUNT = 2
|
|
9
|
+
_CLEANUP_REMOVAL_NAME_TOKENS = (
|
|
10
|
+
"remove",
|
|
11
|
+
"removes",
|
|
12
|
+
"removed",
|
|
13
|
+
"cleanup",
|
|
14
|
+
"clean_up",
|
|
15
|
+
"deletes",
|
|
16
|
+
"discards",
|
|
17
|
+
"no_tmp",
|
|
18
|
+
"no_temp",
|
|
19
|
+
"no_leftover",
|
|
20
|
+
"leftover",
|
|
21
|
+
)
|
|
22
|
+
_FAILURE_CONDITION_NAME_TOKENS = (
|
|
23
|
+
"on_failure",
|
|
24
|
+
"_failure",
|
|
25
|
+
"on_error",
|
|
26
|
+
"_fails",
|
|
27
|
+
"_failed",
|
|
28
|
+
"raises",
|
|
29
|
+
"bad_source",
|
|
30
|
+
)
|
|
31
|
+
_ARRANGEMENT_METHOD_NAMES = (
|
|
32
|
+
"setattr",
|
|
33
|
+
"mkdir",
|
|
34
|
+
"write_text",
|
|
35
|
+
"write_bytes",
|
|
36
|
+
"touch",
|
|
37
|
+
"symlink_to",
|
|
38
|
+
"exists",
|
|
39
|
+
)
|
|
9
40
|
|
|
10
41
|
_BLOCKING_DIRECTORY = str(Path(__file__).resolve().parent)
|
|
11
42
|
_HOOKS_DIRECTORY = str(Path(__file__).resolve().parent.parent)
|
|
@@ -233,6 +264,133 @@ def check_constant_equality_tests(content: str, file_path: str) -> list[str]:
|
|
|
233
264
|
return issues
|
|
234
265
|
|
|
235
266
|
|
|
267
|
+
def _name_signals_cleanup_on_failure(test_name: str) -> bool:
|
|
268
|
+
"""Return True when a test name pairs a removal token with a failure token."""
|
|
269
|
+
lowered_name = test_name.lower()
|
|
270
|
+
has_removal_token = any(
|
|
271
|
+
each_token in lowered_name for each_token in _CLEANUP_REMOVAL_NAME_TOKENS
|
|
272
|
+
)
|
|
273
|
+
has_failure_token = any(
|
|
274
|
+
each_token in lowered_name for each_token in _FAILURE_CONDITION_NAME_TOKENS
|
|
275
|
+
)
|
|
276
|
+
return has_removal_token and has_failure_token
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _is_absence_assertion(assert_node: ast.Assert) -> bool:
|
|
280
|
+
"""Return True when an assertion only checks that a collection is empty."""
|
|
281
|
+
test_expr = assert_node.test
|
|
282
|
+
if isinstance(test_expr, ast.UnaryOp) and isinstance(test_expr.op, ast.Not):
|
|
283
|
+
return True
|
|
284
|
+
if not isinstance(test_expr, ast.Compare):
|
|
285
|
+
return False
|
|
286
|
+
if len(test_expr.ops) != 1 or not isinstance(test_expr.ops[0], ast.Eq):
|
|
287
|
+
return False
|
|
288
|
+
comparator = test_expr.comparators[0]
|
|
289
|
+
if (
|
|
290
|
+
isinstance(comparator, ast.Constant)
|
|
291
|
+
and isinstance(comparator.value, int)
|
|
292
|
+
and not isinstance(comparator.value, bool)
|
|
293
|
+
and comparator.value == 0
|
|
294
|
+
):
|
|
295
|
+
return True
|
|
296
|
+
return isinstance(comparator, ast.List) and not comparator.elts
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _body_calls_attribute(
|
|
300
|
+
function_node: ast.FunctionDef | ast.AsyncFunctionDef, attribute_name: str
|
|
301
|
+
) -> bool:
|
|
302
|
+
"""Return True when the function body calls a method with the given attribute name."""
|
|
303
|
+
for each_node in ast.walk(function_node):
|
|
304
|
+
if not isinstance(each_node, ast.Call):
|
|
305
|
+
continue
|
|
306
|
+
callee = each_node.func
|
|
307
|
+
if isinstance(callee, ast.Attribute) and callee.attr == attribute_name:
|
|
308
|
+
return True
|
|
309
|
+
return False
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _body_arranges_temp_existence(
|
|
313
|
+
function_node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
314
|
+
) -> bool:
|
|
315
|
+
"""Return True when the body creates a temp file or arranges a post-creation failure.
|
|
316
|
+
|
|
317
|
+
A sound cleanup-on-failure test proves the temp existed before asserting its
|
|
318
|
+
removal: it monkeypatches the operation to fail after the temp is created, or
|
|
319
|
+
it creates the temp directly and checks its presence. Either signal means the
|
|
320
|
+
leftover-absence assertion is not vacuous.
|
|
321
|
+
|
|
322
|
+
Args:
|
|
323
|
+
function_node: The test function whose body is inspected.
|
|
324
|
+
|
|
325
|
+
Returns:
|
|
326
|
+
True when the body references ``monkeypatch`` or calls a file-creation or
|
|
327
|
+
existence method that establishes the temp file before the cleanup runs.
|
|
328
|
+
"""
|
|
329
|
+
for each_node in ast.walk(function_node):
|
|
330
|
+
if isinstance(each_node, ast.Name) and each_node.id == "monkeypatch":
|
|
331
|
+
return True
|
|
332
|
+
if not isinstance(each_node, ast.Call):
|
|
333
|
+
continue
|
|
334
|
+
callee = each_node.func
|
|
335
|
+
if isinstance(callee, ast.Attribute) and callee.attr in _ARRANGEMENT_METHOD_NAMES:
|
|
336
|
+
return True
|
|
337
|
+
return False
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def check_vacuous_cleanup_assertion_tests(content: str, file_path: str) -> list[str]:
|
|
341
|
+
"""Flag a cleanup-on-failure test whose leftover-absence assertion is vacuous.
|
|
342
|
+
|
|
343
|
+
A test named for removal on failure that globs a directory and asserts the
|
|
344
|
+
result is empty, yet never creates the temp file nor arranges a failure after
|
|
345
|
+
the temp exists, passes even when the on-failure cleanup is entirely broken —
|
|
346
|
+
the leftover set is empty because nothing ever staged a leftover. The gate
|
|
347
|
+
fires when the test name pairs a removal token with a failure token, the body
|
|
348
|
+
globs a directory, every assertion only checks emptiness, and the body neither
|
|
349
|
+
monkeypatches a post-creation failure nor creates and checks the temp first.
|
|
350
|
+
Only applies to test files; production files are exempt.
|
|
351
|
+
|
|
352
|
+
Args:
|
|
353
|
+
content: The file body under validation.
|
|
354
|
+
file_path: Path to the file, used for the test-file gate.
|
|
355
|
+
|
|
356
|
+
Returns:
|
|
357
|
+
One issue per test whose cleanup-on-failure assertion passes vacuously.
|
|
358
|
+
"""
|
|
359
|
+
if not is_test_file(file_path):
|
|
360
|
+
return []
|
|
361
|
+
try:
|
|
362
|
+
syntax_tree = ast.parse(content)
|
|
363
|
+
except SyntaxError:
|
|
364
|
+
return []
|
|
365
|
+
|
|
366
|
+
issues: list[str] = []
|
|
367
|
+
for each_node in ast.walk(syntax_tree):
|
|
368
|
+
if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
369
|
+
continue
|
|
370
|
+
if not each_node.name.startswith("test"):
|
|
371
|
+
continue
|
|
372
|
+
if not _name_signals_cleanup_on_failure(each_node.name):
|
|
373
|
+
continue
|
|
374
|
+
if not _body_calls_attribute(each_node, "glob"):
|
|
375
|
+
continue
|
|
376
|
+
if _body_arranges_temp_existence(each_node):
|
|
377
|
+
continue
|
|
378
|
+
body_assertions = _collect_body_assertions(each_node.body)
|
|
379
|
+
if not body_assertions:
|
|
380
|
+
continue
|
|
381
|
+
if not all(_is_absence_assertion(each_assert) for each_assert in body_assertions):
|
|
382
|
+
continue
|
|
383
|
+
issues.append(
|
|
384
|
+
f"Line {each_node.lineno}: vacuous cleanup-on-failure test"
|
|
385
|
+
f" — asserts no leftover temp file but never proves the temp was created,"
|
|
386
|
+
f" so it passes even when cleanup is broken; arrange a post-creation"
|
|
387
|
+
f" failure (e.g. monkeypatch os.replace to raise after the temp exists)"
|
|
388
|
+
f" then assert the temp was removed"
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
return issues
|
|
392
|
+
|
|
393
|
+
|
|
236
394
|
def _flag_symbol_from_setattr_target(target_node: ast.expr) -> str | None:
|
|
237
395
|
"""Return the UPPER_SNAKE flag symbol a monkeypatch.setattr target names.
|
|
238
396
|
|
|
@@ -20,13 +20,10 @@ from code_rules_shared import ( # noqa: E402
|
|
|
20
20
|
_build_parent_map,
|
|
21
21
|
is_migration_file,
|
|
22
22
|
is_test_file,
|
|
23
|
-
is_workflow_registry_file,
|
|
24
23
|
)
|
|
25
24
|
|
|
26
25
|
from hooks_constants.unused_module_import_constants import ( # noqa: E402
|
|
27
|
-
ALL_TYPING_MODULE_NAMES,
|
|
28
26
|
MAX_UNUSED_IMPORT_ISSUES,
|
|
29
|
-
TYPE_CHECKING_IDENTIFIER,
|
|
30
27
|
UNUSED_IMPORT_GUIDANCE,
|
|
31
28
|
line_suppresses_unused_import_via_noqa,
|
|
32
29
|
)
|
|
@@ -73,59 +70,6 @@ def _line_number_falls_in_import_ranges(
|
|
|
73
70
|
return False
|
|
74
71
|
|
|
75
72
|
|
|
76
|
-
def _type_checking_guard_aliases(tree: ast.Module) -> tuple[set[str], set[str]]:
|
|
77
|
-
all_type_checking_names = {TYPE_CHECKING_IDENTIFIER}
|
|
78
|
-
all_type_checking_module_aliases = set(ALL_TYPING_MODULE_NAMES)
|
|
79
|
-
for each_statement in tree.body:
|
|
80
|
-
if isinstance(each_statement, ast.Import):
|
|
81
|
-
for each_alias in each_statement.names:
|
|
82
|
-
if each_alias.name in ALL_TYPING_MODULE_NAMES:
|
|
83
|
-
all_type_checking_module_aliases.add(
|
|
84
|
-
each_alias.asname or each_alias.name
|
|
85
|
-
)
|
|
86
|
-
elif isinstance(each_statement, ast.ImportFrom):
|
|
87
|
-
if each_statement.module not in ALL_TYPING_MODULE_NAMES:
|
|
88
|
-
continue
|
|
89
|
-
for each_alias in each_statement.names:
|
|
90
|
-
if each_alias.name == TYPE_CHECKING_IDENTIFIER:
|
|
91
|
-
all_type_checking_names.add(each_alias.asname or each_alias.name)
|
|
92
|
-
return all_type_checking_names, all_type_checking_module_aliases
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
def _expression_guards_type_checking_block(
|
|
96
|
-
test_expression: ast.expr,
|
|
97
|
-
all_type_checking_names: set[str],
|
|
98
|
-
all_type_checking_module_aliases: set[str],
|
|
99
|
-
) -> bool:
|
|
100
|
-
if isinstance(test_expression, ast.Name):
|
|
101
|
-
return test_expression.id in all_type_checking_names
|
|
102
|
-
if isinstance(test_expression, ast.Attribute):
|
|
103
|
-
if test_expression.attr != TYPE_CHECKING_IDENTIFIER:
|
|
104
|
-
return False
|
|
105
|
-
receiver = test_expression.value
|
|
106
|
-
return (
|
|
107
|
-
isinstance(receiver, ast.Name)
|
|
108
|
-
and receiver.id in all_type_checking_module_aliases
|
|
109
|
-
)
|
|
110
|
-
return False
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
def _module_body_declares_type_checking_gate(tree: ast.Module) -> bool:
|
|
114
|
-
(
|
|
115
|
-
all_type_checking_names,
|
|
116
|
-
all_type_checking_module_aliases,
|
|
117
|
-
) = _type_checking_guard_aliases(tree)
|
|
118
|
-
return any(
|
|
119
|
-
isinstance(each_statement, ast.If)
|
|
120
|
-
and _expression_guards_type_checking_block(
|
|
121
|
-
each_statement.test,
|
|
122
|
-
all_type_checking_names,
|
|
123
|
-
all_type_checking_module_aliases,
|
|
124
|
-
)
|
|
125
|
-
for each_statement in tree.body
|
|
126
|
-
)
|
|
127
|
-
|
|
128
|
-
|
|
129
73
|
def _collect_load_names_outside_import_ranges(
|
|
130
74
|
tree: ast.Module,
|
|
131
75
|
all_import_line_ranges: list[tuple[int, int]],
|
|
@@ -191,10 +135,12 @@ def check_unused_module_level_imports(
|
|
|
191
135
|
|
|
192
136
|
References are detected from AST ``Name`` / ``Attribute`` loads outside import
|
|
193
137
|
statements so mentions in comments or string literals do not count. Files
|
|
194
|
-
declaring ``__all__`` (including annotated assignments) are skipped.
|
|
195
|
-
|
|
196
|
-
``
|
|
197
|
-
|
|
138
|
+
declaring ``__all__`` (including annotated assignments) are skipped. A
|
|
139
|
+
``if TYPE_CHECKING:`` block does not exempt the file: its guarded imports
|
|
140
|
+
are nested inside the ``If`` node and are never scanned as top-level
|
|
141
|
+
bindings, while a dead top-level runtime import in the same file is still
|
|
142
|
+
flagged. Suppression honors bare ``# noqa`` or an explicit ``F401`` code in
|
|
143
|
+
the noqa list only.
|
|
198
144
|
|
|
199
145
|
When ``full_file_content`` is provided, ``content`` is treated as an Edit
|
|
200
146
|
fragment containing the imports being added or replaced, while the
|
|
@@ -205,7 +151,7 @@ def check_unused_module_level_imports(
|
|
|
205
151
|
"""
|
|
206
152
|
if is_test_file(file_path):
|
|
207
153
|
return []
|
|
208
|
-
if
|
|
154
|
+
if is_migration_file(file_path):
|
|
209
155
|
return []
|
|
210
156
|
try:
|
|
211
157
|
fragment_tree = ast.parse(content)
|
|
@@ -218,8 +164,6 @@ def check_unused_module_level_imports(
|
|
|
218
164
|
return []
|
|
219
165
|
if _module_declares_dunder_all(reference_tree):
|
|
220
166
|
return []
|
|
221
|
-
if _module_body_declares_type_checking_gate(reference_tree):
|
|
222
|
-
return []
|
|
223
167
|
fragment_lines = content.splitlines()
|
|
224
168
|
reference_import_ranges = _import_statement_line_ranges(reference_tree)
|
|
225
169
|
referenced_names = _collect_load_names_outside_import_ranges(
|