code-mower 0.5.0b5__py3-none-any.whl

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 (185) hide show
  1. code_mower/__init__.py +3 -0
  2. code_mower/adapters/__init__.py +39 -0
  3. code_mower/adapters/_base.py +148 -0
  4. code_mower/adapters/cursor_bugbot.py +97 -0
  5. code_mower/adapters/gitar.py +111 -0
  6. code_mower/adapters/greptile.py +191 -0
  7. code_mower/adapters/qodo.py +140 -0
  8. code_mower/antigravity_cli_audit_pr.py +241 -0
  9. code_mower/audit_handoff_log.py +345 -0
  10. code_mower/audit_labeler_lib.py +311 -0
  11. code_mower/audit_progress.py +203 -0
  12. code_mower/blind_review_artifacts.py +562 -0
  13. code_mower/blind_review_coordinator.py +276 -0
  14. code_mower/bootstrap.py +524 -0
  15. code_mower/builder_experiment.py +539 -0
  16. code_mower/calibration/__init__.py +181 -0
  17. code_mower/calibration/arms.py +267 -0
  18. code_mower/calibration/auto_discovery.py +348 -0
  19. code_mower/calibration/commands.py +192 -0
  20. code_mower/calibration/context_inputs.py +199 -0
  21. code_mower/calibration/corpus.py +91 -0
  22. code_mower/calibration/evidence.py +20 -0
  23. code_mower/calibration/evidence_report.py +360 -0
  24. code_mower/calibration/identity.py +27 -0
  25. code_mower/calibration/metrics.py +18 -0
  26. code_mower/calibration/overlap.py +103 -0
  27. code_mower/calibration/planning.py +309 -0
  28. code_mower/calibration/policy.py +207 -0
  29. code_mower/calibration/results.py +315 -0
  30. code_mower/calibration/run_results.py +147 -0
  31. code_mower/calibration/run_status.py +64 -0
  32. code_mower/calibration/runner.py +360 -0
  33. code_mower/calibration/truth.py +188 -0
  34. code_mower/calibration/value_report.py +142 -0
  35. code_mower/checks.py +402 -0
  36. code_mower/claude_audit_pr.py +1126 -0
  37. code_mower/claude_cli_bounce.py +303 -0
  38. code_mower/claude_cli_environment.py +73 -0
  39. code_mower/clear_stale.py +374 -0
  40. code_mower/cli.py +537 -0
  41. code_mower/cloud.py +674 -0
  42. code_mower/cloud_client/__init__.py +173 -0
  43. code_mower/cloud_client/bundle.py +155 -0
  44. code_mower/cloud_client/doctor.py +206 -0
  45. code_mower/cloud_client/dogfood.py +78 -0
  46. code_mower/cloud_client/endpoints.py +114 -0
  47. code_mower/cloud_client/errors.py +7 -0
  48. code_mower/cloud_client/events.py +278 -0
  49. code_mower/cloud_client/export.py +272 -0
  50. code_mower/cloud_client/git_metadata.py +46 -0
  51. code_mower/cloud_client/manifest.py +39 -0
  52. code_mower/cloud_client/operations.py +448 -0
  53. code_mower/cloud_client/reports.py +45 -0
  54. code_mower/cloud_client/setup.py +205 -0
  55. code_mower/cloud_client/upload.py +97 -0
  56. code_mower/code_mower_calibration.py +598 -0
  57. code_mower/code_mower_context_packs.py +591 -0
  58. code_mower/code_mower_merge.py +227 -0
  59. code_mower/code_mower_telemetry.py +561 -0
  60. code_mower/coderabbit_cli_audit_pr.py +526 -0
  61. code_mower/codex_audit_env_preflight.py +220 -0
  62. code_mower/codex_audit_pr.py +1738 -0
  63. code_mower/codex_audit_schema_smoke.py +160 -0
  64. code_mower/codex_audit_verdict.schema.json +44 -0
  65. code_mower/config.py +655 -0
  66. code_mower/doctor.py +161 -0
  67. code_mower/doctor_checks/__init__.py +104 -0
  68. code_mower/doctor_checks/cloud.py +129 -0
  69. code_mower/doctor_checks/common.py +215 -0
  70. code_mower/doctor_checks/github.py +128 -0
  71. code_mower/doctor_checks/github_actions.py +11 -0
  72. code_mower/doctor_checks/github_actions_cost.py +99 -0
  73. code_mower/doctor_checks/github_actions_cost_summary.py +111 -0
  74. code_mower/doctor_checks/github_actions_failure_annotations.py +27 -0
  75. code_mower/doctor_checks/github_actions_failure_models.py +47 -0
  76. code_mower/doctor_checks/github_actions_failure_scan.py +200 -0
  77. code_mower/doctor_checks/github_actions_failure_selection.py +63 -0
  78. code_mower/doctor_checks/github_actions_failures.py +103 -0
  79. code_mower/doctor_checks/github_actions_permissions.py +55 -0
  80. code_mower/doctor_checks/github_api.py +79 -0
  81. code_mower/doctor_checks/github_branch.py +56 -0
  82. code_mower/doctor_checks/github_config.py +25 -0
  83. code_mower/doctor_checks/github_provider.py +61 -0
  84. code_mower/doctor_checks/github_repo.py +120 -0
  85. code_mower/doctor_checks/groups.py +36 -0
  86. code_mower/doctor_checks/models.py +96 -0
  87. code_mower/doctor_checks/output.py +86 -0
  88. code_mower/doctor_checks/presets.py +64 -0
  89. code_mower/doctor_checks/privacy.py +20 -0
  90. code_mower/doctor_checks/provider_api_model.py +138 -0
  91. code_mower/doctor_checks/provider_api_model_openai.py +29 -0
  92. code_mower/doctor_checks/provider_api_model_profiles.py +137 -0
  93. code_mower/doctor_checks/provider_env.py +113 -0
  94. code_mower/doctor_checks/provider_env_required.py +56 -0
  95. code_mower/doctor_checks/provider_env_tokens.py +100 -0
  96. code_mower/doctor_checks/provider_local_cli.py +162 -0
  97. code_mower/doctor_checks/provider_local_cli_commands.py +47 -0
  98. code_mower/doctor_checks/provider_local_cli_probe_config.py +70 -0
  99. code_mower/doctor_checks/provider_probe.py +20 -0
  100. code_mower/doctor_checks/provider_probe_auth.py +52 -0
  101. code_mower/doctor_checks/provider_probe_evaluation.py +109 -0
  102. code_mower/doctor_checks/provider_probe_json.py +45 -0
  103. code_mower/doctor_checks/provider_probe_remediation.py +39 -0
  104. code_mower/doctor_checks/providers.py +159 -0
  105. code_mower/doctor_checks/registry.py +69 -0
  106. code_mower/doctor_checks/runner.py +188 -0
  107. code_mower/doctor_checks/runtime.py +89 -0
  108. code_mower/doctor_checks/runtime_github_auth.py +148 -0
  109. code_mower/gemini_cli_audit_pr.py +897 -0
  110. code_mower/hermes_cli_audit_pr.py +436 -0
  111. code_mower/init.py +888 -0
  112. code_mower/lane_configs/__init__.py +37 -0
  113. code_mower/lane_configs/aider.py +32 -0
  114. code_mower/lane_configs/antigravity_cli.py +35 -0
  115. code_mower/lane_configs/claude.py +35 -0
  116. code_mower/lane_configs/codex.py +32 -0
  117. code_mower/lane_configs/devin.py +33 -0
  118. code_mower/lane_configs/gemini_cli.py +35 -0
  119. code_mower/lane_configs/hermes_cli.py +35 -0
  120. code_mower/lane_configs/local_llm.py +31 -0
  121. code_mower/local_llm_audit_pr.py +1364 -0
  122. code_mower/local_llm_bakeoff.py +458 -0
  123. code_mower/local_llm_calibration.py +441 -0
  124. code_mower/local_llm_profiles.py +66 -0
  125. code_mower/migration.py +508 -0
  126. code_mower/migration_install.py +292 -0
  127. code_mower/migration_mirror.py +392 -0
  128. code_mower/migration_readiness.py +237 -0
  129. code_mower/migration_rehearsal.py +718 -0
  130. code_mower/next_steps.py +441 -0
  131. code_mower/package.py +673 -0
  132. code_mower/package_content.py +444 -0
  133. code_mower/package_manifest.py +452 -0
  134. code_mower/package_paths.py +53 -0
  135. code_mower/package_rendering.py +90 -0
  136. code_mower/package_static.py +585 -0
  137. code_mower/prompts.py +267 -0
  138. code_mower/provider_registry.py +469 -0
  139. code_mower/provider_runners/__init__.py +60 -0
  140. code_mower/provider_runners/comments.py +31 -0
  141. code_mower/provider_runners/git.py +46 -0
  142. code_mower/provider_runners/github_auth.py +61 -0
  143. code_mower/provider_runners/github_pr.py +120 -0
  144. code_mower/provider_runners/process.py +58 -0
  145. code_mower/provider_runners/repo_paths.py +23 -0
  146. code_mower/provider_runners/text_schema.py +41 -0
  147. code_mower/provider_runners/verdict_artifacts.py +103 -0
  148. code_mower/provider_runners/workspace.py +57 -0
  149. code_mower/release_readiness.py +549 -0
  150. code_mower/reviewer_metrics.py +389 -0
  151. code_mower/saas_reviewer_labeler.py +809 -0
  152. code_mower/secrets.py +89 -0
  153. code_mower/templates/builder-experiment.example.json +55 -0
  154. code_mower/templates/calibration-corpus.example.json +129 -0
  155. code_mower/templates/calibration-corpus.json +129 -0
  156. code_mower/templates/code-mower.example.yml +423 -0
  157. code_mower/templates/context-packs.example.json +150 -0
  158. code_mower/templates/lane_prompts/base-audit.md +22 -0
  159. code_mower/templates/lane_prompts/calibration-policy.md +21 -0
  160. code_mower/templates/lane_prompts/context-driven-quality.md +21 -0
  161. code_mower/templates/lane_prompts/docs-design.md +12 -0
  162. code_mower/templates/lane_prompts/generic-programming.md +21 -0
  163. code_mower/templates/lane_prompts/operability.md +22 -0
  164. code_mower/templates/lane_prompts/package-runtime.md +12 -0
  165. code_mower/templates/lane_prompts/security-threat-model.md +22 -0
  166. code_mower/templates/product-support/code_mower +216 -0
  167. code_mower/templates/product-support/code_mower_standalone_pin.env +7 -0
  168. code_mower/templates/product-support/code_mower_standalone_shadow.sh +151 -0
  169. code_mower/templates/product-support/run_claude_audit_pr.sh +32 -0
  170. code_mower/templates/product-support/run_codex_audit_pr.sh +32 -0
  171. code_mower/templates/product-support/safe_gh_comment.py +96 -0
  172. code_mower/templates/providers.yml +454 -0
  173. code_mower/templates/reviewer-spend.example.json +28 -0
  174. code_mower/templates/reviewer-value-report.example.md +20 -0
  175. code_mower/templates/workflows/private-standalone-shadow.yml.j2 +106 -0
  176. code_mower/templates/workflows/review-clear-stale.yml.j2 +83 -0
  177. code_mower/trailer_comment_labeler.py +207 -0
  178. code_mower/versioning.py +32 -0
  179. code_mower-0.5.0b5.dist-info/METADATA +302 -0
  180. code_mower-0.5.0b5.dist-info/RECORD +185 -0
  181. code_mower-0.5.0b5.dist-info/WHEEL +5 -0
  182. code_mower-0.5.0b5.dist-info/entry_points.txt +2 -0
  183. code_mower-0.5.0b5.dist-info/licenses/LICENSE +202 -0
  184. code_mower-0.5.0b5.dist-info/licenses/NOTICE +10 -0
  185. code_mower-0.5.0b5.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1364 @@
