claude-dev-env 1.72.0 → 1.74.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 (99) hide show
  1. package/CLAUDE.md +2 -0
  2. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +2 -2
  3. package/bin/install.mjs +73 -5
  4. package/bin/install.test.mjs +360 -4
  5. package/hooks/blocking/CLAUDE.md +6 -1
  6. package/hooks/blocking/block_main_commit.py +14 -0
  7. package/hooks/blocking/bot_mention_comment_blocker.py +7 -0
  8. package/hooks/blocking/claude_md_orphan_file_blocker.py +19 -48
  9. package/hooks/blocking/code_rules_dead_config_field.py +69 -56
  10. package/hooks/blocking/code_rules_docstrings.py +839 -0
  11. package/hooks/blocking/code_rules_enforcer.py +38 -0
  12. package/hooks/blocking/code_rules_shared.py +19 -0
  13. package/hooks/blocking/code_verifier_spawn_preflight_gate.py +426 -0
  14. package/hooks/blocking/convergence_gate_blocker.py +17 -3
  15. package/hooks/blocking/destructive_command_blocker.py +7 -0
  16. package/hooks/blocking/docstring_rule_gate_count_blocker.py +321 -0
  17. package/hooks/blocking/gh_body_arg_blocker.py +8 -0
  18. package/hooks/blocking/gh_pr_author_enforcer.py +7 -0
  19. package/hooks/blocking/hedging_language_blocker.py +16 -10
  20. package/hooks/blocking/hook_prose_detector_consistency.py +7 -0
  21. package/hooks/blocking/intent_only_ending_blocker.py +17 -11
  22. package/hooks/blocking/md_to_html_blocker.py +17 -10
  23. package/hooks/blocking/open_questions_in_plans_blocker.py +15 -8
  24. package/hooks/blocking/package_inventory_stale_blocker.py +398 -0
  25. package/hooks/blocking/plain_language_blocker.py +57 -16
  26. package/hooks/blocking/pr_converge_bugteam_enforcer.py +11 -5
  27. package/hooks/blocking/pr_description_enforcer.py +6 -0
  28. package/hooks/blocking/pre_tool_use_dispatcher.py +545 -0
  29. package/hooks/blocking/precommit_code_rules_gate.py +10 -1
  30. package/hooks/blocking/pytest_testpaths_orphan_blocker.py +366 -0
  31. package/hooks/blocking/question_to_user_enforcer.py +18 -12
  32. package/hooks/blocking/send_user_file_open_locally_blocker.py +70 -0
  33. package/hooks/blocking/sensitive_file_protector.py +15 -1
  34. package/hooks/blocking/session_handoff_blocker.py +14 -8
  35. package/hooks/blocking/state_description_blocker.py +81 -36
  36. package/hooks/blocking/subprocess_budget_completeness.py +9 -3
  37. package/hooks/blocking/tdd_enforcer.py +6 -0
  38. package/hooks/blocking/test_code_rules_enforcer_dead_config_field.py +81 -0
  39. package/hooks/blocking/test_code_rules_enforcer_docstring_inline_literal_claim.py +93 -0
  40. package/hooks/blocking/test_code_rules_enforcer_docstring_returns_plural_cardinality.py +207 -0
  41. package/hooks/blocking/test_code_rules_enforcer_docstring_step_dispatch.py +262 -0
  42. package/hooks/blocking/test_code_rules_enforcer_docstring_undefined_constant.py +253 -0
  43. package/hooks/blocking/test_code_rules_enforcer_docstring_unguarded_payload.py +188 -0
  44. package/hooks/blocking/test_code_rules_enforcer_module_docstring_roster.py +279 -0
  45. package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +501 -0
  46. package/hooks/blocking/test_docstring_rule_gate_count_blocker.py +203 -0
  47. package/hooks/blocking/test_hook_block_logger_coverage.py +53 -0
  48. package/hooks/blocking/test_package_inventory_stale_blocker.py +329 -0
  49. package/hooks/blocking/test_plain_language_blocker.py +36 -0
  50. package/hooks/blocking/test_pre_tool_use_dispatcher.py +816 -0
  51. package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +341 -0
  52. package/hooks/blocking/test_pytest_testpaths_orphan_blocker.py +247 -0
  53. package/hooks/blocking/test_send_user_file_open_locally_blocker.py +114 -0
  54. package/hooks/blocking/test_shared_stdin_adoption.py +208 -0
  55. package/hooks/blocking/test_state_description_blocker.py +41 -0
  56. package/hooks/blocking/test_verdict_directory_write_blocker.py +49 -0
  57. package/hooks/blocking/test_workflow_substitution_slot_blocker.py +4 -19
  58. package/hooks/blocking/verdict_directory_write_blocker.py +21 -7
  59. package/hooks/blocking/verified_commit_gate.py +11 -0
  60. package/hooks/blocking/verified_commit_message_accuracy_blocker.py +16 -1
  61. package/hooks/blocking/windows_rmtree_blocker.py +7 -0
  62. package/hooks/blocking/workflow_substitution_slot_blocker.py +10 -5
  63. package/hooks/blocking/write_existing_file_blocker.py +16 -1
  64. package/hooks/hooks.json +19 -79
  65. package/hooks/hooks_constants/CLAUDE.md +7 -1
  66. package/hooks/hooks_constants/blocking_check_limits.py +74 -0
  67. package/hooks/hooks_constants/code_rules_enforcer_constants.py +9 -0
  68. package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +45 -0
  69. package/hooks/hooks_constants/dead_config_field_constants.py +5 -5
  70. package/hooks/hooks_constants/docstring_rule_gate_count_blocker_constants.py +90 -0
  71. package/hooks/hooks_constants/hook_block_logger.py +59 -0
  72. package/hooks/hooks_constants/multi_edit_reconstruction.py +56 -0
  73. package/hooks/hooks_constants/mypy_validator_cache_constants.py +36 -0
  74. package/hooks/hooks_constants/package_inventory_stale_blocker_constants.py +111 -0
  75. package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +68 -0
  76. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +143 -0
  77. package/hooks/hooks_constants/pytest_testpaths_orphan_blocker_constants.py +79 -0
  78. package/hooks/hooks_constants/send_user_file_open_locally_blocker_constants.py +18 -0
  79. package/hooks/hooks_constants/test_dispatcher_constants_docstrings.py +44 -0
  80. package/hooks/hooks_constants/test_hook_block_logger.py +159 -0
  81. package/hooks/lifecycle/config_change_guard.py +12 -0
  82. package/hooks/lifecycle/test_config_change_guard.py +23 -0
  83. package/hooks/validation/hook_format_validator.py +13 -0
  84. package/hooks/validation/mypy_validator.py +245 -18
  85. package/hooks/validation/post_tool_use_dispatcher.py +344 -0
  86. package/hooks/validation/test_hook_format_validator.py +64 -0
  87. package/hooks/validation/test_mypy_validator.py +206 -1
  88. package/hooks/validation/test_post_tool_use_dispatcher.py +610 -0
  89. package/hooks/workflow/test_auto_formatter.py +10 -9
  90. package/package.json +1 -1
  91. package/rules/CLAUDE.md +1 -0
  92. package/rules/docstring-prose-matches-implementation.md +4 -2
  93. package/rules/package-inventory-stale-entry.md +24 -0
  94. package/skills/autoconverge/SKILL.md +111 -1
  95. package/skills/autoconverge/workflow/converge.contract.test.mjs +106 -0
  96. package/skills/autoconverge/workflow/converge.mjs +29 -3
  97. package/skills/autoconverge/workflow/converge.path-aware.test.mjs +47 -0
  98. package/skills/autoconverge/workflow/converge_multi.mjs +161 -0
  99. package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +100 -0
