claude-dev-env 1.81.0 → 1.83.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 (74) hide show
  1. package/CLAUDE.md +16 -13
  2. package/_shared/pr-loop/scripts/CLAUDE.md +1 -0
  3. package/_shared/pr-loop/scripts/README.md +1 -0
  4. package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -0
  5. package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviewer_availability_constants.py +12 -0
  6. package/_shared/pr-loop/scripts/pr_loop_shared_constants/terminology_sweep_constants.py +0 -2
  7. package/_shared/pr-loop/scripts/reviewer_availability.py +182 -0
  8. package/_shared/pr-loop/scripts/terminology_sweep.py +9 -33
  9. package/_shared/pr-loop/scripts/tests/CLAUDE.md +2 -0
  10. package/_shared/pr-loop/scripts/tests/test_reviewer_availability.py +159 -0
  11. package/_shared/pr-loop/scripts/tests/test_reviewer_availability_constants.py +36 -0
  12. package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +14 -4
  13. package/hooks/blocking/CLAUDE.md +3 -0
  14. package/hooks/blocking/code_rules_constants_config.py +159 -1
  15. package/hooks/blocking/code_rules_docstrings.py +312 -9
  16. package/hooks/blocking/code_rules_enforcer.py +29 -0
  17. package/hooks/blocking/code_rules_imports_logging.py +867 -1
  18. package/hooks/blocking/code_rules_naming_collection.py +141 -0
  19. package/hooks/blocking/code_rules_string_magic.py +68 -0
  20. package/hooks/blocking/conventional_pr_title_gate.py +444 -0
  21. package/hooks/blocking/pre_tool_use_dispatcher.py +3 -3
  22. package/hooks/blocking/reviewer_spawn_gate.py +182 -0
  23. package/hooks/blocking/stale_comment_reference_blocker.py +267 -0
  24. package/hooks/blocking/state_description_blocker.py +96 -5
  25. package/hooks/blocking/test_code_rules_config_duplicate_path_anchor.py +132 -0
  26. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +2 -0
  27. package/hooks/blocking/test_code_rules_enforcer_docstring_delegation_summary.py +385 -0
  28. package/hooks/blocking/test_code_rules_enforcer_join_separator_magic.py +67 -0
  29. package/hooks/blocking/test_code_rules_enforcer_module_docstring_roster.py +40 -0
  30. package/hooks/blocking/test_code_rules_enforcer_naive_datetime.py +213 -0
  31. package/hooks/blocking/test_code_rules_enforcer_referenced_underscore_loop.py +169 -0
  32. package/hooks/blocking/test_code_rules_js_bare_flag_return_directive.py +266 -0
  33. package/hooks/blocking/test_code_rules_js_sibling_return_object_key_drift.py +490 -0
  34. package/hooks/blocking/test_code_rules_logging_adjacent_literals.py +171 -0
  35. package/hooks/blocking/test_conventional_pr_title_gate.py +640 -0
  36. package/hooks/blocking/test_pre_tool_use_dispatcher.py +9 -3
  37. package/hooks/blocking/test_reviewer_spawn_gate.py +230 -0
  38. package/hooks/blocking/test_stale_comment_reference_blocker.py +236 -0
  39. package/hooks/blocking/test_state_description_blocker.py +135 -0
  40. package/hooks/hooks.json +10 -0
  41. package/hooks/hooks_constants/CLAUDE.md +4 -1
  42. package/hooks/hooks_constants/blocking_check_limits.py +43 -0
  43. package/hooks/hooks_constants/code_rules_enforcer_constants.py +41 -0
  44. package/hooks/hooks_constants/conventional_pr_title_gate_constants.py +58 -0
  45. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
  46. package/hooks/hooks_constants/reviewer_spawn_gate_constants.py +41 -0
  47. package/hooks/hooks_constants/stale_comment_reference_blocker_constants.py +76 -0
  48. package/hooks/hooks_constants/state_description_blocker_constants.py +8 -0
  49. package/package.json +1 -1
  50. package/rules/CLAUDE.md +4 -1
  51. package/rules/claude-md-orphan-file.md +5 -0
  52. package/rules/docstring-prose-matches-implementation.md +10 -1
  53. package/rules/env-var-table-code-drift.md +5 -0
  54. package/rules/es-exe-file-search.md +17 -0
  55. package/rules/no-historical-clutter.md +12 -1
  56. package/rules/orphan-css-class.md +5 -0
  57. package/rules/package-inventory-stale-entry.md +10 -0
  58. package/rules/paired-test-coverage.md +5 -0
  59. package/rules/plain-illustrative-docstrings.md +5 -0
  60. package/rules/verify-before-asking.md +7 -0
  61. package/rules/verify-runtime-state.md +40 -0
  62. package/rules/windows-filesystem-safe.md +8 -0
  63. package/rules/workers-done-before-complete.md +33 -0
  64. package/rules/workflow-substitution-slots.md +5 -0
  65. package/skills/CLAUDE.md +1 -1
  66. package/skills/autoconverge/SKILL.md +96 -5
  67. package/skills/autoconverge/reference/gotchas.md +5 -5
  68. package/skills/autoconverge/workflow/converge.contract.test.mjs +228 -27
  69. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +154 -21
  70. package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +2 -2
  71. package/skills/autoconverge/workflow/converge.mjs +153 -49
  72. package/skills/autoconverge/workflow/converge_multi.mjs +6 -2
  73. package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +26 -0
  74. package/skills/everything-search/SKILL.md +5 -0
@@ -1,4 +1,4 @@
1
- """Imports-at-top, import-block-sorted, logging f-string, win32gui None, E2E spec naming, JS resume-task enumeration coverage, JS returns-object schema-less branch, file-length advisory, and library-print checks."""
1
+ """Imports-at-top, import-block-sorted, logging f-string, logging printf-token, win32gui None, naive datetime construction, E2E spec naming, JS resume-task enumeration coverage, JS returns-object schema-less branch, JS sibling return-object key drift, JS bare-flag return-directive, file-length advisory, and library-print checks."""
2
2
 
3
3
  import ast
4
4
  import json
@@ -26,50 +26,84 @@ from code_rules_shared import ( # noqa: E402
26
26
  )
27
27
 
