codedeep-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +177 -0
  3. package/dist/config.js +223 -0
  4. package/dist/git/analyzer.js +177 -0
  5. package/dist/git/git-service.js +568 -0
  6. package/dist/git/head-watcher.js +113 -0
  7. package/dist/git/runner.js +204 -0
  8. package/dist/index.js +138 -0
  9. package/dist/indexer/code-index.js +1801 -0
  10. package/dist/indexer/complexity.js +633 -0
  11. package/dist/indexer/extractor.js +354 -0
  12. package/dist/indexer/languages/cpp.js +934 -0
  13. package/dist/indexer/languages/csharp.js +854 -0
  14. package/dist/indexer/languages/dart.js +777 -0
  15. package/dist/indexer/languages/go.js +665 -0
  16. package/dist/indexer/languages/java.js +507 -0
  17. package/dist/indexer/languages/kotlin.js +709 -0
  18. package/dist/indexer/languages/objc.js +397 -0
  19. package/dist/indexer/languages/php.js +771 -0
  20. package/dist/indexer/languages/python.js +455 -0
  21. package/dist/indexer/languages/ruby.js +697 -0
  22. package/dist/indexer/languages/rust.js +754 -0
  23. package/dist/indexer/languages/swift.js +691 -0
  24. package/dist/indexer/languages/typescript.js +485 -0
  25. package/dist/indexer/parser.js +175 -0
  26. package/dist/indexer/pipeline.js +342 -0
  27. package/dist/indexer/scanner.js +279 -0
  28. package/dist/indexer/watcher.js +353 -0
  29. package/dist/logger.js +16 -0
  30. package/dist/server.js +170 -0
  31. package/dist/tools/common.js +207 -0
  32. package/dist/tools/find-references.js +224 -0
  33. package/dist/tools/find-symbol.js +94 -0
  34. package/dist/tools/get-context.js +370 -0
  35. package/dist/tools/impact.js +218 -0
  36. package/dist/tools/overview.js +482 -0
  37. package/dist/tools/search-structure.js +303 -0
  38. package/dist/types.js +61 -0
  39. package/grammars/tree-sitter-c.wasm +0 -0
  40. package/grammars/tree-sitter-c_sharp.wasm +0 -0
  41. package/grammars/tree-sitter-cpp.wasm +0 -0
  42. package/grammars/tree-sitter-dart.wasm +0 -0
  43. package/grammars/tree-sitter-go.wasm +0 -0
  44. package/grammars/tree-sitter-java.wasm +0 -0
  45. package/grammars/tree-sitter-javascript.wasm +0 -0
  46. package/grammars/tree-sitter-kotlin.wasm +0 -0
  47. package/grammars/tree-sitter-objc.wasm +0 -0
  48. package/grammars/tree-sitter-php.wasm +0 -0
  49. package/grammars/tree-sitter-python.wasm +0 -0
  50. package/grammars/tree-sitter-ruby.wasm +0 -0
  51. package/grammars/tree-sitter-rust.wasm +0 -0
  52. package/grammars/tree-sitter-swift.wasm +0 -0
  53. package/grammars/tree-sitter-tsx.wasm +0 -0
  54. package/grammars/tree-sitter-typescript.wasm +0 -0
  55. package/package.json +67 -0
