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:
- a drop-in exports every name its incumbent exports, types included, except the gaps listed by
name
(
drop-in-type-surface-lock.test.ts); require()of a drop-in returns the same kind of value asrequire()of its incumbent (drop-in-require-shape-lock.test.ts);- the table
burgee migraterewrites by is exactly the set of pairs the oracle grades, and the table on Migrate is exactly the one in the code (migrate-drop-ins-lock.test.ts).
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:
| Threshold | Condition | What it gates |
|---|---|---|
| level | passes at least as many cases as the incumbent passes against itself | whether burgee migrate rewrites the import (D-137) |
| full | passes every case of the reference | a yes in a capability matrix that cites the grade |
| 1.0 | a 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.jsondeclares it, or--onlynames it; one imported without being declared is reported asundeclared, and--skipleaves any incumbent alone. - Everywhere or nowhere. An incumbent a refused file or a kept import still names is
heldand 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 undertransitive. - Only the graded major. An incumbent installed, or declared, on another major is reported
under
offMajorwith 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) andrequire-of-default(arequire()whose two sides would return different kinds of value), andsibling-state(a clack file that also imports@clack/core, whose settingscaique/clacknever reads; it carries afix). A type-only import of a missing name iskepton 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-runwrites 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:
{ "name": "hello", "dependencies": { "chalk": "^6.0.0", "commander": "^15.0.0", "roundel": "^1.0.0" } }import chalk from 'chalk';
import { Command } from 'commander';
const program = new Command('hello');
program.action(() => console.log(chalk.green('hi')));
program.parse();import { Help } from 'commander/lib/help.js';
export const help = new Help();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 chalklegacy.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
- Migrate — the full table of what moves, and every case it leaves alone.
- Spec: burgee-migrate; decision D-137 (what "level" means).
- Each package's Drop-ins page and its Coming from guides, such as Coming from chalk.
- Switching from commander and Switching from yargs.
Configuration and precedence
seniority's one fixed order — flag, env, config file, package.json field, default — resolved by a pure function that records where every value came from, so --explain can say what won and what it beat.
How we prove claims
Every number the family publishes comes from a file a command produced: incumbent suites graded against a control, baselines that only ratchet, weight bands, capability matrices whose every cell cites its evidence, and the locks that fail when prose outruns the measurement.