claude-dev-env 1.76.0 → 1.78.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 (31) hide show
  1. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -0
  2. package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +8 -4
  3. package/hooks/blocking/CLAUDE.md +3 -1
  4. package/hooks/blocking/code_rules_dead_module_constant.py +215 -59
  5. package/hooks/blocking/code_rules_dead_split_branch.py +225 -0
  6. package/hooks/blocking/code_rules_docstrings.py +730 -0
  7. package/hooks/blocking/code_rules_enforcer.py +39 -0
  8. package/hooks/blocking/code_rules_paired_test.py +299 -0
  9. package/hooks/blocking/code_rules_string_magic.py +71 -1
  10. package/hooks/blocking/convergence_gate_blocker.py +24 -15
  11. package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +89 -2
  12. package/hooks/blocking/test_code_rules_enforcer_dead_split_branch.py +105 -0
  13. package/hooks/blocking/test_code_rules_enforcer_docstring_args_span_scope.py +174 -0
  14. package/hooks/blocking/test_code_rules_enforcer_docstring_cardinal_family.py +176 -0
  15. package/hooks/blocking/test_code_rules_enforcer_docstring_mark_glyph_enumeration.py +262 -0
  16. package/hooks/blocking/test_code_rules_enforcer_docstring_raises_largezipfile.py +226 -0
  17. package/hooks/blocking/test_code_rules_enforcer_docstring_runon_sentence.py +267 -0
  18. package/hooks/blocking/test_code_rules_enforcer_paired_test.py +149 -0
  19. package/hooks/blocking/test_code_rules_enforcer_whitespace_indentation_magic.py +74 -0
  20. package/hooks/blocking/test_convergence_gate_blocker.py +71 -0
  21. package/hooks/hooks_constants/CLAUDE.md +1 -0
  22. package/hooks/hooks_constants/blocking_check_limits.py +51 -0
  23. package/hooks/hooks_constants/code_rules_enforcer_constants.py +49 -0
  24. package/hooks/hooks_constants/paired_test_coverage_constants.py +27 -0
  25. package/package.json +1 -1
  26. package/rules/CLAUDE.md +2 -0
  27. package/rules/docstring-prose-matches-implementation.md +5 -1
  28. package/rules/file-global-constants.md +2 -2
  29. package/rules/package-inventory-stale-entry.md +8 -0
  30. package/rules/paired-test-coverage.md +28 -0
  31. package/rules/plain-illustrative-docstrings.md +56 -0
