ctxman 3.1.0 → 3.2.7

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.
Files changed (60) hide show
  1. package/.ctxman/llm-profiles.json +6 -6
  2. package/README.md +17 -10
  3. package/bin/cli.js +107 -8
  4. package/bin/cm-ask.js +74 -70
  5. package/bin/cm-audit.js +80 -7
  6. package/bin/cm-diagnose.js +199 -0
  7. package/bin/cm-focus.js +322 -0
  8. package/bin/cm-map.js +71 -18
  9. package/bin/cm-pack.js +36 -5
  10. package/bin/cm-policy.js +107 -2
  11. package/bin/cm-pr.js +18 -3
  12. package/bin/cm-search.js +84 -19
  13. package/bin/cm-task.js +192 -0
  14. package/bin/cm-who.js +119 -0
  15. package/bin/cm-why.js +41 -5
  16. package/examples/github-actions/ctxman-check.yml +1 -1
  17. package/index.d.ts +931 -1
  18. package/index.js +105 -1
  19. package/lib/analyzers/performance-dashboard.js +7 -3
  20. package/lib/analyzers/token-calculator.js +76 -4
  21. package/lib/api/mcp/server.js +713 -14
  22. package/lib/cache/CacheManager.js +161 -47
  23. package/lib/core/Analyzer.js +61 -5
  24. package/lib/diagnose/index.js +712 -0
  25. package/lib/focus/index.js +790 -0
  26. package/lib/map/call-graph.js +570 -0
  27. package/lib/map/graph.js +30 -5
  28. package/lib/map/index.js +12 -4
  29. package/lib/map/rank.js +45 -7
  30. package/lib/map/render.js +89 -28
  31. package/lib/pack/index.js +20 -1
  32. package/lib/pack/select.js +8 -1
  33. package/lib/parsers/gitignore-parser.js +4 -1
  34. package/lib/policy/audit.js +22 -2
  35. package/lib/policy/index.js +6 -0
  36. package/lib/policy/workspace.js +254 -0
  37. package/lib/pr/pr-context.js +292 -31
  38. package/lib/pr/pr-renderer.js +25 -10
  39. package/lib/rag/EmbeddingProvider.js +139 -2
  40. package/lib/rag/VectorStoreFactory.js +10 -4
  41. package/lib/rag/ask.js +291 -0
  42. package/lib/rag/sources/FileDataSource.js +6 -3
  43. package/lib/rag/stores/SqliteVectorStore.js +595 -0
  44. package/lib/search/hybrid.js +324 -0
  45. package/lib/search/symbols.js +75 -11
  46. package/lib/task/index.js +630 -0
  47. package/lib/utils/file-utils.js +1 -0
  48. package/lib/utils/logger.js +5 -2
  49. package/lib/utils/multi-repo-manager.js +4 -1
  50. package/lib/watch/FileWatcher.js +121 -31
  51. package/lib/watch/IncrementalAnalyzer.js +964 -16
  52. package/lib/who/index.js +893 -0
  53. package/lib/why/analyzer-bridge.js +223 -0
  54. package/lib/why/discussions.js +314 -0
  55. package/lib/why/index.js +356 -30
  56. package/lib/why/render.js +154 -9
  57. package/lib/wizards/config-generator.js +20 -18
  58. package/lib/wizards/project-detector.js +2 -1
  59. package/lib/workspace/index.js +1274 -0
  60. package/package.json +2 -2
@@ -4,8 +4,8 @@
4
4
  *
5
5
  * Responsibilities:
6
6
  * - Cache analysis results
7
- * - Invalidate on file changes
8
- * - Support multiple storage backends
7
+ * - Invalidate on file changes (mtimeMs, byte size, analysis options, tokenizer encoding)
8
+ * - Support multiple storage backends (memory, disk)
9
9
  * - TTL-based expiration
10
10
  */
11
11
 
@@ -16,6 +16,22 @@ import { getLogger } from '../utils/logger.js';
16
16
 
17
17
  const logger = getLogger('CacheManager');
18
18
 
