claude-dev-env 1.72.0 → 1.74.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 -0
- package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +2 -2
- package/bin/install.mjs +73 -5
- package/bin/install.test.mjs +360 -4
- package/hooks/blocking/CLAUDE.md +6 -1
- package/hooks/blocking/block_main_commit.py +14 -0
- package/hooks/blocking/bot_mention_comment_blocker.py +7 -0
- package/hooks/blocking/claude_md_orphan_file_blocker.py +19 -48
- package/hooks/blocking/code_rules_dead_config_field.py +69 -56
- package/hooks/blocking/code_rules_docstrings.py +839 -0
- package/hooks/blocking/code_rules_enforcer.py +38 -0
- package/hooks/blocking/code_rules_shared.py +19 -0
- package/hooks/blocking/code_verifier_spawn_preflight_gate.py +426 -0
- package/hooks/blocking/convergence_gate_blocker.py +17 -3
- package/hooks/blocking/destructive_command_blocker.py +7 -0
- package/hooks/blocking/docstring_rule_gate_count_blocker.py +321 -0
- package/hooks/blocking/gh_body_arg_blocker.py +8 -0
- package/hooks/blocking/gh_pr_author_enforcer.py +7 -0
- package/hooks/blocking/hedging_language_blocker.py +16 -10
- package/hooks/blocking/hook_prose_detector_consistency.py +7 -0
- package/hooks/blocking/intent_only_ending_blocker.py +17 -11
- package/hooks/blocking/md_to_html_blocker.py +17 -10
- package/hooks/blocking/open_questions_in_plans_blocker.py +15 -8
- package/hooks/blocking/package_inventory_stale_blocker.py +398 -0
- package/hooks/blocking/plain_language_blocker.py +57 -16
- package/hooks/blocking/pr_converge_bugteam_enforcer.py +11 -5
- package/hooks/blocking/pr_description_enforcer.py +6 -0
- package/hooks/blocking/pre_tool_use_dispatcher.py +545 -0
- package/hooks/blocking/precommit_code_rules_gate.py +10 -1
- package/hooks/blocking/pytest_testpaths_orphan_blocker.py +366 -0
- package/hooks/blocking/question_to_user_enforcer.py +18 -12
- package/hooks/blocking/send_user_file_open_locally_blocker.py +70 -0
- package/hooks/blocking/sensitive_file_protector.py +15 -1
- package/hooks/blocking/session_handoff_blocker.py +14 -8
- package/hooks/blocking/state_description_blocker.py +81 -36
- package/hooks/blocking/subprocess_budget_completeness.py +9 -3
- package/hooks/blocking/tdd_enforcer.py +6 -0
- package/hooks/blocking/test_code_rules_enforcer_dead_config_field.py +81 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_inline_literal_claim.py +93 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_returns_plural_cardinality.py +207 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_step_dispatch.py +262 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_undefined_constant.py +253 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_unguarded_payload.py +188 -0
- package/hooks/blocking/test_code_rules_enforcer_module_docstring_roster.py +279 -0
- package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +501 -0
- package/hooks/blocking/test_docstring_rule_gate_count_blocker.py +203 -0
- package/hooks/blocking/test_hook_block_logger_coverage.py +53 -0
- package/hooks/blocking/test_package_inventory_stale_blocker.py +329 -0
- package/hooks/blocking/test_plain_language_blocker.py +36 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +816 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +341 -0
- package/hooks/blocking/test_pytest_testpaths_orphan_blocker.py +247 -0
- package/hooks/blocking/test_send_user_file_open_locally_blocker.py +114 -0
- package/hooks/blocking/test_shared_stdin_adoption.py +208 -0
- package/hooks/blocking/test_state_description_blocker.py +41 -0
- package/hooks/blocking/test_verdict_directory_write_blocker.py +49 -0
- package/hooks/blocking/test_workflow_substitution_slot_blocker.py +4 -19
- package/hooks/blocking/verdict_directory_write_blocker.py +21 -7
- package/hooks/blocking/verified_commit_gate.py +11 -0
- package/hooks/blocking/verified_commit_message_accuracy_blocker.py +16 -1
- package/hooks/blocking/windows_rmtree_blocker.py +7 -0
- package/hooks/blocking/workflow_substitution_slot_blocker.py +10 -5
- package/hooks/blocking/write_existing_file_blocker.py +16 -1
- package/hooks/hooks.json +19 -79
- package/hooks/hooks_constants/CLAUDE.md +7 -1
- package/hooks/hooks_constants/blocking_check_limits.py +74 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +9 -0
- package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +45 -0
- package/hooks/hooks_constants/dead_config_field_constants.py +5 -5
- package/hooks/hooks_constants/docstring_rule_gate_count_blocker_constants.py +90 -0
- package/hooks/hooks_constants/hook_block_logger.py +59 -0
- package/hooks/hooks_constants/multi_edit_reconstruction.py +56 -0
- package/hooks/hooks_constants/mypy_validator_cache_constants.py +36 -0
- package/hooks/hooks_constants/package_inventory_stale_blocker_constants.py +111 -0
- package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +68 -0
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +143 -0
- package/hooks/hooks_constants/pytest_testpaths_orphan_blocker_constants.py +79 -0
- package/hooks/hooks_constants/send_user_file_open_locally_blocker_constants.py +18 -0
- package/hooks/hooks_constants/test_dispatcher_constants_docstrings.py +44 -0
- package/hooks/hooks_constants/test_hook_block_logger.py +159 -0
- package/hooks/lifecycle/config_change_guard.py +12 -0
- package/hooks/lifecycle/test_config_change_guard.py +23 -0
- package/hooks/validation/hook_format_validator.py +13 -0
- package/hooks/validation/mypy_validator.py +245 -18
- package/hooks/validation/post_tool_use_dispatcher.py +344 -0
- package/hooks/validation/test_hook_format_validator.py +64 -0
- package/hooks/validation/test_mypy_validator.py +206 -1
- package/hooks/validation/test_post_tool_use_dispatcher.py +610 -0
- package/hooks/workflow/test_auto_formatter.py +10 -9
- package/package.json +1 -1
- package/rules/CLAUDE.md +1 -0
- package/rules/docstring-prose-matches-implementation.md +4 -2
- package/rules/package-inventory-stale-entry.md +24 -0
- package/skills/autoconverge/SKILL.md +111 -1
- package/skills/autoconverge/workflow/converge.contract.test.mjs +106 -0
- package/skills/autoconverge/workflow/converge.mjs +29 -3
- package/skills/autoconverge/workflow/converge.path-aware.test.mjs +47 -0
- package/skills/autoconverge/workflow/converge_multi.mjs +161 -0
- package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +100 -0
|
@@ -69,7 +69,14 @@ from code_rules_docstrings import ( # noqa: E402
|
|
|
69
69
|
check_docstring_args_match_signature,
|
|
70
70
|
check_docstring_fallback_branch_coverage,
|
|
71
71
|
check_docstring_format,
|
|
72
|
+
check_docstring_names_undefined_constant,
|
|
72
73
|
check_docstring_no_consumer_claim,
|
|
74
|
+
check_docstring_no_inline_literal_claim,
|
|
75
|
+
check_docstring_returns_plural_cardinality,
|
|
76
|
+
check_docstring_step_enumeration_dispatch_coverage,
|
|
77
|
+
check_docstring_tuple_enumeration_match,
|
|
78
|
+
check_docstring_unguarded_malformed_payload_claim,
|
|
79
|
+
check_module_docstring_names_public_checks,
|
|
73
80
|
)
|
|
74
81
|
from code_rules_duplicate_body import ( # noqa: E402
|
|
75
82
|
advise_cross_skill_duplicate_helper,
|
|
@@ -154,6 +161,7 @@ from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
|
|
|
154
161
|
PRECHECK_USAGE_EXIT_CODE,
|
|
155
162
|
PRECHECK_USAGE_MESSAGE,
|
|
156
163
|
)
|
|
164
|
+
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
157
165
|
from hooks_constants.setup_project_paths_constants import ( # noqa: E402
|
|
158
166
|
UTF8_BYTE_ORDER_MARK,
|
|
159
167
|
)
|
|
@@ -255,9 +263,34 @@ def validate_content(
|
|
|
255
263
|
all_issues.extend(check_docstring_args_match_signature(effective_content, file_path))
|
|
256
264
|
all_issues.extend(check_docstring_fallback_branch_coverage(effective_content, file_path))
|
|
257
265
|
all_issues.extend(check_docstring_no_consumer_claim(effective_content, file_path))
|
|
266
|
+
all_issues.extend(
|
|
267
|
+
check_docstring_unguarded_malformed_payload_claim(
|
|
268
|
+
effective_content, file_path
|
|
269
|
+
)
|
|
270
|
+
)
|
|
271
|
+
all_issues.extend(
|
|
272
|
+
check_docstring_no_inline_literal_claim(effective_content, file_path)
|
|
273
|
+
)
|
|
258
274
|
all_issues.extend(
|
|
259
275
|
check_class_docstring_names_public_methods(effective_content, file_path)
|
|
260
276
|
)
|
|
277
|
+
all_issues.extend(
|
|
278
|
+
check_module_docstring_names_public_checks(effective_content, file_path)
|
|
279
|
+
)
|
|
280
|
+
all_issues.extend(
|
|
281
|
+
check_docstring_tuple_enumeration_match(effective_content, file_path)
|
|
282
|
+
)
|
|
283
|
+
all_issues.extend(
|
|
284
|
+
check_docstring_step_enumeration_dispatch_coverage(
|
|
285
|
+
effective_content, file_path
|
|
286
|
+
)
|
|
287
|
+
)
|
|
288
|
+
all_issues.extend(
|
|
289
|
+
check_docstring_returns_plural_cardinality(effective_content, file_path)
|
|
290
|
+
)
|
|
291
|
+
all_issues.extend(
|
|
292
|
+
check_docstring_names_undefined_constant(effective_content, file_path)
|
|
293
|
+
)
|
|
261
294
|
all_issues.extend(
|
|
262
295
|
check_boolean_naming(
|
|
263
296
|
effective_content,
|
|
@@ -761,6 +794,11 @@ def _write_deny_payload(deny_reason: str, deny_stream: TextIO) -> None:
|
|
|
761
794
|
"permissionDecisionReason": deny_reason,
|
|
762
795
|
}
|
|
763
796
|
}
|
|
797
|
+
log_hook_block(
|
|
798
|
+
calling_hook_name="code_rules_enforcer.py",
|
|
799
|
+
hook_event="PreToolUse",
|
|
800
|
+
block_reason=deny_reason,
|
|
801
|
+
)
|
|
764
802
|
deny_stream.write(json.dumps(deny_payload) + "\n")
|
|
765
803
|
deny_stream.flush()
|
|
766
804
|
|
|
@@ -20,12 +20,14 @@ from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
|
|
|
20
20
|
ALL_HOOK_INFRASTRUCTURE_PATTERNS,
|
|
21
21
|
ALL_MIGRATION_PATH_PATTERNS,
|
|
22
22
|
ALL_ROOT_ANCHORED_EPHEMERAL_DIRECTORIES,
|
|
23
|
+
ALL_STRICT_TEST_DIRECTORY_SEGMENTS,
|
|
23
24
|
ALL_TEST_PATH_PATTERNS,
|
|
24
25
|
ALL_WORKFLOW_REGISTRY_PATTERNS,
|
|
25
26
|
CLAUDE_JOB_DIR_ENVIRONMENT_VARIABLE_NAME,
|
|
26
27
|
CLAUDE_JOB_DIR_SCRATCH_SUBDIRECTORY,
|
|
27
28
|
EPHEMERAL_EXEMPT_DISABLE_ENVIRONMENT_VARIABLE_NAME,
|
|
28
29
|
LEADING_DRIVE_LETTER_PATTERN,
|
|
30
|
+
STRICT_TEST_FILE_BASENAME_PATTERN,
|
|
29
31
|
)
|
|
30
32
|
from hooks_constants.unused_module_import_constants import ( # noqa: E402
|
|
31
33
|
TYPE_CHECKING_IDENTIFIER,
|
|
@@ -55,6 +57,23 @@ def is_test_file(file_path: str) -> bool:
|
|
|
55
57
|
return any(pattern in path_lower for pattern in ALL_TEST_PATH_PATTERNS)
|
|
56
58
|
|
|
57
59
|
|
|
60
|
+
def is_strict_test_file(file_path: str) -> bool:
|
|
61
|
+
"""Check if a file is a genuine test module by its basename, not a mid-name match.
|
|
62
|
+
|
|
63
|
+
A production module whose name carries the substring ``test`` mid-name —
|
|
64
|
+
such as ``code_rules_test_assertions.py`` — is not a test module. This
|
|
65
|
+
predicate anchors on the basename shape (``test_*`` / ``*_test.*`` /
|
|
66
|
+
``*.test.*`` / ``*.spec.*`` / ``conftest.py``) or a ``/tests/`` path
|
|
67
|
+
segment, so it keeps such production modules out of the test exemption that
|
|
68
|
+
the substring-based is_test_file applies.
|
|
69
|
+
"""
|
|
70
|
+
normalized_path = file_path.lower().replace("\\", "/")
|
|
71
|
+
if any(segment in normalized_path for segment in ALL_STRICT_TEST_DIRECTORY_SEGMENTS):
|
|
72
|
+
return True
|
|
73
|
+
basename_lower = normalized_path.rsplit("/", 1)[-1]
|
|
74
|
+
return STRICT_TEST_FILE_BASENAME_PATTERN.match(basename_lower) is not None
|
|
75
|
+
|
|
76
|
+
|
|
58
77
|
def is_workflow_registry_file(file_path: str) -> bool:
|
|
59
78
|
"""Check if file is a workflow state/module registry file.
|
|
60
79
|
|
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PreToolUse hook: pre-flight gate for the code-verifier subagent spawn.
|
|
3
|
+
|
|
4
|
+
The hook fires only on an ``Agent`` tool call whose ``subagent_type`` is
|
|
5
|
+
``code-verifier``. Before that verification spawn runs, the hook checks the
|
|
6
|
+
branch for two committability problems against the resolved base ref: a real
|
|
7
|
+
merge conflict (a non-mutating trial-merge of HEAD against the base ref) and a
|
|
8
|
+
CODE_RULES violation on a line added in the uncommitted working tree. When
|
|
9
|
+
either fires, the hook denies the spawn with a reason addressed to the spawning
|
|
10
|
+
agent that names the conflicting files and the violating file:line, so that
|
|
11
|
+
agent fixes them and re-spawns. Both checks fail OPEN on any infrastructure
|
|
12
|
+
problem — a non-repo cwd, an absent base ref, a git or engine failure, or a
|
|
13
|
+
timeout — because the authoritative fail-closed CODE_RULES enforcement already
|
|
14
|
+
runs at Write time and at commit time. The hook never network-fetches and never
|
|
15
|
+
mutates the index or working tree.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import contextlib
|
|
21
|
+
import io
|
|
22
|
+
import json
|
|
23
|
+
import subprocess
|
|
24
|
+
import sys
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import TextIO
|
|
27
|
+
|
|
28
|
+
_blocking_dir = str(Path(__file__).resolve().parent)
|
|
29
|
+
if _blocking_dir not in sys.path:
|
|
30
|
+
sys.path.insert(0, _blocking_dir)
|
|
31
|
+
|
|
32
|
+
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
33
|
+
if _hooks_dir not in sys.path:
|
|
34
|
+
sys.path.append(_hooks_dir)
|
|
35
|
+
|
|
36
|
+
from verification_verdict_store import ( # noqa: E402
|
|
37
|
+
candidate_base_references,
|
|
38
|
+
resolve_merge_base,
|
|
39
|
+
resolve_repo_root,
|
|
40
|
+
run_git,
|
|
41
|
+
untracked_file_paths,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
from hooks_constants.code_verifier_spawn_preflight_gate_constants import ( # noqa: E402
|
|
45
|
+
ALL_MERGE_TREE_COMMAND_FLAGS,
|
|
46
|
+
ALL_NAME_ONLY_WORKTREE_DIFF_FLAGS,
|
|
47
|
+
ALL_UNIFIED_ZERO_DIFF_FLAGS,
|
|
48
|
+
CODE_RULES_SECTION_HEADER,
|
|
49
|
+
CODE_VERIFIER_SUBAGENT_TYPE,
|
|
50
|
+
DENY_REASON_LEAD,
|
|
51
|
+
GATE_SCRIPTS_RELATIVE_PATH,
|
|
52
|
+
MERGE_CONFLICT_SECTION_HEADER,
|
|
53
|
+
MERGE_TREE_CLEAN_EXIT_CODE,
|
|
54
|
+
MERGE_TREE_CONFLICT_EXIT_CODE,
|
|
55
|
+
MERGE_TREE_TIMEOUT_SECONDS,
|
|
56
|
+
)
|
|
57
|
+
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
58
|
+
from hooks_constants.pr_converge_bugteam_enforcer_constants import ( # noqa: E402
|
|
59
|
+
AGENT_TOOL_NAME,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
_scripts_dir = str(Path(__file__).resolve().parents[2] / GATE_SCRIPTS_RELATIVE_PATH)
|
|
63
|
+
if _scripts_dir not in sys.path:
|
|
64
|
+
sys.path.insert(0, _scripts_dir)
|
|
65
|
+
|
|
66
|
+
from code_rules_gate import ( # noqa: E402
|
|
67
|
+
ValidateContentCallable,
|
|
68
|
+
_collect_partitioned_violations,
|
|
69
|
+
_report_partitioned_violations,
|
|
70
|
+
is_code_path,
|
|
71
|
+
load_validate_content,
|
|
72
|
+
parse_added_line_numbers,
|
|
73
|
+
whole_file_line_set,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _should_run(payload_by_field: dict[str, object]) -> bool:
|
|
78
|
+
"""Return True only for a code-verifier Agent spawn.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
payload_by_field: The full PreToolUse hook payload (already
|
|
82
|
+
JSON-parsed), keyed by top-level field name.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
True when the tool is Agent and ``tool_input.subagent_type`` is
|
|
86
|
+
``code-verifier``; False for every other shape.
|
|
87
|
+
"""
|
|
88
|
+
if payload_by_field.get("tool_name", "") != AGENT_TOOL_NAME:
|
|
89
|
+
return False
|
|
90
|
+
tool_input = payload_by_field.get("tool_input", {})
|
|
91
|
+
if not isinstance(tool_input, dict):
|
|
92
|
+
return False
|
|
93
|
+
return tool_input.get("subagent_type", "") == CODE_VERIFIER_SUBAGENT_TYPE
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _resolve_repo_root_and_base(working_directory: str | None) -> tuple[str, str, str] | None:
|
|
97
|
+
"""Resolve the repo root, merge-base sha, and chosen base ref.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
working_directory: The spawn's working directory from the payload, or
|
|
101
|
+
None when the payload carries no ``cwd``.
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
A ``(repo_root, merge_base_sha, base_ref)`` triple, or None when the
|
|
105
|
+
directory is not a work tree or no base ref resolves on disk — the
|
|
106
|
+
caller fails OPEN on None.
|
|
107
|
+
"""
|
|
108
|
+
start_directory = working_directory if working_directory else str(Path.cwd())
|
|
109
|
+
repo_root = resolve_repo_root(start_directory)
|
|
110
|
+
if repo_root is None:
|
|
111
|
+
return None
|
|
112
|
+
merge_base_sha = resolve_merge_base(repo_root)
|
|
113
|
+
if merge_base_sha is None:
|
|
114
|
+
return None
|
|
115
|
+
for each_reference in candidate_base_references(repo_root):
|
|
116
|
+
if run_git(repo_root, "merge-base", "HEAD", each_reference) is not None:
|
|
117
|
+
return repo_root, merge_base_sha, each_reference
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _run_trial_merge(repo_root: str, base_ref: str) -> tuple[int, str] | None:
|
|
122
|
+
"""Run the non-mutating trial-merge and return its exit code and stdout.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
repo_root: The repository top-level directory.
|
|
126
|
+
base_ref: The base ref to trial-merge HEAD against.
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
A ``(returncode, stdout)`` pair, or None when the command is missing,
|
|
130
|
+
times out, or raises an OS-level error — the caller fails OPEN on None.
|
|
131
|
+
"""
|
|
132
|
+
try:
|
|
133
|
+
completed_process = subprocess.run(
|
|
134
|
+
["git", "-C", repo_root, *ALL_MERGE_TREE_COMMAND_FLAGS, base_ref, "HEAD"],
|
|
135
|
+
capture_output=True,
|
|
136
|
+
text=True,
|
|
137
|
+
encoding="utf-8",
|
|
138
|
+
errors="replace",
|
|
139
|
+
timeout=MERGE_TREE_TIMEOUT_SECONDS,
|
|
140
|
+
check=False,
|
|
141
|
+
)
|
|
142
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
143
|
+
return None
|
|
144
|
+
return completed_process.returncode, completed_process.stdout
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _conflicting_files(repo_root: str, base_ref: str) -> list[str] | None:
|
|
148
|
+
"""Return the files that conflict when HEAD trial-merges against the base.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
repo_root: The repository top-level directory.
|
|
152
|
+
base_ref: The base ref to trial-merge HEAD against.
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
The conflicting file paths on a conflict exit, an empty list on a
|
|
156
|
+
clean merge, or None on any infrastructure failure (command missing,
|
|
157
|
+
timeout, or an exit code that is neither clean nor conflict) — the
|
|
158
|
+
caller fails OPEN on None.
|
|
159
|
+
"""
|
|
160
|
+
merge_outcome = _run_trial_merge(repo_root, base_ref)
|
|
161
|
+
if merge_outcome is None:
|
|
162
|
+
return None
|
|
163
|
+
return_code, merge_stdout = merge_outcome
|
|
164
|
+
if return_code == MERGE_TREE_CLEAN_EXIT_CODE:
|
|
165
|
+
return []
|
|
166
|
+
if return_code != MERGE_TREE_CONFLICT_EXIT_CODE:
|
|
167
|
+
return None
|
|
168
|
+
return _parse_conflicting_paths(merge_stdout)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _parse_conflicting_paths(merge_stdout: str) -> list[str]:
|
|
172
|
+
"""Extract conflicting paths from trial-merge stdout.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
merge_stdout: The stdout of a conflict-exit trial-merge: the written
|
|
176
|
+
tree-OID line, then one conflicting path per line, then a blank
|
|
177
|
+
line and informational text.
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
The conflicting file paths — the lines after the tree-OID up to the
|
|
181
|
+
first blank line.
|
|
182
|
+
"""
|
|
183
|
+
all_lines = merge_stdout.splitlines()
|
|
184
|
+
conflicting_paths: list[str] = []
|
|
185
|
+
for each_line in all_lines[1:]:
|
|
186
|
+
if not each_line.strip():
|
|
187
|
+
break
|
|
188
|
+
conflicting_paths.append(each_line)
|
|
189
|
+
return conflicting_paths
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _working_tree_added_lines_by_path(
|
|
193
|
+
repo_root: str, merge_base_sha: str
|
|
194
|
+
) -> tuple[list[Path], dict[Path, set[int]]] | None:
|
|
195
|
+
"""Build the code-file surface and its working-tree-vs-merge-base added lines.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
repo_root: The repository top-level directory.
|
|
199
|
+
merge_base_sha: The merge-base sha the surface diffs against.
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
A ``(file_paths, added_lines_by_path)`` pair keyed by resolved
|
|
203
|
+
absolute path, or None when a surface git query fails — the caller
|
|
204
|
+
fails OPEN on None.
|
|
205
|
+
"""
|
|
206
|
+
tracked_changed_text = run_git(repo_root, *ALL_NAME_ONLY_WORKTREE_DIFF_FLAGS, merge_base_sha)
|
|
207
|
+
if tracked_changed_text is None:
|
|
208
|
+
return None
|
|
209
|
+
untracked_paths = untracked_file_paths(repo_root)
|
|
210
|
+
if untracked_paths is None:
|
|
211
|
+
return None
|
|
212
|
+
repo_root_path = Path(repo_root)
|
|
213
|
+
file_paths: list[Path] = []
|
|
214
|
+
added_lines_by_path: dict[Path, set[int]] = {}
|
|
215
|
+
for each_relative in tracked_changed_text.splitlines():
|
|
216
|
+
if not each_relative or not is_code_path(Path(each_relative)):
|
|
217
|
+
continue
|
|
218
|
+
resolved_path = (repo_root_path / each_relative).resolve()
|
|
219
|
+
file_paths.append(resolved_path)
|
|
220
|
+
added_lines_by_path[resolved_path] = _tracked_file_added_lines(
|
|
221
|
+
repo_root, merge_base_sha, each_relative
|
|
222
|
+
)
|
|
223
|
+
for each_relative in untracked_paths:
|
|
224
|
+
if not is_code_path(Path(each_relative)):
|
|
225
|
+
continue
|
|
226
|
+
resolved_path = (repo_root_path / each_relative).resolve()
|
|
227
|
+
file_paths.append(resolved_path)
|
|
228
|
+
added_lines_by_path[resolved_path] = whole_file_line_set(resolved_path)
|
|
229
|
+
return file_paths, added_lines_by_path
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _tracked_file_added_lines(repo_root: str, merge_base_sha: str, relative_path: str) -> set[int]:
|
|
233
|
+
"""Return the working-tree-added line numbers for one tracked file.
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
repo_root: The repository top-level directory.
|
|
237
|
+
merge_base_sha: The merge-base sha the diff runs against.
|
|
238
|
+
relative_path: The repo-relative path of the tracked changed file.
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
The 1-indexed line numbers added vs the merge base in the working
|
|
242
|
+
tree, or an empty set when the per-file diff fails.
|
|
243
|
+
"""
|
|
244
|
+
unified_diff_text = run_git(
|
|
245
|
+
repo_root, *ALL_UNIFIED_ZERO_DIFF_FLAGS, merge_base_sha, "--", relative_path
|
|
246
|
+
)
|
|
247
|
+
if unified_diff_text is None:
|
|
248
|
+
return set()
|
|
249
|
+
return parse_added_line_numbers(unified_diff_text)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _code_rules_report(
|
|
253
|
+
repo_root: str, all_file_paths: list[Path], all_added_lines_by_path: dict[Path, set[int]]
|
|
254
|
+
) -> str | None:
|
|
255
|
+
"""Run the CODE_RULES engine and return its blocking report, or None.
|
|
256
|
+
|
|
257
|
+
Args:
|
|
258
|
+
repo_root: The repository top-level directory.
|
|
259
|
+
all_file_paths: The resolved code-file paths to inspect.
|
|
260
|
+
all_added_lines_by_path: Per-file working-tree-added line numbers keyed
|
|
261
|
+
by resolved absolute path.
|
|
262
|
+
|
|
263
|
+
Returns:
|
|
264
|
+
The engine's grouped file:line report when a blocking violation lands
|
|
265
|
+
on an added line, or None when the surface is clean, only an unreadable
|
|
266
|
+
changed file caused a non-zero gate exit, the engine fails to load, or
|
|
267
|
+
any engine error arises — every non-block outcome fails OPEN. The
|
|
268
|
+
harness hook timeout in hooks.json is the wall-clock bound on a runaway
|
|
269
|
+
engine.
|
|
270
|
+
"""
|
|
271
|
+
if not all_file_paths:
|
|
272
|
+
return None
|
|
273
|
+
try:
|
|
274
|
+
validate_content = load_validate_content()
|
|
275
|
+
except SystemExit:
|
|
276
|
+
return None
|
|
277
|
+
try:
|
|
278
|
+
blocking_present, captured_report = _run_gate_capturing_stderr(
|
|
279
|
+
validate_content, all_file_paths, Path(repo_root), all_added_lines_by_path
|
|
280
|
+
)
|
|
281
|
+
except OSError:
|
|
282
|
+
return None
|
|
283
|
+
if not blocking_present:
|
|
284
|
+
return None
|
|
285
|
+
return captured_report
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _run_gate_capturing_stderr(
|
|
289
|
+
validate_content: ValidateContentCallable,
|
|
290
|
+
all_file_paths: list[Path],
|
|
291
|
+
repository_root: Path,
|
|
292
|
+
all_added_lines_by_path: dict[Path, set[int]],
|
|
293
|
+
) -> tuple[bool, str]:
|
|
294
|
+
"""Run the gate, reporting whether a blocking violation was actually found.
|
|
295
|
+
|
|
296
|
+
Args:
|
|
297
|
+
validate_content: The enforcer ``validate_content`` callable.
|
|
298
|
+
all_file_paths: The resolved code-file paths to inspect.
|
|
299
|
+
repository_root: The repository root path the gate resolves against.
|
|
300
|
+
all_added_lines_by_path: Per-file working-tree-added line numbers keyed
|
|
301
|
+
by resolved absolute path.
|
|
302
|
+
|
|
303
|
+
Returns:
|
|
304
|
+
A ``(blocking_present, captured_report)`` pair. ``blocking_present`` is
|
|
305
|
+
True only when at least one blocking violation landed on an added line;
|
|
306
|
+
an unreadable changed file alone (which exits the gate non-zero) leaves
|
|
307
|
+
it False, so the caller fails OPEN. ``captured_report`` is the gate's
|
|
308
|
+
grouped stderr report.
|
|
309
|
+
"""
|
|
310
|
+
blocking_by_file, advisory_by_file, skipped_unreadable_count = (
|
|
311
|
+
_collect_partitioned_violations(
|
|
312
|
+
validate_content,
|
|
313
|
+
all_file_paths,
|
|
314
|
+
repository_root,
|
|
315
|
+
all_added_lines_by_path,
|
|
316
|
+
False,
|
|
317
|
+
)
|
|
318
|
+
)
|
|
319
|
+
captured_stderr = io.StringIO()
|
|
320
|
+
with contextlib.redirect_stderr(captured_stderr):
|
|
321
|
+
_report_partitioned_violations(
|
|
322
|
+
blocking_by_file,
|
|
323
|
+
advisory_by_file,
|
|
324
|
+
repository_root,
|
|
325
|
+
False,
|
|
326
|
+
skipped_unreadable_count,
|
|
327
|
+
)
|
|
328
|
+
return bool(blocking_by_file), captured_stderr.getvalue()
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _build_deny_reason(
|
|
332
|
+
all_conflicting_files: list[str] | None, base_ref: str, code_rules_report: str | None
|
|
333
|
+
) -> str | None:
|
|
334
|
+
"""Assemble the spawner-addressed deny reason from the two check results.
|
|
335
|
+
|
|
336
|
+
Args:
|
|
337
|
+
all_conflicting_files: The conflicting file paths from the conflict
|
|
338
|
+
check, an empty list when clean, or None when that check failed open.
|
|
339
|
+
base_ref: The base ref named in the conflict section header.
|
|
340
|
+
code_rules_report: The grouped report from the CODE_RULES check, or None
|
|
341
|
+
when that check found nothing or failed open.
|
|
342
|
+
|
|
343
|
+
Returns:
|
|
344
|
+
The full deny reason when either check fired, or None when neither
|
|
345
|
+
produced an issue.
|
|
346
|
+
"""
|
|
347
|
+
reason_sections: list[str] = []
|
|
348
|
+
if all_conflicting_files:
|
|
349
|
+
conflict_lines = "\n".join(f" {each_path}" for each_path in all_conflicting_files)
|
|
350
|
+
conflict_header = MERGE_CONFLICT_SECTION_HEADER.format(base_ref=base_ref)
|
|
351
|
+
reason_sections.append(f"{conflict_header}\n{conflict_lines}")
|
|
352
|
+
if code_rules_report:
|
|
353
|
+
reason_sections.append(f"{CODE_RULES_SECTION_HEADER}\n{code_rules_report.strip()}")
|
|
354
|
+
if not reason_sections:
|
|
355
|
+
return None
|
|
356
|
+
return DENY_REASON_LEAD + "\n\n" + "\n\n".join(reason_sections)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _emit_deny_payload(output_stream: TextIO, reason: str) -> None:
|
|
360
|
+
"""Write the PreToolUse deny payload to the provided stream.
|
|
361
|
+
|
|
362
|
+
Args:
|
|
363
|
+
output_stream: Writable text stream — production code passes
|
|
364
|
+
``sys.stdout``; tests pass a ``StringIO`` to capture the JSON.
|
|
365
|
+
reason: The ``permissionDecisionReason`` text for the deny payload.
|
|
366
|
+
"""
|
|
367
|
+
deny_payload = {
|
|
368
|
+
"hookSpecificOutput": {
|
|
369
|
+
"hookEventName": "PreToolUse",
|
|
370
|
+
"permissionDecision": "deny",
|
|
371
|
+
"permissionDecisionReason": reason,
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
log_hook_block(
|
|
375
|
+
calling_hook_name="code_verifier_spawn_preflight_gate.py",
|
|
376
|
+
hook_event="PreToolUse",
|
|
377
|
+
block_reason=reason,
|
|
378
|
+
)
|
|
379
|
+
output_stream.write(json.dumps(deny_payload) + "\n")
|
|
380
|
+
output_stream.flush()
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _preflight_deny_reason(payload_by_field: dict[str, object]) -> str | None:
|
|
384
|
+
"""Run both pre-flight checks and return a deny reason, or None to allow.
|
|
385
|
+
|
|
386
|
+
Args:
|
|
387
|
+
payload_by_field: The full PreToolUse hook payload, keyed by top-level
|
|
388
|
+
field name.
|
|
389
|
+
|
|
390
|
+
Returns:
|
|
391
|
+
The deny reason when a check fired, or None when both checks pass or
|
|
392
|
+
fail open.
|
|
393
|
+
"""
|
|
394
|
+
working_directory = payload_by_field.get("cwd")
|
|
395
|
+
resolution = _resolve_repo_root_and_base(
|
|
396
|
+
working_directory if isinstance(working_directory, str) else None
|
|
397
|
+
)
|
|
398
|
+
if resolution is None:
|
|
399
|
+
return None
|
|
400
|
+
repo_root, merge_base_sha, base_ref = resolution
|
|
401
|
+
conflicting_files = _conflicting_files(repo_root, base_ref)
|
|
402
|
+
surface = _working_tree_added_lines_by_path(repo_root, merge_base_sha)
|
|
403
|
+
code_rules_report = None
|
|
404
|
+
if surface is not None:
|
|
405
|
+
file_paths, added_lines_by_path = surface
|
|
406
|
+
code_rules_report = _code_rules_report(repo_root, file_paths, added_lines_by_path)
|
|
407
|
+
return _build_deny_reason(conflicting_files, base_ref, code_rules_report)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def main() -> None:
|
|
411
|
+
try:
|
|
412
|
+
hook_payload = json.load(sys.stdin)
|
|
413
|
+
except json.JSONDecodeError:
|
|
414
|
+
sys.exit(0)
|
|
415
|
+
if not isinstance(hook_payload, dict):
|
|
416
|
+
sys.exit(0)
|
|
417
|
+
if not _should_run(hook_payload):
|
|
418
|
+
sys.exit(0)
|
|
419
|
+
deny_reason = _preflight_deny_reason(hook_payload)
|
|
420
|
+
if deny_reason is not None:
|
|
421
|
+
_emit_deny_payload(sys.stdout, deny_reason)
|
|
422
|
+
sys.exit(0)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
if __name__ == "__main__":
|
|
426
|
+
main()
|
|
@@ -14,6 +14,13 @@ import subprocess
|
|
|
14
14
|
import sys
|
|
15
15
|
from pathlib import Path
|
|
16
16
|
|
|
17
|
+
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
18
|
+
if _hooks_dir not in sys.path:
|
|
19
|
+
sys.path.insert(0, _hooks_dir)
|
|
20
|
+
|
|
21
|
+
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
22
|
+
|
|
23
|
+
|
|
17
24
|
def _resolve_pr_number(command: str, cwd: str | None) -> int | None:
|
|
18
25
|
direct_match = re.search(r"\bgh\s+pr\s+ready\s+(\d+)", command)
|
|
19
26
|
if direct_match:
|
|
@@ -112,15 +119,22 @@ def main() -> None:
|
|
|
112
119
|
if completed_process.returncode in (0, 2):
|
|
113
120
|
sys.exit(0)
|
|
114
121
|
|
|
122
|
+
block_reason = (
|
|
123
|
+
"Convergence check failed — PR is not ready to mark ready:\n\n" + completed_process.stdout
|
|
124
|
+
)
|
|
115
125
|
deny_payload = {
|
|
116
126
|
"hookSpecificOutput": {
|
|
117
127
|
"hookEventName": "PreToolUse",
|
|
118
128
|
"permissionDecision": "deny",
|
|
119
|
-
"permissionDecisionReason":
|
|
120
|
-
"Convergence check failed — PR is not ready to mark ready:\n\n" + completed_process.stdout
|
|
121
|
-
),
|
|
129
|
+
"permissionDecisionReason": block_reason,
|
|
122
130
|
}
|
|
123
131
|
}
|
|
132
|
+
log_hook_block(
|
|
133
|
+
calling_hook_name="convergence_gate_blocker.py",
|
|
134
|
+
hook_event="PreToolUse",
|
|
135
|
+
block_reason=block_reason,
|
|
136
|
+
tool_name="Bash",
|
|
137
|
+
)
|
|
124
138
|
print(json.dumps(deny_payload))
|
|
125
139
|
sys.stdout.flush()
|
|
126
140
|
sys.exit(0)
|
|
@@ -19,6 +19,7 @@ from hooks_constants.convergence_branch_constants import ( # noqa: E402
|
|
|
19
19
|
CONVERGENCE_BRANCH_SUFFIX_PATTERN,
|
|
20
20
|
CONVERGENCE_FORCE_PUSH_DETECTION_PATTERN,
|
|
21
21
|
)
|
|
22
|
+
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
22
23
|
from hooks_constants.destructive_command_segment_constants import ( # noqa: E402
|
|
23
24
|
ALL_BENIGN_COMPOUND_SEGMENT_COMMANDS,
|
|
24
25
|
ALL_COMMAND_LAUNCHER_WRAPPER_COMMANDS,
|
|
@@ -197,6 +198,12 @@ def _build_silent_gh_deny_response(matched_description: str) -> dict:
|
|
|
197
198
|
"Bash call prevents duplicate execution."
|
|
198
199
|
)
|
|
199
200
|
_append_destructive_gate_log_entry(brief_label, full_reason)
|
|
201
|
+
log_hook_block(
|
|
202
|
+
calling_hook_name="destructive_command_blocker.py",
|
|
203
|
+
hook_event="PreToolUse",
|
|
204
|
+
block_reason=full_reason,
|
|
205
|
+
tool_name="Bash",
|
|
206
|
+
)
|
|
200
207
|
return {
|
|
201
208
|
"hookSpecificOutput": {
|
|
202
209
|
"hookEventName": "PreToolUse",
|