claude-dev-env 1.77.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 +5 -2
- package/hooks/blocking/code_rules_dead_module_constant.py +215 -59
- package/hooks/blocking/code_rules_dead_split_branch.py +225 -0
- package/hooks/blocking/code_rules_docstrings.py +951 -6
- package/hooks/blocking/code_rules_enforcer.py +64 -0
- package/hooks/blocking/code_rules_naming_collection.py +76 -1
- package/hooks/blocking/code_rules_paired_test.py +517 -0
- package/hooks/blocking/code_rules_string_magic.py +71 -1
- package/hooks/blocking/code_rules_test_assertions.py +159 -1
- package/hooks/blocking/convergence_gate_blocker.py +24 -15
- 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_dead_module_constant.py +89 -2
- package/hooks/blocking/test_code_rules_enforcer_dead_split_branch.py +105 -0
- 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_mark_glyph_enumeration.py +262 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_no_network.py +115 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_raises_largezipfile.py +226 -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 +339 -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_code_rules_enforcer_whitespace_indentation_magic.py +74 -0
- package/hooks/blocking/test_convergence_gate_blocker.py +71 -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 +2 -0
- package/hooks/hooks_constants/blocking_check_limits.py +102 -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 +35 -0
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
- package/package.json +1 -1
- package/rules/CLAUDE.md +2 -0
- package/rules/docstring-prose-matches-implementation.md +56 -53
- package/rules/env-var-table-code-drift.md +24 -0
- package/rules/file-global-constants.md +2 -2
- package/rules/package-inventory-stale-entry.md +4 -4
- package/rules/paired-test-coverage.md +35 -0
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
"""Public-function paired-test coverage check for ``code_rules_enforcer``.
|
|
2
|
+
|
|
3
|
+
The TDD gate ``tdd_enforcer.py`` requires a fresh test file to exist before a
|
|
4
|
+
production module is written, but it judges coverage at file granularity: a
|
|
5
|
+
module whose dedicated test file exercises some public functions while leaving
|
|
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.
|
|
41
|
+
- ``main`` and underscore-prefixed functions are never required to carry a test.
|
|
42
|
+
- Test modules, hook infrastructure, config modules, migrations, workflow
|
|
43
|
+
registries, and ``__init__`` modules are exempt as production targets.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
import ast
|
|
47
|
+
import os
|
|
48
|
+
import sys
|
|
49
|
+
from pathlib import Path
|
|
50
|
+
|
|
51
|
+
_blocking_directory = str(Path(__file__).resolve().parent)
|
|
52
|
+
_hooks_directory = str(Path(__file__).resolve().parent.parent)
|
|
53
|
+
if _blocking_directory not in sys.path:
|
|
54
|
+
sys.path.insert(0, _blocking_directory)
|
|
55
|
+
if _hooks_directory not in sys.path:
|
|
56
|
+
sys.path.insert(0, _hooks_directory)
|
|
57
|
+
|
|
58
|
+
from code_rules_path_utils import ( # noqa: E402
|
|
59
|
+
is_config_file,
|
|
60
|
+
)
|
|
61
|
+
from code_rules_shared import ( # noqa: E402
|
|
62
|
+
_scope_violations_to_changed_lines,
|
|
63
|
+
is_hook_infrastructure,
|
|
64
|
+
is_migration_file,
|
|
65
|
+
is_test_file,
|
|
66
|
+
is_workflow_registry_file,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
from hooks_constants.paired_test_coverage_constants import ( # noqa: E402
|
|
70
|
+
ALL_TEST_FILENAME_GLOBS,
|
|
71
|
+
ANCESTOR_DIRECTORY_WALK_LIMIT,
|
|
72
|
+
EXEMPT_PUBLIC_FUNCTION_NAMES,
|
|
73
|
+
INIT_MODULE_FILENAME,
|
|
74
|
+
MAX_PAIRED_TEST_COVERAGE_ISSUES,
|
|
75
|
+
MAX_TEST_FILES_SCANNED,
|
|
76
|
+
MINIMUM_COVERED_PUBLIC_FUNCTIONS,
|
|
77
|
+
MISSING_PAIRED_TEST_GUIDANCE,
|
|
78
|
+
PYTHON_SOURCE_SUFFIX,
|
|
79
|
+
STEM_TEST_FILENAME_PREFIX,
|
|
80
|
+
STEM_TEST_FILENAME_SUFFIX,
|
|
81
|
+
TEST_SUITE_OMITS_FUNCTION_GUIDANCE,
|
|
82
|
+
TESTS_DIRECTORY_NAME,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _is_public_function_name(function_name: str) -> bool:
|
|
87
|
+
"""Return whether a function name is a public, test-worthy entry point.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
function_name: The bare function name from a module-scope definition.
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
True when the name does not start with an underscore and is not one of
|
|
94
|
+
the conventionally untested entry points such as ``main``.
|
|
95
|
+
"""
|
|
96
|
+
if function_name.startswith("_"):
|
|
97
|
+
return False
|
|
98
|
+
return function_name not in EXEMPT_PUBLIC_FUNCTION_NAMES
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _public_function_definitions(tree: ast.Module) -> list[tuple[str, int]]:
|
|
102
|
+
"""Return ``(name, line)`` for each module-scope public function definition.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
tree: The parsed production module.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
One ``(name, definition_line)`` pair per module-scope public function,
|
|
109
|
+
in source order.
|
|
110
|
+
"""
|
|
111
|
+
all_definitions: list[tuple[str, int]] = []
|
|
112
|
+
for each_statement in tree.body:
|
|
113
|
+
if not isinstance(each_statement, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
114
|
+
continue
|
|
115
|
+
if _is_public_function_name(each_statement.name):
|
|
116
|
+
all_definitions.append((each_statement.name, each_statement.lineno))
|
|
117
|
+
return all_definitions
|
|
118
|
+
|
|
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
|
+
|
|
140
|
+
def _ancestor_tests_directories(start_directory: Path) -> list[Path]:
|
|
141
|
+
"""Return each ancestor's ``tests`` directory, nearest ancestor first.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
start_directory: The directory holding the production module.
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
Existing ``tests`` directories found by walking from start_directory
|
|
148
|
+
toward the filesystem root, bounded by the ancestor walk limit.
|
|
149
|
+
"""
|
|
150
|
+
all_candidate_directories = [start_directory, *start_directory.parents]
|
|
151
|
+
all_tests_directories: list[Path] = []
|
|
152
|
+
for each_directory in all_candidate_directories[:ANCESTOR_DIRECTORY_WALK_LIMIT]:
|
|
153
|
+
candidate_tests_directory = each_directory / TESTS_DIRECTORY_NAME
|
|
154
|
+
if candidate_tests_directory.is_dir():
|
|
155
|
+
all_tests_directories.append(candidate_tests_directory)
|
|
156
|
+
return all_tests_directories
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _stem_matched_test_path(module_path: Path) -> Path | None:
|
|
160
|
+
"""Return the first existing stem-matched test file for a module, or None.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
module_path: The resolved path of the production module.
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
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.
|
|
169
|
+
"""
|
|
170
|
+
module_directory = module_path.parent
|
|
171
|
+
module_stem = module_path.stem
|
|
172
|
+
flat_prefixed_name = STEM_TEST_FILENAME_PREFIX + module_stem + PYTHON_SOURCE_SUFFIX
|
|
173
|
+
flat_suffixed_name = module_stem + STEM_TEST_FILENAME_SUFFIX
|
|
174
|
+
all_candidate_paths = [
|
|
175
|
+
module_directory / flat_prefixed_name,
|
|
176
|
+
module_directory / flat_suffixed_name,
|
|
177
|
+
]
|
|
178
|
+
for each_tests_directory in _ancestor_tests_directories(module_directory):
|
|
179
|
+
all_candidate_paths.append(each_tests_directory / flat_prefixed_name)
|
|
180
|
+
for each_candidate_path in all_candidate_paths:
|
|
181
|
+
if each_candidate_path.is_file():
|
|
182
|
+
return each_candidate_path
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _collect_referenced_identifiers(source: str) -> set[str]:
|
|
187
|
+
"""Return every identifier a test module references — imported, called, or named.
|
|
188
|
+
|
|
189
|
+
Collects ``import`` binding names and ``from``-import member names, every
|
|
190
|
+
``Name`` node id, and every attribute access name, so a public function the
|
|
191
|
+
test imports, calls bare, or reaches through an attribute all count as a
|
|
192
|
+
reference. A module that fails to parse contributes no identifiers.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
source: The full text of a test module.
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
The set of identifiers the test module references.
|
|
199
|
+
"""
|
|
200
|
+
try:
|
|
201
|
+
tree = ast.parse(source)
|
|
202
|
+
except SyntaxError:
|
|
203
|
+
return set()
|
|
204
|
+
all_referenced_identifiers: set[str] = set()
|
|
205
|
+
for each_node in ast.walk(tree):
|
|
206
|
+
if isinstance(each_node, ast.Name):
|
|
207
|
+
all_referenced_identifiers.add(each_node.id)
|
|
208
|
+
elif isinstance(each_node, ast.Attribute):
|
|
209
|
+
all_referenced_identifiers.add(each_node.attr)
|
|
210
|
+
elif isinstance(each_node, (ast.Import, ast.ImportFrom)):
|
|
211
|
+
for each_alias in each_node.names:
|
|
212
|
+
all_referenced_identifiers.add(each_alias.asname or each_alias.name)
|
|
213
|
+
all_referenced_identifiers.add(each_alias.name)
|
|
214
|
+
return all_referenced_identifiers
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _suite_referenced_identifiers(stem_test_path: Path) -> set[str]:
|
|
218
|
+
"""Return identifiers referenced across every test file beside the stem test.
|
|
219
|
+
|
|
220
|
+
Scans every ``test_*.py`` and ``*_test.py`` file in the directory holding the
|
|
221
|
+
stem-matched test file, bounded by the scan cap, and unions the identifiers
|
|
222
|
+
each one references.
|
|
223
|
+
|
|
224
|
+
Args:
|
|
225
|
+
stem_test_path: The stem-matched test file whose directory is scanned.
|
|
226
|
+
|
|
227
|
+
Returns:
|
|
228
|
+
The union of referenced identifiers across the scanned test files.
|
|
229
|
+
"""
|
|
230
|
+
suite_directory = stem_test_path.parent
|
|
231
|
+
all_test_file_paths: list[Path] = []
|
|
232
|
+
for each_glob in ALL_TEST_FILENAME_GLOBS:
|
|
233
|
+
all_test_file_paths.extend(sorted(suite_directory.glob(each_glob)))
|
|
234
|
+
all_referenced_identifiers: set[str] = set()
|
|
235
|
+
for each_test_file_path in all_test_file_paths[:MAX_TEST_FILES_SCANNED]:
|
|
236
|
+
try:
|
|
237
|
+
test_source = each_test_file_path.read_text(encoding="utf-8")
|
|
238
|
+
except (OSError, UnicodeDecodeError):
|
|
239
|
+
continue
|
|
240
|
+
all_referenced_identifiers |= _collect_referenced_identifiers(test_source)
|
|
241
|
+
return all_referenced_identifiers
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _module_is_exempt(file_path: str) -> bool:
|
|
245
|
+
"""Return whether a path is exempt from the paired-test coverage check.
|
|
246
|
+
|
|
247
|
+
Test modules, hook infrastructure, config modules, migrations, workflow
|
|
248
|
+
registries, and package ``__init__`` re-export modules are all exempt.
|
|
249
|
+
|
|
250
|
+
Args:
|
|
251
|
+
file_path: The destination path of the write or edit.
|
|
252
|
+
|
|
253
|
+
Returns:
|
|
254
|
+
True when the paired-test coverage check must not run on this path.
|
|
255
|
+
"""
|
|
256
|
+
if is_test_file(file_path):
|
|
257
|
+
return True
|
|
258
|
+
if is_hook_infrastructure(file_path):
|
|
259
|
+
return True
|
|
260
|
+
if is_config_file(file_path):
|
|
261
|
+
return True
|
|
262
|
+
if is_migration_file(file_path):
|
|
263
|
+
return True
|
|
264
|
+
if is_workflow_registry_file(file_path):
|
|
265
|
+
return True
|
|
266
|
+
return Path(file_path).name == INIT_MODULE_FILENAME
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def check_public_function_missing_paired_test(
|
|
270
|
+
content: str,
|
|
271
|
+
file_path: str,
|
|
272
|
+
all_changed_lines: set[int] | None = None,
|
|
273
|
+
defer_scope_to_caller: bool = False,
|
|
274
|
+
) -> list[str]:
|
|
275
|
+
"""Flag a public function the module's established paired test suite omits.
|
|
276
|
+
|
|
277
|
+
Runs only on a production module whose stem-matched ``test_<stem>.py`` already
|
|
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.
|
|
280
|
+
Under that established-suite precondition, a public function the suite
|
|
281
|
+
references nowhere is flagged, so the forgotten function gets a behavioral
|
|
282
|
+
test before the partially-covered module lands. ``main`` and
|
|
283
|
+
underscore-prefixed functions are never required to carry a test, and test
|
|
284
|
+
modules, hook infrastructure, config modules, migrations, workflow
|
|
285
|
+
registries, and ``__init__`` modules are exempt.
|
|
286
|
+
|
|
287
|
+
Args:
|
|
288
|
+
content: The reconstructed post-edit whole-file content of the module.
|
|
289
|
+
file_path: The destination path, used for the exemptions and to locate
|
|
290
|
+
the module's paired test files on disk.
|
|
291
|
+
all_changed_lines: Post-edit line numbers the current edit touched, or
|
|
292
|
+
None to treat every public-function definition as in scope.
|
|
293
|
+
defer_scope_to_caller: When True, return every violation so the
|
|
294
|
+
commit/push gate scopes by added line.
|
|
295
|
+
|
|
296
|
+
Returns:
|
|
297
|
+
One violation per uncovered public function, capped at the configured
|
|
298
|
+
maximum, scoped to the changed lines unless deferred or unscoped. Empty
|
|
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.
|
|
302
|
+
"""
|
|
303
|
+
if _module_is_exempt(file_path):
|
|
304
|
+
return []
|
|
305
|
+
try:
|
|
306
|
+
tree = ast.parse(content)
|
|
307
|
+
except SyntaxError:
|
|
308
|
+
return []
|
|
309
|
+
all_public_definitions = _public_function_definitions(tree)
|
|
310
|
+
if not all_public_definitions:
|
|
311
|
+
return []
|
|
312
|
+
stem_test_path = _stem_matched_test_path(Path(file_path).resolve())
|
|
313
|
+
if stem_test_path is None:
|
|
314
|
+
return []
|
|
315
|
+
all_referenced_identifiers = _suite_referenced_identifiers(stem_test_path)
|
|
316
|
+
covered_function_count = sum(
|
|
317
|
+
1
|
|
318
|
+
for each_name, _each_line in all_public_definitions
|
|
319
|
+
if each_name in all_referenced_identifiers
|
|
320
|
+
)
|
|
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:
|
|
329
|
+
return []
|
|
330
|
+
all_violations_in_walk_order: list[tuple[range, str]] = []
|
|
331
|
+
for each_name, each_definition_line in all_public_definitions:
|
|
332
|
+
if each_name in all_referenced_identifiers:
|
|
333
|
+
continue
|
|
334
|
+
message = (
|
|
335
|
+
f"Line {each_definition_line}: public function {each_name!r} "
|
|
336
|
+
f"{MISSING_PAIRED_TEST_GUIDANCE}"
|
|
337
|
+
)
|
|
338
|
+
all_violations_in_walk_order.append(
|
|
339
|
+
(range(each_definition_line, each_definition_line + 1), message)
|
|
340
|
+
)
|
|
341
|
+
if len(all_violations_in_walk_order) >= MAX_PAIRED_TEST_COVERAGE_ISSUES:
|
|
342
|
+
break
|
|
343
|
+
return _scope_violations_to_changed_lines(
|
|
344
|
+
all_violations_in_walk_order,
|
|
345
|
+
all_changed_lines,
|
|
346
|
+
defer_scope_to_caller,
|
|
347
|
+
)
|
|
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
|
-
"""Bare string-literal magic, inline literal-collection,
|
|
1
|
+
"""Bare string-literal magic, inline literal-collection, inline tuple string-magic, and whitespace-indentation magic checks."""
|
|
2
2
|
|
|
3
3
|
import ast
|
|
4
4
|
import re
|
|
@@ -25,7 +25,11 @@ from code_rules_shared import ( # noqa: E402
|
|
|
25
25
|
from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
|
|
26
26
|
ALL_CAPS_WITH_UNDERSCORE_PATTERN,
|
|
27
27
|
DOTTED_SEGMENT_PATTERN,
|
|
28
|
+
INDENTATION_MAGIC_MINIMUM_SPACE_RUN,
|
|
29
|
+
INDENTATION_MAGIC_MINIMUM_TAB_RUN,
|
|
28
30
|
INLINE_COLLECTION_MIN_LENGTH,
|
|
31
|
+
MAX_WHITESPACE_INDENTATION_MAGIC_ISSUES,
|
|
32
|
+
WHITESPACE_INDENTATION_MAGIC_MESSAGE_SUFFIX,
|
|
29
33
|
)
|
|
30
34
|
from hooks_constants.inline_tuple_string_magic_constants import ( # noqa: E402
|
|
31
35
|
ALL_SNAKE_CASE_KEYWORD_EXEMPTIONS,
|
|
@@ -205,3 +209,69 @@ def check_inline_tuple_string_magic(content: str, file_path: str) -> list[str]:
|
|
|
205
209
|
if len(issues) >= MAX_INLINE_TUPLE_STRING_MAGIC_ISSUES:
|
|
206
210
|
return issues
|
|
207
211
|
return issues
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _is_whitespace_indentation_literal(string_value: str) -> bool:
|
|
215
|
+
if not string_value:
|
|
216
|
+
return False
|
|
217
|
+
if string_value.strip():
|
|
218
|
+
return False
|
|
219
|
+
if "\t" * INDENTATION_MAGIC_MINIMUM_TAB_RUN in string_value:
|
|
220
|
+
return True
|
|
221
|
+
return " " * INDENTATION_MAGIC_MINIMUM_SPACE_RUN in string_value
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def check_whitespace_indentation_magic(content: str, file_path: str) -> list[str]:
|
|
225
|
+
"""Flag a whitespace-only indentation literal in a function body.
|
|
226
|
+
|
|
227
|
+
A string literal that is entirely whitespace and carries a run of at least
|
|
228
|
+
``INDENTATION_MAGIC_MINIMUM_SPACE_RUN`` spaces, or a run of at least
|
|
229
|
+
``INDENTATION_MAGIC_MINIMUM_TAB_RUN`` tabs, is a formatting default — the
|
|
230
|
+
indentation an output builder prepends — so it belongs in a
|
|
231
|
+
named constant in ``config/`` that one definition feeds to every call site.
|
|
232
|
+
Both a standalone string constant (``" "``) and the literal
|
|
233
|
+
fragment of an f-string (``f"\\n {value}"``) are inspected, so an
|
|
234
|
+
indent embedded beside an interpolation is caught too. Config files, test
|
|
235
|
+
files, workflow-registry files, and migration files are exempt.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
content: The source text to inspect.
|
|
239
|
+
file_path: The path the source will be written to, used for exemptions.
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
One issue per whitespace-only indentation literal found in a function
|
|
243
|
+
body, capped at the module limit.
|
|
244
|
+
"""
|
|
245
|
+
if is_test_file(file_path):
|
|
246
|
+
return []
|
|
247
|
+
if is_config_file(file_path):
|
|
248
|
+
return []
|
|
249
|
+
if is_workflow_registry_file(file_path) or is_migration_file(file_path):
|
|
250
|
+
return []
|
|
251
|
+
try:
|
|
252
|
+
tree = ast.parse(content)
|
|
253
|
+
except SyntaxError:
|
|
254
|
+
return []
|
|
255
|
+
issues: list[str] = []
|
|
256
|
+
flagged_node_ids: set[int] = set()
|
|
257
|
+
for each_function_node in ast.walk(tree):
|
|
258
|
+
if not isinstance(each_function_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
259
|
+
continue
|
|
260
|
+
for each_body_statement in each_function_node.body:
|
|
261
|
+
for each_descendant in _walk_skipping_nested_function_defs(each_body_statement):
|
|
262
|
+
if not isinstance(each_descendant, ast.Constant):
|
|
263
|
+
continue
|
|
264
|
+
if not isinstance(each_descendant.value, str):
|
|
265
|
+
continue
|
|
266
|
+
if id(each_descendant) in flagged_node_ids:
|
|
267
|
+
continue
|
|
268
|
+
if not _is_whitespace_indentation_literal(each_descendant.value):
|
|
269
|
+
continue
|
|
270
|
+
flagged_node_ids.add(id(each_descendant))
|
|
271
|
+
issues.append(
|
|
272
|
+
f"Line {each_descendant.lineno}: {each_descendant.value!r} "
|
|
273
|
+
f"{WHITESPACE_INDENTATION_MAGIC_MESSAGE_SUFFIX}"
|
|
274
|
+
)
|
|
275
|
+
if len(issues) >= MAX_WHITESPACE_INDENTATION_MAGIC_ISSUES:
|
|
276
|
+
return issues
|
|
277
|
+
return issues
|