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.
- code_mower/__init__.py +3 -0
- code_mower/adapters/__init__.py +39 -0
- code_mower/adapters/_base.py +148 -0
- code_mower/adapters/cursor_bugbot.py +97 -0
- code_mower/adapters/gitar.py +111 -0
- code_mower/adapters/greptile.py +191 -0
- code_mower/adapters/qodo.py +140 -0
- code_mower/antigravity_cli_audit_pr.py +241 -0
- code_mower/audit_handoff_log.py +345 -0
- code_mower/audit_labeler_lib.py +311 -0
- code_mower/audit_progress.py +203 -0
- code_mower/blind_review_artifacts.py +562 -0
- code_mower/blind_review_coordinator.py +276 -0
- code_mower/bootstrap.py +524 -0
- code_mower/builder_experiment.py +539 -0
- code_mower/calibration/__init__.py +181 -0
- code_mower/calibration/arms.py +267 -0
- code_mower/calibration/auto_discovery.py +348 -0
- code_mower/calibration/commands.py +192 -0
- code_mower/calibration/context_inputs.py +199 -0
- code_mower/calibration/corpus.py +91 -0
- code_mower/calibration/evidence.py +20 -0
- code_mower/calibration/evidence_report.py +360 -0
- code_mower/calibration/identity.py +27 -0
- code_mower/calibration/metrics.py +18 -0
- code_mower/calibration/overlap.py +103 -0
- code_mower/calibration/planning.py +309 -0
- code_mower/calibration/policy.py +207 -0
- code_mower/calibration/results.py +315 -0
- code_mower/calibration/run_results.py +147 -0
- code_mower/calibration/run_status.py +64 -0
- code_mower/calibration/runner.py +360 -0
- code_mower/calibration/truth.py +188 -0
- code_mower/calibration/value_report.py +142 -0
- code_mower/checks.py +402 -0
- code_mower/claude_audit_pr.py +1126 -0
- code_mower/claude_cli_bounce.py +303 -0
- code_mower/claude_cli_environment.py +73 -0
- code_mower/clear_stale.py +374 -0
- code_mower/cli.py +537 -0
- code_mower/cloud.py +674 -0
- code_mower/cloud_client/__init__.py +173 -0
- code_mower/cloud_client/bundle.py +155 -0
- code_mower/cloud_client/doctor.py +206 -0
- code_mower/cloud_client/dogfood.py +78 -0
- code_mower/cloud_client/endpoints.py +114 -0
- code_mower/cloud_client/errors.py +7 -0
- code_mower/cloud_client/events.py +278 -0
- code_mower/cloud_client/export.py +272 -0
- code_mower/cloud_client/git_metadata.py +46 -0
- code_mower/cloud_client/manifest.py +39 -0
- code_mower/cloud_client/operations.py +448 -0
- code_mower/cloud_client/reports.py +45 -0
- code_mower/cloud_client/setup.py +205 -0
- code_mower/cloud_client/upload.py +97 -0
- code_mower/code_mower_calibration.py +598 -0
- code_mower/code_mower_context_packs.py +591 -0
- code_mower/code_mower_merge.py +227 -0
- code_mower/code_mower_telemetry.py +561 -0
- code_mower/coderabbit_cli_audit_pr.py +526 -0
- code_mower/codex_audit_env_preflight.py +220 -0
- code_mower/codex_audit_pr.py +1738 -0
- code_mower/codex_audit_schema_smoke.py +160 -0
- code_mower/codex_audit_verdict.schema.json +44 -0
- code_mower/config.py +655 -0
- code_mower/doctor.py +161 -0
- code_mower/doctor_checks/__init__.py +104 -0
- code_mower/doctor_checks/cloud.py +129 -0
- code_mower/doctor_checks/common.py +215 -0
- code_mower/doctor_checks/github.py +128 -0
- code_mower/doctor_checks/github_actions.py +11 -0
- code_mower/doctor_checks/github_actions_cost.py +99 -0
- code_mower/doctor_checks/github_actions_cost_summary.py +111 -0
- code_mower/doctor_checks/github_actions_failure_annotations.py +27 -0
- code_mower/doctor_checks/github_actions_failure_models.py +47 -0
- code_mower/doctor_checks/github_actions_failure_scan.py +200 -0
- code_mower/doctor_checks/github_actions_failure_selection.py +63 -0
- code_mower/doctor_checks/github_actions_failures.py +103 -0
- code_mower/doctor_checks/github_actions_permissions.py +55 -0
- code_mower/doctor_checks/github_api.py +79 -0
- code_mower/doctor_checks/github_branch.py +56 -0
- code_mower/doctor_checks/github_config.py +25 -0
- code_mower/doctor_checks/github_provider.py +61 -0
- code_mower/doctor_checks/github_repo.py +120 -0
- code_mower/doctor_checks/groups.py +36 -0
- code_mower/doctor_checks/models.py +96 -0
- code_mower/doctor_checks/output.py +86 -0
- code_mower/doctor_checks/presets.py +64 -0
- code_mower/doctor_checks/privacy.py +20 -0
- code_mower/doctor_checks/provider_api_model.py +138 -0
- code_mower/doctor_checks/provider_api_model_openai.py +29 -0
- code_mower/doctor_checks/provider_api_model_profiles.py +137 -0
- code_mower/doctor_checks/provider_env.py +113 -0
- code_mower/doctor_checks/provider_env_required.py +56 -0
- code_mower/doctor_checks/provider_env_tokens.py +100 -0
- code_mower/doctor_checks/provider_local_cli.py +162 -0
- code_mower/doctor_checks/provider_local_cli_commands.py +47 -0
- code_mower/doctor_checks/provider_local_cli_probe_config.py +70 -0
- code_mower/doctor_checks/provider_probe.py +20 -0
- code_mower/doctor_checks/provider_probe_auth.py +52 -0
- code_mower/doctor_checks/provider_probe_evaluation.py +109 -0
- code_mower/doctor_checks/provider_probe_json.py +45 -0
- code_mower/doctor_checks/provider_probe_remediation.py +39 -0
- code_mower/doctor_checks/providers.py +159 -0
- code_mower/doctor_checks/registry.py +69 -0
- code_mower/doctor_checks/runner.py +188 -0
- code_mower/doctor_checks/runtime.py +89 -0
- code_mower/doctor_checks/runtime_github_auth.py +148 -0
- code_mower/gemini_cli_audit_pr.py +897 -0
- code_mower/hermes_cli_audit_pr.py +436 -0
- code_mower/init.py +888 -0
- code_mower/lane_configs/__init__.py +37 -0
- code_mower/lane_configs/aider.py +32 -0
- code_mower/lane_configs/antigravity_cli.py +35 -0
- code_mower/lane_configs/claude.py +35 -0
- code_mower/lane_configs/codex.py +32 -0
- code_mower/lane_configs/devin.py +33 -0
- code_mower/lane_configs/gemini_cli.py +35 -0
- code_mower/lane_configs/hermes_cli.py +35 -0
- code_mower/lane_configs/local_llm.py +31 -0
- code_mower/local_llm_audit_pr.py +1364 -0
- code_mower/local_llm_bakeoff.py +458 -0
- code_mower/local_llm_calibration.py +441 -0
- code_mower/local_llm_profiles.py +66 -0
- code_mower/migration.py +508 -0
- code_mower/migration_install.py +292 -0
- code_mower/migration_mirror.py +392 -0
- code_mower/migration_readiness.py +237 -0
- code_mower/migration_rehearsal.py +718 -0
- code_mower/next_steps.py +441 -0
- code_mower/package.py +673 -0
- code_mower/package_content.py +444 -0
- code_mower/package_manifest.py +452 -0
- code_mower/package_paths.py +53 -0
- code_mower/package_rendering.py +90 -0
- code_mower/package_static.py +585 -0
- code_mower/prompts.py +267 -0
- code_mower/provider_registry.py +469 -0
- code_mower/provider_runners/__init__.py +60 -0
- code_mower/provider_runners/comments.py +31 -0
- code_mower/provider_runners/git.py +46 -0
- code_mower/provider_runners/github_auth.py +61 -0
- code_mower/provider_runners/github_pr.py +120 -0
- code_mower/provider_runners/process.py +58 -0
- code_mower/provider_runners/repo_paths.py +23 -0
- code_mower/provider_runners/text_schema.py +41 -0
- code_mower/provider_runners/verdict_artifacts.py +103 -0
- code_mower/provider_runners/workspace.py +57 -0
- code_mower/release_readiness.py +549 -0
- code_mower/reviewer_metrics.py +389 -0
- code_mower/saas_reviewer_labeler.py +809 -0
- code_mower/secrets.py +89 -0
- code_mower/templates/builder-experiment.example.json +55 -0
- code_mower/templates/calibration-corpus.example.json +129 -0
- code_mower/templates/calibration-corpus.json +129 -0
- code_mower/templates/code-mower.example.yml +423 -0
- code_mower/templates/context-packs.example.json +150 -0
- code_mower/templates/lane_prompts/base-audit.md +22 -0
- code_mower/templates/lane_prompts/calibration-policy.md +21 -0
- code_mower/templates/lane_prompts/context-driven-quality.md +21 -0
- code_mower/templates/lane_prompts/docs-design.md +12 -0
- code_mower/templates/lane_prompts/generic-programming.md +21 -0
- code_mower/templates/lane_prompts/operability.md +22 -0
- code_mower/templates/lane_prompts/package-runtime.md +12 -0
- code_mower/templates/lane_prompts/security-threat-model.md +22 -0
- code_mower/templates/product-support/code_mower +216 -0
- code_mower/templates/product-support/code_mower_standalone_pin.env +7 -0
- code_mower/templates/product-support/code_mower_standalone_shadow.sh +151 -0
- code_mower/templates/product-support/run_claude_audit_pr.sh +32 -0
- code_mower/templates/product-support/run_codex_audit_pr.sh +32 -0
- code_mower/templates/product-support/safe_gh_comment.py +96 -0
- code_mower/templates/providers.yml +454 -0
- code_mower/templates/reviewer-spend.example.json +28 -0
- code_mower/templates/reviewer-value-report.example.md +20 -0
- code_mower/templates/workflows/private-standalone-shadow.yml.j2 +106 -0
- code_mower/templates/workflows/review-clear-stale.yml.j2 +83 -0
- code_mower/trailer_comment_labeler.py +207 -0
- code_mower/versioning.py +32 -0
- code_mower-0.5.0b5.dist-info/METADATA +302 -0
- code_mower-0.5.0b5.dist-info/RECORD +185 -0
- code_mower-0.5.0b5.dist-info/WHEEL +5 -0
- code_mower-0.5.0b5.dist-info/entry_points.txt +2 -0
- code_mower-0.5.0b5.dist-info/licenses/LICENSE +202 -0
- code_mower-0.5.0b5.dist-info/licenses/NOTICE +10 -0
- 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())
|