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
code_mower/package.py ADDED
@@ -0,0 +1,673 @@
1
+ #!/usr/bin/env python3
2
+ """Render the future standalone Code Mower package/install dry-run."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import re
9
+ import shutil
10
+ import sys
11
+ from pathlib import Path
12
+ from typing import Any, Mapping
13
+
14
+
15
+ def _render_provider_catalog_json_fallback(data: Mapping[str, Any]) -> str:
16
+ return json.dumps(data, indent=2, sort_keys=True) + "\n"
17
+
18
+
19
+ if __package__ in {None, ""}:
20
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
21
+
22
+ if __package__ in {None, "", "tools"}:
23
+ from tools.code_mower_config import (
24
+ ConfigError,
25
+ RenderedPlan,
26
+ SAFE_IDENTIFIER_RE,
27
+ _format_issues,
28
+ load_config,
29
+ validate_config,
30
+ )
31
+ from tools.code_mower_package_paths import (
32
+ DEFAULT_PROVIDER_TEMPLATES,
33
+ _as_mapping,
34
+ load_provider_templates,
35
+ resolve_provider_templates_path,
36
+ )
37
+ else: # pragma: no cover - exercised after package extraction.
38
+ from .config import (
39
+ ConfigError,
40
+ RenderedPlan,
41
+ SAFE_IDENTIFIER_RE,
42
+ _format_issues,
43
+ load_config,
44
+ validate_config,
45
+ )
46
+ from .package_paths import (
47
+ DEFAULT_PROVIDER_TEMPLATES,
48
+ _as_mapping,
49
+ load_provider_templates,
50
+ resolve_provider_templates_path,
51
+ )
52
+
53
+ if __package__ in {None, "", "tools"}: # pragma: no cover - legacy product-local layout.
54
+ try:
55
+ from tools.code_mower_package_content import (
56
+ cli_commands,
57
+ _config_template_text,
58
+ _init_py_text,
59
+ _pyproject_text,
60
+ _workflow_template_text,
61
+ )
62
+ except ModuleNotFoundError:
63
+ from code_mower.package_content import (
64
+ cli_commands,
65
+ _config_template_text,
66
+ _init_py_text,
67
+ _pyproject_text,
68
+ _workflow_template_text,
69
+ )
70
+ else: # pragma: no cover - exercised after package extraction.
71
+ from .package_content import (
72
+ cli_commands,
73
+ _config_template_text,
74
+ _init_py_text,
75
+ _pyproject_text,
76
+ _workflow_template_text,
77
+ )
78
+
79
+ if __package__ in {None, ""}:
80
+ from code_mower.package_manifest import (
81
+ DEFAULT_PACKAGE_CONFIG,
82
+ DEFERRED_PACKAGE_FILES,
83
+ PACKAGE_FILES,
84
+ TEMPLATE_FILES,
85
+ )
86
+ if __package__ in {None, "", "tools"}: # pragma: no cover - legacy product-local layout.
87
+ try:
88
+ from tools.code_mower_package_manifest import (
89
+ DEFAULT_PACKAGE_CONFIG,
90
+ DEFERRED_PACKAGE_FILES,
91
+ PACKAGE_FILES,
92
+ TEMPLATE_FILES,
93
+ )
94
+ except ModuleNotFoundError:
95
+ from code_mower.package_manifest import (
96
+ DEFAULT_PACKAGE_CONFIG,
97
+ DEFERRED_PACKAGE_FILES,
98
+ PACKAGE_FILES,
99
+ TEMPLATE_FILES,
100
+ )
101
+ else: # pragma: no cover - exercised after package extraction.
102
+ from .package_manifest import (
103
+ DEFAULT_PACKAGE_CONFIG,
104
+ DEFERRED_PACKAGE_FILES,
105
+ PACKAGE_FILES,
106
+ TEMPLATE_FILES,
107
+ )
108
+
109
+ if __package__ in {None, "", "tools"}: # pragma: no cover - legacy product-local layout.
110
+ try:
111
+ from tools.code_mower_package_static import STATIC_PACKAGE_FILES
112
+ except ModuleNotFoundError:
113
+ from code_mower.package_static import STATIC_PACKAGE_FILES
114
+ else: # pragma: no cover - exercised after package extraction.
115
+ from .package_static import STATIC_PACKAGE_FILES
116
+
117
+ if __package__ in {None, "", "tools"}: # pragma: no cover - legacy product-local layout.
118
+ try:
119
+ from tools.code_mower_package_rendering import _render_provider_catalog
120
+ except ModuleNotFoundError:
121
+ try:
122
+ from code_mower.package_rendering import _render_provider_catalog
123
+ except ModuleNotFoundError:
124
+ _render_provider_catalog = _render_provider_catalog_json_fallback
125
+ else: # pragma: no cover - exercised after package extraction.
126
+ from .package_rendering import _render_provider_catalog
127
+
128
+
129
+ def _running_code_mower_version(repo_root: Path | None = None) -> str:
130
+ roots = [repo_root] if repo_root is not None else []
131
+ roots.extend(_candidate_package_source_roots())
132
+ version_re = re.compile(r'__version__\s*=\s*"([^"]+)"')
133
+ for root in roots:
134
+ if root is None:
135
+ continue
136
+ init_path = root / "src/code_mower/__init__.py"
137
+ if not init_path.is_file():
138
+ continue
139
+ match = version_re.search(init_path.read_text(encoding="utf-8"))
140
+ if match:
141
+ return match.group(1)
142
+
143
+ loaded_package = sys.modules.get("code_mower")
144
+ loaded_version = getattr(loaded_package, "__version__", "")
145
+ if isinstance(loaded_version, str) and loaded_version:
146
+ return loaded_version
147
+
148
+ return "0.0.0"
149
+
150
+
151
+ def resolve_package_config_path(path_text: str, *, explicit: bool = False) -> Path:
152
+ path = Path(path_text)
153
+ if explicit or path_text != DEFAULT_PACKAGE_CONFIG or path.is_absolute():
154
+ return path
155
+
156
+ for root in _candidate_package_source_roots():
157
+ source_path = _resolve_package_source(
158
+ root,
159
+ DEFAULT_PACKAGE_CONFIG,
160
+ "src/code_mower/templates/code-mower.example.yml",
161
+ )
162
+ if source_path is not None:
163
+ return source_path
164
+
165
+ return Path.cwd() / DEFAULT_PACKAGE_CONFIG
166
+
167
+
168
+ def _provider_template_rows(
169
+ config: Mapping[str, Any],
170
+ provider_templates: Mapping[str, Any],
171
+ ) -> list[dict[str, Any]]:
172
+ lanes = _as_mapping(config.get("lanes"), "lanes")
173
+ templates = _as_mapping(provider_templates.get("provider_templates"), "provider_templates")
174
+ rows: list[dict[str, Any]] = []
175
+ missing = sorted(set(lanes) - set(templates))
176
+
177
+ for lane_id in sorted(templates):
178
+ if not SAFE_IDENTIFIER_RE.fullmatch(str(lane_id)):
179
+ raise ConfigError(
180
+ "provider template lane ids must match "
181
+ f"[A-Za-z0-9][A-Za-z0-9_-]*: {lane_id}"
182
+ )
183
+ template = templates.get(lane_id)
184
+ if not isinstance(template, Mapping):
185
+ raise ConfigError(f"provider template for {lane_id} must be a mapping")
186
+ lane = lanes.get(lane_id, {})
187
+ if not isinstance(lane, Mapping):
188
+ lane = {}
189
+ token_env = lane.get("token_env", template.get("token_env", []))
190
+ token_env_any = lane.get("token_env_any", template.get("token_env_any", []))
191
+ review_hygiene = lane.get("review_hygiene", template.get("review_hygiene", {}))
192
+ if not token_env and isinstance(review_hygiene, Mapping):
193
+ review_token = review_hygiene.get("token_env")
194
+ token_env = [review_token] if review_token else []
195
+ rows.append(
196
+ {
197
+ "lane": str(lane_id),
198
+ "provider": str(lane.get("provider", template.get("provider", ""))),
199
+ "driver": str(lane.get("driver", template.get("driver", ""))),
200
+ "type": str(lane.get("type", template.get("type", ""))),
201
+ "adapter": lane.get("adapter", template.get("adapter")),
202
+ "trailer_lane": lane.get("trailer_lane", template.get("trailer_lane")),
203
+ "spend_policy": str(lane.get("spend_policy", template.get("spend_policy", "none"))),
204
+ "merge_authority": bool(lane.get("merge_authority", template.get("merge_authority", False))),
205
+ "informational": bool(lane.get("informational", template.get("informational", False))),
206
+ "enabled_by_default": lane.get(
207
+ "enabled_by_default",
208
+ template.get("enabled_by_default", True),
209
+ ),
210
+ "events": list(lane.get("events", template.get("events", []))),
211
+ "token_env": list(token_env),
212
+ "token_env_any": list(token_env_any),
213
+ "trigger_policy": lane.get(
214
+ "trigger_policy",
215
+ template.get("trigger_policy", "label"),
216
+ ),
217
+ "provider_config": lane.get(
218
+ "provider_config",
219
+ template.get("provider_config", {}),
220
+ ),
221
+ "review_hygiene": review_hygiene if isinstance(review_hygiene, Mapping) else {},
222
+ "template_path": f"templates/providers/{lane_id}.yml",
223
+ }
224
+ )
225
+
226
+ if missing:
227
+ missing_text = ", ".join(sorted(missing))
228
+ raise ConfigError(f"provider templates missing configured lanes: {missing_text}")
229
+ return rows
230
+
231
+
232
+ def render_package_plan(
233
+ config: Mapping[str, Any],
234
+ provider_templates: Mapping[str, Any],
235
+ package_name: str = "code-mower",
236
+ ) -> RenderedPlan:
237
+ if not SAFE_IDENTIFIER_RE.fullmatch(package_name):
238
+ raise ConfigError("package name must match [A-Za-z0-9][A-Za-z0-9_-]*")
239
+
240
+ issues = validate_config(config)
241
+ if issues:
242
+ raise ConfigError(f"invalid Code Mower config:\n{_format_issues(issues)}")
243
+
244
+ template_rows = _provider_template_rows(config, provider_templates)
245
+ catalog_profiles = _as_mapping(provider_templates.get("profiles"), "profiles")
246
+ repo_profiles = _as_mapping(config.get("profiles", {}), "profiles")
247
+ profiles: dict[str, dict[str, Any]] = {
248
+ profile_id: {
249
+ "description": profile.get("description", ""),
250
+ "lanes": profile.get("lanes", []),
251
+ "source": "catalog",
252
+ }
253
+ for profile_id, profile in catalog_profiles.items()
254
+ }
255
+ for profile_id, profile in repo_profiles.items():
256
+ profiles[profile_id] = {
257
+ "description": profile.get(
258
+ "description",
259
+ profiles.get(profile_id, {}).get("description", "Repo-local profile."),
260
+ ),
261
+ "lanes": profile.get("lanes", []),
262
+ "source": "repo",
263
+ }
264
+
265
+ package_files = [
266
+ {"source": source, "target": target, "kind": kind}
267
+ for source, target, kind in PACKAGE_FILES
268
+ ]
269
+ deferred_package_files = [
270
+ {"source": source, "target": target, "reason": reason}
271
+ for source, target, reason in DEFERRED_PACKAGE_FILES
272
+ ]
273
+ template_files = [
274
+ {"kind": kind, "target": target}
275
+ for kind, target in TEMPLATE_FILES
276
+ ]
277
+ template_files.extend(
278
+ {"kind": "provider-template", "target": row["template_path"]}
279
+ for row in template_rows
280
+ )
281
+ package_version = _running_code_mower_version()
282
+ command_inventory = cli_commands(package_version)
283
+ data = {
284
+ "mode": "dry-run",
285
+ "package": {
286
+ "name": package_name,
287
+ "module": "code_mower",
288
+ "console_script": f"{package_name}=code_mower.cli:main",
289
+ "source_layout": "src/code_mower",
290
+ "version": package_version,
291
+ },
292
+ "package_files": package_files,
293
+ "deferred_package_files": deferred_package_files,
294
+ "template_files": template_files,
295
+ "provider_templates": template_rows,
296
+ "profiles": profiles,
297
+ "cli_commands": list(command_inventory),
298
+ "install_docs": [
299
+ "README.md",
300
+ "docs/getting-started.md",
301
+ "docs/package-skeleton.md",
302
+ "docs/package-customization.md",
303
+ "docs/repo-strategy.md",
304
+ "docs/mirror-removal-runbook.md",
305
+ "docs/commercial-boundary.md",
306
+ "docs/public-release-checklist.md",
307
+ "docs/github-setup.md",
308
+ "docs/troubleshooting.md",
309
+ "docs/provider-matrix.md",
310
+ "docs/providers.md",
311
+ "docs/security.md",
312
+ "docs/workflow-templates.md",
313
+ ],
314
+ }
315
+
316
+ lines = [
317
+ "Code Mower OSS package dry-run",
318
+ f"Package: {data['package']['name']}",
319
+ f"Module: {data['package']['module']}",
320
+ f"Console script: {data['package']['console_script']}",
321
+ "",
322
+ "Package files to extract:",
323
+ ]
324
+ lines.extend(
325
+ f"- {entry['source']} -> {entry['target']} [{entry['kind']}]"
326
+ for entry in package_files
327
+ )
328
+ lines.extend(["", "Deferred package files:"])
329
+ lines.extend(
330
+ f"- {entry['source']} -> {entry['target']} ({entry['reason']})"
331
+ for entry in deferred_package_files
332
+ )
333
+
334
+ lines.extend(["", "Template files to ship:"])
335
+ lines.extend(f"- {entry['target']} [{entry['kind']}]" for entry in template_files)
336
+
337
+ lines.extend(["", "Provider templates:"])
338
+ lines.extend(
339
+ f"- {entry['lane']}: {entry['driver']} / {entry['provider']} "
340
+ f"({entry['spend_policy']}) -> {entry['template_path']}"
341
+ for entry in template_rows
342
+ )
343
+
344
+ lines.extend(["", "CLI commands to document:"])
345
+ lines.extend(f"- {command}" for command in command_inventory)
346
+
347
+ return RenderedPlan(text="\n".join(lines) + "\n", data=data)
348
+
349
+
350
+ def _write_text(path: Path, content: str, *, force: bool) -> None:
351
+ if path.exists() and not force:
352
+ raise ConfigError(f"refusing to overwrite existing file: {path}")
353
+ path.parent.mkdir(parents=True, exist_ok=True)
354
+ path.write_text(content, encoding="utf-8")
355
+
356
+
357
+ def _copy_file(source: Path, target: Path, *, force: bool) -> None:
358
+ if not source.is_file():
359
+ raise ConfigError(f"package source file does not exist: {source}")
360
+ if target.exists() and not force:
361
+ raise ConfigError(f"refusing to overwrite existing file: {target}")
362
+ target.parent.mkdir(parents=True, exist_ok=True)
363
+ shutil.copyfile(source, target)
364
+
365
+
366
+ def _resolve_package_source(repo_root: Path, source: str, target: str) -> Path | None:
367
+ source_path = repo_root / source
368
+ if source_path.is_file():
369
+ return source_path
370
+ target_path = repo_root / target
371
+ if target_path.is_file():
372
+ return target_path
373
+ return None
374
+
375
+
376
+ def _missing_package_sources(repo_root: Path) -> list[str]:
377
+ return sorted(
378
+ source
379
+ for source, target, _ in PACKAGE_FILES
380
+ if _resolve_package_source(repo_root, source, target) is None
381
+ )
382
+
383
+
384
+ def _candidate_package_source_roots() -> list[Path]:
385
+ module_path = Path(__file__).resolve()
386
+ # Prefer the checkout that loaded this module. Fall back to cwd for
387
+ # installed-package runs that intentionally materialize a local checkout.
388
+ candidates = [
389
+ module_path.parents[2],
390
+ module_path.parents[1],
391
+ Path.cwd().resolve(),
392
+ ]
393
+ unique: list[Path] = []
394
+ seen: set[Path] = set()
395
+ for candidate in candidates:
396
+ if candidate in seen:
397
+ continue
398
+ seen.add(candidate)
399
+ unique.append(candidate)
400
+ return unique
401
+
402
+
403
+ def _default_package_source_root() -> Path:
404
+ candidates = _candidate_package_source_roots()
405
+ for candidate in candidates:
406
+ if not _missing_package_sources(candidate):
407
+ return candidate
408
+ return candidates[0]
409
+
410
+
411
+ def _planned_materialized_targets(plan: RenderedPlan) -> list[str]:
412
+ targets = [target for _, target, _ in PACKAGE_FILES]
413
+ targets.extend(target for target, _ in STATIC_PACKAGE_FILES)
414
+ targets.extend(entry["target"] for entry in plan.data["template_files"])
415
+ targets.extend(
416
+ [
417
+ "pyproject.toml",
418
+ "src/code_mower/templates/providers.yml",
419
+ "code-mower-package-manifest.json",
420
+ ]
421
+ )
422
+ targets.extend(
423
+ f"templates/providers/{row['lane']}.yml"
424
+ for row in plan.data["provider_templates"]
425
+ )
426
+ return sorted(set(targets))
427
+
428
+
429
+ def _preflight_output_collisions(plan: RenderedPlan, output_dir: Path) -> None:
430
+ collisions = [
431
+ target
432
+ for target in _planned_materialized_targets(plan)
433
+ if (output_dir / target).exists()
434
+ ]
435
+ parent_collisions: list[str] = []
436
+ for target in _planned_materialized_targets(plan):
437
+ relative_parent = Path(target).parent
438
+ while str(relative_parent) not in {"", "."}:
439
+ parent_path = output_dir / relative_parent
440
+ if parent_path.exists() and not parent_path.is_dir():
441
+ parent_collisions.append(str(relative_parent))
442
+ break
443
+ relative_parent = relative_parent.parent
444
+ collisions.extend(sorted(set(parent_collisions)))
445
+ if not collisions:
446
+ return
447
+ sample = ", ".join(collisions[:3])
448
+ if len(collisions) > 3:
449
+ sample += f", ... ({len(collisions)} total)"
450
+ raise ConfigError(f"refusing to overwrite existing file(s): {sample}")
451
+
452
+
453
+ def materialize_package_plan(
454
+ plan: RenderedPlan,
455
+ *,
456
+ output_dir: Path,
457
+ repo_root: Path | None = None,
458
+ force: bool = False,
459
+ ) -> RenderedPlan:
460
+ """Write the planned standalone package tree to ``output_dir``."""
461
+
462
+ repo_root = repo_root or Path.cwd()
463
+ missing_sources = _missing_package_sources(repo_root)
464
+ if missing_sources:
465
+ sample = ", ".join(missing_sources[:3])
466
+ if len(missing_sources) > 3:
467
+ sample += f", ... ({len(missing_sources)} total)"
468
+ raise ConfigError(
469
+ "package materialization requires the reference repository checkout; "
470
+ f"missing source file(s): {sample}"
471
+ )
472
+ if output_dir.exists() and not output_dir.is_dir():
473
+ raise ConfigError(f"output path is not a directory: {output_dir}")
474
+ if not force:
475
+ _preflight_output_collisions(plan, output_dir)
476
+ output_dir.mkdir(parents=True, exist_ok=True)
477
+ written: list[dict[str, str]] = []
478
+ package_version = _running_code_mower_version(repo_root)
479
+
480
+ for source, target, kind in PACKAGE_FILES:
481
+ source_path = _resolve_package_source(repo_root, source, target)
482
+ if source_path is None: # pragma: no cover - guarded by preflight above.
483
+ raise ConfigError(f"package source file does not exist: {repo_root / source}")
484
+ target_path = output_dir / target
485
+ _copy_file(source_path, target_path, force=force)
486
+ written.append(
487
+ {
488
+ "target": target,
489
+ "source": source_path.relative_to(repo_root).as_posix(),
490
+ "kind": kind,
491
+ }
492
+ )
493
+
494
+ for target, content in STATIC_PACKAGE_FILES:
495
+ if target == "src/code_mower/__init__.py":
496
+ content = _init_py_text(package_version)
497
+ elif target == "scripts/smoke_easy_mode.py":
498
+ content = content.replace(
499
+ "__CODE_MOWER_CONSOLE_SCRIPT__",
500
+ str(plan.data["package"]["name"]),
501
+ )
502
+ _write_text(output_dir / target, content, force=force)
503
+ written.append({"target": target, "source": "generated", "kind": "package"})
504
+ _write_text(
505
+ output_dir / "pyproject.toml",
506
+ _pyproject_text(str(plan.data["package"]["name"]), version=package_version),
507
+ force=force,
508
+ )
509
+ written.append(
510
+ {"target": "pyproject.toml", "source": "generated", "kind": "package"}
511
+ )
512
+
513
+ requirements_dir = output_dir / "requirements"
514
+ requirements_dir.mkdir(parents=True, exist_ok=True)
515
+
516
+ provider_templates = {
517
+ row["lane"]: {
518
+ key: value
519
+ for key, value in row.items()
520
+ if key not in {"lane", "template_path"}
521
+ }
522
+ for row in plan.data["provider_templates"]
523
+ }
524
+ provider_catalog = {
525
+ "version": 1,
526
+ "provider_templates": provider_templates,
527
+ "profiles": plan.data["profiles"],
528
+ }
529
+ catalog_target = "src/code_mower/templates/providers.yml"
530
+ _write_text(
531
+ output_dir / catalog_target,
532
+ _render_provider_catalog(provider_catalog),
533
+ force=force,
534
+ )
535
+ written.append(
536
+ {"target": catalog_target, "source": "generated", "kind": "provider-catalog"}
537
+ )
538
+
539
+ for entry in plan.data["template_files"]:
540
+ target = entry["target"]
541
+ kind = entry["kind"]
542
+ if target in {"pyproject.toml", "README.md", "MANIFEST.in", catalog_target}:
543
+ continue
544
+ if kind == "provider-template":
545
+ continue
546
+ if target == "templates/providers.yml":
547
+ content = _render_provider_catalog(provider_catalog)
548
+ elif target == "templates/code-mower.yml.j2":
549
+ content = _config_template_text()
550
+ elif target.startswith("templates/workflows/") or target.startswith("src/code_mower/templates/workflows/"):
551
+ content = _workflow_template_text(target)
552
+ else:
553
+ continue
554
+ _write_text(output_dir / target, content, force=force)
555
+ written.append({"target": target, "source": "generated", "kind": kind})
556
+
557
+ for row in plan.data["provider_templates"]:
558
+ lane = row["lane"]
559
+ target = f"templates/providers/{lane}.yml"
560
+ _write_text(
561
+ output_dir / target,
562
+ _render_provider_catalog({lane: provider_templates[lane]}),
563
+ force=force,
564
+ )
565
+ written.append(
566
+ {"target": target, "source": "generated", "kind": "provider-template"}
567
+ )
568
+
569
+ manifest = {
570
+ "mode": "materialize",
571
+ "package": plan.data["package"],
572
+ "output_dir": str(output_dir),
573
+ "files_written": written,
574
+ "deferred_package_files": list(plan.data["deferred_package_files"]),
575
+ }
576
+ manifest_path = output_dir / "code-mower-package-manifest.json"
577
+ written.append(
578
+ {
579
+ "target": "code-mower-package-manifest.json",
580
+ "source": "generated",
581
+ "kind": "manifest",
582
+ }
583
+ )
584
+ manifest["files_written"] = written
585
+ _write_text(
586
+ manifest_path,
587
+ json.dumps(manifest, indent=2, sort_keys=True) + "\n",
588
+ force=force,
589
+ )
590
+
591
+ lines = [
592
+ "Code Mower package materialized",
593
+ f"Output: {output_dir}",
594
+ f"Files written: {len(written)}",
595
+ "",
596
+ "Deferred package files:",
597
+ ]
598
+ if manifest["deferred_package_files"]:
599
+ lines.extend(
600
+ f"- {entry['source']} ({entry['reason']})"
601
+ for entry in manifest["deferred_package_files"]
602
+ )
603
+ else:
604
+ lines.append("- none")
605
+
606
+ return RenderedPlan(text="\n".join(lines) + "\n", data=manifest)
607
+
608
+
609
+ def main(argv: list[str] | None = None) -> int:
610
+ parser = argparse.ArgumentParser(description=__doc__)
611
+ parser.add_argument("config", nargs="?")
612
+ parser.add_argument(
613
+ "--provider-templates",
614
+ default=None,
615
+ help="provider template catalog to include in the package plan",
616
+ )
617
+ parser.add_argument("--package-name", default="code-mower")
618
+ parser.add_argument("--dry-run", action="store_true", help="render the package plan")
619
+ parser.add_argument(
620
+ "--output-dir",
621
+ type=Path,
622
+ default=None,
623
+ help="materialize the package tree into this directory",
624
+ )
625
+ parser.add_argument(
626
+ "--force",
627
+ action="store_true",
628
+ help="overwrite existing files under --output-dir",
629
+ )
630
+ parser.add_argument("--json", action="store_true", help="emit package plan as JSON")
631
+ args = parser.parse_args(argv)
632
+
633
+ if not args.dry_run and args.output_dir is None:
634
+ print("error: pass --dry-run or --output-dir", file=sys.stderr)
635
+ return 1
636
+ if args.dry_run and args.output_dir is not None:
637
+ print("error: --dry-run and --output-dir are mutually exclusive", file=sys.stderr)
638
+ return 1
639
+
640
+ try:
641
+ config_path = resolve_package_config_path(
642
+ args.config or DEFAULT_PACKAGE_CONFIG,
643
+ explicit=args.config is not None,
644
+ )
645
+ plan = render_package_plan(
646
+ load_config(config_path),
647
+ load_provider_templates(
648
+ resolve_provider_templates_path(DEFAULT_PROVIDER_TEMPLATES)
649
+ if args.provider_templates is None
650
+ else Path(args.provider_templates)
651
+ ),
652
+ package_name=args.package_name,
653
+ )
654
+ if args.output_dir is not None:
655
+ plan = materialize_package_plan(
656
+ plan,
657
+ output_dir=args.output_dir,
658
+ repo_root=_default_package_source_root(),
659
+ force=args.force,
660
+ )
661
+ except ConfigError as exc:
662
+ print(f"error: {exc}", file=sys.stderr)
663
+ return 1
664
+
665
+ if args.json:
666
+ print(json.dumps(plan.data, indent=2, sort_keys=True))
667
+ else:
668
+ print(plan.text, end="")
669
+ return 0
670
+
671
+
672
+ if __name__ == "__main__":
673
+ raise SystemExit(main())