feat: add ORM dataflow detection (Prisma + Supabase) (#511)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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' };
|
||||
@@ -488,6 +499,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('],
|
||||
|
||||
@@ -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;
|
||||
@@ -25,6 +25,7 @@ export interface WorkerExtractedData {
|
||||
fetchCalls: ExtractedFetchCall[];
|
||||
decoratorRoutes: ExtractedDecoratorRoute[];
|
||||
toolDefs: ExtractedToolDef[];
|
||||
ormQueries: ExtractedORMQuery[];
|
||||
constructorBindings: FileConstructorBindings[];
|
||||
typeEnvBindings: FileTypeEnvBindings[];
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -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';
|
||||
@@ -514,6 +514,7 @@ async function runChunkedParseAndResolve(
|
||||
allExtractedRoutes: ExtractedRoute[];
|
||||
allDecoratorRoutes: ExtractedDecoratorRoute[];
|
||||
allToolDefs: ExtractedToolDef[];
|
||||
allORMQueries: ExtractedORMQuery[];
|
||||
}> {
|
||||
const symbolTable = ctx.symbols;
|
||||
|
||||
@@ -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++) {
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
@@ -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)`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,6 +185,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;
|
||||
@@ -210,6 +218,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[];
|
||||
@@ -358,6 +367,7 @@ const processBatch = (files: ParseWorkerInput[], onProgress?: (filesProcessed: n
|
||||
fetchCalls: [],
|
||||
decoratorRoutes: [],
|
||||
toolDefs: [],
|
||||
ormQueries: [],
|
||||
constructorBindings: [],
|
||||
typeEnvBindings: [],
|
||||
skippedLanguages: {},
|
||||
@@ -830,6 +840,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,
|
||||
@@ -1351,6 +1408,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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1361,7 +1421,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;
|
||||
|
||||
@@ -1377,6 +1437,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)) {
|
||||
@@ -1403,7 +1464,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;
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ export const REL_TABLE_NAME = 'CodeRelation';
|
||||
|
||||
// Valid relation types
|
||||
// Note: WRAPS is reserved for future middleware graph traversal (not yet emitted)
|
||||
export const REL_TYPES = ['CONTAINS', 'DEFINES', 'IMPORTS', 'CALLS', 'EXTENDS', 'IMPLEMENTS', 'HAS_METHOD', 'HAS_PROPERTY', 'ACCESSES', 'OVERRIDES', 'MEMBER_OF', 'STEP_IN_PROCESS', 'HANDLES_ROUTE', 'FETCHES', 'HANDLES_TOOL', 'ENTRY_POINT_OF', 'WRAPS'] as const;
|
||||
export const REL_TYPES = ['CONTAINS', 'DEFINES', 'IMPORTS', 'CALLS', 'EXTENDS', 'IMPLEMENTS', 'HAS_METHOD', 'HAS_PROPERTY', 'ACCESSES', 'OVERRIDES', 'MEMBER_OF', 'STEP_IN_PROCESS', 'HANDLES_ROUTE', 'FETCHES', 'HANDLES_TOOL', 'ENTRY_POINT_OF', 'WRAPS', 'QUERIES'] as const;
|
||||
export type RelType = typeof REL_TYPES[number];
|
||||
|
||||
// ============================================================================
|
||||
@@ -284,6 +284,7 @@ CREATE REL TABLE ${REL_TABLE_NAME} (
|
||||
FROM Function TO \`Typedef\`,
|
||||
FROM Function TO \`Union\`,
|
||||
FROM Function TO \`Property\`,
|
||||
FROM Function TO CodeElement,
|
||||
FROM Class TO Method,
|
||||
FROM Class TO Function,
|
||||
FROM Class TO Class,
|
||||
@@ -317,6 +318,7 @@ CREATE REL TABLE ${REL_TABLE_NAME} (
|
||||
FROM Method TO Interface,
|
||||
FROM Method TO \`Constructor\`,
|
||||
FROM Method TO \`Property\`,
|
||||
FROM Method TO CodeElement,
|
||||
FROM \`Template\` TO \`Template\`,
|
||||
FROM \`Template\` TO Function,
|
||||
FROM \`Template\` TO Method,
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
export const supabase = createClient('https://example.supabase.co', 'anon-key');
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
export const prisma = new PrismaClient();
|
||||
@@ -0,0 +1,25 @@
|
||||
import { prisma } from './db';
|
||||
|
||||
export async function getUsers() {
|
||||
return prisma.user.findMany({ where: { active: true } });
|
||||
}
|
||||
|
||||
export async function createPost(title: string, userId: number) {
|
||||
return prisma.post.create({ data: { title, authorId: userId } });
|
||||
}
|
||||
|
||||
export async function getUserById(id: number) {
|
||||
return prisma.user.findUnique({ where: { id } });
|
||||
}
|
||||
|
||||
export async function updatePost(id: number, title: string) {
|
||||
return prisma.post.update({ where: { id }, data: { title } });
|
||||
}
|
||||
|
||||
export async function deletePost(id: number) {
|
||||
return prisma.post.delete({ where: { id } });
|
||||
}
|
||||
|
||||
export async function countUsers() {
|
||||
return prisma.user.count();
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { supabase } from './client';
|
||||
|
||||
export async function getBookings() {
|
||||
return supabase.from('bookings').select('*');
|
||||
}
|
||||
|
||||
export async function createInterpreter(name: string) {
|
||||
return supabase.from('interpreters').insert({ name, active: true });
|
||||
}
|
||||
|
||||
export async function updateBooking(id: string, status: string) {
|
||||
return supabase.from('bookings').update({ status }).eq('id', id);
|
||||
}
|
||||
|
||||
export async function deleteSession(id: string) {
|
||||
return supabase.from('sessions').delete().eq('id', id);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Integration Tests: ORM Dataflow Detection (Prisma + Supabase)
|
||||
*/
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import path from 'path';
|
||||
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
|
||||
import type { PipelineResult } from '../../src/types/pipeline.js';
|
||||
|
||||
const ORM_REPO = path.resolve(__dirname, '..', 'fixtures', 'orm-repo');
|
||||
|
||||
describe('ORM dataflow detection', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(ORM_REPO, () => {});
|
||||
}, 60000);
|
||||
|
||||
it('creates QUERIES edges for Prisma calls', () => {
|
||||
const queryEdges: { source: string; target: string; reason: string }[] = [];
|
||||
for (const rel of result.graph.iterRelationships()) {
|
||||
if (rel.type === 'QUERIES') {
|
||||
const source = result.graph.getNode(rel.sourceId);
|
||||
const target = result.graph.getNode(rel.targetId);
|
||||
if (source && target) {
|
||||
queryEdges.push({
|
||||
source: source.properties.filePath || source.properties.name,
|
||||
target: target.properties.name,
|
||||
reason: rel.reason ?? '',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
const prismaEdges = queryEdges.filter(e => e.source.includes('prisma-service'));
|
||||
const prismaModels = [...new Set(prismaEdges.map(e => e.target))];
|
||||
expect(prismaModels).toContain('user');
|
||||
expect(prismaModels).toContain('post');
|
||||
const reasons = prismaEdges.map(e => e.reason);
|
||||
expect(reasons.some(r => r.includes('prisma-findMany'))).toBe(true);
|
||||
expect(reasons.some(r => r.includes('prisma-create'))).toBe(true);
|
||||
});
|
||||
|
||||
it('creates QUERIES edges for Supabase calls', () => {
|
||||
const queryEdges: { source: string; target: string; reason: string }[] = [];
|
||||
for (const rel of result.graph.iterRelationships()) {
|
||||
if (rel.type === 'QUERIES') {
|
||||
const source = result.graph.getNode(rel.sourceId);
|
||||
const target = result.graph.getNode(rel.targetId);
|
||||
if (source && target) {
|
||||
queryEdges.push({
|
||||
source: source.properties.filePath || source.properties.name,
|
||||
target: target.properties.name,
|
||||
reason: rel.reason ?? '',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
const supabaseEdges = queryEdges.filter(e => e.source.includes('supabase-service'));
|
||||
const supabaseModels = [...new Set(supabaseEdges.map(e => e.target))];
|
||||
expect(supabaseModels).toContain('bookings');
|
||||
expect(supabaseModels).toContain('interpreters');
|
||||
expect(supabaseModels).toContain('sessions');
|
||||
const reasons = supabaseEdges.map(e => e.reason);
|
||||
expect(reasons.some(r => r.includes('supabase-select'))).toBe(true);
|
||||
expect(reasons.some(r => r.includes('supabase-insert'))).toBe(true);
|
||||
});
|
||||
|
||||
it('creates CodeElement nodes for ORM models', () => {
|
||||
const codeElements: string[] = [];
|
||||
result.graph.forEachNode(n => {
|
||||
if (n.label === 'CodeElement' && n.properties.description?.includes('model/table')) {
|
||||
codeElements.push(n.properties.name);
|
||||
}
|
||||
});
|
||||
expect(codeElements).toContain('user');
|
||||
expect(codeElements).toContain('post');
|
||||
expect(codeElements).toContain('bookings');
|
||||
expect(codeElements).toContain('interpreters');
|
||||
expect(codeElements).toContain('sessions');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user