claude-dev-env 1.73.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 +1 -1
- package/hooks/blocking/CLAUDE.md +3 -0
- 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 +14 -42
- package/hooks/blocking/code_rules_docstrings.py +223 -0
- package/hooks/blocking/code_rules_enforcer.py +16 -0
- package/hooks/blocking/code_verifier_spawn_preflight_gate.py +10 -4
- 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 +10 -2
- package/hooks/blocking/open_questions_in_plans_blocker.py +10 -2
- package/hooks/blocking/package_inventory_stale_blocker.py +398 -0
- package/hooks/blocking/plain_language_blocker.py +6 -0
- package/hooks/blocking/pr_converge_bugteam_enforcer.py +6 -0
- package/hooks/blocking/pr_description_enforcer.py +6 -0
- package/hooks/blocking/pre_tool_use_dispatcher.py +3 -3
- package/hooks/blocking/precommit_code_rules_gate.py +10 -1
- package/hooks/blocking/pytest_testpaths_orphan_blocker.py +8 -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 +6 -0
- package/hooks/blocking/subprocess_budget_completeness.py +9 -3
- package/hooks/blocking/tdd_enforcer.py +6 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_returns_plural_cardinality.py +207 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_unguarded_payload.py +188 -0
- package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +45 -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 +8 -8
- package/hooks/blocking/test_send_user_file_open_locally_blocker.py +114 -0
- package/hooks/blocking/test_shared_stdin_adoption.py +42 -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 +10 -1
- 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 +10 -0
- package/hooks/hooks_constants/CLAUDE.md +4 -0
- package/hooks/hooks_constants/blocking_check_limits.py +13 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +3 -0
- 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/package_inventory_stale_blocker_constants.py +111 -0
- package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +1 -2
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +9 -1
- 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 +30 -1
- package/hooks/validation/test_hook_format_validator.py +64 -0
- package/hooks/validation/test_mypy_validator.py +22 -0
- package/package.json +1 -1
- package/rules/CLAUDE.md +1 -0
- package/rules/docstring-prose-matches-implementation.md +2 -1
- package/rules/package-inventory-stale-entry.md +24 -0
- package/skills/autoconverge/SKILL.md +18 -1
- package/skills/autoconverge/workflow/converge.contract.test.mjs +106 -0
- package/skills/autoconverge/workflow/converge.mjs +2 -1
package/CLAUDE.md
CHANGED
|
@@ -35,6 +35,8 @@ When I ask you to "show me", "open", "display", "let me see", or "pull up" a fil
|
|
|
35
35
|
|
|
36
36
|
It sizes each image window to the image (scaled down to fit the screen) and opens non-image files in their default app; pass every path I name. Printing a path or attaching the file is not showing it — do that only when the file truly cannot be opened, and say why.
|
|
37
37
|
|
|
38
|
+
The `send_user_file_open_locally_blocker` hook backs this up: it blocks a desk-side `SendUserFile` attach and sends you back to this command, while a phone push (`status: "proactive"`) stays allowed.
|
|
39
|
+
|
|
38
40
|
## Test Philosophy
|
|
39
41
|
|
|
40
42
|
When writing tests, always write tests that actually test the behavior of the function against actual, real data and environments.
|
|
@@ -25,7 +25,7 @@ Decomposition is by the **kind of docstring claim** that needs to be cross-check
|
|
|
25
25
|
| O3 | Predicate-name and -docstring vs body breadth | A boolean helper's name and docstring promise a narrow predicate. Walk the body's branches: every branch's `return True` path is consistent with the promised name. Bodies that accept inputs broader than the name (`_dir_value_resolves_to_shared_temp` also accepting HOME/TMP env-derived paths) are O3 findings. |
|
|
26
26
|
| O4 | Step-ordering narrative | A docstring describes processing as `A then B then C`. Walk the body and confirm the call order matches. Mismatched order is an O4 finding regardless of whether the final output is the same. A docstring step enumeration that names the body's linear steps but omits a corrective workflow step the body guards inside an `if`/`elif` branch (`if not await cancel_and_reinitiate_update(...): return`) is also an O4 finding: the reader trusts the step list to be complete and misses the conditional path. The branch-guarded-dispatch shape of this drift — a docstring that names two or more linear-step callees while the body guards a two-or-more-token dispatch callee inside a branch whose name the prose never spells out — is gated deterministically at Write/Edit time by `check_docstring_step_enumeration_dispatch_coverage` (`packages/claude-dev-env/hooks/blocking/code_rules_docstrings.py`), so the audit lane focuses on the step-ordering shapes the gate cannot match (re-ordered steps, plain unguarded steps the prose omits). |
|
|
27
27
|
| O5 | Named-sentinel / filename references | A docstring names a sentinel marker, environment variable, filename, or magic string. Confirm the named token actually exists in the module body or in the repo's naming convention. |
|
|
28
|
-
| O6 | Free-form `Args:`-adjacent claims | A docstring's `Returns:` / `Raises:` / `Note:` / `Example:` sections make claims (`returns shared-temp only`, `raises ValueError on missing key`). Verify each claim against the body. When a docstring enumerates the inputs a body counts (a "field counts as read when ..." list, a list of conditions treated as a match, a list of cases the body skips), list every union member and every suppressor the body applies (`read_names = a | b | c`, each early-return guard) and confirm each appears in the prose enumeration. A union member or suppressor the body applies but the prose omits is an O6 finding. When a docstring sentence excludes a named category of input from what the function flags (`X are not dispatch steps`, `Y is not a match`), confirm the axis the prose excludes on is the axis the body's branch condition actually keys on. A body that flags a call when it sits inside an `If.test` guard, paired with prose that excludes by the call's receiver shape (`method-on-local calls inside a branch are not dispatch steps`), is an O6 finding: a guarded method-on-local call is flagged even though the prose lists it as excluded — the exclusion is keyed to the wrong axis. The single-condition shared-fallback shape of this drift — a summary that scopes a fallback call to one condition while the body routes to that same call from two or more early-return guards — is gated deterministically at Write/Edit time by `check_docstring_fallback_branch_coverage`, so the audit lane focuses on the O6 shapes the gate cannot match. A `Returns:` that names the mechanism, tool, or output format the function produces (`instructing a StructuredOutput summary`, `returns a YAML document`, `emits a JSON object`) matches the artifact the body actually builds: a prompt body that asks the agent to "Return strictly a JSON object" while the docstring claims it "instruct[s] a StructuredOutput" summary is an O6 finding, because the named tool appears nowhere in the emitted text. See `../../rules/docstring-prose-matches-implementation.md`. |
|
|
28
|
+
| O6 | Free-form `Args:`-adjacent claims | A docstring's `Returns:` / `Raises:` / `Note:` / `Example:` sections make claims (`returns shared-temp only`, `raises ValueError on missing key`). Verify each claim against the body. When a docstring enumerates the inputs a body counts (a "field counts as read when ..." list, a list of conditions treated as a match, a list of cases the body skips), list every union member and every suppressor the body applies (`read_names = a | b | c`, each early-return guard) and confirm each appears in the prose enumeration. A union member or suppressor the body applies but the prose omits is an O6 finding. When a docstring sentence excludes a named category of input from what the function flags (`X are not dispatch steps`, `Y is not a match`), confirm the axis the prose excludes on is the axis the body's branch condition actually keys on. A body that flags a call when it sits inside an `If.test` guard, paired with prose that excludes by the call's receiver shape (`method-on-local calls inside a branch are not dispatch steps`), is an O6 finding: a guarded method-on-local call is flagged even though the prose lists it as excluded — the exclusion is keyed to the wrong axis. The single-condition shared-fallback shape of this drift — a summary that scopes a fallback call to one condition while the body routes to that same call from two or more early-return guards — is gated deterministically at Write/Edit time by `check_docstring_fallback_branch_coverage`, so the audit lane focuses on the O6 shapes the gate cannot match. The exception-guard shape of this drift — a docstring that promises a malformed payload `resolves to None` while a payload subscript (`payload["key"]`, `float(payload["key"])`) sits outside the try/except whose handler returns None, so a present-but-malformed payload raises rather than resolving to None — is gated deterministically at Write/Edit time by `check_docstring_unguarded_malformed_payload_claim`, so the audit lane focuses on the wider Raises/None-on-failure claims the gate cannot match. A `Returns:` that names the mechanism, tool, or output format the function produces (`instructing a StructuredOutput summary`, `returns a YAML document`, `emits a JSON object`) matches the artifact the body actually builds: a prompt body that asks the agent to "Return strictly a JSON object" while the docstring claims it "instruct[s] a StructuredOutput" summary is an O6 finding, because the named tool appears nowhere in the emitted text. See `../../rules/docstring-prose-matches-implementation.md`. |
|
|
29
29
|
| O7 | Module-doc-vs-split-module after refactor | When a refactor moves a responsibility to a sibling module, the originating module's docstring and the receiving module's docstring both describe the home of that responsibility. A module docstring should describe only the responsibilities it owns. |
|
|
30
30
|
| O8 | Companion-doc ordering/content vs producer | When a PR changes a producer function's ordering or union, read that skill's companion `SKILL.md` and sibling `.md` docs for any sentence naming the same produced artifact (a file path, a JSON key, a named list). A doc sentence that claims the artifact is `sorted` / `alphabetical` / `in sorted order`, or holds `just the at-risk names` / `only the current set`, while the producer merges stored names with new names and appends — preserving file order, not re-sorting the union — is an O8 finding on both counts (wrong order claim, hidden merged-in entries). The finding stands even when the PR diff never touched the `.md` file, because the behavior change orphaned the doc claim. See `../../rules/docstring-prose-matches-implementation.md`. |
|
|
31
31
|
|
package/hooks/blocking/CLAUDE.md
CHANGED
|
@@ -62,6 +62,7 @@ The check modules it calls are the `code_rules_<concern>.py` files below.
|
|
|
62
62
|
| `code_verifier_spawn_preflight_gate.py` | PreToolUse (Agent) | Spawning the `code-verifier` subagent when the branch has a merge conflict vs its base or a CODE_RULES violation on a working-tree-added line |
|
|
63
63
|
| `convergence_gate_blocker.py` | PreToolUse (Bash) | Convergence workflow actions on a conflicting PR |
|
|
64
64
|
| `destructive_command_blocker.py` | PreToolUse (Bash/PowerShell) | Shell commands with destructive literals (`rm -rf`, `git reset --hard`, etc.) |
|
|
65
|
+
| `docstring_rule_gate_count_blocker.py` | PreToolUse (Write/Edit/MultiEdit) | A stale spelled-out gate-validator count in `docstring-prose-matches-implementation.md` — the "N more gate validators" / "M gated slices" count drifting from the `check_docstring_*` validators the prose names |
|
|
65
66
|
| `es_exe_path_rewriter.py` | PreToolUse | Rewrites paths referencing `.exe` under the Everything search path |
|
|
66
67
|
| `gh_body_arg_blocker.py` | PreToolUse (Bash) | `gh` commands passing `--body`/`-b` directly (requires `--body-file` instead) |
|
|
67
68
|
| `gh_pr_author_enforcer.py` | PreToolUse | Enforces PR author identity rules |
|
|
@@ -71,12 +72,14 @@ The check modules it calls are the `code_rules_<concern>.py` files below.
|
|
|
71
72
|
| `intent_only_ending_blocker.py` | Stop | Responses that end on a plan or intent without doing the work |
|
|
72
73
|
| `md_to_html_blocker.py` | PreToolUse (Write/Edit) | Writing `.md` files when an `.html` companion is required |
|
|
73
74
|
| `open_questions_in_plans_blocker.py` | PreToolUse (Write/Edit) | Plan documents with unresolved open questions |
|
|
75
|
+
| `package_inventory_stale_blocker.py` | PreToolUse (Write) | A new production code file created in a directory whose `README.md`/`CLAUDE.md` inventory names two or more sibling files but no entry for the new file |
|
|
74
76
|
| `plain_language_blocker.py` | PreToolUse (Write/Edit/AskUserQuestion) | Heavy or jargon words in user-facing prose |
|
|
75
77
|
| `pr_converge_bugteam_enforcer.py` | PreToolUse | Enforces that bugteam runs in parallel with bugbot in pr-converge loops |
|
|
76
78
|
| `pr_description_enforcer.py` | PreToolUse (Bash) | `gh pr create`/`edit` without a PR-description-writer-authored body |
|
|
77
79
|
| `precommit_code_rules_gate.py` | PreToolUse (Bash) | Staged changes that fail the CODE_RULES gate at commit time |
|
|
78
80
|
| `pytest_testpaths_orphan_blocker.py` | PreToolUse (Write/Edit/MultiEdit) | New `test_*.py` files created under a directory absent from a package's explicit pytest `testpaths` allowlist |
|
|
79
81
|
| `question_to_user_enforcer.py` | Stop | User-directed questions not routed through `AskUserQuestion` |
|
|
82
|
+
| `send_user_file_open_locally_blocker.py` | PreToolUse (SendUserFile) | A desk-side file attach (`SendUserFile` with `status` not `proactive`); points to opening the file locally via `Show-Asset.ps1` |
|
|
80
83
|
| `sensitive_file_protector.py` | PreToolUse (Write/Edit) | Writes to sensitive credential or config files |
|
|
81
84
|
| `session_handoff_blocker.py` | Stop | Responses suggesting a new session mid-task |
|
|
82
85
|
| `state_description_blocker.py` | PreToolUse (Write/Edit) | Historical/comparative language in documentation |
|
|
@@ -15,6 +15,13 @@ import os
|
|
|
15
15
|
import re
|
|
16
16
|
import subprocess
|
|
17
17
|
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
21
|
+
if _hooks_dir not in sys.path:
|
|
22
|
+
sys.path.insert(0, _hooks_dir)
|
|
23
|
+
|
|
24
|
+
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
18
25
|
|
|
19
26
|
GIT_COMMAND_TIMEOUT_SECONDS = 5
|
|
20
27
|
PROTECTED_BRANCHES = ("main", "master")
|
|
@@ -172,6 +179,13 @@ def main() -> None:
|
|
|
172
179
|
sys.exit(0)
|
|
173
180
|
|
|
174
181
|
denial = build_denial_response(current_branch, target_dir)
|
|
182
|
+
log_hook_block(
|
|
183
|
+
calling_hook_name="block_main_commit.py",
|
|
184
|
+
hook_event="PreToolUse",
|
|
185
|
+
block_reason=denial["hookSpecificOutput"]["permissionDecisionReason"],
|
|
186
|
+
tool_name="Bash",
|
|
187
|
+
offending_input_preview=bash_command,
|
|
188
|
+
)
|
|
175
189
|
print(json.dumps(denial))
|
|
176
190
|
sys.exit(0)
|
|
177
191
|
|
|
@@ -21,6 +21,7 @@ from hooks_constants.bot_mention_comment_blocker_constants import ( # noqa: E40
|
|
|
21
21
|
CURSOR_MENTION_TOKEN,
|
|
22
22
|
TOOL_NAME,
|
|
23
23
|
)
|
|
24
|
+
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
24
25
|
|
|
25
26
|
|
|
26
27
|
def _body_contains_token(body: str, token: str) -> bool:
|
|
@@ -60,6 +61,12 @@ def main() -> None:
|
|
|
60
61
|
"permissionDecisionReason": corrective_message,
|
|
61
62
|
}
|
|
62
63
|
}
|
|
64
|
+
log_hook_block(
|
|
65
|
+
calling_hook_name="bot_mention_comment_blocker.py",
|
|
66
|
+
hook_event="PreToolUse",
|
|
67
|
+
block_reason=corrective_message,
|
|
68
|
+
tool_name=TOOL_NAME,
|
|
69
|
+
)
|
|
63
70
|
print(json.dumps(deny_payload))
|
|
64
71
|
sys.stdout.flush()
|
|
65
72
|
sys.exit(0)
|
|
@@ -39,6 +39,11 @@ from hooks_constants.claude_md_orphan_file_blocker_constants import ( # noqa: E
|
|
|
39
39
|
SEPARATOR_CELL_PATTERN,
|
|
40
40
|
TABLE_ROW_PATTERN,
|
|
41
41
|
)
|
|
42
|
+
from hooks_constants.multi_edit_reconstruction import ( # noqa: E402
|
|
43
|
+
apply_edits,
|
|
44
|
+
edits_for_tool,
|
|
45
|
+
)
|
|
46
|
+
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
42
47
|
from hooks_constants.pre_tool_use_stdin import ( # noqa: E402
|
|
43
48
|
read_hook_input_dictionary_from_stdin,
|
|
44
49
|
)
|
|
@@ -409,29 +414,6 @@ def _read_existing_file_content(file_path: str) -> str | None:
|
|
|
409
414
|
return None
|
|
410
415
|
|
|
411
416
|
|
|
412
|
-
def _apply_edits(existing_content: str, all_edits: list[dict]) -> str:
|
|
413
|
-
"""Return *existing_content* with each MultiEdit replacement applied in order.
|
|
414
|
-
|
|
415
|
-
Args:
|
|
416
|
-
existing_content: The current on-disk file content.
|
|
417
|
-
all_edits: The MultiEdit ``edits`` list, each a mapping with an
|
|
418
|
-
``old_string`` and a ``new_string``.
|
|
419
|
-
|
|
420
|
-
Returns:
|
|
421
|
-
The content after replacing the first occurrence of each edit's
|
|
422
|
-
``old_string`` with its ``new_string``, in list order.
|
|
423
|
-
"""
|
|
424
|
-
edited_content = existing_content
|
|
425
|
-
for each_edit in all_edits:
|
|
426
|
-
if not isinstance(each_edit, dict):
|
|
427
|
-
continue
|
|
428
|
-
old_string = each_edit.get("old_string", "")
|
|
429
|
-
new_string = each_edit.get("new_string", "")
|
|
430
|
-
if isinstance(old_string, str) and isinstance(new_string, str) and old_string:
|
|
431
|
-
edited_content = edited_content.replace(old_string, new_string, 1)
|
|
432
|
-
return edited_content
|
|
433
|
-
|
|
434
|
-
|
|
435
417
|
def _edit_fragments(all_edits: list[dict]) -> list[str]:
|
|
436
418
|
"""Return each MultiEdit ``new_string`` fragment present as a non-empty string.
|
|
437
419
|
|
|
@@ -498,29 +480,12 @@ def _build_orphan_scan_plan(
|
|
|
498
480
|
content = tool_input.get("content", "")
|
|
499
481
|
candidate_contents = [content] if isinstance(content, str) and content else []
|
|
500
482
|
return _OrphanScanPlan(candidate_contents, set())
|
|
501
|
-
all_edits =
|
|
483
|
+
all_edits = edits_for_tool(tool_name, tool_input)
|
|
502
484
|
existing_content = _read_existing_file_content(file_path)
|
|
503
485
|
if existing_content is None:
|
|
504
486
|
return _OrphanScanPlan(_edit_fragments(all_edits), set())
|
|
505
487
|
baseline_missing = set(find_missing_filenames(existing_content, claude_md_directory))
|
|
506
|
-
return _OrphanScanPlan([
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
def _edits_for_tool(tool_name: str, tool_input: dict) -> list[dict]:
|
|
510
|
-
"""Return the edit mappings an Edit or MultiEdit payload carries.
|
|
511
|
-
|
|
512
|
-
Args:
|
|
513
|
-
tool_name: The intercepted tool — ``Edit`` or ``MultiEdit``.
|
|
514
|
-
tool_input: The tool's input payload.
|
|
515
|
-
|
|
516
|
-
Returns:
|
|
517
|
-
A single-element list holding the Edit payload, or the MultiEdit
|
|
518
|
-
``edits`` list when it is present as a list; an empty list otherwise.
|
|
519
|
-
"""
|
|
520
|
-
if tool_name == "Edit":
|
|
521
|
-
return [tool_input]
|
|
522
|
-
all_edits = tool_input.get("edits", [])
|
|
523
|
-
return all_edits if isinstance(all_edits, list) else []
|
|
488
|
+
return _OrphanScanPlan([apply_edits(existing_content, all_edits)], baseline_missing)
|
|
524
489
|
|
|
525
490
|
|
|
526
491
|
def _collect_missing_filenames(scan_plan: _OrphanScanPlan, claude_md_directory: Path) -> list[str]:
|
|
@@ -623,6 +588,13 @@ def main() -> None:
|
|
|
623
588
|
sys.exit(0)
|
|
624
589
|
|
|
625
590
|
block_payload = _build_block_payload(missing_filenames, str(claude_md_directory))
|
|
591
|
+
log_hook_block(
|
|
592
|
+
calling_hook_name="claude_md_orphan_file_blocker.py",
|
|
593
|
+
hook_event="PreToolUse",
|
|
594
|
+
block_reason=block_payload["hookSpecificOutput"]["permissionDecisionReason"],
|
|
595
|
+
tool_name=tool_name,
|
|
596
|
+
offending_input_preview=file_path,
|
|
597
|
+
)
|
|
626
598
|
_emit_hook_result(block_payload, sys.stdout)
|
|
627
599
|
sys.exit(0)
|
|
628
600
|
|
|
@@ -25,6 +25,7 @@ from hooks_constants.blocking_check_limits import ( # noqa: E402
|
|
|
25
25
|
ALL_DOCSTRING_EXCLUSIVE_SCOPE_PHRASES,
|
|
26
26
|
ALL_DOCSTRING_EXEMPT_DECORATOR_NAMES,
|
|
27
27
|
ALL_DOCSTRING_FILE_REFERENCE_SUFFIXES,
|
|
28
|
+
ALL_DOCSTRING_GUARDED_FAILURE_CLAIM_PHRASES,
|
|
28
29
|
ALL_DOCSTRING_IMPLICIT_INSTANCE_PARAMETER_NAMES,
|
|
29
30
|
ALL_DOCSTRING_MULTIPLE_CONDITION_JOINING_PHRASES,
|
|
30
31
|
ALL_DOCSTRING_NO_CONSUMER_CLAIM_PHRASES,
|
|
@@ -42,14 +43,17 @@ from hooks_constants.blocking_check_limits import ( # noqa: E402
|
|
|
42
43
|
MAX_DOCSTRING_INLINE_LITERAL_CLAIM_ISSUES,
|
|
43
44
|
MAX_DOCSTRING_NO_CONSUMER_CLAIM_ISSUES,
|
|
44
45
|
MAX_DOCSTRING_STEP_DISPATCH_ISSUES,
|
|
46
|
+
MAX_DOCSTRING_RETURNS_PLURAL_CARDINALITY_ISSUES,
|
|
45
47
|
MAX_DOCSTRING_TUPLE_ENUMERATION_ISSUES,
|
|
46
48
|
MAX_DOCSTRING_UNDEFINED_CONSTANT_ISSUES,
|
|
49
|
+
MAX_DOCSTRING_UNGUARDED_PAYLOAD_CLAIM_ISSUES,
|
|
47
50
|
MAX_MODULE_DOCSTRING_CHECK_ROSTER_ISSUES,
|
|
48
51
|
MINIMUM_NAMED_LINEAR_STEPS_FOR_DISPATCH_CHECK,
|
|
49
52
|
MINIMUM_PUBLIC_CHECKS_FOR_MODULE_DOCSTRING_ROSTER,
|
|
50
53
|
MINIMUM_PUBLIC_METHODS_FOR_CLASS_DOCSTRING_BREADTH,
|
|
51
54
|
MINIMUM_TOKENS_FOR_DISPATCH_CALLEE,
|
|
52
55
|
MINIMUM_TUPLE_MEMBERS_FOR_DOCSTRING_ENUMERATION,
|
|
56
|
+
SINGLE_DICT_KEY_COUNT_FOR_PLURAL_CARDINALITY_DRIFT,
|
|
53
57
|
)
|
|
54
58
|
from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
|
|
55
59
|
ALL_CAPS_WITH_UNDERSCORE_PATTERN,
|
|
@@ -57,6 +61,7 @@ from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
|
|
|
57
61
|
ALL_DOCSTRING_TERMINATING_SECTION_HEADERS,
|
|
58
62
|
ALL_SELF_AND_CLS_PARAMETER_NAMES,
|
|
59
63
|
DOCSTRING_ARG_ENTRY_PATTERN,
|
|
64
|
+
DOCSTRING_PLURAL_FAMILY_STOP_PATTERN,
|
|
60
65
|
IDENTIFIER_SHAPED_TUPLE_MEMBER_PATTERN,
|
|
61
66
|
INLINE_CODE_TOKEN_PATTERN,
|
|
62
67
|
)
|
|
@@ -641,6 +646,123 @@ def check_docstring_no_consumer_claim(content: str, file_path: str) -> list[str]
|
|
|
641
646
|
return issues[:MAX_DOCSTRING_NO_CONSUMER_CLAIM_ISSUES]
|
|
642
647
|
|
|
643
648
|
|
|
649
|
+
def _docstring_claims_malformed_payload_is_guarded(docstring_text: str) -> str:
|
|
650
|
+
collapsed_docstring = " ".join(docstring_text.lower().split())
|
|
651
|
+
for each_phrase in ALL_DOCSTRING_GUARDED_FAILURE_CLAIM_PHRASES:
|
|
652
|
+
if each_phrase in collapsed_docstring:
|
|
653
|
+
return each_phrase
|
|
654
|
+
return ""
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def _try_handler_returns_none(try_node: ast.Try) -> bool:
|
|
658
|
+
for each_handler in try_node.handlers:
|
|
659
|
+
for each_statement in each_handler.body:
|
|
660
|
+
if isinstance(each_statement, ast.Return) and isinstance(
|
|
661
|
+
each_statement.value, ast.Constant
|
|
662
|
+
):
|
|
663
|
+
if each_statement.value.value is None:
|
|
664
|
+
return True
|
|
665
|
+
return False
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def _names_bound_in_try_body(try_node: ast.Try) -> set[str]:
|
|
669
|
+
all_bound_names: set[str] = set()
|
|
670
|
+
for each_statement in try_node.body:
|
|
671
|
+
for each_descendant in ast.walk(each_statement):
|
|
672
|
+
if isinstance(each_descendant, ast.Name) and isinstance(
|
|
673
|
+
each_descendant.ctx, ast.Store
|
|
674
|
+
):
|
|
675
|
+
all_bound_names.add(each_descendant.id)
|
|
676
|
+
return all_bound_names
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def _statement_subscripts_one_of(
|
|
680
|
+
statement: ast.stmt, all_payload_names: set[str]
|
|
681
|
+
) -> bool:
|
|
682
|
+
for each_descendant in ast.walk(statement):
|
|
683
|
+
if (
|
|
684
|
+
isinstance(each_descendant, ast.Subscript)
|
|
685
|
+
and isinstance(each_descendant.value, ast.Name)
|
|
686
|
+
and each_descendant.value.id in all_payload_names
|
|
687
|
+
):
|
|
688
|
+
return True
|
|
689
|
+
return False
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def _function_has_unguarded_payload_dereference(
|
|
693
|
+
function_node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
694
|
+
) -> bool:
|
|
695
|
+
all_payload_names: set[str] = set()
|
|
696
|
+
saw_returning_guard = False
|
|
697
|
+
for each_statement in function_node.body:
|
|
698
|
+
if isinstance(each_statement, ast.Try) and _try_handler_returns_none(
|
|
699
|
+
each_statement
|
|
700
|
+
):
|
|
701
|
+
all_payload_names |= _names_bound_in_try_body(each_statement)
|
|
702
|
+
saw_returning_guard = True
|
|
703
|
+
continue
|
|
704
|
+
if not saw_returning_guard:
|
|
705
|
+
continue
|
|
706
|
+
if _statement_subscripts_one_of(each_statement, all_payload_names):
|
|
707
|
+
return True
|
|
708
|
+
return False
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def check_docstring_unguarded_malformed_payload_claim(
|
|
712
|
+
content: str, file_path: str
|
|
713
|
+
) -> list[str]:
|
|
714
|
+
"""Flag a docstring that promises malformed-payload safety the guard misses.
|
|
715
|
+
|
|
716
|
+
The drift this catches: a function whose docstring states that a malformed
|
|
717
|
+
payload "resolves to None" while a subscript dereference of that payload
|
|
718
|
+
(``payload["key"]``, ``float(payload["key"])``) sits OUTSIDE the try/except
|
|
719
|
+
whose handler returns None. A present-but-malformed payload then raises
|
|
720
|
+
KeyError or TypeError from that unguarded access and propagates rather than
|
|
721
|
+
resolving to None, so the docstring overstates the protection. This is the
|
|
722
|
+
deterministic slice of Category O6 (docstring prose vs implementation drift)
|
|
723
|
+
for an exception-guard claim: move the dereference inside the guarded block,
|
|
724
|
+
or narrow the docstring to the failures the guard actually catches.
|
|
725
|
+
|
|
726
|
+
Args:
|
|
727
|
+
content: The source text to inspect.
|
|
728
|
+
file_path: The path the source will be written to, used for exemptions.
|
|
729
|
+
|
|
730
|
+
Returns:
|
|
731
|
+
One issue per function whose malformed-payload claim outruns its guard,
|
|
732
|
+
capped at the module limit.
|
|
733
|
+
"""
|
|
734
|
+
if is_test_file(file_path) or is_hook_infrastructure(file_path):
|
|
735
|
+
return []
|
|
736
|
+
try:
|
|
737
|
+
parsed_tree = ast.parse(content)
|
|
738
|
+
except SyntaxError:
|
|
739
|
+
return []
|
|
740
|
+
issues: list[str] = []
|
|
741
|
+
for each_node in _walk_skipping_type_checking_blocks(parsed_tree):
|
|
742
|
+
if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
743
|
+
continue
|
|
744
|
+
if _function_has_exempt_decorator(each_node):
|
|
745
|
+
continue
|
|
746
|
+
docstring_text = _function_docstring_text(each_node)
|
|
747
|
+
if not docstring_text:
|
|
748
|
+
continue
|
|
749
|
+
matched_phrase = _docstring_claims_malformed_payload_is_guarded(docstring_text)
|
|
750
|
+
if not matched_phrase:
|
|
751
|
+
continue
|
|
752
|
+
if not _function_has_unguarded_payload_dereference(each_node):
|
|
753
|
+
continue
|
|
754
|
+
issues.append(
|
|
755
|
+
f"Line {each_node.lineno}: {each_node.name}() docstring claims "
|
|
756
|
+
f"'{matched_phrase}' but a payload subscript sits outside the try/except that "
|
|
757
|
+
"returns None — a malformed-but-present payload raises rather than resolving to "
|
|
758
|
+
"None; move the dereference inside the guard or narrow the docstring "
|
|
759
|
+
"(Category O6 docstring-vs-implementation drift)"
|
|
760
|
+
)
|
|
761
|
+
if len(issues) >= MAX_DOCSTRING_UNGUARDED_PAYLOAD_CLAIM_ISSUES:
|
|
762
|
+
break
|
|
763
|
+
return issues[:MAX_DOCSTRING_UNGUARDED_PAYLOAD_CLAIM_ISSUES]
|
|
764
|
+
|
|
765
|
+
|
|
644
766
|
def _module_docstring_claims_no_inline_literal(module_docstring: str) -> str:
|
|
645
767
|
collapsed_docstring = " ".join(module_docstring.lower().split())
|
|
646
768
|
for each_phrase in ALL_DOCSTRING_NO_INLINE_LITERAL_CLAIM_PHRASES:
|
|
@@ -888,6 +1010,107 @@ def check_docstring_tuple_enumeration_match(content: str, file_path: str) -> lis
|
|
|
888
1010
|
return issues[:MAX_DOCSTRING_TUPLE_ENUMERATION_ISSUES]
|
|
889
1011
|
|
|
890
1012
|
|
|
1013
|
+
def _returns_section_text(docstring_text: str) -> str:
|
|
1014
|
+
docstring_lines = docstring_text.splitlines()
|
|
1015
|
+
returns_section_lines: list[str] = []
|
|
1016
|
+
inside_returns_section = False
|
|
1017
|
+
for each_line in docstring_lines:
|
|
1018
|
+
stripped_line = each_line.strip()
|
|
1019
|
+
if stripped_line in ("Returns:", "Yields:"):
|
|
1020
|
+
inside_returns_section = True
|
|
1021
|
+
continue
|
|
1022
|
+
if not inside_returns_section:
|
|
1023
|
+
continue
|
|
1024
|
+
if _is_docstring_terminating_section_header(stripped_line):
|
|
1025
|
+
break
|
|
1026
|
+
returns_section_lines.append(stripped_line)
|
|
1027
|
+
return " ".join(returns_section_lines)
|
|
1028
|
+
|
|
1029
|
+
|
|
1030
|
+
def _plural_families_in_returns_section(returns_section_text: str) -> set[str]:
|
|
1031
|
+
return {
|
|
1032
|
+
each_match.group(1)
|
|
1033
|
+
for each_match in DOCSTRING_PLURAL_FAMILY_STOP_PATTERN.finditer(returns_section_text)
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
|
|
1037
|
+
def _returned_dict_key_names(
|
|
1038
|
+
function_node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
1039
|
+
) -> set[str]:
|
|
1040
|
+
all_key_names: set[str] = set()
|
|
1041
|
+
for each_node in ast.walk(function_node):
|
|
1042
|
+
if not isinstance(each_node, ast.Return):
|
|
1043
|
+
continue
|
|
1044
|
+
if not isinstance(each_node.value, ast.Dict):
|
|
1045
|
+
continue
|
|
1046
|
+
for each_key in each_node.value.keys:
|
|
1047
|
+
if isinstance(each_key, ast.Constant) and isinstance(each_key.value, str):
|
|
1048
|
+
all_key_names.add(each_key.value)
|
|
1049
|
+
return all_key_names
|
|
1050
|
+
|
|
1051
|
+
|
|
1052
|
+
def _family_prefixed_key_count(family: str, all_key_names: set[str]) -> int:
|
|
1053
|
+
family_prefix = f"{family}_"
|
|
1054
|
+
return sum(1 for each_key in all_key_names if each_key.startswith(family_prefix))
|
|
1055
|
+
|
|
1056
|
+
|
|
1057
|
+
def check_docstring_returns_plural_cardinality(content: str, file_path: str) -> list[str]:
|
|
1058
|
+
"""Flag a Returns clause plural noun that names one dict key in its family.
|
|
1059
|
+
|
|
1060
|
+
The drift this catches: a function returns a dict literal whose keys carry
|
|
1061
|
+
prefix families (``sheen_mid``, ``body_highlight``), and its Returns clause
|
|
1062
|
+
names one family with a plural noun (``the sheen stops``) while exactly one
|
|
1063
|
+
key in that family exists. The plural prose claims two or more entries the
|
|
1064
|
+
dict no longer holds — the shape that appears when a producer removes the
|
|
1065
|
+
second key in a family but leaves the plural prose untouched. The check binds
|
|
1066
|
+
only when the plural family prefixes exactly one returned dict key, so a
|
|
1067
|
+
singular noun, a family with two or more keys, and a family absent from the
|
|
1068
|
+
dict are all left alone. This is the deterministic single-key slice of
|
|
1069
|
+
Category O6 docstring-prose-vs-implementation drift.
|
|
1070
|
+
|
|
1071
|
+
Args:
|
|
1072
|
+
content: The source text to inspect.
|
|
1073
|
+
file_path: The path the source will be written to, used for exemptions.
|
|
1074
|
+
|
|
1075
|
+
Returns:
|
|
1076
|
+
One issue per function whose Returns clause names a plural family that
|
|
1077
|
+
prefixes a single returned dict key, capped at the module limit.
|
|
1078
|
+
"""
|
|
1079
|
+
if is_test_file(file_path) or is_hook_infrastructure(file_path):
|
|
1080
|
+
return []
|
|
1081
|
+
try:
|
|
1082
|
+
parsed_tree = ast.parse(content)
|
|
1083
|
+
except SyntaxError:
|
|
1084
|
+
return []
|
|
1085
|
+
issues: list[str] = []
|
|
1086
|
+
for each_node in _walk_skipping_type_checking_blocks(parsed_tree):
|
|
1087
|
+
if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
1088
|
+
continue
|
|
1089
|
+
docstring_text = _function_docstring_text(each_node)
|
|
1090
|
+
if not docstring_text:
|
|
1091
|
+
continue
|
|
1092
|
+
returns_section_text = _returns_section_text(docstring_text)
|
|
1093
|
+
if not returns_section_text:
|
|
1094
|
+
continue
|
|
1095
|
+
plural_families = _plural_families_in_returns_section(returns_section_text)
|
|
1096
|
+
if not plural_families:
|
|
1097
|
+
continue
|
|
1098
|
+
all_key_names = _returned_dict_key_names(each_node)
|
|
1099
|
+
for each_family in sorted(plural_families):
|
|
1100
|
+
matching_key_count = _family_prefixed_key_count(each_family, all_key_names)
|
|
1101
|
+
if matching_key_count != SINGLE_DICT_KEY_COUNT_FOR_PLURAL_CARDINALITY_DRIFT:
|
|
1102
|
+
continue
|
|
1103
|
+
issues.append(
|
|
1104
|
+
f"Line {each_node.lineno}: {each_node.name}() Returns clause says "
|
|
1105
|
+
f"'the {each_family} stops' (plural) but the returned dict holds a "
|
|
1106
|
+
f"single {each_family}_ key — match the noun to the cardinality "
|
|
1107
|
+
"(Category O6 docstring-vs-implementation drift)"
|
|
1108
|
+
)
|
|
1109
|
+
if len(issues) >= MAX_DOCSTRING_RETURNS_PLURAL_CARDINALITY_ISSUES:
|
|
1110
|
+
return issues[:MAX_DOCSTRING_RETURNS_PLURAL_CARDINALITY_ISSUES]
|
|
1111
|
+
return issues[:MAX_DOCSTRING_RETURNS_PLURAL_CARDINALITY_ISSUES]
|
|
1112
|
+
|
|
1113
|
+
|
|
891
1114
|
def _called_callee_name(statement: ast.stmt) -> str:
|
|
892
1115
|
candidate_expression: ast.expr | None = None
|
|
893
1116
|
if isinstance(statement, ast.Expr):
|
|
@@ -72,8 +72,10 @@ from code_rules_docstrings import ( # noqa: E402
|
|
|
72
72
|
check_docstring_names_undefined_constant,
|
|
73
73
|
check_docstring_no_consumer_claim,
|
|
74
74
|
check_docstring_no_inline_literal_claim,
|
|
75
|
+
check_docstring_returns_plural_cardinality,
|
|
75
76
|
check_docstring_step_enumeration_dispatch_coverage,
|
|
76
77
|
check_docstring_tuple_enumeration_match,
|
|
78
|
+
check_docstring_unguarded_malformed_payload_claim,
|
|
77
79
|
check_module_docstring_names_public_checks,
|
|
78
80
|
)
|
|
79
81
|
from code_rules_duplicate_body import ( # noqa: E402
|
|
@@ -159,6 +161,7 @@ from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
|
|
|
159
161
|
PRECHECK_USAGE_EXIT_CODE,
|
|
160
162
|
PRECHECK_USAGE_MESSAGE,
|
|
161
163
|
)
|
|
164
|
+
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
162
165
|
from hooks_constants.setup_project_paths_constants import ( # noqa: E402
|
|
163
166
|
UTF8_BYTE_ORDER_MARK,
|
|
164
167
|
)
|
|
@@ -260,6 +263,11 @@ def validate_content(
|
|
|
260
263
|
all_issues.extend(check_docstring_args_match_signature(effective_content, file_path))
|
|
261
264
|
all_issues.extend(check_docstring_fallback_branch_coverage(effective_content, file_path))
|
|
262
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
|
+
)
|
|
263
271
|
all_issues.extend(
|
|
264
272
|
check_docstring_no_inline_literal_claim(effective_content, file_path)
|
|
265
273
|
)
|
|
@@ -277,6 +285,9 @@ def validate_content(
|
|
|
277
285
|
effective_content, file_path
|
|
278
286
|
)
|
|
279
287
|
)
|
|
288
|
+
all_issues.extend(
|
|
289
|
+
check_docstring_returns_plural_cardinality(effective_content, file_path)
|
|
290
|
+
)
|
|
280
291
|
all_issues.extend(
|
|
281
292
|
check_docstring_names_undefined_constant(effective_content, file_path)
|
|
282
293
|
)
|
|
@@ -783,6 +794,11 @@ def _write_deny_payload(deny_reason: str, deny_stream: TextIO) -> None:
|
|
|
783
794
|
"permissionDecisionReason": deny_reason,
|
|
784
795
|
}
|
|
785
796
|
}
|
|
797
|
+
log_hook_block(
|
|
798
|
+
calling_hook_name="code_rules_enforcer.py",
|
|
799
|
+
hook_event="PreToolUse",
|
|
800
|
+
block_reason=deny_reason,
|
|
801
|
+
)
|
|
786
802
|
deny_stream.write(json.dumps(deny_payload) + "\n")
|
|
787
803
|
deny_stream.flush()
|
|
788
804
|
|
|
@@ -25,14 +25,14 @@ import sys
|
|
|
25
25
|
from pathlib import Path
|
|
26
26
|
from typing import TextIO
|
|
27
27
|
|
|
28
|
-
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
29
|
-
if _hooks_dir not in sys.path:
|
|
30
|
-
sys.path.insert(0, _hooks_dir)
|
|
31
|
-
|
|
32
28
|
_blocking_dir = str(Path(__file__).resolve().parent)
|
|
33
29
|
if _blocking_dir not in sys.path:
|
|
34
30
|
sys.path.insert(0, _blocking_dir)
|
|
35
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
36
|
from verification_verdict_store import ( # noqa: E402
|
|
37
37
|
candidate_base_references,
|
|
38
38
|
resolve_merge_base,
|
|
@@ -54,6 +54,7 @@ from hooks_constants.code_verifier_spawn_preflight_gate_constants import ( # no
|
|
|
54
54
|
MERGE_TREE_CONFLICT_EXIT_CODE,
|
|
55
55
|
MERGE_TREE_TIMEOUT_SECONDS,
|
|
56
56
|
)
|
|
57
|
+
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
57
58
|
from hooks_constants.pr_converge_bugteam_enforcer_constants import ( # noqa: E402
|
|
58
59
|
AGENT_TOOL_NAME,
|
|
59
60
|
)
|
|
@@ -370,6 +371,11 @@ def _emit_deny_payload(output_stream: TextIO, reason: str) -> None:
|
|
|
370
371
|
"permissionDecisionReason": reason,
|
|
371
372
|
}
|
|
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
|
+
)
|
|
373
379
|
output_stream.write(json.dumps(deny_payload) + "\n")
|
|
374
380
|
output_stream.flush()
|
|
375
381
|
|
|
@@ -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",
|