|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Lint Flashduty public OpenAPI specs for documentation quality. |
| 3 | +
|
| 4 | +Stdlib-only. Lints every api-reference/*.openapi.*.json file (per-module and |
| 5 | +consolidated) and fails on: |
| 6 | +
|
| 7 | + missing-description every schema property must carry a non-empty description |
| 8 | + (recursively, including array item objects and inline |
| 9 | + allOf members; pure $ref nodes are exempt) |
| 10 | + epoch-wording integer fields named ts / timestamp / *_at / *_time must |
| 11 | + mention Unix/epoch/timestamp/时间戳 in the description — |
| 12 | + the go-flashduty SDK maps these fields by description text |
| 13 | + enum-undocumented every value of a string enum must be mentioned in the |
| 14 | + property description (backticked or bare), so readers |
| 15 | + learn what each value means |
| 16 | + missing-example every operation needs a request example (when it has a |
| 17 | + request body) and a 200 response example |
| 18 | +
|
| 19 | +Usage: |
| 20 | + python3 scripts/lint_openapi.py # lint all spec files, exit 1 on violations |
| 21 | + python3 scripts/lint_openapi.py --warn-only |
| 22 | +""" |
| 23 | +import json |
| 24 | +import re |
| 25 | +import sys |
| 26 | +from pathlib import Path |
| 27 | + |
| 28 | +ROOT = Path(__file__).resolve().parent.parent |
| 29 | +SPEC_GLOB = "api-reference/*.json" |
| 30 | +# the legacy hand-maintained Apifox export is exempt — it is read-only reference |
| 31 | +SPEC_EXCLUDE = ("openapi.legacy",) |
| 32 | + |
| 33 | +EPOCH_RE = re.compile(r"\b(unix|epoch|timestamp)\b|时间戳", re.I) |
| 34 | +EPOCH_NAME_RE = re.compile(r"^(ts|timestamp)$|_at$|_time$") |
| 35 | +# duration-ish names that only look like timestamps |
| 36 | +DURATION_HINT_RE = re.compile(r"timeout|interval|duration|delay|elapsed") |
| 37 | + |
| 38 | +# enum value sets whose meaning is self-evident everywhere they appear; |
| 39 | +# anything else must explain each value in the property description |
| 40 | +WELL_KNOWN_ENUMS = [ |
| 41 | + {"Critical", "Warning", "Info"}, |
| 42 | + {"Critical", "Warning", "Info", "Ok"}, |
| 43 | + {"enabled", "disabled"}, |
| 44 | + {"success", "failed"}, |
| 45 | +] |
| 46 | + |
| 47 | +# field-name exemptions: order-by enums list sortable field names, whose |
| 48 | +# meaning lives on the referenced fields themselves |
| 49 | +ENUM_EXEMPT_NAMES = re.compile(r"^(orderby|order_by)$") |
| 50 | + |
| 51 | + |
| 52 | +def enum_is_well_known(values) -> bool: |
| 53 | + vals = {v for v in values if isinstance(v, str) and v} |
| 54 | + return any(vals <= s for s in WELL_KNOWN_ENUMS) |
| 55 | + |
| 56 | + |
| 57 | +class Reporter: |
| 58 | + def __init__(self): |
| 59 | + self.violations = [] |
| 60 | + |
| 61 | + def add(self, file, rule, where, detail): |
| 62 | + self.violations.append((file, rule, where, detail)) |
| 63 | + |
| 64 | + def dump(self): |
| 65 | + by_rule = {} |
| 66 | + for f, rule, where, detail in self.violations: |
| 67 | + by_rule.setdefault(rule, []).append((f, where, detail)) |
| 68 | + for rule, items in sorted(by_rule.items()): |
| 69 | + print(f"\n## {rule} ({len(items)})") |
| 70 | + for f, where, detail in items[:50]: |
| 71 | + print(f" {f}: {where} — {detail}") |
| 72 | + if len(items) > 50: |
| 73 | + print(f" ... and {len(items) - 50} more") |
| 74 | + print(f"\nTotal: {len(self.violations)} violation(s)") |
| 75 | + |
| 76 | + |
| 77 | +def walk_properties(node, where, file, rep, seen): |
| 78 | + """Recursively check every property under a schema node.""" |
| 79 | + if not isinstance(node, dict): |
| 80 | + return |
| 81 | + oid = id(node) |
| 82 | + if oid in seen: |
| 83 | + return |
| 84 | + seen.add(oid) |
| 85 | + |
| 86 | + for member in node.get("allOf", []): |
| 87 | + if isinstance(member, dict) and "$ref" not in member: |
| 88 | + walk_properties(member, where, file, rep, seen) |
| 89 | + |
| 90 | + for name, prop in node.get("properties", {}).items(): |
| 91 | + if not isinstance(prop, dict): |
| 92 | + continue |
| 93 | + pwhere = f"{where}.{name}" |
| 94 | + if "$ref" in prop: |
| 95 | + continue |
| 96 | + desc = prop.get("description") |
| 97 | + if not desc or not desc.strip(): |
| 98 | + rep.add(file, "missing-description", pwhere, f"type={prop.get('type')}") |
| 99 | + desc = "" |
| 100 | + # epoch wording |
| 101 | + if prop.get("type") == "integer" and EPOCH_NAME_RE.search(name) \ |
| 102 | + and not DURATION_HINT_RE.search(name): |
| 103 | + if not EPOCH_RE.search(desc): |
| 104 | + rep.add(file, "epoch-wording", pwhere, |
| 105 | + "integer time field without Unix/epoch/timestamp wording") |
| 106 | + # enum values documented |
| 107 | + enum = prop.get("enum") |
| 108 | + if enum and prop.get("type") == "string" and desc and not enum_is_well_known(enum) \ |
| 109 | + and not ENUM_EXEMPT_NAMES.match(name): |
| 110 | + missing = [v for v in enum if isinstance(v, str) and v and v not in desc] |
| 111 | + if missing: |
| 112 | + rep.add(file, "enum-undocumented", pwhere, |
| 113 | + f"enum values not explained in description: {missing}") |
| 114 | + # recurse |
| 115 | + if prop.get("type") == "object" or "properties" in prop: |
| 116 | + walk_properties(prop, pwhere, file, rep, seen) |
| 117 | + items = prop.get("items") |
| 118 | + if isinstance(items, dict) and "$ref" not in items: |
| 119 | + walk_properties(items, pwhere + "[]", file, rep, seen) |
| 120 | + |
| 121 | + |
| 122 | +def lint_operations(spec, file, rep): |
| 123 | + for path, pi in spec.get("paths", {}).items(): |
| 124 | + if not isinstance(pi, dict): |
| 125 | + continue |
| 126 | + for method, op in pi.items(): |
| 127 | + if not isinstance(op, dict): |
| 128 | + continue |
| 129 | + where = f"{method.upper()} {path}" |
| 130 | + body = op.get("requestBody", {}).get("content", {}).get("application/json", {}) |
| 131 | + if body and "example" not in body and "examples" not in body: |
| 132 | + rep.add(file, "missing-example", where, "request body has no example") |
| 133 | + ok = op.get("responses", {}).get("200", {}) |
| 134 | + if "$ref" not in ok: |
| 135 | + content = ok.get("content", {}).get("application/json", {}) |
| 136 | + # only JSON responses can carry a JSON example; binary/ndjson |
| 137 | + # downloads and empty 200s are exempt |
| 138 | + if content and "example" not in content and "examples" not in content: |
| 139 | + rep.add(file, "missing-example", where, "200 response has no example") |
| 140 | + |
| 141 | + |
| 142 | +def lint_file(path, rep): |
| 143 | + spec = json.load(open(path)) |
| 144 | + file = path.name |
| 145 | + for name, schema in spec.get("components", {}).get("schemas", {}).items(): |
| 146 | + walk_properties(schema, f"schema:{name}", file, rep, set()) |
| 147 | + lint_operations(spec, file, rep) |
| 148 | + |
| 149 | + |
| 150 | +def main(): |
| 151 | + files = [f for f in sorted(ROOT.glob(SPEC_GLOB)) |
| 152 | + if not any(x in f.name for x in SPEC_EXCLUDE)] |
| 153 | + if not files: |
| 154 | + print(f"no spec files matched {SPEC_GLOB} under {ROOT}", file=sys.stderr) |
| 155 | + return 2 |
| 156 | + rep = Reporter() |
| 157 | + for f in files: |
| 158 | + lint_file(f, rep) |
| 159 | + if not rep.violations: |
| 160 | + print(f"OK: {len(files)} spec files, no violations") |
| 161 | + return 0 |
| 162 | + rep.dump() |
| 163 | + if "--warn-only" in sys.argv: |
| 164 | + return 0 |
| 165 | + return 1 |
| 166 | + |
| 167 | + |
| 168 | +if __name__ == "__main__": |
| 169 | + sys.exit(main()) |
0 commit comments