claude-dev-env 1.58.0 → 1.60.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 (106) hide show
  1. package/CLAUDE.md +2 -2
  2. package/_shared/pr-loop/scripts/code_rules_gate.py +36 -3
  3. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +6 -0
  4. package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviews_disabled_constants.py +1 -0
  5. package/_shared/pr-loop/scripts/reviews_disabled.py +12 -0
  6. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +265 -0
  7. package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +29 -0
  8. package/audit-rubrics/category_rubrics/category-b-selector-engine-compat.md +1 -1
  9. package/audit-rubrics/category_rubrics/category-e-dead-code.md +1 -0
  10. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
  11. package/audit-rubrics/prompts/category-b-selector-engine-compat.md +2 -2
  12. package/bin/install.mjs +100 -27
  13. package/bin/install.test.mjs +133 -1
  14. package/docs/CODE_RULES.md +3 -3
  15. package/hooks/blocking/code_rules_annotations_length.py +153 -0
  16. package/hooks/blocking/code_rules_dead_dataclass_field.py +319 -0
  17. package/hooks/blocking/code_rules_dead_module_constant.py +321 -0
  18. package/hooks/blocking/code_rules_duplicate_body.py +439 -0
  19. package/hooks/blocking/code_rules_enforcer.py +190 -21
  20. package/hooks/blocking/code_rules_magic_values.py +98 -0
  21. package/hooks/blocking/code_rules_shared.py +41 -0
  22. package/hooks/blocking/code_rules_typeddict_stub.py +172 -0
  23. package/hooks/blocking/config/__init__.py +5 -0
  24. package/hooks/blocking/config/verified_commit_constants.py +106 -0
  25. package/hooks/blocking/destructive_command_blocker.py +1027 -12
  26. package/hooks/blocking/hook_prose_detector_consistency.py +150 -0
  27. package/hooks/blocking/subprocess_budget_completeness.py +380 -0
  28. package/hooks/blocking/test_code_rules_enforcer_annotations.py +225 -0
  29. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +1 -0
  30. package/hooks/blocking/test_code_rules_enforcer_cross_skill_duplicate.py +146 -0
  31. package/hooks/blocking/test_code_rules_enforcer_dead_dataclass_field.py +467 -0
  32. package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +188 -0
  33. package/hooks/blocking/test_code_rules_enforcer_duplicate_body.py +330 -0
  34. package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +179 -0
  35. package/hooks/blocking/test_code_rules_enforcer_magic_slice_bounds.py +133 -0
  36. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias.py +415 -0
  37. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +156 -0
  38. package/hooks/blocking/test_destructive_command_blocker.py +622 -3
  39. package/hooks/blocking/test_hook_prose_detector_consistency.py +265 -0
  40. package/hooks/blocking/test_subprocess_budget_completeness.py +588 -0
  41. package/hooks/blocking/test_verdict_directory_write_blocker.py +720 -0
  42. package/hooks/blocking/test_verification_verdict_store.py +278 -0
  43. package/hooks/blocking/test_verified_commit_gate.py +368 -0
  44. package/hooks/blocking/test_verified_commit_message_accuracy_blocker.py +131 -0
  45. package/hooks/blocking/test_verifier_verdict_minter.py +214 -0
  46. package/hooks/blocking/test_workflow_substitution_slot_blocker.py +242 -0
  47. package/hooks/blocking/verdict_directory_write_blocker.py +667 -0
  48. package/hooks/blocking/verification_verdict_store.py +446 -0
  49. package/hooks/blocking/verified_commit_gate.py +523 -0
  50. package/hooks/blocking/verified_commit_message_accuracy_blocker.py +152 -0
  51. package/hooks/blocking/verifier_verdict_minter.py +299 -0
  52. package/hooks/blocking/workflow_substitution_slot_blocker.py +159 -0
  53. package/hooks/diagnostic/test_hook_log_extractor.py +3 -3
  54. package/hooks/hooks.json +58 -1
  55. package/hooks/hooks_constants/blocking_check_limits.py +1 -0
  56. package/hooks/hooks_constants/code_rules_enforcer_constants.py +16 -0
  57. package/hooks/hooks_constants/dead_dataclass_field_constants.py +25 -0
  58. package/hooks/hooks_constants/dead_module_constant_constants.py +20 -0
  59. package/hooks/hooks_constants/destructive_command_segment_constants.py +178 -0
  60. package/hooks/hooks_constants/duplicate_function_body_constants.py +34 -0
  61. package/hooks/hooks_constants/hook_prose_detector_consistency_constants.py +30 -0
  62. package/hooks/hooks_constants/precommit_code_rules_gate_constants.py +1 -1
  63. package/hooks/hooks_constants/subprocess_budget_completeness_constants.py +5 -0
  64. package/hooks/hooks_constants/workflow_substitution_slot_blocker_constants.py +22 -0
  65. package/package.json +1 -1
  66. package/rules/docstring-prose-matches-implementation.md +43 -0
  67. package/rules/file-global-constants.md +7 -1
  68. package/rules/hook-prose-matches-detector.md +26 -0
  69. package/rules/no-cross-skill-duplicate-helpers.md +29 -0
  70. package/rules/no-inline-destructive-literals.md +11 -0
  71. package/rules/workflow-substitution-slots.md +7 -0
  72. package/skills/_shared/pr-loop/scripts/preflight_worktree.py +392 -0
  73. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/preflight_constants.py +70 -0
  74. package/skills/_shared/pr-loop/scripts/test_preflight_worktree.py +263 -0
  75. package/skills/autoconverge/SKILL.md +67 -19
  76. package/skills/autoconverge/reference/closing-report.md +59 -17
  77. package/skills/autoconverge/reference/convergence.md +7 -3
  78. package/skills/autoconverge/reference/stop-conditions.md +7 -2
  79. package/skills/autoconverge/workflow/aggregate_runs.py +371 -0
  80. package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +193 -76
  81. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +76 -0
  82. package/skills/autoconverge/workflow/converge.contract.test.mjs +206 -206
  83. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +265 -0
  84. package/skills/autoconverge/workflow/converge.mjs +234 -42
  85. package/skills/autoconverge/workflow/convergence_summary.py +110 -0
  86. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-ab1c2d3e4f5a6b7c8.jsonl +2 -0
  87. package/skills/autoconverge/workflow/fixtures/wf_run/workflows/wf_881252e6-700.json +7 -0
  88. package/skills/autoconverge/workflow/render_report.py +488 -397
  89. package/skills/autoconverge/workflow/test_aggregate_runs.py +134 -0
  90. package/skills/autoconverge/workflow/test_convergence_summary.py +132 -0
  91. package/skills/autoconverge/workflow/test_render_report.py +488 -259
  92. package/skills/pr-converge/reference/per-tick.md +28 -8
  93. package/skills/pr-converge/scripts/check_convergence.py +195 -64
  94. package/skills/pr-converge/scripts/test_check_convergence.py +173 -2
  95. package/skills/rebase/SKILL.md +2 -4
  96. package/skills/update/SKILL.md +37 -5
  97. package/system-prompts/software-engineer.xml +2 -6
  98. package/hooks/blocking/content_search_to_zoekt_redirector.py +0 -59
  99. package/hooks/blocking/content_search_zoekt_bash_block_reason.py +0 -25
  100. package/hooks/blocking/content_search_zoekt_block_payload.py +0 -21
  101. package/hooks/blocking/content_search_zoekt_indexed_paths.py +0 -24
  102. package/hooks/blocking/content_search_zoekt_indexed_roots_config.py +0 -131
  103. package/hooks/blocking/content_search_zoekt_redirect_guidance.py +0 -52
  104. package/hooks/blocking/test_content_search_to_zoekt_redirector_integration.py +0 -61
  105. package/hooks/blocking/test_content_search_to_zoekt_redirector_unit.py +0 -92
  106. package/hooks/blocking/test_content_search_zoekt_indexed_roots_config.py +0 -102
