@maxgfr/codeindex 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.md +115 -0
  2. package/docs/MIGRATION.md +100 -0
  3. package/package.json +89 -0
  4. package/scripts/cli.mjs +10 -0
  5. package/scripts/engine.d.mts +618 -0
  6. package/scripts/engine.mjs +10655 -0
  7. package/scripts/grammars/bash.wasm +0 -0
  8. package/scripts/grammars/c.wasm +0 -0
  9. package/scripts/grammars/c_sharp.wasm +0 -0
  10. package/scripts/grammars/cpp.wasm +0 -0
  11. package/scripts/grammars/go.wasm +0 -0
  12. package/scripts/grammars/java.wasm +0 -0
  13. package/scripts/grammars/javascript.wasm +0 -0
  14. package/scripts/grammars/lua.wasm +0 -0
  15. package/scripts/grammars/php.wasm +0 -0
  16. package/scripts/grammars/python.wasm +0 -0
  17. package/scripts/grammars/ruby.wasm +0 -0
  18. package/scripts/grammars/rust.wasm +0 -0
  19. package/scripts/grammars/scala.wasm +0 -0
  20. package/scripts/grammars/tsx.wasm +0 -0
  21. package/scripts/grammars/typescript.wasm +0 -0
  22. package/scripts/grammars/web-tree-sitter.wasm +0 -0
  23. package/src/ast/extract.ts +713 -0
  24. package/src/ast/loader.ts +104 -0
  25. package/src/bm25.ts +156 -0
  26. package/src/callers.ts +0 -0
  27. package/src/calls.ts +131 -0
  28. package/src/categorize.ts +80 -0
  29. package/src/centrality.ts +148 -0
  30. package/src/classify.ts +53 -0
  31. package/src/cli-entry.ts +16 -0
  32. package/src/community.ts +345 -0
  33. package/src/complexity.ts +69 -0
  34. package/src/coupling.ts +0 -0
  35. package/src/deadcode.ts +46 -0
  36. package/src/edit.ts +93 -0
  37. package/src/engine-cli.ts +278 -0
  38. package/src/engine.ts +148 -0
  39. package/src/extract/code.ts +376 -0
  40. package/src/extract/markdown.ts +135 -0
  41. package/src/git.ts +205 -0
  42. package/src/glob.ts +56 -0
  43. package/src/graph.ts +0 -0
  44. package/src/grep.ts +115 -0
  45. package/src/hash.ts +13 -0
  46. package/src/ignore.ts +134 -0
  47. package/src/lang/c.ts +26 -0
  48. package/src/lang/common.ts +68 -0
  49. package/src/lang/csharp.ts +22 -0
  50. package/src/lang/elixir.ts +18 -0
  51. package/src/lang/go.ts +22 -0
  52. package/src/lang/java.ts +19 -0
  53. package/src/lang/js-ts.ts +112 -0
  54. package/src/lang/kotlin.ts +20 -0
  55. package/src/lang/lua.ts +18 -0
  56. package/src/lang/php.ts +24 -0
  57. package/src/lang/python.ts +22 -0
  58. package/src/lang/registry.ts +54 -0
  59. package/src/lang/ruby.ts +17 -0
  60. package/src/lang/rust.ts +22 -0
  61. package/src/lang/scala.ts +19 -0
  62. package/src/lang/shell.ts +17 -0
  63. package/src/lang/swift.ts +23 -0
  64. package/src/mcp.ts +512 -0
  65. package/src/memory.ts +75 -0
  66. package/src/modules.ts +128 -0
  67. package/src/pipeline.ts +59 -0
  68. package/src/query.ts +118 -0
  69. package/src/render/graph-json.ts +16 -0
  70. package/src/render/symbols-json.ts +79 -0
  71. package/src/repomap.ts +52 -0
  72. package/src/resolve.ts +950 -0
  73. package/src/rules.ts +249 -0
  74. package/src/scan.ts +172 -0
  75. package/src/sort.ts +12 -0
  76. package/src/surprise.ts +58 -0
  77. package/src/tests-map.ts +105 -0
  78. package/src/types.ts +201 -0
  79. package/src/util.ts +169 -0
  80. package/src/viz.ts +44 -0
  81. package/src/walk.ts +216 -0
  82. package/src/workspaces.ts +748 -0
