smart-context-mcp 0.8.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/src/index.js ADDED
@@ -0,0 +1,976 @@
1
+ import fs from 'node:fs';
2
+ import fsp from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import ts from 'typescript';
5
+ import { isBinaryBuffer } from './utils/fs.js';
6
+
7
+ const INDEX_VERSION = 4;
8
+
9
+ const MAX_SIGNATURE_LEN = 200;
10
+ const MAX_SNIPPET_LEN = 280;
11
+ const MAX_SNIPPET_LINES = 3;
12
+
13
+ const trimSignature = (raw) => {
14
+ if (!raw) return undefined;
15
+ const condensed = raw.replace(/\s+/g, ' ').trim();
16
+ return condensed.length > MAX_SIGNATURE_LEN
17
+ ? condensed.substring(0, MAX_SIGNATURE_LEN) + '...'
18
+ : condensed;
19
+ };
20
+
21
+ const trimSnippet = (raw) => {
22
+ if (!raw) return undefined;
23
+ const condensed = raw.replace(/\s+/g, ' ').trim();
24
+ return condensed.length > MAX_SNIPPET_LEN
25
+ ? condensed.substring(0, MAX_SNIPPET_LEN) + '...'
26
+ : condensed;
27
+ };
28
+
29
+ const buildSymbolSnippet = (content, line) => {
30
+ if (!line || line < 1) return undefined;
31
+
32
+ const lines = content.split('\n');
33
+ const start = Math.max(0, line - 1);
34
+ const snippetLines = [];
35
+
36
+ for (let i = start; i < Math.min(lines.length, start + MAX_SNIPPET_LINES); i++) {
37
+ const value = lines[i].trimEnd();
38
+ if (!value.trim() && snippetLines.length > 0) break;
39
+ snippetLines.push(value);
40
+ }
41
+
42
+ return trimSnippet(snippetLines.join('\n'));
43
+ };
44
+
45
+ const enrichSymbolsWithSnippets = (content, symbols = []) =>
46
+ symbols.map((sym) => {
47
+ const snippet = sym.snippet ?? buildSymbolSnippet(content, sym.line);
48
+ return snippet ? { ...sym, snippet } : sym;
49
+ });
50
+
51
+ const resolveIndexPath = (root) => {
52
+ if (process.env.DEVCTX_INDEX_DIR) {
53
+ return path.join(process.env.DEVCTX_INDEX_DIR, 'index.json');
54
+ }
55
+ return path.join(root, '.devctx', 'index.json');
56
+ };
57
+
58
+ const indexableExtensions = new Set([
59
+ '.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs',
60
+ '.py', '.go', '.rs', '.java',
61
+ '.cs', '.kt', '.php', '.swift',
62
+ ]);
63
+
64
+ const ignoredDirs = new Set([
65
+ 'node_modules', '.git', '.next', 'dist', 'build', 'coverage',
66
+ '.venv', 'venv', '__pycache__', '.terraform', '.devctx',
67
+ ]);
68
+
69
+ const scriptKindByExtension = {
70
+ '.js': ts.ScriptKind.JS,
71
+ '.jsx': ts.ScriptKind.JSX,
72
+ '.ts': ts.ScriptKind.TS,
73
+ '.tsx': ts.ScriptKind.TSX,
74
+ '.mjs': ts.ScriptKind.JS,
75
+ '.cjs': ts.ScriptKind.JS,
76
+ };
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // JS/TS extraction
80
+ // ---------------------------------------------------------------------------
81
+
82
+ const parseJsSource = (fullPath, content) => {
83
+ const ext = path.extname(fullPath).toLowerCase();
84
+ const kind = scriptKindByExtension[ext] ?? ts.ScriptKind.TS;
85
+ return ts.createSourceFile(fullPath, content, ts.ScriptTarget.Latest, true, kind);
86
+ };
87
+
88
+ const getNodeSignature = (node, sourceFile) => {
89
+ const start = node.getStart(sourceFile);
90
+ const text = sourceFile.text.substring(start);
91
+ const braceIdx = text.indexOf('{');
92
+ const raw = braceIdx > 0 ? text.substring(0, braceIdx) : text.split('\n')[0];
93
+ return trimSignature(raw);
94
+ };
95
+
96
+ const extractJsSymbolsFromAst = (sourceFile) => {
97
+ const symbols = [];
98
+
99
+ const addSymbol = (name, symbolKind, line, parent, signature) => {
100
+ if (!name) return;
101
+ const entry = { name, kind: symbolKind, line };
102
+ if (parent) entry.parent = parent;
103
+ if (signature) entry.signature = signature;
104
+ symbols.push(entry);
105
+ };
106
+
107
+ const visitMembers = (node, parentName) => {
108
+ ts.forEachChild(node, (child) => {
109
+ if (ts.isMethodDeclaration(child) || ts.isMethodSignature(child)) {
110
+ const name = child.name && ts.isIdentifier(child.name) ? child.name.text : null;
111
+ const line = sourceFile.getLineAndCharacterOfPosition(child.getStart(sourceFile)).line + 1;
112
+ addSymbol(name, 'method', line, parentName, getNodeSignature(child, sourceFile));
113
+ } else if (ts.isPropertyDeclaration(child) || ts.isPropertySignature(child)) {
114
+ const name = child.name && ts.isIdentifier(child.name) ? child.name.text : null;
115
+ const line = sourceFile.getLineAndCharacterOfPosition(child.getStart(sourceFile)).line + 1;
116
+ addSymbol(name, 'property', line, parentName);
117
+ }
118
+ });
119
+ };
120
+
121
+ for (const stmt of sourceFile.statements) {
122
+ const line = sourceFile.getLineAndCharacterOfPosition(stmt.getStart(sourceFile)).line + 1;
123
+ const sig = getNodeSignature(stmt, sourceFile);
124
+
125
+ if (ts.isFunctionDeclaration(stmt)) {
126
+ addSymbol(stmt.name?.text, 'function', line, undefined, sig);
127
+ } else if (ts.isClassDeclaration(stmt)) {
128
+ const className = stmt.name?.text;
129
+ addSymbol(className, 'class', line, undefined, sig);
130
+ if (className) visitMembers(stmt, className);
131
+ } else if (ts.isInterfaceDeclaration(stmt)) {
132
+ const ifName = stmt.name?.text;
133
+ addSymbol(ifName, 'interface', line, undefined, sig);
134
+ if (ifName) visitMembers(stmt, ifName);
135
+ } else if (ts.isTypeAliasDeclaration(stmt)) {
136
+ addSymbol(stmt.name?.text, 'type', line, undefined, sig);
137
+ } else if (ts.isEnumDeclaration(stmt)) {
138
+ addSymbol(stmt.name?.text, 'enum', line, undefined, sig);
139
+ } else if (ts.isVariableStatement(stmt)) {
140
+ for (const decl of stmt.declarationList.declarations) {
141
+ if (ts.isIdentifier(decl.name)) {
142
+ addSymbol(decl.name.text, 'const', line, undefined, sig);
143
+ }
144
+ }
145
+ }
146
+ }
147
+
148
+ return symbols;
149
+ };
150
+
151
+ const hasExportModifier = (node) => {
152
+ const mods = ts.canHaveModifiers(node) ? ts.getModifiers(node) : undefined;
153
+ return mods?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) ?? false;
154
+ };
155
+
156
+ const extractJsImportsExports = (sourceFile) => {
157
+ const imports = [];
158
+ const exports = [];
159
+
160
+ for (const stmt of sourceFile.statements) {
161
+ if (ts.isImportDeclaration(stmt) && ts.isStringLiteral(stmt.moduleSpecifier)) {
162
+ imports.push(stmt.moduleSpecifier.text);
163
+ }
164
+
165
+ if (ts.isExportDeclaration(stmt)) {
166
+ if (stmt.moduleSpecifier && ts.isStringLiteral(stmt.moduleSpecifier)) {
167
+ imports.push(stmt.moduleSpecifier.text);
168
+ }
169
+ if (stmt.exportClause && ts.isNamedExports(stmt.exportClause)) {
170
+ for (const spec of stmt.exportClause.elements) {
171
+ exports.push(spec.name.text);
172
+ }
173
+ }
174
+ }
175
+
176
+ if (ts.isExportAssignment(stmt)) {
177
+ exports.push('default');
178
+ }
179
+
180
+ if (hasExportModifier(stmt)) {
181
+ if (ts.isFunctionDeclaration(stmt) && stmt.name) exports.push(stmt.name.text);
182
+ else if (ts.isClassDeclaration(stmt) && stmt.name) exports.push(stmt.name.text);
183
+ else if (ts.isVariableStatement(stmt)) {
184
+ for (const decl of stmt.declarationList.declarations) {
185
+ if (ts.isIdentifier(decl.name)) exports.push(decl.name.text);
186
+ }
187
+ } else if (ts.isInterfaceDeclaration(stmt)) exports.push(stmt.name.text);
188
+ else if (ts.isTypeAliasDeclaration(stmt)) exports.push(stmt.name.text);
189
+ else if (ts.isEnumDeclaration(stmt)) exports.push(stmt.name.text);
190
+ }
191
+ }
192
+
193
+ return { imports, exports };
194
+ };
195
+
196
+ // ---------------------------------------------------------------------------
197
+ // Python extraction
198
+ // ---------------------------------------------------------------------------
199
+
200
+ const PYTHON_SYMBOL_RE = /^(class|def|async\s+def)\s+(\w+)/;
201
+
202
+ const extractPySymbols = (content) => {
203
+ const symbols = [];
204
+ const lines = content.split('\n');
205
+ let currentClass = null;
206
+
207
+ for (let i = 0; i < lines.length; i++) {
208
+ const trimmed = lines[i].trimStart();
209
+ const indent = lines[i].length - trimmed.length;
210
+ const match = PYTHON_SYMBOL_RE.exec(trimmed);
211
+ if (!match) continue;
212
+
213
+ const keyword = match[1].replace(/\s+/g, ' ');
214
+ const name = match[2];
215
+ const line = i + 1;
216
+ const signature = trimSignature(trimmed.replace(/:$/, ''));
217
+
218
+ if (keyword === 'class') {
219
+ currentClass = name;
220
+ symbols.push({ name, kind: 'class', line, signature });
221
+ } else if (indent > 0 && currentClass) {
222
+ symbols.push({ name, kind: 'method', line, parent: currentClass, signature });
223
+ } else {
224
+ currentClass = null;
225
+ symbols.push({ name, kind: 'function', line, signature });
226
+ }
227
+ }
228
+
229
+ return symbols;
230
+ };
231
+
232
+ const PY_IMPORT_RE = /^(?:from\s+(\S+)\s+import|import\s+(\S+))/;
233
+
234
+ const extractPyImports = (content) => {
235
+ const imports = [];
236
+ for (const line of content.split('\n')) {
237
+ const m = PY_IMPORT_RE.exec(line.trimStart());
238
+ if (m) imports.push(m[1] ?? m[2]);
239
+ }
240
+ return { imports, exports: [] };
241
+ };
242
+
243
+ // ---------------------------------------------------------------------------
244
+ // Go extraction
245
+ // ---------------------------------------------------------------------------
246
+
247
+ const GO_FUNC_RE = /^func\s+(?:\([\w\s*]+\)\s+)?(\w+)\s*\(/;
248
+ const GO_TYPE_RE = /^type\s+(\w+)\s+/;
249
+
250
+ const extractGoSymbols = (content) => {
251
+ const symbols = [];
252
+ const lines = content.split('\n');
253
+
254
+ for (let i = 0; i < lines.length; i++) {
255
+ const trimmed = lines[i].trimStart();
256
+ const funcMatch = GO_FUNC_RE.exec(trimmed);
257
+ if (funcMatch) {
258
+ symbols.push({ name: funcMatch[1], kind: 'function', line: i + 1, signature: trimSignature(trimmed) });
259
+ continue;
260
+ }
261
+ const typeMatch = GO_TYPE_RE.exec(trimmed);
262
+ if (typeMatch) {
263
+ symbols.push({ name: typeMatch[1], kind: 'type', line: i + 1, signature: trimSignature(trimmed) });
264
+ }
265
+ }
266
+
267
+ return symbols;
268
+ };
269
+
270
+ const extractGoImports = (content) => {
271
+ const imports = [];
272
+ const lines = content.split('\n');
273
+ let inBlock = false;
274
+
275
+ for (const line of lines) {
276
+ const trimmed = line.trimStart();
277
+ if (trimmed.startsWith('import (')) { inBlock = true; continue; }
278
+ if (inBlock && trimmed === ')') { inBlock = false; continue; }
279
+
280
+ if (inBlock || trimmed.startsWith('import "')) {
281
+ const m = /"([^"]+)"/.exec(trimmed);
282
+ if (m) imports.push(m[1]);
283
+ }
284
+ }
285
+
286
+ return { imports, exports: [] };
287
+ };
288
+
289
+ // ---------------------------------------------------------------------------
290
+ // Rust extraction
291
+ // ---------------------------------------------------------------------------
292
+
293
+ const RUST_ITEM_RE = /^(?:pub\s+)?(?:async\s+)?(fn|struct|enum|trait|type|impl|const|static)\s+(\w+)/;
294
+
295
+ const extractRustSymbols = (content) => {
296
+ const symbols = [];
297
+ const lines = content.split('\n');
298
+ let currentImpl = null;
299
+
300
+ for (let i = 0; i < lines.length; i++) {
301
+ const trimmed = lines[i].trimStart();
302
+ const match = RUST_ITEM_RE.exec(trimmed);
303
+ if (!match) continue;
304
+
305
+ const [, keyword, name] = match;
306
+ const line = i + 1;
307
+ const signature = trimSignature(trimmed);
308
+
309
+ if (keyword === 'impl') {
310
+ currentImpl = name;
311
+ symbols.push({ name, kind: 'impl', line, signature });
312
+ } else if (keyword === 'fn' && currentImpl && lines[i].startsWith(' ')) {
313
+ symbols.push({ name, kind: 'method', line, parent: currentImpl, signature });
314
+ } else {
315
+ if (keyword === 'fn') currentImpl = null;
316
+ symbols.push({ name, kind: keyword, line, signature });
317
+ }
318
+ }
319
+
320
+ return symbols;
321
+ };
322
+
323
+ // ---------------------------------------------------------------------------
324
+ // Java extraction
325
+ // ---------------------------------------------------------------------------
326
+
327
+ const JAVA_DECL_RE = /^(?:public|private|protected|static|final|abstract|\s)*(?:class|interface|enum|record)\s+(\w+)/;
328
+ const JAVA_METHOD_RE = /^(?:public|private|protected|static|final|abstract|synchronized|\s)*(?:<[\w\s,?]+>\s+)?[\w<>\[\],\s]+\s+(\w+)\s*\(/;
329
+
330
+ const extractJavaSymbols = (content) => {
331
+ const symbols = [];
332
+ const lines = content.split('\n');
333
+ let currentType = null;
334
+
335
+ for (let i = 0; i < lines.length; i++) {
336
+ const trimmed = lines[i].trimStart();
337
+ const declMatch = JAVA_DECL_RE.exec(trimmed);
338
+ if (declMatch) {
339
+ currentType = declMatch[1];
340
+ symbols.push({ name: declMatch[1], kind: 'class', line: i + 1, signature: trimSignature(trimmed) });
341
+ continue;
342
+ }
343
+ if (currentType) {
344
+ const methodMatch = JAVA_METHOD_RE.exec(trimmed);
345
+ if (methodMatch && !trimmed.includes(' new ') && !trimmed.includes('return ')) {
346
+ symbols.push({ name: methodMatch[1], kind: 'method', line: i + 1, parent: currentType, signature: trimSignature(trimmed) });
347
+ }
348
+ }
349
+ }
350
+
351
+ return symbols;
352
+ };
353
+
354
+ // ---------------------------------------------------------------------------
355
+ // C# extraction
356
+ // ---------------------------------------------------------------------------
357
+
358
+ const CSHARP_DECL_RE = /^(?:public|private|protected|internal|static|abstract|sealed|partial|\s)*(class|struct|interface|enum|record)\s+(\w+)/;
359
+ const CSHARP_METHOD_RE = /^(?:public|private|protected|internal|static|virtual|override|abstract|async|\s)*[\w<>\[\],.\s]+\s+(\w+)\s*\(/;
360
+ const CSHARP_USING_RE = /^using\s+([\w.]+);$/;
361
+
362
+ const extractCsharpSymbols = (content) => {
363
+ const symbols = [];
364
+ const lines = content.split('\n');
365
+ let currentType = null;
366
+ let braceDepth = 0;
367
+
368
+ for (let i = 0; i < lines.length; i++) {
369
+ const trimmed = lines[i].trimStart();
370
+ const declMatch = CSHARP_DECL_RE.exec(trimmed);
371
+ if (declMatch) {
372
+ currentType = declMatch[2];
373
+ symbols.push({ name: declMatch[2], kind: declMatch[1], line: i + 1, signature: trimSignature(trimmed) });
374
+ } else if (currentType) {
375
+ const methodMatch = CSHARP_METHOD_RE.exec(trimmed);
376
+ if (methodMatch && !trimmed.includes(' new ') && !trimmed.includes('return ') && !trimmed.startsWith('//')) {
377
+ symbols.push({ name: methodMatch[1], kind: 'method', line: i + 1, parent: currentType, signature: trimSignature(trimmed) });
378
+ }
379
+ }
380
+ braceDepth += (trimmed.match(/\{/g) ?? []).length;
381
+ braceDepth -= (trimmed.match(/\}/g) ?? []).length;
382
+ if (braceDepth <= 0) { currentType = null; braceDepth = 0; }
383
+ }
384
+
385
+ return symbols;
386
+ };
387
+
388
+ const extractCsharpImports = (content) => {
389
+ const imports = [];
390
+ for (const line of content.split('\n')) {
391
+ const m = CSHARP_USING_RE.exec(line.trim());
392
+ if (m) imports.push(m[1]);
393
+ }
394
+ return { imports, exports: [] };
395
+ };
396
+
397
+ // ---------------------------------------------------------------------------
398
+ // Kotlin extraction
399
+ // ---------------------------------------------------------------------------
400
+
401
+ const KOTLIN_DECL_RE = /^(?:open|abstract|data|sealed|internal|private|protected|\s)*(class|object|interface|enum)\s+(\w+)/;
402
+ const KOTLIN_FUN_RE = /^(?:(?:public|private|protected|internal|open|override|suspend|inline)\s+)*fun\s+(?:<[^>]+>\s+)?(\w+)\s*\(/;
403
+ const KOTLIN_IMPORT_RE = /^import\s+([\w.*]+)$/;
404
+
405
+ const extractKotlinSymbols = (content) => {
406
+ const symbols = [];
407
+ const lines = content.split('\n');
408
+ let currentType = null;
409
+ let braceDepth = 0;
410
+
411
+ for (let i = 0; i < lines.length; i++) {
412
+ const trimmed = lines[i].trimStart();
413
+ const declMatch = KOTLIN_DECL_RE.exec(trimmed);
414
+ if (declMatch) {
415
+ currentType = declMatch[2];
416
+ symbols.push({ name: declMatch[2], kind: declMatch[1], line: i + 1, signature: trimSignature(trimmed) });
417
+ continue;
418
+ }
419
+ const funMatch = KOTLIN_FUN_RE.exec(trimmed);
420
+ if (funMatch) {
421
+ if (currentType && lines[i].startsWith(' ')) {
422
+ symbols.push({ name: funMatch[1], kind: 'method', line: i + 1, parent: currentType, signature: trimSignature(trimmed) });
423
+ } else {
424
+ symbols.push({ name: funMatch[1], kind: 'function', line: i + 1, signature: trimSignature(trimmed) });
425
+ }
426
+ continue;
427
+ }
428
+ braceDepth += (trimmed.match(/\{/g) ?? []).length;
429
+ braceDepth -= (trimmed.match(/\}/g) ?? []).length;
430
+ if (braceDepth <= 0) { currentType = null; braceDepth = 0; }
431
+ }
432
+
433
+ return symbols;
434
+ };
435
+
436
+ const extractKotlinImports = (content) => {
437
+ const imports = [];
438
+ for (const line of content.split('\n')) {
439
+ const m = KOTLIN_IMPORT_RE.exec(line.trim());
440
+ if (m) imports.push(m[1]);
441
+ }
442
+ return { imports, exports: [] };
443
+ };
444
+
445
+ // ---------------------------------------------------------------------------
446
+ // PHP extraction
447
+ // ---------------------------------------------------------------------------
448
+
449
+ const PHP_DECL_RE = /^(?:abstract|final|\s)*(class|interface|trait|enum)\s+(\w+)/;
450
+ const PHP_FUNC_RE = /^(?:public|protected|private|static|\s)*function\s+(\w+)\s*\(/;
451
+ const PHP_USE_RE = /^use\s+([\w\\]+)(?:\s+as\s+\w+)?;$/;
452
+
453
+ const extractPhpSymbols = (content) => {
454
+ const symbols = [];
455
+ const lines = content.split('\n');
456
+ let currentType = null;
457
+ let braceDepth = 0;
458
+
459
+ for (let i = 0; i < lines.length; i++) {
460
+ const trimmed = lines[i].trimStart();
461
+ const declMatch = PHP_DECL_RE.exec(trimmed);
462
+ if (declMatch) {
463
+ currentType = declMatch[2];
464
+ symbols.push({ name: declMatch[2], kind: declMatch[1], line: i + 1, signature: trimSignature(trimmed) });
465
+ continue;
466
+ }
467
+ const funcMatch = PHP_FUNC_RE.exec(trimmed);
468
+ if (funcMatch) {
469
+ if (currentType && braceDepth > 0) {
470
+ symbols.push({ name: funcMatch[1], kind: 'method', line: i + 1, parent: currentType, signature: trimSignature(trimmed) });
471
+ } else {
472
+ symbols.push({ name: funcMatch[1], kind: 'function', line: i + 1, signature: trimSignature(trimmed) });
473
+ }
474
+ continue;
475
+ }
476
+ braceDepth += (trimmed.match(/\{/g) ?? []).length;
477
+ braceDepth -= (trimmed.match(/\}/g) ?? []).length;
478
+ if (braceDepth <= 0) { currentType = null; braceDepth = 0; }
479
+ }
480
+
481
+ return symbols;
482
+ };
483
+
484
+ const extractPhpImports = (content) => {
485
+ const imports = [];
486
+ for (const line of content.split('\n')) {
487
+ const m = PHP_USE_RE.exec(line.trim());
488
+ if (m) imports.push(m[1]);
489
+ }
490
+ return { imports, exports: [] };
491
+ };
492
+
493
+ // ---------------------------------------------------------------------------
494
+ // Swift extraction
495
+ // ---------------------------------------------------------------------------
496
+
497
+ const SWIFT_DECL_RE = /^(?:public|private|internal|open|final|\s)*(class|struct|enum|protocol|actor)\s+(\w+)/;
498
+ const SWIFT_FUNC_RE = /^(?:(?:public|private|internal|open|override|static|class|@\w+)\s+)*func\s+(\w+)/;
499
+ const SWIFT_IMPORT_RE = /^import\s+(\w+)$/;
500
+
501
+ const extractSwiftSymbols = (content) => {
502
+ const symbols = [];
503
+ const lines = content.split('\n');
504
+ let currentType = null;
505
+ let braceDepth = 0;
506
+
507
+ for (let i = 0; i < lines.length; i++) {
508
+ const trimmed = lines[i].trimStart();
509
+ const declMatch = SWIFT_DECL_RE.exec(trimmed);
510
+ if (declMatch) {
511
+ currentType = declMatch[2];
512
+ symbols.push({ name: declMatch[2], kind: declMatch[1], line: i + 1, signature: trimSignature(trimmed) });
513
+ continue;
514
+ }
515
+ const funcMatch = SWIFT_FUNC_RE.exec(trimmed);
516
+ if (funcMatch) {
517
+ if (currentType && braceDepth > 0) {
518
+ symbols.push({ name: funcMatch[1], kind: 'method', line: i + 1, parent: currentType, signature: trimSignature(trimmed) });
519
+ } else {
520
+ symbols.push({ name: funcMatch[1], kind: 'function', line: i + 1, signature: trimSignature(trimmed) });
521
+ }
522
+ continue;
523
+ }
524
+ braceDepth += (trimmed.match(/\{/g) ?? []).length;
525
+ braceDepth -= (trimmed.match(/\}/g) ?? []).length;
526
+ if (braceDepth <= 0) { currentType = null; braceDepth = 0; }
527
+ }
528
+
529
+ return symbols;
530
+ };
531
+
532
+ const extractSwiftImports = (content) => {
533
+ const imports = [];
534
+ for (const line of content.split('\n')) {
535
+ const m = SWIFT_IMPORT_RE.exec(line.trim());
536
+ if (m) imports.push(m[1]);
537
+ }
538
+ return { imports, exports: [] };
539
+ };
540
+
541
+ // ---------------------------------------------------------------------------
542
+ // Unified file info extraction
543
+ // ---------------------------------------------------------------------------
544
+
545
+ const extractFileInfo = (fullPath, content) => {
546
+ const ext = path.extname(fullPath).toLowerCase();
547
+
548
+ let info;
549
+
550
+ if (['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs'].includes(ext)) {
551
+ try {
552
+ const sourceFile = parseJsSource(fullPath, content);
553
+ info = {
554
+ symbols: extractJsSymbolsFromAst(sourceFile),
555
+ ...extractJsImportsExports(sourceFile),
556
+ };
557
+ } catch {
558
+ info = { symbols: [], imports: [], exports: [] };
559
+ }
560
+ } else if (ext === '.py') info = { symbols: extractPySymbols(content), ...extractPyImports(content) };
561
+ else if (ext === '.go') info = { symbols: extractGoSymbols(content), ...extractGoImports(content) };
562
+ else if (ext === '.rs') info = { symbols: extractRustSymbols(content), imports: [], exports: [] };
563
+ else if (ext === '.java') info = { symbols: extractJavaSymbols(content), imports: [], exports: [] };
564
+ else if (ext === '.cs') info = { symbols: extractCsharpSymbols(content), ...extractCsharpImports(content) };
565
+ else if (ext === '.kt') info = { symbols: extractKotlinSymbols(content), ...extractKotlinImports(content) };
566
+ else if (ext === '.php') info = { symbols: extractPhpSymbols(content), ...extractPhpImports(content) };
567
+ else if (ext === '.swift') info = { symbols: extractSwiftSymbols(content), ...extractSwiftImports(content) };
568
+ else info = { symbols: [], imports: [], exports: [] };
569
+
570
+ return {
571
+ ...info,
572
+ symbols: enrichSymbolsWithSnippets(content, info.symbols ?? []),
573
+ };
574
+ };
575
+
576
+ // ---------------------------------------------------------------------------
577
+ // Test file detection
578
+ // ---------------------------------------------------------------------------
579
+
580
+ const TEST_FILE_RE = /(?:\.(?:test|spec)\.[jt]sx?$|__tests__|_test\.go$|test_\w+\.py$|Tests?\.(?:cs|kt|swift)$|_test\.(?:cs|kt)$|Test\.php$)/;
581
+ export const isTestFile = (relPath) => TEST_FILE_RE.test(relPath);
582
+
583
+ // ---------------------------------------------------------------------------
584
+ // Import resolution
585
+ // ---------------------------------------------------------------------------
586
+
587
+ const resolveLocalImport = (specifier, fileDir, root, knownRelPaths) => {
588
+ if (!specifier.startsWith('.')) return null;
589
+
590
+ const abs = path.resolve(fileDir, specifier);
591
+ const rel = path.relative(root, abs).replace(/\\/g, '/');
592
+
593
+ if (knownRelPaths.has(rel)) return rel;
594
+
595
+ for (const ext of ['.js', '.ts', '.tsx', '.jsx', '.mjs', '.cjs']) {
596
+ const c = rel + ext;
597
+ if (knownRelPaths.has(c)) return c;
598
+ }
599
+
600
+ for (const ext of ['.js', '.ts', '.tsx', '.jsx']) {
601
+ const c = rel + '/index' + ext;
602
+ if (knownRelPaths.has(c)) return c;
603
+ }
604
+
605
+ return null;
606
+ };
607
+
608
+ const SOURCE_EXTENSIONS = ['.js', '.ts', '.tsx', '.jsx', '.py', '.go', '.rs', '.java', '.cs', '.kt', '.php', '.swift'];
609
+
610
+ const TEST_STRIP_RE = /\.(?:test|spec)\.[^.]+$|Tests?\.(cs|kt|swift)$|_test\.(cs|kt)$|Test\.php$/;
611
+
612
+ const inferTestTarget = (testRelPath, knownRelPaths) => {
613
+ const baseName = path.basename(testRelPath);
614
+ const base = baseName.replace(TEST_STRIP_RE, '');
615
+ const dir = path.dirname(testRelPath);
616
+ const parentDir = path.dirname(dir);
617
+ const prefix = dir === '.' ? '' : `${dir}/`;
618
+ const parentPrefix = parentDir === '.' ? '' : `${parentDir}/`;
619
+
620
+ for (const ext of SOURCE_EXTENSIONS) {
621
+ const c = `${prefix}${base}${ext}`;
622
+ if (knownRelPaths.has(c)) return c;
623
+ }
624
+
625
+ for (const srcDir of ['src', 'lib', 'pkg']) {
626
+ for (const ext of SOURCE_EXTENSIONS) {
627
+ const c = `${parentPrefix}${srcDir}/${base}${ext}`;
628
+ if (knownRelPaths.has(c)) return c;
629
+ }
630
+ }
631
+
632
+ return null;
633
+ };
634
+
635
+ // ---------------------------------------------------------------------------
636
+ // Directory walker
637
+ // ---------------------------------------------------------------------------
638
+
639
+ const walkForIndex = (dir, files = []) => {
640
+ let entries;
641
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return files; }
642
+
643
+ for (const entry of entries) {
644
+ if (ignoredDirs.has(entry.name)) continue;
645
+ const fullPath = path.join(dir, entry.name);
646
+
647
+ if (entry.isDirectory()) {
648
+ walkForIndex(fullPath, files);
649
+ } else if (indexableExtensions.has(path.extname(entry.name).toLowerCase())) {
650
+ files.push(fullPath);
651
+ }
652
+ }
653
+
654
+ return files;
655
+ };
656
+
657
+ // ---------------------------------------------------------------------------
658
+ // Build index
659
+ // ---------------------------------------------------------------------------
660
+
661
+ export const buildIndex = (root) => {
662
+ const files = walkForIndex(root);
663
+ const fileEntries = {};
664
+ const invertedIndex = {};
665
+ const rawImports = {};
666
+
667
+ for (const fullPath of files) {
668
+ try {
669
+ const stat = fs.statSync(fullPath);
670
+ if (stat.size > 512 * 1024) continue;
671
+
672
+ const buffer = fs.readFileSync(fullPath);
673
+ if (isBinaryBuffer(buffer)) continue;
674
+
675
+ const content = buffer.toString('utf8');
676
+ const info = extractFileInfo(fullPath, content);
677
+ if (info.symbols.length === 0 && info.imports.length === 0) continue;
678
+
679
+ const relPath = path.relative(root, fullPath).replace(/\\/g, '/');
680
+ fileEntries[relPath] = {
681
+ mtime: Math.floor(stat.mtimeMs),
682
+ symbols: info.symbols,
683
+ exports: info.exports,
684
+ };
685
+ rawImports[relPath] = info.imports;
686
+
687
+ for (const sym of info.symbols) {
688
+ const key = sym.name.toLowerCase();
689
+ if (!invertedIndex[key]) invertedIndex[key] = [];
690
+ const entry = { path: relPath, line: sym.line, kind: sym.kind };
691
+ if (sym.parent) entry.parent = sym.parent;
692
+ if (sym.signature) entry.signature = sym.signature;
693
+ if (sym.snippet) entry.snippet = sym.snippet;
694
+ invertedIndex[key].push(entry);
695
+ }
696
+ } catch {
697
+ // skip unreadable files
698
+ }
699
+ }
700
+
701
+ const knownRelPaths = new Set(Object.keys(fileEntries));
702
+ const edges = [];
703
+
704
+ for (const [relPath, specifiers] of Object.entries(rawImports)) {
705
+ const fileDir = path.resolve(root, path.dirname(relPath));
706
+ const testFile = isTestFile(relPath);
707
+
708
+ for (const spec of specifiers) {
709
+ const resolved = resolveLocalImport(spec, fileDir, root, knownRelPaths);
710
+ if (!resolved) continue;
711
+
712
+ edges.push({ from: relPath, to: resolved, kind: 'import' });
713
+ if (testFile) edges.push({ from: relPath, to: resolved, kind: 'testOf' });
714
+ }
715
+
716
+ if (testFile && !edges.some((e) => e.from === relPath && e.kind === 'testOf')) {
717
+ const target = inferTestTarget(relPath, knownRelPaths);
718
+ if (target) edges.push({ from: relPath, to: target, kind: 'testOf' });
719
+ }
720
+ }
721
+
722
+ return {
723
+ version: INDEX_VERSION,
724
+ generatedAt: new Date().toISOString(),
725
+ files: fileEntries,
726
+ invertedIndex,
727
+ graph: { edges },
728
+ };
729
+ };
730
+
731
+ // ---------------------------------------------------------------------------
732
+ // Query helpers
733
+ // ---------------------------------------------------------------------------
734
+
735
+ export const queryIndex = (index, symbolName) => {
736
+ if (!index?.invertedIndex) return [];
737
+ const key = symbolName.toLowerCase();
738
+ return index.invertedIndex[key] ?? [];
739
+ };
740
+
741
+ export const queryRelated = (index, relPath) => {
742
+ const result = { imports: [], importedBy: [], tests: [], neighbors: [] };
743
+ if (!index?.graph?.edges) return result;
744
+
745
+ for (const edge of index.graph.edges) {
746
+ if (edge.from === relPath && edge.kind === 'import') result.imports.push(edge.to);
747
+ if (edge.to === relPath && edge.kind === 'import') result.importedBy.push(edge.from);
748
+ if (edge.to === relPath && edge.kind === 'testOf') result.tests.push(edge.from);
749
+ }
750
+
751
+ const dir = path.dirname(relPath);
752
+ if (index.files) {
753
+ result.neighbors = Object.keys(index.files).filter((p) => p !== relPath && path.dirname(p) === dir);
754
+ }
755
+
756
+ return result;
757
+ };
758
+
759
+ // ---------------------------------------------------------------------------
760
+ // Graph coverage per language
761
+ // ---------------------------------------------------------------------------
762
+
763
+ const FULL_GRAPH_EXTS = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs', '.py', '.go']);
764
+ const PARTIAL_IMPORT_EXTS = new Set(['.cs', '.kt', '.php', '.swift']);
765
+ const INDEXED_EXTS = new Set([...FULL_GRAPH_EXTS, ...PARTIAL_IMPORT_EXTS, '.rs', '.java']);
766
+
767
+ export const getGraphCoverage = (ext) => {
768
+ const e = ext.toLowerCase();
769
+ if (FULL_GRAPH_EXTS.has(e)) return { imports: 'full', tests: 'full' };
770
+ if (PARTIAL_IMPORT_EXTS.has(e)) return { imports: 'partial', tests: 'partial' };
771
+ if (INDEXED_EXTS.has(e)) return { imports: 'none', tests: 'partial' };
772
+ return { imports: 'none', tests: 'none' };
773
+ };
774
+
775
+ // ---------------------------------------------------------------------------
776
+ // Staleness & incremental reindex
777
+ // ---------------------------------------------------------------------------
778
+
779
+ export const isFileStale = (index, relPath, currentMtimeMs) => {
780
+ const entry = index?.files?.[relPath];
781
+ if (!entry) return true;
782
+ return Math.floor(currentMtimeMs) !== entry.mtime;
783
+ };
784
+
785
+ export const reindexFile = (index, root, relPath) => {
786
+ const fullPath = path.join(root, relPath);
787
+
788
+ if (index.graph?.edges) {
789
+ index.graph.edges = index.graph.edges.filter((e) => e.from !== relPath);
790
+ }
791
+
792
+ try {
793
+ const stat = fs.statSync(fullPath);
794
+ const buffer = fs.readFileSync(fullPath);
795
+ if (isBinaryBuffer(buffer)) return;
796
+
797
+ const content = buffer.toString('utf8');
798
+ const info = extractFileInfo(fullPath, content);
799
+
800
+ const oldSymbols = index.files[relPath]?.symbols ?? [];
801
+ for (const sym of oldSymbols) {
802
+ const key = sym.name.toLowerCase();
803
+ if (index.invertedIndex[key]) {
804
+ index.invertedIndex[key] = index.invertedIndex[key].filter((e) => e.path !== relPath);
805
+ if (index.invertedIndex[key].length === 0) delete index.invertedIndex[key];
806
+ }
807
+ }
808
+
809
+ if (info.symbols.length === 0 && info.imports.length === 0) {
810
+ delete index.files[relPath];
811
+ return;
812
+ }
813
+
814
+ index.files[relPath] = {
815
+ mtime: Math.floor(stat.mtimeMs),
816
+ symbols: info.symbols,
817
+ exports: info.exports,
818
+ };
819
+
820
+ for (const sym of info.symbols) {
821
+ const key = sym.name.toLowerCase();
822
+ if (!index.invertedIndex[key]) index.invertedIndex[key] = [];
823
+ const invEntry = { path: relPath, line: sym.line, kind: sym.kind };
824
+ if (sym.parent) invEntry.parent = sym.parent;
825
+ if (sym.signature) invEntry.signature = sym.signature;
826
+ if (sym.snippet) invEntry.snippet = sym.snippet;
827
+ index.invertedIndex[key].push(invEntry);
828
+ }
829
+
830
+ if (!index.graph) index.graph = { edges: [] };
831
+ const knownRelPaths = new Set(Object.keys(index.files));
832
+ const fileDir = path.resolve(root, path.dirname(relPath));
833
+ const testFile = isTestFile(relPath);
834
+
835
+ for (const spec of info.imports) {
836
+ const resolved = resolveLocalImport(spec, fileDir, root, knownRelPaths);
837
+ if (!resolved) continue;
838
+ index.graph.edges.push({ from: relPath, to: resolved, kind: 'import' });
839
+ if (testFile) index.graph.edges.push({ from: relPath, to: resolved, kind: 'testOf' });
840
+ }
841
+
842
+ if (testFile && !index.graph.edges.some((e) => e.from === relPath && e.kind === 'testOf')) {
843
+ const target = inferTestTarget(relPath, knownRelPaths);
844
+ if (target) index.graph.edges.push({ from: relPath, to: target, kind: 'testOf' });
845
+ }
846
+ } catch {
847
+ if (index.files[relPath]) {
848
+ const oldSymbols = index.files[relPath].symbols ?? [];
849
+ for (const sym of oldSymbols) {
850
+ const key = sym.name.toLowerCase();
851
+ if (index.invertedIndex[key]) {
852
+ index.invertedIndex[key] = index.invertedIndex[key].filter((e) => e.path !== relPath);
853
+ if (index.invertedIndex[key].length === 0) delete index.invertedIndex[key];
854
+ }
855
+ }
856
+ delete index.files[relPath];
857
+ }
858
+ }
859
+ };
860
+
861
+ export const removeFileFromIndex = (index, relPath) => {
862
+ const oldSymbols = index.files?.[relPath]?.symbols ?? [];
863
+ for (const sym of oldSymbols) {
864
+ const key = sym.name.toLowerCase();
865
+ if (index.invertedIndex?.[key]) {
866
+ index.invertedIndex[key] = index.invertedIndex[key].filter((e) => e.path !== relPath);
867
+ if (index.invertedIndex[key].length === 0) delete index.invertedIndex[key];
868
+ }
869
+ }
870
+ if (index.graph?.edges) {
871
+ index.graph.edges = index.graph.edges.filter((e) => e.from !== relPath && e.to !== relPath);
872
+ }
873
+ delete index.files[relPath];
874
+ };
875
+
876
+ export const buildIndexIncremental = (root) => {
877
+ const existing = loadIndex(root);
878
+ if (!existing) {
879
+ const index = buildIndex(root);
880
+ const total = Object.keys(index.files).length;
881
+ return { index, stats: { total, reindexed: total, removed: 0, unchanged: 0, fullRebuild: true } };
882
+ }
883
+
884
+ const diskFiles = walkForIndex(root);
885
+ const diskRelPaths = new Set();
886
+ const reindexedPaths = [];
887
+ let unchanged = 0;
888
+
889
+ for (const fullPath of diskFiles) {
890
+ try {
891
+ const stat = fs.statSync(fullPath);
892
+ if (stat.size > 512 * 1024) continue;
893
+ const relPath = path.relative(root, fullPath).replace(/\\/g, '/');
894
+ diskRelPaths.add(relPath);
895
+
896
+ if (isFileStale(existing, relPath, stat.mtimeMs)) {
897
+ reindexFile(existing, root, relPath);
898
+ reindexedPaths.push(relPath);
899
+ } else {
900
+ unchanged++;
901
+ }
902
+ } catch { /* skip unreadable */ }
903
+ }
904
+
905
+ const indexedPaths = Object.keys(existing.files);
906
+ let removed = 0;
907
+ for (const relPath of indexedPaths) {
908
+ if (!diskRelPaths.has(relPath)) {
909
+ removeFileFromIndex(existing, relPath);
910
+ removed++;
911
+ }
912
+ }
913
+
914
+ if (reindexedPaths.length > 0) {
915
+ const knownRelPaths = new Set(Object.keys(existing.files));
916
+ if (!existing.graph) existing.graph = { edges: [] };
917
+
918
+ for (const relPath of reindexedPaths) {
919
+ existing.graph.edges = existing.graph.edges.filter((e) => e.from !== relPath);
920
+
921
+ const entry = existing.files[relPath];
922
+ if (!entry) continue;
923
+
924
+ const fullPath = path.join(root, relPath);
925
+ try {
926
+ const content = fs.readFileSync(fullPath, 'utf8');
927
+ const info = extractFileInfo(fullPath, content);
928
+ const fileDir = path.resolve(root, path.dirname(relPath));
929
+ const testFile = isTestFile(relPath);
930
+
931
+ for (const spec of info.imports) {
932
+ const resolved = resolveLocalImport(spec, fileDir, root, knownRelPaths);
933
+ if (!resolved) continue;
934
+ existing.graph.edges.push({ from: relPath, to: resolved, kind: 'import' });
935
+ if (testFile) existing.graph.edges.push({ from: relPath, to: resolved, kind: 'testOf' });
936
+ }
937
+
938
+ if (testFile && !existing.graph.edges.some((e) => e.from === relPath && e.kind === 'testOf')) {
939
+ const target = inferTestTarget(relPath, knownRelPaths);
940
+ if (target) existing.graph.edges.push({ from: relPath, to: target, kind: 'testOf' });
941
+ }
942
+ } catch { /* skip */ }
943
+ }
944
+ }
945
+
946
+ existing.generatedAt = new Date().toISOString();
947
+
948
+ const total = Object.keys(existing.files).length;
949
+ return { index: existing, stats: { total, reindexed: reindexedPaths.length, removed, unchanged, fullRebuild: false } };
950
+ };
951
+
952
+ // ---------------------------------------------------------------------------
953
+ // Persistence
954
+ // ---------------------------------------------------------------------------
955
+
956
+ export const persistIndex = async (index, root) => {
957
+ try {
958
+ const indexPath = resolveIndexPath(root);
959
+ await fsp.mkdir(path.dirname(indexPath), { recursive: true });
960
+ await fsp.writeFile(indexPath, JSON.stringify(index), 'utf8');
961
+ } catch {
962
+ // best-effort
963
+ }
964
+ };
965
+
966
+ export const loadIndex = (root) => {
967
+ try {
968
+ const indexPath = resolveIndexPath(root);
969
+ const raw = fs.readFileSync(indexPath, 'utf8');
970
+ const index = JSON.parse(raw);
971
+ if (index.version !== INDEX_VERSION) return null;
972
+ return index;
973
+ } catch {
974
+ return null;
975
+ }
976
+ };