fix(swift): resolve inherited protocol extension calls (#3309)

* fix(swift): resolve inherited protocol extension calls

* fix(scope): gate inherited implicit receiver lookup

* fix(swift): resolve call result types by exact callee

* test(swift): align cache and local call expectations

* fix(scope): reconcile replay diagnostics

* fix(swift): preserve exact callable return types

* fix(swift): capture throwing async call results

* test(swift): refresh capture golden

* test(swift): refresh scope capture baseline

* fix(swift): require explicit callable returns

* fix(scope): preserve duplicate return metadata

* chore(scope): align index documentation

* Address PR review feedback (#3309)

Stamp only protocol/class-extension members (nested QN + SPM buckets),
arity-narrow implicit-this across MRO, and keep Swift type peeling out
of shared workspace-index via stripTypePreservingDecoration.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3309)

Stamp extension members even when the extension declares a nested type, keep inherited class members ahead of protocol-extension defaults, and report replay-only interface-dispatch fan-out drops.

Note: pre-existing failure in gitnexus tsc against an older gitnexus-shared dist not addressed by this PR.
Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3309)

Walk inherited implicit-this owners nearest-first so a nearer override wins, and tighten Swift owner-stamp tests.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(autofix): apply prettier + eslint fixes via /autofix command

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Parafee41 2026-09-19 14:44:35 +08:00 • committed by GitHub
parent f465f6fb92
commit 795cf0e151
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 1550 additions and 133 deletions

View file