@@ -0,0 +1,319 @@
1
+ """Dead dataclass-field check: a @dataclass field assigned but never read in the same file."""
2
+
3
+ import ast
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ _blocking_directory = str(Path(__file__).resolve().parent)
8
+ _hooks_directory = str(Path(__file__).resolve().parent.parent)
9
+ if _blocking_directory not in sys.path:
10
+ sys.path.insert(0, _blocking_directory)
11
+ if _hooks_directory not in sys.path:
12
+ sys.path.insert(0, _hooks_directory)
13
+
14
+ from code_rules_shared import ( # noqa: E402
15
+ is_migration_file,
16
+ is_test_file,
17
+ )
18
+
19
+ from hooks_constants.dead_dataclass_field_constants import ( # noqa: E402
20
+ ALL_DATACLASS_DECORATOR_NAMES,
21
+ ALL_WHOLE_INSTANCE_STRINGIFY_NAMES,
22
+ ATTRGETTER_FUNCTION_NAME,
23
+ CLASSVAR_ANNOTATION_NAME,
24
+ DEAD_DATACLASS_FIELD_GUIDANCE,
25
+ GETATTR_FUNCTION_NAME,
26
+ GETATTR_NAME_ARGUMENT_MINIMUM,
27
+ MAX_DEAD_DATACLASS_FIELD_ISSUES,
28
+ ALL_REFLECTIVE_FIELD_CONSUMER_NAMES,
29
+ WHOLE_INSTANCE_DICT_ATTRIBUTE_NAME,
30
+ )
31
+
32
+
33
+ def _decorator_calls_dataclass(decorator_node: ast.expr) -> bool:
34
+ """Return whether a decorator expression applies @dataclass (bare or called)."""
35
+ target_node = (
36
+ decorator_node.func if isinstance(decorator_node, ast.Call) else decorator_node
37
+ )
38
+ if isinstance(target_node, ast.Name):
39
+ return target_node.id in ALL_DATACLASS_DECORATOR_NAMES
40
+ if isinstance(target_node, ast.Attribute):
41
+ return target_node.attr in ALL_DATACLASS_DECORATOR_NAMES
42
+ return False
43
+
44
+
45
+ def _is_dataclass(class_node: ast.ClassDef) -> bool:
46
+ return any(
47
+ _decorator_calls_dataclass(each_decorator)
48
+ for each_decorator in class_node.decorator_list
49
+ )
50
+
51
+
52
+ def _annotation_is_classvar(annotation_node: ast.expr | None) -> bool:
53
+ if annotation_node is None:
54
+ return False
55
+ if isinstance(annotation_node, ast.Name):
56
+ return annotation_node.id == CLASSVAR_ANNOTATION_NAME
57
+ if isinstance(annotation_node, ast.Attribute):
58
+ return annotation_node.attr == CLASSVAR_ANNOTATION_NAME
59
+ if isinstance(annotation_node, ast.Subscript):
60
+ return _annotation_is_classvar(annotation_node.value)
61
+ return False
62
+
63
+
64
+ def _dataclass_field_definitions(class_node: ast.ClassDef) -> list[tuple[str, int]]:
65
+ """Return (field_name, line) for each instance field declared in a dataclass body."""
66
+ fields: list[tuple[str, int]] = []
67
+ for each_statement in class_node.body:
68
+ if not isinstance(each_statement, ast.AnnAssign):
69
+ continue
70
+ if not isinstance(each_statement.target, ast.Name):
71
+ continue
72
+ if _annotation_is_classvar(each_statement.annotation):
73
+ continue
74
+ fields.append((each_statement.target.id, each_statement.lineno))
75
+ return fields
76
+
77
+
78
+ def _string_constant_literal(node: ast.expr) -> str | None:
79
+ if isinstance(node, ast.Constant) and isinstance(node.value, str):
80
+ return node.value
81
+ return None
82
+
83
+
84
+ def _dynamic_access_names(tree: ast.Module) -> tuple[set[str], bool]:
85
+ """Return literal attribute-name reads and whether the check must be suppressed.
86
+
87
+ Walks every ``getattr(obj, "name")`` and ``operator.attrgetter("a", "b")``
88
+ call, contributing each literal string name as a read attribute name —
89
+ ``getattr`` names its attribute in the second positional argument while
90
+ ``attrgetter`` names one attribute per positional argument. A non-literal
91
+ name argument, or any reflective whole-instance consumer (``asdict``,
92
+ ``astuple``, ``fields``, ``replace``, ``vars``) that reads every field at
93
+ once, means a field cannot be proven unread, so the boolean signals the
94
+ caller to suppress the check for the whole file.
95
+ """
96
+ literal_names: set[str] = set()
97
+ should_suppress_check = False
98
+ for each_node in ast.walk(tree):
99
+ if not isinstance(each_node, ast.Call):
100
+ continue
101
+ function_node = each_node.func
102
+ function_name = None
103
+ if isinstance(function_node, ast.Name):
104
+ function_name = function_node.id
105
+ elif isinstance(function_node, ast.Attribute):
106
+ function_name = function_node.attr
107
+ if function_name in ALL_REFLECTIVE_FIELD_CONSUMER_NAMES:
108
+ should_suppress_check = True
109
+ continue
110
+ if function_name not in {GETATTR_FUNCTION_NAME, ATTRGETTER_FUNCTION_NAME}:
111
+ continue
112
+ string_arguments = [
113
+ argument
114
+ for argument in each_node.args
115
+ if not isinstance(argument, ast.Starred)
116
+ ]
117
+ if function_name == GETATTR_FUNCTION_NAME:
118
+ name_arguments = (
119
+ [string_arguments[1]]
120
+ if len(string_arguments) >= GETATTR_NAME_ARGUMENT_MINIMUM
121
+ else []
122
+ )
123
+ else:
124
+ name_arguments = string_arguments
125
+ if not name_arguments:
126
+ should_suppress_check = True
127
+ continue
128
+ for each_name_argument in name_arguments:
129
+ literal_name = _string_constant_literal(each_name_argument)
130
+ if literal_name is None:
131
+ should_suppress_check = True
132
+ else:
133
+ literal_names.add(literal_name)
134
+ return literal_names, should_suppress_check
135
+
136
+
137
+ def _augmented_assignment_attribute_names(tree: ast.Module) -> set[str]:
138
+ """Return attribute names that an augmented assignment reads before writing.
139
+
140
+ ``obj.field += value`` parses to an ``ast.Attribute`` target in Store
141
+ context, yet ``+=`` reads the current attribute value before storing the
142
+ result, so the target attribute counts as a read.
143
+ """
144
+ augmented_read_names: set[str] = set()
145
+ for each_node in ast.walk(tree):
146
+ if not isinstance(each_node, ast.AugAssign):
147
+ continue
148
+ if isinstance(each_node.target, ast.Attribute):
149
+ augmented_read_names.add(each_node.target.attr)
150
+ return augmented_read_names
151
+
152
+
153
+ def _attribute_read_names(tree: ast.Module) -> tuple[set[str], bool]:
154
+ """Return literal attribute-name reads and whether the check must be suppressed.
155
+
156
+ Walks every attribute read (Load context) and every augmented-assignment
157
+ target in the module, contributing each attribute name as a read name —
158
+ ``obj.field += value`` reads ``field`` before writing it. A read of
159
+ ``__dict__`` consumes every field of an instance at once, so it cannot prove
160
+ any single field unread and the boolean signals the caller to suppress the
161
+ check for the whole file.
162
+ """
163
+ read_names: set[str] = _augmented_assignment_attribute_names(tree)
164
+ should_suppress_check = False
165
+ for each_node in ast.walk(tree):
166
+ if not isinstance(each_node, ast.Attribute) or not isinstance(
167
+ each_node.ctx, ast.Load
168
+ ):
169
+ continue
170
+ if each_node.attr == WHOLE_INSTANCE_DICT_ATTRIBUTE_NAME:
171
+ should_suppress_check = True
172
+ continue
173
+ read_names.add(each_node.attr)
174
+ return read_names, should_suppress_check
175
+
176
+
177
+ def _match_pattern_attribute_names(tree: ast.Module) -> set[str]:
178
+ """Return field names that a class pattern reads in a ``match`` statement.
179
+
180
+ A class pattern ``case Row(url=found):`` reads the ``url`` field through
181
+ ``ast.MatchClass.kwd_attrs``, so each keyword-pattern attribute name counts
182
+ as a read name even though it never appears as an attribute access.
183
+ """
184
+ matched_names: set[str] = set()
185
+ for each_node in ast.walk(tree):
186
+ if isinstance(each_node, ast.MatchClass):
187
+ matched_names.update(each_node.kwd_attrs)
188
+ return matched_names
189
+
190
+
191
+ def _exported_names(tree: ast.Module) -> set[str]:
192
+ """Return names listed in a module-level ``__all__`` literal."""
193
+ exported: set[str] = set()
194
+ for each_node in tree.body:
195
+ if not isinstance(each_node, ast.Assign):
196
+ continue
197
+ targets_all = any(
198
+ isinstance(each_target, ast.Name) and each_target.id == "__all__"
199
+ for each_target in each_node.targets
200
+ )
201
+ if not targets_all:
202
+ continue
203
+ if isinstance(each_node.value, (ast.List, ast.Tuple, ast.Set)):
204
+ for each_element in each_node.value.elts:
205
+ literal_name = _string_constant_literal(each_element)
206
+ if literal_name is not None:
207
+ exported.add(literal_name)
208
+ return exported
209
+
210
+
211
+ def _constructed_class_names(tree: ast.Module) -> set[str]:
212
+ """Return names of classes instantiated by a direct call anywhere in the module."""
213
+ constructed: set[str] = set()
214
+ for each_node in ast.walk(tree):
215
+ if isinstance(each_node, ast.Call) and isinstance(each_node.func, ast.Name):
216
+ constructed.add(each_node.func.id)
217
+ return constructed
218
+
219
+
220
+ def _is_whole_instance_stringify_call(node: ast.AST) -> bool:
221
+ """Return whether a call stringifies a whole instance via ``str``/``repr``/``format``."""
222
+ if not isinstance(node, ast.Call):
223
+ return False
224
+ function_node = node.func
225
+ if isinstance(function_node, ast.Name):
226
+ return function_node.id in ALL_WHOLE_INSTANCE_STRINGIFY_NAMES
227
+ if isinstance(function_node, ast.Attribute):
228
+ return function_node.attr in ALL_WHOLE_INSTANCE_STRINGIFY_NAMES
229
+ return False
230
+
231
+
232
+ def _uses_dataclass_dunder_field_reads(tree: ast.Module) -> bool:
233
+ """Return whether the file relies on auto-generated dataclass dunders to read fields.
234
+
235
+ ``@dataclass`` synthesizes ``__eq__`` (and ``__lt__``/``__hash__`` under
236
+ ``order``/``frozen``) plus the always-present ``__repr__``, each of which
237
+ reads every field without naming it as an attribute access. Comparing two
238
+ instances, placing instances in a set or dict, formatted-string conversion,
239
+ or stringifying a whole instance therefore reads fields the static scan
240
+ cannot otherwise observe, so the check is suppressed for the whole file.
241
+ """
242
+ for each_node in ast.walk(tree):
243
+ if isinstance(each_node, ast.Compare):
244
+ return True
245
+ if isinstance(each_node, (ast.Set, ast.SetComp, ast.Dict, ast.DictComp)):
246
+ return True
247
+ if isinstance(each_node, ast.FormattedValue):
248
+ return True
249
+ if _is_whole_instance_stringify_call(each_node):
250
+ return True
251
+ return False
252
+
253
+
254
+ def check_dead_dataclass_fields(
255
+ content: str, file_path: str, full_file_content: str | None = None
256
+ ) -> list[str]:
257
+ """Flag a @dataclass field that the same file constructs but never reads.
258
+
259
+ A field is dead when its dataclass is instantiated somewhere in the file
260
+ (so the class is live), the field name never appears as an attribute read,
261
+ an augmented-assignment target, a class-pattern keyword, or a literal
262
+ ``getattr``/``attrgetter`` access anywhere in the file, and the file contains
263
+ no non-literal dynamic access, reflective whole-instance consumer
264
+ (``asdict``, ``astuple``, ``fields``, ``replace``, ``vars``), ``__dict__``
265
+ read, or auto-generated dataclass dunder field read (comparison, set/dict
266
+ membership, or whole-instance stringification) that could read it
267
+ indirectly. Whole-file analysis runs against ``full_file_content`` when
268
+ supplied so an Edit fragment is judged against the reconstructed post-edit
269
+ file.
270
+
271
+ Args:
272
+ content: The new content under validation (Edit fragment or whole file).
273
+ file_path: The destination path, used for the test/registry exemptions.
274
+ full_file_content: The reconstructed post-edit whole-file content for an
275
+ Edit, or None for a Write where ``content`` is already the whole file.
276
+
277
+ Returns:
278
+ One violation message per dead dataclass field, capped at the configured
279
+ maximum.
280
+ """
281
+ if is_test_file(file_path):
282
+ return []
283
+ if is_migration_file(file_path):
284
+ return []
285
+ effective_content = content if full_file_content is None else full_file_content
286
+ try:
287
+ tree = ast.parse(effective_content)
288
+ except SyntaxError:
289
+ return []
290
+ if _uses_dataclass_dunder_field_reads(tree):
291
+ return []
292
+ dynamic_literal_names, dynamic_access_suppresses_check = _dynamic_access_names(tree)
293
+ attribute_read_names, instance_dict_suppresses_check = _attribute_read_names(tree)
294
+ if dynamic_access_suppresses_check or instance_dict_suppresses_check:
295
+ return []
296
+ read_names = (
297
+ attribute_read_names
298
+ | dynamic_literal_names
299
+ | _match_pattern_attribute_names(tree)
300
+ | _exported_names(tree)
301
+ )
302
+ constructed_class_names = _constructed_class_names(tree)
303
+ issues: list[str] = []
304
+ for each_node in ast.walk(tree):
305
+ if not isinstance(each_node, ast.ClassDef) or not _is_dataclass(each_node):
306
+ continue
307
+ if each_node.name not in constructed_class_names:
308
+ continue
309
+ for each_field_definition in _dataclass_field_definitions(each_node):
310
+ field_name, field_line = each_field_definition
311
+ if field_name in read_names:
312
+ continue
313
+ issues.append(
314
+ f"Line {field_line}: dataclass field {field_name!r} on {each_node.name}"
315
+ f" - {DEAD_DATACLASS_FIELD_GUIDANCE}"
316
+ )
317
+ if len(issues) >= MAX_DEAD_DATACLASS_FIELD_ISSUES:
318
+ return issues
319
+ return issues
@@ -0,0 +1,321 @@
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. That is the ``MEDIUM_TEXT``-style dead constant the
10
+ CODE_RULES §9.8 dead-code rule targets, caught at Write/Edit time before the
11
+ unused constant lands.
12
+
13
+ The scan is deliberately conservative to keep false positives near zero:
14
+
15
+ - Only dedicated constants modules participate; ordinary production modules,
16
+ whose file-global constants are governed by the use-count rule, are skipped.
17
+ - A module declaring ``__all__`` is skipped: the author has named its export
18
+ surface explicitly, so a name listed there is live by declaration and a name
19
+ absent there is the author's stated intent, neither of which this check second
20
+ guesses.
21
+ - A constant is live when its name appears anywhere under the scan root —
22
+ imported, read, listed in ``__all__``, or referenced in a string annotation —
23
+ in any ``.py`` module, including the constants module itself.
24
+ - Test modules under the scan root still count as references, so a constant used
25
+ only by a test stays live.
26
+ """
27
+
28
+ import ast
29
+ import os
30
+ import sys
31
+ from pathlib import Path
32
+
33
+ _blocking_directory = str(Path(__file__).resolve().parent)
34
+ _hooks_directory = str(Path(__file__).resolve().parent.parent)
35
+ if _blocking_directory not in sys.path:
36
+ sys.path.insert(0, _blocking_directory)
37
+ if _hooks_directory not in sys.path:
38
+ sys.path.insert(0, _hooks_directory)
39
+
40
+ from code_rules_shared import ( # noqa: E402
41
+ is_migration_file,
42
+ is_test_file,
43
+ )
44
+
45
+ from hooks_constants.dead_module_constant_constants import ( # noqa: E402
46
+ CONFIG_DIRECTORY_SEGMENT,
47
+ CONSTANTS_MODULE_SUFFIX,
48
+ DEAD_MODULE_CONSTANT_GUIDANCE,
49
+ DUNDER_ALL_NAME,
50
+ DUNDER_INIT_FILENAME,
51
+ MAX_DEAD_MODULE_CONSTANT_ISSUES,
52
+ MAX_SCAN_ROOT_FILE_COUNT,
53
+ MINIMUM_UPPER_SNAKE_LENGTH,
54
+ PYTHON_SOURCE_SUFFIX,
55
+ )
56
+
57
+
58
+ def _is_dedicated_constants_module(file_path: str) -> bool:
59
+ """Return whether a path is a dedicated constants module.
60
+
61
+ A dedicated constants module is one whose filename ends in
62
+ ``_constants.py`` or whose path includes a ``config`` directory segment.
63
+ These modules export named values to importers, so their constants need a
64
+ cross-module scan to judge liveness.
65
+
66
+ Args:
67
+ file_path: The destination path of the write.
68
+
69
+ Returns:
70
+ True for a constants-suffixed module or a module under ``config/``.
71
+ """
72
+ normalized_path = file_path.replace("\\", "/").lower()
73
+ if normalized_path.endswith(CONSTANTS_MODULE_SUFFIX):
74
+ return True
75
+ path_segments = normalized_path.split("/")
76
+ return CONFIG_DIRECTORY_SEGMENT in path_segments[:-1]
77
+
78
+
79
+ def _is_upper_snake_name(name: str) -> bool:
80
+ """Return whether a name is an UPPER_SNAKE_CASE constant identifier."""
81
+ if len(name) < MINIMUM_UPPER_SNAKE_LENGTH:
82
+ return False
83
+ if not name.replace("_", "").isalnum():
84
+ return False
85
+ return name == name.upper() and any(each_char.isalpha() for each_char in name)
86
+
87
+
88
+ def _module_constant_definitions(tree: ast.Module) -> list[tuple[str, int]]:
89
+ """Return (name, line) for each module-scope UPPER_SNAKE constant assignment.
90
+
91
+ Both plain assignments (``NAME = value``) and annotated assignments
92
+ (``NAME: type = value``) at module scope are collected. A name bound more
93
+ than once keeps the line of its first binding.
94
+
95
+ Args:
96
+ tree: The parsed constants module.
97
+
98
+ Returns:
99
+ One (name, line) pair per distinct module-scope constant, in source
100
+ order.
101
+ """
102
+ line_by_name: dict[str, int] = {}
103
+ for each_statement in tree.body:
104
+ targets: list[ast.expr] = []
105
+ if isinstance(each_statement, ast.Assign):
106
+ targets = list(each_statement.targets)
107
+ elif isinstance(each_statement, ast.AnnAssign) and each_statement.value is not None:
108
+ targets = [each_statement.target]
109
+ for each_target in targets:
110
+ if not isinstance(each_target, ast.Name):
111
+ continue
112
+ if not _is_upper_snake_name(each_target.id):
113
+ continue
114
+ if each_target.id not in line_by_name:
115
+ line_by_name[each_target.id] = each_statement.lineno
116
+ return list(line_by_name.items())
117
+
118
+
119
+ def _statement_binds_dunder_all(statement: ast.stmt) -> bool:
120
+ """Return whether a single statement assigns or annotates ``__all__``."""
121
+ if isinstance(statement, ast.Assign):
122
+ return any(
123
+ isinstance(each_target, ast.Name) and each_target.id == DUNDER_ALL_NAME
124
+ for each_target in statement.targets
125
+ )
126
+ return (
127
+ isinstance(statement, ast.AnnAssign)
128
+ and isinstance(statement.target, ast.Name)
129
+ and statement.target.id == DUNDER_ALL_NAME
130
+ )
131
+
132
+
133
+ def _module_declares_dunder_all(tree: ast.Module) -> bool:
134
+ """Return whether the module body assigns or annotates ``__all__``."""
135
+ return any(_statement_binds_dunder_all(each_node) for each_node in tree.body)
136
+
137
+
138
+ def _referenced_names_in_source(source: str, load_only: bool = False) -> set[str]:
139
+ """Return every name a module references — imported, read, or re-exported.
140
+
141
+ Collects imported binding names, ``from`` import member names, name
142
+ references, attribute roots, and string literals (so a name listed in an
143
+ ``__all__`` literal or named in a string annotation counts as a reference).
144
+ A module that fails to parse contributes no names. With ``load_only`` set,
145
+ only ``Load``-context names count, so a constant's own assignment target in
146
+ the module being judged does not count as a reference to itself.
147
+
148
+ Args:
149
+ source: The full text of a ``.py`` module under the scan root.
150
+ load_only: When True, count only ``Load``-context name references,
151
+ excluding ``Store``/``Del`` targets. Used for the written constants
152
+ module so a definition is not mistaken for its own consumer.
153
+
154
+ Returns:
155
+ The set of names the module references.
156
+ """
157
+ try:
158
+ tree = ast.parse(source)
159
+ except SyntaxError:
160
+ return set()
161
+ referenced_names: set[str] = set()
162
+ for each_node in ast.walk(tree):
163
+ if isinstance(each_node, ast.Name):
164
+ if load_only and not isinstance(each_node.ctx, ast.Load):
165
+ continue
166
+ referenced_names.add(each_node.id)
167
+ elif isinstance(each_node, ast.Import | ast.ImportFrom):
168
+ for each_alias in each_node.names:
169
+ referenced_names.add(each_alias.asname or each_alias.name)
170
+ referenced_names.add(each_alias.name)
171
+ elif isinstance(each_node, ast.Constant) and isinstance(each_node.value, str):
172
+ referenced_names.add(each_node.value)
173
+ return referenced_names
174
+
175
+
176
+ def _scan_root_for_constants_module(file_path: str) -> Path:
177
+ """Return the directory tree to scan for references to the module's constants.
178
+
179
+ For a constants module inside a package subdirectory
180
+ (``pkg/foo_constants.py``), the scan root is the package's parent, so an
181
+ importer one directory up (``pkg/../consumer.py``) is in scope. For a
182
+ constants module at the top of a directory, the scan root is that directory.
183
+ A ``config/`` module's scan root is the parent of the ``config`` directory.
184
+
185
+ Args:
186
+ file_path: The destination path of the write.
187
+
188
+ Returns:
189
+ The absolute directory to scan recursively for references.
190
+ """
191
+ written_path = Path(file_path).resolve()
192
+ enclosing_directory = written_path.parent
193
+ if enclosing_directory.name.lower() == CONFIG_DIRECTORY_SEGMENT:
194
+ return enclosing_directory.parent
195
+ if (enclosing_directory / DUNDER_INIT_FILENAME).is_file():
196
+ return enclosing_directory.parent
197
+ return enclosing_directory
198
+
199
+
200
+ def _all_referenced_names_under_root(
201
+ scan_root: Path,
202
+ written_path: Path,
203
+ written_content: str,
204
+ ) -> tuple[set[str], bool]:
205
+ """Return referenced names under the scan root and whether the file cap was hit.
206
+
207
+ The written module's on-disk text is replaced by ``written_content`` so the
208
+ post-edit view is judged, never the stale disk copy. Sibling modules are
209
+ read from disk. Reading stops after the configured file cap so a write under
210
+ an unexpectedly large tree cannot stall the hook; the boolean signals the
211
+ caller to treat that case as "cannot prove dead".
212
+
213
+ Args:
214
+ scan_root: The directory tree to scan.
215
+ written_path: The resolved path of the module being written.
216
+ written_content: The post-edit text of the written module.
217
+
218
+ Returns:
219
+ A (referenced_names, cap_was_hit) pair. The name set is the union across
220
+ every scanned module; cap_was_hit is True when the scan stopped at the
221
+ configured file cap before scanning the whole tree.
222
+ """
223
+ all_referenced_names = _referenced_names_in_source(written_content, load_only=True)
224
+ written_path_key = os.path.normcase(str(written_path))
225
+ scanned_file_count = 1
226
+ for each_path in scan_root.rglob("*" + PYTHON_SOURCE_SUFFIX):
227
+ if not each_path.is_file():
228
+ continue
229
+ if os.path.normcase(str(each_path.resolve())) == written_path_key:
230
+ continue
231
+ scanned_file_count += 1
232
+ if scanned_file_count > MAX_SCAN_ROOT_FILE_COUNT:
233
+ return all_referenced_names, True
234
+ try:
235
+ sibling_source = each_path.read_text(encoding="utf-8")
236
+ except (OSError, UnicodeDecodeError):
237
+ continue
238
+ all_referenced_names |= _referenced_names_in_source(sibling_source)
239
+ return all_referenced_names, False
240
+
241
+
242
+ def _module_is_exempt_from_constant_check(file_path: str) -> bool:
243
+ """Return whether a path is exempt from the dead module-constant check.
244
+
245
+ Test modules and migration modules are exempt, and any module that is not a
246
+ dedicated constants module is out of scope because its file-global constants
247
+ are governed by the use-count rule instead.
248
+
249
+ Args:
250
+ file_path: The destination path of the write.
251
+
252
+ Returns:
253
+ True when the dead module-constant check must not run on this path.
254
+ """
255
+ if is_test_file(file_path):
256
+ return True
257
+ if is_migration_file(file_path):
258
+ return True
259
+ return not _is_dedicated_constants_module(file_path)
260
+
261
+
262
+ def check_dead_module_constants(
263
+ content: str,
264
+ file_path: str,
265
+ full_file_content: str | None = None,
266
+ ) -> list[str]:
267
+ """Flag an UPPER_SNAKE constant in a constants module read by no module.
268
+
269
+ Runs only on a dedicated constants module (``*_constants.py`` or a module
270
+ under ``config/``); every other production module's file-global constants
271
+ are governed by the use-count rule instead. A constant is dead when its name
272
+ appears in no ``.py`` module anywhere under the enclosing package tree — not
273
+ imported, not read, not listed in an ``__all__`` literal, not named in a
274
+ string annotation. A module declaring its own ``__all__`` is skipped so the
275
+ author's explicit export surface is never second-guessed. Whole-file
276
+ analysis runs against ``full_file_content`` when supplied so an Edit fragment
277
+ is judged against the reconstructed post-edit file.
278
+
279
+ Args:
280
+ content: The new content under validation (Edit fragment or whole file).
281
+ file_path: The destination path, used for the constants-module gate and
282
+ the test/registry exemptions.
283
+ full_file_content: The reconstructed post-edit whole-file content for an
284
+ Edit, or None for a Write where ``content`` is already the whole file.
285
+
286
+ Returns:
287
+ One violation message per dead module-level constant, capped at the
288
+ configured maximum.
289
+ """
290
+ if _module_is_exempt_from_constant_check(file_path):
291
+ return []
292
+ effective_content = content if full_file_content is None else full_file_content
293
+ try:
294
+ tree = ast.parse(effective_content)
295
+ except SyntaxError:
296
+ return []
297
+ if _module_declares_dunder_all(tree):
298
+ return []
299
+ constant_definitions = _module_constant_definitions(tree)
300
+ if not constant_definitions:
301
+ return []
302
+ scan_root = _scan_root_for_constants_module(file_path)
303
+ written_path = Path(file_path).resolve()
304
+ all_referenced_names, cap_was_hit = _all_referenced_names_under_root(
305
+ scan_root,
306
+ written_path,
307
+ effective_content,
308
+ )
309
+ if cap_was_hit:
310
+ return []
311
+ issues: list[str] = []
312
+ for each_name, each_line in constant_definitions:
313
+ if each_name in all_referenced_names:
314
+ continue
315
+ issues.append(
316
+ f"Line {each_line}: module-level constant {each_name!r}"
317
+ f" - {DEAD_MODULE_CONSTANT_GUIDANCE}"
318
+ )
319
+ if len(issues) >= MAX_DEAD_MODULE_CONSTANT_ISSUES:
320
+ break
321
+ return issues