From 9706b1b7f9d466b989c2e4e7fc809455ba408663 Mon Sep 17 00:00:00 2001 From: gfwangjie Date: Tue, 7 Apr 2026 16:32:28 +0800 Subject: [PATCH] fix: classify request-like HTTP client member calls --- gitnexus/src/core/ingestion/call-processor.ts | 25 +++- .../core/ingestion/request-like-clients.ts | 116 ++++++++++++++++++ .../src/core/ingestion/tree-sitter-queries.ts | 18 +++ .../core/ingestion/workers/parse-worker.ts | 31 ++++- .../express-route-mapping/client.ts | 13 ++ .../express-route-mapping/request.ts | 5 + .../resolvers/express-routes.test.ts | 23 ++++ gitnexus/test/unit/call-processor.test.ts | 104 ++++++++++++++++ 8 files changed, 333 insertions(+), 2 deletions(-) create mode 100644 gitnexus/src/core/ingestion/request-like-clients.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/express-route-mapping/client.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/express-route-mapping/request.ts diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index dc615be1c..9b13dcf38 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -10,6 +10,11 @@ import { generateId } from '../../lib/utils.js'; import { getLanguageFromFilename, SupportedLanguages } from 'gitnexus-shared'; import { isVerboseIngestionEnabled } from './utils/verbose.js'; import { yieldToEventLoop } from './utils/event-loop.js'; +import { + collectRequestLikeImportBindings, + getRequestLikeCapturedUrl, + getRequestLikeMemberCapturedUrl, +} from './request-like-clients.js'; import { FUNCTION_NODE_TYPES, findEnclosingClassId, @@ -2376,6 +2381,8 @@ export const extractFetchCallsFromFiles = async ( continue; } + const requestLikeBindings = collectRequestLikeImportBindings(file.content); + for (const match of matches) { const captureMap: Record = {}; match.captures.forEach((c) => (captureMap[c.name] = c.node)); @@ -2389,11 +2396,27 @@ export const extractFetchCallsFromFiles = async ( lineNumber: captureMap['route.fetch'].startPosition.row, }); } + } else if (captureMap['request_like_client']) { + const url = getRequestLikeCapturedUrl(captureMap, requestLikeBindings); + if (url) { + result.push({ + filePath: file.path, + fetchURL: url, + lineNumber: captureMap['request_like_client'].startPosition.row, + }); + } } else if (captureMap['http_client'] && captureMap['http_client.url']) { const method = captureMap['http_client.method']?.text; const url = captureMap['http_client.url'].text; const HTTP_CLIENT_ONLY = new Set(['head', 'options', 'request', 'ajax']); - if (method && HTTP_CLIENT_ONLY.has(method) && url.startsWith('/')) { + const requestLikeUrl = getRequestLikeMemberCapturedUrl(captureMap, requestLikeBindings); + if (requestLikeUrl) { + result.push({ + filePath: file.path, + fetchURL: requestLikeUrl, + lineNumber: captureMap['http_client'].startPosition.row, + }); + } else if (method && HTTP_CLIENT_ONLY.has(method) && url.startsWith('/')) { result.push({ filePath: file.path, fetchURL: url, diff --git a/gitnexus/src/core/ingestion/request-like-clients.ts b/gitnexus/src/core/ingestion/request-like-clients.ts new file mode 100644 index 000000000..daaf201d8 --- /dev/null +++ b/gitnexus/src/core/ingestion/request-like-clients.ts @@ -0,0 +1,116 @@ +import type { SyntaxNode } from './utils/ast-helpers.js'; + +const KNOWN_REQUEST_LIKE_MODULES = new Set(['umi-request']); +const REQUEST_LIKE_LOCAL_BASENAMES = new Set(['request']); +const REQUEST_LIKE_MEMBER_METHODS = new Set([ + 'get', + 'post', + 'put', + 'patch', + 'delete', + 'head', + 'options', + 'request', + 'ajax', +]); + +const IDENT_RE = /^[A-Za-z_$][\w$]*$/; + +function stripExtension(specifier: string): string { + return specifier.replace(/\.(?:[cm]?[jt]sx?)$/i, ''); +} + +function moduleBasename(specifier: string): string { + const normalized = stripExtension(specifier.replace(/\\/g, '/')).toLowerCase(); + const parts = normalized.split('/'); + return parts[parts.length - 1] || normalized; +} + +function isRequestLikeImportSource(specifier: string): boolean { + const normalized = specifier.trim().replace(/\\/g, '/').toLowerCase(); + if (KNOWN_REQUEST_LIKE_MODULES.has(normalized)) return true; + return REQUEST_LIKE_LOCAL_BASENAMES.has(moduleBasename(normalized)); +} + +function addIdentifier(target: Set, raw: string): void { + const candidate = raw.trim().replace(/^type\s+/, ''); + if (IDENT_RE.test(candidate)) target.add(candidate); +} + +function parseImportClause(clause: string, target: Set): void { + const trimmed = clause.trim(); + if (!trimmed) return; + + const namedMatch = trimmed.match(/\{([^}]+)\}/); + if (namedMatch) { + for (const part of namedMatch[1].split(',')) { + const item = part.trim(); + if (!item) continue; + const aliasMatch = item.match(/^([A-Za-z_$][\w$]*)\s+as\s+([A-Za-z_$][\w$]*)$/); + if (aliasMatch) { + addIdentifier(target, aliasMatch[2]); + } else { + addIdentifier(target, item); + } + } + } + + const defaultPart = trimmed.split(',')[0]?.trim() || ''; + if (defaultPart && !defaultPart.startsWith('{') && !defaultPart.startsWith('*')) { + addIdentifier(target, defaultPart); + } +} + +export function collectRequestLikeImportBindings(content: string): Set { + const bindings = new Set(); + const importRe = /import\s+([\s\S]*?)\s+from\s+['"]([^'"]+)['"]/g; + let match: RegExpExecArray | null; + + while ((match = importRe.exec(content)) !== null) { + const [, clause, source] = match; + if (!isRequestLikeImportSource(source)) continue; + parseImportClause(clause, bindings); + } + + return bindings; +} + +function nodeTextStartsWithPath(node: SyntaxNode): boolean { + return node.text.startsWith('/') || node.text.startsWith('`/'); +} + +function isRequestLikeBinding( + node: SyntaxNode | undefined, + requestLikeBindings: ReadonlySet, +): boolean { + return !!node && requestLikeBindings.has(node.text); +} + +export function getRequestLikeCapturedUrl( + captureMap: Record, + requestLikeBindings: ReadonlySet, +): string | null { + const fnNode = captureMap['request_like_client.fn']; + const urlNode = + captureMap['request_like_client.url'] ?? captureMap['request_like_client.template_url']; + if (!fnNode || !urlNode) return null; + if (!requestLikeBindings.has(fnNode.text)) return null; + if (!nodeTextStartsWithPath(urlNode)) return null; + return urlNode.text; +} + +export function getRequestLikeMemberCapturedUrl( + captureMap: Record, + requestLikeBindings: ReadonlySet, +): string | null { + const receiverNode = captureMap['http_client.receiver'] ?? captureMap['express_route.receiver']; + const methodNode = captureMap['http_client.method'] ?? captureMap['express_route.method']; + const urlNode = captureMap['http_client.url'] ?? captureMap['express_route.path']; + + if (!isRequestLikeBinding(receiverNode, requestLikeBindings) || !methodNode || !urlNode) { + return null; + } + if (!REQUEST_LIKE_MEMBER_METHODS.has(methodNode.text)) return null; + if (!nodeTextStartsWithPath(urlNode)) return null; + return urlNode.text; +} diff --git a/gitnexus/src/core/ingestion/tree-sitter-queries.ts b/gitnexus/src/core/ingestion/tree-sitter-queries.ts index a6c8d74b4..cdf897cf1 100644 --- a/gitnexus/src/core/ingestion/tree-sitter-queries.ts +++ b/gitnexus/src/core/ingestion/tree-sitter-queries.ts @@ -128,9 +128,17 @@ export const TYPESCRIPT_QUERIES = ` [(string (string_fragment) @route.url) (template_string) @route.template_url])) @route.fetch +; Imported request-like HTTP clients: request('/path'), apiClient('/path') +(call_expression + function: (identifier) @request_like_client.fn + arguments: (arguments + [(string (string_fragment) @request_like_client.url) + (template_string) @request_like_client.template_url])) @request_like_client + ; axios.get/post/put/delete/patch('/path'), $.get/post/ajax({url:'/path'}) (call_expression function: (member_expression + object: (_) @http_client.receiver property: (property_identifier) @http_client.method) arguments: (arguments (string (string_fragment) @http_client.url))) @http_client @@ -144,6 +152,7 @@ export const TYPESCRIPT_QUERIES = ` ; Express/Hono route registration: app.get('/path', handler), router.post('/path', fn) (call_expression function: (member_expression + object: (_) @express_route.receiver property: (property_identifier) @express_route.method) arguments: (arguments (string (string_fragment) @express_route.path))) @express_route @@ -236,9 +245,17 @@ export const JAVASCRIPT_QUERIES = ` [(string (string_fragment) @route.url) (template_string) @route.template_url])) @route.fetch +; Imported request-like HTTP clients: request('/path'), apiClient('/path') +(call_expression + function: (identifier) @request_like_client.fn + arguments: (arguments + [(string (string_fragment) @request_like_client.url) + (template_string) @request_like_client.template_url])) @request_like_client + ; axios.get/post, $.get/post/ajax (call_expression function: (member_expression + object: (_) @http_client.receiver property: (property_identifier) @http_client.method) arguments: (arguments (string (string_fragment) @http_client.url))) @http_client @@ -246,6 +263,7 @@ export const JAVASCRIPT_QUERIES = ` ; Express/Hono route registration (call_expression function: (member_expression + object: (_) @express_route.receiver property: (property_identifier) @express_route.method) arguments: (arguments (string (string_fragment) @express_route.path))) @express_route diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index 82ead976c..bbbc6c971 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -67,6 +67,11 @@ import type { ConstructorBinding } from '../type-env.js'; import { detectFrameworkFromAST } from '../framework-detection.js'; import { generateId } from '../../../lib/utils.js'; import { preprocessImportPath } from '../import-processor.js'; +import { + collectRequestLikeImportBindings, + getRequestLikeCapturedUrl, + getRequestLikeMemberCapturedUrl, +} from '../request-like-clients.js'; import { extractVueScript, extractTemplateComponents, @@ -1397,6 +1402,7 @@ const processFileGroup = ( // Per-file map: decorator end-line → decorator info, for associating with definitions const fileDecorators = new Map(); + const requestLikeBindings = collectRequestLikeImportBindings(file.content); for (const match of matches) { const captureMap: Record = {}; @@ -1500,13 +1506,32 @@ const processFileGroup = ( continue; } + if (captureMap['request_like_client']) { + const url = getRequestLikeCapturedUrl(captureMap, requestLikeBindings); + if (url) { + result.fetchCalls.push({ + filePath: file.path, + fetchURL: url, + lineNumber: captureMap['request_like_client'].startPosition.row + lineOffset, + }); + } + continue; + } + // HTTP client calls: axios.get('/path'), $.post('/path'), requests.get('/path') // Skip methods also in EXPRESS_ROUTE_METHODS to avoid double-registering Express // routes as both route definitions AND consumers (both queries match same AST node) if (captureMap['http_client'] && captureMap['http_client.url']) { const method = captureMap['http_client.method']?.text; const url = captureMap['http_client.url'].text; - if (method && HTTP_CLIENT_ONLY_METHODS.has(method) && url.startsWith('/')) { + const requestLikeUrl = getRequestLikeMemberCapturedUrl(captureMap, requestLikeBindings); + if (requestLikeUrl) { + result.fetchCalls.push({ + filePath: file.path, + fetchURL: requestLikeUrl, + lineNumber: captureMap['http_client'].startPosition.row + lineOffset, + }); + } else if (method && HTTP_CLIENT_ONLY_METHODS.has(method) && url.startsWith('/')) { result.fetchCalls.push({ filePath: file.path, fetchURL: url, @@ -1524,6 +1549,10 @@ const processFileGroup = ( ) { const method = captureMap['express_route.method'].text; const routePath = captureMap['express_route.path'].text; + const requestLikeUrl = getRequestLikeMemberCapturedUrl(captureMap, requestLikeBindings); + if (requestLikeUrl) { + continue; + } if (EXPRESS_ROUTE_METHODS.has(method) && routePath.startsWith('/')) { const httpMethod = method === 'all' || method === 'use' || method === 'route' diff --git a/gitnexus/test/fixtures/lang-resolution/express-route-mapping/client.ts b/gitnexus/test/fixtures/lang-resolution/express-route-mapping/client.ts new file mode 100644 index 000000000..463147470 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/express-route-mapping/client.ts @@ -0,0 +1,13 @@ +import request from './request'; + +export async function loadItems() { + return request.get('/api/items'); +} + +export async function loadClientOnly() { + return request.get('/api/client-only'); +} + +export async function createClientOnly(data: unknown) { + return request.post('/api/client-post-only', { data }); +} diff --git a/gitnexus/test/fixtures/lang-resolution/express-route-mapping/request.ts b/gitnexus/test/fixtures/lang-resolution/express-route-mapping/request.ts new file mode 100644 index 000000000..cdafcf3d1 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/express-route-mapping/request.ts @@ -0,0 +1,5 @@ +import { extend } from 'umi-request'; + +const request = extend({ credentials: 'include' }); + +export default request; diff --git a/gitnexus/test/integration/resolvers/express-routes.test.ts b/gitnexus/test/integration/resolvers/express-routes.test.ts index 0669bd9fb..c9e58cadc 100644 --- a/gitnexus/test/integration/resolvers/express-routes.test.ts +++ b/gitnexus/test/integration/resolvers/express-routes.test.ts @@ -56,4 +56,27 @@ describe('Express/Hono route detection', () => { expect(healthEdge).toBeDefined(); expect(healthEdge!.sourceFilePath).toContain('server.ts'); }); + + it('creates FETCHES edges for request-like client member calls', () => { + const edges = getRelationships(result, 'FETCHES'); + const clientFetch = edges.find( + (e) => e.sourceFilePath.includes('client.ts') && e.target === '/api/items', + ); + + expect(clientFetch).toBeDefined(); + }); + + it('does not create HANDLES_ROUTE edges from request-like client files', () => { + const edges = getRelationships(result, 'HANDLES_ROUTE'); + const clientRouteEdge = edges.find((e) => e.sourceFilePath.includes('client.ts')); + + expect(clientRouteEdge).toBeUndefined(); + }); + + it('does not create Route nodes from request-like client-only paths', () => { + const routes = getNodesByLabel(result, 'Route'); + + expect(routes).not.toContain('/api/client-only'); + expect(routes).not.toContain('/api/client-post-only'); + }); }); diff --git a/gitnexus/test/unit/call-processor.test.ts b/gitnexus/test/unit/call-processor.test.ts index 6f1390b45..e202f521d 100644 --- a/gitnexus/test/unit/call-processor.test.ts +++ b/gitnexus/test/unit/call-processor.test.ts @@ -4,9 +4,11 @@ import { seedCrossFileReceiverTypes, extractConsumerAccessedKeys, processNextjsFetchRoutes, + extractFetchCallsFromFiles, buildImplementorMap, mergeImplementorMaps, } from '../../src/core/ingestion/call-processor.js'; +import { createASTCache } from '../../src/core/ingestion/ast-cache.js'; import { extractReturnTypeName } from '../../src/core/ingestion/type-extractors/shared.js'; import { createResolutionContext, @@ -1397,6 +1399,108 @@ describe('processNextjsFetchRoutes', () => { }); }); +describe('extractFetchCallsFromFiles', () => { + it('extracts imported request-like client GET calls as fetch calls', async () => { + const files = [ + { + path: 'src/api/users.ts', + content: ` +import request from 'umi-request'; + +export async function loadUsers() { + return request('/api/users'); +} +`, + }, + ]; + + const fetchCalls = await extractFetchCallsFromFiles(files, createASTCache()); + + expect(fetchCalls).toEqual([ + { + filePath: 'src/api/users.ts', + fetchURL: '/api/users', + lineNumber: 4, + }, + ]); + }); + + it('extracts imported request-like client calls with explicit POST method', async () => { + const files = [ + { + path: 'src/api/users.ts', + content: ` +import request from '@/utils/request'; + +export async function createUser(data: unknown) { + return request('/api/users', { method: 'POST', data }); +} +`, + }, + ]; + + const fetchCalls = await extractFetchCallsFromFiles(files, createASTCache()); + + expect(fetchCalls).toEqual([ + { + filePath: 'src/api/users.ts', + fetchURL: '/api/users', + lineNumber: 4, + }, + ]); + }); + + it('extracts imported request-like client member GET calls as fetch calls', async () => { + const files = [ + { + path: 'src/api/users.ts', + content: ` +import request from '@/utils/request'; + +export async function loadUsers() { + return request.get('/api/users'); +} +`, + }, + ]; + + const fetchCalls = await extractFetchCallsFromFiles(files, createASTCache()); + + expect(fetchCalls).toEqual([ + { + filePath: 'src/api/users.ts', + fetchURL: '/api/users', + lineNumber: 4, + }, + ]); + }); + + it('extracts imported request-like client member POST calls as fetch calls', async () => { + const files = [ + { + path: 'src/api/users.ts', + content: ` +import request from '@/utils/request'; + +export async function createUser(data: unknown) { + return request.post('/api/users', { data }); +} +`, + }, + ]; + + const fetchCalls = await extractFetchCallsFromFiles(files, createASTCache()); + + expect(fetchCalls).toEqual([ + { + filePath: 'src/api/users.ts', + fetchURL: '/api/users', + lineNumber: 4, + }, + ]); + }); +}); + describe('buildImplementorMap / mergeImplementorMaps', () => { it('records direct implements edges per interface name', () => { const heritage: ExtractedHeritage[] = [