fix(engine): CFG/SSA/taint/IPA soundness, precision & recall fixes

This commit is contained in:
elipeter 2026-06-11 16:46:01 -05:00
parent 59e4359257
commit 246f32a419
39 changed files with 4729 additions and 465 deletions

View file

@ -16,6 +16,28 @@ fn lang_has_exclusive_cases(lang: &str) -> bool {
matches!(lang, "rust" | "go")
}
/// True when *this specific switch* has guaranteed-exclusive (non-fall-through)
/// cases, so it is safe to reorder the `default` arm to the cascade tail.
///
/// Rust `match` and Go `switch` are always exclusive. Java mixes shapes: the
/// arrow form (`switch_rule` cases, `case x -> ...`) is exclusive, but the
/// classic colon form (`switch_block_statement_group`, `case x:` with implicit
/// fall-through) is NOT. C/C++/JS/TS/PHP classic switches fall through and are
/// never exclusive. Reordering `default` to the tail is only correct for the
/// exclusive shapes; doing it for fall-through switches connects the wrong
/// case bodies in the source-order fall-through chain (both missed and phantom
/// taint flows).
fn switch_is_exclusive(lang: &str, cases: &[(Node<'_>, bool)]) -> bool {
if lang_has_exclusive_cases(lang) {
return true;
}
if lang == "java" {
// Arrow-switch when every case is the arrow `switch_rule` shape.
return cases.iter().all(|(c, _)| c.kind() == "switch_rule");
}
false
}
/// Extract the scrutinee subtree from a switch-like AST node.
///
/// Returns the AST node referenced by the language's scrutinee field. Only
@ -591,12 +613,21 @@ pub(super) fn build_switch<'a>(
return exits;
}
// Whether this switch's cases are mutually exclusive (no fall-through).
// Only exclusive switches may have `default` reordered to the cascade tail.
let is_exclusive = switch_is_exclusive(lang, &cases);
// Reorder so the default arm (if any) sits at the tail of the cascade.
// Reordering case dispatch is semantically harmless (mutually exclusive
// pattern matches), and it keeps the chain a clean Branch(True→case,
// False→next). Fall-through chains are a separate Seq layer below.
// Reordering case dispatch is semantically harmless ONLY for mutually
// exclusive pattern matches (Rust match, Go switch, Java arrow-switch); it
// keeps the chain a clean Branch(True→case, False→next). For classic
// fall-through switches (C/C++/JS/TS/PHP, Java colon-switch) a mid-chain
// `default:` can fall into the following case and a preceding case can fall
// into it, so the source order MUST be preserved — reordering there breaks
// the fall-through Seq layer and produces both missed and phantom flows.
let default_pos = cases.iter().position(|(_, d)| *d);
if let Some(pos) = default_pos
if is_exclusive
&& let Some(pos) = default_pos
&& pos != cases.len() - 1
{
let default_pair = cases.remove(pos);
@ -648,22 +679,40 @@ pub(super) fn build_switch<'a>(
let mut fallthrough_exits: Vec<NodeIndex> = Vec::new();
let mut last_header_false: Option<NodeIndex> = None;
let mut chain_preds: Vec<NodeIndex> = preds.to_vec();
// First node of the `default` body for a fall-through switch where the
// default is NOT at the tail. The cumulative no-match path (the last
// non-default header's False edge) is wired into it after the loop, so the
// default stays in its source position for the fall-through Seq layer while
// still being reachable when no case matches.
let mut pending_default_no_match: Option<NodeIndex> = None;
for (idx, (case, is_default)) in cases.iter().copied().enumerate() {
let is_last = idx + 1 == cases.len();
// A `default` arm carries no discriminant test, so it never gets its
// own dispatch If. For exclusive switches it has been reordered to the
// tail (`is_last`); for fall-through switches it stays in source
// position (`!is_exclusive`) and is wired into the Seq fall-through
// chain instead of acting as a conditional branch.
let default_no_dispatch = is_default && (is_last || !is_exclusive);
// Default at the chain tail doesn't get its own dispatch If, the
// previous header's False edge already targets it directly.
let case_first_preds: Vec<NodeIndex> = if is_default && is_last {
// First node of the default body becomes the False target of the
// previous header. Build the case with the previous chain_preds
// (the last header's "fall-through" branch) plus any fallthrough
// from the preceding case.
let mut p = chain_preds.clone();
p.append(&mut fallthrough_exits);
// `last_header_false` will receive a False edge once we know the
// first node of this body.
last_header_false = chain_preds.first().copied();
let case_first_preds: Vec<NodeIndex> = if default_no_dispatch {
// Body entry = fall-through from the preceding case body.
let mut p = std::mem::take(&mut fallthrough_exits);
if is_last {
// Tail default: the previous header's False branch also lands
// here directly (legacy behavior preserved for exclusive
// switches and tail defaults).
p.extend(chain_preds.iter().copied());
last_header_false = chain_preds.first().copied();
}
// For a non-tail (fall-through) default the dispatch chain must
// continue PAST it, so `chain_preds` / `last_header_false` are left
// untouched and the next case's dispatch header still receives the
// previous header's False edge. The cumulative no-match entry is
// recorded below once the body's first node is known.
p
} else {
// Normal case: synthesize a per-case dispatch header. We tie it
@ -741,12 +790,21 @@ pub(super) fn build_switch<'a>(
// Wire the dispatch True edge from this header (or from the previous
// header for a tail-default) to the first node of the case body.
if body_first_idx.index() < g.node_count() {
let header_for_true = if is_default && is_last {
// The previous header's False already lands here via the
// EdgeKind::Seq inside `case_first_preds`; we additionally
// emit a False edge directly so SSA labels the branch.
if let Some(prev) = last_header_false {
g.add_edge(prev, body_first_idx, EdgeKind::False);
let header_for_true = if default_no_dispatch {
if is_last {
// Tail default: the previous header's False already lands
// here via the EdgeKind::Seq inside `case_first_preds`; we
// additionally emit a False edge directly so SSA labels the
// branch.
if let Some(prev) = last_header_false {
g.add_edge(prev, body_first_idx, EdgeKind::False);
}
} else {
// Non-tail fall-through default: defer wiring the no-match
// entry until the last non-default header's False edge is
// known (after the loop). The body's only in-edge for now
// is the source-order fall-through from the preceding case.
pending_default_no_match = Some(body_first_idx);
}
None
} else {
@ -762,11 +820,22 @@ pub(super) fn build_switch<'a>(
let _ = is_default;
}
// After the chain: the last non-default header (if no default arm) needs
// a False edge that escapes to the post-switch frontier.
// Resolve the cumulative no-match (the last non-default header's False
// edge):
// - If the `default` arm sits mid-chain (fall-through switch), the
// no-match path enters the default body — wire the deferred False edge
// into it. The default stayed in source position for the fall-through
// Seq layer, so this is the only edge making it reachable on no-match.
// - Otherwise, with no reachable default (no default arm, or it was the
// tail and already consumed the False edge), the no-match path escapes
// to the post-switch frontier.
let mut exits: Vec<NodeIndex> = switch_breaks;
exits.append(&mut fallthrough_exits);
if !has_default {
if let Some(default_first) = pending_default_no_match {
if let Some(prev) = last_header_false {
g.add_edge(prev, default_first, EdgeKind::False);
}
} else if !has_default {
if let Some(prev) = last_header_false {
exits.push(prev);
}

View file

@ -64,13 +64,26 @@ pub(super) fn get_boolean_operands<'a>(node: Node<'a>) -> Option<(Node<'a>, Node
None
}
/// Create a lightweight `StmtKind::If` node for a sub-condition in a boolean chain.
/// Create a `StmtKind::If` node for a sub-condition in a boolean chain.
///
/// When the operand contains a classifiable call (`if (flag && cp.execSync(x))`),
/// the node is built via [`push_node`] so the inner source/sink/sanitizer
/// classification — callee, labels, arg-uses, gated sinks — is preserved, then
/// the branch-condition metadata is overlaid on top. Without this, a sink or
/// source CALL inside a short-circuited operand was dropped entirely (the bare
/// node only carried `condition_vars`, no callee or labels), so the
/// `if (flag && sink(x))` form missed flows that the un-decomposed
/// `if (sink(x))` form catches via the mod.rs condition-call fallback. This
/// mirrors `lower_ternary_branch`, which already uses `push_node` for the same
/// reason. Non-call operands keep the original lightweight shape.
pub(super) fn push_condition_node<'a>(
g: &mut Cfg,
cond_ast: Node<'a>,
lang: &str,
code: &'a [u8],
enclosing_func: Option<&str>,
call_ordinal: &mut u32,
analysis_rules: Option<&LangAnalysisRules>,
) -> NodeIndex {
// Pass cond_ast as both args, sub-conditions are never `unless` nodes
let (inner, negated) = detect_negation(cond_ast, cond_ast, lang);
@ -94,6 +107,44 @@ pub(super) fn push_condition_node<'a>(
// because the per-disjunct cond nodes (built via
// `build_condition_chain`) didn't populate `taint.uses`.
let uses_for_taint: Vec<String> = vars.clone();
// Operand containing a call → route through `push_node` for full
// source/sink/sanitizer classification, then overlay branch metadata.
if has_call_descendant(inner, lang) {
let ord = *call_ordinal;
*call_ordinal += 1;
let node = push_node(
g,
StmtKind::If,
inner,
lang,
code,
enclosing_func,
ord,
analysis_rules,
);
// Overlay the branch-condition metadata that the chain wiring depends
// on. `push_node` set `defines`/`uses`/`labels`/`callee` from the call;
// keep those, but ensure the condition fields and the interned
// condition vars (for `apply_branch_predicates`) are present, and the
// span/enclosing_func point at the operand expression.
let info = &mut g[node];
info.kind = StmtKind::If;
info.ast.span = span;
info.ast.enclosing_func = enclosing_func.map(|s| s.to_string());
info.condition_text = text;
info.condition_vars = vars;
info.condition_negated = negated;
// Union the condition idents into `taint.uses` so branch-predicate
// lookup keeps working without clobbering the call's own arg uses.
for v in &uses_for_taint {
if !info.taint.uses.contains(v) {
info.taint.uses.push(v.clone());
}
}
return node;
}
g.add_node(NodeInfo {
kind: StmtKind::If,
ast: AstMeta {
@ -221,7 +272,15 @@ pub(super) fn build_ternary_diamond<'a>(
// 1. Condition header. `push_condition_node` sets span/text/vars/negated
// but leaves `is_eq_with_const` default; stamp it explicitly so the
// taint engine's equality-narrowing fires for `x === 'literal' ? …`.
let cond_if = push_condition_node(g, cond_ast, lang, code, enclosing_func);
let cond_if = push_condition_node(
g,
cond_ast,
lang,
code,
enclosing_func,
call_ordinal,
analysis_rules,
);
g[cond_if].is_eq_with_const = detect_eq_with_const(cond_ast, lang);
// Capture the pure int-arith + comparison tree so `fold_constant_branches`
// can prune a dead constant-condition arm of the ternary (e.g. Java
@ -488,6 +547,7 @@ pub(super) fn classify_ternary_lhs(
///
/// Returns `(true_exits, false_exits)`, the sets of nodes from which True/False
/// edges should connect to the then/else branches.
#[allow(clippy::too_many_arguments)]
pub(super) fn build_condition_chain<'a>(
cond_ast: Node<'a>,
preds: &[NodeIndex],
@ -496,6 +556,8 @@ pub(super) fn build_condition_chain<'a>(
lang: &str,
code: &'a [u8],
enclosing_func: Option<&str>,
call_ordinal: &mut u32,
analysis_rules: Option<&LangAnalysisRules>,
) -> (Vec<NodeIndex>, Vec<NodeIndex>) {
let inner = unwrap_parens(cond_ast);
@ -503,8 +565,17 @@ pub(super) fn build_condition_chain<'a>(
Some(BoolOp::And) => {
if let Some((left, right)) = get_boolean_operands(inner) {
// Left operand with current preds
let (left_true, left_false) =
build_condition_chain(left, preds, pred_edge, g, lang, code, enclosing_func);
let (left_true, left_false) = build_condition_chain(
left,
preds,
pred_edge,
g,
lang,
code,
enclosing_func,
call_ordinal,
analysis_rules,
);
// Right operand only evaluated when left is true
let (right_true, right_false) = build_condition_chain(
right,
@ -514,6 +585,8 @@ pub(super) fn build_condition_chain<'a>(
lang,
code,
enclosing_func,
call_ordinal,
analysis_rules,
);
// AND: true only when both true; false when either false
let mut false_exits = left_false;
@ -521,7 +594,15 @@ pub(super) fn build_condition_chain<'a>(
(right_true, false_exits)
} else {
// Safety fallback: treat as leaf
let node = push_condition_node(g, inner, lang, code, enclosing_func);
let node = push_condition_node(
g,
inner,
lang,
code,
enclosing_func,
call_ordinal,
analysis_rules,
);
connect_all(g, preds, node, pred_edge);
(vec![node], vec![node])
}
@ -529,8 +610,17 @@ pub(super) fn build_condition_chain<'a>(
Some(BoolOp::Or) => {
if let Some((left, right)) = get_boolean_operands(inner) {
// Left operand with current preds
let (left_true, left_false) =
build_condition_chain(left, preds, pred_edge, g, lang, code, enclosing_func);
let (left_true, left_false) = build_condition_chain(
left,
preds,
pred_edge,
g,
lang,
code,
enclosing_func,
call_ordinal,
analysis_rules,
);
// Right operand only evaluated when left is false
let (right_true, right_false) = build_condition_chain(
right,
@ -540,6 +630,8 @@ pub(super) fn build_condition_chain<'a>(
lang,
code,
enclosing_func,
call_ordinal,
analysis_rules,
);
// OR: true when either true; false only when both false
let mut true_exits = left_true;
@ -547,14 +639,30 @@ pub(super) fn build_condition_chain<'a>(
(true_exits, right_false)
} else {
// Safety fallback: treat as leaf
let node = push_condition_node(g, inner, lang, code, enclosing_func);
let node = push_condition_node(
g,
inner,
lang,
code,
enclosing_func,
call_ordinal,
analysis_rules,
);
connect_all(g, preds, node, pred_edge);
(vec![node], vec![node])
}
}
None => {
// Leaf: single condition node
let node = push_condition_node(g, inner, lang, code, enclosing_func);
let node = push_condition_node(
g,
inner,
lang,
code,
enclosing_func,
call_ordinal,
analysis_rules,
);
connect_all(g, preds, node, pred_edge);
(vec![node], vec![node])
}

View file

@ -3541,9 +3541,17 @@ pub(super) fn push_node<'a>(
None
};
// Extract condition metadata for If nodes.
// Extract condition metadata for If nodes. Python `elif_clause` and PHP
// `else_if_clause` are lowered as guard nodes by `build_alternative_chain`
// (the flat-sibling elif-chain handler) and carry their own `condition`
// field, so they must also receive condition-metadata extraction even
// though their grammar kind maps to `Kind::Block`. These two node kinds
// only ever reach `push_node` from that handler, so widening the guard
// here cannot affect ordinary block lowering.
let (condition_text, condition_vars, condition_negated, cond_arith) =
if matches!(lookup(lang, ast.kind()), Kind::If) {
if matches!(lookup(lang, ast.kind()), Kind::If)
|| matches!(ast.kind(), "elif_clause" | "else_if_clause")
{
extract_condition_raw(ast, lang, code)
} else {
(None, Vec::new(), false, None)
@ -5074,6 +5082,230 @@ fn apply_arg_source_bindings(
}
}
/// Lower a flat chain of `alternative` siblings (Python `elif_clause`s / PHP
/// `else_if_clause`s, optionally trailed by an `else_clause`) into a properly
/// nested else-if CFG.
///
/// tree-sitter exposes these clauses as repeated, FLAT `alternative` fields on
/// a single `if_statement` rather than nesting each `else if` inside the
/// previous `else` (as JS/TS/Rust/Go/Java/C do). The default single-`else`
/// lowering only consumed the first sibling, silently dropping every 2nd+ elif
/// and the trailing else — so any source/sink/sanitizer/guard living there was
/// invisible to the whole pipeline. This builder restores the missing CFG
/// nodes by chaining each elif as a guard whose False edge flows into the next
/// alternative.
///
/// `incoming_edge` is the edge label used to enter the chain (the parent `if`'s
/// false edge — `EdgeKind::False` normally, `EdgeKind::True` for Ruby
/// `unless`). Returns the union of all branch exits plus the final
/// fall-through (when the chain has no terminal `else`).
#[allow(clippy::too_many_arguments)]
pub(super) fn build_alternative_chain<'a>(
alternatives: &[Node<'a>],
preds: &[NodeIndex],
incoming_edge: EdgeKind,
g: &mut Cfg,
lang: &str,
code: &'a [u8],
summaries: &mut FuncSummaries,
file_path: &str,
enclosing_func: Option<&str>,
call_ordinal: &mut u32,
analysis_rules: Option<&LangAnalysisRules>,
break_targets: &mut Vec<NodeIndex>,
continue_targets: &mut Vec<NodeIndex>,
throw_targets: &mut Vec<NodeIndex>,
bodies: &mut Vec<BodyCfg>,
next_body_id: &mut u32,
current_body_id: BodyId,
) -> Vec<NodeIndex> {
// Predecessor frontier entering the current alternative, and the edge label
// to use when wiring into it. Updated as we descend the chain: each elif's
// False edge becomes the predecessor/edge for the next alternative.
let mut chain_preds: Vec<NodeIndex> = preds.to_vec();
let mut chain_edge = incoming_edge;
let mut exits: Vec<NodeIndex> = Vec::new();
for &alt in alternatives {
let is_elif = matches!(alt.kind(), "elif_clause" | "else_if_clause");
if is_elif {
// Guard node for the elif condition. `push_node` with `StmtKind::If`
// extracts condition metadata and runs label classification on the
// clause (its `condition` field), so a source/sink call inside an
// elif condition is no longer dropped.
let guard = push_node(
g,
StmtKind::If,
alt,
lang,
code,
enclosing_func,
0,
analysis_rules,
);
connect_all(g, &chain_preds, guard, chain_edge);
// True branch: the elif body (`consequence` for Python, `body` for
// PHP / colon-block forms).
let body = alt
.child_by_field_name("consequence")
.or_else(|| alt.child_by_field_name("body"));
if let Some(b) = body {
let body_first = NodeIndex::new(g.node_count());
let body_exits = build_sub(
b,
&[guard],
g,
lang,
code,
summaries,
file_path,
enclosing_func,
call_ordinal,
analysis_rules,
break_targets,
continue_targets,
throw_targets,
bodies,
next_body_id,
current_body_id,
);
if body_first.index() < g.node_count() {
connect_all(g, &[guard], body_first, EdgeKind::True);
exits.extend(body_exits);
} else if let Some(&first) = body_exits.first() {
connect_all(g, &[guard], first, EdgeKind::True);
exits.extend(body_exits);
} else {
// Empty body: the guard's True edge falls through.
exits.push(guard);
}
} else {
exits.push(guard);
}
// False branch descends to the next alternative.
chain_preds = vec![guard];
chain_edge = EdgeKind::False;
} else {
// Terminal `else_clause` (or any non-guard block): lower its body and
// end the chain. The else body field is `body` for both Python and
// PHP `else_clause`; fall back to the clause node itself.
let body = alt.child_by_field_name("body").unwrap_or(alt);
let body_first = NodeIndex::new(g.node_count());
let body_exits = build_sub(
body,
&chain_preds,
g,
lang,
code,
summaries,
file_path,
enclosing_func,
call_ordinal,
analysis_rules,
break_targets,
continue_targets,
throw_targets,
bodies,
next_body_id,
current_body_id,
);
if body_first.index() < g.node_count() {
connect_all(g, &chain_preds, body_first, chain_edge);
exits.extend(body_exits);
} else if let Some(&first) = body_exits.first() {
connect_all(g, &chain_preds, first, chain_edge);
exits.extend(body_exits);
} else {
exits.extend(chain_preds.iter().copied());
}
// An else clause terminates the chain; nothing follows it.
return exits;
}
}
// Chain ended with an elif (no terminal else): the last guard's False edge
// is the fall-through path out of the whole if/elif construct. Materialise
// it as a synthetic pass-through so the false edge has a concrete target,
// mirroring the no-else branch in the `Kind::If` arm.
let pass = g.add_node(NodeInfo {
kind: StmtKind::Seq,
ast: AstMeta {
span: chain_preds
.first()
.map(|&n| g[n].ast.span)
.unwrap_or((0, 0)),
enclosing_func: enclosing_func.map(|s| s.to_string()),
},
..Default::default()
});
connect_all(g, &chain_preds, pass, chain_edge);
exits.push(pass);
exits
}
/// Lower a C-style `for` loop's increment/update clause onto the back-edge
/// path. `back_sources` are the body/continue exits that would otherwise
/// back-edge straight to the loop header. When an `update_subtree` exists it
/// is lowered from those sources and its exits are returned, so callers
/// back-edge the update's exits to the header instead — making increment-clause
/// side effects (assignments, sanitizer calls) visible to taint analysis.
/// Without an update clause the input sources are returned unchanged, so the
/// CFG is bit-identical to the pre-fix behaviour.
#[allow(clippy::too_many_arguments)]
pub(super) fn lower_loop_update<'a>(
update_subtree: Option<Node<'a>>,
back_sources: &[NodeIndex],
g: &mut Cfg,
lang: &str,
code: &'a [u8],
summaries: &mut FuncSummaries,
file_path: &str,
enclosing_func: Option<&str>,
call_ordinal: &mut u32,
analysis_rules: Option<&LangAnalysisRules>,
break_targets: &mut Vec<NodeIndex>,
continue_targets: &mut Vec<NodeIndex>,
throw_targets: &mut Vec<NodeIndex>,
bodies: &mut Vec<BodyCfg>,
next_body_id: &mut u32,
current_body_id: BodyId,
) -> Vec<NodeIndex> {
let Some(update) = update_subtree else {
return back_sources.to_vec();
};
if back_sources.is_empty() {
return Vec::new();
}
let update_exits = build_sub(
update,
back_sources,
g,
lang,
code,
summaries,
file_path,
enclosing_func,
call_ordinal,
analysis_rules,
break_targets,
continue_targets,
throw_targets,
bodies,
next_body_id,
current_body_id,
);
// If the update produced no CFG nodes (e.g. it was pure trivia), preserve
// the original back-edge sources so the loop still closes.
if update_exits.is_empty() {
back_sources.to_vec()
} else {
update_exits
}
}
// The recursive *workhorse* that converts an AST node into a CFG slice.
// Returns the set of *exit* nodes that need to be wired further.
#[allow(clippy::too_many_arguments)]
@ -5183,6 +5415,8 @@ pub(super) fn build_sub<'a>(
lang,
code,
enclosing_func,
call_ordinal,
analysis_rules,
)
} else {
// Single-node path (original behavior)
@ -5214,14 +5448,27 @@ pub(super) fn build_sub<'a>(
// Locate then & else blocks using field-based lookup first,
// then positional fallback (Rust uses positional blocks).
let (then_block, else_block) = {
//
// `alternatives` collects *every* `alternative` field, not just the
// first. In tree-sitter-python and tree-sitter-php an
// `if/elif/.../else` (Python) or `if/elseif/.../else` (PHP) chain
// produces several FLAT sibling `alternative` fields on one
// `if_statement` (a list of `elif_clause`/`else_if_clause` nodes
// optionally trailed by an `else_clause`). JS/TS/Rust/Go/Java/C
// nest their `else if`, so they expose at most one `alternative`
// (the nested if) and the list has length ≤ 1.
let (then_block, else_block, alternatives) = {
let field_then = ast
.child_by_field_name("consequence")
.or_else(|| ast.child_by_field_name("body"));
let field_else = ast.child_by_field_name("alternative");
let mut alt_cursor = ast.walk();
let alternatives: Vec<Node> = ast
.children_by_field_name("alternative", &mut alt_cursor)
.collect();
let field_else = alternatives.first().copied();
if field_then.is_some() || field_else.is_some() {
(field_then, field_else)
(field_then, field_else, alternatives)
} else {
// Fallback: positional block children (Rust `if_expression`)
let mut cursor = ast.walk();
@ -5229,10 +5476,22 @@ pub(super) fn build_sub<'a>(
.children(&mut cursor)
.filter(|n| lookup(lang, n.kind()) == Kind::Block)
.collect();
(blocks.first().copied(), blocks.get(1).copied())
(blocks.first().copied(), blocks.get(1).copied(), Vec::new())
}
};
// A flat elif/elseif chain has 2+ `alternative` siblings, or a
// single `alternative` that is itself an elif/else-if clause (the
// `if a: .. elif b: ..` form with no trailing `else`, where the lone
// alternative is an `elif_clause` rather than a nested if). In both
// cases the default single-`else_block` lowering below would either
// drop later siblings entirely or fail to treat the elif condition
// as a branch guard, so route through the dedicated chain builder.
let is_flat_elif_chain = alternatives.len() > 1
|| alternatives
.first()
.is_some_and(|a| matches!(a.kind(), "elif_clause" | "else_if_clause"));
// THEN branch
let then_first_node = NodeIndex::new(g.node_count());
let then_exits = if let Some(b) = then_block {
@ -5267,7 +5526,30 @@ pub(super) fn build_sub<'a>(
// ELSE branch
let else_first_node = NodeIndex::new(g.node_count());
let else_exits = if let Some(b) = else_block {
let else_exits = if is_flat_elif_chain {
// Flat elif/elseif chain: lower every alternative sibling as a
// proper nested else-if so 2nd+ elif and trailing else clauses
// are no longer dropped from the CFG.
build_alternative_chain(
&alternatives,
else_preds,
else_edge,
g,
lang,
code,
summaries,
file_path,
enclosing_func,
call_ordinal,
analysis_rules,
break_targets,
continue_targets,
throw_targets,
bodies,
next_body_id,
current_body_id,
)
} else if let Some(b) = else_block {
let exits = build_sub(
b,
else_preds,
@ -5380,6 +5662,61 @@ pub(super) fn build_sub<'a>(
// WHILE / FOR: classic loop with a back edge.
Kind::While | Kind::For => {
// C-style `for (init; cond; incr) body` loops (C/C++, JS/TS, Go
// three-clause, PHP) attach `initializer`/`update` (or `increment`)
// subtrees that the previous loop lowering ignored entirely — so a
// taint source bound in the init (`for (cmd = getenv("X"); …)`) or a
// side effect in the increment had no CFG node at all and was
// invisible to taint analysis. Tree-sitter exposes these either as
// direct fields on the loop node (C/C++/JS/TS/PHP) or nested under a
// `for_clause` child (Go's three-clause form). The init runs once
// before the header; its exits become the header's predecessors so
// its defs flow into both the condition and the body.
let clause_owner = ast
.child_by_field_name("body")
.is_none()
.then(|| {
let mut c = ast.walk();
ast.children(&mut c).find(|n| n.kind() == "for_clause")
})
.flatten();
let init_subtree = ast
.child_by_field_name("initializer")
.or_else(|| ast.child_by_field_name("initialize"))
.or_else(|| clause_owner.and_then(|fc| fc.child_by_field_name("initializer")));
let update_subtree = ast
.child_by_field_name("update")
.or_else(|| ast.child_by_field_name("increment"))
.or_else(|| clause_owner.and_then(|fc| fc.child_by_field_name("update")));
// Lower the initializer (if any) from `preds`; its exits become the
// header's predecessors. Empty / absent inits leave `preds`
// bit-identical to the pre-fix behaviour.
let init_exits_owned = init_subtree.map(|init| {
build_sub(
init,
preds,
g,
lang,
code,
summaries,
file_path,
enclosing_func,
call_ordinal,
analysis_rules,
break_targets,
continue_targets,
throw_targets,
bodies,
next_body_id,
current_body_id,
)
});
let header_preds: &[NodeIndex] = match &init_exits_owned {
Some(exits) if !exits.is_empty() => exits.as_slice(),
_ => preds,
};
let header = push_node(
g,
StmtKind::Loop,
@ -5390,7 +5727,7 @@ pub(super) fn build_sub<'a>(
0,
analysis_rules,
);
connect_all(g, preds, header, EdgeKind::Seq);
connect_all(g, header_preds, header, EdgeKind::Seq);
// Check for short-circuit condition
let cond_subtree = ast.child_by_field_name("condition");
@ -5445,6 +5782,8 @@ pub(super) fn build_sub<'a>(
lang,
code,
enclosing_func,
call_ordinal,
analysis_rules,
);
// Wire body from true_exits
@ -5472,13 +5811,33 @@ pub(super) fn build_sub<'a>(
connect_all(g, &true_exits, body_first, EdgeKind::True);
}
// The increment runs at the end of each iteration before the
// condition is re-checked, so it sits on the back-edge path
// between the body/continue exits and the header.
let mut back_sources: Vec<NodeIndex> = body_exits;
back_sources.extend(loop_continues.iter().copied());
let back_sources = lower_loop_update(
update_subtree,
&back_sources,
g,
lang,
code,
summaries,
file_path,
enclosing_func,
call_ordinal,
analysis_rules,
break_targets,
continue_targets,
throw_targets,
bodies,
next_body_id,
current_body_id,
);
// Back-edges go to header (not into the condition chain)
for &e in &body_exits {
for &e in &back_sources {
connect_all(g, &[e], header, EdgeKind::Back);
}
for &c in &loop_continues {
connect_all(g, &[c], header, EdgeKind::Back);
}
// Loop exits = false_exits + breaks
let mut exits: Vec<NodeIndex> = false_exits;
@ -5504,14 +5863,32 @@ pub(super) fn build_sub<'a>(
current_body_id,
);
// The increment runs on the back-edge path (end of each
// iteration, before the next condition check).
let mut back_sources: Vec<NodeIndex> = body_exits;
back_sources.extend(loop_continues.iter().copied());
let back_sources = lower_loop_update(
update_subtree,
&back_sources,
g,
lang,
code,
summaries,
file_path,
enclosing_func,
call_ordinal,
analysis_rules,
break_targets,
continue_targets,
throw_targets,
bodies,
next_body_id,
current_body_id,
);
// Backedge for every linear exit → header.
for &e in &body_exits {
for &e in &back_sources {
connect_all(g, &[e], header, EdgeKind::Back);
}
// Wire continue targets as back edges to header
for &c in &loop_continues {
connect_all(g, &[c], header, EdgeKind::Back);
}
// Falling out of the loop = headers false branch +
// any break targets that exit the loop.
let mut exits = vec![header];