import {readFileSync, writeFileSync, existsSync, statSync} from 'fs' import {join, dirname} from 'path' import {Symbol, DependencyEdge, SymbolGraph} from './symbol-graph' export interface FileCacheEntry { mtime: number size: number contentHash: string symbols: Symbol[] edges: DependencyEdge[] parsedAt: number } export interface GraphCache { version: string repoPath: string lastUpdated: number fileEntries: Map globalEdges: DependencyEdge[] } const CACHE_VERSION = '2.0.0' function getPersistBaseDir(): string { if (process.env.PRIX_DATA_DIR) return process.env.PRIX_DATA_DIR if (process.env.HF_HOME) return join(process.env.HF_HOME, 'prix-data') if (process.env.HUGGING_FACE_HUB_TOKEN) return '/tmp/prix-hf' return '/tmp/prix' } export class SymbolGraphCache { private cache: Map = new Map() private repoPath: string private persistPath: string private cacheHits = 0 private cacheMisses = 0 constructor(repoPath: string, persistDir?: string) { this.repoPath = repoPath const baseDir = persistDir || getPersistBaseDir() this.persistPath = join(baseDir, 'symbol-graph-cache') this.load() } get(file: string): FileCacheEntry | undefined { if (!existsSync(file)) return undefined try { const stats = statSync(file) const cached = this.cache.get(file) if (!cached) { this.cacheMisses++ return undefined } if (cached.mtime !== stats.mtimeMs || cached.size !== stats.size) { this.cacheMisses++ this.cache.delete(file) return undefined } this.cacheHits++ return cached } catch { return undefined } } set(file: string, entry: FileCacheEntry): void { this.cache.set(file, entry) } invalidate(file: string): void { this.cache.delete(file) } invalidateContaining(changedFiles: string[]): Set { const affectedFiles = new Set() const changedSet = new Set(changedFiles.map(f => this.normalizePath(f))) for (const cachedFile of this.cache.keys()) { const normalizedCached = this.normalizePath(cachedFile) if (changedSet.has(normalizedCached)) { affectedFiles.add(cachedFile) continue } const cached = this.cache.get(cachedFile) if (!cached) continue for (const edge of cached.edges) { const normalizedEdge = this.normalizePath(edge.to) if (changedSet.has(normalizedEdge)) { affectedFiles.add(cachedFile) break } const normalizedFrom = this.normalizePath(edge.from) if (changedSet.has(normalizedFrom)) { affectedFiles.add(cachedFile) break } } for (const symbol of cached.symbols) { for (const imp of symbol.imports) { const normalizedImp = this.normalizeImport(imp) if (this.pathMatchesImport(normalizedImp, changedSet)) { affectedFiles.add(cachedFile) break } } } } for (const affected of affectedFiles) { this.cache.delete(affected) } return affectedFiles } private normalizePath(p: string): string { return p.replace(/\\/g, '/').toLowerCase() } private normalizeImport(imp: string): string { return imp.replace(/['"]/g, '').replace(/\\/g, '/').toLowerCase() } private pathMatchesImport(imp: string, changedSet: Set): boolean { for (const changed of changedSet) { if (imp.endsWith(changed) || changed.endsWith(imp)) { return true } const impParts = imp.split('/') const changedParts = changed.split('/') if ( impParts[impParts.length - 1] === changedParts[changedParts.length - 1] ) { return true } } return false } getStats(): {hits: number; misses: number; size: number; hitRate: number} { const total = this.cacheHits + this.cacheMisses return { hits: this.cacheHits, misses: this.cacheMisses, size: this.cache.size, hitRate: total > 0 ? this.cacheHits / total : 0 } } prune(maxAge: number = 7 * 24 * 60 * 60 * 1000): number { const now = Date.now() let pruned = 0 for (const [file, entry] of this.cache.entries()) { if (now - entry.parsedAt > maxAge) { this.cache.delete(file) pruned++ } } return pruned } clear(): void { this.cache.clear() this.cacheHits = 0 this.cacheMisses = 0 } persist(): void { try { const {mkdirSync, writeFileSync, existsSync} = require('fs') if (!existsSync(this.persistPath)) { mkdirSync(this.persistPath, {recursive: true}) } const cacheData: any = { version: CACHE_VERSION, repoPath: this.repoPath, lastUpdated: Date.now(), entries: Array.from(this.cache.entries()) } const cacheFile = join( this.persistPath, `${this.sanitizeFilename(this.repoPath)}.json` ) writeFileSync(cacheFile, JSON.stringify(cacheData), 'utf8') } catch (e) { console.warn(`Failed to persist graph cache: ${e}`) } } private load(): void { try { const {readFileSync, existsSync} = require('fs') const cacheFile = join( this.persistPath, `${this.sanitizeFilename(this.repoPath)}.json` ) if (!existsSync(cacheFile)) return const data = JSON.parse(readFileSync(cacheFile, 'utf8')) if (data.version !== CACHE_VERSION) { console.log('Graph cache version mismatch, rebuilding...') return } if (data.repoPath !== this.repoPath) { console.log('Repo path changed, rebuilding...') return } const entries = new Map(data.entries) this.cache = entries } catch (e) { console.warn(`Failed to load graph cache: ${e}`) } } private sanitizeFilename(path: string): string { return path.replace(/[\/\\]/g, '_').substring(0, 100) } rebuildIfNeeded(changedFiles: string[]): { rebuild: boolean filesToRebuild: Set } { if (changedFiles.length === 0) { return {rebuild: false, filesToRebuild: new Set()} } const filesToRebuild = this.invalidateContaining(changedFiles) return { rebuild: filesToRebuild.size > 0 || this.cache.size === 0, filesToRebuild } } } export interface IncrementalUpdateResult { updatedFiles: string[] removedSymbols: number addedSymbols: number cacheStats: {hits: number; misses: number; hitRate: number} } export class IncrementalSymbolGraph { private cache: SymbolGraphCache private graph: SymbolGraph private pendingUpdates: Map = new Map() constructor(repoPath: string, persistDir?: string) { this.cache = new SymbolGraphCache(repoPath, persistDir) this.graph = { symbols: new Map(), edges: [], fileIndex: new Map() } } async update( changedFiles: string[], deletedFiles: string[] ): Promise { for (const file of deletedFiles) { this.removeFile(file) } const {filesToRebuild} = this.cache.rebuildIfNeeded(changedFiles) let removedSymbols = 0 let addedSymbols = 0 for (const file of filesToRebuild) { const removed = this.removeFile(file) removedSymbols += removed } for (const file of changedFiles) { if (existsSync(file)) { const added = await this.addOrUpdateFile(file) addedSymbols += added } } this.cache.persist() return { updatedFiles: [...filesToRebuild, ...changedFiles].filter(f => existsSync(f) ), removedSymbols, addedSymbols, cacheStats: this.cache.getStats() } } private removeFile(file: string): number { const cached = this.cache.get(file) if (cached) { for (const symbol of cached.symbols) { this.graph.symbols.delete(`${file}:${symbol.name}`) } this.graph.fileIndex.delete(file) this.graph.edges = this.graph.edges.filter( e => !e.from.startsWith(file) && !e.to.startsWith(file) ) this.cache.invalidate(file) return cached.symbols.length } return 0 } private async addOrUpdateFile(file: string): Promise { const existing = this.cache.get(file) if (existing) { for (const symbol of existing.symbols) { this.graph.symbols.set(`${file}:${symbol.name}`, symbol) } this.graph.fileIndex.set(file, existing.symbols) this.graph.edges.push(...existing.edges) return existing.symbols.length } return 0 } getGraph(): SymbolGraph { return this.graph } getCacheStats() { return this.cache.getStats() } clearCache(): void { this.cache.clear() this.graph = { symbols: new Map(), edges: [], fileIndex: new Map() } } persist(): void { this.cache.persist() } } export const createIncrementalGraph = ( repoPath: string, persistDir?: string ): IncrementalSymbolGraph => { return new IncrementalSymbolGraph(repoPath, persistDir) }