claude-dev-env 1.76.0 → 1.78.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -0
  2. package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +8 -4
  3. package/hooks/blocking/CLAUDE.md +3 -1
  4. package/hooks/blocking/code_rules_dead_module_constant.py +215 -59
  5. package/hooks/blocking/code_rules_dead_split_branch.py +225 -0
  6. package/hooks/blocking/code_rules_docstrings.py +730 -0
  7. package/hooks/blocking/code_rules_enforcer.py +39 -0
  8. package/hooks/blocking/code_rules_paired_test.py +299 -0
  9. package/hooks/blocking/code_rules_string_magic.py +71 -1
  10. package/hooks/blocking/convergence_gate_blocker.py +24 -15
  11. package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +89 -2
  12. package/hooks/blocking/test_code_rules_enforcer_dead_split_branch.py +105 -0
  13. package/hooks/blocking/test_code_rules_enforcer_docstring_args_span_scope.py +174 -0
  14. package/hooks/blocking/test_code_rules_enforcer_docstring_cardinal_family.py +176 -0
  15. package/hooks/blocking/test_code_rules_enforcer_docstring_mark_glyph_enumeration.py +262 -0
  16. package/hooks/blocking/test_code_rules_enforcer_docstring_raises_largezipfile.py +226 -0
  17. package/hooks/blocking/test_code_rules_enforcer_docstring_runon_sentence.py +267 -0
  18. package/hooks/blocking/test_code_rules_enforcer_paired_test.py +149 -0
  19. package/hooks/blocking/test_code_rules_enforcer_whitespace_indentation_magic.py +74 -0
  20. package/hooks/blocking/test_convergence_gate_blocker.py +71 -0
  21. package/hooks/hooks_constants/CLAUDE.md +1 -0
  22. package/hooks/hooks_constants/blocking_check_limits.py +51 -0
  23. package/hooks/hooks_constants/code_rules_enforcer_constants.py +49 -0
  24. package/hooks/hooks_constants/paired_test_coverage_constants.py +27 -0
  25. package/package.json +1 -1
  26. package/rules/CLAUDE.md +2 -0
  27. package/rules/docstring-prose-matches-implementation.md +5 -1
  28. package/rules/file-global-constants.md +2 -2
  29. package/rules/package-inventory-stale-entry.md +8 -0
  30. package/rules/paired-test-coverage.md +28 -0
  31. 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 8 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.
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 8 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.
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-O8, 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.
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 8 sub-buckets below.
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
 
@@ -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__`` is skipped: the author has named its export
20
- surface explicitly, so a name listed there is live by declaration and a name
21
- absent there is the author's stated intent, neither of which this check second
22
- guesses.
23
- - A constant is live when its name appears anywhere the scan reaches —
24
- imported, read, listed in ``__all__``, or referenced in a string annotation —
25
- in any ``.py`` module, including the constants module itself.
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) so a consumer in a sibling
28
- tree of the same repository counts; a module outside any repository is judged
29
- on the package-tree scan alone. The widened pass skips the package subtree the
30
- first pass already covered, so no file is read twice.
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 _referenced_names_in_source(source: str, load_only: bool = False) -> set[str]:
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 string literals (so a name listed in an
154
- ``__all__`` literal or named in a string annotation counts as a reference).
155
- A module that fails to parse contributes no names. With ``load_only`` set,
156
- only ``Load``-context names count, so a constant's own assignment target in
157
- the module being judged does not count as a reference to itself.
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 isinstance(each_node, ast.Constant) and isinstance(each_node.value, str):
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 _all_referenced_names_under_root(
331
+ def _collect_names_under_root(
230
332
  scan_root: Path,
231
333
  written_path: Path,
232
- written_content: str,
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
- """Return referenced names under the scan root, the running count, and a cap flag.
237
-
238
- The written module's on-disk text is replaced by ``written_content`` so the
239
- post-edit view is judged, never the stale disk copy. Sibling modules are read
240
- from disk. Reading stops once the running file count exceeds the configured
241
- cap so a write under an unexpectedly large tree cannot stall the hook; the
242
- boolean signals the caller to treat that case as "cannot prove dead". When
243
- ``excluded_subtree`` is supplied, every ``.py`` module under that directory is
244
- skipped, so the widened repository scan never re-reads a file the
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
- written_content: The post-edit text of the written module.
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 (referenced_names, running_count, cap_was_hit) triple. The name set is
258
- the union across every scanned module, unioned with the names the written
259
- module itself references; running_count is the cumulative file count
260
- including ``already_scanned_count``; cap_was_hit is True when the scan
261
- stopped at the configured file cap before scanning the whole tree.
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
- all_referenced_names = _referenced_names_in_source(written_content, load_only=True)
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 all_referenced_names, scanned_file_count, True
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
- all_referenced_names |= _referenced_names_in_source(sibling_source)
282
- return all_referenced_names, scanned_file_count, False
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, nor anywhere
338
- in the repository the scan widens to when the package-tree scan leaves the
339
- constant unreferencednot imported, not read, not listed in an ``__all__``
340
- literal, not named in a string annotation. A module declaring its own
341
- ``__all__`` is skipped so the author's explicit export surface is never
342
- second-guessed. A scan whose combined package-tree and widened file count
343
- exceeds the configured cap returns ``[]`` (cannot prove dead), bounding the
344
- work so the blocking hook cannot stall under a large tree. Whole-file
345
- analysis runs against ``full_file_content`` when supplied so an Edit fragment
346
- is judged against the reconstructed post-edit file.
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 annotationand, 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, the module declares ``__all__``, the scan exceeds the
359
- file cap, or a SyntaxError prevents parsing.
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
- if _module_declares_dunder_all(tree):
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
- all_referenced_names, scanned_file_count, cap_was_hit = _all_referenced_names_under_root(
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
- effective_content,
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
- widened_names, _widened_count, widened_cap_was_hit = _all_referenced_names_under_root(
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
- effective_content,
546
+ set(),
547
+ collect_qualified_imports,
392
548
  already_scanned_count=scanned_file_count,
393
549
  excluded_subtree=scan_root,
394
550
  )