claude-dev-env 1.58.0 → 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 (52) 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 +100 -27
  10. package/bin/install.test.mjs +133 -1
  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/subprocess_budget_completeness.py +380 -0
  21. package/hooks/blocking/test_code_rules_enforcer_annotations.py +225 -0
  22. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +1 -0
  23. package/hooks/blocking/test_code_rules_enforcer_dead_dataclass_field.py +467 -0
  24. package/hooks/blocking/test_code_rules_enforcer_duplicate_body.py +330 -0
  25. package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +179 -0
  26. package/hooks/blocking/test_code_rules_enforcer_magic_slice_bounds.py +133 -0
  27. package/hooks/blocking/test_destructive_command_blocker.py +622 -3
  28. package/hooks/blocking/test_hook_prose_detector_consistency.py +265 -0
  29. package/hooks/blocking/test_subprocess_budget_completeness.py +588 -0
  30. package/hooks/blocking/test_workflow_substitution_slot_blocker.py +242 -0
  31. package/hooks/blocking/workflow_substitution_slot_blocker.py +159 -0
  32. package/hooks/hooks.json +15 -0
  33. package/hooks/hooks_constants/code_rules_enforcer_constants.py +16 -0
  34. package/hooks/hooks_constants/dead_dataclass_field_constants.py +25 -0
  35. package/hooks/hooks_constants/destructive_command_segment_constants.py +178 -0
  36. package/hooks/hooks_constants/duplicate_function_body_constants.py +17 -0
  37. package/hooks/hooks_constants/hook_prose_detector_consistency_constants.py +30 -0
  38. package/hooks/hooks_constants/subprocess_budget_completeness_constants.py +5 -0
  39. package/hooks/hooks_constants/workflow_substitution_slot_blocker_constants.py +22 -0
  40. package/package.json +1 -1
  41. package/rules/docstring-prose-matches-implementation.md +43 -0
  42. package/rules/hook-prose-matches-detector.md +26 -0
  43. package/rules/no-inline-destructive-literals.md +11 -0
  44. package/rules/workflow-substitution-slots.md +7 -0
  45. package/skills/autoconverge/SKILL.md +13 -2
  46. package/skills/autoconverge/reference/convergence.md +7 -3
  47. package/skills/autoconverge/reference/stop-conditions.md +7 -2
  48. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +265 -0
  49. package/skills/autoconverge/workflow/converge.mjs +106 -36
  50. package/skills/pr-converge/scripts/check_convergence.py +195 -64
  51. package/skills/pr-converge/scripts/test_check_convergence.py +173 -2
  52. package/skills/update/SKILL.md +37 -5
