feat: add ORM dataflow detection (Prisma + Supabase) (#511)

This commit is contained in:
marxo126
2026-03-26 08:48:40 +00:00
committed by GitHub
parent 8864a0290c
commit a047a08f54
11 changed files with 322 additions and 9 deletions
+1
View File
@@ -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 };
};
// ============================================================================
+106 -3
View File
@@ -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;
}
+3 -1
View File
@@ -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,
+3
View File
@@ -0,0 +1,3 @@
import { createClient } from '@supabase/supabase-js';
export const supabase = createClient('https://example.supabase.co', 'anon-key');
+3
View File
@@ -0,0 +1,3 @@
import { PrismaClient } from '@prisma/client';
export const prisma = new PrismaClient();
+25
View File
@@ -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();
}
+17
View File
@@ -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');
});
});