openapi-agent-ready 0.3.0__tar.gz → 0.4.0__tar.gz
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.
- {openapi_agent_ready-0.3.0 → openapi_agent_ready-0.4.0}/CHANGELOG.md +15 -0
- {openapi_agent_ready-0.3.0 → openapi_agent_ready-0.4.0}/PKG-INFO +38 -7
- {openapi_agent_ready-0.3.0 → openapi_agent_ready-0.4.0}/README.md +37 -6
- {openapi_agent_ready-0.3.0 → openapi_agent_ready-0.4.0}/pyproject.toml +1 -1
- openapi_agent_ready-0.4.0/src/agent_ready/baseline.py +137 -0
- {openapi_agent_ready-0.3.0 → openapi_agent_ready-0.4.0}/src/agent_ready/cli.py +80 -1
- openapi_agent_ready-0.4.0/tests/test_baseline.py +306 -0
- {openapi_agent_ready-0.3.0 → openapi_agent_ready-0.4.0}/.github/workflows/ci.yml +0 -0
- {openapi_agent_ready-0.3.0 → openapi_agent_ready-0.4.0}/.github/workflows/publish.yml +0 -0
- {openapi_agent_ready-0.3.0 → openapi_agent_ready-0.4.0}/.gitignore +0 -0
- {openapi_agent_ready-0.3.0 → openapi_agent_ready-0.4.0}/CONTRIBUTING.md +0 -0
- {openapi_agent_ready-0.3.0 → openapi_agent_ready-0.4.0}/LICENSE +0 -0
- {openapi_agent_ready-0.3.0 → openapi_agent_ready-0.4.0}/examples/agent_ready_booking_api.yaml +0 -0
- {openapi_agent_ready-0.3.0 → openapi_agent_ready-0.4.0}/examples/sample_booking_api.yaml +0 -0
- {openapi_agent_ready-0.3.0 → openapi_agent_ready-0.4.0}/openapi.yaml +0 -0
- {openapi_agent_ready-0.3.0 → openapi_agent_ready-0.4.0}/src/agent_ready/__init__.py +0 -0
- {openapi_agent_ready-0.3.0 → openapi_agent_ready-0.4.0}/src/agent_ready/auditor.py +0 -0
- {openapi_agent_ready-0.3.0 → openapi_agent_ready-0.4.0}/src/agent_ready/mcp_generator.py +0 -0
- {openapi_agent_ready-0.3.0 → openapi_agent_ready-0.4.0}/src/agent_ready/report_generator.py +0 -0
- {openapi_agent_ready-0.3.0 → openapi_agent_ready-0.4.0}/src/agent_ready/rubric.py +0 -0
- {openapi_agent_ready-0.3.0 → openapi_agent_ready-0.4.0}/tests/test_audit.py +0 -0
- {openapi_agent_ready-0.3.0 → openapi_agent_ready-0.4.0}/tests/test_mcp_conformance.py +0 -0
|
@@ -5,6 +5,21 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.4.0] - 2026-08-08
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- **Baseline mode** (`--write-baseline`, `--baseline`, `--max-new`). A mature
|
|
12
|
+
API audited for the first time produces hundreds of findings, so a score gate
|
|
13
|
+
is unusable on day one and gets switched off. Recording the current state lets
|
|
14
|
+
CI block only regressions while the backlog stays visible.
|
|
15
|
+
|
|
16
|
+
Findings are matched on category, endpoint, and severity rather than message
|
|
17
|
+
text — messages embed counts and parameter names ("3/3 parameters have no
|
|
18
|
+
description (a, b, c)") that shift when unrelated things change, which would
|
|
19
|
+
report unfixed problems as new. Severity is part of the identity so a warning
|
|
20
|
+
escalating to a fail is caught; message is excluded so rewording a finding in
|
|
21
|
+
a future release doesn't invalidate stored baselines.
|
|
22
|
+
|
|
8
23
|
## [0.3.0] - 2026-07-31
|
|
9
24
|
|
|
10
25
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: openapi-agent-ready
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Audit any OpenAPI spec for AI-agent and MCP readiness, and generate an MCP server scaffold.
|
|
5
5
|
Project-URL: Homepage, https://github.com/sfaisal/agent-ready
|
|
6
6
|
Project-URL: Issues, https://github.com/sfaisal/agent-ready/issues
|
|
@@ -111,11 +111,9 @@ directly onto the rubric:
|
|
|
111
111
|
| **Frictionless authentication** | 4 (auth/scope clarity) |
|
|
112
112
|
| **Structured error handling** | 3 (actionable errors) |
|
|
113
113
|
|
|
114
|
-
Categories 2 (side effects) and 5 (ambiguity)
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
failures as often as the original three. Category 8 came later still, from
|
|
118
|
-
OpenAI's function-calling guidance.
|
|
114
|
+
Categories 2 (side effects) and 5 (ambiguity) were added during implementation
|
|
115
|
+
— they turned out to matter as much as the original three, which is the usual
|
|
116
|
+
reward for actually building the thing you wrote about.
|
|
119
117
|
|
|
120
118
|
The gap is measurable, and enormous. A 2026 study of 856 tools across 103 MCP
|
|
121
119
|
servers found that **97.1% of tool descriptions contained at least one quality
|
|
@@ -185,9 +183,42 @@ Exit codes:
|
|
|
185
183
|
| Code | Meaning |
|
|
186
184
|
|---|---|
|
|
187
185
|
| `0` | All configured gates passed |
|
|
188
|
-
| `1` | A gate failed (`--min-score` / `--max-fails`) |
|
|
186
|
+
| `1` | A gate failed (`--min-score` / `--max-fails` / `--max-new`) |
|
|
189
187
|
| `2` | The spec could not be loaded or parsed |
|
|
190
188
|
|
|
189
|
+
### Adopting on an existing API
|
|
190
|
+
|
|
191
|
+
A mature API audited for the first time will produce hundreds of findings. No
|
|
192
|
+
team can fix all of them before merging anything, so a score gate is unusable
|
|
193
|
+
on day one and gets switched off within a week.
|
|
194
|
+
|
|
195
|
+
Record the current state instead, and block only what gets worse:
|
|
196
|
+
|
|
197
|
+
```bash
|
|
198
|
+
# once, when you adopt the tool — commit this file
|
|
199
|
+
agent-ready openapi.yaml --write-baseline .agent-ready-baseline.json
|
|
200
|
+
|
|
201
|
+
# in CI, from then on
|
|
202
|
+
agent-ready openapi.yaml --baseline .agent-ready-baseline.json --max-new 0
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
Existing findings stay visible in the report but don't fail the build. A pull
|
|
206
|
+
request that introduces a new one does, and the output names exactly which
|
|
207
|
+
findings it added:
|
|
208
|
+
|
|
209
|
+
```
|
|
210
|
+
Compared against .agent-ready-baseline.json: 5 new, 0 fixed, 11 unchanged
|
|
211
|
+
NEW FAIL GET /invoices — Description is a bare label (12 chars) ...
|
|
212
|
+
NEW FAIL GET /invoices — No error responses documented ...
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
Findings are matched on category, endpoint, and severity — not on message text,
|
|
216
|
+
which contains counts and parameter names that shift when unrelated things
|
|
217
|
+
change. A warning escalating to a fail counts as new; rewording a finding in a
|
|
218
|
+
future release does not invalidate your baseline.
|
|
219
|
+
|
|
220
|
+
As the team fixes the backlog, regenerate the baseline to lock in the progress.
|
|
221
|
+
|
|
191
222
|
## Real-world results
|
|
192
223
|
|
|
193
224
|
Running it against Stripe's production spec (587 endpoints), as of v0.1.0.
|
|
@@ -56,11 +56,9 @@ directly onto the rubric:
|
|
|
56
56
|
| **Frictionless authentication** | 4 (auth/scope clarity) |
|
|
57
57
|
| **Structured error handling** | 3 (actionable errors) |
|
|
58
58
|
|
|
59
|
-
Categories 2 (side effects) and 5 (ambiguity)
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
failures as often as the original three. Category 8 came later still, from
|
|
63
|
-
OpenAI's function-calling guidance.
|
|
59
|
+
Categories 2 (side effects) and 5 (ambiguity) were added during implementation
|
|
60
|
+
— they turned out to matter as much as the original three, which is the usual
|
|
61
|
+
reward for actually building the thing you wrote about.
|
|
64
62
|
|
|
65
63
|
The gap is measurable, and enormous. A 2026 study of 856 tools across 103 MCP
|
|
66
64
|
servers found that **97.1% of tool descriptions contained at least one quality
|
|
@@ -130,9 +128,42 @@ Exit codes:
|
|
|
130
128
|
| Code | Meaning |
|
|
131
129
|
|---|---|
|
|
132
130
|
| `0` | All configured gates passed |
|
|
133
|
-
| `1` | A gate failed (`--min-score` / `--max-fails`) |
|
|
131
|
+
| `1` | A gate failed (`--min-score` / `--max-fails` / `--max-new`) |
|
|
134
132
|
| `2` | The spec could not be loaded or parsed |
|
|
135
133
|
|
|
134
|
+
### Adopting on an existing API
|
|
135
|
+
|
|
136
|
+
A mature API audited for the first time will produce hundreds of findings. No
|
|
137
|
+
team can fix all of them before merging anything, so a score gate is unusable
|
|
138
|
+
on day one and gets switched off within a week.
|
|
139
|
+
|
|
140
|
+
Record the current state instead, and block only what gets worse:
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
# once, when you adopt the tool — commit this file
|
|
144
|
+
agent-ready openapi.yaml --write-baseline .agent-ready-baseline.json
|
|
145
|
+
|
|
146
|
+
# in CI, from then on
|
|
147
|
+
agent-ready openapi.yaml --baseline .agent-ready-baseline.json --max-new 0
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Existing findings stay visible in the report but don't fail the build. A pull
|
|
151
|
+
request that introduces a new one does, and the output names exactly which
|
|
152
|
+
findings it added:
|
|
153
|
+
|
|
154
|
+
```
|
|
155
|
+
Compared against .agent-ready-baseline.json: 5 new, 0 fixed, 11 unchanged
|
|
156
|
+
NEW FAIL GET /invoices — Description is a bare label (12 chars) ...
|
|
157
|
+
NEW FAIL GET /invoices — No error responses documented ...
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Findings are matched on category, endpoint, and severity — not on message text,
|
|
161
|
+
which contains counts and parameter names that shift when unrelated things
|
|
162
|
+
change. A warning escalating to a fail counts as new; rewording a finding in a
|
|
163
|
+
future release does not invalidate your baseline.
|
|
164
|
+
|
|
165
|
+
As the team fixes the backlog, regenerate the baseline to lock in the progress.
|
|
166
|
+
|
|
136
167
|
## Real-world results
|
|
137
168
|
|
|
138
169
|
Running it against Stripe's production spec (587 endpoints), as of v0.1.0.
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "openapi-agent-ready"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4.0"
|
|
8
8
|
description = "Audit any OpenAPI spec for AI-agent and MCP readiness, and generate an MCP server scaffold."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Baseline support: fail CI only on *new* findings.
|
|
3
|
+
|
|
4
|
+
The problem this solves: a mature API audited for the first time produces
|
|
5
|
+
hundreds of findings. A team cannot fix them all before merging anything, so
|
|
6
|
+
`--min-score` and `--max-fails` are unusable as gates and get switched off
|
|
7
|
+
within a week. A baseline records the current state and lets the gate fail only
|
|
8
|
+
when something gets worse.
|
|
9
|
+
|
|
10
|
+
The whole design rests on one question: what makes a finding "the same finding"
|
|
11
|
+
across two runs? Message text is the obvious answer and the wrong one — it
|
|
12
|
+
embeds counts and parameter names ("3/3 parameters have no description
|
|
13
|
+
(date_from, date_to, location)") that change when an unrelated parameter is
|
|
14
|
+
added, which would report a fixed finding as new.
|
|
15
|
+
|
|
16
|
+
So findings are fingerprinted on (category, endpoint, severity) instead:
|
|
17
|
+
|
|
18
|
+
- **category** — which rule fired.
|
|
19
|
+
- **endpoint** — where. Stable across edits to the endpoint's content.
|
|
20
|
+
- **severity** — a warning becoming a fail is a regression worth catching, so
|
|
21
|
+
it belongs in the identity rather than being treated as the same finding.
|
|
22
|
+
|
|
23
|
+
Message is deliberately excluded. Rewording a finding's text in a future
|
|
24
|
+
release must not invalidate every stored baseline.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
from collections import Counter
|
|
29
|
+
from dataclasses import asdict
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
BASELINE_VERSION = 1
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class BaselineError(Exception):
|
|
36
|
+
"""Raised when a baseline file can't be read or is malformed."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def fingerprint(finding) -> str:
|
|
40
|
+
"""A stable identity for a finding across runs. See module docstring."""
|
|
41
|
+
return f"{finding.category}|{finding.endpoint}|{finding.severity}"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _counted(findings) -> Counter:
|
|
45
|
+
"""
|
|
46
|
+
Fingerprints with multiplicity.
|
|
47
|
+
|
|
48
|
+
A single endpoint can legitimately produce two findings in one category —
|
|
49
|
+
for example a parameter-explanation fail for undocumented parameters plus a
|
|
50
|
+
warning for a missing date format. Counting rather than set-membership means
|
|
51
|
+
going from one such finding to two is correctly reported as new.
|
|
52
|
+
"""
|
|
53
|
+
return Counter(fingerprint(f) for f in findings)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def write_baseline(path: str, spec: dict, result: dict) -> dict:
|
|
57
|
+
"""Record the current findings so future runs can compare against them."""
|
|
58
|
+
info = spec.get("info") or {}
|
|
59
|
+
payload = {
|
|
60
|
+
"baseline_version": BASELINE_VERSION,
|
|
61
|
+
"api": {
|
|
62
|
+
"title": info.get("title", "Untitled API"),
|
|
63
|
+
"version": str(info.get("version", "unknown")),
|
|
64
|
+
},
|
|
65
|
+
"overall_score": result["overall_score"],
|
|
66
|
+
"endpoint_count": result["endpoint_count"],
|
|
67
|
+
"fingerprints": sorted(_counted(result["gaps"]).elements()),
|
|
68
|
+
# Stored for human readability when reviewing a baseline diff; never
|
|
69
|
+
# read back during comparison.
|
|
70
|
+
"findings": [asdict(f) for f in result["gaps"]],
|
|
71
|
+
}
|
|
72
|
+
Path(path).write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
|
73
|
+
return payload
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def load_baseline(path: str) -> dict:
|
|
77
|
+
p = Path(path)
|
|
78
|
+
if not p.exists():
|
|
79
|
+
raise BaselineError(
|
|
80
|
+
f"Baseline file not found: {path}\n"
|
|
81
|
+
f"Create one from the current state with: --write-baseline {path}"
|
|
82
|
+
)
|
|
83
|
+
try:
|
|
84
|
+
data = json.loads(p.read_text(encoding="utf-8"))
|
|
85
|
+
except json.JSONDecodeError as exc:
|
|
86
|
+
raise BaselineError(f"Baseline file is not valid JSON: {exc}") from exc
|
|
87
|
+
|
|
88
|
+
if not isinstance(data, dict) or "fingerprints" not in data:
|
|
89
|
+
raise BaselineError(
|
|
90
|
+
f"{path} does not look like an agent-ready baseline "
|
|
91
|
+
"(no 'fingerprints' key)."
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
version = data.get("baseline_version", 0)
|
|
95
|
+
if version > BASELINE_VERSION:
|
|
96
|
+
raise BaselineError(
|
|
97
|
+
f"Baseline was written by a newer version of agent-ready "
|
|
98
|
+
f"(format v{version}, this build understands v{BASELINE_VERSION}). "
|
|
99
|
+
"Upgrade agent-ready or regenerate the baseline."
|
|
100
|
+
)
|
|
101
|
+
return data
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def compare_to_baseline(result: dict, baseline: dict) -> dict:
|
|
105
|
+
"""
|
|
106
|
+
Classify current findings against a baseline.
|
|
107
|
+
|
|
108
|
+
Returns counts plus the actual new findings, so the CLI can show a team
|
|
109
|
+
exactly what their change introduced rather than the whole backlog.
|
|
110
|
+
"""
|
|
111
|
+
baseline_counts = Counter(baseline.get("fingerprints", []))
|
|
112
|
+
current_counts = _counted(result["gaps"])
|
|
113
|
+
|
|
114
|
+
# A fingerprint appearing more often than the baseline recorded is new,
|
|
115
|
+
# by the excess amount.
|
|
116
|
+
new_counts = current_counts - baseline_counts
|
|
117
|
+
fixed_counts = baseline_counts - current_counts
|
|
118
|
+
|
|
119
|
+
# Map fingerprints back to findings so the report can name them. Consume
|
|
120
|
+
# the allowance so duplicates beyond it are still reported.
|
|
121
|
+
remaining = Counter(new_counts)
|
|
122
|
+
new_findings = []
|
|
123
|
+
for f in result["gaps"]:
|
|
124
|
+
fp = fingerprint(f)
|
|
125
|
+
if remaining.get(fp, 0) > 0:
|
|
126
|
+
new_findings.append(f)
|
|
127
|
+
remaining[fp] -= 1
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
"new_findings": new_findings,
|
|
131
|
+
"new_count": sum(new_counts.values()),
|
|
132
|
+
"fixed_count": sum(fixed_counts.values()),
|
|
133
|
+
"unchanged_count": sum((current_counts & baseline_counts).values()),
|
|
134
|
+
"new_fails": sum(1 for f in new_findings if f.severity == "fail"),
|
|
135
|
+
"baseline_score": baseline.get("overall_score"),
|
|
136
|
+
"current_score": result["overall_score"],
|
|
137
|
+
}
|
|
@@ -21,6 +21,12 @@ from .auditor import (
|
|
|
21
21
|
result_to_dict,
|
|
22
22
|
run_audit,
|
|
23
23
|
)
|
|
24
|
+
from .baseline import (
|
|
25
|
+
BaselineError,
|
|
26
|
+
compare_to_baseline,
|
|
27
|
+
load_baseline,
|
|
28
|
+
write_baseline,
|
|
29
|
+
)
|
|
24
30
|
from .mcp_generator import generate_mcp_scaffold
|
|
25
31
|
from .report_generator import generate_report
|
|
26
32
|
|
|
@@ -38,7 +44,12 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
38
44
|
),
|
|
39
45
|
epilog=(
|
|
40
46
|
"CI example:\n"
|
|
41
|
-
" agent-ready openapi.yaml --min-score
|
|
47
|
+
" agent-ready openapi.yaml --min-score 60 --max-fails 0 --quiet\n"
|
|
48
|
+
"\n"
|
|
49
|
+
"Adopting on an existing API, where fixing everything first is not\n"
|
|
50
|
+
"realistic — record today's findings, then block only regressions:\n"
|
|
51
|
+
" agent-ready openapi.yaml --write-baseline .agent-ready-baseline.json\n"
|
|
52
|
+
" agent-ready openapi.yaml --baseline .agent-ready-baseline.json --max-new 0\n"
|
|
42
53
|
),
|
|
43
54
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
44
55
|
)
|
|
@@ -60,6 +71,31 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
60
71
|
type=int,
|
|
61
72
|
help="Exit 1 if the number of FAIL findings exceeds this value (CI gate)",
|
|
62
73
|
)
|
|
74
|
+
parser.add_argument(
|
|
75
|
+
"--write-baseline",
|
|
76
|
+
metavar="PATH",
|
|
77
|
+
help=(
|
|
78
|
+
"Record current findings to PATH as an accepted baseline. Run this "
|
|
79
|
+
"once when adopting the tool on an existing API."
|
|
80
|
+
),
|
|
81
|
+
)
|
|
82
|
+
parser.add_argument(
|
|
83
|
+
"--baseline",
|
|
84
|
+
metavar="PATH",
|
|
85
|
+
help=(
|
|
86
|
+
"Compare against a baseline file and report what changed. Combine "
|
|
87
|
+
"with --max-new to fail only on regressions."
|
|
88
|
+
),
|
|
89
|
+
)
|
|
90
|
+
parser.add_argument(
|
|
91
|
+
"--max-new",
|
|
92
|
+
type=int,
|
|
93
|
+
metavar="N",
|
|
94
|
+
help=(
|
|
95
|
+
"Exit 1 if more than N findings are new since --baseline. "
|
|
96
|
+
"Use --max-new 0 to block any regression."
|
|
97
|
+
),
|
|
98
|
+
)
|
|
63
99
|
parser.add_argument(
|
|
64
100
|
"--generate-mcp",
|
|
65
101
|
metavar="PATH",
|
|
@@ -185,6 +221,37 @@ def _run(argv=None) -> int:
|
|
|
185
221
|
f"({len(ready)}/{result['endpoint_count']} endpoints included)"
|
|
186
222
|
)
|
|
187
223
|
|
|
224
|
+
# Baseline: write, or compare and gate on regressions only.
|
|
225
|
+
if args.write_baseline:
|
|
226
|
+
write_baseline(args.write_baseline, spec, result)
|
|
227
|
+
if not args.quiet:
|
|
228
|
+
print(
|
|
229
|
+
f"Baseline written to {args.write_baseline} "
|
|
230
|
+
f"({len(result['gaps'])} finding(s) recorded as accepted)"
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
comparison = None
|
|
234
|
+
if args.baseline:
|
|
235
|
+
try:
|
|
236
|
+
comparison = compare_to_baseline(result, load_baseline(args.baseline))
|
|
237
|
+
except BaselineError as exc:
|
|
238
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
239
|
+
return EXIT_SPEC_ERROR
|
|
240
|
+
|
|
241
|
+
if not args.quiet:
|
|
242
|
+
print()
|
|
243
|
+
print(
|
|
244
|
+
f"Compared against {args.baseline}: "
|
|
245
|
+
f"{comparison['new_count']} new, "
|
|
246
|
+
f"{comparison['fixed_count']} fixed, "
|
|
247
|
+
f"{comparison['unchanged_count']} unchanged"
|
|
248
|
+
)
|
|
249
|
+
for f in comparison["new_findings"][:20]:
|
|
250
|
+
marker = "FAIL" if f.severity == "fail" else "WARN"
|
|
251
|
+
print(f" NEW {marker} {f.endpoint} — {f.message}")
|
|
252
|
+
if len(comparison["new_findings"]) > 20:
|
|
253
|
+
print(f" ... and {len(comparison['new_findings']) - 20} more")
|
|
254
|
+
|
|
188
255
|
# CI gates
|
|
189
256
|
gate_failed = False
|
|
190
257
|
if args.min_score is not None and result["overall_score"] < args.min_score:
|
|
@@ -202,6 +269,18 @@ def _run(argv=None) -> int:
|
|
|
202
269
|
)
|
|
203
270
|
gate_failed = True
|
|
204
271
|
|
|
272
|
+
if (
|
|
273
|
+
comparison is not None
|
|
274
|
+
and args.max_new is not None
|
|
275
|
+
and comparison["new_count"] > args.max_new
|
|
276
|
+
):
|
|
277
|
+
print(
|
|
278
|
+
f"GATE FAILED: {comparison['new_count']} new finding(s) since "
|
|
279
|
+
f"baseline exceeds maximum {args.max_new}",
|
|
280
|
+
file=sys.stderr,
|
|
281
|
+
)
|
|
282
|
+
gate_failed = True
|
|
283
|
+
|
|
205
284
|
return EXIT_GATE_FAILED if gate_failed else EXIT_OK
|
|
206
285
|
|
|
207
286
|
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for baseline mode.
|
|
3
|
+
|
|
4
|
+
The behaviour that matters here is not "does it diff two lists" — it is whether
|
|
5
|
+
the notion of finding identity survives the edits a real team makes. Each test
|
|
6
|
+
below corresponds to an edit that would produce a false result under a naive
|
|
7
|
+
implementation.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
|
|
15
|
+
from agent_ready.auditor import load_spec, run_audit
|
|
16
|
+
from agent_ready.baseline import (
|
|
17
|
+
BaselineError,
|
|
18
|
+
compare_to_baseline,
|
|
19
|
+
fingerprint,
|
|
20
|
+
load_baseline,
|
|
21
|
+
write_baseline,
|
|
22
|
+
)
|
|
23
|
+
from agent_ready.cli import main
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _spec(paths):
|
|
27
|
+
return {"openapi": "3.0.0", "info": {"title": "T", "version": "1"}, "paths": paths}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
WEAK_ENDPOINT = {
|
|
31
|
+
"get": {
|
|
32
|
+
"summary": "Get things",
|
|
33
|
+
"description": "",
|
|
34
|
+
"parameters": [{"name": "q", "in": "query", "schema": {"type": "string"}}],
|
|
35
|
+
"responses": {"200": {"description": "OK"}},
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
STRONG_ENDPOINT = {
|
|
40
|
+
"get": {
|
|
41
|
+
"summary": "List things",
|
|
42
|
+
"description": (
|
|
43
|
+
"Returns every thing in the account with its identifier and current "
|
|
44
|
+
"status. This is a read-only lookup and does not modify data. Use "
|
|
45
|
+
"this when you need to find a thing before acting on it."
|
|
46
|
+
),
|
|
47
|
+
"parameters": [
|
|
48
|
+
{
|
|
49
|
+
"name": "q",
|
|
50
|
+
"in": "query",
|
|
51
|
+
"description": "Free-text search over thing names.",
|
|
52
|
+
"schema": {"type": "string"},
|
|
53
|
+
}
|
|
54
|
+
],
|
|
55
|
+
"responses": {
|
|
56
|
+
"200": {"description": "A list of matching thing records."},
|
|
57
|
+
"400": {
|
|
58
|
+
"description": "The query was malformed; q must be non-empty.",
|
|
59
|
+
"content": {"application/json": {"schema": {"type": "object"}}},
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _baseline_for(spec, tmp_path, name="base.json"):
|
|
67
|
+
path = tmp_path / name
|
|
68
|
+
write_baseline(str(path), spec, run_audit(spec))
|
|
69
|
+
return str(path)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# --------------------------------------------------------------------------
|
|
73
|
+
# Core behaviour
|
|
74
|
+
# --------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
def test_unchanged_spec_reports_no_new_findings(tmp_path):
|
|
77
|
+
"""The whole point: an existing backlog must not block every merge."""
|
|
78
|
+
spec = _spec({"/a": WEAK_ENDPOINT, "/b": WEAK_ENDPOINT})
|
|
79
|
+
path = _baseline_for(spec, tmp_path)
|
|
80
|
+
|
|
81
|
+
result = run_audit(spec)
|
|
82
|
+
assert result["fail_count"] > 0, "fixture should have pre-existing failures"
|
|
83
|
+
|
|
84
|
+
comparison = compare_to_baseline(result, load_baseline(path))
|
|
85
|
+
assert comparison["new_count"] == 0
|
|
86
|
+
assert comparison["unchanged_count"] == len(result["gaps"])
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_added_bad_endpoint_is_reported_as_new(tmp_path):
|
|
90
|
+
spec = _spec({"/a": WEAK_ENDPOINT})
|
|
91
|
+
path = _baseline_for(spec, tmp_path)
|
|
92
|
+
|
|
93
|
+
worse = _spec({"/a": WEAK_ENDPOINT, "/new": WEAK_ENDPOINT})
|
|
94
|
+
comparison = compare_to_baseline(run_audit(worse), load_baseline(path))
|
|
95
|
+
|
|
96
|
+
assert comparison["new_count"] > 0
|
|
97
|
+
assert all("/new" in f.endpoint for f in comparison["new_findings"]), (
|
|
98
|
+
"only the added endpoint should be reported as new"
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_fixed_findings_are_reported_and_do_not_fail(tmp_path):
|
|
103
|
+
spec = _spec({"/a": WEAK_ENDPOINT, "/b": WEAK_ENDPOINT})
|
|
104
|
+
path = _baseline_for(spec, tmp_path)
|
|
105
|
+
|
|
106
|
+
better = _spec({"/a": STRONG_ENDPOINT, "/b": WEAK_ENDPOINT})
|
|
107
|
+
comparison = compare_to_baseline(run_audit(better), load_baseline(path))
|
|
108
|
+
|
|
109
|
+
assert comparison["fixed_count"] > 0
|
|
110
|
+
assert comparison["new_count"] == 0, "improving a spec must never look like a regression"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# --------------------------------------------------------------------------
|
|
114
|
+
# Fingerprint stability — each of these breaks a naive implementation
|
|
115
|
+
# --------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
def test_message_text_changes_do_not_create_false_regressions(tmp_path):
|
|
118
|
+
"""
|
|
119
|
+
Adding a second undocumented parameter changes the finding's message from
|
|
120
|
+
"1/1 parameter(s) ... (q)" to "2/2 parameter(s) ... (q, extra)". It is the
|
|
121
|
+
same unfixed problem. A message-based fingerprint would report it as new.
|
|
122
|
+
"""
|
|
123
|
+
spec = _spec({"/a": WEAK_ENDPOINT})
|
|
124
|
+
path = _baseline_for(spec, tmp_path)
|
|
125
|
+
|
|
126
|
+
changed = json.loads(json.dumps(spec))
|
|
127
|
+
changed["paths"]["/a"]["get"]["parameters"].append(
|
|
128
|
+
{"name": "extra", "in": "query", "schema": {"type": "string"}}
|
|
129
|
+
)
|
|
130
|
+
comparison = compare_to_baseline(run_audit(changed), load_baseline(path))
|
|
131
|
+
assert comparison["new_count"] == 0, (
|
|
132
|
+
"a reworded message for the same unfixed problem must not count as new"
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_severity_escalation_is_caught(tmp_path):
|
|
137
|
+
"""
|
|
138
|
+
A warning becoming a fail, in the same category on the same endpoint, is a
|
|
139
|
+
regression. Fingerprinting on category and endpoint alone would treat it as
|
|
140
|
+
unchanged and let it through.
|
|
141
|
+
|
|
142
|
+
The description lengths below are chosen deliberately: description_clarity
|
|
143
|
+
warns between 40 and 150 characters and fails below 40, so this shortening
|
|
144
|
+
changes severity without changing which categories fire. An earlier version
|
|
145
|
+
of this test degraded a description that had produced no finding at all, so
|
|
146
|
+
the new *category* made it pass and the severity component was never
|
|
147
|
+
exercised — it stayed green with severity removed from the fingerprint.
|
|
148
|
+
"""
|
|
149
|
+
warning_level = _spec(
|
|
150
|
+
{
|
|
151
|
+
"/a": {
|
|
152
|
+
"get": {
|
|
153
|
+
"summary": "",
|
|
154
|
+
"description": "Returns the things in the account with status.",
|
|
155
|
+
"parameters": [],
|
|
156
|
+
"responses": {
|
|
157
|
+
"200": {"description": "OK"},
|
|
158
|
+
"default": {
|
|
159
|
+
"description": "A structured error object.",
|
|
160
|
+
"content": {"application/json": {"schema": {"type": "object"}}},
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
)
|
|
167
|
+
path = _baseline_for(warning_level, tmp_path)
|
|
168
|
+
|
|
169
|
+
before = [
|
|
170
|
+
f for f in run_audit(warning_level)["gaps"] if f.category == "description_clarity"
|
|
171
|
+
]
|
|
172
|
+
assert [f.severity for f in before] == ["warning"], "fixture must start at warning"
|
|
173
|
+
|
|
174
|
+
fail_level = json.loads(json.dumps(warning_level))
|
|
175
|
+
fail_level["paths"]["/a"]["get"]["description"] = "Returns things."
|
|
176
|
+
|
|
177
|
+
after = [
|
|
178
|
+
f for f in run_audit(fail_level)["gaps"] if f.category == "description_clarity"
|
|
179
|
+
]
|
|
180
|
+
assert [f.severity for f in after] == ["fail"], "fixture must degrade to fail"
|
|
181
|
+
|
|
182
|
+
comparison = compare_to_baseline(run_audit(fail_level), load_baseline(path))
|
|
183
|
+
assert comparison["new_fails"] > 0, (
|
|
184
|
+
"a warning escalating to a fail in the same category must be reported as new"
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def test_fingerprint_excludes_message():
|
|
189
|
+
"""Rewording a finding in a future release must not invalidate baselines."""
|
|
190
|
+
from agent_ready.rubric import Finding
|
|
191
|
+
|
|
192
|
+
a = Finding("description_clarity", "fail", "GET /x", "original wording")
|
|
193
|
+
b = Finding("description_clarity", "fail", "GET /x", "completely different wording")
|
|
194
|
+
assert fingerprint(a) == fingerprint(b)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def test_duplicate_findings_are_counted_not_deduplicated():
|
|
198
|
+
"""
|
|
199
|
+
One endpoint can produce two findings in one category. Going from one to
|
|
200
|
+
two is a regression, so identity must carry multiplicity.
|
|
201
|
+
"""
|
|
202
|
+
from agent_ready.rubric import Finding
|
|
203
|
+
|
|
204
|
+
f = Finding("parameter_explanation", "warning", "GET /x", "m")
|
|
205
|
+
baseline = {"fingerprints": [fingerprint(f)]}
|
|
206
|
+
result = {"gaps": [f, f], "overall_score": 50.0}
|
|
207
|
+
|
|
208
|
+
comparison = compare_to_baseline(result, baseline)
|
|
209
|
+
assert comparison["new_count"] == 1, "the second occurrence is new"
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
# --------------------------------------------------------------------------
|
|
213
|
+
# File handling
|
|
214
|
+
# --------------------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
def test_missing_baseline_gives_actionable_error(tmp_path):
|
|
217
|
+
with pytest.raises(BaselineError, match="--write-baseline"):
|
|
218
|
+
load_baseline(str(tmp_path / "nope.json"))
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def test_malformed_baseline_is_rejected(tmp_path):
|
|
222
|
+
p = tmp_path / "bad.json"
|
|
223
|
+
p.write_text("{not json")
|
|
224
|
+
with pytest.raises(BaselineError):
|
|
225
|
+
load_baseline(str(p))
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def test_wrong_shaped_json_is_rejected(tmp_path):
|
|
229
|
+
p = tmp_path / "other.json"
|
|
230
|
+
p.write_text(json.dumps({"some": "other file"}))
|
|
231
|
+
with pytest.raises(BaselineError, match="fingerprints"):
|
|
232
|
+
load_baseline(str(p))
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def test_newer_baseline_format_is_rejected(tmp_path):
|
|
236
|
+
p = tmp_path / "future.json"
|
|
237
|
+
p.write_text(json.dumps({"baseline_version": 999, "fingerprints": []}))
|
|
238
|
+
with pytest.raises(BaselineError, match="newer version"):
|
|
239
|
+
load_baseline(str(p))
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# --------------------------------------------------------------------------
|
|
243
|
+
# CLI wiring and exit codes
|
|
244
|
+
# --------------------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
def _write_spec(tmp_path, spec, name="spec.json"):
|
|
247
|
+
p = tmp_path / name
|
|
248
|
+
p.write_text(json.dumps(spec))
|
|
249
|
+
return str(p)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def test_cli_write_then_compare_passes(tmp_path):
|
|
253
|
+
spec_path = _write_spec(tmp_path, _spec({"/a": WEAK_ENDPOINT}))
|
|
254
|
+
base = str(tmp_path / "b.json")
|
|
255
|
+
|
|
256
|
+
assert main([spec_path, "--write-baseline", base, "--quiet"]) == 0
|
|
257
|
+
assert main([spec_path, "--baseline", base, "--max-new", "0", "--quiet"]) == 0
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def test_cli_fails_on_new_findings(tmp_path):
|
|
261
|
+
base_spec = _spec({"/a": WEAK_ENDPOINT})
|
|
262
|
+
base = str(tmp_path / "b.json")
|
|
263
|
+
main([_write_spec(tmp_path, base_spec), "--write-baseline", base, "--quiet"])
|
|
264
|
+
|
|
265
|
+
worse = _write_spec(tmp_path, _spec({"/a": WEAK_ENDPOINT, "/b": WEAK_ENDPOINT}), "worse.json")
|
|
266
|
+
assert main([worse, "--baseline", base, "--max-new", "0", "--quiet"]) == 1
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def test_cli_missing_baseline_exits_two(tmp_path):
|
|
270
|
+
spec_path = _write_spec(tmp_path, _spec({"/a": WEAK_ENDPOINT}))
|
|
271
|
+
assert main([spec_path, "--baseline", str(tmp_path / "absent.json"), "--quiet"]) == 2
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def test_baseline_alone_does_not_gate(tmp_path):
|
|
275
|
+
"""--baseline reports; only --max-new turns it into a gate."""
|
|
276
|
+
base_spec = _spec({"/a": WEAK_ENDPOINT})
|
|
277
|
+
base = str(tmp_path / "b.json")
|
|
278
|
+
main([_write_spec(tmp_path, base_spec), "--write-baseline", base, "--quiet"])
|
|
279
|
+
|
|
280
|
+
worse = _write_spec(tmp_path, _spec({"/a": WEAK_ENDPOINT, "/b": WEAK_ENDPOINT}), "worse.json")
|
|
281
|
+
assert main([worse, "--baseline", base, "--quiet"]) == 0
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def test_baseline_file_is_valid_json_with_expected_keys(tmp_path):
|
|
285
|
+
spec = _spec({"/a": WEAK_ENDPOINT})
|
|
286
|
+
path = _baseline_for(spec, tmp_path)
|
|
287
|
+
data = json.loads(Path(path).read_text())
|
|
288
|
+
|
|
289
|
+
assert data["baseline_version"] >= 1
|
|
290
|
+
assert data["api"]["title"] == "T"
|
|
291
|
+
assert isinstance(data["fingerprints"], list)
|
|
292
|
+
assert data["fingerprints"] == sorted(data["fingerprints"]), (
|
|
293
|
+
"fingerprints must be sorted so the file is diff-friendly in git"
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def test_roundtrip_through_a_real_spec_file(tmp_path):
|
|
298
|
+
"""End-to-end against the shipped example, not a synthetic fixture."""
|
|
299
|
+
|
|
300
|
+
examples = Path(__file__).parent.parent / "examples"
|
|
301
|
+
spec = load_spec(str(examples / "sample_booking_api.yaml"))
|
|
302
|
+
path = _baseline_for(spec, tmp_path, "real.json")
|
|
303
|
+
|
|
304
|
+
comparison = compare_to_baseline(run_audit(spec), load_baseline(path))
|
|
305
|
+
assert comparison["new_count"] == 0
|
|
306
|
+
assert comparison["fixed_count"] == 0
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openapi_agent_ready-0.3.0 → openapi_agent_ready-0.4.0}/examples/agent_ready_booking_api.yaml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|