diff --git a/gitnexus-shared/src/index.ts b/gitnexus-shared/src/index.ts index 7958cfa9d..de49223bf 100644 --- a/gitnexus-shared/src/index.ts +++ b/gitnexus-shared/src/index.ts @@ -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, diff --git a/gitnexus-shared/src/scope-resolution/call-result-assignment-site.ts b/gitnexus-shared/src/scope-resolution/call-result-assignment-site.ts new file mode 100644 index 000000000..1b9347101 --- /dev/null +++ b/gitnexus-shared/src/scope-resolution/call-result-assignment-site.ts @@ -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; +} diff --git a/gitnexus-shared/src/scope-resolution/parsed-file.ts b/gitnexus-shared/src/scope-resolution/parsed-file.ts index db0833967..140193afc 100644 --- a/gitnexus-shared/src/scope-resolution/parsed-file.ts +++ b/gitnexus-shared/src/scope-resolution/parsed-file.ts @@ -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 diff --git a/gitnexus/bench/scope-capture/baselines.json b/gitnexus/bench/scope-capture/baselines.json index 94fc262f6..74e4588e9 100644 --- a/gitnexus/bench/scope-capture/baselines.json +++ b/gitnexus/bench/scope-capture/baselines.json @@ -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", diff --git a/gitnexus/src/core/ingestion/languages/swift/captures.ts b/gitnexus/src/core/ingestion/languages/swift/captures.ts index 736c6e962..56f165b21 100644 --- a/gitnexus/src/core/ingestion/languages/swift/captures.ts +++ b/gitnexus/src/core/ingestion/languages/swift/captures.ts @@ -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 diff --git a/gitnexus/src/core/ingestion/languages/swift/index.ts b/gitnexus/src/core/ingestion/languages/swift/index.ts index 4e4e7ef52..f0bcf5ad6 100644 --- a/gitnexus/src/core/ingestion/languages/swift/index.ts +++ b/gitnexus/src/core/ingestion/languages/swift/index.ts @@ -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'; diff --git a/gitnexus/src/core/ingestion/languages/swift/interpret.ts b/gitnexus/src/core/ingestion/languages/swift/interpret.ts index 5536c4a1b..a12816843 100644 --- a/gitnexus/src/core/ingestion/languages/swift/interpret.ts +++ b/gitnexus/src/core/ingestion/languages/swift/interpret.ts @@ -46,7 +46,7 @@ export function interpretSwiftTypeBinding(captures: CaptureMatch): ParsedTypeBin // `[User]` → User (array sugar) // `Array` / `Optional` → 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(); diff --git a/gitnexus/src/core/ingestion/languages/swift/query.ts b/gitnexus/src/core/ingestion/languages/swift/query.ts index 1d50be2df..805329903 100644 --- a/gitnexus/src/core/ingestion/languages/swift/query.ts +++ b/gitnexus/src/core/ingestion/languages/swift/query.ts @@ -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 diff --git a/gitnexus/src/core/ingestion/languages/swift/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/swift/scope-resolver.ts index 83a8e6ce7..4b8f974c5 100644 --- a/gitnexus/src/core/ingestion/languages/swift/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/swift/scope-resolver.ts @@ -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; 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(); + 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, + prefix: string, +): SymbolDefinition | undefined { + const seen = new Set(); + 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, diff --git a/gitnexus/src/core/ingestion/scope-extractor.ts b/gitnexus/src/core/ingestion/scope-extractor.ts index 898282b5e..22d1f2572 100644 --- a/gitnexus/src/core/ingestion/scope-extractor.ts +++ b/gitnexus/src/core/ingestion/scope-extractor.ts @@ -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 { const topics = new Set(); @@ -323,6 +360,7 @@ function topicsOf(match: CaptureMatch): ReadonlySet { 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, + 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); } diff --git a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts index 71385a41f..5fed0ea15 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts @@ -692,7 +692,10 @@ export interface ScopeResolver { */ readonly populateWorkspaceOwners?: ( parsedFiles: readonly ParsedFile[], - ctx: { readonly fileContents: ReadonlyMap }, + ctx: { + readonly fileContents: ReadonlyMap; + 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 diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts index 3e7c3013c..6693a2bc6 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts @@ -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; @@ -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'), + ); } /** diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts index 58dd19248..27a96d599 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts @@ -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, + workspaceIndex: ReturnType, + calleeIds: CalleeIdAccumulator, + nodeLookup: ReturnType, +): number { + let updated = 0; + const returnTypeByGraphId = new Map(); + 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).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(); + const callResultAssignmentSites = new Set(); + 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(); + 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, diff --git a/gitnexus/src/core/ingestion/scope-resolution/workspace-index-types.ts b/gitnexus/src/core/ingestion/scope-resolution/workspace-index-types.ts index 408e5ca42..012562ce7 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/workspace-index-types.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/workspace-index-types.ts @@ -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; + + /** 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; } diff --git a/gitnexus/src/core/ingestion/scope-resolution/workspace-index.ts b/gitnexus/src/core/ingestion/scope-resolution/workspace-index.ts index 597819f58..683ed0972 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/workspace-index.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/workspace-index.ts @@ -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 implements ReadonlyMap { export function buildWorkspaceResolutionIndex( parsedFiles: readonly ParsedFile[], scopeTree?: ScopeTree, + options?: { + readonly stripTypePreservingDecoration?: (typeName: string) => string | undefined; + }, ): WorkspaceResolutionIndex { const classScopeIdByDefId = new Map(); const classScopeIdToDefId = new Map(); const moduleScopeIdByFile = new Map(); const exportedCallableByName = new Map(); + const declaredReturnTypeByCallableId = new Map(); // Back-compat (no scopeTree): keep the direct Scope-object maps. const classScopeByDefIdDirect = scopeTree === undefined ? new Map() : undefined; const moduleScopeByFileDirect = scopeTree === undefined ? new Map() : 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, + }; } diff --git a/gitnexus/src/storage/parse-cache.ts b/gitnexus/src/storage/parse-cache.ts index e49044b7e..a29b17273 100644 --- a/gitnexus/src/storage/parse-cache.ts +++ b/gitnexus/src/storage/parse-cache.ts @@ -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 diff --git a/gitnexus/src/storage/parsedfile-store.ts b/gitnexus/src/storage/parsedfile-store.ts index 58c336c95..4233850d8 100644 --- a/gitnexus/src/storage/parsedfile-store.ts +++ b/gitnexus/src/storage/parsedfile-store.ts @@ -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 ( diff --git a/gitnexus/test/fixtures/lang-resolution/swift-protocol-extension-implicit-self/AUnrelated.swift b/gitnexus/test/fixtures/lang-resolution/swift-protocol-extension-implicit-self/AUnrelated.swift new file mode 100644 index 000000000..da849ee4d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/swift-protocol-extension-implicit-self/AUnrelated.swift @@ -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" } +} diff --git a/gitnexus/test/fixtures/lang-resolution/swift-protocol-extension-implicit-self/Scenario.swift b/gitnexus/test/fixtures/lang-resolution/swift-protocol-extension-implicit-self/Scenario.swift new file mode 100644 index 000000000..d6d8fedd0 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/swift-protocol-extension-implicit-self/Scenario.swift @@ -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)" + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/swift-protocol-extension-implicit-self/Support.swift b/gitnexus/test/fixtures/lang-resolution/swift-protocol-extension-implicit-self/Support.swift new file mode 100644 index 000000000..596ba1472 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/swift-protocol-extension-implicit-self/Support.swift @@ -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" } +} diff --git a/gitnexus/test/fixtures/swift-captures-golden/expected-captures.json b/gitnexus/test/fixtures/swift-captures-golden/expected-captures.json index dd1540ec7..c7ccea7ee 100644 --- a/gitnexus/test/fixtures/swift-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/swift-captures-golden/expected-captures.json @@ -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" } } diff --git a/gitnexus/test/integration/resolvers/swift-local-vs-method-label-split.test.ts b/gitnexus/test/integration/resolvers/swift-local-vs-method-label-split.test.ts index ff7c0fff2..4a29528cd 100644 --- a/gitnexus/test/integration/resolvers/swift-local-vs-method-label-split.test.ts +++ b/gitnexus/test/integration/resolvers/swift-local-vs-method-label-split.test.ts @@ -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]); }); }); diff --git a/gitnexus/test/integration/resolvers/swift.test.ts b/gitnexus/test/integration/resolvers/swift.test.ts index 9b80d92a2..568cf3346 100644 --- a/gitnexus/test/integration/resolvers/swift.test.ts +++ b/gitnexus/test/integration/resolvers/swift.test.ts @@ -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 diff --git a/gitnexus/test/unit/incremental-parse-cache.test.ts b/gitnexus/test/unit/incremental-parse-cache.test.ts index f02c3879e..b52cb30a5 100644 --- a/gitnexus/test/unit/incremental-parse-cache.test.ts +++ b/gitnexus/test/unit/incremental-parse-cache.test.ts @@ -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); } diff --git a/gitnexus/test/unit/parsedfile-store.test.ts b/gitnexus/test/unit/parsedfile-store.test.ts index 79187f73a..7834a9d3c 100644 --- a/gitnexus/test/unit/parsedfile-store.test.ts +++ b/gitnexus/test/unit/parsedfile-store.test.ts @@ -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 { diff --git a/gitnexus/test/unit/scope-resolution/pick-implicit-this-overload.test.ts b/gitnexus/test/unit/scope-resolution/pick-implicit-this-overload.test.ts index 26f526382..3302485f5 100644 --- a/gitnexus/test/unit/scope-resolution/pick-implicit-this-overload.test.ts +++ b/gitnexus/test/unit/scope-resolution/pick-implicit-this-overload.test.ts @@ -47,25 +47,38 @@ const mkClassScope = (): Scope => ownedDefs: [], }) as unknown as Scope; -const mkScopes = (scope: Scope): ScopeResolutionIndexes => +const mkScopes = ( + scope: Scope, + mroByOwner: ReadonlyMap = 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): WorkspaceResolutionIndex => +const mkWorkspaceIndex = ( + mapping: ReadonlyMap, + classScopeByDefId: ReadonlyMap = new Map(), +): WorkspaceResolutionIndex => ({ classScopeIdToDefId: mapping, + classScopeByDefId, }) as unknown as WorkspaceResolutionIndex; const mkModel = ( overloadsByName: ReadonlyMap, + overloadsByOwnerAndName: ReadonlyMap = 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'); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/scope-extractor.test.ts b/gitnexus/test/unit/scope-resolution/scope-extractor.test.ts index 33c8cab51..d2061cfe0 100644 --- a/gitnexus/test/unit/scope-resolution/scope-extractor.test.ts +++ b/gitnexus/test/unit/scope-resolution/scope-extractor.test.ts @@ -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)', () => { diff --git a/gitnexus/test/unit/scope-resolution/swift-extension-owners.test.ts b/gitnexus/test/unit/scope-resolution/swift-extension-owners.test.ts new file mode 100644 index 000000000..45d5ef408 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/swift-extension-owners.test.ts @@ -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>[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); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/workspace-index.test.ts b/gitnexus/test/unit/scope-resolution/workspace-index.test.ts index 234c8d733..f0ea5d290 100644 --- a/gitnexus/test/unit/scope-resolution/workspace-index.test.ts +++ b/gitnexus/test/unit/scope-resolution/workspace-index.test.ts @@ -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(