claude-dev-env 1.57.2 → 1.59.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 (77) hide show
  1. package/CLAUDE.md +2 -2
  2. package/_shared/pr-loop/scripts/code_rules_gate.py +36 -3
  3. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +6 -0
  4. package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviews_disabled_constants.py +1 -0
  5. package/_shared/pr-loop/scripts/reviews_disabled.py +12 -0
  6. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +265 -0
  7. package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +29 -0
  8. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
  9. package/bin/install.mjs +317 -54
  10. package/bin/install.test.mjs +478 -3
  11. package/docs/CODE_RULES.md +3 -3
  12. package/hooks/blocking/code_rules_annotations_length.py +153 -0
  13. package/hooks/blocking/code_rules_dead_dataclass_field.py +319 -0
  14. package/hooks/blocking/code_rules_duplicate_body.py +287 -0
  15. package/hooks/blocking/code_rules_enforcer.py +175 -21
  16. package/hooks/blocking/code_rules_magic_values.py +98 -0
  17. package/hooks/blocking/code_rules_shared.py +41 -0
  18. package/hooks/blocking/destructive_command_blocker.py +1027 -12
  19. package/hooks/blocking/hook_prose_detector_consistency.py +150 -0
  20. package/hooks/blocking/intent_only_ending_blocker.py +155 -0
  21. package/hooks/blocking/session_handoff_blocker.py +190 -0
  22. package/hooks/blocking/subprocess_budget_completeness.py +380 -0
  23. package/hooks/blocking/test_code_rules_enforcer_annotations.py +225 -0
  24. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +1 -0
  25. package/hooks/blocking/test_code_rules_enforcer_dead_dataclass_field.py +467 -0
  26. package/hooks/blocking/test_code_rules_enforcer_duplicate_body.py +330 -0
  27. package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +179 -0
  28. package/hooks/blocking/test_code_rules_enforcer_magic_slice_bounds.py +133 -0
  29. package/hooks/blocking/test_destructive_command_blocker.py +622 -3
  30. package/hooks/blocking/test_hook_prose_detector_consistency.py +265 -0
  31. package/hooks/blocking/test_intent_only_ending_blocker.py +175 -0
  32. package/hooks/blocking/test_session_handoff_blocker.py +312 -0
  33. package/hooks/blocking/test_subprocess_budget_completeness.py +588 -0
  34. package/hooks/blocking/test_workflow_substitution_slot_blocker.py +242 -0
  35. package/hooks/blocking/workflow_substitution_slot_blocker.py +159 -0
  36. package/hooks/hooks.json +25 -0
  37. package/hooks/hooks_constants/code_rules_enforcer_constants.py +16 -0
  38. package/hooks/hooks_constants/dead_dataclass_field_constants.py +25 -0
  39. package/hooks/hooks_constants/destructive_command_segment_constants.py +178 -0
  40. package/hooks/hooks_constants/duplicate_function_body_constants.py +17 -0
  41. package/hooks/hooks_constants/hook_prose_detector_consistency_constants.py +30 -0
  42. package/hooks/hooks_constants/messages.py +4 -0
  43. package/hooks/hooks_constants/session_handoff_blocker_constants.py +10 -0
  44. package/hooks/hooks_constants/subprocess_budget_completeness_constants.py +5 -0
  45. package/hooks/hooks_constants/workflow_substitution_slot_blocker_constants.py +22 -0
  46. package/hooks/workflow/auto_formatter.py +26 -1
  47. package/hooks/workflow/test_auto_formatter.py +134 -0
  48. package/package.json +1 -1
  49. package/rules/conservative-action.md +1 -0
  50. package/rules/docstring-prose-matches-implementation.md +43 -0
  51. package/rules/hook-prose-matches-detector.md +26 -0
  52. package/rules/long-horizon-autonomy.md +43 -0
  53. package/rules/no-inline-destructive-literals.md +11 -0
  54. package/rules/workflow-substitution-slots.md +7 -0
  55. package/skills/autoconverge/SKILL.md +68 -6
  56. package/skills/autoconverge/reference/closing-report.md +44 -0
  57. package/skills/autoconverge/reference/convergence.md +7 -3
  58. package/skills/autoconverge/reference/stop-conditions.md +7 -2
  59. package/skills/autoconverge/workflow/autoconverge_report_constants/__init__.py +0 -0
  60. package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +105 -0
  61. package/skills/autoconverge/workflow/converge.contract.test.mjs +30 -1
  62. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +265 -0
  63. package/skills/autoconverge/workflow/converge.mjs +106 -38
  64. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a11d903476b803493.jsonl +2 -0
  65. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a26213978adeef6fb.jsonl +2 -0
  66. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a3def0d15ed9d9110.jsonl +2 -0
  67. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a41f41b1b708ee3b7.jsonl +2 -0
  68. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a758b880abecc3ff7.jsonl +2 -0
  69. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a8897b89656b1bd16.jsonl +2 -0
  70. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-abd463d744a1437bc.jsonl +2 -0
  71. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-ad19d027ae8ee1816.jsonl +2 -0
  72. package/skills/autoconverge/workflow/fixtures/wf_run/workflows/wf_881252e6-700.json +259 -0
  73. package/skills/autoconverge/workflow/render_report.py +903 -0
  74. package/skills/autoconverge/workflow/test_render_report.py +484 -0
  75. package/skills/pr-converge/scripts/check_convergence.py +195 -64
  76. package/skills/pr-converge/scripts/test_check_convergence.py +173 -2
  77. package/skills/update/SKILL.md +37 -5
