delimit-cli 4.5.6 → 4.5.8

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.
@@ -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 [])