Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions gitnexus/src/core/graph/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ export type RelationshipType =
| 'HANDLES_TOOL' // Function/File → Tool (handler implements this tool)
| 'ENTRY_POINT_OF' // Route/Tool → Process (this endpoint starts this execution flow)
| 'WRAPS' // Function → Function (middleware wrapper chain) — Reserved: future middleware graph traversal (not yet emitted)
| 'QUERIES' // File/Function → CodeElement (ORM query to model/table)

export interface GraphNode {
id: string,
Expand Down
15 changes: 15 additions & 0 deletions gitnexus/src/core/ingestion/framework-detection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,17 @@ export function detectFrameworkFromPath(filePath: string): FrameworkHint | null
return { framework: 'expo-router', entryPointMultiplier: 2.5, reason: 'expo-screen' };
}

// Prisma schema (ORM data model definition)
if (p.includes('/prisma/') && p.endsWith('schema.prisma')) {
return { framework: 'prisma', entryPointMultiplier: 1.5, reason: 'prisma-schema' };
}

// Supabase client files
if ((p.includes('/lib/supabase') || p.includes('/utils/supabase') || p.includes('/supabase/')) &&
(p.endsWith('.ts') || p.endsWith('.js'))) {
return { framework: 'supabase', entryPointMultiplier: 1.5, reason: 'supabase-client' };
}