@@ -1,6 +1,7 @@
1
1
  """Google-style docstring presence and docstring Args-versus-signature checks."""
2
2
 
3
3
  import ast
4
+ import re
4
5
  import sys
5
6
  from pathlib import Path
6
7
 
@@ -16,29 +17,53 @@ from code_rules_shared import ( # noqa: E402
16
17
  _walk_skipping_nested_functions,
17
18
  _walk_skipping_type_checking_blocks,
18
19
  is_hook_infrastructure,
20
+ is_strict_test_file,
19
21
  is_test_file,
20
22
  )
21
23
 
22
24
  from hooks_constants.blocking_check_limits import ( # noqa: E402
23
25
  ALL_DOCSTRING_EXCLUSIVE_SCOPE_PHRASES,
24
26
  ALL_DOCSTRING_EXEMPT_DECORATOR_NAMES,
27
+ ALL_DOCSTRING_FILE_REFERENCE_SUFFIXES,
28
+ ALL_DOCSTRING_GUARDED_FAILURE_CLAIM_PHRASES,
25
29
  ALL_DOCSTRING_IMPLICIT_INSTANCE_PARAMETER_NAMES,
26
30
  ALL_DOCSTRING_MULTIPLE_CONDITION_JOINING_PHRASES,
27
31
  ALL_DOCSTRING_NO_CONSUMER_CLAIM_PHRASES,
32
+ ALL_DOCSTRING_NO_INLINE_LITERAL_CLAIM_PHRASES,
33
+ ALL_DOCSTRING_NON_CONSTANT_REFERENCE_MARKERS,
34
+ ALL_GENERIC_CHECK_NAME_TOKENS,
35
+ ALL_NAMING_CONVENTION_DESCRIPTOR_TOKENS,
28
36
  DOCSTRING_FALLBACK_BRANCH_MINIMUM_ROUTE_COUNT,
37
+ DOCSTRING_REFERENCE_MARKER_WINDOW,
29
38
  DOCSTRING_TRIVIAL_FUNCTION_BODY_LINE_LIMIT,
30
39
  MAX_CLASS_DOCSTRING_PUBLIC_METHOD_ISSUES,
31
40
  MAX_DOCSTRING_ARGS_SIGNATURE_ISSUES,
32
41
  MAX_DOCSTRING_FALLBACK_BRANCH_ISSUES,
33
42
  MAX_DOCSTRING_FORMAT_ISSUES,
43
+ MAX_DOCSTRING_INLINE_LITERAL_CLAIM_ISSUES,
34
44
  MAX_DOCSTRING_NO_CONSUMER_CLAIM_ISSUES,
45
+ MAX_DOCSTRING_STEP_DISPATCH_ISSUES,
46
+ MAX_DOCSTRING_RETURNS_PLURAL_CARDINALITY_ISSUES,
47
+ MAX_DOCSTRING_TUPLE_ENUMERATION_ISSUES,
48
+ MAX_DOCSTRING_UNDEFINED_CONSTANT_ISSUES,
49
+ MAX_DOCSTRING_UNGUARDED_PAYLOAD_CLAIM_ISSUES,
50
+ MAX_MODULE_DOCSTRING_CHECK_ROSTER_ISSUES,
51
+ MINIMUM_NAMED_LINEAR_STEPS_FOR_DISPATCH_CHECK,
52
+ MINIMUM_PUBLIC_CHECKS_FOR_MODULE_DOCSTRING_ROSTER,
35
53
  MINIMUM_PUBLIC_METHODS_FOR_CLASS_DOCSTRING_BREADTH,
54
+ MINIMUM_TOKENS_FOR_DISPATCH_CALLEE,
55
+ MINIMUM_TUPLE_MEMBERS_FOR_DOCSTRING_ENUMERATION,
56
+ SINGLE_DICT_KEY_COUNT_FOR_PLURAL_CARDINALITY_DRIFT,
36
57
  )
37
58
  from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
59
+ ALL_CAPS_WITH_UNDERSCORE_PATTERN,
38
60
  ALL_DOCSTRING_ARGS_SECTION_HEADERS,
39
61
  ALL_DOCSTRING_TERMINATING_SECTION_HEADERS,
40
62
  ALL_SELF_AND_CLS_PARAMETER_NAMES,
41
63
  DOCSTRING_ARG_ENTRY_PATTERN,
64
+ DOCSTRING_PLURAL_FAMILY_STOP_PATTERN,
65
+ IDENTIFIER_SHAPED_TUPLE_MEMBER_PATTERN,
66
+ INLINE_CODE_TOKEN_PATTERN,
42
67
  )
43
68
 
44
69
 
@@ -619,3 +644,817 @@ def check_docstring_no_consumer_claim(content: str, file_path: str) -> list[str]
619
644
  if len(issues) >= MAX_DOCSTRING_NO_CONSUMER_CLAIM_ISSUES:
620
645
  break
621
646
  return issues[:MAX_DOCSTRING_NO_CONSUMER_CLAIM_ISSUES]
