@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.
- package/README.md +115 -0
- package/docs/MIGRATION.md +100 -0
- package/package.json +89 -0
- package/scripts/cli.mjs +10 -0
- package/scripts/engine.d.mts +618 -0
- package/scripts/engine.mjs +10655 -0
- package/scripts/grammars/bash.wasm +0 -0
- package/scripts/grammars/c.wasm +0 -0
- package/scripts/grammars/c_sharp.wasm +0 -0
- package/scripts/grammars/cpp.wasm +0 -0
- package/scripts/grammars/go.wasm +0 -0
- package/scripts/grammars/java.wasm +0 -0
- package/scripts/grammars/javascript.wasm +0 -0
- package/scripts/grammars/lua.wasm +0 -0
- package/scripts/grammars/php.wasm +0 -0
- package/scripts/grammars/python.wasm +0 -0
- package/scripts/grammars/ruby.wasm +0 -0
- package/scripts/grammars/rust.wasm +0 -0
- package/scripts/grammars/scala.wasm +0 -0
- package/scripts/grammars/tsx.wasm +0 -0
- package/scripts/grammars/typescript.wasm +0 -0
- package/scripts/grammars/web-tree-sitter.wasm +0 -0
- package/src/ast/extract.ts +713 -0
- package/src/ast/loader.ts +104 -0
- package/src/bm25.ts +156 -0
- package/src/callers.ts +0 -0
- package/src/calls.ts +131 -0
- package/src/categorize.ts +80 -0
- package/src/centrality.ts +148 -0
- package/src/classify.ts +53 -0
- package/src/cli-entry.ts +16 -0
- package/src/community.ts +345 -0
- package/src/complexity.ts +69 -0
- package/src/coupling.ts +0 -0
- package/src/deadcode.ts +46 -0
- package/src/edit.ts +93 -0
- package/src/engine-cli.ts +278 -0
- package/src/engine.ts +148 -0
- package/src/extract/code.ts +376 -0
- package/src/extract/markdown.ts +135 -0
- package/src/git.ts +205 -0
- package/src/glob.ts +56 -0
- package/src/graph.ts +0 -0
- package/src/grep.ts +115 -0
- package/src/hash.ts +13 -0
- package/src/ignore.ts +134 -0
- package/src/lang/c.ts +26 -0
- package/src/lang/common.ts +68 -0
- package/src/lang/csharp.ts +22 -0
- package/src/lang/elixir.ts +18 -0
- package/src/lang/go.ts +22 -0
- package/src/lang/java.ts +19 -0
- package/src/lang/js-ts.ts +112 -0
- package/src/lang/kotlin.ts +20 -0
- package/src/lang/lua.ts +18 -0
- package/src/lang/php.ts +24 -0
- package/src/lang/python.ts +22 -0
- package/src/lang/registry.ts +54 -0
- package/src/lang/ruby.ts +17 -0
- package/src/lang/rust.ts +22 -0
- package/src/lang/scala.ts +19 -0
- package/src/lang/shell.ts +17 -0
- package/src/lang/swift.ts +23 -0
- package/src/mcp.ts +512 -0
- package/src/memory.ts +75 -0
- package/src/modules.ts +128 -0
- package/src/pipeline.ts +59 -0
- package/src/query.ts +118 -0
- package/src/render/graph-json.ts +16 -0
- package/src/render/symbols-json.ts +79 -0
- package/src/repomap.ts +52 -0
- package/src/resolve.ts +950 -0
- package/src/rules.ts +249 -0
- package/src/scan.ts +172 -0
- package/src/sort.ts +12 -0
- package/src/surprise.ts +58 -0
- package/src/tests-map.ts +105 -0
- package/src/types.ts +201 -0
- package/src/util.ts +169 -0
- package/src/viz.ts +44 -0
- package/src/walk.ts +216 -0
- 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
|
+
}
|