claude-dev-env 1.72.0 → 1.74.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 (99) hide show
  1. package/CLAUDE.md +2 -0
  2. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +2 -2
  3. package/bin/install.mjs +73 -5
  4. package/bin/install.test.mjs +360 -4
  5. package/hooks/blocking/CLAUDE.md +6 -1
  6. package/hooks/blocking/block_main_commit.py +14 -0
  7. package/hooks/blocking/bot_mention_comment_blocker.py +7 -0
  8. package/hooks/blocking/claude_md_orphan_file_blocker.py +19 -48
  9. package/hooks/blocking/code_rules_dead_config_field.py +69 -56
  10. package/hooks/blocking/code_rules_docstrings.py +839 -0
  11. package/hooks/blocking/code_rules_enforcer.py +38 -0
  12. package/hooks/blocking/code_rules_shared.py +19 -0
  13. package/hooks/blocking/code_verifier_spawn_preflight_gate.py +426 -0
  14. package/hooks/blocking/convergence_gate_blocker.py +17 -3
  15. package/hooks/blocking/destructive_command_blocker.py +7 -0
  16. package/hooks/blocking/docstring_rule_gate_count_blocker.py +321 -0
  17. package/hooks/blocking/gh_body_arg_blocker.py +8 -0
  18. package/hooks/blocking/gh_pr_author_enforcer.py +7 -0
  19. package/hooks/blocking/hedging_language_blocker.py +16 -10
  20. package/hooks/blocking/hook_prose_detector_consistency.py +7 -0
  21. package/hooks/blocking/intent_only_ending_blocker.py +17 -11
  22. package/hooks/blocking/md_to_html_blocker.py +17 -10
  23. package/hooks/blocking/open_questions_in_plans_blocker.py +15 -8
  24. package/hooks/blocking/package_inventory_stale_blocker.py +398 -0
  25. package/hooks/blocking/plain_language_blocker.py +57 -16
  26. package/hooks/blocking/pr_converge_bugteam_enforcer.py +11 -5
  27. package/hooks/blocking/pr_description_enforcer.py +6 -0
  28. package/hooks/blocking/pre_tool_use_dispatcher.py +545 -0
  29. package/hooks/blocking/precommit_code_rules_gate.py +10 -1
  30. package/hooks/blocking/pytest_testpaths_orphan_blocker.py +366 -0
  31. package/hooks/blocking/question_to_user_enforcer.py +18 -12
  32. package/hooks/blocking/send_user_file_open_locally_blocker.py +70 -0
  33. package/hooks/blocking/sensitive_file_protector.py +15 -1
  34. package/hooks/blocking/session_handoff_blocker.py +14 -8
  35. package/hooks/blocking/state_description_blocker.py +81 -36
  36. package/hooks/blocking/subprocess_budget_completeness.py +9 -3
  37. package/hooks/blocking/tdd_enforcer.py +6 -0
  38. package/hooks/blocking/test_code_rules_enforcer_dead_config_field.py +81 -0
  39. package/hooks/blocking/test_code_rules_enforcer_docstring_inline_literal_claim.py +93 -0
  40. package/hooks/blocking/test_code_rules_enforcer_docstring_returns_plural_cardinality.py +207 -0
  41. package/hooks/blocking/test_code_rules_enforcer_docstring_step_dispatch.py +262 -0
  42. package/hooks/blocking/test_code_rules_enforcer_docstring_undefined_constant.py +253 -0
  43. package/hooks/blocking/test_code_rules_enforcer_docstring_unguarded_payload.py +188 -0
  44. package/hooks/blocking/test_code_rules_enforcer_module_docstring_roster.py +279 -0
  45. package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +501 -0
  46. package/hooks/blocking/test_docstring_rule_gate_count_blocker.py +203 -0
  47. package/hooks/blocking/test_hook_block_logger_coverage.py +53 -0
  48. package/hooks/blocking/test_package_inventory_stale_blocker.py +329 -0
  49. package/hooks/blocking/test_plain_language_blocker.py +36 -0
  50. package/hooks/blocking/test_pre_tool_use_dispatcher.py +816 -0
  51. package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +341 -0
  52. package/hooks/blocking/test_pytest_testpaths_orphan_blocker.py +247 -0
  53. package/hooks/blocking/test_send_user_file_open_locally_blocker.py +114 -0
  54. package/hooks/blocking/test_shared_stdin_adoption.py +208 -0
  55. package/hooks/blocking/test_state_description_blocker.py +41 -0
  56. package/hooks/blocking/test_verdict_directory_write_blocker.py +49 -0
  57. package/hooks/blocking/test_workflow_substitution_slot_blocker.py +4 -19
  58. package/hooks/blocking/verdict_directory_write_blocker.py +21 -7
  59. package/hooks/blocking/verified_commit_gate.py +11 -0
  60. package/hooks/blocking/verified_commit_message_accuracy_blocker.py +16 -1
  61. package/hooks/blocking/windows_rmtree_blocker.py +7 -0
  62. package/hooks/blocking/workflow_substitution_slot_blocker.py +10 -5
  63. package/hooks/blocking/write_existing_file_blocker.py +16 -1
  64. package/hooks/hooks.json +19 -79
  65. package/hooks/hooks_constants/CLAUDE.md +7 -1
  66. package/hooks/hooks_constants/blocking_check_limits.py +74 -0
  67. package/hooks/hooks_constants/code_rules_enforcer_constants.py +9 -0
  68. package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +45 -0
  69. package/hooks/hooks_constants/dead_config_field_constants.py +5 -5
  70. package/hooks/hooks_constants/docstring_rule_gate_count_blocker_constants.py +90 -0
  71. package/hooks/hooks_constants/hook_block_logger.py +59 -0
  72. package/hooks/hooks_constants/multi_edit_reconstruction.py +56 -0
  73. package/hooks/hooks_constants/mypy_validator_cache_constants.py +36 -0
  74. package/hooks/hooks_constants/package_inventory_stale_blocker_constants.py +111 -0
  75. package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +68 -0
  76. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +143 -0
  77. package/hooks/hooks_constants/pytest_testpaths_orphan_blocker_constants.py +79 -0
  78. package/hooks/hooks_constants/send_user_file_open_locally_blocker_constants.py +18 -0
  79. package/hooks/hooks_constants/test_dispatcher_constants_docstrings.py +44 -0
  80. package/hooks/hooks_constants/test_hook_block_logger.py +159 -0
  81. package/hooks/lifecycle/config_change_guard.py +12 -0
  82. package/hooks/lifecycle/test_config_change_guard.py +23 -0
  83. package/hooks/validation/hook_format_validator.py +13 -0
  84. package/hooks/validation/mypy_validator.py +245 -18
  85. package/hooks/validation/post_tool_use_dispatcher.py +344 -0
  86. package/hooks/validation/test_hook_format_validator.py +64 -0
  87. package/hooks/validation/test_mypy_validator.py +206 -1
  88. package/hooks/validation/test_post_tool_use_dispatcher.py +610 -0
  89. package/hooks/workflow/test_auto_formatter.py +10 -9
  90. package/package.json +1 -1
  91. package/rules/CLAUDE.md +1 -0
  92. package/rules/docstring-prose-matches-implementation.md +4 -2
  93. package/rules/package-inventory-stale-entry.md +24 -0
  94. package/skills/autoconverge/SKILL.md +111 -1
  95. package/skills/autoconverge/workflow/converge.contract.test.mjs +106 -0
  96. package/skills/autoconverge/workflow/converge.mjs +29 -3
  97. package/skills/autoconverge/workflow/converge.path-aware.test.mjs +47 -0
  98. package/skills/autoconverge/workflow/converge_multi.mjs +161 -0
  99. package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +100 -0
