uv-doctor
A uv-hygiene scorecard for Python projects. Drop your pyproject.toml + uv.lock, or paste a public GitHub repo URL, and get:
- a 0–100 hygiene score
- a per-category breakdown (Metadata, Specifiers, Groups, Lockfile, Legacy)
- the top 10 specific papercuts with copy-pasteable fixes, each citing the relevant page on docs.astral.sh/uv
- a concatenated fix script (
uv add …,uv lock --upgrade, TOML diffs) you can paste into a terminal - a persistent share link + embeddable SVG card for READMEs / tweets
- a leaderboard of curated public Python repos, refreshed nightly from real GitHub fetches
This is the implementation of the spec in SPEC.md.
How it works
Every score on this site is computed from a real pyproject.toml (and optional uv.lock) fetched at request time. The leaderboard is computed nightly from live GitHub Contents API calls — no precomputed scores, no hardcoded findings, no Math.random().
The 15 hygiene rules and their weights live in lib/rules.js. Each rule is a pure function over a parsed AST; each finding carries a doc_url pointing at the official uv documentation.
Running locally
npm install # builds better-sqlite3 against your Node
cp .env.example .env
PORT=4842 npm start
Then open http://localhost:4842/uv-doctor/.
Optional environment
| Variable | Default | Purpose |
|---|---|---|
| PORT | 4842 | Listen port |
| BASE_PATH | /uv-doctor | URL prefix (must match the nginx route) |
| NODE_ENV | development | production enables stricter logging; test disables the boot-time leaderboard warm-up and cron |
| GITHUB_TOKEN | — | Optional. Without it the GitHub Contents API allows 60 req/hour per IP. With any PAT (no scopes needed for public repos): 5000/hour. Used for both the URL-flow audits and the nightly leaderboard refresh. |
| UV_DOCTOR_DB | ./uv-doctor.db | SQLite file path |
API
All routes mounted under /uv-doctor.
| Method | Path | Behavior |
|---|---|---|
| GET | /health | {ok:true} HTTP 200, no auth |
| GET | / | SPA shell (HTML) |
| GET | /style.css, /app.js | Static assets |
| POST | /api/audit | Body {pyproject_toml, uv_lock?, project_name?}. Runs the 15 rules and returns {id, score, breakdown, findings, fix_script}. Persists the audit so it can be retrieved by id. |
| POST | /api/audit-github | Body {repo_url}. Accepts owner/repo, https://github.com/owner/repo, https://github.com/owner/repo/tree/branch, or [email protected]:owner/repo.git. Fetches pyproject.toml (required) and uv.lock (optional) from the default or specified branch via the GitHub Contents API, then runs the same audit pipeline. Returns the audit payload plus a github block with owner/repo/branch/stars. Returns HTTP 503 with retry-after when GitHub rate-limits. |
| GET | /api/audit/:id | Fetch a saved audit by its 8-char short id. 404 if not found. |
| GET | /a/:id | Server-rendered HTML view with Open Graph meta tags pointing at the SVG card. Immediately redirects to #/a/:id so the SPA picks up the route. |
| GET | /a/:id/card.svg | Embeddable 640×320 SVG share card (dark theme, score ring + 5 category bars). cache-control: public, max-age=300. |
| GET | /api/leaderboard | Cached leaderboard JSON of the curated watchlist. Sorted by score DESC (NULLs last). Each row has {repo_url, owner, name, stars, score, breakdown, audit_id, last_checked_at, last_error}. |
| GET | /api/rules | All 15 rule definitions: {id, name, category, category_label, severity, weight, description, doc_url}. |
| GET | /api/categories | The category id → label map. |
Data sources
| Source | URL | Frequency | On failure |
|---|---|---|---|
| User-pasted pyproject.toml + optional uv.lock | n/a (request body) | On submit | Parse errors are surfaced as findings, not crashes |
| GitHub Contents API — pyproject.toml | https://api.github.com/repos/{owner}/{repo}/contents/pyproject.toml | On submit (URL flow) + nightly (leaderboard) | 404 → "pyproject.toml missing", abort; 403/429 → 503 with retry-after |
| GitHub Contents API — uv.lock | https://api.github.com/repos/{owner}/{repo}/contents/uv.lock | Same as above | 404 → finding lockfile-missing, continue scoring |
| GitHub Repo metadata (stars, default branch) | https://api.github.com/repos/{owner}/{repo} | Same as above | 404 → fail audit; 403/429 → 503 |
| GitHub Contents API — requirements.txt (side-check) | https://api.github.com/repos/{owner}/{repo}/contents/requirements.txt | On submit (URL flow only) | 404 → no finding, continue |
| Curated watchlist of public Python repos | data/watchlist.js (20 slugs) | Read on cron tick | Per-repo failures recorded as last_error; never block the run |
| uv official documentation | https://docs.astral.sh/uv/... | Static — hard-coded per-rule citations | n/a |
Everything the UI shows traces back to one of these. The watchlist is configuration — it names which real sources to fetch; every score on the leaderboard comes from a live pyproject.toml + uv.lock pulled that day.
Scoring
Each of the 15 rules has a weight derived from its severity (critical=12, high=8, medium=6, low=3). A rule that emits ≥1 finding fails entirely — there's no partial credit per finding. The score is round(100 * sum(weights of passed rules) / sum(weights of all rules)). The per-category breakdown applies the same formula scoped to rules of that category.
Layout
uv-doctor/
├── server.js # Express bootstrap, helmet, compression, cron
├── db.js # better-sqlite3 (WAL), migrations, prepared queries
├── routes/
│ ├── audit.js # POST /api/audit, GET /api/audit/:id, GET /a/:id, GET /a/:id/card.svg
│ ├── github.js # POST /api/audit-github
│ ├── leaderboard.js # GET /api/leaderboard
│ └── rules.js # GET /api/rules, GET /api/categories
├── lib/
│ ├── parser.js # smol-toml + normalize dependencies, extract facts
│ ├── rules.js # 15 rule definitions + checker functions
│ ├── scorer.js # weighted score + breakdown + top-N + fix script
│ ├── github.js # GitHub fetch with optional token, repo URL parser
│ ├── card.js # SVG share card renderer
│ └── shortid.js # 8-char base36 id generator
├── cron/
│ └── leaderboard.js # nightly refresh of watchlist repos (+ boot-time warm-up)
├── data/
│ └── watchlist.js # 20 curated Python OSS repos
└── public/
├── index.html # SPA shell
├── app.js # client-side router + views
└── style.css # dark theme
Cron
15 3 *— daily leaderboard refresh against the watchlist- On boot, if the
leaderboardtable is empty, a one-shot warm-up runs in the background (~1s per repo)
Both paths are skipped when NODE_ENV=test.
Out of scope
Auth, payments, accounts, private repos, Poetry/PDM/pip-tools migration scripts, conda interop, running uv as a subprocess, mobile-optimized layout, i18n. See SPEC.md §6 for the full list.
License
MIT.