fix(swift): avoid false calls for injected closure properties (#3434)

This commit is contained in:
azizur100389 2026-10-02 23:31:14 +01:00 • committed by GitHub
parent 64fd67388f
commit a8f18f00b9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 545 additions and 7 deletions

View file

@ -119,7 +119,8 @@
"_rebaselined_3354_callable_alternatives": "#3354 callable alternatives: a callable chosen by a value-selecting source now flows every branch it can yield (`a ?? b`, `a || b`, `a or b`, `c ? a : b`, statement `if`, elvis), and an operator branch (`x == f || g`) stays opaque instead of seeding a qualified name. Verified by running the BASE (merge-base 233ca2849) and HEAD emitters over the SAME HEAD fixture corpus: every added or removed match is an `@callable-flow.*` match on one of those sources, and the pre-existing corpus is byte-identical (all other languages: zero delta). The rest of the drift is corpus growth from this PR's regression fixture, which this bench globs. Corpus growth: ruby-callable-alternatives/app.rb (+1 file, +58 groups). Emitter delta: +5 / -0: seeds for single-statement `if` / `elsif` branches (run_then, run_else, run_sweep, run_a, run_b); the multi-statement branch contributes nothing. capture_groups_fp 1358 -> 1416, fixture_count 91 -> 92; synthetic counts unchanged; scaling 1.04 < 1.5. Prior 1c8c9c4b54036fa24c2a81e39ea530e938645c856d369075e5f437da78218c57 -> 45e65d9fa8a9e5e905ddb596b179b77c86ed13ab5139c6b17ccaaf7315dfe46a."
},
"swift": {
"fingerprint": "d56406c2645637042899cfcc8dc73f603d77caef0a9a2bac179ffbec848258a3",
"fingerprint": "2507ac75ba6fb47c272a5caa97b5e8b9f6ebc5098c15295d05c7bff3cabc321a",
"_rebaselined_3425_injected_closure": "Swift capture fixture growth: 83 to 85 fixtures, 1436 to 1553 capture groups; scaling budget unchanged.",
"_rebaselined_3355_xcode_and_import_fixtures": "#3355 follow-up: new swift-xcode-targets fixture (four sources) and two sources added to swift-nested-packages (a Docs/Net folder decoy and an `import Net` caller). The capture query is unchanged; this is fixture-corpus growth only. capture_groups_fp 1393 -> 1436 and fixture_count 77 -> 83; synthetic scale counts remain 5012/16012. Prior aea33bf12f57561be7e3125929fb98cec7aed5a681e25aad435c7bb558747853 -> d56406c2645637042899cfcc8dc73f603d77caef0a9a2bac179ffbec848258a3; measured scaling 1.036 < 1.5.",
"_rebaselined_3355_nested_packages": "#3355: new swift-nested-packages fixture (three nested Package.swift manifests, six sources) for nested-package module grouping. The capture query is unchanged; this is fixture-corpus growth only. capture_groups_fp 1333 -> 1393 and fixture_count 68 -> 77; synthetic scale counts remain 5012/16012. Prior c9fc553662f0db18027fba882e3ff730744f6153cc46eca7b00667fabd6a0df8 -> aea33bf12f57561be7e3125929fb98cec7aed5a681e25aad435c7bb558747853; measured scaling 1.023 < 1.5.",
"scaling_budget": 1.5,

View file

@ -0,0 +1,78 @@
import type { ParsedFile, ScopeId, SymbolDefinition, TypeRef } from 'gitnexus-shared';
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
import { findClassBindingInScope } from '../../scope-resolution/scope/walkers.js';
export function swiftIsCallableVisibleFromCaller(ctx: {
readonly candidate: SymbolDefinition;
readonly callerParsed?: ParsedFile;
readonly callArity?: number;
readonly callerScope?: ScopeId;
readonly scopes?: ScopeResolutionIndexes;
}): boolean {
const indexes = ctx.scopes;
if (ctx.callerScope === undefined || indexes === undefined || ctx.callArity !== 0) return true;
const name = ctx.candidate.qualifiedName?.split('.').at(-1);
if (name === undefined) return true;
let scopeId: ScopeId | null = ctx.callerScope;
let selfType: TypeRef | undefined;
while (scopeId !== null) {
const scope = indexes.scopeTree.getScope(scopeId);
if (scope === undefined) break;
// A local function selected inside the caller wins before member lookup.
if (
scope.kind !== 'Class' &&
scope.ownedDefs.some((def) => def.nodeId === ctx.candidate.nodeId)
)
return true;
selfType ??= scope.typeBindings.get('self');
if (scope.kind === 'Class') {
const classDef =
scope.ownedDefs.find((def) => def.type === 'Class') ??
(selfType === undefined
? undefined
: findClassBindingInScope(ctx.callerScope, selfType.rawName, indexes, undefined, {
uniqueQualifiedNameFallback: false,
}));
const propertyOnOwner = (ownerId: string, ownerName: string): boolean =>
indexes.qualifiedNames.get(`${ownerName}.${name}`).some((defId) => {
const def = indexes.defs.get(defId);
return def?.type === 'Property' && def.ownerId === ownerId;
});
const ownProperty =
scope.ownedDefs.some(
(def) => def.type === 'Property' && def.qualifiedName?.split('.').at(-1) === name,
) ||
(classDef !== undefined &&
classDef.qualifiedName !== undefined &&
propertyOnOwner(classDef.nodeId, classDef.qualifiedName));
const inheritedProperty =
classDef !== undefined &&
indexes.methodDispatch.mroFor(classDef.nodeId).some((ownerId) => {
const ownerName = indexes.defs.get(ownerId)?.qualifiedName;
return ownerName !== undefined && propertyOnOwner(ownerId, ownerName);
});
if (!ownProperty && !inheritedProperty) return true;
// Swift's nested function is owned by its own Function scope. A
// same-file sibling must not count as a nearer lexical binding.
const declarationScope = ctx.callerParsed?.scopes.find((candidateScope) =>
candidateScope.ownedDefs.some((def) => def.nodeId === ctx.candidate.nodeId),
);
if (
declarationScope !== undefined &&
declarationScope.kind === 'Function' &&
(declarationScope.id === ctx.callerScope ||
indexes.scopeTree.getAncestors(declarationScope.id).includes(ctx.callerScope))
)
return true;
// Scope defs do not carry Swift access modifiers. A method positively
// selected on this type must not be vetoed by an uncertain ancestor.
return classDef !== undefined && ctx.candidate.ownerId === classDef.nodeId;
}
scopeId = scope.parent;
}
return true;
}

View file

@ -70,6 +70,7 @@ import {
import { stripSwiftTypePreservingDecoration } from './interpret.js';
import { groupSwiftFilesByModule } from './target-grouping.js';
import { swiftIsGlobalNameFallbackPlausible } from './name-fallback-visibility.js';
import { swiftIsCallableVisibleFromCaller } from './callable-visibility.js';
const ZERO_RANGE = { startLine: 0, startCol: 0, endLine: 0, endCol: 0 } as const;
@ -155,6 +156,7 @@ const swiftScopeResolver: ScopeResolver = {
// no-`new` constructor + cross-file free-call shape).
allowGlobalFreeCallFallback: true,
isGlobalNameFallbackPlausible: swiftIsGlobalNameFallbackPlausible,
isCallableVisibleFromCaller: swiftIsCallableVisibleFromCaller,
// Swift's call graph models `Type(...)` as a reference to the type
// itself, not its `init` — both the legacy DAG and this test suite link

View file

@ -1235,6 +1235,9 @@ export interface ScopeResolver {
readonly isCallableVisibleFromCaller?: (ctx: {
readonly callerParsed: ParsedFile;
readonly candidate: SymbolDefinition;
/** Arity of the actual call, when known. A visibility veto must not
* infer applicability from a name match alone. */
readonly callArity?: number;
/** Caller's enclosing scope id. Languages that gate visibility on
* caller scope (e.g. C++ two-phase template lookup) consult it;
* others ignore. Optional so existing implementations stay valid. */

View file

@ -84,12 +84,7 @@ export function emitFreeCallFallback(
* appended to its reason. See `ScopeResolver.markConstructionSites`. */
readonly markConstructionSites?: boolean;
readonly isFileLocalDef?: (def: SymbolDefinition) => boolean;
readonly isCallableVisibleFromCaller?: (ctx: {
readonly callerParsed: ParsedFile;
readonly candidate: SymbolDefinition;
readonly callerScope?: ScopeId;
readonly scopes?: ScopeResolutionIndexes;
}) => boolean;
readonly isCallableVisibleFromCaller?: ScopeResolver['isCallableVisibleFromCaller'];
readonly resolveAdlCandidates?: (
site: {
readonly name: string;
@ -620,6 +615,7 @@ export function emitFreeCallFallback(
options.isCallableVisibleFromCaller!({
callerParsed: parsed,
candidate,
callArity: site.arity,
callerScope: site.inScope,
scopes,
})
@ -698,6 +694,7 @@ export function emitFreeCallFallback(
!options.isCallableVisibleFromCaller({
callerParsed: parsed,
candidate: fnDef,
callArity: site.arity,
callerScope: site.inScope,
scopes,
})

View file

@ -0,0 +1,54 @@
import Foundation
enum Example {
static func runScenario() -> Int {
makeValue(input: 1)
}
}
final class Service {
private let clock: () -> Date
init(clock: @escaping () -> Date) {
self.clock = clock
}
func refreshValue() -> Date {
clock()
}
func refreshWithLocalClock() -> Int {
func clock() -> Int { 2 }
return clock()
}
}
class BaseService {
let clock: () -> Date
init(clock: @escaping () -> Date) {
self.clock = clock
}
}
final class DerivedService: BaseService {
func refreshInheritedValue() -> Date {
clock()
}
}
final class LabeledService {
let first: Int = 1
func first(where value: Bool) -> Int {
value ? 2 : 0
}
func refreshLabeled() -> Int {
first(where: true)
}
}
class PrivateBase {
private let clock: () -> Int = { 1 }
}

View file

@ -0,0 +1,39 @@
import Foundation
extension Example {
static func makeValue(input: Int) -> Int {
input + 1
}
}
enum Other {
private static func makeValue(other: String) -> Int {
-1
}
static func clock() -> Date {
Date.distantPast
}
}
extension DerivedService {
func refreshInheritedFromExtension() -> Date {
clock()
}
}
extension BaseService {
func refreshOwnFromExtension() -> Date {
clock()
}
}
final class PrivateDerived: PrivateBase {
func clock() -> Int {
2
}
func refreshPrivateAncestor() -> Int {
clock()
}
}

View file

@ -127,6 +127,14 @@
"captureGroups": 9,
"digest": "c4d442b67247406de3b1158b958f6f3e294f11c0bbb360ed32489582769a1187"
},
"swift-injected-closure-call/Caller.swift": {
"captureGroups": 69,
"digest": "6768d62722a6b173dbe953507fec53661ea6c9786a49d8edf7caedb0a3cef0e4"
},
"swift-injected-closure-call/Helpers.swift": {
"captureGroups": 48,
"digest": "6e6bfe83084bf189a7789702cfccb5fcc5753986b0de3c0d7f81fc3ae59270b2"
},
"swift-member-write-access/App.swift": {
"captureGroups": 14,
"digest": "7be68b7e510fefac99c94a4ebab3eb96556a0e587155b6c82b7602705e270987"

View file

@ -359,6 +359,95 @@ describe.skipIf(!swiftAvailable)('Swift protocol-extension implicit self (#3273)
});
});
describe.skipIf(!swiftAvailable)('Swift injected closure property call (#3425)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'swift-injected-closure-call'),
() => {},
);
}, 60000);
it('does not resolve an injected closure call to an unrelated method', () => {
expect(
getNodesByLabelFull(result, 'Property').some(
(node) => node.name === 'clock' && node.properties.filePath === 'Caller.swift',
),
).toBe(true);
expect(
getNodesByLabelFull(result, 'Function').some(
(node) => node.name === 'clock' && node.properties.filePath === 'Helpers.swift',
),
).toBe(true);
const calls = getRelationships(result, 'CALLS').filter((c) => c.source === 'refreshValue');
expect(calls.filter((c) => c.target === 'clock')).toEqual([]);
});
it('does not resolve an inherited closure property call to the unrelated method', () => {
const extendsEdges = getRelationships(result, 'EXTENDS');
expect(
extendsEdges.some(
(edge) => edge.source === 'DerivedService' && edge.target === 'BaseService',
),
).toBe(true);
const calls = getRelationships(result, 'CALLS').filter(
(c) => c.source === 'refreshInheritedValue',
);
expect(calls.filter((c) => c.target === 'clock')).toEqual([]);
});
it('keeps inherited closure calls in extensions unlinked', () => {
expect(
getNodesByLabelFull(result, 'Function').some(
(node) =>
node.name === 'refreshInheritedFromExtension' &&
node.properties.filePath === 'Helpers.swift',
),
).toBe(true);
const calls = getRelationships(result, 'CALLS').filter(
(c) => c.source === 'refreshInheritedFromExtension',
);
expect(calls.filter((c) => c.target === 'clock')).toEqual([]);
});
it('keeps same-type closure calls in extensions unlinked', () => {
const calls = getRelationships(result, 'CALLS').filter(
(c) => c.source === 'refreshOwnFromExtension',
);
expect(calls.filter((c) => c.target === 'clock')).toEqual([]);
});
it('still resolves the concrete-type extension call', () => {
const calls = getRelationships(result, 'CALLS').filter((c) => c.source === 'runScenario');
expect(calls.map((c) => c.rel.targetId)).toEqual([
'Function:Helpers.swift:Example.makeValue#1',
]);
});
it('keeps a nested function that shadows the stored closure', () => {
const calls = getRelationships(result, 'CALLS').filter(
(c) => c.source === 'refreshWithLocalClock',
);
expect(calls.filter((c) => c.target === 'clock')).toHaveLength(1);
expect(calls.find((c) => c.target === 'clock')?.targetFilePath).toBe('Caller.swift');
});
it('keeps a labeled method selected alongside a same-name property', () => {
const calls = getRelationships(result, 'CALLS').filter((c) => c.source === 'refreshLabeled');
expect(calls.map((c) => c.target)).toContain('first');
expect(calls.find((c) => c.target === 'first')?.targetFilePath).toBe('Caller.swift');
});
it('keeps a derived method despite an inaccessible ancestor property', () => {
const calls = getRelationships(result, 'CALLS').filter(
(c) => c.source === 'refreshPrivateAncestor',
);
expect(calls.map((c) => c.target)).toContain('clock');
expect(calls.find((c) => c.target === 'clock')?.targetFilePath).toBe('Helpers.swift');
});
});
// ---------------------------------------------------------------------------
// Constructor fallback: Swift constructors look like free function calls
// (no `new` keyword). The resolver retries with constructor form when

View file

@ -0,0 +1,267 @@
import {
buildDefIndex,
buildMethodDispatchIndex,
buildQualifiedNameIndex,
buildScopeTree,
type Scope,
type SymbolDefinition,
} from 'gitnexus-shared';
import { describe, expect, it } from 'vitest';
import { swiftIsCallableVisibleFromCaller } from '../../../../src/core/ingestion/languages/swift/callable-visibility.js';
import type { ScopeResolutionIndexes } from '../../../../src/core/ingestion/model/scope-resolution-indexes.js';
const filePath = 'Service.swift';
const moduleRange = { startLine: 1, startCol: 0, endLine: 12, endCol: 0 };
const classRange = { startLine: 2, startCol: 0, endLine: 10, endCol: 0 };
const functionRange = { startLine: 5, startCol: 0, endLine: 8, endCol: 0 };
function scope(
id: string,
parent: string | null,
kind: Scope['kind'],
ownedDefs: SymbolDefinition[],
range: Scope['range'],
): Scope {
return {
id,
parent,
kind,
range,
filePath,
ownedDefs,
bindings: new Map(),
imports: [],
typeBindings: new Map(),
};
}
const classDef: SymbolDefinition = {
nodeId: 'Service',
filePath,
type: 'Class',
qualifiedName: 'Service',
};
const property: SymbolDefinition = {
nodeId: 'Service.clock',
filePath,
type: 'Property',
qualifiedName: 'Service.clock',
ownerId: 'Service',
};
const method: SymbolDefinition = {
nodeId: 'Service.refresh',
filePath,
type: 'Method',
qualifiedName: 'Service.refresh',
ownerId: 'Service',
};
const scopes = {
scopeTree: buildScopeTree([
scope('module', null, 'Module', [], moduleRange),
scope('class', 'module', 'Class', [classDef, property], classRange),
scope('function', 'class', 'Function', [method], functionRange),
]),
defs: buildDefIndex([classDef, property, method]),
qualifiedNames: buildQualifiedNameIndex([classDef, property, method]),
methodDispatch: buildMethodDispatchIndex({
owners: [classDef.nodeId],
computeMro: () => [],
implementsOf: () => [],
}),
} as ScopeResolutionIndexes;
describe('Swift caller-side callable visibility', () => {
it('rejects an unrelated same-name method shadowed by a stored property', () => {
expect(
swiftIsCallableVisibleFromCaller({
candidate: {
nodeId: 'Other.clock',
filePath: 'Other.swift',
type: 'Method',
qualifiedName: 'Other.clock',
},
callerScope: 'function',
callArity: 0,
scopes,
}),
).toBe(false);
});
it('keeps differently named methods and unknown caller scopes eligible', () => {
const candidate: SymbolDefinition = {
nodeId: 'Other.run',
filePath: 'Other.swift',
type: 'Method',
qualifiedName: 'Other.run',
};
expect(
swiftIsCallableVisibleFromCaller({
candidate,
callerScope: 'function',
callArity: 0,
scopes,
}),
).toBe(true);
expect(
swiftIsCallableVisibleFromCaller({
candidate: { ...candidate, qualifiedName: 'Other.clock' },
}),
).toBe(true);
});
it('preserves a selected local function before checking the enclosing property', () => {
const local: SymbolDefinition = {
nodeId: 'local.clock',
filePath,
type: 'Function',
qualifiedName: 'Service.refresh.clock',
};
const localScopes = {
...scopes,
scopeTree: buildScopeTree([
scope('module', null, 'Module', [], moduleRange),
scope('class', 'module', 'Class', [classDef, property], classRange),
scope('function', 'class', 'Function', [method], functionRange),
scope('local', 'function', 'Function', [local], {
startLine: 6,
startCol: 0,
endLine: 7,
endCol: 0,
}),
]),
} as ScopeResolutionIndexes;
expect(
swiftIsCallableVisibleFromCaller({
candidate: local,
callerParsed: {
filePath,
moduleScope: 'module',
scopes: [...localScopes.scopeTree.byId.values()],
localDefs: [classDef, property, method, local],
parsedImports: [],
referenceSites: [],
},
callerScope: 'function',
callArity: 0,
scopes: localScopes,
}),
).toBe(true);
});
it('preserves a selected method owned by the current type', () => {
expect(
swiftIsCallableVisibleFromCaller({
candidate: { ...method, nodeId: 'Service.clock', qualifiedName: 'Service.clock' },
callerScope: 'function',
callArity: 0,
scopes,
}),
).toBe(true);
});
it('does not veto a call with arguments or unknown arity', () => {
const candidate: SymbolDefinition = {
nodeId: 'Other.clock',
filePath: 'Other.swift',
type: 'Method',
qualifiedName: 'Other.clock',
};
expect(
swiftIsCallableVisibleFromCaller({
candidate,
callerScope: 'function',
callArity: 1,
scopes,
}),
).toBe(true);
expect(swiftIsCallableVisibleFromCaller({ candidate, callerScope: 'function', scopes })).toBe(
true,
);
});
it('rejects a decoy when a superclass owns the closure property', () => {
const base: SymbolDefinition = {
nodeId: 'BaseService',
filePath,
type: 'Class',
qualifiedName: 'BaseService',
};
const derived: SymbolDefinition = {
nodeId: 'DerivedService',
filePath,
type: 'Class',
qualifiedName: 'DerivedService',
};
const inheritedProperty: SymbolDefinition = {
nodeId: 'BaseService.clock',
filePath,
type: 'Property',
qualifiedName: 'BaseService.clock',
ownerId: base.nodeId,
};
const inheritedScopes = {
scopeTree: buildScopeTree([
scope('derivedModule', null, 'Module', [], moduleRange),
scope('derivedClass', 'derivedModule', 'Class', [derived], classRange),
scope('derivedFunction', 'derivedClass', 'Function', [method], functionRange),
]),
defs: buildDefIndex([base, derived, inheritedProperty]),
qualifiedNames: buildQualifiedNameIndex([base, derived, inheritedProperty]),
methodDispatch: buildMethodDispatchIndex({
owners: [derived.nodeId],
computeMro: () => [base.nodeId],
implementsOf: () => [],
}),
} as ScopeResolutionIndexes;
expect(
swiftIsCallableVisibleFromCaller({
candidate: {
nodeId: 'Other.clock',
filePath: 'Other.swift',
type: 'Method',
qualifiedName: 'Other.clock',
},
callerScope: 'derivedFunction',
callArity: 0,
scopes: inheritedScopes,
}),
).toBe(false);
const extensionScopes = {
...inheritedScopes,
bindings: new Map([
[
'extensionModule',
new Map([['DerivedService', [{ def: derived, origin: 'local' as const }]]]),
],
]),
bindingAugmentations: new Map(),
scopeTree: buildScopeTree([
scope('extensionModule', null, 'Module', [], moduleRange),
scope('extensionClass', 'extensionModule', 'Class', [], classRange),
{
...scope('extensionFunction', 'extensionClass', 'Function', [method], functionRange),
typeBindings: new Map([
[
'self',
{ rawName: 'DerivedService', declaredAtScope: 'extensionFunction', source: 'self' },
],
]),
},
]),
} as ScopeResolutionIndexes;
expect(
swiftIsCallableVisibleFromCaller({
candidate: {
nodeId: 'Other.clock',
filePath: 'Other.swift',
type: 'Method',
qualifiedName: 'Other.clock',
},
callerScope: 'extensionFunction',
callArity: 0,
scopes: extensionScopes,
}),
).toBe(false);
});
});