#!/usr/bin/env python3 # SPDX-License-Identifier: AGPL-3.0-only # Copyright (c) 2026 sol pbc """HTTP API conventions lint. Static check for the response/error conventions documented in ``docs/CONVEY.md`` § "HTTP API conventions". It scans every convey route module — discovered by *which files register a Flask ``Blueprint``* under ``solstone/apps/`` and ``solstone/convey/`` (so a new app or core blueprint is picked up automatically) — and, inside each JSON-returning handler, flags the escape hatches the conventions forbid: - ``abort`` — an ``abort(...)`` call. - ``bare-return`` — a bare ``return "", <4xx>`` tuple. - ``inline-error`` — an in-band error body built inline (``jsonify({"error": ...})`` / ``return {"error": ...}``) outside the sanctioned ``error_response`` / ``error_response_with_reason`` helpers. - ``bare-array`` — returning a bare top-level list (``jsonify([...])`` / ``jsonify()`` / ``return [...]``). - ``render-template`` — a Flask ``render_template(...)`` call anywhere under the scanned source scopes, excluding tests. The only allowed instances are the PDF helpers in the ``news`` and ``reflections`` apps. A handler is **JSON-governed** when any of its return paths produces a JSON body: it calls ``jsonify(...)``, returns one of the response helpers (``error_response`` / ``error_response_with_reason`` / ``success_response`` / ``respond_collection`` / ``created``), or returns a dict/list literal. A handler whose only returns are ``render_template`` / ``redirect`` / ``send_file`` / a plain string (a page-or-file route) is exempt from the ``abort`` and ``bare-return`` rules; a bare top-level array is always flagged. Classification is by return style, never by URL — the ``/api/`` path segment is an unreliable signal (app routes carry it in the decorator, ``chat_bp`` carries it in the blueprint ``url_prefix``, and genuinely-JSON endpoints carry it nowhere). The check ships green via a committed ``ALLOWLIST`` of the violations that exist on the current tree, keyed by ``(file, kind)`` with an allowed **count**. A new violation that pushes any ``(file, kind)`` count above its allowed number fails the check; fixing occurrences lets the allowed count be lowered, so the allowlist ratchets toward empty. It is never keyed by line number (brittle to edits above the occurrence) and never a blanket per-file disable (which would hide future new violations in that file). Exit codes: 0 — no un-allowlisted violations 1 — a (file, kind) count exceeds its allowlisted number """ from __future__ import annotations import argparse import ast import sys from pathlib import Path ROOT = Path(__file__).resolve().parent.parent # Trees scanned for Blueprint-registering route modules. SCAN_SCOPES: tuple[str, ...] = ( "solstone/apps", "solstone/convey", ) # Response helpers that legitimately produce a JSON body. A call to one of # these is JSON-producing for classification and is NOT an inline error. RESPONSE_HELPERS: frozenset[str] = frozenset( { "error_response", "error_response_with_reason", "success_response", "respond_collection", "created", } ) # Decorator attributes that mark a function as a Flask route handler. ROUTE_DECORATORS: frozenset[str] = frozenset( {"route", "get", "post", "put", "patch", "delete"} ) # Committed allowlist of violations on the current tree, keyed by # (posix-relative-path, kind) -> allowed count. Ratchets toward empty: lower a # count as occurrences are fixed; never raise one to admit a new violation. ALLOWLIST: dict[tuple[str, str], int] = { ("solstone/apps/network/routes.py", "abort"): 1, ("solstone/apps/news/routes.py", "render-template"): 1, ("solstone/apps/reflections/routes.py", "render-template"): 1, } def _func_name(func: ast.expr) -> str | None: """Return the called name for ``Name``/``Attribute`` call targets.""" if isinstance(func, ast.Name): return func.id if isinstance(func, ast.Attribute): return func.attr return None def _iter_local_nodes(node: ast.AST): """Yield descendants of ``node``, not descending into nested functions. Stops at nested ``FunctionDef`` / ``AsyncFunctionDef`` / ``Lambda`` so that a handler's classification and violations are not polluted by inner helpers or generators (e.g. an SSE ``generate()`` closure). """ for child in ast.iter_child_nodes(node): if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)): continue yield child yield from _iter_local_nodes(child) def _is_route_handler(func: ast.FunctionDef | ast.AsyncFunctionDef) -> bool: for dec in func.decorator_list: if isinstance(dec, ast.Call) and _func_name(dec.func) in ROUTE_DECORATORS: return True return False def _is_jsonify_call(expr: ast.expr | None) -> bool: return isinstance(expr, ast.Call) and _func_name(expr.func) == "jsonify" def _collect_list_names(nodes: list[ast.AST]) -> set[str]: """Names bound to an obvious list value within the handler body.""" names: set[str] = set() for node in nodes: if isinstance(node, ast.Assign): value = node.value is_list = isinstance(value, (ast.List, ast.ListComp)) or ( isinstance(value, ast.Call) and _func_name(value.func) == "list" ) if is_list: for target in node.targets: if isinstance(target, ast.Name): names.add(target.id) elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name): ann = node.annotation ann_name = None if isinstance(ann, ast.Name): ann_name = ann.id elif isinstance(ann, ast.Subscript) and isinstance(ann.value, ast.Name): ann_name = ann.value.id if ann_name in ("list", "List"): names.add(node.target.id) if isinstance(node.value, (ast.List, ast.ListComp)): names.add(node.target.id) return names def _is_list_payload(expr: ast.expr | None, list_names: set[str]) -> bool: if isinstance(expr, (ast.List, ast.ListComp)): return True return isinstance(expr, ast.Name) and expr.id in list_names def _payload_expr(ret: ast.Return) -> ast.expr | None: """The value carried by a return, unwrapping ``(body, status)`` tuples.""" value = ret.value if isinstance(value, ast.Tuple) and value.elts: return value.elts[0] return value def _produces_json(expr: ast.expr | None, list_names: set[str]) -> bool: if expr is None: return False if _is_jsonify_call(expr): return True if isinstance(expr, ast.Call) and _func_name(expr.func) in RESPONSE_HELPERS: return True if isinstance(expr, (ast.Dict, ast.List, ast.ListComp, ast.DictComp)): return True return _is_list_payload(expr, list_names) def _dict_has_error_key(expr: ast.expr | None) -> bool: return isinstance(expr, ast.Dict) and any( isinstance(key, ast.Constant) and key.value == "error" for key in expr.keys ) def _is_inline_error(payload: ast.expr | None) -> bool: if _is_jsonify_call(payload): assert isinstance(payload, ast.Call) return len(payload.args) == 1 and _dict_has_error_key(payload.args[0]) return _dict_has_error_key(payload) def _is_bare_array(payload: ast.expr | None, list_names: set[str]) -> bool: if _is_jsonify_call(payload): assert isinstance(payload, ast.Call) return len(payload.args) == 1 and _is_list_payload(payload.args[0], list_names) return _is_list_payload(payload, list_names) def _is_bare_status_return(ret: ast.Return) -> bool: value = ret.value if not (isinstance(value, ast.Tuple) and len(value.elts) == 2): return False body, status = value.elts return ( isinstance(body, ast.Constant) and isinstance(body.value, str) and isinstance(status, ast.Constant) and isinstance(status.value, int) and not isinstance(status.value, bool) and 400 <= status.value <= 499 ) def classify_handler( func: ast.FunctionDef | ast.AsyncFunctionDef, ) -> list[tuple[int, str]]: """Return ``(lineno, kind)`` violations for a single route handler.""" nodes = list(_iter_local_nodes(func)) list_names = _collect_list_names(nodes) returns = [n for n in nodes if isinstance(n, ast.Return)] json_governed = any(_produces_json(_payload_expr(r), list_names) for r in returns) findings: list[tuple[int, str]] = [] # abort(...) anywhere in the handler's own scope. if json_governed: for node in nodes: if isinstance(node, ast.Call) and _func_name(node.func) == "abort": findings.append((node.lineno, "abort")) for ret in returns: payload = _payload_expr(ret) if json_governed and _is_bare_status_return(ret): findings.append((ret.lineno, "bare-return")) if _is_inline_error(payload): findings.append((ret.lineno, "inline-error")) if _is_bare_array(payload, list_names): findings.append((ret.lineno, "bare-array")) return findings def module_registers_blueprint(tree: ast.AST) -> bool: for node in ast.walk(tree): if isinstance(node, ast.Call) and _func_name(node.func) == "Blueprint": return True return False def discover_modules(root: Path) -> list[Path]: """Posix-relative paths of Blueprint-registering modules under the scopes.""" found: list[Path] = [] for scope in SCAN_SCOPES: scope_dir = root / scope if not scope_dir.is_dir(): continue for path in sorted(scope_dir.rglob("*.py")): if "__pycache__" in path.parts: continue try: tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) except (SyntaxError, UnicodeDecodeError): continue if module_registers_blueprint(tree): found.append(path.relative_to(root)) return found def _flask_render_template_names(tree: ast.AST) -> tuple[set[str], set[str]]: """Local names bound to flask.render_template. Returns (direct, modules): ``direct`` are names from ``from flask import render_template [as X]`` (module- OR function-scoped); ``modules`` are names bound to the flask module via ``import flask [as X]``, whose ``.render_template`` attribute is the same callable. """ direct: set[str] = set() modules: set[str] = set() for node in ast.walk(tree): if isinstance(node, ast.ImportFrom) and node.module == "flask": for alias in node.names: if alias.name == "render_template": direct.add(alias.asname or alias.name) elif isinstance(node, ast.Import): for alias in node.names: if alias.name == "flask": modules.add(alias.asname or "flask") return direct, modules def scan_render_templates(source: str, filename: str = "") -> list[int]: """Line numbers of flask ``render_template(...)`` calls in a module source.""" tree = ast.parse(source, filename=filename) direct, modules = _flask_render_template_names(tree) if not direct and not modules: return [] linenos: list[int] = [] for node in ast.walk(tree): if not isinstance(node, ast.Call): continue func = node.func if isinstance(func, ast.Name) and func.id in direct: linenos.append(node.lineno) elif ( isinstance(func, ast.Attribute) and func.attr == "render_template" and isinstance(func.value, ast.Name) and func.value.id in modules ): linenos.append(node.lineno) return sorted(linenos) def discover_all_modules(root: Path) -> list[Path]: """Posix-relative paths of ALL non-test ``*.py`` modules under the scopes.""" found: list[Path] = [] for scope in SCAN_SCOPES: scope_dir = root / scope if not scope_dir.is_dir(): continue for path in sorted(scope_dir.rglob("*.py")): if "__pycache__" in path.parts or "tests" in path.parts: continue found.append(path.relative_to(root)) return found def scan_source(source: str, filename: str = "") -> list[tuple[int, str, str]]: """Return ``(lineno, kind, function_name)`` violations for a module source.""" tree = ast.parse(source, filename=filename) findings: list[tuple[int, str, str]] = [] for node in ast.walk(tree): if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): continue if not _is_route_handler(node): continue for lineno, kind in classify_handler(node): findings.append((lineno, kind, node.name)) findings.sort() return findings def scan_file(path: Path) -> list[tuple[int, str, str]]: return scan_source(path.read_text(encoding="utf-8"), filename=str(path)) def count_violations(root: Path) -> dict[tuple[str, str], int]: """Map ``(posix-relpath, kind)`` -> occurrence count across the tree.""" counts: dict[tuple[str, str], int] = {} for rel in discover_modules(root): for _lineno, kind, _func in scan_file(root / rel): counts[(rel.as_posix(), kind)] = counts.get((rel.as_posix(), kind), 0) + 1 for rel in discover_all_modules(root): linenos = scan_render_templates( (root / rel).read_text(encoding="utf-8"), str(rel) ) if linenos: key = (rel.as_posix(), "render-template") counts[key] = counts.get(key, 0) + len(linenos) return counts def _account( new: list[str], tracked: list[str], rel_str: str, kind: str, linenos: list[int], allowlist: dict[tuple[str, str], int], ) -> None: count = len(linenos) allowed = allowlist.get((rel_str, kind), 0) if count > allowed: lines = ", ".join(str(n) for n in sorted(linenos)) new.append(f"{rel_str}: {count} {kind} (allowed {allowed}) at line(s) {lines}") elif allowed: tracked.append(f"{rel_str}: {count}/{allowed} {kind} (allowlisted)") def evaluate( root: Path, allowlist: dict[tuple[str, str], int], ) -> tuple[list[str], list[str]]: """Return ``(new_violations, tracked)`` human-readable lines.""" new: list[str] = [] tracked: list[str] = [] for rel in discover_modules(root): rel_str = rel.as_posix() findings = scan_file(root / rel) by_kind: dict[str, list[int]] = {} for lineno, kind, _func in findings: by_kind.setdefault(kind, []).append(lineno) for kind, linenos in sorted(by_kind.items()): _account(new, tracked, rel_str, kind, linenos, allowlist) for rel in discover_all_modules(root): rel_str = rel.as_posix() linenos = scan_render_templates( (root / rel).read_text(encoding="utf-8"), str(rel) ) if linenos: _account(new, tracked, rel_str, "render-template", linenos, allowlist) return new, tracked def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description="HTTP API conventions lint") parser.add_argument( "--root", type=Path, default=ROOT, help="Repository root to scan (defaults to the checkout root).", ) args = parser.parse_args(argv) new, tracked = evaluate(args.root, ALLOWLIST) if tracked: print("api-conventions: known violations (allowlisted, ratcheting down):") for line in tracked: print(f" {line}") print() if new: print("api-conventions: NEW violations:", file=sys.stderr) for line in new: print(f" {line}", file=sys.stderr) print(file=sys.stderr) print( "See docs/CONVEY.md § HTTP API conventions. Route a collection " "through respond_collection(), a create through created(), and " "every error through error_response().", file=sys.stderr, ) return 1 print("api-conventions: pass") return 0 if __name__ == "__main__": raise SystemExit(main())