tokenlean 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cache.mjs ADDED
@@ -0,0 +1,493 @@
1
+ /**
2
+ * Shared caching system for tokenlean CLI tools
3
+ *
4
+ * Provides disk-based caching with git-based invalidation for expensive
5
+ * ripgrep operations. Falls back to TTL-based invalidation when not in a git repo.
6
+ *
7
+ * Cache storage: ~/.tokenlean/cache/<project-hash>/<key-hash>.json
8
+ *
9
+ * Usage:
10
+ * // High-level API (preferred)
11
+ * const result = withCache(
12
+ * { op: 'rg-search', pattern: 'useState', glob: '*.tsx' },
13
+ * () => execSync('rg ...'),
14
+ * { projectRoot }
15
+ * );
16
+ *
17
+ * // Low-level API
18
+ * let data = getCached(key, projectRoot);
19
+ * if (!data) {
20
+ * data = computeExpensiveResult();
21
+ * setCached(key, data, projectRoot);
22
+ * }
23
+ */
24
+
25
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync, unlinkSync, rmSync } from 'fs';
26
+ import { join, dirname } from 'path';
27
+ import { homedir } from 'os';
28
+ import { execSync } from 'child_process';
29
+ import { createHash } from 'crypto';
30
+ import { loadConfig } from './config.mjs';
31
+
32
+ // ─────────────────────────────────────────────────────────────
33
+ // Constants
34
+ // ─────────────────────────────────────────────────────────────
35
+
36
+ const DEFAULT_CACHE_DIR = join(homedir(), '.tokenlean', 'cache');
37
+ const DEFAULT_TTL = 300; // 5 minutes fallback for non-git repos
38
+ const DEFAULT_MAX_SIZE = 100 * 1024 * 1024; // 100MB
39
+
40
+ // ─────────────────────────────────────────────────────────────
41
+ // Configuration
42
+ // ─────────────────────────────────────────────────────────────
43
+
44
+ /**
45
+ * Get cache configuration from config system
46
+ */
47
+ export function getCacheConfig() {
48
+ const { config } = loadConfig();
49
+ const cacheConfig = config.cache || {};
50
+
51
+ return {
52
+ enabled: cacheConfig.enabled !== false && process.env.TOKENLEAN_CACHE !== '0',
53
+ ttl: cacheConfig.ttl ?? DEFAULT_TTL,
54
+ maxSize: parseSize(cacheConfig.maxSize) ?? DEFAULT_MAX_SIZE,
55
+ location: cacheConfig.location ?? DEFAULT_CACHE_DIR
56
+ };
57
+ }
58
+
59
+ /**
60
+ * Parse size string like '100MB' to bytes
61
+ */
62
+ function parseSize(size) {
63
+ if (typeof size === 'number') return size;
64
+ if (typeof size !== 'string') return null;
65
+
66
+ const match = size.match(/^(\d+(?:\.\d+)?)\s*(B|KB|MB|GB)?$/i);
67
+ if (!match) return null;
68
+
69
+ const num = parseFloat(match[1]);
70
+ const unit = (match[2] || 'B').toUpperCase();
71
+
72
+ const multipliers = {
73
+ 'B': 1,
74
+ 'KB': 1024,
75
+ 'MB': 1024 * 1024,
76
+ 'GB': 1024 * 1024 * 1024
77
+ };
78
+
79
+ return Math.floor(num * multipliers[unit]);
80
+ }
81
+
82
+ // ─────────────────────────────────────────────────────────────
83
+ // Hashing Utilities
84
+ // ─────────────────────────────────────────────────────────────
85
+
86
+ /**
87
+ * Create a short hash from any value
88
+ */
89
+ function hash(value) {
90
+ const str = typeof value === 'string' ? value : JSON.stringify(value);
91
+ return createHash('sha256').update(str).digest('hex').slice(0, 16);
92
+ }
93
+
94
+ /**
95
+ * Get hash of project root path for cache directory
96
+ */
97
+ function getProjectHash(projectRoot) {
98
+ return hash(projectRoot);
99
+ }
100
+
101
+ /**
102
+ * Get cache key hash from operation key object
103
+ */
104
+ function getCacheKeyHash(key) {
105
+ return hash(key);
106
+ }
107
+
108
+ // ─────────────────────────────────────────────────────────────
109
+ // Git State Detection
110
+ // ─────────────────────────────────────────────────────────────
111
+
112
+ /**
113
+ * Check if directory is a git repository
114
+ */
115
+ function isGitRepo(dir) {
116
+ try {
117
+ execSync('git rev-parse --git-dir', { cwd: dir, stdio: 'ignore' });
118
+ return true;
119
+ } catch {
120
+ return false;
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Get current git state (HEAD commit + dirty files)
126
+ * Returns null if not in a git repo
127
+ */
128
+ export function getGitState(projectRoot) {
129
+ if (!isGitRepo(projectRoot)) {
130
+ return null;
131
+ }
132
+
133
+ try {
134
+ // Get HEAD commit
135
+ const head = execSync('git rev-parse HEAD', {
136
+ cwd: projectRoot,
137
+ encoding: 'utf-8'
138
+ }).trim();
139
+
140
+ // Get list of modified/untracked files (sorted for consistency)
141
+ const status = execSync('git status --porcelain', {
142
+ cwd: projectRoot,
143
+ encoding: 'utf-8'
144
+ });
145
+
146
+ const dirtyFiles = status
147
+ .split('\n')
148
+ .filter(line => line.trim())
149
+ .map(line => line.slice(3)) // Remove status prefix
150
+ .sort();
151
+
152
+ return {
153
+ head,
154
+ dirtyFiles
155
+ };
156
+ } catch {
157
+ return null;
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Check if git state matches stored state
163
+ */
164
+ function gitStateMatches(stored, current) {
165
+ if (!stored || !current) return false;
166
+ if (stored.head !== current.head) return false;
167
+ if (stored.dirtyFiles.length !== current.dirtyFiles.length) return false;
168
+
169
+ for (let i = 0; i < stored.dirtyFiles.length; i++) {
170
+ if (stored.dirtyFiles[i] !== current.dirtyFiles[i]) return false;
171
+ }
172
+
173
+ return true;
174
+ }
175
+
176
+ // ─────────────────────────────────────────────────────────────
177
+ // Cache Directory Management
178
+ // ─────────────────────────────────────────────────────────────
179
+
180
+ /**
181
+ * Get or create cache directory for a project
182
+ */
183
+ export function getCacheDir(projectRoot) {
184
+ const config = getCacheConfig();
185
+ const projectHash = getProjectHash(projectRoot);
186
+ const cacheDir = join(config.location, projectHash);
187
+
188
+ if (!existsSync(cacheDir)) {
189
+ mkdirSync(cacheDir, { recursive: true });
190
+ }
191
+
192
+ return cacheDir;
193
+ }
194
+
195
+ /**
196
+ * Get cache file path for a key
197
+ */
198
+ function getCacheFilePath(key, projectRoot) {
199
+ const cacheDir = getCacheDir(projectRoot);
200
+ const keyHash = getCacheKeyHash(key);
201
+ return join(cacheDir, `${keyHash}.json`);
202
+ }
203
+
204
+ // ─────────────────────────────────────────────────────────────
205
+ // Cache Size Management
206
+ // ─────────────────────────────────────────────────────────────
207
+
208
+ /**
209
+ * Get total size of cache directory in bytes
210
+ */
211
+ function getCacheDirSize(cacheDir) {
212
+ if (!existsSync(cacheDir)) return 0;
213
+
214
+ let total = 0;
215
+ try {
216
+ const files = readdirSync(cacheDir);
217
+ for (const file of files) {
218
+ try {
219
+ const stat = statSync(join(cacheDir, file));
220
+ if (stat.isFile()) {
221
+ total += stat.size;
222
+ }
223
+ } catch { /* skip unreadable files */ }
224
+ }
225
+ } catch { /* directory read error */ }
226
+
227
+ return total;
228
+ }
229
+
230
+ /**
231
+ * Get all cache entries with metadata
232
+ */
233
+ function getCacheEntries(cacheDir) {
234
+ if (!existsSync(cacheDir)) return [];
235
+
236
+ const entries = [];
237
+ try {
238
+ const files = readdirSync(cacheDir).filter(f => f.endsWith('.json'));
239
+ for (const file of files) {
240
+ try {
241
+ const filePath = join(cacheDir, file);
242
+ const stat = statSync(filePath);
243
+ entries.push({
244
+ path: filePath,
245
+ size: stat.size,
246
+ mtime: stat.mtime.getTime()
247
+ });
248
+ } catch { /* skip unreadable files */ }
249
+ }
250
+ } catch { /* directory read error */ }
251
+
252
+ return entries;
253
+ }
254
+
255
+ /**
256
+ * Remove oldest cache entries until under maxSize
257
+ */
258
+ function enforceMaxSize(projectRoot) {
259
+ const config = getCacheConfig();
260
+ const cacheDir = getCacheDir(projectRoot);
261
+
262
+ const entries = getCacheEntries(cacheDir);
263
+ let totalSize = entries.reduce((sum, e) => sum + e.size, 0);
264
+
265
+ if (totalSize <= config.maxSize) return;
266
+
267
+ // Sort by modification time (oldest first)
268
+ entries.sort((a, b) => a.mtime - b.mtime);
269
+
270
+ // Remove oldest entries until under limit
271
+ for (const entry of entries) {
272
+ if (totalSize <= config.maxSize) break;
273
+
274
+ try {
275
+ unlinkSync(entry.path);
276
+ totalSize -= entry.size;
277
+ } catch { /* skip if can't delete */ }
278
+ }
279
+ }
280
+
281
+ // ─────────────────────────────────────────────────────────────
282
+ // Low-Level Cache API
283
+ // ─────────────────────────────────────────────────────────────
284
+
285
+ /**
286
+ * Read from cache if valid
287
+ * Returns cached data or null if cache miss/invalid
288
+ */
289
+ export function getCached(key, projectRoot) {
290
+ const config = getCacheConfig();
291
+ if (!config.enabled) return null;
292
+
293
+ const filePath = getCacheFilePath(key, projectRoot);
294
+ if (!existsSync(filePath)) return null;
295
+
296
+ try {
297
+ const cached = JSON.parse(readFileSync(filePath, 'utf-8'));
298
+
299
+ // Git-based invalidation
300
+ const currentGitState = getGitState(projectRoot);
301
+ if (currentGitState) {
302
+ // If we have git state, use it for validation
303
+ if (!gitStateMatches(cached.gitState, currentGitState)) {
304
+ return null;
305
+ }
306
+ } else {
307
+ // Fall back to TTL-based invalidation
308
+ const age = (Date.now() - cached.timestamp) / 1000;
309
+ if (age > config.ttl) {
310
+ return null;
311
+ }
312
+ }
313
+
314
+ return cached.data;
315
+ } catch {
316
+ return null;
317
+ }
318
+ }
319
+
320
+ /**
321
+ * Write to cache with git state
322
+ */
323
+ export function setCached(key, data, projectRoot) {
324
+ const config = getCacheConfig();
325
+ if (!config.enabled) return;
326
+
327
+ const filePath = getCacheFilePath(key, projectRoot);
328
+ const gitState = getGitState(projectRoot);
329
+
330
+ const cacheEntry = {
331
+ data,
332
+ gitState,
333
+ timestamp: Date.now(),
334
+ key: typeof key === 'string' ? key : JSON.stringify(key)
335
+ };
336
+
337
+ try {
338
+ // Ensure directory exists
339
+ const dir = dirname(filePath);
340
+ if (!existsSync(dir)) {
341
+ mkdirSync(dir, { recursive: true });
342
+ }
343
+
344
+ writeFileSync(filePath, JSON.stringify(cacheEntry));
345
+
346
+ // Enforce size limit
347
+ enforceMaxSize(projectRoot);
348
+ } catch {
349
+ // Silently fail - caching is best-effort
350
+ }
351
+ }
352
+
353
+ // ─────────────────────────────────────────────────────────────
354
+ // High-Level Cache API
355
+ // ─────────────────────────────────────────────────────────────
356
+
357
+ /**
358
+ * Execute function with caching
359
+ * Preferred API for caching expensive operations
360
+ *
361
+ * @param {Object|string} key - Cache key (operation + args)
362
+ * @param {Function} fn - Function to execute if cache miss
363
+ * @param {Object} options - Options including projectRoot
364
+ * @returns {*} Cached or computed result
365
+ */
366
+ export function withCache(key, fn, options = {}) {
367
+ const { projectRoot = process.cwd() } = options;
368
+
369
+ // Check cache first
370
+ const cached = getCached(key, projectRoot);
371
+ if (cached !== null) {
372
+ return cached;
373
+ }
374
+
375
+ // Execute function and cache result
376
+ const result = fn();
377
+ setCached(key, result, projectRoot);
378
+
379
+ return result;
380
+ }
381
+
382
+ // ─────────────────────────────────────────────────────────────
383
+ // Cache Management Utilities
384
+ // ─────────────────────────────────────────────────────────────
385
+
386
+ /**
387
+ * Clear cache for a project or all projects
388
+ * @param {string|null} projectRoot - Project to clear, or null for all
389
+ */
390
+ export function clearCache(projectRoot = null) {
391
+ const config = getCacheConfig();
392
+
393
+ if (projectRoot) {
394
+ // Clear single project cache
395
+ const cacheDir = getCacheDir(projectRoot);
396
+ if (existsSync(cacheDir)) {
397
+ try {
398
+ rmSync(cacheDir, { recursive: true });
399
+ } catch { /* ignore errors */ }
400
+ }
401
+ } else {
402
+ // Clear all caches
403
+ if (existsSync(config.location)) {
404
+ try {
405
+ rmSync(config.location, { recursive: true });
406
+ } catch { /* ignore errors */ }
407
+ }
408
+ }
409
+ }
410
+
411
+ /**
412
+ * Get cache statistics
413
+ */
414
+ export function getCacheStats(projectRoot = null) {
415
+ const config = getCacheConfig();
416
+
417
+ if (projectRoot) {
418
+ // Stats for single project
419
+ const cacheDir = getCacheDir(projectRoot);
420
+ const entries = getCacheEntries(cacheDir);
421
+ const totalSize = entries.reduce((sum, e) => sum + e.size, 0);
422
+
423
+ return {
424
+ enabled: config.enabled,
425
+ location: cacheDir,
426
+ entries: entries.length,
427
+ size: totalSize,
428
+ sizeFormatted: formatSize(totalSize),
429
+ maxSize: config.maxSize,
430
+ maxSizeFormatted: formatSize(config.maxSize)
431
+ };
432
+ }
433
+
434
+ // Stats for all projects
435
+ if (!existsSync(config.location)) {
436
+ return {
437
+ enabled: config.enabled,
438
+ location: config.location,
439
+ projects: 0,
440
+ totalEntries: 0,
441
+ totalSize: 0,
442
+ totalSizeFormatted: '0 B',
443
+ maxSize: config.maxSize,
444
+ maxSizeFormatted: formatSize(config.maxSize)
445
+ };
446
+ }
447
+
448
+ let totalEntries = 0;
449
+ let totalSize = 0;
450
+ let projects = 0;
451
+
452
+ try {
453
+ const projectDirs = readdirSync(config.location);
454
+ for (const dir of projectDirs) {
455
+ const projectDir = join(config.location, dir);
456
+ try {
457
+ if (statSync(projectDir).isDirectory()) {
458
+ projects++;
459
+ const entries = getCacheEntries(projectDir);
460
+ totalEntries += entries.length;
461
+ totalSize += entries.reduce((sum, e) => sum + e.size, 0);
462
+ }
463
+ } catch { /* skip */ }
464
+ }
465
+ } catch { /* location doesn't exist yet */ }
466
+
467
+ return {
468
+ enabled: config.enabled,
469
+ location: config.location,
470
+ projects,
471
+ totalEntries,
472
+ totalSize,
473
+ totalSizeFormatted: formatSize(totalSize),
474
+ maxSize: config.maxSize,
475
+ maxSizeFormatted: formatSize(config.maxSize)
476
+ };
477
+ }
478
+
479
+ /**
480
+ * Format bytes to human readable
481
+ */
482
+ function formatSize(bytes) {
483
+ if (bytes >= 1024 * 1024 * 1024) {
484
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
485
+ }
486
+ if (bytes >= 1024 * 1024) {
487
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
488
+ }
489
+ if (bytes >= 1024) {
490
+ return `${(bytes / 1024).toFixed(1)} KB`;
491
+ }
492
+ return `${bytes} B`;
493
+ }
package/src/config.mjs CHANGED
@@ -81,6 +81,12 @@ const DEFAULT_CONFIG = {
81
81
  },
82
82
  impact: {
83
83
  depth: 2
84
+ },
85
+ cache: {
86
+ enabled: true, // Enable/disable caching
87
+ ttl: 300, // Max age in seconds (fallback for non-git)
88
+ maxSize: '100MB', // Max cache directory size
89
+ location: null // Override ~/.tokenlean/cache
84
90
  }
85
91
  };
86
92