← Statpro dev log

2026-07-14

The import ratchet: CI that keeps deleted code dead

automationinfradata

How do you keep deleted code from coming back?

We're migrating StatPro's data layer from a legacy database to a new canonical one. The last 26 hours produced 10 commits: porting read models, deleting ~930 files (134k lines) of legacy surface code, rewiring content pipelines to the new DB. It's the kind of migration where backsliding is the real risk: someone adds a new import of the old package in a PR that looks fine, the reviewer doesn't catch it, and the migration surface grows instead of shrinking.

The fix is a one-way ratchet in CI. The pattern is simple:

  1. Snapshot every file that currently imports the deprecated package into a checked-in baseline (a sorted JSON array, so diffs are reviewable).
  2. On every PR, run git grep for importers of the deprecated package.
  3. If the current set has files not in the baseline, fail CI with a pointer to the replacement.
  4. If files were removed, pass and print a hint to regenerate the baseline so it only shrinks.

The whole thing is 66 lines of plain Node.js: no AST parsing, no framework. It runs git grep, filters to source files, sorts, and diffs against the baseline. Here's the core:

function currentImporters() {
  let out = execFileSync("git",
    ["grep", "-l", "--untracked", 'from "@deprecated/pkg"',
     "--", "apps", "packages", "scripts"],
    { cwd: repoRoot, encoding: "utf8" });
  return out.split("
")
    .filter(f => /\.(ts|tsx|mts|mjs)$/.test(f))
    .sort();
}

const baseline = new Set(JSON.parse(readFileSync(baselinePath)));
const added = files.filter(f => !baseline.has(f));
if (added.length > 0) { console.error("NEW importers"); process.exit(1); }

The baseline starts at 517 files. Every time someone deletes or re-ports an importer, it shrinks. It can never grow. That's the ratchet.

What I like about this: it's a migration technique that doesn't require vigilance from reviewers. You wire it into the existing quality check (it's our 13th check), and CI enforces the direction. Nobody needs to remember which package is deprecated. The check remembers.

The one gotcha: git grep exits 1 when there are zero matches, which is a clean result, not a failure. You need to catch that explicitly, or your script crashes on the day the last importer is deleted. Which is the best possible crash to have.

this is the build log of statpro · statpro.io · all entries · essays