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
44 changes: 44 additions & 0 deletions src/domain/graph/builder/call-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
isModuleScopedLanguage,
resolveByGlobal,
resolveByReceiver,
unwrapTypeEntry,
} from '../resolver/strategy.js';

// ── Public interface ─────────────────────────────────────────────────────
Expand Down Expand Up @@ -60,6 +61,49 @@ export function resolveSameClassQualifiedMethod(
.filter((n) => n.kind === 'method');
}

/**
* Shared by both the full-build (build-edges.ts, including its native-engine
* post-pass) and incremental (incremental.ts) `Object.defineProperty` accessor
* fallback: when a function is registered as a getter/setter via
* `Object.defineProperty(obj, "bar", { get: getter })`, calls to `this.X()`
* inside `getter` resolve against `obj` (this === obj when the accessor is
* invoked).
*
* `definePropertyReceivers` maps the getter/setter's own name (`callerName`)
* to the receiver variable name (`obj`). Resolution:
* 1. Look up `obj`'s type in the typeMap and try the qualified `Type.X`
* method in the same file.
* 2. Otherwise, fall back to any same-file definition named `X` — handles
* plain object literals where the method isn't qualified (e.g.
* `const obj = { baz() {} }` defines `baz` directly).
*
* The fallback tier (2) is restricted to `function`/`method` kinds: a
* getter/setter's implementation is always callable code, so an unfiltered
* lookup could otherwise match an unrelated same-named class or variable in
* the same file (issue #1766). Tier (1) is intentionally left unfiltered,
* matching its pre-existing behaviour on all three call sites.
*/
export function resolveDefinePropertyAccessorTarget(
callName: string,
callerName: string,
relPath: string,
typeMap: Map<string, unknown>,
lookup: CallNodeLookup,
definePropertyReceivers: ReadonlyMap<string, string>,
): Array<{ id: number; file: string; kind?: string }> {
const receiverVarName = definePropertyReceivers.get(callerName);
if (!receiverVarName) return [];

const typeName = unwrapTypeEntry(typeMap.get(receiverVarName));
if (typeName) {
const qualified = lookup.byNameAndFile(`${typeName}.${callName}`, relPath);
if (qualified.length > 0) return [...qualified];
}
return lookup
.byNameAndFile(callName, relPath)
.filter((n) => n.kind === 'function' || n.kind === 'method');
}

// ── Shared resolution functions ──────────────────────────────────────────

/**
Expand Down
43 changes: 5 additions & 38 deletions src/domain/graph/builder/incremental.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
findCaller,
isModuleScopedLanguage,
resolveCallTargets,
resolveDefinePropertyAccessorTarget,
resolveReceiverEdge,
resolveSameClassQualifiedMethod,
} from './call-resolver.js';
Expand Down Expand Up @@ -616,42 +617,6 @@ function buildIncrementalTypeMap(symbols: ExtractorOutput): Map<string, unknown>
return typeMap;
}

/**
* Strategy 2 — Object.defineProperty accessor fallback.
* When a function is registered as a getter/setter via
* `Object.defineProperty(obj, "bar", { get: getter })`, calls to `this.X()`
* inside `getter` resolve against `obj`. Looks up the receiver var in the
* typeMap for its type, then falls back to any same-file definition named
* `callName` with function or method kind.
*/
function resolveDefinePropertyTarget(
callName: string,
callerName: string,
relPath: string,
typeMap: Map<string, unknown>,
lookup: CallNodeLookup,
definePropertyReceivers: Map<string, string>,
): Array<{ id: number; file: string; kind?: string }> {
const receiverVarName = definePropertyReceivers.get(callerName);
if (!receiverVarName) return [];

const typeEntry = typeMap.get(receiverVarName);
const typeName = typeEntry
? typeof typeEntry === 'string'
? typeEntry
: (typeEntry as { type?: string }).type
: null;
if (typeName) {
const qualified = lookup.byNameAndFile(`${typeName}.${callName}`, relPath);
if (qualified.length > 0) return [...qualified];
}
// Narrow to function/method kinds only to avoid matching unrelated
// variables or classes that share a name in the same file.
return lookup
.byNameAndFile(callName, relPath)
.filter((n) => n.kind === 'function' || n.kind === 'method');
}

