Coverage for core/langgraph/_builtins.py: 32%
468 statements
« prev ^ index » next coverage.py v7.15.3, created at 2026-08-08 05:24 +0000
« prev ^ index » next coverage.py v7.15.3, created at 2026-08-08 05:24 +0000
1"""
2_builtins.py — Domain builtins for agent-platform pipelines (v1).
4Toteuttaa ne builtin-komponentit, joita doc-quality.yaml käyttää ja jotka
5porttaavat doc-quality-pipeline.py:n ei-LLM-logiikan generic-muotoon:
7- file-collector kerää & luokittelee tiedostot mountista
8- path-verifier tarkistaa dokumenttien viittaamat polut
9- finding-prefilter suodattaa false-positive findingit ennen LLM-triagea
10- ticket-publisher kirjoittaa hyväksytyn ticketin staging → published
11- run-finalizer koostaa stats + flushaa Langfusen
13Kaikki builtinit noudattavat sopimusta:
14 fn(step: dict, state: dict) -> dict # palauttaa uuden state-snapshotin
15ja kirjoittavat tuloksensa kenttään `state[step["id"]]`.
16"""
17from __future__ import annotations
19import fnmatch
20import json
21import logging
22import os
23import re
24import shutil
25import subprocess
26from concurrent.futures import ThreadPoolExecutor, as_completed
27from datetime import date
28from pathlib import Path
29from typing import Any
30from urllib.parse import urlparse, urlunparse
32log = logging.getLogger(__name__)
35# ---------------------------------------------------------------------------
36# file-collector
37# ---------------------------------------------------------------------------
40_DEFAULT_SKIP_DIRS = {
41 ".git", "node_modules", "__pycache__", ".venv", "venv",
42 ".mypy_cache", ".pytest_cache", ".ruff_cache",
43}
44_DEFAULT_SUFFIXES = {".py", ".sh", ".yaml", ".yml", ".md", ".json", ".toml"}
45_MAX_FILE_BYTES_DEFAULT = 64 * 1024
46_MAX_EVIDENCE_DEFAULT = 32
49def _split_exclude(exclude: list[str] | None) -> tuple[set[str], list[str]]:
50 """Erottele dir-nimet ja glob-patternit."""
51 excl_dirs: set[str] = set(_DEFAULT_SKIP_DIRS)
52 excl_globs: list[str] = []
53 for item in (exclude or []):
54 if not item:
55 continue
56 if "/" in item or "*" in item:
57 excl_globs.append(item)
58 else:
59 excl_dirs.add(item)
60 return excl_dirs, excl_globs
63def _list_mount_files(root: Path, excl_dirs: set[str], excl_globs: list[str]) -> list[str]:
64 out: list[str] = []
65 for dirpath, dirnames, filenames in os.walk(root):
66 rel_dir = Path(dirpath).relative_to(root)
67 parts = rel_dir.parts
68 # Skip if any path segment matches excl_dirs
69 if parts and any(p in excl_dirs for p in parts):
70 dirnames.clear()
71 continue
72 # Skip explicit nested patterns like ".agent-platform/tmp"
73 rel_dir_posix = rel_dir.as_posix() if parts else ""
74 if rel_dir_posix and any(fnmatch.fnmatch(rel_dir_posix, g) or rel_dir_posix.startswith(g.rstrip("/*") + "/") for g in excl_globs):
75 dirnames.clear()
76 continue
77 dirnames[:] = [d for d in dirnames if d not in excl_dirs]
78 for name in filenames:
79 rel = (rel_dir / name).as_posix() if parts else name
80 out.append(rel)
81 return out
84def _match_any(rel: str, patterns: list[str]) -> bool:
85 for pat in patterns:
86 if fnmatch.fnmatch(rel, pat) or rel == pat:
87 return True
88 if pat.endswith("/**") and rel.startswith(pat[:-3]):
89 return True
90 if pat.endswith("/*") and rel.startswith(pat[:-2]) and "/" not in rel[len(pat) - 2:]:
91 return True
92 return False
95def _classify(rel: str, include: list[str], doc_paths: set[str], ticket_glob: str, evidence_globs: list[str]) -> str | None:
96 """Luokittele tiedosto: doc / ticket / evidence."""
97 # docs: explicit list or glob
98 if rel in doc_paths or _match_any(rel, [p for p in include if p.startswith("docs/") and p.endswith(".md") and "**" not in p]):
99 # docs/ai-context.md, docs/architecture.md, docs/adr/*.md
100 if rel.startswith("docs/") and rel.endswith(".md") and not rel.startswith("docs/tickets/"):
101 return "doc"
102 # tickets
103 if fnmatch.fnmatch(rel, ticket_glob):
104 return "ticket"
105 if fnmatch.fnmatch(rel, "docs/tickets/done/*.md"):
106 return "ticket"
107 # evidence
108 if _match_any(rel, evidence_globs):
109 return "evidence"
110 return None
113def _read_file(root: Path, rel: str, max_bytes: int) -> dict:
114 full = root / rel
115 truncated = False
116 content = ""
117 if full.is_file():
118 data = full.read_bytes()
119 if len(data) > max_bytes:
120 data = data[:max_bytes]
121 truncated = True
122 content = data.decode("utf-8", errors="replace")
123 return {"path": rel, "content": content, "truncated": truncated}
126_PIPELINE_SOURCE_FOOTER = "Source: doc-quality"
129def _ticket_is_audit_input(root: Path, rel: str, filter_mode: str) -> bool:
130 """human_backlog_only → ohita pipeline-julkaistut, paitsi jos GATED/REVIEWED."""
131 if filter_mode != "human_backlog_only":
132 return True
133 full = root / rel
134 if not full.is_file():
135 return False
136 try:
137 head = full.read_text(encoding="utf-8", errors="replace")[:8000]
138 except OSError:
139 return False
140 if _PIPELINE_SOURCE_FOOTER in head:
141 if re.search(r"^\*\*Status:\*\*\s*(GATED|REVIEWED)\b", head, re.M | re.I):
142 return True
143 return False
144 return True
147def file_collector(step: dict, state: dict) -> dict:
148 """
149 params:
150 path: str (oletus: state.project_root tai /project)
151 include: [glob]
152 exclude: [str|glob]
153 max_evidence_files: int
154 max_file_bytes: int
155 allowed_suffixes: [str]
156 ticket_filter: 'human_backlog_only' | 'all'
157 output (state[step.id]):
158 {doc_files: [{path, content, truncated}], ticket_files: [...], evidence_files: [...], stats: {...}}
159 """
160 step_id = step.get("id", "gather")
161 params = step.get("params") or {}
162 root = Path(params.get("path") or state.get("project_root") or os.getenv("PROJECT_ROOT", "/project"))
163 include: list[str] = list(params.get("include") or [])
164 exclude: list[str] = list(params.get("exclude") or [])
165 max_evidence = int(params.get("max_evidence_files", _MAX_EVIDENCE_DEFAULT))
166 max_bytes = int(params.get("max_file_bytes", _MAX_FILE_BYTES_DEFAULT))
167 allowed = {s.lower() for s in (params.get("allowed_suffixes") or [])} or set(_DEFAULT_SUFFIXES)
168 ticket_filter = str(params.get("ticket_filter", "all"))
170 excl_dirs, excl_globs = _split_exclude(exclude)
171 all_files = _list_mount_files(root, excl_dirs, excl_globs)
173 # Doc paths: explicit literal paths from include list ending .md and not under tickets/
174 doc_paths: set[str] = {p for p in include if p.endswith(".md") and "**" not in p and "*" not in p and not p.startswith("docs/tickets/")}
175 # adr glob
176 adr_globs = [p for p in include if "*" in p and p.endswith(".md") and not p.startswith("docs/tickets/")]
177 # ticket glob
178 ticket_glob = next((p for p in include if p.startswith("docs/tickets/") and p.endswith("*.md")), "docs/tickets/*.md")
179 # evidence patterns (everything else)
180 evidence_globs = [
181 p for p in include
182 if p not in doc_paths and not p.startswith("docs/tickets/") and not (p in adr_globs)
183 ]
185 docs: list[str] = []
186 tickets: list[str] = []
187 evidence: list[str] = []
189 for rel in sorted(all_files):
190 suffix = Path(rel).suffix.lower()
191 if suffix and suffix not in allowed:
192 continue
193 # doc?
194 if rel in doc_paths:
195 docs.append(rel); continue
196 if any(fnmatch.fnmatch(rel, g) for g in adr_globs):
197 docs.append(rel); continue
198 # ticket?
199 if fnmatch.fnmatch(rel, ticket_glob) or fnmatch.fnmatch(rel, "docs/tickets/done/*.md"):
200 if _ticket_is_audit_input(root, rel, ticket_filter):
201 tickets.append(rel)
202 continue
203 # evidence?
204 if _match_any(rel, evidence_globs):
205 evidence.append(rel); continue
207 evidence = evidence[:max_evidence]
209 def _read_all(paths: list[str]) -> list[dict]:
210 if not paths:
211 return []
212 out: list[dict] = []
213 with ThreadPoolExecutor(max_workers=min(16, len(paths))) as ex:
214 futs = {ex.submit(_read_file, root, p, max_bytes): p for p in paths}
215 for fut in as_completed(futs):
216 out.append(fut.result())
217 return sorted(out, key=lambda d: d["path"])
219 result = {
220 "doc_files": _read_all(docs),
221 "ticket_files": _read_all(tickets),
222 "evidence_files": _read_all(evidence),
223 "stats": {
224 "docs": len(docs),
225 "tickets": len(tickets),
226 "evidence": len(evidence),
227 },
228 }
229 log.info("[file-collector] docs=%d tickets=%d evidence=%d", len(docs), len(tickets), len(evidence))
230 return {**state, step_id: result, "project_root": str(root)}
233# ---------------------------------------------------------------------------
234# path-verifier
235# ---------------------------------------------------------------------------
238def _path_resolution_candidates(rel: str, source_doc: str = "") -> list[str]:
239 rel = rel.strip().lstrip("/")
240 if not rel or rel.startswith("http"):
241 return []
242 candidates: list[str] = [rel]
243 if source_doc:
244 doc_dir = Path(source_doc).parent.as_posix()
245 if doc_dir and doc_dir != ".":
246 joined = f"{doc_dir}/{rel}".replace("//", "/")
247 if joined not in candidates:
248 candidates.append(joined)
249 if not rel.startswith(("docs/", ".agent-platform/", "skills/", "assets/", "scripts/")):
250 if f"docs/{rel}" not in candidates:
251 candidates.append(f"docs/{rel}")
252 if rel.startswith("adr/"):
253 prefixed = f"docs/{rel}"
254 if prefixed not in candidates:
255 candidates.append(prefixed)
256 return candidates
259def _paths_in_text(text: str, max_paths: int = 25) -> list[str]:
260 seen: set[str] = set()
261 out: list[str] = []
263 def add(raw: str) -> None:
264 p = raw.strip().split("#")[0].strip()
265 if not p or p.startswith("http") or p in seen:
266 return
267 # Suodata pois ei-polku-osumat: rivinvaihdot, välilyönnit, liian pitkät
268 # (esim. mermaid-koodilohkojen sisältö, joka match-aa backtick-regexiin).
269 if any(ch in p for ch in ("\n", "\r", " ", "\t")) or len(p) > 255:
270 return
271 if "/" in p or p.endswith((".md", ".py", ".yaml", ".yml", ".sh", ".json", ".toml")):
272 seen.add(p)
273 out.append(p)
275 for p in re.findall(r"`([^`]+)`", text):
276 add(p)
277 if len(out) >= max_paths:
278 return out[:max_paths]
279 for p in re.findall(r"\]\(([^)]+)\)", text):
280 add(p)
281 if len(out) >= max_paths:
282 break
283 return out[:max_paths]
286def _verify_paths_for_doc(root: Path, source_doc: str, doc_text: str) -> str:
287 paths = _paths_in_text(doc_text)
288 if not paths:
289 return ""
290 lines = [
291 "\n\n## Path verification (project mount — authoritative)\n",
292 f"Document under audit: `{source_doc}` — resolve relative links from this directory.\n",
293 "Do NOT report a path as missing when any resolved candidate below is `exists`.\n",
294 ]
295 for p in paths:
296 resolved = _path_resolution_candidates(p, source_doc)
297 hit = next((c for c in resolved if (root / c).is_file()), None)
298 if hit:
299 lines.append(f"- `{p}`: exists (at `{hit}`)")
300 else:
301 shown = ", ".join(f"`{c}`" for c in resolved[:4])
302 lines.append(f"- `{p}`: MISSING (checked: {shown})")
303 return "\n".join(lines)
306def path_verifier(step: dict, state: dict) -> dict:
307 """
308 params:
309 docs_from: dot-path state-avain (esim. "gather.doc_files")
310 output (state[step.id]):
311 {by_path: {<doc.path>: <verification_block_string>}}
312 """
313 step_id = step.get("id", "verify_paths")
314 params = step.get("params") or {}
315 docs_from = params.get("docs_from", "gather.doc_files")
316 root = Path(state.get("project_root") or os.getenv("PROJECT_ROOT", "/project"))
318 # Naive dot-path lookup against state
319 cur: Any = state
320 for part in docs_from.split("."):
321 if isinstance(cur, dict):
322 cur = cur.get(part)
323 else:
324 cur = None
325 break
326 docs_list = cur if isinstance(cur, list) else []
328 by_path: dict[str, str] = {}
329 for d in docs_list:
330 if not isinstance(d, dict):
331 continue
332 path = d.get("path") or ""
333 content = d.get("content") or ""
334 if path and content:
335 by_path[path] = _verify_paths_for_doc(root, path, content)
336 log.info("[path-verifier] verified %d docs", len(by_path))
337 return {**state, step_id: {"by_path": by_path}}
340# ---------------------------------------------------------------------------
341# finding-prefilter
342# ---------------------------------------------------------------------------
345_FALSE_MISSING_PHRASES = (
346 "does not exist", "do not exist", "non-existent", "non existent",
347 "not exist in the project", "missing from the project", "cannot be found",
348 "can't be found", "file is missing", "not found in the project",
349)
350_NON_ACTIONABLE_PHRASES = (
351 "no violation", "no missing path", "not a claim of absence",
352 "no observable mismatch", "does not violate", "without verification",
353 "not an issue", "no actionable", "no issue found",
354 "no concrete doc defect", "no documentation defect",
355 "not a documentation defect", "forward reference, not a claim",
356 "this is a forward reference", "no missing path claims",
357 "not verified to exist", "are not verified to exist",
358 "is not verified to exist",
359)
360_FUTURE_CLAIM_PHRASES = (
361 "is in the future",
362 "which is in the future",
363 "future date",
364 "in the future",
365)
366_DATE_PATTERN = re.compile(r"\b(\d{4}-\d{2}-\d{2})\b")
367_SEVERITY_RANK = {"high": 0, "medium": 1, "low": 2}
370def _is_non_actionable(detail: str) -> bool:
371 lower = (detail or "").lower()
372 if not lower.strip():
373 return True
374 for phrase in _NON_ACTIONABLE_PHRASES:
375 if phrase in lower:
376 return True
377 if "does not cite" in lower and "not existence" in lower:
378 return True
379 return False
382def _is_false_future_date(detail: str) -> bool:
383 """Return True when the finding claims a past-or-present date is in the future.
385 Auditors occasionally hallucinate temporal direction — labelling a document
386 date that has already passed as "in the future". The check is conservative:
387 we only reject when the detail contains an explicit future-claim phrase AND
388 every ISO-8601 date found in the text is on or before today.
389 """
390 lower = (detail or "").lower()
391 if not any(phrase in lower for phrase in _FUTURE_CLAIM_PHRASES):
392 return False
393 today = date.today()
394 dates_found = _DATE_PATTERN.findall(detail or "")
395 if not dates_found:
396 return False
397 # Reject only when all mentioned dates are past-or-present (no genuinely future date)
398 for raw in dates_found:
399 try:
400 if date.fromisoformat(raw) > today:
401 return False # at least one date really is in the future → keep
402 except ValueError:
403 continue
404 return True
407def _claimed_missing_paths(detail: str) -> list[str]:
408 paths = [p.strip() for p in re.findall(r"Referenced path `([^`]+)`", detail or "", re.I)]
409 if not paths:
410 for p in re.findall(r"`([^`]+)`", detail or ""):
411 p = p.strip()
412 if p and not p.startswith("http") and ("/" in p or "." in p):
413 paths.append(p)
414 seen: set[str] = set()
415 out: list[str] = []
416 for p in paths:
417 if p not in seen:
418 seen.add(p)
419 out.append(p)
420 return out
423def _project_path_exists(root: Path, rel: str, source_doc: str = "") -> bool:
424 for c in _path_resolution_candidates(rel, source_doc):
425 if (root / c).is_file():
426 return True
427 return False
430def _finding_source_doc(finding: dict) -> str:
431 for r in finding.get("refs") or []:
432 if isinstance(r, str) and ("/" in r or r.endswith(".md")):
433 return r
434 return ""
437def _is_false_positive(root: Path, finding: dict) -> tuple[bool, str]:
438 detail = finding.get("detail") or ""
439 if _is_non_actionable(detail):
440 return True, "non_actionable"
441 if _is_false_future_date(detail):
442 return True, "false_future_date"
443 lower = detail.lower()
444 layer = finding.get("layer", "")
446 if "docs/tickets/" in lower and ("ticket `" in lower or "ticket '" in lower):
447 return True, "meta_ticket_noise"
449 if any(p in lower for p in _FALSE_MISSING_PHRASES):
450 source_doc = _finding_source_doc(finding)
451 claimed = _claimed_missing_paths(detail)
452 if claimed and all(_project_path_exists(root, p, source_doc) for p in claimed):
453 return True, "false_missing_file"
454 refs = [r for r in (finding.get("refs") or []) if isinstance(r, str)]
455 if refs and all(_project_path_exists(root, r, source_doc) for r in refs):
456 return True, "false_missing_file"
458 if layer == "C" and "doc sample" in lower and ("empty" in lower or "does not include" in lower):
459 return True, "false_empty_doc_sample"
461 if finding.get("severity") == "low":
462 for noise in ("typo", "capitalization", "readability", "finnish", "english",
463 "language mixing", "mixed language"):
464 if noise in lower:
465 return True, "low_style_noise"
467 return False, ""
470def _severity_allowed(severity: str, min_severity: str) -> bool:
471 s = _SEVERITY_RANK.get(str(severity).lower(), 3)
472 m = _SEVERITY_RANK.get(min_severity, 1)
473 return s <= m
476def finding_prefilter(step: dict, state: dict) -> dict:
477 """
478 params:
479 min_severity: 'high'|'medium'|'low'
480 finding: the finding dict (passed via params via {{ item }} in manifest)
481 OR step.input resolves to it (preferred shape).
482 output (state[step.id]):
483 {keep: bool, reason: str, finding: dict}
484 """
485 step_id = step.get("id", "prefilter")
486 params = step.get("params") or {}
487 min_severity = str(params.get("min_severity", "medium")).lower()
489 # The finding can arrive via input (resolved) or params.finding
490 finding = step.get("input")
491 if not isinstance(finding, dict):
492 finding = params.get("finding")
493 if not isinstance(finding, dict):
494 return {**state, step_id: {"keep": False, "reason": "no_finding", "finding": {}}}
496 root = Path(state.get("project_root") or os.getenv("PROJECT_ROOT", "/project"))
497 is_fp, reason = _is_false_positive(root, finding)
498 if is_fp:
499 return {**state, step_id: {"keep": False, "reason": reason, "finding": finding}}
500 if not _severity_allowed(finding.get("severity", "low"), min_severity):
501 return {**state, step_id: {"keep": False, "reason": f"below_min_severity_{min_severity}", "finding": finding}}
502 return {**state, step_id: {"keep": True, "reason": "", "finding": finding}}
505# ---------------------------------------------------------------------------
506# ticket-publisher
507# ---------------------------------------------------------------------------
510_TICKET_ORIGIN = "doc-quality-pipeline"
513def _ensure_ticket_date(text: str, today_str: str) -> str:
514 """Inject **Date:** after the title line if not already present."""
515 if re.search(r"^\*\*Date:\*\*", text, re.M | re.I):
516 return text
517 m = re.search(r"^(#\s+Ticket:.*)$", text, re.M | re.I)
518 if m:
519 insert_at = m.end()
520 return text[:insert_at] + f"\n\n**Date:** {today_str}" + text[insert_at:]
521 return text
524def _ensure_ticket_origin(text: str) -> str:
525 if re.search(r"^\*\*Origin:\*\*", text, re.M | re.I):
526 return re.sub(
527 r"^\*\*Origin:\*\*\s*.*$",
528 f"**Origin:** {_TICKET_ORIGIN}",
529 text,
530 count=1,
531 flags=re.M | re.I,
532 )
533 m = re.search(r"^(#\s+Ticket:.*)$", text, re.M | re.I)
534 if m:
535 insert_at = m.end()
536 return text[:insert_at] + f"\n\n**Origin:** {_TICKET_ORIGIN}" + text[insert_at:]
537 return f"**Origin:** {_TICKET_ORIGIN}\n\n" + text
540def _slug_from_text(text: str) -> str:
541 m = re.search(r"^#\s+Ticket:\s*(.+)$", text, re.M | re.I)
542 title = m.group(1).strip() if m else "doc-quality-issue"
543 return re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-")[:40] or "issue"
546def ticket_publisher(step: dict, state: dict) -> dict:
547 """
548 params:
549 staging_dir: str (oletus .agent-platform/tmp/doc-quality-tickets)
550 publish_dir: str (oletus docs/tickets)
551 status_from: str (DRAFT)
552 status_to: str (GATED)
553 source_footer: str (Source: doc-quality)
554 input (resolved): ticket-markdown teksti TAI {output: "..."} dict
555 output (state[step.id]): {path: str|None, slug: str|None, error?: str}
556 """
557 step_id = step.get("id", "publish")
558 params = step.get("params") or {}
559 root = Path(state.get("project_root") or os.getenv("PROJECT_ROOT", "/project"))
561 ticket_text = step.get("input")
562 if isinstance(ticket_text, dict):
563 ticket_text = ticket_text.get("output") or ticket_text.get("text") or ""
564 if not isinstance(ticket_text, str) or not ticket_text.strip():
565 return {**state, step_id: {"path": None, "slug": None, "error": "empty_ticket"}}
567 status_from = str(params.get("status_from", "DRAFT"))
568 status_to = str(params.get("status_to", "GATED"))
569 publish_dir = root / str(params.get("publish_dir", "docs/tickets"))
570 source_footer = str(params.get("source_footer", "Source: doc-quality"))
572 body = ticket_text.strip()
573 today = date.today().isoformat()
574 # Strip optional draft prefix line "> **DRAFT** — ..."
575 body = re.sub(r"^>\s*\*\*DRAFT\*\*.*\n\n", "", body, count=1, flags=re.M)
576 # Status DRAFT → GATED
577 if re.search(rf"^\*\*Status:\*\*\s*{status_from}\s*$", body, re.M | re.I):
578 body = re.sub(
579 rf"^\*\*Status:\*\*\s*{status_from}\s*$",
580 f"**Status:** {status_to}",
581 body,
582 count=1,
583 flags=re.M | re.I,
584 )
585 elif not re.search(r"^\*\*Status:\*\*", body, re.M | re.I):
586 # Inject Status block after first heading
587 body = re.sub(
588 r"^(#\s+Ticket:.*)$",
589 rf"\1\n\n**Status:** {status_to}",
590 body,
591 count=1,
592 flags=re.M,
593 )
594 body = _ensure_ticket_date(body, today)
595 body = _ensure_ticket_origin(body)
597 slug = _slug_from_text(body)
598 publish_dir.mkdir(parents=True, exist_ok=True)
599 target = publish_dir / f"{today}-{slug}.md"
600 n = 1
601 while target.exists():
602 target = publish_dir / f"{today}-{slug}-{n}.md"
603 n += 1
605 footer = f"\n\n---\n{source_footer}\n"
606 try:
607 target.write_text(body.rstrip() + footer, encoding="utf-8")
608 except OSError as exc:
609 return {**state, step_id: {"path": None, "slug": slug, "error": str(exc)}}
611 rel_path = str(target.relative_to(root)) if target.is_relative_to(root) else str(target)
612 log.info("[ticket-publisher] published %s", rel_path)
613 return {**state, step_id: {"path": rel_path, "slug": slug}}
616# ---------------------------------------------------------------------------
617# run-finalizer
618# ---------------------------------------------------------------------------
621def run_finalizer(step: dict, state: dict) -> dict:
622 """
623 Koostaa loppustatuksen + runlog-rivin + flushaa Langfuse.
624 params:
625 runlog: str (oletus .agent-platform/tmp/runlog)
626 pipeline_id: str
627 output (state[step.id]): {run_status, stats, runlog_path?}
628 """
629 step_id = step.get("id", "finalize")
630 params = step.get("params") or {}
631 pipeline_id = str(params.get("pipeline_id", "pipeline"))
632 root = Path(state.get("project_root") or os.getenv("PROJECT_ROOT", "/project"))
634 stats: dict[str, Any] = dict(state.get("stats") or {})
636 # Aggregate counts from common state fields if present
637 # foreach tallentaa publish-stepin outputin (= {"path": rel}) sellaisenaan
638 # results-listaan, ei {"publish": ...} -kääreessä. Tuetaan molempia muotoja.
639 process_results = state.get("process_findings") or []
640 if isinstance(process_results, list):
641 def _published_path(r: Any) -> str | None:
642 if not isinstance(r, dict):
643 return None
644 # Suora muoto: {"path": "..."} (foreachin publish_out)
645 if r.get("path"):
646 return r.get("path")
647 # Käärittu muoto: {"publish": {"path": "..."}}
648 return ((r.get("publish") or {}).get("path"))
650 published = [r for r in process_results if _published_path(r)]
651 stats["tickets_published"] = len(published)
652 stats["tickets_created"] = len(published)
654 merged = state.get("merge_findings")
655 if isinstance(merged, dict):
656 findings = merged.get("findings") or []
657 stats["findings_merged"] = len(findings)
659 gather = state.get("gather")
660 if isinstance(gather, dict) and gather.get("stats"):
661 for k, v in gather["stats"].items():
662 stats.setdefault(k, v)
664 run_status = state.get("run_status") or "ok"
665 if state.get("errors"):
666 run_status = "partial" if run_status != "failed" else run_status
668 # Append runlog line (best-effort)
669 runlog_path: str | None = None
670 runlog_dir = root / str(params.get("runlog", ".agent-platform/tmp/runlog"))
671 try:
672 runlog_dir.mkdir(parents=True, exist_ok=True)
673 line = {
674 "pipeline": pipeline_id,
675 "status": run_status,
676 "stats": stats,
677 "errors": (state.get("errors") or [])[:10],
678 }
679 target = runlog_dir / f"{date.today().isoformat()}-{pipeline_id}.jsonl"
680 with target.open("a", encoding="utf-8") as fh:
681 fh.write(json.dumps(line, ensure_ascii=False) + "\n")
682 runlog_path = str(target.relative_to(root)) if target.is_relative_to(root) else str(target)
683 except OSError as exc:
684 log.warning("[run-finalizer] runlog write failed: %s", exc)
686 out_state = {
687 **state,
688 step_id: {"run_status": run_status, "stats": stats, "runlog_path": runlog_path},
689 "run_status": run_status,
690 "stats": stats,
691 }
692 return out_state
694# ---------------------------------------------------------------------------
695# git-checkout
696# ---------------------------------------------------------------------------
699_PR_DIFF_MAX_CHARS = 20_000
700_PR_DIFF_TRUNCATION_SUFFIX = "\n… (truncated)"
703def _require_env(name: str) -> str:
704 value = os.environ.get(name)
705 if not value:
706 raise ValueError(f"git-checkout: required env var {name!r} is not set")
707 return value
710def _join_pr_diff(patches: list[str]) -> str:
711 joined = "\n".join(p for p in patches if p)
712 if len(joined) > _PR_DIFF_MAX_CHARS:
713 joined = joined[:_PR_DIFF_MAX_CHARS].rstrip() + _PR_DIFF_TRUNCATION_SUFFIX
714 return joined
717def _git_checkout(step: dict, state: dict) -> dict:
718 url = _require_env("GIT_REPO_URL")
719 token = _require_env("GIT_TOKEN")
720 run_profile = _require_env("RUN_PROFILE")
722 if run_profile == "PR":
723 branch = _require_env("PR_HEAD_BRANCH")
724 elif run_profile == "BRANCH":
725 branch = _require_env("BASE_BRANCH")
726 else:
727 raise ValueError(
728 f"git-checkout: unsupported RUN_PROFILE {run_profile!r} (expected 'PR' or 'BRANCH')"
729 )
731 parsed = urlparse(url)
732 host = parsed.hostname or ""
733 new_netloc = f"x-access-token:{token}@{host}"
734 url = urlunparse(parsed._replace(netloc=new_netloc))
736 project_dir = "/project"
737 if os.path.exists(project_dir):
738 with os.scandir(project_dir) as entries:
739 for entry in entries:
740 if entry.is_symlink() or not entry.is_dir():
741 os.unlink(entry.path)
742 else:
743 shutil.rmtree(entry.path)
745 subprocess.run(
746 ["git", "clone", "--depth", "1", "--branch", branch, url, project_dir],
747 check=True,
748 )
750 result = {**state, "checkout_done": True, "project_dir": project_dir}
752 if run_profile == "PR":
753 base_branch = _require_env("PR_BASE_BRANCH")
754 diff_base = f"origin/{base_branch}"
756 def _git(*args: str) -> subprocess.CompletedProcess:
757 try:
758 return subprocess.run(
759 ["git", *args],
760 cwd=project_dir,
761 capture_output=True,
762 text=True,
763 check=True,
764 )
765 except subprocess.CalledProcessError as exc:
766 detail = (exc.stderr or exc.stdout or "").strip()
767 raise ValueError(
768 f"git-checkout: 'git {' '.join(args)}' failed "
769 f"(exit {exc.returncode}): {detail or 'no output'}"
770 ) from exc
772 _git("fetch", "--depth", "1", "origin", f"{base_branch}:refs/remotes/origin/{base_branch}")
773 changed = _git("diff", "--name-only", f"{diff_base}..HEAD")
774 diff_out = _git("diff", f"{diff_base}..HEAD")
775 changed_files = [line for line in changed.stdout.splitlines() if line.strip()]
776 pr_diff = _join_pr_diff([diff_out.stdout])
777 result = {**result, "pr_diff": pr_diff, "changed_files": changed_files}
779 return result
782# ---------------------------------------------------------------------------
783# Registry helper
784# ---------------------------------------------------------------------------
787def register_all(executor: Any) -> None:
788 """Rekisteröi kaikki builtinit BuiltinExecutoriin (sekä '-' että '_' nimillä)."""
789 executor.register("file-collector", file_collector)
790 executor.register("path-verifier", path_verifier)
791 executor.register("finding-prefilter", finding_prefilter)
792 executor.register("ticket-publisher", ticket_publisher)
793 executor.register("run-finalizer", run_finalizer)
794 executor.register("git-checkout", _git_checkout)