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.
Files changed (81) hide show
  1. qaas/adapters/__init__.py +19 -0
  2. qaas/adapters/tracker.py +1350 -0
  3. qaas/adapters/vcs.py +494 -0
  4. qaas/cli.py +1564 -0
  5. qaas/conductor.py +527 -0
  6. qaas/config.py +407 -0
  7. qaas/defaults/config/agents/arbiter.yaml +19 -0
  8. qaas/defaults/config/agents/cartographer.yaml +20 -0
  9. qaas/defaults/config/agents/clerk.yaml +21 -0
  10. qaas/defaults/config/agents/conduit.yaml +19 -0
  11. qaas/defaults/config/agents/forge.yaml +22 -0
  12. qaas/defaults/config/agents/mender.yaml +56 -0
  13. qaas/defaults/config/agents/proof.yaml +21 -0
  14. qaas/defaults/config/agents/surface.yaml +16 -0
  15. qaas/defaults/config/system.yaml +69 -0
  16. qaas/discover.py +227 -0
  17. qaas/envelope.py +290 -0
  18. qaas/guardrails.py +431 -0
  19. qaas/mcp/__init__.py +0 -0
  20. qaas/mcp/context.py +70 -0
  21. qaas/mcp/contract_diff.py +937 -0
  22. qaas/mcp/defect_memory.py +495 -0
  23. qaas/mcp/env_control.py +905 -0
  24. qaas/mcp/envelope_server.py +463 -0
  25. qaas/mcp/test_runner.py +773 -0
  26. qaas/mcp/tracker.py +412 -0
  27. qaas/mcp/vcs.py +506 -0
  28. qaas/paths.py +317 -0
  29. qaas/plugin/.claude-plugin/plugin.json +9 -0
  30. qaas/plugin/skills/a11y-audit/SKILL.md +34 -0
  31. qaas/plugin/skills/adversarial-review/SKILL.md +120 -0
  32. qaas/plugin/skills/api-surface-extraction/SKILL.md +38 -0
  33. qaas/plugin/skills/authz-matrix-check/SKILL.md +46 -0
  34. qaas/plugin/skills/console-error-triage/SKILL.md +39 -0
  35. qaas/plugin/skills/contract-test-generation/SKILL.md +36 -0
  36. qaas/plugin/skills/dedupe-strategy/SKILL.md +39 -0
  37. qaas/plugin/skills/environment-pinning/SKILL.md +35 -0
  38. qaas/plugin/skills/error-taxonomy/SKILL.md +42 -0
  39. qaas/plugin/skills/exploratory-ui-walk/SKILL.md +46 -0
  40. qaas/plugin/skills/failing-test-authoring/SKILL.md +47 -0
  41. qaas/plugin/skills/flake-detection/SKILL.md +39 -0
  42. qaas/plugin/skills/form-state-probe/SKILL.md +36 -0
  43. qaas/plugin/skills/minimal-diff-discipline/SKILL.md +70 -0
  44. qaas/plugin/skills/openapi-diff/SKILL.md +45 -0
  45. qaas/plugin/skills/ownership-resolution/SKILL.md +31 -0
  46. qaas/plugin/skills/product-task-graph/SKILL.md +35 -0
  47. qaas/plugin/skills/regression-risk-scoring/SKILL.md +59 -0
  48. qaas/plugin/skills/regression-suite-selection/SKILL.md +36 -0
  49. qaas/plugin/skills/repo-cartography/SKILL.md +38 -0
  50. qaas/plugin/skills/repro-minimisation/SKILL.md +41 -0
  51. qaas/plugin/skills/rollback-plan-authoring/SKILL.md +81 -0
  52. qaas/plugin/skills/root-cause-vs-symptom/SKILL.md +67 -0
  53. qaas/plugin/skills/routing-rules/SKILL.md +34 -0
  54. qaas/plugin/skills/severity-rubric/SKILL.md +42 -0
  55. qaas/plugin/skills/test-first-fix/SKILL.md +66 -0
  56. qaas/plugin/skills/test-quality-audit/SKILL.md +58 -0
  57. qaas/plugin/skills/ticket-writer/SKILL.md +40 -0
  58. qaas/plugin/skills/verdict-reporting/SKILL.md +35 -0
  59. qaas/plugin/skills/verification-protocol/SKILL.md +39 -0
  60. qaas/prompts/ARBITER.md +53 -0
  61. qaas/prompts/CARTOGRAPHER.md +46 -0
  62. qaas/prompts/CLERK.md +45 -0
  63. qaas/prompts/CONDUIT.md +44 -0
  64. qaas/prompts/FORGE.md +43 -0
  65. qaas/prompts/MENDER.md +55 -0
  66. qaas/prompts/PROOF.md +41 -0
  67. qaas/prompts/SURFACE.md +46 -0
  68. qaas/prompts/_shared.md +45 -0
  69. qaas/registry.py +465 -0
  70. qaas/runner.py +192 -0
  71. qaas/scorecard.py +425 -0
  72. qaas/sdk_compat.py +52 -0
  73. qaas/store.py +290 -0
  74. qaas/target.py +261 -0
  75. qaas/tasks.py +361 -0
  76. qaas/trace.py +270 -0
  77. qaas_python-0.1.0.dist-info/METADATA +388 -0
  78. qaas_python-0.1.0.dist-info/RECORD +81 -0
  79. qaas_python-0.1.0.dist-info/WHEEL +4 -0
  80. qaas_python-0.1.0.dist-info/entry_points.txt +2 -0
  81. qaas_python-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -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))