@@ -39,6 +39,14 @@ from hooks_constants.claude_md_orphan_file_blocker_constants import ( # noqa: E
39
39
  SEPARATOR_CELL_PATTERN,
40
40
  TABLE_ROW_PATTERN,
41
41
  )
42
+ from hooks_constants.multi_edit_reconstruction import ( # noqa: E402
43
+ apply_edits,
44
+ edits_for_tool,
45
+ )
46
+ from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
47
+ from hooks_constants.pre_tool_use_stdin import ( # noqa: E402
48
+ read_hook_input_dictionary_from_stdin,
49
+ )
42
50
 
43
51
 
44
52
  def is_claude_md_file(file_path: str) -> bool:
@@ -406,29 +414,6 @@ def _read_existing_file_content(file_path: str) -> str | None:
406
414
  return None
407
415
 
408
416
 
409
- def _apply_edits(existing_content: str, all_edits: list[dict]) -> str:
410
- """Return *existing_content* with each MultiEdit replacement applied in order.
411
-
412
- Args:
413
- existing_content: The current on-disk file content.
414
- all_edits: The MultiEdit ``edits`` list, each a mapping with an
415
- ``old_string`` and a ``new_string``.
416
-
417
- Returns:
418
- The content after replacing the first occurrence of each edit's
419
- ``old_string`` with its ``new_string``, in list order.
420
- """
421
- edited_content = existing_content
422
- for each_edit in all_edits:
423
- if not isinstance(each_edit, dict):
424
- continue
425
- old_string = each_edit.get("old_string", "")
426
- new_string = each_edit.get("new_string", "")
427
- if isinstance(old_string, str) and isinstance(new_string, str) and old_string:
428
- edited_content = edited_content.replace(old_string, new_string, 1)
429
- return edited_content
430
-
431
-
432
417
  def _edit_fragments(all_edits: list[dict]) -> list[str]:
