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
@@ -0,0 +1,262 @@
1
+ """Tests for check_class_docstring_names_public_methods — class prose breadth.
2
+
3
+ A class whose docstring is a single summary line names one responsibility. When
4
+ the class exposes a second public entry point the summary never names, the prose
5
+ under-describes the class — the same drift the os_update_workflow break reporter
6
+ hit when it grew a regular-pace method beside its coffee-break method.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import importlib.util
12
+ from pathlib import Path
13
+ from types import ModuleType
14
+
15
+
16
+ def _load_enforcer_module() -> ModuleType:
17
+ module_path = Path(__file__).parent / "code_rules_enforcer.py"
18
+ spec = importlib.util.spec_from_file_location("code_rules_enforcer", module_path)
19
+ assert spec is not None
20
+ assert spec.loader is not None
21
+ module = importlib.util.module_from_spec(spec)
22
+ spec.loader.exec_module(module)
23
+ return module
24
+
25
+
26
+ code_rules_enforcer = _load_enforcer_module()
27
+
28
+
29
+ def check_class_docstring_names_public_methods(content: str, file_path: str) -> list[str]:
30
+ return code_rules_enforcer.check_class_docstring_names_public_methods(content, file_path)
31
+
32
+
33
+ def validate_content(content: str, file_path: str, old_content: str) -> list[str]:
34
+ return code_rules_enforcer.validate_content(content, file_path, old_content)
35
+
36
+
37
+ PRODUCTION_FILE_PATH = "/project/src/break_reporter.py"
38
+ TEST_FILE_PATH = "/project/src/test_break_reporter.py"
39
+ HOOK_INFRASTRUCTURE_PATH = "/home/user/.claude/hooks/blocking/example.py"
40
+
41
+
42
+ def _narrow_class_with_widened_surface() -> str:
43
+ return (
44
+ "class ConsoleBreakReporter:\n"
45
+ ' """Run a coffee break with operator visibility: announce, then count down."""\n'
46
+ "\n"
47
+ " async def pause_then_resume(self, seconds: float) -> None:\n"
48
+ " await self._sleep(seconds)\n"
49
+ "\n"
50
+ " async def stretch_then_resume(self, seconds: float) -> None:\n"
51
+ " await self._sleep(seconds)\n"
52
+ )
53
+
54
+
55
+ def test_should_flag_single_line_docstring_omitting_two_public_methods() -> None:
56
+ issues = check_class_docstring_names_public_methods(
57
+ _narrow_class_with_widened_surface(), PRODUCTION_FILE_PATH
58
+ )
59
+ assert any("pause_then_resume" in each for each in issues), (
60
+ f"Expected omitted-method flag, got: {issues!r}"
61
+ )
62
+ assert any("stretch_then_resume" in each for each in issues)
63
+ assert len(issues) == 1
64
+
65
+
66
+ def test_should_not_flag_when_summary_names_every_public_method() -> None:
67
+ source = (
68
+ "class ConsoleBreakReporter:\n"
69
+ ' """Announce a pause then resume, or stretch then resume, with a countdown."""\n'
70
+ "\n"
71
+ " async def pause_then_resume(self, seconds: float) -> None:\n"
72
+ " await self._sleep(seconds)\n"
73
+ "\n"
74
+ " async def stretch_then_resume(self, seconds: float) -> None:\n"
75
+ " await self._sleep(seconds)\n"
76
+ )
77
+ issues = check_class_docstring_names_public_methods(source, PRODUCTION_FILE_PATH)
78
+ assert issues == [], f"Summary naming every method must not flag, got: {issues!r}"
79
+
80
+
81
+ def test_should_not_flag_when_only_one_public_method_is_omitted() -> None:
82
+ source = (
83
+ "class ConsoleBreakReporter:\n"
84
+ ' """Pause then resume the submission run with an operator countdown."""\n'
85
+ "\n"
86
+ " async def pause_then_resume(self, seconds: float) -> None:\n"
87
+ " await self._sleep(seconds)\n"
88
+ "\n"
89
+ " async def stretch_then_resume(self, seconds: float) -> None:\n"
90
+ " await self._sleep(seconds)\n"
91
+ )
92
+ issues = check_class_docstring_names_public_methods(source, PRODUCTION_FILE_PATH)
93
+ assert issues == [], f"A single omitted method must not flag, got: {issues!r}"
94
+
95
+
96
+ def test_should_not_flag_multi_line_docstring_body() -> None:
97
+ source = (
98
+ "class ConsoleBreakReporter:\n"
99
+ ' """Run a coffee break with operator visibility.\n'
100
+ "\n"
101
+ " Also paces the regular between-theme waits through the same seam.\n"
102
+ ' """\n'
103
+ "\n"
104
+ " async def pause_then_resume(self, seconds: float) -> None:\n"
105
+ " await self._sleep(seconds)\n"
106
+ "\n"
107
+ " async def stretch_then_resume(self, seconds: float) -> None:\n"
108
+ " await self._sleep(seconds)\n"
109
+ )
110
+ issues = check_class_docstring_names_public_methods(source, PRODUCTION_FILE_PATH)
111
+ assert issues == [], f"Multi-line docstrings go to the audit lane, got: {issues!r}"
112
+
113
+
114
+ def test_should_not_flag_class_with_single_public_method() -> None:
115
+ source = (
116
+ "class ConsoleBreakReporter:\n"
117
+ ' """Run a coffee break with operator visibility."""\n'
118
+ "\n"
119
+ " async def pause_then_resume(self, seconds: float) -> None:\n"
120
+ " await self._sleep(seconds)\n"
121
+ )
122
+ issues = check_class_docstring_names_public_methods(source, PRODUCTION_FILE_PATH)
123
+ assert issues == [], f"A one-method class must not flag, got: {issues!r}"
124
+
125
+
126
+ def test_should_skip_private_methods_when_counting_surface() -> None:
127
+ source = (
128
+ "class ConsoleBreakReporter:\n"
129
+ ' """Run a coffee break with operator visibility."""\n'
130
+ "\n"
131
+ " async def pause_then_resume(self, seconds: float) -> None:\n"
132
+ " await self._sleep(seconds)\n"
133
+ "\n"
134
+ " async def _sleep(self, seconds: float) -> None:\n"
135
+ " await self._clock.sleep(seconds)\n"
136
+ "\n"
137
+ " def __init__(self) -> None:\n"
138
+ " self._clock = None\n"
139
+ )
140
+ issues = check_class_docstring_names_public_methods(source, PRODUCTION_FILE_PATH)
141
+ assert issues == [], f"Private and dunder methods are not public surface, got: {issues!r}"
142
+
143
+
144
+ def test_should_skip_class_without_docstring() -> None:
145
+ source = (
146
+ "class ConsoleBreakReporter:\n"
147
+ " async def pause_then_resume(self, seconds: float) -> None:\n"
148
+ " await self._sleep(seconds)\n"
149
+ "\n"
150
+ " async def stretch_then_resume(self, seconds: float) -> None:\n"
151
+ " await self._sleep(seconds)\n"
152
+ )
153
+ issues = check_class_docstring_names_public_methods(source, PRODUCTION_FILE_PATH)
154
+ assert issues == [], f"No-docstring classes are out of scope, got: {issues!r}"
155
+
156
+
157
+ def test_should_skip_test_file() -> None:
158
+ issues = check_class_docstring_names_public_methods(
159
+ _narrow_class_with_widened_surface(), TEST_FILE_PATH
160
+ )
161
+ assert issues == [], f"Test files exempt, got: {issues!r}"
162
+
163
+
164
+ def test_should_skip_hook_infrastructure() -> None:
165
+ issues = check_class_docstring_names_public_methods(
166
+ _narrow_class_with_widened_surface(), HOOK_INFRASTRUCTURE_PATH
167
+ )
168
+ assert issues == [], f"Hook infrastructure exempt, got: {issues!r}"
169
+
170
+
171
+ def test_should_handle_syntax_error_gracefully() -> None:
172
+ issues = check_class_docstring_names_public_methods("class Broken(\n", PRODUCTION_FILE_PATH)
173
+ assert issues == [], f"Syntax error must yield no issues, got: {issues!r}"
174
+
175
+
176
+ def _real_break_reporter_drift() -> str:
177
+ return (
178
+ "class ConsoleBreakReporter:\n"
179
+ ' """Run a coffee-break with operator visibility: announce, then count down."""\n'
180
+ "\n"
181
+ " async def announce_and_pause(self, nominal_break_seconds: float) -> None:\n"
182
+ " await self._announce(nominal_break_seconds)\n"
183
+ "\n"
184
+ " async def announce_and_pause_exact(self, break_seconds: float) -> None:\n"
185
+ " await self._announce(break_seconds)\n"
186
+ )
187
+
188
+
189
+ def test_should_flag_real_break_reporter_widened_surface() -> None:
190
+ issues = check_class_docstring_names_public_methods(
191
+ _real_break_reporter_drift(), PRODUCTION_FILE_PATH
192
+ )
193
+ assert any("announce_and_pause_exact" in each for each in issues), (
194
+ f"Expected the regular-pace method to flag, got: {issues!r}"
195
+ )
196
+ assert any("announce_and_pause" in each for each in issues)
197
+ assert len(issues) == 1
198
+
199
+
200
+ def _overload_class_omitting_the_only_method() -> str:
201
+ return (
202
+ "from typing import overload\n"
203
+ "\n"
204
+ "class PayloadTransformer:\n"
205
+ ' """Hold a fixed payload value for later use."""\n'
206
+ "\n"
207
+ " @overload\n"
208
+ " def transform(self, payload: str) -> dict[str, str]: ...\n"
209
+ "\n"
210
+ " @overload\n"
211
+ " def transform(self, payload: bytes) -> dict[str, str]: ...\n"
212
+ "\n"
213
+ " def transform(self, payload: str | bytes) -> dict[str, str]:\n"
214
+ " return self._normalize(payload)\n"
215
+ )
216
+
217
+
218
+ def test_should_not_flag_single_overloaded_method_below_breadth_threshold() -> None:
219
+ issues = check_class_docstring_names_public_methods(
220
+ _overload_class_omitting_the_only_method(), PRODUCTION_FILE_PATH
221
+ )
222
+ assert issues == [], f"One overloaded public method must not flag, got: {issues!r}"
223
+
224
+
225
+ def test_should_not_repeat_an_overloaded_name_in_the_issue_message() -> None:
226
+ source = (
227
+ "from typing import overload\n"
228
+ "\n"
229
+ "class PayloadTransformer:\n"
230
+ ' """Hold a fixed payload value for later use."""\n'
231
+ "\n"
232
+ " @overload\n"
233
+ " def transform(self, payload: str) -> dict[str, str]: ...\n"
234
+ "\n"
235
+ " @overload\n"
236
+ " def transform(self, payload: bytes) -> dict[str, str]: ...\n"
237
+ "\n"
238
+ " def transform(self, payload: str | bytes) -> dict[str, str]:\n"
239
+ " return self._normalize(payload)\n"
240
+ "\n"
241
+ " def reset(self) -> None:\n"
242
+ " self._payload = None\n"
243
+ )
244
+ issues = check_class_docstring_names_public_methods(source, PRODUCTION_FILE_PATH)
245
+ assert len(issues) == 1, f"Expected a single drift issue, got: {issues!r}"
246
+ only_issue = issues[0]
247
+ assert only_issue.count("transform") == 1, (
248
+ f"Overloaded name must appear once in the message, got: {only_issue!r}"
249
+ )
250
+ assert "reset" in only_issue
251
+
252
+
253
+ def test_validate_content_surfaces_class_docstring_breadth_drift() -> None:
254
+ issues = validate_content(
255
+ _narrow_class_with_widened_surface(), PRODUCTION_FILE_PATH, old_content=""
256
+ )
257
+ matching_issues = [
258
+ each for each in issues if "pause_then_resume" in each and "public method" in each
259
+ ]
260
+ assert matching_issues, (
261
+ f"Expected validate_content to surface the class-breadth drift, got: {issues!r}"
262
+ )
@@ -223,19 +223,260 @@ def test_does_not_flag_field_used_only_as_replace_keyword(neutral_root: Path) ->
223
223
  )