28
28
  from hooks_constants.blocking_check_limits import ( # noqa: E402
29
+ ALL_ALWAYS_NAIVE_DATETIME_CONSTRUCTORS,
29
30
  ALL_FORMAT_LOGGER_FUNCTION_NAMES,
30
31
  ALL_IMPORT_BLOCK_SORT_RUFF_COMMAND_PREFIX,
31
32
  ALL_RUFF_STANDALONE_CONFIG_FILENAMES,
33
+ DATETIME_CLASS_ATTRIBUTE_NAME,
34
+ FROMTIMESTAMP_POSITIONAL_TIMEZONE_ARGUMENT_COUNT,
32
35
  IMPORT_BLOCK_SORT_RUFF_TIMEOUT_SECONDS,
33
36
  IMPORT_BLOCK_SORT_RULE_CODE,
34
37
  MAX_E2E_TEST_NAMING_ISSUES,
35
38
  MAX_IMPORT_BLOCK_SORT_ISSUES,
39
+ MAX_JS_BARE_FLAG_RETURN_DIRECTIVE_ISSUES,
36
40
  MAX_JS_RESUME_TASK_ENUMERATION_ISSUES,
37
41
  MAX_JS_RETURNS_OBJECT_SCHEMALESS_ISSUES,
42
+ MAX_JS_SIBLING_RETURN_OBJECT_KEY_DRIFT_ISSUES,
43
+ MAX_LOGGING_ADJACENT_LITERAL_ISSUES,
38
44
  MAX_LOGGING_FSTRING_ISSUES,
39
45
  MAX_LOGGING_PRINTF_TOKEN_ISSUES,
46
+ MAX_NAIVE_DATETIME_ISSUES,
40
47
  MAX_WINDOWS_API_NONE_ISSUES,
41
48
  MINIMUM_RESUME_TASK_ENUMERATION_ITEMS,
49
+ MINIMUM_SIBLING_RETURN_OBJECT_KEYS,
50
+ NAIVE_DATETIME_FROMTIMESTAMP_CONSTRUCTOR,
51
+ NAIVE_DATETIME_TIMEZONE_KEYWORD,
52
+ NAIVE_DATETIME_UTCFROMTIMESTAMP_CONSTRUCTOR,
53
+ NAIVE_DATETIME_UTCNOW_CONSTRUCTOR,
42
54
  RUFF_PYPROJECT_CONFIG_FILENAME,
43
55
  RUFF_PYPROJECT_TOOL_TABLE_MARKER,
44
56
  RUFF_STDIN_ENCODING,
57
+ SIBLING_RETURN_OBJECT_EXACT_MISSING_KEY_COUNT,
58
+ SIBLING_RETURN_OBJECT_SINGLE_AGREED_MISSING_KEY_COUNT,
45
59
  )
46
60
  from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
61
+ ADJACENT_STRING_LITERAL_PATTERN,
47
62
  ADVISORY_LINE_THRESHOLD_HARD,
48
63
  ADVISORY_LINE_THRESHOLD_SOFT,
49
64
  ALL_CLI_FILE_PATH_MARKERS,
50
65
  ALL_IMPORT_STATEMENT_PREFIXES,
66
+ ALL_JAVASCRIPT_BARE_RETURN_TYPE_EXTRA_CHARACTERS,
67
+ ALL_JAVASCRIPT_BRACKET_CLOSERS,
68
+ ALL_JAVASCRIPT_BRACKET_OPENERS,
69
+ ALL_JAVASCRIPT_CONTROL_FLOW_BLOCK_KEYWORDS,
51
70
  ALL_JAVASCRIPT_EXTENSIONS,
71
+ ALL_JAVASCRIPT_IDENTIFIER_EXTRA_CHARACTERS,
72
+ ALL_JAVASCRIPT_PARENTHESIS_FREE_BLOCK_KEYWORDS,
52
73
  ALL_JAVASCRIPT_REGEX_PRECEDING_CHARACTERS,
53
74
  ALL_JAVASCRIPT_REGEX_PRECEDING_KEYWORDS,
75
+ ALL_JAVASCRIPT_RETURN_TYPE_TERMINATORS,
54
76
  ALL_JAVASCRIPT_STRING_DELIMITERS,
77
+ ALL_LOGGING_CALL_METHOD_NAMES,
55
78
  ALL_PYTHON_EXTENSIONS,
79
+ ARROW_CONCISE_BODY_OBJECT_LITERAL_OPENING_PATTERN,
80
+ BARE_FLAG_CONTRACT_PATTERN,
81
+ BARE_FLAG_RETURN_DIRECTIVE_PATTERN,
56
82
  ENUMERATION_LEADING_CONJUNCTION_PATTERN,
57
83
  ENUMERATION_LIST_ITEM_SEPARATOR_PATTERN,
58
84
  ENUMERATION_TASK_ITEM_PATTERN,
59
85
  FUNCTION_WITH_JSDOC_PATTERN,
60
86
  HYPHENATED_TASK_ITEM_PATTERN,
87
+ JAVASCRIPT_ARROW_TOKEN_LENGTH,
61
88
  JAVASCRIPT_BLOCK_COMMENT_CLOSER,
62
89
  JAVASCRIPT_BLOCK_COMMENT_OPENER,
63
90
  JAVASCRIPT_LINE_COMMENT_OPENER,
91
+ JAVASCRIPT_MODULE_SCOPE_SENTINEL,
92
+ JAVASCRIPT_OBJECT_SPREAD_PREFIX,
64
93
  JAVASCRIPT_REGEX_DELIMITER,
65
94
  JAVASCRIPT_STRING_ESCAPE_CHARACTER,
95
+ JS_OBJECT_KEY_IDENTIFIER_PATTERN,
96
+ JS_OBJECT_METHOD_SHORTHAND_KEY_PATTERN,
66
97
  JSDOC_RETURNS_STRUCTURED_OBJECT_PROMISE_PATTERN,
67
98
  LOGGING_FSTRING_PATTERN,
99
+ LOGGING_HELPER_FUNCTION_NAME_PATTERN,
68
100
  LOGGING_PRINTF_TOKEN_PATTERN,
101
+ LOGGING_RECEIVER_NAME_PATTERN,
69
102
  MINIMUM_FORMAT_LOGGER_ARGUMENT_COUNT,
70
103
  NOT_INSIDE_TYPE_CHECKING_BLOCK,
71
104
  RESUME_TASK_ENUMERATION_PATTERN,
72
105
  RETURN_CALL_OPENING_PARENTHESIS_PATTERN,
106
+ RETURN_OBJECT_LITERAL_OPENING_PATTERN,
73
107
  SCHEMA_OPTIONS_PROPERTY_KEY_PATTERN,
74
108
  SPAWN_AGENT_WITH_JSDOC_PATTERN,
75
109
  TASK_DISPATCH_NAME_PATTERN,
@@ -562,6 +596,84 @@ def check_logging_printf_tokens(content: str, file_path: str) -> list[str]:
562
596
  return issues
563
597
 
564
598
 
