claude-dev-env 1.68.0 → 1.69.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/_shared/CLAUDE.md +13 -0
  2. package/_shared/pr-loop/CLAUDE.md +24 -0
  3. package/_shared/pr-loop/scripts/CLAUDE.md +30 -0
  4. package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +21 -0
  5. package/_shared/pr-loop/scripts/tests/CLAUDE.md +32 -0
  6. package/agents/CLAUDE.md +29 -0
  7. package/audit-rubrics/CLAUDE.md +41 -0
  8. package/audit-rubrics/category_rubrics/CLAUDE.md +36 -0
  9. package/audit-rubrics/prompts/CLAUDE.md +36 -0
  10. package/bin/CLAUDE.md +28 -0
  11. package/commands/CLAUDE.md +25 -0
  12. package/docs/CLAUDE.md +28 -0
  13. package/docs/references/CLAUDE.md +13 -0
  14. package/hooks/CLAUDE.md +31 -0
  15. package/hooks/advisory/CLAUDE.md +16 -0
  16. package/hooks/blocking/CLAUDE.md +107 -0
  17. package/hooks/blocking/code_rules_constants_config.py +7 -4
  18. package/hooks/blocking/code_rules_dead_config_field.py +284 -50
  19. package/hooks/blocking/code_rules_docstrings.py +97 -0
  20. package/hooks/blocking/code_rules_enforcer.py +4 -0
  21. package/hooks/blocking/config/CLAUDE.md +22 -0
  22. package/hooks/blocking/test_code_rules_enforcer_class_docstring_methods.py +262 -0
  23. package/hooks/blocking/test_code_rules_enforcer_dead_config_field.py +336 -3
  24. package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +36 -0
  25. package/hooks/blocking/test_code_rules_enforcer_file_global_constants.py +9 -0
  26. package/hooks/diagnostic/CLAUDE.md +43 -0
  27. package/hooks/diagnostic/migrations/CLAUDE.md +16 -0
  28. package/hooks/diagnostic/queries/CLAUDE.md +19 -0
  29. package/hooks/git-hooks/CLAUDE.md +28 -0
  30. package/hooks/git-hooks/git_hooks_constants/CLAUDE.md +21 -0
  31. package/hooks/hooks_constants/CLAUDE.md +60 -0
  32. package/hooks/hooks_constants/blocking_check_limits.py +2 -0
  33. package/hooks/lifecycle/CLAUDE.md +18 -0
  34. package/hooks/observability/CLAUDE.md +16 -0
  35. package/hooks/session/CLAUDE.md +21 -0
  36. package/hooks/validation/CLAUDE.md +19 -0
  37. package/hooks/validators/CLAUDE.md +49 -0
  38. package/hooks/workflow/CLAUDE.md +22 -0
  39. package/package.json +1 -1
  40. package/rules/CLAUDE.md +46 -0
  41. package/rules/docstring-prose-matches-implementation.md +1 -1
  42. package/scripts/CLAUDE.md +34 -0
  43. package/scripts/dev_env_scripts_constants/CLAUDE.md +14 -0
  44. package/scripts/sync_to_cursor/CLAUDE.md +23 -0
  45. package/scripts/tests/CLAUDE.md +18 -0
  46. package/skills/CLAUDE.md +66 -0
  47. package/skills/_shared/CLAUDE.md +11 -0
  48. package/skills/_shared/pr-loop/CLAUDE.md +27 -0
  49. package/skills/_shared/pr-loop/prompts/CLAUDE.md +9 -0
  50. package/skills/_shared/pr-loop/scripts/CLAUDE.md +23 -0
  51. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/CLAUDE.md +19 -0
  52. package/skills/anthropic-plan/CLAUDE.md +34 -0
  53. package/skills/anthropic-plan/SKILL.md +1 -1
  54. package/skills/anthropic-plan/scripts/CLAUDE.md +11 -0
  55. package/skills/anthropic-plan/scripts/anthropic_plan_scripts_constants/CLAUDE.md +16 -0
  56. package/skills/anthropic-plan/templates/CLAUDE.md +13 -0
  57. package/skills/anthropic-plan/workflow/CLAUDE.md +14 -0
  58. package/skills/auditing-claude-config/CLAUDE.md +20 -0
  59. package/skills/autoconverge/CLAUDE.md +30 -0
  60. package/skills/autoconverge/reference/CLAUDE.md +12 -0
  61. package/skills/autoconverge/workflow/CLAUDE.md +23 -0
  62. package/skills/autoconverge/workflow/autoconverge_report_constants/CLAUDE.md +16 -0
  63. package/skills/bdd-protocol/CLAUDE.md +26 -0
  64. package/skills/bdd-protocol/references/CLAUDE.md +10 -0
  65. package/skills/bg-agent/CLAUDE.md +17 -0
  66. package/skills/bugteam/CLAUDE.md +30 -0
  67. package/skills/bugteam/reference/CLAUDE.md +22 -0
  68. package/skills/bugteam/reference/obstacles/CLAUDE.md +24 -0
  69. package/skills/bugteam/scripts/CLAUDE.md +36 -0
  70. package/skills/bugteam/scripts/bugteam_scripts_constants/CLAUDE.md +20 -0
  71. package/skills/caveman/CLAUDE.md +15 -0
  72. package/skills/code/CLAUDE.md +17 -0
  73. package/skills/copilot-review/CLAUDE.md +17 -0
  74. package/skills/deep-research/CLAUDE.md +17 -0
  75. package/skills/doc-gist/CLAUDE.md +25 -0
  76. package/skills/doc-gist/references/CLAUDE.md +9 -0
  77. package/skills/doc-gist/references/examples/CLAUDE.md +25 -0
  78. package/skills/doc-gist/scripts/CLAUDE.md +27 -0
  79. package/skills/doc-gist/scripts/doc_gist_scripts_constants/CLAUDE.md +10 -0
  80. package/skills/everything-search/CLAUDE.md +17 -0
  81. package/skills/findbugs/CLAUDE.md +20 -0
  82. package/skills/fixbugs/CLAUDE.md +19 -0
  83. package/skills/fresh-branch/CLAUDE.md +15 -0
  84. package/skills/gh-paginate/CLAUDE.md +18 -0
  85. package/skills/gotcha/CLAUDE.md +33 -0
  86. package/skills/implement/CLAUDE.md +27 -0
  87. package/skills/implement/scripts/CLAUDE.md +22 -0
  88. package/skills/implement/scripts/implement_scripts_constants/CLAUDE.md +22 -0
  89. package/skills/logifix/CLAUDE.md +36 -0
  90. package/skills/logifix/scripts/CLAUDE.md +16 -0
  91. package/skills/monitor-open-prs/CLAUDE.md +34 -0
  92. package/skills/monitor-open-prs/scripts/CLAUDE.md +17 -0
  93. package/skills/pr-consistency-audit/CLAUDE.md +34 -0
  94. package/skills/pr-consistency-audit/reference/CLAUDE.md +16 -0
  95. package/skills/pr-converge/CLAUDE.md +29 -0
  96. package/skills/pr-converge/pr_converge_skill_constants/CLAUDE.md +26 -0
  97. package/skills/pr-converge/reference/CLAUDE.md +27 -0
  98. package/skills/pr-converge/reference/obstacles/CLAUDE.md +23 -0
  99. package/skills/pr-converge/scripts/CLAUDE.md +36 -0
  100. package/skills/pr-converge/scripts/pr_converge_scripts_constants/CLAUDE.md +17 -0
  101. package/skills/pr-converge/workflows/CLAUDE.md +16 -0
  102. package/skills/pr-review-responder/CLAUDE.md +35 -0
  103. package/skills/pre-compact/CLAUDE.md +24 -0
  104. package/skills/qbug/CLAUDE.md +40 -0
  105. package/skills/rebase/CLAUDE.md +32 -0
  106. package/skills/recall/CLAUDE.md +30 -0
  107. package/skills/refine/CLAUDE.md +44 -0
  108. package/skills/refine/templates/CLAUDE.md +17 -0
  109. package/skills/remember/CLAUDE.md +31 -0
  110. package/skills/research-mode/CLAUDE.md +35 -0
  111. package/skills/session-log/CLAUDE.md +31 -0
  112. package/skills/session-tidy/CLAUDE.md +36 -0
  113. package/skills/skill-builder/CLAUDE.md +45 -0
  114. package/skills/skill-builder/references/CLAUDE.md +19 -0
  115. package/skills/skill-builder/templates/CLAUDE.md +14 -0
  116. package/skills/skill-builder/workflows/CLAUDE.md +17 -0
  117. package/skills/structure-prompt/CLAUDE.md +42 -0
  118. package/skills/structure-prompt/reference/CLAUDE.md +28 -0
  119. package/skills/task-build/CLAUDE.md +28 -0
  120. package/skills/update/CLAUDE.md +38 -0
  121. package/skills/verified-build/CLAUDE.md +33 -0
  122. package/system-prompts/CLAUDE.md +17 -0
