[cli] Replace owner-filtered method lookups in type-env (#736)

* refactor(type-env): use owner method lookup

* test(type-env): cover owner lookup edge cases

* test(type-env): cover inherited overload ambiguity

---------

Co-authored-by: 许恩宁 <xuenning@qiyi.com>
This commit is contained in:
MyShining 2026-04-09 00:41:09 +08:00 • committed by GitHub
parent 3388ae16d7
commit 83b5bec293
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 418 additions and 18 deletions

View file

@ -459,19 +459,19 @@ const SKIP_SUBTREE_TYPES = new Set([
]);
const CLASS_LIKE_TYPES = new Set(['Class', 'Struct', 'Interface']);
type ClassDefRef = { nodeId: string; type: string; filePath: string };
const lookupClassDefsByName = (
symbolTable: SymbolTable,
name: string,
allowedTypes: ReadonlySet<string> = CLASS_LIKE_TYPES,
): Array<{ nodeId: string; type: string }> =>
symbolTable.lookupClassByName(name).filter((d) => allowedTypes.has(d.type));
): ClassDefRef[] => symbolTable.lookupClassByName(name).filter((d) => allowedTypes.has(d.type));
/** Memoize class definition lookups during fixpoint iteration.
* SymbolTable is immutable during type resolution, so results never change.
* Eliminates redundant array allocations + filter scans across iterations. */
const createClassDefCache = (symbolTable?: SymbolTable) => {
const cache = new Map<string, Array<{ nodeId: string; type: string }>>();
const cache = new Map<string, ClassDefRef[]>();
return (typeName: string) => {
let result = cache.get(typeName);
if (result === undefined) {
@ -561,7 +561,7 @@ export const isSubclassOf = (
const walkParentChain = <T>(
typeName: string,
parentMap: ReadonlyMap<string, readonly string[]> | undefined,
getClassDefs: (name: string) => Array<{ nodeId: string; type: string }>,
getClassDefs: (name: string) => ClassDefRef[],
lookupOnClass: (nodeId: string) => T | undefined,
): T | undefined => {
if (!parentMap) return undefined;
@ -597,7 +597,7 @@ const resolveFieldType = (
field: string,
scopeEnv: ReadonlyMap<string, string>,
symbolTable?: SymbolTable,
getClassDefs?: (typeName: string) => Array<{ nodeId: string; type: string }>,
getClassDefs?: (typeName: string) => ClassDefRef[],
parentMap?: ReadonlyMap<string, readonly string[]>,
): string | undefined => {
if (!symbolTable) return undefined;
@ -619,14 +619,14 @@ const resolveFieldType = (
/** Resolve a method's return type given a receiver variable and method name.
* Uses SymbolTable to find class nodeIds for the receiver's type, then
* looks up the method via lookupFuzzyCallable filtered by ownerId.
* looks up the method via owner-scoped lookupMethodByOwner.
* Falls back to MRO parent chain walking if direct lookup fails (Phase 11A). */
const resolveMethodReturnType = (
receiver: string,
method: string,
scopeEnv: ReadonlyMap<string, string>,
symbolTable?: SymbolTable,
getClassDefs?: (typeName: string) => Array<{ nodeId: string; type: string }>,
getClassDefs?: (typeName: string) => ClassDefRef[],
parentMap?: ReadonlyMap<string, readonly string[]>,
): string | undefined => {
if (!symbolTable) return undefined;
@ -642,21 +642,29 @@ const resolveMethodReturnType = (
const classDefs = lookup(receiverType);
if (classDefs.length === 0) return undefined;
// Direct lookup first
const classNodeIds = new Set(classDefs.map((d) => d.nodeId));
const methods = symbolTable
.lookupFuzzyCallable(method)
.filter((d) => d.ownerId && classNodeIds.has(d.ownerId));
const directMethodLookups = classDefs.map((d) => ({
classDef: d,
methodDef: symbolTable.lookupMethodByOwner(d.nodeId, method),
}));
const hasAmbiguousDirectLookup = directMethodLookups.some(({ classDef, methodDef }) => {
if (methodDef) return false;
return symbolTable
.lookupExactAll(classDef.filePath, method)
.some((d) => d.ownerId === classDef.nodeId);
});
if (hasAmbiguousDirectLookup) return undefined;
const methods = directMethodLookups
.map(({ methodDef }) => methodDef)
.filter((d): d is NonNullable<typeof d> => d !== undefined);
if (methods.length === 1 && methods[0].returnType) {
return extractReturnTypeName(methods[0].returnType);
}
// MRO parent chain walking on miss
if (methods.length === 0) {
const inherited = walkParentChain(receiverType, parentMap, lookup, (nodeId) => {
const parentMethods = symbolTable
.lookupFuzzyCallable(method)
.filter((d) => d.ownerId === nodeId);
if (parentMethods.length !== 1 || !parentMethods[0].returnType) return undefined;
return extractReturnTypeName(parentMethods[0].returnType);
const parentMethod = symbolTable.lookupMethodByOwner(nodeId, method);
if (!parentMethod?.returnType) return undefined;
return extractReturnTypeName(parentMethod.returnType);
});
return inherited;
}

View file

@ -1,6 +1,10 @@
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi } from 'vitest';
import { buildTypeEnv, type TypeEnvironment } from '../../src/core/ingestion/type-env.js';
import type { SymbolDefinition, SymbolTable } from '../../src/core/ingestion/symbol-table.js';
import {
createSymbolTable,
type SymbolDefinition,
type SymbolTable,
} from '../../src/core/ingestion/symbol-table.js';
import {
stripNullable,
extractSimpleTypeName,
@ -2445,6 +2449,394 @@ function process(user: User) {
const typeEnv = buildTypeEnv(tree, 'typescript', { symbolTable });
expect(flatGet(typeEnv, 'addr')).toBeUndefined();
});
it('method return type resolution uses lookupMethodByOwner-backed class defs', () => {
const tree = parse(
`
function process(repo: Repo) {
const profile = repo.getProfile();
}
`,
TypeScript.typescript,
);
const lookupFuzzyCallable = vi.fn(() => []);
const symbolTable = createMockSymbolTable({
lookupClassByName: (name: string) =>
name === 'Repo' ? [createClassDef('Repo', 'Class', 'models.ts')] : [],
lookupMethodByOwner: (ownerNodeId: string, methodName: string) =>
ownerNodeId === 'class:Repo' && methodName === 'getProfile'
? {
nodeId: 'method:Repo:getProfile',
filePath: 'models.ts',
type: 'Method',
ownerId: 'class:Repo',
returnType: 'Profile',
}
: undefined,
lookupFuzzyCallable,
});
const typeEnv = buildTypeEnv(tree, 'typescript', { symbolTable });
expect(flatGet(typeEnv, 'profile')).toBe('Profile');
expect(lookupFuzzyCallable).not.toHaveBeenCalledWith('getProfile');
});
it('inherited method return type resolution uses lookupMethodByOwner on parent owners', () => {
const tree = parse(
`
function process(repo: Repo) {
const profile = repo.getProfile();
}
`,
TypeScript.typescript,
);
const lookupFuzzyCallable = vi.fn(() => []);
const symbolTable = createMockSymbolTable({
lookupClassByName: (name: string) => {
if (name === 'Repo') return [createClassDef('Repo', 'Class', 'models.ts')];
if (name === 'BaseRepo') return [createClassDef('BaseRepo', 'Class', 'base.ts')];
return [];
},
lookupMethodByOwner: (ownerNodeId: string, methodName: string) =>
ownerNodeId === 'class:BaseRepo' && methodName === 'getProfile'
? {
nodeId: 'method:BaseRepo:getProfile',
filePath: 'base.ts',
type: 'Method',
ownerId: 'class:BaseRepo',
returnType: 'Profile',
}
: undefined,
lookupFuzzyCallable,
});
const typeEnv = buildTypeEnv(tree, 'typescript', {
symbolTable,
parentMap: new Map([['Repo', ['BaseRepo']]]),
});
expect(flatGet(typeEnv, 'profile')).toBe('Profile');
expect(lookupFuzzyCallable).not.toHaveBeenCalledWith('getProfile');
});
it('method return type resolution handles multiple class defs when only one owner has the method', () => {
const tree = parse(
`
function process(repo: Repo) {
const profile = repo.getProfile();
}
`,
TypeScript.typescript,
);
const lookupFuzzyCallable = vi.fn(() => []);
const symbolTable = createMockSymbolTable({
lookupClassByName: (name: string) =>
name === 'Repo'
? [
createClassDef('Repo', 'Class', 'models-a.ts'),
{
...createClassDef('Repo', 'Class', 'models-b.ts'),
nodeId: 'class:Repo:partial',
},
]
: [],
lookupMethodByOwner: (ownerNodeId: string, methodName: string) =>
ownerNodeId === 'class:Repo:partial' && methodName === 'getProfile'
? {
nodeId: 'method:Repo:getProfile',
filePath: 'models-b.ts',
type: 'Method',
ownerId: 'class:Repo:partial',
returnType: 'Profile',
}
: undefined,
lookupExactAll: () => [],
lookupFuzzyCallable,
});
const typeEnv = buildTypeEnv(tree, 'typescript', { symbolTable });
expect(flatGet(typeEnv, 'profile')).toBe('Profile');
expect(lookupFuzzyCallable).not.toHaveBeenCalledWith('getProfile');
});
it('method return type resolution with multiple class defs falls back to MRO when direct owners miss', () => {
const tree = parse(
`
function process(repo: Repo) {
const profile = repo.getProfile();
}
`,
TypeScript.typescript,
);
const lookupFuzzyCallable = vi.fn(() => []);
const symbolTable = createMockSymbolTable({
lookupClassByName: (name: string) => {
if (name === 'Repo') {
return [
createClassDef('Repo', 'Class', 'models-a.ts'),
{ ...createClassDef('Repo', 'Class', 'models-b.ts'), nodeId: 'class:Repo:partial' },
];
}
if (name === 'BaseRepo') return [createClassDef('BaseRepo', 'Class', 'base.ts')];
return [];
},
lookupMethodByOwner: (ownerNodeId: string, methodName: string) =>
ownerNodeId === 'class:BaseRepo' && methodName === 'getProfile'
? {
nodeId: 'method:BaseRepo:getProfile',
filePath: 'base.ts',
type: 'Method',
ownerId: 'class:BaseRepo',
returnType: 'Profile',
}
: undefined,
lookupExactAll: () => [],
lookupFuzzyCallable,
});
const typeEnv = buildTypeEnv(tree, 'typescript', {
symbolTable,
parentMap: new Map([['Repo', ['BaseRepo']]]),
});
expect(flatGet(typeEnv, 'profile')).toBe('Profile');
expect(lookupFuzzyCallable).not.toHaveBeenCalledWith('getProfile');
});
it('method return type resolution stays unresolved when multiple class defs each define the method', () => {
const tree = parse(
`
function process(repo: Repo) {
const profile = repo.getProfile();
}
`,
TypeScript.typescript,
);
const lookupFuzzyCallable = vi.fn(() => []);
const symbolTable = createMockSymbolTable({
lookupClassByName: (name: string) =>
name === 'Repo'
? [
createClassDef('Repo', 'Class', 'models-a.ts'),
{
...createClassDef('Repo', 'Class', 'models-b.ts'),
nodeId: 'class:Repo:partial',
},
]
: [],
lookupMethodByOwner: (ownerNodeId: string, methodName: string) => {
if (methodName !== 'getProfile') return undefined;
if (ownerNodeId === 'class:Repo') {
return {
nodeId: 'method:Repo:getProfile#a',
filePath: 'models-a.ts',
type: 'Method',
ownerId: 'class:Repo',
returnType: 'Profile',
};
}
if (ownerNodeId === 'class:Repo:partial') {
return {
nodeId: 'method:Repo:getProfile#b',
filePath: 'models-b.ts',
type: 'Method',
ownerId: 'class:Repo:partial',
returnType: 'Profile',
};
}
return undefined;
},
lookupExactAll: () => [],
lookupFuzzyCallable,
});
const typeEnv = buildTypeEnv(tree, 'typescript', { symbolTable });
expect(flatGet(typeEnv, 'profile')).toBeUndefined();
expect(lookupFuzzyCallable).not.toHaveBeenCalledWith('getProfile');
});
it('method return type resolution preserves same-return overload success', () => {
const tree = parse(
`
function process(repo: Repo) {
const profile = repo.getProfile();
}
`,
TypeScript.typescript,
);
const lookupFuzzyCallable = vi.fn(() => []);
const symbolTable = createMockSymbolTable({
lookupClassByName: (name: string) =>
name === 'Repo' ? [createClassDef('Repo', 'Class', 'models.ts')] : [],
lookupMethodByOwner: (ownerNodeId: string, methodName: string) =>
ownerNodeId === 'class:Repo' && methodName === 'getProfile'
? {
nodeId: 'method:Repo:getProfile#1',
filePath: 'models.ts',
type: 'Method',
ownerId: 'class:Repo',
returnType: 'Profile',
}
: undefined,
lookupExactAll: (filePath: string, name: string) =>
filePath === 'models.ts' && name === 'getProfile'
? [
{
nodeId: 'method:Repo:getProfile#1',
filePath: 'models.ts',
type: 'Method',
ownerId: 'class:Repo',
returnType: 'Profile',
},
{
nodeId: 'method:Repo:getProfile#2',
filePath: 'models.ts',
type: 'Method',
ownerId: 'class:Repo',
returnType: 'Profile',
},
]
: [],
lookupFuzzyCallable,
});
const typeEnv = buildTypeEnv(tree, 'typescript', { symbolTable });
expect(flatGet(typeEnv, 'profile')).toBe('Profile');
expect(lookupFuzzyCallable).not.toHaveBeenCalledWith('getProfile');
});
it('method return type resolution stays unresolved for ambiguous overloads with differing returns', () => {
const tree = parse(
`
function process(repo: Repo) {
const profile = repo.getProfile();
}
`,
TypeScript.typescript,
);
const lookupFuzzyCallable = vi.fn(() => []);
const symbolTable = createMockSymbolTable({
lookupClassByName: (name: string) => {
if (name === 'Repo') return [createClassDef('Repo', 'Class', 'models.ts')];
if (name === 'BaseRepo') return [createClassDef('BaseRepo', 'Class', 'base.ts')];
return [];
},
lookupMethodByOwner: (ownerNodeId: string, methodName: string) =>
ownerNodeId === 'class:BaseRepo' && methodName === 'getProfile'
? {
nodeId: 'method:BaseRepo:getProfile',
filePath: 'base.ts',
type: 'Method',
ownerId: 'class:BaseRepo',
returnType: 'Profile',
}
: undefined,
lookupExactAll: (filePath: string, name: string) =>
filePath === 'models.ts' && name === 'getProfile'
? [
{
nodeId: 'method:Repo:getProfile#1',
filePath: 'models.ts',
type: 'Method',
ownerId: 'class:Repo',
returnType: 'User',
},
{
nodeId: 'method:Repo:getProfile#2',
filePath: 'models.ts',
type: 'Method',
ownerId: 'class:Repo',
returnType: 'Admin',
},
]
: [],
lookupFuzzyCallable,
});
const typeEnv = buildTypeEnv(tree, 'typescript', {
symbolTable,
parentMap: new Map([['Repo', ['BaseRepo']]]),
});
expect(flatGet(typeEnv, 'profile')).toBeUndefined();
expect(lookupFuzzyCallable).not.toHaveBeenCalledWith('getProfile');
});
it('inherited method return type resolution preserves same-return overload success on parent owners', () => {
const tree = parse(
`
function process(repo: Repo) {
const profile = repo.getProfile();
}
`,
TypeScript.typescript,
);
const lookupFuzzyCallable = vi.fn(() => []);
const symbolTable = createMockSymbolTable({
lookupClassByName: (name: string) => {
if (name === 'Repo') return [createClassDef('Repo', 'Class', 'models.ts')];
if (name === 'BaseRepo') return [createClassDef('BaseRepo', 'Class', 'base.ts')];
return [];
},
lookupMethodByOwner: (ownerNodeId: string, methodName: string) =>
ownerNodeId === 'class:BaseRepo' && methodName === 'getProfile'
? {
nodeId: 'method:BaseRepo:getProfile#1',
filePath: 'base.ts',
type: 'Method',
ownerId: 'class:BaseRepo',
returnType: 'Profile',
}
: undefined,
lookupExactAll: (filePath: string, name: string) =>
filePath === 'base.ts' && name === 'getProfile'
? [
{
nodeId: 'method:BaseRepo:getProfile#1',
filePath: 'base.ts',
type: 'Method',
ownerId: 'class:BaseRepo',
returnType: 'Profile',
},
{
nodeId: 'method:BaseRepo:getProfile#2',
filePath: 'base.ts',
type: 'Method',
ownerId: 'class:BaseRepo',
returnType: 'Profile',
},
]
: [],
lookupFuzzyCallable,
});
const typeEnv = buildTypeEnv(tree, 'typescript', {
symbolTable,
parentMap: new Map([['Repo', ['BaseRepo']]]),
});
expect(flatGet(typeEnv, 'profile')).toBe('Profile');
expect(lookupFuzzyCallable).not.toHaveBeenCalledWith('getProfile');
});
it('inherited method return type resolution stays unresolved for ambiguous overloads on parent owners', () => {
const tree = parse(
`
function process(repo: Repo) {
const profile = repo.getProfile();
}
`,
TypeScript.typescript,
);
const symbolTable = createSymbolTable();
symbolTable.add('models.ts', 'Repo', 'class:Repo', 'Class');
symbolTable.add('base.ts', 'BaseRepo', 'class:BaseRepo', 'Class');
symbolTable.add('base.ts', 'getProfile', 'method:BaseRepo:getProfile#1', 'Method', {
ownerId: 'class:BaseRepo',
parameterCount: 1,
returnType: 'User',
});
symbolTable.add('base.ts', 'getProfile', 'method:BaseRepo:getProfile#2', 'Method', {
ownerId: 'class:BaseRepo',
parameterCount: 2,
returnType: 'Admin',
});
const lookupFuzzyCallable = vi.spyOn(symbolTable, 'lookupFuzzyCallable');
const typeEnv = buildTypeEnv(tree, 'typescript', {
symbolTable,
parentMap: new Map([['Repo', ['BaseRepo']]]),
});
expect(flatGet(typeEnv, 'profile')).toBeUndefined();
expect(lookupFuzzyCallable).not.toHaveBeenCalled();
});
});
describe('Python walrus operator type inference', () => {