Spaces:
Paused
Paused
| 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<string, FileCacheEntry> | |
| 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<string, FileCacheEntry> = 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<string> { | |
| const affectedFiles = new Set<string>() | |
| 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<string>): 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<string, FileCacheEntry>(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<string> | |
| } { | |
| 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<string, boolean> = 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<IncrementalUpdateResult> { | |
| 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<number> { | |
| 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) | |
| } | |