@@ -0,0 +1,482 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import { basename, extname, join, resolve } from 'node:path';
3
+ import { ENTRY_POINT_FILENAME_RE, isClassMember, zeroSymbolsByKind, } from '../indexer/code-index.js';
4
+ import { compareShallowFirst } from '../indexer/scanner.js';
5
+ import { errMsg, log } from '../logger.js';
6
+ import { LANGUAGE_UNKNOWN, } from '../types.js';
7
+ import { BEHAVIORAL_TAG, formatComplexityMetrics, INDEXING_BANNER, plural, textResponse, } from './common.js';
8
+ const MAX_DIR_GROUPS = 7;
9
+ const MAX_KINDS_PER_GROUP = 3;
10
+ const MAX_ENTRY_POINTS = 15;
11
+ const MAX_OTHER_EXTENSIONS = 5;
12
+ const MAX_HOTSPOTS = 10;
13
+ const MAX_RISK_HOTSPOTS = 10;
14
+ // Without this, monorepos with many packages/*/index.ts (or many
15
+ // __init__.py) saturate MAX_ENTRY_POINTS alphabetically and hide the real
16
+ // startup file. Tested only against names that already passed
17
+ // ENTRY_POINT_FILENAME_RE, so a stem-only check is sufficient.
18
+ const BARREL_ENTRY_RE = /^(index|__init__)\./i;
19
+ const LANGUAGE_DISPLAY = {
20
+ typescript: 'TypeScript',
21
+ tsx: 'TSX',
22
+ javascript: 'JavaScript',
23
+ python: 'Python',
24
+ java: 'Java',
25
+ go: 'Go',
26
+ rust: 'Rust',
27
+ swift: 'Swift',
28
+ kotlin: 'Kotlin',
29
+ dart: 'Dart',
30
+ csharp: 'C#',
31
+ php: 'PHP',
32
+ ruby: 'Ruby',
33
+ cpp: 'C++',
34
+ c: 'C',
35
+ objc: 'Objective-C',
36
+ };
37
+ const KIND_PLURAL = {
38
+ function: 'functions',
39
+ class: 'classes',
40
+ interface: 'interfaces',
41
+ type: 'types',
42
+ variable: 'variables',
43
+ method: 'methods',
44
+ module: 'modules',
45
+ enum: 'enums',
46
+ };
47
+ export async function runOverview(args, deps) {
48
+ try {
49
+ const projectRoot = deps.config.projectRoot;
50
+ if (args.path && resolve(args.path) !== projectRoot) {
51
+ return textResponse(`Error: path "${args.path}" does not match configured project root "${projectRoot}". Multi-root workspaces are not yet supported.`);
52
+ }
53
+ const stats = deps.index.getStats();
54
+ const allFiles = deps.index.getAllFiles();
55
+ const lines = [];
56
+ if (!deps.indexer.ready) {
57
+ lines.push(INDEXING_BANNER, '');
58
+ }
59
+ lines.push(`## Project: ${basename(projectRoot)}`, '');
60
+ const unknownCount = stats.filesByLanguage[LANGUAGE_UNKNOWN] ?? 0;
61
+ const recognizedTotal = stats.totalFiles - unknownCount;
62
+ lines.push('### Languages');
63
+ if (recognizedTotal === 0) {
64
+ lines.push('- (no source files indexed)');
65
+ }
66
+ else {
67
+ const langs = Object.entries(stats.filesByLanguage)
68
+ .filter(([lang]) => lang !== LANGUAGE_UNKNOWN)
69
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
70
+ for (const [lang, count] of langs) {
71
+ const pct = Math.round((count / recognizedTotal) * 100);
72
+ lines.push(`- ${displayLanguage(lang)}: ${count} ${plural('file', count)} (${pct}%)`);
73
+ }
74
+ }
75
+ lines.push('');
76
+ if (unknownCount > 0) {
77
+ lines.push('### Other files');
78
+ const topExts = topUnknownExtensions(allFiles, MAX_OTHER_EXTENSIONS);
79
+ const detail = topExts.length > 0 ? ` (${topExts.join(', ')})` : '';
80
+ lines.push(`- ${unknownCount} ${plural('file', unknownCount)} not parsed${detail}`);
81
+ lines.push('');
82
+ }
83
+ lines.push('### Structure');
84
+ const groups = groupFilesByDirectory(allFiles, deps.index);
85
+ if (groups.length === 0) {
86
+ lines.push('- (no files indexed)');
87
+ }
88
+ else {
89
+ for (const g of groups) {
90
+ const kindParts = g.topKinds.map((k) => `${k.count} ${pluralKind(k.kind, k.count)}`);
91
+ const kindSuffix = kindParts.length > 0 ? ` (${kindParts.join(', ')})` : '';
92
+ lines.push(`- ${g.dir} — ${g.fileCount} ${plural('file', g.fileCount)}${kindSuffix}`);
93
+ }
94
+ }
95
+ lines.push('');
96
+ lines.push('### Entry Points');
97
+ const entries = await collectEntryPoints(deps.index, allFiles, projectRoot);
98
+ if (entries.length === 0) {
99
+ lines.push('- (none detected)');
100
+ }
101
+ else {
102
+ for (const e of entries) {
103
+ if (e.summary) {
104
+ lines.push(`- ${e.file} — ${e.summary}`);
105
+ }
106
+ else if (e.symbol !== undefined && e.line !== undefined) {
107
+ lines.push(`- ${e.file}:${e.line} — ${e.symbol}`);
108
+ }
109
+ else {
110
+ lines.push(`- ${e.file}`);
111
+ }
112
+ }
113
+ }
114
+ lines.push('');
115
+ lines.push('### Symbols');
116
+ const kindLine = formatSymbolKinds(stats.symbolsByKind);
117
+ if (kindLine)
118
+ lines.push(`- ${kindLine}`);
119
+ lines.push(`- ${stats.totalFiles} ${plural('file', stats.totalFiles)} indexed, ${stats.totalSymbols} total ${plural('symbol', stats.totalSymbols)}`);
120
+ appendGitSections(lines, await collectGitData(deps));
121
+ return textResponse(lines.join('\n'));
122
+ }
123
+ catch (err) {
124
+ return textResponse(`Error: ${errMsg(err)}`);
125
+ }
126
+ }
127
+ // Defensive catch at the tool boundary: GitService promises not to
128
+ // throw, but a git failure must never break overview output.
129
+ async function collectGitData(deps) {
130
+ let branch = null;
131
+ try {
132
+ branch = await deps.git.branchSummary();
133
+ }
134
+ catch {
135
+ branch = null;
136
+ }
137
+ return {
138
+ branch,
139
+ hotspots: deps.index.getHotspots(MAX_HOTSPOTS),
140
+ // Empty off-git (getRiskHotspots gates on getGitMeta) — the section is
141
+ // then omitted, matching the silent-omission contract below.
142
+ riskHotspots: deps.index.getRiskHotspots(MAX_RISK_HOTSPOTS),
143
+ // Label with the window that PRODUCED the data (gitMeta provenance),
144
+ // not the live config: after a gitWindow change, persisted counts
145
+ // keep their true label until the re-analysis lands.
146
+ windowDays: deps.index.getGitMeta()?.windowDays ?? deps.config.gitWindow,
147
+ };
148
+ }
149
+ // Both sections vanish entirely outside git repos (and before the first
150
+ // analysis lands) — silent omission is the degradation contract, never a
151
+ // placeholder. Hotspots come from the persisted index, so a warm start
152
+ // shows them immediately, even while the indexing banner is up.
153
+ function appendGitSections(lines, data) {
154
+ if (data.branch !== null) {
155
+ lines.push('', `### Branch ${BEHAVIORAL_TAG}`, formatBranchLine(data.branch));
156
+ }
157
+ if (data.hotspots.length > 0) {
158
+ lines.push('', `### Hotspots (last ${data.windowDays} days) ${BEHAVIORAL_TAG}`);
159
+ for (const h of data.hotspots) {
160
+ lines.push(`- ${h.path} — ${h.commits} ${plural('commit', h.commits)}`);
161
+ }
162
+ }
163
+ // Churn × coupling × complexity: the file's most-coupled symbol crossed with
164
+ // its commit frequency, refined by that offender's complexity. Empty (and so
165
+ // omitted) off-git, where the product has no churn factor — the same silent-
166
+ // omission contract as the sections above.
167
+ if (data.riskHotspots.length > 0) {
168
+ lines.push('', `### Risk Hotspots (churn × coupling × complexity) ${BEHAVIORAL_TAG}`);
169
+ for (const r of data.riskHotspots) {
170
+ // No `()` after the offender — it can be a class/variable, not a function.
171
+ // "references" (not "callers") because fanIn is reference-granular, unlike
172
+ // the distinct-caller blast count; a trailing `+` flags a capped walk.
173
+ // The offender's complexity is appended tag-less (fanIn/blast are already
174
+ // rendered tag-less under the one [behavioral] heading); omitted entirely
175
+ // for a trivial offender so the line keeps its churn × coupling shape.
176
+ const complexity = formatComplexityMetrics(r);
177
+ lines.push(`- ${r.file} — ${r.symbol} — ${r.churn} ${plural('commit', r.churn)} × ` +
178
+ `${r.fanIn} ${plural('reference', r.fanIn)} ` +
179
+ `(blast radius ${r.blast.callers}${r.blast.truncated ? '+' : ''} across ${r.blast.files} ${plural('file', r.blast.files)})` +
180
+ (complexity ? ` — ${complexity}` : ''));
181
+ }
182
+ }
183
+ }
184
+ function formatBranchLine(s) {
185
+ if (s.defaultBranch !== null && s.branch === s.defaultBranch) {
186
+ return `- ${s.branch} (default branch)`;
187
+ }
188
+ if (s.defaultBranch === null || s.ahead === null) {
189
+ return `- ${s.branch}`;
190
+ }
191
+ const files = s.changedFiles === null
192
+ ? ''
193
+ : `, ${s.changedFiles.length} ${plural('file', s.changedFiles.length)} changed on branch`;
194
+ return `- ${s.branch} — ${s.ahead} ${plural('commit', s.ahead)} ahead of ${s.defaultBranch}${files}`;
195
+ }
196
+ function displayLanguage(lang) {
197
+ return LANGUAGE_DISPLAY[lang] ?? capitalize(lang);
198
+ }
199
+ function topUnknownExtensions(files, limit) {
200
+ const counts = new Map();
201
+ for (const f of files) {
202
+ if (f.language !== LANGUAGE_UNKNOWN)
203
+ continue;
204
+ const ext = extname(f.path).toLowerCase();
205
+ const key = ext === '' ? '(no ext)' : ext;
206
+ counts.set(key, (counts.get(key) ?? 0) + 1);
207
+ }
208
+ return [...counts.entries()]
209
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
210
+ .slice(0, limit)
211
+ .map(([ext]) => ext);
212
+ }
213
+ function capitalize(s) {
214
+ return s.length === 0 ? s : s[0].toUpperCase() + s.slice(1);
215
+ }
216
+ function pluralKind(kind, count) {
217
+ return count === 1 ? kind : KIND_PLURAL[kind];
218
+ }
219
+ function sortedKindCounts(kinds) {
220
+ return Object.entries(kinds)
221
+ .filter(([, c]) => c > 0)
222
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
223
+ }
224
+ function groupKey(filePath) {
225
+ const parts = filePath.split('/');
226
+ if (parts.length <= 1)
227
+ return '(root)';
228
+ const dirParts = parts.slice(0, -1);
229
+ if (dirParts.length === 1)
230
+ return dirParts[0];
231
+ return dirParts.slice(0, 2).join('/');
232
+ }
233
+ function groupFilesByDirectory(files, index) {
234
+ const groups = new Map();
235
+ for (const f of files) {
236
+ const key = groupKey(f.path);
237
+ let g = groups.get(key);
238
+ if (!g) {
239
+ g = { fileCount: 0, kinds: zeroSymbolsByKind() };
240
+ groups.set(key, g);
241
+ }
242
+ g.fileCount++;
243
+ for (const sym of index.getSymbolsInFile(f.path)) {
244
+ g.kinds[sym.kind]++;
245
+ }
246
+ }
247
+ return [...groups.entries()]
248
+ .sort((a, b) => b[1].fileCount - a[1].fileCount || a[0].localeCompare(b[0]))
249
+ .slice(0, MAX_DIR_GROUPS)
250
+ .map(([dir, g]) => ({
251
+ dir: dir === '(root)' ? '(root)' : `${dir}/`,
252
+ fileCount: g.fileCount,
253
+ topKinds: topKinds(g.kinds, MAX_KINDS_PER_GROUP),
254
+ }));
255
+ }
256
+ function topKinds(kinds, limit) {
257
+ return sortedKindCounts(kinds)
258
+ .slice(0, limit)
259
+ .map(([kind, count]) => ({ kind, count }));
260
+ }
261
+ function formatSymbolKinds(kinds) {
262
+ return sortedKindCounts(kinds)
263
+ .map(([k, c]) => `${c} ${pluralKind(k, c)}`)
264
+ .join(', ');
265
+ }
266
+ const PY_MAIN_GUARD_RE = /^if\s+__name__\s*==\s*['"]__main__['"]/m;
267
+ const PY_FILE_SCAN_CAP = 100;
268
+ async function collectPythonMainGuards(pyFiles, projectRoot, slots) {
269
+ // Re-sort shallow-first here rather than trusting scan order: CodeIndex
270
+ // Map iteration order shifts as updateFile (remove + re-add) moves
271
+ // modified files to the end, so a root manage.py edited post-scan can
272
+ // land beyond PY_FILE_SCAN_CAP without this.
273
+ const sorted = [...pyFiles].sort(compareShallowFirst);
274
+ const limit = Math.min(sorted.length, PY_FILE_SCAN_CAP);
275
+ const reads = sorted.slice(0, limit).map(async (f) => {
276
+ try {
277
+ const content = await fs.readFile(join(projectRoot, f.path), 'utf8');
278
+ return PY_MAIN_GUARD_RE.test(content) ? f.path : null;
279
+ }
280
+ catch (err) {
281
+ const code = err?.code;
282
+ if (code !== 'ENOENT' && code !== 'EISDIR') {
283
+ log.warn(`overview: failed to read ${f.path}: ${errMsg(err)}`);
284
+ }
285
+ return null;
286
+ }
287
+ });
288
+ const results = await Promise.all(reads);
289
+ const out = [];
290
+ for (const r of results) {
291
+ if (r === null)
292
+ continue;
293
+ if (out.length >= slots)
294
+ break;
295
+ out.push(r);
296
+ }
297
+ return out;
298
+ }
299
+ function fallbackByExportCount(allFiles, index, excluded, slots) {
300
+ const ranked = [];
301
+ for (const f of allFiles) {
302
+ if (excluded.has(f.path))
303
+ continue;
304
+ const count = index
305
+ .getSymbolsInFile(f.path)
306
+ .filter(isTopLevelExport).length;
307
+ if (count > 0)
308
+ ranked.push({ path: f.path, count });
309
+ }
310
+ ranked.sort((a, b) => b.count - a.count || a.path.localeCompare(b.path));
311
+ return ranked.slice(0, slots).map((r) => r.path);
312
+ }
313
+ function insertCandidates(candidates, paths, index) {
314
+ for (const p of paths) {
315
+ if (candidates.has(p))
316
+ continue;
317
+ if (candidates.size >= MAX_ENTRY_POINTS)
318
+ break;
319
+ candidates.set(p, makeEntry(p, index));
320
+ }
321
+ }
322
+ async function collectEntryPoints(index, allFiles, projectRoot) {
323
+ const candidates = new Map();
324
+ // Restrict to parseable files so package.json self-export patterns like
325
+ // `"./package.json": "./package.json"` don't resolve and pollute the list.
326
+ const indexed = new Set();
327
+ // Split entry-name files: non-barrels (main.py, server.ts) take
328
+ // precedence over barrels (__init__.py, index.ts). Without the split,
329
+ // 15+ barrels in a monorepo saturate the cap and prevent the main-guard
330
+ // tier from ever surfacing manage.py-style scripts. Sort each sub-tier
331
+ // shallow-first; otherwise 15+ packages/*/server.ts crowds out src/server.ts.
332
+ const nonBarrelEntries = [];
333
+ const barrelEntries = [];
334
+ for (const f of allFiles) {
335
+ if (f.language !== LANGUAGE_UNKNOWN)
336
+ indexed.add(f.path);
337
+ const name = basename(f.path);
338
+ if (!ENTRY_POINT_FILENAME_RE.test(name))
339
+ continue;
340
+ if (BARREL_ENTRY_RE.test(name))
341
+ barrelEntries.push(f);
342
+ else
343
+ nonBarrelEntries.push(f);
344
+ }
345
+ nonBarrelEntries.sort(compareShallowFirst);
346
+ barrelEntries.sort(compareShallowFirst);
347
+ // package.json entries are inserted first so they survive cap-clipping
348
+ // in barrel-heavy monorepos.
349
+ const pkgPaths = await readPackageJsonEntries(projectRoot);
350
+ const resolvedPkg = pkgPaths
351
+ .map((p) => resolveIndexedPath(p, indexed))
352
+ .filter((r) => r !== undefined);
353
+ insertCandidates(candidates, resolvedPkg, index);
354
+ if (candidates.size < MAX_ENTRY_POINTS) {
355
+ insertCandidates(candidates, nonBarrelEntries.map((f) => f.path), index);
356
+ }
357
+ // Catches Python scripts (e.g. Django manage.py, train.py) whose names
358
+ // don't match ENTRY_POINT_FILENAME_RE but use the main-guard idiom.
359
+ if (candidates.size < MAX_ENTRY_POINTS) {
360
+ const pyCandidates = allFiles.filter((f) => f.language === 'python' && !candidates.has(f.path));
361
+ const guarded = await collectPythonMainGuards(pyCandidates, projectRoot, MAX_ENTRY_POINTS - candidates.size);
362
+ insertCandidates(candidates, guarded, index);
363
+ }
364
+ if (candidates.size < MAX_ENTRY_POINTS) {
365
+ insertCandidates(candidates, barrelEntries.map((f) => f.path), index);
366
+ }
367
+ // Fallback for library projects: rank remaining files by top-level
368
+ // export count so a public API in src/foo.ts surfaces even without a
369
+ // package.json main, entry-named file, or Python main guard.
370
+ if (candidates.size < MAX_ENTRY_POINTS) {
371
+ const fallback = fallbackByExportCount(allFiles, index, candidates, MAX_ENTRY_POINTS - candidates.size);
372
+ insertCandidates(candidates, fallback, index);
373
+ }
374
+ return [...candidates.values()].slice(0, MAX_ENTRY_POINTS);
375
+ }
376
+ function isTopLevelExport(s) {
377
+ return s.exported && !isClassMember(s);
378
+ }
379
+ function makeEntry(file, index) {
380
+ const exported = index.getSymbolsInFile(file).filter(isTopLevelExport);
381
+ if (exported.length === 0)
382
+ return { file };
383
+ if (exported.length === 1) {
384
+ const e = exported[0];
385
+ return { file, symbol: e.name, line: e.startLine };
386
+ }
387
+ return {
388
+ file,
389
+ summary: `exports ${exported.length} ${plural('symbol', exported.length)}`,
390
+ };
391
+ }
392
+ const RESOLVE_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'];
393
+ function resolveIndexedPath(p, indexed) {
394
+ // "." and "" both denote the project root — collapse to empty prefix.
395
+ const cleaned = p === '.' || p === '' ? '' : p.replace(/\/$/, '');
396
+ if (indexed.has(cleaned))
397
+ return cleaned;
398
+ const bases = [cleaned, cleaned ? `${cleaned}/index` : 'index'];
399
+ for (const base of bases) {
400
+ for (const ext of RESOLVE_EXTENSIONS) {
401
+ const candidate = base + ext;
402
+ if (indexed.has(candidate))
403
+ return candidate;
404
+ }
405
+ }
406
+ return undefined;
407
+ }
408
+ async function readPackageJsonEntries(projectRoot) {
409
+ const pkgPath = join(projectRoot, 'package.json');
410
+ let raw;
411
+ try {
412
+ raw = await fs.readFile(pkgPath, 'utf8');
413
+ }
414
+ catch (err) {
415
+ const code = err?.code;
416
+ if (code !== 'ENOENT') {
417
+ log.warn(`overview: failed to read ${pkgPath}: ${errMsg(err)}`);
418
+ }
419
+ return [];
420
+ }
421
+ let parsed;
422
+ try {
423
+ parsed = JSON.parse(raw);
424
+ }
425
+ catch (err) {
426
+ log.warn(`overview: failed to parse ${pkgPath}: ${errMsg(err)}`);
427
+ return [];
428
+ }
429
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
430
+ return [];
431
+ }
432
+ const pkg = parsed;
433
+ const out = [];
434
+ if (typeof pkg.main === 'string' && pkg.main.length > 0) {
435
+ out.push(normalizeRelative(pkg.main));
436
+ }
437
+ if (typeof pkg.bin === 'string' && pkg.bin.length > 0) {
438
+ out.push(normalizeRelative(pkg.bin));
439
+ }
440
+ else if (pkg.bin &&
441
+ typeof pkg.bin === 'object' &&
442
+ !Array.isArray(pkg.bin)) {
443
+ for (const v of Object.values(pkg.bin)) {
444
+ if (typeof v === 'string' && v.length > 0) {
445
+ out.push(normalizeRelative(v));
446
+ }
447
+ }
448
+ }
449
+ if (pkg.exports !== undefined) {
450
+ collectExportPaths(pkg.exports, out);
451
+ }
452
+ return out;
453
+ }
454
+ function collectExportPaths(node, out) {
455
+ if (typeof node === 'string') {
456
+ // Skip wildcard subpath patterns ("./features/*" → "./src/features/*.js")
457
+ // since they require glob expansion against the file system.
458
+ if (node.startsWith('./') && !node.includes('*')) {
459
+ out.push(normalizeRelative(node));
460
+ }
461
+ return;
462
+ }
463
+ if (Array.isArray(node)) {
464
+ for (const v of node)
465
+ collectExportPaths(v, out);
466
+ return;
467
+ }
468
+ if (typeof node === 'object' && node !== null) {
469
+ // Skip the `types` exports condition: it points to .d.ts metadata,
470
+ // not runtime entry points. Subpath keys always start with "." per
471
+ // the Node.js exports spec, so a bare "types" key is unambiguously
472
+ // the type-declaration condition.
473
+ for (const [k, v] of Object.entries(node)) {
474
+ if (k === 'types')
475
+ continue;
476
+ collectExportPaths(v, out);
477
+ }
478
+ }
479
+ }
480
+ function normalizeRelative(p) {
481
+ return p.replace(/\\/g, '/').replace(/^\.\/+/, '');
482
+ }