( spec: string, fromFile: string, fileSet: Set<string>, basenameIndex: Map<string, string[]>, )
| 100 | * alias fallback. |
| 101 | */ |
| 102 | export function resolveSpecifier( |
| 103 | spec: string, |
| 104 | fromFile: string, |
| 105 | fileSet: Set<string>, |
| 106 | basenameIndex: Map<string, string[]>, |
| 107 | ): string | null { |
| 108 | // Relative import → resolve against the importing file's dir. |
| 109 | if (spec.startsWith('./') || spec.startsWith('../')) { |
| 110 | const baseDir = path.dirname(fromFile); |
| 111 | const joined = path.normalize(path.join(baseDir, spec)); |
| 112 | // try exact, with extensions, and as a directory index |
| 113 | for (const ext of RESOLVE_EXTS) { |
| 114 | const cand = ext ? `${joined}${ext}` : joined; |
| 115 | if (fileSet.has(cand)) return cand; |
| 116 | } |
| 117 | for (const idx of INDEX_FILES) { |
| 118 | const cand = path.join(joined, idx); |
| 119 | if (fileSet.has(cand)) return cand; |
| 120 | } |
| 121 | return null; |
| 122 | } |
| 123 | |
| 124 | // Python dotted intra-package (from .models import X handled above as ".models") |
| 125 | if (spec.startsWith('.')) { |
| 126 | const baseDir = path.dirname(fromFile); |
| 127 | const rel = spec.replace(/\./g, '/').replace(/^\//, ''); |
| 128 | for (const ext of RESOLVE_EXTS) { |
| 129 | const cand = path.normalize(path.join(baseDir, rel + (ext || '.py'))); |
| 130 | if (fileSet.has(cand)) return cand; |
| 131 | } |
| 132 | return null; |
| 133 | } |
| 134 | |
| 135 | // Bare specifier: could be a path alias (@/store/cart, ~/lib/db) or an npm pkg. |
| 136 | // Strategy: take the last path segment and match by basename against project |
| 137 | // files. This resolves aliases without parsing tsconfig "paths", at the cost |
| 138 | // of occasionally matching a same-named file — acceptable for RAG widening |
| 139 | // (we're casting a slightly wider net, not doing a refactor). |
| 140 | const lastSeg = spec.split('/').pop()!; |
| 141 | const base = lastSeg.replace(path.extname(lastSeg), ''); |
| 142 | const cands = basenameIndex.get(base); |
| 143 | if (cands && cands.length > 0) { |
| 144 | // Prefer a candidate whose path contains an earlier segment of the spec |
| 145 | // (e.g. "@/store/cart" → prefer .../store/cart.ts over .../cart.ts elsewhere). |
| 146 | const segs = spec.split('/').filter(s => s && !s.startsWith('@') && !s.startsWith('~') && s !== '.'); |
| 147 | const scored = cands |
| 148 | .map(c => ({ c, score: segs.filter(s => c.includes(s)).length })) |
| 149 | .sort((a, b) => b.score - a.score); |
| 150 | // Only accept if at least the basename matched a real project file. |
| 151 | return scored[0]!.c; |
| 152 | } |
| 153 | return null; // external package |
| 154 | } |
| 155 | |
| 156 | /** Build the import graph for a set of files. `read` lets callers inject cached content. */ |
| 157 | export async function buildImportGraph( |
no test coverage detected