claude-dev-env 1.49.1 → 1.50.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-a-api-contracts.md +17 -3
- package/audit-rubrics/prompts/category-a-api-contracts.md +17 -2
- package/docs/CODE_RULES.md +6 -1
- package/hooks/blocking/code_rules_enforcer.py +323 -11
- package/hooks/blocking/md_to_html_blocker.py +2 -2
- package/hooks/blocking/test_code_rules_enforcer.py +65 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_args_signature.py +256 -0
- package/hooks/blocking/test_code_rules_enforcer_ignored_must_check_return.py +256 -0
- package/hooks/blocking/test_code_rules_enforcer_naming_pattern.py +137 -1
- package/hooks/blocking/test_md_to_html_blocker.py +38 -0
- package/hooks/hooks_constants/blocking_check_limits.py +2 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +15 -1
- package/hooks/hooks_constants/md_to_html_blocker_constants.py +1 -1
- package/hooks/hooks_constants/test_md_to_html_blocker_constants.py +11 -4
- package/package.json +1 -1
|
@@ -21,10 +21,10 @@ The decomposition that worked best for PR #394 (a Python+PowerShell scheduled-ta
|
|
|
21
21
|
|
|
22
22
|
| ID | Axis name | Concrete checks |
|
|
23
23
|
|---|---|---|
|
|
24
|
-
| A1 | Python function signatures vs internal call sites | Parameter count, names, defaults, kw-only barriers; every internal call binds correctly. |
|
|
25
|
-
| A2 | Python return-type annotation vs every code path | Each function's return annotation is satisfied by every path: explicit `return X`, fall-through, exception-handler exit. |
|
|
24
|
+
| A1 | Python function signatures vs internal call sites | Parameter count, names, defaults, kw-only barriers; every internal call binds correctly. Is the symbol `async def`? Confirm the exact access path a caller uses: free function vs instance method reached through an object attribute vs import path. A keyword-only parameter with no default is required; omitting it raises `TypeError`. |
|
|
25
|
+
| A2 | Python return-type annotation vs every code path | Each function's return annotation is satisfied by every path: explicit `return X`, fall-through, exception-handler exit. The full failure contract is the return value AND every exception raised — trace the body and the docstring `Raises:` for each `raise`, including custom errors. A `-> bool` function that also raises is not fully described by "returns bool". |
|
|
26
26
|
| A3 | argparse parser → Namespace contract | Every `add_argument(...)` produces the exact dest name accessed downstream; `type=` matches downstream usage; switches produce bools. |
|
|
27
|
-
| A4 | Stdlib callback contracts | `os.walk(onerror=...)` callback shape; `os.path.getctime` / `os.rmdir` argument and exception contracts; `time.sleep` argument types. |
|
|
27
|
+
| A4 | Stdlib callback contracts | `os.walk(onerror=...)` callback shape; `os.path.getctime` / `os.rmdir` argument and exception contracts; `time.sleep` argument types. Catch-site precision: for any claim that code "catches X", confirm the exact catch site and scope — an `except` around only a rollback inside `finally` does not catch the same error raised in the `with` body. |
|
|
28
28
|
| A5 | subprocess invocation contract | `subprocess.run` kwargs valid for the targeted Python; `args=[list]` shape; exception propagation under `check=True`. |
|
|
29
29
|
| A6 | PowerShell cmdlet parameter sets and binding | `param(...)` with `ParameterSetName=`; `[CmdletBinding(DefaultParameterSetName=…)]` presence; cmdlet parameter combinations valid per Microsoft docs. |
|
|
30
30
|
| A7 | Cross-language argv boundary | The `-Argument` string composition → Windows process loader → C-runtime argv parser → Python `sys.argv` → argparse. Trailing-backslash and embedded-space hazards. |
|
|
@@ -34,6 +34,20 @@ Adapt these axes for your artifact. For a pure Python codebase, drop A6 and A7 a
|
|
|
34
34
|
|
|
35
35
|
---
|
|
36
36
|
|
|
37
|
+
### Documentation as contract: verifying a doc claim about code
|
|
38
|
+
|
|
39
|
+
When the audited artifact is documentation — a CLAUDE.md, a rule file, a README, a table mapping symbols to behavior — that asserts facts about the codebase, API-contract verification means checking every assertion against the current code, not just confirming the symbol exists and its return type matches. A doc that passes the happy-path contract can still be wrong on any of the seven checks below. Run all seven up front. Checks 1, 2, and 6 are the full-contract sharpening of sub-buckets A1, A2, and A4 applied to a doc claim; checks 3, 4, 5, and 7 are specific to documentation artifacts.
|
|
40
|
+
|
|
41
|
+
1. **Full failure contract** — the failure signals of a function are its return value AND every exception it raises; trace the body and the docstring `Raises:` for every `raise`. _Example:_ a docs PR says a UI helper "returns `bool`", but it also raises a custom not-found error, and a database writer documented by its return type also raises `ValueError` / `RuntimeError` / a driver error, so "returns bool" understates the contract.
|
|
42
|
+
2. **Call shape** — required versus optional parameters (a keyword-only parameter with NO default is required; omitting it raises `TypeError`), sync versus async, and the exact access path (free function versus instance method reached through an object attribute versus import path). _Example:_ a doc presents a helper as a free function, but it is an `async` instance method reached through an object attribute and one keyword-only parameter has no default, so the call example in the doc would raise `TypeError`.
|
|
43
|
+
3. **Reuse-first** — before a doc endorses a hand-written snippet, search for a dedicated helper that already does it. _Example:_ a doc endorses hand-composing `normalize(name).lower()` inline while a dedicated `normalize_for_matching()` helper already does exactly that, contradicting the reuse-before-building rule the doc itself states.
|
|
44
|
+
4. **Path resolution** — every file or directory path a doc cites resolves from the repository root. _Example:_ a doc cites a bare `snapshots/` directory as if it sat at the repo root, but the tree lives under `subsystem/snapshots/`.
|
|
45
|
+
5. **Cross-entry consistency** — scan parallel rows, sections, and table entries for claims that contradict each other. _Example:_ two adjacent table rows map the same subsystem to two different exception base classes.
|
|
46
|
+
6. **Catch-site precision** — when a doc claims code "catches X", confirm the exact site and scope of the catch. _Example:_ a doc says a context manager catches a driver error, but the `except` wraps only the rollback inside `finally`, so an error raised in the `with` body propagates uncaught.
|
|
47
|
+
7. **Citation freshness** — re-derive every `file:line` claim against the current code; never trust a prior "verified" assertion or wording borrowed from a comment. _Example:_ an attribute name carried over from a review comment names a member the class does not define; the current code exposes it under a different name.
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
37
51
|
## Sample prompt
|
|
38
52
|
|
|
39
53
|
The literal text used in the May 2026 audit experiment is in [`../prompts/category-a-api-contracts.md`](../prompts/category-a-api-contracts.md). It produced 8–10 findings (P0=1–2, P1=2–6, P2=2–5) across two runs. Inline the full diff verbatim — do not ask the agent to fetch it.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
Audit [REPO/ARTIFACT] [TARGET_ID] for **Category A only** (API contract verification). Skip B–
|
|
1
|
+
Audit [REPO/ARTIFACT] [TARGET_ID] for **Category A only** (API contract verification). Skip B–N. Sub-bucket forced-exhaustion mode: Category A 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: title / change description / head SHA or revision identifier / scope summary]
|
|
4
4
|
ID prefix: `find`.
|
|
@@ -15,6 +15,7 @@ ID prefix: `find`.
|
|
|
15
15
|
- Flag positional arguments passed to keyword-only parameters and vice versa.
|
|
16
16
|
- Flag calls that omit a required parameter relying on a default that does not exist on the current branch.
|
|
17
17
|
- Verify decorators (`@staticmethod`, `@classmethod`, `@property`) do not silently shift the parameter binding (e.g., `self` / `cls` insertion).
|
|
18
|
+
- Confirm sync-vs-async (is the symbol `async def`?), the exact access path a caller uses (free function vs instance method via an object attribute vs import path), and that a keyword-only parameter with no default is required — omitting it raises `TypeError`.
|
|
18
19
|
|
|
19
20
|
**A2. Return-type annotation vs every code path**
|
|
20
21
|
- For each annotated function, walk every code path: explicit `return X`, fall-through to implicit `None`, exception-handler exit, generator `yield` paths, async coroutine return value.
|
|
@@ -22,6 +23,7 @@ ID prefix: `find`.
|
|
|
22
23
|
- For functions that raise instead of returning on some path, confirm the annotation does not promise a value the caller will dereference.
|
|
23
24
|
- Inspect `try/except/finally` chains for paths that return from `finally` and override `try`/`except` returns.
|
|
24
25
|
- For async functions, confirm the annotation refers to the awaited type, not the coroutine wrapper.
|
|
26
|
+
- The full failure contract is the return value AND every exception raised — list each `raise` in the body and the docstring `Raises:`; a `-> bool` that can also raise is not fully described as "returns bool".
|
|
25
27
|
|
|
26
28
|
**A3. CLI/argument-parser declaration → downstream Namespace contract**
|
|
27
29
|
- For every `add_argument(...)` (or equivalent CLI declaration), verify the auto-derived or explicit `dest=` matches the attribute name accessed downstream on the parsed namespace.
|
|
@@ -34,6 +36,7 @@ ID prefix: `find`.
|
|
|
34
36
|
- Identify every callback handed to a library function (e.g., `os.walk(onerror=...)`, sort `key=`, `filter`, `map`, `re.sub(repl=callable)`, signal handlers, threading callbacks). Verify each callback's signature matches what the library calls it with — arity, positional-vs-keyword, return type the library consumes.
|
|
35
37
|
- For every stdlib function the artifact calls, verify argument types and exception contracts: which exceptions can each call raise, and is each caller prepared (or deliberately not prepared) for them.
|
|
36
38
|
- Verify kwargs to stdlib functions are spelled correctly for the targeted runtime version (deprecated/renamed kwargs, version-introduced kwargs).
|
|
39
|
+
- Catch-site precision — for any "catches X" claim confirm the exact catch site and scope (an `except` around only a rollback inside `finally` does not catch the same error from the `with` body).
|
|
37
40
|
- Flag callbacks whose return value the library consumes but the implementation returns `None` (or vice versa).
|
|
38
41
|
- Confirm callback exception behavior: which exceptions in the callback bubble out, which are swallowed by the library, which terminate iteration.
|
|
39
42
|
|
|
@@ -66,6 +69,18 @@ ID prefix: `find`.
|
|
|
66
69
|
- For write calls, verify the signature against the provider's own published API contract — their REST reference docs, OpenAPI spec, SDK source code, or `--help` output. When a read endpoint exposes the same state, call it to confirm the write contract.
|
|
67
70
|
- Flag every call where documented parameters, types, or behavior diverge from the official API contract.
|
|
68
71
|
|
|
72
|
+
**A9. Documentation claims about the codebase (when the artifact asserts facts about the code)**
|
|
73
|
+
|
|
74
|
+
When the artifact is documentation that asserts facts about the codebase (symbol names, signatures, return types, exceptions, file paths), run all seven documentation-as-contract checks below; each yields a confirmation or a finding. For a pure-code artifact, A9 is one line of proof-of-absence (the artifact asserts no code facts).
|
|
75
|
+
|
|
76
|
+
- Full failure contract — the failure signals of a function are its return value AND every exception it raises; trace the body and the docstring `Raises:` for every `raise`. _Example:_ a docs PR says a UI helper "returns `bool`", but it also raises a custom not-found error, so "returns bool" understates the contract.
|
|
77
|
+
- Call shape — required versus optional parameters (a keyword-only parameter with NO default is required; omitting it raises `TypeError`), sync versus async, and the exact access path (free function versus instance method reached through an object attribute versus import path). _Example:_ a doc presents a helper as a free function, but it is an `async` instance method reached through an object attribute, so the doc's call example would raise `TypeError`.
|
|
78
|
+
- Reuse-first — before a doc endorses a hand-written snippet, search for a dedicated helper that already does it. _Example:_ a doc endorses hand-composing `normalize(name).lower()` inline while a dedicated `normalize_for_matching()` helper already does exactly that.
|
|
79
|
+
- Path resolution — every file or directory path a doc cites resolves from the repository root. _Example:_ a doc cites a bare `snapshots/` directory as if it sat at the repo root, but the tree lives under `subsystem/snapshots/`.
|
|
80
|
+
- Cross-entry consistency — scan parallel rows, sections, and table entries for claims that contradict each other. _Example:_ two adjacent table rows map the same subsystem to two different exception base classes.
|
|
81
|
+
- Catch-site precision — when a doc claims code "catches X", confirm the exact site and scope of the catch. _Example:_ a doc says a context manager catches a driver error, but the `except` wraps only the rollback inside `finally`, so an error raised in the `with` body propagates uncaught.
|
|
82
|
+
- Citation freshness — re-derive every `file:line` claim against the current code; never trust a prior "verified" assertion or wording borrowed from a comment. _Example:_ an attribute name carried over from a review comment names a member the class does not define; the current code exposes it under a different name.
|
|
83
|
+
|
|
69
84
|
## Cross-bucket questions to answer at the end
|
|
70
85
|
|
|
71
86
|
Q1: Are there any contracts that span two sub-buckets that single-bucket analysis would miss?
|
|
@@ -74,7 +89,7 @@ Q3: Where would a future refactor most likely break a cross-bucket or cross-lang
|
|
|
74
89
|
|
|
75
90
|
## Output
|
|
76
91
|
|
|
77
|
-
Lead: `Total: N (P0=N, P1=N, P2=N)`. For each sub-bucket A1–
|
|
92
|
+
Lead: `Total: N (P0=N, P1=N, P2=N)`. For each sub-bucket A1–A9, produce Shape A or Shape B (with ≥3 adversarial probes). Cross-bucket Q1–Q3 answers after the per-sub-bucket walk. Adversarial second pass: "assume your first pass missed at least 3 P1 bugs across these 9 sub-buckets — find them." Open Questions section for ambiguities. Read-only. No edits, no commits.
|
|
78
93
|
|
|
79
94
|
---
|
|
80
95
|
|
package/docs/CODE_RULES.md
CHANGED
|
@@ -62,6 +62,8 @@ These rules are automatically enforced by `code_rules_enforcer.py`. Violations b
|
|
|
62
62
|
| Test-mode branching in production | Reading `TESTING`, `PYTEST_CURRENT_TEST`, `IS_TEST`, etc. from production code creates two parallel implementations. Use dependency injection so production stays single-path. **Test files and hook infrastructure exempt.** |
|
|
63
63
|
| Thin wrapper files | A non-`__init__.py` module whose body is only imports (optionally with an `__all__` assignment) is a re-export indirection with no payload. Callers should import from the real module. `__init__.py` is the canonical re-export surface and is exempt. |
|
|
64
64
|
| Docstring format (Google-style) | Public functions/methods (no leading underscore, not dunder, body > 3 lines, not `@property`/`@abstractmethod`) require Google-style `Args:` / `Returns:` (or `Yields:`) / `Raises:` sections matching the signature. **Test files exempt.** |
|
|
65
|
+
| Docstring Args match signature | A public function whose docstring `Args:` section names a parameter the signature does not declare is flagged — a rename that left the adjacent `Args:` line stale. Only the `Args:` section is compared against the signature; `Raises:` is left alone because callee-propagated exceptions cause false positives. **Test files and hook infrastructure exempt.** |
|
|
66
|
+
| Ignored must-check return | A bare-statement call to a function whose return value is its only failure signal (the curated `find_and_click`, `write_outcome` set) is flagged — the discarded boolean lets the caller move on silently after a failure. Assign the return and check it. Assigned (`clicked = …`) and branched-on (`if …:`) calls are exempt. Attribute calls are matched by their terminal method name alone (the receiver type is not resolved), so an unrelated `obj.write_outcome()` or `widget.find_and_click()` whose method name collides with a curated name is also flagged. **Test files exempt.** |
|
|
65
67
|
|
|
66
68
|
### Where UPPER_SNAKE is allowed
|
|
67
69
|
|
|
@@ -124,7 +126,7 @@ Full words only. No mental translation.
|
|
|
124
126
|
|
|
125
127
|
**Extended naming rules** :
|
|
126
128
|
- Loop vars: `each_order`, `each_user` (prefix `each_`)
|
|
127
|
-
- Booleans: `is_valid`, `has_permission`, `should_retry` (prefix `is_`/`has_`/`should_`/`can_`)
|
|
129
|
+
- Booleans: `is_valid`, `has_permission`, `should_retry`, `was_clicked`, `did_succeed` (prefix `is_`/`has_`/`should_`/`can_`/`was_`/`did_`). The hook covers both boolean assignments and boolean-typed function parameters (a parameter annotated `bool` or defaulting to a boolean literal); `self`/`cls` and single-character names are exempt.
|
|
128
130
|
- Collections: `all_orders`, `all_users` (prefix `all_`)
|
|
129
131
|
- Maps: `price_by_product`, `user_by_id` (pattern `X_by_Y`)
|
|
130
132
|
- Preposition params: `from_path=`, `to=`, `into=`
|
|
@@ -400,6 +402,9 @@ Hook will enforce:
|
|
|
400
402
|
[⚡] No test-mode branching in production (TESTING / PYTEST_CURRENT_TEST)
|
|
401
403
|
[⚡] No thin wrapper modules (imports only, optionally with __all__, outside __init__.py)
|
|
402
404
|
[⚡] Public functions have Google-style Args:/Returns:/Raises: when warranted
|
|
405
|
+
[⚡] Docstring Args: names match the signature (a stale renamed param is flagged)
|
|
406
|
+
[⚡] Boolean names prefixed is_/has_/should_/can_/was_/did_ (assignments AND bool-typed parameters)
|
|
407
|
+
[⚡] No discarded must-check return (assign and check find_and_click/write_outcome outcomes)
|
|
403
408
|
|
|
404
409
|
Manual check:
|
|
405
410
|
[ ] No abbreviations?
|
|
@@ -91,7 +91,9 @@ from hooks_constants.blocking_check_limits import ( # noqa: E402
|
|
|
91
91
|
MAX_BANNED_PREFIX_ISSUES,
|
|
92
92
|
MAX_BARE_EXCEPT_ISSUES,
|
|
93
93
|
MAX_BOUNDARY_TYPE_ISSUES,
|
|
94
|
+
MAX_DOCSTRING_ARGS_SIGNATURE_ISSUES,
|
|
94
95
|
MAX_DOCSTRING_FORMAT_ISSUES,
|
|
96
|
+
MAX_IGNORED_MUST_CHECK_RETURN_ISSUES,
|
|
95
97
|
MAX_STUB_IMPLEMENTATION_ISSUES,
|
|
96
98
|
MAX_TEST_BRANCHING_ISSUES,
|
|
97
99
|
MAX_TYPED_DICT_PAIR_ISSUES,
|
|
@@ -132,6 +134,10 @@ from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
|
|
|
132
134
|
BANNED_NOUN_SPAN_FRAGMENT_TEMPLATE,
|
|
133
135
|
BARE_EACH_TOKEN,
|
|
134
136
|
ALL_BOOLEAN_NAME_PREFIXES,
|
|
137
|
+
ALL_DOCSTRING_ARGS_SECTION_HEADERS,
|
|
138
|
+
ALL_DOCSTRING_TERMINATING_SECTION_HEADERS,
|
|
139
|
+
DOCSTRING_ARG_ENTRY_PATTERN,
|
|
140
|
+
ALL_MUST_CHECK_RETURN_FUNCTION_NAMES,
|
|
135
141
|
ALL_BUILTIN_DICT_METHOD_NAMES,
|
|
136
142
|
ALL_CLI_FILE_PATH_MARKERS,
|
|
137
143
|
CHAINED_INLINE_COMMENT_PATTERN,
|
|
@@ -2092,6 +2098,110 @@ def check_docstring_format(content: str, file_path: str) -> list[str]:
|
|
|
2092
2098
|
return issues[:MAX_DOCSTRING_FORMAT_ISSUES]
|
|
2093
2099
|
|
|
2094
2100
|
|
|
2101
|
+
def _signature_parameter_names(
|
|
2102
|
+
function_node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
2103
|
+
) -> set[str]:
|
|
2104
|
+
arguments = function_node.args
|
|
2105
|
+
real_names: set[str] = set()
|
|
2106
|
+
for each_argument in arguments.posonlyargs + arguments.args + arguments.kwonlyargs:
|
|
2107
|
+
real_names.add(each_argument.arg)
|
|
2108
|
+
if arguments.vararg is not None:
|
|
2109
|
+
real_names.add(arguments.vararg.arg)
|
|
2110
|
+
if arguments.kwarg is not None:
|
|
2111
|
+
real_names.add(arguments.kwarg.arg)
|
|
2112
|
+
return real_names - ALL_SELF_AND_CLS_PARAMETER_NAMES
|
|
2113
|
+
|
|
2114
|
+
|
|
2115
|
+
def _is_docstring_terminating_section_header(stripped_line: str) -> bool:
|
|
2116
|
+
return stripped_line in ALL_DOCSTRING_TERMINATING_SECTION_HEADERS
|
|
2117
|
+
|
|
2118
|
+
|
|
2119
|
+
def _documented_argument_names(docstring_text: str) -> list[str]:
|
|
2120
|
+
docstring_lines = docstring_text.splitlines()
|
|
2121
|
+
args_section_index = _find_args_section_index(docstring_lines)
|
|
2122
|
+
if args_section_index is None:
|
|
2123
|
+
return []
|
|
2124
|
+
documented_names: list[str] = []
|
|
2125
|
+
entry_indent: int | None = None
|
|
2126
|
+
for each_line in docstring_lines[args_section_index + 1:]:
|
|
2127
|
+
stripped_line = each_line.strip()
|
|
2128
|
+
if not stripped_line:
|
|
2129
|
+
continue
|
|
2130
|
+
if _is_docstring_terminating_section_header(stripped_line):
|
|
2131
|
+
break
|
|
2132
|
+
current_indent = len(each_line) - len(each_line.lstrip())
|
|
2133
|
+
if current_indent == 0:
|
|
2134
|
+
break
|
|
2135
|
+
if entry_indent is None:
|
|
2136
|
+
entry_indent = current_indent
|
|
2137
|
+
if current_indent > entry_indent:
|
|
2138
|
+
continue
|
|
2139
|
+
entry_match = DOCSTRING_ARG_ENTRY_PATTERN.match(stripped_line)
|
|
2140
|
+
if entry_match is not None:
|
|
2141
|
+
documented_names.append(entry_match.group(1))
|
|
2142
|
+
return documented_names
|
|
2143
|
+
|
|
2144
|
+
|
|
2145
|
+
def _find_args_section_index(all_docstring_lines: list[str]) -> int | None:
|
|
2146
|
+
for each_line_index, each_line in enumerate(all_docstring_lines):
|
|
2147
|
+
if each_line.strip() in ALL_DOCSTRING_ARGS_SECTION_HEADERS:
|
|
2148
|
+
return each_line_index
|
|
2149
|
+
return None
|
|
2150
|
+
|
|
2151
|
+
|
|
2152
|
+
def check_docstring_args_match_signature(content: str, file_path: str) -> list[str]:
|
|
2153
|
+
"""Flag docstring Args: entries naming a parameter the signature lacks.
|
|
2154
|
+
|
|
2155
|
+
A fix that renames a parameter often leaves the adjacent ``Args:`` line
|
|
2156
|
+
stale. Each documented argument name is compared to the real signature;
|
|
2157
|
+
a documented name with no matching parameter is reported. Only the
|
|
2158
|
+
``Args:`` section is validated — ``Raises:`` is left alone because
|
|
2159
|
+
callee-propagated exceptions cause false positives. Functions that
|
|
2160
|
+
accept ``**kwargs`` are skipped because their documented names may be
|
|
2161
|
+
keyword keys the signature cannot enumerate.
|
|
2162
|
+
|
|
2163
|
+
Args:
|
|
2164
|
+
content: The source text to inspect.
|
|
2165
|
+
file_path: The path the source will be written to, used for exemptions.
|
|
2166
|
+
|
|
2167
|
+
Returns:
|
|
2168
|
+
One issue per stale documented argument, capped at the module limit.
|
|
2169
|
+
"""
|
|
2170
|
+
if is_test_file(file_path) or is_hook_infrastructure(file_path):
|
|
2171
|
+
return []
|
|
2172
|
+
try:
|
|
2173
|
+
parsed_tree = ast.parse(content)
|
|
2174
|
+
except SyntaxError:
|
|
2175
|
+
return []
|
|
2176
|
+
issues: list[str] = []
|
|
2177
|
+
for each_node in _walk_skipping_type_checking_blocks(parsed_tree):
|
|
2178
|
+
if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
2179
|
+
continue
|
|
2180
|
+
if _function_is_private_or_dunder(each_node.name):
|
|
2181
|
+
continue
|
|
2182
|
+
if _function_has_exempt_decorator(each_node):
|
|
2183
|
+
continue
|
|
2184
|
+
if _function_body_line_count(each_node) <= DOCSTRING_TRIVIAL_FUNCTION_BODY_LINE_LIMIT:
|
|
2185
|
+
continue
|
|
2186
|
+
if each_node.args.kwarg is not None:
|
|
2187
|
+
continue
|
|
2188
|
+
documented_names = _documented_argument_names(_function_docstring_text(each_node))
|
|
2189
|
+
if not documented_names:
|
|
2190
|
+
continue
|
|
2191
|
+
real_names = _signature_parameter_names(each_node)
|
|
2192
|
+
for each_documented_name in documented_names:
|
|
2193
|
+
if each_documented_name in real_names:
|
|
2194
|
+
continue
|
|
2195
|
+
issues.append(
|
|
2196
|
+
f"Line {each_node.lineno}: {each_node.name}() docstring Args: lists "
|
|
2197
|
+
f"'{each_documented_name}' which is not a parameter - update the "
|
|
2198
|
+
"docstring to match the signature"
|
|
2199
|
+
)
|
|
2200
|
+
if len(issues) >= MAX_DOCSTRING_ARGS_SIGNATURE_ISSUES:
|
|
2201
|
+
return issues[:MAX_DOCSTRING_ARGS_SIGNATURE_ISSUES]
|
|
2202
|
+
return issues[:MAX_DOCSTRING_ARGS_SIGNATURE_ISSUES]
|
|
2203
|
+
|
|
2204
|
+
|
|
2095
2205
|
_PASCAL_TO_SNAKE_WORD_BOUNDARY = re.compile(r"(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])")
|
|
2096
2206
|
|
|
2097
2207
|
|
|
@@ -2440,8 +2550,89 @@ def _collect_boolean_assignments(tree: ast.Module) -> list[tuple[str, int, bool]
|
|
|
2440
2550
|
return collected
|
|
2441
2551
|
|
|
2442
2552
|
|
|
2443
|
-
def
|
|
2444
|
-
|
|
2553
|
+
def _argument_is_boolean(argument_node: ast.arg, default_node: ast.expr | None) -> bool:
|
|
2554
|
+
annotation_is_bool = (
|
|
2555
|
+
isinstance(argument_node.annotation, ast.Name)
|
|
2556
|
+
and argument_node.annotation.id == "bool"
|
|
2557
|
+
)
|
|
2558
|
+
default_is_bool = default_node is not None and _is_bool_constant(default_node)
|
|
2559
|
+
return annotation_is_bool or default_is_bool
|
|
2560
|
+
|
|
2561
|
+
|
|
2562
|
+
def _bool_parameters_for_function(
|
|
2563
|
+
function_node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
2564
|
+
) -> list[tuple[str, int]]:
|
|
2565
|
+
arguments = function_node.args
|
|
2566
|
+
positional_arguments = arguments.posonlyargs + arguments.args
|
|
2567
|
+
positional_defaults = arguments.defaults
|
|
2568
|
+
leading_without_default = len(positional_arguments) - len(positional_defaults)
|
|
2569
|
+
bool_parameters: list[tuple[str, int]] = []
|
|
2570
|
+
for each_position, each_argument in enumerate(positional_arguments):
|
|
2571
|
+
default_index = each_position - leading_without_default
|
|
2572
|
+
default_node = (
|
|
2573
|
+
positional_defaults[default_index] if default_index >= 0 else None
|
|
2574
|
+
)
|
|
2575
|
+
if each_argument.arg in ALL_SELF_AND_CLS_PARAMETER_NAMES:
|
|
2576
|
+
continue
|
|
2577
|
+
if _argument_is_boolean(each_argument, default_node):
|
|
2578
|
+
bool_parameters.append((each_argument.arg, each_argument.lineno))
|
|
2579
|
+
for each_argument, each_default in zip(arguments.kwonlyargs, arguments.kw_defaults):
|
|
2580
|
+
if each_argument.arg in ALL_SELF_AND_CLS_PARAMETER_NAMES:
|
|
2581
|
+
continue
|
|
2582
|
+
if _argument_is_boolean(each_argument, each_default):
|
|
2583
|
+
bool_parameters.append((each_argument.arg, each_argument.lineno))
|
|
2584
|
+
return bool_parameters
|
|
2585
|
+
|
|
2586
|
+
|
|
2587
|
+
def _collect_bool_parameter_names(tree: ast.Module) -> list[tuple[str, int]]:
|
|
2588
|
+
"""Collect (name, line_number) for boolean-typed function parameters.
|
|
2589
|
+
|
|
2590
|
+
A parameter counts as boolean when its annotation is the ``bool`` name or
|
|
2591
|
+
its default is a boolean literal. ``self`` and ``cls`` are skipped.
|
|
2592
|
+
|
|
2593
|
+
Args:
|
|
2594
|
+
tree: The parsed module to inspect.
|
|
2595
|
+
|
|
2596
|
+
Returns:
|
|
2597
|
+
Each boolean parameter as a (name, line_number) pair.
|
|
2598
|
+
"""
|
|
2599
|
+
bool_parameters: list[tuple[str, int]] = []
|
|
2600
|
+
for each_node in ast.walk(tree):
|
|
2601
|
+
if isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
2602
|
+
bool_parameters.extend(_bool_parameters_for_function(each_node))
|
|
2603
|
+
return bool_parameters
|
|
2604
|
+
|
|
2605
|
+
|
|
2606
|
+
def check_boolean_naming(
|
|
2607
|
+
content: str,
|
|
2608
|
+
file_path: str,
|
|
2609
|
+
all_changed_lines: set[int] | None = None,
|
|
2610
|
+
defer_scope_to_caller: bool = False,
|
|
2611
|
+
) -> list[str]:
|
|
2612
|
+
"""Flag boolean assignments and parameters whose name lacks a required prefix.
|
|
2613
|
+
|
|
2614
|
+
The caller passes the reconstructed full file as *content* so ``ast.parse``
|
|
2615
|
+
sees a complete module rather than an Edit's ``new_string`` fragment, which is
|
|
2616
|
+
rarely valid standalone Python. Findings are then scoped to *all_changed_lines*
|
|
2617
|
+
so an Edit blocks on the unprefixed boolean it just introduced while a
|
|
2618
|
+
pre-existing violation on an untouched line does not block the edit.
|
|
2619
|
+
|
|
2620
|
+
Args:
|
|
2621
|
+
content: The source text to inspect — the reconstructed full file on an
|
|
2622
|
+
Edit so the parse succeeds.
|
|
2623
|
+
file_path: The path the source will be written to, used for exemptions.
|
|
2624
|
+
all_changed_lines: Post-edit line numbers the current edit touched, or
|
|
2625
|
+
None to treat the whole file as in scope. When provided, a violation
|
|
2626
|
+
blocks only when its source line intersects the changed lines.
|
|
2627
|
+
defer_scope_to_caller: When True, return every violation so the
|
|
2628
|
+
commit/push gate's ``split_violations_by_scope`` can scope by added
|
|
2629
|
+
line.
|
|
2630
|
+
|
|
2631
|
+
Returns:
|
|
2632
|
+
One issue per unprefixed boolean assignment and parameter, scoped to the
|
|
2633
|
+
changed lines unless *defer_scope_to_caller* is True or *all_changed_lines*
|
|
2634
|
+
is None. This check has no module cap.
|
|
2635
|
+
"""
|
|
2445
2636
|
if is_test_file(file_path):
|
|
2446
2637
|
return []
|
|
2447
2638
|
if is_hook_infrastructure(file_path):
|
|
@@ -2459,20 +2650,125 @@ def check_boolean_naming(content: str, file_path: str) -> list[str]:
|
|
|
2459
2650
|
file=sys.stderr,
|
|
2460
2651
|
)
|
|
2461
2652
|
return []
|
|
2462
|
-
|
|
2463
|
-
for
|
|
2464
|
-
if len(
|
|
2653
|
+
all_violations_in_walk_order: list[tuple[range, str]] = []
|
|
2654
|
+
for each_name, each_line_number, each_is_in_upper_snake_scope in _collect_boolean_assignments(tree):
|
|
2655
|
+
if len(each_name) == 1:
|
|
2465
2656
|
continue
|
|
2466
|
-
if
|
|
2657
|
+
if each_is_in_upper_snake_scope and UPPER_SNAKE_CONSTANT_PATTERN.match(each_name):
|
|
2467
2658
|
continue
|
|
2468
|
-
if
|
|
2659
|
+
if each_name.startswith(ALL_BOOLEAN_NAME_PREFIXES):
|
|
2469
2660
|
continue
|
|
2470
|
-
|
|
2471
|
-
f"Line {
|
|
2661
|
+
message = (
|
|
2662
|
+
f"Line {each_line_number}: Boolean {each_name} - prefix with "
|
|
2663
|
+
"is_/has_/should_/can_/was_/did_"
|
|
2472
2664
|
)
|
|
2473
|
-
|
|
2665
|
+
all_violations_in_walk_order.append(
|
|
2666
|
+
(range(each_line_number, each_line_number + 1), message)
|
|
2667
|
+
)
|
|
2668
|
+
for each_name, each_line_number in _collect_bool_parameter_names(tree):
|
|
2669
|
+
if len(each_name) == 1:
|
|
2670
|
+
continue
|
|
2671
|
+
if each_name.startswith(ALL_BOOLEAN_NAME_PREFIXES):
|
|
2672
|
+
continue
|
|
2673
|
+
message = (
|
|
2674
|
+
f"Line {each_line_number}: Boolean parameter {each_name} - prefix with "
|
|
2675
|
+
"is_/has_/should_/can_/was_/did_"
|
|
2676
|
+
)
|
|
2677
|
+
all_violations_in_walk_order.append(
|
|
2678
|
+
(range(each_line_number, each_line_number + 1), message)
|
|
2679
|
+
)
|
|
2680
|
+
return _scope_violations_to_changed_lines(
|
|
2681
|
+
all_violations_in_walk_order,
|
|
2682
|
+
all_changed_lines,
|
|
2683
|
+
defer_scope_to_caller,
|
|
2684
|
+
)
|
|
2474
2685
|
|
|
2475
2686
|
|
|
2687
|
+
def _called_terminal_name(call_node: ast.Call) -> str | None:
|
|
2688
|
+
callee = call_node.func
|
|
2689
|
+
if isinstance(callee, ast.Name):
|
|
2690
|
+
return callee.id
|
|
2691
|
+
if isinstance(callee, ast.Attribute):
|
|
2692
|
+
return callee.attr
|
|
2693
|
+
return None
|
|
2694
|
+
|
|
2695
|
+
|
|
2696
|
+
def check_ignored_must_check_return(
|
|
2697
|
+
content: str,
|
|
2698
|
+
file_path: str,
|
|
2699
|
+
all_changed_lines: set[int] | None = None,
|
|
2700
|
+
defer_scope_to_caller: bool = False,
|
|
2701
|
+
) -> list[str]:
|
|
2702
|
+
"""Flag bare-expression calls whose discarded return is the only failure signal.
|
|
2703
|
+
|
|
2704
|
+
Functions in ``ALL_MUST_CHECK_RETURN_FUNCTION_NAMES`` report success or failure
|
|
2705
|
+
solely through their return value. A bare-statement call discards that value,
|
|
2706
|
+
so the caller silently proceeds on failure. Bare ``ast.Expr`` calls are flagged,
|
|
2707
|
+
including a bare ``await``-wrapped call (``await find_and_click(...)`` as a
|
|
2708
|
+
statement); an assigned or branched-on call is exempt.
|
|
2709
|
+
|
|
2710
|
+
The caller passes the reconstructed full file as *content* so ``ast.parse``
|
|
2711
|
+
sees a complete module rather than an Edit's ``new_string`` fragment, which is
|
|
2712
|
+
rarely valid standalone Python (a bare ``await find_and_click(...)`` line is a
|
|
2713
|
+
SyntaxError on its own). Findings are then scoped to *all_changed_lines* so an
|
|
2714
|
+
Edit blocks on the discarded return it just introduced while a pre-existing
|
|
2715
|
+
violation on an untouched line does not block the edit.
|
|
2716
|
+
|
|
2717
|
+
Args:
|
|
2718
|
+
content: The source text to inspect — the reconstructed full file on an
|
|
2719
|
+
Edit so the parse succeeds.
|
|
2720
|
+
file_path: The path the source will be written to, used for exemptions.
|
|
2721
|
+
all_changed_lines: Post-edit line numbers the current edit touched, or
|
|
2722
|
+
None to treat the whole file as in scope. When provided, a violation
|
|
2723
|
+
blocks only when the bare call's line intersects the changed lines.
|
|
2724
|
+
defer_scope_to_caller: When True, return every violation so the
|
|
2725
|
+
commit/push gate's ``split_violations_by_scope`` can scope by added
|
|
2726
|
+
line.
|
|
2727
|
+
|
|
2728
|
+
Returns:
|
|
2729
|
+
One issue per discarded must-check return, scoped to the changed lines
|
|
2730
|
+
unless *defer_scope_to_caller* is True or *all_changed_lines* is None. When
|
|
2731
|
+
*defer_scope_to_caller* is True every violation is returned uncapped so the
|
|
2732
|
+
gate can scope by added line and apply its own ceiling; otherwise the
|
|
2733
|
+
terminal result is capped at the module limit.
|
|
2734
|
+
"""
|
|
2735
|
+
if is_test_file(file_path):
|
|
2736
|
+
return []
|
|
2737
|
+
try:
|
|
2738
|
+
tree = ast.parse(content)
|
|
2739
|
+
except SyntaxError:
|
|
2740
|
+
return []
|
|
2741
|
+
all_violations_in_walk_order: list[tuple[range, str]] = []
|
|
2742
|
+
for each_node in ast.walk(tree):
|
|
2743
|
+
if not isinstance(each_node, ast.Expr):
|
|
2744
|
+
continue
|
|
2745
|
+
expression_value = each_node.value
|
|
2746
|
+
call_node = (
|
|
2747
|
+
expression_value.value
|
|
2748
|
+
if isinstance(expression_value, ast.Await)
|
|
2749
|
+
else expression_value
|
|
2750
|
+
)
|
|
2751
|
+
if not isinstance(call_node, ast.Call):
|
|
2752
|
+
continue
|
|
2753
|
+
called_name = _called_terminal_name(call_node)
|
|
2754
|
+
if called_name is None or called_name not in ALL_MUST_CHECK_RETURN_FUNCTION_NAMES:
|
|
2755
|
+
continue
|
|
2756
|
+
end_line_number = each_node.end_lineno or each_node.lineno
|
|
2757
|
+
line_span = range(each_node.lineno, end_line_number + 1)
|
|
2758
|
+
message = (
|
|
2759
|
+
f"Line {each_node.lineno}: return value of {called_name}() is discarded - "
|
|
2760
|
+
"assign and check it (the boolean/outcome is the only failure signal)"
|
|
2761
|
+
)
|
|
2762
|
+
all_violations_in_walk_order.append((line_span, message))
|
|
2763
|
+
scoped_issues = _scope_violations_to_changed_lines(
|
|
2764
|
+
all_violations_in_walk_order,
|
|
2765
|
+
all_changed_lines,
|
|
2766
|
+
defer_scope_to_caller,
|
|
2767
|
+
)
|
|
2768
|
+
if defer_scope_to_caller:
|
|
2769
|
+
return scoped_issues
|
|
2770
|
+
return scoped_issues[:MAX_IGNORED_MUST_CHECK_RETURN_ISSUES]
|
|
2771
|
+
|
|
2476
2772
|
|
|
2477
2773
|
def _decorator_name_contains_skip(decorator_node: ast.expr) -> bool:
|
|
2478
2774
|
"""Return True when a decorator AST node references an identifier containing 'skip'."""
|
|
@@ -5570,7 +5866,23 @@ def validate_content(
|
|
|
5570
5866
|
all_issues.extend(check_thin_wrapper_files(effective_content, file_path))
|
|
5571
5867
|
all_issues.extend(check_boundary_types(effective_content, file_path))
|
|
5572
5868
|
all_issues.extend(check_docstring_format(effective_content, file_path))
|
|
5573
|
-
all_issues.extend(
|
|
5869
|
+
all_issues.extend(check_docstring_args_match_signature(effective_content, file_path))
|
|
5870
|
+
all_issues.extend(
|
|
5871
|
+
check_boolean_naming(
|
|
5872
|
+
effective_content,
|
|
5873
|
+
file_path,
|
|
5874
|
+
all_changed_lines,
|
|
5875
|
+
defer_scope_to_caller,
|
|
5876
|
+
)
|
|
5877
|
+
)
|
|
5878
|
+
all_issues.extend(
|
|
5879
|
+
check_ignored_must_check_return(
|
|
5880
|
+
effective_content,
|
|
5881
|
+
file_path,
|
|
5882
|
+
all_changed_lines,
|
|
5883
|
+
defer_scope_to_caller,
|
|
5884
|
+
)
|
|
5885
|
+
)
|
|
5574
5886
|
all_issues.extend(check_skip_decorators_in_tests(content, file_path))
|
|
5575
5887
|
all_issues.extend(
|
|
5576
5888
|
check_tests_use_isolated_filesystem_paths(
|
|
@@ -68,7 +68,7 @@ def _block_context() -> str:
|
|
|
68
68
|
f"- Files under {_exempt_plugin_segments_summary} directories\n"
|
|
69
69
|
f"- Files under {_claude_dev_env_source_directories_summary} source directories\n"
|
|
70
70
|
f"- Files under any directory whose ancestor contains {PLUGIN_ROOT_MARKER_DIRECTORY_NAME}/\n"
|
|
71
|
-
"- README.md and
|
|
71
|
+
"- README.md, CHANGELOG.md, CLAUDE.md, and AGENTS.md at any repo root\n"
|
|
72
72
|
f"- Files under {_exempt_home_directories_summary}\n"
|
|
73
73
|
"- Files under the OS temp directory"
|
|
74
74
|
)
|
|
@@ -83,7 +83,7 @@ def _block_system_message() -> str:
|
|
|
83
83
|
f"{_exempt_anywhere_filenames_summary} anywhere, {_exempt_plugin_segments_summary} trees, "
|
|
84
84
|
f"{_claude_dev_env_source_directories_summary} source trees, "
|
|
85
85
|
f"files under a {PLUGIN_ROOT_MARKER_DIRECTORY_NAME}/ root, "
|
|
86
|
-
f"README.md/CHANGELOG.md at any repo root, {_exempt_home_directories_summary}, "
|
|
86
|
+
f"README.md/CHANGELOG.md/CLAUDE.md/AGENTS.md at any repo root, {_exempt_home_directories_summary}, "
|
|
87
87
|
"and the OS temp directory."
|
|
88
88
|
)
|
|
89
89
|
|
|
@@ -2602,3 +2602,68 @@ def test_banned_noun_word_boundary_flags_plural_results_identifier() -> None:
|
|
|
2602
2602
|
"a plural banned-noun identifier must be flagged by the word-boundary "
|
|
2603
2603
|
f"check; got: {issues!r}"
|
|
2604
2604
|
)
|
|
2605
|
+
|
|
2606
|
+
|
|
2607
|
+
def test_ignored_must_check_return_flags_bare_awaited_call() -> None:
|
|
2608
|
+
"""A bare ``await find_and_click(...)`` statement discards its only failure signal.
|
|
2609
|
+
|
|
2610
|
+
The curated must-check functions are async, so the common real call site is a
|
|
2611
|
+
bare ``await``-wrapped call. Unwrapping ``ast.Await`` before the Call check is
|
|
2612
|
+
required for this case to be flagged.
|
|
2613
|
+
"""
|
|
2614
|
+
source = "async def step() -> None:\n await find_and_click('#x')\n"
|
|
2615
|
+
issues = code_rules_enforcer.check_ignored_must_check_return(
|
|
2616
|
+
source, "/project/src/clicker.py"
|
|
2617
|
+
)
|
|
2618
|
+
assert any("find_and_click" in each_issue for each_issue in issues), (
|
|
2619
|
+
f"a bare awaited must-check call must be flagged; got: {issues!r}"
|
|
2620
|
+
)
|
|
2621
|
+
assert len(issues) == 1
|
|
2622
|
+
|
|
2623
|
+
|
|
2624
|
+
def test_ignored_must_check_return_exempts_consumed_awaited_call() -> None:
|
|
2625
|
+
"""An assigned or branched-on awaited must-check call consumes its outcome."""
|
|
2626
|
+
assigned = "async def step() -> None:\n clicked = await find_and_click('#x')\n print(clicked)\n"
|
|
2627
|
+
branched = "async def step() -> None:\n if await find_and_click('#x'):\n pass\n"
|
|
2628
|
+
assert (
|
|
2629
|
+
code_rules_enforcer.check_ignored_must_check_return(assigned, "/project/src/clicker.py")
|
|
2630
|
+
== []
|
|
2631
|
+
)
|
|
2632
|
+
assert (
|
|
2633
|
+
code_rules_enforcer.check_ignored_must_check_return(branched, "/project/src/clicker.py")
|
|
2634
|
+
== []
|
|
2635
|
+
)
|
|
2636
|
+
|
|
2637
|
+
|
|
2638
|
+
def test_ignored_must_check_return_flags_edited_line_past_a_cap_of_earlier_violations() -> None:
|
|
2639
|
+
"""The cap must apply after scoping so the edited-line violation is never dropped.
|
|
2640
|
+
|
|
2641
|
+
Collecting only a cap's worth of violations in ``ast.walk`` order, then scoping,
|
|
2642
|
+
fills the cap with earlier out-of-scope calls and discards the edited-line one —
|
|
2643
|
+
the very violation the scoped enforcer exists to block. Every violation must be
|
|
2644
|
+
collected before scoping so the edited line survives the diff filter.
|
|
2645
|
+
"""
|
|
2646
|
+
pre_existing_call_count = 5
|
|
2647
|
+
edited_call_line_number = pre_existing_call_count + 2
|
|
2648
|
+
all_pre_existing_call_lines = [
|
|
2649
|
+
f" await find_and_click('#x{each_index}')"
|
|
2650
|
+
for each_index in range(pre_existing_call_count)
|
|
2651
|
+
]
|
|
2652
|
+
all_lines = (
|
|
2653
|
+
["async def step() -> None:"]
|
|
2654
|
+
+ all_pre_existing_call_lines
|
|
2655
|
+
+ [" await find_and_click('#edited')"]
|
|
2656
|
+
)
|
|
2657
|
+
source = "\n".join(all_lines) + "\n"
|
|
2658
|
+
issues = code_rules_enforcer.check_ignored_must_check_return(
|
|
2659
|
+
source,
|
|
2660
|
+
"/project/src/clicker.py",
|
|
2661
|
+
{edited_call_line_number},
|
|
2662
|
+
False,
|
|
2663
|
+
)
|
|
2664
|
+
assert len(issues) == 1, (
|
|
2665
|
+
f"the edited-line violation must survive a cap's worth of earlier calls; got: {issues!r}"
|
|
2666
|
+
)
|
|
2667
|
+
assert f"Line {edited_call_line_number}:" in issues[0], (
|
|
2668
|
+
f"the single issue must name the edited line {edited_call_line_number}; got: {issues!r}"
|
|
2669
|
+
)
|