fix(ingestion): expand ??/?:/ternary branches through a provider hook (#3354)

Review follow-up on #3373. The shared value-alternatives rule keys on
tree-sitter field names, and several grammars spell the same construct
differently, so the expansion never fired for them:
- Kotlin `elvis_expression` has no fields.
- Swift uses `value`/`if_nil` and `if_true`/`if_false`.
- Dart uses `first`/`second`, and its conditional has no `condition` field.
- Python `a if c else b` has no fields.
The `'?:'` operator entry was dead, since no bundled grammar emits it.

Add an optional `valueAlternatives` hook to CallableFlowCaptureOptions,
consulted before the shared rule, and implement it in the Kotlin, Swift,
Dart, Python and Ruby providers. Shared code still names no language.

Ruby's statement-bodied `if`/`unless`/`elsif` also carries
`condition`/`consequence`/`alternative`, so the shared ternary rule dug
an identifier out of an arbitrary statement (`g = h; 0` flowed `h`) and
produced a wrong CALLS edge. The Ruby hook now keeps a multi-statement
branch as one opaque source, as before #3373.

Tests: new provider fixtures for Python, Kotlin, Swift, Dart and Ruby
(including a Ruby negative), and TS chain, parenthesized, callable-left
and `&&` negative cases. Captures goldens gain one entry each for the
new fixtures. SCHEMA_BUMP stays 112 (same unreleased PR).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergo Magyar 2026-09-24 19:01:40 +00:00
parent 765d84d18d
commit a4fc15eb9a
19 changed files with 421 additions and 6 deletions

View file

@ -119,6 +119,23 @@ const DART_CALLABLE_CAPTURE_OPTIONS = {
extractCallCallee: (node: SyntaxNode) => dartCallableCallee(node) ?? undefined,
callSiteNode: (node: SyntaxNode) => dartCallableCallee(node) ?? undefined,
callableProtocolMethods: new Set(['call']),
// tree-sitter-dart spells `a ?? b` as `if_null_expression(first:, second:)`
// and `c ? a : b` as a `conditional_expression` with `consequence` /
// `alternative` but NO `condition` field, so neither matches the shared
// field-based branch rule and only the last operand flowed (#3354).
valueAlternatives: (node: SyntaxNode) => {
if (node.type === 'if_null_expression') {
const first = node.childForFieldName('first');
const second = node.childForFieldName('second');
return first !== null && second !== null ? [first, second] : undefined;
}
if (node.type === 'conditional_expression') {
const consequence = node.childForFieldName('consequence');
const alternative = node.childForFieldName('alternative');
return consequence !== null && alternative !== null ? [consequence, alternative] : undefined;
}
return undefined;
},
} as const;
function dartLexicalFunctionOwner(input: SyntaxNode): SyntaxNode | undefined {

View file

@ -89,8 +89,42 @@ const KOTLIN_CALLABLE_CAPTURE_OPTIONS = {
return destination === undefined || source === undefined ? undefined : { destination, source };
},
normalizeQualifiedName: (raw: string) => raw.replaceAll('::', '.'),
valueAlternatives: (node: SyntaxNode) => kotlinValueAlternatives(node),
} as const;
const isKotlinComment = (node: SyntaxNode): boolean =>
node.type === 'line_comment' || node.type === 'multiline_comment';
/**
* Branches of a Kotlin value-selecting expression (#3354). `a ?: b` is a
* FIELDLESS `elvis_expression` (positional operands), so the shared
* field-based rule never saw it and only the last operand flowed. An
* `if_expression` fields its branches as `control_structure_body` wrappers:
* a wrapper holding one expression is that expression's value, anything
* else (a multi-statement block) keeps the whole `if` one opaque source.
*/
function kotlinValueAlternatives(node: SyntaxNode): readonly SyntaxNode[] | undefined {
if (node.type === 'elvis_expression') {
const operands = node.namedChildren.filter(
(child): child is SyntaxNode => child !== null && !isKotlinComment(child),
);
return operands.length === 2 ? operands : undefined;
}
if (node.type !== 'if_expression') return undefined;
const branches: SyntaxNode[] = [];
for (const field of ['consequence', 'alternative'] as const) {
const body = node.childForFieldName(field);
if (body === null) return [node];
const values = body.namedChildren.filter(
(child): child is SyntaxNode => child !== null && !isKotlinComment(child),
);
const [value] = values;
if (values.length !== 1 || value === undefined) return [node];
branches.push(value);
}
return branches;
}
export function emitKotlinScopeCaptures(
sourceText: string,
filePath: string,

View file

@ -53,6 +53,20 @@ const PYTHON_CALLABLE_CAPTURE_OPTIONS = {
assignmentNodeTypes: new Set(['assignment', 'named_expression']),
identifierNodeTypes: new Set(['identifier']),
functionScopedValueBindings: true,
// `a if c else b` is a FIELDLESS `conditional_expression` (positional
// value, condition, value), so the shared condition/consequence/alternative
// rule never sees its branches and only the last operand flowed (#3354).
// `a or b` is a fielded `boolean_operator` the shared rule already handles.
valueAlternatives: (node: SyntaxNode) => {
if (node.type !== 'conditional_expression') return undefined;
const named = node.namedChildren.filter(
(child): child is SyntaxNode => child !== null && child.type !== 'comment',
);
const [value, , alternative] = named;
return named.length === 3 && value !== undefined && alternative !== undefined
? [value, alternative]
: undefined;
},
} as const;
export function emitPythonScopeCaptures(

View file

@ -51,6 +51,7 @@ const RUBY_CALLABLE_CAPTURE_OPTIONS = {
// Ruby (`action = process` stores process's RETURN value) — only explicit
// reference forms (method(:x), &:x, lambda/proc) reference the callable.
bareNamesAreCalls: true,
valueAlternatives: (node: SyntaxNode) => rubyValueAlternatives(node),
extractCallableReference: (node: SyntaxNode) => {
if (node.type !== 'call') return undefined;
const method = node.childForFieldName('method');
@ -65,6 +66,36 @@ const RUBY_CALLABLE_CAPTURE_OPTIONS = {
},
} as const;
/**
* Branches of a statement-bodied `if` / `unless` / `elsif` (#3354). They field
* `condition` / `consequence` / `alternative` like a ternary, but their
* branches are `then` / `else` STATEMENT LISTS, so the shared ternary rule
* would dig an identifier out of whichever statement it found (`g = h; 0`
* flowed `h`, although the branch evaluates to `0`). A branch holding one
* statement is that statement's value; anything longer keeps the whole
* conditional one opaque source. The `c ? a : b` ternary (`conditional`) is
* left to the shared rule.
*/
function rubyValueAlternatives(node: SyntaxNode): readonly SyntaxNode[] | undefined {
if (node.type !== 'if' && node.type !== 'unless' && node.type !== 'elsif') return undefined;
const branches: SyntaxNode[] = [];
for (const field of ['consequence', 'alternative'] as const) {
const branch = node.childForFieldName(field);
if (branch === null) continue;
if (branch.type === 'elsif') {
branches.push(branch);
continue;
}
const statements = branch.namedChildren.filter(
(child): child is SyntaxNode => child !== null && child.type !== 'comment',
);
const [statement] = statements;
if (statements.length !== 1 || statement === undefined) return [node];
branches.push(statement);
}
return branches.length > 0 ? branches : [node];
}
/**
* Build the full `.`-joined qualified owner name for a heritage/attr call by
* walking ALL enclosing class/module ancestors (not just the immediate one),

View file

@ -109,6 +109,18 @@ const SWIFT_CALLABLE_CAPTURE_OPTIONS = {
node.namedChildren.filter(
(child): child is SyntaxNode => child !== null && child.type === 'parameter',
),
// tree-sitter-swift fields `a ?? b` as `value` / `if_nil` and `c ? a : b` as
// `if_true` / `if_false`, neither of which the shared field-based branch
// rule knows, so only the last operand flowed (#3354).
valueAlternatives: (node: SyntaxNode) => {
const [first, second] =
node.type === 'nil_coalescing_expression'
? [node.childForFieldName('value'), node.childForFieldName('if_nil')]
: node.type === 'ternary_expression'
? [node.childForFieldName('if_true'), node.childForFieldName('if_false')]
: [null, null];
return first !== null && second !== null ? [first, second] : undefined;
},
} as const;
/** tree-sitter-swift node types that carry arity. */

View file

@ -47,6 +47,12 @@
* `extractAssignment` (Kotlin's `assignment`, Dart's
* `initialized_identifier`). Returning `undefined` falls back to the shared
* path, so one callback can handle the odd node and leave the rest alone.
* - A **fieldless or oddly-fielded** `??`/`?:`/elvis/ternary node is invisible
* to the shared branch expansion, so only one operand (if any) flows.
* Supply `valueAlternatives` (Kotlin's `elvis_expression`, Swift's
* `nil_coalescing_expression`, Dart's `if_null_expression`, Python's
* `conditional_expression`); return `[node]` to keep a statement-bodied
* `if` opaque (Ruby).
* - A binding needs a `SymbolDefinition` for the pass to attach to. Captures
* alone are not enough: without a `@declaration.*` for the bound name, the
* seed has no cell to key on.
@ -150,6 +156,20 @@ export interface CallableFlowCaptureOptions {
readonly qualifiedName?: string;
}
| undefined;
/**
* Provider-owned branches of a value-selecting expression (#3354). The
* shared rule only knows the field shapes `left`/`operator`/`right` (with a
* `??`/`||`/`or` operator) and `condition`/`consequence`/`alternative`; a
* grammar that spells the same construct differently (fieldless children,
* `value`/`if_nil`, `first`/`second`, a ternary without a `condition`
* field) supplies the branches here. Each returned branch is expanded again,
* so chains work. Returning `[node]` means "recognized, but opaque": the
* whole expression stays one source, which is how a provider keeps the
* shared ternary rule off a statement-bodied `if` whose branches are
* statement lists, not values. `undefined` falls back to the shared rule
* (mirrors `extractAssignment`).
*/
readonly valueAlternatives?: (node: SyntaxNode) => readonly SyntaxNode[] | undefined;
}
interface OperandSyntax {
@ -216,7 +236,7 @@ export function synthesizeCallableFlowCaptures(
const out: CaptureMatch[] = [];
for (const assignment of assignments) {
for (const source of valueAlternatives(assignment.source)) {
for (const source of valueAlternatives(assignment.source, options)) {
emitAssignmentFact(
{ ...assignment, source },
knownCallableNames,
@ -596,29 +616,36 @@ function assignmentParts(
}
/** Operators whose result is one of their operands, not a computed value. */
const VALUE_SELECTING_OPERATORS = new Set(['??', '||', 'or', '?:']);
const VALUE_SELECTING_OPERATORS = new Set(['??', '||', 'or']);
/**
* The operands a value-selecting expression can evaluate to (#3354):
* `a ?? b`, `a || b`, `a or b`, and `c ? a : b` each yield one of their
* branches, so each branch flows into the destination. Anything else is its
* own single alternative, which leaves every other source shape untouched.
* `options.valueAlternatives` is consulted first for grammars whose shape the
* field-based rule below cannot see.
*/
function valueAlternatives(node: SyntaxNode): SyntaxNode[] {
function valueAlternatives(node: SyntaxNode, options: CallableFlowCaptureOptions): SyntaxNode[] {
let inner = node;
while (inner.type.includes('parenthesized') && inner.namedChildCount === 1) {
inner = inner.namedChild(0)!;
}
const provided = options.valueAlternatives?.(inner);
if (provided !== undefined) {
if (provided.length === 1 && provided[0]?.id === inner.id) return [node];
return provided.flatMap((branch) => valueAlternatives(branch, options));
}
const consequence = inner.childForFieldName('consequence');
const alternative = inner.childForFieldName('alternative');
if (consequence !== null && alternative !== null && inner.childForFieldName('condition')) {
return [...valueAlternatives(consequence), ...valueAlternatives(alternative)];
return [...valueAlternatives(consequence, options), ...valueAlternatives(alternative, options)];
}
const left = inner.childForFieldName('left');
const right = inner.childForFieldName('right');
const operator = inner.childForFieldName('operator')?.type;
if (left !== null && right !== null && operator && VALUE_SELECTING_OPERATORS.has(operator)) {
return [...valueAlternatives(left), ...valueAlternatives(right)];
return [...valueAlternatives(left, options), ...valueAlternatives(right, options)];
}
return [node];
}

View file

@ -782,6 +782,9 @@ import { copyV8CacheIfPresent, tryLoadV8Cache, writeV8CacheFile } from './v8-sid
// `a || f`, and `c ? f : g`. Warm caches replay the pre-fix flow facts, which
// have no flow for those assignments, so both stores re-extract. 105-111 are
// claimed by open PR #3326 (Elixir).
// Same v112: the `valueAlternatives` provider hook extends it to Kotlin
// `?:`/`if`, Swift/Dart `??`/`?:`, and Python `x if c else y`, and keeps a
// Ruby multi-statement `if` one opaque source.
const SCHEMA_BUMP = 112;
const GITNEXUS_PKG_VERSION = (() => {
try {

View file

@ -0,0 +1,13 @@
void runSweep() {}
void runThen() {}
void runElse() {}
void ifNull(void Function()? override) {
final run = override ?? runSweep;
run();
}
void conditional(bool fast) {
final run = fast ? runThen : runElse;
run();
}

View file

@ -0,0 +1,13 @@
fun runSweep() {}
fun runThen() {}
fun runElse() {}
fun elvis(override: (() -> Unit)?) {
val run = override ?: ::runSweep
run()
}
fun ifExpression(fast: Boolean) {
val run = if (fast) ::runThen else ::runElse
run()
}

View file

@ -0,0 +1,20 @@
def run_sweep():
pass
def run_then():
pass
def run_else():
pass
def logical_or(override):
run = override or run_sweep
run()
def ternary(fast):
run = run_then if fast else run_else
run()

View file

@ -0,0 +1,27 @@
def run_other; end
def run_sweep; end
def run_then; end
def run_else; end
# Each branch holds one statement, so each branch is the value.
def single_statement_if(fast)
run = if fast
method(:run_then)
else
method(:run_else)
end
run.call
end
# The `then` branch evaluates to 0; `h` is only read by an inner statement,
# so it must not flow into `run`.
def statement_if(fast)
h = method(:run_other)
run = if fast
g = h
0
else
method(:run_sweep)
end
run.call
end

View file

@ -0,0 +1,13 @@
func runSweep() {}
func runThen() {}
func runElse() {}
func nilCoalescing(override: (() -> Void)?) {
let run = override ?? runSweep
run()
}
func ternary(fast: Bool) {
let run = fast ? runThen : runElse
run()
}

View file

@ -1,4 +1,15 @@
import { runSweep, runAlias, runOr, runThen, runElse } from './sweep';
import {
runSweep,
runAlias,
runOr,
runThen,
runElse,
runChained,
runParen,
runLeft,
runAndLeft,
runAndRight,
} from './sweep';
type Handler = (env: unknown) => Promise<void>;
@ -21,3 +32,25 @@ export async function ternary(env: unknown, fast: boolean) {
const run = fast ? runThen : runElse;
await run(env);
}
export async function chained(env: { a?: Handler; b?: Handler }) {
const run = env.a ?? env.b ?? runChained;
await run(env);
}
export async function parenthesized(env: { a?: Handler }) {
const run = (env.a ?? runParen);
await run(env);
}
export async function callableLeft(env: { fallback: Handler }) {
const run = runLeft ?? env.fallback;
await run(env);
}
// `&&` is not expanded into branches: a callable left operand is truthy, so
// it is never the value. The assignment keeps its pre-#3354 single source.
export async function logicalAnd(env: unknown) {
const run = runAndLeft && runAndRight;
await run(env);
}

View file

@ -3,3 +3,8 @@ export async function runAlias(env: unknown): Promise<void> {}
export async function runOr(env: unknown): Promise<void> {}
export async function runThen(env: unknown): Promise<void> {}
export async function runElse(env: unknown): Promise<void> {}
export async function runChained(env: unknown): Promise<void> {}
export async function runParen(env: unknown): Promise<void> {}
export async function runLeft(env: unknown): Promise<void> {}
export async function runAndLeft(env: unknown): Promise<void> {}
export async function runAndRight(env: unknown): Promise<void> {}

View file

@ -95,6 +95,10 @@
"captureGroups": 9,
"digest": "9c7f1c54a9b0ba3bfa4410e1843bf5edb570777789ee659a00d7dd6771f3670f"
},
"python-callable-alternatives/app.py": {
"captureGroups": 23,
"digest": "5209559e5d1a906536fa4a61b8fc130220e8fea0328b7877d93641d381724e0e"
},
"python-calls/one.py": {
"captureGroups": 4,
"digest": "b99c3406b4b8555cc521b27bab98eefed02a121dc2e878dd4f63133088603dc5"

View file

@ -39,6 +39,10 @@
"captureGroups": 18,
"digest": "194c1ca21a7d5d8d85cf9aecfdc4edd881c5d3447de1ad574b56cff5a411bab1"
},
"ruby-callable-alternatives/app.rb": {
"captureGroups": 33,
"digest": "d3fe15a75710d65ed0d5116d9fcc7b6891e460ff037a478472fda999161aed49"
},
"ruby-calls/lib/one_arg.rb": {
"captureGroups": 8,
"digest": "61300083e7a2e6d9a116c3975ddd3b78a593a9a0e397c252bedb160d481c20b7"

View file

@ -23,6 +23,10 @@
"captureGroups": 15,
"digest": "f192ae885ea3de848f3b93acf7672d91b0118800cb0c6513628a6c09c26fadbf"
},
"swift-callable-alternatives/App.swift": {
"captureGroups": 25,
"digest": "0d76e7d11637a41550666de93a390861d15a0a26edf9c49af19fbd625a3ba1f3"
},
"swift-child-extends-parent/Sources/App.swift": {
"captureGroups": 11,
"digest": "4841a7a147f5cac64ed70af845534845bf34701af4bc55a6a9f02fb09ad4b716"

View file

@ -0,0 +1,116 @@
/**
* Callable chosen by a value-selecting expression, per provider (#3354).
*
* The shared branch expansion in `callable-flow-captures.ts` keys on tree-sitter
* field names (`left`/`operator`/`right`, `condition`/`consequence`/
* `alternative`). Grammars that spell `??` / `?:` / elvis / ternary without
* those fields supply their branches through the `valueAlternatives` provider
* hook; without it only the LAST operand flowed and `impact` under-reported
* callers while still claiming `epistemic: "exact"`. Ruby's statement-bodied
* `if` shares the ternary's field names but its branches are statement lists,
* so its hook only expands single-statement branches.
*/
import { describe, it, expect, beforeAll } from 'vitest';
import path from 'path';
import {
FIXTURES,
getRelationships,
edgeSet,
runPipelineFromRepo,
type PipelineResult,
} from './helpers.js';
const runFixture = (name: string): Promise<PipelineResult> =>
runPipelineFromRepo(path.join(FIXTURES, name), () => {});
// Only flow edges count: Kotlin's `::fn` reference alone already yields a
// `local-call` edge to fn, which would make its assertions vacuous.
const callsOf = (result: PipelineResult): string[] =>
edgeSet(
getRelationships(result, 'CALLS').filter((edge) => edge.rel.reason === 'callable-value-flow'),
);
describe('Python callable chosen by `or` / `x if c else y`', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runFixture('python-callable-alternatives');
}, 60000);
it('`override or fn` reaches fn', () => {
expect(callsOf(result)).toContain('logical_or → run_sweep');
});
it('`f if c else g` reaches both branches', () => {
expect(callsOf(result)).toEqual(
expect.arrayContaining(['ternary → run_then', 'ternary → run_else']),
);
});
});
describe('Kotlin callable chosen by `?:` / `if` expression', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runFixture('kotlin-callable-alternatives');
}, 60000);
it('`override ?: ::fn` reaches fn', () => {
expect(callsOf(result)).toContain('elvis → runSweep');
});
it('`if (c) ::f else ::g` reaches both branches', () => {
expect(callsOf(result)).toEqual(
expect.arrayContaining(['ifExpression → runThen', 'ifExpression → runElse']),
);
});
});
describe('Swift callable chosen by `??` / `?:`', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runFixture('swift-callable-alternatives');
}, 60000);
it('`override ?? fn` reaches fn', () => {
expect(callsOf(result)).toContain('nilCoalescing → runSweep');
});
it('`c ? f : g` reaches both branches', () => {
expect(callsOf(result)).toEqual(
expect.arrayContaining(['ternary → runThen', 'ternary → runElse']),
);
});
});
describe('Dart callable chosen by `??` / `?:`', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runFixture('dart-callable-alternatives');
}, 60000);
it('`override ?? fn` reaches fn', () => {
expect(callsOf(result)).toContain('ifNull → runSweep');
});
it('`c ? f : g` reaches both branches', () => {
expect(callsOf(result)).toEqual(
expect.arrayContaining(['conditional → runThen', 'conditional → runElse']),
);
});
});
describe('Ruby statement-bodied `if` as a callable source', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runFixture('ruby-callable-alternatives');
}, 60000);
it('single-statement branches each reach their callable', () => {
expect(callsOf(result)).toEqual(
expect.arrayContaining(['single_statement_if → run_then', 'single_statement_if → run_else']),
);
});
it('an identifier read inside a multi-statement branch does not flow into the binding', () => {
expect(callsOf(result)).not.toContain('statement_if → run_other');
});
});

