claude-dev-env 1.34.1 → 1.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/agents/clean-coder.md +109 -1
  2. package/agents/docs-agent.md +1 -1
  3. package/agents/project-docs-analyzer.md +0 -1
  4. package/agents/skill-to-agent-converter.md +0 -1
  5. package/bin/install.mjs +28 -8
  6. package/bin/install.test.mjs +9 -1
  7. package/commands/initialize.md +0 -1
  8. package/commands/readability-review.md +4 -4
  9. package/commands/review-plan.md +2 -4
  10. package/commands/stubcheck.md +1 -2
  11. package/docs/CODE_RULES.md +3 -0
  12. package/docs/agents-md-alignment-plan.md +123 -0
  13. package/hooks/blocking/code_rules_enforcer.py +686 -60
  14. package/hooks/blocking/es_exe_path_rewriter.py +10 -4
  15. package/hooks/blocking/test_code_rules_enforcer.py +273 -39
  16. package/hooks/blocking/test_code_rules_enforcer_annotations.py +97 -0
  17. package/hooks/blocking/test_code_rules_enforcer_banned_identifier.py +106 -0
  18. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +173 -0
  19. package/hooks/blocking/test_code_rules_enforcer_collection_prefix.py +328 -0
  20. package/hooks/blocking/test_code_rules_enforcer_config_path.py +0 -20
  21. package/hooks/blocking/test_code_rules_enforcer_constant_equality.py +33 -11
  22. package/hooks/blocking/test_code_rules_enforcer_existence_checks.py +0 -18
  23. package/hooks/blocking/test_code_rules_enforcer_hardcoded_user_path.py +291 -0
  24. package/hooks/blocking/test_code_rules_enforcer_inline_literal_collections.py +155 -0
  25. package/hooks/blocking/test_code_rules_enforcer_loop_variable_naming.py +194 -0
  26. package/hooks/blocking/test_code_rules_enforcer_naming_pattern.py +49 -13
  27. package/hooks/blocking/test_code_rules_enforcer_skip_decorators.py +0 -26
  28. package/hooks/blocking/test_code_rules_enforcer_string_magic.py +234 -0
  29. package/hooks/blocking/test_code_rules_enforcer_sys_path_insert.py +157 -0
  30. package/hooks/blocking/test_code_rules_enforcer_unused_imports.py +244 -0
  31. package/hooks/blocking/test_es_exe_path_rewriter.py +81 -3
  32. package/hooks/blocking/test_windows_rmtree_blocker.py +120 -8
  33. package/hooks/blocking/windows_rmtree_blocker.py +23 -6
  34. package/hooks/config/banned_identifiers_constants.py +24 -0
  35. package/hooks/config/hardcoded_user_path_constants.py +12 -0
  36. package/hooks/config/hook_log_extractor_constants.py +1 -1
  37. package/hooks/config/pre_tool_use_stdin.py +48 -0
  38. package/hooks/config/setup_project_paths_constants.py +4 -0
  39. package/hooks/config/stuttering_check_config.py +14 -0
  40. package/hooks/config/stuttering_import_binding_constants.py +11 -0
  41. package/hooks/config/sys_path_insert_constants.py +4 -0
  42. package/hooks/config/test_banned_identifiers_constants.py +48 -0
  43. package/hooks/config/test_hardcoded_user_path_constants.py +78 -0
  44. package/hooks/config/test_hook_log_extractor_constants.py +3 -3
  45. package/hooks/config/test_pre_tool_use_stdin.py +80 -0
  46. package/hooks/config/unused_module_import_constants.py +7 -0
  47. package/hooks/config/windows_rmtree_blocker_constants.py +3 -0
  48. package/hooks/diagnostic/hook_log_stop_wrapper.py +7 -4
  49. package/hooks/git-hooks/config.py +3 -3
  50. package/hooks/git-hooks/test_gate_utils.py +10 -10
  51. package/hooks/mypy.ini +2 -0
  52. package/package.json +1 -1
  53. package/rules/gh-paginate.md +125 -0
  54. package/skills/bugteam/CONSTRAINTS.md +12 -6
  55. package/skills/bugteam/PROMPTS.md +0 -39
  56. package/skills/bugteam/SKILL.md +93 -125
  57. package/skills/bugteam/SKILL_EVALS.md +25 -23
  58. package/skills/bugteam/reference/README.md +2 -0
  59. package/skills/bugteam/reference/audit-and-teammates.md +2 -2
  60. package/skills/bugteam/reference/copilot-gap-analysis.md +12 -0
  61. package/skills/bugteam/reference/teardown-publish-permissions.md +1 -1
  62. package/skills/bugteam/reference/workflow-path-a-orchestrated-teams.md +113 -0
  63. package/skills/bugteam/reference/workflow-path-b-task-harness.md +48 -0
  64. package/skills/bugteam/test_skill_additions.py +13 -4
  65. package/skills/bugteam/test_team_lifecycle.py +94 -0
  66. package/skills/findbugs/SKILL.md +3 -3
  67. package/skills/fixbugs/SKILL.md +4 -4
  68. package/skills/monitor-open-prs/SKILL.md +32 -2
  69. package/skills/monitor-open-prs/test_team_lifecycle.py +46 -0
  70. package/skills/pr-converge/SKILL.md +576 -95
  71. package/skills/pr-converge/scripts/README.md +145 -0
  72. package/skills/pr-converge/scripts/caller-window-pid.ps1 +86 -0
  73. package/skills/pr-converge/scripts/check_pr_mergeability.py +79 -0
  74. package/skills/pr-converge/scripts/config/pr_converge_constants.py +65 -0
  75. package/skills/pr-converge/scripts/config/test_pr_converge_constants.py +176 -0
  76. package/skills/pr-converge/scripts/cursor-agents-continue-caller.cmd +9 -0
  77. package/skills/pr-converge/scripts/cursor-agents-continue-stop-others.ps1 +16 -0
  78. package/skills/pr-converge/scripts/cursor-agents-continue.ahk +172 -0
  79. package/skills/pr-converge/scripts/cursor-agents-continue.cmd +2 -0
  80. package/skills/pr-converge/scripts/evict_cached_config_modules.py +20 -0
  81. package/skills/pr-converge/scripts/fetch_bugbot_inline_comments.py +110 -0
  82. package/skills/pr-converge/scripts/fetch_bugbot_reviews.py +103 -0
  83. package/skills/pr-converge/scripts/fetch_copilot_inline_comments.py +112 -0
  84. package/skills/pr-converge/scripts/fetch_copilot_reviews.py +121 -0
  85. package/skills/pr-converge/scripts/mark_pr_ready.py +54 -0
  86. package/skills/pr-converge/scripts/open_followup_copilot_pr.py +136 -0
  87. package/skills/pr-converge/scripts/post-bugbot-run.helpers.ps1 +49 -0
  88. package/skills/pr-converge/scripts/post-bugbot-run.ps1 +33 -0
  89. package/skills/pr-converge/scripts/reply_to_inline_comment.py +84 -0
  90. package/skills/pr-converge/scripts/request_copilot_review.py +71 -0
  91. package/skills/pr-converge/scripts/resolve_pr_head.py +58 -0
  92. package/skills/pr-converge/scripts/review_field_helpers.py +43 -0
  93. package/skills/pr-converge/scripts/test_check_pr_mergeability.py +126 -0
  94. package/skills/pr-converge/scripts/test_evict_cached_config_modules.py +22 -0
  95. package/skills/pr-converge/scripts/test_fetch_bugbot_inline_comments.py +342 -0
  96. package/skills/pr-converge/scripts/test_fetch_bugbot_reviews.py +220 -0
  97. package/skills/pr-converge/scripts/test_fetch_copilot_inline_comments.py +372 -0
  98. package/skills/pr-converge/scripts/test_fetch_copilot_reviews.py +280 -0
  99. package/skills/pr-converge/scripts/test_mark_pr_ready.py +69 -0
  100. package/skills/pr-converge/scripts/test_open_followup_copilot_pr.py +236 -0
  101. package/skills/pr-converge/scripts/test_post_bugbot_run.py +195 -0
  102. package/skills/pr-converge/scripts/test_reply_to_inline_comment.py +159 -0
  103. package/skills/pr-converge/scripts/test_request_copilot_review.py +101 -0
  104. package/skills/pr-converge/scripts/test_resolve_pr_head.py +79 -0
  105. package/skills/pr-converge/scripts/test_review_field_helpers.py +80 -0
  106. package/skills/pr-converge/scripts/test_trigger_bugbot.py +139 -0
  107. package/skills/pr-converge/scripts/test_view_pr_context.py +111 -0
  108. package/skills/pr-converge/scripts/trigger_bugbot.py +77 -0
  109. package/skills/pr-converge/scripts/view_pr_context.py +47 -0
  110. package/skills/pr-converge/test_team_lifecycle.py +47 -0
  111. package/skills/pr-converge/workflows/ahk-auto-continue-loop.md +108 -0
  112. package/skills/pr-converge/workflows/schedule-wakeup-loop.md +37 -0
  113. package/skills/qbug/SKILL.md +4 -4
  114. package/skills/qbug/test_qbug_skill_post_fix_audit.py +2 -2
  115. package/skills/resume-review/SKILL.md +261 -0
  116. package/agents/agent-writer.md +0 -157
  117. package/agents/config-centralizer.md +0 -686
  118. package/agents/config-extraction-agent.md +0 -225
  119. package/agents/doc-orchestrator.md +0 -47
  120. package/agents/docx-agent.md +0 -211
  121. package/agents/magic-value-eliminator-agent.md +0 -72
  122. package/agents/mandatory-agent-workflow-agent.md +0 -88
  123. package/agents/parallel-workflow-coordinator.md +0 -779
  124. package/agents/pdf-agent.md +0 -302
  125. package/agents/project-context-loader.md +0 -238
  126. package/agents/readability-review-agent.md +0 -76
  127. package/agents/refactoring-specialist.md +0 -69
  128. package/agents/right-sized-engineer.md +0 -129
  129. package/agents/session-continuity-manager.md +0 -53
  130. package/agents/stub-detector-agent.md +0 -140
  131. package/agents/tdd-test-writer.md +0 -62
  132. package/agents/test-data-builder.md +0 -68
  133. package/agents/tooling-builder.md +0 -78
  134. package/agents/validation-expert.md +0 -71
  135. package/agents/xlsx-agent.md +0 -169
  136. package/skills/bugteam/scripts/README.md +0 -58
  137. package/skills/bugteam/scripts/_claude_permissions_common.py +0 -219
  138. package/skills/bugteam/scripts/bugteam_code_rules_gate.py +0 -633
  139. package/skills/bugteam/scripts/bugteam_fix_hookspath.py +0 -260
  140. package/skills/bugteam/scripts/bugteam_preflight.py +0 -201
  141. package/skills/bugteam/scripts/config/bugteam_fix_hookspath_constants.py +0 -17
  142. package/skills/bugteam/scripts/grant_project_claude_permissions.py +0 -109
  143. package/skills/bugteam/scripts/revoke_project_claude_permissions.py +0 -135
  144. package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +0 -271
  145. package/skills/bugteam/scripts/test_bugteam_fix_hookspath.py +0 -267
  146. package/skills/bugteam/scripts/test_bugteam_preflight.py +0 -189
  147. package/skills/bugteam/scripts/test_claude_permissions_common.py +0 -44
  148. /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())