Merge branch 'main' into main

This commit is contained in:
Gergő Magyar 2026-05-28 05:34:20 +01:00 • committed by GitHub
commit fbcd77369a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 356 additions and 2 deletions

View file

@ -21,6 +21,7 @@ import { findNodeAtRange, nodeToCapture, syntheticCapture } from '../../utils/as
import { splitImportStatement } from './import-decomposer.js';
import { getPythonParser, getPythonScopeQuery } from './query.js';
import { synthesizeReceiverTypeBinding } from './receiver-binding.js';
import { synthesizeDependsReferences } from './depends-references.js';
import { computePythonArityMetadata } from './arity-metadata.js';
import { recordCacheHit, recordCacheMiss } from './cache-stats.js';
import { getTreeSitterBufferSize } from '../../constants.js';
@ -98,6 +99,7 @@ export function emitPythonScopeCaptures(
if (fnNode !== null) {
const synth = synthesizeReceiverTypeBinding(fnNode);
if (synth !== null) out.push(synth);
for (const depRef of synthesizeDependsReferences(fnNode)) out.push(depRef);
}
continue;
}

View file

@ -0,0 +1,72 @@
/**
* Synthesize `@reference.call.free` captures for FastAPI `Depends(callable)`
* parameter defaults.
*
* `Depends(get_db)` passes `get_db` as a callable that the DI framework
* calls on every request. The route handler is functionally a caller of
* the dependency — impact analysis needs that edge.
*
* Tree-sitter can't express "the first argument of a call named Depends
* inside a parameter default" in a single static query, so we synthesize
* reference captures in code, mirroring the receiver-binding pattern.
*/
import type { CaptureMatch } from 'gitnexus-shared';
import { nodeToCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
/**
* Inspect a `function_definition` node's parameters for `Depends(callable)`
* defaults. Returns one `@reference.call.free` CaptureMatch per dependency.
*/
export function synthesizeDependsReferences(fnNode: SyntaxNode): readonly CaptureMatch[] {
const params = fnNode.childForFieldName('parameters');
if (params === null) return [];
const results: CaptureMatch[] = [];
for (let i = 0; i < params.namedChildCount; i++) {
const param = params.namedChild(i);
if (param === null) continue;
if (param.type !== 'typed_default_parameter' && param.type !== 'default_parameter') {
continue;
}
const defaultValue = param.childForFieldName('value') ?? param.childForFieldName('default');
if (defaultValue === null) continue;
const callNode = defaultValue.type === 'call' ? defaultValue : null;
if (callNode === null) continue;
const fnIdent = callNode.childForFieldName('function');
if (fnIdent === null || fnIdent.type !== 'identifier' || fnIdent.text !== 'Depends') continue;
const args = callNode.childForFieldName('arguments');
if (args === null || args.namedChildCount === 0) continue;
const firstArg = args.namedChild(0);
if (firstArg === null) continue;
if (firstArg.type === 'identifier') {
results.push({
'@reference.call.free': nodeToCapture('@reference.call.free', firstArg),
'@reference.name': nodeToCapture('@reference.name', firstArg),
});
continue;
}
if (firstArg.type === 'attribute') {
const attrName = firstArg.childForFieldName('attribute');
const obj = firstArg.childForFieldName('object');
if (attrName !== null && obj !== null) {
results.push({
'@reference.call.member': nodeToCapture('@reference.call.member', attrName),
'@reference.name': nodeToCapture('@reference.name', attrName),
'@reference.receiver': nodeToCapture('@reference.receiver', obj),
});
}
}
}
return results;
}

View file

@ -53,6 +53,7 @@ import type {
FileConstructorBindings,
FileScopeBindings,
ExtractedORMQuery,
FetchWrapperDef,
} from './workers/parse-worker.js';
import {
getTreeSitterBufferSize,
@ -78,6 +79,7 @@ export interface WorkerExtractedData {
heritage: ExtractedHeritage[];
routes: ExtractedRoute[];
fetchCalls: ExtractedFetchCall[];
fetchWrapperDefs: FetchWrapperDef[];
decoratorRoutes: ExtractedDecoratorRoute[];
toolDefs: ExtractedToolDef[];
ormQueries: ExtractedORMQuery[];
@ -119,6 +121,7 @@ export const mergeChunkResults = (
const allHeritage: ExtractedHeritage[] = [];
const allRoutes: ExtractedRoute[] = [];
const allFetchCalls: ExtractedFetchCall[] = [];
const allFetchWrapperDefs: FetchWrapperDef[] = [];
const allDecoratorRoutes: ExtractedDecoratorRoute[] = [];
const allToolDefs: ExtractedToolDef[] = [];
const allORMQueries: ExtractedORMQuery[] = [];
@ -156,6 +159,7 @@ export const mergeChunkResults = (
for (const item of result.heritage) allHeritage.push(item);
for (const item of result.routes) allRoutes.push(item);
for (const item of result.fetchCalls) allFetchCalls.push(item);
for (const item of result.fetchWrapperDefs ?? []) allFetchWrapperDefs.push(item);
for (const item of result.decoratorRoutes) allDecoratorRoutes.push(item);
for (const item of result.toolDefs) allToolDefs.push(item);
if (result.ormQueries) for (const item of result.ormQueries) allORMQueries.push(item);
@ -172,6 +176,7 @@ export const mergeChunkResults = (
heritage: allHeritage,
routes: allRoutes,
fetchCalls: allFetchCalls,
fetchWrapperDefs: allFetchWrapperDefs,
decoratorRoutes: allDecoratorRoutes,
toolDefs: allToolDefs,
ormQueries: allORMQueries,
@ -212,6 +217,7 @@ const processParsingWithWorkers = async (
heritage: [],
routes: [],
fetchCalls: [],
fetchWrapperDefs: [],
decoratorRoutes: [],
toolDefs: [],
ormQueries: [],

View file

@ -61,6 +61,7 @@ import type {
ExtractedRoute,
ExtractedToolDef,
FileConstructorBindings,
FetchWrapperDef,
} from '../workers/parse-worker.js';
import type { ExtractedHeritage } from '../model/heritage-map.js';
import type { KnowledgeGraph } from '../../graph/types.js';
@ -141,6 +142,7 @@ export async function runChunkedParseAndResolve(
): Promise<{
exportedTypeMap: ExportedTypeMap;
allFetchCalls: ExtractedFetchCall[];
allFetchWrapperDefs: FetchWrapperDef[];
allExtractedRoutes: ExtractedRoute[];
allDecoratorRoutes: ExtractedDecoratorRoute[];
allToolDefs: ExtractedToolDef[];
@ -352,6 +354,7 @@ export async function runChunkedParseAndResolve(
// it, and later wildcard chunks re-run it themselves.
let hasSynthesized = false;
const allFetchCalls: ExtractedFetchCall[] = [];
const allFetchWrapperDefs: FetchWrapperDef[] = [];
const allExtractedRoutes: ExtractedRoute[] = [];
const allDecoratorRoutes: ExtractedDecoratorRoute[] = [];
const allToolDefs: ExtractedToolDef[] = [];
@ -663,6 +666,9 @@ export async function runChunkedParseAndResolve(
if (chunkWorkerData.fetchCalls?.length) {
for (const item of chunkWorkerData.fetchCalls) allFetchCalls.push(item);
}
if (chunkWorkerData.fetchWrapperDefs?.length) {
for (const item of chunkWorkerData.fetchWrapperDefs) allFetchWrapperDefs.push(item);
}
if (chunkWorkerData.routes?.length) {
for (const item of chunkWorkerData.routes) allExtractedRoutes.push(item);
}
@ -1082,6 +1088,7 @@ export async function runChunkedParseAndResolve(
return {
exportedTypeMap,
allFetchCalls,
allFetchWrapperDefs,
allExtractedRoutes,
allDecoratorRoutes,
allToolDefs,

View file

@ -27,6 +27,7 @@ import type {
ExtractedDecoratorRoute,
ExtractedToolDef,
ExtractedORMQuery,
FetchWrapperDef,
} from '../workers/parse-worker.js';
import type { createResolutionContext } from '../model/resolution-context.js';
import { runChunkedParseAndResolve } from './parse-impl.js';
@ -45,6 +46,7 @@ export interface ParseOutput {
*/
readonly exportedTypeMap: ReadonlyMap<string, ReadonlyMap<string, string>>;
readonly allFetchCalls: readonly ExtractedFetchCall[];
readonly allFetchWrapperDefs: readonly FetchWrapperDef[];
readonly allExtractedRoutes: readonly ExtractedRoute[];
readonly allDecoratorRoutes: readonly ExtractedDecoratorRoute[];
readonly allToolDefs: readonly ExtractedToolDef[];

View file

@ -131,6 +131,10 @@ export function normalizeExtractedRoutePath(routePath: string, prefix: string |
return joined.replace(/\/+/g, '/') || '/';
}
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
export const routesPhase: PipelinePhase<RoutesOutput> = {
name: 'routes',
deps: ['parse'],
@ -142,6 +146,7 @@ export const routesPhase: PipelinePhase<RoutesOutput> = {
const {
allPaths,
allFetchCalls: parseFetchCalls,
allFetchWrapperDefs,
allExtractedRoutes,
allDecoratorRoutes,
} = getPhaseOutput<ParseOutput>(deps, 'parse');
@ -357,6 +362,35 @@ export const routesPhase: PipelinePhase<RoutesOutput> = {
}
}
// ── Cross-file fetch wrapper consumer extraction ──
// When the parse phase discovered functions that internally call fetch(),
// scan JS/TS consumer files for calls to those wrapper functions with
// URL-like string arguments and add them to allFetchCalls so
// processNextjsFetchRoutes can create FETCHES edges.
if (allFetchWrapperDefs && allFetchWrapperDefs.length > 0 && routeRegistry.size > 0) {
const wrapperNames = new Set(allFetchWrapperDefs.map((d) => d.functionName));
const jsFiles = allPaths.filter((p) => /\.[jt]sx?$/.test(p));
if (jsFiles.length > 0 && wrapperNames.size > 0) {
const jsContents = await readFileContents(ctx.repoPath, jsFiles);
for (const [filePath, content] of jsContents) {
for (const name of wrapperNames) {
const regex = new RegExp(
`\\b${escapeRegex(name)}\\s*\\(\\s*['"\`](/[^'"\`\\s)]+)['"\`]`,
'g',
);
let match;
while ((match = regex.exec(content)) !== null) {
allFetchCalls.push({
filePath,
fetchURL: match[1],
lineNumber: content.substring(0, match.index).split('\n').length,
});
}
}
}
}
}
if (routeRegistry.size > 0 && allFetchCalls.length > 0) {
const routeURLToFile = new Map<string, string>();
for (const [url, entry] of routeRegistry) routeURLToFile.set(url, entry.filePath);

View file

@ -241,6 +241,12 @@ export const TYPESCRIPT_QUERIES = `
[(string (string_fragment) @route.url)
(template_string) @route.template_url])) @route.fetch
; Custom fetch wrappers: apiFetch('/path'), fetchJSON('/api/data'), httpGet('/users'), etc.
(call_expression
function: (identifier) @_wrapper_fn (#match? @_wrapper_fn "^(api(Fetch|Get|Post|Put|Delete|Patch|Request)|fetch(API|JSON|Data|Endpoint|Resource|Url)|http(Fetch|Get|Post|Put|Delete|Patch|Request))$")
arguments: (arguments
(string (string_fragment) @route.url))) @route.fetch
; axios.get/post/put/delete/patch('/path'), $.get/post/ajax({url:'/path'})
(call_expression
function: (member_expression
@ -434,6 +440,12 @@ export const JAVASCRIPT_QUERIES = `
[(string (string_fragment) @route.url)
(template_string) @route.template_url])) @route.fetch
; Custom fetch wrappers: apiFetch('/path'), fetchJSON('/api/data'), httpGet('/users'), etc.
(call_expression
function: (identifier) @_wrapper_fn (#match? @_wrapper_fn "^(api(Fetch|Get|Post|Put|Delete|Patch|Request)|fetch(API|JSON|Data|Endpoint|Resource|Url)|http(Fetch|Get|Post|Put|Delete|Patch|Request))$")
arguments: (arguments
(string (string_fragment) @route.url))) @route.fetch
; axios.get/post, $.get/post/ajax
(call_expression
function: (member_expression

View file

@ -206,6 +206,11 @@ export interface ExtractedFetchCall {
lineNumber: number;
}
export interface FetchWrapperDef {
filePath: string;
functionName: string;
}
export interface ExtractedDecoratorRoute {
filePath: string;
routePath: string;
@ -276,6 +281,7 @@ export interface ParseWorkerResult {
heritage: ExtractedHeritage[];
routes: ExtractedRoute[];
fetchCalls: ExtractedFetchCall[];
fetchWrapperDefs: FetchWrapperDef[];
decoratorRoutes: ExtractedDecoratorRoute[];
toolDefs: ExtractedToolDef[];
ormQueries: ExtractedORMQuery[];
@ -740,6 +746,7 @@ const processBatch = (
heritage: [],
routes: [],
fetchCalls: [],
fetchWrapperDefs: [],
decoratorRoutes: [],
toolDefs: [],
ormQueries: [],
@ -850,6 +857,23 @@ const EXPRESS_ROUTE_METHODS = new Set([
'route',
]);
/**
* Walk a tree-sitter AST subtree looking for a call to the global `fetch()` function.
* Returns `true` if found within `maxDepth` levels of nesting — keeps the check
* lightweight so it doesn't slow down parse-worker on large function bodies.
*/
const checkForFetchCall = (node: SyntaxNode, depth = 0, maxDepth = 5): boolean => {
if (depth > maxDepth) return false;
if (node.type === 'call_expression') {
const fn = node.childForFieldName('function');
if (fn?.type === 'identifier' && fn.text === 'fetch') return true;
}
for (let i = 0; i < node.childCount; i++) {
if (checkForFetchCall(node.child(i)!, depth + 1, maxDepth)) return true;
}
return false;
};
// HTTP client methods that are ONLY used by clients, not Express route registration.
// Methods like get/post/put/delete/patch overlap with Express — those are captured by
// the express_route handler as route definitions, not consumers. The fetch() global
@ -1962,6 +1986,21 @@ const processFileGroup = (
: '',
});
}
// ── Fetch wrapper detection: record functions that call fetch() internally ──
if (
nodeLabel === 'Function' &&
definitionNode &&
nameNode &&
(language === SupportedLanguages.TypeScript || language === SupportedLanguages.JavaScript)
) {
if (checkForFetchCall(definitionNode)) {
result.fetchWrapperDefs.push({
filePath: file.path,
functionName: nameNode.text,
});
}
}
}
// Extract framework routes via provider detection (e.g., Laravel routes.php)
@ -2025,6 +2064,7 @@ let accumulated: ParseWorkerResult = {
heritage: [],
routes: [],
fetchCalls: [],
fetchWrapperDefs: [],
decoratorRoutes: [],
toolDefs: [],
ormQueries: [],
@ -2053,6 +2093,7 @@ const mergeResult = (target: ParseWorkerResult, src: ParseWorkerResult) => {
appendAll(target.heritage, src.heritage);
appendAll(target.routes, src.routes);
appendAll(target.fetchCalls, src.fetchCalls);
appendAll(target.fetchWrapperDefs, src.fetchWrapperDefs);
appendAll(target.decoratorRoutes, src.decoratorRoutes);
appendAll(target.toolDefs, src.toolDefs);
appendAll(target.ormQueries, src.ormQueries);
@ -2144,6 +2185,7 @@ parentPort!.on('message', (msg: WorkerIncomingMessage) => {
heritage: [],
routes: [],
fetchCalls: [],
fetchWrapperDefs: [],
decoratorRoutes: [],
toolDefs: [],
ormQueries: [],

View file

@ -44,7 +44,7 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j
* On version mismatch, `loadParseCache` returns an empty cache and the
* next save overwrites the on-disk file with the new version baked in.
*/
const SCHEMA_BUMP = 1;
const SCHEMA_BUMP = 2;
const GITNEXUS_PKG_VERSION = (() => {
try {
// package.json sits at gitnexus/package.json — two levels up from

View file

@ -0,0 +1,12 @@
from fastapi import Depends, APIRouter
from app.dependencies import get_current_user_record, get_db, User, Session
router = APIRouter()
@router.get("/calls")
async def list_calls(
user: User = Depends(get_current_user_record),
db: Session = Depends(get_db),
):
return []

View file

@ -0,0 +1,14 @@
from fastapi import Depends, APIRouter
from app.dependencies import get_current_user_record, get_db, User, Session
router = APIRouter()
@router.get("/users")
async def get_user(user: User = Depends(get_current_user_record)):
return user
@router.post("/users")
async def create_user(db=Depends(get_db)):
return {}

View file

@ -0,0 +1,22 @@
from typing import Optional
class Session:
pass
class User:
id: int
username: str
async def get_db() -> Session:
db = Session()
try:
yield db
finally:
pass
async def get_current_user_record(db: Session) -> User:
return User()

View file

@ -0,0 +1,4 @@
class CallRecord:
id: int
caller: str
callee: str

View file

@ -0,0 +1,6 @@
import { NextResponse } from 'next/server';
export async function GET() {
const grants = [{ id: 1, name: 'Research Grant' }];
return NextResponse.json(grants);
}

View file

@ -0,0 +1,6 @@
import { NextResponse } from 'next/server';
export async function GET() {
const users = [{ id: 1, username: 'admin' }];
return NextResponse.json(users);
}

View file

@ -0,0 +1,5 @@
const API_BASE = process.env.API_BASE || '';
export async function apiFetch(path: string, opts?: RequestInit) {
return fetch(`${API_BASE}${path}`, opts);
}

View file

@ -0,0 +1,9 @@
import { apiFetch } from '../lib/api-client';
export default function GrantsList() {
const loadGrants = async () => {
const res = await apiFetch('/api/grants');
return res.json();
};
return null;
}

View file

@ -0,0 +1,9 @@
import { apiFetch } from '../lib/api-client';
export default function UserList() {
const loadUsers = async () => {
const res = await apiFetch('/api/users');
return res.json();
};
return null;
}

View file

@ -129,6 +129,7 @@ const accumulated = {
heritage: [],
routes: [],
fetchCalls: [],
fetchWrapperDefs: [],
decoratorRoutes: [],
toolDefs: [],
ormQueries: [],

View file

@ -0,0 +1,43 @@
import { describe, it, expect, beforeAll } from 'vitest';
import path from 'path';
import { FIXTURES, getRelationships, runPipelineFromRepo, type PipelineResult } from './helpers.js';
describe('FastAPI Depends() CALLS edge extraction', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'fastapi-depends'), () => {});
}, 60000);
it('emits CALLS edges from route handlers to get_current_user_record via Depends()', () => {
const edges = getRelationships(result, 'CALLS');
const dependsEdges = edges.filter((e) => e.target === 'get_current_user_record');
expect(dependsEdges.length).toBe(2);
const sources = dependsEdges.map((e) => e.source).sort();
expect(sources).toContain('list_calls');
expect(sources).toContain('get_user');
});
it('emits CALLS edges from route handlers to get_db via Depends()', () => {
const edges = getRelationships(result, 'CALLS');
const dependsEdges = edges.filter((e) => e.target === 'get_db');
expect(dependsEdges.length).toBe(2);
const sources = dependsEdges.map((e) => e.source).sort();
expect(sources).toContain('list_calls');
expect(sources).toContain('create_user');
});
it('traces typed default parameter: user: User = Depends(get_current_user_record)', () => {
const edges = getRelationships(result, 'CALLS');
const edge = edges.find(
(e) => e.target === 'get_current_user_record' && e.sourceFilePath.includes('calls.py'),
);
expect(edge).toBeDefined();
});
it('traces untyped default parameter: db=Depends(get_db)', () => {
const edges = getRelationships(result, 'CALLS');
const edge = edges.find((e) => e.target === 'get_db' && e.sourceFilePath.includes('users.py'));
expect(edge).toBeDefined();
});
});

View file

@ -0,0 +1,44 @@
import { describe, it, expect, beforeAll } from 'vitest';
import path from 'path';
import {
FIXTURES,
getRelationships,
getNodesByLabel,
runPipelineFromRepo,
type PipelineResult,
} from './helpers.js';
describe('Fetch wrapper consumer FETCHES edge extraction', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'fetch-wrapper-consumers'), () => {});
}, 60000);
it('creates Route nodes for API endpoints', () => {
const routes = getNodesByLabel(result, 'Route');
expect(routes).toContain('/api/grants');
expect(routes).toContain('/api/users');
});
it('creates FETCHES edge from GrantsList via apiFetch wrapper', () => {
const edges = getRelationships(result, 'FETCHES');
const grantsEdge = edges.find(
(e) => e.sourceFilePath.includes('GrantsList') && e.target === '/api/grants',
);
expect(grantsEdge).toBeDefined();
});
it('creates FETCHES edge from UserList via apiFetch wrapper', () => {
const edges = getRelationships(result, 'FETCHES');
const usersEdge = edges.find(
(e) => e.sourceFilePath.includes('UserList') && e.target === '/api/users',
);
expect(usersEdge).toBeDefined();
});
it('produces the correct total number of FETCHES edges', () => {
const edges = getRelationships(result, 'FETCHES');
expect(edges.length).toBe(2);
});
});

View file

@ -23,6 +23,7 @@ const minimalResult = (overrides: Partial<ParseWorkerResult> = {}): ParseWorkerR
heritage: [],
routes: [],
fetchCalls: [],
fetchWrapperDefs: [],
decoratorRoutes: [],
toolDefs: [],
ormQueries: [],

View file

@ -39,6 +39,7 @@ const emptyWorkerResult = (filePath: string, name: string): ParseWorkerResult =>
heritage: [],
routes: [],
fetchCalls: [],
fetchWrapperDefs: [],
decoratorRoutes: [],
toolDefs: [],
ormQueries: [],
@ -73,7 +74,7 @@ fs.writeFileSync(${JSON.stringify(markerPath)}, 'spawned');
parentPort.postMessage({ type: 'ready' });
const accumulated = {
nodes: [], relationships: [], symbols: [], imports: [], calls: [], assignments: [], heritage: [],
routes: [], fetchCalls: [], decoratorRoutes: [], toolDefs: [], ormQueries: [], constructorBindings: [],
routes: [], fetchCalls: [], fetchWrapperDefs: [], decoratorRoutes: [], toolDefs: [], ormQueries: [], constructorBindings: [],
fileScopeBindings: [], parsedFiles: [], skippedLanguages: {}, fileCount: 0,
};
parentPort.on('message', (msg) => {