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.
Files changed (22) hide show
  1. {openapi_agent_ready-0.3.0 → openapi_agent_ready-0.4.0}/CHANGELOG.md +15 -0
  2. {openapi_agent_ready-0.3.0 → openapi_agent_ready-0.4.0}/PKG-INFO +38 -7
  3. {openapi_agent_ready-0.3.0 → openapi_agent_ready-0.4.0}/README.md +37 -6
  4. {openapi_agent_ready-0.3.0 → openapi_agent_ready-0.4.0}/pyproject.toml +1 -1
  5. openapi_agent_ready-0.4.0/src/agent_ready/baseline.py +137 -0
  6. {openapi_agent_ready-0.3.0 → openapi_agent_ready-0.4.0}/src/agent_ready/cli.py +80 -1
  7. openapi_agent_ready-0.4.0/tests/test_baseline.py +306 -0
  8. {openapi_agent_ready-0.3.0 → openapi_agent_ready-0.4.0}/.github/workflows/ci.yml +0 -0
  9. {openapi_agent_ready-0.3.0 → openapi_agent_ready-0.4.0}/.github/workflows/publish.yml +0 -0
  10. {openapi_agent_ready-0.3.0 → openapi_agent_ready-0.4.0}/.gitignore +0 -0
  11. {openapi_agent_ready-0.3.0 → openapi_agent_ready-0.4.0}/CONTRIBUTING.md +0 -0
  12. {openapi_agent_ready-0.3.0 → openapi_agent_ready-0.4.0}/LICENSE +0 -0
  13. {openapi_agent_ready-0.3.0 → openapi_agent_ready-0.4.0}/examples/agent_ready_booking_api.yaml +0 -0
  14. {openapi_agent_ready-0.3.0 → openapi_agent_ready-0.4.0}/examples/sample_booking_api.yaml +0 -0
  15. {openapi_agent_ready-0.3.0 → openapi_agent_ready-0.4.0}/openapi.yaml +0 -0
  16. {openapi_agent_ready-0.3.0 → openapi_agent_ready-0.4.0}/src/agent_ready/__init__.py +0 -0
  17. {openapi_agent_ready-0.3.0 → openapi_agent_ready-0.4.0}/src/agent_ready/auditor.py +0 -0
  18. {openapi_agent_ready-0.3.0 → openapi_agent_ready-0.4.0}/src/agent_ready/mcp_generator.py +0 -0
  19. {openapi_agent_ready-0.3.0 → openapi_agent_ready-0.4.0}/src/agent_ready/report_generator.py +0 -0
  20. {openapi_agent_ready-0.3.0 → openapi_agent_ready-0.4.0}/src/agent_ready/rubric.py +0 -0
  21. {openapi_agent_ready-0.3.0 → openapi_agent_ready-0.4.0}/tests/test_audit.py +0 -0
  22. {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.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) have no counterpart in the
115
- article — they were added during implementation, once it became clear that
116
- unsignalled write operations and confusable endpoint descriptions cause agent
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) have no counterpart in the
60
- article — they were added during implementation, once it became clear that
61
- unsignalled write operations and confusable endpoint descriptions cause agent
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.3.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 70 --max-fails 0 --quiet\n"
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