433
418
  """Return each MultiEdit ``new_string`` fragment present as a non-empty string.
434
419
 
@@ -495,29 +480,12 @@ def _build_orphan_scan_plan(
495
480
  content = tool_input.get("content", "")
496
481
  candidate_contents = [content] if isinstance(content, str) and content else []
497
482
  return _OrphanScanPlan(candidate_contents, set())
498
- all_edits = _edits_for_tool(tool_name, tool_input)
483
+ all_edits = edits_for_tool(tool_name, tool_input)
499
484
  existing_content = _read_existing_file_content(file_path)
500
485
  if existing_content is None:
501
486
  return _OrphanScanPlan(_edit_fragments(all_edits), set())
502
487
  baseline_missing = set(find_missing_filenames(existing_content, claude_md_directory))
503
- return _OrphanScanPlan([_apply_edits(existing_content, all_edits)], baseline_missing)
504
-
505
-
506
- def _edits_for_tool(tool_name: str, tool_input: dict) -> list[dict]:
507
- """Return the edit mappings an Edit or MultiEdit payload carries.
508
-
509
- Args:
510
- tool_name: The intercepted tool — ``Edit`` or ``MultiEdit``.
511
- tool_input: The tool's input payload.
512
-
513
- Returns:
514
- A single-element list holding the Edit payload, or the MultiEdit
515
- ``edits`` list when it is present as a list; an empty list otherwise.
516
- """
517
- if tool_name == "Edit":
518
- return [tool_input]
519
- all_edits = tool_input.get("edits", [])
520
- return all_edits if isinstance(all_edits, list) else []
488
+ return _OrphanScanPlan([apply_edits(existing_content, all_edits)], baseline_missing)
521
489
 
522
490
 
523
491
  def _collect_missing_filenames(scan_plan: _OrphanScanPlan, claude_md_directory: Path) -> list[str]:
@@ -588,12 +556,8 @@ def _emit_hook_result(all_hook_data: dict, output_stream: TextIO) -> None:
588
556
 
589
557
  def main() -> None:
590
558
  """Read the PreToolUse payload from stdin and block an orphan-file CLAUDE.md."""
591
- try:
592
- input_data = json.load(sys.stdin)
593
- except json.JSONDecodeError:
594
- sys.exit(0)
595
-
596
- if not isinstance(input_data, dict):
559
+ input_data = read_hook_input_dictionary_from_stdin()
560
+ if input_data is None:
597
561
  sys.exit(0)
598
562
 
599
563
  tool_name = input_data.get("tool_name", "")
@@ -624,6 +588,13 @@ def main() -> None:
624
588
  sys.exit(0)
625
589
 
626
590
  block_payload = _build_block_payload(missing_filenames, str(claude_md_directory))
591
+ log_hook_block(
592
+ calling_hook_name="claude_md_orphan_file_blocker.py",
593
+ hook_event="PreToolUse",
594
+ block_reason=block_payload["hookSpecificOutput"]["permissionDecisionReason"],
595
+ tool_name=tool_name,
596
+ offending_input_preview=file_path,
597
+ )
627
598
  _emit_hook_result(block_payload, sys.stdout)
628
599
  sys.exit(0)
629
600
 
@@ -1,17 +1,21 @@
1
- """Dead config-dataclass field check: cross-module scan for ``*Config`` @dataclass fields.
2
-
3
- A config ``@dataclass`` (any class whose name ends in ``"Config"``) is defined
4
- in one module but constructed and consumed in others, so the per-file dead-field
5
- check in ``code_rules_dead_dataclass_field`` cannot judge its fields — it skips
6
- any class that is not constructed in the same file. This check resolves the
7
- enclosing package tree the scan root and flags a ``*Config`` dataclass field
8
- whose name appears as an attribute read (``obj.field``) in no production module
9
- anywhere under that root.
1
+ """Dead config-dataclass field check: cross-module scan for config-like @dataclass fields.
2
+
3
+ A config-like ``@dataclass`` any class whose name ends in ``"Config"`` or
4
+ ``"Selectors"`` — is defined in one module but constructed and consumed in
5
+ others, so the per-file dead-field check in
6
+ ``code_rules_dead_dataclass_field`` cannot judge its fields it skips any class
7
+ that is not constructed in the same file. This check resolves the enclosing
8
+ package tree the scan root and flags a config-like dataclass field whose
9
+ name appears as an attribute read (``obj.field``) in no production module
10
+ anywhere under that root. A selectors dataclass is the same shape as a config
11
+ dataclass: it is bound to a module-level singleton (``binary_selectors =
12
+ BinarySelectors()``) and its fields are read across files, so an unwired
13
+ selector field is caught the same way as a dead config field.
10
14
 
11
15
  The scan is deliberately conservative to keep false positives near zero:
12
16
 
13
- - Only ``@dataclass`` classes whose name ends in ``"Config"`` participate; other
14
- dataclasses are covered by the per-file check.
17
+ - Only ``@dataclass`` classes whose name ends in ``"Config"`` or ``"Selectors"``
18
+ participate; other dataclasses are covered by the per-file check.
15
19
  - Test and migration files are exempt as write destinations, so a field added to
16
20
  a config dataclass inside a test is never flagged.
17
21
  - Production modules under the scan root are scanned for attribute reads; test
@@ -24,11 +28,11 @@ The scan is deliberately conservative to keep false positives near zero:
24
28
  ``replace(cfg, debug_port=1)``), and match-pattern keyword attribute names
25
29
  (``case Config(field=found)``). Two field-write forms are excluded because they
26
30
  name a field without consuming it: a keyword that writes a field in a constructor
27
- of a known ``*Config`` dataclass defined under the scan root
31
+ of a known config-like dataclass defined under the scan root
28
32
  (``ThemeUpdateConfig(debug_port=1)``, excluded per keyword node so a same-named
29
33
  keyword on a ``replace`` call stays a read, and a factory function whose name
30
34
  merely ends in ``"Config"`` is not excluded), and a self-referential attribute
31
- read inside a ``*Config`` field's own default-value expression in the class body
35
+ read inside a config-like field's own default-value expression in the class body
32
36
  whose attribute name equals the field being defined
33
37
  (``debug_port: int = source.debug_port``) — a field written only these ways and
34
38
  read by no module is the dead-config case this check exists to catch. A default
@@ -54,7 +58,7 @@ suppress on a dataclass-dunder whole-instance read — instance comparison
54
58
  (``str(cfg)``/``repr(cfg)``/``format(cfg)``). Those syntactic forms are not bound
55
59
  to a config instance, and tree-wide one incidental match anywhere would disable
56
60
  the check on any realistic package. The consequence is a documented, rare
57
- limitation: a ``*Config`` field read ONLY via whole-instance dunder comparison or
61
+ limitation: a config-like field read ONLY via whole-instance dunder comparison or
58
62
  stringification, and never read directly anywhere in production, may be flagged.
59
63
  The augmented-assignment read mechanism (``cfg.field += 1`` reads ``field``
60
64
  before writing it) is precise and remains a counted read.
@@ -87,8 +91,8 @@ from code_rules_shared import ( # noqa: E402
87
91
  )
88
92
 
89
93
  from hooks_constants.dead_config_field_constants import ( # noqa: E402
94
+ ALL_CONFIG_CLASS_NAME_SUFFIXES,
90
95
  ALL_REFLECTIVE_FIELD_CONSUMER_NAMES,
91
- CONFIG_CLASS_NAME_SUFFIX,
92
96
  DATACLASSES_MODULE_NAME,
93
97
  DEAD_CONFIG_FIELD_GUIDANCE,
94
98
  MAX_DEAD_CONFIG_FIELD_ISSUES,
@@ -99,16 +103,23 @@ from hooks_constants.dead_config_field_constants import ( # noqa: E402
99
103
 
100
104
 
101
105
  def _is_config_dataclass(class_node: ast.ClassDef) -> bool:
102
- """Return whether a class is a @dataclass whose name ends in ``"Config"``.
106
+ """Return whether a class is a @dataclass whose name ends in a config-like suffix.
107
+
108
+ A config-like surface is a ``@dataclass`` whose name ends in ``"Config"`` or
109
+ ``"Selectors"``. Both shapes are defined in one module, bound to a
110
+ module-level singleton, and read across files, so the per-file dead-field
111
+ check cannot judge their fields and the cross-module scan covers them here.
103
112
 
104
113
  Args:
105
114
  class_node: The class definition node to test.
106
115
 
107
116
  Returns:
108
117
  True when the class carries a ``@dataclass`` decorator and its name ends
109
- in ``"Config"``.
118
+ in one of ``ALL_CONFIG_CLASS_NAME_SUFFIXES``.
110
119
  """