224
224
 
225
225
 
226
- def test_does_not_flag_field_used_only_as_constructor_keyword(neutral_root: Path) -> None:
226
+ def test_flags_field_set_only_by_constructor_keyword_and_read_nowhere(
227
+ neutral_root: Path,
228
+ ) -> None:
229
+ """A field set ONLY by a ``*Config`` constructor keyword, read nowhere, is dead.
230
+
231
+ A constructor keyword writes the field; it is not a read. When ``debug_port``
232
+ is set by ``ThemeUpdateConfig(debug_port=1)`` and read through no config
233
+ instance anywhere in production, tuning it has no effect, so it is flagged as
234
+ dead config (CODE_RULES §9.8).
235
+ """
227
236
  consumer_body = (
228
237
  "from os_update_workflow.config import ThemeUpdateConfig\n"
229
238
  "\n"
230
239
  "def build() -> ThemeUpdateConfig:\n"
231
- " return ThemeUpdateConfig(portal_url='x', debug_port=1, timeout_seconds=99)\n"
240
+ " configuration = ThemeUpdateConfig(portal_url='x', debug_port=1, timeout_seconds=99)\n"
241
+ " print(configuration.portal_url)\n"
242
+ " print(configuration.timeout_seconds)\n"
243
+ " return configuration\n"
232
244
  )
