claude-dev-env 2.0.2 → 2.1.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 +1 -1
- package/hooks/blocking/CLAUDE.md +1 -0
- package/hooks/blocking/code_rules_shared.py +126 -47
- package/hooks/blocking/config/CLAUDE.md +2 -0
- package/hooks/blocking/config/verified_commit_context_constants.py +21 -0
- package/hooks/blocking/config/verified_commit_gate_output_constants.py +14 -0
- package/hooks/blocking/conftest.py +36 -30
- package/hooks/blocking/convergence_gate_blocker.py +76 -8
- package/hooks/blocking/plain_language_blocker.py +8 -0
- package/hooks/blocking/state_description_blocker.py +4 -1
- package/hooks/blocking/test_code_rules_shared.py +120 -20
- package/hooks/blocking/test_convergence_gate_blocker.py +146 -0
- package/hooks/blocking/test_plain_language_blocker.py +15 -0
- package/hooks/blocking/test_state_description_blocker.py +15 -0
- package/hooks/blocking/test_verified_commit_config_bootstrap.py +18 -0
- package/hooks/blocking/test_verified_commit_gate_additional_context.py +134 -0
- package/hooks/blocking/tests/test_verified_commit_gate.py +41 -0
- package/hooks/blocking/verified_commit_config_bootstrap.py +22 -10
- package/hooks/blocking/verified_commit_gate.py +113 -568
- package/hooks/blocking/verified_commit_gate_parts/CLAUDE.md +28 -0
- package/hooks/blocking/verified_commit_gate_parts/__init__.py +1 -0
- package/hooks/blocking/verified_commit_gate_parts/command_tokenization.py +174 -0
- package/hooks/blocking/verified_commit_gate_parts/deny_payload.py +53 -0
- package/hooks/blocking/verified_commit_gate_parts/deny_reason.py +80 -0
- package/hooks/blocking/verified_commit_gate_parts/directory_resolution.py +170 -0
- package/hooks/blocking/verified_commit_gate_parts/gated_invocations.py +205 -0
- package/hooks/blocking/verified_commit_gate_parts/tests/conftest.py +10 -0
- package/hooks/blocking/verified_commit_gate_parts/tests/test_command_tokenization.py +94 -0
- package/hooks/blocking/verified_commit_gate_parts/tests/test_deny_payload.py +17 -0
- package/hooks/blocking/verified_commit_gate_parts/tests/test_deny_reason.py +38 -0
- package/hooks/blocking/verified_commit_gate_parts/tests/test_directory_resolution.py +71 -0
- package/hooks/blocking/verified_commit_gate_parts/tests/test_gated_invocations.py +61 -0
- package/hooks/hooks_constants/CLAUDE.md +4 -1
- package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -0
- package/hooks/hooks_constants/convergence_gate_blocker_constants.py +36 -0
- package/hooks/hooks_constants/harness_scratchpad_constants.py +10 -9
- package/hooks/hooks_constants/mypy_integration_constants.py +16 -0
- package/hooks/validators/mypy_integration.py +145 -24
- package/hooks/validators/python_antipattern_checks.py +16 -0
- package/hooks/validators/run_all_validators.py +9 -3
- package/hooks/validators/test_mypy_integration.py +154 -0
- package/hooks/validators/test_python_antipattern_checks.py +112 -1
- package/hooks/validators/test_run_all_validators_pretooluse.py +16 -0
- package/package.json +1 -1
- package/rules/CLAUDE.md +1 -0
- package/rules/verified-commit-gate-skip.md +28 -0
- package/skills/auditing-claude-config/CLAUDE.md +2 -1
- package/skills/auditing-claude-config/SKILL.md +114 -176
- package/skills/auditing-claude-config/reference/probe-hook.md +74 -0
- package/skills/autoconverge/CLAUDE.md +1 -0
- package/skills/autoconverge/SKILL.md +310 -346
- package/skills/autoconverge/reference/CLAUDE.md +1 -0
- package/skills/autoconverge/reference/copilot-findings.md +51 -0
- package/skills/closeout/SKILL.md +7 -9
- package/skills/copilot-finding-triage/SKILL.md +5 -7
- package/skills/copilot-review/CLAUDE.md +3 -2
- package/skills/copilot-review/SKILL.md +119 -155
- package/skills/copilot-review/templates/subagent-prompt.md +49 -0
- package/skills/fresh-branch/CLAUDE.md +6 -9
- package/skills/fresh-branch/SKILL.md +84 -33
- package/skills/fresh-branch/scripts/create_fresh_branch.py +445 -0
- package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/__init__.py +1 -0
- package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +74 -0
- package/skills/fresh-branch/scripts/test_create_fresh_branch.py +670 -0
- package/skills/pr-converge/SKILL.md +277 -422
- package/skills/pr-converge/reference/CLAUDE.md +1 -0
- package/skills/pr-converge/reference/progress-checklist.md +168 -0
- package/skills/pr-converge/test_step5_host_branch.py +8 -6
- package/skills/pr-loop-cloud-transport/SKILL.md +1 -1
- package/skills/privacy-hygiene/SKILL.md +68 -115
- package/skills/privacy-hygiene/reference/sweep-procedure.md +62 -0
- package/skills/session-log/CLAUDE.md +2 -1
- package/skills/session-log/SKILL.md +4 -26
- package/skills/session-log/templates/frontmatter.md +40 -0
- package/skills/skill-builder/CLAUDE.md +8 -7
- package/skills/skill-builder/SKILL.md +26 -11
- package/skills/skill-builder/references/CLAUDE.md +3 -1
- package/skills/skill-builder/references/delegation-map.md +21 -12
- package/skills/skill-builder/references/description-field.md +9 -11
- package/skills/skill-builder/references/deterministic-elements.md +218 -0
- package/skills/skill-builder/references/self-audit-checklist.md +62 -45
- package/skills/skill-builder/references/skill-modularity.md +8 -9
- package/skills/skill-builder/templates/CLAUDE.md +2 -2
- package/skills/skill-builder/templates/gap-analysis.md +15 -0
- package/skills/skill-builder/workflows/CLAUDE.md +5 -5
- package/skills/skill-builder/workflows/improve-skill.md +18 -9
- package/skills/skill-builder/workflows/new-skill.md +23 -15
- package/skills/skill-builder/workflows/polish-skill.md +28 -21
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Find every gated ``git commit``/``git push`` and the directory it targets.
|
|
2
|
+
|
|
3
|
+
::
|
|
4
|
+
|
|
5
|
+
git commit -m x -> gated in the session directory
|
|
6
|
+
cd subdir && git commit -m x -> gated in session_directory/subdir
|
|
7
|
+
git -C /repo commit -m x -> gated in /repo
|
|
8
|
+
git stash push -> not gated (push is an argument)
|
|
9
|
+
|
|
10
|
+
Walks the ``git`` words and directory-change verbs in a command, in the order
|
|
11
|
+
they appear, so a commit after a ``cd`` gates against the directory the shell
|
|
12
|
+
is actually in when it runs.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
|
|
19
|
+
from config.verified_commit_constants import (
|
|
20
|
+
DIRECTORY_CHANGE_PATTERN_PREFIX,
|
|
21
|
+
DIRECTORY_CHANGE_PATTERN_SUFFIX,
|
|
22
|
+
DIRECTORY_CHANGE_VERBS,
|
|
23
|
+
GATED_GIT_SUBCOMMANDS,
|
|
24
|
+
OPTION_WITH_VALUE_STEP,
|
|
25
|
+
REPO_DIRECTORY_OPTION,
|
|
26
|
+
VALUE_TAKING_GIT_OPTIONS,
|
|
27
|
+
WORK_TREE_OPTION,
|
|
28
|
+
)
|
|
29
|
+
from config.verified_commit_gate_output_constants import REGEX_ALTERNATION_SEPARATOR
|
|
30
|
+
from verified_commit_gate_parts.command_tokenization import (
|
|
31
|
+
collapse_line_continuations,
|
|
32
|
+
git_word_match_gates,
|
|
33
|
+
is_inside_quoted_region,
|
|
34
|
+
quoted_spans,
|
|
35
|
+
strip_token_quotes,
|
|
36
|
+
)
|
|
37
|
+
from verified_commit_gate_parts.directory_resolution import (
|
|
38
|
+
directory_change_target,
|
|
39
|
+
expand_home_prefix,
|
|
40
|
+
resolve_against,
|
|
41
|
+
split_option_value,
|
|
42
|
+
value_after_option,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _record_directory_option(
|
|
47
|
+
option_name: str,
|
|
48
|
+
option_value: str | None,
|
|
49
|
+
repo_directory: str | None,
|
|
50
|
+
work_tree_directory: str | None,
|
|
51
|
+
) -> tuple[str | None, str | None]:
|
|
52
|
+
"""Fold one value-taking git option into the tracked target directories."""
|
|
53
|
+
if option_value is None:
|
|
54
|
+
return repo_directory, work_tree_directory
|
|
55
|
+
if option_name == REPO_DIRECTORY_OPTION:
|
|
56
|
+
return expand_home_prefix(option_value), work_tree_directory
|
|
57
|
+
if option_name == WORK_TREE_OPTION:
|
|
58
|
+
return repo_directory, expand_home_prefix(option_value)
|
|
59
|
+
return repo_directory, work_tree_directory
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _resolved_option_value(
|
|
63
|
+
attached_value: str | None, all_following_tokens: list[str], option_index: int
|
|
64
|
+
) -> str | None:
|
|
65
|
+
"""Read a value-taking option's value: attached (``--opt=v``) or the next token.
|
|
66
|
+
|
|
67
|
+
An attached value is used as given, even when it is the empty string
|
|
68
|
+
(``--work-tree=``), so an empty value never falls through to read the
|
|
69
|
+
following subcommand token as the value.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
attached_value: The ``=``-attached value, or None when none was attached.
|
|
73
|
+
all_following_tokens: Quote-stripped tokens after the ``git`` word.
|
|
74
|
+
option_index: Index of the value-taking option token.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
The attached value when present, otherwise the separate value token.
|
|
78
|
+
"""
|
|
79
|
+
if attached_value is not None:
|
|
80
|
+
return attached_value
|
|
81
|
+
return value_after_option(all_following_tokens, option_index)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def gated_invocation_directory(all_following_tokens: list[str]) -> tuple[bool, str | None]:
|
|
85
|
+
"""Walk the quote-stripped tokens after a ``git`` word to its subcommand.
|
|
86
|
+
|
|
87
|
+
``git stash push`` is not gated ("push" is an argument); ``git commit -m
|
|
88
|
+
x`` is gated ("commit" sits in subcommand position).
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
Whether the subcommand is gated, and its ``-C``/``--work-tree`` directory.
|
|
92
|
+
"""
|
|
93
|
+
repo_directory: str | None = None
|
|
94
|
+
work_tree_directory: str | None = None
|
|
95
|
+
token_index = 0
|
|
96
|
+
while token_index < len(all_following_tokens):
|
|
97
|
+
each_token = all_following_tokens[token_index]
|
|
98
|
+
option_name, attached_value = split_option_value(each_token)
|
|
99
|
+
if option_name in VALUE_TAKING_GIT_OPTIONS:
|
|
100
|
+
option_value = _resolved_option_value(
|
|
101
|
+
attached_value, all_following_tokens, token_index
|
|
102
|
+
)
|
|
103
|
+
repo_directory, work_tree_directory = _record_directory_option(
|
|
104
|
+
option_name, option_value, repo_directory, work_tree_directory
|
|
105
|
+
)
|
|
106
|
+
token_index += 1 if attached_value is not None else OPTION_WITH_VALUE_STEP
|
|
107
|
+
continue
|
|
108
|
+
if each_token.startswith("-"):
|
|
109
|
+
token_index += 1
|
|
110
|
+
continue
|
|
111
|
+
return each_token.lower() in GATED_GIT_SUBCOMMANDS, repo_directory or work_tree_directory
|
|
112
|
+
return False, repo_directory or work_tree_directory
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _directory_change_verb_pattern() -> re.Pattern[str]:
|
|
116
|
+
"""Build the regex matching any directory-change verb spelling."""
|
|
117
|
+
verb_alternation = REGEX_ALTERNATION_SEPARATOR.join(
|
|
118
|
+
re.escape(each_verb) for each_verb in sorted(DIRECTORY_CHANGE_VERBS)
|
|
119
|
+
)
|
|
120
|
+
return re.compile(
|
|
121
|
+
DIRECTORY_CHANGE_PATTERN_PREFIX + verb_alternation + DIRECTORY_CHANGE_PATTERN_SUFFIX,
|
|
122
|
+
re.IGNORECASE,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _ordered_gate_matches(command_text: str) -> list[re.Match[str]]:
|
|
127
|
+
"""Collect every ``git`` word and directory-change verb match, in order."""
|
|
128
|
+
git_word_pattern = re.compile(
|
|
129
|
+
r"(?:^|(?<=[\s;&|(\"'/\\]))git(?:\.exe)?(?:[\"'](?=\s|$)|(?=\s|$))",
|
|
130
|
+
re.IGNORECASE,
|
|
131
|
+
)
|
|
132
|
+
all_quoted_spans = quoted_spans(command_text)
|
|
133
|
+
all_directory_change_matches = [
|
|
134
|
+
each_match
|
|
135
|
+
for each_match in _directory_change_verb_pattern().finditer(command_text)
|
|
136
|
+
if not is_inside_quoted_region(each_match.start(), all_quoted_spans)
|
|
137
|
+
]
|
|
138
|
+
all_git_word_matches = [
|
|
139
|
+
each_match
|
|
140
|
+
for each_match in git_word_pattern.finditer(command_text)
|
|
141
|
+
if git_word_match_gates(each_match, command_text, all_quoted_spans)
|
|
142
|
+
]
|
|
143
|
+
return sorted(
|
|
144
|
+
all_git_word_matches + all_directory_change_matches,
|
|
145
|
+
key=lambda each_match: each_match.start(),
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _following_tokens(command_text: str, match_end: int) -> list[str]:
|
|
150
|
+
"""Quote-strip the tokens following a ``git`` word match."""
|
|
151
|
+
command_token_pattern = re.compile(r"\"[^\"]*\"|'[^']*'|\S+")
|
|
152
|
+
following_text = command_text[match_end:]
|
|
153
|
+
return [
|
|
154
|
+
strip_token_quotes(each_token)
|
|
155
|
+
for each_token in command_token_pattern.findall(following_text)
|
|
156
|
+
]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _apply_directory_change(
|
|
160
|
+
command_text: str, directory_change_match: re.Match[str], active_directory: str
|
|
161
|
+
) -> str:
|
|
162
|
+
"""Resolve the active directory after one ``cd``/``pushd`` match."""
|
|
163
|
+
changed_directory = directory_change_target(command_text, directory_change_match.end())
|
|
164
|
+
if changed_directory is None:
|
|
165
|
+
return active_directory
|
|
166
|
+
return resolve_against(active_directory, changed_directory)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _target_directory_for_match(
|
|
170
|
+
command_text: str, git_word_match: re.Match[str], active_directory: str
|
|
171
|
+
) -> str | None:
|
|
172
|
+
"""Resolve the gated directory for one ``git`` word match, or None."""
|
|
173
|
+
all_following_tokens = _following_tokens(command_text, git_word_match.end())
|
|
174
|
+
is_gated, flagged_directory = gated_invocation_directory(all_following_tokens)
|
|
175
|
+
if not is_gated:
|
|
176
|
+
return None
|
|
177
|
+
if flagged_directory is None:
|
|
178
|
+
return active_directory
|
|
179
|
+
return resolve_against(active_directory, flagged_directory)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def gated_repo_directories(command_text: str, fallback_directory: str) -> list[str]:
|
|
183
|
+
"""Collect the directories of every git commit/push found in a command.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
command_text: The raw command string from the tool payload.
|
|
187
|
+
fallback_directory: The session working directory, used as the
|
|
188
|
+
active directory until a directory-change verb changes it and
|
|
189
|
+
when the git call carries no ``-C`` flag.
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
One directory per detected commit/push invocation, in order; empty
|
|
193
|
+
when the command carries no gated git verb.
|
|
194
|
+
"""
|
|
195
|
+
command_text = collapse_line_continuations(command_text)
|
|
196
|
+
active_directory = fallback_directory
|
|
197
|
+
target_directories: list[str] = []
|
|
198
|
+
for each_match in _ordered_gate_matches(command_text):
|
|
199
|
+
if each_match.group().lower().strip("\"'") in DIRECTORY_CHANGE_VERBS:
|
|
200
|
+
active_directory = _apply_directory_change(command_text, each_match, active_directory)
|
|
201
|
+
continue
|
|
202
|
+
target_directory = _target_directory_for_match(command_text, each_match, active_directory)
|
|
203
|
+
if target_directory is not None:
|
|
204
|
+
target_directories.append(target_directory)
|
|
205
|
+
return target_directories
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Put the blocking and hooks directories on sys.path for the parts tests."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
_blocking_directory = str(Path(__file__).resolve().parents[2])
|
|
7
|
+
_hooks_directory = str(Path(__file__).resolve().parents[3])
|
|
8
|
+
for each_bootstrap_directory in (_blocking_directory, _hooks_directory):
|
|
9
|
+
if each_bootstrap_directory not in sys.path:
|
|
10
|
+
sys.path.insert(0, each_bootstrap_directory)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Behavioral tests for the verified-commit gate's command tokenization."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from verified_commit_gate_parts.command_tokenization import (
|
|
6
|
+
collapse_line_continuations,
|
|
7
|
+
command_carries_trailing_marker,
|
|
8
|
+
containing_quoted_span,
|
|
9
|
+
git_word_match_gates,
|
|
10
|
+
is_inside_quoted_region,
|
|
11
|
+
quoted_spans,
|
|
12
|
+
strip_token_quotes,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
VERIFY_SKIP_MARKER = "# verify-skip"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_command_carries_trailing_marker_true_for_a_real_trailing_comment() -> None:
|
|
19
|
+
command_text = f'git commit -m "wire up" {VERIFY_SKIP_MARKER}'
|
|
20
|
+
assert command_carries_trailing_marker(command_text, VERIFY_SKIP_MARKER)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_command_carries_trailing_marker_false_when_marker_sits_inside_quotes() -> None:
|
|
24
|
+
command_text = f'git commit -m "explain the {VERIFY_SKIP_MARKER} hatch"'
|
|
25
|
+
assert not command_carries_trailing_marker(command_text, VERIFY_SKIP_MARKER)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_command_carries_trailing_marker_true_with_trailing_words_after_marker() -> None:
|
|
29
|
+
command_text = f"git commit -m x {VERIFY_SKIP_MARKER} please"
|
|
30
|
+
assert command_carries_trailing_marker(command_text, VERIFY_SKIP_MARKER)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_command_carries_trailing_marker_false_when_hash_abuts_a_prior_hash() -> None:
|
|
34
|
+
command_text = f"git commit -m x #{VERIFY_SKIP_MARKER}"
|
|
35
|
+
assert not command_carries_trailing_marker(command_text, VERIFY_SKIP_MARKER)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_command_carries_trailing_marker_false_when_hash_abuts_a_closing_quote() -> None:
|
|
39
|
+
command_text = f'git commit -m "msg"{VERIFY_SKIP_MARKER}'
|
|
40
|
+
assert not command_carries_trailing_marker(command_text, VERIFY_SKIP_MARKER)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_command_carries_trailing_marker_true_when_real_comment_follows_quoted_mention() -> None:
|
|
44
|
+
command_text = f'git commit -m "see {VERIFY_SKIP_MARKER}" {VERIFY_SKIP_MARKER}'
|
|
45
|
+
assert command_carries_trailing_marker(command_text, VERIFY_SKIP_MARKER)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_collapse_line_continuations_joins_a_split_git_word() -> None:
|
|
49
|
+
assert collapse_line_continuations("git \\\ncommit -m x") == "git commit -m x"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_quoted_spans_finds_each_quoted_region_in_order() -> None:
|
|
53
|
+
all_spans = quoted_spans("git commit -m \"first\" -m 'second'")
|
|
54
|
+
assert len(all_spans) == 2
|
|
55
|
+
assert all_spans[0][0] < all_spans[1][0]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_is_inside_quoted_region_true_for_a_position_inside_a_span() -> None:
|
|
59
|
+
command_text = 'echo "Next: git commit"'
|
|
60
|
+
all_spans = quoted_spans(command_text)
|
|
61
|
+
assert is_inside_quoted_region(command_text.index("git"), all_spans)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_containing_quoted_span_returns_none_outside_any_quotes() -> None:
|
|
65
|
+
command_text = 'git commit -m "msg"'
|
|
66
|
+
all_spans = quoted_spans(command_text)
|
|
67
|
+
assert containing_quoted_span(command_text.index("git"), all_spans) is None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_strip_token_quotes_removes_unpaired_edge_quote() -> None:
|
|
71
|
+
assert strip_token_quotes('push"') == "push"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_git_word_match_gates_true_for_a_bare_git_word() -> None:
|
|
75
|
+
command_text = "git commit -m x"
|
|
76
|
+
git_word_match = re.search(r"git", command_text)
|
|
77
|
+
assert git_word_match is not None
|
|
78
|
+
assert git_word_match_gates(git_word_match, command_text, quoted_spans(command_text))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_git_word_match_gates_false_for_git_inside_prose() -> None:
|
|
82
|
+
command_text = 'echo "Next: git commit"'
|
|
83
|
+
all_spans = quoted_spans(command_text)
|
|
84
|
+
git_word_match = re.search(r"git", command_text)
|
|
85
|
+
assert git_word_match is not None
|
|
86
|
+
assert not git_word_match_gates(git_word_match, command_text, all_spans)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_git_word_match_gates_true_for_a_quoted_git_binary_path() -> None:
|
|
90
|
+
command_text = "& 'C:/x/git.exe' commit -m x"
|
|
91
|
+
all_spans = quoted_spans(command_text)
|
|
92
|
+
git_word_match = re.search(r"git", command_text)
|
|
93
|
+
assert git_word_match is not None
|
|
94
|
+
assert git_word_match_gates(git_word_match, command_text, all_spans)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Behavioral tests for the verified-commit gate's deny-payload shape."""
|
|
2
|
+
|
|
3
|
+
from verified_commit_gate_parts.deny_payload import build_deny_payload
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_build_deny_payload_carries_the_deny_decision_and_reason() -> None:
|
|
7
|
+
deny_payload = build_deny_payload("BLOCKED: needs a verdict")
|
|
8
|
+
hook_specific_output = deny_payload["hookSpecificOutput"]
|
|
9
|
+
assert hook_specific_output["hookEventName"] == "PreToolUse"
|
|
10
|
+
assert hook_specific_output["permissionDecision"] == "deny"
|
|
11
|
+
assert hook_specific_output["permissionDecisionReason"] == "BLOCKED: needs a verdict"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_build_deny_payload_carries_the_verify_skip_additional_context() -> None:
|
|
15
|
+
deny_payload = build_deny_payload("BLOCKED: needs a verdict")
|
|
16
|
+
additional_context = deny_payload["hookSpecificOutput"]["additionalContext"]
|
|
17
|
+
assert "# verify-skip" in additional_context
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Behavioral tests for the verified-commit gate's deny-reason resolution."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from verified_commit_gate_parts.deny_reason import deny_reason_for_directory
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _run_git(work_dir: Path, *git_arguments: str) -> None:
|
|
10
|
+
subprocess.run(
|
|
11
|
+
["git", "-C", str(work_dir), *git_arguments],
|
|
12
|
+
check=True,
|
|
13
|
+
capture_output=True,
|
|
14
|
+
text=True,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _make_repo_with_no_upstream(tmp_path: Path) -> Path:
|
|
19
|
+
work_dir = tmp_path / "work"
|
|
20
|
+
work_dir.mkdir()
|
|
21
|
+
_run_git(work_dir, "init", "--initial-branch=main")
|
|
22
|
+
_run_git(work_dir, "config", "user.email", "tests@example.com")
|
|
23
|
+
_run_git(work_dir, "config", "user.name", "Gate Tests")
|
|
24
|
+
(work_dir / "app.py").write_text(
|
|
25
|
+
"def add(left: int, right: int) -> int:\n return left + right\n", encoding="utf-8"
|
|
26
|
+
)
|
|
27
|
+
_run_git(work_dir, "add", "-A")
|
|
28
|
+
_run_git(work_dir, "commit", "-m", "base")
|
|
29
|
+
return work_dir
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_deny_reason_for_directory_none_when_not_a_git_repo(tmp_path: Path) -> None:
|
|
33
|
+
assert deny_reason_for_directory(str(tmp_path), "") is None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_deny_reason_for_directory_none_with_no_resolvable_upstream(tmp_path: Path) -> None:
|
|
37
|
+
work_dir = _make_repo_with_no_upstream(tmp_path)
|
|
38
|
+
assert deny_reason_for_directory(str(work_dir), "") is None
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Behavioral tests for the verified-commit gate's directory-change resolution."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from verified_commit_gate_parts.directory_resolution import (
|
|
8
|
+
argument_tokens_after_verb,
|
|
9
|
+
directory_change_target,
|
|
10
|
+
expand_home_prefix,
|
|
11
|
+
is_absolute_directory,
|
|
12
|
+
resolve_against,
|
|
13
|
+
split_option_value,
|
|
14
|
+
value_after_option,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_split_option_value_splits_an_equals_form_option() -> None:
|
|
19
|
+
assert split_option_value("--work-tree=/repo") == ("--work-tree", "/repo")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_split_option_value_returns_none_value_for_a_bare_option() -> None:
|
|
23
|
+
assert split_option_value("-C") == ("-C", None)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_value_after_option_reads_the_next_token() -> None:
|
|
27
|
+
assert value_after_option(["-C", "/repo", "commit"], 0) == "/repo"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_value_after_option_returns_none_at_the_end() -> None:
|
|
31
|
+
assert value_after_option(["-C"], 0) is None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_expand_home_prefix_expands_a_leading_tilde(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
35
|
+
monkeypatch.setenv("HOME", "C:/fake-home")
|
|
36
|
+
monkeypatch.setenv("USERPROFILE", "C:/fake-home")
|
|
37
|
+
assert expand_home_prefix("~/repo") == os.path.expanduser("~/repo")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_is_absolute_directory_true_for_a_drive_path() -> None:
|
|
41
|
+
assert is_absolute_directory("C:/repo")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_is_absolute_directory_false_for_a_relative_path() -> None:
|
|
45
|
+
assert not is_absolute_directory("subdir")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_resolve_against_joins_a_relative_target() -> None:
|
|
49
|
+
assert resolve_against("/session", "subdir") == os.path.join("/session", "subdir")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_resolve_against_replaces_with_an_absolute_target() -> None:
|
|
53
|
+
assert resolve_against("/session", "/repo") == "/repo"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_argument_tokens_after_verb_stops_at_a_separator() -> None:
|
|
57
|
+
command_text = "cd subdir; git commit"
|
|
58
|
+
match_end = command_text.index("cd") + len("cd")
|
|
59
|
+
assert argument_tokens_after_verb(command_text, match_end) == ["subdir"]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_directory_change_target_reads_the_destination() -> None:
|
|
63
|
+
command_text = "cd /repo"
|
|
64
|
+
match_end = command_text.index("cd") + len("cd")
|
|
65
|
+
assert directory_change_target(command_text, match_end) == "/repo"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_directory_change_target_none_for_a_bare_change() -> None:
|
|
69
|
+
command_text = "cd && git commit"
|
|
70
|
+
match_end = command_text.index("cd") + len("cd")
|
|
71
|
+
assert directory_change_target(command_text, match_end) is None
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Behavioral tests for gated git-invocation directory resolution."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
from verified_commit_gate_parts.gated_invocations import (
|
|
6
|
+
gated_invocation_directory,
|
|
7
|
+
gated_repo_directories,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_gated_invocation_directory_true_for_commit_in_subcommand_position() -> None:
|
|
12
|
+
is_gated, repo_directory = gated_invocation_directory(["commit", "-m", "x"])
|
|
13
|
+
assert is_gated
|
|
14
|
+
assert repo_directory is None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_gated_invocation_directory_false_for_commit_as_an_argument() -> None:
|
|
18
|
+
is_gated, _ = gated_invocation_directory(["stash", "push"])
|
|
19
|
+
assert not is_gated
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_gated_invocation_directory_reads_the_dash_c_directory() -> None:
|
|
23
|
+
is_gated, repo_directory = gated_invocation_directory(["-C", "/repo", "commit"])
|
|
24
|
+
assert is_gated
|
|
25
|
+
assert repo_directory == "/repo"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_gated_repo_directories_finds_a_bare_commit() -> None:
|
|
29
|
+
assert gated_repo_directories("git commit -m x", "/session") == ["/session"]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_gated_repo_directories_ignores_a_quoted_prose_mention() -> None:
|
|
33
|
+
assert gated_repo_directories('echo "Next: git commit"', "/session") == []
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_gated_repo_directories_follows_a_preceding_cd() -> None:
|
|
37
|
+
assert gated_repo_directories("cd subdir && git commit -m x", "/session") == [
|
|
38
|
+
os.path.join("/session", "subdir")
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_gated_repo_directories_honors_a_dash_c_flag() -> None:
|
|
43
|
+
assert gated_repo_directories("git -C /repo commit -m x", "/session") == ["/repo"]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_gated_repo_directories_empty_for_a_non_gated_subcommand() -> None:
|
|
47
|
+
assert gated_repo_directories("git status", "/session") == []
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_gated_invocation_directory_reads_an_empty_work_tree_value_not_the_subcommand() -> None:
|
|
51
|
+
is_gated, work_tree_directory = gated_invocation_directory(
|
|
52
|
+
["--work-tree=", "commit", "-m", "x"]
|
|
53
|
+
)
|
|
54
|
+
assert is_gated
|
|
55
|
+
assert work_tree_directory == ""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_gated_repo_directories_gates_an_empty_work_tree_against_the_session() -> None:
|
|
59
|
+
assert gated_repo_directories("git --work-tree= commit -m x", "/session") == [
|
|
60
|
+
os.path.join("/session", "")
|
|
61
|
+
]
|
|
@@ -20,6 +20,7 @@ Shared constant modules imported by hooks throughout the `hooks/` tree. Each fil
|
|
|
20
20
|
| `code_verifier_spawn_preflight_gate_constants.py` | Subagent type, merge-tree command flags, timeouts, and deny-message text for the code-verifier spawn pre-flight gate |
|
|
21
21
|
| `command_dispatch_constants.py` | Command-word regex, command-key access pattern, tokenization pattern, and anchors for the unanchored command-dispatch meta-gate |
|
|
22
22
|
| `convergence_branch_constants.py` | Branch and worktree naming patterns for the convergence gate |
|
|
23
|
+
| `convergence_gate_blocker_constants.py` | The full-PR-URL and `--repo`/`-R` flag parse patterns that key the convergence gate to the PR its command names |
|
|
23
24
|
| `conventional_pr_title_gate_constants.py` | Bash tool name, gh executable basenames and pr create/edit subcommand tokens, title/repo flag names, semantic-title-CI workflow markers, the Conventional Commits type list and title pattern, the semantic-pull-request action `types:` input pattern, and block-message text for the conventional-PR-title gate |
|
|
24
25
|
| `dead_argparse_argument_constants.py` | Patterns for detecting unused argparse arguments |
|
|
25
26
|
| `dead_config_field_constants.py` | Patterns for detecting unused `*Config` / `*Selectors` dataclass fields |
|
|
@@ -32,7 +33,7 @@ Shared constant modules imported by hooks throughout the `hooks/` tree. Each fil
|
|
|
32
33
|
| `dynamic_stderr_handler.py` | `DynamicStderrHandler` — a logging handler that resolves `sys.stderr` at emit time (for testability) |
|
|
33
34
|
| `gh_pr_author_swap_constants.py` | Constants for the PR-author swap enforcement hooks |
|
|
34
35
|
| `hardcoded_user_path_constants.py` | Patterns for detecting hardcoded home-directory paths |
|
|
35
|
-
| `harness_scratchpad_constants.py` | Fixed path components (`claude-` user-directory
|
|
36
|
+
| `harness_scratchpad_constants.py` | Fixed path components (`claude` / `claude-` user-directory name and prefix, `scratchpad` leaf name), the PreToolUse session-id payload key, and the `CLAUDE_CODE_SESSION_ID` environment variable name that the code-rules and TDD gates use to recognize an existing harness session scratchpad directory |
|
|
36
37
|
| `hook_block_logger.py` | `log_hook_block()` — shared fail-safe logger every blocking hook calls to append a JSON record of each block decision to `~/.claude/logs/hook-blocks.log` |
|
|
37
38
|
| `hook_log_extractor_constants.py` | Neon table name, offset state file path, timeouts, and outcome-type mapping for the hook-log extractor |
|
|
38
39
|
| `hook_prose_detector_consistency_constants.py` | Trigger patterns and corrective messages for the hook-prose consistency checker |
|
|
@@ -42,6 +43,8 @@ Shared constant modules imported by hooks throughout the `hooks/` tree. Each fil
|
|
|
42
43
|
| `local_identity.py` | Loader for local machine values: NAS host, ssh user, and ssh port the ssh enforcer guards (`CLAUDE_NAS_*` / `~/.claude/local-identity.json` with placeholder defaults), the PII commit-scan exempt-repo slug set (`CLAUDE_PII_EXEMPT_REPOS` / `pii_exempt_repositories`), and the per-repository allowlisted-values mapping (`pii_allowlisted_values`, keyed by owner/repo slug) read from the git-ignored local file whose path `CLAUDE_LOCAL_IDENTITY_PATH` may point elsewhere; also composes the ssh enforcer's two deny messages that quote the NAS values |
|
|
43
44
|
| `messages.py` | Short user-facing notice strings shown when a Stop hook redirects agent behavior |
|
|
44
45
|
| `multi_edit_reconstruction.py` | `apply_edits()` / `edits_for_tool()` — shared helpers that reconstruct the post-edit content of an Edit or MultiEdit, imported by the blockers that judge post-edit content |
|
|
46
|
+
| `mypy_integration_constants.py` | Path markers (``.git``, ``.py``, ``pyproject.toml``) for mypy project-root resolution |
|
|
47
|
+
| `mypy_validator_cache_constants.py` | Cache paths and tunables for the mypy_validator per-session caches |
|
|
45
48
|
| `nas_ssh_binary_enforcer_constants.py` | Bash tool name, ssh-family basenames, OpenSSH binary path suffixes, launcher-wrapper set, shell control-operator tokens and split pattern, leading-assignment and duration patterns, and the batch-mode pattern for the NAS ssh binary enforcer |
|
|
46
49
|
| `open_questions_in_plans_blocker_constants.py` | Patterns for detecting unresolved open questions in plan documents |
|
|
47
50
|
| `orphan_css_class_constants.py` | Scan radius and selector patterns for the orphan-CSS-class check |
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Command-parse patterns for the convergence gate's repo binding.
|
|
2
|
+
|
|
3
|
+
The convergence gate keys its evidence lookup to the PR that the
|
|
4
|
+
``gh pr ready`` command names. These patterns pull an explicit owner/repo
|
|
5
|
+
out of the command — and, for a full PR URL, the PR number too — so the
|
|
6
|
+
gate binds to the repository the command targets rather than the
|
|
7
|
+
session working directory's repository when the two differ.
|
|
8
|
+
|
|
9
|
+
The owner/repo and URL patterns run only over the ``gh pr ready`` segment,
|
|
10
|
+
clipped at the first command separator, so a flag or URL that belongs to a
|
|
11
|
+
chained command does not bind the gate to the wrong PR::
|
|
12
|
+
|
|
13
|
+
gh pr ready 161 && gh pr comment 999 --repo other-owner/other-repo
|
|
14
|
+
^^^^^^^^^^^^^^^^ clipped here ^
|
|
15
|
+
-> ready segment scanned; the chained --repo is out of scope
|
|
16
|
+
|
|
17
|
+
gh pr ready 161 --repo sample-owner/target-repo
|
|
18
|
+
^^^^^^ ^^^^^^^^^^^^ ^^^^^^^^^^^
|
|
19
|
+
flag owner repo -> REPO_OVERRIDE_FLAG_PATTERN
|
|
20
|
+
gh pr ready https://github.com/sample-owner/target-repo/pull/161
|
|
21
|
+
^^^^^^^^^^^^ ^^^^^^^^^^^ ^^^
|
|
22
|
+
owner repo number
|
|
23
|
+
-> PR_URL_OWNER_REPO_NUMBER_PATTERN
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
GH_PR_READY_ANCHOR_PATTERN: str = r"\bgh\s+pr\s+ready\b(?![^&|;\n]*--undo)"
|
|
27
|
+
|
|
28
|
+
COMMAND_SEPARATOR_PATTERN: str = r"&&|\|\||;|\||\n"
|
|
29
|
+
|
|
30
|
+
PR_URL_OWNER_REPO_NUMBER_PATTERN: str = (
|
|
31
|
+
r"https://github\.com/(?P<owner>[\w.-]+)/(?P<repo>[\w.-]+)/pull/(?P<number>\d+)"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
REPO_OVERRIDE_FLAG_PATTERN: str = (
|
|
35
|
+
r"(?:^|\s)(?:--repo|-R)(?:=|\s+)(?:[\w.-]+/)*(?P<owner>[\w.-]+)/(?P<repo>[\w.-]+)"
|
|
36
|
+
)
|
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
"""Constants for resolving the Claude Code harness session scratchpad directory.
|
|
2
2
|
|
|
3
3
|
The harness gives each session a throwaway scratch directory it announces in
|
|
4
|
-
the session's system prompt
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
4
|
+
the session's system prompt. The layout carries a harness user directory
|
|
5
|
+
(``claude`` on Windows, ``claude-<uid>`` on POSIX), a mangled working-directory
|
|
6
|
+
segment, the session id, and a ``scratchpad`` leaf:
|
|
7
|
+
``<tempdir>/<user-dir>/<mangled-cwd>/<session-id>/scratchpad``. These constants
|
|
8
|
+
name the fixed path components the code-rules, TDD, and doc-language gates match
|
|
9
|
+
to recognize that directory from the signals a hook process reads: the session id in the
|
|
10
|
+
PreToolUse payload or the ``CLAUDE_CODE_SESSION_ID`` environment variable, and
|
|
11
|
+
the temp-directory root.
|
|
11
12
|
"""
|
|
12
13
|
|
|
14
|
+
HARNESS_SCRATCHPAD_USER_DIRECTORY_NAME: str = "claude"
|
|
13
15
|
HARNESS_SCRATCHPAD_USER_DIRECTORY_PREFIX: str = "claude-"
|
|
14
|
-
HARNESS_SCRATCHPAD_PATH_SEPARATOR_REPLACEMENT: str = "-"
|
|
15
16
|
HARNESS_SCRATCHPAD_LEAF_DIRECTORY_NAME: str = "scratchpad"
|
|
16
17
|
HOOK_PAYLOAD_SESSION_ID_KEY: str = "session_id"
|
|
17
|
-
|
|
18
|
+
CLAUDE_SESSION_ID_ENVIRONMENT_VARIABLE_NAME: str = "CLAUDE_CODE_SESSION_ID"
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Path markers used when mypy resolves a target file's project root.
|
|
2
|
+
|
|
3
|
+
The integration walks ancestors for a ``.git`` entry or a ``pyproject.toml``,
|
|
4
|
+
and filters the check list to ``.py`` files. These names live here so the
|
|
5
|
+
integration module carries no inline path or extension literals.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"GIT_DIRECTORY_NAME",
|
|
10
|
+
"PYTHON_SOURCE_SUFFIX",
|
|
11
|
+
"PYPROJECT_FILENAME",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
GIT_DIRECTORY_NAME: str = ".git"
|
|
15
|
+
PYTHON_SOURCE_SUFFIX: str = ".py"
|
|
16
|
+
PYPROJECT_FILENAME: str = "pyproject.toml"
|