package/src/resolve.ts ADDED
@@ -0,0 +1,950 @@
1
+ import { posix } from "node:path";
2
+ import { join } from "node:path";
3
+ import type { RepoScan } from "./scan.js";
4
+ import { readText } from "./walk.js";
5
+ import { byStr } from "./sort.js";
6
+
7
+ // Resolution outcome for a single ref. `external` ⇒ no edge (third-party,
8
+ // stdlib, URL, in-page anchor). `dangling` ⇒ an edge that points nowhere real
9
+ // (a local target that doesn't exist) — surfaced, never silently dropped.
10
+ export type Resolution =
11
+ | { kind: "resolved"; target: string }
12
+ | { kind: "external" }
13
+ | { kind: "dangling"; reason: string };
14
+
15
+ interface TsPath {
16
+ prefix: string; // text before a trailing "*", or the whole alias if exact
17
+ star: boolean;
18
+ targets: string[]; // path-relative-to-baseUrl, "*" preserved
19
+ }
20
+
21
+ // One tsconfig/jsconfig's alias scope. In a monorepo each package can declare its
22
+ // own baseUrl/paths, so we keep them per-config and resolve an import against the
23
+ // NEAREST enclosing config (deepest dir first) rather than a single root config.
24
+ interface TsConfigScope {
25
+ dir: string; // the config's own directory (posix, "" = repo root) — scope test
26
+ baseUrl: string; // repo-relative posix dir the `targets` resolve against
27
+ paths: TsPath[];
28
+ }
29
+
30
+ // One subpath of a package.json `exports` map, conditions already flattened into
31
+ // an ordered target list (source-ish conditions first, `types` last).
32
+ interface ExportEntry {
33
+ key: string; // "." | "./utils" | "./features/*"
34
+ star: boolean;
35
+ targets: string[]; // pkg-dir-relative, "*" preserved in star entries
36
+ }
37
+
38
+ interface WorkspacePackage {
39
+ name: string;
40
+ dir: string; // posix dir of its package.json, "" for root
41
+ exportEntries: ExportEntry[]; // empty when the package declares no `exports`
42
+ mainCandidates: string[]; // source/main/module/types fields, priority order
43
+ }
44
+
45
+ interface GoModule {
46
+ module: string;
47
+ dir: string; // posix dir of go.mod, "" for root
48
+ replaces: { from: string; toDir: string }[]; // in-repo relative replaces only
49
+ }
50
+
51
+ interface RustCrate {
52
+ name: string; // [package].name with "-" mapped to "_" (the in-code identifier)
53
+ dir: string; // posix dir of Cargo.toml, "" for root
54
+ srcDir: string; // dir/src
55
+ rootFile?: string; // src/lib.rs or src/main.rs, whichever exists
56
+ }
57
+
58
+ export interface ResolveContext {
59
+ fileSet: Set<string>;
60
+ dirSet: Set<string>; // every directory that has any file beneath it
61
+ filesByDir: Map<string, string[]>; // dir (posix, "" for root) -> rel files
62
+ tsConfigs: TsConfigScope[]; // nearest-enclosing first (deepest dir wins)
63
+ goModules: GoModule[]; // every in-repo go.mod, deepest dir first
64
+ rustCrates: RustCrate[]; // every in-repo Cargo.toml [package], deepest dir first
65
+ javaRoots: string[]; // dirs that java package paths resolve against
66
+ pyRoots: string[]; // posix dirs that are python import roots ("" allowed)
67
+ workspacePackages: WorkspacePackage[]; // monorepo pkg name -> its dir + entry points
68
+ cIncludeRoots: string[]; // dirs a C/C++ `#include "x"` resolves against (besides the file's dir)
69
+ rubyLibRoots: string[]; // dirs a Ruby bare `require` resolves against
70
+ phpPsr4: { prefix: string; dir: string }[]; // composer PSR-4 namespace prefix -> dir, longest first
71
+ csharpNamespaces: Map<string, string[]>; // C# namespace -> files declaring it (sorted)
72
+ warnings: string[]; // build-time config issues (e.g. an unparseable tsconfig)
73
+ }
74
+
75
+ // Extensions walk() skips (images, fonts, media, maps). An import of one of these
76
+ // is a real asset dependency handled by a bundler — NOT a broken code edge — so
77
+ // it resolves as `external`, never dangling.
78
+ const ASSET_EXT = new Set([
79
+ ".svg", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".ico", ".icns", ".pdf",
80
+ ".woff", ".woff2", ".ttf", ".otf", ".eot", ".mp3", ".mp4", ".mov", ".avi", ".webm",
81
+ ".wav", ".flac", ".ogg", ".map",
82
+ ]);
83
+
84
+ // Extensionless-import probe order. `.ts` before `.tsx` is deliberate (it
85
+ // mirrors tsc's own resolution order, and pinned output bytes depend on it) —
86
+ // when both `x.ts` and `x.tsx` exist, the plain `.ts` module wins; do NOT
87
+ // reorder. The SFC/HTML candidates (.vue/.svelte/.astro/.html/.htm) sit AFTER
88
+ // every JS-family extension so they only match when no JS/TS source does —
89
+ // appending keeps every pre-existing resolution byte-identical.
90
+ const JS_EXT_PROBES = [
91
+ "", ".ts", ".tsx", ".d.ts", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs",
92
+ ".vue", ".svelte", ".astro", ".html", ".htm",
93
+ ];
94
+ const JS_INDEX = ["index.ts", "index.tsx", "index.js", "index.jsx", "index.mjs", "index.cjs"];
95
+ const JS_TS = new Set([".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"]);
96
+ // SFC/HTML importers: a .vue/.svelte/.astro/.html/.htm file's <script> block
97
+ // carries ordinary ES imports, so those files resolve through the JS-family
98
+ // path exactly like a .tsx file — not through the external/dangling fallback.
99
+ const SFC_HTML = new Set([".vue", ".svelte", ".astro", ".html", ".htm"]);
100
+ const PY = new Set([".py", ".pyi"]);
101
+ const C_CPP = new Set([".c", ".h", ".cc", ".cpp", ".cxx", ".hpp", ".hh"]);
102
+
103
+ // Directory names that hold build output. An exports-map target under one of
104
+ // these usually has its source under `src/` (or at the package root) instead.
105
+ const BUILD_DIRS = new Set(["dist", "build", "lib", "out", "output", "esm", "cjs", "umd"]);
106
+
107
+ // "./dist/esm/index.js" → ["src/esm/index.js", "esm/index.js", "src/index.js",
108
+ // "index.js"] — peel leading build dirs one at a time, trying both a `src/`
109
+ // substitute and a plain drop at each step.
110
+ function distToSrcCandidates(target: string): string[] {
111
+ const segs = norm(target).split("/").filter((s) => s !== ".");
112
+ const out: string[] = [];
113
+ let i = 0;
114
+ while (i < segs.length - 1 && BUILD_DIRS.has(segs[i]!)) {
115
+ i++;
116
+ const rest = segs.slice(i).join("/");
117
+ out.push("src/" + rest, rest);
118
+ }
119
+ return out;
120
+ }
121
+
122
+ function norm(p: string): string {
123
+ // posix.normalize keeps ".." that escape the root as a leading "../"; callers
124
+ // treat an escaping path as unresolved.
125
+ return posix.normalize(p).replace(/\/$/, "");
126
+ }
127
+
128
+ function firstThat(fileSet: Set<string>, candidates: string[]): string | undefined {
129
+ for (const c of candidates) {
130
+ const n = norm(c);
131
+ if (fileSet.has(n)) return n;
132
+ }
133
+ return undefined;
134
+ }
135
+
136
+ function byLen(a: string, b: string): number {
137
+ return a.length - b.length || (a < b ? -1 : a > b ? 1 : 0);
138
+ }
139
+
140
+ function tolerantJsonParse(text: string): unknown {
141
+ // tsconfig.json is JSONC: strip // and /* */ comments and trailing commas. This
142
+ // MUST be string-aware — tsconfig glob values like "**/*.ts" or
143
+ // "./src/styled-system/*" contain `/*`, `*/` and `//` that a naive regex
144
+ // stripper mistakes for comment delimiters and shreds the document (a real
145
+ // failure on Next.js tsconfigs that mix a `…/*` path alias with `**/*.ts`
146
+ // includes). So scan char-by-char and only strip comments OUTSIDE strings.
147
+ let stripped = "";
148
+ let inStr = false;
149
+ for (let i = 0; i < text.length; i++) {
150
+ const c = text[i]!;
151
+ if (inStr) {
152
+ stripped += c;
153
+ if (c === "\\") stripped += text[++i] ?? ""; // keep the escaped char verbatim
154
+ else if (c === '"') inStr = false;
155
+ continue;
156
+ }
157
+ if (c === '"') {
158
+ inStr = true;
159
+ stripped += c;
160
+ } else if (c === "/" && text[i + 1] === "/") {
161
+ while (i < text.length && text[i] !== "\n") i++;
162
+ stripped += "\n"; // preserve line structure
163
+ } else if (c === "/" && text[i + 1] === "*") {
164
+ i += 2;
165
+ while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) i++;
166
+ i++; // skip the closing '/'
167
+ } else {
168
+ stripped += c;
169
+ }
170
+ }
171
+ // Drop trailing commas (a `,` right before a `}` or `]`) — also string-aware,
172
+ // so a path glob value that literally contains `,}` or `,]` is left intact.
173
+ let out = "";
174
+ inStr = false;
175
+ for (let i = 0; i < stripped.length; i++) {
176
+ const c = stripped[i]!;
177
+ if (inStr) {
178
+ out += c;
179
+ if (c === "\\") out += stripped[++i] ?? "";
180
+ else if (c === '"') inStr = false;
181
+ continue;
182
+ }
183
+ if (c === '"') {
184
+ inStr = true;
185
+ out += c;
186
+ continue;
187
+ }
188
+ if (c === ",") {
189
+ let j = i + 1;
190
+ while (j < stripped.length && (stripped[j] === " " || stripped[j] === "\t" || stripped[j] === "\n" || stripped[j] === "\r")) j++;
191
+ if (stripped[j] === "}" || stripped[j] === "]") continue; // trailing comma → drop
192
+ }
193
+ out += c;
194
+ }
195
+ try {
196
+ return JSON.parse(out);
197
+ } catch {
198
+ return undefined;
199
+ }
200
+ }
201
+
202
+ // Resolve a tsconfig `extends` target to an in-repo config path, or undefined for
203
+ // a package specifier (a node_modules base we don't index) or a missing file.
204
+ // A bare non-relative target ("base.json", written without the "./" prefix —
205
+ // tsc accepts it) is probed relative to the config's own directory too; only
206
+ // when no such file exists is it treated as a package base (external), so a
207
+ // true package extends like "@tsconfig/node18" stays external.
208
+ function resolveExtends(fileSet: Set<string>, fromDir: string, ext: string): string | undefined {
209
+ const base = norm(posix.join(fromDir, ext));
210
+ const cands = ext.endsWith(".json") ? [base] : [base + ".json", posix.join(base, "tsconfig.json")];
211
+ for (const c of cands) if (fileSet.has(c)) return c;
212
+ return undefined;
213
+ }
214
+
215
+ interface TsEffective {
216
+ baseUrl?: string; // as written, relative to baseUrlDir
217
+ baseUrlDir: string; // dir of the config that DECLARED baseUrl
218
+ paths?: Record<string, string[]>;
219
+ pathsDir: string; // dir of the config that DECLARED paths
220
+ }
221
+
222
+ // Read a tsconfig/jsconfig and fold in its `extends` chain (in-repo, relative
223
+ // bases only), child overriding base — so a monorepo package whose baseUrl/paths
224
+ // live in a shared base (the dominant Nx/Turborepo/lerna layout) still
225
+ // contributes its aliases. baseUrl and paths are each tracked with the dir of the
226
+ // config that DECLARED them (TS resolves them relative to that file). Cycles are
227
+ // broken via `seen`; a missing relative base is surfaced as a warning.
228
+ function readTsConfig(
229
+ root: string,
230
+ fileSet: Set<string>,
231
+ rel: string,
232
+ warnings: string[],
233
+ seen: Set<string>,
234
+ ): TsEffective | undefined {
235
+ if (seen.has(rel)) return undefined;
236
+ seen.add(rel);
237
+ const cfg = tolerantJsonParse(readText(join(root, rel))) as
238
+ | { compilerOptions?: { baseUrl?: string; paths?: Record<string, string[]> }; extends?: string | string[] }
239
+ | undefined;
240
+ if (cfg === undefined) {
241
+ warnings.push(`unparseable ${rel} — its path aliases were ignored`);
242
+ return undefined;
243
+ }
244
+ const dir = rel.includes("/") ? posix.dirname(rel) : "";
245
+ const eff: TsEffective = { baseUrlDir: "", pathsDir: "" };
246
+ const exts = cfg.extends === undefined ? [] : Array.isArray(cfg.extends) ? cfg.extends : [cfg.extends];
247
+ for (const ext of exts) {
248
+ if (typeof ext !== "string") continue;
249
+ const baseRel = resolveExtends(fileSet, dir, ext);
250
+ if (!baseRel) {
251
+ if (/^\.\.?\//.test(ext)) warnings.push(`${rel} extends "${ext}" which is missing — its path aliases were ignored`);
252
+ continue; // bare specifiers (node_modules tooling bases) carry no repo paths
253
+ }
254
+ const inherited = readTsConfig(root, fileSet, baseRel, warnings, seen);
255
+ if (inherited?.baseUrl !== undefined) {
256
+ eff.baseUrl = inherited.baseUrl;
257
+ eff.baseUrlDir = inherited.baseUrlDir;
258
+ }
259
+ if (inherited?.paths) {
260
+ eff.paths = inherited.paths;
261
+ eff.pathsDir = inherited.pathsDir;
262
+ }
263
+ }
264
+ const co = cfg.compilerOptions;
265
+ if (co?.baseUrl !== undefined) {
266
+ eff.baseUrl = co.baseUrl;
267
+ eff.baseUrlDir = dir;
268
+ }
269
+ if (co?.paths) {
270
+ eff.paths = co.paths;
271
+ eff.pathsDir = dir;
272
+ }
273
+ return eff;
274
+ }
275
+
276
+ // Conditions ordered by how likely they are to point at committed source rather
277
+ // than build output. `types` goes dead last: a stray .d.ts is a worse node than
278
+ // any runtime entry. Unknown conditions (browser, deno, …) sit in between.
279
+ const CONDITION_PRIORITY = ["source", "ts", "import", "module", "require", "node", "default"];
280
+ const MAX_EXPORT_TARGETS = 8; // cap leaves per subpath — exports maps can be deep
281
+
282
+ function conditionRank(key: string): number {
283
+ const i = CONDITION_PRIORITY.indexOf(key);
284
+ if (i !== -1) return i;
285
+ return key === "types" ? CONDITION_PRIORITY.length + 1 : CONDITION_PRIORITY.length;
286
+ }
287
+
288
+ // Flatten one subpath's value (string | array | nested conditions object) into an
289
+ // ordered candidate list. We collect EVERY string leaf rather than picking one
290
+ // condition: an indexer wants "the first candidate that exists in the repo", not
291
+ // Node's single runtime answer.
292
+ function flattenExportTargets(value: unknown, out: string[]): void {
293
+ if (out.length >= MAX_EXPORT_TARGETS) return;
294
+ if (typeof value === "string") {
295
+ if (!out.includes(value)) out.push(value);
296
+ } else if (Array.isArray(value)) {
297
+ for (const v of value) flattenExportTargets(v, out);
298
+ } else if (value !== null && typeof value === "object") {
299
+ const keys = Object.keys(value).sort((a, b) => conditionRank(a) - conditionRank(b) || (a < b ? -1 : a > b ? 1 : 0));
300
+ for (const k of keys) flattenExportTargets((value as Record<string, unknown>)[k], out);
301
+ }
302
+ }
303
+
304
+ // Parse a package.json `exports` field into ordered entries: exact keys before
305
+ // wildcard keys, longer keys first (Node's pattern precedence, simplified).
306
+ function parseExportEntries(exportsField: unknown): ExportEntry[] {
307
+ if (exportsField === undefined || exportsField === null) return [];
308
+ const entries: ExportEntry[] = [];
309
+ const push = (key: string, value: unknown) => {
310
+ const targets: string[] = [];
311
+ flattenExportTargets(value, targets);
312
+ if (targets.length) entries.push({ key, star: key.includes("*"), targets });
313
+ };
314
+ if (typeof exportsField === "string" || Array.isArray(exportsField)) {
315
+ push(".", exportsField);
316
+ } else if (typeof exportsField === "object") {
317
+ const keys = Object.keys(exportsField);
318
+ if (keys.every((k) => k === "." || k.startsWith("./"))) {
319
+ for (const k of keys) push(k, (exportsField as Record<string, unknown>)[k]);
320
+ } else {
321
+ // A bare conditions object ({"import": …, "require": …}) describes ".".
322
+ push(".", exportsField);
323
+ }
324
+ }
325
+ entries.sort((a, b) => Number(a.star) - Number(b.star) || b.key.length - a.key.length || (a.key < b.key ? -1 : 1));
326
+ return entries;
327
+ }
328
+
329
+ // Parse a go.mod's `replace` directives (single-line and block form), keeping
330
+ // only relative targets that stay inside the repo — those are the directives
331
+ // that rewire one in-repo module onto another's source tree.
332
+ function parseGoReplaces(text: string, modDir: string): { from: string; toDir: string }[] {
333
+ const out: { from: string; toDir: string }[] = [];
334
+ const addLine = (line: string) => {
335
+ const m = /^\s*([^\s=]+)(?:\s+v\S+)?\s*=>\s*(\S+)(?:\s+v\S+)?\s*$/.exec(line);
336
+ if (!m) return;
337
+ const target = m[2]!;
338
+ if (!/^\.\.?\//.test(target)) return; // a module-path replacement, not a local dir
339
+ const toDir = norm(posix.join(modDir, target));
340
+ if (toDir.startsWith("..")) return; // escapes the repo — nothing to link to
341
+ out.push({ from: m[1]!, toDir });
342
+ };
343
+ for (const m of text.matchAll(/^[ \t]*replace[ \t]+([^(\r\n][^\r\n]*)$/gm)) addLine(m[1]!);
344
+ for (const b of text.matchAll(/^[ \t]*replace[ \t]*\(([\s\S]*?)\)/gm)) {
345
+ for (const line of b[1]!.split(/\r?\n/)) addLine(line);
346
+ }
347
+ return out;
348
+ }
349
+
350
+ // Build the repo-wide context resolution needs: the file set, a dir→files index,
351
+ // tsconfig path aliases, the go module paths, and python roots. Read once.
352
+ export function buildResolveContext(scan: RepoScan): ResolveContext {
353
+ const fileSet = new Set(scan.files.map((f) => f.rel));
354
+ const filesByDir = new Map<string, string[]>();
355
+ const dirSet = new Set<string>();
356
+ for (const f of scan.files) {
357
+ const dir = f.rel.includes("/") ? posix.dirname(f.rel) : "";
358
+ let list = filesByDir.get(dir);
359
+ if (!list) filesByDir.set(dir, (list = []));
360
+ list.push(f.rel);
361
+ // Record every ancestor directory so a doc link to a real folder (even one
362
+ // with no README) isn't mistaken for a broken link.
363
+ let d = dir;
364
+ while (d) {
365
+ if (dirSet.has(d)) break;
366
+ dirSet.add(d);
367
+ d = d.includes("/") ? posix.dirname(d) : "";
368
+ }
369
+ }
370
+
371
+ // tsconfig/jsconfig path aliases. Collect EVERY config, not just the root one
372
+ // (each monorepo package declares its own baseUrl/paths), and fold in each
373
+ // config's `extends` chain so aliases declared in a shared base config still
374
+ // resolve. An import resolves against the nearest enclosing config. Unparseable
375
+ // or missing configs surface as build warnings rather than vanishing silently.
376
+ const warnings: string[] = [];
377
+ const tsConfigs: TsConfigScope[] = [];
378
+ for (const rel of fileSet) {
379
+ const base = rel.slice(rel.lastIndexOf("/") + 1);
380
+ // Nx-style repos often have NO root tsconfig.json — only a tsconfig.base.json
381
+ // that per-project configs extend. Accept it as a root-scope config so its
382
+ // `@org/*` aliases resolve even for files outside any per-project config.
383
+ // Root only: a nested tsconfig.base.json is always reached via `extends`.
384
+ const isRootBase = rel === "tsconfig.base.json";
385
+ if (base !== "tsconfig.json" && base !== "jsconfig.json" && !isRootBase) continue;
386
+ const dir = rel.includes("/") ? posix.dirname(rel) : "";
387
+ const eff = readTsConfig(scan.root, fileSet, rel, warnings, new Set<string>());
388
+ if (!eff?.paths) continue; // no aliases to contribute
389
+ const tsPaths: TsPath[] = [];
390
+ for (const [alias, targets] of Object.entries(eff.paths)) {
391
+ if (!Array.isArray(targets)) continue;
392
+ const star = alias.endsWith("*");
393
+ tsPaths.push({ prefix: star ? alias.slice(0, -1) : alias, star, targets });
394
+ }
395
+ if (!tsPaths.length) continue; // only path-alias configs affect resolution
396
+ // `paths` resolve against baseUrl when set (relative to the config that
397
+ // declared baseUrl), else relative to the config that declared `paths`.
398
+ const baseUrl =
399
+ eff.baseUrl !== undefined
400
+ ? norm(posix.join(eff.baseUrlDir, eff.baseUrl)).replace(/^\.$/, "")
401
+ : eff.pathsDir;
402
+ tsConfigs.push({ dir, baseUrl, paths: tsPaths });
403
+ }
404
+ // Nearest-enclosing first: deepest dir wins; the root ("") is the fallback.
405
+ tsConfigs.sort((a, b) => b.dir.length - a.dir.length);
406
+
407
+ // Every go.mod, not just the one nearest the root — multi-module repos (a Go
408
+ // service beside a Go CLI) are normal. Deepest dir first so the module
409
+ // enclosing an importing file is found by a simple scan.
410
+ const goModules: GoModule[] = [];
411
+ for (const rel of fileSet) {
412
+ if (rel !== "go.mod" && !rel.endsWith("/go.mod")) continue;
413
+ const text = readText(join(scan.root, rel));
414
+ const m = /^\s*module\s+(\S+)/m.exec(text);
415
+ if (!m) continue;
416
+ const dir = rel.includes("/") ? posix.dirname(rel) : "";
417
+ goModules.push({ module: m[1]!, dir, replaces: parseGoReplaces(text, dir) });
418
+ }
419
+ goModules.sort((a, b) => b.dir.length - a.dir.length || (a.dir < b.dir ? -1 : 1));
420
+
421
+ // Rust crates: every Cargo.toml with a [package] section. The crate's in-code
422
+ // name maps "-" to "_" (cargo's identifier rule); deepest dir first so the
423
+ // crate enclosing an importing file is found by a simple scan.
424
+ const rustCrates: RustCrate[] = [];
425
+ for (const rel of fileSet) {
426
+ if (rel !== "Cargo.toml" && !rel.endsWith("/Cargo.toml")) continue;
427
+ const text = readText(join(scan.root, rel));
428
+ // [package] name = "x" — section-scoped so a [dependencies] entry named
429
+ // `name` can't masquerade as the crate name.
430
+ const m = /\[package\][^[]*?^\s*name\s*=\s*"([^"]+)"/ms.exec(text);
431
+ if (!m) continue; // a virtual workspace manifest — no crate of its own
432
+ const dir = rel.includes("/") ? posix.dirname(rel) : "";
433
+ const srcDir = norm(posix.join(dir, "src")).replace(/^\.$/, "");
434
+ const rootFile = firstThat(fileSet, [posix.join(srcDir, "lib.rs"), posix.join(srcDir, "main.rs")]);
435
+ rustCrates.push({ name: m[1]!.replace(/-/g, "_"), dir, srcDir, rootFile });
436
+ }
437
+ rustCrates.sort((a, b) => b.dir.length - a.dir.length || (a.dir < b.dir ? -1 : 1));
438
+
439
+ // Java source roots: a file at X/com/a/b/C.java declaring `package com.a.b`
440
+ // anchors X as a root — covers src/main/java, Maven/Gradle multi-module, any
441
+ // layout, with zero extra file reads (the package came along with the scan).
442
+ const javaRoots = new Set<string>();
443
+ for (const f of scan.files) {
444
+ if (f.ext !== ".java" || !f.pkg) continue;
445
+ const dir = f.rel.includes("/") ? posix.dirname(f.rel) : "";
446
+ const pkgPath = f.pkg.replace(/\./g, "/");
447
+ if (dir === pkgPath) javaRoots.add("");
448
+ else if (dir.endsWith("/" + pkgPath)) javaRoots.add(dir.slice(0, -pkgPath.length - 1));
449
+ }
450
+
451
+ // Python roots: dirs containing __init__.py / pyproject.toml / setup.py, plus root.
452
+ const pyRoots = new Set<string>([""]);
453
+ for (const rel of fileSet) {
454
+ const base = rel.split("/").pop()!;
455
+ if (base === "__init__.py" || base === "pyproject.toml" || base === "setup.py") {
456
+ pyRoots.add(rel.includes("/") ? posix.dirname(rel) : "");
457
+ }
458
+ }
459
+
460
+ // Workspace packages: map each in-repo package.json `name` to its directory so
461
+ // bare cross-package imports (`@scope/pkg`) resolve to in-repo source, not
462
+ // "external". Longest name first so `@scope/a-b` wins over `@scope/a`. Also
463
+ // keep its `exports` map and main-ish fields — modern monorepo packages route
464
+ // subpath imports (`@scope/pkg/utils`) through `exports`, and probing those
465
+ // declared entry points beats guessing `src/index`.
466
+ const workspacePackages: WorkspacePackage[] = [];
467
+ for (const rel of fileSet) {
468
+ if (rel !== "package.json" && !rel.endsWith("/package.json")) continue;
469
+ // Parse with the same JSONC-tolerant path as tsconfig (some package.json carry
470
+ // comments/trailing commas); a truly unparseable one is surfaced, not silently
471
+ // dropped — losing it erases every cross-package edge for that workspace.
472
+ const pkg = tolerantJsonParse(readText(join(scan.root, rel))) as
473
+ | { name?: string; exports?: unknown; source?: unknown; main?: unknown; module?: unknown; types?: unknown }
474
+ | undefined;
475
+ if (pkg === undefined) {
476
+ warnings.push(`unparseable ${rel} — skipped for workspace resolution`);
477
+ continue;
478
+ }
479
+ if (typeof pkg.name !== "string") continue;
480
+ const mainCandidates = [pkg.source, pkg.main, pkg.module, pkg.types].filter(
481
+ (v): v is string => typeof v === "string",
482
+ );
483
+ workspacePackages.push({
484
+ name: pkg.name,
485
+ dir: rel.includes("/") ? posix.dirname(rel) : "",
486
+ exportEntries: parseExportEntries(pkg.exports),
487
+ mainCandidates,
488
+ });
489
+ }
490
+ workspacePackages.sort((a, b) => b.name.length - a.name.length);
491
+
492
+ // C/C++ include roots: dirs literally named include/inc, plus the repo root, so
493
+ // `#include "a/b.h"` resolves whether written relative to the file or to a root.
494
+ const cIncludeRoots = new Set<string>([""]);
495
+ for (const d of dirSet) {
496
+ const base = d.slice(d.lastIndexOf("/") + 1);
497
+ if (base === "include" || base === "inc" || base === "src") cIncludeRoots.add(d);
498
+ }
499
+
500
+ // Ruby lib roots: dirs named lib, plus the repo root, for bare `require`.
501
+ const rubyLibRoots = new Set<string>([""]);
502
+ for (const d of dirSet) if (d.slice(d.lastIndexOf("/") + 1) === "lib") rubyLibRoots.add(d);
503
+
504
+ // PHP PSR-4: every composer.json's autoload(+autoload-dev).psr-4 maps a
505
+ // namespace prefix onto a directory (relative to the composer.json). Longest
506
+ // prefix first so a more specific mapping wins.
507
+ const phpPsr4: { prefix: string; dir: string }[] = [];
508
+ for (const rel of fileSet) {
509
+ if (rel !== "composer.json" && !rel.endsWith("/composer.json")) continue;
510
+ const composer = tolerantJsonParse(readText(join(scan.root, rel))) as
511
+ | { autoload?: { "psr-4"?: Record<string, string | string[]> }; "autoload-dev"?: { "psr-4"?: Record<string, string | string[]> } }
512
+ | undefined;
513
+ if (!composer) {
514
+ warnings.push(`unparseable ${rel} — skipped for PHP PSR-4 resolution`);
515
+ continue;
516
+ }
517
+ const baseDir = rel.includes("/") ? posix.dirname(rel) : "";
518
+ for (const block of [composer.autoload?.["psr-4"], composer["autoload-dev"]?.["psr-4"]]) {
519
+ if (!block) continue;
520
+ for (const [prefix, dirs] of Object.entries(block)) {
521
+ for (const d of Array.isArray(dirs) ? dirs : [dirs]) {
522
+ if (typeof d !== "string") continue;
523
+ phpPsr4.push({ prefix: prefix.replace(/\\+$/, ""), dir: norm(posix.join(baseDir, d)).replace(/^\.$/, "") });
524
+ }
525
+ }
526
+ }
527
+ }
528
+ phpPsr4.sort((a, b) => b.prefix.length - a.prefix.length);
529
+
530
+ // C# namespaces: file's `namespace X.Y` (captured as pkg) → the files declaring
531
+ // it, so `using X.Y;` resolves to in-repo source (Java's model, applied to C#).
532
+ const csharpNamespaces = new Map<string, string[]>();
533
+ for (const f of scan.files) {
534
+ if (f.ext !== ".cs" || !f.pkg) continue;
535
+ let arr = csharpNamespaces.get(f.pkg);
536
+ if (!arr) csharpNamespaces.set(f.pkg, (arr = []));
537
+ arr.push(f.rel);
538
+ }
539
+ for (const arr of csharpNamespaces.values()) arr.sort(byStr);
540
+
541
+ return {
542
+ fileSet,
543
+ dirSet,
544
+ filesByDir,
545
+ tsConfigs,
546
+ goModules,
547
+ rustCrates,
548
+ javaRoots: [...javaRoots].sort(byLen),
549
+ pyRoots: [...pyRoots],
550
+ workspacePackages,
551
+ cIncludeRoots: [...cIncludeRoots].sort(byLen),
552
+ rubyLibRoots: [...rubyLibRoots].sort(byLen),
553
+ phpPsr4,
554
+ csharpNamespaces,
555
+ warnings,
556
+ };
557
+ }
558
+
559
+ function firstExisting(ctx: ResolveContext, candidates: string[]): string | undefined {
560
+ for (const c of candidates) {
561
+ const n = norm(c);
562
+ if (n && !n.startsWith("..") && ctx.fileSet.has(n)) return n;
563
+ }
564
+ return undefined;
565
+ }
566
+
567
+ // Markdown relative link → a real file. Strips anchors/queries; probes .md/.mdx
568
+ // and directory README/index. External/anchor targets return `external`.
569
+ export function resolveDocLink(fromRel: string, spec: string, ctx: ResolveContext): Resolution {
570
+ let target = spec.split("#")[0]!.split("?")[0]!;
571
+ if (!target) return { kind: "external" }; // pure in-page anchor
572
+ if (target.startsWith("//") || /^[a-z][a-z0-9+.-]*:/i.test(target)) return { kind: "external" };
573
+ const base = fromRel.includes("/") ? posix.dirname(fromRel) : "";
574
+ const p = norm(posix.join(base, target));
575
+ if (p.startsWith("..")) return { kind: "dangling", reason: "escapes-repo-root" };
576
+ const hit = firstExisting(ctx, [
577
+ p, p + ".md", p + ".mdx",
578
+ posix.join(p, "README.md"), posix.join(p, "readme.md"),
579
+ posix.join(p, "index.md"), posix.join(p, "index.mdx"),
580
+ ]);
581
+ if (hit) return { kind: "resolved", target: hit };
582
+ // A link to a real directory (even one without a README/index) is valid — it's
583
+ // just not a file-node edge. Don't cry "broken link".
584
+ if (ctx.dirSet.has(p)) return { kind: "external" };
585
+ return { kind: "dangling", reason: "missing-target" };
586
+ }
587
+
588
+ function resolveJs(fromRel: string, spec: string, ctx: ResolveContext): Resolution {
589
+ const probe = (p: string): string | undefined =>
590
+ firstExisting(ctx, [...JS_EXT_PROBES.map((e) => p + e), ...JS_INDEX.map((i) => posix.join(p, i))]);
591
+ // TS/NodeNext style writes `import "./x.js"` for a source file `x.ts` — so if a
592
+ // direct probe misses, retry with the JS-ish extension stripped.
593
+ const tryResolve = (p: string): string | undefined => {
594
+ const hit = probe(p);
595
+ if (hit) return hit;
596
+ const noJs = p.replace(/\.(js|jsx|mjs|cjs)$/, "");
597
+ return noJs !== p ? probe(noJs) : undefined;
598
+ };
599
+
600
+ if (spec.startsWith(".")) {
601
+ const base = fromRel.includes("/") ? posix.dirname(fromRel) : "";
602
+ const p = norm(posix.join(base, spec));
603
+ if (p.startsWith("..")) return { kind: "dangling", reason: "escapes-repo-root" };
604
+ const hit = tryResolve(p);
605
+ return hit ? { kind: "resolved", target: hit } : { kind: "dangling", reason: "missing-module" };
606
+ }
607
+
608
+ // tsconfig path aliases (e.g. "@/x" -> "src/x"), nearest enclosing config first.
609
+ let aliasFallback: Resolution | undefined;
610
+ for (const cfg of ctx.tsConfigs) {
611
+ if (cfg.dir && fromRel !== cfg.dir && !fromRel.startsWith(cfg.dir + "/")) continue; // out of scope
612
+ let matched = false;
613
+ for (const tp of cfg.paths) {
614
+ if (!(tp.star ? spec.startsWith(tp.prefix) : spec === tp.prefix)) continue;
615
+ matched = true;
616
+ const suffix = tp.star ? spec.slice(tp.prefix.length) : "";
617
+ let targetTreeExists = false;
618
+ for (const t of tp.targets) {
619
+ const resolved = tp.star ? t.replace(/\*/, suffix) : t;
620
+ const p = norm(posix.join(cfg.baseUrl, resolved));
621
+ const hit = tryResolve(p);
622
+ if (hit) return { kind: "resolved", target: hit };
623
+ const tdir = p.includes("/") ? posix.dirname(p) : "";
624
+ if (ctx.dirSet.has(tdir) || ctx.fileSet.has(p)) targetTreeExists = true;
625
+ }
626
+ // Prefix matched but nothing resolved. Remember the verdict — a real broken
627
+ // import into an in-repo dir is dangling; an absent target tree (a generated
628
+ // `styled-system/` codegen dir, not committed) is external (no false
629
+ // dangling) — but DON'T return yet: a workspace package may still claim this
630
+ // specifier (an alias prefix like "@app/*" can overlap a workspace pkg name).
631
+ aliasFallback = targetTreeExists ? { kind: "dangling", reason: "alias-unresolved" } : { kind: "external" };
632
+ break;
633
+ }
634
+ if (matched) break; // the nearest matching config wins; stop scanning broader ones
635
+ }
636
+
637
+ // Monorepo workspace package: resolve `@scope/pkg`(`/subpath`) to in-repo source.
638
+ for (const pkg of ctx.workspacePackages) {
639
+ if (spec !== pkg.name && !spec.startsWith(pkg.name + "/")) continue;
640
+ const sub = spec.slice(pkg.name.length).replace(/^\//, "");
641
+ // Probe a pkg-relative entry-point path, then dist→src remaps of it: exports
642
+ // maps usually point at compiled output (`./dist/esm/index.js`) while only
643
+ // the source tree is committed — peel build dirs and retry under `src/`.
644
+ const probeEntry = (entry: string): string | undefined => {
645
+ for (const cand of [entry, ...distToSrcCandidates(entry)]) {
646
+ const hit = tryResolve(norm(posix.join(pkg.dir, cand)));
647
+ if (hit) return hit;
648
+ }
649
+ return undefined;
650
+ };
651
+ // 1) The declared `exports` map — first matching key wins (Node precedence:
652
+ // exact before wildcard, longest first — already sorted at parse time).
653
+ const subKey = sub ? "./" + sub : ".";
654
+ for (const entry of pkg.exportEntries) {
655
+ let fill: string | undefined;
656
+ if (entry.star) {
657
+ const starAt = entry.key.indexOf("*");
658
+ const pre = entry.key.slice(0, starAt);
659
+ const post = entry.key.slice(starAt + 1);
660
+ if (!subKey.startsWith(pre) || !subKey.endsWith(post) || subKey.length < pre.length + post.length) continue;
661
+ fill = subKey.slice(pre.length, subKey.length - post.length);
662
+ } else if (entry.key !== subKey) continue;
663
+ for (const t of entry.targets) {
664
+ const hit = probeEntry(fill === undefined ? t : t.replace(/\*/g, fill));
665
+ if (hit) return { kind: "resolved", target: hit };
666
+ }
667
+ break; // the matching key resolved nowhere — fall through to the heuristics
668
+ }
669
+ // 2) Declared main-ish fields for the bare specifier.
670
+ if (!sub) {
671
+ for (const m of pkg.mainCandidates) {
672
+ const hit = probeEntry(m);
673
+ if (hit) return { kind: "resolved", target: hit };
674
+ }
675
+ }
676
+ // 3) Naive convention probing — bundler/tsconfig setups legitimately bypass
677
+ // `exports`, so this stays as the final fallback (no false dangling).
678
+ const bases = sub
679
+ ? [posix.join(pkg.dir, "src", sub), posix.join(pkg.dir, sub)]
680
+ : [posix.join(pkg.dir, "src", "index"), posix.join(pkg.dir, "index"), posix.join(pkg.dir, "src")];
681
+ for (const b of bases) {
682
+ const hit = tryResolve(norm(b));
683
+ if (hit) return { kind: "resolved", target: hit };
684
+ }
685
+ return { kind: "external" }; // workspace pkg, but compiled-only/unmapped — no false dangling
686
+ }
687
+
688
+ // An alias prefix matched but neither it nor a workspace package resolved.
689
+ return aliasFallback ?? { kind: "external" }; // else a bare third-party / built-in
690
+ }
691
+
692
+ function resolvePython(fromRel: string, spec: string, ctx: ResolveContext): Resolution {
693
+ const probeModule = (dir: string, dotted: string): string | undefined => {
694
+ const sub = dotted ? dotted.replace(/\./g, "/") : "";
695
+ const base = norm(posix.join(dir, sub));
696
+ return firstExisting(ctx, [base + ".py", base + ".pyi", posix.join(base, "__init__.py")]);
697
+ };
698
+
699
+ if (spec.startsWith(".")) {
700
+ const dots = /^\.+/.exec(spec)![0].length;
701
+ const rest = spec.slice(dots);
702
+ const base = fromRel.includes("/") ? posix.dirname(fromRel) : "";
703
+ // 1 dot = current package (the file's dir); each extra dot goes up one.
704
+ let dir = base;
705
+ for (let i = 1; i < dots; i++) dir = dir.includes("/") ? posix.dirname(dir) : "";
706
+ const hit = rest
707
+ ? probeModule(dir, rest)
708
+ : firstExisting(ctx, [posix.join(norm(dir), "__init__.py")]);
709
+ return hit ? { kind: "resolved", target: hit } : { kind: "dangling", reason: "missing-module" };
710
+ }
711
+
712
+ // Absolute import: only an edge if it resolves inside the repo (same-package);
713
+ // otherwise it's a third-party/stdlib import — external, not dangling.
714
+ for (const root of ctx.pyRoots) {
715
+ const hit = probeModule(root, spec);
716
+ if (hit) return { kind: "resolved", target: hit };
717
+ }
718
+ return { kind: "external" };
719
+ }
720
+
721
+ function resolveGo(fromRel: string, spec: string, ctx: ResolveContext): Resolution {
722
+ if (!ctx.goModules.length) return { kind: "external" };
723
+ // Go imports a package (directory); resolve to the lexicographically-first
724
+ // .go file in that dir as the representative node.
725
+ const probePkg = (dir: string): Resolution => {
726
+ const d = norm(dir).replace(/^\.$/, "");
727
+ const inDir = (ctx.filesByDir.get(d) ?? []).filter((f) => f.endsWith(".go")).sort();
728
+ return inDir.length
729
+ ? { kind: "resolved", target: inDir[0]! }
730
+ : { kind: "dangling", reason: "missing-package" };
731
+ };
732
+ // The importing file's own module (nearest enclosing; goModules is deepest-first).
733
+ const home = ctx.goModules.find((g) => !g.dir || fromRel === g.dir || fromRel.startsWith(g.dir + "/"));
734
+ // (i) The home module's `replace` directives rewrite the import path before
735
+ // normal lookup — exactly the go toolchain's order.
736
+ if (home) {
737
+ for (const r of home.replaces) {
738
+ if (spec !== r.from && !spec.startsWith(r.from + "/")) continue;
739
+ const sub = spec.slice(r.from.length).replace(/^\//, "");
740
+ return probePkg(posix.join(r.toDir, sub));
741
+ }
742
+ }
743
+ // (ii) The home module's own path, then (iii) every other in-repo module —
744
+ // cross-module imports are the point of a multi-module repo.
745
+ const ordered = home ? [home, ...ctx.goModules.filter((g) => g !== home)] : ctx.goModules;
746
+ for (const g of ordered) {
747
+ if (spec !== g.module && !spec.startsWith(g.module + "/")) continue;
748
+ const sub = spec.slice(g.module.length).replace(/^\//, "");
749
+ return probePkg(posix.join(g.dir, sub));
750
+ }
751
+ return { kind: "external" };
752
+ }
753
+
754
+ function resolveRust(fromRel: string, spec: string, ctx: ResolveContext): Resolution {
755
+ if (!ctx.rustCrates.length) return { kind: "external" };
756
+ // Probe a module path: `dir/name.rs` (2018 layout) or `dir/name/mod.rs` (2015).
757
+ const probeMod = (dir: string, name: string): string | undefined =>
758
+ firstExisting(ctx, [posix.join(dir, name + ".rs"), posix.join(dir, name, "mod.rs")]);
759
+ // Walk a `::` path under a base dir, longest prefix first — trailing segments
760
+ // are items (fn/struct), not files, so peel until a module file matches.
761
+ const walkPath = (baseDir: string, segs: string[]): string | undefined => {
762
+ for (let n = segs.length; n >= 1; n--) {
763
+ const dir = norm(posix.join(baseDir, ...segs.slice(0, n - 1)));
764
+ const hit = probeMod(dir, segs[n - 1]!);
765
+ if (hit) return hit;
766
+ }
767
+ return undefined;
768
+ };
769
+
770
+ const fromDir = fromRel.includes("/") ? posix.dirname(fromRel) : "";
771
+ const stem = fromRel.slice(fromRel.lastIndexOf("/") + 1).replace(/\.rs$/, "");
772
+ const isRootish = stem === "mod" || stem === "lib" || stem === "main";
773
+ // The directory the importing file's CHILD modules live in.
774
+ const childDir = isRootish ? fromDir : posix.join(fromDir, stem);
775
+
776
+ if (spec.startsWith("mod ")) {
777
+ const name = spec.slice(4);
778
+ // A declared `mod` MUST exist as a file — safe to call dangling. Probe the
779
+ // edition-correct dir first, then the sibling dir (lenient on mixed layouts).
780
+ const hit = probeMod(childDir, name) ?? (isRootish ? undefined : probeMod(fromDir, name));
781
+ return hit ? { kind: "resolved", target: hit } : { kind: "dangling", reason: "missing-module" };
782
+ }
783
+
784
+ const segs = spec.split("::").map((s) => s.trim()).filter(Boolean);
785
+ if (!segs.length) return { kind: "external" };
786
+ const head = segs[0]!;
787
+
788
+ // The crate enclosing the importing file (deepest dir first).
789
+ const home = ctx.rustCrates.find((c) => !c.dir || fromRel === c.dir || fromRel.startsWith(c.dir + "/"));
790
+
791
+ let baseDir: string | undefined;
792
+ let rest: string[] = [];
793
+ if (head === "crate" && home) {
794
+ baseDir = home.srcDir;
795
+ rest = segs.slice(1);
796
+ } else if (head === "self") {
797
+ baseDir = childDir;
798
+ rest = segs.slice(1);
799
+ } else if (head === "super") {
800
+ // One `super` per leading segment; the importing file's own module dir is
801
+ // childDir, so the first super lands on fromDir (for rootish, its parent).
802
+ let dir = isRootish ? (fromDir.includes("/") ? posix.dirname(fromDir) : "") : fromDir;
803
+ let i = 1;
804
+ while (i < segs.length && segs[i] === "super") {
805
+ dir = dir.includes("/") ? posix.dirname(dir) : "";
806
+ i++;
807
+ }
808
+ baseDir = dir;
809
+ rest = segs.slice(i);
810
+ } else {
811
+ // A bare first segment may be a sibling in-repo crate (`other_crate::…`).
812
+ const target = ctx.rustCrates.find((c) => c.name === head);
813
+ if (target) {
814
+ const walked = walkPath(target.srcDir, segs.slice(1));
815
+ if (walked) return { kind: "resolved", target: walked };
816
+ if (target.rootFile) return { kind: "resolved", target: target.rootFile };
817
+ }
818
+ return { kind: "external" }; // std/serde/… or an unmapped re-export
819
+ }
820
+
821
+ if (!rest.length) return { kind: "external" }; // `use crate;` style — no edge
822
+ const hit = walkPath(baseDir, rest);
823
+ if (hit) return { kind: "resolved", target: hit };
824
+ // No module file matched — the leaf is usually an ITEM (fn/struct) defined in
825
+ // the module that owns baseDir; point the edge at that owning file. For the
826
+ // crate root that's lib.rs/main.rs; for a subdir, `<dir>.rs` or `<dir>/mod.rs`.
827
+ if (home && baseDir === home.srcDir && home.rootFile) return { kind: "resolved", target: home.rootFile };
828
+ const ownerDir = baseDir.includes("/") ? posix.dirname(baseDir) : "";
829
+ const ownerName = baseDir.slice(baseDir.lastIndexOf("/") + 1);
830
+ const owner = ownerName ? probeMod(ownerDir, ownerName) : undefined;
831
+ if (owner && owner !== fromRel) return { kind: "resolved", target: owner };
832
+ // Still nothing — the path may route through `pub use` re-exports or inline
833
+ // `mod {}` blocks we don't model. External, never false-dangling.
834
+ return { kind: "external" };
835
+ }
836
+
837
+ function resolveJava(spec: string, ctx: ResolveContext): Resolution {
838
+ if (!ctx.javaRoots.length) return { kind: "external" };
839
+ const probe = (pkgPath: string): string | undefined => {
840
+ for (const root of ctx.javaRoots) {
841
+ const p = norm(posix.join(root, pkgPath));
842
+ // Wildcard import: the package directory — resolve to its
843
+ // lexicographically-first .java file (the Go-package pattern).
844
+ if (p.endsWith("/*") || p === "*") {
845
+ const dir = p === "*" ? "" : p.slice(0, -2);
846
+ const inDir = (ctx.filesByDir.get(dir) ?? []).filter((f) => f.endsWith(".java")).sort();
847
+ if (inDir.length) return inDir[0];
848
+ continue;
849
+ }
850
+ if (ctx.fileSet.has(p + ".java")) return p + ".java";
851
+ }
852
+ return undefined;
853
+ };
854
+
855
+ const path = spec.replace(/\./g, "/");
856
+ let hit = probe(path);
857
+ if (!hit && !spec.endsWith(".*")) {
858
+ // `import com.a.Outer.Inner` (nested class) or `import static com.a.C.m` —
859
+ // peel trailing segments until a type file matches.
860
+ const segs = path.split("/");
861
+ for (let n = segs.length - 1; n >= 2 && !hit; n--) {
862
+ hit = probe(segs.slice(0, n).join("/"));
863
+ }
864
+ }
865
+ // stdlib/third-party (java.util.*, com.google.*) simply never match a root.
866
+ return hit ? { kind: "resolved", target: hit } : { kind: "external" };
867
+ }
868
+
869
+ // C/C++: local `#include "x"` relative to the including file, then each include
870
+ // root. Unresolved quoted includes are dangling (they read as an in-repo intent).
871
+ function resolveC(fromRel: string, spec: string, ctx: ResolveContext): Resolution {
872
+ const fromDir = fromRel.includes("/") ? posix.dirname(fromRel) : "";
873
+ const hit = firstExisting(ctx, [posix.join(fromDir, spec), ...ctx.cIncludeRoots.map((r) => posix.join(r, spec))]);
874
+ return hit ? { kind: "resolved", target: hit } : { kind: "dangling", reason: "missing-include" };
875
+ }
876
+
877
+ // Ruby: `require_relative` (we normalised to a leading "./") resolves against the
878
+ // file's dir; a bare `require` resolves against lib roots, else it is a gem/stdlib.
879
+ function resolveRuby(fromRel: string, spec: string, ctx: ResolveContext): Resolution {
880
+ if (spec.startsWith(".")) {
881
+ const fromDir = fromRel.includes("/") ? posix.dirname(fromRel) : "";
882
+ const base = norm(posix.join(fromDir, spec));
883
+ const hit = firstExisting(ctx, [base + ".rb", posix.join(base, "index.rb")]);
884
+ return hit ? { kind: "resolved", target: hit } : { kind: "dangling", reason: "missing-module" };
885
+ }
886
+ for (const root of ctx.rubyLibRoots) {
887
+ const hit = firstExisting(ctx, [posix.join(root, spec + ".rb")]);
888
+ if (hit) return { kind: "resolved", target: hit };
889
+ }
890
+ return { kind: "external" };
891
+ }
892
+
893
+ // PHP: relative `require/include` against the file's dir; `use Namespace\Class`
894
+ // against composer PSR-4 (longest prefix wins). Unmatched namespaces are external.
895
+ function resolvePhp(fromRel: string, spec: string, ctx: ResolveContext): Resolution {
896
+ if (spec.startsWith(".")) {
897
+ const fromDir = fromRel.includes("/") ? posix.dirname(fromRel) : "";
898
+ const base = norm(posix.join(fromDir, spec));
899
+ const hit = firstExisting(ctx, [base, base + ".php"]);
900
+ return hit ? { kind: "resolved", target: hit } : { kind: "dangling", reason: "missing-module" };
901
+ }
902
+ const ns = spec.replace(/^\\+/, "");
903
+ for (const { prefix, dir } of ctx.phpPsr4) {
904
+ if (prefix && ns !== prefix && !ns.startsWith(prefix + "\\")) continue;
905
+ const rest = prefix ? ns.slice(prefix.length).replace(/^\\+/, "") : ns;
906
+ const hit = firstExisting(ctx, [posix.join(dir, rest.replace(/\\/g, "/")) + ".php"]);
907
+ if (hit) return { kind: "resolved", target: hit };
908
+ }
909
+ return { kind: "external" };
910
+ }
911
+
912
+ // C#: `using X.Y` → files declaring `namespace X.Y` (exact), else the first file
913
+ // of any namespace nested under it. Unmatched namespaces are system/third-party.
914
+ function resolveCsharp(spec: string, ctx: ResolveContext): Resolution {
915
+ const exact = ctx.csharpNamespaces.get(spec);
916
+ if (exact?.length) return { kind: "resolved", target: exact[0]! };
917
+ let best: string | undefined;
918
+ for (const [ns, files] of ctx.csharpNamespaces) {
919
+ if (ns === spec || ns.startsWith(spec + ".")) {
920
+ const f = files[0]!;
921
+ if (best === undefined || byStr(f, best) < 0) best = f;
922
+ }
923
+ }
924
+ return best ? { kind: "resolved", target: best } : { kind: "external" };
925
+ }
926
+
927
+ // Resolve an import specifier for a file of the given extension.
928
+ export function resolveImport(
929
+ fromRel: string,
930
+ ext: string,
931
+ spec: string,
932
+ ctx: ResolveContext,
933
+ ): Resolution {
934
+ // Asset imports (`import logo from './x.svg'`) target files walk() skips on
935
+ // purpose — a bundler dependency, not a broken code edge.
936
+ const dot = spec.lastIndexOf(".");
937
+ if (dot !== -1 && ASSET_EXT.has(spec.slice(dot).toLowerCase().replace(/[?#].*$/, ""))) {
938
+ return { kind: "external" };
939
+ }
940
+ if (JS_TS.has(ext) || SFC_HTML.has(ext)) return resolveJs(fromRel, spec, ctx);
941
+ if (PY.has(ext)) return resolvePython(fromRel, spec, ctx);
942
+ if (ext === ".go") return resolveGo(fromRel, spec, ctx);
943
+ if (ext === ".rs") return resolveRust(fromRel, spec, ctx);
944
+ if (ext === ".java") return resolveJava(spec, ctx);
945
+ if (C_CPP.has(ext)) return resolveC(fromRel, spec, ctx);
946
+ if (ext === ".rb" || ext === ".rake") return resolveRuby(fromRel, spec, ctx);
947
+ if (ext === ".php") return resolvePhp(fromRel, spec, ctx);
948
+ if (ext === ".cs") return resolveCsharp(spec, ctx);
949
+ return { kind: "external" };
950
+ }