diff --git a/gitnexus/src/core/ingestion/utils/callable-flow-captures.ts b/gitnexus/src/core/ingestion/utils/callable-flow-captures.ts index 91a474eea..9c2b5b9d7 100644 --- a/gitnexus/src/core/ingestion/utils/callable-flow-captures.ts +++ b/gitnexus/src/core/ingestion/utils/callable-flow-captures.ts @@ -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, @@ -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, 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 7321e5e82..46180c9ba 100644 --- a/gitnexus/test/fixtures/lang-resolution/python-callable-alternatives/app.py +++ b/gitnexus/test/fixtures/lang-resolution/python-callable-alternatives/app.py @@ -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() diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-callable-alternatives/compare.ts b/gitnexus/test/fixtures/lang-resolution/typescript-callable-alternatives/compare.ts new file mode 100644 index 000000000..7068a187f --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-callable-alternatives/compare.ts @@ -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(); + } +} diff --git a/gitnexus/test/integration/resolvers/callable-alternatives-providers.test.ts b/gitnexus/test/integration/resolvers/callable-alternatives-providers.test.ts index 6fbf588d4..7e619d5ed 100644 --- a/gitnexus/test/integration/resolvers/callable-alternatives-providers.test.ts +++ b/gitnexus/test/integration/resolvers/callable-alternatives-providers.test.ts @@ -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', () => { diff --git a/gitnexus/test/integration/resolvers/typescript-callable-alternatives.test.ts b/gitnexus/test/integration/resolvers/typescript-callable-alternatives.test.ts index 39498fb20..d1853b9f7 100644 --- a/gitnexus/test/integration/resolvers/typescript-callable-alternatives.test.ts +++ b/gitnexus/test/integration/resolvers/typescript-callable-alternatives.test.ts @@ -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([]); + }); });