dripbird is a TypeScript/JavaScript automated refactoring tool. It reads a unified diff from stdin, identifies changed lines, applies a set of automated refactors only to the changed regions, and writes modified files back in place.
It uses recast for format-preserving AST transformations and optional LLM integration (via Moonshot AI) for intelligent function naming.
The wise dripbird sits perched among the highest branches of the Abstract Syntax Trees in the forest of code. Looking snatched in a suit that high key slays, the dripbird is ready to lock in and sharpen your change set before it becomes canon—for real.
git diff --cached HEAD~1 | dripbird
dripbird operates only on the lines you actually changed — not the whole file. Each refactor receives the diff's line ranges and skips code outside those ranges. This makes it safe to run on any in-progress change without disturbing surrounding code.
If dripbird modifies a file, it exits with code 1 (signaling a pre-commit hook to abort so you can re-stage). If no changes are needed, it exits 0.
Requires Deno 2.0+ and Lefthook.
git clone https://github.com/Voidious/dripbird
cd dripbird
deno task installPipe any unified diff to dripbird:
# Refactor uncommitted changes
git diff | dripbird
# Refactor staged changes
git diff --cached | dripbird
# Refactor changes since a specific commit
git diff HEAD~1 | dripbird
# Refactor a specific file as if it were entirely new
git diff /dev/null somefile.ts | dripbirddripbird prints a summary of every change it applies, modifies files in place, and exits 1 if any file was changed.
dripbird reads optional YAML config files from your project root:
dripbird.yml— committed, shared project defaults.dripbird.yml— local overrides (git-ignored, for personal preferences)
Local overrides take precedence over committed settings.
| Option | Default | Description |
|---|---|---|
max_function_lines |
75 |
Line count threshold above which the function splitter will consider splitting a function |
function_splitter_retries |
2 |
Number of LLM retry attempts when naming a helper function |
function_matcher_retries |
2 |
Number of LLM retry attempts when a function matcher edit fails verification |
duplicate_extractor_min_lines |
2 |
Minimum line span for a code block to be considered for duplicate extraction |
duplicate_extractor_max_lines |
12 |
Maximum line span for a code block to be considered for duplicate extraction |
duplicate_extractor_retries |
2 |
Number of LLM retry attempts when a duplicate extraction fails verification |
provider |
"moonshot" |
LLM provider (currently only "moonshot") |
model |
"kimi-k2.5" |
LLM model name to use |
enabled_refactors |
[] |
If non-empty, only these refactors will run |
disabled_refactors |
[] |
These refactors will be skipped |
verbose |
false |
Print detailed log output for each refactor |
max_function_lines: 50
function_splitter_retries: 3
disabled_refactors:
- function_splitterThe function splitter, function matcher, and duplicate extractor refactors require a
Moonshot AI API key. Set the MOONSHOT_API_KEY environment variable:
export MOONSHOT_API_KEY="your-api-key-here"If the API key is not set, all three are automatically disabled. All other refactors (e.g., flip negated if/else) work without LLM access.
Flips if (!condition) { ... } else { ... } to eliminate the negation.
When an if has a negated condition (!) and an else clause that is not an
else if, dripbird removes the ! and swaps the two branches. This eliminates a
layer of logical indirection and makes intent clearer.
Before:
if (!validInput(frequency, duration)) {
doErrorThing();
} else {
doMainThing();
}After:
if (validInput(frequency, duration)) {
doMainThing();
} else {
doErrorThing();
}Skipped when:
- There is no
elseclause - The
elseis anelse ifchain (which would change semantics) - The condition is not a top-level
!expression
Splits long functions into smaller, well-named helper functions.
When a function exceeds max_function_lines and falls within the diff, dripbird
identifies a good split point, computes the free variables the tail needs, extracts
the tail into a new helper function, and replaces the original tail with a call to
it. The helper function's name is suggested by an LLM to be semantically meaningful.
Works on both standalone function declarations and class methods. For class methods,
it automatically determines whether the helper should be a static method (if this
is not used) or an instance method.
Before:
function processOrder(order: Order, user: User) {
validateOrder(order);
const total = calculateTotal(order.items);
const discount = applyDiscount(user, total);
const finalAmount = total - discount;
chargePayment(finalAmount, user.paymentMethod);
sendConfirmation(user.email, order.id);
updateInventory(order.items);
logTransaction(order.id, finalAmount);
}After:
function processOrder(order: Order, user: User) {
validateOrder(order);
const total = calculateTotal(order.items);
const discount = applyDiscount(user, total);
const finalAmount = total - discount;
chargePayment(finalAmount, user.paymentMethod);
return finalizeOrder(user, order, finalAmount);
}
function finalizeOrder(user: User, order: Order, finalAmount: number) {
sendConfirmation(user.email, order.id);
updateInventory(order.items);
logTransaction(order.id, finalAmount);
}Skipped when:
- The function is under
max_function_lines - The function is
asyncor a generator - The function contains nested function declarations
- No LLM API key is configured (
MOONSHOT_API_KEY)
Replaces duplicate code with calls to existing functions.
When a code block within the diff is semantically identical to an existing function body (ignoring variable names), dripbird replaces it with a call to that function. It uses fingerprint-based matching to find candidates and LLM verification to confirm the match is semantically correct.
It matches both full statement sequences and single return expressions against existing function bodies.
Scope (single file): standalone function declarations, static class methods, and instance class methods.
- Functions and static methods are reachable from any context via their plain or
ClassName.methodname. - Instance methods, same class: a duplicate inside another instance method of
the same class is replaced with
this.method(...). The call site and the target share the samethis, so this is always safe. - Instance methods, external context: a
this-free (pure) instance method is reachable from a free function, static method, or a different class's method when an instance reference is in scope at the call site. dripbird resolves one from a typed parameter (p: ClassName) or aconst x = new ClassName(...)declared before the call site, and emits<ref>.method(...). Athis-using instance method is not matched externally — no external context shares the target'sthis, so the call would silently retarget instance state.
Scope (across files): a duplicate can also be replaced with a call to an
exported function from another file, but only when the current file already imports
that file with a relative specifier (./ / ../). This is the circular-dependency
protection: dripbird never adds or rewrites imports — it only calls through bindings
that already exist in the file — so cross-file matching can never introduce a new
module dependency edge, and therefore can never create an import cycle.
- Named imports (alias-aware) call the local binding;
import * as nscallsns.exportedName(...); a default-exported function is called through the default-import binding. - Imported classes: static methods are called as
Binding.method(...); pure instance methods follow the same in-scope instance-reference rules as single-file matching (p: Bindingornew Binding(...)before the call site). Athis-using instance method is never matched from another file. - Bare specifiers (package names,
npm:/@std/specifiers) and type-only imports are not followed — they provide no file path or no runtime binding to call through.
Before:
function sendWelcomeEmail(recipient: string) {
const subject = "Welcome!";
const body = `Hello ${recipient}, thanks for signing up.`;
smtp.send(recipient, subject, body);
}
function registerUser(username: string, email: string) {
db.insert("users", { username, email });
const subject = "Welcome!";
const body = `Hello ${email}, thanks for signing up.`;
smtp.send(email, subject, body);
}After:
function sendWelcomeEmail(recipient: string) {
const subject = "Welcome!";
const body = `Hello ${recipient}, thanks for signing up.`;
smtp.send(recipient, subject, body);
}
function registerUser(username: string, email: string) {
db.insert("users", { username, email });
sendWelcomeEmail(email);
}Across files (example): the current file (example.ts) already imports a helper
from ./util, and contactHome duplicates the helper's body:
// util.ts
export function sendGreeting(c) {
c.send("Hello,");
c.send("I am from Earth.");
c.send("We come in peace.");
}
// example.ts
import { sendGreeting } from "./util";
function contactHome(base) {
const signal = getSignal(base);
signal.send("Hello,");
signal.send("I am from Earth.");
signal.send("We come in peace.");
}After (in example.ts only — util.ts is untouched and no import is added):
function contactHome(base) {
const signal = getSignal(base);
sendGreeting(signal);
}Skipped when:
- The matching code is inside the same function it would call
- An instance method target would be called from an external context but no instance
reference is in scope (no typed param or preceding
new) - The target is a getter or setter (invoked as property access, not a call)
- The target lives in another file the current file does not already import (dripbird never adds imports, so it can't create circular dependencies)
- The target lives in another file but is not exported from it
- An imported file cannot be resolved or parsed (the import is skipped with a log line)
- The LLM rejects the match as not semantically equivalent
- No LLM API key is configured (
MOONSHOT_API_KEY)
Extracts structurally identical code blocks into a new helper.
When two or more code blocks within the diff are structurally identical (the same statements, ignoring variable names), dripbird extracts them into a new helper and replaces every duplicate with a call to it. Candidates are found with a pure AST fingerprint (no LLM); the LLM is then asked to verify the match, generate the helper and call sites, and review the result, with retries feeding rejection feedback forward.
Scope (single file): standalone function declarations, static class methods, and instance class methods. A duplicate can live across any of these contexts.
- No
thisin any block: the helper is a top-level function declaration appended at the end of the file, and call sites are plain calls. This applies to duplicates in free functions, static methods, and instance methods alike. - A block uses
this: the helper is extracted as an instance method on the class, placed inside the class body, and the call sites usethis.helper(...). This is only done when every duplicate block is an instance method of the same class; otherwise the group is skipped (thiscannot be reconciled).
Before:
class Account {
logDeposit(amount: number) {
const record = `${this.name}: ${amount}`;
console.log(record);
}
logWithdrawal(amount: number) {
const record = `${this.name}: ${amount}`;
console.log(record);
}
}After:
class Account {
logDeposit(amount: number) {
this.logAmount(amount);
}
logWithdrawal(amount: number) {
this.logAmount(amount);
}
logAmount(amount: number) {
const record = `${this.name}: ${amount}`;
console.log(record);
}
}Skipped when:
- Fewer than two duplicate blocks overlap the diff
- The LLM rejects the group as not actually duplicated
- A block uses
thisbut the blocks are not all instance methods of one class - No LLM API key is configured (
MOONSHOT_API_KEY)
stdin (unified diff)
│
▼
src/cli.ts Entry point: reads stdin, calls run()
│
├── src/diff.ts parseDiff() → DiffHunk[], groupByFile()
│
└── src/main.ts run() / runInDir(): reads files, runs engine, writes back
│
├── src/config.ts loadConfig(): reads dripbird.yml + .dripbird.yml
│
├── src/llm.ts createLLMClient(): Moonshot AI integration
│
├── src/type_checker.ts TypeCheckerImpl: TypeScript type checking
│
└── src/engine.ts runRefactors(): chains refactors sequentially
│
└── src/refactors/
├── if_not_else.ts Flip negated if/else
├── function_splitter.ts Split long functions (LLM-assisted)
├── function_matcher.ts Replace duplicate code with function calls (LLM-assisted)
├── function_matcher_imports.ts Cross-file import resolution for the function matcher
└── duplicate_extractor.ts Extract duplicate blocks into a helper (LLM-assisted)
- Create
src/refactors/my_refactor.tsimplementing theRefactortype fromengine.ts. - The function receives
(source: string, ranges: ChangedRange[], context?: RefactorContext)and returns{ changed, source, description }(sync or async).RefactorContextcarries the file's path and an optionalreadFilefor following imports across files (seefunction_matcher_imports.ts). - Check
inRange(node.loc.start.line, node.loc.end.line, ranges)to only touch changed regions. - Register it as a
NamedRefactorinsrc/main.tswith a unique name (used byenabled_refactors/disabled_refactors). - Add tests in
tests/refactors/my_refactor_test.ts— 100% branch and line coverage is enforced.
deno task fmt # format code
deno task fmt:check # check formatting
deno task lint # lint
deno task test # run tests
deno task test:coverage # run tests with 100% coverage enforcement
deno task install # install the dripbird CLI globallyPre-commit hooks (via Lefthook) run deno fmt --check, deno lint, and the 100%
coverage test suite automatically.
The test suite mocks the LLM client, so it runs offline and never spends tokens. To validate a refactor's behavior against a real LLM, see Live end-to-end testing.