remove-statsig-feature-flag
Deterministically removes a fully rolled-out Statsig flag and the code it
made dead. You give it a flag key and the constant value the gate now always
returns; it does the constant propagation, dead-branch elimination, and
cross-file wrapper inlining that a line-based tool (or an LLM) can't do
reliably.
What it does
Given --param flag=<KEY> --param value=<true|false>:
- Replaces every flag read with the boolean literal:
- Backend:
await checkGate({ gateName: StatsigFlags.KEY, user })or
gateName: KEY_CONST - Frontend:
useGateValue(KEY)/useFeatureGate(KEY)
- Backend:
- Propagates
const isX = <literal>bindings into their uses, including:Promise.allarray destructuring:const [a, b] = await Promise.all([<lit>, other()])
removes the slot and array element in tandem (plainPromise.allonly —
allSettled/racereshape results, so only the inner call is substituted).
APromise.allleft with one element collapses:
const [a] = await Promise.all([x])→const a = await x- shorthand object properties:
{ isX }becomesisX: <literal>
- Folds and deletes dead code:
if/else, early-return guards, ternaries
(including identical arms),!, unreachable statements after a folded
return/throw, empty React effects, and logical chains:- literal on the left (
true && x→x, short-circuit drops the rest); - the identity element in a non-terminal slot, any operand types
(a && true && c→a && c,a || false || c→a || c); - a terminal right literal only when the left is provably boolean by
syntax (a comparison/!/logical-of-those) —a === b && true→a === b; - annihilator tail-drop: when the left is provably always-truthy
(||) / always-falsy (&&), the unreachable right operand is dropped
((x || true) || y→x || true); - test-position truthiness: in an
if/ternary condition,x || true
(any types) is always-truthy so the branch folds.
Value-position residue that needs real type inference (e.g.x || true
assigned to a variable) is left for a human.
- literal on the left (
- Inlines wrapper functions across files (semantic mode): a function whose
body reduces to exactlyreturn <literal>is inlined at every caller
workspace-wide, then deleted — including exported wrappers like
isNewDashboardEnabled(). Caller files get the same propagation/folding
cascade. - Removes the flag's enum member (a
StatsigFlagsenum) and the
flag-key constant (export const MY_FLAG = 'my_flag') once no
references remain workspace-wide. - Drops now-unused flag-support imports and file-local helpers this
migration orphaned (opt-out:--param removeOrphans=false). - Removes always-constant object-property params (opt-in:
--param removeConstParams=true): when the flag leavesf({ x: true, … })
and every call site passesxthe same literal,xis removed from the
free function's destructured param (pattern + inline type + body) and from
every call. Handles object-property params (f({ x }), including shorthand)
and single positional params (f(true)→f()), forfunction
declarations and arrow-/function-expression consts. Chased transitively
— iffforwardedxintog({ y: x })org(x),g's param goes too
(bounded,--param maxParamRounds). Skipped (left for a human): methods
(svc.m(…)), default-exported functions, multi-arg positional calls,
named (non-inline) param types, params with defaults, and object spreads —
see safety notes. The same pass also removes an always-literal prop of a
local (non-exported) React component —<Comp flagProp={x} />where every
usage passes the same literal threaded from the flag: the prop is dropped
from the destructure, its type member, and every<Comp>usage, then the
body is inlined and folded.
Usage
All invocations below do the full cross-file transform — the workflow
declares semantic_analysis: workspace, so wrapper inlining and dead-const
removal work through the registry too. Run on a CLEAN branch and review
git diff — git is the undo mechanism.
From the registry (published package):
bash
From the local workflow (before publishing, or to iterate):
bash
Direct script run (one language at a time, explicit workspace root):
bash
flag accepts any of: the enum member name (ENABLE_NEW_DASHBOARD), the
gate-key constant name (SHOW_BETA_BANNER), or the Statsig gate string
itself (enable_new_dashboard) — the string is resolved (via the semantic
provider) to whichever const(s)/enum member(s) hold it, so it works even when
different files alias the same gate under different constant names. value is
whatever the gate resolves to in production — check Statsig before running. Pass
--param value=false for a flag rolled out to false.
⚠️ Preview with codemod run --dry-run / codemod workflow run --dry-run,
NOT jssg run --dry-run. Cross-file inlining uses SgRoot.write(), and the
transform can't detect dry-run itself. The workflow engine suppresses those
writes in dry-run (safe preview); the low-level jssg run --dry-run does
not — it will write cross-file changes to disk. Either way, run on a clean
branch and use git diff as the undo mechanism.
Params:
| param | default | effect |
|---|---|---|
flag | (required) | flag key to remove |
value | true | the rolled-out boolean |
removeOrphans | true | remove file-local helpers orphaned by this migration |
inlineWrappers | true | cross-file wrapper inlining + flag-const removal (needs semantic mode — on by default via the workflow) |
removeConstParams | false | remove always-constant params from free functions and always-literal props from local components (opt-in; needs semantic mode) |
maxParamRounds | 32 | how many transitive hops of param removal to chase (1 = single hop; capped at 200) |
Safety model — what it will NOT do
Correctness beats completeness. When a rewrite can't be proven safe, the code
is left for a human instead of guessed:
- Files the flag never touched are left byte-identical. Simplification
never runs on unrelated files, so pre-existingconst x = trueconstants
elsewhere are not folded. (Within files the flag did touch, folding may
clean up adjacent literal residue — bounded and semantics-preserving.) - Wrapper inlining requires proof. Only fires when the semantic provider
resolves the wrapper, its body is exactlyreturn <literal>, every reference
is a plain call (never passed as a value) with side-effect-free arguments,
and every caller is inside the semantic workspace. Type-position references
(x: StatsigUser) count as usage, so imports stay when used as types. - Constant removal requires zero workspace references. The exported
flag-key const is removed only whenreferences()proves the definition is
the last remaining mention. Promise.allSettled/race/anydestructures are left alone (result
shape differs); only the inner call becomes a literal.- Skips on shadowing or destructuring re-bindings of the same name,
including array-destructure elements, renamed/rest object-destructure
bindings, andtypeof xtype queries (any of these blocks inlining of that
binding entirely, rather than partially rewriting it). - Exported
constbindings are never inlined/removed in phase 2 — an
unrelatedexport const DEBUG = falsein a flag-touched file is left alone
(only phase 3's orphan pass, which already checks this, may remove one). if (<literal>)with noelsenever deletes the statement outright when
that would orphan the surrounding syntax. Anelse if— the if_statement
is wrapped in an optionalelse_clause— has the wholeelseclause
removed cleanly (not left as a danglingelse ;). A brace-less
if/while/for/dobody, which has no such optional wrapper to drop,
becomes an empty statement (;) instead, which is valid in every statement
position but reads a little oddly — a rare shape, left for a human to tidy.- The flag's enum member is removed only when no reference to
Enum.MEMBERsurvives substitution in that file (exposure logging, a
lookup table, or any use this codemod doesn't recognize as a gate read keeps
the member). This check is same-file only — the semantic provider does not
track enum-member references cross-file, so a member used exclusively from
another file is not verified; treat enum-member removal as best-effort
outside the defining file's own usages. - Const-param removal accounts for aliased/renamed imports in its unanimity
check — if a call site is reached only throughimport { f as g }, the
semantic reference count and the name-based call count disagree, and the
param is kept rather than removed out from under a callerreferences()
found but the textual scan couldn't see. - Const-param body-inlining checks for nested shadows — a param removed
from a signature is only inlined into the body when no inner scope (a
nested function/arrow param, a nested destructure, a localconst/let)
reuses the same name; otherwise the whole removal is skipped. - Malformed
--paramvalues error instead of silently defaulting.
value/removeOrphans/inlineWrappers/removeConstParamsmust be
true/false(string or boolean) when present — an unrecognized value
(e.g.value=False) throws rather than silently taking the default, which
forvaluewould otherwise invert the rollout and delete the live branch.
Likewise,--param flag=(present but empty) throws — only a fully absent
flag(as underjssg test, which can't pass params) enters test mode. - Constant-param removal is opt-in and narrowly scoped. With
removeConstParams=true, an object-property or single positional param of a
free function is removed only when it was fed a non-literal originally
(the flag made it constant) and every call site passes the same literal.
Left for a human: methods (svc.m(…)) and default-exported functions
— the semantic provider can't resolvesvc.methodorexport default fn
back to a definition, so these are skipped rather than half-edited. Also
skipped: multi-arg positional calls, named (non-inline) param types, params
with defaults, and object spreads. (Note: a chain blocked by one of these —
e.g. a positional callee whose other caller sits inside a default-exported
wrapper — stays put, since that call site never becomes a literal.) - It never changes behavior. The removed param was already passed the same
literal everywhere; that literal is inlined into the body, so the function
computes exactly what it did before. The only edge risk is an incomplete
call-site graph (dynamic/re-exported callsreferences()can't see) leaving a
stale{ x: true }— a compile error caught bytsc, never a silent
runtime change. Run on a clean branch and typecheck after. - Recursion is safe. A function that forwards the param into its own
(direct or indirect) recursive call passes a non-literal there, so unanimity
fails and the param is kept — no infinite loop (a per-symboldoneset
plusmaxParamRoundsbound the work). Depth is capped bymaxParamRounds;
re-running the codemod does not go deeper (the flag is already gone, so
nothing re-triggers) — raisemaxParamRoundsinstead.
Development
bash
Fixtures cover the transforms and — just as important — the safety boundaries:
| fixture | asserts |
|---|---|
backend-if-else, backend-early-return, expressions | branch / ternary / &&||| folding |
frontend-hook | useGateValue + JSX ternary |
promise-all | destructure propagation + single-element collapse |
promise-all-multi | multi-element Promise.all is re-aligned, NOT collapsed |
promise-allsettled | allSettled inner call substituted, destructure left intact |
logical-asymmetry | flag && x → x, but x && flag → x && true (no side effect dropped) |
orphan-helpers | orphan removal, exported symbols preserved |
enum-member | enum member removed, siblings kept |
wrapper-inline | cross-file wrapper inlined + removed |
wrapper-value-use | wrapper referenced as a value is left intact |
const-param | always-constant object-property param removed cross-file (opt-in) |
const-param-shorthand | flag passed via object shorthand { prop } is still detected + removed |
const-param-positional | single positional param f(true) → f() (arrow-const def) |
const-param-transitive | removal chases the chain a → b → c through forwarded params |
const-param-recursion | direct recursion (forwarded param) → kept, no loop |
const-param-recursion-indirect | indirect recursion a ↔ b → kept, no loop |
const-param-not-unanimous | param kept when a call site passes a different value |
negative-other-flag | an unrelated flag is left byte-identical |
regression-shadow-destructure | flag var name colliding with an array-destructure binding in another scope is not inlined |
regression-exported-const | an unrelated export const in a flag-touched file is left alone |
regression-else-if-noelse | a false if with no else, itself in an else if slot, is removed cleanly (the whole else clause drops, not left as else ;) |
regression-else-if-chain | a false else if in the MIDDLE of a longer chain is excised, reconnecting the surrounding branches |
regression-enum-surviving-ref | enum member with a surviving same-file reference is kept |
regression-typeof-query | a typeof x type query blocks inlining of x entirely |
regression-const-param-shadow | const-param body-inline aborts when an inner scope shadows the param name |
regression-const-param-alias | const-param removal aborts when a call site is reached only via an aliased import |
regression-multi-wrapper-same-caller | two wrappers in one file, both called from the same external file, compose correctly in one run |
Tests use --strictness ast, so expected files are written clean —
comparison ignores whitespace residue.
Verified end-to-end on real Statsig flags — including a multi-caller exported
wrapper inlined and removed across files — with the result reviewed against
git diff.