claude-dev-env 1.77.0 → 1.79.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 (46) hide show
  1. package/audit-rubrics/category_rubrics/category-k-codebase-conflicts.md +1 -0
  2. package/bin/install.mjs +1 -0
  3. package/bin/install.test.mjs +3 -2
  4. package/hooks/blocking/CLAUDE.md +5 -2
  5. package/hooks/blocking/code_rules_dead_module_constant.py +215 -59
  6. package/hooks/blocking/code_rules_dead_split_branch.py +225 -0
  7. package/hooks/blocking/code_rules_docstrings.py +951 -6
  8. package/hooks/blocking/code_rules_enforcer.py +64 -0
  9. package/hooks/blocking/code_rules_naming_collection.py +76 -1
  10. package/hooks/blocking/code_rules_paired_test.py +517 -0
  11. package/hooks/blocking/code_rules_string_magic.py +71 -1
  12. package/hooks/blocking/code_rules_test_assertions.py +159 -1
  13. package/hooks/blocking/convergence_gate_blocker.py +24 -15
  14. package/hooks/blocking/env_var_table_code_drift_blocker.py +475 -0
  15. package/hooks/blocking/package_inventory_stale_blocker.py +54 -15
  16. package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +89 -2
  17. package/hooks/blocking/test_code_rules_enforcer_dead_split_branch.py +105 -0
  18. package/hooks/blocking/test_code_rules_enforcer_docstring_field_runmode_outcome.py +129 -0
  19. package/hooks/blocking/test_code_rules_enforcer_docstring_length_constant_superlative.py +198 -0
  20. package/hooks/blocking/test_code_rules_enforcer_docstring_mark_glyph_enumeration.py +262 -0
  21. package/hooks/blocking/test_code_rules_enforcer_docstring_no_network.py +115 -0
  22. package/hooks/blocking/test_code_rules_enforcer_docstring_raises_largezipfile.py +226 -0
  23. package/hooks/blocking/test_code_rules_enforcer_docstring_unreferenced_param.py +160 -0
  24. package/hooks/blocking/test_code_rules_enforcer_module_docstring_data_schema_scope.py +82 -0
  25. package/hooks/blocking/test_code_rules_enforcer_paired_test.py +339 -0
  26. package/hooks/blocking/test_code_rules_enforcer_polarity_name_contradiction.py +76 -0
  27. package/hooks/blocking/test_code_rules_enforcer_vacuous_cleanup_assertion.py +132 -0
  28. package/hooks/blocking/test_code_rules_enforcer_whitespace_indentation_magic.py +74 -0
  29. package/hooks/blocking/test_convergence_gate_blocker.py +71 -0
  30. package/hooks/blocking/test_env_var_table_code_drift_blocker.py +94 -0
  31. package/hooks/blocking/test_package_inventory_stale_blocker.py +46 -0
  32. package/hooks/blocking/test_pre_tool_use_dispatcher.py +7 -7
  33. package/hooks/hooks_constants/CLAUDE.md +2 -0
  34. package/hooks/hooks_constants/blocking_check_limits.py +102 -0
  35. package/hooks/hooks_constants/code_rules_enforcer_constants.py +28 -0
  36. package/hooks/hooks_constants/env_var_table_code_drift_constants.py +64 -0
  37. package/hooks/hooks_constants/package_inventory_stale_blocker_constants.py +20 -9
  38. package/hooks/hooks_constants/paired_test_coverage_constants.py +35 -0
  39. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
  40. package/package.json +1 -1
  41. package/rules/CLAUDE.md +2 -0
  42. package/rules/docstring-prose-matches-implementation.md +56 -53
  43. package/rules/env-var-table-code-drift.md +24 -0
  44. package/rules/file-global-constants.md +2 -2
  45. package/rules/package-inventory-stale-entry.md +4 -4
  46. package/rules/paired-test-coverage.md +35 -0
