claude-dev-env 1.76.0 → 1.78.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-o-docstring-vs-impl-drift.md +1 -0
- package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +8 -4
- package/hooks/blocking/CLAUDE.md +3 -1
- 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 +730 -0
- package/hooks/blocking/code_rules_enforcer.py +39 -0
- package/hooks/blocking/code_rules_paired_test.py +299 -0
- package/hooks/blocking/code_rules_string_magic.py +71 -1
- package/hooks/blocking/convergence_gate_blocker.py +24 -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_args_span_scope.py +174 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_cardinal_family.py +176 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_mark_glyph_enumeration.py +262 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_raises_largezipfile.py +226 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_runon_sentence.py +267 -0
- package/hooks/blocking/test_code_rules_enforcer_paired_test.py +149 -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/hooks_constants/CLAUDE.md +1 -0
- package/hooks/hooks_constants/blocking_check_limits.py +51 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +49 -0
- package/hooks/hooks_constants/paired_test_coverage_constants.py +27 -0
- package/package.json +1 -1
- package/rules/CLAUDE.md +2 -0
- package/rules/docstring-prose-matches-implementation.md +5 -1
- package/rules/file-global-constants.md +2 -2
- package/rules/package-inventory-stale-entry.md +8 -0
- package/rules/paired-test-coverage.md +28 -0
- package/rules/plain-illustrative-docstrings.md +56 -0
|
@@ -64,15 +64,23 @@ from code_rules_dead_dataclass_field import ( # noqa: E402
|
|
|
64
64
|
from code_rules_dead_module_constant import ( # noqa: E402
|
|
65
65
|
check_dead_module_constants,
|
|
66
66
|
)
|
|
67
|
+
from code_rules_dead_split_branch import ( # noqa: E402
|
|
68
|
+
check_dead_split_truthiness_branch,
|
|
69
|
+
)
|
|
67
70
|
from code_rules_docstrings import ( # noqa: E402
|
|
68
71
|
check_class_docstring_names_public_methods,
|
|
69
72
|
check_docstring_args_match_signature,
|
|
73
|
+
check_docstring_args_single_line_scope_vs_span,
|
|
74
|
+
check_docstring_cardinal_count_matches_constant_family,
|
|
70
75
|
check_docstring_fallback_branch_coverage,
|
|
71
76
|
check_docstring_format,
|
|
72
77
|
check_docstring_names_undefined_constant,
|
|
73
78
|
check_docstring_no_consumer_claim,
|
|
74
79
|
check_docstring_no_inline_literal_claim,
|
|
80
|
+
check_docstring_punctuation_mark_enumeration_coverage,
|
|
81
|
+
check_docstring_raises_unraisable_largezipfile,
|
|
75
82
|
check_docstring_returns_plural_cardinality,
|
|
83
|
+
check_docstring_runon_sentence,
|
|
76
84
|
check_docstring_step_enumeration_dispatch_coverage,
|
|
77
85
|
check_docstring_tuple_enumeration_match,
|
|
78
86
|
check_docstring_unguarded_malformed_payload_claim,
|
|
@@ -113,6 +121,9 @@ from code_rules_optional_params import ( # noqa: E402
|
|
|
113
121
|
from code_rules_orphan_css_class import ( # noqa: E402
|
|
114
122
|
check_orphan_css_classes,
|
|
115
123
|
)
|
|
124
|
+
from code_rules_paired_test import ( # noqa: E402
|
|
125
|
+
check_public_function_missing_paired_test,
|
|
126
|
+
)
|
|
116
127
|
from code_rules_paths_syspath import ( # noqa: E402
|
|
117
128
|
check_hardcoded_user_paths,
|
|
118
129
|
check_sys_path_insert_deduplication_guard,
|
|
@@ -128,6 +139,7 @@ from code_rules_string_magic import ( # noqa: E402
|
|
|
128
139
|
check_inline_literal_collections,
|
|
129
140
|
check_inline_tuple_string_magic,
|
|
130
141
|
check_string_literal_magic,
|
|
142
|
+
check_whitespace_indentation_magic,
|
|
131
143
|
)
|
|
132
144
|
from code_rules_test_assertions import ( # noqa: E402
|
|
133
145
|
check_constant_equality_tests,
|
|
@@ -295,12 +307,18 @@ def validate_content(
|
|
|
295
307
|
all_issues.extend(
|
|
296
308
|
check_class_docstring_names_public_methods(effective_content, file_path)
|
|
297
309
|
)
|
|
310
|
+
all_issues.extend(check_docstring_runon_sentence(effective_content, file_path))
|
|
298
311
|
all_issues.extend(
|
|
299
312
|
check_module_docstring_names_public_checks(effective_content, file_path)
|
|
300
313
|
)
|
|
301
314
|
all_issues.extend(
|
|
302
315
|
check_docstring_tuple_enumeration_match(effective_content, file_path)
|
|
303
316
|
)
|
|
317
|
+
all_issues.extend(
|
|
318
|
+
check_docstring_punctuation_mark_enumeration_coverage(
|
|
319
|
+
effective_content, file_path
|
|
320
|
+
)
|
|
321
|
+
)
|
|
304
322
|
all_issues.extend(
|
|
305
323
|
check_docstring_step_enumeration_dispatch_coverage(
|
|
306
324
|
effective_content, file_path
|
|
@@ -309,9 +327,20 @@ def validate_content(
|
|
|
309
327
|
all_issues.extend(
|
|
310
328
|
check_docstring_returns_plural_cardinality(effective_content, file_path)
|
|
311
329
|
)
|
|
330
|
+
all_issues.extend(
|
|
331
|
+
check_docstring_raises_unraisable_largezipfile(effective_content, file_path)
|
|
332
|
+
)
|
|
333
|
+
all_issues.extend(
|
|
334
|
+
check_docstring_cardinal_count_matches_constant_family(
|
|
335
|
+
effective_content, file_path
|
|
336
|
+
)
|
|
337
|
+
)
|
|
312
338
|
all_issues.extend(
|
|
313
339
|
check_docstring_names_undefined_constant(effective_content, file_path)
|
|
314
340
|
)
|
|
341
|
+
all_issues.extend(
|
|
342
|
+
check_docstring_args_single_line_scope_vs_span(effective_content, file_path)
|
|
343
|
+
)
|
|
315
344
|
all_issues.extend(
|
|
316
345
|
check_boolean_naming(
|
|
317
346
|
effective_content,
|
|
@@ -361,6 +390,7 @@ def validate_content(
|
|
|
361
390
|
all_issues.extend(
|
|
362
391
|
check_dead_module_constants(content, file_path, full_file_content)
|
|
363
392
|
)
|
|
393
|
+
all_issues.extend(check_dead_split_truthiness_branch(content, file_path))
|
|
364
394
|
all_issues.extend(check_library_print(content, file_path))
|
|
365
395
|
all_issues.extend(check_parameter_annotations(content, file_path))
|
|
366
396
|
all_issues.extend(check_known_pytest_fixture_annotations(content, file_path))
|
|
@@ -376,10 +406,19 @@ def validate_content(
|
|
|
376
406
|
defer_scope_to_caller,
|
|
377
407
|
)
|
|
378
408
|
)
|
|
409
|
+
all_issues.extend(
|
|
410
|
+
check_public_function_missing_paired_test(
|
|
411
|
+
effective_content,
|
|
412
|
+
file_path,
|
|
413
|
+
all_changed_lines,
|
|
414
|
+
defer_scope_to_caller,
|
|
415
|
+
)
|
|
416
|
+
)
|
|
379
417
|
all_issues.extend(check_loop_variable_naming(content, file_path))
|
|
380
418
|
all_issues.extend(check_inline_literal_collections(content, file_path))
|
|
381
419
|
all_issues.extend(check_inline_tuple_string_magic(content, file_path))
|
|
382
420
|
all_issues.extend(check_string_literal_magic(content, file_path))
|
|
421
|
+
all_issues.extend(check_whitespace_indentation_magic(content, file_path))
|
|
383
422
|
all_issues.extend(check_orphan_css_classes(effective_content, file_path))
|
|
384
423
|
check_incomplete_mocks(content, file_path)
|
|
385
424
|
check_duplicated_format_patterns(content, file_path)
|
|
@@ -0,0 +1,299 @@
|
|
|
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. When a production
|
|
7
|
+
module has an established paired test suite — a stem-matched ``test_<stem>.py``
|
|
8
|
+
that already exercises at least one of the module's public functions — a public
|
|
9
|
+
function that suite references nowhere is flagged, so the forgotten function
|
|
10
|
+
gets a behavioral test before the partially-covered module lands.
|
|
11
|
+
|
|
12
|
+
The check stays conservative to keep false positives near zero:
|
|
13
|
+
|
|
14
|
+
- It runs only on a production module whose stem-matched test file already
|
|
15
|
+
exists; a module with no dedicated test file is out of scope and left to the
|
|
16
|
+
file-level TDD gate.
|
|
17
|
+
- It fires only when the suite already covers at least one public function of
|
|
18
|
+
the same module — the signature of a maintained per-function suite rather than
|
|
19
|
+
an unrelated or placeholder test file.
|
|
20
|
+
- A public function counts as covered when its name appears in any test file
|
|
21
|
+
beside the stem-matched one — imported, called, or named — so a function
|
|
22
|
+
exercised by a differently-named sibling test still counts.
|
|
23
|
+
- ``main`` and underscore-prefixed functions are never required to carry a test.
|
|
24
|
+
- Test modules, hook infrastructure, config modules, migrations, workflow
|
|
25
|
+
registries, and ``__init__`` modules are exempt.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
import ast
|
|
29
|
+
import sys
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
_blocking_directory = str(Path(__file__).resolve().parent)
|
|
33
|
+
_hooks_directory = str(Path(__file__).resolve().parent.parent)
|
|
34
|
+
if _blocking_directory not in sys.path:
|
|
35
|
+
sys.path.insert(0, _blocking_directory)
|
|
36
|
+
if _hooks_directory not in sys.path:
|
|
37
|
+
sys.path.insert(0, _hooks_directory)
|
|
38
|
+
|
|
39
|
+
from code_rules_path_utils import ( # noqa: E402
|
|
40
|
+
is_config_file,
|
|
41
|
+
)
|
|
42
|
+
from code_rules_shared import ( # noqa: E402
|
|
43
|
+
_scope_violations_to_changed_lines,
|
|
44
|
+
is_hook_infrastructure,
|
|
45
|
+
is_migration_file,
|
|
46
|
+
is_test_file,
|
|
47
|
+
is_workflow_registry_file,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
from hooks_constants.paired_test_coverage_constants import ( # noqa: E402
|
|
51
|
+
ALL_TEST_FILENAME_GLOBS,
|
|
52
|
+
ANCESTOR_DIRECTORY_WALK_LIMIT,
|
|
53
|
+
EXEMPT_PUBLIC_FUNCTION_NAMES,
|
|
54
|
+
INIT_MODULE_FILENAME,
|
|
55
|
+
MAX_PAIRED_TEST_COVERAGE_ISSUES,
|
|
56
|
+
MAX_TEST_FILES_SCANNED,
|
|
57
|
+
MINIMUM_COVERED_PUBLIC_FUNCTIONS,
|
|
58
|
+
MISSING_PAIRED_TEST_GUIDANCE,
|
|
59
|
+
PYTHON_SOURCE_SUFFIX,
|
|
60
|
+
STEM_TEST_FILENAME_PREFIX,
|
|
61
|
+
STEM_TEST_FILENAME_SUFFIX,
|
|
62
|
+
TESTS_DIRECTORY_NAME,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _is_public_function_name(function_name: str) -> bool:
|
|
67
|
+
"""Return whether a function name is a public, test-worthy entry point.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
function_name: The bare function name from a module-scope definition.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
True when the name does not start with an underscore and is not one of
|
|
74
|
+
the conventionally untested entry points such as ``main``.
|
|
75
|
+
"""
|
|
76
|
+
if function_name.startswith("_"):
|
|
77
|
+
return False
|
|
78
|
+
return function_name not in EXEMPT_PUBLIC_FUNCTION_NAMES
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _public_function_definitions(tree: ast.Module) -> list[tuple[str, int]]:
|
|
82
|
+
"""Return ``(name, line)`` for each module-scope public function definition.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
tree: The parsed production module.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
One ``(name, definition_line)`` pair per module-scope public function,
|
|
89
|
+
in source order.
|
|
90
|
+
"""
|
|
91
|
+
all_definitions: list[tuple[str, int]] = []
|
|
92
|
+
for each_statement in tree.body:
|
|
93
|
+
if not isinstance(each_statement, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
94
|
+
continue
|
|
95
|
+
if _is_public_function_name(each_statement.name):
|
|
96
|
+
all_definitions.append((each_statement.name, each_statement.lineno))
|
|
97
|
+
return all_definitions
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _ancestor_tests_directories(start_directory: Path) -> list[Path]:
|
|
101
|
+
"""Return each ancestor's ``tests`` directory, nearest ancestor first.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
start_directory: The directory holding the production module.
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
Existing ``tests`` directories found by walking from start_directory
|
|
108
|
+
toward the filesystem root, bounded by the ancestor walk limit.
|
|
109
|
+
"""
|
|
110
|
+
all_candidate_directories = [start_directory, *start_directory.parents]
|
|
111
|
+
all_tests_directories: list[Path] = []
|
|
112
|
+
for each_directory in all_candidate_directories[:ANCESTOR_DIRECTORY_WALK_LIMIT]:
|
|
113
|
+
candidate_tests_directory = each_directory / TESTS_DIRECTORY_NAME
|
|
114
|
+
if candidate_tests_directory.is_dir():
|
|
115
|
+
all_tests_directories.append(candidate_tests_directory)
|
|
116
|
+
return all_tests_directories
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _stem_matched_test_path(module_path: Path) -> Path | None:
|
|
120
|
+
"""Return the first existing stem-matched test file for a module, or None.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
module_path: The resolved path of the production module.
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
The path of the first existing ``test_<stem>.py`` or ``<stem>_test.py``
|
|
127
|
+
file — beside the module or under an ancestor ``tests`` directory — or
|
|
128
|
+
None when the module has no dedicated test file.
|
|
129
|
+
"""
|
|
130
|
+
module_directory = module_path.parent
|
|
131
|
+
module_stem = module_path.stem
|
|
132
|
+
flat_prefixed_name = STEM_TEST_FILENAME_PREFIX + module_stem + PYTHON_SOURCE_SUFFIX
|
|
133
|
+
flat_suffixed_name = module_stem + STEM_TEST_FILENAME_SUFFIX
|
|
134
|
+
all_candidate_paths = [
|
|
135
|
+
module_directory / flat_prefixed_name,
|
|
136
|
+
module_directory / flat_suffixed_name,
|
|
137
|
+
]
|
|
138
|
+
for each_tests_directory in _ancestor_tests_directories(module_directory):
|
|
139
|
+
all_candidate_paths.append(each_tests_directory / flat_prefixed_name)
|
|
140
|
+
for each_candidate_path in all_candidate_paths:
|
|
141
|
+
if each_candidate_path.is_file():
|
|
142
|
+
return each_candidate_path
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _collect_referenced_identifiers(source: str) -> set[str]:
|
|
147
|
+
"""Return every identifier a test module references — imported, called, or named.
|
|
148
|
+
|
|
149
|
+
Collects ``import`` binding names and ``from``-import member names, every
|
|
150
|
+
``Name`` node id, and every attribute access name, so a public function the
|
|
151
|
+
test imports, calls bare, or reaches through an attribute all count as a
|
|
152
|
+
reference. A module that fails to parse contributes no identifiers.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
source: The full text of a test module.
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
The set of identifiers the test module references.
|
|
159
|
+
"""
|
|
160
|
+
try:
|
|
161
|
+
tree = ast.parse(source)
|
|
162
|
+
except SyntaxError:
|
|
163
|
+
return set()
|
|
164
|
+
all_referenced_identifiers: set[str] = set()
|
|
165
|
+
for each_node in ast.walk(tree):
|
|
166
|
+
if isinstance(each_node, ast.Name):
|
|
167
|
+
all_referenced_identifiers.add(each_node.id)
|
|
168
|
+
elif isinstance(each_node, ast.Attribute):
|
|
169
|
+
all_referenced_identifiers.add(each_node.attr)
|
|
170
|
+
elif isinstance(each_node, (ast.Import, ast.ImportFrom)):
|
|
171
|
+
for each_alias in each_node.names:
|
|
172
|
+
all_referenced_identifiers.add(each_alias.asname or each_alias.name)
|
|
173
|
+
all_referenced_identifiers.add(each_alias.name)
|
|
174
|
+
return all_referenced_identifiers
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _suite_referenced_identifiers(stem_test_path: Path) -> set[str]:
|
|
178
|
+
"""Return identifiers referenced across every test file beside the stem test.
|
|
179
|
+
|
|
180
|
+
Scans every ``test_*.py`` and ``*_test.py`` file in the directory holding the
|
|
181
|
+
stem-matched test file, bounded by the scan cap, and unions the identifiers
|
|
182
|
+
each one references.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
stem_test_path: The stem-matched test file whose directory is scanned.
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
The union of referenced identifiers across the scanned test files.
|
|
189
|
+
"""
|
|
190
|
+
suite_directory = stem_test_path.parent
|
|
191
|
+
all_test_file_paths: list[Path] = []
|
|
192
|
+
for each_glob in ALL_TEST_FILENAME_GLOBS:
|
|
193
|
+
all_test_file_paths.extend(sorted(suite_directory.glob(each_glob)))
|
|
194
|
+
all_referenced_identifiers: set[str] = set()
|
|
195
|
+
for each_test_file_path in all_test_file_paths[:MAX_TEST_FILES_SCANNED]:
|
|
196
|
+
try:
|
|
197
|
+
test_source = each_test_file_path.read_text(encoding="utf-8")
|
|
198
|
+
except (OSError, UnicodeDecodeError):
|
|
199
|
+
continue
|
|
200
|
+
all_referenced_identifiers |= _collect_referenced_identifiers(test_source)
|
|
201
|
+
return all_referenced_identifiers
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _module_is_exempt(file_path: str) -> bool:
|
|
205
|
+
"""Return whether a path is exempt from the paired-test coverage check.
|
|
206
|
+
|
|
207
|
+
Test modules, hook infrastructure, config modules, migrations, workflow
|
|
208
|
+
registries, and package ``__init__`` re-export modules are all exempt.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
file_path: The destination path of the write or edit.
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
True when the paired-test coverage check must not run on this path.
|
|
215
|
+
"""
|
|
216
|
+
if is_test_file(file_path):
|
|
217
|
+
return True
|
|
218
|
+
if is_hook_infrastructure(file_path):
|
|
219
|
+
return True
|
|
220
|
+
if is_config_file(file_path):
|
|
221
|
+
return True
|
|
222
|
+
if is_migration_file(file_path):
|
|
223
|
+
return True
|
|
224
|
+
if is_workflow_registry_file(file_path):
|
|
225
|
+
return True
|
|
226
|
+
return Path(file_path).name == INIT_MODULE_FILENAME
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def check_public_function_missing_paired_test(
|
|
230
|
+
content: str,
|
|
231
|
+
file_path: str,
|
|
232
|
+
all_changed_lines: set[int] | None = None,
|
|
233
|
+
defer_scope_to_caller: bool = False,
|
|
234
|
+
) -> list[str]:
|
|
235
|
+
"""Flag a public function the module's established paired test suite omits.
|
|
236
|
+
|
|
237
|
+
Runs only on a production module whose stem-matched ``test_<stem>.py`` already
|
|
238
|
+
exists and already exercises at least one of the module's public functions.
|
|
239
|
+
Under that established-suite precondition, a public function the suite
|
|
240
|
+
references nowhere is flagged, so the forgotten function gets a behavioral
|
|
241
|
+
test before the partially-covered module lands. ``main`` and
|
|
242
|
+
underscore-prefixed functions are never required to carry a test, and test
|
|
243
|
+
modules, hook infrastructure, config modules, migrations, workflow
|
|
244
|
+
registries, and ``__init__`` modules are exempt.
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
content: The reconstructed post-edit whole-file content of the module.
|
|
248
|
+
file_path: The destination path, used for the exemptions and to locate
|
|
249
|
+
the module's paired test files on disk.
|
|
250
|
+
all_changed_lines: Post-edit line numbers the current edit touched, or
|
|
251
|
+
None to treat every public-function definition as in scope.
|
|
252
|
+
defer_scope_to_caller: When True, return every violation so the
|
|
253
|
+
commit/push gate scopes by added line.
|
|
254
|
+
|
|
255
|
+
Returns:
|
|
256
|
+
One violation per uncovered public function, capped at the configured
|
|
257
|
+
maximum, scoped to the changed lines unless deferred or unscoped. Empty
|
|
258
|
+
when the module is exempt, has no dedicated test file, the suite covers
|
|
259
|
+
no public function, or the content fails to parse.
|
|
260
|
+
"""
|
|
261
|
+
if _module_is_exempt(file_path):
|
|
262
|
+
return []
|
|
263
|
+
try:
|
|
264
|
+
tree = ast.parse(content)
|
|
265
|
+
except SyntaxError:
|
|
266
|
+
return []
|
|
267
|
+
all_public_definitions = _public_function_definitions(tree)
|
|
268
|
+
if not all_public_definitions:
|
|
269
|
+
return []
|
|
270
|
+
stem_test_path = _stem_matched_test_path(Path(file_path).resolve())
|
|
271
|
+
if stem_test_path is None:
|
|
272
|
+
return []
|
|
273
|
+
all_referenced_identifiers = _suite_referenced_identifiers(stem_test_path)
|
|
274
|
+
covered_function_count = sum(
|
|
275
|
+
1
|
|
276
|
+
for each_name, _each_line in all_public_definitions
|
|
277
|
+
if each_name in all_referenced_identifiers
|
|
278
|
+
)
|
|
279
|
+
if covered_function_count < MINIMUM_COVERED_PUBLIC_FUNCTIONS:
|
|
280
|
+
return []
|
|
281
|
+
all_violations_in_walk_order: list[tuple[range, str]] = []
|
|
282
|
+
for each_name, each_definition_line in all_public_definitions:
|
|
283
|
+
if each_name in all_referenced_identifiers:
|
|
284
|
+
continue
|
|
285
|
+
message = (
|
|
286
|
+
f"Line {each_definition_line}: public function {each_name!r} "
|
|
287
|
+
f"{MISSING_PAIRED_TEST_GUIDANCE}"
|
|
288
|
+
)
|
|
289
|
+
all_violations_in_walk_order.append(
|
|
290
|
+
(range(each_definition_line, each_definition_line + 1), message)
|
|
291
|
+
)
|
|
292
|
+
if len(all_violations_in_walk_order) >= MAX_PAIRED_TEST_COVERAGE_ISSUES:
|
|
293
|
+
break
|
|
294
|
+
return _scope_violations_to_changed_lines(
|
|
295
|
+
all_violations_in_walk_order,
|
|
296
|
+
all_changed_lines,
|
|
297
|
+
defer_scope_to_caller,
|
|
298
|
+
)
|
|
299
|
+
|
|
@@ -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
|
|
@@ -68,6 +68,27 @@ def _resolve_owner_repo(cwd: str | None) -> tuple[str, str] | None:
|
|
|
68
68
|
return parts[0], parts[1]
|
|
69
69
|
|
|
70
70
|
|
|
71
|
+
def _run_convergence_check(
|
|
72
|
+
script: str, owner: str, repo: str, pr_number: int, cwd: str | None
|
|
73
|
+
) -> subprocess.CompletedProcess[str]:
|
|
74
|
+
return subprocess.run(
|
|
75
|
+
[
|
|
76
|
+
sys.executable,
|
|
77
|
+
script,
|
|
78
|
+
"--owner",
|
|
79
|
+
owner,
|
|
80
|
+
"--repo",
|
|
81
|
+
repo,
|
|
82
|
+
"--pr-number",
|
|
83
|
+
str(pr_number),
|
|
84
|
+
],
|
|
85
|
+
capture_output=True,
|
|
86
|
+
text=True,
|
|
87
|
+
cwd=cwd or None,
|
|
88
|
+
check=False,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
71
92
|
def main() -> None:
|
|
72
93
|
check_convergence_script = str(
|
|
73
94
|
Path.home() / ".claude/skills/pr-converge/scripts/check_convergence.py"
|
|
@@ -90,7 +111,7 @@ def main() -> None:
|
|
|
90
111
|
if not gh_pr_ready_pattern.search(command):
|
|
91
112
|
sys.exit(0)
|
|
92
113
|
|
|
93
|
-
cwd = hook_input.get("
|
|
114
|
+
cwd = hook_input.get("cwd")
|
|
94
115
|
pr_number = _resolve_pr_number(command, cwd)
|
|
95
116
|
if pr_number is None:
|
|
96
117
|
sys.exit(0)
|
|
@@ -100,20 +121,8 @@ def main() -> None:
|
|
|
100
121
|
sys.exit(0)
|
|
101
122
|
owner, repo = owner_repo
|
|
102
123
|
|
|
103
|
-
completed_process =
|
|
104
|
-
|
|
105
|
-
sys.executable,
|
|
106
|
-
check_convergence_script,
|
|
107
|
-
"--owner",
|
|
108
|
-
owner,
|
|
109
|
-
"--repo",
|
|
110
|
-
repo,
|
|
111
|
-
"--pr-number",
|
|
112
|
-
str(pr_number),
|
|
113
|
-
],
|
|
114
|
-
capture_output=True,
|
|
115
|
-
text=True,
|
|
116
|
-
check=False,
|
|
124
|
+
completed_process = _run_convergence_check(
|
|
125
|
+
check_convergence_script, owner, repo, pr_number, cwd
|
|
117
126
|
)
|
|
118
127
|
|
|
119
128
|
if completed_process.returncode in (0, 2):
|
|
@@ -152,7 +152,7 @@ def test_does_not_flag_constant_imported_one_directory_up(neutral_root: Path) ->
|
|
|
152
152
|
assert issues == [], f"No constant is dead when all are imported, got: {issues}"
|
|
153
153
|
|
|
154
154
|
|
|
155
|
-
def
|
|
155
|
+
def test_does_not_flag_dunder_all_member_with_a_live_consumer(neutral_root: Path) -> None:
|
|
156
156
|
constants_body = CONSTANTS_BODY + '__all__ = ["MEDIUM_TERMINAL"]\n'
|
|
157
157
|
consumer_body = (
|
|
158
158
|
"from report_constants.render_report_constants import MEDIUM_TERMINAL\n"
|
|
@@ -164,7 +164,35 @@ def test_does_not_flag_when_module_declares_dunder_all(neutral_root: Path) -> No
|
|
|
164
164
|
neutral_root / "workflow", constants_body, consumer_body
|
|
165
165
|
)
|
|
166
166
|
issues = _check(constants_body, str(constants_path))
|
|
167
|
-
assert issues == [], f"__all__
|
|
167
|
+
assert issues == [], f"An __all__ member a consumer imports stays live, got: {issues}"
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def test_dunder_all_narrows_check_to_exported_constants(neutral_root: Path) -> None:
|
|
171
|
+
constants_body = (
|
|
172
|
+
'JSONL_APPEND_OPEN_MODE = "a"\n'
|
|
173
|
+
'ZIPFILE_READ_OPEN_MODE = "r"\n'
|
|
174
|
+
"PRIVATE_BUFFER_BYTES = 1024\n"
|
|
175
|
+
'__all__ = ["JSONL_APPEND_OPEN_MODE", "ZIPFILE_READ_OPEN_MODE"]\n'
|
|
176
|
+
)
|
|
177
|
+
consumer_body = (
|
|
178
|
+
"from report_constants.render_report_constants import JSONL_APPEND_OPEN_MODE\n"
|
|
179
|
+
"\n"
|
|
180
|
+
"def open_mode() -> str:\n"
|
|
181
|
+
" return JSONL_APPEND_OPEN_MODE\n"
|
|
182
|
+
)
|
|
183
|
+
constants_path = _build_constants_package(
|
|
184
|
+
neutral_root / "workflow", constants_body, consumer_body
|
|
185
|
+
)
|
|
186
|
+
issues = _check(constants_body, str(constants_path))
|
|
187
|
+
assert any("ZIPFILE_READ_OPEN_MODE" in each_issue for each_issue in issues), (
|
|
188
|
+
f"An exported constant no module imports is dead, got: {issues}"
|
|
189
|
+
)
|
|
190
|
+
assert not any("JSONL_APPEND_OPEN_MODE" in each_issue for each_issue in issues), (
|
|
191
|
+
f"An exported constant a consumer imports must not be flagged, got: {issues}"
|
|
192
|
+
)
|
|
193
|
+
assert not any("PRIVATE_BUFFER_BYTES" in each_issue for each_issue in issues), (
|
|
194
|
+
f"A constant __all__ omits is the author's private value and is exempt, got: {issues}"
|
|
195
|
+
)
|
|
168
196
|
|
|
169
197
|
|
|
170
198
|
def test_does_not_run_on_ordinary_production_module(neutral_root: Path) -> None:
|
|
@@ -264,6 +292,65 @@ def test_does_not_flag_constant_used_only_in_a_sibling_tree(neutral_root: Path)
|
|
|
264
292
|
)
|
|
265
293
|
|
|
266
294
|
|
|
295
|
+
def _build_name_collision_repository(
|
|
296
|
+
repository_root: Path,
|
|
297
|
+
promoter_timing_body: str,
|
|
298
|
+
promoter_consumer_body: str,
|
|
299
|
+
harness_constants_body: str,
|
|
300
|
+
harness_consumer_body: str,
|
|
301
|
+
) -> Path:
|
|
302
|
+
promoter_config = repository_root / "promoter" / "config"
|
|
303
|
+
promoter_config.mkdir(parents=True)
|
|
304
|
+
timing_path = promoter_config / "timing.py"
|
|
305
|
+
timing_path.write_text(promoter_timing_body, encoding="utf-8")
|
|
306
|
+
(repository_root / "promoter" / "runtime.py").write_text(
|
|
307
|
+
promoter_consumer_body, encoding="utf-8"
|
|
308
|
+
)
|
|
309
|
+
harness_config = repository_root / "harness" / "config"
|
|
310
|
+
harness_config.mkdir(parents=True)
|
|
311
|
+
(harness_config / "lockfile_constants.py").write_text(
|
|
312
|
+
harness_constants_body, encoding="utf-8"
|
|
313
|
+
)
|
|
314
|
+
(repository_root / "harness" / "lockfile.py").write_text(
|
|
315
|
+
harness_consumer_body, encoding="utf-8"
|
|
316
|
+
)
|
|
317
|
+
return timing_path
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def test_same_named_constant_in_an_unrelated_module_does_not_mask_a_dead_one(
|
|
321
|
+
neutral_root: Path,
|
|
322
|
+
) -> None:
|
|
323
|
+
promoter_timing_body = "LOCKFILE_ACQUIRE_BYTE_LENGTH = 1\nSWEEP_INTERVAL_SECONDS = 30\n"
|
|
324
|
+
promoter_consumer_body = (
|
|
325
|
+
"from config.timing import SWEEP_INTERVAL_SECONDS\n"
|
|
326
|
+
"\n"
|
|
327
|
+
"def deadline() -> int:\n"
|
|
328
|
+
" return SWEEP_INTERVAL_SECONDS\n"
|
|
329
|
+
)
|
|
330
|
+
harness_constants_body = "LOCKFILE_ACQUIRE_BYTE_LENGTH = 1\n"
|
|
331
|
+
harness_consumer_body = (
|
|
332
|
+
"from harness.config.lockfile_constants import LOCKFILE_ACQUIRE_BYTE_LENGTH\n"
|
|
333
|
+
"\n"
|
|
334
|
+
"def acquire() -> int:\n"
|
|
335
|
+
" return LOCKFILE_ACQUIRE_BYTE_LENGTH\n"
|
|
336
|
+
)
|
|
337
|
+
timing_path = _build_name_collision_repository(
|
|
338
|
+
neutral_root,
|
|
339
|
+
promoter_timing_body,
|
|
340
|
+
promoter_consumer_body,
|
|
341
|
+
harness_constants_body,
|
|
342
|
+
harness_consumer_body,
|
|
343
|
+
)
|
|
344
|
+
issues = _check(promoter_timing_body, str(timing_path))
|
|
345
|
+
assert any("LOCKFILE_ACQUIRE_BYTE_LENGTH" in each_issue for each_issue in issues), (
|
|
346
|
+
"A constant whose only same-named twin lives in an unrelated module's "
|
|
347
|
+
f"import must still be flagged dead, got: {issues}"
|
|
348
|
+
)
|
|
349
|
+
assert not any("SWEEP_INTERVAL_SECONDS" in each_issue for each_issue in issues), (
|
|
350
|
+
f"A constant consumed within its own package must not be flagged, got: {issues}"
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
|
|
267
354
|
def test_returns_empty_list_at_file_cap(
|
|
268
355
|
neutral_root: Path, monkeypatch: pytest.MonkeyPatch
|
|
269
356
|
) -> None:
|