pi-lens
Version:
Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo
274 lines (273 loc) • 9.19 kB
JavaScript
/**
* CacheManager for pi-lens.
*
* Manages persistent cache for scanner results and turn state.
* Provides read/write/freshness checks for:
* - Scanner cache: .pi-lens/cache/{scanner}.json
* - Turn state: .pi-lens/turn-state.json
*
* All paths are relative to project root (process.cwd()).
*/
import * as fs from "node:fs";
import * as path from "node:path";
import { getProjectDataDir } from "./file-utils.js";
import { normalizeMapKey } from "./path-utils.js";
// --- Defaults ---
const DEFAULT_MAX_AGE_MS = 30 * 60 * 1000; // 30 minutes
const DEFAULT_TURN_STATE = {
files: {},
turnCycles: 0,
maxCycles: 3,
lastUpdated: "",
};
// --- Helpers ---
function getLensDir(cwd) {
return getProjectDataDir(cwd);
}
function getCacheDir(cwd) {
return path.join(getLensDir(cwd), "cache");
}
function getTurnStatePath(cwd) {
return path.join(getLensDir(cwd), "turn-state.json");
}
// --- Cache Manager ---
export class CacheManager {
log;
constructor(verbose = false) {
this.log = verbose
? (msg) => console.error(`[cache] ${msg}`)
: () => { };
}
/**
* Convert a file path to a stable turn-state key.
* Uses normalized absolute paths first, then stores cwd-relative keys when possible.
*/
toTurnStateKey(filePath, cwd) {
const cwdNorm = normalizeMapKey(path.resolve(cwd));
const fileNorm = normalizeMapKey(path.resolve(cwd, filePath));
const rel = path.relative(cwdNorm, fileNorm).replace(/\\/g, "/");
if (!rel || rel === ".")
return fileNorm;
if (rel === ".." || rel.startsWith("../"))
return fileNorm;
return rel;
}
/**
* Get turn-state entry for a file path using normalized lookup.
*/
getTurnFileState(filePath, cwd) {
const state = this.readTurnState(cwd);
const key = this.toTurnStateKey(filePath, cwd);
return state.files[key];
}
// ---- Scanner Cache ----
/**
* Read a scanner cache entry. Returns null if not found or stale.
*/
readCache(scanner, cwd, maxAgeMs = DEFAULT_MAX_AGE_MS) {
const cachePath = path.join(getCacheDir(cwd), `${scanner}.json`);
const metaPath = path.join(getCacheDir(cwd), `${scanner}.meta.json`);
if (!fs.existsSync(cachePath) || !fs.existsSync(metaPath)) {
this.log(`Cache miss: ${scanner} (files don't exist)`);
return null;
}
try {
const meta = JSON.parse(fs.readFileSync(metaPath, "utf-8"));
const age = Date.now() - new Date(meta.timestamp).getTime();
if (age > maxAgeMs) {
this.log(`Cache stale: ${scanner} (age: ${Math.round(age / 1000)}s, max: ${maxAgeMs / 1000}s)`);
return null;
}
const data = JSON.parse(fs.readFileSync(cachePath, "utf-8"));
this.log(`Cache hit: ${scanner} (age: ${Math.round(age / 1000)}s)`);
return { data, meta };
}
catch (err) {
this.log(`Cache read error: ${scanner} — ${err}`);
return null;
}
}
/**
* Write a scanner cache entry.
*/
writeCache(scanner, data, cwd, extraMeta) {
const cacheDir = getCacheDir(cwd);
fs.mkdirSync(cacheDir, { recursive: true });
const cachePath = path.join(cacheDir, `${scanner}.json`);
const metaPath = path.join(cacheDir, `${scanner}.meta.json`);
const meta = {
timestamp: new Date().toISOString(),
...extraMeta,
};
fs.writeFileSync(cachePath, JSON.stringify(data, null, 2));
fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2));
this.log(`Cache written: ${scanner}`);
}
/**
* Check if a cache entry is fresh (exists and not expired).
*/
isCacheFresh(scanner, cwd, maxAgeMs = DEFAULT_MAX_AGE_MS) {
const metaPath = path.join(getCacheDir(cwd), `${scanner}.meta.json`);
if (!fs.existsSync(metaPath))
return false;
try {
const meta = JSON.parse(fs.readFileSync(metaPath, "utf-8"));
const age = Date.now() - new Date(meta.timestamp).getTime();
return age <= maxAgeMs;
}
catch {
return false;
}
}
/**
* Clear a specific cache entry.
*/
clearCache(scanner, cwd) {
const cachePath = path.join(getCacheDir(cwd), `${scanner}.json`);
const metaPath = path.join(getCacheDir(cwd), `${scanner}.meta.json`);
for (const p of [cachePath, metaPath]) {
try {
fs.unlinkSync(p);
}
catch (err) {
// ENOENT: file doesn't exist, other errors logged
if (err.code !== "ENOENT") {
this.log(`Failed to delete ${p}: ${err}`);
}
}
}
}
// ---- Turn State ----
/**
* Read turn state. Returns default if not found.
*/
readTurnState(cwd) {
const statePath = getTurnStatePath(cwd);
if (!fs.existsSync(statePath)) {
return {
...DEFAULT_TURN_STATE,
files: {},
lastUpdated: new Date().toISOString(),
};
}
try {
return JSON.parse(fs.readFileSync(statePath, "utf-8"));
}
catch {
return {
...DEFAULT_TURN_STATE,
files: {},
lastUpdated: new Date().toISOString(),
};
}
}
/**
* Write turn state.
*/
writeTurnState(state, cwd) {
const lensDir = getLensDir(cwd);
fs.mkdirSync(lensDir, { recursive: true });
const statePath = getTurnStatePath(cwd);
state.lastUpdated = new Date().toISOString();
fs.writeFileSync(statePath, JSON.stringify(state, null, 2));
}
/**
* Add or update a file's modified ranges in turn state.
* Merges overlapping ranges.
*/
addModifiedRange(filePath, range, importsChanged, cwd, sessionId) {
const state = this.readTurnState(cwd);
if (sessionId)
state.sessionId = sessionId;
const normalizedPath = this.toTurnStateKey(filePath, cwd);
const existing = state.files[normalizedPath];
if (existing) {
// Merge ranges
existing.modifiedRanges = this.mergeRanges([
...existing.modifiedRanges,
range,
]);
existing.importsChanged = existing.importsChanged || importsChanged;
existing.lastEdit = new Date().toISOString();
}
else {
state.files[normalizedPath] = {
modifiedRanges: [range],
importsChanged,
lastEdit: new Date().toISOString(),
};
}
this.writeTurnState(state, cwd);
return state;
}
/**
* Clear turn state (after turn_end processes it).
*/
clearTurnState(cwd) {
const state = {
...DEFAULT_TURN_STATE,
files: {}, // fresh object — DEFAULT_TURN_STATE.files can be polluted by addModifiedRange
lastUpdated: new Date().toISOString(),
};
this.writeTurnState(state, cwd);
}
/**
* Increment turn cycle counter.
*/
incrementTurnCycle(cwd) {
const state = this.readTurnState(cwd);
state.turnCycles++;
this.writeTurnState(state, cwd);
return state;
}
/**
* Check if max cycles exceeded.
*/
isMaxCyclesExceeded(cwd) {
const state = this.readTurnState(cwd);
return state.turnCycles >= state.maxCycles;
}
/**
* Get files that need jscpd re-scan (any edit).
* Only returns source code files jscpd can meaningfully analyse.
*/
getFilesForJscpd(cwd) {
const state = this.readTurnState(cwd);
return Object.keys(state.files).filter((f) => /\.(ts|tsx|js|jsx|mjs|cjs|py|go|rs|rb|java|cs|php|cpp|c|h|hpp|swift|kt)$/.test(f));
}
/**
* Get files that need madge re-scan (imports changed).
*/
getFilesForMadge(cwd) {
const state = this.readTurnState(cwd);
return Object.entries(state.files)
.filter(([, f]) => f.importsChanged)
.map(([p]) => p);
}
// ---- Utilities ----
/**
* Merge overlapping or adjacent ranges.
*/
mergeRanges(ranges) {
if (ranges.length === 0)
return [];
const sorted = [...ranges].sort((a, b) => a.start - b.start);
const merged = [sorted[0]];
for (const current of sorted.slice(1)) {
const last = merged[merged.length - 1];
if (current.start <= last.end + 1) {
last.end = Math.max(last.end, current.end);
}
else {
merged.push({ ...current });
}
}
return merged;
}
/**
* Check if a line falls within any modified range.
*/
isLineInModifiedRange(line, ranges) {
return ranges.some((r) => r.start <= line && line <= r.end);
}
}