smart-context-mcp 1.0.4 → 1.2.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 +196 -586
- package/package.json +11 -7
- package/scripts/init-clients.js +56 -27
- package/scripts/report-metrics.js +5 -0
- package/scripts/report-workflow-metrics.js +255 -0
- package/src/analytics/adoption.js +197 -0
- package/src/cache-warming.js +131 -0
- package/src/context-patterns.js +192 -0
- package/src/cross-project.js +343 -0
- package/src/diff-analysis.js +291 -0
- package/src/git-blame.js +324 -0
- package/src/index.js +54 -5
- package/src/metrics.js +6 -1
- package/src/server.js +199 -13
- package/src/storage/sqlite.js +50 -1
- package/src/streaming.js +152 -0
- package/src/tools/smart-context.js +115 -6
- package/src/tools/smart-metrics.js +7 -0
- package/src/tools/smart-read-batch.js +9 -0
- package/src/tools/smart-read.js +21 -1
- package/src/tools/smart-shell.js +33 -9
- package/src/tools/smart-turn.js +1 -0
- package/src/workflow-tracker-stub.js +53 -0
- package/src/workflow-tracker.js +410 -0
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Diff-aware context analysis for intelligent change-based retrieval.
|
|
3
|
+
*
|
|
4
|
+
* Analyzes git diffs to understand change impact and expand context intelligently.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { execFile as execFileCallback } from 'node:child_process';
|
|
8
|
+
import { promisify } from 'node:util';
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
|
|
12
|
+
const execFile = promisify(execFileCallback);
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Get detailed diff statistics for changed files.
|
|
16
|
+
*
|
|
17
|
+
* @param {string} ref - Git reference (e.g., 'HEAD', 'main')
|
|
18
|
+
* @param {string} root - Project root
|
|
19
|
+
* @returns {Promise<Array>} Array of { file, additions, deletions, changeType }
|
|
20
|
+
*/
|
|
21
|
+
export const getDetailedDiff = async (ref, root) => {
|
|
22
|
+
try {
|
|
23
|
+
const { stdout } = await execFile('git', ['diff', '--numstat', ref], {
|
|
24
|
+
cwd: root,
|
|
25
|
+
timeout: 10000,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const changes = [];
|
|
29
|
+
for (const line of stdout.split('\n')) {
|
|
30
|
+
if (!line.trim()) continue;
|
|
31
|
+
|
|
32
|
+
const parts = line.split('\t');
|
|
33
|
+
if (parts.length < 3) continue;
|
|
34
|
+
|
|
35
|
+
const [additions, deletions, file] = parts;
|
|
36
|
+
|
|
37
|
+
const adds = additions === '-' ? 0 : parseInt(additions, 10);
|
|
38
|
+
const dels = deletions === '-' ? 0 : parseInt(deletions, 10);
|
|
39
|
+
|
|
40
|
+
changes.push({
|
|
41
|
+
file,
|
|
42
|
+
additions: adds,
|
|
43
|
+
deletions: dels,
|
|
44
|
+
totalChanges: adds + dels,
|
|
45
|
+
changeType: classifyChange(adds, dels),
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return changes;
|
|
50
|
+
} catch (err) {
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Classify the type of change based on additions/deletions ratio.
|
|
57
|
+
*/
|
|
58
|
+
const classifyChange = (additions, deletions) => {
|
|
59
|
+
const total = additions + deletions;
|
|
60
|
+
if (total === 0) return 'unchanged';
|
|
61
|
+
|
|
62
|
+
const ratio = additions / total;
|
|
63
|
+
|
|
64
|
+
if (ratio > 0.9) return 'addition';
|
|
65
|
+
if (ratio < 0.1) return 'deletion';
|
|
66
|
+
if (Math.abs(ratio - 0.5) < 0.2) return 'refactor';
|
|
67
|
+
return 'modification';
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Analyze change impact and prioritize files.
|
|
72
|
+
*
|
|
73
|
+
* @param {Array} changes - Array from getDetailedDiff
|
|
74
|
+
* @param {object} index - Symbol index
|
|
75
|
+
* @returns {Array} Prioritized changes with impact scores
|
|
76
|
+
*/
|
|
77
|
+
export const analyzeChangeImpact = (changes, index) => {
|
|
78
|
+
return changes.map(change => {
|
|
79
|
+
const impactScore = calculateImpactScore(change, index);
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
...change,
|
|
83
|
+
impactScore,
|
|
84
|
+
priority: categorizePriority(impactScore, change),
|
|
85
|
+
};
|
|
86
|
+
}).sort((a, b) => b.impactScore - a.impactScore);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Calculate impact score for a changed file.
|
|
91
|
+
*/
|
|
92
|
+
const calculateImpactScore = (change, index) => {
|
|
93
|
+
let score = 0;
|
|
94
|
+
|
|
95
|
+
score += Math.min(change.totalChanges, 100);
|
|
96
|
+
|
|
97
|
+
if (isImplementationFile(change.file)) {
|
|
98
|
+
score += 50;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (index?.graph?.edges) {
|
|
102
|
+
const dependents = index.graph.edges.filter(e => e.to === change.file && e.kind === 'import');
|
|
103
|
+
score += dependents.length * 10;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (isTestFile(change.file)) {
|
|
107
|
+
score -= 20;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (isConfigFile(change.file) && change.totalChanges < 10) {
|
|
111
|
+
score -= 30;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return Math.max(0, score);
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const isImplementationFile = (filePath) => {
|
|
118
|
+
const ext = path.extname(filePath);
|
|
119
|
+
return ['.js', '.jsx', '.ts', '.tsx', '.py', '.go', '.rs', '.java'].includes(ext)
|
|
120
|
+
&& !isTestFile(filePath);
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const isTestFile = (filePath) => {
|
|
124
|
+
const patterns = ['.test.', '.spec.', '__tests__', '__mocks__', '/tests/', '/test/'];
|
|
125
|
+
return patterns.some(p => filePath.includes(p));
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const isConfigFile = (filePath) => {
|
|
129
|
+
const ext = path.extname(filePath);
|
|
130
|
+
const configExts = ['.json', '.yaml', '.yml', '.toml', '.config.js', '.config.ts'];
|
|
131
|
+
return configExts.some(e => filePath.endsWith(e)) ||
|
|
132
|
+
['Dockerfile', 'docker-compose', '.env', '.gitignore'].some(n => filePath.includes(n));
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const categorizePriority = (score, change) => {
|
|
136
|
+
if (score >= 100) return 'critical';
|
|
137
|
+
if (score >= 50) return 'high';
|
|
138
|
+
if (score >= 20) return 'medium';
|
|
139
|
+
return 'low';
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Expand changed files to include their context.
|
|
144
|
+
*
|
|
145
|
+
* @param {Array<string>} changedFiles - Changed file paths
|
|
146
|
+
* @param {object} index - Symbol index
|
|
147
|
+
* @param {number} maxExpansion - Max files to add
|
|
148
|
+
* @returns {Set<string>} Expanded file set
|
|
149
|
+
*/
|
|
150
|
+
export const expandChangedContext = (changedFiles, index, maxExpansion = 10) => {
|
|
151
|
+
const expanded = new Set(changedFiles);
|
|
152
|
+
const candidates = new Map();
|
|
153
|
+
|
|
154
|
+
if (!index?.graph?.edges) return expanded;
|
|
155
|
+
|
|
156
|
+
for (const changed of changedFiles) {
|
|
157
|
+
const importers = index.graph.edges
|
|
158
|
+
.filter(e => e.to === changed && e.kind === 'import')
|
|
159
|
+
.map(e => e.from);
|
|
160
|
+
|
|
161
|
+
for (const importer of importers) {
|
|
162
|
+
if (!expanded.has(importer)) {
|
|
163
|
+
const currentScore = candidates.get(importer) || 0;
|
|
164
|
+
candidates.set(importer, currentScore + 10);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const imports = index.graph.edges
|
|
169
|
+
.filter(e => e.from === changed && e.kind === 'import')
|
|
170
|
+
.map(e => e.to);
|
|
171
|
+
|
|
172
|
+
for (const imported of imports) {
|
|
173
|
+
if (!expanded.has(imported)) {
|
|
174
|
+
const currentScore = candidates.get(imported) || 0;
|
|
175
|
+
candidates.set(imported, currentScore + 5);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const tests = index.graph.edges
|
|
180
|
+
.filter(e => e.from !== changed && e.to === changed && e.kind === 'testOf')
|
|
181
|
+
.map(e => e.from);
|
|
182
|
+
|
|
183
|
+
for (const test of tests) {
|
|
184
|
+
if (!expanded.has(test)) {
|
|
185
|
+
const currentScore = candidates.get(test) || 0;
|
|
186
|
+
candidates.set(test, currentScore + 8);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const sorted = Array.from(candidates.entries())
|
|
192
|
+
.sort((a, b) => b[1] - a[1])
|
|
193
|
+
.slice(0, maxExpansion);
|
|
194
|
+
|
|
195
|
+
for (const [file] of sorted) {
|
|
196
|
+
expanded.add(file);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return expanded;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Generate a human-readable diff summary.
|
|
204
|
+
*
|
|
205
|
+
* @param {Array} changes - Prioritized changes from analyzeChangeImpact
|
|
206
|
+
* @returns {string} Summary text
|
|
207
|
+
*/
|
|
208
|
+
export const generateDiffSummary = (changes) => {
|
|
209
|
+
if (changes.length === 0) return 'No changes detected';
|
|
210
|
+
|
|
211
|
+
const byType = {
|
|
212
|
+
addition: [],
|
|
213
|
+
deletion: [],
|
|
214
|
+
modification: [],
|
|
215
|
+
refactor: [],
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
for (const change of changes) {
|
|
219
|
+
byType[change.changeType]?.push(change);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const lines = [];
|
|
223
|
+
|
|
224
|
+
const total = changes.reduce((sum, c) => sum + c.totalChanges, 0);
|
|
225
|
+
lines.push(`${changes.length} files changed, ${total} lines modified`);
|
|
226
|
+
|
|
227
|
+
if (byType.addition.length > 0) {
|
|
228
|
+
lines.push(` ${byType.addition.length} new files (+${byType.addition.reduce((s, c) => s + c.additions, 0)} lines)`);
|
|
229
|
+
}
|
|
230
|
+
if (byType.deletion.length > 0) {
|
|
231
|
+
lines.push(` ${byType.deletion.length} deletions (-${byType.deletion.reduce((s, c) => s + c.deletions, 0)} lines)`);
|
|
232
|
+
}
|
|
233
|
+
if (byType.modification.length > 0) {
|
|
234
|
+
lines.push(` ${byType.modification.length} modifications`);
|
|
235
|
+
}
|
|
236
|
+
if (byType.refactor.length > 0) {
|
|
237
|
+
lines.push(` ${byType.refactor.length} refactorings`);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const critical = changes.filter(c => c.priority === 'critical');
|
|
241
|
+
if (critical.length > 0) {
|
|
242
|
+
lines.push(`\nHigh-impact files (${critical.length}):`);
|
|
243
|
+
for (const change of critical.slice(0, 5)) {
|
|
244
|
+
lines.push(` - ${change.file} (+${change.additions}/-${change.deletions})`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return lines.join('\n');
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Extract changed function/class names from diff.
|
|
253
|
+
*
|
|
254
|
+
* @param {string} ref - Git reference
|
|
255
|
+
* @param {string} file - File path
|
|
256
|
+
* @param {string} root - Project root
|
|
257
|
+
* @returns {Promise<Array<string>>} Changed symbol names
|
|
258
|
+
*/
|
|
259
|
+
export const getChangedSymbols = async (ref, file, root) => {
|
|
260
|
+
try {
|
|
261
|
+
const { stdout } = await execFile('git', ['diff', '-U0', ref, '--', file], {
|
|
262
|
+
cwd: root,
|
|
263
|
+
timeout: 5000,
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
const symbols = new Set();
|
|
267
|
+
const lines = stdout.split('\n');
|
|
268
|
+
|
|
269
|
+
for (const line of lines) {
|
|
270
|
+
if (!line.startsWith('+')) continue;
|
|
271
|
+
|
|
272
|
+
const functionMatch = line.match(/\b(function|const|let|var)\s+(\w+)/);
|
|
273
|
+
const classMatch = line.match(/\bclass\s+(\w+)/);
|
|
274
|
+
const arrowMatch = line.match(/\b(\w+)\s*=\s*(?:async\s*)?\([^)]*\)\s*=>/);
|
|
275
|
+
|
|
276
|
+
if (functionMatch) symbols.add(functionMatch[2]);
|
|
277
|
+
if (classMatch) symbols.add(classMatch[1]);
|
|
278
|
+
if (arrowMatch) symbols.add(arrowMatch[1]);
|
|
279
|
+
|
|
280
|
+
const pyDefMatch = line.match(/\bdef\s+(\w+)/);
|
|
281
|
+
const pyClassMatch = line.match(/\bclass\s+(\w+)/);
|
|
282
|
+
|
|
283
|
+
if (pyDefMatch) symbols.add(pyDefMatch[1]);
|
|
284
|
+
if (pyClassMatch) symbols.add(pyClassMatch[1]);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return Array.from(symbols);
|
|
288
|
+
} catch {
|
|
289
|
+
return [];
|
|
290
|
+
}
|
|
291
|
+
};
|
package/src/git-blame.js
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Symbol-level git blame for fine-grained code attribution.
|
|
3
|
+
*
|
|
4
|
+
* Provides author information at function/class level instead of just file level.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { execFile as execFileCallback } from 'node:child_process';
|
|
8
|
+
import { promisify } from 'node:util';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { loadIndex } from './index.js';
|
|
11
|
+
import { projectRoot } from './utils/paths.js';
|
|
12
|
+
|
|
13
|
+
const execFile = promisify(execFileCallback);
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Get git blame data for a file with line-level attribution.
|
|
17
|
+
*
|
|
18
|
+
* @param {string} filePath - Relative path from project root
|
|
19
|
+
* @param {string} root - Project root
|
|
20
|
+
* @returns {Promise<Array>} Array of { line, author, email, date, commit, content }
|
|
21
|
+
*/
|
|
22
|
+
export const getFileBlame = async (filePath, root = projectRoot) => {
|
|
23
|
+
try {
|
|
24
|
+
const { stdout } = await execFile('git', [
|
|
25
|
+
'blame',
|
|
26
|
+
'--line-porcelain',
|
|
27
|
+
'--',
|
|
28
|
+
filePath
|
|
29
|
+
], {
|
|
30
|
+
cwd: root,
|
|
31
|
+
timeout: 10000,
|
|
32
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const lines = stdout.split('\n');
|
|
36
|
+
const blameData = [];
|
|
37
|
+
let currentCommit = null;
|
|
38
|
+
let currentAuthor = null;
|
|
39
|
+
let currentEmail = null;
|
|
40
|
+
let currentDate = null;
|
|
41
|
+
let lineNumber = 0;
|
|
42
|
+
|
|
43
|
+
for (let i = 0; i < lines.length; i++) {
|
|
44
|
+
const line = lines[i];
|
|
45
|
+
|
|
46
|
+
if (line.match(/^[0-9a-f]{40}/)) {
|
|
47
|
+
const parts = line.split(' ');
|
|
48
|
+
currentCommit = parts[0];
|
|
49
|
+
lineNumber = parseInt(parts[2], 10);
|
|
50
|
+
} else if (line.startsWith('author ')) {
|
|
51
|
+
currentAuthor = line.substring(7);
|
|
52
|
+
} else if (line.startsWith('author-mail ')) {
|
|
53
|
+
currentEmail = line.substring(12).replace(/[<>]/g, '');
|
|
54
|
+
} else if (line.startsWith('author-time ')) {
|
|
55
|
+
const timestamp = parseInt(line.substring(12), 10);
|
|
56
|
+
currentDate = new Date(timestamp * 1000).toISOString();
|
|
57
|
+
} else if (line.startsWith('\t')) {
|
|
58
|
+
const content = line.substring(1);
|
|
59
|
+
blameData.push({
|
|
60
|
+
line: lineNumber,
|
|
61
|
+
author: currentAuthor,
|
|
62
|
+
email: currentEmail,
|
|
63
|
+
date: currentDate,
|
|
64
|
+
commit: currentCommit,
|
|
65
|
+
content,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return blameData;
|
|
71
|
+
} catch (err) {
|
|
72
|
+
if (err.code === 'ENOENT' || err.stderr?.includes('no such path')) {
|
|
73
|
+
return [];
|
|
74
|
+
}
|
|
75
|
+
throw err;
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Get symbol-level blame information for a file.
|
|
81
|
+
*
|
|
82
|
+
* @param {string} filePath - Relative path from project root
|
|
83
|
+
* @param {string} root - Project root
|
|
84
|
+
* @returns {Promise<Array>} Array of { symbol, kind, author, email, date, commit, lineStart, lineEnd }
|
|
85
|
+
*/
|
|
86
|
+
export const getSymbolBlame = async (filePath, root = projectRoot) => {
|
|
87
|
+
const index = loadIndex(root);
|
|
88
|
+
if (!index?.files?.[filePath]) {
|
|
89
|
+
return [];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const fileInfo = index.files[filePath];
|
|
93
|
+
if (!fileInfo.symbols || fileInfo.symbols.length === 0) {
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const blameData = await getFileBlame(filePath, root);
|
|
98
|
+
if (blameData.length === 0) {
|
|
99
|
+
return [];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const symbolBlame = [];
|
|
103
|
+
|
|
104
|
+
for (const symbol of fileInfo.symbols) {
|
|
105
|
+
const lineStart = symbol.line;
|
|
106
|
+
const lineEnd = symbol.lineEnd || lineStart;
|
|
107
|
+
|
|
108
|
+
const relevantLines = blameData.filter(
|
|
109
|
+
b => b.line >= lineStart && b.line <= lineEnd
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
if (relevantLines.length === 0) continue;
|
|
113
|
+
|
|
114
|
+
const authorCounts = {};
|
|
115
|
+
for (const line of relevantLines) {
|
|
116
|
+
const key = `${line.author}|${line.email}`;
|
|
117
|
+
if (!authorCounts[key]) {
|
|
118
|
+
authorCounts[key] = {
|
|
119
|
+
author: line.author,
|
|
120
|
+
email: line.email,
|
|
121
|
+
commit: line.commit,
|
|
122
|
+
date: line.date,
|
|
123
|
+
count: 0,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
authorCounts[key].count++;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const sortedAuthors = Object.values(authorCounts).sort((a, b) => b.count - a.count);
|
|
130
|
+
const primaryAuthor = sortedAuthors[0];
|
|
131
|
+
|
|
132
|
+
const contributorCount = sortedAuthors.length;
|
|
133
|
+
const primaryPercentage = Math.round((primaryAuthor.count / relevantLines.length) * 100);
|
|
134
|
+
|
|
135
|
+
symbolBlame.push({
|
|
136
|
+
symbol: symbol.name,
|
|
137
|
+
kind: symbol.kind,
|
|
138
|
+
author: primaryAuthor.author,
|
|
139
|
+
email: primaryAuthor.email,
|
|
140
|
+
date: primaryAuthor.date,
|
|
141
|
+
commit: primaryAuthor.commit,
|
|
142
|
+
lineStart,
|
|
143
|
+
lineEnd,
|
|
144
|
+
linesAuthored: primaryAuthor.count,
|
|
145
|
+
totalLines: relevantLines.length,
|
|
146
|
+
authorshipPercentage: primaryPercentage,
|
|
147
|
+
contributors: contributorCount,
|
|
148
|
+
...(contributorCount > 1 ? { allContributors: sortedAuthors } : {}),
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return symbolBlame;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Get aggregated authorship statistics for a file.
|
|
157
|
+
*
|
|
158
|
+
* @param {string} filePath - Relative path from project root
|
|
159
|
+
* @param {string} root - Project root
|
|
160
|
+
* @returns {Promise<object>} Aggregated stats
|
|
161
|
+
*/
|
|
162
|
+
export const getFileAuthorshipStats = async (filePath, root = projectRoot) => {
|
|
163
|
+
const blameData = await getFileBlame(filePath, root);
|
|
164
|
+
if (blameData.length === 0) {
|
|
165
|
+
return {
|
|
166
|
+
totalLines: 0,
|
|
167
|
+
authors: [],
|
|
168
|
+
lastModified: null,
|
|
169
|
+
oldestLine: null,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const authorStats = {};
|
|
174
|
+
let mostRecentDate = null;
|
|
175
|
+
let oldestDate = null;
|
|
176
|
+
|
|
177
|
+
for (const line of blameData) {
|
|
178
|
+
const key = line.email;
|
|
179
|
+
if (!authorStats[key]) {
|
|
180
|
+
authorStats[key] = {
|
|
181
|
+
author: line.author,
|
|
182
|
+
email: line.email,
|
|
183
|
+
lines: 0,
|
|
184
|
+
commits: new Set(),
|
|
185
|
+
firstContribution: line.date,
|
|
186
|
+
lastContribution: line.date,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
authorStats[key].lines++;
|
|
191
|
+
authorStats[key].commits.add(line.commit);
|
|
192
|
+
|
|
193
|
+
if (!mostRecentDate || line.date > mostRecentDate) {
|
|
194
|
+
mostRecentDate = line.date;
|
|
195
|
+
}
|
|
196
|
+
if (!oldestDate || line.date < oldestDate) {
|
|
197
|
+
oldestDate = line.date;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (line.date < authorStats[key].firstContribution) {
|
|
201
|
+
authorStats[key].firstContribution = line.date;
|
|
202
|
+
}
|
|
203
|
+
if (line.date > authorStats[key].lastContribution) {
|
|
204
|
+
authorStats[key].lastContribution = line.date;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const authors = Object.values(authorStats)
|
|
209
|
+
.map(a => ({
|
|
210
|
+
author: a.author,
|
|
211
|
+
email: a.email,
|
|
212
|
+
lines: a.lines,
|
|
213
|
+
percentage: Math.round((a.lines / blameData.length) * 100),
|
|
214
|
+
commits: a.commits.size,
|
|
215
|
+
firstContribution: a.firstContribution,
|
|
216
|
+
lastContribution: a.lastContribution,
|
|
217
|
+
}))
|
|
218
|
+
.sort((a, b) => b.lines - a.lines);
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
totalLines: blameData.length,
|
|
222
|
+
authors,
|
|
223
|
+
lastModified: mostRecentDate,
|
|
224
|
+
oldestLine: oldestDate,
|
|
225
|
+
};
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Find symbols authored by a specific person.
|
|
230
|
+
*
|
|
231
|
+
* @param {string} authorQuery - Author name or email (partial match)
|
|
232
|
+
* @param {string} root - Project root
|
|
233
|
+
* @param {number} limit - Max results
|
|
234
|
+
* @returns {Promise<Array>} Array of { file, symbol, kind, author, email, percentage }
|
|
235
|
+
*/
|
|
236
|
+
export const findSymbolsByAuthor = async (authorQuery, root = projectRoot, limit = 50) => {
|
|
237
|
+
const index = loadIndex(root);
|
|
238
|
+
if (!index?.files) return [];
|
|
239
|
+
|
|
240
|
+
const normalizedQuery = authorQuery.toLowerCase();
|
|
241
|
+
const results = [];
|
|
242
|
+
|
|
243
|
+
const files = Object.keys(index.files).slice(0, 100);
|
|
244
|
+
|
|
245
|
+
for (const filePath of files) {
|
|
246
|
+
try {
|
|
247
|
+
const symbolBlame = await getSymbolBlame(filePath, root);
|
|
248
|
+
|
|
249
|
+
for (const sb of symbolBlame) {
|
|
250
|
+
const authorMatch = sb.author.toLowerCase().includes(normalizedQuery);
|
|
251
|
+
const emailMatch = sb.email.toLowerCase().includes(normalizedQuery);
|
|
252
|
+
|
|
253
|
+
if (authorMatch || emailMatch) {
|
|
254
|
+
results.push({
|
|
255
|
+
file: filePath,
|
|
256
|
+
symbol: sb.symbol,
|
|
257
|
+
kind: sb.kind,
|
|
258
|
+
author: sb.author,
|
|
259
|
+
email: sb.email,
|
|
260
|
+
authorshipPercentage: sb.authorshipPercentage,
|
|
261
|
+
lineStart: sb.lineStart,
|
|
262
|
+
lineEnd: sb.lineEnd,
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
if (results.length >= limit) {
|
|
266
|
+
return results;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
} catch {
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return results;
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Get recently modified symbols across the project.
|
|
280
|
+
*
|
|
281
|
+
* @param {string} root - Project root
|
|
282
|
+
* @param {number} limit - Max results
|
|
283
|
+
* @param {number} daysBack - How many days to look back
|
|
284
|
+
* @returns {Promise<Array>} Array of { file, symbol, kind, author, date, daysAgo }
|
|
285
|
+
*/
|
|
286
|
+
export const getRecentlyModifiedSymbols = async (root = projectRoot, limit = 20, daysBack = 30) => {
|
|
287
|
+
const index = loadIndex(root);
|
|
288
|
+
if (!index?.files) return [];
|
|
289
|
+
|
|
290
|
+
const cutoffDate = new Date();
|
|
291
|
+
cutoffDate.setDate(cutoffDate.getDate() - daysBack);
|
|
292
|
+
const cutoffISO = cutoffDate.toISOString();
|
|
293
|
+
|
|
294
|
+
const results = [];
|
|
295
|
+
const files = Object.keys(index.files).slice(0, 50);
|
|
296
|
+
|
|
297
|
+
for (const filePath of files) {
|
|
298
|
+
try {
|
|
299
|
+
const symbolBlame = await getSymbolBlame(filePath, root);
|
|
300
|
+
|
|
301
|
+
for (const sb of symbolBlame) {
|
|
302
|
+
if (sb.date >= cutoffISO) {
|
|
303
|
+
const daysAgo = Math.floor((Date.now() - new Date(sb.date).getTime()) / (1000 * 60 * 60 * 24));
|
|
304
|
+
|
|
305
|
+
results.push({
|
|
306
|
+
file: filePath,
|
|
307
|
+
symbol: sb.symbol,
|
|
308
|
+
kind: sb.kind,
|
|
309
|
+
author: sb.author,
|
|
310
|
+
email: sb.email,
|
|
311
|
+
date: sb.date,
|
|
312
|
+
daysAgo,
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
} catch {
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return results
|
|
322
|
+
.sort((a, b) => new Date(b.date) - new Date(a.date))
|
|
323
|
+
.slice(0, limit);
|
|
324
|
+
};
|
package/src/index.js
CHANGED
|
@@ -658,11 +658,18 @@ const walkForIndex = (dir, files = []) => {
|
|
|
658
658
|
// Build index
|
|
659
659
|
// ---------------------------------------------------------------------------
|
|
660
660
|
|
|
661
|
-
export const buildIndex = (root) => {
|
|
661
|
+
export const buildIndex = (root, progress = null) => {
|
|
662
662
|
const files = walkForIndex(root);
|
|
663
663
|
const fileEntries = {};
|
|
664
664
|
const invertedIndex = {};
|
|
665
665
|
const rawImports = {};
|
|
666
|
+
const total = files.length;
|
|
667
|
+
let processed = 0;
|
|
668
|
+
let lastReportAt = 0;
|
|
669
|
+
|
|
670
|
+
if (progress) {
|
|
671
|
+
progress.report({ phase: 'scanning', total });
|
|
672
|
+
}
|
|
666
673
|
|
|
667
674
|
for (const fullPath of files) {
|
|
668
675
|
try {
|
|
@@ -696,6 +703,26 @@ export const buildIndex = (root) => {
|
|
|
696
703
|
} catch {
|
|
697
704
|
// skip unreadable files
|
|
698
705
|
}
|
|
706
|
+
|
|
707
|
+
processed++;
|
|
708
|
+
|
|
709
|
+
// Report progress every 50 files or 5% of total
|
|
710
|
+
if (progress && (processed - lastReportAt >= 50 || processed - lastReportAt >= total * 0.05)) {
|
|
711
|
+
const percentage = Math.floor((processed / total) * 100);
|
|
712
|
+
progress.report({
|
|
713
|
+
phase: 'indexing',
|
|
714
|
+
processed,
|
|
715
|
+
total,
|
|
716
|
+
percentage,
|
|
717
|
+
files: Object.keys(fileEntries).length,
|
|
718
|
+
symbols: Object.keys(invertedIndex).length,
|
|
719
|
+
});
|
|
720
|
+
lastReportAt = processed;
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
if (progress) {
|
|
725
|
+
progress.report({ phase: 'resolving', total: Object.keys(rawImports).length });
|
|
699
726
|
}
|
|
700
727
|
|
|
701
728
|
const knownRelPaths = new Set(Object.keys(fileEntries));
|
|
@@ -873,10 +900,13 @@ export const removeFileFromIndex = (index, relPath) => {
|
|
|
873
900
|
delete index.files[relPath];
|
|
874
901
|
};
|
|
875
902
|
|
|
876
|
-
export const buildIndexIncremental = (root) => {
|
|
903
|
+
export const buildIndexIncremental = (root, progress = null) => {
|
|
877
904
|
const existing = loadIndex(root);
|
|
878
905
|
if (!existing) {
|
|
879
|
-
|
|
906
|
+
if (progress) {
|
|
907
|
+
progress.report({ phase: 'full_rebuild', reason: 'no_existing_index' });
|
|
908
|
+
}
|
|
909
|
+
const index = buildIndex(root, progress);
|
|
880
910
|
const total = Object.keys(index.files).length;
|
|
881
911
|
return { index, stats: { total, reindexed: total, removed: 0, unchanged: 0, fullRebuild: true } };
|
|
882
912
|
}
|
|
@@ -885,6 +915,12 @@ export const buildIndexIncremental = (root) => {
|
|
|
885
915
|
const diskRelPaths = new Set();
|
|
886
916
|
const reindexedPaths = [];
|
|
887
917
|
let unchanged = 0;
|
|
918
|
+
const total = diskFiles.length;
|
|
919
|
+
let processed = 0;
|
|
920
|
+
|
|
921
|
+
if (progress) {
|
|
922
|
+
progress.report({ phase: 'scanning', total });
|
|
923
|
+
}
|
|
888
924
|
|
|
889
925
|
for (const fullPath of diskFiles) {
|
|
890
926
|
try {
|
|
@@ -900,6 +936,19 @@ export const buildIndexIncremental = (root) => {
|
|
|
900
936
|
unchanged++;
|
|
901
937
|
}
|
|
902
938
|
} catch { /* skip unreadable */ }
|
|
939
|
+
|
|
940
|
+
processed++;
|
|
941
|
+
if (progress && processed % 100 === 0) {
|
|
942
|
+
const percentage = Math.floor((processed / total) * 100);
|
|
943
|
+
progress.report({
|
|
944
|
+
phase: 'checking',
|
|
945
|
+
processed,
|
|
946
|
+
total,
|
|
947
|
+
percentage,
|
|
948
|
+
stale: reindexedPaths.length,
|
|
949
|
+
unchanged,
|
|
950
|
+
});
|
|
951
|
+
}
|
|
903
952
|
}
|
|
904
953
|
|
|
905
954
|
const indexedPaths = Object.keys(existing.files);
|
|
@@ -945,8 +994,8 @@ export const buildIndexIncremental = (root) => {
|
|
|
945
994
|
|
|
946
995
|
existing.generatedAt = new Date().toISOString();
|
|
947
996
|
|
|
948
|
-
const
|
|
949
|
-
return { index: existing, stats: { total, reindexed: reindexedPaths.length, removed, unchanged, fullRebuild: false } };
|
|
997
|
+
const finalTotal = Object.keys(existing.files).length;
|
|
998
|
+
return { index: existing, stats: { total: finalTotal, reindexed: reindexedPaths.length, removed, unchanged, fullRebuild: false } };
|
|
950
999
|
};
|
|
951
1000
|
|
|
952
1001
|
// ---------------------------------------------------------------------------
|