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/LICENSE +21 -0
- package/README.md +414 -0
- package/package.json +63 -0
- package/scripts/devctx-server.js +4 -0
- package/scripts/init-clients.js +356 -0
- package/scripts/report-metrics.js +195 -0
- package/src/index.js +976 -0
- package/src/mcp-server.js +3 -0
- package/src/metrics.js +65 -0
- package/src/server.js +143 -0
- package/src/tokenCounter.js +12 -0
- package/src/tools/smart-context.js +1192 -0
- package/src/tools/smart-read/additional-languages.js +684 -0
- package/src/tools/smart-read/code.js +216 -0
- package/src/tools/smart-read/fallback.js +23 -0
- package/src/tools/smart-read/python.js +178 -0
- package/src/tools/smart-read/shared.js +39 -0
- package/src/tools/smart-read/structured.js +72 -0
- package/src/tools/smart-read-batch.js +63 -0
- package/src/tools/smart-read.js +459 -0
- package/src/tools/smart-search.js +412 -0
- package/src/tools/smart-shell.js +213 -0
- package/src/utils/fs.js +47 -0
- package/src/utils/paths.js +1 -0
- package/src/utils/runtime-config.js +29 -0
- package/src/utils/text.js +38 -0
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
|
+
};
|