diff --git a/gitnexus/src/core/ingestion/languages/dart/captures.ts b/gitnexus/src/core/ingestion/languages/dart/captures.ts index a6c5ef773..fb5fc63ee 100644 --- a/gitnexus/src/core/ingestion/languages/dart/captures.ts +++ b/gitnexus/src/core/ingestion/languages/dart/captures.ts @@ -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 { diff --git a/gitnexus/src/core/ingestion/languages/kotlin/captures.ts b/gitnexus/src/core/ingestion/languages/kotlin/captures.ts index a24073324..d58f4981c 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/captures.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/captures.ts @@ -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, diff --git a/gitnexus/src/core/ingestion/languages/python/captures.ts b/gitnexus/src/core/ingestion/languages/python/captures.ts index a6d19fdc4..e1aefd57b 100644 --- a/gitnexus/src/core/ingestion/languages/python/captures.ts +++ b/gitnexus/src/core/ingestion/languages/python/captures.ts @@ -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( diff --git a/gitnexus/src/core/ingestion/languages/ruby/captures.ts b/gitnexus/src/core/ingestion/languages/ruby/captures.ts index f66095c02..eb046f509 100644 --- a/gitnexus/src/core/ingestion/languages/ruby/captures.ts +++ b/gitnexus/src/core/ingestion/languages/ruby/captures.ts @@ -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), diff --git a/gitnexus/src/core/ingestion/languages/swift/captures.ts b/gitnexus/src/core/ingestion/languages/swift/captures.ts index 56f165b21..d555e634f 100644 --- a/gitnexus/src/core/ingestion/languages/swift/captures.ts +++ b/gitnexus/src/core/ingestion/languages/swift/captures.ts @@ -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. */ diff --git a/gitnexus/src/core/ingestion/utils/callable-flow-captures.ts b/gitnexus/src/core/ingestion/utils/callable-flow-captures.ts index b62b6ff20..5de2f9bd6 100644 --- a/gitnexus/src/core/ingestion/utils/callable-flow-captures.ts +++ b/gitnexus/src/core/ingestion/utils/callable-flow-captures.ts @@ -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]; } diff --git a/gitnexus/src/storage/parse-cache.ts b/gitnexus/src/storage/parse-cache.ts index e7a6645c6..0f7ce849c 100644 --- a/gitnexus/src/storage/parse-cache.ts +++ b/gitnexus/src/storage/parse-cache.ts @@ -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 { diff --git a/gitnexus/test/fixtures/lang-resolution/dart-callable-alternatives/app.dart b/gitnexus/test/fixtures/lang-resolution/dart-callable-alternatives/app.dart new file mode 100644 index 000000000..f07d2c009 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/dart-callable-alternatives/app.dart @@ -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(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-callable-alternatives/App.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-callable-alternatives/App.kt new file mode 100644 index 000000000..58bef2c21 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-callable-alternatives/App.kt @@ -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() +} diff --git a/gitnexus/test/fixtures/lang-resolution/python-callable-alternatives/app.py b/gitnexus/test/fixtures/lang-resolution/python-callable-alternatives/app.py new file mode 100644 index 000000000..776d37b4c --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-callable-alternatives/app.py @@ -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() diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-callable-alternatives/app.rb b/gitnexus/test/fixtures/lang-resolution/ruby-callable-alternatives/app.rb new file mode 100644 index 000000000..205db44f1 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-callable-alternatives/app.rb @@ -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 diff --git a/gitnexus/test/fixtures/lang-resolution/swift-callable-alternatives/App.swift b/gitnexus/test/fixtures/lang-resolution/swift-callable-alternatives/App.swift new file mode 100644 index 000000000..bf5d0dab2 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/swift-callable-alternatives/App.swift @@ -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() +} 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 be47f8e38..025e0a9c7 100644 --- a/gitnexus/test/fixtures/lang-resolution/typescript-callable-alternatives/index.ts +++ b/gitnexus/test/fixtures/lang-resolution/typescript-callable-alternatives/index.ts @@ -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; @@ -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); +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-callable-alternatives/sweep/index.ts b/gitnexus/test/fixtures/lang-resolution/typescript-callable-alternatives/sweep/index.ts index 10b6950ca..344710c69 100644 --- a/gitnexus/test/fixtures/lang-resolution/typescript-callable-alternatives/sweep/index.ts +++ b/gitnexus/test/fixtures/lang-resolution/typescript-callable-alternatives/sweep/index.ts @@ -3,3 +3,8 @@ export async function runAlias(env: unknown): Promise {} export async function runOr(env: unknown): Promise {} export async function runThen(env: unknown): Promise {} export async function runElse(env: unknown): Promise {} +export async function runChained(env: unknown): Promise {} +export async function runParen(env: unknown): Promise {} +export async function runLeft(env: unknown): Promise {} +export async function runAndLeft(env: unknown): Promise {} +export async function runAndRight(env: unknown): Promise {} diff --git a/gitnexus/test/fixtures/python-captures-golden/expected-captures.json b/gitnexus/test/fixtures/python-captures-golden/expected-captures.json index 99aececf7..bcccdf622 100644 --- a/gitnexus/test/fixtures/python-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/python-captures-golden/expected-captures.json @@ -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" diff --git a/gitnexus/test/fixtures/ruby-captures-golden/expected-captures.json b/gitnexus/test/fixtures/ruby-captures-golden/expected-captures.json index a5e825c71..cc2d731e9 100644 --- a/gitnexus/test/fixtures/ruby-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/ruby-captures-golden/expected-captures.json @@ -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" diff --git a/gitnexus/test/fixtures/swift-captures-golden/expected-captures.json b/gitnexus/test/fixtures/swift-captures-golden/expected-captures.json index 1d67db04f..4522da5ff 100644 --- a/gitnexus/test/fixtures/swift-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/swift-captures-golden/expected-captures.json @@ -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" diff --git a/gitnexus/test/integration/resolvers/callable-alternatives-providers.test.ts b/gitnexus/test/integration/resolvers/callable-alternatives-providers.test.ts new file mode 100644 index 000000000..cace93f5d --- /dev/null +++ b/gitnexus/test/integration/resolvers/callable-alternatives-providers.test.ts @@ -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 => + 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'); + }); +}); diff --git a/gitnexus/test/integration/resolvers/typescript-callable-alternatives.test.ts b/gitnexus/test/integration/resolvers/typescript-callable-alternatives.test.ts index 2cc102e9a..b2fe59fb2 100644 --- a/gitnexus/test/integration/resolvers/typescript-callable-alternatives.test.ts +++ b/gitnexus/test/integration/resolvers/typescript-callable-alternatives.test.ts @@ -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); + }); });