1
+ #!/usr/bin/env python3
2
+ """Local LLM audit CLI — review a single PR.
3
+
4
+ Standalone command + Python module. Reviews ONE PR end-to-end:
5
+
6
+ 1. Fetch PR metadata + per-file diffs from GitHub API.
7
+ 2. For each changed file (under size budget): fetch full file content at
8
+ head SHA. This is the "local checkout" context Codex flagged as
9
+ necessary — diff-only review is too weak.
10
+ 3. Per-file LLM pass: prompt sees full file content + diff hunks + PR
11
+ title/body. Asks the LLM to classify findings as BLOCKER / CONCERN /
12
+ NONE for that file.
13
+ 4. Synthesis LLM pass: gathers per-file findings + cross-cutting
14
+ concerns (test coverage, doc-index, lane discipline) → final verdict.
15
+ 5. Refetch head SHA at end. If it changed mid-review, emit the
16
+ `needs-local-llm-audit` trailer instead of PASS/BLOCKED.
17
+ 6. Post a structured comment ending with the authoritative trailer.
18
+
19
+ CLI usage:
20
+
21
+ GITHUB_TOKEN=... python3 tools/local_llm_audit_pr.py \
22
+ --repo owner/repo --pr 142
23
+
24
+ Module usage (called by `local_llm_audit_bridge.py`):
25
+
26
+ from tools.local_llm_audit_pr import AuditConfig, audit_pr
27
+ result = audit_pr(config, "owner/repo", 142)
28
+ # result.posted_comment_url, result.verdict, result.trailer
29
+
30
+ Exit codes (CLI mode):
31
+ 0 comment posted (or dry-run printed)
32
+ 1 generic error (config, network, API)
33
+ 2 stale head SHA detected mid-review (caller may requeue)
34
+
35
+ Severity contract (REQUIRED in the prompt):
36
+ BLOCKER — must fix before merge (correctness, test gap, secrets, etc.)
37
+ CONCERN — non-blocking observation
38
+ NONE — no issues
39
+
40
+ Final verdict:
41
+ BLOCKED — any BLOCKER findings
42
+ PASS — no BLOCKERs (CONCERNs allowed and surfaced)
43
+
44
+ Codex's "don't approve by vibes" safety net is enforced around reviewer
45
+ coverage: parse failures, missing changed-file coverage, and unreviewed file
46
+ budgets become blockers. PR-description claims are handled with more nuance:
47
+ unverifiable claims are blockers only when the missing evidence could hide a
48
+ correctness, security, schema, or test-coverage gap; otherwise they are
49
+ concerns so informational local models do not block on harmless release prose.
50
+ """
51
+
52
+ from __future__ import annotations
53
+
54
+ import argparse
55
+ import base64
56
+ import json
57
+ import os
58
+ import subprocess
59
+ import sys
60
+ import textwrap
61
+ import urllib.error
62
+ import urllib.request
63
+ from dataclasses import dataclass, field
64
+ from pathlib import Path
65
+ from typing import Any, Dict, List, Optional, Tuple
66
+
67
+ if __package__ in {None, ""}:
68
+ module_dir = Path(__file__).resolve().parent
69
+ sys.path.insert(0, str(module_dir.parent))
70
+ if module_dir.name == "code_mower": # pragma: no cover - extracted direct CLI.
71
+ from code_mower import local_llm_profiles
72
+ from code_mower import prompts as code_mower_prompts
73
+ from code_mower.provider_runners import (
74
+ fetch_pull_request as _fetch_pull_request,
75
+ fetch_pull_request_files as _fetch_pull_request_files,
76
+ resolve_github_token_from_env_or_gh,
77
+ )
78
+ else:
79
+ from tools import code_mower_prompts, local_llm_profiles
80
+ from tools.provider_runners import (
81
+ fetch_pull_request as _fetch_pull_request,
82
+ fetch_pull_request_files as _fetch_pull_request_files,
83
+ resolve_github_token_from_env_or_gh,
84
+ )
85
+ elif __package__ == "tools":
86
+ from tools import code_mower_prompts, local_llm_profiles
87
+ from tools.provider_runners import (
88
+ fetch_pull_request as _fetch_pull_request,
89
+ fetch_pull_request_files as _fetch_pull_request_files,
90
+ resolve_github_token_from_env_or_gh,
91
+ )
92
+ else: # pragma: no cover - exercised after package extraction.
93
+ from . import local_llm_profiles
94
+ from . import prompts as code_mower_prompts
95
+ from .provider_runners import (
96
+ fetch_pull_request as _fetch_pull_request,
97
+ fetch_pull_request_files as _fetch_pull_request_files,
98
+ resolve_github_token_from_env_or_gh,
99
+ )
100
+
101
+
102
+ # ----- Configuration / defaults -----
103
+
104
+ DEFAULT_API_BASE = "http://localhost:1234/v1" # LM Studio
105
+ DEFAULT_MODEL = "qwen/qwen3-coder-next" # LM Studio model id; override via LOCAL_LLM_MODEL
106
+ DEFAULT_API_KEY = "EMPTY"
107
+ DEFAULT_HTTP_TIMEOUT = 600
108
+ DEFAULT_PROMPT_REF = "origin/main"
109
+ DEFAULT_BASE_REF = "origin/main"
110
+
111
+ # File-size budgets. Files over MAX_FILE_BYTES are referenced by name only;
112
+ # the LLM sees the diff hunks but not the full content. Files under that limit
113
+ # are included in full.
114
+ MAX_FILE_BYTES = 60_000 # ~1500 lines of typical code
115
+ MAX_FILES_PER_REVIEW = 25 # synthesis pass struggles past ~25 per-file findings
116
+ MAX_PER_FILE_PROMPT_TOKENS = 6000 # rough budget; we truncate content if hit
117
+ MAX_FETCHED_PR_FILES = 500 # GitHub pulls/files pagination cap in fetch_pr_files()
118
+
119
+
120
+ # ----- Data classes -----
121
+
122
+
123
+ @dataclass
124
+ class AuditConfig:
125
+ github_token: str
126
+ api_base: str = DEFAULT_API_BASE
127
+ model: str = DEFAULT_MODEL
128
+ api_key: str = DEFAULT_API_KEY
129
+ http_timeout: int = DEFAULT_HTTP_TIMEOUT
130
+ dry_run: bool = False
131
+ max_file_bytes: int = MAX_FILE_BYTES
132
+ max_files: int = MAX_FILES_PER_REVIEW
133
+ profile_id: str = ""
134
+ context_window: int = 0
135
+ json_repair_retries: int = 1
136
+ prompt_lenses: tuple[str, ...] = field(
137
+ default_factory=lambda: code_mower_prompts.DEFAULT_REVIEW_LENSES
138
+ )
139
+ prompt_dir: Optional[Path] = None
140
+ prompt_ref: str = DEFAULT_PROMPT_REF
141
+ prompt_repo: Optional[Path] = None
142
+ repo_path: Optional[Path] = None
143
+ base_ref: str = DEFAULT_BASE_REF
144
+ allow_historical_head: bool = False
145
+
146
+
147
+ @dataclass
148
+ class FileFinding:
149
+ """Per-file finding emitted by the per-file LLM pass."""
150
+ path: str
151
+ status: str # "added" | "modified" | "removed" | "renamed" | "skipped-binary" | "skipped-toobig"
152
+ blockers: List[str] = field(default_factory=list)
153
+ concerns: List[str] = field(default_factory=list)
154
+ raw_response: str = "" # for debugging / dry-run inspection
155
+ parse_attempts: int = 1
156
+ json_repair_used: bool = False
157
+ parse_failed: bool = False
158
+
159
+ def has_blocker(self) -> bool:
160
+ return bool(self.blockers)
161
+
162
+
163
+ @dataclass
164
+ class AuditResult:
165
+ repo: str
166
+ pr_number: int
167
+ head_sha_start: str
168
+ head_sha_end: str
169
+ file_findings: List[FileFinding]
170
+ synthesis_response: str
171
+ verdict: str # "PASS" | "BLOCKED" | "STALE"
172
+ trailer: str # the full HTML-comment trailer line
173
+ comment_body: str
174
+ posted_comment_url: Optional[str] = None
175
+ # PR-level blockers — issues that aren't tied to a single file but that
176
+ # gate the verdict. Currently used for the file-truncation case (PR has
177
+ # more changed files than config.max_files): Codex blocker on #231
178
+ # required this to be surfaced rather than silently dropped.
179
+ pr_level_blockers: List[str] = field(default_factory=list)
180
+
181
+ def head_changed_during_review(self) -> bool:
182
+ return self.head_sha_start != self.head_sha_end
183
+
184
+
185
+ # ----- GitHub helpers -----
186
+
187
+
188
+ def _gh_request(
189
+ method: str,
190
+ path: str,
191
+ *,
192
+ token: str,
193
+ body: Optional[Dict[str, Any]] = None,
194
+ accept: str = "application/vnd.github+json",
195
+ timeout: int = 30,
196
+ ) -> Any:
197
+ """Single GitHub REST call. Returns parsed JSON, or text for diff Accept."""
198
+ data = json.dumps(body).encode("utf-8") if body is not None else None
199
+ req = urllib.request.Request(
200
+ f"https://api.github.com{path}",
201
+ data=data,
202
+ headers={
203
+ "Accept": accept,
204
+ "Authorization": f"Bearer {token}",
205
+ "Content-Type": "application/json",
206
+ "X-GitHub-Api-Version": "2022-11-28",
207
+ },
208
+ method=method,
209
+ )
210
+ with urllib.request.urlopen(req, timeout=timeout) as response:
211
+ body_bytes = response.read()
212
+ if accept.endswith("diff"):
213
+ return body_bytes.decode("utf-8", errors="replace")
214
+ text = body_bytes.decode("utf-8")
215
+ return json.loads(text) if text else None
216
+
217
+
218
+ def fetch_pull_request(repo: str, pr_number: int, *, token: str) -> Dict[str, Any]:
219
+ payload = _fetch_pull_request(repo, pr_number, token=token)
220
+ if not isinstance(payload, dict):
221
+ raise ValueError("GitHub pull request response was not an object")
222
+ return payload
223
+
224
+
225
+ def fetch_pr_files(repo: str, pr_number: int, *, token: str) -> List[Dict[str, Any]]:
226
+ """Return per-file diff entries (status, filename, patch, etc.)."""
227
+
228
+ return _fetch_pull_request_files(
229
+ repo,
230
+ pr_number,
231
+ token=token,
232
+ max_pages=MAX_FETCHED_PR_FILES // 100,
233
+ per_page=100,
234
+ )
235
+
236
+
237
+ def fetch_file_content(repo: str, path: str, ref: str, *, token: str) -> Optional[bytes]:
238
+ """Fetch raw file content at a given ref. Returns None if not found (e.g.
239
+ file deleted in the PR), or if the path is a directory / symlink / submodule."""
240
+ try:
241
+ meta = _gh_request(
242
+ "GET",
243
+ f"/repos/{repo}/contents/{path}?ref={ref}",
244
+ token=token,
245
+ )
246
+ except urllib.error.HTTPError as exc:
247
+ if exc.code == 404:
248
+ return None
249
+ raise
250
+ if not isinstance(meta, dict):
251
+ # Directory listing — we asked for a path that's actually a directory.
252
+ return None
253
+ if meta.get("type") != "file":
254
+ return None
255
+ encoding = meta.get("encoding", "")
256
+ content = meta.get("content", "")
257
+ if encoding == "base64":
258
+ try:
259
+ return base64.b64decode(content)
260
+ except (ValueError, TypeError):
261
+ return None
262
+ # Large files: GitHub returns a download_url instead of inline content.
263
+ download_url = meta.get("download_url")
264
+ if download_url:
265
+ try:
266
+ with urllib.request.urlopen(download_url, timeout=30) as response:
267
+ return response.read()
268
+ except urllib.error.URLError:
269
+ return None
270
+ return None
271
+
272
+
273
+ def _git(
274
+ repo_path: Path,
275
+ args: List[str],
276
+ *,
277
+ timeout: int = 60,
278
+ ) -> subprocess.CompletedProcess[str]:
279
+ return subprocess.run(
280
+ ["git", "-C", str(repo_path), *args],
281
+ capture_output=True,
282
+ text=True,
283
+ check=True,
284
+ timeout=timeout,
285
+ )
286
+
287
+
288
+ def _local_head_sha(repo_path: Path) -> str:
289
+ return _git(repo_path, ["rev-parse", "HEAD"]).stdout.strip()
290
+
291
+
292
+ def _local_file_content(repo_path: Path, path: str) -> Optional[bytes]:
293
+ relative = Path(path)
294
+ if relative.is_absolute() or ".." in relative.parts:
295
+ return None
296
+ resolved_repo_path = repo_path.expanduser().resolve()
297
+ try:
298
+ completed = subprocess.run(
299
+ ["git", "-C", str(resolved_repo_path), "show", f"HEAD:{path}"],
300
+ capture_output=True,
301
+ check=True,
302
+ timeout=60,
303
+ )
304
+ return completed.stdout
305
+ except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired):
306
+ return None
307
+
308
+
309
+ def _status_name(code: str) -> str:
310
+ if code.startswith("A"):
311
+ return "added"
312
+ if code.startswith("D"):
313
+ return "removed"
314
+ if code.startswith("R"):
315
+ return "renamed"
316
+ if code.startswith("C"):
317
+ return "copied"
318
+ return "modified"
319
+
320
+
321
+ def fetch_local_pr_files(repo_path: Path, *, base_ref: str) -> List[Dict[str, Any]]:
322
+ resolved_repo_path = repo_path.expanduser().resolve()
323
+ status_lines = _git(
324
+ resolved_repo_path,
325
+ ["diff", "--name-status", "--find-renames", f"{base_ref}...HEAD"],
326
+ ).stdout.splitlines()
327
+ files: List[Dict[str, Any]] = []
328
+ for line in status_lines:
329
+ parts = line.split("\t")
330
+ if len(parts) < 2:
331
+ continue
332
+ status_code = parts[0]
333
+ filename = parts[-1]
334
+ patch = _git(
335
+ resolved_repo_path,
336
+ [
337
+ "diff",
338
+ "--no-ext-diff",
339
+ "--find-renames",
340
+ f"{base_ref}...HEAD",
341
+ "--",
342
+ filename,
343
+ ],
344
+ timeout=120,
345
+ ).stdout
346
+ files.append(
347
+ {
348
+ "filename": filename,
349
+ "status": _status_name(status_code),
350
+ "patch": patch,
351
+ }
352
+ )
353
+ return files
354
+
355
+
356
+ def post_pr_comment(repo: str, pr_number: int, body: str, *, token: str) -> Dict[str, Any]:
357
+ return _gh_request(
358
+ "POST",
359
+ f"/repos/{repo}/issues/{pr_number}/comments",
360
+ token=token,
361
+ body={"body": body},
362
+ )
363
+
364
+
365
+ # ----- LLM call -----
366
+
367
+
368
+ def call_llm(config: AuditConfig, system: str, user: str, *, max_tokens: int = 2048) -> str:
369
+ """Single LLM chat-completion. Returns raw text."""
370
+ req_body = {
371
+ "model": config.model,
372
+ "messages": [
373
+ {"role": "system", "content": system},
374
+ {"role": "user", "content": user},
375
+ ],
376
+ "temperature": 0.1,
377
+ "max_tokens": max_tokens,
378
+ }
379
+ data = json.dumps(req_body).encode("utf-8")
380
+ req = urllib.request.Request(
381
+ f"{config.api_base.rstrip('/')}/chat/completions",
382
+ data=data,
383
+ headers={
384
+ "Authorization": f"Bearer {config.api_key}",
385
+ "Content-Type": "application/json",
386
+ },
387
+ method="POST",
388
+ )
389
+ with urllib.request.urlopen(req, timeout=config.http_timeout) as response:
390
+ body = json.loads(response.read().decode("utf-8"))
391
+ return body["choices"][0]["message"]["content"]
392
+
393
+
394
+ # ----- Prompts -----
395
+
396
+
397
+ PER_FILE_SYSTEM_PROMPT = """\
398
+ You are an automated code reviewer. Your job is to find BLOCKERS — issues
399
+ that must be fixed before merge — and surface non-blocking CONCERNS.
400
+
401
+ You will see ONE file at a time: its full current contents (at PR head)
402
+ plus the unified diff of what changed. You also see the PR's title and
403
+ body for context.
404
+
405
+ You MUST classify every observation into one of three severities:
406
+
407
+ BLOCKER — must fix before merge. Examples:
408
+ - correctness bug introduced by the change
409
+ - missing test for new behavior (when the PR claims tests)
410
+ - schema / contract break
411
+ - committed secret or credential
412
+ - claim in PR body cannot be verified from the visible code
413
+ AND the missing evidence could hide a correctness,
414
+ security, schema, or test-coverage gap
415
+ CONCERN — non-blocking observation (style, naming, refactor opportunity)
416
+ - PR prose claims that are not fully visible but do not
417
+ create a concrete merge risk
418
+ NONE — no issues to report
419
+
420
+ Output STRICTLY in this JSON format and NOTHING else (no prose around it,
421
+ no markdown fences):
422
+
423
+ {"blockers": ["...", "..."], "concerns": ["...", "..."]}
424
+
425
+ Empty arrays are fine. Each entry is one specific finding, ideally with
426
+ file location ("line ~123: ...") and what the fix would be.
427
+ """
428
+
429
+
430
+ PER_FILE_REPAIR_SYSTEM_PROMPT = PER_FILE_SYSTEM_PROMPT + """\
431
+
432
+ Your previous response for this same file was not parseable JSON. Re-review
433
+ the file and return ONLY the strict JSON object. Do not include markdown,
434
+ commentary, or a code fence.
435
+ """
436
+
437
+
438
+ SYNTHESIS_SYSTEM_PROMPT = """\
439
+ You are an automated code reviewer producing the FINAL verdict for a
440
+ pull request. The per-file pass already produced findings for each
441
+ changed file. Your job is two things:
442
+
443
+ 1. Surface CROSS-FILE / PR-LEVEL issues the per-file pass cannot see:
444
+ - test coverage: does the changed behavior have tests that
445
+ actually exercise the changed code paths?
446
+ - doc-index consistency: did the PR add tools/fixtures that
447
+ should be registered in README / index docs?
448
+ - lane discipline: does the PR touch paths that are owned by
449
+ a different lane (e.g., Codex-owned production code in a
450
+ Claude PR) without coordination?
451
+ - secrets / config: any `.env`, credentials, or tokens?
452
+ - schema / regression-gate: if production geometry / recognizer
453
+ behavior changed, does the PR include the row-level baseline
454
+ diff (not just aggregate A/B)?
455
+
456
+ 2. Produce the FINAL human-readable comment.
457
+
458
+ Severity rules (same as per-file pass):
459
+ BLOCKER → must fix before merge
460
+ CONCERN → non-blocking
461
+ NONE → no issues
462
+
463
+ Final verdict logic:
464
+ Any BLOCKER (from per-file findings OR your cross-cutting pass) → BLOCKED
465
+ Else → PASS
466
+
467
+ Output STRICTLY in this format (no JSON, no markdown fences around the
468
+ whole output — but you may use markdown WITHIN the human-readable body):
469
+
470
+ Local LLM Audit: <PASS or BLOCKED>
471
+
472
+ <one or two sentence headline>
473
+
474
+ <detailed body — markdown OK. List BLOCKERs first if any, then CONCERNs,
475
+ then "Cross-cutting notes" with anything from your PR-level pass.>
476
+
477
+ <!-- LOCAL_LLM_AUDIT_STATE: local-llm-audit-done -->
478
+ or
479
+ <!-- LOCAL_LLM_AUDIT_STATE: local-llm-audit-blocked -->
480
+
481
+ Pick exactly ONE trailer matching the verdict. The trailer MUST be the
482
+ final line of your output.
483
+
484
+ Important:
485
+ - Do not approve by vibes. If a claim made in the PR body is unverifiable and
486
+ could hide a correctness, security, schema, or test-coverage gap, treat it as
487
+ a BLOCKER ("cannot verify: ...").
488
+ - If an unverifiable claim is just release prose or background context with no
489
+ concrete merge risk, list it as a CONCERN rather than blocking the PR.
490
+ - If you only have CONCERNs (no BLOCKERs), the verdict is PASS, and
491
+ the CONCERNs are listed in the body for the author to consider.
492
+ """
493
+
494
+
495
+ def _review_doctrine(config: AuditConfig) -> str:
496
+ return code_mower_prompts.load_review_prompt(
497
+ config.prompt_lenses,
498
+ prompt_dir=config.prompt_dir,
499
+ trusted_git_ref=None if config.prompt_dir else config.prompt_ref,
500
+ repo_root=config.prompt_repo or Path.cwd(),
501
+ missing_ok=True,
502
+ )
503
+
504
+
505
+ def _with_review_doctrine(system_prompt: str, doctrine: str) -> str:
506
+ return code_mower_prompts.append_review_prompt(system_prompt, doctrine)
507
+
508
+
509
+ def _safe_decode(content_bytes: bytes) -> Tuple[Optional[str], str]:
510
+ """Return (decoded_text, reason) where reason is 'ok', 'binary', or 'too-big'."""
511
+ if b"\x00" in content_bytes[:8192]:
512
+ return None, "binary"
513
+ try:
514
+ return content_bytes.decode("utf-8"), "ok"
515
+ except UnicodeDecodeError:
516
+ return None, "binary"
517
+
518
+
519
+ def build_per_file_user_prompt(
520
+ file_meta: Dict[str, Any],
521
+ file_content: Optional[str],
522
+ pr_meta: Dict[str, Any],
523
+ *,
524
+ max_file_bytes: int = MAX_FILE_BYTES,
525
+ ) -> str:
526
+ """Compose the per-file LLM prompt."""
527
+ patch = file_meta.get("patch", "") or "(no patch — likely a binary or rename-only)"
528
+ status = file_meta.get("status", "modified")
529
+ path = file_meta.get("filename", "?")
530
+
531
+ if file_content is None:
532
+ content_block = "(file content not available — file may have been deleted,\nbe binary, exceed size limit, or be a directory/submodule)"
533
+ elif len(file_content) > max_file_bytes:
534
+ content_block = (
535
+ file_content[:max_file_bytes]
536
+ + f"\n\n... (truncated: file is {len(file_content)} bytes, showing first {max_file_bytes})\n"
537
+ )
538
+ else:
539
+ content_block = file_content
540
+
541
+ pr_title = pr_meta.get("title", "")
542
+ pr_body = (pr_meta.get("body") or "").strip()
543
+ pr_body_short = pr_body[:2000] + ("\n... (truncated)\n" if len(pr_body) > 2000 else "")
544
+
545
+ return textwrap.dedent(
546
+ """\
547
+ PR title: {title}
548
+ PR body (truncated to 2000 chars):
549
+ ---
550
+ {body}
551
+ ---
552
+
553
+ File path: {path}
554
+ Status: {status}
555
+
556
+ ## Full file content at PR head SHA
557
+
558
+ ```
559
+ {content}
560
+ ```
561
+
562
+ ## Diff (unified)
563
+
564
+ ```diff
565
+ {patch}
566
+ ```
567
+
568
+ Review this single file. Output the JSON object only.
569
+ """
570
+ ).format(
571
+ title=pr_title,
572
+ body=pr_body_short or "(empty)",
573
+ path=path,
574
+ status=status,
575
+ content=content_block,
576
+ patch=patch,
577
+ )
578
+
579
+
580
+ def build_synthesis_user_prompt(
581
+ pr_meta: Dict[str, Any],
582
+ findings: List[FileFinding],
583
+ pr_level_blockers: Optional[List[str]] = None,
584
+ ) -> str:
585
+ """Compose the synthesis LLM prompt."""
586
+ pr_level_blockers = pr_level_blockers or []
587
+ pr_title = pr_meta.get("title", "")
588
+ pr_body = (pr_meta.get("body") or "").strip()
589
+ pr_body_short = pr_body[:3000] + ("\n... (truncated)\n" if len(pr_body) > 3000 else "")
590
+ repo = pr_meta.get("base", {}).get("repo", {}).get("full_name", "?")
591
+ head_sha = pr_meta.get("head", {}).get("sha", "?")
592
+
593
+ file_list_lines = []
594
+ findings_lines = []
595
+ for f in findings:
596
+ file_list_lines.append(f"- `{f.path}` ({f.status})")
597
+ if f.blockers or f.concerns:
598
+ findings_lines.append(f"### {f.path}")
599
+ for b in f.blockers:
600
+ findings_lines.append(f"- BLOCKER: {b}")
601
+ for c in f.concerns:
602
+ findings_lines.append(f"- CONCERN: {c}")
603
+ findings_lines.append("")
604
+ elif f.status.startswith("skipped"):
605
+ findings_lines.append(f"### {f.path}")
606
+ findings_lines.append(f"- {f.status} (no review)")
607
+ findings_lines.append("")
608
+
609
+ files_block = "\n".join(file_list_lines) or "(none)"
610
+ findings_block = "\n".join(findings_lines) or "(per-file pass found no issues)"
611
+
612
+ pr_level_block_text = ""
613
+ if pr_level_blockers:
614
+ pr_level_lines = ["## PR-level BLOCKERs (these gate the verdict regardless of per-file findings)\n"]
615
+ for b in pr_level_blockers:
616
+ pr_level_lines.append(f"- {b}")
617
+ pr_level_block_text = "\n".join(pr_level_lines) + "\n\n"
618
+
619
+ return textwrap.dedent(
620
+ """\
621
+ Repository: {repo}
622
+ PR #{pr_number}: {title}
623
+ Head SHA: {head_sha}
624
+ Author: {author}
625
+ Files changed ({n_files}):
626
+ {files}
627
+
628
+ {pr_level_block}## PR body (truncated to 3000 chars)
629
+
630
+ {body}
631
+
632
+ ## Per-file findings
633
+
634
+ {findings}
635
+
636
+ Now produce the FINAL audit comment per your system prompt. Remember:
637
+ - Surface cross-file / PR-level issues the per-file pass cannot see.
638
+ - If you cannot verify a claim in the PR body and the missing evidence
639
+ could hide a correctness, security, schema, or test-coverage gap,
640
+ that's a BLOCKER, not a PASS.
641
+ - If the unverifiable claim does not create a concrete merge risk,
642
+ list it as a CONCERN.
643
+ - If the PR-level BLOCKERs section above is non-empty, the verdict
644
+ MUST be BLOCKED and you must reference those PR-level blockers
645
+ in the body.
646
+ - Final line MUST be the trailer:
647
+ <!-- LOCAL_LLM_AUDIT_STATE: local-llm-audit-done --> (PASS)
648
+ <!-- LOCAL_LLM_AUDIT_STATE: local-llm-audit-blocked --> (BLOCKED)
649
+ """
650
+ ).format(
651
+ repo=repo,
652
+ pr_number=pr_meta.get("number", "?"),
653
+ title=pr_title,
654
+ head_sha=head_sha,
655
+ author=pr_meta.get("user", {}).get("login", "?"),
656
+ n_files=len(findings),
657
+ files=files_block,
658
+ pr_level_block=pr_level_block_text,
659
+ body=pr_body_short or "(empty)",
660
+ findings=findings_block,
661
+ )
662
+
663
+
664
+ # ----- Response parsing -----
665
+
666
+
667
+ def parse_per_file_response(text: str) -> Optional[Tuple[List[str], List[str]]]:
668
+ """Parse the JSON `{blockers: [...], concerns: [...]}` from the per-file
669
+ pass. Tolerant of extra prose / markdown fences.
670
+
671
+ Returns `(blockers, concerns)` on successful parse (either list may be
672
+ empty, meaning "the LLM reviewed this file and found nothing").
673
+
674
+ Returns ``None`` when no parseable JSON object was found in the response
675
+ OR when the JSON didn't have the expected shape. Callers MUST treat
676
+ ``None`` differently from `([], [])` — a parse failure is a reviewer
677
+ breakdown, not a clean review. The caller in `review_one_file` converts
678
+ this into a BLOCKER per Codex's "don't approve by vibes" rule on PR #231.
679
+ """
680
+ text = text.strip()
681
+ # Strip code fences if present.
682
+ if text.startswith("```"):
683
+ first_newline = text.find("\n")
684
+ if first_newline != -1:
685
+ text = text[first_newline + 1 :]
686
+ if text.endswith("```"):
687
+ text = text[: -3]
688
+ text = text.strip()
689
+ # Find the outermost JSON object.
690
+ start = text.find("{")
691
+ end = text.rfind("}")
692
+ if start == -1 or end == -1 or end <= start:
693
+ return None
694
+ candidate = text[start : end + 1]
695
+ try:
696
+ obj = json.loads(candidate)
697
+ except json.JSONDecodeError:
698
+ return None
699
+ if not isinstance(obj, dict):
700
+ return None
701
+ # If the parsed object doesn't expose at least one of the expected keys,
702
+ # treat as parse failure — the LLM emitted JSON in a format we can't trust.
703
+ if "blockers" not in obj and "concerns" not in obj:
704
+ return None
705
+ blockers = [str(x) for x in obj.get("blockers", []) if x]
706
+ concerns = [str(x) for x in obj.get("concerns", []) if x]
707
+ return blockers, concerns
708
+
709
+
710
+ STALE_TRAILER = "<!-- LOCAL_LLM_AUDIT_STATE: needs-local-llm-audit -->"
711
+ DONE_TRAILER = "<!-- LOCAL_LLM_AUDIT_STATE: local-llm-audit-done -->"
712
+ BLOCKED_TRAILER = "<!-- LOCAL_LLM_AUDIT_STATE: local-llm-audit-blocked -->"
713
+
714
+
715
+ def ensure_trailer(synthesis_text: str, verdict: str) -> str:
716
+ """Ensure the synthesis response ends with the correct authoritative trailer.
717
+ Strips any other LOCAL_LLM_AUDIT_STATE trailers the LLM may have emitted and
718
+ appends the canonical one for the verdict."""
719
+ canonical = DONE_TRAILER if verdict == "PASS" else BLOCKED_TRAILER
720
+ cleaned_lines = [
721
+ line for line in synthesis_text.splitlines()
722
+ if "LOCAL_LLM_AUDIT_STATE" not in line
723
+ ]
724
+ while cleaned_lines and not cleaned_lines[-1].strip():
725
+ cleaned_lines.pop()
726
+ cleaned_lines.append("")
727
+ cleaned_lines.append(canonical)
728
+ return "\n".join(cleaned_lines) + "\n"
729
+
730
+
731
+ # ----- Orchestration -----
732
+
733
+
734
+ def _is_likely_binary_filename(path: str) -> bool:
735
+ binary_exts = {
736
+ ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".tiff", ".webp", ".ico",
737
+ ".pdf", ".zip", ".tar", ".gz", ".bz2", ".7z", ".rar",
738
+ ".woff", ".woff2", ".ttf", ".otf", ".eot",
739
+ ".mp3", ".mp4", ".mov", ".avi", ".wav", ".flac",
740
+ ".pyc", ".so", ".dylib", ".dll", ".exe",
741
+ ".onnx", ".pt", ".pth", ".bin", ".npy", ".npz",
742
+ }
743
+ lower = path.lower()
744
+ return any(lower.endswith(ext) for ext in binary_exts)
745
+
746
+
747
+ def review_one_file(
748
+ config: AuditConfig,
749
+ repo: str,
750
+ head_sha: str,
751
+ file_meta: Dict[str, Any],
752
+ pr_meta: Dict[str, Any],
753
+ *,
754
+ log: bool = True,
755
+ review_doctrine: str | None = None,
756
+ ) -> FileFinding:
757
+ path = file_meta.get("filename", "?")
758
+ status = file_meta.get("status", "modified")
759
+
760
+ if _is_likely_binary_filename(path):
761
+ if log:
762
+ print(f" skip-binary {path}", file=sys.stderr, flush=True)
763
+ return FileFinding(path=path, status="skipped-binary")
764
+
765
+ # Removed files have no content at head; only patch.
766
+ file_content: Optional[str] = None
767
+ if status != "removed":
768
+ raw = (
769
+ _local_file_content(config.repo_path, path)
770
+ if config.repo_path is not None
771
+ else fetch_file_content(repo, path, head_sha, token=config.github_token)
772
+ )
773
+ if raw is None:
774
+ file_content = None
775
+ else:
776
+ text, reason = _safe_decode(raw)
777
+ if reason == "binary":
778
+ if log:
779
+ print(f" skip-binary-content {path}", file=sys.stderr, flush=True)
780
+ return FileFinding(path=path, status="skipped-binary")
781
+ if len(raw) > config.max_file_bytes:
782
+ if log:
783
+ print(f" truncate-toobig {path} ({len(raw)}B)", file=sys.stderr, flush=True)
784
+ file_content = text
785
+
786
+ user_prompt = build_per_file_user_prompt(
787
+ file_meta,
788
+ file_content,
789
+ pr_meta,
790
+ max_file_bytes=config.max_file_bytes,
791
+ )
792
+
793
+ if log:
794
+ size_hint = len(file_content) if file_content else 0
795
+ print(f" review {path} (content={size_hint}ch)", file=sys.stderr, flush=True)
796
+
797
+ response = ""
798
+ parsed: Optional[Tuple[List[str], List[str]]] = None
799
+ parse_attempts = 0
800
+ json_repair_used = False
801
+ call_error: Optional[Exception] = None
802
+ doctrine = _review_doctrine(config) if review_doctrine is None else review_doctrine
803
+ prompts = [_with_review_doctrine(PER_FILE_SYSTEM_PROMPT, doctrine)] + [
804
+ _with_review_doctrine(PER_FILE_REPAIR_SYSTEM_PROMPT, doctrine)
805
+ for _ in range(max(0, config.json_repair_retries))
806
+ ]
807
+
808
+ for attempt, system_prompt in enumerate(prompts, start=1):
809
+ parse_attempts = attempt
810
+ if attempt > 1:
811
+ json_repair_used = True
812
+ if log:
813
+ print(f" json-retry {path} attempt={attempt}", file=sys.stderr, flush=True)
814
+ try:
815
+ # 2048 tokens is empirically enough for the verbose-concerns case on
816
+ # files up to ~15K chars (observed on reference-app#142 per-file passes;
817
+ # 1024 was hitting length truncation mid-JSON, which previously
818
+ # silently became PASS — now becomes a parse-failure BLOCKER per
819
+ # Codex's #231 review).
820
+ response = call_llm(config, system_prompt, user_prompt, max_tokens=2048)
821
+ except (urllib.error.URLError, KeyError, json.JSONDecodeError) as exc:
822
+ call_error = exc
823
+ break
824
+
825
+ parsed = parse_per_file_response(response)
826
+ if parsed is not None:
827
+ break
828
+
829
+ preview = response.strip().replace("\n", " ")[:200]
830
+ if log:
831
+ print(
832
+ f" parse-failure {path} attempt={attempt}: {preview[:80]}",
833
+ file=sys.stderr,
834
+ flush=True,
835
+ )
836
+
837
+ if call_error is not None:
838
+ if log:
839
+ print(f" llm-error {path}: {call_error}", file=sys.stderr, flush=True)
840
+ return FileFinding(
841
+ path=path,
842
+ status=status,
843
+ blockers=[f"Per-file LLM call failed: {call_error}. Cannot review this file."],
844
+ raw_response=response,
845
+ parse_attempts=parse_attempts,
846
+ json_repair_used=json_repair_used,
847
+ )
848
+
849
+ if parsed is None:
850
+ # Codex blocker on #231: malformed LLM output must NOT be treated
851
+ # as clean. Emit a BLOCKER so the synthesis pass and final verdict
852
+ # reflect the reviewer's breakdown.
853
+ preview = response.strip().replace("\n", " ")[:200]
854
+ return FileFinding(
855
+ path=path,
856
+ status=status,
857
+ blockers=[
858
+ "Per-file LLM output was not parseable JSON after retry — "
859
+ "cannot trust the verdict for this file. Response preview: "
860
+ f"{preview!r}"
861
+ ],
862
+ raw_response=response,
863
+ parse_attempts=parse_attempts,
864
+ json_repair_used=json_repair_used,
865
+ parse_failed=True,
866
+ )
867
+
868
+ blockers, concerns = parsed
869
+ return FileFinding(
870
+ path=path,
871
+ status=status,
872
+ blockers=blockers,
873
+ concerns=concerns,
874
+ raw_response=response,
875
+ parse_attempts=parse_attempts,
876
+ json_repair_used=json_repair_used,
877
+ )
878
+
879
+
880
+ def audit_pr(config: AuditConfig, repo: str, pr_number: int) -> AuditResult:
881
+ """End-to-end audit of one PR. Returns AuditResult (does NOT post by itself
882
+ unless caller wants — this function is pure orchestration plus optional
883
+ posting controlled by config.dry_run).
884
+ """
885
+ pr_meta = fetch_pull_request(repo, pr_number, token=config.github_token)
886
+ pr_head_sha = pr_meta["head"]["sha"]
887
+ if config.repo_path is not None:
888
+ repo_path = config.repo_path.expanduser().resolve()
889
+ head_sha_start = _local_head_sha(repo_path)
890
+ if not config.allow_historical_head and head_sha_start != pr_head_sha:
891
+ raise RuntimeError(
892
+ "local checkout is not at the current PR head; use "
893
+ "allow_historical_head for archived calibration runs"
894
+ )
895
+ pr_meta = dict(pr_meta)
896
+ pr_meta["head"] = {**dict(pr_meta.get("head", {})), "sha": head_sha_start}
897
+ else:
898
+ repo_path = None
899
+ head_sha_start = pr_head_sha
900
+
901
+ all_files = (
902
+ fetch_local_pr_files(repo_path, base_ref=config.base_ref)
903
+ if repo_path is not None
904
+ else fetch_pr_files(repo, pr_number, token=config.github_token)
905
+ )
906
+ if repo_path is not None and not all_files:
907
+ raise RuntimeError(
908
+ "local checkout diff produced no files; set --base-ref to the "
909
+ "PR base or merge-base for archived calibration runs"
910
+ )
911
+ pr_level_blockers: List[str] = []
912
+ reported_changed_files = None if repo_path is not None else pr_meta.get("changed_files")
913
+ if isinstance(reported_changed_files, int) and reported_changed_files > len(all_files):
914
+ pr_level_blockers.append(
915
+ f"GitHub reports {reported_changed_files} changed files, but the "
916
+ f"reviewer fetched only {len(all_files)} via the pulls/files API "
917
+ f"pagination cap ({MAX_FETCHED_PR_FILES}). Files beyond the fetch "
918
+ "cap were not reviewed, so this verdict must remain blocked until "
919
+ "the PR is split or the fetcher is expanded."
920
+ )
921
+ if len(all_files) > config.max_files:
922
+ # Codex blocker on #231: silently reviewing the first N files of a
923
+ # bigger PR could allow a blocker in an omitted file to slip past
924
+ # as PASS. Surface this explicitly as a PR-level blocker that gates
925
+ # the verdict; the synthesis prompt also sees the omitted-files list.
926
+ omitted = [f.get("filename", "?") for f in all_files[config.max_files :]]
927
+ pr_level_blockers.append(
928
+ f"PR has {len(all_files)} changed files, exceeding the per-audit "
929
+ f"budget of {config.max_files}. Reviewed only the first "
930
+ f"{config.max_files}; {len(omitted)} files omitted: "
931
+ + ", ".join(omitted[:10])
932
+ + (f", ... ({len(omitted) - 10} more)" if len(omitted) > 10 else "")
933
+ + ". Split the PR into smaller pieces, or re-audit with a larger "
934
+ "max_files setting, before treating this verdict as authoritative."
935
+ )
936
+ files = all_files[: config.max_files]
937
+ else:
938
+ files = all_files
939
+
940
+ print(
941
+ f"audit {repo}#{pr_number} head={head_sha_start[:8]} files={len(files)}"
942
+ f"{f' (of {len(all_files)} — truncated)' if pr_level_blockers else ''}",
943
+ file=sys.stderr, flush=True,
944
+ )
945
+
946
+ findings: List[FileFinding] = []
947
+ review_doctrine = _review_doctrine(config)
948
+ for file_meta in files:
949
+ finding = review_one_file(
950
+ config,
951
+ repo,
952
+ head_sha_start,
953
+ file_meta,
954
+ pr_meta,
955
+ review_doctrine=review_doctrine,
956
+ )
957
+ findings.append(finding)
958
+
959
+ # Stale HEAD check before synthesis. If HEAD changed during the per-file
960
+ # passes, requeue rather than produce a misleading verdict.
961
+ if repo_path is not None:
962
+ head_sha_end = _local_head_sha(repo_path)
963
+ else:
964
+ pr_meta_after = fetch_pull_request(repo, pr_number, token=config.github_token)
965
+ head_sha_end = pr_meta_after["head"]["sha"]
966
+
967
+ if head_sha_start != head_sha_end:
968
+ comment_body = _format_stale_comment(repo, pr_number, head_sha_start, head_sha_end)
969
+ result = AuditResult(
970
+ repo=repo,
971
+ pr_number=pr_number,
972
+ head_sha_start=head_sha_start,
973
+ head_sha_end=head_sha_end,
974
+ file_findings=findings,
975
+ synthesis_response="",
976
+ verdict="STALE",
977
+ trailer=STALE_TRAILER,
978
+ comment_body=comment_body,
979
+ )
980
+ if not config.dry_run:
981
+ posted = post_pr_comment(repo, pr_number, comment_body, token=config.github_token)
982
+ result.posted_comment_url = posted.get("html_url")
983
+ return result
984
+
985
+ # Synthesis pass. The PR-level blockers (if any) are passed alongside
986
+ # per-file findings so the LLM can incorporate them into its reasoning.
987
+ synth_user = build_synthesis_user_prompt(pr_meta, findings, pr_level_blockers)
988
+ synthesis_system_prompt = _with_review_doctrine(
989
+ SYNTHESIS_SYSTEM_PROMPT,
990
+ review_doctrine,
991
+ )
992
+ try:
993
+ synth_response = call_llm(config, synthesis_system_prompt, synth_user, max_tokens=3000)
994
+ except (urllib.error.URLError, KeyError, json.JSONDecodeError) as exc:
995
+ synth_response = (
996
+ f"Local LLM Audit: BLOCKED\n\n"
997
+ f"Synthesis pass failed: {exc}. The per-file pass produced the "
998
+ f"findings above but the final synthesis could not complete. "
999
+ f"Treating as BLOCKED out of caution.\n"
1000
+ )
1001
+
1002
+ # Determine verdict from per-file findings, PR-level blockers, and the
1003
+ # synthesis self-claim. ANY of the three trips BLOCKED — per-file or
1004
+ # PR-level blockers cannot be overridden by a synthesis-pass PASS claim
1005
+ # (Codex's "don't approve by vibes" rule).
1006
+ has_per_file_blocker = any(f.has_blocker() for f in findings)
1007
+ has_pr_level_blocker = bool(pr_level_blockers)
1008
+ llm_says_blocked = "Local LLM Audit: BLOCKED" in synth_response
1009
+ verdict = "BLOCKED" if (has_per_file_blocker or has_pr_level_blocker or llm_says_blocked) else "PASS"
1010
+
1011
+ full_body = ensure_trailer(synth_response, verdict)
1012
+ comment_body = (
1013
+ _format_top_matter(repo, pr_number, head_sha_start, findings, pr_level_blockers)
1014
+ + full_body
1015
+ )
1016
+
1017
+ result = AuditResult(
1018
+ repo=repo,
1019
+ pr_number=pr_number,
1020
+ head_sha_start=head_sha_start,
1021
+ head_sha_end=head_sha_end,
1022
+ file_findings=findings,
1023
+ synthesis_response=synth_response,
1024
+ verdict=verdict,
1025
+ trailer=DONE_TRAILER if verdict == "PASS" else BLOCKED_TRAILER,
1026
+ comment_body=comment_body,
1027
+ pr_level_blockers=pr_level_blockers,
1028
+ )
1029
+
1030
+ if not config.dry_run:
1031
+ posted = post_pr_comment(repo, pr_number, comment_body, token=config.github_token)
1032
+ result.posted_comment_url = posted.get("html_url")
1033
+
1034
+ return result
1035
+
1036
+
1037
+ def _format_top_matter(
1038
+ repo: str,
1039
+ pr_number: int,
1040
+ head_sha: str,
1041
+ findings: List[FileFinding],
1042
+ pr_level_blockers: Optional[List[str]] = None,
1043
+ ) -> str:
1044
+ n_files = len(findings)
1045
+ n_blocker_files = sum(1 for f in findings if f.has_blocker())
1046
+ n_concern_files = sum(1 for f in findings if f.concerns and not f.has_blocker())
1047
+ n_clean = n_files - n_blocker_files - n_concern_files
1048
+
1049
+ top = (
1050
+ f"## Local LLM audit (calibration phase — informational only)\n\n"
1051
+ f"Head SHA: `{head_sha}`\n"
1052
+ f"Files reviewed: {n_files} "
1053
+ f"(blocker findings: {n_blocker_files}, concern-only: {n_concern_files}, clean: {n_clean})\n\n"
1054
+ )
1055
+
1056
+ if pr_level_blockers:
1057
+ top += "### PR-level blockers\n\n"
1058
+ for b in pr_level_blockers:
1059
+ top += f"- {b}\n"
1060
+ top += "\n"
1061
+
1062
+ return top
1063
+
1064
+
1065
+ def _format_stale_comment(repo: str, pr_number: int, start_sha: str, end_sha: str) -> str:
1066
+ return (
1067
+ f"## Local LLM audit (calibration phase — informational only)\n\n"
1068
+ f"Head SHA changed during review (`{start_sha[:8]}` → `{end_sha[:8]}`). "
1069
+ f"Skipping this verdict and requeuing for re-review of the new head.\n\n"
1070
+ f"{STALE_TRAILER}\n"
1071
+ )
1072
+
1073
+
1074
+ # ----- CLI entry point -----
1075
+
1076
+
1077
+ def _env_int(name: str) -> Optional[int]:
1078
+ value = os.environ.get(name)
1079
+ if value is None or value == "":
1080
+ return None
1081
+ try:
1082
+ return int(value)
1083
+ except ValueError as exc:
1084
+ raise ValueError(f"env var {name} must be an integer, got {value!r}") from exc
1085
+
1086
+
1087
+ def _resolve_int_option(
1088
+ explicit_value: Optional[int],
1089
+ env_name: str,
1090
+ profile_value: Optional[int],
1091
+ default_value: int,
1092
+ ) -> int:
1093
+ if explicit_value is not None:
1094
+ return explicit_value
1095
+ env_value = _env_int(env_name)
1096
+ if env_value is not None:
1097
+ return env_value
1098
+ if profile_value is not None:
1099
+ return profile_value
1100
+ return default_value
1101
+
1102
+
1103
+ def resolve_runtime_options(
1104
+ *,
1105
+ profile_id: Optional[str] = None,
1106
+ api_base: Optional[str] = None,
1107
+ model: Optional[str] = None,
1108
+ api_key: Optional[str] = None,
1109
+ http_timeout: Optional[int] = None,
1110
+ max_files: Optional[int] = None,
1111
+ max_file_bytes: Optional[int] = None,
1112
+ context_window: Optional[int] = None,
1113
+ json_repair_retries: Optional[int] = None,
1114
+ ) -> Dict[str, Any]:
1115
+ """Resolve local LLM runtime options.
1116
+
1117
+ Precedence is explicit CLI value, environment variable, named profile,
1118
+ hardcoded default. Profiles are data-only and never grant merge authority.
1119
+ """
1120
+ resolved_profile_id = profile_id or os.environ.get("LOCAL_LLM_PROFILE") or ""
1121
+ profile = (
1122
+ local_llm_profiles.get_profile(resolved_profile_id)
1123
+ if resolved_profile_id
1124
+ else None
1125
+ )
1126
+ return {
1127
+ "profile_id": resolved_profile_id,
1128
+ "api_base": (
1129
+ api_base
1130
+ or os.environ.get("LOCAL_LLM_API_BASE")
1131
+ or (profile.api_base if profile else DEFAULT_API_BASE)
1132
+ ),
1133
+ "model": (
1134
+ model
1135
+ or os.environ.get("LOCAL_LLM_MODEL")
1136
+ or (profile.model if profile else DEFAULT_MODEL)
1137
+ ),
1138
+ "api_key": (
1139
+ api_key
1140
+ or os.environ.get("LOCAL_LLM_API_KEY")
1141
+ or (profile.api_key if profile else DEFAULT_API_KEY)
1142
+ ),
1143
+ "http_timeout": _resolve_int_option(
1144
+ http_timeout,
1145
+ "LOCAL_LLM_HTTP_TIMEOUT",
1146
+ profile.http_timeout if profile else None,
1147
+ DEFAULT_HTTP_TIMEOUT,
1148
+ ),
1149
+ "max_files": _resolve_int_option(
1150
+ max_files,
1151
+ "LOCAL_LLM_MAX_FILES",
1152
+ profile.max_files if profile else None,
1153
+ MAX_FILES_PER_REVIEW,
1154
+ ),
1155
+ "max_file_bytes": _resolve_int_option(
1156
+ max_file_bytes,
1157
+ "LOCAL_LLM_MAX_FILE_BYTES",
1158
+ profile.max_file_bytes if profile else None,
1159
+ MAX_FILE_BYTES,
1160
+ ),
1161
+ "context_window": _resolve_int_option(
1162
+ context_window,
1163
+ "LOCAL_LLM_CONTEXT_WINDOW",
1164
+ profile.context_window if profile else None,
1165
+ 0,
1166
+ ),
1167
+ "json_repair_retries": _resolve_int_option(
1168
+ json_repair_retries,
1169
+ "LOCAL_LLM_JSON_REPAIR_RETRIES",
1170
+ None,
1171
+ 1,
1172
+ ),
1173
+ }
1174
+
1175
+
1176
+ def _parse_args(argv: Optional[List[str]] = None) -> argparse.Namespace:
1177
+ ap = argparse.ArgumentParser(
1178
+ description="Local LLM audit CLI — review a single pull request.",
1179
+ )
1180
+ ap.add_argument("--repo", required=True, help="owner/repo, e.g. owner/repo")
1181
+ ap.add_argument("--pr", type=int, required=True, help="PR number")
1182
+ ap.add_argument(
1183
+ "--profile",
1184
+ choices=local_llm_profiles.profile_ids(),
1185
+ default=None,
1186
+ help="Named local LLM runtime profile.",
1187
+ )
1188
+ ap.add_argument("--api-base", default=None)
1189
+ ap.add_argument("--model", default=None)
1190
+ ap.add_argument("--api-key", default=None)
1191
+ ap.add_argument(
1192
+ "--http-timeout",
1193
+ type=int,
1194
+ default=None,
1195
+ help="HTTP timeout in seconds for local LLM calls.",
1196
+ )
1197
+ ap.add_argument(
1198
+ "--max-files",
1199
+ type=int,
1200
+ default=None,
1201
+ help=(
1202
+ "Maximum changed files to review before emitting a PR-level "
1203
+ "truncation blocker."
1204
+ ),
1205
+ )
1206
+ ap.add_argument(
1207
+ "--max-file-bytes",
1208
+ type=int,
1209
+ default=None,
1210
+ help="Maximum file-content bytes included in each per-file prompt.",
1211
+ )
1212
+ ap.add_argument(
1213
+ "--context-window",
1214
+ type=int,
1215
+ default=None,
1216
+ help="Declared model context window for metadata and bakeoff reporting.",
1217
+ )
1218
+ ap.add_argument(
1219
+ "--json-repair-retries",
1220
+ type=int,
1221
+ default=None,
1222
+ help="Malformed per-file JSON retry count before fail-closed blocker.",
1223
+ )
1224
+ ap.add_argument(
1225
+ "--repo-path",
1226
+ type=Path,
1227
+ default=None,
1228
+ help="optional local checkout to review for archived calibration heads",
1229
+ )
1230
+ ap.add_argument("--base-ref", default=DEFAULT_BASE_REF)
1231
+ ap.add_argument(
1232
+ "--allow-historical-head",
1233
+ action="store_true",
1234
+ help="allow --repo-path HEAD to differ from the current GitHub PR head",
1235
+ )
1236
+ ap.add_argument(
1237
+ "--prompt-lenses",
1238
+ default=(
1239
+ os.environ.get("LOCAL_LLM_PROMPT_LENSES")
1240
+ or os.environ.get("CODE_MOWER_REVIEW_LENSES")
1241
+ or ",".join(code_mower_prompts.DEFAULT_REVIEW_LENSES)
1242
+ ),
1243
+ help="Comma-separated Code Mower review prompt lenses.",
1244
+ )
1245
+ ap.add_argument(
1246
+ "--prompt-dir",
1247
+ type=Path,
1248
+ default=(
1249
+ os.environ.get("LOCAL_LLM_PROMPT_DIR")
1250
+ or os.environ.get("CODE_MOWER_PROMPT_DIR")
1251
+ or None
1252
+ ),
1253
+ help="Directory containing Code Mower review lens markdown files.",
1254
+ )
1255
+ ap.add_argument(
1256
+ "--prompt-ref",
1257
+ default=(
1258
+ os.environ.get("LOCAL_LLM_PROMPT_REF")
1259
+ or os.environ.get("CODE_MOWER_PROMPT_REF")
1260
+ or DEFAULT_PROMPT_REF
1261
+ ),
1262
+ help=(
1263
+ "Trusted git ref for default review lenses when --prompt-dir is "
1264
+ "not supplied."
1265
+ ),
1266
+ )
1267
+ ap.add_argument(
1268
+ "--prompt-repo",
1269
+ type=Path,
1270
+ default=(
1271
+ os.environ.get("LOCAL_LLM_PROMPT_REPO")
1272
+ or os.environ.get("CODE_MOWER_PROMPT_REPO")
1273
+ or None
1274
+ ),
1275
+ help="Repository root used with --prompt-ref. Defaults to cwd.",
1276
+ )
1277
+ ap.add_argument(
1278
+ "--dry-run",
1279
+ action="store_true",
1280
+ default=bool(os.environ.get("LOCAL_LLM_AUDIT_DRY_RUN")),
1281
+ help="Print the audit comment to stdout instead of posting it.",
1282
+ )
1283
+ return ap.parse_args(argv)
1284
+
1285
+
1286
+ def main(argv: Optional[List[str]] = None) -> int:
1287
+ args = _parse_args(argv)
1288
+ token = resolve_github_token_from_env_or_gh()
1289
+ if not token:
1290
+ print(
1291
+ "error: set GITHUB_TOKEN or authenticate gh so `gh auth token` works",
1292
+ file=sys.stderr,
1293
+ )
1294
+ return 1
1295
+ try:
1296
+ runtime_options = resolve_runtime_options(
1297
+ profile_id=args.profile,
1298
+ api_base=args.api_base,
1299
+ model=args.model,
1300
+ api_key=args.api_key,
1301
+ http_timeout=args.http_timeout,
1302
+ max_files=args.max_files,
1303
+ max_file_bytes=args.max_file_bytes,
1304
+ context_window=args.context_window,
1305
+ json_repair_retries=args.json_repair_retries,
1306
+ )
1307
+ except (KeyError, ValueError) as exc:
1308
+ print(f"error: {exc}", file=sys.stderr)
1309
+ return 1
1310
+
1311
+ config = AuditConfig(
1312
+ github_token=token,
1313
+ api_base=runtime_options["api_base"],
1314
+ model=runtime_options["model"],
1315
+ api_key=runtime_options["api_key"],
1316
+ http_timeout=runtime_options["http_timeout"],
1317
+ max_file_bytes=runtime_options["max_file_bytes"],
1318
+ max_files=runtime_options["max_files"],
1319
+ profile_id=runtime_options["profile_id"],
1320
+ context_window=runtime_options["context_window"],
1321
+ json_repair_retries=runtime_options["json_repair_retries"],
1322
+ prompt_lenses=code_mower_prompts.split_lenses(args.prompt_lenses),
1323
+ prompt_dir=args.prompt_dir,
1324
+ prompt_ref=args.prompt_ref,
1325
+ prompt_repo=args.prompt_repo,
1326
+ repo_path=args.repo_path,
1327
+ base_ref=args.base_ref,
1328
+ allow_historical_head=args.allow_historical_head,
1329
+ dry_run=args.dry_run,
1330
+ )
1331
+
1332
+ try:
1333
+ result = audit_pr(config, args.repo, args.pr)
1334
+ except urllib.error.HTTPError as exc:
1335
+ print(f"error: GitHub API HTTP {exc.code} — {exc.reason}", file=sys.stderr)
1336
+ return 1
1337
+ except urllib.error.URLError as exc:
1338
+ print(f"error: network — {exc}", file=sys.stderr)
1339
+ return 1
1340
+ except (
1341
+ OSError,
1342
+ RuntimeError,
1343
+ subprocess.CalledProcessError,
1344
+ subprocess.TimeoutExpired,
1345
+ ) as exc:
1346
+ print(f"error: {exc}", file=sys.stderr)
1347
+ return 1
1348
+
1349
+ if args.dry_run:
1350
+ print(result.comment_body)
1351
+ else:
1352
+ print(
1353
+ f"posted {args.repo}#{args.pr} verdict={result.verdict} "
1354
+ f"url={result.posted_comment_url}",
1355
+ file=sys.stderr,
1356
+ )
1357
+
1358
+ if result.verdict == "STALE":
1359
+ return 2
1360
+ return 0
1361
+
1362
+
1363
+ if __name__ == "__main__":
1364
+ raise SystemExit(main())