claude-dev-env 1.44.0 → 1.46.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 +9 -0
- package/_shared/pr-loop/scripts/code_rules_gate.py +426 -85
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +20 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviews_disabled_constants.py +1 -0
- package/_shared/pr-loop/scripts/reviews_disabled.py +82 -9
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +630 -21
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate_constants.py +15 -0
- package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +57 -0
- package/agents/clean-coder.md +7 -1
- package/agents/code-quality-agent.md +8 -5
- package/hooks/blocking/code_rules_enforcer.py +1562 -37
- package/hooks/blocking/content_search_zoekt_redirect_guidance.py +19 -0
- package/hooks/blocking/open_questions_in_plans_blocker.py +249 -0
- package/hooks/blocking/test_code_rules_enforcer.py +1389 -0
- package/hooks/blocking/test_code_rules_enforcer_banned_noun_word.py +292 -0
- package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +46 -8
- package/hooks/blocking/test_code_rules_enforcer_exempt_marker_chained.py +189 -0
- package/hooks/blocking/test_code_rules_enforcer_function_length.py +210 -0
- package/hooks/blocking/test_code_rules_enforcer_tests_isolate_home_temp.py +1512 -0
- package/hooks/blocking/test_code_rules_enforcer_unused_imports.py +9 -5
- package/hooks/blocking/test_content_search_to_zoekt_redirector_unit.py +30 -0
- package/hooks/blocking/test_open_questions_in_plans_blocker.py +790 -0
- package/hooks/hooks.json +10 -0
- package/hooks/hooks_constants/banned_identifiers_constants.py +19 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +129 -2
- package/hooks/hooks_constants/open_questions_in_plans_blocker_constants.py +35 -0
- package/hooks/hooks_constants/test_open_questions_in_plans_blocker_constants.py +125 -0
- package/package.json +1 -1
- package/skills/_shared/pr-loop/scripts/_path_resolver.py +34 -13
- package/skills/_shared/pr-loop/scripts/init_loop_state.py +1 -2
- package/skills/_shared/pr-loop/scripts/teardown_worktrees.py +1 -4
- package/skills/_shared/pr-loop/scripts/test__path_resolver.py +57 -0
- package/skills/_shared/pr-loop/scripts/test_init_loop_state.py +48 -0
- package/skills/_shared/pr-loop/scripts/test_teardown_worktrees.py +59 -0
- package/skills/bugteam/PROMPTS.md +48 -12
- package/skills/bugteam/reference/team-setup.md +4 -2
- package/skills/bugteam/scripts/bugteam_code_rules_gate.py +487 -76
- package/skills/bugteam/scripts/bugteam_scripts_constants/bugteam_code_rules_gate_constants.py +22 -1
- package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +602 -12
- package/skills/pr-converge/SKILL.md +5 -0
- package/skills/pr-converge/reference/per-tick.md +14 -5
- package/skills/pr-converge/reference/state-schema.md +7 -3
- package/skills/pr-converge/scripts/check_convergence.py +27 -1
- package/skills/pr-converge/scripts/test_check_convergence.py +28 -0
package/hooks/hooks.json
CHANGED
|
@@ -62,6 +62,16 @@
|
|
|
62
62
|
}
|
|
63
63
|
]
|
|
64
64
|
},
|
|
65
|
+
{
|
|
66
|
+
"matcher": "Write|Edit|MultiEdit",
|
|
67
|
+
"hooks": [
|
|
68
|
+
{
|
|
69
|
+
"type": "command",
|
|
70
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/open_questions_in_plans_blocker.py",
|
|
71
|
+
"timeout": 10
|
|
72
|
+
}
|
|
73
|
+
]
|
|
74
|
+
},
|
|
65
75
|
{
|
|
66
76
|
"matcher": "Edit",
|
|
67
77
|
"hooks": [
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
"""Configuration constants for the banned-identifier check in code_rules_enforcer."""
|
|
2
2
|
|
|
3
|
+
import re
|
|
4
|
+
|
|
3
5
|
ALL_BANNED_IDENTIFIERS: frozenset[str] = frozenset(
|
|
4
6
|
{
|
|
5
7
|
"result",
|
|
@@ -26,10 +28,27 @@ ALL_BANNED_IDENTIFIERS: frozenset[str] = frozenset(
|
|
|
26
28
|
"val",
|
|
27
29
|
}
|
|
28
30
|
)
|
|
31
|
+
ALL_BANNED_NOUN_WORDS: frozenset[str] = frozenset(
|
|
32
|
+
{
|
|
33
|
+
"result", "results",
|
|
34
|
+
"data",
|
|
35
|
+
"output", "outputs",
|
|
36
|
+
"response", "responses",
|
|
37
|
+
"value", "values",
|
|
38
|
+
"item", "items",
|
|
39
|
+
"temp",
|
|
40
|
+
}
|
|
41
|
+
)
|
|
42
|
+
CAMEL_CASE_WORD_PATTERN: re.Pattern[str] = re.compile(
|
|
43
|
+
r"[A-Z]+(?=[A-Z][a-z])|[A-Z]?[a-z]+|[A-Z]+"
|
|
44
|
+
)
|
|
29
45
|
MAX_BANNED_IDENTIFIER_ISSUES: int = 3
|
|
30
46
|
BANNED_IDENTIFIER_MESSAGE_SUFFIX: str = (
|
|
31
47
|
"use descriptive name (see CODE_RULES Naming section)"
|
|
32
48
|
)
|
|
49
|
+
BANNED_NOUN_WORD_MESSAGE_SUFFIX: str = (
|
|
50
|
+
"contains banned noun word - rename to a domain-specific term (see CODE_RULES §5)"
|
|
51
|
+
)
|
|
33
52
|
BANNED_IDENTIFIER_SKIP_ADVISORY: str = (
|
|
34
53
|
"banned-identifier check skipped: file did not parse as Python"
|
|
35
54
|
)
|
|
@@ -30,16 +30,20 @@ UPPER_SNAKE_CONSTANT_PATTERN = re.compile(r"^[A-Z][A-Z0-9_]*$")
|
|
|
30
30
|
|
|
31
31
|
TYPE_CHECKING_BLOCK_PATTERN = re.compile(r"^(?P<indent>\s*)if\s+(typing\.)?TYPE_CHECKING\s*:\s*$")
|
|
32
32
|
ALL_IMPORT_STATEMENT_PREFIXES: tuple[str, ...] = ("import ", "from ")
|
|
33
|
-
|
|
34
|
-
"type:",
|
|
33
|
+
ALL_TOKEN_ANCHORED_EXEMPT_COMMENT_BODIES: tuple[str, ...] = (
|
|
35
34
|
"noqa",
|
|
36
35
|
"pylint:",
|
|
37
36
|
"pragma:",
|
|
37
|
+
)
|
|
38
|
+
ALL_TOKEN_ANCHORED_DIRECTIVE_BOUNDARY_CHARACTERS: frozenset[str] = frozenset({":"})
|
|
39
|
+
ALL_FREE_FORM_EXEMPT_COMMENT_BODIES: tuple[str, ...] = (
|
|
40
|
+
"type:",
|
|
38
41
|
"TODO",
|
|
39
42
|
"FIXME",
|
|
40
43
|
"HACK",
|
|
41
44
|
"XXX",
|
|
42
45
|
)
|
|
46
|
+
CHAINED_INLINE_COMMENT_PATTERN = re.compile(r"#")
|
|
43
47
|
ALL_JAVASCRIPT_EXEMPT_COMMENT_PREFIXES: tuple[str, ...] = (
|
|
44
48
|
"// @ts-",
|
|
45
49
|
"// eslint-",
|
|
@@ -88,3 +92,126 @@ BARE_EACH_TOKEN = "each"
|
|
|
88
92
|
INLINE_COLLECTION_MIN_LENGTH = 3
|
|
89
93
|
ALL_CAPS_WITH_UNDERSCORE_PATTERN = re.compile(r"^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+$")
|
|
90
94
|
DOTTED_SEGMENT_PATTERN = re.compile(r"^\.[a-z][a-z0-9_]*$")
|
|
95
|
+
|
|
96
|
+
ALL_DIFF_CHANGED_OPCODE_TAGS: tuple[str, str] = ("replace", "insert")
|
|
97
|
+
|
|
98
|
+
FUNCTION_LENGTH_BLOCKING_THRESHOLD: int = 60
|
|
99
|
+
FUNCTION_LENGTH_BLOCKING_MESSAGE_SUFFIX: str = (
|
|
100
|
+
"exceeds blocking threshold - split into helpers (small functions: Robert C. "
|
|
101
|
+
"Martin, Clean Code Ch. 3 'Functions'; Google Python Style Guide ~40-line "
|
|
102
|
+
"function review hint)"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
BANNED_NOUN_SPAN_FRAGMENT_TEMPLATE: str = (
|
|
106
|
+
"(binding span at line {definition_line}, spanning {line_span} lines)"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
ALL_PYTEST_FILESYSTEM_ISOLATION_FIXTURE_NAMES: frozenset[str] = frozenset({
|
|
110
|
+
"monkeypatch",
|
|
111
|
+
})
|
|
112
|
+
PYTEST_USEFIXTURES_MARKER_NAME: str = "usefixtures"
|
|
113
|
+
PYTEST_TEST_CLASS_NAME_PREFIX: str = "Test"
|
|
114
|
+
ALL_HOME_DIRECTORY_ENV_VAR_NAMES: frozenset[str] = frozenset({
|
|
115
|
+
"HOME",
|
|
116
|
+
"USERPROFILE",
|
|
117
|
+
"XDG_CONFIG_HOME",
|
|
118
|
+
"XDG_DATA_HOME",
|
|
119
|
+
"TMPDIR",
|
|
120
|
+
"TEMP",
|
|
121
|
+
"TMP",
|
|
122
|
+
})
|
|
123
|
+
ALL_FILESYSTEM_HOME_PROBE_DOTTED_NAMES: frozenset[str] = frozenset({
|
|
124
|
+
"Path.home",
|
|
125
|
+
"pathlib.Path.home",
|
|
126
|
+
"tempfile.gettempdir",
|
|
127
|
+
"tempfile.gettempdirb",
|
|
128
|
+
"tempfile.gettempprefix",
|
|
129
|
+
"tempfile.mkstemp",
|
|
130
|
+
"tempfile.mkdtemp",
|
|
131
|
+
"tempfile.mktemp",
|
|
132
|
+
"tempfile.NamedTemporaryFile",
|
|
133
|
+
"tempfile.TemporaryFile",
|
|
134
|
+
"tempfile.TemporaryDirectory",
|
|
135
|
+
"tempfile.SpooledTemporaryFile",
|
|
136
|
+
})
|
|
137
|
+
ALL_DIR_ACCEPTING_TEMPFILE_FACTORY_DOTTED_NAMES: frozenset[str] = frozenset({
|
|
138
|
+
"tempfile.mkstemp",
|
|
139
|
+
"tempfile.mkdtemp",
|
|
140
|
+
"tempfile.mktemp",
|
|
141
|
+
"tempfile.NamedTemporaryFile",
|
|
142
|
+
"tempfile.TemporaryFile",
|
|
143
|
+
"tempfile.TemporaryDirectory",
|
|
144
|
+
"tempfile.SpooledTemporaryFile",
|
|
145
|
+
})
|
|
146
|
+
TEMPFILE_FACTORY_ISOLATION_DIRECTORY_KEYWORD: str = "dir"
|
|
147
|
+
ALL_SHARED_TEMP_SOURCE_PROBE_DOTTED_NAMES: frozenset[str] = frozenset({
|
|
148
|
+
"tempfile.gettempdir",
|
|
149
|
+
"tempfile.gettempdirb",
|
|
150
|
+
"tempfile.gettempprefix",
|
|
151
|
+
})
|
|
152
|
+
EXPANDVARS_DOTTED_NAME: str = "os.path.expandvars"
|
|
153
|
+
EXPANDUSER_DOTTED_NAME: str = "os.path.expanduser"
|
|
154
|
+
ALL_PATHLIB_STATIC_EXPANDUSER_DOTTED_NAMES: frozenset[str] = frozenset({
|
|
155
|
+
"Path.expanduser",
|
|
156
|
+
"pathlib.Path.expanduser",
|
|
157
|
+
})
|
|
158
|
+
PATHLIB_EXPANDUSER_METHOD_NAME: str = "expanduser"
|
|
159
|
+
ALL_PATHLIB_PATH_CONSTRUCTOR_CANONICAL_NAMES: frozenset[str] = frozenset({
|
|
160
|
+
"Path",
|
|
161
|
+
"pathlib.Path",
|
|
162
|
+
})
|
|
163
|
+
ALL_PROBE_ALIASABLE_CANONICAL_PREFIXES: frozenset[str] = frozenset({
|
|
164
|
+
"os",
|
|
165
|
+
"os.path",
|
|
166
|
+
"os.environ",
|
|
167
|
+
"os.getenv",
|
|
168
|
+
"pathlib",
|
|
169
|
+
"pathlib.Path",
|
|
170
|
+
"Path",
|
|
171
|
+
"tempfile",
|
|
172
|
+
})
|
|
173
|
+
HOME_DIRECTORY_TILDE_PREFIX: str = "~"
|
|
174
|
+
ENVIRONMENT_VARIABLE_REFERENCE_PATTERN: re.Pattern[str] = re.compile(
|
|
175
|
+
r"\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?"
|
|
176
|
+
)
|
|
177
|
+
WINDOWS_PERCENT_VARIABLE_REFERENCE_PATTERN: re.Pattern[str] = re.compile(
|
|
178
|
+
r"%([A-Za-z_][A-Za-z0-9_]*)%"
|
|
179
|
+
)
|
|
180
|
+
OS_GETENV_DOTTED_NAME: str = "os.getenv"
|
|
181
|
+
OS_ENVIRON_GET_DOTTED_NAME: str = "os.environ.get"
|
|
182
|
+
OS_ENVIRON_DOTTED_NAME: str = "os.environ"
|
|
183
|
+
ENVIRON_GET_METHOD_NAME: str = "get"
|
|
184
|
+
ALL_ENVIRONMENT_GETTER_DOTTED_NAMES: frozenset[str] = frozenset({
|
|
185
|
+
OS_GETENV_DOTTED_NAME,
|
|
186
|
+
OS_ENVIRON_GET_DOTTED_NAME,
|
|
187
|
+
})
|
|
188
|
+
ALL_PROBE_RELEVANT_MODULE_CANONICAL_NAMES: frozenset[str] = frozenset({
|
|
189
|
+
"os",
|
|
190
|
+
"os.path",
|
|
191
|
+
"pathlib",
|
|
192
|
+
"tempfile",
|
|
193
|
+
})
|
|
194
|
+
ALL_CANONICAL_DOTTED_NAMES_BY_BARE_IMPORT: dict[tuple[str, str], str] = {
|
|
195
|
+
("os.path", "expanduser"): "os.path.expanduser",
|
|
196
|
+
("os.path", "expandvars"): "os.path.expandvars",
|
|
197
|
+
("os", "path"): "os.path",
|
|
198
|
+
("os", "getenv"): "os.getenv",
|
|
199
|
+
("os", "environ"): "os.environ",
|
|
200
|
+
("tempfile", "gettempdir"): "tempfile.gettempdir",
|
|
201
|
+
("tempfile", "gettempdirb"): "tempfile.gettempdirb",
|
|
202
|
+
("tempfile", "gettempprefix"): "tempfile.gettempprefix",
|
|
203
|
+
("tempfile", "mkstemp"): "tempfile.mkstemp",
|
|
204
|
+
("tempfile", "mkdtemp"): "tempfile.mkdtemp",
|
|
205
|
+
("tempfile", "mktemp"): "tempfile.mktemp",
|
|
206
|
+
("tempfile", "NamedTemporaryFile"): "tempfile.NamedTemporaryFile",
|
|
207
|
+
("tempfile", "TemporaryFile"): "tempfile.TemporaryFile",
|
|
208
|
+
("tempfile", "TemporaryDirectory"): "tempfile.TemporaryDirectory",
|
|
209
|
+
("tempfile", "SpooledTemporaryFile"): "tempfile.SpooledTemporaryFile",
|
|
210
|
+
("pathlib", "Path"): "Path",
|
|
211
|
+
}
|
|
212
|
+
TEST_ISOLATION_MESSAGE_SUFFIX: str = (
|
|
213
|
+
"must take a monkeypatch fixture and route HOME/TMP env reads through "
|
|
214
|
+
"monkeypatch.setenv; tmp_path / tmpdir allocate a sandbox path but do "
|
|
215
|
+
"not intercept env reads, so they leak across the suite (CODE_RULES — "
|
|
216
|
+
"see audits 2026-05-22 Theme M)"
|
|
217
|
+
)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Configuration constants for the open_questions_in_plans_blocker PreToolUse hook."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from re import IGNORECASE, MULTILINE, Pattern, compile
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
MARKDOWN_EXTENSION: str = ".md"
|
|
9
|
+
|
|
10
|
+
PLANS_PATH_SEGMENT: str = "/.claude/plans/"
|
|
11
|
+
PLANS_PATH_PREFIX: str = ".claude/plans/"
|
|
12
|
+
|
|
13
|
+
PLAN_FILE_ENCODING: str = "utf-8"
|
|
14
|
+
|
|
15
|
+
UNREADABLE_FILE_SYNTHETIC_CONTENT: str = "## Open Questions\n"
|
|
16
|
+
|
|
17
|
+
OPEN_QUESTIONS_HEADING_PATTERN: Pattern[str] = compile(
|
|
18
|
+
r"^\s*(?:#{1,6}\s+|\*\*\s*|__\s*)open[\s_-]+questions(?:[^A-Za-z0-9]|$)",
|
|
19
|
+
IGNORECASE | MULTILINE,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
CODE_FENCE_PATTERN: Pattern[str] = compile(r"```[\s\S]*?```")
|
|
23
|
+
INLINE_CODE_PATTERN: Pattern[str] = compile(r"``[^`\n]+``|`[^`\n]+`")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"CODE_FENCE_PATTERN",
|
|
28
|
+
"INLINE_CODE_PATTERN",
|
|
29
|
+
"MARKDOWN_EXTENSION",
|
|
30
|
+
"OPEN_QUESTIONS_HEADING_PATTERN",
|
|
31
|
+
"PLANS_PATH_PREFIX",
|
|
32
|
+
"PLANS_PATH_SEGMENT",
|
|
33
|
+
"PLAN_FILE_ENCODING",
|
|
34
|
+
"UNREADABLE_FILE_SYNTHETIC_CONTENT",
|
|
35
|
+
]
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Behavior tests for open_questions_in_plans_blocker_constants module."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
_HOOKS_ROOT = Path(__file__).resolve().parent.parent
|
|
9
|
+
if str(_HOOKS_ROOT) not in sys.path:
|
|
10
|
+
sys.path.insert(0, str(_HOOKS_ROOT))
|
|
11
|
+
|
|
12
|
+
from hooks_constants import open_questions_in_plans_blocker_constants as constants_module
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_markdown_extension_is_lowercase_dot_md() -> None:
|
|
16
|
+
assert constants_module.MARKDOWN_EXTENSION == ".md"
|
|
17
|
+
assert "MARKDOWN_EXTENSION" in constants_module.__all__
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_plans_path_segment_matches_nested_plans_directory() -> None:
|
|
21
|
+
assert constants_module.PLANS_PATH_SEGMENT == "/.claude/plans/"
|
|
22
|
+
assert "PLANS_PATH_SEGMENT" in constants_module.__all__
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_plans_path_prefix_matches_project_local_plans_directory() -> None:
|
|
26
|
+
assert constants_module.PLANS_PATH_PREFIX == ".claude/plans/"
|
|
27
|
+
assert "PLANS_PATH_PREFIX" in constants_module.__all__
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_open_questions_heading_pattern_matches_atx_heading() -> None:
|
|
31
|
+
assert constants_module.OPEN_QUESTIONS_HEADING_PATTERN.search("## Open Questions\n")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_open_questions_heading_pattern_matches_bold_heading() -> None:
|
|
35
|
+
assert constants_module.OPEN_QUESTIONS_HEADING_PATTERN.search("**Open Questions**\n")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_open_questions_heading_pattern_matches_underscore_bold_heading() -> None:
|
|
39
|
+
assert constants_module.OPEN_QUESTIONS_HEADING_PATTERN.search("__Open Questions__\n")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_open_questions_heading_pattern_is_case_insensitive() -> None:
|
|
43
|
+
assert constants_module.OPEN_QUESTIONS_HEADING_PATTERN.search("# open questions\n")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_open_questions_heading_pattern_does_not_match_concatenated_word() -> None:
|
|
47
|
+
assert constants_module.OPEN_QUESTIONS_HEADING_PATTERN.search("## OpenQuestions\n") is None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_open_questions_heading_pattern_does_not_match_longer_word() -> None:
|
|
51
|
+
assert constants_module.OPEN_QUESTIONS_HEADING_PATTERN.search("## Open Questionable\n") is None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_open_questions_heading_pattern_in_export_list() -> None:
|
|
55
|
+
assert "OPEN_QUESTIONS_HEADING_PATTERN" in constants_module.__all__
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_code_fence_pattern_matches_triple_backtick_block() -> None:
|
|
59
|
+
fenced_block_sample = "```markdown\n## Open Questions\n- placeholder\n```"
|
|
60
|
+
assert constants_module.CODE_FENCE_PATTERN.fullmatch(fenced_block_sample)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_code_fence_pattern_is_non_greedy_across_two_blocks() -> None:
|
|
64
|
+
two_blocks_sample = "```first```\n\n```second```"
|
|
65
|
+
all_matches = constants_module.CODE_FENCE_PATTERN.findall(two_blocks_sample)
|
|
66
|
+
assert all_matches == ["```first```", "```second```"]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_code_fence_pattern_in_export_list() -> None:
|
|
70
|
+
assert "CODE_FENCE_PATTERN" in constants_module.__all__
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_inline_code_pattern_matches_single_backtick_span() -> None:
|
|
74
|
+
assert constants_module.INLINE_CODE_PATTERN.fullmatch("`code`")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_inline_code_pattern_matches_double_backtick_span() -> None:
|
|
78
|
+
assert constants_module.INLINE_CODE_PATTERN.fullmatch("``double tick span``")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_inline_code_pattern_does_not_cross_newlines() -> None:
|
|
82
|
+
"""CommonMark inline-code spans cannot cross newlines. A stray opening backtick
|
|
83
|
+
followed later by another backtick must NOT be treated as a single inline-code
|
|
84
|
+
span — otherwise the inline-code stripper deletes everything between the two
|
|
85
|
+
backticks, including any text that lives on intervening lines."""
|
|
86
|
+
stray_backtick_sample = "stray `here.\n\nreal heading.\n\nmore `code`"
|
|
87
|
+
all_matches = constants_module.INLINE_CODE_PATTERN.findall(stray_backtick_sample)
|
|
88
|
+
assert all_matches == ["`code`"]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_inline_code_pattern_does_not_match_span_spanning_newlines() -> None:
|
|
92
|
+
multiline_span_sample = "`opens here\nbut never closes on the same line`"
|
|
93
|
+
assert constants_module.INLINE_CODE_PATTERN.search(multiline_span_sample) is None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_inline_code_pattern_in_export_list() -> None:
|
|
97
|
+
assert "INLINE_CODE_PATTERN" in constants_module.__all__
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_plan_file_encoding_is_utf8() -> None:
|
|
101
|
+
assert constants_module.PLAN_FILE_ENCODING == "utf-8"
|
|
102
|
+
assert "PLAN_FILE_ENCODING" in constants_module.__all__
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_unreadable_file_synthetic_content_triggers_heading_pattern() -> None:
|
|
106
|
+
"""The synthetic content used when an existing plan file cannot be read must
|
|
107
|
+
contain a heading the open-questions regex matches. This guarantees the
|
|
108
|
+
downstream scan denies the write rather than silently passing."""
|
|
109
|
+
synthetic = constants_module.UNREADABLE_FILE_SYNTHETIC_CONTENT
|
|
110
|
+
assert constants_module.OPEN_QUESTIONS_HEADING_PATTERN.search(synthetic)
|
|
111
|
+
assert "UNREADABLE_FILE_SYNTHETIC_CONTENT" in constants_module.__all__
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_all_exports_enumerates_eight_public_constants_in_sorted_order() -> None:
|
|
115
|
+
expected_exports = [
|
|
116
|
+
"CODE_FENCE_PATTERN",
|
|
117
|
+
"INLINE_CODE_PATTERN",
|
|
118
|
+
"MARKDOWN_EXTENSION",
|
|
119
|
+
"OPEN_QUESTIONS_HEADING_PATTERN",
|
|
120
|
+
"PLANS_PATH_PREFIX",
|
|
121
|
+
"PLANS_PATH_SEGMENT",
|
|
122
|
+
"PLAN_FILE_ENCODING",
|
|
123
|
+
"UNREADABLE_FILE_SYNTHETIC_CONTENT",
|
|
124
|
+
]
|
|
125
|
+
assert constants_module.__all__ == expected_exports
|
package/package.json
CHANGED
|
@@ -7,6 +7,7 @@ file is edited. Pure functions with no side effects.
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
9
|
import tempfile
|
|
10
|
+
from dataclasses import dataclass
|
|
10
11
|
from pathlib import Path
|
|
11
12
|
|
|
12
13
|
from skills_pr_loop_constants.path_resolver_constants import (
|
|
@@ -23,6 +24,27 @@ from skills_pr_loop_constants.path_resolver_constants import (
|
|
|
23
24
|
)
|
|
24
25
|
|
|
25
26
|
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class PerPrWorkspace:
|
|
29
|
+
"""Resolved per-PR workspace paths for a pr-loop run.
|
|
30
|
+
|
|
31
|
+
Attributes:
|
|
32
|
+
worktree: Absolute path to the git worktree checkout.
|
|
33
|
+
diff_patch_template: A ``str.format`` template carrying a ``{loop}``
|
|
34
|
+
placeholder for the per-loop diff/patch file path.
|
|
35
|
+
outcome_xml_template: A ``str.format`` template carrying ``{number}``
|
|
36
|
+
and ``{loop}`` placeholders for the AUDIT outcome XML filename.
|
|
37
|
+
fix_outcome_xml_template: A ``str.format`` template carrying
|
|
38
|
+
``{number}`` and ``{loop}`` placeholders for the FIX outcome XML
|
|
39
|
+
filename.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
worktree: Path
|
|
43
|
+
diff_patch_template: str
|
|
44
|
+
outcome_xml_template: str
|
|
45
|
+
fix_outcome_xml_template: str
|
|
46
|
+
|
|
47
|
+
|
|
26
48
|
def sanitize_branch_name(head_branch: str) -> str:
|
|
27
49
|
"""Replace filesystem-unsafe characters in a git branch name.
|
|
28
50
|
|
|
@@ -83,8 +105,8 @@ def slugify_pr_identity(owner: str, repo: str, pr_number: int) -> str:
|
|
|
83
105
|
|
|
84
106
|
def per_pr_workspace(
|
|
85
107
|
run_temp_dir: Path, owner: str, repo: str, pr_number: int
|
|
86
|
-
) ->
|
|
87
|
-
"""Build the per-PR workspace paths
|
|
108
|
+
) -> PerPrWorkspace:
|
|
109
|
+
"""Build the per-PR workspace paths.
|
|
88
110
|
|
|
89
111
|
Args:
|
|
90
112
|
run_temp_dir: Run temp directory (from resolve_run_temp_dir).
|
|
@@ -93,20 +115,19 @@ def per_pr_workspace(
|
|
|
93
115
|
pr_number: Pull request number.
|
|
94
116
|
|
|
95
117
|
Returns:
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
- fix_outcome_xml_template: str template with {number} and {loop} placeholders
|
|
118
|
+
A PerPrWorkspace whose ``worktree`` is the git worktree checkout Path
|
|
119
|
+
and whose ``diff_patch_template`` / ``outcome_xml_template`` /
|
|
120
|
+
``fix_outcome_xml_template`` are ``str.format`` templates carrying
|
|
121
|
+
``{loop}`` (and, for the XML templates, ``{number}``) placeholders.
|
|
101
122
|
"""
|
|
102
123
|
pr_workspace_dir = run_temp_dir / PER_PR_WORKSPACE_TEMPLATE.format(number=pr_number)
|
|
103
124
|
slug = slugify_pr_identity(owner, repo, pr_number)
|
|
104
|
-
return
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
125
|
+
return PerPrWorkspace(
|
|
126
|
+
worktree=pr_workspace_dir / WORKTREE_DIRNAME,
|
|
127
|
+
diff_patch_template=str(pr_workspace_dir / slug / DIFF_PATCH_TEMPLATE),
|
|
128
|
+
outcome_xml_template=OUTCOME_XML_TEMPLATE,
|
|
129
|
+
fix_outcome_xml_template=FIX_OUTCOME_XML_TEMPLATE,
|
|
130
|
+
)
|
|
110
131
|
|
|
111
132
|
|
|
112
133
|
def outcome_xml_path(worktree_path: Path, pr_number: int, loop_number: int) -> Path:
|
|
@@ -60,8 +60,7 @@ def create_loop_state(
|
|
|
60
60
|
run_name = build_run_name(pr_number, head_ref, is_multi_pr=is_multi_pr)
|
|
61
61
|
run_temp_dir = resolve_run_temp_dir(run_name)
|
|
62
62
|
workspace = per_pr_workspace(run_temp_dir, "", "", pr_number)
|
|
63
|
-
worktree_path = workspace
|
|
64
|
-
assert isinstance(worktree_path, Path)
|
|
63
|
+
worktree_path = workspace.worktree
|
|
65
64
|
|
|
66
65
|
worktree_path.mkdir(parents=True, exist_ok=True)
|
|
67
66
|
state_path = worktree_path / "loop-state.json"
|
|
@@ -118,10 +118,7 @@ def teardown_run(
|
|
|
118
118
|
if not isinstance(pr_number, int):
|
|
119
119
|
continue
|
|
120
120
|
workspace = per_pr_workspace(run_temp_dir, str(owner), str(repo), pr_number)
|
|
121
|
-
|
|
122
|
-
if not isinstance(worktree_path, Path):
|
|
123
|
-
continue
|
|
124
|
-
if remove_worktree(worktree_path):
|
|
121
|
+
if remove_worktree(workspace.worktree):
|
|
125
122
|
removed_count += 1
|
|
126
123
|
|
|
127
124
|
force_rmtree(str(run_temp_dir))
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Tests for _path_resolver.per_pr_workspace typed structure."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import dataclasses
|
|
6
|
+
import importlib.util
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from types import ModuleType
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
_SCRIPTS_DIR = Path(__file__).resolve().parent
|
|
14
|
+
if str(_SCRIPTS_DIR) not in sys.path:
|
|
15
|
+
sys.path.insert(0, str(_SCRIPTS_DIR))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _load_path_resolver() -> ModuleType:
|
|
19
|
+
module_path = _SCRIPTS_DIR / "_path_resolver.py"
|
|
20
|
+
spec = importlib.util.spec_from_file_location("_path_resolver", module_path)
|
|
21
|
+
assert spec is not None
|
|
22
|
+
assert spec.loader is not None
|
|
23
|
+
module = importlib.util.module_from_spec(spec)
|
|
24
|
+
sys.modules["_path_resolver"] = module
|
|
25
|
+
spec.loader.exec_module(module)
|
|
26
|
+
return module
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
path_resolver = _load_path_resolver()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_per_pr_workspace_returns_typed_structure_with_concrete_fields() -> None:
|
|
33
|
+
run_temp_dir = Path("/tmp/bugteam-pr-422")
|
|
34
|
+
workspace = path_resolver.per_pr_workspace(
|
|
35
|
+
run_temp_dir, "jl-cmd", "claude-code-config", 422
|
|
36
|
+
)
|
|
37
|
+
assert isinstance(workspace, path_resolver.PerPrWorkspace)
|
|
38
|
+
assert isinstance(workspace.worktree, Path)
|
|
39
|
+
assert workspace.worktree == run_temp_dir / "pr-422" / "worktree"
|
|
40
|
+
assert isinstance(workspace.diff_patch_template, str)
|
|
41
|
+
assert isinstance(workspace.outcome_xml_template, str)
|
|
42
|
+
assert isinstance(workspace.fix_outcome_xml_template, str)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_per_pr_workspace_diff_patch_template_carries_loop_placeholder() -> None:
|
|
46
|
+
run_temp_dir = Path("/tmp/bugteam-pr-7")
|
|
47
|
+
workspace = path_resolver.per_pr_workspace(run_temp_dir, "owner", "repo", 7)
|
|
48
|
+
rendered = workspace.diff_patch_template.format(loop=3)
|
|
49
|
+
assert rendered.endswith("loop-3.patch")
|
|
50
|
+
assert "owner-repo-pr-7" in rendered.replace("\\", "/")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_per_pr_workspace_is_frozen() -> None:
|
|
54
|
+
run_temp_dir = Path("/tmp/bugteam-pr-9")
|
|
55
|
+
workspace = path_resolver.per_pr_workspace(run_temp_dir, "owner", "repo", 9)
|
|
56
|
+
with pytest.raises(dataclasses.FrozenInstanceError):
|
|
57
|
+
workspace.worktree = Path("/tmp/other")
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Tests for init_loop_state.create_loop_state consuming the typed workspace."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.util
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from types import ModuleType
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
_SCRIPTS_DIR = Path(__file__).resolve().parent
|
|
14
|
+
if str(_SCRIPTS_DIR) not in sys.path:
|
|
15
|
+
sys.path.insert(0, str(_SCRIPTS_DIR))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _load_init_loop_state_module() -> ModuleType:
|
|
19
|
+
module_path = _SCRIPTS_DIR / "init_loop_state.py"
|
|
20
|
+
spec = importlib.util.spec_from_file_location("init_loop_state", module_path)
|
|
21
|
+
assert spec is not None
|
|
22
|
+
assert spec.loader is not None
|
|
23
|
+
module = importlib.util.module_from_spec(spec)
|
|
24
|
+
spec.loader.exec_module(module)
|
|
25
|
+
return module
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
init_loop_state = _load_init_loop_state_module()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_create_loop_state_writes_state_under_typed_worktree(
|
|
32
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
33
|
+
) -> None:
|
|
34
|
+
"""create_loop_state resolves the worktree from PerPrWorkspace.worktree and
|
|
35
|
+
writes loop-state.json inside it, with the starting SHA recorded."""
|
|
36
|
+
path_resolver_module = init_loop_state.resolve_run_temp_dir.__globals__["tempfile"]
|
|
37
|
+
monkeypatch.setattr(path_resolver_module, "gettempdir", lambda: str(tmp_path))
|
|
38
|
+
state_path = init_loop_state.create_loop_state(
|
|
39
|
+
pr_number=422,
|
|
40
|
+
head_ref="feat/branch",
|
|
41
|
+
starting_sha="abc1234",
|
|
42
|
+
is_multi_pr=False,
|
|
43
|
+
)
|
|
44
|
+
assert state_path.name == "loop-state.json"
|
|
45
|
+
assert state_path.parent.name == "worktree"
|
|
46
|
+
written_state = json.loads(state_path.read_text(encoding="utf-8"))
|
|
47
|
+
assert written_state["starting_sha"] == "abc1234"
|
|
48
|
+
assert written_state["loop_count"] == 0
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Tests for teardown_worktrees.teardown_run consuming the typed workspace."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.util
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from types import ModuleType
|
|
9
|
+
|
|
10
|
+
_SCRIPTS_DIR = Path(__file__).resolve().parent
|
|
11
|
+
if str(_SCRIPTS_DIR) not in sys.path:
|
|
12
|
+
sys.path.insert(0, str(_SCRIPTS_DIR))
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _load_teardown_module() -> ModuleType:
|
|
16
|
+
module_path = _SCRIPTS_DIR / "teardown_worktrees.py"
|
|
17
|
+
spec = importlib.util.spec_from_file_location("teardown_worktrees", module_path)
|
|
18
|
+
assert spec is not None
|
|
19
|
+
assert spec.loader is not None
|
|
20
|
+
module = importlib.util.module_from_spec(spec)
|
|
21
|
+
spec.loader.exec_module(module)
|
|
22
|
+
return module
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
teardown_worktrees = _load_teardown_module()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_teardown_run_returns_zero_when_no_worktrees_exist(tmp_path: Path) -> None:
|
|
29
|
+
"""With absent worktrees, teardown_run removes nothing and reports zero,
|
|
30
|
+
reading each worktree path from the typed PerPrWorkspace.worktree attribute
|
|
31
|
+
rather than a dict lookup."""
|
|
32
|
+
run_temp_dir = tmp_path / "run"
|
|
33
|
+
run_temp_dir.mkdir()
|
|
34
|
+
all_pr_entries: list[dict[str, object]] = [
|
|
35
|
+
{"number": 7, "owner": "owner", "repo": "repo"},
|
|
36
|
+
]
|
|
37
|
+
removed_count = teardown_worktrees.teardown_run(
|
|
38
|
+
run_temp_dir=run_temp_dir,
|
|
39
|
+
all_pr_entries=all_pr_entries,
|
|
40
|
+
)
|
|
41
|
+
assert removed_count == 0
|
|
42
|
+
assert not run_temp_dir.exists()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_teardown_run_skips_entries_without_integer_number(tmp_path: Path) -> None:
|
|
46
|
+
"""A PR entry whose number is not an integer is skipped before any workspace
|
|
47
|
+
resolution, so teardown_run reports zero removals and still clears the run
|
|
48
|
+
temp directory."""
|
|
49
|
+
run_temp_dir = tmp_path / "run"
|
|
50
|
+
run_temp_dir.mkdir()
|
|
51
|
+
all_pr_entries: list[dict[str, object]] = [
|
|
52
|
+
{"number": "not-an-int", "owner": "owner", "repo": "repo"},
|
|
53
|
+
]
|
|
54
|
+
removed_count = teardown_worktrees.teardown_run(
|
|
55
|
+
run_temp_dir=run_temp_dir,
|
|
56
|
+
all_pr_entries=all_pr_entries,
|
|
57
|
+
)
|
|
58
|
+
assert removed_count == 0
|
|
59
|
+
assert not run_temp_dir.exists()
|