claude-dev-env 1.82.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.
- package/CLAUDE.md +16 -13
- package/_shared/pr-loop/scripts/CLAUDE.md +1 -0
- package/_shared/pr-loop/scripts/README.md +1 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviewer_availability_constants.py +12 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/terminology_sweep_constants.py +0 -2
- package/_shared/pr-loop/scripts/reviewer_availability.py +182 -0
- package/_shared/pr-loop/scripts/terminology_sweep.py +9 -33
- package/_shared/pr-loop/scripts/tests/CLAUDE.md +2 -0
- package/_shared/pr-loop/scripts/tests/test_reviewer_availability.py +159 -0
- package/_shared/pr-loop/scripts/tests/test_reviewer_availability_constants.py +36 -0
- package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +14 -4
- package/hooks/blocking/CLAUDE.md +2 -0
- package/hooks/blocking/code_rules_constants_config.py +159 -1
- package/hooks/blocking/code_rules_docstrings.py +312 -9
- package/hooks/blocking/code_rules_enforcer.py +29 -0
- package/hooks/blocking/code_rules_imports_logging.py +867 -1
- package/hooks/blocking/code_rules_naming_collection.py +141 -0
- package/hooks/blocking/code_rules_string_magic.py +68 -0
- package/hooks/blocking/pre_tool_use_dispatcher.py +3 -3
- package/hooks/blocking/reviewer_spawn_gate.py +182 -0
- package/hooks/blocking/stale_comment_reference_blocker.py +267 -0
- package/hooks/blocking/state_description_blocker.py +96 -5
- package/hooks/blocking/test_code_rules_config_duplicate_path_anchor.py +132 -0
- package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +2 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_delegation_summary.py +385 -0
- package/hooks/blocking/test_code_rules_enforcer_join_separator_magic.py +67 -0
- package/hooks/blocking/test_code_rules_enforcer_module_docstring_roster.py +40 -0
- package/hooks/blocking/test_code_rules_enforcer_naive_datetime.py +213 -0
- package/hooks/blocking/test_code_rules_enforcer_referenced_underscore_loop.py +169 -0
- package/hooks/blocking/test_code_rules_js_bare_flag_return_directive.py +266 -0
- package/hooks/blocking/test_code_rules_js_sibling_return_object_key_drift.py +490 -0
- package/hooks/blocking/test_code_rules_logging_adjacent_literals.py +171 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +9 -3
- package/hooks/blocking/test_reviewer_spawn_gate.py +230 -0
- package/hooks/blocking/test_stale_comment_reference_blocker.py +236 -0
- package/hooks/blocking/test_state_description_blocker.py +135 -0
- package/hooks/hooks.json +5 -0
- package/hooks/hooks_constants/CLAUDE.md +3 -1
- package/hooks/hooks_constants/blocking_check_limits.py +43 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +41 -0
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
- package/hooks/hooks_constants/reviewer_spawn_gate_constants.py +41 -0
- package/hooks/hooks_constants/stale_comment_reference_blocker_constants.py +76 -0
- package/hooks/hooks_constants/state_description_blocker_constants.py +8 -0
- package/package.json +1 -1
- package/rules/CLAUDE.md +4 -1
- package/rules/claude-md-orphan-file.md +5 -0
- package/rules/docstring-prose-matches-implementation.md +10 -1
- package/rules/env-var-table-code-drift.md +5 -0
- package/rules/es-exe-file-search.md +17 -0
- package/rules/no-historical-clutter.md +12 -1
- package/rules/orphan-css-class.md +5 -0
- package/rules/package-inventory-stale-entry.md +10 -0
- package/rules/paired-test-coverage.md +5 -0
- package/rules/plain-illustrative-docstrings.md +5 -0
- package/rules/verify-before-asking.md +7 -0
- package/rules/verify-runtime-state.md +40 -0
- package/rules/windows-filesystem-safe.md +8 -0
- package/rules/workers-done-before-complete.md +33 -0
- package/rules/workflow-substitution-slots.md +5 -0
- package/skills/CLAUDE.md +1 -1
- package/skills/autoconverge/SKILL.md +10 -4
- package/skills/autoconverge/workflow/converge.contract.test.mjs +69 -0
- package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +54 -18
- package/skills/autoconverge/workflow/converge.mjs +97 -33
- 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)
|