claude-dev-env 2.29.0 → 2.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/agents/pr-description-writer.md +21 -6
- package/.agents/agents/reference/README.md +6 -0
- package/.agents/agents/reference/pr-description-illustrative-voice.md +45 -0
- package/.agents/agents/reference/pr-description-verification.md +19 -0
- package/.agents/agents/test_pr_description_writer_style.py +47 -0
- package/_shared/pr-loop/code-rules-gate.md +1 -1
- package/_shared/pr-loop/scripts/code_rules_gate.py +2 -2
- package/_shared/pr-loop/scripts/code_rules_gate_parts/enforcer_loading.py +8 -8
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_enforcer_loading.py +2 -2
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_gate_running.py +1 -1
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +15 -15
- package/docs/CODE_RULES.md +10 -0
- package/hooks/blocking/code_rules_enforcer.py +433 -313
- package/hooks/blocking/test_code_rules_enforcer_banned_noun_word.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_class_docstring_methods.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_dispatch_wiring.py +117 -22
- package/hooks/blocking/test_code_rules_enforcer_docstring_args_signature.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_docstring_prose_wall_illustration.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_docstring_runon_sentence.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_docstring_unreferenced_param.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_fstring_scan.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_ignored_must_check_return.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_inline_tuple_string_magic.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_join_separator_magic.py +2 -2
- package/hooks/blocking/test_code_rules_enforcer_js_returns_object.py +3 -3
- package/hooks/blocking/test_code_rules_enforcer_module_docstring_roster.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_naming_pattern.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +117 -38
- package/hooks/blocking/test_code_rules_enforcer_output_snapshot.py +121 -0
- package/hooks/blocking/test_code_rules_enforcer_precheck_forecast.py +17 -15
- package/hooks/blocking/test_code_rules_enforcer_split_entry_1.py +4 -2
- package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_stage.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_string_magic.py +4 -4
- package/hooks/blocking/test_code_rules_enforcer_thin_wrapper_files.py +2 -2
- package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias.py +2 -2
- package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +2 -2
- package/hooks/blocking/test_code_rules_validation_phase.py +281 -0
- package/hooks/blocking/test_fable_spawn_gate.py +1 -1
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +26 -11
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +8 -0
- package/hooks/hooks_constants/test_code_rules_enforcer_constants.py +21 -0
- package/hooks/hooks_constants/validation_phase_constants.py +34 -0
- package/hooks/validators/README.md +1 -1
- package/hooks/validators/config/hook_timing_harness_constants.py +16 -0
- package/hooks/validators/hook_timing_harness.py +287 -0
- package/hooks/validators/test_hook_timing_harness.py +251 -0
- package/package.json +1 -1
|
@@ -17,12 +17,17 @@ if str(_HOOKS_ROOT) not in sys.path:
|
|
|
17
17
|
sys.path.insert(0, str(_HOOKS_ROOT))
|
|
18
18
|
|
|
19
19
|
from hooks_constants.code_rules_enforcer_constants import (
|
|
20
|
+
ALL_HOOK_INFRASTRUCTURE_EDIT_LANE_CHECK_NAMES,
|
|
21
|
+
ALL_VALIDATION_PHASES,
|
|
20
22
|
CONFIG_DIRECTORY_SEGMENT,
|
|
21
23
|
CONSTANTS_MODULE_SUFFIX,
|
|
24
|
+
EDIT_LANE_PHASE,
|
|
25
|
+
FULL_GATE_PHASE,
|
|
22
26
|
FUNCTION_WITH_JSDOC_PATTERN,
|
|
23
27
|
JSDOC_RETURNS_STRUCTURED_OBJECT_PROMISE_PATTERN,
|
|
24
28
|
RETURN_CALL_OPENING_PARENTHESIS_PATTERN,
|
|
25
29
|
SCHEMA_OPTIONS_PROPERTY_KEY_PATTERN,
|
|
30
|
+
UNKNOWN_VALIDATION_PHASE_MESSAGE_TEMPLATE,
|
|
26
31
|
)
|
|
27
32
|
|
|
28
33
|
|
|
@@ -98,3 +103,19 @@ def test_schema_property_key_pattern_matches_only_the_options_key() -> None:
|
|
|
98
103
|
def test_constants_module_suffix_and_config_directory_segment_are_defined() -> None:
|
|
99
104
|
assert CONSTANTS_MODULE_SUFFIX == "_constants.py"
|
|
100
105
|
assert CONFIG_DIRECTORY_SEGMENT == "config"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_validation_phase_constants_are_reexported_and_render() -> None:
|
|
109
|
+
assert ALL_VALIDATION_PHASES == {EDIT_LANE_PHASE, FULL_GATE_PHASE}
|
|
110
|
+
rendered_message = UNKNOWN_VALIDATION_PHASE_MESSAGE_TEMPLATE.format(
|
|
111
|
+
phase="bogus_phase", all_phases=sorted(ALL_VALIDATION_PHASES)
|
|
112
|
+
)
|
|
113
|
+
assert "bogus_phase" in rendered_message
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_hook_infrastructure_edit_lane_check_names_are_reexported() -> None:
|
|
117
|
+
assert ALL_HOOK_INFRASTRUCTURE_EDIT_LANE_CHECK_NAMES == {
|
|
118
|
+
"check_same_file_inline_duplicate_body",
|
|
119
|
+
"check_zero_payload_function_alias",
|
|
120
|
+
"check_unanchored_command_dispatch",
|
|
121
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Constants for the validate_content edit-lane and full-gate phase split.
|
|
2
|
+
|
|
3
|
+
The edit lane and the full gate share every check except six that read
|
|
4
|
+
sibling modules from disk. These names identify each phase, name the checks
|
|
5
|
+
the full gate alone runs, name the hook-infrastructure checks the edit lane
|
|
6
|
+
still runs, and template the error for an unrecognized phase.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
EDIT_LANE_PHASE: str = "edit_lane"
|
|
10
|
+
FULL_GATE_PHASE: str = "full_gate"
|
|
11
|
+
ALL_VALIDATION_PHASES: frozenset[str] = frozenset({EDIT_LANE_PHASE, FULL_GATE_PHASE})
|
|
12
|
+
|
|
13
|
+
ALL_FULL_GATE_ONLY_CHECK_NAMES: frozenset[str] = frozenset(
|
|
14
|
+
{
|
|
15
|
+
"check_duplicate_function_body_across_files",
|
|
16
|
+
"advise_cross_skill_duplicate_helper",
|
|
17
|
+
"check_public_function_missing_paired_test",
|
|
18
|
+
"check_test_file_omits_module_public_function",
|
|
19
|
+
"check_orphan_css_classes",
|
|
20
|
+
"check_config_duplicate_path_anchor",
|
|
21
|
+
}
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
ALL_HOOK_INFRASTRUCTURE_EDIT_LANE_CHECK_NAMES: frozenset[str] = frozenset(
|
|
25
|
+
{
|
|
26
|
+
"check_same_file_inline_duplicate_body",
|
|
27
|
+
"check_zero_payload_function_alias",
|
|
28
|
+
"check_unanchored_command_dispatch",
|
|
29
|
+
}
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
UNKNOWN_VALIDATION_PHASE_MESSAGE_TEMPLATE: str = (
|
|
33
|
+
"unrecognized validation phase {phase!r}; expected one of {all_phases}"
|
|
34
|
+
)
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
AST-based Python style checks for code quality enforcement.
|
|
4
4
|
|
|
5
|
-
The checks live in `python_style_checks.py`; the shared source-line splitting and function-discovery helpers they build on live in `python_style_helpers.py`.
|
|
5
|
+
The checks live in `python_style_checks.py`; the shared source-line splitting and function-discovery helpers they build on live in `python_style_helpers.py`. `hook_timing_harness.py` measures real wall-clock time for the Write/Edit hooks `hooks.json` registers, reporting p50 and p95 over a configurable run count.
|
|
6
6
|
|
|
7
7
|
## Checks Implemented
|
|
8
8
|
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Constants for hook_timing_harness.py.
|
|
2
|
+
|
|
3
|
+
Extracted to satisfy the constants-location rule: production constants outside
|
|
4
|
+
``config/`` move here rather than sitting at module scope in the harness.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
DEFAULT_RUN_COUNT = 7
|
|
8
|
+
P50_FRACTION = 0.50
|
|
9
|
+
P95_FRACTION = 0.95
|
|
10
|
+
DEFAULT_TARGET_RELATIVE_PATH = "hooks/blocking/code_rules_shared.py"
|
|
11
|
+
SUBPROCESS_TIMEOUT_SECONDS = 60.0
|
|
12
|
+
ALL_WRITE_EDIT_MATCHER_PATTERNS = frozenset({"Write|Edit", "Write|Edit|MultiEdit"})
|
|
13
|
+
ALL_TIMED_EVENT_NAMES = ("PreToolUse", "PostToolUse")
|
|
14
|
+
RUN_ALL_VALIDATORS_LABEL = "run_all_validators"
|
|
15
|
+
CLAUDE_PLUGIN_ROOT_PLACEHOLDER = "${CLAUDE_PLUGIN_ROOT}"
|
|
16
|
+
PARENT_LEVELS_TO_PACKAGE_ROOT = 2
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""Measure real wall-clock time for the hooks.json-registered Write/Edit hooks.
|
|
2
|
+
|
|
3
|
+
Each timed command is the literal ``hooks.json`` command string with
|
|
4
|
+
``${CLAUDE_PLUGIN_ROOT}`` resolved to the real package root, run as its own
|
|
5
|
+
subprocess against a real repository file, timed over a configurable run
|
|
6
|
+
count, and reported as p50 and p95 milliseconds.
|
|
7
|
+
|
|
8
|
+
::
|
|
9
|
+
|
|
10
|
+
$ python3 hook_timing_harness.py --runs 7
|
|
11
|
+
run_all_validators p50=1948.3ms p95=3020.1ms
|
|
12
|
+
pre_tool_use_dispatcher p50=180.2ms p95=230.4ms
|
|
13
|
+
post_tool_use_dispatcher p50=80.1ms p95=1200.5ms
|
|
14
|
+
session_file_edit_tracker p50=31.0ms p95=34.2ms
|
|
15
|
+
|
|
16
|
+
A target under the OS temp root would return before any validator ran, so this
|
|
17
|
+
harness refuses one. It always runs the whole command line ``hooks.json``
|
|
18
|
+
registers, never a single hosted check module standing in for it, so neither
|
|
19
|
+
shortcut can distort the numbers.
|
|
20
|
+
|
|
21
|
+
The timed payload carries no ``session_id``, so ``session_file_edit_tracker``
|
|
22
|
+
writes its edit record to the default-session file rather than to a
|
|
23
|
+
caller-specific one.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import argparse
|
|
29
|
+
import importlib.util
|
|
30
|
+
import json
|
|
31
|
+
import math
|
|
32
|
+
import re
|
|
33
|
+
import subprocess
|
|
34
|
+
import sys
|
|
35
|
+
import time
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
from types import ModuleType
|
|
38
|
+
from typing import TextIO
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _load_module_from_path(module_name: str, module_path: Path) -> ModuleType:
|
|
42
|
+
"""Load and execute the module found at module_path, under module_name.
|
|
43
|
+
|
|
44
|
+
Loads by absolute path rather than a package-qualified import, so this
|
|
45
|
+
harness resolves its own constants module the same way whether it runs
|
|
46
|
+
standalone (``python3 hook_timing_harness.py``) or loaded by a test —
|
|
47
|
+
neither invocation shape needs ``hooks/validators`` on ``sys.path``.
|
|
48
|
+
"""
|
|
49
|
+
specification = importlib.util.spec_from_file_location(module_name, module_path)
|
|
50
|
+
assert specification is not None
|
|
51
|
+
assert specification.loader is not None
|
|
52
|
+
module = importlib.util.module_from_spec(specification)
|
|
53
|
+
specification.loader.exec_module(module)
|
|
54
|
+
return module
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
_harness_constants = _load_module_from_path(
|
|
58
|
+
"hook_timing_harness_constants",
|
|
59
|
+
Path(__file__).resolve().parent / "config" / "hook_timing_harness_constants.py",
|
|
60
|
+
)
|
|
61
|
+
_system_temporary_roots = _load_module_from_path(
|
|
62
|
+
"hook_timing_harness_system_temporary_roots",
|
|
63
|
+
Path(__file__).resolve().parent / "system_temporary_roots.py",
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _hook_label_for_command(command: str) -> str:
|
|
68
|
+
"""Return the short name a wall-time report row uses for one hook command.
|
|
69
|
+
|
|
70
|
+
::
|
|
71
|
+
|
|
72
|
+
'python3 .../pre_tool_use_dispatcher.py' -> 'pre_tool_use_dispatcher'
|
|
73
|
+
'python3 -c "... run_all_validators ..." ...' -> 'run_all_validators'
|
|
74
|
+
"""
|
|
75
|
+
if _harness_constants.RUN_ALL_VALIDATORS_LABEL in command:
|
|
76
|
+
return _harness_constants.RUN_ALL_VALIDATORS_LABEL
|
|
77
|
+
script_basename_pattern = re.compile(r"([A-Za-z0-9_]+)\.py")
|
|
78
|
+
all_basename_matches = script_basename_pattern.findall(command)
|
|
79
|
+
if not all_basename_matches:
|
|
80
|
+
raise ValueError(f"cannot derive a hook label from command: {command!r}")
|
|
81
|
+
return all_basename_matches[-1]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _commands_in_matcher_group(all_matcher_group: dict[str, object]) -> list[tuple[str, str]]:
|
|
85
|
+
"""Return the (label, command) pairs one hooks.json matcher group registers."""
|
|
86
|
+
if all_matcher_group.get("matcher") not in _harness_constants.ALL_WRITE_EDIT_MATCHER_PATTERNS:
|
|
87
|
+
return []
|
|
88
|
+
all_hosted_hooks = all_matcher_group.get("hooks", [])
|
|
89
|
+
if not isinstance(all_hosted_hooks, list):
|
|
90
|
+
return []
|
|
91
|
+
all_commands: list[tuple[str, str]] = []
|
|
92
|
+
for each_hook in all_hosted_hooks:
|
|
93
|
+
command = each_hook.get("command", "") if isinstance(each_hook, dict) else ""
|
|
94
|
+
all_commands.append((_hook_label_for_command(command), command))
|
|
95
|
+
return all_commands
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def write_edit_hook_commands(hooks_json_path: Path) -> list[tuple[str, str]]:
|
|
99
|
+
"""Return the (label, command) pairs hooks.json registers for Write/Edit.
|
|
100
|
+
|
|
101
|
+
Reads the real ``PreToolUse`` and ``PostToolUse`` sections and keeps only
|
|
102
|
+
the matcher groups that fire on a Write or Edit tool call, so the harness
|
|
103
|
+
times the same roster a live edit exercises rather than every hook in the
|
|
104
|
+
file (session, lifecycle, and Bash-only hooks included).
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
hooks_json_path: Path to the ``hooks.json`` file to read.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
One ``(label, command)`` pair per Write/Edit-matching hook, in the
|
|
111
|
+
file's own declaration order.
|
|
112
|
+
"""
|
|
113
|
+
hooks_document = json.loads(hooks_json_path.read_text(encoding="utf-8"))
|
|
114
|
+
all_commands: list[tuple[str, str]] = []
|
|
115
|
+
for each_event_name in _harness_constants.ALL_TIMED_EVENT_NAMES:
|
|
116
|
+
for each_matcher_group in hooks_document["hooks"].get(each_event_name, []):
|
|
117
|
+
all_commands.extend(_commands_in_matcher_group(each_matcher_group))
|
|
118
|
+
return all_commands
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _is_under_directory(candidate: Path, directory: Path) -> bool:
|
|
122
|
+
"""Return whether *candidate* equals *directory* or sits under it."""
|
|
123
|
+
return candidate == directory or directory in candidate.parents
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def ensure_real_repository_target(
|
|
127
|
+
target_path: Path, repository_root: Path | None = None
|
|
128
|
+
) -> Path:
|
|
129
|
+
"""Return *target_path* resolved, after refusing an ephemeral scratch path.
|
|
130
|
+
|
|
131
|
+
A target under a system temporary root (``tempfile.gettempdir()`` plus any
|
|
132
|
+
set ``TEMP`` / ``TMP`` / ``TMPDIR`` / ``RUNNER_TEMP``) trips
|
|
133
|
+
``is_ephemeral_path`` inside the hooks and returns before any validator
|
|
134
|
+
runs. A target inside *repository_root* is always real, even when the
|
|
135
|
+
checkout itself sits under a system temporary root.
|
|
136
|
+
|
|
137
|
+
Raises:
|
|
138
|
+
ValueError: When the path sits under a system temporary root and
|
|
139
|
+
outside *repository_root*.
|
|
140
|
+
"""
|
|
141
|
+
resolved_target = target_path.resolve()
|
|
142
|
+
if repository_root is not None and _is_under_directory(
|
|
143
|
+
resolved_target, repository_root.resolve()
|
|
144
|
+
):
|
|
145
|
+
return resolved_target
|
|
146
|
+
enclosing_temporary_root = _system_temporary_roots.enclosing_system_temporary_root(
|
|
147
|
+
resolved_target
|
|
148
|
+
)
|
|
149
|
+
if enclosing_temporary_root is None:
|
|
150
|
+
return resolved_target
|
|
151
|
+
raise ValueError(
|
|
152
|
+
f"refusing an ephemeral timing target under {enclosing_temporary_root}: "
|
|
153
|
+
f"{resolved_target}"
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _write_tool_payload(target_path: Path) -> str:
|
|
158
|
+
"""Build a Write-tool PreToolUse/PostToolUse payload naming *target_path*."""
|
|
159
|
+
return json.dumps(
|
|
160
|
+
{
|
|
161
|
+
"tool_name": "Write",
|
|
162
|
+
"tool_input": {
|
|
163
|
+
"file_path": str(target_path),
|
|
164
|
+
"content": target_path.read_text(encoding="utf-8"),
|
|
165
|
+
},
|
|
166
|
+
}
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def run_hosted_command_once_milliseconds(
|
|
171
|
+
command_template: str, package_root: Path, payload_text: str
|
|
172
|
+
) -> float:
|
|
173
|
+
"""Run one registered hooks.json command once and return its wall time.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
command_template: The ``hooks.json`` command string, ``${CLAUDE_PLUGIN_ROOT}``
|
|
177
|
+
still unresolved.
|
|
178
|
+
package_root: The real package root ``${CLAUDE_PLUGIN_ROOT}`` resolves to.
|
|
179
|
+
payload_text: The JSON payload piped to the command's stdin.
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
The wall-clock time the subprocess took, in milliseconds.
|
|
183
|
+
"""
|
|
184
|
+
resolved_command = command_template.replace(
|
|
185
|
+
_harness_constants.CLAUDE_PLUGIN_ROOT_PLACEHOLDER, str(package_root)
|
|
186
|
+
)
|
|
187
|
+
started_at = time.perf_counter()
|
|
188
|
+
subprocess.run(
|
|
189
|
+
resolved_command,
|
|
190
|
+
shell=True,
|
|
191
|
+
input=payload_text,
|
|
192
|
+
capture_output=True,
|
|
193
|
+
text=True,
|
|
194
|
+
timeout=_harness_constants.SUBPROCESS_TIMEOUT_SECONDS,
|
|
195
|
+
check=False,
|
|
196
|
+
)
|
|
197
|
+
finished_at = time.perf_counter()
|
|
198
|
+
return (finished_at - started_at) * 1000.0
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def percentile(
|
|
202
|
+
all_sorted_wall_times_milliseconds: list[float], fraction: float
|
|
203
|
+
) -> float:
|
|
204
|
+
"""Return the nearest-rank percentile of an already-sorted sample.
|
|
205
|
+
|
|
206
|
+
::
|
|
207
|
+
|
|
208
|
+
percentile([10, 20, 30, 40, 50, 60, 70], 0.95) -> 70 (rank 7 of 7)
|
|
209
|
+
percentile([10, 20, 30, 40, 50, 60, 70], 0.50) -> 40 (rank 4 of 7)
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
all_sorted_wall_times_milliseconds: Ascending wall-time samples.
|
|
213
|
+
fraction: The percentile as a fraction between 0 and 1.
|
|
214
|
+
|
|
215
|
+
Returns:
|
|
216
|
+
The nearest-rank percentile value.
|
|
217
|
+
|
|
218
|
+
Raises:
|
|
219
|
+
ValueError: When the sample is empty.
|
|
220
|
+
"""
|
|
221
|
+
if not all_sorted_wall_times_milliseconds:
|
|
222
|
+
raise ValueError("cannot compute a percentile of an empty sample")
|
|
223
|
+
sample_size = len(all_sorted_wall_times_milliseconds)
|
|
224
|
+
rank_index = math.ceil(fraction * sample_size) - 1
|
|
225
|
+
return all_sorted_wall_times_milliseconds[max(0, min(rank_index, sample_size - 1))]
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def measure_hosted_command_wall_times(
|
|
229
|
+
hooks_json_path: Path,
|
|
230
|
+
package_root: Path,
|
|
231
|
+
target_path: Path,
|
|
232
|
+
run_count: int,
|
|
233
|
+
*,
|
|
234
|
+
repository_root: Path | None = None,
|
|
235
|
+
) -> dict[str, list[float]]:
|
|
236
|
+
"""Time every Write/Edit hooks.json command over *run_count* runs each.
|
|
237
|
+
|
|
238
|
+
``repository_root`` is the root ``ensure_real_repository_target`` judges
|
|
239
|
+
ephemerality against; it defaults to ``package_root``.
|
|
240
|
+
"""
|
|
241
|
+
real_target = ensure_real_repository_target(
|
|
242
|
+
target_path, repository_root if repository_root is not None else package_root
|
|
243
|
+
)
|
|
244
|
+
payload_text = _write_tool_payload(real_target)
|
|
245
|
+
all_wall_times_by_label: dict[str, list[float]] = {}
|
|
246
|
+
for each_label, each_command_template in write_edit_hook_commands(hooks_json_path):
|
|
247
|
+
all_wall_times_by_label[each_label] = [
|
|
248
|
+
run_hosted_command_once_milliseconds(each_command_template, package_root, payload_text)
|
|
249
|
+
for _ in range(run_count)
|
|
250
|
+
]
|
|
251
|
+
return all_wall_times_by_label
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _report_line(label: str, all_wall_times_milliseconds: list[float]) -> str:
|
|
255
|
+
sorted_wall_times_milliseconds = sorted(all_wall_times_milliseconds)
|
|
256
|
+
p50 = percentile(sorted_wall_times_milliseconds, _harness_constants.P50_FRACTION)
|
|
257
|
+
p95 = percentile(sorted_wall_times_milliseconds, _harness_constants.P95_FRACTION)
|
|
258
|
+
return f"{label:<26} p50={p50:.1f}ms p95={p95:.1f}ms"
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def main(
|
|
262
|
+
all_arguments: list[str],
|
|
263
|
+
report_stream: TextIO = sys.stdout,
|
|
264
|
+
repository_root: Path | None = None,
|
|
265
|
+
) -> int:
|
|
266
|
+
"""Run the harness CLI; report_stream and repository_root are injectable seams."""
|
|
267
|
+
package_root = Path(__file__).resolve().parents[
|
|
268
|
+
_harness_constants.PARENT_LEVELS_TO_PACKAGE_ROOT
|
|
269
|
+
]
|
|
270
|
+
hooks_json_path = Path(__file__).resolve().parent.parent / "hooks.json"
|
|
271
|
+
default_target_path = package_root / _harness_constants.DEFAULT_TARGET_RELATIVE_PATH
|
|
272
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
273
|
+
parser.add_argument("--runs", type=int, default=_harness_constants.DEFAULT_RUN_COUNT)
|
|
274
|
+
parser.add_argument("--target", type=Path, default=default_target_path)
|
|
275
|
+
arguments = parser.parse_args(all_arguments)
|
|
276
|
+
effective_repository_root = repository_root if repository_root is not None else package_root
|
|
277
|
+
all_wall_times_by_label = measure_hosted_command_wall_times(
|
|
278
|
+
hooks_json_path, package_root, arguments.target, arguments.runs,
|
|
279
|
+
repository_root=effective_repository_root,
|
|
280
|
+
)
|
|
281
|
+
for each_label, each_wall_times in all_wall_times_by_label.items():
|
|
282
|
+
report_stream.write(_report_line(each_label, each_wall_times) + "\n")
|
|
283
|
+
return 0
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
if __name__ == "__main__":
|
|
287
|
+
sys.exit(main(sys.argv[1:]))
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""Tests for hook_timing_harness.py."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.util
|
|
6
|
+
import io
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from types import ModuleType
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _load_module_from_path(module_name: str, module_path: Path) -> ModuleType:
|
|
16
|
+
"""Load and execute the module found at module_path, under module_name."""
|
|
17
|
+
specification = importlib.util.spec_from_file_location(module_name, module_path)
|
|
18
|
+
assert specification is not None
|
|
19
|
+
assert specification.loader is not None
|
|
20
|
+
module = importlib.util.module_from_spec(specification)
|
|
21
|
+
specification.loader.exec_module(module)
|
|
22
|
+
return module
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
_VALIDATORS_DIRECTORY = Path(__file__).resolve().parent
|
|
26
|
+
_PACKAGE_ROOT = _VALIDATORS_DIRECTORY.parents[1]
|
|
27
|
+
_HOOKS_JSON_PATH = _VALIDATORS_DIRECTORY.parent / "hooks.json"
|
|
28
|
+
_REAL_TARGET_FILE = _PACKAGE_ROOT / "hooks" / "blocking" / "code_rules_shared.py"
|
|
29
|
+
|
|
30
|
+
if str(_VALIDATORS_DIRECTORY) not in sys.path:
|
|
31
|
+
sys.path.insert(0, str(_VALIDATORS_DIRECTORY))
|
|
32
|
+
hook_timing_harness = _load_module_from_path(
|
|
33
|
+
"hook_timing_harness", _VALIDATORS_DIRECTORY / "hook_timing_harness.py"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_hook_label_for_command_names_run_all_validators() -> None:
|
|
38
|
+
command = (
|
|
39
|
+
'python3 -c "import sys; from validators.run_all_validators import main; '
|
|
40
|
+
'sys.exit(main())" --pre-tool-use'
|
|
41
|
+
)
|
|
42
|
+
assert hook_timing_harness._hook_label_for_command(command) == "run_all_validators"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_hook_label_for_command_names_the_script_basename() -> None:
|
|
46
|
+
command = "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/pre_tool_use_dispatcher.py"
|
|
47
|
+
assert hook_timing_harness._hook_label_for_command(command) == "pre_tool_use_dispatcher"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_write_edit_hook_commands_reads_the_real_hooks_json_roster() -> None:
|
|
51
|
+
all_commands = hook_timing_harness.write_edit_hook_commands(_HOOKS_JSON_PATH)
|
|
52
|
+
all_labels = [each_label for each_label, _each_command in all_commands]
|
|
53
|
+
assert all_labels == [
|
|
54
|
+
"run_all_validators",
|
|
55
|
+
"pre_tool_use_dispatcher",
|
|
56
|
+
"post_tool_use_dispatcher",
|
|
57
|
+
"session_file_edit_tracker",
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_percentile_returns_the_nearest_rank_value() -> None:
|
|
62
|
+
sample = [10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0]
|
|
63
|
+
assert hook_timing_harness.percentile(sample, 0.50) == 40.0
|
|
64
|
+
assert hook_timing_harness.percentile(sample, 0.95) == 70.0
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_percentile_raises_on_an_empty_sample() -> None:
|
|
68
|
+
with pytest.raises(ValueError):
|
|
69
|
+
hook_timing_harness.percentile([], 0.50)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_ensure_real_repository_target_refuses_an_os_temp_path(
|
|
73
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
74
|
+
) -> None:
|
|
75
|
+
monkeypatch.setenv("TMPDIR", str(tmp_path))
|
|
76
|
+
ephemeral_target = Path(tempfile.gettempdir()) / "scratch.py"
|
|
77
|
+
with pytest.raises(ValueError, match="ephemeral"):
|
|
78
|
+
hook_timing_harness.ensure_real_repository_target(ephemeral_target)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_ensure_real_repository_target_accepts_a_real_repository_file() -> None:
|
|
82
|
+
resolved = hook_timing_harness.ensure_real_repository_target(
|
|
83
|
+
_REAL_TARGET_FILE, _PACKAGE_ROOT
|
|
84
|
+
)
|
|
85
|
+
assert resolved == _REAL_TARGET_FILE.resolve()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_ensure_real_repository_target_accepts_a_repo_file_under_the_os_temp_root(
|
|
89
|
+
tmp_path: Path,
|
|
90
|
+
) -> None:
|
|
91
|
+
"""A checkout placed under the OS temp root still accepts its own real files.
|
|
92
|
+
|
|
93
|
+
Ephemerality is judged relative to the given repository root, not the OS
|
|
94
|
+
temp root alone, so a checkout that itself sits under the OS temp root (a
|
|
95
|
+
common sandbox layout) does not misclassify every real file inside it as
|
|
96
|
+
a throwaway scratch target.
|
|
97
|
+
"""
|
|
98
|
+
repository_root = tmp_path / "repo"
|
|
99
|
+
real_file = repository_root / "hooks" / "blocking" / "code_rules_shared.py"
|
|
100
|
+
real_file.parent.mkdir(parents=True)
|
|
101
|
+
real_file.write_text("x = 1\n", encoding="utf-8")
|
|
102
|
+
|
|
103
|
+
resolved = hook_timing_harness.ensure_real_repository_target(real_file, repository_root)
|
|
104
|
+
|
|
105
|
+
assert resolved == real_file.resolve()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_ensure_real_repository_target_refuses_a_scratch_path_outside_the_repository_root(
|
|
109
|
+
tmp_path: Path,
|
|
110
|
+
) -> None:
|
|
111
|
+
repository_root = tmp_path / "repo"
|
|
112
|
+
repository_root.mkdir()
|
|
113
|
+
scratch_target = tmp_path / "scratch.py"
|
|
114
|
+
|
|
115
|
+
with pytest.raises(ValueError, match="ephemeral"):
|
|
116
|
+
hook_timing_harness.ensure_real_repository_target(scratch_target, repository_root)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_ensure_real_repository_target_refuses_a_path_under_runner_temp_alone(
|
|
120
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
121
|
+
) -> None:
|
|
122
|
+
"""GitHub Actions puts pytest basetemp under RUNNER_TEMP, not gettempdir().
|
|
123
|
+
|
|
124
|
+
``gettempdir()`` stays ``/tmp`` on the hosted runner while pytest's own
|
|
125
|
+
``tmp_path`` lives under ``RUNNER_TEMP`` (``/home/runner/work/_temp``), a
|
|
126
|
+
directory ``gettempdir()`` never reports. A refusal that judges
|
|
127
|
+
ephemerality against ``gettempdir()`` alone misses that target and reads
|
|
128
|
+
the runner's own scratch file as a real repository file.
|
|
129
|
+
"""
|
|
130
|
+
fake_os_temporary_root = tmp_path / "os-temp"
|
|
131
|
+
fake_os_temporary_root.mkdir()
|
|
132
|
+
monkeypatch.setattr(tempfile, "gettempdir", lambda: str(fake_os_temporary_root))
|
|
133
|
+
runner_temporary_root = tmp_path / "runner-temp"
|
|
134
|
+
monkeypatch.setenv("RUNNER_TEMP", str(runner_temporary_root))
|
|
135
|
+
ephemeral_target = runner_temporary_root / "scratch.py"
|
|
136
|
+
|
|
137
|
+
with pytest.raises(ValueError, match="ephemeral"):
|
|
138
|
+
hook_timing_harness.ensure_real_repository_target(ephemeral_target)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_run_hosted_command_once_milliseconds_drives_the_real_tracker() -> None:
|
|
142
|
+
"""A live subprocess run against a real repository file measures a positive time.
|
|
143
|
+
|
|
144
|
+
::
|
|
145
|
+
|
|
146
|
+
session_file_edit_tracker is the fastest hosted command, so this is the
|
|
147
|
+
cheap live end-to-end proof: it drives the actual registered
|
|
148
|
+
``hooks.json`` command line, not a standalone hosted check module.
|
|
149
|
+
"""
|
|
150
|
+
all_commands = hook_timing_harness.write_edit_hook_commands(_HOOKS_JSON_PATH)
|
|
151
|
+
tracker_command = next(
|
|
152
|
+
each_command
|
|
153
|
+
for each_label, each_command in all_commands
|
|
154
|
+
if each_label == "session_file_edit_tracker"
|
|
155
|
+
)
|
|
156
|
+
payload_text = hook_timing_harness._write_tool_payload(_REAL_TARGET_FILE)
|
|
157
|
+
elapsed_milliseconds = hook_timing_harness.run_hosted_command_once_milliseconds(
|
|
158
|
+
tracker_command, _PACKAGE_ROOT, payload_text
|
|
159
|
+
)
|
|
160
|
+
assert 0.0 < elapsed_milliseconds < 5000.0
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_measure_hosted_command_wall_times_covers_the_real_roster() -> None:
|
|
164
|
+
"""A live run against the real hooks.json roster returns all four hooks.
|
|
165
|
+
|
|
166
|
+
Runs each registered command once against a real repository file,
|
|
167
|
+
including ``run_all_validators``, so this is the one place the suite pays
|
|
168
|
+
its full wall time to prove the roster end to end.
|
|
169
|
+
"""
|
|
170
|
+
all_wall_times_by_label = hook_timing_harness.measure_hosted_command_wall_times(
|
|
171
|
+
_HOOKS_JSON_PATH, _PACKAGE_ROOT, _REAL_TARGET_FILE, 1
|
|
172
|
+
)
|
|
173
|
+
assert set(all_wall_times_by_label) == {
|
|
174
|
+
"run_all_validators",
|
|
175
|
+
"pre_tool_use_dispatcher",
|
|
176
|
+
"post_tool_use_dispatcher",
|
|
177
|
+
"session_file_edit_tracker",
|
|
178
|
+
}
|
|
179
|
+
assert all(
|
|
180
|
+
each_time > 0.0
|
|
181
|
+
for each_times in all_wall_times_by_label.values()
|
|
182
|
+
for each_time in each_times
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def test_measure_hosted_command_wall_times_honors_a_repository_root_override(
|
|
187
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
188
|
+
) -> None:
|
|
189
|
+
"""The wrapper forwards repository_root to the delegate, not just accepts it.
|
|
190
|
+
|
|
191
|
+
``ensure_real_repository_target`` already accepts ``repository_root``, but
|
|
192
|
+
until the wrapper's own signature exposes and forwards it, no caller of
|
|
193
|
+
``measure_hosted_command_wall_times`` can reach that parameter — the exact
|
|
194
|
+
gap the wrapper-plumb-through check caught.
|
|
195
|
+
"""
|
|
196
|
+
monkeypatch.setattr(hook_timing_harness, "write_edit_hook_commands", lambda path: [])
|
|
197
|
+
repository_root = tmp_path / "repo"
|
|
198
|
+
real_file = repository_root / "hooks" / "blocking" / "code_rules_shared.py"
|
|
199
|
+
real_file.parent.mkdir(parents=True)
|
|
200
|
+
real_file.write_text("x = 1\n", encoding="utf-8")
|
|
201
|
+
|
|
202
|
+
with pytest.raises(ValueError, match="ephemeral"):
|
|
203
|
+
hook_timing_harness.measure_hosted_command_wall_times(
|
|
204
|
+
_HOOKS_JSON_PATH, _PACKAGE_ROOT, real_file, 1
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
all_wall_times_by_label = hook_timing_harness.measure_hosted_command_wall_times(
|
|
208
|
+
_HOOKS_JSON_PATH, _PACKAGE_ROOT, real_file, 1, repository_root=repository_root
|
|
209
|
+
)
|
|
210
|
+
assert all_wall_times_by_label == {}
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def test_main_writes_one_report_line_per_label(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
214
|
+
fake_wall_times_by_label = {"pre_tool_use_dispatcher": [100.0, 200.0]}
|
|
215
|
+
monkeypatch.setattr(
|
|
216
|
+
hook_timing_harness,
|
|
217
|
+
"measure_hosted_command_wall_times",
|
|
218
|
+
lambda *args, **kwargs: fake_wall_times_by_label,
|
|
219
|
+
)
|
|
220
|
+
report_stream = io.StringIO()
|
|
221
|
+
exit_code = hook_timing_harness.main(["--runs", "2"], report_stream=report_stream)
|
|
222
|
+
report_text = report_stream.getvalue()
|
|
223
|
+
assert exit_code == 0
|
|
224
|
+
assert "pre_tool_use_dispatcher" in report_text
|
|
225
|
+
assert "p50=" in report_text and "p95=" in report_text
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def test_main_forwards_its_own_repository_root_to_the_wrapper(
|
|
229
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
230
|
+
) -> None:
|
|
231
|
+
"""main() forwards a caller-supplied repository_root, not just accepts it.
|
|
232
|
+
|
|
233
|
+
Mirrors the wrapper-level gap: adding ``repository_root`` to
|
|
234
|
+
``measure_hosted_command_wall_times`` alone leaves ``main`` as a second
|
|
235
|
+
wrapper dropping the same optional keyword.
|
|
236
|
+
"""
|
|
237
|
+
all_captured_kwargs: dict[str, object] = {}
|
|
238
|
+
|
|
239
|
+
def _spy_measure_hosted_command_wall_times(*args: object, **kwargs: object) -> dict[str, list[float]]:
|
|
240
|
+
all_captured_kwargs.update(kwargs)
|
|
241
|
+
return {}
|
|
242
|
+
|
|
243
|
+
monkeypatch.setattr(
|
|
244
|
+
hook_timing_harness,
|
|
245
|
+
"measure_hosted_command_wall_times",
|
|
246
|
+
_spy_measure_hosted_command_wall_times,
|
|
247
|
+
)
|
|
248
|
+
override_root = tmp_path / "repo"
|
|
249
|
+
exit_code = hook_timing_harness.main([], repository_root=override_root)
|
|
250
|
+
assert exit_code == 0
|
|
251
|
+
assert all_captured_kwargs.get("repository_root") == override_root
|