claude-ex 1.2.0 → 1.4.1

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.
@@ -19,6 +19,8 @@ exports.getTypeHierarchy = getTypeHierarchy;
19
19
  exports.findDeadExports = findDeadExports;
20
20
  exports.getPkgUsages = getPkgUsages;
21
21
  exports.reviewDiff = reviewDiff;
22
+ exports.transparentReview = transparentReview;
23
+ exports.transparentReviewFromRoot = transparentReviewFromRoot;
22
24
  exports.searchFromRoot = searchFromRoot;
23
25
  exports.getCallersFromRoot = getCallersFromRoot;
24
26
  exports.getContextFromRoot = getContextFromRoot;
@@ -32,23 +34,44 @@ exports.preEditContextFromRoot = preEditContextFromRoot;
32
34
  exports.reviewDiffFromRoot = reviewDiffFromRoot;
33
35
  const child_process_1 = require("child_process");
34
36
  const schema_1 = require("../db/schema");
35
- // FTS5 query sanitizer
37
+ // --- Prepared statement cache ---
38
+ // Each db connection gets its own cache; WeakMap ensures cleanup when db is GCed.
39
+ const stmtCache = new WeakMap();
40
+ function cached(db, sql) {
41
+ let cache = stmtCache.get(db);
42
+ if (!cache) {
43
+ cache = new Map();
44
+ stmtCache.set(db, cache);
45
+ }
46
+ let stmt = cache.get(sql);
47
+ if (!stmt) {
48
+ stmt = db.prepare(sql);
49
+ cache.set(sql, stmt);
50
+ }
51
+ return stmt;
52
+ }
53
+ // FTS5 query sanitizer — uses NEAR for multi-token phrase matching
36
54
  function sanitizeFts(query) {
37
55
  const tokens = query.replace(/[^\w\s]/g, ' ').trim().split(/\s+/).filter(Boolean);
38
56
  if (tokens.length === 0)
39
57
  return '';
40
- return tokens.map(t => `"${t}"`).join(' OR ');
58
+ if (tokens.length === 1)
59
+ return `"${tokens[0]}"`;
60
+ // Multi-token: prefer NEAR phrase match, fall back to OR for partial matches
61
+ const phrase = tokens.map(t => `"${t}"`).join(' NEAR ');
62
+ const orFallback = tokens.map(t => `"${t}"`).join(' OR ');
63
+ return `(${phrase}) OR (${orFallback})`;
41
64
  }
42
65
  // --- DB-direct functions (for MCP server hot path) ---
