Mmkhuzaima

remove-statsig-feature-flag

Remove a stale Statsig feature flag and its now-dead code (constant propagation + dead-branch elimination)

statsigfeature-flagdead-codecleanupmigration
Public
111 executions

Run locally

npx codemod remove-statsig-feature-flag

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>:

  1. Replaces every flag read with the boolean literal:
    • Backend: await checkGate({ gateName: StatsigFlags.KEY, user }) or
      gateName: KEY_CONST
    • Frontend: useGateValue(KEY) / useFeatureGate(KEY)
  2. Propagates const isX = <literal> bindings into their uses, including:
    • Promise.all array destructuring: const [a, b] = await Promise.all([<lit>, other()])
      removes the slot and array element in tandem (plain Promise.all only —
      allSettled/race reshape results, so only the inner call is substituted).
      A Promise.all left with one element collapses:
      const [a] = await Promise.all([x])const a = await x
    • shorthand object properties: { isX } becomes isX: <literal>
  3. 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 && xx, short-circuit drops the rest);
    • the identity element in a non-terminal slot, any operand types
      (a && true && ca && c, a || false || ca || c);
    • a terminal right literal only when the left is provably boolean by
      syntax (a comparison/!/logical-of-those) — a === b && truea === b;
    • annihilator tail-drop: when the left is provably always-truthy
      (||) / always-falsy (&&), the unreachable right operand is dropped
      ((x || true) || yx || 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.
  4. Inlines wrapper functions across files (semantic mode): a function whose
    body reduces to exactly return <literal> is inlined at every caller
    workspace-wide, then deleted — including exported wrappers like
    isNewDashboardEnabled(). Caller files get the same propagation/folding
    cascade.
  5. Removes the flag's enum member (a StatsigFlags enum) and the
    flag-key constant (export const MY_FLAG = 'my_flag') once no
    references remain workspace-wide.
  6. Drops now-unused flag-support imports and file-local helpers this
    migration orphaned
    (opt-out: --param removeOrphans=false).
  7. Removes always-constant object-property params (opt-in:
    --param removeConstParams=true): when the flag leaves f({ x: true, … })
    and every call site passes x the same literal, x is 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()), for function
    declarations and arrow-/function-expression consts. Chased transitively
    — if f forwarded x into g({ y: x }) or g(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:

paramdefaulteffect
flag(required)flag key to remove
valuetruethe rolled-out boolean
removeOrphanstrueremove file-local helpers orphaned by this migration
inlineWrapperstruecross-file wrapper inlining + flag-const removal (needs semantic mode — on by default via the workflow)
removeConstParamsfalseremove always-constant params from free functions and always-literal props from local components (opt-in; needs semantic mode)
maxParamRounds32how 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-existing const x = true constants
    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 exactly return <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 when references() proves the definition is
    the last remaining mention.
  • Promise.allSettled/race/any destructures 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, and typeof x type queries (any of these blocks inlining of that
    binding entirely, rather than partially rewriting it).
  • Exported const bindings are never inlined/removed in phase 2 — an
    unrelated export const DEBUG = false in a flag-touched file is left alone
    (only phase 3's orphan pass, which already checks this, may remove one).
  • if (<literal>) with no else never deletes the statement outright when
    that would orphan the surrounding syntax.
    An else if — the if_statement
    is wrapped in an optional else_clause — has the whole else clause
    removed cleanly (not left as a dangling else ;). A brace-less
    if/while/for/do body, 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.MEMBER survives 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 through import { 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 caller references()
    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 local const/let)
    reuses the same name; otherwise the whole removal is skipped.
  • Malformed --param values error instead of silently defaulting.
    value/removeOrphans/inlineWrappers/removeConstParams must be
    true/false (string or boolean) when present — an unrecognized value
    (e.g. value=False) throws rather than silently taking the default, which
    for value would otherwise invert the rollout and delete the live branch.
    Likewise, --param flag= (present but empty) throws — only a fully absent
    flag (as under jssg 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 resolve svc.method or export 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 calls references() can't see) leaving a
    stale { x: true } — a compile error caught by tsc, 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-symbol done set
    plus maxParamRounds bound the work). Depth is capped by maxParamRounds;
    re-running the codemod does not go deeper (the flag is already gone, so
    nothing re-triggers) — raise maxParamRounds instead.

Development

bash

Fixtures cover the transforms and — just as important — the safety boundaries:

fixtureasserts
backend-if-else, backend-early-return, expressionsbranch / ternary / &&||| folding
frontend-hookuseGateValue + JSX ternary
promise-alldestructure propagation + single-element collapse
promise-all-multimulti-element Promise.all is re-aligned, NOT collapsed
promise-allsettledallSettled inner call substituted, destructure left intact
logical-asymmetryflag && xx, but x && flagx && true (no side effect dropped)
orphan-helpersorphan removal, exported symbols preserved
enum-memberenum member removed, siblings kept
wrapper-inlinecross-file wrapper inlined + removed
wrapper-value-usewrapper referenced as a value is left intact
const-paramalways-constant object-property param removed cross-file (opt-in)
const-param-shorthandflag passed via object shorthand { prop } is still detected + removed
const-param-positionalsingle positional param f(true)f() (arrow-const def)
const-param-transitiveremoval chases the chain a → b → c through forwarded params
const-param-recursiondirect recursion (forwarded param) → kept, no loop
const-param-recursion-indirectindirect recursion a ↔ bkept, no loop
const-param-not-unanimousparam kept when a call site passes a different value
negative-other-flagan unrelated flag is left byte-identical
regression-shadow-destructureflag var name colliding with an array-destructure binding in another scope is not inlined
regression-exported-constan unrelated export const in a flag-touched file is left alone
regression-else-if-noelsea 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-chaina false else if in the MIDDLE of a longer chain is excised, reconnecting the surrounding branches
regression-enum-surviving-refenum member with a surviving same-file reference is kept
regression-typeof-querya typeof x type query blocks inlining of x entirely
regression-const-param-shadowconst-param body-inline aborts when an inner scope shadows the param name
regression-const-param-aliasconst-param removal aborts when a call site is reached only via an aliased import
regression-multi-wrapper-same-callertwo 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.

Ready to contribute?

Build your own codemod and share it with the community.