@@ -20,10 +20,23 @@ The scan is deliberately conservative to keep false positives near zero:
20
20
  - Field reads are collected as ``ast.Attribute.attr`` values (``obj.field``),
21
21
  augmented-assignment targets (``cfg.field += 1`` reads ``field`` before
22
22
  writing it), string literals (covers ``getattr(obj, "field")``),
23
- keyword-argument names (covers ``ThemeUpdateConfig(debug_port=1)`` and
23
+ keyword-argument names on non-constructor calls (covers
24
24
  ``replace(cfg, debug_port=1)``), and match-pattern keyword attribute names
25
- (``case Config(field=found)``). Plain ``ast.Name`` references are excluded a
26
- local variable named ``debug_port`` is not a read of ``config.debug_port``.
25
+ (``case Config(field=found)``). Two field-write forms are excluded because they
26
+ 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
28
+ (``ThemeUpdateConfig(debug_port=1)``, excluded per keyword node so a same-named
29
+ keyword on a ``replace`` call stays a read, and a factory function whose name
30
+ 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
32
+ whose attribute name equals the field being defined
33
+ (``debug_port: int = source.debug_port``) — a field written only these ways and
34
+ read by no module is the dead-config case this check exists to catch. A default
35
+ that sources a differently-named field on another object
36
+ (``timeout_ms: int = other_config.base_timeout``) leaves that read counted, so
37
+ ``base_timeout`` stays a live consumer. Plain
38
+ ``ast.Name`` references are excluded — a local variable named ``debug_port`` is
39
+ not a read of ``config.debug_port``.
27
40
  - A production module that reflectively reads a whole instance — a bare or
28
41
  ``dataclasses``-qualified call to ``asdict``, ``astuple``, ``fields``,
29
42
  ``replace``, or ``vars``, or a read of ``obj.__dict__`` — consumes every field
@@ -50,6 +63,7 @@ before writing it) is precise and remains a counted read.
50
63
  import ast
51
64
  import os
52
65
  import sys
66
+ from collections.abc import Iterator
53
67
  from pathlib import Path
54
68
 
55
69
  _blocking_directory = str(Path(__file__).resolve().parent)
@@ -137,79 +151,242 @@ def _reads_whole_instance_reflectively(tree: ast.Module) -> bool:
137
151
  return False
138
152
 
139
153
 
140
- def _attribute_read_names_in_source(source: str) -> tuple[set[str], bool]:
141
- """Return attribute names read in a module's source and a suppression flag.
154
+ def _config_dataclass_names(tree: ast.Module) -> set[str]:
155
+ """Return names of ``*Config`` ``@dataclass`` classes defined in a module.
156
+
157
+ 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.
160
+
161
+ Args:
162
+ tree: The parsed module to inspect.
163
+
164
+ Returns:
165
+ Every class name in the module that is a ``@dataclass`` whose name ends in
166
+ the config class name suffix.
167
+ """
168
+ config_dataclass_names: set[str] = set()
169
+ for each_node in ast.walk(tree):
170
+ if isinstance(each_node, ast.ClassDef) and _is_config_dataclass(each_node):
171
+ config_dataclass_names.add(each_node.name)
172
+ return config_dataclass_names
173
+
174
+
175
+ def _call_constructs_config_class(
176
+ call_node: ast.Call, all_known_config_class_names: set[str]
177
+ ) -> bool:
178
+ """Return whether a call constructs a known ``*Config`` dataclass.
179
+
180
+ A call whose callee names a ``*Config`` dataclass defined under the scan root —
181
+ ``AppInfoConfig(...)`` or a qualified ``module.AppInfoConfig(...)`` —
182
+ constructs a config instance, and its keyword arguments write the named fields
183
+ 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.
186
+
187
+ Args:
188
+ call_node: The call expression to test.
189
+ all_known_config_class_names: Names of ``*Config`` dataclasses defined under
190
+ the scan root.
191
+
192
+ Returns:
193
+ True when the callee names a known ``*Config`` dataclass.
194
+ """
195
+ callee_node = call_node.func
196
+ if isinstance(callee_node, ast.Name):
197
+ return callee_node.id in all_known_config_class_names
198
+ if isinstance(callee_node, ast.Attribute):
199
+ return callee_node.attr in all_known_config_class_names
200
+ return False
201
+
202
+
203
+ def _config_constructor_keyword_node_ids(
204
+ tree: ast.Module, all_known_config_class_names: set[str]
205
+ ) -> set[int]:
206
+ """Return ids of keyword nodes that write fields in a known ``*Config`` constructor.
207
+
208
+ A keyword in an ``AppInfoConfig(field=value)`` call sets ``field`` rather than
209
+ reading it, so its node id is collected for the caller to exclude. The
210
+ exclusion is keyed per keyword node, not by name, so a same-named keyword in a
211
+ ``replace(cfg, field=value)`` call — which reuses a live instance and stays a
212
+ read — keeps its own distinct node and is not stripped.
213
+
214
+ Args:
215
+ tree: The parsed module to inspect.
216
+ all_known_config_class_names: Names of ``*Config`` dataclasses defined under
217
+ the scan root.
218
+
219
+ Returns:
220
+ The ``id()`` of every keyword node passed to a known ``*Config``
221
+ constructor call.
222
+ """
223
+ constructor_keyword_node_ids: set[int] = set()
224
+ for each_node in ast.walk(tree):
225
+ if not isinstance(each_node, ast.Call):
226
+ continue
227
+ if not _call_constructs_config_class(each_node, all_known_config_class_names):
228
+ continue
229
+ for each_keyword in each_node.keywords:
230
+ if each_keyword.arg is not None:
231
+ constructor_keyword_node_ids.add(id(each_keyword))
232
+ return constructor_keyword_node_ids
233
+
234
+
235
+ def _self_referential_default_attribute_node_ids(
236
+ field_name: str, default_value: ast.expr
237
+ ) -> set[int]:
238
+ """Return ids of attribute reads in a default whose name equals the field.
239
+
240
+ Walks a ``*Config`` field's default-value expression and collects the ``id()``
241
+ of each ``ast.Attribute`` read whose ``.attr`` equals ``field_name``. Such a
242
+ read — ``sound_upload_timeout_ms: int = submission_timing.sound_upload_timeout_ms``
243
+ — names the field being defined inside the class body, so it is not a consumer
244
+ of the field. An attribute read of a differently-named field
245
+ (``timeout_ms: int = other_config.base_timeout``) is a genuine consumer and is
246
+ left out of the returned set.
247
+
248
+ Args:
249
+ field_name: The name of the field being defined.
250
+ default_value: The default-value expression of that field.
251
+
252
+ Returns:
253
+ The ``id()`` of every self-referential attribute read inside the
254
+ default-value expression.
255
+ """
256
+ self_referential_node_ids: set[int] = set()
257
+ for each_inner_node in ast.walk(default_value):
258
+ if isinstance(each_inner_node, ast.Attribute) and each_inner_node.attr == field_name:
259
+ self_referential_node_ids.add(id(each_inner_node))
260
+ return self_referential_node_ids
261
+
262
+
263
+ def _config_field_default_value_nodes(tree: ast.Module) -> set[int]:
264
+ """Return ids of self-referential attribute reads in ``*Config`` field defaults.
265
+
266
+ A field default such as ``sound_upload_timeout_ms: int =``
267
+ ``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
273
+ (``timeout_ms: int = other_config.base_timeout``) leaves that read counted, so
274
+ ``base_timeout`` stays a live consumer.
275
+
276
+ Args:
277
+ tree: The parsed module to inspect.
278
+
279
+ Returns:
280
+ The ``id()`` of every self-referential attribute read within the
281
+ default-value expression of a field declared in a ``*Config`` dataclass
282
+ body.
283
+ """
284
+ default_value_node_ids: set[int] = set()
285
+ for each_node in ast.walk(tree):
286
+ if not isinstance(each_node, ast.ClassDef) or not _is_config_dataclass(each_node):
287
+ continue
288
+ for each_statement in each_node.body:
289
+ if not isinstance(each_statement, ast.AnnAssign):
290
+ continue
291
+ if each_statement.value is None:
292
+ continue
293
+ if not isinstance(each_statement.target, ast.Name):
294
+ continue
295
+ default_value_node_ids |= _self_referential_default_attribute_node_ids(
296
+ each_statement.target.id, each_statement.value
297
+ )
298
+ return default_value_node_ids
299
+
300
+
301
+ def _attribute_read_names_in_tree(
302
+ tree: ast.Module, all_known_config_class_names: set[str]
303
+ ) -> tuple[set[str], bool]:
304
+ """Return attribute names read in a parsed module and a suppression flag.
142
305
 
143
306
  Collects attribute names via five mechanisms: ``ast.Attribute.attr`` values
144
307
  in Load context, augmented-assignment targets (so ``cfg.debug_port += 1``
145
308
  contributes ``"debug_port"`` because ``+=`` reads the attribute before
146
309
  writing it), string literals (so ``getattr(obj, "field")`` contributes
147
- ``"field"``), keyword-argument names (so ``ThemeUpdateConfig(debug_port=1)``
148
- and ``replace(cfg, debug_port=1)`` each contribute ``"debug_port"``), and
149
- ``ast.MatchClass.kwd_attrs`` names (so ``case Config(field=x)`` contributes
150
- ``"field"``). The boolean reports whether the module suppresses the
151
- dead-field check, which it does only when it reflectively reads a whole
152
- instance a bare or ``dataclasses``-qualified ``asdict``/``astuple``/
153
- ``fields``/``replace``/``vars`` call, or an ``obj.__dict__`` read — because
154
- that pattern reads every field at once without naming any single field, so
155
- the caller treats it as "cannot prove any field dead". A ``SyntaxError``
156
- contributes no names and no suppression.
310
+ ``"field"``), keyword-argument names (so ``replace(cfg, debug_port=1)``
311
+ contributes ``"debug_port"``), and ``ast.MatchClass.kwd_attrs`` names (so
312
+ ``case Config(field=x)`` contributes ``"field"``). Two field-write forms are
313
+ excluded because they name a field without consuming it: a keyword that writes
314
+ a field in a known ``*Config`` constructor (``AppInfoConfig(field=value)``,
315
+ 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
317
+ own default-value expression (``field: int = source.field`` in the class body,
318
+ excluded only when the read name equals the field name) counting either
319
+ 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
322
+ suppresses the dead-field check, which it does only when it reflectively reads a
323
+ whole instance — a bare or ``dataclasses``-qualified
324
+ ``asdict``/``astuple``/``fields``/``replace``/``vars`` call, or an
325
+ ``obj.__dict__`` read — because that pattern reads every field at once without
326
+ naming any single field, so the caller treats it as "cannot prove any field
327
+ dead".
157
328
 
158
329
  Args:
159
- source: The full text of a ``.py`` module.
330
+ tree: The parsed module to inspect.
331
+ all_known_config_class_names: Names of ``*Config`` dataclasses defined under
332
+ the scan root, used to scope the constructor-keyword exclusion to
333
+ genuine config constructors.
160
334
 
161
335
  Returns:
162
336
  A (read_names, suppresses_dead_field_check) pair. The name set is every
163
- attribute name the module reads via any of the five mechanisms above;
164
- suppresses_dead_field_check is True only when a reflective whole-instance
165
- read is present.
337
+ attribute name the module reads via the mechanisms above, excluding known
338
+ ``*Config`` constructor keyword nodes and self-referential config-field
339
+ default-value attribute reads; suppresses_dead_field_check is True only when
340
+ a reflective whole-instance read is present.
166
341
  """
167
- try:
168
- tree = ast.parse(source)
169
- except SyntaxError:
170
- return set(), False
171
342
  all_read_names: set[str] = _augmented_assignment_attribute_names(tree)
343
+ config_constructor_keyword_node_ids = _config_constructor_keyword_node_ids(
344
+ tree, all_known_config_class_names
345
+ )
346
+ config_field_default_node_ids = _config_field_default_value_nodes(tree)
172
347
  for each_node in ast.walk(tree):
173
- if isinstance(each_node, ast.Attribute) and isinstance(each_node.ctx, ast.Load):
348
+ if (
349
+ isinstance(each_node, ast.Attribute)
350
+ and isinstance(each_node.ctx, ast.Load)
351
+ and id(each_node) not in config_field_default_node_ids
352
+ ):
174
353
  all_read_names.add(each_node.attr)
175
354
  elif isinstance(each_node, ast.Constant) and isinstance(each_node.value, str):
176
355
  all_read_names.add(each_node.value)
177
356
  elif isinstance(each_node, ast.MatchClass):
178
357
  all_read_names.update(each_node.kwd_attrs)
179
- elif isinstance(each_node, ast.keyword) and each_node.arg is not None:
358
+ elif (
359
+ isinstance(each_node, ast.keyword)
360
+ and each_node.arg is not None
361
+ and id(each_node) not in config_constructor_keyword_node_ids
362
+ ):
180
363
  all_read_names.add(each_node.arg)
181
364
  suppresses_dead_field_check = _reads_whole_instance_reflectively(tree)
182
365
  return all_read_names, suppresses_dead_field_check
183
366
 
184
367
 
185
- def _all_production_read_names_under_root(
368
+ def _iter_production_module_sources(
186
369
  scan_root: Path,
187
370
  written_path: Path,
188
371
  written_content: str,
189
- ) -> tuple[set[str], bool, bool]:
190
- """Return read names, a cap-hit flag, and a suppression flag for the tree.
372
+ ) -> Iterator[str | None]:
373
+ """Yield the source of each production module under the scan root.
191
374
 
192
- Scans every production ``.py`` module under ``scan_root`` (excluding test and
193
- migration files) for attribute reads. The written module's post-edit content
194
- replaces its on-disk text so the current edit is included. Scanning stops at
195
- the configured file cap. A module that reflectively reads a whole instance —
196
- a bare or ``dataclasses``-qualified ``asdict``/``astuple``/``fields``/
197
- ``replace``/``vars`` call, or an ``obj.__dict__`` read — sets the suppression
198
- flag, signalling the caller that no field can be proven dead.
375
+ Yields ``written_content`` for the written module so the current edit is
376
+ included, then each sibling production module's source (excluding test and
377
+ migration files). A sibling whose text cannot be read is skipped. A single
378
+ ``None`` is yielded when the production module count exceeds the configured
379
+ file cap, signalling the caller that no field can be proven dead.
199
380
 
200
381
  Args:
201
382
  scan_root: The directory tree to scan.
202
383
  written_path: The resolved path of the module being written.
203
384
  written_content: The post-edit text of the written module.
204
385
 
205
- Returns:
206
- A (read_names, cap_was_hit, suppresses_dead_field_check) triple. The name
207
- set is the union of attribute reads across every scanned production
208
- module; cap_was_hit is True when the scan stopped at the configured file
209
- cap before finishing the tree; suppresses_dead_field_check is True when
210
- any scanned module reflectively reads a whole instance.
386
+ Yields:
387
+ Each production module's source text, or a single ``None`` on a cap hit.
211
388
  """
