claude-dev-env 8.6.1 → 8.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/privacy-hygiene/reference/sweep-procedure.md +1 -1
- package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +1 -0
- package/_shared/advisor/scripts/model_tier_run_validator.py +3 -2
- package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +35 -0
- package/_shared/pr-loop/scripts/terminology_sweep.py +10 -9
- package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +13 -0
- package/bin/install.test.mjs +25 -0
- package/docs/high-trust-agent-delivery.md +132 -0
- package/hooks/advisory/refactor_guard.py +2 -2
- package/hooks/advisory/test_refactor_guard.py +21 -0
- package/hooks/blocking/code_rules_enforcer_test_support.py +10 -0
- package/hooks/blocking/code_rules_shared.py +4 -7
- package/hooks/blocking/conftest.py +22 -0
- package/hooks/blocking/state_description_blocker.py +10 -8
- package/hooks/blocking/test_code_rules_enforcer_duplicate_body.py +2 -11
- package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +4 -29
- package/hooks/blocking/test_code_rules_enforcer_test_support.py +21 -0
- package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +3 -19
- package/hooks/blocking/test_code_rules_shared.py +18 -0
- package/hooks/blocking/test_conftest.py +35 -0
- package/hooks/blocking/test_state_description_blocker.py +16 -0
- package/hooks/git-hooks/git_hooks_constants/__init__.py +1 -14
- package/hooks/git-hooks/pull_request_handoff.py +2 -2
- package/hooks/git-hooks/test_config.py +10 -3
- package/hooks/git-hooks/test_pull_request_handoff.py +49 -0
- package/hooks/git-hooks/test_verification_notice_state.py +50 -0
- package/hooks/git-hooks/verification_notice_state.py +3 -1
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +13 -5
- package/hooks/hooks_constants/refactor_guard_constants.py +2 -0
- package/hooks/hooks_constants/session_edit_stage_gate_constants.py +3 -4
- package/hooks/hooks_constants/state_description_blocker_constants.py +0 -3
- package/hooks/hooks_constants/test_state_description_blocker_constants.py +9 -7
- package/hooks/validators/exempt_paths.py +3 -18
- package/hooks/validators/test_exempt_paths.py +20 -0
- package/hooks/validators/test_python_antipattern_checks.py +14 -0
- package/package.json +1 -1
- package/rules/agent-merges-its-own-green-pull-request.md +54 -0
- package/rules/archiving-agent-config.md +10 -0
- package/rules/code-standards.md +1 -0
- package/rules/correction-lens.md +134 -0
- package/rules/flag-non-breaking-findings.md +11 -0
- package/rules/no-contrast-framing.md +68 -0
- package/rules/review-closure-is-a-check.md +44 -0
- package/scripts/Invoke-PesterSuites.ps1 +42 -0
- package/scripts/agent_merge_check.py +381 -0
- package/scripts/check.ps1 +12 -1
- package/scripts/ci/install-playtest.mjs +291 -0
- package/scripts/ci/install-playtest.test.mjs +84 -0
- package/scripts/ci/installer-lifecycle.mjs +11 -30
- package/scripts/ci/installer-lifecycle.test.mjs +14 -0
- package/scripts/ci/pstack-command-shim.test.mjs +33 -0
- package/scripts/ci/scratch-home-install.mjs +73 -0
- package/scripts/ci/scratch-home-install.test.mjs +49 -0
- package/scripts/codex_compat_materializer.py +33 -16
- package/scripts/contrast_framing.py +101 -0
- package/scripts/dev_env_scripts_constants/agent_merge_check_constants.py +121 -0
- package/scripts/dev_env_scripts_constants/contrast_framing_constants.py +82 -0
- package/scripts/dev_env_scripts_constants/merge_gate_check_constants.py +23 -0
- package/scripts/dev_env_scripts_constants/review_closure_constants.py +241 -0
- package/scripts/dev_env_scripts_constants/test_code_review_constants.py +12 -0
- package/scripts/dev_env_scripts_constants/test_contrast_framing_constants.py +60 -0
- package/scripts/durable_post_lint.py +30 -4
- package/scripts/followup_cli.py +6 -10
- package/scripts/merge_gate_checks.py +236 -0
- package/scripts/policy_lint/adapter_contrast_framing.py +59 -0
- package/scripts/policy_lint/adapters.py +30 -0
- package/scripts/policy_lint/config/constants.py +2 -0
- package/scripts/policy_lint/registry.py +5 -0
- package/scripts/repository_checks/config/constants.py +12 -0
- package/scripts/repository_checks/config/policy_document.py +28 -2
- package/scripts/repository_checks/followups.py +54 -0
- package/scripts/repository_checks/match_exemptions.py +67 -0
- package/scripts/repository_checks/models.py +46 -2
- package/scripts/repository_checks/reporting.py +23 -7
- package/scripts/repository_checks/tracked_secrets.py +2 -2
- package/scripts/repository_policy.py +4 -2
- package/scripts/review_closure.py +134 -0
- package/scripts/review_closure_github.py +345 -0
- package/scripts/review_closure_model.py +360 -0
- package/scripts/test_agent_merge_check.py +317 -0
- package/scripts/test_durable_post_lint.py +46 -0
- package/scripts/test_followup_cli.py +40 -0
- package/scripts/test_merge_gate_checks.py +92 -0
- package/scripts/tests/test_code_review_constants.py +78 -0
- package/scripts/tests/test_codex_compat_materializer.py +48 -4
- package/scripts/tests/test_contrast_framing.py +117 -0
- package/scripts/tests/test_followups.py +88 -0
- package/scripts/tests/test_match_exemptions.py +291 -0
- package/scripts/tests/test_models.py +67 -2
- package/scripts/tests/test_package_inventory.py +6 -4
- package/scripts/tests/test_pester_suites_run.py +53 -0
- package/scripts/tests/test_policy_document.py +39 -1
- package/scripts/tests/test_policy_lint_rules_contrast_framing.py +68 -0
- package/scripts/tests/test_reporting.py +18 -0
- package/scripts/tests/test_repository_policy.py +43 -1
- package/scripts/tests/test_review_closure.py +126 -0
- package/scripts/tests/test_review_closure_github.py +240 -0
- package/scripts/tests/test_review_closure_model.py +299 -0
- package/scripts/tests/test_tracked_secrets.py +42 -0
- package/hooks/hooks_constants/precommit_code_rules_gate_constants.py +0 -12
- package/scripts/repository_checks/email_exemptions.py +0 -39
- package/scripts/tests/test_email_exemptions.py +0 -158
|
@@ -7,19 +7,16 @@ through ``validate_content_for_edit_lane``, which hook files never reach at PreT
|
|
|
7
7
|
tests drive the real ``main()`` stdin entry point and the pre-check CLI with a
|
|
8
8
|
hook-infrastructure target.
|
|
9
9
|
|
|
10
|
-
Each test
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
Each test takes the ``hook_blocking_dir`` fixture from ``conftest.py``, which builds
|
|
11
|
+
a temporary tree matching the production hook directory so ``is_hook_infrastructure``
|
|
12
|
+
routes a target inside it the same way it would for the production directory.
|
|
13
13
|
"""
|
|
14
14
|
|
|
15
15
|
from __future__ import annotations
|
|
16
16
|
|
|
17
17
|
import json
|
|
18
18
|
import pathlib
|
|
19
|
-
import shutil
|
|
20
19
|
import sys
|
|
21
|
-
import tempfile
|
|
22
|
-
from collections.abc import Iterator
|
|
23
20
|
|
|
24
21
|
import pytest
|
|
25
22
|
|
|
@@ -46,19 +43,6 @@ PASS_THROUGH_ALIAS_SOURCE = (
|
|
|
46
43
|
" return find_bare_path_segments(content)\n"
|
|
47
44
|
)
|
|
48
45
|
|
|
49
|
-
_HOOK_INFRASTRUCTURE_TAIL = pathlib.Path("packages") / "claude-dev-env" / "hooks" / "blocking"
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
@pytest.fixture
|
|
53
|
-
def hook_blocking_dir() -> Iterator[pathlib.Path]:
|
|
54
|
-
base_directory = pathlib.Path(tempfile.mkdtemp())
|
|
55
|
-
blocking_directory = base_directory / _HOOK_INFRASTRUCTURE_TAIL
|
|
56
|
-
blocking_directory.mkdir(parents=True)
|
|
57
|
-
try:
|
|
58
|
-
yield blocking_directory
|
|
59
|
-
finally:
|
|
60
|
-
shutil.rmtree(base_directory, ignore_errors=False)
|
|
61
|
-
|
|
62
46
|
|
|
63
47
|
def test_write_of_pass_through_alias_into_hook_directory_denies(
|
|
64
48
|
hook_blocking_dir: pathlib.Path,
|
|
@@ -291,3 +291,21 @@ def test_is_dedicated_constants_module_true_for_config_directory() -> None:
|
|
|
291
291
|
|
|
292
292
|
def test_is_dedicated_constants_module_false_for_ordinary_module() -> None:
|
|
293
293
|
assert _SHARED_MODULE._is_dedicated_constants_module("app/services/orders.py") is False
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
@pytest.mark.parametrize(
|
|
297
|
+
"normalized_marker_table_name",
|
|
298
|
+
[
|
|
299
|
+
"ALL_AGENT_HOME_TOOLING_PATTERNS",
|
|
300
|
+
"ALL_HOOK_INFRASTRUCTURE_PATTERNS",
|
|
301
|
+
"ALL_WORKFLOW_REGISTRY_PATTERNS",
|
|
302
|
+
"ALL_MIGRATION_PATH_PATTERNS",
|
|
303
|
+
],
|
|
304
|
+
)
|
|
305
|
+
def test_normalized_path_marker_table_carries_no_backslash_entry(
|
|
306
|
+
normalized_marker_table_name: str,
|
|
307
|
+
) -> None:
|
|
308
|
+
"""Each table's consumer normalizes the path with .replace("\\\\", "/") but
|
|
309
|
+
not the marker, so a backslash entry can never match."""
|
|
310
|
+
marker_table = getattr(_SHARED_MODULE, normalized_marker_table_name)
|
|
311
|
+
assert all("\\" not in each_marker for each_marker in marker_table)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Tests for the shared fixtures this directory's ``conftest.py`` provides."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pathlib
|
|
6
|
+
|
|
7
|
+
from code_rules_shared import is_hook_infrastructure
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def should_yield_a_directory_a_target_inside_it_routes_as_hook_infrastructure(
|
|
11
|
+
hook_blocking_dir: pathlib.Path,
|
|
12
|
+
) -> None:
|
|
13
|
+
target_file = hook_blocking_dir / "new_blocker.py"
|
|
14
|
+
assert is_hook_infrastructure(str(target_file)) is True
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def should_yield_a_directory_that_exists_on_disk(
|
|
18
|
+
hook_blocking_dir: pathlib.Path,
|
|
19
|
+
) -> None:
|
|
20
|
+
assert hook_blocking_dir.is_dir()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def should_keep_the_hook_blocking_dir_explanation_only_on_the_fixture() -> None:
|
|
24
|
+
directory = pathlib.Path(__file__).parent
|
|
25
|
+
exempt_paths = {directory / "conftest.py", pathlib.Path(__file__)}
|
|
26
|
+
explanation_fragment = "tail mirrors a production hook directory"
|
|
27
|
+
offending_files = [
|
|
28
|
+
path.name
|
|
29
|
+
for path in sorted(directory.glob("*.py"))
|
|
30
|
+
if path not in exempt_paths and explanation_fragment in path.read_text(encoding="utf-8")
|
|
31
|
+
]
|
|
32
|
+
assert offending_files == [], (
|
|
33
|
+
"the hook_blocking_dir fixture's explanation belongs on conftest.py alone, "
|
|
34
|
+
f"got it repeated in: {offending_files}"
|
|
35
|
+
)
|
|
@@ -34,6 +34,11 @@ from code_rules_enforcer_test_support import (
|
|
|
34
34
|
repository_file_path,
|
|
35
35
|
repository_root,
|
|
36
36
|
)
|
|
37
|
+
from hooks_constants.code_rules_enforcer_constants import (
|
|
38
|
+
JAVASCRIPT_BLOCK_COMMENT_CLOSER,
|
|
39
|
+
JAVASCRIPT_BLOCK_COMMENT_OPENER,
|
|
40
|
+
)
|
|
41
|
+
import state_description_blocker as _state_description_blocker_module
|
|
37
42
|
|
|
38
43
|
HOOK_SCRIPT_PATH = os.path.join(
|
|
39
44
|
os.path.dirname(__file__), "state_description_blocker.py"
|
|
@@ -995,3 +1000,14 @@ def test_find_violations_with_lines_reports_markdown_line_past_a_code_fence() ->
|
|
|
995
1000
|
all_violations = find_violations_with_lines(markdown_source, "docs/api.md")
|
|
996
1001
|
|
|
997
1002
|
assert all_violations == [("previously", 7), ("was previously", 7)]
|
|
1003
|
+
|
|
1004
|
+
|
|
1005
|
+
def test_block_comment_markers_are_the_javascript_constants() -> None:
|
|
1006
|
+
assert (
|
|
1007
|
+
_state_description_blocker_module.JAVASCRIPT_BLOCK_COMMENT_OPENER
|
|
1008
|
+
is JAVASCRIPT_BLOCK_COMMENT_OPENER
|
|
1009
|
+
)
|
|
1010
|
+
assert (
|
|
1011
|
+
_state_description_blocker_module.JAVASCRIPT_BLOCK_COMMENT_CLOSER
|
|
1012
|
+
is JAVASCRIPT_BLOCK_COMMENT_CLOSER
|
|
1013
|
+
)
|
|
@@ -47,20 +47,7 @@ LOCAL_SHA_FIELD_INDEX: int = 1
|
|
|
47
47
|
NO_PARSEABLE_STDIN_LINES_MESSAGE: str = (
|
|
48
48
|
"claude-dev-env pre-push: no parseable stdin lines; aborting"
|
|
49
49
|
)
|
|
50
|
-
NO_PARSEABLE_STDIN_LINES_SENTINEL: str = "__no_parseable_stdin_lines__"
|
|
51
|
-
UNRESOLVABLE_MERGE_BASE_SENTINEL: str = "__unresolvable_merge_base__"
|
|
52
|
-
UNRESOLVABLE_MERGE_BASE_MESSAGE: str = (
|
|
53
|
-
"claude-dev-env pre-push: CODE_RULES validation is pending because the pushed "
|
|
54
|
-
"object and default branch have no shared merge base. Restore shared history "
|
|
55
|
-
"or provide a reachable default branch, then run the push again."
|
|
56
|
-
)
|
|
57
50
|
LOCAL_BRANCH_REFERENCE_PREFIX: str = "refs/heads/"
|
|
58
|
-
ORIGIN_HEAD_SYMBOLIC_REFERENCE: str = "refs/remotes/origin/HEAD"
|
|
59
|
-
ORIGIN_REMOTE_TRACKING_REFERENCE_PREFIX: str = "refs/remotes/origin/"
|
|
60
|
-
ALL_DEFAULT_BRANCH_FALLBACK_REFERENCES: tuple[str, ...] = (
|
|
61
|
-
"refs/remotes/origin/main",
|
|
62
|
-
"refs/remotes/origin/master",
|
|
63
|
-
)
|
|
64
51
|
REMOTE_BRANCH_REFERENCE_TEMPLATE: str = "refs/remotes/{remote}/{branch}"
|
|
65
52
|
ALL_GIT_SYMBOLIC_REFERENCE_COMMAND_PREFIX: tuple[str, ...] = (
|
|
66
53
|
"git",
|
|
@@ -99,7 +86,7 @@ GIT_SYMBOLIC_REFERENCE_SUBCOMMAND: str = "symbolic-ref"
|
|
|
99
86
|
GIT_COMMAND_TIMEOUT_SECONDS: int = 30
|
|
100
87
|
GH_EXECUTABLE_NAME: str = "gh"
|
|
101
88
|
GH_PR_VIEW_TIMEOUT_SECONDS: int = 5
|
|
102
|
-
|
|
89
|
+
ALL_GH_PR_VIEW_ARGUMENTS: tuple[str, ...] = ("pr", "view", "--json", "url", "--jq", ".url")
|
|
103
90
|
GIT_FOR_EACH_REF_SUBCOMMAND: str = "for-each-ref"
|
|
104
91
|
GIT_REFERENCE_SHORT_NAME_FORMAT_ARGUMENT: str = "--format=%(refname:short)"
|
|
105
92
|
COMMIT_OBJECT_NAME_SUFFIX: str = "^{commit}"
|
|
@@ -5,7 +5,7 @@ from pathlib import Path
|
|
|
5
5
|
|
|
6
6
|
from git_hooks_constants import (
|
|
7
7
|
GH_EXECUTABLE_NAME,
|
|
8
|
-
|
|
8
|
+
ALL_GH_PR_VIEW_ARGUMENTS,
|
|
9
9
|
GH_PR_VIEW_TIMEOUT_SECONDS,
|
|
10
10
|
)
|
|
11
11
|
|
|
@@ -15,7 +15,7 @@ from subprocess_window_access import hidden_window_creation_flags
|
|
|
15
15
|
def get_pull_request_url(repo_dir: Path) -> str | None:
|
|
16
16
|
try:
|
|
17
17
|
result = subprocess.run(
|
|
18
|
-
[GH_EXECUTABLE_NAME, *
|
|
18
|
+
[GH_EXECUTABLE_NAME, *ALL_GH_PR_VIEW_ARGUMENTS],
|
|
19
19
|
cwd=repo_dir,
|
|
20
20
|
check=False,
|
|
21
21
|
capture_output=True,
|
|
@@ -11,6 +11,13 @@ def test_no_parseable_stdin_lines_message_exists_and_describes_problem() -> None
|
|
|
11
11
|
assert "no parseable stdin lines" in git_hooks_constants.NO_PARSEABLE_STDIN_LINES_MESSAGE
|
|
12
12
|
|
|
13
13
|
|
|
14
|
-
def
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
def test_pre_push_cut_leftovers_are_removed() -> None:
|
|
15
|
+
for removed_name in (
|
|
16
|
+
"ALL_DEFAULT_BRANCH_FALLBACK_REFERENCES",
|
|
17
|
+
"ORIGIN_HEAD_SYMBOLIC_REFERENCE",
|
|
18
|
+
"ORIGIN_REMOTE_TRACKING_REFERENCE_PREFIX",
|
|
19
|
+
"UNRESOLVABLE_MERGE_BASE_SENTINEL",
|
|
20
|
+
"UNRESOLVABLE_MERGE_BASE_MESSAGE",
|
|
21
|
+
"NO_PARSEABLE_STDIN_LINES_SENTINEL",
|
|
22
|
+
):
|
|
23
|
+
assert not hasattr(git_hooks_constants, removed_name)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Tests for the pull-request URL query the commit handoff runs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
import pull_request_handoff
|
|
11
|
+
from git_hooks_constants import (
|
|
12
|
+
ALL_GH_PR_VIEW_ARGUMENTS,
|
|
13
|
+
GH_EXECUTABLE_NAME,
|
|
14
|
+
GH_PR_VIEW_TIMEOUT_SECONDS,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_should_query_the_url_through_the_named_executable_and_arguments(
|
|
19
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
20
|
+
) -> None:
|
|
21
|
+
all_calls: list[tuple[list[str], object]] = []
|
|
22
|
+
|
|
23
|
+
def record_call(
|
|
24
|
+
command: list[str], **keyword_arguments: object
|
|
25
|
+
) -> subprocess.CompletedProcess[str]:
|
|
26
|
+
all_calls.append((command, keyword_arguments["timeout"]))
|
|
27
|
+
return subprocess.CompletedProcess(command, 0, "https://example/pull/1\n", "")
|
|
28
|
+
|
|
29
|
+
monkeypatch.setattr(pull_request_handoff.subprocess, "run", record_call)
|
|
30
|
+
|
|
31
|
+
url = pull_request_handoff.get_pull_request_url(Path("/repo"))
|
|
32
|
+
|
|
33
|
+
assert url == "https://example/pull/1"
|
|
34
|
+
assert all_calls == [
|
|
35
|
+
([GH_EXECUTABLE_NAME, *ALL_GH_PR_VIEW_ARGUMENTS], GH_PR_VIEW_TIMEOUT_SECONDS)
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_should_report_no_url_when_the_query_exits_non_zero(
|
|
40
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
41
|
+
) -> None:
|
|
42
|
+
def failing_call(
|
|
43
|
+
command: list[str], **keyword_arguments: object
|
|
44
|
+
) -> subprocess.CompletedProcess[str]:
|
|
45
|
+
return subprocess.CompletedProcess(command, 1, "", "no pull request")
|
|
46
|
+
|
|
47
|
+
monkeypatch.setattr(pull_request_handoff.subprocess, "run", failing_call)
|
|
48
|
+
|
|
49
|
+
assert pull_request_handoff.get_pull_request_url(Path("/repo")) is None
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Tests for the base-reference construction in ``_base_matches_current``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
import verification_notice
|
|
10
|
+
import verification_notice_state
|
|
11
|
+
from git_hooks_constants import COMMIT_OBJECT_NAME_SUFFIX
|
|
12
|
+
from git_hooks_constants.verification_notice_constants import (
|
|
13
|
+
ALL_GIT_BASE_QUERY_PREFIX,
|
|
14
|
+
BASE_REFERENCE,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_base_matches_current_queries_base_reference_plus_commit_suffix() -> None:
|
|
19
|
+
captured_queries: list[tuple[str, ...]] = []
|
|
20
|
+
|
|
21
|
+
def _fake_git_query(
|
|
22
|
+
repository_root: Path, arguments: tuple[str, ...], capture_stderr: bool
|
|
23
|
+
) -> str | None:
|
|
24
|
+
captured_queries.append(arguments)
|
|
25
|
+
return "deadbeef"
|
|
26
|
+
|
|
27
|
+
verification_notice_state._base_matches_current(Path("/repo"), "deadbeef", _fake_git_query)
|
|
28
|
+
|
|
29
|
+
assert captured_queries == [
|
|
30
|
+
(*ALL_GIT_BASE_QUERY_PREFIX, BASE_REFERENCE + COMMIT_OBJECT_NAME_SUFFIX)
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_base_matches_current_derives_its_query_from_the_base_reference_constant(
|
|
35
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
36
|
+
) -> None:
|
|
37
|
+
monkeypatch.setattr(verification_notice_state, "BASE_REFERENCE", "custom/base")
|
|
38
|
+
captured_queries: list[tuple[str, ...]] = []
|
|
39
|
+
|
|
40
|
+
def _fake_git_query(
|
|
41
|
+
repository_root: Path, arguments: tuple[str, ...], capture_stderr: bool
|
|
42
|
+
) -> str | None:
|
|
43
|
+
captured_queries.append(arguments)
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
verification_notice_state._base_matches_current(Path("/repo"), "deadbeef", _fake_git_query)
|
|
47
|
+
|
|
48
|
+
assert captured_queries == [
|
|
49
|
+
(*ALL_GIT_BASE_QUERY_PREFIX, "custom/base" + COMMIT_OBJECT_NAME_SUFFIX)
|
|
50
|
+
]
|
|
@@ -6,8 +6,10 @@ from collections.abc import Callable, Mapping
|
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
from typing import Protocol
|
|
8
8
|
|
|
9
|
+
from git_hooks_constants import COMMIT_OBJECT_NAME_SUFFIX
|
|
9
10
|
from git_hooks_constants.verification_notice_constants import (
|
|
10
11
|
ALL_GIT_BASE_QUERY_PREFIX,
|
|
12
|
+
BASE_REFERENCE,
|
|
11
13
|
GIT_COMMAND_SUCCESS_EXIT_CODE,
|
|
12
14
|
HEX_DIGITS,
|
|
13
15
|
NOTICE_FAILED_LINE,
|
|
@@ -180,7 +182,7 @@ def _base_matches_current(
|
|
|
180
182
|
) -> bool:
|
|
181
183
|
current_base_revision = run_git_query(
|
|
182
184
|
repository_root,
|
|
183
|
-
(*ALL_GIT_BASE_QUERY_PREFIX,
|
|
185
|
+
(*ALL_GIT_BASE_QUERY_PREFIX, BASE_REFERENCE + COMMIT_OBJECT_NAME_SUFFIX),
|
|
184
186
|
False,
|
|
185
187
|
)
|
|
186
188
|
return current_base_revision == base_revision
|
|
@@ -38,10 +38,13 @@ CLAUDE_JOB_DIR_SCRATCH_SUBDIRECTORY: str = "tmp"
|
|
|
38
38
|
EPHEMERAL_EXEMPT_DISABLE_ENVIRONMENT_VARIABLE_NAME: str = "CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT"
|
|
39
39
|
ALL_EPHEMERAL_EXEMPT_DISABLE_TRUTHY_VALUES: frozenset[str] = frozenset({"1", "true", "yes", "on"})
|
|
40
40
|
LEADING_DRIVE_LETTER_PATTERN: re.Pattern[str] = re.compile(r"^[a-z]:")
|
|
41
|
-
ALL_AGENT_HOME_TOOLING_PATTERNS = {"/.grok/"
|
|
42
|
-
ALL_HOOK_INFRASTRUCTURE_PATTERNS = {
|
|
43
|
-
|
|
44
|
-
|
|
41
|
+
ALL_AGENT_HOME_TOOLING_PATTERNS = {"/.grok/"}
|
|
42
|
+
ALL_HOOK_INFRASTRUCTURE_PATTERNS = {
|
|
43
|
+
"/.claude/hooks/",
|
|
44
|
+
"/packages/claude-dev-env/hooks/",
|
|
45
|
+
} | ALL_AGENT_HOME_TOOLING_PATTERNS
|
|
46
|
+
ALL_WORKFLOW_REGISTRY_PATTERNS = {"/workflow/", "_tab.py", "/states.py", "/modules.py"}
|
|
47
|
+
ALL_MIGRATION_PATH_PATTERNS = {"/migrations/"}
|
|
45
48
|
|
|
46
49
|
CONSTANTS_MODULE_SUFFIX: str = "_constants.py"
|
|
47
50
|
CONFIG_DIRECTORY_SEGMENT: str = "config"
|
|
@@ -192,7 +195,12 @@ ALL_COLLECTION_TYPE_NAMES: frozenset[str] = frozenset({
|
|
|
192
195
|
})
|
|
193
196
|
ALL_SUBSCRIPT_ONLY_COLLECTION_TYPE_NAMES: frozenset[str] = frozenset({"dict"})
|
|
194
197
|
COLLECTION_BY_NAME_PATTERN: re.Pattern[str] = re.compile(r"^[a-z][a-z0-9]*_by_[a-z][a-z0-9_]*$")
|
|
195
|
-
ALL_CLI_FILE_PATH_MARKERS: tuple[str, ...] = (
|
|
198
|
+
ALL_CLI_FILE_PATH_MARKERS: tuple[str, ...] = (
|
|
199
|
+
"/scripts/",
|
|
200
|
+
"/diagnostics/",
|
|
201
|
+
"_cli.py",
|
|
202
|
+
"/cli.py",
|
|
203
|
+
)
|
|
196
204
|
|
|
197
205
|
LOGGING_FSTRING_PATTERN = re.compile(
|
|
198
206
|
r'\b(?:log_(?:debug|info|warning|error|critical|exception)'
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
"""Constants for the session-edit stage gate hook trio.
|
|
2
2
|
|
|
3
3
|
Shared by the PostToolUse tracker
|
|
4
|
-
(``hooks/observability/session_file_edit_tracker.py``)
|
|
5
|
-
(``hooks/
|
|
6
|
-
|
|
4
|
+
(``hooks/observability/session_file_edit_tracker.py``) and the SessionStart
|
|
5
|
+
cleanup (``hooks/session/session_edit_tracker_cleanup.py``): the per-session
|
|
6
|
+
tracker
|
|
7
7
|
filename shape and JSON payload key, the session-id sanitize pattern, the
|
|
8
8
|
SessionStart source key and the fresh-startup source value the cleanup keys its
|
|
9
9
|
deletion on, the edit-tool name set, the per-session lock filename suffix and
|
|
@@ -62,7 +62,6 @@ ALL_TRACKED_UNSTAGED_FILES_COMMAND: tuple[str, ...] = (
|
|
|
62
62
|
|
|
63
63
|
PARTIAL_COMMIT_BYPASS_MARKER: str = "# partial-commit"
|
|
64
64
|
COMMIT_SUBCOMMAND_TOKEN: str = "commit"
|
|
65
|
-
GIT_EXECUTABLE_TOKEN: str = "git"
|
|
66
65
|
ENV_ASSIGNMENT_PREFIX_PATTERN: re.Pattern[str] = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
|
|
67
66
|
ALL_STAGING_SUBCOMMAND_TOKENS: frozenset[str] = frozenset({"add", "stage"})
|
|
68
67
|
ALL_STAGE_ALL_ADD_FLAG_TOKENS: frozenset[str] = frozenset({"-A", "--all", "-u", "--update"})
|
|
@@ -40,9 +40,6 @@ ALL_COMMENT_TRANSITION_PATTERNS: list[Pattern[str]] = [
|
|
|
40
40
|
compile(r"\bas of\b", IGNORECASE),
|
|
41
41
|
]
|
|
42
42
|
|
|
43
|
-
BLOCK_COMMENT_OPEN_MARKER: str = "/*"
|
|
44
|
-
BLOCK_COMMENT_CLOSE_MARKER: str = "*/"
|
|
45
|
-
|
|
46
43
|
INLINE_CODE_PATTERN: Pattern[str] = compile(r"``[^`]+``|`[^`]+`")
|
|
47
44
|
DOUBLE_QUOTED_SPAN_PATTERN: Pattern[str] = compile(r'"[^"\n]*"')
|
|
48
45
|
TRIPLE_QUOTED_BLOCK_PATTERN: Pattern[str] = compile(
|
|
@@ -10,13 +10,15 @@ if str(_HOOKS_ROOT) not in sys.path:
|
|
|
10
10
|
sys.path.insert(0, str(_HOOKS_ROOT))
|
|
11
11
|
|
|
12
12
|
from blocking.state_description_blocker import _extract_comment_lines
|
|
13
|
+
from hooks_constants.code_rules_enforcer_constants import (
|
|
14
|
+
JAVASCRIPT_BLOCK_COMMENT_CLOSER,
|
|
15
|
+
JAVASCRIPT_BLOCK_COMMENT_OPENER,
|
|
16
|
+
)
|
|
13
17
|
from hooks_constants.state_description_blocker_constants import (
|
|
14
18
|
ALL_BLOCK_COMMENT_EXTENSIONS,
|
|
15
19
|
ALL_COMMENT_TRANSITION_PATTERNS,
|
|
16
20
|
ALL_HASH_ONLY_EXTENSIONS,
|
|
17
21
|
ALL_MARKDOWN_EXTENSIONS,
|
|
18
|
-
BLOCK_COMMENT_CLOSE_MARKER,
|
|
19
|
-
BLOCK_COMMENT_OPEN_MARKER,
|
|
20
22
|
DOUBLE_QUOTE_BODY_GROUP,
|
|
21
23
|
SINGLE_QUOTE_BODY_GROUP,
|
|
22
24
|
TRIPLE_QUOTED_BLOCK_PATTERN,
|
|
@@ -47,19 +49,19 @@ def test_transition_patterns_do_not_match_unrelated_prose() -> None:
|
|
|
47
49
|
|
|
48
50
|
|
|
49
51
|
def test_block_comment_markers_close_a_single_line_block_comment() -> None:
|
|
50
|
-
text = f"{
|
|
52
|
+
text = f"{JAVASCRIPT_BLOCK_COMMENT_OPENER} note {JAVASCRIPT_BLOCK_COMMENT_CLOSER}\ncode();"
|
|
51
53
|
all_comment_lines = _extract_comment_lines(text, ".js")
|
|
52
54
|
assert all_comment_lines == [
|
|
53
|
-
(1, f"{
|
|
55
|
+
(1, f"{JAVASCRIPT_BLOCK_COMMENT_OPENER} note {JAVASCRIPT_BLOCK_COMMENT_CLOSER}")
|
|
54
56
|
]
|
|
55
57
|
|
|
56
58
|
|
|
57
59
|
def test_block_comment_markers_carry_an_unclosed_comment_to_the_next_line() -> None:
|
|
58
|
-
text = f"{
|
|
60
|
+
text = f"{JAVASCRIPT_BLOCK_COMMENT_OPENER} still open\nclosed here {JAVASCRIPT_BLOCK_COMMENT_CLOSER}"
|
|
59
61
|
all_comment_lines = _extract_comment_lines(text, ".js")
|
|
60
62
|
assert all_comment_lines == [
|
|
61
|
-
(1, f"{
|
|
62
|
-
(2, f"closed here {
|
|
63
|
+
(1, f"{JAVASCRIPT_BLOCK_COMMENT_OPENER} still open"),
|
|
64
|
+
(2, f"closed here {JAVASCRIPT_BLOCK_COMMENT_CLOSER}"),
|
|
63
65
|
]
|
|
64
66
|
|
|
65
67
|
|
|
@@ -48,27 +48,21 @@ TEST_PATH_PATTERNS: frozenset[str] = frozenset(
|
|
|
48
48
|
HOOK_INFRASTRUCTURE_PATTERNS: frozenset[str] = frozenset(
|
|
49
49
|
{
|
|
50
50
|
"/.claude/hooks/",
|
|
51
|
-
"\\.claude\\hooks\\",
|
|
52
|
-
"\\.claude/hooks/",
|
|
53
51
|
}
|
|
54
52
|
)
|
|
55
53
|
|
|
56
54
|
WORKFLOW_REGISTRY_PATTERNS: frozenset[str] = frozenset(
|
|
57
55
|
{
|
|
58
56
|
"/workflow/",
|
|
59
|
-
"\\workflow\\",
|
|
60
57
|
"_tab.py",
|
|
61
58
|
"/states.py",
|
|
62
|
-
"\\states.py",
|
|
63
59
|
"/modules.py",
|
|
64
|
-
"\\modules.py",
|
|
65
60
|
}
|
|
66
61
|
)
|
|
67
62
|
|
|
68
63
|
MIGRATION_PATH_PATTERNS: frozenset[str] = frozenset(
|
|
69
64
|
{
|
|
70
65
|
"/migrations/",
|
|
71
|
-
"\\migrations\\",
|
|
72
66
|
}
|
|
73
67
|
)
|
|
74
68
|
|
|
@@ -80,23 +74,14 @@ def is_test_file(file_path: str) -> bool:
|
|
|
80
74
|
|
|
81
75
|
def is_hook_infrastructure(file_path: str) -> bool:
|
|
82
76
|
path_normalized = file_path.lower().replace("\\", "/")
|
|
83
|
-
return any(
|
|
84
|
-
pattern.replace("\\", "/") in path_normalized
|
|
85
|
-
for pattern in HOOK_INFRASTRUCTURE_PATTERNS
|
|
86
|
-
)
|
|
77
|
+
return any(pattern in path_normalized for pattern in HOOK_INFRASTRUCTURE_PATTERNS)
|
|
87
78
|
|
|
88
79
|
|
|
89
80
|
def is_workflow_registry_file(file_path: str) -> bool:
|
|
90
81
|
path_normalized = file_path.lower().replace("\\", "/")
|
|
91
|
-
return any(
|
|
92
|
-
pattern.replace("\\", "/") in path_normalized
|
|
93
|
-
for pattern in WORKFLOW_REGISTRY_PATTERNS
|
|
94
|
-
)
|
|
82
|
+
return any(pattern in path_normalized for pattern in WORKFLOW_REGISTRY_PATTERNS)
|
|
95
83
|
|
|
96
84
|
|
|
97
85
|
def is_migration_file(file_path: str) -> bool:
|
|
98
86
|
path_normalized = file_path.lower().replace("\\", "/")
|
|
99
|
-
return any(
|
|
100
|
-
pattern.replace("\\", "/") in path_normalized
|
|
101
|
-
for pattern in MIGRATION_PATH_PATTERNS
|
|
102
|
-
)
|
|
87
|
+
return any(pattern in path_normalized for pattern in MIGRATION_PATH_PATTERNS)
|
|
@@ -14,6 +14,9 @@ _BLOCKING_DIR = str(Path(__file__).resolve().parent.parent / "blocking")
|
|
|
14
14
|
if _BLOCKING_DIR not in sys.path:
|
|
15
15
|
sys.path.insert(0, _BLOCKING_DIR)
|
|
16
16
|
|
|
17
|
+
import pytest # noqa: E402
|
|
18
|
+
|
|
19
|
+
from validators import exempt_paths # noqa: E402
|
|
17
20
|
from validators.exempt_paths import is_config_file # noqa: E402
|
|
18
21
|
from code_rules_path_utils import is_config_file as path_utils_is_config_file # noqa: E402
|
|
19
22
|
|
|
@@ -63,3 +66,20 @@ def test_is_config_file_is_identical_function_object_from_path_utils() -> None:
|
|
|
63
66
|
assert is_config_file is path_utils_is_config_file, (
|
|
64
67
|
"exempt_paths.is_config_file must be imported from code_rules_path_utils, not re-defined"
|
|
65
68
|
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@pytest.mark.parametrize(
|
|
72
|
+
"normalized_pattern_set_name",
|
|
73
|
+
[
|
|
74
|
+
"HOOK_INFRASTRUCTURE_PATTERNS",
|
|
75
|
+
"WORKFLOW_REGISTRY_PATTERNS",
|
|
76
|
+
"MIGRATION_PATH_PATTERNS",
|
|
77
|
+
],
|
|
78
|
+
)
|
|
79
|
+
def test_normalized_pattern_set_carries_no_backslash_entry(
|
|
80
|
+
normalized_pattern_set_name: str,
|
|
81
|
+
) -> None:
|
|
82
|
+
"""Each set's consumer normalizes the path with .replace("\\\\", "/") but
|
|
83
|
+
not the pattern, so a backslash entry can never match."""
|
|
84
|
+
pattern_set = getattr(exempt_paths, normalized_pattern_set_name)
|
|
85
|
+
assert all("\\" not in each_pattern for each_pattern in pattern_set)
|
|
@@ -150,6 +150,18 @@ class TestPrintInCliEntryPoint:
|
|
|
150
150
|
tree = ast.parse(CLI_ENTRY_POINT_WITH_PRINTS)
|
|
151
151
|
assert check_print_in_production(tree, "packages/foo/scripts/run_job.py") == []
|
|
152
152
|
|
|
153
|
+
def test_diagnostics_path_allows_print(self) -> None:
|
|
154
|
+
tree = ast.parse(CLI_ENTRY_POINT_WITH_PRINTS)
|
|
155
|
+
assert check_print_in_production(tree, "theme_exports/tools/diagnostics/probe.py") == []
|
|
156
|
+
|
|
157
|
+
def test_windows_diagnostics_path_allows_print(self) -> None:
|
|
158
|
+
tree = ast.parse(CLI_ENTRY_POINT_WITH_PRINTS)
|
|
159
|
+
assert check_print_in_production(tree, "theme_exports\\tools\\diagnostics\\probe.py") == []
|
|
160
|
+
|
|
161
|
+
def test_near_miss_diagnostics_name_still_flagged(self) -> None:
|
|
162
|
+
tree = ast.parse(NEAR_MISS_WITH_PRINT)
|
|
163
|
+
assert len(check_print_in_production(tree, "run_diagnostics.py")) == 1
|
|
164
|
+
|
|
153
165
|
def test_near_miss_name_still_flagged(self) -> None:
|
|
154
166
|
tree = ast.parse(NEAR_MISS_WITH_PRINT)
|
|
155
167
|
assert len(check_print_in_production(tree, "publicli.py")) == 1
|
|
@@ -180,6 +192,8 @@ class TestCrossSurfaceConsistency:
|
|
|
180
192
|
"stp_preview/renderer.py",
|
|
181
193
|
"publicli.py",
|
|
182
194
|
"packages/foo/scripts/run_job.py",
|
|
195
|
+
"theme_exports/tools/diagnostics/probe.py",
|
|
196
|
+
"run_diagnostics.py",
|
|
183
197
|
):
|
|
184
198
|
production_allows = check_print_in_production(tree, path) == []
|
|
185
199
|
library_allows = check_library_print(source, path) == []
|
package/package.json
CHANGED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# The Agent Merges Its Own Green Pull Request
|
|
2
|
+
|
|
3
|
+
**When this applies:** Any pull request an agent opened or was asked to drive, once its checks report.
|
|
4
|
+
|
|
5
|
+
## Rule
|
|
6
|
+
|
|
7
|
+
The agent that drives a pull request merges it. A pull request that is green, carries no open review thread, and sits at a merge state of `clean` is merged in the same run that brought it there. Waiting for the owner to type "merge" parks finished work on the person the work was done for.
|
|
8
|
+
|
|
9
|
+
Three things stay with the owner, and nothing else does:
|
|
10
|
+
|
|
11
|
+
- A pull request the owner asked to hold.
|
|
12
|
+
- A change the owner said they want to read first.
|
|
13
|
+
- A repository whose branch rule requires an approving review the agent cannot give.
|
|
14
|
+
|
|
15
|
+
Where the branch rule requires zero approvals and one status check, that check is the gate, and the agent merges on its verdict.
|
|
16
|
+
|
|
17
|
+
Validation is the precondition, and green means every check reported on the exact head commit. A branch rule that requires one status check names the floor a merge needs; a pull request whose other checks are red or still running is held until they report.
|
|
18
|
+
|
|
19
|
+
## The precondition is mechanical
|
|
20
|
+
|
|
21
|
+
One command prints the verdict:
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
python packages/claude-dev-env/scripts/agent_merge_check.py <owner>/<name> <number>
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
It prints `MERGE` and exits 0 when the pull request is ready. It prints `HOLD` with the reason and exits 1 for a draft, for a head behind or conflicting with the base, for a required check that is not passing, for a check still running or red, and for an open review thread. It exits 2 when the state could not be read.
|
|
28
|
+
|
|
29
|
+
Each hold reason names its own repair, and each repair belongs to the agent:
|
|
30
|
+
|
|
31
|
+
| Hold | What the agent does |
|
|
32
|
+
|---|---|
|
|
33
|
+
| Head behind the base | Merge the base branch in and push |
|
|
34
|
+
| Head conflicts with the base | Merge the base branch in, resolve, push |
|
|
35
|
+
| A required check is red | Read the failing check, fix it, push |
|
|
36
|
+
| A check is red or still running | Fix it, or wait for it, then read the verdict again |
|
|
37
|
+
| A review thread is open | Answer it, push the fix, resolve the thread |
|
|
38
|
+
| The pull request is a draft | Mark it ready once the checks pass |
|
|
39
|
+
|
|
40
|
+
## When a gate elsewhere holds the merge command
|
|
41
|
+
|
|
42
|
+
A session working inside another repository can sit behind that repository's own pre-merge gate, which reads the checkout the session works in and refuses a merge command whatever repository the pull request belongs to. That session hands the merge to the session that owns this repository's pull requests, by message, naming the pull request. The receiving session reads the verdict above and merges. The hand-off carries the work; it never lands on the owner.
|
|
43
|
+
|
|
44
|
+
## After the merge
|
|
45
|
+
|
|
46
|
+
Delete nothing by hand. The repository deletes the head branch on merge.
|
|
47
|
+
|
|
48
|
+
## Sibling rules
|
|
49
|
+
|
|
50
|
+
| Rule | Role |
|
|
51
|
+
|---|---|
|
|
52
|
+
| [`git-workflow.md`](git-workflow.md) | Draft first, and confirm each required context fired after the push |
|
|
53
|
+
| [`ci-owns-the-gate.md`](ci-owns-the-gate.md) | The gate runs once, and it runs on CI |
|
|
54
|
+
| [`correction-lens.md`](correction-lens.md) | A correction becomes a control at the highest layer that can hold it |
|
|
@@ -43,6 +43,16 @@ Record a row in the archive manifest for each file: why it went, the `git mv`
|
|
|
43
43
|
that restores it, and every other edit a restore has to undo. A restore that has
|
|
44
44
|
to be reconstructed from the diff is not recoverable in any useful sense.
|
|
45
45
|
|
|
46
|
+
## Exempt files
|
|
47
|
+
|
|
48
|
+
The "Never archived" section of `rules-archived/ARCHIVE-MANIFEST.md` names the
|
|
49
|
+
files this procedure leaves alone. Read that section before a prune moves
|
|
50
|
+
anything. A pass that reaches an exempt file stops there and reports which file
|
|
51
|
+
it was, so the prune's other moves still land.
|
|
52
|
+
|
|
53
|
+
`rules/correction-lens.md` is on that list today. Editing an exempt file to
|
|
54
|
+
sharpen it is ordinary work.
|
|
55
|
+
|
|
46
56
|
## Make the archive stick
|
|
47
57
|
|
|
48
58
|
Two things do not follow the file on their own.
|
package/rules/code-standards.md
CHANGED
|
@@ -27,6 +27,7 @@ BDD is the outer process and TDD is the inner loop: [`bdd.md`](bdd.md) discovers
|
|
|
27
27
|
|
|
28
28
|
| Concern | Rule file |
|
|
29
29
|
|---|---|
|
|
30
|
+
| Handling a correction from the user | [`correction-lens.md`](correction-lens.md) |
|
|
30
31
|
| Task tracking / worker completion | [`workers-done-before-complete.md`](workers-done-before-complete.md) |
|
|
31
32
|
| Multi-step task list | skill `task-build` (see agents catalog) |
|
|
32
33
|
|