claude-ex 1.2.0 → 1.4.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/README.md +175 -31
- package/dist/claude/claudemd.js +94 -33
- package/dist/claude/claudemd.js.map +1 -1
- package/dist/claude/mcp.js +162 -139
- package/dist/claude/mcp.js.map +1 -1
- package/dist/db/schema.js +64 -13
- package/dist/db/schema.js.map +1 -1
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -1
- package/dist/indexer/index.js +39 -24
- package/dist/indexer/index.js.map +1 -1
- package/dist/query/engine.d.ts +2 -0
- package/dist/query/engine.js +740 -137
- package/dist/query/engine.js.map +1 -1
- package/dist/watcher/daemon.js +19 -12
- package/dist/watcher/daemon.js.map +1 -1
- package/package.json +36 -18
package/dist/query/engine.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
196
|
-
const
|
|
197
|
-
SELECT
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
|
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, '*')
|
|
241
|
-
.replace(/\.\*/g, '.*');
|
|
242
|
-
|
|
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
|
-
|
|
260
|
-
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
286
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
310
|
-
const suffix = totalExports > names.length ? ` +${totalExports - names.length} more` : '';
|
|
311
|
-
lines.push(`- \`${f.path}\` — ${
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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);
|