claude-dev-env 2.28.0 → 2.28.1

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 (106) hide show
  1. package/.agents/skills-archived/pr-loop-cloud-transport/reference/identity-and-hooks.md +1 -1
  2. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +16 -29
  3. package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +2 -2
  4. package/docs/CODE_RULES.md +3 -3
  5. package/docs/agent-spawn-protocol.md +1 -1
  6. package/docs/references/prose-style-enforcement.md +5 -9
  7. package/hooks/blocking/AGENTS.md +3 -13
  8. package/hooks/blocking/code_rules_docstrings.py +10 -2138
  9. package/hooks/blocking/code_rules_enforcer.py +0 -121
  10. package/hooks/blocking/code_rules_imports_logging.py +1 -236
  11. package/hooks/blocking/code_rules_shared.py +23 -0
  12. package/hooks/blocking/code_rules_test_layout.py +8 -8
  13. package/hooks/blocking/config/prose_style_enforcement_constants.py +4 -2
  14. package/hooks/blocking/config/test_prose_style_enforcement_constants.py +5 -1
  15. package/hooks/blocking/precommit_code_rules_gate.py +8 -43
  16. package/hooks/blocking/state_description_blocker.py +1 -7
  17. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +0 -1
  18. package/hooks/blocking/test_code_rules_enforcer_dispatch_wiring.py +0 -8
  19. package/hooks/blocking/test_code_rules_enforcer_module_docstring_roster.py +11 -112
  20. package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +0 -1
  21. package/hooks/blocking/test_code_rules_enforcer_split_entry_1.py +1 -18
  22. package/hooks/blocking/test_code_rules_shared.py +12 -0
  23. package/hooks/blocking/test_precommit_code_rules_gate.py +32 -179
  24. package/hooks/blocking/test_precommit_code_rules_gate_native_owner.py +0 -1
  25. package/hooks/blocking/test_state_description_blocker.py +6 -4
  26. package/hooks/blocking/test_stop_dispatcher.py +5 -29
  27. package/hooks/hooks_constants/AGENTS.md +1 -6
  28. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +0 -1
  29. package/hooks/hooks_constants/code_rules_enforcer_constants.py +3 -0
  30. package/hooks/hooks_constants/messages.py +0 -2
  31. package/hooks/hooks_constants/precommit_code_rules_gate_constants.py +3 -17
  32. package/hooks/hooks_constants/stop_dispatcher_constants.py +0 -2
  33. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +0 -1
  34. package/hooks/hooks_constants/test_code_rules_enforcer_constants.py +7 -0
  35. package/hooks/hooks_constants/test_messages.py +5 -3
  36. package/hooks/hooks_constants/test_stop_dispatcher_constants.py +0 -2
  37. package/package.json +1 -1
  38. package/rules/AGENTS.md +3 -6
  39. package/rules/claims-as-quotes.md +0 -10
  40. package/rules/code-standards.md +6 -6
  41. package/rules/explore-thoroughly.md +0 -1
  42. package/rules/failure-blast-radius.md +0 -8
  43. package/rules/falsify-before-green.md +0 -8
  44. package/rules/file-global-constants.md +2 -2
  45. package/rules/filesystem-search.md +1 -1
  46. package/rules/git-workflow.md +1 -9
  47. package/rules/hedging-claims.md +2 -6
  48. package/rules/long-horizon-autonomy.md +1 -1
  49. package/rules/measurement-denominators.md +0 -9
  50. package/rules/research-mode.md +0 -6
  51. package/rules/verify-before-asking.md +0 -5
  52. package/rules/verify-runtime-state.md +0 -5
  53. package/scripts/codex_compat_materializer.py +0 -12
  54. package/scripts/sync_to_cursor/rules.py +0 -10
  55. package/scripts/tests/test_engine.py +0 -1
  56. package/scripts/tests/test_rules.py +0 -1
  57. package/scripts/tests/test_sync_to_cursor.py +0 -1
  58. package/system-prompts/software-engineer.xml +3 -3
  59. package/hooks/blocking/code_rules_dead_argparse_argument.py +0 -554
  60. package/hooks/blocking/code_rules_dead_config_field.py +0 -568
  61. package/hooks/blocking/code_rules_dead_dataclass_field.py +0 -348
  62. package/hooks/blocking/code_rules_dead_module_constant.py +0 -757
  63. package/hooks/blocking/code_rules_dead_split_branch.py +0 -225
  64. package/hooks/blocking/code_rules_mock_completeness.py +0 -295
  65. package/hooks/blocking/code_rules_scope_binding.py +0 -151
  66. package/hooks/blocking/code_rules_unused_imports.py +0 -197
  67. package/hooks/blocking/hedging_language_blocker.py +0 -221
  68. package/hooks/blocking/intent_only_ending_blocker.py +0 -148
  69. package/hooks/blocking/test_code_rules_enforcer_dead_argparse_argument.py +0 -534
  70. package/hooks/blocking/test_code_rules_enforcer_dead_config_field.py +0 -846
  71. package/hooks/blocking/test_code_rules_enforcer_dead_dataclass_field.py +0 -507
  72. package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +0 -679
  73. package/hooks/blocking/test_code_rules_enforcer_dead_module_constant_alias.py +0 -133
  74. package/hooks/blocking/test_code_rules_enforcer_dead_module_constant_read_cap.py +0 -103
  75. package/hooks/blocking/test_code_rules_enforcer_dead_split_branch.py +0 -105
  76. package/hooks/blocking/test_code_rules_enforcer_docstring_args_span_scope.py +0 -425
  77. package/hooks/blocking/test_code_rules_enforcer_docstring_cardinal_family.py +0 -176
  78. package/hooks/blocking/test_code_rules_enforcer_docstring_delegation_summary.py +0 -385
  79. package/hooks/blocking/test_code_rules_enforcer_docstring_fallback_branch.py +0 -398
  80. package/hooks/blocking/test_code_rules_enforcer_docstring_field_runmode_outcome.py +0 -129
  81. package/hooks/blocking/test_code_rules_enforcer_docstring_inline_literal_claim.py +0 -93
  82. package/hooks/blocking/test_code_rules_enforcer_docstring_length_constant_superlative.py +0 -198
  83. package/hooks/blocking/test_code_rules_enforcer_docstring_mark_glyph_enumeration.py +0 -262
  84. package/hooks/blocking/test_code_rules_enforcer_docstring_no_consumer.py +0 -93
  85. package/hooks/blocking/test_code_rules_enforcer_docstring_no_network.py +0 -115
  86. package/hooks/blocking/test_code_rules_enforcer_docstring_raises_largezipfile.py +0 -226
  87. package/hooks/blocking/test_code_rules_enforcer_docstring_returns_plural_cardinality.py +0 -207
  88. package/hooks/blocking/test_code_rules_enforcer_docstring_step_dispatch.py +0 -262
  89. package/hooks/blocking/test_code_rules_enforcer_docstring_type_checking_gate.py +0 -164
  90. package/hooks/blocking/test_code_rules_enforcer_docstring_unguarded_payload.py +0 -188
  91. package/hooks/blocking/test_code_rules_enforcer_import_block_sort.py +0 -157
  92. package/hooks/blocking/test_code_rules_enforcer_split_mocks_1.py +0 -303
  93. package/hooks/blocking/test_code_rules_enforcer_split_mocks_2.py +0 -111
  94. package/hooks/blocking/test_code_rules_enforcer_unused_imports.py +0 -656
  95. package/hooks/blocking/test_hedging_language_blocker.py +0 -261
  96. package/hooks/blocking/test_intent_only_ending_blocker.py +0 -209
  97. package/hooks/hooks_constants/dead_argparse_argument_constants.py +0 -28
  98. package/hooks/hooks_constants/dead_config_field_constants.py +0 -39
  99. package/hooks/hooks_constants/dead_dataclass_field_constants.py +0 -25
  100. package/hooks/hooks_constants/dead_module_constant_constants.py +0 -30
  101. package/hooks/hooks_constants/hedging_uncertainty_constants.py +0 -42
  102. package/hooks/hooks_constants/test_dispatcher_constants_docstrings.py +0 -44
  103. package/rules/conservative-action.md +0 -17
  104. package/rules/context7.md +0 -8
  105. package/rules/few-words.md +0 -3
  106. package/rules/parallel-tools.md +0 -23
