claude-dev-env 1.82.0 → 1.83.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/CLAUDE.md +16 -13
- package/_shared/pr-loop/scripts/CLAUDE.md +1 -0
- package/_shared/pr-loop/scripts/README.md +1 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviewer_availability_constants.py +12 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/terminology_sweep_constants.py +0 -2
- package/_shared/pr-loop/scripts/reviewer_availability.py +182 -0
- package/_shared/pr-loop/scripts/terminology_sweep.py +9 -33
- package/_shared/pr-loop/scripts/tests/CLAUDE.md +2 -0
- package/_shared/pr-loop/scripts/tests/test_reviewer_availability.py +159 -0
- package/_shared/pr-loop/scripts/tests/test_reviewer_availability_constants.py +36 -0
- package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +14 -4
- package/hooks/blocking/CLAUDE.md +2 -0
- package/hooks/blocking/code_rules_constants_config.py +159 -1
- package/hooks/blocking/code_rules_docstrings.py +312 -9
- package/hooks/blocking/code_rules_enforcer.py +29 -0
- package/hooks/blocking/code_rules_imports_logging.py +867 -1
- package/hooks/blocking/code_rules_naming_collection.py +141 -0
- package/hooks/blocking/code_rules_string_magic.py +68 -0
- package/hooks/blocking/pre_tool_use_dispatcher.py +3 -3
- package/hooks/blocking/reviewer_spawn_gate.py +182 -0
- package/hooks/blocking/stale_comment_reference_blocker.py +267 -0
- package/hooks/blocking/state_description_blocker.py +96 -5
- package/hooks/blocking/test_code_rules_config_duplicate_path_anchor.py +132 -0
- package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +2 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_delegation_summary.py +385 -0
- package/hooks/blocking/test_code_rules_enforcer_join_separator_magic.py +67 -0
- package/hooks/blocking/test_code_rules_enforcer_module_docstring_roster.py +40 -0
- package/hooks/blocking/test_code_rules_enforcer_naive_datetime.py +213 -0
- package/hooks/blocking/test_code_rules_enforcer_referenced_underscore_loop.py +169 -0
- package/hooks/blocking/test_code_rules_js_bare_flag_return_directive.py +266 -0
- package/hooks/blocking/test_code_rules_js_sibling_return_object_key_drift.py +490 -0
- package/hooks/blocking/test_code_rules_logging_adjacent_literals.py +171 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +9 -3
- package/hooks/blocking/test_reviewer_spawn_gate.py +230 -0
- package/hooks/blocking/test_stale_comment_reference_blocker.py +236 -0
- package/hooks/blocking/test_state_description_blocker.py +135 -0
- package/hooks/hooks.json +5 -0
- package/hooks/hooks_constants/CLAUDE.md +3 -1
- package/hooks/hooks_constants/blocking_check_limits.py +43 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +41 -0
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
- package/hooks/hooks_constants/reviewer_spawn_gate_constants.py +41 -0
- package/hooks/hooks_constants/stale_comment_reference_blocker_constants.py +76 -0
- package/hooks/hooks_constants/state_description_blocker_constants.py +8 -0
- package/package.json +1 -1
- package/rules/CLAUDE.md +4 -1
- package/rules/claude-md-orphan-file.md +5 -0
- package/rules/docstring-prose-matches-implementation.md +10 -1
- package/rules/env-var-table-code-drift.md +5 -0
- package/rules/es-exe-file-search.md +17 -0
- package/rules/no-historical-clutter.md +12 -1
- package/rules/orphan-css-class.md +5 -0
- package/rules/package-inventory-stale-entry.md +10 -0
- package/rules/paired-test-coverage.md +5 -0
- package/rules/plain-illustrative-docstrings.md +5 -0
- package/rules/verify-before-asking.md +7 -0
- package/rules/verify-runtime-state.md +40 -0
- package/rules/windows-filesystem-safe.md +8 -0
- package/rules/workers-done-before-complete.md +33 -0
- package/rules/workflow-substitution-slots.md +5 -0
- package/skills/CLAUDE.md +1 -1
- package/skills/autoconverge/SKILL.md +10 -4
- package/skills/autoconverge/workflow/converge.contract.test.mjs +69 -0
- package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +54 -18
- package/skills/autoconverge/workflow/converge.mjs +97 -33
- package/skills/everything-search/SKILL.md +5 -0
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
"""Constants-outside-config checks and the file-global constant use-count check.
|
|
1
|
+
"""Constants-outside-config checks and the file-global constant use-count check.
|
|
2
|
+
|
|
3
|
+
Also carries check_config_duplicate_path_anchor, which flags a config module
|
|
4
|
+
that rebuilds a directory a sibling module in the same package already
|
|
5
|
+
anchors from its own location.
|
|
6
|
+
"""
|
|
2
7
|
|
|
3
8
|
import ast
|
|
4
9
|
import re
|
|
@@ -24,6 +29,9 @@ from code_rules_shared import ( # noqa: E402
|
|
|
24
29
|
is_workflow_registry_file,
|
|
25
30
|
)
|
|
26
31
|
|
|
32
|
+
from hooks_constants.blocking_check_limits import ( # noqa: E402
|
|
33
|
+
MAX_CONFIG_DUPLICATE_PATH_ANCHOR_ISSUES,
|
|
34
|
+
)
|
|
27
35
|
from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
|
|
28
36
|
ALL_PYTHON_EXTENSIONS,
|
|
29
37
|
FILE_GLOBAL_UPPER_SNAKE_PATTERN,
|
|
@@ -253,3 +261,153 @@ def check_file_global_constants_use_count(content: str, file_path: str) -> list[
|
|
|
253
261
|
)
|
|
254
262
|
|
|
255
263
|
return issues
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _references_dunder_file(node: ast.AST) -> bool:
|
|
267
|
+
"""Return True when the expression tree reads ``__file__``."""
|
|
268
|
+
return any(
|
|
269
|
+
isinstance(each_node, ast.Name) and each_node.id == "__file__"
|
|
270
|
+
for each_node in ast.walk(node)
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _file_anchor_up_count(node: ast.AST) -> Optional[int]:
|
|
275
|
+
"""Return how many directory levels a ``__file__`` anchor climbs, else None.
|
|
276
|
+
|
|
277
|
+
A ``parents[N]`` subscript on a ``__file__``-rooted expression climbs
|
|
278
|
+
``N + 1`` levels; a chain of ``.parent`` attributes climbs one level per
|
|
279
|
+
link. Any other expression returns None.
|
|
280
|
+
"""
|
|
281
|
+
if isinstance(node, ast.Subscript):
|
|
282
|
+
subscripted = node.value
|
|
283
|
+
if (
|
|
284
|
+
isinstance(subscripted, ast.Attribute)
|
|
285
|
+
and subscripted.attr == "parents"
|
|
286
|
+
and isinstance(node.slice, ast.Constant)
|
|
287
|
+
and isinstance(node.slice.value, int)
|
|
288
|
+
and _references_dunder_file(subscripted.value)
|
|
289
|
+
):
|
|
290
|
+
return node.slice.value + 1
|
|
291
|
+
parent_step_count = 0
|
|
292
|
+
current_node = node
|
|
293
|
+
while isinstance(current_node, ast.Attribute) and current_node.attr == "parent":
|
|
294
|
+
parent_step_count += 1
|
|
295
|
+
current_node = current_node.value
|
|
296
|
+
if parent_step_count and _references_dunder_file(current_node):
|
|
297
|
+
return parent_step_count
|
|
298
|
+
return None
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _anchored_join_signature(node: ast.AST) -> Optional[tuple[int, str]]:
|
|
302
|
+
"""Return (up_count, first literal segment) for an anchored ``/`` join, else None.
|
|
303
|
+
|
|
304
|
+
Flattens a left-leaning ``/`` chain, requires the left-most operand to be
|
|
305
|
+
a ``__file__`` anchor, and requires the first joined segment to be a
|
|
306
|
+
string literal.
|
|
307
|
+
"""
|
|
308
|
+
if not isinstance(node, ast.BinOp) or not isinstance(node.op, ast.Div):
|
|
309
|
+
return None
|
|
310
|
+
all_segments: list[str] = []
|
|
311
|
+
current_node: ast.expr = node
|
|
312
|
+
while isinstance(current_node, ast.BinOp) and isinstance(current_node.op, ast.Div):
|
|
313
|
+
right_operand = current_node.right
|
|
314
|
+
if isinstance(right_operand, ast.Constant) and isinstance(right_operand.value, str):
|
|
315
|
+
all_segments.insert(0, right_operand.value)
|
|
316
|
+
else:
|
|
317
|
+
all_segments.insert(0, "")
|
|
318
|
+
current_node = current_node.left
|
|
319
|
+
anchor_up_count = _file_anchor_up_count(current_node)
|
|
320
|
+
if anchor_up_count is None or not all_segments or not all_segments[0]:
|
|
321
|
+
return None
|
|
322
|
+
return anchor_up_count, all_segments[0]
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _module_anchor_signatures(module_tree: ast.Module) -> dict[tuple[int, str], int]:
|
|
326
|
+
"""Return line numbers keyed by anchored-join signature for top-level assignments."""
|
|
327
|
+
signatures_by_key: dict[tuple[int, str], int] = {}
|
|
328
|
+
for each_statement in module_tree.body:
|
|
329
|
+
if isinstance(each_statement, ast.Assign):
|
|
330
|
+
assigned_expression = each_statement.value
|
|
331
|
+
elif isinstance(each_statement, ast.AnnAssign) and each_statement.value is not None:
|
|
332
|
+
assigned_expression = each_statement.value
|
|
333
|
+
else:
|
|
334
|
+
continue
|
|
335
|
+
for each_node in ast.walk(assigned_expression):
|
|
336
|
+
signature = _anchored_join_signature(each_node)
|
|
337
|
+
if signature is not None:
|
|
338
|
+
signatures_by_key.setdefault(signature, each_statement.lineno)
|
|
339
|
+
return signatures_by_key
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _config_anchor_check_applies(file_path: str) -> bool:
|
|
343
|
+
"""Return whether the duplicate-path-anchor check should inspect this file."""
|
|
344
|
+
return (
|
|
345
|
+
is_config_file(file_path)
|
|
346
|
+
and not is_test_file(file_path)
|
|
347
|
+
and get_file_extension(file_path) in ALL_PYTHON_EXTENSIONS
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _sibling_anchor_owner_names(config_directory: Path, written_name: str) -> dict[tuple[int, str], str]:
|
|
352
|
+
"""Return the sibling config module name that first builds each anchor signature."""
|
|
353
|
+
owner_name_by_signature: dict[tuple[int, str], str] = {}
|
|
354
|
+
for each_sibling_path in sorted(config_directory.glob("*.py")):
|
|
355
|
+
if each_sibling_path.name == written_name or is_test_file(str(each_sibling_path)):
|
|
356
|
+
continue
|
|
357
|
+
try:
|
|
358
|
+
sibling_tree = ast.parse(each_sibling_path.read_text(encoding="utf-8"))
|
|
359
|
+
except (OSError, SyntaxError, UnicodeDecodeError):
|
|
360
|
+
continue
|
|
361
|
+
for each_signature in _module_anchor_signatures(sibling_tree):
|
|
362
|
+
owner_name_by_signature.setdefault(each_signature, each_sibling_path.name)
|
|
363
|
+
return owner_name_by_signature
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def check_config_duplicate_path_anchor(content: str, file_path: str) -> list[str]:
|
|
367
|
+
"""Flag a config module re-anchoring a path a sibling config module already builds.
|
|
368
|
+
|
|
369
|
+
Two config modules in the same ``config/`` directory that each anchor
|
|
370
|
+
``Path(__file__)`` the same number of levels up and join the same first
|
|
371
|
+
literal segment are two sources of truth for one directory: a rename in
|
|
372
|
+
one module silently leaves the other pointing at the old folder. The
|
|
373
|
+
check fires on a config-module write whose module-level assignment joins
|
|
374
|
+
a ``__file__`` anchor (``parents[N]`` or a ``.parent`` chain) with a
|
|
375
|
+
literal segment that a sibling module in the same directory also joins at
|
|
376
|
+
the same depth. Compose one shared base constant and build the other path
|
|
377
|
+
from it. Test files, non-Python files, and modules outside a config
|
|
378
|
+
directory are exempt; an unreadable or unparseable sibling is skipped.
|
|
379
|
+
|
|
380
|
+
Args:
|
|
381
|
+
content: The Python source under validation.
|
|
382
|
+
file_path: The destination path, used to locate sibling config modules.
|
|
383
|
+
|
|
384
|
+
Returns:
|
|
385
|
+
One issue line per duplicated anchor, capped at the configured maximum.
|
|
386
|
+
"""
|
|
387
|
+
if not _config_anchor_check_applies(file_path):
|
|
388
|
+
return []
|
|
389
|
+
try:
|
|
390
|
+
module_tree = ast.parse(content)
|
|
391
|
+
except SyntaxError:
|
|
392
|
+
return []
|
|
393
|
+
written_signatures = _module_anchor_signatures(module_tree)
|
|
394
|
+
if not written_signatures:
|
|
395
|
+
return []
|
|
396
|
+
config_directory = Path(file_path).parent
|
|
397
|
+
if not config_directory.is_dir():
|
|
398
|
+
return []
|
|
399
|
+
owner_name_by_signature = _sibling_anchor_owner_names(config_directory, Path(file_path).name)
|
|
400
|
+
issues: list[str] = []
|
|
401
|
+
for each_signature, each_line_number in sorted(written_signatures.items(), key=lambda pair: pair[1]):
|
|
402
|
+
owner_name = owner_name_by_signature.get(each_signature)
|
|
403
|
+
if owner_name is None:
|
|
404
|
+
continue
|
|
405
|
+
anchor_up_count, first_segment = each_signature
|
|
406
|
+
issues.append(
|
|
407
|
+
f"Line {each_line_number}: joins {first_segment} onto the same base, "
|
|
408
|
+
f"{anchor_up_count} levels above this file, that {owner_name} "
|
|
409
|
+
"already builds - define the base once and compose both paths from it"
|
|
410
|
+
)
|
|
411
|
+
if len(issues) >= MAX_CONFIG_DUPLICATE_PATH_ANCHOR_ISSUES:
|
|
412
|
+
break
|
|
413
|
+
return issues
|
|
@@ -25,6 +25,7 @@ from hooks_constants.blocking_check_limits import ( # noqa: E402
|
|
|
25
25
|
ALL_ABSENT_TYPE_CHECKING_GATE_DOCSTRING_PHRASES,
|
|
26
26
|
ALL_DATA_SCHEMA_CONSTANT_NAME_MARKERS,
|
|
27
27
|
ALL_DATA_SCHEMA_DOCSTRING_ACKNOWLEDGEMENT_PHRASES,
|
|
28
|
+
ALL_DELEGATION_ENUMERATION_STOP_TOKENS,
|
|
28
29
|
ALL_DOCSTRING_EXCLUSIVE_SCOPE_PHRASES,
|
|
29
30
|
ALL_DOCSTRING_EXEMPT_DECORATOR_NAMES,
|
|
30
31
|
ALL_DOCSTRING_FILE_REFERENCE_SUFFIXES,
|
|
@@ -63,6 +64,7 @@ from hooks_constants.blocking_check_limits import ( # noqa: E402
|
|
|
63
64
|
MAX_DOCSTRING_ARGS_SIGNATURE_ISSUES,
|
|
64
65
|
MAX_DOCSTRING_ARGS_SPAN_SCOPE_ISSUES,
|
|
65
66
|
MAX_DOCSTRING_CARDINAL_FAMILY_ISSUES,
|
|
67
|
+
MAX_DOCSTRING_DELEGATION_ENUMERATION_ISSUES,
|
|
66
68
|
MAX_DOCSTRING_FALLBACK_BRANCH_ISSUES,
|
|
67
69
|
MAX_DOCSTRING_FIELD_RUNMODE_OUTCOME_ISSUES,
|
|
68
70
|
MAX_DOCSTRING_FORMAT_ISSUES,
|
|
@@ -82,16 +84,21 @@ from hooks_constants.blocking_check_limits import ( # noqa: E402
|
|
|
82
84
|
MAX_MODULE_DOCSTRING_CHECK_ROSTER_ISSUES,
|
|
83
85
|
MAX_MODULE_DOCSTRING_DATA_SCHEMA_SCOPE_ISSUES,
|
|
84
86
|
MINIMUM_CONSTANT_FAMILY_MEMBERS_FOR_CARDINAL_CHECK,
|
|
87
|
+
MINIMUM_DELEGATION_ENUMERATION_ITEMS,
|
|
85
88
|
MINIMUM_DOCSTRING_FAMILY_OVERLAP_FOR_CARDINAL_CHECK,
|
|
86
89
|
MINIMUM_NAMED_LINEAR_STEPS_FOR_DISPATCH_CHECK,
|
|
87
90
|
MINIMUM_NAMED_MARKS_FOR_PROSE_ENUMERATION,
|
|
88
91
|
MINIMUM_PUBLIC_CHECKS_FOR_MODULE_DOCSTRING_ROSTER,
|
|
89
92
|
MINIMUM_PUBLIC_METHODS_FOR_CLASS_DOCSTRING_BREADTH,
|
|
93
|
+
MINIMUM_SIBLING_OCCURRENCES_FOR_SHARED_TOKEN,
|
|
90
94
|
MINIMUM_TOKENS_FOR_DISPATCH_CALLEE,
|
|
91
95
|
MINIMUM_TUPLE_MEMBERS_FOR_DOCSTRING_ENUMERATION,
|
|
92
96
|
MODULE_DOCSTRING_DATA_SCHEMA_CONSTANT_SAMPLE_LIMIT,
|
|
97
|
+
NEIGHBOR_SCAN_FILE_LIMIT,
|
|
98
|
+
POINTER_TO_DELEGATE_PATTERN,
|
|
93
99
|
PYTHON_MODULE_FILE_SUFFIX,
|
|
94
100
|
SINGLE_DICT_KEY_COUNT_FOR_PLURAL_CARDINALITY_DRIFT,
|
|
101
|
+
TOKEN_WORD_PATTERN,
|
|
95
102
|
TYPE_CHECKING_IDENTIFIER_MARKER,
|
|
96
103
|
WORD_BOUNDARY_REGEX,
|
|
97
104
|
ZIPFILE_ALLOW_ZIP64_KEYWORD,
|
|
@@ -1128,14 +1135,48 @@ def _distinctive_name_tokens(check_name: str) -> list[str]:
|
|
|
1128
1135
|
]
|
|
1129
1136
|
|
|
1130
1137
|
|
|
1131
|
-
def
|
|
1138
|
+
def _shared_sibling_name_tokens(all_check_names: list[str]) -> frozenset[str]:
|
|
1139
|
+
token_occurrence_count: dict[str, int] = {}
|
|
1140
|
+
for each_check_name in all_check_names:
|
|
1141
|
+
for each_token in set(each_token.lower() for each_token in _name_tokens(each_check_name)):
|
|
1142
|
+
token_occurrence_count[each_token] = token_occurrence_count.get(each_token, 0) + 1
|
|
1143
|
+
return frozenset(
|
|
1144
|
+
each_token
|
|
1145
|
+
for each_token, each_count in token_occurrence_count.items()
|
|
1146
|
+
if each_count >= MINIMUM_SIBLING_OCCURRENCES_FOR_SHARED_TOKEN
|
|
1147
|
+
)
|
|
1148
|
+
|
|
1149
|
+
|
|
1150
|
+
def _module_distinctive_name_tokens(
|
|
1151
|
+
check_name: str, all_shared_sibling_tokens: frozenset[str]
|
|
1152
|
+
) -> list[str]:
|
|
1153
|
+
return [
|
|
1154
|
+
each_token
|
|
1155
|
+
for each_token in _distinctive_name_tokens(check_name)
|
|
1156
|
+
if each_token.lower() not in all_shared_sibling_tokens
|
|
1157
|
+
]
|
|
1158
|
+
|
|
1159
|
+
|
|
1160
|
+
def _token_present_in_text(token: str, lowered_text: str) -> bool:
|
|
1161
|
+
lowered_token = token.lower()
|
|
1162
|
+
if lowered_token in lowered_text:
|
|
1163
|
+
return True
|
|
1164
|
+
return lowered_token.endswith("s") and lowered_token[:-1] in lowered_text
|
|
1165
|
+
|
|
1166
|
+
|
|
1167
|
+
def _docstring_mentions_check(
|
|
1168
|
+
docstring_text: str, check_name: str, all_shared_sibling_tokens: frozenset[str]
|
|
1169
|
+
) -> bool:
|
|
1132
1170
|
lowered_docstring = docstring_text.lower()
|
|
1133
1171
|
if check_name.lower() in lowered_docstring:
|
|
1134
1172
|
return True
|
|
1135
|
-
|
|
1136
|
-
if not
|
|
1173
|
+
distinguishing_tokens = _module_distinctive_name_tokens(check_name, all_shared_sibling_tokens)
|
|
1174
|
+
if not distinguishing_tokens:
|
|
1137
1175
|
return True
|
|
1138
|
-
return any(
|
|
1176
|
+
return any(
|
|
1177
|
+
_token_present_in_text(each_token, lowered_docstring)
|
|
1178
|
+
for each_token in distinguishing_tokens
|
|
1179
|
+
)
|
|
1139
1180
|
|
|
1140
1181
|
|
|
1141
1182
|
def check_module_docstring_names_public_checks(content: str, file_path: str) -> list[str]:
|
|
@@ -1146,10 +1187,17 @@ def check_module_docstring_names_public_checks(content: str, file_path: str) ->
|
|
|
1146
1187
|
roster. When the module grows a public ``check_*`` entry point the summary
|
|
1147
1188
|
never names, the enumeration under-describes the module — the
|
|
1148
1189
|
docstring-prose-vs-implementation drift the repo flags as Category O6/O8.
|
|
1149
|
-
A check counts as named when the full ``check_*`` name, or
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1190
|
+
A check counts as named when the full ``check_*`` name, or a
|
|
1191
|
+
module-distinctive underscore-separated token of it, appears in the summary
|
|
1192
|
+
(matched allowing a trailing-``s`` plural). A token is module-distinctive
|
|
1193
|
+
when it is non-generic (generic tokens ``check``, ``test``, ``tests`` never
|
|
1194
|
+
count) and it is not shared by two or more of the module's check names — a
|
|
1195
|
+
token such as ``string`` or ``magic`` that recurs across sibling checks is
|
|
1196
|
+
no evidence that any one check is named, so a check whose only
|
|
1197
|
+
summary-present tokens are shared ones is reported. A check with no
|
|
1198
|
+
module-distinctive token is treated as named, since the summary cannot single
|
|
1199
|
+
it out. A module with two or more public checks and any check the summary
|
|
1200
|
+
never names is reported
|
|
1153
1201
|
so the summary names the full roster. Modules with a multi-paragraph
|
|
1154
1202
|
docstring body are left to the audit lane, since their prose can carry the
|
|
1155
1203
|
roster without naming each check by name. This check covers hook
|
|
@@ -1175,9 +1223,10 @@ def check_module_docstring_names_public_checks(content: str, file_path: str) ->
|
|
|
1175
1223
|
public_check_names = _module_public_check_names(parsed_tree)
|
|
1176
1224
|
if len(public_check_names) < MINIMUM_PUBLIC_CHECKS_FOR_MODULE_DOCSTRING_ROSTER:
|
|
1177
1225
|
return []
|
|
1226
|
+
all_shared_sibling_tokens = _shared_sibling_name_tokens(public_check_names)
|
|
1178
1227
|
issues: list[str] = []
|
|
1179
1228
|
for each_name in public_check_names:
|
|
1180
|
-
if _docstring_mentions_check(module_docstring, each_name):
|
|
1229
|
+
if _docstring_mentions_check(module_docstring, each_name, all_shared_sibling_tokens):
|
|
1181
1230
|
continue
|
|
1182
1231
|
issues.append(
|
|
1183
1232
|
f"Line 1: module docstring omits public check {each_name}() — name every "
|
|
@@ -2874,3 +2923,257 @@ def check_docstring_raises_unraisable_largezipfile(
|
|
|
2874
2923
|
if len(issues) >= MAX_DOCSTRING_RAISES_LARGEZIPFILE_ISSUES:
|
|
2875
2924
|
break
|
|
2876
2925
|
return issues[:MAX_DOCSTRING_RAISES_LARGEZIPFILE_ISSUES]
|
|
2926
|
+
|
|
2927
|
+
|
|
2928
|
+
def _leading_summary_line(raw_docstring: str) -> str:
|
|
2929
|
+
summary_paragraph = raw_docstring.strip().split("\n\n", maxsplit=1)[0]
|
|
2930
|
+
return " ".join(summary_paragraph.split())
|
|
2931
|
+
|
|
2932
|
+
|
|
2933
|
+
def _pointer_target_stem(raw_docstring: str) -> str:
|
|
2934
|
+
marker_match = POINTER_TO_DELEGATE_PATTERN.search(raw_docstring)
|
|
2935
|
+
return marker_match.group(1) if marker_match is not None else ""
|
|
2936
|
+
|
|
2937
|
+
|
|
2938
|
+
def _listed_entries(wrapper_summary: str) -> list[str]:
|
|
2939
|
+
marker_match = POINTER_TO_DELEGATE_PATTERN.search(wrapper_summary)
|
|
2940
|
+
if marker_match is None:
|
|
2941
|
+
return []
|
|
2942
|
+
listed_text = wrapper_summary[: marker_match.start()].strip().rstrip(";,").strip()
|
|
2943
|
+
normalized_text = re.sub(r"\s+and\s+", ", ", listed_text, flags=re.IGNORECASE)
|
|
2944
|
+
return [
|
|
2945
|
+
each_entry.strip()
|
|
2946
|
+
for each_entry in normalized_text.split(",")
|
|
2947
|
+
if each_entry.strip()
|
|
2948
|
+
]
|
|
2949
|
+
|
|
2950
|
+
|
|
2951
|
+
def _words_of_entry(raw_entry: str) -> list[str]:
|
|
2952
|
+
return [
|
|
2953
|
+
each_token.lower()
|
|
2954
|
+
for each_token in TOKEN_WORD_PATTERN.findall(raw_entry)
|
|
2955
|
+
if each_token.lower() not in ALL_DELEGATION_ENUMERATION_STOP_TOKENS
|
|
2956
|
+
]
|
|
2957
|
+
|
|
2958
|
+
|
|
2959
|
+
def _matches_any_word(delegate_summary: str, all_distinct_words: list[str]) -> bool:
|
|
2960
|
+
lowered_summary = delegate_summary.lower()
|
|
2961
|
+
return any(
|
|
2962
|
+
re.search(
|
|
2963
|
+
WORD_BOUNDARY_REGEX + re.escape(each_token) + WORD_BOUNDARY_REGEX,
|
|
2964
|
+
lowered_summary,
|
|
2965
|
+
)
|
|
2966
|
+
is not None
|
|
2967
|
+
for each_token in all_distinct_words
|
|
2968
|
+
)
|
|
2969
|
+
|
|
2970
|
+
|
|
2971
|
+
def _absent_entries(wrapper_summary: str, delegate_summary: str) -> list[str]:
|
|
2972
|
+
all_entries = _listed_entries(wrapper_summary)
|
|
2973
|
+
if len(all_entries) < MINIMUM_DELEGATION_ENUMERATION_ITEMS:
|
|
2974
|
+
return []
|
|
2975
|
+
if "," not in delegate_summary:
|
|
2976
|
+
return []
|
|
2977
|
+
absent_entry_texts: list[str] = []
|
|
2978
|
+
for each_entry in all_entries:
|
|
2979
|
+
distinct_words = _words_of_entry(each_entry)
|
|
2980
|
+
if not distinct_words:
|
|
2981
|
+
continue
|
|
2982
|
+
if _matches_any_word(delegate_summary, distinct_words):
|
|
2983
|
+
continue
|
|
2984
|
+
absent_entry_texts.append(each_entry)
|
|
2985
|
+
return absent_entry_texts
|
|
2986
|
+
|
|
2987
|
+
|
|
2988
|
+
def _top_level_definitions_and_methods(
|
|
2989
|
+
parsed_tree: ast.Module,
|
|
2990
|
+
) -> list[ast.FunctionDef | ast.AsyncFunctionDef]:
|
|
2991
|
+
all_definitions_and_methods: list[ast.FunctionDef | ast.AsyncFunctionDef] = []
|
|
2992
|
+
for each_node in parsed_tree.body:
|
|
2993
|
+
if isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
2994
|
+
all_definitions_and_methods.append(each_node)
|
|
2995
|
+
elif isinstance(each_node, ast.ClassDef):
|
|
2996
|
+
all_definitions_and_methods.extend(
|
|
2997
|
+
each_member
|
|
2998
|
+
for each_member in each_node.body
|
|
2999
|
+
if isinstance(each_member, (ast.FunctionDef, ast.AsyncFunctionDef))
|
|
3000
|
+
)
|
|
3001
|
+
return all_definitions_and_methods
|
|
3002
|
+
|
|
3003
|
+
|
|
3004
|
+
def _entries_by_function_name(parsed_tree: ast.Module) -> dict[str, tuple[int, str]]:
|
|
3005
|
+
entries_by_name: dict[str, tuple[int, str]] = {}
|
|
3006
|
+
ambiguous_names: set[str] = set()
|
|
3007
|
+
for each_definition in _top_level_definitions_and_methods(parsed_tree):
|
|
3008
|
+
if each_definition.name in entries_by_name:
|
|
3009
|
+
ambiguous_names.add(each_definition.name)
|
|
3010
|
+
continue
|
|
3011
|
+
entries_by_name[each_definition.name] = (
|
|
3012
|
+
each_definition.lineno,
|
|
3013
|
+
ast.get_docstring(each_definition) or "",
|
|
3014
|
+
)
|
|
3015
|
+
for each_ambiguous_name in ambiguous_names:
|
|
3016
|
+
del entries_by_name[each_ambiguous_name]
|
|
3017
|
+
return entries_by_name
|
|
3018
|
+
|
|
3019
|
+
|
|
3020
|
+
def _source_text_or_empty(source_path: Path) -> str:
|
|
3021
|
+
try:
|
|
3022
|
+
return source_path.read_text(encoding="utf-8")
|
|
3023
|
+
except (OSError, UnicodeDecodeError):
|
|
3024
|
+
return ""
|
|
3025
|
+
|
|
3026
|
+
|
|
3027
|
+
def _parsed_module_or_none(source_text: str) -> ast.Module | None:
|
|
3028
|
+
if not source_text:
|
|
3029
|
+
return None
|
|
3030
|
+
try:
|
|
3031
|
+
return ast.parse(source_text)
|
|
3032
|
+
except SyntaxError:
|
|
3033
|
+
return None
|
|
3034
|
+
|
|
3035
|
+
|
|
3036
|
+
def _outbound_pointer_issues(parsed_tree: ast.Module, file_path: str) -> list[str]:
|
|
3037
|
+
parent_directory = Path(file_path).parent
|
|
3038
|
+
issues: list[str] = []
|
|
3039
|
+
delegate_entries_by_stem: dict[str, dict[str, tuple[int, str]]] = {}
|
|
3040
|
+
for each_node in _top_level_definitions_and_methods(parsed_tree):
|
|
3041
|
+
wrapper_docstring = ast.get_docstring(each_node) or ""
|
|
3042
|
+
wrapper_summary = _leading_summary_line(wrapper_docstring)
|
|
3043
|
+
target_stem = _pointer_target_stem(wrapper_summary)
|
|
3044
|
+
if not target_stem:
|
|
3045
|
+
continue
|
|
3046
|
+
if target_stem not in delegate_entries_by_stem:
|
|
3047
|
+
delegate_path = parent_directory / (target_stem + PYTHON_MODULE_FILE_SUFFIX)
|
|
3048
|
+
delegate_tree = _parsed_module_or_none(_source_text_or_empty(delegate_path))
|
|
3049
|
+
delegate_entries_by_stem[target_stem] = (
|
|
3050
|
+
_entries_by_function_name(delegate_tree)
|
|
3051
|
+
if delegate_tree is not None
|
|
3052
|
+
else {}
|
|
3053
|
+
)
|
|
3054
|
+
delegate_entry = delegate_entries_by_stem[target_stem].get(each_node.name)
|
|
3055
|
+
if delegate_entry is None:
|
|
3056
|
+
continue
|
|
3057
|
+
delegate_summary = _leading_summary_line(delegate_entry[1])
|
|
3058
|
+
for each_entry in _absent_entries(wrapper_summary, delegate_summary):
|
|
3059
|
+
issues.append(
|
|
3060
|
+
f"Line {each_node.lineno}: {each_node.name}() names "
|
|
3061
|
+
f"'{each_entry}' in its summary, but the summary of the "
|
|
3062
|
+
f"same-named function in {target_stem} omits it - align the "
|
|
3063
|
+
"two summaries (Category O6 docstring-vs-implementation drift)"
|
|
3064
|
+
)
|
|
3065
|
+
return issues
|
|
3066
|
+
|
|
3067
|
+
|
|
3068
|
+
def _scannable_neighbor_paths(written_path: Path) -> list[Path]:
|
|
3069
|
+
try:
|
|
3070
|
+
all_neighbor_paths = sorted(
|
|
3071
|
+
written_path.parent.glob("*" + PYTHON_MODULE_FILE_SUFFIX)
|
|
3072
|
+
)
|
|
3073
|
+
except OSError:
|
|
3074
|
+
return []
|
|
3075
|
+
scannable_paths: list[Path] = []
|
|
3076
|
+
for each_path in all_neighbor_paths:
|
|
3077
|
+
if each_path.name == written_path.name:
|
|
3078
|
+
continue
|
|
3079
|
+
if is_strict_test_file(str(each_path)):
|
|
3080
|
+
continue
|
|
3081
|
+
scannable_paths.append(each_path)
|
|
3082
|
+
if len(scannable_paths) >= NEIGHBOR_SCAN_FILE_LIMIT:
|
|
3083
|
+
break
|
|
3084
|
+
return scannable_paths
|
|
3085
|
+
|
|
3086
|
+
|
|
3087
|
+
def _inbound_pointer_issues(
|
|
3088
|
+
neighbor_tree: ast.Module,
|
|
3089
|
+
neighbor_name: str,
|
|
3090
|
+
delegate_stem: str,
|
|
3091
|
+
all_entries_by_name: dict[str, tuple[int, str]],
|
|
3092
|
+
) -> list[str]:
|
|
3093
|
+
issues: list[str] = []
|
|
3094
|
+
for each_node in _top_level_definitions_and_methods(neighbor_tree):
|
|
3095
|
+
wrapper_docstring = ast.get_docstring(each_node) or ""
|
|
3096
|
+
wrapper_summary = _leading_summary_line(wrapper_docstring)
|
|
3097
|
+
if _pointer_target_stem(wrapper_summary) != delegate_stem:
|
|
3098
|
+
continue
|
|
3099
|
+
delegate_entry = all_entries_by_name.get(each_node.name)
|
|
3100
|
+
if delegate_entry is None:
|
|
3101
|
+
continue
|
|
3102
|
+
delegate_summary = _leading_summary_line(delegate_entry[1])
|
|
3103
|
+
for each_entry in _absent_entries(wrapper_summary, delegate_summary):
|
|
3104
|
+
issues.append(
|
|
3105
|
+
f"Line {delegate_entry[0]}: {each_node.name}() summary omits "
|
|
3106
|
+
f"'{each_entry}', named by the pointing wrapper docstring in "
|
|
3107
|
+
f"{neighbor_name} - reword that wrapper docstring in the same "
|
|
3108
|
+
"change (Category O6 docstring-vs-implementation drift)"
|
|
3109
|
+
)
|
|
3110
|
+
return issues
|
|
3111
|
+
|
|
3112
|
+
|
|
3113
|
+
def _neighbor_scan_issues(parsed_tree: ast.Module, file_path: str) -> list[str]:
|
|
3114
|
+
written_path = Path(file_path)
|
|
3115
|
+
all_entries_by_name = _entries_by_function_name(parsed_tree)
|
|
3116
|
+
if not all_entries_by_name:
|
|
3117
|
+
return []
|
|
3118
|
+
issues: list[str] = []
|
|
3119
|
+
for each_neighbor in _scannable_neighbor_paths(written_path):
|
|
3120
|
+
neighbor_source = _source_text_or_empty(each_neighbor)
|
|
3121
|
+
if POINTER_TO_DELEGATE_PATTERN.search(neighbor_source) is None:
|
|
3122
|
+
continue
|
|
3123
|
+
neighbor_tree = _parsed_module_or_none(neighbor_source)
|
|
3124
|
+
if neighbor_tree is None:
|
|
3125
|
+
continue
|
|
3126
|
+
issues.extend(
|
|
3127
|
+
_inbound_pointer_issues(
|
|
3128
|
+
neighbor_tree,
|
|
3129
|
+
each_neighbor.name,
|
|
3130
|
+
written_path.stem,
|
|
3131
|
+
all_entries_by_name,
|
|
3132
|
+
)
|
|
3133
|
+
)
|
|
3134
|
+
return issues
|
|
3135
|
+
|
|
3136
|
+
|
|
3137
|
+
def check_docstring_delegation_summary_enumeration_drift(
|
|
3138
|
+
content: str, file_path: str
|
|
3139
|
+
) -> list[str]:
|
|
3140
|
+
"""Flag a delegating wrapper summary that its delegation target contradicts.
|
|
3141
|
+
|
|
3142
|
+
The drift this catches: a thin wrapper method whose docstring summary
|
|
3143
|
+
enumerates the actions it stands for and points at the home of the real
|
|
3144
|
+
body ("Apply App Info, Russia, review note, publication edits; full doc
|
|
3145
|
+
on `listing_edit_flow`"), while the same-named function in that named
|
|
3146
|
+
sibling file carries a summary enumeration that omits one of those
|
|
3147
|
+
actions. An edit that moves one action out of the delegated body and
|
|
3148
|
+
rewords only the delegated docstring leaves the wrapper claiming work the
|
|
3149
|
+
body skips. The gate fires from both sides. On a wrapper save, it resolves
|
|
3150
|
+
the named sibling file beside the saved one, finds the same-named
|
|
3151
|
+
function, and compares the two summary enumerations. On a delegated-body
|
|
3152
|
+
save, it scans neighboring .py files for a wrapper docstring pointing at
|
|
3153
|
+
the saved stem and runs the same comparison, so the save that removes the
|
|
3154
|
+
action hears about the stranded wrapper docstring. A listed action counts
|
|
3155
|
+
as named when any of its distinctive words appears at a word boundary in
|
|
3156
|
+
the delegated summary. The comparison binds when the wrapper enumerates
|
|
3157
|
+
two or more actions and the delegated summary itself holds a comma, so a
|
|
3158
|
+
plain one-purpose delegated summary never gets compared. This covers the
|
|
3159
|
+
deterministic delegating-wrapper slice of Category O6
|
|
3160
|
+
docstring-prose-vs-implementation drift.
|
|
3161
|
+
|
|
3162
|
+
Args:
|
|
3163
|
+
content: The source text to inspect.
|
|
3164
|
+
file_path: The path the source will be written to, used for exemptions
|
|
3165
|
+
and to resolve the delegated or wrapping file beside it.
|
|
3166
|
+
|
|
3167
|
+
Returns:
|
|
3168
|
+
One issue per listed wrapper action the paired delegated summary
|
|
3169
|
+
omits, capped at the module limit.
|
|
3170
|
+
"""
|
|
3171
|
+
if is_strict_test_file(file_path) or is_hook_infrastructure(file_path):
|
|
3172
|
+
return []
|
|
3173
|
+
try:
|
|
3174
|
+
parsed_tree = ast.parse(content)
|
|
3175
|
+
except SyntaxError:
|
|
3176
|
+
return []
|
|
3177
|
+
issues = _outbound_pointer_issues(parsed_tree, file_path)
|
|
3178
|
+
issues.extend(_neighbor_scan_issues(parsed_tree, file_path))
|
|
3179
|
+
return issues[:MAX_DOCSTRING_DELEGATION_ENUMERATION_ISSUES]
|
|
@@ -51,6 +51,7 @@ from code_rules_comments import ( # noqa: E402
|
|
|
51
51
|
check_comment_changes,
|
|
52
52
|
)
|
|
53
53
|
from code_rules_constants_config import ( # noqa: E402
|
|
54
|
+
check_config_duplicate_path_anchor,
|
|
54
55
|
check_constants_outside_config,
|
|
55
56
|
check_constants_outside_config_advisory,
|
|
56
57
|
check_file_global_constants_use_count,
|
|
@@ -75,6 +76,7 @@ from code_rules_docstrings import ( # noqa: E402
|
|
|
75
76
|
check_docstring_args_match_signature,
|
|
76
77
|
check_docstring_args_single_line_scope_vs_span,
|
|
77
78
|
check_docstring_cardinal_count_matches_constant_family,
|
|
79
|
+
check_docstring_delegation_summary_enumeration_drift,
|
|
78
80
|
check_docstring_documents_unreferenced_parameter,
|
|
79
81
|
check_docstring_fallback_branch_coverage,
|
|
80
82
|
check_docstring_field_runmode_outcome,
|
|
@@ -105,11 +107,15 @@ from code_rules_imports_logging import ( # noqa: E402
|
|
|
105
107
|
check_e2e_test_naming,
|
|
106
108
|
check_import_block_sorted,
|
|
107
109
|
check_imports_at_top,
|
|
110
|
+
check_js_bare_flag_return_directive,
|
|
108
111
|
check_js_resume_task_enumeration_coverage,
|
|
109
112
|
check_js_returns_object_schemaless_branch,
|
|
113
|
+
check_js_sibling_return_object_key_drift,
|
|
110
114
|
check_library_print,
|
|
115
|
+
check_logging_adjacent_string_literals,
|
|
111
116
|
check_logging_fstrings,
|
|
112
117
|
check_logging_printf_tokens,
|
|
118
|
+
check_naive_datetime_construction,
|
|
113
119
|
check_windows_api_none,
|
|
114
120
|
)
|
|
115
121
|
from code_rules_js_conventions import ( # noqa: E402
|
|
@@ -127,6 +133,7 @@ from code_rules_naming_collection import ( # noqa: E402
|
|
|
127
133
|
check_collection_prefix,
|
|
128
134
|
check_loop_variable_naming,
|
|
129
135
|
check_polarity_name_contradiction,
|
|
136
|
+
check_referenced_underscore_loop_variable,
|
|
130
137
|
check_stuttering_collection_prefix,
|
|
131
138
|
)
|
|
132
139
|
from code_rules_optional_params import ( # noqa: E402
|
|
@@ -154,6 +161,7 @@ from code_rules_shared import ( # noqa: E402
|
|
|
154
161
|
from code_rules_string_magic import ( # noqa: E402
|
|
155
162
|
check_inline_literal_collections,
|
|
156
163
|
check_inline_tuple_string_magic,
|
|
164
|
+
check_join_separator_string_magic,
|
|
157
165
|
check_string_literal_magic,
|
|
158
166
|
check_whitespace_indentation_magic,
|
|
159
167
|
)
|
|
@@ -268,10 +276,13 @@ def validate_content(
|
|
|
268
276
|
)
|
|
269
277
|
all_issues.extend(check_logging_fstrings(content))
|
|
270
278
|
all_issues.extend(check_logging_printf_tokens(content, file_path))
|
|
279
|
+
all_issues.extend(check_logging_adjacent_string_literals(content, file_path))
|
|
271
280
|
all_issues.extend(check_windows_api_none(content))
|
|
281
|
+
all_issues.extend(check_naive_datetime_construction(content, file_path))
|
|
272
282
|
all_issues.extend(check_magic_values(content, file_path))
|
|
273
283
|
all_issues.extend(check_fstring_structural_literals(content, file_path))
|
|
274
284
|
all_issues.extend(check_constants_outside_config(content, file_path))
|
|
285
|
+
all_issues.extend(check_config_duplicate_path_anchor(content, file_path))
|
|
275
286
|
all_issues.extend(check_constants_outside_config_advisory(content, file_path))
|
|
276
287
|
all_issues.extend(check_file_global_constants_use_count(content, file_path))
|
|
277
288
|
all_issues.extend(
|
|
@@ -389,6 +400,11 @@ def validate_content(
|
|
|
389
400
|
all_issues.extend(
|
|
390
401
|
check_docstring_args_single_line_scope_vs_span(effective_content, file_path)
|
|
391
402
|
)
|
|
403
|
+
all_issues.extend(
|
|
404
|
+
check_docstring_delegation_summary_enumeration_drift(
|
|
405
|
+
effective_content, file_path
|
|
406
|
+
)
|
|
407
|
+
)
|
|
392
408
|
all_issues.extend(
|
|
393
409
|
check_boolean_naming(
|
|
394
410
|
effective_content,
|
|
@@ -477,9 +493,11 @@ def validate_content(
|
|
|
477
493
|
)
|
|
478
494
|
)
|
|
479
495
|
all_issues.extend(check_loop_variable_naming(content, file_path))
|
|
496
|
+
all_issues.extend(check_referenced_underscore_loop_variable(content, file_path))
|
|
480
497
|
all_issues.extend(check_polarity_name_contradiction(content, file_path))
|
|
481
498
|
all_issues.extend(check_inline_literal_collections(content, file_path))
|
|
482
499
|
all_issues.extend(check_inline_tuple_string_magic(content, file_path))
|
|
500
|
+
all_issues.extend(check_join_separator_string_magic(content, file_path))
|
|
483
501
|
all_issues.extend(check_string_literal_magic(content, file_path))
|
|
484
502
|
all_issues.extend(check_whitespace_indentation_magic(content, file_path))
|
|
485
503
|
all_issues.extend(check_orphan_css_classes(effective_content, file_path))
|
|
@@ -513,6 +531,17 @@ def validate_content(
|
|
|
513
531
|
all_issues.extend(
|
|
514
532
|
check_js_returns_object_schemaless_branch(content, file_path)
|
|
515
533
|
)
|
|
534
|
+
all_issues.extend(
|
|
535
|
+
check_js_sibling_return_object_key_drift(content, file_path)
|
|
536
|
+
)
|
|
537
|
+
all_issues.extend(
|
|
538
|
+
check_js_bare_flag_return_directive(
|
|
539
|
+
effective_content,
|
|
540
|
+
file_path,
|
|
541
|
+
all_changed_lines,
|
|
542
|
+
defer_scope_to_caller,
|
|
543
|
+
)
|
|
544
|
+
)
|
|
516
545
|
|
|
517
546
|
if extension in ALL_CODE_EXTENSIONS:
|
|
518
547
|
advise_file_line_count(content, file_path)
|