From cd0001c8060c5a532f47dc97609d8533126aa062 Mon Sep 17 00:00:00 2001 From: Ayomide Date: Thu, 18 Dec 2025 19:33:51 +0100 Subject: [PATCH 01/10] feat(cache): Add caching infrastructure and types - Create cache module with index, types, and store implementations - Add CacheKey interface for unique cache entry identification using file path and content/prompt hashes - Add CachedIssue interface for minimal issue data storage and replay - Add CachedResult interface to store evaluation results with errors, warnings, and operational status - Add CacheData interface for versioned cache entry management - Add CacheOptions interface for cache configuration (enabled, forceFullRun, cacheDir) - Update EvaluationOptions to include cacheEnabled and forceFullRun flags - Update ProcessViolationsParams, ProcessCriterionParams, and ProcessPromptResultParams with optional issueCollector - Update EvaluateFileParams to accept cacheStore, promptsHash, and useCache options - Update EvaluateFileResult to include wasCacheHit flag for cache hit detection - Add CLI schema options for --full and --noCache flags - Enable caching support for improved performance on repeated evaluations --- src/cache/index.ts | 3 ++ src/cache/types.ts | 58 ++++++++++++++++++++++++++++++++++++++ src/cli/types.ts | 10 +++++++ src/schemas/cli-schemas.ts | 2 ++ 4 files changed, 73 insertions(+) create mode 100644 src/cache/index.ts create mode 100644 src/cache/types.ts diff --git a/src/cache/index.ts b/src/cache/index.ts new file mode 100644 index 0000000..0a70ebb --- /dev/null +++ b/src/cache/index.ts @@ -0,0 +1,3 @@ +export * from './types'; +export * from './content-hasher'; +export * from './cache-store'; diff --git a/src/cache/types.ts b/src/cache/types.ts new file mode 100644 index 0000000..584f123 --- /dev/null +++ b/src/cache/types.ts @@ -0,0 +1,58 @@ +import { Severity } from '../evaluators/types'; + +/** + * 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; +} + +export interface CachedScore { + ruleName: string; + items: CachedEvaluationSummary[]; +} +export interface CachedResult { + errors: number; + warnings: number; + hadOperationalErrors: boolean; + hadSeverityErrors: boolean; + requestFailures: number; + issues?: CachedIssue[]; + scores?: CachedScore[]; + jsonOutput?: unknown; + timestamp: number; +} + +export interface CacheData { + version: number; + entries: Record; +} + +export interface CacheOptions { + enabled: boolean; + forceFullRun: boolean; + cacheDir?: string; +} diff --git a/src/cli/types.ts b/src/cli/types.ts index ace62ce..0cef169 100644 --- a/src/cli/types.ts +++ b/src/cli/types.ts @@ -9,6 +9,7 @@ import { JsonFormatter, type ScoreComponent } from '../output/json-formatter'; import { RdJsonFormatter } from '../output/rdjson-formatter'; import type { EvaluationResult as PromptEvaluationResult, SubjectiveResult } from '../prompts/schema'; import { Severity } from '../evaluators/types'; +import type { CacheStore, CachedIssue } from '../cache/index'; export enum OutputFormat { Line = 'line', @@ -26,6 +27,8 @@ export interface EvaluationOptions { verbose: boolean; scanPaths: FilePatternConfig[]; outputFormat?: OutputFormat; + cacheEnabled?: boolean; + forceFullRun?: boolean; } export interface EvaluationResult { @@ -89,6 +92,7 @@ export interface ProcessViolationsParams extends EvaluationContext { severity: Severity; ruleName: string; scoreText: string; + issueCollector?: CachedIssue[] | undefined; } export interface ProcessCriterionParams extends EvaluationContext { @@ -97,6 +101,7 @@ export interface ProcessCriterionParams extends EvaluationContext { promptId: string; promptFilename: string; meta: PromptMeta; + issueCollector?: CachedIssue[] | undefined; } export interface ProcessCriterionResult extends ErrorTrackingResult { @@ -114,6 +119,7 @@ export interface ValidationParams { export interface ProcessPromptResultParams extends EvaluationContext { promptFile: PromptFile; result: PromptEvaluationResult; + issueCollector?: CachedIssue[] | undefined; } export interface RunPromptEvaluationParams { @@ -133,8 +139,12 @@ export interface EvaluateFileParams { file: string; options: EvaluationOptions; jsonFormatter: ValeJsonFormatter | JsonFormatter | RdJsonFormatter; + cacheStore?: CacheStore | undefined; + promptsHash?: string | undefined; + useCache?: boolean | undefined; } export interface EvaluateFileResult extends ErrorTrackingResult { requestFailures: number; + wasCacheHit?: boolean; } diff --git a/src/schemas/cli-schemas.ts b/src/schemas/cli-schemas.ts index f216d16..e7c6cbe 100644 --- a/src/schemas/cli-schemas.ts +++ b/src/schemas/cli-schemas.ts @@ -10,6 +10,8 @@ export const CLI_OPTIONS_SCHEMA = z.object({ prompts: z.string().optional(), evals: z.string().optional(), config: z.string().optional(), + full: z.boolean().default(false), + noCache: z.boolean().default(false), }); // Validate command options schema From 0b495b0f3ef0a4fdd6f136e01d6293a2c92adaa5 Mon Sep 17 00:00:00 2001 From: Ayomide Date: Thu, 18 Dec 2025 19:35:09 +0100 Subject: [PATCH 02/10] feat(cache): Add cache store and content hashing utilities - Add CacheStore class for persistent cache management with disk I/O - Stores cache in .vectorlint/cache.json by default - Implements get, set, has, clear, and size operations - Includes version checking for future cache migrations - Gracefully handles corrupted cache files by starting fresh - Only writes to disk when cache is modified (dirty flag) - Add content hashing utilities for cache key generation - Implement hashContent() to compute SHA256 of normalized file content - Implement hashPrompts() to hash prompt configurations for rule change detection - Implement createCacheKeyString() to combine file path and hashes into cache keys - Normalize content by trimming whitespace and standardizing line endings - Add error handling to prevent cache failures from blocking execution --- src/cache/cache-store.ts | 90 +++++++++++++++++++++++++++++++++++++ src/cache/content-hasher.ts | 44 ++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 src/cache/cache-store.ts create mode 100644 src/cache/content-hasher.ts diff --git a/src/cache/cache-store.ts b/src/cache/cache-store.ts new file mode 100644 index 0000000..472d267 --- /dev/null +++ b/src/cache/cache-store.ts @@ -0,0 +1,90 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; +import * as path from 'path'; +import type { CacheData, CachedResult } from './types'; + +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 parsed = JSON.parse(raw) as CacheData; + + // Version check for future migrations + if (parsed.version !== CACHE_VERSION) { + console.warn(`[vectorlint] Cache version mismatch, clearing cache`); + return { version: CACHE_VERSION, entries: {} }; + } + + return parsed; + } + } catch { + // If cache is corrupted, start fresh + console.warn(`[vectorlint] Could not read cache, starting fresh`); + } + + 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}`); + } + } +} diff --git a/src/cache/content-hasher.ts b/src/cache/content-hasher.ts new file mode 100644 index 0000000..fb9c6da --- /dev/null +++ b/src/cache/content-hasher.ts @@ -0,0 +1,44 @@ +import { createHash } from 'crypto'; +import type { PromptFile } from '../prompts/prompt-loader'; + +/** + * Computes a SHA256 hash of normalized content. + * Normalization: trims whitespace and normalizes line endings. + */ +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 consistent ordering + 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|promptsHash + */ +export function createCacheKeyString( + filePath: string, + contentHash: string, + promptsHash: string +): string { + // Use | as separator since it's not valid in file paths + return `${filePath}|${contentHash.substring(0, 16)}|${promptsHash.substring(0, 16)}`; +} From f5234021b758747a71aa27ae410bde7fb7088fea Mon Sep 17 00:00:00 2001 From: Ayomide Date: Thu, 18 Dec 2025 19:57:02 +0100 Subject: [PATCH 03/10] feat(cache): Integrate caching into CLI and orchestrator - Add --full and --no-cache CLI options to control cache behavior - Pass cacheEnabled and forceFullRun flags to orchestrator - Implement cache store integration in evaluateFile function - Add issueCollector parameter to reportIssue and related functions - Filter runRules from scanPaths configuration before passing to orchestrator - Create cache key from content and prompts hash for cache lookups - Store and retrieve cached issues and scores from cache store - Skip evaluation for cached files and merge cached results with new ones - Add comprehensive cache store tests for validation and edge cases --- src/cli/commands.ts | 14 ++- src/cli/orchestrator.ts | 195 +++++++++++++++++++++++++---- tests/cache-store.test.ts | 256 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 442 insertions(+), 23 deletions(-) create mode 100644 tests/cache-store.test.ts diff --git a/src/cli/commands.ts b/src/cli/commands.ts index fcecccd..fc2157b 100644 --- a/src/cli/commands.ts +++ b/src/cli/commands.ts @@ -27,6 +27,8 @@ export function registerMainCommand(program: Command): void { .option('--debug-json', 'Print full JSON response from the API') .option('--output ', 'Output format: line (default), json, or vale-json, rdjson', 'line') .option('--config ', 'Path to custom vectorlint.ini config file') + .option('--full', 'Force full evaluation, ignore cache') + .option('--no-cache', 'Disable caching entirely') .argument('[paths...]', 'files or directories to check (optional)') .action(async (paths: string[] = []) => { @@ -135,7 +137,10 @@ export function registerMainCommand(program: Command): void { cliArgs: paths, cwd: process.cwd(), rulesPath, - scanPaths: config.scanPaths, + scanPaths: config.scanPaths.map(({ runRules, ...rest }) => ({ + ...rest, + ...(runRules !== undefined ? { runRules } : {}) + })), configDir: config.configDir, }); } catch (e: unknown) { @@ -167,7 +172,12 @@ export function registerMainCommand(program: Command): void { concurrency: config.concurrency, verbose: cliOptions.verbose, outputFormat: outputFormat, - scanPaths: config.scanPaths, + scanPaths: config.scanPaths.map(({ runRules, ...rest }) => ({ + ...rest, + ...(runRules !== undefined ? { runRules } : {}) + })), + cacheEnabled: !cliOptions.noCache, + forceFullRun: cliOptions.full, }); // Print global summary (only for line format) diff --git a/src/cli/orchestrator.ts b/src/cli/orchestrator.ts index c909df8..36b76fa 100644 --- a/src/cli/orchestrator.ts +++ b/src/cli/orchestrator.ts @@ -13,6 +13,7 @@ import { handleUnknownError, MissingDependencyError } from '../errors/index'; import { createEvaluator } from '../evaluators/index'; import { Type, Severity } from '../evaluators/types'; import { OutputFormat } from './types'; +import { CacheStore, hashContent, hashPrompts, createCacheKeyString, type CachedIssue, type CachedScore } from '../cache/index'; import type { EvaluationOptions, EvaluationResult, ErrorTrackingResult, ReportIssueParams, ExtractMatchTextParams, LocationMatch, ProcessViolationsParams, @@ -55,8 +56,33 @@ async function runWithConcurrency( /* * Reports an issue in either line or JSON format. */ -function reportIssue(params: ReportIssueParams): void { - const { file, line, column, severity, summary, ruleName, outputFormat, jsonFormatter, suggestion, scoreText, match } = params; +function reportIssue(params: ReportIssueParams, issueCollector?: CachedIssue[]): void { + const { + file, + line, + column, + severity, + summary, + ruleName, + outputFormat, + jsonFormatter, + suggestion, + scoreText, + match + } = params; + + if (issueCollector) { + issueCollector.push({ + line, + column, + severity, + summary, + ruleName, + suggestion, + scoreText, + match + }); + } if (outputFormat === OutputFormat.Line) { const locStr = `${line}:${column}`; @@ -154,7 +180,7 @@ function extractMatchText(params: ExtractMatchTextParams): LocationMatch { * couldn't be located, signaling text matching issues vs. content quality issues. */ function locateAndReportViolations(params: ProcessViolationsParams): { hadOperationalErrors: boolean } { - const { violations, content, relFile, severity, ruleName, scoreText, outputFormat, jsonFormatter } = params; + const { violations, content, relFile, severity, ruleName, scoreText, outputFormat, jsonFormatter, issueCollector } = params; let hadOperationalErrors = false; @@ -198,7 +224,7 @@ function locateAndReportViolations(params: ProcessViolationsParams): { hadOperat ...(v.suggestion !== undefined && { suggestion: v.suggestion }), scoreText, match: matchedText - }); + }, issueCollector); } return { hadOperationalErrors }; @@ -210,7 +236,7 @@ function locateAndReportViolations(params: ProcessViolationsParams): { hadOperat * Returns error/warning counts, score entry for Quality Scores, and score components for JSON. */ function extractAndReportCriterion(params: ProcessCriterionParams): ProcessCriterionResult { - const { exp, result, content, relFile, promptId, promptFilename, meta, outputFormat, jsonFormatter } = params; + const { exp, result, content, relFile, promptId, promptFilename, meta, outputFormat, jsonFormatter, issueCollector } = params; let hadOperationalErrors = false; let hadSeverityErrors = false; @@ -243,7 +269,7 @@ function extractAndReportCriterion(params: ProcessCriterionParams): ProcessCrite suggestion, scoreText: 'nil', match: '' - }); + }, issueCollector); return { errors: 1, warnings: 0, @@ -329,7 +355,8 @@ function extractAndReportCriterion(params: ProcessCriterionParams): ProcessCrite ruleName, scoreText, outputFormat, - jsonFormatter + jsonFormatter, + issueCollector }); hadOperationalErrors = hadOperationalErrors || violationResult.hadOperationalErrors; } else if (score <= 2) { @@ -357,7 +384,7 @@ function extractAndReportCriterion(params: ProcessCriterionParams): ProcessCrite jsonFormatter, scoreText, match: '' - }); + }, issueCollector); } return { @@ -453,7 +480,7 @@ function validateScores(params: ValidationParams): boolean { * Both paths generate scoreEntries for Quality Scores display. */ function routePromptResult(params: ProcessPromptResultParams): ErrorTrackingResult { - const { promptFile, result, content, relFile, outputFormat, jsonFormatter } = params; + const { promptFile, result, content, relFile, outputFormat, jsonFormatter, issueCollector } = params; const meta = promptFile.meta; const promptId = (meta.id || '').toString(); @@ -477,7 +504,8 @@ function routePromptResult(params: ProcessPromptResultParams): ErrorTrackingResu ruleName, scoreText: '', outputFormat, - jsonFormatter + jsonFormatter, + issueCollector }); hadOperationalErrors = hadOperationalErrors || violationResult.hadOperationalErrors; } else if ((outputFormat === OutputFormat.Json || outputFormat === OutputFormat.ValeJson) && result.message) { @@ -492,7 +520,7 @@ function routePromptResult(params: ProcessPromptResultParams): ErrorTrackingResu outputFormat, jsonFormatter, match: '' - }); + }, issueCollector); } // Create scoreEntry for Quality Scores display @@ -533,7 +561,8 @@ function routePromptResult(params: ProcessPromptResultParams): ErrorTrackingResu promptFilename: promptFile.filename, meta, outputFormat, - jsonFormatter + jsonFormatter, + issueCollector }); promptErrors += criterionResult.errors; @@ -610,18 +639,74 @@ async function runPromptEvaluation(params: RunPromptEvaluationParams): Promise { - const { file, options, jsonFormatter } = params; + const { file, options, jsonFormatter, cacheStore, promptsHash, useCache } = params; const { prompts, provider, searchProvider, concurrency, scanPaths, outputFormat = OutputFormat.Line } = options; + const content = readFileSync(file, 'utf-8'); + const relFile = path.relative(process.cwd(), file) || file; + + // Check cache before running evaluations + if (useCache && cacheStore && promptsHash) { + const contentHash = hashContent(content); + const cacheKey = createCacheKeyString(relFile, contentHash, promptsHash); + const cached = cacheStore.get(cacheKey); + + if (cached) { + // Cache hit - return cached result without running LLM + if (outputFormat === OutputFormat.Line) { + printFileHeader(relFile); + console.log(' (cached)'); + } + + // Replay cached issues + if (cached.issues) { + for (const issue of cached.issues) { + reportIssue({ + file: relFile, + line: issue.line, + column: issue.column, + severity: issue.severity, + summary: issue.summary, + ruleName: issue.ruleName, + outputFormat, + jsonFormatter, + ...(issue.suggestion !== undefined ? { suggestion: issue.suggestion } : {}), + ...(issue.scoreText !== undefined ? { scoreText: issue.scoreText } : {}), + ...(issue.match !== undefined ? { match: issue.match } : {}) + }); + } + } + + if (outputFormat === OutputFormat.Line) { + if (cached.scores) { + const replayScores = new Map(); + for (const s of cached.scores) { + replayScores.set(s.ruleName, s.items); + } + printEvaluationSummaries(replayScores); + } + console.log(''); + } + + return { + errors: cached.errors, + warnings: cached.warnings, + requestFailures: cached.requestFailures, + hadOperationalErrors: cached.hadOperationalErrors, + hadSeverityErrors: cached.hadSeverityErrors, + wasCacheHit: true + }; + } + } + let hadOperationalErrors = false; let hadSeverityErrors = false; let totalErrors = 0; let totalWarnings = 0; let requestFailures = 0; const allScores = new Map(); - - const content = readFileSync(file, 'utf-8'); - const relFile = path.relative(process.cwd(), file) || file; + // Collect issues for caching + const issueCollector: CachedIssue[] = []; if (outputFormat === OutputFormat.Line) { printFileHeader(relFile); @@ -710,7 +795,8 @@ async function evaluateFile(params: EvaluateFileParams): Promise { - const { outputFormat = OutputFormat.Line } = options; + const { outputFormat = OutputFormat.Line, cacheEnabled = true, forceFullRun = false, prompts, verbose } = options; let hadOperationalErrors = false; let hadSeverityErrors = false; @@ -755,6 +866,26 @@ export async function evaluateFiles( let totalErrors = 0; let totalWarnings = 0; let requestFailures = 0; + let cacheHits = 0; + + // Initialize cache if enabled + const useCache = cacheEnabled && !forceFullRun; + let cacheStore: CacheStore | undefined; + let promptsHash: string | undefined; + + if (cacheEnabled) { + cacheStore = new CacheStore(); + promptsHash = hashPrompts(prompts); + + if (verbose) { + const cacheSize = cacheStore.size(); + if (forceFullRun) { + console.log(`[vectorlint] Cache: --full flag, ignoring ${cacheSize} cached entries`); + } else if (cacheSize > 0) { + console.log(`[vectorlint] Cache: ${cacheSize} entries loaded`); + } + } + } let jsonFormatter: ValeJsonFormatter | JsonFormatter | RdJsonFormatter; if (outputFormat === OutputFormat.Json) { @@ -768,7 +899,20 @@ export async function evaluateFiles( for (const file of targets) { try { totalFiles += 1; - const fileResult = await evaluateFile({ file, options, jsonFormatter }); + const fileResult = await evaluateFile({ + file, + options, + jsonFormatter, + cacheStore, + promptsHash, + useCache + }); + + // Track cache hits + if (fileResult.wasCacheHit) { + cacheHits += 1; + } + totalErrors += fileResult.errors; totalWarnings += fileResult.warnings; requestFailures += fileResult.requestFailures; @@ -781,6 +925,15 @@ export async function evaluateFiles( } } + // Save cache after all evaluations + if (cacheStore) { + cacheStore.save(); + + if (verbose && cacheHits > 0) { + console.log(`[vectorlint] Cache: ${cacheHits}/${totalFiles} files from cache`); + } + } + // Output results based on format (always to stdout for JSON formats) if (outputFormat === OutputFormat.Json || outputFormat === OutputFormat.ValeJson || outputFormat === OutputFormat.RdJson) { const jsonStr = jsonFormatter.toJson(); diff --git a/tests/cache-store.test.ts b/tests/cache-store.test.ts new file mode 100644 index 0000000..34b5133 --- /dev/null +++ b/tests/cache-store.test.ts @@ -0,0 +1,256 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdirSync, rmSync, existsSync } from 'fs'; +import * as path from 'path'; +import { hashContent, hashPrompts, createCacheKeyString } from '../src/cache/content-hasher'; +import { CacheStore } from '../src/cache/cache-store'; +import type { PromptFile } from '../src/prompts/prompt-loader'; +import type { CachedResult } from '../src/cache/types'; + +describe('Content Hasher', () => { + describe('hashContent', () => { + it('produces consistent hashes for same content', () => { + const content = 'Hello, World!'; + const hash1 = hashContent(content); + const hash2 = hashContent(content); + expect(hash1).toBe(hash2); + }); + + it('normalizes line endings', () => { + const contentCRLF = 'Line 1\r\nLine 2'; + const contentLF = 'Line 1\nLine 2'; + expect(hashContent(contentCRLF)).toBe(hashContent(contentLF)); + }); + + it('trims whitespace', () => { + const content1 = ' Hello '; + const content2 = 'Hello'; + expect(hashContent(content1)).toBe(hashContent(content2)); + }); + + it('produces different hashes for different content', () => { + const hash1 = hashContent('Content A'); + const hash2 = hashContent('Content B'); + expect(hash1).not.toBe(hash2); + }); + }); + + describe('hashPrompts', () => { + const createMockPrompt = (id: string, body: string): PromptFile => ({ + id, + filename: `${id}.md`, + fullPath: `/path/to/${id}.md`, + meta: { + id, + name: `Test ${id}`, + }, + body, + pack: 'TestPack', + }); + + it('produces consistent hashes for same prompts', () => { + const prompts = [createMockPrompt('rule1', 'Body 1')]; + const hash1 = hashPrompts(prompts); + const hash2 = hashPrompts(prompts); + expect(hash1).toBe(hash2); + }); + + it('produces same hash regardless of prompt order', () => { + const prompt1 = createMockPrompt('a-rule', 'Body A'); + const prompt2 = createMockPrompt('b-rule', 'Body B'); + + const hash1 = hashPrompts([prompt1, prompt2]); + const hash2 = hashPrompts([prompt2, prompt1]); + expect(hash1).toBe(hash2); + }); + + it('produces different hash when prompt body changes', () => { + const v1 = [createMockPrompt('rule1', 'Original body')]; + const v2 = [createMockPrompt('rule1', 'Modified body')]; + + expect(hashPrompts(v1)).not.toBe(hashPrompts(v2)); + }); + + it('produces different hash when prompt meta changes', () => { + const v1: PromptFile[] = [{ + id: 'rule1', + filename: 'rule1.md', + fullPath: '/path/rule1.md', + meta: { id: 'rule1', name: 'Rule One' }, + body: 'Same body', + }]; + const v2: PromptFile[] = [{ + id: 'rule1', + filename: 'rule1.md', + fullPath: '/path/rule1.md', + meta: { id: 'rule1', name: 'Rule One Modified' }, + body: 'Same body', + }]; + + expect(hashPrompts(v1)).not.toBe(hashPrompts(v2)); + }); + }); + + describe('createCacheKeyString', () => { + it('creates a key with truncated hashes', () => { + const key = createCacheKeyString( + 'path/to/file.md', + 'abc123def456ghi789jkl012mno345pqr678', + 'xyz987wvu654tsr321qpo098nml765kji432' + ); + expect(key).toBe('path/to/file.md|abc123def456ghi7|xyz987wvu654tsr3'); + }); + }); +}); + +describe('CacheStore', () => { + const testDir = path.join(__dirname, 'fixtures', 'cache-test'); + const cacheDir = '.test-vectorlint'; + + beforeEach(() => { + // Ensure test directory exists + if (!existsSync(testDir)) { + mkdirSync(testDir, { recursive: true }); + } + // Clean up any existing test cache + const fullCacheDir = path.join(testDir, cacheDir); + if (existsSync(fullCacheDir)) { + rmSync(fullCacheDir, { recursive: true }); + } + }); + + afterEach(() => { + // Clean up test cache + const fullCacheDir = path.join(testDir, cacheDir); + if (existsSync(fullCacheDir)) { + rmSync(fullCacheDir, { recursive: true }); + } + }); + + it('returns undefined for missing keys', () => { + const store = new CacheStore(testDir, cacheDir); + expect(store.get('nonexistent-key')).toBeUndefined(); + }); + + it('stores and retrieves results', () => { + const store = new CacheStore(testDir, cacheDir); + const result: CachedResult = { + errors: 2, + warnings: 1, + hadOperationalErrors: false, + hadSeverityErrors: true, + requestFailures: 0, + timestamp: Date.now(), + }; + + store.set('test-key', result); + const retrieved = store.get('test-key'); + + expect(retrieved).toBeDefined(); + expect(retrieved?.errors).toBe(2); + expect(retrieved?.warnings).toBe(1); + expect(retrieved?.hadSeverityErrors).toBe(true); + }); + + it('persists cache to disk', () => { + const store1 = new CacheStore(testDir, cacheDir); + const result: CachedResult = { + errors: 1, + warnings: 0, + hadOperationalErrors: false, + hadSeverityErrors: false, + requestFailures: 0, + timestamp: Date.now(), + }; + + store1.set('persistent-key', result); + store1.save(); + + // Create new store instance to load from disk + const store2 = new CacheStore(testDir, cacheDir); + const retrieved = store2.get('persistent-key'); + + expect(retrieved).toBeDefined(); + expect(retrieved?.errors).toBe(1); + }); + + it('creates cache directory if missing', () => { + const store = new CacheStore(testDir, cacheDir); + store.set('key', { + errors: 0, + warnings: 0, + hadOperationalErrors: false, + hadSeverityErrors: false, + requestFailures: 0, + timestamp: Date.now(), + }); + store.save(); + + const fullCacheDir = path.join(testDir, cacheDir); + expect(existsSync(fullCacheDir)).toBe(true); + expect(existsSync(path.join(fullCacheDir, 'cache.json'))).toBe(true); + }); + + it('clears all entries', () => { + const store = new CacheStore(testDir, cacheDir); + store.set('key1', { + errors: 0, + warnings: 0, + hadOperationalErrors: false, + hadSeverityErrors: false, + requestFailures: 0, + timestamp: Date.now(), + }); + store.set('key2', { + errors: 0, + warnings: 0, + hadOperationalErrors: false, + hadSeverityErrors: false, + requestFailures: 0, + timestamp: Date.now(), + }); + + expect(store.size()).toBe(2); + store.clear(); + expect(store.size()).toBe(0); + }); + + it('reports correct size', () => { + const store = new CacheStore(testDir, cacheDir); + expect(store.size()).toBe(0); + + store.set('key1', { + errors: 0, + warnings: 0, + hadOperationalErrors: false, + hadSeverityErrors: false, + requestFailures: 0, + timestamp: Date.now(), + }); + expect(store.size()).toBe(1); + + store.set('key2', { + errors: 0, + warnings: 0, + hadOperationalErrors: false, + hadSeverityErrors: false, + requestFailures: 0, + timestamp: Date.now(), + }); + expect(store.size()).toBe(2); + }); + + it('checks key existence with has()', () => { + const store = new CacheStore(testDir, cacheDir); + expect(store.has('missing')).toBe(false); + + store.set('exists', { + errors: 0, + warnings: 0, + hadOperationalErrors: false, + hadSeverityErrors: false, + requestFailures: 0, + timestamp: Date.now(), + }); + expect(store.has('exists')).toBe(true); + }); +}); From eef5729c3d3f116a9e4038cab6a36453b7b048c8 Mon Sep 17 00:00:00 2001 From: Ayomide Date: Thu, 18 Dec 2025 20:11:17 +0100 Subject: [PATCH 04/10] docs(cache): Add schema version documentation to cache store --- src/cache/cache-store.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/cache/cache-store.ts b/src/cache/cache-store.ts index 472d267..8930811 100644 --- a/src/cache/cache-store.ts +++ b/src/cache/cache-store.ts @@ -2,6 +2,10 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; import * as path from 'path'; import type { CacheData, CachedResult } from './types'; +/** + * 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'; From 219d150905a3f21a510143e5713ab19ec284383d Mon Sep 17 00:00:00 2001 From: Ayomide Date: Thu, 18 Dec 2025 20:40:40 +0100 Subject: [PATCH 05/10] feat(cache): Add score components support for JSON output format - Add CachedScoreComponent interface to store minimal score data for replay - Extend CachedScore interface with optional components field for JSON output - Add scoreComponents field to ErrorTrackingResult for tracking score data - Update routePromptResult to initialize and populate scoreComponents - Modify evaluateFile to handle cached score replay for both Line and JSON formats - Refactor allScores map to use CachedScore objects instead of raw summaries - Simplify cache storage by converting map to array using Array.from() - Enable JSON formatter to output evaluation scores from cached data - Improve cache replay logic to support multiple output formats --- src/cache/types.ts | 17 +++++++++++++++++ src/cli/orchestrator.ts | 41 ++++++++++++++++++++++++++++------------- src/cli/types.ts | 1 + 3 files changed, 46 insertions(+), 13 deletions(-) diff --git a/src/cache/types.ts b/src/cache/types.ts index 584f123..9399da9 100644 --- a/src/cache/types.ts +++ b/src/cache/types.ts @@ -30,9 +30,26 @@ export interface CachedEvaluationSummary { score?: number; } +/** + * Minimal score component for replay (mirrors ScoreComponent). + */ +export interface CachedScoreComponent { + criterion?: string; + rawScore: number; + maxScore: number; + weightedScore: number; + weightedMaxScore: number; + normalizedScore: number; + normalizedMaxScore: number; +} + +/** + * Grouped scores by rule/prompt. + */ export interface CachedScore { ruleName: string; items: CachedEvaluationSummary[]; + components?: CachedScoreComponent[]; } export interface CachedResult { errors: number; diff --git a/src/cli/orchestrator.ts b/src/cli/orchestrator.ts index 36b76fa..e5e9e26 100644 --- a/src/cli/orchestrator.ts +++ b/src/cli/orchestrator.ts @@ -535,7 +535,8 @@ function routePromptResult(params: ProcessPromptResultParams): ErrorTrackingResu warnings: severity === Severity.WARNING ? violationCount : 0, hadOperationalErrors, hadSeverityErrors: severity === Severity.ERROR, - scoreEntries: [scoreEntry] + scoreEntries: [scoreEntry], + scoreComponents: [] }; } @@ -588,7 +589,8 @@ function routePromptResult(params: ProcessPromptResultParams): ErrorTrackingResu warnings: promptWarnings, hadOperationalErrors, hadSeverityErrors, - scoreEntries: criterionScores + scoreEntries: criterionScores, + scoreComponents: scoreComponents }; } @@ -677,15 +679,24 @@ async function evaluateFile(params: EvaluateFileParams): Promise(); for (const s of cached.scores) { replayScores.set(s.ruleName, s.items); } printEvaluationSummaries(replayScores); + console.log(''); + } else if (outputFormat === OutputFormat.Json) { + for (const s of cached.scores) { + if (s.components && s.components.length > 0) { + (jsonFormatter as JsonFormatter | RdJsonFormatter).addEvaluationScore(relFile, { + id: s.ruleName, + scores: s.components + }); + } + } } - console.log(''); } return { @@ -704,7 +715,7 @@ async function evaluateFile(params: EvaluateFileParams): Promise(); + const allScores = new Map(); // Collect issues for caching const issueCollector: CachedIssue[] = []; @@ -805,13 +816,20 @@ async function evaluateFile(params: EvaluateFileParams): Promise 0) { const ruleName = (p.meta.id || p.filename).toString(); - allScores.set(ruleName, promptResult.scoreEntries); + allScores.set(ruleName, { + ruleName, + items: promptResult.scoreEntries, + ...(promptResult.scoreComponents ? { components: promptResult.scoreComponents as any } : {}) + }); } } if (outputFormat === OutputFormat.Line) { - - printEvaluationSummaries(allScores); + const summaryMap = new Map(); + for (const [key, val] of allScores.entries()) { + summaryMap.set(key, val.items); + } + printEvaluationSummaries(summaryMap); console.log(''); } @@ -829,10 +847,7 @@ async function evaluateFile(params: EvaluateFileParams): Promise Date: Fri, 19 Dec 2025 08:09:37 +0100 Subject: [PATCH 06/10] Clean Up eslint errors --- src/cache/cache-store.ts | 12 +++++++++++- src/cache/types.ts | 16 ++-------------- src/cli/orchestrator.ts | 2 +- src/schemas/cache-schema.ts | 6 ++++++ 4 files changed, 20 insertions(+), 16 deletions(-) create mode 100644 src/schemas/cache-schema.ts diff --git a/src/cache/cache-store.ts b/src/cache/cache-store.ts index 8930811..2452635 100644 --- a/src/cache/cache-store.ts +++ b/src/cache/cache-store.ts @@ -1,6 +1,7 @@ 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 @@ -33,7 +34,16 @@ export class CacheStore { try { if (existsSync(this.cacheFile)) { const raw = readFileSync(this.cacheFile, 'utf-8'); - const parsed = JSON.parse(raw) as CacheData; + 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 as CacheData; // Version check for future migrations if (parsed.version !== CACHE_VERSION) { diff --git a/src/cache/types.ts b/src/cache/types.ts index 9399da9..c574f39 100644 --- a/src/cache/types.ts +++ b/src/cache/types.ts @@ -1,4 +1,5 @@ import { Severity } from '../evaluators/types'; +import { ScoreComponent } from '../output/json-formatter'; /** * Unique key for cache entries consisting of file path and content/prompt hashes. @@ -30,26 +31,13 @@ export interface CachedEvaluationSummary { score?: number; } -/** - * Minimal score component for replay (mirrors ScoreComponent). - */ -export interface CachedScoreComponent { - criterion?: string; - rawScore: number; - maxScore: number; - weightedScore: number; - weightedMaxScore: number; - normalizedScore: number; - normalizedMaxScore: number; -} - /** * Grouped scores by rule/prompt. */ export interface CachedScore { ruleName: string; items: CachedEvaluationSummary[]; - components?: CachedScoreComponent[]; + components?: ScoreComponent[]; } export interface CachedResult { errors: number; diff --git a/src/cli/orchestrator.ts b/src/cli/orchestrator.ts index e5e9e26..e5ed324 100644 --- a/src/cli/orchestrator.ts +++ b/src/cli/orchestrator.ts @@ -819,7 +819,7 @@ async function evaluateFile(params: EvaluateFileParams): Promise Date: Fri, 19 Dec 2025 09:08:39 +0100 Subject: [PATCH 07/10] Clean up cache implementation --- src/cache/cache-store.ts | 19 ++++++++++--- src/cache/content-hasher.ts | 16 +++++++---- src/cache/types.ts | 9 +++--- src/output/reporter.ts | 2 +- src/schemas/cache-schema.ts | 55 ++++++++++++++++++++++++++++++++++++- 5 files changed, 86 insertions(+), 15 deletions(-) diff --git a/src/cache/cache-store.ts b/src/cache/cache-store.ts index 2452635..74cd515 100644 --- a/src/cache/cache-store.ts +++ b/src/cache/cache-store.ts @@ -43,9 +43,19 @@ export class CacheStore { return { version: CACHE_VERSION, entries: {} }; } - const parsed = result.data as CacheData; + 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. + */ - // Version check for future migrations if (parsed.version !== CACHE_VERSION) { console.warn(`[vectorlint] Cache version mismatch, clearing cache`); return { version: CACHE_VERSION, entries: {} }; @@ -53,9 +63,10 @@ export class CacheStore { return parsed; } - } catch { + } catch (e: unknown) { // If cache is corrupted, start fresh - console.warn(`[vectorlint] Could not read cache, starting 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: {} }; diff --git a/src/cache/content-hasher.ts b/src/cache/content-hasher.ts index fb9c6da..40ce1df 100644 --- a/src/cache/content-hasher.ts +++ b/src/cache/content-hasher.ts @@ -1,9 +1,16 @@ import { createHash } from 'crypto'; import type { PromptFile } from '../prompts/prompt-loader'; +const HASH_TRUNCATE_LENGTH = 16; + /** * Computes a SHA256 hash of normalized content. - * Normalization: trims whitespace and normalizes line endings. + * + * 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(); @@ -15,7 +22,7 @@ export function hashContent(content: string): string { * This includes prompt id, meta, and body to detect rule changes. */ export function hashPrompts(prompts: PromptFile[]): string { - // Sort prompts by id for consistent ordering + // 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 @@ -32,13 +39,12 @@ export function hashPrompts(prompts: PromptFile[]): string { /** * Creates a cache key string from components. - * Format: filePath|contentHash|promptsHash + * Format: "filePath|contentHash(16)|promptsHash(16)" */ export function createCacheKeyString( filePath: string, contentHash: string, promptsHash: string ): string { - // Use | as separator since it's not valid in file paths - return `${filePath}|${contentHash.substring(0, 16)}|${promptsHash.substring(0, 16)}`; + return `${filePath}|${contentHash.substring(0, HASH_TRUNCATE_LENGTH)}|${promptsHash.substring(0, HASH_TRUNCATE_LENGTH)}`; } diff --git a/src/cache/types.ts b/src/cache/types.ts index c574f39..2d1eb99 100644 --- a/src/cache/types.ts +++ b/src/cache/types.ts @@ -28,7 +28,7 @@ export interface CachedIssue { export interface CachedEvaluationSummary { id: string; scoreText: string; - score?: number; + score?: number | undefined; } /** @@ -37,16 +37,17 @@ export interface CachedEvaluationSummary { export interface CachedScore { ruleName: string; items: CachedEvaluationSummary[]; - components?: ScoreComponent[]; + components?: ScoreComponent[] | undefined; } + export interface CachedResult { errors: number; warnings: number; hadOperationalErrors: boolean; hadSeverityErrors: boolean; requestFailures: number; - issues?: CachedIssue[]; - scores?: CachedScore[]; + issues?: CachedIssue[] | undefined; + scores?: CachedScore[] | undefined; jsonOutput?: unknown; timestamp: number; } diff --git a/src/output/reporter.ts b/src/output/reporter.ts index abc4c06..c204c51 100644 --- a/src/output/reporter.ts +++ b/src/output/reporter.ts @@ -6,7 +6,7 @@ import { Severity } from '../evaluators/types'; export interface EvaluationSummary { id: string; scoreText: string; - score?: number; + score?: number | undefined; } export type Status = Severity; diff --git a/src/schemas/cache-schema.ts b/src/schemas/cache-schema.ts index fc333fa..9797bda 100644 --- a/src/schemas/cache-schema.ts +++ b/src/schemas/cache-schema.ts @@ -1,6 +1,59 @@ import { z } from 'zod'; +import { Severity } from '../evaluators/types'; + +// Schema for cached issues +const CACHED_ISSUE_SCHEMA = z.object({ + line: z.number(), + column: z.number(), + severity: z.nativeEnum(Severity), + summary: z.string(), + ruleName: z.string(), + suggestion: z.string().optional(), + scoreText: z.string().optional(), + match: z.string().optional(), +}); + +// Schema for evaluation summary in scores +const CACHED_EVALUATION_SUMMARY_SCHEMA = z.object({ + id: z.string(), + scoreText: z.string(), + score: z.number().optional(), +}); + +// Schema for granular score components +const SCORE_COMPONENT_SCHEMA = z.object({ + criterion: z.string(), + rawScore: z.number(), + maxScore: z.number(), + weightedScore: z.number(), + weightedMaxScore: z.number(), + normalizedScore: z.number(), + normalizedMaxScore: z.number(), +}); + +// Schema for grouped scores +const CACHED_SCORE_SCHEMA = z.object({ + ruleName: z.string(), + items: z.array(CACHED_EVALUATION_SUMMARY_SCHEMA), + components: z.array(SCORE_COMPONENT_SCHEMA).optional(), +}); + +// Schema for the main cached result +const CACHED_RESULT_SCHEMA = z.object({ + errors: z.number(), + warnings: z.number(), + hadOperationalErrors: z.boolean(), + hadSeverityErrors: z.boolean(), + requestFailures: z.number(), + issues: z.array(CACHED_ISSUE_SCHEMA).optional(), + scores: z.array(CACHED_SCORE_SCHEMA).optional(), + // Use unknown for jsonOutput as it can be any valid JSON structure + jsonOutput: z.unknown().optional(), + timestamp: z.number(), +}); + export const CACHE_SCHEMA = z.object({ version: z.number(), - entries: z.record(z.string(), z.any()) + entries: z.record(z.string(), CACHED_RESULT_SCHEMA) }); \ No newline at end of file From 912aa37f712190ed4da3ee582022796ca6a6b8b2 Mon Sep 17 00:00:00 2001 From: Ayomide Date: Fri, 19 Dec 2025 09:14:30 +0100 Subject: [PATCH 08/10] Add new line --- src/schemas/cache-schema.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/schemas/cache-schema.ts b/src/schemas/cache-schema.ts index 9797bda..a1ea06f 100644 --- a/src/schemas/cache-schema.ts +++ b/src/schemas/cache-schema.ts @@ -56,4 +56,4 @@ const CACHED_RESULT_SCHEMA = z.object({ export const CACHE_SCHEMA = z.object({ version: z.number(), entries: z.record(z.string(), CACHED_RESULT_SCHEMA) -}); \ No newline at end of file +}); From 7325aad10b6b97b4e77a696875046705df246beb Mon Sep 17 00:00:00 2001 From: Jahtofunmi Osho Date: Mon, 29 Dec 2025 01:18:01 +0100 Subject: [PATCH 09/10] fix: convert CLI output option string to OutputFormat enum --- src/cli/commands.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/cli/commands.ts b/src/cli/commands.ts index cd3eb8b..575091a 100644 --- a/src/cli/commands.ts +++ b/src/cli/commands.ts @@ -14,6 +14,7 @@ import { resolveTargets } from "../scan/file-resolver"; import { parseCliOptions, parseEnvironment } from "../boundaries/index"; import { handleUnknownError } from "../errors/index"; import { evaluateFiles } from "./orchestrator"; +import { OutputFormat } from "./types"; import { DEFAULT_CONFIG_FILENAME } from "../config/constants"; /* @@ -176,8 +177,15 @@ export function registerMainCommand(program: Command): void { ? new PerplexitySearchProvider({ debug: false }) : undefined; + // Convert string to OutputFormat enum + const outputFormatMap: Record = { + line: OutputFormat.Line, + json: OutputFormat.Json, + "vale-json": OutputFormat.ValeJson, + rdjson: OutputFormat.RdJson, + }; const outputFormat = - cliOptions.output === "JSON" ? "json" : cliOptions.output; + outputFormatMap[cliOptions.output] || OutputFormat.Line; // Run evaluations via orchestrator const result = await evaluateFiles(targets, { @@ -197,7 +205,7 @@ export function registerMainCommand(program: Command): void { }); // Print global summary (only for line format) - if (cliOptions.output === "line") { + if (outputFormat === OutputFormat.Line) { printGlobalSummary( result.totalFiles, result.totalErrors, From b77a753ce2ac0920e31c0e0ddb3c02e607bb313d Mon Sep 17 00:00:00 2001 From: Jahtofunmi Osho Date: Mon, 29 Dec 2025 01:45:02 +0100 Subject: [PATCH 10/10] fix: correct --config option syntax and extract scanPaths transformation --- src/cli/commands.ts | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/cli/commands.ts b/src/cli/commands.ts index 575091a..64c3d58 100644 --- a/src/cli/commands.ts +++ b/src/cli/commands.ts @@ -36,7 +36,8 @@ export function registerMainCommand(program: Command): void { "line" ) .option( - `--config ', 'Path to custom ${DEFAULT_CONFIG_FILENAME} config file` + "--config ", + `Path to custom ${DEFAULT_CONFIG_FILENAME} config file` ) .option("--full", "Force full evaluation, ignore cache") .option("--no-cache", "Disable caching entirely") @@ -147,6 +148,13 @@ export function registerMainCommand(program: Command): void { process.exit(1); } + const normalizedScanPaths = config.scanPaths.map( + ({ runRules, ...rest }) => ({ + ...rest, + ...(runRules !== undefined ? { runRules } : {}), + }) + ); + // Resolve target files let targets: string[] = []; try { @@ -154,10 +162,7 @@ export function registerMainCommand(program: Command): void { cliArgs: paths, cwd: process.cwd(), rulesPath, - scanPaths: config.scanPaths.map(({ runRules, ...rest }) => ({ - ...rest, - ...(runRules !== undefined ? { runRules } : {}), - })), + scanPaths: normalizedScanPaths, configDir: config.configDir, }); } catch (e: unknown) { @@ -196,10 +201,7 @@ export function registerMainCommand(program: Command): void { concurrency: config.concurrency, verbose: cliOptions.verbose, outputFormat: outputFormat, - scanPaths: config.scanPaths.map(({ runRules, ...rest }) => ({ - ...rest, - ...(runRules !== undefined ? { runRules } : {}), - })), + scanPaths: normalizedScanPaths, cacheEnabled: !cliOptions.noCache, forceFullRun: cliOptions.full, });