Spaces:
Paused
Paused
| /* eslint-disable no-console */ | |
| import Parser, {Tree, Query} from 'web-tree-sitter' | |
| import {Project, SyntaxKind} from 'ts-morph' | |
| import {SymbolGraphCache, IncrementalSymbolGraph} from './symbol-graph-cache' | |
| import {createHash} from 'crypto' | |
| import {discoverFiles, FileDiscoverer} from './file-discoverer' | |
| import { LRUCache } from 'lru-cache' | |
| import pino from 'pino' | |
| import {readFile, writeFile} from 'fs/promises' | |
| import {existsSync, readdirSync} from 'fs' | |
| const logger = pino({ level: process.env.LOG_LEVEL || 'info' }) | |
| // Initialize Tree-sitter once at module load time | |
| let parserInitialized = false | |
| const initParser = async (): Promise<boolean> => { | |
| if (parserInitialized) return true | |
| try { | |
| await Parser.init() | |
| parserInitialized = true | |
| logger.info('Tree-sitter parser initialized') | |
| return true | |
| } catch (e) { | |
| logger.warn({ error: e }, 'Tree-sitter init failed, using fallback parsing') | |
| return false | |
| } | |
| } | |
| export interface Symbol { | |
| name: string | |
| type: | |
| | 'function' | |
| | 'class' | |
| | 'interface' | |
| | 'type' | |
| | 'variable' | |
| | 'method' | |
| | 'const' | |
| file: string | |
| line: number | |
| exports: string[] | |
| imports: string[] | |
| references: string[] | |
| } | |
| export interface DependencyEdge { | |
| from: string | |
| to: string | |
| type: 'imports' | 'calls' | 'extends' | 'implements' | 'uses' | |
| } | |
| export interface SymbolGraph { | |
| symbols: Map<string, Symbol> | |
| edges: DependencyEdge[] | |
| fileIndex: Map<string, Symbol[]> | |
| } | |
| export interface RepoContext { | |
| symbolGraph: SymbolGraph | |
| typeHierarchy: Map<string, string[]> | |
| callGraph: Map<string, string[]> | |
| architecturalLayers: Map<string, string> | |
| } | |
| export interface ArchitecturalViolation { | |
| from: string | |
| to: string | |
| fromLayer: string | |
| toLayer: string | |
| severity: 'critical' | 'major' | 'minor' | |
| description: string | |
| } | |
| const PARSER_DIR = process.env.PARSER_DIR || '/app/parsers' | |
| const LANGUAGE_LOADERS: Record<string, () => Promise<any>> = { | |
| '.ts': async () => Parser.Language.load(`${PARSER_DIR}/tree-sitter-typescript.wasm`), | |
| '.tsx': async () => Parser.Language.load(`${PARSER_DIR}/tree-sitter-tsx.wasm`), | |
| '.js': async () => Parser.Language.load(`${PARSER_DIR}/tree-sitter-javascript.wasm`), | |
| '.jsx': async () => Parser.Language.load(`${PARSER_DIR}/tree-sitter-javascript.wasm`), | |
| '.go': async () => Parser.Language.load(`${PARSER_DIR}/tree-sitter-go.wasm`), | |
| '.py': async () => Parser.Language.load(`${PARSER_DIR}/tree-sitter-python.wasm`), | |
| '.rs': async () => { | |
| try { | |
| return Parser.Language.load(`${PARSER_DIR}/tree-sitter-rust.wasm`) | |
| } catch { | |
| return null | |
| } | |
| }, | |
| '.cpp': async () => { | |
| try { | |
| return Parser.Language.load(`${PARSER_DIR}/tree-sitter-cpp.wasm`) | |
| } catch { | |
| return null | |
| } | |
| }, | |
| '.c': async () => { | |
| try { | |
| return Parser.Language.load(`${PARSER_DIR}/tree-sitter-c.wasm`) | |
| } catch { | |
| return null | |
| } | |
| }, | |
| '.java': async () => { | |
| try { | |
| return Parser.Language.load(`${PARSER_DIR}/tree-sitter-java.wasm`) | |
| } catch { | |
| return null | |
| } | |
| } | |
| } | |
| const LANGUAGE_LOADER_CACHE_TTL_MS = 30 * 60 * 1000 // 30 minutes | |
| const LANGUAGE_LOADER_MAX_SIZE = 10 | |
| function computeContentHash(content: string): string { | |
| return createHash('sha256').update(content).digest('hex').substring(0, 16) | |
| } | |
| interface CachedLanguage { | |
| language: any | |
| } | |
| export class UnifiedContextEngine { | |
| private graph: SymbolGraph = { | |
| symbols: new Map(), | |
| edges: [], | |
| fileIndex: new Map() | |
| } | |
| private project: Project | |
| // SECURITY: LRU cache prevents memory leaks (Issue #5) | |
| private languageCache: LRUCache<string, CachedLanguage> | |
| private graphCache!: SymbolGraphCache | |
| private incrementalGraph!: IncrementalSymbolGraph | |
| private repoPath: string = '' | |
| constructor() { | |
| this.project = new Project() | |
| // LRU cache with max size and TTL to prevent unbounded memory growth | |
| this.languageCache = new LRUCache<string, CachedLanguage>({ | |
| max: LANGUAGE_LOADER_MAX_SIZE, | |
| ttl: LANGUAGE_LOADER_CACHE_TTL_MS, | |
| updateAgeOnGet: true, | |
| allowStale: false, | |
| dispose: (value, key) => { | |
| logger.debug({ key }, 'Language evicted from cache') | |
| } | |
| }) | |
| } | |
| async initialize(repoPath: string, stableId?: string): Promise<RepoContext> { | |
| this.repoPath = repoPath | |
| const cacheId = stableId || repoPath | |
| this.graphCache = new SymbolGraphCache(cacheId) | |
| this.incrementalGraph = new IncrementalSymbolGraph(cacheId) | |
| await this.buildSymbolGraph(repoPath) | |
| return this.buildRepoContext() | |
| } | |
| async updateForChanges( | |
| changedFiles: string[], | |
| deletedFiles: string[] | |
| ): Promise<{ | |
| updatedFiles: string[] | |
| cacheStats: {hits: number; misses: number; hitRate: number} | |
| }> { | |
| if (!this.incrementalGraph) { | |
| return {updatedFiles: [], cacheStats: {hits: 0, misses: 0, hitRate: 0}} | |
| } | |
| const result = await this.incrementalGraph.update( | |
| changedFiles, | |
| deletedFiles | |
| ) | |
| return { | |
| updatedFiles: result.updatedFiles, | |
| cacheStats: result.cacheStats | |
| } | |
| } | |
| private async buildSymbolGraph(repoPath: string): Promise<void> { | |
| const parserReady = await initParser() | |
| if (!parserReady) { | |
| logger.warn('Parser not available, using fallback parsing') | |
| return | |
| } | |
| // Robustness: Check if parser assets exist | |
| const parserDir = process.env.PARSER_DIR || '/app/parsers' | |
| if (!existsSync(parserDir) || readdirSync(parserDir).length === 0) { | |
| console.warn(`[PRIX] WARNING: Parser directory ${parserDir} is missing or empty. Deep auditing will be degraded.`) | |
| } | |
| const discovered = discoverFiles(repoPath, { | |
| include: [ | |
| '.ts', | |
| '.tsx', | |
| '.js', | |
| '.jsx', | |
| '.go', | |
| '.py', | |
| '.rs', | |
| '.cpp', | |
| '.c', | |
| '.h', | |
| '.java' | |
| ] | |
| }) | |
| const filesByExt = new FileDiscoverer().groupByExtension(discovered) | |
| const tsFiles = [ | |
| ...(filesByExt.get('.ts') || []), | |
| ...(filesByExt.get('.tsx') || []), | |
| ...(filesByExt.get('.js') || []), | |
| ...(filesByExt.get('.jsx') || []) | |
| ].map(f => f.path) | |
| const goFiles = (filesByExt.get('.go') || []).map(f => f.path) | |
| const pyFiles = (filesByExt.get('.py') || []).map(f => f.path) | |
| const rsFiles = (filesByExt.get('.rs') || []).map(f => f.path) | |
| const cppFiles = [ | |
| ...(filesByExt.get('.cpp') || []), | |
| ...(filesByExt.get('.c') || []), | |
| ...(filesByExt.get('.h') || []), | |
| ...(filesByExt.get('.hpp') || []), | |
| ...(filesByExt.get('.java') || []) | |
| ].map(f => f.path) | |
| console.log( | |
| `[Graph] Discovered: ${tsFiles.length} TS/JS, ${goFiles.length} Go, ${pyFiles.length} Python, ${rsFiles.length} Rust, ${cppFiles.length} C++/Java files` | |
| ) | |
| await Promise.all([ | |
| this.processTSFilesWithCache(tsFiles), | |
| this.processGoFilesWithCache(goFiles), | |
| this.processPythonFilesWithCache(pyFiles), | |
| this.processRustFilesWithCache(rsFiles), | |
| this.processCppJavaFilesWithCache(cppFiles) | |
| ]) | |
| this.buildDependencyEdges() | |
| this.graphCache?.persist() | |
| } | |
| private async processTSFilesWithCache(files: string[]): Promise<void> { | |
| const uncachedFiles: string[] = [] | |
| for (const file of files) { | |
| const cached = this.graphCache?.get(file) | |
| if (cached) { | |
| for (const sym of cached.symbols) { | |
| this.graph.symbols.set(`${file}:${sym.name}`, sym) | |
| } | |
| if (!this.graph.fileIndex.has(file)) { | |
| this.graph.fileIndex.set(file, []) | |
| } | |
| this.graph.fileIndex.get(file)!.push(...cached.symbols) | |
| this.graph.edges.push(...cached.edges) | |
| } else { | |
| uncachedFiles.push(file) | |
| } | |
| } | |
| if (uncachedFiles.length === 0) { | |
| console.log( | |
| `[GraphCache] TS: All ${files.length} files served from cache` | |
| ) | |
| return | |
| } | |
| console.log( | |
| `[GraphCache] TS: ${uncachedFiles.length}/${files.length} files need parsing` | |
| ) | |
| for (const file of uncachedFiles) { | |
| try { | |
| const content = await readFile(file, 'utf8') | |
| await this.analyzeTypeScriptFile(file, content) | |
| } catch (e) { | |
| this.analyzeWithTsMorph(file) | |
| } | |
| } | |
| } | |
| private async processGoFilesWithCache(files: string[]): Promise<void> { | |
| const uncachedFiles: string[] = [] | |
| for (const file of files) { | |
| const cached = this.graphCache?.get(file) | |
| if (cached) { | |
| for (const sym of cached.symbols) { | |
| this.graph.symbols.set(`${file}:${sym.name}`, sym) | |
| } | |
| if (!this.graph.fileIndex.has(file)) { | |
| this.graph.fileIndex.set(file, []) | |
| } | |
| this.graph.fileIndex.get(file)!.push(...cached.symbols) | |
| this.graph.edges.push(...cached.edges) | |
| } else { | |
| uncachedFiles.push(file) | |
| } | |
| } | |
| if (uncachedFiles.length === 0) { | |
| console.log( | |
| `[GraphCache] Go: All ${files.length} files served from cache` | |
| ) | |
| return | |
| } | |
| console.log( | |
| `[GraphCache] Go: ${uncachedFiles.length}/${files.length} files need parsing` | |
| ) | |
| for (const file of uncachedFiles) { | |
| try { | |
| const content = await readFile(file, 'utf8') | |
| await this.analyzeGoFile(file, content) | |
| } catch (e) { | |
| // Skip files that can't be read | |
| } | |
| } | |
| } | |
| private async processPythonFilesWithCache(files: string[]): Promise<void> { | |
| const uncachedFiles: string[] = [] | |
| for (const file of files) { | |
| const cached = this.graphCache?.get(file) | |
| if (cached) { | |
| for (const sym of cached.symbols) { | |
| this.graph.symbols.set(`${file}:${sym.name}`, sym) | |
| } | |
| if (!this.graph.fileIndex.has(file)) { | |
| this.graph.fileIndex.set(file, []) | |
| } | |
| this.graph.fileIndex.get(file)!.push(...cached.symbols) | |
| this.graph.edges.push(...cached.edges) | |
| } else { | |
| uncachedFiles.push(file) | |
| } | |
| } | |
| if (uncachedFiles.length === 0) { | |
| console.log( | |
| `[GraphCache] Python: All ${files.length} files served from cache` | |
| ) | |
| return | |
| } | |
| console.log( | |
| `[GraphCache] Python: ${uncachedFiles.length}/${files.length} files need parsing` | |
| ) | |
| for (const file of uncachedFiles) { | |
| try { | |
| const content = await readFile(file, 'utf8') | |
| await this.analyzePythonFile(file, content) | |
| } catch (e) { | |
| // Skip files that can't be read | |
| } | |
| } | |
| } | |
| private async analyzeRustFile(file: string, content: string): Promise<void> { | |
| try { | |
| const lang = await this.getLanguage(file) | |
| if (!lang) return | |
| const parser = new Parser() | |
| parser.setLanguage(lang) | |
| const tree = parser.parse(content) | |
| const symbols: Symbol[] = [] | |
| const fnQuery = lang.query(` | |
| (function_item name: (identifier) @name) @func | |
| (struct_item name: (type_identifier) @name) @struct | |
| (enum_item name: (type_identifier) @name) @enum | |
| (trait_item name: (type_identifier) @name) @trait | |
| `) | |
| for (const match of fnQuery.captures(tree.rootNode)) { | |
| const name = match.node.text | |
| let type: Symbol['type'] = 'function' | |
| if (match.name === 'struct') type = 'class' | |
| if (match.name === 'enum') type = 'type' | |
| if (match.name === 'trait') type = 'interface' | |
| symbols.push({ | |
| name, | |
| type, | |
| file, | |
| line: match.node.startPosition.row, | |
| exports: [], | |
| imports: [], | |
| references: [] | |
| }) | |
| } | |
| for (const sym of symbols) { | |
| this.graph.symbols.set(`${file}:${sym.name}`, sym) | |
| } | |
| if (!this.graph.fileIndex.has(file)) this.graph.fileIndex.set(file, []) | |
| this.graph.fileIndex.get(file)!.push(...symbols) | |
| if (this.graphCache) { | |
| const {statSync} = require('fs') | |
| const stats = statSync(file) | |
| this.graphCache.set(file, { | |
| mtime: stats.mtimeMs, | |
| size: stats.size, | |
| contentHash: computeContentHash(content), | |
| symbols, | |
| edges: [], | |
| parsedAt: Date.now() | |
| }) | |
| } | |
| } catch {} | |
| } | |
| private async analyzeCppJavaFile( | |
| file: string, | |
| content: string | |
| ): Promise<void> { | |
| try { | |
| const lang = await this.getLanguage(file) | |
| if (!lang) return | |
| const parser = new Parser() | |
| parser.setLanguage(lang) | |
| const tree = parser.parse(content) | |
| const symbols: Symbol[] = [] | |
| const query = lang.query(` | |
| (class_declaration name: (identifier) @name) @class | |
| (method_declaration name: (identifier) @name) @method | |
| (function_definition declarator: (function_declarator declarator: (identifier) @name)) @func | |
| `) | |
| for (const match of query.captures(tree.rootNode)) { | |
| const name = match.node.text | |
| let type: Symbol['type'] = 'variable' | |
| if (match.name === 'class') type = 'class' | |
| if (match.name === 'method') type = 'method' | |
| if (match.name === 'func') type = 'function' | |
| symbols.push({ | |
| name, | |
| type, | |
| file, | |
| line: match.node.startPosition.row, | |
| exports: [], | |
| imports: [], | |
| references: [] | |
| }) | |
| } | |
| for (const sym of symbols) { | |
| this.graph.symbols.set(`${file}:${sym.name}`, sym) | |
| } | |
| if (!this.graph.fileIndex.has(file)) this.graph.fileIndex.set(file, []) | |
| this.graph.fileIndex.get(file)!.push(...symbols) | |
| if (this.graphCache) { | |
| const {statSync} = require('fs') | |
| const stats = statSync(file) | |
| this.graphCache.set(file, { | |
| mtime: stats.mtimeMs, | |
| size: stats.size, | |
| contentHash: computeContentHash(content), | |
| symbols, | |
| edges: [], | |
| parsedAt: Date.now() | |
| }) | |
| } | |
| } catch {} | |
| } | |
| private async processRustFilesWithCache(files: string[]): Promise<void> { | |
| const uncached: string[] = [] | |
| for (const file of files) { | |
| const cached = this.graphCache?.get(file) | |
| if (cached) { | |
| for (const sym of cached.symbols) | |
| this.graph.symbols.set(`${file}:${sym.name}`, sym) | |
| if (!this.graph.fileIndex.has(file)) this.graph.fileIndex.set(file, []) | |
| this.graph.fileIndex.get(file)!.push(...cached.symbols) | |
| } else { | |
| uncached.push(file) | |
| } | |
| } | |
| for (const file of uncached) { | |
| try { | |
| const content = await readFile(file, 'utf8') | |
| await this.analyzeRustFile(file, content) | |
| } catch {} | |
| } | |
| } | |
| private async processCppJavaFilesWithCache(files: string[]): Promise<void> { | |
| const uncached: string[] = [] | |
| for (const file of files) { | |
| const cached = this.graphCache?.get(file) | |
| if (cached) { | |
| for (const sym of cached.symbols) | |
| this.graph.symbols.set(`${file}:${sym.name}`, sym) | |
| if (!this.graph.fileIndex.has(file)) this.graph.fileIndex.set(file, []) | |
| this.graph.fileIndex.get(file)!.push(...cached.symbols) | |
| } else { | |
| uncached.push(file) | |
| } | |
| } | |
| for (const file of uncached) { | |
| try { | |
| const content = await readFile(file, 'utf8') | |
| await this.analyzeCppJavaFile(file, content) | |
| } catch {} | |
| } | |
| } | |
| private async analyzeTypeScriptFile( | |
| file: string, | |
| content: string | |
| ): Promise<void> { | |
| const lang = await this.getLanguage(file) | |
| if (!lang) return | |
| const parser = new Parser() | |
| parser.setLanguage(lang) | |
| try { | |
| const tree = parser.parse(content) | |
| const symbols = this.extractTSSymbols(file, tree, lang) | |
| const edges = this.extractTSEdges(file, tree, lang, symbols) | |
| for (const sym of symbols) { | |
| this.graph.symbols.set(`${file}:${sym.name}`, sym) | |
| if (!this.graph.fileIndex.has(file)) { | |
| this.graph.fileIndex.set(file, []) | |
| } | |
| this.graph.fileIndex.get(file)!.push(sym) | |
| } | |
| this.graph.edges.push(...edges) | |
| if (this.graphCache) { | |
| try { | |
| const {statSync} = require('fs') | |
| const stats = statSync(file) | |
| this.graphCache.set(file, { | |
| mtime: stats.mtimeMs, | |
| size: stats.size, | |
| contentHash: computeContentHash(content), | |
| symbols, | |
| edges, | |
| parsedAt: Date.now() | |
| }) | |
| } catch {} | |
| } | |
| } catch (e) { | |
| this.analyzeWithTsMorph(file) | |
| } | |
| } | |
| private extractTSEdges( | |
| file: string, | |
| tree: Tree, | |
| lang: any, | |
| symbols: Symbol[] | |
| ): DependencyEdge[] { | |
| const edges: DependencyEdge[] = [] | |
| for (const sym of symbols) { | |
| for (const imp of sym.imports) { | |
| edges.push({ | |
| from: file, | |
| to: imp, | |
| type: 'imports' | |
| }) | |
| } | |
| } | |
| return edges | |
| } | |
| private extractTSSymbols(file: string, tree: Tree, lang: any): Symbol[] { | |
| const symbols: Symbol[] = [] | |
| const query = lang.query(` | |
| (function_declaration | |
| name: (identifier) @name | |
| parameters: (parameter_list) @params) @func | |
| (class_declaration | |
| name: (identifier) @name | |
| heritage: (class_heritage)?) @class | |
| (interface_declaration | |
| name: (type_identifier) @name) @iface | |
| (type_alias_declaration | |
| name: (type_identifier) @name) @type | |
| (variable_declaration | |
| name: (identifier) @name | |
| value: (assignment_expression) @val) @var | |
| (method_definition | |
| name: (property_identifier) @name) @method | |
| `) | |
| const queryMatches = query.captures(tree.rootNode) | |
| for (const match of queryMatches) { | |
| const name = match.node.text | |
| const symbol: Symbol = { | |
| name, | |
| type: this.inferSymbolType(match.name), | |
| file, | |
| line: match.node.startPosition.row, | |
| exports: [], | |
| imports: [], | |
| references: [] | |
| } | |
| symbols.push(symbol) | |
| } | |
| return symbols | |
| } | |
| private inferSymbolType(captureName: string): Symbol['type'] { | |
| switch (captureName) { | |
| case 'func': | |
| return 'function' | |
| case 'class': | |
| return 'class' | |
| case 'iface': | |
| return 'interface' | |
| case 'type': | |
| return 'type' | |
| case 'var': | |
| return 'variable' | |
| case 'method': | |
| return 'method' | |
| default: | |
| return 'variable' | |
| } | |
| } | |
| private analyzeWithTsMorph(file: string): void { | |
| try { | |
| const sourceFile = this.project.addSourceFileAtPath(file) | |
| sourceFile.getExportedDeclarations().forEach((declarations, name) => { | |
| const decl = declarations[0] | |
| if (!decl) return | |
| const symbol: Symbol = { | |
| name, | |
| type: this.getTsMorphSymbolType(decl.getKind()), | |
| file, | |
| line: decl.getStartLineNumber(), | |
| exports: [name], | |
| imports: this.extractImports(sourceFile), | |
| references: [] | |
| } | |
| this.graph.symbols.set(`${file}:${name}`, symbol) | |
| if (!this.graph.fileIndex.has(file)) { | |
| this.graph.fileIndex.set(file, []) | |
| } | |
| this.graph.fileIndex.get(file)!.push(symbol) | |
| }) | |
| } catch (e) { | |
| // Skip files that can't be parsed | |
| } | |
| } | |
| private getTsMorphSymbolType(kind: SyntaxKind): Symbol['type'] { | |
| const map: Record<number, Symbol['type']> = { | |
| [SyntaxKind.FunctionDeclaration]: 'function', | |
| [SyntaxKind.ClassDeclaration]: 'class', | |
| [SyntaxKind.InterfaceDeclaration]: 'interface', | |
| [SyntaxKind.TypeAliasDeclaration]: 'type', | |
| [SyntaxKind.VariableDeclaration]: 'const', | |
| [SyntaxKind.MethodDeclaration]: 'method', | |
| [SyntaxKind.PropertyDeclaration]: 'variable' | |
| } | |
| return map[kind] || 'variable' | |
| } | |
| private extractImports(sourceFile: any): string[] { | |
| const imports: string[] = [] | |
| try { | |
| const importDefs = sourceFile.getImportDeclarations() | |
| for (const imp of importDefs) { | |
| const moduleName = imp.getModuleSpecifier().getText() | |
| imports.push(moduleName.replace(/['"]/g, '')) | |
| } | |
| } catch (e) {} | |
| return imports | |
| } | |
| private async extractGoSymbolsTreeSitter( | |
| file: string, | |
| content: string | |
| ): Promise<Symbol[]> { | |
| const symbols: Symbol[] = [] | |
| try { | |
| const lang = await this.getLanguage(file) | |
| if (!lang) return this.extractGoSymbolsRegex(content, file) | |
| const parser = new Parser() | |
| parser.setLanguage(lang) | |
| const tree = parser.parse(content) | |
| const funcQuery = lang.query(` | |
| (function_declaration | |
| name: (identifier) @funcName) @func | |
| (method_declaration | |
| receiver: (parameter_list) | |
| name: (field_identifier) @methodName) @method | |
| (type_declaration | |
| name: (type_identifier) @typeName) @type | |
| `) | |
| for (const match of funcQuery.captures(tree.rootNode)) { | |
| const name = match.node.text | |
| const line = match.node.startPosition.row + 1 | |
| if (match.name === 'funcName') { | |
| const isExported = name[0] === name[0].toUpperCase() | |
| symbols.push({ | |
| name, | |
| type: 'function', | |
| file, | |
| line, | |
| exports: isExported ? [name] : [], | |
| imports: this.extractGoImports(content), | |
| references: [] | |
| }) | |
| } else if (match.name === 'methodName') { | |
| const isExported = name[0] === name[0].toUpperCase() | |
| symbols.push({ | |
| name, | |
| type: 'method', | |
| file, | |
| line, | |
| exports: isExported ? [name] : [], | |
| imports: this.extractGoImports(content), | |
| references: [] | |
| }) | |
| } else if (match.name === 'typeName') { | |
| symbols.push({ | |
| name, | |
| type: 'class', | |
| file, | |
| line, | |
| exports: [name], | |
| imports: [], | |
| references: [] | |
| }) | |
| } | |
| } | |
| } catch (e) { | |
| return this.extractGoSymbolsRegex(content, file) | |
| } | |
| return symbols | |
| } | |
| private extractGoSymbolsRegex(content: string, file: string): Symbol[] { | |
| const symbols: Symbol[] = [] | |
| const lines = content.split('\n') | |
| for (let i = 0; i < lines.length; i++) { | |
| const line = lines[i] | |
| let match = line.match(/^func\s+(\([^*]+\)\s+)?(\*)?([A-Z][a-zA-Z0-9_]*)/) | |
| if (match) { | |
| symbols.push({ | |
| name: match[3], | |
| type: match[2] ? 'method' : 'function', | |
| file, | |
| line: i + 1, | |
| exports: match[3][0] === match[3][0].toUpperCase() ? [match[3]] : [], | |
| imports: this.extractGoImports(content), | |
| references: [] | |
| }) | |
| } | |
| match = line.match(/^type\s+([A-Z][a-zA-Z0-9_]*)\s+(struct|interface)/) | |
| if (match) { | |
| symbols.push({ | |
| name: match[1], | |
| type: match[2] === 'struct' ? 'class' : 'interface', | |
| file, | |
| line: i + 1, | |
| exports: [match[1]], | |
| imports: [], | |
| references: [] | |
| }) | |
| } | |
| } | |
| return symbols | |
| } | |
| private async extractGoSymbols( | |
| file: string, | |
| content: string | |
| ): Promise<Symbol[]> { | |
| return this.extractGoSymbolsTreeSitter(file, content) | |
| } | |
| private extractGoImports(content: string): string[] { | |
| const imports: string[] = [] | |
| const importMatch = content.match(/import\s+\(([\s\S]*?)\)/) | |
| if (importMatch) { | |
| const importBlock = importMatch[1] | |
| const quoteMatches = importBlock.match(/"([^"]+)"/g) | |
| if (quoteMatches) { | |
| for (const m of quoteMatches) { | |
| imports.push(m.replace(/"/g, '')) | |
| } | |
| } | |
| } | |
| return imports | |
| } | |
| private async analyzeGoFile(file: string, content: string): Promise<void> { | |
| const symbols = await this.extractGoSymbols(file, content) | |
| for (const sym of symbols) { | |
| this.graph.symbols.set(`${file}:${sym.name}`, sym) | |
| if (!this.graph.fileIndex.has(file)) this.graph.fileIndex.set(file, []) | |
| this.graph.fileIndex.get(file)!.push(sym) | |
| } | |
| } | |
| private async analyzePythonFile( | |
| file: string, | |
| content: string | |
| ): Promise<void> { | |
| const symbols = this.extractPythonSymbols(file, content) | |
| for (const sym of symbols) { | |
| this.graph.symbols.set(`${file}:${sym.name}`, sym) | |
| if (!this.graph.fileIndex.has(file)) this.graph.fileIndex.set(file, []) | |
| this.graph.fileIndex.get(file)!.push(sym) | |
| } | |
| } | |
| private extractPythonSymbols(file: string, content: string): Symbol[] { | |
| const symbols: Symbol[] = [] | |
| const lines = content.split('\n') | |
| for (let i = 0; i < lines.length; i++) { | |
| const line = lines[i] | |
| let match = line.match(/^class\s+([A-Z][a-zA-Z0-9_]*)/) | |
| if (match) { | |
| symbols.push({ | |
| name: match[1], | |
| type: 'class', | |
| file, | |
| line: i + 1, | |
| exports: [match[1]], | |
| imports: this.extractPythonImports(content), | |
| references: [] | |
| }) | |
| } | |
| match = line.match(/^def\s+([a-z_][a-zA-Z0-9_]*)/) | |
| if (match) { | |
| symbols.push({ | |
| name: match[1], | |
| type: 'function', | |
| file, | |
| line: i + 1, | |
| exports: [], | |
| imports: [], | |
| references: [] | |
| }) | |
| } | |
| match = line.match(/^([A-Z_][A-Z0-9_]*)\s*=/) | |
| if (match) { | |
| symbols.push({ | |
| name: match[1], | |
| type: 'const', | |
| file, | |
| line: i + 1, | |
| exports: [match[1]], | |
| imports: [], | |
| references: [] | |
| }) | |
| } | |
| } | |
| return symbols | |
| } | |
| private extractPythonImports(content: string): string[] { | |
| const imports: string[] = [] | |
| const importRegex = /^(?:from\s+([.\w]+)\s+)?import\s+([.\w, ]+)/gm | |
| let match | |
| while ((match = importRegex.exec(content)) !== null) { | |
| const module = match[1] || match[2] | |
| imports.push(module.trim()) | |
| } | |
| return imports | |
| } | |
| private async getLanguage(file: string): Promise<any> { | |
| const ext = file.substring(file.lastIndexOf('.')) | |
| // LRU cache handles TTL automatically - no manual eviction needed | |
| if (this.languageCache.has(ext)) { | |
| const cached = this.languageCache.get(ext)! | |
| logger.debug({ ext }, 'Language cache hit') | |
| return cached.language | |
| } | |
| const loader = LANGUAGE_LOADERS[ext] | |
| if (loader) { | |
| try { | |
| const lang = await loader() | |
| if (lang) { | |
| // LRU cache handles size limits automatically | |
| this.languageCache.set(ext, {language: lang}) | |
| logger.info({ ext, cacheSize: this.languageCache.size }, 'Language loaded and cached') | |
| return lang | |
| } else { | |
| logger.warn({ ext }, 'Language loader returned null (wasm might be missing)') | |
| return null | |
| } | |
| } catch (e) { | |
| logger.error({ ext, error: e }, 'Error loading parser') | |
| return null | |
| } | |
| } | |
| return null | |
| } | |
| private buildDependencyEdges(): void { | |
| for (const [key, sym] of this.graph.symbols) { | |
| for (const imported of sym.imports) { | |
| for (const [otherKey, otherSym] of this.graph.symbols) { | |
| if ( | |
| otherSym.file.includes(imported) || | |
| imported.includes(otherSym.name) | |
| ) { | |
| this.graph.edges.push({ | |
| from: key, | |
| to: otherKey, | |
| type: 'imports' | |
| }) | |
| } | |
| } | |
| } | |
| } | |
| } | |
| private buildRepoContext(): RepoContext { | |
| const typeHierarchy = new Map<string, string[]>() | |
| const callGraph = new Map<string, string[]>() | |
| for (const [key, sym] of this.graph.symbols) { | |
| if (sym.type === 'class' || sym.type === 'interface') { | |
| const children = this.findTypeChildren(sym.name) | |
| typeHierarchy.set(key, children) | |
| } | |
| if (sym.type === 'function' || sym.type === 'method') { | |
| const callers = this.findFunctionCallers(sym.name) | |
| callGraph.set(key, callers) | |
| } | |
| } | |
| const architecturalLayers = this.inferArchitecturalLayers() | |
| return { | |
| symbolGraph: this.graph, | |
| typeHierarchy, | |
| callGraph, | |
| architecturalLayers | |
| } | |
| } | |
| private findTypeChildren(typeName: string): string[] { | |
| const children: string[] = [] | |
| for (const [, sym] of this.graph.symbols) { | |
| if (sym.type === 'class' && sym.exports.includes(typeName)) { | |
| children.push(`${sym.file}:${sym.name}`) | |
| } | |
| } | |
| return children | |
| } | |
| private findFunctionCallers(funcName: string): string[] { | |
| const callers: string[] = [] | |
| for (const [key, sym] of this.graph.symbols) { | |
| if (sym.references.includes(funcName)) { | |
| callers.push(key) | |
| } | |
| } | |
| return callers | |
| } | |
| private inferFileLayer(file: string): string | null { | |
| const lowerFile = file.toLowerCase() | |
| const patterns: Array<{layer: string; patterns: string[]}> = [ | |
| { | |
| layer: 'controller', | |
| patterns: ['controller', 'handler', 'route', 'endpoint', 'api'] | |
| }, | |
| {layer: 'service', patterns: ['service', 'business', 'manager']}, | |
| { | |
| layer: 'repository', | |
| patterns: ['repository', 'repo', 'dal', 'dataaccess'] | |
| }, | |
| {layer: 'model', patterns: ['model', 'entity', 'domain', 'schema']}, | |
| {layer: 'util', patterns: ['util', 'helper', 'common', 'shared']} | |
| ] | |
| for (const {layer, patterns: layerPatterns} of patterns) { | |
| if (layerPatterns.some(p => lowerFile.includes(p))) { | |
| return layer | |
| } | |
| } | |
| return null | |
| } | |
| private isAllowedCrossing( | |
| fromLayer: string, | |
| toLayer: string, | |
| allowedCrossings: Record<string, string[]> | |
| ): boolean { | |
| const allowed = allowedCrossings[fromLayer] | |
| return allowed ? allowed.includes(toLayer) : false | |
| } | |
| private calculateViolationSeverity( | |
| fromLayer: string, | |
| toLayer: string | |
| ): 'critical' | 'major' | 'minor' { | |
| if (fromLayer === 'model' || toLayer === 'model') { | |
| return fromLayer === 'model' ? 'critical' : 'major' | |
| } | |
| if ( | |
| fromLayer === 'controller' && | |
| (toLayer === 'repository' || toLayer === 'model') | |
| ) { | |
| return 'major' | |
| } | |
| return 'minor' | |
| } | |
| inferArchitecturalLayers(): Map<string, string> { | |
| const layers = new Map<string, string>() | |
| const layerPatterns = [ | |
| { | |
| layer: 'controller', | |
| patterns: ['controller', 'handler', 'route', 'endpoint', 'api'] | |
| }, | |
| {layer: 'service', patterns: ['service', 'business', 'logic', 'manager']}, | |
| { | |
| layer: 'repository', | |
| patterns: ['repository', 'repo', 'dal', 'dataaccess'] | |
| }, | |
| {layer: 'model', patterns: ['model', 'entity', 'domain', 'schema']}, | |
| {layer: 'util', patterns: ['util', 'helper', 'common', 'shared']} | |
| ] | |
| for (const [file] of this.graph.fileIndex) { | |
| const lowerFile = file.toLowerCase() | |
| for (const {layer, patterns} of layerPatterns) { | |
| if (patterns.some(p => lowerFile.includes(p))) { | |
| for (const sym of this.graph.fileIndex.get(file) || []) { | |
| layers.set(`${file}:${sym.name}`, layer) | |
| } | |
| break | |
| } | |
| } | |
| } | |
| return layers | |
| } | |
| detectArchitecturalViolations( | |
| changedFiles: string[], | |
| architecturalLayers: Map<string, string> | |
| ): ArchitecturalViolation[] { | |
| const violations: ArchitecturalViolation[] = [] | |
| const layerHierarchy: Record<string, number> = { | |
| controller: 1, | |
| handler: 1, | |
| service: 2, | |
| business: 2, | |
| repository: 3, | |
| repo: 3, | |
| dal: 3, | |
| model: 4, | |
| entity: 4, | |
| domain: 4, | |
| util: 5, | |
| helper: 5 | |
| } | |
| const allowedCrossings: Record<string, string[]> = { | |
| controller: ['service', 'handler'], | |
| handler: ['service'], | |
| service: ['repository', 'model', 'util'], | |
| repository: ['model', 'dal'] | |
| } | |
| for (const file of changedFiles) { | |
| const fileLayer = this.inferFileLayer(file) | |
| if (!fileLayer) continue | |
| const fileDeps = this.getFileDependencies(file) | |
| const fileLayerRank = layerHierarchy[fileLayer] || 0 | |
| for (const dep of fileDeps) { | |
| const depFile = dep.split(':')[0] | |
| const depLayer = this.inferFileLayer(depFile) | |
| if (!depLayer) continue | |
| const depRank = layerHierarchy[depLayer] || 0 | |
| if ( | |
| fileLayerRank > depRank && | |
| !this.isAllowedCrossing(fileLayer, depLayer, allowedCrossings) | |
| ) { | |
| violations.push({ | |
| from: file, | |
| to: depFile, | |
| fromLayer: fileLayer, | |
| toLayer: depLayer, | |
| severity: this.calculateViolationSeverity(fileLayer, depLayer), | |
| description: `Layer violation: \`${fileLayer}\` should not depend on \`${depLayer}\`` | |
| }) | |
| } | |
| } | |
| } | |
| return violations | |
| } | |
| querySymbol(symbolName: string): Symbol | null { | |
| for (const [, sym] of this.graph.symbols) { | |
| if (sym.name === symbolName) { | |
| return sym | |
| } | |
| } | |
| return null | |
| } | |
| getFileDependencies(file: string): string[] { | |
| const deps: string[] = [] | |
| for (const edge of this.graph.edges) { | |
| if (edge.from.startsWith(file)) { | |
| deps.push(edge.to) | |
| } | |
| } | |
| return deps | |
| } | |
| getDownstreamImpact(symbolKey: string): string[] { | |
| const impacted: string[] = [] | |
| const maxDepth = 2 | |
| for (const edge of this.graph.edges) { | |
| if (edge.to === symbolKey) { | |
| impacted.push(edge.from) | |
| } | |
| } | |
| const transitive = new Set(impacted) | |
| const queue: Array<{key: string; depth: number}> = impacted.map(k => ({ | |
| key: k, | |
| depth: 1 | |
| })) | |
| while (queue.length > 0) { | |
| const {key: current, depth} = queue.shift()! | |
| if (depth >= maxDepth) continue | |
| for (const edge of this.graph.edges) { | |
| if (edge.to === current && !transitive.has(edge.from)) { | |
| transitive.add(edge.from) | |
| queue.push({key: edge.from, depth: depth + 1}) | |
| } | |
| } | |
| } | |
| return Array.from(transitive) | |
| } | |
| getContextForSymbol(symbolKey: string): string { | |
| const sym = this.graph.symbols.get(symbolKey) | |
| if (!sym) return '' | |
| const layer = this.inferFileLayer(sym.file) || 'unknown' | |
| const deps = this.getFileDependencies(sym.file) | |
| const downstream = this.getDownstreamImpact(symbolKey) | |
| return ` | |
| Symbol: ${sym.name} | |
| Type: ${sym.type} | |
| File: ${sym.file}:${sym.line} | |
| Layer: ${layer} | |
| Dependencies: ${deps.length} | |
| Downstream Impact: ${downstream.length} files | |
| `.trim() | |
| } | |
| getRemedyContext( | |
| filename: string, | |
| lineContext: number, | |
| codeSnippet: string, | |
| maxSymbols: number = 15 | |
| ): string { | |
| const symbolsInFile = this.graph.fileIndex.get(filename) || [] | |
| const fileSymbols = symbolsInFile.slice(0, maxSymbols) | |
| let context = `=== Symbols in ${filename} ===\n` | |
| for (const sym of fileSymbols) { | |
| context += `[${sym.type}] ${sym.name} (line ${sym.line})\n` | |
| } | |
| const nearbySymbols = symbolsInFile.filter( | |
| s => Math.abs(s.line - lineContext) <= 30 | |
| ) | |
| if (nearbySymbols.length > 0) { | |
| context += `\n=== Symbols near line ${lineContext} ===\n` | |
| for (const sym of nearbySymbols.slice(0, 5)) { | |
| context += `[${sym.type}] ${sym.name} (line ${sym.line})\n` | |
| } | |
| } | |
| const deps = this.getFileDependencies(filename) | |
| if (deps.length > 0) { | |
| context += `\n=== Dependencies ===\n` | |
| for (const dep of deps.slice(0, 10)) { | |
| const depFile = dep.split(':')[0] | |
| const depSyms = this.graph.fileIndex.get(depFile) || [] | |
| context += `${depFile}: ${depSyms.map(s => s.name).join(', ')}\n` | |
| } | |
| } | |
| return context | |
| } | |
| generateVisualImpactMap(changedFiles: string[]): string { | |
| let mermaid = 'graph TD\n' | |
| const edges = new Set<string>() | |
| const nodes = new Set<string>() | |
| const getBasename = (path: string) => path.split('/').pop() || path | |
| for (const file of changedFiles) { | |
| const layer = this.inferFileLayer(file) || 'Unknown' | |
| const baseName = getBasename(file) | |
| nodes.add(`file_${baseName.replace(/[^a-zA-Z0-9]/g, '_')}["${baseName} (${layer})"]`) | |
| const deps = this.getFileDependencies(file) | |
| for (const dep of deps) { | |
| const depFile = dep.split(':')[0] | |
| const depLayer = this.inferFileLayer(depFile) || 'Unknown' | |
| const depBaseName = getBasename(depFile) | |
| const fromId = `file_${baseName.replace(/[^a-zA-Z0-9]/g, '_')}` | |
| const toId = `file_${depBaseName.replace(/[^a-zA-Z0-9]/g, '_')}` | |
| nodes.add(`${toId}["${depBaseName} (${depLayer})"]`) | |
| edges.add(`${fromId} --> ${toId}`) | |
| } | |
| // Also show who depends ON the changed files (downstream impact) | |
| for (const edge of this.graph.edges) { | |
| if (edge.to.startsWith(file)) { | |
| const fromFile = edge.from.split(':')[0] | |
| const fromLayer = this.inferFileLayer(fromFile) || 'Unknown' | |
| const fromBaseName = getBasename(fromFile) | |
| const fromId = `file_${fromBaseName.replace(/[^a-zA-Z0-9]/g, '_')}` | |
| const toId = `file_${baseName.replace(/[^a-zA-Z0-9]/g, '_')}` | |
| nodes.add(`${fromId}["${fromBaseName} (${fromLayer})"]`) | |
| edges.add(`${fromId} -- impacts --> ${toId}`) | |
| } | |
| } | |
| } | |
| if (nodes.size === 0) return '' | |
| mermaid += Array.from(nodes).map(n => ` ${n}`).join('\n') + '\n' | |
| mermaid += Array.from(edges).map(e => ` ${e}`).join('\n') | |
| return `\`\`\`mermaid\n${mermaid}\n\`\`\`` | |
| } | |
| } | |
| export const unifiedContextEngine = new UnifiedContextEngine() | |