claude-dev-env 1.29.2 → 1.30.0

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 (44) hide show
  1. package/agents/code-quality-agent.md +279 -24
  2. package/agents/groq-coder.md +111 -0
  3. package/commands/plan.md +4 -5
  4. package/hooks/blocking/code_rules_enforcer.py +775 -8
  5. package/hooks/blocking/destructive_command_blocker.py +149 -12
  6. package/hooks/blocking/test_code_rules_enforcer.py +751 -0
  7. package/hooks/blocking/test_code_rules_enforcer_constant_equality.py +130 -0
  8. package/hooks/blocking/test_code_rules_enforcer_existence_checks.py +134 -0
  9. package/hooks/blocking/test_code_rules_enforcer_skip_decorators.py +150 -0
  10. package/hooks/blocking/test_destructive_command_blocker.py +281 -4
  11. package/hooks/blocking/test_pr_description_enforcer.py +9 -8
  12. package/hooks/git-hooks/test_config.py +9 -3
  13. package/hooks/git-hooks/test_gate_utils.py +9 -3
  14. package/hooks/git-hooks/test_pre_commit.py +9 -3
  15. package/hooks/git-hooks/test_pre_push.py +9 -3
  16. package/hooks/validators/run_all_validators.py +76 -3
  17. package/hooks/validators/test_files/skip_decorators/conftest.py +9 -0
  18. package/hooks/validators/test_output_formatter.py +4 -16
  19. package/hooks/validators/test_run_all_validators.py +22 -0
  20. package/hooks/validators/test_run_all_validators_integration.py +2 -11
  21. package/package.json +1 -1
  22. package/scripts/config/groq_bugteam_config.py +104 -0
  23. package/scripts/config/test_groq_bugteam_config.py +11 -0
  24. package/scripts/config/test_spec_implementer_prompt.py +36 -0
  25. package/scripts/groq_bugteam.README.md +2 -0
  26. package/scripts/groq_bugteam.py +74 -15
  27. package/scripts/groq_bugteam_dotenv.py +40 -0
  28. package/scripts/groq_bugteam_spec.py +226 -0
  29. package/scripts/test_groq_bugteam.py +143 -5
  30. package/scripts/test_groq_bugteam_apply_fix_from_spec.py +426 -0
  31. package/scripts/test_groq_bugteam_dotenv.py +66 -0
  32. package/scripts/test_groq_bugteam_spec.py +346 -0
  33. package/scripts/tests/test_sync_to_cursor.py +36 -70
  34. package/skills/bugteam/SKILL.md +4 -0
  35. package/skills/bugteam/reference/README.md +16 -0
  36. package/skills/bugteam/test_skill_additions.py +30 -0
  37. package/skills/monitor-open-prs/SKILL.md +104 -0
  38. package/skills/monitor-open-prs/scripts/discover_open_prs.py +69 -0
  39. package/skills/monitor-open-prs/scripts/test_discover_open_prs.py +149 -0
  40. package/skills/monitor-open-prs/test_skill_contract.py +43 -0
  41. package/skills/pr-review-responder/SKILL.md +10 -8
  42. package/hooks/github-action/pre-push-review.yml +0 -27
  43. package/hooks/github-action/test_workflow.py +0 -33
  44. 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 the placeholder word ``INTERP`` so regex patterns for path
502
- shape (``\\w+/\\w+/\\w+``) still match across interpolation boundaries
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``). Escaped braces (``{{`` / ``}}``) are
505
- already decoded by :mod:`ast` into their literal forms.
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
- annotation_is_bool = isinstance(node.annotation, ast.Name) and node.annotation.id == "bool"
971
- value_is_bool = node.value is not None and _is_bool_constant(node.value)
972
- if annotation_is_bool and value_is_bool:
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):