diff --git a/gitnexus-shared/src/scope-resolution/position-index.ts b/gitnexus-shared/src/scope-resolution/position-index.ts index 4ba30600d..c6f7ad39f 100644 --- a/gitnexus-shared/src/scope-resolution/position-index.ts +++ b/gitnexus-shared/src/scope-resolution/position-index.ts @@ -203,6 +203,9 @@ function wrapIndex(files: ReadonlyMap, size: number): Positio const endIdx = findLastStartLteIndex(file.entries, line, col); if (endIdx < 0) return undefined; + // The innermost scope usually starts last; it is then the rightmost match. + const latest = file.entries[endIdx]; + if (latest && endIsAtOrAfter(latest.range, line, col)) return latest.id; const match = findLastContainingEntry(file, endIdx, line, col); return match < 0 ? undefined : file.entries[match]!.id; diff --git a/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts b/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts index c2312c7b5..725df68f1 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts @@ -1566,12 +1566,14 @@ export async function runChunkedParseAndResolve( const routeFilePaths = new Set(allPaths); const routeResolutionConfigs = new Map(); - const resolutionFilePaths = new Set([ - ...allRouteFacts.map((file) => file.filePath), - ...allFetchWrapperDefs.map((definition) => definition.filePath), - ...allFetchWrapperCalls.map((call) => call.filePath), - ...allFetchWrapperReexports.map((reexport) => reexport.filePath), - ]); + const resolutionFilePaths = new Set(); + for (const facts of [ + allRouteFacts, + allFetchWrapperDefs, + allFetchWrapperCalls, + allFetchWrapperReexports, + ] as const) + for (const { filePath } of facts) resolutionFilePaths.add(filePath); for (const filePath of resolutionFilePaths) { const language = getLanguageFromFilename(filePath); if (language === null || routeResolutionConfigs.has(language)) continue; diff --git a/gitnexus/src/core/ingestion/pipeline-phases/routes.ts b/gitnexus/src/core/ingestion/pipeline-phases/routes.ts index 61cb9b6ed..cf4e6f3b0 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/routes.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/routes.ts @@ -507,9 +507,10 @@ export const routesPhase: PipelinePhase = { (n): n is string => typeof n === 'string' && n.trim().length > 0, ); // The index is only worth building when a candidate call can match a route. - if (routeRegistry.size > 0 && (allFetchWrapperCalls?.length ?? 0) > 0) { + const wrapperCalls = allFetchWrapperCalls ?? []; + if (routeRegistry.size > 0 && wrapperCalls.length > 0) { const wrappers = buildFetchWrapperIndex(allFetchWrapperDefs ?? [], configuredWrappers); - for (const call of allFetchWrapperCalls ?? []) { + for (const call of wrapperCalls) { if (wrappers.isWrapperCall(call)) allFetchCalls.push(call); } } diff --git a/gitnexus/src/core/ingestion/route-extractors/fetch-wrapper.ts b/gitnexus/src/core/ingestion/route-extractors/fetch-wrapper.ts index c241ccf23..481d13126 100644 --- a/gitnexus/src/core/ingestion/route-extractors/fetch-wrapper.ts +++ b/gitnexus/src/core/ingestion/route-extractors/fetch-wrapper.ts @@ -7,6 +7,8 @@ import { bindingKey, createLexicalBindings, importBindingKey, + isCommonJsGlobal, + isModuleExports, LEXICAL_FUNCTION_TYPES, parameterPattern, patternBindingNames, @@ -76,15 +78,21 @@ function calleeName(node: Parser.SyntaxNode | null): string | undefined { return parts.reverse().join('.'); } -function unwrap(node: Node | null): Node | null { - while (node && (TRANSPARENT_EXPRESSION_TYPES.has(node.type) || node.type === 'await_expression')) - node = firstNamed(node); - return node; -} const UNWRAPPED_TYPES: ReadonlySet = new Set([ ...TRANSPARENT_EXPRESSION_TYPES, 'await_expression', ]); +function unwrap(node: Node | null): Node | null { + while (node && UNWRAPPED_TYPES.has(node.type)) node = firstNamed(node); + return node; +} +/** Argument forms that can carry a parameter's value into a call. */ +const CARRYING_TYPES: ReadonlySet = new Set([ + 'identifier', + 'template_string', + 'binary_expression', +]); +const NO_BINDINGS: ReadonlySet = new Set(); const SKIPPED_TYPES: ReadonlySet = new Set([ 'comment', 'type_annotation', @@ -312,16 +320,10 @@ export function createFetchWrapperContext( exported(node, name); else if (node) exportedValues.push({ value: node, name }); }; - const commonJsGlobal = (node: Node | null, name: string) => - node?.type === 'identifier' && node.text === name && bindings.isGlobal(node, name); - const moduleExports = (node: Node | null) => - node?.type === 'member_expression' && - node.childForFieldName('property')?.text === 'exports' && - commonJsGlobal(node.childForFieldName('object'), 'module'); // `module.exports = …`, `module.exports.name = …` and `exports.name = …`. const commonJsExport = (left: Node | null, right: Node | null) => { if (left?.type !== 'member_expression') return; - if (moduleExports(left)) { + if (isModuleExports(bindings, left)) { const value = unwrap(right); if (value?.type !== 'object') return exportValue(value, 'default'); for (const member of value.namedChildren) { @@ -343,7 +345,7 @@ export function createFetchWrapperContext( const property = left.childForFieldName('property'); if ( property?.type === 'property_identifier' && - (commonJsGlobal(object, 'exports') || moduleExports(object)) + (isCommonJsGlobal(bindings, object, 'exports') || isModuleExports(bindings, object)) ) exportValue(right, property.text); }; @@ -479,13 +481,13 @@ export function createFetchWrapperContext( } if (type === 'class_declaration' || type === 'class') { const node = cursor.currentNode; - const deferred = region(node); + const classBody = region(node); const name = node.childForFieldName('name'); const stored = storedAt(frames, first, cursor.currentFieldName); - bindRegion(stored ?? name, deferred); - if (stored && name) bindRegion(name, deferred); - if (!stored && !name) active.edges.add(deferred); - return { ...frame, children: 'class', inner: deferred }; + bindRegion(stored ?? name, classBody); + if (stored && name) bindRegion(name, classBody); + if (!stored && !name) active.edges.add(classBody); + return { ...frame, children: 'class', inner: classBody }; } if (type === 'field_definition' || type === 'public_field_definition') { const isStatic = cursor.currentNode.children.some((child) => child.type === 'static'); @@ -576,13 +578,11 @@ export function createFetchWrapperContext( }; const argumentBindings = new Map>(); const carried = (argument: Node | undefined): ReadonlySet => { - if (!argument) return new Set(); + if (!argument || !CARRYING_TYPES.has(argument.type)) return NO_BINDINGS; const cached = argumentBindings.get(argument.id); if (cached) return cached; const result = new Set(); - const stack = ['identifier', 'template_string', 'binary_expression'].includes(argument.type) - ? [argument] - : []; + const stack = [argument]; while (stack.length) { const node = stack.pop()!; if (node.type === 'identifier') { diff --git a/gitnexus/src/core/ingestion/route-extractors/framework-route-facts.ts b/gitnexus/src/core/ingestion/route-extractors/framework-route-facts.ts index 634085785..e477f6b5d 100644 --- a/gitnexus/src/core/ingestion/route-extractors/framework-route-facts.ts +++ b/gitnexus/src/core/ingestion/route-extractors/framework-route-facts.ts @@ -5,6 +5,8 @@ import type { FrameworkExpression, FrameworkRouteFacts } from './framework-route import { createLexicalBindings, importBindingKey, + isCommonJsGlobal, + isModuleExports, LEXICAL_FUNCTION_TYPES, parameterPattern, patternBindingNames, @@ -201,11 +203,12 @@ export function extractFrameworkRouteFacts( const nodes: { node: Node; enclosingFunction?: Node }[] = [{ node: tree.rootNode }]; while (nodes.length) { const { node, enclosingFunction } = nodes.pop()!; - if (node.type === 'ERROR') continue; - const childFunction = LEXICAL_FUNCTION_TYPES.has(node.type) ? node : enclosingFunction; + const type = node.type; + if (type === 'ERROR') continue; + const childFunction = LEXICAL_FUNCTION_TYPES.has(type) ? node : enclosingFunction; for (const child of children(node).reverse()) nodes.push({ node: child, enclosingFunction: childFunction }); - if (node.type === 'import_statement') { + if (type === 'import_statement') { // ESM decomposition already carries precise aliases/type-only flags. CJS // imports are intentionally handled at their AST calls to guard require. const position = `${node.startPosition.row + 1 + lineOffset}:${node.startPosition.column}`; @@ -224,7 +227,7 @@ export function extractFrameworkRouteFacts( }, }); } - } else if (node.type === 'variable_declarator') { + } else if (type === 'variable_declarator') { const name = node.childForFieldName('name'); const value = node.childForFieldName('value'); if (value && enclosingFunction) { @@ -241,17 +244,14 @@ export function extractFrameworkRouteFacts( name, value && FUNCTION_TYPES.includes(value.type) ? functionValue(value) : expression(value), ); - } else if ( - node.type === 'function_declaration' || - node.type === 'generator_function_declaration' - ) { + } else if (type === 'function_declaration' || type === 'generator_function_declaration') { const name = node.childForFieldName('name'); if (name && enclosingFunction) { const id = bindingId(name); if (id && parametersOf(enclosingFunction).has(id)) invalid.add(id); } if (name) bind(name, functionValue(node)); - } else if (node.type === 'export_statement') { + } else if (type === 'export_statement') { const sourceNode = node.childForFieldName('source'); const source = sourceNode && plainString(sourceNode); if (node.children.some((child) => child.type === 'type')) continue; @@ -302,31 +302,26 @@ export function extractFrameworkRouteFacts( } } } else if ( - node.type === 'assignment_expression' || - node.type === 'augmented_assignment_expression' || - node.type === 'update_expression' || - node.type === 'for_in_statement' || - (node.type === 'unary_expression' && node.children.some((child) => child.type === 'delete')) + type === 'assignment_expression' || + type === 'augmented_assignment_expression' || + type === 'update_expression' || + type === 'for_in_statement' || + (type === 'unary_expression' && node.children.some((child) => child.type === 'delete')) ) { const left = node.childForFieldName('left') ?? node.childForFieldName('argument'); const right = node.childForFieldName('right'); const object = left?.childForFieldName('object'); const property = left?.childForFieldName('property'); - if (left?.type === 'identifier' && left.text === 'exports' && isGlobal(left, 'exports')) - cjsShortcutDetached = true; - const moduleExports = (candidate: Node | null | undefined): boolean => - candidate?.type === 'member_expression' && - candidate.childForFieldName('object')?.text === 'module' && - candidate.childForFieldName('property')?.text === 'exports' && - isGlobal(candidate, 'module'); + if (isCommonJsGlobal(lexical, left, 'exports')) cjsShortcutDetached = true; const writesExportProperty = - moduleExports(object) || (object?.text === 'exports' && isGlobal(object, 'exports')); + isModuleExports(lexical, object) || isCommonJsGlobal(lexical, object, 'exports'); if ( (left?.type === 'subscript_expression' && writesExportProperty) || - (node.type !== 'assignment_expression' && (moduleExports(left) || writesExportProperty)) + (type !== 'assignment_expression' && + (isModuleExports(lexical, left) || writesExportProperty)) ) cjsUnknownWrite = true; - if (node.type === 'assignment_expression' && moduleExports(left)) { + if (type === 'assignment_expression' && isModuleExports(lexical, left)) { cjsDefaultAssignments++; cjsExports.length = 0; if (right?.type === 'object') { @@ -349,7 +344,7 @@ export function extractFrameworkRouteFacts( } } else cjsExports.push({ name: 'default', value: expression(right) }); } else if ( - node.type === 'assignment_expression' && + type === 'assignment_expression' && left?.type === 'member_expression' && property?.type === 'property_identifier' && writesExportProperty @@ -412,7 +407,7 @@ export function extractFrameworkRouteFacts( }; invalidate(left); } - } else if (node.type === 'call_expression') { + } else if (type === 'call_expression') { const callee = node.childForFieldName('function'); const method = callee?.childForFieldName('property')?.text; if (callee?.type === 'member_expression' && method && METHODS.has(method)) { diff --git a/gitnexus/src/core/ingestion/route-extractors/lexical-bindings.ts b/gitnexus/src/core/ingestion/route-extractors/lexical-bindings.ts index 677ce1e64..fcf367cf6 100644 --- a/gitnexus/src/core/ingestion/route-extractors/lexical-bindings.ts +++ b/gitnexus/src/core/ingestion/route-extractors/lexical-bindings.ts @@ -1,6 +1,7 @@ import type Parser from 'tree-sitter'; import { buildPositionIndex, buildScopeTree, lookupLexicalName } from 'gitnexus-shared'; import type { NameClaimResult, ParsedFile, ParsedImport } from 'gitnexus-shared'; +import { FUNCTION_NODE_TYPES } from '../languages/typescript/captures.js'; type Node = Parser.SyntaxNode; export interface LexicalBindings { @@ -10,18 +11,9 @@ export interface LexicalBindings { ambiguous(bindingId: string): boolean; } -/** Callable boundaries, including methods that can contain their own var declarations. */ -export const LEXICAL_FUNCTION_TYPES: ReadonlySet = new Set([ - 'arrow_function', - 'function_expression', - 'function_declaration', - 'generator_function', - 'generator_function_declaration', - 'method_definition', - 'function_signature', - 'method_signature', - 'abstract_method_signature', -]); +/** Callable boundaries: the TypeScript function scopes, which cover JavaScript's. */ +export const LEXICAL_FUNCTION_TYPES: ReadonlySet = new Set(FUNCTION_NODE_TYPES); +const LEXICAL_FUNCTION_TYPE_LIST: string[] = [...FUNCTION_NODE_TYPES]; /** Expressions that wrap a value or an assignment target without changing it. */ export const TRANSPARENT_EXPRESSION_TYPES: ReadonlySet = new Set([ @@ -46,6 +38,22 @@ export const importBindingKey = ( imported: Extract, ): string => bindingKey(imported.declaredAtScope ?? parsedFile.moduleScope, imported.localName); +/** An unshadowed CommonJS `module` or `exports` reference. */ +export const isCommonJsGlobal = ( + bindings: LexicalBindings, + node: Node | null | undefined, + name: 'module' | 'exports', +): boolean => node?.type === 'identifier' && node.text === name && bindings.isGlobal(node, name); + +/** `module.exports` through an unshadowed `module`. */ +export const isModuleExports = ( + bindings: LexicalBindings, + node: Node | null | undefined, +): boolean => + node?.type === 'member_expression' && + node.childForFieldName('property')?.text === 'exports' && + isCommonJsGlobal(bindings, node.childForFieldName('object'), 'module'); + /** The pattern a formal parameter declares, without its type or default value. */ export function parameterPattern(node: Node | null): Node | null { if (node?.type === 'required_parameter' || node?.type === 'optional_parameter') @@ -234,7 +242,7 @@ export function createLexicalBindings( // functions have neither, so a native type search finds the few that do and // only their formals and bodies are walked. const ambiguous = new Set(); - for (const fn of rootNode.descendantsOfType([...LEXICAL_FUNCTION_TYPES])) { + for (const fn of rootNode.descendantsOfType(LEXICAL_FUNCTION_TYPE_LIST)) { const parameters = fn.childForFieldName('parameters') ?? fn.childForFieldName('parameter'); if (!parameters || !containsParameterExpression(parameters)) continue; let functionScope = scopes.getScope(scopeAt(parameters) ?? '');