Skip to content

fix(parser): omit calls inside Rust if false - #1132

Merged
1 commit merged into
tirth8205:stagingfrom
HouMinXi:fix/rust-dead-guard
Nov 6, 2026
Merged

1 commit merged into
tirth8205:stagingfrom
HouMinXi:fix/rust-dead-guard

Conversation

@HouMinXi

@HouMinXi HouMinXi commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Linked issue

No issue. The gap turned up while porting a local branch onto staging: the
walk covers if_statement for Go and TS/JS, and C/C++ preprocessor blocks,
but nothing matched Rust's shape.

What & why

A call under if false in Rust is kept as a CALLS edge.

Rust's if is an if_expression, and its false is a boolean_literal. The
tree-sitter walk in _is_in_static_dead_guard looks for if_statement with a
bare false node, so neither half matches. _is_in_rust_dead_guard walks the
ancestors for that pair and drops the call when it sits in the consequence.

What stays live: else branches, if true, if !false, if false && false,
while false, and cfg!(any()). A compound condition is left alone on
purpose: false || true is also statically true, and a wrong call there would
delete a live edge.

Kotlin and Scala build the same if_expression with a boolean_literal:

file before after
Kotlin if (false) { dead() } dead, live, other unchanged
Scala if (false) dead() dead, live, other unchanged
Rust if false { dead() } dead, live, other live, other

Putting the new node types in the shared walk instead deleted the Kotlin and
Scala edges. That is why the walk is Rust-only.

How it was tested

uv run pytest tests/test_parser.py::TestCodeParser::test_rust_dead_guard_if_false_omits_dead_edges \
  tests/test_parser.py::TestCodeParser::test_go_dead_guard_if_false_omits_dead_edges \
  tests/test_parser.py::TestCodeParser::test_c_dead_guard_if0_omits_dead_edges \
  tests/test_parser.py::TestCodeParser::test_ts_dead_guard_if_false_omits_dead_edges -q
# 4 passed

uv run pytest tests/ --tb=line -q
# 5053 passed, 880 skipped, 3 xfailed, 2 xpassed

uv run ruff check code_review_graph/
# All checks passed!

uv run mypy code_review_graph/ --ignore-missing-imports --no-strict-optional
# Success: no issues found in 77 source files

The new test fails if either half of the lookup is removed: dropping the
if_expression branch or dropping the boolean_literal check each turns
test_rust_dead_guard_if_false_omits_dead_edges red.

tests/fixtures/sample_dead_guard.rs covers an unguarded call, if false,
the else branch, an else if, and if true.

TestDeadGuardHelpers uses the same shape as the existing helper tests: the
fixture test covers the parse path, and small direct tests pin the helper's
branches so a wrong node type or a dropped range check fails on its own.

Against real code

I parsed eight Rust codebases with both staging and this branch and diffed
every CALLS edge. The right column is what this branch removes.

Codebase Files Edges before -> after Removed
trussed-dev/fido-authenticator 23 5,329 -> 5,327 2
pop-os/smithay 174 14,558 -> 14,557 1
frankmcsherry/dataflow-join 46 4,427 -> 4,425 2
dburgener/cascade 23 5,330 -> 5,318 12
fengalin/jstation-controller 67 2,042 -> 2,027 15
glitchcore/CC1895 11 143 -> 143 0
dtolnay/no-panic 10 189 -> 189 0
private 153-file service 153 32,743 -> 32,743 0

No codebase gains an edge, and every removed edge sits inside an if false
body. Two of them in context:

// src/ctap2.rs, fido-authenticator: a FIDO2 path held back over NFC
if false {
    self.state.runtime.clear_credential_cache();
    self.state.runtime.active_get_assertion = None;
    return Err(Error::NotAllowed);
}
// src/functions.rs, cascade: SELinux policy held back until secilc catches up
// if secilc/libsepol version is new enough {
if false {
    return Ok(list(&[
        atom_s("genfscon"),
        atom_s(f.fs_name.to_string().trim_matches('"')),
        ...
    ]));
}

jstation-controller is the other common shape: a layout-debug switch in
if false with the real branch in else. Only the explain edge goes.

while false keeps its edges, same as today.

One consequence: a function whose only call sites sit under if false now has
no caller, so dead-code reporting will list it. That is the point of the
change, but it is a visible diff for anyone carrying dormant code that way.

