mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-30 01:51:20 +00:00
fix(swift): resolve inherited protocol extension calls (#3309)
* fix(swift): resolve inherited protocol extension calls * fix(scope): gate inherited implicit receiver lookup * fix(swift): resolve call result types by exact callee * test(swift): align cache and local call expectations * fix(scope): reconcile replay diagnostics * fix(swift): preserve exact callable return types * fix(swift): capture throwing async call results * test(swift): refresh capture golden * test(swift): refresh scope capture baseline * fix(swift): require explicit callable returns * fix(scope): preserve duplicate return metadata * chore(scope): align index documentation * Address PR review feedback (#3309) Stamp only protocol/class-extension members (nested QN + SPM buckets), arity-narrow implicit-this across MRO, and keep Swift type peeling out of shared workspace-index via stripTypePreservingDecoration. Co-authored-by: Cursor <cursoragent@cursor.com> * Address PR review feedback (#3309) Stamp extension members even when the extension declares a nested type, keep inherited class members ahead of protocol-extension defaults, and report replay-only interface-dispatch fan-out drops. Note: pre-existing failure in gitnexus tsc against an older gitnexus-shared dist not addressed by this PR. Co-authored-by: Cursor <cursoragent@cursor.com> * Address PR review feedback (#3309) Walk inherited implicit-this owners nearest-first so a nearer override wins, and tighten Swift owner-stamp tests. Co-authored-by: Cursor <cursoragent@cursor.com> * chore(autofix): apply prettier + eslint fixes via /autofix command --------- Co-authored-by: Gergő Magyar <gergomagyar@icloud.com> Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
parent
f465f6fb92
commit
795cf0e151
29 changed files with 1550 additions and 133 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ export function interpretSwiftTypeBinding(captures: CaptureMatch): ParsedTypeBin
|
|||
// `[User]` → User (array sugar)
|
||||
// `Array<User>` / `Optional<User>` → User (single-arg generic)
|
||||
// `Foundation.URL` → URL (qualifier)
|
||||
const rawType = stripQualifier(stripGeneric(stripArraySugar(stripOptional(typeCap.text.trim()))));
|
||||
const rawType = normalizeSwiftTypeName(typeCap.text);
|
||||
|
||||
let source: TypeRef['source'] = 'parameter-annotation';
|
||||
if (captures['@type-binding.self'] !== undefined) source = 'self';
|
||||
|
|
@ -58,6 +58,22 @@ export function interpretSwiftTypeBinding(captures: CaptureMatch): ParsedTypeBin
|
|||
return { boundName: nameCap.text, rawTypeName: rawType, source };
|
||||
}
|
||||
|
||||
export function normalizeSwiftTypeName(text: string): string {
|
||||
return stripQualifier(stripGeneric(stripArraySugar(stripOptional(text.trim()))));
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-preserving decoration only — used by `stripTypePreservingDecoration`
|
||||
* for class lookup and declared-return replay. `User?` / `User!` → `User`.
|
||||
* Arrays, generics, and nested `Foo.Bar` are left intact so a binding used
|
||||
* for member lookup does not follow the element type or the trailing ident.
|
||||
*/
|
||||
export function stripSwiftTypePreservingDecoration(typeName: string): string | undefined {
|
||||
const trimmed = typeName.trim();
|
||||
if (trimmed.endsWith('?') || trimmed.endsWith('!')) return trimmed.slice(0, -1).trim();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** `User?` / `User!` → `User`. */
|
||||
function stripOptional(text: string): string {
|
||||
if (text.endsWith('?') || text.endsWith('!')) return text.slice(0, -1).trim();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -14,7 +14,8 @@
|
|||
* re-keys an `extension Foo { … }` to a `class_declaration`-style def
|
||||
* named `Foo`, so its members land on `Foo`'s scope and the shared
|
||||
* `populateClassOwnedMembers` stamps them with `Foo`'s ownerId — the
|
||||
* same mechanism C# uses for `partial class`. No separate hoist pass.
|
||||
* same mechanism C# uses for `partial class`. Cross-file extensions
|
||||
* that mint no type def are reconciled by `populateWorkspaceOwners`.
|
||||
* - **Labeled arguments** narrow by ARITY only (count-primary, labels
|
||||
* soft) — see `arity.ts`. Label-precise dispatch is deferred to the
|
||||
* type-binding layer.
|
||||
|
|
@ -46,7 +47,7 @@
|
|||
* 4. **`@_exported import` re-exports** are treated as plain imports.
|
||||
*/
|
||||
|
||||
import type { ParsedFile } from 'gitnexus-shared';
|
||||
import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared';
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import { loadSwiftPackageConfig } from '../../language-config.js';
|
||||
import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js';
|
||||
|
|
@ -66,6 +67,8 @@ import {
|
|||
mirrorSwiftSiblingTypeBindings,
|
||||
type SwiftResolveContext,
|
||||
} from './index.js';
|
||||
import { stripSwiftTypePreservingDecoration } from './interpret.js';
|
||||
import { coerceSwiftTargets, groupSwiftFilesBySpmTarget } from './target-grouping.js';
|
||||
import { swiftIsGlobalNameFallbackPlausible } from './name-fallback-visibility.js';
|
||||
|
||||
const ZERO_RANGE = { startLine: 0, startCol: 0, endLine: 0, endCol: 0 } as const;
|
||||
|
|
@ -101,6 +104,8 @@ const swiftScopeResolver: ScopeResolver = {
|
|||
arityCompatibility: (callsite, def) => swiftArityCompatibility(def, callsite),
|
||||
|
||||
buildMro: (graph, parsedFiles, nodeLookup) => buildSwiftMro(graph, parsedFiles, nodeLookup),
|
||||
implicitThisWalksMro: true,
|
||||
stripTypePreservingDecoration: stripSwiftTypePreservingDecoration,
|
||||
|
||||
// Methods/properties/init are owned by their enclosing class/struct/
|
||||
// extension(→extended type)/protocol. Extension members hoist for free
|
||||
|
|
@ -108,6 +113,13 @@ const swiftScopeResolver: ScopeResolver = {
|
|||
// the extended type.
|
||||
populateOwners: (parsed: ParsedFile) => populateClassOwnedMembers(parsed),
|
||||
|
||||
// An extension has its own Class scope but deliberately does not mint a
|
||||
// second type def. Its members therefore leave the per-file owner walk with
|
||||
// a qualified name (`ExtendedType.member`) but no ownerId. Reconcile those
|
||||
// members after all files are available so extensions declared in sibling
|
||||
// files work as well as extensions beside the original type.
|
||||
populateWorkspaceOwners: populateSwiftExtensionOwners,
|
||||
|
||||
// `super.method()` dispatches through the superclass chain.
|
||||
isSuperReceiver: (text) => text.trim() === 'super',
|
||||
|
||||
|
|
@ -209,6 +221,91 @@ function buildSwiftMro(
|
|||
return mro;
|
||||
}
|
||||
|
||||
function populateSwiftExtensionOwners(
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
ctx?: { readonly fileContents: ReadonlyMap<string, string>; readonly resolutionConfig?: unknown },
|
||||
): void {
|
||||
const filesByTarget = groupSwiftFilesBySpmTarget(
|
||||
parsedFiles,
|
||||
(parsed) => parsed.filePath,
|
||||
coerceSwiftTargets(ctx?.resolutionConfig),
|
||||
);
|
||||
for (const files of filesByTarget.values()) {
|
||||
stampSwiftExtensionOwnersInTarget(files);
|
||||
}
|
||||
}
|
||||
|
||||
function stampSwiftExtensionOwnersInTarget(parsedFiles: readonly ParsedFile[]): void {
|
||||
const ownersByName = new Map<string, SymbolDefinition[]>();
|
||||
for (const parsed of parsedFiles) {
|
||||
for (const def of parsed.localDefs) {
|
||||
if (!isClassLike(def.type) || def.qualifiedName === undefined) continue;
|
||||
const bucket = ownersByName.get(def.qualifiedName);
|
||||
if (bucket === undefined) ownersByName.set(def.qualifiedName, [def]);
|
||||
else bucket.push(def);
|
||||
}
|
||||
}
|
||||
|
||||
// Extension members are Function-owned defs whose parent Class minted no
|
||||
// type def. Nested locals are Function-owned defs whose parent is another
|
||||
// Function — leave those ownerless so they cannot enter implicit-self CALLS.
|
||||
for (const parsed of parsedFiles) {
|
||||
const byId = new Map(parsed.scopes.map((scope) => [scope.id, scope]));
|
||||
for (const scope of parsed.scopes) {
|
||||
if (scope.kind !== 'Function') continue;
|
||||
const parent = scope.parent === null ? undefined : byId.get(scope.parent);
|
||||
if (parent?.kind !== 'Class') continue;
|
||||
// A type-decl Class owns the type that opened it (same start line).
|
||||
// Nested types inside an `extension` are also class-like and live on
|
||||
// that Class scope — they are not the extended type, so they must
|
||||
// not suppress stamping `func added` onto `Foo`.
|
||||
if (parent.ownedDefs.some((d) => isClassLike(d.type) && defDeclaresThisClassScope(parent, d)))
|
||||
continue;
|
||||
for (const def of scope.ownedDefs) {
|
||||
if (def.ownerId !== undefined || def.qualifiedName === undefined) continue;
|
||||
const dot = def.qualifiedName.lastIndexOf('.');
|
||||
if (dot <= 0) continue;
|
||||
const owner = uniqueOwnerForExtensionPrefix(ownersByName, def.qualifiedName.slice(0, dot));
|
||||
if (owner !== undefined) {
|
||||
(def as { ownerId?: string }).ownerId = owner.nodeId;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function defDeclaresThisClassScope(
|
||||
parent: { readonly range: { readonly startLine: number } },
|
||||
def: SymbolDefinition,
|
||||
): boolean {
|
||||
const line = /#(\d+):/.exec(def.nodeId);
|
||||
if (line === null) return true;
|
||||
return Number(line[1]) === parent.range.startLine;
|
||||
}
|
||||
|
||||
function uniqueOwnerForExtensionPrefix(
|
||||
ownersByName: ReadonlyMap<string, readonly SymbolDefinition[]>,
|
||||
prefix: string,
|
||||
): SymbolDefinition | undefined {
|
||||
const seen = new Set<string>();
|
||||
const matches: SymbolDefinition[] = [];
|
||||
const consider = (defs: readonly SymbolDefinition[] | undefined): void => {
|
||||
if (defs === undefined) return;
|
||||
for (const def of defs) {
|
||||
if (seen.has(def.nodeId)) continue;
|
||||
seen.add(def.nodeId);
|
||||
matches.push(def);
|
||||
}
|
||||
};
|
||||
consider(ownersByName.get(prefix));
|
||||
if (!prefix.includes('.')) {
|
||||
for (const [qn, defs] of ownersByName) {
|
||||
if (qn !== prefix && qn.endsWith('.' + prefix)) consider(defs);
|
||||
}
|
||||
}
|
||||
return matches.length === 1 ? matches[0] : undefined;
|
||||
}
|
||||
|
||||
function closeProtocols(
|
||||
seeds: readonly string[],
|
||||
directImpls: ReadonlyMap<string, readonly string[]>,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* (RFC §5.3 + §3.2 Phase 1; Ring 2 PKG #919).
|
||||
*
|
||||
* Exactly one entry point: `extract(matches, filePath, provider) → ParsedFile`.
|
||||
* Runs a five-pass pipeline over the matches. Each pass is internal; the
|
||||
* Runs a seven-pass pipeline over the matches. Each pass is internal; the
|
||||
* public contract is the output `ParsedFile`.
|
||||
*
|
||||
* ## Design principles
|
||||
|
|
@ -22,7 +22,7 @@
|
|||
* don't overlap) are enforced by `buildScopeTree` from Ring 2 SHARED
|
||||
* (#912). Malformed inputs throw `ScopeTreeInvariantError`.
|
||||
*
|
||||
* ## The five passes
|
||||
* ## The seven passes
|
||||
*
|
||||
* 1. **Build scope tree.** Walk `@scope.*` matches. For each, consult
|
||||
* `provider.resolveScopeKind` (default: suffix of the capture name).
|
||||
|
|
@ -60,6 +60,10 @@
|
|||
* one `ReferenceSite` per match. Classify call form via
|
||||
* `provider.classifyCallForm` (default: the capture's sub-tag if
|
||||
* present; else `'free'`).
|
||||
* 6. **Collect callable-value-flow facts.** Independent of Pass 5 so
|
||||
* existing reference-site extraction stays byte-identical.
|
||||
* 7. **Preserve call-result assignment identity.** Untyped
|
||||
* `let lhs = call()` facts used by exact-callee return-type replay.
|
||||
*
|
||||
* ## What gets attached where
|
||||
*
|
||||
|
|
@ -80,6 +84,7 @@ import type {
|
|||
CallableFlowOperand,
|
||||
CallableFlowPassingMode,
|
||||
CallableFlowSite,
|
||||
CallResultAssignmentSite,
|
||||
Capture,
|
||||
CaptureMatch,
|
||||
ImportEdge,
|
||||
|
|
@ -134,7 +139,7 @@ export type ScopeExtractorHooks = Pick<
|
|||
// ─── Public entry point ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Drive the five extraction passes and return a `ParsedFile`.
|
||||
* Drive the seven extraction passes and return a `ParsedFile`.
|
||||
*
|
||||
* Throws `ScopeTreeInvariantError` (from #912) when the provider emits
|
||||
* captures that violate structural scope invariants (e.g., overlapping
|
||||
|
|
@ -240,6 +245,15 @@ export function extract(
|
|||
const callableFlowSites: CallableFlowSite[] = [];
|
||||
pass6CollectCallableFlows(partitioned.callableFlow, positionIndex, filePath, callableFlowSites);
|
||||
|
||||
// ── Pass 7: preserve call-result assignment identity ───────────────
|
||||
const callResultAssignmentSites: CallResultAssignmentSite[] = [];
|
||||
pass7CollectCallResultAssignments(
|
||||
partitioned.callResultAssignment,
|
||||
positionIndex,
|
||||
filePath,
|
||||
callResultAssignmentSites,
|
||||
);
|
||||
|
||||
// Freeze Scope drafts into final shape and return.
|
||||
const frozenScopes = scopeDrafts.map(draftToScope);
|
||||
return Object.freeze({
|
||||
|
|
@ -252,6 +266,9 @@ export function extract(
|
|||
...(callableFlowSites.length > 0
|
||||
? { callableFlowSites: Object.freeze(callableFlowSites.slice()) }
|
||||
: {}),
|
||||
...(callResultAssignmentSites.length > 0
|
||||
? { callResultAssignmentSites: Object.freeze(callResultAssignmentSites.slice()) }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -264,6 +281,7 @@ interface Partitioned {
|
|||
readonly typeBinding: readonly CaptureMatch[];
|
||||
readonly reference: readonly CaptureMatch[];
|
||||
readonly callableFlow: readonly CaptureMatch[];
|
||||
readonly callResultAssignment: readonly CaptureMatch[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -283,6 +301,7 @@ function partitionByTopic(matches: readonly CaptureMatch[]): Partitioned {
|
|||
const typeBinding: CaptureMatch[] = [];
|
||||
const reference: CaptureMatch[] = [];
|
||||
const callableFlow: CaptureMatch[] = [];
|
||||
const callResultAssignment: CaptureMatch[] = [];
|
||||
|
||||
for (const match of matches) {
|
||||
for (const topic of topicsOf(match)) {
|
||||
|
|
@ -305,14 +324,32 @@ function partitionByTopic(matches: readonly CaptureMatch[]): Partitioned {
|
|||
case 'callable-flow':
|
||||
callableFlow.push(match);
|
||||
break;
|
||||
case 'call-result-assignment':
|
||||
callResultAssignment.push(match);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { scope, declaration, import_, typeBinding, reference, callableFlow };
|
||||
return {
|
||||
scope,
|
||||
declaration,
|
||||
import_,
|
||||
typeBinding,
|
||||
reference,
|
||||
callableFlow,
|
||||
callResultAssignment,
|
||||
};
|
||||
}
|
||||
|
||||
type Topic = 'scope' | 'declaration' | 'import' | 'type-binding' | 'reference' | 'callable-flow';
|
||||
type Topic =
|
||||
| 'scope'
|
||||
| 'declaration'
|
||||
| 'import'
|
||||
| 'type-binding'
|
||||
| 'reference'
|
||||
| 'callable-flow'
|
||||
| 'call-result-assignment';
|
||||
|
||||
function topicsOf(match: CaptureMatch): ReadonlySet<Topic> {
|
||||
const topics = new Set<Topic>();
|
||||
|
|
@ -323,6 +360,7 @@ function topicsOf(match: CaptureMatch): ReadonlySet<Topic> {
|
|||
else if (name.startsWith('@type-binding.')) topics.add('type-binding');
|
||||
else if (name.startsWith('@reference.')) topics.add('reference');
|
||||
else if (name.startsWith('@callable-flow.')) topics.add('callable-flow');
|
||||
else if (name.startsWith('@call-result-assignment.')) topics.add('call-result-assignment');
|
||||
}
|
||||
return topics;
|
||||
}
|
||||
|
|
@ -598,17 +636,25 @@ function pass2AttachDeclarations(
|
|||
// fact is the failure mode this subsystem rejects everywhere else.
|
||||
//
|
||||
// Copying the field onto BOTH twins makes the outcome identical whichever
|
||||
// one wins. Deliberately narrow — only `typeParameters`, the one field with
|
||||
// an asymmetric twin today. Widening this to "merge all metadata" would
|
||||
// change what every existing duplicate resolves to, which is a different
|
||||
// change with a different blast radius and no evidence behind it yet.
|
||||
// one wins. Deliberately narrow — only metadata whose asymmetric twins have
|
||||
// executable regressions (`typeParameters` and `returnType`). Widening this
|
||||
// to "merge all metadata" would change what every existing duplicate
|
||||
// resolves to, which is a different change with a different blast radius
|
||||
// and no evidence behind it yet.
|
||||
const first = firstDefByNodeId.get(def.nodeId);
|
||||
if (first === undefined) {
|
||||
firstDefByNodeId.set(def.nodeId, def);
|
||||
} else if (first.typeParameters === undefined && def.typeParameters !== undefined) {
|
||||
first.typeParameters = def.typeParameters;
|
||||
} else if (def.typeParameters === undefined && first.typeParameters !== undefined) {
|
||||
def.typeParameters = first.typeParameters;
|
||||
} else {
|
||||
if (first.typeParameters === undefined && def.typeParameters !== undefined) {
|
||||
first.typeParameters = def.typeParameters;
|
||||
} else if (def.typeParameters === undefined && first.typeParameters !== undefined) {
|
||||
def.typeParameters = first.typeParameters;
|
||||
}
|
||||
if (first.returnType === undefined && def.returnType !== undefined) {
|
||||
first.returnType = def.returnType;
|
||||
} else if (def.returnType === undefined && first.returnType !== undefined) {
|
||||
def.returnType = first.returnType;
|
||||
}
|
||||
}
|
||||
|
||||
// Find the innermost scope that contains the declaration's anchor range.
|
||||
|
|
@ -1652,6 +1698,24 @@ function pass6CollectCallableFlows(
|
|||
}
|
||||
}
|
||||
|
||||
// ─── Pass 7: collect call-result assignment identity ──────────────────────
|
||||
|
||||
function pass7CollectCallResultAssignments(
|
||||
matches: readonly CaptureMatch[],
|
||||
positionIndex: ReturnType<typeof buildPositionIndex>,
|
||||
filePath: string,
|
||||
out: CallResultAssignmentSite[],
|
||||
): void {
|
||||
for (const match of matches) {
|
||||
const call = match['@call-result-assignment.call'];
|
||||
const lhs = match['@call-result-assignment.lhs'];
|
||||
if (call === undefined || lhs === undefined || !nonEmpty(lhs.text)) continue;
|
||||
const inScope = positionIndex.atPosition(filePath, call.range.startLine, call.range.startCol);
|
||||
if (inScope === undefined) continue;
|
||||
out.push({ callSite: call.range, inScope, lhs: lhs.text });
|
||||
}
|
||||
}
|
||||
|
||||
function callableFlowKind(match: CaptureMatch): CallableFlowKind | undefined {
|
||||
return CALLABLE_FLOW_KINDS.find((kind) => match[`@callable-flow.${kind}`] !== undefined);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -692,7 +692,10 @@ export interface ScopeResolver {
|
|||
*/
|
||||
readonly populateWorkspaceOwners?: (
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
ctx: { readonly fileContents: ReadonlyMap<string, string> },
|
||||
ctx: {
|
||||
readonly fileContents: ReadonlyMap<string, string>;
|
||||
readonly resolutionConfig?: unknown;
|
||||
},
|
||||
) => void;
|
||||
|
||||
/**
|
||||
|
|
@ -965,6 +968,11 @@ export interface ScopeResolver {
|
|||
*/
|
||||
readonly freeCallsRequireInstanceOwnership?: boolean;
|
||||
|
||||
/** Whether an unqualified call inside a type may dispatch to an inherited
|
||||
* instance method. Languages such as Swift allow implicit-self lookup,
|
||||
* while Python/JavaScript/PHP require an explicit receiver. */
|
||||
readonly implicitThisWalksMro?: boolean;
|
||||
|
||||
/**
|
||||
* When true, a constructor-form call `Type(...)` links to the Class def
|
||||
* itself rather than its explicit Constructor def. Default
|
||||
|
|
|
|||
|
|
@ -121,6 +121,7 @@ export function emitFreeCallFallback(
|
|||
* contains the method's owner. See
|
||||
* `ScopeResolver.freeCallsRequireInstanceOwnership`. */
|
||||
readonly freeCallsRequireInstanceOwnership?: boolean;
|
||||
readonly implicitThisWalksMro?: boolean;
|
||||
readonly recordResolutionOutcome?: ResolutionOutcomeRecorder;
|
||||
/** Call sites owned by a later precise pass (for example callable-value-flow). */
|
||||
readonly skipSites?: ReadonlySet<string>;
|
||||
|
|
@ -314,6 +315,7 @@ export function emitFreeCallFallback(
|
|||
conversionRankFn: options.conversionRankFn,
|
||||
conversionOnlyArgTypePrefixes: options.conversionOnlyArgTypePrefixes,
|
||||
constraintCompatibility: options.constraintCompatibility,
|
||||
implicitThisWalksMro: options.implicitThisWalksMro,
|
||||
});
|
||||
fnDefFromImplicitThis = fnDef !== undefined;
|
||||
}
|
||||
|
|
@ -1167,11 +1169,14 @@ export function pickUniqueGlobalClass(
|
|||
* pick a method member by name with overload narrowing on arity +
|
||||
* argument types. Returns undefined if there's no enclosing class,
|
||||
* no matching method, OR narrowing leaves multiple compatible
|
||||
* candidates — in the multi-candidate case, picking
|
||||
* `candidates[0]` would emit a high-confidence CALLS edge whose
|
||||
* target depends on registration order rather than a defensible
|
||||
* resolution. Mirrors `pickUniqueGlobalCallable`'s uniqueness check
|
||||
* in the same file (Codex PR #1497 review, finding 2).
|
||||
* candidates — except when those survivors are a protocol/interface
|
||||
* requirement plus exactly one extension witness, in which case the
|
||||
* witness (the default body) is returned. An inherited class, struct,
|
||||
* or enum member still wins over a protocol-extension default.
|
||||
* Picking `candidates[0]` would emit a high-confidence CALLS edge
|
||||
* whose target depends on registration order rather than a
|
||||
* defensible resolution. Mirrors `pickUniqueGlobalCallable`'s
|
||||
* uniqueness check in the same file (Codex PR #1497 review, finding 2).
|
||||
*
|
||||
* Exported for unit testing — language-agnostic logic, exercised
|
||||
* via synthetic stubs in `pick-implicit-this-overload.test.ts`. The
|
||||
|
|
@ -1191,6 +1196,7 @@ export function pickImplicitThisOverload(
|
|||
readonly conversionRankFn?: ConversionRankFn;
|
||||
readonly conversionOnlyArgTypePrefixes?: readonly string[];
|
||||
readonly constraintCompatibility?: ScopeResolver['constraintCompatibility'];
|
||||
readonly implicitThisWalksMro?: boolean;
|
||||
},
|
||||
): SymbolDefinition | undefined {
|
||||
// Find the enclosing Class scope by walking parents.
|
||||
|
|
@ -1211,22 +1217,118 @@ export function pickImplicitThisOverload(
|
|||
const classDefId = workspaceIndex.classScopeIdToDefId.get(classScopeId);
|
||||
if (classDefId === undefined) return undefined;
|
||||
|
||||
const overloads = model.methods.lookupAllByOwner(classDefId, site.name);
|
||||
if (overloads.length === 0) return undefined;
|
||||
if (overloads.length === 1) return overloads[0];
|
||||
// Bare calls in an instance method use the same implicit receiver as
|
||||
// `self.member()`. Prefer declarations on the enclosing type; when the
|
||||
// language opts into MRO implicit-this, walk inherited owners
|
||||
// nearest-first and arity-narrow per owner. Compatible-but-ambiguous
|
||||
// on a nearer ancestor fail-closes — do not fall through to a farther
|
||||
// override. Falling through to the global name lookup makes inherited
|
||||
// defaults depend on file order.
|
||||
const own = model.methods.lookupAllByOwner(classDefId, site.name);
|
||||
const ownPicked = pickUniqueImplicitThisCandidate(own, site, hookCtx, workspaceIndex);
|
||||
if (ownPicked !== undefined) return ownPicked;
|
||||
if (own.length > 0) {
|
||||
const ownCompatible = narrowOverloadCandidates(own, site.arity, site.argumentTypes, {
|
||||
argumentTypeClasses: site.argumentTypeClasses,
|
||||
conversionRankFn: hookCtx?.conversionRankFn,
|
||||
conversionOnlyArgTypePrefixes: hookCtx?.conversionOnlyArgTypePrefixes,
|
||||
constraintCompatibility: hookCtx?.constraintCompatibility,
|
||||
});
|
||||
if (ownCompatible.length > 0) return undefined;
|
||||
}
|
||||
if (hookCtx?.implicitThisWalksMro !== true) return undefined;
|
||||
|
||||
// Narrow on arity + argument types. Require a UNIQUE survivor —
|
||||
// ambiguous narrowing (multiple compatible candidates with no
|
||||
// disambiguating signal) leaves the call unresolved rather than
|
||||
// routing to an arbitrary first overload by registration order.
|
||||
for (const ownerId of scopes.methodDispatch?.mroFor(classDefId) ?? []) {
|
||||
const inherited = model.methods.lookupAllByOwner(ownerId, site.name);
|
||||
const inheritedPicked = pickUniqueImplicitThisCandidate(
|
||||
inherited,
|
||||
site,
|
||||
hookCtx,
|
||||
workspaceIndex,
|
||||
);
|
||||
if (inheritedPicked !== undefined) return inheritedPicked;
|
||||
if (inherited.length > 0) {
|
||||
const inheritedCompatible = narrowOverloadCandidates(
|
||||
inherited,
|
||||
site.arity,
|
||||
site.argumentTypes,
|
||||
{
|
||||
argumentTypeClasses: site.argumentTypeClasses,
|
||||
conversionRankFn: hookCtx?.conversionRankFn,
|
||||
conversionOnlyArgTypePrefixes: hookCtx?.conversionOnlyArgTypePrefixes,
|
||||
constraintCompatibility: hookCtx?.constraintCompatibility,
|
||||
},
|
||||
);
|
||||
if (inheritedCompatible.length > 0) return undefined;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function pickUniqueImplicitThisCandidate(
|
||||
overloads: readonly SymbolDefinition[],
|
||||
site: {
|
||||
readonly arity?: number;
|
||||
readonly argumentTypes?: readonly string[];
|
||||
readonly argumentTypeClasses?: readonly import('gitnexus-shared').ParameterTypeClass[];
|
||||
},
|
||||
hookCtx:
|
||||
| {
|
||||
readonly conversionRankFn?: ConversionRankFn;
|
||||
readonly conversionOnlyArgTypePrefixes?: readonly string[];
|
||||
readonly constraintCompatibility?: ScopeResolver['constraintCompatibility'];
|
||||
}
|
||||
| undefined,
|
||||
workspaceIndex: WorkspaceResolutionIndex,
|
||||
): SymbolDefinition | undefined {
|
||||
if (overloads.length === 0) return undefined;
|
||||
const candidates = narrowOverloadCandidates(overloads, site.arity, site.argumentTypes, {
|
||||
argumentTypeClasses: site.argumentTypeClasses,
|
||||
conversionRankFn: hookCtx?.conversionRankFn,
|
||||
conversionOnlyArgTypePrefixes: hookCtx?.conversionOnlyArgTypePrefixes,
|
||||
constraintCompatibility: hookCtx?.constraintCompatibility,
|
||||
});
|
||||
if (candidates.length !== 1) return undefined;
|
||||
return candidates[0];
|
||||
if (candidates.length === 0) return undefined;
|
||||
if (candidates.length === 1) return candidates[0];
|
||||
const witnesses = preferExtensionWitnesses(candidates, workspaceIndex);
|
||||
return witnesses.length === 1 ? witnesses[0] : undefined;
|
||||
}
|
||||
|
||||
function preferExtensionWitnesses(
|
||||
candidates: readonly SymbolDefinition[],
|
||||
workspaceIndex: WorkspaceResolutionIndex,
|
||||
): readonly SymbolDefinition[] {
|
||||
const onOwnerType: SymbolDefinition[] = [];
|
||||
const extensionWitnesses: SymbolDefinition[] = [];
|
||||
for (const def of candidates) {
|
||||
const ownerId = def.ownerId;
|
||||
if (ownerId === undefined) {
|
||||
extensionWitnesses.push(def);
|
||||
continue;
|
||||
}
|
||||
const ownerScope = workspaceIndex.classScopeByDefId?.get(ownerId);
|
||||
const livesOnOwner =
|
||||
ownerScope?.ownedDefs.some((owned) => owned.nodeId === def.nodeId) === true;
|
||||
if (livesOnOwner) onOwnerType.push(def);
|
||||
else extensionWitnesses.push(def);
|
||||
}
|
||||
if (extensionWitnesses.length === 0 || onOwnerType.length === 0) return candidates;
|
||||
const concrete = onOwnerType.filter((def) => !isProtocolLikeOwner(def.ownerId, workspaceIndex));
|
||||
if (concrete.length > 0) return concrete;
|
||||
return extensionWitnesses;
|
||||
}
|
||||
|
||||
function isProtocolLikeOwner(
|
||||
ownerId: string | undefined,
|
||||
workspaceIndex: WorkspaceResolutionIndex,
|
||||
): boolean {
|
||||
if (ownerId === undefined) return false;
|
||||
const ownerScope = workspaceIndex.classScopeByDefId?.get(ownerId);
|
||||
if (ownerScope === undefined) return false;
|
||||
return ownerScope.ownedDefs.some(
|
||||
(owned) =>
|
||||
owned.nodeId === ownerId && (owned.type === 'Protocol' || owned.type === 'Interface'),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@
|
|||
*/
|
||||
|
||||
import type { ParsedFile, RegistryProviders } from 'gitnexus-shared';
|
||||
import type { TypeRef } from 'gitnexus-shared';
|
||||
import type { KnowledgeGraph } from '../../../graph/types.js';
|
||||
import { generateId } from '../../../../lib/utils.js';
|
||||
import { lookupOwnedMembersByOwner } from '../../model/owned-members-lookup.js';
|
||||
|
|
@ -84,6 +85,7 @@ import {
|
|||
import { emitReturnShapeMemberAccesses } from '../passes/return-shape-members.js';
|
||||
import { emitImportedValueReferences } from '../passes/imported-value-refs.js';
|
||||
import {
|
||||
calleeIdPosKey,
|
||||
createCalleeIdAccumulator,
|
||||
type CalleeIdAccumulator,
|
||||
} from '../graph-bridge/callee-id-sink.js';
|
||||
|
|
@ -103,6 +105,58 @@ import { buildWorkspaceResolutionIndex } from '../workspace-index.js';
|
|||
import type { ResolutionOutcome, ResolutionOutcomeRecorder } from '../resolution-outcome.js';
|
||||
import { logHeapProbe } from '../../utils/heap-probe.js';
|
||||
import { parseTruthyEnv } from '../../utils/env.js';
|
||||
|
||||
/**
|
||||
* Join extraction-time assignment identity to Phase-4's exact resolved callee.
|
||||
* Ambiguous dispatch and missing return annotations deliberately produce no
|
||||
* binding. Extraction emits these facts only for untyped declarations, so an
|
||||
* existing entry here is necessarily inference/mirroring and may be corrected;
|
||||
* explicit annotations never enter this join.
|
||||
*/
|
||||
export function applyPreciseCallResultBindings(
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
indexes: ReturnType<typeof finalizeScopeModel>,
|
||||
workspaceIndex: ReturnType<typeof buildWorkspaceResolutionIndex>,
|
||||
calleeIds: CalleeIdAccumulator,
|
||||
nodeLookup: ReturnType<typeof buildGraphNodeLookup>,
|
||||
): number {
|
||||
let updated = 0;
|
||||
const returnTypeByGraphId = new Map<string, TypeRef>();
|
||||
for (const parsed of parsedFiles) {
|
||||
for (const def of parsed.localDefs) {
|
||||
const returnType = workspaceIndex.declaredReturnTypeByCallableId.get(def.nodeId);
|
||||
if (returnType === undefined) continue;
|
||||
const graphId = resolveDefGraphId(def.filePath, def, nodeLookup);
|
||||
if (graphId !== undefined) returnTypeByGraphId.set(graphId, returnType);
|
||||
}
|
||||
}
|
||||
for (const parsed of parsedFiles) {
|
||||
const resolvedByPosition = calleeIds.get(parsed.filePath);
|
||||
if (resolvedByPosition === undefined) continue;
|
||||
for (const assignment of parsed.callResultAssignmentSites ?? []) {
|
||||
const targets = resolvedByPosition.get(
|
||||
calleeIdPosKey(assignment.callSite.startLine, assignment.callSite.startCol),
|
||||
);
|
||||
if (targets === undefined || targets.size !== 1) continue;
|
||||
const targetId = targets.values().next().value as string | undefined;
|
||||
if (targetId === undefined) continue;
|
||||
const returnType = returnTypeByGraphId.get(targetId);
|
||||
if (returnType === undefined) continue;
|
||||
const scope = indexes.scopeTree.getScope(assignment.inScope);
|
||||
if (scope === undefined) continue;
|
||||
(scope.typeBindings as Map<string, TypeRef>).set(assignment.lhs, {
|
||||
rawName: returnType.rawName,
|
||||
...(returnType.declaredSpelling !== undefined
|
||||
? { declaredSpelling: returnType.declaredSpelling }
|
||||
: {}),
|
||||
declaredAtScope: assignment.inScope,
|
||||
source: 'assignment-inferred',
|
||||
});
|
||||
updated++;
|
||||
}
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
import { isValueDefinitionLabel } from '../../utils/ast-helpers.js';
|
||||
import { TransitionalScopeTree } from '../../../../storage/scope-index-store.js';
|
||||
import { forceGc } from '../../../../storage/parsedfile-store.js';
|
||||
|
|
@ -551,7 +605,6 @@ export function runScopeResolution(
|
|||
const undecidedSatisfaction: UndecidedSatisfaction[] = [];
|
||||
const recordResolutionOutcome: ResolutionOutcomeRecorder = (outcome) => {
|
||||
resolutionOutcomes.push(outcome);
|
||||
input.recordResolutionOutcome?.(outcome);
|
||||
};
|
||||
const PROF = process.env.PROF_SCOPE_RESOLUTION === '1';
|
||||
const tStart = PROF ? process.hrtime.bigint() : 0n;
|
||||
|
|
@ -638,7 +691,10 @@ export function runScopeResolution(
|
|||
'sr-extract-end',
|
||||
`lang=${provider.language} parsedFiles=${parsedFiles.length} preExtractedHits=${preExtractedHits} skipped=${filesSkipped}`,
|
||||
);
|
||||
provider.populateWorkspaceOwners?.(parsedFiles, { fileContents: getFileContents() });
|
||||
provider.populateWorkspaceOwners?.(parsedFiles, {
|
||||
fileContents: getFileContents(),
|
||||
resolutionConfig: input.resolutionConfig,
|
||||
});
|
||||
provider.populateWorkspaceReferences?.(parsedFiles, {
|
||||
fileContents: getFileContents(),
|
||||
treeCache,
|
||||
|
|
@ -844,7 +900,9 @@ export function runScopeResolution(
|
|||
// views that delegate to it (out-of-core scope index) — the index pins no Scope objects, so the
|
||||
// disk seal can reclaim them. Byte-identical: the view returns the same Scope
|
||||
// the resident tree holds (or a value-identical revived one in disk mode).
|
||||
const workspaceIndex = buildWorkspaceResolutionIndex(parsedFiles, indexes.scopeTree);
|
||||
const workspaceIndex = buildWorkspaceResolutionIndex(parsedFiles, indexes.scopeTree, {
|
||||
stripTypePreservingDecoration: provider.stripTypePreservingDecoration,
|
||||
});
|
||||
logHeapProbe('sr-post-workspaceIndex', `lang=${provider.language}`);
|
||||
|
||||
// Cross-file implicit-namespace visibility (C#). Must run before
|
||||
|
|
@ -1011,6 +1069,12 @@ export function runScopeResolution(
|
|||
const deferredIndirectCollection = collectDeferredIndirectCollection(emitParsedFiles, indexes);
|
||||
const deferredIndirectSites = deferredIndirectCollection.sites;
|
||||
const callableArgumentSites = new Set<string>();
|
||||
const callResultAssignmentSites = new Set<string>();
|
||||
for (const parsed of emitParsedFiles) {
|
||||
for (const site of parsed.callResultAssignmentSites ?? []) {
|
||||
callResultAssignmentSites.add(callableFlowSiteKey(parsed.filePath, site.callSite));
|
||||
}
|
||||
}
|
||||
if (input.pdg !== true && deferredIndirectSites.size > 0) {
|
||||
for (const parsed of emitParsedFiles) {
|
||||
for (const site of parsed.callableFlowSites ?? []) {
|
||||
|
|
@ -1025,11 +1089,14 @@ export function runScopeResolution(
|
|||
// propagation. Populated below at every CALLS emit path before dedup; the CFG
|
||||
// join still consumes it only inside the `input.pdg` block.
|
||||
const calleeIdAccumulator: CalleeIdAccumulator | undefined =
|
||||
input.pdg === true || deferredIndirectSites.size > 0
|
||||
input.pdg === true || deferredIndirectSites.size > 0 || callResultAssignmentSites.size > 0
|
||||
? createCalleeIdAccumulator(
|
||||
input.pdg === true
|
||||
? undefined
|
||||
: (filePath, line, col) => callableArgumentSites.has(`${filePath}:${line}:${col}`),
|
||||
: (filePath, line, col) => {
|
||||
const key = `${filePath}:${line}:${col}`;
|
||||
return callableArgumentSites.has(key) || callResultAssignmentSites.has(key);
|
||||
},
|
||||
)
|
||||
: undefined;
|
||||
const receiverBound = callableFlowOnly
|
||||
|
|
@ -1060,7 +1127,7 @@ export function runScopeResolution(
|
|||
heritageTypeArguments,
|
||||
},
|
||||
);
|
||||
const receiverExtras = receiverBound.emitted;
|
||||
let receiverExtras = receiverBound.emitted;
|
||||
if (receiverBound.dispatchFanoutSkipped > 0) {
|
||||
// Never drop dispatch coverage silently (#2829) — same contract as the
|
||||
// property-dispatch cap below. An interface member over the cap loses real
|
||||
|
|
@ -1112,6 +1179,7 @@ export function runScopeResolution(
|
|||
isFileLocalDef: provider.isFileLocalDef,
|
||||
isBuiltInName: provider.languageProvider.isBuiltInName,
|
||||
freeCallsRequireInstanceOwnership: provider.freeCallsRequireInstanceOwnership === true,
|
||||
implicitThisWalksMro: provider.implicitThisWalksMro === true,
|
||||
isCallableVisibleFromCaller: provider.isCallableVisibleFromCaller,
|
||||
resolveAdlCandidates: provider.resolveAdlCandidates,
|
||||
resolveQualifiedFreeCall: provider.resolveQualifiedFreeCall,
|
||||
|
|
@ -1123,6 +1191,63 @@ export function runScopeResolution(
|
|||
skipSites: deferredIndirectSites,
|
||||
},
|
||||
);
|
||||
const replayedCallResultBindings =
|
||||
callableFlowOnly || calleeIdAccumulator === undefined
|
||||
? 0
|
||||
: applyPreciseCallResultBindings(
|
||||
emitParsedFiles,
|
||||
indexes,
|
||||
workspaceIndex,
|
||||
calleeIdAccumulator,
|
||||
postHeritageNodeLookup,
|
||||
);
|
||||
if (replayedCallResultBindings > 0) {
|
||||
const handledBeforeReplay = new Set(handledSites);
|
||||
const replayedReceiverBound = emitReceiverBoundCalls(
|
||||
graph,
|
||||
indexes,
|
||||
emitParsedFiles,
|
||||
postHeritageNodeLookup,
|
||||
handledSites,
|
||||
provider,
|
||||
workspaceIndex,
|
||||
readonlyModel,
|
||||
{
|
||||
calleeIdSink: calleeIdAccumulator,
|
||||
isBuiltInName: provider.languageProvider.isBuiltInName,
|
||||
heritageTypeArguments,
|
||||
},
|
||||
);
|
||||
receiverExtras += replayedReceiverBound.emitted;
|
||||
if (replayedReceiverBound.dispatchFanoutSkipped > 0) {
|
||||
logger.warn(
|
||||
{
|
||||
lang: provider.language,
|
||||
dispatchFanoutSkipped: replayedReceiverBound.dispatchFanoutSkipped,
|
||||
dispatchFanoutSkippedNames: replayedReceiverBound.dispatchFanoutSkippedNames,
|
||||
fanoutCap: MAX_INTERFACE_DISPATCH_FANOUT,
|
||||
replay: true,
|
||||
},
|
||||
'interface-dispatch: members over the fan-out cap dropped implementors (their CALLS edges were not emitted)',
|
||||
);
|
||||
}
|
||||
const resolvedOnReplay = new Set<string>();
|
||||
for (const key of handledSites) {
|
||||
if (!handledBeforeReplay.has(key)) resolvedOnReplay.add(key);
|
||||
}
|
||||
if (resolvedOnReplay.size > 0) {
|
||||
for (let i = resolutionOutcomes.length - 1; i >= 0; i--) {
|
||||
const outcome = resolutionOutcomes[i];
|
||||
if (
|
||||
outcome.kind === 'suppressed' &&
|
||||
outcome.reason === 'receiver-unresolved' &&
|
||||
resolvedOnReplay.has(callableFlowSiteKey(outcome.filePath, outcome.range))
|
||||
) {
|
||||
resolutionOutcomes.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const referenceSkipSites = new Set(handledSites);
|
||||
for (const key of deferredIndirectSites) referenceSkipSites.add(key);
|
||||
const { emitted, skipped } = callableFlowOnly
|
||||
|
|
@ -1688,6 +1813,8 @@ export function runScopeResolution(
|
|||
|
||||
logHeapProbe('sr-end', `lang=${provider.language} parsedFiles=${parsedFiles.length}`);
|
||||
|
||||
for (const outcome of resolutionOutcomes) input.recordResolutionOutcome?.(outcome);
|
||||
|
||||
return {
|
||||
filesProcessed: parsedFiles.length,
|
||||
filesSkipped,
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
* on `SemanticModel`, and for the builder itself.
|
||||
*/
|
||||
|
||||
import type { Scope, ScopeId, SymbolDefinition } from 'gitnexus-shared';
|
||||
import type { Scope, ScopeId, SymbolDefinition, TypeRef } from 'gitnexus-shared';
|
||||
|
||||
export interface WorkspaceResolutionIndex {
|
||||
/** Class def `nodeId` → that class's `Scope`. */
|
||||
|
|
@ -36,4 +36,10 @@ export interface WorkspaceResolutionIndex {
|
|||
* killer). "First module-local callable in `moduleScopeByFile` order" is the
|
||||
* exact semantics the old scan returned, so it is byte-identical. */
|
||||
readonly exportedCallableByName: ReadonlyMap<string, SymbolDefinition>;
|
||||
|
||||
/** Exact callable def `nodeId` → its declared return type binding.
|
||||
* Built from definitions owned by each callable's Function scope, so methods
|
||||
* declared in extension/partial scopes remain addressable even when those
|
||||
* scopes deliberately own no separate class-like definition. */
|
||||
readonly declaredReturnTypeByCallableId: ReadonlyMap<string, TypeRef>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,7 +39,14 @@
|
|||
* Build cost is O(totalScopes). Read-only after construction.
|
||||
*/
|
||||
|
||||
import type { ParsedFile, Scope, ScopeId, ScopeTree, SymbolDefinition } from 'gitnexus-shared';
|
||||
import type {
|
||||
ParsedFile,
|
||||
Scope,
|
||||
ScopeId,
|
||||
ScopeTree,
|
||||
SymbolDefinition,
|
||||
TypeRef,
|
||||
} from 'gitnexus-shared';
|
||||
import type { WorkspaceResolutionIndex } from './workspace-index-types.js';
|
||||
import { isClassLike } from './scope/walkers.js';
|
||||
|
||||
|
|
@ -107,11 +114,15 @@ class ScopeByKeyView<K> implements ReadonlyMap<K, Scope> {
|
|||
export function buildWorkspaceResolutionIndex(
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
scopeTree?: ScopeTree,
|
||||
options?: {
|
||||
readonly stripTypePreservingDecoration?: (typeName: string) => string | undefined;
|
||||
},
|
||||
): WorkspaceResolutionIndex {
|
||||
const classScopeIdByDefId = new Map<string, ScopeId>();
|
||||
const classScopeIdToDefId = new Map<ScopeId, string>();
|
||||
const moduleScopeIdByFile = new Map<string, ScopeId>();
|
||||
const exportedCallableByName = new Map<string, SymbolDefinition>();
|
||||
const declaredReturnTypeByCallableId = new Map<string, TypeRef>();
|
||||
// Back-compat (no scopeTree): keep the direct Scope-object maps.
|
||||
const classScopeByDefIdDirect = scopeTree === undefined ? new Map<string, Scope>() : undefined;
|
||||
const moduleScopeByFileDirect = scopeTree === undefined ? new Map<string, Scope>() : undefined;
|
||||
|
|
@ -138,6 +149,24 @@ export function buildWorkspaceResolutionIndex(
|
|||
}
|
||||
|
||||
for (const scope of parsed.scopes) {
|
||||
if (scope.kind === 'Function' && scope.parent !== null) {
|
||||
for (const def of scope.ownedDefs) {
|
||||
if (def.type !== 'Function' && def.type !== 'Method' && def.type !== 'Constructor') {
|
||||
continue;
|
||||
}
|
||||
if (def.returnType !== undefined) {
|
||||
const stripped = options?.stripTypePreservingDecoration?.(def.returnType);
|
||||
const rawName = stripped ?? def.returnType;
|
||||
declaredReturnTypeByCallableId.set(def.nodeId, {
|
||||
rawName,
|
||||
...(rawName !== def.returnType ? { declaredSpelling: def.returnType } : {}),
|
||||
declaredAtScope: scope.id,
|
||||
source: 'return-annotation',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (scope.kind !== 'Class') continue;
|
||||
const cd = scope.ownedDefs.find((d) => isClassLike(d.type));
|
||||
if (cd !== undefined) {
|
||||
|
|
@ -157,5 +186,11 @@ export function buildWorkspaceResolutionIndex(
|
|||
? moduleScopeByFileDirect!
|
||||
: new ScopeByKeyView(moduleScopeIdByFile, scopeTree);
|
||||
|
||||
return { classScopeByDefId, classScopeIdToDefId, moduleScopeByFile, exportedCallableByName };
|
||||
return {
|
||||
classScopeByDefId,
|
||||
classScopeIdToDefId,
|
||||
moduleScopeByFile,
|
||||
exportedCallableByName,
|
||||
declaredReturnTypeByCallableId,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
13
gitnexus/test/fixtures/lang-resolution/swift-protocol-extension-implicit-self/AUnrelated.swift
vendored
Normal file
13
gitnexus/test/fixtures/lang-resolution/swift-protocol-extension-implicit-self/AUnrelated.swift
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
struct OtherStore { let value: Int }
|
||||
|
||||
struct OtherScenario {
|
||||
private func makeStore(currentValue: Int) -> OtherStore { OtherStore(value: currentValue) }
|
||||
}
|
||||
|
||||
enum OtherValue {
|
||||
private static func makeValue(year: Int, month: Int, day: Int) -> Int { -1 }
|
||||
}
|
||||
|
||||
enum OtherItems {
|
||||
private static func insertItem(into store: Store) -> String { "decoy" }
|
||||
}
|
||||
8
gitnexus/test/fixtures/lang-resolution/swift-protocol-extension-implicit-self/Scenario.swift
vendored
Normal file
8
gitnexus/test/fixtures/lang-resolution/swift-protocol-extension-implicit-self/Scenario.swift
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
struct Scenario: ScenarioSupport {
|
||||
func run() -> String {
|
||||
let store = makeStore()
|
||||
let value = makeValue(year: 2, month: 3, day: 4)
|
||||
let item = insertItem(into: store, count: 1)
|
||||
return "\(store.worker.execute()):\(value):\(item)"
|
||||
}
|
||||
}
|
||||
13
gitnexus/test/fixtures/lang-resolution/swift-protocol-extension-implicit-self/Support.swift
vendored
Normal file
13
gitnexus/test/fixtures/lang-resolution/swift-protocol-extension-implicit-self/Support.swift
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
protocol ScenarioSupport {}
|
||||
|
||||
struct Worker {
|
||||
func execute() -> String { "worker" }
|
||||
}
|
||||
|
||||
struct Store { let worker: Worker }
|
||||
|
||||
extension ScenarioSupport {
|
||||
func makeStore(observer: Int? = nil) -> Store { Store(worker: Worker()) }
|
||||
func makeValue(year: Int, month: Int, day: Int) -> Int { year + month + day }
|
||||
func insertItem(into store: Store, count: Int = 1) -> String { "item" }
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -47,25 +47,38 @@ const mkClassScope = (): Scope =>
|
|||
ownedDefs: [],
|
||||
}) as unknown as Scope;
|
||||
|
||||
const mkScopes = (scope: Scope): ScopeResolutionIndexes =>
|
||||
const mkScopes = (
|
||||
scope: Scope,
|
||||
mroByOwner: ReadonlyMap<string, readonly string[]> = new Map(),
|
||||
): ScopeResolutionIndexes =>
|
||||
({
|
||||
scopeTree: {
|
||||
getScope: (id: ScopeId) => (id === scope.id ? scope : undefined),
|
||||
},
|
||||
methodDispatch: {
|
||||
mroFor: (classDefId: string) => mroByOwner.get(classDefId) ?? [],
|
||||
},
|
||||
}) as unknown as ScopeResolutionIndexes;
|
||||
|
||||
const mkWorkspaceIndex = (mapping: ReadonlyMap<ScopeId, string>): WorkspaceResolutionIndex =>
|
||||
const mkWorkspaceIndex = (
|
||||
mapping: ReadonlyMap<ScopeId, string>,
|
||||
classScopeByDefId: ReadonlyMap<string, Scope> = new Map(),
|
||||
): WorkspaceResolutionIndex =>
|
||||
({
|
||||
classScopeIdToDefId: mapping,
|
||||
classScopeByDefId,
|
||||
}) as unknown as WorkspaceResolutionIndex;
|
||||
|
||||
const mkModel = (
|
||||
overloadsByName: ReadonlyMap<string, readonly SymbolDefinition[]>,
|
||||
overloadsByOwnerAndName: ReadonlyMap<string, readonly SymbolDefinition[]> = new Map(),
|
||||
): SemanticModel =>
|
||||
({
|
||||
methods: {
|
||||
lookupAllByOwner: (_classDefId: string, name: string) =>
|
||||
overloadsByName.get(name) ?? ([] as readonly SymbolDefinition[]),
|
||||
lookupAllByOwner: (classDefId: string, name: string) =>
|
||||
overloadsByOwnerAndName.get(`${classDefId}::${name}`) ??
|
||||
overloadsByName.get(name) ??
|
||||
([] as readonly SymbolDefinition[]),
|
||||
},
|
||||
}) as unknown as SemanticModel;
|
||||
|
||||
|
|
@ -154,3 +167,238 @@ describe('pickImplicitThisOverload — uniqueness guard (Codex #1497 finding 2)'
|
|||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('pickImplicitThisOverload — inherited implicit-this', () => {
|
||||
const PROTO_A = 'def:P.swift:A';
|
||||
const PROTO_B = 'def:P.swift:B';
|
||||
const site0 = {
|
||||
inScope: CLASS_SCOPE_ID,
|
||||
name: 'foo',
|
||||
arity: 0,
|
||||
argumentTypes: undefined,
|
||||
};
|
||||
|
||||
it('arity-narrows a singleton on the enclosing type instead of returning it blindly', () => {
|
||||
const oneArg = mkMethod({
|
||||
nodeId: 'm:own-1',
|
||||
parameterCount: 1,
|
||||
requiredParameterCount: 1,
|
||||
});
|
||||
const scopes = mkScopes(mkClassScope());
|
||||
const workspace = mkWorkspaceIndex(new Map([[CLASS_SCOPE_ID, CLASS_DEF_ID]]));
|
||||
const model = mkModel(new Map([['foo', [oneArg]]]));
|
||||
|
||||
expect(pickImplicitThisOverload(site0, scopes, workspace, model)?.nodeId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('unions inherited owners and arity-narrows instead of taking the first MRO name hit', () => {
|
||||
const aFoo = mkMethod({
|
||||
nodeId: 'm:A.foo',
|
||||
ownerId: PROTO_A,
|
||||
parameterCount: 1,
|
||||
requiredParameterCount: 1,
|
||||
});
|
||||
const bFoo = mkMethod({
|
||||
nodeId: 'm:B.foo',
|
||||
ownerId: PROTO_B,
|
||||
parameterCount: 0,
|
||||
requiredParameterCount: 0,
|
||||
});
|
||||
const scopes = mkScopes(mkClassScope(), new Map([[CLASS_DEF_ID, [PROTO_A, PROTO_B]]]));
|
||||
const workspace = mkWorkspaceIndex(new Map([[CLASS_SCOPE_ID, CLASS_DEF_ID]]));
|
||||
const model = mkModel(
|
||||
new Map(),
|
||||
new Map([
|
||||
[`${CLASS_DEF_ID}::foo`, []],
|
||||
[`${PROTO_A}::foo`, [aFoo]],
|
||||
[`${PROTO_B}::foo`, [bFoo]],
|
||||
]),
|
||||
);
|
||||
|
||||
const result = pickImplicitThisOverload(site0, scopes, workspace, model, {
|
||||
implicitThisWalksMro: true,
|
||||
});
|
||||
expect(result?.nodeId).toBe('m:B.foo');
|
||||
});
|
||||
|
||||
it('picks the nearest inherited override when two MRO ancestors share a signature', () => {
|
||||
const MID = 'def:Mid.swift:Mid';
|
||||
const GRAND = 'def:Grand.swift:Grand';
|
||||
const midFoo = mkMethod({
|
||||
nodeId: 'm:Mid.foo',
|
||||
ownerId: MID,
|
||||
parameterCount: 0,
|
||||
requiredParameterCount: 0,
|
||||
});
|
||||
const grandFoo = mkMethod({
|
||||
nodeId: 'm:Grand.foo',
|
||||
ownerId: GRAND,
|
||||
parameterCount: 0,
|
||||
requiredParameterCount: 0,
|
||||
});
|
||||
const scopes = mkScopes(mkClassScope(), new Map([[CLASS_DEF_ID, [MID, GRAND]]]));
|
||||
const workspace = mkWorkspaceIndex(new Map([[CLASS_SCOPE_ID, CLASS_DEF_ID]]));
|
||||
const model = mkModel(
|
||||
new Map(),
|
||||
new Map([
|
||||
[`${CLASS_DEF_ID}::foo`, []],
|
||||
[`${MID}::foo`, [midFoo]],
|
||||
[`${GRAND}::foo`, [grandFoo]],
|
||||
]),
|
||||
);
|
||||
|
||||
const result = pickImplicitThisOverload(site0, scopes, workspace, model, {
|
||||
implicitThisWalksMro: true,
|
||||
});
|
||||
expect(result?.nodeId).toBe('m:Mid.foo');
|
||||
});
|
||||
|
||||
it('prefers a protocol-extension default over the protocol requirement of the same arity', () => {
|
||||
const requirement = mkMethod({
|
||||
nodeId: 'm:P.req',
|
||||
ownerId: PROTO_A,
|
||||
parameterCount: 0,
|
||||
requiredParameterCount: 0,
|
||||
});
|
||||
const body = mkMethod({
|
||||
nodeId: 'm:P.ext',
|
||||
ownerId: PROTO_A,
|
||||
parameterCount: 0,
|
||||
requiredParameterCount: 0,
|
||||
});
|
||||
const protocolScope = {
|
||||
id: 'scope:P.swift#1:1-20:1:Class' as ScopeId,
|
||||
parent: null,
|
||||
kind: 'Class',
|
||||
range: { startLine: 1, startCol: 1, endLine: 20, endCol: 1 },
|
||||
filePath: 'P.swift',
|
||||
bindings: new Map(),
|
||||
typeBindings: new Map(),
|
||||
ownedDefs: [
|
||||
{
|
||||
nodeId: PROTO_A,
|
||||
filePath: 'P.swift',
|
||||
type: 'Protocol',
|
||||
qualifiedName: 'A',
|
||||
} as SymbolDefinition,
|
||||
requirement,
|
||||
],
|
||||
} as unknown as Scope;
|
||||
const scopes = mkScopes(mkClassScope(), new Map([[CLASS_DEF_ID, [PROTO_A]]]));
|
||||
const workspace = mkWorkspaceIndex(
|
||||
new Map([[CLASS_SCOPE_ID, CLASS_DEF_ID]]),
|
||||
new Map([[PROTO_A, protocolScope]]),
|
||||
);
|
||||
const model = mkModel(
|
||||
new Map(),
|
||||
new Map([
|
||||
[`${CLASS_DEF_ID}::foo`, []],
|
||||
[`${PROTO_A}::foo`, [requirement, body]],
|
||||
]),
|
||||
);
|
||||
|
||||
const result = pickImplicitThisOverload(site0, scopes, workspace, model, {
|
||||
implicitThisWalksMro: true,
|
||||
});
|
||||
expect(result?.nodeId).toBe('m:P.ext');
|
||||
});
|
||||
|
||||
it('does not prefer a protocol-extension witness over an inherited class member', () => {
|
||||
const BASE = 'def:Base.swift:Base';
|
||||
const baseType = {
|
||||
nodeId: BASE,
|
||||
filePath: 'Base.swift',
|
||||
type: 'Class',
|
||||
qualifiedName: 'Base',
|
||||
} as SymbolDefinition;
|
||||
const baseFoo = mkMethod({
|
||||
nodeId: 'm:Base.foo',
|
||||
ownerId: BASE,
|
||||
parameterCount: 0,
|
||||
requiredParameterCount: 0,
|
||||
});
|
||||
const extFoo = mkMethod({
|
||||
nodeId: 'm:P.ext',
|
||||
ownerId: PROTO_A,
|
||||
parameterCount: 0,
|
||||
requiredParameterCount: 0,
|
||||
});
|
||||
const baseScope = {
|
||||
id: 'scope:Base.swift#1:1-20:1:Class' as ScopeId,
|
||||
parent: null,
|
||||
kind: 'Class',
|
||||
range: { startLine: 1, startCol: 1, endLine: 20, endCol: 1 },
|
||||
filePath: 'Base.swift',
|
||||
bindings: new Map(),
|
||||
typeBindings: new Map(),
|
||||
ownedDefs: [baseType, baseFoo],
|
||||
} as unknown as Scope;
|
||||
const protocolScope = {
|
||||
id: 'scope:P.swift#1:1-20:1:Class' as ScopeId,
|
||||
parent: null,
|
||||
kind: 'Class',
|
||||
range: { startLine: 1, startCol: 1, endLine: 20, endCol: 1 },
|
||||
filePath: 'P.swift',
|
||||
bindings: new Map(),
|
||||
typeBindings: new Map(),
|
||||
ownedDefs: [
|
||||
{
|
||||
nodeId: PROTO_A,
|
||||
filePath: 'P.swift',
|
||||
type: 'Protocol',
|
||||
qualifiedName: 'A',
|
||||
} as SymbolDefinition,
|
||||
],
|
||||
} as unknown as Scope;
|
||||
const scopes = mkScopes(mkClassScope(), new Map([[CLASS_DEF_ID, [BASE, PROTO_A]]]));
|
||||
const workspace = mkWorkspaceIndex(
|
||||
new Map([[CLASS_SCOPE_ID, CLASS_DEF_ID]]),
|
||||
new Map([
|
||||
[BASE, baseScope],
|
||||
[PROTO_A, protocolScope],
|
||||
]),
|
||||
);
|
||||
const model = mkModel(
|
||||
new Map(),
|
||||
new Map([
|
||||
[`${CLASS_DEF_ID}::foo`, []],
|
||||
[`${BASE}::foo`, [baseFoo]],
|
||||
[`${PROTO_A}::foo`, [extFoo]],
|
||||
]),
|
||||
);
|
||||
|
||||
const result = pickImplicitThisOverload(site0, scopes, workspace, model, {
|
||||
implicitThisWalksMro: true,
|
||||
});
|
||||
expect(result?.nodeId).toBe('m:Base.foo');
|
||||
});
|
||||
|
||||
it('falls through to inherited when the enclosing type only has an arity-incompatible decoy', () => {
|
||||
const decoy = mkMethod({
|
||||
nodeId: 'm:own-decoy',
|
||||
ownerId: CLASS_DEF_ID,
|
||||
parameterCount: 1,
|
||||
requiredParameterCount: 1,
|
||||
});
|
||||
const inherited = mkMethod({
|
||||
nodeId: 'm:ext',
|
||||
ownerId: PROTO_A,
|
||||
parameterCount: 0,
|
||||
requiredParameterCount: 0,
|
||||
});
|
||||
const scopes = mkScopes(mkClassScope(), new Map([[CLASS_DEF_ID, [PROTO_A]]]));
|
||||
const workspace = mkWorkspaceIndex(new Map([[CLASS_SCOPE_ID, CLASS_DEF_ID]]));
|
||||
const model = mkModel(
|
||||
new Map(),
|
||||
new Map([
|
||||
[`${CLASS_DEF_ID}::foo`, [decoy]],
|
||||
[`${PROTO_A}::foo`, [inherited]],
|
||||
]),
|
||||
);
|
||||
|
||||
const result = pickImplicitThisOverload(site0, scopes, workspace, model, {
|
||||
implicitThisWalksMro: true,
|
||||
});
|
||||
expect(result?.nodeId).toBe('m:ext');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)', () => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,198 @@
|
|||
/**
|
||||
* `populateWorkspaceOwners` for Swift must stamp extension members onto a
|
||||
* unique owner in the same SPM target, skip nested locals, and fail closed
|
||||
* when `Bar` and `Foo.Bar` both exist.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { extractParsedFile } from '../../../src/core/ingestion/scope-extractor-bridge.js';
|
||||
import { swiftScopeResolver } from '../../../src/core/ingestion/languages/swift/scope-resolver.js';
|
||||
import { isLanguageAvailable } from '../../../src/core/tree-sitter/parser-loader.js';
|
||||
import { SupportedLanguages } from '../../../src/config/supported-languages.js';
|
||||
|
||||
const swiftAvailable = isLanguageAvailable(SupportedLanguages.Swift);
|
||||
|
||||
function parseSwift(source: string, filePath: string) {
|
||||
const parsed = extractParsedFile(swiftScopeResolver.languageProvider, source, filePath, () => {});
|
||||
if (parsed === undefined) throw new Error('scope extraction failed');
|
||||
swiftScopeResolver.populateOwners(parsed);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function stamp(
|
||||
files: Parameters<NonNullable<typeof swiftScopeResolver.populateWorkspaceOwners>>[0],
|
||||
resolutionConfig?: unknown,
|
||||
) {
|
||||
swiftScopeResolver.populateWorkspaceOwners?.(files, {
|
||||
fileContents: new Map(),
|
||||
resolutionConfig,
|
||||
});
|
||||
}
|
||||
|
||||
describe.skipIf(!swiftAvailable)('populateSwiftExtensionOwners', () => {
|
||||
it('stamps protocol-extension methods onto the unique protocol', () => {
|
||||
const parsed = parseSwift(
|
||||
`
|
||||
protocol ScenarioSupport {}
|
||||
struct Store {}
|
||||
extension ScenarioSupport {
|
||||
func makeStore() -> Store { Store() }
|
||||
}
|
||||
`,
|
||||
'Support.swift',
|
||||
);
|
||||
const protocol = parsed.localDefs.find((d) => d.qualifiedName === 'ScenarioSupport');
|
||||
const method = parsed.localDefs.find(
|
||||
(d) => d.qualifiedName?.split('.').at(-1) === 'makeStore' && d.type === 'Method',
|
||||
);
|
||||
expect(protocol).toBeDefined();
|
||||
expect(method?.ownerId).toBeUndefined();
|
||||
stamp([parsed]);
|
||||
expect(method?.ownerId).toBe(protocol?.nodeId);
|
||||
});
|
||||
|
||||
it('does not stamp a nested local onto the enclosing type', () => {
|
||||
const parsed = parseSwift(
|
||||
`
|
||||
class Host {
|
||||
func helper(_ a: Int) -> Int { a }
|
||||
func run(_ x: Int) -> Int {
|
||||
func helper(_ v: Int, _ w: Int) -> Int { v + w }
|
||||
return helper(x, x)
|
||||
}
|
||||
}
|
||||
`,
|
||||
'Host.swift',
|
||||
);
|
||||
stamp([parsed]);
|
||||
const twoArg = parsed.localDefs.filter(
|
||||
(d) => d.type === 'Method' && d.qualifiedName?.endsWith('helper') && d.parameterCount === 2,
|
||||
);
|
||||
expect(twoArg).toHaveLength(1);
|
||||
expect(twoArg[0]?.ownerId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('stamps extension Foo.Bar members onto Foo.Bar when no top-level Bar exists', () => {
|
||||
const parsed = parseSwift(
|
||||
`
|
||||
enum Foo {
|
||||
struct Bar {}
|
||||
}
|
||||
extension Foo.Bar {
|
||||
func added() {}
|
||||
}
|
||||
`,
|
||||
'Nested.swift',
|
||||
);
|
||||
stamp([parsed]);
|
||||
const added = parsed.localDefs.find((d) => d.qualifiedName?.split('.').at(-1) === 'added');
|
||||
const bar = parsed.localDefs.find(
|
||||
(d) => d.qualifiedName === 'Foo.Bar' || d.qualifiedName === 'Bar',
|
||||
);
|
||||
expect(added?.ownerId).toBeDefined();
|
||||
expect(added?.ownerId).toBe(bar?.nodeId);
|
||||
});
|
||||
|
||||
it('fails closed when top-level Bar and nested Foo.Bar both exist', () => {
|
||||
const parsed = parseSwift(
|
||||
`
|
||||
struct Bar {}
|
||||
enum Foo {
|
||||
struct Bar {}
|
||||
}
|
||||
extension Foo.Bar {
|
||||
func added() {}
|
||||
}
|
||||
`,
|
||||
'Collide.swift',
|
||||
);
|
||||
stamp([parsed]);
|
||||
const added = parsed.localDefs.find((d) => d.qualifiedName?.split('.').at(-1) === 'added');
|
||||
expect(added).toBeDefined();
|
||||
expect(added?.ownerId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('stamps extension members when the extension also declares a nested type', () => {
|
||||
const parsed = parseSwift(
|
||||
`
|
||||
struct Foo {}
|
||||
extension Foo {
|
||||
struct Helper {}
|
||||
func added() {}
|
||||
}
|
||||
`,
|
||||
'NestedInExt.swift',
|
||||
);
|
||||
stamp([parsed]);
|
||||
const added = parsed.localDefs.find((d) => d.qualifiedName?.split('.').at(-1) === 'added');
|
||||
const foo = parsed.localDefs.find(
|
||||
(d) => d.qualifiedName === 'Foo' && (d.type === 'Struct' || d.type === 'Class'),
|
||||
);
|
||||
expect(added).toBeDefined();
|
||||
expect(foo).toBeDefined();
|
||||
expect(added?.ownerId).toBe(foo?.nodeId);
|
||||
});
|
||||
|
||||
it('does not let one target’s Foo own another target’s extension', () => {
|
||||
const app = parseSwift(
|
||||
`
|
||||
protocol Foo {}
|
||||
extension Foo {
|
||||
func added() {}
|
||||
}
|
||||
`,
|
||||
'Sources/App/A.swift',
|
||||
);
|
||||
const lib = parseSwift(
|
||||
`
|
||||
protocol Foo {}
|
||||
`,
|
||||
'Sources/Lib/B.swift',
|
||||
);
|
||||
stamp([app, lib], {
|
||||
targets: new Map([
|
||||
['App', 'Sources/App'],
|
||||
['Lib', 'Sources/Lib'],
|
||||
]),
|
||||
});
|
||||
const added = app.localDefs.find((d) => d.qualifiedName?.split('.').at(-1) === 'added');
|
||||
const appFoo = app.localDefs.find((d) => d.qualifiedName === 'Foo');
|
||||
expect(added).toBeDefined();
|
||||
expect(appFoo).toBeDefined();
|
||||
expect(added?.ownerId).toBe(appFoo?.nodeId);
|
||||
const libFoo = lib.localDefs.find((d) => d.qualifiedName === 'Foo');
|
||||
expect(added?.ownerId).not.toBe(libFoo?.nodeId);
|
||||
});
|
||||
|
||||
it('stamps a one-line extension whose synthetic class shares the Class start line', () => {
|
||||
const parsed = parseSwift(
|
||||
`
|
||||
struct Foo {}
|
||||
extension Foo { func added() {} }
|
||||
`,
|
||||
'OneLine.swift',
|
||||
);
|
||||
const added = parsed.localDefs.find((d) => d.qualifiedName?.split('.').at(-1) === 'added');
|
||||
const foo = parsed.localDefs.find(
|
||||
(d) => d.qualifiedName === 'Foo' && (d.type === 'Struct' || d.type === 'Class'),
|
||||
);
|
||||
expect(added).toBeDefined();
|
||||
expect(foo).toBeDefined();
|
||||
expect(added?.ownerId).toBeUndefined();
|
||||
stamp([parsed]);
|
||||
expect(added?.ownerId).toBe(foo?.nodeId);
|
||||
});
|
||||
|
||||
it('stamps a cross-file extension onto the real type, not the synthetic class', () => {
|
||||
const typeFile = parseSwift('struct Foo {}', 'Foo.swift');
|
||||
const extFile = parseSwift('extension Foo { func added() {} }', 'Foo+Added.swift');
|
||||
const added = extFile.localDefs.find((d) => d.qualifiedName?.split('.').at(-1) === 'added');
|
||||
const foo = typeFile.localDefs.find(
|
||||
(d) => d.qualifiedName === 'Foo' && (d.type === 'Struct' || d.type === 'Class'),
|
||||
);
|
||||
expect(added).toBeDefined();
|
||||
expect(foo).toBeDefined();
|
||||
expect(added?.ownerId).toBeUndefined();
|
||||
stamp([typeFile, extFile]);
|
||||
expect(added?.ownerId).toBe(foo?.nodeId);
|
||||
});
|
||||
});
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue