mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-03 02:21:44 +00:00
fix(ingestion): expand ??/?:/ternary branches through a provider hook (#3354)
Review follow-up on #3373. The shared value-alternatives rule keys on tree-sitter field names, and several grammars spell the same construct differently, so the expansion never fired for them: - Kotlin `elvis_expression` has no fields. - Swift uses `value`/`if_nil` and `if_true`/`if_false`. - Dart uses `first`/`second`, and its conditional has no `condition` field. - Python `a if c else b` has no fields. The `'?:'` operator entry was dead, since no bundled grammar emits it. Add an optional `valueAlternatives` hook to CallableFlowCaptureOptions, consulted before the shared rule, and implement it in the Kotlin, Swift, Dart, Python and Ruby providers. Shared code still names no language. Ruby's statement-bodied `if`/`unless`/`elsif` also carries `condition`/`consequence`/`alternative`, so the shared ternary rule dug an identifier out of an arbitrary statement (`g = h; 0` flowed `h`) and produced a wrong CALLS edge. The Ruby hook now keeps a multi-statement branch as one opaque source, as before #3373. Tests: new provider fixtures for Python, Kotlin, Swift, Dart and Ruby (including a Ruby negative), and TS chain, parenthesized, callable-left and `&&` negative cases. Captures goldens gain one entry each for the new fixtures. SCHEMA_BUMP stays 112 (same unreleased PR). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
765d84d18d
commit
a4fc15eb9a
19 changed files with 421 additions and 6 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
13
gitnexus/test/fixtures/lang-resolution/dart-callable-alternatives/app.dart
vendored
Normal file
13
gitnexus/test/fixtures/lang-resolution/dart-callable-alternatives/app.dart
vendored
Normal file
|
|
@ -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();
|
||||
}
|
||||
13
gitnexus/test/fixtures/lang-resolution/kotlin-callable-alternatives/App.kt
vendored
Normal file
13
gitnexus/test/fixtures/lang-resolution/kotlin-callable-alternatives/App.kt
vendored
Normal file
|
|
@ -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()
|
||||
}
|
||||
20
gitnexus/test/fixtures/lang-resolution/python-callable-alternatives/app.py
vendored
Normal file
20
gitnexus/test/fixtures/lang-resolution/python-callable-alternatives/app.py
vendored
Normal file
|
|
@ -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()
|
||||
27
gitnexus/test/fixtures/lang-resolution/ruby-callable-alternatives/app.rb
vendored
Normal file
27
gitnexus/test/fixtures/lang-resolution/ruby-callable-alternatives/app.rb
vendored
Normal file
|
|
@ -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
|
||||
13
gitnexus/test/fixtures/lang-resolution/swift-callable-alternatives/App.swift
vendored
Normal file
13
gitnexus/test/fixtures/lang-resolution/swift-callable-alternatives/App.swift
vendored
Normal file
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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<void>;
|
||||
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,3 +3,8 @@ export async function runAlias(env: unknown): Promise<void> {}
|
|||
export async function runOr(env: unknown): Promise<void> {}
|
||||
export async function runThen(env: unknown): Promise<void> {}
|
||||
export async function runElse(env: unknown): Promise<void> {}
|
||||
export async function runChained(env: unknown): Promise<void> {}
|
||||
export async function runParen(env: unknown): Promise<void> {}
|
||||
export async function runLeft(env: unknown): Promise<void> {}
|
||||
export async function runAndLeft(env: unknown): Promise<void> {}
|
||||
export async function runAndRight(env: unknown): Promise<void> {}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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<PipelineResult> =>
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue