/** * @module custom_cil-callmap * @audience installer * @layer api-handler * @stability experimental * * Pass 4b of the CIL ingestion pipeline: static call-graph extraction. * * Reads each cil_chunk's stored code_content and: * 1. Extracts import statements → writes cil_imports links between cil_file items * 2. Extracts function call sites → writes cil_calls links between cil_chunk items * 3. Extracts export symbols → writes cil_exports links from chunks * * This uses regex-based static analysis (no TS compiler needed at runtime). * The result feeds the graph visualiser and blast-radius queries. * * Actions: * POST ?action=map_project { project_id, force? } — map all files in a project * POST ?action=map_file { file_id, force? } — map one file * GET ?action=status { project_id } — count links created * DELETE ?action=clear { project_id } — delete all CIL links for project */ import { createHandler } from './_shared/middleware' import { adminDb } from './_shared/db' import { resolveLinkTypeId } from './_shared/resolve-ids' import * as fs from 'fs' import * as path from 'path' // ─── TYPES ──────────────────────────────────────────────────────────────────── interface ExtractedImport { from_path: string // relative import specifier e.g. './_shared/db' symbols: string[] // named imports is_default: boolean } interface ExtractedCall { callee_name: string // function/method name called is_await: boolean } // ─── STATIC EXTRACTORS ──────────────────────────────────────────────────────── /** * Extract import statements from TypeScript/JavaScript source. * Handles: import X from '…', import { X, Y } from '…', import * as X from '…' */ function extractImports(source: string): ExtractedImport[] { const results: ExtractedImport[] = [] // Match: import ... from '...' or import ... from "..." const importRe = /^import\s+([\s\S]*?)\s+from\s+['"]([^'"]+)['"]/gm let m: RegExpExecArray | null while ((m = importRe.exec(source)) !== null) { const specifierBlock = m[1].trim() const fromPath = m[2] let symbols: string[] = [] let isDefault = false if (specifierBlock.startsWith('{')) { // Named imports: { X, Y as Z } const inner = specifierBlock.replace(/^\{|\}$/g, '').trim() symbols = inner.split(',').map(s => { const parts = s.trim().split(/\s+as\s+/) return (parts[parts.length - 1] || '').trim() }).filter(Boolean) } else if (specifierBlock.startsWith('*')) { // Namespace import: * as X const asMatch = specifierBlock.match(/\*\s+as\s+(\w+)/) if (asMatch) symbols = [asMatch[1]] } else { // Default import: X or X, { Y } const defaultMatch = specifierBlock.match(/^(\w+)/) if (defaultMatch) { symbols = [defaultMatch[1]]; isDefault = true } const namedMatch = specifierBlock.match(/\{([^}]+)\}/) if (namedMatch) { const named = namedMatch[1].split(',').map(s => s.trim().split(/\s+as\s+/).pop()!.trim()).filter(Boolean) symbols = [...symbols, ...named] } } results.push({ from_path: fromPath, symbols, is_default: isDefault }) } return results } /** * Extract function call expressions from a chunk's source code. * Captures: identifier(...), await identifier(...), object.method(...) * Returns unique callee names (deduped). */ function extractCalls(source: string): ExtractedCall[] { const seen = new Set() const results: ExtractedCall[] = [] // Match: [await] [obj.]funcName( const callRe = /(await\s+)?(?:[\w$]+\.)*?([\w$]{2,})\s*\(/g let m: RegExpExecArray | null while ((m = callRe.exec(source)) !== null) { const isAwait = !!m[1] const calleeName = m[2] // Skip common JS keywords and very short names if (['if', 'for', 'while', 'switch', 'catch', 'return', 'new', 'typeof', 'instanceof', 'function'].includes(calleeName)) continue if (calleeName.length < 3) continue if (seen.has(calleeName)) continue seen.add(calleeName) results.push({ callee_name: calleeName, is_await: isAwait }) } return results } /** * Extract exported symbol names from a file's full source. */ function extractExports(source: string): string[] { const exports: string[] = [] // export function/class/const/type/interface/enum const exportRe = /^export\s+(?:default\s+)?(?:async\s+)?(?:function|class|const|let|var|type|interface|enum)\s+(\w+)/gm let m: RegExpExecArray | null while ((m = exportRe.exec(source)) !== null) { exports.push(m[1]) } // export { X, Y, Z } const namedRe = /^export\s+\{([^}]+)\}/gm while ((m = namedRe.exec(source)) !== null) { const named = m[1].split(',').map(s => s.trim().split(/\s+as\s+/).pop()!.trim()).filter(Boolean) exports.push(...named) } return [...new Set(exports)] } // ─── RESOLVE FILE PATH → FILE ITEM ─────────────────────────────────────────── function resolveImportPath(fromFilePath: string, importSpecifier: string): string | null { // Only resolve relative imports — skip node_modules if (!importSpecifier.startsWith('.')) return null const fromDir = fromFilePath.split('/').slice(0, -1).join('/') const segments = `${fromDir}/${importSpecifier}`.split('/') const resolved: string[] = [] for (const seg of segments) { if (seg === '..') resolved.pop() else if (seg !== '.') resolved.push(seg) } const base = resolved.join('/') // TypeScript: try .ts, .tsx, /index.ts return base // caller will look up with ilike } // ─── WRITE LINK (upsert-safe) ───────────────────────────────────────────────── async function upsertLink( sourceId: string, targetId: string, linkTypeId: string, metadata: Record = {} ): Promise { // Check if link already exists const { data: existing } = await adminDb .from('links') .select('id') .eq('source_id', sourceId) .eq('target_id', targetId) .eq('link_type_id', linkTypeId) .maybeSingle() if (existing) return false // already exists const { error } = await adminDb.from('links').insert({ source_id: sourceId, target_id: targetId, link_type_id: linkTypeId, metadata, created_at: new Date().toISOString(), }) return !error } // ─── MAP ONE FILE ───────────────────────────────────────────────────────────── async function mapFile( fileItem: any, projectId: string, allChunksByFile: Map, chunksByName: Map, fileItemsByPath: Map, importsLinkTypeId: string, callsLinkTypeId: string, exportsLinkTypeId: string ): Promise<{ imports_created: number; calls_created: number; exports_created: number }> { let importsCreated = 0, callsCreated = 0, exportsCreated = 0 const filePath: string = fileItem.data?.file_path || fileItem.title const chunksInFile = allChunksByFile.get(fileItem.id) || [] // ── Process each chunk ────────────────────────────────────────────────────── for (const chunk of chunksInFile) { const codeContent: string = chunk.data?.code_content || '' if (!codeContent) continue // ── Extract and write call edges ───────────────────────────────────────── const calls = extractCalls(codeContent) for (const call of calls) { const targetChunk = chunksByName.get(call.callee_name) if (!targetChunk || targetChunk.id === chunk.id) continue const created = await upsertLink(chunk.id, targetChunk.id, callsLinkTypeId, { callee_name: call.callee_name, is_await: call.is_await, }) if (created) callsCreated++ } } // ── Process file-level imports ────────────────────────────────────────────── // Combine all code_content in file to extract imports (they're usually at file top) const fullFileContent = chunksInFile.map((c: any) => c.data?.code_content || '').join('\n') const imports = extractImports(fullFileContent) for (const imp of imports) { const resolvedBase = resolveImportPath(filePath, imp.from_path) if (!resolvedBase) continue // Look up target file by path prefix match const targetFile = fileItemsByPath.get(resolvedBase) || fileItemsByPath.get(resolvedBase + '.ts') || fileItemsByPath.get(resolvedBase + '.tsx') || fileItemsByPath.get(resolvedBase + '/index.ts') || fileItemsByPath.get(resolvedBase + '/index.tsx') if (!targetFile || targetFile.id === fileItem.id) continue const created = await upsertLink(fileItem.id, targetFile.id, importsLinkTypeId, { symbols: imp.symbols, is_default: imp.is_default, specifier: imp.from_path, }) if (created) importsCreated++ } // ── Write export links ──────────────────────────────────────────────────── const exportedNames = extractExports(fullFileContent) for (const exportedName of exportedNames) { const chunk = chunksByName.get(exportedName) if (!chunk) continue const created = await upsertLink(chunk.id, fileItem.id, exportsLinkTypeId, { exported_name: exportedName, }) if (created) exportsCreated++ } return { imports_created: importsCreated, calls_created: callsCreated, exports_created: exportsCreated } } // ─── TEST PATH EXTRACTOR ────────────────────────────────────────────────────── /** * Determines whether a file path looks like a test file. */ function isTestFile(filePath: string): boolean { return /\.(test|spec)\.(ts|tsx|js|jsx)$/.test(filePath) || /\/__tests__\//.test(filePath) || /\/tests?\//.test(filePath) } /** * For every test file in a project: * 1. Reads its source from disk via repo_path * 2. Scans for describe/it/test/beforeEach blocks that reference chunk names * 3. Writes a `cil_tested_by` link from each matched chunk to the test file item * 4. Updates `test_unit_path` or `test_e2e_path` on the chunk item * * Returns counts of links created and chunks updated. */ async function mapTests( projectId: string, repoPath: string, testedByLinkTypeId: string ): Promise<{ test_files_scanned: number; tested_by_links_created: number; chunks_updated: number; errors: string[] }> { const errors: string[] = [] let testFilesScanned = 0, linksCreated = 0, chunksUpdated = 0 // Load all test cil_file items for this project const { data: allFiles } = await adminDb .from('items').select('id, title, data') .filter('data->>project_id', 'eq', projectId) .filter('data->>kb_type', 'eq', 'cil_file') .eq('is_active', true) const testFiles = (allFiles || []).filter((f: any) => isTestFile(f.data?.file_path || f.title)) // Load all chunks for this project const { data: allChunks } = await adminDb .from('items').select('id, data') .filter('data->>project_id', 'eq', projectId) .filter('data->>kb_type', 'eq', 'cil_chunk') .eq('is_active', true) // Build lookup: chunk_name → chunk item const chunksByName = new Map() for (const c of allChunks || []) { const name = c.data?.chunk_name || '' if (name) chunksByName.set(name, c) } for (const testFileItem of testFiles) { const relPath: string = testFileItem.data?.file_path || testFileItem.title const absPath = path.join(repoPath, relPath) const isE2E = /e2e|cypress|playwright|integration/.test(relPath) try { if (!fs.existsSync(absPath)) continue const source = fs.readFileSync(absPath, 'utf-8') testFilesScanned++ // Find all chunk names referenced in this test file // Heuristic: look for chunk names used as identifiers (import or describe/it strings) const matchedChunkNames = new Set() // 1. Import references: import { ChunkName } from '...' const importRe = /import\s+\{([^}]+)\}/g let m: RegExpExecArray | null while ((m = importRe.exec(source)) !== null) { const names = m[1].split(',').map(s => s.trim().split(/\s+as\s+/).shift()!.trim()).filter(Boolean) for (const n of names) { if (chunksByName.has(n)) matchedChunkNames.add(n) } } // 2. String references in describe/it/test blocks const stringRe = /(?:describe|it|test|beforeEach|afterEach)\s*\(\s*['"`]([^'"`]+)['"`]/g while ((m = stringRe.exec(source)) !== null) { // Check if any word in the string matches a chunk name const words = m[1].split(/\W+/) for (const word of words) { if (chunksByName.has(word)) matchedChunkNames.add(word) } } // 3. Direct function calls that match chunk names const callRe = /\b(\w{3,})\s*\(/g while ((m = callRe.exec(source)) !== null) { const name = m[1] if (chunksByName.has(name)) matchedChunkNames.add(name) } for (const chunkName of matchedChunkNames) { const chunk = chunksByName.get(chunkName)! const cd = chunk.data || {} // Write cil_tested_by link: chunk → test file const created = await upsertLink(chunk.id, testFileItem.id, testedByLinkTypeId, { test_path: relPath, is_e2e: isE2E, }) if (created) linksCreated++ // Update test_unit_path or test_e2e_path on the chunk const pathField = isE2E ? 'test_e2e_path' : 'test_unit_path' const existingPath = cd[pathField] if (!existingPath || existingPath === relPath) { await adminDb.from('items').update({ data: { ...cd, [pathField]: relPath }, updated_at: new Date().toISOString(), }).eq('id', chunk.id) chunksUpdated++ } } } catch (err) { errors.push(`${relPath}: ${err instanceof Error ? err.message : String(err)}`) } } return { test_files_scanned: testFilesScanned, tested_by_links_created: linksCreated, chunks_updated: chunksUpdated, errors } } // ─── HANDLER ────────────────────────────────────────────────────────────────── export const handler = createHandler(async (ctx, body) => { const action = ctx.query?.action // ── STATUS ──────────────────────────────────────────────────────────────────── if (action === 'status') { const projectId = ctx.query?.project_id || body?.project_id if (!projectId) throw new Error('project_id is required') const [importsTypeId, callsTypeId, exportsTypeId, testedByTypeId] = await Promise.all([ resolveLinkTypeId('cil_imports').catch(() => null), resolveLinkTypeId('cil_calls').catch(() => null), resolveLinkTypeId('cil_exports').catch(() => null), resolveLinkTypeId('cil_tested_by').catch(() => null), ]) const { data: fileItems } = await adminDb .from('items') .select('id') .filter('data->>project_id', 'eq', projectId) .filter('data->>kb_type', 'eq', 'cil_file') const { data: chunkItems } = await adminDb .from('items') .select('id') .filter('data->>project_id', 'eq', projectId) .filter('data->>kb_type', 'eq', 'cil_chunk') const fileIds = (fileItems || []).map((f: any) => f.id) const chunkIds = (chunkItems || []).map((c: any) => c.id) let importCount = 0, callCount = 0, exportCount = 0 if (importsTypeId && fileIds.length) { const { count } = await adminDb.from('links').select('id', { count: 'exact' }) .eq('link_type_id', importsTypeId).in('source_id', fileIds) importCount = count || 0 } if (callsTypeId && chunkIds.length) { const { count } = await adminDb.from('links').select('id', { count: 'exact' }) .eq('link_type_id', callsTypeId).in('source_id', chunkIds) callCount = count || 0 } if (exportsTypeId && chunkIds.length) { const { count } = await adminDb.from('links').select('id', { count: 'exact' }) .eq('link_type_id', exportsTypeId).in('source_id', chunkIds) exportCount = count || 0 } let testedByCount = 0 if (testedByTypeId && chunkIds.length) { const { count } = await adminDb.from('links').select('id', { count: 'exact' }) .eq('link_type_id', testedByTypeId).in('source_id', chunkIds) testedByCount = count || 0 } return { project_id: projectId, import_links: importCount, call_links: callCount, export_links: exportCount, tested_by_links: testedByCount, total_links: importCount + callCount + exportCount + testedByCount, } } // ── CLEAR — delete all CIL links for a project ──────────────────────────────── if (action === 'clear') { const projectId = body?.project_id if (!projectId) throw new Error('project_id is required') const [importsTypeId, callsTypeId, exportsTypeId] = await Promise.all([ resolveLinkTypeId('cil_imports').catch(() => null), resolveLinkTypeId('cil_calls').catch(() => null), resolveLinkTypeId('cil_exports').catch(() => null), ]) const { data: chunkItems } = await adminDb .from('items').select('id') .filter('data->>project_id', 'eq', projectId) .filter('data->>kb_type', 'in', '(cil_chunk,cil_file)') const ids = (chunkItems || []).map((c: any) => c.id) let deleted = 0 for (const ltId of [importsTypeId, callsTypeId, exportsTypeId].filter(Boolean)) { if (!ids.length) continue const { data: deletedRows } = await adminDb.from('links') .delete().eq('link_type_id', ltId!).in('source_id', ids).select('id') deleted += (deletedRows || []).length } return { project_id: projectId, deleted_links: deleted } } // ── MAP ONE FILE ────────────────────────────────────────────────────────────── if (action === 'map_file') { const fileId = body?.file_id if (!fileId) throw new Error('file_id is required') const { data: fileItem } = await adminDb .from('items').select('id, title, data').eq('id', fileId).maybeSingle() if (!fileItem) throw new Error(`File not found: ${fileId}`) const projectId = fileItem.data?.project_id if (!projectId) throw new Error('File has no project_id') const [importsTypeId, callsTypeId, exportsTypeId] = await Promise.all([ resolveLinkTypeId('cil_imports'), resolveLinkTypeId('cil_calls'), resolveLinkTypeId('cil_exports'), ]) // Load supporting maps const { data: allFiles } = await adminDb .from('items').select('id, title, data') .filter('data->>project_id', 'eq', projectId) .filter('data->>kb_type', 'eq', 'cil_file').eq('is_active', true) const { data: allChunks } = await adminDb .from('items').select('id, title, data') .filter('data->>project_id', 'eq', projectId) .filter('data->>kb_type', 'eq', 'cil_chunk').eq('is_active', true) const fileItemsByPath = new Map((allFiles || []).map((f: any) => [f.data?.file_path || f.title, f])) const chunksByName = new Map((allChunks || []).map((c: any) => [c.data?.chunk_name || '', c])) const allChunksByFile = new Map() for (const c of allChunks || []) { const fid = c.data?.file_id if (!fid) continue if (!allChunksByFile.has(fid)) allChunksByFile.set(fid, []) allChunksByFile.get(fid)!.push(c) } const result = await mapFile( fileItem, projectId, allChunksByFile, chunksByName, fileItemsByPath, importsTypeId, callsTypeId, exportsTypeId ) return { file_id: fileId, ...result } } // ── MAP TESTS — scan test files and write cil_tested_by links ──────────────── if (action === 'map_tests') { const projectId = body?.project_id if (!projectId) throw new Error('project_id is required') // Resolve repo_path from the project item const { data: projectItem } = await adminDb .from('items').select('data').eq('id', projectId).maybeSingle() const repoPath: string = projectItem?.data?.repo_path || body?.repo_path || '' if (!repoPath) throw new Error('Project has no repo_path — pass repo_path in body') const testedByTypeId = await resolveLinkTypeId('cil_tested_by') const result = await mapTests(projectId, repoPath, testedByTypeId) return { project_id: projectId, repo_path: repoPath, ...result } } // ── MAP ENTIRE PROJECT (paginated) ─────────────────────────────────────────── // // Supports limit + offset so the UI can page through large projects: // POST { project_id, limit: 100, offset: 0 } // POST { project_id, limit: 100, offset: 100 } ... until has_more=false // if (action === 'map_project') { const projectId = body?.project_id if (!projectId) throw new Error('project_id is required') const pageLimit = Math.min(parseInt(body?.limit || '100', 10), 300) const pageOffset = parseInt(body?.offset || '0', 10) const filePath = body?.file_path || undefined const [importsTypeId, callsTypeId, exportsTypeId] = await Promise.all([ resolveLinkTypeId('cil_imports'), resolveLinkTypeId('cil_calls'), resolveLinkTypeId('cil_exports'), ]) // Load all files + chunks for this project (chunks needed for lookup maps regardless of page) let filesQ = adminDb.from('items').select('id, title, data') .filter('data->>project_id', 'eq', projectId) .filter('data->>kb_type', 'eq', 'cil_file').eq('is_active', true) .order('id') if (filePath) filesQ = filesQ.filter('data->>file_path', 'eq', filePath) const [filesResult, chunksResult] = await Promise.all([ filesQ, adminDb.from('items').select('id, title, data') .filter('data->>project_id', 'eq', projectId) .filter('data->>kb_type', 'eq', 'cil_chunk').eq('is_active', true), ]) const allFiles = filesResult.data || [] const allChunks = chunksResult.data || [] if (!allChunks.length) { return { project_id: projectId, file_path: filePath || null, message: 'No chunks found — run Pass 1 + Pass 2 first', imports_created: 0, calls_created: 0, exports_created: 0, has_more: false } } const totalFiles = allFiles.length const pageFiles = allFiles.slice(pageOffset, pageOffset + pageLimit) // Build lookup maps (always from full set for correct cross-file resolution) const fileItemsByPath = new Map(allFiles.map((f: any) => [f.data?.file_path || f.title, f])) const chunksByName = new Map(allChunks.map((c: any) => [c.data?.chunk_name || '', c])) const allChunksByFile = new Map() for (const c of allChunks) { const fid = c.data?.file_id if (!fid) continue if (!allChunksByFile.has(fid)) allChunksByFile.set(fid, []) allChunksByFile.get(fid)!.push(c) } let totalImports = 0, totalCalls = 0, totalExports = 0 const errors: string[] = [] for (const fileItem of pageFiles) { try { const result = await mapFile( fileItem, projectId, allChunksByFile, chunksByName, fileItemsByPath, importsTypeId, callsTypeId, exportsTypeId ) totalImports += result.imports_created totalCalls += result.calls_created totalExports += result.exports_created } catch (err) { errors.push(`${fileItem.data?.file_path || fileItem.id}: ${err instanceof Error ? err.message : String(err)}`) } } const nextOffset = pageOffset + pageLimit const isLastPage = nextOffset >= totalFiles // Run test-coverage mapping only on the last page let testResult: { test_files_scanned: number; tested_by_links_created: number; chunks_updated: number; errors: string[] } | null = null if (isLastPage) { const { data: projectItemForTests } = await adminDb .from('items').select('data').eq('id', projectId).maybeSingle() const repoPath: string = projectItemForTests?.data?.repo_path || '' if (repoPath) { try { const testedByTypeId = await resolveLinkTypeId('cil_tested_by').catch(() => null) if (testedByTypeId) { testResult = await mapTests(projectId, repoPath, testedByTypeId) errors.push(...(testResult.errors || [])) } } catch { // test mapping is best-effort } } } return { project_id: projectId, file_path: filePath || null, total_files: totalFiles, files_processed: pageFiles.length, imports_created: totalImports, calls_created: totalCalls, exports_created: totalExports, tested_by_links_created: testResult?.tested_by_links_created ?? 0, test_files_scanned: testResult?.test_files_scanned ?? 0, chunks_updated_with_test_paths: testResult?.chunks_updated ?? 0, total_links_created: totalImports + totalCalls + totalExports + (testResult?.tested_by_links_created ?? 0), errors, has_more: !isLastPage, next_offset: !isLastPage ? nextOffset : null, done_total: pageOffset + pageFiles.length, } } throw new Error(`Unknown action: ${action}. Valid: map_project, map_file, map_tests, status, clear`) })