fix(ingestion): keep operator branches of a value-selecting source opaque (#3354)

The fan-out sent every branch of `??` / `||` / `or` / `?:` to
emitAssignmentFact on its own, including branches that compute a value.
`Handlers.fallback === run || fb` then emitted the comparison as a seed
whose qualified text sliced to receiver `Handlers`, member `run`, and
resolveBoundMemberCandidates minted a CALLS edge to `Handlers.run`. The
same happened for `this.state !== run ?? this.fallback`,
`this.state + run || this.fallback`, and Python
`self.state != run or self.fallback`. Before the fan-out, the whole
compound source was one opaque seed.

A branch that is a binary operator expression now contributes nothing.
The check uses the field vocabulary valueBranches already reads (a
`left`/`right` pair, or an `operator`/`operators`/`op` token after the
expression start), not grammar type names. Member accesses that field
their `.`/`->` as `operator` (Ruby `call`, C/C++ `field_expression`) also
field a member name through the list memberParts uses, now shared as
memberNameNode, so they stay designators. Unary `&f`/`*fp` lead with
their operator and stay designators. Call results were already dropped
by emitAssignmentFact, and lambdas and callable references are unchanged.

CALLS-edge diff over 87 lang-resolution fixtures (505 -> 501 edges):
only the four false edges above were removed, and none were added.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergo Magyar 2026-09-25 17:37:07 +00:00
parent 6eea55d7a4
commit 8ad0d8db7f
5 changed files with 157 additions and 13 deletions

View file

@ -710,7 +710,8 @@ const RIGHT_SELECTING_OPERATORS = new Set(['&&', 'and']);
* 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.
* source shape untouched. A branch that is itself an operator expression
* (`x.kind === f || g`) yields a computed value, so it contributes nothing.
* `options.valueAlternatives` is consulted first for grammars whose shape the
* field-based rule below cannot see.
*/
@ -726,7 +727,12 @@ function valueAlternatives(
for (let current = pending.pop(); current !== undefined; current = pending.pop()) {
const branches = valueBranches(current, options);
if (branches === undefined) {
out.push(current);
// An operator branch is opaque, as the whole compound source was before
// the fan-out: emitted alone, `x.kind === Handlers.run` becomes a seed
// whose qualified text slices to receiver `Handlers`, member `run`.
if (current === node || !isBinaryOperatorExpression(unwrapParentheses(current))) {
out.push(current);
}
continue;
}
// Reverse push keeps the left-to-right branch order on output.
@ -744,12 +750,7 @@ function valueBranches(
node: SyntaxNode,
options: CallableFlowCaptureOptions,
): readonly SyntaxNode[] | undefined {
let inner = node;
while (inner.type.includes('parenthesized') && inner.namedChildCount === 1) {
const child = inner.namedChild(0);
if (child === null) break;
inner = child;
}
const inner = unwrapParentheses(node);
const provided = options.valueAlternatives?.(inner);
if (provided !== undefined) {
return provided.length === 1 && provided[0]?.id === inner.id ? undefined : provided;
@ -768,6 +769,38 @@ function valueBranches(
return undefined;
}
function unwrapParentheses(node: SyntaxNode): SyntaxNode {
let inner = node;
while (inner.type.includes('parenthesized') && inner.namedChildCount === 1) {
const child = inner.namedChild(0);
if (child === null) break;
inner = child;
}
return inner;
}
/**
* True for an expression that computes a value from two operands (`a === b`,
* `a + b`, `a is b`), which designates neither operand. Read from the same
* field vocabulary as `valueBranches` rather than grammar type names: a
* `left`/`right` pair, or an operator token (`operator`, Python's
* `operators`, Swift's `op`) that follows the expression's start. A member
* access that fields its `.` / `->` as `operator` (Ruby `call`, C/C++
* `field_expression`) also fields its member name, so it stays a designator;
* a unary `&f` / `*fp` leads with its operator and stays one too.
*/
function isBinaryOperatorExpression(node: SyntaxNode): boolean {
if (node.childForFieldName('left') !== null && node.childForFieldName('right') !== null) {
return true;
}
if (memberNameNode(node) !== null) return false;
const operator =
node.childForFieldName('operator') ??
node.childForFieldName('operators') ??
node.childForFieldName('op');
return operator !== null && operator.startIndex > node.startIndex;
}
function emitAssignmentFact(
assignment: AssignmentParts,
knownCallableNames: ReadonlySet<string>,
@ -1202,11 +1235,7 @@ function memberParts(
// stay unaffected. Without it every `x.f(arg)` in such a grammar collapsed
// to a DIRECT call named `f` and the flow solver fanned the argument out to
// every same-named callable.
const memberNode =
node.childForFieldName('property') ??
node.childForFieldName('field') ??
node.childForFieldName('method') ??
node.childForFieldName('member');
const memberNode = memberNameNode(node);
if (receiverNode === null || memberNode === null) return undefined;
const receiver = operandSyntax(receiverNode, options);
const member = operandSyntax(memberNode, options);
@ -1219,6 +1248,17 @@ function memberParts(
return { receiver, member, ...(operator !== undefined ? { operator: operator.text } : {}) };
}
/** The member-name child of a member access, under the field names the
* grammars use for it. */
function memberNameNode(node: SyntaxNode): SyntaxNode | null {
return (
node.childForFieldName('property') ??
node.childForFieldName('field') ??
node.childForFieldName('method') ??
node.childForFieldName('member')
);
}
function operandSyntax(
input: SyntaxNode,
options: CallableFlowCaptureOptions,

View file

@ -31,3 +31,32 @@ def ternary(fast):
def and_or(ready):
run = ready and run_and or run_or_else
run()
# A comparison branch of `or` yields a bool, never what it compares against:
# none of these may reach `run`; the `self.fallback` branch still flows.
class Handlers:
@staticmethod
def run():
pass
def comparison_branch(x, fb):
h = x.kind == Handlers.run or fb
h()
class Machine:
def run(self):
pass
def fallback(self):
pass
def self_comparison(self):
h = self.state != self.run or self.fallback
h()
def bare_comparison(self, run):
h = self.state != run or self.fallback
h()

View file

@ -0,0 +1,42 @@
// A branch of `||` / `??` / `?:` that is a comparison yields a boolean, never
// the member it compares against. None of these bindings may reach `run`.
export class Handlers {
static run(x: unknown): void {}
static fallback(x: unknown): void {}
}
export async function comparisonBranch(x: { kind: unknown }, fb: (x: unknown) => void) {
const h = x.kind === Handlers.run || fb;
h(x);
}
export async function staticComparison(run: unknown, fb: (x: unknown) => void) {
const h = Handlers.fallback === run || fb;
h(run);
}
export class Machine {
state: unknown;
run(): void {}
fallback(): void {}
thisComparison() {
const h = this.state !== this.run || this.fallback;
h();
}
bareComparison(run: unknown) {
const h = this.state !== run ?? this.fallback;
h();
}
arithmeticBranch(run: number) {
const h = this.state + run || this.fallback;
h();
}
ternaryComparison(fast: boolean) {
const h = fast ? this.state === this.run : this.fallback;
h();
}
}

View file

@ -52,6 +52,17 @@ describe('Python callable chosen by `or` / `and` / `x if c else y`', () => {
'and_or → run_or_else',
]);
});
it('a comparison branch of `or` flows nothing, while its designator sibling still does', () => {
expect(
callsOf(result).filter(
(edge) =>
edge.startsWith('comparison_branch → ') ||
edge.startsWith('self_comparison → ') ||
edge.startsWith('bare_comparison → '),
),
).toEqual(['bare_comparison → fallback', 'self_comparison → fallback']);
});
});
describe('Kotlin callable chosen by `?:` / `if` expression', () => {

View file

@ -9,6 +9,8 @@
* 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.
* A branch that is itself a comparison or arithmetic expression yields a
* computed value, so it flows nothing (compare.ts).
*/
import { describe, it, expect, beforeAll } from 'vitest';
import path from 'path';
@ -68,4 +70,24 @@ describe('TypeScript callable chosen by ?? / || / ?: / &&', () => {
const fromLogicalAnd = calls().filter((edge) => edge.startsWith('logicalAnd → '));
expect(fromLogicalAnd).toEqual(['logicalAnd → runAndRight']);
});
// `x.kind === Handlers.run` emitted as its own source becomes a seed whose
// qualified text slices to receiver `Handlers` and member `run`.
it('a comparison branch never reaches the static member it compares against', () => {
const fromComparisons = calls().filter(
(edge) => edge.startsWith('comparisonBranch → ') || edge.startsWith('staticComparison → '),
);
expect(fromComparisons).toEqual([]);
});
it('a comparison or arithmetic branch never reaches a same-named method of `this`', () => {
const fromOperators = calls().filter(
(edge) =>
edge.startsWith('bareComparison → ') ||
edge.startsWith('arithmeticBranch → ') ||
edge.startsWith('thisComparison → ') ||
edge.startsWith('ternaryComparison → '),
);
expect(fromOperators).toEqual([]);
});
});