claude-dev-env 1.74.0 → 1.76.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/_shared/pr-loop/scripts/code_rules_gate.py +60 -5
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/inline_duplicate_body_span_constants.py +22 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +147 -3
- package/docs/CODE_RULES.md +1 -1
- package/hooks/blocking/CLAUDE.md +3 -2
- package/hooks/blocking/claude_md_orphan_file_blocker.py +170 -20
- package/hooks/blocking/code_rules_duplicate_body.py +378 -26
- package/hooks/blocking/code_rules_enforcer.py +36 -5
- package/hooks/blocking/code_rules_imports_logging.py +679 -1
- package/hooks/blocking/code_rules_shared.py +8 -5
- package/hooks/blocking/code_rules_test_assertions.py +6 -7
- package/hooks/blocking/code_verifier_spawn_preflight_gate.py +2 -1
- package/hooks/blocking/duplicate_rmtree_helper_blocker.py +155 -0
- package/hooks/blocking/hedging_language_blocker.py +1 -13
- package/hooks/blocking/intent_only_ending_blocker.py +1 -15
- package/hooks/blocking/pre_tool_use_dispatcher.py +4 -5
- package/hooks/blocking/question_to_user_enforcer.py +1 -11
- package/hooks/blocking/session_handoff_blocker.py +1 -15
- package/hooks/blocking/test_claude_md_orphan_file_blocker.py +484 -0
- package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +1 -0
- package/hooks/blocking/test_code_rules_enforcer_import_block_sort.py +157 -0
- package/hooks/blocking/test_code_rules_enforcer_same_file_inline_duplicate.py +466 -0
- package/hooks/blocking/test_code_rules_enforcer_split_test_assertions.py +11 -9
- package/hooks/blocking/test_code_rules_js_resume_task_enumeration.py +758 -0
- package/hooks/blocking/test_code_rules_logging_printf_tokens.py +134 -0
- package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +16 -0
- package/hooks/blocking/test_duplicate_rmtree_helper_blocker.py +328 -0
- package/hooks/blocking/test_hedging_language_blocker.py +6 -0
- package/hooks/blocking/test_intent_only_ending_blocker.py +5 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +52 -5
- package/hooks/blocking/test_question_to_user_enforcer.py +6 -0
- package/hooks/blocking/test_session_handoff_blocker.py +6 -0
- package/hooks/blocking/test_verification_verdict_store.py +66 -1
- package/hooks/blocking/test_verifier_verdict_minter.py +64 -5
- package/hooks/blocking/verification_verdict_store.py +19 -5
- package/hooks/blocking/verifier_verdict_minter.py +18 -15
- package/hooks/hooks_constants/CLAUDE.md +4 -1
- package/hooks/hooks_constants/blocking_check_limits.py +30 -1
- package/hooks/hooks_constants/claude_md_orphan_file_blocker_constants.py +52 -24
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +41 -1
- package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +2 -1
- package/hooks/hooks_constants/duplicate_function_body_constants.py +21 -5
- package/hooks/hooks_constants/duplicate_rmtree_helper_blocker_constants.py +27 -0
- package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +2 -0
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +8 -2
- package/hooks/hooks_constants/test_post_tool_use_dispatcher_constants.py +43 -0
- package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +99 -0
- package/hooks/hooks_constants/test_text_stripping.py +39 -0
- package/hooks/hooks_constants/text_stripping.py +36 -0
- package/hooks/validation/CLAUDE.md +1 -0
- package/hooks/validation/post_tool_use_dispatcher.py +2 -2
- package/hooks/validation/test_mypy_validator.py +1 -1
- package/hooks/validation/test_post_tool_use_dispatcher.py +6 -0
- package/hooks/workflow/auto_formatter.py +8 -5
- package/hooks/workflow/test_auto_formatter.py +33 -0
- package/package.json +1 -1
- package/rules/claude-md-orphan-file.md +7 -8
- package/rules/docstring-prose-matches-implementation.md +1 -0
- package/rules/package-inventory-stale-entry.md +8 -0
- package/rules/windows-filesystem-safe.md +2 -0
- package/skills/anthropic-plan/CLAUDE.md +1 -1
- package/skills/anthropic-plan/SKILL.md +15 -2
- package/skills/autoconverge/SKILL.md +6 -3
- package/skills/autoconverge/reference/stop-conditions.md +7 -0
- package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +5 -4
- package/skills/autoconverge/workflow/converge.contract.test.mjs +306 -137
- package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +16 -16
- package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +107 -44
- package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +16 -24
- package/skills/autoconverge/workflow/converge.mjs +581 -613
- package/skills/autoconverge/workflow/convergence_summary.py +1 -1
- package/skills/autoconverge/workflow/render_report.py +2 -6
- package/skills/autoconverge/workflow/test_convergence_summary.py +17 -0
- package/skills/autoconverge/workflow/test_render_report.py +1 -0
- package/skills/bugteam/scripts/bugteam_code_rules_gate.py +58 -4
- package/skills/bugteam/scripts/bugteam_scripts_constants/bugteam_code_rules_gate_constants.py +9 -0
- package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +42 -0
|
@@ -15,6 +15,7 @@ __all__ = [
|
|
|
15
15
|
"HOOK_EVENT_NAME",
|
|
16
16
|
"BLOCKING_CRASH_EXIT_CODE",
|
|
17
17
|
"EXIT_CODE_TWO_DENY_REASON",
|
|
18
|
+
"BLOCKING_CRASH_DENY_REASON",
|
|
18
19
|
"WRITE_TOOL_NAME",
|
|
19
20
|
"EDIT_TOOL_NAME",
|
|
20
21
|
"MULTI_EDIT_TOOL_NAME",
|
|
@@ -31,6 +32,7 @@ ALLOW_DECISION = "allow"
|
|
|
31
32
|
HOOK_EVENT_NAME = "PreToolUse"
|
|
32
33
|
BLOCKING_CRASH_EXIT_CODE = 2
|
|
33
34
|
EXIT_CODE_TWO_DENY_REASON = "[dispatcher] hook denied via exit code 2 — write blocked"
|
|
35
|
+
BLOCKING_CRASH_DENY_REASON = "[dispatcher] hook crash in blocking hook — write blocked for safety"
|
|
34
36
|
|
|
35
37
|
WRITE_TOOL_NAME = "Write"
|
|
36
38
|
EDIT_TOOL_NAME = "Edit"
|
|
@@ -59,8 +61,8 @@ class HostedHookEntry:
|
|
|
59
61
|
native_module_name: The importable module name whose evaluate function
|
|
60
62
|
the dispatcher calls in-process for this hook, or None when the hook
|
|
61
63
|
runs via runpy under __main__. The named module exposes a function
|
|
62
|
-
named
|
|
63
|
-
|
|
64
|
+
named `evaluate` taking the payload dict and returning a deny-reason
|
|
65
|
+
string or None.
|
|
64
66
|
"""
|
|
65
67
|
|
|
66
68
|
script_relative_path: str
|
|
@@ -94,6 +96,10 @@ ALL_HOSTED_HOOK_ENTRIES: tuple[HostedHookEntry, ...] = (
|
|
|
94
96
|
script_relative_path="blocking/windows_rmtree_blocker.py",
|
|
95
97
|
applicable_tool_names=ALL_WRITE_AND_EDIT_TOOL_NAMES,
|
|
96
98
|
),
|
|
99
|
+
HostedHookEntry(
|
|
100
|
+
script_relative_path="blocking/duplicate_rmtree_helper_blocker.py",
|
|
101
|
+
applicable_tool_names=ALL_WRITE_AND_EDIT_TOOL_NAMES,
|
|
102
|
+
),
|
|
97
103
|
HostedHookEntry(
|
|
98
104
|
script_relative_path="blocking/state_description_blocker.py",
|
|
99
105
|
applicable_tool_names=ALL_WRITE_AND_EDIT_TOOL_NAMES,
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Tests for the PostToolUse dispatcher constants module."""
|
|
2
|
+
|
|
3
|
+
import pathlib
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
_HOOKS_ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
7
|
+
if str(_HOOKS_ROOT) not in sys.path:
|
|
8
|
+
sys.path.insert(0, str(_HOOKS_ROOT))
|
|
9
|
+
|
|
10
|
+
_VALIDATION_DIR = _HOOKS_ROOT / "validation"
|
|
11
|
+
if str(_VALIDATION_DIR) not in sys.path:
|
|
12
|
+
sys.path.insert(0, str(_VALIDATION_DIR))
|
|
13
|
+
|
|
14
|
+
from post_tool_use_dispatcher import (
|
|
15
|
+
PostHostedHookResult,
|
|
16
|
+
aggregate_post_hosted_hook_results,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
from hooks_constants.post_tool_use_dispatcher_constants import (
|
|
20
|
+
BLOCKING_CRASH_DENY_REASON,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_blocking_hook_crash_block_reason_surfaces_the_constant() -> None:
|
|
25
|
+
crash_result = PostHostedHookResult(
|
|
26
|
+
captured_stdout="",
|
|
27
|
+
did_crash=True,
|
|
28
|
+
is_blocking=True,
|
|
29
|
+
)
|
|
30
|
+
decision = aggregate_post_hosted_hook_results([crash_result])
|
|
31
|
+
assert decision.should_block
|
|
32
|
+
assert BLOCKING_CRASH_DENY_REASON in decision.all_block_reasons
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_non_blocking_hook_crash_does_not_surface_the_constant() -> None:
|
|
36
|
+
crash_result = PostHostedHookResult(
|
|
37
|
+
captured_stdout="",
|
|
38
|
+
did_crash=True,
|
|
39
|
+
is_blocking=False,
|
|
40
|
+
)
|
|
41
|
+
decision = aggregate_post_hosted_hook_results([crash_result])
|
|
42
|
+
assert not decision.should_block
|
|
43
|
+
assert BLOCKING_CRASH_DENY_REASON not in decision.all_block_reasons
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Tests for the PreToolUse dispatcher hosted-hook roster."""
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
import pathlib
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
_HOOKS_ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
8
|
+
if str(_HOOKS_ROOT) not in sys.path:
|
|
9
|
+
sys.path.insert(0, str(_HOOKS_ROOT))
|
|
10
|
+
|
|
11
|
+
_BLOCKING_DIR = _HOOKS_ROOT / "blocking"
|
|
12
|
+
if str(_BLOCKING_DIR) not in sys.path:
|
|
13
|
+
sys.path.insert(0, str(_BLOCKING_DIR))
|
|
14
|
+
|
|
15
|
+
from hooks_constants.pre_tool_use_dispatcher_constants import (
|
|
16
|
+
ALL_HOSTED_HOOK_ENTRIES,
|
|
17
|
+
ALL_WRITE_AND_EDIT_TOOL_NAMES,
|
|
18
|
+
BLOCKING_CRASH_DENY_REASON,
|
|
19
|
+
EDIT_TOOL_NAME,
|
|
20
|
+
WRITE_TOOL_NAME,
|
|
21
|
+
)
|
|
22
|
+
from pre_tool_use_dispatcher import (
|
|
23
|
+
HostedHookResult,
|
|
24
|
+
aggregate_hosted_hook_results,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _entry_for(script_relative_path: str):
|
|
29
|
+
matching_entries = [
|
|
30
|
+
each_entry
|
|
31
|
+
for each_entry in ALL_HOSTED_HOOK_ENTRIES
|
|
32
|
+
if each_entry.script_relative_path == script_relative_path
|
|
33
|
+
]
|
|
34
|
+
return matching_entries[0] if matching_entries else None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_roster_includes_duplicate_rmtree_helper_blocker_script_path() -> None:
|
|
38
|
+
all_registered_script_paths = [
|
|
39
|
+
each_entry.script_relative_path for each_entry in ALL_HOSTED_HOOK_ENTRIES
|
|
40
|
+
]
|
|
41
|
+
assert "blocking/duplicate_rmtree_helper_blocker.py" in all_registered_script_paths, (
|
|
42
|
+
"duplicate_rmtree_helper_blocker must be hosted by the dispatcher so a local "
|
|
43
|
+
"re-definition of the Windows-safe rmtree helper trio is blocked at Write time"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_duplicate_rmtree_helper_blocker_applies_to_write_and_edit() -> None:
|
|
48
|
+
entry = _entry_for("blocking/duplicate_rmtree_helper_blocker.py")
|
|
49
|
+
assert entry is not None
|
|
50
|
+
assert WRITE_TOOL_NAME in entry.applicable_tool_names
|
|
51
|
+
assert EDIT_TOOL_NAME in entry.applicable_tool_names
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_duplicate_rmtree_helper_blocker_is_blocking() -> None:
|
|
55
|
+
entry = _entry_for("blocking/duplicate_rmtree_helper_blocker.py")
|
|
56
|
+
assert entry is not None
|
|
57
|
+
assert entry.is_blocking is True
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_duplicate_rmtree_helper_blocker_runs_via_runpy() -> None:
|
|
61
|
+
entry = _entry_for("blocking/duplicate_rmtree_helper_blocker.py")
|
|
62
|
+
assert entry is not None
|
|
63
|
+
assert entry.native_module_name is None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_windows_rmtree_blocker_still_registered() -> None:
|
|
67
|
+
entry = _entry_for("blocking/windows_rmtree_blocker.py")
|
|
68
|
+
assert entry is not None
|
|
69
|
+
assert entry.applicable_tool_names == ALL_WRITE_AND_EDIT_TOOL_NAMES
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_blocking_hook_crash_deny_reason_surfaces_the_constant() -> None:
|
|
73
|
+
crash_result = HostedHookResult(
|
|
74
|
+
exit_code=0,
|
|
75
|
+
captured_stdout="",
|
|
76
|
+
did_crash=True,
|
|
77
|
+
is_blocking=True,
|
|
78
|
+
)
|
|
79
|
+
decision = aggregate_hosted_hook_results([crash_result])
|
|
80
|
+
assert decision.should_deny
|
|
81
|
+
assert BLOCKING_CRASH_DENY_REASON in decision.all_deny_reasons
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_every_native_module_exposes_a_callable_evaluate() -> None:
|
|
85
|
+
nativized_entries = [
|
|
86
|
+
each_entry
|
|
87
|
+
for each_entry in ALL_HOSTED_HOOK_ENTRIES
|
|
88
|
+
if each_entry.native_module_name is not None
|
|
89
|
+
]
|
|
90
|
+
assert nativized_entries, (
|
|
91
|
+
"the roster must carry at least one nativized hook for this test to lock the contract"
|
|
92
|
+
)
|
|
93
|
+
for each_entry in nativized_entries:
|
|
94
|
+
native_module = importlib.import_module(each_entry.native_module_name)
|
|
95
|
+
evaluate_function = getattr(native_module, "evaluate", None)
|
|
96
|
+
assert callable(evaluate_function), (
|
|
97
|
+
f"{each_entry.native_module_name} must expose a callable named evaluate, "
|
|
98
|
+
"matching the native_module_name docstring contract"
|
|
99
|
+
)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Tests for the shared strip_code_and_quotes helper."""
|
|
2
|
+
|
|
3
|
+
import pathlib
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
_HOOKS_ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
7
|
+
if str(_HOOKS_ROOT) not in sys.path:
|
|
8
|
+
sys.path.insert(0, str(_HOOKS_ROOT))
|
|
9
|
+
|
|
10
|
+
from hooks_constants.text_stripping import strip_code_and_quotes
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_removes_fenced_code_block() -> None:
|
|
14
|
+
text = "before\n```python\nshould I run this?\n```\nafter"
|
|
15
|
+
stripped = strip_code_and_quotes(text)
|
|
16
|
+
assert "should I run this?" not in stripped
|
|
17
|
+
assert "before" in stripped
|
|
18
|
+
assert "after" in stripped
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_removes_inline_code_span() -> None:
|
|
22
|
+
text = "the function `would you like` is named oddly"
|
|
23
|
+
stripped = strip_code_and_quotes(text)
|
|
24
|
+
assert "would you like" not in stripped
|
|
25
|
+
assert "the function" in stripped
|
|
26
|
+
assert "is named oddly" in stripped
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_removes_leading_blockquote_lines() -> None:
|
|
30
|
+
text = "real line\n> should I proceed?\nfinal line"
|
|
31
|
+
stripped = strip_code_and_quotes(text)
|
|
32
|
+
assert "should I proceed?" not in stripped
|
|
33
|
+
assert "real line" in stripped
|
|
34
|
+
assert "final line" in stripped
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_leaves_plain_prose_unchanged() -> None:
|
|
38
|
+
text = "This sentence carries no code or quotes."
|
|
39
|
+
assert strip_code_and_quotes(text) == text
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Shared text-stripping helper for the Stop-hook prose blockers.
|
|
2
|
+
|
|
3
|
+
Several Stop hooks judge the prose of an assistant message and must ignore
|
|
4
|
+
fenced code blocks, inline code spans, and leading blockquotes so a phrase that
|
|
5
|
+
appears only inside code or a quote never trips the detector. The stripping
|
|
6
|
+
logic is identical across those blockers, so it lives here once and is imported
|
|
7
|
+
from each.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"strip_code_and_quotes",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
CODE_BLOCK_PATTERN = re.compile(r"```[\s\S]*?```", re.MULTILINE)
|
|
19
|
+
INLINE_CODE_PATTERN = re.compile(r"`[^`]+`")
|
|
20
|
+
QUOTED_BLOCK_PATTERN = re.compile(r"^>.*$", re.MULTILINE)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def strip_code_and_quotes(text: str) -> str:
|
|
24
|
+
"""Remove fenced code blocks, inline code, and blockquotes from prose.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
text: The raw assistant message to clean of code and quoted lines.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
The text with every fenced code block, inline code span, and leading
|
|
31
|
+
blockquote line removed, so only the prose a reader sees remains.
|
|
32
|
+
"""
|
|
33
|
+
text = CODE_BLOCK_PATTERN.sub("", text)
|
|
34
|
+
text = INLINE_CODE_PATTERN.sub("", text)
|
|
35
|
+
text = QUOTED_BLOCK_PATTERN.sub("", text)
|
|
36
|
+
return text
|
|
@@ -9,6 +9,7 @@ PostToolUse hooks that validate code quality after Claude writes or edits a file
|
|
|
9
9
|
| `mypy_validator.py` | PostToolUse (Write/Edit on `.py` files) | Runs mypy on the written file and blocks (via PostToolUse block decision) when type errors are found — catches missing attributes, wrong signatures, type mismatches, and import errors |
|
|
10
10
|
| `hook_format_validator.py` | PostToolUse | Validates that a hook script's output JSON matches the expected Claude Code hook-output schema |
|
|
11
11
|
| `test_mypy_validator.py` | — | Tests for `mypy_validator.py` |
|
|
12
|
+
| `test_hook_format_validator.py` | — | Tests for `hook_format_validator.py` |
|
|
12
13
|
|
|
13
14
|
## Conventions
|
|
14
15
|
|
|
@@ -33,6 +33,7 @@ if _hooks_directory not in sys.path:
|
|
|
33
33
|
from hooks_constants.post_tool_use_dispatcher_constants import ( # noqa: E402
|
|
34
34
|
ALL_POST_HOSTED_HOOK_ENTRIES,
|
|
35
35
|
BLOCK_DECISION,
|
|
36
|
+
BLOCKING_CRASH_DENY_REASON,
|
|
36
37
|
DECISION_KEY,
|
|
37
38
|
EMPTY_REASON_BLOCK_FALLBACK,
|
|
38
39
|
HOOK_EVENT_NAME,
|
|
@@ -197,7 +198,6 @@ def aggregate_post_hosted_hook_results(
|
|
|
197
198
|
A PostDispatcherDecision with the aggregated allow-or-block signal,
|
|
198
199
|
all block reasons, and all non-block stdout.
|
|
199
200
|
"""
|
|
200
|
-
blocking_crash_reason = "[dispatcher] hook crash in blocking hook — write blocked for safety"
|
|
201
201
|
all_block_reasons: list[str] = []
|
|
202
202
|
all_non_block_stdout: list[str] = []
|
|
203
203
|
|
|
@@ -206,7 +206,7 @@ def aggregate_post_hosted_hook_results(
|
|
|
206
206
|
if is_block:
|
|
207
207
|
all_block_reasons.append(block_reason if block_reason else EMPTY_REASON_BLOCK_FALLBACK)
|
|
208
208
|
elif each_result.did_crash and each_result.is_blocking:
|
|
209
|
-
all_block_reasons.append(
|
|
209
|
+
all_block_reasons.append(BLOCKING_CRASH_DENY_REASON)
|
|
210
210
|
else:
|
|
211
211
|
non_block_text = each_result.captured_stdout.strip()
|
|
212
212
|
if non_block_text:
|
|
@@ -94,7 +94,7 @@ def test_build_mypy_command_includes_config_file_when_present(tmp_path: Path) ->
|
|
|
94
94
|
assert command[-1] == "package/module.py"
|
|
95
95
|
|
|
96
96
|
|
|
97
|
-
def test_build_mypy_command_omits_config_file_when_absent(
|
|
97
|
+
def test_build_mypy_command_omits_config_file_when_absent() -> None:
|
|
98
98
|
validator = _load_validator()
|
|
99
99
|
|
|
100
100
|
command = validator.build_mypy_command("package/module.py", None)
|
|
@@ -39,6 +39,7 @@ from hooks_constants.doc_gist_auto_publish_constants import ( # noqa: E402, I00
|
|
|
39
39
|
from hooks_constants.post_tool_use_dispatcher_constants import ( # noqa: E402, I001
|
|
40
40
|
ALL_POST_HOSTED_HOOK_ENTRIES,
|
|
41
41
|
BLOCK_DECISION,
|
|
42
|
+
BLOCKING_CRASH_DENY_REASON,
|
|
42
43
|
EMPTY_REASON_BLOCK_FALLBACK,
|
|
43
44
|
PLUGIN_ROOT_PLACEHOLDER,
|
|
44
45
|
PostHostedHookEntry,
|
|
@@ -608,3 +609,8 @@ def test_blocking_hook_crash_surfaces_a_block() -> None:
|
|
|
608
609
|
"The block reason from a blocking hook crash must reference the dispatcher.\n"
|
|
609
610
|
f"Got: {aggregated_decision.all_block_reasons[0]!r}"
|
|
610
611
|
)
|
|
612
|
+
assert BLOCKING_CRASH_DENY_REASON in aggregated_decision.all_block_reasons, (
|
|
613
|
+
"The block reason from a blocking hook crash must be the "
|
|
614
|
+
"BLOCKING_CRASH_DENY_REASON constant.\n"
|
|
615
|
+
f"Got: {aggregated_decision.all_block_reasons!r}"
|
|
616
|
+
)
|
|
@@ -79,11 +79,14 @@ def has_prettier_config(file_path: str) -> bool:
|
|
|
79
79
|
def budgeted_python_format_seconds() -> int:
|
|
80
80
|
"""Return the wall-clock budget for the two-subprocess happy path.
|
|
81
81
|
|
|
82
|
-
The
|
|
83
|
-
|
|
84
|
-
is
|
|
85
|
-
|
|
86
|
-
|
|
82
|
+
The fix loop breaks on the first command that runs — whether it returns zero
|
|
83
|
+
or non-zero — or on a timeout, and continues to the next command only when a
|
|
84
|
+
command is missing (FileNotFoundError). The format loop breaks only on a
|
|
85
|
+
returncode of zero or on a timeout, and continues on a non-zero return or a
|
|
86
|
+
missing command. The common case spends one fix subprocess plus one format
|
|
87
|
+
subprocess. This is a budget for that assumed path, not a guaranteed upper
|
|
88
|
+
bound: when commands are missing or time out the loops can spend more than
|
|
89
|
+
this budget.
|
|
87
90
|
"""
|
|
88
91
|
fix_phase_seconds = PYTHON_FORMAT_TIMEOUT_SECONDS
|
|
89
92
|
format_phase_seconds = PYTHON_FORMAT_TIMEOUT_SECONDS
|
|
@@ -105,6 +105,39 @@ class TestRuffFixOnNewFiles:
|
|
|
105
105
|
assert completed_hook.returncode == 0
|
|
106
106
|
assert "import os" in edited_file.read_text(encoding="utf-8")
|
|
107
107
|
|
|
108
|
+
def should_leave_tracked_python_file_arriving_through_write_untouched(
|
|
109
|
+
self, git_repository: Path
|
|
110
|
+
) -> None:
|
|
111
|
+
tracked_file = git_repository / "tracked.py"
|
|
112
|
+
tracked_file.write_text(UNUSED_IMPORT_SOURCE, encoding="utf-8")
|
|
113
|
+
subprocess.run(
|
|
114
|
+
["git", "add", "tracked.py"],
|
|
115
|
+
cwd=git_repository,
|
|
116
|
+
capture_output=True,
|
|
117
|
+
check=True,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
completed_hook = _run_hook("Write", tracked_file)
|
|
121
|
+
|
|
122
|
+
assert completed_hook.returncode == 0
|
|
123
|
+
assert "import os" in tracked_file.read_text(encoding="utf-8")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_tracked_write_leaves_unused_import_in_place(git_repository: Path) -> None:
|
|
127
|
+
tracked_file = git_repository / "tracked_module.py"
|
|
128
|
+
tracked_file.write_text(UNUSED_IMPORT_SOURCE, encoding="utf-8")
|
|
129
|
+
subprocess.run(
|
|
130
|
+
["git", "add", "tracked_module.py"],
|
|
131
|
+
cwd=git_repository,
|
|
132
|
+
capture_output=True,
|
|
133
|
+
check=True,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
completed_hook = _run_hook("Write", tracked_file)
|
|
137
|
+
|
|
138
|
+
assert completed_hook.returncode == 0
|
|
139
|
+
assert "import os" in tracked_file.read_text(encoding="utf-8")
|
|
140
|
+
|
|
108
141
|
|
|
109
142
|
def _load_auto_formatter_module() -> object:
|
|
110
143
|
module_spec = importlib.util.spec_from_file_location("auto_formatter", HOOK_SCRIPT_PATH)
|
package/package.json
CHANGED
|
@@ -1,24 +1,23 @@
|
|
|
1
1
|
# Orphan File Reference in a Per-Directory CLAUDE.md
|
|
2
2
|
|
|
3
|
-
**When this applies:** Any Write, Edit, or MultiEdit to a file named `CLAUDE.md` that lists files in a markdown table whose first column names each file in backticks.
|
|
3
|
+
**When this applies:** Any Write, Edit, or MultiEdit to a file named `CLAUDE.md` that lists files in a markdown table whose first column names each file in backticks, or that shows run commands invoking those files inside fenced code blocks.
|
|
4
4
|
|
|
5
5
|
## Rule
|
|
6
6
|
|
|
7
|
-
Every bare filename a per-directory `CLAUDE.md`
|
|
7
|
+
Every bare filename a per-directory `CLAUDE.md` names points at a file that exists in the directory subtree the `CLAUDE.md` describes — both the filenames its table cells list and the scripts its fenced run commands invoke (`python script.py`). A table cell or a run command naming a file that exists nowhere in that subtree points a reader at something that is not there: the doc claims a file the directory does not hold.
|
|
8
8
|
|
|
9
|
-
When you add a table row, the file it names already exists in this directory or a subdirectory of it. When you remove a file, drop the row that named it.
|
|
9
|
+
When you add a table row or a run command, the file it names already exists in this directory or a subdirectory of it. When you remove a file, drop the row and the run command that named it.
|
|
10
10
|
|
|
11
11
|
## What the gate checks
|
|
12
12
|
|
|
13
13
|
The `claude_md_orphan_file_blocker.py` hook runs on every Write, Edit, and MultiEdit whose target basename is `CLAUDE.md`. It:
|
|
14
14
|
|
|
15
15
|
1. Reads the content the tool would leave on disk. For a Write that is the full `content`. For an Edit or MultiEdit it reconstructs the post-edit file — the existing on-disk file with the replacements applied — and also notes which orphans the file already held before the edit, so a pre-existing orphan on an untouched line is excluded and only an orphan the edit introduces is reported; when the existing file cannot be read, it scans the raw `new_string` fragment(s) instead.
|
|
16
|
-
2.
|
|
17
|
-
3.
|
|
18
|
-
4. Blocks the write when a named file exists nowhere under the scan root — the `CLAUDE.md` directory's parent, which covers the directory, its subdirectories, and its siblings. A filesystem error that halts the whole subtree walk fails open (the write proceeds), so an unreadable tree never blocks a write.
|
|
16
|
+
2. Collects two kinds of referenced filename. Table cells: the first column of each markdown table row **outside** a fenced code block, keeping cells that name a bare filename wrapped in backticks, no path separator, not a slash-command, ending in a known file extension (`.py`, `.md`, `.json`, `.mjs`, `.js`, `.ts`, `.ps1`, `.cmd`, `.ahk`, `.yml`, `.yaml`, `.sh`, `.txt`, `.cfg`, `.toml`, `.ini`). Run commands: each line **inside** a fenced code block (between a ``` or `~~~` fence pair) that invokes an interpreter (`python`, `python.exe`, `python3`, `node`, `pwsh`, `powershell`, `bash`, `sh`, `ruby`, `perl`) on a script, taking that script's basename when it ends in `.py`, `.mjs`, `.js`, `.ts`, `.ps1`, `.sh`, `.rb`, or `.pl`. A fenced *table row* is an example, not a live listing, so it contributes no table-cell filename; a fenced *run command* is the contract a reader runs, so its script filename is checked.
|
|
17
|
+
3. Blocks the write when a referenced filename — from a table cell or a fenced run command — exists nowhere under the scan root — the `CLAUDE.md` directory's parent, which covers the directory, its subdirectories, and its siblings. A filesystem error that halts the whole subtree walk fails open (the write proceeds), so an unreadable tree never blocks a write.
|
|
19
18
|
|
|
20
|
-
The check stays quiet for a target that is not a `CLAUDE.md`, for a table cell that holds a path, a subdirectory ending in `/`, or a slash-command, for a table row inside a fenced code block, and for a table whose content names an explicit relative-path source (a `../` token), since that table documents files that sit outside the subtree by design.
|
|
19
|
+
The check stays quiet for a target that is not a `CLAUDE.md`, for a table cell that holds a path, a subdirectory ending in `/`, or a slash-command, for a table row inside a fenced code block, for an inline `python x.py` mention outside a fence (prose, not a runnable contract), and for a table whose content names an explicit relative-path source (a `../` token), since that table documents files that sit outside the subtree by design.
|
|
21
20
|
|
|
22
21
|
## Why this is a hook, not a lint pass
|
|
23
22
|
|
|
24
|
-
A table row that names an absent file reads as a contract: a reader trusts the listing to map the directory. A wrong row sends the reader looking for a file that is not there
|
|
23
|
+
A table row or a run command that names an absent file reads as a contract: a reader trusts the listing to map the directory and trusts the shown command to run. A wrong row sends the reader looking for a file that is not there; a stale run command fails the moment the reader runs it. Both erode trust in every other entry. Catching them as each line is written keeps the doc and the directory in step.
|
|
@@ -16,6 +16,7 @@ Read the body and the docstring side by side:
|
|
|
16
16
|
- **Suppressor / skip lists.** A body with several early returns that suppress the check names each suppressor in the prose.
|
|
17
17
|
- **Shared fallback routes.** A summary that scopes a fallback call to one condition names every condition that reaches that call. When the body routes to the same fallback from two or more early-return guards (`if a is None: fallback(); return` and `if random() < p: fallback(); return`), the prose enumerates both guards. The `check_docstring_fallback_branch_coverage` gate blocks the single-condition form of this drift at Write/Edit time.
|
|
18
18
|
- **Step order.** A docstring that says `A then B then C` matches the call order in the body. A step enumeration that names the body's linear steps also names every corrective step the body guards inside an `if`/`elif` branch (`if not await cancel_and_reinitiate_update(...): return`). The `check_docstring_step_enumeration_dispatch_coverage` gate blocks the branch-guarded-dispatch form of this drift — a step-enumeration docstring that omits a two-or-more-token dispatch step the body guards inside a branch — at Write/Edit time.
|
|
19
|
+
- **JS/`.mjs` resume-task enumerations.** A `spawn<Role>Agent` JSDoc that enumerates its sibling `resume<Role>Agent`'s resume tasks in a parenthetical `resume (repair-verify, hardening-verify)` list names every `task === '<name>'` branch the resume body dispatches on. The `check_js_resume_task_enumeration_coverage` gate blocks the JavaScript form of this drift — a spawn JSDoc whose resume enumeration omits a dispatched task — at Write/Edit time. This is the `.mjs` slice of the same Category O6 standard the Python gates carry; the Python AST docstring gates never inspect JavaScript source.
|
|
19
20
|
- **Returns-clause cardinality.** A `Returns:` clause that names a dict-key prefix family with a plural noun (`the sheen stops`) matches the count of keys in that family in the returned dict literal. When the dict holds one key in the family (`sheen_mid`), the noun is singular (`the sheen stop`); a plural noun there claims two or more entries the dict does not hold. The `check_docstring_returns_plural_cardinality` gate blocks the single-key-with-plural-noun form of this drift at Write/Edit time.
|
|
20
21
|
- **Predicate breadth.** A boolean helper whose prose promises a narrow check accepts only the inputs the prose names — no broader input class the name and prose do not mention.
|
|
21
22
|
- **Exclusion-clause distinguisher.** A docstring sentence that says a named category of input "are not" / "is not" the thing the function flags (`plain logging, screenshot, or method-on-local calls inside a branch are not dispatch steps`) keys the exclusion to the same axis the body's classification keys on. When the body decides on one axis (a call sits in an `If.test` guard versus a plain statement) but the prose excludes on a different axis (the call's receiver shape — a method on a local), the exclusion clause names a category the body still flags: a guarded method-on-local call is flagged even though the prose lists method-on-local calls as excluded. Read the body's actual branch condition, then state the exclusion on that same axis (`plain (unguarded) calls inside a branch body are not dispatch steps`), so every member the prose excludes is a member the body also excludes.
|
|
@@ -8,6 +8,14 @@ A package directory that documents its own files in a `README.md` Layout table o
|
|
|
8
8
|
|
|
9
9
|
When you create a new production file in such a directory, add an entry naming it — a row in the `README.md` table, a bullet in the `CLAUDE.md` list — in the same change. The entry names the file in backticks and says what it does.
|
|
10
10
|
|
|
11
|
+
## Companion: keep the Purpose/scope sentence in step with the new responsibility
|
|
12
|
+
|
|
13
|
+
The file-list entry is the deterministic slice the gate enforces. A package inventory also carries a free-prose scope sentence — a `## Purpose` paragraph in a `CLAUDE.md`, a one-line summary the parent directory's inventory gives each subdirectory — that names the responsibilities the package's modules cover. When the new module adds a responsibility the scope sentence omits, the same change broadens that sentence to name it, and updates the parent inventory's one-line summary of this subdirectory to match.
|
|
14
|
+
|
|
15
|
+
Take a `files/` package whose `Purpose` reads "Holds helpers for downloading files over HTTP and extracting zip archives" and whose parent summary reads "file download, extraction, and path config helpers". Once a `force_remove.py` module that removes a directory tree sits beside the download helpers, both sentences name a narrower responsibility set than the directory holds. The required file-list bullet alone leaves that gap open. Broaden the `Purpose` sentence to name directory removal, and broaden the parent summary to match, in the same change that adds the module and its bullet.
|
|
16
|
+
|
|
17
|
+
This scope-sentence slice is free prose: a hook cannot derive a module's responsibility from its filename, so the gate leaves it to judgment. It is the judgment companion to the file-list entry the gate enforces, and it belongs in the same change. This is the `category-o-docstring-vs-impl-drift` (O8) orphaned-doc-claim shape applied to a package inventory: a behavior change orphans a scope claim the prose still makes.
|
|
18
|
+
|
|
11
19
|
## What the gate checks
|
|
12
20
|
|
|
13
21
|
The `package_inventory_stale_blocker.py` hook runs on every Write whose target is a new file (a path not yet on disk). It:
|
|
@@ -5,3 +5,5 @@ Never call `shutil.rmtree` with `ignore_errors=True` — Windows `ReadOnly` file
|
|
|
5
5
|
In Node, call `mkdirSync(targetPath, { recursive: true })` on possibly-existing paths — `ReadOnly` directories break the non-recursive form. When the call must be non-recursive, strip the attribute first (`(Get-Item $path -Force).Attributes = "Directory"` / `os.chmod(path, stat.S_IWRITE)`).
|
|
6
6
|
|
|
7
7
|
The `windows_rmtree_blocker.py` PreToolUse hook (Write/Edit/Bash) blocks the unsafe rmtree pattern and returns the full `force_rmtree` safe-pattern code.
|
|
8
|
+
|
|
9
|
+
Define the safe handler trio (`_strip_read_only_and_retry`, `_force_remove_tree` / `force_rmtree`, and the `inspect.signature` onexc/onerror guard) once in a shared Windows-filesystem utility module, and import it from every call site. A second local copy drifts from the first — a fix lands in one and the other keeps the bug (CODE_RULES.md section 3, Reuse before create). The `duplicate_rmtree_helper_blocker.py` PreToolUse hook (Write/Edit) blocks a local re-definition of any trio member outside the shared home and points the writer at the import. This complements the same-directory `check_duplicate_function_body_across_files` gate, which a copy between two distant packages slips past.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
**Trigger:** `/anthropic-plan`, `/plan`, "plan this first", "think before coding", "make a plan", "scope this out", "don't code yet", and non-trivial requests that need source-grounded design before build work.
|
|
4
4
|
|
|
5
|
-
Creates a repo-local plan packet under `docs/plans/<slug>/` by running the `plan-packet.mjs` workflow. The packet holds context, spec, implementation steps, validation, and a handoff prompt for the build agent. The skill stops before any production code changes.
|
|
5
|
+
Creates a repo-local plan packet under `docs/plans/<slug>/` by running the `plan-packet.mjs` workflow. The skill first drafts a short starting plan and gets the user's approval in plan mode (`EnterPlanMode` / `ExitPlanMode`); on approval it runs the workflow. The packet holds context, spec, implementation steps, validation, and a handoff prompt for the build agent. The skill stops before any production code changes.
|
|
6
6
|
|
|
7
7
|
## Subdirectories
|
|
8
8
|
|
|
@@ -5,9 +5,20 @@ description: Workflow-backed implementation planning that creates a deep repo-lo
|
|
|
5
5
|
|
|
6
6
|
# Anthropic Plan
|
|
7
7
|
|
|
8
|
-
Create a source-grounded plan packet through the Claude Code Workflow runtime.
|
|
8
|
+
Create a source-grounded plan packet through the Claude Code Workflow runtime. First draft a short starting plan and get it approved in plan mode; then the workflow builds the full repo-local `docs/plans/<slug>/` packet — context, spec, implementation, validation, and handoff docs. Stop before implementation.
|
|
9
9
|
|
|
10
|
-
##
|
|
10
|
+
## Plan first (plan mode)
|
|
11
|
+
|
|
12
|
+
Before any worktree or workflow, build a short starting plan in plan mode and get the user's approval. This is a cheap gate: it confirms the direction before the workflow spends tokens building the full packet.
|
|
13
|
+
|
|
14
|
+
1. Enter plan mode with `EnterPlanMode` if the session is not already in it.
|
|
15
|
+
2. Read the handful of files closest to the request — the entry points, the modules the change touches, the tests that cover them — enough to ground the approach in real source.
|
|
16
|
+
3. Write a short plan: the goal in a sentence or two, the approach, the files the build will touch, and the main risks or open choices. Use `AskUserQuestion` for any product choice that local context cannot settle.
|
|
17
|
+
4. Call `ExitPlanMode` to hand the plan to the user for approval.
|
|
18
|
+
|
|
19
|
+
On approval, fold the approved scope and direction into the `task` payload at launch, then continue with the steps below. If the user revises the plan, fold the changes in and present it again. Do not isolate a worktree or launch the workflow until the starting plan is approved.
|
|
20
|
+
|
|
21
|
+
## Isolate the session
|
|
11
22
|
|
|
12
23
|
The workflow's background subagents write the packet into the working tree. A background session that has not isolated into a worktree cannot write a shared checkout — the background-isolation guard rejects the write. So put the session in a worktree before launching the workflow:
|
|
13
24
|
|
|
@@ -27,6 +38,8 @@ Workflow({
|
|
|
27
38
|
})
|
|
28
39
|
```
|
|
29
40
|
|
|
41
|
+
The `task` string carries the user request together with the approved starting plan, so the packet reflects the direction the user signed off on.
|
|
42
|
+
|
|
30
43
|
If the Workflow tool is unavailable, say `anthropic-plan requires the Workflow tool; aborting` and stop.
|
|
31
44
|
|
|
32
45
|
## Self-healing writes
|
|
@@ -266,9 +266,12 @@ describes the narrowest rm auto-allow path — a standalone Bash call whose targ
|
|
|
266
266
|
resolves inside the ephemeral namespace (`/tmp`, `/temp`, the OS temp root, or the
|
|
267
267
|
run worktree) — and a compound path that accepts an rm joined with benign
|
|
268
268
|
reporting segments when every rm target is an absolute ephemeral path. Both of
|
|
269
|
-
those paths fail closed on `$(...)` substitution
|
|
270
|
-
|
|
271
|
-
|
|
269
|
+
those paths fail closed on `$(...)` substitution and backtick subshells. The
|
|
270
|
+
compound path also fails closed on any `$` in the target — including
|
|
271
|
+
`$CLAUDE_JOB_DIR`. The standalone path declines a `$`-bearing target only when
|
|
272
|
+
the literal path is not already under an ephemeral root, so it does not by
|
|
273
|
+
itself stop a `$VAR` that expands inside an ephemeral root. A third, broad path
|
|
274
|
+
matches only when the command itself declares an
|
|
272
275
|
ephemeral working directory (it `cd`s into one, or runs under one): that
|
|
273
276
|
cwd-scoped path resolves the target against the declared cwd, fails closed on
|
|
274
277
|
`$(...)`, backticks, and unknown variables, and resolves the known temporary
|
|
@@ -31,6 +31,13 @@ skill still runs teardown (revoke permissions, final report).
|
|
|
31
31
|
cannot confirm the PR left draft state (`gh pr ready` errored, or the draft
|
|
32
32
|
re-query still reports true). The workflow does not report `converged: true`;
|
|
33
33
|
the run ends with a `blocker` naming the failed ready transition.
|
|
34
|
+
- **Clean-audit post blocked** — every review lens is clean on HEAD, but the
|
|
35
|
+
CLEAN bugteam review cannot be posted (the `post_audit_thread.py` post is
|
|
36
|
+
denied, errors, or its agent dies). The convergence gate's bugteam-review
|
|
37
|
+
check can never pass without that CLEAN review, so the run stops rather than
|
|
38
|
+
re-converge to the iteration cap. The `blocker` names the post failure and the
|
|
39
|
+
HEAD. Unblock by allowing `post_audit_thread.py` with a Bash permission rule,
|
|
40
|
+
or post the CLEAN review by hand, then re-run.
|
|
34
41
|
|
|
35
42
|
## Not a blocker (the run continues)
|
|
36
43
|
|
|
@@ -40,8 +40,9 @@ test('cleanAuditBlocker falls back to a no-result reason when the post agent die
|
|
|
40
40
|
assert.match(message, /the post agent returned no result/);
|
|
41
41
|
});
|
|
42
42
|
|
|
43
|
-
test('
|
|
44
|
-
const body = functionBody('
|
|
43
|
+
test('the post-clean-audit task in resumeGeneralUtilityAgent returns the CLEAN_AUDIT_SCHEMA result rather than an unused transcript', () => {
|
|
44
|
+
const body = functionBody('resumeGeneralUtilityAgent');
|
|
45
|
+
assert.match(body, /task === 'post-clean-audit'/);
|
|
45
46
|
assert.match(body, /schema: CLEAN_AUDIT_SCHEMA/);
|
|
46
47
|
assert.doesNotMatch(body, /agent transcript \(unused\)/);
|
|
47
48
|
});
|
|
@@ -58,7 +59,7 @@ test('the standards-only call site breaks with a clean-audit blocker when the po
|
|
|
58
59
|
convergeSource.indexOf('if (isStandardsOnlyRound(findings)) {'),
|
|
59
60
|
convergeSource.indexOf('if (findings.length > 0) {'),
|
|
60
61
|
);
|
|
61
|
-
assert.match(branch, /
|
|
62
|
+
assert.match(branch, /resumeGeneralUtilityAgent\(.*'post-clean-audit'/);
|
|
62
63
|
assert.match(branch, /if \(!auditResult\?\.posted\)/);
|
|
63
64
|
assert.match(branch, /blocker = cleanAuditBlocker\(head, auditResult\)/);
|
|
64
65
|
assert.match(branch, /\bbreak\b/);
|
|
@@ -69,7 +70,7 @@ test('the all-clean call site breaks with a clean-audit blocker when the post do
|
|
|
69
70
|
convergeSource.indexOf('all lenses clean on'),
|
|
70
71
|
convergeSource.indexOf("if (phase === 'COPILOT') {"),
|
|
71
72
|
);
|
|
72
|
-
assert.match(branch, /
|
|
73
|
+
assert.match(branch, /resumeGeneralUtilityAgent\(.*'post-clean-audit'/);
|
|
73
74
|
assert.match(branch, /if \(!auditResult\?\.posted\)/);
|
|
74
75
|
assert.match(branch, /blocker = cleanAuditBlocker\(head, auditResult\)/);
|
|
75
76
|
assert.match(branch, /\bbreak\b/);
|