Coverage for src/keel/runner.py: 100%
63 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-18 12:05 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-18 12:05 +0000
1"""Thin I/O: execute shell-command gates (build / lint / command extensions).
3This is the only place keel shells out for gates. It is deliberately thin and
4**fail-soft**: a timeout or a missing binary becomes a failed :class:`CommandResult`
5rather than an exception. The subprocess call is injectable (``_run``) so the gate
6runner is fully unit-testable offline; agentic gates are dispatched elsewhere.
7"""
9from __future__ import annotations
11import re
12import subprocess # nosec B404
13from collections.abc import Callable
14from dataclasses import dataclass
15from typing import TYPE_CHECKING
17from .findings import Finding
18from .model import DEFAULT_GATE_TIMEOUT_S
20if TYPE_CHECKING: # pragma: no cover
21 from .gates import GateSpec
23GateRunner = Callable[
24 ["GateSpec"], "tuple[bool, list[Finding]] | tuple[bool, list[Finding], bool]"
25]
27_ON_FAIL_SEVERITY = {"block": "major", "suggest": "minor", "warn": "nit"}
29#: reviewdog-style errorformat: ``path:line[:col]: message`` (first hit wins).
30#: A single multiline ``search`` replaces a per-``splitlines`` loop: ``^`` is
31#: anchored to each line by ``re.MULTILINE``, the path classes exclude ``\n`` so a
32#: match can never span lines, and the trailing ``(?:[:\s]|$)`` accepts the line
33#: number at end-of-line.
34_LOCATION_RE = re.compile(
35 r"^[ \t]*(?P<path>[^\s\n:][^:\n]*?):(?P<line>\d+)(?::\d+)?(?:[:\s]|$)",
36 re.MULTILINE,
37)
40def first_location(text: str) -> tuple[str | None, int | None]:
41 """Extract the first ``path:line`` location from tool output (``(None, None)`` if none)."""
42 m = _LOCATION_RE.search(text)
43 return (m.group("path"), int(m.group("line"))) if m else (None, None)
46@dataclass(frozen=True)
47class CommandResult:
48 ok: bool
49 code: int
50 #: ``stdout + stderr``, concatenated. Kept for the diagnostic uses that genuinely
51 #: want both (a failing gate's message, an output tail). **Do not parse structured
52 #: data out of this** — a command that writes progress or warnings to stderr while
53 #: exiting 0 (git's ``warning: refname … is ambiguous``, ai-jury's ``[jury] …``
54 #: logs) leaves the real payload glued to noise. Parse :attr:`stdout` instead.
55 output: str
56 #: True when the wall-clock timeout killed the command (exit 124). A timeout is
57 #: still a failure — ``ok`` stays False — but it carries no pass/fail verdict, so
58 #: callers can label it distinctly instead of reporting it as a broken test.
59 timed_out: bool = False
60 #: Captured standard output alone. This is what parsers must read: a tool's
61 #: machine-readable result goes here, never contaminated by stderr diagnostics.
62 stdout: str = ""
63 #: Captured standard error alone.
64 stderr: str = ""
67def _result(proc) -> CommandResult:
68 out = proc.stdout or ""
69 err = proc.stderr or ""
70 return CommandResult(proc.returncode == 0, proc.returncode, out + err,
71 stdout=out, stderr=err)
74def run_command(
75 cmd: str, *, cwd: str | None = None, timeout: int = DEFAULT_GATE_TIMEOUT_S, _run=subprocess.run
76) -> CommandResult:
77 """Run ``cmd`` in a shell, capturing output. Fail-soft on timeout/OS error."""
78 try:
79 # Intentional shell boundary: cmd must come only from operator-controlled
80 # project config or extension YAML, never from PR content or agent output.
81 proc = _run(cmd, shell=True, cwd=cwd, capture_output=True, text=True, timeout=timeout) # nosec B604
82 except subprocess.TimeoutExpired:
83 return CommandResult(False, 124, f"timed out after {timeout}s", timed_out=True)
84 except OSError as exc:
85 return CommandResult(False, 127, str(exc), stderr=str(exc))
86 return _result(proc)
89def run_argv(
90 argv: list[str], *, cwd: str | None = None, timeout: int = 120, _run=subprocess.run
91) -> CommandResult:
92 """Run an argv list (no shell). Fail-soft on timeout/OS error. Used by git/gh wrappers."""
93 try:
94 proc = _run(argv, cwd=cwd, capture_output=True, text=True, timeout=timeout)
95 except subprocess.TimeoutExpired:
96 return CommandResult(False, 124, f"timed out after {timeout}s", timed_out=True)
97 except OSError as exc:
98 return CommandResult(False, 127, str(exc), stderr=str(exc))
99 return _result(proc)
102def _tail(text: str, n: int = 20) -> str:
103 return "\n".join(text.strip().splitlines()[-n:])
106def command_gate_runner(
107 repo_root: str | None = None,
108 *,
109 timeout: int = DEFAULT_GATE_TIMEOUT_S,
110 _run=subprocess.run,
111) -> GateRunner:
112 """A :data:`keel.gates.GateRunner` that executes ``command`` gates via the shell.
114 Non-command gates (agentic / builtin like ``jury``) are not executed here — in
115 command-only mode they pass as no-ops; the agent-dispatch layer runs those.
117 ``timeout`` is the fallback wall-clock limit for a gate that carries none of its
118 own; a :attr:`~keel.gates.GateSpec.timeout` resolved by
119 :func:`~keel.gates.plan_gates` always wins. A gate killed by that limit is
120 reported as a **timeout** rather than a failure: it still blocks (``ok`` is
121 False and the severity is unchanged), but the message says the command never
122 produced a verdict instead of implying a test broke.
123 """
125 def runner(spec: GateSpec) -> tuple[bool, list[Finding], bool, bool]:
126 if spec.kind != "command" or not spec.run:
127 # Not executed here — the agent-dispatch layer runs agentic gates. Flagged
128 # `not_run` so this can never be recorded as "ran and passed"; `ok` stays
129 # True so a soft gate does not spuriously fail a command-only run.
130 return True, [], False, True
131 limit = timeout if spec.timeout is None else spec.timeout
132 result = run_command(spec.run, cwd=repo_root, timeout=limit, _run=_run)
133 if result.ok:
134 return True, [], False, False
135 severity = _ON_FAIL_SEVERITY[spec.on_fail]
136 if result.timed_out:
137 # No pass/fail verdict exists — do not dress the kill up as a test result.
138 message = (
139 f"{spec.id} timed out after {limit}s (exit {result.code}); "
140 "the command produced no pass/fail result. Raise the limit via "
141 "knobs.gate_timeout_s (or this gate's timeout:) if it legitimately "
142 "needs longer — a genuinely hanging command is still a defect."
143 )
144 return False, [Finding(severity, message, spec.id)], True, False
145 message = f"{spec.id} failed (exit {result.code})"
146 tail = _tail(result.output)
147 if tail:
148 message += f": {tail}"
149 path, line = first_location(result.output)
150 return False, [Finding(
151 severity, message, spec.id,
152 path=path, line=line, anchorable=path is not None and line is not None,
153 )], False, False
155 return runner