@ -98,6 +98,7 @@ export type { ResolveTypeRefContext } from './scope-resolution/resolve-type-ref.
// ScopeExtractor output contracts (RFC §3.2 Phase 1; Ring 2 PKG #919)
export type { ParsedFile } from './scope-resolution/parsed-file.js';
export type { CallResultAssignmentSite } from './scope-resolution/call-result-assignment-site.js';
export type {
ReferenceSite,
ReferenceKind,

View file

@ -0,0 +1,14 @@
import type { Range, ScopeId } from './types.js';
/**
* Compact extraction-time identity for `lhs = callee()`.
*
* The call-site range uses the same call-expression anchor as reference
* resolution, allowing downstream passes to join this fact to the exact
* resolved callee id without relying on a possibly polluted type binding.
*/
export interface CallResultAssignmentSite {
readonly callSite: Range;
readonly inScope: ScopeId;
readonly lhs: string;
}

View file

@ -56,6 +56,7 @@ import type { ParsedImport } from './types.js';
import type { SymbolDefinition } from './symbol-definition.js';
import type { ReferenceSite } from './reference-site.js';
import type { CallableFlowSite } from './callable-flow-site.js';
import type { CallResultAssignmentSite } from './call-result-assignment-site.js';
export interface ParsedFile {
readonly filePath: string;
@ -81,6 +82,8 @@ export interface ParsedFile {
* syntax remain source-compatible; consumers normalize absence to `[]`.
*/
readonly callableFlowSites?: readonly CallableFlowSite[];
/** Exact call-expression → assigned local identity for return-type replay. */
readonly callResultAssignmentSites?: readonly CallResultAssignmentSite[];
/**
* Opaque, language-private serialization of capture-time side-channel
* state that a provider's `emitScopeCaptures` populates into module-level

View file

@ -117,7 +117,7 @@
"_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior fc81941b0a921074fa80dc448284de9a23bd07358ddc84d4894797cc08c3fe83 -> 1c8c9c4b54036fa24c2a81e39ea530e938645c856d369075e5f437da78218c57."
},
"swift": {
"fingerprint": "adef9284feaecd39cb490aebce83876e15b9150c7a04b00a396feb78b7e1e0a9",
"fingerprint": "724553c91e8ebcf872d7302b14105d59476c230f9b623acdacc0b58e39912329",
"scaling_budget": 1.5,
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior 5f923c6604d825d12b249f31c155b0f4d13a8379d532e5dde64a0f9b15cf4725 -> 7687ee2466e16020a12440a03fbda53e63aa05f94b4481f6133c09867a0d560d; scaling 1.042 < 1.5.",
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: Swift function-value callable flow facts with invocation-result suppression. Prior 180ac68e780bdf6f9089d53f51cbb9a66aed3e7774631cc3fcbaae5020213998 -> 5f923c6604d825d12b249f31c155b0f4d13a8379d532e5dde64a0f9b15cf4725; scaling 1.043 < 1.5.",
@ -125,7 +125,9 @@
"_rebaselined_2522_review_fixes": "PR #2522 review fixes: assignment target:/result: fields join the shared fallback. Prior 7687ee2466e16020a12440a03fbda53e63aa05f94b4481f6133c09867a0d560d -> 115c5da807e36bb12fdeba28e44f2b6484ef322ff26c19fa0f191febaf774248; scaling ratio re-verified within budget.",
"_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior 115c5da807e36bb12fdeba28e44f2b6484ef322ff26c19fa0f191febaf774248 -> a6fca5f052ae5ec635b56051e28a168c864a988b2221a3279ddd69807378ba0b.",
"_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior a6fca5f052ae5ec635b56051e28a168c864a988b2221a3279ddd69807378ba0b -> 2f04ae960123cf50138a49fabdc5a146c2963170cecf5755c552b23c9055a9e7.",
"_rebaselined_inferred_field_receiver_2807": "#2807: optional property annotations (`var a: Outer?`) now emit a type binding. The prior pattern required the `user_type` to be a DIRECT child of the annotation, so an `optional_type` wrapper meant an optional field was never typed at all and its receiver could not resolve. ADDS @type-binding.annotation captures on the optional form only; no capture is removed. Prior 2f04ae960123cf50138a49fabdc5a146c2963170cecf5755c552b23c9055a9e7 -> adef9284feaecd39cb490aebce83876e15b9150c7a04b00a396feb78b7e1e0a9; scaling 1.023 < 1.5."
"_rebaselined_inferred_field_receiver_2807": "#2807: optional property annotations (`var a: Outer?`) now emit a type binding. The prior pattern required the `user_type` to be a DIRECT child of the annotation, so an `optional_type` wrapper meant an optional field was never typed at all and its receiver could not resolve. ADDS @type-binding.annotation captures on the optional form only; no capture is removed. Prior 2f04ae960123cf50138a49fabdc5a146c2963170cecf5755c552b23c9055a9e7 -> adef9284feaecd39cb490aebce83876e15b9150c7a04b00a396feb78b7e1e0a9; scaling 1.023 < 1.5.",
"_rebaselined_3309_call_result_assignment": "#3309: the Swift protocol-extension regression fixture adds one file with an untyped local initialized from a helper call, plus a compound-field receiver call. The capture query is unchanged; this is fixture-corpus growth only. capture_groups_fp 1235 -> 1272 and fixture_count 62 -> 64; synthetic scale counts remain 5012/16012. Prior adef9284feaecd39cb490aebce83876e15b9150c7a04b00a396feb78b7e1e0a9 -> 4d32535d454cd79086f0fcaec3b75de855c1314e73630321460e569f19aa484f; measured scaling 1.020 < 1.5.",
"_rebaselined_3309_exact_return_replay": "#3309 follow-up: Swift callable declarations now emit exact return-type captures, and untyped local initializers emit call-result assignment captures through direct, try, await, and try-await forms. This is intentional capture-set growth covered by exact-callable collision and wrapper regressions. Prior 4d32535d454cd79086f0fcaec3b75de855c1314e73630321460e569f19aa484f -> 724553c91e8ebcf872d7302b14105d59476c230f9b623acdacc0b58e39912329; hosted benchmark scaling remained within the existing 1.5 budget."
},
"dart": {
"fingerprint": "3a8ddabbeb1cba47a4757451d4f79d726ca230fd15e860772b11526fbb1c6687",

View file

@ -48,6 +48,7 @@ import {
synthesizeSwiftReceiverBinding,
} from './receiver-binding.js';
import { synthesizeSwiftSignatureBindings } from './signature-bindings.js';
import { swiftMethodConfig } from '../../method-extractors/configs/swift.js';
import { getSwiftParser, getSwiftScopeQuery } from './query.js';
import { preprocessSwiftConditionalDirectives } from './conditional-directive-preprocess.js';
import { recordCacheHit, recordCacheMiss } from './cache-stats.js';
@ -198,6 +199,26 @@ export function emitSwiftScopeCaptures(
continue;
}
// The query deliberately recognizes the compact `lhs = call()` shape;
// enforce "untyped lhs" here because tree-sitter queries cannot express
// absence of Swift's sibling type_annotation robustly. A typed declaration
// remains authoritative and must never enter return-type replay.
if (grouped['@call-result-assignment.call'] !== undefined) {
const callNode = nodeIfType(nodeMap['@call-result-assignment.call'], 'call_expression');
let property = callNode?.parent;
while (property?.type === 'await_expression' || property?.type === 'try_expression') {
property = property.parent;
}
if (
property?.type !== 'property_declaration' ||
property.namedChildren.some((child) => child.type === 'type_annotation')
) {
continue;
}
out.push(grouped);
continue;
}
// ── Field accesses: a `navigation_expression` (`obj.field`) is one of
// three things. Drop it when it's a call's callee (`u.save` in
// `u.save()` — the @reference.call.member query already covers that).
@ -314,7 +335,17 @@ export function emitSwiftScopeCaptures(
nodeMap['@declaration.constructor'],
...FUNCTION_NODE_TYPES,
);
if (fnNodeForArity !== null) attachArityMetadata(grouped, fnNodeForArity);
if (fnNodeForArity !== null) {
attachArityMetadata(grouped, fnNodeForArity);
const returnType = swiftMethodConfig.extractReturnType?.(fnNodeForArity);
if (returnType !== undefined && returnType !== '') {
grouped['@declaration.return-type'] = syntheticCapture(
'@declaration.return-type',
fnNodeForArity,
returnType,
);
}
}
// Structural receiver chain for a call whose receiver is itself an
// expression, so resolution can type it by folding over structure
// instead of re-parsing the receiver's source text. Self-gating: a
@ -338,7 +369,17 @@ export function emitSwiftScopeCaptures(
const declTag = FUNCTION_DECL_TAGS.find((t) => grouped[t] !== undefined);
if (declTag !== undefined) {
const fnNode = nodeIfType(nodeMap[declTag], ...FUNCTION_NODE_TYPES);
if (fnNode !== null) attachArityMetadata(grouped, fnNode);
if (fnNode !== null) {
attachArityMetadata(grouped, fnNode);
const returnType = swiftMethodConfig.extractReturnType?.(fnNode);
if (returnType !== undefined && returnType !== '') {
grouped['@declaration.return-type'] = syntheticCapture(
'@declaration.return-type',
fnNode,
returnType,
);
}
}
}
// ── Constructor calls: Swift has no `new`, so `Foo()` is a free call

View file

@ -27,7 +27,12 @@
export { emitSwiftScopeCaptures } from './captures.js';
export { getSwiftCaptureCacheStats, resetSwiftCaptureCacheStats } from './cache-stats.js';
export { interpretSwiftImport, interpretSwiftTypeBinding } from './interpret.js';
export {
interpretSwiftImport,
interpretSwiftTypeBinding,
normalizeSwiftTypeName,
stripSwiftTypePreservingDecoration,
} from './interpret.js';
export { swiftMergeBindings } from './merge-bindings.js';
export { swiftArityCompatibility } from './arity.js';
export { resolveSwiftImportTarget, type SwiftResolveContext } from './import-target.js';

View file

@ -46,7 +46,7 @@ export function interpretSwiftTypeBinding(captures: CaptureMatch): ParsedTypeBin
// `[User]` → User (array sugar)
// `Array<User>` / `Optional<User>` → User (single-arg generic)
// `Foundation.URL` → URL (qualifier)
const rawType = stripQualifier(stripGeneric(stripArraySugar(stripOptional(typeCap.text.trim()))));
const rawType = normalizeSwiftTypeName(typeCap.text);
let source: TypeRef['source'] = 'parameter-annotation';
if (captures['@type-binding.self'] !== undefined) source = 'self';
@ -58,6 +58,22 @@ export function interpretSwiftTypeBinding(captures: CaptureMatch): ParsedTypeBin
return { boundName: nameCap.text, rawTypeName: rawType, source };
}
export function normalizeSwiftTypeName(text: string): string {
return stripQualifier(stripGeneric(stripArraySugar(stripOptional(text.trim()))));
}
/**
* Type-preserving decoration only — used by `stripTypePreservingDecoration`
* for class lookup and declared-return replay. `User?` / `User!` → `User`.
* Arrays, generics, and nested `Foo.Bar` are left intact so a binding used
* for member lookup does not follow the element type or the trailing ident.
*/
export function stripSwiftTypePreservingDecoration(typeName: string): string | undefined {
const trimmed = typeName.trim();
if (trimmed.endsWith('?') || trimmed.endsWith('!')) return trimmed.slice(0, -1).trim();
return undefined;
}
/** `User?` / `User!` → `User`. */
function stripOptional(text: string): string {
if (text.endsWith('?') || text.endsWith('!')) return text.slice(0, -1).trim();

View file

@ -188,6 +188,32 @@ const SWIFT_SCOPE_QUERY = `
(call_expression
(simple_identifier) @reference.name) @reference.call.free
;; Exact identity for \`let lhs = callee()\`. The call-expression anchor is
;; byte-identical to @reference.call.free / member for downstream position join.
(property_declaration
name: (pattern
bound_identifier: (simple_identifier) @call-result-assignment.lhs)
value: (call_expression) @call-result-assignment.call)
(property_declaration
name: (pattern
bound_identifier: (simple_identifier) @call-result-assignment.lhs)
value: (await_expression
(call_expression) @call-result-assignment.call))
(property_declaration
name: (pattern
bound_identifier: (simple_identifier) @call-result-assignment.lhs)
value: (try_expression
(call_expression) @call-result-assignment.call))
(property_declaration
name: (pattern
bound_identifier: (simple_identifier) @call-result-assignment.lhs)
value: (try_expression
(await_expression
(call_expression) @call-result-assignment.call)))
;; ── References — member / method calls: \`obj.method(...)\` ───────────
;; navigation_expression carries the receiver (target:) and the member
;; (suffix > navigation_suffix > simple_identifier). \`self\` is a

View file

@ -14,7 +14,8 @@
* re-keys an `extension Foo { … }` to a `class_declaration`-style def
* named `Foo`, so its members land on `Foo`'s scope and the shared
* `populateClassOwnedMembers` stamps them with `Foo`'s ownerId — the
* same mechanism C# uses for `partial class`. No separate hoist pass.
* same mechanism C# uses for `partial class`. Cross-file extensions
* that mint no type def are reconciled by `populateWorkspaceOwners`.
* - **Labeled arguments** narrow by ARITY only (count-primary, labels
* soft) — see `arity.ts`. Label-precise dispatch is deferred to the
* type-binding layer.
@ -46,7 +47,7 @@
* 4. **`@_exported import` re-exports** are treated as plain imports.
*/
import type { ParsedFile } from 'gitnexus-shared';
import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared';
import { SupportedLanguages } from 'gitnexus-shared';
import { loadSwiftPackageConfig } from '../../language-config.js';
import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js';
@ -66,6 +67,8 @@ import {
mirrorSwiftSiblingTypeBindings,
type SwiftResolveContext,
} from './index.js';
import { stripSwiftTypePreservingDecoration } from './interpret.js';
import { coerceSwiftTargets, groupSwiftFilesBySpmTarget } from './target-grouping.js';
import { swiftIsGlobalNameFallbackPlausible } from './name-fallback-visibility.js';
const ZERO_RANGE = { startLine: 0, startCol: 0, endLine: 0, endCol: 0 } as const;
@ -101,6 +104,8 @@ const swiftScopeResolver: ScopeResolver = {
arityCompatibility: (callsite, def) => swiftArityCompatibility(def, callsite),
buildMro: (graph, parsedFiles, nodeLookup) => buildSwiftMro(graph, parsedFiles, nodeLookup),
implicitThisWalksMro: true,
stripTypePreservingDecoration: stripSwiftTypePreservingDecoration,
// Methods/properties/init are owned by their enclosing class/struct/
// extension(→extended type)/protocol. Extension members hoist for free
@ -108,6 +113,13 @@ const swiftScopeResolver: ScopeResolver = {
// the extended type.
populateOwners: (parsed: ParsedFile) => populateClassOwnedMembers(parsed),
// An extension has its own Class scope but deliberately does not mint a
// second type def. Its members therefore leave the per-file owner walk with
// a qualified name (`ExtendedType.member`) but no ownerId. Reconcile those
// members after all files are available so extensions declared in sibling
// files work as well as extensions beside the original type.
populateWorkspaceOwners: populateSwiftExtensionOwners,
// `super.method()` dispatches through the superclass chain.
isSuperReceiver: (text) => text.trim() === 'super',
@ -209,6 +221,91 @@ function buildSwiftMro(
return mro;
}
function populateSwiftExtensionOwners(
parsedFiles: readonly ParsedFile[],
ctx?: { readonly fileContents: ReadonlyMap<string, string>; readonly resolutionConfig?: unknown },
): void {
const filesByTarget = groupSwiftFilesBySpmTarget(
parsedFiles,
(parsed) => parsed.filePath,
coerceSwiftTargets(ctx?.resolutionConfig),
);
for (const files of filesByTarget.values()) {
stampSwiftExtensionOwnersInTarget(files);
}
}
function stampSwiftExtensionOwnersInTarget(parsedFiles: readonly ParsedFile[]): void {
const ownersByName = new Map<string, SymbolDefinition[]>();
for (const parsed of parsedFiles) {
for (const def of parsed.localDefs) {
if (!isClassLike(def.type) || def.qualifiedName === undefined) continue;
const bucket = ownersByName.get(def.qualifiedName);
if (bucket === undefined) ownersByName.set(def.qualifiedName, [def]);
else bucket.push(def);
}
}
// Extension members are Function-owned defs whose parent Class minted no
// type def. Nested locals are Function-owned defs whose parent is another
// Function — leave those ownerless so they cannot enter implicit-self CALLS.
for (const parsed of parsedFiles) {
const byId = new Map(parsed.scopes.map((scope) => [scope.id, scope]));
for (const scope of parsed.scopes) {
if (scope.kind !== 'Function') continue;
const parent = scope.parent === null ? undefined : byId.get(scope.parent);
if (parent?.kind !== 'Class') continue;
// A type-decl Class owns the type that opened it (same start line).
// Nested types inside an `extension` are also class-like and live on
// that Class scope — they are not the extended type, so they must
// not suppress stamping `func added` onto `Foo`.
if (parent.ownedDefs.some((d) => isClassLike(d.type) && defDeclaresThisClassScope(parent, d)))
continue;
for (const def of scope.ownedDefs) {
if (def.ownerId !== undefined || def.qualifiedName === undefined) continue;
const dot = def.qualifiedName.lastIndexOf('.');
if (dot <= 0) continue;
const owner = uniqueOwnerForExtensionPrefix(ownersByName, def.qualifiedName.slice(0, dot));
if (owner !== undefined) {
(def as { ownerId?: string }).ownerId = owner.nodeId;
}
}
}
}
}
function defDeclaresThisClassScope(
parent: { readonly range: { readonly startLine: number } },
def: SymbolDefinition,
): boolean {
const line = /#(\d+):/.exec(def.nodeId);
if (line === null) return true;
return Number(line[1]) === parent.range.startLine;
}
function uniqueOwnerForExtensionPrefix(
ownersByName: ReadonlyMap<string, readonly SymbolDefinition[]>,
prefix: string,
): SymbolDefinition | undefined {
const seen = new Set<string>();
const matches: SymbolDefinition[] = [];
const consider = (defs: readonly SymbolDefinition[] | undefined): void => {
if (defs === undefined) return;
for (const def of defs) {
if (seen.has(def.nodeId)) continue;
seen.add(def.nodeId);
matches.push(def);
}
};
consider(ownersByName.get(prefix));
if (!prefix.includes('.')) {
for (const [qn, defs] of ownersByName) {
if (qn !== prefix && qn.endsWith('.' + prefix)) consider(defs);
}
}
return matches.length === 1 ? matches[0] : undefined;
}
function closeProtocols(
seeds: readonly string[],
directImpls: ReadonlyMap<string, readonly string[]>,

View file

@ -4,7 +4,7 @@
* (RFC §5.3 + §3.2 Phase 1; Ring 2 PKG #919).
*
* Exactly one entry point: `extract(matches, filePath, provider) → ParsedFile`.
* Runs a five-pass pipeline over the matches. Each pass is internal; the
* Runs a seven-pass pipeline over the matches. Each pass is internal; the
* public contract is the output `ParsedFile`.
*
* ## Design principles
@ -22,7 +22,7 @@
* don't overlap) are enforced by `buildScopeTree` from Ring 2 SHARED
* (#912). Malformed inputs throw `ScopeTreeInvariantError`.
*
* ## The five passes
* ## The seven passes
*
* 1. **Build scope tree.** Walk `@scope.*` matches. For each, consult
* `provider.resolveScopeKind` (default: suffix of the capture name).
@ -60,6 +60,10 @@
* one `ReferenceSite` per match. Classify call form via
* `provider.classifyCallForm` (default: the capture's sub-tag if
* present; else `'free'`).
* 6. **Collect callable-value-flow facts.** Independent of Pass 5 so
* existing reference-site extraction stays byte-identical.
* 7. **Preserve call-result assignment identity.** Untyped
* `let lhs = call()` facts used by exact-callee return-type replay.
*
* ## What gets attached where
*
@ -80,6 +84,7 @@ import type {
CallableFlowOperand,
CallableFlowPassingMode,
CallableFlowSite,
CallResultAssignmentSite,
Capture,
CaptureMatch,
ImportEdge,
@ -134,7 +139,7 @@ export type ScopeExtractorHooks = Pick<
// ─── Public entry point ─────────────────────────────────────────────────────
/**
* Drive the five extraction passes and return a `ParsedFile`.
* Drive the seven extraction passes and return a `ParsedFile`.
*
* Throws `ScopeTreeInvariantError` (from #912) when the provider emits
* captures that violate structural scope invariants (e.g., overlapping
@ -240,6 +245,15 @@ export function extract(
const callableFlowSites: CallableFlowSite[] = [];
pass6CollectCallableFlows(partitioned.callableFlow, positionIndex, filePath, callableFlowSites);
// ── Pass 7: preserve call-result assignment identity ───────────────
const callResultAssignmentSites: CallResultAssignmentSite[] = [];
pass7CollectCallResultAssignments(
partitioned.callResultAssignment,
positionIndex,
filePath,
callResultAssignmentSites,
);
// Freeze Scope drafts into final shape and return.
const frozenScopes = scopeDrafts.map(draftToScope);
return Object.freeze({
@ -252,6 +266,9 @@ export function extract(
...(callableFlowSites.length > 0
? { callableFlowSites: Object.freeze(callableFlowSites.slice()) }
: {}),
...(callResultAssignmentSites.length > 0
? { callResultAssignmentSites: Object.freeze(callResultAssignmentSites.slice()) }
: {}),
});
}
@ -264,6 +281,7 @@ interface Partitioned {
readonly typeBinding: readonly CaptureMatch[];
readonly reference: readonly CaptureMatch[];
readonly callableFlow: readonly CaptureMatch[];
readonly callResultAssignment: readonly CaptureMatch[];
}
/**
@ -283,6 +301,7 @@ function partitionByTopic(matches: readonly CaptureMatch[]): Partitioned {
const typeBinding: CaptureMatch[] = [];
const reference: CaptureMatch[] = [];
const callableFlow: CaptureMatch[] = [];
const callResultAssignment: CaptureMatch[] = [];
for (const match of matches) {
for (const topic of topicsOf(match)) {
@ -305,14 +324,32 @@ function partitionByTopic(matches: readonly CaptureMatch[]): Partitioned {
case 'callable-flow':
callableFlow.push(match);
break;
case 'call-result-assignment':
callResultAssignment.push(match);
break;
}
}
}
return { scope, declaration, import_, typeBinding, reference, callableFlow };
return {
scope,
declaration,
import_,
typeBinding,
reference,
callableFlow,
callResultAssignment,
};
}
type Topic = 'scope' | 'declaration' | 'import' | 'type-binding' | 'reference' | 'callable-flow';
type Topic =
| 'scope'
| 'declaration'
| 'import'
| 'type-binding'
| 'reference'
| 'callable-flow'
| 'call-result-assignment';
function topicsOf(match: CaptureMatch): ReadonlySet<Topic> {
const topics = new Set<Topic>();
@ -323,6 +360,7 @@ function topicsOf(match: CaptureMatch): ReadonlySet<Topic> {
else if (name.startsWith('@type-binding.')) topics.add('type-binding');
else if (name.startsWith('@reference.')) topics.add('reference');
else if (name.startsWith('@callable-flow.')) topics.add('callable-flow');
else if (name.startsWith('@call-result-assignment.')) topics.add('call-result-assignment');
}
return topics;
}
@ -598,17 +636,25 @@ function pass2AttachDeclarations(
// fact is the failure mode this subsystem rejects everywhere else.
//
// Copying the field onto BOTH twins makes the outcome identical whichever
// one wins. Deliberately narrow — only `typeParameters`, the one field with
// an asymmetric twin today. Widening this to "merge all metadata" would
// change what every existing duplicate resolves to, which is a different
// change with a different blast radius and no evidence behind it yet.
// one wins. Deliberately narrow — only metadata whose asymmetric twins have
// executable regressions (`typeParameters` and `returnType`). Widening this
// to "merge all metadata" would change what every existing duplicate
// resolves to, which is a different change with a different blast radius
// and no evidence behind it yet.
const first = firstDefByNodeId.get(def.nodeId);
if (first === undefined) {
firstDefByNodeId.set(def.nodeId, def);
} else if (first.typeParameters === undefined && def.typeParameters !== undefined) {
first.typeParameters = def.typeParameters;
} else if (def.typeParameters === undefined && first.typeParameters !== undefined) {
def.typeParameters = first.typeParameters;
} else {
if (first.typeParameters === undefined && def.typeParameters !== undefined) {
first.typeParameters = def.typeParameters;
} else if (def.typeParameters === undefined && first.typeParameters !== undefined) {
def.typeParameters = first.typeParameters;
}
if (first.returnType === undefined && def.returnType !== undefined) {
first.returnType = def.returnType;
} else if (def.returnType === undefined && first.returnType !== undefined) {
def.returnType = first.returnType;
}
}
// Find the innermost scope that contains the declaration's anchor range.
@ -1652,6 +1698,24 @@ function pass6CollectCallableFlows(
}
}
// ─── Pass 7: collect call-result assignment identity ──────────────────────
function pass7CollectCallResultAssignments(
matches: readonly CaptureMatch[],
positionIndex: ReturnType<typeof buildPositionIndex>,
filePath: string,
out: CallResultAssignmentSite[],
): void {
for (const match of matches) {
const call = match['@call-result-assignment.call'];
const lhs = match['@call-result-assignment.lhs'];
if (call === undefined || lhs === undefined || !nonEmpty(lhs.text)) continue;
const inScope = positionIndex.atPosition(filePath, call.range.startLine, call.range.startCol);
if (inScope === undefined) continue;
out.push({ callSite: call.range, inScope, lhs: lhs.text });
}
}
function callableFlowKind(match: CaptureMatch): CallableFlowKind | undefined {
return CALLABLE_FLOW_KINDS.find((kind) => match[`@callable-flow.${kind}`] !== undefined);
}

View file

@ -692,7 +692,10 @@ export interface ScopeResolver {
*/
readonly populateWorkspaceOwners?: (
parsedFiles: readonly ParsedFile[],
ctx: { readonly fileContents: ReadonlyMap<string, string> },
ctx: {
readonly fileContents: ReadonlyMap<string, string>;
readonly resolutionConfig?: unknown;
},
) => void;
/**
@ -965,6 +968,11 @@ export interface ScopeResolver {
*/
readonly freeCallsRequireInstanceOwnership?: boolean;
/** Whether an unqualified call inside a type may dispatch to an inherited
* instance method. Languages such as Swift allow implicit-self lookup,
* while Python/JavaScript/PHP require an explicit receiver. */
readonly implicitThisWalksMro?: boolean;
/**
* When true, a constructor-form call `Type(...)` links to the Class def
* itself rather than its explicit Constructor def. Default

View file

@ -121,6 +121,7 @@ export function emitFreeCallFallback(
* contains the method's owner. See
* `ScopeResolver.freeCallsRequireInstanceOwnership`. */
readonly freeCallsRequireInstanceOwnership?: boolean;
readonly implicitThisWalksMro?: boolean;
readonly recordResolutionOutcome?: ResolutionOutcomeRecorder;
/** Call sites owned by a later precise pass (for example callable-value-flow). */
readonly skipSites?: ReadonlySet<string>;
@ -314,6 +315,7 @@ export function emitFreeCallFallback(
conversionRankFn: options.conversionRankFn,
conversionOnlyArgTypePrefixes: options.conversionOnlyArgTypePrefixes,
constraintCompatibility: options.constraintCompatibility,
implicitThisWalksMro: options.implicitThisWalksMro,
});
fnDefFromImplicitThis = fnDef !== undefined;
}
@ -1167,11 +1169,14 @@ export function pickUniqueGlobalClass(
* pick a method member by name with overload narrowing on arity +
* argument types. Returns undefined if there's no enclosing class,
* no matching method, OR narrowing leaves multiple compatible
* candidates — in the multi-candidate case, picking
* `candidates[0]` would emit a high-confidence CALLS edge whose
* target depends on registration order rather than a defensible
* resolution. Mirrors `pickUniqueGlobalCallable`'s uniqueness check
* in the same file (Codex PR #1497 review, finding 2).
* candidates — except when those survivors are a protocol/interface
* requirement plus exactly one extension witness, in which case the
* witness (the default body) is returned. An inherited class, struct,
* or enum member still wins over a protocol-extension default.
* Picking `candidates[0]` would emit a high-confidence CALLS edge
* whose target depends on registration order rather than a
* defensible resolution. Mirrors `pickUniqueGlobalCallable`'s
* uniqueness check in the same file (Codex PR #1497 review, finding 2).
*
* Exported for unit testing — language-agnostic logic, exercised
* via synthetic stubs in `pick-implicit-this-overload.test.ts`. The
@ -1191,6 +1196,7 @@ export function pickImplicitThisOverload(
readonly conversionRankFn?: ConversionRankFn;
readonly conversionOnlyArgTypePrefixes?: readonly string[];
readonly constraintCompatibility?: ScopeResolver['constraintCompatibility'];
readonly implicitThisWalksMro?: boolean;
},
): SymbolDefinition | undefined {
// Find the enclosing Class scope by walking parents.
@ -1211,22 +1217,118 @@ export function pickImplicitThisOverload(
const classDefId = workspaceIndex.classScopeIdToDefId.get(classScopeId);
if (classDefId === undefined) return undefined;
const overloads = model.methods.lookupAllByOwner(classDefId, site.name);
if (overloads.length === 0) return undefined;
if (overloads.length === 1) return overloads[0];
// Bare calls in an instance method use the same implicit receiver as
// `self.member()`. Prefer declarations on the enclosing type; when the
// language opts into MRO implicit-this, walk inherited owners
// nearest-first and arity-narrow per owner. Compatible-but-ambiguous
// on a nearer ancestor fail-closes — do not fall through to a farther
// override. Falling through to the global name lookup makes inherited
// defaults depend on file order.
const own = model.methods.lookupAllByOwner(classDefId, site.name);
const ownPicked = pickUniqueImplicitThisCandidate(own, site, hookCtx, workspaceIndex);
if (ownPicked !== undefined) return ownPicked;
if (own.length > 0) {
const ownCompatible = narrowOverloadCandidates(own, site.arity, site.argumentTypes, {
argumentTypeClasses: site.argumentTypeClasses,
conversionRankFn: hookCtx?.conversionRankFn,
conversionOnlyArgTypePrefixes: hookCtx?.conversionOnlyArgTypePrefixes,
constraintCompatibility: hookCtx?.constraintCompatibility,
});
if (ownCompatible.length > 0) return undefined;
}
if (hookCtx?.implicitThisWalksMro !== true) return undefined;
// Narrow on arity + argument types. Require a UNIQUE survivor —
// ambiguous narrowing (multiple compatible candidates with no
// disambiguating signal) leaves the call unresolved rather than
// routing to an arbitrary first overload by registration order.
for (const ownerId of scopes.methodDispatch?.mroFor(classDefId) ?? []) {
const inherited = model.methods.lookupAllByOwner(ownerId, site.name);
const inheritedPicked = pickUniqueImplicitThisCandidate(
inherited,
site,
hookCtx,
workspaceIndex,
);
if (inheritedPicked !== undefined) return inheritedPicked;
if (inherited.length > 0) {
const inheritedCompatible = narrowOverloadCandidates(
inherited,
site.arity,
site.argumentTypes,
{
argumentTypeClasses: site.argumentTypeClasses,
conversionRankFn: hookCtx?.conversionRankFn,
conversionOnlyArgTypePrefixes: hookCtx?.conversionOnlyArgTypePrefixes,
constraintCompatibility: hookCtx?.constraintCompatibility,
},
);
if (inheritedCompatible.length > 0) return undefined;
}
}
return undefined;
}
function pickUniqueImplicitThisCandidate(
overloads: readonly SymbolDefinition[],
site: {
readonly arity?: number;
readonly argumentTypes?: readonly string[];
readonly argumentTypeClasses?: readonly import('gitnexus-shared').ParameterTypeClass[];
},
hookCtx:
| {
readonly conversionRankFn?: ConversionRankFn;
readonly conversionOnlyArgTypePrefixes?: readonly string[];
readonly constraintCompatibility?: ScopeResolver['constraintCompatibility'];
}
| undefined,
workspaceIndex: WorkspaceResolutionIndex,
): SymbolDefinition | undefined {
if (overloads.length === 0) return undefined;
const candidates = narrowOverloadCandidates(overloads, site.arity, site.argumentTypes, {
argumentTypeClasses: site.argumentTypeClasses,
conversionRankFn: hookCtx?.conversionRankFn,
conversionOnlyArgTypePrefixes: hookCtx?.conversionOnlyArgTypePrefixes,
constraintCompatibility: hookCtx?.constraintCompatibility,
});
if (candidates.length !== 1) return undefined;
return candidates[0];
if (candidates.length === 0) return undefined;
if (candidates.length === 1) return candidates[0];
const witnesses = preferExtensionWitnesses(candidates, workspaceIndex);
return witnesses.length === 1 ? witnesses[0] : undefined;
}
function preferExtensionWitnesses(
candidates: readonly SymbolDefinition[],
workspaceIndex: WorkspaceResolutionIndex,
): readonly SymbolDefinition[] {
const onOwnerType: SymbolDefinition[] = [];
const extensionWitnesses: SymbolDefinition[] = [];
for (const def of candidates) {
const ownerId = def.ownerId;
if (ownerId === undefined) {
extensionWitnesses.push(def);
continue;
}
const ownerScope = workspaceIndex.classScopeByDefId?.get(ownerId);
const livesOnOwner =
ownerScope?.ownedDefs.some((owned) => owned.nodeId === def.nodeId) === true;
if (livesOnOwner) onOwnerType.push(def);
else extensionWitnesses.push(def);
}
if (extensionWitnesses.length === 0 || onOwnerType.length === 0) return candidates;
const concrete = onOwnerType.filter((def) => !isProtocolLikeOwner(def.ownerId, workspaceIndex));
if (concrete.length > 0) return concrete;
return extensionWitnesses;
}
function isProtocolLikeOwner(
ownerId: string | undefined,
workspaceIndex: WorkspaceResolutionIndex,
): boolean {
if (ownerId === undefined) return false;
const ownerScope = workspaceIndex.classScopeByDefId?.get(ownerId);
if (ownerScope === undefined) return false;
return ownerScope.ownedDefs.some(
(owned) =>
owned.nodeId === ownerId && (owned.type === 'Protocol' || owned.type === 'Interface'),
);
}
/**

View file

@ -24,6 +24,7 @@
*/
import type { ParsedFile, RegistryProviders } from 'gitnexus-shared';
import type { TypeRef } from 'gitnexus-shared';
import type { KnowledgeGraph } from '../../../graph/types.js';
import { generateId } from '../../../../lib/utils.js';
import { lookupOwnedMembersByOwner } from '../../model/owned-members-lookup.js';
@ -84,6 +85,7 @@ import {
import { emitReturnShapeMemberAccesses } from '../passes/return-shape-members.js';
import { emitImportedValueReferences } from '../passes/imported-value-refs.js';
import {
calleeIdPosKey,
createCalleeIdAccumulator,
type CalleeIdAccumulator,
} from '../graph-bridge/callee-id-sink.js';
@ -103,6 +105,58 @@ import { buildWorkspaceResolutionIndex } from '../workspace-index.js';
import type { ResolutionOutcome, ResolutionOutcomeRecorder } from '../resolution-outcome.js';
import { logHeapProbe } from '../../utils/heap-probe.js';
import { parseTruthyEnv } from '../../utils/env.js';
/**
* Join extraction-time assignment identity to Phase-4's exact resolved callee.
* Ambiguous dispatch and missing return annotations deliberately produce no
* binding. Extraction emits these facts only for untyped declarations, so an
* existing entry here is necessarily inference/mirroring and may be corrected;
* explicit annotations never enter this join.
*/
export function applyPreciseCallResultBindings(
parsedFiles: readonly ParsedFile[],
indexes: ReturnType<typeof finalizeScopeModel>,
workspaceIndex: ReturnType<typeof buildWorkspaceResolutionIndex>,
calleeIds: CalleeIdAccumulator,
nodeLookup: ReturnType<typeof buildGraphNodeLookup>,
): number {
let updated = 0;
const returnTypeByGraphId = new Map<string, TypeRef>();
for (const parsed of parsedFiles) {
for (const def of parsed.localDefs) {
const returnType = workspaceIndex.declaredReturnTypeByCallableId.get(def.nodeId);
if (returnType === undefined) continue;
const graphId = resolveDefGraphId(def.filePath, def, nodeLookup);
if (graphId !== undefined) returnTypeByGraphId.set(graphId, returnType);
}
}
for (const parsed of parsedFiles) {
const resolvedByPosition = calleeIds.get(parsed.filePath);
if (resolvedByPosition === undefined) continue;
for (const assignment of parsed.callResultAssignmentSites ?? []) {
const targets = resolvedByPosition.get(
calleeIdPosKey(assignment.callSite.startLine, assignment.callSite.startCol),
);
if (targets === undefined || targets.size !== 1) continue;
const targetId = targets.values().next().value as string | undefined;
if (targetId === undefined) continue;
const returnType = returnTypeByGraphId.get(targetId);
if (returnType === undefined) continue;
const scope = indexes.scopeTree.getScope(assignment.inScope);
if (scope === undefined) continue;
(scope.typeBindings as Map<string, TypeRef>).set(assignment.lhs, {
rawName: returnType.rawName,
...(returnType.declaredSpelling !== undefined
? { declaredSpelling: returnType.declaredSpelling }
: {}),
declaredAtScope: assignment.inScope,
source: 'assignment-inferred',
});
updated++;
}
}
return updated;
}
import { isValueDefinitionLabel } from '../../utils/ast-helpers.js';
import { TransitionalScopeTree } from '../../../../storage/scope-index-store.js';
import { forceGc } from '../../../../storage/parsedfile-store.js';
@ -551,7 +605,6 @@ export function runScopeResolution(
const undecidedSatisfaction: UndecidedSatisfaction[] = [];
const recordResolutionOutcome: ResolutionOutcomeRecorder = (outcome) => {
resolutionOutcomes.push(outcome);
input.recordResolutionOutcome?.(outcome);
};
const PROF = process.env.PROF_SCOPE_RESOLUTION === '1';
const tStart = PROF ? process.hrtime.bigint() : 0n;
@ -638,7 +691,10 @@ export function runScopeResolution(
'sr-extract-end',
`lang=${provider.language} parsedFiles=${parsedFiles.length} preExtractedHits=${preExtractedHits} skipped=${filesSkipped}`,
);
provider.populateWorkspaceOwners?.(parsedFiles, { fileContents: getFileContents() });
provider.populateWorkspaceOwners?.(parsedFiles, {
fileContents: getFileContents(),
resolutionConfig: input.resolutionConfig,
});
provider.populateWorkspaceReferences?.(parsedFiles, {
fileContents: getFileContents(),
treeCache,
@ -844,7 +900,9 @@ export function runScopeResolution(
// views that delegate to it (out-of-core scope index) — the index pins no Scope objects, so the
// disk seal can reclaim them. Byte-identical: the view returns the same Scope
// the resident tree holds (or a value-identical revived one in disk mode).
const workspaceIndex = buildWorkspaceResolutionIndex(parsedFiles, indexes.scopeTree);
const workspaceIndex = buildWorkspaceResolutionIndex(parsedFiles, indexes.scopeTree, {
stripTypePreservingDecoration: provider.stripTypePreservingDecoration,
});
logHeapProbe('sr-post-workspaceIndex', `lang=${provider.language}`);
// Cross-file implicit-namespace visibility (C#). Must run before
@ -1011,6 +1069,12 @@ export function runScopeResolution(
const deferredIndirectCollection = collectDeferredIndirectCollection(emitParsedFiles, indexes);
const deferredIndirectSites = deferredIndirectCollection.sites;
const callableArgumentSites = new Set<string>();
const callResultAssignmentSites = new Set<string>();
for (const parsed of emitParsedFiles) {
for (const site of parsed.callResultAssignmentSites ?? []) {
callResultAssignmentSites.add(callableFlowSiteKey(parsed.filePath, site.callSite));
}
}
if (input.pdg !== true && deferredIndirectSites.size > 0) {
for (const parsed of emitParsedFiles) {
for (const site of parsed.callableFlowSites ?? []) {
@ -1025,11 +1089,14 @@ export function runScopeResolution(
// propagation. Populated below at every CALLS emit path before dedup; the CFG
// join still consumes it only inside the `input.pdg` block.
const calleeIdAccumulator: CalleeIdAccumulator | undefined =
input.pdg === true || deferredIndirectSites.size > 0
input.pdg === true || deferredIndirectSites.size > 0 || callResultAssignmentSites.size > 0
? createCalleeIdAccumulator(
input.pdg === true
? undefined
: (filePath, line, col) => callableArgumentSites.has(`${filePath}:${line}:${col}`),
: (filePath, line, col) => {
const key = `${filePath}:${line}:${col}`;
return callableArgumentSites.has(key) || callResultAssignmentSites.has(key);
},
)
: undefined;
const receiverBound = callableFlowOnly
@ -1060,7 +1127,7 @@ export function runScopeResolution(
heritageTypeArguments,
},
);
const receiverExtras = receiverBound.emitted;
let receiverExtras = receiverBound.emitted;
if (receiverBound.dispatchFanoutSkipped > 0) {
// Never drop dispatch coverage silently (#2829) — same contract as the
// property-dispatch cap below. An interface member over the cap loses real
@ -1112,6 +1179,7 @@ export function runScopeResolution(
isFileLocalDef: provider.isFileLocalDef,
isBuiltInName: provider.languageProvider.isBuiltInName,
freeCallsRequireInstanceOwnership: provider.freeCallsRequireInstanceOwnership === true,
implicitThisWalksMro: provider.implicitThisWalksMro === true,
isCallableVisibleFromCaller: provider.isCallableVisibleFromCaller,
resolveAdlCandidates: provider.resolveAdlCandidates,
resolveQualifiedFreeCall: provider.resolveQualifiedFreeCall,
@ -1123,6 +1191,63 @@ export function runScopeResolution(
skipSites: deferredIndirectSites,
},
);
const replayedCallResultBindings =
callableFlowOnly || calleeIdAccumulator === undefined
? 0
: applyPreciseCallResultBindings(
emitParsedFiles,
indexes,
workspaceIndex,
calleeIdAccumulator,
postHeritageNodeLookup,
);
if (replayedCallResultBindings > 0) {
const handledBeforeReplay = new Set(handledSites);
const replayedReceiverBound = emitReceiverBoundCalls(
graph,
indexes,
emitParsedFiles,
postHeritageNodeLookup,
handledSites,
provider,
workspaceIndex,
readonlyModel,
{
calleeIdSink: calleeIdAccumulator,
isBuiltInName: provider.languageProvider.isBuiltInName,
heritageTypeArguments,
},
);
receiverExtras += replayedReceiverBound.emitted;
if (replayedReceiverBound.dispatchFanoutSkipped > 0) {
logger.warn(
{
lang: provider.language,
dispatchFanoutSkipped: replayedReceiverBound.dispatchFanoutSkipped,
dispatchFanoutSkippedNames: replayedReceiverBound.dispatchFanoutSkippedNames,
fanoutCap: MAX_INTERFACE_DISPATCH_FANOUT,
replay: true,
},
'interface-dispatch: members over the fan-out cap dropped implementors (their CALLS edges were not emitted)',
);
}
const resolvedOnReplay = new Set<string>();
for (const key of handledSites) {
if (!handledBeforeReplay.has(key)) resolvedOnReplay.add(key);
}
if (resolvedOnReplay.size > 0) {
for (let i = resolutionOutcomes.length - 1; i >= 0; i--) {
const outcome = resolutionOutcomes[i];
if (
outcome.kind === 'suppressed' &&
outcome.reason === 'receiver-unresolved' &&
resolvedOnReplay.has(callableFlowSiteKey(outcome.filePath, outcome.range))
) {
resolutionOutcomes.splice(i, 1);
}
}
}
}
const referenceSkipSites = new Set(handledSites);
for (const key of deferredIndirectSites) referenceSkipSites.add(key);
const { emitted, skipped } = callableFlowOnly
@ -1688,6 +1813,8 @@ export function runScopeResolution(
logHeapProbe('sr-end', `lang=${provider.language} parsedFiles=${parsedFiles.length}`);
for (const outcome of resolutionOutcomes) input.recordResolutionOutcome?.(outcome);
return {
filesProcessed: parsedFiles.length,
filesSkipped,

View file

@ -13,7 +13,7 @@
* on `SemanticModel`, and for the builder itself.
*/
import type { Scope, ScopeId, SymbolDefinition } from 'gitnexus-shared';
import type { Scope, ScopeId, SymbolDefinition, TypeRef } from 'gitnexus-shared';
export interface WorkspaceResolutionIndex {
/** Class def `nodeId` → that class's `Scope`. */
@ -36,4 +36,10 @@ export interface WorkspaceResolutionIndex {
* killer). "First module-local callable in `moduleScopeByFile` order" is the
* exact semantics the old scan returned, so it is byte-identical. */
readonly exportedCallableByName: ReadonlyMap<string, SymbolDefinition>;
/** Exact callable def `nodeId` → its declared return type binding.
* Built from definitions owned by each callable's Function scope, so methods
* declared in extension/partial scopes remain addressable even when those
* scopes deliberately own no separate class-like definition. */
readonly declaredReturnTypeByCallableId: ReadonlyMap<string, TypeRef>;
}

View file

@ -39,7 +39,14 @@
* Build cost is O(totalScopes). Read-only after construction.
*/
import type { ParsedFile, Scope, ScopeId, ScopeTree, SymbolDefinition } from 'gitnexus-shared';
import type {
ParsedFile,
Scope,
ScopeId,
ScopeTree,
SymbolDefinition,
TypeRef,
} from 'gitnexus-shared';
import type { WorkspaceResolutionIndex } from './workspace-index-types.js';
import { isClassLike } from './scope/walkers.js';
@ -107,11 +114,15 @@ class ScopeByKeyView<K> implements ReadonlyMap<K, Scope> {
export function buildWorkspaceResolutionIndex(
parsedFiles: readonly ParsedFile[],
scopeTree?: ScopeTree,
options?: {
readonly stripTypePreservingDecoration?: (typeName: string) => string | undefined;
},
): WorkspaceResolutionIndex {
const classScopeIdByDefId = new Map<string, ScopeId>();
const classScopeIdToDefId = new Map<ScopeId, string>();
const moduleScopeIdByFile = new Map<string, ScopeId>();
const exportedCallableByName = new Map<string, SymbolDefinition>();
const declaredReturnTypeByCallableId = new Map<string, TypeRef>();
// Back-compat (no scopeTree): keep the direct Scope-object maps.
const classScopeByDefIdDirect = scopeTree === undefined ? new Map<string, Scope>() : undefined;
const moduleScopeByFileDirect = scopeTree === undefined ? new Map<string, Scope>() : undefined;
@ -138,6 +149,24 @@ export function buildWorkspaceResolutionIndex(
}
for (const scope of parsed.scopes) {
if (scope.kind === 'Function' && scope.parent !== null) {
for (const def of scope.ownedDefs) {
if (def.type !== 'Function' && def.type !== 'Method' && def.type !== 'Constructor') {
continue;
}
if (def.returnType !== undefined) {
const stripped = options?.stripTypePreservingDecoration?.(def.returnType);
const rawName = stripped ?? def.returnType;
declaredReturnTypeByCallableId.set(def.nodeId, {
rawName,
...(rawName !== def.returnType ? { declaredSpelling: def.returnType } : {}),
declaredAtScope: scope.id,
source: 'return-annotation',
});
continue;
}
}
}
if (scope.kind !== 'Class') continue;
const cd = scope.ownedDefs.find((d) => isClassLike(d.type));
if (cd !== undefined) {
@ -157,5 +186,11 @@ export function buildWorkspaceResolutionIndex(
? moduleScopeByFileDirect!
: new ScopeByKeyView(moduleScopeIdByFile, scopeTree);
return { classScopeByDefId, classScopeIdToDefId, moduleScopeByFile, exportedCallableByName };
return {
classScopeByDefId,
classScopeIdToDefId,
moduleScopeByFile,
exportedCallableByName,
declaredReturnTypeByCallableId,
};
}

View file

@ -769,7 +769,9 @@ import { copyV8CacheIfPresent, tryLoadV8Cache, writeV8CacheFile } from './v8-sid
// v101 (#3294 review): Rust bare-keyword glob imports retain crate/self/super
// instead of an empty target path; restricted pub(...) imports are no longer
// captured as unrestricted reexports. Re-extract both facts on warm indexes.
const SCHEMA_BUMP = 101;
// v102: ParsedFile gained callResultAssignmentSites; old durable shards do
// not carry the exact assignment identity required by return-type replay.
const SCHEMA_BUMP = 102;
const GITNEXUS_PKG_VERSION = (() => {
try {
// package.json sits at gitnexus/package.json — two levels up from

View file

@ -53,6 +53,7 @@ import path from 'node:path';
import v8 from 'node:v8';
import vm from 'node:vm';
import type {
CallResultAssignmentSite,
CallableFlowSite,
ParsedFile,
ReferenceSite,
@ -352,20 +353,26 @@ export const loadParsedFilesForPaths = async (
rejectedFiles++;
continue;
}
const assignments = sanitizeCallResultAssignmentSites(pf.callResultAssignmentSites);
if (assignments === undefined) {
rejectedFiles++;
continue;
}
const chains = sanitizeReceiverChains(pf.referenceSites);
if (chains === undefined) {
rejectedFiles++;
continue;
}
if (flow.dropped === 0 && chains.dropped === 0) {
if (flow.dropped === 0 && assignments.dropped === 0 && chains.dropped === 0) {
out.set(pf.filePath, pf);
} else {
droppedSites += flow.dropped;
droppedSites += flow.dropped + assignments.dropped;
droppedChains += chains.dropped;
filesWithDroppedSites++;
out.set(pf.filePath, {
...pf,
...(flow.dropped === 0 ? {} : { callableFlowSites: flow.sites }),
...(assignments.dropped === 0 ? {} : { callResultAssignmentSites: assignments.sites }),
...(chains.dropped === 0 ? {} : { referenceSites: chains.sites }),
});
}
@ -401,6 +408,19 @@ function sanitizeCallableFlowSites(
return { sites, dropped: value.length - sites.length };
}
function sanitizeCallResultAssignmentSites(
value: unknown,
): { sites: readonly CallResultAssignmentSite[] | undefined; dropped: number } | undefined {
if (value === undefined) return { sites: undefined, dropped: 0 };
if (!Array.isArray(value)) return undefined;
const bounded =
value.length > MAX_CALLABLE_FLOW_SITES_PER_FILE
? value.slice(0, MAX_CALLABLE_FLOW_SITES_PER_FILE)
: value;
const sites = bounded.filter(isValidCallResultAssignmentSite);
return { sites, dropped: value.length - sites.length };
}
/**
* Untrusted-boundary handling for the compact `receiverChain` on a reference site.
*
@ -503,6 +523,15 @@ function isValidCallableFlowSite(value: unknown): value is CallableFlowSite {
}
}
function isValidCallResultAssignmentSite(value: unknown): value is CallResultAssignmentSite {
return (
isRecord(value) &&
isValidRange(value.callSite) &&
isBoundedString(value.inScope) &&
isBoundedString(value.lhs)
);
}
function isValidOperand(value: unknown): boolean {
if (!isRecord(value)) return false;
return (

View file

@ -0,0 +1,13 @@
struct OtherStore { let value: Int }
struct OtherScenario {
private func makeStore(currentValue: Int) -> OtherStore { OtherStore(value: currentValue) }
}
enum OtherValue {
private static func makeValue(year: Int, month: Int, day: Int) -> Int { -1 }
}
enum OtherItems {
private static func insertItem(into store: Store) -> String { "decoy" }
}

View file

@ -0,0 +1,8 @@
struct Scenario: ScenarioSupport {
func run() -> String {
let store = makeStore()
let value = makeValue(year: 2, month: 3, day: 4)
let item = insertItem(into: store, count: 1)
return "\(store.worker.execute()):\(value):\(item)"
}
}

View file

@ -0,0 +1,13 @@
protocol ScenarioSupport {}
struct Worker {
func execute() -> String { "worker" }
}
struct Store { let worker: Worker }
extension ScenarioSupport {
func makeStore(observer: Int? = nil) -> Store { Store(worker: Worker()) }
func makeValue(year: Int, month: Int, day: Int) -> Int { year + month + day }
func insertItem(into store: Store, count: Int = 1) -> String { "item" }
}

View file

@ -1,31 +1,31 @@
{
"swift-abstract-dispatch/Sources/App.swift": {
"captureGroups": 10,
"digest": "0ec4bb27184ce68dca2ef6bef58c23353b363be9aa483d0bbea5321bccf85ae2"
"captureGroups": 12,
"digest": "29e6acb3afba30eca11eb947812e834196a01dceac7eff131710405b9f5963fa"
},
"swift-abstract-dispatch/Sources/Repository.swift": {
"captureGroups": 26,
"digest": "d8a8c59b7236d6b25a063b8584637d5c89a86cf022776272f80aeb4f1e4826b9"
"digest": "6978cdb9fc70bdba0b640d547e2fb0c4e21c96463303e91612ef30ba1597b3c4"
},
"swift-await-try/App.swift": {
"captureGroups": 13,
"digest": "7ba2042b0cb0a6e5c5d459dc460c509ace477b0ce97bec0cc8aaf981f320e69a"
"captureGroups": 15,
"digest": "6d3bef6d2c98dfaf64c6cb728a54aad87efcc16d7a8ce5aeb1cc2e1207b4d1a8"
},
"swift-await-try/Models.swift": {
"captureGroups": 21,
"digest": "df2a06182275e023c6f40e978f60a0ff933fccc711d7c15f4a2434e8e282a653"
"digest": "c00efbc5b57cce03a5847b304f0956a424ccc345309c8907cd12e5c32449ced1"
},
"swift-call-result-binding/App.swift": {
"captureGroups": 7,
"digest": "979534a46ee420a7a5dfd6b029c9fe5d67ca1b00f3bdc5e8977d72a1c316b75d"
"captureGroups": 8,
"digest": "ae788e37585c57c13bdb29cce48a0fbd1bcdbfa91ba415bdbc675945e500919d"
},
"swift-call-result-binding/Models.swift": {
"captureGroups": 15,
"digest": "d312b4003cbe7f3eec8e1b6f924b0e1afb7023ca086ec26ef0ad69078cc67925"
"digest": "f192ae885ea3de848f3b93acf7672d91b0118800cb0c6513628a6c09c26fadbf"
},
"swift-child-extends-parent/Sources/App.swift": {
"captureGroups": 10,
"digest": "9532b3989cd104dd468deb8d671e727cdf944ebb858e002072875519a014e968"
"captureGroups": 11,
"digest": "4841a7a147f5cac64ed70af845534845bf34701af4bc55a6a9f02fb09ad4b716"
},
"swift-child-extends-parent/Sources/Child.swift": {
"captureGroups": 4,
@ -33,55 +33,55 @@
},
"swift-child-extends-parent/Sources/Parent.swift": {
"captureGroups": 7,
"digest": "09d26410736d5ca5ec8dfe598212e1eefc64075bc2199fda170bb08a478392a4"
"digest": "705670271274ef3f484a4974aba743d8871c8e0ba41afd8bcf07a48225c12880"
},
"swift-class-func-receiver/Service.swift": {
"captureGroups": 27,
"digest": "5d7e5b278bdc94cc2014ebf2f8c975afb10aa80bc2e1bd62ec5559a102fc8e41"
},
"swift-constructor-fallback/App.swift": {
"captureGroups": 7,
"digest": "782cf85fcfe24b2baea6226f09226d23adc2bab591380bd33600cfe8509dbb83"
"captureGroups": 8,
"digest": "c5b2e7f8ea76636a45d46ecee6b44f0687fc6fe2f983bab0d98f8ce2a5c16828"
},
"swift-constructor-fallback/Service.swift": {
"captureGroups": 7,
"digest": "5744bba4c9b821b2264dea36de3e864723f8ca1be9face21ec9de55117a9e7a5"
"digest": "083dd54d6999b18bd96ee51503d5b63957e49f981199022d27112cf9478f89e0"
},
"swift-constructor-type-inference/Models/Repo.swift": {
"captureGroups": 15,
"digest": "e8ea96d49fc2113e29b88961686e39472b92481e929b3f887d53b65201e2e7bb"
"digest": "e5f29bb46ce39f7fcf98a53378ca00825134350afb6c81dd9fe4ff07e1201348"
},
"swift-constructor-type-inference/Models/User.swift": {
"captureGroups": 15,
"digest": "fe1039891719c640bfa7d362040460addcf744eed1350521cbcc7c9399395087"
"digest": "349b1dc04ef00d74058c7e8e7431cd19529c77ffeac5dac76ee7fe5a4ff39f53"
},
"swift-constructor-type-inference/Services/App.swift": {
"captureGroups": 12,
"digest": "a9c40a2dcb51c6e6a9adbaa869183eed90a81302729d4d7a0c300084ea2978e1"
"captureGroups": 14,
"digest": "54cf81184c5017a96c983ee6c5e0e46efa99b1fd4eb67a77d911a5303094173a"
},
"swift-enum-members/Direction.swift": {
"captureGroups": 18,
"digest": "bd0bf779b08305fcd7dfadfcad45319123393aadc17044784314a7b34cb9e6ae"
"digest": "9eb1209d4e89040515e21c0efa17490c58232aa8f5f853ca210e6c1e0470d4cc"
},
"swift-export-visibility/App.swift": {
"captureGroups": 10,
"digest": "6be49190e8a120ed1c3c3812afaa717f60fc12dae82d4b1b7158a358c53ea75a"
"captureGroups": 11,
"digest": "a0ef5984650c2220865999fba38d0803533f1c41d9941031d2b8c25e894ae84f"
},
"swift-export-visibility/Visible.swift": {
"captureGroups": 15,
"digest": "c8515702b94de2f9e3ad009daec61bad6f9328fb39796e57ec03348438446c4c"
"digest": "a0ef7c53a9cc19836afbe94c9bd5f32cabf1ce2756bee4e36267b5a64d1b66f0"
},
"swift-extension-dedup/App.swift": {
"captureGroups": 7,
"digest": "f7062b53f33ec548a2ba5836ef5507b4c662d74ac4935dc654e6f95081f6fcbd"
"captureGroups": 8,
"digest": "4c9583519283afe6f7b80261cc2105781b86ea90bae69882468a71276bbe162c"
},
"swift-extension-dedup/Product.swift": {
"captureGroups": 15,
"digest": "c0f08c41829db294b2c89876a329cfffd7f3fb0a4b2bca315ae867a1d263a068"
"digest": "aaa046017c54533f938ec03669a80ef4b541fd32dad968ec7a339a6438f860b3"
},
"swift-extension-dedup/ProductExtensions.swift": {
"captureGroups": 8,
"digest": "228f2f32494a51fb371c754d9ae11a94e7181a14d875a339c631e26f82df542e"
"digest": "faa5f45d1bbad8478902814d100d776681b2e9015c7bfd519b432effdfb6f915"
},
"swift-field-types/App.swift": {
"captureGroups": 7,
@ -89,7 +89,7 @@
},
"swift-field-types/Models.swift": {
"captureGroups": 20,
"digest": "cad215dd5ce4d0c93056ec5600663b8d17eebbefa06d09675fb64392196531ab"
"digest": "cb40d6eee9315b77bb6f1b1f82644a3636e7ef9aa30cf5edddd55d845b832486"
},
"swift-for-loop-inference/App.swift": {
"captureGroups": 5,
@ -105,39 +105,39 @@
},
"swift-if-let-guard-let/Models.swift": {
"captureGroups": 19,
"digest": "51d6986620c07de553b2c8d848d908899a96c7174a7ae56bf68c7de930c8d5c5"
"digest": "9cf6d8e36976de137d378e2822e0883c7c33a869bc6615b632ee1b7433fde89e"
},
"swift-implicit-imports/App.swift": {
"captureGroups": 7,
"digest": "5a423f851ee81956f48209e618c71668394e2a7312d812656a7f326233bc1387"
"captureGroups": 8,
"digest": "c2a0718ef2ef0f686a36c247bc7a37a64dbfc38f49c400dcd41462050a2e75f4"
},
"swift-implicit-imports/Models.swift": {
"captureGroups": 7,
"digest": "995e604bfd03b42eaeabff130fd1671e524832c64ee52f01cd193a88283d3764"
"digest": "aff5d14d229b15b0806531ebd6db27600b358a6a6fd57a481893e702e3839eb7"
},
"swift-init-cross-file/User.swift": {
"captureGroups": 18,
"digest": "13d60237fb85ddeba695f3e5f0a202c327309bf159a04e696531c419c42e8cf1"
"digest": "005c808719efd792c7ee8f73b863047db27ba57dd7c3c94e0e550186c4274c4e"
},
"swift-init-cross-file/main.swift": {
"captureGroups": 8,
"digest": "3007e4849cf77cc6cb360a1dbf70abec414a408076020f4730d01b8a80c1ddd3"
"captureGroups": 9,
"digest": "c4d442b67247406de3b1158b958f6f3e294f11c0bbb360ed32489582769a1187"
},
"swift-member-write-access/App.swift": {
"captureGroups": 14,
"digest": "f3c28a41c270cea237598bd20c688ba6ab791e3701e85c66bfe3aff7ecfb1ec8"
"digest": "7be68b7e510fefac99c94a4ebab3eb96556a0e587155b6c82b7602705e270987"
},
"swift-member-write-access/Models.swift": {
"captureGroups": 26,
"digest": "376359897f8706015f702b2f3346ad4ca4e78e9f11ae6425746ae57116c2cc51"
"digest": "0eb01f35af949541bf63f781c0d23d2b890a09a858575d2d0922093657bb7661"
},
"swift-method-enrichment/Sources/Animal.swift": {
"captureGroups": 24,
"digest": "48bd53d1e69d4a48569985296f5aa44d4b41ff474566445f4e3d33390165c84d"
"digest": "49eec3df81637274541eb67fd2a640d22ec2166a17854d3f7be179294eee1386"
},
"swift-method-enrichment/Sources/App.swift": {
"captureGroups": 10,
"digest": "906c1f026eb183306a19eff5fafd888fc8982434e2ab07bc886100b99653359b"
"captureGroups": 13,
"digest": "af60edc088ece4298751041cd8cb3fdf3c2a3c03f14f78fcead821b275260f7c"
},
"swift-multi-if-let/App.swift": {
"captureGroups": 17,
@ -145,19 +145,19 @@
},
"swift-multi-if-let/Models.swift": {
"captureGroups": 24,
"digest": "e6add0cb3679cb2800094e41d4afc66c5d714c19ffefc38edfb5686ed9d46d51"
"digest": "b681c53b823b275dd517d29244b8f1b70094e69159d9e49193b6d9fd117385cc"
},
"swift-multidir-target/Package.swift": {
"captureGroups": 5,
"digest": "7c27b1db8b34bf3e81963c29f9f51f1226c1012b7a028c6207209914412b9d5f"
"captureGroups": 6,
"digest": "b7401530c522ff647a7a1d958a35314f669d5dfad2b39578fb9866d2f2d76e58"
},
"swift-multidir-target/Sources/Alpha/Core/User.swift": {
"captureGroups": 7,
"digest": "a67cb60787680595b43af3fdd371ad84237e17fa7de928610a8c14e8ac062043"
},
"swift-multidir-target/Sources/Alpha/Entry/App.swift": {
"captureGroups": 7,
"digest": "3f5df4e88a54d05032cb00c999551cb4f41e0782616ec0cdbf353f3b80a4404d"
"captureGroups": 8,
"digest": "6670c64ae61885694d980ab798e5bab0664bf6b95727ce8a70b08749790b8052"
},
"swift-multidir-target/Sources/Beta/Core/User.swift": {
"captureGroups": 7,
@ -168,8 +168,8 @@
"digest": "356dcd75eb93636607d406e26ce0e62dfa1fe9c78dcd1f950adca304a14d3ad1"
},
"swift-multifolder-nopackage/Services/App.swift": {
"captureGroups": 7,
"digest": "7ed3ccfa724fd58d3c9e10a176101364f2a01f20a7a87f9e3d76b715e4a307d8"
"captureGroups": 8,
"digest": "469f895fc72297a08eb0c7ddbf7fdbb812c9296076b4797cd9262e2b7ab59a94"
},
"swift-nested-extension/Extension.swift": {
"captureGroups": 7,
@ -180,28 +180,40 @@
"digest": "2738a4d77473a2641166b95bc1107f99afca55e9b9d4186eb102e053d378d8ab"
},
"swift-overload-dispatch/App.swift": {
"captureGroups": 7,
"digest": "db8515d0d61419e827767ced61b6bb1949e672b8df11c35cc96882d60f007e07"
"captureGroups": 8,
"digest": "da644f9163f61ef3ec0a000d31dd93b5f0dd11b33def9d96ffe425ff4288b922"
},
"swift-overload-dispatch/Repository.swift": {
"captureGroups": 11,
"digest": "07c6509becf6ae01ae336c53ddeeff38b486bea4a7facad0edf129eb67401408"
"digest": "0dadccb34bc2cc5150bc7d2ac878c2d8f6e1526738e5b5a6301161ff2fe6ae3f"
},
"swift-overload-dispatch/SqlRepository.swift": {
"captureGroups": 28,
"digest": "59d11755ba6796355751608bbf74bd03b99edd6c601c51dcd3e46ff33a4af659"
"digest": "891d106fd673247c89ffbcaf7fe4f6103e2ed79cac09bcf5c3933385c77c287f"
},
"swift-parent-resolution/Sources/Models/BaseModel.swift": {
"captureGroups": 7,
"digest": "0d9056d5dbd480f04ad915c628c81a71e9c1b4ec8981d8027d3a4edcd0f06427"
"digest": "0dbf9f9aff651e5231d87b31d74d0c9d37b30ad6ecdf9e1913cb0da3539bbe00"
},
"swift-parent-resolution/Sources/Models/Serializable.swift": {
"captureGroups": 6,
"digest": "502ce9136303235cb8e1cdd459afeb4bb76c658d186c63731a3dbb3c42fadac9"
"digest": "5551bfbb93cacb230bb635880af1d87fcaffdff6c54173ff1db5aa84079cef36"
},
"swift-parent-resolution/Sources/Models/User.swift": {
"captureGroups": 10,
"digest": "9bc07345adb74d5e2b4711354175825ef00918dbd61785e472d091f14567f9d5"
"digest": "014f2ea08b5a45692b8fbd2dc6cc3e16004908fe6aa229535f59c59c7a4c6a93"
},
"swift-protocol-extension-implicit-self/AUnrelated.swift": {
"captureGroups": 33,
"digest": "2df68574e940bc9d384517dc8ab845525e05a0cd8612973360e4c5c2619622dc"
},
"swift-protocol-extension-implicit-self/Scenario.swift": {
"captureGroups": 23,
"digest": "e0713e3117f5c668b2a96fde7c1e62457e433b9072b8ed4ad9b96af501f0cf39"
},
"swift-protocol-extension-implicit-self/Support.swift": {
"captureGroups": 41,
"digest": "60b645833f81961448817fad0c4c5431b0cb2d19e5d37aa711a65aa600e7438d"
},
"swift-protocol-property/Repository.swift": {
"captureGroups": 7,
@ -213,34 +225,34 @@
},
"swift-qualified-base/Sources/Outer.swift": {
"captureGroups": 9,
"digest": "1ff59349dc4d45a5e1f6fdebf19caf65427d3904eddbc50efeb0879e31574a17"
"digest": "0c6af8f37f15553ed2d6199689994606ae580026e7a608d534cea5834021c89d"
},
"swift-return-type-inference/App.swift": {
"captureGroups": 21,
"digest": "ec0a14090e0240bdd52a955cf7c95c2bc994085ca1482911dd882c5cdaa17c81"
"captureGroups": 23,
"digest": "6e342715fd1d2fa576a713fdf3bdd4f1367566182442b8398f6b35b861946a8d"
},
"swift-return-type-inference/Models.swift": {
"captureGroups": 29,
"digest": "bc539da3c97ac77543b7a1cce457c6ec0f8072c1e5861bfc027cf8fe8f4b541c"
"digest": "1616d99b6c529274c0fe33645b58c69a507162353de96694d90d7539e6c69569"
},
"swift-return-type/App.swift": {
"captureGroups": 7,
"digest": "979534a46ee420a7a5dfd6b029c9fe5d67ca1b00f3bdc5e8977d72a1c316b75d"
"captureGroups": 8,
"digest": "ae788e37585c57c13bdb29cce48a0fbd1bcdbfa91ba415bdbc675945e500919d"
},
"swift-return-type/Models.swift": {
"captureGroups": 21,
"digest": "dc230a5f6085165fb717632f62b65fd3c99825b5fcc7a3a597a8d9143627729e"
"digest": "f3daaadcce524d8c44ef4077bb858a1124e3f9ef9525d5c84fb1288f79e97c4a"
},
"swift-self-this-resolution/Sources/Models/Repo.swift": {
"captureGroups": 7,
"digest": "61fe84aa9826726ee02bce54c62507cc4f27e56e9be2d57c10f55dd8043e13d9"
"digest": "44f6a8907accee7b3dfc6d35de6a70d3e8a154e1bc602f8545aec3bcd2f9af9c"
},
"swift-self-this-resolution/Sources/Models/User.swift": {
"captureGroups": 11,
"digest": "1841837c805e19746138dbb80ccdfccbce483d68d1e7ea08d92daec255cfbdc7"
"digest": "133a8a1186742b19b7408703262fed82ebdf2aa48b9728ae78aa3b26b5b06428"
},
"synthetic:dao-20": {
"captureGroups": 361,
"digest": "aa1747e2f297f5a5a71bd0361273f5505adf93db3d4a2ae3111916bc109c1c7b"
"digest": "56dde1e182164a4ad86de0254e7b683596d9b0340ec941fc09cdff598f16c6dc"
}
}

View file

@ -116,19 +116,7 @@ describe.skipIf(!swiftAvailable)('a function-local callable keeps its own node (
});
});
/**
* KNOWN GAP, pinned so it cannot be mistaken for part of the fix above.
*
* `helper(x, x)` inside `run` still targets the one-argument METHOD instead of
* the two-argument local. That is decided upstream of the graph bridge: the
* free-call binding hands `emitFreeCallFallback` the class-member def
* (`def:src/app.swift#5:4:Method:helper`), never the local's
* (`def:src/app.swift#9:8:Method:helper`), so no def→node mapping can correct
* it — both defs carry the same `qualifiedName` and the same label, and the
* binding walk picks the member. Recorded here rather than fixed because it
* lives in the scope walk, not in `resolveDefGraphId`.
*/
it('KNOWN GAP: the call to the local still binds to the same-named method', () => {
expect(targetsFrom(RUN)).toEqual([METHOD_HELPER]);
it('binds the call in run to the function-local rather than the same-named method', () => {
expect(targetsFrom(RUN)).toEqual([LOCAL_HELPER]);
});
});

View file

@ -305,6 +305,52 @@ describe.skipIf(!swiftAvailable)('Swift extension deduplication', () => {
});
});
// ---------------------------------------------------------------------------
// Protocol-extension implicit self (issue #3273): a conforming type may call
// default implementation methods without an explicit receiver. Resolution
// must traverse the protocol's extension surface instead of falling back to
// unrelated same-named private methods elsewhere in the module.
// ---------------------------------------------------------------------------
describe.skipIf(!swiftAvailable)('Swift protocol-extension implicit self (#3273)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'swift-protocol-extension-implicit-self'),
() => {},
);
}, 60000);
it('resolves unqualified helper calls to the protocol extension', () => {
const calls = getRelationships(result, 'CALLS').filter((c) => c.source === 'run');
for (const target of ['makeStore', 'makeValue', 'insertItem']) {
const call = calls.find((c) => c.target === target);
expect(call?.targetFilePath).toBe('Support.swift');
}
});
it('keeps unrelated private same-name methods unreachable', () => {
const calls = getRelationships(result, 'CALLS').filter((c) => c.source === 'run');
expect(calls.some((c) => c.targetFilePath === 'AUnrelated.swift')).toBe(false);
});
it('preserves the downstream call through the extension helper return type', () => {
const calls = getRelationships(result, 'CALLS');
const execute = calls.find((c) => c.source === 'run' && c.target === 'execute');
expect(execute?.targetFilePath).toBe('Support.swift');
expect(
result.resolutionOutcomes.some(
(outcome) =>
outcome.kind === 'suppressed' &&
outcome.reason === 'receiver-unresolved' &&
outcome.filePath === 'Scenario.swift' &&
outcome.name === 'execute',
),
).toBe(false);
});
});
// ---------------------------------------------------------------------------
// Constructor fallback: Swift constructors look like free function calls
// (no `new` keyword). The resolver retries with constructor form when

