claude-dev-env 1.35.0 → 1.36.1
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/agents/clean-coder.md +109 -1
- package/bin/install.mjs +28 -8
- package/bin/install.test.mjs +9 -1
- package/docs/CODE_RULES.md +3 -0
- package/docs/agents-md-alignment-plan.md +123 -0
- package/hooks/blocking/code_rules_enforcer.py +451 -39
- package/hooks/blocking/es_exe_path_rewriter.py +10 -4
- package/hooks/blocking/test_code_rules_enforcer.py +182 -0
- package/hooks/blocking/test_code_rules_enforcer_banned_identifier.py +106 -0
- package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +173 -0
- package/hooks/blocking/test_code_rules_enforcer_collection_prefix.py +191 -0
- package/hooks/blocking/test_code_rules_enforcer_constant_equality.py +40 -0
- package/hooks/blocking/test_code_rules_enforcer_hardcoded_user_path.py +291 -0
- package/hooks/blocking/test_code_rules_enforcer_loop_variable_naming.py +87 -3
- package/hooks/blocking/test_code_rules_enforcer_naming_pattern.py +49 -0
- package/hooks/blocking/test_code_rules_enforcer_sys_path_insert.py +157 -0
- package/hooks/blocking/test_code_rules_enforcer_unused_imports.py +244 -0
- package/hooks/blocking/test_es_exe_path_rewriter.py +81 -3
- package/hooks/blocking/test_windows_rmtree_blocker.py +120 -8
- package/hooks/blocking/windows_rmtree_blocker.py +23 -6
- package/hooks/config/banned_identifiers_constants.py +24 -0
- package/hooks/config/hardcoded_user_path_constants.py +12 -0
- package/hooks/config/hook_log_extractor_constants.py +1 -1
- package/hooks/config/pre_tool_use_stdin.py +48 -0
- package/hooks/config/setup_project_paths_constants.py +4 -0
- package/hooks/config/stuttering_check_config.py +14 -0
- package/hooks/config/stuttering_import_binding_constants.py +11 -0
- package/hooks/config/sys_path_insert_constants.py +4 -0
- package/hooks/config/test_banned_identifiers_constants.py +48 -0
- package/hooks/config/test_hardcoded_user_path_constants.py +78 -0
- package/hooks/config/test_hook_log_extractor_constants.py +3 -3
- package/hooks/config/test_pre_tool_use_stdin.py +80 -0
- package/hooks/config/unused_module_import_constants.py +7 -0
- package/hooks/config/windows_rmtree_blocker_constants.py +3 -0
- package/hooks/diagnostic/hook_log_stop_wrapper.py +7 -4
- package/hooks/git-hooks/config.py +3 -3
- package/hooks/git-hooks/test_gate_utils.py +10 -10
- package/hooks/mypy.ini +2 -0
- package/package.json +1 -1
- package/rules/gh-paginate.md +125 -0
- package/skills/bugteam/CONSTRAINTS.md +12 -6
- package/skills/bugteam/SKILL.md +364 -154
- package/skills/bugteam/SKILL_EVALS.md +25 -23
- package/skills/bugteam/reference/README.md +2 -0
- package/skills/bugteam/reference/audit-and-teammates.md +2 -2
- package/skills/bugteam/reference/teardown-publish-permissions.md +1 -1
- package/skills/bugteam/reference/workflow-path-a-orchestrated-teams.md +113 -0
- package/skills/bugteam/reference/workflow-path-b-task-harness.md +48 -0
- package/skills/bugteam/scripts/reflow_skill_md.py +298 -0
- package/skills/bugteam/test_skill_additions.py +13 -4
- package/skills/bugteam/test_team_lifecycle.py +103 -0
- package/skills/findbugs/SKILL.md +3 -3
- package/skills/fixbugs/SKILL.md +4 -4
- package/skills/monitor-open-prs/SKILL.md +32 -2
- package/skills/monitor-open-prs/test_team_lifecycle.py +46 -0
- package/skills/pr-converge/SKILL.md +1206 -131
- package/skills/pr-converge/scripts/README.md +145 -0
- package/skills/pr-converge/scripts/caller-window-pid.ps1 +86 -0
- package/skills/pr-converge/scripts/check_pr_mergeability.py +79 -0
- package/skills/pr-converge/scripts/config/pr_converge_constants.py +65 -0
- package/skills/pr-converge/scripts/config/test_pr_converge_constants.py +176 -0
- package/skills/pr-converge/scripts/cursor-agents-continue-caller.cmd +9 -0
- package/skills/pr-converge/scripts/cursor-agents-continue-stop-others.ps1 +16 -0
- package/skills/pr-converge/scripts/cursor-agents-continue.ahk +172 -0
- package/skills/pr-converge/scripts/cursor-agents-continue.cmd +2 -0
- package/skills/pr-converge/scripts/evict_cached_config_modules.py +20 -0
- package/skills/pr-converge/scripts/fetch_bugbot_inline_comments.py +110 -0
- package/skills/pr-converge/scripts/fetch_bugbot_reviews.py +103 -0
- package/skills/pr-converge/scripts/fetch_copilot_inline_comments.py +112 -0
- package/skills/pr-converge/scripts/fetch_copilot_reviews.py +121 -0
- package/skills/pr-converge/scripts/mark_pr_ready.py +54 -0
- package/skills/pr-converge/scripts/open_followup_copilot_pr.py +136 -0
- package/skills/pr-converge/scripts/post-bugbot-run.helpers.ps1 +49 -0
- package/skills/pr-converge/scripts/post-bugbot-run.ps1 +33 -0
- package/skills/pr-converge/scripts/reflow_skill_md.py +288 -0
- package/skills/pr-converge/scripts/reply_to_inline_comment.py +84 -0
- package/skills/pr-converge/scripts/request_copilot_review.py +71 -0
- package/skills/pr-converge/scripts/resolve_pr_head.py +58 -0
- package/skills/pr-converge/scripts/review_field_helpers.py +43 -0
- package/skills/pr-converge/scripts/test_check_pr_mergeability.py +126 -0
- package/skills/pr-converge/scripts/test_evict_cached_config_modules.py +22 -0
- package/skills/pr-converge/scripts/test_fetch_bugbot_inline_comments.py +342 -0
- package/skills/pr-converge/scripts/test_fetch_bugbot_reviews.py +220 -0
- package/skills/pr-converge/scripts/test_fetch_copilot_inline_comments.py +372 -0
- package/skills/pr-converge/scripts/test_fetch_copilot_reviews.py +280 -0
- package/skills/pr-converge/scripts/test_mark_pr_ready.py +69 -0
- package/skills/pr-converge/scripts/test_open_followup_copilot_pr.py +236 -0
- package/skills/pr-converge/scripts/test_post_bugbot_run.py +195 -0
- package/skills/pr-converge/scripts/test_reply_to_inline_comment.py +159 -0
- package/skills/pr-converge/scripts/test_request_copilot_review.py +101 -0
- package/skills/pr-converge/scripts/test_resolve_pr_head.py +79 -0
- package/skills/pr-converge/scripts/test_review_field_helpers.py +80 -0
- package/skills/pr-converge/scripts/test_trigger_bugbot.py +139 -0
- package/skills/pr-converge/scripts/test_view_pr_context.py +111 -0
- package/skills/pr-converge/scripts/trigger_bugbot.py +77 -0
- package/skills/pr-converge/scripts/view_pr_context.py +47 -0
- package/skills/pr-converge/test_team_lifecycle.py +56 -0
- package/skills/pr-converge/workflows/ahk-auto-continue-loop.md +108 -0
- package/skills/pr-converge/workflows/schedule-wakeup-loop.md +37 -0
- package/skills/qbug/SKILL.md +4 -4
- package/skills/qbug/test_qbug_skill_post_fix_audit.py +2 -2
- package/skills/resume-review/SKILL.md +261 -0
- package/skills/bugteam/scripts/README.md +0 -58
- package/skills/bugteam/scripts/_claude_permissions_common.py +0 -219
- package/skills/bugteam/scripts/bugteam_code_rules_gate.py +0 -633
- package/skills/bugteam/scripts/bugteam_fix_hookspath.py +0 -260
- package/skills/bugteam/scripts/bugteam_preflight.py +0 -201
- package/skills/bugteam/scripts/config/bugteam_fix_hookspath_constants.py +0 -17
- package/skills/bugteam/scripts/grant_project_claude_permissions.py +0 -109
- package/skills/bugteam/scripts/revoke_project_claude_permissions.py +0 -135
- package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +0 -271
- package/skills/bugteam/scripts/test_bugteam_fix_hookspath.py +0 -267
- package/skills/bugteam/scripts/test_bugteam_preflight.py +0 -189
- package/skills/bugteam/scripts/test_claude_permissions_common.py +0 -44
- /package/skills/{bugteam → pr-converge}/scripts/config/__init__.py +0 -0
|
@@ -1,633 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import argparse
|
|
4
|
-
import ast
|
|
5
|
-
import importlib.util
|
|
6
|
-
import re
|
|
7
|
-
import subprocess
|
|
8
|
-
import sys
|
|
9
|
-
from pathlib import Path
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
def hunk_header_pattern() -> re.Pattern[str]:
|
|
13
|
-
return re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@")
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
def violation_line_pattern() -> re.Pattern[str]:
|
|
17
|
-
return re.compile(r"^Line (\d+):")
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
def resolve_claude_dev_env_root() -> Path:
|
|
21
|
-
environment_value = (Path(__file__).resolve().parents[3]).resolve()
|
|
22
|
-
return environment_value
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
def load_validate_content():
|
|
26
|
-
package_root = resolve_claude_dev_env_root()
|
|
27
|
-
enforcer_path = package_root / "hooks" / "blocking" / "code_rules_enforcer.py"
|
|
28
|
-
if not enforcer_path.is_file():
|
|
29
|
-
message = f"bugteam_code_rules_gate: missing enforcer at {enforcer_path}"
|
|
30
|
-
print(message, file=sys.stderr)
|
|
31
|
-
raise SystemExit(2)
|
|
32
|
-
specification = importlib.util.spec_from_file_location(
|
|
33
|
-
"code_rules_enforcer",
|
|
34
|
-
enforcer_path,
|
|
35
|
-
)
|
|
36
|
-
if specification is None or specification.loader is None:
|
|
37
|
-
print("bugteam_code_rules_gate: could not load code_rules_enforcer.", file=sys.stderr)
|
|
38
|
-
raise SystemExit(2)
|
|
39
|
-
module = importlib.util.module_from_spec(specification)
|
|
40
|
-
specification.loader.exec_module(module)
|
|
41
|
-
return module.validate_content
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
def resolve_merge_base(repository_root: Path, base_reference: str) -> str:
|
|
45
|
-
merge_result = subprocess.run(
|
|
46
|
-
["git", "merge-base", "HEAD", base_reference],
|
|
47
|
-
cwd=str(repository_root),
|
|
48
|
-
capture_output=True,
|
|
49
|
-
text=True,
|
|
50
|
-
encoding="utf-8",
|
|
51
|
-
errors="replace",
|
|
52
|
-
check=False,
|
|
53
|
-
)
|
|
54
|
-
if merge_result.returncode != 0:
|
|
55
|
-
print(
|
|
56
|
-
f"bugteam_code_rules_gate: git merge-base HEAD {base_reference} failed:\n"
|
|
57
|
-
f"{merge_result.stderr}",
|
|
58
|
-
file=sys.stderr,
|
|
59
|
-
)
|
|
60
|
-
raise SystemExit(2)
|
|
61
|
-
return merge_result.stdout.strip()
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
def filter_paths_under_prefixes(
|
|
65
|
-
file_paths: list[Path],
|
|
66
|
-
repository_root: Path,
|
|
67
|
-
prefix_list: list[str],
|
|
68
|
-
) -> list[Path]:
|
|
69
|
-
if not prefix_list:
|
|
70
|
-
return file_paths
|
|
71
|
-
normalized_prefixes = [
|
|
72
|
-
each.strip().replace("\\", "/").rstrip("/")
|
|
73
|
-
for each in prefix_list
|
|
74
|
-
if each.strip()
|
|
75
|
-
]
|
|
76
|
-
if not normalized_prefixes:
|
|
77
|
-
return file_paths
|
|
78
|
-
resolved_root = repository_root.resolve()
|
|
79
|
-
filtered: list[Path] = []
|
|
80
|
-
for each_path in file_paths:
|
|
81
|
-
try:
|
|
82
|
-
relative_posix = each_path.resolve().relative_to(resolved_root).as_posix()
|
|
83
|
-
except ValueError:
|
|
84
|
-
continue
|
|
85
|
-
if any(
|
|
86
|
-
relative_posix == prefix or relative_posix.startswith(prefix + "/")
|
|
87
|
-
for prefix in normalized_prefixes
|
|
88
|
-
):
|
|
89
|
-
filtered.append(each_path)
|
|
90
|
-
return filtered
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
def paths_from_git_staged(repository_root: Path) -> list[Path]:
|
|
94
|
-
name_result = subprocess.run(
|
|
95
|
-
["git", "diff", "--cached", "--name-only", "-z"],
|
|
96
|
-
cwd=str(repository_root),
|
|
97
|
-
capture_output=True,
|
|
98
|
-
check=False,
|
|
99
|
-
)
|
|
100
|
-
if name_result.returncode != 0:
|
|
101
|
-
stderr_text = name_result.stderr.decode("utf-8", errors="replace")
|
|
102
|
-
print(
|
|
103
|
-
f"bugteam_code_rules_gate: git diff --cached --name-only -z failed:\n{stderr_text}",
|
|
104
|
-
file=sys.stderr,
|
|
105
|
-
)
|
|
106
|
-
raise SystemExit(2)
|
|
107
|
-
raw_paths = name_result.stdout.split(b"\x00")
|
|
108
|
-
resolved_paths = []
|
|
109
|
-
for each_raw_path in raw_paths:
|
|
110
|
-
if not each_raw_path:
|
|
111
|
-
continue
|
|
112
|
-
try:
|
|
113
|
-
relative_path = each_raw_path.decode("utf-8")
|
|
114
|
-
except UnicodeDecodeError:
|
|
115
|
-
print(
|
|
116
|
-
f"bugteam_code_rules_gate: skipping staged path with non-UTF-8 filename: {each_raw_path!r}",
|
|
117
|
-
file=sys.stderr,
|
|
118
|
-
)
|
|
119
|
-
continue
|
|
120
|
-
resolved_paths.append(repository_root / relative_path)
|
|
121
|
-
return resolved_paths
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
def staged_file_line_count(
|
|
125
|
-
repository_root: Path,
|
|
126
|
-
relative_path_posix: str,
|
|
127
|
-
) -> int:
|
|
128
|
-
show_result = subprocess.run(
|
|
129
|
-
["git", "show", f":{relative_path_posix}"],
|
|
130
|
-
cwd=str(repository_root),
|
|
131
|
-
capture_output=True,
|
|
132
|
-
text=True,
|
|
133
|
-
encoding="utf-8",
|
|
134
|
-
errors="replace",
|
|
135
|
-
check=False,
|
|
136
|
-
)
|
|
137
|
-
if show_result.returncode != 0:
|
|
138
|
-
return 0
|
|
139
|
-
staged_content = show_result.stdout
|
|
140
|
-
if not staged_content:
|
|
141
|
-
return 0
|
|
142
|
-
return len(staged_content.splitlines())
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
def is_staged_file_newly_added(
|
|
146
|
-
repository_root: Path,
|
|
147
|
-
relative_path_posix: str,
|
|
148
|
-
) -> bool:
|
|
149
|
-
status_result = subprocess.run(
|
|
150
|
-
["git", "diff", "--cached", "--name-status", "--", relative_path_posix],
|
|
151
|
-
cwd=str(repository_root),
|
|
152
|
-
capture_output=True,
|
|
153
|
-
text=True,
|
|
154
|
-
encoding="utf-8",
|
|
155
|
-
errors="replace",
|
|
156
|
-
check=False,
|
|
157
|
-
)
|
|
158
|
-
if status_result.returncode != 0:
|
|
159
|
-
return False
|
|
160
|
-
for each_line in status_result.stdout.splitlines():
|
|
161
|
-
stripped_line = each_line.strip()
|
|
162
|
-
if stripped_line:
|
|
163
|
-
return stripped_line.startswith("A")
|
|
164
|
-
return False
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
def added_lines_for_staged_file(
|
|
168
|
-
repository_root: Path,
|
|
169
|
-
relative_path_posix: str,
|
|
170
|
-
) -> set[int]:
|
|
171
|
-
diff_result = subprocess.run(
|
|
172
|
-
["git", "diff", "--cached", "--unified=0", "--", relative_path_posix],
|
|
173
|
-
cwd=str(repository_root),
|
|
174
|
-
capture_output=True,
|
|
175
|
-
text=True,
|
|
176
|
-
encoding="utf-8",
|
|
177
|
-
errors="replace",
|
|
178
|
-
check=False,
|
|
179
|
-
)
|
|
180
|
-
if diff_result.returncode != 0:
|
|
181
|
-
print(
|
|
182
|
-
f"bugteam_code_rules_gate: git diff --cached --unified=0 failed for {relative_path_posix}:\n"
|
|
183
|
-
f"{diff_result.stderr}",
|
|
184
|
-
file=sys.stderr,
|
|
185
|
-
)
|
|
186
|
-
raise SystemExit(2)
|
|
187
|
-
if diff_result.stdout.strip():
|
|
188
|
-
return parse_added_line_numbers(diff_result.stdout)
|
|
189
|
-
if is_staged_file_newly_added(repository_root, relative_path_posix):
|
|
190
|
-
total_lines = staged_file_line_count(repository_root, relative_path_posix)
|
|
191
|
-
if total_lines > 0:
|
|
192
|
-
return set(range(1, total_lines + 1))
|
|
193
|
-
return set()
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
def added_lines_by_file_staged(
|
|
197
|
-
repository_root: Path,
|
|
198
|
-
file_paths: list[Path],
|
|
199
|
-
) -> dict[Path, set[int]]:
|
|
200
|
-
resolved_root = repository_root.resolve()
|
|
201
|
-
added_by_path: dict[Path, set[int]] = {}
|
|
202
|
-
for each_path in file_paths:
|
|
203
|
-
try:
|
|
204
|
-
resolved = each_path.resolve()
|
|
205
|
-
except OSError:
|
|
206
|
-
continue
|
|
207
|
-
try:
|
|
208
|
-
relative = resolved.relative_to(resolved_root)
|
|
209
|
-
except ValueError:
|
|
210
|
-
continue
|
|
211
|
-
relative_posix = str(relative).replace("\\", "/")
|
|
212
|
-
added_numbers = added_lines_for_staged_file(resolved_root, relative_posix)
|
|
213
|
-
added_by_path[resolved] = added_numbers
|
|
214
|
-
return added_by_path
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
def paths_from_git_diff(repository_root: Path, base_reference: str) -> list[Path]:
|
|
218
|
-
merge_base = resolve_merge_base(repository_root, base_reference)
|
|
219
|
-
name_result = subprocess.run(
|
|
220
|
-
["git", "diff", "--name-only", f"{merge_base}..HEAD"],
|
|
221
|
-
cwd=str(repository_root),
|
|
222
|
-
capture_output=True,
|
|
223
|
-
text=True,
|
|
224
|
-
encoding="utf-8",
|
|
225
|
-
errors="replace",
|
|
226
|
-
check=False,
|
|
227
|
-
)
|
|
228
|
-
if name_result.returncode != 0:
|
|
229
|
-
print(
|
|
230
|
-
f"bugteam_code_rules_gate: git diff --name-only failed:\n{name_result.stderr}",
|
|
231
|
-
file=sys.stderr,
|
|
232
|
-
)
|
|
233
|
-
raise SystemExit(2)
|
|
234
|
-
relative_paths = [line.strip() for line in name_result.stdout.splitlines() if line.strip()]
|
|
235
|
-
return [repository_root / relative_path for relative_path in relative_paths]
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
def is_code_path(file_path: Path) -> bool:
|
|
239
|
-
suffix = file_path.suffix.lower()
|
|
240
|
-
return suffix in {".py", ".js", ".ts", ".tsx", ".jsx"}
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
def check_database_column_string_magic(content: str, file_path: str) -> list[str]:
|
|
244
|
-
"""Flag string literals that look like database/HTTP column or key names inside function bodies.
|
|
245
|
-
|
|
246
|
-
Triggers when a snake_case string literal appears as the first element of a
|
|
247
|
-
two-element tuple inside a function body (the characteristic column-name/value
|
|
248
|
-
pair pattern). Files under ``config/`` and test files are exempt.
|
|
249
|
-
"""
|
|
250
|
-
if "/config/" in file_path.replace("\\", "/") or "\\config\\" in file_path:
|
|
251
|
-
return []
|
|
252
|
-
if "/tests/" in file_path.replace("\\", "/") or file_path.endswith(("_test.py", ".spec.py")):
|
|
253
|
-
return []
|
|
254
|
-
try:
|
|
255
|
-
tree = ast.parse(content)
|
|
256
|
-
except SyntaxError:
|
|
257
|
-
return []
|
|
258
|
-
issues: list[str] = []
|
|
259
|
-
column_key_pattern = re.compile(r"^[a-z][a-z0-9_]{2,}$")
|
|
260
|
-
for node in ast.walk(tree):
|
|
261
|
-
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
262
|
-
continue
|
|
263
|
-
for each_child in ast.walk(node):
|
|
264
|
-
if not isinstance(each_child, ast.Tuple):
|
|
265
|
-
continue
|
|
266
|
-
if len(each_child.elts) != 2:
|
|
267
|
-
continue
|
|
268
|
-
first_element = each_child.elts[0]
|
|
269
|
-
if not isinstance(first_element, ast.Constant):
|
|
270
|
-
continue
|
|
271
|
-
if not isinstance(first_element.value, str):
|
|
272
|
-
continue
|
|
273
|
-
literal_text = first_element.value
|
|
274
|
-
if not column_key_pattern.match(literal_text):
|
|
275
|
-
continue
|
|
276
|
-
if literal_text in {"true", "false", "none", "null"}:
|
|
277
|
-
continue
|
|
278
|
-
issues.append(
|
|
279
|
-
f"Line {first_element.lineno}: Column-name string magic {literal_text!r} - extract to config"
|
|
280
|
-
)
|
|
281
|
-
if len(issues) >= 3:
|
|
282
|
-
return issues
|
|
283
|
-
return issues
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
def check_wrapper_plumb_through(content: str, file_path: str) -> list[str]:
|
|
287
|
-
"""Flag public wrappers that drop optional kwargs of a same-file delegate.
|
|
288
|
-
|
|
289
|
-
Walks the AST. For every public function (name does not start with '_'),
|
|
290
|
-
if its body contains exactly one direct call to another same-file
|
|
291
|
-
function and that delegate's signature accepts optional kwargs that the
|
|
292
|
-
wrapper does not also accept, emit a finding with both line numbers.
|
|
293
|
-
"""
|
|
294
|
-
if file_path.endswith((".js", ".ts", ".tsx", ".jsx")):
|
|
295
|
-
return []
|
|
296
|
-
try:
|
|
297
|
-
tree = ast.parse(content)
|
|
298
|
-
except SyntaxError:
|
|
299
|
-
return []
|
|
300
|
-
function_signatures: dict[str, set[str]] = {}
|
|
301
|
-
for node in ast.walk(tree):
|
|
302
|
-
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
303
|
-
optional_kwargs: set[str] = set()
|
|
304
|
-
for each_kwonly, each_default in zip(node.args.kwonlyargs, node.args.kw_defaults):
|
|
305
|
-
if each_default is not None:
|
|
306
|
-
optional_kwargs.add(each_kwonly.arg)
|
|
307
|
-
function_signatures[node.name] = optional_kwargs
|
|
308
|
-
issues: list[str] = []
|
|
309
|
-
for node in ast.walk(tree):
|
|
310
|
-
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
311
|
-
continue
|
|
312
|
-
if node.name.startswith("_"):
|
|
313
|
-
continue
|
|
314
|
-
wrapper_kwargs = function_signatures.get(node.name, set())
|
|
315
|
-
for each_call in ast.walk(node):
|
|
316
|
-
if not isinstance(each_call, ast.Call):
|
|
317
|
-
continue
|
|
318
|
-
if not isinstance(each_call.func, ast.Attribute):
|
|
319
|
-
continue
|
|
320
|
-
delegate_name = each_call.func.attr
|
|
321
|
-
delegate_kwargs = function_signatures.get(delegate_name)
|
|
322
|
-
if delegate_kwargs is None:
|
|
323
|
-
continue
|
|
324
|
-
missing = delegate_kwargs - wrapper_kwargs
|
|
325
|
-
if missing:
|
|
326
|
-
issues.append(
|
|
327
|
-
f"Line {node.lineno}: Wrapper {node.name!r} drops optional kwargs {sorted(missing)!r} of delegate {delegate_name!r}"
|
|
328
|
-
)
|
|
329
|
-
if len(issues) >= 3:
|
|
330
|
-
return issues
|
|
331
|
-
return issues
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
def parse_added_line_numbers(unified_diff_text: str) -> set[int]:
|
|
335
|
-
header_regex = hunk_header_pattern()
|
|
336
|
-
added_line_numbers: set[int] = set()
|
|
337
|
-
for each_line in unified_diff_text.splitlines():
|
|
338
|
-
header_match = header_regex.match(each_line)
|
|
339
|
-
if header_match is None:
|
|
340
|
-
continue
|
|
341
|
-
new_start_text, new_count_text = header_match.groups()
|
|
342
|
-
new_start = int(new_start_text)
|
|
343
|
-
new_count = 1 if new_count_text is None else int(new_count_text)
|
|
344
|
-
if new_count <= 0:
|
|
345
|
-
continue
|
|
346
|
-
for each_number in range(new_start, new_start + new_count):
|
|
347
|
-
added_line_numbers.add(each_number)
|
|
348
|
-
return added_line_numbers
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
def is_file_new_at_base(
|
|
352
|
-
repository_root: Path,
|
|
353
|
-
merge_base: str,
|
|
354
|
-
relative_path_posix: str,
|
|
355
|
-
) -> bool:
|
|
356
|
-
cat_result = subprocess.run(
|
|
357
|
-
["git", "cat-file", "-e", f"{merge_base}:{relative_path_posix}"],
|
|
358
|
-
cwd=str(repository_root),
|
|
359
|
-
capture_output=True,
|
|
360
|
-
text=True,
|
|
361
|
-
encoding="utf-8",
|
|
362
|
-
errors="replace",
|
|
363
|
-
check=False,
|
|
364
|
-
)
|
|
365
|
-
return cat_result.returncode != 0
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
def added_lines_for_file(
|
|
369
|
-
repository_root: Path,
|
|
370
|
-
merge_base: str,
|
|
371
|
-
relative_path_posix: str,
|
|
372
|
-
) -> set[int]:
|
|
373
|
-
diff_result = subprocess.run(
|
|
374
|
-
["git", "diff", "--unified=0", f"{merge_base}..HEAD", "--", relative_path_posix],
|
|
375
|
-
cwd=str(repository_root),
|
|
376
|
-
capture_output=True,
|
|
377
|
-
text=True,
|
|
378
|
-
encoding="utf-8",
|
|
379
|
-
errors="replace",
|
|
380
|
-
check=False,
|
|
381
|
-
)
|
|
382
|
-
if diff_result.returncode != 0:
|
|
383
|
-
print(
|
|
384
|
-
f"bugteam_code_rules_gate: git diff --unified=0 failed for {relative_path_posix}:\n"
|
|
385
|
-
f"{diff_result.stderr}",
|
|
386
|
-
file=sys.stderr,
|
|
387
|
-
)
|
|
388
|
-
raise SystemExit(2)
|
|
389
|
-
if not diff_result.stdout.strip():
|
|
390
|
-
return set()
|
|
391
|
-
return parse_added_line_numbers(diff_result.stdout)
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
def whole_file_line_set(file_path: Path) -> set[int]:
|
|
395
|
-
try:
|
|
396
|
-
total_lines = len(file_path.read_text().splitlines())
|
|
397
|
-
except OSError:
|
|
398
|
-
return set()
|
|
399
|
-
if total_lines <= 0:
|
|
400
|
-
return set()
|
|
401
|
-
return set(range(1, total_lines + 1))
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
def added_lines_by_file(
|
|
405
|
-
repository_root: Path,
|
|
406
|
-
base_reference: str,
|
|
407
|
-
file_paths: list[Path],
|
|
408
|
-
) -> dict[Path, set[int]]:
|
|
409
|
-
merge_base = resolve_merge_base(repository_root, base_reference)
|
|
410
|
-
resolved_root = repository_root.resolve()
|
|
411
|
-
added_by_path: dict[Path, set[int]] = {}
|
|
412
|
-
for each_path in file_paths:
|
|
413
|
-
try:
|
|
414
|
-
resolved = each_path.resolve()
|
|
415
|
-
except OSError:
|
|
416
|
-
continue
|
|
417
|
-
try:
|
|
418
|
-
relative = resolved.relative_to(resolved_root)
|
|
419
|
-
except ValueError:
|
|
420
|
-
continue
|
|
421
|
-
relative_posix = str(relative).replace("\\", "/")
|
|
422
|
-
added_numbers = added_lines_for_file(resolved_root, merge_base, relative_posix)
|
|
423
|
-
if not added_numbers and resolved.is_file():
|
|
424
|
-
if is_file_new_at_base(resolved_root, merge_base, relative_posix):
|
|
425
|
-
added_numbers = whole_file_line_set(resolved)
|
|
426
|
-
added_by_path[resolved] = added_numbers
|
|
427
|
-
return added_by_path
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
def extract_violation_line_number(violation_text: str) -> int | None:
|
|
431
|
-
match_result = violation_line_pattern().match(violation_text)
|
|
432
|
-
if match_result is None:
|
|
433
|
-
return None
|
|
434
|
-
return int(match_result.group(1))
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
def split_violations_by_scope(
|
|
438
|
-
issues: list[str],
|
|
439
|
-
added_line_numbers: set[int] | None,
|
|
440
|
-
) -> tuple[list[str], list[str]]:
|
|
441
|
-
if added_line_numbers is None:
|
|
442
|
-
return list(issues), []
|
|
443
|
-
blocking: list[str] = []
|
|
444
|
-
advisory: list[str] = []
|
|
445
|
-
for each_issue in issues:
|
|
446
|
-
violation_line = extract_violation_line_number(each_issue)
|
|
447
|
-
if violation_line is None:
|
|
448
|
-
blocking.append(each_issue)
|
|
449
|
-
continue
|
|
450
|
-
if violation_line in added_line_numbers:
|
|
451
|
-
blocking.append(each_issue)
|
|
452
|
-
else:
|
|
453
|
-
advisory.append(each_issue)
|
|
454
|
-
return blocking, advisory
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
def print_violation_section(
|
|
458
|
-
header_message: str,
|
|
459
|
-
violations_by_file: dict[Path, list[str]],
|
|
460
|
-
repository_root: Path,
|
|
461
|
-
) -> None:
|
|
462
|
-
print(header_message, file=sys.stderr)
|
|
463
|
-
resolved_root = repository_root.resolve()
|
|
464
|
-
for each_path in sorted(violations_by_file.keys()):
|
|
465
|
-
relative = each_path.relative_to(resolved_root)
|
|
466
|
-
print(f"{relative}:", file=sys.stderr)
|
|
467
|
-
for each_issue in violations_by_file[each_path]:
|
|
468
|
-
print(f" {each_issue}", file=sys.stderr)
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
def run_gate(
|
|
472
|
-
validate_content,
|
|
473
|
-
file_paths: list[Path],
|
|
474
|
-
repository_root: Path,
|
|
475
|
-
added_lines_map: dict[Path, set[int]] | None = None,
|
|
476
|
-
) -> int:
|
|
477
|
-
blocking_by_file: dict[Path, list[str]] = {}
|
|
478
|
-
advisory_by_file: dict[Path, list[str]] = {}
|
|
479
|
-
for file_path in sorted(set(file_paths)):
|
|
480
|
-
try:
|
|
481
|
-
resolved = file_path.resolve()
|
|
482
|
-
except OSError:
|
|
483
|
-
continue
|
|
484
|
-
try:
|
|
485
|
-
resolved.relative_to(repository_root.resolve())
|
|
486
|
-
except ValueError:
|
|
487
|
-
continue
|
|
488
|
-
if not is_code_path(resolved):
|
|
489
|
-
continue
|
|
490
|
-
if not resolved.is_file():
|
|
491
|
-
continue
|
|
492
|
-
try:
|
|
493
|
-
content = resolved.read_text(encoding="utf-8")
|
|
494
|
-
except OSError:
|
|
495
|
-
print(f"bugteam_code_rules_gate: skip unreadable {resolved}", file=sys.stderr)
|
|
496
|
-
continue
|
|
497
|
-
relative = resolved.relative_to(repository_root.resolve())
|
|
498
|
-
issues = validate_content(content, str(relative).replace("\\", "/"), old_content=content)
|
|
499
|
-
issues.extend(check_database_column_string_magic(content, str(relative).replace("\\", "/")))
|
|
500
|
-
issues.extend(check_wrapper_plumb_through(content, str(relative).replace("\\", "/")))
|
|
501
|
-
if not issues:
|
|
502
|
-
continue
|
|
503
|
-
added_for_file = None if added_lines_map is None else added_lines_map.get(resolved)
|
|
504
|
-
blocking, advisory = split_violations_by_scope(issues, added_for_file)
|
|
505
|
-
if blocking:
|
|
506
|
-
blocking_by_file[resolved] = blocking
|
|
507
|
-
if advisory:
|
|
508
|
-
advisory_by_file[resolved] = advisory
|
|
509
|
-
blocking_count = sum(len(each_list) for each_list in blocking_by_file.values())
|
|
510
|
-
advisory_count = sum(len(each_list) for each_list in advisory_by_file.values())
|
|
511
|
-
if blocking_count:
|
|
512
|
-
if added_lines_map is None:
|
|
513
|
-
header = f"bugteam_code_rules_gate: {blocking_count} violation(s) reported."
|
|
514
|
-
else:
|
|
515
|
-
header = (
|
|
516
|
-
f"bugteam_code_rules_gate: {blocking_count} violation(s) "
|
|
517
|
-
"introduced on changed lines:"
|
|
518
|
-
)
|
|
519
|
-
print_violation_section(
|
|
520
|
-
header,
|
|
521
|
-
blocking_by_file,
|
|
522
|
-
repository_root,
|
|
523
|
-
)
|
|
524
|
-
if advisory_count:
|
|
525
|
-
if blocking_count:
|
|
526
|
-
print("", file=sys.stderr)
|
|
527
|
-
print_violation_section(
|
|
528
|
-
(
|
|
529
|
-
f"bugteam_code_rules_gate: {advisory_count} pre-existing violation(s) "
|
|
530
|
-
"in touched files (advisory, not blocking):"
|
|
531
|
-
),
|
|
532
|
-
advisory_by_file,
|
|
533
|
-
repository_root,
|
|
534
|
-
)
|
|
535
|
-
if blocking_count:
|
|
536
|
-
return 1
|
|
537
|
-
return 0
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
def parse_arguments(argv: list[str]) -> argparse.Namespace:
|
|
541
|
-
parser = argparse.ArgumentParser(
|
|
542
|
-
description=(
|
|
543
|
-
"Run CODE_RULES validators (validate_content) on files in the working tree. "
|
|
544
|
-
"Default file set: git diff --name-only merge-base(base)..HEAD."
|
|
545
|
-
),
|
|
546
|
-
)
|
|
547
|
-
parser.add_argument(
|
|
548
|
-
"--repo-root",
|
|
549
|
-
type=Path,
|
|
550
|
-
default=None,
|
|
551
|
-
help="Repository root (default: cwd).",
|
|
552
|
-
)
|
|
553
|
-
parser.add_argument(
|
|
554
|
-
"--base",
|
|
555
|
-
default="origin/main",
|
|
556
|
-
help="Merge-base ref for git diff (default: origin/main).",
|
|
557
|
-
)
|
|
558
|
-
parser.add_argument(
|
|
559
|
-
"--staged",
|
|
560
|
-
action="store_true",
|
|
561
|
-
default=False,
|
|
562
|
-
help=(
|
|
563
|
-
"Scope to staged changes only (git diff --cached). "
|
|
564
|
-
"Blocks on violations introduced on staged-added lines; "
|
|
565
|
-
"reports pre-existing violations in touched files as advisory."
|
|
566
|
-
),
|
|
567
|
-
)
|
|
568
|
-
parser.add_argument(
|
|
569
|
-
"--only-under",
|
|
570
|
-
action="append",
|
|
571
|
-
default=[],
|
|
572
|
-
dest="only_under",
|
|
573
|
-
metavar="PREFIX",
|
|
574
|
-
help=(
|
|
575
|
-
"After resolving the merge-base diff, keep only files whose repo-relative path "
|
|
576
|
-
"uses POSIX slashes and starts with PREFIX or equals PREFIX (repeatable)."
|
|
577
|
-
),
|
|
578
|
-
)
|
|
579
|
-
parser.add_argument(
|
|
580
|
-
"paths",
|
|
581
|
-
nargs="*",
|
|
582
|
-
type=Path,
|
|
583
|
-
help="Optional explicit files; if set, git diff is not used.",
|
|
584
|
-
)
|
|
585
|
-
return parser.parse_args(argv)
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
def main(argv: list[str] | None = None) -> int:
|
|
589
|
-
arguments = parse_arguments(sys.argv[1:] if argv is None else argv)
|
|
590
|
-
repository_root = (
|
|
591
|
-
arguments.repo_root.resolve()
|
|
592
|
-
if arguments.repo_root is not None
|
|
593
|
-
else Path.cwd().resolve()
|
|
594
|
-
)
|
|
595
|
-
validate_content = load_validate_content()
|
|
596
|
-
if arguments.paths:
|
|
597
|
-
file_paths = [repository_root / path for path in arguments.paths]
|
|
598
|
-
return run_gate(validate_content, file_paths, repository_root, added_lines_map=None)
|
|
599
|
-
if arguments.staged:
|
|
600
|
-
staged_file_paths = paths_from_git_staged(repository_root)
|
|
601
|
-
staged_file_paths = filter_paths_under_prefixes(
|
|
602
|
-
staged_file_paths,
|
|
603
|
-
repository_root,
|
|
604
|
-
arguments.only_under,
|
|
605
|
-
)
|
|
606
|
-
if not staged_file_paths:
|
|
607
|
-
return 0
|
|
608
|
-
staged_added_lines = added_lines_by_file_staged(repository_root, staged_file_paths)
|
|
609
|
-
return run_gate(
|
|
610
|
-
validate_content,
|
|
611
|
-
staged_file_paths,
|
|
612
|
-
repository_root,
|
|
613
|
-
added_lines_map=staged_added_lines,
|
|
614
|
-
)
|
|
615
|
-
file_paths = paths_from_git_diff(repository_root, arguments.base)
|
|
616
|
-
file_paths = filter_paths_under_prefixes(
|
|
617
|
-
file_paths,
|
|
618
|
-
repository_root,
|
|
619
|
-
arguments.only_under,
|
|
620
|
-
)
|
|
621
|
-
if not file_paths:
|
|
622
|
-
return 0
|
|
623
|
-
scoped_added_lines = added_lines_by_file(repository_root, arguments.base, file_paths)
|
|
624
|
-
return run_gate(
|
|
625
|
-
validate_content,
|
|
626
|
-
file_paths,
|
|
627
|
-
repository_root,
|
|
628
|
-
added_lines_map=scoped_added_lines,
|
|
629
|
-
)
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
if __name__ == "__main__":
|
|
633
|
-
raise SystemExit(main())
|