claude-dev-env 1.29.3 → 1.30.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 +8 -0
- package/agents/code-quality-agent.md +279 -24
- package/agents/groq-coder.md +111 -0
- package/commands/plan.md +4 -5
- package/docs/CODE_RULES.md +40 -0
- package/hooks/blocking/code_rules_enforcer.py +775 -8
- package/hooks/blocking/destructive_command_blocker.py +149 -12
- package/hooks/blocking/test_code_rules_enforcer.py +751 -0
- package/hooks/blocking/test_code_rules_enforcer_constant_equality.py +130 -0
- package/hooks/blocking/test_code_rules_enforcer_existence_checks.py +134 -0
- package/hooks/blocking/test_code_rules_enforcer_skip_decorators.py +150 -0
- package/hooks/blocking/test_destructive_command_blocker.py +281 -4
- package/hooks/git-hooks/test_config.py +9 -3
- package/hooks/git-hooks/test_gate_utils.py +9 -3
- package/hooks/git-hooks/test_pre_commit.py +9 -3
- package/hooks/git-hooks/test_pre_push.py +9 -3
- package/hooks/validators/run_all_validators.py +76 -3
- package/hooks/validators/test_output_formatter.py +4 -16
- package/hooks/validators/test_run_all_validators.py +22 -0
- package/hooks/validators/test_run_all_validators_integration.py +2 -11
- package/package.json +1 -1
- package/scripts/config/groq_bugteam_config.py +104 -0
- package/scripts/config/test_groq_bugteam_config.py +11 -0
- package/scripts/config/test_spec_implementer_prompt.py +36 -0
- package/scripts/groq_bugteam.README.md +2 -0
- package/scripts/groq_bugteam.py +74 -15
- package/scripts/groq_bugteam_dotenv.py +40 -0
- package/scripts/groq_bugteam_spec.py +226 -0
- package/scripts/test_groq_bugteam.py +143 -5
- package/scripts/test_groq_bugteam_apply_fix_from_spec.py +426 -0
- package/scripts/test_groq_bugteam_dotenv.py +66 -0
- package/scripts/test_groq_bugteam_spec.py +346 -0
- package/skills/bugteam/SKILL.md +4 -0
- package/skills/bugteam/reference/README.md +16 -0
- package/skills/bugteam/test_skill_additions.py +30 -0
- package/skills/monitor-open-prs/SKILL.md +104 -0
- package/skills/monitor-open-prs/scripts/discover_open_prs.py +69 -0
- package/skills/monitor-open-prs/scripts/test_discover_open_prs.py +149 -0
- package/skills/monitor-open-prs/test_skill_contract.py +43 -0
- package/skills/pr-review-responder/SKILL.md +10 -8
- package/hooks/github-action/pre-push-review.yml +0 -27
- package/hooks/github-action/test_workflow.py +0 -33
- package/skills/pr-review-responder/update_skill.py +0 -297
|
@@ -493,18 +493,24 @@ def check_magic_values(content: str, file_path: str) -> list[str]:
|
|
|
493
493
|
|
|
494
494
|
def _extract_fstring_literal_parts(
|
|
495
495
|
joined_string_node: ast.JoinedStr,
|
|
496
|
+
interpolation_placeholder: str = "INTERP",
|
|
496
497
|
) -> tuple[str, str]:
|
|
497
498
|
"""Return (display_body, shape_body) for an f-string node.
|
|
498
499
|
|
|
499
500
|
``display_body`` concatenates only the literal segments for use in the
|
|
500
501
|
human-readable flag message. ``shape_body`` substitutes each interpolation
|
|
501
|
-
slot with
|
|
502
|
-
shape
|
|
502
|
+
slot with ``interpolation_placeholder`` so callers can choose a token that
|
|
503
|
+
both preserves structural shape and does not collide with literal text in
|
|
504
|
+
the source. The default ``"INTERP"`` keeps regex patterns for path shape
|
|
505
|
+
(``\\w+/\\w+/\\w+``) matching across interpolation boundaries
|
|
503
506
|
(e.g. ``/api/v1/{id}/home`` keeps its three path segments instead of
|
|
504
|
-
collapsing to ``/api/v1//home``).
|
|
505
|
-
|
|
507
|
+
collapsing to ``/api/v1//home``). Callers that will compare shape bodies
|
|
508
|
+
verbatim — such as the skeleton builder — should pass their final token
|
|
509
|
+
here directly rather than post-processing with ``.replace``, since that
|
|
510
|
+
would corrupt literal text containing the default placeholder. Escaped
|
|
511
|
+
braces (``{{`` / ``}}``) are already decoded by :mod:`ast` into their
|
|
512
|
+
literal forms.
|
|
506
513
|
"""
|
|
507
|
-
interpolation_placeholder = "INTERP"
|
|
508
514
|
display_segments: list[str] = []
|
|
509
515
|
shape_segments: list[str] = []
|
|
510
516
|
for each_part in joined_string_node.values:
|
|
@@ -967,9 +973,9 @@ def _assign_target_names_for_bool(node: ast.Assign) -> list[str]:
|
|
|
967
973
|
def _annassign_target_name_for_bool(node: ast.AnnAssign) -> list[str]:
|
|
968
974
|
if not isinstance(node.target, ast.Name):
|
|
969
975
|
return []
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
if
|
|
976
|
+
is_annotation_bool_type = isinstance(node.annotation, ast.Name) and node.annotation.id == "bool"
|
|
977
|
+
is_value_bool_constant = node.value is not None and _is_bool_constant(node.value)
|
|
978
|
+
if is_annotation_bool_type and is_value_bool_constant:
|
|
973
979
|
return [node.target.id]
|
|
974
980
|
return []
|
|
975
981
|
|
|
@@ -1059,6 +1065,218 @@ def check_boolean_naming(content: str, file_path: str) -> list[str]:
|
|
|
1059
1065
|
|
|
1060
1066
|
|
|
1061
1067
|
|
|
1068
|
+
def _decorator_name_contains_skip(decorator_node: ast.expr) -> bool:
|
|
1069
|
+
"""Return True when a decorator AST node references an identifier containing 'skip'."""
|
|
1070
|
+
if isinstance(decorator_node, ast.Name):
|
|
1071
|
+
return "skip" in decorator_node.id.lower()
|
|
1072
|
+
if isinstance(decorator_node, ast.Attribute):
|
|
1073
|
+
return "skip" in decorator_node.attr.lower()
|
|
1074
|
+
if isinstance(decorator_node, ast.Call):
|
|
1075
|
+
return _decorator_name_contains_skip(decorator_node.func)
|
|
1076
|
+
return False
|
|
1077
|
+
|
|
1078
|
+
|
|
1079
|
+
def check_skip_decorators_in_tests(content: str, file_path: str) -> list[str]:
|
|
1080
|
+
"""Flag @skip decorators on test functions in test files.
|
|
1081
|
+
|
|
1082
|
+
Tests must fail on missing dependencies rather than skip silently.
|
|
1083
|
+
Only applies to test files; production files are exempt.
|
|
1084
|
+
Only flags decorators applied to functions whose names start with 'test'.
|
|
1085
|
+
"""
|
|
1086
|
+
if not is_test_file(file_path):
|
|
1087
|
+
return []
|
|
1088
|
+
|
|
1089
|
+
try:
|
|
1090
|
+
syntax_tree = ast.parse(content)
|
|
1091
|
+
except SyntaxError:
|
|
1092
|
+
return []
|
|
1093
|
+
|
|
1094
|
+
issues: list[str] = []
|
|
1095
|
+
for each_node in ast.walk(syntax_tree):
|
|
1096
|
+
if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
1097
|
+
continue
|
|
1098
|
+
if not each_node.name.startswith("test"):
|
|
1099
|
+
continue
|
|
1100
|
+
for each_decorator in each_node.decorator_list:
|
|
1101
|
+
if _decorator_name_contains_skip(each_decorator):
|
|
1102
|
+
issues.append(
|
|
1103
|
+
f"Line {each_decorator.lineno}: @skip decorator on test"
|
|
1104
|
+
f" — tests must fail on missing deps"
|
|
1105
|
+
)
|
|
1106
|
+
if len(issues) >= MAX_ISSUES_PER_CHECK:
|
|
1107
|
+
return issues
|
|
1108
|
+
|
|
1109
|
+
return issues
|
|
1110
|
+
|
|
1111
|
+
|
|
1112
|
+
def _collect_assert_nodes_bounded(node: ast.AST) -> list[ast.Assert]:
|
|
1113
|
+
"""Collect Assert nodes under node without crossing scope boundaries.
|
|
1114
|
+
|
|
1115
|
+
Terminates descent at FunctionDef, AsyncFunctionDef, ClassDef, and Lambda
|
|
1116
|
+
nodes so that assertions belonging to nested scopes are not attributed to
|
|
1117
|
+
the enclosing function body.
|
|
1118
|
+
"""
|
|
1119
|
+
scope_boundary_types = (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda)
|
|
1120
|
+
assertions: list[ast.Assert] = []
|
|
1121
|
+
nodes_to_visit: list[ast.AST] = list(ast.iter_child_nodes(node))
|
|
1122
|
+
while nodes_to_visit:
|
|
1123
|
+
current = nodes_to_visit.pop()
|
|
1124
|
+
if isinstance(current, ast.Assert):
|
|
1125
|
+
assertions.append(current)
|
|
1126
|
+
if isinstance(current, scope_boundary_types):
|
|
1127
|
+
continue
|
|
1128
|
+
nodes_to_visit.extend(ast.iter_child_nodes(current))
|
|
1129
|
+
return assertions
|
|
1130
|
+
|
|
1131
|
+
|
|
1132
|
+
def _collect_body_assertions(statement_nodes: list[ast.stmt]) -> list[ast.Assert]:
|
|
1133
|
+
"""Collect Assert nodes from a function body without descending into nested scopes."""
|
|
1134
|
+
assertions: list[ast.Assert] = []
|
|
1135
|
+
for each_stmt in statement_nodes:
|
|
1136
|
+
if isinstance(each_stmt, ast.Assert):
|
|
1137
|
+
assertions.append(each_stmt)
|
|
1138
|
+
continue
|
|
1139
|
+
if isinstance(each_stmt, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
1140
|
+
continue
|
|
1141
|
+
assertions.extend(_collect_assert_nodes_bounded(each_stmt))
|
|
1142
|
+
return assertions
|
|
1143
|
+
|
|
1144
|
+
|
|
1145
|
+
def _is_existence_only_assertion(call_node: ast.Call) -> bool:
|
|
1146
|
+
"""Return True when a Call node is callable() or hasattr()."""
|
|
1147
|
+
function_reference = call_node.func
|
|
1148
|
+
if isinstance(function_reference, ast.Name):
|
|
1149
|
+
return function_reference.id in ("callable", "hasattr")
|
|
1150
|
+
if isinstance(function_reference, ast.Attribute):
|
|
1151
|
+
return function_reference.attr in ("callable", "hasattr")
|
|
1152
|
+
return False
|
|
1153
|
+
|
|
1154
|
+
|
|
1155
|
+
def _test_body_has_only_existence_assertions(
|
|
1156
|
+
function_node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
1157
|
+
) -> bool:
|
|
1158
|
+
"""Return True when a test function body contains only existence-check assertions."""
|
|
1159
|
+
assertion_nodes = _collect_body_assertions(function_node.body)
|
|
1160
|
+
if not assertion_nodes:
|
|
1161
|
+
return False
|
|
1162
|
+
|
|
1163
|
+
non_existence_assertions = 0
|
|
1164
|
+
for each_assert in assertion_nodes:
|
|
1165
|
+
test_expr = each_assert.test
|
|
1166
|
+
if isinstance(test_expr, ast.Call) and _is_existence_only_assertion(test_expr):
|
|
1167
|
+
continue
|
|
1168
|
+
if isinstance(test_expr, ast.Compare):
|
|
1169
|
+
comparators = test_expr.comparators
|
|
1170
|
+
ops = test_expr.ops
|
|
1171
|
+
if (
|
|
1172
|
+
len(ops) == 1
|
|
1173
|
+
and isinstance(ops[0], ast.IsNot)
|
|
1174
|
+
and len(comparators) == 1
|
|
1175
|
+
and isinstance(comparators[0], ast.Constant)
|
|
1176
|
+
and comparators[0].value is None
|
|
1177
|
+
):
|
|
1178
|
+
continue
|
|
1179
|
+
non_existence_assertions += 1
|
|
1180
|
+
|
|
1181
|
+
return non_existence_assertions == 0
|
|
1182
|
+
|
|
1183
|
+
|
|
1184
|
+
def check_existence_check_tests(content: str, file_path: str) -> list[str]:
|
|
1185
|
+
"""Flag test functions containing only existence-check assertions.
|
|
1186
|
+
|
|
1187
|
+
Tests asserting only callable(x), hasattr(m, 'name'), or x is not None
|
|
1188
|
+
verify nothing about behavior. They should be deleted or replaced with
|
|
1189
|
+
assertions that exercise actual functionality.
|
|
1190
|
+
Only applies to test files.
|
|
1191
|
+
"""
|
|
1192
|
+
if not is_test_file(file_path):
|
|
1193
|
+
return []
|
|
1194
|
+
|
|
1195
|
+
try:
|
|
1196
|
+
syntax_tree = ast.parse(content)
|
|
1197
|
+
except SyntaxError:
|
|
1198
|
+
return []
|
|
1199
|
+
|
|
1200
|
+
issues: list[str] = []
|
|
1201
|
+
for each_node in ast.walk(syntax_tree):
|
|
1202
|
+
if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
1203
|
+
continue
|
|
1204
|
+
if not each_node.name.startswith("test"):
|
|
1205
|
+
continue
|
|
1206
|
+
if _test_body_has_only_existence_assertions(each_node):
|
|
1207
|
+
issues.append(
|
|
1208
|
+
f"Line {each_node.lineno}: existence-check test"
|
|
1209
|
+
f" — delete or replace with a behavior test"
|
|
1210
|
+
)
|
|
1211
|
+
if len(issues) >= MAX_ISSUES_PER_CHECK:
|
|
1212
|
+
return issues
|
|
1213
|
+
|
|
1214
|
+
return issues
|
|
1215
|
+
|
|
1216
|
+
|
|
1217
|
+
def _is_upper_snake_name(name: str) -> bool:
|
|
1218
|
+
"""Return True when an identifier is written in UPPER_SNAKE_CASE."""
|
|
1219
|
+
return bool(UPPER_SNAKE_CONSTANT_PATTERN.match(name))
|
|
1220
|
+
|
|
1221
|
+
|
|
1222
|
+
def _assert_is_constant_equality_only(assert_node: ast.Assert) -> bool:
|
|
1223
|
+
"""Return True when the assertion compares an UPPER_SNAKE name to a literal."""
|
|
1224
|
+
test_expr = assert_node.test
|
|
1225
|
+
if not isinstance(test_expr, ast.Compare):
|
|
1226
|
+
return False
|
|
1227
|
+
if len(test_expr.ops) != 1 or not isinstance(test_expr.ops[0], ast.Eq):
|
|
1228
|
+
return False
|
|
1229
|
+
left = test_expr.left
|
|
1230
|
+
right = test_expr.comparators[0]
|
|
1231
|
+
is_left_upper_snake = isinstance(left, ast.Name) and _is_upper_snake_name(left.id)
|
|
1232
|
+
is_right_upper_snake = isinstance(right, ast.Name) and _is_upper_snake_name(right.id)
|
|
1233
|
+
if is_left_upper_snake and is_right_upper_snake:
|
|
1234
|
+
return False
|
|
1235
|
+
is_left_a_literal = isinstance(left, ast.Constant)
|
|
1236
|
+
is_right_a_literal = isinstance(right, ast.Constant)
|
|
1237
|
+
return (
|
|
1238
|
+
(is_left_upper_snake and is_right_a_literal)
|
|
1239
|
+
or (is_right_upper_snake and is_left_a_literal)
|
|
1240
|
+
)
|
|
1241
|
+
|
|
1242
|
+
|
|
1243
|
+
def check_constant_equality_tests(content: str, file_path: str) -> list[str]:
|
|
1244
|
+
"""Flag test functions whose sole assertion compares a constant to a literal.
|
|
1245
|
+
|
|
1246
|
+
Tests like 'assert CACHE_DIR == "cache"' cover no behavior — they just
|
|
1247
|
+
verify the constant has not changed. Such tests should be deleted.
|
|
1248
|
+
Only applies to test files; production files are exempt.
|
|
1249
|
+
"""
|
|
1250
|
+
if not is_test_file(file_path):
|
|
1251
|
+
return []
|
|
1252
|
+
|
|
1253
|
+
try:
|
|
1254
|
+
syntax_tree = ast.parse(content)
|
|
1255
|
+
except SyntaxError:
|
|
1256
|
+
return []
|
|
1257
|
+
|
|
1258
|
+
issues: list[str] = []
|
|
1259
|
+
for each_node in ast.walk(syntax_tree):
|
|
1260
|
+
if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
1261
|
+
continue
|
|
1262
|
+
if not each_node.name.startswith("test"):
|
|
1263
|
+
continue
|
|
1264
|
+
all_assertions = _collect_body_assertions(each_node.body)
|
|
1265
|
+
if not all_assertions:
|
|
1266
|
+
continue
|
|
1267
|
+
if len(all_assertions) > 1:
|
|
1268
|
+
continue
|
|
1269
|
+
if _assert_is_constant_equality_only(all_assertions[0]):
|
|
1270
|
+
issues.append(
|
|
1271
|
+
f"Line {each_node.lineno}: constant-value test"
|
|
1272
|
+
f" — delete; tests must cover behavior"
|
|
1273
|
+
)
|
|
1274
|
+
if len(issues) >= MAX_ISSUES_PER_CHECK:
|
|
1275
|
+
return issues
|
|
1276
|
+
|
|
1277
|
+
return issues
|
|
1278
|
+
|
|
1279
|
+
|
|
1062
1280
|
def _is_upper_snake_constant_name(name: str) -> bool:
|
|
1063
1281
|
"""Return True for UPPER_SNAKE identifiers including those with a leading underscore."""
|
|
1064
1282
|
return bool(FILE_GLOBAL_UPPER_SNAKE_PATTERN.match(name))
|
|
@@ -1170,6 +1388,549 @@ def check_file_global_constants_use_count(content: str, file_path: str) -> list[
|
|
|
1170
1388
|
return issues
|
|
1171
1389
|
|
|
1172
1390
|
|
|
1391
|
+
def _collect_optional_param_defaults(
|
|
1392
|
+
function_node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
1393
|
+
) -> dict[str, ast.expr]:
|
|
1394
|
+
"""Return mapping of param name to its default AST node for params with defaults."""
|
|
1395
|
+
arguments = function_node.args
|
|
1396
|
+
all_args = arguments.posonlyargs + arguments.args
|
|
1397
|
+
defaults_aligned = [None] * (len(all_args) - len(arguments.defaults)) + list(arguments.defaults)
|
|
1398
|
+
param_defaults: dict[str, ast.expr] = {}
|
|
1399
|
+
for each_arg, each_default in zip(all_args, defaults_aligned):
|
|
1400
|
+
if each_default is not None:
|
|
1401
|
+
param_defaults[each_arg.arg] = each_default
|
|
1402
|
+
for each_kwarg, each_kwdefault in zip(arguments.kwonlyargs, arguments.kw_defaults):
|
|
1403
|
+
if each_kwdefault is not None:
|
|
1404
|
+
param_defaults[each_kwarg.arg] = each_kwdefault
|
|
1405
|
+
return param_defaults
|
|
1406
|
+
|
|
1407
|
+
|
|
1408
|
+
_NON_LITERAL_DEFAULT_SENTINEL = object()
|
|
1409
|
+
|
|
1410
|
+
|
|
1411
|
+
def _is_non_literal_default(value: object) -> bool:
|
|
1412
|
+
"""Return True when a value is the sentinel for a non-literal default."""
|
|
1413
|
+
return value is _NON_LITERAL_DEFAULT_SENTINEL
|
|
1414
|
+
|
|
1415
|
+
|
|
1416
|
+
def _ast_constant_value(node: ast.expr) -> object:
|
|
1417
|
+
"""Return the Python value of a Constant node, or a stable sentinel for non-constants.
|
|
1418
|
+
|
|
1419
|
+
Non-literal defaults (e.g. DEFAULT_TIMEOUT) return a single shared sentinel
|
|
1420
|
+
so that the unused-optional check can identify and skip them rather than
|
|
1421
|
+
treating every non-literal as automatically different.
|
|
1422
|
+
"""
|
|
1423
|
+
if isinstance(node, ast.Constant):
|
|
1424
|
+
return node.value
|
|
1425
|
+
return _NON_LITERAL_DEFAULT_SENTINEL
|
|
1426
|
+
|
|
1427
|
+
|
|
1428
|
+
def _call_passes_keyword_argument_differing_from_default(
|
|
1429
|
+
call_node: ast.Call,
|
|
1430
|
+
param_name: str,
|
|
1431
|
+
default_value: object,
|
|
1432
|
+
) -> bool:
|
|
1433
|
+
"""Return True when a Call passes param_name with a value different from default.
|
|
1434
|
+
|
|
1435
|
+
Returns True conservatively when **kwargs expansion is present, because the
|
|
1436
|
+
expansion may pass the parameter with an unknown value — treating it as
|
|
1437
|
+
indeterminate prevents false positives from the unused-optional check.
|
|
1438
|
+
"""
|
|
1439
|
+
for each_keyword in call_node.keywords:
|
|
1440
|
+
if each_keyword.arg is None:
|
|
1441
|
+
return True
|
|
1442
|
+
if each_keyword.arg == param_name:
|
|
1443
|
+
passed_value = _ast_constant_value(each_keyword.value)
|
|
1444
|
+
return passed_value != default_value
|
|
1445
|
+
return False
|
|
1446
|
+
|
|
1447
|
+
|
|
1448
|
+
def _call_has_kwargs_expansion(call_node: ast.Call) -> bool:
|
|
1449
|
+
"""Return True when a Call contains a **kwargs expansion (arg=None in AST keywords)."""
|
|
1450
|
+
return any(each_keyword.arg is None for each_keyword in call_node.keywords)
|
|
1451
|
+
|
|
1452
|
+
|
|
1453
|
+
def _call_has_starargs_expansion(call_node: ast.Call) -> bool:
|
|
1454
|
+
"""Return True when a Call contains a *args expansion (Starred node in positional args)."""
|
|
1455
|
+
return any(isinstance(each_arg, ast.Starred) for each_arg in call_node.args)
|
|
1456
|
+
|
|
1457
|
+
|
|
1458
|
+
def _call_passes_positional_argument_for_param(
|
|
1459
|
+
call_node: ast.Call,
|
|
1460
|
+
function_node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
1461
|
+
param_name: str,
|
|
1462
|
+
default_value: object,
|
|
1463
|
+
) -> bool:
|
|
1464
|
+
"""Return True when a Call passes param_name positionally with a varied value.
|
|
1465
|
+
|
|
1466
|
+
Returns False when **kwargs expansion is present (the keyword helper covers
|
|
1467
|
+
that case). Returns True conservatively when *args expansion is present,
|
|
1468
|
+
because the expanded iterable may provide the parameter at runtime.
|
|
1469
|
+
"""
|
|
1470
|
+
if _call_has_kwargs_expansion(call_node):
|
|
1471
|
+
return False
|
|
1472
|
+
if _call_has_starargs_expansion(call_node):
|
|
1473
|
+
return True
|
|
1474
|
+
all_args = function_node.args.posonlyargs + function_node.args.args
|
|
1475
|
+
try:
|
|
1476
|
+
param_index = next(
|
|
1477
|
+
each_index
|
|
1478
|
+
for each_index, each_arg in enumerate(all_args)
|
|
1479
|
+
if each_arg.arg == param_name
|
|
1480
|
+
)
|
|
1481
|
+
except StopIteration:
|
|
1482
|
+
return False
|
|
1483
|
+
if param_index >= len(call_node.args):
|
|
1484
|
+
return False
|
|
1485
|
+
passed_value = _ast_constant_value(call_node.args[param_index])
|
|
1486
|
+
return passed_value != default_value
|
|
1487
|
+
|
|
1488
|
+
|
|
1489
|
+
def _function_name_from_call(call_node: ast.Call) -> str | None:
|
|
1490
|
+
"""Return the function name for direct calls only, or None.
|
|
1491
|
+
|
|
1492
|
+
Only direct calls (ast.Name) are matched as same-file call sites.
|
|
1493
|
+
Attribute calls like obj.foo() are not counted because the receiver
|
|
1494
|
+
object may not be the same file's definition — returning the attr name
|
|
1495
|
+
would cause false positives against any local function sharing that name.
|
|
1496
|
+
"""
|
|
1497
|
+
if isinstance(call_node.func, ast.Name):
|
|
1498
|
+
return call_node.func.id
|
|
1499
|
+
return None
|
|
1500
|
+
|
|
1501
|
+
|
|
1502
|
+
BUILTIN_DICT_METHOD_NAMES: frozenset[str] = frozenset({
|
|
1503
|
+
"get", "items", "keys", "values", "update", "pop",
|
|
1504
|
+
"setdefault", "copy", "clear",
|
|
1505
|
+
})
|
|
1506
|
+
|
|
1507
|
+
|
|
1508
|
+
def _collect_mock_dict_keys(assign_value: ast.expr) -> set[str] | None:
|
|
1509
|
+
"""Return the string key set for a dict literal, or None if not a dict literal."""
|
|
1510
|
+
if not isinstance(assign_value, ast.Dict):
|
|
1511
|
+
return None
|
|
1512
|
+
key_names: set[str] = set()
|
|
1513
|
+
for each_key in assign_value.keys:
|
|
1514
|
+
if isinstance(each_key, ast.Constant) and isinstance(each_key.value, str):
|
|
1515
|
+
key_names.add(each_key.value)
|
|
1516
|
+
return key_names
|
|
1517
|
+
|
|
1518
|
+
|
|
1519
|
+
def _target_binds_name(target_node: ast.AST, variable_name: str) -> bool:
|
|
1520
|
+
"""Return True when an assignment target binds variable_name.
|
|
1521
|
+
|
|
1522
|
+
Handles the recursive assignment target shapes Python permits:
|
|
1523
|
+
a bare ``Name``, a ``Tuple`` or ``List`` of targets (including
|
|
1524
|
+
nested ones), and a ``Starred`` wrapper around any of the above.
|
|
1525
|
+
"""
|
|
1526
|
+
if isinstance(target_node, ast.Name):
|
|
1527
|
+
return target_node.id == variable_name
|
|
1528
|
+
if isinstance(target_node, (ast.Tuple, ast.List)):
|
|
1529
|
+
return any(_target_binds_name(each_element, variable_name) for each_element in target_node.elts)
|
|
1530
|
+
if isinstance(target_node, ast.Starred):
|
|
1531
|
+
return _target_binds_name(target_node.value, variable_name)
|
|
1532
|
+
return False
|
|
1533
|
+
|
|
1534
|
+
|
|
1535
|
+
def _function_arguments_bind_name(
|
|
1536
|
+
arguments_node: ast.arguments,
|
|
1537
|
+
variable_name: str,
|
|
1538
|
+
) -> bool:
|
|
1539
|
+
"""Return True when any parameter slot declares variable_name."""
|
|
1540
|
+
all_positional_arguments = list(arguments_node.posonlyargs) + list(arguments_node.args)
|
|
1541
|
+
for each_argument in all_positional_arguments + list(arguments_node.kwonlyargs):
|
|
1542
|
+
if each_argument.arg == variable_name:
|
|
1543
|
+
return True
|
|
1544
|
+
if arguments_node.vararg is not None and arguments_node.vararg.arg == variable_name:
|
|
1545
|
+
return True
|
|
1546
|
+
if arguments_node.kwarg is not None and arguments_node.kwarg.arg == variable_name:
|
|
1547
|
+
return True
|
|
1548
|
+
return False
|
|
1549
|
+
|
|
1550
|
+
|
|
1551
|
+
def _node_binds_name(node: ast.AST, variable_name: str) -> bool:
|
|
1552
|
+
"""Return True when a single AST node binds variable_name in its enclosing scope."""
|
|
1553
|
+
if isinstance(node, ast.Assign):
|
|
1554
|
+
return any(_target_binds_name(each_target, variable_name) for each_target in node.targets)
|
|
1555
|
+
if isinstance(node, ast.AnnAssign):
|
|
1556
|
+
return _target_binds_name(node.target, variable_name)
|
|
1557
|
+
if isinstance(node, ast.AugAssign):
|
|
1558
|
+
return _target_binds_name(node.target, variable_name)
|
|
1559
|
+
if isinstance(node, (ast.For, ast.AsyncFor)):
|
|
1560
|
+
return _target_binds_name(node.target, variable_name)
|
|
1561
|
+
if isinstance(node, (ast.With, ast.AsyncWith)):
|
|
1562
|
+
for each_item in node.items:
|
|
1563
|
+
optional_target = each_item.optional_vars
|
|
1564
|
+
if optional_target is not None and _target_binds_name(optional_target, variable_name):
|
|
1565
|
+
return True
|
|
1566
|
+
return False
|
|
1567
|
+
if isinstance(node, ast.ExceptHandler):
|
|
1568
|
+
return node.name == variable_name
|
|
1569
|
+
if isinstance(node, ast.NamedExpr):
|
|
1570
|
+
return _target_binds_name(node.target, variable_name)
|
|
1571
|
+
if isinstance(node, (ast.Import, ast.ImportFrom)):
|
|
1572
|
+
for each_alias in node.names:
|
|
1573
|
+
bound_name = each_alias.asname if each_alias.asname is not None else each_alias.name.split(".")[0]
|
|
1574
|
+
if bound_name == variable_name:
|
|
1575
|
+
return True
|
|
1576
|
+
return False
|
|
1577
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
1578
|
+
return node.name == variable_name
|
|
1579
|
+
return False
|
|
1580
|
+
|
|
1581
|
+
|
|
1582
|
+
def _body_binds_name_recursively(body_statements: list[ast.stmt], variable_name: str) -> bool:
|
|
1583
|
+
"""Return True when any node reachable within body_statements binds variable_name.
|
|
1584
|
+
|
|
1585
|
+
Walks the body using a stack, descending into control-flow constructs
|
|
1586
|
+
(if/for/while/try/with) but treating nested function, async-function,
|
|
1587
|
+
class, and lambda definitions as opaque: their bodies belong to a
|
|
1588
|
+
different scope and do not affect bindings in the enclosing one.
|
|
1589
|
+
Function/class definitions themselves still bind their own name in
|
|
1590
|
+
the enclosing scope, which is handled by _node_binds_name.
|
|
1591
|
+
"""
|
|
1592
|
+
nodes_to_visit: list[ast.AST] = list(body_statements)
|
|
1593
|
+
while nodes_to_visit:
|
|
1594
|
+
current_node = nodes_to_visit.pop()
|
|
1595
|
+
if _node_binds_name(current_node, variable_name):
|
|
1596
|
+
return True
|
|
1597
|
+
if isinstance(current_node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda)):
|
|
1598
|
+
continue
|
|
1599
|
+
nodes_to_visit.extend(ast.iter_child_nodes(current_node))
|
|
1600
|
+
return False
|
|
1601
|
+
|
|
1602
|
+
|
|
1603
|
+
def _scope_shadows_name(
|
|
1604
|
+
scope_node: ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef,
|
|
1605
|
+
variable_name: str,
|
|
1606
|
+
) -> bool:
|
|
1607
|
+
"""Return True when scope_node locally binds variable_name.
|
|
1608
|
+
|
|
1609
|
+
Detects every binding form Python treats as a local assignment:
|
|
1610
|
+
plain ``Assign``, annotated ``AnnAssign``, augmented ``AugAssign``,
|
|
1611
|
+
``for`` targets, ``with`` as-targets, ``except`` handler names,
|
|
1612
|
+
walrus ``NamedExpr`` targets, ``import`` and ``from`` bindings
|
|
1613
|
+
(base name or ``as`` alias), nested function/class definitions
|
|
1614
|
+
(whose own name binds locally), and function parameters for
|
|
1615
|
+
``FunctionDef`` / ``AsyncFunctionDef`` scopes. Bindings are
|
|
1616
|
+
detected at any nesting depth inside control-flow constructs;
|
|
1617
|
+
nested function, async-function, class, and lambda bodies are
|
|
1618
|
+
treated as opaque because their contents live in a different scope.
|
|
1619
|
+
"""
|
|
1620
|
+
if isinstance(scope_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
1621
|
+
if _function_arguments_bind_name(scope_node.args, variable_name):
|
|
1622
|
+
return True
|
|
1623
|
+
return _body_binds_name_recursively(list(scope_node.body), variable_name)
|
|
1624
|
+
|
|
1625
|
+
|
|
1626
|
+
def _walk_scope_skipping_shadowed(
|
|
1627
|
+
scope_node: ast.AST,
|
|
1628
|
+
variable_name: str,
|
|
1629
|
+
) -> list[ast.AST]:
|
|
1630
|
+
"""Walk all nodes in a scope, skipping nested function/class bodies that shadow variable_name."""
|
|
1631
|
+
collected: list[ast.AST] = []
|
|
1632
|
+
nodes_to_visit: list[ast.AST] = [scope_node]
|
|
1633
|
+
while nodes_to_visit:
|
|
1634
|
+
current = nodes_to_visit.pop()
|
|
1635
|
+
collected.append(current)
|
|
1636
|
+
for each_child in ast.iter_child_nodes(current):
|
|
1637
|
+
if (
|
|
1638
|
+
isinstance(each_child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef))
|
|
1639
|
+
and each_child is not scope_node
|
|
1640
|
+
and _scope_shadows_name(each_child, variable_name)
|
|
1641
|
+
):
|
|
1642
|
+
continue
|
|
1643
|
+
nodes_to_visit.append(each_child)
|
|
1644
|
+
return collected
|
|
1645
|
+
|
|
1646
|
+
|
|
1647
|
+
def _collect_mock_field_accesses_in_scope(
|
|
1648
|
+
scope_node: ast.AST,
|
|
1649
|
+
mock_name: str,
|
|
1650
|
+
) -> list[tuple[str, int]]:
|
|
1651
|
+
"""Return (field_name, line_number) for attribute or subscript accesses on mock_name within a scope.
|
|
1652
|
+
|
|
1653
|
+
Skips nested function/class bodies that locally redefine the same mock
|
|
1654
|
+
variable to avoid false positives from name shadowing.
|
|
1655
|
+
"""
|
|
1656
|
+
accesses: list[tuple[str, int]] = []
|
|
1657
|
+
for each_node in _walk_scope_skipping_shadowed(scope_node, mock_name):
|
|
1658
|
+
if isinstance(each_node, ast.Attribute):
|
|
1659
|
+
if isinstance(each_node.value, ast.Name) and each_node.value.id == mock_name:
|
|
1660
|
+
if isinstance(each_node.ctx, ast.Load):
|
|
1661
|
+
if each_node.attr in BUILTIN_DICT_METHOD_NAMES:
|
|
1662
|
+
continue
|
|
1663
|
+
accesses.append((each_node.attr, each_node.lineno))
|
|
1664
|
+
elif isinstance(each_node, ast.Subscript):
|
|
1665
|
+
if isinstance(each_node.value, ast.Name) and each_node.value.id == mock_name:
|
|
1666
|
+
if isinstance(each_node.ctx, ast.Load):
|
|
1667
|
+
slice_node = each_node.slice
|
|
1668
|
+
if isinstance(slice_node, ast.Constant) and isinstance(slice_node.value, str):
|
|
1669
|
+
accesses.append((slice_node.value, each_node.lineno))
|
|
1670
|
+
return accesses
|
|
1671
|
+
|
|
1672
|
+
|
|
1673
|
+
def _collect_mock_attribute_assignments_in_scope(
|
|
1674
|
+
scope_node: ast.AST,
|
|
1675
|
+
mock_name: str,
|
|
1676
|
+
) -> set[str]:
|
|
1677
|
+
"""Return field names assigned on a mock variable within a scope.
|
|
1678
|
+
|
|
1679
|
+
Collects both attribute assignments (mock_x.field = ...) and subscript
|
|
1680
|
+
assignments with constant string keys (mock_x['field'] = ...).
|
|
1681
|
+
|
|
1682
|
+
Skips nested function/class bodies that locally redefine the same mock
|
|
1683
|
+
variable, mirroring _collect_mock_field_accesses_in_scope so an outer
|
|
1684
|
+
mock's known-fields set cannot absorb assignments made on a shadowed
|
|
1685
|
+
inner mock of the same name.
|
|
1686
|
+
"""
|
|
1687
|
+
assigned_fields: set[str] = set()
|
|
1688
|
+
for each_node in _walk_scope_skipping_shadowed(scope_node, mock_name):
|
|
1689
|
+
if not isinstance(each_node, ast.Assign):
|
|
1690
|
+
continue
|
|
1691
|
+
for each_target in each_node.targets:
|
|
1692
|
+
if (
|
|
1693
|
+
isinstance(each_target, ast.Attribute)
|
|
1694
|
+
and isinstance(each_target.value, ast.Name)
|
|
1695
|
+
and each_target.value.id == mock_name
|
|
1696
|
+
):
|
|
1697
|
+
assigned_fields.add(each_target.attr)
|
|
1698
|
+
elif (
|
|
1699
|
+
isinstance(each_target, ast.Subscript)
|
|
1700
|
+
and isinstance(each_target.value, ast.Name)
|
|
1701
|
+
and each_target.value.id == mock_name
|
|
1702
|
+
and isinstance(each_target.slice, ast.Constant)
|
|
1703
|
+
and isinstance(each_target.slice.value, str)
|
|
1704
|
+
):
|
|
1705
|
+
assigned_fields.add(each_target.slice.value)
|
|
1706
|
+
return assigned_fields
|
|
1707
|
+
|
|
1708
|
+
|
|
1709
|
+
def _collect_scoped_mock_definitions(
|
|
1710
|
+
module_tree: ast.Module,
|
|
1711
|
+
) -> list[tuple[int, str, set[str], int, ast.AST]]:
|
|
1712
|
+
"""Return (scope_id, mock_name, declared_keys, definition_line, scope_node) for each mock.
|
|
1713
|
+
|
|
1714
|
+
Keyed by (scope_node id, variable_name) so the same mock name in two different
|
|
1715
|
+
test functions is tracked independently. Scope is the enclosing function node,
|
|
1716
|
+
or the module node for module-level assignments.
|
|
1717
|
+
"""
|
|
1718
|
+
scope_definitions: list[tuple[int, str, set[str], int, ast.AST]] = []
|
|
1719
|
+
for each_scope in ast.walk(module_tree):
|
|
1720
|
+
if not isinstance(each_scope, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Module)):
|
|
1721
|
+
continue
|
|
1722
|
+
scope_body = each_scope.body
|
|
1723
|
+
for each_stmt in scope_body:
|
|
1724
|
+
if not isinstance(each_stmt, ast.Assign):
|
|
1725
|
+
continue
|
|
1726
|
+
for each_target in each_stmt.targets:
|
|
1727
|
+
if not isinstance(each_target, ast.Name):
|
|
1728
|
+
continue
|
|
1729
|
+
target_name = each_target.id
|
|
1730
|
+
if not (target_name.startswith("mock_") or target_name.startswith("MOCK_")):
|
|
1731
|
+
continue
|
|
1732
|
+
mock_keys = _collect_mock_dict_keys(each_stmt.value)
|
|
1733
|
+
if mock_keys is not None:
|
|
1734
|
+
scope_definitions.append(
|
|
1735
|
+
(id(each_scope), target_name, mock_keys, each_stmt.lineno, each_scope)
|
|
1736
|
+
)
|
|
1737
|
+
elif isinstance(each_stmt.value, ast.Call):
|
|
1738
|
+
scope_definitions.append(
|
|
1739
|
+
(id(each_scope), target_name, set(), each_stmt.lineno, each_scope)
|
|
1740
|
+
)
|
|
1741
|
+
return scope_definitions
|
|
1742
|
+
|
|
1743
|
+
|
|
1744
|
+
def check_incomplete_mocks(content: str, file_path: str) -> None:
|
|
1745
|
+
"""Emit stderr advisories when a mock dict/object is missing fields that are accessed.
|
|
1746
|
+
|
|
1747
|
+
Scans test files for variables named mock_* or MOCK_* whose value is a dict
|
|
1748
|
+
literal. Each mock definition is keyed by (scope_node_id, variable_name) so
|
|
1749
|
+
the same name in different test functions is checked independently. Advisories
|
|
1750
|
+
are deduplicated per (mock_name, field_name) pair within each scope.
|
|
1751
|
+
|
|
1752
|
+
This is advisory-only (no return value, no blocking).
|
|
1753
|
+
"""
|
|
1754
|
+
if not is_test_file(file_path):
|
|
1755
|
+
return
|
|
1756
|
+
|
|
1757
|
+
try:
|
|
1758
|
+
module_tree = ast.parse(content)
|
|
1759
|
+
except SyntaxError:
|
|
1760
|
+
return
|
|
1761
|
+
|
|
1762
|
+
all_scoped_definitions = _collect_scoped_mock_definitions(module_tree)
|
|
1763
|
+
|
|
1764
|
+
for _scope_id, mock_name, declared_keys, definition_line, scope_node in all_scoped_definitions:
|
|
1765
|
+
assigned_attributes = _collect_mock_attribute_assignments_in_scope(scope_node, mock_name)
|
|
1766
|
+
all_known_fields = declared_keys | assigned_attributes
|
|
1767
|
+
field_accesses = _collect_mock_field_accesses_in_scope(scope_node, mock_name)
|
|
1768
|
+
already_advised: set[tuple[str, str]] = set()
|
|
1769
|
+
for accessed_field, access_line in field_accesses:
|
|
1770
|
+
if accessed_field in all_known_fields:
|
|
1771
|
+
continue
|
|
1772
|
+
advisory_key = (mock_name, accessed_field)
|
|
1773
|
+
if advisory_key in already_advised:
|
|
1774
|
+
continue
|
|
1775
|
+
already_advised.add(advisory_key)
|
|
1776
|
+
print(
|
|
1777
|
+
f"[CODE_RULES advisory] Line {definition_line}: mock {mock_name}"
|
|
1778
|
+
f" missing field {accessed_field} accessed at line {access_line}",
|
|
1779
|
+
file=sys.stderr,
|
|
1780
|
+
)
|
|
1781
|
+
|
|
1782
|
+
|
|
1783
|
+
def _build_fstring_skeleton(joined_str_node: ast.JoinedStr) -> str:
|
|
1784
|
+
"""Collapse interpolations in an f-string to a placeholder to form a pattern skeleton.
|
|
1785
|
+
|
|
1786
|
+
Injects the skeleton placeholder directly via _extract_fstring_literal_parts
|
|
1787
|
+
instead of post-processing, so literal text in the source that happens to
|
|
1788
|
+
contain the default placeholder (or any other substring) is preserved
|
|
1789
|
+
verbatim and cannot collide with interpolation slots.
|
|
1790
|
+
"""
|
|
1791
|
+
skeleton_interpolation_placeholder = "<x>"
|
|
1792
|
+
_display_body, shape_body = _extract_fstring_literal_parts(
|
|
1793
|
+
joined_str_node,
|
|
1794
|
+
interpolation_placeholder=skeleton_interpolation_placeholder,
|
|
1795
|
+
)
|
|
1796
|
+
return shape_body
|
|
1797
|
+
|
|
1798
|
+
|
|
1799
|
+
def check_duplicated_format_patterns(content: str, file_path: str) -> None:
|
|
1800
|
+
"""Emit stderr advisories when an f-string skeleton appears 3+ times in a production file.
|
|
1801
|
+
|
|
1802
|
+
Collapses each f-string's interpolations to '<x>' placeholders, then counts
|
|
1803
|
+
skeleton occurrences per file. When any skeleton appears three or more times,
|
|
1804
|
+
it suggests the pattern belongs in a helper or model method.
|
|
1805
|
+
|
|
1806
|
+
This is advisory-only (no return value, no blocking). Skips test files,
|
|
1807
|
+
config files, workflow registry files, migration files, and hook infrastructure.
|
|
1808
|
+
"""
|
|
1809
|
+
if is_test_file(file_path):
|
|
1810
|
+
return
|
|
1811
|
+
if is_config_file(file_path):
|
|
1812
|
+
return
|
|
1813
|
+
if is_workflow_registry_file(file_path):
|
|
1814
|
+
return
|
|
1815
|
+
if is_migration_file(file_path):
|
|
1816
|
+
return
|
|
1817
|
+
if is_hook_infrastructure(file_path):
|
|
1818
|
+
return
|
|
1819
|
+
|
|
1820
|
+
try:
|
|
1821
|
+
module_tree = ast.parse(content)
|
|
1822
|
+
except SyntaxError:
|
|
1823
|
+
return
|
|
1824
|
+
|
|
1825
|
+
minimum_repetition_count = 3
|
|
1826
|
+
minimum_literal_character_count = 5
|
|
1827
|
+
|
|
1828
|
+
skeleton_occurrences: dict[str, list[int]] = {}
|
|
1829
|
+
literal_length_by_skeleton: dict[str, int] = {}
|
|
1830
|
+
for each_node in ast.walk(module_tree):
|
|
1831
|
+
if not isinstance(each_node, ast.JoinedStr):
|
|
1832
|
+
continue
|
|
1833
|
+
skeleton = _build_fstring_skeleton(each_node)
|
|
1834
|
+
literal_body, _shape_body = _extract_fstring_literal_parts(each_node)
|
|
1835
|
+
if skeleton not in skeleton_occurrences:
|
|
1836
|
+
skeleton_occurrences[skeleton] = []
|
|
1837
|
+
literal_length_by_skeleton[skeleton] = len(literal_body)
|
|
1838
|
+
skeleton_occurrences[skeleton].append(each_node.lineno)
|
|
1839
|
+
|
|
1840
|
+
for skeleton, line_numbers in skeleton_occurrences.items():
|
|
1841
|
+
if len(line_numbers) < minimum_repetition_count:
|
|
1842
|
+
continue
|
|
1843
|
+
if literal_length_by_skeleton[skeleton] < minimum_literal_character_count:
|
|
1844
|
+
continue
|
|
1845
|
+
print(
|
|
1846
|
+
f"[CODE_RULES advisory] f-string pattern {skeleton!r} appears"
|
|
1847
|
+
f" {len(line_numbers)} times — consider encapsulating in a helper or model.",
|
|
1848
|
+
file=sys.stderr,
|
|
1849
|
+
)
|
|
1850
|
+
|
|
1851
|
+
|
|
1852
|
+
def check_unused_optional_parameters(content: str, file_path: str) -> list[str]:
|
|
1853
|
+
"""Flag optional parameters never varied at same-file call sites.
|
|
1854
|
+
|
|
1855
|
+
A parameter with a default value that every same-file caller either omits
|
|
1856
|
+
or always passes with the identical default literal is never varied and
|
|
1857
|
+
should be inlined or dropped per the YAGNI API surface rule.
|
|
1858
|
+
|
|
1859
|
+
Skips test files, config files, workflow registry files, migration files,
|
|
1860
|
+
and hook infrastructure files. Only checks functions that have at least
|
|
1861
|
+
one same-file call site.
|
|
1862
|
+
|
|
1863
|
+
Scope limit (v1): only module-level functions are analyzed. Methods defined
|
|
1864
|
+
inside a ClassDef are skipped because the positional-index calculation would
|
|
1865
|
+
need to account for the implicit self/cls parameter, which is absent at
|
|
1866
|
+
call sites using attribute access (obj.method(...)). Method analysis is out
|
|
1867
|
+
of scope for this version.
|
|
1868
|
+
"""
|
|
1869
|
+
if is_test_file(file_path):
|
|
1870
|
+
return []
|
|
1871
|
+
if is_config_file(file_path):
|
|
1872
|
+
return []
|
|
1873
|
+
if is_workflow_registry_file(file_path):
|
|
1874
|
+
return []
|
|
1875
|
+
if is_migration_file(file_path):
|
|
1876
|
+
return []
|
|
1877
|
+
if is_hook_infrastructure(file_path):
|
|
1878
|
+
return []
|
|
1879
|
+
|
|
1880
|
+
try:
|
|
1881
|
+
module_tree = ast.parse(content)
|
|
1882
|
+
except SyntaxError:
|
|
1883
|
+
return []
|
|
1884
|
+
|
|
1885
|
+
all_function_nodes: dict[str, ast.FunctionDef | ast.AsyncFunctionDef] = {}
|
|
1886
|
+
for each_node in module_tree.body:
|
|
1887
|
+
if isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
1888
|
+
all_function_nodes[each_node.name] = each_node
|
|
1889
|
+
|
|
1890
|
+
all_call_nodes: list[ast.Call] = [
|
|
1891
|
+
each_node
|
|
1892
|
+
for each_node in ast.walk(module_tree)
|
|
1893
|
+
if isinstance(each_node, ast.Call)
|
|
1894
|
+
]
|
|
1895
|
+
|
|
1896
|
+
issues: list[str] = []
|
|
1897
|
+
for function_name, function_node in all_function_nodes.items():
|
|
1898
|
+
param_defaults = _collect_optional_param_defaults(function_node)
|
|
1899
|
+
if not param_defaults:
|
|
1900
|
+
continue
|
|
1901
|
+
|
|
1902
|
+
same_file_calls = [
|
|
1903
|
+
each_call
|
|
1904
|
+
for each_call in all_call_nodes
|
|
1905
|
+
if _function_name_from_call(each_call) == function_name
|
|
1906
|
+
]
|
|
1907
|
+
if not same_file_calls:
|
|
1908
|
+
continue
|
|
1909
|
+
|
|
1910
|
+
for param_name, default_node in param_defaults.items():
|
|
1911
|
+
default_value = _ast_constant_value(default_node)
|
|
1912
|
+
if _is_non_literal_default(default_value):
|
|
1913
|
+
continue
|
|
1914
|
+
is_param_varied = any(
|
|
1915
|
+
_call_passes_keyword_argument_differing_from_default(
|
|
1916
|
+
each_call, param_name, default_value
|
|
1917
|
+
)
|
|
1918
|
+
or _call_passes_positional_argument_for_param(
|
|
1919
|
+
each_call, function_node, param_name, default_value
|
|
1920
|
+
)
|
|
1921
|
+
for each_call in same_file_calls
|
|
1922
|
+
)
|
|
1923
|
+
if not is_param_varied:
|
|
1924
|
+
issues.append(
|
|
1925
|
+
f"Line {function_node.lineno}: optional parameter {param_name}"
|
|
1926
|
+
f" is never varied — inline default or drop"
|
|
1927
|
+
)
|
|
1928
|
+
if len(issues) >= MAX_ISSUES_PER_CHECK:
|
|
1929
|
+
return issues
|
|
1930
|
+
|
|
1931
|
+
return issues
|
|
1932
|
+
|
|
1933
|
+
|
|
1173
1934
|
def validate_content(content: str, file_path: str, old_content: str = "") -> list[str]:
|
|
1174
1935
|
"""Run all applicable validators on content.
|
|
1175
1936
|
|
|
@@ -1197,6 +1958,12 @@ def validate_content(content: str, file_path: str, old_content: str = "") -> lis
|
|
|
1197
1958
|
all_issues.extend(check_type_escape_hatches(content, file_path))
|
|
1198
1959
|
all_issues.extend(check_banned_identifiers(content, file_path))
|
|
1199
1960
|
all_issues.extend(check_boolean_naming(content, file_path))
|
|
1961
|
+
all_issues.extend(check_skip_decorators_in_tests(content, file_path))
|
|
1962
|
+
all_issues.extend(check_existence_check_tests(content, file_path))
|
|
1963
|
+
all_issues.extend(check_constant_equality_tests(content, file_path))
|
|
1964
|
+
all_issues.extend(check_unused_optional_parameters(content, file_path))
|
|
1965
|
+
check_incomplete_mocks(content, file_path)
|
|
1966
|
+
check_duplicated_format_patterns(content, file_path)
|
|
1200
1967
|
|
|
1201
1968
|
elif extension in JAVASCRIPT_EXTENSIONS:
|
|
1202
1969
|
if not is_test_file(file_path):
|