19
+ /**
20
+ * Normalize a Date or numeric timestamp to milliseconds, or null when missing
21
+ * @param {Date|number|undefined|null} value
22
+ * @returns {number|null}
23
+ */
24
+ function normalizeMtime(value) {
25
+ if (value instanceof Date) {
26
+ const ms = value.getTime();
27
+ return Number.isFinite(ms) ? ms : null;
28
+ }
29
+ if (typeof value === 'number' && Number.isFinite(value)) {
30
+ return value;
31
+ }
32
+ return null;
33
+ }
34
+
19
35
  export class CacheManager {
20
36
  constructor(options = {}) {
21
37
  this.options = {
@@ -23,6 +39,7 @@ export class CacheManager {
23
39
  strategy: 'disk', // disk, memory
24
40
  ttl: 3600, // 1 hour in seconds
25
41
  path: '.context-cache',
42
+ maxEntries: 5000,
26
43
  maxSize: 100 * 1024 * 1024, // 100MB
27
44
  ...options,
28
45
  };
@@ -36,8 +53,8 @@ export class CacheManager {
36
53
  errors: 0,
37
54
  };
38
55
 
39
- // Initialize cache directory
40
- if (this.options.strategy === 'disk') {
56
+ // Initialize cache directory when enabled on disk
57
+ if (this.options.enabled && this.options.strategy === 'disk') {
41
58
  this.initCacheDirectory();
42
59
  }
43
60
  }
@@ -62,22 +79,29 @@ export class CacheManager {
62
79
  /**
63
80
  * Get cached analysis for file
64
81
  * @param {string} filePath
65
- * @param {number} modifiedTime - File modification timestamp
66
- * @returns {FileAnalysis|null}
82
+ * @param {number|Date} modifiedTime - File modification timestamp
83
+ * @param {object} [meta] - Additional cache key/validation metadata
84
+ * @param {number} [meta.size] - Current file byte size
85
+ * @param {string} [meta.namespace] - Caller namespace ('analyzer', 'token-calculator', ...)
86
+ * @param {string} [meta.encoding] - Tokenizer encoding ('cl100k_base', 'o200k_base', ...)
87
+ * @param {boolean} [meta.methodLevel] - Whether method-level analysis is included
88
+ * @param {string} [meta.extraKey] - Extra discriminator (e.g. method filter signature)
89
+ * @returns {object|null}
67
90
  */
68
- get(filePath, modifiedTime) {
91
+ get(filePath, modifiedTime, meta = {}) {
69
92
  if (!this.options.enabled) {
70
93
  return null;
71
94
  }
72
95
 
73
- const key = this.getCacheKey(filePath);
96
+ const key = this.getCacheKey(filePath, meta);
97
+ const mtimeMs = normalizeMtime(modifiedTime);
98
+ const size = typeof meta?.size === 'number' ? meta.size : null;
74
99
 
75
100
  try {
76
101
  if (this.options.strategy === 'memory') {
77
- return this.getFromMemory(key, modifiedTime);
78
- } else {
79
- return this.getFromDisk(key, modifiedTime);
102
+ return this.getFromMemory(key, mtimeMs, size);
80
103
  }
104
+ return this.getFromDisk(key, mtimeMs, size);
81
105
  } catch (error) {
82
106
  logger.error(`Cache get error: ${error.message}`);
83
107
  this.stats.errors++;
@@ -88,10 +112,11 @@ export class CacheManager {
88
112
  /**
89
113
  * Get from memory cache
90
114
  * @param {string} key
91
- * @param {number} modifiedTime
92
- * @returns {FileAnalysis|null}
115
+ * @param {number|null} modifiedTime
116
+ * @param {number|null} size
117
+ * @returns {object|null}
93
118
  */
94
- getFromMemory(key, modifiedTime) {
119
+ getFromMemory(key, modifiedTime, size = null) {
95
120
  const cached = this.memoryCache.get(key);
96
121
 
97
122
  if (!cached) {
@@ -99,25 +124,30 @@ export class CacheManager {
99
124
  return null;
100
125
  }
101
126
 
102
- // Check if expired
103
- if (this.isExpired(cached.timestamp, cached.modifiedTime, modifiedTime)) {
127
+ // Check if expired or invalidated by mtime/size change
128
+ if (this.isExpired(cached.timestamp, cached.modifiedTime, modifiedTime, cached.size, size)) {
104
129
  this.memoryCache.delete(key);
105
130
  this.stats.misses++;
106
131
  return null;
107
132
  }
108
133
 
134
+ // Refresh insertion order for LRU eviction
135
+ this.memoryCache.delete(key);
136
+ this.memoryCache.set(key, cached);
137
+
109
138
  this.stats.hits++;
110
139
  logger.debug(`Cache hit (memory): ${key}`);
111
- return cached.data;
140
+ return structuredClone(cached.data);
112
141
  }
113
142
 
114
143
  /**
115
144
  * Get from disk cache
116
145
  * @param {string} key
117
- * @param {number} modifiedTime
118
- * @returns {FileAnalysis|null}
146
+ * @param {number|null} modifiedTime
147
+ * @param {number|null} size
148
+ * @returns {object|null}
119
149
  */
120
- getFromDisk(key, modifiedTime) {
150
+ getFromDisk(key, modifiedTime, size = null) {
121
151
  const cacheFile = path.join(this.options.path, `${key}.json`);
122
152
 
123
153
  if (!fs.existsSync(cacheFile)) {
@@ -129,9 +159,13 @@ export class CacheManager {
129
159
  const content = fs.readFileSync(cacheFile, 'utf-8');
130
160
  const cached = JSON.parse(content);
131
161
 
132
- // Check if expired
133
- if (this.isExpired(cached.timestamp, cached.modifiedTime, modifiedTime)) {
134
- fs.unlinkSync(cacheFile);
162
+ // Check if expired or invalidated by mtime/size change
163
+ if (this.isExpired(cached.timestamp, cached.modifiedTime, modifiedTime, cached.size, size)) {
164
+ try {
165
+ fs.unlinkSync(cacheFile);
166
+ } catch {
167
+ // Ignore race if already removed
168
+ }
135
169
  this.stats.misses++;
136
170
  return null;
137
171
  }
@@ -148,19 +182,21 @@ export class CacheManager {
148
182
  /**
149
183
  * Set cache entry
150
184
  * @param {string} filePath
151
- * @param {FileAnalysis} analysis
152
- * @param {number} modifiedTime
185
+ * @param {object} analysis
186
+ * @param {number|Date} modifiedTime
187
+ * @param {object} [meta]
153
188
  */
154
- set(filePath, analysis, modifiedTime) {
189
+ set(filePath, analysis, modifiedTime, meta = {}) {
155
190
  if (!this.options.enabled) {
156
191
  return;
157
192
  }
158
193
 
159
- const key = this.getCacheKey(filePath);
194
+ const key = this.getCacheKey(filePath, meta);
160
195
  const cacheEntry = {
161
- data: analysis,
196
+ data: this.options.strategy === 'memory' ? structuredClone(analysis) : analysis,
162
197
  timestamp: Date.now(),
163
- modifiedTime: modifiedTime,
198
+ modifiedTime: normalizeMtime(modifiedTime),
199
+ size: typeof meta?.size === 'number' ? meta.size : null,
164
200
  };
165
201
 
166
202
  try {
@@ -184,10 +220,14 @@ export class CacheManager {
184
220
  * @param {object} cacheEntry
185
221
  */
186
222
  setInMemory(key, cacheEntry) {
223
+ if (this.memoryCache.has(key)) {
224
+ this.memoryCache.delete(key);
225
+ }
187
226
  this.memoryCache.set(key, cacheEntry);
188
227
 
189
228
  // Check memory limit
190
- if (this.memoryCache.size > 1000) {
229
+ const limit = Number.isInteger(this.options.maxEntries) ? this.options.maxEntries : 1000;
230
+ while (this.memoryCache.size > limit) {
191
231
  this.evictOldest();
192
232
  }
193
233
  }
@@ -198,26 +238,47 @@ export class CacheManager {
198
238
  * @param {object} cacheEntry
199
239
  */
200
240
  setOnDisk(key, cacheEntry) {
201
- const cacheFile = path.join(this.options.path, `${key}.json`);
241
+ const cacheDir = path.resolve(this.options.path);
242
+ if (!fs.existsSync(cacheDir)) {
243
+ fs.mkdirSync(cacheDir, { recursive: true });
244
+ }
245
+ const cacheFile = path.join(cacheDir, `${key}.json`);
202
246
  fs.writeFileSync(cacheFile, JSON.stringify(cacheEntry), 'utf-8');
203
247
  }
204
248
 
205
249
  /**
206
- * Check if cache entry is expired
250
+ * Check if cache entry is expired or invalidated
207
251
  * @param {number} cacheTimestamp - When cache was created
208
- * @param {number} cacheModifiedTime - File modified time when cached
209
- * @param {number} currentModifiedTime - Current file modified time
252
+ * @param {number|null} cacheModifiedTime - File modified time when cached
253
+ * @param {number|Date|null} currentModifiedTime - Current file modified time
254
+ * @param {number|null} [cachedSize] - File size when cached
255
+ * @param {number|null} [currentSize] - Current file size
210
256
  * @returns {boolean}
211
257
  */
212
- isExpired(cacheTimestamp, cacheModifiedTime, currentModifiedTime) {
213
- // Check TTL
214
- const age = (Date.now() - cacheTimestamp) / 1000; // seconds
215
- if (age > this.options.ttl) {
258
+ isExpired(
259
+ cacheTimestamp,
260
+ cacheModifiedTime,
261
+ currentModifiedTime,
262
+ cachedSize = null,
263
+ currentSize = null
264
+ ) {
265
+ // Check TTL when positive
266
+ if (this.options.ttl > 0) {
267
+ const age = (Date.now() - cacheTimestamp) / 1000; // seconds
268
+ if (age > this.options.ttl) {
269
+ return true;
270
+ }
271
+ }
272
+
273
+ // Any modification time mismatch (newer OR reverted to an older commit) invalidates
274
+ const currentMs = normalizeMtime(currentModifiedTime);
275
+ const cachedMs = normalizeMtime(cacheModifiedTime);
276
+ if (currentMs !== null && cachedMs !== null && currentMs !== cachedMs) {
216
277
  return true;
217
278
  }
218
279
 
219
- // Check if file was modified
220
- if (currentModifiedTime > cacheModifiedTime) {
280
+ // Byte size mismatch invalidates even within identical mtime granularity
281
+ if (currentSize !== null && cachedSize !== null && currentSize !== cachedSize) {
221
282
  return true;
222
283
  }
223
284
 
@@ -225,12 +286,30 @@ export class CacheManager {
225
286
  }
226
287
 
227
288
  /**
228
- * Generate cache key from file path
289
+ * Generate cache key from file path and analysis discriminator metadata
229
290
  * @param {string} filePath
291
+ * @param {object} [meta]
230
292
  * @returns {string}
231
293
  */
232
- getCacheKey(filePath) {
233
- return crypto.createHash('md5').update(filePath).digest('hex');
294
+ getCacheKey(filePath, meta = {}) {
295
+ const hasMeta =
296
+ meta &&
297
+ (meta.namespace !== undefined ||
298
+ meta.encoding !== undefined ||
299
+ meta.methodLevel !== undefined ||
300
+ meta.extraKey !== undefined);
301
+
302
+ const payload = hasMeta
303
+ ? JSON.stringify([
304
+ path.resolve(filePath),
305
+ meta.namespace || '',
306
+ meta.encoding || '',
307
+ Boolean(meta.methodLevel),
308
+ meta.extraKey || '',
309
+ ])
310
+ : String(filePath);
311
+
312
+ return crypto.createHash('sha256').update(payload).digest('hex');
234
313
  }
235
314
 
236
315
  /**
@@ -256,7 +335,9 @@ export class CacheManager {
256
335
  if (fs.existsSync(cachePath)) {
257
336
  const files = fs.readdirSync(cachePath);
258
337
  for (const file of files) {
259
- fs.unlinkSync(path.join(cachePath, file));
338
+ if (file.endsWith('.json')) {
339
+ fs.unlinkSync(path.join(cachePath, file));
340
+ }
260
341
  }
261
342
  }
262
343
  } catch (error) {
@@ -278,7 +359,7 @@ export class CacheManager {
278
359
  }
279
360
  }
280
361
 
281
- if (oldestKey) {
362
+ if (oldestKey !== null) {
282
363
  this.memoryCache.delete(oldestKey);
283
364
  this.stats.evictions++;
284
365
  logger.debug(`Evicted oldest entry: ${oldestKey}`);
@@ -291,7 +372,7 @@ export class CacheManager {
291
372
  */
292
373
  getStats() {
293
374
  const totalRequests = this.stats.hits + this.stats.misses;
294
- const hitRate = totalRequests > 0 ? ((this.stats.hits / totalRequests) * 100).toFixed(1) : 0;
375
+ const hitRate = totalRequests > 0 ? ((this.stats.hits / totalRequests) * 100).toFixed(1) : '0';
295
376
 
296
377
  return {
297
378
  ...this.stats,
@@ -311,7 +392,7 @@ export class CacheManager {
311
392
  const cachePath = path.resolve(this.options.path);
312
393
  if (!fs.existsSync(cachePath)) return 0;
313
394
 
314
- const files = fs.readdirSync(cachePath);
395
+ const files = fs.readdirSync(cachePath).filter((f) => f.endsWith('.json'));
315
396
  return files.length;
316
397
  } catch (_error) {
317
398
  return 0;
@@ -335,6 +416,7 @@ export class CacheManager {
335
416
  * Prune memory cache
336
417
  */
337
418
  pruneMemoryCache() {
419
+ if (this.options.ttl <= 0) return;
338
420
  const now = Date.now();
339
421
  const ttlMs = this.options.ttl * 1000;
340
422
  let pruned = 0;
@@ -353,14 +435,17 @@ export class CacheManager {
353
435
  * Prune disk cache
354
436
  */
355
437
  pruneDiskCache() {
438
+ if (this.options.ttl <= 0) return;
356
439
  try {
357
440
  const cachePath = path.resolve(this.options.path);
441
+ if (!fs.existsSync(cachePath)) return;
358
442
  const files = fs.readdirSync(cachePath);
359
443
  const now = Date.now();
360
444
  const ttlMs = this.options.ttl * 1000;
361
445
  let pruned = 0;
362
446
 
363
447
  for (const file of files) {
448
+ if (!file.endsWith('.json')) continue;
364
449
  const filePath = path.join(cachePath, file);
365
450
  const stats = fs.statSync(filePath);
366
451
 
@@ -377,4 +462,33 @@ export class CacheManager {
377
462
  }
378
463
  }
379
464
 
465
+ /**
466
+ * Resolve a `cache` option value (`false`, `true`, `'memory'`, `'disk'`, options object,
467
+ * or a `CacheManager` instance) into a `CacheManager` or `null`.
468
+ * @param {boolean|string|object|CacheManager|null|undefined} cacheOption
469
+ * @param {object} [defaults]
470
+ * @returns {CacheManager|null}
471
+ */
472
+ export function resolveCacheManager(cacheOption, defaults = {}) {
473
+ if (!cacheOption) return null;
474
+ if (
475
+ cacheOption instanceof CacheManager ||
476
+ (typeof cacheOption === 'object' &&
477
+ typeof cacheOption.get === 'function' &&
478
+ typeof cacheOption.set === 'function')
479
+ ) {
480
+ return cacheOption;
481
+ }
482
+ if (cacheOption === true || cacheOption === 'memory') {
483
+ return new CacheManager({ strategy: 'memory', ...defaults });
484
+ }
485
+ if (cacheOption === 'disk') {
486
+ return new CacheManager({ strategy: 'disk', ...defaults });
487
+ }
488
+ if (typeof cacheOption === 'object') {
489
+ return new CacheManager({ ...defaults, ...cacheOption });
490
+ }
491
+ return null;
492
+ }
493
+
380
494
  export default CacheManager;
@@ -7,6 +7,7 @@ import fs from 'fs';
7
7
  import TokenUtils from '../utils/token-utils.js';
8
8
  import FileUtils from '../utils/file-utils.js';
9
9
  import MethodAnalyzer from '../analyzers/method-analyzer.js';
10
+ import { resolveCacheManager } from '../cache/CacheManager.js';
10
11
  import { getLogger } from '../utils/logger.js';
11
12
 
12
13
  const logger = getLogger('Analyzer');
@@ -17,15 +18,21 @@ export class Analyzer {
17
18
  methodLevel: false,
18
19
  parallel: false,
19
20
  maxWorkers: 4,
21
+ targetModel: null,
22
+ cache: false,
20
23
  ...options,
21
24
  };
22
25
 
26
+ this.encoding = options.encoding || TokenUtils.encodingFor(this.options.targetModel);
27
+ this.cache = resolveCacheManager(this.options.cache, {
28
+ ...(this.options.cachePath ? { path: this.options.cachePath } : {}),
29
+ });
23
30
  this.methodAnalyzer = new MethodAnalyzer();
24
31
  this.stats = this.initStats();
25
32
  }
26
33
 
27
34
  initStats() {
28
- return {
35
+ const base = {
29
36
  totalFiles: 0,
30
37
  totalTokens: 0,
31
38
  totalSize: 0,
@@ -35,6 +42,11 @@ export class Analyzer {
35
42
  largestFiles: [],
36
43
  analysisTime: 0,
37
44
  };
45
+ if (this.cache) {
46
+ base.cacheHits = 0;
47
+ base.cacheMisses = 0;
48
+ }
49
+ return base;
38
50
  }
39
51
 
40
52
  async analyze(files) {
@@ -75,16 +87,56 @@ export class Analyzer {
75
87
  return null;
76
88
  }
77
89
 
90
+ let size = fileInfo.size;
91
+ let modified = fileInfo.modified;
92
+ let mtimeMs =
93
+ modified instanceof Date
94
+ ? modified.getTime()
95
+ : typeof modified === 'number'
96
+ ? modified
97
+ : null;
98
+
99
+ if (this.cache && (mtimeMs === null || typeof size !== 'number')) {
100
+ const stat = fs.statSync(fileInfo.path);
101
+ mtimeMs = stat.mtimeMs;
102
+ size = stat.size;
103
+ if (modified === undefined) modified = stat.mtime;
104
+ }
105
+
106
+ const cacheMeta = {
107
+ namespace: 'analyzer',
108
+ size,
109
+ encoding: this.encoding,
110
+ methodLevel: Boolean(this.options.methodLevel),
111
+ };
112
+
113
+ if (this.cache) {
114
+ const cached = this.cache.get(fileInfo.path, mtimeMs, cacheMeta);
115
+ if (cached) {
116
+ this.stats.cacheHits++;
117
+ return {
118
+ ...cached,
119
+ path: fileInfo.path,
120
+ relativePath: fileInfo.relativePath,
121
+ name: fileInfo.name ?? cached.name,
122
+ extension: fileInfo.extension ?? cached.extension,
123
+ size: size ?? cached.size,
124
+ modified: modified ?? cached.modified,
125
+ };
126
+ }
127
+ this.stats.cacheMisses++;
128
+ }
129
+
78
130
  const content = fs.readFileSync(fileInfo.path, 'utf-8');
79
- const tokens = TokenUtils.calculate(content, fileInfo.path);
131
+ const tokens = TokenUtils.calculate(content, fileInfo.path, this.encoding);
80
132
 
81
133
  const analysis = {
82
134
  path: fileInfo.path,
83
135
  relativePath: fileInfo.relativePath,
84
136
  name: fileInfo.name,
85
137
  extension: fileInfo.extension,
86
- size: fileInfo.size,
87
- modified: fileInfo.modified,
138
+ size: size,
139
+ modified: modified,
88
140
  tokens: tokens,
89
141
  lines: content.split('\n').length,
90
142
  language: this.detectLanguage(fileInfo.extension),
@@ -95,11 +147,15 @@ export class Analyzer {
95
147
  const sources = this.methodAnalyzer.extractMethodSources(content, methods, fileInfo.path);
96
148
  analysis.methods = methods.map((method, i) => ({
97
149
  ...method,
98
- tokens: TokenUtils.calculate(sources[i], fileInfo.path),
150
+ tokens: TokenUtils.calculate(sources[i], fileInfo.path, this.encoding),
99
151
  }));
100
152
  analysis.methodCount = methods.length;
101
153
  }
102
154
 
155
+ if (this.cache) {
156
+ this.cache.set(fileInfo.path, analysis, mtimeMs, cacheMeta);
157
+ }
158
+
103
159
  return analysis;
104
160
  } catch (error) {
105
161
  logger.error(`Error analyzing file ${fileInfo.relativePath}: ${error.message}`);