delimit-cli 4.5.7 → 4.5.9
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.
- package/CHANGELOG.md +6 -6
- package/gateway/ai/backends/gateway_core.py +62 -1
- package/gateway/ai/governance.py +1 -1
- package/gateway/ai/led193_daemon/__init__.py +61 -0
- package/gateway/ai/led193_daemon/audit.py +174 -0
- package/gateway/ai/led193_daemon/cost.py +133 -0
- package/gateway/ai/led193_daemon/executor.py +683 -0
- package/gateway/ai/led193_daemon/gate.py +300 -0
- package/gateway/ai/led193_daemon/pause.py +83 -0
- package/gateway/ai/led193_daemon/picker.py +236 -0
- package/gateway/ai/social_capability/current_capabilities.yaml +1 -0
- package/gateway/ai/workers/executor.py +18 -9
- package/gateway/core/contract_ledger.py +1 -1
- package/gateway/core/dependency_graph.py +1 -1
- package/gateway/core/dependency_manifest.py +1 -1
- package/gateway/core/event_backbone.py +2 -2
- package/gateway/core/event_schema.py +1 -1
- package/gateway/core/impact_analyzer.py +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,683 @@
|
|
|
1
|
+
"""LED-193 profile executors.
|
|
2
|
+
|
|
3
|
+
Three deterministic Class A profiles:
|
|
4
|
+
- format_fix — run language formatter, commit if changed
|
|
5
|
+
- lockfile_refresh — refresh package lockfile, commit if changed
|
|
6
|
+
- docs_typo — exact-string replacement across a file glob
|
|
7
|
+
|
|
8
|
+
NO LLM. NO judgment. Each profile is a pure transformation that either
|
|
9
|
+
produces a diff or doesn't. If it doesn't, the executor returns
|
|
10
|
+
``noop`` and no PR is opened.
|
|
11
|
+
|
|
12
|
+
Hard sandbox enforcement (per spec):
|
|
13
|
+
- Branch must start with ``auto/`` — we generate it; never accept
|
|
14
|
+
a caller-provided name.
|
|
15
|
+
- NEVER push to ``main``/``master``/``trunk``/``develop``. This is
|
|
16
|
+
a defensive check on the branch name; the executor will refuse
|
|
17
|
+
to push any branch whose name doesn't start with the ``auto/``
|
|
18
|
+
prefix.
|
|
19
|
+
- NEVER ``--no-verify``. The push command is constructed without it.
|
|
20
|
+
- NEVER force-push. The push command is constructed without ``-f``
|
|
21
|
+
/ ``--force``.
|
|
22
|
+
- Filesystem write blacklist: we refuse to operate on a repo path
|
|
23
|
+
that resolves under ``~/.delimit/secrets/``, ``~/.config/``,
|
|
24
|
+
``/etc/``, ``/root/.ssh/``.
|
|
25
|
+
|
|
26
|
+
NB: the executor does not itself spawn a subagent. Per spec the
|
|
27
|
+
deterministic profiles run in-process (no LLM). The Agent-tool
|
|
28
|
+
``isolation: "worktree"`` recommendation in the spec applies to the
|
|
29
|
+
Class C ``bounded_patch`` profile that comes later. For MVP, the
|
|
30
|
+
sandbox is "this Python process running these specific subprocess
|
|
31
|
+
commands inside a worktree-safe target repo".
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import fnmatch
|
|
37
|
+
import hashlib
|
|
38
|
+
import json
|
|
39
|
+
import logging
|
|
40
|
+
import os
|
|
41
|
+
import re
|
|
42
|
+
import shlex
|
|
43
|
+
import shutil
|
|
44
|
+
import subprocess
|
|
45
|
+
from dataclasses import dataclass, field, asdict
|
|
46
|
+
from pathlib import Path
|
|
47
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
48
|
+
|
|
49
|
+
logger = logging.getLogger("delimit.ai.led193_daemon.executor")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ── Sandbox-violation constants ────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
FORBIDDEN_BASE_BRANCHES = {"main", "master", "trunk", "develop", "release"}
|
|
55
|
+
FORBIDDEN_PATH_PREFIXES = (
|
|
56
|
+
str(Path.home() / ".delimit" / "secrets"),
|
|
57
|
+
str(Path.home() / ".config"),
|
|
58
|
+
"/etc",
|
|
59
|
+
"/root/.ssh",
|
|
60
|
+
)
|
|
61
|
+
DANGEROUS_REPLACE_PATTERNS = [
|
|
62
|
+
re.compile(r"DROP\s+TABLE", re.IGNORECASE),
|
|
63
|
+
re.compile(r"DELETE\s+FROM", re.IGNORECASE),
|
|
64
|
+
re.compile(r";\s*--"),
|
|
65
|
+
re.compile(r"<script\b", re.IGNORECASE),
|
|
66
|
+
re.compile(r"\$\{\s*jndi\s*:", re.IGNORECASE),
|
|
67
|
+
]
|
|
68
|
+
MAX_DOCS_TYPO_FILE_BYTES = 1_000_000 # 1 MB ceiling per file
|
|
69
|
+
MAX_DOCS_TYPO_FILES = 50 # ceiling on number of files modified
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ── Result container ───────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class ExecResult:
|
|
77
|
+
result: str = "failed" # success|failed|noop|skipped|ci_failed_after_open
|
|
78
|
+
reason: str = ""
|
|
79
|
+
branch: str = ""
|
|
80
|
+
pr_url: str = ""
|
|
81
|
+
files_changed: int = 0
|
|
82
|
+
cost_estimate: float = 0.0
|
|
83
|
+
profile: str = ""
|
|
84
|
+
item_id: str = ""
|
|
85
|
+
|
|
86
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
87
|
+
return asdict(self)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ── Sandbox checks ─────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _path_under_forbidden_prefix(p: Path) -> bool:
|
|
94
|
+
"""True iff ``p`` resolves under any FORBIDDEN_PATH_PREFIXES entry."""
|
|
95
|
+
try:
|
|
96
|
+
resolved = str(p.resolve())
|
|
97
|
+
except (OSError, RuntimeError):
|
|
98
|
+
return True # fail-closed
|
|
99
|
+
for prefix in FORBIDDEN_PATH_PREFIXES:
|
|
100
|
+
if resolved == prefix or resolved.startswith(prefix.rstrip("/") + "/"):
|
|
101
|
+
return True
|
|
102
|
+
return False
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _validate_repo_path(repo_path: Path) -> Tuple[bool, str]:
|
|
106
|
+
if not repo_path.exists():
|
|
107
|
+
return False, "repo_not_found"
|
|
108
|
+
if not repo_path.is_dir():
|
|
109
|
+
return False, "repo_not_dir"
|
|
110
|
+
if _path_under_forbidden_prefix(repo_path):
|
|
111
|
+
return False, "repo_in_forbidden_prefix"
|
|
112
|
+
git_dir = repo_path / ".git"
|
|
113
|
+
if not git_dir.exists():
|
|
114
|
+
return False, "not_a_git_repo"
|
|
115
|
+
return True, ""
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _validate_branch_name(branch: str) -> Tuple[bool, str]:
|
|
119
|
+
"""Branch must start with ``auto/`` and not match a protected base."""
|
|
120
|
+
if not branch.startswith("auto/"):
|
|
121
|
+
return False, "branch_must_start_with_auto/"
|
|
122
|
+
suffix = branch[len("auto/"):]
|
|
123
|
+
head = suffix.split("/")[0].split("-")[0].lower()
|
|
124
|
+
if head in FORBIDDEN_BASE_BRANCHES:
|
|
125
|
+
return False, f"branch_collides_with_protected:{head}"
|
|
126
|
+
return True, ""
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# ── Branch + commit helpers ────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _short_hash(seed: str, n: int = 6) -> str:
|
|
133
|
+
return hashlib.sha1(seed.encode("utf-8")).hexdigest()[:n]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def make_branch_name(profile: str, item_id: str, *, seed: Optional[str] = None) -> str:
|
|
137
|
+
"""``auto/{profile}-{item_id}-{short_hash}``.
|
|
138
|
+
|
|
139
|
+
The short hash is derived from ``seed`` (defaults to a process-
|
|
140
|
+
unique string) so two crons running for the same item won't open
|
|
141
|
+
PRs from the same branch name. Concurrency=1 makes that mostly
|
|
142
|
+
moot but the hash gives an extra safety belt.
|
|
143
|
+
"""
|
|
144
|
+
seed = seed or f"{profile}:{item_id}:{os.getpid()}"
|
|
145
|
+
return f"auto/{profile}-{item_id}-{_short_hash(seed)}"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _run_git(
|
|
149
|
+
repo_path: Path,
|
|
150
|
+
args: List[str],
|
|
151
|
+
*,
|
|
152
|
+
runner: Optional[Callable] = None,
|
|
153
|
+
timeout: int = 60,
|
|
154
|
+
check: bool = False,
|
|
155
|
+
) -> Tuple[int, str, str]:
|
|
156
|
+
cmd = ["git", *args]
|
|
157
|
+
if runner is not None:
|
|
158
|
+
proc = runner(cmd, cwd=str(repo_path))
|
|
159
|
+
return (
|
|
160
|
+
getattr(proc, "returncode", 1),
|
|
161
|
+
getattr(proc, "stdout", "") or "",
|
|
162
|
+
getattr(proc, "stderr", "") or "",
|
|
163
|
+
)
|
|
164
|
+
try:
|
|
165
|
+
p = subprocess.run(
|
|
166
|
+
cmd, cwd=str(repo_path), capture_output=True, text=True,
|
|
167
|
+
timeout=timeout, check=check,
|
|
168
|
+
)
|
|
169
|
+
return p.returncode, p.stdout, p.stderr
|
|
170
|
+
except subprocess.TimeoutExpired:
|
|
171
|
+
return 124, "", "timeout"
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _ensure_clean_worktree(repo_path: Path, runner=None) -> Tuple[bool, str]:
|
|
175
|
+
rc, stdout, _ = _run_git(repo_path, ["status", "--porcelain"], runner=runner)
|
|
176
|
+
if rc != 0:
|
|
177
|
+
return False, "git_status_failed"
|
|
178
|
+
if stdout.strip():
|
|
179
|
+
return False, "worktree_dirty"
|
|
180
|
+
return True, ""
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _create_branch(repo_path: Path, branch: str, runner=None) -> Tuple[bool, str]:
|
|
184
|
+
ok, reason = _validate_branch_name(branch)
|
|
185
|
+
if not ok:
|
|
186
|
+
return False, reason
|
|
187
|
+
rc, _, stderr = _run_git(repo_path, ["checkout", "-b", branch], runner=runner)
|
|
188
|
+
if rc != 0:
|
|
189
|
+
return False, f"checkout_failed: {stderr.strip()[:200]}"
|
|
190
|
+
return True, ""
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _commit_all(
|
|
194
|
+
repo_path: Path,
|
|
195
|
+
message: str,
|
|
196
|
+
runner=None,
|
|
197
|
+
) -> Tuple[bool, str, int]:
|
|
198
|
+
"""Stage everything, commit, return (ok, reason, files_changed)."""
|
|
199
|
+
rc, stdout, _ = _run_git(repo_path, ["status", "--porcelain"], runner=runner)
|
|
200
|
+
if rc != 0:
|
|
201
|
+
return False, "git_status_failed", 0
|
|
202
|
+
files_changed = sum(1 for ln in stdout.splitlines() if ln.strip())
|
|
203
|
+
if files_changed == 0:
|
|
204
|
+
return True, "no_changes", 0
|
|
205
|
+
rc, _, stderr = _run_git(repo_path, ["add", "-A"], runner=runner)
|
|
206
|
+
if rc != 0:
|
|
207
|
+
return False, f"add_failed: {stderr.strip()[:200]}", 0
|
|
208
|
+
rc, _, stderr = _run_git(
|
|
209
|
+
repo_path, ["commit", "-m", message], runner=runner, timeout=120,
|
|
210
|
+
)
|
|
211
|
+
if rc != 0:
|
|
212
|
+
return False, f"commit_failed: {stderr.strip()[:200]}", 0
|
|
213
|
+
return True, "", files_changed
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _push_branch(
|
|
217
|
+
repo_path: Path,
|
|
218
|
+
branch: str,
|
|
219
|
+
runner=None,
|
|
220
|
+
) -> Tuple[bool, str]:
|
|
221
|
+
"""Push ``branch`` to origin. NEVER --no-verify, NEVER --force.
|
|
222
|
+
|
|
223
|
+
Defensive: re-validate the branch name immediately before push so a
|
|
224
|
+
test that bypasses ``_create_branch`` can't sneak a push to main
|
|
225
|
+
through this helper.
|
|
226
|
+
"""
|
|
227
|
+
ok, reason = _validate_branch_name(branch)
|
|
228
|
+
if not ok:
|
|
229
|
+
return False, reason
|
|
230
|
+
rc, _, stderr = _run_git(
|
|
231
|
+
repo_path,
|
|
232
|
+
["push", "--set-upstream", "origin", branch],
|
|
233
|
+
runner=runner,
|
|
234
|
+
timeout=120,
|
|
235
|
+
)
|
|
236
|
+
if rc != 0:
|
|
237
|
+
return False, f"push_failed: {stderr.strip()[:200]}"
|
|
238
|
+
return True, ""
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _open_pr(
|
|
242
|
+
repo_path: Path,
|
|
243
|
+
*,
|
|
244
|
+
branch: str,
|
|
245
|
+
title: str,
|
|
246
|
+
body: str,
|
|
247
|
+
runner=None,
|
|
248
|
+
) -> Tuple[bool, str, str]:
|
|
249
|
+
"""Open a PR via ``gh pr create``. Returns (ok, pr_url, reason).
|
|
250
|
+
|
|
251
|
+
Daemon never auto-merges; the PR is opened in the default state.
|
|
252
|
+
"""
|
|
253
|
+
cmd = [
|
|
254
|
+
"gh", "pr", "create",
|
|
255
|
+
"--head", branch,
|
|
256
|
+
"--title", title,
|
|
257
|
+
"--body", body,
|
|
258
|
+
]
|
|
259
|
+
if runner is not None:
|
|
260
|
+
proc = runner(cmd, cwd=str(repo_path))
|
|
261
|
+
rc = getattr(proc, "returncode", 1)
|
|
262
|
+
stdout = getattr(proc, "stdout", "") or ""
|
|
263
|
+
stderr = getattr(proc, "stderr", "") or ""
|
|
264
|
+
else:
|
|
265
|
+
try:
|
|
266
|
+
p = subprocess.run(
|
|
267
|
+
cmd, cwd=str(repo_path), capture_output=True,
|
|
268
|
+
text=True, timeout=60, check=False,
|
|
269
|
+
)
|
|
270
|
+
rc, stdout, stderr = p.returncode, p.stdout, p.stderr
|
|
271
|
+
except (subprocess.TimeoutExpired, OSError) as exc:
|
|
272
|
+
return False, "", f"gh_pr_create_failed: {exc}"
|
|
273
|
+
if rc != 0:
|
|
274
|
+
return False, "", f"gh_pr_create_failed: {stderr.strip()[:200]}"
|
|
275
|
+
pr_url = (stdout or "").strip().splitlines()[-1] if stdout else ""
|
|
276
|
+
return True, pr_url, ""
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
# ── Profile: format_fix ────────────────────────────────────────────────
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _detect_format_command(repo_path: Path) -> Optional[List[str]]:
|
|
283
|
+
"""Return a deterministic format command, or None.
|
|
284
|
+
|
|
285
|
+
Order:
|
|
286
|
+
1. ``package.json`` script ``format`` (npm run format)
|
|
287
|
+
2. ``package.json`` script ``lint:fix``
|
|
288
|
+
3. ``prettier --write .`` if prettier is on PATH AND a
|
|
289
|
+
``.prettierrc*`` config exists.
|
|
290
|
+
4. Python: ``black .`` if a ``pyproject.toml`` has black config
|
|
291
|
+
AND ``black`` is on PATH.
|
|
292
|
+
|
|
293
|
+
Lock to the first hit. We don't auto-introduce a formatter to a
|
|
294
|
+
repo that didn't already configure one — that would be intrusive.
|
|
295
|
+
"""
|
|
296
|
+
pkg = repo_path / "package.json"
|
|
297
|
+
if pkg.exists():
|
|
298
|
+
try:
|
|
299
|
+
data = json.loads(pkg.read_text(encoding="utf-8"))
|
|
300
|
+
scripts = (data.get("scripts") or {}) if isinstance(data, dict) else {}
|
|
301
|
+
if isinstance(scripts, dict):
|
|
302
|
+
for key in ("format", "lint:fix"):
|
|
303
|
+
if key in scripts and isinstance(scripts[key], str) and scripts[key].strip():
|
|
304
|
+
return ["npm", "run", key]
|
|
305
|
+
except (OSError, json.JSONDecodeError, ValueError):
|
|
306
|
+
pass
|
|
307
|
+
# Prettier with config
|
|
308
|
+
prettier_configs = [
|
|
309
|
+
".prettierrc", ".prettierrc.json", ".prettierrc.yaml",
|
|
310
|
+
".prettierrc.yml", ".prettierrc.js", "prettier.config.js",
|
|
311
|
+
]
|
|
312
|
+
if shutil.which("prettier") and any((repo_path / c).exists() for c in prettier_configs):
|
|
313
|
+
return ["prettier", "--write", "."]
|
|
314
|
+
# Black
|
|
315
|
+
pyproject = repo_path / "pyproject.toml"
|
|
316
|
+
if pyproject.exists() and shutil.which("black"):
|
|
317
|
+
try:
|
|
318
|
+
content = pyproject.read_text(encoding="utf-8", errors="replace")
|
|
319
|
+
if "[tool.black]" in content:
|
|
320
|
+
return ["black", "."]
|
|
321
|
+
except OSError:
|
|
322
|
+
pass
|
|
323
|
+
return None
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def execute_format_fix(
|
|
327
|
+
*,
|
|
328
|
+
repo_path: Path,
|
|
329
|
+
item_id: str,
|
|
330
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
331
|
+
runner: Optional[Callable] = None,
|
|
332
|
+
) -> ExecResult:
|
|
333
|
+
"""Run formatter; commit + return success only if files changed.
|
|
334
|
+
|
|
335
|
+
The PR is NOT opened here — that's the caller's responsibility
|
|
336
|
+
after the gate passes. This function returns either:
|
|
337
|
+
- ``noop`` when no files changed
|
|
338
|
+
- ``failed`` when something broke
|
|
339
|
+
- ``success`` (pre-PR) when a commit landed on the new branch
|
|
340
|
+
and the caller can run the gate + open PR.
|
|
341
|
+
"""
|
|
342
|
+
out = ExecResult(profile="format_fix", item_id=item_id)
|
|
343
|
+
ok, reason = _validate_repo_path(repo_path)
|
|
344
|
+
if not ok:
|
|
345
|
+
out.reason = reason
|
|
346
|
+
return out
|
|
347
|
+
ok, reason = _ensure_clean_worktree(repo_path, runner=runner)
|
|
348
|
+
if not ok:
|
|
349
|
+
out.reason = reason
|
|
350
|
+
return out
|
|
351
|
+
|
|
352
|
+
cmd = _detect_format_command(repo_path)
|
|
353
|
+
if cmd is None:
|
|
354
|
+
out.reason = "no_formatter_detected"
|
|
355
|
+
return out
|
|
356
|
+
|
|
357
|
+
branch = make_branch_name("format_fix", item_id)
|
|
358
|
+
out.branch = branch
|
|
359
|
+
ok, reason = _create_branch(repo_path, branch, runner=runner)
|
|
360
|
+
if not ok:
|
|
361
|
+
out.reason = reason
|
|
362
|
+
return out
|
|
363
|
+
|
|
364
|
+
# Run the formatter.
|
|
365
|
+
if runner is not None:
|
|
366
|
+
proc = runner(cmd, cwd=str(repo_path))
|
|
367
|
+
rc = getattr(proc, "returncode", 1)
|
|
368
|
+
stderr = getattr(proc, "stderr", "") or ""
|
|
369
|
+
else:
|
|
370
|
+
try:
|
|
371
|
+
p = subprocess.run(
|
|
372
|
+
cmd, cwd=str(repo_path), capture_output=True, text=True,
|
|
373
|
+
timeout=300, check=False,
|
|
374
|
+
)
|
|
375
|
+
rc, stderr = p.returncode, p.stderr
|
|
376
|
+
except (subprocess.TimeoutExpired, OSError) as exc:
|
|
377
|
+
out.reason = f"formatter_failed: {exc}"
|
|
378
|
+
return out
|
|
379
|
+
if rc != 0:
|
|
380
|
+
out.reason = f"formatter_returned_nonzero: {stderr.strip()[:200]}"
|
|
381
|
+
return out
|
|
382
|
+
|
|
383
|
+
ok, reason, files = _commit_all(
|
|
384
|
+
repo_path, f"chore(led193): format_fix for {item_id}", runner=runner,
|
|
385
|
+
)
|
|
386
|
+
out.files_changed = files
|
|
387
|
+
if not ok:
|
|
388
|
+
out.reason = reason
|
|
389
|
+
return out
|
|
390
|
+
if files == 0:
|
|
391
|
+
out.result = "noop"
|
|
392
|
+
out.reason = "no_changes_after_format"
|
|
393
|
+
return out
|
|
394
|
+
|
|
395
|
+
out.result = "success"
|
|
396
|
+
return out
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
# ── Profile: lockfile_refresh ──────────────────────────────────────────
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _detect_lockfile_command(repo_path: Path) -> Optional[Tuple[List[str], str]]:
|
|
403
|
+
"""Return (cmd, lockfile_basename) or None."""
|
|
404
|
+
if (repo_path / "pnpm-lock.yaml").exists() and shutil.which("pnpm"):
|
|
405
|
+
return (["pnpm", "install", "--lockfile-only"], "pnpm-lock.yaml")
|
|
406
|
+
if (repo_path / "yarn.lock").exists() and shutil.which("yarn"):
|
|
407
|
+
# yarn classic / berry both refresh on `install --mode update-lockfile`
|
|
408
|
+
return (["yarn", "install", "--mode", "update-lockfile"], "yarn.lock")
|
|
409
|
+
if (repo_path / "package-lock.json").exists() and shutil.which("npm"):
|
|
410
|
+
return (["npm", "install", "--package-lock-only"], "package-lock.json")
|
|
411
|
+
if (repo_path / "poetry.lock").exists() and shutil.which("poetry"):
|
|
412
|
+
return (["poetry", "lock", "--no-update"], "poetry.lock")
|
|
413
|
+
if (repo_path / "Pipfile.lock").exists() and shutil.which("pipenv"):
|
|
414
|
+
return (["pipenv", "lock"], "Pipfile.lock")
|
|
415
|
+
return None
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def execute_lockfile_refresh(
|
|
419
|
+
*,
|
|
420
|
+
repo_path: Path,
|
|
421
|
+
item_id: str,
|
|
422
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
423
|
+
runner: Optional[Callable] = None,
|
|
424
|
+
) -> ExecResult:
|
|
425
|
+
out = ExecResult(profile="lockfile_refresh", item_id=item_id)
|
|
426
|
+
ok, reason = _validate_repo_path(repo_path)
|
|
427
|
+
if not ok:
|
|
428
|
+
out.reason = reason
|
|
429
|
+
return out
|
|
430
|
+
ok, reason = _ensure_clean_worktree(repo_path, runner=runner)
|
|
431
|
+
if not ok:
|
|
432
|
+
out.reason = reason
|
|
433
|
+
return out
|
|
434
|
+
|
|
435
|
+
detected = _detect_lockfile_command(repo_path)
|
|
436
|
+
if detected is None:
|
|
437
|
+
out.reason = "no_lockfile_or_manager_detected"
|
|
438
|
+
return out
|
|
439
|
+
cmd, _lockfile = detected
|
|
440
|
+
|
|
441
|
+
branch = make_branch_name("lockfile_refresh", item_id)
|
|
442
|
+
out.branch = branch
|
|
443
|
+
ok, reason = _create_branch(repo_path, branch, runner=runner)
|
|
444
|
+
if not ok:
|
|
445
|
+
out.reason = reason
|
|
446
|
+
return out
|
|
447
|
+
|
|
448
|
+
if runner is not None:
|
|
449
|
+
proc = runner(cmd, cwd=str(repo_path))
|
|
450
|
+
rc = getattr(proc, "returncode", 1)
|
|
451
|
+
stderr = getattr(proc, "stderr", "") or ""
|
|
452
|
+
else:
|
|
453
|
+
try:
|
|
454
|
+
p = subprocess.run(
|
|
455
|
+
cmd, cwd=str(repo_path), capture_output=True, text=True,
|
|
456
|
+
timeout=600, check=False,
|
|
457
|
+
)
|
|
458
|
+
rc, stderr = p.returncode, p.stderr
|
|
459
|
+
except (subprocess.TimeoutExpired, OSError) as exc:
|
|
460
|
+
out.reason = f"lockfile_refresh_failed: {exc}"
|
|
461
|
+
return out
|
|
462
|
+
if rc != 0:
|
|
463
|
+
out.reason = f"lockfile_manager_nonzero: {stderr.strip()[:200]}"
|
|
464
|
+
return out
|
|
465
|
+
|
|
466
|
+
ok, reason, files = _commit_all(
|
|
467
|
+
repo_path, f"chore(led193): lockfile_refresh for {item_id}", runner=runner,
|
|
468
|
+
)
|
|
469
|
+
out.files_changed = files
|
|
470
|
+
if not ok:
|
|
471
|
+
out.reason = reason
|
|
472
|
+
return out
|
|
473
|
+
if files == 0:
|
|
474
|
+
out.result = "noop"
|
|
475
|
+
out.reason = "no_lockfile_change"
|
|
476
|
+
return out
|
|
477
|
+
|
|
478
|
+
out.result = "success"
|
|
479
|
+
return out
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
# ── Profile: docs_typo ─────────────────────────────────────────────────
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _validate_docs_typo_metadata(metadata: Dict[str, Any]) -> Tuple[bool, str]:
|
|
486
|
+
if not isinstance(metadata, dict):
|
|
487
|
+
return False, "metadata_missing_or_invalid"
|
|
488
|
+
find_string = metadata.get("find_string")
|
|
489
|
+
replace_string = metadata.get("replace_string")
|
|
490
|
+
file_glob = metadata.get("file_glob")
|
|
491
|
+
if not isinstance(find_string, str) or len(find_string) < 3:
|
|
492
|
+
return False, "find_string_too_short_or_missing"
|
|
493
|
+
if not isinstance(replace_string, str):
|
|
494
|
+
return False, "replace_string_missing"
|
|
495
|
+
if not isinstance(file_glob, str) or not file_glob.strip():
|
|
496
|
+
return False, "file_glob_missing"
|
|
497
|
+
# Reject dangerous strings — both find AND replace get scanned
|
|
498
|
+
# because someone could try to "fix" a comment that injects script.
|
|
499
|
+
for pat in DANGEROUS_REPLACE_PATTERNS:
|
|
500
|
+
if pat.search(find_string) or pat.search(replace_string):
|
|
501
|
+
return False, "dangerous_pattern_in_strings"
|
|
502
|
+
# Cap find/replace length so a megabyte payload can't sneak in.
|
|
503
|
+
if len(find_string) > 500 or len(replace_string) > 500:
|
|
504
|
+
return False, "find_or_replace_too_long"
|
|
505
|
+
return True, ""
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _glob_files(repo_path: Path, glob: str) -> List[Path]:
|
|
509
|
+
"""Return files matching ``glob`` under ``repo_path``.
|
|
510
|
+
|
|
511
|
+
``glob`` is interpreted relative to the repo root. ``Path.glob``
|
|
512
|
+
supports ``**`` recursion — perfect for callers writing things like
|
|
513
|
+
``docs/**/*.md``. We reject path-traversal attempts (``..``) and
|
|
514
|
+
bound the result count.
|
|
515
|
+
"""
|
|
516
|
+
if ".." in Path(glob).parts:
|
|
517
|
+
return []
|
|
518
|
+
matches: List[Path] = []
|
|
519
|
+
try:
|
|
520
|
+
# Path.glob handles ``**`` correctly (fnmatch doesn't).
|
|
521
|
+
iterator = repo_path.glob(glob)
|
|
522
|
+
except (ValueError, OSError):
|
|
523
|
+
return []
|
|
524
|
+
for path in iterator:
|
|
525
|
+
if not path.is_file():
|
|
526
|
+
continue
|
|
527
|
+
# Skip anything inside .git/ — never modify VCS metadata.
|
|
528
|
+
try:
|
|
529
|
+
rel_parts = path.relative_to(repo_path).parts
|
|
530
|
+
except ValueError:
|
|
531
|
+
continue
|
|
532
|
+
if rel_parts and rel_parts[0] == ".git":
|
|
533
|
+
continue
|
|
534
|
+
matches.append(path)
|
|
535
|
+
if len(matches) > MAX_DOCS_TYPO_FILES * 4:
|
|
536
|
+
break # don't walk forever
|
|
537
|
+
return matches
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def execute_docs_typo(
|
|
541
|
+
*,
|
|
542
|
+
repo_path: Path,
|
|
543
|
+
item_id: str,
|
|
544
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
545
|
+
runner: Optional[Callable] = None,
|
|
546
|
+
) -> ExecResult:
|
|
547
|
+
out = ExecResult(profile="docs_typo", item_id=item_id)
|
|
548
|
+
ok, reason = _validate_repo_path(repo_path)
|
|
549
|
+
if not ok:
|
|
550
|
+
out.reason = reason
|
|
551
|
+
return out
|
|
552
|
+
ok, reason = _ensure_clean_worktree(repo_path, runner=runner)
|
|
553
|
+
if not ok:
|
|
554
|
+
out.reason = reason
|
|
555
|
+
return out
|
|
556
|
+
|
|
557
|
+
md = metadata or {}
|
|
558
|
+
ok, reason = _validate_docs_typo_metadata(md)
|
|
559
|
+
if not ok:
|
|
560
|
+
out.reason = reason
|
|
561
|
+
return out
|
|
562
|
+
find_string = md["find_string"]
|
|
563
|
+
replace_string = md["replace_string"]
|
|
564
|
+
file_glob = md["file_glob"]
|
|
565
|
+
|
|
566
|
+
candidates = _glob_files(repo_path, file_glob)
|
|
567
|
+
if not candidates:
|
|
568
|
+
out.result = "noop"
|
|
569
|
+
out.reason = "no_files_matched_glob"
|
|
570
|
+
return out
|
|
571
|
+
|
|
572
|
+
branch = make_branch_name("docs_typo", item_id)
|
|
573
|
+
out.branch = branch
|
|
574
|
+
ok, reason = _create_branch(repo_path, branch, runner=runner)
|
|
575
|
+
if not ok:
|
|
576
|
+
out.reason = reason
|
|
577
|
+
return out
|
|
578
|
+
|
|
579
|
+
files_changed = 0
|
|
580
|
+
for path in candidates:
|
|
581
|
+
# File-size guard
|
|
582
|
+
try:
|
|
583
|
+
sz = path.stat().st_size
|
|
584
|
+
except OSError:
|
|
585
|
+
continue
|
|
586
|
+
if sz > MAX_DOCS_TYPO_FILE_BYTES:
|
|
587
|
+
continue
|
|
588
|
+
try:
|
|
589
|
+
text = path.read_text(encoding="utf-8")
|
|
590
|
+
except (OSError, UnicodeDecodeError):
|
|
591
|
+
continue
|
|
592
|
+
if find_string not in text:
|
|
593
|
+
continue
|
|
594
|
+
new_text = text.replace(find_string, replace_string)
|
|
595
|
+
if new_text == text:
|
|
596
|
+
continue
|
|
597
|
+
try:
|
|
598
|
+
path.write_text(new_text, encoding="utf-8")
|
|
599
|
+
except OSError:
|
|
600
|
+
continue
|
|
601
|
+
files_changed += 1
|
|
602
|
+
if files_changed >= MAX_DOCS_TYPO_FILES:
|
|
603
|
+
break
|
|
604
|
+
|
|
605
|
+
out.files_changed = files_changed
|
|
606
|
+
if files_changed == 0:
|
|
607
|
+
out.result = "noop"
|
|
608
|
+
out.reason = "find_string_not_present"
|
|
609
|
+
return out
|
|
610
|
+
|
|
611
|
+
ok, reason, committed = _commit_all(
|
|
612
|
+
repo_path, f"docs(led193): typo fix for {item_id}", runner=runner,
|
|
613
|
+
)
|
|
614
|
+
if not ok:
|
|
615
|
+
out.reason = reason
|
|
616
|
+
return out
|
|
617
|
+
if committed == 0:
|
|
618
|
+
# Shouldn't happen (we just wrote files) but defend anyway
|
|
619
|
+
out.result = "noop"
|
|
620
|
+
out.reason = "commit_saw_no_changes"
|
|
621
|
+
return out
|
|
622
|
+
out.files_changed = committed
|
|
623
|
+
out.result = "success"
|
|
624
|
+
return out
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
# ── Dispatcher ─────────────────────────────────────────────────────────
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
PROFILE_DISPATCH = {
|
|
631
|
+
"format_fix": execute_format_fix,
|
|
632
|
+
"lockfile_refresh": execute_lockfile_refresh,
|
|
633
|
+
"docs_typo": execute_docs_typo,
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def execute_item(
|
|
638
|
+
*,
|
|
639
|
+
profile: str,
|
|
640
|
+
item: Dict[str, Any],
|
|
641
|
+
repo_path: Path,
|
|
642
|
+
runner: Optional[Callable] = None,
|
|
643
|
+
) -> ExecResult:
|
|
644
|
+
"""Dispatch to the right profile executor.
|
|
645
|
+
|
|
646
|
+
Returns an ExecResult with ``result`` in {success, failed, noop}.
|
|
647
|
+
The caller (``scripts/led193_cron.py``) then decides whether to run
|
|
648
|
+
the pre-push gate, push, and open the PR — based on the result.
|
|
649
|
+
"""
|
|
650
|
+
fn = PROFILE_DISPATCH.get(profile)
|
|
651
|
+
if fn is None:
|
|
652
|
+
return ExecResult(
|
|
653
|
+
result="failed",
|
|
654
|
+
reason=f"unknown_profile:{profile}",
|
|
655
|
+
profile=profile,
|
|
656
|
+
item_id=item.get("id", ""),
|
|
657
|
+
)
|
|
658
|
+
item_id = item.get("id") or ""
|
|
659
|
+
if not item_id:
|
|
660
|
+
return ExecResult(
|
|
661
|
+
result="failed",
|
|
662
|
+
reason="item_missing_id",
|
|
663
|
+
profile=profile,
|
|
664
|
+
)
|
|
665
|
+
metadata = item.get("metadata") or {}
|
|
666
|
+
return fn(
|
|
667
|
+
repo_path=repo_path,
|
|
668
|
+
item_id=item_id,
|
|
669
|
+
metadata=metadata,
|
|
670
|
+
runner=runner,
|
|
671
|
+
)
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
# ── Reject-helpers (used by tests + cron) ──────────────────────────────
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def reject_no_verify(args: List[str]) -> bool:
|
|
678
|
+
"""Return True iff ``--no-verify`` is in the arg list."""
|
|
679
|
+
return any(a == "--no-verify" for a in args or [])
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def reject_force_push(args: List[str]) -> bool:
|
|
683
|
+
return any(a in ("-f", "--force", "--force-with-lease") for a in args or [])
|