claude-dev-env 1.36.1 → 1.37.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 (101) hide show
  1. package/_shared/pr-loop/audit-contract.md +159 -0
  2. package/_shared/pr-loop/code-rules-gate.md +64 -0
  3. package/_shared/pr-loop/fix-protocol.md +37 -0
  4. package/_shared/pr-loop/gh-payloads.md +85 -0
  5. package/_shared/pr-loop/scripts/README.md +20 -0
  6. package/_shared/pr-loop/scripts/_claude_permissions_common.py +234 -0
  7. package/_shared/pr-loop/scripts/code_rules_gate.py +975 -0
  8. package/_shared/pr-loop/scripts/config/__init__.py +0 -0
  9. package/_shared/pr-loop/scripts/config/claude_permissions_constants.py +36 -0
  10. package/_shared/pr-loop/scripts/config/claude_settings_keys_constants.py +11 -0
  11. package/_shared/pr-loop/scripts/config/code_rules_gate_constants.py +56 -0
  12. package/_shared/pr-loop/scripts/config/fix_hookspath_constants.py +25 -0
  13. package/_shared/pr-loop/scripts/config/gh_util_constants.py +31 -0
  14. package/_shared/pr-loop/scripts/config/preflight_constants.py +68 -0
  15. package/_shared/pr-loop/scripts/fix_hookspath.py +260 -0
  16. package/_shared/pr-loop/scripts/gh_util.py +193 -0
  17. package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +130 -0
  18. package/_shared/pr-loop/scripts/preflight.py +449 -0
  19. package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +156 -0
  20. package/_shared/pr-loop/scripts/tests/conftest.py +51 -0
  21. package/_shared/pr-loop/scripts/tests/test__claude_permissions_common.py +135 -0
  22. package/_shared/pr-loop/scripts/tests/test_claude_permissions_common.py +169 -0
  23. package/_shared/pr-loop/scripts/tests/test_claude_permissions_constants.py +58 -0
  24. package/_shared/pr-loop/scripts/tests/test_claude_settings_keys_constants.py +50 -0
  25. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +917 -0
  26. package/_shared/pr-loop/scripts/tests/test_code_rules_gate_constants.py +102 -0
  27. package/_shared/pr-loop/scripts/tests/test_fix_hookspath.py +374 -0
  28. package/_shared/pr-loop/scripts/tests/test_fix_hookspath_constants.py +47 -0
  29. package/_shared/pr-loop/scripts/tests/test_gh_util.py +257 -0
  30. package/_shared/pr-loop/scripts/tests/test_gh_util_constants.py +61 -0
  31. package/_shared/pr-loop/scripts/tests/test_grant_project_claude_permissions.py +49 -0
  32. package/_shared/pr-loop/scripts/tests/test_preflight.py +670 -0
  33. package/_shared/pr-loop/scripts/tests/test_preflight_constants.py +77 -0
  34. package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +49 -0
  35. package/_shared/pr-loop/state-schema.md +81 -0
  36. package/hooks/blocking/code_rules_enforcer.py +269 -23
  37. package/hooks/blocking/test_code_rules_enforcer_unused_imports.py +157 -1
  38. package/hooks/config/test_unused_module_import_constants.py +48 -0
  39. package/hooks/config/unused_module_import_constants.py +41 -0
  40. package/package.json +2 -1
  41. package/skills/bg-agent/SKILL.md +69 -0
  42. package/skills/bugteam/CONSTRAINTS.md +10 -19
  43. package/skills/bugteam/PROMPTS.md +3 -3
  44. package/skills/bugteam/SKILL.md +103 -202
  45. package/skills/bugteam/SKILL_EVALS.md +75 -114
  46. package/skills/bugteam/reference/README.md +2 -4
  47. package/skills/bugteam/reference/design-rationale.md +3 -8
  48. package/skills/bugteam/reference/team-setup.md +11 -19
  49. package/skills/bugteam/reference/teardown-publish-permissions.md +2 -14
  50. package/skills/bugteam/scripts/config/__init__.py +0 -0
  51. package/skills/bugteam/scripts/config/reflow_skill_md_constants.py +12 -0
  52. package/skills/bugteam/scripts/reflow_skill_md.py +51 -47
  53. package/skills/bugteam/sources.md +1 -25
  54. package/skills/bugteam/test_skill_additions.py +4 -13
  55. package/skills/fresh-branch/SKILL.md +71 -0
  56. package/skills/gotcha/SKILL.md +73 -0
  57. package/skills/monitor-open-prs/SKILL.md +4 -37
  58. package/skills/monitor-open-prs/test_skill_contract.py +0 -5
  59. package/skills/pr-converge/SKILL.md +60 -1298
  60. package/skills/pr-converge/reference/convergence-gates.md +118 -0
  61. package/skills/pr-converge/reference/examples.md +76 -0
  62. package/skills/pr-converge/reference/fix-protocol.md +54 -0
  63. package/skills/pr-converge/reference/ground-rules.md +13 -0
  64. package/skills/pr-converge/reference/multi-pr-orchestration.md +204 -0
  65. package/skills/pr-converge/reference/per-tick.md +201 -0
  66. package/skills/pr-converge/reference/state-schema.md +19 -0
  67. package/skills/pr-converge/reference/stop-conditions.md +26 -0
  68. package/skills/pr-converge/scripts/README.md +36 -9
  69. package/skills/pr-converge/scripts/check_pr_mergeability.py +1 -2
  70. package/skills/pr-converge/scripts/config/pr_converge_constants.py +58 -5
  71. package/skills/pr-converge/scripts/config/reflow_skill_md_constants.py +13 -0
  72. package/skills/pr-converge/scripts/config/test_pr_converge_constants.py +0 -24
  73. package/skills/pr-converge/scripts/cursor-agents-continue.ahk +22 -2
  74. package/skills/pr-converge/scripts/fetch_bugbot_inline_comments.py +19 -59
  75. package/skills/pr-converge/scripts/fetch_bugbot_reviews.py +15 -61
  76. package/skills/pr-converge/scripts/fetch_claude_inline_comments.py +70 -0
  77. package/skills/pr-converge/scripts/fetch_claude_reviews.py +61 -0
  78. package/skills/pr-converge/scripts/fetch_copilot_inline_comments.py +19 -61
  79. package/skills/pr-converge/scripts/fetch_copilot_reviews.py +14 -74
  80. package/skills/pr-converge/scripts/reflow_skill_md.py +71 -50
  81. package/skills/pr-converge/scripts/reviewer_fetch_core.py +153 -0
  82. package/skills/pr-converge/scripts/reviewer_specs.py +98 -0
  83. package/skills/pr-converge/scripts/test_cursor_agents_continue.py +65 -0
  84. package/skills/pr-converge/scripts/test_fetch_bugbot_inline_comments.py +107 -6
  85. package/skills/pr-converge/scripts/test_fetch_bugbot_reviews.py +85 -6
  86. package/skills/pr-converge/scripts/test_fetch_claude_inline_comments.py +485 -0
  87. package/skills/pr-converge/scripts/test_fetch_claude_reviews.py +368 -0
  88. package/skills/pr-converge/scripts/test_fetch_copilot_inline_comments.py +74 -6
  89. package/skills/pr-converge/scripts/test_fetch_copilot_reviews.py +94 -8
  90. package/skills/pr-converge/scripts/test_reflow_skill_md.py +162 -0
  91. package/skills/pr-converge/scripts/test_reviewer_fetch_core.py +448 -0
  92. package/skills/pr-converge/scripts/test_reviewer_specs.py +107 -0
  93. package/skills/pr-converge/workflows/schedule-wakeup-loop.md +24 -22
  94. package/skills/bugteam/reference/workflow-path-a-orchestrated-teams.md +0 -113
  95. package/skills/bugteam/reference/workflow-path-b-task-harness.md +0 -48
  96. package/skills/bugteam/test_team_lifecycle.py +0 -103
  97. package/skills/monitor-open-prs/test_team_lifecycle.py +0 -46
  98. package/skills/pr-converge/scripts/open_followup_copilot_pr.py +0 -136
  99. package/skills/pr-converge/scripts/test_open_followup_copilot_pr.py +0 -236
  100. package/skills/pr-converge/test_team_lifecycle.py +0 -56
  101. package/skills/pr-converge/workflows/ahk-auto-continue-loop.md +0 -108
