diff --git a/README.md b/README.md index dd6be489..edfd4ca7 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,7 @@ lore search --text "session" --confidence high | Command | Description | |---------|-------------| | `lore init` | Initialize `.lore/` config in repository | +| `lore cache --clean` | Clear all local cache data | | `lore commit` | Create a Lore-enriched commit | | `lore context ` | Full lore summary for a code region | | `lore constraints ` | Active constraints for a code region | @@ -131,6 +132,7 @@ lore search --text "session" --confidence high | `--format ` | Output format: `text` (default) or `json` | | `--no-color` | Disable colored output | | `--no-update-notifier` | Disable update notification | +| `--no-cache` | Bypass caching in the CLI | | `--limit ` | Limit number of results | | `--since ` | Only consider commits since ref/date | @@ -155,7 +157,7 @@ Every Lore-enriched commit carries a `Lore-id` and any combination of these trai ## Configuration -`lore init` creates `.lore/config.toml`: +`lore init` creates `.lore/config.toml` and ensures `.lore/cache` is added to your `.gitignore` to prevent local cache files from being tracked. ```toml [protocol] @@ -188,6 +190,7 @@ update_check = true # Set to false to disable update notifications | Variable | Description | |----------|-------------| +| `LORE_NO_CACHE` | Set to `1` or `true` to bypass the atom cache | | `LORE_NO_UPDATE_CHECK` | Set to `1` or `true` to disable update notifications | | `NO_UPDATE_NOTIFIER` | Standard variable to disable update notifications (set to `1` or `true`) | | `CI` | Set to `true` or `1` to disable notifications (automatic in most CI) | diff --git a/documents/PROJECT_ARCHITECTURE.md b/documents/PROJECT_ARCHITECTURE.md index 262a4a21..140b57a0 100644 --- a/documents/PROJECT_ARCHITECTURE.md +++ b/documents/PROJECT_ARCHITECTURE.md @@ -1,7 +1,7 @@ # Lore CLI -- Project Architecture > Authoritative reference for contributors (human or AI) to the lore-cli codebase. -> Last updated 2026-03-22. Reflects the codebase after PR #1-15 merge cycle. +> Last updated 2026-05-11. Reflects the codebase after the Atom Cache implementation. --- @@ -148,6 +148,12 @@ No command or service instantiates its own dependencies. All wiring is centraliz - **Dependencies**: None. - **Dependents**: `TerminalPrompt` (implements), `commit.ts`, `main.ts`. +#### `src/interfaces/atom-cache.ts` +- **Contains**: `IAtomCache` interface. +- **Single Responsibility**: Contract for sharded immutable file list caching. +- **Dependencies**: None. +- **Dependents**: `AtomCache` (implements), `NullAtomCache` (implements), `AtomRepository`. + #### `src/interfaces/commit-input-reader.ts` - **Contains**: `ICommitInputReader` interface. - **Single Responsibility**: Strategy contract for reading commit input from any source. @@ -168,6 +174,12 @@ No command or service instantiates its own dependencies. All wiring is centraliz - **Dependencies**: `output.ts` (`ValidationIssue`). - **Dependents**: `GitClient`, `commit.ts`, `squash.ts`, `trace.ts`, `why.ts`, `main.ts`. +#### `src/util/cache-check.ts` +- **Contains**: `shouldBypassCache()` function. +- **Single Responsibility**: Determines whether the atom cache should be bypassed based on CLI flags, environment variables, and config. Extracted to avoid side effects during testing. +- **Dependencies**: None. +- **Dependents**: `main.ts`. + #### `src/util/constants.ts` - **Contains**: All protocol constants: `LORE_TRAILER_KEYS`, `ARRAY_TRAILER_KEYS`, `ENUM_TRAILER_KEYS`, valid enum value arrays, `LORE_ID_PATTERN` regex, `REFERENCE_TRAILER_KEYS`, default limits/thresholds, config file names, exit codes. - **Single Responsibility**: Central registry of all protocol-level constants. Changing a trailer name or adding a new one starts here. @@ -197,10 +209,17 @@ No command or service instantiates its own dependencies. All wiring is centraliz - **Dependents**: `CommitBuilder`, `SquashMerger`, `main.ts`. - **Key methods**: `generate()`. +#### `src/services/atom-cache.ts` +- **Contains**: `AtomCache` and `NullAtomCache` classes. +- **Single Responsibility**: Implements sharded immutable file list caching on the filesystem. Uses a sharded structure (e.g., `.lore/cache/atoms/ab/cd/ef...`) to avoid directory entry limits. +- **Dependencies**: `IAtomCache`, Node.js `fs`, `path`. +- **Dependents**: `main.ts`, `AtomRepository`. +- **Key methods**: `getFiles()`, `setFiles()`. + #### `src/services/atom-repository.ts` - **Contains**: `AtomRepository` class. -- **Single Responsibility**: Central query engine. Retrieves `LoreAtom` objects from git history by target path, Lore-id, revision range, scope, or globally. Handles follow-link BFS traversal. -- **Dependencies**: `IGitClient`, `TrailerParser`, `domain.ts`, `query.ts`, `constants.ts`. +- **Single Responsibility**: Central query engine. Retrieves `LoreAtom` objects from git history by target path, Lore-id, revision range, scope, or globally. Handles follow-link BFS traversal and file-list caching. +- **Dependencies**: `IGitClient`, `TrailerParser`, `IAtomCache`, `domain.ts`, `query.ts`, `constants.ts`. - **Dependents**: Commands (`context`, `constraints`, `rejected`, `directives`, `tested`, `search`, `log`, `stale`, `trace`, `squash`, `doctor`), `Validator`, `main.ts`. - **Key methods**: `findByTarget()`, `findByLoreId()`, `findByRange()`, `findAll()`, `findByScope()`, `resolveFollowLinks()`. @@ -329,7 +348,12 @@ No command or service instantiates its own dependencies. All wiring is centraliz #### `src/commands/init.ts` - **Contains**: `registerInitCommand()` function. -- **Single Responsibility**: Creates `.lore/config.toml` with default content. Shows existing config if already present. +- **Single Responsibility**: Creates `.lore/config.toml` with default content and ensures `.lore/cache` is ignored via `.gitignore`. Shows existing config if already present. +- **Dependencies**: `IOutputFormatter`, `constants.ts`, Node.js `fs`. + +#### `src/commands/cache.ts` +- **Contains**: `registerCacheCommand()` function. +- **Single Responsibility**: Provides local cache management, including clearing the `.lore/cache` directory. - **Dependencies**: `IOutputFormatter`, `constants.ts`, Node.js `fs`. #### `src/commands/context.ts` @@ -597,6 +621,7 @@ graph LR LIG[LoreIdGenerator] CL[ConfigLoader] TMP[TerminalPrompt] + AC[AtomCache] end subgraph Composed Services @@ -614,6 +639,7 @@ graph LR MAIN --> LIG MAIN --> CL MAIN --> TMP + MAIN --> AC MAIN --> AR MAIN --> SR @@ -624,6 +650,7 @@ graph LR AR -.->|IGitClient| GC AR -.-> TP + AR -.->|IAtomCache| AC SD -.->|IGitClient| GC CB -.-> TP CB -.-> LIG @@ -697,6 +724,7 @@ graph LR AtomRepository( gitClient: IGitClient, trailerParser: TrailerParser, + atomCache: IAtomCache, customTrailerKeys: readonly string[] ) diff --git a/src/commands/cache.ts b/src/commands/cache.ts new file mode 100644 index 00000000..a823e725 --- /dev/null +++ b/src/commands/cache.ts @@ -0,0 +1,39 @@ +import type { Command } from 'commander'; +import type { IOutputFormatter } from '../interfaces/output-formatter.js'; +import { rm } from 'node:fs/promises'; + +/** + * Register the `lore cache` command. + * Provides management utilities for the local sharded cache. + */ +export function registerCacheCommand( + program: Command, + deps: { + getFormatter: () => IOutputFormatter; + cacheDir: string; + }, +): void { + program + .command('cache') + .description('Manage the local Lore cache') + .option('--clean', 'Clear all cached atom and query data') + .action(async (options) => { + const formatter = deps.getFormatter(); + + if (options.clean) { + try { + await rm(deps.cacheDir, { recursive: true, force: true }); + console.log(formatter.formatSuccess('Successfully cleared local cache.')); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + console.error(formatter.formatError(1, [{ severity: 'error', message: `Failed to clear cache: ${message}` }])); + process.exitCode = 1; + return; + } + return; + } + + // If no options are provided, show help + program.commands.find(c => c.name() === 'cache')?.help(); + }); +} diff --git a/src/commands/init.ts b/src/commands/init.ts index ec30e840..887d2a00 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -27,6 +27,7 @@ default_format = "text" max_depth = 3 [cli] +cache = true update_check = true `; @@ -34,6 +35,7 @@ update_check = true * Register the `lore init` command. * Creates .lore/config.toml with default content. * If the config file already exists, prints its content and exits. + * Also ensures .lore/cache is added to .gitignore. */ export function registerInitCommand( program: Command, @@ -64,15 +66,52 @@ export function registerInitCommand( `Config already exists at ${CONFIG_DIR}/${CONFIG_FILENAME}:`, )); console.log(content); - return; - } + } else { + // Create directory and write config + await mkdir(configDir, { recursive: true }); + await writeFile(configPath, DEFAULT_CONFIG_CONTENT, 'utf-8'); - // Create directory and write config - await mkdir(configDir, { recursive: true }); - await writeFile(configPath, DEFAULT_CONFIG_CONTENT, 'utf-8'); + console.log(formatter.formatSuccess( + `Created ${CONFIG_DIR}/${CONFIG_FILENAME} with protocol version 1.0`, + )); + } - console.log(formatter.formatSuccess( - `Created ${CONFIG_DIR}/${CONFIG_FILENAME} with protocol version 1.0`, - )); + // Ensure cache is ignored + await ensureCacheIgnored(formatter); }); } + +/** + * Ensures that .lore/cache is added to the .gitignore in the current directory. + * Uses process.cwd() to match where `lore init` creates .lore/config.toml. + * Idempotent: does nothing if the pattern is already present. + */ +async function ensureCacheIgnored(formatter: IOutputFormatter): Promise { + const gitignorePath = join(process.cwd(), '.gitignore'); + const ignorePattern = '.lore/cache'; + let content = ''; + let exists = false; + + try { + content = await readFile(gitignorePath, 'utf-8'); + exists = true; + } catch { + // File does not exist + } + + const lines = content.split('\n').map((l) => l.trim()); + if (lines.includes(ignorePattern)) { + return; + } + + const suffix = content === '' || content.endsWith('\n') ? '' : '\n'; + const newContent = `${content}${suffix}${ignorePattern}\n`; + + await writeFile(gitignorePath, newContent, 'utf-8'); + + if (exists) { + console.log(formatter.formatSuccess(`Updated .gitignore to ignore ${ignorePattern}`)); + } else { + console.log(formatter.formatSuccess(`Created .gitignore to ignore ${ignorePattern}`)); + } +} diff --git a/src/interfaces/atom-cache.ts b/src/interfaces/atom-cache.ts new file mode 100644 index 00000000..5aab6f1c --- /dev/null +++ b/src/interfaces/atom-cache.ts @@ -0,0 +1,12 @@ +export interface IAtomCache { + /** + * Get cached list of files changed for a commit hash. + * Returns null if not in cache. + */ + getFiles(hash: string): Promise; + + /** + * Cache the list of files changed for a commit hash. + */ + setFiles(hash: string, files: readonly string[]): Promise; +} diff --git a/src/main.ts b/src/main.ts index c027262e..557a7f34 100644 --- a/src/main.ts +++ b/src/main.ts @@ -16,6 +16,7 @@ import { PathResolver } from './services/path-resolver.js'; import { LoreIdGenerator } from './services/lore-id-generator.js'; import { ConfigLoader } from './services/config-loader.js'; import { AtomRepository } from './services/atom-repository.js'; +import { AtomCache, NullAtomCache } from './services/atom-cache.js'; import { SupersessionResolver } from './services/supersession-resolver.js'; import { StalenessDetector } from './services/staleness-detector.js'; import { CommitBuilder } from './services/commit-builder.js'; @@ -26,10 +27,12 @@ import { CommitInputResolver } from './services/commit-input-resolver.js'; import { HeadLoreIdReader } from './services/head-lore-id-reader.js'; import { SearchFilter } from './services/search-filter.js'; +import { join } from 'node:path'; import { TextFormatter } from './formatters/text-formatter.js'; import { JsonFormatter } from './formatters/json-formatter.js'; import { registerInitCommand } from './commands/init.js'; +import { registerCacheCommand } from './commands/cache.js'; import { registerContextCommand } from './commands/context.js'; import { registerConstraintsCommand } from './commands/constraints.js'; import { registerRejectedCommand } from './commands/rejected.js'; @@ -47,6 +50,8 @@ import { registerDoctorCommand } from './commands/doctor.js'; import { LoreError, ValidationError } from './util/errors.js'; import { shouldCheckForUpdate } from './util/update-check.js'; +import { shouldBypassCache } from './util/cache-check.js'; +import { resolveLoreRoot } from './services/root-resolver.js'; /** * Composition root: constructs all dependencies and wires them together. @@ -68,7 +73,9 @@ async function main(): Promise { .option('--json', 'Shorthand for --format json') .option('--format ', 'Output format: text or json', 'text') .option('--no-color', 'Disable colored output') - .option('--no-update-notifier', 'Disable update notification'); + .option('--no-update-notifier', 'Disable update notification') + .option('--no-cache', 'Bypass caching in the CLI'); + // 1. Create concrete implementations const gitClient: IGitClient = new GitClient(); @@ -87,13 +94,31 @@ async function main(): Promise { config = DEFAULT_CONFIG; } - // 3. Update notification (fire-and-forget, respects env vars and config) + // 3. Resolve project root for caching and config + const loreRoot = await resolveLoreRoot(process.cwd(), configLoader, gitClient); + + const cacheDir = join(loreRoot, '.lore', 'cache'); + const atomCacheDir = join(cacheDir, 'atoms'); + + // 4. Update notification (fire-and-forget, respects env vars and config) if (shouldCheckForUpdate(config.cli.updateCheck)) { simpleUpdateNotifier({ pkg }).catch(() => {}); } - // 4. Create services that depend on others - const atomRepository = new AtomRepository(gitClient, trailerParser, config.trailers.custom); + // 5. Determine if caching is bypassed via command line, env, or config + const bypassCache = shouldBypassCache(config.cli.cache); + + const atomCache = bypassCache + ? new NullAtomCache() + : new AtomCache(atomCacheDir); + + // 6. Create services that depend on others + const atomRepository = new AtomRepository( + gitClient, + trailerParser, + atomCache, + config.trailers.custom, + ); const supersessionResolver = new SupersessionResolver(); const stalenessDetector = new StalenessDetector(gitClient, config); const commitBuilder = new CommitBuilder(trailerParser, loreIdGenerator, config); @@ -104,7 +129,7 @@ async function main(): Promise { const commitInputResolver = new CommitInputResolver(prompt); const headLoreIdReader = new HeadLoreIdReader(gitClient, trailerParser); - // 5. Formatter factory (reads --format/--json from program options at call time) + // 7. Formatter factory (reads --format/--json from program options at call time) // Memoized: the formatter is created once on first call and reused thereafter. let cachedFormatter: IOutputFormatter | null = null; const getFormatter = (): IOutputFormatter => { @@ -120,9 +145,10 @@ async function main(): Promise { return cachedFormatter; }; - // 6. Register all commands with their dependencies + // 8. Register all commands with their dependencies registerInitCommand(program, { getFormatter }); + registerCacheCommand(program, { getFormatter, cacheDir }); const pathQueryDeps = { atomRepository, @@ -197,7 +223,7 @@ async function main(): Promise { getFormatter, }); - // 7. Parse and run + // 9. Parse and run await program.parseAsync(process.argv); } diff --git a/src/services/atom-cache.ts b/src/services/atom-cache.ts new file mode 100644 index 00000000..dc2096f9 --- /dev/null +++ b/src/services/atom-cache.ts @@ -0,0 +1,86 @@ +import { readFile, writeFile, mkdir, rename, unlink } from 'node:fs/promises'; +import { join, dirname } from 'node:path'; +import { randomUUID } from 'node:crypto'; +import type { IAtomCache } from '../interfaces/atom-cache.js'; + +const HEX_HASH = /^[0-9a-f]{7,64}$/i; + +export class AtomCache implements IAtomCache { + constructor(private readonly cacheDir: string) {} + + async getFiles(hash: string): Promise { + if (!HEX_HASH.test(hash)) return null; + const path = this.getCachePath(hash); + try { + const content = await readFile(path, 'utf8'); + + // Corruption check: empty file or NUL bytes indicate a failed/partial write. + if (!content || content.includes('\0')) { + return null; + } + + // We use a trailing newline as a sentinel for a valid write. + // If the file is just whitespace (and not exactly a single newline), it's corrupt. + if (content.trim() === '' && content !== '\n') { + return null; + } + + // If it's just a newline, it's a valid empty list (e.g. from --allow-empty commits) + if (content === '\n') { + return []; + } + + // Remove exactly one trailing newline and split + return content.replace(/\n$/, '').split('\n'); + } catch (error: unknown) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return null; + } + throw error; + } + } + + async setFiles(hash: string, files: readonly string[]): Promise { + if (!HEX_HASH.test(hash)) return; + const path = this.getCachePath(hash); + const tempPath = `${path}.tmp.${randomUUID()}`; + + await mkdir(dirname(path), { recursive: true }); + + try { + // Always ensure a newline so empty lists aren't 0-byte files. + // Empty lists occur for commits created with --allow-empty. + // Atomic write: write to unique temp file then rename to final destination. + await writeFile(tempPath, files.join('\n') + '\n', 'utf8'); + await rename(tempPath, path); + } catch (error: unknown) { + // Clean up temp file on failure if it exists + try { + await unlink(tempPath); + } catch { + // Ignore cleanup errors + } + throw error; + } + } + + private getCachePath(hash: string): string { + const shard = hash.substring(0, 2); + const rest = hash.substring(2); + return join(this.cacheDir, shard, rest); + } +} + +/** + * Null Object implementation of IAtomCache. + * Performs no caching; always returns null and ignores writes. + */ +export class NullAtomCache implements IAtomCache { + async getFiles(_hash: string): Promise { + return null; + } + + async setFiles(_hash: string, _files: readonly string[]): Promise { + // No-op + } +} diff --git a/src/services/atom-repository.ts b/src/services/atom-repository.ts index a636ba9c..d96cde0a 100644 --- a/src/services/atom-repository.ts +++ b/src/services/atom-repository.ts @@ -2,6 +2,7 @@ import type { IGitClient, RawCommit } from '../interfaces/git-client.js'; import type { PathQueryOptions } from '../types/query.js'; import type { LoreAtom, LoreId, LoreTrailers } from '../types/domain.js'; import type { TrailerParser } from '../services/trailer-parser.js'; +import type { IAtomCache } from '../interfaces/atom-cache.js'; import { LORE_ID_PATTERN, REFERENCE_TRAILER_KEYS, GIT_FILES_CHANGED_BATCH_SIZE } from '../util/constants.js'; /** @@ -15,6 +16,7 @@ export class AtomRepository { constructor( private readonly gitClient: IGitClient, private readonly trailerParser: TrailerParser, + private readonly atomCache: IAtomCache, private readonly customTrailerKeys: readonly string[] = [], ) {} @@ -207,21 +209,37 @@ export class AtomRepository { loreCommits.push({ raw, trailers }); } - // Second pass: batch getFilesChanged calls with concurrency limit. - // Results accumulate in insertion order, maintaining 1:1 alignment with loreCommits. - const filesPerCommit: (readonly string[])[] = []; - for (let i = 0; i < loreCommits.length; i += GIT_FILES_CHANGED_BATCH_SIZE) { - const batch = loreCommits.slice(i, i + GIT_FILES_CHANGED_BATCH_SIZE); - const batchResults = await Promise.all( - batch.map(({ raw }) => this.gitClient.getFilesChanged(raw.hash)), + // Second pass: get files changed using a 2-stage strategy. + // Stage 1: Fast concurrent cache check for all identified Lore commits. + const filesPerCommit: (readonly string[] | null)[] = await Promise.all( + loreCommits.map(({ raw }) => this.atomCache.getFiles(raw.hash)), + ); + + const misses: Array<{ index: number; hash: string }> = []; + for (let i = 0; i < filesPerCommit.length; i++) { + if (filesPerCommit[i] === null) { + misses.push({ index: i, hash: loreCommits[i].raw.hash }); + } + } + + // Stage 2: Batched Git fetch for cache misses only (respecting concurrency limit). + for (let i = 0; i < misses.length; i += GIT_FILES_CHANGED_BATCH_SIZE) { + const batch = misses.slice(i, i + GIT_FILES_CHANGED_BATCH_SIZE); + await Promise.all( + batch.map(async ({ index, hash }) => { + const files = await this.gitClient.getFilesChanged(hash); + await this.atomCache.setFiles(hash, files); + filesPerCommit[index] = files; + }), ); - filesPerCommit.push(...batchResults); } // Build atoms by pairing parsed trailers with their file lists - const atoms: LoreAtom[] = loreCommits.map(({ raw, trailers }, index) => - this.buildAtom(raw, trailers, filesPerCommit[index]), - ); + const atoms: LoreAtom[] = loreCommits.map(({ raw, trailers }, index) => { + const files = filesPerCommit[index]; + if (!files) throw new Error(`BUG: filesChanged not resolved for commit ${raw.hash}`); + return this.buildAtom(raw, trailers, files); + }); return atoms; } diff --git a/src/services/root-resolver.ts b/src/services/root-resolver.ts new file mode 100644 index 00000000..670f3bf8 --- /dev/null +++ b/src/services/root-resolver.ts @@ -0,0 +1,35 @@ +import { join, dirname } from 'node:path'; +import type { IConfigLoader } from '../interfaces/config-loader.js'; +import type { IGitClient } from '../interfaces/git-client.js'; + +/** + * Resolves the root directory for Lore operations. + * + * Logic: + * 1. Walk up from CWD to find nearest .lore directory. + * 2. If not found, try to find the git repository root. + * 3. Fallback to process.cwd(). + */ +export async function resolveLoreRoot( + cwd: string, + configLoader: IConfigLoader, + gitClient: IGitClient, +): Promise { + try { + const configPath = await configLoader.findConfigPath(cwd); + if (configPath) { + // configPath is /path/to/project/.lore/config.toml + // dirname(configPath) is /path/to/project/.lore + // dirname(dirname(configPath)) is /path/to/project + return dirname(dirname(configPath)); + } + + if (await gitClient.isInsideRepo()) { + return await gitClient.getRepoRoot(); + } + } catch { + // Best-effort; fallback to cwd + } + + return cwd; +} diff --git a/src/types/config.ts b/src/types/config.ts index 14850d1d..c7534f82 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -22,6 +22,7 @@ export interface LoreConfig { readonly maxDepth: number; }; readonly cli: { + readonly cache: boolean; readonly updateCheck: boolean; }; } @@ -33,5 +34,5 @@ export const DEFAULT_CONFIG: LoreConfig = { stale: { olderThan: '6m', driftThreshold: 20 }, output: { defaultFormat: 'text' }, follow: { maxDepth: 3 }, - cli: { updateCheck: true }, + cli: { cache: true, updateCheck: true }, }; diff --git a/src/util/cache-check.ts b/src/util/cache-check.ts new file mode 100644 index 00000000..ce2a9115 --- /dev/null +++ b/src/util/cache-check.ts @@ -0,0 +1,17 @@ +/** + * Determine whether the atom cache should be bypassed. + * Respects command-line flags, environment variables, and project configuration. + * + * Note: reads process.argv directly because this runs before Commander's + * parseAsync() — the cache instance must be decided before command execution. + */ +export function shouldBypassCache(configCache: boolean | undefined): boolean { + if (process.argv.includes('--no-cache')) return true; + + const env = process.env; + if (['1', 'true'].includes(env['LORE_NO_CACHE'] ?? '')) return true; + + if (configCache === false) return true; + + return false; +} diff --git a/tests/unit/cache-bypass.test.ts b/tests/unit/cache-bypass.test.ts new file mode 100644 index 00000000..e1d0c23e --- /dev/null +++ b/tests/unit/cache-bypass.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { shouldBypassCache } from '../../src/util/cache-check.js'; + +describe('shouldBypassCache', () => { + const originalEnv = { ...process.env }; + const originalArgv = [...process.argv]; + + beforeEach(() => { + process.env = { ...originalEnv }; + process.argv = ['node', 'lore', 'log']; + delete process.env['LORE_NO_CACHE']; + }); + + afterEach(() => { + process.env = originalEnv; + process.argv = originalArgv; + }); + + it('returns false by default (caching enabled)', () => { + expect(shouldBypassCache(true)).toBe(false); + expect(shouldBypassCache(undefined)).toBe(false); + }); + + it('bypasses when --no-cache is in argv', () => { + process.argv = ['node', 'lore', 'log', '--no-cache']; + expect(shouldBypassCache(true)).toBe(true); + }); + + it('bypasses when LORE_NO_CACHE is "1"', () => { + process.env['LORE_NO_CACHE'] = '1'; + expect(shouldBypassCache(true)).toBe(true); + }); + + it('bypasses when LORE_NO_CACHE is "true"', () => { + process.env['LORE_NO_CACHE'] = 'true'; + expect(shouldBypassCache(true)).toBe(true); + }); + + it('bypasses when config cache is false', () => { + expect(shouldBypassCache(false)).toBe(true); + }); + + it('returns false when LORE_NO_CACHE is "0" or "false"', () => { + process.env['LORE_NO_CACHE'] = 'false'; + expect(shouldBypassCache(true)).toBe(false); + + process.env['LORE_NO_CACHE'] = '0'; + expect(shouldBypassCache(true)).toBe(false); + }); + + it('bypasses if flag is set even if config says true', () => { + process.argv = ['node', 'lore', 'log', '--no-cache']; + expect(shouldBypassCache(true)).toBe(true); + }); + + it('bypasses if env is set even if config says true', () => { + process.env['LORE_NO_CACHE'] = '1'; + expect(shouldBypassCache(true)).toBe(true); + }); +}); diff --git a/tests/unit/commands/cache.test.ts b/tests/unit/commands/cache.test.ts new file mode 100644 index 00000000..17613e62 --- /dev/null +++ b/tests/unit/commands/cache.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import { Command } from 'commander'; +import { registerCacheCommand } from '../../../src/commands/cache.js'; +import type { IOutputFormatter } from '../../../src/interfaces/output-formatter.js'; +import { rm } from 'node:fs/promises'; +import { join } from 'node:path'; + +vi.mock('node:fs/promises'); + +describe('registerCacheCommand', () => { + let program: Command; + let formatter: IOutputFormatter; + let consoleSpy: any; + + beforeEach(() => { + vi.resetAllMocks(); + consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + formatter = { + formatSuccess: vi.fn((m) => `SUCCESS: ${m}`), + formatError: vi.fn((code, messages) => `ERROR ${code}: ${messages[0].message}`), + } as any; + + program = new Command(); + program.exitOverride(); + registerCacheCommand(program, { + getFormatter: () => formatter, + cacheDir: join(process.cwd(), '.lore', 'cache'), + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('removes the cache directory when --clean is provided', async () => { + vi.mocked(rm).mockResolvedValue(undefined); + + await program.parseAsync(['node', 'lore', 'cache', '--clean']); + + expect(rm).toHaveBeenCalledWith( + expect.stringContaining(join('.lore', 'cache')), + { recursive: true, force: true } + ); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Successfully cleared local cache.')); + }); + + it('handles errors during cache removal', async () => { + const error = new Error('Permission denied'); + vi.mocked(rm).mockRejectedValue(error); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const initialExitCode = process.exitCode; + + await program.parseAsync(['node', 'lore', 'cache', '--clean']); + + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Failed to clear cache: Permission denied')); + expect(process.exitCode).toBe(1); + + // Reset exitCode for other tests/runs + process.exitCode = initialExitCode; + }); +}); diff --git a/tests/unit/commands/init.test.ts b/tests/unit/commands/init.test.ts new file mode 100644 index 00000000..ed652619 --- /dev/null +++ b/tests/unit/commands/init.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import { Command } from 'commander'; +import { registerInitCommand } from '../../../src/commands/init.js'; +import type { IOutputFormatter } from '../../../src/interfaces/output-formatter.js'; +import { mkdir, readFile, writeFile, access } from 'node:fs/promises'; +import { join } from 'node:path'; + +vi.mock('node:fs/promises'); + +describe('registerInitCommand', () => { + let program: Command; + let formatter: IOutputFormatter; + let consoleSpy: any; + + beforeEach(() => { + vi.resetAllMocks(); + consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + formatter = { + formatSuccess: vi.fn((m) => `SUCCESS: ${m}`), + formatError: vi.fn((m) => `ERROR: ${m}`), + } as any; + + program = new Command(); + program.exitOverride(); + registerInitCommand(program, { getFormatter: () => formatter }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('creates .lore/config.toml and .gitignore when they do not exist', async () => { + // access throws for all files + vi.mocked(access).mockRejectedValue(new Error('ENOENT')); + vi.mocked(readFile).mockRejectedValue(new Error('ENOENT')); + vi.mocked(mkdir).mockResolvedValue(undefined); + vi.mocked(writeFile).mockResolvedValue(undefined); + + await program.parseAsync(['node', 'lore', 'init']); + + // Check directory creation + expect(mkdir).toHaveBeenCalledWith(expect.stringContaining('.lore'), { recursive: true }); + + // Check config creation + expect(writeFile).toHaveBeenCalledWith( + expect.stringContaining(join('.lore', 'config.toml')), + expect.stringContaining('version = "1.0"'), + 'utf-8' + ); + + // Check .gitignore creation + expect(writeFile).toHaveBeenCalledWith( + expect.stringContaining('.gitignore'), + '.lore/cache\n', + 'utf-8' + ); + + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Created .lore/config.toml')); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Created .gitignore to ignore .lore/cache')); + }); + + it('updates existing .gitignore if .lore/cache is missing', async () => { + // Config exists, but .gitignore exists without the pattern + vi.mocked(access).mockResolvedValue(undefined); // config exists + vi.mocked(readFile).mockImplementation(async (path: any) => { + if (path.toString().endsWith('.gitignore')) return 'node_modules\n'; + return 'existing config content'; + }); + vi.mocked(writeFile).mockResolvedValue(undefined); + + await program.parseAsync(['node', 'lore', 'init']); + + expect(writeFile).toHaveBeenCalledWith( + expect.stringContaining('.gitignore'), + 'node_modules\n.lore/cache\n', + 'utf-8' + ); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Updated .gitignore to ignore .lore/cache')); + }); + + it('does not update .gitignore if .lore/cache is already present', async () => { + vi.mocked(access).mockResolvedValue(undefined); + vi.mocked(readFile).mockImplementation(async (path: any) => { + if (path.toString().endsWith('.gitignore')) return 'node_modules\n.lore/cache\n'; + return 'existing config'; + }); + + await program.parseAsync(['node', 'lore', 'init']); + + // writeFile should NOT be called for .gitignore + const gitignoreCalls = vi.mocked(writeFile).mock.calls.filter(call => call[0].toString().endsWith('.gitignore')); + expect(gitignoreCalls).toHaveLength(0); + }); + + it('handles existing .gitignore without a trailing newline', async () => { + vi.mocked(access).mockResolvedValue(undefined); + vi.mocked(readFile).mockImplementation(async (path: any) => { + if (path.toString().endsWith('.gitignore')) return 'node_modules'; // No newline + return 'existing config'; + }); + + await program.parseAsync(['node', 'lore', 'init']); + + expect(writeFile).toHaveBeenCalledWith( + expect.stringContaining('.gitignore'), + 'node_modules\n.lore/cache\n', + 'utf-8' + ); + }); +}); diff --git a/tests/unit/root-resolver.test.ts b/tests/unit/root-resolver.test.ts new file mode 100644 index 00000000..6f06d99a --- /dev/null +++ b/tests/unit/root-resolver.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { resolveLoreRoot } from '../../src/services/root-resolver.js'; +import type { IConfigLoader } from '../../src/interfaces/config-loader.js'; +import type { IGitClient } from '../../src/interfaces/git-client.js'; +import { join } from 'node:path'; + +describe('resolveLoreRoot', () => { + const mockConfigLoader = { + findConfigPath: vi.fn(), + } as unknown as IConfigLoader; + + const mockGitClient = { + isInsideRepo: vi.fn(), + getRepoRoot: vi.fn(), + } as unknown as IGitClient; + + const cwd = '/users/dev/project/src'; + + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('returns the parent of .lore directory if config is found', async () => { + const configPath = '/users/dev/project/.lore/config.toml'; + vi.mocked(mockConfigLoader.findConfigPath).mockResolvedValue(configPath); + + const root = await resolveLoreRoot(cwd, mockConfigLoader, mockGitClient); + + expect(root).toBe('/users/dev/project'); + expect(mockConfigLoader.findConfigPath).toHaveBeenCalledWith(cwd); + }); + + it('returns git root if no .lore is found but inside a repo', async () => { + vi.mocked(mockConfigLoader.findConfigPath).mockResolvedValue(null); + vi.mocked(mockGitClient.isInsideRepo).mockResolvedValue(true); + vi.mocked(mockGitClient.getRepoRoot).mockResolvedValue('/users/dev/project'); + + const root = await resolveLoreRoot(cwd, mockConfigLoader, mockGitClient); + + expect(root).toBe('/users/dev/project'); + }); + + it('falls back to cwd if neither .lore nor git root is found', async () => { + vi.mocked(mockConfigLoader.findConfigPath).mockResolvedValue(null); + vi.mocked(mockGitClient.isInsideRepo).mockResolvedValue(false); + + const root = await resolveLoreRoot(cwd, mockConfigLoader, mockGitClient); + + expect(root).toBe(cwd); + }); + + it('falls back to cwd if an error occurs during resolution', async () => { + vi.mocked(mockConfigLoader.findConfigPath).mockRejectedValue(new Error('FS Error')); + + const root = await resolveLoreRoot(cwd, mockConfigLoader, mockGitClient); + + expect(root).toBe(cwd); + }); + + it('prioritizes .lore directory over git root', async () => { + // Scenario: A project inside a larger git repo, but with its own .lore + const configPath = '/users/dev/project/sub-project/.lore/config.toml'; + vi.mocked(mockConfigLoader.findConfigPath).mockResolvedValue(configPath); + vi.mocked(mockGitClient.isInsideRepo).mockResolvedValue(true); + vi.mocked(mockGitClient.getRepoRoot).mockResolvedValue('/users/dev/project'); + + const root = await resolveLoreRoot(cwd, mockConfigLoader, mockGitClient); + + expect(root).toBe('/users/dev/project/sub-project'); + }); +}); diff --git a/tests/unit/services/atom-cache.test.ts b/tests/unit/services/atom-cache.test.ts new file mode 100644 index 00000000..a399f19b --- /dev/null +++ b/tests/unit/services/atom-cache.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { AtomCache, NullAtomCache } from '../../../src/services/atom-cache.js'; +import { rm, mkdir, access, writeFile } from 'node:fs/promises'; +import { join, dirname } from 'node:path'; + +describe('AtomCache', () => { + const cacheDir = join(process.cwd(), '.lore-test-cache'); + + beforeEach(async () => { + await mkdir(cacheDir, { recursive: true }); + }); + + afterEach(async () => { + await rm(cacheDir, { recursive: true, force: true }); + }); + + it('should return null for miss', async () => { + const cache = new AtomCache(cacheDir); + expect(await cache.getFiles('abc123')).toBeNull(); + }); + + it('should round-trip files without .txt extension', async () => { + const cache = new AtomCache(cacheDir); + const files = ['src/a.ts', 'src/b.ts']; + const hash = 'abc123456789'; + + await cache.setFiles(hash, files); + const result = await cache.getFiles(hash); + + expect(result).toEqual(files); + + // Verify file exists at sharded path without .txt + const shard = hash.substring(0, 2); + const rest = hash.substring(2); + const expectedPath = join(cacheDir, shard, rest); + await expect(access(expectedPath)).resolves.toBeUndefined(); + }); + + it('should handle empty file list', async () => { + const cache = new AtomCache(cacheDir); + const hash = 'aabbccdd11223344'; + await cache.setFiles(hash, []); + expect(await cache.getFiles(hash)).toEqual([]); + }); + + it('should reject invalid hashes', async () => { + const cache = new AtomCache(cacheDir); + expect(await cache.getFiles('../../../etc/passwd')).toBeNull(); + expect(await cache.getFiles('not-a-hash!')).toBeNull(); + }); + + it('should return null for corrupted cache files', async () => { + const cache = new AtomCache(cacheDir); + const hash = 'corrupt123'; + const shard = hash.substring(0, 2); + const rest = hash.substring(2); + const path = join(cacheDir, shard, rest); + + await mkdir(dirname(path), { recursive: true }); + + // Case 1: NUL bytes + await writeFile(path, Buffer.from([0, 1, 2, 3])); + expect(await cache.getFiles(hash)).toBeNull(); + + // Case 2: Empty file + await writeFile(path, ''); + expect(await cache.getFiles(hash)).toBeNull(); + + // Case 3: Only whitespace + await writeFile(path, ' \n '); + expect(await cache.getFiles(hash)).toBeNull(); + }); + + it('should recover from and overwrite corrupted cache files', async () => { + const cache = new AtomCache(cacheDir); + const hash = 'abc12345'; + const shard = hash.substring(0, 2); + const rest = hash.substring(2); + const path = join(cacheDir, shard, rest); + + await mkdir(dirname(path), { recursive: true }); + + // 1. Create a corrupted file (with NUL bytes) + await writeFile(path, Buffer.from([0, 0, 0])); + + // 2. Verify it's detected as a miss (returns null) + expect(await cache.getFiles(hash)).toBeNull(); + + // 3. Overwrite it with valid data + const validFiles = ['file1.ts', 'file2.ts']; + await cache.setFiles(hash, validFiles); + + // 4. Verify it's now a hit with the correct data + expect(await cache.getFiles(hash)).toEqual(validFiles); + }); +}); + +describe('NullAtomCache', () => { + it('should always return null and ignore sets', async () => { + const cache = new NullAtomCache(); + await cache.setFiles('hash', ['file.txt']); + expect(await cache.getFiles('hash')).toBeNull(); + }); +}); diff --git a/tests/unit/services/atom-repository.test.ts b/tests/unit/services/atom-repository.test.ts index 45d502f7..35876f57 100644 --- a/tests/unit/services/atom-repository.test.ts +++ b/tests/unit/services/atom-repository.test.ts @@ -3,6 +3,7 @@ import { AtomRepository } from '../../../src/services/atom-repository.js'; import type { IGitClient, RawCommit } from '../../../src/interfaces/git-client.js'; import type { PathQueryOptions } from '../../../src/types/query.js'; import type { LoreTrailers } from '../../../src/types/domain.js'; +import type { IAtomCache } from '../../../src/interfaces/atom-cache.js'; import { CustomTrailerCollection } from '../../../src/types/custom-trailer-collection.js'; /** @@ -20,6 +21,13 @@ function createMockTrailerParser() { }; } +function createMockCache(): IAtomCache { + return { + getFiles: vi.fn(async () => null), + setFiles: vi.fn(async () => {}), + }; +} + /** * Simple trailer text parser for tests. * Extracts key: value pairs from a multi-line trailer block. @@ -134,12 +142,14 @@ function makeQueryOptions(overrides: Partial = {}): PathQueryO describe('AtomRepository', () => { let gitClient: IGitClient; let trailerParser: ReturnType; + let cache: IAtomCache; let repo: AtomRepository; beforeEach(() => { gitClient = createMockGitClient(); trailerParser = createMockTrailerParser(); - repo = new AtomRepository(gitClient, trailerParser as any); + cache = createMockCache(); + repo = new AtomRepository(gitClient, trailerParser as any, cache); }); describe('findByTarget', () => { @@ -665,5 +675,29 @@ describe('AtomRepository', () => { repo.findByTarget(makeGitLogArgs(), makeQueryOptions()), ).rejects.toThrow('git failed'); }); + + it('should use and update cache', async () => { + const commit = makeLoreCommit({ hash: 'abc', loreId: 'aaaa1111' }); + vi.mocked(gitClient.log).mockResolvedValue([commit]); + vi.mocked(cache.getFiles).mockResolvedValue(null); + vi.mocked(gitClient.getFilesChanged).mockResolvedValue(['file.ts']); + + await repo.findByTarget(makeGitLogArgs(), makeQueryOptions()); + + expect(cache.getFiles).toHaveBeenCalledWith('abc'); + expect(gitClient.getFilesChanged).toHaveBeenCalledWith('abc'); + expect(cache.setFiles).toHaveBeenCalledWith('abc', ['file.ts']); + }); + + it('should return cached files without calling git when cache hits', async () => { + const commit = makeLoreCommit({ hash: 'abc', loreId: 'aaaa1111' }); + vi.mocked(gitClient.log).mockResolvedValue([commit]); + vi.mocked(cache.getFiles).mockResolvedValue(['cached.ts']); + + const result = await repo.findByTarget(makeGitLogArgs(), makeQueryOptions()); + + expect(result[0].filesChanged).toEqual(['cached.ts']); + expect(gitClient.getFilesChanged).not.toHaveBeenCalled(); + }); }); });