mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-25 01:01:28 +00:00
fix: classify request-like HTTP client member calls
This commit is contained in:
parent
b73928f732
commit
9706b1b7f9
8 changed files with 333 additions and 2 deletions
|
|
@ -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,
|
||||
|
|
|
|||
116
gitnexus/src/core/ingestion/request-like-clients.ts
Normal file
116
gitnexus/src/core/ingestion/request-like-clients.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
13
gitnexus/test/fixtures/lang-resolution/express-route-mapping/client.ts
vendored
Normal file
13
gitnexus/test/fixtures/lang-resolution/express-route-mapping/client.ts
vendored
Normal 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 });
|
||||
}
|
||||
5
gitnexus/test/fixtures/lang-resolution/express-route-mapping/request.ts
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/express-route-mapping/request.ts
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { extend } from 'umi-request';
|
||||
|
||||
const request = extend({ credentials: 'include' });
|
||||
|
||||
export default request;
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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[] = [
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue