burgee
Concepts

Drop-in façades and migration

A drop-in reproduces its incumbent's API and behaviour, never its mechanism; burgee migrate moves only the imports the compatibility oracle grades level, and refuses the rest file by file.

What it is

Each family package publishes drop-ins: subpaths that export an incumbent's API over the family's own implementation. burgee/commander for commander, roundel/chalk for chalk, flagstaff/ora for ora, linegauge for string-width, and 26 specifiers in all. Moving to one is an import change and nothing else:

- import { Command } from 'commander';
+ import { Command } from 'burgee/commander';

burgee migrate makes that change across a project, for every drop-in that is ready.

Why it exists

A replacement nobody can adopt without a rewrite is not a replacement. A drop-in lets a program move one import at a time and keep working at every step — and once it has moved, the program is on the family's mechanisms: one output policy, one exit registry, one width function, and the agent surfaces that come with burgee's engine.

The rule: API and behaviour, never mechanism

A drop-in reproduces what the incumbent does — its exports, its return values, its exit codes, its output — because that is what the incumbent's own test suite grades. It does not reproduce how the incumbent does it when a layer of the family owns that job. burgee/src/yargs/cliui.ts is the model the rule cites: a yargs façade that takes width, strip and wrap from linegauge and keeps only yargs' own layout. The rule is stated, and enforced, by inline-implementation-lock.test.ts: a façade that measures text by hand, reads NO_COLOR itself or spawns a process with node:child_process fails it, unless the file is a row in the lock's KNOWN table with the reason — which is how burgee/commander keeps commander's own colour rule, because commander's suite grades exactly that rule.

The façades are also never wrappers: burgee/commander is written over burgee's engine, and no published package depends on commander. The real incumbents are development dependencies of this repository, installed only to be compared against — by the compatibility oracle and the benchmarks.

Three more locks hold the surface:

How a drop-in reaches 100%

The compatibility oracle runs the incumbent's own test suite against the drop-in. The rate is passed / reference, where the reference is the number of cases the same suite registers against the real incumbent (the control), so a file that fails to load cannot shrink the denominator. Three thresholds use that number:

ThresholdConditionWhat it gates
levelpasses at least as many cases as the incumbent passes against itselfwhether burgee migrate rewrites the import (D-137)
fullpasses every case of the referencea yes in a capability matrix that cites the grade
1.0a rate of 100%the front-end's own 1.0 release; below it, never described as compatible

A drop-in that is graded but not level — cosmiconfig and dotenv today — is reported by migrate as partial, with its grade, and left alone. The rates are on Compatibility.

burgee migrate

It rewrites import specifiers and nothing else: import … from, bare import, export … from, import() and require() with a literal string, and the module a test mocks (vi.mock, jest.mock, vi.importActual, jest.requireActual, require.resolve and their kin). Everything else in a file is left byte for byte. Its safety rules:

  • Only level drop-ins. The mapping is derived from the graded table, not typed by hand.
  • Only what the project chose. An incumbent moves when package.json declares it, or --only names it; one imported without being declared is reported as undeclared, and --skip leaves any incumbent alone.
  • Everywhere or nowhere. An incumbent a refused file or a kept import still names is held and rewritten in no file, so a program never runs on two copies of one incumbent. An import of an error class that another declared dependency still throws is kept for the same reason, and that dependency is listed under transitive.
  • Only the graded major. An incumbent installed, or declared, on another major is reported under offMajor with both versions, and its files are skipped.
  • A file is rewritten whole or not at all. One refused specifier leaves the whole file as it was. The refusals are deep-import (commander/lib/command.js), non-literal-specifier (require(name)), unknown-export (a value import of a name the drop-in does not export) and require-of-default (a require() whose two sides would return different kinds of value), and sibling-state (a clack file that also imports @clack/core, whose settings caique/clack never reads; it carries a fix). A type-only import of a missing name is kept on the incumbent, not refused.
  • No dirty tree. Without --force, uncommitted changes stop the run before anything is written, so the rewrite is one reviewable diff, and the refusal prints its fix: commit or stash your changes, or pass --force. --dry-run writes nothing, so it skips that check.
  • It never edits package.json. It prints the one command that adds the family packages, each pinned to the version that carries its graded drop-in, and removes the incumbents that are no longer imported, for the package manager the lockfile names. A held incumbent is still imported, so it is never called removable or uninstalled (keeps an incumbent imported only in a refused file out of removable, and out of npm uninstall).
  • It exits 1 when anything was refused, with the whole report still printed and its first line saying the run was partial, so an agent can run it unattended and branch on the result.

Each rule is a case in migrate.test.ts: refuses a deep import and leaves the file exactly as it was, leaves a file entirely unchanged when one specifier in it is refused, leaves an incumbent on another major alone, and says which versions, refuses a dirty tree, and names the fix, writes nothing under --dry-run, and still reports what it would do and reports RUNTIME when anything was refused.

Run it

A project with two level drop-ins and one deep import, which already depends on roundel:

package.json
{ "name": "hello", "dependencies": { "chalk": "^6.0.0", "commander": "^15.0.0", "roundel": "^1.0.0" } }
cli.mjs
import chalk from 'chalk';
import { Command } from 'commander';

const program = new Command('hello');
program.action(() => console.log(chalk.green('hi')));
program.parse();
legacy.mjs
import { Help } from 'commander/lib/help.js';

export const help = new Help();
npx burgee migrate --dry-run
partial: 1 file would be rewritten, 1 refused
before the move:
  warn  floor  the family needs Node >=22.13.0; adopting raises this package's floor, a semver-major for it; fix: npm pkg set "engines.node=>=22.13.0"
would rewrite:
  chalk -> roundel/chalk  1 import in 1 file
refused, and left exactly as it was:
  legacy.mjs:1  commander/lib/help.js  deep-import
held, rewritten in no file:
  commander  refused in legacy.mjs
graded by the incumbent’s own suite:
  chalk  59 / 59 (the incumbent passes 59)
  commander  1418 / 1418 (the incumbent passes 1418)
declared: commander, chalk
imported: chalk, commander
add: none
removable: chalk
next: npm uninstall chalk

legacy.mjs is refused and stays as it was, so commander is held: cli.mjs keeps its commander import too, and moves only its chalk. commander stays installed, and only chalk is removable. roundel is already a dependency, so there is nothing to add; a project without it would be told npm install roundel@^ and roundel's current version.

The project declares no engines.node, so the report warns, before anything else, that adopting sets a Node floor it did not have. That is a warning, not a block: a CommonJS package whose engines allowed a Node without require(esm) would be blocked and nothing written. What it checks before it writes lists every check.

Where the rules live

On this page