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.
Files changed (76) hide show
  1. package/_shared/pr-loop/scripts/CLAUDE.md +3 -1
  2. package/_shared/pr-loop/scripts/code_rules_gate.py +116 -30
  3. package/_shared/pr-loop/scripts/copilot_quota.py +360 -0
  4. package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +2 -0
  5. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +13 -0
  6. package/_shared/pr-loop/scripts/pr_loop_shared_constants/copilot_quota_constants.py +24 -0
  7. package/_shared/pr-loop/scripts/pr_loop_shared_constants/terminology_sweep_constants.py +113 -0
  8. package/_shared/pr-loop/scripts/terminology_sweep.py +467 -0
  9. package/_shared/pr-loop/scripts/tests/CLAUDE.md +8 -0
  10. package/_shared/pr-loop/scripts/tests/fixtures/copilot_internal_user_jonecho.json +76 -0
  11. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +339 -0
  12. package/_shared/pr-loop/scripts/tests/test_copilot_quota.py +242 -0
  13. package/_shared/pr-loop/scripts/tests/test_copilot_quota_constants.py +63 -0
  14. package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +297 -0
  15. package/audit-rubrics/CLAUDE.md +3 -2
  16. package/audit-rubrics/category_rubrics/CLAUDE.md +2 -1
  17. package/audit-rubrics/category_rubrics/category-a-api-contracts.md +2 -1
  18. package/audit-rubrics/category_rubrics/category-j-code-rules-compliance.md +13 -1
  19. package/audit-rubrics/category_rubrics/category-p-name-vs-behavior-contract.md +19 -0
  20. package/audit-rubrics/category_rubrics/category-q-cross-surface-claims.md +46 -0
  21. package/audit-rubrics/prompts/CLAUDE.md +2 -1
  22. package/audit-rubrics/prompts/category-a-api-contracts.md +1 -0
  23. package/audit-rubrics/prompts/category-j-code-rules-compliance.md +19 -7
  24. package/audit-rubrics/prompts/category-p-name-vs-behavior-contract.md +14 -0
  25. package/audit-rubrics/prompts/category-q-cross-surface-claims.md +51 -0
  26. package/docs/CODE_RULES.md +2 -2
  27. package/hooks/blocking/CLAUDE.md +2 -0
  28. package/hooks/blocking/code_rules_annotations_length.py +59 -11
  29. package/hooks/blocking/code_rules_banned_identifiers.py +48 -9
  30. package/hooks/blocking/code_rules_command_dispatch.py +140 -0
  31. package/hooks/blocking/code_rules_docstrings.py +93 -0
  32. package/hooks/blocking/code_rules_enforcer.py +58 -4
  33. package/hooks/blocking/code_rules_imports_logging.py +136 -1
  34. package/hooks/blocking/code_rules_js_conventions.py +246 -0
  35. package/hooks/blocking/code_rules_naming_collection.py +5 -0
  36. package/hooks/blocking/code_rules_test_assertions.py +3 -0
  37. package/hooks/blocking/code_rules_unused_imports.py +7 -66
  38. package/hooks/blocking/duplicate_rmtree_helper_blocker.py +7 -0
  39. package/hooks/blocking/test_code_rules_command_dispatch.py +95 -0
  40. package/hooks/blocking/test_code_rules_enforcer_annotations.py +20 -2
  41. package/hooks/blocking/test_code_rules_enforcer_banned_identifier.py +10 -2
  42. package/hooks/blocking/test_code_rules_enforcer_banned_import_alias.py +8 -4
  43. package/hooks/blocking/test_code_rules_enforcer_dispatch_wiring.py +9 -3
  44. package/hooks/blocking/test_code_rules_enforcer_docstring_type_checking_gate.py +164 -0
  45. package/hooks/blocking/test_code_rules_enforcer_js_returns_object.py +72 -0
  46. package/hooks/blocking/test_code_rules_enforcer_unused_imports.py +112 -18
  47. package/hooks/blocking/test_code_rules_js_conventions.py +167 -0
  48. package/hooks/blocking/test_code_rules_js_returns_object_schemaless.py +167 -0
  49. package/hooks/hooks_constants/CLAUDE.md +2 -0
  50. package/hooks/hooks_constants/blocking_check_limits.py +10 -0
  51. package/hooks/hooks_constants/code_rules_enforcer_constants.py +14 -0
  52. package/hooks/hooks_constants/command_dispatch_constants.py +28 -0
  53. package/hooks/hooks_constants/js_conventions_constants.py +54 -0
  54. package/hooks/hooks_constants/test_code_rules_enforcer_constants.py +93 -0
  55. package/hooks/hooks_constants/unused_module_import_constants.py +0 -1
  56. package/package.json +1 -1
  57. package/rules/docstring-prose-matches-implementation.md +3 -1
  58. package/skills/_shared/pr-loop/scripts/build_audit_prompt.py +43 -1
  59. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/CLAUDE.md +2 -2
  60. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/path_resolver_constants.py +1 -4
  61. package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +100 -4
  62. package/skills/autoconverge/SKILL.md +28 -7
  63. package/skills/autoconverge/reference/convergence.md +3 -3
  64. package/skills/autoconverge/reference/stop-conditions.md +1 -1
  65. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +4 -4
  66. package/skills/autoconverge/workflow/converge.contract.test.mjs +58 -122
  67. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +289 -5
  68. package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +50 -46
  69. package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +6 -6
  70. package/skills/autoconverge/workflow/converge.mjs +235 -247
  71. package/skills/autoconverge/workflow/converge.run-input.test.mjs +11 -0
  72. package/skills/autoconverge/workflow/converge_multi.mjs +23 -7
  73. package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +28 -2
  74. package/skills/pr-converge/SKILL.md +28 -2
  75. package/skills/pr-converge/reference/convergence-gates.md +13 -1
  76. 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. Files
195
- whose module body includes ``if TYPE_CHECKING:`` (or
196
- ``typing[._extensions].TYPE_CHECKING``) are skipped. Suppression honors bare
197
- ``# noqa`` or an explicit ``F401`` code in the noqa list only.
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 is_test_file(file_path):
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