View file

@ -19,6 +19,14 @@ import {
type PipelineResult,
} from './helpers.js';
/**
* `&&` is not value-selecting for a callable, so #3354 leaves it on the single-
* source path: whatever the pre-#3354 capture (one seed for the last operand,
* qualified by the whole expression, byte-identical before and after this
* change) resolved to must stay exactly that: no edge at all.
*/
const PRE_3354_LOGICAL_AND_EDGES: string[] = [];
describe('TypeScript callable chosen by ?? / || / ?:', () => {
let result: PipelineResult;
@ -50,4 +58,21 @@ describe('TypeScript callable chosen by ?? / || / ?:', () => {
it('`c ? f : g` reaches both branches', () => {
expect(calls()).toEqual(expect.arrayContaining(['ternary → runThen', 'ternary → runElse']));
});
it('a chain `a ?? b ?? fn` reaches fn', () => {
expect(calls()).toContain('chained → runChained');
});
it('a parenthesized `(a ?? fn)` reaches fn', () => {
expect(calls()).toContain('parenthesized → runParen');
});
it('a callable LEFT operand `fn ?? fallback` reaches fn', () => {
expect(calls()).toContain('callableLeft → runLeft');
});
it('`&&` is not expanded: its left operand gains no edge', () => {
const fromLogicalAnd = calls().filter((edge) => edge.startsWith('logicalAnd → '));
expect(fromLogicalAnd).toEqual(PRE_3354_LOGICAL_AND_EDGES);
});
});