claude-dev-env 1.24.0 → 1.25.1
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 +5 -18
- package/docs/CODE_RULES.md +14 -1
- package/hooks/blocking/_gh_body_arg_utils.py +171 -13
- package/hooks/blocking/code-rules-enforcer.py +490 -15
- package/hooks/blocking/gh-body-arg-blocker.py +27 -21
- package/hooks/blocking/pr-description-enforcer.py +247 -11
- package/hooks/blocking/tdd-enforcer.py +208 -13
- package/hooks/blocking/test_code_rules_enforcer_any_type_ignore.py +116 -0
- package/hooks/blocking/test_code_rules_enforcer_banned_identifier.py +231 -0
- package/hooks/blocking/test_code_rules_enforcer_conftest_anchor.py +51 -0
- package/hooks/blocking/test_code_rules_enforcer_dot_test_pattern.py +55 -0
- package/hooks/blocking/test_code_rules_enforcer_fstring_scan.py +144 -0
- package/hooks/blocking/test_code_rules_enforcer_logger_fstring.py +102 -0
- package/hooks/blocking/test_code_rules_enforcer_magic_allowlist.py +76 -0
- package/hooks/blocking/test_code_rules_enforcer_naming_pattern.py +176 -0
- package/hooks/blocking/test_code_rules_enforcer_type_checking_scope.py +112 -0
- package/hooks/blocking/test_gh_body_arg_blocker.py +229 -2
- package/hooks/blocking/test_pr_description_enforcer.py +193 -3
- package/hooks/blocking/test_tdd_enforcer.py +249 -0
- package/hooks/validators/exempt_paths.py +99 -0
- package/hooks/validators/magic_value_checks.py +126 -26
- package/hooks/validators/test_magic_value_checks.py +356 -2
- package/package.json +1 -1
- package/rules/gh-body-file.md +11 -2
- package/skills/searching-obsidian-vault/SKILL.md +131 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""Tests for tdd-enforcer hook (blocking behavior)."""
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
SCRIPT_PATH = Path(__file__).parent / "tdd-enforcer.py"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _load_production_module():
|
|
16
|
+
module_spec = importlib.util.spec_from_file_location("tdd_enforcer_under_test", SCRIPT_PATH)
|
|
17
|
+
assert module_spec is not None and module_spec.loader is not None
|
|
18
|
+
loaded_module = importlib.util.module_from_spec(module_spec)
|
|
19
|
+
module_spec.loader.exec_module(loaded_module)
|
|
20
|
+
return loaded_module
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
_PRODUCTION_MODULE = _load_production_module()
|
|
24
|
+
FRESHNESS_SECONDS = _PRODUCTION_MODULE._freshness_seconds()
|
|
25
|
+
STALE_MTIME_OFFSET_SECONDS = FRESHNESS_SECONDS + 60
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _run_hook_with_payload(payload: dict) -> subprocess.CompletedProcess[str]:
|
|
29
|
+
return subprocess.run(
|
|
30
|
+
[sys.executable, str(SCRIPT_PATH)],
|
|
31
|
+
input=json.dumps(payload),
|
|
32
|
+
text=True,
|
|
33
|
+
capture_output=True,
|
|
34
|
+
check=False,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _make_write_payload(file_path: Path, content: str = "") -> dict:
|
|
39
|
+
return {
|
|
40
|
+
"tool_name": "Write",
|
|
41
|
+
"tool_input": {"file_path": str(file_path), "content": content},
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _decision_from(completed: subprocess.CompletedProcess[str]) -> str | None:
|
|
46
|
+
if not completed.stdout:
|
|
47
|
+
return None
|
|
48
|
+
parsed = json.loads(completed.stdout)
|
|
49
|
+
hook_output = parsed.get("hookSpecificOutput", {})
|
|
50
|
+
return hook_output.get("permissionDecision")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _sandbox(tmp_path: Path) -> Path:
|
|
54
|
+
isolated_root = tmp_path / "sandbox"
|
|
55
|
+
isolated_root.mkdir()
|
|
56
|
+
(isolated_root / ".git").mkdir()
|
|
57
|
+
return isolated_root
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_should_allow_when_sibling_test_file_exists_and_recent(tmp_path: Path) -> None:
|
|
61
|
+
sandbox = _sandbox(tmp_path)
|
|
62
|
+
production_file = sandbox / "orders.py"
|
|
63
|
+
production_file.write_text("def fulfill(): pass\n")
|
|
64
|
+
sibling_test = sandbox / "test_orders.py"
|
|
65
|
+
sibling_test.write_text("def test_fulfill(): pass\n")
|
|
66
|
+
|
|
67
|
+
completed = _run_hook_with_payload(_make_write_payload(production_file))
|
|
68
|
+
|
|
69
|
+
assert _decision_from(completed) == "allow"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_should_deny_when_no_test_file_exists(tmp_path: Path) -> None:
|
|
73
|
+
sandbox = _sandbox(tmp_path)
|
|
74
|
+
production_file = sandbox / "orders.py"
|
|
75
|
+
production_file.write_text("def fulfill(): pass\n")
|
|
76
|
+
|
|
77
|
+
completed = _run_hook_with_payload(_make_write_payload(production_file))
|
|
78
|
+
|
|
79
|
+
assert _decision_from(completed) == "deny"
|
|
80
|
+
parsed = json.loads(completed.stdout)
|
|
81
|
+
reason = parsed["hookSpecificOutput"]["permissionDecisionReason"]
|
|
82
|
+
assert "test_orders.py" in reason
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_should_deny_when_test_file_exists_but_is_stale(tmp_path: Path) -> None:
|
|
86
|
+
sandbox = _sandbox(tmp_path)
|
|
87
|
+
production_file = sandbox / "orders.py"
|
|
88
|
+
production_file.write_text("def fulfill(): pass\n")
|
|
89
|
+
sibling_test = sandbox / "test_orders.py"
|
|
90
|
+
sibling_test.write_text("def test_fulfill(): pass\n")
|
|
91
|
+
stale_timestamp = time.time() - STALE_MTIME_OFFSET_SECONDS
|
|
92
|
+
os.utime(sibling_test, (stale_timestamp, stale_timestamp))
|
|
93
|
+
|
|
94
|
+
completed = _run_hook_with_payload(_make_write_payload(production_file))
|
|
95
|
+
|
|
96
|
+
assert _decision_from(completed) == "deny"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_should_allow_when_bypass_sentinel_present_in_content(tmp_path: Path) -> None:
|
|
100
|
+
sandbox = _sandbox(tmp_path)
|
|
101
|
+
production_file = sandbox / "orders.py"
|
|
102
|
+
content_with_sentinel = "# pragma: no-tdd-gate\ndef fulfill(): pass\n"
|
|
103
|
+
|
|
104
|
+
completed = _run_hook_with_payload(
|
|
105
|
+
_make_write_payload(production_file, content_with_sentinel)
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
assert _decision_from(completed) == "allow"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_should_skip_markdown_files_entirely(tmp_path: Path) -> None:
|
|
112
|
+
markdown_file = tmp_path / "notes.md"
|
|
113
|
+
|
|
114
|
+
completed = _run_hook_with_payload(_make_write_payload(markdown_file, "# Notes\n"))
|
|
115
|
+
|
|
116
|
+
assert completed.returncode == 0
|
|
117
|
+
assert completed.stdout.strip() == ""
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_should_skip_test_files_entirely(tmp_path: Path) -> None:
|
|
121
|
+
test_file = tmp_path / "test_orders.py"
|
|
122
|
+
|
|
123
|
+
completed = _run_hook_with_payload(
|
|
124
|
+
_make_write_payload(test_file, "def test_fulfill(): pass\n")
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
assert completed.returncode == 0
|
|
128
|
+
assert completed.stdout.strip() == ""
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_should_allow_when_tests_directory_sibling_has_fresh_test(
|
|
132
|
+
tmp_path: Path,
|
|
133
|
+
) -> None:
|
|
134
|
+
sandbox = _sandbox(tmp_path)
|
|
135
|
+
package_dir = sandbox / "source"
|
|
136
|
+
package_dir.mkdir()
|
|
137
|
+
tests_dir = sandbox / "tests"
|
|
138
|
+
tests_dir.mkdir()
|
|
139
|
+
production_file = package_dir / "orders.py"
|
|
140
|
+
production_file.write_text("def fulfill(): pass\n")
|
|
141
|
+
matching_test = tests_dir / "test_orders.py"
|
|
142
|
+
matching_test.write_text("def test_fulfill(): pass\n")
|
|
143
|
+
|
|
144
|
+
completed = _run_hook_with_payload(_make_write_payload(production_file))
|
|
145
|
+
|
|
146
|
+
assert _decision_from(completed) == "allow"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_should_allow_tsx_when_dot_test_sibling_exists(tmp_path: Path) -> None:
|
|
150
|
+
sandbox = _sandbox(tmp_path)
|
|
151
|
+
production_file = sandbox / "Button.tsx"
|
|
152
|
+
production_file.write_text("export const Button = () => null;\n")
|
|
153
|
+
sibling_test = sandbox / "Button.test.tsx"
|
|
154
|
+
sibling_test.write_text("test('renders', () => {});\n")
|
|
155
|
+
|
|
156
|
+
completed = _run_hook_with_payload(_make_write_payload(production_file))
|
|
157
|
+
|
|
158
|
+
assert _decision_from(completed) == "allow"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def test_should_deny_when_test_file_has_no_test_evidence(tmp_path: Path) -> None:
|
|
162
|
+
sandbox = _sandbox(tmp_path)
|
|
163
|
+
production_file = sandbox / "orders.py"
|
|
164
|
+
production_file.write_text("def fulfill(): pass\n")
|
|
165
|
+
sibling_test = sandbox / "test_orders.py"
|
|
166
|
+
sibling_test.write_text("x = 1\n")
|
|
167
|
+
|
|
168
|
+
completed = _run_hook_with_payload(_make_write_payload(production_file))
|
|
169
|
+
|
|
170
|
+
assert _decision_from(completed) == "deny"
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def test_should_allow_edit_when_bypass_sentinel_present_in_new_string(
|
|
174
|
+
tmp_path: Path,
|
|
175
|
+
) -> None:
|
|
176
|
+
sandbox = _sandbox(tmp_path)
|
|
177
|
+
production_file = sandbox / "orders.py"
|
|
178
|
+
payload = {
|
|
179
|
+
"tool_name": "Edit",
|
|
180
|
+
"tool_input": {
|
|
181
|
+
"file_path": str(production_file),
|
|
182
|
+
"old_string": "pass",
|
|
183
|
+
"new_string": "# pragma: no-tdd-gate\npass",
|
|
184
|
+
},
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
completed = _run_hook_with_payload(payload)
|
|
188
|
+
|
|
189
|
+
assert _decision_from(completed) == "allow"
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def test_should_deny_production_file_inside_directory_containing_skip_substring(
|
|
193
|
+
tmp_path: Path,
|
|
194
|
+
) -> None:
|
|
195
|
+
sandbox = _sandbox(tmp_path)
|
|
196
|
+
mockingbird_directory = sandbox / "mockingbird"
|
|
197
|
+
mockingbird_directory.mkdir()
|
|
198
|
+
production_file = mockingbird_directory / "orders.py"
|
|
199
|
+
production_file.write_text("def fulfill(): pass\n")
|
|
200
|
+
|
|
201
|
+
completed = _run_hook_with_payload(_make_write_payload(production_file))
|
|
202
|
+
|
|
203
|
+
assert _decision_from(completed) == "deny"
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def test_should_deny_when_test_file_contains_only_production_stem_without_test_function(
|
|
207
|
+
tmp_path: Path,
|
|
208
|
+
) -> None:
|
|
209
|
+
sandbox = _sandbox(tmp_path)
|
|
210
|
+
production_file = sandbox / "orders.py"
|
|
211
|
+
production_file.write_text("def fulfill(): pass\n")
|
|
212
|
+
sibling_test = sandbox / "test_orders.py"
|
|
213
|
+
sibling_test.write_text("orders = 'mentioned but no test function'\n")
|
|
214
|
+
|
|
215
|
+
completed = _run_hook_with_payload(_make_write_payload(production_file))
|
|
216
|
+
|
|
217
|
+
assert _decision_from(completed) == "deny"
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def test_should_allow_when_large_test_file_has_valid_test_function_past_first_chunk(
|
|
221
|
+
tmp_path: Path,
|
|
222
|
+
) -> None:
|
|
223
|
+
sandbox = _sandbox(tmp_path)
|
|
224
|
+
production_file = sandbox / "orders.py"
|
|
225
|
+
production_file.write_text("def fulfill(): pass\n")
|
|
226
|
+
sibling_test = sandbox / "test_orders.py"
|
|
227
|
+
padding_byte_count = 250_000
|
|
228
|
+
padding_comment = "# " + ("x" * padding_byte_count) + "\n"
|
|
229
|
+
sibling_test.write_text(padding_comment + "def test_fulfill(): pass\n")
|
|
230
|
+
|
|
231
|
+
completed = _run_hook_with_payload(_make_write_payload(production_file))
|
|
232
|
+
|
|
233
|
+
assert _decision_from(completed) == "allow"
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def test_directory_skip_components_exactly_matches_pre_fc61a8b_hardcoded_set() -> None:
|
|
237
|
+
expected_directory_skip_components = frozenset({
|
|
238
|
+
"conftest", "fixture", "fixtures", "mock", "mocks", "stub", "stubs",
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
actual_directory_skip_components = _PRODUCTION_MODULE._directory_skip_components()
|
|
242
|
+
|
|
243
|
+
assert actual_directory_skip_components == expected_directory_skip_components
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def test_directory_skip_components_excludes_pluralized_conftest() -> None:
|
|
247
|
+
actual_directory_skip_components = _PRODUCTION_MODULE._directory_skip_components()
|
|
248
|
+
|
|
249
|
+
assert "conftests" not in actual_directory_skip_components
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Canonical path-exemption helpers shared between validator hooks.
|
|
2
|
+
|
|
3
|
+
Single source of truth for CONFIG / TEST / HOOK-INFRASTRUCTURE /
|
|
4
|
+
WORKFLOW-REGISTRY / MIGRATION path pattern sets. Both Pre-Write
|
|
5
|
+
(``code-rules-enforcer.py``) and pre-push (``magic_value_checks.py``)
|
|
6
|
+
scanners must short-circuit on the same file categories; drift between
|
|
7
|
+
the two produced the "inconsistent verdicts" bug this module prevents.
|
|
8
|
+
|
|
9
|
+
Matching is case-insensitive so paths like ``Config/foo.py`` or
|
|
10
|
+
``src/Tests/test_x.py`` are treated the same on case-preserving
|
|
11
|
+
filesystems (macOS default, Windows NTFS) as on case-sensitive ones.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
CONFIG_PATH_PATTERNS: frozenset[str] = frozenset(
|
|
18
|
+
{
|
|
19
|
+
"config/",
|
|
20
|
+
"config\\",
|
|
21
|
+
"/config.",
|
|
22
|
+
"\\config.",
|
|
23
|
+
"settings.py",
|
|
24
|
+
}
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
TEST_PATH_PATTERNS: frozenset[str] = frozenset(
|
|
28
|
+
{
|
|
29
|
+
"test_",
|
|
30
|
+
"_test.",
|
|
31
|
+
".spec.",
|
|
32
|
+
"conftest",
|
|
33
|
+
"/tests/",
|
|
34
|
+
"\\tests\\",
|
|
35
|
+
"/tests.py",
|
|
36
|
+
"\\tests.py",
|
|
37
|
+
}
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
HOOK_INFRASTRUCTURE_PATTERNS: frozenset[str] = frozenset(
|
|
41
|
+
{
|
|
42
|
+
"/.claude/hooks/",
|
|
43
|
+
"\\.claude\\hooks\\",
|
|
44
|
+
"\\.claude/hooks/",
|
|
45
|
+
}
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
WORKFLOW_REGISTRY_PATTERNS: frozenset[str] = frozenset(
|
|
49
|
+
{
|
|
50
|
+
"/workflow/",
|
|
51
|
+
"\\workflow\\",
|
|
52
|
+
"_tab.py",
|
|
53
|
+
"/states.py",
|
|
54
|
+
"\\states.py",
|
|
55
|
+
"/modules.py",
|
|
56
|
+
"\\modules.py",
|
|
57
|
+
}
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
MIGRATION_PATH_PATTERNS: frozenset[str] = frozenset(
|
|
61
|
+
{
|
|
62
|
+
"/migrations/",
|
|
63
|
+
"\\migrations\\",
|
|
64
|
+
}
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def is_config_file(file_path: str) -> bool:
|
|
69
|
+
path_lower = file_path.lower()
|
|
70
|
+
return any(pattern in path_lower for pattern in CONFIG_PATH_PATTERNS)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def is_test_file(file_path: str) -> bool:
|
|
74
|
+
path_lower = file_path.lower()
|
|
75
|
+
return any(pattern in path_lower for pattern in TEST_PATH_PATTERNS)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def is_hook_infrastructure(file_path: str) -> bool:
|
|
79
|
+
path_normalized = file_path.lower().replace("\\", "/")
|
|
80
|
+
return any(
|
|
81
|
+
pattern.replace("\\", "/") in path_normalized
|
|
82
|
+
for pattern in HOOK_INFRASTRUCTURE_PATTERNS
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def is_workflow_registry_file(file_path: str) -> bool:
|
|
87
|
+
path_normalized = file_path.lower().replace("\\", "/")
|
|
88
|
+
return any(
|
|
89
|
+
pattern.replace("\\", "/") in path_normalized
|
|
90
|
+
for pattern in WORKFLOW_REGISTRY_PATTERNS
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def is_migration_file(file_path: str) -> bool:
|
|
95
|
+
path_normalized = file_path.lower().replace("\\", "/")
|
|
96
|
+
return any(
|
|
97
|
+
pattern.replace("\\", "/") in path_normalized
|
|
98
|
+
for pattern in MIGRATION_PATH_PATTERNS
|
|
99
|
+
)
|
|
@@ -7,56 +7,156 @@ Note: Only checks for magic numbers. Magic string detection is not implemented.
|
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
9
|
import ast
|
|
10
|
+
import re
|
|
10
11
|
import sys
|
|
11
12
|
from pathlib import Path
|
|
12
|
-
from typing import List, Set
|
|
13
|
+
from typing import Dict, FrozenSet, List, Set, Tuple, Type
|
|
13
14
|
|
|
15
|
+
from exempt_paths import (
|
|
16
|
+
is_config_file,
|
|
17
|
+
is_test_file,
|
|
18
|
+
)
|
|
14
19
|
from validator_base import Violation
|
|
15
20
|
|
|
16
21
|
|
|
17
|
-
ALLOWED_NUMBERS:
|
|
22
|
+
ALLOWED_NUMBERS: FrozenSet[int] = frozenset({-1, 0, 1})
|
|
23
|
+
|
|
24
|
+
_UPPER_SNAKE_NAME_PATTERN = re.compile(r"^[A-Z][A-Z0-9_]*$")
|
|
25
|
+
|
|
26
|
+
_CONTAINER_LITERAL_TYPES: Tuple[Type[ast.AST], ...] = (
|
|
27
|
+
ast.Dict,
|
|
28
|
+
ast.List,
|
|
29
|
+
ast.Tuple,
|
|
30
|
+
ast.Set,
|
|
31
|
+
)
|
|
18
32
|
|
|
19
33
|
|
|
20
34
|
def check_magic_values(tree: ast.AST, filename: str) -> List[Violation]:
|
|
21
35
|
violations: List[Violation] = []
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
for node in ast.walk(tree):
|
|
25
|
-
if isinstance(node, ast.Assign):
|
|
26
|
-
for target in node.targets:
|
|
27
|
-
if isinstance(target, ast.Name) and target.id.isupper():
|
|
28
|
-
constant_names.add(target.id)
|
|
36
|
+
negated_literal_ids: Set[int] = set()
|
|
37
|
+
parent_by_child_id = _build_parent_map(tree)
|
|
29
38
|
|
|
30
39
|
for node in ast.walk(tree):
|
|
31
|
-
if isinstance(node, ast.
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
+
if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.USub):
|
|
41
|
+
operand = node.operand
|
|
42
|
+
if isinstance(operand, ast.UnaryOp):
|
|
43
|
+
continue
|
|
44
|
+
if (
|
|
45
|
+
isinstance(operand, ast.Constant)
|
|
46
|
+
and isinstance(operand.value, int)
|
|
47
|
+
and not isinstance(operand.value, bool)
|
|
48
|
+
):
|
|
49
|
+
negated_literal_ids.add(id(operand))
|
|
50
|
+
outermost_unary = _walk_up_unary_minus(node, parent_by_child_id)
|
|
51
|
+
negation_depth = _count_unary_minus_depth(outermost_unary)
|
|
52
|
+
unsigned_value = operand.value
|
|
53
|
+
signed_value = -unsigned_value if negation_depth % 2 == 1 else unsigned_value
|
|
54
|
+
if signed_value in ALLOWED_NUMBERS:
|
|
55
|
+
continue
|
|
56
|
+
if _is_in_constant_definition(outermost_unary, parent_by_child_id):
|
|
57
|
+
continue
|
|
58
|
+
violations.append(
|
|
59
|
+
Violation(
|
|
60
|
+
filename,
|
|
61
|
+
node.lineno,
|
|
62
|
+
f"Magic number {signed_value} - use named constant",
|
|
40
63
|
)
|
|
64
|
+
)
|
|
65
|
+
continue
|
|
66
|
+
if isinstance(node, ast.Constant):
|
|
67
|
+
if not isinstance(node.value, int):
|
|
68
|
+
continue
|
|
69
|
+
if isinstance(node.value, bool):
|
|
70
|
+
continue
|
|
71
|
+
if id(node) in negated_literal_ids:
|
|
72
|
+
continue
|
|
73
|
+
if node.value in ALLOWED_NUMBERS:
|
|
74
|
+
continue
|
|
75
|
+
if _is_in_constant_definition(node, parent_by_child_id):
|
|
76
|
+
continue
|
|
77
|
+
violations.append(
|
|
78
|
+
Violation(
|
|
79
|
+
filename,
|
|
80
|
+
node.lineno,
|
|
81
|
+
f"Magic number {node.value} - use named constant",
|
|
82
|
+
)
|
|
83
|
+
)
|
|
41
84
|
|
|
42
85
|
return violations
|
|
43
86
|
|
|
44
87
|
|
|
45
|
-
def
|
|
88
|
+
def _build_parent_map(tree: ast.AST) -> Dict[int, ast.AST]:
|
|
89
|
+
parent_by_child_id: Dict[int, ast.AST] = {}
|
|
46
90
|
for parent in ast.walk(tree):
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
91
|
+
for child in ast.iter_child_nodes(parent):
|
|
92
|
+
parent_by_child_id[id(child)] = parent
|
|
93
|
+
return parent_by_child_id
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _walk_up_unary_minus(
|
|
97
|
+
node: ast.UnaryOp,
|
|
98
|
+
parent_by_child_id: Dict[int, ast.AST],
|
|
99
|
+
) -> ast.UnaryOp:
|
|
100
|
+
current: ast.UnaryOp = node
|
|
101
|
+
while True:
|
|
102
|
+
parent = parent_by_child_id.get(id(current))
|
|
103
|
+
if isinstance(parent, ast.UnaryOp) and isinstance(parent.op, ast.USub):
|
|
104
|
+
current = parent
|
|
105
|
+
continue
|
|
106
|
+
return current
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _count_unary_minus_depth(node: ast.UnaryOp) -> int:
|
|
110
|
+
depth = 0
|
|
111
|
+
current: ast.AST = node
|
|
112
|
+
while isinstance(current, ast.UnaryOp) and isinstance(current.op, ast.USub):
|
|
113
|
+
depth += 1
|
|
114
|
+
current = current.operand
|
|
115
|
+
return depth
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _is_in_constant_definition(
|
|
119
|
+
node: ast.AST,
|
|
120
|
+
parent_by_child_id: Dict[int, ast.AST],
|
|
121
|
+
) -> bool:
|
|
122
|
+
current_node: ast.AST = node
|
|
123
|
+
while id(current_node) in parent_by_child_id:
|
|
124
|
+
parent = parent_by_child_id[id(current_node)]
|
|
125
|
+
if _is_upper_snake_constant_assignment(parent):
|
|
126
|
+
return True
|
|
127
|
+
if not isinstance(parent, _CONTAINER_LITERAL_TYPES):
|
|
128
|
+
return False
|
|
129
|
+
current_node = parent
|
|
52
130
|
return False
|
|
53
131
|
|
|
54
132
|
|
|
133
|
+
def _is_upper_snake_constant_assignment(node: ast.AST) -> bool:
|
|
134
|
+
if isinstance(node, ast.Assign):
|
|
135
|
+
for target in node.targets:
|
|
136
|
+
if isinstance(target, ast.Name) and _is_upper_snake_name(target.id):
|
|
137
|
+
return True
|
|
138
|
+
return False
|
|
139
|
+
if isinstance(node, ast.AnnAssign):
|
|
140
|
+
target = node.target
|
|
141
|
+
return isinstance(target, ast.Name) and _is_upper_snake_name(target.id)
|
|
142
|
+
return False
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _is_upper_snake_name(name: str) -> bool:
|
|
146
|
+
return bool(_UPPER_SNAKE_NAME_PATTERN.match(name))
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _is_exempt_path(file_path: str) -> bool:
|
|
150
|
+
return is_test_file(file_path) or is_config_file(file_path)
|
|
151
|
+
|
|
152
|
+
|
|
55
153
|
def validate_file(file_path: Path) -> List[Violation]:
|
|
56
154
|
filename = str(file_path)
|
|
155
|
+
if _is_exempt_path(filename):
|
|
156
|
+
return []
|
|
57
157
|
try:
|
|
58
|
-
|
|
59
|
-
tree = ast.parse(
|
|
158
|
+
source_bytes = file_path.read_bytes()
|
|
159
|
+
tree = ast.parse(source_bytes)
|
|
60
160
|
except Exception as error:
|
|
61
161
|
return [Violation(filename, 0, f"Error: {error}")]
|
|
62
162
|
|