# Develop transformation rules

> Add or modify VisitMut rules: test-first workflow, pipeline placement in RuleDescriptor order, unresolved_mark guards, BindingRenamer for renames, and definition-of-done verification checklist.

- Repository: pionxzh/wakaru
- GitHub: https://github.com/pionxzh/wakaru
- Human docs: https://grok-wiki.com/public/docs/pionxzh-wakaru-77a438a6cc6b
- Complete Markdown: https://grok-wiki.com/public/docs/pionxzh-wakaru-77a438a6cc6b/llms-full.txt

## Source Files

- `AGENTS.md`
- `CONTRIBUTING.md`
- `docs/architecture.md`
- `docs/testing.md`
- `crates/core/src/rules/pipeline.rs`
- `crates/core/src/rules/rename_utils.rs`

---

---
title: "Develop transformation rules"
description: "Add or modify VisitMut rules: test-first workflow, pipeline placement in RuleDescriptor order, unresolved_mark guards, BindingRenamer for renames, and definition-of-done verification checklist."
---

Wakaru's decompile pipeline applies roughly 60 SWC `VisitMut` transformation rules from `crates/core/src/rules/`, executed in a fixed `RuleDescriptor` order defined in `pipeline.rs`. Each rule is one Rust file, registered via `mod.rs` and a `runner!` entry in `define_rule_registry!`; rule behavior is validated with isolated `render_rule` tests before pipeline snapshot regressions are reviewed.

## Rule file layout

| Path | Role |
|---|---|
| `crates/core/src/rules/<rule>.rs` | Rule implementation (`VisitMut`) |
| `crates/core/src/rules/mod.rs` | `mod` declaration + `pub use` export |
| `crates/core/src/rules/pipeline.rs` | `runner!` function, `RuleDescriptor` entry, registry order |
| `crates/core/tests/<rule>_rule.rs` | Isolated unit tests (required for every change) |

A minimal rule struct implements `VisitMut` and visits children before applying its own transform:

```rust
use swc_core::ecma::ast::Expr;
use swc_core::ecma::visit::{VisitMut, VisitMutWith};

pub struct MyRule;

impl VisitMut for MyRule {
    fn visit_mut_expr(&mut self, expr: &mut Expr) {
        expr.visit_mut_children_with(self);
        // transformation logic
    }
}
```