@@ -0,0 +1,380 @@
1
+ #!/usr/bin/env python3
2
+ """Blocking hook: a named subprocess-budget helper must account for every reachable subprocess timeout.
3
+
4
+ Fires when a Write/Edit produces a Python module that both:
5
+
6
+ * defines a function whose name names a worst-case or budget total
7
+ (a marker ``worst_case``, ``_budget``, or ``budget_seconds`` aligns with the
8
+ start or end of the function name's underscore-delimited tokens, so an
9
+ interior ``budget`` segment such as ``audit_budget_report`` does not qualify),
10
+ and
11
+ * passes ``timeout=`` (an integer literal or a module-level integer
12
+ constant) to one or more subprocess ``run`` calls, recognized in both the
13
+ ``subprocess.run(...)`` attribute form and the bare ``run(...)`` form bound
14
+ by ``from subprocess import run`` (including an aliased import),
15
+
16
+ but the budget total omits a distinct subprocess timeout value reachable in one
17
+ invocation. The reachable set is the subprocess timeouts in functions the module
18
+ ``main`` entry point transitively calls; a module with no ``main`` treats every
19
+ function as reachable. The budget total counts only the integer values that flow
20
+ into the helper's ``return`` expression — its returned literals, the module
21
+ constants it references there, and the local names bound to integers it returns
22
+ — so a stray literal elsewhere in the helper body never masks an omitted timeout.
23
+ A budget helper that undercounts a reachable subprocess timeout reports a
24
+ wall-clock margin wider than the real one, so a later change can silently cross
25
+ the harness timeout while the named guard still reads green.
26
+
27
+ Test files are exempt: the gate skips paths matching the project's test-path
28
+ patterns so a test module can stage undercounting fixtures freely.
29
+ """
30
+
31
+ import ast
32
+ import json
33
+ import sys
34
+ from pathlib import Path
35
+
36
+ FunctionDefinition = ast.FunctionDef | ast.AsyncFunctionDef
37
+
38
+ _blocking_dir = str(Path(__file__).resolve().parent)
39
+ _hooks_dir = str(Path(__file__).resolve().parent.parent)
40
+ if _blocking_dir not in sys.path:
41
+ sys.path.insert(0, _blocking_dir)
42
+ if _hooks_dir not in sys.path:
43
+ sys.path.insert(0, _hooks_dir)
44
+
45
+ from code_rules_shared import is_test_file # noqa: E402
46
+
47
+ from hooks_constants.pre_tool_use_stdin import ( # noqa: E402
48
+ read_hook_input_dictionary_from_stdin,
49
+ )
50
+ from hooks_constants.subprocess_budget_completeness_constants import ( # noqa: E402
51
+ ALL_BUDGET_NAME_MARKERS,
52
+ BUDGET_ENTRY_POINT_FUNCTION_NAME,
53
+ SUBPROCESS_TIMEOUT_KEYWORD,
54
+ )
55
+ from hooks_constants.windows_rmtree_blocker_constants import ( # noqa: E402
56
+ PYTHON_FILE_EXTENSION,
57
+ )
58
+
59
+
60
+ def is_python_target(file_path: str) -> bool:
61
+ return file_path.endswith(PYTHON_FILE_EXTENSION)
62
+
63
+
64
+ def resolved_content(all_tool_input_fields: dict[str, object]) -> str:
65
+ written_content = all_tool_input_fields.get("content")
66
+ if isinstance(written_content, str):
67
+ return written_content
68
+ return reconstructed_edit_content(all_tool_input_fields)
69
+
70
+
71
+ def reconstructed_edit_content(all_tool_input_fields: dict[str, object]) -> str:
72
+ file_path = all_tool_input_fields.get("file_path")
73
+ old_string = all_tool_input_fields.get("old_string")
74
+ new_string = all_tool_input_fields.get("new_string")
75
+ if not isinstance(file_path, str) or not isinstance(old_string, str):
76
+ return ""
77
+ if not isinstance(new_string, str) or not old_string:
78
+ return ""
79
+ existing_content = existing_file_content(file_path)
80
+ if existing_content is None or old_string not in existing_content:
81
+ return ""
82
+ return existing_content.replace(old_string, new_string, 1)
83
+
84
+
85
+ def existing_file_content(file_path: str) -> str | None:
86
+ try:
87
+ with open(file_path, "r", encoding="utf-8") as existing_file:
88
+ return existing_file.read()
89
+ except (FileNotFoundError, OSError, UnicodeDecodeError):
90
+ return None
91
+
92
+
93
+ def integer_literal_value(node: ast.expr) -> int | None:
94
+ if (
95
+ isinstance(node, ast.Constant)
96
+ and isinstance(node.value, int)
97
+ and not isinstance(node.value, bool)
98
+ ):
99
+ return node.value
100
+ return None
101
+
102
+
103
+ def resolved_integer_value(node: ast.expr, value_by_constant_name: dict[str, int]) -> int | None:
104
+ literal_value = integer_literal_value(node)
105
+ if literal_value is not None:
106
+ return literal_value
107
+ if isinstance(node, ast.Name):
108
+ return value_by_constant_name.get(node.id)
109
+ return None
110
+
111
+
112
+ def collect_reachable_subprocess_timeout_values(
113
+ tree: ast.Module,
114
+ value_by_constant_name: dict[str, int],
115
+ all_reachable_function_names: set[str] | None,
116
+ all_bare_run_aliases: set[str],
117
+ ) -> set[int]:
118
+ all_timeout_values: set[int] = set()
119
+ for each_function in iter_function_definitions(tree):
120
+ if (
121
+ all_reachable_function_names is not None
122
+ and each_function.name not in all_reachable_function_names
123
+ ):
124
+ continue
125
+ all_timeout_values |= subprocess_timeout_values_in_function(
126
+ each_function, value_by_constant_name, all_bare_run_aliases
127
+ )
128
+ return all_timeout_values
129
+
130
+
131
+ def subprocess_timeout_values_in_function(
132
+ function_node: FunctionDefinition,
133
+ value_by_constant_name: dict[str, int],
134
+ all_bare_run_aliases: set[str],
135
+ ) -> set[int]:
136
+ all_timeout_values: set[int] = set()
137
+ for each_node in ast.walk(function_node):
138
+ if not isinstance(each_node, ast.Call):
139
+ continue
140
+ if not is_subprocess_run_call(each_node, all_bare_run_aliases):
141
+ continue
142
+ for each_keyword in each_node.keywords:
143
+ if each_keyword.arg != SUBPROCESS_TIMEOUT_KEYWORD:
144
+ continue
145
+ timeout_value = resolved_integer_value(each_keyword.value, value_by_constant_name)
146
+ if timeout_value is not None:
147
+ all_timeout_values.add(timeout_value)
148
+ return all_timeout_values
149
+
150
+
151
+ def iter_function_definitions(tree: ast.Module) -> list[FunctionDefinition]:
152
+ return [
153
+ each_node
154
+ for each_node in ast.walk(tree)
155
+ if isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef))
156
+ ]
157
+
158
+
159
+ def callees_by_function_name(tree: ast.Module) -> dict[str, set[str]]:
160
+ callee_names_by_caller: dict[str, set[str]] = {}
161
+ for each_function in iter_function_definitions(tree):
162
+ callee_names_by_caller[each_function.name] = called_function_names(each_function)
163
+ return callee_names_by_caller
164
+
165
+
166
+ def called_function_names(function_node: FunctionDefinition) -> set[str]:
167
+ all_called_names: set[str] = set()
168
+ for each_node in ast.walk(function_node):
169
+ if isinstance(each_node, ast.Call) and isinstance(each_node.func, ast.Name):
170
+ all_called_names.add(each_node.func.id)
171
+ return all_called_names
172
+
173
+
174
+ def reachable_function_names_from_entry_points(tree: ast.Module) -> set[str] | None:
175
+ callee_names_by_caller = callees_by_function_name(tree)
176
+ if BUDGET_ENTRY_POINT_FUNCTION_NAME not in callee_names_by_caller:
177
+ return None
178
+ reachable_names: set[str] = set()
179
+ pending_names = [BUDGET_ENTRY_POINT_FUNCTION_NAME]
180
+ while pending_names:
181
+ current_name = pending_names.pop()
182
+ if current_name in reachable_names:
183
+ continue
184
+ reachable_names.add(current_name)
185
+ pending_names.extend(callee_names_by_caller.get(current_name, set()))
186
+ return reachable_names
187
+
188
+
189
+ def is_subprocess_run_call(call_node: ast.Call, all_bare_run_aliases: set[str]) -> bool:
190
+ function_node = call_node.func
191
+ if isinstance(function_node, ast.Attribute):
192
+ return function_node.attr == "run" and _attribute_root_name(function_node) == "subprocess"
193
+ if isinstance(function_node, ast.Name):
194
+ return function_node.id in all_bare_run_aliases
195
+ return False
196
+
197
+
198
+ def _attribute_root_name(attribute_node: ast.Attribute) -> str | None:
199
+ base_node = attribute_node.value
200
+ if isinstance(base_node, ast.Name):
201
+ return base_node.id
202
+ return None
203
+
204
+
205
+ def bare_run_aliases(tree: ast.Module) -> set[str]:
206
+ all_aliases: set[str] = set()
207
+ for each_node in ast.walk(tree):
208
+ if not isinstance(each_node, ast.ImportFrom) or each_node.module != "subprocess":
209
+ continue
210
+ for each_name in each_node.names:
211
+ if each_name.name == "run":
212
+ all_aliases.add(each_name.asname or each_name.name)
213
+ return all_aliases
214
+
215
+
216
+ def values_flowing_into_returned_total(
217
+ function_node: FunctionDefinition, value_by_constant_name: dict[str, int]
218
+ ) -> set[int]:
219
+ value_by_local_name = local_integer_bindings(function_node, value_by_constant_name)
220
+ all_accounted_values: set[int] = set()
221
+ for each_node in ast.walk(function_node):
222
+ if not isinstance(each_node, ast.Return) or each_node.value is None:
223
+ continue
224
+ all_accounted_values |= integer_values_in_expression(
225
+ each_node.value, value_by_local_name, value_by_constant_name
226
+ )
227
+ return all_accounted_values
228
+
229
+
230
+ def local_integer_bindings(
231
+ function_node: FunctionDefinition, value_by_constant_name: dict[str, int]
232
+ ) -> dict[str, int]:
233
+ value_by_local_name: dict[str, int] = {}
234
+ for each_node in ast.walk(function_node):
235
+ if not isinstance(each_node, ast.Assign):
236
+ continue
237
+ bound_value = resolved_integer_value(each_node.value, value_by_constant_name)
238
+ if bound_value is None:
239
+ continue
240
+ for each_target in each_node.targets:
241
+ if isinstance(each_target, ast.Name):
242
+ value_by_local_name[each_target.id] = bound_value
243
+ return value_by_local_name
244
+
245
+
246
+ def integer_values_in_expression(
247
+ expression_node: ast.expr,
248
+ value_by_local_name: dict[str, int],
249
+ value_by_constant_name: dict[str, int],
250
+ ) -> set[int]:
251
+ all_values: set[int] = set()
252
+ for each_node in ast.walk(expression_node):
253
+ literal_value = integer_literal_value(each_node) if isinstance(each_node, ast.expr) else None
254
+ if literal_value is not None:
255
+ all_values.add(literal_value)
256
+ elif isinstance(each_node, ast.Name):
257
+ named_value = value_by_local_name.get(each_node.id, value_by_constant_name.get(each_node.id))
258
+ if named_value is not None:
259
+ all_values.add(named_value)
260
+ return all_values
261
+
262
+
263
+ def is_budget_function(function_node: FunctionDefinition) -> bool:
264
+ all_name_tokens = underscore_tokens(function_node.name.lower())
265
+ return any(
266
+ marker_anchored_to_name_boundary(underscore_tokens(each_marker), all_name_tokens)
267
+ for each_marker in ALL_BUDGET_NAME_MARKERS
268
+ )
269
+
270
+
271
+ def underscore_tokens(snake_case_name: str) -> list[str]:
272
+ return [each_segment for each_segment in snake_case_name.split("_") if each_segment]
273
+
274
+
275
+ def marker_anchored_to_name_boundary(
276
+ all_marker_tokens: list[str], all_name_tokens: list[str]
277
+ ) -> bool:
278
+ if not all_marker_tokens or len(all_marker_tokens) > len(all_name_tokens):
279
+ return False
280
+ starts_with_marker = all_name_tokens[: len(all_marker_tokens)] == all_marker_tokens
281
+ ends_with_marker = all_name_tokens[-len(all_marker_tokens) :] == all_marker_tokens
282
+ return starts_with_marker or ends_with_marker
283
+
284
+
285
+ def find_undercounted_budget(content: str) -> tuple[str, set[int]] | None:
286
+ try:
287
+ tree = ast.parse(content)
288
+ except SyntaxError:
289
+ return None
290
+
291
+ referenced_constants = collect_module_constant_values(tree)
292
+ all_reachable_function_names = reachable_function_names_from_entry_points(tree)
293
+ all_bare_run_aliases = bare_run_aliases(tree)
294
+ subprocess_timeout_values = collect_reachable_subprocess_timeout_values(
295
+ tree, referenced_constants, all_reachable_function_names, all_bare_run_aliases
296
+ )
297
+ if not subprocess_timeout_values:
298
+ return None
299
+
300
+ for each_node in ast.walk(tree):
301
+ if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
302
+ continue
303
+ if not is_budget_function(each_node):
304
+ continue
305
+ accounted_values = values_flowing_into_returned_total(each_node, referenced_constants)
306
+ omitted_values = subprocess_timeout_values - accounted_values
307
+ if omitted_values:
308
+ return each_node.name, omitted_values
309
+ return None
310
+
311
+
312
+ def collect_module_constant_values(tree: ast.Module) -> dict[str, int]:
313
+ value_by_constant_name: dict[str, int] = {}
314
+ for each_node in tree.body:
315
+ if isinstance(each_node, ast.Assign):
316
+ assigned_value = integer_literal_value(each_node.value)
317
+ if assigned_value is None:
318
+ continue
319
+ for each_target in each_node.targets:
320
+ if isinstance(each_target, ast.Name):
321
+ value_by_constant_name[each_target.id] = assigned_value
322
+ elif isinstance(each_node, ast.AnnAssign) and each_node.value is not None:
323
+ annotated_value = integer_literal_value(each_node.value)
324
+ if annotated_value is not None and isinstance(each_node.target, ast.Name):
325
+ value_by_constant_name[each_node.target.id] = annotated_value
326
+ return value_by_constant_name
327
+
328
+
329
+ def format_block_message(file_path: str, function_name: str, all_omitted_values: set[int]) -> str:
330
+ omitted_text = ", ".join(f"{each_value}s" for each_value in sorted(all_omitted_values))
331
+ return (
332
+ f"SUBPROCESS BUDGET INCOMPLETE: {function_name}() in {file_path} sums a subset of the "
333
+ f"subprocess timeouts reachable in one invocation and omits timeout value(s) {omitted_text} that "
334
+ "one invocation can reach. A named worst-case/budget helper must account for every subprocess timeout reachable "
335
+ "in a single invocation, so its reported margin against the harness timeout is real. Either add the "
336
+ f"omitted timeout(s) to the modeled total, or rename the helper to name the phases it actually covers "
337
+ "and document the residual full-invocation margin separately."
338
+ )
339
+
340
+
341
+ def main() -> None:
342
+ hook_input = read_hook_input_dictionary_from_stdin()
343
+ if hook_input is None:
344
+ sys.exit(0)
345
+
346
+ raw_tool_input = hook_input.get("tool_input", {})
347
+ tool_input = raw_tool_input if isinstance(raw_tool_input, dict) else {}
348
+ file_path = tool_input.get("file_path", "")
349
+ if not isinstance(file_path, str) or not file_path or not is_python_target(file_path):
350
+ sys.exit(0)
351
+ if is_test_file(file_path):
352
+ sys.exit(0)
353
+
354
+ content = resolved_content(tool_input)
355
+ if not content:
356
+ sys.exit(0)
357
+
358
+ undercounted_budget = find_undercounted_budget(content)
359
+ if undercounted_budget is None:
360
+ sys.exit(0)
361
+
362
+ function_name, omitted_values = undercounted_budget
363
+ print(
364
+ json.dumps(
365
+ {
366
+ "hookSpecificOutput": {
367
+ "hookEventName": "PreToolUse",
368
+ "permissionDecision": "deny",
369
+ "permissionDecisionReason": format_block_message(
370
+ file_path, function_name, omitted_values
371
+ ),
372
+ }
373
+ }
374
+ )
375
+ )
376
+ sys.exit(0)
377
+
378
+
379
+ if __name__ == "__main__":
380
+ main()
@@ -95,3 +95,228 @@ def test_should_skip_return_check_in_test_files() -> None:
95
95
  assert issues == [], f"Test files must be exempt, got: {issues}"