599
+ def _logging_call_receiver_name(receiver_node: ast.expr) -> str | None:
600
+ """Return the trailing identifier of a logging-call receiver expression, else None.
601
+
602
+ A bare name (``logger.info(...)``) returns its own identifier; an
603
+ attribute chain (``self.logger.info(...)``) returns the attribute
604
+ closest to the call. Any other receiver shape (a call, a subscript, ...)
605
+ returns None, since neither shape names a plain logger reference.
606
+ """
607
+ if isinstance(receiver_node, ast.Name):
608
+ return receiver_node.id
609
+ if isinstance(receiver_node, ast.Attribute):
610
+ return receiver_node.attr
611
+ return None
612
+
613
+
614
+ def _is_logging_call_node(call_node: ast.Call) -> bool:
615
+ """Return True when call_node is a real logging call: a log_* helper or a logger.<method>(...) call."""
616
+ function_reference = call_node.func
617
+ if isinstance(function_reference, ast.Name):
618
+ return bool(LOGGING_HELPER_FUNCTION_NAME_PATTERN.match(function_reference.id))
619
+ if isinstance(function_reference, ast.Attribute):
620
+ if function_reference.attr not in ALL_LOGGING_CALL_METHOD_NAMES:
621
+ return False
622
+ receiver_name = _logging_call_receiver_name(function_reference.value)
623
+ return receiver_name is not None and bool(
624
+ LOGGING_RECEIVER_NAME_PATTERN.match(receiver_name)
625
+ )
626
+ return False
627
+
628
+
629
+ def check_logging_adjacent_string_literals(content: str, file_path: str) -> list[str]:
630
+ """Flag implicit adjacent string-literal concatenation inside a logging call's arguments.
631
+
632
+ A log pattern written as two side-by-side literals reads as an editing
633
+ artifact: the pieces join into one string at compile time, so the split
634
+ adds nothing and hides the real message from a plain-text search for the
635
+ full pattern. The check walks the parsed AST for a real logging call (an
636
+ attribute call such as a logger method, or a ``log_*`` helper) and runs
637
+ the adjacent-literal search only against that call's own source segment,
638
+ so text that merely looks like a logging call inside a comment,
639
+ docstring, or other string literal is never a real ``ast.Call`` node and
640
+ is left alone. A single-literal message, an explicit ``+`` join, and
641
+ comma-separated string arguments are all left alone. Test files and
642
+ non-Python files are exempt.
643
+
644
+ Args:
645
+ content: The Python source under validation.
646
+ file_path: The destination path, used to skip test files and non-Python.
647
+
648
+ Returns:
649
+ One issue line per offending call, capped at the configured maximum.
650
+ """
651
+ if is_test_file(file_path):
652
+ return []
653
+ if get_file_extension(file_path) not in ALL_PYTHON_EXTENSIONS:
654
+ return []
655
+ try:
656
+ tree = ast.parse(content)
657
+ except SyntaxError:
658
+ return []
659
+ issues: list[str] = []
660
+ for each_node in ast.walk(tree):
661
+ if not isinstance(each_node, ast.Call) or not _is_logging_call_node(each_node):
662
+ continue
663
+ call_source_segment = ast.get_source_segment(content, each_node)
664
+ if call_source_segment is None:
665
+ continue
666
+ if not ADJACENT_STRING_LITERAL_PATTERN.search(call_source_segment):
667
+ continue
668
+ issues.append(
669
+ f"Line {each_node.lineno}: adjacent string literals in a logging call - "
670
+ "collapse the implicit concatenation into one literal"
671
+ )
672
+ if len(issues) >= MAX_LOGGING_ADJACENT_LITERAL_ISSUES:
673
+ break
674
+ return issues
675
+
676
+
565
677
  def advise_file_line_count(content: str, file_path: str) -> None:
