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.
@@ -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
+ };
@@ -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
- const index = buildIndex(root);
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 total = Object.keys(existing.files).length;
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
  // ---------------------------------------------------------------------------