fix(resolution): calls through an imported singleton resolve to the method (#1315)

reproStore.notifyJoinGuildStatus() after `import { reproStore }`
resolved its calls edge to the exported CONSTANT (resolvedBy:'import'),
while the identical same-file call resolved to the method via
local-variable receiver inference (#1108) — so `callers <method>`
missed every cross-file use and a widely-used method could look
unused (#1292).

resolveViaImport's member-descend now handles imported VALUES alongside
the #825 static-member case: when the base resolves to a
constant/variable, the value's type is inferred from ITS OWN
declaration lines in the exporting file (the shared #1108 pattern
table: `= new T(...)` initializers and type annotations) and the member
is resolved AND VALIDATED on that type via resolveMethodOnType. A
failed inference or validation keeps the existing constant edge —
never a fabricated one. Calls only; plain member reads still reference
the value.

excalidraw control: byte-identical graph (10,653 nodes / 19,483 calls
edges before and after).

Fixes #1292

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-07-16 15:36:33 -05:00
committed by GitHub
co-authored by Claude Fable 5
parent ce983a08fe
commit 2ec877b08c
4 changed files with 117 additions and 2 deletions
+59
View File
@@ -10,6 +10,11 @@ import { Language, Node } from '../types';
import { UnresolvedRef, ResolvedRef, ResolutionContext, ImportMapping, ReExport } from './types';
import { applyAliases } from './path-aliases';
import { resolveWorkspaceImport } from './workspace-packages';
import {
resolveMethodOnType,
localReceiverTypePatterns,
normalizeInferredTypeName,
} from './name-matcher';
/**
* Extension resolution order by language
@@ -1512,6 +1517,18 @@ export function resolveViaImport(
resolvedBy: 'import',
};
}
// An imported VALUE (singleton constant / shared instance) called
// through a member: `reproStore.notifyJoinGuildStatus()` after
// `import { reproStore } from './store'`. findExportedSymbol
// resolved the CONSTANT itself; linking the CALL there hides the
// real callee — callers of the method miss every cross-file use
// and the method can look unused (#1292). Infer the value's type
// from its own declaration in the exporting file and resolve the
// member on that type. resolveMethodOnType VALIDATES the type
// declares the method, so a mis-inference falls through to the
// constant edge below rather than fabricating a wrong one.
const instanceMember = resolveImportedInstanceMember(targetNode, ref, imp.localName, context);
if (instanceMember) return instanceMember;
}
return {
@@ -2158,6 +2175,48 @@ const STATIC_MEMBER_CONTAINERS = new Set<Node['kind']>([
* languages whose members aren't `::`-qualified, and genuine class references,
* are unaffected. See #825.
*/
/**
* Resolve a CALL through an imported value to the method on the value's own
* type: `reproStore.notifyJoinGuildStatus()` where `reproStore` is
* `export const reproStore = new ReproStore()` in the imported file (#1292).
* The same-file form of this call already resolves via local-variable
* receiver inference (#1108); this is the cross-file/import half. The type is
* recovered from the VALUE'S OWN declaration lines in the exporting file
* (initializer `= new T(...)` or a type annotation, per the shared #1108
* pattern table), then the member is resolved AND VALIDATED on that type by
* resolveMethodOnType — a failed inference or validation returns null so the
* caller keeps its existing constant-edge behavior.
*/
function resolveImportedInstanceMember(
value: Node,
ref: UnresolvedRef,
localName: string,
context: ResolutionContext
): ResolvedRef | null {
if (ref.referenceKind !== 'calls') return null;
if (value.kind !== 'constant' && value.kind !== 'variable') return null;
const member = ref.referenceName.slice(localName.length + 1).split('.')[0];
if (!member) return null;
const source = context.readFile(value.filePath);
if (!source) return null;
// Only the value's own declaration lines — never the whole file, so a
// same-named identifier elsewhere can't donate a type.
const lines = source.split('\n');
const declSlice = lines.slice(Math.max(0, value.startLine - 1), value.endLine).join('\n');
const receiver = value.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
for (const pattern of localReceiverTypePatterns(value.language as Language, receiver)) {
const m = declSlice.match(pattern);
if (!m || !m[1]) continue;
const typeName = normalizeInferredTypeName(m[1]);
if (!typeName) continue;
const resolved = resolveMethodOnType(typeName, member, ref, context, 0.85, 'instance-method');
if (resolved) return resolved;
}
return null;
}
function resolveStaticMember(
container: Node,
ref: UnresolvedRef,
+2 -2
View File
@@ -1075,7 +1075,7 @@ const NON_TYPE_RECEIVER_TOKENS = new Set([
* args and pointer/ref markers, take the last `.`/`::`-qualified segment, and
* reject obvious non-types.
*/
function normalizeInferredTypeName(raw: string): string | null {
export function normalizeInferredTypeName(raw: string): string | null {
const cleaned = raw.replace(/<[^>]*>/g, '').replace(/[&*]/g, '').trim();
const seg = cleaned.split(/[.:]+/).filter(Boolean).pop();
if (!seg) return null;
@@ -1090,7 +1090,7 @@ function normalizeInferredTypeName(raw: string): string | null {
* PascalCase is required in the capture where the language convention allows,
* as a cheap false-positive guard on top of resolveMethodOnType's validation.
*/
function localReceiverTypePatterns(language: Language, r: string): RegExp[] {
export function localReceiverTypePatterns(language: Language, r: string): RegExp[] {
switch (language) {
case 'typescript':
case 'javascript':