claude-dev-env 2.27.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.
- package/.agents/skills/hitl/SKILL.md +45 -0
- package/.agents/skills-archived/pr-loop-cloud-transport/reference/identity-and-hooks.md +1 -1
- package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +16 -29
- package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +2 -2
- package/bin/ever-shipped-skills.mjs +1 -0
- package/docs/CODE_RULES.md +3 -3
- package/docs/agent-spawn-protocol.md +1 -1
- package/docs/references/prose-style-enforcement.md +5 -9
- package/hooks/blocking/AGENTS.md +3 -13
- package/hooks/blocking/code_rules_docstrings.py +10 -2138
- package/hooks/blocking/code_rules_enforcer.py +0 -121
- package/hooks/blocking/code_rules_imports_logging.py +1 -236
- package/hooks/blocking/code_rules_shared.py +23 -0
- package/hooks/blocking/code_rules_test_layout.py +8 -8
- package/hooks/blocking/config/prose_style_enforcement_constants.py +4 -2
- package/hooks/blocking/config/test_prose_style_enforcement_constants.py +5 -1
- package/hooks/blocking/precommit_code_rules_gate.py +8 -43
- package/hooks/blocking/state_description_blocker.py +1 -7
- package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +0 -1
- package/hooks/blocking/test_code_rules_enforcer_dispatch_wiring.py +0 -8
- package/hooks/blocking/test_code_rules_enforcer_module_docstring_roster.py +11 -112
- package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +0 -1
- package/hooks/blocking/test_code_rules_enforcer_split_entry_1.py +1 -18
- package/hooks/blocking/test_code_rules_shared.py +12 -0
- package/hooks/blocking/test_precommit_code_rules_gate.py +32 -179
- package/hooks/blocking/test_precommit_code_rules_gate_native_owner.py +0 -1
- package/hooks/blocking/test_state_description_blocker.py +6 -4
- package/hooks/blocking/test_stop_dispatcher.py +5 -29
- package/hooks/hooks_constants/AGENTS.md +1 -6
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +0 -1
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +3 -0
- package/hooks/hooks_constants/messages.py +0 -2
- package/hooks/hooks_constants/precommit_code_rules_gate_constants.py +3 -17
- package/hooks/hooks_constants/stop_dispatcher_constants.py +0 -2
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +0 -1
- package/hooks/hooks_constants/test_code_rules_enforcer_constants.py +7 -0
- package/hooks/hooks_constants/test_messages.py +5 -3
- package/hooks/hooks_constants/test_stop_dispatcher_constants.py +0 -2
- package/package.json +1 -1
- package/rules/AGENTS.md +3 -6
- package/rules/claims-as-quotes.md +0 -10
- package/rules/code-standards.md +6 -6
- package/rules/explore-thoroughly.md +0 -1
- package/rules/failure-blast-radius.md +0 -8
- package/rules/falsify-before-green.md +0 -8
- package/rules/file-global-constants.md +2 -2
- package/rules/filesystem-search.md +1 -1
- package/rules/git-workflow.md +1 -9
- package/rules/hedging-claims.md +2 -6
- package/rules/long-horizon-autonomy.md +1 -1
- package/rules/measurement-denominators.md +0 -9
- package/rules/research-mode.md +0 -6
- package/rules/verify-before-asking.md +0 -5
- package/rules/verify-runtime-state.md +0 -5
- package/scripts/codex_compat_materializer.py +99 -24
- package/scripts/sync_to_cursor/rules.py +0 -10
- package/scripts/tests/test_codex_compat_materializer.py +70 -19
- package/scripts/tests/test_engine.py +0 -1
- package/scripts/tests/test_rules.py +0 -1
- package/scripts/tests/test_sync_to_cursor.py +0 -1
- package/system-prompts/software-engineer.xml +3 -3
- package/hooks/blocking/code_rules_dead_argparse_argument.py +0 -554
- package/hooks/blocking/code_rules_dead_config_field.py +0 -568
- package/hooks/blocking/code_rules_dead_dataclass_field.py +0 -348
- package/hooks/blocking/code_rules_dead_module_constant.py +0 -757
- package/hooks/blocking/code_rules_dead_split_branch.py +0 -225
- package/hooks/blocking/code_rules_mock_completeness.py +0 -295
- package/hooks/blocking/code_rules_scope_binding.py +0 -151
- package/hooks/blocking/code_rules_unused_imports.py +0 -197
- package/hooks/blocking/hedging_language_blocker.py +0 -221
- package/hooks/blocking/intent_only_ending_blocker.py +0 -148
- package/hooks/blocking/test_code_rules_enforcer_dead_argparse_argument.py +0 -534
- package/hooks/blocking/test_code_rules_enforcer_dead_config_field.py +0 -846
- package/hooks/blocking/test_code_rules_enforcer_dead_dataclass_field.py +0 -507
- package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +0 -679
- package/hooks/blocking/test_code_rules_enforcer_dead_module_constant_alias.py +0 -133
- package/hooks/blocking/test_code_rules_enforcer_dead_module_constant_read_cap.py +0 -103
- package/hooks/blocking/test_code_rules_enforcer_dead_split_branch.py +0 -105
- package/hooks/blocking/test_code_rules_enforcer_docstring_args_span_scope.py +0 -425
- package/hooks/blocking/test_code_rules_enforcer_docstring_cardinal_family.py +0 -176
- package/hooks/blocking/test_code_rules_enforcer_docstring_delegation_summary.py +0 -385
- package/hooks/blocking/test_code_rules_enforcer_docstring_fallback_branch.py +0 -398
- package/hooks/blocking/test_code_rules_enforcer_docstring_field_runmode_outcome.py +0 -129
- package/hooks/blocking/test_code_rules_enforcer_docstring_inline_literal_claim.py +0 -93
- package/hooks/blocking/test_code_rules_enforcer_docstring_length_constant_superlative.py +0 -198
- package/hooks/blocking/test_code_rules_enforcer_docstring_mark_glyph_enumeration.py +0 -262
- package/hooks/blocking/test_code_rules_enforcer_docstring_no_consumer.py +0 -93
- package/hooks/blocking/test_code_rules_enforcer_docstring_no_network.py +0 -115
- package/hooks/blocking/test_code_rules_enforcer_docstring_raises_largezipfile.py +0 -226
- package/hooks/blocking/test_code_rules_enforcer_docstring_returns_plural_cardinality.py +0 -207
- package/hooks/blocking/test_code_rules_enforcer_docstring_step_dispatch.py +0 -262
- package/hooks/blocking/test_code_rules_enforcer_docstring_type_checking_gate.py +0 -164
- package/hooks/blocking/test_code_rules_enforcer_docstring_unguarded_payload.py +0 -188
- package/hooks/blocking/test_code_rules_enforcer_import_block_sort.py +0 -157
- package/hooks/blocking/test_code_rules_enforcer_split_mocks_1.py +0 -303
- package/hooks/blocking/test_code_rules_enforcer_split_mocks_2.py +0 -111
- package/hooks/blocking/test_code_rules_enforcer_unused_imports.py +0 -656
- package/hooks/blocking/test_hedging_language_blocker.py +0 -261
- package/hooks/blocking/test_intent_only_ending_blocker.py +0 -209
- package/hooks/hooks_constants/dead_argparse_argument_constants.py +0 -28
- package/hooks/hooks_constants/dead_config_field_constants.py +0 -39
- package/hooks/hooks_constants/dead_dataclass_field_constants.py +0 -25
- package/hooks/hooks_constants/dead_module_constant_constants.py +0 -30
- package/hooks/hooks_constants/hedging_uncertainty_constants.py +0 -42
- package/hooks/hooks_constants/test_dispatcher_constants_docstrings.py +0 -44
- package/rules/conservative-action.md +0 -17
- package/rules/context7.md +0 -8
- package/rules/few-words.md +0 -3
- 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
|