647
+
648
+
649
+ def _docstring_claims_malformed_payload_is_guarded(docstring_text: str) -> str:
650
+ collapsed_docstring = " ".join(docstring_text.lower().split())
651
+ for each_phrase in ALL_DOCSTRING_GUARDED_FAILURE_CLAIM_PHRASES:
652
+ if each_phrase in collapsed_docstring:
653
+ return each_phrase
654
+ return ""
655
+
656
+
657
+ def _try_handler_returns_none(try_node: ast.Try) -> bool:
658
+ for each_handler in try_node.handlers:
659
+ for each_statement in each_handler.body:
660
+ if isinstance(each_statement, ast.Return) and isinstance(
661
+ each_statement.value, ast.Constant
662
+ ):
663
+ if each_statement.value.value is None:
664
+ return True
665
+ return False
666
+
667
+
668
+ def _names_bound_in_try_body(try_node: ast.Try) -> set[str]:
669
+ all_bound_names: set[str] = set()
670
+ for each_statement in try_node.body:
671
+ for each_descendant in ast.walk(each_statement):
672
+ if isinstance(each_descendant, ast.Name) and isinstance(
673
+ each_descendant.ctx, ast.Store
674
+ ):
675
+ all_bound_names.add(each_descendant.id)
676
+ return all_bound_names
677
+
678
+
679
+ def _statement_subscripts_one_of(
680
+ statement: ast.stmt, all_payload_names: set[str]
681
+ ) -> bool:
682
+ for each_descendant in ast.walk(statement):
683
+ if (
684
+ isinstance(each_descendant, ast.Subscript)
685
+ and isinstance(each_descendant.value, ast.Name)
686
+ and each_descendant.value.id in all_payload_names
687
+ ):
688
+ return True
689
+ return False
690
+
691
+
692
+ def _function_has_unguarded_payload_dereference(
693
+ function_node: ast.FunctionDef | ast.AsyncFunctionDef,
694
+ ) -> bool:
695
+ all_payload_names: set[str] = set()
696
+ saw_returning_guard = False
697
+ for each_statement in function_node.body:
698
+ if isinstance(each_statement, ast.Try) and _try_handler_returns_none(
699
+ each_statement
700
+ ):
701
+ all_payload_names |= _names_bound_in_try_body(each_statement)
702
+ saw_returning_guard = True
703
+ continue
704
+ if not saw_returning_guard:
705
+ continue
706
+ if _statement_subscripts_one_of(each_statement, all_payload_names):
707
+ return True
708
+ return False
709
+
710
+
711
+ def check_docstring_unguarded_malformed_payload_claim(
712
+ content: str, file_path: str
713
+ ) -> list[str]:
714
+ """Flag a docstring that promises malformed-payload safety the guard misses.
715
+
716
+ The drift this catches: a function whose docstring states that a malformed
717
+ payload "resolves to None" while a subscript dereference of that payload
718
+ (``payload["key"]``, ``float(payload["key"])``) sits OUTSIDE the try/except
719
+ whose handler returns None. A present-but-malformed payload then raises
720
+ KeyError or TypeError from that unguarded access and propagates rather than
721
+ resolving to None, so the docstring overstates the protection. This is the
722
+ deterministic slice of Category O6 (docstring prose vs implementation drift)
723
+ for an exception-guard claim: move the dereference inside the guarded block,
724
+ or narrow the docstring to the failures the guard actually catches.
725
+
726
+ Args:
727
+ content: The source text to inspect.
728
+ file_path: The path the source will be written to, used for exemptions.
729
+
730
+ Returns:
731
+ One issue per function whose malformed-payload claim outruns its guard,
732
+ capped at the module limit.
733
+ """
734
+ if is_test_file(file_path) or is_hook_infrastructure(file_path):
735
+ return []
736
+ try:
737
+ parsed_tree = ast.parse(content)
738
+ except SyntaxError:
739
+ return []
740
+ issues: list[str] = []
741
+ for each_node in _walk_skipping_type_checking_blocks(parsed_tree):
742
+ if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
743
+ continue
744
+ if _function_has_exempt_decorator(each_node):
745
+ continue
746
+ docstring_text = _function_docstring_text(each_node)
747
+ if not docstring_text:
748
+ continue
749
+ matched_phrase = _docstring_claims_malformed_payload_is_guarded(docstring_text)
750
+ if not matched_phrase:
751
+ continue
752
+ if not _function_has_unguarded_payload_dereference(each_node):
753
+ continue
754
+ issues.append(
755
+ f"Line {each_node.lineno}: {each_node.name}() docstring claims "
756
+ f"'{matched_phrase}' but a payload subscript sits outside the try/except that "
757
+ "returns None — a malformed-but-present payload raises rather than resolving to "
758
+ "None; move the dereference inside the guard or narrow the docstring "
759
+ "(Category O6 docstring-vs-implementation drift)"
760
+ )
761
+ if len(issues) >= MAX_DOCSTRING_UNGUARDED_PAYLOAD_CLAIM_ISSUES:
762
+ break
763
+ return issues[:MAX_DOCSTRING_UNGUARDED_PAYLOAD_CLAIM_ISSUES]
764
+
765
+
766
+ def _module_docstring_claims_no_inline_literal(module_docstring: str) -> str:
767
+ collapsed_docstring = " ".join(module_docstring.lower().split())
768
+ for each_phrase in ALL_DOCSTRING_NO_INLINE_LITERAL_CLAIM_PHRASES:
769
+ if each_phrase in collapsed_docstring:
770
+ return each_phrase
771
+ return ""
772
+
773
+
774
+ def check_docstring_no_inline_literal_claim(content: str, file_path: str) -> list[str]:
775
+ """Flag a module docstring that asserts no literals appear inline elsewhere.
776
+
777
+ A constants-module docstring claiming "no literals appear inline in the
778
+ dispatcher" makes an unverifiable completeness claim about a companion file.
779
+ The claim drifts the moment a literal lands inline in that companion — a deny
780
+ or block reason left inline in the dispatcher contradicts the docstring even
781
+ though the constants file under edit never changed. This is the deterministic
782
+ slice of Category O6 (docstring prose vs implementation drift) and a
783
+ no-transitional-language violation in its own right: a docstring describes
784
+ what the module holds, not the absence of literals in a sibling. Rephrase to
785
+ state what the module centralizes, or drop the no-inline-literal sentence.
786
+
787
+ Args:
788
+ content: The source text to inspect.
789
+ file_path: The path the source will be written to, used for exemptions.
790
+
791
+ Returns:
792
+ One issue when the module docstring carries a no-inline-literal claim,
793
+ capped at the module limit.
794
+ """
795
+ if is_strict_test_file(file_path):
796
+ return []
797
+ try:
798
+ parsed_tree = ast.parse(content)
799
+ except SyntaxError:
800
+ return []
801
+ module_docstring = ast.get_docstring(parsed_tree) or ""
802
+ matched_phrase = _module_docstring_claims_no_inline_literal(module_docstring)
803
+ if not matched_phrase:
804
+ return []
805
+ issues = [
806
+ f"Line 1: module docstring claims '{matched_phrase}' about a companion file "
807
+ "— an unverifiable completeness claim that drifts the moment a literal lands "
808
+ "inline; state what the module centralizes instead (Category O6 docstring-vs-"
809
+ "implementation drift)"
810
+ ]
811
+ return issues[:MAX_DOCSTRING_INLINE_LITERAL_CLAIM_ISSUES]
812
+
813
+
814
+ def _module_docstring_summary_is_single_paragraph(module_docstring: str) -> bool:
815
+ stripped_text = module_docstring.strip()
816
+ if not stripped_text:
817
+ return False
818
+ return "\n" not in stripped_text
819
+
820
+
821
+ def _module_public_check_names(parsed_tree: ast.Module) -> list[str]:
822
+ deduplicated_names: dict[str, None] = {}
823
+ for each_statement in parsed_tree.body:
824
+ if not isinstance(each_statement, (ast.FunctionDef, ast.AsyncFunctionDef)):
825
+ continue
826
+ if not each_statement.name.startswith("check_"):
827
+ continue
828
+ if _function_is_private_or_dunder(each_statement.name):
829
+ continue
830
+ deduplicated_names[each_statement.name] = None
831
+ return list(deduplicated_names)
832
+
833
+
834
+ def _distinctive_name_tokens(check_name: str) -> list[str]:
835
+ return [
836
+ each_token
837
+ for each_token in _name_tokens(check_name)
838
+ if each_token.lower() not in ALL_GENERIC_CHECK_NAME_TOKENS
839
+ ]
840
+
841
+
842
+ def _docstring_mentions_check(docstring_text: str, check_name: str) -> bool:
843
+ lowered_docstring = docstring_text.lower()
844
+ if check_name.lower() in lowered_docstring:
845
+ return True
846
+ distinctive_tokens = _distinctive_name_tokens(check_name)
847
+ if not distinctive_tokens:
848
+ return True
849
+ return any(each_token.lower() in lowered_docstring for each_token in distinctive_tokens)
850
+
851
+
852
+ def check_module_docstring_names_public_checks(content: str, file_path: str) -> list[str]:
853
+ """Flag a one-line module docstring that omits a public ``check_*`` function.
854
+
855
+ A check-registry module whose docstring is a single summary paragraph names
856
+ each check it dispatches, so a reader trusts that one line to be the full
857
+ roster. When the module grows a public ``check_*`` entry point the summary
858
+ never names, the enumeration under-describes the module — the
859
+ docstring-prose-vs-implementation drift the repo flags as Category O6/O8.
860
+ A check counts as named when the full ``check_*`` name, or any distinctive
861
+ (non-generic) underscore-separated token of it, appears in the summary;
862
+ generic tokens (``check``, ``test``, ``tests``) do not count. A module with
863
+ two or more public checks and any check the summary never names is reported
864
+ so the summary names the full roster. Modules with a multi-paragraph
865
+ docstring body are left to the audit lane, since their prose can carry the
866
+ roster without naming each check by name. This check covers hook
867
+ infrastructure, where the affected check registries live.
868
+
869
+ Args:
870
+ content: The source text to inspect.
871
+ file_path: The path the source will be written to, used for exemptions.
872
+
873
+ Returns:
874
+ One issue per public check the single-paragraph module docstring omits,
875
+ capped at the module limit.
876
+ """
877
+ if is_strict_test_file(file_path):
878
+ return []
879
+ try:
880
+ parsed_tree = ast.parse(content)
881
+ except SyntaxError:
882
+ return []
883
+ module_docstring = ast.get_docstring(parsed_tree) or ""
884
+ if not _module_docstring_summary_is_single_paragraph(module_docstring):
885
+ return []
886
+ public_check_names = _module_public_check_names(parsed_tree)
887
+ if len(public_check_names) < MINIMUM_PUBLIC_CHECKS_FOR_MODULE_DOCSTRING_ROSTER:
888
+ return []
889
+ issues: list[str] = []
890
+ for each_name in public_check_names:
891
+ if _docstring_mentions_check(module_docstring, each_name):
892
+ continue
893
+ issues.append(
894
+ f"Line 1: module docstring omits public check {each_name}() — name every "
895
+ "public check_* function the module exposes so the roster is complete "
896
+ "(Category O6/O8 docstring-vs-implementation drift)"
897
+ )
898
+ if len(issues) >= MAX_MODULE_DOCSTRING_CHECK_ROSTER_ISSUES:
899
+ break
900
+ return issues[:MAX_MODULE_DOCSTRING_CHECK_ROSTER_ISSUES]
901
+
902
+
903
+ def _module_string_tuple_members(parsed_tree: ast.Module) -> dict[str, frozenset[str]]:
904
+ members_by_constant: dict[str, frozenset[str]] = {}
905
+ for each_statement in parsed_tree.body:
906
+ if not isinstance(each_statement, ast.Assign):
907
+ continue
908
+ if not isinstance(each_statement.value, ast.Tuple):
909
+ continue
910
+ literal_members: set[str] = set()
911
+ every_member_is_identifier_shaped = True
912
+ for each_element in each_statement.value.elts:
913
+ if (
914
+ isinstance(each_element, ast.Constant)
915
+ and isinstance(each_element.value, str)
916
+ and IDENTIFIER_SHAPED_TUPLE_MEMBER_PATTERN.match(each_element.value)
917
+ ):
918
+ literal_members.add(each_element.value.lstrip("."))
919
+ continue
920
+ every_member_is_identifier_shaped = False
921
+ break
922
+ if not every_member_is_identifier_shaped:
923
+ continue
924
+ if len(literal_members) < MINIMUM_TUPLE_MEMBERS_FOR_DOCSTRING_ENUMERATION:
925
+ continue
926
+ for each_target in each_statement.targets:
927
+ if isinstance(each_target, ast.Name):
928
+ members_by_constant[each_target.id] = frozenset(literal_members)
929
+ return members_by_constant
930
+
931
+
932
+ def _names_referenced_in_function(
933
+ function_node: ast.FunctionDef | ast.AsyncFunctionDef,
934
+ ) -> set[str]:
935
+ return {
936
+ each_node.id
937
+ for each_node in ast.walk(function_node)
938
+ if isinstance(each_node, ast.Name)
939
+ }
940
+
941
+
942
+ def _docstring_inline_code_tokens(docstring_text: str) -> set[str]:
943
+ tokens: set[str] = set()
944
+ for each_match in INLINE_CODE_TOKEN_PATTERN.finditer(docstring_text):
945
+ token = each_match.group(1).strip().lstrip(".")
946
+ if token:
947
+ tokens.add(token)
948
+ return tokens
949
+
950
+
951
+ def check_docstring_tuple_enumeration_match(content: str, file_path: str) -> list[str]:
952
+ """Flag a docstring enumeration that drifts from a literal tuple it reads.
953
+
954
+ The drift this catches: a function reads a module-level tuple of literal
955
+ string members and its docstring enumerates inline-code tokens that name
956
+ some of those members, but the enumerated set and the tuple membership
957
+ differ. A token the docstring lists that the tuple lacks, or a tuple member
958
+ the docstring omits, misleads a reader who trusts the prose enumeration to
959
+ match the detection set — the deterministic slice of Category O6
960
+ docstring-prose-vs-implementation drift. The check binds only when the
961
+ docstring's inline-code tokens overlap the tuple membership, so a docstring
962
+ that names unrelated attributes is left alone. This check covers hook
963
+ infrastructure, where the affected detection tuples live.
964
+
965
+ Args:
966
+ content: The source text to inspect.
967
+ file_path: The path the source will be written to, used for exemptions.
968
+
969
+ Returns:
970
+ One issue per function whose docstring enumeration diverges from the
971
+ tuple it reads, capped at the module limit.
972
+ """
973
+ if is_strict_test_file(file_path):
974
+ return []
975
+ try:
976
+ parsed_tree = ast.parse(content)
977
+ except SyntaxError:
978
+ return []
979
+ members_by_constant = _module_string_tuple_members(parsed_tree)
980
+ if not members_by_constant:
981
+ return []
982
+ issues: list[str] = []
983
+ for each_node in _walk_skipping_type_checking_blocks(parsed_tree):
984
+ if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
985
+ continue
986
+ docstring_text = _function_docstring_text(each_node)
987
+ if not docstring_text:
988
+ continue
989
+ docstring_tokens = _docstring_inline_code_tokens(docstring_text)
990
+ if not docstring_tokens:
991
+ continue
992
+ referenced_names = _names_referenced_in_function(each_node)
993
+ for each_constant_name in referenced_names & set(members_by_constant):
994
+ tuple_members = members_by_constant[each_constant_name]
995
+ if not (docstring_tokens & tuple_members):
996
+ continue
997
+ if docstring_tokens == tuple_members:
998
+ continue
999
+ docstring_only = sorted(docstring_tokens - tuple_members)
1000
+ tuple_only = sorted(tuple_members - docstring_tokens)
1001
+ issues.append(
1002
+ f"Line {each_node.lineno}: {each_node.name}() docstring enumerates "
1003
+ f"{sorted(docstring_tokens)} but {each_constant_name} holds "
1004
+ f"{sorted(tuple_members)} — docstring-only: {docstring_only}, "
1005
+ f"tuple-only: {tuple_only}; match the enumeration to the tuple "
1006
+ "(Category O6 docstring-vs-implementation drift)"
1007
+ )
1008
+ if len(issues) >= MAX_DOCSTRING_TUPLE_ENUMERATION_ISSUES:
1009
+ return issues[:MAX_DOCSTRING_TUPLE_ENUMERATION_ISSUES]
1010
+ return issues[:MAX_DOCSTRING_TUPLE_ENUMERATION_ISSUES]
1011
+
1012
+
1013
+ def _returns_section_text(docstring_text: str) -> str:
1014
+ docstring_lines = docstring_text.splitlines()
1015
+ returns_section_lines: list[str] = []
1016
+ inside_returns_section = False
1017
+ for each_line in docstring_lines:
1018
+ stripped_line = each_line.strip()
1019
+ if stripped_line in ("Returns:", "Yields:"):
1020
+ inside_returns_section = True
1021
+ continue
1022
+ if not inside_returns_section:
1023
+ continue
1024
+ if _is_docstring_terminating_section_header(stripped_line):
1025
+ break
1026
+ returns_section_lines.append(stripped_line)
1027
+ return " ".join(returns_section_lines)
1028
+
1029
+
1030
+ def _plural_families_in_returns_section(returns_section_text: str) -> set[str]:
1031
+ return {
1032
+ each_match.group(1)
1033
+ for each_match in DOCSTRING_PLURAL_FAMILY_STOP_PATTERN.finditer(returns_section_text)
1034
+ }
1035
+
1036
+
1037
+ def _returned_dict_key_names(
1038
+ function_node: ast.FunctionDef | ast.AsyncFunctionDef,
1039
+ ) -> set[str]:
1040
+ all_key_names: set[str] = set()
1041
+ for each_node in ast.walk(function_node):
1042
+ if not isinstance(each_node, ast.Return):
1043
+ continue
1044
+ if not isinstance(each_node.value, ast.Dict):
1045
+ continue
1046
+ for each_key in each_node.value.keys:
1047
+ if isinstance(each_key, ast.Constant) and isinstance(each_key.value, str):
1048
+ all_key_names.add(each_key.value)
1049
+ return all_key_names
1050
+
1051
+
1052
+ def _family_prefixed_key_count(family: str, all_key_names: set[str]) -> int:
1053
+ family_prefix = f"{family}_"
1054
+ return sum(1 for each_key in all_key_names if each_key.startswith(family_prefix))
1055
+
1056
+
1057
+ def check_docstring_returns_plural_cardinality(content: str, file_path: str) -> list[str]:
1058
+ """Flag a Returns clause plural noun that names one dict key in its family.
1059
+
1060
+ The drift this catches: a function returns a dict literal whose keys carry
1061
+ prefix families (``sheen_mid``, ``body_highlight``), and its Returns clause
1062
+ names one family with a plural noun (``the sheen stops``) while exactly one
1063
+ key in that family exists. The plural prose claims two or more entries the
1064
+ dict no longer holds — the shape that appears when a producer removes the
1065
+ second key in a family but leaves the plural prose untouched. The check binds
1066
+ only when the plural family prefixes exactly one returned dict key, so a
1067
+ singular noun, a family with two or more keys, and a family absent from the
1068
+ dict are all left alone. This is the deterministic single-key slice of
1069
+ Category O6 docstring-prose-vs-implementation drift.
1070
+
1071
+ Args:
1072
+ content: The source text to inspect.
1073
+ file_path: The path the source will be written to, used for exemptions.
1074
+
1075
+ Returns:
1076
+ One issue per function whose Returns clause names a plural family that
1077
+ prefixes a single returned dict key, capped at the module limit.
1078
+ """
1079
+ if is_test_file(file_path) or is_hook_infrastructure(file_path):
1080
+ return []
1081
+ try:
1082
+ parsed_tree = ast.parse(content)
1083
+ except SyntaxError:
1084
+ return []
1085
+ issues: list[str] = []
1086
+ for each_node in _walk_skipping_type_checking_blocks(parsed_tree):
1087
+ if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
1088
+ continue
1089
+ docstring_text = _function_docstring_text(each_node)
1090
+ if not docstring_text:
1091
+ continue
1092
+ returns_section_text = _returns_section_text(docstring_text)
1093
+ if not returns_section_text:
1094
+ continue
1095
+ plural_families = _plural_families_in_returns_section(returns_section_text)
1096
+ if not plural_families:
1097
+ continue
1098
+ all_key_names = _returned_dict_key_names(each_node)
1099
+ for each_family in sorted(plural_families):
1100
+ matching_key_count = _family_prefixed_key_count(each_family, all_key_names)
1101
+ if matching_key_count != SINGLE_DICT_KEY_COUNT_FOR_PLURAL_CARDINALITY_DRIFT:
1102
+ continue
1103
+ issues.append(
1104
+ f"Line {each_node.lineno}: {each_node.name}() Returns clause says "
1105
+ f"'the {each_family} stops' (plural) but the returned dict holds a "
1106
+ f"single {each_family}_ key — match the noun to the cardinality "
1107
+ "(Category O6 docstring-vs-implementation drift)"
1108
+ )
1109
+ if len(issues) >= MAX_DOCSTRING_RETURNS_PLURAL_CARDINALITY_ISSUES:
1110
+ return issues[:MAX_DOCSTRING_RETURNS_PLURAL_CARDINALITY_ISSUES]
1111
+ return issues[:MAX_DOCSTRING_RETURNS_PLURAL_CARDINALITY_ISSUES]
1112
+
1113
+
1114
+ def _called_callee_name(statement: ast.stmt) -> str:
1115
+ candidate_expression: ast.expr | None = None
1116
+ if isinstance(statement, ast.Expr):
1117
+ candidate_expression = statement.value
1118
+ elif isinstance(statement, ast.Assign):
1119
+ candidate_expression = statement.value
1120
+ elif isinstance(statement, ast.AnnAssign):
1121
+ candidate_expression = statement.value
1122
+ if candidate_expression is None:
1123
+ return ""
1124
+ if isinstance(candidate_expression, ast.Await):
1125
+ candidate_expression = candidate_expression.value
1126
+ if not isinstance(candidate_expression, ast.Call):
1127
+ return ""
1128
+ return _call_callee_name(candidate_expression)
1129
+
1130
+
1131
+ def _called_callees_in_expression(expression: ast.expr) -> set[str]:
1132
+ callees: set[str] = set()
1133
+ for each_descendant in ast.walk(expression):
1134
+ if not isinstance(each_descendant, ast.Call):
1135
+ continue
1136
+ callee_name = _call_callee_name(each_descendant)
1137
+ if callee_name:
1138
+ callees.add(callee_name)
1139
+ return callees
1140
+
1141
+
1142
+ def _linear_step_callees(
1143
+ function_node: ast.FunctionDef | ast.AsyncFunctionDef,
1144
+ ) -> set[str]:
1145
+ callees: set[str] = set()
1146
+ for each_statement in function_node.body:
1147
+ callee_name = _called_callee_name(each_statement)
1148
+ if callee_name:
1149
+ callees.add(callee_name)
1150
+ if isinstance(each_statement, ast.If):
1151
+ callees |= _called_callees_in_expression(each_statement.test)
1152
+ return callees
1153
+
1154
+
1155
+ def _branch_guarded_dispatch_callees(
1156
+ function_node: ast.FunctionDef | ast.AsyncFunctionDef,
1157
+ ) -> set[str]:
1158
+ callees: set[str] = set()
1159
+ for each_statement in function_node.body:
1160
+ if not isinstance(each_statement, ast.If):
1161
+ continue
1162
+ for each_branch_statement in each_statement.body + each_statement.orelse:
1163
+ for each_descendant in ast.walk(each_branch_statement):
1164
+ if not isinstance(each_descendant, ast.If):
1165
+ continue
1166
+ callees |= _called_callees_in_expression(each_descendant.test)
1167
+ return callees
1168
+
1169
+
1170
+ def _bare_callee_name(qualified_callee: str) -> str:
1171
+ return qualified_callee.rsplit(".", 1)[-1]
1172
+
1173
+
1174
+ def _docstring_names_all_callee_tokens(docstring_text: str, callee_name: str) -> bool:
1175
+ bare_name = _bare_callee_name(callee_name)
1176
+ lowered_docstring = docstring_text.lower()
1177
+ if bare_name.lower() in lowered_docstring:
1178
+ return True
1179
+ callee_tokens = _name_tokens(bare_name)
1180
+ if not callee_tokens:
1181
+ return False
1182
+ return all(each_token.lower() in lowered_docstring for each_token in callee_tokens)
1183
+
1184
+
1185
+ def check_docstring_step_enumeration_dispatch_coverage(
1186
+ content: str, file_path: str
1187
+ ) -> list[str]:
1188
+ """Flag a step-enumeration docstring that omits a conditional dispatch call.
1189
+
1190
+ The drift this catches: a function whose docstring enumerates a linear
1191
+ sequence of steps (``Navigates ..., searches ..., clicks ..., uploads ...``)
1192
+ matching the body's linear-step calls, while the body also routes to a
1193
+ corrective workflow step inside an ``if``/``elif`` branch — a cancel-and-reinitiate
1194
+ or replace-target-row step — whose name the prose never spells out. A reader
1195
+ who trusts the step list to be complete misses that the function can take that
1196
+ conditional path. This is the deterministic slice of Category O4 (step-ordering
1197
+ narrative): a body that guards a branch-only workflow step the enumeration omits.
1198
+
1199
+ A linear-step call is one made as a top-level statement or inside the ``If.test``
1200
+ guard of a top-level ``if`` (``if not await self.navigate(): return``). A
1201
+ dispatch step is a call inside a guard (``If.test``) nested within an
1202
+ ``if``/``elif`` branch (``if not await cancel_and_reinitiate_update(...): return``)
1203
+ that is never also a linear step — the same control-flow-gating shape as a
1204
+ linear step, so plain (unguarded) logging, screenshot, or method-on-local
1205
+ calls inside a branch body are not dispatch steps. The check binds only when
1206
+ the docstring already names two or more linear-step callees by their
1207
+ underscore tokens, proving the prose is a step enumeration describing this
1208
+ body. A dispatch-step callee with two or more underscore tokens, none of
1209
+ whose tokens appear in the prose, is flagged.
1210
+
1211
+ Args:
1212
+ content: The source text to inspect.
1213
+ file_path: The path the source will be written to, used for exemptions.
1214
+
1215
+ Returns:
1216
+ One issue per conditional dispatch call the step enumeration omits, capped
1217
+ at the module limit.
1218
+ """
1219
+ if is_test_file(file_path) or is_hook_infrastructure(file_path):
1220
+ return []
1221
+ try:
1222
+ parsed_tree = ast.parse(content)
1223
+ except SyntaxError:
1224
+ return []
1225
+ issues: list[str] = []
1226
+ for each_node in _walk_skipping_type_checking_blocks(parsed_tree):
1227
+ if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
1228
+ continue
1229
+ if _function_has_exempt_decorator(each_node):
1230
+ continue
1231
+ docstring_text = _function_docstring_text(each_node)
1232
+ if not docstring_text:
1233
+ continue
1234
+ linear_step_callees = _linear_step_callees(each_node)
1235
+ named_linear_steps = [
1236
+ each_callee
1237
+ for each_callee in linear_step_callees
1238
+ if _docstring_names_all_callee_tokens(docstring_text, each_callee)
1239
+ ]
1240
+ if len(named_linear_steps) < MINIMUM_NAMED_LINEAR_STEPS_FOR_DISPATCH_CHECK:
1241
+ continue
1242
+ branch_only_callees = (
1243
+ _branch_guarded_dispatch_callees(each_node) - linear_step_callees
1244
+ )
1245
+ for each_callee in sorted(branch_only_callees):
1246
+ if len(_name_tokens(_bare_callee_name(each_callee))) < MINIMUM_TOKENS_FOR_DISPATCH_CALLEE:
1247
+ continue
1248
+ if _docstring_names_all_callee_tokens(docstring_text, each_callee):
1249
+ continue
1250
+ issues.append(
1251
+ f"Line {each_node.lineno}: {each_node.name}() docstring enumerates linear "
1252
+ f"steps but omits the conditional dispatch step {each_callee}() the body "
1253
+ "guards inside a branch — add the corrective-path step to the enumeration "
1254
+ "(Category O4 step-ordering narrative drift)"
1255
+ )
1256
+ if len(issues) >= MAX_DOCSTRING_STEP_DISPATCH_ISSUES:
1257
+ return issues[:MAX_DOCSTRING_STEP_DISPATCH_ISSUES]
1258
+ return issues[:MAX_DOCSTRING_STEP_DISPATCH_ISSUES]
1259
+
1260
+
1261
+ def _imported_binding_names(import_node: ast.Import | ast.ImportFrom) -> set[str]:
1262
+ bound_names: set[str] = set()
1263
+ for each_alias in import_node.names:
1264
+ bound_names.add(each_alias.asname or each_alias.name.split(".", 1)[0])
1265
+ return bound_names
1266
+
1267
+
1268
+ def _module_defined_and_imported_names(parsed_tree: ast.Module) -> set[str]:
1269
+ defined_names: set[str] = set()
1270
+ for each_node in ast.walk(parsed_tree):
1271
+ if isinstance(each_node, (ast.Import, ast.ImportFrom)):
1272
+ defined_names |= _imported_binding_names(each_node)
1273
+ elif isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
1274
+ defined_names.add(each_node.name)
1275
+ elif isinstance(each_node, ast.Name) and isinstance(each_node.ctx, ast.Store):
1276
+ defined_names.add(each_node.id)
1277
+ return defined_names
1278
+
1279
+
1280
+ def _module_attribute_access_names(parsed_tree: ast.Module) -> set[str]:
1281
+ attribute_names: set[str] = set()
1282
+ for each_node in ast.walk(parsed_tree):
1283
+ if isinstance(each_node, ast.Attribute):
1284
+ attribute_names.add(each_node.attr)
1285
+ return attribute_names
1286
+
1287
+
1288
+ def _docstring_constant_node_ids(parsed_tree: ast.Module) -> set[int]:
1289
+ docstring_node_ids: set[int] = set()
1290
+ for each_node in ast.walk(parsed_tree):
1291
+ if not isinstance(
1292
+ each_node, (ast.Module, ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)
1293
+ ):
1294
+ continue
1295
+ body_statements = each_node.body
1296
+ if not body_statements or not _statement_is_docstring(body_statements[0]):
1297
+ continue
1298
+ first_statement = body_statements[0]
1299
+ assert isinstance(first_statement, ast.Expr)
1300
+ docstring_node_ids.add(id(first_statement.value))
1301
+ return docstring_node_ids
1302
+
1303
+
1304
+ def _module_string_literal_word_runs(parsed_tree: ast.Module) -> set[str]:
1305
+ docstring_node_ids = _docstring_constant_node_ids(parsed_tree)
1306
+ word_runs: set[str] = set()
1307
+ for each_node in ast.walk(parsed_tree):
1308
+ if not (isinstance(each_node, ast.Constant) and isinstance(each_node.value, str)):
1309
+ continue
1310
+ if id(each_node) in docstring_node_ids:
1311
+ continue
1312
+ for each_run in re.findall(r"[A-Za-z0-9_]+", each_node.value):
1313
+ if ALL_CAPS_WITH_UNDERSCORE_PATTERN.match(each_run):
1314
+ word_runs.add(each_run)
1315
+ return word_runs
1316
+
1317
+
1318
+ def _name_word_prefix_families(all_supporting_names: set[str]) -> set[str]:
1319
+ prefix_families: set[str] = set()
1320
+ for each_name in all_supporting_names:
1321
+ leading_word = each_name.split("_", 1)[0]
1322
+ prefix_families.add(leading_word)
1323
+ return prefix_families
1324
+
1325
+
1326
+ def _token_is_word_run_of_any_name(token: str, all_supporting_names: set[str]) -> bool:
1327
+ return any(f"_{token}_" in f"_{each_name}_" for each_name in all_supporting_names)
1328
+
1329
+
1330
+ def _docstring_words(docstring_text: str) -> list[str]:
1331
+ return [
1332
+ each_word.strip(".,:;()[]{}'\"`")
1333
+ for each_word in docstring_text.replace("`", " ").split()
1334
+ ]
1335
+
1336
+
1337
+ def _docstring_frames_token_as_non_constant_reference(
1338
+ token: str, docstring_text: str
1339
+ ) -> bool:
1340
+ if any(
1341
+ f"{token}{each_suffix}" in docstring_text
1342
+ for each_suffix in ALL_DOCSTRING_FILE_REFERENCE_SUFFIXES
1343
+ ):
1344
+ return True
1345
+ words = _docstring_words(docstring_text)
1346
+ for each_index, each_word in enumerate(words):
1347
+ if each_word != token:
1348
+ continue
1349
+ neighbors = words[max(each_index - DOCSTRING_REFERENCE_MARKER_WINDOW, 0) : each_index + DOCSTRING_REFERENCE_MARKER_WINDOW + 1]
1350
+ if any(
1351
+ each_neighbor.lower() in ALL_DOCSTRING_NON_CONSTANT_REFERENCE_MARKERS
1352
+ for each_neighbor in neighbors
1353
+ ):
1354
+ return True
1355
+ return False
1356
+
1357
+
1358
+ def _docstring_constant_token_is_supported(
1359
+ token: str, parsed_tree: ast.Module, all_known_names: set[str], docstring_text: str
1360
+ ) -> bool:
1361
+ supporting_predicates = (
1362
+ lambda: token in all_known_names,
1363
+ lambda: token in ALL_NAMING_CONVENTION_DESCRIPTOR_TOKENS,
1364
+ lambda: token in _module_attribute_access_names(parsed_tree),
1365
+ lambda: token in _module_string_literal_word_runs(parsed_tree),
1366
+ lambda: _token_is_word_run_of_any_name(token, all_known_names),
1367
+ lambda: _docstring_frames_token_as_non_constant_reference(token, docstring_text),
1368
+ lambda: token.split("_", 1)[0] in _name_word_prefix_families(all_known_names),
1369
+ )
1370
+ return any(each_predicate() for each_predicate in supporting_predicates)
1371
+
1372
+
1373
+ def _docstring_constant_tokens(docstring_text: str) -> set[str]:
1374
+ candidate_tokens: set[str] = set()
1375
+ for each_word in docstring_text.replace("`", " ").split():
1376
+ stripped_word = each_word.strip(".,:;()[]{}'\"")
1377
+ if stripped_word.startswith("__") and stripped_word.endswith("__"):
1378
+ continue
1379
+ if ALL_CAPS_WITH_UNDERSCORE_PATTERN.match(stripped_word):
1380
+ candidate_tokens.add(stripped_word)
1381
+ return candidate_tokens
1382
+
1383
+
1384
+ def _documentable_nodes_with_docstrings(
1385
+ parsed_tree: ast.Module,
1386
+ ) -> list[tuple[int, str]]:
1387
+ documentable: list[tuple[int, str]] = []
1388
+ module_docstring = ast.get_docstring(parsed_tree)
1389
+ if module_docstring:
1390
+ documentable.append((1, module_docstring))
1391
+ for each_node in _walk_skipping_type_checking_blocks(parsed_tree):
1392
+ if not isinstance(
1393
+ each_node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)
1394
+ ):
1395
+ continue
1396
+ node_docstring = ast.get_docstring(each_node)
1397
+ if node_docstring:
1398
+ documentable.append((each_node.lineno, node_docstring))
1399
+ return documentable
1400
+
1401
+
1402
+ def check_docstring_names_undefined_constant(content: str, file_path: str) -> list[str]:
1403
+ """Flag a docstring naming an UPPER_SNAKE constant nothing in the module backs.
1404
+
1405
+ The drift this catches: a docstring names an all-caps, underscore-joined
1406
+ token as a contract identifier (``NATIVE_EVALUATE_FUNCTION_NAME``) while the
1407
+ enclosing module carries no supporting reference for it. A reader who trusts
1408
+ the docstring to name a real symbol finds nothing — the deterministic slice
1409
+ of Category O6 docstring-prose-vs-implementation drift where the named token
1410
+ is structurally a constant and unresolvable against the module.
1411
+
1412
+ A token counts as supported, and is left alone, when any of these holds: it
1413
+ is defined at module scope or imported; it is a naming-convention descriptor
1414
+ (``UPPER_SNAKE_CASE`` and its siblings, describing a style, not a symbol); it
1415
+ is the attribute of an attribute access in the body (``os.O_NOFOLLOW``,
1416
+ ``config.timing.MAX_DELAY``, resolving stdlib and dotted-import constants);
1417
+ it is an all-caps word run inside a string literal (an env-var key read via
1418
+ ``os.environ[...]`` or ``os.getenv(...)``, an API enum string value, a doc
1419
+ stem in ``CODE_RULES.md``); it is a contiguous word run of a defined or
1420
+ imported name (``GH_TOKEN`` within ``ALL_GH_TOKEN_ENV_VAR_NAMES``); it shares
1421
+ a leading word component with a defined or imported name, marking the same
1422
+ enum family (``MODE_CLASSIFY`` beside an imported ``MODE_STRICT``); or the
1423
+ docstring prose frames it as a non-constant reference — followed by a file
1424
+ suffix (``CODE_RULES.md``) or sitting within two words of a marker such as
1425
+ ``rule``, ``doc``, ``file``, ``env``, ``variable``, ``set``, ``read``,
1426
+ ``per``, ``follows``, or ``see`` (``per CODE_RULES``, ``LLM_SETTINGS_ROOT is
1427
+ set to``). Single-segment all-caps acronyms (``HTTP``, ``JSON``) and dunder
1428
+ names (``__all__``) are not constants and are left alone.
1429
+
1430
+ Args:
1431
+ content: The source text to inspect.
1432
+ file_path: The path the source will be written to, used for exemptions.
1433
+
1434
+ Returns:
1435
+ One issue per docstring token that no module reference backs, capped at
1436
+ the module limit.
1437
+ """
1438
+ if is_test_file(file_path) or is_hook_infrastructure(file_path):
1439
+ return []
1440
+ try:
1441
+ parsed_tree = ast.parse(content)
1442
+ except SyntaxError:
1443
+ return []
1444
+ known_names = _module_defined_and_imported_names(parsed_tree)
1445
+ issues: list[str] = []
1446
+ for each_line_number, each_docstring in _documentable_nodes_with_docstrings(parsed_tree):
1447
+ for each_token in sorted(_docstring_constant_tokens(each_docstring)):
1448
+ if _docstring_constant_token_is_supported(
1449
+ each_token, parsed_tree, known_names, each_docstring
1450
+ ):
1451
+ continue
1452
+ issues.append(
1453
+ f"Line {each_line_number}: docstring names '{each_token}' which the "
1454
+ "module neither defines at module scope nor imports — name the real "
1455
+ "symbol or drop the reference (Category O6 docstring-vs-implementation "
1456
+ "drift)"
1457
+ )
1458
+ if len(issues) >= MAX_DOCSTRING_UNDEFINED_CONSTANT_ISSUES:
1459
+ return issues[:MAX_DOCSTRING_UNDEFINED_CONSTANT_ISSUES]
1460
+ return issues[:MAX_DOCSTRING_UNDEFINED_CONSTANT_ISSUES]