qaas-python 0.1.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.
- qaas/adapters/__init__.py +19 -0
- qaas/adapters/tracker.py +1350 -0
- qaas/adapters/vcs.py +494 -0
- qaas/cli.py +1564 -0
- qaas/conductor.py +527 -0
- qaas/config.py +407 -0
- qaas/defaults/config/agents/arbiter.yaml +19 -0
- qaas/defaults/config/agents/cartographer.yaml +20 -0
- qaas/defaults/config/agents/clerk.yaml +21 -0
- qaas/defaults/config/agents/conduit.yaml +19 -0
- qaas/defaults/config/agents/forge.yaml +22 -0
- qaas/defaults/config/agents/mender.yaml +56 -0
- qaas/defaults/config/agents/proof.yaml +21 -0
- qaas/defaults/config/agents/surface.yaml +16 -0
- qaas/defaults/config/system.yaml +69 -0
- qaas/discover.py +227 -0
- qaas/envelope.py +290 -0
- qaas/guardrails.py +431 -0
- qaas/mcp/__init__.py +0 -0
- qaas/mcp/context.py +70 -0
- qaas/mcp/contract_diff.py +937 -0
- qaas/mcp/defect_memory.py +495 -0
- qaas/mcp/env_control.py +905 -0
- qaas/mcp/envelope_server.py +463 -0
- qaas/mcp/test_runner.py +773 -0
- qaas/mcp/tracker.py +412 -0
- qaas/mcp/vcs.py +506 -0
- qaas/paths.py +317 -0
- qaas/plugin/.claude-plugin/plugin.json +9 -0
- qaas/plugin/skills/a11y-audit/SKILL.md +34 -0
- qaas/plugin/skills/adversarial-review/SKILL.md +120 -0
- qaas/plugin/skills/api-surface-extraction/SKILL.md +38 -0
- qaas/plugin/skills/authz-matrix-check/SKILL.md +46 -0
- qaas/plugin/skills/console-error-triage/SKILL.md +39 -0
- qaas/plugin/skills/contract-test-generation/SKILL.md +36 -0
- qaas/plugin/skills/dedupe-strategy/SKILL.md +39 -0
- qaas/plugin/skills/environment-pinning/SKILL.md +35 -0
- qaas/plugin/skills/error-taxonomy/SKILL.md +42 -0
- qaas/plugin/skills/exploratory-ui-walk/SKILL.md +46 -0
- qaas/plugin/skills/failing-test-authoring/SKILL.md +47 -0
- qaas/plugin/skills/flake-detection/SKILL.md +39 -0
- qaas/plugin/skills/form-state-probe/SKILL.md +36 -0
- qaas/plugin/skills/minimal-diff-discipline/SKILL.md +70 -0
- qaas/plugin/skills/openapi-diff/SKILL.md +45 -0
- qaas/plugin/skills/ownership-resolution/SKILL.md +31 -0
- qaas/plugin/skills/product-task-graph/SKILL.md +35 -0
- qaas/plugin/skills/regression-risk-scoring/SKILL.md +59 -0
- qaas/plugin/skills/regression-suite-selection/SKILL.md +36 -0
- qaas/plugin/skills/repo-cartography/SKILL.md +38 -0
- qaas/plugin/skills/repro-minimisation/SKILL.md +41 -0
- qaas/plugin/skills/rollback-plan-authoring/SKILL.md +81 -0
- qaas/plugin/skills/root-cause-vs-symptom/SKILL.md +67 -0
- qaas/plugin/skills/routing-rules/SKILL.md +34 -0
- qaas/plugin/skills/severity-rubric/SKILL.md +42 -0
- qaas/plugin/skills/test-first-fix/SKILL.md +66 -0
- qaas/plugin/skills/test-quality-audit/SKILL.md +58 -0
- qaas/plugin/skills/ticket-writer/SKILL.md +40 -0
- qaas/plugin/skills/verdict-reporting/SKILL.md +35 -0
- qaas/plugin/skills/verification-protocol/SKILL.md +39 -0
- qaas/prompts/ARBITER.md +53 -0
- qaas/prompts/CARTOGRAPHER.md +46 -0
- qaas/prompts/CLERK.md +45 -0
- qaas/prompts/CONDUIT.md +44 -0
- qaas/prompts/FORGE.md +43 -0
- qaas/prompts/MENDER.md +55 -0
- qaas/prompts/PROOF.md +41 -0
- qaas/prompts/SURFACE.md +46 -0
- qaas/prompts/_shared.md +45 -0
- qaas/registry.py +465 -0
- qaas/runner.py +192 -0
- qaas/scorecard.py +425 -0
- qaas/sdk_compat.py +52 -0
- qaas/store.py +290 -0
- qaas/target.py +261 -0
- qaas/tasks.py +361 -0
- qaas/trace.py +270 -0
- qaas_python-0.1.0.dist-info/METADATA +388 -0
- qaas_python-0.1.0.dist-info/RECORD +81 -0
- qaas_python-0.1.0.dist-info/WHEEL +4 -0
- qaas_python-0.1.0.dist-info/entry_points.txt +2 -0
- qaas_python-0.1.0.dist-info/licenses/LICENSE +21 -0
qaas/mcp/test_runner.py
ADDED
|
@@ -0,0 +1,773 @@
|
|
|
1
|
+
"""The `test_runner` MCP server — structured outcomes, never scraped CLI text.
|
|
2
|
+
|
|
3
|
+
An agent that reads pytest's terminal output makes two kinds of mistake: it
|
|
4
|
+
misreads a summary line, and it argues with itself about what "2 failed, 1
|
|
5
|
+
passed" implies for the one test it cares about. Both disappear if the tool
|
|
6
|
+
returns a list of `{nodeid, outcome, duration_s, message}` and the agent reads a
|
|
7
|
+
field. §5.2 asks for this server for exactly that reason.
|
|
8
|
+
|
|
9
|
+
Parsing strategy, in order of preference:
|
|
10
|
+
|
|
11
|
+
* `pytest-json-report` if it is installed. Exact durations, exact longrepr.
|
|
12
|
+
* The terminal output otherwise, run with `-v -rfE --durations=0` so the
|
|
13
|
+
facts we need are on lines with a stable shape. Robust beats clever here:
|
|
14
|
+
outcomes come from the per-test progress lines, failure messages from the
|
|
15
|
+
short summary, durations from the durations table, and anything unparseable
|
|
16
|
+
degrades to a total from the exit code rather than to a wrong answer.
|
|
17
|
+
|
|
18
|
+
Every subprocess is argv-only and time-boxed. A caller never supplies a command
|
|
19
|
+
string, and a run that exceeds its timeout returns what it managed to collect,
|
|
20
|
+
flagged, instead of hanging the agent's turn.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import asyncio
|
|
26
|
+
import importlib.util
|
|
27
|
+
import json
|
|
28
|
+
import os
|
|
29
|
+
import re
|
|
30
|
+
import subprocess
|
|
31
|
+
import sys
|
|
32
|
+
import tempfile
|
|
33
|
+
import time
|
|
34
|
+
from collections import Counter
|
|
35
|
+
from dataclasses import dataclass
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
from typing import Any
|
|
38
|
+
|
|
39
|
+
from claude_agent_sdk import create_sdk_mcp_server, tool
|
|
40
|
+
|
|
41
|
+
from qaas.mcp.context import ToolContext, err, ok
|
|
42
|
+
|
|
43
|
+
DEFAULT_TIMEOUT_S = 300
|
|
44
|
+
MAX_TIMEOUT_S = 900
|
|
45
|
+
|
|
46
|
+
# §10 says flake rate is measured, not guessed, but a 200-run loop is a budget
|
|
47
|
+
# incident. Twenty runs already resolves a 5%-flaky test most of the time.
|
|
48
|
+
MAX_FLAKE_RUNS = 20
|
|
49
|
+
|
|
50
|
+
# How much raw output to hand back when parsing found nothing useful. Enough to
|
|
51
|
+
# diagnose a collection error, not enough to flood the agent's context.
|
|
52
|
+
OUTPUT_TAIL = 6_000
|
|
53
|
+
|
|
54
|
+
# pytest exit codes we treat specially (see pytest.ExitCode).
|
|
55
|
+
_EXIT_NO_TESTS = 5
|
|
56
|
+
_EXIT_USAGE_ERROR = 4
|
|
57
|
+
|
|
58
|
+
_OUTCOMES = {
|
|
59
|
+
"PASSED": "passed",
|
|
60
|
+
"FAILED": "failed",
|
|
61
|
+
"ERROR": "error",
|
|
62
|
+
"SKIPPED": "skipped",
|
|
63
|
+
"XFAIL": "xfailed",
|
|
64
|
+
"XPASS": "xpassed",
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
# `tests/test_a.py::test_x PASSED [ 50%]` — the -v progress line.
|
|
68
|
+
_PROGRESS_RE = re.compile(
|
|
69
|
+
r"^(?P<nodeid>\S+)\s+(?P<outcome>PASSED|FAILED|ERROR|SKIPPED|XFAIL|XPASS)\b"
|
|
70
|
+
)
|
|
71
|
+
# `FAILED tests/test_a.py::test_x - AssertionError: ...` — the -rfE summary line.
|
|
72
|
+
_SUMMARY_RE = re.compile(
|
|
73
|
+
r"^(?P<outcome>FAILED|ERROR)\s+(?P<nodeid>\S+)(?:\s+-\s+(?P<message>.*))?$"
|
|
74
|
+
)
|
|
75
|
+
# `0.01s call tests/test_a.py::test_x` — the --durations table.
|
|
76
|
+
_DURATION_RE = re.compile(r"^(?P<seconds>\d+\.\d+)s\s+(?:call|setup|teardown)\s+(?P<nodeid>\S+)$")
|
|
77
|
+
|
|
78
|
+
# pytest reports an unknown nodeid as a usage error, not as "no tests ran", so
|
|
79
|
+
# the two have to be told apart by the message rather than by the exit code.
|
|
80
|
+
_NOT_FOUND_RE = re.compile(r"^ERROR: not found: ", re.MULTILINE)
|
|
81
|
+
_UNKNOWN_OPTION_RE = re.compile(r"unrecognized (arguments|option)", re.IGNORECASE)
|
|
82
|
+
|
|
83
|
+
_FAILURE_HEADER_RE = re.compile(r"^=+ (FAILURES|ERRORS) =+$", re.MULTILINE)
|
|
84
|
+
_SUMMARY_HEADER_RE = re.compile(r"^=+ (short test summary info|warnings summary) =+", re.MULTILINE)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ---------------------------------------------------------------------------
|
|
88
|
+
# subprocess plumbing
|
|
89
|
+
# ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass
|
|
93
|
+
class _Completed:
|
|
94
|
+
argv: list[str]
|
|
95
|
+
returncode: int
|
|
96
|
+
stdout: str
|
|
97
|
+
stderr: str
|
|
98
|
+
duration_s: float
|
|
99
|
+
timed_out: bool
|
|
100
|
+
started: bool = True
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _decode(raw: str | bytes | None) -> str:
|
|
104
|
+
if raw is None:
|
|
105
|
+
return ""
|
|
106
|
+
return raw if isinstance(raw, str) else raw.decode(errors="replace")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _run(argv: list[str], cwd: Path, timeout_s: int, env_extra: dict[str, str] | None = None) -> _Completed:
|
|
110
|
+
"""Run argv under a hard timeout, returning partial output if it expires.
|
|
111
|
+
|
|
112
|
+
`subprocess.run` kills the child and hands the partial streams back on the
|
|
113
|
+
exception, which is the difference between "the suite hung, here is how far
|
|
114
|
+
it got" and an agent staring at nothing.
|
|
115
|
+
"""
|
|
116
|
+
env = dict(os.environ, **(env_extra or {}))
|
|
117
|
+
# A wide terminal keeps pytest from wrapping nodeids across lines, which is
|
|
118
|
+
# the one thing that would break the progress-line parser.
|
|
119
|
+
env["COLUMNS"] = "250"
|
|
120
|
+
# The parent process is usually pytest itself (this server is exercised from
|
|
121
|
+
# a test suite); its addopts must not leak into the child's run.
|
|
122
|
+
env.pop("PYTEST_ADDOPTS", None)
|
|
123
|
+
env.pop("PYTEST_CURRENT_TEST", None)
|
|
124
|
+
|
|
125
|
+
started = time.monotonic()
|
|
126
|
+
try:
|
|
127
|
+
proc = subprocess.run(
|
|
128
|
+
argv, cwd=cwd, env=env, capture_output=True, text=True, timeout=timeout_s, check=False
|
|
129
|
+
)
|
|
130
|
+
except subprocess.TimeoutExpired as exc:
|
|
131
|
+
return _Completed(
|
|
132
|
+
argv, -1, _decode(exc.stdout), _decode(exc.stderr), time.monotonic() - started, True
|
|
133
|
+
)
|
|
134
|
+
except OSError as exc:
|
|
135
|
+
return _Completed(argv, -1, "", f"could not start {argv[0]}: {exc}", 0.0, False, started=False)
|
|
136
|
+
return _Completed(
|
|
137
|
+
argv, proc.returncode, proc.stdout, proc.stderr, time.monotonic() - started, False
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
async def _run_async(argv: list[str], cwd: Path, timeout_s: int, env_extra: dict[str, str] | None = None) -> _Completed:
|
|
142
|
+
"""Off the event loop: a 300s suite must not block the other tools."""
|
|
143
|
+
return await asyncio.to_thread(_run, argv, cwd, timeout_s, env_extra)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _resolve_cwd(ctx: ToolContext, raw: str | None) -> tuple[Path | None, str | None]:
|
|
147
|
+
"""Working directory for a run: the repo root, or a directory inside it.
|
|
148
|
+
|
|
149
|
+
Resolved before the containment check so `..` and symlinks cannot walk out
|
|
150
|
+
of the checkout the run is supposed to be confined to.
|
|
151
|
+
"""
|
|
152
|
+
root = ctx.target_root.resolve()
|
|
153
|
+
if not raw:
|
|
154
|
+
return root, None
|
|
155
|
+
candidate = Path(raw)
|
|
156
|
+
resolved = (candidate if candidate.is_absolute() else root / candidate).resolve()
|
|
157
|
+
if resolved != root and not resolved.is_relative_to(root):
|
|
158
|
+
return None, f"cwd '{raw}' resolves to {resolved}, outside the repository ({root})."
|
|
159
|
+
if not resolved.is_dir():
|
|
160
|
+
return None, f"cwd '{raw}' is not a directory."
|
|
161
|
+
return resolved, None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _timeout(args: dict[str, Any]) -> tuple[int, str | None]:
|
|
165
|
+
raw = args.get("timeout_s")
|
|
166
|
+
if raw is None:
|
|
167
|
+
return DEFAULT_TIMEOUT_S, None
|
|
168
|
+
try:
|
|
169
|
+
value = int(raw)
|
|
170
|
+
except (TypeError, ValueError):
|
|
171
|
+
return DEFAULT_TIMEOUT_S, f"timeout_s must be a number, got {raw!r}."
|
|
172
|
+
if value < 1:
|
|
173
|
+
return DEFAULT_TIMEOUT_S, "timeout_s must be at least 1 second."
|
|
174
|
+
if value > MAX_TIMEOUT_S:
|
|
175
|
+
return DEFAULT_TIMEOUT_S, (
|
|
176
|
+
f"timeout_s {value} exceeds the {MAX_TIMEOUT_S}s cap. A test that needs "
|
|
177
|
+
"longer than fifteen minutes is a finding in itself, not a longer wait."
|
|
178
|
+
)
|
|
179
|
+
return value, None
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# ---------------------------------------------------------------------------
|
|
183
|
+
# pytest invocation and parsing
|
|
184
|
+
# ---------------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _json_report_available() -> bool:
|
|
188
|
+
"""Whether the child interpreter (which is this one) has the plugin."""
|
|
189
|
+
return importlib.util.find_spec("pytest_jsonreport") is not None
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _base_argv(selectors: list[str], json_report_path: Path | None) -> list[str]:
|
|
193
|
+
argv = [sys.executable, "-m", "pytest", "-p", "no:cacheprovider", "--tb=short"]
|
|
194
|
+
if json_report_path is not None:
|
|
195
|
+
argv += ["--json-report", f"--json-report-file={json_report_path}", "-q"]
|
|
196
|
+
else:
|
|
197
|
+
argv += ["-v", "-rfE", "--durations=0", "--durations-min=0"]
|
|
198
|
+
return argv + selectors
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _parse_json_report(path: Path) -> list[dict[str, Any]] | None:
|
|
202
|
+
"""Per-test rows from pytest-json-report, or None if it wrote nothing usable."""
|
|
203
|
+
try:
|
|
204
|
+
report = json.loads(path.read_text())
|
|
205
|
+
except (OSError, ValueError):
|
|
206
|
+
return None
|
|
207
|
+
raw_tests = report.get("tests")
|
|
208
|
+
if not isinstance(raw_tests, list):
|
|
209
|
+
return None
|
|
210
|
+
|
|
211
|
+
rows: list[dict[str, Any]] = []
|
|
212
|
+
for entry in raw_tests:
|
|
213
|
+
phases = [entry.get(p) for p in ("setup", "call", "teardown")]
|
|
214
|
+
duration = sum(p.get("duration", 0.0) for p in phases if isinstance(p, dict))
|
|
215
|
+
message = None
|
|
216
|
+
for phase in phases:
|
|
217
|
+
if isinstance(phase, dict) and phase.get("outcome") in {"failed", "error"}:
|
|
218
|
+
message = _shorten(_stringify_longrepr(phase.get("longrepr")))
|
|
219
|
+
break
|
|
220
|
+
rows.append(
|
|
221
|
+
{
|
|
222
|
+
"nodeid": entry.get("nodeid", "?"),
|
|
223
|
+
"outcome": entry.get("outcome", "unknown"),
|
|
224
|
+
"duration_s": round(duration, 4),
|
|
225
|
+
"message": message,
|
|
226
|
+
}
|
|
227
|
+
)
|
|
228
|
+
return rows
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _stringify_longrepr(longrepr: Any) -> str:
|
|
232
|
+
"""pytest-json-report emits a string, or a dict when tracebacks are structured."""
|
|
233
|
+
if isinstance(longrepr, str):
|
|
234
|
+
return longrepr
|
|
235
|
+
if isinstance(longrepr, dict):
|
|
236
|
+
crash = longrepr.get("crash")
|
|
237
|
+
if isinstance(crash, dict) and crash.get("message"):
|
|
238
|
+
return str(crash["message"])
|
|
239
|
+
return json.dumps(longrepr)[:2_000]
|
|
240
|
+
return "" if longrepr is None else str(longrepr)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _shorten(text: str, limit: int = 1_200) -> str | None:
|
|
244
|
+
text = (text or "").strip()
|
|
245
|
+
if not text:
|
|
246
|
+
return None
|
|
247
|
+
return text if len(text) <= limit else text[:limit] + "\n… (truncated)"
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _parse_terminal(stdout: str) -> list[dict[str, Any]]:
|
|
251
|
+
"""Per-test rows from pytest's terminal output.
|
|
252
|
+
|
|
253
|
+
Three independent line shapes, merged: outcomes from the progress lines
|
|
254
|
+
(the only source that names every test, skips included), messages from the
|
|
255
|
+
short summary, durations from the durations table. Any one of them missing
|
|
256
|
+
degrades a field, not the whole result.
|
|
257
|
+
"""
|
|
258
|
+
outcomes: dict[str, str] = {}
|
|
259
|
+
order: list[str] = []
|
|
260
|
+
messages: dict[str, str] = {}
|
|
261
|
+
durations: dict[str, float] = {}
|
|
262
|
+
|
|
263
|
+
for line in stdout.splitlines():
|
|
264
|
+
line = line.rstrip()
|
|
265
|
+
summary = _SUMMARY_RE.match(line)
|
|
266
|
+
if summary:
|
|
267
|
+
nodeid = summary.group("nodeid").rstrip(":")
|
|
268
|
+
message = (summary.group("message") or "").strip()
|
|
269
|
+
if message:
|
|
270
|
+
messages[nodeid] = message
|
|
271
|
+
outcomes.setdefault(nodeid, _OUTCOMES[summary.group("outcome")])
|
|
272
|
+
if nodeid not in order:
|
|
273
|
+
order.append(nodeid)
|
|
274
|
+
continue
|
|
275
|
+
|
|
276
|
+
progress = _PROGRESS_RE.match(line)
|
|
277
|
+
if progress:
|
|
278
|
+
nodeid = progress.group("nodeid")
|
|
279
|
+
if nodeid not in outcomes:
|
|
280
|
+
order.append(nodeid)
|
|
281
|
+
outcomes[nodeid] = _OUTCOMES[progress.group("outcome")]
|
|
282
|
+
continue
|
|
283
|
+
|
|
284
|
+
duration = _DURATION_RE.match(line.strip())
|
|
285
|
+
if duration:
|
|
286
|
+
nodeid = duration.group("nodeid")
|
|
287
|
+
durations[nodeid] = durations.get(nodeid, 0.0) + float(duration.group("seconds"))
|
|
288
|
+
|
|
289
|
+
return [
|
|
290
|
+
{
|
|
291
|
+
"nodeid": nodeid,
|
|
292
|
+
"outcome": outcomes.get(nodeid, "unknown"),
|
|
293
|
+
"duration_s": round(durations[nodeid], 4) if nodeid in durations else None,
|
|
294
|
+
"message": _shorten(messages.get(nodeid, "")),
|
|
295
|
+
}
|
|
296
|
+
for nodeid in order
|
|
297
|
+
]
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _failure_detail(stdout: str) -> str | None:
|
|
301
|
+
"""The FAILURES/ERRORS section verbatim — what `run_single` is actually for."""
|
|
302
|
+
header = _FAILURE_HEADER_RE.search(stdout)
|
|
303
|
+
if not header:
|
|
304
|
+
return None
|
|
305
|
+
tail = stdout[header.start():]
|
|
306
|
+
end = _SUMMARY_HEADER_RE.search(tail, 1)
|
|
307
|
+
section = tail[: end.start()] if end else tail
|
|
308
|
+
return _shorten(section, 8_000)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _totals(rows: list[dict[str, Any]]) -> dict[str, int]:
|
|
312
|
+
counts = Counter(row["outcome"] for row in rows)
|
|
313
|
+
return {"total": len(rows), **{outcome: counts[outcome] for outcome in sorted(counts)}}
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
async def _pytest(cwd: Path, selectors: list[str], timeout_s: int) -> tuple[_Completed, list[dict[str, Any]], str]:
|
|
317
|
+
"""Run pytest and return (process, per-test rows, parser used)."""
|
|
318
|
+
use_json = _json_report_available()
|
|
319
|
+
with tempfile.TemporaryDirectory(prefix="qaas-pytest-") as tmp:
|
|
320
|
+
report_path = Path(tmp) / "report.json" if use_json else None
|
|
321
|
+
proc = await _run_async(_base_argv(selectors, report_path), cwd, timeout_s)
|
|
322
|
+
|
|
323
|
+
# An unknown option (an older pytest without --durations-min, say) is a
|
|
324
|
+
# usage error, not a test failure. Retry once with the minimal flag set
|
|
325
|
+
# rather than reporting a suite that never ran.
|
|
326
|
+
if proc.returncode == _EXIT_USAGE_ERROR and not use_json and _UNKNOWN_OPTION_RE.search(
|
|
327
|
+
proc.stdout + proc.stderr
|
|
328
|
+
):
|
|
329
|
+
argv = [sys.executable, "-m", "pytest", "-p", "no:cacheprovider", "--tb=short", "-v", "-rfE"]
|
|
330
|
+
proc = await _run_async(argv + selectors, cwd, timeout_s)
|
|
331
|
+
|
|
332
|
+
rows: list[dict[str, Any]] | None = None
|
|
333
|
+
if report_path is not None:
|
|
334
|
+
rows = _parse_json_report(report_path)
|
|
335
|
+
parser = "json-report" if rows is not None else "terminal"
|
|
336
|
+
if rows is None:
|
|
337
|
+
rows = _parse_terminal(proc.stdout)
|
|
338
|
+
return proc, rows, parser
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _matched_nothing(proc: _Completed) -> bool:
|
|
342
|
+
"""Whether the selector picked no test at all, however pytest said so."""
|
|
343
|
+
if proc.returncode == _EXIT_NO_TESTS:
|
|
344
|
+
return True
|
|
345
|
+
return proc.returncode == _EXIT_USAGE_ERROR and bool(
|
|
346
|
+
_NOT_FOUND_RE.search(proc.stdout + proc.stderr)
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _outcome_of(rows: list[dict[str, Any]], test_id: str, proc: _Completed) -> str:
|
|
351
|
+
"""One test's outcome, falling back to the exit code if parsing missed it."""
|
|
352
|
+
for row in rows:
|
|
353
|
+
if row["nodeid"] == test_id or row["nodeid"].endswith(test_id):
|
|
354
|
+
return row["outcome"]
|
|
355
|
+
if proc.timed_out:
|
|
356
|
+
return "timeout"
|
|
357
|
+
if _matched_nothing(proc):
|
|
358
|
+
return "not_collected"
|
|
359
|
+
if proc.returncode == 0:
|
|
360
|
+
return "passed"
|
|
361
|
+
return "failed"
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _tail(proc: _Completed) -> str:
|
|
365
|
+
combined = (proc.stdout + ("\n" + proc.stderr if proc.stderr else "")).strip()
|
|
366
|
+
return combined[-OUTPUT_TAIL:]
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
# ---------------------------------------------------------------------------
|
|
370
|
+
# the server
|
|
371
|
+
# ---------------------------------------------------------------------------
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def build_tools(ctx: ToolContext) -> list:
|
|
375
|
+
"""The test-runner tools, bound to one agent's run context.
|
|
376
|
+
|
|
377
|
+
Split from `build` so tests can call the handlers directly without standing
|
|
378
|
+
up an MCP transport.
|
|
379
|
+
"""
|
|
380
|
+
|
|
381
|
+
_CWD_SCHEMA = {
|
|
382
|
+
"cwd": {"type": "string", "description": "Directory to run in. Defaults to the repo root; must stay inside it."},
|
|
383
|
+
"timeout_s": {"type": "number", "description": f"Seconds before the run is killed. Default {DEFAULT_TIMEOUT_S}, cap {MAX_TIMEOUT_S}."},
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
@tool(
|
|
387
|
+
"run_suite",
|
|
388
|
+
"Run the test suite, optionally narrowed by a selector, and get structured "
|
|
389
|
+
"per-test outcomes back. Read the fields; do not parse the summary text.",
|
|
390
|
+
{
|
|
391
|
+
"type": "object",
|
|
392
|
+
"properties": {
|
|
393
|
+
"selector": {
|
|
394
|
+
"type": "string",
|
|
395
|
+
"description": "A path ('tests/api'), a nodeid, or a -k expression ('order and not slow').",
|
|
396
|
+
},
|
|
397
|
+
**_CWD_SCHEMA,
|
|
398
|
+
},
|
|
399
|
+
},
|
|
400
|
+
)
|
|
401
|
+
async def run_suite(args: dict[str, Any]) -> dict[str, Any]:
|
|
402
|
+
cwd, cwd_error = _resolve_cwd(ctx, args.get("cwd"))
|
|
403
|
+
if cwd_error:
|
|
404
|
+
return err(cwd_error)
|
|
405
|
+
timeout_s, timeout_error = _timeout(args)
|
|
406
|
+
if timeout_error:
|
|
407
|
+
return err(timeout_error)
|
|
408
|
+
|
|
409
|
+
selector = (args.get("selector") or "").strip()
|
|
410
|
+
selectors: list[str] = []
|
|
411
|
+
if selector:
|
|
412
|
+
# A selector that names something on disk is a path; anything else is
|
|
413
|
+
# a -k expression. Guessing wrong wastes a run, so the check is a
|
|
414
|
+
# filesystem question, not a syntax one.
|
|
415
|
+
head = selector.split("::", 1)[0]
|
|
416
|
+
selectors = [selector] if (cwd / head).exists() else ["-k", selector]
|
|
417
|
+
|
|
418
|
+
proc, rows, parser = await _pytest(cwd, selectors, timeout_s)
|
|
419
|
+
if not proc.started:
|
|
420
|
+
return err(proc.stderr)
|
|
421
|
+
if _matched_nothing(proc) and not rows:
|
|
422
|
+
return err(
|
|
423
|
+
f"No tests matched {selector or 'the default collection'} in {cwd}. "
|
|
424
|
+
"Check the selector against the files that exist."
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
totals = _totals(rows)
|
|
428
|
+
structured = {
|
|
429
|
+
"tests": rows,
|
|
430
|
+
"totals": totals,
|
|
431
|
+
"exit_code": proc.returncode,
|
|
432
|
+
"timed_out": proc.timed_out,
|
|
433
|
+
"duration_s": round(proc.duration_s, 3),
|
|
434
|
+
"parser": parser,
|
|
435
|
+
"cwd": str(cwd),
|
|
436
|
+
"selector": selector or None,
|
|
437
|
+
}
|
|
438
|
+
if proc.timed_out or not rows:
|
|
439
|
+
structured["output_tail"] = _tail(proc)
|
|
440
|
+
|
|
441
|
+
headline = ", ".join(f"{count} {name}" for name, count in totals.items() if name != "total")
|
|
442
|
+
note = f" TIMED OUT after {timeout_s}s; these are partial results." if proc.timed_out else ""
|
|
443
|
+
return ok(
|
|
444
|
+
f"{totals['total']} tests: {headline or 'none run'} in {proc.duration_s:.1f}s.{note}",
|
|
445
|
+
**structured,
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
@tool(
|
|
449
|
+
"run_single",
|
|
450
|
+
"Run one test by nodeid and get its full failure output. Use this to confirm "
|
|
451
|
+
"a repro, not to browse the suite.",
|
|
452
|
+
{
|
|
453
|
+
"type": "object",
|
|
454
|
+
"required": ["test_id"],
|
|
455
|
+
"properties": {
|
|
456
|
+
"test_id": {"type": "string", "description": "e.g. 'tests/api/test_orders.py::test_returns_500'"},
|
|
457
|
+
**_CWD_SCHEMA,
|
|
458
|
+
},
|
|
459
|
+
},
|
|
460
|
+
)
|
|
461
|
+
async def run_single(args: dict[str, Any]) -> dict[str, Any]:
|
|
462
|
+
cwd, cwd_error = _resolve_cwd(ctx, args.get("cwd"))
|
|
463
|
+
if cwd_error:
|
|
464
|
+
return err(cwd_error)
|
|
465
|
+
timeout_s, timeout_error = _timeout(args)
|
|
466
|
+
if timeout_error:
|
|
467
|
+
return err(timeout_error)
|
|
468
|
+
|
|
469
|
+
test_id = str(args["test_id"]).strip()
|
|
470
|
+
if not test_id:
|
|
471
|
+
return err("test_id is required.")
|
|
472
|
+
|
|
473
|
+
proc, rows, parser = await _pytest(cwd, [test_id], timeout_s)
|
|
474
|
+
if not proc.started:
|
|
475
|
+
return err(proc.stderr)
|
|
476
|
+
if _matched_nothing(proc):
|
|
477
|
+
return err(
|
|
478
|
+
f"'{test_id}' matched no test in {cwd}. Nodeids look like "
|
|
479
|
+
"'path/to/test_file.py::test_name'."
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
outcome = _outcome_of(rows, test_id, proc)
|
|
483
|
+
row = next((r for r in rows if r["nodeid"] == test_id or r["nodeid"].endswith(test_id)), None)
|
|
484
|
+
detail = _failure_detail(proc.stdout)
|
|
485
|
+
structured = {
|
|
486
|
+
"nodeid": test_id,
|
|
487
|
+
"outcome": outcome,
|
|
488
|
+
"duration_s": (row or {}).get("duration_s"),
|
|
489
|
+
"message": (row or {}).get("message"),
|
|
490
|
+
"output": detail or (_tail(proc) if outcome != "passed" else None),
|
|
491
|
+
"exit_code": proc.returncode,
|
|
492
|
+
"timed_out": proc.timed_out,
|
|
493
|
+
"parser": parser,
|
|
494
|
+
}
|
|
495
|
+
return ok(f"{test_id}: {outcome} in {proc.duration_s:.1f}s.", **structured)
|
|
496
|
+
|
|
497
|
+
@tool(
|
|
498
|
+
"run_n_times",
|
|
499
|
+
"Run one test repeatedly and measure its flake rate — the share of runs whose "
|
|
500
|
+
"outcome differs from the majority. A non-zero rate means the defect is flaky, "
|
|
501
|
+
"which is a different finding from a defect that always reproduces (§10).",
|
|
502
|
+
{
|
|
503
|
+
"type": "object",
|
|
504
|
+
"required": ["test_id", "n"],
|
|
505
|
+
"properties": {
|
|
506
|
+
"test_id": {"type": "string"},
|
|
507
|
+
"n": {"type": "number", "description": f"Number of runs, 1-{MAX_FLAKE_RUNS}."},
|
|
508
|
+
**_CWD_SCHEMA,
|
|
509
|
+
},
|
|
510
|
+
},
|
|
511
|
+
)
|
|
512
|
+
async def run_n_times(args: dict[str, Any]) -> dict[str, Any]:
|
|
513
|
+
cwd, cwd_error = _resolve_cwd(ctx, args.get("cwd"))
|
|
514
|
+
if cwd_error:
|
|
515
|
+
return err(cwd_error)
|
|
516
|
+
timeout_s, timeout_error = _timeout(args)
|
|
517
|
+
if timeout_error:
|
|
518
|
+
return err(timeout_error)
|
|
519
|
+
|
|
520
|
+
test_id = str(args["test_id"]).strip()
|
|
521
|
+
if not test_id:
|
|
522
|
+
return err("test_id is required.")
|
|
523
|
+
try:
|
|
524
|
+
n = int(args["n"])
|
|
525
|
+
except (TypeError, ValueError):
|
|
526
|
+
return err(f"n must be a whole number, got {args['n']!r}.")
|
|
527
|
+
if n < 1:
|
|
528
|
+
return err("n must be at least 1.")
|
|
529
|
+
if n > MAX_FLAKE_RUNS:
|
|
530
|
+
return err(
|
|
531
|
+
f"n={n} exceeds the {MAX_FLAKE_RUNS}-run cap. Twenty runs resolve a "
|
|
532
|
+
"5% flake most of the time; more is a budget problem, not better evidence."
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
outcomes: list[str] = []
|
|
536
|
+
messages: list[str] = []
|
|
537
|
+
for attempt in range(n):
|
|
538
|
+
proc, rows, _ = await _pytest(cwd, [test_id], timeout_s)
|
|
539
|
+
if not proc.started:
|
|
540
|
+
return err(proc.stderr)
|
|
541
|
+
if attempt == 0 and _matched_nothing(proc):
|
|
542
|
+
return err(f"'{test_id}' matched no test in {cwd}.")
|
|
543
|
+
outcome = _outcome_of(rows, test_id, proc)
|
|
544
|
+
outcomes.append(outcome)
|
|
545
|
+
row = next((r for r in rows if r["nodeid"] == test_id or r["nodeid"].endswith(test_id)), None)
|
|
546
|
+
if row and row.get("message"):
|
|
547
|
+
messages.append(f"run {attempt + 1}: {row['message']}")
|
|
548
|
+
|
|
549
|
+
counts = Counter(outcomes)
|
|
550
|
+
majority, majority_count = counts.most_common(1)[0]
|
|
551
|
+
flake_rate = round((n - majority_count) / n, 4)
|
|
552
|
+
passed = counts.get("passed", 0)
|
|
553
|
+
|
|
554
|
+
verdict = (
|
|
555
|
+
f"stable ({majority})" if flake_rate == 0
|
|
556
|
+
else f"FLAKY: {flake_rate:.0%} of runs disagreed with the majority ({majority})"
|
|
557
|
+
)
|
|
558
|
+
return ok(
|
|
559
|
+
f"{test_id} over {n} runs — {passed} passed, {n - passed} not passed. {verdict}.",
|
|
560
|
+
runs=n,
|
|
561
|
+
passed=passed,
|
|
562
|
+
failed=n - passed,
|
|
563
|
+
flake_rate=flake_rate,
|
|
564
|
+
majority_outcome=majority,
|
|
565
|
+
outcomes=outcomes,
|
|
566
|
+
counts=dict(counts),
|
|
567
|
+
messages=messages[:5],
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
@tool(
|
|
571
|
+
"affected_tests",
|
|
572
|
+
"Heuristic: given changed source paths, the test files most likely to cover them. "
|
|
573
|
+
"It is a heuristic, not a coverage-derived answer — treat the ranking as a place to "
|
|
574
|
+
"start, and run the full suite before concluding nothing broke.",
|
|
575
|
+
{
|
|
576
|
+
"type": "object",
|
|
577
|
+
"required": ["paths"],
|
|
578
|
+
"properties": {
|
|
579
|
+
"paths": {"type": "array", "items": {"type": "string"}, "description": "Repo-relative changed paths."},
|
|
580
|
+
"cwd": _CWD_SCHEMA["cwd"],
|
|
581
|
+
},
|
|
582
|
+
},
|
|
583
|
+
)
|
|
584
|
+
async def affected_tests(args: dict[str, Any]) -> dict[str, Any]:
|
|
585
|
+
cwd, cwd_error = _resolve_cwd(ctx, args.get("cwd"))
|
|
586
|
+
if cwd_error:
|
|
587
|
+
return err(cwd_error)
|
|
588
|
+
raw_paths = args.get("paths")
|
|
589
|
+
if not isinstance(raw_paths, list) or not raw_paths:
|
|
590
|
+
return err("paths must be a non-empty array of repo-relative paths.")
|
|
591
|
+
|
|
592
|
+
candidates = await asyncio.to_thread(_collect_test_files, cwd)
|
|
593
|
+
if not candidates:
|
|
594
|
+
return err(f"No test files found under {cwd}.")
|
|
595
|
+
|
|
596
|
+
scored = await asyncio.to_thread(_score_tests, cwd, [str(p) for p in raw_paths], candidates)
|
|
597
|
+
if not scored:
|
|
598
|
+
return ok(
|
|
599
|
+
"No test file looks related to those paths. That is itself worth reporting: "
|
|
600
|
+
"the change may be untested.",
|
|
601
|
+
affected=[],
|
|
602
|
+
heuristic=True,
|
|
603
|
+
)
|
|
604
|
+
return ok(
|
|
605
|
+
"Likely covering tests, best first: "
|
|
606
|
+
+ ", ".join(item["test_file"] for item in scored[:10]),
|
|
607
|
+
affected=scored[:25],
|
|
608
|
+
heuristic=True,
|
|
609
|
+
searched=len(candidates),
|
|
610
|
+
)
|
|
611
|
+
|
|
612
|
+
@tool(
|
|
613
|
+
"get_coverage",
|
|
614
|
+
"Per-file line coverage, measured by running the suite under coverage.py. "
|
|
615
|
+
"Returns an error if coverage is not installed rather than an estimate.",
|
|
616
|
+
{
|
|
617
|
+
"type": "object",
|
|
618
|
+
"properties": {
|
|
619
|
+
"paths": {"type": "array", "items": {"type": "string"}, "description": "Limit measurement to these source paths."},
|
|
620
|
+
"selector": {"type": "string", "description": "Optional -k expression or path to narrow the suite."},
|
|
621
|
+
**_CWD_SCHEMA,
|
|
622
|
+
},
|
|
623
|
+
},
|
|
624
|
+
)
|
|
625
|
+
async def get_coverage(args: dict[str, Any]) -> dict[str, Any]:
|
|
626
|
+
if importlib.util.find_spec("coverage") is None:
|
|
627
|
+
return err(
|
|
628
|
+
"coverage is not installed in this environment, so there is no coverage "
|
|
629
|
+
"number to report. Install `coverage` (or add it to the dev extras) and "
|
|
630
|
+
"call again; do not estimate coverage from reading the code."
|
|
631
|
+
)
|
|
632
|
+
|
|
633
|
+
cwd, cwd_error = _resolve_cwd(ctx, args.get("cwd"))
|
|
634
|
+
if cwd_error:
|
|
635
|
+
return err(cwd_error)
|
|
636
|
+
timeout_s, timeout_error = _timeout(args)
|
|
637
|
+
if timeout_error:
|
|
638
|
+
return err(timeout_error)
|
|
639
|
+
|
|
640
|
+
paths = [str(p) for p in (args.get("paths") or [])]
|
|
641
|
+
selector = (args.get("selector") or "").strip()
|
|
642
|
+
selectors: list[str] = []
|
|
643
|
+
if selector:
|
|
644
|
+
head = selector.split("::", 1)[0]
|
|
645
|
+
selectors = [selector] if (cwd / head).exists() else ["-k", selector]
|
|
646
|
+
|
|
647
|
+
with tempfile.TemporaryDirectory(prefix="qaas-coverage-") as tmp:
|
|
648
|
+
data_file = Path(tmp) / ".coverage"
|
|
649
|
+
json_file = Path(tmp) / "coverage.json"
|
|
650
|
+
env_extra = {"COVERAGE_FILE": str(data_file)}
|
|
651
|
+
run_argv = [sys.executable, "-m", "coverage", "run"]
|
|
652
|
+
if paths:
|
|
653
|
+
run_argv.append("--source=" + ",".join(paths))
|
|
654
|
+
run_argv += ["-m", "pytest", "-q", "-p", "no:cacheprovider", *selectors]
|
|
655
|
+
|
|
656
|
+
run = await _run_async(run_argv, cwd, timeout_s, env_extra)
|
|
657
|
+
if not run.started:
|
|
658
|
+
return err(run.stderr)
|
|
659
|
+
if run.timed_out:
|
|
660
|
+
return err(f"The coverage run exceeded {timeout_s}s and was killed. Narrow it with `selector`.")
|
|
661
|
+
|
|
662
|
+
report = await _run_async(
|
|
663
|
+
[sys.executable, "-m", "coverage", "json", "-o", str(json_file)],
|
|
664
|
+
cwd,
|
|
665
|
+
min(timeout_s, 120),
|
|
666
|
+
env_extra,
|
|
667
|
+
)
|
|
668
|
+
if not json_file.exists():
|
|
669
|
+
return err(
|
|
670
|
+
"coverage produced no report: "
|
|
671
|
+
+ (_tail(report) or _tail(run) or "no output")
|
|
672
|
+
)
|
|
673
|
+
try:
|
|
674
|
+
data = json.loads(json_file.read_text())
|
|
675
|
+
except ValueError as exc:
|
|
676
|
+
return err(f"coverage report was not valid JSON: {exc}")
|
|
677
|
+
|
|
678
|
+
files = {
|
|
679
|
+
name: round(info.get("summary", {}).get("percent_covered", 0.0), 2)
|
|
680
|
+
for name, info in (data.get("files") or {}).items()
|
|
681
|
+
}
|
|
682
|
+
if paths:
|
|
683
|
+
wanted = tuple(paths)
|
|
684
|
+
files = {n: pct for n, pct in files.items() if n.startswith(wanted)} or files
|
|
685
|
+
overall = round((data.get("totals") or {}).get("percent_covered", 0.0), 2)
|
|
686
|
+
lowest = sorted(files.items(), key=lambda kv: kv[1])[:5]
|
|
687
|
+
|
|
688
|
+
return ok(
|
|
689
|
+
f"Overall line coverage {overall}% across {len(files)} files. "
|
|
690
|
+
+ ("Lowest: " + ", ".join(f"{n} {p}%" for n, p in lowest) if lowest else ""),
|
|
691
|
+
overall_percent=overall,
|
|
692
|
+
files=files,
|
|
693
|
+
tests_exit_code=run.returncode,
|
|
694
|
+
)
|
|
695
|
+
|
|
696
|
+
return [run_suite, run_single, run_n_times, affected_tests, get_coverage]
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
# ---------------------------------------------------------------------------
|
|
700
|
+
# affected_tests heuristic
|
|
701
|
+
# ---------------------------------------------------------------------------
|
|
702
|
+
|
|
703
|
+
_SKIP_DIRS = {".git", ".venv", "venv", "node_modules", "__pycache__", ".tox", ".mypy_cache", ".qaas"}
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def _collect_test_files(root: Path) -> list[Path]:
|
|
707
|
+
found: list[Path] = []
|
|
708
|
+
for path in root.rglob("*.py"):
|
|
709
|
+
if any(part in _SKIP_DIRS for part in path.parts):
|
|
710
|
+
continue
|
|
711
|
+
if path.name.startswith("test_") or path.name.endswith("_test.py"):
|
|
712
|
+
found.append(path)
|
|
713
|
+
return found
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def _score_tests(root: Path, changed: list[str], candidates: list[Path]) -> list[dict[str, Any]]:
|
|
717
|
+
"""Rank test files by three independent, cheap signals.
|
|
718
|
+
|
|
719
|
+
Name correspondence is the strongest (`foo.py` -> `test_foo.py` is a
|
|
720
|
+
convention people actually follow), an import of the changed module is next,
|
|
721
|
+
and sharing a directory is the weak tie-breaker that catches package-level
|
|
722
|
+
test layouts. Deliberately no AST or coverage database: this runs before the
|
|
723
|
+
agent knows which suite to run, so it must be fast and never wrong-by-crash.
|
|
724
|
+
"""
|
|
725
|
+
scores: dict[Path, int] = {}
|
|
726
|
+
reasons: dict[Path, list[str]] = {}
|
|
727
|
+
|
|
728
|
+
for raw in changed:
|
|
729
|
+
source = Path(raw)
|
|
730
|
+
stem = source.stem
|
|
731
|
+
module_dir = source.parent.as_posix()
|
|
732
|
+
for candidate in candidates:
|
|
733
|
+
score = 0
|
|
734
|
+
why: list[str] = []
|
|
735
|
+
name = candidate.name
|
|
736
|
+
if name in {f"test_{stem}.py", f"{stem}_test.py"}:
|
|
737
|
+
score += 100
|
|
738
|
+
why.append(f"name matches {source.name}")
|
|
739
|
+
elif stem and stem in name:
|
|
740
|
+
score += 25
|
|
741
|
+
why.append(f"filename mentions '{stem}'")
|
|
742
|
+
|
|
743
|
+
if stem:
|
|
744
|
+
try:
|
|
745
|
+
text = candidate.read_text(errors="ignore")
|
|
746
|
+
except OSError:
|
|
747
|
+
text = ""
|
|
748
|
+
if re.search(rf"\b(import|from)\b[^\n]*\b{re.escape(stem)}\b", text):
|
|
749
|
+
score += 40
|
|
750
|
+
why.append(f"imports '{stem}'")
|
|
751
|
+
|
|
752
|
+
if module_dir and module_dir not in {".", ""} and module_dir in candidate.as_posix():
|
|
753
|
+
score += 10
|
|
754
|
+
why.append(f"shares directory {module_dir}")
|
|
755
|
+
|
|
756
|
+
if score:
|
|
757
|
+
scores[candidate] = scores.get(candidate, 0) + score
|
|
758
|
+
reasons.setdefault(candidate, []).extend(w for w in why if w not in reasons.get(candidate, []))
|
|
759
|
+
|
|
760
|
+
ranked = sorted(scores.items(), key=lambda kv: (-kv[1], str(kv[0])))
|
|
761
|
+
return [
|
|
762
|
+
{
|
|
763
|
+
"test_file": path.relative_to(root).as_posix() if path.is_relative_to(root) else str(path),
|
|
764
|
+
"score": score,
|
|
765
|
+
"why": reasons.get(path, []),
|
|
766
|
+
}
|
|
767
|
+
for path, score in ranked
|
|
768
|
+
]
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
def build(ctx: ToolContext):
|
|
772
|
+
"""Construct the test_runner MCP server bound to one agent's run context."""
|
|
773
|
+
return create_sdk_mcp_server(name="test_runner", version="1.0.0", tools=build_tools(ctx))
|