Spaces:
Running
Running
File size: 5,176 Bytes
ff3c2e9 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 | """
check_contract.py β static conformance check against the frontend contract.
Compares every response model in app/schemas/ with the corresponding interface
in outreach-frontend/types/, and every route in app/api/ with the paths
outreach-frontend/lib/api/ actually calls. Pure stdlib: it parses both trees, so
it runs without installing anything and without a database.
uv run python scripts/check_contract.py (or: python3 scripts/check_contract.py)
Exit code is the number of mismatches.
"""
from __future__ import annotations
import ast
import re
import sys
from pathlib import Path
BACKEND = Path(__file__).resolve().parent.parent
FRONTEND = BACKEND.parent / "outreach-frontend"
PAIRS = [
("types/lead.ts", "Lead", "schemas/lead.py", "LeadOut"),
("types/problem.ts", "Problem", "schemas/problem.py", "ProblemOut"),
("types/problem.ts", "ProblemComment", "schemas/problem.py", "ProblemCommentOut"),
("types/metrics.ts", "CrmMetrics", "schemas/metrics.py", "CrmMetrics"),
("types/metrics.ts", "WeekStats", "schemas/metrics.py", "WeekStats"),
("types/metrics.ts", "TeamMetrics", "schemas/metrics.py", "TeamMetrics"),
("types/metrics.ts", "SystemStatus", "schemas/metrics.py", "SystemStatus"),
("types/scan.ts", "ScanResult", "schemas/scan.py", "ScanResult"),
("types/scan.ts", "DecisionMaker", "schemas/scan.py", "DecisionMaker"),
("types/scan.ts", "LinkedInResearch", "schemas/scan.py", "LinkedInResearch"),
("types/api.ts", "Job", "schemas/job.py", "JobOut"),
]
def ts_interface_fields(rel: str, name: str) -> list[str]:
src = (FRONTEND / rel).read_text(encoding="utf-8")
m = re.search(rf"export interface {name}(?:<[^>]*>)?\s*\{{(.*?)\n\}}", src, re.S)
if not m:
raise SystemExit(f"interface {name} not found in {rel}")
body = re.sub(r"//.*", "", re.sub(r"/\*.*?\*/", "", m.group(1), flags=re.S))
return re.findall(r"^\s*([a-z_][A-Za-z0-9_]*)\??\s*:", body, re.M)
def py_model_fields(rel: str, cls: str) -> list[str]:
tree = ast.parse((BACKEND / "app" / rel).read_text(encoding="utf-8"))
for node in ast.walk(tree):
if isinstance(node, ast.ClassDef) and node.name == cls:
return [s.target.id for s in node.body if isinstance(s, ast.AnnAssign)]
raise SystemExit(f"class {cls} not found in {rel}")
def backend_routes() -> set[tuple[str, str]]:
routes: set[tuple[str, str]] = set()
for path in (BACKEND / "app" / "api").glob("*.py"):
tree = ast.parse(path.read_text(encoding="utf-8"))
for node in ast.walk(tree):
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
continue
for dec in node.decorator_list:
if (
isinstance(dec, ast.Call)
and isinstance(dec.func, ast.Attribute)
and dec.func.attr in {"get", "post", "patch", "delete"}
and dec.args
and isinstance(dec.args[0], ast.Constant)
):
routes.add((dec.func.attr.upper(), _normalise(dec.args[0].value)))
return routes
def frontend_routes() -> set[tuple[str, str]]:
"""Pulls api.get/post/patch/delete("...") calls out of lib/api/*.ts."""
routes: set[tuple[str, str]] = set()
for path in (FRONTEND / "lib" / "api").glob("*.ts"):
src = path.read_text(encoding="utf-8")
for verb, raw in re.findall(
r"api\.(get|post|patch|delete)<[^>]*>\(\s*[`\"']([^`\"']+)", src
):
routes.add((verb.upper(), _normalise(raw)))
return routes
def _normalise(p: str) -> str:
"""/leads/${id} and /leads/{lead_id} both become /leads/:id."""
p = re.sub(r"\$\{[^}]+\}", ":id", p)
p = re.sub(r"\{[^}]+\}", ":id", p)
return "/" + p.strip("/")
def main() -> int:
failures = 0
print("ββ response shapes ββ")
for ts_rel, iface, py_rel, cls in PAIRS:
want, got = ts_interface_fields(ts_rel, iface), py_model_fields(py_rel, cls)
missing = [f for f in want if f not in got]
extra = [f for f in got if f not in want]
if missing or extra:
failures += 1
print(f"DIFF {iface:18s} missing={missing} extra={extra}")
else:
print(f"OK {iface:18s} {len(want)} fields")
print("\nββ routes ββ")
be, fe = backend_routes(), frontend_routes()
# /jobs/{discover|scan|import} is one client call with a variable kind slug.
fe.discard(("POST", "/jobs/:id"))
for kind in ("discover", "scan", "import"):
fe.add(("POST", f"/jobs/{kind}"))
for verb, path in sorted(fe):
if (verb, path) in be:
print(f"OK {verb:6s} {path}")
else:
failures += 1
print(f"MISS {verb:6s} {path} β frontend calls it, backend has no route")
unused = sorted(be - fe)
if unused:
print("\nbackend routes the frontend does not call (fine, just noting):")
for verb, path in unused:
print(f" {verb:6s} {path}")
print(f"\nmismatches: {failures}")
return failures
if __name__ == "__main__":
sys.exit(main())
|