111
- return _is_dataclass(class_node) and class_node.name.endswith(CONFIG_CLASS_NAME_SUFFIX)
120
+ return _is_dataclass(class_node) and class_node.name.endswith(
121
+ ALL_CONFIG_CLASS_NAME_SUFFIXES
122
+ )
112
123
 
113
124
 
114
125
  def _reads_whole_instance_reflectively(tree: ast.Module) -> bool:
@@ -152,18 +163,18 @@ def _reads_whole_instance_reflectively(tree: ast.Module) -> bool:
152
163
 
153
164
 
154
165
  def _config_dataclass_names(tree: ast.Module) -> set[str]:
155
- """Return names of ``*Config`` ``@dataclass`` classes defined in a module.
166
+ """Return names of config-like ``@dataclass`` classes defined in a module.
156
167
 
157
168
  A constructor-keyword exclusion fires only for a callee that names a genuine
158
- config dataclass, so the caller first gathers the config dataclass names a
159
- module defines, then unions those names across the scan root.
169
+ config-like dataclass, so the caller first gathers the config-like dataclass
170
+ names a module defines, then unions those names across the scan root.
160
171
 
161
172
  Args:
162
173
  tree: The parsed module to inspect.
163
174
 
164
175
  Returns:
165
176
  Every class name in the module that is a ``@dataclass`` whose name ends in