Checklist

  • Tests added for new functionality
  • All tests pass: uv run pytest tests/ --tb=short -q
  • Linting passes: uv run ruff check code_review_graph/
  • Type checking passes: uv run mypy code_review_graph/ --ignore-missing-imports --no-strict-optional
  • Lines are at most 100 characters
  • No docs change. Patch comments are the only behaviour description here.

Signed-off-by: Minxi Hou houminxi@gmail.com

@github-actions

github-actions Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

code-review-graph review

Overall risk: 0.55 (MEDIUM) — 35 changed function(s)/class(es), 47 affected flow(s), 1 test gap(s)

Risk-scored changes

Risk Level Symbol Location Tested
0.55 medium code_review_graph/parser.py::_is_in_rust_dead_guard code_review_graph/parser.py:491 yes
0.55 medium code_review_graph/parser.py::CodeParser code_review_graph/parser.py:2985 yes
0.40 medium code_review_graph/parser.py::CodeParser._extract_calls code_review_graph/parser.py:12231 no
0.40 medium tests/fixtures/sample_dead_guard.rs::dead_in_consequence tests/fixtures/sample_dead_guard.rs:13 (test)
0.35 low tests/fixtures/sample_dead_guard.rs::live_helper tests/fixtures/sample_dead_guard.rs:7 (test)
0.35 low tests/fixtures/sample_dead_guard.rs::dead_false_call tests/fixtures/sample_dead_guard.rs:9 (test)
0.35 low tests/fixtures/sample_dead_guard.rs::live_in_else tests/fixtures/sample_dead_guard.rs:11 (test)
0.35 low tests/fixtures/sample_dead_guard.rs::live_final_else tests/fixtures/sample_dead_guard.rs:15 (test)
0.35 low tests/fixtures/sample_dead_guard.rs::some_condition tests/fixtures/sample_dead_guard.rs:17 (test)
0.35 low tests/fixtures/sample_dead_guard.rs::live_in_if_true tests/fixtures/sample_dead_guard.rs:45 (test)

Affected execution flows

  • process — criticality 0.74, 540 node(s) across 14 file(s)
  • _run — criticality 0.74, 567 node(s) across 16 file(s)
  • build_or_update_graph_tool — criticality 0.73, 621 node(s) across 24 file(s)
  • refactor_tool — criticality 0.72, 159 node(s) across 12 file(s)
  • main — criticality 0.72, 927 node(s) across 40 file(s)
  • ...and 42 more affected flow(s)

Test gaps

  • code_review_graph/parser.py::CodeParser._extract_calls (code_review_graph/parser.py:12231)

Powered by code-review-graph — local-first analysis; no code leaves the CI runner.

@HouMinXi
HouMinXi force-pushed the fix/rust-dead-guard branch from 773d483 to 21c92f7 Compare October 22, 2026 18:50
@HouMinXi

Copy link
Copy Markdown
Contributor Author

Hi @tirth8205, bump on this one.

The gap: Rust's if parses as if_expression with a boolean_literal condition, so the dead-guard walk the other languages use (looking for if_statement and a bare false node) never fires. Calls under if false in Rust still land in the graph as CALLS edges; Go, TypeScript and C already drop theirs.

The fix is a Rust-only helper, _is_in_rust_dead_guard. Kotlin and Scala build the same node types and keep today's behavior. I left them alone on purpose rather than widen the blast radius.

Tests: a fixture test on the parse path, plus direct unit tests for the helper in TestDeadGuardHelpers, same shape as the ones the review bot asked for earlier. Every branch of the helper is pinned, including false && false staying live.

CI is green on this head and it merges cleanly against staging. Happy to adjust anything.

@HouMinXi

Copy link
Copy Markdown
Contributor Author

Filled in the description with what this does to real code: eight Rust codebases parsed on both branches, every CALLS edge diffed.

32 edges go, none are added, and each removed edge sits in an if false body — a FIDO2 credential path in fido-authenticator, a SELinux policy branch in cascade held back until secilc catches up, a layout-debug switch in jstation-controller. Two of the eight are unchanged.

while false is untouched, same as today.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The parser incorrectly applies guards around an impl declaration to its method bodies, dropping reachable calls and potentially reporting live targets as dead code.

1 open issue
What changed in this PR

This PR adds Rust-specific dead-guard detection so calls inside literal if false branches are omitted from the code-review graph.

