claude-dev-env 1.79.0 → 1.81.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/_shared/pr-loop/scripts/CLAUDE.md +3 -1
- package/_shared/pr-loop/scripts/code_rules_gate.py +116 -30
- package/_shared/pr-loop/scripts/copilot_quota.py +360 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +2 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +13 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/copilot_quota_constants.py +24 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/terminology_sweep_constants.py +113 -0
- package/_shared/pr-loop/scripts/terminology_sweep.py +467 -0
- package/_shared/pr-loop/scripts/tests/CLAUDE.md +8 -0
- package/_shared/pr-loop/scripts/tests/fixtures/copilot_internal_user_jonecho.json +76 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +339 -0
- package/_shared/pr-loop/scripts/tests/test_copilot_quota.py +242 -0
- package/_shared/pr-loop/scripts/tests/test_copilot_quota_constants.py +63 -0
- package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +297 -0
- package/audit-rubrics/CLAUDE.md +3 -2
- package/audit-rubrics/category_rubrics/CLAUDE.md +2 -1
- package/audit-rubrics/category_rubrics/category-a-api-contracts.md +2 -1
- package/audit-rubrics/category_rubrics/category-j-code-rules-compliance.md +13 -1
- package/audit-rubrics/category_rubrics/category-p-name-vs-behavior-contract.md +19 -0
- package/audit-rubrics/category_rubrics/category-q-cross-surface-claims.md +46 -0
- package/audit-rubrics/prompts/CLAUDE.md +2 -1
- package/audit-rubrics/prompts/category-a-api-contracts.md +1 -0
- package/audit-rubrics/prompts/category-j-code-rules-compliance.md +19 -7
- package/audit-rubrics/prompts/category-p-name-vs-behavior-contract.md +14 -0
- package/audit-rubrics/prompts/category-q-cross-surface-claims.md +51 -0
- package/docs/CODE_RULES.md +2 -2
- package/hooks/blocking/CLAUDE.md +2 -0
- package/hooks/blocking/code_rules_annotations_length.py +59 -11
- package/hooks/blocking/code_rules_banned_identifiers.py +48 -9
- package/hooks/blocking/code_rules_command_dispatch.py +140 -0
- package/hooks/blocking/code_rules_docstrings.py +93 -0
- package/hooks/blocking/code_rules_enforcer.py +58 -4
- package/hooks/blocking/code_rules_imports_logging.py +136 -1
- package/hooks/blocking/code_rules_js_conventions.py +246 -0
- package/hooks/blocking/code_rules_naming_collection.py +5 -0
- package/hooks/blocking/code_rules_test_assertions.py +3 -0
- package/hooks/blocking/code_rules_unused_imports.py +7 -66
- package/hooks/blocking/duplicate_rmtree_helper_blocker.py +7 -0
- package/hooks/blocking/test_code_rules_command_dispatch.py +95 -0
- package/hooks/blocking/test_code_rules_enforcer_annotations.py +20 -2
- package/hooks/blocking/test_code_rules_enforcer_banned_identifier.py +10 -2
- package/hooks/blocking/test_code_rules_enforcer_banned_import_alias.py +8 -4
- package/hooks/blocking/test_code_rules_enforcer_dispatch_wiring.py +9 -3
- package/hooks/blocking/test_code_rules_enforcer_docstring_type_checking_gate.py +164 -0
- package/hooks/blocking/test_code_rules_enforcer_js_returns_object.py +72 -0
- package/hooks/blocking/test_code_rules_enforcer_unused_imports.py +112 -18
- package/hooks/blocking/test_code_rules_js_conventions.py +167 -0
- package/hooks/blocking/test_code_rules_js_returns_object_schemaless.py +167 -0
- package/hooks/hooks_constants/CLAUDE.md +2 -0
- package/hooks/hooks_constants/blocking_check_limits.py +10 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +14 -0
- package/hooks/hooks_constants/command_dispatch_constants.py +28 -0
- package/hooks/hooks_constants/js_conventions_constants.py +54 -0
- package/hooks/hooks_constants/test_code_rules_enforcer_constants.py +93 -0
- package/hooks/hooks_constants/unused_module_import_constants.py +0 -1
- package/package.json +1 -1
- package/rules/docstring-prose-matches-implementation.md +3 -1
- package/skills/_shared/pr-loop/scripts/build_audit_prompt.py +43 -1
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/CLAUDE.md +2 -2
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/path_resolver_constants.py +1 -4
- package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +100 -4
- package/skills/autoconverge/SKILL.md +28 -7
- package/skills/autoconverge/reference/convergence.md +3 -3
- package/skills/autoconverge/reference/stop-conditions.md +1 -1
- package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +4 -4
- package/skills/autoconverge/workflow/converge.contract.test.mjs +58 -122
- package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +289 -5
- package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +50 -46
- package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +6 -6
- package/skills/autoconverge/workflow/converge.mjs +235 -247
- package/skills/autoconverge/workflow/converge.run-input.test.mjs +11 -0
- package/skills/autoconverge/workflow/converge_multi.mjs +23 -7
- package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +28 -2
- package/skills/pr-converge/SKILL.md +28 -2
- package/skills/pr-converge/reference/convergence-gates.md +13 -1
- package/skills/pr-converge/reference/state-schema.md +11 -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, file-length advisory, and library-print checks."""
|
|
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."""
|
|
2
2
|
|
|
3
3
|
import ast
|
|
4
4
|
import json
|
|
@@ -34,6 +34,7 @@ from hooks_constants.blocking_check_limits import ( # noqa: E402
|
|
|
34
34
|
MAX_E2E_TEST_NAMING_ISSUES,
|
|
35
35
|
MAX_IMPORT_BLOCK_SORT_ISSUES,
|
|
36
36
|
MAX_JS_RESUME_TASK_ENUMERATION_ISSUES,
|
|
37
|
+
MAX_JS_RETURNS_OBJECT_SCHEMALESS_ISSUES,
|
|
37
38
|
MAX_LOGGING_FSTRING_ISSUES,
|
|
38
39
|
MAX_LOGGING_PRINTF_TOKEN_ISSUES,
|
|
39
40
|
MAX_WINDOWS_API_NONE_ISSUES,
|
|
@@ -55,17 +56,21 @@ from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
|
|
|
55
56
|
ENUMERATION_LEADING_CONJUNCTION_PATTERN,
|
|
56
57
|
ENUMERATION_LIST_ITEM_SEPARATOR_PATTERN,
|
|
57
58
|
ENUMERATION_TASK_ITEM_PATTERN,
|
|
59
|
+
FUNCTION_WITH_JSDOC_PATTERN,
|
|
58
60
|
HYPHENATED_TASK_ITEM_PATTERN,
|
|
59
61
|
JAVASCRIPT_BLOCK_COMMENT_CLOSER,
|
|
60
62
|
JAVASCRIPT_BLOCK_COMMENT_OPENER,
|
|
61
63
|
JAVASCRIPT_LINE_COMMENT_OPENER,
|
|
62
64
|
JAVASCRIPT_REGEX_DELIMITER,
|
|
63
65
|
JAVASCRIPT_STRING_ESCAPE_CHARACTER,
|
|
66
|
+
JSDOC_RETURNS_STRUCTURED_OBJECT_PROMISE_PATTERN,
|
|
64
67
|
LOGGING_FSTRING_PATTERN,
|
|
65
68
|
LOGGING_PRINTF_TOKEN_PATTERN,
|
|
66
69
|
MINIMUM_FORMAT_LOGGER_ARGUMENT_COUNT,
|
|
67
70
|
NOT_INSIDE_TYPE_CHECKING_BLOCK,
|
|
68
71
|
RESUME_TASK_ENUMERATION_PATTERN,
|
|
72
|
+
RETURN_CALL_OPENING_PARENTHESIS_PATTERN,
|
|
73
|
+
SCHEMA_OPTIONS_PROPERTY_KEY_PATTERN,
|
|
69
74
|
SPAWN_AGENT_WITH_JSDOC_PATTERN,
|
|
70
75
|
TASK_DISPATCH_NAME_PATTERN,
|
|
71
76
|
TRIPLE_DOUBLE_QUOTE_DELIMITER,
|
|
@@ -919,6 +924,136 @@ def check_js_resume_task_enumeration_coverage(
|
|
|
919
924
|
return issues[:MAX_JS_RESUME_TASK_ENUMERATION_ISSUES]
|
|
920
925
|
|
|
921
926
|
|
|
927
|
+
def _parameter_list_end_index(blanked_content: str, after_open_parenthesis_index: int) -> int:
|
|
928
|
+
"""Return the index just past the ``)`` that closes an opened parameter list.
|
|
929
|
+
|
|
930
|
+
``after_open_parenthesis_index`` sits just past the opening ``(``, so the scan
|
|
931
|
+
starts one level deep and walks paren-balanced. A nested ``(`` inside the list
|
|
932
|
+
— a default-value call, a destructure default — does not close the list early.
|
|
933
|
+
"""
|
|
934
|
+
depth = 1
|
|
935
|
+
for each_position in range(after_open_parenthesis_index, len(blanked_content)):
|
|
936
|
+
character = blanked_content[each_position]
|
|
937
|
+
if character == "(":
|
|
938
|
+
depth += 1
|
|
939
|
+
elif character == ")":
|
|
940
|
+
depth -= 1
|
|
941
|
+
if depth == 0:
|
|
942
|
+
return each_position + 1
|
|
943
|
+
return len(blanked_content)
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
def _javascript_function_body(content: str, header_end_index: int) -> str | None:
|
|
947
|
+
"""Return the brace-balanced body of the function whose header ends at the index.
|
|
948
|
+
|
|
949
|
+
``header_end_index`` sits just past the opening ``(`` of the parameter list, so
|
|
950
|
+
the scan advances past the paren-balanced parameter list before finding the body
|
|
951
|
+
brace. A destructured parameter (``function f({ ... }) { ... }``) opens a brace
|
|
952
|
+
inside that list; the body brace is the first ``{`` after the list closes, not
|
|
953
|
+
the destructure brace.
|
|
954
|
+
"""
|
|
955
|
+
body_search_index = _parameter_list_end_index(
|
|
956
|
+
_blank_non_code_regions(content), header_end_index
|
|
957
|
+
)
|
|
958
|
+
bounded_content = content[: _next_top_level_function_index(content, body_search_index)]
|
|
959
|
+
return _balanced_brace_body(bounded_content, body_search_index)
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
def _balanced_parenthesis_span(blanked_content: str, opening_index: int) -> str:
|
|
963
|
+
"""Return the paren-balanced slice of blanked source starting at an opening ``(``."""
|
|
964
|
+
depth = 0
|
|
965
|
+
for each_position in range(opening_index, len(blanked_content)):
|
|
966
|
+
character = blanked_content[each_position]
|
|
967
|
+
if character == "(":
|
|
968
|
+
depth += 1
|
|
969
|
+
elif character == ")":
|
|
970
|
+
depth -= 1
|
|
971
|
+
if depth == 0:
|
|
972
|
+
return blanked_content[opening_index : each_position + 1]
|
|
973
|
+
return blanked_content[opening_index:]
|
|
974
|
+
|
|
975
|
+
|
|
976
|
+
def _mixed_schema_return_callees(function_body: str) -> set[str]:
|
|
977
|
+
"""Return callees a function returns both with and without a schema options object.
|
|
978
|
+
|
|
979
|
+
The body is blanked so a brace or keyword inside a prompt string never counts.
|
|
980
|
+
Each ``return <callee>(...)`` whose argument list carries an object literal is
|
|
981
|
+
grouped by whether that argument list also carries a ``schema`` key. A callee
|
|
982
|
+
that appears on both sides marks a function that returns a schema object in one
|
|
983
|
+
branch and a schema-less transcript in another.
|
|
984
|
+
"""
|
|
985
|
+
blanked_body = _blank_non_code_regions(function_body)
|
|
986
|
+
schema_bearing_callees: set[str] = set()
|
|
987
|
+
schema_less_callees: set[str] = set()
|
|
988
|
+
for each_return_match in RETURN_CALL_OPENING_PARENTHESIS_PATTERN.finditer(blanked_body):
|
|
989
|
+
argument_span = _balanced_parenthesis_span(blanked_body, each_return_match.end() - 1)
|
|
990
|
+
if "{" not in argument_span:
|
|
991
|
+
continue
|
|
992
|
+
callee_name = each_return_match.group("callee")
|
|
993
|
+
if SCHEMA_OPTIONS_PROPERTY_KEY_PATTERN.search(argument_span) is not None:
|
|
994
|
+
schema_bearing_callees.add(callee_name)
|
|
995
|
+
else:
|
|
996
|
+
schema_less_callees.add(callee_name)
|
|
997
|
+
return schema_bearing_callees & schema_less_callees
|
|
998
|
+
|
|
999
|
+
|
|
1000
|
+
def check_js_returns_object_schemaless_branch(content: str, file_path: str) -> list[str]:
|
|
1001
|
+
"""Flag a JSDoc @returns Promise<object> whose branch returns a transcript string.
|
|
1002
|
+
|
|
1003
|
+
Picture a dispatcher that spawns an agent and returns whatever the agent
|
|
1004
|
+
resolves to. Most branches pass a ``schema`` in the agent options, so the agent
|
|
1005
|
+
resolves to a structured object — and the JSDoc reads
|
|
1006
|
+
``@returns {Promise<object>}``. But one branch spawns the same agent with no
|
|
1007
|
+
schema, so that branch resolves to a plain transcript string. A caller who
|
|
1008
|
+
trusts the object contract and reads a field off the result gets undefined on
|
|
1009
|
+
that branch.
|
|
1010
|
+
|
|
1011
|
+
The check reads each ``function`` declaration that carries a JSDoc block. When
|
|
1012
|
+
the JSDoc promises a ``Promise<object>`` and the body returns one agent helper
|
|
1013
|
+
both with a ``schema`` options object and without one, the schema-less branch
|
|
1014
|
+
contradicts the object claim and the function is flagged. A function whose every
|
|
1015
|
+
return passes a schema, one that returns plain object literals, and one whose
|
|
1016
|
+
``@returns`` already admits a string are all left alone. This is the JS/.mjs
|
|
1017
|
+
slice of Category O6 docstring-prose-vs-implementation drift; the Python
|
|
1018
|
+
enforcer's AST docstring checks never inspect JavaScript source.
|
|
1019
|
+
|
|
1020
|
+
Args:
|
|
1021
|
+
content: The source text to inspect.
|
|
1022
|
+
file_path: The path the source will be written to, used for exemptions.
|
|
1023
|
+
|
|
1024
|
+
Returns:
|
|
1025
|
+
One issue per function whose object return-type claim a schema-less branch
|
|
1026
|
+
contradicts, capped at the module limit.
|
|
1027
|
+
"""
|
|
1028
|
+
if is_test_file(file_path) or is_hook_infrastructure(file_path):
|
|
1029
|
+
return []
|
|
1030
|
+
if get_file_extension(file_path) not in ALL_JAVASCRIPT_EXTENSIONS:
|
|
1031
|
+
return []
|
|
1032
|
+
issues: list[str] = []
|
|
1033
|
+
for each_match in FUNCTION_WITH_JSDOC_PATTERN.finditer(content):
|
|
1034
|
+
if JSDOC_RETURNS_STRUCTURED_OBJECT_PROMISE_PATTERN.search(each_match.group("jsdoc")) is None:
|
|
1035
|
+
continue
|
|
1036
|
+
function_body = _javascript_function_body(content, each_match.end())
|
|
1037
|
+
if function_body is None:
|
|
1038
|
+
continue
|
|
1039
|
+
mixed_callees = _mixed_schema_return_callees(function_body)
|
|
1040
|
+
if not mixed_callees:
|
|
1041
|
+
continue
|
|
1042
|
+
function_name = each_match.group("name")
|
|
1043
|
+
line_number = content.count("\n", 0, each_match.start("name")) + 1
|
|
1044
|
+
joined_callees = ", ".join(sorted(mixed_callees))
|
|
1045
|
+
issues.append(
|
|
1046
|
+
f"Line {line_number}: {function_name}() JSDoc @returns a Promise<object> but a "
|
|
1047
|
+
f"branch returns {joined_callees}(...) with a schema-less options object — that "
|
|
1048
|
+
"branch resolves to a transcript string, not the structured object the @returns "
|
|
1049
|
+
"claims; widen the @returns type to admit the string transcript, or pass a schema "
|
|
1050
|
+
"(Category O6 docstring-vs-implementation drift)"
|
|
1051
|
+
)
|
|
1052
|
+
if len(issues) >= MAX_JS_RETURNS_OBJECT_SCHEMALESS_ISSUES:
|
|
1053
|
+
break
|
|
1054
|
+
return issues[:MAX_JS_RETURNS_OBJECT_SCHEMALESS_ISSUES]
|
|
1055
|
+
|
|
1056
|
+
|
|
922
1057
|
def _is_cli_entry_point(file_path: str) -> bool:
|
|
923
1058
|
path_lower = file_path.lower().replace("\\", "/")
|
|
924
1059
|
return any(marker.replace("\\", "/") in path_lower for marker in ALL_CLI_FILE_PATH_MARKERS)
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""Boolean-naming and banned-identifier checks for JavaScript and TypeScript source.
|
|
2
|
+
|
|
3
|
+
These mirror the Python ``check_boolean_naming`` and ``check_banned_identifiers``
|
|
4
|
+
rules for the ``.mjs`` / ``.js`` family so a JavaScript declaration receives the
|
|
5
|
+
same naming discipline the Python checks apply. Detection is line-regex based
|
|
6
|
+
over a copy of the source whose string, comment, and regex regions are blanked,
|
|
7
|
+
so a boolean assignment inside a prompt string or a comment never fires. The
|
|
8
|
+
``@param {boolean}`` scan reads the raw source instead, because JSDoc lives
|
|
9
|
+
inside a block comment the blanking step removes.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
_blocking_directory = str(Path(__file__).resolve().parent)
|
|
16
|
+
_hooks_directory = str(Path(__file__).resolve().parent.parent)
|
|
17
|
+
if _blocking_directory not in sys.path:
|
|
18
|
+
sys.path.insert(0, _blocking_directory)
|
|
19
|
+
if _hooks_directory not in sys.path:
|
|
20
|
+
sys.path.insert(0, _hooks_directory)
|
|
21
|
+
|
|
22
|
+
from code_rules_imports_logging import ( # noqa: E402
|
|
23
|
+
_blank_non_code_regions,
|
|
24
|
+
)
|
|
25
|
+
from code_rules_shared import ( # noqa: E402
|
|
26
|
+
_scope_violations_to_changed_lines,
|
|
27
|
+
get_file_extension,
|
|
28
|
+
is_hook_infrastructure,
|
|
29
|
+
is_test_file,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
from hooks_constants.banned_identifiers_constants import ( # noqa: E402
|
|
33
|
+
BANNED_IDENTIFIER_MESSAGE_SUFFIX,
|
|
34
|
+
)
|
|
35
|
+
from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
|
|
36
|
+
ALL_JAVASCRIPT_EXTENSIONS,
|
|
37
|
+
)
|
|
38
|
+
from hooks_constants.js_conventions_constants import ( # noqa: E402
|
|
39
|
+
ALL_JAVASCRIPT_BANNED_IDENTIFIERS,
|
|
40
|
+
BOOLEAN_PREFIX_GUIDANCE,
|
|
41
|
+
JAVASCRIPT_BOOLEAN_DECLARATION_PATTERN,
|
|
42
|
+
JAVASCRIPT_BOOLEAN_JSDOC_PARAMETER_PATTERN,
|
|
43
|
+
JAVASCRIPT_BOOLEAN_PREFIX_PATTERN,
|
|
44
|
+
JAVASCRIPT_DECLARATION_NAME_PATTERN,
|
|
45
|
+
MAX_JAVASCRIPT_BANNED_IDENTIFIER_ISSUES,
|
|
46
|
+
MAX_JAVASCRIPT_BOOLEAN_NAMING_ISSUES,
|
|
47
|
+
SINGLE_CHARACTER_NAME_LENGTH,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _is_javascript_target(file_path: str) -> bool:
|
|
52
|
+
"""Return whether a check should run on this path.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
file_path: The destination path of the write or edit.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
True when the path carries a JavaScript extension and is neither a test
|
|
59
|
+
file nor hook infrastructure; False for every exempt path.
|
|
60
|
+
"""
|
|
61
|
+
if is_test_file(file_path) or is_hook_infrastructure(file_path):
|
|
62
|
+
return False
|
|
63
|
+
return get_file_extension(file_path) in ALL_JAVASCRIPT_EXTENSIONS
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _boolean_name_lacks_prefix(name: str) -> bool:
|
|
67
|
+
"""Return whether a boolean name needs a naming prefix it does not carry.
|
|
68
|
+
|
|
69
|
+
A single-character name, an all-uppercase constant name, and a name already
|
|
70
|
+
carrying an ``is``/``has``/``should``/``can``/``was``/``did`` prefix are all
|
|
71
|
+
accepted.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
name: The declared boolean identifier.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
True when the name should carry a boolean prefix and does not.
|
|
78
|
+
"""
|
|
79
|
+
if len(name) <= SINGLE_CHARACTER_NAME_LENGTH:
|
|
80
|
+
return False
|
|
81
|
+
if name.isupper():
|
|
82
|
+
return False
|
|
83
|
+
return JAVASCRIPT_BOOLEAN_PREFIX_PATTERN.match(name) is None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _boolean_declaration_violations(
|
|
87
|
+
blanked_content: str,
|
|
88
|
+
) -> list[tuple[range, str]]:
|
|
89
|
+
"""Return one span-tagged violation per unprefixed boolean declaration.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
blanked_content: The source with string, comment, and regex regions
|
|
93
|
+
replaced by spaces so only structural code is scanned.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
``(line_range, message)`` pairs in source order.
|
|
97
|
+
"""
|
|
98
|
+
all_violations: list[tuple[range, str]] = []
|
|
99
|
+
for each_line_number, each_line in enumerate(blanked_content.split("\n"), 1):
|
|
100
|
+
for each_match in JAVASCRIPT_BOOLEAN_DECLARATION_PATTERN.finditer(each_line):
|
|
101
|
+
name = each_match.group("name")
|
|
102
|
+
if not _boolean_name_lacks_prefix(name):
|
|
103
|
+
continue
|
|
104
|
+
message = (
|
|
105
|
+
f"Line {each_line_number}: Boolean {name} - {BOOLEAN_PREFIX_GUIDANCE}"
|
|
106
|
+
)
|
|
107
|
+
all_violations.append(
|
|
108
|
+
(range(each_line_number, each_line_number + 1), message)
|
|
109
|
+
)
|
|
110
|
+
return all_violations
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _boolean_jsdoc_parameter_violations(content: str) -> list[tuple[range, str]]:
|
|
114
|
+
"""Return one span-tagged violation per unprefixed ``@param {boolean}`` name.
|
|
115
|
+
|
|
116
|
+
The raw source is scanned rather than the blanked copy because JSDoc lives
|
|
117
|
+
inside a block comment the blanking step removes.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
content: The raw source text.
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
``(line_range, message)`` pairs in source order.
|
|
124
|
+
"""
|
|
125
|
+
all_violations: list[tuple[range, str]] = []
|
|
126
|
+
for each_line_number, each_line in enumerate(content.split("\n"), 1):
|
|
127
|
+
for each_match in JAVASCRIPT_BOOLEAN_JSDOC_PARAMETER_PATTERN.finditer(
|
|
128
|
+
each_line
|
|
129
|
+
):
|
|
130
|
+
name = each_match.group("name")
|
|
131
|
+
if not _boolean_name_lacks_prefix(name):
|
|
132
|
+
continue
|
|
133
|
+
message = (
|
|
134
|
+
f"Line {each_line_number}: Boolean parameter {name} - "
|
|
135
|
+
f"{BOOLEAN_PREFIX_GUIDANCE}"
|
|
136
|
+
)
|
|
137
|
+
all_violations.append(
|
|
138
|
+
(range(each_line_number, each_line_number + 1), message)
|
|
139
|
+
)
|
|
140
|
+
return all_violations
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def check_js_boolean_naming(
|
|
144
|
+
content: str,
|
|
145
|
+
file_path: str,
|
|
146
|
+
all_changed_lines: set[int] | None = None,
|
|
147
|
+
defer_scope_to_caller: bool = False,
|
|
148
|
+
) -> list[str]:
|
|
149
|
+
"""Flag JavaScript boolean declarations and JSDoc params lacking a prefix.
|
|
150
|
+
|
|
151
|
+
A ``const``/``let``/``var`` declaration whose right-hand side is a boolean
|
|
152
|
+
literal, or a negation that is the entire right-hand side (``= !ready;`` or
|
|
153
|
+
``= !obj.prop`` or ``= !call(...)``, ending the statement with no ``?``,
|
|
154
|
+
``&&``, ``||``, or ``,`` combining the negated operand), and a
|
|
155
|
+
``@param {boolean}`` JSDoc entry, all name a boolean. A ``!``-headed side
|
|
156
|
+
that a ternary or logical operator continues (``!active ? "on" : "off"``,
|
|
157
|
+
``!err && getName()``) is an ordinary expression, not a boolean, and is left
|
|
158
|
+
alone. When the boolean name lacks an ``is``/``has``/``should``/``can``/
|
|
159
|
+
``was``/``did`` prefix (camelCase forms such as ``isReady``), the check flags
|
|
160
|
+
it, so a reader learns a value is a boolean from its name. Findings scope to
|
|
161
|
+
*all_changed_lines* so an edit blocks on the unprefixed boolean it just
|
|
162
|
+
introduced while a pre-existing one on an untouched line does not block.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
content: The source text to inspect.
|
|
166
|
+
file_path: The path the source will be written to, used for exemptions.
|
|
167
|
+
all_changed_lines: Post-edit line numbers the current edit touched, or
|
|
168
|
+
None to treat the whole file as in scope. When provided, a violation
|
|
169
|
+
blocks only when its line intersects the changed lines.
|
|
170
|
+
defer_scope_to_caller: When True, return every violation so the
|
|
171
|
+
commit/push gate scopes by added line through its ``Line N:``
|
|
172
|
+
partitioning.
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
One issue per unprefixed boolean declaration and JSDoc parameter, scoped
|
|
176
|
+
to the changed lines unless *defer_scope_to_caller* is True or
|
|
177
|
+
*all_changed_lines* is None, capped at the module limit.
|
|
178
|
+
"""
|
|
179
|
+
if not _is_javascript_target(file_path):
|
|
180
|
+
return []
|
|
181
|
+
blanked_content = _blank_non_code_regions(content)
|
|
182
|
+
all_violations_in_source_order = _boolean_declaration_violations(blanked_content)
|
|
183
|
+
all_violations_in_source_order.extend(_boolean_jsdoc_parameter_violations(content))
|
|
184
|
+
scoped_issues = _scope_violations_to_changed_lines(
|
|
185
|
+
all_violations_in_source_order,
|
|
186
|
+
all_changed_lines,
|
|
187
|
+
defer_scope_to_caller,
|
|
188
|
+
)
|
|
189
|
+
if defer_scope_to_caller:
|
|
190
|
+
return scoped_issues
|
|
191
|
+
return scoped_issues[:MAX_JAVASCRIPT_BOOLEAN_NAMING_ISSUES]
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def check_js_banned_identifiers(
|
|
195
|
+
content: str,
|
|
196
|
+
file_path: str,
|
|
197
|
+
all_changed_lines: set[int] | None = None,
|
|
198
|
+
defer_scope_to_caller: bool = False,
|
|
199
|
+
) -> list[str]:
|
|
200
|
+
"""Flag JavaScript declarations bound to a banned identifier name.
|
|
201
|
+
|
|
202
|
+
A ``const``/``let``/``var`` declaration whose name is one of the banned
|
|
203
|
+
placeholder names (``result``, ``data``, ``response``, ``ctx``, and the rest)
|
|
204
|
+
is flagged so the author picks a domain-specific name. Findings scope to
|
|
205
|
+
*all_changed_lines* so a pre-existing ``result`` on an untouched line never
|
|
206
|
+
blocks while a newly written one does.
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
content: The source text to inspect.
|
|
210
|
+
file_path: The path the source will be written to, used for exemptions.
|
|
211
|
+
all_changed_lines: Post-edit line numbers the current edit touched, or
|
|
212
|
+
None to treat the whole file as in scope. When provided, a violation
|
|
213
|
+
blocks only when its line intersects the changed lines.
|
|
214
|
+
defer_scope_to_caller: When True, return every violation so the
|
|
215
|
+
commit/push gate scopes by added line through its ``Line N:``
|
|
216
|
+
partitioning.
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
One issue per banned declaration name, scoped to the changed lines unless
|
|
220
|
+
*defer_scope_to_caller* is True or *all_changed_lines* is None, capped at
|
|
221
|
+
the module limit.
|
|
222
|
+
"""
|
|
223
|
+
if not _is_javascript_target(file_path):
|
|
224
|
+
return []
|
|
225
|
+
blanked_content = _blank_non_code_regions(content)
|
|
226
|
+
all_violations_in_source_order: list[tuple[range, str]] = []
|
|
227
|
+
for each_line_number, each_line in enumerate(blanked_content.split("\n"), 1):
|
|
228
|
+
for each_match in JAVASCRIPT_DECLARATION_NAME_PATTERN.finditer(each_line):
|
|
229
|
+
name = each_match.group("name")
|
|
230
|
+
if name not in ALL_JAVASCRIPT_BANNED_IDENTIFIERS:
|
|
231
|
+
continue
|
|
232
|
+
message = (
|
|
233
|
+
f"Line {each_line_number}: Banned identifier '{name}' - "
|
|
234
|
+
f"{BANNED_IDENTIFIER_MESSAGE_SUFFIX}"
|
|
235
|
+
)
|
|
236
|
+
all_violations_in_source_order.append(
|
|
237
|
+
(range(each_line_number, each_line_number + 1), message)
|
|
238
|
+
)
|
|
239
|
+
scoped_issues = _scope_violations_to_changed_lines(
|
|
240
|
+
all_violations_in_source_order,
|
|
241
|
+
all_changed_lines,
|
|
242
|
+
defer_scope_to_caller,
|
|
243
|
+
)
|
|
244
|
+
if defer_scope_to_caller:
|
|
245
|
+
return scoped_issues
|
|
246
|
+
return scoped_issues[:MAX_JAVASCRIPT_BANNED_IDENTIFIER_ISSUES]
|
|
@@ -33,6 +33,9 @@ from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
|
|
|
33
33
|
POLARITY_TOKEN_BOUNDARY_PATTERN,
|
|
34
34
|
UPPER_SNAKE_CONSTANT_PATTERN,
|
|
35
35
|
)
|
|
36
|
+
from hooks_constants.blocking_check_limits import ( # noqa: E402
|
|
37
|
+
MAX_POLARITY_CONTRADICTION_ISSUES,
|
|
38
|
+
)
|
|
36
39
|
from hooks_constants.stuttering_check_config import ( # noqa: E402
|
|
37
40
|
MAX_STUTTERING_PREFIX_ISSUES,
|
|
38
41
|
STUTTERING_ALL_PREFIX_PATTERN,
|
|
@@ -336,4 +339,6 @@ def check_polarity_name_contradiction(content: str, file_path: str) -> list[str]
|
|
|
336
339
|
f" {called_name!r} (says {called_token!r}) - the name contradicts the value; rename the"
|
|
337
340
|
f" callee to a neutral form so each call site reads truthfully (CODE_RULES §5)"
|
|
338
341
|
)
|
|
342
|
+
if len(issues) >= MAX_POLARITY_CONTRADICTION_ISSUES:
|
|
343
|
+
break
|
|
339
344
|
return issues
|
|
@@ -51,6 +51,7 @@ from code_rules_shared import ( # noqa: E402
|
|
|
51
51
|
|
|
52
52
|
from hooks_constants.blocking_check_limits import ( # noqa: E402
|
|
53
53
|
MAX_STALE_TEST_NAME_TARGET_ISSUES,
|
|
54
|
+
MAX_VACUOUS_CLEANUP_ASSERTION_ISSUES,
|
|
54
55
|
STALE_TEST_NAME_MINIMUM_SHARED_TOKEN_COUNT,
|
|
55
56
|
)
|
|
56
57
|
from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
|
|
@@ -387,6 +388,8 @@ def check_vacuous_cleanup_assertion_tests(content: str, file_path: str) -> list[
|
|
|
387
388
|
f" failure (e.g. monkeypatch os.replace to raise after the temp exists)"
|
|
388
389
|
f" then assert the temp was removed"
|
|
389
390
|
)
|
|
391
|
+
if len(issues) >= MAX_VACUOUS_CLEANUP_ASSERTION_ISSUES:
|
|
392
|
+
break
|
|
390
393
|
|
|
391
394
|
return issues
|
|
392
395
|
|
|
@@ -19,14 +19,10 @@ from code_rules_scope_binding import ( # noqa: E402
|
|
|
19
19
|
from code_rules_shared import ( # noqa: E402
|
|
20
20
|
_build_parent_map,
|
|
21
21
|
is_migration_file,
|
|
22
|
-
is_test_file,
|
|
23
|
-
is_workflow_registry_file,
|
|
24
22
|
)
|
|
25
23
|
|
|
26
24
|
from hooks_constants.unused_module_import_constants import ( # noqa: E402
|
|
27
|
-
ALL_TYPING_MODULE_NAMES,
|
|
28
25
|
MAX_UNUSED_IMPORT_ISSUES,
|
|
29
|
-
TYPE_CHECKING_IDENTIFIER,
|
|
30
26
|
UNUSED_IMPORT_GUIDANCE,
|
|
31
27
|
line_suppresses_unused_import_via_noqa,
|
|
32
28
|
)
|
|
@@ -73,59 +69,6 @@ def _line_number_falls_in_import_ranges(
|
|
|
73
69
|
return False
|
|
74
70
|
|
|
75
71
|
|
|
76
|
-
def _type_checking_guard_aliases(tree: ast.Module) -> tuple[set[str], set[str]]:
|
|
77
|
-
all_type_checking_names = {TYPE_CHECKING_IDENTIFIER}
|
|
78
|
-
all_type_checking_module_aliases = set(ALL_TYPING_MODULE_NAMES)
|
|
79
|
-
for each_statement in tree.body:
|
|
80
|
-
if isinstance(each_statement, ast.Import):
|
|
81
|
-
for each_alias in each_statement.names:
|
|
82
|
-
if each_alias.name in ALL_TYPING_MODULE_NAMES:
|
|
83
|
-
all_type_checking_module_aliases.add(
|
|
84
|
-
each_alias.asname or each_alias.name
|
|
85
|
-
)
|
|
86
|
-
elif isinstance(each_statement, ast.ImportFrom):
|
|
87
|
-
if each_statement.module not in ALL_TYPING_MODULE_NAMES:
|
|
88
|
-
continue
|
|
89
|
-
for each_alias in each_statement.names:
|
|
90
|
-
if each_alias.name == TYPE_CHECKING_IDENTIFIER:
|
|
91
|
-
all_type_checking_names.add(each_alias.asname or each_alias.name)
|
|
92
|
-
return all_type_checking_names, all_type_checking_module_aliases
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
def _expression_guards_type_checking_block(
|
|
96
|
-
test_expression: ast.expr,
|
|
97
|
-
all_type_checking_names: set[str],
|
|
98
|
-
all_type_checking_module_aliases: set[str],
|
|
99
|
-
) -> bool:
|
|
100
|
-
if isinstance(test_expression, ast.Name):
|
|
101
|
-
return test_expression.id in all_type_checking_names
|
|
102
|
-
if isinstance(test_expression, ast.Attribute):
|
|
103
|
-
if test_expression.attr != TYPE_CHECKING_IDENTIFIER:
|
|
104
|
-
return False
|
|
105
|
-
receiver = test_expression.value
|
|
106
|
-
return (
|
|
107
|
-
isinstance(receiver, ast.Name)
|
|
108
|
-
and receiver.id in all_type_checking_module_aliases
|
|
109
|
-
)
|
|
110
|
-
return False
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
def _module_body_declares_type_checking_gate(tree: ast.Module) -> bool:
|
|
114
|
-
(
|
|
115
|
-
all_type_checking_names,
|
|
116
|
-
all_type_checking_module_aliases,
|
|
117
|
-
) = _type_checking_guard_aliases(tree)
|
|
118
|
-
return any(
|
|
119
|
-
isinstance(each_statement, ast.If)
|
|
120
|
-
and _expression_guards_type_checking_block(
|
|
121
|
-
each_statement.test,
|
|
122
|
-
all_type_checking_names,
|
|
123
|
-
all_type_checking_module_aliases,
|
|
124
|
-
)
|
|
125
|
-
for each_statement in tree.body
|
|
126
|
-
)
|
|
127
|
-
|
|
128
|
-
|
|
129
72
|
def _collect_load_names_outside_import_ranges(
|
|
130
73
|
tree: ast.Module,
|
|
131
74
|
all_import_line_ranges: list[tuple[int, int]],
|
|
@@ -191,10 +134,12 @@ def check_unused_module_level_imports(
|
|
|
191
134
|
|
|
192
135
|
References are detected from AST ``Name`` / ``Attribute`` loads outside import
|
|
193
136
|
statements so mentions in comments or string literals do not count. Files
|
|
194
|
-
declaring ``__all__`` (including annotated assignments) are skipped.
|
|
195
|
-
|
|
196
|
-
``
|
|
197
|
-
|
|
137
|
+
declaring ``__all__`` (including annotated assignments) are skipped. A
|
|
138
|
+
``if TYPE_CHECKING:`` block does not exempt the file: its guarded imports
|
|
139
|
+
are nested inside the ``If`` node and are never scanned as top-level
|
|
140
|
+
bindings, while a dead top-level runtime import in the same file is still
|
|
141
|
+
flagged. Suppression honors bare ``# noqa`` or an explicit ``F401`` code in
|
|
142
|
+
the noqa list only.
|
|
198
143
|
|
|
199
144
|
When ``full_file_content`` is provided, ``content`` is treated as an Edit
|
|
200
145
|
fragment containing the imports being added or replaced, while the
|
|
@@ -203,9 +148,7 @@ def check_unused_module_level_imports(
|
|
|
203
148
|
Edit applies). This prevents false-positive flags on imports added in the
|
|
204
149
|
same Edit as their consumers.
|
|
205
150
|
"""
|
|
206
|
-
if
|
|
207
|
-
return []
|
|
208
|
-
if is_workflow_registry_file(file_path) or is_migration_file(file_path):
|
|
151
|
+
if is_migration_file(file_path):
|
|
209
152
|
return []
|
|
210
153
|
try:
|
|
211
154
|
fragment_tree = ast.parse(content)
|
|
@@ -218,8 +161,6 @@ def check_unused_module_level_imports(
|
|
|
218
161
|
return []
|
|
219
162
|
if _module_declares_dunder_all(reference_tree):
|
|
220
163
|
return []
|
|
221
|
-
if _module_body_declares_type_checking_gate(reference_tree):
|
|
222
|
-
return []
|
|
223
164
|
fragment_lines = content.splitlines()
|
|
224
165
|
reference_import_ranges = _import_statement_line_ranges(reference_tree)
|
|
225
166
|
referenced_names = _collect_load_names_outside_import_ranges(
|
|
@@ -39,6 +39,7 @@ from hooks_constants.duplicate_rmtree_helper_blocker_constants import ( # noqa:
|
|
|
39
39
|
PYTHON_FILE_EXTENSION,
|
|
40
40
|
TRIPLE_QUOTED_STRING_PATTERN,
|
|
41
41
|
)
|
|
42
|
+
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
42
43
|
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin # noqa: E402
|
|
43
44
|
|
|
44
45
|
|
|
@@ -146,6 +147,12 @@ def main() -> None:
|
|
|
146
147
|
"permissionDecisionReason": corrective_message,
|
|
147
148
|
}
|
|
148
149
|
}
|
|
150
|
+
log_hook_block(
|
|
151
|
+
calling_hook_name="duplicate_rmtree_helper_blocker.py",
|
|
152
|
+
hook_event="PreToolUse",
|
|
153
|
+
block_reason=corrective_message,
|
|
154
|
+
tool_name=tool_name,
|
|
155
|
+
)
|
|
149
156
|
print(json.dumps(deny_response))
|
|
150
157
|
sys.stdout.flush()
|
|
151
158
|
sys.exit(0)
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Behavioral tests for the unanchored command-dispatch meta-gate."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
_blocking_directory = str(Path(__file__).resolve().parent)
|
|
7
|
+
if _blocking_directory not in sys.path:
|
|
8
|
+
sys.path.insert(0, _blocking_directory)
|
|
9
|
+
|
|
10
|
+
from code_rules_command_dispatch import ( # noqa: E402
|
|
11
|
+
check_unanchored_command_dispatch,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
GATE_PATH = "packages/claude-dev-env/hooks/blocking/conventional_pr_title_gate.py"
|
|
15
|
+
|
|
16
|
+
UNANCHORED_SOURCE = (
|
|
17
|
+
"import re\n"
|
|
18
|
+
"def run(tool_input):\n"
|
|
19
|
+
' command_text = tool_input["command"]\n'
|
|
20
|
+
' if re.search(r"gh\\s+pr\\s+(create|edit)", command_text):\n'
|
|
21
|
+
" return True\n"
|
|
22
|
+
" return False\n"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_flags_unanchored_multi_word_command_pattern() -> None:
|
|
27
|
+
issues = check_unanchored_command_dispatch(UNANCHORED_SOURCE, GATE_PATH)
|
|
28
|
+
assert len(issues) == 1
|
|
29
|
+
assert "gh" in issues[0]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_accepts_anchored_command_pattern() -> None:
|
|
33
|
+
source = (
|
|
34
|
+
"import re\n"
|
|
35
|
+
"def run(tool_input):\n"
|
|
36
|
+
' command_text = tool_input["command"]\n'
|
|
37
|
+
' if re.search(r"^gh\\s+pr\\s+(create|edit)", command_text):\n'
|
|
38
|
+
" return True\n"
|
|
39
|
+
" return False\n"
|
|
40
|
+
)
|
|
41
|
+
assert check_unanchored_command_dispatch(source, GATE_PATH) == []
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_accepts_pattern_when_first_word_is_tokenized() -> None:
|
|
45
|
+
source = (
|
|
46
|
+
"import re\n"
|
|
47
|
+
"import shlex\n"
|
|
48
|
+
"def run(tool_input):\n"
|
|
49
|
+
' command_text = tool_input["command"]\n'
|
|
50
|
+
" first_word = shlex.split(command_text)[0]\n"
|
|
51
|
+
' if first_word == "gh" and re.search(r"gh\\s+pr\\s+create", command_text):\n'
|
|
52
|
+
" return True\n"
|
|
53
|
+
" return False\n"
|
|
54
|
+
)
|
|
55
|
+
assert check_unanchored_command_dispatch(source, GATE_PATH) == []
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_ignores_file_that_does_not_read_a_command_key() -> None:
|
|
59
|
+
source = (
|
|
60
|
+
"import re\n"
|
|
61
|
+
"def run(text):\n"
|
|
62
|
+
' if re.search(r"gh\\s+pr\\s+create", text):\n'
|
|
63
|
+
" return True\n"
|
|
64
|
+
" return False\n"
|
|
65
|
+
)
|
|
66
|
+
assert check_unanchored_command_dispatch(source, GATE_PATH) == []
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_ignores_single_word_command_literal() -> None:
|
|
70
|
+
source = (
|
|
71
|
+
"import re\n"
|
|
72
|
+
"def run(tool_input):\n"
|
|
73
|
+
' command_text = tool_input["command"]\n'
|
|
74
|
+
' if re.search(r"gh", command_text):\n'
|
|
75
|
+
" return True\n"
|
|
76
|
+
" return False\n"
|
|
77
|
+
)
|
|
78
|
+
assert check_unanchored_command_dispatch(source, GATE_PATH) == []
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_ignores_file_outside_hooks_blocking() -> None:
|
|
82
|
+
other_path = "packages/claude-dev-env/skills/thing/helper.py"
|
|
83
|
+
assert check_unanchored_command_dispatch(UNANCHORED_SOURCE, other_path) == []
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_ignores_test_file() -> None:
|
|
87
|
+
test_path = "packages/claude-dev-env/hooks/blocking/test_conventional_pr_title_gate.py"
|
|
88
|
+
assert check_unanchored_command_dispatch(UNANCHORED_SOURCE, test_path) == []
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_scoped_to_changed_lines() -> None:
|
|
92
|
+
unchanged = check_unanchored_command_dispatch(UNANCHORED_SOURCE, GATE_PATH, {1, 2, 3})
|
|
93
|
+
assert unchanged == []
|
|
94
|
+
on_pattern_line = check_unanchored_command_dispatch(UNANCHORED_SOURCE, GATE_PATH, {4})
|
|
95
|
+
assert len(on_pattern_line) == 1
|