maudlin/checkup
GitHub: maudlin/checkup
一款工具无关的便携式代码库健康扫描器,通过约二十项跨维度检查生成标准化健康报告,帮助团队和 AI 代理快速定位最高杠杆的改进方向。
Stars: 0 | Forks: 0
# 🩺 Application Checkup
[](https://github.com/maudlin/checkup/actions/workflows/ci.yml)
[](LICENSE)
**A deterministic localiser of codebase-health problems** — it tells you (or an
agent) *where the effort should go*, before a line of code is read. A single
shell entrypoint runs ~20 checks across code, dependencies, security, containers,
CI and git history, and produces a machine-readable JSON stream (primary) plus a
human-readable markdown report (always) — two signals: an **overall health
read** and the **biggest problems**, ranked.
It is **not a deploy gate** — that's CI's job. Its value is front-loading,
deterministically and up front, the gestalt a smart agent would otherwise spend
tokens inferring ("this is Classic ASP", "there are no tests", "this module is
hot, complex and bug-prone"). Cheaper, certain, reproducible, pre-token. See
[ADR-0009](docs/decisions/0009-deterministic-health-localiser.md).
Four contexts, one job — *"here's where the health problems are,"* never *"may I
ship?"*:
1. **Prime an AI agent** — a deterministic "start here" before the expensive,
non-deterministic agent runs.
2. **Team prioritisation** — find the highest-leverage fix for today's pains and
tomorrow's failures.
3. **Tech due diligence** — a fast read of a product's code hygiene.
4. **Periodic safety-net sweep** — a coarse-cadence catch of what slipped past CI.
Health is read across four pillars: **maintainability** (complexity,
duplication, coupling, hotspots), **safety/maturity** (tests, coverage,
mutation, docs — *absence is a loud signal*), **currency & technology-viability**
(dependency rot, EOL runtimes, dead platforms), and **correctness** (does it
build / pass — lowest weight, often unrunnable on a target you don't own).
It is **tool-agnostic and portable**: every check degrades gracefully when its
tool is absent, and the contract documented below lets you swap the
language-specific checks for your own stack's equivalents without touching the
helpers, the renderer, or the report format. A grade is fine; a gate it is not.
## Quick start
# Run against the current project (resolves the enclosing git repo):
cd /path/to/your-project
/path/to/checkup/bin/checkup.sh
# …or scan an explicit target without cd-ing into it:
CHECKUP_TARGET=/path/to/your-project /path/to/checkup/bin/checkup.sh
Output lands under the **scanned project**: `docs/reports/checkup-report.md`
(committable "latest") plus `reports/parsed/*.json` (machine-readable, one file
per check). Add an alias (`alias checkup=/path/to/checkup/bin/checkup.sh`) or
symlink `bin/checkup.sh` onto your `PATH`. To pin it into a project, vendor the
repo (e.g. as a git submodule) and call `bin/checkup.sh` from an npm/make task.
### Run in Docker (no host installs)
The `checkup-core` image bakes the cross-stack tools (gitleaks, semgrep,
shellcheck, yamllint, hadolint, scc) so you can examine **any** repository with
nothing installed but Docker — ideal for ad-hoc audits and due diligence:
docker build -t checkup-core . # one-off, from this repo
# Scan a project: source mounted READ-ONLY, report written to ./checkup-out
docker run --rm \
-v "/path/to/project:/src:ro" \
-v "$PWD/checkup-out:/out" \
checkup-core
# → ./checkup-out/checkup-report.md (+ parsed/*.json, by-file.json)
The source is mounted read-only — checkup writes nothing into it; everything
goes to `/out`. Mount a full clone (not a shallow/exported tree) so the
git-forensics checks have history.
For sensitive or due-diligence scans, run it **sealed** (`--network none` + a
minimal sandbox) so a compromised tool can't exfiltrate the code — see
[`SECURITY.md`](SECURITY.md#running-it-safely-on-sensitive-code-recommended).
What runs in `checkup-core`: the cross-stack security, hygiene and forensics
checks (secrets, SAST, shell/YAML/Dockerfile lint, stats, churn × complexity).
Language- and build-specific checks (typecheck, test, build, coverage) belong
to per-stack images — see [`ROADMAP.md`](ROADMAP.md). On a repo without the Node
toolchain they `skip` honestly (they don't fail or false-pass); read the
cross-stack sections for the core signal.
#### `checkup-dotnet` overlay (.NET / legacy ASP)
`FROM checkup-core` plus the .NET SDK, Microsoft DevSkim and PMD CPD. Runs every
core check, then adds four .NET / legacy-ASP passes — **asp-classic** (semgrep
ruleset for Classic ASP/VBScript), **devskim** (source SAST, no build, reaches
.NET Framework source), **dotnet-vuln** (`dotnet list package --vulnerable`,
skips honestly on legacy `packages.config`), and **duplication** (PMD CPD —
language-aware copy-paste detection for C# and other CPD languages; Classic ASP
has no CPD tokeniser). New findings flow into the same report automatically (the
renderer is tool-agnostic).
docker build -t checkup-core . # base first
docker build -f Dockerfile.dotnet -t checkup-dotnet . # overlay
docker run --rm -v "/path/to/app:/src:ro" -v "$PWD/out:/out" checkup-dotnet
The report location is controlled by **`CHECKUP_OUT_DIR`** (set to `/out` in
the image): set it in any context to write outputs outside the scanned tree.
Unset, checkup keeps the committed `docs/reports/checkup-report.md` convention.
## Priming an agent
checkup's first-class use is **front-loading an AI coding agent**: run it, then
hand the result to the agent as a _briefing_ so it starts in the right place,
the right way — before it spends a token reading code. The agent-first artefact
is **`reports/checkup.json`** (a single versioned bundle; see
[architecture](docs/architecture.md#agent-first-contract--checkupjson-adr-0009)).
A prompt that turns the report into safe, prioritised action:
A checkup health report exists for this codebase. Start with reports/checkup.json
— the bundled signal — before reading source:
• overall — the headline health read
• headlineAlarms — the loudest whole-codebase risks
• pillars — health by axis (maintainability / safety / currency / correctness) + security
• focusTop — the highest-risk files (hot × complex × bug-prone)
Use it to decide where and how to start:
1. Headline alarms first, explicitly. A leaked secret → rotate & purge before
anything else. A dead/declining platform → flag and discuss; don't sink
refactor effort into a rewrite candidate. No test safety net → write
characterisation tests before you change behaviour.
2. Let the safety/maturity pillar set your method. If tests are absent or weak,
work in small verifiable steps and add coverage as you go — don't refactor blind.
3. Take the highest-leverage item from focusTop, open those files to confirm,
and propose a short plan before changing anything.
4. Treat skipped / "no data" checks as "not assessed", not "fine" — state what
you couldn't determine.
checkup tells you WHERE and HOW SAFELY to start; you read the code to decide WHAT to do.
This is a starting template — tailor it to your agent and stack. The same
bundle drives non-agentic uses too (a human reads `checkup-report.md`; CI/trend
consumers read the JSON). checkup is a localiser and a briefing, **not a gate**
([ADR-0009](docs/decisions/0009-deterministic-health-localiser.md)).
## Documentation
| Doc | What |
| ------------------------------------------------------ | ------------------------------------------ |
| [`docs/architecture.md`](docs/architecture.md) | How it works — contract, schema, layering |
| [`docs/build-your-own.md`](docs/build-your-own.md) | Run on a host, slim images, extract tools |
| [`docs/tools.md`](docs/tools.md) | Bundled tools, versions, verification |
| [`docs/decisions/`](docs/decisions/) | ADRs — _why_ it's built this way |
| [`ROADMAP.md`](ROADMAP.md) + [Issues](https://github.com/maudlin/checkup/issues) | What's next (milestone `v0.2.0`) |
| [`AGENTS.md`](AGENTS.md) · [`CONTRIBUTING.md`](CONTRIBUTING.md) | Agent guidance · engagement model |
## Entrypoints
| Script | Purpose |
| ----------------------- | ------------------------------------------------------------------------------------------------------ |
| `bin/checkup.sh` | Orchestrator. Sources `lib/run-tool.sh`, runs every check, emits the normalised stream. |
| `bin/checkup-dotnet.sh` | .NET / legacy-ASP overlay. Runs core, then appends asp-classic + devskim + dotnet-vuln + duplication. |
| `bin/checkup-report.sh` | Tool-agnostic markdown renderer. Reads `reports/parsed/*.json` → writes the report. |
| `lib/run-tool.sh` | Shared helpers (`run_tool`, `write_parsed`, `write_skipped`, `write_failed`, `is_valid_json`, `slug`). |
./bin/checkup.sh # runs all checks, then renders the report
## Prerequisites
### Tier 1 — required (everything below this line is mandatory)
The substrate cannot run without these. Most are already on any modern dev box;
listed for forker completeness.
| Tool | Why |
| ------------------------------------------ | ---------------------------------------------------- |
| **bash** (4+) | Orchestrator + helpers |
| **jq** | All parsed-JSON emission and the cross-tool renderer |
| **git** | Required by `git-hotspots` + the `git-smells` trio |
| **node** / **npm** | Every npm-script-driven check |
| POSIX `find`, `grep`, `sort`, `awk`, `sed` | Helpers in `lib/run-tool.sh` and section parsers |
### Tier 2 — per-check graceful-degrade (each check skips with a documented reason if its tool is absent)
Every section in `checkup.sh` follows the contract: if its tool is missing
(`LAST_EXIT == 127`), the section emits a `skip` parsed JSON with a human
reason. No check is mandatory; missing tools never block the run.
| Tool | Used by | Install |
| ---------------------------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------ |
| `shellcheck` | `shellcheck` section | `apt install shellcheck` / `brew install shellcheck` / static binary on GitHub releases |
| `yamllint` | `yamllint` section | `pipx install yamllint` (recommended) / `apt install yamllint` |
| `hadolint` | `hadolint` section | `brew install hadolint` / Linux static binary on GitHub releases (arch-mapped: `x86_64`/`arm64`) |
| `gitleaks` | `gitleaks` section | `brew install gitleaks` / Linux static binary on GitHub releases (arch-mapped: `x64`/`arm64`) |
| `scc` | `codebase-stats` section | `brew install scc` / Linux static binary on GitHub releases |
| `madge`, `jscpd`, `knip`, `semgrep`, `stryker` | various npm-script-driven sections | `npm install` (devDependencies; provided by the host project) |
### Configuration files (project-owned)
The substrate is config-driven for the linters that can be tuned:
- **`.shellcheckrc`** — disabled rules
- **`.yamllint.yml`** — line-length, truthy keywords, comment style
- **`.hadolint.yaml`** — ignored rules
- **`.gitleaks.toml`** — allowlist (paths, regexes, stopwords)
Without these, each tool runs on defaults — the substrate doesn't depend on the
config files existing.
## Other ecosystems
The substrate is more language-agnostic than the npm-script defaults suggest.
The following all work unchanged on any stack:
- **Complexity** — the default uses ESLint's `complexity` +
`sonarjs/cognitive-complexity` in reporter mode (AST-aware via
typescript-eslint). For non-TS stacks, the shape is portable: `lizard`
natively parses C, C++, Java, JS, Python, Ruby, Rust, Go, Swift, Kotlin, Lua,
Scala, PHP, Objective-C, etc.; `radon` covers Python in more depth; `mccabe`
/ `cyclonedx` etc. are language-specific alternatives. Replace the ESLint
invocation in the section with whichever produces per-function `(file, line,
name, score)` and the rest of the substrate carries it through. The default
moved off lizard because lizard's state-machine TS parser mis-attributes
class-method CCN to the first top-level function before a class — fine for
non-TS, broken for TS-heavy codebases.
- **Stats** — `scc` covers ~150 languages.
- **Security** — `gitleaks` is content-based (not language-aware); `semgrep`
has community rulesets for most major languages.
- **Config-lint** — `yamllint`, `hadolint` are language-neutral.
- **Git-axis** — `git-hotspots`, `change-coupling`, `bug-fix-density`,
`branch-hygiene` are pure git; identical on every stack.
- **Contract, helpers, renderer** — language-agnostic by design.
The language-specific work is concentrated in the ten npm-script-driven
sections. Swap those for your build system's equivalents and the rest of the
substrate works as-is.
| Section | TS / Node (default) | Java / Kotlin | Python | Go | Rust | C# / .NET |
| -------------- | ------------------- | ------------------------------ | ------------------- | ------------------------- | -------------------- | ---------------------------------- |
| build | `npm run build` | `gradle build` / `mvn package` | `pip install -e .` | `go build ./...` | `cargo build` | `dotnet build` |
| typecheck | `tsc --noEmit` | (compile-time) | `mypy` / `pyright` | (compile-time) | (compile-time) | (compile-time) |
| test | vitest / jest | JUnit (gradle / maven) | pytest | `go test ./...` | `cargo test` | `dotnet test` |
| lint | ESLint | SpotBugs + Checkstyle + PMD | ruff | golangci-lint | clippy | built-in analyzers |
| format:check | prettier | google-java-format / ktlint | ruff format / black | `gofmt -l` | `cargo fmt --check` | `dotnet format` |
| coverage | vitest --coverage | JaCoCo | coverage.py | `go test -cover` (native) | tarpaulin / llvm-cov | coverlet |
| unused | knip | (reflection-limited) | vulture / ruff F841 | `go vet` / deadcode | cargo-udeps | R# CLI / IDE |
| duplication | jscpd | jscpd | jscpd | dupl / jscpd | jscpd | jscpd |
| security:audit | npm audit | OWASP dep-check / Snyk | pip-audit / safety | govulncheck | cargo-audit | `dotnet list package --vulnerable` |
| mutation | Stryker | PIT (Pitest) | mutmut / cosmic-ray | go-mutesting | cargo-mutants | Stryker.NET |
| circular-deps | madge | jdeps (built-in) | pydeps | `go list` / staticcheck | cargo-modules | NDepend (commercial) |
The mapping is approximate — many of these tools cover different surface area
than their JS-ecosystem equivalents (e.g. `golangci-lint` wraps ~10 linters;
`clippy` is more conservative than ESLint by default). The point is the
**shape** is portable: parse the tool's output into the standard `top[]`
finding shape and the rest of the substrate carries it through unchanged.
## Environment variables
| Variable | Used by | Default | Purpose |
| --------------------- | ----------------------------------------- | ---------------------------------------- | ----------------------------------------------------------------------------------------- |
| `CHECKUP_TARGET` | path resolution (`checkup.sh` + renderer) | enclosing git repo, else `$PWD` | Explicit project root to scan, instead of auto-detecting from the git top level. For one service in a monorepo, see [Scanning a monorepo subdirectory](#scanning-a-monorepo-subdirectory). |
| `CHECKUP_MODE` | closing verdict (`checkup.sh` + renderer) | `tailored` | `tailored` (a repo you own & tune): verdict framed for your own codebase ("where to focus next"); a low score exits non-zero as a quality signal you may act on — not a deploy gate. `audit` (a repo you don't own / due diligence): informational only, framed as "where to invest", **always exits 0**. checkup never gates ([ADR-0009](docs/decisions/0009-deterministic-health-localiser.md)). |
| `CHECKUP_SRC_ROOTS` | complexity + git-axis sections | `src server` | Space-separated source roots for the git-forensics and complexity scans (e.g. `app cmd`). |
| `CHECKUP_FORENSIC_SINCE` | git-axis sections | `6.months.ago` | `git log --since` window for hotspots / change-coupling / bug-fix-density. Widen (e.g. `2.years.ago`) for repos with sparse recent history; an empty window degrades to `skip`, never a false `pass`. |
| `CHECKUP_EXCLUDE` | lizard complexity + duplication scans | unset | Extra space-separated fnmatch globs excluded from the lizard scans, on top of the built-in generated/vendored defaults (node_modules, migrations, snapshots, `*.min.*`, …). |
| `CHECKUP_SHELL_DIRS` | `shellcheck` section | `scripts .husky .githooks .claude/hooks` | Space-separated dirs to search for shell scripts. Missing dirs are skipped silently. |
| `HADOLINT_DOCKERFILE` | `hadolint` section | auto-detect `Dockerfile*` at root | Override the Dockerfile filename when it is named non-conventionally. |
| `MUTATION_TEST` | `mutation` section | unset (skipped) | Set to `1` to enable Stryker; opt-in because mutation testing is slow (~2 min). |
| `RAW_DIR` | every section (via `run_tool`) | `reports/raw` | Where each section's stdout/stderr capture is written. |
| `PARSED_DIR` | every section (via `run_tool`) | `reports/parsed` | Where each section's normalised JSON is written. |
Forks adding new env-overridable knobs should follow the same `_`
naming convention and document them here in one table.
### Scanning a monorepo subdirectory
Point `CHECKUP_TARGET` at one service inside a larger repo and scope the source
roots to it:
CHECKUP_TARGET=/path/to/monorepo/services/api \
CHECKUP_SRC_ROOTS="src" \
CHECKUP_OUT_DIR=/tmp/checkup-out \
bin/checkup.sh
Caveats:
- **Churn / coupling / bug-fix density scope correctly** — git pathspecs are
cwd-relative, so the git-forensics scans see only the subtree.
- **Paths are target-relative** — the file-based scanners and git-forensics share
one namespace (e.g. `src/app.ts`, not `services/api/src/app.ts`), so the
by-file hotspot aggregate joins correctly.
- **branch-hygiene is repo-wide** — branches can't be scoped to a subtree, so its
counts cover the whole monorepo, not just the service.
- **One stack per run** — a monorepo mixing stacks wants one run per service with
the matching overlay; there's no built-in cross-service roll-up.
## npm-script contract
These commands are the **default Node profile** (`profiles/node.sh`). Each is
overridable per check — via a `.checkup.yml` `commands:` block or a
`CHECKUP_CMD_` environment variable — so adapting checkup to another stack
is "set the commands", not "fork the orchestrator" (see
[Overrides](#overrides-checkupyml)). On a Node repo with no overrides the
defaults below apply unchanged.
Every `npm run