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 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
@@ -0,0 +1,6 @@
1
+ """Allow running with `python -m artie`."""
2
+
3
+ from artie.cli import app
4
+
5
+ if __name__ == "__main__":
6
+ app()
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
+ )
@@ -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