diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index 144947d02..f7dfc51f9 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -3490,6 +3490,41 @@ func runCaller() { Foo.make().onlyOther() } }); }); + describe('Swift computed-property read shows callers (property_read)', () => { + function callerNamesOf(kind: string, name: string): string[] { + const target = cg.getNodesByKind(kind).find((n) => n.name === name); + if (!target) return []; + const names = cg + .getIncomingEdges(target.id) + .filter((e) => e.kind === 'calls') + .map((e) => cg.getNode(e.source)?.name) + .filter((n): n is string => !!n); + return [...new Set(names)].sort(); + } + + it('a computed-property gate lists its readers; a stored field of the same shape does not', async () => { + // Reading a computed property runs its getter — behaviourally a call, but + // paren-free, so it produced no `calls` edge and a gate looked uncalled. + fs.writeFileSync( + path.join(tempDir, 'Gate.swift'), + `struct Header { + var counter: Int = 0 + var isWellFormed: Bool { return counter >= 0 } +} +final class Receiver { + func receive(_ h: Header) { guard h.isWellFormed else { return } } + func check(_ h: Header) -> Bool { return h.isWellFormed } +} +` + ); + cg = await CodeGraph.init(tempDir, { index: true }); + // The computed property surfaces both readers as callers… + expect(callerNamesOf('property', 'isWellFormed')).toEqual(['check', 'receive']); + // …while a stored field read (`h.counter`) creates no bogus caller edge. + expect(callerNamesOf('field', 'counter')).toEqual([]); + }); + }); + describe('Chained call resolves a method on a supertype (conformance, #750)', () => { function callerNamesOf(qualifiedName: string): string[] { const target = cg.getNodesByKind('method').find((n) => n.qualifiedName === qualifiedName); diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index ce72d13c8..db182a363 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -4499,6 +4499,52 @@ export class TreeSitterExtractor { }); } + /** + * Swift computed-property read (#1159 sibling). A `navigation_expression` + * value-read — `enc.header.isWellFormed` — that is NOT the callee of a call + * emits a `property_read` ref for its LAST member (`isWellFormed`). Reading a + * computed property runs its getter, so it is behaviourally a call, but the + * paren-free syntax parsed as neither a `call_expression` (the call extractor) + * nor a capitalized `Type.member` (the static-member pass) — so a heavily-used + * gate property (`isWellFormed`, `canPublishBundle`) showed zero callers. + * + * Works for instance (`enc.header.isWellFormed`) and static (`Store.can + * PublishBundle`) receivers alike. A capitalized `Type.member` receiver also + * gets a `references` edge to the TYPE from extractStaticMemberRef — a + * different target, so no double edge. Precision comes at resolution: + * `property_read` binds to computed-`property` nodes ONLY (matchPropertyRead), + * so `arr.count`, `obj.storedField`, `Type.CONST`, and enum values resolve to + * nothing and drop — no new noise. See {@link matchPropertyRead}. + */ + private extractSwiftPropertyRead(node: SyntaxNode): void { + if (this.language !== 'swift' || node.type !== 'navigation_expression') return; + if (this.nodeStack.length === 0) return; + const ownerId = this.nodeStack[this.nodeStack.length - 1]; + if (!ownerId) return; + + // Skip `obj.method()` — this navigation is the callee of a call, already + // linked by the call extractor as a `calls` edge. + const parent = node.parent; + if (parent?.type === 'call_expression') { + const callee = getChildByField(parent, 'function') ?? parent.namedChild(0); + if (callee && callee.startIndex === node.startIndex) return; + } + + // Member name is the simple_identifier inside the trailing navigation_suffix. + const suffix = node.namedChild(node.namedChildCount - 1); + if (!suffix || suffix.type !== 'navigation_suffix') return; + const member = suffix.namedChildren.find((c: SyntaxNode) => c.type === 'simple_identifier'); + if (!member) return; + + this.unresolvedReferences.push({ + fromNodeId: ownerId, + referenceName: getNodeText(member, this.source), + referenceKind: 'property_read', + line: member.startPosition.row + 1, + column: member.startPosition.column, + }); + } + /** * Find a `class_body` child of an `object_creation_expression` — the * marker for an anonymous class (`new T() { ... }`). Returns the body @@ -4909,6 +4955,11 @@ export class TreeSitterExtractor { // Static-member / value-read: `Enum.value`, `Type.CONST`, `Foo::BAR`. this.extractStaticMemberRef(node); + // Swift computed-property read: `obj.isReady` runs a getter — a call + // written without parens. Emit a `property_read` ref (resolved to + // computed-`property` nodes only) so gate properties show real callers. + this.extractSwiftPropertyRead(node); + // Local variable type annotations inside a body — `const items: Foo[] = []`, // `const x: SomeType = svc.load()`. We deliberately do NOT create nodes for // locals (that would explode the graph — the data-flow frontier we leave diff --git a/src/resolution/index.ts b/src/resolution/index.ts index aa36fbfd3..f2da3b9fe 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -16,7 +16,7 @@ import { FrameworkResolver, ImportMapping, } from './types'; -import { matchReference, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, sameLanguageFamily, crossesKnownFamily } from './name-matcher'; +import { matchReference, matchFunctionRef, matchPropertyRead, matchDottedCallChain, matchScopedCallChain, sameLanguageFamily, crossesKnownFamily } from './name-matcher'; import { resolveViaImport, resolveJvmImport, extractImportMappings, extractReExports, loadCppIncludeDirs, isPhpIncludePathRef, isCobolCopybookRef } from './import-resolver'; import { detectFrameworks } from './frameworks'; import { synthesizeCallbackEdges } from './callback-synthesizer'; @@ -773,6 +773,14 @@ export class ReferenceResolver { return this.gateLanguage(matchFunctionRef(ref, this.context), ref); } + // Swift computed-property reads (`obj.isReady`) get a dedicated, strictly- + // gated path: computed-`property` targets only, same-file first, unique-only + // cross-file. They never reach the framework or fuzzy strategies below, so a + // stored-field or stdlib read that matches no computed property simply drops. + if (ref.referenceKind === 'property_read') { + return this.gateLanguage(matchPropertyRead(ref, this.context), ref); + } + // JVM FQN imports skip framework/name-matcher: `import com.example.Bar` // resolves directly through the qualifiedName index, which is unambiguous // even when several `Bar` classes exist in different packages. @@ -864,7 +872,13 @@ export class ReferenceResolver { // traverse `references`, so registration sites surface with no // graph-layer changes. let kind: Edge['kind'] = - ref.original.referenceKind === 'function_ref' ? 'references' : ref.original.referenceKind; + ref.original.referenceKind === 'function_ref' + ? 'references' + : ref.original.referenceKind === 'property_read' + // A computed-property read runs the getter — surface it as a `calls` + // edge so `callers`/`callees`/`impact` all traverse it. + ? 'calls' + : ref.original.referenceKind; // Promote "extends" to "implements" when a class/struct targets an interface if (kind === 'extends') { diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index e9fb64594..5f7b2378d 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -337,6 +337,58 @@ export function matchFunctionRef( return null; } +/** + * Resolve a Swift computed-property read (`obj.isReady`). The ONLY strategy for + * a `property_read` ref, and deliberately strict — mirroring matchFunctionRef: + * computed-`property` targets only, same language family, same-file first, and + * cross-file only when the match is UNIQUE. No fuzzy fallback. Because Swift's + * stored properties extract as `field`/`constant`/`variable` (never `property`) + * and stdlib members aren't in the graph, restricting to `property` kind means a + * read of a stored field or a `.count` resolves to nothing and drops — a wrong + * "call" edge onto an unrelated same-named symbol is worse than none. + */ +export function matchPropertyRead( + ref: UnresolvedRef, + context: ResolutionContext +): ResolvedRef | null { + const candidates = context + .getNodesByName(ref.referenceName) + .filter( + (n) => + n.kind === 'property' && + sameLanguageFamily(n.language, ref.language) && + n.id !== ref.fromNodeId // a computed property reading itself is not an edge + ); + if (candidates.length === 0) return null; + + // Same-file definition wins outright. + const sameFile = candidates.filter((n) => n.filePath === ref.filePath); + let pool = sameFile; + if (pool.length === 0) { + // No same-file def — prefer a UNIQUE same-directory computed property (the + // locality signal pickClosestFileNode uses for file resolution). Symlinked / + // mirrored source trees — a test target re-exposing app files (Auris's + // tools/crypto-tests mirror of Crypto.swift) — put a second same-named + // property in a distant dir; the same-dir one is the real referent. + const dirOf = (p: string): string => { + const i = p.lastIndexOf('/'); + return i >= 0 ? p.slice(0, i) : ''; + }; + const refDir = dirOf(ref.filePath); + pool = candidates.filter((n) => dirOf(n.filePath) === refDir); + } + // Unique-or-drop: refuse to guess between same-named computed properties we + // can't disambiguate without receiver-type inference — a wrong "call" edge is + // worse than none (the matchFunctionRef philosophy). + if (pool.length !== 1) return null; + return { + original: ref, + targetNodeId: pool[0]!.id, + confidence: 0.85, + resolvedBy: 'property-read', + }; +} + /** * Try to resolve a reference by exact name match */ diff --git a/src/resolution/types.ts b/src/resolution/types.ts index af92bc7ed..36122b70f 100644 --- a/src/resolution/types.ts +++ b/src/resolution/types.ts @@ -39,7 +39,7 @@ export interface ResolvedRef { /** Confidence score (0-1) */ confidence: number; /** How it was resolved */ - resolvedBy: 'exact-match' | 'import' | 'qualified-name' | 'framework' | 'fuzzy' | 'instance-method' | 'file-path' | 'function-ref'; + resolvedBy: 'exact-match' | 'import' | 'qualified-name' | 'framework' | 'fuzzy' | 'instance-method' | 'file-path' | 'function-ref' | 'property-read'; } /** diff --git a/src/types.ts b/src/types.ts index 3681723a9..54cf27f5f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -293,8 +293,15 @@ export interface ExtractionError { * a function name used as a VALUE (callback registration, #756). It never * becomes an edge kind: resolution maps it to a `references` edge targeting * function/method nodes only (see `matchFunctionRef`). + * + * `property_read` is likewise internal-only (Swift): reading a COMPUTED + * property (`obj.isReady`) runs its getter — behaviourally a call, but written + * without parens, so the call extractor never saw it and a computed-property + * gate showed zero callers. Resolution maps it to a `calls` edge targeting + * computed-`property` nodes ONLY (see `matchPropertyRead`); reads of stored + * fields and stdlib members resolve to nothing and drop, so no new noise. */ -export type ReferenceKind = EdgeKind | 'function_ref'; +export type ReferenceKind = EdgeKind | 'function_ref' | 'property_read'; /** * A reference that couldn't be resolved during extraction