566
678
  """Emit non-blocking stderr advisories when a file crosses size smell thresholds.
567
679
 
@@ -601,6 +713,171 @@ def check_windows_api_none(content: str) -> list[str]:
601
713
  return issues
602
714
 
603
715
 
716
+ def _attribute_base_is_datetime(attribute_node: ast.Attribute) -> bool:
717
+ """Report whether an attribute access hangs off the ``datetime`` class.
718
+
719
+ Covers both import forms: ``datetime.fromtimestamp`` (from
720
+ ``from datetime import datetime``) where the base is a plain name, and
721
+ ``datetime.datetime.fromtimestamp`` (from ``import datetime``) where the base
722
+ is itself a ``.datetime`` attribute access.
723
+
724
+ Args:
725
+ attribute_node: The attribute access whose base is examined.
726
+
727
+ Returns:
728
+ True when the base resolves to the ``datetime`` class, else False.
729
+ """
730
+ base_node = attribute_node.value
731
+ if isinstance(base_node, ast.Name):
732
+ return base_node.id == DATETIME_CLASS_ATTRIBUTE_NAME
733
+ if isinstance(base_node, ast.Attribute):
734
+ inner_base = base_node.value
735
+ return (
736
+ base_node.attr == DATETIME_CLASS_ATTRIBUTE_NAME
737
+ and isinstance(inner_base, ast.Name)
738
+ and inner_base.id == DATETIME_CLASS_ATTRIBUTE_NAME
739
+ )
740
+ return False
741
+
742
+
743
+ def _is_literal_none(node: ast.expr) -> bool:
744
+ """Report whether an expression is the literal ``None``.
745
+
746
+ A ``None`` timezone argument leaves a datetime naive, so it does not count as
747
+ a supplied zone.
748
+
749
+ Args:
750
+ node: The argument expression to inspect.
751
+
752
+ Returns:
753
+ True when the node is a literal ``None``, else False.
754
+ """
755
+ return isinstance(node, ast.Constant) and node.value is None
756
+
757
+
758
+ def _fromtimestamp_supplies_concrete_timezone(call_node: ast.Call) -> bool:
759
+ """Report whether a ``fromtimestamp`` call pins a real timezone.
760
+
761
+ The zone arrives either as a ``tz=`` keyword or as the second positional
762
+ argument, matching the ``datetime.fromtimestamp(timestamp, tz=None)``
763
+ signature. A literal ``None`` in either slot leaves the value naive and does
764
+ not count.
765
+
766
+ Args:
767
+ call_node: The ``fromtimestamp`` call to inspect.
768
+
769
+ Returns:
770
+ True when a concrete (non-None) timezone is supplied, else False.
771
+ """
772
+ for each_keyword in call_node.keywords:
773
+ if each_keyword.arg == NAIVE_DATETIME_TIMEZONE_KEYWORD:
774
+ return not _is_literal_none(each_keyword.value)
775
+ if len(call_node.args) >= FROMTIMESTAMP_POSITIONAL_TIMEZONE_ARGUMENT_COUNT:
776
+ return not _is_literal_none(call_node.args[1])
777
+ return False
778
+
779
+
780
+ def _naive_datetime_constructor_name(call_node: ast.Call) -> str | None:
781
+ """Return the naive datetime constructor a call uses, or None when it is safe.
782
+
783
+ ``utcnow`` and ``utcfromtimestamp`` always build a naive datetime — neither
784
+ accepts a ``tz`` argument, so any such call is flagged. ``fromtimestamp``
785
+ builds a naive datetime only when no timezone pins the zone; a concrete
786
+ timezone counts when supplied as a ``tz=`` keyword or as a second positional
787
+ argument (``datetime.fromtimestamp(stamp, timezone.utc)``), while a literal
788
+ ``None`` in either slot still yields a naive value. Any other call is safe and
789
+ returns None.
790
+
791
+ Args:
792
+ call_node: The call expression to classify.
793
+
794
+ Returns:
795
+ The constructor name when the call builds a naive datetime, else None.
796
+ """
797
+ call_function = call_node.func
798
+ if not isinstance(call_function, ast.Attribute):
799
+ return None
800
+ if not _attribute_base_is_datetime(call_function):
801
+ return None
802
+ constructor_name = call_function.attr
803
+ if constructor_name in ALL_ALWAYS_NAIVE_DATETIME_CONSTRUCTORS:
804
+ return constructor_name
805
+ if constructor_name != NAIVE_DATETIME_FROMTIMESTAMP_CONSTRUCTOR:
806
+ return None
807
+ if _fromtimestamp_supplies_concrete_timezone(call_node):
808
+ return None
809
+ return constructor_name
810
+
811
+
812
+ def _naive_datetime_remediation(constructor_name: str) -> str:
813
+ """Return the fix guidance for a specific naive datetime constructor.
814
+
815
+ ``utcnow`` and ``utcfromtimestamp`` accept no ``tz`` argument, so each points
816
+ at its aware replacement (``datetime.now(tz=...)`` and
817
+ ``datetime.fromtimestamp(..., tz=...)``); ``fromtimestamp`` keeps its shape and
818
+ only needs a ``tz=`` argument.
819
+
820
+ Args:
821
+ constructor_name: The naive constructor the call used.
822
+
823
+ Returns:
824
+ The remediation clause to embed in the emitted issue.
825
+ """
826
+ if constructor_name == NAIVE_DATETIME_UTCNOW_CONSTRUCTOR:
827
+ return "use datetime.now(tz=timezone.utc)"
828
+ if constructor_name == NAIVE_DATETIME_UTCFROMTIMESTAMP_CONSTRUCTOR:
829
+ return "use datetime.fromtimestamp(..., tz=timezone.utc)"
830
+ return "pass tz= (e.g. tz=timezone.utc)"
831
+
832
+
833
+ def check_naive_datetime_construction(content: str, file_path: str) -> list[str]:
834
+ """Flag naive datetime constructors that invite DST-fold ambiguity.
835
+
836
+ ``datetime.fromtimestamp(epoch)`` and ``datetime.utcfromtimestamp(epoch)``
837
+ build a datetime with no timezone, and ``datetime.utcnow()`` does the same for
838
+ the current moment. A later ``.astimezone()`` on that naive value guesses the
839
+ local offset, so a timestamp landing in a daylight-saving fold reads back an
840
+ hour off. The aware replacements differ by constructor: ``fromtimestamp`` takes
841
+ a ``tz=`` keyword or a second positional timezone (``datetime.fromtimestamp(
842
+ stamp, timezone.utc)``); ``utcnow`` becomes ``datetime.now(tz=timezone.utc)``;
843
+ and ``utcfromtimestamp`` becomes ``datetime.fromtimestamp(stamp,
844
+ tz=timezone.utc)`` — neither ``utc`` constructor accepts a ``tz`` argument. A
845
+ plain ``datetime.now()`` is left alone, since it does not derive an instant from
846
+ an epoch value.
847
+
848
+ Args:
849
+ content: The source text to inspect.
850
+ file_path: The path the source will be written to, used for exemptions.
851
+
852
+ Returns:
853
+ One issue per naive datetime constructor, capped at the module limit.
854
+ """
855
+ if is_test_file(file_path):
856
+ return []
857
+ try:
858
+ parsed_tree = ast.parse(content)
859
+ except SyntaxError:
860
+ return []
861
+
862
+ issues: list[str] = []
863
+ for each_node in ast.walk(parsed_tree):
864
+ if not isinstance(each_node, ast.Call):
865
+ continue
866
+ naive_constructor_name = _naive_datetime_constructor_name(each_node)
867
+ if naive_constructor_name is None:
868
+ continue
869
+ remediation = _naive_datetime_remediation(naive_constructor_name)
870
+ issues.append(
871
+ f"Line {each_node.lineno}: naive datetime.{naive_constructor_name}() - "
872
+ f"{remediation} so the instant is unambiguous; call .astimezone() only "
873
+ "when local display is needed (avoids DST-fold ambiguity)"
874
+ )
875
+ if len(issues) >= MAX_NAIVE_DATETIME_ISSUES:
876
+ break
877
+
878
+ return issues
879
+
880
+
604
881
  def check_e2e_test_naming(content: str, file_path: str) -> list[str]:
605
882
  """Check for online/offline in test names (spec files only)."""
606
883
  if not is_spec_file(file_path):
@@ -1054,6 +1331,595 @@ def check_js_returns_object_schemaless_branch(content: str, file_path: str) -> l
1054
1331
  return issues[:MAX_JS_RETURNS_OBJECT_SCHEMALESS_ISSUES]
1055
1332
 
1056
1333
 
1334
+ def _matching_open_parenthesis_index(blanked_content: str, closing_index: int) -> int | None:
1335
+ """Return the index of the ``(`` that matches the ``)`` at ``closing_index``."""
1336
+ depth = 0
1337
+ for each_position in range(closing_index, -1, -1):
1338
+ character = blanked_content[each_position]
1339
+ if character == ")":
1340
+ depth += 1
1341
+ elif character == "(":
1342
+ depth -= 1
1343
+ if depth == 0:
1344
+ return each_position
1345
+ return None
1346
+
1347
+
1348
+ def _is_return_type_opening_colon(blanked_content: str, colon_index: int) -> bool:
1349
+ """Report whether the ``:`` at ``colon_index`` opens a return-type annotation.
1350
+
1351
+ A return-type colon follows the parameter-list ``)`` directly, ignoring any
1352
+ whitespace between them. A ``:`` reached elsewhere in the type — the branch
1353
+ separator of a TypeScript conditional type (``T extends U ? A : B``) — sits at
1354
+ the same bracket depth as the annotation-opening colon but is not preceded by
1355
+ ``)``, so it is not the boundary a caller stops the scan on.
1356
+ """
1357
+ preceding_index = colon_index - 1
1358
+ while preceding_index >= 0 and blanked_content[preceding_index].isspace():
1359
+ preceding_index -= 1
1360
+ return preceding_index >= 0 and blanked_content[preceding_index] == ")"
1361
+
1362
+
1363
+ def _return_type_annotation_colon_index(blanked_content: str, terminator_index: int) -> int | None:
1364
+ """Return the index of the ``:`` that opens the return-type annotation.
1365
+
1366
+ Scanning backward from the annotation's terminating bracket at
1367
+ ``terminator_index``, nested ``{}``, ``[]``, ``<>``, and ``()`` are balanced so a
1368
+ ``:`` inside the type never counts. An arrow ``=>`` inside a function-type
1369
+ annotation (``(x: number) => void``) is depth-neutral: both characters are
1370
+ skipped so the arrow's ``>`` never counts as a generic closer. A ``:`` reached
1371
+ at annotation depth zero is the return-type colon only when it is preceded
1372
+ (ignoring whitespace) by the parameter-list ``)`` — a depth-zero ``:`` that
1373
+ fails that check is a conditional type's branch separator, so the scan keeps
1374
+ going past it rather than stopping there. The result is None when a statement
1375
+ boundary (``;`` at depth zero, or an opener that drops the depth below zero) or
1376
+ the source start is reached first, so a plain block brace is not read as a
1377
+ function body.
1378
+ """
1379
+ depth = 0
1380
+ each_index = terminator_index
1381
+ while each_index >= 0:
1382
+ character = blanked_content[each_index]
1383
+ if character == ">" and each_index > 0 and blanked_content[each_index - 1] == "=":
1384
+ each_index -= JAVASCRIPT_ARROW_TOKEN_LENGTH
1385
+ continue
1386
+ if character in ALL_JAVASCRIPT_BRACKET_CLOSERS or character == ">":
1387
+ depth += 1
1388
+ elif character in ALL_JAVASCRIPT_BRACKET_OPENERS or character == "<":
1389
+ depth -= 1
1390
+ if depth < 0:
1391
+ return None
1392
+ elif depth == 0:
1393
+ if character == ":" and _is_return_type_opening_colon(blanked_content, each_index):
1394
+ return each_index
1395
+ if character == ";":
1396
+ return None
1397
+ each_index -= 1
1398
+ return None
1399
+
1400
+
1401
+ def _is_bare_return_type_character(character: str) -> bool:
1402
+ """Report whether ``character`` can appear in a bracket-free return-type annotation.
1403
+
1404
+ A bare return type — a named type (``ParseResult``), a primitive (``void``), a
1405
+ qualified name (``Foo.Bar``), or a union of them (``A | B``) — is built from
1406
+ letters, digits, ``_``, ``$``, ``.``, ``|``, ``&``, and the whitespace between
1407
+ them.
1408
+ """
1409
+ return (
1410
+ character.isalnum()
1411
+ or character.isspace()
1412
+ or character in ALL_JAVASCRIPT_BARE_RETURN_TYPE_EXTRA_CHARACTERS
1413
+ )
1414
+
1415
+
1416
+ def _bare_return_type_annotation_colon_index(
1417
+ blanked_content: str, identifier_end_index: int
1418
+ ) -> int | None:
1419
+ """Return the ``:`` index that opens a bracket-free return-type annotation.
1420
+
1421
+ A bare return type ends in an identifier character rather than a bracket, so the
1422
+ scan walks backward from that last character at ``identifier_end_index`` across the
1423
+ type's characters to the return-type ``:``. A closing bracket reached mid-walk
1424
+ marks a bracketed segment inside the annotation — a generic union member
1425
+ (``Promise<Foo> | Bar``) or a function type (``(x: number) => void``) — so the
1426
+ scan hands off to the bracket-balancing walk from that bracket. A ``:`` reached
1427
+ mid-walk is the return-type colon only when it is preceded (ignoring
1428
+ whitespace) by the parameter-list ``)``; a ``:`` that fails that check is a
1429
+ conditional type's branch separator (``T extends U ? A : B``), so the walk
1430
+ treats it as an ordinary bare character and keeps going past it. The result is
1431
+ None when any other non-bare character is reached first, so a plain block brace
1432
+ is not read as a function body.
1433
+ """
1434
+ for each_index in range(identifier_end_index, -1, -1):
1435
+ character = blanked_content[each_index]
1436
+ if character == ":":
1437
+ if _is_return_type_opening_colon(blanked_content, each_index):
1438
+ return each_index
1439
+ continue
1440
+ if character in ALL_JAVASCRIPT_RETURN_TYPE_TERMINATORS:
1441
+ return _return_type_annotation_colon_index(blanked_content, each_index)
1442
+ if not _is_bare_return_type_character(character):
1443
+ return None
1444
+ return None
1445
+
1446
+
1447
+ def _return_type_annotation_colon_index_for_shape(
1448
+ blanked_content: str, preceding_index: int
1449
+ ) -> int | None:
1450
+ """Return the return-type ``:`` index for the annotation ending at ``preceding_index``.
1451
+
1452
+ A return type ends either in a bracket (``}``, ``>``, ``]`` — an inline object
1453
+ literal, a generic, or a tuple type), which balances nested brackets back to the
1454
+ colon, or in an identifier character (a named type ``ParseResult``, a primitive
1455
+ ``void``, a qualified or union type), which walks back across type characters to
1456
+ the colon. The result is None when neither shape reaches a return-type colon.
1457
+ """
1458
+ if blanked_content[preceding_index] in ALL_JAVASCRIPT_RETURN_TYPE_TERMINATORS:
1459
+ return _return_type_annotation_colon_index(blanked_content, preceding_index)
1460
+ if _is_bare_return_type_character(blanked_content[preceding_index]):
1461
+ return _bare_return_type_annotation_colon_index(blanked_content, preceding_index)
1462
+ return None
1463
+
1464
+
1465
+ def _parameter_list_close_index(blanked_content: str, preceding_index: int) -> int | None:
1466
+ """Return the index of the parameter-list ``)`` a function body brace follows.
1467
+
1468
+ A plain function body brace follows the ``)`` directly. A TypeScript body brace
1469
+ follows a ``: <return type>`` annotation, so the scan walks back across the
1470
+ annotation — a bracket-terminated type (``}``, ``>``, ``]``) or a bare
1471
+ identifier, primitive, qualified, or union type — to the return-type ``:`` and
1472
+ reports the ``)`` before it. The result is None when the brace opens a plain
1473
+ block rather than a function body.
1474
+ """
1475
+ if blanked_content[preceding_index] == ")":
1476
+ return preceding_index
1477
+ colon_index = _return_type_annotation_colon_index_for_shape(blanked_content, preceding_index)
1478
+ if colon_index is None:
1479
+ return None
1480
+ parenthesis_index = colon_index - 1
1481
+ while parenthesis_index >= 0 and blanked_content[parenthesis_index].isspace():
1482
+ parenthesis_index -= 1
1483
+ if parenthesis_index < 0 or blanked_content[parenthesis_index] != ")":
1484
+ return None
1485
+ return parenthesis_index
1486
+
1487
+
1488
+ def _bare_word_ending_at(blanked_content: str, end_index: int) -> str:
1489
+ """Return the identifier word whose last character sits at ``end_index``."""
1490
+ start_index = end_index
1491
+ while start_index > 0 and (
1492
+ blanked_content[start_index - 1].isalnum()
1493
+ or blanked_content[start_index - 1] in ALL_JAVASCRIPT_IDENTIFIER_EXTRA_CHARACTERS
1494
+ ):
1495
+ start_index -= 1
1496
+ return blanked_content[start_index : end_index + 1]
1497
+
1498
+
1499
+ def _is_function_scope_opener(blanked_content: str, brace_index: int) -> bool:
1500
+ """Report whether the ``{`` at ``brace_index`` opens a function or arrow body.
1501
+
1502
+ An arrow body follows ``=>``. A function or method body follows the parameter
1503
+ list ``)`` — directly, or across a TypeScript ``: <return type>`` annotation of
1504
+ any shape (a bracket-terminated object, generic, or tuple type, or a bare
1505
+ identifier, primitive, qualified, or union type) — whose matching ``(`` is not
1506
+ preceded by a control-flow keyword. A control block (``if``, ``for``,
1507
+ ``for await``, ``while``, ``switch``, ``catch``, ``with``) opens a plain block,
1508
+ not a scope, so a return inside it belongs to the enclosing function; the
1509
+ ``await`` token of a ``for await`` head is the word the lookback reads, so it
1510
+ sits in the keyword set. A parenthesis-free block keyword (``else``, ``do``,
1511
+ ``try``, ``finally``) opens a plain block the same way, without a parameter
1512
+ list to look back across.
1513
+ """
1514
+ preceding_index = brace_index - 1
1515
+ while preceding_index >= 0 and blanked_content[preceding_index].isspace():
1516
+ preceding_index -= 1
1517
+ if preceding_index < 1:
1518
+ return False
1519
+ if blanked_content[preceding_index] == ">" and blanked_content[preceding_index - 1] == "=":
1520
+ return True
1521
+ if blanked_content[preceding_index].isalnum():
1522
+ preceding_word = _bare_word_ending_at(blanked_content, preceding_index)
1523
+ if preceding_word in ALL_JAVASCRIPT_PARENTHESIS_FREE_BLOCK_KEYWORDS:
1524
+ return False
1525
+ parameter_close_index = _parameter_list_close_index(blanked_content, preceding_index)
1526
+ if parameter_close_index is None:
1527
+ return False
1528
+ open_parenthesis_index = _matching_open_parenthesis_index(blanked_content, parameter_close_index)
1529
+ if open_parenthesis_index is None:
1530
+ return False
1531
+ keyword_end_index = open_parenthesis_index - 1
1532
+ while keyword_end_index >= 0 and blanked_content[keyword_end_index].isspace():
1533
+ keyword_end_index -= 1
1534
+ preceding_keyword = _bare_word_ending_at(blanked_content, keyword_end_index)
1535
+ return preceding_keyword not in ALL_JAVASCRIPT_CONTROL_FLOW_BLOCK_KEYWORDS
1536
+
1537
+
1538
+ def _split_top_level_object_entry_spans(inner_content: str) -> list[tuple[int, int]]:
1539
+ """Split object-literal inner text into ``(start, end)`` spans at depth-zero commas.
1540
+
1541
+ Spans index into ``inner_content`` so a caller reads each entry's key from the
1542
+ blanked source and its value from the original source at the same offsets.
1543
+ """
1544
+ spans: list[tuple[int, int]] = []
1545
+ depth = 0
1546
+ entry_start_index = 0
1547
+ for each_index, each_character in enumerate(inner_content):
1548
+ if each_character in ALL_JAVASCRIPT_BRACKET_OPENERS:
1549
+ depth += 1
1550
+ elif each_character in ALL_JAVASCRIPT_BRACKET_CLOSERS:
1551
+ depth -= 1
1552
+ elif each_character == "," and depth == 0:
1553
+ spans.append((entry_start_index, each_index))
1554
+ entry_start_index = each_index + 1
1555
+ spans.append((entry_start_index, len(inner_content)))
1556
+ return spans
1557
+
1558
+
1559
+ def _top_level_colon_index(entry_content: str) -> int | None:
1560
+ """Return the index of the first bracket-depth-zero ``:`` in the entry, or None."""
1561
+ depth = 0
1562
+ for each_index, each_character in enumerate(entry_content):
1563
+ if each_character in ALL_JAVASCRIPT_BRACKET_OPENERS:
1564
+ depth += 1
1565
+ elif each_character in ALL_JAVASCRIPT_BRACKET_CLOSERS:
1566
+ depth -= 1
1567
+ elif each_character == ":" and depth == 0:
1568
+ return each_index
1569
+ return None
1570
+
1571
+
1572
+ def _string_literal_value_or_none(value_text: str) -> str | None:
1573
+ """Return the string literal's content when the value is one, else None.
1574
+
1575
+ A discriminated-union tag is a string literal (``'created'``). Only the
1576
+ leading literal is read — the opening quote is matched to its own closing
1577
+ quote (backslash escapes honored) — so trailing non-code such as a comment
1578
+ (``'created' /* legacy */``) never leaks into the content, and ``'created'``
1579
+ and ``"created"`` carry the same content and compare equal. A value that is
1580
+ a boolean, number, variable, call, or other expression is not a tag, so it
1581
+ maps to None and never suppresses a drift finding for its key. An unterminated
1582
+ literal — no closing quote reached — is malformed source, not a real tag, so it
1583
+ also maps to None rather than the raw truncated text.
1584
+ """
1585
+ if not value_text or value_text[0] not in ALL_JAVASCRIPT_STRING_DELIMITERS:
1586
+ return None
1587
+ opening_quote = value_text[0]
1588
+ for each_index in range(1, len(value_text)):
1589
+ if value_text[each_index] == opening_quote and not _is_escaped(value_text, each_index):
1590
+ return value_text[1:each_index]
1591
+ return None
1592
+
1593
+
1594
+ def _matching_close_brace_index(blanked_content: str, opening_index: int) -> int | None:
1595
+ """Return the index of the ``}`` that matches the ``{`` at ``opening_index``."""
1596
+ depth = 0
1597
+ for each_position in range(opening_index, len(blanked_content)):
1598
+ character = blanked_content[each_position]
1599
+ if character == "{":
1600
+ depth += 1
1601
+ elif character == "}":
1602
+ depth -= 1
1603
+ if depth == 0:
1604
+ return each_position
1605
+ return None
1606
+
1607
+
1608
+ def _top_level_object_key_values(
1609
+ blanked_content: str, content: str, brace_index: int
1610
+ ) -> dict[str, str | None]:
1611
+ """Return each top-level key mapped to its string-literal value, or None.
1612
+
1613
+ The literal begins at the ``{`` at ``brace_index``. Entry boundaries and keys
1614
+ come from the already-blanked ``blanked_content``, so a brace, comma, or colon
1615
+ inside a nested object, array, call, or string never counts toward a key. Each
1616
+ value is read from the original ``content`` at the same offsets, so a string
1617
+ discriminant such as ``action: 'created'`` keeps its quote-stripped text; a
1618
+ shorthand key, a method-shorthand key, and a non-string value map to None. A
1619
+ spread entry, a computed key, and a quoted string-literal key are skipped.
1620
+ """
1621
+ closing_index = _matching_close_brace_index(blanked_content, brace_index)
1622
+ if closing_index is None:
1623
+ return {}
1624
+ blanked_inner = blanked_content[brace_index + 1 : closing_index]
1625
+ inner_start_index = brace_index + 1
1626
+ key_values: dict[str, str | None] = {}
1627
+ for each_entry_start_index, each_entry_end_index in _split_top_level_object_entry_spans(
1628
+ blanked_inner
1629
+ ):
1630
+ blanked_entry = blanked_inner[each_entry_start_index:each_entry_end_index]
1631
+ stripped_entry = blanked_entry.strip()
1632
+ if not stripped_entry or stripped_entry.startswith(JAVASCRIPT_OBJECT_SPREAD_PREFIX):
1633
+ continue
1634
+ colon_index = _top_level_colon_index(blanked_entry)
1635
+ if colon_index is None:
1636
+ method_shorthand_match = JS_OBJECT_METHOD_SHORTHAND_KEY_PATTERN.match(
1637
+ stripped_entry
1638
+ )
1639
+ if method_shorthand_match is not None:
1640
+ key_values[method_shorthand_match.group(1)] = None
1641
+ continue
1642
+ if not JS_OBJECT_KEY_IDENTIFIER_PATTERN.fullmatch(stripped_entry):
1643
+ continue
1644
+ key_values[stripped_entry] = None
1645
+ continue
1646
+ candidate_key = blanked_entry[:colon_index].strip()
1647
+ if not JS_OBJECT_KEY_IDENTIFIER_PATTERN.fullmatch(candidate_key):
1648
+ continue
1649
+ value_start_index = inner_start_index + each_entry_start_index + colon_index + 1
1650
+ value_end_index = inner_start_index + each_entry_end_index
1651
+ original_value = content[value_start_index:value_end_index].strip()
1652
+ key_values[candidate_key] = _string_literal_value_or_none(original_value)
1653
+ return key_values
1654
+
1655
+
1656
+ def _return_object_literal_records(content: str) -> list[tuple[int, dict[str, str | None], int]]:
1657
+ """Return one record per returned ``{ ... }`` object literal in the JS source.
1658
+
1659
+ A record comes from an explicit ``return { ... }`` or from a concise-body
1660
+ arrow's implicit return, ``() => ({ ... })``. Each record pairs a scope id,
1661
+ the literal's top-level key-to-string-literal-value map, and its 1-based line
1662
+ number. An explicit ``return { ... }`` carries the nearest enclosing function
1663
+ or arrow body's scope id, so two such returns share a scope id when that
1664
+ enclosing body is the same; a module-top-level return carries the module
1665
+ sentinel. A control-flow block does not open a scope, so an early return
1666
+ inside an ``if`` block shares its function's scope. A concise-body arrow's
1667
+ implicit return carries its own object literal's brace index as a scope id
1668
+ instead, since ``=> ({`` never opens a function-scope brace: the arrow's
1669
+ return would otherwise be pooled with unrelated sibling returns in whatever
1670
+ scope encloses the arrow, and a concise-body arrow has exactly one return, so
1671
+ isolating it into its own scope loses no real drift detection.
1672
+ """
1673
+ blanked_content = _blank_non_code_regions(content)
1674
+ explicit_return_brace_indices = {
1675
+ each_match.end() - 1
1676
+ for each_match in RETURN_OBJECT_LITERAL_OPENING_PATTERN.finditer(blanked_content)
1677
+ }
1678
+ arrow_concise_body_brace_indices = {
1679
+ each_match.end() - 1
1680
+ for each_match in ARROW_CONCISE_BODY_OBJECT_LITERAL_OPENING_PATTERN.finditer(
1681
+ blanked_content
1682
+ )
1683
+ }
1684
+ return_brace_indices = explicit_return_brace_indices | arrow_concise_body_brace_indices
1685
+ records: list[tuple[int, dict[str, str | None], int]] = []
1686
+ scope_restore_stack: list[int] = []
1687
+ current_scope_id = JAVASCRIPT_MODULE_SCOPE_SENTINEL
1688
+ current_line_number = 1
1689
+ for each_index, each_character in enumerate(blanked_content):
1690
+ if each_character == "\n":
1691
+ current_line_number += 1
1692
+ elif each_character == "{":
1693
+ if each_index in return_brace_indices:
1694
+ object_key_values = _top_level_object_key_values(
1695
+ blanked_content, content, each_index
1696
+ )
1697
+ record_scope_id = (
1698
+ each_index
1699
+ if each_index in arrow_concise_body_brace_indices
1700
+ else current_scope_id
1701
+ )
1702
+ records.append((record_scope_id, object_key_values, current_line_number))
1703
+ scope_restore_stack.append(current_scope_id)
1704
+ if _is_function_scope_opener(blanked_content, each_index):
1705
+ current_scope_id = each_index
1706
+ elif each_character == "}":
1707
+ if scope_restore_stack:
1708
+ current_scope_id = scope_restore_stack.pop()
1709
+ return records
1710
+
1711
+
1712
+ def _shares_differing_string_discriminant(
1713
+ all_candidate_key_values: dict[str, str | None],
1714
+ all_sibling_key_values: dict[str, str | None],
1715
+ ) -> bool:
1716
+ """Report whether two returns share a key carrying differing string literals.
1717
+
1718
+ A shared key whose value is a string literal in both returns but differs between
1719
+ them is a discriminated-union tag (``action: 'created'`` versus ``action:
1720
+ 'skipped-existing'``): the two returns are distinct variants, not one record with
1721
+ a drifted field. A boolean or numeric status flag maps to None, so only string
1722
+ literals count.
1723
+ """
1724
+ for each_shared_key in all_candidate_key_values.keys() & all_sibling_key_values.keys():
1725
+ candidate_value = all_candidate_key_values[each_shared_key]
1726
+ sibling_value = all_sibling_key_values[each_shared_key]
1727
+ if candidate_value is None or sibling_value is None:
1728
+ continue
1729
+ if candidate_value != sibling_value:
1730
+ return True
1731
+ return False
1732
+
1733
+
1734
+ def _single_missing_sibling_key(
1735
+ all_candidate_key_values: dict[str, str | None],
1736
+ all_scope_key_value_maps: list[dict[str, str | None]],
1737
+ ) -> str | None:
1738
+ """Return the lone key every sibling superset adds to the candidate return.
1739
+
1740
+ A sibling qualifies when the candidate's key set is a proper subset of it,
1741
+ exactly one key separates them, and the two returns share no differing
1742
+ string-literal discriminant. A discriminated-union variant whose tag differs
1743
+ from its sibling is a distinct exit shape, not a drifted record, so it is
1744
+ skipped. When every
1745
+ qualifying sibling adds the same single key, that key is returned so the caller
1746
+ names the omitted field. When qualifying siblings disagree on which key the
1747
+ candidate omits, the scope holds distinct exit shapes rather than one drifted
1748
+ record, so None is returned, as it is when no sibling qualifies.
1749
+ """
1750
+ candidate_keys = all_candidate_key_values.keys()
1751
+ all_missing_keys: set[str] = set()
1752
+ for each_sibling_key_values in all_scope_key_value_maps:
1753
+ sibling_keys = each_sibling_key_values.keys()
1754
+ if not candidate_keys < sibling_keys:
1755
+ continue
1756
+ if _shares_differing_string_discriminant(all_candidate_key_values, each_sibling_key_values):
1757
+ continue
1758
+ extra_keys = sibling_keys - candidate_keys
1759
+ if len(extra_keys) == SIBLING_RETURN_OBJECT_EXACT_MISSING_KEY_COUNT:
1760
+ all_missing_keys.update(extra_keys)
1761
+ if len(all_missing_keys) == SIBLING_RETURN_OBJECT_SINGLE_AGREED_MISSING_KEY_COUNT:
1762
+ return next(iter(all_missing_keys))
1763
+ return None
1764
+
1765
+
1766
+ def check_js_sibling_return_object_key_drift(content: str, file_path: str) -> list[str]:
1767
+ """Flag a return object literal that omits one field a sibling return carries.
1768
+
1769
+ Picture two return paths of the same function or workflow body. Both yield the
1770
+ same record, so both list the same fields — except one early return drops a
1771
+ single field the other carries. A caller that reads that field off the result
1772
+ gets undefined on the short path, while the documented contract and the sibling
1773
+ return both promise it.
1774
+
1775
+ Within one function or module scope, the check pairs each ``return { ... }``
1776
+ object literal, and each concise-body arrow's implicit ``() => ({ ... })``
1777
+ return, against its siblings. A literal whose key set is a proper subset
1778
+ of a sibling's, missing exactly one key and carrying at least the minimum key
1779
+ count, is flagged for that one missing key. A control-flow block does not open a
1780
+ scope, so an early return inside an ``if`` block is compared with the tail
1781
+ return of the same function. Three shapes pass: returns differing by two or more
1782
+ keys are a distinct exit shape, a discriminated-union variant that shares a
1783
+ key carrying a differing string-literal tag with its sibling (``action:
1784
+ 'created'`` versus ``action: 'skipped-existing'``) is a distinct variant, not a
1785
+ drifted record, and a candidate whose qualifying sibling supersets disagree on
1786
+ which single key it omits sits among distinct exit shapes rather than one
1787
+ drifted record. A TypeScript function whose return type is an inline object,
1788
+ generic, or tuple annotation opens its own scope, so its returns are not
1789
+ compared with an unrelated function's. This is the JS/.mjs slice of Category O
1790
+ doc-vs-implementation drift; the Python enforcer's AST docstring checks never
1791
+ inspect JavaScript source.
1792
+
1793
+ Args:
1794
+ content: The source text to inspect.
1795
+ file_path: The path the source will be written to, used for exemptions.
1796
+
1797
+ Returns:
1798
+ One issue per return literal that omits a single sibling field, capped at
1799
+ the module limit.
1800
+ """
1801
+ if is_test_file(file_path) or is_hook_infrastructure(file_path):
1802
+ return []
1803
+ if get_file_extension(file_path) not in ALL_JAVASCRIPT_EXTENSIONS:
1804
+ return []
1805
+ records = _return_object_literal_records(content)
1806
+ records_by_scope: dict[int, list[tuple[dict[str, str | None], int]]] = {}
1807
+ for each_scope_id, each_key_values, each_line_number in records:
1808
+ records_by_scope.setdefault(each_scope_id, []).append(
1809
+ (each_key_values, each_line_number)
1810
+ )
1811
+ issues: list[str] = []
1812
+ for each_scope_records in records_by_scope.values():
1813
+ all_scope_key_value_maps = [each_record[0] for each_record in each_scope_records]
1814
+ for each_key_values, each_line_number in each_scope_records:
1815
+ if len(each_key_values) < MINIMUM_SIBLING_RETURN_OBJECT_KEYS:
1816
+ continue
1817
+ missing_key = _single_missing_sibling_key(each_key_values, all_scope_key_value_maps)
1818
+ if missing_key is None:
1819
+ continue
1820
+ issues.append(
1821
+ f"Line {each_line_number}: this return object literal omits '{missing_key}' "
1822
+ "that a sibling return in the same scope carries — every return path that "
1823
+ f"yields this record carries the same keys, so a caller reading '{missing_key}' "
1824
+ f"gets undefined on this path; add '{missing_key}' "
1825
+ "(Category O doc-vs-implementation return-shape drift)"
1826
+ )
1827
+ if len(issues) >= MAX_JS_SIBLING_RETURN_OBJECT_KEY_DRIFT_ISSUES:
1828
+ return issues
1829
+ return issues
1830
+
1831
+
1832
+ def check_js_bare_flag_return_directive(
1833
+ content: str,
1834
+ file_path: str,
1835
+ all_changed_lines: set[int] | None = None,
1836
+ defer_scope_to_caller: bool = False,
1837
+ ) -> list[str]:
1838
+ """Flag a prose directive that returns a bare status flag a converge workflow rules out.
1839
+
1840
+ Picture the converge workflow preamble. It states the whole contract: the
1841
+ Copilot gate time-out value must be the full down result
1842
+ ``{sha, clean:false, down:true, findings:[]}``, and it adds "never a bare
1843
+ down flag". A later poll step then tells the agent to "return down: true"
1844
+ once the attempt budget runs out. That parenthetical repeats the bare flag
1845
+ the preamble rules out. A StructuredOutput run whose schema needs every
1846
+ field would reject a lone ``{down:true}``, so the shorthand drifts from the
1847
+ contract stated earlier in the same file.
1848
+
1849
+ The sweep reads every flag name any preamble in the file rules out ("never
1850
+ a bare <name> flag") and reports every ``return <name>: true`` or
1851
+ ``return <name>: false`` directive anywhere in the file that repeats one of
1852
+ them, with no proximity or ordering check between the two — the contract
1853
+ and the directive commonly sit far apart in a converge workflow file, so
1854
+ *content* must carry the whole file rather than an edit's changed fragment
1855
+ for the sweep to see both halves.
1856
+ ``return <word>: <bool>`` never forms the full-result object literal the
1857
+ contract requires — whether it lands in agent-prompt prose, a comment, or
1858
+ accidental code, the shape itself is never a compliant return. A full
1859
+ ``return {sha:..., down:true, ...}`` object literal
1860
+ stays untouched, and so does a source that states no such contract. This
1861
+ belongs to the JS/.mjs slice of Category O6 docstring-prose-versus-body
1862
+ drift, which the Python enforcer's syntax-tree checks never reach.
1863
+
1864
+ Args:
1865
+ content: The full file content the Write or Edit would leave on disk.
1866
+ file_path: The path the source will be written to, used for exemptions.
1867
+ all_changed_lines: Post-edit line numbers the current edit touched, or
1868
+ None to treat the whole file as in scope. When provided, a
1869
+ violation blocks when its directive line or any of its contract's
1870
+ "never a bare <name> flag" lines are among the changed lines, so
1871
+ editing either half of the contradiction trips the gate.
1872
+ defer_scope_to_caller: When True, return every violation so the
1873
+ commit/push gate scopes by added line through its ``Line N:``
1874
+ partitioning.
1875
+
1876
+ Returns:
1877
+ One issue per bare-flag return directive that repeats a status flag the
1878
+ preamble rules out, scoped to the changed lines unless
1879
+ *defer_scope_to_caller* is True or *all_changed_lines* is None, capped
1880
+ at the module limit.
1881
+ """
1882
+ if is_test_file(file_path) or is_hook_infrastructure(file_path):
1883
+ return []
1884
+ if get_file_extension(file_path) not in ALL_JAVASCRIPT_EXTENSIONS:
1885
+ return []
1886
+ contract_line_numbers_by_flag: dict[str, set[int]] = {}
1887
+ contract_matched_phrase_by_flag: dict[str, str] = {}
1888
+ for each_contract in BARE_FLAG_CONTRACT_PATTERN.finditer(content):
1889
+ folded_flag = each_contract.group("flag").casefold()
1890
+ contract_line_number = content.count("\n", 0, each_contract.start()) + 1
1891
+ contract_line_numbers_by_flag.setdefault(folded_flag, set()).add(
1892
+ contract_line_number
1893
+ )
1894
+ contract_matched_phrase_by_flag.setdefault(folded_flag, each_contract.group(0))
1895
+ if not contract_line_numbers_by_flag:
1896
+ return []
1897
+ all_violations_in_source_order: list[tuple[set[int], str]] = []
1898
+ for each_directive in BARE_FLAG_RETURN_DIRECTIVE_PATTERN.finditer(content):
1899
+ forbidden_flag = each_directive.group("flag")
1900
+ folded_flag = forbidden_flag.casefold()
1901
+ if folded_flag not in contract_line_numbers_by_flag:
1902
+ continue
1903
+ offending_line = content.count("\n", 0, each_directive.start()) + 1
1904
+ contract_matched_phrase = contract_matched_phrase_by_flag[folded_flag]
1905
+ message = (
1906
+ f"Line {offending_line}: 'return {forbidden_flag}: ...' repeats a bare "
1907
+ f"{forbidden_flag} status the same converge workflow forbids "
1908
+ f"('{contract_matched_phrase}'); return the whole result object "
1909
+ "with every StructuredOutput field set (Category O6 docstring drift)"
1910
+ )
1911
+ violation_lines = {offending_line} | contract_line_numbers_by_flag[folded_flag]
1912
+ all_violations_in_source_order.append((violation_lines, message))
1913
+ scoped_issues = _scope_violations_to_changed_lines(
1914
+ all_violations_in_source_order,
1915
+ all_changed_lines,
1916
+ defer_scope_to_caller,
1917
+ )
1918
+ if defer_scope_to_caller:
1919
+ return scoped_issues
1920
+ return scoped_issues[:MAX_JS_BARE_FLAG_RETURN_DIRECTIVE_ISSUES]
1921
+
1922
+
1057
1923
  def _is_cli_entry_point(file_path: str) -> bool:
1058
1924
  path_lower = file_path.lower().replace("\\", "/")
1059
1925
  return any(marker.replace("\\", "/") in path_lower for marker in ALL_CLI_FILE_PATH_MARKERS)