claude-dev-env 1.50.1 → 1.50.2
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/hooks/blocking/code_rules_annotations_length.py +167 -0
- package/hooks/blocking/code_rules_banned_identifiers.py +385 -0
- package/hooks/blocking/code_rules_boolean_mustcheck.py +350 -0
- package/hooks/blocking/code_rules_comments.py +337 -0
- package/hooks/blocking/code_rules_constants_config.py +252 -0
- package/hooks/blocking/code_rules_docstrings.py +308 -0
- package/hooks/blocking/code_rules_enforcer.py +98 -5807
- package/hooks/blocking/code_rules_imports_logging.py +276 -0
- package/hooks/blocking/code_rules_magic_values.py +180 -0
- package/hooks/blocking/code_rules_mock_completeness.py +295 -0
- package/hooks/blocking/code_rules_naming_collection.py +264 -0
- package/hooks/blocking/code_rules_optional_params.py +288 -0
- package/hooks/blocking/code_rules_paths_syspath.py +186 -0
- package/hooks/blocking/code_rules_probe_chains.py +305 -0
- package/hooks/blocking/code_rules_probe_detection.py +257 -0
- package/hooks/blocking/code_rules_probe_recording.py +225 -0
- package/hooks/blocking/code_rules_scope_binding.py +151 -0
- package/hooks/blocking/code_rules_shared.py +301 -0
- package/hooks/blocking/code_rules_string_magic.py +207 -0
- package/hooks/blocking/code_rules_test_assertions.py +226 -0
- package/hooks/blocking/code_rules_test_branching_except.py +181 -0
- package/hooks/blocking/code_rules_test_isolation.py +341 -0
- package/hooks/blocking/code_rules_type_escape.py +341 -0
- package/hooks/blocking/code_rules_typeddict_stub.py +305 -0
- package/hooks/blocking/code_rules_unused_imports.py +256 -0
- package/hooks/blocking/tdd_enforcer.py +31 -0
- package/hooks/blocking/test_code_rules_constants_config.py +26 -0
- package/hooks/blocking/test_code_rules_enforcer_banned_noun_word.py +5 -2
- package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +0 -5
- package/hooks/blocking/test_code_rules_enforcer_comment_string_awareness.py +21 -15
- package/hooks/blocking/test_code_rules_enforcer_config_path.py +20 -16
- package/hooks/blocking/test_code_rules_enforcer_exempt_marker_chained.py +4 -2
- package/hooks/blocking/test_code_rules_enforcer_function_length.py +18 -13
- package/hooks/blocking/test_code_rules_enforcer_hardcoded_user_path.py +1 -2
- package/hooks/blocking/test_code_rules_enforcer_ignored_must_check_return.py +22 -12
- package/hooks/blocking/test_code_rules_enforcer_split_annotations_length.py +55 -0
- package/hooks/blocking/test_code_rules_enforcer_split_banned.py +170 -0
- package/hooks/blocking/test_code_rules_enforcer_split_comments.py +60 -0
- package/hooks/blocking/test_code_rules_enforcer_split_config_path.py +52 -0
- package/hooks/blocking/test_code_rules_enforcer_split_constants_config.py +236 -0
- package/hooks/blocking/test_code_rules_enforcer_split_entry_1.py +296 -0
- package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +238 -0
- package/hooks/blocking/test_code_rules_enforcer_split_isolation_1.py +271 -0
- package/hooks/blocking/test_code_rules_enforcer_split_isolation_2.py +283 -0
- package/hooks/blocking/test_code_rules_enforcer_split_isolation_3.py +268 -0
- package/hooks/blocking/test_code_rules_enforcer_split_isolation_4.py +85 -0
- package/hooks/blocking/test_code_rules_enforcer_split_mocks_1.py +303 -0
- package/hooks/blocking/test_code_rules_enforcer_split_mocks_2.py +111 -0
- package/hooks/blocking/test_code_rules_enforcer_split_mustcheck.py +87 -0
- package/hooks/blocking/test_code_rules_enforcer_split_naming.py +107 -0
- package/hooks/blocking/test_code_rules_enforcer_split_optional_params.py +325 -0
- package/hooks/blocking/test_code_rules_enforcer_split_paths_syspath.py +110 -0
- package/hooks/blocking/test_code_rules_enforcer_split_shared.py +44 -0
- package/hooks/blocking/test_code_rules_enforcer_split_string_magic.py +55 -0
- package/hooks/blocking/test_code_rules_enforcer_split_test_assertions.py +56 -0
- package/hooks/blocking/test_code_rules_enforcer_todo_markers.py +21 -15
- package/hooks/blocking/test_code_rules_paths_syspath.py +26 -0
- package/hooks/blocking/test_tdd_enforcer.py +116 -0
- package/hooks/hooks_constants/blocking_check_limits.py +3 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +8 -0
- package/hooks/hooks_constants/sys_path_insert_constants.py +1 -0
- package/package.json +1 -1
- package/hooks/blocking/test_code_rules_enforcer.py +0 -2669
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Parameter-annotation, return-annotation, and function-length checks."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
_blocking_directory = str(Path(__file__).resolve().parent)
|
|
8
|
+
_hooks_directory = str(Path(__file__).resolve().parent.parent)
|
|
9
|
+
if _blocking_directory not in sys.path:
|
|
10
|
+
sys.path.insert(0, _blocking_directory)
|
|
11
|
+
if _hooks_directory not in sys.path:
|
|
12
|
+
sys.path.insert(0, _hooks_directory)
|
|
13
|
+
|
|
14
|
+
from code_rules_shared import ( # noqa: E402
|
|
15
|
+
_collect_annotated_arguments,
|
|
16
|
+
_definition_docstring_line_span,
|
|
17
|
+
_function_definition_line_span,
|
|
18
|
+
_scope_violations_to_changed_lines,
|
|
19
|
+
is_hook_infrastructure,
|
|
20
|
+
is_migration_file,
|
|
21
|
+
is_test_file,
|
|
22
|
+
is_workflow_registry_file,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
|
|
26
|
+
ALL_SELF_AND_CLS_PARAMETER_NAMES,
|
|
27
|
+
FUNCTION_LENGTH_BLOCKING_MESSAGE_SUFFIX,
|
|
28
|
+
FUNCTION_LENGTH_BLOCKING_THRESHOLD,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def check_parameter_annotations(content: str, file_path: str) -> list[str]:
|
|
33
|
+
if is_test_file(file_path):
|
|
34
|
+
return []
|
|
35
|
+
if is_workflow_registry_file(file_path) or is_migration_file(file_path):
|
|
36
|
+
return []
|
|
37
|
+
try:
|
|
38
|
+
tree = ast.parse(content)
|
|
39
|
+
except SyntaxError:
|
|
40
|
+
return []
|
|
41
|
+
issues: list[str] = []
|
|
42
|
+
for each_node in ast.walk(tree):
|
|
43
|
+
if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
44
|
+
continue
|
|
45
|
+
for each_arg in _collect_annotated_arguments(each_node):
|
|
46
|
+
if each_arg.arg in ALL_SELF_AND_CLS_PARAMETER_NAMES:
|
|
47
|
+
continue
|
|
48
|
+
if each_arg.annotation is None:
|
|
49
|
+
issues.append(
|
|
50
|
+
f"Line {each_arg.lineno}: parameter {each_arg.arg!r} on {each_node.name!r} missing type annotation (CODE_RULES §6)"
|
|
51
|
+
)
|
|
52
|
+
return issues
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def check_return_annotations(content: str, file_path: str) -> list[str]:
|
|
56
|
+
if is_test_file(file_path):
|
|
57
|
+
return []
|
|
58
|
+
if is_workflow_registry_file(file_path) or is_migration_file(file_path):
|
|
59
|
+
return []
|
|
60
|
+
try:
|
|
61
|
+
tree = ast.parse(content)
|
|
62
|
+
except SyntaxError:
|
|
63
|
+
return []
|
|
64
|
+
issues: list[str] = []
|
|
65
|
+
for each_node in ast.walk(tree):
|
|
66
|
+
if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
67
|
+
continue
|
|
68
|
+
if each_node.returns is None:
|
|
69
|
+
issues.append(
|
|
70
|
+
f"Line {each_node.lineno}: function {each_node.name!r} missing return type annotation (CODE_RULES §6)"
|
|
71
|
+
)
|
|
72
|
+
return issues
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def check_function_length(
|
|
76
|
+
content: str,
|
|
77
|
+
file_path: str,
|
|
78
|
+
all_changed_lines: set[int] | None = None,
|
|
79
|
+
defer_scope_to_caller: bool = False,
|
|
80
|
+
) -> list[str]:
|
|
81
|
+
"""Flag functions whose executable span exceeds cognitive-load thresholds.
|
|
82
|
+
|
|
83
|
+
Function executable spans — the definition span (signature line through
|
|
84
|
+
last body statement, inclusive) minus the leading docstring lines of the
|
|
85
|
+
function and of every function or class nested within it, per
|
|
86
|
+
``_definition_docstring_line_span`` summed over the nested definitions —
|
|
87
|
+
at or above ``FUNCTION_LENGTH_BLOCKING_THRESHOLD`` appear in
|
|
88
|
+
the returned issues list and block the write at the
|
|
89
|
+
gate. The threshold rests on the small-function guidance in Robert C.
|
|
90
|
+
Martin, *Clean Code* Chapter Three ("Functions") and the Google Python Style
|
|
91
|
+
Guide's ~forty-line function review hint
|
|
92
|
+
(https://google.github.io/styleguide/pyguide.html) — a measure of
|
|
93
|
+
executable complexity, paired with the Guide's complete-docstring mandate
|
|
94
|
+
for public APIs, so documentation lines never count against the gate; this
|
|
95
|
+
gate blocks on body growth that pushes a function past that span. It does
|
|
96
|
+
not derive from CODE_RULES file-length guidance, which governs advisory
|
|
97
|
+
file-length signals and argues against hard numeric blocks.
|
|
98
|
+
|
|
99
|
+
The issue message carries ``Function NAME (defined at line X) is Y lines``
|
|
100
|
+
precisely so the gate's ``function_length_span_range`` can recover the
|
|
101
|
+
function's full declared span (lines ``X`` through ``X + Y - 1``). The
|
|
102
|
+
gate classifies the violation blocking when that span intersects the
|
|
103
|
+
diff's added lines — the body grew this diff — and advisory otherwise — a
|
|
104
|
+
pre-existing, untouched long function in a file the diff happened to
|
|
105
|
+
touch. Anchoring to the span rather than a single ``Line N:`` definition
|
|
106
|
+
line lets body growth on any interior line block correctly even when the
|
|
107
|
+
``def`` line itself is untouched.
|
|
108
|
+
|
|
109
|
+
Exempt: test files (test bodies are sometimes long by necessity), Django
|
|
110
|
+
migrations (auto-generated), workflow registries (registry entries), and
|
|
111
|
+
hook infrastructure.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
content: The Python source to analyze.
|
|
115
|
+
file_path: The path of the file being checked.
|
|
116
|
+
all_changed_lines: Post-edit line numbers the current edit touched, or
|
|
117
|
+
None to treat the whole file as in scope. When provided, a violation
|
|
118
|
+
blocks only when the function's declared span intersects the changed
|
|
119
|
+
lines.
|
|
120
|
+
defer_scope_to_caller: When True, return every violation so the
|
|
121
|
+
commit/push gate's ``split_violations_by_scope`` can scope by added
|
|
122
|
+
line and report the in-scope set.
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
Blocking issues. When *defer_scope_to_caller* is True every violation is
|
|
126
|
+
returned for the gate to scope; otherwise every violation in scope is
|
|
127
|
+
returned.
|
|
128
|
+
"""
|
|
129
|
+
if is_test_file(file_path):
|
|
130
|
+
return []
|
|
131
|
+
if is_hook_infrastructure(file_path):
|
|
132
|
+
return []
|
|
133
|
+
if is_workflow_registry_file(file_path) or is_migration_file(file_path):
|
|
134
|
+
return []
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
parsed_tree = ast.parse(content)
|
|
138
|
+
except SyntaxError:
|
|
139
|
+
return []
|
|
140
|
+
|
|
141
|
+
all_violations_in_walk_order: list[tuple[range, str]] = []
|
|
142
|
+
for each_node in ast.walk(parsed_tree):
|
|
143
|
+
if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
144
|
+
continue
|
|
145
|
+
line_span = _function_definition_line_span(each_node)
|
|
146
|
+
if line_span < FUNCTION_LENGTH_BLOCKING_THRESHOLD:
|
|
147
|
+
continue
|
|
148
|
+
docstring_line_total = sum(
|
|
149
|
+
_definition_docstring_line_span(each_definition)
|
|
150
|
+
for each_definition in ast.walk(each_node)
|
|
151
|
+
if isinstance(
|
|
152
|
+
each_definition, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)
|
|
153
|
+
)
|
|
154
|
+
)
|
|
155
|
+
executable_line_span = line_span - docstring_line_total
|
|
156
|
+
if executable_line_span >= FUNCTION_LENGTH_BLOCKING_THRESHOLD:
|
|
157
|
+
span_range = range(each_node.lineno, each_node.lineno + line_span)
|
|
158
|
+
message = (
|
|
159
|
+
f"Function {each_node.name!r} (defined at line {each_node.lineno}) "
|
|
160
|
+
f"is {line_span} lines - {FUNCTION_LENGTH_BLOCKING_MESSAGE_SUFFIX}"
|
|
161
|
+
)
|
|
162
|
+
all_violations_in_walk_order.append((span_range, message))
|
|
163
|
+
return _scope_violations_to_changed_lines(
|
|
164
|
+
all_violations_in_walk_order,
|
|
165
|
+
all_changed_lines,
|
|
166
|
+
defer_scope_to_caller,
|
|
167
|
+
)
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
"""Banned identifier, banned noun-word, and banned function-prefix naming checks."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
_blocking_directory = str(Path(__file__).resolve().parent)
|
|
8
|
+
_hooks_directory = str(Path(__file__).resolve().parent.parent)
|
|
9
|
+
if _blocking_directory not in sys.path:
|
|
10
|
+
sys.path.insert(0, _blocking_directory)
|
|
11
|
+
if _hooks_directory not in sys.path:
|
|
12
|
+
sys.path.insert(0, _hooks_directory)
|
|
13
|
+
|
|
14
|
+
from code_rules_path_utils import ( # noqa: E402
|
|
15
|
+
is_config_file,
|
|
16
|
+
)
|
|
17
|
+
from code_rules_shared import ( # noqa: E402
|
|
18
|
+
_collect_annotated_arguments,
|
|
19
|
+
_collect_target_names,
|
|
20
|
+
_scope_violations_to_changed_lines,
|
|
21
|
+
_walk_skipping_type_checking_blocks,
|
|
22
|
+
is_hook_infrastructure,
|
|
23
|
+
is_migration_file,
|
|
24
|
+
is_test_file,
|
|
25
|
+
is_workflow_registry_file,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
from hooks_constants.banned_identifiers_constants import ( # noqa: E402
|
|
29
|
+
ALL_BANNED_IDENTIFIERS,
|
|
30
|
+
ALL_BANNED_NOUN_WORDS,
|
|
31
|
+
BANNED_IDENTIFIER_MESSAGE_SUFFIX,
|
|
32
|
+
BANNED_IDENTIFIER_SKIP_ADVISORY,
|
|
33
|
+
BANNED_NOUN_WORD_MESSAGE_SUFFIX,
|
|
34
|
+
CAMEL_CASE_WORD_PATTERN,
|
|
35
|
+
MAX_BANNED_IDENTIFIER_ISSUES,
|
|
36
|
+
)
|
|
37
|
+
from hooks_constants.blocking_check_limits import ( # noqa: E402
|
|
38
|
+
ALL_BANNED_PREFIX_NAMES,
|
|
39
|
+
MAX_BANNED_PREFIX_ISSUES,
|
|
40
|
+
)
|
|
41
|
+
from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
|
|
42
|
+
ALL_SELF_AND_CLS_PARAMETER_NAMES,
|
|
43
|
+
BANNED_NOUN_SPAN_FRAGMENT_TEMPLATE,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _collect_banned_names_from_target(target: ast.expr) -> list[ast.Name]:
|
|
48
|
+
"""Return every banned ast.Name reachable through tuple/list unpacking or starred targets."""
|
|
49
|
+
return [
|
|
50
|
+
each_name_node
|
|
51
|
+
for each_name_node in _collect_target_names(target)
|
|
52
|
+
if each_name_node.id in ALL_BANNED_IDENTIFIERS
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _value_is_parse_args_namespace_call(value_node: ast.AST | None) -> bool:
|
|
57
|
+
if value_node is None:
|
|
58
|
+
return False
|
|
59
|
+
if not isinstance(value_node, ast.Call):
|
|
60
|
+
return False
|
|
61
|
+
callee = value_node.func
|
|
62
|
+
return isinstance(callee, ast.Attribute) and callee.attr == "parse_args"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _without_parse_args_namespace_exemption(
|
|
66
|
+
all_banned_names: list[ast.Name], value_node: ast.AST | None
|
|
67
|
+
) -> list[ast.Name]:
|
|
68
|
+
if not _value_is_parse_args_namespace_call(value_node):
|
|
69
|
+
return all_banned_names
|
|
70
|
+
return [each_name for each_name in all_banned_names if each_name.id != "args"]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _synthesize_alias_name_node(
|
|
74
|
+
bound_identifier: str, alias_node: ast.alias
|
|
75
|
+
) -> ast.Name:
|
|
76
|
+
synthetic_name = ast.Name(id=bound_identifier, ctx=ast.Store())
|
|
77
|
+
synthetic_name.lineno = alias_node.lineno
|
|
78
|
+
synthetic_name.col_offset = alias_node.col_offset
|
|
79
|
+
return synthetic_name
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _collect_banned_names_from_import(
|
|
83
|
+
import_statement: ast.Import | ast.ImportFrom,
|
|
84
|
+
) -> list[ast.Name]:
|
|
85
|
+
banned_alias_nodes: list[ast.Name] = []
|
|
86
|
+
for each_alias in import_statement.names:
|
|
87
|
+
bound_identifier = each_alias.asname or each_alias.name.split(".")[0]
|
|
88
|
+
if bound_identifier in ALL_BANNED_IDENTIFIERS:
|
|
89
|
+
banned_alias_nodes.append(
|
|
90
|
+
_synthesize_alias_name_node(bound_identifier, each_alias)
|
|
91
|
+
)
|
|
92
|
+
return banned_alias_nodes
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _collect_banned_names_from_node(node: ast.AST) -> list[ast.Name]:
|
|
96
|
+
"""Return banned ast.Name nodes introduced by a single binding construct."""
|
|
97
|
+
if isinstance(node, ast.Assign):
|
|
98
|
+
banned_names: list[ast.Name] = []
|
|
99
|
+
for each_target in node.targets:
|
|
100
|
+
banned_names.extend(_collect_banned_names_from_target(each_target))
|
|
101
|
+
return _without_parse_args_namespace_exemption(banned_names, node.value)
|
|
102
|
+
if isinstance(node, ast.AnnAssign):
|
|
103
|
+
banned_names = _collect_banned_names_from_target(node.target)
|
|
104
|
+
return _without_parse_args_namespace_exemption(banned_names, node.value)
|
|
105
|
+
if isinstance(node, (ast.For, ast.AsyncFor)):
|
|
106
|
+
return _collect_banned_names_from_target(node.target)
|
|
107
|
+
if isinstance(node, ast.comprehension):
|
|
108
|
+
return _collect_banned_names_from_target(node.target)
|
|
109
|
+
if isinstance(node, ast.withitem):
|
|
110
|
+
if node.optional_vars is None:
|
|
111
|
+
return []
|
|
112
|
+
return _collect_banned_names_from_target(node.optional_vars)
|
|
113
|
+
if isinstance(node, ast.NamedExpr):
|
|
114
|
+
banned_names = _collect_banned_names_from_target(node.target)
|
|
115
|
+
return _without_parse_args_namespace_exemption(banned_names, node.value)
|
|
116
|
+
if isinstance(node, (ast.Import, ast.ImportFrom)):
|
|
117
|
+
return _collect_banned_names_from_import(node)
|
|
118
|
+
return []
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def check_banned_identifiers(content: str, file_path: str) -> list[str]:
|
|
122
|
+
"""Flag assignments to identifiers banned by the project Naming rules."""
|
|
123
|
+
if is_test_file(file_path) or is_hook_infrastructure(file_path):
|
|
124
|
+
return []
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
parsed_tree = ast.parse(content)
|
|
128
|
+
except SyntaxError:
|
|
129
|
+
print(f"{file_path}: {BANNED_IDENTIFIER_SKIP_ADVISORY}", file=sys.stderr)
|
|
130
|
+
return []
|
|
131
|
+
|
|
132
|
+
banned_name_nodes: list[ast.Name] = []
|
|
133
|
+
for each_node in ast.walk(parsed_tree):
|
|
134
|
+
banned_name_nodes.extend(_collect_banned_names_from_node(each_node))
|
|
135
|
+
|
|
136
|
+
banned_name_nodes.sort(key=lambda each_name: (each_name.lineno, each_name.col_offset))
|
|
137
|
+
|
|
138
|
+
issues: list[str] = []
|
|
139
|
+
for each_name in banned_name_nodes:
|
|
140
|
+
issues.append(
|
|
141
|
+
f"Line {each_name.lineno}: Banned identifier '{each_name.id}' - {BANNED_IDENTIFIER_MESSAGE_SUFFIX}"
|
|
142
|
+
)
|
|
143
|
+
if len(issues) >= MAX_BANNED_IDENTIFIER_ISSUES:
|
|
144
|
+
break
|
|
145
|
+
|
|
146
|
+
return issues
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _identifier_word_parts(identifier: str) -> list[str]:
|
|
150
|
+
"""Split an identifier into lowercase word parts.
|
|
151
|
+
|
|
152
|
+
Handles snake_case (split on ``_``), SCREAMING_SNAKE_CASE, and camelCase /
|
|
153
|
+
PascalCase (split on capital-letter boundaries). Returns a list of
|
|
154
|
+
lowercased word tokens for membership comparison against banned-noun
|
|
155
|
+
vocabularies.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
identifier: A Python identifier (variable, parameter, class, or
|
|
159
|
+
function name).
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
Word tokens in their original order, lowercased. Empty list when the
|
|
163
|
+
identifier carries no letter characters.
|
|
164
|
+
"""
|
|
165
|
+
all_words: list[str] = []
|
|
166
|
+
for each_snake_segment in identifier.split("_"):
|
|
167
|
+
if not each_snake_segment:
|
|
168
|
+
continue
|
|
169
|
+
camel_pieces = CAMEL_CASE_WORD_PATTERN.findall(each_snake_segment)
|
|
170
|
+
if camel_pieces:
|
|
171
|
+
for each_piece in camel_pieces:
|
|
172
|
+
all_words.append(each_piece.lower())
|
|
173
|
+
else:
|
|
174
|
+
all_words.append(each_snake_segment.lower())
|
|
175
|
+
return all_words
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _find_banned_noun_word(identifier: str) -> str | None:
|
|
179
|
+
"""Return the first banned-noun word embedded in *identifier*, or None.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
identifier: A Python identifier.
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
The lowercased banned noun word that appears as a word part inside the
|
|
186
|
+
identifier (e.g., ``'result'`` for ``'HolidayPeakResult'``). Returns
|
|
187
|
+
``None`` when no banned noun word is present.
|
|
188
|
+
"""
|
|
189
|
+
for each_word in _identifier_word_parts(identifier):
|
|
190
|
+
if each_word in ALL_BANNED_NOUN_WORDS:
|
|
191
|
+
return each_word
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _is_dunder_name(identifier: str) -> bool:
|
|
196
|
+
return identifier.startswith("__") and identifier.endswith("__")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _collect_banned_noun_word_bindings(
|
|
200
|
+
parsed_tree: ast.AST,
|
|
201
|
+
) -> list[tuple[str, int, int, str]]:
|
|
202
|
+
"""Yield ``(identifier, lineno, col_offset, banned_word)`` for each binding.
|
|
203
|
+
|
|
204
|
+
Walks assignment targets, annotated assignments, function/method
|
|
205
|
+
parameters, function/method definitions, and class definitions. Skips
|
|
206
|
+
identifiers that already match ``ALL_BANNED_IDENTIFIERS`` exactly (those
|
|
207
|
+
are reported by ``check_banned_identifiers``) and dunder names.
|
|
208
|
+
"""
|
|
209
|
+
flagged_bindings: list[tuple[str, int, int, str]] = []
|
|
210
|
+
seen_keys: set[tuple[str, int, int]] = set()
|
|
211
|
+
|
|
212
|
+
def record(name: str, lineno: int, col_offset: int) -> None:
|
|
213
|
+
if name in ALL_BANNED_IDENTIFIERS:
|
|
214
|
+
return
|
|
215
|
+
if _is_dunder_name(name):
|
|
216
|
+
return
|
|
217
|
+
banned_word = _find_banned_noun_word(name)
|
|
218
|
+
if banned_word is None:
|
|
219
|
+
return
|
|
220
|
+
key = (name, lineno, col_offset)
|
|
221
|
+
if key in seen_keys:
|
|
222
|
+
return
|
|
223
|
+
seen_keys.add(key)
|
|
224
|
+
flagged_bindings.append((name, lineno, col_offset, banned_word))
|
|
225
|
+
|
|
226
|
+
for each_node in ast.walk(parsed_tree):
|
|
227
|
+
if isinstance(each_node, ast.Assign):
|
|
228
|
+
for each_target in each_node.targets:
|
|
229
|
+
for each_name_node in _collect_target_names(each_target):
|
|
230
|
+
record(each_name_node.id, each_name_node.lineno, each_name_node.col_offset)
|
|
231
|
+
elif isinstance(each_node, ast.AnnAssign):
|
|
232
|
+
for each_name_node in _collect_target_names(each_node.target):
|
|
233
|
+
record(each_name_node.id, each_name_node.lineno, each_name_node.col_offset)
|
|
234
|
+
elif isinstance(each_node, (ast.For, ast.AsyncFor)):
|
|
235
|
+
for each_name_node in _collect_target_names(each_node.target):
|
|
236
|
+
record(each_name_node.id, each_name_node.lineno, each_name_node.col_offset)
|
|
237
|
+
elif isinstance(each_node, ast.NamedExpr) and isinstance(each_node.target, ast.Name):
|
|
238
|
+
record(each_node.target.id, each_node.target.lineno, each_node.target.col_offset)
|
|
239
|
+
elif isinstance(each_node, ast.comprehension):
|
|
240
|
+
for each_name_node in _collect_target_names(each_node.target):
|
|
241
|
+
record(each_name_node.id, each_name_node.lineno, each_name_node.col_offset)
|
|
242
|
+
elif isinstance(each_node, ast.withitem) and each_node.optional_vars is not None:
|
|
243
|
+
for each_name_node in _collect_target_names(each_node.optional_vars):
|
|
244
|
+
record(each_name_node.id, each_name_node.lineno, each_name_node.col_offset)
|
|
245
|
+
elif isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
246
|
+
record(each_node.name, each_node.lineno, each_node.col_offset)
|
|
247
|
+
for each_arg in _collect_annotated_arguments(each_node):
|
|
248
|
+
if each_arg.arg in ALL_SELF_AND_CLS_PARAMETER_NAMES:
|
|
249
|
+
continue
|
|
250
|
+
record(each_arg.arg, each_arg.lineno, each_arg.col_offset)
|
|
251
|
+
elif isinstance(each_node, ast.ClassDef):
|
|
252
|
+
record(each_node.name, each_node.lineno, each_node.col_offset)
|
|
253
|
+
elif isinstance(each_node, (ast.Import, ast.ImportFrom)):
|
|
254
|
+
for each_alias in each_node.names:
|
|
255
|
+
if each_alias.asname is None:
|
|
256
|
+
continue
|
|
257
|
+
record(each_alias.asname, each_node.lineno, each_node.col_offset)
|
|
258
|
+
|
|
259
|
+
flagged_bindings.sort(key=lambda binding: (binding[1], binding[2]))
|
|
260
|
+
return flagged_bindings
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def check_banned_noun_word_boundary(
|
|
264
|
+
content: str,
|
|
265
|
+
file_path: str,
|
|
266
|
+
all_changed_lines: set[int] | None = None,
|
|
267
|
+
defer_scope_to_caller: bool = False,
|
|
268
|
+
) -> list[str]:
|
|
269
|
+
"""Flag identifiers containing CODE_RULES naming-rule banned noun words.
|
|
270
|
+
|
|
271
|
+
Companion to ``check_banned_identifiers`` (exact-match cases only). This
|
|
272
|
+
check catches the wider pattern: a banned noun word from
|
|
273
|
+
``ALL_BANNED_NOUN_WORDS`` — the singular nouns ``result``, ``data``,
|
|
274
|
+
``output``, ``response``, ``value``, ``item``, ``temp`` plus the plural
|
|
275
|
+
forms ``results``, ``outputs``, ``responses``, ``values``, ``items`` —
|
|
276
|
+
appearing as a snake_case word part or camelCase word part inside a longer
|
|
277
|
+
identifier (``canned_results``, ``HolidayPeakResult``, ``OUTPUT_DIR``,
|
|
278
|
+
``cached_response``).
|
|
279
|
+
|
|
280
|
+
Skips test files, config files, hook infrastructure, workflow registries,
|
|
281
|
+
and migrations. Identifiers that exactly match ``ALL_BANNED_IDENTIFIERS``
|
|
282
|
+
are skipped because they are already reported by
|
|
283
|
+
``check_banned_identifiers``.
|
|
284
|
+
|
|
285
|
+
Scoping mirrors ``check_function_length`` and
|
|
286
|
+
``check_tests_use_isolated_filesystem_paths`` through the shared
|
|
287
|
+
``_scope_violations_to_changed_lines`` helper. A banned-noun binding is a
|
|
288
|
+
point fact about one identifier, so its enclosing unit is its own binding
|
|
289
|
+
line: each violation carries the binding line as a one-line ``range`` for
|
|
290
|
+
terminal diff scoping and a ``(binding span at line X, spanning 1 lines)``
|
|
291
|
+
message fragment the commit gate reconstructs through the same shared span
|
|
292
|
+
extractor registry the other two scoped checks use. Anchoring to the
|
|
293
|
+
binding line (rather than the whole enclosing function) matches the
|
|
294
|
+
companion exact-match ``check_banned_identifiers`` and keeps a pre-existing
|
|
295
|
+
binding out of scope when an unrelated line of its enclosing function is
|
|
296
|
+
edited. On a terminal Edit only violations whose binding line is among
|
|
297
|
+
``all_changed_lines`` are returned; on a new-file or full-file write every
|
|
298
|
+
violation is in scope; ``defer_scope_to_caller`` returns every violation so
|
|
299
|
+
the gate scopes by added line.
|
|
300
|
+
|
|
301
|
+
Args:
|
|
302
|
+
content: The reconstructed effective file content to analyze (the
|
|
303
|
+
whole post-edit file on an Edit, the whole file at the gate).
|
|
304
|
+
file_path: The path of the file being checked (used for exemption
|
|
305
|
+
routing).
|
|
306
|
+
all_changed_lines: Post-edit line numbers the current edit touched, or
|
|
307
|
+
None to treat the whole file as in scope. When provided, a violation
|
|
308
|
+
blocks only when its binding line is among the changed lines.
|
|
309
|
+
defer_scope_to_caller: When True, return every violation so the
|
|
310
|
+
commit/push gate's ``split_violations_by_scope`` can scope by added
|
|
311
|
+
line and report the in-scope set.
|
|
312
|
+
|
|
313
|
+
Returns:
|
|
314
|
+
Issue strings, each describing one offending binding. When
|
|
315
|
+
*defer_scope_to_caller* is True every binding is returned for the gate
|
|
316
|
+
to scope; otherwise every binding in scope is returned.
|
|
317
|
+
"""
|
|
318
|
+
if is_test_file(file_path) or is_hook_infrastructure(file_path):
|
|
319
|
+
return []
|
|
320
|
+
if is_config_file(file_path):
|
|
321
|
+
return []
|
|
322
|
+
if is_workflow_registry_file(file_path):
|
|
323
|
+
return []
|
|
324
|
+
if is_migration_file(file_path):
|
|
325
|
+
return []
|
|
326
|
+
|
|
327
|
+
try:
|
|
328
|
+
parsed_tree = ast.parse(content)
|
|
329
|
+
except SyntaxError:
|
|
330
|
+
return []
|
|
331
|
+
|
|
332
|
+
single_line_span = 1
|
|
333
|
+
all_violations_in_walk_order: list[tuple[range, str]] = []
|
|
334
|
+
for each_name, each_lineno, _, each_word in _collect_banned_noun_word_bindings(parsed_tree):
|
|
335
|
+
span_range = range(each_lineno, each_lineno + single_line_span)
|
|
336
|
+
span_fragment = BANNED_NOUN_SPAN_FRAGMENT_TEMPLATE.format(
|
|
337
|
+
definition_line=each_lineno, line_span=single_line_span
|
|
338
|
+
)
|
|
339
|
+
message = (
|
|
340
|
+
f"Line {each_lineno}: Identifier {each_name!r} {BANNED_NOUN_WORD_MESSAGE_SUFFIX} "
|
|
341
|
+
f"(word: {each_word!r}) {span_fragment}"
|
|
342
|
+
)
|
|
343
|
+
all_violations_in_walk_order.append((span_range, message))
|
|
344
|
+
return _scope_violations_to_changed_lines(
|
|
345
|
+
all_violations_in_walk_order,
|
|
346
|
+
all_changed_lines,
|
|
347
|
+
defer_scope_to_caller,
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def check_banned_prefixes(content: str, file_path: str) -> list[str]:
|
|
352
|
+
"""Flag function and method names using generic banned prefixes.
|
|
353
|
+
|
|
354
|
+
Per CODE_RULES.md / AGENTS.md Naming, function names use specific verbs.
|
|
355
|
+
Generic prefixes ``handle_``, ``process_``, ``manage_``, ``do_`` are
|
|
356
|
+
placeholders that hide the actual responsibility and are flagged so the
|
|
357
|
+
author renames the function to a specific verb.
|
|
358
|
+
"""
|
|
359
|
+
if is_test_file(file_path) or is_hook_infrastructure(file_path) or is_config_file(file_path):
|
|
360
|
+
return []
|
|
361
|
+
|
|
362
|
+
try:
|
|
363
|
+
parsed_tree = ast.parse(content)
|
|
364
|
+
except SyntaxError:
|
|
365
|
+
return []
|
|
366
|
+
|
|
367
|
+
flagged_function_nodes: list[ast.FunctionDef | ast.AsyncFunctionDef] = []
|
|
368
|
+
for each_node in _walk_skipping_type_checking_blocks(parsed_tree):
|
|
369
|
+
if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
370
|
+
continue
|
|
371
|
+
if any(each_node.name.startswith(each_prefix) for each_prefix in ALL_BANNED_PREFIX_NAMES):
|
|
372
|
+
flagged_function_nodes.append(each_node)
|
|
373
|
+
|
|
374
|
+
flagged_function_nodes.sort(key=lambda each_function: each_function.lineno)
|
|
375
|
+
|
|
376
|
+
issues: list[str] = []
|
|
377
|
+
for each_function in flagged_function_nodes:
|
|
378
|
+
issues.append(
|
|
379
|
+
f"Line {each_function.lineno}: Function '{each_function.name}' uses banned prefix - "
|
|
380
|
+
"rename to a specific verb (see CODE_RULES Naming section)"
|
|
381
|
+
)
|
|
382
|
+
if len(issues) >= MAX_BANNED_PREFIX_ISSUES:
|
|
383
|
+
break
|
|
384
|
+
|
|
385
|
+
return issues
|