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
|
@@ -0,0 +1,937 @@
|
|
|
1
|
+
"""The `contract_diff` MCP server — what changed for the people calling you.
|
|
2
|
+
|
|
3
|
+
A text diff of two OpenAPI documents tells an agent that lines moved. It does
|
|
4
|
+
not tell it that `currency` vanished from the Invoice response and every
|
|
5
|
+
consumer reading that field now gets a KeyError. This server answers the second
|
|
6
|
+
question: it walks both documents structurally, resolves `$ref`s, and reports
|
|
7
|
+
changes as consumer-visible facts with a breaking/non-breaking verdict attached.
|
|
8
|
+
|
|
9
|
+
Two things follow from CONDUIT's brief (§4.5):
|
|
10
|
+
|
|
11
|
+
* **The declared contract is the reference.** `spec_a` defaults to
|
|
12
|
+
the target's declared spec and `spec_b` to the running app's `/openapi.json`,
|
|
13
|
+
so "drift" here means the implementation disagrees with the published spec —
|
|
14
|
+
which is the defect, not the other way round.
|
|
15
|
+
* **A finding ships with a failing test.** `generate_contract_test` emits a
|
|
16
|
+
standalone pytest module that asserts the spec's promises against a live
|
|
17
|
+
server. That file is the evidence an envelope cites; it must fail on the
|
|
18
|
+
violating implementation and pass on a conforming one, or it is worthless.
|
|
19
|
+
|
|
20
|
+
Classification follows one fixed rule set, applied identically by `diff_openapi`
|
|
21
|
+
and `classify_breaking` so the two can never disagree: losing a guarantee is
|
|
22
|
+
breaking (a removed endpoint or field, a dropped required-ness, a narrowed type,
|
|
23
|
+
a newly required request input, a changed status code); gaining an optional one
|
|
24
|
+
is not.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import json
|
|
30
|
+
import os
|
|
31
|
+
import re
|
|
32
|
+
import urllib.error
|
|
33
|
+
import urllib.request
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
from typing import Any, Iterable
|
|
36
|
+
|
|
37
|
+
import yaml
|
|
38
|
+
from claude_agent_sdk import create_sdk_mcp_server, tool
|
|
39
|
+
|
|
40
|
+
from qaas.mcp.context import ToolContext, err, ok
|
|
41
|
+
|
|
42
|
+
DEFAULT_SPEC_FILE = "openapi.yaml"
|
|
43
|
+
DEFAULT_LIVE_SPEC_URL = "http://localhost:8000/openapi.json"
|
|
44
|
+
FETCH_TIMEOUT_S = 10
|
|
45
|
+
GENERATED_DIR = "generated"
|
|
46
|
+
|
|
47
|
+
HTTP_METHODS = ("get", "put", "post", "delete", "patch", "options", "head", "trace")
|
|
48
|
+
|
|
49
|
+
#: Source extensions worth grepping for call sites. Everything else is noise.
|
|
50
|
+
CONSUMER_SUFFIXES = frozenset(
|
|
51
|
+
{".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".vue", ".svelte",
|
|
52
|
+
".py", ".go", ".rb", ".java", ".kt", ".rs", ".php", ".cs", ".swift"}
|
|
53
|
+
)
|
|
54
|
+
SKIP_DIRS = frozenset({".git", ".venv", "node_modules", "__pycache__", "dist", "build", ".qaas", ".pytest_cache", ".mypy_cache"})
|
|
55
|
+
MAX_CONSUMER_HITS = 200
|
|
56
|
+
|
|
57
|
+
#: kind -> (verdict, why). The single source of truth for breaking-ness: every
|
|
58
|
+
#: change `diff_openapi` emits carries a kind from this table, and
|
|
59
|
+
#: `classify_breaking` is a lookup into it. One table, one answer.
|
|
60
|
+
RULES: dict[str, tuple[str, str]] = {
|
|
61
|
+
"endpoint_removed": ("breaking", "Consumers calling this endpoint now get a 404."),
|
|
62
|
+
"endpoint_added": ("non_breaking", "New surface; no existing caller is affected."),
|
|
63
|
+
"status_code_removed": ("breaking", "A documented outcome disappeared; consumers branching on it are wrong."),
|
|
64
|
+
"status_code_added": ("non_breaking", "An additional documented outcome; existing handling still applies."),
|
|
65
|
+
"response_field_removed": ("breaking", "Consumers reading this field get nothing back."),
|
|
66
|
+
"response_field_renamed": ("breaking", "A rename is a removal and an addition; every reader of the old name breaks."),
|
|
67
|
+
"response_field_added": ("non_breaking", "An added optional response field is ignored by existing consumers."),
|
|
68
|
+
"response_required_dropped": ("breaking", "The field was guaranteed present and no longer is."),
|
|
69
|
+
"response_required_added": ("non_breaking", "A field that was optional is now always present; that only helps."),
|
|
70
|
+
"response_type_narrowed": ("breaking", "The value set shrank; consumers may receive nothing they can use."),
|
|
71
|
+
"response_type_widened": ("non_breaking", "The declared value set grew; previously valid values still arrive."),
|
|
72
|
+
"response_enum_narrowed": ("breaking", "Documented values were withdrawn."),
|
|
73
|
+
"response_enum_widened": ("non_breaking", "Additional documented values. Consumers with exhaustive switches should still be told."),
|
|
74
|
+
"request_field_removed": ("breaking", "Requests that carried this field may now be rejected."),
|
|
75
|
+
"request_field_added_required": ("breaking", "Existing requests omit it and will now fail validation."),
|
|
76
|
+
"request_field_added_optional": ("non_breaking", "Existing requests remain valid."),
|
|
77
|
+
"request_required_added": ("breaking", "A previously optional input is now mandatory."),
|
|
78
|
+
"request_required_dropped": ("non_breaking", "Fewer inputs are mandatory; existing requests still validate."),
|
|
79
|
+
"request_type_narrowed": ("breaking", "Values that used to validate no longer do."),
|
|
80
|
+
"request_type_widened": ("non_breaking", "More values validate than before."),
|
|
81
|
+
"parameter_removed": ("breaking", "Callers passing this parameter silently lose the behaviour it controlled."),
|
|
82
|
+
"parameter_added_required": ("breaking", "Existing callers omit it and will now fail."),
|
|
83
|
+
"parameter_added_optional": ("non_breaking", "Existing callers are unaffected."),
|
|
84
|
+
"parameter_required_added": ("breaking", "A previously optional parameter is now mandatory."),
|
|
85
|
+
"parameter_type_narrowed": ("breaking", "Values callers already send may now be rejected."),
|
|
86
|
+
"parameter_type_widened": ("non_breaking", "More values are accepted than before."),
|
|
87
|
+
"security_added": ("breaking", "An endpoint that accepted anonymous calls now requires credentials."),
|
|
88
|
+
"security_removed": ("non_breaking", "Compatibility is unaffected, but dropping auth is a security finding in its own right."),
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# --------------------------------------------------------------------------
|
|
93
|
+
# spec loading
|
|
94
|
+
# --------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _is_url(value: str) -> bool:
|
|
98
|
+
return value.startswith(("http://", "https://"))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _fetch_spec(url: str) -> tuple[dict[str, Any] | None, str | None]:
|
|
102
|
+
try:
|
|
103
|
+
with urllib.request.urlopen(url, timeout=FETCH_TIMEOUT_S) as resp: # noqa: S310 - http(s) only, checked by caller
|
|
104
|
+
body = resp.read().decode(errors="replace")
|
|
105
|
+
except urllib.error.HTTPError as exc:
|
|
106
|
+
return None, f"HTTP {exc.code} from {url}"
|
|
107
|
+
except (urllib.error.URLError, OSError, TimeoutError, ValueError) as exc:
|
|
108
|
+
return None, f"could not reach {url} ({exc})"
|
|
109
|
+
try:
|
|
110
|
+
doc = json.loads(body)
|
|
111
|
+
except json.JSONDecodeError:
|
|
112
|
+
try:
|
|
113
|
+
doc = yaml.safe_load(body)
|
|
114
|
+
except yaml.YAMLError as exc:
|
|
115
|
+
return None, f"{url} returned something that is neither JSON nor YAML ({exc})"
|
|
116
|
+
return (doc, None) if isinstance(doc, dict) else (None, f"{url} did not return an object")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _read_spec(ref: str, target_root: Path) -> tuple[dict[str, Any] | None, str | None]:
|
|
120
|
+
"""Load a spec from a URL or a path. Returns (doc, error)."""
|
|
121
|
+
if _is_url(ref):
|
|
122
|
+
return _fetch_spec(ref)
|
|
123
|
+
path = Path(ref)
|
|
124
|
+
if not path.is_absolute():
|
|
125
|
+
path = target_root / path
|
|
126
|
+
if not path.exists():
|
|
127
|
+
return None, f"no such spec file: {path}"
|
|
128
|
+
try:
|
|
129
|
+
doc = yaml.safe_load(path.read_text())
|
|
130
|
+
except (OSError, yaml.YAMLError) as exc:
|
|
131
|
+
return None, f"could not parse {path}: {exc}"
|
|
132
|
+
return (doc, None) if isinstance(doc, dict) else (None, f"{path} does not contain an OpenAPI object")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# --------------------------------------------------------------------------
|
|
136
|
+
# schema walking
|
|
137
|
+
# --------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _resolve(node: Any, doc: dict[str, Any], seen: frozenset[str] = frozenset()) -> tuple[Any, frozenset[str]]:
|
|
141
|
+
"""Follow local `$ref`s. Cycles stop at the second visit rather than recurse."""
|
|
142
|
+
guard = 0
|
|
143
|
+
while isinstance(node, dict) and "$ref" in node and guard < 20:
|
|
144
|
+
ref = str(node["$ref"])
|
|
145
|
+
if not ref.startswith("#/") or ref in seen:
|
|
146
|
+
return {}, seen
|
|
147
|
+
seen = seen | {ref}
|
|
148
|
+
target: Any = doc
|
|
149
|
+
for part in ref[2:].split("/"):
|
|
150
|
+
part = part.replace("~1", "/").replace("~0", "~")
|
|
151
|
+
if not isinstance(target, dict) or part not in target:
|
|
152
|
+
return {}, seen
|
|
153
|
+
target = target[part]
|
|
154
|
+
node = target
|
|
155
|
+
guard += 1
|
|
156
|
+
return node, seen
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _merged(schema: Any, doc: dict[str, Any], seen: frozenset[str]) -> tuple[dict[str, Any], frozenset[str]]:
|
|
160
|
+
"""Resolve a schema and flatten a single level of allOf into it."""
|
|
161
|
+
schema, seen = _resolve(schema, doc, seen)
|
|
162
|
+
if not isinstance(schema, dict):
|
|
163
|
+
return {}, seen
|
|
164
|
+
if "allOf" not in schema:
|
|
165
|
+
return schema, seen
|
|
166
|
+
merged: dict[str, Any] = {k: v for k, v in schema.items() if k != "allOf"}
|
|
167
|
+
props: dict[str, Any] = dict(merged.get("properties") or {})
|
|
168
|
+
required: list[str] = list(merged.get("required") or [])
|
|
169
|
+
for part in schema["allOf"]:
|
|
170
|
+
sub, seen = _merged(part, doc, seen)
|
|
171
|
+
props.update(sub.get("properties") or {})
|
|
172
|
+
required.extend(sub.get("required") or [])
|
|
173
|
+
for key, value in sub.items():
|
|
174
|
+
if key not in ("properties", "required"):
|
|
175
|
+
merged.setdefault(key, value)
|
|
176
|
+
merged["properties"] = props
|
|
177
|
+
merged["required"] = sorted(set(required))
|
|
178
|
+
return merged, seen
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _types(schema: dict[str, Any]) -> frozenset[str]:
|
|
182
|
+
"""The JSON types a schema admits, unioned across type lists and anyOf/oneOf."""
|
|
183
|
+
out: set[str] = set()
|
|
184
|
+
raw = schema.get("type")
|
|
185
|
+
if isinstance(raw, str):
|
|
186
|
+
out.add(raw)
|
|
187
|
+
elif isinstance(raw, list):
|
|
188
|
+
out.update(str(t) for t in raw)
|
|
189
|
+
for key in ("anyOf", "oneOf"):
|
|
190
|
+
for part in schema.get(key) or []:
|
|
191
|
+
if isinstance(part, dict):
|
|
192
|
+
out |= _types(part)
|
|
193
|
+
if not out and "properties" in schema:
|
|
194
|
+
out.add("object")
|
|
195
|
+
if not out and "items" in schema:
|
|
196
|
+
out.add("array")
|
|
197
|
+
return frozenset(out)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _enum(schema: dict[str, Any]) -> frozenset[str] | None:
|
|
201
|
+
values = schema.get("enum")
|
|
202
|
+
if isinstance(values, list):
|
|
203
|
+
return frozenset(json.dumps(v, sort_keys=True) for v in values)
|
|
204
|
+
for key in ("anyOf", "oneOf"):
|
|
205
|
+
collected: set[str] = set()
|
|
206
|
+
for part in schema.get(key) or []:
|
|
207
|
+
sub = _enum(part) if isinstance(part, dict) else None
|
|
208
|
+
if sub:
|
|
209
|
+
collected |= set(sub)
|
|
210
|
+
if collected:
|
|
211
|
+
return frozenset(collected)
|
|
212
|
+
return None
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def flatten_schema(schema: Any, doc: dict[str, Any], prefix: str = "", *, depth: int = 0,
|
|
216
|
+
seen: frozenset[str] = frozenset()) -> dict[str, dict[str, Any]]:
|
|
217
|
+
"""Field path -> {types, enum, required}. Arrays flatten as `items[].field`.
|
|
218
|
+
|
|
219
|
+
Dotted paths are what a consumer actually reads (`items[].currency`), so a
|
|
220
|
+
diff expressed over them lands in the same vocabulary as the bug report.
|
|
221
|
+
"""
|
|
222
|
+
out: dict[str, dict[str, Any]] = {}
|
|
223
|
+
if depth > 12:
|
|
224
|
+
return out
|
|
225
|
+
node, seen = _merged(schema, doc, seen)
|
|
226
|
+
if not isinstance(node, dict):
|
|
227
|
+
return out
|
|
228
|
+
|
|
229
|
+
if "items" in node:
|
|
230
|
+
out.update(flatten_schema(node["items"], doc, f"{prefix}[]", depth=depth + 1, seen=seen))
|
|
231
|
+
|
|
232
|
+
required = set(node.get("required") or [])
|
|
233
|
+
for name, sub in (node.get("properties") or {}).items():
|
|
234
|
+
path = f"{prefix}.{name}" if prefix else str(name)
|
|
235
|
+
resolved, sub_seen = _merged(sub, doc, seen)
|
|
236
|
+
out[path] = {
|
|
237
|
+
"types": _types(resolved),
|
|
238
|
+
"enum": _enum(resolved),
|
|
239
|
+
"required": name in required,
|
|
240
|
+
}
|
|
241
|
+
out.update(flatten_schema(resolved, doc, path, depth=depth + 1, seen=sub_seen))
|
|
242
|
+
return out
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _json_schema(container: Any, doc: dict[str, Any]) -> Any:
|
|
246
|
+
"""The JSON body schema out of a responses/requestBody entry, if there is one."""
|
|
247
|
+
node, _ = _resolve(container, doc)
|
|
248
|
+
if not isinstance(node, dict):
|
|
249
|
+
return None
|
|
250
|
+
content = node.get("content")
|
|
251
|
+
if not isinstance(content, dict):
|
|
252
|
+
return None
|
|
253
|
+
for media, spec in content.items():
|
|
254
|
+
if "json" in str(media):
|
|
255
|
+
return (spec or {}).get("schema")
|
|
256
|
+
first = next(iter(content.values()), None)
|
|
257
|
+
return (first or {}).get("schema") if isinstance(first, dict) else None
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _operations(doc: dict[str, Any]) -> dict[tuple[str, str], dict[str, Any]]:
|
|
261
|
+
"""(path, METHOD) -> operation, with path-level parameters folded in."""
|
|
262
|
+
ops: dict[tuple[str, str], dict[str, Any]] = {}
|
|
263
|
+
for path, item in (doc.get("paths") or {}).items():
|
|
264
|
+
if not isinstance(item, dict):
|
|
265
|
+
continue
|
|
266
|
+
shared = item.get("parameters") or []
|
|
267
|
+
for method in HTTP_METHODS:
|
|
268
|
+
op = item.get(method)
|
|
269
|
+
if not isinstance(op, dict):
|
|
270
|
+
continue
|
|
271
|
+
merged = dict(op)
|
|
272
|
+
merged["parameters"] = list(shared) + list(op.get("parameters") or [])
|
|
273
|
+
ops[(str(path), method.upper())] = merged
|
|
274
|
+
return ops
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _requires_auth(op: dict[str, Any], doc: dict[str, Any]) -> bool:
|
|
278
|
+
security = op.get("security", doc.get("security", []))
|
|
279
|
+
return bool(security) and any(bool(entry) for entry in security)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _parameters(op: dict[str, Any], doc: dict[str, Any]) -> dict[tuple[str, str], dict[str, Any]]:
|
|
283
|
+
out: dict[tuple[str, str], dict[str, Any]] = {}
|
|
284
|
+
for raw in op.get("parameters") or []:
|
|
285
|
+
param, _ = _resolve(raw, doc)
|
|
286
|
+
if not isinstance(param, dict) or "name" not in param:
|
|
287
|
+
continue
|
|
288
|
+
schema, _ = _merged(param.get("schema") or {}, doc, frozenset())
|
|
289
|
+
out[(str(param.get("in", "query")), str(param["name"]))] = {
|
|
290
|
+
"required": bool(param.get("required", False)),
|
|
291
|
+
"types": _types(schema),
|
|
292
|
+
"enum": _enum(schema),
|
|
293
|
+
}
|
|
294
|
+
return out
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
# --------------------------------------------------------------------------
|
|
298
|
+
# the diff itself
|
|
299
|
+
# --------------------------------------------------------------------------
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _change(kind: str, path: str, method: str, detail: str) -> dict[str, Any]:
|
|
303
|
+
verdict, _ = RULES.get(kind, ("unknown", ""))
|
|
304
|
+
return {"kind": kind, "path": path, "method": method, "detail": detail, "breaking": verdict == "breaking"}
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _kind(prefix: str, facet: str, direction: str) -> str:
|
|
308
|
+
"""`response_enum_widened` if that rule exists, else the `_type_` equivalent.
|
|
309
|
+
|
|
310
|
+
Only responses get their own enum rules; for requests and parameters an enum
|
|
311
|
+
change is just a change to the admissible value space, which is what the
|
|
312
|
+
type rules already say.
|
|
313
|
+
"""
|
|
314
|
+
candidate = f"{prefix}_{facet}_{direction}"
|
|
315
|
+
return candidate if candidate in RULES else f"{prefix}_type_{direction}"
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _compare_value_space(a: dict[str, Any], b: dict[str, Any], prefix: str) -> str | None:
|
|
319
|
+
"""The widened/narrowed kind for a field's value space, or None if unchanged.
|
|
320
|
+
|
|
321
|
+
Direction is decided by subset relation, not by name: only a strict shrink of
|
|
322
|
+
the admissible values can break a consumer that already works. An unrelated
|
|
323
|
+
change (string -> integer, one enum swapped for another) counts as narrowing,
|
|
324
|
+
because at least one value the consumer handled is now impossible.
|
|
325
|
+
"""
|
|
326
|
+
enum_a, enum_b = a.get("enum"), b.get("enum")
|
|
327
|
+
if enum_a != enum_b:
|
|
328
|
+
if enum_a is None: # was unconstrained, now restricted
|
|
329
|
+
return _kind(prefix, "enum", "narrowed")
|
|
330
|
+
if enum_b is None: # was restricted, now open
|
|
331
|
+
return _kind(prefix, "enum", "widened")
|
|
332
|
+
return _kind(prefix, "enum", "widened" if enum_a < enum_b else "narrowed")
|
|
333
|
+
|
|
334
|
+
types_a, types_b = a.get("types") or frozenset(), b.get("types") or frozenset()
|
|
335
|
+
if types_a == types_b:
|
|
336
|
+
return None
|
|
337
|
+
if types_a and types_b and types_a < types_b:
|
|
338
|
+
return f"{prefix}_type_widened"
|
|
339
|
+
return f"{prefix}_type_narrowed"
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _detect_renames(removed: list[str], added: list[str], fields_a: dict[str, dict[str, Any]],
|
|
343
|
+
fields_b: dict[str, dict[str, Any]]) -> list[tuple[str, str]]:
|
|
344
|
+
"""Pair a removal with an addition when the sibling and shape both match.
|
|
345
|
+
|
|
346
|
+
Heuristic, deliberately conservative: a pair is only a rename when exactly
|
|
347
|
+
one candidate matches, so an object that lost two fields and gained two is
|
|
348
|
+
reported as four changes rather than two invented renames.
|
|
349
|
+
"""
|
|
350
|
+
pairs: list[tuple[str, str]] = []
|
|
351
|
+
taken: set[str] = set()
|
|
352
|
+
for old in removed:
|
|
353
|
+
parent = old.rsplit(".", 1)[0] if "." in old else ""
|
|
354
|
+
shape = (fields_a[old]["types"], fields_a[old]["enum"], fields_a[old]["required"])
|
|
355
|
+
candidates = [
|
|
356
|
+
new for new in added
|
|
357
|
+
if new not in taken
|
|
358
|
+
and (new.rsplit(".", 1)[0] if "." in new else "") == parent
|
|
359
|
+
and (fields_b[new]["types"], fields_b[new]["enum"], fields_b[new]["required"]) == shape
|
|
360
|
+
]
|
|
361
|
+
if len(candidates) == 1:
|
|
362
|
+
taken.add(candidates[0])
|
|
363
|
+
pairs.append((old, candidates[0]))
|
|
364
|
+
return pairs
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def diff_specs(spec_a: dict[str, Any], spec_b: dict[str, Any]) -> list[dict[str, Any]]:
|
|
368
|
+
"""Structural diff of two OpenAPI documents, A being the reference."""
|
|
369
|
+
ops_a, ops_b = _operations(spec_a), _operations(spec_b)
|
|
370
|
+
changes: list[dict[str, Any]] = []
|
|
371
|
+
|
|
372
|
+
for key in sorted(set(ops_a) - set(ops_b)):
|
|
373
|
+
changes.append(_change("endpoint_removed", key[0], key[1], f"{key[1]} {key[0]} is declared in A but absent from B."))
|
|
374
|
+
for key in sorted(set(ops_b) - set(ops_a)):
|
|
375
|
+
changes.append(_change("endpoint_added", key[0], key[1], f"{key[1]} {key[0]} exists in B but is undeclared in A."))
|
|
376
|
+
|
|
377
|
+
for key in sorted(set(ops_a) & set(ops_b)):
|
|
378
|
+
path, method = key
|
|
379
|
+
op_a, op_b = ops_a[key], ops_b[key]
|
|
380
|
+
changes.extend(_diff_security(op_a, op_b, spec_a, spec_b, path, method))
|
|
381
|
+
changes.extend(_diff_parameters(op_a, op_b, spec_a, spec_b, path, method))
|
|
382
|
+
changes.extend(_diff_request(op_a, op_b, spec_a, spec_b, path, method))
|
|
383
|
+
changes.extend(_diff_responses(op_a, op_b, spec_a, spec_b, path, method))
|
|
384
|
+
|
|
385
|
+
return changes
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _diff_security(op_a: dict[str, Any], op_b: dict[str, Any], doc_a: dict[str, Any], doc_b: dict[str, Any],
|
|
389
|
+
path: str, method: str) -> list[dict[str, Any]]:
|
|
390
|
+
auth_a, auth_b = _requires_auth(op_a, doc_a), _requires_auth(op_b, doc_b)
|
|
391
|
+
if auth_a == auth_b:
|
|
392
|
+
return []
|
|
393
|
+
kind = "security_added" if auth_b else "security_removed"
|
|
394
|
+
verb = "now requires" if auth_b else "no longer requires"
|
|
395
|
+
return [_change(kind, path, method, f"{method} {path} {verb} authentication.")]
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _diff_parameters(op_a: dict[str, Any], op_b: dict[str, Any], doc_a: dict[str, Any], doc_b: dict[str, Any],
|
|
399
|
+
path: str, method: str) -> list[dict[str, Any]]:
|
|
400
|
+
params_a, params_b = _parameters(op_a, doc_a), _parameters(op_b, doc_b)
|
|
401
|
+
changes: list[dict[str, Any]] = []
|
|
402
|
+
for key in sorted(set(params_a) - set(params_b)):
|
|
403
|
+
changes.append(_change("parameter_removed", path, method, f"{key[0]} parameter '{key[1]}' was removed."))
|
|
404
|
+
for key in sorted(set(params_b) - set(params_a)):
|
|
405
|
+
kind = "parameter_added_required" if params_b[key]["required"] else "parameter_added_optional"
|
|
406
|
+
changes.append(_change(kind, path, method, f"{key[0]} parameter '{key[1]}' was added."))
|
|
407
|
+
for key in sorted(set(params_a) & set(params_b)):
|
|
408
|
+
a, b = params_a[key], params_b[key]
|
|
409
|
+
if not a["required"] and b["required"]:
|
|
410
|
+
changes.append(_change("parameter_required_added", path, method, f"{key[0]} parameter '{key[1]}' became required."))
|
|
411
|
+
kind = _compare_value_space(a, b, "parameter")
|
|
412
|
+
if kind:
|
|
413
|
+
changes.append(_change(kind, path, method,
|
|
414
|
+
f"{key[0]} parameter '{key[1]}': {_describe(a)} -> {_describe(b)}."))
|
|
415
|
+
return changes
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _diff_request(op_a: dict[str, Any], op_b: dict[str, Any], doc_a: dict[str, Any], doc_b: dict[str, Any],
|
|
419
|
+
path: str, method: str) -> list[dict[str, Any]]:
|
|
420
|
+
schema_a = _json_schema(op_a.get("requestBody") or {}, doc_a)
|
|
421
|
+
schema_b = _json_schema(op_b.get("requestBody") or {}, doc_b)
|
|
422
|
+
if schema_a is None and schema_b is None:
|
|
423
|
+
return []
|
|
424
|
+
fields_a = flatten_schema(schema_a or {}, doc_a)
|
|
425
|
+
fields_b = flatten_schema(schema_b or {}, doc_b)
|
|
426
|
+
changes: list[dict[str, Any]] = []
|
|
427
|
+
for name in sorted(set(fields_a) - set(fields_b)):
|
|
428
|
+
changes.append(_change("request_field_removed", path, method, f"request field '{name}' was removed."))
|
|
429
|
+
for name in sorted(set(fields_b) - set(fields_a)):
|
|
430
|
+
kind = "request_field_added_required" if fields_b[name]["required"] else "request_field_added_optional"
|
|
431
|
+
changes.append(_change(kind, path, method, f"request field '{name}' was added."))
|
|
432
|
+
for name in sorted(set(fields_a) & set(fields_b)):
|
|
433
|
+
a, b = fields_a[name], fields_b[name]
|
|
434
|
+
if not a["required"] and b["required"]:
|
|
435
|
+
changes.append(_change("request_required_added", path, method, f"request field '{name}' became required."))
|
|
436
|
+
elif a["required"] and not b["required"]:
|
|
437
|
+
changes.append(_change("request_required_dropped", path, method, f"request field '{name}' is no longer required."))
|
|
438
|
+
kind = _compare_value_space(a, b, "request")
|
|
439
|
+
if kind:
|
|
440
|
+
changes.append(_change(kind, path, method, f"request field '{name}': {_describe(a)} -> {_describe(b)}."))
|
|
441
|
+
return changes
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _diff_responses(op_a: dict[str, Any], op_b: dict[str, Any], doc_a: dict[str, Any], doc_b: dict[str, Any],
|
|
445
|
+
path: str, method: str) -> list[dict[str, Any]]:
|
|
446
|
+
responses_a = {str(k): v for k, v in (op_a.get("responses") or {}).items()}
|
|
447
|
+
responses_b = {str(k): v for k, v in (op_b.get("responses") or {}).items()}
|
|
448
|
+
changes: list[dict[str, Any]] = []
|
|
449
|
+
|
|
450
|
+
for code in sorted(set(responses_a) - set(responses_b)):
|
|
451
|
+
changes.append(_change("status_code_removed", path, method, f"documented status {code} is gone."))
|
|
452
|
+
for code in sorted(set(responses_b) - set(responses_a)):
|
|
453
|
+
changes.append(_change("status_code_added", path, method, f"status {code} is documented in B only."))
|
|
454
|
+
|
|
455
|
+
for code in sorted(set(responses_a) & set(responses_b)):
|
|
456
|
+
schema_a = _json_schema(responses_a[code], doc_a)
|
|
457
|
+
schema_b = _json_schema(responses_b[code], doc_b)
|
|
458
|
+
if schema_a is None and schema_b is None:
|
|
459
|
+
continue
|
|
460
|
+
fields_a = flatten_schema(schema_a or {}, doc_a)
|
|
461
|
+
fields_b = flatten_schema(schema_b or {}, doc_b)
|
|
462
|
+
removed = sorted(set(fields_a) - set(fields_b))
|
|
463
|
+
added = sorted(set(fields_b) - set(fields_a))
|
|
464
|
+
renamed = _detect_renames(removed, added, fields_a, fields_b)
|
|
465
|
+
renamed_old = {old for old, _ in renamed}
|
|
466
|
+
renamed_new = {new for _, new in renamed}
|
|
467
|
+
|
|
468
|
+
for old, new in renamed:
|
|
469
|
+
changes.append(_change("response_field_renamed", path, method,
|
|
470
|
+
f"{code} response field '{old}' appears to have been renamed to '{new}'."))
|
|
471
|
+
for name in removed:
|
|
472
|
+
if name in renamed_old:
|
|
473
|
+
continue
|
|
474
|
+
qualifier = "required " if fields_a[name]["required"] else ""
|
|
475
|
+
changes.append(_change("response_field_removed", path, method,
|
|
476
|
+
f"{code} response is missing the {qualifier}field '{name}' the reference declares."))
|
|
477
|
+
for name in added:
|
|
478
|
+
if name in renamed_new:
|
|
479
|
+
continue
|
|
480
|
+
qualifier = "required" if fields_b[name]["required"] else "optional"
|
|
481
|
+
changes.append(_change("response_field_added", path, method,
|
|
482
|
+
f"{code} response gained the {qualifier} field '{name}'."))
|
|
483
|
+
for name in sorted(set(fields_a) & set(fields_b)):
|
|
484
|
+
a, b = fields_a[name], fields_b[name]
|
|
485
|
+
if a["required"] and not b["required"]:
|
|
486
|
+
changes.append(_change("response_required_dropped", path, method,
|
|
487
|
+
f"{code} response field '{name}' is no longer guaranteed present."))
|
|
488
|
+
elif not a["required"] and b["required"]:
|
|
489
|
+
changes.append(_change("response_required_added", path, method,
|
|
490
|
+
f"{code} response field '{name}' is now always present."))
|
|
491
|
+
kind = _compare_value_space(a, b, "response")
|
|
492
|
+
if kind:
|
|
493
|
+
changes.append(_change(kind, path, method,
|
|
494
|
+
f"{code} response field '{name}': {_describe(a)} -> {_describe(b)}."))
|
|
495
|
+
return changes
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def _describe(field: dict[str, Any]) -> str:
|
|
499
|
+
types = "/".join(sorted(field.get("types") or [])) or "any"
|
|
500
|
+
enum = field.get("enum")
|
|
501
|
+
if enum:
|
|
502
|
+
# Enum members are stored as canonical JSON so they can be set-compared;
|
|
503
|
+
# decode them again for a message a human reads.
|
|
504
|
+
values = sorted(str(json.loads(v)) for v in enum)
|
|
505
|
+
return f"{types} enum[{', '.join(values)}]"
|
|
506
|
+
return types
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
# --------------------------------------------------------------------------
|
|
510
|
+
# consumers and generated tests
|
|
511
|
+
# --------------------------------------------------------------------------
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def _split_endpoint(raw: str) -> tuple[str | None, str]:
|
|
515
|
+
"""'GET /v1/orders' -> ('GET', '/v1/orders'); a bare path -> (None, path)."""
|
|
516
|
+
parts = raw.strip().split()
|
|
517
|
+
if len(parts) == 2 and parts[0].upper() in {m.upper() for m in HTTP_METHODS}:
|
|
518
|
+
return parts[0].upper(), parts[1]
|
|
519
|
+
return None, parts[-1] if parts else raw.strip()
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def _search_terms(path: str) -> list[str]:
|
|
523
|
+
"""Literal needles for a templated path: the whole thing and its stable prefix."""
|
|
524
|
+
terms = {path}
|
|
525
|
+
head = path.split("{", 1)[0].rstrip("/")
|
|
526
|
+
if head and head != path:
|
|
527
|
+
terms.add(head)
|
|
528
|
+
return sorted(terms, key=len, reverse=True)
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _walk_sources(root: Path) -> Iterable[Path]:
|
|
532
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
533
|
+
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS and not d.startswith(".")]
|
|
534
|
+
for name in filenames:
|
|
535
|
+
if Path(name).suffix in CONSUMER_SUFFIXES:
|
|
536
|
+
yield Path(dirpath) / name
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def _identifier(method: str, path: str) -> str:
|
|
540
|
+
slug = re.sub(r"[^a-z0-9]+", "_", path.lower()).strip("_")
|
|
541
|
+
return f"{method.lower()}_{slug}" or "endpoint"
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _required_fields(schema: Any, doc: dict[str, Any]) -> tuple[list[str], list[str]]:
|
|
545
|
+
"""(top-level required fields, required fields of `items[]`) for a response schema."""
|
|
546
|
+
fields = flatten_schema(schema or {}, doc)
|
|
547
|
+
top = sorted(n for n, f in fields.items() if f["required"] and "." not in n and "[]" not in n)
|
|
548
|
+
item = sorted(
|
|
549
|
+
n.split("items[].", 1)[1]
|
|
550
|
+
for n, f in fields.items()
|
|
551
|
+
if f["required"] and n.startswith("items[].") and "." not in n.split("items[].", 1)[1]
|
|
552
|
+
)
|
|
553
|
+
return top, item
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
TEST_TEMPLATE = '''"""Contract test for {method} {path} — generated by CONDUIT from {spec_name}.
|
|
557
|
+
|
|
558
|
+
{why}
|
|
559
|
+
|
|
560
|
+
Runs against a live server: set QAAS_TARGET_BASE_URL (default {default_base}).
|
|
561
|
+
Supply QAAS_TARGET_TOKEN to skip the login round-trip. Standard library only, so
|
|
562
|
+
it runs anywhere pytest does — including in a fix branch's CI.
|
|
563
|
+
"""
|
|
564
|
+
|
|
565
|
+
import json
|
|
566
|
+
import os
|
|
567
|
+
import urllib.error
|
|
568
|
+
import urllib.request
|
|
569
|
+
|
|
570
|
+
import pytest
|
|
571
|
+
|
|
572
|
+
BASE_URL = os.environ.get("QAAS_TARGET_BASE_URL", "{default_base}").rstrip("/")
|
|
573
|
+
PATH = {path!r}
|
|
574
|
+
METHOD = {method!r}
|
|
575
|
+
EXPECTED_STATUS = {expected_status}
|
|
576
|
+
REQUIRED_TOP_LEVEL_FIELDS = {required_top!r}
|
|
577
|
+
REQUIRED_ITEM_FIELDS = {required_item!r}
|
|
578
|
+
AUTH_REQUIRED = {auth_required!r}
|
|
579
|
+
LOGIN_EMAIL = "admin@northwind.test"
|
|
580
|
+
LOGIN_PASSWORD = "password123"
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def _call(path, method="GET", token=None, body=None):
|
|
584
|
+
data = json.dumps(body).encode() if body is not None else None
|
|
585
|
+
headers = {{"Accept": "application/json"}}
|
|
586
|
+
if data is not None:
|
|
587
|
+
headers["Content-Type"] = "application/json"
|
|
588
|
+
if token:
|
|
589
|
+
headers["Authorization"] = "Bearer " + token
|
|
590
|
+
request = urllib.request.Request(BASE_URL + path, data=data, headers=headers, method=method)
|
|
591
|
+
try:
|
|
592
|
+
with urllib.request.urlopen(request, timeout=15) as resp:
|
|
593
|
+
return resp.status, resp.read().decode(errors="replace")
|
|
594
|
+
except urllib.error.HTTPError as exc:
|
|
595
|
+
return exc.code, exc.read().decode(errors="replace")
|
|
596
|
+
except (urllib.error.URLError, OSError) as exc:
|
|
597
|
+
pytest.fail("target app unreachable at " + BASE_URL + ": " + str(exc))
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
@pytest.fixture(scope="module")
|
|
601
|
+
def token():
|
|
602
|
+
if not AUTH_REQUIRED:
|
|
603
|
+
return None
|
|
604
|
+
preset = os.environ.get("QAAS_TARGET_TOKEN")
|
|
605
|
+
if preset:
|
|
606
|
+
return preset
|
|
607
|
+
status, body = _call("/v1/auth/login", "POST", body={{"email": LOGIN_EMAIL, "password": LOGIN_PASSWORD}})
|
|
608
|
+
assert status == 200, "could not log in to fetch a token: HTTP %s %s" % (status, body[:300])
|
|
609
|
+
return json.loads(body)["access_token"]
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
@pytest.fixture(scope="module")
|
|
613
|
+
def response(token):
|
|
614
|
+
status, body = _call(PATH, METHOD, token=token)
|
|
615
|
+
return status, body
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def test_status_code_matches_the_spec(response):
|
|
619
|
+
status, body = response
|
|
620
|
+
assert status == EXPECTED_STATUS, (
|
|
621
|
+
"%s %s: spec declares %s, server returned %s. Body: %s"
|
|
622
|
+
% (METHOD, PATH, EXPECTED_STATUS, status, body[:300])
|
|
623
|
+
)
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def test_response_is_json(response):
|
|
627
|
+
_, body = response
|
|
628
|
+
try:
|
|
629
|
+
json.loads(body)
|
|
630
|
+
except json.JSONDecodeError:
|
|
631
|
+
pytest.fail("%s %s did not return JSON: %s" % (METHOD, PATH, body[:300]))
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def test_required_response_fields_are_present(response):
|
|
635
|
+
if not REQUIRED_TOP_LEVEL_FIELDS:
|
|
636
|
+
pytest.skip("the spec declares no required top-level response fields")
|
|
637
|
+
_, body = response
|
|
638
|
+
payload = json.loads(body)
|
|
639
|
+
missing = [name for name in REQUIRED_TOP_LEVEL_FIELDS if name not in payload]
|
|
640
|
+
assert not missing, (
|
|
641
|
+
"%s %s response is missing spec-required field(s): %s"
|
|
642
|
+
% (METHOD, PATH, ", ".join(missing))
|
|
643
|
+
)
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def test_collection_items_carry_required_fields(response):
|
|
647
|
+
if not REQUIRED_ITEM_FIELDS:
|
|
648
|
+
pytest.skip("this response is not a collection with a declared item schema")
|
|
649
|
+
_, body = response
|
|
650
|
+
payload = json.loads(body)
|
|
651
|
+
items = payload.get("items") if isinstance(payload, dict) else None
|
|
652
|
+
if not items:
|
|
653
|
+
pytest.skip("no items returned; seed the fixture to exercise this assertion")
|
|
654
|
+
missing = sorted({{name for item in items for name in REQUIRED_ITEM_FIELDS if name not in item}})
|
|
655
|
+
assert not missing, (
|
|
656
|
+
"%s %s items are missing spec-required field(s): %s"
|
|
657
|
+
% (METHOD, PATH, ", ".join(missing))
|
|
658
|
+
)
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def test_endpoint_requires_authentication():
|
|
662
|
+
if not AUTH_REQUIRED:
|
|
663
|
+
pytest.skip("the spec marks this endpoint as public")
|
|
664
|
+
status, body = _call(PATH, METHOD)
|
|
665
|
+
assert status == 401, (
|
|
666
|
+
"%s %s is declared as authenticated but answered an anonymous call with %s"
|
|
667
|
+
% (METHOD, PATH, status)
|
|
668
|
+
)
|
|
669
|
+
'''
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
# --------------------------------------------------------------------------
|
|
673
|
+
# tools
|
|
674
|
+
# --------------------------------------------------------------------------
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def build_tools(ctx: ToolContext) -> list:
|
|
678
|
+
"""The contract_diff tools, bound to one agent's run context.
|
|
679
|
+
|
|
680
|
+
Split from `build` so tests can call the handlers directly without standing
|
|
681
|
+
up an MCP transport.
|
|
682
|
+
"""
|
|
683
|
+
|
|
684
|
+
# `layout.spec` has existed since the schema was written and nothing read
|
|
685
|
+
# it, so every target was assumed to keep an `openapi.yaml` at its root.
|
|
686
|
+
_profile = getattr(ctx.config, "profile", None)
|
|
687
|
+
_declared_spec = getattr(getattr(_profile, "layout", None), "spec", None) if _profile else None
|
|
688
|
+
default_spec = ctx.target_root / (_declared_spec or DEFAULT_SPEC_FILE)
|
|
689
|
+
_spec_label = str(_declared_spec or DEFAULT_SPEC_FILE)
|
|
690
|
+
|
|
691
|
+
@tool(
|
|
692
|
+
"diff_openapi",
|
|
693
|
+
"Compare two OpenAPI documents semantically. Defaults to the declared contract "
|
|
694
|
+
f"({_spec_label}) against the running app's /openapi.json, so a reported change "
|
|
695
|
+
"means the implementation disagrees with the spec. Pass file paths when the app is down.",
|
|
696
|
+
{
|
|
697
|
+
"type": "object",
|
|
698
|
+
"properties": {
|
|
699
|
+
"spec_a": {"type": "string", "description": "Reference spec: a path or an http(s) URL. Default: the declared openapi.yaml."},
|
|
700
|
+
"spec_b": {"type": "string", "description": "Spec under test. Default: the running app's /openapi.json."},
|
|
701
|
+
"only_breaking": {"type": "boolean", "description": "Return only changes classified breaking."},
|
|
702
|
+
},
|
|
703
|
+
},
|
|
704
|
+
)
|
|
705
|
+
async def diff_openapi(args: dict[str, Any]) -> dict[str, Any]:
|
|
706
|
+
ref_a = str(args.get("spec_a") or default_spec)
|
|
707
|
+
live_default = os.environ.get("QAAS_TARGET_BASE_URL")
|
|
708
|
+
live_default = f"{live_default.rstrip('/')}/openapi.json" if live_default else DEFAULT_LIVE_SPEC_URL
|
|
709
|
+
ref_b = str(args.get("spec_b") or live_default)
|
|
710
|
+
|
|
711
|
+
doc_a, problem = _read_spec(ref_a, ctx.target_root)
|
|
712
|
+
if problem:
|
|
713
|
+
return err(f"Could not load spec_a: {problem}")
|
|
714
|
+
doc_b, problem = _read_spec(ref_b, ctx.target_root)
|
|
715
|
+
if problem:
|
|
716
|
+
hint = (
|
|
717
|
+
" The target app does not appear to be running. Start it with env_control.spin_up, "
|
|
718
|
+
"or pass spec_b as a file path to compare two documents on disk."
|
|
719
|
+
if _is_url(ref_b) else ""
|
|
720
|
+
)
|
|
721
|
+
return err(f"Could not load spec_b: {problem}.{hint}")
|
|
722
|
+
|
|
723
|
+
assert doc_a is not None and doc_b is not None
|
|
724
|
+
try:
|
|
725
|
+
changes = diff_specs(doc_a, doc_b)
|
|
726
|
+
except RecursionError:
|
|
727
|
+
return err("The specs contain a recursion this differ cannot follow; compare a subset of paths.")
|
|
728
|
+
|
|
729
|
+
if args.get("only_breaking"):
|
|
730
|
+
changes = [c for c in changes if c["breaking"]]
|
|
731
|
+
breaking = [c for c in changes if c["breaking"]]
|
|
732
|
+
|
|
733
|
+
if not changes:
|
|
734
|
+
return ok(f"No semantic differences between {ref_a} and {ref_b}.", changes=[], breaking_count=0)
|
|
735
|
+
|
|
736
|
+
lines = [f"{'BREAKING' if c['breaking'] else 'compatible'} {c['method']} {c['path']} [{c['kind']}] {c['detail']}"
|
|
737
|
+
for c in changes[:120]]
|
|
738
|
+
more = f"\n(+{len(changes) - 120} more)" if len(changes) > 120 else ""
|
|
739
|
+
return ok(
|
|
740
|
+
f"{len(changes)} change(s), {len(breaking)} breaking, comparing {ref_a} (reference) "
|
|
741
|
+
f"against {ref_b}:\n" + "\n".join(lines) + more,
|
|
742
|
+
changes=changes, breaking_count=len(breaking), spec_a=ref_a, spec_b=ref_b,
|
|
743
|
+
)
|
|
744
|
+
|
|
745
|
+
@tool(
|
|
746
|
+
"classify_breaking",
|
|
747
|
+
"Classify one change as breaking, non_breaking or unknown, with the consumer-impact reason. "
|
|
748
|
+
"Pass a change object from diff_openapi.",
|
|
749
|
+
{
|
|
750
|
+
"type": "object",
|
|
751
|
+
"required": ["change"],
|
|
752
|
+
"properties": {
|
|
753
|
+
"change": {
|
|
754
|
+
"type": "object",
|
|
755
|
+
"description": "A change from diff_openapi: {kind, path, method, detail}.",
|
|
756
|
+
"properties": {
|
|
757
|
+
"kind": {"type": "string"},
|
|
758
|
+
"path": {"type": "string"},
|
|
759
|
+
"method": {"type": "string"},
|
|
760
|
+
"detail": {"type": "string"},
|
|
761
|
+
},
|
|
762
|
+
}
|
|
763
|
+
},
|
|
764
|
+
},
|
|
765
|
+
)
|
|
766
|
+
async def classify_breaking(args: dict[str, Any]) -> dict[str, Any]:
|
|
767
|
+
change = args.get("change")
|
|
768
|
+
if not isinstance(change, dict):
|
|
769
|
+
change = {k: v for k, v in args.items() if k != "change"}
|
|
770
|
+
kind = str(change.get("kind") or "").strip()
|
|
771
|
+
if not kind:
|
|
772
|
+
return err(
|
|
773
|
+
"The change has no 'kind'. Pass a change object exactly as diff_openapi returned it; "
|
|
774
|
+
f"recognised kinds are: {', '.join(sorted(RULES))}."
|
|
775
|
+
)
|
|
776
|
+
verdict, reason = RULES.get(kind, (
|
|
777
|
+
"unknown",
|
|
778
|
+
f"'{kind}' is not a kind this server emits, so its consumer impact cannot be decided here. "
|
|
779
|
+
"Judge it by hand, or re-run diff_openapi and classify one of its changes.",
|
|
780
|
+
))
|
|
781
|
+
where = f"{change.get('method', '')} {change.get('path', '')}".strip()
|
|
782
|
+
return ok(
|
|
783
|
+
f"{kind}{f' on {where}' if where else ''}: {verdict}. {reason}",
|
|
784
|
+
verdict=verdict, reason=reason, kind=kind, breaking=verdict == "breaking",
|
|
785
|
+
)
|
|
786
|
+
|
|
787
|
+
@tool(
|
|
788
|
+
"find_consumers",
|
|
789
|
+
"Find likely call sites of an endpoint in this repository, across its declared frontend roots. "
|
|
790
|
+
"Heuristic: it matches the literal path string and the stable prefix before any {param}, "
|
|
791
|
+
"so a client that assembles its URL from fragments will be missed and an unrelated string "
|
|
792
|
+
"that happens to contain the path will be reported.",
|
|
793
|
+
{
|
|
794
|
+
"type": "object",
|
|
795
|
+
"required": ["endpoint"],
|
|
796
|
+
"properties": {
|
|
797
|
+
"endpoint": {"type": "string", "description": "'GET /v1/orders' or just '/v1/orders'."},
|
|
798
|
+
"root": {"type": "string", "description": "Subdirectory to search, repo-relative. Default: the whole repo."},
|
|
799
|
+
},
|
|
800
|
+
},
|
|
801
|
+
)
|
|
802
|
+
async def find_consumers(args: dict[str, Any]) -> dict[str, Any]:
|
|
803
|
+
_, path = _split_endpoint(str(args["endpoint"]))
|
|
804
|
+
if not path.startswith("/"):
|
|
805
|
+
return err(f"'{args['endpoint']}' does not name a path. Use 'GET /v1/orders' or '/v1/orders'.")
|
|
806
|
+
|
|
807
|
+
root = ctx.target_root
|
|
808
|
+
if args.get("root"):
|
|
809
|
+
candidate = (ctx.target_root / str(args["root"])).resolve()
|
|
810
|
+
if not candidate.is_relative_to(ctx.target_root.resolve()):
|
|
811
|
+
return err("root must stay inside the repository.")
|
|
812
|
+
if not candidate.exists():
|
|
813
|
+
return err(f"No such directory: {candidate}.")
|
|
814
|
+
root = candidate
|
|
815
|
+
|
|
816
|
+
terms = _search_terms(path)
|
|
817
|
+
hits: list[dict[str, Any]] = []
|
|
818
|
+
for file in _walk_sources(root):
|
|
819
|
+
try:
|
|
820
|
+
text = file.read_text(errors="replace")
|
|
821
|
+
except OSError:
|
|
822
|
+
continue
|
|
823
|
+
if not any(term in text for term in terms):
|
|
824
|
+
continue
|
|
825
|
+
for number, line in enumerate(text.splitlines(), start=1):
|
|
826
|
+
if any(term in line for term in terms):
|
|
827
|
+
hits.append({
|
|
828
|
+
"file": str(file.relative_to(ctx.target_root)) if file.is_relative_to(ctx.target_root) else str(file),
|
|
829
|
+
"line": number,
|
|
830
|
+
"text": line.strip()[:200],
|
|
831
|
+
})
|
|
832
|
+
if len(hits) >= MAX_CONSUMER_HITS:
|
|
833
|
+
break
|
|
834
|
+
if len(hits) >= MAX_CONSUMER_HITS:
|
|
835
|
+
break
|
|
836
|
+
|
|
837
|
+
if not hits:
|
|
838
|
+
return ok(
|
|
839
|
+
f"No call sites matched {path} under {root}. That is weak evidence: a client that builds "
|
|
840
|
+
"the URL from fragments would not match. Check by hand before claiming no consumers exist.",
|
|
841
|
+
consumers=[], searched=str(root), terms=terms,
|
|
842
|
+
)
|
|
843
|
+
listing = "\n".join(f"{h['file']}:{h['line']} {h['text']}" for h in hits)
|
|
844
|
+
return ok(f"{len(hits)} possible call site(s) for {path} (heuristic):\n{listing}",
|
|
845
|
+
consumers=hits, searched=str(root), terms=terms)
|
|
846
|
+
|
|
847
|
+
@tool(
|
|
848
|
+
"generate_contract_test",
|
|
849
|
+
"Emit a runnable pytest module asserting the spec's contract for one endpoint: status code, "
|
|
850
|
+
"required response fields, and the auth requirement. Written under .qaas/generated/ and stored "
|
|
851
|
+
"as an artifact so the envelope can cite it. Run it before filing — a contract test that does "
|
|
852
|
+
"not fail against the current implementation is not evidence.",
|
|
853
|
+
{
|
|
854
|
+
"type": "object",
|
|
855
|
+
"required": ["endpoint", "method"],
|
|
856
|
+
"properties": {
|
|
857
|
+
"endpoint": {"type": "string", "description": "Spec path, e.g. '/v1/invoices'."},
|
|
858
|
+
"method": {"type": "string", "description": "HTTP method, e.g. 'GET'."},
|
|
859
|
+
"expectation": {"type": "string", "description": "Why you are generating it — what you expect to break. Recorded in the test's docstring."},
|
|
860
|
+
"spec": {"type": "string", "description": f"Spec to read the contract from. Default: {_spec_label}."},
|
|
861
|
+
"expected_status": {"type": "integer", "description": "Override the success status. Default: the first 2xx in the spec."},
|
|
862
|
+
},
|
|
863
|
+
},
|
|
864
|
+
)
|
|
865
|
+
async def generate_contract_test(args: dict[str, Any]) -> dict[str, Any]:
|
|
866
|
+
ref = str(args.get("spec") or default_spec)
|
|
867
|
+
doc, problem = _read_spec(ref, ctx.target_root)
|
|
868
|
+
if problem:
|
|
869
|
+
return err(f"Could not load the spec: {problem}")
|
|
870
|
+
assert doc is not None
|
|
871
|
+
|
|
872
|
+
_, path = _split_endpoint(str(args["endpoint"]))
|
|
873
|
+
method = str(args["method"]).upper()
|
|
874
|
+
if "{" in path:
|
|
875
|
+
return err(
|
|
876
|
+
f"{path} has path parameters this generator cannot fill in — it would have to invent "
|
|
877
|
+
"an id, and a test that 404s proves nothing. Generate a test for a collection endpoint, "
|
|
878
|
+
"or write the parameterised case by hand."
|
|
879
|
+
)
|
|
880
|
+
ops = _operations(doc)
|
|
881
|
+
op = ops.get((path, method))
|
|
882
|
+
if op is None:
|
|
883
|
+
known = ", ".join(f"{m} {p}" for p, m in sorted(ops)[:25])
|
|
884
|
+
return err(f"{method} {path} is not in {ref}. It declares: {known}")
|
|
885
|
+
|
|
886
|
+
responses = {str(k): v for k, v in (op.get("responses") or {}).items()}
|
|
887
|
+
override = args.get("expected_status")
|
|
888
|
+
if override is not None:
|
|
889
|
+
expected = int(override)
|
|
890
|
+
if str(expected) not in responses:
|
|
891
|
+
return err(f"{method} {path} does not declare status {expected}; it declares {', '.join(sorted(responses))}.")
|
|
892
|
+
else:
|
|
893
|
+
success = sorted(c for c in responses if c.isdigit() and 200 <= int(c) < 300)
|
|
894
|
+
if not success:
|
|
895
|
+
return err(f"{method} {path} declares no 2xx response, so there is no success contract to assert.")
|
|
896
|
+
expected = int(success[0])
|
|
897
|
+
|
|
898
|
+
required_top, required_item = _required_fields(_json_schema(responses[str(expected)], doc), doc)
|
|
899
|
+
source = TEST_TEMPLATE.format(
|
|
900
|
+
method=method,
|
|
901
|
+
path=path,
|
|
902
|
+
spec_name=Path(ref).name if not _is_url(ref) else ref,
|
|
903
|
+
# The expectation is free text from an agent and lands inside a
|
|
904
|
+
# docstring, so a stray triple quote would produce a file that will
|
|
905
|
+
# not import. Neutralise it rather than reject the call.
|
|
906
|
+
why=str(args.get("expectation") or "Asserts the published contract for this endpoint.").strip().replace('"""', "'''").replace("\\", "/"),
|
|
907
|
+
default_base=os.environ.get("QAAS_TARGET_BASE_URL", "http://localhost:8000").rstrip("/"),
|
|
908
|
+
expected_status=expected,
|
|
909
|
+
required_top=required_top,
|
|
910
|
+
required_item=required_item,
|
|
911
|
+
auth_required=_requires_auth(op, doc),
|
|
912
|
+
)
|
|
913
|
+
|
|
914
|
+
out_dir = ctx.store.root / GENERATED_DIR
|
|
915
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
916
|
+
filename = f"test_contract_{_identifier(method, path)}.py"
|
|
917
|
+
target = out_dir / filename
|
|
918
|
+
target.write_text(source)
|
|
919
|
+
uri = ctx.store.put_artifact(filename, source)
|
|
920
|
+
ctx.store.log("contract_test", agent=ctx.agent.name, endpoint=f"{method} {path}", path=str(target))
|
|
921
|
+
|
|
922
|
+
return ok(
|
|
923
|
+
f"Wrote {target}. It asserts HTTP {expected}"
|
|
924
|
+
+ (f", required response fields {', '.join(required_top)}" if required_top else "")
|
|
925
|
+
+ (f", required item fields {', '.join(required_item)}" if required_item else "")
|
|
926
|
+
+ (", and that anonymous calls get 401" if _requires_auth(op, doc) else "")
|
|
927
|
+
+ f". Run it with QAAS_TARGET_BASE_URL set, then cite {uri} as evidence.\n\n{source}",
|
|
928
|
+
path=str(target), uri=uri, source=source, expected_status=expected,
|
|
929
|
+
required_fields=required_top, required_item_fields=required_item,
|
|
930
|
+
)
|
|
931
|
+
|
|
932
|
+
return [diff_openapi, classify_breaking, find_consumers, generate_contract_test]
|
|
933
|
+
|
|
934
|
+
|
|
935
|
+
def build(ctx: ToolContext):
|
|
936
|
+
"""Construct the contract_diff MCP server bound to one agent's run context."""
|
|
937
|
+
return create_sdk_mcp_server(name="contract_diff", version="1.0.0", tools=build_tools(ctx))
|