claude-dev-env 1.36.0 → 1.36.2
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/_shared/pr-loop/audit-contract.md +159 -0
- package/_shared/pr-loop/code-rules-gate.md +64 -0
- package/_shared/pr-loop/fix-protocol.md +37 -0
- package/_shared/pr-loop/gh-payloads.md +85 -0
- package/_shared/pr-loop/scripts/README.md +20 -0
- package/_shared/pr-loop/scripts/_claude_permissions_common.py +234 -0
- package/_shared/pr-loop/scripts/code_rules_gate.py +975 -0
- package/_shared/pr-loop/scripts/config/__init__.py +0 -0
- package/_shared/pr-loop/scripts/config/claude_permissions_constants.py +36 -0
- package/_shared/pr-loop/scripts/config/claude_settings_keys_constants.py +11 -0
- package/_shared/pr-loop/scripts/config/code_rules_gate_constants.py +56 -0
- package/_shared/pr-loop/scripts/config/fix_hookspath_constants.py +25 -0
- package/_shared/pr-loop/scripts/config/gh_util_constants.py +31 -0
- package/_shared/pr-loop/scripts/config/preflight_constants.py +47 -0
- package/_shared/pr-loop/scripts/fix_hookspath.py +260 -0
- package/_shared/pr-loop/scripts/gh_util.py +193 -0
- package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +130 -0
- package/_shared/pr-loop/scripts/preflight.py +227 -0
- package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +156 -0
- package/_shared/pr-loop/scripts/tests/conftest.py +51 -0
- package/_shared/pr-loop/scripts/tests/test__claude_permissions_common.py +135 -0
- package/_shared/pr-loop/scripts/tests/test_claude_permissions_common.py +169 -0
- package/_shared/pr-loop/scripts/tests/test_claude_permissions_constants.py +58 -0
- package/_shared/pr-loop/scripts/tests/test_claude_settings_keys_constants.py +50 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +917 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate_constants.py +102 -0
- package/_shared/pr-loop/scripts/tests/test_fix_hookspath.py +374 -0
- package/_shared/pr-loop/scripts/tests/test_fix_hookspath_constants.py +47 -0
- package/_shared/pr-loop/scripts/tests/test_gh_util.py +257 -0
- package/_shared/pr-loop/scripts/tests/test_gh_util_constants.py +61 -0
- package/_shared/pr-loop/scripts/tests/test_grant_project_claude_permissions.py +49 -0
- package/_shared/pr-loop/scripts/tests/test_preflight.py +333 -0
- package/_shared/pr-loop/scripts/tests/test_preflight_constants.py +82 -0
- package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +49 -0
- package/_shared/pr-loop/state-schema.md +81 -0
- package/package.json +2 -1
- package/skills/bugteam/SKILL.md +332 -108
- package/skills/bugteam/scripts/reflow_skill_md.py +298 -0
- package/skills/bugteam/test_team_lifecycle.py +9 -0
- package/skills/pr-converge/SKILL.md +1005 -395
- package/skills/pr-converge/scripts/reflow_skill_md.py +288 -0
- package/skills/pr-converge/test_team_lifecycle.py +9 -0
|
@@ -0,0 +1,975 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import ast
|
|
3
|
+
import importlib.util
|
|
4
|
+
import re
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
from collections.abc import Callable, Iterator
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
sys.modules.pop("config", None)
|
|
11
|
+
if str(Path(__file__).resolve().parent) not in sys.path:
|
|
12
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
13
|
+
|
|
14
|
+
from config.code_rules_gate_constants import (
|
|
15
|
+
ALL_CODE_FILE_EXTENSIONS,
|
|
16
|
+
ALL_GIT_DIFF_CACHED_NAME_ONLY_NULL_TERMINATED_COMMAND,
|
|
17
|
+
ALL_GIT_DIFF_NAME_ONLY_NULL_TERMINATED_COMMAND_PREFIX,
|
|
18
|
+
ALL_LITERAL_KEYWORD_EXEMPTIONS,
|
|
19
|
+
ALL_TEST_FILENAME_GLOB_SUFFIXES,
|
|
20
|
+
ALL_TEST_FILENAME_SUFFIXES,
|
|
21
|
+
COLUMN_KEY_PATTERN_TEMPLATE,
|
|
22
|
+
CONFIG_PATH_SEGMENT,
|
|
23
|
+
EXPECTED_NON_RENAME_COLUMN_COUNT,
|
|
24
|
+
EXPECTED_RENAME_COLUMN_COUNT,
|
|
25
|
+
EXPECTED_TUPLE_PAIR_LENGTH,
|
|
26
|
+
GIT_NAME_STATUS_ADDED_PREFIX,
|
|
27
|
+
GIT_NAME_STATUS_RENAMED_PREFIX,
|
|
28
|
+
MAX_VIOLATIONS_PER_CHECK,
|
|
29
|
+
MINIMUM_COLUMN_NAME_LENGTH_AFTER_FIRST_CHAR,
|
|
30
|
+
PYTHON_FILE_EXTENSION,
|
|
31
|
+
TEST_CONFTEST_FILENAME,
|
|
32
|
+
TEST_FILENAME_PREFIX,
|
|
33
|
+
TESTS_PATH_SEGMENT,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
ValidateContentCallable = Callable[[str, str, str], list[str]]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def hunk_header_pattern() -> re.Pattern[str]:
|
|
41
|
+
return re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def violation_line_pattern() -> re.Pattern[str]:
|
|
45
|
+
return re.compile(r"^Line (\d+):")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def resolve_claude_dev_env_root(starting_path: Path) -> Path:
|
|
49
|
+
starting = Path(starting_path).resolve()
|
|
50
|
+
enforcer_relative = Path("hooks") / "blocking" / "code_rules_enforcer.py"
|
|
51
|
+
for each_candidate in [starting, *starting.parents]:
|
|
52
|
+
if (each_candidate / enforcer_relative).is_file():
|
|
53
|
+
return each_candidate
|
|
54
|
+
print(
|
|
55
|
+
f"code_rules_gate: could not locate {enforcer_relative} above {starting}",
|
|
56
|
+
file=sys.stderr,
|
|
57
|
+
)
|
|
58
|
+
raise SystemExit(2)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _resolve_package_root_absolute(starting_path: Path) -> Path:
|
|
62
|
+
enforcer_relative = Path("hooks") / "blocking" / "code_rules_enforcer.py"
|
|
63
|
+
for each_starting_form in (
|
|
64
|
+
Path(starting_path).absolute(),
|
|
65
|
+
Path(starting_path).resolve(),
|
|
66
|
+
):
|
|
67
|
+
for each_candidate in [each_starting_form, *each_starting_form.parents]:
|
|
68
|
+
if (each_candidate / enforcer_relative).is_file():
|
|
69
|
+
return each_candidate
|
|
70
|
+
raise SystemExit(2)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def load_validate_content() -> ValidateContentCallable:
|
|
74
|
+
package_root = resolve_claude_dev_env_root(Path(__file__).resolve())
|
|
75
|
+
enforcer_path = package_root / "hooks" / "blocking" / "code_rules_enforcer.py"
|
|
76
|
+
if not enforcer_path.is_file():
|
|
77
|
+
message = f"code_rules_gate: missing enforcer at {enforcer_path}"
|
|
78
|
+
print(message, file=sys.stderr)
|
|
79
|
+
raise SystemExit(2)
|
|
80
|
+
specification = importlib.util.spec_from_file_location(
|
|
81
|
+
"code_rules_enforcer",
|
|
82
|
+
enforcer_path,
|
|
83
|
+
)
|
|
84
|
+
if specification is None or specification.loader is None:
|
|
85
|
+
print("code_rules_gate: could not load code_rules_enforcer.", file=sys.stderr)
|
|
86
|
+
raise SystemExit(2)
|
|
87
|
+
module = importlib.util.module_from_spec(specification)
|
|
88
|
+
package_root_for_imports = _resolve_package_root_absolute(Path(__file__).absolute())
|
|
89
|
+
hooks_root_path = str(package_root_for_imports / "hooks")
|
|
90
|
+
while hooks_root_path in sys.path:
|
|
91
|
+
sys.path.remove(hooks_root_path)
|
|
92
|
+
sys.path.insert(0, hooks_root_path)
|
|
93
|
+
saved_config_modules = {
|
|
94
|
+
each_module_name: sys.modules.pop(each_module_name)
|
|
95
|
+
for each_module_name in [
|
|
96
|
+
each_key for each_key in list(sys.modules)
|
|
97
|
+
if each_key == "config" or each_key.startswith("config.")
|
|
98
|
+
]
|
|
99
|
+
}
|
|
100
|
+
try:
|
|
101
|
+
specification.loader.exec_module(module)
|
|
102
|
+
finally:
|
|
103
|
+
while hooks_root_path in sys.path:
|
|
104
|
+
sys.path.remove(hooks_root_path)
|
|
105
|
+
for each_module_name in [
|
|
106
|
+
each_key for each_key in list(sys.modules)
|
|
107
|
+
if each_key == "config" or each_key.startswith("config.")
|
|
108
|
+
]:
|
|
109
|
+
sys.modules.pop(each_module_name, None)
|
|
110
|
+
sys.modules.update(saved_config_modules)
|
|
111
|
+
return module.validate_content
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def resolve_merge_base(repository_root: Path, base_reference: str) -> str:
|
|
115
|
+
merge_result = subprocess.run(
|
|
116
|
+
["git", "merge-base", "HEAD", base_reference],
|
|
117
|
+
cwd=str(repository_root),
|
|
118
|
+
capture_output=True,
|
|
119
|
+
text=True,
|
|
120
|
+
encoding="utf-8",
|
|
121
|
+
errors="replace",
|
|
122
|
+
check=False,
|
|
123
|
+
)
|
|
124
|
+
if merge_result.returncode != 0:
|
|
125
|
+
print(
|
|
126
|
+
f"code_rules_gate: git merge-base HEAD {base_reference} failed:\n"
|
|
127
|
+
f"{merge_result.stderr}",
|
|
128
|
+
file=sys.stderr,
|
|
129
|
+
)
|
|
130
|
+
raise SystemExit(2)
|
|
131
|
+
return merge_result.stdout.strip()
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def filter_paths_under_prefixes(
|
|
135
|
+
all_file_paths: list[Path],
|
|
136
|
+
repository_root: Path,
|
|
137
|
+
all_prefixes: list[str],
|
|
138
|
+
) -> list[Path]:
|
|
139
|
+
if not all_prefixes:
|
|
140
|
+
return all_file_paths
|
|
141
|
+
normalized_prefixes = [
|
|
142
|
+
each_prefix.strip().replace("\\", "/").rstrip("/")
|
|
143
|
+
for each_prefix in all_prefixes
|
|
144
|
+
if each_prefix.strip()
|
|
145
|
+
]
|
|
146
|
+
if not normalized_prefixes:
|
|
147
|
+
return all_file_paths
|
|
148
|
+
resolved_root = repository_root.resolve()
|
|
149
|
+
filtered: list[Path] = []
|
|
150
|
+
for each_path in all_file_paths:
|
|
151
|
+
try:
|
|
152
|
+
relative_posix = each_path.resolve().relative_to(resolved_root).as_posix()
|
|
153
|
+
except ValueError:
|
|
154
|
+
continue
|
|
155
|
+
if any(
|
|
156
|
+
relative_posix == each_prefix
|
|
157
|
+
or relative_posix.startswith(each_prefix + "/")
|
|
158
|
+
for each_prefix in normalized_prefixes
|
|
159
|
+
):
|
|
160
|
+
filtered.append(each_path)
|
|
161
|
+
return filtered
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def paths_from_git_staged(repository_root: Path) -> list[Path]:
|
|
165
|
+
name_result = subprocess.run(
|
|
166
|
+
list(ALL_GIT_DIFF_CACHED_NAME_ONLY_NULL_TERMINATED_COMMAND),
|
|
167
|
+
cwd=str(repository_root),
|
|
168
|
+
capture_output=True,
|
|
169
|
+
check=False,
|
|
170
|
+
)
|
|
171
|
+
if name_result.returncode != 0:
|
|
172
|
+
stderr_text = name_result.stderr.decode("utf-8", errors="replace")
|
|
173
|
+
print(
|
|
174
|
+
f"code_rules_gate: git diff --cached --name-only -z failed:\n{stderr_text}",
|
|
175
|
+
file=sys.stderr,
|
|
176
|
+
)
|
|
177
|
+
raise SystemExit(2)
|
|
178
|
+
raw_paths = name_result.stdout.split(b"\x00")
|
|
179
|
+
resolved_paths = []
|
|
180
|
+
for each_raw_path in raw_paths:
|
|
181
|
+
if not each_raw_path:
|
|
182
|
+
continue
|
|
183
|
+
try:
|
|
184
|
+
relative_path = each_raw_path.decode("utf-8")
|
|
185
|
+
except UnicodeDecodeError:
|
|
186
|
+
print(
|
|
187
|
+
f"code_rules_gate: skipping staged path with non-UTF-8 filename: {each_raw_path!r}",
|
|
188
|
+
file=sys.stderr,
|
|
189
|
+
)
|
|
190
|
+
continue
|
|
191
|
+
resolved_paths.append(repository_root / relative_path)
|
|
192
|
+
return resolved_paths
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def staged_file_line_count(
|
|
196
|
+
repository_root: Path,
|
|
197
|
+
relative_path_posix: str,
|
|
198
|
+
) -> int:
|
|
199
|
+
show_result = subprocess.run(
|
|
200
|
+
["git", "show", f":{relative_path_posix}"],
|
|
201
|
+
cwd=str(repository_root),
|
|
202
|
+
capture_output=True,
|
|
203
|
+
text=True,
|
|
204
|
+
encoding="utf-8",
|
|
205
|
+
errors="replace",
|
|
206
|
+
check=False,
|
|
207
|
+
)
|
|
208
|
+
if show_result.returncode != 0:
|
|
209
|
+
print(
|
|
210
|
+
f"code_rules_gate: git show :{relative_path_posix} failed:\n"
|
|
211
|
+
f"{show_result.stderr}",
|
|
212
|
+
file=sys.stderr,
|
|
213
|
+
)
|
|
214
|
+
raise SystemExit(2)
|
|
215
|
+
staged_content = show_result.stdout
|
|
216
|
+
if not staged_content:
|
|
217
|
+
return 0
|
|
218
|
+
return len(staged_content.splitlines())
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def is_staged_file_newly_added(
|
|
222
|
+
repository_root: Path,
|
|
223
|
+
relative_path_posix: str,
|
|
224
|
+
) -> bool:
|
|
225
|
+
status_result = subprocess.run(
|
|
226
|
+
["git", "diff", "--cached", "--name-status", "--", relative_path_posix],
|
|
227
|
+
cwd=str(repository_root),
|
|
228
|
+
capture_output=True,
|
|
229
|
+
text=True,
|
|
230
|
+
encoding="utf-8",
|
|
231
|
+
errors="replace",
|
|
232
|
+
check=False,
|
|
233
|
+
)
|
|
234
|
+
if status_result.returncode != 0:
|
|
235
|
+
print(
|
|
236
|
+
f"code_rules_gate: git diff --cached --name-status failed for "
|
|
237
|
+
f"{relative_path_posix}:\n{status_result.stderr}",
|
|
238
|
+
file=sys.stderr,
|
|
239
|
+
)
|
|
240
|
+
raise SystemExit(2)
|
|
241
|
+
for each_line in status_result.stdout.splitlines():
|
|
242
|
+
stripped_line = each_line.strip()
|
|
243
|
+
if stripped_line:
|
|
244
|
+
return stripped_line.startswith(GIT_NAME_STATUS_ADDED_PREFIX)
|
|
245
|
+
return False
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def added_lines_for_staged_file(
|
|
249
|
+
repository_root: Path,
|
|
250
|
+
relative_path_posix: str,
|
|
251
|
+
) -> set[int]:
|
|
252
|
+
diff_result = subprocess.run(
|
|
253
|
+
["git", "diff", "--cached", "--unified=0", "--", relative_path_posix],
|
|
254
|
+
cwd=str(repository_root),
|
|
255
|
+
capture_output=True,
|
|
256
|
+
text=True,
|
|
257
|
+
encoding="utf-8",
|
|
258
|
+
errors="replace",
|
|
259
|
+
check=False,
|
|
260
|
+
)
|
|
261
|
+
if diff_result.returncode != 0:
|
|
262
|
+
print(
|
|
263
|
+
f"code_rules_gate: git diff --cached --unified=0 failed for {relative_path_posix}:\n"
|
|
264
|
+
f"{diff_result.stderr}",
|
|
265
|
+
file=sys.stderr,
|
|
266
|
+
)
|
|
267
|
+
raise SystemExit(2)
|
|
268
|
+
if diff_result.stdout.strip():
|
|
269
|
+
return parse_added_line_numbers(diff_result.stdout)
|
|
270
|
+
if is_staged_file_newly_added(repository_root, relative_path_posix):
|
|
271
|
+
total_lines = staged_file_line_count(repository_root, relative_path_posix)
|
|
272
|
+
if total_lines > 0:
|
|
273
|
+
return set(range(1, total_lines + 1))
|
|
274
|
+
return set()
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def added_lines_by_file_staged(
|
|
278
|
+
repository_root: Path,
|
|
279
|
+
all_file_paths: list[Path],
|
|
280
|
+
) -> dict[Path, set[int]]:
|
|
281
|
+
resolved_root = repository_root.resolve()
|
|
282
|
+
added_by_path: dict[Path, set[int]] = {}
|
|
283
|
+
for each_path in all_file_paths:
|
|
284
|
+
try:
|
|
285
|
+
resolved = each_path.resolve()
|
|
286
|
+
except OSError:
|
|
287
|
+
continue
|
|
288
|
+
try:
|
|
289
|
+
relative = resolved.relative_to(resolved_root)
|
|
290
|
+
except ValueError:
|
|
291
|
+
continue
|
|
292
|
+
relative_posix = str(relative).replace("\\", "/")
|
|
293
|
+
added_numbers = added_lines_for_staged_file(resolved_root, relative_posix)
|
|
294
|
+
added_by_path[resolved] = added_numbers
|
|
295
|
+
return added_by_path
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def paths_from_git_diff(repository_root: Path, base_reference: str) -> list[Path]:
|
|
299
|
+
merge_base = resolve_merge_base(repository_root, base_reference)
|
|
300
|
+
diff_command = list(ALL_GIT_DIFF_NAME_ONLY_NULL_TERMINATED_COMMAND_PREFIX) + [
|
|
301
|
+
f"{merge_base}..HEAD"
|
|
302
|
+
]
|
|
303
|
+
name_result = subprocess.run(
|
|
304
|
+
diff_command,
|
|
305
|
+
cwd=str(repository_root),
|
|
306
|
+
capture_output=True,
|
|
307
|
+
check=False,
|
|
308
|
+
)
|
|
309
|
+
if name_result.returncode != 0:
|
|
310
|
+
stderr_text = name_result.stderr.decode("utf-8", errors="replace")
|
|
311
|
+
print(
|
|
312
|
+
f"code_rules_gate: git diff --name-only -z failed:\n{stderr_text}",
|
|
313
|
+
file=sys.stderr,
|
|
314
|
+
)
|
|
315
|
+
raise SystemExit(2)
|
|
316
|
+
raw_paths = name_result.stdout.split(b"\x00")
|
|
317
|
+
resolved_paths: list[Path] = []
|
|
318
|
+
for each_raw_path in raw_paths:
|
|
319
|
+
if not each_raw_path:
|
|
320
|
+
continue
|
|
321
|
+
try:
|
|
322
|
+
relative_path = each_raw_path.decode("utf-8")
|
|
323
|
+
except UnicodeDecodeError:
|
|
324
|
+
print(
|
|
325
|
+
f"code_rules_gate: skipping diff path with non-UTF-8 filename: {each_raw_path!r}",
|
|
326
|
+
file=sys.stderr,
|
|
327
|
+
)
|
|
328
|
+
continue
|
|
329
|
+
resolved_paths.append(repository_root / relative_path)
|
|
330
|
+
return resolved_paths
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def is_code_path(file_path: Path) -> bool:
|
|
334
|
+
suffix = file_path.suffix.lower()
|
|
335
|
+
return suffix in ALL_CODE_FILE_EXTENSIONS
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def is_test_path(file_path: str) -> bool:
|
|
339
|
+
"""Return True when *file_path* matches CODE_RULES.md test-file detection patterns.
|
|
340
|
+
|
|
341
|
+
Mirrors the test-file detection rule documented in CODE_RULES.md:
|
|
342
|
+
filename matches test_*.py OR *_test.py OR *.test.* OR *.spec.* OR
|
|
343
|
+
conftest.py, OR path contains the segment /tests/.
|
|
344
|
+
"""
|
|
345
|
+
normalized_posix = file_path.replace("\\", "/")
|
|
346
|
+
filename_only = normalized_posix.rsplit("/", maxsplit=1)[-1]
|
|
347
|
+
if TESTS_PATH_SEGMENT in normalized_posix:
|
|
348
|
+
return True
|
|
349
|
+
if filename_only == TEST_CONFTEST_FILENAME:
|
|
350
|
+
return True
|
|
351
|
+
if filename_only.startswith(TEST_FILENAME_PREFIX) and filename_only.endswith(
|
|
352
|
+
PYTHON_FILE_EXTENSION
|
|
353
|
+
):
|
|
354
|
+
return True
|
|
355
|
+
if any(
|
|
356
|
+
filename_only.endswith(each_suffix)
|
|
357
|
+
for each_suffix in ALL_TEST_FILENAME_SUFFIXES
|
|
358
|
+
):
|
|
359
|
+
return True
|
|
360
|
+
if any(
|
|
361
|
+
each_glob_suffix in filename_only
|
|
362
|
+
for each_glob_suffix in ALL_TEST_FILENAME_GLOB_SUFFIXES
|
|
363
|
+
):
|
|
364
|
+
return True
|
|
365
|
+
return False
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def check_database_column_string_magic(content: str, file_path: str) -> list[str]:
|
|
369
|
+
"""Flag string literals that look like database/HTTP column or key names inside function bodies.
|
|
370
|
+
|
|
371
|
+
Triggers when a snake_case string literal appears as the first element of a
|
|
372
|
+
two-element tuple inside a function body (the characteristic column-name/value
|
|
373
|
+
pair pattern). Files under ``config/`` and test files are exempt.
|
|
374
|
+
"""
|
|
375
|
+
normalized_path = file_path.replace("\\", "/")
|
|
376
|
+
if CONFIG_PATH_SEGMENT in normalized_path:
|
|
377
|
+
return []
|
|
378
|
+
if is_test_path(normalized_path):
|
|
379
|
+
return []
|
|
380
|
+
try:
|
|
381
|
+
tree = ast.parse(content)
|
|
382
|
+
except SyntaxError:
|
|
383
|
+
return []
|
|
384
|
+
issues: list[str] = []
|
|
385
|
+
column_key_pattern = re.compile(
|
|
386
|
+
COLUMN_KEY_PATTERN_TEMPLATE.format(
|
|
387
|
+
minimum_length=MINIMUM_COLUMN_NAME_LENGTH_AFTER_FIRST_CHAR
|
|
388
|
+
)
|
|
389
|
+
)
|
|
390
|
+
seen_tuple_node_ids: set[int] = set()
|
|
391
|
+
for each_node in ast.walk(tree):
|
|
392
|
+
if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
393
|
+
continue
|
|
394
|
+
for each_child in ast.walk(each_node):
|
|
395
|
+
if not isinstance(each_child, ast.Tuple):
|
|
396
|
+
continue
|
|
397
|
+
if id(each_child) in seen_tuple_node_ids:
|
|
398
|
+
continue
|
|
399
|
+
seen_tuple_node_ids.add(id(each_child))
|
|
400
|
+
if len(each_child.elts) != EXPECTED_TUPLE_PAIR_LENGTH:
|
|
401
|
+
continue
|
|
402
|
+
first_element = each_child.elts[0]
|
|
403
|
+
if not isinstance(first_element, ast.Constant):
|
|
404
|
+
continue
|
|
405
|
+
if not isinstance(first_element.value, str):
|
|
406
|
+
continue
|
|
407
|
+
literal_text = first_element.value
|
|
408
|
+
if not column_key_pattern.match(literal_text):
|
|
409
|
+
continue
|
|
410
|
+
if literal_text in ALL_LITERAL_KEYWORD_EXEMPTIONS:
|
|
411
|
+
continue
|
|
412
|
+
issues.append(
|
|
413
|
+
f"Line {first_element.lineno}: Column-name string magic {literal_text!r} - extract to config"
|
|
414
|
+
)
|
|
415
|
+
if len(issues) >= MAX_VIOLATIONS_PER_CHECK:
|
|
416
|
+
return issues
|
|
417
|
+
return issues
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _iter_calls_excluding_nested_functions(node: ast.AST) -> Iterator[ast.Call]:
|
|
421
|
+
for each_child in ast.iter_child_nodes(node):
|
|
422
|
+
if isinstance(each_child, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
423
|
+
continue
|
|
424
|
+
if isinstance(each_child, ast.Call):
|
|
425
|
+
yield each_child
|
|
426
|
+
continue
|
|
427
|
+
yield from _iter_calls_excluding_nested_functions(each_child)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def check_wrapper_plumb_through(content: str, file_path: str) -> list[str]:
|
|
431
|
+
"""Flag calls inside public functions that drop a same-file delegate's optional kwargs.
|
|
432
|
+
|
|
433
|
+
Walks the AST. For every public function (name does not start with '_'),
|
|
434
|
+
inspects every ast.Call inside its body and emits one finding per call
|
|
435
|
+
whose target name matches a same-file function that exposes optional
|
|
436
|
+
kwargs the enclosing public function does not also accept. Emission is
|
|
437
|
+
capped at MAX_VIOLATIONS_PER_CHECK findings per call to run_gate.
|
|
438
|
+
|
|
439
|
+
Limitations:
|
|
440
|
+
- Only module-level FunctionDef nodes contribute signatures. Methods
|
|
441
|
+
defined inside ClassDef bodies are ignored so cross-class same-name
|
|
442
|
+
methods cannot overwrite a module-level delegate's signature index.
|
|
443
|
+
- Methods defined inside ClassDef bodies are also skipped as wrapper
|
|
444
|
+
candidates. A class method that calls a module-level delegate has a
|
|
445
|
+
signature unrelated to that delegate's keyword-argument surface, so
|
|
446
|
+
treating it as a wrapper produces false positives that flag every
|
|
447
|
+
class method calling a free-function delegate with optional kwargs.
|
|
448
|
+
- ast.Attribute calls match by attribute name only; the receiver type is
|
|
449
|
+
not checked, so `self.fetch(...)` and `other.fetch(...)` both match a
|
|
450
|
+
module-level `fetch` definition.
|
|
451
|
+
- Nested call expressions inside another call's arguments are not treated as
|
|
452
|
+
separate call sites; only the enclosing Call is inspected. This avoids
|
|
453
|
+
false positives where a callee nested as an argument is confused with a
|
|
454
|
+
top-level delegate invocation (for example `delegate(helper(x))`).
|
|
455
|
+
"""
|
|
456
|
+
non_python_code_extensions = ALL_CODE_FILE_EXTENSIONS - {PYTHON_FILE_EXTENSION}
|
|
457
|
+
lowercase_file_path = file_path.lower()
|
|
458
|
+
if any(
|
|
459
|
+
lowercase_file_path.endswith(each_extension)
|
|
460
|
+
for each_extension in non_python_code_extensions
|
|
461
|
+
):
|
|
462
|
+
return []
|
|
463
|
+
try:
|
|
464
|
+
tree = ast.parse(content)
|
|
465
|
+
except SyntaxError:
|
|
466
|
+
return []
|
|
467
|
+
function_signatures: dict[str, set[str]] = {}
|
|
468
|
+
for each_node in ast.iter_child_nodes(tree):
|
|
469
|
+
if isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
470
|
+
optional_kwargs: set[str] = set()
|
|
471
|
+
for each_kwonly, each_default in zip(
|
|
472
|
+
each_node.args.kwonlyargs, each_node.args.kw_defaults
|
|
473
|
+
):
|
|
474
|
+
if each_default is not None:
|
|
475
|
+
optional_kwargs.add(each_kwonly.arg)
|
|
476
|
+
positional_defaults = each_node.args.defaults
|
|
477
|
+
positional_args_with_defaults = (
|
|
478
|
+
each_node.args.args[-len(positional_defaults):]
|
|
479
|
+
if positional_defaults
|
|
480
|
+
else []
|
|
481
|
+
)
|
|
482
|
+
for each_positional_arg in positional_args_with_defaults:
|
|
483
|
+
optional_kwargs.add(each_positional_arg.arg)
|
|
484
|
+
function_signatures[each_node.name] = optional_kwargs
|
|
485
|
+
class_method_node_ids: set[int] = set()
|
|
486
|
+
for each_class_def in ast.walk(tree):
|
|
487
|
+
if not isinstance(each_class_def, ast.ClassDef):
|
|
488
|
+
continue
|
|
489
|
+
for each_class_body_node in each_class_def.body:
|
|
490
|
+
if isinstance(
|
|
491
|
+
each_class_body_node, (ast.FunctionDef, ast.AsyncFunctionDef)
|
|
492
|
+
):
|
|
493
|
+
class_method_node_ids.add(id(each_class_body_node))
|
|
494
|
+
issues: list[str] = []
|
|
495
|
+
for each_node in ast.walk(tree):
|
|
496
|
+
if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
497
|
+
continue
|
|
498
|
+
if id(each_node) in class_method_node_ids:
|
|
499
|
+
continue
|
|
500
|
+
if each_node.name.startswith("_"):
|
|
501
|
+
continue
|
|
502
|
+
wrapper_kwargs = function_signatures.get(each_node.name, set())
|
|
503
|
+
for each_call in _iter_calls_excluding_nested_functions(each_node):
|
|
504
|
+
if isinstance(each_call.func, ast.Name):
|
|
505
|
+
delegate_name = each_call.func.id
|
|
506
|
+
elif isinstance(each_call.func, ast.Attribute):
|
|
507
|
+
delegate_name = each_call.func.attr
|
|
508
|
+
else:
|
|
509
|
+
continue
|
|
510
|
+
delegate_kwargs = function_signatures.get(delegate_name)
|
|
511
|
+
if delegate_kwargs is None:
|
|
512
|
+
continue
|
|
513
|
+
missing = delegate_kwargs - wrapper_kwargs
|
|
514
|
+
if missing:
|
|
515
|
+
issues.append(
|
|
516
|
+
f"Line {each_node.lineno}: Wrapper {each_node.name!r} drops optional kwargs {sorted(missing)!r} of delegate {delegate_name!r}"
|
|
517
|
+
)
|
|
518
|
+
if len(issues) >= MAX_VIOLATIONS_PER_CHECK:
|
|
519
|
+
return issues
|
|
520
|
+
return issues
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def parse_added_line_numbers(unified_diff_text: str) -> set[int]:
|
|
524
|
+
header_regex = hunk_header_pattern()
|
|
525
|
+
added_line_numbers: set[int] = set()
|
|
526
|
+
for each_line in unified_diff_text.splitlines():
|
|
527
|
+
header_match = header_regex.match(each_line)
|
|
528
|
+
if header_match is None:
|
|
529
|
+
continue
|
|
530
|
+
new_start_text, new_count_text = header_match.groups()
|
|
531
|
+
new_start = int(new_start_text)
|
|
532
|
+
new_count = 1 if new_count_text is None else int(new_count_text)
|
|
533
|
+
if new_count <= 0:
|
|
534
|
+
continue
|
|
535
|
+
for each_number in range(new_start, new_start + new_count):
|
|
536
|
+
added_line_numbers.add(each_number)
|
|
537
|
+
return added_line_numbers
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def is_file_new_at_base(
|
|
541
|
+
repository_root: Path,
|
|
542
|
+
merge_base: str,
|
|
543
|
+
relative_path_posix: str,
|
|
544
|
+
) -> bool:
|
|
545
|
+
cat_result = subprocess.run(
|
|
546
|
+
["git", "cat-file", "-e", f"{merge_base}:{relative_path_posix}"],
|
|
547
|
+
cwd=str(repository_root),
|
|
548
|
+
capture_output=True,
|
|
549
|
+
text=True,
|
|
550
|
+
encoding="utf-8",
|
|
551
|
+
errors="replace",
|
|
552
|
+
check=False,
|
|
553
|
+
)
|
|
554
|
+
return cat_result.returncode != 0
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def added_lines_for_file(
|
|
558
|
+
repository_root: Path,
|
|
559
|
+
merge_base: str,
|
|
560
|
+
relative_path_posix: str,
|
|
561
|
+
) -> set[int]:
|
|
562
|
+
diff_result = subprocess.run(
|
|
563
|
+
[
|
|
564
|
+
"git",
|
|
565
|
+
"diff",
|
|
566
|
+
"--unified=0",
|
|
567
|
+
f"{merge_base}..HEAD",
|
|
568
|
+
"--",
|
|
569
|
+
relative_path_posix,
|
|
570
|
+
],
|
|
571
|
+
cwd=str(repository_root),
|
|
572
|
+
capture_output=True,
|
|
573
|
+
text=True,
|
|
574
|
+
encoding="utf-8",
|
|
575
|
+
errors="replace",
|
|
576
|
+
check=False,
|
|
577
|
+
)
|
|
578
|
+
if diff_result.returncode != 0:
|
|
579
|
+
print(
|
|
580
|
+
f"code_rules_gate: git diff --unified=0 failed for {relative_path_posix}:\n"
|
|
581
|
+
f"{diff_result.stderr}",
|
|
582
|
+
file=sys.stderr,
|
|
583
|
+
)
|
|
584
|
+
raise SystemExit(2)
|
|
585
|
+
if not diff_result.stdout.strip():
|
|
586
|
+
return set()
|
|
587
|
+
return parse_added_line_numbers(diff_result.stdout)
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def whole_file_line_set(file_path: Path) -> set[int]:
|
|
591
|
+
try:
|
|
592
|
+
total_lines = len(file_path.read_text(encoding="utf-8").splitlines())
|
|
593
|
+
except (OSError, UnicodeDecodeError) as read_error:
|
|
594
|
+
print(
|
|
595
|
+
f"code_rules_gate: skipping unreadable file {file_path}: {read_error}",
|
|
596
|
+
file=sys.stderr,
|
|
597
|
+
)
|
|
598
|
+
return set()
|
|
599
|
+
if total_lines <= 0:
|
|
600
|
+
return set()
|
|
601
|
+
return set(range(1, total_lines + 1))
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def renamed_file_source_map_since(
|
|
605
|
+
repository_root: Path,
|
|
606
|
+
merge_base: str,
|
|
607
|
+
) -> dict[str, str]:
|
|
608
|
+
"""Return a mapping from rename-destination path to rename-source path.
|
|
609
|
+
|
|
610
|
+
Runs `git diff --name-status -M -z merge_base..HEAD` and collects both
|
|
611
|
+
paths of every rename entry (status code starting with R, e.g. `R100`).
|
|
612
|
+
Keys are destination posix paths; values are source posix paths. The
|
|
613
|
+
-z flag asks git for null-terminated, unquoted output so paths
|
|
614
|
+
containing tab or newline bytes are not misparsed by column or line
|
|
615
|
+
splitting; rename records emit three null-terminated tokens in
|
|
616
|
+
sequence (status, source, destination), other status records emit
|
|
617
|
+
two (status, path).
|
|
618
|
+
"""
|
|
619
|
+
name_status_result = subprocess.run(
|
|
620
|
+
["git", "diff", "--name-status", "-M", "-z", f"{merge_base}..HEAD"],
|
|
621
|
+
cwd=str(repository_root),
|
|
622
|
+
capture_output=True,
|
|
623
|
+
check=False,
|
|
624
|
+
)
|
|
625
|
+
if name_status_result.returncode != 0:
|
|
626
|
+
stderr_text = name_status_result.stderr.decode("utf-8", errors="replace")
|
|
627
|
+
print(
|
|
628
|
+
f"code_rules_gate: git diff --name-status -M -z failed:\n"
|
|
629
|
+
f"{stderr_text}",
|
|
630
|
+
file=sys.stderr,
|
|
631
|
+
)
|
|
632
|
+
raise SystemExit(2)
|
|
633
|
+
null_separated_tokens = [
|
|
634
|
+
each_token.decode("utf-8", errors="replace")
|
|
635
|
+
for each_token in name_status_result.stdout.split(b"\x00")
|
|
636
|
+
if each_token
|
|
637
|
+
]
|
|
638
|
+
rename_source_by_destination: dict[str, str] = {}
|
|
639
|
+
next_token_index = 0
|
|
640
|
+
while next_token_index < len(null_separated_tokens):
|
|
641
|
+
status_code = null_separated_tokens[next_token_index]
|
|
642
|
+
if status_code.startswith(GIT_NAME_STATUS_RENAMED_PREFIX):
|
|
643
|
+
if next_token_index + EXPECTED_RENAME_COLUMN_COUNT > len(
|
|
644
|
+
null_separated_tokens
|
|
645
|
+
):
|
|
646
|
+
break
|
|
647
|
+
source_path = null_separated_tokens[next_token_index + 1].replace(
|
|
648
|
+
"\\", "/"
|
|
649
|
+
)
|
|
650
|
+
destination_path = null_separated_tokens[next_token_index + 2].replace(
|
|
651
|
+
"\\", "/"
|
|
652
|
+
)
|
|
653
|
+
rename_source_by_destination[destination_path] = source_path
|
|
654
|
+
next_token_index += EXPECTED_RENAME_COLUMN_COUNT
|
|
655
|
+
continue
|
|
656
|
+
next_token_index += EXPECTED_NON_RENAME_COLUMN_COUNT
|
|
657
|
+
return rename_source_by_destination
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def added_lines_for_renamed_file(
|
|
661
|
+
repository_root: Path,
|
|
662
|
+
merge_base: str,
|
|
663
|
+
source_posix: str,
|
|
664
|
+
destination_posix: str,
|
|
665
|
+
) -> set[int]:
|
|
666
|
+
"""Return added line numbers for a renamed file via blob comparison.
|
|
667
|
+
|
|
668
|
+
Compares `merge_base:source_posix` against `HEAD:destination_posix`
|
|
669
|
+
to surface only truly added lines, ignoring lines that already existed
|
|
670
|
+
in the source file before the rename. Falls back to whole-file coverage
|
|
671
|
+
when the source blob is absent at the merge base (i.e. the source was
|
|
672
|
+
itself a new or renamed file that landed earlier in the branch).
|
|
673
|
+
"""
|
|
674
|
+
diff_result = subprocess.run(
|
|
675
|
+
[
|
|
676
|
+
"git",
|
|
677
|
+
"diff",
|
|
678
|
+
"--unified=0",
|
|
679
|
+
f"{merge_base}:{source_posix}",
|
|
680
|
+
f"HEAD:{destination_posix}",
|
|
681
|
+
],
|
|
682
|
+
cwd=str(repository_root),
|
|
683
|
+
capture_output=True,
|
|
684
|
+
text=True,
|
|
685
|
+
encoding="utf-8",
|
|
686
|
+
errors="replace",
|
|
687
|
+
check=False,
|
|
688
|
+
)
|
|
689
|
+
if diff_result.returncode != 0:
|
|
690
|
+
print(
|
|
691
|
+
f"code_rules_gate: git diff failed for renamed file {merge_base}:{source_posix} "
|
|
692
|
+
f"vs HEAD:{destination_posix} (returncode={diff_result.returncode}); "
|
|
693
|
+
f"stderr={diff_result.stderr.strip()!r}",
|
|
694
|
+
file=sys.stderr,
|
|
695
|
+
)
|
|
696
|
+
return set()
|
|
697
|
+
if not diff_result.stdout.strip():
|
|
698
|
+
return set()
|
|
699
|
+
return parse_added_line_numbers(diff_result.stdout)
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def added_lines_by_file(
|
|
703
|
+
repository_root: Path,
|
|
704
|
+
base_reference: str,
|
|
705
|
+
all_file_paths: list[Path],
|
|
706
|
+
) -> dict[Path, set[int]]:
|
|
707
|
+
merge_base = resolve_merge_base(repository_root, base_reference)
|
|
708
|
+
resolved_root = repository_root.resolve()
|
|
709
|
+
rename_source_map = renamed_file_source_map_since(resolved_root, merge_base)
|
|
710
|
+
added_by_path: dict[Path, set[int]] = {}
|
|
711
|
+
for each_path in all_file_paths:
|
|
712
|
+
try:
|
|
713
|
+
resolved = each_path.resolve()
|
|
714
|
+
except OSError:
|
|
715
|
+
continue
|
|
716
|
+
try:
|
|
717
|
+
relative = resolved.relative_to(resolved_root)
|
|
718
|
+
except ValueError:
|
|
719
|
+
continue
|
|
720
|
+
relative_posix = str(relative).replace("\\", "/")
|
|
721
|
+
if relative_posix in rename_source_map:
|
|
722
|
+
added_numbers = added_lines_for_renamed_file(
|
|
723
|
+
resolved_root,
|
|
724
|
+
merge_base,
|
|
725
|
+
rename_source_map[relative_posix],
|
|
726
|
+
relative_posix,
|
|
727
|
+
)
|
|
728
|
+
else:
|
|
729
|
+
added_numbers = added_lines_for_file(
|
|
730
|
+
resolved_root, merge_base, relative_posix
|
|
731
|
+
)
|
|
732
|
+
if not added_numbers and resolved.is_file():
|
|
733
|
+
if is_file_new_at_base(resolved_root, merge_base, relative_posix):
|
|
734
|
+
added_numbers = whole_file_line_set(resolved)
|
|
735
|
+
added_by_path[resolved] = added_numbers
|
|
736
|
+
return added_by_path
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def extract_violation_line_number(violation_text: str) -> int | None:
|
|
740
|
+
match_result = violation_line_pattern().match(violation_text)
|
|
741
|
+
if match_result is None:
|
|
742
|
+
return None
|
|
743
|
+
return int(match_result.group(1))
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def split_violations_by_scope(
|
|
747
|
+
all_issues: list[str],
|
|
748
|
+
all_added_line_numbers: set[int] | None,
|
|
749
|
+
) -> tuple[list[str], list[str]]:
|
|
750
|
+
if all_added_line_numbers is None:
|
|
751
|
+
return list(all_issues), []
|
|
752
|
+
blocking: list[str] = []
|
|
753
|
+
advisory: list[str] = []
|
|
754
|
+
for each_issue in all_issues:
|
|
755
|
+
violation_line = extract_violation_line_number(each_issue)
|
|
756
|
+
if violation_line is None:
|
|
757
|
+
blocking.append(each_issue)
|
|
758
|
+
continue
|
|
759
|
+
if violation_line in all_added_line_numbers:
|
|
760
|
+
blocking.append(each_issue)
|
|
761
|
+
else:
|
|
762
|
+
advisory.append(each_issue)
|
|
763
|
+
return blocking, advisory
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def print_violation_section(
|
|
767
|
+
header_message: str,
|
|
768
|
+
violations_by_file: dict[Path, list[str]],
|
|
769
|
+
repository_root: Path,
|
|
770
|
+
) -> None:
|
|
771
|
+
print(header_message, file=sys.stderr)
|
|
772
|
+
resolved_root = repository_root.resolve()
|
|
773
|
+
for each_path in sorted(violations_by_file.keys()):
|
|
774
|
+
relative = each_path.relative_to(resolved_root)
|
|
775
|
+
print(f"{relative}:", file=sys.stderr)
|
|
776
|
+
for each_issue in violations_by_file[each_path]:
|
|
777
|
+
print(f" {each_issue}", file=sys.stderr)
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def read_prior_committed_content(
|
|
781
|
+
repository_root: Path, relative_path_posix: str
|
|
782
|
+
) -> str:
|
|
783
|
+
show_result = subprocess.run(
|
|
784
|
+
["git", "show", f"HEAD:{relative_path_posix}"],
|
|
785
|
+
cwd=str(repository_root),
|
|
786
|
+
capture_output=True,
|
|
787
|
+
text=True,
|
|
788
|
+
encoding="utf-8",
|
|
789
|
+
errors="replace",
|
|
790
|
+
check=False,
|
|
791
|
+
)
|
|
792
|
+
if show_result.returncode != 0:
|
|
793
|
+
return ""
|
|
794
|
+
return show_result.stdout
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
def run_gate(
|
|
798
|
+
validate_content: ValidateContentCallable,
|
|
799
|
+
all_file_paths: list[Path],
|
|
800
|
+
repository_root: Path,
|
|
801
|
+
all_added_lines_by_path: dict[Path, set[int]] | None = None,
|
|
802
|
+
) -> int:
|
|
803
|
+
blocking_by_file: dict[Path, list[str]] = {}
|
|
804
|
+
advisory_by_file: dict[Path, list[str]] = {}
|
|
805
|
+
for each_path in sorted(set(all_file_paths)):
|
|
806
|
+
try:
|
|
807
|
+
resolved = each_path.resolve()
|
|
808
|
+
except OSError:
|
|
809
|
+
continue
|
|
810
|
+
try:
|
|
811
|
+
resolved.relative_to(repository_root.resolve())
|
|
812
|
+
except ValueError:
|
|
813
|
+
continue
|
|
814
|
+
if not is_code_path(resolved):
|
|
815
|
+
continue
|
|
816
|
+
if not resolved.is_file():
|
|
817
|
+
continue
|
|
818
|
+
try:
|
|
819
|
+
content = resolved.read_text(encoding="utf-8")
|
|
820
|
+
except (OSError, UnicodeDecodeError):
|
|
821
|
+
print(f"code_rules_gate: skip unreadable {resolved}", file=sys.stderr)
|
|
822
|
+
continue
|
|
823
|
+
relative = resolved.relative_to(repository_root.resolve())
|
|
824
|
+
relative_posix = str(relative).replace("\\", "/")
|
|
825
|
+
prior_content = read_prior_committed_content(
|
|
826
|
+
repository_root.resolve(), relative_posix
|
|
827
|
+
)
|
|
828
|
+
issues = validate_content(content, relative_posix, prior_content)
|
|
829
|
+
issues.extend(
|
|
830
|
+
check_database_column_string_magic(content, relative_posix)
|
|
831
|
+
)
|
|
832
|
+
issues.extend(check_wrapper_plumb_through(content, relative_posix))
|
|
833
|
+
if not issues:
|
|
834
|
+
continue
|
|
835
|
+
added_for_file = (
|
|
836
|
+
None
|
|
837
|
+
if all_added_lines_by_path is None
|
|
838
|
+
else all_added_lines_by_path.get(resolved)
|
|
839
|
+
)
|
|
840
|
+
blocking, advisory = split_violations_by_scope(issues, added_for_file)
|
|
841
|
+
if blocking:
|
|
842
|
+
blocking_by_file[resolved] = blocking
|
|
843
|
+
if advisory:
|
|
844
|
+
advisory_by_file[resolved] = advisory
|
|
845
|
+
blocking_count = sum(len(each_list) for each_list in blocking_by_file.values())
|
|
846
|
+
advisory_count = sum(len(each_list) for each_list in advisory_by_file.values())
|
|
847
|
+
if blocking_count:
|
|
848
|
+
if all_added_lines_by_path is None:
|
|
849
|
+
header = f"code_rules_gate: {blocking_count} violation(s) reported."
|
|
850
|
+
else:
|
|
851
|
+
header = (
|
|
852
|
+
f"code_rules_gate: {blocking_count} violation(s) "
|
|
853
|
+
"introduced on changed lines:"
|
|
854
|
+
)
|
|
855
|
+
print_violation_section(
|
|
856
|
+
header,
|
|
857
|
+
blocking_by_file,
|
|
858
|
+
repository_root,
|
|
859
|
+
)
|
|
860
|
+
if advisory_count:
|
|
861
|
+
if blocking_count:
|
|
862
|
+
print("", file=sys.stderr)
|
|
863
|
+
print_violation_section(
|
|
864
|
+
(
|
|
865
|
+
f"code_rules_gate: {advisory_count} pre-existing violation(s) "
|
|
866
|
+
"in touched files (advisory, not blocking):"
|
|
867
|
+
),
|
|
868
|
+
advisory_by_file,
|
|
869
|
+
repository_root,
|
|
870
|
+
)
|
|
871
|
+
if blocking_count:
|
|
872
|
+
return 1
|
|
873
|
+
return 0
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
def parse_arguments(all_arguments: list[str]) -> argparse.Namespace:
|
|
877
|
+
parser = argparse.ArgumentParser(
|
|
878
|
+
description=(
|
|
879
|
+
"Run CODE_RULES validators (validate_content) on files in the working tree. "
|
|
880
|
+
"Default file set: git diff --name-only merge-base(base)..HEAD."
|
|
881
|
+
),
|
|
882
|
+
)
|
|
883
|
+
parser.add_argument(
|
|
884
|
+
"--repo-root",
|
|
885
|
+
type=Path,
|
|
886
|
+
default=None,
|
|
887
|
+
help="Repository root (default: cwd).",
|
|
888
|
+
)
|
|
889
|
+
parser.add_argument(
|
|
890
|
+
"--base",
|
|
891
|
+
default="origin/main",
|
|
892
|
+
help="Merge-base ref for git diff (default: origin/main).",
|
|
893
|
+
)
|
|
894
|
+
parser.add_argument(
|
|
895
|
+
"--staged",
|
|
896
|
+
action="store_true",
|
|
897
|
+
default=False,
|
|
898
|
+
help=(
|
|
899
|
+
"Scope to staged changes only (git diff --cached). "
|
|
900
|
+
"Blocks on violations introduced on staged-added lines; "
|
|
901
|
+
"reports pre-existing violations in touched files as advisory."
|
|
902
|
+
),
|
|
903
|
+
)
|
|
904
|
+
parser.add_argument(
|
|
905
|
+
"--only-under",
|
|
906
|
+
action="append",
|
|
907
|
+
default=[],
|
|
908
|
+
dest="only_under",
|
|
909
|
+
metavar="PREFIX",
|
|
910
|
+
help=(
|
|
911
|
+
"After resolving the merge-base diff, keep only files whose repo-relative path "
|
|
912
|
+
"uses POSIX slashes and starts with PREFIX or equals PREFIX (repeatable)."
|
|
913
|
+
),
|
|
914
|
+
)
|
|
915
|
+
parser.add_argument(
|
|
916
|
+
"paths",
|
|
917
|
+
nargs="*",
|
|
918
|
+
type=Path,
|
|
919
|
+
help="Optional explicit files; if set, git diff is not used.",
|
|
920
|
+
)
|
|
921
|
+
return parser.parse_args(all_arguments)
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
def main(all_arguments: list[str]) -> int:
|
|
925
|
+
arguments = parse_arguments(all_arguments)
|
|
926
|
+
repository_root = (
|
|
927
|
+
arguments.repo_root.resolve()
|
|
928
|
+
if arguments.repo_root is not None
|
|
929
|
+
else Path.cwd().resolve()
|
|
930
|
+
)
|
|
931
|
+
validate_content = load_validate_content()
|
|
932
|
+
if arguments.paths:
|
|
933
|
+
file_paths = [repository_root / each_path for each_path in arguments.paths]
|
|
934
|
+
return run_gate(
|
|
935
|
+
validate_content, file_paths, repository_root, all_added_lines_by_path=None
|
|
936
|
+
)
|
|
937
|
+
if arguments.staged:
|
|
938
|
+
staged_file_paths = paths_from_git_staged(repository_root)
|
|
939
|
+
staged_file_paths = filter_paths_under_prefixes(
|
|
940
|
+
staged_file_paths,
|
|
941
|
+
repository_root,
|
|
942
|
+
arguments.only_under,
|
|
943
|
+
)
|
|
944
|
+
if not staged_file_paths:
|
|
945
|
+
return 0
|
|
946
|
+
staged_added_lines = added_lines_by_file_staged(
|
|
947
|
+
repository_root, staged_file_paths
|
|
948
|
+
)
|
|
949
|
+
return run_gate(
|
|
950
|
+
validate_content,
|
|
951
|
+
staged_file_paths,
|
|
952
|
+
repository_root,
|
|
953
|
+
all_added_lines_by_path=staged_added_lines,
|
|
954
|
+
)
|
|
955
|
+
file_paths = paths_from_git_diff(repository_root, arguments.base)
|
|
956
|
+
file_paths = filter_paths_under_prefixes(
|
|
957
|
+
file_paths,
|
|
958
|
+
repository_root,
|
|
959
|
+
arguments.only_under,
|
|
960
|
+
)
|
|
961
|
+
if not file_paths:
|
|
962
|
+
return 0
|
|
963
|
+
scoped_added_lines = added_lines_by_file(
|
|
964
|
+
repository_root, arguments.base, file_paths
|
|
965
|
+
)
|
|
966
|
+
return run_gate(
|
|
967
|
+
validate_content,
|
|
968
|
+
file_paths,
|
|
969
|
+
repository_root,
|
|
970
|
+
all_added_lines_by_path=scoped_added_lines,
|
|
971
|
+
)
|
|
972
|
+
|
|
973
|
+
|
|
974
|
+
if __name__ == "__main__":
|
|
975
|
+
raise SystemExit(main(sys.argv[1:]))
|