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.
Files changed (44) hide show
  1. package/CLAUDE.md +9 -0
  2. package/_shared/pr-loop/scripts/code_rules_gate.py +426 -85
  3. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +20 -0
  4. package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviews_disabled_constants.py +1 -0
  5. package/_shared/pr-loop/scripts/reviews_disabled.py +82 -9
  6. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +630 -21
  7. package/_shared/pr-loop/scripts/tests/test_code_rules_gate_constants.py +15 -0
  8. package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +57 -0
  9. package/agents/clean-coder.md +7 -1
  10. package/agents/code-quality-agent.md +8 -5
  11. package/hooks/blocking/code_rules_enforcer.py +1562 -37
  12. package/hooks/blocking/content_search_zoekt_redirect_guidance.py +19 -0
  13. package/hooks/blocking/open_questions_in_plans_blocker.py +249 -0
  14. package/hooks/blocking/test_code_rules_enforcer.py +1389 -0
  15. package/hooks/blocking/test_code_rules_enforcer_banned_noun_word.py +292 -0
  16. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +46 -8
  17. package/hooks/blocking/test_code_rules_enforcer_exempt_marker_chained.py +189 -0
  18. package/hooks/blocking/test_code_rules_enforcer_function_length.py +210 -0
  19. package/hooks/blocking/test_code_rules_enforcer_tests_isolate_home_temp.py +1512 -0
  20. package/hooks/blocking/test_code_rules_enforcer_unused_imports.py +9 -5
  21. package/hooks/blocking/test_content_search_to_zoekt_redirector_unit.py +30 -0
  22. package/hooks/blocking/test_open_questions_in_plans_blocker.py +790 -0
  23. package/hooks/hooks.json +10 -0
  24. package/hooks/hooks_constants/banned_identifiers_constants.py +19 -0
  25. package/hooks/hooks_constants/code_rules_enforcer_constants.py +129 -2
  26. package/hooks/hooks_constants/open_questions_in_plans_blocker_constants.py +35 -0
  27. package/hooks/hooks_constants/test_open_questions_in_plans_blocker_constants.py +125 -0
  28. package/package.json +1 -1
  29. package/skills/_shared/pr-loop/scripts/_path_resolver.py +34 -13
  30. package/skills/_shared/pr-loop/scripts/init_loop_state.py +1 -2
  31. package/skills/_shared/pr-loop/scripts/teardown_worktrees.py +1 -4
  32. package/skills/_shared/pr-loop/scripts/test__path_resolver.py +57 -0
  33. package/skills/_shared/pr-loop/scripts/test_init_loop_state.py +48 -0
  34. package/skills/_shared/pr-loop/scripts/test_teardown_worktrees.py +59 -0
  35. package/skills/bugteam/PROMPTS.md +48 -12
  36. package/skills/bugteam/reference/team-setup.md +4 -2
  37. package/skills/bugteam/scripts/bugteam_code_rules_gate.py +487 -76
  38. package/skills/bugteam/scripts/bugteam_scripts_constants/bugteam_code_rules_gate_constants.py +22 -1
  39. package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +602 -12
  40. package/skills/pr-converge/SKILL.md +5 -0
  41. package/skills/pr-converge/reference/per-tick.md +14 -5
  42. package/skills/pr-converge/reference/state-schema.md +7 -3
  43. package/skills/pr-converge/scripts/check_convergence.py +27 -1
  44. 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
- ALL_EXEMPT_PYTHON_COMMENT_BODIES: tuple[str, ...] = (
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "1.44.0",
3
+ "version": "1.46.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
- ) -> dict[str, object]:
87
- """Build the per-PR workspace paths dict.
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
- Dict with keys:
97
- - worktree: Path to the git worktree checkout
98
- - diff_patch_template: str template with {loop} placeholder
99
- - outcome_xml_template: str template with {number} and {loop} placeholders
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
- "worktree": pr_workspace_dir / WORKTREE_DIRNAME,
106
- "diff_patch_template": str(pr_workspace_dir / slug / DIFF_PATCH_TEMPLATE),
107
- "outcome_xml_template": OUTCOME_XML_TEMPLATE,
108
- "fix_outcome_xml_template": FIX_OUTCOME_XML_TEMPLATE,
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["worktree"]
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
- worktree_path = workspace["worktree"]
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()