devlensio 1.1.0 → 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.
@@ -167,6 +167,7 @@ export async function runInlineExtractor(input, onStep) {
167
167
  nodes: allNodes,
168
168
  edges: allEdges,
169
169
  routes,
170
+ exports: parserResult.exports,
170
171
  stats,
171
172
  errors: [],
172
173
  };
@@ -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
  }
@@ -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 {};
@@ -0,0 +1,324 @@
1
+ import fs from "fs";
2
+ import os from "os";
3
+ import path from "path";
4
+ import { parseRepo } from "./index.js";
5
+ import { buildLookupMaps } from "../graph/buildLookup.js";
6
+ import { detectImportEdges } from "../graph/edges/importEdges.js";
7
+ import { detectCallEdges } from "../graph/edges/callEdges.js";
8
+ function createFakeRepo(files) {
9
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "devlens-exports-map-test-"));
10
+ for (const [filePath, content] of Object.entries(files)) {
11
+ const fullPath = path.join(tmpDir, filePath);
12
+ fs.mkdirSync(path.dirname(fullPath), { recursive: true });
13
+ fs.writeFileSync(fullPath, content);
14
+ }
15
+ return tmpDir;
16
+ }
17
+ function deleteFakeRepo(repoPath) {
18
+ fs.rmSync(repoPath, { recursive: true, force: true });
19
+ }
20
+ describe("exports map (cross-repo linkage)", () => {
21
+ it("returns undefined when the repo has no package.json", () => {
22
+ const repo = createFakeRepo({
23
+ "src/a.ts": `export function alpha() { return 1; }\n`,
24
+ });
25
+ const result = parseRepo(repo);
26
+ expect(result.exports).toBeUndefined();
27
+ deleteFakeRepo(repo);
28
+ });
29
+ it("maps direct exports to single-element arrays of proper nodeIds", () => {
30
+ const repo = createFakeRepo({
31
+ "package.json": JSON.stringify({ name: "fakepkg", main: "./src/index.ts" }),
32
+ "src/index.ts": `export { alpha } from './a';\n`,
33
+ "src/a.ts": `export function alpha() { return 1; }\n`,
34
+ });
35
+ const { exports } = parseRepo(repo);
36
+ expect(exports).toBeDefined();
37
+ expect(exports.exports["."]["alpha"]).toEqual(["src/a.ts::alpha"]);
38
+ expect(exports.ambiguousNames).toEqual({});
39
+ deleteFakeRepo(repo);
40
+ });
41
+ it("flattens chained re-exports to the final definition node", () => {
42
+ const repo = createFakeRepo({
43
+ "package.json": JSON.stringify({ name: "fakepkg", main: "./src/index.ts" }),
44
+ "src/index.ts": `export { beta } from './helpers';\n`,
45
+ "src/helpers.ts": `export { beta } from './dateUtils';\n`,
46
+ "src/dateUtils.ts": `export function beta(d: string) { return d; }\n`,
47
+ });
48
+ const { exports } = parseRepo(repo);
49
+ expect(exports.exports["."]["beta"]).toEqual(["src/dateUtils.ts::beta"]);
50
+ deleteFakeRepo(repo);
51
+ });
52
+ it("keys the map by the name the importer sees when aliases are involved", () => {
53
+ const repo = createFakeRepo({
54
+ "package.json": JSON.stringify({ name: "fakepkg", main: "./src/index.ts" }),
55
+ "src/index.ts": `export { formatDate as fmtDate } from './dateUtils';\n`,
56
+ "src/dateUtils.ts": `export function formatDate(d: string) { return d; }\n`,
57
+ });
58
+ const { exports } = parseRepo(repo);
59
+ expect(exports.exports["."]["fmtDate"]).toEqual(["src/dateUtils.ts::formatDate"]);
60
+ expect(exports.exports["."]["formatDate"]).toBeUndefined();
61
+ deleteFakeRepo(repo);
62
+ });
63
+ it("handles the name-swap case via bindings, never by string matching", () => {
64
+ const repo = createFakeRepo({
65
+ "package.json": JSON.stringify({ name: "fakepkg", main: "./src/index.ts" }),
66
+ "src/index.ts": `
67
+ export { formatDate as fmtDate } from './dateUtils';
68
+ export { fmtDate as formatDate } from './misc';
69
+ `,
70
+ "src/dateUtils.ts": `export function formatDate(d: string) { return d; }\n`,
71
+ "src/misc.ts": `export function fmtDate(x: number) { return x; }\n`,
72
+ });
73
+ const { exports } = parseRepo(repo);
74
+ expect(exports.exports["."]["fmtDate"]).toEqual(["src/dateUtils.ts::formatDate"]);
75
+ expect(exports.exports["."]["formatDate"]).toEqual(["src/misc.ts::fmtDate"]);
76
+ expect(exports.ambiguousNames).toEqual({});
77
+ deleteFakeRepo(repo);
78
+ });
79
+ it("emits one map key per package.json exports subpath", () => {
80
+ const repo = createFakeRepo({
81
+ "package.json": JSON.stringify({
82
+ name: "fakepkg",
83
+ exports: {
84
+ ".": "./src/index.ts",
85
+ "./server": "./src/server/index.ts",
86
+ },
87
+ }),
88
+ "src/index.ts": `export { alpha } from './a';\n`,
89
+ "src/a.ts": `export function alpha() { return 1; }\n`,
90
+ "src/server/index.ts": `export { serve } from './serve';\n`,
91
+ "src/server/serve.ts": `export function serve() { return 2; }\n`,
92
+ });
93
+ const { exports } = parseRepo(repo);
94
+ expect(exports.exports["."]["alpha"]).toEqual(["src/a.ts::alpha"]);
95
+ expect(exports.exports["./server"]["serve"]).toEqual(["src/server/serve.ts::serve"]);
96
+ deleteFakeRepo(repo);
97
+ });
98
+ it("resolves dist entry targets back to src (same coordinate system as nodes)", () => {
99
+ const repo = createFakeRepo({
100
+ "package.json": JSON.stringify({
101
+ name: "fakepkg",
102
+ exports: { ".": { import: "./dist/index.js", types: "./dist/index.d.ts" } },
103
+ }),
104
+ "src/index.ts": `export { alpha } from './a';\n`,
105
+ "src/a.ts": `export function alpha() { return 1; }\n`,
106
+ });
107
+ const { exports } = parseRepo(repo);
108
+ expect(exports.exports["."]["alpha"]).toEqual(["src/a.ts::alpha"]);
109
+ deleteFakeRepo(repo);
110
+ });
111
+ it("falls back to main/module when the exports field is absent", () => {
112
+ const repo = createFakeRepo({
113
+ "package.json": JSON.stringify({ name: "fakepkg", main: "./dist/index.js" }),
114
+ "src/index.ts": `export { alpha } from './a';\n`,
115
+ "src/a.ts": `export function alpha() { return 1; }\n`,
116
+ });
117
+ const { exports } = parseRepo(repo);
118
+ expect(exports.exports["."]["alpha"]).toEqual(["src/a.ts::alpha"]);
119
+ deleteFakeRepo(repo);
120
+ });
121
+ it("emits distinct #arity suffixed nodeIds for same-name different-arity exports", () => {
122
+ const repo = createFakeRepo({
123
+ "package.json": JSON.stringify({ name: "fakepkg", main: "./src/index.ts" }),
124
+ "src/index.ts": `export { formatValue } from './arity';\n`,
125
+ "src/arity.ts": `
126
+ export function formatValue(v: string) { return v.trim(); }
127
+ export function formatValue(v: string, n: number) { return v.repeat(n); }
128
+ `,
129
+ });
130
+ const { exports } = parseRepo(repo);
131
+ const ids = exports.exports["."]["formatValue"].sort();
132
+ expect(ids).toEqual(["src/arity.ts::formatValue#1", "src/arity.ts::formatValue#2"]);
133
+ expect(exports.ambiguousNames["."]).toBeUndefined();
134
+ deleteFakeRepo(repo);
135
+ });
136
+ it("emits distinct sig8-suffixed nodeIds for same-arity different-type exports", () => {
137
+ const repo = createFakeRepo({
138
+ "package.json": JSON.stringify({ name: "fakepkg", main: "./src/index.ts" }),
139
+ "src/index.ts": `export { decode } from './types';\n`,
140
+ "src/types.ts": `
141
+ export function decode(input: string, mode: string) { return input; }
142
+ export function decode(input: number, mode: string) { return String(input); }
143
+ `,
144
+ });
145
+ const { exports } = parseRepo(repo);
146
+ const ids = exports.exports["."]["decode"].sort();
147
+ expect(ids.length).toBe(2);
148
+ expect(ids[0]).toMatch(/^src\/types\.ts::decode#2#[0-9a-f]{8}$/);
149
+ expect(ids[1]).toMatch(/^src\/types\.ts::decode#2#[0-9a-f]{8}$/);
150
+ expect(ids[0]).not.toBe(ids[1]);
151
+ deleteFakeRepo(repo);
152
+ });
153
+ it("excludes star re-export conflicts per the JS module spec (name absent, not guessed)", () => {
154
+ const repo = createFakeRepo({
155
+ "package.json": JSON.stringify({ name: "fakepkg", main: "./src/index.ts" }),
156
+ "src/index.ts": `export * from './a';\nexport * from './b';\n`,
157
+ "src/a.ts": `export function clash() { return 1; }\nexport function onlyA() { return 2; }\n`,
158
+ "src/b.ts": `export function clash() { return 3; }\n`,
159
+ });
160
+ const { exports } = parseRepo(repo);
161
+ expect(exports.exports["."]["clash"]).toBeUndefined();
162
+ expect(exports.exports["."]["onlyA"]).toEqual(["src/a.ts::onlyA"]);
163
+ expect(exports.ambiguousNames["."]).toEqual(["clash"]);
164
+ deleteFakeRepo(repo);
165
+ });
166
+ it("resolves star re-exports like named ones", () => {
167
+ const repo = createFakeRepo({
168
+ "package.json": JSON.stringify({ name: "fakepkg", main: "./src/index.ts" }),
169
+ "src/index.ts": `export * from './barrel';\n`,
170
+ "src/barrel.ts": `export * from './a';\n`,
171
+ "src/a.ts": `export function alpha() { return 1; }\n`,
172
+ });
173
+ const { exports } = parseRepo(repo);
174
+ expect(exports.exports["."]["alpha"]).toEqual(["src/a.ts::alpha"]);
175
+ deleteFakeRepo(repo);
176
+ });
177
+ it("maps named default exports and omits anonymous ones", () => {
178
+ const repo = createFakeRepo({
179
+ "package.json": JSON.stringify({ name: "fakepkg", main: "./src/index.ts" }),
180
+ "src/index.ts": `
181
+ export { default as named } from './named';
182
+ export { default as anon } from './anon';
183
+ `,
184
+ "src/named.ts": `export default function loader() { return 1; }\n`,
185
+ "src/anon.ts": `export default () => 2;\n`,
186
+ });
187
+ const { exports } = parseRepo(repo);
188
+ expect(exports.exports["."]["named"]).toEqual(["src/named.ts::loader"]);
189
+ expect(exports.exports["."]["anon"]).toBeUndefined();
190
+ deleteFakeRepo(repo);
191
+ });
192
+ it("omits exports that have no node representation (consts, types, interfaces)", () => {
193
+ const repo = createFakeRepo({
194
+ "package.json": JSON.stringify({ name: "fakepkg", main: "./src/index.ts" }),
195
+ "src/index.ts": `export * from './mixed';\n`,
196
+ "src/mixed.ts": `
197
+ export const config = { a: 1 };
198
+ export type Maybe = string | null;
199
+ export interface Opts { a: number }
200
+ export function run(o: Opts) { return o.a; }
201
+ `,
202
+ });
203
+ const { exports } = parseRepo(repo);
204
+ expect(exports.exports["."]["run"]).toEqual(["src/mixed.ts::run"]);
205
+ expect(exports.exports["."]["config"]).toBeUndefined();
206
+ expect(exports.exports["."]["Maybe"]).toBeUndefined();
207
+ deleteFakeRepo(repo);
208
+ });
209
+ it("survives re-export cycles without hanging and omits the undefined name", () => {
210
+ const repo = createFakeRepo({
211
+ "package.json": JSON.stringify({ name: "fakepkg", main: "./src/index.ts" }),
212
+ "src/index.ts": `export { gone } from './a';\n`,
213
+ "src/a.ts": `export { gone } from './b';\n`,
214
+ "src/b.ts": `export { gone } from './a';\n`,
215
+ });
216
+ const { exports } = parseRepo(repo);
217
+ const gone = exports ? exports.exports["."]?.["gone"] : undefined;
218
+ expect(gone).toBeUndefined();
219
+ deleteFakeRepo(repo);
220
+ });
221
+ it("maps exported classes and exported const arrow functions to their nodes", () => {
222
+ const repo = createFakeRepo({
223
+ "package.json": JSON.stringify({ name: "fakepkg", main: "./src/index.ts" }),
224
+ "src/index.ts": `export * from './things';\n`,
225
+ "src/things.ts": `
226
+ export class Widget { render() { return 1; } }
227
+ export const handler = () => 2;
228
+ `,
229
+ });
230
+ const { exports } = parseRepo(repo);
231
+ expect(exports.exports["."]["Widget"]).toEqual(["src/things.ts::Widget"]);
232
+ expect(exports.exports["."]["handler"]).toEqual(["src/things.ts::handler"]);
233
+ deleteFakeRepo(repo);
234
+ });
235
+ it("never references a nodeId that is absent from the nodes array", () => {
236
+ const repo = createFakeRepo({
237
+ "package.json": JSON.stringify({ name: "fakepkg", main: "./src/index.ts" }),
238
+ "src/index.ts": `export * from './a';\nexport { beta } from './b';\n`,
239
+ "src/a.ts": `export function alpha() { return 1; }\n`,
240
+ "src/b.ts": `export function beta() { return 2; }\n`,
241
+ });
242
+ const result = parseRepo(repo);
243
+ const nodeIds = new Set(result.nodes.map((n) => n.id));
244
+ for (const perPath of Object.values(result.exports.exports)) {
245
+ for (const ids of Object.values(perPath)) {
246
+ for (const id of ids)
247
+ expect(nodeIds.has(id)).toBe(true);
248
+ }
249
+ }
250
+ deleteFakeRepo(repo);
251
+ });
252
+ it("resolves one condition per subpath: import beats require and types", () => {
253
+ const repo = createFakeRepo({
254
+ "package.json": JSON.stringify({
255
+ name: "fakepkg",
256
+ exports: {
257
+ ".": { import: "./src/index.ts", require: "./src/legacy.ts", types: "./src/index.d.ts" },
258
+ "./legacy": { require: "./src/legacy.ts" },
259
+ "./types-only": { types: "./src/index.d.ts" },
260
+ },
261
+ }),
262
+ "src/index.ts": `export { alpha } from './a';\n`,
263
+ "src/a.ts": `export function alpha() { return 1; }\n`,
264
+ "src/legacy.ts": `export function alpha() { return 2; }\n`,
265
+ "src/index.d.ts": `export declare function alpha(): number;\n`,
266
+ });
267
+ const { exports } = parseRepo(repo);
268
+ expect(exports.exports["."]["alpha"]).toEqual(["src/a.ts::alpha"]);
269
+ expect(exports.exports["./legacy"]["alpha"]).toEqual(["src/legacy.ts::alpha"]);
270
+ expect(exports.exports["./types-only"]).toBeUndefined();
271
+ deleteFakeRepo(repo);
272
+ });
273
+ it("skips unparseable files and still builds the map from valid ones", () => {
274
+ const repo = createFakeRepo({
275
+ "package.json": JSON.stringify({ name: "fakepkg", main: "./src/index.ts" }),
276
+ "src/index.ts": `export { alpha } from './a';\n`,
277
+ "src/a.ts": `export function alpha() { return 1; }\n`,
278
+ "src/broken.ts": `export function broken( {\n`,
279
+ });
280
+ const result = parseRepo(repo);
281
+ expect(result.exports).toBeDefined();
282
+ expect(result.exports.exports["."]["alpha"]).toEqual(["src/a.ts::alpha"]);
283
+ expect(result.exports.exports["."]["broken"]).toBeUndefined();
284
+ deleteFakeRepo(repo);
285
+ });
286
+ it("call-site narrowing picks within an exported overload group (map ids = edge targets)", () => {
287
+ const repo = createFakeRepo({
288
+ "package.json": JSON.stringify({ name: "fakepkg", main: "./src/index.ts" }),
289
+ "src/index.ts": `export { formatValue } from './overloads';\n`,
290
+ "src/overloads.ts": `
291
+ export function formatValue(v: string) { return v.trim(); }
292
+ export function formatValue(v: string, n: number) { return v.repeat(n); }
293
+ `,
294
+ "src/caller.ts": `
295
+ import { formatValue } from './index';
296
+ export function useOne() { return formatValue("x"); }
297
+ export function useTwo() { return formatValue("x", 3); }
298
+ `,
299
+ });
300
+ const parsed = parseRepo(repo);
301
+ const lookup = buildLookupMaps(parsed.nodes);
302
+ detectImportEdges(lookup, repo);
303
+ const callResult = detectCallEdges(parsed.nodes, lookup);
304
+ const { exports } = parsed;
305
+ const group = exports.exports["."]["formatValue"];
306
+ expect(group.length).toBe(2);
307
+ const targets = callResult.edges
308
+ .filter((e) => e.type === "CALLS" && e.from === "src/caller.ts::useOne")
309
+ .map((e) => e.to);
310
+ expect(targets.length).toBe(1);
311
+ expect(group).toContain(targets[0]);
312
+ const targetsTwo = callResult.edges
313
+ .filter((e) => e.type === "CALLS" && e.from === "src/caller.ts::useTwo")
314
+ .map((e) => e.to);
315
+ expect(targetsTwo.length).toBe(1);
316
+ expect(group).toContain(targetsTwo[0]);
317
+ expect(targets[0]).not.toBe(targetsTwo[0]);
318
+ // every narrowed target is a real node the map points at — no phantom ids
319
+ const nodeIds = new Set(parsed.nodes.map((n) => n.id));
320
+ for (const t of [...targets, ...targetsTwo])
321
+ expect(nodeIds.has(t)).toBe(true);
322
+ deleteFakeRepo(repo);
323
+ });
324
+ });
@@ -1,6 +1,7 @@
1
- import type { CodeNode } from "../types.js";
1
+ import type { CodeNode, GraphExports } from "../types.js";
2
2
  export interface ParserResult {
3
3
  nodes: CodeNode[];
4
+ exports?: GraphExports;
4
5
  stats: {
5
6
  totalFiles: number;
6
7
  totalNodes: number;
@@ -9,6 +9,7 @@ import { extractObjectMethods } from "./extractors/objectMethods.js";
9
9
  import { extractClasses } from "./extractors/classes.js";
10
10
  import { detectFileDirective } from "./directives.js";
11
11
  import { applyOverloadDisambiguation } from "./overloads.js";
12
+ import { buildExportsMap, validateExportsMap, exportsMapStats } from "./exportsMap.js";
12
13
  import { createHash } from "crypto";
13
14
  // Directories to skip entirely while walking
14
15
  const IGNORE_DIRS = [
@@ -164,8 +165,40 @@ export function parseRepo(repoPath) {
164
165
  const storeCount = allNodes.filter((n) => n.type === "STATE_STORE").length;
165
166
  const classCount = allNodes.filter((n) => n.type === "CLASS").length;
166
167
  const methodCount = allNodes.filter((n) => n.type === "METHOD").length;
168
+ let exports;
169
+ try {
170
+ const built = buildExportsMap(project, repoPath, allNodes);
171
+ if (built) {
172
+ const dangling = validateExportsMap(built, new Set(allNodes.map((n) => n.id)));
173
+ if (dangling.length > 0) {
174
+ console.warn(`Exports map: dropping ${dangling.length} dangling nodeId references`);
175
+ for (const perPath of Object.values(built.exports)) {
176
+ for (const [name, ids] of Object.entries(perPath)) {
177
+ const filtered = ids.filter((id) => !dangling.includes(id));
178
+ if (filtered.length > 0)
179
+ perPath[name] = filtered;
180
+ else
181
+ delete perPath[name];
182
+ }
183
+ }
184
+ for (const [subpath, perPath] of Object.entries(built.exports)) {
185
+ if (Object.keys(perPath).length === 0)
186
+ delete built.exports[subpath];
187
+ }
188
+ }
189
+ if (Object.keys(built.exports).length > 0) {
190
+ exports = built;
191
+ const s = exportsMapStats(built);
192
+ console.log(` Exports map: ${s.names} names across ${s.subpaths} subpath(s), ${s.ambiguous} ambiguous`);
193
+ }
194
+ }
195
+ }
196
+ catch (err) {
197
+ console.warn(`Exports map build failed — continuing without it: ${err}`);
198
+ }
167
199
  return {
168
200
  nodes: allNodes,
201
+ exports,
169
202
  stats: {
170
203
  totalFiles: sourceFiles.length,
171
204
  totalNodes: allNodes.length,
@@ -1,5 +1,5 @@
1
1
  import type { FilterThresholds } from "../scoring/noiseFilter.js";
2
- import type { CodeNode, CodeEdge, ProjectFingerprint, RouteNode, BackendRouteNode } from "../types.js";
2
+ import type { CodeNode, CodeEdge, ProjectFingerprint, RouteNode, BackendRouteNode, GraphExports } from "../types.js";
3
3
  export type { FilterThresholds };
4
4
  export interface GitInfo {
5
5
  commitHash: string;
@@ -37,6 +37,7 @@ export interface PipelineResult {
37
37
  analyzedAt: string;
38
38
  fingerprint: ProjectFingerprint;
39
39
  routes: (RouteNode | BackendRouteNode)[];
40
+ exports?: GraphExports;
40
41
  nodes: CodeNode[];
41
42
  edges: CodeEdge[];
42
43
  allNodes: CodeNode[];
@@ -251,6 +251,7 @@ export async function analyzePipeline(repoPath, isGithubRepo, options) {
251
251
  analyzedAt,
252
252
  fingerprint: extractorResult.fingerprint,
253
253
  routes: extractorResult.routes ?? [],
254
+ exports: extractorResult.exports,
254
255
  nodes: scoringResult.filteredNodes,
255
256
  edges: scoringResult.filteredEdges,
256
257
  allNodes,
@@ -1,5 +1,5 @@
1
1
  import type { PipelineResult, PipelineStats } from "../pipeline/index.js";
2
- import type { CodeNode, CodeEdge } from "../types.js";
2
+ import type { CodeNode, CodeEdge, GraphExports } from "../types.js";
3
3
  import type { GraphStorage } from "./interface.js";
4
4
  export interface GraphIndexEntry {
5
5
  graphId: string;
@@ -44,6 +44,7 @@ export interface CommitData {
44
44
  allEdges: CodeEdge[];
45
45
  nodeScores: Record<string, number>;
46
46
  stats: PipelineStats;
47
+ exports?: GraphExports;
47
48
  }
48
49
  export interface NodeDiff {
49
50
  added: DiffNode[];
@@ -14,7 +14,9 @@ import { simpleGit } from "simple-git";
14
14
  //
15
15
  // Diffs are computed on demand — not stored.
16
16
  // GitHub scope is reserved in meta.json for future cloud repo support.
17
- const STORAGE_DIR = path.join(os.homedir(), ".devlens");
17
+ const STORAGE_DIR = process.env.DEVLENS_STORAGE_DIR
18
+ ? path.resolve(process.env.DEVLENS_STORAGE_DIR)
19
+ : path.join(os.homedir(), ".devlens");
18
20
  const GRAPHS_DIR = path.join(STORAGE_DIR, "graphs");
19
21
  const INDEX_FILE = path.join(STORAGE_DIR, "index.json");
20
22
  const SCHEMA_VERSION = "1.0";
@@ -118,6 +120,7 @@ function buildCommitData(result) {
118
120
  allEdges: result.allEdges,
119
121
  nodeScores: result.nodeScores,
120
122
  stats: result.stats,
123
+ exports: result.exports,
121
124
  };
122
125
  }
123
126
  // ─── Public API ───────────────────────────────────────────────────────────────
@@ -239,6 +242,7 @@ export function getGraph(graphId, commitHash // defaults to latest commit
239
242
  allEdges: data.allEdges,
240
243
  nodeScores: data.nodeScores,
241
244
  stats: data.stats,
245
+ exports: data.exports,
242
246
  isGithubRepo: meta.isGithubRepo,
243
247
  gitInfo: {
244
248
  commitHash: data.commitHash,
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,112 @@
1
+ // Storage round-trip + back-compat tests for the exports map.
2
+ //
3
+ // STORAGE_DIR is computed from os.homedir() at module load, so these tests
4
+ // point it at a temp dir via DEVLENS_STORAGE_DIR and import the module
5
+ // dynamically AFTER setting the env var — nothing here ever touches the
6
+ // real ~/.devlens.
7
+ import { describe, expect, test } from "bun:test";
8
+ import fs from "fs";
9
+ import os from "os";
10
+ import path from "path";
11
+ const FAKE_STORAGE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "devlens-storage-test-"));
12
+ process.env.DEVLENS_STORAGE_DIR = FAKE_STORAGE_ROOT;
13
+ const { saveGraph, getGraph, deleteGraph } = await import("./fileStorage.js");
14
+ function makeNode(id, name) {
15
+ return {
16
+ id,
17
+ name,
18
+ type: "FUNCTION",
19
+ filePath: `src/${name}.ts`,
20
+ startLine: 1,
21
+ endLine: 2,
22
+ parentFile: `file::src/${name}.ts`,
23
+ metadata: {},
24
+ };
25
+ }
26
+ function makeResult(overrides) {
27
+ const nodes = [makeNode("src/alpha.ts::alpha", "alpha"), makeNode("src/alpha.ts::alpha#2", "alpha")];
28
+ const edges = [];
29
+ return {
30
+ repoPath: FAKE_STORAGE_ROOT,
31
+ analyzedAt: "2026-10-07T00:00:00.000Z",
32
+ fingerprint: { language: "typescript" },
33
+ routes: [],
34
+ nodes,
35
+ edges,
36
+ allNodes: nodes,
37
+ allEdges: edges,
38
+ nodeScores: {},
39
+ stats: {
40
+ totalNodesBeforeFilter: nodes.length,
41
+ totalEdgesBeforeFilter: 0,
42
+ totalNodesAfterFilter: nodes.length,
43
+ totalEdgesAfterFilter: 0,
44
+ removedNodeCount: 0,
45
+ removedEdgeCount: 0,
46
+ averageNodeScore: 0,
47
+ topScoringNodes: [],
48
+ topScoringFiles: [],
49
+ },
50
+ isGithubRepo: false,
51
+ gitInfo: { commitHash: "abc12345", branch: "main", message: "test", hasGit: true },
52
+ ...overrides,
53
+ };
54
+ }
55
+ const MAP = {
56
+ exports: {
57
+ ".": {
58
+ alpha: ["src/alpha.ts::alpha", "src/alpha.ts::alpha#2"],
59
+ beta: ["src/beta.ts::beta"],
60
+ },
61
+ "./server": { serve: ["src/server.ts::serve"] },
62
+ },
63
+ ambiguousNames: {},
64
+ };
65
+ describe("exports map persistence (saveGraph -> getGraph)", () => {
66
+ test("round trip: map survives byte-for-byte, including multi-id groups and order", () => {
67
+ const result = makeResult({ graphId: "rt-test-graph", exports: MAP });
68
+ saveGraph(result, { force: true });
69
+ const loaded = getGraph("rt-test-graph", "abc12345");
70
+ expect(loaded).toBeDefined();
71
+ expect(loaded.exports).toBeDefined();
72
+ // deep identity: shape, keys, values, AND array order within groups
73
+ expect(loaded.exports).toEqual(MAP);
74
+ expect(loaded.exports.exports["."].alpha).toEqual(["src/alpha.ts::alpha", "src/alpha.ts::alpha#2"]);
75
+ expect(loaded.exports.ambiguousNames).toEqual({});
76
+ deleteGraph("rt-test-graph");
77
+ });
78
+ test("legacy artifact without the exports field: loads, yields undefined, no fabricated empty map", () => {
79
+ const result = makeResult({ graphId: "legacy-test-graph" });
80
+ delete result.exports;
81
+ saveGraph(result, { force: true });
82
+ const commitFile = path.join(FAKE_STORAGE_ROOT, "graphs", "legacy-test-graph", "commits", "abc12345.json");
83
+ const onDisk = JSON.parse(fs.readFileSync(commitFile, "utf-8"));
84
+ expect("exports" in onDisk).toBe(false);
85
+ const loaded = getGraph("legacy-test-graph", "abc12345");
86
+ expect(loaded).toBeDefined();
87
+ expect(loaded.exports).toBeUndefined();
88
+ deleteGraph("legacy-test-graph");
89
+ });
90
+ test("degraded input: exports present but EMPTY subpaths load without crash", () => {
91
+ const emptyMap = { exports: { ".": {} }, ambiguousNames: {} };
92
+ const result = makeResult({ graphId: "empty-map-graph", exports: emptyMap });
93
+ saveGraph(result, { force: true });
94
+ const loaded = getGraph("empty-map-graph", "abc12345");
95
+ expect(loaded).toBeDefined();
96
+ expect(loaded.exports).toEqual(emptyMap);
97
+ deleteGraph("empty-map-graph");
98
+ });
99
+ test("degraded input: exports referencing node ids ABSENT from the nodes array load without crash", () => {
100
+ const danglingMap = {
101
+ exports: { ".": { ghost: ["src/gone.ts::gone"] } },
102
+ ambiguousNames: {},
103
+ };
104
+ const result = makeResult({ graphId: "dangling-map-graph", exports: danglingMap });
105
+ saveGraph(result, { force: true });
106
+ const loaded = getGraph("dangling-map-graph", "abc12345");
107
+ expect(loaded).toBeDefined();
108
+ expect(loaded.exports).toEqual(danglingMap);
109
+ expect(loaded.exports.exports["."].ghost).toEqual(["src/gone.ts::gone"]);
110
+ deleteGraph("dangling-map-graph");
111
+ });
112
+ });
package/dist/types.d.ts CHANGED
@@ -83,3 +83,7 @@ export interface CodeEdge {
83
83
  type: EdgeType;
84
84
  metadata?: Record<string, unknown>;
85
85
  }
86
+ export interface GraphExports {
87
+ exports: Record<string, Record<string, string[]>>;
88
+ ambiguousNames: Record<string, string[]>;
89
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devlensio",
3
- "version": "1.1.0",
3
+ "version": "1.1.1",
4
4
  "description": "Codebase intelligence engine for TypeScript/JavaScript/React/Next.js, Python, Java, Go, and Rust repositories.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",