maudlin/checkup

GitHub: maudlin/checkup

一款工具无关的便携式代码库健康扫描器,通过约二十项跨维度检查生成标准化健康报告,帮助团队和 AI 代理快速定位最高杠杆的改进方向。

Stars: 0 | Forks: 0

# 🩺 Application Checkup [![CI](https://static.pigsec.cn/wp-content/uploads/repos/2026/06/119beb1d53070335.svg)](https://github.com/maudlin/checkup/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](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