claude-dev-env 1.25.2 → 1.26.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 +6 -0
- package/agents/clean-coder.md +1 -1
- package/docs/CODE_RULES.md +3 -1
- package/hooks/HOOK_SPECS_PROMPT_WORKFLOW.md +54 -0
- package/hooks/blocking/{code-rules-enforcer.py → code_rules_enforcer.py} +150 -5
- package/hooks/blocking/test_code_rules_enforcer_any_type_ignore.py +2 -2
- package/hooks/blocking/test_code_rules_enforcer_banned_identifier.py +2 -2
- package/hooks/blocking/test_code_rules_enforcer_conftest_anchor.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_dot_test_pattern.py +2 -2
- package/hooks/blocking/test_code_rules_enforcer_file_global_constants.py +181 -0
- package/hooks/blocking/test_code_rules_enforcer_fstring_scan.py +4 -4
- package/hooks/blocking/test_code_rules_enforcer_logger_fstring.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_magic_allowlist.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_magic_string_masking.py +104 -0
- package/hooks/blocking/test_code_rules_enforcer_naming_pattern.py +2 -2
- package/hooks/blocking/test_code_rules_enforcer_type_checking_scope.py +2 -2
- package/hooks/blocking/test_content_search_to_zoekt_redirector_integration.py +1 -1
- package/hooks/blocking/test_destructive_command_blocker.py +1 -1
- package/hooks/blocking/test_gh_body_arg_blocker.py +1 -1
- package/hooks/blocking/test_pr_description_enforcer.py +8 -8
- package/hooks/blocking/test_tdd_enforcer.py +1 -1
- package/hooks/github-action/pre-push-review.yml +27 -0
- package/hooks/hooks.json +28 -28
- package/hooks/lifecycle/{config-change-guard.py → config_change_guard.py} +27 -12
- package/hooks/lifecycle/test_config_change_guard.py +3 -3
- package/hooks/notification/{attention-needed-notify.py → attention_needed_notify.py} +7 -0
- package/hooks/notification/{claude-notification-handler.py → claude_notification_handler.py} +8 -0
- package/hooks/notification/notification_utils.py +56 -0
- package/hooks/notification/subagent_complete_notify.py +381 -0
- package/hooks/notification/test_attention_needed_notify.py +47 -0
- package/hooks/notification/test_claude_notification_handler.py +54 -0
- package/hooks/notification/test_notification_utils.py +45 -0
- package/hooks/notification/test_subagent_complete_notify.py +72 -0
- package/hooks/validators/README.md +5 -1
- package/hooks/validators/abbreviation_checks.py +1 -1
- package/hooks/validators/code_quality_checks.py +1 -1
- package/hooks/validators/config.py +5 -0
- package/hooks/validators/conftest.py +10 -0
- package/hooks/validators/exempt_paths.py +1 -1
- package/hooks/validators/git_checks.py +80 -0
- package/hooks/validators/magic_value_checks.py +2 -2
- package/hooks/validators/pr_reference_checks.py +1 -1
- package/hooks/validators/python_antipattern_checks.py +1 -1
- package/hooks/validators/run_all_validators.py +53 -105
- package/hooks/validators/security_checks.py +1 -1
- package/hooks/validators/test_abbreviation_checks.py +2 -2
- package/hooks/validators/test_code_quality_checks.py +2 -2
- package/hooks/validators/test_file_structure_checks.py +1 -1
- package/hooks/validators/test_git_checks.py +79 -13
- package/hooks/validators/test_health_check.py +1 -1
- package/hooks/validators/test_magic_value_checks.py +2 -2
- package/hooks/validators/test_mypy_integration.py +1 -1
- package/hooks/validators/test_output_formatter.py +3 -1
- package/hooks/validators/test_pr_reference_checks.py +2 -2
- package/hooks/validators/test_python_antipattern_checks.py +2 -2
- package/hooks/validators/test_python_style_checks.py +2 -4
- package/hooks/validators/test_react_checks.py +1 -1
- package/hooks/validators/test_ruff_integration.py +1 -1
- package/hooks/validators/test_run_all_validators.py +75 -43
- package/hooks/validators/test_run_all_validators_integration.py +14 -37
- package/hooks/validators/test_security_checks.py +2 -2
- package/hooks/validators/test_test_safety_checks.py +1 -1
- package/hooks/validators/test_todo_checks.py +2 -2
- package/hooks/validators/test_type_safety_checks.py +2 -2
- package/hooks/validators/test_useless_test_checks.py +2 -2
- package/hooks/validators/test_validator_base.py +1 -1
- package/hooks/validators/test_verify_paths.py +2 -4
- package/hooks/validators/todo_checks.py +1 -1
- package/hooks/validators/type_safety_checks.py +1 -1
- package/hooks/validators/useless_test_checks.py +1 -1
- package/package.json +1 -1
- package/rules/file-global-constants.md +71 -0
- package/rules/gh-body-file.md +1 -1
- package/rules/prompt-workflow-context-controls.md +48 -0
- package/scripts/sync_to_cursor/rules.py +2 -2
- package/scripts/tests/test_sync_to_cursor.py +2 -2
- package/skills/bugteam/CONSTRAINTS.md +37 -0
- package/skills/bugteam/EXAMPLES.md +64 -0
- package/skills/bugteam/PROMPTS.md +175 -0
- package/skills/bugteam/SKILL.md +204 -295
- package/skills/bugteam/SKILL_EVALS.md +346 -0
- package/skills/bugteam/scripts/README.md +37 -0
- package/skills/bugteam/scripts/bugteam_code_rules_gate.py +334 -0
- package/skills/bugteam/scripts/bugteam_preflight.py +135 -0
- package/skills/rule-audit/SKILL.md +4 -4
- /package/hooks/advisory/{migration-safety-advisor.py → migration_safety_advisor.py} +0 -0
- /package/hooks/advisory/{refactor-guard.py → refactor_guard.py} +0 -0
- /package/hooks/blocking/{block-main-commit.py → block_main_commit.py} +0 -0
- /package/hooks/blocking/{content-search-to-zoekt-redirector.py → content_search_to_zoekt_redirector.py} +0 -0
- /package/hooks/blocking/{destructive-command-blocker.py → destructive_command_blocker.py} +0 -0
- /package/hooks/blocking/{gh-body-arg-blocker.py → gh_body_arg_blocker.py} +0 -0
- /package/hooks/blocking/{hedging-language-blocker.py → hedging_language_blocker.py} +0 -0
- /package/hooks/blocking/{pr-description-enforcer.py → pr_description_enforcer.py} +0 -0
- /package/hooks/blocking/{sensitive-file-protector.py → sensitive_file_protector.py} +0 -0
- /package/hooks/blocking/{tdd-enforcer.py → tdd_enforcer.py} +0 -0
- /package/hooks/blocking/{test-preflight-check.py → test_preflight_check.py} +0 -0
- /package/hooks/blocking/{write-existing-file-blocker.py → write_existing_file_blocker.py} +0 -0
- /package/hooks/git-hooks/{post-commit.py → post_commit.py} +0 -0
- /package/hooks/lifecycle/{session-end-cleanup.py → session_end_cleanup.py} +0 -0
- /package/hooks/{rewrite-plugin-paths.py → rewrite_plugin_paths.py} +0 -0
- /package/hooks/session/{plugin-data-dir-cleanup.py → plugin_data_dir_cleanup.py} +0 -0
- /package/hooks/validation/{hook-format-validator.py → hook_format_validator.py} +0 -0
- /package/hooks/workflow/{auto-formatter.py → auto_formatter.py} +0 -0
- /package/hooks/workflow/{investigation-tracker-reset.py → investigation_tracker_reset.py} +0 -0
- /package/scripts/{sync-to-cursor.py → sync_to_cursor.py} +0 -0
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import importlib.util
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def hunk_header_pattern() -> re.Pattern[str]:
|
|
12
|
+
return re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def violation_line_pattern() -> re.Pattern[str]:
|
|
16
|
+
return re.compile(r"^Line (\d+):")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def resolve_claude_dev_env_root() -> Path:
|
|
20
|
+
environment_value = (Path(__file__).resolve().parents[3]).resolve()
|
|
21
|
+
return environment_value
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def load_validate_content():
|
|
25
|
+
package_root = resolve_claude_dev_env_root()
|
|
26
|
+
enforcer_path = package_root / "hooks" / "blocking" / "code_rules_enforcer.py"
|
|
27
|
+
if not enforcer_path.is_file():
|
|
28
|
+
message = f"bugteam_code_rules_gate: missing enforcer at {enforcer_path}"
|
|
29
|
+
print(message, file=sys.stderr)
|
|
30
|
+
raise SystemExit(2)
|
|
31
|
+
specification = importlib.util.spec_from_file_location(
|
|
32
|
+
"code_rules_enforcer",
|
|
33
|
+
enforcer_path,
|
|
34
|
+
)
|
|
35
|
+
if specification is None or specification.loader is None:
|
|
36
|
+
print("bugteam_code_rules_gate: could not load code_rules_enforcer.", file=sys.stderr)
|
|
37
|
+
raise SystemExit(2)
|
|
38
|
+
module = importlib.util.module_from_spec(specification)
|
|
39
|
+
specification.loader.exec_module(module)
|
|
40
|
+
return module.validate_content
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def resolve_merge_base(repository_root: Path, base_reference: str) -> str:
|
|
44
|
+
merge_result = subprocess.run(
|
|
45
|
+
["git", "merge-base", "HEAD", base_reference],
|
|
46
|
+
cwd=str(repository_root),
|
|
47
|
+
capture_output=True,
|
|
48
|
+
text=True,
|
|
49
|
+
check=False,
|
|
50
|
+
)
|
|
51
|
+
if merge_result.returncode != 0:
|
|
52
|
+
print(
|
|
53
|
+
f"bugteam_code_rules_gate: git merge-base HEAD {base_reference} failed:\n"
|
|
54
|
+
f"{merge_result.stderr}",
|
|
55
|
+
file=sys.stderr,
|
|
56
|
+
)
|
|
57
|
+
raise SystemExit(2)
|
|
58
|
+
return merge_result.stdout.strip()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def paths_from_git_diff(repository_root: Path, base_reference: str) -> list[Path]:
|
|
62
|
+
merge_base = resolve_merge_base(repository_root, base_reference)
|
|
63
|
+
name_result = subprocess.run(
|
|
64
|
+
["git", "diff", "--name-only", f"{merge_base}..HEAD"],
|
|
65
|
+
cwd=str(repository_root),
|
|
66
|
+
capture_output=True,
|
|
67
|
+
text=True,
|
|
68
|
+
check=False,
|
|
69
|
+
)
|
|
70
|
+
if name_result.returncode != 0:
|
|
71
|
+
print(
|
|
72
|
+
f"bugteam_code_rules_gate: git diff --name-only failed:\n{name_result.stderr}",
|
|
73
|
+
file=sys.stderr,
|
|
74
|
+
)
|
|
75
|
+
raise SystemExit(2)
|
|
76
|
+
relative_paths = [line.strip() for line in name_result.stdout.splitlines() if line.strip()]
|
|
77
|
+
return [repository_root / relative_path for relative_path in relative_paths]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def is_code_path(file_path: Path) -> bool:
|
|
81
|
+
suffix = file_path.suffix.lower()
|
|
82
|
+
return suffix in {".py", ".js", ".ts", ".tsx", ".jsx"}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def parse_added_line_numbers(unified_diff_text: str) -> set[int]:
|
|
86
|
+
header_regex = hunk_header_pattern()
|
|
87
|
+
added_line_numbers: set[int] = set()
|
|
88
|
+
for each_line in unified_diff_text.splitlines():
|
|
89
|
+
header_match = header_regex.match(each_line)
|
|
90
|
+
if header_match is None:
|
|
91
|
+
continue
|
|
92
|
+
new_start_text, new_count_text = header_match.groups()
|
|
93
|
+
new_start = int(new_start_text)
|
|
94
|
+
new_count = 1 if new_count_text is None else int(new_count_text)
|
|
95
|
+
if new_count <= 0:
|
|
96
|
+
continue
|
|
97
|
+
for each_number in range(new_start, new_start + new_count):
|
|
98
|
+
added_line_numbers.add(each_number)
|
|
99
|
+
return added_line_numbers
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def is_file_new_at_base(
|
|
103
|
+
repository_root: Path,
|
|
104
|
+
merge_base: str,
|
|
105
|
+
relative_path_posix: str,
|
|
106
|
+
) -> bool:
|
|
107
|
+
cat_result = subprocess.run(
|
|
108
|
+
["git", "cat-file", "-e", f"{merge_base}:{relative_path_posix}"],
|
|
109
|
+
cwd=str(repository_root),
|
|
110
|
+
capture_output=True,
|
|
111
|
+
text=True,
|
|
112
|
+
check=False,
|
|
113
|
+
)
|
|
114
|
+
return cat_result.returncode != 0
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def added_lines_for_file(
|
|
118
|
+
repository_root: Path,
|
|
119
|
+
merge_base: str,
|
|
120
|
+
relative_path_posix: str,
|
|
121
|
+
) -> set[int]:
|
|
122
|
+
diff_result = subprocess.run(
|
|
123
|
+
["git", "diff", "--unified=0", f"{merge_base}..HEAD", "--", relative_path_posix],
|
|
124
|
+
cwd=str(repository_root),
|
|
125
|
+
capture_output=True,
|
|
126
|
+
text=True,
|
|
127
|
+
check=False,
|
|
128
|
+
)
|
|
129
|
+
if diff_result.returncode != 0:
|
|
130
|
+
print(
|
|
131
|
+
f"bugteam_code_rules_gate: git diff --unified=0 failed for {relative_path_posix}:\n"
|
|
132
|
+
f"{diff_result.stderr}",
|
|
133
|
+
file=sys.stderr,
|
|
134
|
+
)
|
|
135
|
+
raise SystemExit(2)
|
|
136
|
+
if not diff_result.stdout.strip():
|
|
137
|
+
return set()
|
|
138
|
+
return parse_added_line_numbers(diff_result.stdout)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def whole_file_line_set(file_path: Path) -> set[int]:
|
|
142
|
+
try:
|
|
143
|
+
total_lines = len(file_path.read_text().splitlines())
|
|
144
|
+
except OSError:
|
|
145
|
+
return set()
|
|
146
|
+
if total_lines <= 0:
|
|
147
|
+
return set()
|
|
148
|
+
return set(range(1, total_lines + 1))
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def added_lines_by_file(
|
|
152
|
+
repository_root: Path,
|
|
153
|
+
base_reference: str,
|
|
154
|
+
file_paths: list[Path],
|
|
155
|
+
) -> dict[Path, set[int]]:
|
|
156
|
+
merge_base = resolve_merge_base(repository_root, base_reference)
|
|
157
|
+
resolved_root = repository_root.resolve()
|
|
158
|
+
added_by_path: dict[Path, set[int]] = {}
|
|
159
|
+
for each_path in file_paths:
|
|
160
|
+
try:
|
|
161
|
+
resolved = each_path.resolve()
|
|
162
|
+
except OSError:
|
|
163
|
+
continue
|
|
164
|
+
try:
|
|
165
|
+
relative = resolved.relative_to(resolved_root)
|
|
166
|
+
except ValueError:
|
|
167
|
+
continue
|
|
168
|
+
relative_posix = str(relative).replace("\\", "/")
|
|
169
|
+
added_numbers = added_lines_for_file(resolved_root, merge_base, relative_posix)
|
|
170
|
+
if not added_numbers and resolved.is_file():
|
|
171
|
+
if is_file_new_at_base(resolved_root, merge_base, relative_posix):
|
|
172
|
+
added_numbers = whole_file_line_set(resolved)
|
|
173
|
+
added_by_path[resolved] = added_numbers
|
|
174
|
+
return added_by_path
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def extract_violation_line_number(violation_text: str) -> int | None:
|
|
178
|
+
match_result = violation_line_pattern().match(violation_text)
|
|
179
|
+
if match_result is None:
|
|
180
|
+
return None
|
|
181
|
+
return int(match_result.group(1))
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def split_violations_by_scope(
|
|
185
|
+
issues: list[str],
|
|
186
|
+
added_line_numbers: set[int] | None,
|
|
187
|
+
) -> tuple[list[str], list[str]]:
|
|
188
|
+
if added_line_numbers is None:
|
|
189
|
+
return list(issues), []
|
|
190
|
+
blocking: list[str] = []
|
|
191
|
+
advisory: list[str] = []
|
|
192
|
+
for each_issue in issues:
|
|
193
|
+
violation_line = extract_violation_line_number(each_issue)
|
|
194
|
+
if violation_line is None:
|
|
195
|
+
blocking.append(each_issue)
|
|
196
|
+
continue
|
|
197
|
+
if violation_line in added_line_numbers:
|
|
198
|
+
blocking.append(each_issue)
|
|
199
|
+
else:
|
|
200
|
+
advisory.append(each_issue)
|
|
201
|
+
return blocking, advisory
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def print_violation_section(
|
|
205
|
+
header_message: str,
|
|
206
|
+
violations_by_file: dict[Path, list[str]],
|
|
207
|
+
repository_root: Path,
|
|
208
|
+
) -> None:
|
|
209
|
+
print(header_message, file=sys.stderr)
|
|
210
|
+
resolved_root = repository_root.resolve()
|
|
211
|
+
for each_path in sorted(violations_by_file.keys()):
|
|
212
|
+
relative = each_path.relative_to(resolved_root)
|
|
213
|
+
print(f"{relative}:", file=sys.stderr)
|
|
214
|
+
for each_issue in violations_by_file[each_path]:
|
|
215
|
+
print(f" {each_issue}", file=sys.stderr)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def run_gate(
|
|
219
|
+
validate_content,
|
|
220
|
+
file_paths: list[Path],
|
|
221
|
+
repository_root: Path,
|
|
222
|
+
added_lines_map: dict[Path, set[int]] | None = None,
|
|
223
|
+
) -> int:
|
|
224
|
+
blocking_by_file: dict[Path, list[str]] = {}
|
|
225
|
+
advisory_by_file: dict[Path, list[str]] = {}
|
|
226
|
+
for file_path in sorted(set(file_paths)):
|
|
227
|
+
try:
|
|
228
|
+
resolved = file_path.resolve()
|
|
229
|
+
except OSError:
|
|
230
|
+
continue
|
|
231
|
+
try:
|
|
232
|
+
resolved.relative_to(repository_root.resolve())
|
|
233
|
+
except ValueError:
|
|
234
|
+
continue
|
|
235
|
+
if not is_code_path(resolved):
|
|
236
|
+
continue
|
|
237
|
+
if not resolved.is_file():
|
|
238
|
+
continue
|
|
239
|
+
try:
|
|
240
|
+
content = resolved.read_text(encoding="utf-8")
|
|
241
|
+
except OSError:
|
|
242
|
+
print(f"bugteam_code_rules_gate: skip unreadable {resolved}", file=sys.stderr)
|
|
243
|
+
continue
|
|
244
|
+
relative = resolved.relative_to(repository_root.resolve())
|
|
245
|
+
issues = validate_content(content, str(relative).replace("\\", "/"), old_content=content)
|
|
246
|
+
if not issues:
|
|
247
|
+
continue
|
|
248
|
+
added_for_file = None if added_lines_map is None else added_lines_map.get(resolved)
|
|
249
|
+
blocking, advisory = split_violations_by_scope(issues, added_for_file)
|
|
250
|
+
if blocking:
|
|
251
|
+
blocking_by_file[resolved] = blocking
|
|
252
|
+
if advisory:
|
|
253
|
+
advisory_by_file[resolved] = advisory
|
|
254
|
+
blocking_count = sum(len(each_list) for each_list in blocking_by_file.values())
|
|
255
|
+
advisory_count = sum(len(each_list) for each_list in advisory_by_file.values())
|
|
256
|
+
if blocking_count:
|
|
257
|
+
if added_lines_map is None:
|
|
258
|
+
header = f"bugteam_code_rules_gate: {blocking_count} violation(s) reported."
|
|
259
|
+
else:
|
|
260
|
+
header = (
|
|
261
|
+
f"bugteam_code_rules_gate: {blocking_count} violation(s) "
|
|
262
|
+
"introduced on changed lines:"
|
|
263
|
+
)
|
|
264
|
+
print_violation_section(
|
|
265
|
+
header,
|
|
266
|
+
blocking_by_file,
|
|
267
|
+
repository_root,
|
|
268
|
+
)
|
|
269
|
+
if advisory_count:
|
|
270
|
+
if blocking_count:
|
|
271
|
+
print("", file=sys.stderr)
|
|
272
|
+
print_violation_section(
|
|
273
|
+
(
|
|
274
|
+
f"bugteam_code_rules_gate: {advisory_count} pre-existing violation(s) "
|
|
275
|
+
"in touched files (advisory, not blocking):"
|
|
276
|
+
),
|
|
277
|
+
advisory_by_file,
|
|
278
|
+
repository_root,
|
|
279
|
+
)
|
|
280
|
+
if blocking_count:
|
|
281
|
+
return 1
|
|
282
|
+
return 0
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def parse_arguments(argv: list[str]) -> argparse.Namespace:
|
|
286
|
+
parser = argparse.ArgumentParser(
|
|
287
|
+
description=(
|
|
288
|
+
"Run CODE_RULES validators (validate_content) on files in the working tree. "
|
|
289
|
+
"Default file set: git diff --name-only merge-base(base)..HEAD."
|
|
290
|
+
),
|
|
291
|
+
)
|
|
292
|
+
parser.add_argument(
|
|
293
|
+
"--repo-root",
|
|
294
|
+
type=Path,
|
|
295
|
+
default=None,
|
|
296
|
+
help="Repository root (default: cwd).",
|
|
297
|
+
)
|
|
298
|
+
parser.add_argument(
|
|
299
|
+
"--base",
|
|
300
|
+
default="origin/main",
|
|
301
|
+
help="Merge-base ref for git diff (default: origin/main).",
|
|
302
|
+
)
|
|
303
|
+
parser.add_argument(
|
|
304
|
+
"paths",
|
|
305
|
+
nargs="*",
|
|
306
|
+
type=Path,
|
|
307
|
+
help="Optional explicit files; if set, git diff is not used.",
|
|
308
|
+
)
|
|
309
|
+
return parser.parse_args(argv)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def main(argv: list[str] | None = None) -> int:
|
|
313
|
+
arguments = parse_arguments(sys.argv[1:] if argv is None else argv)
|
|
314
|
+
repository_root = (
|
|
315
|
+
arguments.repo_root.resolve()
|
|
316
|
+
if arguments.repo_root is not None
|
|
317
|
+
else Path.cwd().resolve()
|
|
318
|
+
)
|
|
319
|
+
validate_content = load_validate_content()
|
|
320
|
+
if arguments.paths:
|
|
321
|
+
file_paths = [repository_root / path for path in arguments.paths]
|
|
322
|
+
return run_gate(validate_content, file_paths, repository_root, added_lines_map=None)
|
|
323
|
+
file_paths = paths_from_git_diff(repository_root, arguments.base)
|
|
324
|
+
scoped_added_lines = added_lines_by_file(repository_root, arguments.base, file_paths)
|
|
325
|
+
return run_gate(
|
|
326
|
+
validate_content,
|
|
327
|
+
file_paths,
|
|
328
|
+
repository_root,
|
|
329
|
+
added_lines_map=scoped_added_lines,
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
if __name__ == "__main__":
|
|
334
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def find_repository_root(start: Path) -> Path:
|
|
11
|
+
resolved = start.resolve()
|
|
12
|
+
candidates = [resolved, *resolved.parents]
|
|
13
|
+
for candidate in candidates:
|
|
14
|
+
if (candidate / ".git").is_dir() or (candidate / ".git").is_file():
|
|
15
|
+
return candidate
|
|
16
|
+
for candidate in candidates:
|
|
17
|
+
if (candidate / "pytest.ini").is_file():
|
|
18
|
+
return candidate
|
|
19
|
+
return resolved
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def has_pytest_configuration(root: Path) -> bool:
|
|
23
|
+
if (root / "pytest.ini").is_file():
|
|
24
|
+
return True
|
|
25
|
+
pyproject = root / "pyproject.toml"
|
|
26
|
+
if not pyproject.is_file():
|
|
27
|
+
return False
|
|
28
|
+
text = pyproject.read_text(encoding="utf-8", errors="replace")
|
|
29
|
+
return "[tool.pytest" in text
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def has_discoverable_tests(root: Path) -> bool:
|
|
33
|
+
ignore = {"site-packages", ".venv", "venv", "node_modules"}
|
|
34
|
+
for path in root.rglob("test_*.py"):
|
|
35
|
+
if any(part in ignore for part in path.parts):
|
|
36
|
+
continue
|
|
37
|
+
return True
|
|
38
|
+
for path in root.rglob("*_test.py"):
|
|
39
|
+
if any(part in ignore for part in path.parts):
|
|
40
|
+
continue
|
|
41
|
+
return True
|
|
42
|
+
return False
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _pytest_exit_code_no_tests_collected() -> int:
|
|
46
|
+
return 5
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def run_pytest(repository_root: Path, verbose: bool) -> int:
|
|
50
|
+
command = [sys.executable, "-m", "pytest"]
|
|
51
|
+
if not verbose:
|
|
52
|
+
command.append("-q")
|
|
53
|
+
completed = subprocess.run(
|
|
54
|
+
command,
|
|
55
|
+
cwd=str(repository_root),
|
|
56
|
+
check=False,
|
|
57
|
+
)
|
|
58
|
+
if completed.returncode == _pytest_exit_code_no_tests_collected():
|
|
59
|
+
return 0
|
|
60
|
+
return completed.returncode
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def run_pre_commit(repository_root: Path) -> int:
|
|
64
|
+
completed = subprocess.run(
|
|
65
|
+
["pre-commit", "run", "--all-files"],
|
|
66
|
+
cwd=str(repository_root),
|
|
67
|
+
check=False,
|
|
68
|
+
)
|
|
69
|
+
return completed.returncode
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def parse_arguments(argv: list[str]) -> argparse.Namespace:
|
|
73
|
+
parser = argparse.ArgumentParser(
|
|
74
|
+
description="Run local checks before /bugteam (pytest, optional pre-commit).",
|
|
75
|
+
)
|
|
76
|
+
parser.add_argument(
|
|
77
|
+
"--repo-root",
|
|
78
|
+
type=Path,
|
|
79
|
+
default=None,
|
|
80
|
+
help="Repository root (default: discover from cwd).",
|
|
81
|
+
)
|
|
82
|
+
parser.add_argument(
|
|
83
|
+
"--no-pytest",
|
|
84
|
+
action="store_true",
|
|
85
|
+
help="Skip pytest.",
|
|
86
|
+
)
|
|
87
|
+
parser.add_argument(
|
|
88
|
+
"--pre-commit",
|
|
89
|
+
action="store_true",
|
|
90
|
+
help="Run pre-commit when .pre-commit-config.yaml exists.",
|
|
91
|
+
)
|
|
92
|
+
parser.add_argument(
|
|
93
|
+
"-v",
|
|
94
|
+
"--verbose",
|
|
95
|
+
action="store_true",
|
|
96
|
+
help="Verbose pytest output.",
|
|
97
|
+
)
|
|
98
|
+
return parser.parse_args(argv)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def main(argv: list[str] | None = None) -> int:
|
|
102
|
+
arguments = parse_arguments(sys.argv[1:] if argv is None else argv)
|
|
103
|
+
if os.environ.get("BUGTEAM_PREFLIGHT_SKIP", "").strip() == "1":
|
|
104
|
+
print("bugteam_preflight: skipped (BUGTEAM_PREFLIGHT_SKIP=1).", file=sys.stderr)
|
|
105
|
+
return 0
|
|
106
|
+
start = Path.cwd()
|
|
107
|
+
repository_root = (
|
|
108
|
+
arguments.repo_root.resolve()
|
|
109
|
+
if arguments.repo_root is not None
|
|
110
|
+
else find_repository_root(start)
|
|
111
|
+
)
|
|
112
|
+
if not arguments.no_pytest and has_pytest_configuration(repository_root):
|
|
113
|
+
if not has_discoverable_tests(repository_root):
|
|
114
|
+
print(
|
|
115
|
+
"bugteam_preflight: pytest configured but no tests found; skipping pytest.",
|
|
116
|
+
file=sys.stderr,
|
|
117
|
+
)
|
|
118
|
+
else:
|
|
119
|
+
exit_code = run_pytest(repository_root, arguments.verbose)
|
|
120
|
+
if exit_code != 0:
|
|
121
|
+
return exit_code
|
|
122
|
+
elif not arguments.no_pytest:
|
|
123
|
+
print(
|
|
124
|
+
"bugteam_preflight: no pytest configuration found; skipping pytest.",
|
|
125
|
+
file=sys.stderr,
|
|
126
|
+
)
|
|
127
|
+
if arguments.pre_commit and (repository_root / ".pre-commit-config.yaml").is_file():
|
|
128
|
+
exit_code = run_pre_commit(repository_root)
|
|
129
|
+
if exit_code != 0:
|
|
130
|
+
return exit_code
|
|
131
|
+
return 0
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
if __name__ == "__main__":
|
|
135
|
+
raise SystemExit(main())
|
|
@@ -129,8 +129,8 @@ Build a matrix of where each concept appears:
|
|
|
129
129
|
```
|
|
130
130
|
| Rule/Concept | AGENTS.md | rules/*.md | docs/*.md | hooks | Count |
|
|
131
131
|
|---|---|---|---|---|---|
|
|
132
|
-
| BDD first | line 52, 92 | bdd.md | - |
|
|
133
|
-
| No magic values | - | code-standards.md | CODE_RULES.md:49 |
|
|
132
|
+
| BDD first | line 52, 92 | bdd.md | - | tdd_enforcer.py | 3 advisory + 1 hook |
|
|
133
|
+
| No magic values | - | code-standards.md | CODE_RULES.md:49 | code_rules_enforcer.py | 2 advisory + 1 hook |
|
|
134
134
|
| ... | ... | ... | ... | ... | ... |
|
|
135
135
|
```
|
|
136
136
|
|
|
@@ -143,8 +143,8 @@ For each rule/concept, classify its enforcement level:
|
|
|
143
143
|
```
|
|
144
144
|
| Level | Description | Example |
|
|
145
145
|
|---|---|---|
|
|
146
|
-
| ENFORCED | Hook blocks the action deterministically |
|
|
147
|
-
| VALIDATED | PostToolUse checks after the fact | mypy_validator.py,
|
|
146
|
+
| ENFORCED | Hook blocks the action deterministically | destructive_command_blocker.py |
|
|
147
|
+
| VALIDATED | PostToolUse checks after the fact | mypy_validator.py, auto_formatter.py |
|
|
148
148
|
| ADVISORY | In AGENTS.md/rules but no hook backs it | most rules |
|
|
149
149
|
| REDUNDANT | Codex already does this by default | "write clean code" |
|
|
150
150
|
| ORPHANED | Hook exists but no corresponding rule | hook with no rule backing |
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|