claude-dev-env 1.78.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 (35) 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 +3 -2
  5. package/hooks/blocking/code_rules_docstrings.py +609 -16
  6. package/hooks/blocking/code_rules_enforcer.py +37 -0
  7. package/hooks/blocking/code_rules_naming_collection.py +76 -1
  8. package/hooks/blocking/code_rules_paired_test.py +240 -22
  9. package/hooks/blocking/code_rules_test_assertions.py +159 -1
  10. package/hooks/blocking/env_var_table_code_drift_blocker.py +475 -0
  11. package/hooks/blocking/package_inventory_stale_blocker.py +54 -15
  12. package/hooks/blocking/test_code_rules_enforcer_docstring_field_runmode_outcome.py +129 -0
  13. package/hooks/blocking/test_code_rules_enforcer_docstring_length_constant_superlative.py +198 -0
  14. package/hooks/blocking/test_code_rules_enforcer_docstring_no_network.py +115 -0
  15. package/hooks/blocking/test_code_rules_enforcer_docstring_unreferenced_param.py +160 -0
  16. package/hooks/blocking/test_code_rules_enforcer_module_docstring_data_schema_scope.py +82 -0
  17. package/hooks/blocking/test_code_rules_enforcer_paired_test.py +190 -0
  18. package/hooks/blocking/test_code_rules_enforcer_polarity_name_contradiction.py +76 -0
  19. package/hooks/blocking/test_code_rules_enforcer_vacuous_cleanup_assertion.py +132 -0
  20. package/hooks/blocking/test_env_var_table_code_drift_blocker.py +94 -0
  21. package/hooks/blocking/test_package_inventory_stale_blocker.py +46 -0
  22. package/hooks/blocking/test_pre_tool_use_dispatcher.py +7 -7
  23. package/hooks/hooks_constants/CLAUDE.md +1 -0
  24. package/hooks/hooks_constants/blocking_check_limits.py +78 -0
  25. package/hooks/hooks_constants/code_rules_enforcer_constants.py +14 -0
  26. package/hooks/hooks_constants/env_var_table_code_drift_constants.py +64 -0
  27. package/hooks/hooks_constants/package_inventory_stale_blocker_constants.py +20 -9
  28. package/hooks/hooks_constants/paired_test_coverage_constants.py +11 -3
  29. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
  30. package/package.json +1 -1
  31. package/rules/CLAUDE.md +1 -0
  32. package/rules/docstring-prose-matches-implementation.md +56 -54
  33. package/rules/env-var-table-code-drift.md +24 -0
  34. package/rules/package-inventory-stale-entry.md +4 -4
  35. package/rules/paired-test-coverage.md +12 -5
