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,199 @@
1
+ """Context-pack input materialization for calibration runs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import subprocess
7
+ from pathlib import Path
8
+ from typing import Any, Mapping
9
+
10
+ from .commands import repo_path_for_item
11
+ from .identity import safe_slug
12
+
13
+ if __package__ == "tools.calibration": # pragma: no cover - legacy product layout.
14
+ from tools import code_mower_context_packs
15
+ else: # pragma: no cover - import shape depends on package layout.
16
+ from .. import code_mower_context_packs
17
+
18
+
19
+ CONTEXT_PACK_CLI_LANES = {"antigravity-cli", "gemini-cli", "hermes-cli"}
20
+
21
+
22
+ def repo_roots_from_path_map(repo_path_map: Mapping[str, str]) -> dict[str, Path]:
23
+ roots: dict[str, Path] = {}
24
+ for selector, path_text in repo_path_map.items():
25
+ repo = re.split(r"[#@]", str(selector), maxsplit=1)[0]
26
+ if "/" in repo and str(path_text).strip():
27
+ roots.setdefault(repo, Path(path_text).expanduser())
28
+ return roots
29
+
30
+
31
+ def changed_files_from_checkout(repo_path: Path, base_ref: str) -> list[dict[str, str]]:
32
+ completed = subprocess.run(
33
+ ["git", "diff", "--name-only", f"{base_ref}...HEAD"],
34
+ cwd=repo_path,
35
+ text=True,
36
+ stdout=subprocess.PIPE,
37
+ stderr=subprocess.PIPE,
38
+ check=False,
39
+ )
40
+ if completed.returncode != 0:
41
+ raise ValueError(
42
+ "unable to list changed files for context packs with "
43
+ f"{base_ref}...HEAD in {repo_path}: {completed.stderr.strip()}"
44
+ )
45
+ return [
46
+ {"filename": line.strip()}
47
+ for line in completed.stdout.splitlines()
48
+ if line.strip()
49
+ ]
50
+
51
+
52
+ def selected_context_pack_manifest(
53
+ *,
54
+ context_pack_manifest: Mapping[str, Any],
55
+ item: Mapping[str, Any],
56
+ repo_path: Path,
57
+ ) -> dict[str, Any] | None:
58
+ selected_ids = {
59
+ str(pack_id).strip()
60
+ for pack_id in item.get("context_packs", []) or []
61
+ if str(pack_id).strip()
62
+ }
63
+ if not selected_ids:
64
+ return None
65
+ pack_values = context_pack_manifest.get("packs")
66
+ if not isinstance(pack_values, list):
67
+ raise ValueError("context pack manifest must include a packs list")
68
+ packs_by_id = {
69
+ str(pack.get("id") or "").strip(): pack
70
+ for pack in pack_values
71
+ if isinstance(pack, Mapping)
72
+ }
73
+ missing = sorted(pack_id for pack_id in selected_ids if pack_id not in packs_by_id)
74
+ if missing:
75
+ raise ValueError(f"context pack manifest is missing pack id(s): {', '.join(missing)}")
76
+ base_ref = str(item.get("base_ref") or "origin/main")
77
+ return {
78
+ "repo": str(item.get("repo") or context_pack_manifest.get("repo") or ""),
79
+ "pr_number": item.get("pr_number"),
80
+ "head_sha": str(item.get("head_sha") or context_pack_manifest.get("head_sha") or ""),
81
+ "changed_files": changed_files_from_checkout(repo_path, base_ref),
82
+ "packs": [packs_by_id[pack_id] for pack_id in sorted(selected_ids)],
83
+ }
84
+
85
+
86
+ def render_materialized_context_pack_prompt_text(report: Mapping[str, Any]) -> str:
87
+ lines: list[str] = [
88
+ "Code Mower selected context packs",
89
+ f"Manifest: {report.get('manifest_path', '')}",
90
+ "",
91
+ ]
92
+ for pack in report.get("packs", []) or []:
93
+ if not isinstance(pack, Mapping):
94
+ continue
95
+ pack_id = str(pack.get("id") or "context-pack")
96
+ reason = str(pack.get("reason") or "").strip()
97
+ lines.append(f"## Context Pack: {pack_id}")
98
+ if reason:
99
+ lines.append(f"Reason: {reason}")
100
+ files = pack.get("files", [])
101
+ if not isinstance(files, list) or not files:
102
+ lines.append("(no files materialized)")
103
+ lines.append("")
104
+ continue
105
+ for file_entry in files:
106
+ if not isinstance(file_entry, Mapping):
107
+ continue
108
+ path = str(file_entry.get("path") or "")
109
+ repo = str(file_entry.get("repo") or report.get("repo") or "")
110
+ if file_entry.get("exists") is False:
111
+ reason_text = str(file_entry.get("reason") or "missing")
112
+ lines.append(f"### {path}")
113
+ if repo:
114
+ lines.append(f"Repository: {repo}")
115
+ lines.append(f"[not included: {reason_text}]")
116
+ lines.append("")
117
+ continue
118
+ artifact_path = Path(str(file_entry.get("artifact_path") or ""))
119
+ try:
120
+ content = artifact_path.read_text(encoding="utf-8", errors="replace")
121
+ except OSError as exc:
122
+ content = f"[unable to read context artifact: {exc}]"
123
+ lines.append(f"### {path}")
124
+ if repo:
125
+ lines.append(f"Repository: {repo}")
126
+ if file_entry.get("truncated"):
127
+ lines.append(
128
+ "[truncated to "
129
+ f"{file_entry.get('bytes_written')} of "
130
+ f"{file_entry.get('source_bytes')} bytes]"
131
+ )
132
+ lines.append("```")
133
+ lines.append(content.rstrip())
134
+ lines.append("```")
135
+ lines.append("")
136
+ warnings = [
137
+ str(warning)
138
+ for warning in report.get("warnings", []) or []
139
+ if str(warning).strip()
140
+ ]
141
+ if warnings:
142
+ lines.append("## Context Pack Warnings")
143
+ lines.extend(f"- {warning}" for warning in warnings)
144
+ return "\n".join(lines).rstrip() + "\n"
145
+
146
+
147
+ def context_pack_file_for_command(
148
+ *,
149
+ item: Mapping[str, Any],
150
+ lane_id: str,
151
+ result_dir: Path,
152
+ repo_path_map: Mapping[str, str],
153
+ context_pack_manifest: Mapping[str, Any] | None,
154
+ context_pack_output_dir: Path,
155
+ require_context_pack_files: bool,
156
+ ) -> Path | None:
157
+ if lane_id not in CONTEXT_PACK_CLI_LANES or context_pack_manifest is None:
158
+ return None
159
+ selected_ids = [
160
+ str(pack_id).strip()
161
+ for pack_id in item.get("context_packs", []) or []
162
+ if str(pack_id).strip()
163
+ ]
164
+ if not selected_ids:
165
+ return None
166
+ repo_path_text = repo_path_for_item(item, repo_path_map)
167
+ if not repo_path_text:
168
+ raise ValueError(
169
+ "context-pack calibration needs --repo-path-map for "
170
+ f"{item.get('repo')}#{item.get('pr_number')}"
171
+ )
172
+ repo_path = Path(repo_path_text).expanduser().resolve()
173
+ if not repo_path.is_dir():
174
+ raise ValueError(f"context-pack repo path is not a directory: {repo_path}")
175
+ manifest = selected_context_pack_manifest(
176
+ context_pack_manifest=context_pack_manifest,
177
+ item=item,
178
+ repo_path=repo_path,
179
+ )
180
+ if manifest is None:
181
+ return None
182
+ plan = code_mower_context_packs.build_context_pack_plan(manifest)
183
+ output_dir = context_pack_output_dir / safe_slug(
184
+ str(item.get("calibration_run_id") or result_dir.parent.name),
185
+ "run",
186
+ ) / safe_slug(lane_id, "lane")
187
+ report = code_mower_context_packs.materialize_context_pack_plan(
188
+ plan,
189
+ repo_root=repo_path,
190
+ output_dir=output_dir,
191
+ require_files=require_context_pack_files,
192
+ repo_roots=repo_roots_from_path_map(repo_path_map),
193
+ )
194
+ context_text_path = result_dir / "context-pack.txt"
195
+ context_text_path.write_text(
196
+ render_materialized_context_pack_prompt_text(report),
197
+ encoding="utf-8",
198
+ )
199
+ return context_text_path
@@ -0,0 +1,91 @@
1
+ """Calibration corpus parsing helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any, Mapping
8
+
9
+ from .truth import normalize_truth
10
+
11
+
12
+ def load_json_object(path: Path) -> Mapping[str, Any]:
13
+ try:
14
+ payload = json.loads(path.read_text(encoding="utf-8"))
15
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
16
+ raise ValueError(f"unable to read {path}: {exc}") from exc
17
+ if not isinstance(payload, Mapping):
18
+ raise ValueError(f"{path} must contain a JSON object")
19
+ return payload
20
+
21
+
22
+ def parse_int(value: Any, *, field: str) -> int:
23
+ if isinstance(value, bool):
24
+ raise ValueError(f"{field} must be an integer")
25
+ if isinstance(value, int):
26
+ return value
27
+ if isinstance(value, str) and value.strip().isdigit():
28
+ return int(value.strip())
29
+ raise ValueError(f"{field} must be an integer")
30
+
31
+
32
+ def load_corpus(path: Path) -> dict[str, Any]:
33
+ payload = dict(load_json_object(path))
34
+ if payload.get("version", 1) not in {1, "1"}:
35
+ raise ValueError("calibration corpus version must be 1")
36
+ raw_items = payload.get("corpus", payload.get("pull_requests"))
37
+ if not isinstance(raw_items, list) or not raw_items:
38
+ raise ValueError("calibration corpus must include a non-empty corpus list")
39
+
40
+ items: list[dict[str, Any]] = []
41
+ seen: set[tuple[str, int, str]] = set()
42
+ for index, item in enumerate(raw_items):
43
+ if not isinstance(item, Mapping):
44
+ raise ValueError(f"corpus[{index}] must be a JSON object")
45
+ repo = str(item.get("repo") or "").strip()
46
+ if "/" not in repo:
47
+ raise ValueError(f"corpus[{index}].repo must be an owner/repo slug")
48
+ pr_number = parse_int(
49
+ item.get("pr_number", item.get("pr")),
50
+ field=f"corpus[{index}].pr_number",
51
+ )
52
+ head_sha = str(item.get("head_sha") or item.get("head") or "").strip()
53
+ key = (repo, pr_number, head_sha)
54
+ if key in seen:
55
+ raise ValueError(
56
+ f"duplicate corpus PR entry: {repo}#{pr_number} "
57
+ f"{head_sha or '(head unspecified)'}"
58
+ )
59
+ seen.add(key)
60
+ source = str(item.get("source") or "known-pr")
61
+ expected_findings = list(item.get("expected_findings", []))
62
+ truth = normalize_truth(item, source=source)
63
+ expected_findings = list(truth.get("expected_findings") or expected_findings)
64
+ items.append(
65
+ {
66
+ "repo": repo,
67
+ "pr_number": pr_number,
68
+ "head_sha": head_sha,
69
+ "base_ref": str(item.get("base_ref") or ""),
70
+ "difficulty": str(item.get("difficulty") or "unknown"),
71
+ "review_class": str(item.get("review_class") or "general"),
72
+ "source": source,
73
+ "truth": truth,
74
+ "known_clean": truth["known_clean"],
75
+ "known_blocked": truth["known_blocked"],
76
+ "expected_findings": expected_findings,
77
+ "context_packs": list(item.get("context_packs", [])),
78
+ "reviewer_evidence": list(item.get("reviewer_evidence", [])),
79
+ "reviewer_runs": list(item.get("reviewer_runs", [])),
80
+ "reviewer_run_dispositions": list(
81
+ item.get("reviewer_run_dispositions", [])
82
+ ),
83
+ "notes": str(item.get("notes") or ""),
84
+ }
85
+ )
86
+ return {
87
+ "version": 1,
88
+ "name": str(payload.get("name") or path.stem),
89
+ "description": str(payload.get("description") or ""),
90
+ "corpus": items,
91
+ }
@@ -0,0 +1,20 @@
1
+ """Calibration evidence classification constants."""
2
+
3
+ KNOWN_EVIDENCE_DISPOSITIONS = {
4
+ "true_positive",
5
+ "useful",
6
+ "false_positive",
7
+ "noise",
8
+ "unknown",
9
+ }
10
+ USEFUL_EVIDENCE_DISPOSITIONS = {"true_positive", "useful"}
11
+ NON_BLOCKING_CODERABBIT_SEVERITIES = {
12
+ "info",
13
+ "informational",
14
+ "low",
15
+ "minor",
16
+ "nit",
17
+ "notice",
18
+ "style",
19
+ "suggestion",
20
+ }
@@ -0,0 +1,360 @@
1
+ """Reviewer evidence report building for calibration corpora."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Mapping
6
+
7
+ from .evidence import USEFUL_EVIDENCE_DISPOSITIONS
8
+ from .run_results import normalize_disposition
9
+ from .run_status import (
10
+ RUN_STATUS_AUDIT_INPUT_INSUFFICIENT,
11
+ RUN_STATUS_BLOCKED,
12
+ RUN_STATUS_INFRA_ERROR,
13
+ RUN_STATUS_PASS,
14
+ normalize_run_status_category,
15
+ )
16
+ from .truth import truth_for_item
17
+
18
+
19
+ def _reviewer_id(record: Mapping[str, Any]) -> str:
20
+ reviewer = str(
21
+ record.get("reviewer")
22
+ or record.get("profile_id")
23
+ or record.get("lane")
24
+ or "unknown-reviewer"
25
+ ).strip()
26
+ return reviewer or "unknown-reviewer"
27
+
28
+
29
+ def build_reviewer_evidence_report(corpus: Mapping[str, Any]) -> dict[str, Any]:
30
+ """Summarize adjudicated reviewer evidence embedded in a calibration corpus."""
31
+
32
+ profile_counts: dict[str, dict[str, int]] = {}
33
+ profile_runs: dict[str, set[tuple[Any, ...]]] = {}
34
+ profile_durations: dict[str, float] = {}
35
+ profile_clean_run_keys: dict[str, set[tuple[Any, ...]]] = {}
36
+ profile_blocking_false_positive_run_keys: dict[str, set[tuple[Any, ...]]] = {}
37
+ profile_known_blocked_run_keys: dict[str, set[tuple[Any, ...]]] = {}
38
+ profile_known_blocked_caught_run_keys: dict[str, set[tuple[Any, ...]]] = {}
39
+ profile_known_blocked_missed_run_keys: dict[str, set[tuple[Any, ...]]] = {}
40
+ profile_audit_input_insufficient_run_keys: dict[str, set[tuple[Any, ...]]] = {}
41
+ profile_infra_error_run_keys: dict[str, set[tuple[Any, ...]]] = {}
42
+ profile_run_statuses: dict[str, dict[str, int]] = {}
43
+ profile_review_classes: dict[str, set[str]] = {}
44
+ profile_context_packs: dict[str, set[str]] = {}
45
+ profile_useful_review_classes: dict[str, set[str]] = {}
46
+ profile_useful_context_packs: dict[str, set[str]] = {}
47
+ models: dict[str, str] = {}
48
+ findings: list[dict[str, Any]] = []
49
+ run_dispositions: list[dict[str, Any]] = []
50
+ run_records: list[dict[str, Any]] = []
51
+
52
+ for item in corpus.get("corpus", []) or []:
53
+ if not isinstance(item, Mapping):
54
+ continue
55
+ repo = str(item.get("repo") or "")
56
+ pr_number = int(item.get("pr_number") or 0)
57
+ head_sha = str(item.get("head_sha") or "")
58
+ review_class = str(item.get("review_class") or "general")
59
+ context_packs = [
60
+ str(pack)
61
+ for pack in item.get("context_packs", []) or []
62
+ if str(pack).strip()
63
+ ]
64
+ truth = truth_for_item(item)
65
+ run_key = (repo, pr_number, head_sha)
66
+
67
+ for index, evidence in enumerate(item.get("reviewer_evidence", []) or []):
68
+ if not isinstance(evidence, Mapping):
69
+ continue
70
+ reviewer = _reviewer_id(evidence)
71
+ disposition = normalize_disposition(evidence.get("disposition"))
72
+ profile_review_classes.setdefault(reviewer, set()).add(review_class)
73
+ profile_context_packs.setdefault(reviewer, set()).update(context_packs)
74
+ if disposition in USEFUL_EVIDENCE_DISPOSITIONS:
75
+ profile_useful_review_classes.setdefault(reviewer, set()).add(review_class)
76
+ profile_useful_context_packs.setdefault(reviewer, set()).update(context_packs)
77
+ profile_counts.setdefault(reviewer, {})
78
+ profile_counts[reviewer][disposition] = (
79
+ profile_counts[reviewer].get(disposition, 0) + 1
80
+ )
81
+ profile_runs.setdefault(reviewer, set()).add(run_key)
82
+ if evidence.get("duration_seconds") is not None:
83
+ try:
84
+ profile_durations[reviewer] = profile_durations.get(
85
+ reviewer, 0.0
86
+ ) + float(evidence.get("duration_seconds"))
87
+ except (TypeError, ValueError):
88
+ pass
89
+ if evidence.get("model"):
90
+ models[reviewer] = str(evidence.get("model"))
91
+ findings.append(
92
+ {
93
+ "profile_id": reviewer,
94
+ "repo": repo,
95
+ "pr_number": pr_number,
96
+ "head_sha": head_sha,
97
+ "difficulty": str(item.get("difficulty") or "unknown"),
98
+ "review_class": review_class,
99
+ "context_packs": context_packs,
100
+ "source": str(item.get("source") or ""),
101
+ "evidence_index": index,
102
+ "disposition": disposition,
103
+ "path": str(evidence.get("path") or ""),
104
+ "severity": str(evidence.get("severity") or ""),
105
+ "text": str(evidence.get("summary") or evidence.get("text") or ""),
106
+ }
107
+ )
108
+
109
+ for run_index, run in enumerate(item.get("reviewer_runs", []) or []):
110
+ if not isinstance(run, Mapping):
111
+ continue
112
+ reviewer = _reviewer_id(run)
113
+ profile_review_classes.setdefault(reviewer, set()).add(review_class)
114
+ profile_context_packs.setdefault(reviewer, set()).update(context_packs)
115
+ run_identity_parts = [
116
+ str(run.get("calibration_manifest_id") or "").strip(),
117
+ str(
118
+ run.get("calibration_run_id")
119
+ or run.get("run_id")
120
+ or run.get("replicate")
121
+ or ""
122
+ ).strip(),
123
+ ]
124
+ run_identity = "::".join(part for part in run_identity_parts if part)
125
+ reviewer_run_key: tuple[Any, ...] = (
126
+ (*run_key, run_identity) if run_identity else run_key
127
+ )
128
+ profile_runs.setdefault(reviewer, set()).add(reviewer_run_key)
129
+ if run.get("duration_seconds") is not None:
130
+ try:
131
+ profile_durations[reviewer] = profile_durations.get(
132
+ reviewer, 0.0
133
+ ) + float(run.get("duration_seconds"))
134
+ except (TypeError, ValueError):
135
+ pass
136
+ if run.get("model"):
137
+ models[reviewer] = str(run.get("model"))
138
+ run_disposition = normalize_disposition(
139
+ run.get("disposition")
140
+ or (
141
+ run.get("adjudication", {}).get("disposition")
142
+ if isinstance(run.get("adjudication"), Mapping)
143
+ else None
144
+ )
145
+ )
146
+ status = str(run.get("status") or run.get("verdict") or "unknown").strip().lower()
147
+ status_category = normalize_run_status_category(status)
148
+ profile_run_statuses.setdefault(reviewer, {})
149
+ profile_run_statuses[reviewer][status] = (
150
+ profile_run_statuses[reviewer].get(status, 0) + 1
151
+ )
152
+ known_clean = bool(run.get("known_clean") or truth.get("known_clean"))
153
+ known_blocked = bool(run.get("known_blocked") or truth.get("known_blocked"))
154
+ try:
155
+ finding_count = int(run.get("finding_count") or 0)
156
+ except (TypeError, ValueError):
157
+ finding_count = 0
158
+ try:
159
+ expected_finding_matches = int(run.get("expected_finding_matches") or 0)
160
+ except (TypeError, ValueError):
161
+ expected_finding_matches = 0
162
+ if (
163
+ known_clean
164
+ and finding_count == 0
165
+ and status_category == RUN_STATUS_PASS
166
+ ):
167
+ profile_clean_run_keys.setdefault(reviewer, set()).add(reviewer_run_key)
168
+ if known_clean and status_category == RUN_STATUS_BLOCKED:
169
+ profile_blocking_false_positive_run_keys.setdefault(
170
+ reviewer, set()
171
+ ).add(reviewer_run_key)
172
+ expected_blocker_caught = bool(
173
+ run.get("expected_blocker_caught")
174
+ or run.get("caught_expected_blocker")
175
+ or (
176
+ run_disposition in USEFUL_EVIDENCE_DISPOSITIONS
177
+ and status_category == RUN_STATUS_BLOCKED
178
+ )
179
+ or expected_finding_matches > 0
180
+ )
181
+ evidence_disposition = run_disposition
182
+ evidence_notes = str(
183
+ run.get("disposition_notes")
184
+ or (
185
+ run.get("adjudication", {}).get("notes")
186
+ if isinstance(run.get("adjudication"), Mapping)
187
+ else ""
188
+ )
189
+ or ""
190
+ )
191
+ if evidence_disposition == "unknown" and expected_finding_matches > 0:
192
+ evidence_disposition = "true_positive"
193
+ evidence_notes = evidence_notes or "Matched an expected calibration finding."
194
+ if (
195
+ evidence_disposition == "unknown"
196
+ and known_clean
197
+ and status_category == RUN_STATUS_BLOCKED
198
+ ):
199
+ evidence_disposition = "false_positive"
200
+ evidence_notes = evidence_notes or "Blocked a known-clean calibration control."
201
+ if evidence_disposition != "unknown":
202
+ profile_counts.setdefault(reviewer, {})
203
+ profile_counts[reviewer][evidence_disposition] = (
204
+ profile_counts[reviewer].get(evidence_disposition, 0) + 1
205
+ )
206
+ if evidence_disposition in USEFUL_EVIDENCE_DISPOSITIONS:
207
+ profile_useful_review_classes.setdefault(reviewer, set()).add(
208
+ review_class
209
+ )
210
+ profile_useful_context_packs.setdefault(reviewer, set()).update(
211
+ context_packs
212
+ )
213
+ run_dispositions.append(
214
+ {
215
+ "profile_id": reviewer,
216
+ "repo": repo,
217
+ "pr_number": pr_number,
218
+ "head_sha": head_sha,
219
+ "difficulty": str(item.get("difficulty") or "unknown"),
220
+ "review_class": review_class,
221
+ "context_packs": context_packs,
222
+ "source": str(item.get("source") or ""),
223
+ "run_index": run_index,
224
+ "disposition": evidence_disposition,
225
+ "inferred": run_disposition == "unknown",
226
+ "notes": evidence_notes,
227
+ }
228
+ )
229
+ if known_blocked:
230
+ profile_known_blocked_run_keys.setdefault(reviewer, set()).add(
231
+ reviewer_run_key
232
+ )
233
+ if expected_blocker_caught:
234
+ profile_known_blocked_caught_run_keys.setdefault(
235
+ reviewer, set()
236
+ ).add(reviewer_run_key)
237
+ elif status_category in {RUN_STATUS_PASS, RUN_STATUS_BLOCKED}:
238
+ profile_known_blocked_missed_run_keys.setdefault(
239
+ reviewer, set()
240
+ ).add(reviewer_run_key)
241
+ if status_category == RUN_STATUS_INFRA_ERROR:
242
+ profile_infra_error_run_keys.setdefault(reviewer, set()).add(
243
+ reviewer_run_key
244
+ )
245
+ if status_category == RUN_STATUS_AUDIT_INPUT_INSUFFICIENT:
246
+ profile_audit_input_insufficient_run_keys.setdefault(
247
+ reviewer, set()
248
+ ).add(reviewer_run_key)
249
+ run_records.append(
250
+ {
251
+ "profile_id": reviewer,
252
+ "repo": repo,
253
+ "pr_number": pr_number,
254
+ "head_sha": head_sha,
255
+ "difficulty": str(item.get("difficulty") or "unknown"),
256
+ "review_class": review_class,
257
+ "context_packs": context_packs,
258
+ "source": str(item.get("source") or ""),
259
+ "run_index": run_index,
260
+ "status": status,
261
+ "status_category": status_category,
262
+ "known_clean": known_clean,
263
+ "known_blocked": known_blocked,
264
+ "finding_count": finding_count,
265
+ "expected_finding_matches": expected_finding_matches,
266
+ "expected_blocker_caught": expected_blocker_caught,
267
+ "disposition": evidence_disposition,
268
+ "duration_seconds": run.get("duration_seconds"),
269
+ "parse_status": str(run.get("parse_status") or ""),
270
+ "result_category": str(run.get("result_category") or ""),
271
+ "audit_input_insufficient_count": int(
272
+ run.get("audit_input_insufficient_count") or 0
273
+ ),
274
+ "artifact": str(run.get("artifact") or ""),
275
+ "calibration_run_id": run_identity,
276
+ }
277
+ )
278
+
279
+ profiles = {
280
+ reviewer: {
281
+ "model": models.get(reviewer, ""),
282
+ "runs": len(profile_runs.get(reviewer, set())),
283
+ "duration_seconds_total": round(profile_durations.get(reviewer, 0.0), 3),
284
+ "dispositions": dict(sorted(counts.items())),
285
+ "finding_count": sum(counts.values()),
286
+ "known_clean_pass_runs": len(profile_clean_run_keys.get(reviewer, set())),
287
+ "blocking_false_positive_runs": len(
288
+ profile_blocking_false_positive_run_keys.get(reviewer, set())
289
+ ),
290
+ "known_blocked_runs": len(profile_known_blocked_run_keys.get(reviewer, set())),
291
+ "known_blocked_caught_runs": len(
292
+ profile_known_blocked_caught_run_keys.get(reviewer, set())
293
+ ),
294
+ "known_blocked_missed_runs": len(
295
+ profile_known_blocked_missed_run_keys.get(reviewer, set())
296
+ ),
297
+ "infra_error_runs": len(profile_infra_error_run_keys.get(reviewer, set())),
298
+ "audit_input_insufficient_runs": len(
299
+ profile_audit_input_insufficient_run_keys.get(reviewer, set())
300
+ ),
301
+ "run_statuses": dict(sorted(profile_run_statuses.get(reviewer, {}).items())),
302
+ "review_classes": sorted(profile_review_classes.get(reviewer, set())),
303
+ "context_packs": sorted(profile_context_packs.get(reviewer, set())),
304
+ "useful_review_classes": sorted(
305
+ profile_useful_review_classes.get(reviewer, set())
306
+ ),
307
+ "useful_context_packs": sorted(
308
+ profile_useful_context_packs.get(reviewer, set())
309
+ ),
310
+ }
311
+ for reviewer in sorted(set(profile_counts) | set(profile_runs))
312
+ for counts in [profile_counts.get(reviewer, {})]
313
+ }
314
+ return {
315
+ "mode": "reviewer-evidence-calibration",
316
+ "corpus_name": corpus.get("name", ""),
317
+ "description": corpus.get("description", ""),
318
+ "source_item_count": len(corpus.get("corpus", []) or []),
319
+ "evidence_count": len(findings) + len(run_dispositions),
320
+ "finding_evidence_count": len(findings),
321
+ "run_disposition_count": len(run_dispositions),
322
+ "sources": [str(corpus.get("name", "calibration-corpus"))],
323
+ "profiles": profiles,
324
+ "findings": findings,
325
+ "run_dispositions": run_dispositions,
326
+ "reviewer_runs": run_records,
327
+ "caveat": (
328
+ "This report summarizes historical adjudicated evidence embedded in "
329
+ "the corpus. Use it to bootstrap calibration, then confirm with fresh "
330
+ "blind runs before promoting lanes."
331
+ ),
332
+ }
333
+
334
+
335
+ def render_evidence_text(report: Mapping[str, Any]) -> str:
336
+ lines = [
337
+ "Code Mower reviewer evidence",
338
+ f"Corpus: {report.get('corpus_name', '')}",
339
+ f"Adjudicated evidence: {report.get('evidence_count', 0)}",
340
+ f"Finding evidence: {report.get('finding_evidence_count', 0)}",
341
+ f"Run dispositions: {report.get('run_disposition_count', 0)}",
342
+ "",
343
+ "Profiles:",
344
+ ]
345
+ profiles = report.get("profiles", {})
346
+ if isinstance(profiles, Mapping) and profiles:
347
+ for profile_id, stats in profiles.items():
348
+ if not isinstance(stats, Mapping):
349
+ continue
350
+ dispositions = stats.get("dispositions", {})
351
+ lines.append(
352
+ f"- {profile_id}: runs={stats.get('runs', 0)} "
353
+ f"findings={stats.get('finding_count', 0)} "
354
+ f"clean_passes={stats.get('known_clean_pass_runs', 0)} "
355
+ f"dispositions={dispositions}"
356
+ )
357
+ else:
358
+ lines.append("- none")
359
+ lines.extend(["", f"Caveat: {report.get('caveat', '')}"])
360
+ return "\n".join(lines) + "\n"