/**
* Apply fallback resolution strategies for a single call site when the
* primary resolveCallTargets pass returned no targets.
Expand Down Expand Up @@ -694,9 +659,11 @@ function applyCallFallbacks(
if (s2.length > 0) return s2;
}

// Strategy 3: Object.defineProperty accessor fallback.
// Strategy 3: Object.defineProperty accessor fallback. Shared with the
// full-build path (stages/build-edges.ts) via call-resolver.ts so both
// paths apply the same function/method kind filter (issue #1766).
if (call.receiver === 'this' && callerName != null && definePropertyReceivers) {
return resolveDefinePropertyTarget(
return resolveDefinePropertyAccessorTarget(
call.name,
callerName,
relPath,
Expand Down
57 changes: 24 additions & 33 deletions src/domain/graph/builder/stages/build-edges.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import {
findCaller,
isModuleScopedLanguage,
resolveCallTargets,
resolveDefinePropertyAccessorTarget,
resolveReceiverEdge,
resolveSameClassQualifiedMethod,
} from '../call-resolver.js';
Expand Down Expand Up @@ -720,22 +721,16 @@ function buildDefinePropertyPostPass(
);
if (directTargets.length > 0) continue;

// Resolve via receiver type
let targets: ReadonlyArray<{ id: number; file: string }> = [];
const typeEntry = typeMap.get(receiverVarName);
const typeName = typeEntry
? typeof typeEntry === 'string'
? typeEntry
: (typeEntry as { type?: string }).type
: null;
if (typeName) {
const qualifiedName = `${typeName}.${call.name}`;
targets = lookup.byNameAndFile(qualifiedName, relPath);
}
// Same-file fallback for plain object-literal methods
if (targets.length === 0) {
targets = lookup.byNameAndFile(call.name, relPath);
}
// Resolve via receiver type, restricted to function/method kinds
// (shared with the WASM-path fallback and incremental.ts — issue #1766).
const targets = resolveDefinePropertyAccessorTarget(
call.name,
caller.callerName,
relPath,
typeMap as Map<string, unknown>,
lookup,
definePropertyReceivers,
);

for (const t of targets) {
const edgeKey = `${caller.id}|${t.id}`;
Expand Down Expand Up @@ -1267,6 +1262,11 @@ function resolveReflectionKeyExprFallback(
* nothing, try treating `obj` as the receiver and look up `obj.X` in the
* typeMap, or fall back to a same-file lookup of any definition named X
* that belongs to the object literal or its type.
*
* Checks applicability (this-receiver + known caller + a receiver map to
* consult) then delegates the actual resolution to the shared
* `resolveDefinePropertyAccessorTarget` (call-resolver.ts), which is also
* used by the native-engine post-pass below and by incremental.ts.
*/
function resolveDefinePropertyAccessorFallback(
call: Call,
Expand All @@ -1277,23 +1277,14 @@ function resolveDefinePropertyAccessorFallback(
definePropertyReceivers: Map<string, string> | undefined,
): Array<{ id: number; file: string; kind?: string }> {
if (call.receiver !== 'this' || callerName == null || !definePropertyReceivers) return [];
const receiverVarName = definePropertyReceivers.get(callerName);
if (!receiverVarName) return [];

const typeName = unwrapTypeEntry(typeMap.get(receiverVarName));
if (typeName) {
const qualified = lookup.byNameAndFile(`${typeName}.${call.name}`, relPath);
if (qualified.length > 0) return [...qualified];
}
// If still no targets, search for any definition named `call.name` in
// the same file — handles plain object literals where the method isn't
// qualified (e.g. `const obj = { baz() {} }` defines `baz` directly).
// Note: this is intentionally broad — it matches any same-file definition
// with the called name, not just members of the receiver object. This is
// the same behaviour used by the native post-pass path (buildDefinePropertyPostPass).
const sameFile = lookup.byNameAndFile(call.name, relPath);
if (sameFile.length > 0) return [...sameFile];
return [];
return resolveDefinePropertyAccessorTarget(
call.name,
callerName,
relPath,
typeMap as Map<string, unknown>,
lookup,
definePropertyReceivers,
);
}

/**
Expand Down
Loading
Loading