Rules that match identifiers by name take `unresolved_mark: Mark` and receive it from `RuleRunContext` in the pipeline runner (see [Scope-aware matching](#scope-aware-matching-with-unresolved_mark)).

<Note>
Second pipeline passes reuse the same runner but get a suffixed registry ID — for example `UnWebpackInterop2`, `UnIife2`, `UnParameters3`. Test helpers and `debug trace` use these suffixed names.
</Note>

## Test-first workflow

Every rule change requires a focused unit test. Pipeline snapshot updates alone do not satisfy coverage — they exercise the whole pipeline, not the individual rule.

<Steps>
<Step title="Write failing tests">

Create or extend `crates/core/tests/my_rule_rule.rs` with positive cases (pattern transforms) and negative cases (unrelated code unchanged):

```rust
mod common;

use common::{assert_eq_normalized, render_rule};
use wakaru_core::rules::MyRule;

fn apply(input: &str) -> String {
    render_rule(input, |unresolved_mark| MyRule::new(unresolved_mark))
}

#[test]
fn transforms_target_pattern() {
    let input = r#"/* minified input */"#;
    let expected = r#"/* readable output */"#;
    assert_eq_normalized(&apply(input), expected);
}

#[test]
fn leaves_unrelated_code_alone() {
    let input = r#"/* code that should not change */"#;
    assert_eq_normalized(&apply(input), input);
}
```

Run only your test file while iterating:

```bash
cargo test -p wakaru-core --test my_rule_rule
```

</Step>

<Step title="Implement the rule">

Add `crates/core/src/rules/my_rule.rs` and wire it into `mod.rs` and `pipeline.rs` (next section). Iterate until focused tests pass.

</Step>

<Step title="Check pipeline regressions">

Run the required pipeline test binaries and review any snapshot drift:

```bash
cargo test -p wakaru-core --test noop_pipeline
cargo test -p wakaru-core --test webpack4_unpack
cargo test -p wakaru-core --test webpack4_unpack_raw
cargo test -p wakaru-core --test bundle_unpack
cargo test -p wakaru-core --test esbuild_unpack
```

</Step>
</Steps>

### Choosing a test helper

| Helper | When to use |
|---|---|
| `render_rule(source, builder)` | Default — single rule in isolation (resolver + rule + fixer) |
| `render(source)` | Rule depends on earlier normalization (helper detection, Stage 1 transforms) |
| `render_pipeline_until(source, stop_after)` | Verify AST shape at a specific pipeline point |
| `render_pipeline_between(source, start, stop)` | Test a rule given realistic pre-processed input without downstream effects |
| `assert_eq_normalized(actual, expected)` | Compare output after whitespace normalization |

For rules needing `unresolved_mark`, pass it through the builder closure:

```rust
fn apply(input: &str) -> String {
    render_rule(input, |unresolved_mark| MyRule::new(unresolved_mark))
}
```

For `.ts`/`.tsx` inputs, use `render_rule_with_filename`.

<Warning>
Do not use bare expression statements as test inputs (e.g. `65536;`) — `SimplifySequence` drops them as dead code. Wrap in a binding: `const x = 65536;`.
</Warning>

Bugfixes to existing rules need a regression test that reproduces the exact failure before the fix.

## Register a rule in the pipeline

<Steps>
<Step title="Export from mod.rs">

```rust
mod my_rule;
pub use my_rule::MyRule;
```

</Step>

<Step title="Add a runner in pipeline.rs">

Use the `runner!` macro. Pass `unresolved_mark` or other context from `RuleRunContext` when needed:

```rust
runner!(run_my_rule, |ctx| MyRule::new(ctx.unresolved_mark));
```

For rules without context:

```rust
runner!(run_my_rule, MyRule);
```

Helper-dependent rules may need a custom runner function (see `run_un_es6_class` or `run_un_template_literal` for patterns that call `ctx.local_helpers(module)`).

</Step>

<Step title="Insert a RuleDescriptor entry">

Add an entry inside `define_rule_registry!` at the position where upstream dependencies are satisfied:

```rust
("MyRule", Structural, run_my_rule, always_enabled, requires: [
    "UnBracketNotation"
]),
```

Each descriptor specifies:

<ParamField body="id" type="&'static str" required>
Registry name used by `rule_names()`, `debug trace`, and `render_pipeline_until`. Must be unique; second passes append a numeric suffix (`UnIife2`).
</ParamField>

<ParamField body="stage" type="RuleStage" required>
One of `Syntax`, `Helpers`, `Structural`, `Complex`, `Modernization`, `Cleanup`. Metadata for grouping; execution order follows registry position, not stage enum order alone.
</ParamField>

<ParamField body="runner" type="RuleRunner" required>
Function produced by `runner!` or a custom `fn run_*` that calls `module.visit_mut_with`.
</ParamField>

<ParamField body="enabled" type="RuleEnabled" required>
Gate function: `always_enabled`, `standard_or_above`, or `dead_code_elimination_enabled`.
</ParamField>

<ParamField body="requires" type="&[&'static str]" >
Optional documented dependency list. Does not auto-enforce ordering — placement in the registry array is what determines run order. Comments in the registry document why a rule sits where it does.
</ParamField>

</Step>
</Steps>

### Pipeline stages

Rules group into six stages. Placement within the ordered registry matters more than the stage label:

| Stage | Examples | Typical purpose |
|---|---|---|
| `Syntax` | `SimplifySequence`, `FlipComparisons`, `UnBracketNotation` | Normalize minified syntax |
| `Helpers` | `UnInteropRequireDefault`, `UnCurlyBraces`, `UnEsm` | Unwrap transpiler helpers, reconstruct modules |
| `Structural` | `UnTemplateLiteral`, `UnNullishCoalescing`, `UnOptionalChaining` | Restore language constructs |
| `Complex` | `UnIife`, `UnParameters`, `UnEs6Class`, `UnAsyncAwait` | Multi-pattern recovery |
| `Modernization` | `ArrowFunction`, `VarDeclToLetConst`, `UnForOf` | ESNext idioms |
| `Cleanup` | `SmartInline`, `SmartRename`, `DeadDecls`, `UnReturn` | Rename, inline, dead-code cleanup |

```text
parse → resolver(unresolved_mark) → apply_rules(RULE_DESCRIPTORS) → fixer → emit
                              ↑
                    RulePipelineOptions controls
                    start/stop range, rewrite level,
                    DCE mode, module facts
```

### Placement heuristics

When choosing registry position, ask what AST shape your rule expects and what later rules consume:

| Requirement | Place after / before |
|---|---|
| `["default"]` normalized to `.default` | After `UnBracketNotation` |
| `require()` calls still present | Before `UnEsm` |
| Rule creates new IIFEs | Before `UnIife2` (second pass) |
| Alias `var` declarations must remain | Before `SmartInline` (removes `var h = p`) |
| Export specifiers must reference real bindings | After `SmartInline` |
| `UnEsm` prerequisites (braces, `__esModule`, assignment splitting) | After `UnCurlyBraces`, `UnEsmoduleFlag`, `UnAssignmentMerging`, etc. |

Use `cargo run -p wakaru-cli -- debug trace input.js` or `render_pipeline_until` to confirm the AST shape your rule receives. See the [rule pipeline reference](/rule-pipeline-reference) for the full ordered registry and documented `requires` edges.

<Info>
`RulePipelineOptions` supports `until(stop_after)`, `between(start, stop)`, `with_rewrite_level`, `with_dce_mode`, and `with_module_facts` for tests and the unpack driver's two-phase execution.
</Info>

## Scope-aware matching with unresolved_mark

After `resolver(unresolved_mark, top_level_mark)`, every identifier carries a `SyntaxContext`. Free variables (globals like `Object`, `require`) have `unresolved_mark` as their outer mark; locally bound identifiers do not.

Rules that match identifiers **by name** must gate on `SyntaxContext` to avoid transforming unrelated inner-scope bindings:

```rust
use swc_core::common::Mark;
use swc_core::ecma::ast::Ident;

pub struct MyRule {
    unresolved_mark: Mark,
}

impl MyRule {
    pub fn new(unresolved_mark: Mark) -> Self {
        Self { unresolved_mark }
    }
}

// Inside a visitor method:
fn is_free_variable(&self, id: &Ident) -> bool {
    id.ctxt.outer() == self.unresolved_mark
}

// Guard pattern — skip bound locals:
if id.ctxt.outer() != self.unresolved_mark {
    return;
}
```

Without this guard, a rule matching webpack factory param `e` would also rewrite `e` inside `function inner(e) { ... }`.

<Warning>
Every new visitor that matches identifiers by name must take `unresolved_mark: Mark` and gate on it. Missing guards are a common source of snapshot cascades and incorrect renames.
</Warning>

## Renaming with BindingRenamer

Never rename identifiers by `sym` alone with a custom `VisitMut` — that hits inner-scope locals and parameters sharing the same name. Use `rename_utils::BindingRenamer`, which keys renames on `(Atom, SyntaxContext)` bindings.

### Core types and functions

```rust
use wakaru_core::rules::rename_utils::{
    rename_bindings_in_module, rename_bindings,
    BindingRename, BindingId,
};

// BindingId = (Atom, SyntaxContext)
let renames = vec![BindingRename {
    old: (old_sym, old_ctxt),
    new: new_name,
}];
rename_bindings_in_module(module, &renames);
// Or on a subtree:
rename_bindings(&mut stmts, &renames);
```

`BindingRenamer` handles declaration sites, import/export specifiers, object shorthand, and destructuring patterns — cases a naive ident swap misses.

### Shadow safety

Before applying a rename, check whether the new name would be captured by a nested scope:

| Function | Purpose |
|---|---|
| `rename_causes_shadowing(module, old, new_name)` | Whole-module shadow check for one binding |
| `binding_replacement_would_be_shadowed(module, old, replacement_name)` | Targeted check before raw `Expr::Ident` substitution |
| `RenameShadowIndex::for_bindings(module, bindings)` | Batch forbidden-name index for multiple renames |

`SmartRename`, `UnImportRename`, `ImportDedup`, and `UnParameters` all route through these utilities.

## Modify an existing rule

Default: add tests to the existing `crates/core/tests/<rule>_rule.rs` file rather than creating a new one. Only create a new `*_rule.rs` file when adding an entirely new rule.

For bugfixes:

1. Add a regression test reproducing the exact broken input/output pair.
2. Fix the rule implementation.
3. Run focused tests, then pipeline tests.
4. If snapshots change, inspect diffs — confirm output is semantically better, not merely different.

## Definition of done

Before opening a PR, complete this checklist:

<Steps>
<Step title="Focused rule tests">

```bash
cargo test -p wakaru-core --test my_rule_rule
```

</Step>

<Step title="Pipeline integration tests">

```bash
cargo test -p wakaru-core --test noop_pipeline
cargo test -p wakaru-core --test webpack4_unpack
cargo test -p wakaru-core --test webpack4_unpack_raw
cargo test -p wakaru-core --test bundle_unpack
cargo test -p wakaru-core --test esbuild_unpack
```

</Step>

<Step title="Formatting and lint">

```bash
cargo fmt --check
cargo clippy -p wakaru-core --all-targets -- -D warnings
```

Use `cargo clippy --workspace --all-targets -- -D warnings` when touching non-core crates.

</Step>

<Step title="Review snapshot diffs">

`.cargo/config.toml` sets `INSTA_UPDATE=new` — changed snapshots fail tests and write `.snap.new` files. Review each diff; accept intentional changes with `cargo insta accept`. No stale `.snap.new` files should remain (`git status --short`).

</Step>

<Step title="Optional fixture matrix">

If you have the sibling `wakaru-fixtures` repo and the change affects decompile output:

```bash
../wakaru-fixtures/run.sh --check
```

</Step>
</Steps>

Prefer `cargo nextest run -p wakaru-core` for faster iteration during development; `cargo test` remains valid for single-file focus.

## Debugging rule behavior

When a rule does not fire or produces unexpected output:

| Symptom | Likely cause | Action |
|---|---|---|
| Rule not firing | Earlier rule changed AST shape | `debug trace` to see input at your rule's position |
| Wrong variable renamed | Missing `unresolved_mark` guard | Add `ctxt.outer()` check |
| Many snapshots changed | Early rule cascading | Bisect with `render_pipeline_until` or `--from`/`--until` trace ranges |
| `render_rule` unchanged but `render` works | Depends on earlier normalization | Use `render` or pre-normalize test input |
| Test hangs | Infinite recursion in visitor | `RUST_BACKTRACE=1 cargo test -- --nocapture` |

<CodeGroup>

```bash title="Rule trace CLI"
cargo run -p wakaru-cli -- debug trace path/to/input.js
```

```bash title="Trace a rule range"
cargo run -p wakaru-cli -- debug trace path/to/input.js --from RemoveVoid --until UnEsm
```

```bash title="Show all rules including no-ops"
cargo run -p wakaru-cli -- debug trace path/to/input.js --all
```

</CodeGroup>

## Related pages

<CardGroup>
<Card title="Rule pipeline reference" href="/rule-pipeline-reference">
Ordered `RuleDescriptor` registry, `rule_names()` identifiers, `RuleStage` groupings, and documented cross-rule dependencies.
</Card>
<Card title="Trace the rule pipeline" href="/trace-rule-pipeline">
Per-rule diffs with `debug trace`, `--from`/`--until` ranges, and bisection workflow for single-file regressions.
</Card>
<Card title="Testing and snapshots" href="/testing-and-snapshots">
`cargo nextest` vs `cargo test`, insta snapshot workflow, test helpers, and the pre-commit verification matrix.
</Card>
<Card title="Decompile pipeline" href="/decompile-pipeline">
Parse → resolver → staged rules → fixer → emit flow, including `unresolved_mark` scope gating in context.
</Card>
<Card title="Helper detection" href="/helper-detection">
`LocalHelperContext`, body-shape matchers, and helper lifecycle for rules in the Helpers stage.
</Card>
<Card title="Debug regressions" href="/debugging-regressions">
Snapshot drift investigation, `unresolved_mark` symptom mapping, and early-rule cascade diagnosis.
</Card>
</CardGroup>
