stargate-cli 1.0.0__py3-none-any.whl
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.
- stargate/__init__.py +0 -0
- stargate/__main__.py +3 -0
- stargate/agent.py +194 -0
- stargate/agents.yaml +148 -0
- stargate/cli.py +286 -0
- stargate/commit.py +253 -0
- stargate/config.py +422 -0
- stargate/core.py +341 -0
- stargate/detect.py +140 -0
- stargate/doctor.py +407 -0
- stargate/fanout.py +1152 -0
- stargate/prompts/architect.md +28 -0
- stargate/prompts/developer.md +27 -0
- stargate/prompts/fanout.md +44 -0
- stargate/prompts/fixer.md +35 -0
- stargate/prompts/reviewer.md +86 -0
- stargate/publish.py +105 -0
- stargate/run.py +847 -0
- stargate/source.py +66 -0
- stargate/stages.py +1131 -0
- stargate_cli-1.0.0.dist-info/METADATA +1427 -0
- stargate_cli-1.0.0.dist-info/RECORD +26 -0
- stargate_cli-1.0.0.dist-info/WHEEL +5 -0
- stargate_cli-1.0.0.dist-info/entry_points.txt +2 -0
- stargate_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
- stargate_cli-1.0.0.dist-info/top_level.txt +1 -0
stargate/core.py
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
"""Errors, the run's shared state, and the two things everything shells out to:
|
|
2
|
+
a subprocess runner that streams and stays killable, and git."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import shlex
|
|
9
|
+
import signal
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, TextIO
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class StargateError(RuntimeError):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Terminated(KeyboardInterrupt):
|
|
24
|
+
"""A terminating signal that follows the already-safe interrupt path."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, signum: int) -> None:
|
|
27
|
+
super().__init__(f"signal {signum} ({signal.Signals(signum).name})")
|
|
28
|
+
self.signum = signum
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class Detected:
|
|
33
|
+
command: str
|
|
34
|
+
source: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class RunContext:
|
|
39
|
+
repo: Path
|
|
40
|
+
config: dict[str, Any]
|
|
41
|
+
run_id: str
|
|
42
|
+
slug: str
|
|
43
|
+
branch: str
|
|
44
|
+
base_ref: str
|
|
45
|
+
base_commit: str
|
|
46
|
+
worktree: Path
|
|
47
|
+
artifacts: Path
|
|
48
|
+
task: str = ""
|
|
49
|
+
stage: str = "init"
|
|
50
|
+
done: set[str] = field(default_factory=set)
|
|
51
|
+
tokens_used: int = 0
|
|
52
|
+
tag: str = ""
|
|
53
|
+
named_by_user: bool = False
|
|
54
|
+
test_command: str = ""
|
|
55
|
+
test_source: str = ""
|
|
56
|
+
detected: list[Detected] = field(default_factory=list)
|
|
57
|
+
test_artifacts: set[str] = field(default_factory=set)
|
|
58
|
+
commit: str = ""
|
|
59
|
+
commit_error: str = ""
|
|
60
|
+
mode: str = "linear"
|
|
61
|
+
fanout: dict[str, Any] = field(default_factory=dict)
|
|
62
|
+
review: dict[str, Any] = field(default_factory=dict)
|
|
63
|
+
# `review` is a checkpoint: control state for re-entering an interrupted
|
|
64
|
+
# loop, cleared once the run finishes. `findings` is a report: it has to
|
|
65
|
+
# survive to the terminal state, so it cannot share that dict.
|
|
66
|
+
findings: list[dict[str, Any]] = field(default_factory=list)
|
|
67
|
+
# Provenance survives every stage and resume independently of the task text.
|
|
68
|
+
task_source: str = ""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# How often a running agent prints that it is still alive.
|
|
72
|
+
HEARTBEAT_SECONDS = 30
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# Cleanup must not turn a terminating signal into an indefinite wait.
|
|
76
|
+
KILL_GRACE_SECONDS = 10
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
_ACTIVE_PROCESS_LOCK = threading.RLock()
|
|
80
|
+
_ACTIVE_PROCESSES: set[subprocess.Popen[str]] = set()
|
|
81
|
+
_TERMINATION_REQUESTED = threading.Event()
|
|
82
|
+
_OUTPUT_LOCK = threading.RLock()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def print_output(
|
|
86
|
+
message: str,
|
|
87
|
+
*,
|
|
88
|
+
end: str = "\n",
|
|
89
|
+
file: TextIO | None = None,
|
|
90
|
+
) -> None:
|
|
91
|
+
"""Write one complete status message without another worker splicing it."""
|
|
92
|
+
stream = sys.stdout if file is None else file
|
|
93
|
+
with _OUTPUT_LOCK:
|
|
94
|
+
stream.write(message + end)
|
|
95
|
+
stream.flush()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _kill_process_group(proc: subprocess.Popen[str]) -> None:
|
|
99
|
+
"""Kill a tracked command and descendants that kept its process group."""
|
|
100
|
+
if proc.poll() is not None:
|
|
101
|
+
# Already reaped, so its PID is free for the kernel to reissue, and
|
|
102
|
+
# killpg would signal a whole unrelated group under that number.
|
|
103
|
+
return
|
|
104
|
+
try:
|
|
105
|
+
os.killpg(proc.pid, signal.SIGKILL)
|
|
106
|
+
except ProcessLookupError:
|
|
107
|
+
pass
|
|
108
|
+
except OSError:
|
|
109
|
+
# The Popen child remains the minimum cleanup guarantee if a platform
|
|
110
|
+
# cannot address the new session as a process group.
|
|
111
|
+
with contextlib.suppress(OSError):
|
|
112
|
+
proc.kill()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def terminate_active_processes() -> None:
|
|
116
|
+
"""Kill all tracked process groups before fan-out workers unwind."""
|
|
117
|
+
_TERMINATION_REQUESTED.set()
|
|
118
|
+
with _ACTIVE_PROCESS_LOCK:
|
|
119
|
+
processes = list(_ACTIVE_PROCESSES)
|
|
120
|
+
for proc in processes:
|
|
121
|
+
_kill_process_group(proc)
|
|
122
|
+
|
|
123
|
+
# Reap every direct child against one shared deadline. Waiting a full
|
|
124
|
+
# grace period for each concurrent worker would serialize shutdown.
|
|
125
|
+
deadline = time.monotonic() + KILL_GRACE_SECONDS
|
|
126
|
+
for proc in processes:
|
|
127
|
+
remaining = deadline - time.monotonic()
|
|
128
|
+
if remaining <= 0:
|
|
129
|
+
break
|
|
130
|
+
with contextlib.suppress(subprocess.TimeoutExpired, OSError):
|
|
131
|
+
proc.wait(timeout=remaining)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def termination_requested() -> bool:
|
|
135
|
+
return _TERMINATION_REQUESTED.is_set()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def wait_for_termination(timeout: float) -> bool:
|
|
139
|
+
"""Wait up to timeout seconds, returning early when shutdown starts."""
|
|
140
|
+
return _TERMINATION_REQUESTED.wait(timeout)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def run_process(
|
|
144
|
+
args: list[str],
|
|
145
|
+
cwd: Path,
|
|
146
|
+
*,
|
|
147
|
+
capture: bool = True,
|
|
148
|
+
check: bool = True,
|
|
149
|
+
timeout: float | None = None,
|
|
150
|
+
log_path: Path | None = None,
|
|
151
|
+
env: dict[str, str] | None = None,
|
|
152
|
+
timeout_is_error: bool = True,
|
|
153
|
+
output_label: str | None = None,
|
|
154
|
+
display_args: list[str] | None = None,
|
|
155
|
+
) -> subprocess.CompletedProcess[str]:
|
|
156
|
+
if termination_requested():
|
|
157
|
+
raise StargateError("Orchestrator is terminating.")
|
|
158
|
+
prefix = f"[{output_label}] " if output_label else ""
|
|
159
|
+
# Callers may hide credentials in diagnostics without changing execution.
|
|
160
|
+
command = shlex.join(args if display_args is None else display_args)
|
|
161
|
+
print_output(f"\n{prefix}$ {command}")
|
|
162
|
+
if log_path is not None:
|
|
163
|
+
# Straight to disk, so a silent multi-minute agent can be tailed live
|
|
164
|
+
# instead of surfacing only once the process exits.
|
|
165
|
+
with log_path.open("w") as handle:
|
|
166
|
+
proc: subprocess.Popen[str] | None = None
|
|
167
|
+
try:
|
|
168
|
+
proc = subprocess.Popen(
|
|
169
|
+
args,
|
|
170
|
+
cwd=str(cwd),
|
|
171
|
+
text=True,
|
|
172
|
+
stdout=handle,
|
|
173
|
+
stderr=subprocess.STDOUT,
|
|
174
|
+
stdin=subprocess.DEVNULL,
|
|
175
|
+
env=env,
|
|
176
|
+
start_new_session=True,
|
|
177
|
+
)
|
|
178
|
+
with _ACTIVE_PROCESS_LOCK:
|
|
179
|
+
_ACTIVE_PROCESSES.add(proc)
|
|
180
|
+
if termination_requested():
|
|
181
|
+
_kill_process_group(proc)
|
|
182
|
+
started = time.monotonic()
|
|
183
|
+
deadline = None if timeout is None else started + timeout
|
|
184
|
+
timed_out = False
|
|
185
|
+
while True:
|
|
186
|
+
# Clamped to what is left of the deadline, not a fixed
|
|
187
|
+
# heartbeat: waiting the full interval means a timeout
|
|
188
|
+
# shorter than one heartbeat is not enforced until one has
|
|
189
|
+
# passed, and a process that exits inside that window
|
|
190
|
+
# breaks out before the deadline is ever consulted.
|
|
191
|
+
remaining = (
|
|
192
|
+
HEARTBEAT_SECONDS if deadline is None
|
|
193
|
+
else max(0.0, min(HEARTBEAT_SECONDS, deadline - time.monotonic()))
|
|
194
|
+
)
|
|
195
|
+
try:
|
|
196
|
+
proc.wait(timeout=remaining)
|
|
197
|
+
break
|
|
198
|
+
except subprocess.TimeoutExpired:
|
|
199
|
+
pass
|
|
200
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
201
|
+
_kill_process_group(proc)
|
|
202
|
+
proc.wait()
|
|
203
|
+
if timeout_is_error:
|
|
204
|
+
raise StargateError(
|
|
205
|
+
f"Command timed out after {timeout}s "
|
|
206
|
+
f"(partial trace in {log_path}): {command}"
|
|
207
|
+
)
|
|
208
|
+
timed_out = True
|
|
209
|
+
break
|
|
210
|
+
# Growing byte count is the "still moving, not hung" signal;
|
|
211
|
+
# the trace itself stays out of the terminal.
|
|
212
|
+
size = log_path.stat().st_size if log_path.exists() else 0
|
|
213
|
+
elapsed = time.monotonic() - started
|
|
214
|
+
print_output(
|
|
215
|
+
f"{prefix} ... {elapsed:.0f}s elapsed, "
|
|
216
|
+
f"{size:,} bytes written"
|
|
217
|
+
)
|
|
218
|
+
except BaseException:
|
|
219
|
+
# A signal reaches the orchestrator, not necessarily the agent.
|
|
220
|
+
# Leaving it alive would let it keep editing during a resume.
|
|
221
|
+
if proc is not None:
|
|
222
|
+
_kill_process_group(proc)
|
|
223
|
+
# The signal handler already reaps all active children
|
|
224
|
+
# against one shared deadline. Keep standalone interrupt
|
|
225
|
+
# cleanup bounded when no orchestrator signal was handled.
|
|
226
|
+
if not termination_requested():
|
|
227
|
+
with contextlib.suppress(
|
|
228
|
+
subprocess.TimeoutExpired, OSError
|
|
229
|
+
):
|
|
230
|
+
proc.wait(timeout=KILL_GRACE_SECONDS)
|
|
231
|
+
raise
|
|
232
|
+
finally:
|
|
233
|
+
if proc is not None:
|
|
234
|
+
with _ACTIVE_PROCESS_LOCK:
|
|
235
|
+
_ACTIVE_PROCESSES.discard(proc)
|
|
236
|
+
if proc is None: # pragma: no cover - Popen either returns or raises
|
|
237
|
+
raise StargateError(f"Could not start command: {command}")
|
|
238
|
+
output = log_path.read_text() if log_path.exists() else ""
|
|
239
|
+
returncode = 124 if timed_out else proc.returncode
|
|
240
|
+
if check and returncode != 0:
|
|
241
|
+
raise StargateError(
|
|
242
|
+
f"Command failed with exit code {returncode} "
|
|
243
|
+
f"(trace in {log_path}): {command}"
|
|
244
|
+
)
|
|
245
|
+
return subprocess.CompletedProcess(args, returncode, output, None)
|
|
246
|
+
try:
|
|
247
|
+
proc = subprocess.run(
|
|
248
|
+
args,
|
|
249
|
+
cwd=str(cwd),
|
|
250
|
+
text=True,
|
|
251
|
+
stdout=subprocess.PIPE if capture else None,
|
|
252
|
+
stderr=subprocess.STDOUT if capture else None,
|
|
253
|
+
stdin=subprocess.DEVNULL,
|
|
254
|
+
timeout=timeout,
|
|
255
|
+
env=env,
|
|
256
|
+
)
|
|
257
|
+
except subprocess.TimeoutExpired as exc:
|
|
258
|
+
raise StargateError(
|
|
259
|
+
f"Command timed out after {timeout}s: {command}"
|
|
260
|
+
) from exc
|
|
261
|
+
if capture and proc.stdout:
|
|
262
|
+
print_output(proc.stdout, end="" if proc.stdout.endswith("\n") else "\n")
|
|
263
|
+
if check and proc.returncode != 0:
|
|
264
|
+
raise StargateError(
|
|
265
|
+
f"Command failed with exit code {proc.returncode}: {command}"
|
|
266
|
+
)
|
|
267
|
+
return proc
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def git(repo: Path, *args: str, capture: bool = True, check: bool = True):
|
|
271
|
+
return run_process(["git", *args], repo, capture=capture, check=check)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def repo_root(start: Path) -> Path:
|
|
275
|
+
proc = subprocess.run(
|
|
276
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
277
|
+
cwd=str(start),
|
|
278
|
+
text=True,
|
|
279
|
+
capture_output=True,
|
|
280
|
+
)
|
|
281
|
+
if proc.returncode != 0:
|
|
282
|
+
raise StargateError("Current directory is not inside a Git repository.")
|
|
283
|
+
return Path(proc.stdout.strip()).resolve()
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def slugify(text: str, max_len: int = 42) -> str:
|
|
287
|
+
text = re.sub(r"[^a-zA-Z0-9]+", "-", text).strip("-").lower()
|
|
288
|
+
return (text or "task")[:max_len].rstrip("-")
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
BRANCH_NAME_WORDS = 5
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
BRANCH_NAME_CHARS = 32
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def short_name(text: str) -> str:
|
|
298
|
+
"""Return a short slug made only from whole words."""
|
|
299
|
+
words = [word.lower() for word in re.findall(r"[a-zA-Z0-9]+", text)]
|
|
300
|
+
kept: list[str] = []
|
|
301
|
+
for word in words[:BRANCH_NAME_WORDS]:
|
|
302
|
+
candidate = "-".join([*kept, word])
|
|
303
|
+
if len(candidate) > BRANCH_NAME_CHARS:
|
|
304
|
+
break
|
|
305
|
+
kept.append(word)
|
|
306
|
+
return "-".join(kept)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
NAME_PREFIX = "NAME:"
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def split_plan_name(plan: str) -> tuple[str, str]:
|
|
313
|
+
"""Extract an optional first-line name without damaging older plans.
|
|
314
|
+
|
|
315
|
+
Only a valid name on the first non-empty line is removed. Custom and frozen
|
|
316
|
+
prompts predate this contract, and an ignored or malformed instruction must
|
|
317
|
+
keep flowing verbatim instead of turning a compatible run into a failure.
|
|
318
|
+
"""
|
|
319
|
+
lines = plan.splitlines()
|
|
320
|
+
first = next((index for index, line in enumerate(lines) if line.strip()), None)
|
|
321
|
+
if first is None:
|
|
322
|
+
return "", plan
|
|
323
|
+
line = lines[first].strip()
|
|
324
|
+
if not line.startswith(NAME_PREFIX):
|
|
325
|
+
return "", plan
|
|
326
|
+
name = short_name(line[len(NAME_PREFIX):].strip())
|
|
327
|
+
if not name:
|
|
328
|
+
return "", plan
|
|
329
|
+
return name, "\n".join([*lines[:first], *lines[first + 1:]]).strip()
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def git_quiet(repo: Path, *args: str) -> str:
|
|
333
|
+
"""Read Git state without burying run output under a full printed diff."""
|
|
334
|
+
proc = subprocess.run(
|
|
335
|
+
["git", *args], cwd=str(repo), text=True, capture_output=True
|
|
336
|
+
)
|
|
337
|
+
if proc.returncode:
|
|
338
|
+
raise StargateError(
|
|
339
|
+
f"git {' '.join(args)} failed in {repo}: {proc.stderr.strip()}"
|
|
340
|
+
)
|
|
341
|
+
return proc.stdout
|
stargate/detect.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Guessing the project's test command from what is checked in, and deciding
|
|
2
|
+
whether a guess may run."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .core import Detected, StargateError
|
|
12
|
+
|
|
13
|
+
DETECTION_MODES = ("report", "auto", "off")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
DETECTION_READ_LIMIT = 65536
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# A detected command is input the user never wrote. It may run unattended after
|
|
20
|
+
# every fixer, influence the review verdict and set the process exit code; a bad
|
|
21
|
+
# guess can also be a deploy-ish Make target or a watcher that only ends at the
|
|
22
|
+
# timeout. The default therefore fixes the dangerous silence by reporting the
|
|
23
|
+
# guess and its evidence, without executing it. Users who accept that risk can
|
|
24
|
+
# opt into `auto`; `off` records that the repository intentionally has no test.
|
|
25
|
+
def detection_mode(config: dict[str, Any]) -> str:
|
|
26
|
+
settings = config.get("settings", {})
|
|
27
|
+
mode = str(settings.get("test_command_detection", "report") or "report")
|
|
28
|
+
mode = mode.strip().lower()
|
|
29
|
+
if mode not in DETECTION_MODES:
|
|
30
|
+
raise StargateError(
|
|
31
|
+
"settings.test_command_detection must be one of report, auto, off"
|
|
32
|
+
)
|
|
33
|
+
return mode
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def read_detection_file(path: Path) -> str | None:
|
|
37
|
+
"""Read enough evidence for detection without trusting project file size."""
|
|
38
|
+
try:
|
|
39
|
+
with path.open(encoding="utf-8") as handle:
|
|
40
|
+
return handle.read(DETECTION_READ_LIMIT)
|
|
41
|
+
except (OSError, UnicodeError, ValueError):
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def is_file(path: Path) -> bool:
|
|
46
|
+
with contextlib.suppress(OSError):
|
|
47
|
+
return path.is_file()
|
|
48
|
+
return False
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def detect_test_commands(root: Path) -> list[Detected]:
|
|
52
|
+
"""Return all likely commands in a deliberate, user-visible priority."""
|
|
53
|
+
detected: list[Detected] = []
|
|
54
|
+
|
|
55
|
+
# A hand-written project entry point commonly wraps the native runner, so
|
|
56
|
+
# it outranks language metadata. All lower-ranked matches are still shown.
|
|
57
|
+
for name in ("Makefile", "makefile", "GNUmakefile"):
|
|
58
|
+
text = read_detection_file(root / name)
|
|
59
|
+
if text is not None and re.search(r"(?m)^test\s*:(?!=)", text):
|
|
60
|
+
detected.append(Detected("make test", f"{name}: test target"))
|
|
61
|
+
break
|
|
62
|
+
|
|
63
|
+
package = read_detection_file(root / "package.json")
|
|
64
|
+
if package is not None:
|
|
65
|
+
with contextlib.suppress(json.JSONDecodeError, TypeError, AttributeError):
|
|
66
|
+
script = json.loads(package).get("scripts", {}).get("test", "")
|
|
67
|
+
script = script.strip() if isinstance(script, str) else ""
|
|
68
|
+
if script and "error: no test specified" not in script.lower():
|
|
69
|
+
runner = (
|
|
70
|
+
"pnpm" if is_file(root / "pnpm-lock.yaml")
|
|
71
|
+
else "yarn" if is_file(root / "yarn.lock")
|
|
72
|
+
else "npm"
|
|
73
|
+
)
|
|
74
|
+
detected.append(Detected(
|
|
75
|
+
f"{runner} test", "package.json: scripts.test"
|
|
76
|
+
))
|
|
77
|
+
|
|
78
|
+
for filename, command in (
|
|
79
|
+
("Cargo.toml", "cargo test"),
|
|
80
|
+
("go.mod", "go test ./..."),
|
|
81
|
+
("Package.swift", "swift test"),
|
|
82
|
+
):
|
|
83
|
+
if is_file(root / filename):
|
|
84
|
+
detected.append(Detected(command, filename))
|
|
85
|
+
|
|
86
|
+
pytest_source = ""
|
|
87
|
+
pyproject = read_detection_file(root / "pyproject.toml")
|
|
88
|
+
if pyproject is not None:
|
|
89
|
+
if re.search(r"(?im)^\s*\[tool\.pytest(?:\.|\])", pyproject):
|
|
90
|
+
pytest_source = "pyproject.toml: tool.pytest"
|
|
91
|
+
elif (
|
|
92
|
+
re.search(r"(?im)^\s*pytest(?:[-_.][\w.-]+)?\s*=", pyproject)
|
|
93
|
+
or re.search(
|
|
94
|
+
r'''(?i)["']pytest(?:[-_.][\w.-]+)?(?:[<>=~!][^"']*)?["']''',
|
|
95
|
+
pyproject,
|
|
96
|
+
)
|
|
97
|
+
):
|
|
98
|
+
pytest_source = "pyproject.toml: pytest dependency"
|
|
99
|
+
if not pytest_source and is_file(root / "pytest.ini"):
|
|
100
|
+
pytest_source = "pytest.ini"
|
|
101
|
+
if not pytest_source:
|
|
102
|
+
for filename in ("setup.cfg", "tox.ini"):
|
|
103
|
+
text = read_detection_file(root / filename)
|
|
104
|
+
if text is not None and re.search(r"(?im)^\s*\[tool:pytest\]", text):
|
|
105
|
+
pytest_source = f"{filename}: tool:pytest"
|
|
106
|
+
break
|
|
107
|
+
if not pytest_source:
|
|
108
|
+
tests = root / "tests"
|
|
109
|
+
with contextlib.suppress(OSError):
|
|
110
|
+
if tests.is_dir() and any(path.is_file() for path in tests.rglob("test_*.py")):
|
|
111
|
+
pytest_source = "tests/: test_*.py"
|
|
112
|
+
# A root-level test_*.py is not enough evidence: this repository's smoke
|
|
113
|
+
# test takes a custom argument and pytest collection would fail. That false
|
|
114
|
+
# positive is exactly why detection reports rather than runs by default.
|
|
115
|
+
if pytest_source:
|
|
116
|
+
detected.append(Detected("pytest -q", pytest_source))
|
|
117
|
+
|
|
118
|
+
return detected
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def selected_test_command(
|
|
122
|
+
config: dict[str, Any], root: Path
|
|
123
|
+
) -> tuple[str, list[Detected]]:
|
|
124
|
+
"""The command stargate would run, plus every detected candidate.
|
|
125
|
+
|
|
126
|
+
Report-only detection intentionally grants nothing: its candidate is input
|
|
127
|
+
the user has not approved, so letting an agent execute it would defeat the
|
|
128
|
+
mode even if the orchestrator itself abstained.
|
|
129
|
+
"""
|
|
130
|
+
settings = config.get("settings", {})
|
|
131
|
+
configured = str(settings.get("test_command", "") or "").strip()
|
|
132
|
+
if configured:
|
|
133
|
+
return configured, []
|
|
134
|
+
mode = detection_mode(config)
|
|
135
|
+
if mode == "off":
|
|
136
|
+
return "", []
|
|
137
|
+
detected = detect_test_commands(root)
|
|
138
|
+
if mode == "auto" and detected:
|
|
139
|
+
return detected[0].command, detected
|
|
140
|
+
return "", detected
|