claude-dev-env 8.106.3 → 8.106.5
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/code_rules_gate_parts/tests/test_gate_running.py +1 -1
- package/docs/CODE_RULES.md +3 -3
- package/docs/code-rules/core-principles-and-config.md +2 -1
- package/hooks/blocking/code_rules_magic_values.py +134 -1
- package/hooks/blocking/test_code_rules_enforcer_stage.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_string_magic.py +1 -1
- package/hooks/blocking/test_code_rules_magic_values.py +162 -0
- package/hooks/blocking/test_code_rules_validation_phase.py +1 -1
- package/hooks/validators/magic_value_checks.py +77 -12
- package/hooks/validators/test_magic_value_checks.py +88 -4
- package/hooks/validators/test_run_all_validators_pretooluse.py +1 -1
- package/hooks/validators/test_todo_checks.py +13 -1
- package/hooks/validators/test_validator_base.py +19 -1
- package/hooks/validators/todo_checks.py +2 -12
- package/hooks/validators/validator_base.py +33 -2
- package/package.json +1 -1
- package/scripts/policy_lint/config/check_catalog_constants.py +6 -0
- package/scripts/tests/test_policy_lint_rules_check_catalog.py +21 -0
|
@@ -119,7 +119,7 @@ def test_gate_caller_scans_staged_content_with_commit_stage_scope(
|
|
|
119
119
|
init_repository(repository_root)
|
|
120
120
|
committed_source = "def calculate_total() -> int:\n return 0\n"
|
|
121
121
|
staged_source = (
|
|
122
|
-
"def calculate_total() -> int:\n total = 9999\n return total\n"
|
|
122
|
+
"def calculate_total() -> int:\n total = abs(9999)\n return total\n"
|
|
123
123
|
)
|
|
124
124
|
file_path = write_commit_and_stage_change(
|
|
125
125
|
repository_root,
|
package/docs/CODE_RULES.md
CHANGED
|
@@ -13,13 +13,13 @@ A keep marker is the one comment that may be added and kept: a comment that open
|
|
|
13
13
|
|
|
14
14
|
## CORE PRINCIPLES
|
|
15
15
|
|
|
16
|
-
Use clear names. Keep shared constants in `config
|
|
16
|
+
Use clear names. Keep shared and tuned constants in `config/`; a value used once stays in its function under a local name. Search before adding helpers. Put construction and formatting with the data owner. Encapsulation enables cleaner naming, so prefer `isMaxLevel(level)` to `level >= MAXIMUM_LEVEL`. Session policy lives in [`code-standards.md`](../rules/code-standards.md).
|
|
17
17
|
|
|
18
18
|
Open [details](code-rules/core-principles-and-config.md) when adding shared values or construction logic.
|
|
19
19
|
|
|
20
20
|
## ⚡ LINT-ENFORCED RULES
|
|
21
21
|
|
|
22
|
-
Staged lint runs `code_rules_enforcer.py`. Keep imports at the top, parameterize logging, guard path insertion, use specific exceptions, and follow checks for values, types, docstrings, names, and tests. Magic values exempt 0, 1, and -1. Type escape hatches are allowed in boundary files. Stub bodies are allowed in abstract and Protocol classes.
|
|
22
|
+
Staged lint runs `code_rules_enforcer.py`. Keep imports at the top, parameterize logging, guard path insertion, use specific exceptions, and follow checks for values, types, docstrings, names, and tests. Magic values exempt 0, 1, and -1, and a number bound alone to a named local passes. Type escape hatches are allowed in boundary files. Stub bodies are allowed in abstract and Protocol classes.
|
|
23
23
|
|
|
24
24
|
- UPPER_SNAKE constants belong in `config/`. Exemptions include `config/*`, `/migrations/`, and test paths or names matching `test_`, `_test.`, `.spec.`, `conftest`, or `/tests/`.
|
|
25
25
|
- Workflow registries: a path that contains any of these substrings, `/workflow/`, `_tab.py`, `/states.py`, or `/modules.py`, is exempt; each matches independently as a substring.
|
|
@@ -62,7 +62,7 @@ Open [details](code-rules/design-and-structure.md) when adding a class or parame
|
|
|
62
62
|
|
|
63
63
|
## 7.5 SOLID PRINCIPLES
|
|
64
64
|
|
|
65
|
-
|
|
65
|
+
Keep a function whole while it reads top to bottom. Split it when the new piece has a second caller or makes the caller easier to read, or when it reaches the 60-line cap the lint and the write-time hook enforce. Apply OCP, LSP, ISP, and DIP when two concrete implementations share a contract. Keep cohesive classes together.
|
|
66
66
|
|
|
67
67
|
Open [details](code-rules/design-and-structure.md) when extracting a responsibility.
|
|
68
68
|
|
|
@@ -24,7 +24,7 @@ I search for shared code and configuration before adding a constant or repeating
|
|
|
24
24
|
|
|
25
25
|
The code-rules enforcer runs constant, path, magic-value, and duplicate-format checks on changed *.py files. String and collection checks inspect production function bodies in *.py files.
|
|
26
26
|
|
|
27
|
-
Search config for the exact value and a semantic match. Add timing to config/timing.py, ports and thresholds to config/constants.py, and selectors to config/selectors.py. Keep construction and formatting with the owner of the data. Constants outside config are exempt in migrations, workflow registries, and test files. Workflow registry matching uses path substrings /workflow/, _tab.py, /states.py, and /modules.py. The test-file patterns include test_, _test., .spec., conftest, and /tests/.
|
|
27
|
+
A value used once stays in its function under a local name, such as `retry_backoff_factor = 2.5`. A value moves to config when two modules share it or someone tunes it, such as a timeout, threshold, port, or selector. Search config for the exact value and a semantic match. Add timing to config/timing.py, ports and thresholds to config/constants.py, and selectors to config/selectors.py. Keep construction and formatting with the owner of the data. Constants outside config are exempt in migrations, workflow registries, and test files. Workflow registry matching uses path substrings /workflow/, _tab.py, /states.py, and /modules.py. The test-file patterns include test_, _test., .spec., conftest, and /tests/.
|
|
28
28
|
|
|
29
29
|
## Proving it
|
|
30
30
|
|
|
@@ -35,6 +35,7 @@ Run the named pytest node or command with the described input. A breach produces
|
|
|
35
35
|
- check_constants_outside_config_advisory: a function-local UPPER_SNAKE constant; `python -m pytest packages/claude-dev-env/hooks/blocking/test_code_rules_enforcer_split_constants_config.py::test_advisory_should_flag_annotated_function_body_constant` reports a stderr advisory.
|
|
36
36
|
- check_fstring_structural_literals: an f-string that embeds a URL path fragment; `python -m pytest packages/claude-dev-env/hooks/blocking/test_code_rules_enforcer_fstring_scan.py::test_should_flag_fstring_with_url_path` reports a named violation.
|
|
37
37
|
- check_magic_values: a production function comparing a value with 2; `python -m pytest packages/claude-dev-env/hooks/blocking/test_code_rules_enforcer_magic_allowlist.py::test_check_magic_values_should_flag_literal_two_in_function_body` reports a named violation.
|
|
38
|
+
- check_magic_values exemption: a function binding `retry_backoff_factor = 2.5` alone on its line; `python -m pytest packages/claude-dev-env/hooks/blocking/test_code_rules_magic_values.py::test_should_pass_a_number_bound_to_a_local_name` passes with no violation.
|
|
38
39
|
- check_duplicated_format_patterns: the same f-string skeleton at three call sites; `python -m pytest packages/claude-dev-env/hooks/blocking/test_code_rules_enforcer_split_optional_params.py::test_should_advise_when_fstring_skeleton_appears_three_or_more_times` reports a stderr advisory.
|
|
39
40
|
- check_hardcoded_user_paths: a fixed user-home path in source; `python -m pytest packages/claude-dev-env/hooks/blocking/test_code_rules_enforcer_hardcoded_user_path.py::test_should_flag_windows_user_path_with_forward_slashes` reports a named violation.
|
|
40
41
|
- check_sys_path_insert_deduplication_guard: an unguarded module-level sys.path.insert call; `python -m pytest packages/claude-dev-env/hooks/blocking/test_code_rules_enforcer_sys_path_insert.py::test_should_flag_unguarded_module_level_insert` reports a named violation.
|
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
import ast
|
|
4
4
|
import re
|
|
5
5
|
import sys
|
|
6
|
+
import textwrap
|
|
7
|
+
from collections import Counter
|
|
8
|
+
from collections.abc import Iterator
|
|
6
9
|
from pathlib import Path
|
|
7
10
|
|
|
8
11
|
_blocking_directory = str(Path(__file__).resolve().parent)
|
|
@@ -148,15 +151,135 @@ def _is_magic_number_inside_slice_bound(masked_line: str, number_text: str) -> b
|
|
|
148
151
|
return False
|
|
149
152
|
|
|
150
153
|
|
|
154
|
+
def _named_number_binding_line_numbers(content: str) -> set[int] | None:
|
|
155
|
+
"""Return the lines where a function binds one number literal to one name.
|
|
156
|
+
|
|
157
|
+
The name carries the meaning, so the number needs no constant::
|
|
158
|
+
|
|
159
|
+
def backoff_seconds(attempt: int) -> float:
|
|
160
|
+
retry_backoff_factor = 2.5
|
|
161
|
+
|
|
162
|
+
A binding sharing its line with another statement, a number in an
|
|
163
|
+
expression, a keyword argument, a module-level binding, and a ``global``
|
|
164
|
+
or ``nonlocal`` name still fire. A fragment is dedented before the
|
|
165
|
+
parse; content that does not parse returns None.
|
|
166
|
+
"""
|
|
167
|
+
try:
|
|
168
|
+
syntax_tree = ast.parse(textwrap.dedent(content))
|
|
169
|
+
except SyntaxError:
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
all_outer_scope_names = _global_and_nonlocal_names(syntax_tree)
|
|
173
|
+
statement_count_by_line = Counter(
|
|
174
|
+
each_node.lineno for each_node in ast.walk(syntax_tree) if isinstance(each_node, ast.stmt)
|
|
175
|
+
)
|
|
176
|
+
return {
|
|
177
|
+
each_node.lineno
|
|
178
|
+
for each_node in _nodes_inside_functions(syntax_tree)
|
|
179
|
+
if isinstance(each_node, ast.stmt)
|
|
180
|
+
and _number_bound_name(each_node) not in (None, *all_outer_scope_names)
|
|
181
|
+
and each_node.lineno == each_node.end_lineno
|
|
182
|
+
and statement_count_by_line[each_node.lineno] == 1
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _global_and_nonlocal_names(syntax_tree: ast.AST) -> set[str]:
|
|
187
|
+
"""Return every name a ``global`` or ``nonlocal`` statement declares in the file."""
|
|
188
|
+
return {
|
|
189
|
+
each_name
|
|
190
|
+
for each_node in ast.walk(syntax_tree)
|
|
191
|
+
if isinstance(each_node, (ast.Global, ast.Nonlocal))
|
|
192
|
+
for each_name in each_node.names
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _nodes_inside_functions(syntax_tree: ast.AST) -> Iterator[ast.AST]:
|
|
197
|
+
"""Yield every node whose nearest enclosing scope is a function, each one once."""
|
|
198
|
+
all_pending: list[tuple[ast.AST, bool]] = [(syntax_tree, False)]
|
|
199
|
+
while all_pending:
|
|
200
|
+
node, is_inside_function = all_pending.pop()
|
|
201
|
+
if is_inside_function:
|
|
202
|
+
yield node
|
|
203
|
+
is_child_inside_function = not isinstance(node, ast.ClassDef) and (
|
|
204
|
+
is_inside_function or isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
|
205
|
+
)
|
|
206
|
+
all_pending.extend((each_child, is_child_inside_function) for each_child in ast.iter_child_nodes(node))
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _fragment_global_and_nonlocal_names(content: str) -> set[str]:
|
|
210
|
+
"""Return every name a ``global`` or ``nonlocal`` line declares in text that does not parse."""
|
|
211
|
+
return {
|
|
212
|
+
each_name.strip()
|
|
213
|
+
for each_match in re.finditer(r"^[ \t]*(?:global|nonlocal)[ \t]+([\w \t,]+)", content, re.MULTILINE)
|
|
214
|
+
for each_name in each_match.group(1).split(",")
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _is_fragment_named_number_line(
|
|
219
|
+
all_lines: list[str], line_number: int, function_indent: int, all_outer_scope_names: set[str]
|
|
220
|
+
) -> bool:
|
|
221
|
+
"""Return True when a line of unparseable content binds one number to one name in a function.
|
|
222
|
+
|
|
223
|
+
An Edit fragment that ends partway through a block does not parse whole::
|
|
224
|
+
|
|
225
|
+
def backoff_seconds(attempt: int) -> float:
|
|
226
|
+
retry_backoff_factor = 2.5
|
|
227
|
+
if attempt > 0:
|
|
228
|
+
|
|
229
|
+
The line must sit deeper than the last ``def`` line and parse alone as
|
|
230
|
+
the binding, so ``retry_backoff_factor = 2.5`` passes there. A name a
|
|
231
|
+
``global`` or ``nonlocal`` line declares still fires, and so does a line
|
|
232
|
+
after one ending in ``,``, ``(``, ``[`` or ``{``, such as the last
|
|
233
|
+
keyword argument of a call split across lines.
|
|
234
|
+
"""
|
|
235
|
+
line = all_lines[line_number - 1]
|
|
236
|
+
stripped_line = line.strip()
|
|
237
|
+
all_earlier_code_lines = [each_line.strip() for each_line in all_lines[: line_number - 1] if each_line.strip()]
|
|
238
|
+
is_inside_brackets = bool(all_earlier_code_lines) and all_earlier_code_lines[-1].endswith((",", "(", "[", "{"))
|
|
239
|
+
if is_inside_brackets or len(line) - len(line.lstrip()) <= function_indent:
|
|
240
|
+
return False
|
|
241
|
+
try:
|
|
242
|
+
line_tree = ast.parse(stripped_line)
|
|
243
|
+
except SyntaxError:
|
|
244
|
+
return False
|
|
245
|
+
return len(line_tree.body) == 1 and _number_bound_name(line_tree.body[0]) not in (None, *all_outer_scope_names)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _number_bound_name(node: ast.AST) -> str | None:
|
|
249
|
+
"""Return the name ``node`` binds one signed number literal to, else None."""
|
|
250
|
+
if isinstance(node, ast.Assign):
|
|
251
|
+
all_targets, bound_value = node.targets, node.value
|
|
252
|
+
elif isinstance(node, ast.AnnAssign) and node.value is not None:
|
|
253
|
+
all_targets, bound_value = [node.target], node.value
|
|
254
|
+
else:
|
|
255
|
+
return None
|
|
256
|
+
if len(all_targets) != 1 or not isinstance(all_targets[0], ast.Name):
|
|
257
|
+
return None
|
|
258
|
+
while isinstance(bound_value, ast.UnaryOp) and isinstance(bound_value.op, (ast.USub, ast.UAdd)):
|
|
259
|
+
bound_value = bound_value.operand
|
|
260
|
+
is_number_literal = isinstance(bound_value, ast.Constant) and type(bound_value.value) in (int, float)
|
|
261
|
+
return all_targets[0].id if is_number_literal else None
|
|
262
|
+
|
|
263
|
+
|
|
151
264
|
def check_magic_values(content: str, file_path: str) -> list[str]:
|
|
152
|
-
"""Check for magic values in function bodies.
|
|
265
|
+
"""Check for magic values in function bodies.
|
|
266
|
+
|
|
267
|
+
A line inside a function that binds a single number literal to a name
|
|
268
|
+
passes, since the name explains the number. A number inside a larger
|
|
269
|
+
expression fires.
|
|
270
|
+
"""
|
|
153
271
|
if is_config_file(file_path) or is_test_file(file_path):
|
|
154
272
|
return []
|
|
155
273
|
|
|
156
274
|
issues = []
|
|
157
275
|
lines = content.split("\n")
|
|
158
276
|
all_docstring_line_numbers = docstring_line_numbers(content)
|
|
277
|
+
all_named_number_line_numbers = _named_number_binding_line_numbers(content)
|
|
278
|
+
all_fragment_outer_scope_names = (
|
|
279
|
+
_fragment_global_and_nonlocal_names(content) if all_named_number_line_numbers is None else set()
|
|
280
|
+
)
|
|
159
281
|
is_inside_function = False
|
|
282
|
+
function_indent = 0
|
|
160
283
|
|
|
161
284
|
number_pattern = re.compile(r"(?<![.\w])(\d+\.?\d*)(?![.\w])")
|
|
162
285
|
allowed_numbers = ALL_ALLOWED_MAGIC_NUMBER_LITERALS
|
|
@@ -172,6 +295,7 @@ def check_magic_values(content: str, file_path: str) -> list[str]:
|
|
|
172
295
|
|
|
173
296
|
if re.match(r"^(async\s+)?def\s+\w+", stripped):
|
|
174
297
|
is_inside_function = True
|
|
298
|
+
function_indent = len(each_line) - len(each_line.lstrip())
|
|
175
299
|
continue
|
|
176
300
|
|
|
177
301
|
if re.match(r"^class\s+\w+", stripped):
|
|
@@ -182,6 +306,15 @@ def check_magic_values(content: str, file_path: str) -> list[str]:
|
|
|
182
306
|
if "=" in stripped and stripped.split("=")[0].strip().isupper():
|
|
183
307
|
continue
|
|
184
308
|
|
|
309
|
+
if all_named_number_line_numbers is None:
|
|
310
|
+
is_named_number_line = _is_fragment_named_number_line(
|
|
311
|
+
lines, each_line_number, function_indent, all_fragment_outer_scope_names
|
|
312
|
+
)
|
|
313
|
+
else:
|
|
314
|
+
is_named_number_line = each_line_number in all_named_number_line_numbers
|
|
315
|
+
if is_named_number_line:
|
|
316
|
+
continue
|
|
317
|
+
|
|
185
318
|
if stripped.startswith(("return", "yield", "raise")):
|
|
186
319
|
continue
|
|
187
320
|
|
|
@@ -171,7 +171,7 @@ def test_gate_caller_scans_staged_content_with_commit_stage_scope(
|
|
|
171
171
|
committed_source = "def calculate_total() -> int:\n return 0\n"
|
|
172
172
|
staged_source = (
|
|
173
173
|
"def calculate_total() -> int:\n"
|
|
174
|
-
" total = 9999\n"
|
|
174
|
+
" total = abs(9999)\n"
|
|
175
175
|
" return total\n"
|
|
176
176
|
)
|
|
177
177
|
file_path = write_commit_and_stage_change(
|
|
@@ -317,7 +317,7 @@ def test_should_block_second_identical_string_magic_per_occurrence() -> None:
|
|
|
317
317
|
def test_should_surface_magic_when_gate_defers_scope_with_head_as_old() -> None:
|
|
318
318
|
source = (
|
|
319
319
|
"def compute() -> int:\n"
|
|
320
|
-
" old_number = 9999\n"
|
|
320
|
+
" old_number = abs(9999)\n"
|
|
321
321
|
" return old_number\n"
|
|
322
322
|
)
|
|
323
323
|
issues = code_rules_enforcer.validate_content_for_full_gate(
|
|
@@ -52,3 +52,165 @@ def test_magic_value_check_still_flags_real_magic_number_in_code_statement() ->
|
|
|
52
52
|
assert any(issue.endswith("Magic value 42 - extract to named constant") for issue in issues), (
|
|
53
53
|
f"Expected the real magic number 42 to stay flagged, got: {issues}"
|
|
54
54
|
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _magic_issues_for_function_line(function_body_line: str) -> list[str]:
|
|
58
|
+
source = f"def backoff_seconds(attempt: int) -> float:\n {function_body_line}\n return attempt\n"
|
|
59
|
+
return check_magic_values(source, PRODUCTION_FILE_PATH)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_should_pass_a_number_bound_to_a_local_name() -> None:
|
|
63
|
+
assert _magic_issues_for_function_line("retry_backoff_factor = 2.5") == []
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_should_pass_an_annotated_number_bound_to_a_local_name() -> None:
|
|
67
|
+
assert _magic_issues_for_function_line("header_row_count: int | None = 30") == []
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_should_pass_a_named_number_with_a_trailing_comment() -> None:
|
|
71
|
+
assert _magic_issues_for_function_line("header_row_count = 30 # rows") == []
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_should_pass_a_negative_number_bound_to_a_local_name() -> None:
|
|
75
|
+
assert _magic_issues_for_function_line("lowest_offset = -40") == []
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_should_flag_a_number_inside_an_expression() -> None:
|
|
79
|
+
assert _magic_issues_for_function_line("backoff = attempt * 2.5") == [
|
|
80
|
+
"Line 2: Magic value 2.5 - extract to named constant"
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_should_flag_a_number_in_a_comparison_assignment() -> None:
|
|
85
|
+
assert _magic_issues_for_function_line("is_slow = attempt > 40") == [
|
|
86
|
+
"Line 2: Magic value 40 - extract to named constant"
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_should_flag_a_keyword_argument_on_its_own_line() -> None:
|
|
91
|
+
source = "def backoff_seconds(attempt: int) -> float:\n wait_for(\n attempt,\n timeout=30\n )\n return attempt\n"
|
|
92
|
+
assert check_magic_values(source, PRODUCTION_FILE_PATH) == [
|
|
93
|
+
"Line 4: Magic value 30 - extract to named constant"
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_should_flag_a_module_level_binding_after_a_function() -> None:
|
|
98
|
+
source = "def backoff_seconds(attempt: int) -> float:\n return attempt\nretry_limit = 30\n"
|
|
99
|
+
assert check_magic_values(source, PRODUCTION_FILE_PATH) == [
|
|
100
|
+
"Line 3: Magic value 30 - extract to named constant"
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_should_pass_a_positive_signed_number_bound_to_a_local_name() -> None:
|
|
105
|
+
assert _magic_issues_for_function_line("lowest_offset = +40") == []
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_should_flag_a_number_sharing_a_line_with_a_named_binding() -> None:
|
|
109
|
+
assert _magic_issues_for_function_line("limit = 5; scaled = attempt * 99") == [
|
|
110
|
+
"Line 2: Magic value 5 - extract to named constant"
|
|
111
|
+
]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_should_flag_two_named_bindings_sharing_one_line() -> None:
|
|
115
|
+
assert _magic_issues_for_function_line("low_offset = 30; high_offset = 40") == [
|
|
116
|
+
"Line 2: Magic value 30 - extract to named constant"
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_should_flag_a_condition_number_beside_a_named_binding() -> None:
|
|
121
|
+
assert _magic_issues_for_function_line("if attempt > 99: limit = 40") == [
|
|
122
|
+
"Line 2: Magic value 99 - extract to named constant"
|
|
123
|
+
]
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_should_pass_a_named_number_in_an_indented_method_fragment() -> None:
|
|
127
|
+
fragment = " def backoff_seconds(self) -> float:\n retry_backoff_factor = 2.5\n return retry_backoff_factor\n"
|
|
128
|
+
assert check_magic_values(fragment, PRODUCTION_FILE_PATH) == []
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_should_pass_a_doubly_signed_number_bound_to_a_local_name() -> None:
|
|
132
|
+
assert _magic_issues_for_function_line("lowest_offset = --40") == []
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_should_flag_a_keyword_argument_in_an_indented_method_fragment() -> None:
|
|
136
|
+
fragment = " def wait(self) -> None:\n wait_for(\n self,\n timeout=30\n )\n"
|
|
137
|
+
assert check_magic_values(fragment, PRODUCTION_FILE_PATH) == [
|
|
138
|
+
"Line 4: Magic value 30 - extract to named constant"
|
|
139
|
+
]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_should_flag_a_module_level_binding_in_unparseable_content() -> None:
|
|
143
|
+
source = "def build() -> int:\n return 0\nretry_limit = 30\nprint 'x'\n"
|
|
144
|
+
assert check_magic_values(source, PRODUCTION_FILE_PATH) == [
|
|
145
|
+
"Line 3: Magic value 30 - extract to named constant"
|
|
146
|
+
]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_should_flag_a_number_bound_to_a_global_name() -> None:
|
|
150
|
+
source = "retry_limit = 0\ndef raise_limit() -> None:\n global retry_limit\n retry_limit = 40\n"
|
|
151
|
+
assert check_magic_values(source, PRODUCTION_FILE_PATH) == [
|
|
152
|
+
"Line 4: Magic value 40 - extract to named constant"
|
|
153
|
+
]
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_should_pass_a_named_number_in_a_fragment_ending_inside_a_block() -> None:
|
|
157
|
+
source = "def backoff_seconds(attempt: int) -> float:\n retry_backoff_factor = 2.5\n if attempt > 0:\n"
|
|
158
|
+
assert check_magic_values(source, PRODUCTION_FILE_PATH) == []
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def test_should_pass_a_named_number_in_a_method_fragment_with_a_column_zero_string() -> None:
|
|
162
|
+
source = (
|
|
163
|
+
" def backoff_seconds(self) -> float:\n"
|
|
164
|
+
" retry_backoff_factor = 2.5\n"
|
|
165
|
+
' banner = """\n'
|
|
166
|
+
"hello\n"
|
|
167
|
+
'"""\n'
|
|
168
|
+
" return retry_backoff_factor\n"
|
|
169
|
+
)
|
|
170
|
+
assert check_magic_values(source, PRODUCTION_FILE_PATH) == []
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def test_should_flag_a_global_name_binding_in_a_fragment_ending_inside_a_block() -> None:
|
|
174
|
+
source = "def raise_limit(attempt: int) -> None:\n global retry_limit\n retry_limit = 40\n if attempt > 0:\n"
|
|
175
|
+
assert check_magic_values(source, PRODUCTION_FILE_PATH) == [
|
|
176
|
+
"Line 3: Magic value 40 - extract to named constant"
|
|
177
|
+
]
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def test_should_flag_the_last_keyword_argument_in_a_fragment_ending_inside_a_block() -> None:
|
|
181
|
+
source = (
|
|
182
|
+
"def fetch(address: str) -> None:\n"
|
|
183
|
+
" response = send(\n"
|
|
184
|
+
" address,\n"
|
|
185
|
+
" timeout=30\n"
|
|
186
|
+
" )\n"
|
|
187
|
+
" if response:\n"
|
|
188
|
+
)
|
|
189
|
+
assert check_magic_values(source, PRODUCTION_FILE_PATH) == [
|
|
190
|
+
"Line 4: Magic value 30 - extract to named constant"
|
|
191
|
+
]
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def test_should_flag_each_name_of_consecutive_global_lines_in_a_fragment() -> None:
|
|
195
|
+
source = (
|
|
196
|
+
"def tally(attempt: int) -> None:\n"
|
|
197
|
+
" global total\n"
|
|
198
|
+
" global count\n"
|
|
199
|
+
" total = 7\n"
|
|
200
|
+
" if attempt > 0:\n"
|
|
201
|
+
)
|
|
202
|
+
assert check_magic_values(source, PRODUCTION_FILE_PATH) == [
|
|
203
|
+
"Line 4: Magic value 7 - extract to named constant"
|
|
204
|
+
]
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def test_should_flag_a_global_name_declared_beside_a_comment_in_a_fragment() -> None:
|
|
208
|
+
source = (
|
|
209
|
+
"def tally(attempt: int) -> None:\n"
|
|
210
|
+
" global total # shared\n"
|
|
211
|
+
" total = 7\n"
|
|
212
|
+
" if attempt > 0:\n"
|
|
213
|
+
)
|
|
214
|
+
assert check_magic_values(source, PRODUCTION_FILE_PATH) == [
|
|
215
|
+
"Line 3: Magic value 7 - extract to named constant"
|
|
216
|
+
]
|
|
@@ -185,7 +185,7 @@ def _write_scoped_service_file_with_a_committed_baseline(repository_root: Path)
|
|
|
185
185
|
repository_root,
|
|
186
186
|
"service.py",
|
|
187
187
|
"def read_port() -> int:\n return 0\n",
|
|
188
|
-
"def read_port() -> int:\n port_number = 9999\n return port_number\n",
|
|
188
|
+
"def read_port() -> int:\n port_number = abs(9999)\n return port_number\n",
|
|
189
189
|
)
|
|
190
190
|
|
|
191
191
|
|
|
@@ -9,6 +9,7 @@ Note: Only checks for magic numbers. Magic string detection is not implemented.
|
|
|
9
9
|
import ast
|
|
10
10
|
import re
|
|
11
11
|
import sys
|
|
12
|
+
from collections import Counter
|
|
12
13
|
from pathlib import Path
|
|
13
14
|
from typing import Dict, FrozenSet, List, Set, Tuple, Type
|
|
14
15
|
|
|
@@ -16,7 +17,7 @@ from .exempt_paths import (
|
|
|
16
17
|
is_config_file,
|
|
17
18
|
is_test_file,
|
|
18
19
|
)
|
|
19
|
-
from .validator_base import Violation
|
|
20
|
+
from .validator_base import Violation, run_file_validator_cli
|
|
20
21
|
|
|
21
22
|
|
|
22
23
|
ALLOWED_NUMBERS: FrozenSet[int] = frozenset({-1, 0, 1})
|
|
@@ -35,6 +36,8 @@ def check_magic_values(tree: ast.AST, filename: str) -> List[Violation]:
|
|
|
35
36
|
violations: List[Violation] = []
|
|
36
37
|
negated_literal_ids: Set[int] = set()
|
|
37
38
|
parent_by_child_id = _build_parent_map(tree)
|
|
39
|
+
all_outer_scope_names = _global_and_nonlocal_names(tree)
|
|
40
|
+
all_shared_statement_lines = _lines_holding_several_statements(tree)
|
|
38
41
|
|
|
39
42
|
for node in ast.walk(tree):
|
|
40
43
|
if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.USub):
|
|
@@ -55,6 +58,10 @@ def check_magic_values(tree: ast.AST, filename: str) -> List[Violation]:
|
|
|
55
58
|
continue
|
|
56
59
|
if _is_in_constant_definition(outermost_unary, parent_by_child_id):
|
|
57
60
|
continue
|
|
61
|
+
if _is_named_function_local_binding(
|
|
62
|
+
outermost_unary, parent_by_child_id, all_outer_scope_names, all_shared_statement_lines
|
|
63
|
+
):
|
|
64
|
+
continue
|
|
58
65
|
violations.append(
|
|
59
66
|
Violation(
|
|
60
67
|
filename,
|
|
@@ -74,6 +81,10 @@ def check_magic_values(tree: ast.AST, filename: str) -> List[Violation]:
|
|
|
74
81
|
continue
|
|
75
82
|
if _is_in_constant_definition(node, parent_by_child_id):
|
|
76
83
|
continue
|
|
84
|
+
if _is_named_function_local_binding(
|
|
85
|
+
node, parent_by_child_id, all_outer_scope_names, all_shared_statement_lines
|
|
86
|
+
):
|
|
87
|
+
continue
|
|
77
88
|
violations.append(
|
|
78
89
|
Violation(
|
|
79
90
|
filename,
|
|
@@ -130,6 +141,70 @@ def _is_in_constant_definition(
|
|
|
130
141
|
return False
|
|
131
142
|
|
|
132
143
|
|
|
144
|
+
def _global_and_nonlocal_names(tree: ast.AST) -> Set[str]:
|
|
145
|
+
"""Return every name a ``global`` or ``nonlocal`` statement declares in the tree."""
|
|
146
|
+
return {
|
|
147
|
+
each_name
|
|
148
|
+
for each_node in ast.walk(tree)
|
|
149
|
+
if isinstance(each_node, (ast.Global, ast.Nonlocal))
|
|
150
|
+
for each_name in each_node.names
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _lines_holding_several_statements(tree: ast.AST) -> Set[int]:
|
|
155
|
+
"""Return the line numbers where more than one statement starts."""
|
|
156
|
+
statement_count_by_line = Counter(
|
|
157
|
+
each_node.lineno for each_node in ast.walk(tree) if isinstance(each_node, ast.stmt)
|
|
158
|
+
)
|
|
159
|
+
return {each_line for each_line, each_count in statement_count_by_line.items() if each_count > 1}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _is_named_function_local_binding(
|
|
163
|
+
node: ast.AST,
|
|
164
|
+
parent_by_child_id: Dict[int, ast.AST],
|
|
165
|
+
all_outer_scope_names: Set[str],
|
|
166
|
+
all_shared_statement_lines: Set[int],
|
|
167
|
+
) -> bool:
|
|
168
|
+
"""Return True when a function binds ``node`` alone to one plain name.
|
|
169
|
+
|
|
170
|
+
``header_row_count = 30`` on one line inside a function passes, since
|
|
171
|
+
the name explains the number. A number inside an expression, a default
|
|
172
|
+
parameter, a keyword argument, a module-level or class-level binding, a
|
|
173
|
+
binding that shares its line with another statement, and a binding to a
|
|
174
|
+
name the tree declares ``global`` or ``nonlocal`` still fire.
|
|
175
|
+
"""
|
|
176
|
+
bound_value: ast.AST = node
|
|
177
|
+
parent = parent_by_child_id.get(id(bound_value))
|
|
178
|
+
while isinstance(parent, ast.UnaryOp) and isinstance(parent.op, (ast.USub, ast.UAdd)):
|
|
179
|
+
bound_value = parent
|
|
180
|
+
parent = parent_by_child_id.get(id(bound_value))
|
|
181
|
+
if not isinstance(parent, (ast.Assign, ast.AnnAssign)) or parent.value is not bound_value:
|
|
182
|
+
return False
|
|
183
|
+
all_targets = parent.targets if isinstance(parent, ast.Assign) else [parent.target]
|
|
184
|
+
if len(all_targets) != 1 or not isinstance(all_targets[0], ast.Name):
|
|
185
|
+
return False
|
|
186
|
+
is_one_line = parent.lineno == parent.end_lineno
|
|
187
|
+
is_alone_on_line = parent.lineno not in all_shared_statement_lines
|
|
188
|
+
if not (is_one_line and is_alone_on_line) or all_targets[0].id in all_outer_scope_names:
|
|
189
|
+
return False
|
|
190
|
+
return _encloses_in_function(parent, parent_by_child_id)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _encloses_in_function(
|
|
194
|
+
node: ast.AST,
|
|
195
|
+
parent_by_child_id: Dict[int, ast.AST],
|
|
196
|
+
) -> bool:
|
|
197
|
+
"""Return True when the nearest enclosing scope of ``node`` is a function."""
|
|
198
|
+
current_node: ast.AST = node
|
|
199
|
+
while id(current_node) in parent_by_child_id:
|
|
200
|
+
current_node = parent_by_child_id[id(current_node)]
|
|
201
|
+
if isinstance(current_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
202
|
+
return True
|
|
203
|
+
if isinstance(current_node, ast.ClassDef):
|
|
204
|
+
return False
|
|
205
|
+
return False
|
|
206
|
+
|
|
207
|
+
|
|
133
208
|
def _is_upper_snake_constant_assignment(node: ast.AST) -> bool:
|
|
134
209
|
if isinstance(node, ast.Assign):
|
|
135
210
|
for target in node.targets:
|
|
@@ -164,17 +239,7 @@ def validate_file(file_path: Path) -> List[Violation]:
|
|
|
164
239
|
|
|
165
240
|
|
|
166
241
|
def main() -> int:
|
|
167
|
-
|
|
168
|
-
return 1
|
|
169
|
-
|
|
170
|
-
all_violations: List[Violation] = []
|
|
171
|
-
for file_arg in sys.argv[1:]:
|
|
172
|
-
all_violations.extend(validate_file(Path(file_arg)))
|
|
173
|
-
|
|
174
|
-
for violation in all_violations:
|
|
175
|
-
print(violation)
|
|
176
|
-
|
|
177
|
-
return 1 if all_violations else 0
|
|
242
|
+
return run_file_validator_cli(validate_file)
|
|
178
243
|
|
|
179
244
|
|
|
180
245
|
if __name__ == "__main__":
|
|
@@ -27,8 +27,8 @@ def process():
|
|
|
27
27
|
'''
|
|
28
28
|
|
|
29
29
|
BAD_MAGIC_NUMBER = '''
|
|
30
|
-
def process():
|
|
31
|
-
timeout = 5000
|
|
30
|
+
def process(attempts):
|
|
31
|
+
timeout = attempts * 5000
|
|
32
32
|
return timeout
|
|
33
33
|
'''
|
|
34
34
|
|
|
@@ -113,8 +113,8 @@ SETTINGS: dict[str, int] = {"timeout": 30}
|
|
|
113
113
|
'''
|
|
114
114
|
|
|
115
115
|
NON_CONSTANT_ASSIGNMENT_IN_FUNCTION = '''
|
|
116
|
-
def configure():
|
|
117
|
-
delay = 30
|
|
116
|
+
def configure(base):
|
|
117
|
+
delay = base + 30
|
|
118
118
|
return delay
|
|
119
119
|
'''
|
|
120
120
|
|
|
@@ -415,3 +415,87 @@ class TestValidateFilePathExemptions:
|
|
|
415
415
|
violations = validate_file(production_path)
|
|
416
416
|
assert len(violations) == 1
|
|
417
417
|
assert "42" in violations[0].message
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
NAMED_FUNCTION_LOCAL_NUMBERS = """
|
|
421
|
+
def configure():
|
|
422
|
+
header_row_count: int = 30
|
|
423
|
+
lowest_offset = -40
|
|
424
|
+
highest_offset = +40
|
|
425
|
+
return header_row_count, lowest_offset, highest_offset
|
|
426
|
+
"""
|
|
427
|
+
|
|
428
|
+
DEFAULT_PARAMETER_NUMBER = """
|
|
429
|
+
def configure(header_row_count=30):
|
|
430
|
+
return header_row_count
|
|
431
|
+
"""
|
|
432
|
+
|
|
433
|
+
CLASS_LEVEL_NUMBER = """
|
|
434
|
+
class Settings:
|
|
435
|
+
header_row_count = 30
|
|
436
|
+
"""
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def test_should_pass_numbers_bound_alone_to_function_local_names() -> None:
|
|
440
|
+
assert check_magic_values(ast.parse(NAMED_FUNCTION_LOCAL_NUMBERS), "test.py") == []
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def test_should_flag_a_default_parameter_number() -> None:
|
|
444
|
+
violations = check_magic_values(ast.parse(DEFAULT_PARAMETER_NUMBER), "test.py")
|
|
445
|
+
assert [violation.message for violation in violations] == ["Magic number 30 - use named constant"]
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def test_should_flag_a_class_level_number() -> None:
|
|
449
|
+
violations = check_magic_values(ast.parse(CLASS_LEVEL_NUMBER), "test.py")
|
|
450
|
+
assert [violation.message for violation in violations] == ["Magic number 30 - use named constant"]
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
GLOBAL_NAME_NUMBER = """
|
|
454
|
+
def raise_limit():
|
|
455
|
+
global retry_limit
|
|
456
|
+
retry_limit = 40
|
|
457
|
+
"""
|
|
458
|
+
|
|
459
|
+
MIXED_SIGN_NAMED_LOCAL = """
|
|
460
|
+
def configure():
|
|
461
|
+
lowest_offset = -+40
|
|
462
|
+
return lowest_offset
|
|
463
|
+
"""
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def test_should_flag_a_number_bound_to_a_global_name() -> None:
|
|
467
|
+
violations = check_magic_values(ast.parse(GLOBAL_NAME_NUMBER), "test.py")
|
|
468
|
+
assert [violation.message for violation in violations] == ["Magic number 40 - use named constant"]
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def test_should_pass_a_mixed_sign_number_bound_to_a_local_name() -> None:
|
|
472
|
+
assert check_magic_values(ast.parse(MIXED_SIGN_NAMED_LOCAL), "test.py") == []
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
MULTI_LINE_NAMED_LOCAL = """
|
|
476
|
+
def configure():
|
|
477
|
+
header_row_count = (
|
|
478
|
+
30
|
|
479
|
+
)
|
|
480
|
+
return header_row_count
|
|
481
|
+
"""
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def test_should_flag_a_number_bound_across_several_lines() -> None:
|
|
485
|
+
violations = check_magic_values(ast.parse(MULTI_LINE_NAMED_LOCAL), "test.py")
|
|
486
|
+
assert [violation.message for violation in violations] == ["Magic number 30 - use named constant"]
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
SHARED_LINE_NAMED_LOCALS = """
|
|
490
|
+
def configure():
|
|
491
|
+
low_offset = 30; high_offset = 40
|
|
492
|
+
return low_offset, high_offset
|
|
493
|
+
"""
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def test_should_flag_numbers_bound_on_a_line_shared_with_another_statement() -> None:
|
|
497
|
+
violations = check_magic_values(ast.parse(SHARED_LINE_NAMED_LOCALS), "test.py")
|
|
498
|
+
assert sorted(violation.message for violation in violations) == [
|
|
499
|
+
"Magic number 30 - use named constant",
|
|
500
|
+
"Magic number 40 - use named constant",
|
|
501
|
+
]
|
|
@@ -458,7 +458,7 @@ class TestBaselineScopedGate:
|
|
|
458
458
|
"tool_input": {
|
|
459
459
|
"file_path": str(target_file),
|
|
460
460
|
"old_string": " return 199\n",
|
|
461
|
-
"new_string": " threshold = 42\n return 199 + threshold\n",
|
|
461
|
+
"new_string": " threshold = abs(42)\n return 199 + threshold\n",
|
|
462
462
|
},
|
|
463
463
|
}
|
|
464
464
|
)
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
"""Tests for TODO/FIXME detection."""
|
|
2
2
|
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
3
5
|
import pytest
|
|
4
6
|
|
|
5
|
-
from .todo_checks import check_untracked_todos
|
|
7
|
+
from .todo_checks import check_untracked_todos, main
|
|
6
8
|
from .validator_base import Violation
|
|
7
9
|
|
|
8
10
|
|
|
@@ -38,3 +40,13 @@ class TestUntrackedTodos:
|
|
|
38
40
|
code = "# FIXME: broken\ndef foo(): pass"
|
|
39
41
|
violations = check_untracked_todos(code, "test.py")
|
|
40
42
|
assert len(violations) == 1
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_main_should_report_an_untracked_todo_in_a_named_file(
|
|
46
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
47
|
+
) -> None:
|
|
48
|
+
source_path = tmp_path / "worker.py"
|
|
49
|
+
source_path.write_text("def run():\n pass # TODO tidy up\n", encoding="utf-8")
|
|
50
|
+
monkeypatch.setattr("sys.argv", ["todo_checks", str(source_path)])
|
|
51
|
+
assert main() == 1
|
|
52
|
+
assert capsys.readouterr().out.startswith(f"{source_path}:2:")
|
|
@@ -5,7 +5,7 @@ from pathlib import Path
|
|
|
5
5
|
|
|
6
6
|
import pytest
|
|
7
7
|
|
|
8
|
-
from .validator_base import Violation, source_text, syntax_tree
|
|
8
|
+
from .validator_base import Violation, run_file_validator_cli, source_text, syntax_tree
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
class TestViolation:
|
|
@@ -73,3 +73,21 @@ def test_syntax_tree_raises_syntax_error_for_unparseable_source() -> None:
|
|
|
73
73
|
"""A caller's own SyntaxError branch still fires."""
|
|
74
74
|
with pytest.raises(SyntaxError):
|
|
75
75
|
syntax_tree("def broken(:\n")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _flag_every_file(file_path: Path) -> list[Violation]:
|
|
79
|
+
return [Violation(file=str(file_path), line=3, message="flagged")]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_run_file_validator_cli_should_print_violations_and_fail(capsys: pytest.CaptureFixture[str]) -> None:
|
|
83
|
+
exit_code = run_file_validator_cli(_flag_every_file, ["validator", "a.py", "b.py"])
|
|
84
|
+
assert exit_code == 1
|
|
85
|
+
assert capsys.readouterr().out == "a.py:3: flagged\nb.py:3: flagged\n"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_run_file_validator_cli_should_pass_clean_files() -> None:
|
|
89
|
+
assert run_file_validator_cli(lambda file_path: [], ["validator", "a.py"]) == 0
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_run_file_validator_cli_should_fail_with_no_file_named() -> None:
|
|
93
|
+
assert run_file_validator_cli(_flag_every_file, ["validator"]) == 1
|
|
@@ -8,7 +8,7 @@ import sys
|
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
from typing import List
|
|
10
10
|
|
|
11
|
-
from .validator_base import Violation, source_text
|
|
11
|
+
from .validator_base import Violation, run_file_validator_cli, source_text
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
TODO_PATTERN = re.compile(r"#\s*(TODO|FIXME)\b(?!.*#\d+)", re.IGNORECASE)
|
|
@@ -42,17 +42,7 @@ def validate_file(file_path: Path) -> List[Violation]:
|
|
|
42
42
|
|
|
43
43
|
|
|
44
44
|
def main() -> int:
|
|
45
|
-
|
|
46
|
-
return 1
|
|
47
|
-
|
|
48
|
-
all_violations: List[Violation] = []
|
|
49
|
-
for file_arg in sys.argv[1:]:
|
|
50
|
-
all_violations.extend(validate_file(Path(file_arg)))
|
|
51
|
-
|
|
52
|
-
for violation in all_violations:
|
|
53
|
-
print(violation)
|
|
54
|
-
|
|
55
|
-
return 1 if all_violations else 0
|
|
45
|
+
return run_file_validator_cli(validate_file)
|
|
56
46
|
|
|
57
47
|
|
|
58
48
|
if __name__ == "__main__":
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
"""Base classes for validators.
|
|
2
2
|
|
|
3
|
-
Provides shared dataclasses used across all validator modules,
|
|
4
|
-
read and the one parse each of those modules shares for a given file
|
|
3
|
+
Provides shared dataclasses used across all validator modules, the one
|
|
4
|
+
read and the one parse each of those modules shares for a given file, and
|
|
5
|
+
the command-line runner a validator module's ``main`` can call.
|
|
5
6
|
"""
|
|
6
7
|
|
|
7
8
|
import ast
|
|
9
|
+
import sys
|
|
10
|
+
from collections.abc import Callable, Sequence
|
|
8
11
|
from dataclasses import dataclass
|
|
9
12
|
from functools import lru_cache
|
|
10
13
|
from pathlib import Path
|
|
@@ -89,3 +92,31 @@ def syntax_tree(source: str) -> ast.Module:
|
|
|
89
92
|
The parsed module.
|
|
90
93
|
"""
|
|
91
94
|
return ast.parse(source)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def run_file_validator_cli(
|
|
98
|
+
validate_file: Callable[[Path], list[Violation]],
|
|
99
|
+
all_arguments: Sequence[str] | None = None,
|
|
100
|
+
) -> int:
|
|
101
|
+
"""Validate each file named on the command line and print its violations.
|
|
102
|
+
|
|
103
|
+
``python -m validators.todo_checks a.py b.py`` prints one line per
|
|
104
|
+
violation and exits 1 when any file has one.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
validate_file: The module's per-file check.
|
|
108
|
+
all_arguments: The command-line arguments, program name first.
|
|
109
|
+
Defaults to ``sys.argv``.
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
1 with no file named or any violation found, else 0.
|
|
113
|
+
"""
|
|
114
|
+
all_file_arguments = list(sys.argv if all_arguments is None else all_arguments)[1:]
|
|
115
|
+
if not all_file_arguments:
|
|
116
|
+
return 1
|
|
117
|
+
all_violations: list[Violation] = []
|
|
118
|
+
for each_file_argument in all_file_arguments:
|
|
119
|
+
all_violations.extend(validate_file(Path(each_file_argument)))
|
|
120
|
+
for each_violation in all_violations:
|
|
121
|
+
sys.stdout.write(f"{each_violation}\n")
|
|
122
|
+
return 1 if all_violations else 0
|
package/package.json
CHANGED
|
@@ -61,6 +61,9 @@ ALL_CHECK_CATALOG_ENTRIES: tuple[CheckCatalogEntry, ...] = (
|
|
|
61
61
|
"paired-test-missing-function",
|
|
62
62
|
),
|
|
63
63
|
CheckCatalogEntry("but exercises nowhere", "paired-test-omitted-function"),
|
|
64
|
+
CheckCatalogEntry(") - consider splitting", "file-length"),
|
|
65
|
+
CheckCatalogEntry(" lines (max ", "function-line-cap"),
|
|
66
|
+
CheckCatalogEntry(" has nesting depth ", "nesting-depth"),
|
|
64
67
|
)
|
|
65
68
|
|
|
66
69
|
SEVERITY_BREAKING: str = "breaking"
|
|
@@ -68,5 +71,8 @@ SEVERITY_SMELL: str = "smell"
|
|
|
68
71
|
SEVERITY_BY_CHECK_ID: dict[str, str] = {
|
|
69
72
|
"code-rules/paired-test-missing-function": SEVERITY_SMELL,
|
|
70
73
|
"code-rules/paired-test-omitted-function": SEVERITY_SMELL,
|
|
74
|
+
"validators/file-length": SEVERITY_SMELL,
|
|
75
|
+
"validators/function-line-cap": SEVERITY_SMELL,
|
|
76
|
+
"validators/nesting-depth": SEVERITY_SMELL,
|
|
71
77
|
"test-pairing": SEVERITY_SMELL,
|
|
72
78
|
}
|
|
@@ -18,6 +18,7 @@ from policy_lint.config.check_catalog_constants import (
|
|
|
18
18
|
from policy_lint.model import Diagnostic, Severity
|
|
19
19
|
|
|
20
20
|
_BUNDLING_RULE_ID = "code-rules"
|
|
21
|
+
_VALIDATORS_RULE_ID = "validators"
|
|
21
22
|
_HOOKS_DIRECTORY = Path(__file__).resolve().parents[2] / "hooks"
|
|
22
23
|
_TEST_FILE_PREFIX = "test_"
|
|
23
24
|
_PYTHON_GLOB = "*.py"
|
|
@@ -82,6 +83,9 @@ ALL_MESSAGE_SAMPLES: tuple[tuple[str, str], ...] = (
|
|
|
82
83
|
" already covers another public function in that module",
|
|
83
84
|
"paired-test-omitted-function",
|
|
84
85
|
),
|
|
86
|
+
("src/app.py:1: File is 412 lines (max 400) - consider splitting", "file-length"),
|
|
87
|
+
("src/app.py:4: Function 'run' is 37 lines (max 30)", "function-line-cap"),
|
|
88
|
+
("src/app.py:9: Function 'run' has nesting depth 3 (max 2)", "nesting-depth"),
|
|
85
89
|
)
|
|
86
90
|
|
|
87
91
|
|
|
@@ -169,6 +173,23 @@ def test_a_paired_test_check_reports_as_a_warning(check_name: str) -> None:
|
|
|
169
173
|
assert severity_for_check_id(f"{_BUNDLING_RULE_ID}/{check_name}") is Severity.WARNING
|
|
170
174
|
|
|
171
175
|
|
|
176
|
+
@pytest.mark.parametrize(
|
|
177
|
+
("sample_message", "check_name"),
|
|
178
|
+
tuple(
|
|
179
|
+
each_sample
|
|
180
|
+
for each_sample in ALL_MESSAGE_SAMPLES
|
|
181
|
+
if each_sample[1] in ("file-length", "function-line-cap", "nesting-depth")
|
|
182
|
+
),
|
|
183
|
+
)
|
|
184
|
+
def test_a_validator_smell_reports_as_a_warning(
|
|
185
|
+
sample_message: str, check_name: str
|
|
186
|
+
) -> None:
|
|
187
|
+
check_id = check_id_for_message(_VALIDATORS_RULE_ID, sample_message)
|
|
188
|
+
|
|
189
|
+
assert check_id == f"{_VALIDATORS_RULE_ID}/{check_name}"
|
|
190
|
+
assert severity_for_check_id(check_id) is Severity.WARNING
|
|
191
|
+
|
|
192
|
+
|
|
172
193
|
def test_an_unclassified_check_reports_as_an_error() -> None:
|
|
173
194
|
unclassified_check_id = f"{_BUNDLING_RULE_ID}/{UNCLASSIFIED_CHECK_NAME}"
|
|
174
195
|
|