preflight-mcp 0.1.2 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -142
- package/README.zh-CN.md +141 -124
- package/dist/ast/treeSitter.js +588 -0
- package/dist/bundle/analysis.js +47 -0
- package/dist/bundle/context7.js +65 -36
- package/dist/bundle/facts.js +829 -0
- package/dist/bundle/githubArchive.js +49 -28
- package/dist/bundle/overview.js +226 -48
- package/dist/bundle/service.js +27 -126
- package/dist/config.js +29 -3
- package/dist/context7/client.js +5 -2
- package/dist/evidence/dependencyGraph.js +826 -0
- package/dist/http/server.js +109 -0
- package/dist/search/sqliteFts.js +150 -10
- package/dist/server.js +84 -295
- package/dist/trace/service.js +108 -0
- package/dist/trace/store.js +170 -0
- package/package.json +4 -2
- package/dist/bundle/deepwiki.js +0 -206
|
@@ -0,0 +1,826 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import * as z from 'zod';
|
|
5
|
+
import { extractImportRefsWasm } from '../ast/treeSitter.js';
|
|
6
|
+
import { findBundleStorageDir, getBundlePathsForId } from '../bundle/service.js';
|
|
7
|
+
import { readManifest } from '../bundle/manifest.js';
|
|
8
|
+
import { searchIndex } from '../search/sqliteFts.js';
|
|
9
|
+
import { safeJoin, toBundleFileUri } from '../mcp/uris.js';
|
|
10
|
+
export const DependencyGraphInputSchema = {
|
|
11
|
+
bundleId: z.string().describe('Bundle ID to analyze.'),
|
|
12
|
+
target: z.object({
|
|
13
|
+
file: z
|
|
14
|
+
.string()
|
|
15
|
+
.describe('Bundle-relative file path (posix). Example: repos/owner/repo/norm/src/index.ts'),
|
|
16
|
+
symbol: z
|
|
17
|
+
.string()
|
|
18
|
+
.optional()
|
|
19
|
+
.describe('Optional symbol name (function/class). If omitted, graph is file-level.'),
|
|
20
|
+
}),
|
|
21
|
+
options: z
|
|
22
|
+
.object({
|
|
23
|
+
maxFiles: z.number().int().min(1).max(500).default(200),
|
|
24
|
+
maxNodes: z.number().int().min(10).max(2000).default(300),
|
|
25
|
+
maxEdges: z.number().int().min(10).max(5000).default(800),
|
|
26
|
+
timeBudgetMs: z.number().int().min(1000).max(30_000).default(25_000),
|
|
27
|
+
})
|
|
28
|
+
.default({ maxFiles: 200, maxNodes: 300, maxEdges: 800, timeBudgetMs: 25_000 }),
|
|
29
|
+
};
|
|
30
|
+
function sha256Hex(text) {
|
|
31
|
+
return crypto.createHash('sha256').update(text, 'utf8').digest('hex');
|
|
32
|
+
}
|
|
33
|
+
function nowIso() {
|
|
34
|
+
return new Date().toISOString();
|
|
35
|
+
}
|
|
36
|
+
function makeEvidenceId(parts) {
|
|
37
|
+
return `e_${sha256Hex(parts.join('|')).slice(0, 24)}`;
|
|
38
|
+
}
|
|
39
|
+
function clampSnippet(s, maxLen) {
|
|
40
|
+
const t = s.replace(/\s+/g, ' ').trim();
|
|
41
|
+
if (t.length <= maxLen)
|
|
42
|
+
return t;
|
|
43
|
+
return t.slice(0, Math.max(0, maxLen - 1)) + '…';
|
|
44
|
+
}
|
|
45
|
+
function normalizeExt(p) {
|
|
46
|
+
return path.extname(p).toLowerCase();
|
|
47
|
+
}
|
|
48
|
+
function parseRepoNormPath(bundleRelativePath) {
|
|
49
|
+
const p = bundleRelativePath.replaceAll('\\', '/').replace(/^\/+/, '');
|
|
50
|
+
const parts = p.split('/').filter(Boolean);
|
|
51
|
+
if (parts.length < 5)
|
|
52
|
+
return null;
|
|
53
|
+
if (parts[0] !== 'repos')
|
|
54
|
+
return null;
|
|
55
|
+
const owner = parts[1];
|
|
56
|
+
const repo = parts[2];
|
|
57
|
+
if (!owner || !repo)
|
|
58
|
+
return null;
|
|
59
|
+
if (parts[3] !== 'norm')
|
|
60
|
+
return null;
|
|
61
|
+
const repoRelativePath = parts.slice(4).join('/');
|
|
62
|
+
if (!repoRelativePath)
|
|
63
|
+
return null;
|
|
64
|
+
return {
|
|
65
|
+
repoId: `${owner}/${repo}`,
|
|
66
|
+
repoRoot: `repos/${owner}/${repo}/norm`,
|
|
67
|
+
repoRelativePath,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
async function fileExists(absPath) {
|
|
71
|
+
try {
|
|
72
|
+
await fs.access(absPath);
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function extractImportsFromLinesHeuristic(filePath, lines) {
|
|
80
|
+
const ext = normalizeExt(filePath);
|
|
81
|
+
const isJs = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'].includes(ext);
|
|
82
|
+
const isPy = ext === '.py';
|
|
83
|
+
const isGo = ext === '.go';
|
|
84
|
+
const out = [];
|
|
85
|
+
for (let i = 0; i < lines.length; i++) {
|
|
86
|
+
const line = lines[i] ?? '';
|
|
87
|
+
const lineNo = i + 1;
|
|
88
|
+
const mk = (module, startCol, endCol) => ({
|
|
89
|
+
module,
|
|
90
|
+
range: { startLine: lineNo, startCol, endLine: lineNo, endCol },
|
|
91
|
+
method: 'heuristic',
|
|
92
|
+
confidence: 0.7,
|
|
93
|
+
notes: ['import extraction is regex-based; module resolution (if any) is best-effort and separate'],
|
|
94
|
+
});
|
|
95
|
+
if (isJs) {
|
|
96
|
+
// import ... from 'x' / export ... from 'x'
|
|
97
|
+
const m1 = line.match(/\bfrom\s+['"]([^'"]+)['"]/);
|
|
98
|
+
if (m1?.[1]) {
|
|
99
|
+
const idx = line.indexOf(m1[1]);
|
|
100
|
+
out.push(mk(m1[1], Math.max(1, idx + 1), Math.max(1, idx + 1 + m1[1].length)));
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
// import 'x'
|
|
104
|
+
const m2 = line.match(/\bimport\s+['"]([^'"]+)['"]/);
|
|
105
|
+
if (m2?.[1]) {
|
|
106
|
+
const idx = line.indexOf(m2[1]);
|
|
107
|
+
out.push(mk(m2[1], Math.max(1, idx + 1), Math.max(1, idx + 1 + m2[1].length)));
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
// require('x')
|
|
111
|
+
const m3 = line.match(/\brequire\s*\(\s*['"]([^'"]+)['"]/);
|
|
112
|
+
if (m3?.[1]) {
|
|
113
|
+
const idx = line.indexOf(m3[1]);
|
|
114
|
+
out.push(mk(m3[1], Math.max(1, idx + 1), Math.max(1, idx + 1 + m3[1].length)));
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (isPy) {
|
|
119
|
+
// import x
|
|
120
|
+
const m1 = line.match(/^\s*import\s+([a-zA-Z_][\w.]*)/);
|
|
121
|
+
if (m1?.[1]) {
|
|
122
|
+
const mod = m1[1].split('.')[0] ?? m1[1];
|
|
123
|
+
const idx = line.indexOf(m1[1]);
|
|
124
|
+
out.push(mk(mod, Math.max(1, idx + 1), Math.max(1, idx + 1 + mod.length)));
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
// from x import y
|
|
128
|
+
const m2 = line.match(/^\s*from\s+([a-zA-Z_][\w.]*)\s+import\b/);
|
|
129
|
+
if (m2?.[1]) {
|
|
130
|
+
const mod = m2[1].split('.')[0] ?? m2[1];
|
|
131
|
+
const idx = line.indexOf(m2[1]);
|
|
132
|
+
out.push(mk(mod, Math.max(1, idx + 1), Math.max(1, idx + 1 + mod.length)));
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (isGo) {
|
|
137
|
+
// import "x"
|
|
138
|
+
const m = line.match(/\bimport\s+['"]([^'"]+)['"]/);
|
|
139
|
+
if (m?.[1]) {
|
|
140
|
+
const idx = line.indexOf(m[1]);
|
|
141
|
+
out.push(mk(m[1], Math.max(1, idx + 1), Math.max(1, idx + 1 + m[1].length)));
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
147
|
+
async function extractImportsForFile(cfg, filePath, normalizedContent, lines, warnings) {
|
|
148
|
+
const astEngine = cfg.astEngine ?? 'wasm';
|
|
149
|
+
if (astEngine !== 'wasm') {
|
|
150
|
+
return { imports: extractImportsFromLinesHeuristic(filePath, lines), usedAst: false, usedFallback: true };
|
|
151
|
+
}
|
|
152
|
+
try {
|
|
153
|
+
const parsed = await extractImportRefsWasm(filePath, normalizedContent);
|
|
154
|
+
if (!parsed) {
|
|
155
|
+
return { imports: extractImportsFromLinesHeuristic(filePath, lines), usedAst: false, usedFallback: true };
|
|
156
|
+
}
|
|
157
|
+
const imports = [];
|
|
158
|
+
for (const imp of parsed.imports) {
|
|
159
|
+
if (imp.range.startLine !== imp.range.endLine)
|
|
160
|
+
continue; // module specifiers should be single-line
|
|
161
|
+
imports.push({
|
|
162
|
+
module: imp.module,
|
|
163
|
+
range: imp.range,
|
|
164
|
+
method: 'exact',
|
|
165
|
+
confidence: 0.9,
|
|
166
|
+
notes: [`import extraction is parser-backed (tree-sitter:${imp.language}:${imp.kind}); module resolution (if any) is best-effort and separate`],
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
return { imports, usedAst: true, usedFallback: false };
|
|
170
|
+
}
|
|
171
|
+
catch (err) {
|
|
172
|
+
warnings.push({
|
|
173
|
+
code: 'ast_import_extraction_failed',
|
|
174
|
+
message: `AST/WASM import extraction failed; falling back to regex: ${err instanceof Error ? err.message : String(err)}`,
|
|
175
|
+
});
|
|
176
|
+
return { imports: extractImportsFromLinesHeuristic(filePath, lines), usedAst: false, usedFallback: true };
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
function isLikelyCallSite(line, symbol) {
|
|
180
|
+
// Avoid regex DoS by escaping symbol.
|
|
181
|
+
const escaped = symbol.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
182
|
+
const re = new RegExp(`\\b${escaped}\\s*\\(`);
|
|
183
|
+
const m = line.match(re);
|
|
184
|
+
if (!m || m.index === undefined)
|
|
185
|
+
return null;
|
|
186
|
+
const startCol = m.index + 1;
|
|
187
|
+
const endCol = startCol + Math.max(1, symbol.length);
|
|
188
|
+
return { startCol, endCol };
|
|
189
|
+
}
|
|
190
|
+
export async function generateDependencyGraph(cfg, rawArgs) {
|
|
191
|
+
const args = z.object(DependencyGraphInputSchema).parse(rawArgs);
|
|
192
|
+
const startedAt = Date.now();
|
|
193
|
+
const requestId = crypto.randomUUID();
|
|
194
|
+
const storageDir = await findBundleStorageDir(cfg.storageDirs, args.bundleId);
|
|
195
|
+
if (!storageDir) {
|
|
196
|
+
throw new Error(`Bundle not found: ${args.bundleId}`);
|
|
197
|
+
}
|
|
198
|
+
const paths = getBundlePathsForId(storageDir, args.bundleId);
|
|
199
|
+
const manifest = await readManifest(paths.manifestPath);
|
|
200
|
+
const limits = {
|
|
201
|
+
maxFiles: args.options.maxFiles,
|
|
202
|
+
maxNodes: args.options.maxNodes,
|
|
203
|
+
maxEdges: args.options.maxEdges,
|
|
204
|
+
};
|
|
205
|
+
const nodes = new Map();
|
|
206
|
+
const edges = [];
|
|
207
|
+
const warnings = [];
|
|
208
|
+
let truncated = false;
|
|
209
|
+
let truncatedReason;
|
|
210
|
+
const timeBudgetMs = args.options.timeBudgetMs;
|
|
211
|
+
const timeLeft = () => timeBudgetMs - (Date.now() - startedAt);
|
|
212
|
+
const checkBudget = (reason) => {
|
|
213
|
+
if (truncated)
|
|
214
|
+
return true;
|
|
215
|
+
if (timeLeft() <= 0) {
|
|
216
|
+
truncated = true;
|
|
217
|
+
truncatedReason = reason;
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
if (edges.length >= limits.maxEdges) {
|
|
221
|
+
truncated = true;
|
|
222
|
+
truncatedReason = 'maxEdges reached';
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
if (nodes.size >= limits.maxNodes) {
|
|
226
|
+
truncated = true;
|
|
227
|
+
truncatedReason = 'maxNodes reached';
|
|
228
|
+
return true;
|
|
229
|
+
}
|
|
230
|
+
return false;
|
|
231
|
+
};
|
|
232
|
+
const addNode = (n) => {
|
|
233
|
+
if (nodes.has(n.id))
|
|
234
|
+
return;
|
|
235
|
+
nodes.set(n.id, n);
|
|
236
|
+
};
|
|
237
|
+
const addEdge = (e) => {
|
|
238
|
+
if (edges.length >= limits.maxEdges) {
|
|
239
|
+
truncated = true;
|
|
240
|
+
truncatedReason = 'maxEdges reached';
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
edges.push(e);
|
|
244
|
+
};
|
|
245
|
+
const bundleFileUri = (p) => toBundleFileUri({ bundleId: args.bundleId, relativePath: p });
|
|
246
|
+
const targetFile = args.target.file.replaceAll('\\', '/');
|
|
247
|
+
const targetRepo = parseRepoNormPath(targetFile);
|
|
248
|
+
const targetFileId = `file:${targetFile}`;
|
|
249
|
+
addNode({ id: targetFileId, kind: 'file', name: targetFile, file: targetFile });
|
|
250
|
+
const targetSymbol = args.target.symbol?.trim();
|
|
251
|
+
const targetSymbolId = targetSymbol ? `symbol:${targetSymbol}@${targetFile}` : targetFileId;
|
|
252
|
+
if (targetSymbol) {
|
|
253
|
+
addNode({
|
|
254
|
+
id: targetSymbolId,
|
|
255
|
+
kind: 'symbol',
|
|
256
|
+
name: targetSymbol,
|
|
257
|
+
file: targetFile,
|
|
258
|
+
attrs: { role: 'target' },
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
// 1) Downstream: imports from target file
|
|
262
|
+
let usedAstForImports = false;
|
|
263
|
+
try {
|
|
264
|
+
const absTarget = safeJoin(paths.rootDir, targetFile);
|
|
265
|
+
const raw = await fs.readFile(absTarget, 'utf8');
|
|
266
|
+
const normalized = raw.replace(/\r\n/g, '\n');
|
|
267
|
+
const lines = normalized.split('\n');
|
|
268
|
+
const extracted = await extractImportsForFile(cfg, targetFile, normalized, lines, warnings);
|
|
269
|
+
usedAstForImports = extracted.usedAst;
|
|
270
|
+
const resolvedCache = new Map(); // module -> bundle-relative file path
|
|
271
|
+
const goModuleCache = new Map(); // dir -> go.mod info
|
|
272
|
+
let rustCrateRoot;
|
|
273
|
+
const normalizeDir = (d) => (d === '.' ? '' : d);
|
|
274
|
+
const resolveImportToFile = async (module) => {
|
|
275
|
+
const cached = resolvedCache.get(module);
|
|
276
|
+
if (cached !== undefined)
|
|
277
|
+
return cached;
|
|
278
|
+
if (!targetRepo) {
|
|
279
|
+
resolvedCache.set(module, null);
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
const importerRel = targetRepo.repoRelativePath;
|
|
283
|
+
const ext = normalizeExt(importerRel);
|
|
284
|
+
const cleaned = (module.split(/[?#]/, 1)[0] ?? '').trim();
|
|
285
|
+
if (!cleaned) {
|
|
286
|
+
resolvedCache.set(module, null);
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
const bundlePathForRepoRel = (repoRel) => `${targetRepo.repoRoot}/${repoRel.replaceAll('\\', '/')}`;
|
|
290
|
+
const isJs = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'].includes(ext);
|
|
291
|
+
if (isJs) {
|
|
292
|
+
if (!cleaned.startsWith('.') && !cleaned.startsWith('/')) {
|
|
293
|
+
resolvedCache.set(module, null);
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
const importerDir = normalizeDir(path.posix.dirname(importerRel));
|
|
297
|
+
const base = cleaned.startsWith('/')
|
|
298
|
+
? path.posix.normalize(cleaned.slice(1))
|
|
299
|
+
: path.posix.normalize(path.posix.join(importerDir, cleaned));
|
|
300
|
+
const candidates = [];
|
|
301
|
+
const baseExt = path.posix.extname(base).toLowerCase();
|
|
302
|
+
const add = (repoRel) => {
|
|
303
|
+
candidates.push(repoRel);
|
|
304
|
+
};
|
|
305
|
+
if (baseExt) {
|
|
306
|
+
add(base);
|
|
307
|
+
// TS projects sometimes import './x.js' but source is './x.ts'
|
|
308
|
+
if (baseExt === '.js' || baseExt === '.mjs' || baseExt === '.cjs') {
|
|
309
|
+
const stem = base.slice(0, -baseExt.length);
|
|
310
|
+
add(`${stem}.ts`);
|
|
311
|
+
add(`${stem}.tsx`);
|
|
312
|
+
add(`${stem}.jsx`);
|
|
313
|
+
}
|
|
314
|
+
if (baseExt === '.jsx') {
|
|
315
|
+
const stem = base.slice(0, -baseExt.length);
|
|
316
|
+
add(`${stem}.tsx`);
|
|
317
|
+
add(`${stem}.ts`);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
else {
|
|
321
|
+
const exts = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'];
|
|
322
|
+
for (const e of exts)
|
|
323
|
+
add(`${base}${e}`);
|
|
324
|
+
for (const e of exts)
|
|
325
|
+
add(path.posix.join(base, `index${e}`));
|
|
326
|
+
}
|
|
327
|
+
for (const repoRel of candidates) {
|
|
328
|
+
const bundleRel = bundlePathForRepoRel(repoRel);
|
|
329
|
+
const abs = safeJoin(paths.rootDir, bundleRel);
|
|
330
|
+
if (await fileExists(abs)) {
|
|
331
|
+
resolvedCache.set(module, bundleRel);
|
|
332
|
+
return bundleRel;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
resolvedCache.set(module, null);
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
if (ext === '.py') {
|
|
339
|
+
// Best-effort Python module resolution (deterministic, file-existence based).
|
|
340
|
+
// - Relative imports: .foo / ..foo.bar
|
|
341
|
+
// - Absolute imports: pkg.sub (tries repo root + src/ + importer top-level dir)
|
|
342
|
+
// Relative imports
|
|
343
|
+
if (cleaned.startsWith('.')) {
|
|
344
|
+
const m = cleaned.match(/^(\.+)(.*)$/);
|
|
345
|
+
if (!m) {
|
|
346
|
+
resolvedCache.set(module, null);
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
const dotCount = m[1]?.length ?? 0;
|
|
350
|
+
const rest = (m[2] ?? '').replace(/^\.+/, '');
|
|
351
|
+
let baseDir = normalizeDir(path.posix.dirname(importerRel));
|
|
352
|
+
for (let i = 1; i < dotCount; i++) {
|
|
353
|
+
baseDir = normalizeDir(path.posix.dirname(baseDir));
|
|
354
|
+
}
|
|
355
|
+
const restPath = rest ? rest.replace(/\./g, '/') : '';
|
|
356
|
+
const candidatesRepoRel = [];
|
|
357
|
+
if (restPath) {
|
|
358
|
+
candidatesRepoRel.push(normalizeDir(path.posix.join(baseDir, `${restPath}.py`)));
|
|
359
|
+
candidatesRepoRel.push(normalizeDir(path.posix.join(baseDir, restPath, '__init__.py')));
|
|
360
|
+
}
|
|
361
|
+
else {
|
|
362
|
+
candidatesRepoRel.push(normalizeDir(path.posix.join(baseDir, '__init__.py')));
|
|
363
|
+
}
|
|
364
|
+
for (const repoRel of candidatesRepoRel) {
|
|
365
|
+
const bundleRel = bundlePathForRepoRel(repoRel);
|
|
366
|
+
const abs = safeJoin(paths.rootDir, bundleRel);
|
|
367
|
+
if (await fileExists(abs)) {
|
|
368
|
+
resolvedCache.set(module, bundleRel);
|
|
369
|
+
return bundleRel;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
resolvedCache.set(module, null);
|
|
373
|
+
return null;
|
|
374
|
+
}
|
|
375
|
+
// Absolute imports
|
|
376
|
+
const modulePath = cleaned.replace(/\./g, '/');
|
|
377
|
+
const topLevelDir = importerRel.includes('/') ? importerRel.split('/')[0] ?? '' : '';
|
|
378
|
+
// Candidate roots in deterministic order (prefer the importer's layout if possible).
|
|
379
|
+
const roots = [];
|
|
380
|
+
if (importerRel.startsWith('src/'))
|
|
381
|
+
roots.push('src');
|
|
382
|
+
const moduleStartsWithTop = topLevelDir ? modulePath === topLevelDir || modulePath.startsWith(`${topLevelDir}/`) : false;
|
|
383
|
+
if (topLevelDir && topLevelDir !== 'src' && !moduleStartsWithTop)
|
|
384
|
+
roots.push(topLevelDir);
|
|
385
|
+
roots.push('');
|
|
386
|
+
if (!roots.includes('src'))
|
|
387
|
+
roots.push('src');
|
|
388
|
+
const matches = [];
|
|
389
|
+
for (const root of roots) {
|
|
390
|
+
const base = root ? `${root}/${modulePath}` : modulePath;
|
|
391
|
+
const repoRelFile = `${base}.py`;
|
|
392
|
+
const bundleRelFile = bundlePathForRepoRel(repoRelFile);
|
|
393
|
+
const absFile = safeJoin(paths.rootDir, bundleRelFile);
|
|
394
|
+
if (await fileExists(absFile)) {
|
|
395
|
+
matches.push({ root, repoRel: repoRelFile, bundleRel: bundleRelFile });
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
const repoRelInit = `${base}/__init__.py`;
|
|
399
|
+
const bundleRelInit = bundlePathForRepoRel(repoRelInit);
|
|
400
|
+
const absInit = safeJoin(paths.rootDir, bundleRelInit);
|
|
401
|
+
if (await fileExists(absInit)) {
|
|
402
|
+
matches.push({ root, repoRel: repoRelInit, bundleRel: bundleRelInit });
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
if (matches.length === 0) {
|
|
406
|
+
resolvedCache.set(module, null);
|
|
407
|
+
return null;
|
|
408
|
+
}
|
|
409
|
+
if (matches.length === 1) {
|
|
410
|
+
const only = matches[0].bundleRel;
|
|
411
|
+
resolvedCache.set(module, only);
|
|
412
|
+
return only;
|
|
413
|
+
}
|
|
414
|
+
// If multiple matches exist, pick the one in the importer's own root (if any).
|
|
415
|
+
const preferred = matches.filter((m) => m.root && importerRel.startsWith(`${m.root}/`));
|
|
416
|
+
if (preferred.length === 1) {
|
|
417
|
+
const only = preferred[0].bundleRel;
|
|
418
|
+
resolvedCache.set(module, only);
|
|
419
|
+
return only;
|
|
420
|
+
}
|
|
421
|
+
// Ambiguous.
|
|
422
|
+
resolvedCache.set(module, null);
|
|
423
|
+
return null;
|
|
424
|
+
}
|
|
425
|
+
if (ext === '.go') {
|
|
426
|
+
// Best-effort: resolve in-module imports using nearest go.mod's module path.
|
|
427
|
+
const importerDir = normalizeDir(path.posix.dirname(importerRel));
|
|
428
|
+
const findGoModule = async () => {
|
|
429
|
+
let cur = importerDir;
|
|
430
|
+
while (true) {
|
|
431
|
+
const cached = goModuleCache.get(cur);
|
|
432
|
+
if (cached !== undefined)
|
|
433
|
+
return cached;
|
|
434
|
+
const goModRepoRel = cur ? `${cur}/go.mod` : 'go.mod';
|
|
435
|
+
const goModBundleRel = bundlePathForRepoRel(goModRepoRel);
|
|
436
|
+
let mod = null;
|
|
437
|
+
const abs = safeJoin(paths.rootDir, goModBundleRel);
|
|
438
|
+
if (await fileExists(abs)) {
|
|
439
|
+
try {
|
|
440
|
+
const raw = await fs.readFile(abs, 'utf8');
|
|
441
|
+
const content = raw.replace(/\r\n/g, '\n');
|
|
442
|
+
for (const line of content.split('\n')) {
|
|
443
|
+
const t = line.trim();
|
|
444
|
+
if (!t || t.startsWith('//'))
|
|
445
|
+
continue;
|
|
446
|
+
const m = t.match(/^module\s+(\S+)/);
|
|
447
|
+
if (m?.[1]) {
|
|
448
|
+
mod = { moduleRootDir: cur, modulePath: m[1] };
|
|
449
|
+
break;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
catch {
|
|
454
|
+
// ignore
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
goModuleCache.set(cur, mod);
|
|
458
|
+
if (mod)
|
|
459
|
+
return mod;
|
|
460
|
+
if (!cur)
|
|
461
|
+
return null;
|
|
462
|
+
cur = normalizeDir(path.posix.dirname(cur));
|
|
463
|
+
}
|
|
464
|
+
};
|
|
465
|
+
const mod = await findGoModule();
|
|
466
|
+
if (!mod) {
|
|
467
|
+
resolvedCache.set(module, null);
|
|
468
|
+
return null;
|
|
469
|
+
}
|
|
470
|
+
if (cleaned !== mod.modulePath && !cleaned.startsWith(`${mod.modulePath}/`)) {
|
|
471
|
+
resolvedCache.set(module, null);
|
|
472
|
+
return null;
|
|
473
|
+
}
|
|
474
|
+
const sub = cleaned === mod.modulePath ? '' : cleaned.slice(mod.modulePath.length + 1);
|
|
475
|
+
const pkgDirRepoRel = normalizeDir(path.posix.join(mod.moduleRootDir, sub));
|
|
476
|
+
const pkgDirBundleRel = pkgDirRepoRel
|
|
477
|
+
? `${targetRepo.repoRoot}/${pkgDirRepoRel}`
|
|
478
|
+
: targetRepo.repoRoot;
|
|
479
|
+
try {
|
|
480
|
+
const absDir = safeJoin(paths.rootDir, pkgDirBundleRel);
|
|
481
|
+
const entries = await fs.readdir(absDir, { withFileTypes: true });
|
|
482
|
+
const goFiles = entries
|
|
483
|
+
.filter((e) => e.isFile() && e.name.endsWith('.go'))
|
|
484
|
+
.map((e) => e.name)
|
|
485
|
+
.sort();
|
|
486
|
+
const picked = goFiles.find((n) => !n.endsWith('_test.go')) ?? goFiles[0];
|
|
487
|
+
if (!picked) {
|
|
488
|
+
resolvedCache.set(module, null);
|
|
489
|
+
return null;
|
|
490
|
+
}
|
|
491
|
+
const bundleRel = `${pkgDirBundleRel}/${picked}`;
|
|
492
|
+
resolvedCache.set(module, bundleRel);
|
|
493
|
+
return bundleRel;
|
|
494
|
+
}
|
|
495
|
+
catch {
|
|
496
|
+
resolvedCache.set(module, null);
|
|
497
|
+
return null;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
if (ext === '.rs') {
|
|
501
|
+
// Resolve crate/self/super imports to actual module files (best-effort).
|
|
502
|
+
const importerDir = normalizeDir(path.posix.dirname(importerRel));
|
|
503
|
+
const repoRelFileExists = async (repoRel) => {
|
|
504
|
+
const bundleRel = bundlePathForRepoRel(repoRel);
|
|
505
|
+
const abs = safeJoin(paths.rootDir, bundleRel);
|
|
506
|
+
return fileExists(abs);
|
|
507
|
+
};
|
|
508
|
+
const findRustCrateRoot = async () => {
|
|
509
|
+
if (rustCrateRoot !== undefined)
|
|
510
|
+
return rustCrateRoot;
|
|
511
|
+
let cur = importerDir;
|
|
512
|
+
while (true) {
|
|
513
|
+
// try <cur>/lib.rs or <cur>/main.rs
|
|
514
|
+
if (cur) {
|
|
515
|
+
const lib = `${cur}/lib.rs`;
|
|
516
|
+
if (await repoRelFileExists(lib)) {
|
|
517
|
+
rustCrateRoot = { crateRootDir: cur, crateRootFileRel: lib };
|
|
518
|
+
return rustCrateRoot;
|
|
519
|
+
}
|
|
520
|
+
const main = `${cur}/main.rs`;
|
|
521
|
+
if (await repoRelFileExists(main)) {
|
|
522
|
+
rustCrateRoot = { crateRootDir: cur, crateRootFileRel: main };
|
|
523
|
+
return rustCrateRoot;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
// try <cur>/src/lib.rs or <cur>/src/main.rs
|
|
527
|
+
const lib2 = `${cur ? cur + '/' : ''}src/lib.rs`;
|
|
528
|
+
if (await repoRelFileExists(lib2)) {
|
|
529
|
+
const crateDir = normalizeDir(path.posix.dirname(lib2));
|
|
530
|
+
rustCrateRoot = { crateRootDir: crateDir, crateRootFileRel: lib2 };
|
|
531
|
+
return rustCrateRoot;
|
|
532
|
+
}
|
|
533
|
+
const main2 = `${cur ? cur + '/' : ''}src/main.rs`;
|
|
534
|
+
if (await repoRelFileExists(main2)) {
|
|
535
|
+
const crateDir = normalizeDir(path.posix.dirname(main2));
|
|
536
|
+
rustCrateRoot = { crateRootDir: crateDir, crateRootFileRel: main2 };
|
|
537
|
+
return rustCrateRoot;
|
|
538
|
+
}
|
|
539
|
+
if (!cur)
|
|
540
|
+
break;
|
|
541
|
+
cur = normalizeDir(path.posix.dirname(cur));
|
|
542
|
+
}
|
|
543
|
+
rustCrateRoot = null;
|
|
544
|
+
return null;
|
|
545
|
+
};
|
|
546
|
+
const crate = await findRustCrateRoot();
|
|
547
|
+
if (!crate) {
|
|
548
|
+
resolvedCache.set(module, null);
|
|
549
|
+
return null;
|
|
550
|
+
}
|
|
551
|
+
const moduleDirForFile = (fileRepoRel) => {
|
|
552
|
+
const dir = normalizeDir(path.posix.dirname(fileRepoRel));
|
|
553
|
+
if (fileRepoRel === crate.crateRootFileRel)
|
|
554
|
+
return crate.crateRootDir;
|
|
555
|
+
const base = path.posix.basename(fileRepoRel);
|
|
556
|
+
if (base === 'mod.rs')
|
|
557
|
+
return dir;
|
|
558
|
+
const stem = path.posix.basename(fileRepoRel, '.rs');
|
|
559
|
+
return normalizeDir(path.posix.join(dir, stem));
|
|
560
|
+
};
|
|
561
|
+
let t = cleaned;
|
|
562
|
+
t = t.replace(/;$/, '');
|
|
563
|
+
t = t.replace(/^::+/, '');
|
|
564
|
+
if (!t) {
|
|
565
|
+
resolvedCache.set(module, null);
|
|
566
|
+
return null;
|
|
567
|
+
}
|
|
568
|
+
const segs = t.split('::').filter(Boolean);
|
|
569
|
+
if (segs.length === 0) {
|
|
570
|
+
resolvedCache.set(module, null);
|
|
571
|
+
return null;
|
|
572
|
+
}
|
|
573
|
+
let baseDir;
|
|
574
|
+
let i = 0;
|
|
575
|
+
if (segs[0] === 'crate') {
|
|
576
|
+
baseDir = crate.crateRootDir;
|
|
577
|
+
i = 1;
|
|
578
|
+
}
|
|
579
|
+
else if (segs[0] === 'self') {
|
|
580
|
+
baseDir = moduleDirForFile(importerRel);
|
|
581
|
+
i = 1;
|
|
582
|
+
}
|
|
583
|
+
else if (segs[0] === 'super') {
|
|
584
|
+
baseDir = moduleDirForFile(importerRel);
|
|
585
|
+
while (i < segs.length && segs[i] === 'super') {
|
|
586
|
+
baseDir = normalizeDir(path.posix.dirname(baseDir));
|
|
587
|
+
i++;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
else {
|
|
591
|
+
resolvedCache.set(module, null);
|
|
592
|
+
return null;
|
|
593
|
+
}
|
|
594
|
+
if (i >= segs.length) {
|
|
595
|
+
resolvedCache.set(module, null);
|
|
596
|
+
return null;
|
|
597
|
+
}
|
|
598
|
+
let curDir = baseDir;
|
|
599
|
+
let lastResolvedRepoRel = null;
|
|
600
|
+
for (let j = i; j < segs.length; j++) {
|
|
601
|
+
const name = segs[j];
|
|
602
|
+
const cand1 = normalizeDir(path.posix.join(curDir, `${name}.rs`));
|
|
603
|
+
const cand2 = normalizeDir(path.posix.join(curDir, name, 'mod.rs'));
|
|
604
|
+
let found = null;
|
|
605
|
+
if (await repoRelFileExists(cand1))
|
|
606
|
+
found = cand1;
|
|
607
|
+
else if (await repoRelFileExists(cand2))
|
|
608
|
+
found = cand2;
|
|
609
|
+
if (!found)
|
|
610
|
+
break;
|
|
611
|
+
lastResolvedRepoRel = found;
|
|
612
|
+
curDir = moduleDirForFile(found);
|
|
613
|
+
}
|
|
614
|
+
if (!lastResolvedRepoRel) {
|
|
615
|
+
resolvedCache.set(module, null);
|
|
616
|
+
return null;
|
|
617
|
+
}
|
|
618
|
+
const bundleRel = bundlePathForRepoRel(lastResolvedRepoRel);
|
|
619
|
+
resolvedCache.set(module, bundleRel);
|
|
620
|
+
return bundleRel;
|
|
621
|
+
}
|
|
622
|
+
resolvedCache.set(module, null);
|
|
623
|
+
return null;
|
|
624
|
+
};
|
|
625
|
+
for (const imp of extracted.imports) {
|
|
626
|
+
if (checkBudget('timeBudget exceeded during import extraction'))
|
|
627
|
+
break;
|
|
628
|
+
const modId = `module:${imp.module}`;
|
|
629
|
+
addNode({ id: modId, kind: 'module', name: imp.module });
|
|
630
|
+
const source = {
|
|
631
|
+
file: targetFile,
|
|
632
|
+
range: imp.range,
|
|
633
|
+
uri: bundleFileUri(targetFile),
|
|
634
|
+
snippet: clampSnippet(lines[imp.range.startLine - 1] ?? '', 200),
|
|
635
|
+
};
|
|
636
|
+
source.snippetSha256 = sha256Hex(source.snippet ?? '');
|
|
637
|
+
addEdge({
|
|
638
|
+
evidenceId: makeEvidenceId([
|
|
639
|
+
'imports',
|
|
640
|
+
targetFileId,
|
|
641
|
+
modId,
|
|
642
|
+
String(imp.range.startLine),
|
|
643
|
+
String(imp.range.startCol),
|
|
644
|
+
]),
|
|
645
|
+
kind: 'edge',
|
|
646
|
+
type: 'imports',
|
|
647
|
+
from: targetFileId,
|
|
648
|
+
to: modId,
|
|
649
|
+
method: imp.method,
|
|
650
|
+
confidence: imp.confidence,
|
|
651
|
+
sources: [source],
|
|
652
|
+
notes: imp.notes,
|
|
653
|
+
});
|
|
654
|
+
const resolvedFile = await resolveImportToFile(imp.module);
|
|
655
|
+
if (!resolvedFile)
|
|
656
|
+
continue;
|
|
657
|
+
if (checkBudget('timeBudget exceeded during import resolution'))
|
|
658
|
+
break;
|
|
659
|
+
const fileId = `file:${resolvedFile}`;
|
|
660
|
+
addNode({ id: fileId, kind: 'file', name: resolvedFile, file: resolvedFile, attrs: { role: 'internal' } });
|
|
661
|
+
addEdge({
|
|
662
|
+
evidenceId: makeEvidenceId([
|
|
663
|
+
'imports_resolved',
|
|
664
|
+
targetFileId,
|
|
665
|
+
fileId,
|
|
666
|
+
String(imp.range.startLine),
|
|
667
|
+
String(imp.range.startCol),
|
|
668
|
+
]),
|
|
669
|
+
kind: 'edge',
|
|
670
|
+
type: 'imports_resolved',
|
|
671
|
+
from: targetFileId,
|
|
672
|
+
to: fileId,
|
|
673
|
+
method: 'heuristic',
|
|
674
|
+
confidence: Math.min(0.85, imp.confidence),
|
|
675
|
+
sources: [source],
|
|
676
|
+
notes: [...imp.notes, `resolved import to bundle file: ${resolvedFile}`],
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
catch (err) {
|
|
681
|
+
warnings.push({
|
|
682
|
+
code: 'target_file_unreadable',
|
|
683
|
+
message: `Failed to read target file for import extraction: ${err instanceof Error ? err.message : String(err)}`,
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
// 2) Upstream: find callers via FTS hits
|
|
687
|
+
let searchHits = 0;
|
|
688
|
+
let filesRead = 0;
|
|
689
|
+
let callEdges = 0;
|
|
690
|
+
let importEdges = edges.filter((e) => e.type === 'imports').length;
|
|
691
|
+
if (targetSymbol && targetSymbol.length >= 2) {
|
|
692
|
+
const maxHits = Math.min(500, limits.maxFiles * 5);
|
|
693
|
+
const hits = searchIndex(paths.searchDbPath, targetSymbol, 'code', maxHits, paths.rootDir);
|
|
694
|
+
searchHits = hits.length;
|
|
695
|
+
const fileLineCache = new Map();
|
|
696
|
+
for (const hit of hits) {
|
|
697
|
+
if (checkBudget('timeBudget exceeded during caller scan'))
|
|
698
|
+
break;
|
|
699
|
+
if (edges.length >= limits.maxEdges)
|
|
700
|
+
break;
|
|
701
|
+
const hitPath = hit.path;
|
|
702
|
+
if (!hitPath || hit.kind !== 'code')
|
|
703
|
+
continue;
|
|
704
|
+
// Skip obvious self-reference in the same file if no symbol boundary detection.
|
|
705
|
+
// We still allow calls within the same file (but avoid exploding edges).
|
|
706
|
+
// Read file lines (cache)
|
|
707
|
+
let lines = fileLineCache.get(hitPath);
|
|
708
|
+
if (!lines) {
|
|
709
|
+
try {
|
|
710
|
+
const abs = safeJoin(paths.rootDir, hitPath);
|
|
711
|
+
const content = await fs.readFile(abs, 'utf8');
|
|
712
|
+
lines = content.replace(/\r\n/g, '\n').split('\n');
|
|
713
|
+
fileLineCache.set(hitPath, lines);
|
|
714
|
+
filesRead++;
|
|
715
|
+
}
|
|
716
|
+
catch {
|
|
717
|
+
continue;
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
const line = lines[hit.lineNo - 1] ?? '';
|
|
721
|
+
const call = isLikelyCallSite(line, targetSymbol);
|
|
722
|
+
if (!call)
|
|
723
|
+
continue;
|
|
724
|
+
const callerId = hit.context?.functionName
|
|
725
|
+
? `symbol:${hit.context.functionName}@${hitPath}#${hit.context.startLine}`
|
|
726
|
+
: `file:${hitPath}`;
|
|
727
|
+
if (callerId === targetSymbolId)
|
|
728
|
+
continue;
|
|
729
|
+
if (hit.context?.functionName) {
|
|
730
|
+
addNode({
|
|
731
|
+
id: callerId,
|
|
732
|
+
kind: 'symbol',
|
|
733
|
+
name: hit.context.functionName,
|
|
734
|
+
file: hitPath,
|
|
735
|
+
range: {
|
|
736
|
+
startLine: hit.context.startLine,
|
|
737
|
+
startCol: 1,
|
|
738
|
+
endLine: hit.context.endLine,
|
|
739
|
+
endCol: 1,
|
|
740
|
+
},
|
|
741
|
+
attrs: hit.context.className ? { className: hit.context.className } : undefined,
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
else {
|
|
745
|
+
addNode({ id: callerId, kind: 'file', name: hitPath, file: hitPath });
|
|
746
|
+
}
|
|
747
|
+
const src = {
|
|
748
|
+
file: hitPath,
|
|
749
|
+
range: { startLine: hit.lineNo, startCol: call.startCol, endLine: hit.lineNo, endCol: call.endCol },
|
|
750
|
+
uri: bundleFileUri(hitPath),
|
|
751
|
+
snippet: clampSnippet(line, 200),
|
|
752
|
+
};
|
|
753
|
+
src.snippetSha256 = sha256Hex(src.snippet ?? '');
|
|
754
|
+
const evidenceId = makeEvidenceId(['calls', callerId, targetSymbolId, hitPath, String(hit.lineNo), String(call.startCol)]);
|
|
755
|
+
addEdge({
|
|
756
|
+
evidenceId,
|
|
757
|
+
kind: 'edge',
|
|
758
|
+
type: 'calls',
|
|
759
|
+
from: callerId,
|
|
760
|
+
to: targetSymbolId,
|
|
761
|
+
method: 'heuristic',
|
|
762
|
+
confidence: 0.6,
|
|
763
|
+
sources: [src],
|
|
764
|
+
notes: ['call edge is name-based (no type/overload resolution)'],
|
|
765
|
+
});
|
|
766
|
+
callEdges++;
|
|
767
|
+
if (nodes.size >= limits.maxNodes) {
|
|
768
|
+
truncated = true;
|
|
769
|
+
truncatedReason = 'maxNodes reached';
|
|
770
|
+
break;
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
if (searchHits === maxHits) {
|
|
774
|
+
warnings.push({
|
|
775
|
+
code: 'search_hits_capped',
|
|
776
|
+
message: `Search hits were capped at ${maxHits}; graph may be incomplete.`,
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
else {
|
|
781
|
+
warnings.push({
|
|
782
|
+
code: 'symbol_missing_or_too_short',
|
|
783
|
+
message: 'No symbol provided (or symbol too short). Upstream call graph was skipped; only imports were extracted from the target file.',
|
|
784
|
+
});
|
|
785
|
+
}
|
|
786
|
+
// Post-process warnings
|
|
787
|
+
warnings.push({
|
|
788
|
+
code: 'limitations',
|
|
789
|
+
message: usedAstForImports
|
|
790
|
+
? 'This dependency graph uses deterministic parsing for imports (Tree-sitter WASM syntax AST) plus heuristics for callers (FTS + name-based). Results may be incomplete and are not type-resolved. Each edge includes method/confidence/sources for auditability.'
|
|
791
|
+
: 'This dependency graph is generated with deterministic heuristics (FTS + regex). Calls/imports may be incomplete and are not type-resolved. Each edge includes method/confidence/sources for auditability.',
|
|
792
|
+
});
|
|
793
|
+
// Stats
|
|
794
|
+
importEdges = edges.filter((e) => e.type === 'imports').length;
|
|
795
|
+
const out = {
|
|
796
|
+
meta: {
|
|
797
|
+
requestId,
|
|
798
|
+
generatedAt: nowIso(),
|
|
799
|
+
timeMs: Date.now() - startedAt,
|
|
800
|
+
repo: {
|
|
801
|
+
bundleId: args.bundleId,
|
|
802
|
+
headSha: manifest.repos?.[0]?.headSha,
|
|
803
|
+
},
|
|
804
|
+
budget: {
|
|
805
|
+
timeBudgetMs,
|
|
806
|
+
truncated,
|
|
807
|
+
truncatedReason,
|
|
808
|
+
limits,
|
|
809
|
+
},
|
|
810
|
+
},
|
|
811
|
+
facts: {
|
|
812
|
+
nodes: Array.from(nodes.values()),
|
|
813
|
+
edges,
|
|
814
|
+
},
|
|
815
|
+
signals: {
|
|
816
|
+
stats: {
|
|
817
|
+
filesRead,
|
|
818
|
+
searchHits,
|
|
819
|
+
callEdges,
|
|
820
|
+
importEdges,
|
|
821
|
+
},
|
|
822
|
+
warnings,
|
|
823
|
+
},
|
|
824
|
+
};
|
|
825
|
+
return out;
|
|
826
|
+
}
|