166
- the config class name suffix.
177
+ one of ``ALL_CONFIG_CLASS_NAME_SUFFIXES``.
167
178
  """
168
179
  config_dataclass_names: set[str] = set()
169
180
  for each_node in ast.walk(tree):
@@ -175,22 +186,22 @@ def _config_dataclass_names(tree: ast.Module) -> set[str]:
175
186
  def _call_constructs_config_class(
176
187
  call_node: ast.Call, all_known_config_class_names: set[str]
177
188
  ) -> bool:
178
- """Return whether a call constructs a known ``*Config`` dataclass.
189
+ """Return whether a call constructs a known config-like dataclass.
179
190
 
180
- A call whose callee names a ``*Config`` dataclass defined under the scan root —
191
+ A call whose callee names a config-like dataclass defined under the scan root —
181
192
  ``AppInfoConfig(...)`` or a qualified ``module.AppInfoConfig(...)`` —
182
- constructs a config instance, and its keyword arguments write the named fields
193
+ constructs the instance, and its keyword arguments write the named fields
183
194
  rather than read them. A factory function whose name merely ends in
184
- ``"Config"`` (``getThemeConfig(...)``) is not a known config dataclass, so its
185
- keyword arguments stay genuine reads.
195
+ ``"Config"`` (``getThemeConfig(...)``) is not a known config-like dataclass, so
196
+ its keyword arguments stay genuine reads.
186
197
 
187
198
  Args:
188
199
  call_node: The call expression to test.
189
- all_known_config_class_names: Names of ``*Config`` dataclasses defined under
200
+ all_known_config_class_names: Names of config-like dataclasses defined under
190
201
  the scan root.
191
202
 
192
203
  Returns:
193
- True when the callee names a known ``*Config`` dataclass.
204
+ True when the callee names a known config-like dataclass.
194
205
  """
