claude-dev-env 1.50.0 → 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.
Files changed (82) hide show
  1. package/hooks/blocking/_gh_body_arg_utils.py +67 -11
  2. package/hooks/blocking/_md_to_html_blocker_test_support.py +65 -0
  3. package/hooks/blocking/code_rules_annotations_length.py +167 -0
  4. package/hooks/blocking/code_rules_banned_identifiers.py +385 -0
  5. package/hooks/blocking/code_rules_boolean_mustcheck.py +350 -0
  6. package/hooks/blocking/code_rules_comments.py +337 -0
  7. package/hooks/blocking/code_rules_constants_config.py +252 -0
  8. package/hooks/blocking/code_rules_docstrings.py +308 -0
  9. package/hooks/blocking/code_rules_enforcer.py +98 -5765
  10. package/hooks/blocking/code_rules_imports_logging.py +276 -0
  11. package/hooks/blocking/code_rules_magic_values.py +180 -0
  12. package/hooks/blocking/code_rules_mock_completeness.py +295 -0
  13. package/hooks/blocking/code_rules_naming_collection.py +264 -0
  14. package/hooks/blocking/code_rules_optional_params.py +288 -0
  15. package/hooks/blocking/code_rules_paths_syspath.py +186 -0
  16. package/hooks/blocking/code_rules_probe_chains.py +305 -0
  17. package/hooks/blocking/code_rules_probe_detection.py +257 -0
  18. package/hooks/blocking/code_rules_probe_recording.py +225 -0
  19. package/hooks/blocking/code_rules_scope_binding.py +151 -0
  20. package/hooks/blocking/code_rules_shared.py +301 -0
  21. package/hooks/blocking/code_rules_string_magic.py +207 -0
  22. package/hooks/blocking/code_rules_test_assertions.py +226 -0
  23. package/hooks/blocking/code_rules_test_branching_except.py +181 -0
  24. package/hooks/blocking/code_rules_test_isolation.py +341 -0
  25. package/hooks/blocking/code_rules_type_escape.py +341 -0
  26. package/hooks/blocking/code_rules_typeddict_stub.py +305 -0
  27. package/hooks/blocking/code_rules_unused_imports.py +256 -0
  28. package/hooks/blocking/conftest.py +30 -0
  29. package/hooks/blocking/pr_description_body_audit.py +148 -0
  30. package/hooks/blocking/pr_description_command_parser.py +233 -0
  31. package/hooks/blocking/pr_description_enforcer.py +36 -825
  32. package/hooks/blocking/pr_description_pr_number.py +153 -0
  33. package/hooks/blocking/pr_description_readability.py +366 -0
  34. package/hooks/blocking/tdd_enforcer.py +31 -0
  35. package/hooks/blocking/test_code_rules_constants_config.py +26 -0
  36. package/hooks/blocking/test_code_rules_enforcer_banned_noun_word.py +5 -2
  37. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +0 -5
  38. package/hooks/blocking/test_code_rules_enforcer_comment_string_awareness.py +21 -15
  39. package/hooks/blocking/test_code_rules_enforcer_config_path.py +20 -16
  40. package/hooks/blocking/test_code_rules_enforcer_exempt_marker_chained.py +4 -2
  41. package/hooks/blocking/test_code_rules_enforcer_function_length.py +154 -18
  42. package/hooks/blocking/test_code_rules_enforcer_hardcoded_user_path.py +1 -2
  43. package/hooks/blocking/test_code_rules_enforcer_ignored_must_check_return.py +22 -12
  44. package/hooks/blocking/test_code_rules_enforcer_split_annotations_length.py +55 -0
  45. package/hooks/blocking/test_code_rules_enforcer_split_banned.py +170 -0
  46. package/hooks/blocking/test_code_rules_enforcer_split_comments.py +60 -0
  47. package/hooks/blocking/test_code_rules_enforcer_split_config_path.py +52 -0
  48. package/hooks/blocking/test_code_rules_enforcer_split_constants_config.py +236 -0
  49. package/hooks/blocking/test_code_rules_enforcer_split_entry_1.py +296 -0
  50. package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +238 -0
  51. package/hooks/blocking/test_code_rules_enforcer_split_isolation_1.py +271 -0
  52. package/hooks/blocking/test_code_rules_enforcer_split_isolation_2.py +283 -0
  53. package/hooks/blocking/test_code_rules_enforcer_split_isolation_3.py +268 -0
  54. package/hooks/blocking/test_code_rules_enforcer_split_isolation_4.py +85 -0
  55. package/hooks/blocking/test_code_rules_enforcer_split_mocks_1.py +303 -0
  56. package/hooks/blocking/test_code_rules_enforcer_split_mocks_2.py +111 -0
  57. package/hooks/blocking/test_code_rules_enforcer_split_mustcheck.py +87 -0
  58. package/hooks/blocking/test_code_rules_enforcer_split_naming.py +107 -0
  59. package/hooks/blocking/test_code_rules_enforcer_split_optional_params.py +325 -0
  60. package/hooks/blocking/test_code_rules_enforcer_split_paths_syspath.py +110 -0
  61. package/hooks/blocking/test_code_rules_enforcer_split_shared.py +44 -0
  62. package/hooks/blocking/test_code_rules_enforcer_split_string_magic.py +55 -0
  63. package/hooks/blocking/test_code_rules_enforcer_split_test_assertions.py +56 -0
  64. package/hooks/blocking/test_code_rules_enforcer_todo_markers.py +21 -15
  65. package/hooks/blocking/test_code_rules_paths_syspath.py +26 -0
  66. package/hooks/blocking/test_md_to_html_blocker_exemptions.py +368 -0
  67. package/hooks/blocking/test_md_to_html_blocker_extensions.py +157 -0
  68. package/hooks/blocking/test_md_to_html_blocker_path_resolution.py +336 -0
  69. package/hooks/blocking/test_pr_description_enforcer.py +13 -1499
  70. package/hooks/blocking/test_pr_description_enforcer_body_audit.py +247 -0
  71. package/hooks/blocking/test_pr_description_enforcer_body_rules.py +493 -0
  72. package/hooks/blocking/test_pr_description_enforcer_command_parser.py +366 -0
  73. package/hooks/blocking/test_pr_description_enforcer_pr_number.py +159 -0
  74. package/hooks/blocking/test_pr_description_enforcer_readability.py +443 -0
  75. package/hooks/blocking/test_tdd_enforcer.py +116 -0
  76. package/hooks/hooks_constants/blocking_check_limits.py +3 -0
  77. package/hooks/hooks_constants/code_rules_enforcer_constants.py +8 -0
  78. package/hooks/hooks_constants/pr_description_enforcer_constants.py +7 -0
  79. package/hooks/hooks_constants/sys_path_insert_constants.py +1 -0
  80. package/package.json +1 -1
  81. package/hooks/blocking/test_code_rules_enforcer.py +0 -2669
  82. package/hooks/blocking/test_md_to_html_blocker.py +0 -810
@@ -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