Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions __tests__/resolution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
51 changes: 51 additions & 0 deletions src/extraction/tree-sitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
18 changes: 16 additions & 2 deletions src/resolution/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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') {
Expand Down
52 changes: 52 additions & 0 deletions src/resolution/name-matcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
2 changes: 1 addition & 1 deletion src/resolution/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}

/**
Expand Down
9 changes: 8 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down