fix(scope): remove non-null assertions from scope resolution

Made-with: Cursor
This commit is contained in:
Gergo Magyar 2026-04-25 13:33:13 +01:00
parent fc919ad6de
commit eeacd57822
5 changed files with 93 additions and 62 deletions

View file

@ -62,6 +62,14 @@ const CALL_TAGS = [
'@reference.call.constructor',
] as const;
function pickFirstDefined(grouped: CaptureMatch, tags: readonly string[]): Capture | undefined {
for (const tag of tags) {
const cap = grouped[tag];
if (cap !== undefined) return cap;
}
return undefined;
}
/**
* Drop `@reference.read.member` matches whose underlying `member_expression`
* is NOT actually a read context:
@ -189,10 +197,9 @@ export function emitTsScopeCaptures(
// overloads — TypeScript supports overload signatures via
// function_signature, so `parameterTypes` is populated when
// available.
const declTag = FUNCTION_DECL_TAGS.find((t) => grouped[t] !== undefined);
if (declTag !== undefined) {
const anchor = grouped[declTag]!;
const fnNode = findFunctionNode(tree.rootNode, anchor.range);
const declAnchor = pickFirstDefined(grouped, FUNCTION_DECL_TAGS);
if (declAnchor !== undefined) {
const fnNode = findFunctionNode(tree.rootNode, declAnchor.range);
if (fnNode !== null) {
const arity = computeTsArityMetadata(fnNode);
if (arity.parameterCount !== undefined) {
@ -223,25 +230,26 @@ export function emitTsScopeCaptures(
// arity filter can narrow overloads. Count the `argument` named
// children of the backing `arguments` node. TypeScript constructor
// calls use `new_expression`; regular calls use `call_expression`.
const callTag = CALL_TAGS.find((t) => grouped[t] !== undefined);
if (callTag !== undefined && grouped['@reference.arity'] === undefined) {
const anchor = grouped[callTag]!;
const callAnchor = pickFirstDefined(grouped, CALL_TAGS);
if (callAnchor !== undefined && grouped['@reference.arity'] === undefined) {
const callNode =
findNodeAtRange(tree.rootNode, anchor.range, 'call_expression') ??
findNodeAtRange(tree.rootNode, anchor.range, 'new_expression');
findNodeAtRange(tree.rootNode, callAnchor.range, 'call_expression') ??
findNodeAtRange(tree.rootNode, callAnchor.range, 'new_expression');
if (callNode !== null) {
const argList = callNode.childForFieldName('arguments');
const args =
const args: SyntaxNode[] =
argList === null
? []
: argList.namedChildren.filter((c) => c !== null && c.type !== 'comment');
: argList.namedChildren.filter(
(c): c is SyntaxNode => c !== null && c.type !== 'comment',
);
grouped['@reference.arity'] = syntheticCapture(
'@reference.arity',
callNode,
String(args.length),
);
const argTypes = args.map((arg) => inferArgType(arg!));
const argTypes = args.map((arg) => inferArgType(arg));
grouped['@reference.parameter-types'] = syntheticCapture(
'@reference.parameter-types',
callNode,
@ -258,9 +266,9 @@ export function emitTsScopeCaptures(
// signature, arrow/function-expression assigned to a class field).
// Arrow functions nested inside method bodies rely on scope-chain
// lookup instead of synthesis — covered by `tsReceiverBinding`.
if (grouped['@scope.function'] !== undefined) {
const anchor = grouped['@scope.function']!;
const fnNode = findFunctionNode(tree.rootNode, anchor.range);
const scopeFnAnchor = grouped['@scope.function'];
if (scopeFnAnchor !== undefined) {
const fnNode = findFunctionNode(tree.rootNode, scopeFnAnchor.range);
if (fnNode !== null) {
const synth = synthesizeTsReceiverBinding(fnNode);
if (synth !== null) out.push(synth);
@ -301,8 +309,9 @@ export function emitTsScopeCaptures(
*/
function synthesizeDestructuringBindings(root: SyntaxNode, out: CaptureMatch[]): void {
const stack: SyntaxNode[] = [root];
while (stack.length > 0) {
const node = stack.pop()!;
for (;;) {
const node = stack.pop();
if (node === undefined) break;
for (const child of node.namedChildren) {
if (child !== null) stack.push(child);
}
@ -364,8 +373,9 @@ function synthesizeDestructuringBindings(root: SyntaxNode, out: CaptureMatch[]):
*/
function synthesizeForOfMapTupleBindings(root: SyntaxNode, out: CaptureMatch[]): void {
const stack: SyntaxNode[] = [root];
while (stack.length > 0) {
const node = stack.pop()!;
for (;;) {
const node = stack.pop();
if (node === undefined) break;
for (const child of node.namedChildren) {
if (child !== null) stack.push(child);
}
@ -412,8 +422,9 @@ function synthesizeForOfMapTupleBindings(root: SyntaxNode, out: CaptureMatch[]):
*/
function synthesizeInstanceofNarrowings(root: SyntaxNode, out: CaptureMatch[]): void {
const stack: SyntaxNode[] = [root];
while (stack.length > 0) {
const node = stack.pop()!;
for (;;) {
const node = stack.pop();
if (node === undefined) break;
for (const child of node.namedChildren) {
if (child !== null) stack.push(child);
}

View file

@ -45,28 +45,34 @@ interface TypescriptResolutionConfig {
* total work for what should be O(N_files + N_imports).
*/
function makeTsResolveImportTarget(): ScopeResolver['resolveImportTarget'] {
let cachedAllFilePaths: ReadonlySet<string> | null = null;
let cachedSet: Set<string> | null = null;
let cachedAllFileList: readonly string[] | null = null;
let cachedNormalizedFileList: readonly string[] | null = null;
let cachedResolveCache: Map<string, string | null> | null = null;
interface PassCache {
readonly key: ReadonlySet<string>;
readonly allFilePaths: Set<string>;
readonly allFileList: readonly string[];
readonly normalizedFileList: readonly string[];
readonly resolveCache: Map<string, string | null>;
}
let cached: PassCache | null = null;
return (targetRaw, fromFile, allFilePaths, resolutionConfig) => {
if (cachedAllFilePaths !== allFilePaths) {
cachedAllFilePaths = allFilePaths;
cachedSet = new Set(allFilePaths);
cachedAllFileList = Array.from(allFilePaths);
cachedNormalizedFileList = cachedAllFileList.map((f) => f.toLowerCase());
cachedResolveCache = new Map();
if (cached === null || cached.key !== allFilePaths) {
const allFileList = Array.from(allFilePaths);
cached = {
key: allFilePaths,
allFilePaths: new Set(allFilePaths),
allFileList,
normalizedFileList: allFileList.map((f) => f.toLowerCase()),
resolveCache: new Map(),
};
}
const cfg = resolutionConfig as TypescriptResolutionConfig | undefined;
const ws: TsResolveContext = {
fromFile,
allFilePaths: cachedSet!,
allFileList: cachedAllFileList!,
normalizedFileList: cachedNormalizedFileList!,
resolveCache: cachedResolveCache!,
allFilePaths: cached.allFilePaths,
allFileList: cached.allFileList,
normalizedFileList: cached.normalizedFileList,
resolveCache: cached.resolveCache,
tsconfigPaths: cfg?.tsconfigPaths ?? null,
};
return resolveTsTarget(targetRaw, ws);

View file

@ -34,6 +34,16 @@ import {
* pathological recursion if the receiver text is malformed. */
const COMPOUND_RECEIVER_MAX_DEPTH = 4;
const MAP_TUPLE_SENTINEL_RE = /^__MAP_TUPLE_(\d+)__:(.+)$/;
function parseMapTupleSentinel(text: string): { tupleIdx: number; rhs: string } | null {
const match = MAP_TUPLE_SENTINEL_RE.exec(text);
if (match === null) return null;
const [, idxStr, rhs] = match;
if (idxStr === undefined || rhs === undefined) return null;
return { tupleIdx: Number(idxStr), rhs };
}
interface ResolveCompoundReceiverOptions {
/** When true (default), if method lookup fails on the receiver's
* class, walk its fields and try the lookup on each field's class.
@ -73,13 +83,11 @@ export function resolveCompoundReceiverClass(
// "static receiver" shapes like `UserService.findUser()` where
// `UserService` isn't a variable but a class imported into scope.
if (!text.includes('.') && !text.includes('(')) {
const mapTuple = /^__MAP_TUPLE_(\d+)__:(.+)$/.exec(text);
const mapTuple = parseMapTupleSentinel(text);
if (mapTuple !== null) {
const tupleIdx = Number(mapTuple[1]);
const rhs = mapTuple[2]!;
const rhsTb = findReceiverTypeBinding(inScope, rhs, scopes);
const rhsTb = findReceiverTypeBinding(inScope, mapTuple.rhs, scopes);
if (rhsTb === undefined) return undefined;
const arg = extractShallowMapTypeArgByIndex(rhsTb.rawName, tupleIdx);
const arg = extractShallowMapTypeArgByIndex(rhsTb.rawName, mapTuple.tupleIdx);
if (arg === undefined) return undefined;
return findClassBindingInScope(rhsTb.declaredAtScope, arg, scopes);
}
@ -89,13 +97,11 @@ export function resolveCompoundReceiverClass(
// Map for-of: binding name is `user` but rawType is
// `__MAP_TUPLE_i__:entries` (see captures.ts) — same extraction as
// the literal-sentinel branch above.
const boundMapTuple = /^__MAP_TUPLE_(\d+)__:(.+)$/.exec(tb.rawName);
const boundMapTuple = parseMapTupleSentinel(tb.rawName);
if (boundMapTuple !== null) {
const tupleIdx = Number(boundMapTuple[1]);
const rhs = boundMapTuple[2]!;
const rhsTb = findReceiverTypeBinding(inScope, rhs, scopes);
const rhsTb = findReceiverTypeBinding(inScope, boundMapTuple.rhs, scopes);
if (rhsTb === undefined) return undefined;
const arg = extractShallowMapTypeArgByIndex(rhsTb.rawName, tupleIdx);
const arg = extractShallowMapTypeArgByIndex(rhsTb.rawName, boundMapTuple.tupleIdx);
if (arg === undefined) return undefined;
return findClassBindingInScope(rhsTb.declaredAtScope, arg, scopes);
}
@ -266,7 +272,9 @@ export function resolveCompoundReceiverClass(
// the element class directly. Resolved before the field-walk
// because Dictionary-family types aren't local class defs.
if (options.unwrapCollectionAccessor !== undefined && parts.length >= 2) {
const last = parts[parts.length - 1]!;
const last = parts[parts.length - 1];
const headInner = parts[0];
if (last === undefined || headInner === undefined) return undefined;
const prefix = parts.slice(0, -1).join('.');
let prefixType: TypeRef | undefined;
if (parts.length === 2) {
@ -276,16 +284,17 @@ export function resolveCompoundReceiverClass(
// to find its typeRef. We need the TypeRef (not the class def)
// because the hook inspects the raw generic args (e.g.
// `Dictionary<string, User>`).
const headInner = parts[0]!;
let cur = findReceiverTypeBinding(inScope, headInner, scopes);
for (let i = 1; i < parts.length - 1 && cur !== undefined; i++) {
const segment = parts[i];
if (segment === undefined) break;
const cls = findClassBindingInScope(cur.declaredAtScope, cur.rawName, scopes);
if (cls === undefined) {
cur = undefined;
break;
}
const cs = classScopeByDefId.get(cls.nodeId);
cur = cs?.typeBindings.get(parts[i]!);
cur = cs?.typeBindings.get(segment);
}
prefixType = cur;
}
@ -297,7 +306,8 @@ export function resolveCompoundReceiverClass(
}
}
const head = parts[0]!;
const head = parts[0];
if (head === undefined) return undefined;
const headMemberName = stripCallParens(head);
const headType = findReceiverTypeBinding(inScope, headMemberName, scopes);
let currentClass: SymbolDefinition | undefined = headType
@ -323,7 +333,8 @@ export function resolveCompoundReceiverClass(
);
}
for (let i = 1; i < parts.length && currentClass !== undefined; i++) {
const segment = parts[i]!;
const segment = parts[i];
if (segment === undefined) break;
const memberName = stripCallParens(segment);
const cs = classScopeByDefId.get(currentClass.nodeId);
let memberType = cs?.typeBindings.get(memberName);
@ -478,8 +489,9 @@ function resolveMapValueTypeNameFromPrefix(
): string | undefined {
const classScopeByDefId = index.classScopeByDefId;
const parts = splitChainAtTopLevel(objExpr);
if (parts.length === 0) return undefined;
const headMemberName = stripCallParens(parts[0]!);
const head = parts[0];
if (head === undefined) return undefined;
const headMemberName = stripCallParens(head);
const headType = findReceiverTypeBinding(inScope, headMemberName, scopes);
let currentClass: SymbolDefinition | undefined = headType
? findClassBindingInScope(headType.declaredAtScope, headType.rawName, scopes)
@ -501,7 +513,9 @@ function resolveMapValueTypeNameFromPrefix(
}
let lastMemberType: TypeRef | undefined;
for (let i = 1; i < parts.length && currentClass !== undefined; i++) {
const memberName = stripCallParens(parts[i]!);
const segment = parts[i];
if (segment === undefined) break;
const memberName = stripCallParens(segment);
const cs = classScopeByDefId.get(currentClass.nodeId);
if (cs === undefined) return undefined;
let memberType = cs.typeBindings.get(memberName);

View file

@ -97,8 +97,9 @@ export const defaultLinearize: LinearizeStrategy = (_classDefId, directParents,
const ancestors: string[] = [];
const visited = new Set<string>();
const queue: string[] = [...directParents];
while (queue.length > 0) {
const cur = queue.shift()!;
for (;;) {
const cur = queue.shift();
if (cur === undefined) break;
if (visited.has(cur)) continue;
visited.add(cur);
ancestors.push(cur);

View file

@ -335,12 +335,11 @@ export function emitReceiverBoundCalls(
}
// ── Case 3b: chain-typebinding (`city → user.get_city`) ──────
if (
typeRef !== undefined &&
typeRef.rawName.includes('.') &&
!typeRef.rawName.includes('(') &&
!namespaceTargets.has(typeRef.rawName.split('.')[0]!)
) {
const chainHead =
typeRef !== undefined && typeRef.rawName.includes('.') && !typeRef.rawName.includes('(')
? (typeRef.rawName.split('.', 1)[0] ?? '')
: undefined;
if (typeRef !== undefined && chainHead !== undefined && !namespaceTargets.has(chainHead)) {
// Try the plain dotted-field walk first — covers property /
// collection-accessor shapes (`.Values`, Kotlin `.size`) and
// field chains. Fall back to call-form (`x()`) which treats