PRIX / src /symbol-graph.ts
Rachit-Tw's picture
Upload 169 files
9284ad7 verified
Raw
History Blame Contribute Delete
38.6 kB
/* 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()