claude-dev-env 2.28.1 → 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/AGENTS.md +0 -31
- package/.agents/agents/clean-coder.md +1 -1
- 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_agent_frontmatter.py +3 -1
- package/.agents/agents/test_pr_description_writer_style.py +47 -0
- package/.agents/skills/AGENTS.md +0 -70
- package/.agents/skills/_shared/AGENTS.md +0 -44
- package/.agents/skills/_shared/advisor/AGENTS.md +0 -8
- package/.agents/skills/_shared/pr-loop/AGENTS.md +0 -57
- package/.agents/skills/_shared/pr-loop/prompts/AGENTS.md +0 -8
- package/.agents/skills/_shared/pr-loop/scripts/AGENTS.md +0 -34
- package/.agents/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/AGENTS.md +0 -23
- package/.agents/skills/autoconverge/AGENTS.md +0 -35
- package/.agents/skills/autoconverge/reference/AGENTS.md +0 -15
- package/.agents/skills/autoconverge/workflow/AGENTS.md +0 -25
- package/.agents/skills/autoconverge/workflow/autoconverge_report_constants/AGENTS.md +0 -15
- package/.agents/skills/everything-search/AGENTS.md +0 -16
- package/.agents/skills/fresh-branch/AGENTS.md +0 -13
- package/.agents/skills/grok-spawn/AGENTS.md +0 -27
- package/.agents/skills/orchestrator/.claude/CLAUDE.md +1 -0
- package/.agents/skills/orchestrator/AGENTS.md +1 -0
- package/.agents/skills/orchestrator/SKILL.md +44 -53
- package/.agents/skills/orchestrator/reference/.claude/CLAUDE.md +1 -0
- package/.agents/skills/orchestrator/reference/AGENTS.md +1 -0
- package/.agents/skills/orchestrator/reference/consult-the-orchestrator.md +70 -0
- package/.agents/skills/orchestrator/reference/executor-consult-block.md +62 -0
- package/.agents/skills/orchestrator/reference/host-detect.md +15 -0
- package/.agents/skills/orchestrator/test_orchestrator_skill_contract.py +62 -0
- package/.agents/skills/orchestrator-refresh/SKILL.md +16 -34
- package/.agents/skills/rebase/AGENTS.md +0 -31
- package/.agents/skills/session-log/AGENTS.md +0 -31
- package/.agents/skills/session-tidy/AGENTS.md +0 -35
- package/.agents/skills/skill-builder/AGENTS.md +0 -48
- package/.agents/skills/skill-builder/references/AGENTS.md +0 -24
- package/.agents/skills/skill-builder/templates/AGENTS.md +0 -13
- package/.agents/skills/skill-builder/workflows/AGENTS.md +0 -18
- package/.agents/skills/task-build/AGENTS.md +0 -28
- package/.agents/skills/update/AGENTS.md +0 -37
- package/.agents/skills-archived/AGENTS.md +0 -44
- package/.agents/skills-archived/anthropic-plan/AGENTS.md +0 -33
- package/.agents/skills-archived/anthropic-plan/scripts/AGENTS.md +0 -10
- package/.agents/skills-archived/anthropic-plan/scripts/anthropic_plan_scripts_constants/AGENTS.md +0 -15
- package/.agents/skills-archived/anthropic-plan/templates/AGENTS.md +0 -12
- package/.agents/skills-archived/anthropic-plan/workflow/AGENTS.md +0 -13
- package/.agents/skills-archived/auditing-claude-config/AGENTS.md +0 -20
- package/.agents/skills-archived/bugteam/AGENTS.md +0 -29
- package/.agents/skills-archived/bugteam/reference/AGENTS.md +0 -19
- package/.agents/skills-archived/bugteam/reference/obstacles/AGENTS.md +0 -23
- package/.agents/skills-archived/bugteam/scripts/AGENTS.md +0 -29
- package/.agents/skills-archived/bugteam/scripts/bugteam_scripts_constants/AGENTS.md +0 -17
- package/.agents/skills-archived/codex-review/AGENTS.md +0 -45
- package/.agents/skills-archived/codex-review/reference/AGENTS.md +0 -14
- package/.agents/skills-archived/codex-review/scripts/codex_review_scripts_constants/AGENTS.md +0 -17
- package/.agents/skills-archived/codex-review/test_skill_scaffold.py +13 -3
- package/.agents/skills-archived/copilot-review/AGENTS.md +0 -17
- package/.agents/skills-archived/pr-converge/AGENTS.md +0 -31
- package/.agents/skills-archived/pr-converge/pr_converge_skill_constants/AGENTS.md +0 -25
- package/.agents/skills-archived/pr-converge/reference/AGENTS.md +0 -27
- package/.agents/skills-archived/pr-converge/reference/obstacles/AGENTS.md +0 -22
- package/.agents/skills-archived/pr-converge/scripts/AGENTS.md +0 -45
- package/.agents/skills-archived/pr-converge/scripts/pr_converge_scripts_constants/AGENTS.md +0 -17
- package/.agents/skills-archived/pr-converge/workflows/AGENTS.md +0 -15
- package/.agents/skills-archived/recall/AGENTS.md +0 -29
- package/.agents/skills-archived/remember/AGENTS.md +0 -30
- package/AGENTS.md +0 -112
- package/_shared/AGENTS.md +0 -16
- package/_shared/advisor/AGENTS.md +0 -21
- package/_shared/advisor/advisor-protocol.md +4 -1
- package/_shared/advisor/reference/consult-format.md +4 -2
- package/_shared/pr-loop/AGENTS.md +0 -27
- package/_shared/pr-loop/code-rules-gate.md +1 -1
- package/_shared/pr-loop/scripts/AGENTS.md +0 -49
- package/_shared/pr-loop/scripts/code_rules_gate.py +2 -2
- package/_shared/pr-loop/scripts/code_rules_gate_parts/AGENTS.md +0 -41
- 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/codex_review_scripts_constants/AGENTS.md +0 -17
- package/_shared/pr-loop/scripts/pr_converge_scripts_constants/AGENTS.md +0 -17
- package/_shared/pr-loop/scripts/pr_converge_skill_constants/AGENTS.md +0 -25
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/AGENTS.md +0 -25
- package/_shared/pr-loop/scripts/tests/AGENTS.md +0 -43
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +15 -15
- package/_shared/process-tree/AGENTS.md +0 -40
- package/audit-rubrics/AGENTS.md +0 -42
- package/audit-rubrics/category_rubrics/AGENTS.md +0 -36
- package/audit-rubrics/prompts/AGENTS.md +0 -36
- package/bin/AGENTS.md +0 -118
- package/bin/install.test.mjs +1 -1
- package/commands/AGENTS.md +0 -14
- package/docs/AGENTS.md +0 -31
- package/docs/CODE_RULES.md +10 -0
- package/docs/references/AGENTS.md +0 -16
- package/hooks/AGENTS.md +0 -27
- package/hooks/advisory/AGENTS.md +0 -15
- package/hooks/blocking/AGENTS.md +0 -112
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/AGENTS.md +0 -27
- package/hooks/blocking/code_rules_enforcer.py +433 -313
- package/hooks/blocking/config/AGENTS.md +0 -9
- package/hooks/blocking/inventory_intent_records/AGENTS.md +0 -25
- package/hooks/blocking/package_inventory_stale_blocker_parts/AGENTS.md +0 -25
- package/hooks/blocking/pii_prevention_blocker_parts/AGENTS.md +0 -23
- package/hooks/blocking/tdd_enforcer_parts/AGENTS.md +0 -29
- 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/git-hooks/AGENTS.md +0 -31
- package/hooks/git-hooks/git_hooks_constants/AGENTS.md +0 -20
- package/hooks/hooks_constants/AGENTS.md +0 -99
- 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/lifecycle/AGENTS.md +0 -17
- package/hooks/observability/AGENTS.md +0 -19
- package/hooks/session/AGENTS.md +0 -37
- package/hooks/validation/AGENTS.md +0 -19
- package/hooks/validators/AGENTS.md +0 -51
- 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/hooks/workflow/AGENTS.md +0 -15
- package/output-styles/AGENTS.md +0 -14
- package/package.json +1 -1
- package/rules/AGENTS.md +0 -55
- package/scripts/AGENTS.md +0 -55
- package/scripts/dev_env_scripts_constants/AGENTS.md +0 -20
- package/scripts/sync_to_cursor/AGENTS.md +0 -22
- package/scripts/tests/AGENTS.md +0 -34
- package/system-prompts/AGENTS.md +0 -24
package/hooks/session/AGENTS.md
CHANGED
|
@@ -1,38 +1 @@
|
|
|
1
|
-
# hooks/session
|
|
2
1
|
|
|
3
|
-
SessionStart and SessionEnd hooks for per-session setup and cleanup: removing stale session and plugin-data directories at startup, starting the session's task-list maintenance loop, injecting working-style guidance, and clearing PR-author swap state at shutdown. Also holds the UserPromptSubmit hook. It adds a style reminder to each message.
|
|
4
|
-
|
|
5
|
-
The working-style projection uses `~/.claude/rules/asd-ste100-language.md` for
|
|
6
|
-
user-facing word choice, sentence style, tone, punctuation, and prose form.
|
|
7
|
-
|
|
8
|
-
## Key files
|
|
9
|
-
|
|
10
|
-
| File | Event | What it does |
|
|
11
|
-
|---|---|---|
|
|
12
|
-
| `session_env_cleanup.py` | SessionStart | Removes the current session's pre-existing `~/.claude/session-env/<session_id>/` directory and prunes sibling entries older than the stale-age threshold. Prevents `EEXIST` errors from non-recursive `mkdir` calls in the Bash tool on Windows. |
|
|
13
|
-
| `gh_pr_author_session_cleanup.py` | SessionEnd | Clears any PR-author swap state left over from the current session |
|
|
14
|
-
| `session_edit_tracker_cleanup.py` | SessionStart, SessionEnd | Deletes the tracker file for the running Claude Code conversation from the system temp directory — at start for a clean slate and at end for a clean exit. A tracker is read only by the conversation that wrote it, so a live idle tracker is kept while a peer cleans up |
|
|
15
|
-
| `plugin_data_dir_cleanup.py` | SessionStart | Removes empty plugin data directories at startup to prevent `EEXIST` when Claude Code recreates them |
|
|
16
|
-
| `untracked_repo_detector.py` | — | Retired legacy script; no longer registered |
|
|
17
|
-
| `task_list_loop_starter.py` | SessionStart | Emits an `additionalContext` directive telling Claude to keep the task list current on a 10-minute cadence, starting the `/loop` skill when one is not already running. Writes nothing and runs no tools itself. |
|
|
18
|
-
| `orchestrator_auto_starter.py` | SessionStart | Opt-in (`CLAUDE_ORCHESTRATOR_AUTO_STARTER_ENABLED`) consumer of the shared SessionStart injector; emits orchestrator skill context when enabled. Manual `/orchestrator` unchanged. |
|
|
19
|
-
| `issue_tracker_session_starter.py` | SessionStart | Opt-in (`CLAUDE_ISSUE_TRACKER_SESSION_STARTER_ENABLED`) and repository-gated (git root in `~/.claude/project-paths.json`) issue-tracker skill context. |
|
|
20
|
-
| `_path_setup.py` | — | Inserts the hooks directory on `sys.path` so SessionStart scripts import `hooks_constants` with top-level imports. |
|
|
21
|
-
| `test_orchestrator_auto_starter.py` | — | Tests for `orchestrator_auto_starter.py` |
|
|
22
|
-
| `test_issue_tracker_session_starter.py` | — | Tests for `issue_tracker_session_starter.py` |
|
|
23
|
-
| `working_style_prompt.py` | SessionStart | Emits an `additionalContext` block with the fixed working-style prompt (running ledger, canonical language policy, outcome-first finish, scope discipline). Writes nothing and runs no tools itself. |
|
|
24
|
-
| `style_reminder_prompt.py` | UserPromptSubmit | Adds the style reminder ("small words. few words. always. forever.") to every message, as `hookSpecificOutput.additionalContext`. Codex reads the same shape, so a manually wired Codex `hooks.json` can reuse this script too (see `docs/codex-compatibility.md`). Prints text and stops. |
|
|
25
|
-
| `test_style_reminder_prompt.py` | — | Tests for `style_reminder_prompt.py` |
|
|
26
|
-
| `test_gh_pr_author_session_cleanup.py` | — | Tests for `gh_pr_author_session_cleanup.py` |
|
|
27
|
-
| `test_session_edit_tracker_cleanup.py` | — | Tests for `session_edit_tracker_cleanup.py` |
|
|
28
|
-
| `test_session_env_cleanup.py` | — | Tests for `session_env_cleanup.py` |
|
|
29
|
-
| `test_untracked_repo_detector.py` | — | Tests for `untracked_repo_detector.py` |
|
|
30
|
-
| `test_task_list_loop_starter.py` | — | Tests for `task_list_loop_starter.py` |
|
|
31
|
-
| `test__path_setup.py` | — | Tests for `_path_setup.py` |
|
|
32
|
-
| `test_working_style_prompt.py` | — | Tests for `working_style_prompt.py` |
|
|
33
|
-
|
|
34
|
-
## Conventions
|
|
35
|
-
|
|
36
|
-
- `session_env_cleanup.py` is Windows-specific in effect but safe to run on all platforms; it exits 0 when the target directory does not exist.
|
|
37
|
-
- Constants (stale-age threshold, directory names) live in `hooks_constants/session_env_cleanup_constants.py`.
|
|
38
|
-
- Tests run with `python -m pytest session/test_<name>.py`.
|
|
@@ -1,20 +1 @@
|
|
|
1
|
-
# hooks/validation
|
|
2
1
|
|
|
3
|
-
PostToolUse hooks that validate code quality after Claude writes or edits a file. Unlike the blocking hooks (which fire PreToolUse and can deny the write), these hooks run after the write and report errors that need a follow-up fix.
|
|
4
|
-
|
|
5
|
-
## Key files
|
|
6
|
-
|
|
7
|
-
| File | Event | What it does |
|
|
8
|
-
|---|---|---|
|
|
9
|
-
| `mypy_validator.py` | PostToolUse (Write/Edit on `.py` files) | Runs mypy on the written file and blocks (via PostToolUse block decision) when type errors are found — catches missing attributes, wrong signatures, type mismatches, and import errors |
|
|
10
|
-
| `hook_format_validator.py` | PostToolUse | Validates that a hook script's output JSON matches the expected Claude Code hook-output schema |
|
|
11
|
-
| `test_mypy_validator.py` | — | Tests for `mypy_validator.py` |
|
|
12
|
-
| `test_hook_format_validator.py` | — | Tests for `hook_format_validator.py` |
|
|
13
|
-
|
|
14
|
-
## Conventions
|
|
15
|
-
|
|
16
|
-
- `mypy_validator.py` resolves the project root via `CLAUDE_PROJECT_ROOT` or `git rev-parse --show-toplevel`.
|
|
17
|
-
- It works on both WSL and Windows.
|
|
18
|
-
- Constants (timeouts, max displayed errors) are inline in `mypy_validator.py`; longer tunables go in `hooks_constants/`.
|
|
19
|
-
- The `eval_*.txt` files in this directory are evaluation exports used during development — not runtime artifacts.
|
|
20
|
-
- Tests run with `python -m pytest validation/test_<name>.py`.
|
|
@@ -1,52 +1 @@
|
|
|
1
|
-
# hooks/validators
|
|
2
1
|
|
|
3
|
-
A library of check modules used by the validation hooks. Each module focuses on one concern; `run_all_validators.py` runs them all and collects results. These modules do not hook into Claude Code directly — they are imported by the validation hooks.
|
|
4
|
-
|
|
5
|
-
## Core infrastructure
|
|
6
|
-
|
|
7
|
-
| File | Role |
|
|
8
|
-
|---|---|
|
|
9
|
-
| `validator_base.py` | Defines the `Violation` dataclass (`file`, `line`, `message`) used by every check module |
|
|
10
|
-
| `validator_defaults.py` | Default configuration values shared across check modules |
|
|
11
|
-
| `exempt_paths.py` | Path exemption logic — paths that checks skip (e.g. vendored code) |
|
|
12
|
-
| `output_formatter.py` | Formats `Violation` lists into human-readable output |
|
|
13
|
-
| `run_all_validators.py` | Entry point — runs every check module and aggregates results |
|
|
14
|
-
| `health_check.py` | Verifies that all validator dependencies (ruff, mypy) are reachable |
|
|
15
|
-
| `pyproject_config_discovery.py` | Shared walk-up primitive that resolves a tool's pyproject.toml config from an original target path, matching the `[tool.<name>]` table the tool owns |
|
|
16
|
-
| `system_temporary_roots.py` | Shared membership for OS temp roots (`gettempdir` plus `TEMP` / `TMP` / `TMPDIR` / `RUNNER_TEMP`); mypy walk stop and PreToolUse staging both call it |
|
|
17
|
-
| `python_style_helpers.py` | Shared source-line splitting and function-discovery helpers imported by `python_style_checks.py` |
|
|
18
|
-
|
|
19
|
-
## Check modules
|
|
20
|
-
|
|
21
|
-
| Module | What it checks |
|
|
22
|
-
|---|---|
|
|
23
|
-
| `abbreviation_checks.py` | Abbreviated names in Python code |
|
|
24
|
-
| `code_quality_checks.py` | General code quality concerns (dead code, stub bodies, etc.) |
|
|
25
|
-
| `comment_checks.py` | Inline comment presence and content |
|
|
26
|
-
| `file_structure_checks.py` | File-level structural rules (line count, module layout) |
|
|
27
|
-
| `git_checks.py` | Git-state checks (untracked files, merge conflicts) |
|
|
28
|
-
| `magic_value_checks.py` | Magic numbers and strings |
|
|
29
|
-
| `mypy_integration.py` | Runs mypy and converts its output to `Violation` objects; stops project-root walks at the system temp directory; detached gate files skip followed imports and time out as a skip (passed) |
|
|
30
|
-
| `pr_reference_checks.py` | PR references in commit messages and changelogs |
|
|
31
|
-
| `python_antipattern_checks.py` | Python-specific anti-patterns (bare `except`, `Any`, etc.) |
|
|
32
|
-
| `python_style_checks.py` | Python style rules (naming, imports, type hints) |
|
|
33
|
-
| `react_checks.py` | React/TSX-specific checks (class component patterns, PureComponent usage) |
|
|
34
|
-
| `ruff_integration.py` | Runs ruff and converts its output to `Violation` objects |
|
|
35
|
-
| `security_checks.py` | Security anti-patterns (hardcoded secrets, unsafe calls) |
|
|
36
|
-
| `todo_checks.py` | TODO/FIXME markers without an associated issue reference |
|
|
37
|
-
| `type_safety_checks.py` | Type-safety rules (no `Any`, no `cast`, no `# type: ignore`) |
|
|
38
|
-
| `useless_test_checks.py` | Tests that check only existence or constant values |
|
|
39
|
-
|
|
40
|
-
## Subdirectory
|
|
41
|
-
|
|
42
|
-
| Directory | Role |
|
|
43
|
-
|---|---|
|
|
44
|
-
| `config/` | Constants module (`directory_exemption_constants.py`) holding the exemption directory-segment names that `validate_proposed_file` staging keeps |
|
|
45
|
-
| `test_files/` | Fixture files used by the validator tests — not checked-in test code |
|
|
46
|
-
|
|
47
|
-
## Conventions
|
|
48
|
-
|
|
49
|
-
- Every check module exposes one or more functions that take file content or an AST and return a list of `Violation` objects.
|
|
50
|
-
- Test files live beside the modules they test: `test_<module>.py`. Run with `python -m pytest validators/test_<name>.py`.
|
|
51
|
-
- `conftest.py` provides shared test fixtures (sample files, fixture paths).
|
|
52
|
-
- `README.md` in this directory documents the validator design and how to add a new check.
|
|
@@ -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
|