fix(ingestion): flow the right operand of && / and into callable bindings (#3354)

`a && b` / `a and b` yields `a` when it is falsy and `b` otherwise. A
falsy value is never a callable, so the right operand is the only one
that can be invoked later. The capture left `&&` unexpanded, and the
compound source became a qualified seed that resolves to nothing:
`const run = x && f; run()` gained no edge to `f` while impact claimed
`exact`. Python `x and f or g` reached only `g`.

The shared expansion now maps `&&` / `and` to the right branch only.
It recurses, so `x and f or g` reaches both `f` and `g`. Where `&&`
yields a boolean (Java, C#, Go, Rust, C, C++, PHP, Zig), the
destination cannot be invoked, so the flow never meets a call. A
before/after CALLS diff over all 83 lang-resolution fixtures that
contain `&&` or `and` shows exactly one new edge,
logicalAnd -> runAndRight. PHP and Ruby bind low-precedence `and`
looser than `=`, so `$g = $x and $y` never reaches this rule.

The Python golden digest changes only for the extended
python-callable-alternatives fixture.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergo Magyar 2026-09-25 16:32:06 +00:00
parent 83b029f09e
commit 6eea55d7a4
6 changed files with 45 additions and 21 deletions

View file

@ -695,11 +695,22 @@ function assignmentParts(
/** Operators whose result is one of their operands, not a computed value. */
const VALUE_SELECTING_OPERATORS = new Set(['??', '||', 'or']);
/**
* Operators that yield their left operand when it is falsy and their right
* operand otherwise. A falsy value is never a callable, so only the RIGHT
* operand can be the callable that is later invoked. Where `&&` / `and`
* yields a boolean instead (Java, C#, Go, Rust, C, C++, PHP, Zig), the
* destination is not callable, so the flow never meets an invoke.
*/
const RIGHT_SELECTING_OPERATORS = new Set(['&&', 'and']);
/**
* 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.
* branches, so each branch flows into the destination. `a && b` / `a and b`
* can only yield a callable through `b`, so `x and f or g` reaches `f` and `g`.
* 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.
*/
@ -751,9 +762,9 @@ function valueBranches(
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 [left, right];
}
if (left === null || right === null || !operator) return undefined;
if (VALUE_SELECTING_OPERATORS.has(operator)) return [left, right];
if (RIGHT_SELECTING_OPERATORS.has(operator)) return [right];
return undefined;
}

View file

@ -10,6 +10,14 @@ def run_else():
pass
def run_and():
pass
def run_or_else():
pass
def logical_or(override):
run = override or run_sweep
run()
@ -18,3 +26,8 @@ def logical_or(override):
def ternary(fast):
run = run_then if fast else run_else
run()
def and_or(ready):
run = ready and run_and or run_or_else
run()

View file

@ -48,8 +48,8 @@ export async function callableLeft(env: { fallback: Handler }) {
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.
// `a && b` yields `a` when it is falsy and `b` otherwise. A falsy value is
// never a callable, so only the right operand can be the one invoked.
export async function logicalAnd(env: unknown) {
const run = runAndLeft && runAndRight;
await run(env);

View file

@ -96,8 +96,8 @@
"digest": "9c7f1c54a9b0ba3bfa4410e1843bf5edb570777789ee659a00d7dd6771f3670f"
},
"python-callable-alternatives/app.py": {
"captureGroups": 23,
"digest": "5209559e5d1a906536fa4a61b8fc130220e8fea0328b7877d93641d381724e0e"
"captureGroups": 35,
"digest": "e35b269b282c3955c4a196c6ecc2292c82bbba1c9579590cc970fe2ddee6f3c5"
},
"python-calls/one.py": {
"captureGroups": 4,

View file

@ -30,7 +30,7 @@ const callsOf = (result: PipelineResult): string[] =>
getRelationships(result, 'CALLS').filter((edge) => edge.rel.reason === 'callable-value-flow'),
);
describe('Python callable chosen by `or` / `x if c else y`', () => {
describe('Python callable chosen by `or` / `and` / `x if c else y`', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runFixture('python-callable-alternatives');
@ -45,6 +45,13 @@ describe('Python callable chosen by `or` / `x if c else y`', () => {
expect.arrayContaining(['ternary → run_then', 'ternary → run_else']),
);
});
it('`x and f or g` reaches f and g, never x', () => {
expect(callsOf(result).filter((edge) => edge.startsWith('and_or → '))).toEqual([
'and_or → run_and',
'and_or → run_or_else',
]);
});
});
describe('Kotlin callable chosen by `?:` / `if` expression', () => {

View file

@ -8,6 +8,7 @@
* of `runSweep`, and `impact` answered with one caller fewer while still
* claiming `epistemic: "exact"`. Each branch of `??`, `||`, and `?:` can be
* the value that is later invoked, so each branch is a flow into the binding.
* `a && b` can only yield a callable through `b`, so only `b` flows.
*/
import { describe, it, expect, beforeAll } from 'vitest';
import path from 'path';
@ -19,15 +20,7 @@ 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 ?? / || / ?:', () => {
describe('TypeScript callable chosen by ?? / || / ?: / &&', () => {
let result: PipelineResult;
beforeAll(async () => {
@ -71,8 +64,8 @@ describe('TypeScript callable chosen by ?? / || / ?:', () => {
expect(calls()).toContain('callableLeft → runLeft');
});
it('`&&` is not expanded: its left operand gains no edge', () => {
it('`a && fn` reaches fn and never the left operand', () => {
const fromLogicalAnd = calls().filter((edge) => edge.startsWith('logicalAnd → '));
expect(fromLogicalAnd).toEqual(PRE_3354_LOGICAL_AND_EDGES);
expect(fromLogicalAnd).toEqual(['logicalAnd → runAndRight']);
});
});