43
66
  function search(db, query, limit = 15) {
44
67
  const ftsQuery = sanitizeFts(query);
45
68
  if (!ftsQuery)
46
69
  return [];
47
- const stmt = db.prepare(`
70
+ // Primary: FTS5 word-level search (fast, ranked)
71
+ const results = cached(db, `
48
72
  SELECT s.name, s.qualified_name, s.kind, f.path as file,
49
73
  s.line_start, s.line_end, s.signature,
50
- COALESCE(r.pagerank, 0) as pagerank,
51
- snippet(symbols_fts, 4, '>>>', '<<<', '...', 30) as snippet
74
+ COALESCE(r.pagerank, 0) as pagerank
52
75
  FROM symbols_fts fts
53
76
  JOIN symbols s ON s.id = fts.rowid
54
77
  JOIN files f ON f.id = s.file_id
@@ -56,11 +79,45 @@ function search(db, query, limit = 15) {
56
79
  WHERE symbols_fts MATCH ?
57
80
  ORDER BY r.pagerank DESC, fts.rank
58
81
  LIMIT ?
59
- `);
60
- return stmt.all(ftsQuery, limit);
82
+ `).all(ftsQuery, limit);
83
+ // If FTS has enough results, return immediately
84
+ if (results.length >= limit)
85
+ return results;
86
+ // Fallback: trigram substring search for partial/camelCase matches
87
+ try {
88
+ const seenIds = new Set(results.map(r => `${r.name}:${r.file}`));
89
+ const cleaned = query.replace(/[^\w\s]/g, '').trim();
90
+ if (cleaned.length < 3)
91
+ return results;
92
+ const trigramResults = cached(db, `
93
+ SELECT s.name, s.qualified_name, s.kind, f.path as file,
94
+ s.line_start, s.line_end, s.signature,
95
+ COALESCE(r.pagerank, 0) as pagerank
96
+ FROM symbols_trigram tri
97
+ JOIN symbols s ON s.id = tri.rowid
98
+ JOIN files f ON f.id = s.file_id
99
+ LEFT JOIN rankings r ON r.symbol_id = s.id
100
+ WHERE symbols_trigram MATCH ?
101
+ ORDER BY r.pagerank DESC
102
+ LIMIT ?
103
+ `).all(cleaned, limit);
104
+ for (const r of trigramResults) {
105
+ const key = `${r.name}:${r.file}`;
106
+ if (!seenIds.has(key)) {
107
+ results.push(r);
108
+ seenIds.add(key);
109
+ if (results.length >= limit)
110
+ break;
111
+ }
112
+ }
113
+ }
114
+ catch {
115
+ // Trigram table may not exist on older DBs
116
+ }
117
+ return results;
61
118
  }
62
119
  function getCallers(db, symbolName) {
63
- const stmt = db.prepare(`
120
+ return cached(db, `
64
121
  SELECT DISTINCT s.name, s.qualified_name, s.kind, f.path as file,
65
122
  s.line_start, s.line_end, s.signature,
66
123
  COALESCE(r.pagerank, 0) as pagerank
@@ -72,12 +129,10 @@ function getCallers(db, symbolName) {
72
129
  WHERE (target.name = ? OR target.qualified_name = ?)
73
130
  AND e.kind IN ('calls', 'references')
74
131
  ORDER BY r.pagerank DESC
75
- `);
76
- return stmt.all(symbolName, symbolName);
132
+ `).all(symbolName, symbolName);
77
133
  }
78
134
  function getContext(db, symbolName) {
79
- // Find the symbol (prefer exported, highest pagerank)
80
- const sym = db.prepare(`
135
+ const sym = cached(db, `
81
136
  SELECT s.id, s.name, s.qualified_name, s.kind, f.path as file,
82
137
  s.line_start, s.line_end, s.signature, s.docstring, s.content as code,
83
138
  s.file_id
@@ -90,8 +145,7 @@ function getContext(db, symbolName) {
90
145
  `).get(symbolName, symbolName);
91
146
  if (!sym)
92
147
  return null;
93
- // Dependencies (edges FROM this symbol)
94
- const deps = db.prepare(`
148
+ const deps = cached(db, `
95
149
  SELECT s.name, s.qualified_name, s.kind, f.path as file,
96
150
  s.line_start, s.line_end, s.signature,
97
151
  COALESCE(r.pagerank, 0) as pagerank
@@ -101,8 +155,7 @@ function getContext(db, symbolName) {
101
155
  LEFT JOIN rankings r ON r.symbol_id = s.id
102
156
  WHERE e.from_id = ?
103
157
  `).all(sym.id);
104
- // Dependents (edges TO this symbol)
105
- const dependents = db.prepare(`
158
+ const dependents = cached(db, `
106
159
  SELECT s.name, s.qualified_name, s.kind, f.path as file,
107
160
  s.line_start, s.line_end, s.signature,
108
161
  COALESCE(r.pagerank, 0) as pagerank
@@ -112,8 +165,7 @@ function getContext(db, symbolName) {
112
165
  LEFT JOIN rankings r ON r.symbol_id = s.id
113
166
  WHERE e.to_id = ?
114
167
  `).all(sym.id);
115
- // Same-file siblings
116
- const siblings = db.prepare(`
168
+ const siblings = cached(db, `
117
169
  SELECT s.name, s.qualified_name, s.kind, f.path as file,
118
170
  s.line_start, s.line_end, s.signature,
119
171
  COALESCE(r.pagerank, 0) as pagerank
@@ -141,7 +193,7 @@ function getContext(db, symbolName) {
141
193
  };
142
194
  }
143
195
  function getImpact(db, filePath, maxDepth = 10) {
144
- const stmt = db.prepare(`
196
+ return cached(db, `
145
197
  WITH RECURSIVE impact(file_id, depth) AS (
146
198
  SELECT fd.from_file, 1
147
199
  FROM file_deps fd
@@ -159,11 +211,10 @@ function getImpact(db, filePath, maxDepth = 10) {
159
211
  JOIN files f ON f.id = i.file_id
160
212
  GROUP BY f.path
161
213
  ORDER BY depth, symbolCount DESC
162
- `);
163
- return stmt.all(filePath, maxDepth);
214
+ `).all(filePath, maxDepth);
164
215
  }
165
216
  function getDeps(db, symbolName) {
166
- const stmt = db.prepare(`
217
+ return cached(db, `
167
218
  SELECT DISTINCT s.name, s.qualified_name, s.kind, f.path as file,
168
219
  s.line_start, s.line_end, s.signature,
169
220
  COALESCE(r.pagerank, 0) as pagerank
@@ -174,11 +225,10 @@ function getDeps(db, symbolName) {
174
225
  LEFT JOIN rankings r ON r.symbol_id = s.id
175
226
  WHERE (source.name = ? OR source.qualified_name = ?)
176
227
  ORDER BY r.pagerank DESC
177
- `);
178
- return stmt.all(symbolName, symbolName);
228
+ `).all(symbolName, symbolName);
179
229
  }
180
230
  function getRank(db, top = 20) {
181
- const stmt = db.prepare(`
231
+ return cached(db, `
182
232
  SELECT s.name, s.qualified_name, s.kind, f.path as file,
183
233
  s.line_start, s.line_end, s.signature,
184
234
  r.pagerank
@@ -188,65 +238,64 @@ function getRank(db, top = 20) {
188
238
  WHERE s.kind IN ('function', 'class', 'method', 'interface', 'type')
189
239
  ORDER BY r.pagerank DESC
190
240
  LIMIT ?
191
- `);
192
- return stmt.all(top);
241
+ `).all(top);
193
242
  }
194
243
  function getModules(db) {
195
- // Group files by top-level directory
196
- const files = db.prepare(`
197
- SELECT f.id, f.path,
198
- CASE WHEN INSTR(f.path, '/') > 0
199
- THEN SUBSTR(f.path, 1, INSTR(f.path, '/') - 1)
200
- ELSE '.'
201
- END as module
244
+ // Single query: files grouped by module with symbol counts
245
+ const moduleRows = cached(db, `
246
+ SELECT
247
+ CASE WHEN INSTR(f.path, '/') > 0
248
+ THEN SUBSTR(f.path, 1, INSTR(f.path, '/') - 1)
249
+ ELSE '.'
250
+ END as module,
251
+ COUNT(DISTINCT f.id) as fileCount,
252
+ COUNT(DISTINCT s.id) as symbolCount
202
253
  FROM files f
254
+ LEFT JOIN symbols s ON s.file_id = f.id
255
+ GROUP BY module
203
256
  `).all();
204
- const moduleMap = new Map();
205
- for (const f of files) {
206
- if (!moduleMap.has(f.module)) {
207
- moduleMap.set(f.module, { fileIds: new Set(), files: [] });
208
- }
209
- moduleMap.get(f.module).fileIds.add(f.id);
210
- moduleMap.get(f.module).files.push(f.path);
211
- }
212
- const results = [];
213
- for (const [name, data] of moduleMap) {
214
- const symbolCount = db.prepare(`SELECT COUNT(*) as cnt FROM symbols WHERE file_id IN (${[...data.fileIds].join(',')})`).get();
215
- // Find which other modules this module imports from
216
- const deps = db.prepare(`
217
- SELECT DISTINCT
218
- CASE WHEN INSTR(f2.path, '/') > 0
219
- THEN SUBSTR(f2.path, 1, INSTR(f2.path, '/') - 1)
220
- ELSE '.'
221
- END as target_module
222
- FROM file_deps fd
223
- JOIN files f2 ON f2.id = fd.to_file
224
- WHERE fd.from_file IN (${[...data.fileIds].join(',')})
225
- `).all();
226
- results.push({
227
- name,
228
- fileCount: data.files.length,
229
- symbolCount: symbolCount.cnt,
230
- importsFrom: deps.map(d => d.target_module).filter(m => m !== name),
231
- });
257
+ // Single query: all cross-module imports
258
+ const depRows = cached(db, `
259
+ SELECT DISTINCT
260
+ CASE WHEN INSTR(f1.path, '/') > 0
261
+ THEN SUBSTR(f1.path, 1, INSTR(f1.path, '/') - 1)
262
+ ELSE '.'
263
+ END as from_module,
264
+ CASE WHEN INSTR(f2.path, '/') > 0
265
+ THEN SUBSTR(f2.path, 1, INSTR(f2.path, '/') - 1)
266
+ ELSE '.'
267
+ END as to_module
268
+ FROM file_deps fd
269
+ JOIN files f1 ON f1.id = fd.from_file
270
+ JOIN files f2 ON f2.id = fd.to_file
271
+ `).all();
272
+ // Build dep map
273
+ const depMap = new Map();
274
+ for (const row of depRows) {
275
+ if (row.from_module === row.to_module)
276
+ continue;
277
+ if (!depMap.has(row.from_module))
278
+ depMap.set(row.from_module, new Set());
279
+ depMap.get(row.from_module).add(row.to_module);
232
280
  }
233
- return results.sort((a, b) => b.symbolCount - a.symbolCount);
281
+ return moduleRows.map(m => ({
282
+ name: m.module,
283
+ fileCount: m.fileCount,
284
+ symbolCount: m.symbolCount,
285
+ importsFrom: [...(depMap.get(m.module) || [])],
286
+ })).sort((a, b) => b.symbolCount - a.symbolCount);
234
287
  }
235
288
  function findFiles(db, pattern, limit = 50) {
236
- // Convert glob-like pattern to SQL GLOB pattern
237
- // SQL GLOB uses * for any chars and ? for single char (same as shell glob)
238
- // But we want ** to match path separators too, which GLOB * already does
239
289
  const sqlPattern = pattern
240
- .replace(/\*\*/g, '*') // ** -> * (SQL GLOB * matches everything including /)
241
- .replace(/\.\*/g, '.*'); // preserve .* as literal
242
- const stmt = db.prepare(`
290
+ .replace(/\*\*/g, '*')
291
+ .replace(/\.\*/g, '.*');
292
+ return cached(db, `
243
293
  SELECT path, language, line_count as lineCount
244
294
  FROM files
245
295
  WHERE path GLOB ?
246
296
  ORDER BY path
247
297
  LIMIT ?
248
- `);
249
- return stmt.all(sqlPattern, limit);
298
+ `).all(sqlPattern, limit);
250
299
  }
251
300
  /** Directories to exclude from file map (dependencies, build output) */
252
301
  const FILE_MAP_SKIP = new Set(['node_modules', 'dist', 'build', 'out', '.next', '.nuxt', 'vendor', 'target', 'coverage']);
@@ -256,59 +305,50 @@ function isProjectFile(filePath) {
256
305
  }
257
306
  /** Returns a map of every project file → what it exports. This is the "memory" of the project. */
258
307
  function getFileMap(db) {
259
- const files = db.prepare(`
260
- SELECT f.id, f.path, f.language, f.line_count as lineCount
308
+ // Single query: all files with their exports via GROUP_CONCAT
309
+ const rows = cached(db, `
310
+ SELECT f.path, f.language, f.line_count as lineCount,
311
+ GROUP_CONCAT(s.name || ' [' || s.kind || ']', '|||') as exports_str
261
312
  FROM files f
313
+ LEFT JOIN symbols s ON s.file_id = f.id AND s.exported = 1
314
+ GROUP BY f.id
262
315
  ORDER BY f.path
263
316
  `).all();
264
- const exportStmt = db.prepare(`
265
- SELECT name, kind, signature FROM symbols
266
- WHERE file_id = ? AND exported = 1
267
- ORDER BY line_start
268
- `);
269
- const results = [];
270
- for (const f of files) {
271
- if (!isProjectFile(f.path))
272
- continue;
273
- const exports = exportStmt.all(f.id);
274
- results.push({
275
- path: f.path,
276
- language: f.language,
277
- lineCount: f.lineCount,
278
- exports: exports.map(e => `${e.name} [${e.kind}]`),
279
- });
280
- }
281
- return results;
317
+ return rows
318
+ .filter(r => isProjectFile(r.path))
319
+ .map(r => ({
320
+ path: r.path,
321
+ language: r.language,
322
+ lineCount: r.lineCount,
323
+ exports: r.exports_str ? r.exports_str.split('|||') : [],
324
+ }));
282
325
  }
283
326
  /** Compact file map string for embedding in CLAUDE.md / brief */
284
327
  function getFileMapCompact(db, maxFiles = 80) {
285
- const files = db.prepare(`
286
- SELECT f.id, f.path, f.language, f.line_count as lineCount
328
+ // Single query: files with top-8 exports by pagerank and total export count
329
+ const rows = cached(db, `
330
+ SELECT f.id, f.path,
331
+ (SELECT COUNT(*) FROM symbols WHERE file_id = f.id AND exported = 1) as totalExports,
332
+ (SELECT GROUP_CONCAT(name, ', ')
333
+ FROM (SELECT s.name FROM symbols s
334
+ LEFT JOIN rankings r ON r.symbol_id = s.id
335
+ WHERE s.file_id = f.id AND s.exported = 1
336
+ ORDER BY COALESCE(r.pagerank, 0) DESC LIMIT 8)
337
+ ) as topNames
287
338
  FROM files f
288
339
  ORDER BY f.path
289
340
  `).all();
290
- const projectFiles = files.filter(f => isProjectFile(f.path));
291
- const exportStmt = db.prepare(`
292
- SELECT name, kind FROM symbols
293
- WHERE file_id = ? AND exported = 1
294
- ORDER BY COALESCE((SELECT pagerank FROM rankings WHERE symbol_id = symbols.id), 0) DESC
295
- LIMIT 8
296
- `);
297
- const allExportStmt = db.prepare(`
298
- SELECT COUNT(*) as cnt FROM symbols WHERE file_id = ? AND exported = 1
299
- `);
341
+ const projectFiles = rows.filter(r => isProjectFile(r.path));
300
342
  const lines = [];
301
343
  const shown = projectFiles.slice(0, maxFiles);
302
344
  for (const f of shown) {
303
- const exports = exportStmt.all(f.id);
304
- const totalExports = allExportStmt.get(f.id).cnt;
305
- if (exports.length === 0) {
345
+ if (!f.topNames || f.totalExports === 0) {
306
346
  lines.push(`- \`${f.path}\``);
307
347
  }
308
348
  else {
309
- const names = exports.map(e => e.name);
310
- const suffix = totalExports > names.length ? ` +${totalExports - names.length} more` : '';
311
- lines.push(`- \`${f.path}\` — ${names.join(', ')}${suffix}`);
349
+ const names = f.topNames.split(', ');
350
+ const suffix = f.totalExports > names.length ? ` +${f.totalExports - names.length} more` : '';
351
+ lines.push(`- \`${f.path}\` — ${f.topNames}${suffix}`);
312
352
  }
313
353
  }
314
354
  if (projectFiles.length > maxFiles) {
@@ -317,18 +357,20 @@ function getFileMapCompact(db, maxFiles = 80) {
317
357
  return lines.join('\n');
318
358
  }
319
359
  function getStats(db) {
320
- const files = db.prepare('SELECT COUNT(*) as cnt FROM files').get().cnt;
321
- const symbols = db.prepare('SELECT COUNT(*) as cnt FROM symbols').get().cnt;
322
- const edges = db.prepare('SELECT COUNT(*) as cnt FROM edges').get().cnt;
323
- const fileDeps = db.prepare('SELECT COUNT(*) as cnt FROM file_deps').get().cnt;
324
- return { files, symbols, edges, fileDeps };
360
+ return cached(db, `
361
+ SELECT
362
+ (SELECT COUNT(*) FROM files) as files,
363
+ (SELECT COUNT(*) FROM symbols) as symbols,
364
+ (SELECT COUNT(*) FROM edges) as edges,
365
+ (SELECT COUNT(*) FROM file_deps) as fileDeps
366
+ `).get();
325
367
  }
326
368
  function brief(db) {
327
369
  const stats = getStats(db);
328
370
  const topSymbols = getRank(db, 10);
329
371
  const modules = getModules(db);
330
372
  // Language breakdown
331
- const langs = db.prepare(`
373
+ const langs = cached(db, `
332
374
  SELECT language, COUNT(*) as cnt FROM files WHERE language IS NOT NULL GROUP BY language ORDER BY cnt DESC
333
375
  `).all();
334
376
  const lines = [];
@@ -358,12 +400,12 @@ function brief(db) {
358
400
  return lines.join('\n');
359
401
  }
360
402
  function preEditContext(db, filePath) {
361
- const file = db.prepare('SELECT id FROM files WHERE path = ?').get(filePath);
403
+ const file = cached(db, 'SELECT id FROM files WHERE path = ?').get(filePath);
362
404
  if (!file)
363
405
  return `File ${filePath} not in index.`;
364
406
  const lines = [];
365
407
  // What this file exports
366
- const exports = db.prepare(`
408
+ const exports = cached(db, `
367
409
  SELECT name, kind, signature FROM symbols WHERE file_id = ? AND exported = 1 ORDER BY line_start
368
410
  `).all(file.id);
369
411
  if (exports.length > 0) {
@@ -373,7 +415,7 @@ function preEditContext(db, filePath) {
373
415
  }
374
416
  }
375
417
  // What files import from this file
376
- const dependents = db.prepare(`
418
+ const dependents = cached(db, `
377
419
  SELECT DISTINCT f.path FROM file_deps fd JOIN files f ON f.id = fd.from_file WHERE fd.to_file = ?
378
420
  `).all(file.id);
379
421
  if (dependents.length > 0) {
@@ -387,7 +429,7 @@ function preEditContext(db, filePath) {
387
429
  }
388
430
  }
389
431
  // What this file imports
390
- const imports = db.prepare(`
432
+ const imports = cached(db, `
391
433
  SELECT f.path, fd.import_name FROM file_deps fd JOIN files f ON f.id = fd.to_file WHERE fd.from_file = ?
392
434
  `).all(file.id);
393
435
  if (imports.length > 0) {
@@ -401,19 +443,18 @@ function preEditContext(db, filePath) {
401
443
  }
402
444
  /** Get all symbols in a specific file */
403
445
  function getFileSymbols(db, filePath) {
404
- const stmt = db.prepare(`
446
+ return cached(db, `
405
447
  SELECT s.name, s.qualified_name as qualifiedName, s.kind, s.line_start as lineStart,
406
448
  s.line_end as lineEnd, s.signature, s.exported, s.parameters
407
449
  FROM symbols s
408
450
  JOIN files f ON f.id = s.file_id
409
451
  WHERE f.path = ?
410
452
  ORDER BY s.line_start
411
- `);
412
- return stmt.all(filePath);
453
+ `).all(filePath);
413
454
  }
414
455
  /** Find symbols by kind (class, function, interface, type, enum, method, variable) */
415
456
  function findByKind(db, kind, limit = 50) {
416
- const stmt = db.prepare(`
457
+ return cached(db, `
417
458
  SELECT s.name, s.qualified_name, s.kind, f.path as file,
418
459
  s.line_start, s.line_end, s.signature,
419
460
  COALESCE(r.pagerank, 0) as pagerank
@@ -423,12 +464,11 @@ function findByKind(db, kind, limit = 50) {
423
464
  WHERE s.kind = ?
424
465
  ORDER BY r.pagerank DESC
425
466
  LIMIT ?
426
- `);
427
- return stmt.all(kind, limit);
467
+ `).all(kind, limit);
428
468
  }
429
469
  /** Find all classes/interfaces that extend or implement a given name */
430
470
  function getTypeHierarchy(db, parentName) {
431
- const stmt = db.prepare(`
471
+ return cached(db, `
432
472
  SELECT s.name, s.qualified_name as qualifiedName, s.kind, f.path as file,
433
473
  s.line_start as lineStart, tr.kind as relationKind
434
474
  FROM type_relations tr
@@ -436,12 +476,11 @@ function getTypeHierarchy(db, parentName) {
436
476
  JOIN files f ON f.id = s.file_id
437
477
  WHERE tr.parent_name = ?
438
478
  ORDER BY tr.kind, s.name
439
- `);
440
- return stmt.all(parentName);
479
+ `).all(parentName);
441
480
  }
442
481
  /** Find exported symbols that nothing references or imports */
443
482
  function findDeadExports(db, limit = 50) {
444
- const stmt = db.prepare(`
483
+ return cached(db, `
445
484
  SELECT s.name, s.kind, f.path as file, s.line_start as lineStart
446
485
  FROM symbols s
447
486
  JOIN files f ON f.id = s.file_id
@@ -457,19 +496,17 @@ function findDeadExports(db, limit = 50) {
457
496
  )
458
497
  ORDER BY f.path, s.line_start
459
498
  LIMIT ?
460
- `);
461
- return stmt.all(limit);
499
+ `).all(limit);
462
500
  }
463
501
  /** Find all files that import from a given package */
464
502
  function getPkgUsages(db, packageName) {
465
- const stmt = db.prepare(`
503
+ return cached(db, `
466
504
  SELECT f.path as file, pd.imported_names as importedNames
467
505
  FROM pkg_deps pd
468
506
  JOIN files f ON f.id = pd.file_id
469
507
  WHERE pd.package = ? OR pd.package LIKE ? || '/%'
470
508
  ORDER BY f.path
471
- `);
472
- return stmt.all(packageName, packageName);
509
+ `).all(packageName, packageName);
473
510
  }
474
511
  // --- review_diff helpers ---
475
512
  function getGitDiff(rootDir, target) {
@@ -549,7 +586,7 @@ function parseDiff(rawDiff) {
549
586
  return files;
550
587
  }
551
588
  function matchHunksToSymbols(db, diffFile) {
552
- const allSymbols = db.prepare(`
589
+ const allSymbols = cached(db, `
553
590
  SELECT s.name, s.qualified_name, s.kind, f.path as file,
554
591
  s.line_start, s.line_end, s.signature, s.exported,
555
592
  COALESCE(r.pagerank, 0) as pagerank
@@ -610,11 +647,11 @@ function matchHunksToSymbols(db, diffFile) {
610
647
  return { changed, unchangedExports };
611
648
  }
612
649
  function getMedianPagerank(db) {
613
- const count = db.prepare('SELECT COUNT(*) as cnt FROM rankings').get()?.cnt || 0;
650
+ const count = cached(db, 'SELECT COUNT(*) as cnt FROM rankings').get()?.cnt || 0;
614
651
  if (count === 0)
615
652
  return 0;
616
653
  const mid = Math.floor(count / 2);
617
- const row = db.prepare('SELECT pagerank FROM rankings ORDER BY pagerank LIMIT 1 OFFSET ?').get(mid);
654
+ const row = cached(db, 'SELECT pagerank FROM rankings ORDER BY pagerank LIMIT 1 OFFSET ?').get(mid);
618
655
  return row?.pagerank || 0;
619
656
  }
620
657
  // --- review_diff main function ---
@@ -741,6 +778,572 @@ function reviewDiff(db, rootDir, target = 'last_commit') {
741
778
  risks,
742
779
  };
743
780
  }
781
+ function getFileAtRef(rootDir, filePath, ref) {
782
+ try {
783
+ return (0, child_process_1.execSync)(`git show ${ref}:${filePath}`, {
784
+ cwd: rootDir,
785
+ maxBuffer: 5 * 1024 * 1024,
786
+ encoding: 'utf-8',
787
+ stdio: ['pipe', 'pipe', 'pipe'],
788
+ });
789
+ }
790
+ catch {
791
+ return null;
792
+ }
793
+ }
794
+ function getGitRef(rootDir, target) {
795
+ const opts = { cwd: rootDir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] };
796
+ switch (target) {
797
+ case 'staged':
798
+ case 'last_commit':
799
+ return 'HEAD~1';
800
+ case 'branch': {
801
+ let baseBranch = 'main';
802
+ try {
803
+ (0, child_process_1.execSync)('git rev-parse --verify main', opts);
804
+ }
805
+ catch {
806
+ baseBranch = 'master';
807
+ }
808
+ return (0, child_process_1.execSync)(`git merge-base ${baseBranch} HEAD`, opts).trim();
809
+ }
810
+ default:
811
+ return `${target}~1`;
812
+ }
813
+ }
814
+ function extractSymbolCode(fileContent, lineStart, lineEnd) {
815
+ const lines = fileContent.split('\n');
816
+ return lines.slice(lineStart - 1, lineEnd).join('\n');
817
+ }
818
+ function findSymbolInOldFile(oldContent, symbolName, kind) {
819
+ // Try to find the symbol definition in the old file by matching typical patterns
820
+ const lines = oldContent.split('\n');
821
+ const escapedName = symbolName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
822
+ // Build patterns based on symbol kind
823
+ const patterns = [];
824
+ if (kind === 'function') {
825
+ patterns.push(new RegExp(`^\\s*(?:export\\s+)?(?:async\\s+)?function\\s+${escapedName}\\b`));
826
+ patterns.push(new RegExp(`^\\s*(?:export\\s+)?(?:const|let|var)\\s+${escapedName}\\s*=`));
827
+ }
828
+ else if (kind === 'class') {
829
+ patterns.push(new RegExp(`^\\s*(?:export\\s+)?(?:abstract\\s+)?class\\s+${escapedName}\\b`));
830
+ }
831
+ else if (kind === 'interface') {
832
+ patterns.push(new RegExp(`^\\s*(?:export\\s+)?interface\\s+${escapedName}\\b`));
833
+ }
834
+ else if (kind === 'type') {
835
+ patterns.push(new RegExp(`^\\s*(?:export\\s+)?type\\s+${escapedName}\\b`));
836
+ }
837
+ else if (kind === 'method') {
838
+ const methodName = symbolName.includes('.') ? symbolName.split('.').pop() : symbolName;
839
+ const escaped = methodName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
840
+ patterns.push(new RegExp(`^\\s*(?:async\\s+)?${escaped}\\s*\\(`));
841
+ patterns.push(new RegExp(`^\\s*(?:public|private|protected)?\\s*(?:async\\s+)?${escaped}\\s*\\(`));
842
+ }
843
+ else if (kind === 'variable') {
844
+ patterns.push(new RegExp(`^\\s*(?:export\\s+)?(?:const|let|var)\\s+${escapedName}\\b`));
845
+ }
846
+ else if (kind === 'enum') {
847
+ patterns.push(new RegExp(`^\\s*(?:export\\s+)?enum\\s+${escapedName}\\b`));
848
+ }
849
+ // Fallback: just look for the name
850
+ patterns.push(new RegExp(`\\b${escapedName}\\b`));
851
+ for (const pattern of patterns) {
852
+ for (let i = 0; i < lines.length; i++) {
853
+ if (pattern.test(lines[i])) {
854
+ // Found start, now find end by tracking braces
855
+ const startLine = i;
856
+ let braceDepth = 0;
857
+ let foundOpenBrace = false;
858
+ let endLine = i;
859
+ for (let j = i; j < lines.length; j++) {
860
+ for (const ch of lines[j]) {
861
+ if (ch === '{') {
862
+ braceDepth++;
863
+ foundOpenBrace = true;
864
+ }
865
+ else if (ch === '}') {
866
+ braceDepth--;
867
+ }
868
+ }
869
+ endLine = j;
870
+ if (foundOpenBrace && braceDepth <= 0)
871
+ break;
872
+ // For single-line declarations without braces
873
+ if (!foundOpenBrace && j > i && !lines[j + 1]?.match(/^\s/))
874
+ break;
875
+ }
876
+ return lines.slice(startLine, endLine + 1).join('\n');
877
+ }
878
+ }
879
+ }
880
+ return null;
881
+ }
882
+ function extractDiffForRange(rawDiff, filePath, lineStart, lineEnd) {
883
+ // Find the diff section for this file
884
+ const fileSections = rawDiff.split(/^diff --git /m).filter(Boolean);
885
+ for (const section of fileSections) {
886
+ if (!section.includes(filePath))
887
+ continue;
888
+ const lines = section.split('\n');
889
+ const result = [];
890
+ let inRelevantHunk = false;
891
+ let currentNewLine = 0;
892
+ const hunkRegex = /^@@\s+-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s+@@/;
893
+ for (const line of lines) {
894
+ const hunkMatch = line.match(hunkRegex);
895
+ if (hunkMatch) {
896
+ currentNewLine = parseInt(hunkMatch[3], 10);
897
+ const newCount = parseInt(hunkMatch[4] ?? '1', 10);
898
+ const hunkEnd = currentNewLine + newCount - 1;
899
+ // Check if this hunk overlaps with symbol range
900
+ inRelevantHunk = (hunkEnd >= lineStart && currentNewLine <= lineEnd);
901
+ if (inRelevantHunk) {
902
+ result.push(line);
903
+ }
904
+ continue;
905
+ }
906
+ if (inRelevantHunk) {
907
+ if (line.startsWith('+') || line.startsWith('-') || line.startsWith(' ')) {
908
+ if (!line.startsWith('-'))
909
+ currentNewLine++;
910
+ // Only include lines within or near the symbol range
911
+ if (currentNewLine >= lineStart - 2 && currentNewLine <= lineEnd + 2) {
912
+ result.push(line);
913
+ }
914
+ else if (line.startsWith('+') || line.startsWith('-')) {
915
+ // Always include actual changes
916
+ result.push(line);
917
+ }
918
+ }
919
+ }
920
+ }
921
+ if (result.length > 0)
922
+ return result.join('\n');
923
+ }
924
+ return '';
925
+ }
926
+ function describeChange(sym) {
927
+ const parts = [];
928
+ if (!sym.beforeCode && sym.afterCode) {
929
+ parts.push(`New ${sym.kind} added.`);
930
+ if (sym.exported)
931
+ parts.push('It is exported — other files can import it.');
932
+ if (sym.signature)
933
+ parts.push(`Signature: ${sym.signature}`);
934
+ return parts.join(' ');
935
+ }
936
+ if (sym.beforeCode && !sym.afterCode) {
937
+ parts.push(`This ${sym.kind} was deleted.`);
938
+ if (sym.exported)
939
+ parts.push('It was exported — anything importing it will break.');
940
+ return parts.join(' ');
941
+ }
942
+ if (!sym.beforeCode || !sym.afterCode) {
943
+ return `${sym.kind} was ${sym.hunkOverlap === 'full' ? 'fully rewritten' : 'partially modified'}.`;
944
+ }
945
+ const beforeLines = sym.beforeCode.split('\n');
946
+ const afterLines = sym.afterCode.split('\n');
947
+ const sizeDelta = afterLines.length - beforeLines.length;
948
+ // Analyze what changed between before and after
949
+ const beforeSig = beforeLines[0]?.trim() || '';
950
+ const afterSig = afterLines[0]?.trim() || '';
951
+ const sigChanged = beforeSig !== afterSig;
952
+ // Detect parameter changes
953
+ const paramRegex = /\(([^)]*)\)/;
954
+ const beforeParams = beforeSig.match(paramRegex)?.[1] || '';
955
+ const afterParams = afterSig.match(paramRegex)?.[1] || '';
956
+ const paramsChanged = beforeParams !== afterParams;
957
+ // Detect return type changes
958
+ const retRegex = /\):\s*(.+?)[\s{]/;
959
+ const beforeRet = beforeSig.match(retRegex)?.[1];
960
+ const afterRet = afterSig.match(retRegex)?.[1];
961
+ const retChanged = beforeRet !== afterRet && beforeRet && afterRet;
962
+ if (sym.hunkOverlap === 'full') {
963
+ parts.push(`Fully rewritten (${beforeLines.length} → ${afterLines.length} lines).`);
964
+ }
965
+ else {
966
+ parts.push(`Partially modified.`);
967
+ if (sizeDelta > 0)
968
+ parts.push(`${sizeDelta} lines added.`);
969
+ else if (sizeDelta < 0)
970
+ parts.push(`${Math.abs(sizeDelta)} lines removed.`);
971
+ }
972
+ if (sigChanged) {
973
+ parts.push(`Signature changed.`);
974
+ if (paramsChanged) {
975
+ const bCount = beforeParams ? beforeParams.split(',').length : 0;
976
+ const aCount = afterParams ? afterParams.split(',').length : 0;
977
+ if (aCount > bCount)
978
+ parts.push(`${aCount - bCount} new parameter(s) added.`);
979
+ else if (aCount < bCount)
980
+ parts.push(`${bCount - aCount} parameter(s) removed.`);
981
+ else
982
+ parts.push('Parameter types/names changed.');
983
+ }
984
+ }
985
+ if (retChanged) {
986
+ parts.push(`Return type changed: ${beforeRet} → ${afterRet}.`);
987
+ }
988
+ // Detect new error handling
989
+ const beforeHasTryCatch = /\btry\s*\{/.test(sym.beforeCode);
990
+ const afterHasTryCatch = /\btry\s*\{/.test(sym.afterCode);
991
+ if (!beforeHasTryCatch && afterHasTryCatch)
992
+ parts.push('Added error handling (try/catch).');
993
+ if (beforeHasTryCatch && !afterHasTryCatch)
994
+ parts.push('Removed error handling (try/catch).');
995
+ // Detect async changes
996
+ const wasAsync = /\basync\b/.test(sym.beforeCode);
997
+ const isAsync = /\basync\b/.test(sym.afterCode);
998
+ if (!wasAsync && isAsync)
999
+ parts.push('Now async.');
1000
+ if (wasAsync && !isAsync)
1001
+ parts.push('No longer async.');
1002
+ // Detect new conditionals
1003
+ const beforeIfs = (sym.beforeCode.match(/\bif\s*\(/g) || []).length;
1004
+ const afterIfs = (sym.afterCode.match(/\bif\s*\(/g) || []).length;
1005
+ if (afterIfs > beforeIfs)
1006
+ parts.push(`${afterIfs - beforeIfs} new conditional branch(es).`);
1007
+ if (afterIfs < beforeIfs)
1008
+ parts.push(`${beforeIfs - afterIfs} conditional branch(es) removed.`);
1009
+ // Detect loop changes
1010
+ const beforeLoops = (sym.beforeCode.match(/\b(for|while|forEach)\b/g) || []).length;
1011
+ const afterLoops = (sym.afterCode.match(/\b(for|while|forEach)\b/g) || []).length;
1012
+ if (afterLoops > beforeLoops)
1013
+ parts.push(`${afterLoops - beforeLoops} new loop(s).`);
1014
+ if (afterLoops < beforeLoops)
1015
+ parts.push(`${beforeLoops - afterLoops} loop(s) removed.`);
1016
+ return parts.join(' ');
1017
+ }
1018
+ function describeCallerImpact(caller, sym) {
1019
+ const parts = [];
1020
+ parts.push(`${caller.callerName} (${caller.callerKind} in ${caller.callerFile})`);
1021
+ parts.push(`calls ${sym.qualifiedName || sym.name}.`);
1022
+ if (!sym.beforeCode && sym.afterCode) {
1023
+ parts.push('This is a new symbol — caller was just added or is using new functionality.');
1024
+ }
1025
+ else if (sym.beforeCode && !sym.afterCode) {
1026
+ parts.push('This symbol was DELETED — this caller WILL BREAK.');
1027
+ }
1028
+ else {
1029
+ // Check if signature changed
1030
+ const beforeSig = sym.beforeCode?.split('\n')[0]?.trim() || '';
1031
+ const afterSig = sym.afterCode?.split('\n')[0]?.trim() || '';
1032
+ if (beforeSig !== afterSig) {
1033
+ parts.push(`Signature changed, so this caller may need updating.`);
1034
+ }
1035
+ else {
1036
+ parts.push('Signature unchanged — caller compiles fine, but behavior changed.');
1037
+ }
1038
+ }
1039
+ return parts.join(' ');
1040
+ }
1041
+ function transparentReview(db, rootDir, target = 'last_commit') {
1042
+ const rawDiff = getGitDiff(rootDir, target);
1043
+ if (!rawDiff.trim()) {
1044
+ return '# Transparent Review\n\nNo changes found for target: ' + target;
1045
+ }
1046
+ const diffFiles = parseDiff(rawDiff);
1047
+ const gitRef = getGitRef(rootDir, target);
1048
+ const out = [];
1049
+ out.push(`# Transparent Review — ${target}`);
1050
+ out.push('');
1051
+ // Quick stats
1052
+ const added = diffFiles.filter(f => f.status === 'added').length;
1053
+ const modified = diffFiles.filter(f => f.status === 'modified').length;
1054
+ const deleted = diffFiles.filter(f => f.status === 'deleted').length;
1055
+ const renamed = diffFiles.filter(f => f.status === 'renamed').length;
1056
+ const totalAdded = diffFiles.reduce((s, f) => s + f.addedLines, 0);
1057
+ const totalDeleted = diffFiles.reduce((s, f) => s + f.deletedLines, 0);
1058
+ const statParts = [];
1059
+ if (modified)
1060
+ statParts.push(`${modified} modified`);
1061
+ if (added)
1062
+ statParts.push(`${added} added`);
1063
+ if (deleted)
1064
+ statParts.push(`${deleted} deleted`);
1065
+ if (renamed)
1066
+ statParts.push(`${renamed} renamed`);
1067
+ out.push(`**${diffFiles.length} file(s):** ${statParts.join(', ')} — +${totalAdded} / -${totalDeleted} lines`);
1068
+ out.push('');
1069
+ // --- Per-file breakdown with full transparency ---
1070
+ const allSymbolStories = [];
1071
+ out.push('---');
1072
+ out.push('## What Changed (file by file)');
1073
+ out.push('');
1074
+ for (const df of diffFiles) {
1075
+ out.push(`### \`${df.path}\` — ${df.status}`);
1076
+ if (df.oldPath)
1077
+ out.push(` (renamed from \`${df.oldPath}\`)`);
1078
+ out.push(` +${df.addedLines} / -${df.deletedLines} lines`);
1079
+ out.push('');
1080
+ // Get before and after file content
1081
+ const beforeFile = getFileAtRef(rootDir, df.oldPath || df.path, gitRef);
1082
+ let afterFile = null;
1083
+ if (df.status !== 'deleted') {
1084
+ try {
1085
+ const fs = require('fs');
1086
+ const fullPath = require('path').join(rootDir, df.path);
1087
+ afterFile = fs.readFileSync(fullPath, 'utf-8');
1088
+ }
1089
+ catch {
1090
+ // For committed changes, get from HEAD
1091
+ afterFile = getFileAtRef(rootDir, df.path, 'HEAD');
1092
+ }
1093
+ }
1094
+ // Match hunks to symbols
1095
+ const { changed, unchangedExports } = matchHunksToSymbols(db, df);
1096
+ if (changed.length === 0 && df.status === 'modified') {
1097
+ out.push('No tracked symbols changed (changes may be in whitespace, comments, or untracked code).');
1098
+ out.push('');
1099
+ continue;
1100
+ }
1101
+ for (const sym of changed) {
1102
+ // For before code: find by name in old file (line numbers shift between versions)
1103
+ const beforeCode = beforeFile
1104
+ ? findSymbolInOldFile(beforeFile, sym.qualifiedName?.split('.').pop() || sym.name, sym.kind)
1105
+ : null;
1106
+ const afterCode = afterFile
1107
+ ? extractSymbolCode(afterFile, sym.lineStart, sym.lineEnd)
1108
+ : null;
1109
+ const diffSnippet = extractDiffForRange(rawDiff, df.path, sym.lineStart, sym.lineEnd);
1110
+ const story = {
1111
+ ...sym,
1112
+ beforeCode: beforeCode && beforeCode.trim() ? beforeCode : null,
1113
+ afterCode: afterCode && afterCode.trim() ? afterCode : null,
1114
+ diffSnippet,
1115
+ };
1116
+ allSymbolStories.push(story);
1117
+ const exportTag = sym.exported ? ' (exported)' : '';
1118
+ out.push(`#### \`${sym.qualifiedName || sym.name}\` — ${sym.kind}${exportTag}`);
1119
+ out.push('');
1120
+ // Plain English description
1121
+ const desc = describeChange(story);
1122
+ out.push(`**What changed:** ${desc}`);
1123
+ out.push('');
1124
+ // Show before/after code
1125
+ if (story.beforeCode && story.afterCode) {
1126
+ out.push('<details><summary>Before</summary>');
1127
+ out.push('');
1128
+ out.push('```');
1129
+ out.push(story.beforeCode);
1130
+ out.push('```');
1131
+ out.push('</details>');
1132
+ out.push('');
1133
+ out.push('<details><summary>After</summary>');
1134
+ out.push('');
1135
+ out.push('```');
1136
+ out.push(story.afterCode);
1137
+ out.push('```');
1138
+ out.push('</details>');
1139
+ out.push('');
1140
+ }
1141
+ else if (story.afterCode) {
1142
+ out.push('**New code:**');
1143
+ out.push('```');
1144
+ out.push(story.afterCode.length > 1500
1145
+ ? story.afterCode.slice(0, 1500) + '\n// ... truncated'
1146
+ : story.afterCode);
1147
+ out.push('```');
1148
+ out.push('');
1149
+ }
1150
+ else if (story.beforeCode) {
1151
+ out.push('**Deleted code:**');
1152
+ out.push('```');
1153
+ out.push(story.beforeCode.length > 1500
1154
+ ? story.beforeCode.slice(0, 1500) + '\n// ... truncated'
1155
+ : story.beforeCode);
1156
+ out.push('```');
1157
+ out.push('');
1158
+ }
1159
+ // Show the exact diff lines for this symbol
1160
+ if (story.diffSnippet) {
1161
+ out.push('<details><summary>Diff</summary>');
1162
+ out.push('');
1163
+ out.push('```diff');
1164
+ out.push(story.diffSnippet);
1165
+ out.push('```');
1166
+ out.push('</details>');
1167
+ out.push('');
1168
+ }
1169
+ }
1170
+ if (unchangedExports.length > 0) {
1171
+ out.push(`**Unchanged exports:** ${unchangedExports.join(', ')}`);
1172
+ out.push('');
1173
+ }
1174
+ }
1175
+ // --- Caller Impact Stories ---
1176
+ out.push('---');
1177
+ out.push('## Who Gets Affected');
1178
+ out.push('');
1179
+ const exportedChanged = allSymbolStories.filter(s => s.exported);
1180
+ if (exportedChanged.length === 0) {
1181
+ out.push('No exported symbols were changed — impact is contained to the files above.');
1182
+ out.push('');
1183
+ }
1184
+ else {
1185
+ const callerStories = [];
1186
+ const seenCallers = new Set();
1187
+ for (const sym of exportedChanged) {
1188
+ const callers = getCallers(db, sym.qualifiedName || sym.name);
1189
+ for (const caller of callers) {
1190
+ if (caller.file === sym.file)
1191
+ continue;
1192
+ const key = `${caller.name}:${caller.file}:${sym.name}`;
1193
+ if (seenCallers.has(key))
1194
+ continue;
1195
+ seenCallers.add(key);
1196
+ // Get just the caller's code (1 query, not 4 via getContext)
1197
+ const callerRow = cached(db, `
1198
+ SELECT s.content as code FROM symbols s
1199
+ WHERE s.name = ? OR s.qualified_name = ?
1200
+ ORDER BY s.exported DESC LIMIT 1
1201
+ `).get(caller.qualifiedName || caller.name, caller.qualifiedName || caller.name);
1202
+ const callerCode = callerRow?.code || null;
1203
+ callerStories.push({
1204
+ callerName: caller.qualifiedName || caller.name,
1205
+ callerFile: caller.file,
1206
+ callerKind: caller.kind,
1207
+ callerPagerank: caller.pagerank,
1208
+ callerSignature: caller.signature,
1209
+ callerCode: callerCode && callerCode.length > 800
1210
+ ? callerCode.slice(0, 800) + '\n// ... truncated'
1211
+ : callerCode,
1212
+ changedSymbol: sym.qualifiedName || sym.name,
1213
+ changedFile: sym.file,
1214
+ });
1215
+ }
1216
+ }
1217
+ callerStories.sort((a, b) => b.callerPagerank - a.callerPagerank);
1218
+ if (callerStories.length === 0) {
1219
+ out.push('No cross-file callers found for the changed exports.');
1220
+ out.push('');
1221
+ }
1222
+ else {
1223
+ out.push(`**${callerStories.length} caller(s)** in other files use the changed exports:`);
1224
+ out.push('');
1225
+ for (const cs of callerStories.slice(0, 25)) {
1226
+ const sym = allSymbolStories.find(s => (s.qualifiedName || s.name) === cs.changedSymbol);
1227
+ if (!sym)
1228
+ continue;
1229
+ const impact = describeCallerImpact(cs, sym);
1230
+ out.push(`- **${cs.callerName}** in \`${cs.callerFile}\``);
1231
+ out.push(` ${impact}`);
1232
+ if (cs.callerCode) {
1233
+ out.push(` <details><summary>Caller code</summary>`);
1234
+ out.push('');
1235
+ out.push(' ```');
1236
+ out.push(cs.callerCode);
1237
+ out.push(' ```');
1238
+ out.push(' </details>');
1239
+ }
1240
+ out.push('');
1241
+ }
1242
+ if (callerStories.length > 25) {
1243
+ out.push(`... and ${callerStories.length - 25} more callers.`);
1244
+ out.push('');
1245
+ }
1246
+ }
1247
+ }
1248
+ // --- Transitive blast radius ---
1249
+ out.push('---');
1250
+ out.push('## Blast Radius');
1251
+ out.push('');
1252
+ const transitiveImpact = [];
1253
+ const impactedFileSet = new Set();
1254
+ for (const df of diffFiles) {
1255
+ if (df.status === 'deleted')
1256
+ continue;
1257
+ const impact = getImpact(db, df.path, 3);
1258
+ for (const imp of impact) {
1259
+ if (!impactedFileSet.has(imp.file)) {
1260
+ impactedFileSet.add(imp.file);
1261
+ transitiveImpact.push(imp);
1262
+ }
1263
+ }
1264
+ }
1265
+ if (transitiveImpact.length === 0) {
1266
+ out.push('No transitive file dependencies detected — these changes are self-contained.');
1267
+ }
1268
+ else {
1269
+ out.push(`**${transitiveImpact.length} file(s)** could be transitively affected:`);
1270
+ out.push('');
1271
+ // Group by depth
1272
+ const byDepth = new Map();
1273
+ for (const imp of transitiveImpact) {
1274
+ if (!byDepth.has(imp.depth))
1275
+ byDepth.set(imp.depth, []);
1276
+ byDepth.get(imp.depth).push(imp);
1277
+ }
1278
+ for (const [depth, files] of [...byDepth.entries()].sort((a, b) => a[0] - b[0])) {
1279
+ const label = depth === 1 ? 'Direct importers' : `${depth} levels deep`;
1280
+ out.push(`**${label}** (${files.length} file${files.length > 1 ? 's' : ''}):`);
1281
+ for (const f of files.slice(0, 15)) {
1282
+ out.push(` - \`${f.file}\` (${f.symbolCount} symbols)`);
1283
+ }
1284
+ if (files.length > 15) {
1285
+ out.push(` - ... and ${files.length - 15} more`);
1286
+ }
1287
+ out.push('');
1288
+ }
1289
+ }
1290
+ // --- Risk Summary ---
1291
+ out.push('---');
1292
+ out.push('## Risk Summary');
1293
+ out.push('');
1294
+ const medianPagerank = getMedianPagerank(db);
1295
+ const risks = [];
1296
+ // High-importance symbols
1297
+ const highRank = allSymbolStories.filter(s => s.pagerank > medianPagerank && medianPagerank > 0);
1298
+ if (highRank.length > 0) {
1299
+ risks.push(`**High-importance code touched:** ${highRank.map(s => `\`${s.name}\` (rank #${s.pagerank.toFixed(6)})`).join(', ')} — these are among the most-referenced symbols in the project. Changes here ripple widely.`);
1300
+ }
1301
+ // Cascade risk
1302
+ for (const sym of exportedChanged) {
1303
+ const callerCount = allSymbolStories.length > 0
1304
+ ? getCallers(db, sym.qualifiedName || sym.name).filter(c => c.file !== sym.file).length
1305
+ : 0;
1306
+ if (callerCount >= 5) {
1307
+ risks.push(`**Cascade risk:** \`${sym.name}\` has ${callerCount} callers in other files. Behavioral changes will propagate to all of them.`);
1308
+ }
1309
+ }
1310
+ // Large blast radius
1311
+ if (transitiveImpact.length > 20) {
1312
+ risks.push(`**Wide blast radius:** ${transitiveImpact.length} files could be transitively affected. Consider testing downstream modules.`);
1313
+ }
1314
+ // Deleted files with dependents
1315
+ for (const df of diffFiles) {
1316
+ if (df.status !== 'deleted')
1317
+ continue;
1318
+ const impact = getImpact(db, df.path, 1);
1319
+ if (impact.length > 0) {
1320
+ risks.push(`**Broken imports likely:** Deleted \`${df.path}\` still has ${impact.length} file(s) importing from it: ${impact.slice(0, 5).map(i => `\`${i.file}\``).join(', ')}`);
1321
+ }
1322
+ }
1323
+ // Signature changes on exported symbols
1324
+ for (const sym of exportedChanged) {
1325
+ if (sym.beforeCode && sym.afterCode) {
1326
+ const bSig = sym.beforeCode.split('\n')[0]?.trim();
1327
+ const aSig = sym.afterCode.split('\n')[0]?.trim();
1328
+ if (bSig !== aSig) {
1329
+ risks.push(`**API change:** \`${sym.name}\` signature changed. Callers may need to update their call sites.`);
1330
+ }
1331
+ }
1332
+ }
1333
+ if (risks.length === 0) {
1334
+ out.push('No significant risks detected. Changes look contained and low-impact.');
1335
+ }
1336
+ else {
1337
+ for (const risk of risks) {
1338
+ out.push(`- ${risk}`);
1339
+ }
1340
+ }
1341
+ out.push('');
1342
+ return out.join('\n');
1343
+ }
1344
+ function transparentReviewFromRoot(rootDir, target) {
1345
+ return withDb(rootDir, db => transparentReview(db, rootDir, target));
1346
+ }
744
1347
  // --- Convenience wrappers for CLI (open/close DB internally) ---
745
1348
  function withDb(rootDir, fn) {
746
1349
  const db = (0, schema_1.openDatabase)(rootDir);