Skip to content

fix(parser): omit calls from dead guards in C/C++, Go, TS and JS#666

Open
HouMinXi wants to merge 3 commits into
tirth8205:mainfrom
HouMinXi:pr/tree-sitter-deadguard-nonpython
Open

fix(parser): omit calls from dead guards in C/C++, Go, TS and JS#666
HouMinXi wants to merge 3 commits into
tirth8205:mainfrom
HouMinXi:pr/tree-sitter-deadguard-nonpython

Conversation

@HouMinXi

@HouMinXi HouMinXi commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Summary

f84b7d1 was the right call, and using ast for Python gets things a
syntactic walk cannot: alias resolution, shadowing, and boolean
combinations. This PR does not touch that path, and does not try to
replace it. It only covers the languages ast cannot reach.

Every non-Python language still emits dead edges:

# on main (v2.3.7)
C    void c(){ #if 0  dead(); #endif  live(); }   -> ['dead', 'live']
Go   func c(){ if false { dead() }; live() }      -> ['dead', 'live']
TS   function c(){ if(false){dead();} live(); }   -> ['dead', 'live']
Py   if False: dead()  / live()                   -> ['live']       (already fixed)

ast.parse() raises SyntaxError on C, Go, TypeScript and Rust, and
tree-sitter-language-pack is the only multi-language parser in the
dependency set, so the non-Python path has to be a tree-sitter walk.
This PR adds one, alongside the Python pass rather than replacing it.
_python_unreachable_call_positions keeps ownership of Python.

What it detects

Language Guard
Go if false { ... }
TypeScript / JavaScript if (false) { ... }, if (0) { ... }
C / C++ #if 0, #elif 0

The TS/JS condition arrives wrapped in a parenthesized_expression and
is unwrapped before matching; 0 is node type number there, not
Python's integer. The C/C++ walk deliberately ignores function and
class scope, because the preprocessor deletes the text before the
compiler sees it, so a call inside a whole function wrapped in #if 0
is dead too.

What stays live

Only the consequence is dead. These keep their edges, and each has a
test:

  • else, #else, #elif branches of a dead guard
  • if (true), if (1), and any non-literal condition
  • if (a === false), if (foo(false)), which are not literal conditions
  • calls after the dead block, and sibling declarations

Direction matters here: the risk worth guarding against is suppressing a
live call, so every ambiguous case is left live.

Semantics for nested declarations

A function or class declared inside a dead branch is never evaluated, so
calls in its body are dead. That matches the Python pass, which already
drops calls inside a def or class under if False:. JS/TS class
declarations are not hoisted, so no reachable symbol is lost by treating
them this way.

Scope

Numeric detection is the literal 0 only. 0x0 and 0.0 are equally
falsy but stay live: a missed guard just drops a suppression, whereas
evaluating arbitrary numeric literals risks the opposite error. Noted in
a comment at the check.

Not included: #ifdef / #ifndef (needs macro state, not available to
a syntactic walk), and Rust, which is ready but held back to keep this
diff reviewable.

Tests

tests/test_parser.py, one per language plus:

  • test_dead_guard_covers_declarations_nested_in_dead_branch:
    nested-declaration semantics above.
  • test_dead_guard_calls_absent_from_graph_store: builds a real
    GraphStore from each fixture and asserts the dead target never
    appears in get_all_call_targets(), mirroring the consumer-level
    check in test_python_reachability.py.

Each test was verified by injecting the bug it targets, confirming the
failure, then reverting; same drill for the 24 direct helper tests added
in bc1936e (gate disabled: the integration tests fail; helper broken:
the literal and fixture tests fail; restored: all green).

Full suite at bc1936e: 1989 passed, 5 skipped, 2 xpassed, plus one
failure in test_embedding_initialization that is pre-existing: it fails
identically at the v2.3.7 base in a clean worktree, and only on Python
3.14 Linux, which the CI matrix does not run. The 1966 figure I first
posted was from a run on a different branch; corrected.
Python behavior is unchanged; the ast path is untouched.

Questions for you

I would rather match your design than land my own, so please push back
on any of this:

  1. Placement. I put the helpers next to
    _python_unreachable_call_positions so the two reachability passes
    sit together, and added a single gate in _extract_calls. If you
    would prefer them in their own module, or the gate somewhere else,
    say the word and I will move them.
  2. Scope. Is per-language literal matching the shape you want, or
    would you rather have one small table of "statically false condition"
    node types per language that this and future languages read from? I
    kept it inline because there are only three shapes today, but I am
    happy to build the table if you expect more languages.
  3. Rust. It is written and tested (if false, and cfg!(any())
    distinguished from cfg!(any(feature = "x"))), held back only to
    keep this diff small. Want it here, as a follow-up, or not at all?
  4. #ifdef / #ifndef. Left out because it needs macro state
    rather than syntax. If you think an approximation is worth having
    for the common #ifdef UNDEFINED_MACRO case, I will look at it.
  5. Anything you would rather solve differently. If you already have
    a direction in mind for non-Python reachability, I am glad to drop
    this and implement yours instead.

