claude-dev-env 1.78.0 → 1.79.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/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 +37 -0
- 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/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_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_vacuous_cleanup_assertion.py +132 -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 +78 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +14 -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/package.json +1 -1
- package/rules/CLAUDE.md +1 -0
- package/rules/docstring-prose-matches-implementation.md +56 -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
|
@@ -72,11 +72,15 @@ from code_rules_docstrings import ( # noqa: E402
|
|
|
72
72
|
check_docstring_args_match_signature,
|
|
73
73
|
check_docstring_args_single_line_scope_vs_span,
|
|
74
74
|
check_docstring_cardinal_count_matches_constant_family,
|
|
75
|
+
check_docstring_documents_unreferenced_parameter,
|
|
75
76
|
check_docstring_fallback_branch_coverage,
|
|
77
|
+
check_docstring_field_runmode_outcome,
|
|
76
78
|
check_docstring_format,
|
|
79
|
+
check_docstring_length_constant_superlative_vs_exact_gate,
|
|
77
80
|
check_docstring_names_undefined_constant,
|
|
78
81
|
check_docstring_no_consumer_claim,
|
|
79
82
|
check_docstring_no_inline_literal_claim,
|
|
83
|
+
check_docstring_no_network_claim_with_metadata_access,
|
|
80
84
|
check_docstring_punctuation_mark_enumeration_coverage,
|
|
81
85
|
check_docstring_raises_unraisable_largezipfile,
|
|
82
86
|
check_docstring_returns_plural_cardinality,
|
|
@@ -85,6 +89,7 @@ from code_rules_docstrings import ( # noqa: E402
|
|
|
85
89
|
check_docstring_tuple_enumeration_match,
|
|
86
90
|
check_docstring_unguarded_malformed_payload_claim,
|
|
87
91
|
check_module_docstring_names_public_checks,
|
|
92
|
+
check_module_docstring_scope_omits_data_schema_constants,
|
|
88
93
|
)
|
|
89
94
|
from code_rules_duplicate_body import ( # noqa: E402
|
|
90
95
|
advise_cross_skill_duplicate_helper,
|
|
@@ -112,6 +117,7 @@ from code_rules_mock_completeness import ( # noqa: E402
|
|
|
112
117
|
from code_rules_naming_collection import ( # noqa: E402
|
|
113
118
|
check_collection_prefix,
|
|
114
119
|
check_loop_variable_naming,
|
|
120
|
+
check_polarity_name_contradiction,
|
|
115
121
|
check_stuttering_collection_prefix,
|
|
116
122
|
)
|
|
117
123
|
from code_rules_optional_params import ( # noqa: E402
|
|
@@ -123,6 +129,7 @@ from code_rules_orphan_css_class import ( # noqa: E402
|
|
|
123
129
|
)
|
|
124
130
|
from code_rules_paired_test import ( # noqa: E402
|
|
125
131
|
check_public_function_missing_paired_test,
|
|
132
|
+
check_test_file_omits_module_public_function,
|
|
126
133
|
)
|
|
127
134
|
from code_rules_paths_syspath import ( # noqa: E402
|
|
128
135
|
check_hardcoded_user_paths,
|
|
@@ -147,6 +154,7 @@ from code_rules_test_assertions import ( # noqa: E402
|
|
|
147
154
|
check_flag_gated_scenario_test_naming,
|
|
148
155
|
check_skip_decorators_in_tests,
|
|
149
156
|
check_stale_test_name_target,
|
|
157
|
+
check_vacuous_cleanup_assertion_tests,
|
|
150
158
|
)
|
|
151
159
|
from code_rules_test_branching_except import ( # noqa: E402
|
|
152
160
|
check_bare_except,
|
|
@@ -294,8 +302,16 @@ def validate_content(
|
|
|
294
302
|
all_issues.extend(check_boundary_types(effective_content, file_path))
|
|
295
303
|
all_issues.extend(check_docstring_format(effective_content, file_path))
|
|
296
304
|
all_issues.extend(check_docstring_args_match_signature(effective_content, file_path))
|
|
305
|
+
all_issues.extend(
|
|
306
|
+
check_docstring_documents_unreferenced_parameter(effective_content, file_path)
|
|
307
|
+
)
|
|
297
308
|
all_issues.extend(check_docstring_fallback_branch_coverage(effective_content, file_path))
|
|
298
309
|
all_issues.extend(check_docstring_no_consumer_claim(effective_content, file_path))
|
|
310
|
+
all_issues.extend(
|
|
311
|
+
check_docstring_no_network_claim_with_metadata_access(
|
|
312
|
+
effective_content, file_path
|
|
313
|
+
)
|
|
314
|
+
)
|
|
299
315
|
all_issues.extend(
|
|
300
316
|
check_docstring_unguarded_malformed_payload_claim(
|
|
301
317
|
effective_content, file_path
|
|
@@ -311,6 +327,11 @@ def validate_content(
|
|
|
311
327
|
all_issues.extend(
|
|
312
328
|
check_module_docstring_names_public_checks(effective_content, file_path)
|
|
313
329
|
)
|
|
330
|
+
all_issues.extend(
|
|
331
|
+
check_module_docstring_scope_omits_data_schema_constants(
|
|
332
|
+
effective_content, file_path
|
|
333
|
+
)
|
|
334
|
+
)
|
|
314
335
|
all_issues.extend(
|
|
315
336
|
check_docstring_tuple_enumeration_match(effective_content, file_path)
|
|
316
337
|
)
|
|
@@ -327,6 +348,14 @@ def validate_content(
|
|
|
327
348
|
all_issues.extend(
|
|
328
349
|
check_docstring_returns_plural_cardinality(effective_content, file_path)
|
|
329
350
|
)
|
|
351
|
+
all_issues.extend(
|
|
352
|
+
check_docstring_length_constant_superlative_vs_exact_gate(
|
|
353
|
+
effective_content, file_path
|
|
354
|
+
)
|
|
355
|
+
)
|
|
356
|
+
all_issues.extend(
|
|
357
|
+
check_docstring_field_runmode_outcome(effective_content, file_path)
|
|
358
|
+
)
|
|
330
359
|
all_issues.extend(
|
|
331
360
|
check_docstring_raises_unraisable_largezipfile(effective_content, file_path)
|
|
332
361
|
)
|
|
@@ -368,6 +397,7 @@ def validate_content(
|
|
|
368
397
|
)
|
|
369
398
|
all_issues.extend(check_existence_check_tests(content, file_path))
|
|
370
399
|
all_issues.extend(check_constant_equality_tests(content, file_path))
|
|
400
|
+
all_issues.extend(check_vacuous_cleanup_assertion_tests(content, file_path))
|
|
371
401
|
all_issues.extend(check_stale_test_name_target(content, file_path))
|
|
372
402
|
check_flag_gated_scenario_test_naming(content, file_path)
|
|
373
403
|
all_issues.extend(check_unused_optional_parameters(content, file_path))
|
|
@@ -414,7 +444,14 @@ def validate_content(
|
|
|
414
444
|
defer_scope_to_caller,
|
|
415
445
|
)
|
|
416
446
|
)
|
|
447
|
+
all_issues.extend(
|
|
448
|
+
check_test_file_omits_module_public_function(
|
|
449
|
+
effective_content,
|
|
450
|
+
file_path,
|
|
451
|
+
)
|
|
452
|
+
)
|
|
417
453
|
all_issues.extend(check_loop_variable_naming(content, file_path))
|
|
454
|
+
all_issues.extend(check_polarity_name_contradiction(content, file_path))
|
|
418
455
|
all_issues.extend(check_inline_literal_collections(content, file_path))
|
|
419
456
|
all_issues.extend(check_inline_tuple_string_magic(content, file_path))
|
|
420
457
|
all_issues.extend(check_string_literal_magic(content, file_path))
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
"""Collection-prefix, stuttering-prefix,
|
|
1
|
+
"""Collection-prefix, stuttering-prefix, loop-variable, and polarity-name-contradiction naming checks."""
|
|
2
2
|
|
|
3
3
|
import ast
|
|
4
|
+
import re
|
|
4
5
|
import sys
|
|
5
6
|
from pathlib import Path
|
|
6
7
|
|
|
@@ -11,6 +12,7 @@ if _blocking_directory not in sys.path:
|
|
|
11
12
|
if _hooks_directory not in sys.path:
|
|
12
13
|
sys.path.insert(0, _hooks_directory)
|
|
13
14
|
|
|
15
|
+
from code_rules_boolean_mustcheck import _called_terminal_name # noqa: E402
|
|
14
16
|
from code_rules_shared import ( # noqa: E402
|
|
15
17
|
_collect_annotated_arguments,
|
|
16
18
|
_collect_target_names,
|
|
@@ -22,11 +24,13 @@ from code_rules_shared import ( # noqa: E402
|
|
|
22
24
|
from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
|
|
23
25
|
ALL_COLLECTION_TYPE_NAMES,
|
|
24
26
|
ALL_LOOP_INDEX_LETTER_EXEMPTIONS,
|
|
27
|
+
ALL_POLARITY_ANTONYM_TOKEN_PAIRS,
|
|
25
28
|
ALL_SUBSCRIPT_ONLY_COLLECTION_TYPE_NAMES,
|
|
26
29
|
ALL_UNION_TYPING_NAMES,
|
|
27
30
|
BARE_EACH_TOKEN,
|
|
28
31
|
COLLECTION_BY_NAME_PATTERN,
|
|
29
32
|
EACH_PREFIX,
|
|
33
|
+
POLARITY_TOKEN_BOUNDARY_PATTERN,
|
|
30
34
|
UPPER_SNAKE_CONSTANT_PATTERN,
|
|
31
35
|
)
|
|
32
36
|
from hooks_constants.stuttering_check_config import ( # noqa: E402
|
|
@@ -262,3 +266,74 @@ def check_loop_variable_naming(content: str, file_path: str) -> list[str]:
|
|
|
262
266
|
f"Line {each_name_node.lineno}: loop variable {target_name!r} - prefix with each_ (CODE_RULES §5)"
|
|
263
267
|
)
|
|
264
268
|
return issues
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _name_carries_token(name: str, token: str) -> bool:
|
|
272
|
+
"""True when name carries token as a whole underscore-delimited word."""
|
|
273
|
+
return re.search(POLARITY_TOKEN_BOUNDARY_PATTERN % re.escape(token), name) is not None
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _contradicting_polarity_pair(target_name: str, called_name: str) -> tuple[str, str] | None:
|
|
277
|
+
"""The (target_token, called_token) antonym pair when target and callee assert opposite polarity."""
|
|
278
|
+
target_lower = target_name.lower()
|
|
279
|
+
called_lower = called_name.lower()
|
|
280
|
+
for each_positive_token, each_negative_token in ALL_POLARITY_ANTONYM_TOKEN_PAIRS:
|
|
281
|
+
if _name_carries_token(target_lower, each_positive_token) and _name_carries_token(
|
|
282
|
+
called_lower, each_negative_token
|
|
283
|
+
):
|
|
284
|
+
return each_positive_token, each_negative_token
|
|
285
|
+
if _name_carries_token(target_lower, each_negative_token) and _name_carries_token(
|
|
286
|
+
called_lower, each_positive_token
|
|
287
|
+
):
|
|
288
|
+
return each_negative_token, each_positive_token
|
|
289
|
+
return None
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def check_polarity_name_contradiction(content: str, file_path: str) -> list[str]:
|
|
293
|
+
"""Flag a boolean assignment whose target and callee assert opposite polarity.
|
|
294
|
+
|
|
295
|
+
Catches the self-contradicting statement ``is_inside_allowed =
|
|
296
|
+
_point_hits_any_forbidden(...)`` — a target name carrying one polarity token
|
|
297
|
+
(``allowed``) assigned directly from a single call whose callee name carries
|
|
298
|
+
the antonym token (``forbidden``). One side lies about the behavior, so the
|
|
299
|
+
reader cannot trust either name. Rename the callee to a neutral form that
|
|
300
|
+
reads truthfully at every call site.
|
|
301
|
+
"""
|
|
302
|
+
if is_test_file(file_path):
|
|
303
|
+
return []
|
|
304
|
+
if is_workflow_registry_file(file_path) or is_migration_file(file_path):
|
|
305
|
+
return []
|
|
306
|
+
try:
|
|
307
|
+
tree = ast.parse(content)
|
|
308
|
+
except SyntaxError:
|
|
309
|
+
return []
|
|
310
|
+
issues: list[str] = []
|
|
311
|
+
for each_node in ast.walk(tree):
|
|
312
|
+
target_name: str | None = None
|
|
313
|
+
value_node: ast.expr | None = None
|
|
314
|
+
target_line = 0
|
|
315
|
+
if isinstance(each_node, ast.Assign) and len(each_node.targets) == 1 and isinstance(
|
|
316
|
+
each_node.targets[0], ast.Name
|
|
317
|
+
):
|
|
318
|
+
target_name = each_node.targets[0].id
|
|
319
|
+
value_node = each_node.value
|
|
320
|
+
target_line = each_node.lineno
|
|
321
|
+
elif isinstance(each_node, ast.AnnAssign) and isinstance(each_node.target, ast.Name):
|
|
322
|
+
target_name = each_node.target.id
|
|
323
|
+
value_node = each_node.value
|
|
324
|
+
target_line = each_node.lineno
|
|
325
|
+
if target_name is None or not isinstance(value_node, ast.Call):
|
|
326
|
+
continue
|
|
327
|
+
called_name = _called_terminal_name(value_node)
|
|
328
|
+
if called_name is None:
|
|
329
|
+
continue
|
|
330
|
+
contradicting_pair = _contradicting_polarity_pair(target_name, called_name)
|
|
331
|
+
if contradicting_pair is None:
|
|
332
|
+
continue
|
|
333
|
+
target_token, called_token = contradicting_pair
|
|
334
|
+
issues.append(
|
|
335
|
+
f"Line {target_line}: {target_name!r} (says {target_token!r}) is assigned from"
|
|
336
|
+
f" {called_name!r} (says {called_token!r}) - the name contradicts the value; rename the"
|
|
337
|
+
f" callee to a neutral form so each call site reads truthfully (CODE_RULES §5)"
|
|
338
|
+
)
|
|
339
|
+
return issues
|
|
@@ -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
|
+
|