233
245
  config_path = _build_config_package(
234
246
  neutral_root / "workflow", THEME_UPDATE_CONFIG_BODY, consumer_body
235
247
  )
236
248
  issues = _check(THEME_UPDATE_CONFIG_BODY, str(config_path))
249
+ assert any("'debug_port'" in each_issue for each_issue in issues), (
250
+ f"Field set only by constructor keyword and read nowhere must be flagged, got: {issues}"
251
+ )
252
+ assert not any(
253
+ "'portal_url'" in each_issue or "'timeout_seconds'" in each_issue for each_issue in issues
254
+ ), f"Fields read through the config instance must not be flagged, got: {issues}"
255
+
256
+
257
+ def test_qualified_config_constructor_keyword_does_not_clear_field(
258
+ neutral_root: Path,
259
+ ) -> None:
260
+ """A qualified ``module.ThemeUpdateConfig(field=value)`` keyword is a write, not a read.
261
+
262
+ The constructor callee may be a qualified attribute (``config_module.ThemeUpdateConfig``)
263
+ rather than a bare name. Its keyword still writes the field, so a field set only
264
+ this way and read through no config instance is flagged dead.
265
+ """
266
+ consumer_body = (
267
+ "import os_update_workflow.config as config_module\n"
268
+ "\n"
269
+ "def build() -> config_module.ThemeUpdateConfig:\n"
270
+ " configuration = config_module.ThemeUpdateConfig(\n"
271
+ " portal_url='x', debug_port=1, timeout_seconds=99\n"
272
+ " )\n"
273
+ " print(configuration.portal_url)\n"
274
+ " print(configuration.timeout_seconds)\n"
275
+ " return configuration\n"
276
+ )
277
+ config_path = _build_config_package(
278
+ neutral_root / "workflow", THEME_UPDATE_CONFIG_BODY, consumer_body
279
+ )
280
+ issues = _check(THEME_UPDATE_CONFIG_BODY, str(config_path))
281
+ assert any("'debug_port'" in each_issue for each_issue in issues), (
282
+ f"A qualified config constructor keyword is a write, so debug_port is flagged, got: {issues}"
283
+ )
284
+
285
+
286
+ def test_does_not_flag_field_set_by_constructor_keyword_and_read_elsewhere(
287
+ neutral_root: Path,
288
+ ) -> None:
289
+ """A field set by a constructor keyword AND read via attribute elsewhere is live.
290
+
291
+ The constructor keyword does not clear the field, but a genuine attribute read
292
+ of the same field in another module does, so the field is not flagged.
293
+ """
294
+ builder_body = (
295
+ "from os_update_workflow.config import ThemeUpdateConfig\n"
296
+ "\n"
297
+ "def build() -> ThemeUpdateConfig:\n"
298
+ " return ThemeUpdateConfig(portal_url='x', debug_port=1, timeout_seconds=99)\n"
299
+ )
300
+ config_path = _build_config_package(
301
+ neutral_root / "workflow", THEME_UPDATE_CONFIG_BODY, builder_body
302
+ )
303
+ reader_body = (
304
+ "from os_update_workflow.config import ThemeUpdateConfig\n"
305
+ "\n"
306
+ "def connect(configuration: ThemeUpdateConfig) -> int:\n"
307
+ " return configuration.debug_port\n"
308
+ )
309
+ (config_path.parent.parent / "reader.py").write_text(reader_body, encoding="utf-8")
310
+ issues = _check(THEME_UPDATE_CONFIG_BODY, str(config_path))
311
+ assert not any("'debug_port'" in each_issue for each_issue in issues), (
312
+ f"An attribute read in another module keeps debug_port live, got: {issues}"
313
+ )
314
+
315
+
316
+ def test_flags_field_set_by_default_and_constructor_keyword_only(
317
+ neutral_root: Path,
318
+ ) -> None:
319
+ """The PR #317 dead-config shape: field set by config default + constructor keyword only.
320
+
321
+ ``AppInfoConfig.sound_upload_max_attempts: int = submission_timing.sound_upload_max_attempts``
322
+ sets the field from a same-named attribute on another object inside the config
323
+ body, and the orchestrator sets ``sound_upload_timeout_ms`` by a constructor
324
+ keyword. Neither field is read through any config instance. The default-value
325
+ read inside the config body and the constructor keyword are both writes, so
326
+ both fields are flagged dead.
327
+
328
+ Residual limitation: when a consumer module's constructor VALUE expression
329
+ itself reads a same-named attribute on a different object
330
+ (``AppInfoConfig(field=other.field)``), the object-blind attribute-read
331
+ collector counts ``field`` as read and the field escapes. This test keeps the
332
+ constructor value a literal so the keyword-write and default-write exclusions
333
+ are exercised without that foreign-attribute leak.
334
+ """
335
+ config_body = (
336
+ "from dataclasses import dataclass\n"
337
+ "import submission_timing_module as submission_timing\n"
338
+ "\n"
339
+ "@dataclass(frozen=True)\n"
340
+ "class AppInfoConfig:\n"
341
+ " sound_upload_timeout_ms: int = submission_timing.sound_upload_timeout_ms\n"
342
+ " sound_upload_max_attempts: int = submission_timing.sound_upload_max_attempts\n"
343
+ )
344
+ workflow_directory = neutral_root / "workflow"
345
+ config_package = workflow_directory / "os_update_workflow"
346
+ config_package.mkdir(parents=True)
347
+ (config_package / "__init__.py").write_text("", encoding="utf-8")
348
+ config_path = config_package / "config.py"
349
+ config_path.write_text(config_body, encoding="utf-8")
350
+ orchestrator_body = (
351
+ "from os_update_workflow.config import AppInfoConfig\n"
352
+ "\n"
353
+ "def build() -> AppInfoConfig:\n"
354
+ " config = AppInfoConfig(sound_upload_timeout_ms=60000)\n"
355
+ " return config\n"
356
+ )
357
+ (workflow_directory / "orchestrator.py").write_text(orchestrator_body, encoding="utf-8")
358
+ issues = _check(config_body, str(config_path))
359
+ assert any("'sound_upload_timeout_ms'" in each_issue for each_issue in issues), (
360
+ f"Field set by constructor keyword and read nowhere must be flagged, got: {issues}"
361
+ )
362
+ assert any("'sound_upload_max_attempts'" in each_issue for each_issue in issues), (
363
+ f"Field set by config default only and read nowhere must be flagged, got: {issues}"
364
+ )
365
+
366
+
367
+ def test_does_not_flag_config_default_field_read_through_instance(
368
+ neutral_root: Path,
369
+ ) -> None:
370
+ """A field whose default reads a foreign attribute but is read through the instance is live.
371
+
372
+ The default-value exclusion only drops the self-referential read inside the
373
+ config body; a genuine ``config.sound_upload_timeout_ms`` read in production
374
+ keeps the field live.
375
+ """
376
+ config_body = (
377
+ "from dataclasses import dataclass\n"
378
+ "import submission_timing_module as submission_timing\n"
379
+ "\n"
380
+ "@dataclass(frozen=True)\n"
381
+ "class AppInfoConfig:\n"
382
+ " sound_upload_timeout_ms: int = submission_timing.sound_upload_timeout_ms\n"
383
+ )
384
+ workflow_directory = neutral_root / "workflow"
385
+ config_package = workflow_directory / "os_update_workflow"
386
+ config_package.mkdir(parents=True)
387
+ (config_package / "__init__.py").write_text("", encoding="utf-8")
388
+ config_path = config_package / "config.py"
389
+ config_path.write_text(config_body, encoding="utf-8")
390
+ processor_body = (
391
+ "from os_update_workflow.config import AppInfoConfig\n"
392
+ "\n"
393
+ "def wait(config: AppInfoConfig) -> int:\n"
394
+ " return config.sound_upload_timeout_ms\n"
395
+ )
396
+ (workflow_directory / "processor.py").write_text(processor_body, encoding="utf-8")
397
+ issues = _check(config_body, str(config_path))
237
398
  assert issues == [], (
238
- f"Constructor keyword arguments name every field, so none may be flagged, got: {issues}"
399
+ f"A genuine config-instance read keeps the field live, got: {issues}"
400
+ )
401
+
402
+
403
+ def test_config_default_sourcing_differently_named_field_keeps_it_live(
404
+ neutral_root: Path,
405
+ ) -> None:
406
+ """A default that sources a DIFFERENTLY-named field on another config keeps it live.
407
+
408
+ ``AppConfig.timeout_ms: int = DEFAULT_TIMING.base_timeout`` reads
409
+ ``base_timeout`` on another config object inside the class body. The
410
+ default-value exclusion drops only the self-referential read whose attribute
411
+ name equals the field being defined; a differently-named attribute read is a
412
+ genuine consumer of ``base_timeout``, so it stays live and is not flagged.
413
+ """
414
+ config_body = (
415
+ "from dataclasses import dataclass\n"
416
+ "\n"
417
+ "@dataclass(frozen=True)\n"
418
+ "class TimingConfig:\n"
419
+ " base_timeout: int\n"
420
+ " poll_interval: int\n"
421
+ "\n"
422
+ "DEFAULT_TIMING = TimingConfig(base_timeout=30, poll_interval=5)\n"
423
+ "\n"
424
+ "@dataclass(frozen=True)\n"
425
+ "class AppConfig:\n"
426
+ " timeout_ms: int = DEFAULT_TIMING.base_timeout\n"
427
+ " poll_interval: int = DEFAULT_TIMING.poll_interval\n"
428
+ )
429
+ workflow_directory = neutral_root / "workflow"
430
+ config_package = workflow_directory / "os_update_workflow"
431
+ config_package.mkdir(parents=True)
432
+ (config_package / "__init__.py").write_text("", encoding="utf-8")
433
+ config_path = config_package / "config.py"
434
+ config_path.write_text(config_body, encoding="utf-8")
435
+ consumer_body = (
436
+ "from os_update_workflow.config import AppConfig\n"
437
+ "\n"
438
+ "def run(app_config: AppConfig) -> int:\n"
439
+ " return app_config.timeout_ms + app_config.poll_interval\n"
440
+ )
441
+ (workflow_directory / "runner.py").write_text(consumer_body, encoding="utf-8")
442
+ issues = _check(config_body, str(config_path))
443
+ assert not any("'base_timeout'" in each_issue for each_issue in issues), (
444
+ f"A default sourcing a differently-named field must keep it live, got: {issues}"
445
+ )
446
+ assert not any("'poll_interval'" in each_issue for each_issue in issues), (
447
+ f"poll_interval is read both in the default and the consumer, got: {issues}"
448
+ )
449
+
450
+
451
+ def test_self_referential_default_still_excludes_only_the_self_read(
452
+ neutral_root: Path,
453
+ ) -> None:
454
+ """The self-referential default read is still excluded after narrowing.
455
+
456
+ ``sound_upload_timeout_ms: int = submission_timing.sound_upload_timeout_ms``
457
+ reads an attribute whose name equals the field being defined, so it is still
458
+ excluded; when that field is read by no module it stays flagged dead.
459
+ """
460
+ config_body = (
461
+ "from dataclasses import dataclass\n"
462
+ "import submission_timing_module as submission_timing\n"
463
+ "\n"
464
+ "@dataclass(frozen=True)\n"
465
+ "class AppInfoConfig:\n"
466
+ " sound_upload_timeout_ms: int = submission_timing.sound_upload_timeout_ms\n"
467
+ )
468
+ workflow_directory = neutral_root / "workflow"
469
+ config_package = workflow_directory / "os_update_workflow"
470
+ config_package.mkdir(parents=True)
471
+ (config_package / "__init__.py").write_text("", encoding="utf-8")
472
+ config_path = config_package / "config.py"
473
+ config_path.write_text(config_body, encoding="utf-8")
474
+ (workflow_directory / "runner.py").write_text(
475
+ "def noop() -> None:\n pass\n", encoding="utf-8"
476
+ )
477
+ issues = _check(config_body, str(config_path))
478
+ assert any("'sound_upload_timeout_ms'" in each_issue for each_issue in issues), (
479
+ f"Self-referential default read is excluded, so the field stays flagged, got: {issues}"
239
480
  )
