claude-dev-env 1.50.1 → 1.50.3
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/audit-contract.md +3 -3
- package/audit-rubrics/category_rubrics/category-e-dead-code.md +3 -2
- package/audit-rubrics/prompts/category-a-api-contracts.md +1 -1
- package/audit-rubrics/prompts/category-b-selector-engine-compat.md +2 -2
- package/audit-rubrics/prompts/category-c-resource-cleanup.md +2 -2
- package/audit-rubrics/prompts/category-d-scoping-and-ordering.md +2 -2
- package/audit-rubrics/prompts/category-e-dead-code.md +5 -4
- package/audit-rubrics/prompts/category-f-silent-failures.md +2 -2
- package/audit-rubrics/prompts/category-g-bounds-and-overflow.md +2 -2
- package/audit-rubrics/prompts/category-h-security-boundaries.md +2 -2
- package/audit-rubrics/prompts/category-i-concurrency.md +2 -2
- package/audit-rubrics/prompts/category-j-code-rules-compliance.md +2 -2
- package/audit-rubrics/prompts/category-k-codebase-conflicts.md +2 -2
- package/docs/CODE_RULES.md +1 -1
- 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/skills/_shared/pr-loop/scripts/build_audit_prompt.py +13 -7
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/path_resolver_constants.py +21 -11
- package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +92 -0
- package/skills/bugteam/CONSTRAINTS.md +1 -1
- package/skills/bugteam/PROMPTS.md +20 -48
- package/skills/bugteam/SKILL.md +5 -5
- package/skills/bugteam/reference/audit-and-teammates.md +1 -1
- package/skills/bugteam/reference/audit-contract.md +4 -4
- package/skills/bugteam/reference/design-rationale.md +1 -1
- package/skills/findbugs/SKILL.md +21 -12
- package/skills/fixbugs/SKILL.md +1 -1
- package/skills/qbug/SKILL.md +5 -5
- package/skills/qbug/test_qbug_skill_audit_schema.py +13 -23
- package/skills/refine/SKILL.md +1 -1
- package/hooks/blocking/test_code_rules_enforcer.py +0 -2669
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"""Unused-optional-parameter and duplicated-format-pattern 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
|
+
_extract_fstring_literal_parts,
|
|
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
|
+
DUPLICATED_FORMAT_MINIMUM_LITERAL_CHARACTER_COUNT,
|
|
27
|
+
DUPLICATED_FORMAT_MINIMUM_REPETITION_COUNT,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _collect_optional_param_defaults(
|
|
32
|
+
function_node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
33
|
+
) -> dict[str, ast.expr]:
|
|
34
|
+
"""Return mapping of param name to its default AST node for params with defaults."""
|
|
35
|
+
arguments = function_node.args
|
|
36
|
+
all_args = arguments.posonlyargs + arguments.args
|
|
37
|
+
defaults_aligned = [None] * (len(all_args) - len(arguments.defaults)) + list(arguments.defaults)
|
|
38
|
+
param_defaults: dict[str, ast.expr] = {}
|
|
39
|
+
for each_arg, each_default in zip(all_args, defaults_aligned):
|
|
40
|
+
if each_default is not None:
|
|
41
|
+
param_defaults[each_arg.arg] = each_default
|
|
42
|
+
for each_kwarg, each_kwdefault in zip(arguments.kwonlyargs, arguments.kw_defaults):
|
|
43
|
+
if each_kwdefault is not None:
|
|
44
|
+
param_defaults[each_kwarg.arg] = each_kwdefault
|
|
45
|
+
return param_defaults
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
_NON_LITERAL_DEFAULT_SENTINEL = object()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _is_non_literal_default(candidate_default: object) -> bool:
|
|
52
|
+
"""Return True when a value is the sentinel for a non-literal default."""
|
|
53
|
+
return candidate_default is _NON_LITERAL_DEFAULT_SENTINEL
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _ast_constant_value(node: ast.expr) -> object:
|
|
57
|
+
"""Return the Python value of a Constant node, or a stable sentinel for non-constants.
|
|
58
|
+
|
|
59
|
+
Non-literal defaults (e.g. DEFAULT_TIMEOUT) return a single shared sentinel
|
|
60
|
+
so that the unused-optional check can identify and skip them rather than
|
|
61
|
+
treating every non-literal as automatically different.
|
|
62
|
+
"""
|
|
63
|
+
if isinstance(node, ast.Constant):
|
|
64
|
+
return node.value
|
|
65
|
+
return _NON_LITERAL_DEFAULT_SENTINEL
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _call_passes_keyword_argument_differing_from_default(
|
|
69
|
+
call_node: ast.Call,
|
|
70
|
+
param_name: str,
|
|
71
|
+
default_value: object,
|
|
72
|
+
) -> bool:
|
|
73
|
+
"""Return True when a Call passes param_name with a value different from default.
|
|
74
|
+
|
|
75
|
+
Returns True conservatively when **kwargs expansion is present, because the
|
|
76
|
+
expansion may pass the parameter with an unknown value — treating it as
|
|
77
|
+
indeterminate prevents false positives from the unused-optional check.
|
|
78
|
+
"""
|
|
79
|
+
for each_keyword in call_node.keywords:
|
|
80
|
+
if each_keyword.arg is None:
|
|
81
|
+
return True
|
|
82
|
+
if each_keyword.arg == param_name:
|
|
83
|
+
passed_value = _ast_constant_value(each_keyword.value)
|
|
84
|
+
return passed_value != default_value
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _call_has_kwargs_expansion(call_node: ast.Call) -> bool:
|
|
89
|
+
"""Return True when a Call contains a **kwargs expansion (arg=None in AST keywords)."""
|
|
90
|
+
return any(each_keyword.arg is None for each_keyword in call_node.keywords)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _call_has_starargs_expansion(call_node: ast.Call) -> bool:
|
|
94
|
+
"""Return True when a Call contains a *args expansion (Starred node in positional args)."""
|
|
95
|
+
return any(isinstance(each_arg, ast.Starred) for each_arg in call_node.args)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _call_passes_positional_argument_for_param(
|
|
99
|
+
call_node: ast.Call,
|
|
100
|
+
function_node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
101
|
+
param_name: str,
|
|
102
|
+
default_value: object,
|
|
103
|
+
) -> bool:
|
|
104
|
+
"""Return True when a Call passes param_name positionally with a varied value.
|
|
105
|
+
|
|
106
|
+
Returns False when **kwargs expansion is present (the keyword helper covers
|
|
107
|
+
that case). Returns True conservatively when *args expansion is present,
|
|
108
|
+
because the expanded iterable may provide the parameter at runtime.
|
|
109
|
+
"""
|
|
110
|
+
if _call_has_kwargs_expansion(call_node):
|
|
111
|
+
return False
|
|
112
|
+
if _call_has_starargs_expansion(call_node):
|
|
113
|
+
return True
|
|
114
|
+
all_args = function_node.args.posonlyargs + function_node.args.args
|
|
115
|
+
try:
|
|
116
|
+
param_index = next(
|
|
117
|
+
each_index
|
|
118
|
+
for each_index, each_arg in enumerate(all_args)
|
|
119
|
+
if each_arg.arg == param_name
|
|
120
|
+
)
|
|
121
|
+
except StopIteration:
|
|
122
|
+
return False
|
|
123
|
+
if param_index >= len(call_node.args):
|
|
124
|
+
return False
|
|
125
|
+
passed_value = _ast_constant_value(call_node.args[param_index])
|
|
126
|
+
return passed_value != default_value
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _function_name_from_call(call_node: ast.Call) -> str | None:
|
|
130
|
+
"""Return the function name for direct calls only, or None.
|
|
131
|
+
|
|
132
|
+
Only direct calls (ast.Name) are matched as same-file call sites.
|
|
133
|
+
Attribute calls like obj.foo() are not counted because the receiver
|
|
134
|
+
object may not be the same file's definition — returning the attr name
|
|
135
|
+
would cause false positives against any local function sharing that name.
|
|
136
|
+
"""
|
|
137
|
+
if isinstance(call_node.func, ast.Name):
|
|
138
|
+
return call_node.func.id
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def check_unused_optional_parameters(content: str, file_path: str) -> list[str]:
|
|
143
|
+
"""Flag optional parameters never varied at same-file call sites.
|
|
144
|
+
|
|
145
|
+
A parameter with a default value that every same-file caller either omits
|
|
146
|
+
or always passes with the identical default literal is never varied and
|
|
147
|
+
should be inlined or dropped per the YAGNI API surface rule.
|
|
148
|
+
|
|
149
|
+
Skips test files, config files, workflow registry files, migration files,
|
|
150
|
+
and hook infrastructure files. Only checks functions that have at least
|
|
151
|
+
one same-file call site.
|
|
152
|
+
|
|
153
|
+
Scope limit (v1): only module-level functions are analyzed. Methods defined
|
|
154
|
+
inside a ClassDef are skipped because the positional-index calculation would
|
|
155
|
+
need to account for the implicit self/cls parameter, which is absent at
|
|
156
|
+
call sites using attribute access (obj.method(...)). Method analysis is out
|
|
157
|
+
of scope for this version.
|
|
158
|
+
"""
|
|
159
|
+
if is_test_file(file_path):
|
|
160
|
+
return []
|
|
161
|
+
if is_config_file(file_path):
|
|
162
|
+
return []
|
|
163
|
+
if is_workflow_registry_file(file_path):
|
|
164
|
+
return []
|
|
165
|
+
if is_migration_file(file_path):
|
|
166
|
+
return []
|
|
167
|
+
if is_hook_infrastructure(file_path):
|
|
168
|
+
return []
|
|
169
|
+
|
|
170
|
+
try:
|
|
171
|
+
module_tree = ast.parse(content)
|
|
172
|
+
except SyntaxError:
|
|
173
|
+
return []
|
|
174
|
+
|
|
175
|
+
all_function_nodes: dict[str, ast.FunctionDef | ast.AsyncFunctionDef] = {}
|
|
176
|
+
for each_node in module_tree.body:
|
|
177
|
+
if isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
178
|
+
all_function_nodes[each_node.name] = each_node
|
|
179
|
+
|
|
180
|
+
all_call_nodes: list[ast.Call] = [
|
|
181
|
+
each_node
|
|
182
|
+
for each_node in ast.walk(module_tree)
|
|
183
|
+
if isinstance(each_node, ast.Call)
|
|
184
|
+
]
|
|
185
|
+
|
|
186
|
+
issues: list[str] = []
|
|
187
|
+
for each_function_name, each_function_node in all_function_nodes.items():
|
|
188
|
+
param_defaults = _collect_optional_param_defaults(each_function_node)
|
|
189
|
+
if not param_defaults:
|
|
190
|
+
continue
|
|
191
|
+
|
|
192
|
+
same_file_calls = [
|
|
193
|
+
each_call
|
|
194
|
+
for each_call in all_call_nodes
|
|
195
|
+
if _function_name_from_call(each_call) == each_function_name
|
|
196
|
+
]
|
|
197
|
+
if not same_file_calls:
|
|
198
|
+
continue
|
|
199
|
+
|
|
200
|
+
for each_param_name, each_default_node in param_defaults.items():
|
|
201
|
+
default_value = _ast_constant_value(each_default_node)
|
|
202
|
+
if _is_non_literal_default(default_value):
|
|
203
|
+
continue
|
|
204
|
+
is_param_varied = any(
|
|
205
|
+
_call_passes_keyword_argument_differing_from_default(
|
|
206
|
+
each_call, each_param_name, default_value
|
|
207
|
+
)
|
|
208
|
+
or _call_passes_positional_argument_for_param(
|
|
209
|
+
each_call, each_function_node, each_param_name, default_value
|
|
210
|
+
)
|
|
211
|
+
for each_call in same_file_calls
|
|
212
|
+
)
|
|
213
|
+
if not is_param_varied:
|
|
214
|
+
issues.append(
|
|
215
|
+
f"Line {each_function_node.lineno}: optional parameter {each_param_name}"
|
|
216
|
+
f" is never varied — inline default or drop"
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
return issues
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _build_fstring_skeleton(joined_str_node: ast.JoinedStr) -> str:
|
|
223
|
+
"""Collapse interpolations in an f-string to a placeholder to form a pattern skeleton.
|
|
224
|
+
|
|
225
|
+
Injects the skeleton placeholder directly via _extract_fstring_literal_parts
|
|
226
|
+
instead of post-processing, so literal text in the source that happens to
|
|
227
|
+
contain the default placeholder (or any other substring) is preserved
|
|
228
|
+
verbatim and cannot collide with interpolation slots.
|
|
229
|
+
"""
|
|
230
|
+
skeleton_interpolation_placeholder = "<x>"
|
|
231
|
+
_display_body, shape_body = _extract_fstring_literal_parts(
|
|
232
|
+
joined_str_node,
|
|
233
|
+
interpolation_placeholder=skeleton_interpolation_placeholder,
|
|
234
|
+
)
|
|
235
|
+
return shape_body
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def check_duplicated_format_patterns(content: str, file_path: str) -> None:
|
|
239
|
+
"""Emit stderr advisories when an f-string skeleton repeats in a production file.
|
|
240
|
+
|
|
241
|
+
Collapses each f-string's interpolations to '<x>' placeholders, then counts
|
|
242
|
+
skeleton occurrences per file. When any skeleton appears three or more times,
|
|
243
|
+
it suggests the pattern belongs in a helper or model method.
|
|
244
|
+
|
|
245
|
+
This is advisory-only (no return value, no blocking). Skips test files,
|
|
246
|
+
config files, workflow registry files, migration files, and hook infrastructure.
|
|
247
|
+
"""
|
|
248
|
+
if is_test_file(file_path):
|
|
249
|
+
return
|
|
250
|
+
if is_config_file(file_path):
|
|
251
|
+
return
|
|
252
|
+
if is_workflow_registry_file(file_path):
|
|
253
|
+
return
|
|
254
|
+
if is_migration_file(file_path):
|
|
255
|
+
return
|
|
256
|
+
if is_hook_infrastructure(file_path):
|
|
257
|
+
return
|
|
258
|
+
|
|
259
|
+
try:
|
|
260
|
+
module_tree = ast.parse(content)
|
|
261
|
+
except SyntaxError:
|
|
262
|
+
return
|
|
263
|
+
|
|
264
|
+
minimum_repetition_count = DUPLICATED_FORMAT_MINIMUM_REPETITION_COUNT
|
|
265
|
+
minimum_literal_character_count = DUPLICATED_FORMAT_MINIMUM_LITERAL_CHARACTER_COUNT
|
|
266
|
+
|
|
267
|
+
skeleton_occurrences: dict[str, list[int]] = {}
|
|
268
|
+
literal_length_by_skeleton: dict[str, int] = {}
|
|
269
|
+
for each_node in ast.walk(module_tree):
|
|
270
|
+
if not isinstance(each_node, ast.JoinedStr):
|
|
271
|
+
continue
|
|
272
|
+
skeleton = _build_fstring_skeleton(each_node)
|
|
273
|
+
literal_body, _shape_body = _extract_fstring_literal_parts(each_node)
|
|
274
|
+
if skeleton not in skeleton_occurrences:
|
|
275
|
+
skeleton_occurrences[skeleton] = []
|
|
276
|
+
literal_length_by_skeleton[skeleton] = len(literal_body)
|
|
277
|
+
skeleton_occurrences[skeleton].append(each_node.lineno)
|
|
278
|
+
|
|
279
|
+
for each_skeleton, each_line_numbers in skeleton_occurrences.items():
|
|
280
|
+
if len(each_line_numbers) < minimum_repetition_count:
|
|
281
|
+
continue
|
|
282
|
+
if literal_length_by_skeleton[each_skeleton] < minimum_literal_character_count:
|
|
283
|
+
continue
|
|
284
|
+
print(
|
|
285
|
+
f"[CODE_RULES advisory] f-string pattern {each_skeleton!r} appears"
|
|
286
|
+
f" {len(each_line_numbers)} times — consider encapsulating in a helper or model.",
|
|
287
|
+
file=sys.stderr,
|
|
288
|
+
)
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Hardcoded user-path and sys.path.insert deduplication-guard 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
|
+
_build_parent_map,
|
|
19
|
+
is_hook_infrastructure,
|
|
20
|
+
is_migration_file,
|
|
21
|
+
is_test_file,
|
|
22
|
+
is_workflow_registry_file,
|
|
23
|
+
)
|
|
24
|
+
from code_rules_string_magic import ( # noqa: E402
|
|
25
|
+
_collect_docstring_node_ids,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
from hooks_constants.hardcoded_user_path_constants import ( # noqa: E402
|
|
29
|
+
HARDCODED_USER_PATH_GUIDANCE,
|
|
30
|
+
HARDCODED_USER_PATH_PATTERN,
|
|
31
|
+
MAX_HARDCODED_USER_PATH_ISSUES,
|
|
32
|
+
)
|
|
33
|
+
from hooks_constants.sys_path_insert_constants import ( # noqa: E402
|
|
34
|
+
MAX_SYS_PATH_INSERT_ISSUES,
|
|
35
|
+
SYS_PATH_INSERT_GUIDANCE,
|
|
36
|
+
SYS_PATH_INSERT_MINIMUM_ARGUMENT_COUNT,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def check_hardcoded_user_paths(content: str, file_path: str) -> list[str]:
|
|
41
|
+
"""Flag string literals naming a specific user's home directory.
|
|
42
|
+
|
|
43
|
+
Catches non-portable paths like `C:/Users/jon/...`, `/Users/alice/...`,
|
|
44
|
+
and `/home/bob/...` that surface in production code.
|
|
45
|
+
Test files, config/ files, workflow registry files, migration files,
|
|
46
|
+
and hook infrastructure files are exempt. Hook infrastructure exemption
|
|
47
|
+
matches the pattern used by check_library_print and other check
|
|
48
|
+
functions, and prevents the enforcer from self-blocking on its own
|
|
49
|
+
HARDCODED_USER_PATH_PATTERN definition.
|
|
50
|
+
"""
|
|
51
|
+
if is_test_file(file_path):
|
|
52
|
+
return []
|
|
53
|
+
if is_config_file(file_path):
|
|
54
|
+
return []
|
|
55
|
+
if is_workflow_registry_file(file_path) or is_migration_file(file_path):
|
|
56
|
+
return []
|
|
57
|
+
if is_hook_infrastructure(file_path):
|
|
58
|
+
return []
|
|
59
|
+
try:
|
|
60
|
+
tree = ast.parse(content)
|
|
61
|
+
except SyntaxError:
|
|
62
|
+
return []
|
|
63
|
+
docstring_node_ids = _collect_docstring_node_ids(tree)
|
|
64
|
+
issues: list[str] = []
|
|
65
|
+
for each_node in ast.walk(tree):
|
|
66
|
+
if not isinstance(each_node, ast.Constant):
|
|
67
|
+
continue
|
|
68
|
+
if not isinstance(each_node.value, str):
|
|
69
|
+
continue
|
|
70
|
+
if id(each_node) in docstring_node_ids:
|
|
71
|
+
continue
|
|
72
|
+
match = HARDCODED_USER_PATH_PATTERN.search(each_node.value)
|
|
73
|
+
if match is None:
|
|
74
|
+
continue
|
|
75
|
+
issues.append(
|
|
76
|
+
f"Line {each_node.lineno}: hardcoded user path {match.group(0)!r}"
|
|
77
|
+
f" — {HARDCODED_USER_PATH_GUIDANCE}"
|
|
78
|
+
)
|
|
79
|
+
if len(issues) >= MAX_HARDCODED_USER_PATH_ISSUES:
|
|
80
|
+
break
|
|
81
|
+
return issues
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _is_sys_path_insert_call(call_node: ast.Call) -> bool:
|
|
85
|
+
function_reference = call_node.func
|
|
86
|
+
if not isinstance(function_reference, ast.Attribute) or function_reference.attr != "insert":
|
|
87
|
+
return False
|
|
88
|
+
receiver = function_reference.value
|
|
89
|
+
if not isinstance(receiver, ast.Attribute) or receiver.attr != "path":
|
|
90
|
+
return False
|
|
91
|
+
receiver_value = receiver.value
|
|
92
|
+
return isinstance(receiver_value, ast.Name) and receiver_value.id == "sys"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _is_sys_path_membership_if_test(if_test_expression: ast.AST) -> bool:
|
|
96
|
+
"""Return True when `if X not in sys.path:` would guard a then-branch insert.
|
|
97
|
+
|
|
98
|
+
Only `ast.NotIn` is accepted: `_scope_has_guard_for_insert` walks the
|
|
99
|
+
then-branch (`each_statement.body`) for the insert, so accepting `ast.In`
|
|
100
|
+
here would silently approve `if X in sys.path: sys.path.insert(0, X)` —
|
|
101
|
+
code that always inserts a duplicate. The else-branch is intentionally not
|
|
102
|
+
inspected; a guard that places the insert in the else-branch of `if X in
|
|
103
|
+
sys.path:` is unconventional and not supported.
|
|
104
|
+
"""
|
|
105
|
+
if not isinstance(if_test_expression, ast.Compare):
|
|
106
|
+
return False
|
|
107
|
+
if len(if_test_expression.ops) != 1:
|
|
108
|
+
return False
|
|
109
|
+
if not isinstance(if_test_expression.ops[0], ast.NotIn):
|
|
110
|
+
return False
|
|
111
|
+
membership_target = if_test_expression.comparators[0]
|
|
112
|
+
if not isinstance(membership_target, ast.Attribute) or membership_target.attr != "path":
|
|
113
|
+
return False
|
|
114
|
+
membership_receiver = membership_target.value
|
|
115
|
+
return isinstance(membership_receiver, ast.Name) and membership_receiver.id == "sys"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _scope_has_guard_for_insert(
|
|
119
|
+
all_scope_statements: list[ast.stmt],
|
|
120
|
+
insert_call_node: ast.Call,
|
|
121
|
+
) -> bool:
|
|
122
|
+
for each_statement in all_scope_statements:
|
|
123
|
+
if not isinstance(each_statement, ast.If):
|
|
124
|
+
continue
|
|
125
|
+
membership_test = each_statement.test
|
|
126
|
+
if not isinstance(membership_test, ast.Compare):
|
|
127
|
+
continue
|
|
128
|
+
if not _is_sys_path_membership_if_test(membership_test):
|
|
129
|
+
continue
|
|
130
|
+
for each_inner in each_statement.body:
|
|
131
|
+
if isinstance(each_inner, ast.Expr) and each_inner.value is insert_call_node:
|
|
132
|
+
if len(insert_call_node.args) < SYS_PATH_INSERT_MINIMUM_ARGUMENT_COUNT:
|
|
133
|
+
return True
|
|
134
|
+
if ast.dump(membership_test.left) == ast.dump(insert_call_node.args[1]):
|
|
135
|
+
return True
|
|
136
|
+
return False
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _enclosing_scope_body(
|
|
140
|
+
insert_call_node: ast.Call,
|
|
141
|
+
parent_by_node_id: dict[int, ast.AST],
|
|
142
|
+
) -> list[ast.stmt]:
|
|
143
|
+
parent = parent_by_node_id.get(id(insert_call_node))
|
|
144
|
+
while parent is not None:
|
|
145
|
+
if isinstance(parent, (ast.Module, ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
146
|
+
return list(parent.body)
|
|
147
|
+
parent = parent_by_node_id.get(id(parent))
|
|
148
|
+
return []
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def check_sys_path_insert_deduplication_guard(content: str, file_path: str) -> list[str]:
|
|
152
|
+
"""Flag sys.path.insert calls that lack a `not in sys.path` guard.
|
|
153
|
+
|
|
154
|
+
Repeated module reloads can push the same entry onto sys.path multiple
|
|
155
|
+
times when the call is unguarded. The repo convention is to wrap the
|
|
156
|
+
call with `if <path> not in sys.path:`. The grant and revoke project
|
|
157
|
+
permission scripts (grant_project_claude_permissions.py,
|
|
158
|
+
revoke_project_claude_permissions.py) bypassed the convention.
|
|
159
|
+
"""
|
|
160
|
+
if is_test_file(file_path):
|
|
161
|
+
return []
|
|
162
|
+
if is_hook_infrastructure(file_path):
|
|
163
|
+
return []
|
|
164
|
+
if is_workflow_registry_file(file_path) or is_migration_file(file_path):
|
|
165
|
+
return []
|
|
166
|
+
try:
|
|
167
|
+
tree = ast.parse(content)
|
|
168
|
+
except SyntaxError:
|
|
169
|
+
return []
|
|
170
|
+
parent_by_node_id = _build_parent_map(tree)
|
|
171
|
+
issues: list[str] = []
|
|
172
|
+
for each_node in ast.walk(tree):
|
|
173
|
+
if not isinstance(each_node, ast.Call):
|
|
174
|
+
continue
|
|
175
|
+
if not _is_sys_path_insert_call(each_node):
|
|
176
|
+
continue
|
|
177
|
+
all_scope_statements = _enclosing_scope_body(each_node, parent_by_node_id)
|
|
178
|
+
if _scope_has_guard_for_insert(all_scope_statements, each_node):
|
|
179
|
+
continue
|
|
180
|
+
issues.append(
|
|
181
|
+
f"Line {each_node.lineno}: unguarded sys.path.insert"
|
|
182
|
+
f" — {SYS_PATH_INSERT_GUIDANCE}"
|
|
183
|
+
)
|
|
184
|
+
if len(issues) >= MAX_SYS_PATH_INSERT_ISSUES:
|
|
185
|
+
break
|
|
186
|
+
return issues
|