flag-prune

Safety guarantees

The central invariant is:

Replace configured flag reads without changing the observable evaluation that must still occur.

flag-prune is deliberately conservative. A source shape that cannot be matched or simplified safely remains in place. Don’t let AI guess.

Exact, binding-aware matching

The tool matches syntax and bindings rather than searching text. It operates on the AST, not the raw source. This ensures that:

Required evaluation is preserved

Removing a flag read must not accidentally remove observable work. The transform retains required evaluation from:

Example:

if (client.isEnabled("new-ui", loadContext())) {
  renderNewUi()
}

With a final value of true, the result is equivalent to:

loadContext()
renderNewUi()

The flag result disappears; the trailing argument’s required evaluation does not.

Short-circuiting remains short-circuited

The simplifier does not introduce calls that were previously unreachable.

true || load()

becomes:

true

load() remains unexecuted.

When an always-true condition must evaluate an effectful left side, that effect is retained:

if (load() || true) run()

becomes the equivalent of:

load()
run()

Set simplifyEffectfulConditions: false or use --skip-effectful-conditions to leave such conditions unchanged instead.

Unknown values remain unknown

An expression is not simplified merely because a boolean identity would be valid for booleans.

const value = load() || true

stays unchanged in value context because load() might return a non-boolean value whose original result matters.

Boolean identities are applied only when the expression is known to be boolean, such as a literal, a boolean annotation, or a stable boolean initializer.

Object identity is preserved

When a flag resolves to an object or array value, flag-prune folds static member and index reads to their configured values but does not inline the whole value at each reference. Reusing the same binding keeps object identity intact:

const variant = getVariant("checkout")
register(variant)
if (variant.enabled) enable()

With getVariant("checkout")={ enabled: true }, variant.enabled folds to true, but the declaration is kept so register(variant) still receives one object:

const variant = { enabled: true }
register(variant)
enable()

The declaration is removed only when every read is folded and nothing else uses the binding. Member reads on an inline literal are folded only when the literal is pure, so no observable evaluation is discarded.

Empty object spreads are removed

Folding a flag inside an object spread can leave a spread that contributes no properties, such as ...(false) or ...({}):

const content = {
  state: null,
  ...(flagValue ? {} : { permission: 1 }),
}

With flagValue true, the spread folds to ...({}) and is removed:

const content = {
  state: null,
}

Only object spreads are simplified, and only when the argument is pure and provably contributes nothing: a boolean, number, bigint, or null, or an empty object or array literal. String spreads, array spreads, non-empty objects, and any spread whose argument has side effects are left unchanged.

Lexical scope is de-scoped only when safe

Removing an if or loop can leave a bare block that exists only to scope let, const, function, or class declarations. By default flag-prune de-scopes such a block by hoisting its declarations into the parent block, but only when it is provably safe:

When any check fails, the block is left intact, and generated output is reparsed to guarantee it stays valid.

if (FLAG) {
  const access = await load()
  user = await resolve(access)
}

With FLAG true, this de-scopes to:

const access = await load()
user = await resolve(access)

Set flattenBlocks: false or pass --no-flatten-blocks to keep the scoping block instead:

{
  const access = await load()
  user = await resolve(access)
}

Control flow is conservative

The transform can simplify:

An unreachable variable, function, or class declaration is removed only when all uses and mutations of its binding are removed with it. Declarations stay in place when reachable code depends on their hoisting or temporal dead zone, or when direct eval makes that dependency impossible to prove statically.

It avoids loop rewrites that would change break or continue behavior, and it preserves initializer and condition evaluation order.

Imports preserve module initialization

When the final configured import binding is removed, the default result is a side-effect import:

import { FLAG } from "./flags"

becomes:

import "./flags"

This preserves module initialization.

Only set removeSideEffectImports: true or pass --remove-side-effect-imports when the module is proven side-effect-free.

Comments

The default report policy records ordinary comments that belong only to removed code.

Protected comments survive regardless of the ordinary comment policy. Protection includes common markers such as:

Policies:

Policy Behavior
report Remove and report ordinary dead comments; retain protected comments.
preserve Move comments onto surviving output and mark them retained in the report.
discard Do not report ordinary dead comments; protected comments still survive.

Fixed-point simplification

Flag replacement often exposes another simplification:

A && (B || false);

With A=true and B=false, several passes may be required before the final expression is known. flag-prune repeats simplification until the code reaches a fixed point.

Defaults:

If the pass limit is reached, the report sets converged: false and includes a warning. The CLI treats non-convergence as an error.

Output verification and idempotence

Generated output is reparsed by default. Disable this only with verify.parse: false or --no-parse-check.

The test model expects representative transforms to be idempotent: applying the same rules to the transformed output should produce no additional change.

Reparsing is not a substitute for project checks. Run the repository’s typecheck, lint, and tests after writing.

Conservative opt-outs

Need Library option CLI option
Keep effectful constant conditions unchanged simplifyEffectfulConditions: false --skip-effectful-conditions
Keep newly unused imports removeUnusedImports: false --no-remove-unused-imports
Preserve all removed comments commentPolicy: "preserve" --keep-comments
Keep scoping blocks left by folding flattenBlocks: false --no-flatten-blocks
Skip output reparsing verify: { parse: false } --no-parse-check

Opt-in behavior that trades conservatism for a cleaner result:

Need Library option CLI option
Remove empty side-effect-free imports removeSideEffectImports: true --remove-side-effect-imports
Match stable local bindings by name allowLocalBindings: true --allow-local-bindings

Local-binding matching retains an effectful initializer when the binding becomes unused. It cannot preserve a getter or proxy trap on the configured member itself, so enabling it explicitly asserts that the selected property read is safe to replace.

The default settings favor useful cleanup while preserving evaluation and module behavior.