claude-dev-env 1.76.0 → 1.77.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/code_rules_docstrings.py +378 -0
- package/hooks/blocking/code_rules_enforcer.py +12 -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_runon_sentence.py +267 -0
- package/hooks/hooks_constants/blocking_check_limits.py +27 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +35 -0
- package/package.json +1 -1
- package/rules/CLAUDE.md +1 -0
- package/rules/docstring-prose-matches-implementation.md +4 -1
- package/rules/package-inventory-stale-entry.md +8 -0
- package/rules/plain-illustrative-docstrings.md +56 -0
|
@@ -28,6 +28,7 @@ Decomposition is by the **kind of docstring claim** that needs to be cross-check
|
|
|
28
28
|
| O6 | Free-form `Args:`-adjacent claims | A docstring's `Returns:` / `Raises:` / `Note:` / `Example:` sections make claims (`returns shared-temp only`, `raises ValueError on missing key`). Verify each claim against the body. When a docstring enumerates the inputs a body counts (a "field counts as read when ..." list, a list of conditions treated as a match, a list of cases the body skips), list every union member and every suppressor the body applies (`read_names = a | b | c`, each early-return guard) and confirm each appears in the prose enumeration. A union member or suppressor the body applies but the prose omits is an O6 finding. When a docstring sentence excludes a named category of input from what the function flags (`X are not dispatch steps`, `Y is not a match`), confirm the axis the prose excludes on is the axis the body's branch condition actually keys on. A body that flags a call when it sits inside an `If.test` guard, paired with prose that excludes by the call's receiver shape (`method-on-local calls inside a branch are not dispatch steps`), is an O6 finding: a guarded method-on-local call is flagged even though the prose lists it as excluded — the exclusion is keyed to the wrong axis. The single-condition shared-fallback shape of this drift — a summary that scopes a fallback call to one condition while the body routes to that same call from two or more early-return guards — is gated deterministically at Write/Edit time by `check_docstring_fallback_branch_coverage`, so the audit lane focuses on the O6 shapes the gate cannot match. The exception-guard shape of this drift — a docstring that promises a malformed payload `resolves to None` while a payload subscript (`payload["key"]`, `float(payload["key"])`) sits outside the try/except whose handler returns None, so a present-but-malformed payload raises rather than resolving to None — is gated deterministically at Write/Edit time by `check_docstring_unguarded_malformed_payload_claim`, so the audit lane focuses on the wider Raises/None-on-failure claims the gate cannot match. A `Returns:` that names the mechanism, tool, or output format the function produces (`instructing a StructuredOutput summary`, `returns a YAML document`, `emits a JSON object`) matches the artifact the body actually builds: a prompt body that asks the agent to "Return strictly a JSON object" while the docstring claims it "instruct[s] a StructuredOutput" summary is an O6 finding, because the named tool appears nowhere in the emitted text. See `../../rules/docstring-prose-matches-implementation.md`. |
|
|
29
29
|
| O7 | Module-doc-vs-split-module after refactor | When a refactor moves a responsibility to a sibling module, the originating module's docstring and the receiving module's docstring both describe the home of that responsibility. A module docstring should describe only the responsibilities it owns. |
|
|
30
30
|
| O8 | Companion-doc ordering/content vs producer | When a PR changes a producer function's ordering or union, read that skill's companion `SKILL.md` and sibling `.md` docs for any sentence naming the same produced artifact (a file path, a JSON key, a named list). A doc sentence that claims the artifact is `sorted` / `alphabetical` / `in sorted order`, or holds `just the at-risk names` / `only the current set`, while the producer merges stored names with new names and appends — preserving file order, not re-sorting the union — is an O8 finding on both counts (wrong order claim, hidden merged-in entries). The finding stands even when the PR diff never touched the `.md` file, because the behavior change orphaned the doc claim. See `../../rules/docstring-prose-matches-implementation.md`. |
|
|
31
|
+
| O9 | Python docstring plainness for a general developer | A changed module / class / public-function docstring's narrative prose — the summary and description before the first `Args:` / `Returns:` / `Raises:` / `Yields:` section — reads plainly and paints a concrete scene a general developer follows on first read. Flag a narrative that stacks abstract machinery nouns into a wall (`the SIGINT install/restore/installability check, the atexit terminal-record registration, and the interrupted-run finalizer`), that defines a thing by what it is not (`the non-promoter-specific machinery`), or that runs one sentence long while joining clauses with an em-dash or a semicolon. The deterministic run-on mark is gated at Write/Edit time by `check_docstring_runon_sentence` in `code_rules_docstrings.py`, so this lane carries the illustrative-and-brief judgment the gate cannot: whether a stranger to the code pictures the moment, the input, and the outcome after one read. See `../../rules/plain-illustrative-docstrings.md`. |
|
|
31
32
|
|
|
32
33
|
---
|
|
33
34
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
Audit [REPO/ARTIFACT] [TARGET_ID] for **Category O only** (docstring / fixture-prose vs implementation drift). Skip A–N, P. Sub-bucket forced-exhaustion mode: Category O is decomposed into
|
|
1
|
+
Audit [REPO/ARTIFACT] [TARGET_ID] for **Category O only** (docstring / fixture-prose vs implementation drift). Skip A–N, P. Sub-bucket forced-exhaustion mode: Category O is decomposed into 9 sub-buckets below. Each sub-bucket REQUIRES at least one Shape A finding OR exactly one Shape B proof-of-absence with **at least 3 adversarial probes** specific to that sub-bucket. A sub-bucket returning neither is a protocol gap.
|
|
2
2
|
|
|
3
3
|
[ARTIFACT METADATA — include every changed module's docstring AND the exported symbols of that module so the audit can compare claim vs body]
|
|
4
4
|
|
|
@@ -51,9 +51,13 @@ ID prefix: `find`.
|
|
|
51
51
|
- When the diff changes a producer function's ordering or union, read that skill's companion `SKILL.md` and sibling `.md` docs for any sentence naming the same produced artifact (a file path, a JSON key, a named list). A doc sentence that claims the artifact is `sorted` / `alphabetical` / `in sorted order`, or holds `just the at-risk names` / `only the current set`, while the producer merges stored names with new names and appends — preserving file order, not re-sorting the union — is an O8 finding on both counts (wrong order claim, hidden merged-in entries). The finding stands even when the diff never touched the `.md` file, because the behavior change orphaned the doc claim.
|
|
52
52
|
- Adversarial probes: (a) for each changed producer, name the artifact it builds and grep the skill's `SKILL.md` and sibling `.md` files for any sentence naming that artifact; (b) walk the producer body's build step — does it sort, or does it merge stored names and append in file order — and compare against the doc's order word (`sorted`, `alphabetical`); (c) check whether the doc's content claim (`just the at-risk names`, `only the current set`) hides merged-in prior entries the producer carries over from the stored file.
|
|
53
53
|
|
|
54
|
+
**O9. Python docstring plainness for a general developer**
|
|
55
|
+
- For every changed module / class / public-function docstring, read the narrative prose before the first `Args:` / `Returns:` / `Raises:` / `Yields:` section as a stranger to the code. Flag a narrative that stacks abstract machinery nouns into a single wall, that defines a thing by what it is not (`the non-promoter-specific machinery`), or that runs one sentence past the word limit while joining clauses with an em-dash or a semicolon. The run-on mark is gated at Write/Edit time by `check_docstring_runon_sentence`, so this lane judges the part the gate cannot: whether the prose paints a concrete scene — the moment the code matters, the input it sees, the outcome it produces — that a general developer follows on the first read.
|
|
56
|
+
- Adversarial probes: (a) read each changed narrative and name the concrete moment, input, and outcome it paints — a narrative that names none is an O9 finding; (b) count the longest sentence's words and check for an em-dash or semicolon join — over the limit with a join is the wall mark the gate also catches; (c) rewrite each "is not" clause as a positive statement — a clause that resists rewriting because the body offers no positive description is an O9 finding.
|
|
57
|
+
|
|
54
58
|
## Cross-bucket questions to answer at the end
|
|
55
59
|
|
|
56
|
-
Q1: Across all
|
|
60
|
+
Q1: Across all 9 sub-buckets, which docstring claim is the most misleading — i.e., a future maintainer reading only the docstring would write or change code that contradicts the body? Cite file:line of the docstring AND the body line(s) that contradict it.
|
|
57
61
|
|
|
58
62
|
Q2: Which docstring claim is at highest risk of becoming load-bearing — i.e., a future caller or test author would rely on the claim to skip reading the body? Cite the claim and the use case.
|
|
59
63
|
|
|
@@ -61,13 +65,13 @@ Q3: Of the changed docstrings, which one most clearly shows a refactor was incom
|
|
|
61
65
|
|
|
62
66
|
## Output
|
|
63
67
|
|
|
64
|
-
Lead: `Total: N (P0=N, P1=N, P2=N)`. For each sub-bucket O1-
|
|
68
|
+
Lead: `Total: N (P0=N, P1=N, P2=N)`. For each sub-bucket O1-O9, produce Shape A or Shape B (with ≥3 probes). Each Shape A finding must cite (a) the docstring file:line, (b) the body file:line that contradicts it, and (c) one sentence describing the contradiction in concrete terms. Cross-bucket Q1-Q3 answers after the per-sub-bucket walk. Adversarial second pass: "assume your first pass missed at least 3 module-level docstring claims whose implementation moved during a refactor — find them." Open Questions section for ambiguities. Read-only. No edits, no commits.
|
|
65
69
|
|
|
66
70
|
---
|
|
67
71
|
|
|
68
72
|
# Worked example: jl-cmd/claude-code-config PR #522
|
|
69
73
|
|
|
70
|
-
Audit jl-cmd/claude-code-config PR #522 for **Category O only** (docstring / fixture-prose vs implementation drift). Skip A-N, P. Sub-bucket forced-exhaustion mode: Category O is decomposed into
|
|
74
|
+
Audit jl-cmd/claude-code-config PR #522 for **Category O only** (docstring / fixture-prose vs implementation drift). Skip A-N, P. Sub-bucket forced-exhaustion mode: Category O is decomposed into 9 sub-buckets below.
|
|
71
75
|
|
|
72
76
|
PR #522 split `pr_description_command_parser.py` into two modules — the original parser and a new `pr_description_pr_number.py` — but the originating module's docstring still claims the PR-number recovery responsibility. A sibling change to `pr_description_body_audit.py` introduced a module docstring whose verb (`detects vague language`) overstates the module's actual responsibility (it only exposes `_extract_vague_scan_text()`; detection runs elsewhere).
|
|
73
77
|
|
|
@@ -31,23 +31,34 @@ from hooks_constants.blocking_check_limits import ( # noqa: E402
|
|
|
31
31
|
ALL_DOCSTRING_NO_CONSUMER_CLAIM_PHRASES,
|
|
32
32
|
ALL_DOCSTRING_NO_INLINE_LITERAL_CLAIM_PHRASES,
|
|
33
33
|
ALL_DOCSTRING_NON_CONSTANT_REFERENCE_MARKERS,
|
|
34
|
+
ALL_DOCSTRING_RUNON_JOINER_MARKERS,
|
|
34
35
|
ALL_GENERIC_CHECK_NAME_TOKENS,
|
|
35
36
|
ALL_NAMING_CONVENTION_DESCRIPTOR_TOKENS,
|
|
36
37
|
DOCSTRING_FALLBACK_BRANCH_MINIMUM_ROUTE_COUNT,
|
|
37
38
|
DOCSTRING_REFERENCE_MARKER_WINDOW,
|
|
39
|
+
DOCSTRING_RUNON_SENTENCE_BOUNDARY_PATTERN,
|
|
40
|
+
DOCSTRING_RUNON_SENTENCE_WORD_LIMIT,
|
|
38
41
|
DOCSTRING_TRIVIAL_FUNCTION_BODY_LINE_LIMIT,
|
|
39
42
|
MAX_CLASS_DOCSTRING_PUBLIC_METHOD_ISSUES,
|
|
40
43
|
MAX_DOCSTRING_ARGS_SIGNATURE_ISSUES,
|
|
44
|
+
MAX_DOCSTRING_CARDINAL_FAMILY_ISSUES,
|
|
41
45
|
MAX_DOCSTRING_FALLBACK_BRANCH_ISSUES,
|
|
42
46
|
MAX_DOCSTRING_FORMAT_ISSUES,
|
|
43
47
|
MAX_DOCSTRING_INLINE_LITERAL_CLAIM_ISSUES,
|
|
44
48
|
MAX_DOCSTRING_NO_CONSUMER_CLAIM_ISSUES,
|
|
49
|
+
MAX_DOCSTRING_RUNON_SENTENCE_ISSUES,
|
|
50
|
+
ALL_DOCSTRING_SINGLE_LINE_SCOPE_PHRASES,
|
|
51
|
+
ALL_DOCSTRING_SPAN_RANGE_BODY_CALLEE_NAMES,
|
|
52
|
+
ALL_DOCSTRING_SPAN_SCOPE_OVERRIDE_PHRASES,
|
|
53
|
+
MAX_DOCSTRING_ARGS_SPAN_SCOPE_ISSUES,
|
|
45
54
|
MAX_DOCSTRING_STEP_DISPATCH_ISSUES,
|
|
46
55
|
MAX_DOCSTRING_RETURNS_PLURAL_CARDINALITY_ISSUES,
|
|
47
56
|
MAX_DOCSTRING_TUPLE_ENUMERATION_ISSUES,
|
|
48
57
|
MAX_DOCSTRING_UNDEFINED_CONSTANT_ISSUES,
|
|
49
58
|
MAX_DOCSTRING_UNGUARDED_PAYLOAD_CLAIM_ISSUES,
|
|
50
59
|
MAX_MODULE_DOCSTRING_CHECK_ROSTER_ISSUES,
|
|
60
|
+
MINIMUM_CONSTANT_FAMILY_MEMBERS_FOR_CARDINAL_CHECK,
|
|
61
|
+
MINIMUM_DOCSTRING_FAMILY_OVERLAP_FOR_CARDINAL_CHECK,
|
|
51
62
|
MINIMUM_NAMED_LINEAR_STEPS_FOR_DISPATCH_CHECK,
|
|
52
63
|
MINIMUM_PUBLIC_CHECKS_FOR_MODULE_DOCSTRING_ROSTER,
|
|
53
64
|
MINIMUM_PUBLIC_METHODS_FOR_CLASS_DOCSTRING_BREADTH,
|
|
@@ -57,10 +68,13 @@ from hooks_constants.blocking_check_limits import ( # noqa: E402
|
|
|
57
68
|
)
|
|
58
69
|
from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
|
|
59
70
|
ALL_CAPS_WITH_UNDERSCORE_PATTERN,
|
|
71
|
+
ALL_CARDINAL_NUMBER_WORD_VALUES,
|
|
60
72
|
ALL_DOCSTRING_ARGS_SECTION_HEADERS,
|
|
61
73
|
ALL_DOCSTRING_TERMINATING_SECTION_HEADERS,
|
|
62
74
|
ALL_SELF_AND_CLS_PARAMETER_NAMES,
|
|
63
75
|
DOCSTRING_ARG_ENTRY_PATTERN,
|
|
76
|
+
DOCSTRING_CARDINAL_OUTCOME_PHRASE_PATTERN,
|
|
77
|
+
DOCSTRING_MULTI_SEGMENT_SNAKE_TOKEN_PATTERN,
|
|
64
78
|
DOCSTRING_PLURAL_FAMILY_STOP_PATTERN,
|
|
65
79
|
IDENTIFIER_SHAPED_TUPLE_MEMBER_PATTERN,
|
|
66
80
|
INLINE_CODE_TOKEN_PATTERN,
|
|
@@ -1111,6 +1125,122 @@ def check_docstring_returns_plural_cardinality(content: str, file_path: str) ->
|
|
|
1111
1125
|
return issues[:MAX_DOCSTRING_RETURNS_PLURAL_CARDINALITY_ISSUES]
|
|
1112
1126
|
|
|
1113
1127
|
|
|
1128
|
+
def _referenced_constant_families(parsed_tree: ast.Module) -> dict[str, set[str]]:
|
|
1129
|
+
members_by_family: dict[str, set[str]] = {}
|
|
1130
|
+
for each_node in ast.walk(parsed_tree):
|
|
1131
|
+
if not isinstance(each_node, ast.Name):
|
|
1132
|
+
continue
|
|
1133
|
+
if not isinstance(each_node.ctx, ast.Load):
|
|
1134
|
+
continue
|
|
1135
|
+
if not ALL_CAPS_WITH_UNDERSCORE_PATTERN.match(each_node.id):
|
|
1136
|
+
continue
|
|
1137
|
+
family_prefix, _, member_suffix = each_node.id.partition("_")
|
|
1138
|
+
if not member_suffix:
|
|
1139
|
+
continue
|
|
1140
|
+
members_by_family.setdefault(family_prefix, set()).add(member_suffix.lower())
|
|
1141
|
+
return members_by_family
|
|
1142
|
+
|
|
1143
|
+
|
|
1144
|
+
def _eligible_constant_families(parsed_tree: ast.Module) -> dict[str, set[str]]:
|
|
1145
|
+
return {
|
|
1146
|
+
each_family: each_members
|
|
1147
|
+
for each_family, each_members in _referenced_constant_families(parsed_tree).items()
|
|
1148
|
+
if len(each_members) >= MINIMUM_CONSTANT_FAMILY_MEMBERS_FOR_CARDINAL_CHECK
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
|
|
1152
|
+
def _docstring_snake_case_tokens(docstring_text: str) -> set[str]:
|
|
1153
|
+
return set(DOCSTRING_MULTI_SEGMENT_SNAKE_TOKEN_PATTERN.findall(docstring_text))
|
|
1154
|
+
|
|
1155
|
+
|
|
1156
|
+
def _docstring_stated_outcome_cardinals(docstring_text: str) -> set[int]:
|
|
1157
|
+
return {
|
|
1158
|
+
ALL_CARDINAL_NUMBER_WORD_VALUES[each_match.group(1).lower()]
|
|
1159
|
+
for each_match in DOCSTRING_CARDINAL_OUTCOME_PHRASE_PATTERN.finditer(docstring_text)
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
|
|
1163
|
+
def _documentable_docstrings_with_line(parsed_tree: ast.Module) -> list[tuple[int, str]]:
|
|
1164
|
+
docstrings_with_line: list[tuple[int, str]] = []
|
|
1165
|
+
module_docstring = ast.get_docstring(parsed_tree) or ""
|
|
1166
|
+
if module_docstring:
|
|
1167
|
+
docstrings_with_line.append((1, module_docstring))
|
|
1168
|
+
for each_node in ast.walk(parsed_tree):
|
|
1169
|
+
if not isinstance(
|
|
1170
|
+
each_node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)
|
|
1171
|
+
):
|
|
1172
|
+
continue
|
|
1173
|
+
node_docstring = ast.get_docstring(each_node) or ""
|
|
1174
|
+
if node_docstring:
|
|
1175
|
+
docstrings_with_line.append((each_node.lineno, node_docstring))
|
|
1176
|
+
return docstrings_with_line
|
|
1177
|
+
|
|
1178
|
+
|
|
1179
|
+
def check_docstring_cardinal_count_matches_constant_family(
|
|
1180
|
+
content: str, file_path: str
|
|
1181
|
+
) -> list[str]:
|
|
1182
|
+
"""Flag a docstring cardinal count that under-names a referenced constant family.
|
|
1183
|
+
|
|
1184
|
+
The drift this catches: a docstring states a cardinal count of an outcome
|
|
1185
|
+
family (``Covers the four outcome branches: ...``) and enumerates that
|
|
1186
|
+
family's members in prose, while the module references more members of the
|
|
1187
|
+
same ``UPPER_SNAKE`` constant family than the count names. The prose
|
|
1188
|
+
under-describes the code — a reader trusts the count and the enumeration to
|
|
1189
|
+
be the full set, but the module imports and exercises an outcome the summary
|
|
1190
|
+
omits. The check binds only when the docstring states a cardinal beside an
|
|
1191
|
+
outcome noun, names two or more members of one referenced family, leaves at
|
|
1192
|
+
least one referenced family member unnamed, and the family member count
|
|
1193
|
+
differs from every stated cardinal; a complete enumeration, a count that
|
|
1194
|
+
matches the family, and a single passing mention are all left alone. This is
|
|
1195
|
+
the deterministic cardinal-count slice of Category O6
|
|
1196
|
+
docstring-prose-vs-implementation drift. The check runs on test files too,
|
|
1197
|
+
since the drift class lives in a test-module summary.
|
|
1198
|
+
|
|
1199
|
+
Args:
|
|
1200
|
+
content: The source text to inspect.
|
|
1201
|
+
file_path: The path the source will be written to, used for exemptions.
|
|
1202
|
+
|
|
1203
|
+
Returns:
|
|
1204
|
+
One issue per docstring whose cardinal count and enumeration omit a
|
|
1205
|
+
referenced constant-family member, capped at the module limit.
|
|
1206
|
+
"""
|
|
1207
|
+
try:
|
|
1208
|
+
parsed_tree = ast.parse(content)
|
|
1209
|
+
except SyntaxError:
|
|
1210
|
+
return []
|
|
1211
|
+
eligible_families = _eligible_constant_families(parsed_tree)
|
|
1212
|
+
if not eligible_families:
|
|
1213
|
+
return []
|
|
1214
|
+
issues: list[str] = []
|
|
1215
|
+
for each_line, each_docstring in _documentable_docstrings_with_line(parsed_tree):
|
|
1216
|
+
stated_cardinals = _docstring_stated_outcome_cardinals(each_docstring)
|
|
1217
|
+
if not stated_cardinals:
|
|
1218
|
+
continue
|
|
1219
|
+
docstring_tokens = _docstring_snake_case_tokens(each_docstring)
|
|
1220
|
+
if not docstring_tokens:
|
|
1221
|
+
continue
|
|
1222
|
+
for each_family in sorted(eligible_families):
|
|
1223
|
+
family_members = eligible_families[each_family]
|
|
1224
|
+
named_members = docstring_tokens & family_members
|
|
1225
|
+
if len(named_members) < MINIMUM_DOCSTRING_FAMILY_OVERLAP_FOR_CARDINAL_CHECK:
|
|
1226
|
+
continue
|
|
1227
|
+
omitted_members = family_members - docstring_tokens
|
|
1228
|
+
if not omitted_members:
|
|
1229
|
+
continue
|
|
1230
|
+
if len(family_members) in stated_cardinals:
|
|
1231
|
+
continue
|
|
1232
|
+
issues.append(
|
|
1233
|
+
f"Line {each_line}: docstring names {sorted(stated_cardinals)} as the "
|
|
1234
|
+
f"{each_family}_ count but the module references {len(family_members)} "
|
|
1235
|
+
f"{each_family}_ constants — omits {sorted(omitted_members)}; match the "
|
|
1236
|
+
"count and enumeration to the referenced family "
|
|
1237
|
+
"(Category O6 docstring-vs-implementation drift)"
|
|
1238
|
+
)
|
|
1239
|
+
if len(issues) >= MAX_DOCSTRING_CARDINAL_FAMILY_ISSUES:
|
|
1240
|
+
return issues[:MAX_DOCSTRING_CARDINAL_FAMILY_ISSUES]
|
|
1241
|
+
return issues[:MAX_DOCSTRING_CARDINAL_FAMILY_ISSUES]
|
|
1242
|
+
|
|
1243
|
+
|
|
1114
1244
|
def _called_callee_name(statement: ast.stmt) -> str:
|
|
1115
1245
|
candidate_expression: ast.expr | None = None
|
|
1116
1246
|
if isinstance(statement, ast.Expr):
|
|
@@ -1458,3 +1588,251 @@ def check_docstring_names_undefined_constant(content: str, file_path: str) -> li
|
|
|
1458
1588
|
if len(issues) >= MAX_DOCSTRING_UNDEFINED_CONSTANT_ISSUES:
|
|
1459
1589
|
return issues[:MAX_DOCSTRING_UNDEFINED_CONSTANT_ISSUES]
|
|
1460
1590
|
return issues[:MAX_DOCSTRING_UNDEFINED_CONSTANT_ISSUES]
|
|
1591
|
+
|
|
1592
|
+
|
|
1593
|
+
def _args_entry_prose_by_argument(docstring_text: str) -> dict[str, str]:
|
|
1594
|
+
docstring_lines = docstring_text.splitlines()
|
|
1595
|
+
args_section_index = _find_args_section_index(docstring_lines)
|
|
1596
|
+
if args_section_index is None:
|
|
1597
|
+
return {}
|
|
1598
|
+
prose_by_argument: dict[str, list[str]] = {}
|
|
1599
|
+
current_argument_name: str | None = None
|
|
1600
|
+
entry_indent: int | None = None
|
|
1601
|
+
for each_line in docstring_lines[args_section_index + 1:]:
|
|
1602
|
+
stripped_line = each_line.strip()
|
|
1603
|
+
if not stripped_line:
|
|
1604
|
+
continue
|
|
1605
|
+
if _is_docstring_terminating_section_header(stripped_line):
|
|
1606
|
+
break
|
|
1607
|
+
current_indent = len(each_line) - len(each_line.lstrip())
|
|
1608
|
+
if current_indent == 0:
|
|
1609
|
+
break
|
|
1610
|
+
if entry_indent is None:
|
|
1611
|
+
entry_indent = current_indent
|
|
1612
|
+
if current_indent <= entry_indent:
|
|
1613
|
+
entry_match = DOCSTRING_ARG_ENTRY_PATTERN.match(stripped_line)
|
|
1614
|
+
if entry_match is not None:
|
|
1615
|
+
current_argument_name = entry_match.group(1)
|
|
1616
|
+
prose_by_argument[current_argument_name] = [stripped_line]
|
|
1617
|
+
continue
|
|
1618
|
+
if current_argument_name is not None:
|
|
1619
|
+
prose_by_argument[current_argument_name].append(stripped_line)
|
|
1620
|
+
return {
|
|
1621
|
+
each_name: " ".join(each_lines).lower()
|
|
1622
|
+
for each_name, each_lines in prose_by_argument.items()
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
|
|
1626
|
+
def _argument_prose_scopes_a_single_line(argument_prose: str) -> bool:
|
|
1627
|
+
if any(
|
|
1628
|
+
each_phrase in argument_prose for each_phrase in ALL_DOCSTRING_SPAN_SCOPE_OVERRIDE_PHRASES
|
|
1629
|
+
):
|
|
1630
|
+
return False
|
|
1631
|
+
return any(
|
|
1632
|
+
each_phrase in argument_prose for each_phrase in ALL_DOCSTRING_SINGLE_LINE_SCOPE_PHRASES
|
|
1633
|
+
)
|
|
1634
|
+
|
|
1635
|
+
|
|
1636
|
+
def _call_node_builds_two_argument_range(call_node: ast.Call) -> bool:
|
|
1637
|
+
callee = call_node.func
|
|
1638
|
+
return (
|
|
1639
|
+
isinstance(callee, ast.Name)
|
|
1640
|
+
and callee.id == "range"
|
|
1641
|
+
and len(call_node.args) >= MINIMUM_TOKENS_FOR_DISPATCH_CALLEE
|
|
1642
|
+
)
|
|
1643
|
+
|
|
1644
|
+
|
|
1645
|
+
def _function_body_scopes_a_span_by_intersection(
|
|
1646
|
+
function_node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
1647
|
+
) -> bool:
|
|
1648
|
+
builds_range_span = False
|
|
1649
|
+
calls_span_scoper = False
|
|
1650
|
+
for each_node in ast.walk(function_node):
|
|
1651
|
+
if not isinstance(each_node, ast.Call):
|
|
1652
|
+
continue
|
|
1653
|
+
if _call_node_builds_two_argument_range(each_node):
|
|
1654
|
+
builds_range_span = True
|
|
1655
|
+
if _call_callee_name(each_node) in ALL_DOCSTRING_SPAN_RANGE_BODY_CALLEE_NAMES:
|
|
1656
|
+
calls_span_scoper = True
|
|
1657
|
+
return builds_range_span and calls_span_scoper
|
|
1658
|
+
|
|
1659
|
+
|
|
1660
|
+
def check_docstring_args_single_line_scope_vs_span(content: str, file_path: str) -> list[str]:
|
|
1661
|
+
"""Flag a docstring Args entry scoping a finding to one line over a span body.
|
|
1662
|
+
|
|
1663
|
+
The drift this catches: an ``Args:`` entry whose prose says a finding blocks
|
|
1664
|
+
"only when its block-anchor line is among the changed lines" (a single named
|
|
1665
|
+
line) while the function body builds a ``range(...)`` span over the finding's
|
|
1666
|
+
source lines and routes it through a span-intersection scoper that blocks when
|
|
1667
|
+
ANY line of the span is among the changed lines. The Args sentence claims a
|
|
1668
|
+
narrower single-line scope than the body applies, so an edit touching a
|
|
1669
|
+
non-anchor line of the span still blocks — contradicting the Args entry. This
|
|
1670
|
+
is the deterministic slice of Category O6 docstring-vs-implementation drift
|
|
1671
|
+
for an Args single-line scope claim disagreeing with a span-intersection body.
|
|
1672
|
+
|
|
1673
|
+
An entry is left alone when its prose says "any line of" / "any line in" its
|
|
1674
|
+
span, since that wording matches the span body. The body is judged a
|
|
1675
|
+
span-intersection scoper only when it both builds a two-argument ``range(...)``
|
|
1676
|
+
and calls a known span scoper, so a body that scopes by a single line never
|
|
1677
|
+
trips the check. Hook infrastructure is in scope here — the import-sort gate
|
|
1678
|
+
that carries this drift class is itself a hook — and test files are exempt.
|
|
1679
|
+
|
|
1680
|
+
Args:
|
|
1681
|
+
content: The source text to inspect.
|
|
1682
|
+
file_path: The path the source will be written to, used for exemptions.
|
|
1683
|
+
|
|
1684
|
+
Returns:
|
|
1685
|
+
One issue per function whose Args single-line scope claim disagrees with
|
|
1686
|
+
its span-intersection body, capped at the module limit.
|
|
1687
|
+
"""
|
|
1688
|
+
if is_test_file(file_path):
|
|
1689
|
+
return []
|
|
1690
|
+
try:
|
|
1691
|
+
parsed_tree = ast.parse(content)
|
|
1692
|
+
except SyntaxError:
|
|
1693
|
+
return []
|
|
1694
|
+
issues: list[str] = []
|
|
1695
|
+
for each_node in _walk_skipping_type_checking_blocks(parsed_tree):
|
|
1696
|
+
if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
1697
|
+
continue
|
|
1698
|
+
if _function_has_exempt_decorator(each_node):
|
|
1699
|
+
continue
|
|
1700
|
+
docstring_text = _function_docstring_text(each_node)
|
|
1701
|
+
if not docstring_text:
|
|
1702
|
+
continue
|
|
1703
|
+
if not _function_body_scopes_a_span_by_intersection(each_node):
|
|
1704
|
+
continue
|
|
1705
|
+
for each_argument, each_prose in _args_entry_prose_by_argument(docstring_text).items():
|
|
1706
|
+
if not _argument_prose_scopes_a_single_line(each_prose):
|
|
1707
|
+
continue
|
|
1708
|
+
issues.append(
|
|
1709
|
+
f"Line {each_node.lineno}: {each_node.name}() Args entry '{each_argument}' "
|
|
1710
|
+
"scopes a finding to a single line ('anchor line is among the changed lines') "
|
|
1711
|
+
"while the body builds a range() span and scopes by span intersection — an "
|
|
1712
|
+
"edit touching any non-anchor line of the span still blocks; restate the Args "
|
|
1713
|
+
"entry as 'any line of its span is among the changed lines' (Category O6 "
|
|
1714
|
+
"docstring-vs-implementation drift)"
|
|
1715
|
+
)
|
|
1716
|
+
if len(issues) >= MAX_DOCSTRING_ARGS_SPAN_SCOPE_ISSUES:
|
|
1717
|
+
return issues[:MAX_DOCSTRING_ARGS_SPAN_SCOPE_ISSUES]
|
|
1718
|
+
return issues[:MAX_DOCSTRING_ARGS_SPAN_SCOPE_ISSUES]
|
|
1719
|
+
|
|
1720
|
+
|
|
1721
|
+
def _is_narrative_cut_header(stripped_line: str) -> bool:
|
|
1722
|
+
return (
|
|
1723
|
+
stripped_line in ALL_DOCSTRING_TERMINATING_SECTION_HEADERS
|
|
1724
|
+
or stripped_line in ALL_DOCSTRING_ARGS_SECTION_HEADERS
|
|
1725
|
+
)
|
|
1726
|
+
|
|
1727
|
+
|
|
1728
|
+
def _docstring_narrative_text(docstring_text: str) -> str:
|
|
1729
|
+
narrative_lines: list[str] = []
|
|
1730
|
+
for each_line in docstring_text.splitlines():
|
|
1731
|
+
stripped_line = each_line.strip()
|
|
1732
|
+
if _is_narrative_cut_header(stripped_line):
|
|
1733
|
+
break
|
|
1734
|
+
narrative_lines.append(stripped_line)
|
|
1735
|
+
return " ".join(narrative_lines)
|
|
1736
|
+
|
|
1737
|
+
|
|
1738
|
+
def _sentence_word_count(sentence_text: str) -> int:
|
|
1739
|
+
return sum(
|
|
1740
|
+
1
|
|
1741
|
+
for each_token in sentence_text.split()
|
|
1742
|
+
if any(each_character.isalnum() for each_character in each_token)
|
|
1743
|
+
)
|
|
1744
|
+
|
|
1745
|
+
|
|
1746
|
+
def _sentence_carries_joiner_marker(sentence_text: str) -> bool:
|
|
1747
|
+
return any(
|
|
1748
|
+
each_marker in sentence_text for each_marker in ALL_DOCSTRING_RUNON_JOINER_MARKERS
|
|
1749
|
+
)
|
|
1750
|
+
|
|
1751
|
+
|
|
1752
|
+
def _runon_sentences(narrative_text: str) -> list[str]:
|
|
1753
|
+
flagged_sentences: list[str] = []
|
|
1754
|
+
for each_sentence in DOCSTRING_RUNON_SENTENCE_BOUNDARY_PATTERN.split(narrative_text):
|
|
1755
|
+
stripped_sentence = each_sentence.strip()
|
|
1756
|
+
if not stripped_sentence:
|
|
1757
|
+
continue
|
|
1758
|
+
if _sentence_word_count(stripped_sentence) <= DOCSTRING_RUNON_SENTENCE_WORD_LIMIT:
|
|
1759
|
+
continue
|
|
1760
|
+
if not _sentence_carries_joiner_marker(stripped_sentence):
|
|
1761
|
+
continue
|
|
1762
|
+
flagged_sentences.append(stripped_sentence)
|
|
1763
|
+
return flagged_sentences
|
|
1764
|
+
|
|
1765
|
+
|
|
1766
|
+
def _documentable_docstring_targets(
|
|
1767
|
+
parsed_tree: ast.Module,
|
|
1768
|
+
) -> list[tuple[int, str, str]]:
|
|
1769
|
+
documentable_targets: list[tuple[int, str, str]] = []
|
|
1770
|
+
module_docstring = ast.get_docstring(parsed_tree)
|
|
1771
|
+
if module_docstring and parsed_tree.body:
|
|
1772
|
+
documentable_targets.append((parsed_tree.body[0].lineno, "module", module_docstring))
|
|
1773
|
+
for each_node in _walk_skipping_type_checking_blocks(parsed_tree):
|
|
1774
|
+
if isinstance(each_node, ast.ClassDef):
|
|
1775
|
+
class_docstring = ast.get_docstring(each_node)
|
|
1776
|
+
if class_docstring:
|
|
1777
|
+
documentable_targets.append((each_node.lineno, each_node.name, class_docstring))
|
|
1778
|
+
continue
|
|
1779
|
+
if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
1780
|
+
continue
|
|
1781
|
+
if _function_is_private_or_dunder(each_node.name):
|
|
1782
|
+
continue
|
|
1783
|
+
if _function_has_exempt_decorator(each_node):
|
|
1784
|
+
continue
|
|
1785
|
+
function_docstring = _function_docstring_text(each_node)
|
|
1786
|
+
if function_docstring:
|
|
1787
|
+
documentable_targets.append(
|
|
1788
|
+
(each_node.lineno, f"{each_node.name}()", function_docstring)
|
|
1789
|
+
)
|
|
1790
|
+
return documentable_targets
|
|
1791
|
+
|
|
1792
|
+
|
|
1793
|
+
def check_docstring_runon_sentence(content: str, file_path: str) -> list[str]:
|
|
1794
|
+
"""Flag a docstring narrative sentence that reads as a dense run-on wall.
|
|
1795
|
+
|
|
1796
|
+
A readable docstring breaks its narrative into short sentences a general
|
|
1797
|
+
developer follows on the first read. The one mechanical mark of a wall is a
|
|
1798
|
+
single sentence that runs past the word limit while chaining clauses with an
|
|
1799
|
+
em-dash or a semicolon. This check inspects the narrative prose of module,
|
|
1800
|
+
class, and public-function docstrings — the text before the first structured
|
|
1801
|
+
section header (``Args:``, ``Arguments:``, ``Returns:``, ``Yields:``,
|
|
1802
|
+
``Raises:``, ``Note:``, ``Notes:``, ``Example:``, or ``Examples:``) — and
|
|
1803
|
+
reports a sentence that is both over the word limit and joined by one of those
|
|
1804
|
+
marks.
|
|
1805
|
+
Whether the prose paints a concrete, illustrative picture is judgment the
|
|
1806
|
+
plain-illustrative-docstrings audit lane carries; this gate catches only the
|
|
1807
|
+
run-on mark.
|
|
1808
|
+
|
|
1809
|
+
Args:
|
|
1810
|
+
content: The source text to inspect.
|
|
1811
|
+
file_path: The path the source will be written to, used for exemptions.
|
|
1812
|
+
|
|
1813
|
+
Returns:
|
|
1814
|
+
One issue per docstring whose narrative carries a run-on sentence, capped
|
|
1815
|
+
at the module limit.
|
|
1816
|
+
"""
|
|
1817
|
+
if is_test_file(file_path) or is_hook_infrastructure(file_path):
|
|
1818
|
+
return []
|
|
1819
|
+
try:
|
|
1820
|
+
parsed_tree = ast.parse(content)
|
|
1821
|
+
except SyntaxError:
|
|
1822
|
+
return []
|
|
1823
|
+
issues: list[str] = []
|
|
1824
|
+
for each_line_number, each_label, each_docstring in _documentable_docstring_targets(
|
|
1825
|
+
parsed_tree
|
|
1826
|
+
):
|
|
1827
|
+
flagged_sentences = _runon_sentences(_docstring_narrative_text(each_docstring))
|
|
1828
|
+
if not flagged_sentences:
|
|
1829
|
+
continue
|
|
1830
|
+
run_on_word_count = _sentence_word_count(flagged_sentences[0])
|
|
1831
|
+
issues.append(
|
|
1832
|
+
f"Line {each_line_number}: {each_label} docstring carries a {run_on_word_count}-word "
|
|
1833
|
+
"run-on sentence — break the narrative into short, illustrative sentences a general "
|
|
1834
|
+
"developer reads in one pass (plain-illustrative-docstrings)"
|
|
1835
|
+
)
|
|
1836
|
+
if len(issues) >= MAX_DOCSTRING_RUNON_SENTENCE_ISSUES:
|
|
1837
|
+
break
|
|
1838
|
+
return issues[:MAX_DOCSTRING_RUNON_SENTENCE_ISSUES]
|
|
@@ -67,12 +67,15 @@ from code_rules_dead_module_constant import ( # noqa: E402
|
|
|
67
67
|
from code_rules_docstrings import ( # noqa: E402
|
|
68
68
|
check_class_docstring_names_public_methods,
|
|
69
69
|
check_docstring_args_match_signature,
|
|
70
|
+
check_docstring_args_single_line_scope_vs_span,
|
|
71
|
+
check_docstring_cardinal_count_matches_constant_family,
|
|
70
72
|
check_docstring_fallback_branch_coverage,
|
|
71
73
|
check_docstring_format,
|
|
72
74
|
check_docstring_names_undefined_constant,
|
|
73
75
|
check_docstring_no_consumer_claim,
|
|
74
76
|
check_docstring_no_inline_literal_claim,
|
|
75
77
|
check_docstring_returns_plural_cardinality,
|
|
78
|
+
check_docstring_runon_sentence,
|
|
76
79
|
check_docstring_step_enumeration_dispatch_coverage,
|
|
77
80
|
check_docstring_tuple_enumeration_match,
|
|
78
81
|
check_docstring_unguarded_malformed_payload_claim,
|
|
@@ -295,6 +298,7 @@ def validate_content(
|
|
|
295
298
|
all_issues.extend(
|
|
296
299
|
check_class_docstring_names_public_methods(effective_content, file_path)
|
|
297
300
|
)
|
|
301
|
+
all_issues.extend(check_docstring_runon_sentence(effective_content, file_path))
|
|
298
302
|
all_issues.extend(
|
|
299
303
|
check_module_docstring_names_public_checks(effective_content, file_path)
|
|
300
304
|
)
|
|
@@ -309,9 +313,17 @@ def validate_content(
|
|
|
309
313
|
all_issues.extend(
|
|
310
314
|
check_docstring_returns_plural_cardinality(effective_content, file_path)
|
|
311
315
|
)
|
|
316
|
+
all_issues.extend(
|
|
317
|
+
check_docstring_cardinal_count_matches_constant_family(
|
|
318
|
+
effective_content, file_path
|
|
319
|
+
)
|
|
320
|
+
)
|
|
312
321
|
all_issues.extend(
|
|
313
322
|
check_docstring_names_undefined_constant(effective_content, file_path)
|
|
314
323
|
)
|
|
324
|
+
all_issues.extend(
|
|
325
|
+
check_docstring_args_single_line_scope_vs_span(effective_content, file_path)
|
|
326
|
+
)
|
|
315
327
|
all_issues.extend(
|
|
316
328
|
check_boolean_naming(
|
|
317
329
|
effective_content,
|