본문으로 건너뛰기
ff1451 logo ff1451

Biome `noUselessReturn` 기여기: AST 구현과 `.same()`에서 `.inspired()`로 정리한 이유

ESLint `no-useless-return`을 Biome nursery 룰로 포팅하면서 AST 기반 tail position 판별, `.inspired()` 매핑 선택, 26개 스냅샷 테스트로 검증한 과정을 정리했다.

8 min read

들어가며

Biome 도입하고 얼마 안 됐을 때였다. ESLint에서 쓰던 룰 중에 Biome에 없는 게 꽤 있었는데, no-useless-return도 그중 하나였다. 이슈 트래커를 뒤지다 #8205를 발견했으며, 마침 아무도 작업 중이지 않았기에 직접 포팅해보기로 했다.

1. noUselessReturn이 필요한 이유

function foo() {
    doSomething();
    return; // 이 return은 아무 의미가 없다
}

함수 끝에 남겨진 빈 return;은 지워도 동작이 바뀌지 않는다. 리팩토링 과정에서 남은 잔재인 경우가 대부분이다. ESLint에선 no-useless-return이 이걸 잡아줬는데, Biome엔 없었다.

2. Biome lint 룰의 구조

Biome의 lint 룰은 declare_lint_rule! 매크로와 Rule 트레이트로 구성된다.

declare_lint_rule! {
    /// rustdoc 형식의 문서 — 웹사이트 문서로 자동 생성됨
    pub NoUselessReturn {
        version: "next",
        name: "noUselessReturn",
        language: "js",
        sources: &[RuleSource::Eslint("no-useless-return").inspired()],
        recommended: false,
        fix_kind: FixKind::Safe,
        issue_number: Some("8205"),
    }
}

impl Rule for NoUselessReturn {
    type Query = Ast<JsReturnStatement>;
    type State = ();
    type Signals = Option<Self::State>;
    type Options = NoUselessReturnOptions;

    fn run(ctx: &RuleContext<Self>) -> Self::Signals { ... }
    fn diagnostic(...) -> Option<RuleDiagnostic> { ... }
    fn action(...) -> Option<JsRuleAction> { ... }
}

여기서 Query 타입이 동작 방식을 결정한다. Ast<JsReturnStatement>로 설정하면 파일 내 모든 return 문마다 run()이 호출되는데, Some(())을 반환하면 진단이 발생하고 diagnostic()으로 메시지를, action()으로 자동 수정을 붙일 수 있다.

3. 스캐폴딩

just new-js-lintrule noUselessReturn

명령어 하나로 상당한 양의 보일러플레이트가 생성된다.

  • 룰 구현 파일 (crates/biome_js_analyze/src/lint/nursery/no_useless_return.rs)
  • 옵션 타입 (crates/biome_rule_options/src/no_useless_return.rs)
  • 룰 등록: rules.rs, categories.rs, eslint_any_rule_to_biome.rs
  • JSON schema, TypeScript 바인딩 (configuration_schema.json, workspace.ts)

구현이 끝나면 just gen-analyzer도 돌려야 한다. 처음엔 이걸 빠뜨렸다가 CI에서 codegen diff로 실패했다. 한 번 겪고 나면 잊기 어렵다.

4. AST vs ControlFlowGraph

처음엔 CFG 방식으로 짰다. 모든 블록을 순회하며 Return 인스트럭션을 찾고, 이후에 도달 가능한 블록이 없는지 forward-flow로 확인하는 방식이었는데, 코드가 350줄까지 불어났다. RoaringBitmap, FxHashSet 같은 외부 의존성도 끌려왔다.

뭔가 잘못됐다는 느낌이 들어서 다시 생각해보니, 이 룰이 묻는 질문이 달랐다. “이 return;을 제거해도 실행 흐름이 바뀌지 않는가”가 아니라 “이 return;이 구문적으로 함수의 마지막 위치에 있는가”였다. 도달 가능성 문제가 아니라 위치 문제였고, CFG 전체를 분석할 필요가 없었다.

