From eeacd57822f333a124d8906b77dcdcb31108a9fc Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Sat, 25 Apr 2026 13:33:13 +0100 Subject: [PATCH] fix(scope): remove non-null assertions from scope resolution Made-with: Cursor --- .../languages/typescript/captures.ts | 53 +++++++++++-------- .../languages/typescript/scope-resolver.ts | 36 +++++++------ .../passes/compound-receiver.ts | 50 ++++++++++------- .../ingestion/scope-resolution/passes/mro.ts | 5 +- .../passes/receiver-bound-calls.ts | 11 ++-- 5 files changed, 93 insertions(+), 62 deletions(-) diff --git a/gitnexus/src/core/ingestion/languages/typescript/captures.ts b/gitnexus/src/core/ingestion/languages/typescript/captures.ts index f14b680a5..214ce03c4 100644 --- a/gitnexus/src/core/ingestion/languages/typescript/captures.ts +++ b/gitnexus/src/core/ingestion/languages/typescript/captures.ts @@ -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); } diff --git a/gitnexus/src/core/ingestion/languages/typescript/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/typescript/scope-resolver.ts index f125b06f1..33f49307d 100644 --- a/gitnexus/src/core/ingestion/languages/typescript/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/typescript/scope-resolver.ts @@ -45,28 +45,34 @@ interface TypescriptResolutionConfig { * total work for what should be O(N_files + N_imports). */ function makeTsResolveImportTarget(): ScopeResolver['resolveImportTarget'] { - let cachedAllFilePaths: ReadonlySet | null = null; - let cachedSet: Set | null = null; - let cachedAllFileList: readonly string[] | null = null; - let cachedNormalizedFileList: readonly string[] | null = null; - let cachedResolveCache: Map | null = null; + interface PassCache { + readonly key: ReadonlySet; + readonly allFilePaths: Set; + readonly allFileList: readonly string[]; + readonly normalizedFileList: readonly string[]; + readonly resolveCache: Map; + } + 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); diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts index d667db612..b89551689 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts @@ -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`). - 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); diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/mro.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/mro.ts index b9aa39bf9..a4d7e4cfb 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/mro.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/mro.ts @@ -97,8 +97,9 @@ export const defaultLinearize: LinearizeStrategy = (_classDefId, directParents, const ancestors: string[] = []; const visited = new Set(); 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); diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts index a6bf86e0e..2c994c0ae 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts @@ -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