claude-dev-env 1.36.1 → 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.
Files changed (36) hide show
  1. package/_shared/pr-loop/audit-contract.md +159 -0
  2. package/_shared/pr-loop/code-rules-gate.md +64 -0
  3. package/_shared/pr-loop/fix-protocol.md +37 -0
  4. package/_shared/pr-loop/gh-payloads.md +85 -0
  5. package/_shared/pr-loop/scripts/README.md +20 -0
  6. package/_shared/pr-loop/scripts/_claude_permissions_common.py +234 -0
  7. package/_shared/pr-loop/scripts/code_rules_gate.py +975 -0
  8. package/_shared/pr-loop/scripts/config/__init__.py +0 -0
  9. package/_shared/pr-loop/scripts/config/claude_permissions_constants.py +36 -0
  10. package/_shared/pr-loop/scripts/config/claude_settings_keys_constants.py +11 -0
  11. package/_shared/pr-loop/scripts/config/code_rules_gate_constants.py +56 -0
  12. package/_shared/pr-loop/scripts/config/fix_hookspath_constants.py +25 -0
  13. package/_shared/pr-loop/scripts/config/gh_util_constants.py +31 -0
  14. package/_shared/pr-loop/scripts/config/preflight_constants.py +47 -0
  15. package/_shared/pr-loop/scripts/fix_hookspath.py +260 -0
  16. package/_shared/pr-loop/scripts/gh_util.py +193 -0
  17. package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +130 -0
  18. package/_shared/pr-loop/scripts/preflight.py +227 -0
  19. package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +156 -0
  20. package/_shared/pr-loop/scripts/tests/conftest.py +51 -0
  21. package/_shared/pr-loop/scripts/tests/test__claude_permissions_common.py +135 -0
  22. package/_shared/pr-loop/scripts/tests/test_claude_permissions_common.py +169 -0
  23. package/_shared/pr-loop/scripts/tests/test_claude_permissions_constants.py +58 -0
  24. package/_shared/pr-loop/scripts/tests/test_claude_settings_keys_constants.py +50 -0
  25. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +917 -0
  26. package/_shared/pr-loop/scripts/tests/test_code_rules_gate_constants.py +102 -0
  27. package/_shared/pr-loop/scripts/tests/test_fix_hookspath.py +374 -0
  28. package/_shared/pr-loop/scripts/tests/test_fix_hookspath_constants.py +47 -0
  29. package/_shared/pr-loop/scripts/tests/test_gh_util.py +257 -0
  30. package/_shared/pr-loop/scripts/tests/test_gh_util_constants.py +61 -0
  31. package/_shared/pr-loop/scripts/tests/test_grant_project_claude_permissions.py +49 -0
  32. package/_shared/pr-loop/scripts/tests/test_preflight.py +333 -0
  33. package/_shared/pr-loop/scripts/tests/test_preflight_constants.py +82 -0
  34. package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +49 -0
  35. package/_shared/pr-loop/state-schema.md +81 -0
  36. package/package.json +2 -1
@@ -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:]))