Skip to content

Commit f859c2e

Browse files
fix(health): stop flagging reachable code as unreachable in the dataflow CFG (#760)
Two defects made CFG.reachable_ids() flag plainly reachable lines, tripping a tree-wide sweep on 106 functions with zero real positives. 1. Comment nodes entered the CFG as statements. Tree-sitter emits a trailing comment as a named sibling after its statement, so a return followed by a same-line comment put a comment node after the terminator and _process_seq spawned a bogus unreachable block at the return line. _body_stmts now drops comment nodes, the same idiom nloc counting uses. 2. A finally body was only reachable through the normal join, so a try whose body and handlers all terminate (return / raise) had its finally flagged unreachable even though it runs on every path. The builder now adds an edge from the protected-region entry to the finally entry, mirroring the existing handler approximation. Also renames the misleading local in derive_facts that held the reachable set under the name unreachable. No shipped consumer reads reachable_ids() yet; statement-level dead code will build on it, so it gets fixture coverage for both shapes first.
1 parent 570ca84 commit f859c2e

3 files changed

Lines changed: 163 additions & 5 deletions

File tree

packages/core/src/repowise/core/analysis/health/dataflow/cfg.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -254,12 +254,20 @@ def _block_of(self, node: Node | None) -> Node | None:
254254
def _body_stmts(self, block: Node | None) -> list[Node]:
255255
"""The statement sequence inside a container, unwrapping a single nested
256256
statement-container (Go wraps a ``block``'s statements in a
257-
``statement_list``; Python/TS expose them directly)."""
257+
``statement_list``; Python/TS expose them directly).
258+
259+
Comment nodes are dropped: tree-sitter emits them as named siblings in
260+
the statement sequence (a trailing ``return x # note`` puts the comment
261+
*after* the return), and a comment threaded through ``_process_seq``
262+
after a terminator would spawn a bogus ``unreachable`` block at the
263+
terminator's own line. The substring match covers every grammar's
264+
comment kinds (``comment`` / ``line_comment`` / ``block_comment`` /
265+
``doc_comment``), the same idiom nloc counting uses."""
258266
if block is None:
259267
return []
260-
kids = list(block.named_children)
268+
kids = [c for c in block.named_children if "comment" not in c.type]
261269
while len(kids) == 1 and kids[0].type in self.lmap.block_kinds:
262-
kids = list(kids[0].named_children)
270+
kids = [c for c in kids[0].named_children if "comment" not in c.type]
263271
return kids
264272

265273
# -- the recursive walk ---------------------------------------------------
@@ -444,6 +452,13 @@ def _build_try(self, try_node: Node, cur: BasicBlock) -> BasicBlock:
444452
# try body (the protected region)
445453
body_entry = self._new()
446454
self._edge(cur, body_entry)
455+
# The finally body runs on every path out of the protected region,
456+
# including abrupt exits (a body / handler ``return`` or ``raise``)
457+
# that never reach the normal join. Approximate with an edge from the
458+
# region entry, mirroring the handler edges below; without it a
459+
# ``finally`` after an always-returning body is flagged unreachable.
460+
if finally_clause is not None:
461+
self._edge(body_entry, normal_join)
447462
body_out = self._process_seq(
448463
self._body_stmts(try_node.child_by_field_name("body")), body_entry
449464
)

packages/core/src/repowise/core/analysis/health/dataflow/facts.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -124,13 +124,13 @@ def derive_facts(analysis: FunctionAnalysis) -> DataflowFacts:
124124
exit_defs = reaching.in_sets.get(cfg.exit_id, frozenset())
125125
flows_out = tuple(sorted({def_use.definitions[i].var for i in exit_defs if i >= n_param_seeds}))
126126

127-
unreachable = cfg.reachable_ids()
127+
reachable = cfg.reachable_ids()
128128
unreachable_lines = tuple(
129129
sorted(
130130
{
131131
stmt.start_line
132132
for block in cfg.blocks
133-
if block.id not in unreachable
133+
if block.id not in reachable
134134
for stmt in block.statements
135135
}
136136
)

tests/unit/health/test_dataflow_cfg.py

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,149 @@ def f():
297297
assert unreachable[0].id not in cfg.reachable_ids()
298298

299299

300+
# -- comments are not statements --------------------------------------------------
301+
# Tree-sitter emits a trailing comment as a named sibling *after* its statement,
302+
# so a comment following a terminator used to spawn a bogus ``unreachable`` block
303+
# at the terminator's own line. These mirror the shapes found in the wild.
304+
305+
306+
def _assert_all_reachable(cfg) -> None:
307+
reachable = cfg.reachable_ids()
308+
assert [b.id for b in cfg.blocks if b.id not in reachable] == []
309+
assert _by_kind(cfg, "unreachable") == []
310+
311+
312+
def test_guarded_bare_return_with_trailing_comment():
313+
cfg = _cfg(
314+
"""
315+
def f(token):
316+
if not token:
317+
return # no token configured, skip verification
318+
check(token)
319+
"""
320+
)
321+
_assert_all_reachable(cfg)
322+
323+
324+
def test_trailing_return_after_if_chain_with_comment():
325+
cfg = _cfg(
326+
"""
327+
def f(kind, is_test):
328+
if not kind:
329+
return True
330+
if kind == "test":
331+
return is_test
332+
return False # config / doc: symbols do not qualify
333+
"""
334+
)
335+
_assert_all_reachable(cfg)
336+
337+
338+
def test_post_guard_body_stays_reachable():
339+
cfg = _cfg(
340+
"""
341+
def f(ids, titles):
342+
if not ids:
343+
return None # no siblings to compare
344+
shared = titles & ids
345+
return len(shared) / len(ids)
346+
"""
347+
)
348+
_assert_all_reachable(cfg)
349+
350+
351+
def test_if_guarded_return_with_comment_mid_function():
352+
cfg = _cfg(
353+
"""
354+
def f(include, targets):
355+
if not targets:
356+
return None
357+
if include and "source" in include:
358+
return None # source mode provides its own truncation info
359+
return build(targets)
360+
"""
361+
)
362+
_assert_all_reachable(cfg)
363+
364+
365+
def test_standalone_comment_after_return_is_not_a_statement():
366+
cfg = _cfg(
367+
"""
368+
def f(x):
369+
if x:
370+
return 1
371+
# explains the early exit
372+
return 2
373+
"""
374+
)
375+
_assert_all_reachable(cfg)
376+
377+
378+
def test_ts_mid_return_chain_with_trailing_comment():
379+
src = textwrap.dedent(
380+
"""
381+
function f(scope, dead, hot) {
382+
if (dead && hot) return "unified";
383+
if (dead) return "dead";
384+
if (hot) return "hotfiles";
385+
return scope; // "architecture" | "full"
386+
}
387+
"""
388+
)
389+
result = build_cfgs_for_file("m.ts", "typescript", src.encode(), flagged_only=False)
390+
if result.stats.functions_seen == 0:
391+
pytest.skip("tree-sitter language pack missing for typescript")
392+
_assert_all_reachable(result.functions[0].cfg)
393+
394+
395+
def test_finally_reachable_when_body_and_handlers_return():
396+
# The finally body runs on every path out of the protected region, even
397+
# when the body and all handlers terminate; it must never be flagged.
398+
cfg = _cfg(
399+
"""
400+
def f(store):
401+
try:
402+
write(store)
403+
return True
404+
except Exception:
405+
return False
406+
finally:
407+
store.close()
408+
"""
409+
)
410+
_assert_all_reachable(cfg)
411+
412+
413+
def test_finally_reachable_after_always_returning_try_without_except():
414+
cfg = _cfg(
415+
"""
416+
def f(store):
417+
try:
418+
return read(store)
419+
finally:
420+
store.close()
421+
"""
422+
)
423+
_assert_all_reachable(cfg)
424+
425+
426+
def test_true_unreachable_after_comment_still_flagged():
427+
# A comment between the terminator and real dead code must not hide the
428+
# dead code: the statement itself stays flagged.
429+
cfg = _cfg(
430+
"""
431+
def f():
432+
return 1
433+
# a note about the exit
434+
dead = 2
435+
"""
436+
)
437+
unreachable = _by_kind(cfg, "unreachable")
438+
assert len(unreachable) == 1
439+
assert [s.kind for s in unreachable[0].statements] == ["expression_statement"]
440+
assert unreachable[0].statements[0].start_line == 5
441+
442+
300443
# -- determinism ----------------------------------------------------------------
301444

302445

0 commit comments

Comments
 (0)