Happy to split, rebase, or rework this however suits the project.

The ast-based reachability pass only sees Python, so a call inside
`#if 0`, `if false { ... }` or `if (0) { ... }` still produced a CALLS
edge in every other language. Callers, impact radius and dead-code
queries then traversed code the compiler or preprocessor drops.

Detect those guards on the tree-sitter side, next to the Python pass:

- Go / TypeScript / JavaScript: an `if` whose condition is the literal
  `false`, or `0` for TS/JS. The condition arrives wrapped in a
  `parenthesized_expression` in TS/JS and is unwrapped first.
- C / C++: `#if 0` and `#elif 0` preprocessor blocks. That walk ignores
  function and class scope, because the preprocessor removes the text
  before the compiler ever sees it.

Only the consequence is dead; `else`, `#else` and `#elif` branches keep
their edges, as does `if (true)` and any non-literal condition. A
declaration nested in a dead branch is dead as well, matching how the
Python pass already treats a `def` or `class` under `if False:` --
JS/TS class declarations are not hoisted, so nothing reachable is lost.

Numeric detection is limited to the literal `0`. `0x0` and `0.0` are
equally falsy but stay live: a missed guard only drops a suppression,
while evaluating arbitrary numeric literals risks suppressing a live
call.

Tests cover each language plus the negative cases, and assert at the
store level that a dead target never reaches a graph consumer.

Signed-off-by: Minxi Hou <houminxi@gmail.com>
@github-actions

github-actions Bot commented Jul 19, 2026

Copy link
Copy Markdown

code-review-graph review

Overall risk: 0.65 (MEDIUM) — 68 changed function(s)/class(es), 0 affected flow(s), 31 test gap(s)

Risk-scored changes

Risk Level Symbol Location Tested
0.65 medium tests/fixtures/sample_dead_guard.go::dead_in_consequence tests/fixtures/sample_dead_guard.go:20 no
0.65 medium tests/fixtures/sample_dead_guard.ts::dead_in_consequence tests/fixtures/sample_dead_guard.ts:15 no
0.51 medium tests/fixtures/sample_dead_guard.go::live_helper tests/fixtures/sample_dead_guard.go:14 no
0.51 medium tests/fixtures/sample_dead_guard.go::dead_false_call tests/fixtures/sample_dead_guard.go:16 no
0.51 medium tests/fixtures/sample_dead_guard.go::live_in_else tests/fixtures/sample_dead_guard.go:18 no
0.51 medium tests/fixtures/sample_dead_guard.go::live_final_else tests/fixtures/sample_dead_guard.go:22 no
0.51 medium tests/fixtures/sample_dead_guard.go::live_in_wrapped tests/fixtures/sample_dead_guard.go:24 no
0.51 medium tests/fixtures/sample_dead_guard.go::some_condition tests/fixtures/sample_dead_guard.go:50 no
0.51 medium tests/fixtures/sample_dead_guard.go::live_in_if_true tests/fixtures/sample_dead_guard.go:66 no
0.51 medium tests/fixtures/sample_dead_guard.ts::live_helper tests/fixtures/sample_dead_guard.ts:7 no

Test gaps

  • code_review_graph/parser.py::CodeParser._extract_calls (code_review_graph/parser.py:9500)
  • tests/fixtures/sample_dead_guard.c::dead_wrapped_func (tests/fixtures/sample_dead_guard.c:20)
  • tests/fixtures/sample_dead_guard.c::caller (tests/fixtures/sample_dead_guard.c:25)
  • tests/fixtures/sample_dead_guard.go::live_helper (tests/fixtures/sample_dead_guard.go:14)
  • tests/fixtures/sample_dead_guard.go::dead_false_call (tests/fixtures/sample_dead_guard.go:16)
  • ...and 26 more without direct tests

Token savings: this graph-backed report used ~154,018 fewer tokens (~94%) than reading every changed file in full (estimated, chars/4 approximation).


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

The automated review flagged _node_is_in_child,
_is_statically_false_condition, _is_in_static_dead_guard and
_extract_calls as lacking direct tests.  The behaviour-level tests
exercise them through parse_file(), but these tests call them
directly with tree-sitter nodes so every branch is provably hit.

24 tests covering:
- _node_is_in_child: direct child, nested, sibling, self, root
- _is_statically_false_condition: false, 0, parens, true, 1, variable
- _is_in_static_dead_guard: Go/TS/C dead + live + else + no-guard
- _extract_calls: integration verifying dead calls omitted per language

@rajpratham1 rajpratham1 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This pull request introduces a well-designed enhancement for detecting and excluding calls located in statically unreachable code across C/C++, Go, TypeScript, and JavaScript. The parser changes are focused on dead-guard detection without impacting live execution paths, and the implementation is supported by comprehensive fixture-based, integration, and helper-level unit tests covering multiple edge cases. The changes improve the accuracy of generated call graphs while maintaining consistency across supported languages. No blocking issues were identified during review. LGTM and approved.

@HouMinXi

Copy link
Copy Markdown
Contributor Author

@tirth8205
Do you have any comments regarding this patch?

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.

2 participants