artie-cli 0.7.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.
- artie/__init__.py +11 -0
- artie/__main__.py +6 -0
- artie/baseline.py +71 -0
- artie/checks/__init__.py +23 -0
- artie/checks/auth_clarity.py +173 -0
- artie/checks/base.py +93 -0
- artie/checks/endpoint_completeness.py +138 -0
- artie/checks/error_documentation.py +180 -0
- artie/checks/example_coverage.py +157 -0
- artie/checks/format_efficiency.py +164 -0
- artie/checks/generation_quality.py +604 -0
- artie/checks/parameter_naming.py +227 -0
- artie/checks/schema_complexity.py +178 -0
- artie/cli.py +280 -0
- artie/config.py +180 -0
- artie/fetcher.py +117 -0
- artie/generator.py +186 -0
- artie/parsers/__init__.py +75 -0
- artie/parsers/openapi.py +325 -0
- artie/parsers/types.py +60 -0
- artie/reporters/__init__.py +1 -0
- artie/reporters/json_report.py +66 -0
- artie/reporters/terminal.py +186 -0
- artie_cli-0.7.0.dist-info/METADATA +169 -0
- artie_cli-0.7.0.dist-info/RECORD +27 -0
- artie_cli-0.7.0.dist-info/WHEEL +4 -0
- artie_cli-0.7.0.dist-info/entry_points.txt +2 -0
artie/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""artie-cli: Score your API documentation for AI-readiness."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.7.0"
|
|
4
|
+
|
|
5
|
+
# Version of the scoring rubric: the thresholds, buckets, and weightings that
|
|
6
|
+
# turn documentation into scores. Bump this whenever any of those change, so
|
|
7
|
+
# JSON consumers can tell whether two runs are comparable. This is the runtime
|
|
8
|
+
# source of truth; pyproject.toml [tool.artie] mirrors it for packaging tools,
|
|
9
|
+
# and tests/test_scoring_version.py asserts the two stay in sync.
|
|
10
|
+
# See docs/scoring.md for the full rubric.
|
|
11
|
+
SCORING_VERSION = "1.0"
|
artie/__main__.py
ADDED
artie/baseline.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Load a previously saved JSON report for run-over-run comparison.
|
|
2
|
+
|
|
3
|
+
`artie check --output json > previous.json` produces a report; passing it
|
|
4
|
+
back via `--baseline previous.json` on a later run lets the reporters show
|
|
5
|
+
per-check score deltas, which is what turns artie into a regression gate.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class BaselineError(Exception):
|
|
17
|
+
"""Raised when a baseline report cannot be loaded or understood."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Baseline:
|
|
22
|
+
"""Scores from a prior run, keyed by check name."""
|
|
23
|
+
|
|
24
|
+
scores: dict[str, int | None] = field(default_factory=dict)
|
|
25
|
+
scoring_version: str | None = None
|
|
26
|
+
source: str | None = None
|
|
27
|
+
path: Path | None = None
|
|
28
|
+
|
|
29
|
+
def score_for(self, check_name: str) -> int | None:
|
|
30
|
+
"""Prior score for a check, or None if absent or unscored."""
|
|
31
|
+
return self.scores.get(check_name)
|
|
32
|
+
|
|
33
|
+
def delta_for(self, check_name: str, current: int | None) -> int | None:
|
|
34
|
+
"""Signed change from the prior run, or None if not comparable."""
|
|
35
|
+
prior = self.scores.get(check_name)
|
|
36
|
+
if prior is None or current is None:
|
|
37
|
+
return None
|
|
38
|
+
return current - prior
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def load_baseline(path: str | Path) -> Baseline:
|
|
42
|
+
"""Load a saved artie JSON report as a Baseline."""
|
|
43
|
+
path = Path(path)
|
|
44
|
+
if not path.is_file():
|
|
45
|
+
raise BaselineError(f"Baseline file not found: {path}")
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
data: Any = json.loads(path.read_text(encoding="utf-8"))
|
|
49
|
+
except json.JSONDecodeError as exc:
|
|
50
|
+
raise BaselineError(f"Baseline {path} is not valid JSON: {exc}") from exc
|
|
51
|
+
except OSError as exc:
|
|
52
|
+
raise BaselineError(f"Could not read baseline {path}: {exc}") from exc
|
|
53
|
+
|
|
54
|
+
if not isinstance(data, dict) or not isinstance(data.get("checks"), list):
|
|
55
|
+
raise BaselineError(
|
|
56
|
+
f"Baseline {path} is not an artie JSON report "
|
|
57
|
+
f"(expected a top-level `checks` array)."
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
scores: dict[str, int | None] = {}
|
|
61
|
+
for check in data["checks"]:
|
|
62
|
+
if isinstance(check, dict) and isinstance(check.get("name"), str):
|
|
63
|
+
score = check.get("score")
|
|
64
|
+
scores[check["name"]] = score if isinstance(score, int) else None
|
|
65
|
+
|
|
66
|
+
return Baseline(
|
|
67
|
+
scores=scores,
|
|
68
|
+
scoring_version=data.get("scoring_version"),
|
|
69
|
+
source=data.get("source"),
|
|
70
|
+
path=path,
|
|
71
|
+
)
|
artie/checks/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Documentation check registry."""
|
|
2
|
+
|
|
3
|
+
from artie.checks.auth_clarity import AuthClarityCheck
|
|
4
|
+
from artie.checks.base import BaseCheck, CheckResult, Severity
|
|
5
|
+
from artie.checks.endpoint_completeness import EndpointCompletenessCheck
|
|
6
|
+
from artie.checks.error_documentation import ErrorDocumentationCheck
|
|
7
|
+
from artie.checks.example_coverage import ExampleCoverageCheck
|
|
8
|
+
from artie.checks.format_efficiency import FormatEfficiencyCheck
|
|
9
|
+
from artie.checks.parameter_naming import ParameterNamingCheck
|
|
10
|
+
from artie.checks.schema_complexity import SchemaComplexityCheck
|
|
11
|
+
|
|
12
|
+
# Registry of all available checks, ordered roughly format -> content -> quality.
|
|
13
|
+
ALL_CHECKS: list[type[BaseCheck]] = [
|
|
14
|
+
FormatEfficiencyCheck,
|
|
15
|
+
EndpointCompletenessCheck,
|
|
16
|
+
ExampleCoverageCheck,
|
|
17
|
+
ErrorDocumentationCheck,
|
|
18
|
+
AuthClarityCheck,
|
|
19
|
+
ParameterNamingCheck,
|
|
20
|
+
SchemaComplexityCheck,
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
__all__ = ["ALL_CHECKS", "BaseCheck", "CheckResult", "Severity"]
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Auth Clarity check.
|
|
2
|
+
|
|
3
|
+
Scores documentation based on how clearly authentication is defined and
|
|
4
|
+
applied. Agents need to know what credentials to send, where to send them,
|
|
5
|
+
and on which operations.
|
|
6
|
+
|
|
7
|
+
Three subscores:
|
|
8
|
+
|
|
9
|
+
1. Schemes defined: at least one security scheme exists in
|
|
10
|
+
components.securitySchemes.
|
|
11
|
+
2. Schemes described: every scheme has a non-trivial description (or, for
|
|
12
|
+
schemes where the type is self-documenting like `http bearer`, the type
|
|
13
|
+
itself counts).
|
|
14
|
+
3. Application coverage: either a top-level `security` requirement is set,
|
|
15
|
+
or every operation declares one. Operations that explicitly opt out with
|
|
16
|
+
`security: []` count as documented (they're public on purpose).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from artie.checks.base import BaseCheck, CheckResult
|
|
22
|
+
from artie.parsers.types import Endpoint, ParsedDocs
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
SELF_DOCUMENTING_TYPES = {"http", "apiKey", "openIdConnect"}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AuthClarityCheck(BaseCheck):
|
|
29
|
+
name = "Auth Clarity"
|
|
30
|
+
description = "Security schemes defined, described, and applied to operations."
|
|
31
|
+
|
|
32
|
+
def run(
|
|
33
|
+
self, content: str, format_type: str, parsed: Any = None
|
|
34
|
+
) -> CheckResult:
|
|
35
|
+
if not isinstance(parsed, ParsedDocs) or not parsed.is_structured:
|
|
36
|
+
return self.not_evaluable(
|
|
37
|
+
"Auth Clarity requires a parsed OpenAPI document."
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
schemes = _security_schemes(parsed.components)
|
|
41
|
+
endpoints = parsed.endpoints
|
|
42
|
+
|
|
43
|
+
if not schemes and not _global_security(parsed.raw) and not _any_operation_has_security(endpoints):
|
|
44
|
+
# No security mentioned anywhere. This is either a fully public
|
|
45
|
+
# API (legitimate) or undocumented auth (not legitimate). We can't
|
|
46
|
+
# tell from the spec alone, so flag it but do not score zero.
|
|
47
|
+
return CheckResult(
|
|
48
|
+
name=self.name,
|
|
49
|
+
description=self.description,
|
|
50
|
+
score=0,
|
|
51
|
+
max_score=self.max_score,
|
|
52
|
+
severity=self.severity_for(0),
|
|
53
|
+
findings=[
|
|
54
|
+
"No security schemes defined and no operations declare security",
|
|
55
|
+
"API appears to be fully unauthenticated, or auth is undocumented",
|
|
56
|
+
],
|
|
57
|
+
recommendations=[
|
|
58
|
+
"If this API requires authentication, declare a scheme under "
|
|
59
|
+
"components.securitySchemes and reference it from operations "
|
|
60
|
+
"(or globally). If it is genuinely public, add a top-level "
|
|
61
|
+
"comment to make that explicit."
|
|
62
|
+
],
|
|
63
|
+
metadata={"schemes": 0, "global_security": False},
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
scheme_count = len(schemes)
|
|
67
|
+
described_schemes = sum(1 for s in schemes.values() if _is_described(s))
|
|
68
|
+
global_security = _global_security(parsed.raw)
|
|
69
|
+
|
|
70
|
+
operation_coverage = _operation_coverage(endpoints, global_security)
|
|
71
|
+
|
|
72
|
+
scheme_defined_ratio = 1.0 if scheme_count > 0 else 0.0
|
|
73
|
+
described_ratio = (
|
|
74
|
+
described_schemes / scheme_count if scheme_count > 0 else 0.0
|
|
75
|
+
)
|
|
76
|
+
application_ratio = operation_coverage
|
|
77
|
+
|
|
78
|
+
composite = (scheme_defined_ratio + described_ratio + application_ratio) / 3
|
|
79
|
+
score = round(composite * self.max_score)
|
|
80
|
+
severity = self.severity_for(score)
|
|
81
|
+
|
|
82
|
+
scheme_names = ", ".join(schemes.keys()) if schemes else "none"
|
|
83
|
+
findings = [
|
|
84
|
+
f"{scheme_count} security scheme{'s' if scheme_count != 1 else ''} "
|
|
85
|
+
f"defined: {scheme_names}",
|
|
86
|
+
f"{described_schemes} of {scheme_count} schemes have a description "
|
|
87
|
+
f"or self-documenting type"
|
|
88
|
+
if scheme_count
|
|
89
|
+
else "No security schemes defined",
|
|
90
|
+
"Top-level security requirement present" if global_security
|
|
91
|
+
else "No top-level security requirement",
|
|
92
|
+
f"{int(application_ratio * 100)}% of operations have an explicit "
|
|
93
|
+
"or inherited security requirement",
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
recommendations: list[str] = []
|
|
97
|
+
undescribed = [name for name, s in schemes.items() if not _is_described(s)]
|
|
98
|
+
if undescribed:
|
|
99
|
+
recommendations.append(
|
|
100
|
+
"Add a description to each security scheme explaining how to "
|
|
101
|
+
"obtain credentials and what they authorize. Missing on: "
|
|
102
|
+
+ ", ".join(undescribed)
|
|
103
|
+
)
|
|
104
|
+
if scheme_count and application_ratio < 1.0:
|
|
105
|
+
recommendations.append(
|
|
106
|
+
"Set a top-level security requirement so every operation "
|
|
107
|
+
"inherits it by default, or declare security on every operation. "
|
|
108
|
+
"Operations that are genuinely public should opt out explicitly "
|
|
109
|
+
"with security: []."
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
return CheckResult(
|
|
113
|
+
name=self.name,
|
|
114
|
+
description=self.description,
|
|
115
|
+
score=score,
|
|
116
|
+
max_score=self.max_score,
|
|
117
|
+
severity=severity,
|
|
118
|
+
findings=findings,
|
|
119
|
+
recommendations=recommendations,
|
|
120
|
+
metadata={
|
|
121
|
+
"schemes": scheme_count,
|
|
122
|
+
"schemes_described": described_schemes,
|
|
123
|
+
"global_security": global_security,
|
|
124
|
+
"operation_coverage": application_ratio,
|
|
125
|
+
},
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _security_schemes(components: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
|
130
|
+
if not isinstance(components, dict):
|
|
131
|
+
return {}
|
|
132
|
+
schemes = components.get("securitySchemes")
|
|
133
|
+
if not isinstance(schemes, dict):
|
|
134
|
+
return {}
|
|
135
|
+
return {k: v for k, v in schemes.items() if isinstance(v, dict)}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _global_security(raw: dict[str, Any] | None) -> bool:
|
|
139
|
+
if not isinstance(raw, dict):
|
|
140
|
+
return False
|
|
141
|
+
security = raw.get("security")
|
|
142
|
+
return isinstance(security, list) and len(security) > 0
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _any_operation_has_security(endpoints: list[Endpoint]) -> bool:
|
|
146
|
+
return any("security" in e.operation for e in endpoints)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _operation_coverage(endpoints: list[Endpoint], global_security: bool) -> float:
|
|
150
|
+
"""Fraction of operations whose security posture is documented.
|
|
151
|
+
|
|
152
|
+
An operation is covered if it explicitly sets `security` (including the
|
|
153
|
+
empty list to opt out), or if a top-level security requirement applies.
|
|
154
|
+
"""
|
|
155
|
+
if not endpoints:
|
|
156
|
+
return 0.0
|
|
157
|
+
covered = 0
|
|
158
|
+
for endpoint in endpoints:
|
|
159
|
+
if "security" in endpoint.operation:
|
|
160
|
+
covered += 1
|
|
161
|
+
elif global_security:
|
|
162
|
+
covered += 1
|
|
163
|
+
return covered / len(endpoints)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _is_described(scheme: dict[str, Any]) -> bool:
|
|
167
|
+
"""A scheme is described if it has a non-trivial description, or if its
|
|
168
|
+
type is self-documenting (http bearer, apiKey, openIdConnect)."""
|
|
169
|
+
description = (scheme.get("description") or "").strip()
|
|
170
|
+
if len(description) >= 10:
|
|
171
|
+
return True
|
|
172
|
+
scheme_type = scheme.get("type")
|
|
173
|
+
return scheme_type in SELF_DOCUMENTING_TYPES
|
artie/checks/base.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Base class for all artie checks."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Severity(str, Enum):
|
|
9
|
+
"""Severity levels for check results."""
|
|
10
|
+
|
|
11
|
+
EXCELLENT = "excellent"
|
|
12
|
+
GOOD = "good"
|
|
13
|
+
NEEDS_WORK = "needs_work"
|
|
14
|
+
POOR = "poor"
|
|
15
|
+
NOT_EVALUABLE = "not_evaluable"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class CheckResult:
|
|
20
|
+
"""Result of running a single check against documentation."""
|
|
21
|
+
|
|
22
|
+
name: str
|
|
23
|
+
description: str
|
|
24
|
+
score: int | None
|
|
25
|
+
max_score: int
|
|
26
|
+
severity: Severity
|
|
27
|
+
findings: list[str] = field(default_factory=list)
|
|
28
|
+
recommendations: list[str] = field(default_factory=list)
|
|
29
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
30
|
+
# An informational check ran and produced findings, but deliberately
|
|
31
|
+
# assigns no score. Sample Generation is the only one: it presents
|
|
32
|
+
# generated code as a deliverable, not a 0-10 grade.
|
|
33
|
+
informational: bool = False
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def is_evaluable(self) -> bool:
|
|
37
|
+
"""True when this check produced a numeric score worth gating on.
|
|
38
|
+
|
|
39
|
+
Excludes both not-evaluable results (the check couldn't run) and
|
|
40
|
+
informational results (the check ran but assigns no score).
|
|
41
|
+
"""
|
|
42
|
+
return self.severity != Severity.NOT_EVALUABLE and not self.informational
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def score_display(self) -> str:
|
|
46
|
+
if self.informational:
|
|
47
|
+
return "—"
|
|
48
|
+
if self.severity == Severity.NOT_EVALUABLE:
|
|
49
|
+
return "N/A"
|
|
50
|
+
return f"{self.score}/{self.max_score}"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class BaseCheck:
|
|
54
|
+
"""Base class for all documentation checks."""
|
|
55
|
+
|
|
56
|
+
name: str = "unnamed check"
|
|
57
|
+
description: str = ""
|
|
58
|
+
max_score: int = 10
|
|
59
|
+
|
|
60
|
+
def run(self, content: str, format_type: str, parsed: Any = None) -> CheckResult:
|
|
61
|
+
"""Run this check against documentation content.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
content: Raw documentation text.
|
|
65
|
+
format_type: Detected format (e.g. "openapi-yaml", "markdown").
|
|
66
|
+
parsed: Optional pre-parsed representation of the content.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
CheckResult with score, severity, findings, and recommendations.
|
|
70
|
+
"""
|
|
71
|
+
raise NotImplementedError
|
|
72
|
+
|
|
73
|
+
def not_evaluable(self, reason: str) -> CheckResult:
|
|
74
|
+
"""Return a not-evaluable result when this check can't be assessed."""
|
|
75
|
+
return CheckResult(
|
|
76
|
+
name=self.name,
|
|
77
|
+
description=self.description,
|
|
78
|
+
score=None,
|
|
79
|
+
max_score=self.max_score,
|
|
80
|
+
severity=Severity.NOT_EVALUABLE,
|
|
81
|
+
findings=[reason],
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def severity_for(self, score: int) -> Severity:
|
|
85
|
+
"""Map a numeric score to a severity level."""
|
|
86
|
+
ratio = score / self.max_score
|
|
87
|
+
if ratio >= 0.9:
|
|
88
|
+
return Severity.EXCELLENT
|
|
89
|
+
if ratio >= 0.7:
|
|
90
|
+
return Severity.GOOD
|
|
91
|
+
if ratio >= 0.4:
|
|
92
|
+
return Severity.NEEDS_WORK
|
|
93
|
+
return Severity.POOR
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Endpoint Completeness check.
|
|
2
|
+
|
|
3
|
+
Scores documentation based on how completely each endpoint is described.
|
|
4
|
+
Three signals contribute equally:
|
|
5
|
+
|
|
6
|
+
1. Endpoints with a description or summary (agents need to know what an
|
|
7
|
+
endpoint does, not just its shape).
|
|
8
|
+
2. Endpoints with an operationId (the canonical name agents use for tool
|
|
9
|
+
calling and code generation).
|
|
10
|
+
3. Path parameters with descriptions (agents must know what {bookId}
|
|
11
|
+
actually identifies before constructing requests).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from artie.checks.base import BaseCheck, CheckResult
|
|
17
|
+
from artie.parsers.openapi import path_parameters
|
|
18
|
+
from artie.parsers.types import Endpoint, ParsedDocs
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class EndpointCompletenessCheck(BaseCheck):
|
|
22
|
+
name = "Endpoint Completeness"
|
|
23
|
+
description = (
|
|
24
|
+
"Endpoints with descriptions, operationIds, and documented "
|
|
25
|
+
"path parameters."
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
def run(
|
|
29
|
+
self, content: str, format_type: str, parsed: Any = None
|
|
30
|
+
) -> CheckResult:
|
|
31
|
+
if not isinstance(parsed, ParsedDocs) or not parsed.is_structured:
|
|
32
|
+
return self.not_evaluable(
|
|
33
|
+
"Endpoint Completeness requires a parsed OpenAPI document."
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
endpoints = parsed.endpoints
|
|
37
|
+
if not endpoints:
|
|
38
|
+
return self.not_evaluable("No endpoints found in paths section.")
|
|
39
|
+
|
|
40
|
+
described = [e for e in endpoints if _has_description(e)]
|
|
41
|
+
with_op_id = [e for e in endpoints if e.operation_id]
|
|
42
|
+
path_param_total, path_param_described = _count_path_params(endpoints)
|
|
43
|
+
|
|
44
|
+
# Three subscores out of 1.0, averaged, scaled to max_score.
|
|
45
|
+
desc_ratio = len(described) / len(endpoints)
|
|
46
|
+
op_id_ratio = len(with_op_id) / len(endpoints)
|
|
47
|
+
if path_param_total == 0:
|
|
48
|
+
# No path params is not a failure mode; collapse to two-component avg.
|
|
49
|
+
subscores = [desc_ratio, op_id_ratio]
|
|
50
|
+
else:
|
|
51
|
+
path_ratio = path_param_described / path_param_total
|
|
52
|
+
subscores = [desc_ratio, op_id_ratio, path_ratio]
|
|
53
|
+
|
|
54
|
+
composite = sum(subscores) / len(subscores)
|
|
55
|
+
score = round(composite * self.max_score)
|
|
56
|
+
severity = self.severity_for(score)
|
|
57
|
+
|
|
58
|
+
findings = [
|
|
59
|
+
f"{len(described)} of {len(endpoints)} endpoints have a description or summary",
|
|
60
|
+
f"{len(with_op_id)} of {len(endpoints)} endpoints have an operationId",
|
|
61
|
+
]
|
|
62
|
+
if path_param_total > 0:
|
|
63
|
+
findings.append(
|
|
64
|
+
f"{path_param_described} of {path_param_total} path parameters "
|
|
65
|
+
f"have descriptions"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
recommendations: list[str] = []
|
|
69
|
+
if len(described) < len(endpoints):
|
|
70
|
+
missing = [e.display for e in endpoints if not _has_description(e)][:5]
|
|
71
|
+
recommendations.append(
|
|
72
|
+
"Add a summary or description to every operation. Examples "
|
|
73
|
+
f"missing one: {', '.join(missing)}"
|
|
74
|
+
+ ("..." if len(missing) == 5 else "")
|
|
75
|
+
)
|
|
76
|
+
if len(with_op_id) < len(endpoints):
|
|
77
|
+
missing = [e.display for e in endpoints if not e.operation_id][:5]
|
|
78
|
+
recommendations.append(
|
|
79
|
+
"Add an operationId to every operation. operationIds become "
|
|
80
|
+
"function names in generated SDKs and tool definitions. Missing "
|
|
81
|
+
f"on: {', '.join(missing)}"
|
|
82
|
+
+ ("..." if len(missing) == 5 else "")
|
|
83
|
+
)
|
|
84
|
+
if path_param_total > 0 and path_param_described < path_param_total:
|
|
85
|
+
recommendations.append(
|
|
86
|
+
"Document every path parameter with a description. Without "
|
|
87
|
+
"descriptions, agents guess at what an ID identifies."
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
return CheckResult(
|
|
91
|
+
name=self.name,
|
|
92
|
+
description=self.description,
|
|
93
|
+
score=score,
|
|
94
|
+
max_score=self.max_score,
|
|
95
|
+
severity=severity,
|
|
96
|
+
findings=findings,
|
|
97
|
+
recommendations=recommendations,
|
|
98
|
+
metadata={
|
|
99
|
+
"endpoints": len(endpoints),
|
|
100
|
+
"endpoints_with_descriptions": len(described),
|
|
101
|
+
"endpoints_with_operation_ids": len(with_op_id),
|
|
102
|
+
"path_parameters_total": path_param_total,
|
|
103
|
+
"path_parameters_described": path_param_described,
|
|
104
|
+
},
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _has_description(endpoint: Endpoint) -> bool:
|
|
109
|
+
"""Either description or summary counts; agents read both."""
|
|
110
|
+
return bool((endpoint.description or "").strip() or (endpoint.summary or "").strip())
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _count_path_params(endpoints: list[Endpoint]) -> tuple[int, int]:
|
|
114
|
+
"""Count path parameters across all endpoints, and how many are described.
|
|
115
|
+
|
|
116
|
+
A path parameter is described when an operation declares it under
|
|
117
|
+
`parameters` with `in: path` and a non-empty description. We dedupe per
|
|
118
|
+
endpoint so the same {id} appearing in two operations counts twice
|
|
119
|
+
(it can have different documentation on each).
|
|
120
|
+
"""
|
|
121
|
+
total = 0
|
|
122
|
+
described = 0
|
|
123
|
+
for endpoint in endpoints:
|
|
124
|
+
names_in_path = path_parameters(endpoint.path)
|
|
125
|
+
if not names_in_path:
|
|
126
|
+
continue
|
|
127
|
+
# Build a lookup of declared path parameters on this operation.
|
|
128
|
+
declared = {
|
|
129
|
+
p.get("name"): p
|
|
130
|
+
for p in endpoint.parameters
|
|
131
|
+
if isinstance(p, dict) and p.get("in") == "path"
|
|
132
|
+
}
|
|
133
|
+
for name in names_in_path:
|
|
134
|
+
total += 1
|
|
135
|
+
param = declared.get(name)
|
|
136
|
+
if param and (param.get("description") or "").strip():
|
|
137
|
+
described += 1
|
|
138
|
+
return total, described
|