claude-dev-env 1.44.0 → 1.45.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 +9 -0
- package/_shared/pr-loop/scripts/code_rules_gate.py +426 -85
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +20 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +625 -21
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate_constants.py +15 -0
- package/agents/clean-coder.md +7 -1
- package/agents/code-quality-agent.md +8 -5
- package/hooks/blocking/code_rules_enforcer.py +1562 -37
- package/hooks/blocking/open_questions_in_plans_blocker.py +249 -0
- package/hooks/blocking/test_code_rules_enforcer.py +1389 -0
- package/hooks/blocking/test_code_rules_enforcer_banned_noun_word.py +292 -0
- package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +46 -8
- package/hooks/blocking/test_code_rules_enforcer_exempt_marker_chained.py +189 -0
- package/hooks/blocking/test_code_rules_enforcer_function_length.py +210 -0
- package/hooks/blocking/test_code_rules_enforcer_tests_isolate_home_temp.py +1512 -0
- package/hooks/blocking/test_code_rules_enforcer_unused_imports.py +9 -5
- package/hooks/blocking/test_open_questions_in_plans_blocker.py +790 -0
- package/hooks/hooks.json +10 -0
- package/hooks/hooks_constants/banned_identifiers_constants.py +19 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +129 -2
- package/hooks/hooks_constants/open_questions_in_plans_blocker_constants.py +35 -0
- package/hooks/hooks_constants/test_open_questions_in_plans_blocker_constants.py +125 -0
- package/package.json +1 -1
- package/skills/_shared/pr-loop/scripts/_path_resolver.py +34 -13
- package/skills/_shared/pr-loop/scripts/init_loop_state.py +1 -2
- package/skills/_shared/pr-loop/scripts/teardown_worktrees.py +1 -4
- package/skills/_shared/pr-loop/scripts/test__path_resolver.py +57 -0
- package/skills/_shared/pr-loop/scripts/test_init_loop_state.py +48 -0
- package/skills/_shared/pr-loop/scripts/test_teardown_worktrees.py +59 -0
- package/skills/bugteam/PROMPTS.md +48 -12
- package/skills/bugteam/reference/team-setup.md +4 -2
- package/skills/bugteam/scripts/bugteam_code_rules_gate.py +487 -76
- package/skills/bugteam/scripts/bugteam_scripts_constants/bugteam_code_rules_gate_constants.py +22 -1
- package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +597 -12
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"""Tests for ``check_banned_noun_word_boundary``.
|
|
2
|
+
|
|
3
|
+
Pattern class: identifiers embedding a CODE_RULES §5 banned noun word
|
|
4
|
+
(``result``, ``data``, ``output``, ``response``, ``value``, ``item``,
|
|
5
|
+
``temp``) as a snake_case word part or camelCase word part inside a longer
|
|
6
|
+
identifier, even when the exact-match check would let them through. Cited
|
|
7
|
+
SYNTHESIS evidence: pa#143 F8 (``OUTPUT`` constant), pa#144 F19
|
|
8
|
+
(``HolidayPeakResult`` class), pa#143 F13 (``canned_results`` collection),
|
|
9
|
+
pa#136 F35 (snake_case variants).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import importlib.util
|
|
15
|
+
import pathlib
|
|
16
|
+
import sys
|
|
17
|
+
|
|
18
|
+
_HOOK_DIR = pathlib.Path(__file__).parent
|
|
19
|
+
if str(_HOOK_DIR) not in sys.path:
|
|
20
|
+
sys.path.insert(0, str(_HOOK_DIR))
|
|
21
|
+
|
|
22
|
+
hook_spec = importlib.util.spec_from_file_location(
|
|
23
|
+
"code_rules_enforcer",
|
|
24
|
+
_HOOK_DIR / "code_rules_enforcer.py",
|
|
25
|
+
)
|
|
26
|
+
assert hook_spec is not None
|
|
27
|
+
assert hook_spec.loader is not None
|
|
28
|
+
hook_module = importlib.util.module_from_spec(hook_spec)
|
|
29
|
+
hook_spec.loader.exec_module(hook_module)
|
|
30
|
+
check_banned_noun_word_boundary = hook_module.check_banned_noun_word_boundary
|
|
31
|
+
validate_content = hook_module.validate_content
|
|
32
|
+
_identifier_word_parts = hook_module._identifier_word_parts
|
|
33
|
+
_find_banned_noun_word = hook_module._find_banned_noun_word
|
|
34
|
+
|
|
35
|
+
PRODUCTION_FILE_PATH = "packages/app/services/customer_pipeline.py"
|
|
36
|
+
TEST_FILE_PATH = "packages/app/services/test_customer_pipeline.py"
|
|
37
|
+
CONFIG_FILE_PATH = "packages/app/config/constants.py"
|
|
38
|
+
HOOK_INFRASTRUCTURE_PATH = "/packages/claude-dev-env/hooks/blocking/example.py"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_should_split_snake_case_identifier_into_lowercase_words() -> None:
|
|
42
|
+
assert _identifier_word_parts("canned_results") == ["canned", "results"]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_should_split_camel_case_identifier_into_lowercase_words() -> None:
|
|
46
|
+
assert _identifier_word_parts("HolidayPeakResult") == ["holiday", "peak", "result"]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_should_split_screaming_snake_case_into_lowercase_words() -> None:
|
|
50
|
+
assert _identifier_word_parts("SAFE_OUTPUT_PATH") == ["safe", "output", "path"]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_should_treat_consecutive_capitals_as_acronym_word() -> None:
|
|
54
|
+
assert _identifier_word_parts("XMLParser") == ["xml", "parser"]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_should_return_no_banned_word_for_clean_identifier() -> None:
|
|
58
|
+
assert _find_banned_noun_word("customer_pipeline") is None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_should_return_banned_word_for_camel_case_with_result_suffix() -> None:
|
|
62
|
+
assert _find_banned_noun_word("HolidayPeakResult") == "result"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_should_return_banned_word_for_snake_case_with_results_plural() -> None:
|
|
66
|
+
assert _find_banned_noun_word("canned_results") == "results"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_should_return_banned_word_for_screaming_snake_with_output() -> None:
|
|
70
|
+
assert _find_banned_noun_word("SAFE_OUTPUT_PATH") == "output"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_should_flag_class_name_containing_result_word() -> None:
|
|
74
|
+
source = "class HolidayPeakResult:\n pass\n"
|
|
75
|
+
issues = check_banned_noun_word_boundary(source, PRODUCTION_FILE_PATH)
|
|
76
|
+
assert any("HolidayPeakResult" in each_issue for each_issue in issues)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_should_flag_module_level_constant_with_output_word() -> None:
|
|
80
|
+
source = "SAFE_OUTPUT_PATH = '/tmp/x'\n"
|
|
81
|
+
issues = check_banned_noun_word_boundary(source, PRODUCTION_FILE_PATH)
|
|
82
|
+
assert any("SAFE_OUTPUT_PATH" in each_issue for each_issue in issues)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_should_flag_local_variable_with_results_word() -> None:
|
|
86
|
+
source = (
|
|
87
|
+
"def aggregate() -> list[int]:\n canned_results = [1, 2, 3]\n return canned_results\n"
|
|
88
|
+
)
|
|
89
|
+
issues = check_banned_noun_word_boundary(source, PRODUCTION_FILE_PATH)
|
|
90
|
+
assert any("canned_results" in each_issue for each_issue in issues)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_should_flag_function_parameter_with_response_word() -> None:
|
|
94
|
+
source = (
|
|
95
|
+
"def handle(cached_response: dict[str, int]) -> int:\n return len(cached_response)\n"
|
|
96
|
+
)
|
|
97
|
+
issues = check_banned_noun_word_boundary(source, PRODUCTION_FILE_PATH)
|
|
98
|
+
assert any("cached_response" in each_issue for each_issue in issues)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_should_not_flag_exact_match_banned_identifier() -> None:
|
|
102
|
+
source = "result = compute()\n"
|
|
103
|
+
issues = check_banned_noun_word_boundary(source, PRODUCTION_FILE_PATH)
|
|
104
|
+
assert issues == []
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_should_not_flag_dunder_method_with_banned_word() -> None:
|
|
108
|
+
source = "class Foo:\n def __init_data__(self) -> None: pass\n"
|
|
109
|
+
issues = check_banned_noun_word_boundary(source, PRODUCTION_FILE_PATH)
|
|
110
|
+
assert issues == []
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_should_skip_test_files() -> None:
|
|
114
|
+
source = "class HolidayPeakResult:\n pass\n"
|
|
115
|
+
issues = check_banned_noun_word_boundary(source, TEST_FILE_PATH)
|
|
116
|
+
assert issues == []
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_should_skip_config_files() -> None:
|
|
120
|
+
source = "OUTPUT_DIR = '/tmp'\n"
|
|
121
|
+
issues = check_banned_noun_word_boundary(source, CONFIG_FILE_PATH)
|
|
122
|
+
assert issues == []
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_should_skip_hook_infrastructure() -> None:
|
|
126
|
+
source = "OUTPUT_DIR = '/tmp'\n"
|
|
127
|
+
issues = check_banned_noun_word_boundary(source, HOOK_INFRASTRUCTURE_PATH)
|
|
128
|
+
assert issues == []
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_should_skip_when_source_does_not_parse() -> None:
|
|
132
|
+
source = "def broken(:\n"
|
|
133
|
+
issues = check_banned_noun_word_boundary(source, PRODUCTION_FILE_PATH)
|
|
134
|
+
assert issues == []
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def test_should_flag_response_count_parameter_on_method() -> None:
|
|
138
|
+
source = "class Foo:\n def bar(self, response_count: int) -> None: pass\n"
|
|
139
|
+
issues = check_banned_noun_word_boundary(source, PRODUCTION_FILE_PATH)
|
|
140
|
+
assert any("response_count" in each_issue for each_issue in issues)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_should_include_banned_word_in_message() -> None:
|
|
144
|
+
source = "PEAK_OUTPUTS = []\n"
|
|
145
|
+
issues = check_banned_noun_word_boundary(source, PRODUCTION_FILE_PATH)
|
|
146
|
+
assert issues
|
|
147
|
+
assert "outputs" in issues[0]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_terminal_fragment_reports_every_banned_noun_uncapped() -> None:
|
|
151
|
+
"""On the terminal (non-deferred) path the check analyzes *content* as the
|
|
152
|
+
edited fragment, where every binding is in scope — so every banned-noun
|
|
153
|
+
binding is reported with no ceiling on the count."""
|
|
154
|
+
binding_count = 5
|
|
155
|
+
source = "".join(
|
|
156
|
+
f"BINDING_{each_index}_RESULT_PATH = {each_index}\n"
|
|
157
|
+
for each_index in range(binding_count)
|
|
158
|
+
)
|
|
159
|
+
issues = check_banned_noun_word_boundary(source, PRODUCTION_FILE_PATH)
|
|
160
|
+
assert len(issues) == binding_count
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_deferred_path_returns_every_banned_noun_uncapped() -> None:
|
|
164
|
+
"""When the gate sets the deferral flag the check returns every violation so
|
|
165
|
+
``split_violations_by_scope`` can scope by added line and report the in-scope
|
|
166
|
+
set."""
|
|
167
|
+
binding_count = 5
|
|
168
|
+
source = "".join(
|
|
169
|
+
f"BINDING_{each_index}_RESULT_PATH = {each_index}\n"
|
|
170
|
+
for each_index in range(binding_count)
|
|
171
|
+
)
|
|
172
|
+
issues = check_banned_noun_word_boundary(
|
|
173
|
+
source, PRODUCTION_FILE_PATH, defer_scope_to_caller=True
|
|
174
|
+
)
|
|
175
|
+
assert len(issues) == binding_count
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def test_should_flag_function_definition_with_data_word_in_name() -> None:
|
|
179
|
+
source = "def fetch_data_table() -> None:\n pass\n"
|
|
180
|
+
issues = check_banned_noun_word_boundary(source, PRODUCTION_FILE_PATH)
|
|
181
|
+
assert any("fetch_data_table" in each_issue for each_issue in issues)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def test_should_flag_import_from_author_chosen_alias_with_banned_word() -> None:
|
|
185
|
+
source = "from models import HolidayPeak as holiday_peak_result\n"
|
|
186
|
+
issues = check_banned_noun_word_boundary(source, PRODUCTION_FILE_PATH)
|
|
187
|
+
assert any("holiday_peak_result" in each_issue for each_issue in issues)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def test_should_not_flag_non_aliased_upstream_import_with_banned_word() -> None:
|
|
191
|
+
source = "from typing import ItemsView\n"
|
|
192
|
+
issues = check_banned_noun_word_boundary(source, PRODUCTION_FILE_PATH)
|
|
193
|
+
assert issues == []
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def test_should_flag_import_renamed_with_banned_word() -> None:
|
|
197
|
+
source = "import legacy_helper as cached_response\n"
|
|
198
|
+
issues = check_banned_noun_word_boundary(source, PRODUCTION_FILE_PATH)
|
|
199
|
+
assert any("cached_response" in each_issue for each_issue in issues)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def test_should_skip_star_import_with_no_named_binding() -> None:
|
|
203
|
+
source = "from models import *\n"
|
|
204
|
+
issues = check_banned_noun_word_boundary(source, PRODUCTION_FILE_PATH)
|
|
205
|
+
assert issues == []
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def test_should_not_flag_non_aliased_dotted_import_with_clean_first_segment() -> None:
|
|
209
|
+
source = "import analytics.data_pipeline\n"
|
|
210
|
+
issues = check_banned_noun_word_boundary(source, PRODUCTION_FILE_PATH)
|
|
211
|
+
assert issues == []
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def test_should_not_flag_non_aliased_dotted_import_with_banned_first_segment() -> None:
|
|
215
|
+
source = "import data_pipeline.analytics\n"
|
|
216
|
+
issues = check_banned_noun_word_boundary(source, PRODUCTION_FILE_PATH)
|
|
217
|
+
assert issues == []
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def test_should_flag_aliased_dotted_import_with_banned_word_in_alias() -> None:
|
|
221
|
+
source = "import analytics.pipeline as data_table\n"
|
|
222
|
+
issues = check_banned_noun_word_boundary(source, PRODUCTION_FILE_PATH)
|
|
223
|
+
assert any("data_table" in each_issue for each_issue in issues)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def test_should_flag_with_as_binding_target_with_banned_word() -> None:
|
|
227
|
+
source = (
|
|
228
|
+
"def load_payload() -> str:\n"
|
|
229
|
+
" with open('payload.json') as data_result:\n"
|
|
230
|
+
" return data_result.read()\n"
|
|
231
|
+
)
|
|
232
|
+
issues = check_banned_noun_word_boundary(source, PRODUCTION_FILE_PATH)
|
|
233
|
+
assert any("data_result" in each_issue for each_issue in issues)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
EDIT_FRAGMENT_WITHOUT_BANNED_NAME = (
|
|
237
|
+
"def compute_total() -> int:\n running_sum = 0\n return running_sum\n"
|
|
238
|
+
)
|
|
239
|
+
FULL_FILE_WITH_BANNED_NAME_OUTSIDE_FRAGMENT = (
|
|
240
|
+
"def compute_total() -> int:\n running_sum = 0\n return running_sum\n"
|
|
241
|
+
"\n"
|
|
242
|
+
"def aggregate() -> list[int]:\n"
|
|
243
|
+
" canned_results = [4, 5, 6]\n"
|
|
244
|
+
" return canned_results\n"
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def test_edit_drops_untouched_out_of_scope_banned_noun() -> None:
|
|
249
|
+
"""An Edit that touches none of the banned-noun bindings reports nothing —
|
|
250
|
+
the check routes through the reconstructed effective content and the edit's
|
|
251
|
+
changed lines, exactly like ``check_function_length``, so an untouched
|
|
252
|
+
binding outside the edit hunk stays out of scope."""
|
|
253
|
+
prior_tail = (
|
|
254
|
+
"def compute_total() -> int:\n running_sum = 0\n return 0\n"
|
|
255
|
+
)
|
|
256
|
+
edited_tail = EDIT_FRAGMENT_WITHOUT_BANNED_NAME
|
|
257
|
+
prior_full_file = FULL_FILE_WITH_BANNED_NAME_OUTSIDE_FRAGMENT.replace(
|
|
258
|
+
EDIT_FRAGMENT_WITHOUT_BANNED_NAME, prior_tail
|
|
259
|
+
)
|
|
260
|
+
post_edit_full_file = FULL_FILE_WITH_BANNED_NAME_OUTSIDE_FRAGMENT
|
|
261
|
+
noun_issues = validate_content(
|
|
262
|
+
edited_tail,
|
|
263
|
+
PRODUCTION_FILE_PATH,
|
|
264
|
+
old_content=prior_tail,
|
|
265
|
+
full_file_content=post_edit_full_file,
|
|
266
|
+
prior_full_file_content=prior_full_file,
|
|
267
|
+
)
|
|
268
|
+
assert not any("canned_results" in each_issue for each_issue in noun_issues), (
|
|
269
|
+
"an untouched banned-noun binding outside the edit hunk must stay out of "
|
|
270
|
+
f"scope on a diff-scoped Edit; got {noun_issues!r}"
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def test_edit_still_flags_banned_word_inside_changed_lines() -> None:
|
|
275
|
+
"""An Edit whose changed lines introduce a banned-noun binding reports it,
|
|
276
|
+
using the reconstructed effective content and the edit's changed lines."""
|
|
277
|
+
edited_tail = (
|
|
278
|
+
"def aggregate() -> list[int]:\n"
|
|
279
|
+
" canned_results = [4, 5, 6]\n"
|
|
280
|
+
" return canned_results\n"
|
|
281
|
+
)
|
|
282
|
+
prior_tail = "def aggregate() -> list[int]:\n return []\n"
|
|
283
|
+
prior_full_file = EDIT_FRAGMENT_WITHOUT_BANNED_NAME + "\n" + prior_tail
|
|
284
|
+
post_edit_full_file = EDIT_FRAGMENT_WITHOUT_BANNED_NAME + "\n" + edited_tail
|
|
285
|
+
noun_issues = validate_content(
|
|
286
|
+
edited_tail,
|
|
287
|
+
PRODUCTION_FILE_PATH,
|
|
288
|
+
old_content=prior_tail,
|
|
289
|
+
full_file_content=post_edit_full_file,
|
|
290
|
+
prior_full_file_content=prior_full_file,
|
|
291
|
+
)
|
|
292
|
+
assert any("canned_results" in each_issue for each_issue in noun_issues)
|
|
@@ -8,11 +8,18 @@ payload when a single file has many violations of the same kind.
|
|
|
8
8
|
The convention is: every check_* function should either apply an
|
|
9
9
|
explicit cap (the meta-test treats a function as capped when its source
|
|
10
10
|
contains a ``MAX_`` constant name or uses ``itertools.islice`` for bounded
|
|
11
|
-
iteration), or
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
11
|
+
iteration), or appear in DIFF_SCOPED_CHECK_FUNCTION_NAMES when its blocking
|
|
12
|
+
payload is scoped by the diff: on a terminal Edit (``all_changed_lines`` is a
|
|
13
|
+
set) only violations whose span intersects the edit's changed lines block, so
|
|
14
|
+
untouched code cannot spam the payload; on a new-file or full-file write
|
|
15
|
+
(``all_changed_lines is None``) every violation is reported because the author
|
|
16
|
+
wrote the whole file. The scoping bounds the Edit payload to the change the
|
|
17
|
+
author is making rather than imposing a fixed ceiling. A function may instead
|
|
18
|
+
be explicitly listed in KNOWN_UNCAPPED_CHECKS_PENDING_REVIEW along with the
|
|
19
|
+
reason, or appear in VOID_ADVISORY_CHECK_FUNCTION_NAMES when the function is
|
|
20
|
+
annotated ``-> None`` and never contributes issues to the blocking payload
|
|
21
|
+
(stderr-only advisories). New check_* functions added to the module without
|
|
22
|
+
consideration will trip this test.
|
|
16
23
|
"""
|
|
17
24
|
|
|
18
25
|
from __future__ import annotations
|
|
@@ -43,6 +50,14 @@ VOID_ADVISORY_CHECK_FUNCTION_NAMES: frozenset[str] = frozenset(
|
|
|
43
50
|
}
|
|
44
51
|
)
|
|
45
52
|
|
|
53
|
+
DIFF_SCOPED_CHECK_FUNCTION_NAMES: frozenset[str] = frozenset(
|
|
54
|
+
{
|
|
55
|
+
"check_banned_noun_word_boundary",
|
|
56
|
+
"check_function_length",
|
|
57
|
+
"check_tests_use_isolated_filesystem_paths",
|
|
58
|
+
}
|
|
59
|
+
)
|
|
60
|
+
|
|
46
61
|
KNOWN_UNCAPPED_CHECKS_PENDING_REVIEW: frozenset[str] = frozenset(
|
|
47
62
|
{
|
|
48
63
|
"check_boolean_naming",
|
|
@@ -102,13 +117,16 @@ def test_every_check_function_either_caps_or_is_explicitly_pending() -> None:
|
|
|
102
117
|
uncapped_check_names
|
|
103
118
|
- KNOWN_UNCAPPED_CHECKS_PENDING_REVIEW
|
|
104
119
|
- VOID_ADVISORY_CHECK_FUNCTION_NAMES
|
|
120
|
+
- DIFF_SCOPED_CHECK_FUNCTION_NAMES
|
|
105
121
|
)
|
|
106
122
|
assert unexpected_uncapped == set(), (
|
|
107
123
|
f"New check_* functions added without a cap and not on the pending-review list: "
|
|
108
124
|
f"{sorted(unexpected_uncapped)}. Either add a MAX_* cap or islice-bounded loop in "
|
|
109
|
-
f"source, or
|
|
110
|
-
f"
|
|
111
|
-
f"
|
|
125
|
+
f"source, or add the function to DIFF_SCOPED_CHECK_FUNCTION_NAMES when its blocking "
|
|
126
|
+
f"payload is bounded by diff scoping, or explicitly add it to "
|
|
127
|
+
f"KNOWN_UNCAPPED_CHECKS_PENDING_REVIEW with a reason in the test header docstring, or "
|
|
128
|
+
f"list it in VOID_ADVISORY_CHECK_FUNCTION_NAMES when it is annotated -> None and emits "
|
|
129
|
+
f"only stderr guidance."
|
|
112
130
|
)
|
|
113
131
|
|
|
114
132
|
|
|
@@ -157,6 +175,26 @@ def test_void_advisory_checks_are_registered_and_disjoint() -> None:
|
|
|
157
175
|
)
|
|
158
176
|
|
|
159
177
|
|
|
178
|
+
def test_diff_scoped_checks_are_registered_and_disjoint() -> None:
|
|
179
|
+
all_check_names = set(_all_check_function_names())
|
|
180
|
+
assert DIFF_SCOPED_CHECK_FUNCTION_NAMES <= all_check_names, (
|
|
181
|
+
f"DIFF_SCOPED_CHECK_FUNCTION_NAMES references missing names: "
|
|
182
|
+
f"{sorted(DIFF_SCOPED_CHECK_FUNCTION_NAMES - all_check_names)}"
|
|
183
|
+
)
|
|
184
|
+
for each_name in DIFF_SCOPED_CHECK_FUNCTION_NAMES:
|
|
185
|
+
function_source = inspect.getsource(getattr(_hook_module, each_name))
|
|
186
|
+
assert "all_changed_lines" in function_source or "defer_scope_to_caller" in function_source, (
|
|
187
|
+
f"DIFF_SCOPED_CHECK_FUNCTION_NAMES must list only checks bounded by diff scoping. "
|
|
188
|
+
f"{each_name!r} references neither all_changed_lines nor defer_scope_to_caller."
|
|
189
|
+
)
|
|
190
|
+
pending_overlap = DIFF_SCOPED_CHECK_FUNCTION_NAMES & KNOWN_UNCAPPED_CHECKS_PENDING_REVIEW
|
|
191
|
+
void_overlap = DIFF_SCOPED_CHECK_FUNCTION_NAMES & VOID_ADVISORY_CHECK_FUNCTION_NAMES
|
|
192
|
+
assert pending_overlap == set() and void_overlap == set(), (
|
|
193
|
+
f"Diff-scoped checks must not also appear on the pending or void-advisory lists: "
|
|
194
|
+
f"pending {sorted(pending_overlap)}, void {sorted(void_overlap)}"
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
160
198
|
def test_already_capped_checks_stay_capped() -> None:
|
|
161
199
|
capped_baseline: frozenset[str] = frozenset(
|
|
162
200
|
{
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Tests for token-anchored exempt-marker behavior in `_is_exempt_python_comment`.
|
|
2
|
+
|
|
3
|
+
A bare ``#`` appearing after a token-anchored exempt marker (``noqa``,
|
|
4
|
+
``pylint:``, ``pragma:``) marks the trailing text as a separate inline comment
|
|
5
|
+
that the no-new-comments rule must catch. The chained ``#`` triggers detection
|
|
6
|
+
whether or not it carries surrounding whitespace — glued directly to the
|
|
7
|
+
directive (``# noqa: F401#note``), lacking a trailing space (``# noqa: F401
|
|
8
|
+
#prose``), or padded on both sides (``# noqa: F401 # prose``) all fire.
|
|
9
|
+
Free-form markers (``type:``, ``TODO``, ``FIXME``, ``HACK``, ``XXX``) keep their
|
|
10
|
+
permissive behavior because ``# type:`` participates in the justification
|
|
11
|
+
convention enforced by ``check_type_escape_hatches`` and the TODO-family markers
|
|
12
|
+
carry annotation text by convention.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import importlib.util
|
|
18
|
+
import pathlib
|
|
19
|
+
import sys
|
|
20
|
+
import tokenize
|
|
21
|
+
|
|
22
|
+
_HOOK_DIR = pathlib.Path(__file__).parent
|
|
23
|
+
if str(_HOOK_DIR) not in sys.path:
|
|
24
|
+
sys.path.insert(0, str(_HOOK_DIR))
|
|
25
|
+
|
|
26
|
+
hook_spec = importlib.util.spec_from_file_location(
|
|
27
|
+
"code_rules_enforcer",
|
|
28
|
+
_HOOK_DIR / "code_rules_enforcer.py",
|
|
29
|
+
)
|
|
30
|
+
assert hook_spec is not None
|
|
31
|
+
assert hook_spec.loader is not None
|
|
32
|
+
hook_module = importlib.util.module_from_spec(hook_spec)
|
|
33
|
+
hook_spec.loader.exec_module(hook_module)
|
|
34
|
+
_is_exempt_python_comment = hook_module._is_exempt_python_comment
|
|
35
|
+
check_comments_python = hook_module.check_comments_python
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
FIXTURE_INLINE_COMMENT_LINE = 5
|
|
39
|
+
FIXTURE_INLINE_COMMENT_COLUMN = 4
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _build_comment_token(comment_text: str) -> tokenize.TokenInfo:
|
|
43
|
+
return tokenize.TokenInfo(
|
|
44
|
+
type=tokenize.COMMENT,
|
|
45
|
+
string=comment_text,
|
|
46
|
+
start=(FIXTURE_INLINE_COMMENT_LINE, FIXTURE_INLINE_COMMENT_COLUMN),
|
|
47
|
+
end=(FIXTURE_INLINE_COMMENT_LINE, FIXTURE_INLINE_COMMENT_COLUMN + len(comment_text)),
|
|
48
|
+
line=comment_text + "\n",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_should_exempt_bare_noqa_directive() -> None:
|
|
53
|
+
token = _build_comment_token("# noqa: F401")
|
|
54
|
+
assert _is_exempt_python_comment(token) is True
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_should_flag_noqa_with_chained_inline_comment() -> None:
|
|
58
|
+
token = _build_comment_token("# noqa: F401 # imported for re-export")
|
|
59
|
+
assert _is_exempt_python_comment(token) is False
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_should_flag_noqa_prefixed_prose_lacking_token_boundary() -> None:
|
|
63
|
+
token = _build_comment_token("# noqa-but-not-really: explanation")
|
|
64
|
+
assert _is_exempt_python_comment(token) is False
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_should_exempt_bare_noqa_without_code() -> None:
|
|
68
|
+
token = _build_comment_token("# noqa")
|
|
69
|
+
assert _is_exempt_python_comment(token) is True
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_should_exempt_noqa_followed_by_whitespace_then_code() -> None:
|
|
73
|
+
token = _build_comment_token("# noqa F401")
|
|
74
|
+
assert _is_exempt_python_comment(token) is True
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_should_flag_noqa_with_chained_hash_glued_directly_to_directive() -> None:
|
|
78
|
+
token = _build_comment_token("# noqa: F401#note")
|
|
79
|
+
assert _is_exempt_python_comment(token) is False
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_should_flag_noqa_with_chained_hash_lacking_trailing_space() -> None:
|
|
83
|
+
token = _build_comment_token("# noqa: F401 #prose")
|
|
84
|
+
assert _is_exempt_python_comment(token) is False
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_should_flag_pragma_with_chained_hash_glued_directly_to_directive() -> None:
|
|
88
|
+
token = _build_comment_token("# pragma: no cover#why")
|
|
89
|
+
assert _is_exempt_python_comment(token) is False
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_should_flag_pylint_with_chained_hash_lacking_trailing_space() -> None:
|
|
93
|
+
token = _build_comment_token("# pylint: disable=line-too-long #prose")
|
|
94
|
+
assert _is_exempt_python_comment(token) is False
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_should_exempt_bare_pylint_directive() -> None:
|
|
98
|
+
token = _build_comment_token("# pylint: disable=line-too-long")
|
|
99
|
+
assert _is_exempt_python_comment(token) is True
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_should_flag_pylint_with_chained_inline_comment() -> None:
|
|
103
|
+
token = _build_comment_token("# pylint: disable=line-too-long # see SO answer")
|
|
104
|
+
assert _is_exempt_python_comment(token) is False
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_should_exempt_pylint_directive_without_space_after_colon() -> None:
|
|
108
|
+
token = _build_comment_token("# pylint:disable=unused-import")
|
|
109
|
+
assert _is_exempt_python_comment(token) is True
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_should_exempt_pragma_directive_without_space_after_colon() -> None:
|
|
113
|
+
token = _build_comment_token("# pragma:no-cover")
|
|
114
|
+
assert _is_exempt_python_comment(token) is True
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_should_exempt_type_ignore_without_space_after_colon() -> None:
|
|
118
|
+
token = _build_comment_token("# type:ignore")
|
|
119
|
+
assert _is_exempt_python_comment(token) is True
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_should_flag_noqa_glued_prose_lacking_real_boundary() -> None:
|
|
123
|
+
token = _build_comment_token("# noqaFOO")
|
|
124
|
+
assert _is_exempt_python_comment(token) is False
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_should_exempt_bare_pragma_directive() -> None:
|
|
128
|
+
token = _build_comment_token("# pragma: no cover")
|
|
129
|
+
assert _is_exempt_python_comment(token) is True
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_should_flag_pragma_with_chained_inline_comment() -> None:
|
|
133
|
+
token = _build_comment_token("# pragma: no cover # only exercised in nightly")
|
|
134
|
+
assert _is_exempt_python_comment(token) is False
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def test_should_still_exempt_type_ignore_with_trailing_justification() -> None:
|
|
138
|
+
token = _build_comment_token("# type: ignore[misc] # stubs missing in foo library")
|
|
139
|
+
assert _is_exempt_python_comment(token) is True
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_should_exempt_bare_type_ignore_directive() -> None:
|
|
143
|
+
token = _build_comment_token("# type: ignore")
|
|
144
|
+
assert _is_exempt_python_comment(token) is True
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def test_should_exempt_todo_with_trailing_prose() -> None:
|
|
148
|
+
token = _build_comment_token("# TODO: rename after deprecation period")
|
|
149
|
+
assert _is_exempt_python_comment(token) is True
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_should_exempt_fixme_with_trailing_prose() -> None:
|
|
153
|
+
token = _build_comment_token("# FIXME(jdoe): retry budget feels wrong")
|
|
154
|
+
assert _is_exempt_python_comment(token) is True
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_should_exempt_shebang_at_line_one_column_zero() -> None:
|
|
158
|
+
token = tokenize.TokenInfo(
|
|
159
|
+
type=tokenize.COMMENT,
|
|
160
|
+
string="#!/usr/bin/env python3",
|
|
161
|
+
start=(1, 0),
|
|
162
|
+
end=(1, 22),
|
|
163
|
+
line="#!/usr/bin/env python3\n",
|
|
164
|
+
)
|
|
165
|
+
assert _is_exempt_python_comment(token) is True
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def test_should_flag_shebang_lookalike_off_line_one() -> None:
|
|
169
|
+
token = tokenize.TokenInfo(
|
|
170
|
+
type=tokenize.COMMENT,
|
|
171
|
+
string="#!/usr/bin/env python3",
|
|
172
|
+
start=(42, 0),
|
|
173
|
+
end=(42, 22),
|
|
174
|
+
line="#!/usr/bin/env python3\n",
|
|
175
|
+
)
|
|
176
|
+
assert _is_exempt_python_comment(token) is False
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def test_check_comments_python_flags_chained_noqa_inline_comment() -> None:
|
|
180
|
+
source = "x = 1 # noqa: F401 # this is just a description\n"
|
|
181
|
+
issues = check_comments_python(source)
|
|
182
|
+
assert issues, "chained noqa inline comment must be flagged"
|
|
183
|
+
assert "Line 1" in issues[0]
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def test_check_comments_python_does_not_flag_justified_type_ignore() -> None:
|
|
187
|
+
source = "x = some_value() # type: ignore[arg-type] # third-party stub gap\n"
|
|
188
|
+
issues = check_comments_python(source)
|
|
189
|
+
assert issues == []
|