@@ -64,15 +64,23 @@ from code_rules_dead_dataclass_field import ( # noqa: E402
64
64
  from code_rules_dead_module_constant import ( # noqa: E402
65
65
  check_dead_module_constants,
66
66
  )
67
+ from code_rules_dead_split_branch import ( # noqa: E402
68
+ check_dead_split_truthiness_branch,
69
+ )
67
70
  from code_rules_docstrings import ( # noqa: E402
68
71
  check_class_docstring_names_public_methods,
69
72
  check_docstring_args_match_signature,
73
+ check_docstring_args_single_line_scope_vs_span,
74
+ check_docstring_cardinal_count_matches_constant_family,
70
75
  check_docstring_fallback_branch_coverage,
71
76
  check_docstring_format,
72
77
  check_docstring_names_undefined_constant,
73
78
  check_docstring_no_consumer_claim,
74
79
  check_docstring_no_inline_literal_claim,
80
+ check_docstring_punctuation_mark_enumeration_coverage,
81
+ check_docstring_raises_unraisable_largezipfile,
75
82
  check_docstring_returns_plural_cardinality,
83
+ check_docstring_runon_sentence,
76
84
  check_docstring_step_enumeration_dispatch_coverage,
77
85
  check_docstring_tuple_enumeration_match,
78
86
  check_docstring_unguarded_malformed_payload_claim,
@@ -113,6 +121,9 @@ from code_rules_optional_params import ( # noqa: E402
113
121
  from code_rules_orphan_css_class import ( # noqa: E402
114
122
  check_orphan_css_classes,
115
123
  )
124
+ from code_rules_paired_test import ( # noqa: E402
125
+ check_public_function_missing_paired_test,
126
+ )
116
127
  from code_rules_paths_syspath import ( # noqa: E402
117
128
  check_hardcoded_user_paths,
118
129
  check_sys_path_insert_deduplication_guard,
@@ -128,6 +139,7 @@ from code_rules_string_magic import ( # noqa: E402
128
139
  check_inline_literal_collections,
129
140
  check_inline_tuple_string_magic,
130
141
  check_string_literal_magic,
142
+ check_whitespace_indentation_magic,
131
143
  )
132
144
  from code_rules_test_assertions import ( # noqa: E402
133
145
  check_constant_equality_tests,
@@ -295,12 +307,18 @@ def validate_content(
295
307
  all_issues.extend(
296
308
  check_class_docstring_names_public_methods(effective_content, file_path)
297
309
  )
310
+ all_issues.extend(check_docstring_runon_sentence(effective_content, file_path))
298
311
  all_issues.extend(
299
312
  check_module_docstring_names_public_checks(effective_content, file_path)
300
313
  )
301
314
  all_issues.extend(
302
315
  check_docstring_tuple_enumeration_match(effective_content, file_path)
303
316
  )
317
+ all_issues.extend(
318
+ check_docstring_punctuation_mark_enumeration_coverage(
319
+ effective_content, file_path
320
+ )
321
+ )
304
322
  all_issues.extend(
305
323
  check_docstring_step_enumeration_dispatch_coverage(
306
324
  effective_content, file_path
@@ -309,9 +327,20 @@ def validate_content(
309
327
  all_issues.extend(
310
328
  check_docstring_returns_plural_cardinality(effective_content, file_path)
311
329
  )
330
+ all_issues.extend(
331
+ check_docstring_raises_unraisable_largezipfile(effective_content, file_path)
332
+ )
333
+ all_issues.extend(
334
+ check_docstring_cardinal_count_matches_constant_family(
335
+ effective_content, file_path
336
+ )
337
+ )
312
338
  all_issues.extend(
313
339
  check_docstring_names_undefined_constant(effective_content, file_path)
314
340
  )
341
+ all_issues.extend(
342
+ check_docstring_args_single_line_scope_vs_span(effective_content, file_path)
343
+ )
315
344
  all_issues.extend(
316
345
  check_boolean_naming(
317
346
  effective_content,
@@ -361,6 +390,7 @@ def validate_content(
361
390
  all_issues.extend(
362
391
  check_dead_module_constants(content, file_path, full_file_content)
363
392
  )
393
+ all_issues.extend(check_dead_split_truthiness_branch(content, file_path))
364
394
  all_issues.extend(check_library_print(content, file_path))
365
395
  all_issues.extend(check_parameter_annotations(content, file_path))
366
396
  all_issues.extend(check_known_pytest_fixture_annotations(content, file_path))
@@ -376,10 +406,19 @@ def validate_content(
376
406
  defer_scope_to_caller,
377
407
  )
378
408
  )
409
+ all_issues.extend(
410
+ check_public_function_missing_paired_test(
411
+ effective_content,
412
+ file_path,
413
+ all_changed_lines,
414
+ defer_scope_to_caller,
415
+ )
416
+ )
379
417
  all_issues.extend(check_loop_variable_naming(content, file_path))
380
418
  all_issues.extend(check_inline_literal_collections(content, file_path))
381
419
  all_issues.extend(check_inline_tuple_string_magic(content, file_path))
382
420
  all_issues.extend(check_string_literal_magic(content, file_path))
421
+ all_issues.extend(check_whitespace_indentation_magic(content, file_path))
383
422
  all_issues.extend(check_orphan_css_classes(effective_content, file_path))
384
423
  check_incomplete_mocks(content, file_path)
385
424
  check_duplicated_format_patterns(content, file_path)
@@ -0,0 +1,299 @@
1
+ """Public-function paired-test coverage check for ``code_rules_enforcer``.
2
+
3
+ The TDD gate ``tdd_enforcer.py`` requires a fresh test file to exist before a
4
+ production module is written, but it judges coverage at file granularity: a
5
+ module whose dedicated test file exercises some public functions while leaving
6
+ one untested still satisfies it. This check closes that gap. When a production
7
+ module has an established paired test suite — a stem-matched ``test_<stem>.py``
8
+ that already exercises at least one of the module's public functions — a public
9
+ function that suite references nowhere is flagged, so the forgotten function
10
+ gets a behavioral test before the partially-covered module lands.
11
+
12
+ The check stays conservative to keep false positives near zero:
13
+
14
+ - It runs only on a production module whose stem-matched test file already
15
+ exists; a module with no dedicated test file is out of scope and left to the
16
+ file-level TDD gate.
17
+ - It fires only when the suite already covers at least one public function of
18
+ the same module — the signature of a maintained per-function suite rather than
19
+ an unrelated or placeholder test file.
20
+ - A public function counts as covered when its name appears in any test file
21
+ beside the stem-matched one — imported, called, or named — so a function
22
+ exercised by a differently-named sibling test still counts.
23
+ - ``main`` and underscore-prefixed functions are never required to carry a test.
24
+ - Test modules, hook infrastructure, config modules, migrations, workflow
25
+ registries, and ``__init__`` modules are exempt.
26
+ """
27
+
28
+ import ast
29
+ import sys
30
+ from pathlib import Path
31
+
32
+ _blocking_directory = str(Path(__file__).resolve().parent)
33
+ _hooks_directory = str(Path(__file__).resolve().parent.parent)
34
+ if _blocking_directory not in sys.path:
35
+ sys.path.insert(0, _blocking_directory)
36
+ if _hooks_directory not in sys.path:
37
+ sys.path.insert(0, _hooks_directory)
38
+
39
+ from code_rules_path_utils import ( # noqa: E402
40
+ is_config_file,
41
+ )
42
+ from code_rules_shared import ( # noqa: E402
43
+ _scope_violations_to_changed_lines,
44
+ is_hook_infrastructure,
45
+ is_migration_file,
46
+ is_test_file,
47
+ is_workflow_registry_file,
48
+ )
49
+
50
+ from hooks_constants.paired_test_coverage_constants import ( # noqa: E402
51
+ ALL_TEST_FILENAME_GLOBS,
52
+ ANCESTOR_DIRECTORY_WALK_LIMIT,
53
+ EXEMPT_PUBLIC_FUNCTION_NAMES,
54
+ INIT_MODULE_FILENAME,
55
+ MAX_PAIRED_TEST_COVERAGE_ISSUES,
56
+ MAX_TEST_FILES_SCANNED,
57
+ MINIMUM_COVERED_PUBLIC_FUNCTIONS,
58
+ MISSING_PAIRED_TEST_GUIDANCE,
59
+ PYTHON_SOURCE_SUFFIX,
60
+ STEM_TEST_FILENAME_PREFIX,
61
+ STEM_TEST_FILENAME_SUFFIX,
62
+ TESTS_DIRECTORY_NAME,
63
+ )
64
+
65
+
66
+ def _is_public_function_name(function_name: str) -> bool:
67
+ """Return whether a function name is a public, test-worthy entry point.
68
+
69
+ Args:
70
+ function_name: The bare function name from a module-scope definition.
71
+
72
+ Returns:
73
+ True when the name does not start with an underscore and is not one of
74
+ the conventionally untested entry points such as ``main``.
75
+ """
76
+ if function_name.startswith("_"):
77
+ return False
78
+ return function_name not in EXEMPT_PUBLIC_FUNCTION_NAMES
79
+
80
+
81
+ def _public_function_definitions(tree: ast.Module) -> list[tuple[str, int]]:
82
+ """Return ``(name, line)`` for each module-scope public function definition.
83
+
84
+ Args:
85
+ tree: The parsed production module.
86
+
87
+ Returns:
88
+ One ``(name, definition_line)`` pair per module-scope public function,
89
+ in source order.
90
+ """
91
+ all_definitions: list[tuple[str, int]] = []
92
+ for each_statement in tree.body:
93
+ if not isinstance(each_statement, (ast.FunctionDef, ast.AsyncFunctionDef)):
94
+ continue
95
+ if _is_public_function_name(each_statement.name):
96
+ all_definitions.append((each_statement.name, each_statement.lineno))
97
+ return all_definitions
98
+
99
+
100
+ def _ancestor_tests_directories(start_directory: Path) -> list[Path]:
101
+ """Return each ancestor's ``tests`` directory, nearest ancestor first.
102
+
103
+ Args:
104
+ start_directory: The directory holding the production module.
105
+
106
+ Returns:
107
+ Existing ``tests`` directories found by walking from start_directory
108
+ toward the filesystem root, bounded by the ancestor walk limit.
109
+ """
110
+ all_candidate_directories = [start_directory, *start_directory.parents]
111
+ all_tests_directories: list[Path] = []
112
+ for each_directory in all_candidate_directories[:ANCESTOR_DIRECTORY_WALK_LIMIT]:
113
+ candidate_tests_directory = each_directory / TESTS_DIRECTORY_NAME
114
+ if candidate_tests_directory.is_dir():
115
+ all_tests_directories.append(candidate_tests_directory)
116
+ return all_tests_directories
117
+
118
+
119
+ def _stem_matched_test_path(module_path: Path) -> Path | None:
120
+ """Return the first existing stem-matched test file for a module, or None.
121
+
122
+ Args:
123
+ module_path: The resolved path of the production module.
124
+
125
+ Returns:
126
+ The path of the first existing ``test_<stem>.py`` or ``<stem>_test.py``
127
+ file — beside the module or under an ancestor ``tests`` directory — or
128
+ None when the module has no dedicated test file.
129
+ """
130
+ module_directory = module_path.parent
131
+ module_stem = module_path.stem
132
+ flat_prefixed_name = STEM_TEST_FILENAME_PREFIX + module_stem + PYTHON_SOURCE_SUFFIX
133
+ flat_suffixed_name = module_stem + STEM_TEST_FILENAME_SUFFIX
134
+ all_candidate_paths = [
135
+ module_directory / flat_prefixed_name,
136
+ module_directory / flat_suffixed_name,
137
+ ]
138
+ for each_tests_directory in _ancestor_tests_directories(module_directory):
139
+ all_candidate_paths.append(each_tests_directory / flat_prefixed_name)
140
+ for each_candidate_path in all_candidate_paths:
141
+ if each_candidate_path.is_file():
142
+ return each_candidate_path
143
+ return None
144
+
145
+
146
+ def _collect_referenced_identifiers(source: str) -> set[str]:
147
+ """Return every identifier a test module references — imported, called, or named.
148
+
149
+ Collects ``import`` binding names and ``from``-import member names, every
150
+ ``Name`` node id, and every attribute access name, so a public function the
151
+ test imports, calls bare, or reaches through an attribute all count as a
152
+ reference. A module that fails to parse contributes no identifiers.
153
+
154
+ Args:
155
+ source: The full text of a test module.
156
+
157
+ Returns:
158
+ The set of identifiers the test module references.
159
+ """
160
+ try:
161
+ tree = ast.parse(source)
162
+ except SyntaxError:
163
+ return set()
164
+ all_referenced_identifiers: set[str] = set()
165
+ for each_node in ast.walk(tree):
166
+ if isinstance(each_node, ast.Name):
167
+ all_referenced_identifiers.add(each_node.id)
168
+ elif isinstance(each_node, ast.Attribute):
169
+ all_referenced_identifiers.add(each_node.attr)
170
+ elif isinstance(each_node, (ast.Import, ast.ImportFrom)):
171
+ for each_alias in each_node.names:
172
+ all_referenced_identifiers.add(each_alias.asname or each_alias.name)
173
+ all_referenced_identifiers.add(each_alias.name)
174
+ return all_referenced_identifiers
175
+
176
+
177
+ def _suite_referenced_identifiers(stem_test_path: Path) -> set[str]:
178
+ """Return identifiers referenced across every test file beside the stem test.
179
+
180
+ Scans every ``test_*.py`` and ``*_test.py`` file in the directory holding the
181
+ stem-matched test file, bounded by the scan cap, and unions the identifiers
182
+ each one references.
183
+
184
+ Args:
185
+ stem_test_path: The stem-matched test file whose directory is scanned.
186
+
187
+ Returns:
188
+ The union of referenced identifiers across the scanned test files.
189
+ """
190
+ suite_directory = stem_test_path.parent
191
+ all_test_file_paths: list[Path] = []
192
+ for each_glob in ALL_TEST_FILENAME_GLOBS:
193
+ all_test_file_paths.extend(sorted(suite_directory.glob(each_glob)))
194
+ all_referenced_identifiers: set[str] = set()
195
+ for each_test_file_path in all_test_file_paths[:MAX_TEST_FILES_SCANNED]:
196
+ try:
197
+ test_source = each_test_file_path.read_text(encoding="utf-8")
198
+ except (OSError, UnicodeDecodeError):
199
+ continue
200
+ all_referenced_identifiers |= _collect_referenced_identifiers(test_source)
201
+ return all_referenced_identifiers
202
+
203
+
204
+ def _module_is_exempt(file_path: str) -> bool:
205
+ """Return whether a path is exempt from the paired-test coverage check.
206
+
207
+ Test modules, hook infrastructure, config modules, migrations, workflow
208
+ registries, and package ``__init__`` re-export modules are all exempt.
209
+
210
+ Args:
211
+ file_path: The destination path of the write or edit.
212
+
213
+ Returns:
214
+ True when the paired-test coverage check must not run on this path.
215
+ """
216
+ if is_test_file(file_path):
217
+ return True
218
+ if is_hook_infrastructure(file_path):
219
+ return True
220
+ if is_config_file(file_path):
221
+ return True
222
+ if is_migration_file(file_path):
223
+ return True
224
+ if is_workflow_registry_file(file_path):
225
+ return True
226
+ return Path(file_path).name == INIT_MODULE_FILENAME
227
+
228
+
229
+ def check_public_function_missing_paired_test(
230
+ content: str,
231
+ file_path: str,
232
+ all_changed_lines: set[int] | None = None,
233
+ defer_scope_to_caller: bool = False,
234
+ ) -> list[str]:
235
+ """Flag a public function the module's established paired test suite omits.
236
+
237
+ Runs only on a production module whose stem-matched ``test_<stem>.py`` already
238
+ exists and already exercises at least one of the module's public functions.
239
+ Under that established-suite precondition, a public function the suite
240
+ references nowhere is flagged, so the forgotten function gets a behavioral
241
+ test before the partially-covered module lands. ``main`` and
242
+ underscore-prefixed functions are never required to carry a test, and test
243
+ modules, hook infrastructure, config modules, migrations, workflow
244
+ registries, and ``__init__`` modules are exempt.
245
+
246
+ Args:
247
+ content: The reconstructed post-edit whole-file content of the module.
248
+ file_path: The destination path, used for the exemptions and to locate
249
+ the module's paired test files on disk.
250
+ all_changed_lines: Post-edit line numbers the current edit touched, or
251
+ None to treat every public-function definition as in scope.
252
+ defer_scope_to_caller: When True, return every violation so the
253
+ commit/push gate scopes by added line.
254
+
255
+ Returns:
256
+ One violation per uncovered public function, capped at the configured
257
+ maximum, scoped to the changed lines unless deferred or unscoped. Empty
258
+ when the module is exempt, has no dedicated test file, the suite covers
259
+ no public function, or the content fails to parse.
260
+ """
261
+ if _module_is_exempt(file_path):
262
+ return []
263
+ try:
264
+ tree = ast.parse(content)
265
+ except SyntaxError:
266
+ return []
267
+ all_public_definitions = _public_function_definitions(tree)
268
+ if not all_public_definitions:
269
+ return []
270
+ stem_test_path = _stem_matched_test_path(Path(file_path).resolve())
271
+ if stem_test_path is None:
272
+ return []
273
+ all_referenced_identifiers = _suite_referenced_identifiers(stem_test_path)
274
+ covered_function_count = sum(
275
+ 1
276
+ for each_name, _each_line in all_public_definitions
277
+ if each_name in all_referenced_identifiers
278
+ )
279
+ if covered_function_count < MINIMUM_COVERED_PUBLIC_FUNCTIONS:
280
+ return []
281
+ all_violations_in_walk_order: list[tuple[range, str]] = []
282
+ for each_name, each_definition_line in all_public_definitions:
283
+ if each_name in all_referenced_identifiers:
284
+ continue
285
+ message = (
286
+ f"Line {each_definition_line}: public function {each_name!r} "
287
+ f"{MISSING_PAIRED_TEST_GUIDANCE}"
288
+ )
289
+ all_violations_in_walk_order.append(
290
+ (range(each_definition_line, each_definition_line + 1), message)
291
+ )
292
+ if len(all_violations_in_walk_order) >= MAX_PAIRED_TEST_COVERAGE_ISSUES:
293
+ break
294
+ return _scope_violations_to_changed_lines(
295
+ all_violations_in_walk_order,
296
+ all_changed_lines,
297
+ defer_scope_to_caller,
298
+ )
299
+
@@ -1,4 +1,4 @@
1
- """Bare string-literal magic, inline literal-collection, and inline tuple string-magic checks."""
1
+ """Bare string-literal magic, inline literal-collection, inline tuple string-magic, and whitespace-indentation magic checks."""
2
2
 
3
3
  import ast
4
4
  import re
@@ -25,7 +25,11 @@ from code_rules_shared import ( # noqa: E402
25
25
  from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
26
26
  ALL_CAPS_WITH_UNDERSCORE_PATTERN,
27
27
  DOTTED_SEGMENT_PATTERN,
28
+ INDENTATION_MAGIC_MINIMUM_SPACE_RUN,
29
+ INDENTATION_MAGIC_MINIMUM_TAB_RUN,
28
30
  INLINE_COLLECTION_MIN_LENGTH,
31
+ MAX_WHITESPACE_INDENTATION_MAGIC_ISSUES,
32
+ WHITESPACE_INDENTATION_MAGIC_MESSAGE_SUFFIX,
29
33
  )
30
34
  from hooks_constants.inline_tuple_string_magic_constants import ( # noqa: E402
31
35
  ALL_SNAKE_CASE_KEYWORD_EXEMPTIONS,
@@ -205,3 +209,69 @@ def check_inline_tuple_string_magic(content: str, file_path: str) -> list[str]:
205
209
  if len(issues) >= MAX_INLINE_TUPLE_STRING_MAGIC_ISSUES:
206
210
  return issues
207
211
  return issues
212
+
213
+
214
+ def _is_whitespace_indentation_literal(string_value: str) -> bool:
215
+ if not string_value:
216
+ return False
217
+ if string_value.strip():
218
+ return False
219
+ if "\t" * INDENTATION_MAGIC_MINIMUM_TAB_RUN in string_value:
220
+ return True
221
+ return " " * INDENTATION_MAGIC_MINIMUM_SPACE_RUN in string_value
222
+
223
+
224
+ def check_whitespace_indentation_magic(content: str, file_path: str) -> list[str]:
225
+ """Flag a whitespace-only indentation literal in a function body.
226
+
227
+ A string literal that is entirely whitespace and carries a run of at least
228
+ ``INDENTATION_MAGIC_MINIMUM_SPACE_RUN`` spaces, or a run of at least
229
+ ``INDENTATION_MAGIC_MINIMUM_TAB_RUN`` tabs, is a formatting default — the
230
+ indentation an output builder prepends — so it belongs in a
231
+ named constant in ``config/`` that one definition feeds to every call site.
232
+ Both a standalone string constant (``" "``) and the literal
233
+ fragment of an f-string (``f"\\n {value}"``) are inspected, so an
234
+ indent embedded beside an interpolation is caught too. Config files, test
235
+ files, workflow-registry files, and migration files are exempt.
236
+
237
+ Args:
238
+ content: The source text to inspect.
239
+ file_path: The path the source will be written to, used for exemptions.
240
+
241
+ Returns:
242
+ One issue per whitespace-only indentation literal found in a function
243
+ body, capped at the module limit.
244
+ """
245
+ if is_test_file(file_path):
246
+ return []
247
+ if is_config_file(file_path):
248
+ return []
249
+ if is_workflow_registry_file(file_path) or is_migration_file(file_path):
250
+ return []
251
+ try:
252
+ tree = ast.parse(content)
253
+ except SyntaxError:
254
+ return []
255
+ issues: list[str] = []
256
+ flagged_node_ids: set[int] = set()
257
+ for each_function_node in ast.walk(tree):
258
+ if not isinstance(each_function_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
259
+ continue
260
+ for each_body_statement in each_function_node.body:
261
+ for each_descendant in _walk_skipping_nested_function_defs(each_body_statement):
262
+ if not isinstance(each_descendant, ast.Constant):
263
+ continue
264
+ if not isinstance(each_descendant.value, str):
265
+ continue
266
+ if id(each_descendant) in flagged_node_ids:
267
+ continue
268
+ if not _is_whitespace_indentation_literal(each_descendant.value):
269
+ continue
270
+ flagged_node_ids.add(id(each_descendant))
271
+ issues.append(
272
+ f"Line {each_descendant.lineno}: {each_descendant.value!r} "
273
+ f"{WHITESPACE_INDENTATION_MAGIC_MESSAGE_SUFFIX}"
274
+ )
275
+ if len(issues) >= MAX_WHITESPACE_INDENTATION_MAGIC_ISSUES:
276
+ return issues
277
+ return issues
@@ -68,6 +68,27 @@ def _resolve_owner_repo(cwd: str | None) -> tuple[str, str] | None:
68
68
  return parts[0], parts[1]
69
69
 
70
70
 
71
+ def _run_convergence_check(
72
+ script: str, owner: str, repo: str, pr_number: int, cwd: str | None
73
+ ) -> subprocess.CompletedProcess[str]:
74
+ return subprocess.run(
75
+ [
76
+ sys.executable,
77
+ script,
78
+ "--owner",
79
+ owner,
80
+ "--repo",
81
+ repo,
82
+ "--pr-number",
83
+ str(pr_number),
84
+ ],
85
+ capture_output=True,
86
+ text=True,
87
+ cwd=cwd or None,
88
+ check=False,
89
+ )
90
+
91
+
71
92
  def main() -> None:
72
93
  check_convergence_script = str(
73
94
  Path.home() / ".claude/skills/pr-converge/scripts/check_convergence.py"
@@ -90,7 +111,7 @@ def main() -> None:
90
111
  if not gh_pr_ready_pattern.search(command):
91
112
  sys.exit(0)
92
113
 
93
- cwd = hook_input.get("tool_input", {}).get("cwd")
114
+ cwd = hook_input.get("cwd")
94
115
  pr_number = _resolve_pr_number(command, cwd)
95
116
  if pr_number is None:
96
117
  sys.exit(0)
@@ -100,20 +121,8 @@ def main() -> None:
100
121
  sys.exit(0)
101
122
  owner, repo = owner_repo
102
123
 
103
- completed_process = subprocess.run(
104
- [
105
- sys.executable,
106
- check_convergence_script,
107
- "--owner",
108
- owner,
109
- "--repo",
110
- repo,
111
- "--pr-number",
112
- str(pr_number),
113
- ],
114
- capture_output=True,
115
- text=True,
116
- check=False,
124
+ completed_process = _run_convergence_check(
125
+ check_convergence_script, owner, repo, pr_number, cwd
117
126
  )
118
127
 
119
128
  if completed_process.returncode in (0, 2):
@@ -152,7 +152,7 @@ def test_does_not_flag_constant_imported_one_directory_up(neutral_root: Path) ->
152
152
  assert issues == [], f"No constant is dead when all are imported, got: {issues}"
153
153
 
154
154
 
155
- def test_does_not_flag_when_module_declares_dunder_all(neutral_root: Path) -> None:
155
+ def test_does_not_flag_dunder_all_member_with_a_live_consumer(neutral_root: Path) -> None:
156
156
  constants_body = CONSTANTS_BODY + '__all__ = ["MEDIUM_TERMINAL"]\n'
157
157
  consumer_body = (
158
158
  "from report_constants.render_report_constants import MEDIUM_TERMINAL\n"
@@ -164,7 +164,35 @@ def test_does_not_flag_when_module_declares_dunder_all(neutral_root: Path) -> No
164
164
  neutral_root / "workflow", constants_body, consumer_body
165
165
  )
166
166
  issues = _check(constants_body, str(constants_path))
167
- assert issues == [], f"__all__ surface suppresses the check, got: {issues}"
167
+ assert issues == [], f"An __all__ member a consumer imports stays live, got: {issues}"
168
+
169
+
170
+ def test_dunder_all_narrows_check_to_exported_constants(neutral_root: Path) -> None:
171
+ constants_body = (
172
+ 'JSONL_APPEND_OPEN_MODE = "a"\n'
173
+ 'ZIPFILE_READ_OPEN_MODE = "r"\n'
174
+ "PRIVATE_BUFFER_BYTES = 1024\n"
175
+ '__all__ = ["JSONL_APPEND_OPEN_MODE", "ZIPFILE_READ_OPEN_MODE"]\n'
176
+ )
177
+ consumer_body = (
178
+ "from report_constants.render_report_constants import JSONL_APPEND_OPEN_MODE\n"
179
+ "\n"
180
+ "def open_mode() -> str:\n"
181
+ " return JSONL_APPEND_OPEN_MODE\n"
182
+ )
183
+ constants_path = _build_constants_package(
184
+ neutral_root / "workflow", constants_body, consumer_body
185
+ )
186
+ issues = _check(constants_body, str(constants_path))
187
+ assert any("ZIPFILE_READ_OPEN_MODE" in each_issue for each_issue in issues), (
188
+ f"An exported constant no module imports is dead, got: {issues}"
189
+ )
190
+ assert not any("JSONL_APPEND_OPEN_MODE" in each_issue for each_issue in issues), (
191
+ f"An exported constant a consumer imports must not be flagged, got: {issues}"
192
+ )
193
+ assert not any("PRIVATE_BUFFER_BYTES" in each_issue for each_issue in issues), (
194
+ f"A constant __all__ omits is the author's private value and is exempt, got: {issues}"
195
+ )
168
196
 
169
197
 
170
198
  def test_does_not_run_on_ordinary_production_module(neutral_root: Path) -> None:
@@ -264,6 +292,65 @@ def test_does_not_flag_constant_used_only_in_a_sibling_tree(neutral_root: Path)
264
292
  )
265
293
 
266
294
 
295
+ def _build_name_collision_repository(
296
+ repository_root: Path,
297
+ promoter_timing_body: str,
298
+ promoter_consumer_body: str,
299
+ harness_constants_body: str,
300
+ harness_consumer_body: str,
301
+ ) -> Path:
302
+ promoter_config = repository_root / "promoter" / "config"
303
+ promoter_config.mkdir(parents=True)
304
+ timing_path = promoter_config / "timing.py"
305
+ timing_path.write_text(promoter_timing_body, encoding="utf-8")
306
+ (repository_root / "promoter" / "runtime.py").write_text(
307
+ promoter_consumer_body, encoding="utf-8"
308
+ )
309
+ harness_config = repository_root / "harness" / "config"
310
+ harness_config.mkdir(parents=True)
311
+ (harness_config / "lockfile_constants.py").write_text(
312
+ harness_constants_body, encoding="utf-8"
313
+ )
314
+ (repository_root / "harness" / "lockfile.py").write_text(
315
+ harness_consumer_body, encoding="utf-8"
316
+ )
317
+ return timing_path
318
+
319
+
320
+ def test_same_named_constant_in_an_unrelated_module_does_not_mask_a_dead_one(
321
+ neutral_root: Path,
322
+ ) -> None:
323
+ promoter_timing_body = "LOCKFILE_ACQUIRE_BYTE_LENGTH = 1\nSWEEP_INTERVAL_SECONDS = 30\n"
324
+ promoter_consumer_body = (
325
+ "from config.timing import SWEEP_INTERVAL_SECONDS\n"
326
+ "\n"
327
+ "def deadline() -> int:\n"
328
+ " return SWEEP_INTERVAL_SECONDS\n"
329
+ )
330
+ harness_constants_body = "LOCKFILE_ACQUIRE_BYTE_LENGTH = 1\n"
331
+ harness_consumer_body = (
332
+ "from harness.config.lockfile_constants import LOCKFILE_ACQUIRE_BYTE_LENGTH\n"
333
+ "\n"
334
+ "def acquire() -> int:\n"
335
+ " return LOCKFILE_ACQUIRE_BYTE_LENGTH\n"
336
+ )
337
+ timing_path = _build_name_collision_repository(
338
+ neutral_root,
339
+ promoter_timing_body,
340
+ promoter_consumer_body,
341
+ harness_constants_body,
342
+ harness_consumer_body,
343
+ )
344
+ issues = _check(promoter_timing_body, str(timing_path))
345
+ assert any("LOCKFILE_ACQUIRE_BYTE_LENGTH" in each_issue for each_issue in issues), (
346
+ "A constant whose only same-named twin lives in an unrelated module's "
347
+ f"import must still be flagged dead, got: {issues}"
348
+ )
349
+ assert not any("SWEEP_INTERVAL_SECONDS" in each_issue for each_issue in issues), (
350
+ f"A constant consumed within its own package must not be flagged, got: {issues}"
351
+ )
352
+
353
+
267
354
  def test_returns_empty_list_at_file_cap(
268
355
  neutral_root: Path, monkeypatch: pytest.MonkeyPatch
269
356
  ) -> None: