Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions src/cache/cache-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
import * as path from 'path';
import type { CacheData, CachedResult } from './types';
import { CACHE_SCHEMA } from '../schemas/cache-schema';

/**
* Cache schema version. Bump this to invalidate existing caches
* when the internal data structure changes in a future release.
*/
const CACHE_VERSION = 1;
const DEFAULT_CACHE_DIR = '.vectorlint';
const CACHE_FILENAME = 'cache.json';

/**
* Persistent cache store for evaluation results.
* Stores cache in .vectorlint/cache.json by default.
*/
export class CacheStore {
private readonly cacheDir: string;
private readonly cacheFile: string;
private data: CacheData;
private dirty: boolean = false;

constructor(cwd: string = process.cwd(), cacheDir: string = DEFAULT_CACHE_DIR) {
this.cacheDir = path.resolve(cwd, cacheDir);
this.cacheFile = path.join(this.cacheDir, CACHE_FILENAME);
this.data = this.load();
}

/**
* Load cache from disk or create empty cache.
*/
private load(): CacheData {
try {
if (existsSync(this.cacheFile)) {
const raw = readFileSync(this.cacheFile, 'utf-8');
const json: unknown = JSON.parse(raw);

const result = CACHE_SCHEMA.safeParse(json);

if (!result.success) {
console.warn(`[vectorlint] Cache validation failed, starting fresh: ${result.error.message}`);
return { version: CACHE_VERSION, entries: {} };
}

const parsed = result.data;

/*
* Cache version invalidation: Bump CACHE_VERSION when CachedResult structure changes.
*
* When to bump:
* - Adding/removing fields in CachedResult, CachedIssue, or CachedScore
* - Changing hash algorithms (content or prompts)
* - Modifying score calculation logic that affects cached components
*
* Migration strategy: On version mismatch, clear entire cache and rebuild.
*/

if (parsed.version !== CACHE_VERSION) {
console.warn(`[vectorlint] Cache version mismatch, clearing cache`);
return { version: CACHE_VERSION, entries: {} };
}

return parsed;
}
} catch (e: unknown) {
// If cache is corrupted, start fresh
const err = e instanceof Error ? e : new Error(String(e));
console.warn(`[vectorlint] Could not read cache, starting fresh: ${err.message}`);
}

return { version: CACHE_VERSION, entries: {} };
}

get(key: string): CachedResult | undefined {
return this.data.entries[key];
}

set(key: string, result: CachedResult): void {
this.data.entries[key] = result;
this.dirty = true;
}

has(key: string): boolean {
return key in this.data.entries;
}

clear(): void {
this.data.entries = {};
this.dirty = true;
}

size(): number {
return Object.keys(this.data.entries).length;
}

save(): void {
if (!this.dirty) return;

try {
// Create cache directory if missing
if (!existsSync(this.cacheDir)) {
mkdirSync(this.cacheDir, { recursive: true });
}

const json = JSON.stringify(this.data, null, 2);
writeFileSync(this.cacheFile, json, 'utf-8');
this.dirty = false;
} catch (e) {
// Don't fail the run if cache can't be written
const msg = e instanceof Error ? e.message : String(e);
console.warn(`[vectorlint] Warning: Could not save cache: ${msg}`);
}
}
}
50 changes: 50 additions & 0 deletions src/cache/content-hasher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { createHash } from 'crypto';
import type { PromptFile } from '../prompts/prompt-loader';

const HASH_TRUNCATE_LENGTH = 16;

/**
* Computes a SHA256 hash of normalized content.
*
* Normalization rationale:
* - Line endings (\r\n -> \n): Ensures consistent hashing across Windows/Unix.
* - Trim whitespace: Trailing whitespace is irrelevant for content quality.
*
* IMPORTANT: Changing normalization invalidates ALL cache entries.
*/
export function hashContent(content: string): string {
const normalized = content.replace(/\r\n/g, '\n').trim();
return createHash('sha256').update(normalized, 'utf8').digest('hex');
}

/**
* Computes a SHA256 hash of prompt configurations.
* This includes prompt id, meta, and body to detect rule changes.
*/
export function hashPrompts(prompts: PromptFile[]): string {
// Sort prompts by id for deterministic hashing.
const sorted = [...prompts].sort((a, b) => a.id.localeCompare(b.id));

// Extract hashable parts: id, meta (serialized), and body
const parts = sorted.map(p => ({
id: p.id,
meta: JSON.stringify(p.meta),
body: p.body.trim(),
pack: p.pack || '',
}));

const serialized = JSON.stringify(parts);
return createHash('sha256').update(serialized, 'utf8').digest('hex');
}

/**
* Creates a cache key string from components.
* Format: "filePath|contentHash(16)|promptsHash(16)"
*/
export function createCacheKeyString(
filePath: string,
contentHash: string,
promptsHash: string
): string {
return `${filePath}|${contentHash.substring(0, HASH_TRUNCATE_LENGTH)}|${promptsHash.substring(0, HASH_TRUNCATE_LENGTH)}`;
}
3 changes: 3 additions & 0 deletions src/cache/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './types';
export * from './content-hasher';
export * from './cache-store';
64 changes: 64 additions & 0 deletions src/cache/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { Severity } from '../evaluators/types';
import { ScoreComponent } from '../output/json-formatter';

/**
* Unique key for cache entries consisting of file path and content/prompt hashes.
*/
export interface CacheKey {
filePath: string;
contentHash: string;
promptsHash: string;
}


/**
* Minimal issue data stored in cache for replay.
*/
export interface CachedIssue {
line: number;
column: number;
severity: Severity;
summary: string;
ruleName: string;
suggestion?: string | undefined;
scoreText?: string | undefined;
match?: string | undefined;
}

export interface CachedEvaluationSummary {
id: string;
scoreText: string;
score?: number | undefined;
}

/**
* Grouped scores by rule/prompt.
*/
export interface CachedScore {
ruleName: string;
items: CachedEvaluationSummary[];
components?: ScoreComponent[] | undefined;
}

export interface CachedResult {
errors: number;
warnings: number;
hadOperationalErrors: boolean;
hadSeverityErrors: boolean;
requestFailures: number;
issues?: CachedIssue[] | undefined;
scores?: CachedScore[] | undefined;
jsonOutput?: unknown;
timestamp: number;
}

export interface CacheData {
version: number;
entries: Record<string, CachedResult>;
}

export interface CacheOptions {
enabled: boolean;
forceFullRun: boolean;
cacheDir?: string;
}
Loading