212
- all_read_names, suppresses_dead_field_check = _attribute_read_names_in_source(written_content)
389
+ yield written_content
213
390
  written_path_key = os.path.normcase(str(written_path))
214
391
  scanned_file_count = 1
215
392
  for each_path in scan_root.rglob("*" + PYTHON_SOURCE_SUFFIX):
@@ -223,17 +400,71 @@ def _all_production_read_names_under_root(
223
400
  continue
224
401
  scanned_file_count += 1
225
402
  if scanned_file_count > MAX_SCAN_ROOT_FILE_COUNT:
226
- return all_read_names, True, suppresses_dead_field_check
403
+ yield None
404
+ return
227
405
  try:
228
- sibling_source = each_path.read_text(encoding="utf-8")
406
+ yield each_path.read_text(encoding="utf-8")
229
407
  except (OSError, UnicodeDecodeError):
230
408
  continue
231
- sibling_read_names, sibling_suppresses_dead_field_check = _attribute_read_names_in_source(
232
- sibling_source
409
+
410
+
411
+ def _all_production_read_names_under_root(
412
+ scan_root: Path,
413
+ written_path: Path,
414
+ written_content: str,
415
+ ) -> tuple[set[str], bool, bool]:
416
+ """Return read names, a cap-hit flag, and a suppression flag for the tree.
417
+
418
+ Reads and AST-parses every production ``.py`` module under ``scan_root``
419
+ (excluding test and migration files) at most once: the module sources are
420
+ 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
423
+ the same cached sources collects attribute reads. The written module's
424
+ post-edit content replaces its on-disk text so the current edit is included.
425
+ Scanning stops at the configured file cap. A module that reflectively reads a
426
+ whole instance — a bare or ``dataclasses``-qualified
427
+ ``asdict``/``astuple``/``fields``/``replace``/``vars`` call, or an
428
+ ``obj.__dict__`` read — sets the suppression flag, signalling the caller that
429
+ no field can be proven dead.
430
+
431
+ Args:
432
+ scan_root: The directory tree to scan.
433
+ written_path: The resolved path of the module being written.
434
+ written_content: The post-edit text of the written module.
435
+
436
+ Returns:
437
+ A (read_names, cap_was_hit, suppresses_dead_field_check) triple. The name
438
+ set is the union of attribute reads across every scanned production
439
+ module; cap_was_hit is True when the scan stopped at the configured file
440
+ cap before finishing the tree; suppresses_dead_field_check is True when
441
+ any scanned module reflectively reads a whole instance.
442
+ """
443
+ all_module_sources = list(
444
+ _iter_production_module_sources(scan_root, written_path, written_content)
445
+ )
446
+ if None in all_module_sources:
447
+ return set(), True, False
448
+ all_production_trees: list[ast.Module] = []
449
+ for each_source in all_module_sources:
450
+ if each_source is None:
451
+ continue
452
+ try:
453
+ all_production_trees.append(ast.parse(each_source))
454
+ except SyntaxError:
455
+ continue
456
+ all_known_config_class_names: set[str] = set()
457
+ for each_tree in all_production_trees:
458
+ all_known_config_class_names |= _config_dataclass_names(each_tree)
459
+ all_read_names: set[str] = set()
460
+ suppresses_dead_field_check = False
461
+ for each_tree in all_production_trees:
462
+ module_read_names, module_suppresses_dead_field_check = _attribute_read_names_in_tree(
463
+ each_tree, all_known_config_class_names
233
464
  )
234
- all_read_names |= sibling_read_names
465
+ all_read_names |= module_read_names
235
466
  suppresses_dead_field_check = (
236
- suppresses_dead_field_check or sibling_suppresses_dead_field_check
467
+ suppresses_dead_field_check or module_suppresses_dead_field_check
237
468
  )
238
469
  return all_read_names, False, suppresses_dead_field_check
239
470
 
@@ -247,9 +478,12 @@ def check_dead_config_dataclass_fields(
247
478
  in ``"Config"``. For each such config dataclass in the written file, every
248
479
  instance field whose name does not appear as an attribute read (``obj.field``),
249
480
  augmented-assignment target (``cfg.field += 1``), string literal,
250
- keyword-argument name (constructor or ``replace`` keyword), or match-pattern
481
+ non-constructor keyword-argument name (``replace`` keyword), or match-pattern
251
482
  keyword attribute in any production module under the enclosing scan root is
252
- flagged as dead. When any production module under the scan root reflectively
483
+ flagged as dead. A keyword that writes a field in a ``*Config`` constructor
484
+ (``ThemeUpdateConfig(debug_port=1)``) is a write, not a read, so it does not
485
+ clear a field — a field set by a constructor keyword and read by no module is
486
+ flagged. When any production module under the scan root reflectively
253
487
  reads a whole instance — a bare or ``dataclasses``-qualified call to
254
488
  ``asdict``, ``astuple``, ``fields``, ``replace``, or ``vars``, or a read of
255
489
  ``obj.__dict__`` — the check is suppressed for the whole tree and returns
@@ -26,9 +26,11 @@ from hooks_constants.blocking_check_limits import ( # noqa: E402
26
26
  ALL_DOCSTRING_MULTIPLE_CONDITION_JOINING_PHRASES,
27
27
  DOCSTRING_FALLBACK_BRANCH_MINIMUM_ROUTE_COUNT,
28
28
  DOCSTRING_TRIVIAL_FUNCTION_BODY_LINE_LIMIT,
29
+ MAX_CLASS_DOCSTRING_PUBLIC_METHOD_ISSUES,
29
30
  MAX_DOCSTRING_ARGS_SIGNATURE_ISSUES,
30
31
  MAX_DOCSTRING_FALLBACK_BRANCH_ISSUES,
31
32
  MAX_DOCSTRING_FORMAT_ISSUES,
33
+ MINIMUM_PUBLIC_METHODS_FOR_CLASS_DOCSTRING_BREADTH,
32
34
  )
33
35
  from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
34
36
  ALL_DOCSTRING_ARGS_SECTION_HEADERS,
@@ -462,3 +464,98 @@ def check_docstring_fallback_branch_coverage(content: str, file_path: str) -> li
462
464
  if len(issues) >= MAX_DOCSTRING_FALLBACK_BRANCH_ISSUES:
463
465
  break
464
466
  return issues[:MAX_DOCSTRING_FALLBACK_BRANCH_ISSUES]
467
+
468
+
469
+ def _class_docstring_summary_is_single_line(docstring_text: str) -> bool:
470
+ stripped_text = docstring_text.strip()
471
+ if not stripped_text:
472
+ return False
473
+ summary_line, separator, _remainder = stripped_text.partition("\n")
474
+ if separator and stripped_text[len(summary_line):].strip():
475
+ return False
476
+ return bool(summary_line.strip())
477
+
478
+
479
+ def _public_method_names(class_node: ast.ClassDef) -> list[str]:
480
+ deduplicated_names: dict[str, None] = {}
481
+ for each_statement in class_node.body:
482
+ if not isinstance(each_statement, (ast.FunctionDef, ast.AsyncFunctionDef)):
483
+ continue
484
+ if _function_is_private_or_dunder(each_statement.name):
485
+ continue
486
+ deduplicated_names[each_statement.name] = None
487
+ return list(deduplicated_names)
488
+
489
+
490
+ def _name_tokens(method_name: str) -> list[str]:
491
+ return [each_token for each_token in method_name.split("_") if each_token]
492
+
493
+
494
+ def _docstring_mentions_method(docstring_text: str, method_name: str) -> bool:
495
+ lowered_docstring = docstring_text.lower()
496
+ if method_name.lower() in lowered_docstring:
497
+ return True
498
+ return all(
499
+ each_token.lower() in lowered_docstring for each_token in _name_tokens(method_name)
500
+ )
501
+
502
+
503
+ def _unmentioned_public_methods(class_node: ast.ClassDef, docstring_text: str) -> list[str]:
504
+ return [
505
+ each_name
506
+ for each_name in _public_method_names(class_node)
507
+ if not _docstring_mentions_method(docstring_text, each_name)
508
+ ]
509
+
510
+
511
+ def check_class_docstring_names_public_methods(
512
+ content: str, file_path: str
513
+ ) -> list[str]:
514
+ """Flag a one-line class docstring that omits two or more public methods.
515
+
516
+ A class whose docstring is a single summary line names one responsibility,
517
+ so a reader trusts that line to describe the whole class. When the class
518
+ later gains a second public entry point — the drift pattern where a
519
+ coffee-break reporter grows a regular-pace method — the terse summary keeps
520
+ describing only the original feature. Each public method whose name (or all
521
+ of its underscore-separated tokens) appears nowhere in the summary counts as
522
+ omitted; a class with two or more omitted public methods is reported so the
523
+ summary is widened to name the broader surface. Classes with a multi-line
524
+ docstring body are left to the audit lane, since their prose can carry the
525
+ enumeration without naming each method by name.
526
+
527
+ Args:
528
+ content: The source text to inspect.
529
+ file_path: The path the source will be written to, used for exemptions.
530
+
531
+ Returns:
532
+ One issue per class whose single-line docstring omits two or more of its
533
+ public methods, capped at the module limit.
534
+ """
535
+ if is_test_file(file_path) or is_hook_infrastructure(file_path):
536
+ return []
537
+ try:
538
+ parsed_tree = ast.parse(content)
539
+ except SyntaxError:
540
+ return []
541
+ issues: list[str] = []
542
+ for each_node in _walk_skipping_type_checking_blocks(parsed_tree):
543
+ if not isinstance(each_node, ast.ClassDef):
544
+ continue
545
+ class_docstring = ast.get_docstring(each_node) or ""
546
+ if not _class_docstring_summary_is_single_line(class_docstring):
547
+ continue
548
+ public_names = _public_method_names(each_node)
549
+ if len(public_names) < MINIMUM_PUBLIC_METHODS_FOR_CLASS_DOCSTRING_BREADTH:
550
+ continue
551
+ unmentioned_names = _unmentioned_public_methods(each_node, class_docstring)
552
+ if len(unmentioned_names) < MINIMUM_PUBLIC_METHODS_FOR_CLASS_DOCSTRING_BREADTH:
553
+ continue
554
+ issues.append(
555
+ f"Line {each_node.lineno}: {each_node.name} one-line docstring omits "
556
+ f"public method(s) {', '.join(unmentioned_names)} — widen the summary "
557
+ "so it names the class's full public surface"
558
+ )
559
+ if len(issues) >= MAX_CLASS_DOCSTRING_PUBLIC_METHOD_ISSUES:
560
+ break
561
+ return issues[:MAX_CLASS_DOCSTRING_PUBLIC_METHOD_ISSUES]
@@ -65,6 +65,7 @@ from code_rules_dead_module_constant import ( # noqa: E402
65
65
  check_dead_module_constants,
66
66
  )
67
67
  from code_rules_docstrings import ( # noqa: E402
68
+ check_class_docstring_names_public_methods,
68
69
  check_docstring_args_match_signature,
69
70
  check_docstring_fallback_branch_coverage,
70
71
  check_docstring_format,
@@ -250,6 +251,9 @@ def validate_content(
250
251
  all_issues.extend(check_docstring_format(effective_content, file_path))
251
252
  all_issues.extend(check_docstring_args_match_signature(effective_content, file_path))
252
253
  all_issues.extend(check_docstring_fallback_branch_coverage(effective_content, file_path))
254
+ all_issues.extend(
255
+ check_class_docstring_names_public_methods(effective_content, file_path)
256
+ )
253
257
  all_issues.extend(
254
258
  check_boolean_naming(
255
259
  effective_content,
@@ -0,0 +1,22 @@
1
+ # hooks/blocking/config
2
+
3
+ A Python package that holds shared constants for the verified-commit gate family. Three modules in `blocking/` import from here:
4
+
5
+ - `verification_verdict_store.py`
6
+ - `verified_commit_gate.py`
7
+ - `verifier_verdict_minter.py`
8
+
9
+ ## Key files
10
+
11
+ | File | Contents |
12
+ |---|---|
13
+ | `__init__.py` | Declares this as a regular package (not a namespace package) so it resolves first on `sys.path` |
14
+ | `verified_commit_constants.py` | All tunables for the gate: directory names, regex patterns for detecting verdict paths and obfuscation attempts, timeout values, git subcommand sets, bypass marker, and corrective messages |
15
+
16
+ ## Key constants in `verified_commit_constants.py`
17
+
18
+ - `VERIFICATION_BYPASS_MARKER` — the `# verify-skip` comment that exempts a single commit/push from the gate
19
+ - `MINTING_AGENT_TYPE` — `"code-verifier"`, the agent type whose SubagentStop hook mints verdicts
20
+ - `VERDICT_DIRECTORY_NAME` — `"verification"`, the directory under `~/.claude/` that holds verdict JSON files
21
+ - `DOCS_ONLY_EXTENSIONS` — extensions (`.md`, `.txt`, images) whose changes are mechanically exempt from the gate
22
+ - `CORRECTIVE_MESSAGE` / `VERDICT_DIRECTORY_GUARD_MESSAGE` — user-facing block messages