두 방식의 핵심 질문 비교

방식묻는 질문
ASTreturn;이 구문적으로 함수의 마지막 위치인가?
CFGreturn;을 제거해도 실행 흐름이 바뀌지 않는가?

ControlFlowGraph는 함수 단위로 CFG 전체를 받아 분석한다. BasicBlock과 Instruction의 그래프로 표현되며, 도달 가능성을 분석하는 룰에 적합하다.

Ast<T>는 특정 AST 노드마다 실행된다. 노드의 위치나 구조를 검사할 때 쓴다.

return;이 tail position인지 확인하는 건 후자로 충분하다. 부모를 타고 올라가며 JsStatementList에서 마지막 항목인지, 최종적으로 JsFunctionBody에 도달하는지 보면 된다.

JsFunctionBody
└── JsStatementList
    ├── JsExpressionStatement  // doSomething();
    └── JsReturnStatement      // return;
        └── (argument: None)   // 빈 return

복잡도 비교

항목CFG 방식AST 방식 (채택)
코드 규모약 350줄약 170줄
추가 의존성RoaringBitmap, FxHashSet없음
핵심 분석forward-flow 기반 도달 가능성 분석조상 노드 순회 기반 위치 판정
dead code 반영가능제한적
ESLint 동작 일치성더 높게 맞출 수 있음차이 존재 (.inspired())

5. 구현

기본 조건 체크

run()에서 먼저 간단한 조건들을 걸러낸다. 값을 반환하는 return이나 루프 내부 return처럼 명백히 유효한 케이스는 여기서 빠르게 제외한다.

fn run(ctx: &RuleContext<Self>) -> Self::Signals {
    let ret = ctx.query();

    // 1. 값을 반환하는 return은 제외 (return 5; 같은 경우)
    if ret.argument().is_some() {
        return None;
    }

    // 2. 가장 가까운 control flow root를 찾음
    let function_root = ret
        .syntax()
        .ancestors()
        .find(|node| AnyJsControlFlowRoot::can_cast(node.kind()))?;

    // 3. 모듈/스크립트 레벨의 return은 무시
    if JsModule::can_cast(function_root.kind())
        || JsScript::can_cast(function_root.kind())
    {
        return None;
    }

    // 4. 루프/switch 내부의 return은 의도된 흐름
    if is_inside_loop_or_switch(ret, &function_root) {
        return None;
    }

    // 5. tail position이면 진단
    if is_tail_position(ret, &function_root) {
        return Some(());
    }

    None
}

Tail position 체크

조건을 통과하면 tail position 체크로 넘어간다. return 노드에서 출발해 부모를 타고 올라가며 “이 return이 정말 마지막인가”를 확인하는 구조다.

fn is_tail_position(
    ret: &JsReturnStatement,
    function_root: &SyntaxNode<JsLanguage>,
) -> bool {
    let mut current = ret.syntax().clone();
    loop {
        let Some(parent) = current.parent() else { return false; };

        if JsFunctionBody::can_cast(parent.kind()) {
            return true; // 함수 body에 직접 도달 → tail position 확정
        }
        if &parent == function_root {
            return false;
        }

        if JsStatementList::can_cast(parent.kind()) {
            let list = JsStatementList::cast(parent.clone()).unwrap();
            if let Some(last) = list.iter().last() {
                if last.syntax() != &current {
                    return false; // 뒤에 다른 statement가 있음
                }
            } else {
                return false;
            }
        } else if JsFinallyClause::can_cast(parent.kind()) {
            return false; // finally 내부는 특수한 의미 — 제외
        }
        // 아래 노드들은 "투명하게" 통과
        // JsBlockStatement, JsIfStatement, JsElseClause,
        // JsCatchClause, JsTryStatement, JsTryFinallyStatement,
        // JsLabeledStatement

        current = parent;
    }
}

