fix: classify request-like HTTP client member calls

This commit is contained in:
gfwangjie 2026-04-07 16:32:28 +08:00
parent b73928f732
commit 9706b1b7f9
8 changed files with 333 additions and 2 deletions

View file

@ -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<string, any> = {};
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,

View file

@ -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<string>, raw: string): void {
const candidate = raw.trim().replace(/^type\s+/, '');
if (IDENT_RE.test(candidate)) target.add(candidate);
}
function parseImportClause(clause: string, target: Set<string>): 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<string> {
const bindings = new Set<string>();
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<string>,
): boolean {
return !!node && requestLikeBindings.has(node.text);
}
export function getRequestLikeCapturedUrl(
captureMap: Record<string, SyntaxNode>,
requestLikeBindings: ReadonlySet<string>,
): 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<string, SyntaxNode>,
requestLikeBindings: ReadonlySet<string>,
): 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;
}

View file

@ -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

View file

@ -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<number, { name: string; arg?: string; isTool?: boolean }>();
const requestLikeBindings = collectRequestLikeImportBindings(file.content);
for (const match of matches) {
const captureMap: Record<string, SyntaxNode> = {};
@ -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'

View file

@ -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 });
}

View file

@ -0,0 +1,5 @@
import { extend } from 'umi-request';
const request = extend({ credentials: 'include' });
export default request;

View file

@ -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');
});
});

View file

@ -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[] = [