devlensio 1.0.4 → 1.1.1
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/dist/extractors/runner.js +1 -0
- package/dist/extractors/types.d.ts +2 -1
- package/dist/graph/edges/callEdges.js +97 -5
- package/dist/graph/edges/callEdges.test.js +154 -0
- package/dist/parser/exportsMap.d.ts +9 -0
- package/dist/parser/exportsMap.js +349 -0
- package/dist/parser/exportsMap.test.d.ts +1 -0
- package/dist/parser/exportsMap.test.js +324 -0
- package/dist/parser/extractors/classes.js +23 -4
- package/dist/parser/extractors/functions.d.ts +10 -3
- package/dist/parser/extractors/functions.js +135 -65
- package/dist/parser/extractors/hooks.js +5 -29
- package/dist/parser/extractors/objectMethods.js +3 -4
- package/dist/parser/index.d.ts +2 -1
- package/dist/parser/index.js +44 -6
- package/dist/parser/overloads.d.ts +2 -0
- package/dist/parser/overloads.js +96 -0
- package/dist/parser/overloads.test.d.ts +1 -0
- package/dist/parser/overloads.test.js +175 -0
- package/dist/parser/typeUtils.d.ts +2 -0
- package/dist/parser/typeUtils.js +2 -0
- package/dist/pipeline/index.d.ts +2 -1
- package/dist/pipeline/index.js +1 -0
- package/dist/storage/fileStorage.d.ts +2 -1
- package/dist/storage/fileStorage.js +5 -1
- package/dist/storage/fileStorage.test.d.ts +1 -0
- package/dist/storage/fileStorage.test.js +112 -0
- package/dist/types.d.ts +10 -0
- package/extractors/go/bin/darwin-amd64/devlens_go_extractor +0 -0
- package/extractors/go/bin/darwin-arm64/devlens_go_extractor +0 -0
- package/extractors/go/bin/linux-amd64/devlens_go_extractor +0 -0
- package/extractors/go/bin/linux-arm64/devlens_go_extractor +0 -0
- package/extractors/go/bin/windows-amd64/devlens_go_extractor.exe +0 -0
- package/extractors/go/calls.go +134 -2
- package/extractors/go/contract.go +1 -0
- package/extractors/go/exports.go +107 -0
- package/extractors/go/exports_test.go +64 -0
- package/extractors/go/extractor.go +1 -0
- package/extractors/go/lookup.go +63 -7
- package/extractors/go/nodes.go +28 -0
- package/extractors/go/parser.go +51 -8
- package/extractors/java/devlens_java_extractor.jar +0 -0
- package/extractors/java/src/devlens/extractor/ExportsMapBuilder.java +148 -0
- package/extractors/java/src/devlens/extractor/Extractor.java +34 -0
- package/extractors/java/src/devlens/extractor/LookupMaps.java +12 -0
- package/extractors/java/src/devlens/extractor/Overloads.java +290 -0
- package/extractors/java/src/devlens/extractor/Parser.java +39 -0
- package/extractors/java/src/devlens/extractor/TypeSolverFactory.java +9 -0
- package/extractors/java/src/devlens/extractor/edges/Calls.java +42 -36
- package/extractors/java/src/devlens/extractor/edges/Routes.java +10 -3
- package/extractors/python/devlens_extractors_python/edges/calls.py +127 -26
- package/extractors/python/devlens_extractors_python/exports_map.py +263 -0
- package/extractors/python/devlens_extractors_python/extractor.py +7 -1
- package/extractors/python/devlens_extractors_python/lookup.py +6 -0
- package/extractors/python/devlens_extractors_python/parser/__init__.py +6 -0
- package/extractors/python/devlens_extractors_python/parser/functions.py +103 -6
- package/extractors/python/devlens_extractors_python/parser/overloads.py +105 -0
- package/extractors/rust/bin/darwin-amd64/devlens_rust_extractor +0 -0
- package/extractors/rust/bin/darwin-arm64/devlens_rust_extractor +0 -0
- package/extractors/rust/bin/linux-amd64/devlens_rust_extractor +0 -0
- package/extractors/rust/bin/linux-arm64/devlens_rust_extractor +0 -0
- package/extractors/rust/bin/windows-amd64/devlens_rust_extractor.exe +0 -0
- package/extractors/rust/src/calls.rs +27 -16
- package/extractors/rust/src/contract.rs +2 -0
- package/extractors/rust/src/exports.rs +387 -0
- package/extractors/rust/src/exports_test.rs +105 -0
- package/extractors/rust/src/extractor.rs +2 -0
- package/extractors/rust/src/lookup.rs +60 -0
- package/extractors/rust/src/main.rs +4 -0
- package/extractors/rust/src/nodes.rs +19 -0
- package/extractors/rust/src/parser.rs +66 -1
- package/extractors/rust/src/routes.rs +2 -1
- package/package.json +1 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Language, BackendRouteNode, CodeEdge, CodeNode, ProjectFingerprint, RouteNode } from "../types.js";
|
|
1
|
+
import { Language, BackendRouteNode, CodeEdge, CodeNode, ProjectFingerprint, RouteNode, GraphExports } from "../types.js";
|
|
2
2
|
export interface ExtractorOptions {
|
|
3
3
|
includeThirdPartyLibs?: string[];
|
|
4
4
|
}
|
|
@@ -20,6 +20,7 @@ export interface ExtractorResult {
|
|
|
20
20
|
nodes: CodeNode[];
|
|
21
21
|
edges: CodeEdge[];
|
|
22
22
|
routes?: (RouteNode | BackendRouteNode)[];
|
|
23
|
+
exports?: GraphExports;
|
|
23
24
|
stats: ExtractorStats;
|
|
24
25
|
errors: ExtractorError[];
|
|
25
26
|
}
|
|
@@ -1,8 +1,89 @@
|
|
|
1
1
|
import { closestByPath } from "./utils.js";
|
|
2
|
+
// Arity compatibility: a candidate matches when the call provides at least all
|
|
3
|
+
// required (non-optional, non-rest) params, and does not exceed the declared
|
|
4
|
+
// count unless the candidate declares a rest parameter. Candidates without
|
|
5
|
+
// parameters metadata are permissive (unknown shape).
|
|
6
|
+
function arityMatches(candidate, argCount) {
|
|
7
|
+
const parameters = candidate.metadata?.parameters;
|
|
8
|
+
const params = candidate.metadata?.params;
|
|
9
|
+
if (!params && !parameters)
|
|
10
|
+
return true;
|
|
11
|
+
const total = parameters?.length ?? params?.length ?? 0;
|
|
12
|
+
const hasRest = parameters?.some((p) => p.isRest) ?? false;
|
|
13
|
+
const required = parameters
|
|
14
|
+
? parameters.filter((p) => !p.isOptional && !p.isRest).length
|
|
15
|
+
: total;
|
|
16
|
+
return argCount >= required && (hasRest || argCount <= total);
|
|
17
|
+
}
|
|
18
|
+
// Loose type agreement between a call-site arg type tag and a declared param
|
|
19
|
+
// type text. Both sides are cheap/heuristic (no type checker), so this scores
|
|
20
|
+
// rather than gates: unknown tags never match, text containment counts.
|
|
21
|
+
function typeAgrees(argType, paramType) {
|
|
22
|
+
if (!argType || argType === "unknown" || !paramType)
|
|
23
|
+
return false;
|
|
24
|
+
const p = paramType.trim();
|
|
25
|
+
return p === argType || p.includes(argType);
|
|
26
|
+
}
|
|
27
|
+
function typeScore(candidate, argTypes) {
|
|
28
|
+
const parameters = candidate.metadata?.parameters;
|
|
29
|
+
if (!parameters)
|
|
30
|
+
return 0;
|
|
31
|
+
let score = 0;
|
|
32
|
+
for (let i = 0; i < argTypes.length; i++) {
|
|
33
|
+
if (parameters[i] && typeAgrees(argTypes[i], parameters[i].type))
|
|
34
|
+
score++;
|
|
35
|
+
}
|
|
36
|
+
return score;
|
|
37
|
+
}
|
|
38
|
+
// Picks the right overload among same-name candidates using the call site's
|
|
39
|
+
// argument shape. Ladder:
|
|
40
|
+
// 1. no usable call shape (USES edges, spread calls, missing callSites)
|
|
41
|
+
// → legacy closestByPath behavior
|
|
42
|
+
// 2. arity filter — survivors whose declared params accept argCount
|
|
43
|
+
// 3. type tiebreak — among arity survivors, prefer the candidate with the
|
|
44
|
+
// highest arg-type agreement (handles same arity, different types)
|
|
45
|
+
// 4. still ambiguous → closestByPath among survivors
|
|
46
|
+
function resolveOverloadTarget(candidates, caller, callSite) {
|
|
47
|
+
if (candidates.length === 1)
|
|
48
|
+
return { target: candidates[0], matchedBy: "name" };
|
|
49
|
+
const usableShape = callSite
|
|
50
|
+
&& typeof callSite.argCount === "number"
|
|
51
|
+
&& !callSite.hasSpread;
|
|
52
|
+
if (!usableShape)
|
|
53
|
+
return { target: closestByPath(candidates, caller.filePath), matchedBy: "name" };
|
|
54
|
+
let pool = candidates.filter((c) => arityMatches(c, callSite.argCount));
|
|
55
|
+
if (pool.length === 0)
|
|
56
|
+
pool = candidates; // nothing accepts this arity — keep all
|
|
57
|
+
if (pool.length === 1)
|
|
58
|
+
return { target: pool[0], matchedBy: "arity" };
|
|
59
|
+
const argTypes = callSite.argTypes ?? [];
|
|
60
|
+
if (argTypes.some((t) => t && t !== "unknown")) {
|
|
61
|
+
let best = pool[0];
|
|
62
|
+
let bestScore = typeScore(pool[0], argTypes);
|
|
63
|
+
let tieAtBest = 1;
|
|
64
|
+
for (let i = 1; i < pool.length; i++) {
|
|
65
|
+
const s = typeScore(pool[i], argTypes);
|
|
66
|
+
if (s > bestScore) {
|
|
67
|
+
best = pool[i];
|
|
68
|
+
bestScore = s;
|
|
69
|
+
tieAtBest = 1;
|
|
70
|
+
}
|
|
71
|
+
else if (s === bestScore)
|
|
72
|
+
tieAtBest++;
|
|
73
|
+
}
|
|
74
|
+
if (bestScore > 0 && tieAtBest === 1) {
|
|
75
|
+
return { target: best, matchedBy: "signature" };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return { target: closestByPath(pool, caller.filePath), matchedBy: "name" };
|
|
79
|
+
}
|
|
2
80
|
export function detectCallEdges(nodes, lookupMp) {
|
|
3
81
|
const edges = [];
|
|
4
82
|
// Dedup for THIRD_PARTY CALLS edges — one per (caller, target) pair
|
|
5
83
|
const createdThirdPartyEdges = new Set();
|
|
84
|
+
// Dedup for local CALLS/USES edges — overload call sites can resolve to
|
|
85
|
+
// the same target through different (name, arity) pairs
|
|
86
|
+
const createdLocalEdges = new Set();
|
|
6
87
|
// Accumulate resolved calls per node — written back to metadata after edge loop
|
|
7
88
|
const resolvedCallsMap = new Map();
|
|
8
89
|
// Lazily-created method nodes for default/namespace import member-access calls
|
|
@@ -34,7 +115,16 @@ export function detectCallEdges(nodes, lookupMp) {
|
|
|
34
115
|
if (!hasPrimary && !hasHooks)
|
|
35
116
|
continue;
|
|
36
117
|
// ── Primary names: both third-party and local edges ──────────────
|
|
37
|
-
|
|
118
|
+
// Structured callSites carry the argument shape for overload-aware
|
|
119
|
+
// resolution; fall back to bare calls/uses strings when absent.
|
|
120
|
+
const callSites = node.metadata.callSites;
|
|
121
|
+
// Each (name, arity) call site iterates separately — a caller that
|
|
122
|
+
// invokes two overloads of one name gets two edges.
|
|
123
|
+
const primaryCallSites = (callSites && callSites.length > 0)
|
|
124
|
+
? callSites
|
|
125
|
+
: (primaryNames ?? []).map((name) => ({ name, argCount: -1 }));
|
|
126
|
+
for (const callSite of primaryCallSites) {
|
|
127
|
+
const calledName = callSite.name;
|
|
38
128
|
// ── Third-party guard ─────────────────────────────────────────
|
|
39
129
|
// The alias map is keyed by node.filePath (relative) and populated
|
|
40
130
|
// by importEdges.ts (which runs first).
|
|
@@ -120,16 +210,18 @@ export function detectCallEdges(nodes, lookupMp) {
|
|
|
120
210
|
}
|
|
121
211
|
if (!targets || targets.length === 0)
|
|
122
212
|
continue;
|
|
123
|
-
const target = targets.
|
|
124
|
-
? targets[0]
|
|
125
|
-
: closestByPath(targets, node.filePath);
|
|
213
|
+
const { target, matchedBy } = resolveOverloadTarget(targets, node, callSite.argCount >= 0 ? callSite : undefined);
|
|
126
214
|
if (target.id === node.id)
|
|
127
215
|
continue; // skip self-reference
|
|
216
|
+
const localEdgeKey = `${node.id}→${target.id}:${edgeType}`;
|
|
217
|
+
if (createdLocalEdges.has(localEdgeKey))
|
|
218
|
+
continue;
|
|
219
|
+
createdLocalEdges.add(localEdgeKey);
|
|
128
220
|
edges.push({
|
|
129
221
|
from: node.id,
|
|
130
222
|
to: target.id,
|
|
131
223
|
type: edgeType,
|
|
132
|
-
metadata: { calledName: resolvedName },
|
|
224
|
+
metadata: { calledName: resolvedName, matchedBy },
|
|
133
225
|
});
|
|
134
226
|
if (!resolvedCallsMap.has(node.id))
|
|
135
227
|
resolvedCallsMap.set(node.id, []);
|
|
@@ -198,3 +198,157 @@ describe("detectCallEdges with class methods", () => {
|
|
|
198
198
|
deleteFakeRepo(repo);
|
|
199
199
|
});
|
|
200
200
|
});
|
|
201
|
+
// ─── Overload-aware resolution (same-name candidates in one file) ─────────────
|
|
202
|
+
describe("detectCallEdges with overloads", () => {
|
|
203
|
+
// Arity disambiguates same-name functions in one file
|
|
204
|
+
it("routes each call to the overload matching its argument count", () => {
|
|
205
|
+
const repo = createFakeRepo({
|
|
206
|
+
"src/fmt.ts": `
|
|
207
|
+
export function formatValue(v: string) { return v.trim(); }
|
|
208
|
+
export function formatValue(v: string, n: number) { return v.repeat(n); }
|
|
209
|
+
export function caller() {
|
|
210
|
+
formatValue("a");
|
|
211
|
+
formatValue("a", 3);
|
|
212
|
+
}
|
|
213
|
+
`,
|
|
214
|
+
});
|
|
215
|
+
const { edges } = detectCalls(repo);
|
|
216
|
+
const callEdges = edges.filter((e) => e.type === "CALLS" && e.metadata?.calledName === "formatValue");
|
|
217
|
+
const targets = callEdges.map((e) => e.to).sort();
|
|
218
|
+
expect(targets).toEqual([
|
|
219
|
+
"src/fmt.ts::formatValue#1",
|
|
220
|
+
"src/fmt.ts::formatValue#2",
|
|
221
|
+
]);
|
|
222
|
+
// resolution confidence recorded on the edge
|
|
223
|
+
expect(callEdges.every((e) => e.metadata?.matchedBy === "arity")).toBe(true);
|
|
224
|
+
deleteFakeRepo(repo);
|
|
225
|
+
});
|
|
226
|
+
// Same arity, different param types — literal arg types break the tie
|
|
227
|
+
it("resolves same-arity overloads via argument type tags", () => {
|
|
228
|
+
const repo = createFakeRepo({
|
|
229
|
+
"src/dec.ts": `
|
|
230
|
+
export function decode(input: string, mode: string) { return input; }
|
|
231
|
+
export function decode(input: number, mode: string) { return String(input); }
|
|
232
|
+
export function caller() {
|
|
233
|
+
decode("abc", "utf8");
|
|
234
|
+
decode(42, "utf8");
|
|
235
|
+
}
|
|
236
|
+
`,
|
|
237
|
+
});
|
|
238
|
+
const { edges } = detectCalls(repo);
|
|
239
|
+
const callEdges = edges.filter((e) => e.type === "CALLS" && e.metadata?.calledName === "decode");
|
|
240
|
+
// first call (string literal) → string overload; second (number literal) → number overload
|
|
241
|
+
const stringTargets = callEdges.filter((e) => e.metadata?.matchedBy === "signature").map((e) => e.to).sort();
|
|
242
|
+
expect(stringTargets.length).toBe(2);
|
|
243
|
+
expect(stringTargets[0]).not.toBe(stringTargets[1]);
|
|
244
|
+
deleteFakeRepo(repo);
|
|
245
|
+
});
|
|
246
|
+
// A caller can invoke two overloads of one name — two distinct edges
|
|
247
|
+
it("emits one edge per overload call site, not one per name", () => {
|
|
248
|
+
const repo = createFakeRepo({
|
|
249
|
+
"src/multi.ts": `
|
|
250
|
+
export function send(x: string) { return x; }
|
|
251
|
+
export function send(x: string, y: string) { return x + y; }
|
|
252
|
+
export function client() {
|
|
253
|
+
send("a");
|
|
254
|
+
send("a", "b");
|
|
255
|
+
}
|
|
256
|
+
`,
|
|
257
|
+
});
|
|
258
|
+
const { edges } = detectCalls(repo);
|
|
259
|
+
const callEdges = edges.filter((e) => e.type === "CALLS" && e.metadata?.calledName === "send");
|
|
260
|
+
expect(callEdges.length).toBe(2);
|
|
261
|
+
expect(new Set(callEdges.map((e) => e.to)).size).toBe(2);
|
|
262
|
+
deleteFakeRepo(repo);
|
|
263
|
+
});
|
|
264
|
+
// Spread calls have unknowable arity — must NOT confidently pick an overload
|
|
265
|
+
it("falls back to path proximity for spread calls", () => {
|
|
266
|
+
const repo = createFakeRepo({
|
|
267
|
+
"src/spread.ts": `
|
|
268
|
+
export function combine(a: string) { return a; }
|
|
269
|
+
export function combine(a: string, b: string) { return a + b; }
|
|
270
|
+
export function caller(...args: string[]) {
|
|
271
|
+
return combine(...args);
|
|
272
|
+
}
|
|
273
|
+
`,
|
|
274
|
+
});
|
|
275
|
+
const { edges } = detectCalls(repo);
|
|
276
|
+
const callEdges = edges.filter((e) => e.type === "CALLS" && e.metadata?.calledName === "combine");
|
|
277
|
+
expect(callEdges.length).toBe(1);
|
|
278
|
+
expect(callEdges[0].metadata?.matchedBy).toBe("name");
|
|
279
|
+
deleteFakeRepo(repo);
|
|
280
|
+
});
|
|
281
|
+
// Optional params: a 1-arg call may hit a 2-param overload with optional 2nd
|
|
282
|
+
it("lets optional parameters accept shorter calls via arity fallback", () => {
|
|
283
|
+
const repo = createFakeRepo({
|
|
284
|
+
"src/opt.ts": `
|
|
285
|
+
export function render(template: string, opts?: object) { return template; }
|
|
286
|
+
export function render(template: string, opts: object, mode: string) { return template; }
|
|
287
|
+
export function caller() {
|
|
288
|
+
render("t");
|
|
289
|
+
}
|
|
290
|
+
`,
|
|
291
|
+
});
|
|
292
|
+
const { edges } = detectCalls(repo);
|
|
293
|
+
const callEdges = edges.filter((e) => e.type === "CALLS" && e.metadata?.calledName === "render");
|
|
294
|
+
// 1-arg call: renderStrict requires 3 → excluded; render (optional) → match
|
|
295
|
+
expect(callEdges.length).toBe(1);
|
|
296
|
+
expect(callEdges[0].to).toBe("src/opt.ts::render#2");
|
|
297
|
+
deleteFakeRepo(repo);
|
|
298
|
+
});
|
|
299
|
+
// Rest params accept any extra arguments
|
|
300
|
+
it("lets rest parameters accept longer calls", () => {
|
|
301
|
+
const repo = createFakeRepo({
|
|
302
|
+
"src/rest.ts": `
|
|
303
|
+
export function sum(first: number, ...rest: number[]) { return first; }
|
|
304
|
+
export function pair(a: number, b: number) { return a; }
|
|
305
|
+
export function caller() {
|
|
306
|
+
sum(1, 2, 3, 4);
|
|
307
|
+
}
|
|
308
|
+
`,
|
|
309
|
+
});
|
|
310
|
+
const { edges } = detectCalls(repo);
|
|
311
|
+
const callEdges = edges.filter((e) => e.type === "CALLS" && e.metadata?.calledName === "sum");
|
|
312
|
+
expect(callEdges.length).toBe(1);
|
|
313
|
+
expect(callEdges[0].to).toContain("sum");
|
|
314
|
+
deleteFakeRepo(repo);
|
|
315
|
+
});
|
|
316
|
+
// Legacy behavior preserved when no overload shape exists
|
|
317
|
+
it("still resolves plain single-candidate calls without callSites metadata", () => {
|
|
318
|
+
const repo = createFakeRepo({
|
|
319
|
+
"src/plain.ts": `
|
|
320
|
+
export function helper(x: number) { return x; }
|
|
321
|
+
export function caller() { return helper(1); }
|
|
322
|
+
`,
|
|
323
|
+
});
|
|
324
|
+
const { edges } = detectCalls(repo);
|
|
325
|
+
expect(edges).toContainEqual(expect.objectContaining({
|
|
326
|
+
from: "src/plain.ts::caller",
|
|
327
|
+
to: "src/plain.ts::helper",
|
|
328
|
+
type: "CALLS",
|
|
329
|
+
}));
|
|
330
|
+
deleteFakeRepo(repo);
|
|
331
|
+
});
|
|
332
|
+
// Class method overloads resolve through dotted names
|
|
333
|
+
it("resolves class method overloads by arity through dotted names", () => {
|
|
334
|
+
const repo = createFakeRepo({
|
|
335
|
+
"src/svc.ts": `
|
|
336
|
+
export class Repo {
|
|
337
|
+
find(id: string) { return id; }
|
|
338
|
+
find(id: string, depth: number) { return id; }
|
|
339
|
+
}
|
|
340
|
+
export function caller() {
|
|
341
|
+
Repo.find("a");
|
|
342
|
+
Repo.find("a", 2);
|
|
343
|
+
}
|
|
344
|
+
`,
|
|
345
|
+
});
|
|
346
|
+
const { edges } = detectCalls(repo);
|
|
347
|
+
const callEdges = edges.filter((e) => e.type === "CALLS" && e.metadata?.calledName === "Repo.find");
|
|
348
|
+
expect(callEdges.map((e) => e.to).sort()).toEqual([
|
|
349
|
+
"src/svc.ts::Repo.find#1",
|
|
350
|
+
"src/svc.ts::Repo.find#2",
|
|
351
|
+
]);
|
|
352
|
+
deleteFakeRepo(repo);
|
|
353
|
+
});
|
|
354
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Project } from "ts-morph";
|
|
2
|
+
import type { CodeNode, GraphExports } from "../types.js";
|
|
3
|
+
export declare function buildExportsMap(project: Project, repoPath: string, nodes: CodeNode[]): GraphExports | undefined;
|
|
4
|
+
export declare function validateExportsMap(map: GraphExports, nodeIds: Set<string>): string[];
|
|
5
|
+
export declare function exportsMapStats(map: GraphExports): {
|
|
6
|
+
subpaths: number;
|
|
7
|
+
names: number;
|
|
8
|
+
ambiguous: number;
|
|
9
|
+
};
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
// Exports Map builder — the flattened export surface of the repo, shipped in
|
|
2
|
+
// the graph artifact for future cross-repo linking (see docs/EXPORTS_MAP.md).
|
|
3
|
+
//
|
|
4
|
+
// Architecture notes:
|
|
5
|
+
// - Must live in the parser because binding resolution needs the ts-morph
|
|
6
|
+
// Project created inside parseRepo(); a second Project would double parse
|
|
7
|
+
// cost. parseRepo() calls buildExportsMap() after the node loop, so the
|
|
8
|
+
// map is validated against the FINAL node list (only surviving, deduped,
|
|
9
|
+
// overload-suffixed ids can appear as values).
|
|
10
|
+
// - Values are ALWAYS arrays of proper nodeIds — never bare names. The
|
|
11
|
+
// cloud joins against them in one hash lookup; no second resolution step.
|
|
12
|
+
// - Overloads: multiple nodes sharing one base id (path::name) are an
|
|
13
|
+
// overload group — emitted as a plain array. Distinct base ids under one
|
|
14
|
+
// exported name are a TRUE ambiguity — also flagged in ambiguousNames.
|
|
15
|
+
// - TS resolves `export *` conflicts by EXCLUDING the name from the module's
|
|
16
|
+
// exports (same as the JS spec), so star re-exports need no special code.
|
|
17
|
+
// - Anything without a node representation (plain consts, interfaces,
|
|
18
|
+
// type aliases, anonymous defaults, namespace re-exports) is OMITTED —
|
|
19
|
+
// never fabricate an id that won't exist in the nodes array.
|
|
20
|
+
// - package.json "exports" conditions: v1 resolves one condition per subpath
|
|
21
|
+
// (import → default → require); "types"-only subpaths are skipped; wildcard
|
|
22
|
+
// patterns are skipped. Entry targets pointing into dist/build are mapped
|
|
23
|
+
// back to src/ so ids stay in the same coordinate system as the nodes.
|
|
24
|
+
// - Failures never break analysis: any error yields an absent map.
|
|
25
|
+
import path from "path";
|
|
26
|
+
import fs from "fs";
|
|
27
|
+
const ENTRY_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx"];
|
|
28
|
+
const INDEX_NAMES = ["index.ts", "index.tsx", "index.js", "index.jsx"];
|
|
29
|
+
export function buildExportsMap(project, repoPath, nodes) {
|
|
30
|
+
try {
|
|
31
|
+
const pkgPath = path.join(repoPath, "package.json");
|
|
32
|
+
if (!fs.existsSync(pkgPath))
|
|
33
|
+
return undefined;
|
|
34
|
+
let pkg;
|
|
35
|
+
try {
|
|
36
|
+
pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
const entries = resolveEntryPoints(pkg, repoPath, project);
|
|
42
|
+
if (entries.length === 0)
|
|
43
|
+
return undefined;
|
|
44
|
+
const nodeIndex = buildNodeIndex(nodes);
|
|
45
|
+
const starCache = new Map();
|
|
46
|
+
const exports = {};
|
|
47
|
+
const ambiguousNames = {};
|
|
48
|
+
for (const entry of entries) {
|
|
49
|
+
let decls;
|
|
50
|
+
try {
|
|
51
|
+
decls = entry.file.getExportedDeclarations();
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
const explicitNames = explicitExportNames(entry.file);
|
|
57
|
+
const entryPath = entry.file.getFilePath();
|
|
58
|
+
const stars = starContributors(entry.file, starCache);
|
|
59
|
+
const perPath = {};
|
|
60
|
+
const ambiguous = [];
|
|
61
|
+
for (const [name, declList] of decls) {
|
|
62
|
+
const isExplicit = explicitNames.has(name) || declList.some((d) => d.getSourceFile().getFilePath() === entryPath);
|
|
63
|
+
if (!isExplicit) {
|
|
64
|
+
const contributors = stars.get(name);
|
|
65
|
+
if (contributors && contributors.size > 1) {
|
|
66
|
+
ambiguous.push(name);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const resolved = [];
|
|
71
|
+
for (const decl of declList) {
|
|
72
|
+
const hit = resolveDeclarationToNode(decl, repoPath, nodeIndex);
|
|
73
|
+
if (hit)
|
|
74
|
+
resolved.push(hit);
|
|
75
|
+
}
|
|
76
|
+
const ids = [...new Set(resolved.map((r) => r.id))].sort();
|
|
77
|
+
if (ids.length === 0)
|
|
78
|
+
continue;
|
|
79
|
+
const bases = [...new Set(resolved.map((r) => r.base))];
|
|
80
|
+
if (bases.length > 1)
|
|
81
|
+
ambiguous.push(name);
|
|
82
|
+
perPath[name] = ids;
|
|
83
|
+
}
|
|
84
|
+
if (Object.keys(perPath).length > 0)
|
|
85
|
+
exports[entry.subpath] = perPath;
|
|
86
|
+
if (ambiguous.length > 0)
|
|
87
|
+
ambiguousNames[entry.subpath] = ambiguous;
|
|
88
|
+
}
|
|
89
|
+
if (Object.keys(exports).length === 0)
|
|
90
|
+
return undefined;
|
|
91
|
+
return { exports, ambiguousNames };
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function explicitExportNames(sf) {
|
|
98
|
+
const names = new Set();
|
|
99
|
+
for (const ed of sf.getExportDeclarations()) {
|
|
100
|
+
for (const spec of ed.getNamedExports())
|
|
101
|
+
names.add(spec.getName());
|
|
102
|
+
}
|
|
103
|
+
return names;
|
|
104
|
+
}
|
|
105
|
+
function starContributors(sf, cache) {
|
|
106
|
+
const key = sf.getFilePath();
|
|
107
|
+
const cached = cache.get(key);
|
|
108
|
+
if (cached)
|
|
109
|
+
return cached;
|
|
110
|
+
const result = new Map();
|
|
111
|
+
const seen = new Set([key]);
|
|
112
|
+
const stack = [sf];
|
|
113
|
+
while (stack.length > 0) {
|
|
114
|
+
const current = stack.pop();
|
|
115
|
+
for (const ed of current.getExportDeclarations()) {
|
|
116
|
+
if (ed.getNamedExports().length > 0)
|
|
117
|
+
continue;
|
|
118
|
+
const target = ed.getModuleSpecifierSourceFile();
|
|
119
|
+
if (!target || seen.has(target.getFilePath()))
|
|
120
|
+
continue;
|
|
121
|
+
seen.add(target.getFilePath());
|
|
122
|
+
const targetPath = target.getFilePath();
|
|
123
|
+
let targetDecls;
|
|
124
|
+
try {
|
|
125
|
+
targetDecls = target.getExportedDeclarations();
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
for (const [name, declList] of targetDecls) {
|
|
131
|
+
const isLocal = declList.some((d) => d.getSourceFile().getFilePath() === targetPath);
|
|
132
|
+
if (isLocal) {
|
|
133
|
+
if (!result.has(name))
|
|
134
|
+
result.set(name, new Set());
|
|
135
|
+
result.get(name).add(targetPath);
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
const deeper = starContributors(target, cache);
|
|
139
|
+
const contributors = deeper.get(name);
|
|
140
|
+
if (contributors) {
|
|
141
|
+
if (!result.has(name))
|
|
142
|
+
result.set(name, new Set());
|
|
143
|
+
for (const f of contributors)
|
|
144
|
+
result.get(name).add(f);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
stack.push(target);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
cache.set(key, result);
|
|
152
|
+
return result;
|
|
153
|
+
}
|
|
154
|
+
function buildNodeIndex(nodes) {
|
|
155
|
+
const index = new Map();
|
|
156
|
+
for (const node of nodes) {
|
|
157
|
+
const base = `${node.filePath}::${node.name}`;
|
|
158
|
+
if (!index.has(base))
|
|
159
|
+
index.set(base, []);
|
|
160
|
+
index.get(base).push(node);
|
|
161
|
+
}
|
|
162
|
+
return index;
|
|
163
|
+
}
|
|
164
|
+
function resolveEntryPoints(pkg, repoPath, project) {
|
|
165
|
+
const entries = [];
|
|
166
|
+
if (pkg.exports !== undefined) {
|
|
167
|
+
const normalized = normalizeExportsField(pkg.exports);
|
|
168
|
+
for (const [subpath, target] of normalized) {
|
|
169
|
+
const file = resolveTargetFile(repoPath, target, project);
|
|
170
|
+
if (file)
|
|
171
|
+
entries.push({ subpath, file });
|
|
172
|
+
}
|
|
173
|
+
return entries;
|
|
174
|
+
}
|
|
175
|
+
const fallback = pkg.main ?? pkg.module;
|
|
176
|
+
if (fallback) {
|
|
177
|
+
const file = resolveTargetFile(repoPath, fallback, project);
|
|
178
|
+
if (file)
|
|
179
|
+
entries.push({ subpath: ".", file });
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
for (const candidate of INDEX_NAMES.map((n) => path.join("src", n)).concat(INDEX_NAMES)) {
|
|
183
|
+
const file = resolveTargetFile(repoPath, candidate, project);
|
|
184
|
+
if (file) {
|
|
185
|
+
entries.push({ subpath: ".", file });
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return entries;
|
|
191
|
+
}
|
|
192
|
+
function normalizeExportsField(field) {
|
|
193
|
+
const out = [];
|
|
194
|
+
if (typeof field === "string") {
|
|
195
|
+
out.push([".", field]);
|
|
196
|
+
return out;
|
|
197
|
+
}
|
|
198
|
+
if (typeof field !== "object" || field === null)
|
|
199
|
+
return out;
|
|
200
|
+
for (const [subpath, value] of Object.entries(field)) {
|
|
201
|
+
if (subpath.includes("*"))
|
|
202
|
+
continue;
|
|
203
|
+
if (typeof value === "string") {
|
|
204
|
+
out.push([subpath, value]);
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
if (typeof value !== "object" || value === null)
|
|
208
|
+
continue;
|
|
209
|
+
const conditions = value;
|
|
210
|
+
const chosen = (typeof conditions.import === "string" && conditions.import) ||
|
|
211
|
+
(typeof conditions.default === "string" && conditions.default) ||
|
|
212
|
+
(typeof conditions.require === "string" && conditions.require);
|
|
213
|
+
if (typeof chosen === "string" && chosen)
|
|
214
|
+
out.push([subpath, chosen]);
|
|
215
|
+
}
|
|
216
|
+
return out;
|
|
217
|
+
}
|
|
218
|
+
function resolveTargetFile(repoPath, target, project) {
|
|
219
|
+
if (typeof target !== "string" || !target)
|
|
220
|
+
return undefined;
|
|
221
|
+
let rel = target.replace(/^\.\//, "").split(/[?#]/)[0];
|
|
222
|
+
if (rel.endsWith(".d.ts"))
|
|
223
|
+
return undefined;
|
|
224
|
+
const candidates = [rel];
|
|
225
|
+
const distMatch = rel.match(/^(dist|build)(\/|$)/);
|
|
226
|
+
if (distMatch) {
|
|
227
|
+
candidates.push("src" + rel.slice(distMatch[1].length));
|
|
228
|
+
}
|
|
229
|
+
for (const candidate of candidates) {
|
|
230
|
+
const stem = candidate.replace(/\.[^.]+$/, "");
|
|
231
|
+
const tries = new Set([candidate]);
|
|
232
|
+
for (const ext of ENTRY_EXTENSIONS)
|
|
233
|
+
tries.add(stem + ext);
|
|
234
|
+
for (const idx of INDEX_NAMES)
|
|
235
|
+
tries.add(path.posix.join(candidate, idx));
|
|
236
|
+
for (const tryPath of tries) {
|
|
237
|
+
if (tryPath.endsWith(".d.ts"))
|
|
238
|
+
continue;
|
|
239
|
+
const abs = path.resolve(repoPath, tryPath);
|
|
240
|
+
if (!fs.existsSync(abs) || fs.statSync(abs).isDirectory())
|
|
241
|
+
continue;
|
|
242
|
+
const sf = project.getSourceFile(abs);
|
|
243
|
+
if (sf)
|
|
244
|
+
return sf;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return undefined;
|
|
248
|
+
}
|
|
249
|
+
function resolveDeclarationToNode(decl, repoPath, nodeIndex) {
|
|
250
|
+
const name = declarationName(decl);
|
|
251
|
+
if (!name)
|
|
252
|
+
return undefined;
|
|
253
|
+
const relPath = path.relative(repoPath, decl.getSourceFile().getFilePath()).replace(/\\/g, "/");
|
|
254
|
+
const base = `${relPath}::${name}`;
|
|
255
|
+
const candidates = nodeIndex.get(base);
|
|
256
|
+
if (!candidates || candidates.length === 0)
|
|
257
|
+
return undefined;
|
|
258
|
+
if (candidates.length === 1)
|
|
259
|
+
return { id: candidates[0].id, base };
|
|
260
|
+
const match = matchOverloadedNode(decl, candidates);
|
|
261
|
+
return match ? { id: match.id, base } : undefined;
|
|
262
|
+
}
|
|
263
|
+
function declarationName(decl) {
|
|
264
|
+
const getName = decl.getName;
|
|
265
|
+
if (typeof getName === "function") {
|
|
266
|
+
try {
|
|
267
|
+
const own = getName.call(decl);
|
|
268
|
+
if (typeof own === "string" && own.length > 0)
|
|
269
|
+
return own;
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
// fall through to symbol
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
const sym = decl.getSymbol()?.getName();
|
|
276
|
+
if (sym && sym !== "default" && !sym.startsWith("__"))
|
|
277
|
+
return sym;
|
|
278
|
+
return undefined;
|
|
279
|
+
}
|
|
280
|
+
function matchOverloadedNode(decl, candidates) {
|
|
281
|
+
const paramTypes = declarationParamTypes(decl);
|
|
282
|
+
if (paramTypes === undefined)
|
|
283
|
+
return undefined;
|
|
284
|
+
const declKey = signatureKeyFromTypes(paramTypes);
|
|
285
|
+
const matches = candidates.filter((node) => {
|
|
286
|
+
const params = node.metadata?.parameters;
|
|
287
|
+
if (!Array.isArray(params))
|
|
288
|
+
return false;
|
|
289
|
+
return signatureKeyFromNodeParams(params) === declKey;
|
|
290
|
+
});
|
|
291
|
+
return matches.length >= 1 ? matches[0] : undefined;
|
|
292
|
+
}
|
|
293
|
+
function declarationParamTypes(decl) {
|
|
294
|
+
const anyDecl = decl;
|
|
295
|
+
if (typeof anyDecl.getParameters !== "function")
|
|
296
|
+
return undefined;
|
|
297
|
+
try {
|
|
298
|
+
const params = anyDecl.getParameters();
|
|
299
|
+
if (!Array.isArray(params))
|
|
300
|
+
return undefined;
|
|
301
|
+
return params.map((p) => {
|
|
302
|
+
const t = p.getTypeNode?.()?.getText() ?? "unknown";
|
|
303
|
+
if (p.isRest?.())
|
|
304
|
+
return `...${t}`;
|
|
305
|
+
if (p.isOptional?.())
|
|
306
|
+
return `${t}?`;
|
|
307
|
+
return t;
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
catch {
|
|
311
|
+
return undefined;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
function signatureKeyFromTypes(types) {
|
|
315
|
+
return `(${types.join(",")})`;
|
|
316
|
+
}
|
|
317
|
+
function signatureKeyFromNodeParams(params) {
|
|
318
|
+
return `(${params
|
|
319
|
+
.map((p) => {
|
|
320
|
+
const t = typeof p?.type === "string" && p.type.length > 0 ? p.type : "unknown";
|
|
321
|
+
if (p?.isRest)
|
|
322
|
+
return `...${t}`;
|
|
323
|
+
if (p?.isOptional)
|
|
324
|
+
return `${t}?`;
|
|
325
|
+
return t;
|
|
326
|
+
})
|
|
327
|
+
.join(",")})`;
|
|
328
|
+
}
|
|
329
|
+
export function validateExportsMap(map, nodeIds) {
|
|
330
|
+
const dangling = [];
|
|
331
|
+
for (const perPath of Object.values(map.exports)) {
|
|
332
|
+
for (const ids of Object.values(perPath)) {
|
|
333
|
+
for (const id of ids) {
|
|
334
|
+
if (!nodeIds.has(id))
|
|
335
|
+
dangling.push(id);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return [...new Set(dangling)];
|
|
340
|
+
}
|
|
341
|
+
export function exportsMapStats(map) {
|
|
342
|
+
let names = 0;
|
|
343
|
+
let ambiguous = 0;
|
|
344
|
+
for (const perPath of Object.values(map.exports))
|
|
345
|
+
names += Object.keys(perPath).length;
|
|
346
|
+
for (const list of Object.values(map.ambiguousNames))
|
|
347
|
+
ambiguous += list.length;
|
|
348
|
+
return { subpaths: Object.keys(map.exports).length, names, ambiguous };
|
|
349
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|