@@ -0,0 +1,517 @@
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 from both write
7
+ orders, so a forgotten function gets a behavioral test before the
8
+ partially-covered module lands.
9
+
10
+ ``check_public_function_missing_paired_test`` fires on the production-module
11
+ write: when a module has an established paired test suite — a stem-matched
12
+ ``test_<stem>.py`` that already exercises the module, either by covering at least
13
+ one of its public functions or by referencing one of its private helpers — a
14
+ public function that suite references nowhere is flagged.
15
+
16
+ ``check_test_file_omits_module_public_function`` fires on the stem-matched
17
+ test-file write, covering the reverse order in which the production module is
18
+ written before its test file exists. It resolves the production module the
19
+ written ``test_<stem>.py`` or ``<stem>_test.py`` file pairs with and flags every
20
+ public function the post-edit suite omits, so the gap that the production-side
21
+ check cannot see — because no suite existed when the module was written — is
22
+ caught when the test file lands.
23
+
24
+ Both checks stay conservative to keep false positives near zero:
25
+
26
+ - The production-side check runs only on a production module whose stem-matched
27
+ test file already exists; a module with no dedicated test file is out of scope
28
+ and left to the file-level TDD gate. The test-side check runs only on a
29
+ stem-matched test file whose paired production module exists on disk.
30
+ - The production-side check fires only when the suite already exercises the
31
+ module — covering at least one of its public functions, or referencing one of
32
+ its private helpers by name — the signature of a maintained per-module suite
33
+ rather than an unrelated or placeholder test file. A suite that exercises only
34
+ a private helper while omitting the public surface is the exact shape the
35
+ private-helper establishment catches. The test-side check fires only when the
36
+ post-edit suite already covers at least one public function of the paired
37
+ module.
38
+ - A public function counts as covered when its name appears in any test file in
39
+ the suite directory — imported, called, or named — so a function exercised by
40
+ a differently-named sibling test still counts.
41
+ - ``main`` and underscore-prefixed functions are never required to carry a test.
42
+ - Test modules, hook infrastructure, config modules, migrations, workflow
43
+ registries, and ``__init__`` modules are exempt as production targets.
44
+ """
45
+
46
+ import ast
47
+ import os
48
+ import sys
49
+ from pathlib import Path
50
+
51
+ _blocking_directory = str(Path(__file__).resolve().parent)
52
+ _hooks_directory = str(Path(__file__).resolve().parent.parent)
53
+ if _blocking_directory not in sys.path:
54
+ sys.path.insert(0, _blocking_directory)
55
+ if _hooks_directory not in sys.path:
56
+ sys.path.insert(0, _hooks_directory)
57
+
58
+ from code_rules_path_utils import ( # noqa: E402
59
+ is_config_file,
60
+ )
61
+ from code_rules_shared import ( # noqa: E402
62
+ _scope_violations_to_changed_lines,
63
+ is_hook_infrastructure,
64
+ is_migration_file,
65
+ is_test_file,
66
+ is_workflow_registry_file,
67
+ )
68
+
69
+ from hooks_constants.paired_test_coverage_constants import ( # noqa: E402
70
+ ALL_TEST_FILENAME_GLOBS,
71
+ ANCESTOR_DIRECTORY_WALK_LIMIT,
72
+ EXEMPT_PUBLIC_FUNCTION_NAMES,
73
+ INIT_MODULE_FILENAME,
74
+ MAX_PAIRED_TEST_COVERAGE_ISSUES,
75
+ MAX_TEST_FILES_SCANNED,
76
+ MINIMUM_COVERED_PUBLIC_FUNCTIONS,
77
+ MISSING_PAIRED_TEST_GUIDANCE,
78
+ PYTHON_SOURCE_SUFFIX,
79
+ STEM_TEST_FILENAME_PREFIX,
80
+ STEM_TEST_FILENAME_SUFFIX,
81
+ TEST_SUITE_OMITS_FUNCTION_GUIDANCE,
82
+ TESTS_DIRECTORY_NAME,
83
+ )
84
+
85
+
86
+ def _is_public_function_name(function_name: str) -> bool:
87
+ """Return whether a function name is a public, test-worthy entry point.
88
+
89
+ Args:
90
+ function_name: The bare function name from a module-scope definition.
91
+
92
+ Returns:
93
+ True when the name does not start with an underscore and is not one of
94
+ the conventionally untested entry points such as ``main``.
95
+ """
96
+ if function_name.startswith("_"):
97
+ return False
98
+ return function_name not in EXEMPT_PUBLIC_FUNCTION_NAMES
99
+
100
+
101
+ def _public_function_definitions(tree: ast.Module) -> list[tuple[str, int]]:
102
+ """Return ``(name, line)`` for each module-scope public function definition.
103
+
104
+ Args:
105
+ tree: The parsed production module.
106
+
107
+ Returns:
108
+ One ``(name, definition_line)`` pair per module-scope public function,
109
+ in source order.
110
+ """
111
+ all_definitions: list[tuple[str, int]] = []
112
+ for each_statement in tree.body:
113
+ if not isinstance(each_statement, (ast.FunctionDef, ast.AsyncFunctionDef)):
114
+ continue
115
+ if _is_public_function_name(each_statement.name):
116
+ all_definitions.append((each_statement.name, each_statement.lineno))
117
+ return all_definitions
118
+
119
+
120
+ def _private_function_names(tree: ast.Module) -> set[str]:
121
+ """Return the names of module-scope underscore-prefixed function definitions.
122
+
123
+ Args:
124
+ tree: The parsed production module.
125
+
126
+ Returns:
127
+ Each module-scope function name that begins with an underscore - the
128
+ private helpers a paired test suite reaches when it exercises the
129
+ module's internals.
130
+ """
131
+ all_private_names: set[str] = set()
132
+ for each_statement in tree.body:
133
+ if not isinstance(each_statement, (ast.FunctionDef, ast.AsyncFunctionDef)):
134
+ continue
135
+ if each_statement.name.startswith("_"):
136
+ all_private_names.add(each_statement.name)
137
+ return all_private_names
138
+
139
+
140
+ def _ancestor_tests_directories(start_directory: Path) -> list[Path]:
141
+ """Return each ancestor's ``tests`` directory, nearest ancestor first.
142
+
143
+ Args:
144
+ start_directory: The directory holding the production module.
145
+
146
+ Returns:
147
+ Existing ``tests`` directories found by walking from start_directory
148
+ toward the filesystem root, bounded by the ancestor walk limit.
149
+ """
150
+ all_candidate_directories = [start_directory, *start_directory.parents]
151
+ all_tests_directories: list[Path] = []
152
+ for each_directory in all_candidate_directories[:ANCESTOR_DIRECTORY_WALK_LIMIT]:
153
+ candidate_tests_directory = each_directory / TESTS_DIRECTORY_NAME
154
+ if candidate_tests_directory.is_dir():
155
+ all_tests_directories.append(candidate_tests_directory)
156
+ return all_tests_directories
157
+
158
+
159
+ def _stem_matched_test_path(module_path: Path) -> Path | None:
160
+ """Return the first existing stem-matched test file for a module, or None.
161
+
162
+ Args:
163
+ module_path: The resolved path of the production module.
164
+
165
+ Returns:
166
+ The path of the first existing ``test_<stem>.py`` or ``<stem>_test.py``
167
+ file — beside the module or under an ancestor ``tests`` directory — or
168
+ None when the module has no dedicated test file.
169
+ """
170
+ module_directory = module_path.parent
171
+ module_stem = module_path.stem
172
+ flat_prefixed_name = STEM_TEST_FILENAME_PREFIX + module_stem + PYTHON_SOURCE_SUFFIX
173
+ flat_suffixed_name = module_stem + STEM_TEST_FILENAME_SUFFIX
174
+ all_candidate_paths = [
175
+ module_directory / flat_prefixed_name,
176
+ module_directory / flat_suffixed_name,
177
+ ]
178
+ for each_tests_directory in _ancestor_tests_directories(module_directory):
179
+ all_candidate_paths.append(each_tests_directory / flat_prefixed_name)
180
+ for each_candidate_path in all_candidate_paths:
181
+ if each_candidate_path.is_file():
182
+ return each_candidate_path
183
+ return None
184
+
185
+
186
+ def _collect_referenced_identifiers(source: str) -> set[str]:
187
+ """Return every identifier a test module references — imported, called, or named.
188
+
189
+ Collects ``import`` binding names and ``from``-import member names, every
190
+ ``Name`` node id, and every attribute access name, so a public function the
191
+ test imports, calls bare, or reaches through an attribute all count as a
192
+ reference. A module that fails to parse contributes no identifiers.
193
+
194
+ Args:
195
+ source: The full text of a test module.
196
+
197
+ Returns:
198
+ The set of identifiers the test module references.
199
+ """
200
+ try:
201
+ tree = ast.parse(source)
202
+ except SyntaxError:
203
+ return set()
204
+ all_referenced_identifiers: set[str] = set()
205
+ for each_node in ast.walk(tree):
206
+ if isinstance(each_node, ast.Name):
207
+ all_referenced_identifiers.add(each_node.id)
208
+ elif isinstance(each_node, ast.Attribute):
209
+ all_referenced_identifiers.add(each_node.attr)
210
+ elif isinstance(each_node, (ast.Import, ast.ImportFrom)):
211
+ for each_alias in each_node.names:
212
+ all_referenced_identifiers.add(each_alias.asname or each_alias.name)
213
+ all_referenced_identifiers.add(each_alias.name)
214
+ return all_referenced_identifiers
215
+
216
+
217
+ def _suite_referenced_identifiers(stem_test_path: Path) -> set[str]:
218
+ """Return identifiers referenced across every test file beside the stem test.
219
+
220
+ Scans every ``test_*.py`` and ``*_test.py`` file in the directory holding the
221
+ stem-matched test file, bounded by the scan cap, and unions the identifiers
222
+ each one references.
223
+
224
+ Args:
225
+ stem_test_path: The stem-matched test file whose directory is scanned.
226
+
227
+ Returns:
228
+ The union of referenced identifiers across the scanned test files.
229
+ """
230
+ suite_directory = stem_test_path.parent
231
+ all_test_file_paths: list[Path] = []
232
+ for each_glob in ALL_TEST_FILENAME_GLOBS:
233
+ all_test_file_paths.extend(sorted(suite_directory.glob(each_glob)))
234
+ all_referenced_identifiers: set[str] = set()
235
+ for each_test_file_path in all_test_file_paths[:MAX_TEST_FILES_SCANNED]:
236
+ try:
237
+ test_source = each_test_file_path.read_text(encoding="utf-8")
238
+ except (OSError, UnicodeDecodeError):
239
+ continue
240
+ all_referenced_identifiers |= _collect_referenced_identifiers(test_source)
241
+ return all_referenced_identifiers
242
+
243
+
244
+ def _module_is_exempt(file_path: str) -> bool:
245
+ """Return whether a path is exempt from the paired-test coverage check.
246
+
247
+ Test modules, hook infrastructure, config modules, migrations, workflow
248
+ registries, and package ``__init__`` re-export modules are all exempt.
249
+
250
+ Args:
251
+ file_path: The destination path of the write or edit.
252
+
253
+ Returns:
254
+ True when the paired-test coverage check must not run on this path.
255
+ """
256
+ if is_test_file(file_path):
257
+ return True
258
+ if is_hook_infrastructure(file_path):
259
+ return True
260
+ if is_config_file(file_path):
261
+ return True
262
+ if is_migration_file(file_path):
263
+ return True
264
+ if is_workflow_registry_file(file_path):
265
+ return True
266
+ return Path(file_path).name == INIT_MODULE_FILENAME
267
+
268
+
269
+ def check_public_function_missing_paired_test(
270
+ content: str,
271
+ file_path: str,
272
+ all_changed_lines: set[int] | None = None,
273
+ defer_scope_to_caller: bool = False,
274
+ ) -> list[str]:
275
+ """Flag a public function the module's established paired test suite omits.
276
+
277
+ Runs only on a production module whose stem-matched ``test_<stem>.py`` already
278
+ exists and already exercises the module — by covering at least one of its
279
+ public functions, or by referencing one of its private helpers by name.
280
+ Under that established-suite precondition, a public function the suite
281
+ references nowhere is flagged, so the forgotten function gets a behavioral
282
+ test before the partially-covered module lands. ``main`` and
283
+ underscore-prefixed functions are never required to carry a test, and test
284
+ modules, hook infrastructure, config modules, migrations, workflow
285
+ registries, and ``__init__`` modules are exempt.
286
+
287
+ Args:
288
+ content: The reconstructed post-edit whole-file content of the module.
289
+ file_path: The destination path, used for the exemptions and to locate
290
+ the module's paired test files on disk.
291
+ all_changed_lines: Post-edit line numbers the current edit touched, or
292
+ None to treat every public-function definition as in scope.
293
+ defer_scope_to_caller: When True, return every violation so the
294
+ commit/push gate scopes by added line.
295
+
296
+ Returns:
297
+ One violation per uncovered public function, capped at the configured
298
+ maximum, scoped to the changed lines unless deferred or unscoped. Empty
299
+ when the module is exempt, has no dedicated test file, the suite neither
300
+ covers a public function nor references a private helper of the module,
301
+ or the content fails to parse.
302
+ """
303
+ if _module_is_exempt(file_path):
304
+ return []
305
+ try:
306
+ tree = ast.parse(content)
307
+ except SyntaxError:
308
+ return []
309
+ all_public_definitions = _public_function_definitions(tree)
310
+ if not all_public_definitions:
311
+ return []
312
+ stem_test_path = _stem_matched_test_path(Path(file_path).resolve())
313
+ if stem_test_path is None:
314
+ return []
315
+ all_referenced_identifiers = _suite_referenced_identifiers(stem_test_path)
316
+ covered_function_count = sum(
317
+ 1
318
+ for each_name, _each_line in all_public_definitions
319
+ if each_name in all_referenced_identifiers
320
+ )
321
+ suite_references_private_helper = bool(
322
+ _private_function_names(tree) & all_referenced_identifiers
323
+ )
324
+ suite_is_established = (
325
+ covered_function_count >= MINIMUM_COVERED_PUBLIC_FUNCTIONS
326
+ or suite_references_private_helper
327
+ )
328
+ if not suite_is_established:
329
+ return []
330
+ all_violations_in_walk_order: list[tuple[range, str]] = []
331
+ for each_name, each_definition_line in all_public_definitions:
332
+ if each_name in all_referenced_identifiers:
333
+ continue
334
+ message = (
335
+ f"Line {each_definition_line}: public function {each_name!r} "
336
+ f"{MISSING_PAIRED_TEST_GUIDANCE}"
337
+ )
338
+ all_violations_in_walk_order.append(
339
+ (range(each_definition_line, each_definition_line + 1), message)
340
+ )
341
+ if len(all_violations_in_walk_order) >= MAX_PAIRED_TEST_COVERAGE_ISSUES:
342
+ break
343
+ return _scope_violations_to_changed_lines(
344
+ all_violations_in_walk_order,
345
+ all_changed_lines,
346
+ defer_scope_to_caller,
347
+ )
348
+
349
+
350
+ def _production_stem_from_test_filename(test_filename: str) -> str | None:
351
+ """Return the production-module stem a stem-matched test filename pairs with.
352
+
353
+ Args:
354
+ test_filename: The basename of the written file.
355
+
356
+ Returns:
357
+ The production stem for a ``test_<stem>.py`` or ``<stem>_test.py`` name,
358
+ or None when the filename is not stem-matched or its stem is empty.
359
+ """
360
+ if test_filename.endswith(STEM_TEST_FILENAME_SUFFIX):
361
+ production_stem = test_filename[: -len(STEM_TEST_FILENAME_SUFFIX)]
362
+ return production_stem or None
363
+ if test_filename.startswith(STEM_TEST_FILENAME_PREFIX) and test_filename.endswith(
364
+ PYTHON_SOURCE_SUFFIX
365
+ ):
366
+ production_stem = test_filename[
367
+ len(STEM_TEST_FILENAME_PREFIX) : -len(PYTHON_SOURCE_SUFFIX)
368
+ ]
369
+ return production_stem or None
370
+ return None
371
+
372
+
373
+ def _paired_production_module_path(
374
+ test_path: Path, production_stem: str
375
+ ) -> Path | None:
376
+ """Return the production module a stem-matched test file pairs with, or None.
377
+
378
+ Mirrors ``_stem_matched_test_path`` in reverse for the two common layouts: a
379
+ module beside the test file, and a module in the parent of the ``tests``
380
+ directory that directly holds the test file.
381
+
382
+ Args:
383
+ test_path: The resolved path of the written stem-matched test file.
384
+ production_stem: The production-module stem the test filename names.
385
+
386
+ Returns:
387
+ The first candidate production module path that exists on disk, or None
388
+ when no candidate module file exists.
389
+ """
390
+ production_filename = production_stem + PYTHON_SOURCE_SUFFIX
391
+ test_directory = test_path.parent
392
+ all_candidate_paths = [test_directory / production_filename]
393
+ if test_directory.name == TESTS_DIRECTORY_NAME:
394
+ all_candidate_paths.append(test_directory.parent / production_filename)
395
+ for each_candidate_path in all_candidate_paths:
396
+ if each_candidate_path.is_file():
397
+ return each_candidate_path
398
+ return None
399
+
400
+
401
+ def _post_edit_suite_referenced_identifiers(
402
+ suite_directory: Path,
403
+ written_test_path: Path,
404
+ written_test_content: str,
405
+ ) -> set[str]:
406
+ """Return suite identifiers, reading the written test's post-edit content.
407
+
408
+ Scans every ``test_*.py`` and ``*_test.py`` file in the suite directory,
409
+ bounded by the scan cap, and unions the identifiers each references. The
410
+ written test file contributes ``written_test_content`` rather than its stale
411
+ on-disk text, so the post-edit suite is judged.
412
+
413
+ Args:
414
+ suite_directory: The directory holding the suite's test files.
415
+ written_test_path: The resolved path of the test file being written.
416
+ written_test_content: The post-edit text of the written test file.
417
+
418
+ Returns:
419
+ The union of referenced identifiers across the post-edit suite.
420
+ """
421
+ all_test_file_paths: list[Path] = []
422
+ for each_glob in ALL_TEST_FILENAME_GLOBS:
423
+ all_test_file_paths.extend(sorted(suite_directory.glob(each_glob)))
424
+ written_path_key = os.path.normcase(str(written_test_path))
425
+ all_referenced_identifiers = _collect_referenced_identifiers(written_test_content)
426
+ for each_test_file_path in all_test_file_paths[:MAX_TEST_FILES_SCANNED]:
427
+ if os.path.normcase(str(each_test_file_path.resolve())) == written_path_key:
428
+ continue
429
+ try:
430
+ test_source = each_test_file_path.read_text(encoding="utf-8")
431
+ except (OSError, UnicodeDecodeError):
432
+ continue
433
+ all_referenced_identifiers |= _collect_referenced_identifiers(test_source)
434
+ return all_referenced_identifiers
435
+
436
+
437
+ def check_test_file_omits_module_public_function(
438
+ content: str,
439
+ file_path: str,
440
+ ) -> list[str]:
441
+ """Flag a public function the written test file's paired suite omits.
442
+
443
+ The companion of ``check_public_function_missing_paired_test`` for the
444
+ reverse write order: when a production module is written before its test
445
+ file, the production-side check sees no suite yet and the omission slips
446
+ through, so this check fires on the stem-matched test-file write instead. It
447
+ resolves the production module the written ``test_<stem>.py`` or
448
+ ``<stem>_test.py`` file pairs with, and when the post-edit suite already
449
+ covers at least one of that module's public functions, flags every public
450
+ function the suite references nowhere. ``main`` and underscore-prefixed
451
+ functions are never required to carry a test, and a production module that is
452
+ itself exempt — a test module, hook infrastructure, config module, migration,
453
+ workflow registry, or ``__init__`` module — is skipped.
454
+
455
+ A stem-matched test-file write authors the whole pairing relationship, so
456
+ every omission is reported unscoped: the gap lives in the test file under
457
+ edit, while the omitted function's definition line lives in the production
458
+ module — a different file whose line numbers are not addressable against the
459
+ test file's changed lines. Diff-line intersection would compare the two
460
+ unrelated coordinate spaces, so this check returns each omission directly
461
+ and the commit/push gate treats it as blocking.
462
+
463
+ Args:
464
+ content: The reconstructed post-edit whole-file content of the test file.
465
+ file_path: The destination path of the written test file, used to resolve
466
+ its paired production module on disk.
467
+
468
+ Returns:
469
+ One violation per public function the suite omits, capped at the
470
+ configured maximum. Empty when the file is not a stem-matched test file,
471
+ no paired production module exists on disk, the production module is
472
+ exempt or defines no public function, or the suite covers none of the
473
+ module's public functions.
474
+ """
475
+ production_stem = _production_stem_from_test_filename(Path(file_path).name)
476
+ if production_stem is None:
477
+ return []
478
+ test_path = Path(file_path).resolve()
479
+ production_module_path = _paired_production_module_path(test_path, production_stem)
480
+ if production_module_path is None:
481
+ return []
482
+ if _module_is_exempt(str(production_module_path)):
483
+ return []
484
+ try:
485
+ production_source = production_module_path.read_text(encoding="utf-8")
486
+ except (OSError, UnicodeDecodeError):
487
+ return []
488
+ try:
489
+ production_tree = ast.parse(production_source)
490
+ except SyntaxError:
491
+ return []
492
+ all_public_definitions = _public_function_definitions(production_tree)
493
+ if not all_public_definitions:
494
+ return []
495
+ all_referenced_identifiers = _post_edit_suite_referenced_identifiers(
496
+ test_path.parent, test_path, content
497
+ )
498
+ covered_function_count = sum(
499
+ 1
500
+ for each_name, _each_line in all_public_definitions
501
+ if each_name in all_referenced_identifiers
502
+ )
503
+ if covered_function_count < MINIMUM_COVERED_PUBLIC_FUNCTIONS:
504
+ return []
505
+ production_module_name = production_module_path.name
506
+ all_omission_violations: list[str] = []
507
+ for each_name, _each_definition_line in all_public_definitions:
508
+ if each_name in all_referenced_identifiers:
509
+ continue
510
+ all_omission_violations.append(
511
+ f"{production_module_name} public function "
512
+ f"{each_name!r} {TEST_SUITE_OMITS_FUNCTION_GUIDANCE}"
513
+ )
514
+ if len(all_omission_violations) >= MAX_PAIRED_TEST_COVERAGE_ISSUES:
515
+ break
516
+ return all_omission_violations
517
+
@@ -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