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.
- package/CLAUDE.md +2 -2
- package/_shared/pr-loop/scripts/code_rules_gate.py +36 -3
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +6 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviews_disabled_constants.py +1 -0
- package/_shared/pr-loop/scripts/reviews_disabled.py +12 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +265 -0
- package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +29 -0
- package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
- package/bin/install.mjs +317 -54
- package/bin/install.test.mjs +478 -3
- package/docs/CODE_RULES.md +3 -3
- package/hooks/blocking/code_rules_annotations_length.py +153 -0
- package/hooks/blocking/code_rules_dead_dataclass_field.py +319 -0
- package/hooks/blocking/code_rules_duplicate_body.py +287 -0
- package/hooks/blocking/code_rules_enforcer.py +175 -21
- package/hooks/blocking/code_rules_magic_values.py +98 -0
- package/hooks/blocking/code_rules_shared.py +41 -0
- package/hooks/blocking/destructive_command_blocker.py +1027 -12
- package/hooks/blocking/hook_prose_detector_consistency.py +150 -0
- package/hooks/blocking/intent_only_ending_blocker.py +155 -0
- package/hooks/blocking/session_handoff_blocker.py +190 -0
- package/hooks/blocking/subprocess_budget_completeness.py +380 -0
- package/hooks/blocking/test_code_rules_enforcer_annotations.py +225 -0
- package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +1 -0
- package/hooks/blocking/test_code_rules_enforcer_dead_dataclass_field.py +467 -0
- package/hooks/blocking/test_code_rules_enforcer_duplicate_body.py +330 -0
- package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +179 -0
- package/hooks/blocking/test_code_rules_enforcer_magic_slice_bounds.py +133 -0
- package/hooks/blocking/test_destructive_command_blocker.py +622 -3
- package/hooks/blocking/test_hook_prose_detector_consistency.py +265 -0
- package/hooks/blocking/test_intent_only_ending_blocker.py +175 -0
- package/hooks/blocking/test_session_handoff_blocker.py +312 -0
- package/hooks/blocking/test_subprocess_budget_completeness.py +588 -0
- package/hooks/blocking/test_workflow_substitution_slot_blocker.py +242 -0
- package/hooks/blocking/workflow_substitution_slot_blocker.py +159 -0
- package/hooks/hooks.json +25 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +16 -0
- package/hooks/hooks_constants/dead_dataclass_field_constants.py +25 -0
- package/hooks/hooks_constants/destructive_command_segment_constants.py +178 -0
- package/hooks/hooks_constants/duplicate_function_body_constants.py +17 -0
- package/hooks/hooks_constants/hook_prose_detector_consistency_constants.py +30 -0
- package/hooks/hooks_constants/messages.py +4 -0
- package/hooks/hooks_constants/session_handoff_blocker_constants.py +10 -0
- package/hooks/hooks_constants/subprocess_budget_completeness_constants.py +5 -0
- package/hooks/hooks_constants/workflow_substitution_slot_blocker_constants.py +22 -0
- package/hooks/workflow/auto_formatter.py +26 -1
- package/hooks/workflow/test_auto_formatter.py +134 -0
- package/package.json +1 -1
- package/rules/conservative-action.md +1 -0
- package/rules/docstring-prose-matches-implementation.md +43 -0
- package/rules/hook-prose-matches-detector.md +26 -0
- package/rules/long-horizon-autonomy.md +43 -0
- package/rules/no-inline-destructive-literals.md +11 -0
- package/rules/workflow-substitution-slots.md +7 -0
- package/skills/autoconverge/SKILL.md +68 -6
- package/skills/autoconverge/reference/closing-report.md +44 -0
- package/skills/autoconverge/reference/convergence.md +7 -3
- package/skills/autoconverge/reference/stop-conditions.md +7 -2
- package/skills/autoconverge/workflow/autoconverge_report_constants/__init__.py +0 -0
- package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +105 -0
- package/skills/autoconverge/workflow/converge.contract.test.mjs +30 -1
- package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +265 -0
- package/skills/autoconverge/workflow/converge.mjs +106 -38
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a11d903476b803493.jsonl +2 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a26213978adeef6fb.jsonl +2 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a3def0d15ed9d9110.jsonl +2 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a41f41b1b708ee3b7.jsonl +2 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a758b880abecc3ff7.jsonl +2 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a8897b89656b1bd16.jsonl +2 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-abd463d744a1437bc.jsonl +2 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-ad19d027ae8ee1816.jsonl +2 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/workflows/wf_881252e6-700.json +259 -0
- package/skills/autoconverge/workflow/render_report.py +903 -0
- package/skills/autoconverge/workflow/test_render_report.py +484 -0
- package/skills/pr-converge/scripts/check_convergence.py +195 -64
- package/skills/pr-converge/scripts/test_check_convergence.py +173 -2
- 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",
|