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