여기서 “투명하게 통과”하는 노드 목록이 중요하다. if, else, try/catch는 그 자체가 제어 흐름의 끝이 될 수 있으니 부모를 계속 따라 올라가야 한다. finally는 예외인데, 내부 return을 제거하면 try/catch의 반환값을 덮어쓰는 동작이 바뀔 수 있어서 건드리지 않는다.

자동 수정

자동 수정은 mutation.remove_node()로 return 문을 제거하는 방식이다.

fn action(ctx: &RuleContext<Self>, _state: &Self::State) -> Option<JsRuleAction> {
    let mut mutation = ctx.root().begin();
    mutation.remove_node(ctx.query().clone());
    Some(JsRuleAction::new(
        ctx.metadata().action_category(ctx.category(), ctx.group()),
        ctx.metadata().applicability(),
        markup! { "Remove the unnecessary " <Emphasis>"return"</Emphasis> " statement." }
            .to_owned(),
        mutation,
    ))
}

다만 if (foo) return;처럼 블록 없이 if의 단독 consequent인 경우엔 return만 지우면 if (foo)라는 유효하지 않은 코드가 남는다. 지금은 블록 형태(if (foo) { return; })만 안전하게 처리하고, 블록 없는 형태는 별도 개선으로 남겨뒀다.

6. 놓치기 쉬운 엣지 케이스들

루프 / switch 내부

function foo() {
    for (const x of xs) {
        return; // 유효 — 루프를 빠져나오는 의도
    }
}

루프 안의 return;은 루프를 빠져나오려는 의도가 있으므로 진단하면 안 된다. ancestors()를 순회하다가 function root에 도달하기 전에 루프나 switch 노드가 나오면 거기서 멈춘다.

finally 블록

function foo() {
    try {
        doSomething();
    } finally {
        return; // 유효 — finally의 return은 특수한 의미를 가짐
    }
}

finally 안의 return도 제외 대상이다. try나 catch의 반환값을 덮어쓰는 특수한 동작이라 제거하면 의미가 달라진다.

try-finally에서의 try body

function k() {
    try {
        return; // 진단 대상 — finally 실행 후 함수가 종료됨
    } finally {
        cleanup();
    }
}

반면 try 블록 안의 return;은 다르다. finally가 있더라도 함수가 자연스럽게 종료되는 흐름이라 useless할 수 있고, 진단 대상이 된다.

7. .same() vs .inspired()

처음엔 .same()으로 작성했다. ESLint no-useless-return을 포팅하는 거니까 당연히 동작이 같아야 한다고 생각했는데, 코드 리뷰에서 제동이 걸렸다.

RuleSource::Eslint("no-useless-return").same()      // 동작이 동일
RuleSource::Eslint("no-useless-return").inspired()  // 참고했지만 동작이 다를 수 있음

실제로 두 방식이 다른 결론을 내리는 케이스가 있었다.

function foo() {
    if (condition) {
        return;  // AST: 허용 / CFG: 무의미 (감지)
    } else {
        return;  // AST: 허용 / CFG: 무의미 (감지)
    }
    bar(); // 구문적으로는 존재하지만 dead code
}

AST 방식은 if문 다음에 bar()가 있으므로 if가 리스트의 마지막 요소가 아니라고 판단해 진단하지 않는다. CFG 방식은 bar()로 오는 실행 경로가 없으므로 두 return 모두 감지한다.

CFG 블록 관점으로 보면 더 명확하다.

Block A (if branch)   -> Return
Block B (else branch) -> Return
Block C (bar();)      -> 어떤 블록에서도 진입하지 않음 (unreachable)

AST는 구문상 if 뒤에 bar()가 존재한다는 사실을 본다. CFG는 bar()로 들어오는 실행 경로가 없다는 사실을 본다. 그러니 “같은 동작”이 아니라 “영감을 받은 구현”이 맞았다.

