claude-dev-env 1.58.0 → 1.60.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-b-selector-engine-compat.md +1 -1
- package/audit-rubrics/category_rubrics/category-e-dead-code.md +1 -0
- package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
- package/audit-rubrics/prompts/category-b-selector-engine-compat.md +2 -2
- package/bin/install.mjs +100 -27
- package/bin/install.test.mjs +133 -1
- 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_dead_module_constant.py +321 -0
- package/hooks/blocking/code_rules_duplicate_body.py +439 -0
- package/hooks/blocking/code_rules_enforcer.py +190 -21
- package/hooks/blocking/code_rules_magic_values.py +98 -0
- package/hooks/blocking/code_rules_shared.py +41 -0
- package/hooks/blocking/code_rules_typeddict_stub.py +172 -0
- package/hooks/blocking/config/__init__.py +5 -0
- package/hooks/blocking/config/verified_commit_constants.py +106 -0
- package/hooks/blocking/destructive_command_blocker.py +1027 -12
- package/hooks/blocking/hook_prose_detector_consistency.py +150 -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_cross_skill_duplicate.py +146 -0
- package/hooks/blocking/test_code_rules_enforcer_dead_dataclass_field.py +467 -0
- package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +188 -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_code_rules_enforcer_zero_payload_alias.py +415 -0
- package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +156 -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_subprocess_budget_completeness.py +588 -0
- package/hooks/blocking/test_verdict_directory_write_blocker.py +720 -0
- package/hooks/blocking/test_verification_verdict_store.py +278 -0
- package/hooks/blocking/test_verified_commit_gate.py +368 -0
- package/hooks/blocking/test_verified_commit_message_accuracy_blocker.py +131 -0
- package/hooks/blocking/test_verifier_verdict_minter.py +214 -0
- package/hooks/blocking/test_workflow_substitution_slot_blocker.py +242 -0
- package/hooks/blocking/verdict_directory_write_blocker.py +667 -0
- package/hooks/blocking/verification_verdict_store.py +446 -0
- package/hooks/blocking/verified_commit_gate.py +523 -0
- package/hooks/blocking/verified_commit_message_accuracy_blocker.py +152 -0
- package/hooks/blocking/verifier_verdict_minter.py +299 -0
- package/hooks/blocking/workflow_substitution_slot_blocker.py +159 -0
- package/hooks/diagnostic/test_hook_log_extractor.py +3 -3
- package/hooks/hooks.json +58 -1
- package/hooks/hooks_constants/blocking_check_limits.py +1 -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/dead_module_constant_constants.py +20 -0
- package/hooks/hooks_constants/destructive_command_segment_constants.py +178 -0
- package/hooks/hooks_constants/duplicate_function_body_constants.py +34 -0
- package/hooks/hooks_constants/hook_prose_detector_consistency_constants.py +30 -0
- package/hooks/hooks_constants/precommit_code_rules_gate_constants.py +1 -1
- package/hooks/hooks_constants/subprocess_budget_completeness_constants.py +5 -0
- package/hooks/hooks_constants/workflow_substitution_slot_blocker_constants.py +22 -0
- package/package.json +1 -1
- package/rules/docstring-prose-matches-implementation.md +43 -0
- package/rules/file-global-constants.md +7 -1
- package/rules/hook-prose-matches-detector.md +26 -0
- package/rules/no-cross-skill-duplicate-helpers.md +29 -0
- package/rules/no-inline-destructive-literals.md +11 -0
- package/rules/workflow-substitution-slots.md +7 -0
- package/skills/_shared/pr-loop/scripts/preflight_worktree.py +392 -0
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/preflight_constants.py +70 -0
- package/skills/_shared/pr-loop/scripts/test_preflight_worktree.py +263 -0
- package/skills/autoconverge/SKILL.md +67 -19
- package/skills/autoconverge/reference/closing-report.md +59 -17
- package/skills/autoconverge/reference/convergence.md +7 -3
- package/skills/autoconverge/reference/stop-conditions.md +7 -2
- package/skills/autoconverge/workflow/aggregate_runs.py +371 -0
- package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +193 -76
- package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +76 -0
- package/skills/autoconverge/workflow/converge.contract.test.mjs +206 -206
- package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +265 -0
- package/skills/autoconverge/workflow/converge.mjs +234 -42
- package/skills/autoconverge/workflow/convergence_summary.py +110 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-ab1c2d3e4f5a6b7c8.jsonl +2 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/workflows/wf_881252e6-700.json +7 -0
- package/skills/autoconverge/workflow/render_report.py +488 -397
- package/skills/autoconverge/workflow/test_aggregate_runs.py +134 -0
- package/skills/autoconverge/workflow/test_convergence_summary.py +132 -0
- package/skills/autoconverge/workflow/test_render_report.py +488 -259
- package/skills/pr-converge/reference/per-tick.md +28 -8
- package/skills/pr-converge/scripts/check_convergence.py +195 -64
- package/skills/pr-converge/scripts/test_check_convergence.py +173 -2
- package/skills/rebase/SKILL.md +2 -4
- package/skills/update/SKILL.md +37 -5
- package/system-prompts/software-engineer.xml +2 -6
- package/hooks/blocking/content_search_to_zoekt_redirector.py +0 -59
- package/hooks/blocking/content_search_zoekt_bash_block_reason.py +0 -25
- package/hooks/blocking/content_search_zoekt_block_payload.py +0 -21
- package/hooks/blocking/content_search_zoekt_indexed_paths.py +0 -24
- package/hooks/blocking/content_search_zoekt_indexed_roots_config.py +0 -131
- package/hooks/blocking/content_search_zoekt_redirect_guidance.py +0 -52
- package/hooks/blocking/test_content_search_to_zoekt_redirector_integration.py +0 -61
- package/hooks/blocking/test_content_search_to_zoekt_redirector_unit.py +0 -92
- package/hooks/blocking/test_content_search_zoekt_indexed_roots_config.py +0 -102
|
@@ -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()
|