Changes:

  • Recognizes Rust’s if_expression and boolean_literal nodes without changing Kotlin or Scala behavior.
  • Preserves calls in else branches, if true, and compound conditions.
  • Adds a Rust fixture and helper/integration tests for dead and live branches.
File Description
tests/​test_parser.py Adds Rust dead-guard helper and parsing tests.
tests/​fixtures/​sample_dead_guard.rs Provides dead and live branch examples.
code_review_graph/​parser.py Adds the Rust-specific ancestor check and integrates it into call extraction.

Review effort: Balanced

"""
cursor = node.parent
while cursor is not None:
if cursor.type == "if_expression":
Rust's if is an `if_expression` and its false is a `boolean_literal`, but the
non-Python dead-guard walk looks for `if_statement` and a bare `false` node.
Neither matches, so a call under `if false` in Rust reached the graph as a
CALLS edge.

`_is_in_rust_dead_guard` walks the ancestors for that pair and drops the call
when it sits in the consequence. Else branches and `if true` stay live, and so
do `if !false`, `if false && false`, `while false` and `cfg!(any())`. A
parenthesised literal, `if (false)`, counts the same as the bare one.

The walk ends at an `impl_item`. Rust impl blocks are non-local: one written
inside a branch still applies to the type, so its methods and associated consts
are reached from outside that branch and their callees are live. Inheriting the
guard from the declaration reported those targets as dead code, which is worse
than keeping a call that never runs. Everything else a dead branch encloses
stays dead, because a local `fn`, `mod` or `trait` is scoped to the block.

Only Rust takes this walk. Kotlin and Scala build the same two node types, and
their `if (false)` calls keep the edges they have today.

The helper has direct unit tests in `TestDeadGuardHelpers` alongside the
existing ones, plus fixture tests for the parse path. Both directions are
pinned: an impl const keeps its edge, a block-scoped fn/mod/trait loses it, and
the impl fixture asserts the edge that makes the method live.

Signed-off-by: Minxi Hou <houminxi@gmail.com>
@HouMinXi
HouMinXi force-pushed the fix/rust-dead-guard branch from 21c92f7 to bcaacc8 Compare November 5, 2026 18:29
@HouMinXi

HouMinXi commented Nov 5, 2026

Copy link
Copy Markdown
Contributor Author

Confirmed, and thanks — the guard was inheriting across the declaration boundary.

Reproduced with the shape from the review:

struct S;
fn live_helper() {}
fn main() {
    if false {
        impl S { fn run() { live_helper(); } }
    }
    S::run();
}

rustc --edition 2021 builds it and the call outside the branch reaches the method (the impl is non-local; rustc warns non_local_definitions). Before the fix the parser produced one CALLS edge, S.run, and dropped live_helper, so a live target looked unreachable.

The walk now ends at an impl_item. Two things made function_item the wrong boundary:

  • An associated const has no function_item between the call and the guard: if false { impl S { const X: i32 = g(); } } with S::X read outside. That call is live, and a function-level boundary still dropped it. Covered by test_rust_impl_associated_const_kept.
  • A local fn, mod or trait inside the branch really is unreachable (error[E0425]: cannot find function ... in this scope when named outside), so stopping at the function boundary had it backwards there. Those stay dead now, covered by test_rust_block_scoped_item_under_guard_stays_dead.

Guards inside a body still apply, because the walk reaches the if_expression before any item boundary: impl S { fn run() { if false { g(); } } } still drops g. I also made if (false) count, since the sibling helper unwraps parenthesised conditions for TS/JS but the Rust walk did not.

Tests: test_parser.py 184 passed, test_parser.py + test_python_reachability.py 192 passed, and the full suite on this commit 5059 passed / 880 skipped / 3 xfailed / 2 xpassed / 0 failed. The fixture tests/fixtures/sample_dead_guard_impl.rs asserts both the kept edge S.run -> live_helper and the edge that makes it live, main -> S.run.

Both directions are load-bearing: switching the boundary back to function_item fails test_rust_impl_associated_const_kept and test_rust_block_scoped_item_under_guard_stays_dead; removing the boundary entirely fails the impl tests and the fixture; dropping the parenthesis unwrap fails test_rust_parenthesized_false_dead.

@tirth8205 tirth8205 closed this pull request by merging all changes into tirth8205:staging in 0160eb0 Nov 6, 2026
gowrishacv pushed a commit to gowrishacv/code-review-graph that referenced this pull request Nov 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants