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,591 @@
1
+ #!/usr/bin/env python3
2
+ """Plan bounded context packs for Code Mower review lanes.
3
+
4
+ Context packs are manifests, not file dumps. They let a lane ask for important
5
+ surrounding files by path/pattern without bloating every audit prompt by
6
+ default. A later runner can materialize the listed files under the declared byte
7
+ caps.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import fnmatch
14
+ import hashlib
15
+ import json
16
+ import sys
17
+ from pathlib import PurePosixPath
18
+ from typing import Any, Iterable, Mapping
19
+ from pathlib import Path
20
+
21
+
22
+ DEFAULT_PACK_ID = "changed-files-context"
23
+ DEFAULT_MAX_FILES = 12
24
+ DEFAULT_MAX_FILE_BYTES = 60_000
25
+ MAX_MATERIALIZED_FILE_BYTES = 250_000
26
+ MAX_CONTEXT_PACKS = 20
27
+
28
+
29
+ def _load_json(path: Path) -> Mapping[str, Any]:
30
+ try:
31
+ payload = json.loads(path.read_text(encoding="utf-8"))
32
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
33
+ raise ValueError(f"unable to read {path}: {exc}") from exc
34
+ if not isinstance(payload, Mapping):
35
+ raise ValueError(f"{path} must contain a JSON object")
36
+ return payload
37
+
38
+
39
+ def _safe_relative_path(value: Any) -> str:
40
+ text = str(value or "").strip().replace("\\", "/")
41
+ path = PurePosixPath(text)
42
+ if not text or path.is_absolute():
43
+ raise ValueError(f"context pack path must be relative: {text!r}")
44
+ if any(part in {"", ".", ".."} for part in path.parts):
45
+ raise ValueError(f"context pack path must not contain . or ..: {text!r}")
46
+ return str(path)
47
+
48
+
49
+ def _safe_pack_id(value: Any) -> str:
50
+ text = str(value or "").strip()
51
+ path = PurePosixPath(text.replace("\\", "/"))
52
+ if not text or path.is_absolute() or len(path.parts) != 1:
53
+ raise ValueError(f"context pack id must be a safe file name: {text!r}")
54
+ if any(part in {"", ".", ".."} for part in path.parts):
55
+ raise ValueError(f"context pack id must not contain . or ..: {text!r}")
56
+ return text
57
+
58
+
59
+ def _file_entries(values: Iterable[Any], *, default_repo: str) -> list[dict[str, str]]:
60
+ entries: list[dict[str, str]] = []
61
+ seen: set[tuple[str, str]] = set()
62
+ for item in values:
63
+ if isinstance(item, Mapping):
64
+ raw_path = item.get("filename", item.get("path"))
65
+ repo = str(item.get("repo") or default_repo)
66
+ else:
67
+ raw_path = item
68
+ repo = default_repo
69
+ path = _safe_relative_path(raw_path)
70
+ key = (repo, path)
71
+ if key not in seen:
72
+ seen.add(key)
73
+ entry = {"path": path}
74
+ if repo and repo != default_repo:
75
+ entry["repo"] = repo
76
+ entries.append(entry)
77
+ return entries
78
+
79
+
80
+ def _patterns_for_pack(pack: Mapping[str, Any]) -> list[str]:
81
+ includes = pack.get("include")
82
+ if includes is None:
83
+ includes = pack.get("paths")
84
+ if includes is None:
85
+ return ["*"]
86
+ if not isinstance(includes, list):
87
+ raise ValueError("context pack include must be a list")
88
+ patterns = []
89
+ for pattern in includes:
90
+ text = str(pattern or "").strip().replace("\\", "/")
91
+ if not text:
92
+ continue
93
+ if PurePosixPath(text).is_absolute() or ".." in PurePosixPath(text).parts:
94
+ raise ValueError(f"context pack pattern must be relative: {text!r}")
95
+ patterns.append(text)
96
+ return patterns or ["*"]
97
+
98
+
99
+ def _repos_for_pack(pack: Mapping[str, Any], default_repo: str) -> set[str]:
100
+ repos = pack.get("repos")
101
+ if repos is None:
102
+ repo = str(pack.get("repo") or default_repo)
103
+ return {repo}
104
+ if not isinstance(repos, list):
105
+ raise ValueError("context pack repos must be a list")
106
+ normalized = {str(repo).strip() for repo in repos if str(repo).strip()}
107
+ return normalized or {default_repo}
108
+
109
+
110
+ def _plan_file_entry(path: str, repo: str, default_repo: str, max_bytes: int) -> dict[str, Any]:
111
+ entry: dict[str, Any] = {"path": path, "max_bytes": max_bytes}
112
+ if repo and repo != default_repo:
113
+ entry["repo"] = repo
114
+ return entry
115
+
116
+
117
+ def _int_option(value: Any, default: int, name: str) -> int:
118
+ if value is None:
119
+ return default
120
+ if isinstance(value, bool):
121
+ raise ValueError(f"{name} must be an integer")
122
+ try:
123
+ parsed = int(value)
124
+ except (TypeError, ValueError) as exc:
125
+ raise ValueError(f"{name} must be an integer") from exc
126
+ if parsed <= 0:
127
+ raise ValueError(f"{name} must be greater than zero")
128
+ return parsed
129
+
130
+
131
+ def _file_bytes_option(value: Any, default: int, name: str) -> tuple[int, bool]:
132
+ parsed = _int_option(value, default, name)
133
+ if parsed > MAX_MATERIALIZED_FILE_BYTES:
134
+ return MAX_MATERIALIZED_FILE_BYTES, True
135
+ return parsed, False
136
+
137
+
138
+ def _default_pack() -> dict[str, Any]:
139
+ return {
140
+ "id": DEFAULT_PACK_ID,
141
+ "reason": "bounded surrounding context for changed files",
142
+ "include": ["*"],
143
+ }
144
+
145
+
146
+ def build_context_pack_plan(manifest: Mapping[str, Any]) -> dict[str, Any]:
147
+ default_repo = str(manifest.get("repo") or "")
148
+ changed_values = manifest.get("changed_files", [])
149
+ if not isinstance(changed_values, list):
150
+ raise ValueError("changed_files must be a list")
151
+ changed_files = _file_entries(changed_values, default_repo=default_repo)
152
+
153
+ pack_values = manifest.get("packs") or [_default_pack()]
154
+ if not isinstance(pack_values, list):
155
+ raise ValueError("packs must be a list")
156
+ if len(pack_values) > MAX_CONTEXT_PACKS:
157
+ raise ValueError(f"too many context packs: max {MAX_CONTEXT_PACKS}")
158
+
159
+ planned_packs: list[dict[str, Any]] = []
160
+ warnings: list[str] = []
161
+ seen_pack_ids: set[str] = set()
162
+
163
+ for pack_value in pack_values:
164
+ if not isinstance(pack_value, Mapping):
165
+ raise ValueError("each context pack must be an object")
166
+ pack_id = _safe_pack_id(pack_value.get("id") or DEFAULT_PACK_ID)
167
+ if pack_id in seen_pack_ids:
168
+ raise ValueError(f"duplicate context pack id: {pack_id}")
169
+ seen_pack_ids.add(pack_id)
170
+
171
+ patterns = _patterns_for_pack(pack_value)
172
+ pack_repos = _repos_for_pack(pack_value, default_repo)
173
+ if (
174
+ not default_repo
175
+ and pack_value.get("repo") is None
176
+ and pack_value.get("repos") is None
177
+ ):
178
+ pack_repos = {
179
+ str(file_entry.get("repo") or default_repo)
180
+ for file_entry in changed_files
181
+ } or {default_repo}
182
+ max_files = _int_option(pack_value.get("max_files"), DEFAULT_MAX_FILES, "max_files")
183
+ max_file_bytes, capped_bytes = _file_bytes_option(
184
+ pack_value.get("max_file_bytes"),
185
+ DEFAULT_MAX_FILE_BYTES,
186
+ "max_file_bytes",
187
+ )
188
+ if capped_bytes:
189
+ warnings.append(
190
+ f"{pack_id}: max_file_bytes capped at {MAX_MATERIALIZED_FILE_BYTES}"
191
+ )
192
+ matched_changed_entries = []
193
+ for file_entry in changed_files:
194
+ path = file_entry["path"]
195
+ repo = file_entry.get("repo", default_repo)
196
+ if repo in pack_repos and any(fnmatch.fnmatch(path, pattern) for pattern in patterns):
197
+ matched_changed_entries.append(
198
+ _plan_file_entry(path, repo, default_repo, max_file_bytes)
199
+ )
200
+ extra_values = pack_value.get("extra_files", pack_value.get("context_files", []))
201
+ if extra_values is None:
202
+ extra_values = []
203
+ if not isinstance(extra_values, list):
204
+ raise ValueError("context pack extra_files must be a list")
205
+ extra_entries = []
206
+ extra_default_repo = (
207
+ str(pack_value.get("repo"))
208
+ if pack_value.get("repo")
209
+ else next(iter(pack_repos))
210
+ if len(pack_repos) == 1
211
+ else default_repo
212
+ )
213
+ for file_entry in _file_entries(extra_values, default_repo=extra_default_repo):
214
+ repo = file_entry.get("repo", extra_default_repo)
215
+ extra_entries.append(
216
+ _plan_file_entry(file_entry["path"], repo, default_repo, max_file_bytes)
217
+ )
218
+ pack_repos = pack_repos | {
219
+ str(file_entry.get("repo") or default_repo)
220
+ for file_entry in extra_entries
221
+ }
222
+ deduped_entries: list[dict[str, Any]] = []
223
+ seen_files: set[tuple[str, str]] = set()
224
+ for file_entry in [*matched_changed_entries, *extra_entries]:
225
+ repo = str(file_entry.get("repo") or default_repo)
226
+ key = (repo, str(file_entry.get("path") or ""))
227
+ if key not in seen_files:
228
+ seen_files.add(key)
229
+ deduped_entries.append(file_entry)
230
+ if len(deduped_entries) > max_files:
231
+ warnings.append(
232
+ f"{pack_id}: matched {len(deduped_entries)} files; keeping first {max_files} with explicit extra_files first"
233
+ )
234
+ priority_entries: list[dict[str, Any]] = []
235
+ seen_priority_files: set[tuple[str, str]] = set()
236
+ for file_entry in [*extra_entries, *matched_changed_entries]:
237
+ repo = str(file_entry.get("repo") or default_repo)
238
+ key = (repo, str(file_entry.get("path") or ""))
239
+ if key not in seen_priority_files:
240
+ seen_priority_files.add(key)
241
+ priority_entries.append(file_entry)
242
+ deduped_entries = priority_entries[:max_files]
243
+ if not deduped_entries:
244
+ warnings.append(f"{pack_id}: no changed files matched include patterns")
245
+
246
+ planned_packs.append(
247
+ {
248
+ "id": pack_id,
249
+ "reason": str(pack_value.get("reason") or ""),
250
+ "include": patterns,
251
+ "repos": sorted(pack_repos),
252
+ "max_files": max_files,
253
+ "max_file_bytes": max_file_bytes,
254
+ "files": deduped_entries,
255
+ }
256
+ )
257
+
258
+ return {
259
+ "mode": "code-mower-context-packs",
260
+ "repo": default_repo,
261
+ "pr_number": manifest.get("pr_number"),
262
+ "head_sha": str(manifest.get("head_sha") or ""),
263
+ "changed_file_count": len(changed_files),
264
+ "packs": planned_packs,
265
+ "warnings": warnings,
266
+ }
267
+
268
+
269
+ def render_context_pack_text(plan: Mapping[str, Any]) -> str:
270
+ lines = [
271
+ "Code Mower context pack plan",
272
+ f"Changed files: {plan.get('changed_file_count', 0)}",
273
+ "",
274
+ "Packs:",
275
+ ]
276
+ packs = plan.get("packs", [])
277
+ if isinstance(packs, list) and packs:
278
+ for pack in packs:
279
+ if not isinstance(pack, Mapping):
280
+ continue
281
+ files = pack.get("files", [])
282
+ lines.append(
283
+ f"- {pack.get('id')}: {len(files) if isinstance(files, list) else 0} files"
284
+ )
285
+ if isinstance(files, list):
286
+ for file_entry in files:
287
+ if isinstance(file_entry, Mapping):
288
+ lines.append(
289
+ f" - {file_entry.get('path')} "
290
+ f"(max {file_entry.get('max_bytes')} bytes)"
291
+ )
292
+ else:
293
+ lines.append("- none")
294
+ warnings = plan.get("warnings", [])
295
+ if isinstance(warnings, list) and warnings:
296
+ lines.extend(["", "Warnings:"])
297
+ lines.extend(f"- {warning}" for warning in warnings)
298
+ return "\n".join(lines) + "\n"
299
+
300
+
301
+ def _read_bounded_file(source: Path, max_bytes: int) -> tuple[bytes, int, bool]:
302
+ source_bytes = source.stat().st_size
303
+ with source.open("rb") as handle:
304
+ data = handle.read(max_bytes + 1)
305
+ truncated = len(data) > max_bytes
306
+ if truncated:
307
+ data = data[:max_bytes]
308
+ return data, source_bytes, truncated
309
+
310
+
311
+ def _path_is_relative_to(path: Path, root: Path) -> bool:
312
+ try:
313
+ path.relative_to(root)
314
+ except ValueError:
315
+ return False
316
+ return True
317
+
318
+
319
+ def materialize_context_pack_plan(
320
+ plan: Mapping[str, Any],
321
+ *,
322
+ repo_root: Path,
323
+ output_dir: Path,
324
+ require_files: bool = False,
325
+ repo_roots: Mapping[str, Path] | None = None,
326
+ ) -> dict[str, Any]:
327
+ """Write bounded context pack files and a manifest under ``output_dir``."""
328
+
329
+ if plan.get("mode") != "code-mower-context-packs":
330
+ raise ValueError("context materialization expects a context pack plan")
331
+
332
+ default_repo = str(plan.get("repo") or "")
333
+ repo_root = repo_root.resolve()
334
+ resolved_repo_roots = {
335
+ str(repo): root.resolve()
336
+ for repo, root in (repo_roots or {}).items()
337
+ }
338
+ resolved_repo_roots.setdefault(default_repo, repo_root)
339
+ plan_file_repos = {
340
+ str(file_entry.get("repo") or default_repo)
341
+ for pack in plan.get("packs", [])
342
+ if isinstance(pack, Mapping)
343
+ for file_entry in pack.get("files", [])
344
+ if isinstance(file_entry, Mapping)
345
+ }
346
+ non_default_plan_file_repos = {
347
+ repo for repo in plan_file_repos if repo and repo != default_repo
348
+ }
349
+ if not default_repo and len(non_default_plan_file_repos) == 1:
350
+ resolved_repo_roots.setdefault(next(iter(non_default_plan_file_repos)), repo_root)
351
+ output_dir.mkdir(parents=True, exist_ok=True)
352
+ warnings = list(plan.get("warnings", [])) if isinstance(plan.get("warnings"), list) else []
353
+ materialized_packs: list[dict[str, Any]] = []
354
+
355
+ packs = plan.get("packs", [])
356
+ if not isinstance(packs, list):
357
+ raise ValueError("context pack plan packs must be a list")
358
+
359
+ for pack in packs:
360
+ if not isinstance(pack, Mapping):
361
+ continue
362
+ pack_id = _safe_pack_id(pack.get("id") or DEFAULT_PACK_ID)
363
+ materialized_files: list[dict[str, Any]] = []
364
+ files = pack.get("files", [])
365
+ if not isinstance(files, list):
366
+ raise ValueError("context pack files must be a list")
367
+
368
+ for file_entry in files:
369
+ if not isinstance(file_entry, Mapping):
370
+ continue
371
+ file_repo = str(file_entry.get("repo") or default_repo)
372
+ relative_path = _safe_relative_path(file_entry.get("path"))
373
+ max_bytes, capped_bytes = _file_bytes_option(
374
+ file_entry.get("max_bytes"),
375
+ DEFAULT_MAX_FILE_BYTES,
376
+ "max_bytes",
377
+ )
378
+ if capped_bytes:
379
+ warnings.append(
380
+ f"{pack_id}: {relative_path} max_bytes capped at "
381
+ f"{MAX_MATERIALIZED_FILE_BYTES}"
382
+ )
383
+ source_root = resolved_repo_roots.get(file_repo)
384
+ if source_root is None:
385
+ message = (
386
+ f"{pack_id}: no repo root configured for related repo "
387
+ f"{file_repo or '(unknown)'} file {relative_path}"
388
+ )
389
+ if require_files:
390
+ raise ValueError(message)
391
+ warnings.append(message)
392
+ missing_entry = {
393
+ "path": relative_path,
394
+ "exists": False,
395
+ "reason": "missing-repo-root",
396
+ }
397
+ if file_repo and file_repo != default_repo:
398
+ missing_entry["repo"] = file_repo
399
+ materialized_files.append(missing_entry)
400
+ continue
401
+ source = source_root / relative_path
402
+ try:
403
+ resolved_source = source.resolve(strict=True)
404
+ except OSError:
405
+ resolved_source = source
406
+ source_is_symlink = source.is_symlink()
407
+ resolved_outside_repo = not _path_is_relative_to(resolved_source, source_root)
408
+ missing_or_nonfile = not resolved_source.is_file()
409
+ reason = None
410
+ if source_is_symlink:
411
+ reason = "symlink"
412
+ message = f"{pack_id}: skipped symlinked context file {relative_path}"
413
+ elif resolved_outside_repo:
414
+ reason = "outside-repo"
415
+ message = f"{pack_id}: skipped context file outside repo_root {relative_path}"
416
+ elif missing_or_nonfile:
417
+ reason = "missing"
418
+ message = f"{pack_id}: missing context file {relative_path}"
419
+
420
+ if reason is not None:
421
+ if require_files:
422
+ raise ValueError(message)
423
+ warnings.append(message)
424
+ skipped_entry = {
425
+ "path": relative_path,
426
+ "exists": False,
427
+ "reason": reason,
428
+ }
429
+ if file_repo and file_repo != default_repo:
430
+ skipped_entry["repo"] = file_repo
431
+ materialized_files.append(skipped_entry)
432
+ continue
433
+
434
+ data, source_bytes, truncated = _read_bounded_file(resolved_source, max_bytes)
435
+ repo_prefix = file_repo.replace("/", "__") if file_repo and file_repo != default_repo else ""
436
+ relative_artifact_path = PurePosixPath(relative_path)
437
+ if repo_prefix:
438
+ artifact_relative_path = (
439
+ PurePosixPath(pack_id) / "_repos" / repo_prefix / relative_artifact_path
440
+ )
441
+ elif relative_artifact_path.parts and relative_artifact_path.parts[0] == "_repos":
442
+ artifact_relative_path = PurePosixPath(pack_id) / "_primary" / relative_artifact_path
443
+ else:
444
+ artifact_relative_path = PurePosixPath(pack_id) / relative_artifact_path
445
+ artifact_path = output_dir / artifact_relative_path
446
+ artifact_path.parent.mkdir(parents=True, exist_ok=True)
447
+ artifact_path.write_bytes(data)
448
+ materialized_entry = {
449
+ "path": relative_path,
450
+ "exists": True,
451
+ "artifact_path": str(artifact_path),
452
+ "artifact_relative_path": str(artifact_relative_path),
453
+ "bytes_written": len(data),
454
+ "source_bytes": source_bytes,
455
+ "max_bytes": max_bytes,
456
+ "truncated": truncated,
457
+ "sha256": hashlib.sha256(data).hexdigest(),
458
+ }
459
+ if file_repo and file_repo != default_repo:
460
+ materialized_entry["repo"] = file_repo
461
+ materialized_files.append(materialized_entry)
462
+
463
+ materialized_packs.append(
464
+ {
465
+ "id": pack_id,
466
+ "reason": str(pack.get("reason") or ""),
467
+ "files": materialized_files,
468
+ }
469
+ )
470
+
471
+ manifest = {
472
+ "mode": "code-mower-context-pack-materialization",
473
+ "repo": str(plan.get("repo") or ""),
474
+ "pr_number": plan.get("pr_number"),
475
+ "head_sha": str(plan.get("head_sha") or ""),
476
+ "repo_root": str(repo_root),
477
+ "repo_roots": {
478
+ repo: str(root)
479
+ for repo, root in sorted(resolved_repo_roots.items())
480
+ if repo
481
+ },
482
+ "output_dir": str(output_dir),
483
+ "packs": materialized_packs,
484
+ "warnings": warnings,
485
+ }
486
+ manifest_path = output_dir / "context-pack-manifest.json"
487
+ manifest_path.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8")
488
+ manifest["manifest_path"] = str(manifest_path)
489
+ return manifest
490
+
491
+
492
+ def render_context_pack_materialization_text(report: Mapping[str, Any]) -> str:
493
+ lines = [
494
+ "Code Mower context pack materialization",
495
+ f"Output: {report.get('output_dir')}",
496
+ "",
497
+ "Packs:",
498
+ ]
499
+ packs = report.get("packs", [])
500
+ if isinstance(packs, list) and packs:
501
+ for pack in packs:
502
+ if not isinstance(pack, Mapping):
503
+ continue
504
+ files = pack.get("files", [])
505
+ lines.append(
506
+ f"- {pack.get('id')}: {len(files) if isinstance(files, list) else 0} files"
507
+ )
508
+ if isinstance(files, list):
509
+ for file_entry in files:
510
+ if not isinstance(file_entry, Mapping):
511
+ continue
512
+ if file_entry.get("exists") is False:
513
+ lines.append(f" - {file_entry.get('path')} (missing)")
514
+ else:
515
+ suffix = " truncated" if file_entry.get("truncated") else ""
516
+ lines.append(
517
+ f" - {file_entry.get('path')} -> "
518
+ f"{file_entry.get('artifact_relative_path')} "
519
+ f"({file_entry.get('bytes_written')} bytes{suffix})"
520
+ )
521
+ else:
522
+ lines.append("- none")
523
+ warnings = report.get("warnings", [])
524
+ if isinstance(warnings, list) and warnings:
525
+ lines.extend(["", "Warnings:"])
526
+ lines.extend(f"- {warning}" for warning in warnings)
527
+ return "\n".join(lines) + "\n"
528
+
529
+
530
+ def main(argv: list[str] | None = None) -> int:
531
+ parser = argparse.ArgumentParser(description=__doc__)
532
+ parser.add_argument("manifest", type=Path)
533
+ parser.add_argument("--write", action="store_true")
534
+ parser.add_argument(
535
+ "--repo-root",
536
+ type=Path,
537
+ default=Path("."),
538
+ help="Repository root used when materializing files.",
539
+ )
540
+ parser.add_argument(
541
+ "--output-dir",
542
+ type=Path,
543
+ default=Path(".code-mower/context-packs"),
544
+ help="Directory for materialized context pack artifacts.",
545
+ )
546
+ parser.add_argument(
547
+ "--repo-root-map",
548
+ action="append",
549
+ default=[],
550
+ metavar="OWNER/REPO=PATH",
551
+ help="Additional repository root used when materializing related-repo context.",
552
+ )
553
+ parser.add_argument("--require-files", action="store_true")
554
+ parser.add_argument("--json", action="store_true")
555
+ args = parser.parse_args(argv)
556
+
557
+ try:
558
+ plan = build_context_pack_plan(_load_json(args.manifest))
559
+ payload: Mapping[str, Any]
560
+ if args.write:
561
+ repo_roots = {}
562
+ for item in args.repo_root_map:
563
+ repo, sep, path_text = item.partition("=")
564
+ if not sep or not repo.strip() or not path_text.strip():
565
+ raise ValueError("--repo-root-map must be OWNER/REPO=PATH")
566
+ repo_roots[repo.strip()] = Path(path_text)
567
+ payload = materialize_context_pack_plan(
568
+ plan,
569
+ repo_root=args.repo_root,
570
+ output_dir=args.output_dir,
571
+ require_files=args.require_files,
572
+ repo_roots=repo_roots,
573
+ )
574
+ else:
575
+ payload = plan
576
+ except ValueError as exc:
577
+ print(f"error: {exc}", file=sys.stderr)
578
+ return 1
579
+
580
+ if args.json:
581
+ print(json.dumps(payload, indent=2, sort_keys=True))
582
+ else:
583
+ if args.write:
584
+ print(render_context_pack_materialization_text(payload), end="")
585
+ else:
586
+ print(render_context_pack_text(payload), end="")
587
+ return 0
588
+
589
+
590
+ if __name__ == "__main__":
591
+ raise SystemExit(main())