@@ -0,0 +1,150 @@
1
+ #!/usr/bin/env python3
2
+ """PreToolUse hook: block a hook module whose prose overstates its path-shape detector.
3
+
4
+ A path-shape blocker hook detects a per-iteration token only when the token sits
5
+ next to a path separator (its detection regex keys off a `[\\/]`-style character
6
+ class). When such a hook's user-facing prose -- its module docstring lead
7
+ narrative or its CORRECTIVE_MESSAGE -- also claims it blocks an "output-key
8
+ segment", the prose describes a trigger the detector never fires on: a quoted
9
+ structured-output key alone, with no looped path, is never blocked.
10
+
11
+ This drift misleads two audiences at once. An author whose only per-iteration
12
+ token is an output key never sees the block, yet the message implies they would.
13
+ An author who does see the block is told an output key caused it, when only the
14
+ path-adjacent shape did.
15
+
16
+ Detection strategy: act only on Write/Edit to a `.py` file under `hooks/`. The
17
+ prose claim -- the phrase "output-key segment" describing a blocked trigger --
18
+ is the violation. A `*_constants.py` companion holds only the corrective message
19
+ and never the detector, so that file is flagged on the claim alone. Any other
20
+ hook module is flagged when it also keys a detection regex off a path-separator
21
+ character class (a `[...\\...]`/`[.../...]` class), proving the co-located
22
+ detector is path-shape only and the docstring claim overstates it.
23
+
24
+ This detector's own three source files -- the hook module, its `*_constants.py`
25
+ companion, and its `test_*` module -- carry the forbidden phrase and the
26
+ separator-class shape as load-bearing description, so they are exempt by basename
27
+ and stay editable through the harness this rule runs in.
28
+
29
+ Fails OPEN (approves) on malformed input or a non-hook path; the invariant is
30
+ narrow enough that a false negative is preferable to blocking unrelated edits.
31
+ """
32
+
33
+ import json
34
+ import re
35
+ import sys
36
+ from pathlib import Path
37
+
38
+ _hooks_dir = str(Path(__file__).resolve().parent.parent)
39
+ if _hooks_dir not in sys.path:
40
+ sys.path.insert(0, _hooks_dir)
41
+
42
+ from hooks_constants.hook_prose_detector_consistency_constants import ( # noqa: E402
43
+ CONSTANTS_MODULE_SUFFIX,
44
+ CORRECTIVE_MESSAGE,
45
+ EDIT_TOOL_NAME,
46
+ HOOK_MODULE_PATH_SEGMENT,
47
+ OVERSTATED_OUTPUT_KEY_PHRASE_PATTERN,
48
+ PATH_SEPARATOR_CLASS_PATTERN,
49
+ PYTHON_FILE_SUFFIX,
50
+ TEST_MODULE_PREFIX,
51
+ WRITE_TOOL_NAME,
52
+ )
53
+
54
+
55
+ def written_content(tool_name: str, all_tool_input: dict[str, object]) -> str:
56
+ if tool_name == WRITE_TOOL_NAME:
57
+ content = all_tool_input.get("content", "")
58
+ return content if isinstance(content, str) else ""
59
+ if tool_name == EDIT_TOOL_NAME:
60
+ new_string = all_tool_input.get("new_string", "")
61
+ return new_string if isinstance(new_string, str) else ""
62
+ return ""
63
+
64
+
65
+ def target_path(all_tool_input: dict[str, object]) -> str:
66
+ file_path = all_tool_input.get("file_path", "")
67
+ return file_path if isinstance(file_path, str) else ""
68
+
69
+
70
+ def is_hook_python_module(file_path: str) -> bool:
71
+ normalized_path = file_path.replace("\\", "/")
72
+ if not normalized_path.endswith(PYTHON_FILE_SUFFIX):
73
+ return False
74
+ return HOOK_MODULE_PATH_SEGMENT in normalized_path
75
+
76
+
77
+ def is_constants_module(file_path: str) -> bool:
78
+ normalized_path = file_path.replace("\\", "/")
79
+ return normalized_path.endswith(CONSTANTS_MODULE_SUFFIX)
80
+
81
+
82
+ def is_own_detector_family(file_path: str) -> bool:
83
+ own_module_stem = Path(__file__).stem
84
+ own_family_basenames = {
85
+ f"{own_module_stem}{PYTHON_FILE_SUFFIX}",
86
+ f"{own_module_stem}{CONSTANTS_MODULE_SUFFIX}",
87
+ f"{TEST_MODULE_PREFIX}{own_module_stem}{PYTHON_FILE_SUFFIX}",
88
+ }
89
+ normalized_path = file_path.replace("\\", "/")
90
+ edited_basename = normalized_path.rsplit("/", 1)[-1]
91
+ return edited_basename in own_family_basenames
92
+
93
+
94
+ def detects_only_path_shape(content: str) -> bool:
95
+ separator_class_pattern = re.compile(PATH_SEPARATOR_CLASS_PATTERN)
96
+ return bool(separator_class_pattern.search(content))
97
+
98
+
99
+ def claims_output_key_trigger(content: str) -> bool:
100
+ overstated_phrase_pattern = re.compile(OVERSTATED_OUTPUT_KEY_PHRASE_PATTERN, re.IGNORECASE)
101
+ return bool(overstated_phrase_pattern.search(content))
102
+
103
+
104
+ def content_has_violation(content: str, file_path: str) -> bool:
105
+ if is_own_detector_family(file_path):
106
+ return False
107
+ if not claims_output_key_trigger(content):
108
+ return False
109
+ if is_constants_module(file_path):
110
+ return True
111
+ return detects_only_path_shape(content)
112
+
113
+
114
+ def main() -> None:
115
+ try:
116
+ hook_input = json.load(sys.stdin)
117
+ except json.JSONDecodeError:
118
+ sys.exit(0)
119
+
120
+ tool_name = hook_input.get("tool_name", "")
121
+ if tool_name not in (WRITE_TOOL_NAME, EDIT_TOOL_NAME):
122
+ sys.exit(0)
123
+
124
+ all_tool_input = hook_input.get("tool_input", {})
125
+ if not isinstance(all_tool_input, dict):
126
+ sys.exit(0)
127
+
128
+ edited_path = target_path(all_tool_input)
129
+ if not is_hook_python_module(edited_path):
130
+ sys.exit(0)
131
+
132
+ if not content_has_violation(
133
+ written_content(tool_name, all_tool_input), edited_path
134
+ ):
135
+ sys.exit(0)
136
+
137
+ deny_payload = {
138
+ "hookSpecificOutput": {
139
+ "hookEventName": "PreToolUse",
140
+ "permissionDecision": "deny",
141
+ "permissionDecisionReason": CORRECTIVE_MESSAGE,
142
+ }
143
+ }
144
+ print(json.dumps(deny_payload))
145
+ sys.stdout.flush()
146
+ sys.exit(0)
147
+
148
+
149
+ if __name__ == "__main__":
150
+ main()
@@ -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()