← back to gallery

uv-doctor

Score your Python project's uv hygiene and get a fix script for the top 10 papercuts.

dev-toolspythonuvlinterpyprojecthygienescorecard
Open product ↗

uv-doctor

A uv-hygiene scorecard for Python projects. Drop your pyproject.toml + uv.lock, or paste a public GitHub repo URL, and get:

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

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.