195
206
  callee_node = call_node.func
196
207
  if isinstance(callee_node, ast.Name):
@@ -203,7 +214,7 @@ def _call_constructs_config_class(
203
214
  def _config_constructor_keyword_node_ids(
204
215
  tree: ast.Module, all_known_config_class_names: set[str]
205
216
  ) -> set[int]:
206
- """Return ids of keyword nodes that write fields in a known ``*Config`` constructor.
217
+ """Return ids of keyword nodes that write fields in a known config-like constructor.
207
218
 
208
219
  A keyword in an ``AppInfoConfig(field=value)`` call sets ``field`` rather than
209
220
  reading it, so its node id is collected for the caller to exclude. The
@@ -213,11 +224,11 @@ def _config_constructor_keyword_node_ids(
213
224
 
214
225
  Args:
215
226
  tree: The parsed module to inspect.
216
- all_known_config_class_names: Names of ``*Config`` dataclasses defined under
227
+ all_known_config_class_names: Names of config-like dataclasses defined under
217
228
  the scan root.
218
229
 
219
230
  Returns:
220
- The ``id()`` of every keyword node passed to a known ``*Config``
231
+ The ``id()`` of every keyword node passed to a known config-like
221
232
  constructor call.
222
233
  """
223
234
  constructor_keyword_node_ids: set[int] = set()
@@ -237,7 +248,7 @@ def _self_referential_default_attribute_node_ids(
237
248
  ) -> set[int]:
238
249
  """Return ids of attribute reads in a default whose name equals the field.
239
250
 
240
- Walks a ``*Config`` field's default-value expression and collects the ``id()``
251
+ Walks a config-like field's default-value expression and collects the ``id()``
241
252
  of each ``ast.Attribute`` read whose ``.attr`` equals ``field_name``. Such a
242
253
  read — ``sound_upload_timeout_ms: int = submission_timing.sound_upload_timeout_ms``
243
254
  — names the field being defined inside the class body, so it is not a consumer
@@ -261,15 +272,15 @@ def _self_referential_default_attribute_node_ids(
261
272
 
262
273
 
263
274
  def _config_field_default_value_nodes(tree: ast.Module) -> set[int]:
264
- """Return ids of self-referential attribute reads in ``*Config`` field defaults.
275
+ """Return ids of self-referential attribute reads in config-like field defaults.
265
276
 
266
277
  A field default such as ``sound_upload_timeout_ms: int =``
267
278
  ``submission_timing.sound_upload_timeout_ms`` is an attribute read whose name
268
- matches the field being defined. That self-referential read inside the config
269
- class body is not a consumer of the field, so its node id is collected here for
270
- the caller to exclude from the attribute-read set. Only the attribute read
271
- whose ``.attr`` equals the field name is collected; a default that sources a
272
- differently-named field on another object
279
+ matches the field being defined. That self-referential read inside the
280
+ config-like class body is not a consumer of the field, so its node id is
281
+ collected here for the caller to exclude from the attribute-read set. Only the
282
+ attribute read whose ``.attr`` equals the field name is collected; a default
283
+ that sources a differently-named field on another object
273
284
  (``timeout_ms: int = other_config.base_timeout``) leaves that read counted, so
274
285
  ``base_timeout`` stays a live consumer.
275
286
 
@@ -278,7 +289,7 @@ def _config_field_default_value_nodes(tree: ast.Module) -> set[int]:
278
289
 
279
290
  Returns:
280
291
  The ``id()`` of every self-referential attribute read within the
281
- default-value expression of a field declared in a ``*Config`` dataclass
292
+ default-value expression of a field declared in a config-like dataclass
282
293
  body.
283
294
  """
284
295
  default_value_node_ids: set[int] = set()
@@ -311,14 +322,14 @@ def _attribute_read_names_in_tree(
311
322
  contributes ``"debug_port"``), and ``ast.MatchClass.kwd_attrs`` names (so
312
323
  ``case Config(field=x)`` contributes ``"field"``). Two field-write forms are
313
324
  excluded because they name a field without consuming it: a keyword that writes
314
- a field in a known ``*Config`` constructor (``AppInfoConfig(field=value)``,
325
+ a field in a known config-like constructor (``AppInfoConfig(field=value)``,
315
326
  excluded per keyword node so a same-named ``replace`` keyword stays a read),
316
- and a self-referential attribute read inside a ``*Config`` dataclass field's
327
+ and a self-referential attribute read inside a config-like dataclass field's
317
328
  own default-value expression (``field: int = source.field`` in the class body,
318
329
  excluded only when the read name equals the field name) — counting either
319
330
  would hide a field that is written but read by no module. A keyword passed to a
320
- factory function whose name merely ends in ``"Config"`` is not a known config
321
- constructor, so it stays a read. The boolean reports whether the module
331
+ factory function whose name merely ends in ``"Config"`` is not a known
332
+ config-like constructor, so it stays a read. The boolean reports whether the module
322
333
  suppresses the dead-field check, which it does only when it reflectively reads a
323
334
  whole instance — a bare or ``dataclasses``-qualified
324
335
  ``asdict``/``astuple``/``fields``/``replace``/``vars`` call, or an
@@ -328,14 +339,14 @@ def _attribute_read_names_in_tree(
328
339
 
329
340
  Args:
330
341
  tree: The parsed module to inspect.
331
- all_known_config_class_names: Names of ``*Config`` dataclasses defined under
342
+ all_known_config_class_names: Names of config-like dataclasses defined under
332
343
  the scan root, used to scope the constructor-keyword exclusion to
333
- genuine config constructors.
344
+ genuine config-like constructors.
334
345
 
335
346
  Returns:
336
347
  A (read_names, suppresses_dead_field_check) pair. The name set is every
337
348
  attribute name the module reads via the mechanisms above, excluding known
338
- ``*Config`` constructor keyword nodes and self-referential config-field
349
+ config-like constructor keyword nodes and self-referential config-like-field
339
350
  default-value attribute reads; suppresses_dead_field_check is True only when
340
351
  a reflective whole-instance read is present.
341
352
  """
@@ -418,8 +429,8 @@ def _all_production_read_names_under_root(
418
429
  Reads and AST-parses every production ``.py`` module under ``scan_root``
419
430
  (excluding test and migration files) at most once: the module sources are
420
431
  materialized once, a first pass over the cached sources gathers every
421
- ``*Config`` dataclass name defined under the root so the constructor-keyword
422
- exclusion fires only for a genuine config constructor, and a second pass over
432
+ config-like dataclass name defined under the root so the constructor-keyword
433
+ exclusion fires only for a genuine config-like constructor, and a second pass over
423
434
  the same cached sources collects attribute reads. The written module's
424
435
  post-edit content replaces its on-disk text so the current edit is included.
425
436
  Scanning stops at the configured file cap. A module that reflectively reads a
@@ -472,15 +483,17 @@ def _all_production_read_names_under_root(
472
483
  def check_dead_config_dataclass_fields(
473
484
  content: str, file_path: str, full_file_content: str | None = None
474
485
  ) -> list[str]:
475
- """Flag a ``*Config`` @dataclass field read by no production module in the package tree.
486
+ """Flag a config-like @dataclass field read by no production module in the package tree.
476
487
 
477
488
  Runs a cross-module scan restricted to ``@dataclass`` classes whose name ends
478
- in ``"Config"``. For each such config dataclass in the written file, every
489
+ in ``"Config"`` or ``"Selectors"`` both are config-like surfaces bound to a
490
+ module-level singleton and read across files. For each such dataclass in the
491
+ written file, every
479
492
  instance field whose name does not appear as an attribute read (``obj.field``),
480
493
  augmented-assignment target (``cfg.field += 1``), string literal,
481
494
  non-constructor keyword-argument name (``replace`` keyword), or match-pattern
482
495
  keyword attribute in any production module under the enclosing scan root is
483
- flagged as dead. A keyword that writes a field in a ``*Config`` constructor
496
+ flagged as dead. A keyword that writes a field in a config-like constructor
484
497
  (``ThemeUpdateConfig(debug_port=1)``) is a write, not a read, so it does not
485
498
  clear a field — a field set by a constructor keyword and read by no module is
486
499
  flagged. When any production module under the scan root reflectively
@@ -507,10 +520,10 @@ def check_dead_config_dataclass_fields(
507
520
  Edit, or None for a Write where ``content`` is already the whole file.
508
521
 
509
522
  Returns:
510
- One violation message per dead config dataclass field, capped at the
523
+ One violation message per dead config-like dataclass field, capped at the
511
524
  configured maximum. Returns an empty list when the file is exempt, no
512
- qualifying config dataclass is found, the scan root exceeds the file cap,
513
- or a SyntaxError prevents parsing.
525
+ qualifying config-like dataclass is found, the scan root exceeds the file
526
+ cap, or a SyntaxError prevents parsing.
514
527
  """
515
528
  if is_test_file(file_path):
516
529
  return []
@@ -547,7 +560,7 @@ def check_dead_config_dataclass_fields(
547
560
  if each_field_name in all_read_names:
548
561
  continue
549
562
  all_issues.append(
550
- f"Line {each_field_line}: config dataclass field {each_field_name!r}"
563
+ f"Line {each_field_line}: dataclass field {each_field_name!r}"
551
564
  f" on {each_class.name} - {DEAD_CONFIG_FIELD_GUIDANCE}"
552
565
  )
553
566
  if len(all_issues) >= MAX_DEAD_CONFIG_FIELD_ISSUES: