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.
@@ -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
+ }