240
481
 
241
482
 
@@ -416,6 +657,98 @@ def test_dataclasses_qualified_replace_call_suppresses_so_no_field_flagged(
416
657
  )
417
658
 
418
659
 
660
+ def test_aliased_replace_read_keeps_field_live_despite_constructor_keyword(
661
+ neutral_root: Path,
662
+ ) -> None:
663
+ """An aliased ``replace`` keyword read survives a same-module constructor keyword.
664
+
665
+ A module constructs ``ThemeUpdateConfig(debug_port=1)`` (a write) and also reads
666
+ the field through an aliased ``dataclasses.replace`` —
667
+ ``from dataclasses import replace as rep; rep(cfg, debug_port=2)``. The alias is
668
+ not the bare ``replace`` name, so the whole-instance reflective suppression does
669
+ not fire and the ``rep`` keyword stays a genuine field read. The constructor
670
+ keyword exclusion is scoped per-call, so the constructor's ``debug_port`` keyword
671
+ does not strip the ``rep`` keyword read and the field stays live.
672
+ """
673
+ consumer_body = (
674
+ "from dataclasses import replace as rep\n"
675
+ "from os_update_workflow.config import ThemeUpdateConfig\n"
676
+ "\n"
677
+ "def build_then_repoint() -> ThemeUpdateConfig:\n"
678
+ " configuration = ThemeUpdateConfig(portal_url='x', debug_port=1, timeout_seconds=99)\n"
679
+ " return rep(configuration, debug_port=2)\n"
680
+ )
681
+ config_path = _build_config_package(
682
+ neutral_root / "workflow", THEME_UPDATE_CONFIG_BODY, consumer_body
683
+ )
684
+ issues = _check(THEME_UPDATE_CONFIG_BODY, str(config_path))
685
+ assert not any("'debug_port'" in each_issue for each_issue in issues), (
686
+ f"Aliased replace keyword read must keep debug_port live despite a"
687
+ f" same-module constructor keyword, got: {issues}"
688
+ )
689
+
690
+
691
+ def test_factory_function_ending_in_config_keeps_field_live(
692
+ neutral_root: Path,
693
+ ) -> None:
694
+ """A keyword to a factory FUNCTION ending in ``Config`` is a read, not a write.
695
+
696
+ ``getThemeConfig(debug_port=1)`` calls a factory function, not a ``*Config``
697
+ dataclass constructor. The keyword passes a value into the function, so it
698
+ counts as a field read. The constructor-keyword exclusion fires only for a
699
+ callee that names a known ``*Config`` dataclass defined under the scan root, so
700
+ a factory function whose name merely ends in ``Config`` does not strip the
701
+ field.
702
+ """
703
+ consumer_body = (
704
+ "from os_update_workflow.config import ThemeUpdateConfig\n"
705
+ "\n"
706
+ "def getThemeConfig(portal_url: str, debug_port: int) -> ThemeUpdateConfig:\n"
707
+ " return ThemeUpdateConfig(\n"
708
+ " portal_url=portal_url, debug_port=debug_port, timeout_seconds=30\n"
709
+ " )\n"
710
+ "\n"
711
+ "def run() -> ThemeUpdateConfig:\n"
712
+ " print('x')\n"
713
+ " return getThemeConfig(portal_url='x', debug_port=1)\n"
714
+ )
715
+ config_path = _build_config_package(
716
+ neutral_root / "workflow", THEME_UPDATE_CONFIG_BODY, consumer_body
717
+ )
718
+ issues = _check(THEME_UPDATE_CONFIG_BODY, str(config_path))
719
+ assert not any("'debug_port'" in each_issue for each_issue in issues), (
720
+ f"A keyword passed to a factory function ending in Config must keep the"
721
+ f" field live, got: {issues}"
722
+ )
723
+
724
+
725
+ def test_dead_config_field_module_has_no_collection_parameter_naming_violation() -> None:
726
+ """The hook module's own source must pass the collection-parameter naming check.
727
+
728
+ The cross-module dead-config-field check passes a ``set[str]`` of config class
729
+ names through several helpers; every such collection parameter must carry the
730
+ ``all_`` prefix (CODE_RULES §5). Run the real collection-prefix check over this
731
+ module's on-disk source so a regression that drops the prefix fails here.
732
+ """
733
+ collection_naming_path = (
734
+ Path(__file__).resolve().parent / "code_rules_naming_collection.py"
735
+ )
736
+ naming_specification = importlib.util.spec_from_file_location(
737
+ "code_rules_naming_collection", collection_naming_path
738
+ )
739
+ assert naming_specification is not None and naming_specification.loader is not None
740
+ code_rules_naming_collection = importlib.util.module_from_spec(naming_specification)
741
+ naming_specification.loader.exec_module(code_rules_naming_collection)
742
+ module_path = Path(__file__).resolve().parent / "code_rules_dead_config_field.py"
743
+ module_source = module_path.read_text(encoding="utf-8")
744
+ issues = code_rules_naming_collection.check_collection_prefix(
745
+ module_source, str(module_path)
746
+ )
747
+ assert not any("Collection parameter" in each_issue for each_issue in issues), (
748
+ f"dead-config-field module has a collection-parameter naming violation: {issues}"
749
+ )
750
+
751
+
419
752
  def test_validate_content_dispatch_runs_dead_config_field_check(neutral_root: Path) -> None:
