Coverage for core/langgraph/_builtins.py: 72%
614 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-08 02:20 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-08 02:20 +0000
1"""
2_builtins.py — Domain builtins for agent-platform pipelines (v1).
4Toteuttaa ne builtin-komponentit, joita agent-platform-pipelinet käyttävät
5ja jotka porttaavat ei-LLM-logiikan generic-muotoon:
7- path-verifier tarkistaa dokumenttien viittaamat polut
8- finding-prefilter suodattaa false-positive findingit ennen LLM-triagea
9- ticket-publisher kirjoittaa hyväksytyn ticketin staging → published
10- run-finalizer koostaa stats + flushaa Langfusen
12Kaikki builtinit noudattavat sopimusta:
13 fn(step: dict, state: dict) -> dict # palauttaa uuden state-snapshotin
14ja kirjoittavat tuloksensa kenttään `state[step["id"]]`.
15"""
16from __future__ import annotations
18import json
19import logging
20import os
21import re
22import shutil
23import subprocess
24from concurrent.futures import ThreadPoolExecutor, as_completed
25from datetime import date
26from pathlib import Path
27from typing import Any
28from urllib.parse import urlparse, urlunparse
30log = logging.getLogger(__name__)
32# Same dual-import pattern as runner.py (flat sys.path in tests vs qualified
33# PYTHONPATH=/app in main.py) — slugify is the ADR-0018 slug used for the
34# TICKET-profile per-run feature branch name.
35try:
36 from exec_runner import slugify # noqa: F401
37except ImportError: # pragma: no cover — main.py-ajossa (qualified importit)
38 from langgraph.exec_runner import slugify # noqa: F401
40# Files-output-helper (validate_and_write_files) — sama dual-import-kuvio
41# kuin runner.py:ssa: flat sys.path (testit) vs. langgraph.* (main.py).
42try:
43 from _files_output import ( # noqa: F401
44 FilesOutputError,
45 _resolve_path,
46 resolve_workspace_root,
47 validate_and_write_files,
48 )
49except ImportError: # pragma: no cover — main.py-ajossa (qualified importit)
50 from langgraph._files_output import ( # noqa: F401
51 FilesOutputError,
52 _resolve_path,
53 resolve_workspace_root,
54 validate_and_write_files,
55 )
58_DEFAULT_SKIP_DIRS = {
59 ".git", "node_modules", "__pycache__", ".venv", "venv",
60 ".mypy_cache", ".pytest_cache", ".ruff_cache",
61}
64# ---------------------------------------------------------------------------
65# path-verifier
66# ---------------------------------------------------------------------------
69def _path_resolution_candidates(rel: str, source_doc: str = "") -> list[str]:
70 rel = rel.strip().lstrip("/")
71 if not rel or rel.startswith("http"):
72 return []
73 candidates: list[str] = [rel]
74 if source_doc:
75 doc_dir = Path(source_doc).parent.as_posix()
76 if doc_dir and doc_dir != ".":
77 joined = f"{doc_dir}/{rel}".replace("//", "/")
78 if joined not in candidates:
79 candidates.append(joined)
80 if not rel.startswith(("docs/", ".agent-platform/", "skills/", "assets/", "scripts/")):
81 if f"docs/{rel}" not in candidates:
82 candidates.append(f"docs/{rel}")
83 if rel.startswith("adr/"):
84 prefixed = f"docs/{rel}"
85 if prefixed not in candidates:
86 candidates.append(prefixed)
87 return candidates
90def _paths_in_text(text: str, max_paths: int = 25) -> list[str]:
91 seen: set[str] = set()
92 out: list[str] = []
94 def add(raw: str) -> None:
95 p = raw.strip().split("#")[0].strip()
96 if not p or p.startswith("http") or p in seen:
97 return
98 # Suodata pois ei-polku-osumat: rivinvaihdot, välilyönnit, liian pitkät
99 # (esim. mermaid-koodilohkojen sisältö, joka match-aa backtick-regexiin).
100 if any(ch in p for ch in ("\n", "\r", " ", "\t")) or len(p) > 255:
101 return
102 if "/" in p or p.endswith((".md", ".py", ".yaml", ".yml", ".sh", ".json", ".toml")):
103 seen.add(p)
104 out.append(p)
106 for p in re.findall(r"`([^`]+)`", text):
107 add(p)
108 if len(out) >= max_paths:
109 return out[:max_paths]
110 for p in re.findall(r"\]\(([^)]+)\)", text):
111 add(p)
112 if len(out) >= max_paths:
113 break
114 return out[:max_paths]
117def _verify_paths_for_doc(root: Path, source_doc: str, doc_text: str) -> str:
118 paths = _paths_in_text(doc_text)
119 if not paths:
120 return ""
121 lines = [
122 "\n\n## Path verification (project mount — authoritative)\n",
123 f"Document under audit: `{source_doc}` — resolve relative links from this directory.\n",
124 "Do NOT report a path as missing when any resolved candidate below is `exists`.\n",
125 ]
126 for p in paths:
127 resolved = _path_resolution_candidates(p, source_doc)
128 hit = next((c for c in resolved if (root / c).is_file()), None)
129 if hit:
130 lines.append(f"- `{p}`: exists (at `{hit}`)")
131 else:
132 shown = ", ".join(f"`{c}`" for c in resolved[:4])
133 lines.append(f"- `{p}`: MISSING (checked: {shown})")
134 return "\n".join(lines)
137_HOST_AI_CONTEXT_MAP_PLACEHOLDER = "[host-ai-context-map-path]"
138_CONVENTION_MAP_REL = "docs/ai-context.md"
141def _resolve_required_path_element(raw: str) -> tuple[str, bool]:
142 """Resolve one `input.paths` element to a root-relative path.
144 Returns (root_relative_path, is_placeholder). The host-ai-context-map-path
145 placeholder resolves to the convention map location
146 `<project_root>/docs/ai-context.md` (ADR-0012 mandatory anchor). Never
147 returns the placeholder literal.
148 """
149 stripped = raw.strip()
150 if stripped == _HOST_AI_CONTEXT_MAP_PLACEHOLDER:
151 return _CONVENTION_MAP_REL, True
152 return stripped.lstrip("/"), False
155def _map_exists_block(resolved_rel: str) -> str:
156 return (
157 "\n\n## Path verification (map anchor — project mount)\n"
158 f"- `{resolved_rel}`: exists\n"
159 )
162def _missing_map_hard_failure(step_id: str, raw: str, resolved_rel: str) -> "RunHardFailure":
163 try:
164 from runner import RunHardFailure
165 except ImportError: # pragma: no cover — main.py-ajossa (qualified importit)
166 from langgraph.runner import RunHardFailure
167 message = (
168 f"MISSING_MAP: {step_id}: required map path {raw!r} (resolved "
169 f"{resolved_rel!r}) is missing under the project root — a missing "
170 "required map is a hard error (ADR-0012), no silent no-op."
171 )
172 return RunHardFailure(
173 "MISSING_MAP",
174 message,
175 {
176 "error_code": "MISSING_MAP",
177 "step_id": step_id,
178 "map_path": resolved_rel,
179 },
180 )
183def path_verifier(step: dict, state: dict) -> dict:
184 """
185 params:
186 docs_from: dot-path state-avain (esim. "gather.doc_files") — gather-docs
187 validation when `input.paths` is absent (backward compat).
188 input:
189 paths: [str] required paths to verify existence (CS map anchor). A
190 missing required path is a HARD error (RunHardFailure, ADR-0012) —
191 never a silent empty by_path / no-op.
192 output (state[step.id]):
193 {by_path: {<path>: <verification_block_string>},
194 resolved_map_path?: <resolved convention map path>}
195 """
196 step_id = step.get("id", "verify_paths")
197 params = step.get("params") or {}
198 root = Path(state.get("project_root") or os.getenv("PROJECT_ROOT", "/project"))
200 input_spec = step.get("input")
201 required_paths: list[str] | str | None = None
202 if isinstance(input_spec, dict) and input_spec.get("paths") is not None:
203 required_paths = input_spec["paths"]
205 if required_paths is None:
206 return _verify_docs_from(step, state, step_id, params, root)
208 if isinstance(required_paths, str):
209 required_paths = [required_paths]
210 if not isinstance(required_paths, list) or not required_paths:
211 raise _missing_map_hard_failure(step_id, "<empty required paths>", "<none>")
213 by_path: dict[str, str] = {}
214 resolved_map_path: str | None = None
215 for raw in required_paths:
216 resolved_rel, is_placeholder = _resolve_required_path_element(str(raw))
217 if is_placeholder:
218 resolved_map_path = resolved_rel
219 if (root / resolved_rel).is_file():
220 by_path[resolved_rel] = _map_exists_block(resolved_rel)
221 else:
222 raise _missing_map_hard_failure(step_id, str(raw), resolved_rel)
224 result: dict[str, Any] = {"by_path": by_path}
225 if resolved_map_path is not None:
226 result["resolved_map_path"] = resolved_map_path
227 log.info("[path-verifier] verified %d required map path(s)", len(by_path))
228 return {**state, step_id: result}
231def _verify_docs_from(step: dict, state: dict, step_id: str, params: dict, root: Path) -> dict:
232 """Legacy gather-docs validation via `params.docs_from` (backward compat)."""
233 docs_from = params.get("docs_from", "gather.doc_files")
235 # Naive dot-path lookup against state
236 cur: Any = state
237 for part in docs_from.split("."):
238 if isinstance(cur, dict):
239 cur = cur.get(part)
240 else:
241 cur = None
242 break
243 docs_list = cur if isinstance(cur, list) else []
245 by_path: dict[str, str] = {}
246 for d in docs_list:
247 if not isinstance(d, dict):
248 continue
249 path = d.get("path") or ""
250 content = d.get("content") or ""
251 if path and content:
252 by_path[path] = _verify_paths_for_doc(root, path, content)
253 log.info("[path-verifier] verified %d docs", len(by_path))
254 return {**state, step_id: {"by_path": by_path}}
257# ---------------------------------------------------------------------------
258# finding-prefilter
259# ---------------------------------------------------------------------------
262_FALSE_MISSING_PHRASES = (
263 "does not exist", "do not exist", "non-existent", "non existent",
264 "not exist in the project", "missing from the project", "cannot be found",
265 "can't be found", "file is missing", "not found in the project",
266)
267_NON_ACTIONABLE_PHRASES = (
268 "no violation", "no missing path", "not a claim of absence",
269 "no observable mismatch", "does not violate", "without verification",
270 "not an issue", "no actionable", "no issue found",
271 "no concrete doc defect", "no documentation defect",
272 "not a documentation defect", "forward reference, not a claim",
273 "this is a forward reference", "no missing path claims",
274 "not verified to exist", "are not verified to exist",
275 "is not verified to exist",
276)
277_FUTURE_CLAIM_PHRASES = (
278 "is in the future",
279 "which is in the future",
280 "future date",
281 "in the future",
282)
283_DATE_PATTERN = re.compile(r"\b(\d{4}-\d{2}-\d{2})\b")
284_SEVERITY_RANK = {"high": 0, "medium": 1, "low": 2}
287def _is_non_actionable(detail: str) -> bool:
288 lower = (detail or "").lower()
289 if not lower.strip():
290 return True
291 for phrase in _NON_ACTIONABLE_PHRASES:
292 if phrase in lower:
293 return True
294 if "does not cite" in lower and "not existence" in lower:
295 return True
296 return False
299def _is_false_future_date(detail: str) -> bool:
300 """Return True when the finding claims a past-or-present date is in the future.
302 Auditors occasionally hallucinate temporal direction — labelling a document
303 date that has already passed as "in the future". The check is conservative:
304 we only reject when the detail contains an explicit future-claim phrase AND
305 every ISO-8601 date found in the text is on or before today.
306 """
307 lower = (detail or "").lower()
308 if not any(phrase in lower for phrase in _FUTURE_CLAIM_PHRASES):
309 return False
310 today = date.today()
311 dates_found = _DATE_PATTERN.findall(detail or "")
312 if not dates_found:
313 return False
314 # Reject only when all mentioned dates are past-or-present (no genuinely future date)
315 for raw in dates_found:
316 try:
317 if date.fromisoformat(raw) > today:
318 return False # at least one date really is in the future → keep
319 except ValueError:
320 continue
321 return True
324def _claimed_missing_paths(detail: str) -> list[str]:
325 paths = [p.strip() for p in re.findall(r"Referenced path `([^`]+)`", detail or "", re.I)]
326 if not paths:
327 for p in re.findall(r"`([^`]+)`", detail or ""):
328 p = p.strip()
329 if p and not p.startswith("http") and ("/" in p or "." in p):
330 paths.append(p)
331 seen: set[str] = set()
332 out: list[str] = []
333 for p in paths:
334 if p not in seen:
335 seen.add(p)
336 out.append(p)
337 return out
340def _project_path_exists(root: Path, rel: str, source_doc: str = "") -> bool:
341 for c in _path_resolution_candidates(rel, source_doc):
342 if (root / c).is_file():
343 return True
344 return False
347def _finding_source_doc(finding: dict) -> str:
348 for r in finding.get("refs") or []:
349 if isinstance(r, str) and ("/" in r or r.endswith(".md")):
350 return r
351 return ""
354def _is_false_positive(root: Path, finding: dict) -> tuple[bool, str]:
355 detail = finding.get("detail") or ""
356 if _is_non_actionable(detail):
357 return True, "non_actionable"
358 if _is_false_future_date(detail):
359 return True, "false_future_date"
360 lower = detail.lower()
361 layer = finding.get("layer", "")
363 if "docs/tickets/" in lower and ("ticket `" in lower or "ticket '" in lower):
364 return True, "meta_ticket_noise"
366 if any(p in lower for p in _FALSE_MISSING_PHRASES):
367 source_doc = _finding_source_doc(finding)
368 claimed = _claimed_missing_paths(detail)
369 if claimed and all(_project_path_exists(root, p, source_doc) for p in claimed):
370 return True, "false_missing_file"
371 refs = [r for r in (finding.get("refs") or []) if isinstance(r, str)]
372 if refs and all(_project_path_exists(root, r, source_doc) for r in refs):
373 return True, "false_missing_file"
375 if layer == "C" and "doc sample" in lower and ("empty" in lower or "does not include" in lower):
376 return True, "false_empty_doc_sample"
378 if finding.get("severity") == "low":
379 for noise in ("typo", "capitalization", "readability", "finnish", "english",
380 "language mixing", "mixed language"):
381 if noise in lower:
382 return True, "low_style_noise"
384 return False, ""
387def _severity_allowed(severity: str, min_severity: str) -> bool:
388 s = _SEVERITY_RANK.get(str(severity).lower(), 3)
389 m = _SEVERITY_RANK.get(min_severity, 1)
390 return s <= m
393def finding_prefilter(step: dict, state: dict) -> dict:
394 """
395 params:
396 min_severity: 'high'|'medium'|'low'
397 finding: the finding dict (passed via params via {{ item }} in manifest)
398 OR step.input resolves to it (preferred shape).
399 output (state[step.id]):
400 {keep: bool, reason: str, finding: dict}
401 """
402 step_id = step.get("id", "prefilter")
403 params = step.get("params") or {}
404 min_severity = str(params.get("min_severity", "medium")).lower()
406 # The finding can arrive via input (resolved) or params.finding
407 finding = step.get("input")
408 if not isinstance(finding, dict):
409 finding = params.get("finding")
410 if not isinstance(finding, dict):
411 return {**state, step_id: {"keep": False, "reason": "no_finding", "finding": {}}}
413 root = Path(state.get("project_root") or os.getenv("PROJECT_ROOT", "/project"))
414 is_fp, reason = _is_false_positive(root, finding)
415 if is_fp:
416 return {**state, step_id: {"keep": False, "reason": reason, "finding": finding}}
417 if not _severity_allowed(finding.get("severity", "low"), min_severity):
418 return {**state, step_id: {"keep": False, "reason": f"below_min_severity_{min_severity}", "finding": finding}}
419 return {**state, step_id: {"keep": True, "reason": "", "finding": finding}}
422# ---------------------------------------------------------------------------
423# ticket-publisher
424# ---------------------------------------------------------------------------
427_TICKET_ORIGIN = "pipeline"
430def _ensure_ticket_date(text: str, today_str: str) -> str:
431 """Inject **Date:** after the title line if not already present."""
432 if re.search(r"^\*\*Date:\*\*", text, re.M | re.I):
433 return text
434 m = re.search(r"^(#\s+Ticket:.*)$", text, re.M | re.I)
435 if m:
436 insert_at = m.end()
437 return text[:insert_at] + f"\n\n**Date:** {today_str}" + text[insert_at:]
438 return text
441def _ensure_ticket_origin(text: str) -> str:
442 if re.search(r"^\*\*Origin:\*\*", text, re.M | re.I):
443 return re.sub(
444 r"^\*\*Origin:\*\*\s*.*$",
445 f"**Origin:** {_TICKET_ORIGIN}",
446 text,
447 count=1,
448 flags=re.M | re.I,
449 )
450 m = re.search(r"^(#\s+Ticket:.*)$", text, re.M | re.I)
451 if m:
452 insert_at = m.end()
453 return text[:insert_at] + f"\n\n**Origin:** {_TICKET_ORIGIN}" + text[insert_at:]
454 return f"**Origin:** {_TICKET_ORIGIN}\n\n" + text
457def _slug_from_text(text: str) -> str:
458 m = re.search(r"^#\s+Ticket:\s*(.+)$", text, re.M | re.I)
459 title = m.group(1).strip() if m else "pipeline-issue"
460 return re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-")[:40] or "issue"
463def ticket_publisher(step: dict, state: dict) -> dict:
464 """
465 params:
466 staging_dir: str (oletus .agent-platform/tmp/pipeline-tickets)
467 publish_dir: str (oletus docs/tickets)
468 status_from: str (DRAFT)
469 status_to: str (GATED)
470 source_footer: str (Source: pipeline)
471 input (resolved): ticket-markdown teksti TAI {output: "..."} dict
472 output (state[step.id]): {path: str|None, slug: str|None, error?: str}
473 """
474 step_id = step.get("id", "publish")
475 params = step.get("params") or {}
476 root = Path(state.get("project_root") or os.getenv("PROJECT_ROOT", "/project"))
478 ticket_text = step.get("input")
479 if isinstance(ticket_text, dict):
480 ticket_text = ticket_text.get("output") or ticket_text.get("text") or ""
481 if not isinstance(ticket_text, str) or not ticket_text.strip():
482 return {**state, step_id: {"path": None, "slug": None, "error": "empty_ticket"}}
484 status_from = str(params.get("status_from", "DRAFT"))
485 status_to = str(params.get("status_to", "GATED"))
486 publish_dir = root / str(params.get("publish_dir", "docs/tickets"))
487 source_footer = str(params.get("source_footer", "Source: pipeline"))
489 body = ticket_text.strip()
490 today = date.today().isoformat()
491 # Strip optional draft prefix line "> **DRAFT** — ..."
492 body = re.sub(r"^>\s*\*\*DRAFT\*\*.*\n\n", "", body, count=1, flags=re.M)
493 # Status DRAFT → GATED
494 if re.search(rf"^\*\*Status:\*\*\s*{status_from}\s*$", body, re.M | re.I):
495 body = re.sub(
496 rf"^\*\*Status:\*\*\s*{status_from}\s*$",
497 f"**Status:** {status_to}",
498 body,
499 count=1,
500 flags=re.M | re.I,
501 )
502 elif not re.search(r"^\*\*Status:\*\*", body, re.M | re.I):
503 # Inject Status block after first heading
504 body = re.sub(
505 r"^(#\s+Ticket:.*)$",
506 rf"\1\n\n**Status:** {status_to}",
507 body,
508 count=1,
509 flags=re.M,
510 )
511 body = _ensure_ticket_date(body, today)
512 body = _ensure_ticket_origin(body)
514 slug = _slug_from_text(body)
515 publish_dir.mkdir(parents=True, exist_ok=True)
516 target = publish_dir / f"{today}-{slug}.md"
517 n = 1
518 while target.exists():
519 target = publish_dir / f"{today}-{slug}-{n}.md"
520 n += 1
522 footer = f"\n\n---\n{source_footer}\n"
523 try:
524 target.write_text(body.rstrip() + footer, encoding="utf-8")
525 except OSError as exc:
526 return {**state, step_id: {"path": None, "slug": slug, "error": str(exc)}}
528 rel_path = str(target.relative_to(root)) if target.is_relative_to(root) else str(target)
529 log.info("[ticket-publisher] published %s", rel_path)
530 return {**state, step_id: {"path": rel_path, "slug": slug}}
533# ---------------------------------------------------------------------------
534# run-finalizer
535# ---------------------------------------------------------------------------
538def run_finalizer(step: dict, state: dict) -> dict:
539 """
540 Koostaa loppustatuksen + runlog-rivin + flushaa Langfuse.
541 params:
542 runlog: str (oletus .agent-platform/tmp/runlog)
543 pipeline_id: str
544 output (state[step.id]): {run_status, stats, runlog_path?}
545 """
546 step_id = step.get("id", "finalize")
547 params = step.get("params") or {}
548 pipeline_id = str(params.get("pipeline_id", "pipeline"))
549 root = Path(state.get("project_root") or os.getenv("PROJECT_ROOT", "/project"))
551 stats: dict[str, Any] = dict(state.get("stats") or {})
553 # Aggregate counts from common state fields if present
554 # foreach tallentaa publish-stepin outputin (= {"path": rel}) sellaisenaan
555 # results-listaan, ei {"publish": ...} -kääreessä. Tuetaan molempia muotoja.
556 process_results = state.get("process_findings") or []
557 if isinstance(process_results, list):
558 def _published_path(r: Any) -> str | None:
559 if not isinstance(r, dict):
560 return None
561 # Suora muoto: {"path": "..."} (foreachin publish_out)
562 if r.get("path"):
563 return r.get("path")
564 # Käärittu muoto: {"publish": {"path": "..."}}
565 return ((r.get("publish") or {}).get("path"))
567 published = [r for r in process_results if _published_path(r)]
568 stats["tickets_published"] = len(published)
569 stats["tickets_created"] = len(published)
571 merged = state.get("merge_findings")
572 if isinstance(merged, dict):
573 findings = merged.get("findings") or []
574 stats["findings_merged"] = len(findings)
576 gather = state.get("gather")
577 if isinstance(gather, dict) and gather.get("stats"):
578 for k, v in gather["stats"].items():
579 stats.setdefault(k, v)
581 run_status = state.get("run_status") or "ok"
582 if state.get("errors"):
583 run_status = "partial" if run_status != "failed" else run_status
585 # Append runlog line (best-effort)
586 runlog_path: str | None = None
587 runlog_dir = root / str(params.get("runlog", ".agent-platform/tmp/runlog"))
588 try:
589 runlog_dir.mkdir(parents=True, exist_ok=True)
590 line = {
591 "pipeline": pipeline_id,
592 "status": run_status,
593 "stats": stats,
594 "errors": (state.get("errors") or [])[:10],
595 }
596 target = runlog_dir / f"{date.today().isoformat()}-{pipeline_id}.jsonl"
597 with target.open("a", encoding="utf-8") as fh:
598 fh.write(json.dumps(line, ensure_ascii=False) + "\n")
599 runlog_path = str(target.relative_to(root)) if target.is_relative_to(root) else str(target)
600 except OSError as exc:
601 log.warning("[run-finalizer] runlog write failed: %s", exc)
603 out_state = {
604 **state,
605 step_id: {"run_status": run_status, "stats": stats, "runlog_path": runlog_path},
606 "run_status": run_status,
607 "stats": stats,
608 }
609 return out_state
611# ---------------------------------------------------------------------------
612# git-checkout
613# ---------------------------------------------------------------------------
616_PR_DIFF_MAX_CHARS = 20_000
617_PR_DIFF_TRUNCATION_SUFFIX = "\n… (truncated)"
620def _git_argv(*args: str) -> list[str]:
621 return ["git", "-c", "safe.directory=*", *args]
624def _require_env(name: str) -> str:
625 value = os.environ.get(name)
626 if not value:
627 raise ValueError(f"git-checkout: required env var {name!r} is not set")
628 return value
631def _resolve_default_branch(project_dir: str) -> str:
632 """Resolve the repo's remote default branch (repo fact, not a fallback).
634 TICKET profile with an empty BASE_BRANCH: the base branch is the remote
635 default reported by `git ls-remote --symref origin HEAD` — never a
636 hardcoded "main" assumption (ADR-0012, contract BASE_BRANCH row).
637 """
638 try:
639 proc = subprocess.run(
640 _git_argv("ls-remote", "--symref", "origin", "HEAD"),
641 cwd=project_dir,
642 capture_output=True,
643 text=True,
644 check=True,
645 )
646 except subprocess.CalledProcessError as exc:
647 raise ValueError(
648 f"git-checkout: 'git ls-remote --symref origin HEAD' failed "
649 f"(exit {exc.returncode}): {(exc.stderr or exc.stdout or '').strip()}"
650 ) from exc
651 for line in proc.stdout.splitlines():
652 if line.startswith("ref: "):
653 # Format: "ref: refs/heads/main\tHEAD"
654 parts = line.split("\t")
655 ref = parts[0][len("ref: "):].strip()
656 if ref.startswith("refs/heads/"):
657 return ref[len("refs/heads/"):]
658 raise ValueError(
659 "git-checkout: could not determine the remote default branch from "
660 "'git ls-remote --symref origin HEAD' (no ref: refs/heads/... HEAD line)"
661 )
664def _ticket_branch(issue_key: str, title: str) -> str:
665 """Deterministic per-run feature branch for the TICKET profile.
667 Format (locked): ticket/<issue_key>-<slug(title)>; slugify is the
668 ADR-0018 slug. Retries and continuation runs reuse the same branch:
669 git-checkout resumes an existing remote branch instead of creating a
670 new one per run, and the push is plain — never a forced push.
671 """
672 return f"ticket/{issue_key}-{slugify(title)}"
675def _remote_branch_exists(remote_url: str, branch: str) -> bool:
676 """True when the remote already has the branch (TICKET resume path).
678 `git ls-remote --heads <url> refs/heads/<branch>` runs BEFORE the
679 clone — a non-empty stdout line means the branch exists and the
680 checkout resumes it instead of creating a new one (continuation runs
681 continue the SAME branch and PR). Failure → hard error (fail-fast,
682 ADR-0012).
683 """
684 try:
685 proc = subprocess.run(
686 _git_argv("ls-remote", "--heads", remote_url, f"refs/heads/{branch}"),
687 capture_output=True,
688 text=True,
689 check=True,
690 )
691 except subprocess.CalledProcessError as exc:
692 raise ValueError(
693 f"git-checkout: 'git ls-remote --heads {remote_url} refs/heads/{branch}' failed "
694 f"(exit {exc.returncode}): {(exc.stderr or exc.stdout or '').strip()}"
695 ) from exc
696 return bool(proc.stdout.strip())
699def _join_pr_diff(patches: list[str]) -> str:
700 joined = "\n".join(p for p in patches if p)
701 if len(joined) > _PR_DIFF_MAX_CHARS:
702 joined = joined[:_PR_DIFF_MAX_CHARS].rstrip() + _PR_DIFF_TRUNCATION_SUFFIX
703 return joined
706def _git_checkout(step: dict, state: dict) -> dict:
707 url = _require_env("GIT_REPO_URL")
708 token = _require_env("GIT_TOKEN")
709 run_profile = _require_env("RUN_PROFILE")
711 if run_profile == "PR":
712 branch = _require_env("PR_HEAD_BRANCH")
713 elif run_profile == "BRANCH":
714 branch = _require_env("BASE_BRANCH")
715 elif run_profile != "TICKET":
716 raise ValueError(
717 f"git-checkout: unsupported RUN_PROFILE {run_profile!r} "
718 "(expected 'PR', 'BRANCH', or 'TICKET')"
719 )
721 parsed = urlparse(url)
722 host = parsed.hostname or ""
723 new_netloc = f"x-access-token:{token}@{host}"
724 url = urlunparse(parsed._replace(netloc=new_netloc))
726 project_dir = "/project"
727 if os.path.exists(project_dir):
728 with os.scandir(project_dir) as entries:
729 for entry in entries:
730 if entry.is_symlink() or not entry.is_dir():
731 os.unlink(entry.path)
732 else:
733 shutil.rmtree(entry.path)
735 if run_profile == "TICKET":
736 base_branch = os.environ.get("BASE_BRANCH") or ""
737 issue_key = _require_env("ISSUE_KEY")
738 title = _require_env("PR_TITLE")
739 feature_branch = _ticket_branch(issue_key, title)
741 if _remote_branch_exists(url, feature_branch):
742 subprocess.run(
743 _git_argv("clone", "--depth", "1", "--branch", feature_branch, url, project_dir),
744 check=True,
745 )
746 if not base_branch:
747 # Resume + empty BASE_BRANCH: the PR base is the remote default.
748 base_branch = _resolve_default_branch(project_dir)
749 else:
750 if base_branch:
751 subprocess.run(
752 _git_argv("clone", "--depth", "1", "--branch", base_branch, url, project_dir),
753 check=True,
754 )
755 else:
756 # Empty BASE_BRANCH → clone the default branch, then resolve the
757 # default branch name as a repo fact (PR base field + state).
758 subprocess.run(
759 _git_argv("clone", "--depth", "1", url, project_dir),
760 check=True,
761 )
762 base_branch = _resolve_default_branch(project_dir)
763 subprocess.run(
764 _git_argv("checkout", "-b", feature_branch),
765 cwd=project_dir,
766 check=True,
767 )
768 result = {**state, "checkout_done": True, "project_dir": project_dir,
769 "ticket_branch": feature_branch, "ticket_base_branch": base_branch}
770 return result
772 subprocess.run(
773 _git_argv("clone", "--depth", "1", "--branch", branch, url, project_dir),
774 check=True,
775 )
777 result = {**state, "checkout_done": True, "project_dir": project_dir}
779 if run_profile == "PR":
780 base_branch = _require_env("PR_BASE_BRANCH")
781 diff_base = f"origin/{base_branch}"
783 def _git(*args: str) -> subprocess.CompletedProcess:
784 try:
785 return subprocess.run(
786 _git_argv(*args),
787 cwd=project_dir,
788 capture_output=True,
789 text=True,
790 check=True,
791 )
792 except subprocess.CalledProcessError as exc:
793 detail = (exc.stderr or exc.stdout or "").strip()
794 raise ValueError(
795 f"git-checkout: 'git {' '.join(args)}' failed "
796 f"(exit {exc.returncode}): {detail or 'no output'}"
797 ) from exc
799 _git("fetch", "--depth", "1", "origin", f"{base_branch}:refs/remotes/origin/{base_branch}")
800 changed = _git("diff", "--name-only", f"{diff_base}..HEAD")
801 diff_out = _git("diff", f"{diff_base}..HEAD")
802 changed_files = [line for line in changed.stdout.splitlines() if line.strip()]
803 pr_diff = _join_pr_diff([diff_out.stdout])
804 result = {**result, "pr_diff": pr_diff, "changed_files": changed_files}
806 return result
809# ---------------------------------------------------------------------------
810# files-write
811# ---------------------------------------------------------------------------
814def files_write(step: dict, state: dict) -> dict:
815 """Kirjoittaa template-resolvautuneet {path, content}-entryt workspaceen.
817 params.entries on yksi {path, content}-objekti tai lista; polut ovat
818 workspace-suhteisia, kirjoituskohde workspace-juuri (state.project_root →
819 PROJECT_ROOT-env → /project, ADR-0024). Validointi ja kirjoitus
820 uudelleenkäyttävät validate_and_write_filesia (1 MiB/entry, 10 MiB/step,
821 polkupako/.git/ulkopako-esto, fail-fast ADR-0012).
822 """
823 params = step.get("params") or {}
824 entries = params.get("entries")
825 if entries is None or entries == []:
826 raise FilesOutputError(
827 "files-write: 'params.entries' is required — single "
828 "{path, content} object or a list"
829 )
830 if isinstance(entries, dict):
831 entries = [entries]
832 elif not isinstance(entries, list):
833 raise FilesOutputError(
834 f"files-write: 'params.entries' must be a single "
835 f"{{path, content}} object or a list, got {type(entries).__name__}"
836 )
838 workspace_root = resolve_workspace_root(state)
839 validate_and_write_files(entries, workspace_root)
840 written = [entry["path"] for entry in entries]
841 return {**state, step["id"]: {"written": written, "count": len(written)}}
844# ---------------------------------------------------------------------------
845# files-delete
846# ---------------------------------------------------------------------------
849def _is_tracked(workspace_root: Path, relative: str) -> bool:
850 """Onko tiedosto git-jäljitetty workspace-juuresta suhteellisena polkuna.
852 Ei-repo- tai ei-git-ympäristö → False (untracked-käsittely: pelkkä
853 tiedostojärjestelmän poisto — enginein add -A-commit ei tarvitse
854 stagingia poissaolevalle untracked-tiedostolle).
855 """
856 try:
857 proc = subprocess.run(
858 ["git", "-c", "safe.directory=*", "ls-files", "--", relative],
859 cwd=workspace_root,
860 capture_output=True,
861 text=True,
862 check=False,
863 )
864 except FileNotFoundError:
865 return False
866 return proc.returncode == 0 and bool(proc.stdout.strip())
869def _git_rm(workspace_root: Path, relative: str) -> None:
870 """Poista tracked-tiedosto ja stageaa poisto (git rm --force -semantiikka).
872 --force on pakollinen: poisto on nimenomainen tarkoitus, ja
873 approval-uusinta-ajo KIRJOITTAA split-proposalin uudelleen
874 (manifestin files-write ajaa ennen delete_ephemeral:a) — työpuussa on
875 siis muokattu versio, jonka tavallinen git rm kieltäytyisi poistamasta
876 (approval-run kuolisi deadlock-korjauslooppiin).
877 """
878 try:
879 subprocess.run(
880 ["git", "-c", "safe.directory=*", "rm", "--force", "--", relative],
881 cwd=workspace_root,
882 capture_output=True,
883 text=True,
884 check=True,
885 )
886 except subprocess.CalledProcessError as exc:
887 raise FilesOutputError(
888 f"files-delete: 'git rm {relative}' failed (exit {exc.returncode}): "
889 f"{(exc.stderr or exc.stdout or '').strip()} (ADR-0012)"
890 ) from exc
891 except FileNotFoundError as exc:
892 raise FilesOutputError(
893 "files-delete: git is not available — cannot stage the deletion "
894 f"of tracked file '{relative}' (ADR-0012)"
895 ) from exc
898def files_delete(step: dict, state: dict) -> dict:
899 """Poistaa workspace-suhteiset tiedostot (tracked → git rm, untracked → unlink).
901 params.paths on yksi workspace-suhteinen polkustring tai lista niistä;
902 poistokohde workspace-juuri (state.project_root → PROJECT_ROOT-env →
903 /project, ADR-0024). Kaikki polut validoidaan ENSIN (muoto, polkupako,
904 .git/ulkopako-esto, olemassaolo — fail-fast ADR-0012) ja poistetaan
905 vasta sitten all-or-nothing: yksikin virheellinen polku → mitään ei
906 poisteta. Tracked-tiedosto poistetaan git rm:llä (poisto stageattuna —
907 enginein myöhempi add -A-commit poimii sen), untracked pelkällä
908 tiedostojärjestelmän poistolla. Tulos: {"deleted": [<polut>], "count"}.
909 """
910 params = step.get("params") or {}
911 paths = params.get("paths")
912 if isinstance(paths, str):
913 paths = [paths]
914 elif not isinstance(paths, list) or not paths:
915 raise FilesOutputError(
916 "files-delete: 'params.paths' is required — a single "
917 "workspace-relative path string or a list of them (ADR-0012)"
918 )
920 workspace_root = resolve_workspace_root(state)
921 root = workspace_root.resolve(strict=False)
922 prepared: list[tuple[Path, str, str]] = []
923 for index, raw_path in enumerate(paths):
924 ref = f"path {index}"
925 if not isinstance(raw_path, str):
926 raise FilesOutputError(
927 f"files-delete: {ref} must be a string path, "
928 f"got {type(raw_path).__name__} (ADR-0012)"
929 )
930 if not raw_path.strip():
931 raise FilesOutputError(
932 f"files-delete: {ref} is empty or whitespace-only — "
933 "a path is required (ADR-0012)"
934 )
935 if Path(raw_path).is_absolute():
936 raise FilesOutputError(
937 f"files-delete: {ref} '{raw_path}' is absolute — only "
938 "workspace-relative paths are allowed (ADR-0012)"
939 )
940 resolved = _resolve_path(root, raw_path, root, ref)
941 if not resolved.is_file():
942 raise FilesOutputError(
943 f"files-delete: {ref} '{raw_path}' is missing or not a "
944 "file — nothing deleted (ADR-0012)"
945 )
946 prepared.append((resolved, raw_path, ref))
948 deleted: list[str] = []
949 for resolved, raw_path, ref in prepared:
950 if _is_tracked(workspace_root, raw_path):
951 _git_rm(workspace_root, raw_path)
952 else:
953 try:
954 resolved.unlink()
955 except OSError as exc:
956 raise FilesOutputError(
957 f"files-delete: {ref} failed to delete '{raw_path}': "
958 f"{exc} (ADR-0012)"
959 ) from exc
960 deleted.append(raw_path)
962 return {**state, step["id"]: {"deleted": deleted, "count": len(deleted)}}
965# ---------------------------------------------------------------------------
966# list-dir-entries
967# ---------------------------------------------------------------------------
969def _hard_failure(code: str, message: str, details: dict | None = None) -> "RunHardFailure":
970 try:
971 from runner import RunHardFailure
972 except ImportError: # pragma: no cover — main.py-ajossa (qualified importit)
973 from langgraph.runner import RunHardFailure
974 return RunHardFailure(code, message, details)
977def _entry_size(child: Path) -> int:
978 """Deterministic size for a single top-level entry (dirs use their stat size)."""
979 try:
980 return child.stat().st_size
981 except OSError as exc:
982 raise _hard_failure(
983 "LIST_DIR_UNREADABLE",
984 f"list-dir-entries: cannot stat entry '{child.name}': {exc} (ADR-0012)",
985 {"path": str(child)},
986 ) from exc
989def list_dir_entries(step: dict, state: dict) -> dict:
990 """
991 Deterministic ONE-level directory listing — structural skip only (ADR-0012).
993 Lists exactly one level of the resolved `path`; nested subdirectories are
994 NOT expanded. Only `_DEFAULT_SKIP_DIRS` top-level entries are dropped; no
995 semantic name/path filtering, no content read. A missing/unreadable/non-dir
996 root is a hard error (ADR-0012) — never a silent empty list.
998 params:
999 path: str (oletus: state.project_root tai /project)
1001 output (state[step.id]):
1002 {entries: [{path, name, size, kind: "dir"|"file"}],
1003 stats: {total_files, total_dirs, bytes}}
1004 """
1005 step_id = step.get("id", "list_dir_entries")
1006 params = step.get("params") or {}
1007 root = Path(
1008 params.get("path")
1009 or state.get("project_root")
1010 or os.getenv("PROJECT_ROOT", "/project")
1011 )
1012 if not root.is_dir():
1013 raise _hard_failure(
1014 "LIST_DIR_MISSING",
1015 f"list-dir-entries: path {str(root)!r} is missing or not a directory (ADR-0012)",
1016 {"step_id": step_id, "path": str(root)},
1017 )
1018 try:
1019 children = sorted(root.iterdir(), key=lambda p: p.name)
1020 except OSError as exc:
1021 raise _hard_failure(
1022 "LIST_DIR_UNREADABLE",
1023 f"list-dir-entries: cannot list {str(root)!r}: {exc} (ADR-0012)",
1024 {"step_id": step_id, "path": str(root)},
1025 ) from exc
1027 entries: list[dict] = []
1028 total_files = 0
1029 total_dirs = 0
1030 total_bytes = 0
1031 for child in children:
1032 if child.name in _DEFAULT_SKIP_DIRS:
1033 continue
1034 is_dir = child.is_dir()
1035 size = _entry_size(child)
1036 if is_dir:
1037 total_dirs += 1
1038 else:
1039 total_files += 1
1040 total_bytes += size
1041 entries.append({
1042 "path": child.relative_to(root).as_posix(),
1043 "name": child.name,
1044 "size": size,
1045 "kind": "dir" if is_dir else "file",
1046 })
1048 entries.sort(key=lambda e: e["path"])
1049 log.info("[list-dir-entries] entries=%d files=%d dirs=%d", len(entries), total_files, total_dirs)
1050 return {
1051 **state,
1052 step_id: {
1053 "entries": entries,
1054 "stats": {
1055 "total_files": total_files,
1056 "total_dirs": total_dirs,
1057 "bytes": total_bytes,
1058 },
1059 },
1060 }
1063# ---------------------------------------------------------------------------
1064# spawn-aggregate
1065# ---------------------------------------------------------------------------
1068def _collect_spawn_node(
1069 envelope: Any, *, file_set: list[dict], drops: list[dict],
1070 seen_files: set[str], seen_drops: set[str],
1071) -> int:
1072 """Recursively walk a spawn TResult envelope, unioning file_set/drops.
1074 Returns the number of spawn nodes visited (this envelope + all descendants).
1075 A node envelope contributes its own `result.file_set`/`result.drops` and
1076 recurses into each `children[<id>]` envelope. Dedupe by path (first wins).
1077 """
1078 if not isinstance(envelope, dict):
1079 return 1
1080 result = envelope.get("result")
1081 if isinstance(result, dict):
1082 for item in result.get("file_set") or []:
1083 if isinstance(item, dict) and item.get("path") and item["path"] not in seen_files:
1084 seen_files.add(item["path"])
1085 file_set.append(item)
1086 for drop in result.get("drops") or []:
1087 if isinstance(drop, dict) and drop.get("path") and drop["path"] not in seen_drops:
1088 seen_drops.add(drop["path"])
1089 drops.append(drop)
1090 children = envelope.get("children")
1091 if not isinstance(children, dict):
1092 return 1
1093 count = 1
1094 for child_id in sorted(children):
1095 count += _collect_spawn_node(
1096 children[child_id], file_set=file_set, drops=drops,
1097 seen_files=seen_files, seen_drops=seen_drops,
1098 )
1099 return count
1102def spawn_aggregate(step: dict, state: dict) -> dict:
1103 """
1104 Aggregate a spawn TResult envelope into a flat file_set + drops (ADR-0012).
1106 Recursively walks `state[source]` (the spawn envelope), unions every node's
1107 `result.file_set` and `result.drops`, deduping by `path` (first wins).
1109 params:
1110 source: str — the spawn step id (e.g. "descent")
1112 output (state[step.id]):
1113 {file_set: [{path, rationale, kind}...], drops: [{path, kind, rationale}...],
1114 stats: {nodes, files, drops}}
1115 """
1116 step_id = step.get("id", "spawn_aggregate")
1117 params = step.get("params") or {}
1118 source = str(params.get("source", "") or "").strip()
1119 if not source:
1120 raise _hard_failure(
1121 "SPAWN_AGGREGATE_MISSING_SOURCE",
1122 "spawn-aggregate: 'source' param is required (ADR-0012)",
1123 {"step_id": step_id},
1124 )
1125 if source not in state:
1126 raise _hard_failure(
1127 "SPAWN_AGGREGATE_MISSING_SOURCE",
1128 f"spawn-aggregate: source '{source}' is missing from state (ADR-0012)",
1129 {"step_id": step_id, "source": source},
1130 )
1132 envelope = state[source]
1133 if not isinstance(envelope, dict):
1134 return {
1135 **state,
1136 step_id: {
1137 "file_set": [],
1138 "drops": [],
1139 "stats": {"nodes": 0, "files": 0, "drops": 0},
1140 },
1141 }
1143 file_set: list[dict] = []
1144 drops: list[dict] = []
1145 seen_files: set[str] = set()
1146 seen_drops: set[str] = set()
1147 nodes = _collect_spawn_node(
1148 envelope, file_set=file_set, drops=drops,
1149 seen_files=seen_files, seen_drops=seen_drops,
1150 )
1152 log.info(
1153 "[spawn-aggregate] source=%s nodes=%d files=%d drops=%d",
1154 source, nodes, len(file_set), len(drops),
1155 )
1156 return {
1157 **state,
1158 step_id: {
1159 "file_set": file_set,
1160 "drops": drops,
1161 "stats": {"nodes": nodes, "files": len(file_set), "drops": len(drops)},
1162 },
1163 }
1166# ---------------------------------------------------------------------------
1167# results-union
1168# ---------------------------------------------------------------------------
1171def _split_path(path: Any) -> list[str]:
1172 if not isinstance(path, str):
1173 return []
1174 return [part for part in path.split(".") if part]
1177def _pluck(obj: Any, path: Any) -> Any:
1178 if not path:
1179 return obj
1180 current = obj
1181 for part in _split_path(path):
1182 if isinstance(current, dict) and part in current:
1183 current = current[part]
1184 else:
1185 return None
1186 return current
1189def _item_key(item: Any, dedupe_key: Any) -> Any:
1190 if dedupe_key and isinstance(item, dict):
1191 return _pluck(item, dedupe_key)
1192 if isinstance(item, dict):
1193 return None
1194 return item
1197def _union_results(results: Any, fields: Any, acc: Any = None) -> dict:
1198 """Fold per-iteration `results` into an accumulated, deduped target map.
1200 `acc` is read-only, never mutated. Dedupe is first-wins: items produced
1201 earlier in the fold (acc first, then results in order) shadow later items
1202 of the same key.
1203 """
1204 out: dict[str, list] = {}
1205 for target, items in (acc or {}).items():
1206 out[target] = list(items)
1208 for spec in fields or []:
1209 if not isinstance(spec, dict):
1210 continue
1211 target = spec.get("target")
1212 if not target:
1213 continue
1214 src_path = spec.get("source")
1215 dedupe_key = spec.get("dedupe_key")
1216 cur = out.setdefault(target, [])
1218 seen: dict[Any, bool] = {}
1219 for item in cur:
1220 key = _item_key(item, dedupe_key)
1221 if key is not None and key not in seen:
1222 seen[key] = True
1224 for element in results or []:
1225 src_list = _pluck(element, src_path)
1226 if not isinstance(src_list, (list, tuple)):
1227 continue
1228 for item in src_list:
1229 key = _item_key(item, dedupe_key)
1230 if key is not None and key not in seen:
1231 seen[key] = True
1232 cur.append(item)
1234 return out
1237def results_union(step: dict, state: dict) -> dict:
1238 """Pure, deterministic fold: union per-iteration `results` into a deduped map.
1240 function-agnostic — no context-file-set field names are hardcoded.
1242 input:
1243 results: list — this iteration's output list.
1244 fields: [{"target": str, "source": str|None, "dedupe_key": str|None}, ...]
1245 target: output key in state[step.id].
1246 source: dot-path plucked from each result element; the plucked value must
1247 be a list/tuple of items to fold (None/"" means the element itself
1248 is the list). A missing/None/empty source contributes nothing.
1249 dedupe_key: dot-path (dict items) or value (scalar items) used for
1250 first-wins dedupe; None/"" dedupes on the item value.
1251 acc: optional {target: [items]} accumulated from prior iterations.
1252 Read-only — never mutated.
1254 output (state[step.id]):
1255 {target: [deduped items]} for each field.
1256 """
1257 step_id = step.get("id", "results_union")
1258 input_spec = step.get("input") or {}
1259 out = _union_results(
1260 input_spec.get("results"),
1261 input_spec.get("fields"),
1262 input_spec.get("acc"),
1263 )
1264 return {**state, step_id: out}
1267# ---------------------------------------------------------------------------
1268# Registry helper
1269# ---------------------------------------------------------------------------
1272def register_all(executor: Any) -> None:
1273 """Rekisteröi kaikki builtinit BuiltinExecutoriin (sekä '-' että '_' nimillä)."""
1274 executor.register("list-dir-entries", list_dir_entries)
1275 executor.register("spawn-aggregate", spawn_aggregate)
1276 executor.register("results-union", results_union)
1277 executor.register("path-verifier", path_verifier)
1278 executor.register("finding-prefilter", finding_prefilter)
1279 executor.register("ticket-publisher", ticket_publisher)
1280 executor.register("run-finalizer", run_finalizer)
1281 executor.register("git-checkout", _git_checkout)
1282 executor.register("files-write", files_write)
1283 executor.register("files-delete", files_delete)