preflight-mcp 0.1.2 → 0.1.4
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 +49 -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/github.js +34 -3
- package/dist/bundle/githubArchive.js +102 -29
- package/dist/bundle/overview.js +226 -48
- package/dist/bundle/service.js +250 -130
- package/dist/config.js +30 -3
- package/dist/context7/client.js +5 -2
- package/dist/evidence/dependencyGraph.js +1136 -0
- package/dist/http/server.js +109 -0
- package/dist/jobs/progressTracker.js +191 -0
- package/dist/search/sqliteFts.js +150 -10
- package/dist/server.js +340 -326
- 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,1136 @@
|
|
|
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 (NOT absolute path). Format: repos/{owner}/{repo}/norm/{path}. ' +
|
|
16
|
+
'Example: repos/owner/repo/norm/src/index.ts or repos/jonnyhoo/langextract/norm/langextract/__init__.py. ' +
|
|
17
|
+
'Use preflight_search_bundle to discover the correct path if unsure.'),
|
|
18
|
+
symbol: z
|
|
19
|
+
.string()
|
|
20
|
+
.optional()
|
|
21
|
+
.describe('Optional symbol name (function/class). If omitted, graph is file-level.'),
|
|
22
|
+
}).optional().describe('Target file/symbol to analyze. If omitted, generates a GLOBAL dependency graph of all code files in the bundle. ' +
|
|
23
|
+
'Global mode shows import relationships between all files but may be truncated for large projects.'),
|
|
24
|
+
options: z
|
|
25
|
+
.object({
|
|
26
|
+
maxFiles: z.number().int().min(1).max(500).default(200),
|
|
27
|
+
maxNodes: z.number().int().min(10).max(2000).default(300),
|
|
28
|
+
maxEdges: z.number().int().min(10).max(5000).default(800),
|
|
29
|
+
timeBudgetMs: z.number().int().min(1000).max(30_000).default(25_000),
|
|
30
|
+
})
|
|
31
|
+
.default({ maxFiles: 200, maxNodes: 300, maxEdges: 800, timeBudgetMs: 25_000 }),
|
|
32
|
+
};
|
|
33
|
+
function sha256Hex(text) {
|
|
34
|
+
return crypto.createHash('sha256').update(text, 'utf8').digest('hex');
|
|
35
|
+
}
|
|
36
|
+
function nowIso() {
|
|
37
|
+
return new Date().toISOString();
|
|
38
|
+
}
|
|
39
|
+
function makeEvidenceId(parts) {
|
|
40
|
+
return `e_${sha256Hex(parts.join('|')).slice(0, 24)}`;
|
|
41
|
+
}
|
|
42
|
+
function clampSnippet(s, maxLen) {
|
|
43
|
+
const t = s.replace(/\s+/g, ' ').trim();
|
|
44
|
+
if (t.length <= maxLen)
|
|
45
|
+
return t;
|
|
46
|
+
return t.slice(0, Math.max(0, maxLen - 1)) + '…';
|
|
47
|
+
}
|
|
48
|
+
function normalizeExt(p) {
|
|
49
|
+
return path.extname(p).toLowerCase();
|
|
50
|
+
}
|
|
51
|
+
function parseRepoNormPath(bundleRelativePath) {
|
|
52
|
+
const p = bundleRelativePath.replaceAll('\\', '/').replace(/^\/+/, '');
|
|
53
|
+
const parts = p.split('/').filter(Boolean);
|
|
54
|
+
if (parts.length < 5)
|
|
55
|
+
return null;
|
|
56
|
+
if (parts[0] !== 'repos')
|
|
57
|
+
return null;
|
|
58
|
+
const owner = parts[1];
|
|
59
|
+
const repo = parts[2];
|
|
60
|
+
if (!owner || !repo)
|
|
61
|
+
return null;
|
|
62
|
+
if (parts[3] !== 'norm')
|
|
63
|
+
return null;
|
|
64
|
+
const repoRelativePath = parts.slice(4).join('/');
|
|
65
|
+
if (!repoRelativePath)
|
|
66
|
+
return null;
|
|
67
|
+
return {
|
|
68
|
+
repoId: `${owner}/${repo}`,
|
|
69
|
+
repoRoot: `repos/${owner}/${repo}/norm`,
|
|
70
|
+
repoRelativePath,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
async function fileExists(absPath) {
|
|
74
|
+
try {
|
|
75
|
+
await fs.access(absPath);
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function extractImportsFromLinesHeuristic(filePath, lines) {
|
|
83
|
+
const ext = normalizeExt(filePath);
|
|
84
|
+
const isJs = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'].includes(ext);
|
|
85
|
+
const isPy = ext === '.py';
|
|
86
|
+
const isGo = ext === '.go';
|
|
87
|
+
const out = [];
|
|
88
|
+
for (let i = 0; i < lines.length; i++) {
|
|
89
|
+
const line = lines[i] ?? '';
|
|
90
|
+
const lineNo = i + 1;
|
|
91
|
+
const mk = (module, startCol, endCol) => ({
|
|
92
|
+
module,
|
|
93
|
+
range: { startLine: lineNo, startCol, endLine: lineNo, endCol },
|
|
94
|
+
method: 'heuristic',
|
|
95
|
+
confidence: 0.7,
|
|
96
|
+
notes: ['import extraction is regex-based; module resolution (if any) is best-effort and separate'],
|
|
97
|
+
});
|
|
98
|
+
if (isJs) {
|
|
99
|
+
// import ... from 'x' / export ... from 'x'
|
|
100
|
+
const m1 = line.match(/\bfrom\s+['"]([^'"]+)['"]/);
|
|
101
|
+
if (m1?.[1]) {
|
|
102
|
+
const idx = line.indexOf(m1[1]);
|
|
103
|
+
out.push(mk(m1[1], Math.max(1, idx + 1), Math.max(1, idx + 1 + m1[1].length)));
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
// import 'x'
|
|
107
|
+
const m2 = line.match(/\bimport\s+['"]([^'"]+)['"]/);
|
|
108
|
+
if (m2?.[1]) {
|
|
109
|
+
const idx = line.indexOf(m2[1]);
|
|
110
|
+
out.push(mk(m2[1], Math.max(1, idx + 1), Math.max(1, idx + 1 + m2[1].length)));
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
// require('x')
|
|
114
|
+
const m3 = line.match(/\brequire\s*\(\s*['"]([^'"]+)['"]/);
|
|
115
|
+
if (m3?.[1]) {
|
|
116
|
+
const idx = line.indexOf(m3[1]);
|
|
117
|
+
out.push(mk(m3[1], Math.max(1, idx + 1), Math.max(1, idx + 1 + m3[1].length)));
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (isPy) {
|
|
122
|
+
// import x
|
|
123
|
+
const m1 = line.match(/^\s*import\s+([a-zA-Z_][\w.]*)/);
|
|
124
|
+
if (m1?.[1]) {
|
|
125
|
+
const mod = m1[1].split('.')[0] ?? m1[1];
|
|
126
|
+
const idx = line.indexOf(m1[1]);
|
|
127
|
+
out.push(mk(mod, Math.max(1, idx + 1), Math.max(1, idx + 1 + mod.length)));
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
// from x import y
|
|
131
|
+
const m2 = line.match(/^\s*from\s+([a-zA-Z_][\w.]*)\s+import\b/);
|
|
132
|
+
if (m2?.[1]) {
|
|
133
|
+
const mod = m2[1].split('.')[0] ?? m2[1];
|
|
134
|
+
const idx = line.indexOf(m2[1]);
|
|
135
|
+
out.push(mk(mod, Math.max(1, idx + 1), Math.max(1, idx + 1 + mod.length)));
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (isGo) {
|
|
140
|
+
// import "x"
|
|
141
|
+
const m = line.match(/\bimport\s+['"]([^'"]+)['"]/);
|
|
142
|
+
if (m?.[1]) {
|
|
143
|
+
const idx = line.indexOf(m[1]);
|
|
144
|
+
out.push(mk(m[1], Math.max(1, idx + 1), Math.max(1, idx + 1 + m[1].length)));
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
async function extractImportsForFile(cfg, filePath, normalizedContent, lines, warnings) {
|
|
151
|
+
const astEngine = cfg.astEngine ?? 'wasm';
|
|
152
|
+
if (astEngine !== 'wasm') {
|
|
153
|
+
return { imports: extractImportsFromLinesHeuristic(filePath, lines), usedAst: false, usedFallback: true };
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
const parsed = await extractImportRefsWasm(filePath, normalizedContent);
|
|
157
|
+
if (!parsed) {
|
|
158
|
+
return { imports: extractImportsFromLinesHeuristic(filePath, lines), usedAst: false, usedFallback: true };
|
|
159
|
+
}
|
|
160
|
+
const imports = [];
|
|
161
|
+
for (const imp of parsed.imports) {
|
|
162
|
+
if (imp.range.startLine !== imp.range.endLine)
|
|
163
|
+
continue; // module specifiers should be single-line
|
|
164
|
+
imports.push({
|
|
165
|
+
module: imp.module,
|
|
166
|
+
range: imp.range,
|
|
167
|
+
method: 'exact',
|
|
168
|
+
confidence: 0.9,
|
|
169
|
+
notes: [`import extraction is parser-backed (tree-sitter:${imp.language}:${imp.kind}); module resolution (if any) is best-effort and separate`],
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
return { imports, usedAst: true, usedFallback: false };
|
|
173
|
+
}
|
|
174
|
+
catch (err) {
|
|
175
|
+
warnings.push({
|
|
176
|
+
code: 'ast_import_extraction_failed',
|
|
177
|
+
message: `AST/WASM import extraction failed; falling back to regex: ${err instanceof Error ? err.message : String(err)}`,
|
|
178
|
+
});
|
|
179
|
+
return { imports: extractImportsFromLinesHeuristic(filePath, lines), usedAst: false, usedFallback: true };
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
function isLikelyCallSite(line, symbol) {
|
|
183
|
+
// Avoid regex DoS by escaping symbol.
|
|
184
|
+
const escaped = symbol.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
185
|
+
const re = new RegExp(`\\b${escaped}\\s*\\(`);
|
|
186
|
+
const m = line.match(re);
|
|
187
|
+
if (!m || m.index === undefined)
|
|
188
|
+
return null;
|
|
189
|
+
const startCol = m.index + 1;
|
|
190
|
+
const endCol = startCol + Math.max(1, symbol.length);
|
|
191
|
+
return { startCol, endCol };
|
|
192
|
+
}
|
|
193
|
+
export async function generateDependencyGraph(cfg, rawArgs) {
|
|
194
|
+
const args = z.object(DependencyGraphInputSchema).parse(rawArgs);
|
|
195
|
+
const startedAt = Date.now();
|
|
196
|
+
const requestId = crypto.randomUUID();
|
|
197
|
+
const storageDir = await findBundleStorageDir(cfg.storageDirs, args.bundleId);
|
|
198
|
+
if (!storageDir) {
|
|
199
|
+
throw new Error(`Bundle not found: ${args.bundleId}`);
|
|
200
|
+
}
|
|
201
|
+
const paths = getBundlePathsForId(storageDir, args.bundleId);
|
|
202
|
+
const manifest = await readManifest(paths.manifestPath);
|
|
203
|
+
const limits = {
|
|
204
|
+
maxFiles: args.options.maxFiles,
|
|
205
|
+
maxNodes: args.options.maxNodes,
|
|
206
|
+
maxEdges: args.options.maxEdges,
|
|
207
|
+
};
|
|
208
|
+
const nodes = new Map();
|
|
209
|
+
const edges = [];
|
|
210
|
+
const warnings = [];
|
|
211
|
+
let truncated = false;
|
|
212
|
+
let truncatedReason;
|
|
213
|
+
const timeBudgetMs = args.options.timeBudgetMs;
|
|
214
|
+
const timeLeft = () => timeBudgetMs - (Date.now() - startedAt);
|
|
215
|
+
const checkBudget = (reason) => {
|
|
216
|
+
if (truncated)
|
|
217
|
+
return true;
|
|
218
|
+
if (timeLeft() <= 0) {
|
|
219
|
+
truncated = true;
|
|
220
|
+
truncatedReason = reason;
|
|
221
|
+
return true;
|
|
222
|
+
}
|
|
223
|
+
if (edges.length >= limits.maxEdges) {
|
|
224
|
+
truncated = true;
|
|
225
|
+
truncatedReason = 'maxEdges reached';
|
|
226
|
+
return true;
|
|
227
|
+
}
|
|
228
|
+
if (nodes.size >= limits.maxNodes) {
|
|
229
|
+
truncated = true;
|
|
230
|
+
truncatedReason = 'maxNodes reached';
|
|
231
|
+
return true;
|
|
232
|
+
}
|
|
233
|
+
return false;
|
|
234
|
+
};
|
|
235
|
+
const addNode = (n) => {
|
|
236
|
+
if (nodes.has(n.id))
|
|
237
|
+
return;
|
|
238
|
+
nodes.set(n.id, n);
|
|
239
|
+
};
|
|
240
|
+
const addEdge = (e) => {
|
|
241
|
+
if (edges.length >= limits.maxEdges) {
|
|
242
|
+
truncated = true;
|
|
243
|
+
truncatedReason = 'maxEdges reached';
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
edges.push(e);
|
|
247
|
+
};
|
|
248
|
+
const bundleFileUri = (p) => toBundleFileUri({ bundleId: args.bundleId, relativePath: p });
|
|
249
|
+
// Global mode: no target specified
|
|
250
|
+
if (!args.target) {
|
|
251
|
+
return generateGlobalDependencyGraph({
|
|
252
|
+
cfg,
|
|
253
|
+
args,
|
|
254
|
+
paths,
|
|
255
|
+
manifest,
|
|
256
|
+
limits,
|
|
257
|
+
nodes,
|
|
258
|
+
edges,
|
|
259
|
+
warnings,
|
|
260
|
+
startedAt,
|
|
261
|
+
requestId,
|
|
262
|
+
timeBudgetMs,
|
|
263
|
+
checkBudget,
|
|
264
|
+
addNode,
|
|
265
|
+
addEdge,
|
|
266
|
+
bundleFileUri,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
const targetFile = args.target.file.replaceAll('\\', '/');
|
|
270
|
+
const targetRepo = parseRepoNormPath(targetFile);
|
|
271
|
+
const targetFileId = `file:${targetFile}`;
|
|
272
|
+
addNode({ id: targetFileId, kind: 'file', name: targetFile, file: targetFile });
|
|
273
|
+
const targetSymbol = args.target.symbol?.trim();
|
|
274
|
+
const targetSymbolId = targetSymbol ? `symbol:${targetSymbol}@${targetFile}` : targetFileId;
|
|
275
|
+
if (targetSymbol) {
|
|
276
|
+
addNode({
|
|
277
|
+
id: targetSymbolId,
|
|
278
|
+
kind: 'symbol',
|
|
279
|
+
name: targetSymbol,
|
|
280
|
+
file: targetFile,
|
|
281
|
+
attrs: { role: 'target' },
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
// 1) Downstream: imports from target file
|
|
285
|
+
let usedAstForImports = false;
|
|
286
|
+
try {
|
|
287
|
+
const absTarget = safeJoin(paths.rootDir, targetFile);
|
|
288
|
+
const raw = await fs.readFile(absTarget, 'utf8');
|
|
289
|
+
const normalized = raw.replace(/\r\n/g, '\n');
|
|
290
|
+
const lines = normalized.split('\n');
|
|
291
|
+
const extracted = await extractImportsForFile(cfg, targetFile, normalized, lines, warnings);
|
|
292
|
+
usedAstForImports = extracted.usedAst;
|
|
293
|
+
const resolvedCache = new Map(); // module -> bundle-relative file path
|
|
294
|
+
const goModuleCache = new Map(); // dir -> go.mod info
|
|
295
|
+
let rustCrateRoot;
|
|
296
|
+
const normalizeDir = (d) => (d === '.' ? '' : d);
|
|
297
|
+
const resolveImportToFile = async (module) => {
|
|
298
|
+
const cached = resolvedCache.get(module);
|
|
299
|
+
if (cached !== undefined)
|
|
300
|
+
return cached;
|
|
301
|
+
if (!targetRepo) {
|
|
302
|
+
resolvedCache.set(module, null);
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
const importerRel = targetRepo.repoRelativePath;
|
|
306
|
+
const ext = normalizeExt(importerRel);
|
|
307
|
+
const cleaned = (module.split(/[?#]/, 1)[0] ?? '').trim();
|
|
308
|
+
if (!cleaned) {
|
|
309
|
+
resolvedCache.set(module, null);
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
const bundlePathForRepoRel = (repoRel) => `${targetRepo.repoRoot}/${repoRel.replaceAll('\\', '/')}`;
|
|
313
|
+
const isJs = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'].includes(ext);
|
|
314
|
+
if (isJs) {
|
|
315
|
+
if (!cleaned.startsWith('.') && !cleaned.startsWith('/')) {
|
|
316
|
+
resolvedCache.set(module, null);
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
const importerDir = normalizeDir(path.posix.dirname(importerRel));
|
|
320
|
+
const base = cleaned.startsWith('/')
|
|
321
|
+
? path.posix.normalize(cleaned.slice(1))
|
|
322
|
+
: path.posix.normalize(path.posix.join(importerDir, cleaned));
|
|
323
|
+
const candidates = [];
|
|
324
|
+
const baseExt = path.posix.extname(base).toLowerCase();
|
|
325
|
+
const add = (repoRel) => {
|
|
326
|
+
candidates.push(repoRel);
|
|
327
|
+
};
|
|
328
|
+
if (baseExt) {
|
|
329
|
+
add(base);
|
|
330
|
+
// TS projects sometimes import './x.js' but source is './x.ts'
|
|
331
|
+
if (baseExt === '.js' || baseExt === '.mjs' || baseExt === '.cjs') {
|
|
332
|
+
const stem = base.slice(0, -baseExt.length);
|
|
333
|
+
add(`${stem}.ts`);
|
|
334
|
+
add(`${stem}.tsx`);
|
|
335
|
+
add(`${stem}.jsx`);
|
|
336
|
+
}
|
|
337
|
+
if (baseExt === '.jsx') {
|
|
338
|
+
const stem = base.slice(0, -baseExt.length);
|
|
339
|
+
add(`${stem}.tsx`);
|
|
340
|
+
add(`${stem}.ts`);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
else {
|
|
344
|
+
const exts = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'];
|
|
345
|
+
for (const e of exts)
|
|
346
|
+
add(`${base}${e}`);
|
|
347
|
+
for (const e of exts)
|
|
348
|
+
add(path.posix.join(base, `index${e}`));
|
|
349
|
+
}
|
|
350
|
+
for (const repoRel of candidates) {
|
|
351
|
+
const bundleRel = bundlePathForRepoRel(repoRel);
|
|
352
|
+
const abs = safeJoin(paths.rootDir, bundleRel);
|
|
353
|
+
if (await fileExists(abs)) {
|
|
354
|
+
resolvedCache.set(module, bundleRel);
|
|
355
|
+
return bundleRel;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
resolvedCache.set(module, null);
|
|
359
|
+
return null;
|
|
360
|
+
}
|
|
361
|
+
if (ext === '.py') {
|
|
362
|
+
// Best-effort Python module resolution (deterministic, file-existence based).
|
|
363
|
+
// - Relative imports: .foo / ..foo.bar
|
|
364
|
+
// - Absolute imports: pkg.sub (tries repo root + src/ + importer top-level dir)
|
|
365
|
+
// Relative imports
|
|
366
|
+
if (cleaned.startsWith('.')) {
|
|
367
|
+
const m = cleaned.match(/^(\.+)(.*)$/);
|
|
368
|
+
if (!m) {
|
|
369
|
+
resolvedCache.set(module, null);
|
|
370
|
+
return null;
|
|
371
|
+
}
|
|
372
|
+
const dotCount = m[1]?.length ?? 0;
|
|
373
|
+
const rest = (m[2] ?? '').replace(/^\.+/, '');
|
|
374
|
+
let baseDir = normalizeDir(path.posix.dirname(importerRel));
|
|
375
|
+
for (let i = 1; i < dotCount; i++) {
|
|
376
|
+
baseDir = normalizeDir(path.posix.dirname(baseDir));
|
|
377
|
+
}
|
|
378
|
+
const restPath = rest ? rest.replace(/\./g, '/') : '';
|
|
379
|
+
const candidatesRepoRel = [];
|
|
380
|
+
if (restPath) {
|
|
381
|
+
candidatesRepoRel.push(normalizeDir(path.posix.join(baseDir, `${restPath}.py`)));
|
|
382
|
+
candidatesRepoRel.push(normalizeDir(path.posix.join(baseDir, restPath, '__init__.py')));
|
|
383
|
+
}
|
|
384
|
+
else {
|
|
385
|
+
candidatesRepoRel.push(normalizeDir(path.posix.join(baseDir, '__init__.py')));
|
|
386
|
+
}
|
|
387
|
+
for (const repoRel of candidatesRepoRel) {
|
|
388
|
+
const bundleRel = bundlePathForRepoRel(repoRel);
|
|
389
|
+
const abs = safeJoin(paths.rootDir, bundleRel);
|
|
390
|
+
if (await fileExists(abs)) {
|
|
391
|
+
resolvedCache.set(module, bundleRel);
|
|
392
|
+
return bundleRel;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
resolvedCache.set(module, null);
|
|
396
|
+
return null;
|
|
397
|
+
}
|
|
398
|
+
// Absolute imports
|
|
399
|
+
const modulePath = cleaned.replace(/\./g, '/');
|
|
400
|
+
const topLevelDir = importerRel.includes('/') ? importerRel.split('/')[0] ?? '' : '';
|
|
401
|
+
// Candidate roots in deterministic order (prefer the importer's layout if possible).
|
|
402
|
+
const roots = [];
|
|
403
|
+
if (importerRel.startsWith('src/'))
|
|
404
|
+
roots.push('src');
|
|
405
|
+
const moduleStartsWithTop = topLevelDir ? modulePath === topLevelDir || modulePath.startsWith(`${topLevelDir}/`) : false;
|
|
406
|
+
if (topLevelDir && topLevelDir !== 'src' && !moduleStartsWithTop)
|
|
407
|
+
roots.push(topLevelDir);
|
|
408
|
+
roots.push('');
|
|
409
|
+
if (!roots.includes('src'))
|
|
410
|
+
roots.push('src');
|
|
411
|
+
const matches = [];
|
|
412
|
+
for (const root of roots) {
|
|
413
|
+
const base = root ? `${root}/${modulePath}` : modulePath;
|
|
414
|
+
const repoRelFile = `${base}.py`;
|
|
415
|
+
const bundleRelFile = bundlePathForRepoRel(repoRelFile);
|
|
416
|
+
const absFile = safeJoin(paths.rootDir, bundleRelFile);
|
|
417
|
+
if (await fileExists(absFile)) {
|
|
418
|
+
matches.push({ root, repoRel: repoRelFile, bundleRel: bundleRelFile });
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
const repoRelInit = `${base}/__init__.py`;
|
|
422
|
+
const bundleRelInit = bundlePathForRepoRel(repoRelInit);
|
|
423
|
+
const absInit = safeJoin(paths.rootDir, bundleRelInit);
|
|
424
|
+
if (await fileExists(absInit)) {
|
|
425
|
+
matches.push({ root, repoRel: repoRelInit, bundleRel: bundleRelInit });
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
if (matches.length === 0) {
|
|
429
|
+
resolvedCache.set(module, null);
|
|
430
|
+
return null;
|
|
431
|
+
}
|
|
432
|
+
if (matches.length === 1) {
|
|
433
|
+
const only = matches[0].bundleRel;
|
|
434
|
+
resolvedCache.set(module, only);
|
|
435
|
+
return only;
|
|
436
|
+
}
|
|
437
|
+
// If multiple matches exist, pick the one in the importer's own root (if any).
|
|
438
|
+
const preferred = matches.filter((m) => m.root && importerRel.startsWith(`${m.root}/`));
|
|
439
|
+
if (preferred.length === 1) {
|
|
440
|
+
const only = preferred[0].bundleRel;
|
|
441
|
+
resolvedCache.set(module, only);
|
|
442
|
+
return only;
|
|
443
|
+
}
|
|
444
|
+
// Ambiguous.
|
|
445
|
+
resolvedCache.set(module, null);
|
|
446
|
+
return null;
|
|
447
|
+
}
|
|
448
|
+
if (ext === '.go') {
|
|
449
|
+
// Best-effort: resolve in-module imports using nearest go.mod's module path.
|
|
450
|
+
const importerDir = normalizeDir(path.posix.dirname(importerRel));
|
|
451
|
+
const findGoModule = async () => {
|
|
452
|
+
let cur = importerDir;
|
|
453
|
+
while (true) {
|
|
454
|
+
const cached = goModuleCache.get(cur);
|
|
455
|
+
if (cached !== undefined)
|
|
456
|
+
return cached;
|
|
457
|
+
const goModRepoRel = cur ? `${cur}/go.mod` : 'go.mod';
|
|
458
|
+
const goModBundleRel = bundlePathForRepoRel(goModRepoRel);
|
|
459
|
+
let mod = null;
|
|
460
|
+
const abs = safeJoin(paths.rootDir, goModBundleRel);
|
|
461
|
+
if (await fileExists(abs)) {
|
|
462
|
+
try {
|
|
463
|
+
const raw = await fs.readFile(abs, 'utf8');
|
|
464
|
+
const content = raw.replace(/\r\n/g, '\n');
|
|
465
|
+
for (const line of content.split('\n')) {
|
|
466
|
+
const t = line.trim();
|
|
467
|
+
if (!t || t.startsWith('//'))
|
|
468
|
+
continue;
|
|
469
|
+
const m = t.match(/^module\s+(\S+)/);
|
|
470
|
+
if (m?.[1]) {
|
|
471
|
+
mod = { moduleRootDir: cur, modulePath: m[1] };
|
|
472
|
+
break;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
catch {
|
|
477
|
+
// ignore
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
goModuleCache.set(cur, mod);
|
|
481
|
+
if (mod)
|
|
482
|
+
return mod;
|
|
483
|
+
if (!cur)
|
|
484
|
+
return null;
|
|
485
|
+
cur = normalizeDir(path.posix.dirname(cur));
|
|
486
|
+
}
|
|
487
|
+
};
|
|
488
|
+
const mod = await findGoModule();
|
|
489
|
+
if (!mod) {
|
|
490
|
+
resolvedCache.set(module, null);
|
|
491
|
+
return null;
|
|
492
|
+
}
|
|
493
|
+
if (cleaned !== mod.modulePath && !cleaned.startsWith(`${mod.modulePath}/`)) {
|
|
494
|
+
resolvedCache.set(module, null);
|
|
495
|
+
return null;
|
|
496
|
+
}
|
|
497
|
+
const sub = cleaned === mod.modulePath ? '' : cleaned.slice(mod.modulePath.length + 1);
|
|
498
|
+
const pkgDirRepoRel = normalizeDir(path.posix.join(mod.moduleRootDir, sub));
|
|
499
|
+
const pkgDirBundleRel = pkgDirRepoRel
|
|
500
|
+
? `${targetRepo.repoRoot}/${pkgDirRepoRel}`
|
|
501
|
+
: targetRepo.repoRoot;
|
|
502
|
+
try {
|
|
503
|
+
const absDir = safeJoin(paths.rootDir, pkgDirBundleRel);
|
|
504
|
+
const entries = await fs.readdir(absDir, { withFileTypes: true });
|
|
505
|
+
const goFiles = entries
|
|
506
|
+
.filter((e) => e.isFile() && e.name.endsWith('.go'))
|
|
507
|
+
.map((e) => e.name)
|
|
508
|
+
.sort();
|
|
509
|
+
const picked = goFiles.find((n) => !n.endsWith('_test.go')) ?? goFiles[0];
|
|
510
|
+
if (!picked) {
|
|
511
|
+
resolvedCache.set(module, null);
|
|
512
|
+
return null;
|
|
513
|
+
}
|
|
514
|
+
const bundleRel = `${pkgDirBundleRel}/${picked}`;
|
|
515
|
+
resolvedCache.set(module, bundleRel);
|
|
516
|
+
return bundleRel;
|
|
517
|
+
}
|
|
518
|
+
catch {
|
|
519
|
+
resolvedCache.set(module, null);
|
|
520
|
+
return null;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
if (ext === '.rs') {
|
|
524
|
+
// Resolve crate/self/super imports to actual module files (best-effort).
|
|
525
|
+
const importerDir = normalizeDir(path.posix.dirname(importerRel));
|
|
526
|
+
const repoRelFileExists = async (repoRel) => {
|
|
527
|
+
const bundleRel = bundlePathForRepoRel(repoRel);
|
|
528
|
+
const abs = safeJoin(paths.rootDir, bundleRel);
|
|
529
|
+
return fileExists(abs);
|
|
530
|
+
};
|
|
531
|
+
const findRustCrateRoot = async () => {
|
|
532
|
+
if (rustCrateRoot !== undefined)
|
|
533
|
+
return rustCrateRoot;
|
|
534
|
+
let cur = importerDir;
|
|
535
|
+
while (true) {
|
|
536
|
+
// try <cur>/lib.rs or <cur>/main.rs
|
|
537
|
+
if (cur) {
|
|
538
|
+
const lib = `${cur}/lib.rs`;
|
|
539
|
+
if (await repoRelFileExists(lib)) {
|
|
540
|
+
rustCrateRoot = { crateRootDir: cur, crateRootFileRel: lib };
|
|
541
|
+
return rustCrateRoot;
|
|
542
|
+
}
|
|
543
|
+
const main = `${cur}/main.rs`;
|
|
544
|
+
if (await repoRelFileExists(main)) {
|
|
545
|
+
rustCrateRoot = { crateRootDir: cur, crateRootFileRel: main };
|
|
546
|
+
return rustCrateRoot;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
// try <cur>/src/lib.rs or <cur>/src/main.rs
|
|
550
|
+
const lib2 = `${cur ? cur + '/' : ''}src/lib.rs`;
|
|
551
|
+
if (await repoRelFileExists(lib2)) {
|
|
552
|
+
const crateDir = normalizeDir(path.posix.dirname(lib2));
|
|
553
|
+
rustCrateRoot = { crateRootDir: crateDir, crateRootFileRel: lib2 };
|
|
554
|
+
return rustCrateRoot;
|
|
555
|
+
}
|
|
556
|
+
const main2 = `${cur ? cur + '/' : ''}src/main.rs`;
|
|
557
|
+
if (await repoRelFileExists(main2)) {
|
|
558
|
+
const crateDir = normalizeDir(path.posix.dirname(main2));
|
|
559
|
+
rustCrateRoot = { crateRootDir: crateDir, crateRootFileRel: main2 };
|
|
560
|
+
return rustCrateRoot;
|
|
561
|
+
}
|
|
562
|
+
if (!cur)
|
|
563
|
+
break;
|
|
564
|
+
cur = normalizeDir(path.posix.dirname(cur));
|
|
565
|
+
}
|
|
566
|
+
rustCrateRoot = null;
|
|
567
|
+
return null;
|
|
568
|
+
};
|
|
569
|
+
const crate = await findRustCrateRoot();
|
|
570
|
+
if (!crate) {
|
|
571
|
+
resolvedCache.set(module, null);
|
|
572
|
+
return null;
|
|
573
|
+
}
|
|
574
|
+
const moduleDirForFile = (fileRepoRel) => {
|
|
575
|
+
const dir = normalizeDir(path.posix.dirname(fileRepoRel));
|
|
576
|
+
if (fileRepoRel === crate.crateRootFileRel)
|
|
577
|
+
return crate.crateRootDir;
|
|
578
|
+
const base = path.posix.basename(fileRepoRel);
|
|
579
|
+
if (base === 'mod.rs')
|
|
580
|
+
return dir;
|
|
581
|
+
const stem = path.posix.basename(fileRepoRel, '.rs');
|
|
582
|
+
return normalizeDir(path.posix.join(dir, stem));
|
|
583
|
+
};
|
|
584
|
+
let t = cleaned;
|
|
585
|
+
t = t.replace(/;$/, '');
|
|
586
|
+
t = t.replace(/^::+/, '');
|
|
587
|
+
if (!t) {
|
|
588
|
+
resolvedCache.set(module, null);
|
|
589
|
+
return null;
|
|
590
|
+
}
|
|
591
|
+
const segs = t.split('::').filter(Boolean);
|
|
592
|
+
if (segs.length === 0) {
|
|
593
|
+
resolvedCache.set(module, null);
|
|
594
|
+
return null;
|
|
595
|
+
}
|
|
596
|
+
let baseDir;
|
|
597
|
+
let i = 0;
|
|
598
|
+
if (segs[0] === 'crate') {
|
|
599
|
+
baseDir = crate.crateRootDir;
|
|
600
|
+
i = 1;
|
|
601
|
+
}
|
|
602
|
+
else if (segs[0] === 'self') {
|
|
603
|
+
baseDir = moduleDirForFile(importerRel);
|
|
604
|
+
i = 1;
|
|
605
|
+
}
|
|
606
|
+
else if (segs[0] === 'super') {
|
|
607
|
+
baseDir = moduleDirForFile(importerRel);
|
|
608
|
+
while (i < segs.length && segs[i] === 'super') {
|
|
609
|
+
baseDir = normalizeDir(path.posix.dirname(baseDir));
|
|
610
|
+
i++;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
else {
|
|
614
|
+
resolvedCache.set(module, null);
|
|
615
|
+
return null;
|
|
616
|
+
}
|
|
617
|
+
if (i >= segs.length) {
|
|
618
|
+
resolvedCache.set(module, null);
|
|
619
|
+
return null;
|
|
620
|
+
}
|
|
621
|
+
let curDir = baseDir;
|
|
622
|
+
let lastResolvedRepoRel = null;
|
|
623
|
+
for (let j = i; j < segs.length; j++) {
|
|
624
|
+
const name = segs[j];
|
|
625
|
+
const cand1 = normalizeDir(path.posix.join(curDir, `${name}.rs`));
|
|
626
|
+
const cand2 = normalizeDir(path.posix.join(curDir, name, 'mod.rs'));
|
|
627
|
+
let found = null;
|
|
628
|
+
if (await repoRelFileExists(cand1))
|
|
629
|
+
found = cand1;
|
|
630
|
+
else if (await repoRelFileExists(cand2))
|
|
631
|
+
found = cand2;
|
|
632
|
+
if (!found)
|
|
633
|
+
break;
|
|
634
|
+
lastResolvedRepoRel = found;
|
|
635
|
+
curDir = moduleDirForFile(found);
|
|
636
|
+
}
|
|
637
|
+
if (!lastResolvedRepoRel) {
|
|
638
|
+
resolvedCache.set(module, null);
|
|
639
|
+
return null;
|
|
640
|
+
}
|
|
641
|
+
const bundleRel = bundlePathForRepoRel(lastResolvedRepoRel);
|
|
642
|
+
resolvedCache.set(module, bundleRel);
|
|
643
|
+
return bundleRel;
|
|
644
|
+
}
|
|
645
|
+
resolvedCache.set(module, null);
|
|
646
|
+
return null;
|
|
647
|
+
};
|
|
648
|
+
for (const imp of extracted.imports) {
|
|
649
|
+
if (checkBudget('timeBudget exceeded during import extraction'))
|
|
650
|
+
break;
|
|
651
|
+
const modId = `module:${imp.module}`;
|
|
652
|
+
addNode({ id: modId, kind: 'module', name: imp.module });
|
|
653
|
+
const source = {
|
|
654
|
+
file: targetFile,
|
|
655
|
+
range: imp.range,
|
|
656
|
+
uri: bundleFileUri(targetFile),
|
|
657
|
+
snippet: clampSnippet(lines[imp.range.startLine - 1] ?? '', 200),
|
|
658
|
+
};
|
|
659
|
+
source.snippetSha256 = sha256Hex(source.snippet ?? '');
|
|
660
|
+
addEdge({
|
|
661
|
+
evidenceId: makeEvidenceId([
|
|
662
|
+
'imports',
|
|
663
|
+
targetFileId,
|
|
664
|
+
modId,
|
|
665
|
+
String(imp.range.startLine),
|
|
666
|
+
String(imp.range.startCol),
|
|
667
|
+
]),
|
|
668
|
+
kind: 'edge',
|
|
669
|
+
type: 'imports',
|
|
670
|
+
from: targetFileId,
|
|
671
|
+
to: modId,
|
|
672
|
+
method: imp.method,
|
|
673
|
+
confidence: imp.confidence,
|
|
674
|
+
sources: [source],
|
|
675
|
+
notes: imp.notes,
|
|
676
|
+
});
|
|
677
|
+
const resolvedFile = await resolveImportToFile(imp.module);
|
|
678
|
+
if (!resolvedFile)
|
|
679
|
+
continue;
|
|
680
|
+
if (checkBudget('timeBudget exceeded during import resolution'))
|
|
681
|
+
break;
|
|
682
|
+
const fileId = `file:${resolvedFile}`;
|
|
683
|
+
addNode({ id: fileId, kind: 'file', name: resolvedFile, file: resolvedFile, attrs: { role: 'internal' } });
|
|
684
|
+
addEdge({
|
|
685
|
+
evidenceId: makeEvidenceId([
|
|
686
|
+
'imports_resolved',
|
|
687
|
+
targetFileId,
|
|
688
|
+
fileId,
|
|
689
|
+
String(imp.range.startLine),
|
|
690
|
+
String(imp.range.startCol),
|
|
691
|
+
]),
|
|
692
|
+
kind: 'edge',
|
|
693
|
+
type: 'imports_resolved',
|
|
694
|
+
from: targetFileId,
|
|
695
|
+
to: fileId,
|
|
696
|
+
method: 'heuristic',
|
|
697
|
+
confidence: Math.min(0.85, imp.confidence),
|
|
698
|
+
sources: [source],
|
|
699
|
+
notes: [...imp.notes, `resolved import to bundle file: ${resolvedFile}`],
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
catch (err) {
|
|
704
|
+
// If target file not found, throw a helpful error instead of just warning
|
|
705
|
+
if (err instanceof Error && 'code' in err && err.code === 'ENOENT') {
|
|
706
|
+
// Detect if path looks like an absolute filesystem path (wrong format)
|
|
707
|
+
const looksLikeAbsolutePath = /^[A-Za-z]:[\\/]|^\/(?:home|Users|var|tmp|etc)\//i.test(targetFile);
|
|
708
|
+
// Detect if path looks like correct bundle-relative format
|
|
709
|
+
const looksLikeBundleRelative = /^repos\/[^/]+\/[^/]+\/norm\//i.test(targetFile);
|
|
710
|
+
if (looksLikeAbsolutePath) {
|
|
711
|
+
throw new Error(`Target file not found: ${targetFile}\n\n` +
|
|
712
|
+
`ERROR: You provided an absolute filesystem path, but file paths must be bundle-relative.\n` +
|
|
713
|
+
`Correct format: repos/{owner}/{repo}/norm/{path/to/file}\n` +
|
|
714
|
+
`Example: repos/owner/myrepo/norm/src/main.py\n\n` +
|
|
715
|
+
`Use preflight_search_bundle to find the correct file path.`);
|
|
716
|
+
}
|
|
717
|
+
else if (looksLikeBundleRelative) {
|
|
718
|
+
throw new Error(`Target file not found: ${targetFile}\n\n` +
|
|
719
|
+
`The path format looks correct, but the file does not exist in the bundle.\n` +
|
|
720
|
+
`Possible causes:\n` +
|
|
721
|
+
`1. The bundle may be incomplete (download timed out or failed)\n` +
|
|
722
|
+
`2. The file path may have a typo\n\n` +
|
|
723
|
+
`Suggested actions:\n` +
|
|
724
|
+
`- Use preflight_search_bundle to verify available files\n` +
|
|
725
|
+
`- Use preflight_update_bundle with updateExisting:true to re-download\n` +
|
|
726
|
+
`- Check if repair shows "indexed 0 file(s)" which indicates incomplete bundle`);
|
|
727
|
+
}
|
|
728
|
+
else {
|
|
729
|
+
throw new Error(`Target file not found: ${targetFile}\n\n` +
|
|
730
|
+
`File paths must be bundle-relative, NOT absolute filesystem paths.\n` +
|
|
731
|
+
`Correct format: repos/{owner}/{repo}/norm/{path/to/file}\n` +
|
|
732
|
+
`Example: repos/owner/myrepo/norm/src/main.py\n\n` +
|
|
733
|
+
`Use preflight_search_bundle to find the correct file path.`);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
warnings.push({
|
|
737
|
+
code: 'target_file_unreadable',
|
|
738
|
+
message: `Failed to read target file for import extraction: ${err instanceof Error ? err.message : String(err)}`,
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
// 2) Upstream: find callers via FTS hits
|
|
742
|
+
let searchHits = 0;
|
|
743
|
+
let filesRead = 0;
|
|
744
|
+
let callEdges = 0;
|
|
745
|
+
let importEdges = edges.filter((e) => e.type === 'imports').length;
|
|
746
|
+
if (targetSymbol && targetSymbol.length >= 2) {
|
|
747
|
+
const maxHits = Math.min(500, limits.maxFiles * 5);
|
|
748
|
+
const hits = searchIndex(paths.searchDbPath, targetSymbol, 'code', maxHits, paths.rootDir);
|
|
749
|
+
searchHits = hits.length;
|
|
750
|
+
const fileLineCache = new Map();
|
|
751
|
+
for (const hit of hits) {
|
|
752
|
+
if (checkBudget('timeBudget exceeded during caller scan'))
|
|
753
|
+
break;
|
|
754
|
+
if (edges.length >= limits.maxEdges)
|
|
755
|
+
break;
|
|
756
|
+
const hitPath = hit.path;
|
|
757
|
+
if (!hitPath || hit.kind !== 'code')
|
|
758
|
+
continue;
|
|
759
|
+
// Skip obvious self-reference in the same file if no symbol boundary detection.
|
|
760
|
+
// We still allow calls within the same file (but avoid exploding edges).
|
|
761
|
+
// Read file lines (cache)
|
|
762
|
+
let lines = fileLineCache.get(hitPath);
|
|
763
|
+
if (!lines) {
|
|
764
|
+
try {
|
|
765
|
+
const abs = safeJoin(paths.rootDir, hitPath);
|
|
766
|
+
const content = await fs.readFile(abs, 'utf8');
|
|
767
|
+
lines = content.replace(/\r\n/g, '\n').split('\n');
|
|
768
|
+
fileLineCache.set(hitPath, lines);
|
|
769
|
+
filesRead++;
|
|
770
|
+
}
|
|
771
|
+
catch {
|
|
772
|
+
continue;
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
const line = lines[hit.lineNo - 1] ?? '';
|
|
776
|
+
const call = isLikelyCallSite(line, targetSymbol);
|
|
777
|
+
if (!call)
|
|
778
|
+
continue;
|
|
779
|
+
const callerId = hit.context?.functionName
|
|
780
|
+
? `symbol:${hit.context.functionName}@${hitPath}#${hit.context.startLine}`
|
|
781
|
+
: `file:${hitPath}`;
|
|
782
|
+
if (callerId === targetSymbolId)
|
|
783
|
+
continue;
|
|
784
|
+
if (hit.context?.functionName) {
|
|
785
|
+
addNode({
|
|
786
|
+
id: callerId,
|
|
787
|
+
kind: 'symbol',
|
|
788
|
+
name: hit.context.functionName,
|
|
789
|
+
file: hitPath,
|
|
790
|
+
range: {
|
|
791
|
+
startLine: hit.context.startLine,
|
|
792
|
+
startCol: 1,
|
|
793
|
+
endLine: hit.context.endLine,
|
|
794
|
+
endCol: 1,
|
|
795
|
+
},
|
|
796
|
+
attrs: hit.context.className ? { className: hit.context.className } : undefined,
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
else {
|
|
800
|
+
addNode({ id: callerId, kind: 'file', name: hitPath, file: hitPath });
|
|
801
|
+
}
|
|
802
|
+
const src = {
|
|
803
|
+
file: hitPath,
|
|
804
|
+
range: { startLine: hit.lineNo, startCol: call.startCol, endLine: hit.lineNo, endCol: call.endCol },
|
|
805
|
+
uri: bundleFileUri(hitPath),
|
|
806
|
+
snippet: clampSnippet(line, 200),
|
|
807
|
+
};
|
|
808
|
+
src.snippetSha256 = sha256Hex(src.snippet ?? '');
|
|
809
|
+
const evidenceId = makeEvidenceId(['calls', callerId, targetSymbolId, hitPath, String(hit.lineNo), String(call.startCol)]);
|
|
810
|
+
addEdge({
|
|
811
|
+
evidenceId,
|
|
812
|
+
kind: 'edge',
|
|
813
|
+
type: 'calls',
|
|
814
|
+
from: callerId,
|
|
815
|
+
to: targetSymbolId,
|
|
816
|
+
method: 'heuristic',
|
|
817
|
+
confidence: 0.6,
|
|
818
|
+
sources: [src],
|
|
819
|
+
notes: ['call edge is name-based (no type/overload resolution)'],
|
|
820
|
+
});
|
|
821
|
+
callEdges++;
|
|
822
|
+
if (nodes.size >= limits.maxNodes) {
|
|
823
|
+
truncated = true;
|
|
824
|
+
truncatedReason = 'maxNodes reached';
|
|
825
|
+
break;
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
if (searchHits === maxHits) {
|
|
829
|
+
warnings.push({
|
|
830
|
+
code: 'search_hits_capped',
|
|
831
|
+
message: `Search hits were capped at ${maxHits}; graph may be incomplete.`,
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
else {
|
|
836
|
+
warnings.push({
|
|
837
|
+
code: 'symbol_missing_or_too_short',
|
|
838
|
+
message: 'No symbol provided (or symbol too short). Upstream call graph was skipped; only imports were extracted from the target file.',
|
|
839
|
+
});
|
|
840
|
+
}
|
|
841
|
+
// Post-process warnings
|
|
842
|
+
warnings.push({
|
|
843
|
+
code: 'limitations',
|
|
844
|
+
message: usedAstForImports
|
|
845
|
+
? '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.'
|
|
846
|
+
: '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.',
|
|
847
|
+
});
|
|
848
|
+
// Stats
|
|
849
|
+
importEdges = edges.filter((e) => e.type === 'imports').length;
|
|
850
|
+
const out = {
|
|
851
|
+
meta: {
|
|
852
|
+
requestId,
|
|
853
|
+
generatedAt: nowIso(),
|
|
854
|
+
timeMs: Date.now() - startedAt,
|
|
855
|
+
repo: {
|
|
856
|
+
bundleId: args.bundleId,
|
|
857
|
+
headSha: manifest.repos?.[0]?.headSha,
|
|
858
|
+
},
|
|
859
|
+
budget: {
|
|
860
|
+
timeBudgetMs,
|
|
861
|
+
truncated,
|
|
862
|
+
truncatedReason,
|
|
863
|
+
limits,
|
|
864
|
+
},
|
|
865
|
+
},
|
|
866
|
+
facts: {
|
|
867
|
+
nodes: Array.from(nodes.values()),
|
|
868
|
+
edges,
|
|
869
|
+
},
|
|
870
|
+
signals: {
|
|
871
|
+
stats: {
|
|
872
|
+
filesRead,
|
|
873
|
+
searchHits,
|
|
874
|
+
callEdges,
|
|
875
|
+
importEdges,
|
|
876
|
+
},
|
|
877
|
+
warnings,
|
|
878
|
+
},
|
|
879
|
+
};
|
|
880
|
+
return out;
|
|
881
|
+
}
|
|
882
|
+
/**
|
|
883
|
+
* Global dependency graph mode: analyze all code files in the bundle.
|
|
884
|
+
* Generates import relationships between all files.
|
|
885
|
+
*/
|
|
886
|
+
async function generateGlobalDependencyGraph(ctx) {
|
|
887
|
+
const { cfg, args, paths, manifest, limits, nodes, edges, warnings, startedAt, requestId, timeBudgetMs, checkBudget, addNode, addEdge, bundleFileUri, } = ctx;
|
|
888
|
+
let truncated = false;
|
|
889
|
+
let truncatedReason;
|
|
890
|
+
let filesProcessed = 0;
|
|
891
|
+
let usedAstCount = 0;
|
|
892
|
+
// Collect all code files
|
|
893
|
+
const codeExtensions = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.py', '.go', '.rs', '.java', '.rb', '.php']);
|
|
894
|
+
const codeFiles = [];
|
|
895
|
+
async function* walkDir(dir, prefix) {
|
|
896
|
+
try {
|
|
897
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
898
|
+
for (const ent of entries) {
|
|
899
|
+
if (checkBudget('timeBudget exceeded during file discovery'))
|
|
900
|
+
return;
|
|
901
|
+
const relPath = prefix ? `${prefix}/${ent.name}` : ent.name;
|
|
902
|
+
if (ent.isDirectory()) {
|
|
903
|
+
yield* walkDir(path.join(dir, ent.name), relPath);
|
|
904
|
+
}
|
|
905
|
+
else if (ent.isFile()) {
|
|
906
|
+
const ext = path.extname(ent.name).toLowerCase();
|
|
907
|
+
if (codeExtensions.has(ext)) {
|
|
908
|
+
yield relPath;
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
catch {
|
|
914
|
+
// ignore unreadable directories
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
// Walk repos directory
|
|
918
|
+
for await (const relPath of walkDir(paths.reposDir, 'repos')) {
|
|
919
|
+
if (codeFiles.length >= limits.maxFiles) {
|
|
920
|
+
truncated = true;
|
|
921
|
+
truncatedReason = 'maxFiles reached during discovery';
|
|
922
|
+
break;
|
|
923
|
+
}
|
|
924
|
+
// Only include files under norm/ directories
|
|
925
|
+
if (relPath.includes('/norm/')) {
|
|
926
|
+
codeFiles.push(relPath);
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
warnings.push({
|
|
930
|
+
code: 'global_mode',
|
|
931
|
+
message: `Global dependency graph mode: analyzing ${codeFiles.length} code file(s). Results show import relationships between files.`,
|
|
932
|
+
});
|
|
933
|
+
// Process each file
|
|
934
|
+
const resolvedImportsCache = new Map();
|
|
935
|
+
for (const filePath of codeFiles) {
|
|
936
|
+
if (checkBudget('timeBudget exceeded during file processing')) {
|
|
937
|
+
truncated = true;
|
|
938
|
+
truncatedReason = 'timeBudget exceeded';
|
|
939
|
+
break;
|
|
940
|
+
}
|
|
941
|
+
const fileId = `file:${filePath}`;
|
|
942
|
+
addNode({ id: fileId, kind: 'file', name: filePath, file: filePath });
|
|
943
|
+
// Read and extract imports
|
|
944
|
+
try {
|
|
945
|
+
const absPath = safeJoin(paths.rootDir, filePath);
|
|
946
|
+
const raw = await fs.readFile(absPath, 'utf8');
|
|
947
|
+
const normalized = raw.replace(/\r\n/g, '\n');
|
|
948
|
+
const lines = normalized.split('\n');
|
|
949
|
+
const extracted = await extractImportsForFile(cfg, filePath, normalized, lines, warnings);
|
|
950
|
+
if (extracted.usedAst)
|
|
951
|
+
usedAstCount++;
|
|
952
|
+
filesProcessed++;
|
|
953
|
+
const fileRepo = parseRepoNormPath(filePath);
|
|
954
|
+
if (!fileRepo)
|
|
955
|
+
continue;
|
|
956
|
+
// Resolve imports to files in the bundle
|
|
957
|
+
for (const imp of extracted.imports) {
|
|
958
|
+
if (checkBudget('timeBudget exceeded during import resolution'))
|
|
959
|
+
break;
|
|
960
|
+
// Try to resolve the import to a file in the same repo
|
|
961
|
+
const resolvedFile = await resolveImportInRepo({
|
|
962
|
+
rootDir: paths.rootDir,
|
|
963
|
+
repoRoot: fileRepo.repoRoot,
|
|
964
|
+
importerRepoRel: fileRepo.repoRelativePath,
|
|
965
|
+
module: imp.module,
|
|
966
|
+
cache: resolvedImportsCache,
|
|
967
|
+
});
|
|
968
|
+
if (resolvedFile) {
|
|
969
|
+
const targetId = `file:${resolvedFile}`;
|
|
970
|
+
addNode({ id: targetId, kind: 'file', name: resolvedFile, file: resolvedFile });
|
|
971
|
+
const source = {
|
|
972
|
+
file: filePath,
|
|
973
|
+
range: imp.range,
|
|
974
|
+
uri: bundleFileUri(filePath),
|
|
975
|
+
snippet: clampSnippet(lines[imp.range.startLine - 1] ?? '', 200),
|
|
976
|
+
};
|
|
977
|
+
source.snippetSha256 = sha256Hex(source.snippet ?? '');
|
|
978
|
+
addEdge({
|
|
979
|
+
evidenceId: makeEvidenceId(['imports_resolved', fileId, targetId, String(imp.range.startLine)]),
|
|
980
|
+
kind: 'edge',
|
|
981
|
+
type: 'imports_resolved',
|
|
982
|
+
from: fileId,
|
|
983
|
+
to: targetId,
|
|
984
|
+
method: imp.method,
|
|
985
|
+
confidence: Math.min(0.85, imp.confidence),
|
|
986
|
+
sources: [source],
|
|
987
|
+
notes: [...imp.notes, `resolved import "${imp.module}" to ${resolvedFile}`],
|
|
988
|
+
});
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
catch {
|
|
993
|
+
// Skip unreadable files
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
// Post-process warnings
|
|
997
|
+
warnings.push({
|
|
998
|
+
code: 'limitations',
|
|
999
|
+
message: usedAstCount > 0
|
|
1000
|
+
? `Global graph used AST parsing for ${usedAstCount}/${filesProcessed} files. Import resolution is best-effort. Only internal imports (resolved to files in the bundle) are shown.`
|
|
1001
|
+
: 'Global graph used regex-based import extraction. Import resolution is best-effort. Only internal imports (resolved to files in the bundle) are shown.',
|
|
1002
|
+
});
|
|
1003
|
+
const importEdges = edges.filter((e) => e.type === 'imports_resolved').length;
|
|
1004
|
+
return {
|
|
1005
|
+
meta: {
|
|
1006
|
+
requestId,
|
|
1007
|
+
generatedAt: nowIso(),
|
|
1008
|
+
timeMs: Date.now() - startedAt,
|
|
1009
|
+
repo: {
|
|
1010
|
+
bundleId: args.bundleId,
|
|
1011
|
+
headSha: manifest.repos?.[0]?.headSha,
|
|
1012
|
+
},
|
|
1013
|
+
budget: {
|
|
1014
|
+
timeBudgetMs,
|
|
1015
|
+
truncated,
|
|
1016
|
+
truncatedReason,
|
|
1017
|
+
limits,
|
|
1018
|
+
},
|
|
1019
|
+
},
|
|
1020
|
+
facts: {
|
|
1021
|
+
nodes: Array.from(nodes.values()),
|
|
1022
|
+
edges,
|
|
1023
|
+
},
|
|
1024
|
+
signals: {
|
|
1025
|
+
stats: {
|
|
1026
|
+
filesRead: filesProcessed,
|
|
1027
|
+
searchHits: 0,
|
|
1028
|
+
callEdges: 0,
|
|
1029
|
+
importEdges,
|
|
1030
|
+
},
|
|
1031
|
+
warnings,
|
|
1032
|
+
},
|
|
1033
|
+
};
|
|
1034
|
+
}
|
|
1035
|
+
/**
|
|
1036
|
+
* Resolve an import to a file path within the same repo.
|
|
1037
|
+
*/
|
|
1038
|
+
async function resolveImportInRepo(ctx) {
|
|
1039
|
+
const { rootDir, repoRoot, importerRepoRel, module, cache } = ctx;
|
|
1040
|
+
// Get or create cache for this repo
|
|
1041
|
+
let repoCache = cache.get(repoRoot);
|
|
1042
|
+
if (!repoCache) {
|
|
1043
|
+
repoCache = new Map();
|
|
1044
|
+
cache.set(repoRoot, repoCache);
|
|
1045
|
+
}
|
|
1046
|
+
const cacheKey = `${importerRepoRel}:${module}`;
|
|
1047
|
+
const cached = repoCache.get(cacheKey);
|
|
1048
|
+
if (cached !== undefined)
|
|
1049
|
+
return cached;
|
|
1050
|
+
const ext = path.extname(importerRepoRel).toLowerCase();
|
|
1051
|
+
const cleaned = (module.split(/[?#]/, 1)[0] ?? '').trim();
|
|
1052
|
+
if (!cleaned) {
|
|
1053
|
+
repoCache.set(cacheKey, null);
|
|
1054
|
+
return null;
|
|
1055
|
+
}
|
|
1056
|
+
const bundlePathForRepoRel = (repoRel) => `${repoRoot}/${repoRel.replaceAll('\\', '/')}`;
|
|
1057
|
+
const normalizeDir = (d) => (d === '.' ? '' : d);
|
|
1058
|
+
// JS/TS resolution
|
|
1059
|
+
const isJs = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'].includes(ext);
|
|
1060
|
+
if (isJs) {
|
|
1061
|
+
if (!cleaned.startsWith('.') && !cleaned.startsWith('/')) {
|
|
1062
|
+
repoCache.set(cacheKey, null);
|
|
1063
|
+
return null;
|
|
1064
|
+
}
|
|
1065
|
+
const importerDir = normalizeDir(path.posix.dirname(importerRepoRel));
|
|
1066
|
+
const base = cleaned.startsWith('/')
|
|
1067
|
+
? path.posix.normalize(cleaned.slice(1))
|
|
1068
|
+
: path.posix.normalize(path.posix.join(importerDir, cleaned));
|
|
1069
|
+
const candidates = [];
|
|
1070
|
+
const baseExt = path.posix.extname(base).toLowerCase();
|
|
1071
|
+
if (baseExt) {
|
|
1072
|
+
candidates.push(base);
|
|
1073
|
+
if (['.js', '.mjs', '.cjs'].includes(baseExt)) {
|
|
1074
|
+
const stem = base.slice(0, -baseExt.length);
|
|
1075
|
+
candidates.push(`${stem}.ts`, `${stem}.tsx`, `${stem}.jsx`);
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
else {
|
|
1079
|
+
const exts = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'];
|
|
1080
|
+
for (const e of exts)
|
|
1081
|
+
candidates.push(`${base}${e}`);
|
|
1082
|
+
for (const e of exts)
|
|
1083
|
+
candidates.push(path.posix.join(base, `index${e}`));
|
|
1084
|
+
}
|
|
1085
|
+
for (const repoRel of candidates) {
|
|
1086
|
+
const bundleRel = bundlePathForRepoRel(repoRel);
|
|
1087
|
+
const abs = safeJoin(rootDir, bundleRel);
|
|
1088
|
+
if (await fileExists(abs)) {
|
|
1089
|
+
repoCache.set(cacheKey, bundleRel);
|
|
1090
|
+
return bundleRel;
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
// Python resolution
|
|
1095
|
+
if (ext === '.py') {
|
|
1096
|
+
if (cleaned.startsWith('.')) {
|
|
1097
|
+
// Relative import
|
|
1098
|
+
const m = cleaned.match(/^(\.+)(.*)$/);
|
|
1099
|
+
if (m) {
|
|
1100
|
+
const dotCount = m[1]?.length ?? 0;
|
|
1101
|
+
const rest = (m[2] ?? '').replace(/^\.+/, '');
|
|
1102
|
+
let baseDir = normalizeDir(path.posix.dirname(importerRepoRel));
|
|
1103
|
+
for (let i = 1; i < dotCount; i++) {
|
|
1104
|
+
baseDir = normalizeDir(path.posix.dirname(baseDir));
|
|
1105
|
+
}
|
|
1106
|
+
const restPath = rest ? rest.replace(/\./g, '/') : '';
|
|
1107
|
+
const candidates = restPath
|
|
1108
|
+
? [path.posix.join(baseDir, `${restPath}.py`), path.posix.join(baseDir, restPath, '__init__.py')]
|
|
1109
|
+
: [path.posix.join(baseDir, '__init__.py')];
|
|
1110
|
+
for (const repoRel of candidates) {
|
|
1111
|
+
const bundleRel = bundlePathForRepoRel(repoRel);
|
|
1112
|
+
const abs = safeJoin(rootDir, bundleRel);
|
|
1113
|
+
if (await fileExists(abs)) {
|
|
1114
|
+
repoCache.set(cacheKey, bundleRel);
|
|
1115
|
+
return bundleRel;
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
else {
|
|
1121
|
+
// Absolute import - try common patterns
|
|
1122
|
+
const modPath = cleaned.replace(/\./g, '/');
|
|
1123
|
+
const candidates = [`${modPath}.py`, path.posix.join(modPath, '__init__.py')];
|
|
1124
|
+
for (const repoRel of candidates) {
|
|
1125
|
+
const bundleRel = bundlePathForRepoRel(repoRel);
|
|
1126
|
+
const abs = safeJoin(rootDir, bundleRel);
|
|
1127
|
+
if (await fileExists(abs)) {
|
|
1128
|
+
repoCache.set(cacheKey, bundleRel);
|
|
1129
|
+
return bundleRel;
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
repoCache.set(cacheKey, null);
|
|
1135
|
+
return null;
|
|
1136
|
+
}
|