|
| 1 | +# Wave 2 — `hawkapi doctor` — design spec |
| 2 | + |
| 3 | +**Status:** Approved — ready for implementation |
| 4 | +**Date:** 2026-04-19 |
| 5 | +**Scope:** Ship `hawkapi doctor app:app` — a one-shot health check that lints a running HawkAPI application for common misconfigurations across security, observability, performance, correctness, and dependency hygiene. Produces human-readable or JSON output, exits non-zero on warn/error findings. |
| 6 | + |
| 7 | +--- |
| 8 | + |
| 9 | +## Goal |
| 10 | + |
| 11 | +```bash |
| 12 | +$ hawkapi doctor app:app |
| 13 | +🩺 hawkapi doctor — app:app |
| 14 | + Scanned 42 routes, 8 middleware, 3 plugins |
| 15 | + |
| 16 | +✗ DOC010 CORS allows '*' in production |
| 17 | + Fix: whitelist specific origins in CORSMiddleware. |
| 18 | + https://hawkapi.ashimov.com/doctor/DOC010 |
| 19 | + |
| 20 | +⚠ DOC003 /api/v1/users:POST |
| 21 | + No response_model declared; handler returns dict. |
| 22 | + Fix: add a msgspec.Struct return annotation. |
| 23 | + https://hawkapi.ashimov.com/doctor/DOC003 |
| 24 | + |
| 25 | +Summary: 1 errors, 1 warnings, 0 info · exit 1 |
| 26 | +``` |
| 27 | + |
| 28 | +## CLI surface |
| 29 | + |
| 30 | +``` |
| 31 | +hawkapi doctor <APP_SPEC> [--format={human,json}] [--severity={info,warn,error}] [--fix] |
| 32 | +``` |
| 33 | + |
| 34 | +- `APP_SPEC` — `module:attr` form (same as `hawkapi dev`, `hawkapi check`). |
| 35 | +- `--format` — `human` (default, coloured if TTY) or `json` (machine-readable). |
| 36 | +- `--severity` — minimum severity to report. Default `info` (everything). |
| 37 | +- `--fix` — apply safe, deterministic fixes where a rule supports `auto_fix`. v1: very few rules support this; print `"no auto-fixes available"` when none apply. |
| 38 | +- Exit codes: `0` = no findings at the chosen severity or above. `1` = at least one warn. `2` = at least one error. |
| 39 | + |
| 40 | +## Rule architecture |
| 41 | + |
| 42 | +```python |
| 43 | +from enum import IntEnum |
| 44 | +from dataclasses import dataclass |
| 45 | +from typing import Protocol |
| 46 | + |
| 47 | +class Severity(IntEnum): |
| 48 | + INFO = 1 |
| 49 | + WARN = 2 |
| 50 | + ERROR = 3 |
| 51 | + |
| 52 | +@dataclass(frozen=True, slots=True) |
| 53 | +class Finding: |
| 54 | + rule_id: str |
| 55 | + severity: Severity |
| 56 | + message: str |
| 57 | + fix: str | None = None |
| 58 | + location: str | None = None # e.g. "POST /api/v1/users" |
| 59 | + docs_url: str | None = None |
| 60 | + |
| 61 | +class Rule(Protocol): |
| 62 | + id: str |
| 63 | + category: str # security | observability | performance | correctness | deps |
| 64 | + severity: Severity # default severity (actual finding can override) |
| 65 | + title: str |
| 66 | + docs_url: str |
| 67 | + def check(self, app: "HawkAPI") -> list[Finding]: ... |
| 68 | +``` |
| 69 | + |
| 70 | +Rules are discovered from a static list in `src/hawkapi/doctor/rules/__init__.py` (no dynamic plugin discovery in v1 — keep it simple, auditable). |
| 71 | + |
| 72 | +## Rules v1 |
| 73 | + |
| 74 | +### Security (5) |
| 75 | +- `DOC010` **CORS allows `*` in production.** Detect `CORSMiddleware(allow_origins=["*"])` or equivalent. |
| 76 | +- `DOC011` **CSRFMiddleware not installed but state-changing routes exist.** Warns if any POST/PUT/PATCH/DELETE route is registered and CSRFMiddleware is absent. |
| 77 | +- `DOC012` **TrustedProxyMiddleware missing behind a known proxy.** Warns if `X-Forwarded-*` headers may be parsed without trust configured. |
| 78 | +- `DOC013` **Bearer/OAuth2 auth with hardcoded secrets.** Scan `OAuth2PasswordBearer(tokenUrl=...)` for URL + check Settings/env for obvious placeholders (`changeme`, `secret`, `dev`). |
| 79 | +- `DOC014` **HTTPSRedirectMiddleware absent.** Info-level in dev, warn if `ENV=production` env var is set. |
| 80 | + |
| 81 | +### Observability (4) |
| 82 | +- `DOC020` **No Request-ID / structured-logging / observability middleware installed.** Warn — one-line fix. |
| 83 | +- `DOC021` **No `/metrics` endpoint.** If `PrometheusMiddleware` absent and an observability stack is likely (OTel plugin, Prometheus plugin deps installed), info. |
| 84 | +- `DOC022` **No OTel wiring.** If `opentelemetry` is importable but no `hawkapi-otel` plugin registered, info. |
| 85 | +- `DOC023` **No Sentry wiring.** If `sentry_sdk` is importable but `hawkapi-sentry` plugin absent, info. |
| 86 | + |
| 87 | +### Performance (4) |
| 88 | +- `DOC030` **`debug=True` on HawkAPI constructor in what looks like a production app.** Error if `ENV=production` else info. |
| 89 | +- `DOC031` **GZipMiddleware absent and routes return >1 KiB JSON on average.** Warn — static analysis of `response_model` payloads. |
| 90 | +- `DOC032` **Handler returning `dict`/`list` primitives without `response_model`.** Warn — auto-inference only catches msgspec/Pydantic types. Bare dict response bypasses filtering. |
| 91 | +- `DOC033` **No bulkhead on routes that look like heavy I/O** (handler signature contains `db` / `session` / `http_client` / `redis`). Info. |
| 92 | + |
| 93 | +### Correctness (3) |
| 94 | +- `DOC040` **Route handler missing return annotation.** Info — blocks auto-`response_model` inference. |
| 95 | +- `DOC041` **Route without docstring or `summary=`.** Info — produces empty OpenAPI summary. |
| 96 | +- `DOC042` **Middleware order suspicious.** Warn if `CORSMiddleware` appears after authentication-style middleware (heuristic: any middleware named `*Auth*` / class of `HTTPBearer`-using middleware). Order matters: CORS should run first. |
| 97 | + |
| 98 | +### Dependencies (2) |
| 99 | +- `DOC050` **HawkAPI version older than latest published.** Info — read `hawkapi.__version__`, fetch latest from PyPI (best-effort; skip if offline). |
| 100 | +- `DOC051` **`msgspec` version < 0.19.** Warn — known perf gap. |
| 101 | + |
| 102 | +### Total: 18 rules. Easy to extend by adding a file under `rules/`. |
| 103 | + |
| 104 | +## Output formats |
| 105 | + |
| 106 | +### Human (default) |
| 107 | +- Group by severity (errors first, then warnings, then info). |
| 108 | +- Emoji prefix per severity (`✗` error / `⚠` warn / `ℹ` info), coloured when stdout is a TTY (use `rich` if already in deps, else plain ANSI codes). |
| 109 | +- Footer summary line with counts and exit code. |
| 110 | + |
| 111 | +### JSON |
| 112 | +```json |
| 113 | +{ |
| 114 | + "app": "app:app", |
| 115 | + "summary": {"errors": 1, "warnings": 1, "info": 0, "total": 2}, |
| 116 | + "findings": [ |
| 117 | + {"rule_id": "DOC010", "severity": "error", "message": "...", "fix": "...", "location": null, "docs_url": "..."} |
| 118 | + ] |
| 119 | +} |
| 120 | +``` |
| 121 | + |
| 122 | +## `--fix` mode (v1) |
| 123 | + |
| 124 | +Applies only to rules that declare `auto_fix: Callable[[HawkAPI], bool]`. v1 ships zero `auto_fix` implementations — print `"--fix: no auto-fixable findings"` and exit per severity rule. The infrastructure is there, but each rule must opt in carefully (most need user judgement). |
| 125 | + |
| 126 | +## Module layout |
| 127 | + |
| 128 | +``` |
| 129 | +src/hawkapi/doctor/ |
| 130 | + __init__.py # re-exports Rule, Finding, Severity, run() |
| 131 | + _runner.py # orchestration: load app, run rules, filter, format, exit |
| 132 | + _formatter.py # human + json output |
| 133 | + _types.py # Rule Protocol, Finding, Severity |
| 134 | + rules/ |
| 135 | + __init__.py # ALL_RULES = [...] static list |
| 136 | + security.py # DOC010–DOC014 |
| 137 | + observability.py # DOC020–DOC023 |
| 138 | + performance.py # DOC030–DOC033 |
| 139 | + correctness.py # DOC040–DOC042 |
| 140 | + deps.py # DOC050–DOC051 |
| 141 | +
|
| 142 | +src/hawkapi/cli.py |
| 143 | + +doctor subcommand wiring |
| 144 | +``` |
| 145 | + |
| 146 | +Each file < 200 lines. |
| 147 | + |
| 148 | +## Tests — `tests/unit/test_doctor.py` |
| 149 | + |
| 150 | +~25 tests. For each rule: one happy-path (clean app → no finding), one failing-path (misconfigured app → finding). Plus: |
| 151 | +- `_runner.run(app)` returns findings list. |
| 152 | +- `--format=json` output shape. |
| 153 | +- `--severity=warn` filters info. |
| 154 | +- Exit code mapping. |
| 155 | +- CLI smoke test (`hawkapi doctor app:app` via `subprocess`). |
| 156 | + |
| 157 | +## Docs — `docs/guide/doctor.md` |
| 158 | + |
| 159 | +- Overview + usage. |
| 160 | +- Full rule reference table (ID, category, severity, description, fix). |
| 161 | +- `--fix` caveat. |
| 162 | +- CI integration example (one-liner GitHub Actions step). |
| 163 | + |
| 164 | +## Mkdocs nav + CHANGELOG |
| 165 | + |
| 166 | +- `mkdocs.yml`: `- Doctor: guide/doctor.md` after "OpenAPI linter". |
| 167 | +- `CHANGELOG.md`: `[Unreleased] ### Added` bullet → target v0.1.4. |
| 168 | +- README.md: add a short "Doctor" subsection under "CLI" or "Production Features". |
| 169 | + |
| 170 | +## Out of scope (v2+) |
| 171 | + |
| 172 | +- Dynamic rule discovery (entry points / plugins). |
| 173 | +- `--fix` with actual auto-fixes (needs per-rule careful design). |
| 174 | +- Runtime traffic analysis (would require proxy/sniffer). |
| 175 | +- Config-file output (`.hawkapi-doctor.toml` for rule overrides). |
| 176 | +- IDE integration (SARIF/LSP) — could be a follow-up. |
| 177 | + |
| 178 | +## Success criteria |
| 179 | + |
| 180 | +1. `hawkapi doctor app:app` runs all 18 rules on a real HawkAPI app. |
| 181 | +2. Exit codes map: clean → 0, warn → 1, error → 2. |
| 182 | +3. `--format=json` produces stable schema. |
| 183 | +4. CHANGELOG + docs + mkdocs nav entry present and mkdocs strict-clean. |
| 184 | +5. Full suite + ruff + pyright-strict clean. |
| 185 | + |
| 186 | +## Files touched |
| 187 | + |
| 188 | +- `src/hawkapi/doctor/**` — new (10 files) |
| 189 | +- `src/hawkapi/cli.py` — add `doctor` subcommand |
| 190 | +- `tests/unit/test_doctor.py` — new |
| 191 | +- `docs/guide/doctor.md` — new |
| 192 | +- `mkdocs.yml` — nav entry |
| 193 | +- `CHANGELOG.md` — bullet |
| 194 | +- `README.md` — add Doctor subsection |
| 195 | + |
| 196 | +## Rollback |
| 197 | + |
| 198 | +New module + new CLI subcommand + new docs. No existing paths change. Revert = delete `doctor/` package, remove one argparse subparser, revert three doc diffs. |
0 commit comments