96
96
 
97
97
 
98
+ def test_should_flag_unannotated_known_fixture_in_test_file() -> None:
99
+ source = "def test_board(tmp_path):\n assert tmp_path.exists()\n"
100
+ issues = code_rules_enforcer.check_known_pytest_fixture_annotations(
101
+ source, TEST_FILE_PATH
102
+ )
103
+ assert any(
104
+ "tmp_path" in each_issue and "Path" in each_issue for each_issue in issues
105
+ ), f"Expected unannotated tmp_path fixture flagged, got: {issues}"
106
+
107
+
108
+ def test_should_not_flag_annotated_known_fixture_in_test_file() -> None:
109
+ source = (
110
+ "from pathlib import Path\n"
111
+ "def test_board(tmp_path: Path) -> None:\n"
112
+ " assert tmp_path.exists()\n"
113
+ )
114
+ issues = code_rules_enforcer.check_known_pytest_fixture_annotations(
115
+ source, TEST_FILE_PATH
116
+ )
117
+ assert issues == [], f"Annotated tmp_path must not be flagged, got: {issues}"
118
+
119
+
120
+ def test_should_not_flag_ordinary_test_parameter() -> None:
121
+ source = "def test_thing(some_value):\n assert some_value\n"
122
+ issues = code_rules_enforcer.check_known_pytest_fixture_annotations(
123
+ source, TEST_FILE_PATH
124
+ )
125
+ assert issues == [], (
126
+ f"Ordinary test params stay exempt; only known fixtures are checked, "
127
+ f"got: {issues}"
128
+ )
129
+
130
+
131
+ def test_should_not_flag_known_fixture_name_outside_test_files() -> None:
132
+ source = "def build(monkeypatch):\n return monkeypatch\n"
133
+ issues = code_rules_enforcer.check_known_pytest_fixture_annotations(
134
+ source, PRODUCTION_FILE_PATH
135
+ )
136
+ assert issues == [], (
137
+ f"Non-test files are covered by the broad parameter check, not this one, "
138
+ f"got: {issues}"
139
+ )
140
+
141
+
142
+ def test_should_flag_unannotated_monkeypatch_fixture() -> None:
143
+ source = "def test_env(monkeypatch):\n monkeypatch.setenv('A', 'B')\n"
144
+ issues = code_rules_enforcer.check_known_pytest_fixture_annotations(
145
+ source, TEST_FILE_PATH
146
+ )
147
+ assert any(
148
+ "monkeypatch" in each_issue and "MonkeyPatch" in each_issue
149
+ for each_issue in issues
150
+ ), f"Expected unannotated monkeypatch fixture flagged, got: {issues}"
151
+
152
+
153
+ def test_should_not_flag_known_fixture_in_non_test_helper() -> None:
154
+ source = "def render_view(request):\n return request.path\n"
155
+ issues = code_rules_enforcer.check_known_pytest_fixture_annotations(
156
+ source, TEST_FILE_PATH
157
+ )
158
+ assert issues == [], (
159
+ f"Ordinary helper (non-test, non-fixture) must not be flagged, got: {issues}"
160
+ )
161
+
162
+
163
+ def test_should_flag_unannotated_fixture_in_decorated_fixture() -> None:
164
+ source = (
165
+ "import pytest\n"
166
+ "@pytest.fixture\n"
167
+ "def board(tmp_path):\n"
168
+ " return tmp_path\n"
169
+ )
170
+ issues = code_rules_enforcer.check_known_pytest_fixture_annotations(
171
+ source, TEST_FILE_PATH
172
+ )
173
+ assert any(
174
+ "tmp_path" in each_issue and "Path" in each_issue for each_issue in issues
175
+ ), f"Expected unannotated tmp_path in @pytest.fixture-decorated function flagged, got: {issues}"
176
+
177
+
178
+ def test_should_flag_known_fixture_with_wrong_annotation() -> None:
179
+ source = "def test_board(tmp_path: str):\n assert tmp_path\n"
180
+ issues = code_rules_enforcer.check_known_pytest_fixture_annotations(
181
+ source, TEST_FILE_PATH
182
+ )
183
+ assert any(
184
+ "tmp_path" in each_issue and "Path" in each_issue for each_issue in issues
185
+ ), f"Expected wrongly annotated tmp_path: str flagged, got: {issues}"
186
+
187
+
188
+ def test_should_flag_known_fixture_with_unrelated_annotation() -> None:
189
+ source = "def test_board(tmp_path: int):\n assert tmp_path\n"
190
+ issues = code_rules_enforcer.check_known_pytest_fixture_annotations(
191
+ source, TEST_FILE_PATH
192
+ )
193
+ assert any(
194
+ "tmp_path" in each_issue and "Path" in each_issue for each_issue in issues
195
+ ), f"Expected wrongly annotated tmp_path: int flagged, got: {issues}"
196
+
197
+
198
+ def test_should_not_flag_correctly_annotated_qualified_fixture() -> None:
199
+ source = (
200
+ "import pytest\n"
201
+ "def test_env(monkeypatch: pytest.MonkeyPatch) -> None:\n"
202
+ " monkeypatch.setenv('A', 'B')\n"
203
+ )
204
+ issues = code_rules_enforcer.check_known_pytest_fixture_annotations(
205
+ source, TEST_FILE_PATH
206
+ )
207
+ assert issues == [], (
208
+ f"Correctly annotated monkeypatch must not be flagged, got: {issues}"
209
+ )
210
+
211
+
212
+ def test_should_not_flag_dotted_pathlib_path_fixture_annotation() -> None:
213
+ source = (
214
+ "import pathlib\n"
215
+ "def test_board(tmp_path: pathlib.Path) -> None:\n"
216
+ " assert tmp_path.exists()\n"
217
+ )
218
+ issues = code_rules_enforcer.check_known_pytest_fixture_annotations(
219
+ source, TEST_FILE_PATH
220
+ )
221
+ assert issues == [], (
222
+ f"tmp_path: pathlib.Path is an equally-correct spelling, got: {issues}"
223
+ )
224
+
225
+
226
+ def test_should_not_flag_bare_tail_of_qualified_fixture_annotation() -> None:
227
+ source = (
228
+ "from pytest import MonkeyPatch\n"
229
+ "def test_env(monkeypatch: MonkeyPatch) -> None:\n"
230
+ " monkeypatch.setenv('A', 'B')\n"
231
+ )
232
+ issues = code_rules_enforcer.check_known_pytest_fixture_annotations(
233
+ source, TEST_FILE_PATH
234
+ )
235
+ assert issues == [], (
236
+ f"monkeypatch: MonkeyPatch matches the qualified expected tail, got: {issues}"
237
+ )
238
+
239
+
240
+ def test_should_not_flag_forward_reference_fixture_annotation() -> None:
241
+ source = 'def test_board(tmp_path: "Path") -> None:\n assert tmp_path\n'
242
+ issues = code_rules_enforcer.check_known_pytest_fixture_annotations(
243
+ source, TEST_FILE_PATH
244
+ )
245
+ assert issues == [], (
246
+ f'A forward-ref "Path" annotation must not be flagged, got: {issues}'
247
+ )
248
+
249
+
250
+ def test_should_not_flag_star_arg_fixture_name() -> None:
251
+ source = "def test_board(*tmp_path):\n assert tmp_path\n"
252
+ issues = code_rules_enforcer.check_known_pytest_fixture_annotations(
253
+ source, TEST_FILE_PATH
254
+ )
255
+ assert issues == [], (
256
+ f"A *vararg sharing a fixture name is not an injection site, got: {issues}"
257
+ )
258
+
259
+
260
+ def test_should_not_flag_double_star_arg_fixture_name() -> None:
261
+ source = "def test_env(**monkeypatch):\n assert monkeypatch\n"
262
+ issues = code_rules_enforcer.check_known_pytest_fixture_annotations(
263
+ source, TEST_FILE_PATH
264
+ )
265
+ assert issues == [], (
266
+ f"A **kwarg sharing a fixture name is not an injection site, got: {issues}"
267
+ )
268
+
269
+
270
+ def test_should_not_flag_positional_only_fixture_name() -> None:
271
+ source = "def test_board(tmp_path, /):\n pass\n"
272
+ issues = code_rules_enforcer.check_known_pytest_fixture_annotations(
273
+ source, TEST_FILE_PATH
274
+ )
275
+ assert issues == [], (
276
+ f"A positional-only param cannot receive a keyword-injected fixture, got: {issues}"
277
+ )
278
+
279
+
280
+ def test_should_flag_keyword_only_fixture_name() -> None:
281
+ source = "def test_board(*, tmp_path):\n pass\n"
282
+ issues = code_rules_enforcer.check_known_pytest_fixture_annotations(
283
+ source, TEST_FILE_PATH
284
+ )
285
+ assert any(
286
+ "tmp_path" in each_issue and "Path" in each_issue for each_issue in issues
287
+ ), f"A keyword-only fixture is still an injection site, got: {issues}"
288
+
289
+
290
+ def test_should_not_flag_defaulted_fixture_name() -> None:
291
+ source = "def test_board(tmp_path=None):\n pass\n"
292
+ issues = code_rules_enforcer.check_known_pytest_fixture_annotations(
293
+ source, TEST_FILE_PATH
294
+ )
295
+ assert issues == [], (
296
+ f"A defaulted param is not fixture-injected and must not be flagged, got: {issues}"
297
+ )
298
+
299
+
300
+ def test_should_not_flag_defaulted_keyword_only_fixture_name() -> None:
301
+ source = "def test_board(*, tmp_path=None):\n pass\n"
302
+ issues = code_rules_enforcer.check_known_pytest_fixture_annotations(
303
+ source, TEST_FILE_PATH
304
+ )
305
+ assert issues == [], (
306
+ f"A defaulted keyword-only param is not fixture-injected, got: {issues}"
307
+ )
308
+
309
+
310
+ def test_should_flag_undefaulted_fixture_before_defaulted_one() -> None:
311
+ source = "def test_board(tmp_path, capsys=None):\n pass\n"
312
+ issues = code_rules_enforcer.check_known_pytest_fixture_annotations(
313
+ source, TEST_FILE_PATH
314
+ )
315
+ assert any(
316
+ "tmp_path" in each_issue and "Path" in each_issue for each_issue in issues
317
+ ), f"An undefaulted leading fixture stays an injection site, got: {issues}"
318
+ assert not any("capsys" in each_issue for each_issue in issues), (
319
+ f"The trailing defaulted param must not be flagged, got: {issues}"
320
+ )
321
+
322
+
@@ -70,6 +70,7 @@ KNOWN_UNCAPPED_CHECKS_PENDING_REVIEW: frozenset[str] = frozenset(
70
70
  "check_file_global_constants_use_count",
71
71
  "check_imports_at_top",
72
72
  "check_inline_literal_collections",
73
+ "check_known_pytest_fixture_annotations",
73
74
  "check_library_print",
74
75
  "check_loop_variable_naming",
75
76
  "check_parameter_annotations",