@@ -1,757 +0,0 @@
1
- """Dead module-level constant check for dedicated constants modules.
2
-
3
- A constants module (`*_constants.py`, or any module under a ``config/``
4
- directory) exists to export named values to importer modules elsewhere in the
5
- project, so a constant defined there is never proven dead by a single-file scan
6
- alone. This check resolves the enclosing package tree — the scan root — and
7
- flags an UPPER_SNAKE constant defined in the written module whose name appears
8
- in no ``.py`` module anywhere under that root: not as an imported name, not as a
9
- read, not as a re-export. When a constant looks dead in the package tree, the
10
- scan widens to the whole repository so a consumer in a sibling tree counts
11
- before the constant is flagged. That is the ``MEDIUM_TEXT``-style dead constant
12
- the CODE_RULES §9.8 dead-code rule targets, caught at Write/Edit time before the
13
- unused constant lands.
14
-
15
- The scan is deliberately conservative to keep false positives near zero:
16
-
17
- - Only dedicated constants modules participate; ordinary production modules,
18
- whose file-global constants are governed by the use-count rule, are skipped.
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.
30
- - When the package-tree scan leaves a constant unreferenced, the scan widens to
31
- the repository root (the nearest ``.git`` ancestor). The widened pass counts a
32
- sibling-tree reference in two shapes. The first is an import of the name
33
- through a ``from <module> import`` whose final dotted segment equals the
34
- written module's filename stem. The second is an attribute read on a binding
35
- of the written module itself: a consumer that writes
36
- ``import pkg.config.constants``, the same with an alias,
37
- ``from pkg.config import constants``, or the same with an alias, and then
38
- reads each constant as an attribute on that binding (``alias.NAME``,
39
- ``pkg.config.constants.NAME``), keeps every name it reads that way live. Both
40
- shapes are bound to the written module's stem, so a genuine cross-tree
41
- consumer of this constants module keeps the constant live while a same-named
42
- constant exported by an unrelated module never masks a dead one. The widened
43
- pass reads a repository file only to test whether its text names the written
44
- module's filename stem; neither shape can appear in a file that never spells
45
- the stem, so such a file is
46
- skipped without spending scan-cap budget, keeping the widened pass bounded to
47
- the handful of candidate importer files even in a large repository. A module
48
- outside any repository is
49
- judged on the package-tree scan alone, and the widened pass skips the package
50
- subtree the first pass already covered, so no file is read twice.
51
- - Two caps bound the widened pass: a read-attempt cap bounds how many files
52
- the pass opens and reads at all while testing for the stem, and a separate
53
- parse cap bounds how many of those files (the ones that do name the stem)
54
- get parsed and have their names collected. Either cap bounds the pass even
55
- under an unexpectedly large tree; a write whose scan hits either cap is
56
- treated as "cannot prove dead" and flags nothing.
57
- - Test modules under the scanned tree still count as references, so a constant
58
- used only by a test stays live.
59
- """
60
-
61
- import ast
62
- import os
63
- import sys
64
- from collections.abc import Callable
65
- from functools import partial
66
- from pathlib import Path
67
-
68
- _blocking_directory = str(Path(__file__).resolve().parent)
69
- _hooks_directory = str(Path(__file__).resolve().parent.parent)
70
- if _blocking_directory not in sys.path:
71
- sys.path.insert(0, _blocking_directory)
72
- if _hooks_directory not in sys.path:
73
- sys.path.insert(0, _hooks_directory)
74
-
75
- from code_rules_probe_chains import _dotted_attribute_chain # noqa: E402
76
- from code_rules_shared import ( # noqa: E402
77
- is_migration_file,
78
- is_test_file,
79
- )
80
-
81
- from hooks_constants.dead_module_constant_constants import ( # noqa: E402
82
- CONFIG_DIRECTORY_SEGMENT,
83
- CONSTANTS_MODULE_SUFFIX,
84
- DEAD_MODULE_CONSTANT_GUIDANCE,
85
- DEAD_MODULE_CONSTANT_RETRY_GUIDANCE,
86
- DUNDER_ALL_NAME,
87
- DUNDER_INIT_FILENAME,
88
- GIT_DIRECTORY_NAME,
89
- MAX_DEAD_MODULE_CONSTANT_ISSUES,
90
- MAX_SCAN_ROOT_FILE_COUNT,
91
- MAX_SCAN_ROOT_READ_COUNT,
92
- MINIMUM_UPPER_SNAKE_LENGTH,
93
- PYTHON_SOURCE_SUFFIX,
94
- )
95
-
96
-
97
- def _is_dedicated_constants_module(file_path: str) -> bool:
98
- """Return whether a path is a dedicated constants module.
99
-
100
- A dedicated constants module is one whose filename ends in
101
- ``_constants.py`` or whose path includes a ``config`` directory segment.
102
- These modules export named values to importers, so their constants need a
103
- cross-module scan to judge liveness.
104
-
105
- Args:
106
- file_path: The destination path of the write.
107
-
108
- Returns:
109
- True for a constants-suffixed module or a module under ``config/``.
110
- """
111
- normalized_path = file_path.replace("\\", "/").lower()
112
- if normalized_path.endswith(CONSTANTS_MODULE_SUFFIX):
113
- return True
114
- path_segments = normalized_path.split("/")
115
- return CONFIG_DIRECTORY_SEGMENT in path_segments[:-1]
116
-
117
-
118
- def _is_upper_snake_name(name: str) -> bool:
119
- """Return whether a name is an UPPER_SNAKE_CASE constant identifier."""
120
- if len(name) < MINIMUM_UPPER_SNAKE_LENGTH:
121
- return False
122
- if not name.replace("_", "").isalnum():
123
- return False
124
- return name == name.upper() and any(each_char.isalpha() for each_char in name)
125
-
126
-
127
- def _module_constant_definitions(tree: ast.Module) -> list[tuple[str, int]]:
128
- """Return (name, line) for each module-scope UPPER_SNAKE constant assignment.
129
-
130
- Both plain assignments (``NAME = value``) and annotated assignments
131
- (``NAME: type = value``) at module scope are collected. A name bound more
132
- than once keeps the line of its first binding.
133
-
134
- Args:
135
- tree: The parsed constants module.
136
-
137
- Returns:
138
- One (name, line) pair per distinct module-scope constant, in source
139
- order.
140
- """
141
- line_by_name: dict[str, int] = {}
142
- for each_statement in tree.body:
143
- targets: list[ast.expr] = []
144
- if isinstance(each_statement, ast.Assign):
145
- targets = list(each_statement.targets)
146
- elif isinstance(each_statement, ast.AnnAssign) and each_statement.value is not None:
147
- targets = [each_statement.target]
148
- for each_target in targets:
149
- if not isinstance(each_target, ast.Name):
150
- continue
151
- if not _is_upper_snake_name(each_target.id):
152
- continue
153
- if each_target.id not in line_by_name:
154
- line_by_name[each_target.id] = each_statement.lineno
155
- return list(line_by_name.items())
156
-
157
-
158
- def _statement_binds_dunder_all(statement: ast.stmt) -> bool:
159
- """Return whether a single statement assigns or annotates ``__all__``."""
160
- if isinstance(statement, ast.Assign):
161
- return any(
162
- isinstance(each_target, ast.Name) and each_target.id == DUNDER_ALL_NAME
163
- for each_target in statement.targets
164
- )
165
- return (
166
- isinstance(statement, ast.AnnAssign)
167
- and isinstance(statement.target, ast.Name)
168
- and statement.target.id == DUNDER_ALL_NAME
169
- )
170
-
171
-
172
- def _module_declares_dunder_all(tree: ast.Module) -> bool:
173
- """Return whether the module body assigns or annotates ``__all__``."""
174
- return any(_statement_binds_dunder_all(each_node) for each_node in tree.body)
175
-
176
-
177
- def _dunder_all_member_names(tree: ast.Module) -> set[str]:
178
- """Return the string member names a module's ``__all__`` sequence lists.
179
-
180
- Reads the value of each ``__all__`` assignment whose value is a list, tuple,
181
- or set literal and collects every string element. A non-literal ``__all__``
182
- value (built by concatenation or a comprehension) contributes no names, so a
183
- constant the check cannot prove is exported stays out of the exported set.
184
-
185
- Args:
186
- tree: The parsed constants module.
187
-
188
- Returns:
189
- The set of names the module names in its ``__all__`` literal.
190
- """
191
- member_names: set[str] = set()
192
- for each_statement in tree.body:
193
- if not _statement_binds_dunder_all(each_statement):
194
- continue
195
- value_node: ast.expr | None = None
196
- if isinstance(each_statement, ast.Assign):
197
- value_node = each_statement.value
198
- elif isinstance(each_statement, ast.AnnAssign):
199
- value_node = each_statement.value
200
- if not isinstance(value_node, ast.List | ast.Tuple | ast.Set):
201
- continue
202
- for each_element in value_node.elts:
203
- if isinstance(each_element, ast.Constant) and isinstance(each_element.value, str):
204
- member_names.add(each_element.value)
205
- return member_names
206
-
207
-
208
- def _referenced_names_in_source(
209
- source: str,
210
- load_only: bool = False,
211
- collect_string_literals: bool = True,
212
- ) -> set[str]:
213
- """Return every name a module references — imported, read, or re-exported.
214
-
215
- Collects imported binding names, ``from`` import member names, name
216
- references, both the root and the member name of each attribute access (so
217
- ``module.CONSTANT`` counts ``CONSTANT`` as read), and (when
218
- ``collect_string_literals`` is set) string literals, so a name listed in an
219
- ``__all__`` literal or named in a string annotation counts as a reference. A
220
- module that fails to parse
221
- contributes no names. With ``load_only`` set, only ``Load``-context names
222
- count, so a constant's own assignment target in the module being judged does
223
- not count as a reference to itself.
224
-
225
- Args:
226
- source: The full text of a ``.py`` module under the scan root.
227
- load_only: When True, count only ``Load``-context name references,
228
- excluding ``Store``/``Del`` targets. Used for the written constants
229
- module so a definition is not mistaken for its own consumer.
230
- collect_string_literals: When True, count every string literal as a
231
- referenced name. Set False for the written module under an ``__all__``
232
- export check so the module's own ``__all__`` entry never shields an
233
- exported constant that no other module consumes.
234
-
235
- Returns:
236
- The set of names the module references.
237
- """
238
- try:
239
- tree = ast.parse(source)
240
- except SyntaxError:
241
- return set()
242
- referenced_names: set[str] = set()
243
- for each_node in ast.walk(tree):
244
- if isinstance(each_node, ast.Name):
245
- if load_only and not isinstance(each_node.ctx, ast.Load):
246
- continue
247
- referenced_names.add(each_node.id)
248
- elif isinstance(each_node, ast.Attribute):
249
- referenced_names.add(each_node.attr)
250
- elif isinstance(each_node, ast.Import | ast.ImportFrom):
251
- for each_alias in each_node.names:
252
- referenced_names.add(each_alias.asname or each_alias.name)
253
- referenced_names.add(each_alias.name)
254
- elif (
255
- collect_string_literals
256
- and isinstance(each_node, ast.Constant)
257
- and isinstance(each_node.value, str)
258
- ):
259
- referenced_names.add(each_node.value)
260
- return referenced_names
261
-
262
-
263
- def _module_final_segment(module_path: str | None) -> str:
264
- """Return the final dotted segment of an import module path.
265
-
266
- Args:
267
- module_path: The ``module`` attribute of a ``from ... import`` node, or
268
- None for a bare relative import (``from . import x``).
269
-
270
- Returns:
271
- The text after the last dot, the whole string when it carries no dot, or
272
- the empty string when ``module_path`` is None or empty.
273
- """
274
- if not module_path:
275
- return ""
276
- return module_path.rsplit(".", 1)[-1]
277
-
278
-
279
- def _stem_import_member_names(import_node: ast.ImportFrom, module_stem: str) -> set[str]:
280
- """Return the member names a stem-matched ``from ... import`` brings in.
281
-
282
- ::
283
-
284
- from pkg.config.constants import NAME -> "NAME"
285
- from pkg.config.other import NAME -> nothing (stem mismatch)
286
-
287
- Args:
288
- import_node: A ``from ... import`` statement node.
289
- module_stem: The filename stem of the constants module being judged.
290
-
291
- Returns:
292
- The imported member names when the module path's final dotted segment
293
- equals ``module_stem``, empty otherwise.
294
- """
295
- if _module_final_segment(import_node.module) != module_stem:
296
- return set()
297
- return {each_alias.name for each_alias in import_node.names}
298
-
299
-
300
- def _import_module_bindings(all_import_aliases: list[ast.alias], module_stem: str) -> set[str]:
301
- """Return the names an import's aliases bind to the constants module object.
302
-
303
- ::
304
-
305
- import pkg.config.constants -> "pkg.config.constants"
306
- import pkg.config.constants as sg -> "sg"
307
- from pkg.config import constants -> "constants"
308
- from . import constants as sg -> "sg"
309
- import pkg.config.other -> nothing (stem mismatch)
310
-
311
- A plain ``import`` names the module by its dotted path, so the final
312
- segment is matched against the stem. A ``from ... import`` alias name is a
313
- single identifier, which the same final-segment match covers unchanged.
314
-
315
- Args:
316
- all_import_aliases: The ``names`` list of an ``Import`` or
317
- ``ImportFrom`` node.
318
- module_stem: The filename stem of the constants module being judged.
319
-
320
- Returns:
321
- The dotted paths and aliases through which the module object is
322
- reachable.
323
- """
324
- binding_names: set[str] = set()
325
- for each_alias in all_import_aliases:
326
- if _module_final_segment(each_alias.name) != module_stem:
327
- continue
328
- binding_names.add(each_alias.asname or each_alias.name)
329
- return binding_names
330
-
331
-
332
- def _collect_widened_scan_facts(
333
- tree: ast.Module, module_stem: str
334
- ) -> tuple[set[str], set[str], list[ast.Attribute]]:
335
- """Collect a candidate module's import and attribute facts in one walk.
336
-
337
- Args:
338
- tree: The parsed candidate module.
339
- module_stem: The filename stem of the constants module being judged.
340
-
341
- Returns:
342
- The member names imported through a stem-matched ``from ... import``,
343
- the names bound to the constants module object itself, and every
344
- attribute node in the module.
345
- """
346
- member_names: set[str] = set()
347
- binding_names: set[str] = set()
348
- all_attribute_nodes: list[ast.Attribute] = []
349
- for each_node in ast.walk(tree):
350
- if isinstance(each_node, ast.ImportFrom):
351
- member_names |= _stem_import_member_names(each_node, module_stem)
352
- if isinstance(each_node, (ast.Import, ast.ImportFrom)):
353
- binding_names |= _import_module_bindings(each_node.names, module_stem)
354
- if isinstance(each_node, ast.Attribute):
355
- all_attribute_nodes.append(each_node)
356
- return member_names, binding_names, all_attribute_nodes
357
-
358
-
359
- def _attribute_read_names(
360
- all_attribute_nodes: list[ast.Attribute], all_binding_names: set[str]
361
- ) -> set[str]:
362
- """Return the constant names read as attributes on a module-object binding.
363
-
364
- The expression an attribute is read on is spelled back to its dotted source
365
- form and matched against the bindings, so an alias read (``sg.NAME``) and a
366
- full dotted path (``pkg.config.constants.NAME``) both resolve, while a read
367
- on a binding of some other module counts for nothing.
368
-
369
- Args:
370
- all_attribute_nodes: Every attribute node in the candidate module.
371
- all_binding_names: The names bound to the constants module object.
372
-
373
- Returns:
374
- The attribute names read on a binding of the constants module.
375
- """
376
- if not all_binding_names:
377
- return set()
378
- return {
379
- each_attribute.attr
380
- for each_attribute in all_attribute_nodes
381
- if _dotted_attribute_chain(each_attribute.value) in all_binding_names
382
- }
383
-
384
-
385
- def _widened_scan_reference_names(source: str, module_stem: str) -> set[str]:
386
- """Return the names a repository-wide candidate contributes, parsing it once.
387
-
388
- Both counted shapes are bound to the written module's filename stem, so a
389
- same-named constant exported by an unrelated module never masks a dead one.
390
-
391
- Args:
392
- source: The full text of a ``.py`` module under the repository root.
393
- module_stem: The filename stem of the constants module being judged.
394
-
395
- Returns:
396
- The members imported through a stem-matched ``from <module> import``,
397
- plus the constant names read as attributes on an imported binding of the
398
- constants module. A module that fails to parse contributes no names.
399
- """
400
- try:
401
- tree = ast.parse(source)
402
- except SyntaxError:
403
- return set()
404
- member_names, binding_names, all_attribute_nodes = _collect_widened_scan_facts(
405
- tree, module_stem
406
- )
407
- return member_names | _attribute_read_names(all_attribute_nodes, binding_names)
408
-
409
-
410
- def _scan_root_for_constants_module(file_path: str) -> Path:
411
- """Return the directory tree to scan for references to the module's constants.
412
-
413
- For a constants module inside a package subdirectory
414
- (``pkg/foo_constants.py``), the scan root is the package's parent, so an
415
- importer one directory up (``pkg/../consumer.py``) is in scope. For a
416
- constants module at the top of a directory, the scan root is that directory.
417
- A ``config/`` module's scan root is the parent of the ``config`` directory.
418
-
419
- Args:
420
- file_path: The destination path of the write.
421
-
422
- Returns:
423
- The absolute directory to scan recursively for references.
424
- """
425
- written_path = Path(file_path).resolve()
426
- enclosing_directory = written_path.parent
427
- if enclosing_directory.name.lower() == CONFIG_DIRECTORY_SEGMENT:
428
- return enclosing_directory.parent
429
- if (enclosing_directory / DUNDER_INIT_FILENAME).is_file():
430
- return enclosing_directory.parent
431
- return enclosing_directory
432
-
433
-
434
- def _is_under_directory(candidate_path: Path, ancestor_directory: Path) -> bool:
435
- """Return whether a resolved path lies inside a resolved ancestor directory.
436
-
437
- Args:
438
- candidate_path: The resolved file path to test.
439
- ancestor_directory: The resolved directory that may contain the path.
440
-
441
- Returns:
442
- True when ``candidate_path`` is the ancestor directory itself or a
443
- descendant of it, False otherwise.
444
- """
445
- try:
446
- candidate_path.relative_to(ancestor_directory)
447
- except ValueError:
448
- return False
449
- return True
450
-
451
-
452
- def _read_candidate_source(file_path: Path, required_substring: str | None) -> str | None:
453
- """Return a module's text, or None when it is not a reference-scan candidate.
454
-
455
- ::
456
-
457
- required_substring = None -> read and keep every file
458
- required_substring = the module stem -> keep only files naming it
459
- from pkg.foo_constants import BAR -> names the stem -> candidate
460
- from pkg import foo_constants as c -> names the stem -> candidate
461
- def unrelated() -> int: ... -> no stem mention -> skipped
462
-
463
- The widened repository pass looks only for an import bound to the constants
464
- module's filename stem — a ``from <module> import`` whose final dotted segment
465
- equals the stem, or an import of the module object itself — and every such
466
- import spells that stem in the file's text. A file whose text never
467
- mentions the stem cannot carry the import, so returning None for it lets the
468
- caller skip the file without spending scan-cap budget, which keeps the widened
469
- pass bounded to the candidate importer files even under a large repository.
470
-
471
- Args:
472
- file_path: The ``.py`` module to read.
473
- required_substring: A stem every candidate file's text must contain, or
474
- None to keep every readable file (the package-tree pass keeps all).
475
-
476
- Returns:
477
- The file's text when it is readable and, when ``required_substring`` is
478
- set, contains that stem; None when the file cannot be read or does not
479
- name the stem.
480
- """
481
- try:
482
- source_text = file_path.read_text(encoding="utf-8")
483
- except (OSError, UnicodeDecodeError):
484
- return None
485
- if required_substring is not None and required_substring not in source_text:
486
- return None
487
- return source_text
488
-
489
-
490
- def _collect_names_under_root(
491
- scan_root: Path,
492
- written_path: Path,
493
- all_seed_names: set[str],
494
- extract_names: Callable[[str], set[str]],
495
- already_scanned_count: int = 0,
496
- excluded_subtree: Path | None = None,
497
- required_substring: str | None = None,
498
- ) -> tuple[set[str], int, bool]:
499
- """Collect referenced names under the scan root via a per-module extractor.
500
-
501
- Walks every ``.py`` module under ``scan_root`` (excluding the written module
502
- itself, and any module under ``excluded_subtree``), applies ``extract_names``
503
- to each module's text, and unions the result onto ``all_seed_names``. Two
504
- caps bound the walk so a write under an unexpectedly large tree cannot
505
- stall the hook:
506
-
507
- ::
508
-
509
- read_attempt_count -> every file this call opens and reads,
510
- whether or not it turns out to be a
511
- candidate -> capped by MAX_SCAN_ROOT_READ_COUNT
512
- scanned_file_count -> only the files that pass the candidate
513
- filter and get parsed for names -> capped
514
- by MAX_SCAN_ROOT_FILE_COUNT
515
-
516
- Either cap tripping returns ``cap_was_hit=True``, which signals the caller
517
- to treat the write as "cannot prove dead". The ``excluded_subtree`` skip
518
- keeps the widened repository scan from re-reading a file the package-tree
519
- scan already covered. When ``required_substring`` is set, a module whose
520
- text never contains that stem is skipped after being read but before it is
521
- counted toward ``scanned_file_count`` or parsed, so the parse-and-collect
522
- work stays bounded to the candidate importer files even though the read
523
- cap still bounds the raw disk reads across the whole tree.
524
-
525
- Args:
526
- scan_root: The directory tree to scan.
527
- written_path: The resolved path of the module being written, skipped so
528
- its own text is judged through ``all_seed_names`` rather than the
529
- stale disk copy.
530
- all_seed_names: The names the written module itself contributes, unioned
531
- in before the walk begins.
532
- extract_names: Maps one module's source text to the set of names it
533
- contributes — the generous reference collector for the package-tree
534
- pass, and for the widened pass the stem-bound collector that counts
535
- both a stem-matched from-import and an attribute read on a binding
536
- of the written module.
537
- already_scanned_count: The parsed-file count accumulated by a prior
538
- pass, so the parse cap bounds the combined work of the
539
- package-tree and widened passes.
540
- excluded_subtree: A resolved directory whose ``.py`` modules are skipped,
541
- or None to scan every file under the root.
542
- required_substring: A stem a file's text must contain to count as a
543
- scan candidate, or None to scan every file. The widened pass passes
544
- the written module's filename stem so a file that never names the
545
- module is skipped before it is counted or parsed, spending parse
546
- budget only on the candidate importer files, while the read cap
547
- still bounds how many files get read looking for that stem.
548
-
549
- Returns:
550
- A (collected_names, running_count, cap_was_hit) triple. collected_names
551
- is ``all_seed_names`` unioned with every scanned module's contribution;
552
- running_count is the cumulative file count including
553
- ``already_scanned_count``; cap_was_hit is True when the scan stopped at
554
- the configured file cap before scanning the whole tree.
555
- """
556
- collected_names = set(all_seed_names)
557
- written_path_key = os.path.normcase(str(written_path))
558
- scanned_file_count = already_scanned_count
559
- read_attempt_count = 0
560
- for each_path in scan_root.rglob("*" + PYTHON_SOURCE_SUFFIX):
561
- if not each_path.is_file():
562
- continue
563
- resolved_path = each_path.resolve()
564
- if os.path.normcase(str(resolved_path)) == written_path_key:
565
- continue
566
- if excluded_subtree is not None and _is_under_directory(resolved_path, excluded_subtree):
567
- continue
568
- read_attempt_count += 1
569
- if read_attempt_count > MAX_SCAN_ROOT_READ_COUNT:
570
- return collected_names, scanned_file_count, True
571
- sibling_source = _read_candidate_source(each_path, required_substring)
572
- if sibling_source is None:
573
- continue
574
- scanned_file_count += 1
575
- if scanned_file_count > MAX_SCAN_ROOT_FILE_COUNT:
576
- return collected_names, scanned_file_count, True
577
- collected_names |= extract_names(sibling_source)
578
- return collected_names, scanned_file_count, False
579
-
580
-
581
- def _repository_root_for(written_path: Path) -> Path | None:
582
- """Return the nearest ancestor directory that holds a ``.git`` entry.
583
-
584
- Walks upward from the written module toward the filesystem root. A normal
585
- checkout carries a ``.git`` directory and a git worktree carries a ``.git``
586
- file; both satisfy ``exists()``. The repository root bounds the widened
587
- cross-tree reference scan.
588
-
589
- Args:
590
- written_path: The resolved path of the constants module being written.
591
-
592
- Returns:
593
- The repository root directory, or ``None`` when no ancestor carries a
594
- ``.git`` entry, so a module outside any repository triggers no widened
595
- scan.
596
- """
597
- for each_ancestor in written_path.parents:
598
- if (each_ancestor / GIT_DIRECTORY_NAME).exists():
599
- return each_ancestor
600
- return None
601
-
602
-
603
- def _module_is_exempt_from_constant_check(file_path: str) -> bool:
604
- """Return whether a path is exempt from the dead module-constant check.
605
-
606
- Test modules and migration modules are exempt, and any module that is not a
607
- dedicated constants module is out of scope because its file-global constants
608
- are governed by the use-count rule instead.
609
-
610
- Args:
611
- file_path: The destination path of the write.
612
-
613
- Returns:
614
- True when the dead module-constant check must not run on this path.
615
- """
616
- if is_test_file(file_path):
617
- return True
618
- if is_migration_file(file_path):
619
- return True
620
- return not _is_dedicated_constants_module(file_path)
621
-
622
-
623
- def _constants_under_check(tree: ast.Module) -> tuple[list[tuple[str, int]], bool]:
624
- """Return the constants to judge and whether the seed counts string literals.
625
-
626
- A module without ``__all__`` judges every module-scope constant and lets its
627
- own string literals seed the reference scan. A module declaring ``__all__``
628
- judges only the constants its ``__all__`` list names — the explicit export
629
- surface — and withholds its own string literals from the seed, so an
630
- ``__all__`` entry never counts as the consumer that keeps an exported constant
631
- live. A constant the module defines but ``__all__`` omits is the author's
632
- stated private value and is left out of the judged set.
633
-
634
- Args:
635
- tree: The parsed constants module.
636
-
637
- Returns:
638
- A (definitions, seed_collect_string_literals) pair: the (name, line)
639
- constants to judge, and whether the written module's string literals seed
640
- the referenced-name set.
641
- """
642
- constant_definitions = _module_constant_definitions(tree)
643
- if not _module_declares_dunder_all(tree):
644
- return constant_definitions, True
645
- exported_names = _dunder_all_member_names(tree)
646
- exported_definitions = [
647
- (each_name, each_line)
648
- for each_name, each_line in constant_definitions
649
- if each_name in exported_names
650
- ]
651
- return exported_definitions, False
652
-
653
-
654
- def check_dead_module_constants(
655
- content: str,
656
- file_path: str,
657
- full_file_content: str | None = None,
658
- ) -> list[str]:
659
- """Flag an UPPER_SNAKE constant in a constants module read by no module.
660
-
661
- Runs only on a dedicated constants module (``*_constants.py`` or a module
662
- under ``config/``); every other production module's file-global constants
663
- are governed by the use-count rule instead. A constant is dead when its name
664
- appears in no ``.py`` module under the enclosing package tree — not imported,
665
- not read, not listed in another module's ``__all__`` literal, not named in a
666
- string annotation. When that leaves a constant unreferenced, the scan widens
667
- to the repository, where two shapes count: a ``from <module> import`` whose
668
- final dotted segment equals this module's filename stem, and an attribute
669
- read on a binding of this module (``import pkg.config.constants as alias``
670
- then the alias followed by the constant name). Binding both to the stem
671
- keeps a same-named constant in an unrelated module from masking a dead one.
672
- A module declaring ``__all__``
673
- narrows the check to the constants its ``__all__`` list names: each must be
674
- imported or read by another module, and the module's own ``__all__`` entry
675
- never counts as that consumer, so an exported constant no module consumes is
676
- flagged; a constant the module defines but ``__all__`` omits is the author's
677
- private value and is left alone. A scan whose combined package-tree and
678
- widened file count exceeds the configured cap returns ``[]`` (cannot prove
679
- dead), bounding the work so the blocking hook cannot stall under a large tree.
680
- Whole-file analysis runs against ``full_file_content`` when supplied so an
681
- Edit fragment is judged against the reconstructed post-edit file.
682
-
683
- Args:
684
- content: The new content under validation (Edit fragment or whole file).
685
- file_path: The destination path, used for the constants-module gate and
686
- the test/registry exemptions.
687
- full_file_content: The reconstructed post-edit whole-file content for an
688
- Edit, or None for a Write where ``content`` is already the whole file.
689
-
690
- Returns:
691
- One violation message per dead module-level constant, capped at the
692
- configured maximum. Returns an empty list when the file is exempt, the
693
- path is relative (the scan root cannot be resolved against a known
694
- base, so the check cannot prove a constant dead), no constant is in
695
- scope (none defined, or none exported when ``__all__`` is declared),
696
- the scan exceeds the file cap, or a SyntaxError prevents parsing.
697
- """
698
- if _module_is_exempt_from_constant_check(file_path):
699
- return []
700
- if not Path(file_path).is_absolute():
701
- return []
702
- effective_content = content if full_file_content is None else full_file_content
703
- try:
704
- tree = ast.parse(effective_content)
705
- except SyntaxError:
706
- return []
707
- constant_definitions, seed_collect_string_literals = _constants_under_check(tree)
708
- if not constant_definitions:
709
- return []
710
- scan_root = _scan_root_for_constants_module(file_path)
711
- written_path = Path(file_path).resolve()
712
- written_seed_names = _referenced_names_in_source(
713
- effective_content,
714
- load_only=True,
715
- collect_string_literals=seed_collect_string_literals,
716
- )
717
- all_referenced_names, scanned_file_count, cap_was_hit = _collect_names_under_root(
718
- scan_root,
719
- written_path,
720
- written_seed_names,
721
- _referenced_names_in_source,
722
- )
723
- if cap_was_hit:
724
- return []
725
- has_unreferenced_constant = any(
726
- each_name not in all_referenced_names for each_name, _ in constant_definitions
727
- )
728
- if has_unreferenced_constant:
729
- repository_root = _repository_root_for(written_path)
730
- if repository_root is not None and repository_root != scan_root:
731
- collect_widened_scan_names = partial(
732
- _widened_scan_reference_names, module_stem=written_path.stem
733
- )
734
- widened_names, _widened_count, widened_cap_was_hit = _collect_names_under_root(
735
- repository_root,
736
- written_path,
737
- set(),
738
- collect_widened_scan_names,
739
- already_scanned_count=scanned_file_count,
740
- excluded_subtree=scan_root,
741
- required_substring=written_path.stem,
742
- )
743
- if widened_cap_was_hit:
744
- return []
745
- all_referenced_names |= widened_names
746
- issues: list[str] = []
747
- for each_name, each_line in constant_definitions:
748
- if each_name in all_referenced_names:
749
- continue
750
- issues.append(
751
- f"Line {each_line}: module-level constant {each_name!r} in"
752
- f" {written_path.name} - {DEAD_MODULE_CONSTANT_GUIDANCE}"
753
- f" {DEAD_MODULE_CONSTANT_RETRY_GUIDANCE}"
754
- )
755
- if len(issues) >= MAX_DEAD_MODULE_CONSTANT_ISSUES:
756
- break
757
- return issues