Files
gstack/lib/eval-cache.ts
Garry Tan 1f5b7882e6 feat: add SHA-based eval caching with EVAL_CACHE=0 bypass
Cache at ~/.gstack/eval-cache/{suite}/{sha}.json. Compute cache keys
from source file contents + test input via Bun.CryptoHasher SHA256.
Supports read/write/stats/clear/verify operations. EVAL_CACHE=0
skips reads for force-rerun. 16 tests including corrupt JSON handling.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 09:39:26 -05:00

214 lines
5.6 KiB
TypeScript

/**
* SHA-based eval caching.
*
* Cache path: ~/.gstack/eval-cache/{suite}/{sha}.json
*
* Caches eval results keyed by a SHA256 hash of source files + test input.
* Supports EVAL_CACHE=0 to skip reads (always re-run).
*/
import * as fs from 'fs';
import * as path from 'path';
import { atomicWriteJSON, readJSON } from './util';
const CACHE_VERSION = 1;
/** Resolve cache dir lazily so GSTACK_STATE_DIR env overrides work in tests. */
function getCacheDir(): string {
const stateDir = process.env.GSTACK_STATE_DIR || require('path').join(require('os').homedir(), '.gstack');
return path.join(stateDir, 'eval-cache');
}
// --- Cache key ---
/**
* Compute a cache key from source file contents + test input.
* Returns first 16 hex chars of SHA256.
*/
export function computeCacheKey(sourceFiles: string[], testInput: string): string {
const hasher = new Bun.CryptoHasher('sha256');
for (const file of sourceFiles.sort()) {
try {
hasher.update(fs.readFileSync(file));
} catch (err: any) {
throw new Error(`Cache key: cannot read source file "${file}": ${err.message}`);
}
}
hasher.update(testInput);
return hasher.digest('hex').slice(0, 16);
}
// --- Read / Write ---
function cachePath(suite: string, key: string): string {
return path.join(getCacheDir(), suite, `${key}.json`);
}
/**
* Read a cached value. Returns null on miss, corrupt data, or if EVAL_CACHE=0.
*/
export function cacheRead(suite: string, key: string): unknown | null {
if (process.env.EVAL_CACHE === '0') return null;
const filePath = cachePath(suite, key);
const envelope = readJSON<{ _cache_version: number; data: unknown }>(filePath);
if (!envelope || envelope._cache_version !== CACHE_VERSION) return null;
return envelope.data;
}
/**
* Write a value to cache. Atomic write with metadata envelope.
*/
export function cacheWrite(
suite: string,
key: string,
data: unknown,
meta?: Record<string, unknown>,
): void {
const filePath = cachePath(suite, key);
const envelope = {
_cache_version: CACHE_VERSION,
_cached_at: new Date().toISOString(),
_suite: suite,
...meta,
data,
};
try {
atomicWriteJSON(filePath, envelope);
} catch (err: any) {
throw new Error(`Cache write failed for "${filePath}": ${err.message}`);
}
}
// --- Management ---
interface SuiteStats {
name: string;
entries: number;
size_bytes: number;
oldest: string;
newest: string;
}
/**
* Get cache statistics. If suite is provided, stats for that suite only.
*/
export function cacheStats(suite?: string): { suites: SuiteStats[] } {
const suites: SuiteStats[] = [];
let dirNames: string[];
try {
dirNames = suite ? [suite] : fs.readdirSync(getCacheDir());
} catch {
return { suites: [] };
}
for (const name of dirNames) {
const suiteDir = path.join(getCacheDir(), name);
try {
const stat = fs.statSync(suiteDir);
if (!stat.isDirectory()) continue;
} catch { continue; }
let files: string[];
try {
files = fs.readdirSync(suiteDir).filter(f => f.endsWith('.json'));
} catch { continue; }
if (files.length === 0) {
suites.push({ name, entries: 0, size_bytes: 0, oldest: '', newest: '' });
continue;
}
let totalSize = 0;
let oldest = '';
let newest = '';
for (const file of files) {
try {
const fileStat = fs.statSync(path.join(suiteDir, file));
totalSize += fileStat.size;
const mtime = fileStat.mtime.toISOString();
if (!oldest || mtime < oldest) oldest = mtime;
if (!newest || mtime > newest) newest = mtime;
} catch { continue; }
}
suites.push({ name, entries: files.length, size_bytes: totalSize, oldest, newest });
}
return { suites };
}
/**
* Clear cache entries. If suite is provided, clears only that suite.
* Returns count of deleted files.
*/
export function cacheClear(suite?: string): { deleted: number } {
let deleted = 0;
let dirNames: string[];
try {
dirNames = suite ? [suite] : fs.readdirSync(getCacheDir());
} catch {
return { deleted: 0 };
}
for (const name of dirNames) {
const suiteDir = path.join(getCacheDir(), name);
try {
const files = fs.readdirSync(suiteDir).filter(f => f.endsWith('.json'));
for (const file of files) {
fs.unlinkSync(path.join(suiteDir, file));
deleted++;
}
// Remove empty directory
try { fs.rmdirSync(suiteDir); } catch { /* not empty or doesn't exist */ }
} catch { continue; }
}
return { deleted };
}
/**
* Verify cache integrity. Checks that all cache files are valid JSON
* with the correct cache version.
*/
export function cacheVerify(suite?: string): { valid: number; invalid: number; errors: string[] } {
let valid = 0;
let invalid = 0;
const errors: string[] = [];
let dirNames: string[];
try {
dirNames = suite ? [suite] : fs.readdirSync(getCacheDir());
} catch {
return { valid: 0, invalid: 0, errors: [] };
}
for (const name of dirNames) {
const suiteDir = path.join(getCacheDir(), name);
let files: string[];
try {
files = fs.readdirSync(suiteDir).filter(f => f.endsWith('.json'));
} catch { continue; }
for (const file of files) {
const filePath = path.join(suiteDir, file);
try {
const content = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
if (content._cache_version !== CACHE_VERSION) {
invalid++;
errors.push(`${name}/${file}: wrong cache version (${content._cache_version})`);
} else {
valid++;
}
} catch (err: any) {
invalid++;
errors.push(`${name}/${file}: ${err.message}`);
}
}
}
return { valid, invalid, errors };
}