// Express / Node.js routes
if (p.includes('/routes/') && (p.endsWith('.ts') || p.endsWith('.js'))) {
return { framework: 'express', entryPointMultiplier: 2.5, reason: 'routes-folder' };
Expand Down Expand Up @@ -456,6 +467,10 @@ export const FRAMEWORK_AST_PATTERNS = {
'fiber': ['fiber.Ctx', 'fiber.New', 'fiber.App'],
'go-grpc': ['grpc.Server', 'RegisterServer', 'pb.Unimplemented'],

// ORM patterns
'prisma': ['prisma.', 'PrismaClient', '@prisma/client'],
'supabase': ['supabase.from', 'createClient', '@supabase/supabase-js'],

// PHP/Laravel
'laravel': ['Route::get', 'Route::post', 'Route::put', 'Route::delete',
'Route::resource', 'Route::apiResource', '#[Route('],
Expand Down
9 changes: 6 additions & 3 deletions gitnexus/src/core/ingestion/parsing-processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { getDefinitionNodeFromCaptures, findEnclosingClassId, extractMethodSigna
import { extractPropertyDeclaredType } from './type-extractors/shared.js';
import { detectFrameworkFromAST } from './framework-detection.js';
import { WorkerPool } from './workers/worker-pool.js';
import type { ParseWorkerResult, ParseWorkerInput, ExtractedImport, ExtractedCall, ExtractedAssignment, ExtractedHeritage, ExtractedRoute, ExtractedFetchCall, ExtractedDecoratorRoute, ExtractedToolDef, FileConstructorBindings, FileTypeEnvBindings } from './workers/parse-worker.js';
import type { ParseWorkerResult, ParseWorkerInput, ExtractedImport, ExtractedCall, ExtractedAssignment, ExtractedHeritage, ExtractedRoute, ExtractedFetchCall, ExtractedDecoratorRoute, ExtractedToolDef, FileConstructorBindings, FileTypeEnvBindings, ExtractedORMQuery } from './workers/parse-worker.js';
import { getTreeSitterBufferSize, TREE_SITTER_MAX_BUFFER } from './constants.js';

export type FileProgressCallback = (current: number, total: number, filePath: string) => void;
Expand All @@ -25,6 +25,7 @@ export interface WorkerExtractedData {
fetchCalls: ExtractedFetchCall[];
decoratorRoutes: ExtractedDecoratorRoute[];
toolDefs: ExtractedToolDef[];
ormQueries: ExtractedORMQuery[];
constructorBindings: FileConstructorBindings[];
typeEnvBindings: FileTypeEnvBindings[];
}
Expand All @@ -48,7 +49,7 @@ const processParsingWithWorkers = async (
if (lang) parseableFiles.push({ path: file.path, content: file.content });
}

if (parseableFiles.length === 0) return { imports: [], calls: [], assignments: [], heritage: [], routes: [], fetchCalls: [], decoratorRoutes: [], toolDefs: [], constructorBindings: [], typeEnvBindings: [] };
if (parseableFiles.length === 0) return { imports: [], calls: [], assignments: [], heritage: [], routes: [], fetchCalls: [], decoratorRoutes: [], toolDefs: [], ormQueries: [], constructorBindings: [], typeEnvBindings: [] };

const total = files.length;

Expand All @@ -69,6 +70,7 @@ const processParsingWithWorkers = async (
const allFetchCalls: ExtractedFetchCall[] = [];
const allDecoratorRoutes: ExtractedDecoratorRoute[] = [];
const allToolDefs: ExtractedToolDef[] = [];
const allORMQueries: ExtractedORMQuery[] = [];
const allConstructorBindings: FileConstructorBindings[] = [];
const allTypeEnvBindings: FileTypeEnvBindings[] = [];
for (const result of chunkResults) {
Expand Down Expand Up @@ -103,6 +105,7 @@ const processParsingWithWorkers = async (
allFetchCalls.push(...result.fetchCalls);
allDecoratorRoutes.push(...result.decoratorRoutes);
allToolDefs.push(...result.toolDefs);
if (result.ormQueries) allORMQueries.push(...result.ormQueries);
allConstructorBindings.push(...result.constructorBindings);
allTypeEnvBindings.push(...result.typeEnvBindings);
}
Expand All @@ -123,7 +126,7 @@ const processParsingWithWorkers = async (

// Final progress
onFileProgress?.(total, total, 'done');
return { imports: allImports, calls: allCalls, assignments: allAssignments, heritage: allHeritage, routes: allRoutes, fetchCalls: allFetchCalls, decoratorRoutes: allDecoratorRoutes, toolDefs: allToolDefs, constructorBindings: allConstructorBindings, typeEnvBindings: allTypeEnvBindings };
return { imports: allImports, calls: allCalls, assignments: allAssignments, heritage: allHeritage, routes: allRoutes, fetchCalls: allFetchCalls, decoratorRoutes: allDecoratorRoutes, toolDefs: allToolDefs, ormQueries: allORMQueries, constructorBindings: allConstructorBindings, typeEnvBindings: allTypeEnvBindings };
};

// ============================================================================
Expand Down
109 changes: 106 additions & 3 deletions gitnexus/src/core/ingestion/pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { phpFileToRouteURL } from './route-extractors/php.js';
import { extractResponseShapes, extractPHPResponseShapes } from './route-extractors/response-shapes.js';
import { extractMiddlewareChain, extractNextjsMiddlewareConfig, compileMatcher, compiledMatcherMatchesRoute } from './route-extractors/middleware.js';
import { generateId } from '../../lib/utils.js';
import type { ExtractedFetchCall, ExtractedRoute, ExtractedDecoratorRoute, ExtractedToolDef } from './workers/parse-worker.js';
import type { ExtractedFetchCall, ExtractedRoute, ExtractedDecoratorRoute, ExtractedToolDef, ExtractedORMQuery } from './workers/parse-worker.js';
import { processHeritage, processHeritageFromExtracted } from './heritage-processor.js';
import { computeMRO } from './mro-processor.js';
import { processCommunities } from './community-processor.js';
Expand Down Expand Up @@ -514,6 +514,7 @@ async function runChunkedParseAndResolve(
allExtractedRoutes: ExtractedRoute[];
allDecoratorRoutes: ExtractedDecoratorRoute[];
allToolDefs: ExtractedToolDef[];
allORMQueries: ExtractedORMQuery[];
}> {
const symbolTable = ctx.symbols;

Expand Down Expand Up @@ -634,6 +635,7 @@ async function runChunkedParseAndResolve(
const allDecoratorRoutes: ExtractedDecoratorRoute[] = [];
// Accumulate MCP/RPC tool definitions (@mcp.tool(), @app.tool(), etc.)
const allToolDefs: ExtractedToolDef[] = [];
const allORMQueries: ExtractedORMQuery[] = [];

try {
for (let chunkIdx = 0; chunkIdx < numChunks; chunkIdx++) {
Expand Down Expand Up @@ -762,6 +764,9 @@ async function runChunkedParseAndResolve(
if (chunkWorkerData.toolDefs?.length) {
allToolDefs.push(...chunkWorkerData.toolDefs);
}
if (chunkWorkerData.ormQueries?.length) {
allORMQueries.push(...chunkWorkerData.ormQueries);
}
} else {
await processImports(graph, chunkFiles, astCache, ctx, undefined, repoPath, allPaths);
sequentialChunkPaths.push(chunkPaths);
Expand Down Expand Up @@ -797,6 +802,10 @@ async function runChunkedParseAndResolve(
if (chunkFetchCalls.length > 0) {
allFetchCalls.push(...chunkFetchCalls);
}
// Extract ORM queries (sequential path)
for (const f of chunkFiles) {
extractORMQueriesInline(f.path, f.content, allORMQueries);
}
astCache.clear();
}

Expand Down Expand Up @@ -853,7 +862,7 @@ async function runChunkedParseAndResolve(
importCtx.index = EMPTY_INDEX; // Release suffix index memory (~30MB for large repos)
importCtx.normalizedFileList = [];

return { exportedTypeMap, allFetchCalls, allExtractedRoutes, allDecoratorRoutes, allToolDefs };
return { exportedTypeMap, allFetchCalls, allExtractedRoutes, allDecoratorRoutes, allToolDefs, allORMQueries };
}

/**
Expand Down Expand Up @@ -1075,7 +1084,7 @@ export const runPipelineFromRepo = async (
const { scannedFiles, allPaths, totalFiles } = await runScanAndStructure(repoPath, graph, onProgress);

// Phase 3+4: Chunked parse + resolve (imports, calls, heritage, routes)
const { exportedTypeMap, allFetchCalls, allExtractedRoutes, allDecoratorRoutes, allToolDefs } = await runChunkedParseAndResolve(
const { exportedTypeMap, allFetchCalls, allExtractedRoutes, allDecoratorRoutes, allToolDefs, allORMQueries } = await runChunkedParseAndResolve(
graph, ctx, scannedFiles, allPaths, totalFiles, repoPath, pipelineStart, onProgress,
);

Expand Down Expand Up @@ -1340,6 +1349,11 @@ export const runPipelineFromRepo = async (
}
}

// ── Phase 3.7: ORM Dataflow Detection (Prisma + Supabase) ──────────
if (allORMQueries.length > 0) {
processORMQueries(graph, allORMQueries, isDev);
}

// ── Phase 14: Cross-file binding propagation (topological level sort) ──
await runCrossFileBindingPropagation(
graph, ctx, exportedTypeMap, allPaths, totalFiles, repoPath, pipelineStart, onProgress,
Expand Down Expand Up @@ -1374,3 +1388,92 @@ export const runPipelineFromRepo = async (
throw error;
}
};

// Inline ORM regex extraction (avoids importing parse-worker which has worker-only code)
const PRISMA_QUERY_RE = /\bprisma\.(\w+)\.(findMany|findFirst|findUnique|findUniqueOrThrow|findFirstOrThrow|create|createMany|update|updateMany|delete|deleteMany|upsert|count|aggregate|groupBy)\s*\(/g;
const SUPABASE_QUERY_RE = /\bsupabase\.from\s*\(\s*['"](\w+)['"]\s*\)\s*\.(select|insert|update|delete|upsert)\s*\(/g;

function extractORMQueriesInline(filePath: string, content: string, out: ExtractedORMQuery[]): void {
const hasPrisma = content.includes('prisma.');
const hasSupabase = content.includes('supabase.from');
if (!hasPrisma && !hasSupabase) return;

if (hasPrisma) {
PRISMA_QUERY_RE.lastIndex = 0;
let m;
while ((m = PRISMA_QUERY_RE.exec(content)) !== null) {
const model = m[1];
if (model.startsWith('$')) continue;
out.push({ filePath, orm: 'prisma', model, method: m[2], lineNumber: content.substring(0, m.index).split('\n').length - 1 });
}
}

if (hasSupabase) {
SUPABASE_QUERY_RE.lastIndex = 0;
let m;
while ((m = SUPABASE_QUERY_RE.exec(content)) !== null) {
out.push({ filePath, orm: 'supabase', model: m[1], method: m[2], lineNumber: content.substring(0, m.index).split('\n').length - 1 });
}
}
}

// ============================================================================
// ORM Query Processing — creates QUERIES edges from callers to model nodes
// ============================================================================

function processORMQueries(
graph: ReturnType<typeof createKnowledgeGraph>,
queries: ExtractedORMQuery[],
isDev: boolean,
): void {
const modelNodes = new Map<string, string>();
const seenEdges = new Set<string>();
let edgesCreated = 0;

for (const q of queries) {
const modelKey = `${q.orm}:${q.model}`;
let modelNodeId = modelNodes.get(modelKey);
if (!modelNodeId) {
const candidateIds = [
generateId('Class', `${q.model}`),
generateId('Interface', `${q.model}`),
generateId('CodeElement', `${q.model}`),
];
const existing = candidateIds.find(id => graph.getNode(id));
if (existing) {
modelNodeId = existing;
} else {
modelNodeId = generateId('CodeElement', `${q.orm}:${q.model}`);
graph.addNode({
id: modelNodeId,
label: 'CodeElement',
properties: {
name: q.model,
filePath: '',
description: `${q.orm} model/table: ${q.model}`,
},
});
}
modelNodes.set(modelKey, modelNodeId);
}

const fileId = generateId('File', q.filePath);
const edgeKey = `${fileId}->${modelNodeId}:${q.method}`;
if (seenEdges.has(edgeKey)) continue;
seenEdges.add(edgeKey);

graph.addRelationship({
id: generateId('QUERIES', edgeKey),
sourceId: fileId,
targetId: modelNodeId,
type: 'QUERIES',
confidence: 0.9,
reason: `${q.orm}-${q.method}`,
});
edgesCreated++;
}

if (isDev) {
console.log(`ORM dataflow: ${edgesCreated} QUERIES edges, ${modelNodes.size} models (${queries.length} total calls)`);
}
}
65 changes: 63 additions & 2 deletions gitnexus/src/core/ingestion/workers/parse-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,14 @@ export interface ExtractedToolDef {
lineNumber: number;
}

export interface ExtractedORMQuery {
filePath: string;
orm: 'prisma' | 'supabase';
model: string;
method: string;
lineNumber: number;
}

/** Constructor bindings keyed by filePath for cross-file type resolution */
export interface FileConstructorBindings {
filePath: string;
Expand All @@ -206,6 +214,7 @@ export interface ParseWorkerResult {
fetchCalls: ExtractedFetchCall[];
decoratorRoutes: ExtractedDecoratorRoute[];
toolDefs: ExtractedToolDef[];
ormQueries: ExtractedORMQuery[];
constructorBindings: FileConstructorBindings[];
/** File-scope type bindings from TypeEnv fixpoint for exported symbol collection. */
typeEnvBindings: FileTypeEnvBindings[];
Expand Down Expand Up @@ -353,6 +362,7 @@ const processBatch = (files: ParseWorkerInput[], onProgress?: (filesProcessed: n
fetchCalls: [],
decoratorRoutes: [],
toolDefs: [],
ormQueries: [],
constructorBindings: [],
typeEnvBindings: [],
skippedLanguages: {},
Expand Down Expand Up @@ -825,6 +835,53 @@ function extractLaravelRoutes(tree: any, filePath: string): ExtractedRoute[] {
return routes;
}

// ============================================================================
// ORM Query Detection (Prisma + Supabase)
// ============================================================================

const PRISMA_QUERY_RE = /\bprisma\.(\w+)\.(findMany|findFirst|findUnique|findUniqueOrThrow|findFirstOrThrow|create|createMany|update|updateMany|delete|deleteMany|upsert|count|aggregate|groupBy)\s*\(/g;
const SUPABASE_QUERY_RE = /\bsupabase\.from\s*\(\s*['"](\w+)['"]\s*\)\s*\.(select|insert|update|delete|upsert)\s*\(/g;

/**
* Extract ORM query calls from file content via regex.
* Appends results to the provided array (avoids allocation when no matches).
*/
export function extractORMQueries(filePath: string, content: string, out: ExtractedORMQuery[]): void {
const hasPrisma = content.includes('prisma.');
const hasSupabase = content.includes('supabase.from');
if (!hasPrisma && !hasSupabase) return;

if (hasPrisma) {
PRISMA_QUERY_RE.lastIndex = 0;
let m;
while ((m = PRISMA_QUERY_RE.exec(content)) !== null) {
const model = m[1];
if (model.startsWith('$')) continue;
out.push({
filePath,
orm: 'prisma',
model,
method: m[2],
lineNumber: content.substring(0, m.index).split('\n').length - 1,
});
}
}

if (hasSupabase) {
SUPABASE_QUERY_RE.lastIndex = 0;
let m;
while ((m = SUPABASE_QUERY_RE.exec(content)) !== null) {
out.push({
filePath,
orm: 'supabase',
model: m[1],
method: m[2],
lineNumber: content.substring(0, m.index).split('\n').length - 1,
});
}
}
}

const processFileGroup = (
files: ParseWorkerInput[],
language: SupportedLanguages,
Expand Down Expand Up @@ -1346,6 +1403,9 @@ const processFileGroup = (
const extractedRoutes = extractLaravelRoutes(tree, file.path);
result.routes.push(...extractedRoutes);
}

// Extract ORM queries (Prisma, Supabase)
extractORMQueries(file.path, file.content, result.ormQueries);
}
};

Expand All @@ -1356,7 +1416,7 @@ const processFileGroup = (
/** Accumulated result across sub-batches */
let accumulated: ParseWorkerResult = {
nodes: [], relationships: [], symbols: [],
imports: [], calls: [], assignments: [], heritage: [], routes: [], fetchCalls: [], decoratorRoutes: [], toolDefs: [], constructorBindings: [], typeEnvBindings: [], skippedLanguages: {}, fileCount: 0,
imports: [], calls: [], assignments: [], heritage: [], routes: [], fetchCalls: [], decoratorRoutes: [], toolDefs: [], ormQueries: [], constructorBindings: [], typeEnvBindings: [], skippedLanguages: {}, fileCount: 0,
};
let cumulativeProcessed = 0;

Expand All @@ -1372,6 +1432,7 @@ const mergeResult = (target: ParseWorkerResult, src: ParseWorkerResult) => {
target.fetchCalls.push(...src.fetchCalls);
target.decoratorRoutes.push(...src.decoratorRoutes);
target.toolDefs.push(...src.toolDefs);
target.ormQueries.push(...src.ormQueries);
target.constructorBindings.push(...src.constructorBindings);
target.typeEnvBindings.push(...src.typeEnvBindings);
for (const [lang, count] of Object.entries(src.skippedLanguages)) {
Expand All @@ -1398,7 +1459,7 @@ parentPort!.on('message', (msg: any) => {
if (msg && msg.type === 'flush') {
parentPort!.postMessage({ type: 'result', data: accumulated });
// Reset for potential reuse
accumulated = { nodes: [], relationships: [], symbols: [], imports: [], calls: [], assignments: [], heritage: [], routes: [], fetchCalls: [], decoratorRoutes: [], toolDefs: [], constructorBindings: [], typeEnvBindings: [], skippedLanguages: {}, fileCount: 0 };
accumulated = { nodes: [], relationships: [], symbols: [], imports: [], calls: [], assignments: [], heritage: [], routes: [], fetchCalls: [], decoratorRoutes: [], toolDefs: [], ormQueries: [], constructorBindings: [], typeEnvBindings: [], skippedLanguages: {}, fileCount: 0 };
cumulativeProcessed = 0;
return;
}
Expand Down
Loading
Loading