420
753
  workflow_directory = neutral_root / "workflow"
421
754
  config_package = workflow_directory / "os_update_workflow"
@@ -56,6 +56,12 @@ def _check(source: str, file_path: str) -> list[str]:
56
56
  return code_rules_enforcer.check_dead_module_constants(source, file_path)
57
57
 
58
58
 
59
+ def _check_edit(fragment: str, full_file_content: str, file_path: str) -> list[str]:
60
+ return code_rules_enforcer.check_dead_module_constants(
61
+ fragment, file_path, full_file_content
62
+ )
63
+
64
+
59
65
  def _build_constants_package(
60
66
  workflow_directory: Path,
61
67
  constants_body: str,
@@ -94,6 +100,36 @@ def test_flags_constant_imported_by_no_module_in_the_tree(neutral_root: Path) ->
94
100
  ), f"Imported constants must not be flagged, got: {issues}"
95
101
 
96
102
 
103
+ def test_flags_a_dead_constant_added_by_an_edit_to_an_existing_module(
104
+ neutral_root: Path,
105
+ ) -> None:
106
+ consumer_body = (
107
+ "from report_constants.render_report_constants import (\n"
108
+ " MEDIUM_CODE,\n"
109
+ " MEDIUM_TERMINAL,\n"
110
+ ")\n"
111
+ "\n"
112
+ "def panel_class(medium: str) -> str:\n"
113
+ " if medium == MEDIUM_TERMINAL:\n"
114
+ " return 'terminal'\n"
115
+ " return 'code-panel' if medium == MEDIUM_CODE else 'text-panel'\n"
116
+ )
117
+ prior_body = 'MEDIUM_TERMINAL = "terminal"\nMEDIUM_CODE = "code"\n'
118
+ constants_path = _build_constants_package(
119
+ neutral_root / "workflow", prior_body, consumer_body
120
+ )
121
+ edit_fragment = 'MEDIUM_CODE = "code"\nMEDIUM_TEXT = "text"\n'
122
+ post_edit_body = prior_body + 'MEDIUM_TEXT = "text"\n'
123
+ issues = _check_edit(edit_fragment, post_edit_body, str(constants_path))
124
+ assert any("MEDIUM_TEXT" in each_issue for each_issue in issues), (
125
+ f"An Edit that inserts a dead constant must be flagged, got: {issues}"
126
+ )
127
+ assert not any(
128
+ "MEDIUM_TERMINAL" in each_issue or "MEDIUM_CODE" in each_issue
129
+ for each_issue in issues
130
+ ), f"Imported constants must not be flagged on an edit, got: {issues}"
131
+
132
+
97
133
  def test_does_not_flag_constant_imported_one_directory_up(neutral_root: Path) -> None:
98
134
  consumer_uses_text = (
99
135
  "from report_constants.render_report_constants import (\n"