claude-dev-env 1.77.0 → 1.78.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/hooks/blocking/CLAUDE.md +3 -1
- package/hooks/blocking/code_rules_dead_module_constant.py +215 -59
- package/hooks/blocking/code_rules_dead_split_branch.py +225 -0
- package/hooks/blocking/code_rules_docstrings.py +352 -0
- package/hooks/blocking/code_rules_enforcer.py +27 -0
- package/hooks/blocking/code_rules_paired_test.py +299 -0
- package/hooks/blocking/code_rules_string_magic.py +71 -1
- package/hooks/blocking/convergence_gate_blocker.py +24 -15
- package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +89 -2
- package/hooks/blocking/test_code_rules_enforcer_dead_split_branch.py +105 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_mark_glyph_enumeration.py +262 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_raises_largezipfile.py +226 -0
- package/hooks/blocking/test_code_rules_enforcer_paired_test.py +149 -0
- package/hooks/blocking/test_code_rules_enforcer_whitespace_indentation_magic.py +74 -0
- package/hooks/blocking/test_convergence_gate_blocker.py +71 -0
- package/hooks/hooks_constants/CLAUDE.md +1 -0
- package/hooks/hooks_constants/blocking_check_limits.py +24 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +14 -0
- package/hooks/hooks_constants/paired_test_coverage_constants.py +27 -0
- package/package.json +1 -1
- package/rules/CLAUDE.md +1 -0
- package/rules/docstring-prose-matches-implementation.md +2 -1
- package/rules/file-global-constants.md +2 -2
- package/rules/paired-test-coverage.md +28 -0
package/hooks/blocking/CLAUDE.md
CHANGED
|
@@ -29,6 +29,7 @@ The check modules it calls are the `code_rules_<concern>.py` files below.
|
|
|
29
29
|
| `code_rules_dead_config_field.py` | `*Config` / `*Selectors` dataclass fields with no live references |
|
|
30
30
|
| `code_rules_dead_dataclass_field.py` | Dataclass fields with no consuming references |
|
|
31
31
|
| `code_rules_dead_module_constant.py` | `UPPER_SNAKE` constants in `*_constants.py` modules with no importers |
|
|
32
|
+
| `code_rules_dead_split_branch.py` | A conditional whose falsy branch is unreachable because the tested value comes from a separator `str.split()`, which never returns an empty list |
|
|
32
33
|
| `code_rules_docstrings.py` | Google-style docstrings; `Args:` section matches signature; fallback-branch coverage |
|
|
33
34
|
| `code_rules_duplicate_body.py` | A function body copied from a sibling module, or a helper body inlined as a block inside a larger function in the same file |
|
|
34
35
|
| `code_rules_imports_logging.py` | Imports at top of file; logging format-arg style; printf tokens in `str.format`-logger messages |
|
|
@@ -37,6 +38,7 @@ The check modules it calls are the `code_rules_<concern>.py` files below.
|
|
|
37
38
|
| `code_rules_naming_collection.py` | Collection names must use `all_*` prefix |
|
|
38
39
|
| `code_rules_optional_params.py` | No optional parameters where a required one would do |
|
|
39
40
|
| `code_rules_orphan_css_class.py` | CSS class attributes in Python markup with no matching `.<class>` selector |
|
|
41
|
+
| `code_rules_paired_test.py` | A public function omitted by a module's established paired test suite must get a behavioral test |
|
|
40
42
|
| `code_rules_path_utils.py` | Path utility helpers shared across check modules |
|
|
41
43
|
| `code_rules_paths_syspath.py` | `sys.path.insert` must be guarded |
|
|
42
44
|
| `code_rules_probe_chains.py` | Probe-chain detection logic |
|
|
@@ -44,7 +46,7 @@ The check modules it calls are the `code_rules_<concern>.py` files below.
|
|
|
44
46
|
| `code_rules_probe_recording.py` | Probe recording utilities |
|
|
45
47
|
| `code_rules_scope_binding.py` | Scope/binding analysis utilities |
|
|
46
48
|
| `code_rules_shared.py` | Shared dataclasses and helpers used by multiple check modules |
|
|
47
|
-
| `code_rules_string_magic.py` | Magic string detection with masking and f-string support |
|
|
49
|
+
| `code_rules_string_magic.py` | Magic string detection with masking and f-string support; whitespace-only indentation literals in function bodies |
|
|
48
50
|
| `code_rules_test_assertions.py` | Test assertion style rules |
|
|
49
51
|
| `code_rules_test_branching_except.py` | No bare or broad `except` in test branches |
|
|
50
52
|
| `code_rules_test_isolation.py` | Tests must not rely on home-dir or temp-dir side effects |
|
|
@@ -16,18 +16,26 @@ The scan is deliberately conservative to keep false positives near zero:
|
|
|
16
16
|
|
|
17
17
|
- Only dedicated constants modules participate; ordinary production modules,
|
|
18
18
|
whose file-global constants are governed by the use-count rule, are skipped.
|
|
19
|
-
- A module declaring ``__all__``
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
19
|
+
- A module declaring ``__all__`` narrows the check to the constants its
|
|
20
|
+
``__all__`` list names — the explicit export surface. Each exported constant
|
|
21
|
+
must be imported or read by some other module, since a name an author exports
|
|
22
|
+
yet no module consumes is dead by §9.8; the module's own ``__all__`` entry
|
|
23
|
+
never counts as that consumer. A constant the module defines but ``__all__``
|
|
24
|
+
omits is the author's stated private value and is left alone.
|
|
25
|
+
- A constant is live when its name appears in another ``.py`` module the scan
|
|
26
|
+
reaches — imported, read, listed in that module's ``__all__``, or referenced
|
|
27
|
+
in a string annotation — or when the constants module itself reads it in code;
|
|
28
|
+
a name listed only in the constants module's own ``__all__`` does not keep an
|
|
29
|
+
exported constant live.
|
|
26
30
|
- When the package-tree scan leaves a constant unreferenced, the scan widens to
|
|
27
|
-
the repository root (the nearest ``.git`` ancestor)
|
|
28
|
-
tree
|
|
29
|
-
|
|
30
|
-
|
|
31
|
+
the repository root (the nearest ``.git`` ancestor). The widened pass counts a
|
|
32
|
+
sibling-tree reference only when a module imports the name through a
|
|
33
|
+
``from <module> import`` whose final dotted segment equals the written
|
|
34
|
+
module's filename stem, so a genuine cross-tree consumer of this constants
|
|
35
|
+
module keeps the constant live while a same-named constant exported by an
|
|
36
|
+
unrelated module never masks a dead one. A module outside any repository is
|
|
37
|
+
judged on the package-tree scan alone, and the widened pass skips the package
|
|
38
|
+
subtree the first pass already covered, so no file is read twice.
|
|
31
39
|
- The combined file count of the package-tree and widened passes is bounded by a
|
|
32
40
|
cap, so a write under an unexpectedly large tree cannot stall the hook; a write
|
|
33
41
|
whose scan hits the cap is treated as "cannot prove dead" and flags nothing.
|
|
@@ -38,6 +46,8 @@ The scan is deliberately conservative to keep false positives near zero:
|
|
|
38
46
|
import ast
|
|
39
47
|
import os
|
|
40
48
|
import sys
|
|
49
|
+
from collections.abc import Callable
|
|
50
|
+
from functools import partial
|
|
41
51
|
from pathlib import Path
|
|
42
52
|
|
|
43
53
|
_blocking_directory = str(Path(__file__).resolve().parent)
|
|
@@ -146,21 +156,61 @@ def _module_declares_dunder_all(tree: ast.Module) -> bool:
|
|
|
146
156
|
return any(_statement_binds_dunder_all(each_node) for each_node in tree.body)
|
|
147
157
|
|
|
148
158
|
|
|
149
|
-
def
|
|
159
|
+
def _dunder_all_member_names(tree: ast.Module) -> set[str]:
|
|
160
|
+
"""Return the string member names a module's ``__all__`` sequence lists.
|
|
161
|
+
|
|
162
|
+
Reads the value of each ``__all__`` assignment whose value is a list, tuple,
|
|
163
|
+
or set literal and collects every string element. A non-literal ``__all__``
|
|
164
|
+
value (built by concatenation or a comprehension) contributes no names, so a
|
|
165
|
+
constant the check cannot prove is exported stays out of the exported set.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
tree: The parsed constants module.
|
|
169
|
+
|
|
170
|
+
Returns:
|
|
171
|
+
The set of names the module names in its ``__all__`` literal.
|
|
172
|
+
"""
|
|
173
|
+
member_names: set[str] = set()
|
|
174
|
+
for each_statement in tree.body:
|
|
175
|
+
if not _statement_binds_dunder_all(each_statement):
|
|
176
|
+
continue
|
|
177
|
+
value_node: ast.expr | None = None
|
|
178
|
+
if isinstance(each_statement, ast.Assign):
|
|
179
|
+
value_node = each_statement.value
|
|
180
|
+
elif isinstance(each_statement, ast.AnnAssign):
|
|
181
|
+
value_node = each_statement.value
|
|
182
|
+
if not isinstance(value_node, ast.List | ast.Tuple | ast.Set):
|
|
183
|
+
continue
|
|
184
|
+
for each_element in value_node.elts:
|
|
185
|
+
if isinstance(each_element, ast.Constant) and isinstance(each_element.value, str):
|
|
186
|
+
member_names.add(each_element.value)
|
|
187
|
+
return member_names
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _referenced_names_in_source(
|
|
191
|
+
source: str,
|
|
192
|
+
load_only: bool = False,
|
|
193
|
+
collect_string_literals: bool = True,
|
|
194
|
+
) -> set[str]:
|
|
150
195
|
"""Return every name a module references — imported, read, or re-exported.
|
|
151
196
|
|
|
152
197
|
Collects imported binding names, ``from`` import member names, name
|
|
153
|
-
references, attribute roots, and
|
|
154
|
-
``__all__`` literal or named in a
|
|
155
|
-
A module that fails to parse
|
|
156
|
-
|
|
157
|
-
|
|
198
|
+
references, attribute roots, and (when ``collect_string_literals`` is set)
|
|
199
|
+
string literals, so a name listed in an ``__all__`` literal or named in a
|
|
200
|
+
string annotation counts as a reference. A module that fails to parse
|
|
201
|
+
contributes no names. With ``load_only`` set, only ``Load``-context names
|
|
202
|
+
count, so a constant's own assignment target in the module being judged does
|
|
203
|
+
not count as a reference to itself.
|
|
158
204
|
|
|
159
205
|
Args:
|
|
160
206
|
source: The full text of a ``.py`` module under the scan root.
|
|
161
207
|
load_only: When True, count only ``Load``-context name references,
|
|
162
208
|
excluding ``Store``/``Del`` targets. Used for the written constants
|
|
163
209
|
module so a definition is not mistaken for its own consumer.
|
|
210
|
+
collect_string_literals: When True, count every string literal as a
|
|
211
|
+
referenced name. Set False for the written module under an ``__all__``
|
|
212
|
+
export check so the module's own ``__all__`` entry never shields an
|
|
213
|
+
exported constant that no other module consumes.
|
|
164
214
|
|
|
165
215
|
Returns:
|
|
166
216
|
The set of names the module references.
|
|
@@ -179,11 +229,63 @@ def _referenced_names_in_source(source: str, load_only: bool = False) -> set[str
|
|
|
179
229
|
for each_alias in each_node.names:
|
|
180
230
|
referenced_names.add(each_alias.asname or each_alias.name)
|
|
181
231
|
referenced_names.add(each_alias.name)
|
|
182
|
-
elif
|
|
232
|
+
elif (
|
|
233
|
+
collect_string_literals
|
|
234
|
+
and isinstance(each_node, ast.Constant)
|
|
235
|
+
and isinstance(each_node.value, str)
|
|
236
|
+
):
|
|
183
237
|
referenced_names.add(each_node.value)
|
|
184
238
|
return referenced_names
|
|
185
239
|
|
|
186
240
|
|
|
241
|
+
def _module_final_segment(module_path: str | None) -> str:
|
|
242
|
+
"""Return the final dotted segment of an import module path.
|
|
243
|
+
|
|
244
|
+
Args:
|
|
245
|
+
module_path: The ``module`` attribute of a ``from ... import`` node, or
|
|
246
|
+
None for a bare relative import (``from . import x``).
|
|
247
|
+
|
|
248
|
+
Returns:
|
|
249
|
+
The text after the last dot, the whole string when it carries no dot, or
|
|
250
|
+
the empty string when ``module_path`` is None or empty.
|
|
251
|
+
"""
|
|
252
|
+
if not module_path:
|
|
253
|
+
return ""
|
|
254
|
+
return module_path.rsplit(".", 1)[-1]
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _qualified_import_member_names(source: str, module_stem: str) -> set[str]:
|
|
258
|
+
"""Return names imported from a module whose filename stem is ``module_stem``.
|
|
259
|
+
|
|
260
|
+
A cross-package consumer of an exported constant imports it through an
|
|
261
|
+
explicit ``from <module> import NAME`` whose module path ends in the defining
|
|
262
|
+
module's filename stem. Collecting only those member names binds a
|
|
263
|
+
widened-scan reference to the module that actually defines the constant, so a
|
|
264
|
+
same-named constant exported by an unrelated module never masks a dead one.
|
|
265
|
+
|
|
266
|
+
Args:
|
|
267
|
+
source: The full text of a ``.py`` module under the repository root.
|
|
268
|
+
module_stem: The filename stem of the constants module being judged.
|
|
269
|
+
|
|
270
|
+
Returns:
|
|
271
|
+
The member names imported from a module whose final dotted segment equals
|
|
272
|
+
``module_stem``. A module that fails to parse contributes no names.
|
|
273
|
+
"""
|
|
274
|
+
try:
|
|
275
|
+
tree = ast.parse(source)
|
|
276
|
+
except SyntaxError:
|
|
277
|
+
return set()
|
|
278
|
+
member_names: set[str] = set()
|
|
279
|
+
for each_node in ast.walk(tree):
|
|
280
|
+
if not isinstance(each_node, ast.ImportFrom):
|
|
281
|
+
continue
|
|
282
|
+
if _module_final_segment(each_node.module) != module_stem:
|
|
283
|
+
continue
|
|
284
|
+
for each_alias in each_node.names:
|
|
285
|
+
member_names.add(each_alias.name)
|
|
286
|
+
return member_names
|
|
287
|
+
|
|
288
|
+
|
|
187
289
|
def _scan_root_for_constants_module(file_path: str) -> Path:
|
|
188
290
|
"""Return the directory tree to scan for references to the module's constants.
|
|
189
291
|
|
|
@@ -226,41 +328,48 @@ def _is_under_directory(candidate_path: Path, ancestor_directory: Path) -> bool:
|
|
|
226
328
|
return True
|
|
227
329
|
|
|
228
330
|
|
|
229
|
-
def
|
|
331
|
+
def _collect_names_under_root(
|
|
230
332
|
scan_root: Path,
|
|
231
333
|
written_path: Path,
|
|
232
|
-
|
|
334
|
+
all_seed_names: set[str],
|
|
335
|
+
extract_names: Callable[[str], set[str]],
|
|
233
336
|
already_scanned_count: int = 0,
|
|
234
337
|
excluded_subtree: Path | None = None,
|
|
235
338
|
) -> tuple[set[str], int, bool]:
|
|
236
|
-
"""
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
339
|
+
"""Collect referenced names under the scan root via a per-module extractor.
|
|
340
|
+
|
|
341
|
+
Walks every ``.py`` module under ``scan_root`` (excluding the written module
|
|
342
|
+
itself, and any module under ``excluded_subtree``), applies ``extract_names``
|
|
343
|
+
to each module's text, and unions the result onto ``all_seed_names``. Reading
|
|
344
|
+
stops once the running file count exceeds the configured cap so a write under
|
|
345
|
+
an unexpectedly large tree cannot stall the hook; the boolean signals the
|
|
346
|
+
caller to treat that case as "cannot prove dead". The ``excluded_subtree``
|
|
347
|
+
skip keeps the widened repository scan from re-reading a file the
|
|
245
348
|
package-tree scan already covered.
|
|
246
349
|
|
|
247
350
|
Args:
|
|
248
351
|
scan_root: The directory tree to scan.
|
|
249
|
-
written_path: The resolved path of the module being written
|
|
250
|
-
|
|
352
|
+
written_path: The resolved path of the module being written, skipped so
|
|
353
|
+
its own text is judged through ``all_seed_names`` rather than the
|
|
354
|
+
stale disk copy.
|
|
355
|
+
all_seed_names: The names the written module itself contributes, unioned
|
|
356
|
+
in before the walk begins.
|
|
357
|
+
extract_names: Maps one module's source text to the set of names it
|
|
358
|
+
contributes — the generous reference collector for the package-tree
|
|
359
|
+
pass, the stem-bound import collector for the widened pass.
|
|
251
360
|
already_scanned_count: The file count accumulated by a prior pass, so the
|
|
252
361
|
cap bounds the combined work of the package-tree and widened passes.
|
|
253
362
|
excluded_subtree: A resolved directory whose ``.py`` modules are skipped,
|
|
254
363
|
or None to scan every file under the root.
|
|
255
364
|
|
|
256
365
|
Returns:
|
|
257
|
-
A (
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
366
|
+
A (collected_names, running_count, cap_was_hit) triple. collected_names
|
|
367
|
+
is ``all_seed_names`` unioned with every scanned module's contribution;
|
|
368
|
+
running_count is the cumulative file count including
|
|
369
|
+
``already_scanned_count``; cap_was_hit is True when the scan stopped at
|
|
370
|
+
the configured file cap before scanning the whole tree.
|
|
262
371
|
"""
|
|
263
|
-
|
|
372
|
+
collected_names = set(all_seed_names)
|
|
264
373
|
written_path_key = os.path.normcase(str(written_path))
|
|
265
374
|
scanned_file_count = already_scanned_count
|
|
266
375
|
for each_path in scan_root.rglob("*" + PYTHON_SOURCE_SUFFIX):
|
|
@@ -273,13 +382,13 @@ def _all_referenced_names_under_root(
|
|
|
273
382
|
continue
|
|
274
383
|
scanned_file_count += 1
|
|
275
384
|
if scanned_file_count > MAX_SCAN_ROOT_FILE_COUNT:
|
|
276
|
-
return
|
|
385
|
+
return collected_names, scanned_file_count, True
|
|
277
386
|
try:
|
|
278
387
|
sibling_source = each_path.read_text(encoding="utf-8")
|
|
279
388
|
except (OSError, UnicodeDecodeError):
|
|
280
389
|
continue
|
|
281
|
-
|
|
282
|
-
return
|
|
390
|
+
collected_names |= extract_names(sibling_source)
|
|
391
|
+
return collected_names, scanned_file_count, False
|
|
283
392
|
|
|
284
393
|
|
|
285
394
|
def _repository_root_for(written_path: Path) -> Path | None:
|
|
@@ -324,6 +433,37 @@ def _module_is_exempt_from_constant_check(file_path: str) -> bool:
|
|
|
324
433
|
return not _is_dedicated_constants_module(file_path)
|
|
325
434
|
|
|
326
435
|
|
|
436
|
+
def _constants_under_check(tree: ast.Module) -> tuple[list[tuple[str, int]], bool]:
|
|
437
|
+
"""Return the constants to judge and whether the seed counts string literals.
|
|
438
|
+
|
|
439
|
+
A module without ``__all__`` judges every module-scope constant and lets its
|
|
440
|
+
own string literals seed the reference scan. A module declaring ``__all__``
|
|
441
|
+
judges only the constants its ``__all__`` list names — the explicit export
|
|
442
|
+
surface — and withholds its own string literals from the seed, so an
|
|
443
|
+
``__all__`` entry never counts as the consumer that keeps an exported constant
|
|
444
|
+
live. A constant the module defines but ``__all__`` omits is the author's
|
|
445
|
+
stated private value and is left out of the judged set.
|
|
446
|
+
|
|
447
|
+
Args:
|
|
448
|
+
tree: The parsed constants module.
|
|
449
|
+
|
|
450
|
+
Returns:
|
|
451
|
+
A (definitions, seed_collect_string_literals) pair: the (name, line)
|
|
452
|
+
constants to judge, and whether the written module's string literals seed
|
|
453
|
+
the referenced-name set.
|
|
454
|
+
"""
|
|
455
|
+
constant_definitions = _module_constant_definitions(tree)
|
|
456
|
+
if not _module_declares_dunder_all(tree):
|
|
457
|
+
return constant_definitions, True
|
|
458
|
+
exported_names = _dunder_all_member_names(tree)
|
|
459
|
+
exported_definitions = [
|
|
460
|
+
(each_name, each_line)
|
|
461
|
+
for each_name, each_line in constant_definitions
|
|
462
|
+
if each_name in exported_names
|
|
463
|
+
]
|
|
464
|
+
return exported_definitions, False
|
|
465
|
+
|
|
466
|
+
|
|
327
467
|
def check_dead_module_constants(
|
|
328
468
|
content: str,
|
|
329
469
|
file_path: str,
|
|
@@ -334,16 +474,23 @@ def check_dead_module_constants(
|
|
|
334
474
|
Runs only on a dedicated constants module (``*_constants.py`` or a module
|
|
335
475
|
under ``config/``); every other production module's file-global constants
|
|
336
476
|
are governed by the use-count rule instead. A constant is dead when its name
|
|
337
|
-
appears in no ``.py`` module under the enclosing package tree
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
477
|
+
appears in no ``.py`` module under the enclosing package tree — not imported,
|
|
478
|
+
not read, not listed in another module's ``__all__`` literal, not named in a
|
|
479
|
+
string annotation — and, in the repository-wide scan the check widens to when
|
|
480
|
+
the package-tree scan leaves the constant unreferenced, no module imports the
|
|
481
|
+
name from a ``from <module> import`` whose final dotted segment equals this
|
|
482
|
+
module's filename stem. Binding the widened scan to the stem keeps a genuine
|
|
483
|
+
cross-tree consumer counting while a same-named constant exported by an
|
|
484
|
+
unrelated module never masks a dead one. A module declaring ``__all__``
|
|
485
|
+
narrows the check to the constants its ``__all__`` list names: each must be
|
|
486
|
+
imported or read by another module, and the module's own ``__all__`` entry
|
|
487
|
+
never counts as that consumer, so an exported constant no module consumes is
|
|
488
|
+
flagged; a constant the module defines but ``__all__`` omits is the author's
|
|
489
|
+
private value and is left alone. A scan whose combined package-tree and
|
|
490
|
+
widened file count exceeds the configured cap returns ``[]`` (cannot prove
|
|
491
|
+
dead), bounding the work so the blocking hook cannot stall under a large tree.
|
|
492
|
+
Whole-file analysis runs against ``full_file_content`` when supplied so an
|
|
493
|
+
Edit fragment is judged against the reconstructed post-edit file.
|
|
347
494
|
|
|
348
495
|
Args:
|
|
349
496
|
content: The new content under validation (Edit fragment or whole file).
|
|
@@ -355,8 +502,9 @@ def check_dead_module_constants(
|
|
|
355
502
|
Returns:
|
|
356
503
|
One violation message per dead module-level constant, capped at the
|
|
357
504
|
configured maximum. Returns an empty list when the file is exempt, no
|
|
358
|
-
constant is defined,
|
|
359
|
-
file cap, or a SyntaxError prevents
|
|
505
|
+
constant is in scope (none defined, or none exported when ``__all__`` is
|
|
506
|
+
declared), the scan exceeds the file cap, or a SyntaxError prevents
|
|
507
|
+
parsing.
|
|
360
508
|
"""
|
|
361
509
|
if _module_is_exempt_from_constant_check(file_path):
|
|
362
510
|
return []
|
|
@@ -365,17 +513,21 @@ def check_dead_module_constants(
|
|
|
365
513
|
tree = ast.parse(effective_content)
|
|
366
514
|
except SyntaxError:
|
|
367
515
|
return []
|
|
368
|
-
|
|
369
|
-
return []
|
|
370
|
-
constant_definitions = _module_constant_definitions(tree)
|
|
516
|
+
constant_definitions, seed_collect_string_literals = _constants_under_check(tree)
|
|
371
517
|
if not constant_definitions:
|
|
372
518
|
return []
|
|
373
519
|
scan_root = _scan_root_for_constants_module(file_path)
|
|
374
520
|
written_path = Path(file_path).resolve()
|
|
375
|
-
|
|
521
|
+
written_seed_names = _referenced_names_in_source(
|
|
522
|
+
effective_content,
|
|
523
|
+
load_only=True,
|
|
524
|
+
collect_string_literals=seed_collect_string_literals,
|
|
525
|
+
)
|
|
526
|
+
all_referenced_names, scanned_file_count, cap_was_hit = _collect_names_under_root(
|
|
376
527
|
scan_root,
|
|
377
528
|
written_path,
|
|
378
|
-
|
|
529
|
+
written_seed_names,
|
|
530
|
+
_referenced_names_in_source,
|
|
379
531
|
)
|
|
380
532
|
if cap_was_hit:
|
|
381
533
|
return []
|
|
@@ -385,10 +537,14 @@ def check_dead_module_constants(
|
|
|
385
537
|
if has_unreferenced_constant:
|
|
386
538
|
repository_root = _repository_root_for(written_path)
|
|
387
539
|
if repository_root is not None and repository_root != scan_root:
|
|
388
|
-
|
|
540
|
+
collect_qualified_imports = partial(
|
|
541
|
+
_qualified_import_member_names, module_stem=written_path.stem
|
|
542
|
+
)
|
|
543
|
+
widened_names, _widened_count, widened_cap_was_hit = _collect_names_under_root(
|
|
389
544
|
repository_root,
|
|
390
545
|
written_path,
|
|
391
|
-
|
|
546
|
+
set(),
|
|
547
|
+
collect_qualified_imports,
|
|
392
548
|
already_scanned_count=scanned_file_count,
|
|
393
549
|
excluded_subtree=scan_root,
|
|
394
550
|
)
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Dead-conditional-branch check for a truthiness test on an always-non-empty split."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
_blocking_directory = str(Path(__file__).resolve().parent)
|
|
8
|
+
_hooks_directory = str(Path(__file__).resolve().parent.parent)
|
|
9
|
+
if _blocking_directory not in sys.path:
|
|
10
|
+
sys.path.insert(0, _blocking_directory)
|
|
11
|
+
if _hooks_directory not in sys.path:
|
|
12
|
+
sys.path.insert(0, _hooks_directory)
|
|
13
|
+
|
|
14
|
+
from code_rules_path_utils import ( # noqa: E402
|
|
15
|
+
is_config_file,
|
|
16
|
+
)
|
|
17
|
+
from code_rules_shared import ( # noqa: E402
|
|
18
|
+
_collect_annotated_arguments,
|
|
19
|
+
_walk_skipping_nested_function_defs,
|
|
20
|
+
is_test_file,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
|
|
24
|
+
ALL_ALWAYS_NONEMPTY_SPLIT_METHOD_NAMES,
|
|
25
|
+
DEAD_SPLIT_BRANCH_MESSAGE_SUFFIX,
|
|
26
|
+
MAX_DEAD_SPLIT_BRANCH_ISSUES,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _is_separator_split_call(call_node: ast.Call) -> bool:
|
|
31
|
+
"""Return True for an ``x.split(sep)`` / ``x.rsplit(sep)`` with a non-empty separator.
|
|
32
|
+
|
|
33
|
+
``str.split`` and ``bytes.split`` return a list holding at least one element
|
|
34
|
+
whenever a separator argument is supplied, so the result is always truthy.
|
|
35
|
+
Requiring a non-empty string or bytes literal separator keeps the guarantee
|
|
36
|
+
airtight: ``split()`` with no argument can return an empty list, and an
|
|
37
|
+
empty separator raises at runtime. The receiver is restricted to a bare
|
|
38
|
+
``Name`` or a string/bytes literal so an intermediate attribute chain such
|
|
39
|
+
as a pandas ``s.str.split(",")`` — whose result is a Series, not a list —
|
|
40
|
+
is not treated as an always-non-empty split.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
call_node: The call expression on the right-hand side of an assignment.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
True when the call is a separator-bearing split that never returns an
|
|
47
|
+
empty list.
|
|
48
|
+
"""
|
|
49
|
+
function_node = call_node.func
|
|
50
|
+
if not isinstance(function_node, ast.Attribute):
|
|
51
|
+
return False
|
|
52
|
+
if function_node.attr not in ALL_ALWAYS_NONEMPTY_SPLIT_METHOD_NAMES:
|
|
53
|
+
return False
|
|
54
|
+
receiver_node = function_node.value
|
|
55
|
+
if isinstance(receiver_node, ast.Constant):
|
|
56
|
+
if not isinstance(receiver_node.value, (str, bytes)):
|
|
57
|
+
return False
|
|
58
|
+
elif not isinstance(receiver_node, ast.Name):
|
|
59
|
+
return False
|
|
60
|
+
if not call_node.args:
|
|
61
|
+
return False
|
|
62
|
+
first_argument = call_node.args[0]
|
|
63
|
+
if not isinstance(first_argument, ast.Constant):
|
|
64
|
+
return False
|
|
65
|
+
separator_value = first_argument.value
|
|
66
|
+
if not isinstance(separator_value, (str, bytes)):
|
|
67
|
+
return False
|
|
68
|
+
return len(separator_value) > 0
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _separator_split_target_names(
|
|
72
|
+
function_node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
73
|
+
) -> set[str]:
|
|
74
|
+
"""Return names bound exactly once in the function from a separator split.
|
|
75
|
+
|
|
76
|
+
A name qualifies when its single binding in the function body is an
|
|
77
|
+
assignment whose value is a separator-bearing ``split`` / ``rsplit`` call,
|
|
78
|
+
so the bound list is always truthy. A name bound more than once, or also a
|
|
79
|
+
parameter, is excluded because a later rebinding could make it falsy.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
function_node: The function whose body is inspected.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
The set of always-truthy split-result names safe to reason about.
|
|
86
|
+
"""
|
|
87
|
+
all_store_counts: dict[str, int] = {}
|
|
88
|
+
all_split_names: set[str] = set()
|
|
89
|
+
for each_statement in function_node.body:
|
|
90
|
+
for each_node in _walk_skipping_nested_function_defs(each_statement):
|
|
91
|
+
if isinstance(each_node, ast.Name) and isinstance(each_node.ctx, ast.Store):
|
|
92
|
+
all_store_counts[each_node.id] = (
|
|
93
|
+
all_store_counts.get(each_node.id, 0) + 1
|
|
94
|
+
)
|
|
95
|
+
if not isinstance(each_node, ast.Assign):
|
|
96
|
+
continue
|
|
97
|
+
if len(each_node.targets) != 1:
|
|
98
|
+
continue
|
|
99
|
+
assignment_target = each_node.targets[0]
|
|
100
|
+
if not isinstance(assignment_target, ast.Name):
|
|
101
|
+
continue
|
|
102
|
+
if isinstance(each_node.value, ast.Call) and _is_separator_split_call(
|
|
103
|
+
each_node.value
|
|
104
|
+
):
|
|
105
|
+
all_split_names.add(assignment_target.id)
|
|
106
|
+
all_parameter_names = {
|
|
107
|
+
each_argument.arg
|
|
108
|
+
for each_argument in _collect_annotated_arguments(function_node)
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
each_name
|
|
112
|
+
for each_name in all_split_names
|
|
113
|
+
if all_store_counts.get(each_name, 0) == 1
|
|
114
|
+
and each_name not in all_parameter_names
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _truthiness_target(
|
|
119
|
+
test_node: ast.expr, all_candidate_names: set[str]
|
|
120
|
+
) -> str | None:
|
|
121
|
+
"""Return the candidate name a bare ``Name`` truthiness test reads, else None."""
|
|
122
|
+
if isinstance(test_node, ast.Name) and test_node.id in all_candidate_names:
|
|
123
|
+
return test_node.id
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _negated_truthiness_target(
|
|
128
|
+
test_node: ast.expr, all_candidate_names: set[str]
|
|
129
|
+
) -> str | None:
|
|
130
|
+
"""Return the candidate name a ``not Name`` test reads, else None."""
|
|
131
|
+
if not isinstance(test_node, ast.UnaryOp) or not isinstance(test_node.op, ast.Not):
|
|
132
|
+
return None
|
|
133
|
+
operand = test_node.operand
|
|
134
|
+
if isinstance(operand, ast.Name) and operand.id in all_candidate_names:
|
|
135
|
+
return operand.id
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _branch_finding_for_node(
|
|
140
|
+
node: ast.AST, all_candidate_names: set[str]
|
|
141
|
+
) -> tuple[int, str] | None:
|
|
142
|
+
"""Return ``(line, name)`` when a node carries a dead branch, else None.
|
|
143
|
+
|
|
144
|
+
A conditional expression always carries an else arm, so any truthiness test
|
|
145
|
+
on a candidate name is a dead branch. An ``if`` statement only carries a
|
|
146
|
+
dead else when it has one, while an ``if not name`` statement always has a
|
|
147
|
+
dead body.
|
|
148
|
+
"""
|
|
149
|
+
if isinstance(node, ast.IfExp):
|
|
150
|
+
truthy_name = _truthiness_target(node.test, all_candidate_names)
|
|
151
|
+
if truthy_name is not None:
|
|
152
|
+
return node.lineno, truthy_name
|
|
153
|
+
return _negated_branch_finding(node.test, node.lineno, all_candidate_names)
|
|
154
|
+
if isinstance(node, ast.If):
|
|
155
|
+
truthy_name = _truthiness_target(node.test, all_candidate_names)
|
|
156
|
+
if truthy_name is not None and node.orelse:
|
|
157
|
+
return node.lineno, truthy_name
|
|
158
|
+
return _negated_branch_finding(node.test, node.lineno, all_candidate_names)
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _negated_branch_finding(
|
|
163
|
+
test_node: ast.expr, line_number: int, all_candidate_names: set[str]
|
|
164
|
+
) -> tuple[int, str] | None:
|
|
165
|
+
negated_name = _negated_truthiness_target(test_node, all_candidate_names)
|
|
166
|
+
if negated_name is not None:
|
|
167
|
+
return line_number, negated_name
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _dead_branch_findings(
|
|
172
|
+
function_node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
173
|
+
all_candidate_names: set[str],
|
|
174
|
+
) -> list[tuple[int, str]]:
|
|
175
|
+
"""Return every ``(line, name)`` dead-branch finding in the function body."""
|
|
176
|
+
all_findings: list[tuple[int, str]] = []
|
|
177
|
+
for each_statement in function_node.body:
|
|
178
|
+
for each_node in _walk_skipping_nested_function_defs(each_statement):
|
|
179
|
+
finding = _branch_finding_for_node(each_node, all_candidate_names)
|
|
180
|
+
if finding is not None:
|
|
181
|
+
all_findings.append(finding)
|
|
182
|
+
return all_findings
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def check_dead_split_truthiness_branch(content: str, file_path: str) -> list[str]:
|
|
186
|
+
"""Flag a conditional whose branch is unreachable after a separator split.
|
|
187
|
+
|
|
188
|
+
``str.split(sep)`` and ``bytes.split(sep)`` with a separator always return a
|
|
189
|
+
list holding at least one element, so a value bound from such a call is
|
|
190
|
+
always truthy. A conditional that tests that value's truthiness carries a
|
|
191
|
+
dead branch: ``parts[0] if parts else fallback`` never reaches ``fallback``,
|
|
192
|
+
and ``if not parts:`` never runs its body. Both the conditional-expression
|
|
193
|
+
form and the ``if`` / ``if not`` statement form are reported, scoped to a
|
|
194
|
+
value bound exactly once in the function from a separator split. Config
|
|
195
|
+
files and test files are exempt.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
content: The source text to inspect.
|
|
199
|
+
file_path: The path the source will be written to, used for exemptions.
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
One issue per dead conditional branch found, capped at the module limit.
|
|
203
|
+
"""
|
|
204
|
+
if is_test_file(file_path) or is_config_file(file_path):
|
|
205
|
+
return []
|
|
206
|
+
try:
|
|
207
|
+
tree = ast.parse(content)
|
|
208
|
+
except SyntaxError:
|
|
209
|
+
return []
|
|
210
|
+
issues: list[str] = []
|
|
211
|
+
for each_function_node in ast.walk(tree):
|
|
212
|
+
if not isinstance(each_function_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
213
|
+
continue
|
|
214
|
+
all_candidate_names = _separator_split_target_names(each_function_node)
|
|
215
|
+
if not all_candidate_names:
|
|
216
|
+
continue
|
|
217
|
+
for each_line, each_name in _dead_branch_findings(
|
|
218
|
+
each_function_node, all_candidate_names
|
|
219
|
+
):
|
|
220
|
+
issues.append(
|
|
221
|
+
f"Line {each_line}: {each_name!r} {DEAD_SPLIT_BRANCH_MESSAGE_SUFFIX}"
|
|
222
|
+
)
|
|
223
|
+
if len(issues) >= MAX_DEAD_SPLIT_BRANCH_ISSUES:
|
|
224
|
+
return issues
|
|
225
|
+
return issues
|