debtctl
v0.4.0
Published
Govern npm/bun overrides, pnpm.overrides, and yarn resolutions as documented technical debt. Fails CI when overrides go stale.
Maintainers
Readme
debtctl
Govern
npm/bunoverrides,pnpm.overrides, andyarnresolutionsas documented technical debt — with acheckcommand that fails CI when overrides go stale.
debtctl is a small CLI that treats your npm/bun overrides, pnpm.overrides, and yarn resolutions as what they actually are: technical debt. It maintains a sidecar file (.debtctl.json) that records why each override exists, who owns it, and when it should be revisited — then surfaces stale overrides in CI before they rot for another two years.
Philosophy
Overrides are routinely added to patch a CVE, force peer compatibility, or pin a transitive dependency. They're almost always meant to be temporary. They are almost never reviewed again.
debtctl does one job: it enforces three pieces of metadata next to every override, and fails CI when an override has drifted past its expected lifetime. No metadata, no merge. No revisit trigger, no merge. Reviewer changed the override's range and forgot to update the rationale? CI catches it.
The tool has four runtime dependencies (commander, chalk, semver, yaml) and is deliberately small. It does not patch your package.json, run installs, or talk to a registry — it only reads, classifies, and reports.
How this compares to Renovate / Dependabot
Renovate and Dependabot bump dependencies. They open PRs when a new version is available, including for overridden packages. They don't enforce why an override exists, who owns it, or when it should be revisited.
debtctl is the layer next to them, not a replacement:
- Renovate / Dependabot open the PR that bumps the underlying package.
debtctlfails CI when the override pinning it has no rationale, no owner, or has drifted past its revisit trigger.
It does not call a registry, run installs, or open PRs — it reads package.json, reads .debtctl.json, and reports. Best suited for teams with a backlog of undocumented overrides who want a CI gate to stop the bleeding.
Install
Install as a dev dependency so the version is pinned in your project and CI runs match local:
npm install --save-dev debtctl
# or
pnpm add -D debtctl
# or
yarn add --dev debtctl
# or
bun add -d debtctlThen invoke via your package manager's runner:
npx debtctl init # npm
pnpm debtctl init # pnpm (shortcut for `pnpm exec debtctl`)
yarn debtctl init # yarn
bunx debtctl init # bunOr add a package.json script and call it via the manager:
{
"scripts": {
"debt:check": "debtctl check --strict"
}
}npm run debt:checkAlternatives
- Global install —
npm install -g debtctl. Convenient for ad-hoc use across many projects; not recommended for CI, since the version drifts independently from your repo. - No install —
npx [email protected] check. Pinned to a version, fetched on demand. Fine for one-off exploration; slower on every CI run than installing.
Every release is published from CI with npm provenance attestations, so each version on npm links back to the exact commit and workflow that built it.
Requires Node.js ≥ 20.
Quickstart
cd your-project
npm install --save-dev debtctl
npx debtctl init # scaffolds .debtctl.json with TODO stubs for current overrides
$EDITOR .debtctl.json
npx debtctl check # fails if anything is undocumented or due for reviewCommit .debtctl.json to your repo. Run npx debtctl check --strict in CI.
The command examples below show the bare
debtctlinvocation. Prefix withnpx,pnpm,yarn, orbunxper your package manager.
Commands
debtctl init
Scans your package manifest for overrides, then creates or updates .debtctl.json with stub metadata for each. Existing documented entries are preserved. Orphaned entries (sidecar entries with no matching override) are kept but counted in the summary.
$ debtctl init
Detected: npm
Found 3 overrides. 1 documented, 2 need metadata.debtctl check
Reports overrides that are missing metadata, incomplete (still contain TODO fields), or due for review (their trigger has fired). Read-only — never modifies your sidecar.
$ debtctl check
Missing metadata (1):
- some-package
Incomplete (1):
- other-package: TODO fields present
Due for review (1):
- third-package: Expired on 2025-09-01
✖ 3 problems: 1 missing, 1 incomplete, 1 due for reviewOptions:
| Flag | Effect |
| ----------------- | -------------------------------------------------------------------- |
| --strict | Escalate dueForReview from a warning to a failure. |
| --json | Emit machine-readable JSON; suppress human output. |
| --only <bucket> | Filter to one of missing, incomplete, dueForReview, orphans. |
Detection precedence
debtctl picks the package manager in this order:
- The
packageManagerfield inpackage.json(e.g."[email protected]"). - The lockfile present in the working directory.
- If multiple lockfiles are present,
debtctlreports the ambiguity and falls back to a fixed priority order:npm→pnpm→yarn-classic→yarn-berry→bun.
| Manager | Lockfile | Override location |
| -------------- | ------------------- | --------------------------------------------- |
| npm | package-lock.json | overrides (in package.json) |
| pnpm | pnpm-lock.yaml | pnpm.overrides and/or pnpm-workspace.yaml |
| yarn-classic | yarn.lock (v1) | resolutions (in package.json) |
| yarn-berry | yarn.lock (v6+) | resolutions (in package.json) |
| bun | bun.lock | overrides and/or resolutions |
For pnpm projects, debtctl reads overrides from both pnpm.overrides in package.json and the overrides: block of pnpm-workspace.yaml (the recommended location since pnpm 9). When the same key appears in both, the workspace YAML value wins — matching pnpm's own precedence.
For Bun projects, debtctl reads both overrides and resolutions from package.json. When the same key appears in both, overrides wins. Only the text lockfile bun.lock (default since Bun 1.2) is detected; projects still on the binary bun.lockb should set "packageManager": "[email protected]" in package.json.
Ambiguous lockfiles
When debtctl finds more than one lockfile and no packageManager field disambiguates, it prints a warning to stderr and proceeds with the highest-priority match:
Warning: multiple lockfiles found (package-lock.json, yarn.lock). Using npm. Consider removing the unused lockfile.In --json mode the warning is suppressed and the matched lockfiles are surfaced on the result instead:
{
"manager": "npm",
"ambiguous": ["package-lock.json", "yarn.lock"],
"entries": [...],
"orphans": [...]
}Ambiguity never affects the exit code — it's repo hygiene, not override debt.
Metadata model
.debtctl.json is a versioned JSON file with one entry per override. Three trigger types are supported (version-anchor, date, patch-hash):
{
"version": 2,
"overrides": {
"some-package": {
"reason": "Patches CVE-2024-XXXX until upstream v3 ships",
"owner": "team-security",
"revisitWhen": {
"type": "version-anchor",
"package": "some-package",
"declaredRange": "^2.4.0"
}
},
"other-package": {
"reason": "Force peer compatibility with legacy-ui",
"owner": "alice",
"revisitWhen": {
"type": "date",
"expires": "2026-09-01"
}
}
},
"patches": {}
}Sidecar files written by older versions of debtctl ("version": 1) are auto-migrated to v2 on first read. Your existing file on disk is left untouched until you re-run debtctl init.
version-anchor (recommended for overrides)
Fires when the declared range for the named package in package.json no longer matches declaredRange. This is usually what you want for overrides: if upstream releases a fix and someone bumps the dependency, you'll be prompted to revisit the override automatically — no human-set deadline required.
Ranges are compared semantically via semver.subset, so ^1.0.0 and ^1.0.0 match, but ^1.0.0 and ^2.0.0 don't. Non-semver ranges like latest or workspace:* fall back to string equality.
date
Fires when today is on or after expires. Use this only when there's no natural dependency or patch file to anchor against (e.g., you're waiting on an external schedule).
patch-hash (recommended for patches)
Fires when the patch file's content hash differs from the recorded hash. See Patches for the full story.
Patches
debtctl also governs code patches managed by patch-package, pnpm's and Bun's patchedDependencies, and yarn berry's patch: protocol. Patches are arguably worse than overrides for rot: they include explicit code diffs that can fail to apply silently when upstream shifts, or apply wrongly and quietly diverge from the patch's original intent.
Patches share the same metadata shape as overrides (reason, owner, revisitWhen) and live under the patches key of .debtctl.json. debtctl init detects patches in your project, computes a SHA-256 hash of each patch file, and scaffolds an entry with a patch-hash trigger pre-armed at the current content.
| Manager | Where patches are declared | Where patch files live |
| -------------- | -------------------------------------------- | ---------------------------------------------- |
| npm | package.json scripts.postinstall | patches/*.patch |
| yarn-classic | package.json scripts.postinstall | patches/*.patch |
| pnpm | pnpm.patchedDependencies in package.json | path declared per entry (typically patches/) |
| yarn-berry | resolutions field with patch: protocol | .yarn/patches/*.patch |
| bun | patchedDependencies in package.json | path declared per entry (typically patches/) |
Example sidecar with both an override and two patches:
{
"version": 2,
"overrides": {
"some-package": {
"reason": "Patches CVE-2024-XXXX until upstream v3 ships",
"owner": "team-security",
"revisitWhen": {
"type": "version-anchor",
"package": "some-package",
"declaredRange": "^2.4.0"
}
}
},
"patches": {
"react-router": {
"reason": "Backport upstream PR #1234 until v6.20 ships",
"owner": "team-frontend",
"revisitWhen": {
"type": "patch-hash",
"hash": "sha256:abc123def456..."
}
},
"legacy-thing": {
"reason": "Block calls to deprecated endpoint until migration completes",
"owner": "alice",
"revisitWhen": {
"type": "date",
"expires": "2026-12-01"
}
}
}
}The patch-hash trigger
debtctl init records the SHA-256 hash of each patch file at the moment it's detected. debtctl check recomputes the hash on every run and fires the trigger if it has drifted. This is the killer feature: if a developer edits a patch file without also updating the metadata, CI catches it.
debtctl does not automatically update the stored hash on subsequent init runs. The hash is your snapshot — when the trigger fires, you should review what changed in the patch, update the reason if needed, and then update the hash to the new value to "acknowledge" the change.
Line endings: patch contents are normalized to LF (\n) before hashing. This means a patch file checked out on Windows (CRLF) and macOS/Linux (LF) produces the same hash. Without this, every Windows contributor would see all patch triggers fire on every install.
Out of scope (for now)
- Workspace-sourced yarn berry patches (
patch:.../workspace#...) — only npm-sourced patches stored in.yarn/patches/are detected. - Inline
.pnp.cjsdiffs. - Bun projects using
patch-packageinstead ofbun patch. - Auto-applying or auto-fixing patches —
debtctlis strictly read-only.
Using with Vite+
Vite+ asks you to add overrides so every package in your project resolves vite to npm:@voidzero-dev/vite-plus-core@<version> and pins vitest to the version bundled with vite-plus. Both overrides must move in lockstep with the vite-plus dependency — and nothing enforces that. Bump vite-plus, forget the overrides, and you get mismatched builds or two Vitest runtimes with separate mock state.
No special setup is needed: Vite+ runs pnpm, npm, Yarn, or Bun underneath, and debtctl detects it the usual way. Anchor both overrides on vite-plus:
{
"version": 2,
"overrides": {
"vite": {
"reason": "Vite+ requires vite to resolve to vite-plus-core",
"owner": "team-frontend",
"revisitWhen": {
"type": "version-anchor",
"package": "vite-plus",
"declaredRange": "^1.1.0"
}
},
"vitest": {
"reason": "Pinned to the Vitest bundled with vite-plus (see `vp toolchain vitest`)",
"owner": "team-frontend",
"revisitWhen": {
"type": "version-anchor",
"package": "vite-plus",
"declaredRange": "^1.1.0"
}
}
},
"patches": {}
}When someone bumps vite-plus, debtctl check --strict fails until both overrides are re-verified and their declaredRange updated.
CI usage
With debtctl installed as a dev dependency, run check --strict after npm ci (or the equivalent for your manager). Example for GitHub Actions:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- name: Verify dependency overrides
run: npx debtctl check --strictPinning debtctl in devDependencies keeps the version stable across CI runs and matches whatever you use locally.
Exit codes are designed for CI:
| Code | Meaning |
| ---- | ------------------------------------------------------------------------------------------ |
| 0 | Clean, or only warnings present without --strict. |
| 1 | Errors found: missing or incomplete metadata, or (with --strict) due-for-review entries. |
| 2 | Misuse: invalid --only value, or check run before init. |
Orphans never cause a non-zero exit; they're informational.
Status
debtctl is at 0.4.0. The metadata schema is at version 2; v1 sidecars are auto-migrated on read. Future schema changes will bump it explicitly. Bug reports and PRs welcome.
License
MIT