View file

@ -279,8 +279,10 @@ describe('PARSE_CACHE_VERSION', () => {
// Moved 99 -> 100 for #3253: retain absolute Rust import qualifiers.
// Moved 100 -> 101 for #3294 review: retain keyword glob paths and distinguish
// restricted pub(...) imports from unrestricted reexports.
it('pins SCHEMA_BUMP to 101 so concurrent bumps cannot silently collide (#2766, #3015, #3088, #2885, #3128, #2865, #3130, #1432, #3161, #3179, #3219, #3190, #3253)', () => {
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(101);
// Moved 101 -> 102 for #3273: preserve exact call-result assignment facts
// required by post-resolution Swift return-type replay.
it('pins SCHEMA_BUMP to 102 so concurrent bumps cannot silently collide (#2766, #3015, #3088, #2885, #3128, #2865, #3130, #1432, #3161, #3179, #3219, #3190, #3253, #3273)', () => {
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(102);
expect(PARSE_CACHE_BUCKET_COUNT).toBe(128);
// The PREVIOUS version must fail the reuse gate, not merely differ from the
// current one — a hardcoded number outside the conflict hunk rebases cleanly
@ -288,7 +290,7 @@ describe('PARSE_CACHE_VERSION', () => {
// Every nearby historical or in-flight value is rejected.
for (const taken of [
59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81,
82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100,
82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101,
]) {
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(taken);
}

View file

@ -218,6 +218,55 @@ describe('parsedfile-store', () => {
}
});
it('round-trips exact call-result assignment identities', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'pfstore-'));
try {
const pf = makeStoreEntry('Scenario.swift', {
callResultAssignmentSites: [
{
callSite: { startLine: 3, startCol: 14, endLine: 3, endCol: 25 },
inScope: 'scope:run',
lhs: 'store',
},
],
});
await persistParsedFileChunk(dir, 'assignment', [pf]);
const loaded = await loadParsedFilesForPaths(dir, new Set(['Scenario.swift']));
expect(loaded.get('Scenario.swift')?.callResultAssignmentSites).toEqual(
pf.callResultAssignmentSites,
);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it('drops malformed call-result assignments per site and rejects a non-array field', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'pfstore-'));
try {
const mixed = makeStoreEntry('mixed.swift', {
callResultAssignmentSites: [
{
callSite: { startLine: 3, startCol: 14, endLine: 3, endCol: 25 },
inScope: 'scope:run',
lhs: 'store',
},
{ callSite: '3:14', inScope: 'scope:run', lhs: 'poison' },
],
});
const garbage = makeStoreEntry('garbage.swift', {
callResultAssignmentSites: 'not-an-array',
});
await persistParsedFileChunk(dir, 'assignment-invalid', [mixed, garbage]);
const loaded = await loadParsedFilesForPaths(dir, new Set(['mixed.swift', 'garbage.swift']));
expect(loaded.get('mixed.swift')?.callResultAssignmentSites).toHaveLength(1);
expect(loaded.has('garbage.swift')).toBe(false);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it('drops a malformed callable-flow site but retains the file and its other sites (per-site sanitation, #2522)', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'pfstore-'));
try {

View file

@ -47,25 +47,38 @@ const mkClassScope = (): Scope =>
ownedDefs: [],
}) as unknown as Scope;
const mkScopes = (scope: Scope): ScopeResolutionIndexes =>
const mkScopes = (
scope: Scope,
mroByOwner: ReadonlyMap<string, readonly string[]> = new Map(),
): ScopeResolutionIndexes =>
({
scopeTree: {
getScope: (id: ScopeId) => (id === scope.id ? scope : undefined),
},
methodDispatch: {
mroFor: (classDefId: string) => mroByOwner.get(classDefId) ?? [],
},
}) as unknown as ScopeResolutionIndexes;
const mkWorkspaceIndex = (mapping: ReadonlyMap<ScopeId, string>): WorkspaceResolutionIndex =>
const mkWorkspaceIndex = (
mapping: ReadonlyMap<ScopeId, string>,
classScopeByDefId: ReadonlyMap<string, Scope> = new Map(),
): WorkspaceResolutionIndex =>
({
classScopeIdToDefId: mapping,
classScopeByDefId,
}) as unknown as WorkspaceResolutionIndex;
const mkModel = (
overloadsByName: ReadonlyMap<string, readonly SymbolDefinition[]>,
overloadsByOwnerAndName: ReadonlyMap<string, readonly SymbolDefinition[]> = new Map(),
): SemanticModel =>
({
methods: {
lookupAllByOwner: (_classDefId: string, name: string) =>
overloadsByName.get(name) ?? ([] as readonly SymbolDefinition[]),
lookupAllByOwner: (classDefId: string, name: string) =>
overloadsByOwnerAndName.get(`${classDefId}::${name}`) ??
overloadsByName.get(name) ??
([] as readonly SymbolDefinition[]),
},
}) as unknown as SemanticModel;
@ -154,3 +167,238 @@ describe('pickImplicitThisOverload — uniqueness guard (Codex #1497 finding 2)'
expect(result).toBeUndefined();
});
});
describe('pickImplicitThisOverload — inherited implicit-this', () => {
const PROTO_A = 'def:P.swift:A';
const PROTO_B = 'def:P.swift:B';
const site0 = {
inScope: CLASS_SCOPE_ID,
name: 'foo',
arity: 0,
argumentTypes: undefined,
};
it('arity-narrows a singleton on the enclosing type instead of returning it blindly', () => {
const oneArg = mkMethod({
nodeId: 'm:own-1',
parameterCount: 1,
requiredParameterCount: 1,
});
const scopes = mkScopes(mkClassScope());
const workspace = mkWorkspaceIndex(new Map([[CLASS_SCOPE_ID, CLASS_DEF_ID]]));
const model = mkModel(new Map([['foo', [oneArg]]]));
expect(pickImplicitThisOverload(site0, scopes, workspace, model)?.nodeId).toBeUndefined();
});
it('unions inherited owners and arity-narrows instead of taking the first MRO name hit', () => {
const aFoo = mkMethod({
nodeId: 'm:A.foo',
ownerId: PROTO_A,
parameterCount: 1,
requiredParameterCount: 1,
});
const bFoo = mkMethod({
nodeId: 'm:B.foo',
ownerId: PROTO_B,
parameterCount: 0,
requiredParameterCount: 0,
});
const scopes = mkScopes(mkClassScope(), new Map([[CLASS_DEF_ID, [PROTO_A, PROTO_B]]]));
const workspace = mkWorkspaceIndex(new Map([[CLASS_SCOPE_ID, CLASS_DEF_ID]]));
const model = mkModel(
new Map(),
new Map([
[`${CLASS_DEF_ID}::foo`, []],
[`${PROTO_A}::foo`, [aFoo]],
[`${PROTO_B}::foo`, [bFoo]],
]),
);
const result = pickImplicitThisOverload(site0, scopes, workspace, model, {
implicitThisWalksMro: true,
});
expect(result?.nodeId).toBe('m:B.foo');
});
it('picks the nearest inherited override when two MRO ancestors share a signature', () => {
const MID = 'def:Mid.swift:Mid';
const GRAND = 'def:Grand.swift:Grand';
const midFoo = mkMethod({
nodeId: 'm:Mid.foo',
ownerId: MID,
parameterCount: 0,
requiredParameterCount: 0,
});
const grandFoo = mkMethod({
nodeId: 'm:Grand.foo',
ownerId: GRAND,
parameterCount: 0,
requiredParameterCount: 0,
});
const scopes = mkScopes(mkClassScope(), new Map([[CLASS_DEF_ID, [MID, GRAND]]]));
const workspace = mkWorkspaceIndex(new Map([[CLASS_SCOPE_ID, CLASS_DEF_ID]]));
const model = mkModel(
new Map(),
new Map([
[`${CLASS_DEF_ID}::foo`, []],
[`${MID}::foo`, [midFoo]],
[`${GRAND}::foo`, [grandFoo]],
]),
);
const result = pickImplicitThisOverload(site0, scopes, workspace, model, {
implicitThisWalksMro: true,
});
expect(result?.nodeId).toBe('m:Mid.foo');
});
it('prefers a protocol-extension default over the protocol requirement of the same arity', () => {
const requirement = mkMethod({
nodeId: 'm:P.req',
ownerId: PROTO_A,
parameterCount: 0,
requiredParameterCount: 0,
});
const body = mkMethod({
nodeId: 'm:P.ext',
ownerId: PROTO_A,
parameterCount: 0,
requiredParameterCount: 0,
});
const protocolScope = {
id: 'scope:P.swift#1:1-20:1:Class' as ScopeId,
parent: null,
kind: 'Class',
range: { startLine: 1, startCol: 1, endLine: 20, endCol: 1 },
filePath: 'P.swift',
bindings: new Map(),
typeBindings: new Map(),
ownedDefs: [
{
nodeId: PROTO_A,
filePath: 'P.swift',
type: 'Protocol',
qualifiedName: 'A',
} as SymbolDefinition,
requirement,
],
} as unknown as Scope;
const scopes = mkScopes(mkClassScope(), new Map([[CLASS_DEF_ID, [PROTO_A]]]));
const workspace = mkWorkspaceIndex(
new Map([[CLASS_SCOPE_ID, CLASS_DEF_ID]]),
new Map([[PROTO_A, protocolScope]]),
);
const model = mkModel(
new Map(),
new Map([
[`${CLASS_DEF_ID}::foo`, []],
[`${PROTO_A}::foo`, [requirement, body]],
]),
);
const result = pickImplicitThisOverload(site0, scopes, workspace, model, {
implicitThisWalksMro: true,
});
expect(result?.nodeId).toBe('m:P.ext');
});
it('does not prefer a protocol-extension witness over an inherited class member', () => {
const BASE = 'def:Base.swift:Base';
const baseType = {
nodeId: BASE,
filePath: 'Base.swift',
type: 'Class',
qualifiedName: 'Base',
} as SymbolDefinition;
const baseFoo = mkMethod({
nodeId: 'm:Base.foo',
ownerId: BASE,
parameterCount: 0,
requiredParameterCount: 0,
});
const extFoo = mkMethod({
nodeId: 'm:P.ext',
ownerId: PROTO_A,
parameterCount: 0,
requiredParameterCount: 0,
});
const baseScope = {
id: 'scope:Base.swift#1:1-20:1:Class' as ScopeId,
parent: null,
kind: 'Class',
range: { startLine: 1, startCol: 1, endLine: 20, endCol: 1 },
filePath: 'Base.swift',
bindings: new Map(),
typeBindings: new Map(),
ownedDefs: [baseType, baseFoo],
} as unknown as Scope;
const protocolScope = {
id: 'scope:P.swift#1:1-20:1:Class' as ScopeId,
parent: null,
kind: 'Class',
range: { startLine: 1, startCol: 1, endLine: 20, endCol: 1 },
filePath: 'P.swift',
bindings: new Map(),
typeBindings: new Map(),
ownedDefs: [
{
nodeId: PROTO_A,
filePath: 'P.swift',
type: 'Protocol',
qualifiedName: 'A',
} as SymbolDefinition,
],
} as unknown as Scope;
const scopes = mkScopes(mkClassScope(), new Map([[CLASS_DEF_ID, [BASE, PROTO_A]]]));
const workspace = mkWorkspaceIndex(
new Map([[CLASS_SCOPE_ID, CLASS_DEF_ID]]),
new Map([
[BASE, baseScope],
[PROTO_A, protocolScope],
]),
);
const model = mkModel(
new Map(),
new Map([
[`${CLASS_DEF_ID}::foo`, []],
[`${BASE}::foo`, [baseFoo]],
[`${PROTO_A}::foo`, [extFoo]],
]),
);
const result = pickImplicitThisOverload(site0, scopes, workspace, model, {
implicitThisWalksMro: true,
});
expect(result?.nodeId).toBe('m:Base.foo');
});
it('falls through to inherited when the enclosing type only has an arity-incompatible decoy', () => {
const decoy = mkMethod({
nodeId: 'm:own-decoy',
ownerId: CLASS_DEF_ID,
parameterCount: 1,
requiredParameterCount: 1,
});
const inherited = mkMethod({
nodeId: 'm:ext',
ownerId: PROTO_A,
parameterCount: 0,
requiredParameterCount: 0,
});
const scopes = mkScopes(mkClassScope(), new Map([[CLASS_DEF_ID, [PROTO_A]]]));
const workspace = mkWorkspaceIndex(new Map([[CLASS_SCOPE_ID, CLASS_DEF_ID]]));
const model = mkModel(
new Map(),
new Map([
[`${CLASS_DEF_ID}::foo`, [decoy]],
[`${PROTO_A}::foo`, [inherited]],
]),
);
const result = pickImplicitThisOverload(site0, scopes, workspace, model, {
implicitThisWalksMro: true,
});
expect(result?.nodeId).toBe('m:ext');
});
});

View file

@ -261,6 +261,22 @@ describe('Pass 2: declarations + local bindings', () => {
expect(result.localDefs[0]!.type).toBe('Function');
});
it('backfills return types across duplicate declaration captures', () => {
const plain = declMatch('function', 'makeStore', 5, 0, 10, 0);
const annotated = declMatch('function', 'makeStore', 5, 0, 10, 0, {
'@declaration.return-type': cap('@declaration.return-type', 5, 0, 10, 0, 'Store'),
});
const result = extract(
[scopeMatch('module', 1, 0, 100, 0), scopeMatch('function', 5, 0, 10, 0), plain, annotated],
'Support.swift',
mockProvider(),
);
expect(result.localDefs).toHaveLength(2);
expect(new Set(result.localDefs.map((def) => def.nodeId)).size).toBe(1);
expect(result.localDefs.map((def) => def.returnType)).toEqual(['Store', 'Store']);
});
it('preserves a synthetic declaration marker on the definition', () => {
const result = extract(
[
@ -869,6 +885,55 @@ describe('Pass 6: callable-value-flow facts', () => {
});
});
describe('Pass 7: call-result assignment identity', () => {
it('keeps same-name calls isolated by exact call-expression position', () => {
const result = extract(
[
scopeMatch('module', 1, 0, 100, 0),
scopeMatch('function', 10, 0, 40, 0),
{
'@call-result-assignment.call': cap(
'@call-result-assignment.call',
20,
14,
20,
25,
'makeStore()',
),
'@call-result-assignment.lhs': cap('@call-result-assignment.lhs', 20, 6, 20, 11, 'store'),
},
{
'@call-result-assignment.call': cap(
'@call-result-assignment.call',
21,
14,
21,
25,
'makeStore()',
),
'@call-result-assignment.lhs': cap('@call-result-assignment.lhs', 21, 6, 21, 11, 'other'),
},
],
'a.swift',
mockProvider(),
);
const fnScope = result.scopes.find((scope) => scope.kind === 'Function')!;
expect(result.callResultAssignmentSites).toEqual([
{
callSite: { startLine: 20, startCol: 14, endLine: 20, endCol: 25 },
inScope: fnScope.id,
lhs: 'store',
},
{
callSite: { startLine: 21, startCol: 14, endLine: 21, endCol: 25 },
inScope: fnScope.id,
lhs: 'other',
},
]);
});
});
// ─── §End-to-end fixture ──────────────────────────────────────────────────
describe('end-to-end fixture (all 5 passes together)', () => {

View file

@ -0,0 +1,198 @@
/**
* `populateWorkspaceOwners` for Swift must stamp extension members onto a
* unique owner in the same SPM target, skip nested locals, and fail closed
* when `Bar` and `Foo.Bar` both exist.
*/
import { describe, it, expect } from 'vitest';
import { extractParsedFile } from '../../../src/core/ingestion/scope-extractor-bridge.js';
import { swiftScopeResolver } from '../../../src/core/ingestion/languages/swift/scope-resolver.js';
import { isLanguageAvailable } from '../../../src/core/tree-sitter/parser-loader.js';
import { SupportedLanguages } from '../../../src/config/supported-languages.js';
const swiftAvailable = isLanguageAvailable(SupportedLanguages.Swift);
function parseSwift(source: string, filePath: string) {
const parsed = extractParsedFile(swiftScopeResolver.languageProvider, source, filePath, () => {});
if (parsed === undefined) throw new Error('scope extraction failed');
swiftScopeResolver.populateOwners(parsed);
return parsed;
}
function stamp(
files: Parameters<NonNullable<typeof swiftScopeResolver.populateWorkspaceOwners>>[0],
resolutionConfig?: unknown,
) {
swiftScopeResolver.populateWorkspaceOwners?.(files, {
fileContents: new Map(),
resolutionConfig,
});
}
describe.skipIf(!swiftAvailable)('populateSwiftExtensionOwners', () => {
it('stamps protocol-extension methods onto the unique protocol', () => {
const parsed = parseSwift(
`
protocol ScenarioSupport {}
struct Store {}
extension ScenarioSupport {
func makeStore() -> Store { Store() }
}
`,
'Support.swift',
);
const protocol = parsed.localDefs.find((d) => d.qualifiedName === 'ScenarioSupport');
const method = parsed.localDefs.find(
(d) => d.qualifiedName?.split('.').at(-1) === 'makeStore' && d.type === 'Method',
);
expect(protocol).toBeDefined();
expect(method?.ownerId).toBeUndefined();
stamp([parsed]);
expect(method?.ownerId).toBe(protocol?.nodeId);
});
it('does not stamp a nested local onto the enclosing type', () => {
const parsed = parseSwift(
`
class Host {
func helper(_ a: Int) -> Int { a }
func run(_ x: Int) -> Int {
func helper(_ v: Int, _ w: Int) -> Int { v + w }
return helper(x, x)
}
}
`,
'Host.swift',
);
stamp([parsed]);
const twoArg = parsed.localDefs.filter(
(d) => d.type === 'Method' && d.qualifiedName?.endsWith('helper') && d.parameterCount === 2,
);
expect(twoArg).toHaveLength(1);
expect(twoArg[0]?.ownerId).toBeUndefined();
});
it('stamps extension Foo.Bar members onto Foo.Bar when no top-level Bar exists', () => {
const parsed = parseSwift(
`
enum Foo {
struct Bar {}
}
extension Foo.Bar {
func added() {}
}
`,
'Nested.swift',
);
stamp([parsed]);
const added = parsed.localDefs.find((d) => d.qualifiedName?.split('.').at(-1) === 'added');
const bar = parsed.localDefs.find(
(d) => d.qualifiedName === 'Foo.Bar' || d.qualifiedName === 'Bar',
);
expect(added?.ownerId).toBeDefined();
expect(added?.ownerId).toBe(bar?.nodeId);
});
it('fails closed when top-level Bar and nested Foo.Bar both exist', () => {
const parsed = parseSwift(
`
struct Bar {}
enum Foo {
struct Bar {}
}
extension Foo.Bar {
func added() {}
}
`,
'Collide.swift',
);
stamp([parsed]);
const added = parsed.localDefs.find((d) => d.qualifiedName?.split('.').at(-1) === 'added');
expect(added).toBeDefined();
expect(added?.ownerId).toBeUndefined();
});
it('stamps extension members when the extension also declares a nested type', () => {
const parsed = parseSwift(
`
struct Foo {}
extension Foo {
struct Helper {}
func added() {}
}
`,
'NestedInExt.swift',
);
stamp([parsed]);
const added = parsed.localDefs.find((d) => d.qualifiedName?.split('.').at(-1) === 'added');
const foo = parsed.localDefs.find(
(d) => d.qualifiedName === 'Foo' && (d.type === 'Struct' || d.type === 'Class'),
);
expect(added).toBeDefined();
expect(foo).toBeDefined();
expect(added?.ownerId).toBe(foo?.nodeId);
});
it('does not let one target’s Foo own another target’s extension', () => {
const app = parseSwift(
`
protocol Foo {}
extension Foo {
func added() {}
}
`,
'Sources/App/A.swift',
);
const lib = parseSwift(
`
protocol Foo {}
`,
'Sources/Lib/B.swift',
);
stamp([app, lib], {
targets: new Map([
['App', 'Sources/App'],
['Lib', 'Sources/Lib'],
]),
});
const added = app.localDefs.find((d) => d.qualifiedName?.split('.').at(-1) === 'added');
const appFoo = app.localDefs.find((d) => d.qualifiedName === 'Foo');
expect(added).toBeDefined();
expect(appFoo).toBeDefined();
expect(added?.ownerId).toBe(appFoo?.nodeId);
const libFoo = lib.localDefs.find((d) => d.qualifiedName === 'Foo');
expect(added?.ownerId).not.toBe(libFoo?.nodeId);
});
it('stamps a one-line extension whose synthetic class shares the Class start line', () => {
const parsed = parseSwift(
`
struct Foo {}
extension Foo { func added() {} }
`,
'OneLine.swift',
);
const added = parsed.localDefs.find((d) => d.qualifiedName?.split('.').at(-1) === 'added');
const foo = parsed.localDefs.find(
(d) => d.qualifiedName === 'Foo' && (d.type === 'Struct' || d.type === 'Class'),
);
expect(added).toBeDefined();
expect(foo).toBeDefined();
expect(added?.ownerId).toBeUndefined();
stamp([parsed]);
expect(added?.ownerId).toBe(foo?.nodeId);
});
it('stamps a cross-file extension onto the real type, not the synthetic class', () => {
const typeFile = parseSwift('struct Foo {}', 'Foo.swift');
const extFile = parseSwift('extension Foo { func added() {} }', 'Foo+Added.swift');
const added = extFile.localDefs.find((d) => d.qualifiedName?.split('.').at(-1) === 'added');
const foo = typeFile.localDefs.find(
(d) => d.qualifiedName === 'Foo' && (d.type === 'Struct' || d.type === 'Class'),
);
expect(added).toBeDefined();
expect(foo).toBeDefined();
expect(added?.ownerId).toBeUndefined();
stamp([typeFile, extFile]);
expect(added?.ownerId).toBe(foo?.nodeId);
});
});

View file

@ -18,6 +18,9 @@
import { describe, it, expect } from 'vitest';
import { extractParsedFile } from '../../../src/core/ingestion/scope-extractor-bridge.js';
import { pythonScopeResolver } from '../../../src/core/ingestion/languages/python/scope-resolver.js';
import { swiftScopeResolver } from '../../../src/core/ingestion/languages/swift/scope-resolver.js';
import { isLanguageAvailable } from '../../../src/core/tree-sitter/parser-loader.js';
import { SupportedLanguages } from '../../../src/config/supported-languages.js';
import { buildWorkspaceResolutionIndex } from '../../../src/core/ingestion/scope-resolution/workspace-index.js';
import {
findExportedDef,
@ -39,6 +42,14 @@ function parsePython(source: string, filePath: string) {
return parsed;
}
const swiftAvailable = isLanguageAvailable(SupportedLanguages.Swift);
function parseSwift(source: string, filePath: string) {
const parsed = extractParsedFile(swiftScopeResolver.languageProvider, source, filePath, () => {});
if (parsed === undefined) throw new Error('scope extraction failed');
return parsed;
}
describe('WorkspaceResolutionIndex — scope-only maps', () => {
it('exposes classScopeByDefId, classScopeIdToDefId, and moduleScopeByFile', () => {
const parsed = parsePython(
@ -87,6 +98,192 @@ def helper() -> int:
});
});
describe.skipIf(!swiftAvailable)('declaredReturnTypeByCallableId — exact callable identity', () => {
it('keeps same-file methods and parameter names from overwriting callable returns', () => {
const parsed = parseSwift(
`
struct AResult {}
struct BResult {}
struct OtherStore {}
struct Store {}
struct A {
func make() -> AResult { AResult() }
}
struct B {
func make() -> BResult { BResult() }
}
func makeStore(makeStore: OtherStore) -> Store { Store() }
`,
'Collisions.swift',
);
const index = buildWorkspaceResolutionIndex([parsed]);
const callableDefs = parsed.localDefs.filter((def) => def.returnType !== undefined);
expect(
callableDefs
.map((def) => index.declaredReturnTypeByCallableId.get(def.nodeId)?.rawName)
.sort(),
).toEqual(['AResult', 'BResult', 'Store']);
});
it('keeps extension and decoy return types separate despite the same method name', () => {
const extension = parseSwift(
`
protocol ScenarioSupport {}
struct Store {}
extension ScenarioSupport {
func makeStore() -> Store { Store() }
}
`,
'Support.swift',
);
const decoy = parseSwift(
`
struct OtherStore {}
struct OtherScenario {
private func makeStore() -> OtherStore { OtherStore() }
}
`,
'AUnrelated.swift',
);
const index = buildWorkspaceResolutionIndex([extension, decoy]);
const methods = [...extension.localDefs, ...decoy.localDefs].filter(
(def) => def.qualifiedName?.split('.').at(-1) === 'makeStore',
);
expect(methods).toHaveLength(2);
const byFile = new Map(methods.map((def) => [def.filePath, def]));
expect(
index.declaredReturnTypeByCallableId.get(byFile.get('Support.swift')!.nodeId)?.rawName,
).toBe('Store');
expect(
index.declaredReturnTypeByCallableId.get(byFile.get('AUnrelated.swift')!.nodeId)?.rawName,
).toBe('OtherStore');
expect(index.declaredReturnTypeByCallableId.has('makeStore')).toBe(false);
});
it('does not infer a return type from a same-named parameter', () => {
const parsed = parseSwift(
`
struct OtherStore {}
func makeStore(makeStore: OtherStore) {}
`,
'Unannotated.swift',
);
const index = buildWorkspaceResolutionIndex([parsed]);
const makeStore = parsed.localDefs.find(
(def) => def.qualifiedName?.split('.').at(-1) === 'makeStore',
);
expect(makeStore).toBeDefined();
expect(index.declaredReturnTypeByCallableId.has(makeStore!.nodeId)).toBe(false);
});
it('does not peel array return types without a language stripper', () => {
const parsed = parseSwift(
`
struct User {}
func makeUsers() -> [User] { [] }
`,
'Peel.swift',
);
const index = buildWorkspaceResolutionIndex([parsed]);
const makeUsers = parsed.localDefs.find(
(def) => def.qualifiedName?.split('.').at(-1) === 'makeUsers',
);
expect(makeUsers?.returnType).toBeDefined();
expect(index.declaredReturnTypeByCallableId.get(makeUsers!.nodeId)?.rawName).toBe(
makeUsers!.returnType,
);
});
it('applies stripTypePreservingDecoration to optional returns only', () => {
const parsed = parseSwift(
`
struct Store {}
func makeStore() -> Store? { nil }
func makeUsers() -> [User] { [] }
`,
'Optional.swift',
);
const index = buildWorkspaceResolutionIndex([parsed], undefined, {
stripTypePreservingDecoration: (typeName) =>
typeName.trim().endsWith('?') ? typeName.trim().slice(0, -1).trim() : undefined,
});
const byName = new Map(
parsed.localDefs
.filter((def) => def.returnType !== undefined)
.map((def) => [def.qualifiedName?.split('.').at(-1), def]),
);
expect(index.declaredReturnTypeByCallableId.get(byName.get('makeStore')!.nodeId)?.rawName).toBe(
'Store',
);
expect(index.declaredReturnTypeByCallableId.get(byName.get('makeUsers')!.nodeId)?.rawName).toBe(
byName.get('makeUsers')!.returnType,
);
});
});
describe.skipIf(!swiftAvailable)('Swift call-result assignment extraction', () => {
it('aligns each lhs with its own same-name call-resolution anchor', () => {
const parsed = parseSwift(
`
func run() {
let store = makeStore()
let other = makeStore()
}
`,
'Scenario.swift',
);
const assignments = parsed.callResultAssignmentSites ?? [];
expect(assignments.map(({ lhs }) => lhs)).toEqual(['store', 'other']);
expect(
new Set(assignments.map(({ callSite }) => `${callSite.startLine}:${callSite.startCol}`)).size,
).toBe(2);
const callAnchors = parsed.referenceSites
.filter((site) => site.name === 'makeStore')
.map(({ atRange }) => `${atRange.startLine}:${atRange.startCol}`);
expect(assignments.map(({ callSite }) => `${callSite.startLine}:${callSite.startCol}`)).toEqual(
callAnchors,
);
expect(new Set(assignments.map(({ inScope }) => inScope)).size).toBe(1);
});
it('does not emit replay facts for explicitly typed declarations', () => {
const parsed = parseSwift(
`
func run() {
let explicit: Store = makeStore()
}
`,
'Typed.swift',
);
expect(parsed.callResultAssignmentSites).toBeUndefined();
});
it('unwraps await and try expressions to the exact call position', () => {
const parsed = parseSwift(
`
func run() async throws {
let awaited = await makeStore()
let tried = try makeStore()
let triedAwaited = try await makeStore()
}
`,
'Wrapped.swift',
);
const assignments = parsed.callResultAssignmentSites ?? [];
expect(assignments.map(({ lhs }) => lhs)).toEqual(['awaited', 'tried', 'triedAwaited']);
const callAnchors = parsed.referenceSites
.filter((site) => site.name === 'makeStore')
.map(({ atRange }) => `${atRange.startLine}:${atRange.startCol}`);
expect(assignments.map(({ callSite }) => `${callSite.startLine}:${callSite.startCol}`)).toEqual(
callAnchors,
);
});
});
describe('findExportedDef — module-export visibility filter', () => {
it('keeps top-level class and function defs', () => {
const parsed = parsePython(