@@ -72,11 +72,15 @@ from code_rules_docstrings import ( # noqa: E402
72
72
  check_docstring_args_match_signature,
73
73
  check_docstring_args_single_line_scope_vs_span,
74
74
  check_docstring_cardinal_count_matches_constant_family,
75
+ check_docstring_documents_unreferenced_parameter,
75
76
  check_docstring_fallback_branch_coverage,
77
+ check_docstring_field_runmode_outcome,
76
78
  check_docstring_format,
79
+ check_docstring_length_constant_superlative_vs_exact_gate,
77
80
  check_docstring_names_undefined_constant,
78
81
  check_docstring_no_consumer_claim,
79
82
  check_docstring_no_inline_literal_claim,
83
+ check_docstring_no_network_claim_with_metadata_access,
80
84
  check_docstring_punctuation_mark_enumeration_coverage,
81
85
  check_docstring_raises_unraisable_largezipfile,
82
86
  check_docstring_returns_plural_cardinality,
@@ -85,6 +89,7 @@ from code_rules_docstrings import ( # noqa: E402
85
89
  check_docstring_tuple_enumeration_match,
86
90
  check_docstring_unguarded_malformed_payload_claim,
87
91
  check_module_docstring_names_public_checks,
92
+ check_module_docstring_scope_omits_data_schema_constants,
88
93
  )
89
94
  from code_rules_duplicate_body import ( # noqa: E402
90
95
  advise_cross_skill_duplicate_helper,
@@ -112,6 +117,7 @@ from code_rules_mock_completeness import ( # noqa: E402
112
117
  from code_rules_naming_collection import ( # noqa: E402
113
118
  check_collection_prefix,
114
119
  check_loop_variable_naming,
120
+ check_polarity_name_contradiction,
115
121
  check_stuttering_collection_prefix,
116
122
  )
117
123
  from code_rules_optional_params import ( # noqa: E402
@@ -123,6 +129,7 @@ from code_rules_orphan_css_class import ( # noqa: E402
123
129
  )
124
130
  from code_rules_paired_test import ( # noqa: E402
125
131
  check_public_function_missing_paired_test,
132
+ check_test_file_omits_module_public_function,
126
133
  )
127
134
  from code_rules_paths_syspath import ( # noqa: E402
128
135
  check_hardcoded_user_paths,
@@ -147,6 +154,7 @@ from code_rules_test_assertions import ( # noqa: E402
147
154
  check_flag_gated_scenario_test_naming,
148
155
  check_skip_decorators_in_tests,
149
156
  check_stale_test_name_target,
157
+ check_vacuous_cleanup_assertion_tests,
150
158
  )
151
159
  from code_rules_test_branching_except import ( # noqa: E402
152
160
  check_bare_except,
@@ -294,8 +302,16 @@ def validate_content(
294
302
  all_issues.extend(check_boundary_types(effective_content, file_path))
295
303
  all_issues.extend(check_docstring_format(effective_content, file_path))
296
304
  all_issues.extend(check_docstring_args_match_signature(effective_content, file_path))
305
+ all_issues.extend(
306
+ check_docstring_documents_unreferenced_parameter(effective_content, file_path)
307
+ )
297
308
  all_issues.extend(check_docstring_fallback_branch_coverage(effective_content, file_path))
298
309
  all_issues.extend(check_docstring_no_consumer_claim(effective_content, file_path))
310
+ all_issues.extend(
311
+ check_docstring_no_network_claim_with_metadata_access(
312
+ effective_content, file_path
313
+ )
314
+ )
299
315
  all_issues.extend(
300
316
  check_docstring_unguarded_malformed_payload_claim(
301
317
  effective_content, file_path
@@ -311,6 +327,11 @@ def validate_content(
311
327
  all_issues.extend(
312
328
  check_module_docstring_names_public_checks(effective_content, file_path)
313
329
  )
330
+ all_issues.extend(
331
+ check_module_docstring_scope_omits_data_schema_constants(
332
+ effective_content, file_path
333
+ )
334
+ )
314
335
  all_issues.extend(
315
336
  check_docstring_tuple_enumeration_match(effective_content, file_path)
316
337
  )
@@ -327,6 +348,14 @@ def validate_content(
327
348
  all_issues.extend(
328
349
  check_docstring_returns_plural_cardinality(effective_content, file_path)
329
350
  )
351
+ all_issues.extend(
352
+ check_docstring_length_constant_superlative_vs_exact_gate(
353
+ effective_content, file_path
354
+ )
355
+ )
356
+ all_issues.extend(
357
+ check_docstring_field_runmode_outcome(effective_content, file_path)
358
+ )
330
359
  all_issues.extend(
331
360
  check_docstring_raises_unraisable_largezipfile(effective_content, file_path)
332
361
  )
@@ -368,6 +397,7 @@ def validate_content(
368
397
  )
369
398
  all_issues.extend(check_existence_check_tests(content, file_path))
370
399
  all_issues.extend(check_constant_equality_tests(content, file_path))
400
+ all_issues.extend(check_vacuous_cleanup_assertion_tests(content, file_path))
371
401
  all_issues.extend(check_stale_test_name_target(content, file_path))
372
402
  check_flag_gated_scenario_test_naming(content, file_path)
373
403
  all_issues.extend(check_unused_optional_parameters(content, file_path))
@@ -414,7 +444,14 @@ def validate_content(
414
444
  defer_scope_to_caller,
415
445
  )
416
446
  )
447
+ all_issues.extend(
448
+ check_test_file_omits_module_public_function(
449
+ effective_content,
450
+ file_path,
451
+ )
452
+ )
417
453
  all_issues.extend(check_loop_variable_naming(content, file_path))
454
+ all_issues.extend(check_polarity_name_contradiction(content, file_path))
418
455
  all_issues.extend(check_inline_literal_collections(content, file_path))
419
456
  all_issues.extend(check_inline_tuple_string_magic(content, file_path))
420
457
  all_issues.extend(check_string_literal_magic(content, file_path))
@@ -1,6 +1,7 @@
1
- """Collection-prefix, stuttering-prefix, and loop-variable naming checks."""
1
+ """Collection-prefix, stuttering-prefix, loop-variable, and polarity-name-contradiction naming checks."""
2
2
 
3
3
  import ast
4
+ import re
4
5
  import sys
5
6
  from pathlib import Path
6
7
 
@@ -11,6 +12,7 @@ if _blocking_directory not in sys.path:
11
12
  if _hooks_directory not in sys.path:
12
13
  sys.path.insert(0, _hooks_directory)
13
14
 
15
+ from code_rules_boolean_mustcheck import _called_terminal_name # noqa: E402
14
16
  from code_rules_shared import ( # noqa: E402
15
17
  _collect_annotated_arguments,
16
18
  _collect_target_names,
@@ -22,11 +24,13 @@ from code_rules_shared import ( # noqa: E402
22
24
  from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
23
25
  ALL_COLLECTION_TYPE_NAMES,
24
26
  ALL_LOOP_INDEX_LETTER_EXEMPTIONS,
27
+ ALL_POLARITY_ANTONYM_TOKEN_PAIRS,
25
28
  ALL_SUBSCRIPT_ONLY_COLLECTION_TYPE_NAMES,
26
29
  ALL_UNION_TYPING_NAMES,
27
30
  BARE_EACH_TOKEN,
28
31
  COLLECTION_BY_NAME_PATTERN,
29
32
  EACH_PREFIX,
33
+ POLARITY_TOKEN_BOUNDARY_PATTERN,
30
34
  UPPER_SNAKE_CONSTANT_PATTERN,
31
35
  )
32
36
  from hooks_constants.stuttering_check_config import ( # noqa: E402
@@ -262,3 +266,74 @@ def check_loop_variable_naming(content: str, file_path: str) -> list[str]:
262
266
  f"Line {each_name_node.lineno}: loop variable {target_name!r} - prefix with each_ (CODE_RULES §5)"
263
267
  )
264
268
  return issues
269
+
270
+
271
+ def _name_carries_token(name: str, token: str) -> bool:
272
+ """True when name carries token as a whole underscore-delimited word."""
273
+ return re.search(POLARITY_TOKEN_BOUNDARY_PATTERN % re.escape(token), name) is not None
274
+
275
+
276
+ def _contradicting_polarity_pair(target_name: str, called_name: str) -> tuple[str, str] | None:
277
+ """The (target_token, called_token) antonym pair when target and callee assert opposite polarity."""
278
+ target_lower = target_name.lower()
279
+ called_lower = called_name.lower()
280
+ for each_positive_token, each_negative_token in ALL_POLARITY_ANTONYM_TOKEN_PAIRS:
281
+ if _name_carries_token(target_lower, each_positive_token) and _name_carries_token(
282
+ called_lower, each_negative_token
283
+ ):
284
+ return each_positive_token, each_negative_token
285
+ if _name_carries_token(target_lower, each_negative_token) and _name_carries_token(
286
+ called_lower, each_positive_token
287
+ ):
288
+ return each_negative_token, each_positive_token
289
+ return None
290
+
291
+
292
+ def check_polarity_name_contradiction(content: str, file_path: str) -> list[str]:
293
+ """Flag a boolean assignment whose target and callee assert opposite polarity.
294
+
295
+ Catches the self-contradicting statement ``is_inside_allowed =
296
+ _point_hits_any_forbidden(...)`` — a target name carrying one polarity token
297
+ (``allowed``) assigned directly from a single call whose callee name carries
298
+ the antonym token (``forbidden``). One side lies about the behavior, so the
299
+ reader cannot trust either name. Rename the callee to a neutral form that
300
+ reads truthfully at every call site.
301
+ """
302
+ if is_test_file(file_path):
303
+ return []
304
+ if is_workflow_registry_file(file_path) or is_migration_file(file_path):
305
+ return []
306
+ try:
307
+ tree = ast.parse(content)
308
+ except SyntaxError:
309
+ return []
310
+ issues: list[str] = []
311
+ for each_node in ast.walk(tree):
312
+ target_name: str | None = None
313
+ value_node: ast.expr | None = None
314
+ target_line = 0
315
+ if isinstance(each_node, ast.Assign) and len(each_node.targets) == 1 and isinstance(
316
+ each_node.targets[0], ast.Name
317
+ ):
318
+ target_name = each_node.targets[0].id
319
+ value_node = each_node.value
320
+ target_line = each_node.lineno
321
+ elif isinstance(each_node, ast.AnnAssign) and isinstance(each_node.target, ast.Name):
322
+ target_name = each_node.target.id
323
+ value_node = each_node.value
324
+ target_line = each_node.lineno
325
+ if target_name is None or not isinstance(value_node, ast.Call):
326
+ continue
327
+ called_name = _called_terminal_name(value_node)
328
+ if called_name is None:
329
+ continue
330
+ contradicting_pair = _contradicting_polarity_pair(target_name, called_name)
331
+ if contradicting_pair is None:
332
+ continue
333
+ target_token, called_token = contradicting_pair
334
+ issues.append(
335
+ f"Line {target_line}: {target_name!r} (says {target_token!r}) is assigned from"
336
+ f" {called_name!r} (says {called_token!r}) - the name contradicts the value; rename the"
337
+ f" callee to a neutral form so each call site reads truthfully (CODE_RULES §5)"
338
+ )
339
+ return issues
@@ -3,29 +3,48 @@
3
3
  The TDD gate ``tdd_enforcer.py`` requires a fresh test file to exist before a
4
4
  production module is written, but it judges coverage at file granularity: a
5
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.
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 seebecause 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.
23
41
  - ``main`` and underscore-prefixed functions are never required to carry a test.
24
42
  - Test modules, hook infrastructure, config modules, migrations, workflow
25
- registries, and ``__init__`` modules are exempt.
43
+ registries, and ``__init__`` modules are exempt as production targets.
26
44
  """
27
45
 
28
46
  import ast
47
+ import os
29
48
  import sys
30
49
  from pathlib import Path
31
50
 
@@ -59,6 +78,7 @@ from hooks_constants.paired_test_coverage_constants import ( # noqa: E402
59
78
  PYTHON_SOURCE_SUFFIX,
60
79
  STEM_TEST_FILENAME_PREFIX,
61
80
  STEM_TEST_FILENAME_SUFFIX,
81
+ TEST_SUITE_OMITS_FUNCTION_GUIDANCE,
62
82
  TESTS_DIRECTORY_NAME,
63
83
  )
64
84
 
@@ -97,6 +117,26 @@ def _public_function_definitions(tree: ast.Module) -> list[tuple[str, int]]:
97
117
  return all_definitions
98
118
 
99
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
+
100
140
  def _ancestor_tests_directories(start_directory: Path) -> list[Path]:
101
141
  """Return each ancestor's ``tests`` directory, nearest ancestor first.
102
142
 
@@ -235,7 +275,8 @@ def check_public_function_missing_paired_test(
235
275
  """Flag a public function the module's established paired test suite omits.
236
276
 
237
277
  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.
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.
239
280
  Under that established-suite precondition, a public function the suite
240
281
  references nowhere is flagged, so the forgotten function gets a behavioral
241
282
  test before the partially-covered module lands. ``main`` and
@@ -255,8 +296,9 @@ def check_public_function_missing_paired_test(
255
296
  Returns:
256
297
  One violation per uncovered public function, capped at the configured
257
298
  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.
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.
260
302
  """
261
303
  if _module_is_exempt(file_path):
262
304
  return []
@@ -276,7 +318,14 @@ def check_public_function_missing_paired_test(
276
318
  for each_name, _each_line in all_public_definitions
277
319
  if each_name in all_referenced_identifiers
278
320
  )
279
- if covered_function_count < MINIMUM_COVERED_PUBLIC_FUNCTIONS:
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:
280
329
  return []
281
330
  all_violations_in_walk_order: list[tuple[range, str]] = []
282
331
  for each_name, each_definition_line in all_public_definitions:
@@ -297,3 +346,172 @@ def check_public_function_missing_paired_test(
297
346
  defer_scope_to_caller,
298
347
  )
299
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
+