@@ -0,0 +1,130 @@
1
+ """Grant Edit/Write/Read permissions on the current directory's .claude tree.
2
+
3
+ Run from the project root whose .claude/** you want a Claude Code session
4
+ (including spawned subagents) to edit without prompting. Writes idempotent
5
+ entries into the user-scope settings at ~/.claude/settings.json and prints
6
+ the changes applied. No-op when the entries already exist.
7
+ """
8
+
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ sys.modules.pop("config", None)
13
+ if str(Path(__file__).resolve().parent) not in sys.path:
14
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
15
+
16
+ from _claude_permissions_common import ( # noqa: E402
17
+ append_if_missing,
18
+ build_permission_rules,
19
+ ensure_dict_section,
20
+ ensure_list_entry,
21
+ exit_with_error,
22
+ get_current_project_path,
23
+ is_valid_project_root,
24
+ load_settings,
25
+ save_settings,
26
+ )
27
+ from config.claude_permissions_constants import ( # noqa: E402
28
+ ALL_PERMISSION_ALLOW_TOOLS,
29
+ AUTO_MODE_ENVIRONMENT_ENTRY_TEMPLATE,
30
+ get_claude_user_settings_path,
31
+ )
32
+ from config.claude_settings_keys_constants import ( # noqa: E402
33
+ CLAUDE_SETTINGS_ADDITIONAL_DIRECTORIES_KEY,
34
+ CLAUDE_SETTINGS_ALLOW_KEY,
35
+ CLAUDE_SETTINGS_AUTO_MODE_KEY,
36
+ CLAUDE_SETTINGS_ENVIRONMENT_KEY,
37
+ CLAUDE_SETTINGS_PERMISSIONS_KEY,
38
+ )
39
+
40
+
41
+ def add_rules_to_allow_list(
42
+ all_settings: dict[str, object], all_rules_to_add: list[str]
43
+ ) -> int:
44
+ permissions_section = ensure_dict_section(
45
+ all_settings, CLAUDE_SETTINGS_PERMISSIONS_KEY
46
+ )
47
+ existing_allow_list = ensure_list_entry(
48
+ permissions_section, CLAUDE_SETTINGS_ALLOW_KEY
49
+ )
50
+ return sum(
51
+ 1
52
+ for each_rule in all_rules_to_add
53
+ if append_if_missing(existing_allow_list, each_rule)
54
+ )
55
+
56
+
57
+ def add_directory_to_additional_directories(
58
+ all_settings: dict[str, object], directory_path: str
59
+ ) -> int:
60
+ permissions_section = ensure_dict_section(
61
+ all_settings, CLAUDE_SETTINGS_PERMISSIONS_KEY
62
+ )
63
+ existing_directories = ensure_list_entry(
64
+ permissions_section, CLAUDE_SETTINGS_ADDITIONAL_DIRECTORIES_KEY
65
+ )
66
+ if append_if_missing(existing_directories, directory_path):
67
+ return 1
68
+ return 0
69
+
70
+
71
+ def add_auto_mode_environment_entry(
72
+ all_settings: dict[str, object], entry_text: str
73
+ ) -> int:
74
+ auto_mode_section = ensure_dict_section(
75
+ all_settings, CLAUDE_SETTINGS_AUTO_MODE_KEY
76
+ )
77
+ existing_environment = ensure_list_entry(
78
+ auto_mode_section, CLAUDE_SETTINGS_ENVIRONMENT_KEY
79
+ )
80
+ if append_if_missing(existing_environment, entry_text):
81
+ return 1
82
+ return 0
83
+
84
+
85
+ def grant_permissions_for_current_directory() -> None:
86
+ claude_user_settings_path: Path = get_claude_user_settings_path()
87
+ project_root_path = Path.cwd()
88
+ if not is_valid_project_root(project_root_path):
89
+ print(
90
+ f"ERROR: cwd {project_root_path} is not a project root "
91
+ f"(no .git or .claude). Run from a project root.",
92
+ file=sys.stderr,
93
+ )
94
+ raise SystemExit(1)
95
+ project_path = get_current_project_path()
96
+ all_permission_rules = build_permission_rules(
97
+ project_path, ALL_PERMISSION_ALLOW_TOOLS
98
+ )
99
+ environment_entry = AUTO_MODE_ENVIRONMENT_ENTRY_TEMPLATE.format(
100
+ project_path=project_path
101
+ )
102
+ settings = load_settings(claude_user_settings_path)
103
+ rules_added_count = add_rules_to_allow_list(settings, all_permission_rules)
104
+ directories_added_count = add_directory_to_additional_directories(
105
+ settings, project_path
106
+ )
107
+ environment_entries_added_count = add_auto_mode_environment_entry(
108
+ settings, environment_entry
109
+ )
110
+ total_changes_count = (
111
+ rules_added_count + directories_added_count + environment_entries_added_count
112
+ )
113
+ if total_changes_count == 0:
114
+ print(f"Project path: {project_path}")
115
+ print(f"Settings file: {claude_user_settings_path}")
116
+ print("No changes needed; settings file left untouched.")
117
+ return
118
+ save_settings(claude_user_settings_path, settings)
119
+ print(f"Project path: {project_path}")
120
+ print(f"Settings file: {claude_user_settings_path}")
121
+ print(f"Allow rules added: {rules_added_count} of {len(all_permission_rules)}")
122
+ print(f"Additional directories added: {directories_added_count}")
123
+ print(f"Auto-mode environment entries added: {environment_entries_added_count}")
124
+
125
+
126
+ if __name__ == "__main__":
127
+ try:
128
+ grant_permissions_for_current_directory()
129
+ except ValueError as path_error:
130
+ exit_with_error(str(path_error))
@@ -0,0 +1,449 @@
1
+ import argparse
2
+ import os
3
+ import subprocess
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ sys.modules.pop("config", None)
8
+ _script_directory_resolved = Path(__file__).resolve().parent
9
+ _script_directory_absolute = Path(__file__).absolute().parent
10
+
11
+
12
+ def _entry_points_at_preflight_script_directory(each_path_entry: str) -> bool:
13
+ if each_path_entry in (
14
+ str(_script_directory_resolved),
15
+ str(_script_directory_absolute),
16
+ ):
17
+ return True
18
+ try:
19
+ candidate_path = Path(each_path_entry)
20
+ except (OSError, ValueError):
21
+ return False
22
+ if candidate_path.exists():
23
+ try:
24
+ return os.path.samefile(candidate_path, _script_directory_resolved)
25
+ except OSError:
26
+ return False
27
+ return False
28
+
29
+
30
+ for each_index in range(len(sys.path) - 1, -1, -1):
31
+ if _entry_points_at_preflight_script_directory(sys.path[each_index]):
32
+ sys.path.pop(each_index)
33
+ _preflight_scripts_path_entry = str(_script_directory_absolute)
34
+ if _preflight_scripts_path_entry not in sys.path:
35
+ sys.path.insert(0, _preflight_scripts_path_entry)
36
+
37
+ from config.fix_hookspath_constants import HOOKS_PATH_VERIFICATION_SUFFIX
38
+ from config.preflight_constants import (
39
+ ALL_GIT_CONFIG_GET_CORE_HOOKS_PATH_SUBCOMMAND,
40
+ ALL_GIT_DIFF_NAME_ONLY_SUBCOMMAND,
41
+ ALL_GIT_LS_FILES_TEST_DISCOVERY_SUBCOMMAND,
42
+ ALL_PRE_COMMIT_RUN_ALL_FILES_COMMAND,
43
+ BUGTEAM_PREFLIGHT_SKIP_ENABLED_VALUE,
44
+ BUGTEAM_PREFLIGHT_SKIP_ENV_VAR_NAME,
45
+ GIT_DIRECTORY_NAME,
46
+ PRE_COMMIT_CONFIG_YAML_FILENAME,
47
+ PYPROJECT_TOML_FILENAME,
48
+ PYTEST_FAILED_FIRST_FLAG,
49
+ PYTEST_INI_FILENAME,
50
+ ALL_PYTEST_SCOPE_CHOICES,
51
+ PYTEST_NO_TESTS_COLLECTED_EXIT_CODE,
52
+ PYTEST_SCOPE_ALL,
53
+ PYTEST_SCOPE_CHANGED,
54
+ PYTEST_TEST_FILENAME_PREFIX,
55
+ PYTEST_TEST_FILENAME_SUFFIX,
56
+ PYTEST_TOML_TABLE_PREFIX,
57
+ PYTHON_FILE_SUFFIX,
58
+ TESTS_DIRECTORY_NAME,
59
+ )
60
+
61
+
62
+ def verify_git_hooks_path(repository_root: Path | None = None) -> int:
63
+ """Check that core.hooksPath resolves to the claude-dev-env git-hooks directory.
64
+
65
+ When *repository_root* is provided, queries the effective config for that
66
+ repository (``git -C <root> config --get``), which detects repo-level
67
+ overrides such as Husky or lefthook. Falls back to the current working
68
+ directory's effective config when *repository_root* is None.
69
+
70
+ Returns zero when the configured path ends with the expected hooks suffix.
71
+ Returns non-zero and prints a correction message when unset or pointing elsewhere.
72
+ """
73
+ expected_hooks_path_suffix = HOOKS_PATH_VERIFICATION_SUFFIX
74
+ enforcement_absent_message = (
75
+ "Git-side CODE_RULES enforcement is not active on this host.\n"
76
+ "Run: npx claude-dev-env .\n"
77
+ "Or set core.hooksPath at any scope, e.g.:\n"
78
+ " git config --global core.hooksPath ~/.claude/hooks/git-hooks"
79
+ )
80
+ git_command: list[str] = ["git"]
81
+ if repository_root is not None:
82
+ git_command.extend(["-C", str(repository_root)])
83
+ git_command.extend(list(ALL_GIT_CONFIG_GET_CORE_HOOKS_PATH_SUBCOMMAND))
84
+ try:
85
+ query_result = subprocess.run(
86
+ git_command,
87
+ capture_output=True,
88
+ text=True,
89
+ encoding="utf-8",
90
+ errors="replace",
91
+ check=False,
92
+ )
93
+ except FileNotFoundError:
94
+ print(
95
+ "bugteam_preflight: git is not installed or not available on PATH.\n"
96
+ f"{enforcement_absent_message}",
97
+ file=sys.stderr,
98
+ )
99
+ return 1
100
+ except OSError as os_error:
101
+ print(
102
+ f"bugteam_preflight: failed to run git: {os_error}\n"
103
+ f"{enforcement_absent_message}",
104
+ file=sys.stderr,
105
+ )
106
+ return 1
107
+ if query_result.returncode != 0:
108
+ print(
109
+ f"bugteam_preflight: {enforcement_absent_message}",
110
+ file=sys.stderr,
111
+ )
112
+ return 1
113
+ configured_path = query_result.stdout.strip().replace("\\", "/").rstrip("/")
114
+ if not configured_path.endswith(expected_hooks_path_suffix):
115
+ print(
116
+ f"bugteam_preflight: core.hooksPath is '{configured_path}' — "
117
+ f"expected path ending in '{expected_hooks_path_suffix}'.\n"
118
+ f"{enforcement_absent_message}",
119
+ file=sys.stderr,
120
+ )
121
+ return 1
122
+ return 0
123
+
124
+
125
+ def find_repository_root(start: Path) -> Path:
126
+ resolved = start.resolve()
127
+ all_candidates = [resolved, *resolved.parents]
128
+ for each_candidate in all_candidates:
129
+ git_marker = each_candidate / GIT_DIRECTORY_NAME
130
+ if git_marker.is_dir() or git_marker.is_file():
131
+ return each_candidate
132
+ for each_candidate in all_candidates:
133
+ if (each_candidate / PYTEST_INI_FILENAME).is_file():
134
+ return each_candidate
135
+ return resolved
136
+
137
+
138
+ def has_pytest_configuration(root: Path) -> bool:
139
+ if (root / PYTEST_INI_FILENAME).is_file():
140
+ return True
141
+ pyproject = root / PYPROJECT_TOML_FILENAME
142
+ if not pyproject.is_file():
143
+ return False
144
+ text = pyproject.read_text(encoding="utf-8", errors="replace")
145
+ return PYTEST_TOML_TABLE_PREFIX in text
146
+
147
+
148
+ def has_discoverable_tests(root: Path) -> bool | None:
149
+ git_marker = root / GIT_DIRECTORY_NAME
150
+ if not (git_marker.is_dir() or git_marker.is_file()):
151
+ return True
152
+ command = ["git", "-C", str(root), *ALL_GIT_LS_FILES_TEST_DISCOVERY_SUBCOMMAND]
153
+ try:
154
+ completed = subprocess.run(
155
+ command,
156
+ capture_output=True,
157
+ text=True,
158
+ encoding="utf-8",
159
+ errors="replace",
160
+ check=True,
161
+ )
162
+ except FileNotFoundError:
163
+ print(
164
+ "bugteam_preflight: git is not installed or not available on PATH.",
165
+ file=sys.stderr,
166
+ )
167
+ return None
168
+ except subprocess.CalledProcessError as error:
169
+ error_detail = (error.stderr or "").strip()
170
+ print(
171
+ f"bugteam_preflight: git ls-files failed (exit {error.returncode}):"
172
+ + (f"\n{error_detail}" if error_detail else ""),
173
+ file=sys.stderr,
174
+ )
175
+ return None
176
+ except OSError as error:
177
+ print(
178
+ f"bugteam_preflight: failed to run git ls-files: {error}",
179
+ file=sys.stderr,
180
+ )
181
+ return None
182
+ return bool(completed.stdout.strip())
183
+
184
+
185
+ def _pytest_exit_code_no_tests_collected() -> int:
186
+ pytest_no_tests_collected_exit_code = PYTEST_NO_TESTS_COLLECTED_EXIT_CODE
187
+ return pytest_no_tests_collected_exit_code
188
+
189
+
190
+ def run_pytest(
191
+ repository_root: Path,
192
+ verbose: bool,
193
+ all_test_paths: list[Path] | None = None,
194
+ ) -> int:
195
+ command = [sys.executable, "-m", "pytest", PYTEST_FAILED_FIRST_FLAG]
196
+ if not verbose:
197
+ command.append("-q")
198
+ if all_test_paths is not None:
199
+ command.append("--")
200
+ command.extend(str(each_path) for each_path in all_test_paths)
201
+ completed = subprocess.run(
202
+ command,
203
+ cwd=str(repository_root),
204
+ check=False,
205
+ )
206
+ if completed.returncode == _pytest_exit_code_no_tests_collected():
207
+ return 0
208
+ return completed.returncode
209
+
210
+
211
+ def get_changed_files(repository_root: Path, base_ref: str) -> list[Path] | None:
212
+ if base_ref.startswith("-"):
213
+ print(
214
+ f"bugteam_preflight: invalid base_ref '{base_ref}' starts "
215
+ f"with hyphen; falling back to full suite.",
216
+ file=sys.stderr,
217
+ )
218
+ return None
219
+ command = [
220
+ "git",
221
+ *ALL_GIT_DIFF_NAME_ONLY_SUBCOMMAND,
222
+ f"{base_ref}...HEAD",
223
+ ]
224
+ try:
225
+ completed = subprocess.run(
226
+ command,
227
+ cwd=str(repository_root),
228
+ capture_output=True,
229
+ text=True,
230
+ encoding="utf-8",
231
+ errors="replace",
232
+ check=False,
233
+ )
234
+ except FileNotFoundError:
235
+ print(
236
+ "bugteam_preflight: git is not installed or not available on PATH.\n"
237
+ f"bugteam_preflight: cannot determine changed files against "
238
+ f"{base_ref}; falling back to full suite.",
239
+ file=sys.stderr,
240
+ )
241
+ return None
242
+ except OSError as os_error:
243
+ print(
244
+ f"bugteam_preflight: failed to run git: {os_error}\n"
245
+ f"bugteam_preflight: cannot determine changed files against "
246
+ f"{base_ref}; falling back to full suite.",
247
+ file=sys.stderr,
248
+ )
249
+ return None
250
+ if completed.returncode != 0:
251
+ print(
252
+ f"bugteam_preflight: git diff against {base_ref} failed "
253
+ f"(exit {completed.returncode}); falling back to full suite.\n"
254
+ f"{completed.stderr.strip()}",
255
+ file=sys.stderr,
256
+ )
257
+ return None
258
+ return [
259
+ Path(each_line.strip())
260
+ for each_line in completed.stdout.splitlines()
261
+ if each_line.strip()
262
+ ]
263
+
264
+
265
+ def _find_related_test_files(changed_path: Path, repository_root: Path) -> list[Path]:
266
+ if changed_path.suffix != PYTHON_FILE_SUFFIX:
267
+ return []
268
+ stem = changed_path.stem
269
+ test_prefix = PYTEST_TEST_FILENAME_PREFIX
270
+ test_suffix = PYTEST_TEST_FILENAME_SUFFIX
271
+ if (stem.startswith(test_prefix) or stem.endswith(test_suffix)) and (
272
+ repository_root / changed_path
273
+ ).is_file():
274
+ return [repository_root / changed_path]
275
+ full_path = repository_root / changed_path
276
+ parent = full_path.parent
277
+ adjacent_tests = parent / TESTS_DIRECTORY_NAME
278
+ top_tests = repository_root / TESTS_DIRECTORY_NAME
279
+ relative_parent = changed_path.parent
280
+ python_suffix = PYTHON_FILE_SUFFIX
281
+ all_candidates = [
282
+ parent / f"{test_prefix}{stem}{python_suffix}",
283
+ parent / f"{stem}{test_suffix}{python_suffix}",
284
+ adjacent_tests / f"{test_prefix}{stem}{python_suffix}",
285
+ adjacent_tests / f"{stem}{test_suffix}{python_suffix}",
286
+ ]
287
+ if relative_parent != Path("."):
288
+ all_candidates.extend([
289
+ top_tests / relative_parent / f"{test_prefix}{stem}{python_suffix}",
290
+ top_tests / relative_parent / f"{stem}{test_suffix}{python_suffix}",
291
+ ])
292
+ return sorted({each_candidate for each_candidate in all_candidates if each_candidate.is_file()})
293
+
294
+
295
+ def discover_related_tests(
296
+ all_changed_files: list[Path], repository_root: Path
297
+ ) -> list[Path]:
298
+ related: set[Path] = set()
299
+ for each_file in all_changed_files:
300
+ related.update(_find_related_test_files(each_file, repository_root))
301
+ return sorted(related)
302
+
303
+
304
+ def run_pre_commit(repository_root: Path) -> int:
305
+ completed = subprocess.run(
306
+ list(ALL_PRE_COMMIT_RUN_ALL_FILES_COMMAND),
307
+ cwd=str(repository_root),
308
+ check=False,
309
+ )
310
+ return completed.returncode
311
+
312
+
313
+ def parse_arguments(all_arguments: list[str]) -> argparse.Namespace:
314
+ parser = argparse.ArgumentParser(
315
+ description="Run local checks before /bugteam (pytest, optional pre-commit).",
316
+ )
317
+ parser.add_argument(
318
+ "--repo-root",
319
+ type=Path,
320
+ default=None,
321
+ help="Repository root (default: discover from cwd).",
322
+ )
323
+ parser.add_argument(
324
+ "--no-pytest",
325
+ action="store_true",
326
+ help="Skip pytest.",
327
+ )
328
+ parser.add_argument(
329
+ "--pre-commit",
330
+ action="store_true",
331
+ help=f"Run pre-commit when {PRE_COMMIT_CONFIG_YAML_FILENAME} exists.",
332
+ )
333
+ parser.add_argument(
334
+ "-v",
335
+ "--verbose",
336
+ action="store_true",
337
+ help="Verbose pytest output.",
338
+ )
339
+ parser.add_argument(
340
+ "--base-ref",
341
+ type=str,
342
+ default=None,
343
+ help=(
344
+ "Git base ref for scoped test selection (e.g., origin/main). "
345
+ "When set, only tests related to files changed vs this ref are run."
346
+ ),
347
+ )
348
+ parser.add_argument(
349
+ "--scope",
350
+ type=str,
351
+ choices=list(ALL_PYTEST_SCOPE_CHOICES),
352
+ default=None,
353
+ help=(
354
+ "Test selection scope. 'all' runs the full suite. "
355
+ "'changed' runs only tests related to changed files (requires --base-ref). "
356
+ "Defaults to 'changed' when --base-ref is provided, 'all' otherwise."
357
+ ),
358
+ )
359
+ return parser.parse_args(all_arguments)
360
+
361
+
362
+ def main(all_arguments: list[str]) -> int:
363
+ arguments = parse_arguments(all_arguments)
364
+ skip_env_var_name = BUGTEAM_PREFLIGHT_SKIP_ENV_VAR_NAME
365
+ skip_enabled_value = BUGTEAM_PREFLIGHT_SKIP_ENABLED_VALUE
366
+ if os.environ.get(skip_env_var_name, "").strip() == skip_enabled_value:
367
+ print(
368
+ f"bugteam_preflight: skipped ({skip_env_var_name}={skip_enabled_value}).",
369
+ file=sys.stderr,
370
+ )
371
+ return 0
372
+ start = Path.cwd()
373
+ repository_root = (
374
+ arguments.repo_root.resolve()
375
+ if arguments.repo_root is not None
376
+ else find_repository_root(start)
377
+ )
378
+ hooks_path_exit_code = verify_git_hooks_path(repository_root)
379
+ if hooks_path_exit_code != 0:
380
+ return hooks_path_exit_code
381
+ if not arguments.no_pytest and has_pytest_configuration(repository_root):
382
+ discovery_result = has_discoverable_tests(repository_root)
383
+ if discovery_result is None:
384
+ print(
385
+ "bugteam_preflight: test discovery failed; running full suite anyway.",
386
+ file=sys.stderr,
387
+ )
388
+ elif not discovery_result:
389
+ print(
390
+ "bugteam_preflight: pytest configured but no tests found; skipping pytest.",
391
+ file=sys.stderr,
392
+ )
393
+ if discovery_result is not False:
394
+ effective_scope = arguments.scope
395
+ if discovery_result is None:
396
+ effective_scope = PYTEST_SCOPE_ALL
397
+ if effective_scope is None:
398
+ effective_scope = (
399
+ PYTEST_SCOPE_CHANGED
400
+ if arguments.base_ref is not None
401
+ else PYTEST_SCOPE_ALL
402
+ )
403
+ if effective_scope == PYTEST_SCOPE_CHANGED and arguments.base_ref is None:
404
+ print(
405
+ "bugteam_preflight: --scope changed requires --base-ref; "
406
+ "falling back to full suite.",
407
+ file=sys.stderr,
408
+ )
409
+ effective_scope = PYTEST_SCOPE_ALL
410
+ if effective_scope == PYTEST_SCOPE_CHANGED and arguments.base_ref is not None:
411
+ all_changed = get_changed_files(repository_root, arguments.base_ref)
412
+ if all_changed is None:
413
+ exit_code = run_pytest(repository_root, arguments.verbose)
414
+ else:
415
+ all_related = discover_related_tests(all_changed, repository_root)
416
+ if all_related:
417
+ print(
418
+ f"bugteam_preflight: running {len(all_related)} test(s) "
419
+ f"related to changed files (scope=changed).",
420
+ file=sys.stderr,
421
+ )
422
+ exit_code = run_pytest(
423
+ repository_root, arguments.verbose, all_related
424
+ )
425
+ else:
426
+ print(
427
+ "bugteam_preflight: no related tests found; "
428
+ "running full suite.",
429
+ file=sys.stderr,
430
+ )
431
+ exit_code = run_pytest(repository_root, arguments.verbose)
432
+ else:
433
+ exit_code = run_pytest(repository_root, arguments.verbose)
434
+ if exit_code != 0:
435
+ return exit_code
436
+ elif not arguments.no_pytest:
437
+ print(
438
+ "bugteam_preflight: no pytest configuration found; skipping pytest.",
439
+ file=sys.stderr,
440
+ )
441
+ if arguments.pre_commit and (repository_root / PRE_COMMIT_CONFIG_YAML_FILENAME).is_file():
442
+ exit_code = run_pre_commit(repository_root)
443
+ if exit_code != 0:
444
+ return exit_code
445
+ return 0
446
+
447
+
448
+ if __name__ == "__main__":
449
+ raise SystemExit(main(sys.argv[1:]))