이 변경은 생각보다 파급 효과가 있었다. just gen-analyzer를 돌리자 eslint_any_rule_to_biome.rs가 자동으로 바뀌었다.

// .inspired()로 바꾼 후 (codegen 결과)
"no-useless-return" => {
    if !options.include_inspired {  // 이 체크가 자동으로 추가됨
        results.add(eslint_name, RuleMigrationResult::Inspired);
        return false;
    }
    if !options.include_nursery { ... }
    ...
}

.inspired() 룰은 biome migrate할 때 --include-inspired 옵션을 명시적으로 켜야 마이그레이션된다. 동작이 다를 수 있는 룰을 자동으로 켜지 않도록 하는 안전장치다.

8. 테스트 작성 전략

테스트는 스냅샷 중심으로 구성했다. invalid.js 12개, valid.js 14개로 총 26개 케이스를 고정했는데, invalid는 진단과 safe fix가 기대대로 발생하는지, valid는 오탐이 없는지를 확인하는 역할이다.

invalid.js — 감지되어야 하는 12개 케이스

  • 함수 마지막 단독 return;
  • 다른 구문 뒤에 오는 마지막 return;
  • if/else 블록 끝의 return;
  • 중첩 if 체인의 tail-position return;
  • try/catch 끝의 return;
  • 화살표 함수, labeled statement, try-finallytry 블록 케이스

valid.js — 허용되어야 하는 14개 케이스

  • return 5;, return undefined;
  • early return 뒤에 후속 코드가 있는 패턴
  • 루프(for, while, do-while, for-in, for-of) 내부 return;
  • switch 내부 return;
  • finally 내부 return;
  • 중첩 함수 스코프의 return;

Biome 테스트는 invalid.js 입력을 기준으로 invalid.js.snap을 생성해 진단 메시지와 fix 결과를 함께 고정한다. valid.js는 진단이 없어야 하므로 허용 케이스 회귀를 점검하는 역할을 한다.

9. 보일러플레이트 맵

파일이 13개나 바뀌어서 처음엔 부담스러워 보였는데, 역할 단위로 나눠보면 패턴이 보인다. 대부분은 just gen-analyzer 한 번으로 자동 갱신되고, 직접 손대는 건 룰 구현 파일과 테스트뿐이다.

진단/룰 메타데이터

  • crates/biome_js_analyze/src/lint/nursery/no_useless_return.rs
  • crates/biome_diagnostics_categories/src/categories.rs

설정/옵션 등록

  • crates/biome_configuration/src/analyzer/linter/rules.rs
  • crates/biome_rule_options/src/no_useless_return.rs
  • crates/biome_rule_options/src/lib.rs

ESLint 마이그레이션 매핑

  • crates/biome_cli/src/execute/migrate/eslint_any_rule_to_biome.rs

TypeScript 바인딩과 JSON Schema

  • packages/@biomejs/backend-jsonrpc/src/workspace.ts
  • packages/@biomejs/biome/configuration_schema.json

테스트/릴리즈 문서

  • crates/biome_js_analyze/tests/specs/nursery/noUselessReturn/*
  • .changeset/chatty-lies-greet.md

마치며

CFG로 350줄을 짜다가 AST로 전환한 경험이 이번 기여에서 가장 인상적이었다. “도달 가능성 문제인가, 위치 문제인가”를 먼저 정의했더니 구현이 절반으로 줄었다. 어떤 도구를 쓸지보다 어떤 질문에 답해야 하는지를 먼저 명확히 하는 것, 이번에 확인한 건 그거다.

Biome 기여 자체는 생각보다 진입장벽이 낮았다. 보일러플레이트는 명령어 하나로 생성되고, .inspired()로 바꾸는 것처럼 작은 결정 하나가 마이그레이션 코드까지 자동으로 따라오는 구조가 인상적이었다. 다음에 Biome에서 빠진 룰을 발견하면 또 손댈 것 같다.