From 6eea55d7a47aca297f5d1c820b98ceef30537eea Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Fri, 25 Sep 2026 16:32:06 +0000 Subject: [PATCH] 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) --- .../ingestion/utils/callable-flow-captures.ts | 21 ++++++++++++++----- .../python-callable-alternatives/app.py | 13 ++++++++++++ .../typescript-callable-alternatives/index.ts | 4 ++-- .../expected-captures.json | 4 ++-- .../callable-alternatives-providers.test.ts | 9 +++++++- .../typescript-callable-alternatives.test.ts | 15 ++++--------- 6 files changed, 45 insertions(+), 21 deletions(-) diff --git a/gitnexus/src/core/ingestion/utils/callable-flow-captures.ts b/gitnexus/src/core/ingestion/utils/callable-flow-captures.ts index 531d6bca4..91a474eea 100644 --- a/gitnexus/src/core/ingestion/utils/callable-flow-captures.ts +++ b/gitnexus/src/core/ingestion/utils/callable-flow-captures.ts @@ -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; } diff --git a/gitnexus/test/fixtures/lang-resolution/python-callable-alternatives/app.py b/gitnexus/test/fixtures/lang-resolution/python-callable-alternatives/app.py index 776d37b4c..7321e5e82 100644 --- a/gitnexus/test/fixtures/lang-resolution/python-callable-alternatives/app.py +++ b/gitnexus/test/fixtures/lang-resolution/python-callable-alternatives/app.py @@ -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() diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-callable-alternatives/index.ts b/gitnexus/test/fixtures/lang-resolution/typescript-callable-alternatives/index.ts index 025e0a9c7..c5a56266b 100644 --- a/gitnexus/test/fixtures/lang-resolution/typescript-callable-alternatives/index.ts +++ b/gitnexus/test/fixtures/lang-resolution/typescript-callable-alternatives/index.ts @@ -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); diff --git a/gitnexus/test/fixtures/python-captures-golden/expected-captures.json b/gitnexus/test/fixtures/python-captures-golden/expected-captures.json index bcccdf622..9ea10da78 100644 --- a/gitnexus/test/fixtures/python-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/python-captures-golden/expected-captures.json @@ -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, diff --git a/gitnexus/test/integration/resolvers/callable-alternatives-providers.test.ts b/gitnexus/test/integration/resolvers/callable-alternatives-providers.test.ts index dbce87fd9..6fbf588d4 100644 --- a/gitnexus/test/integration/resolvers/callable-alternatives-providers.test.ts +++ b/gitnexus/test/integration/resolvers/callable-alternatives-providers.test.ts @@ -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', () => { diff --git a/gitnexus/test/integration/resolvers/typescript-callable-alternatives.test.ts b/gitnexus/test/integration/resolvers/typescript-callable-alternatives.test.ts index b2fe59fb2..39498fb20 100644 --- a/gitnexus/test/integration/resolvers/typescript-callable-alternatives.test.ts +++ b/gitnexus/test/integration/resolvers/typescript-callable-alternatives.test.ts @@ -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']); }); });