tokenlean 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.
@@ -0,0 +1,583 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * tl-exports - Show public API surface of a module
5
+ *
6
+ * Lists what a file or package exports - functions, classes, types, constants.
7
+ * Perfect for understanding what a module provides without reading implementation.
8
+ *
9
+ * Usage: tl-exports <file-or-dir> [--types-only]
10
+ */
11
+
12
+ // Prompt info for tl-prompt
13
+ if (process.argv.includes('--prompt')) {
14
+ console.log(JSON.stringify({
15
+ name: 'tl-exports',
16
+ desc: 'Show public API surface of a module',
17
+ when: 'before-read',
18
+ example: 'tl-exports src/utils/'
19
+ }));
20
+ process.exit(0);
21
+ }
22
+
23
+ import { readFileSync, existsSync, readdirSync, statSync } from 'fs';
24
+ import { basename, extname, join, relative, dirname } from 'path';
25
+ import {
26
+ createOutput,
27
+ parseCommonArgs,
28
+ estimateTokens,
29
+ formatTokens,
30
+ COMMON_OPTIONS_HELP
31
+ } from '../src/output.mjs';
32
+ import { findProjectRoot, shouldSkip } from '../src/project.mjs';
33
+
34
+ const HELP = `
35
+ tl-exports - Show public API surface of a module
36
+
37
+ Usage: tl-exports <file-or-dir> [options]
38
+
39
+ Options:
40
+ --types-only, -t Show only type exports (interfaces, types, enums)
41
+ --values-only, -v Show only value exports (functions, classes, constants)
42
+ --with-signatures Include function signatures (more detail)
43
+ --tree Show as import tree (what to import from where)
44
+ ${COMMON_OPTIONS_HELP}
45
+
46
+ Examples:
47
+ tl-exports src/utils.ts # Exports from single file
48
+ tl-exports src/utils/ # Exports from directory (finds index)
49
+ tl-exports src/ --tree # Show as import tree
50
+ tl-exports src/api.ts -t # Types only
51
+ tl-exports src/ -j # JSON output
52
+
53
+ Supported: JavaScript, TypeScript, Python, Go
54
+ `;
55
+
56
+ // ─────────────────────────────────────────────────────────────
57
+ // Language Detection
58
+ // ─────────────────────────────────────────────────────────────
59
+
60
+ const JS_EXTENSIONS = new Set(['.js', '.mjs', '.cjs', '.jsx', '.ts', '.tsx', '.mts']);
61
+ const PY_EXTENSIONS = new Set(['.py']);
62
+ const GO_EXTENSIONS = new Set(['.go']);
63
+
64
+ function detectLanguage(filePath) {
65
+ const ext = extname(filePath).toLowerCase();
66
+ if (JS_EXTENSIONS.has(ext)) return 'js';
67
+ if (PY_EXTENSIONS.has(ext)) return 'python';
68
+ if (GO_EXTENSIONS.has(ext)) return 'go';
69
+ return null;
70
+ }
71
+
72
+ function isSourceFile(filePath) {
73
+ return detectLanguage(filePath) !== null;
74
+ }
75
+
76
+ // ─────────────────────────────────────────────────────────────
77
+ // JavaScript/TypeScript Export Extraction
78
+ // ─────────────────────────────────────────────────────────────
79
+
80
+ function extractJsExports(content, withSignatures = false) {
81
+ const exports = {
82
+ types: [], // interfaces, type aliases, enums
83
+ functions: [], // functions, arrow functions
84
+ classes: [], // classes
85
+ constants: [], // const exports
86
+ reexports: [], // export { x } from './y' or export * from './z'
87
+ default: null // default export
88
+ };
89
+
90
+ const lines = content.split('\n');
91
+
92
+ for (let i = 0; i < lines.length; i++) {
93
+ const line = lines[i];
94
+ const trimmed = line.trim();
95
+
96
+ // Skip non-export lines
97
+ if (!trimmed.startsWith('export ')) continue;
98
+
99
+ // Re-exports: export { x, y } from './module'
100
+ const reexportMatch = trimmed.match(/^export\s+\{([^}]+)\}\s+from\s+['"]([^'"]+)['"]/);
101
+ if (reexportMatch) {
102
+ const names = reexportMatch[1].split(',').map(n => {
103
+ const parts = n.trim().split(/\s+as\s+/);
104
+ return parts.length > 1 ? `${parts[0].trim()} as ${parts[1].trim()}` : parts[0].trim();
105
+ });
106
+ exports.reexports.push({ names, from: reexportMatch[2] });
107
+ continue;
108
+ }
109
+
110
+ // Star re-export: export * from './module'
111
+ const starReexportMatch = trimmed.match(/^export\s+\*\s+(?:as\s+(\w+)\s+)?from\s+['"]([^'"]+)['"]/);
112
+ if (starReexportMatch) {
113
+ exports.reexports.push({
114
+ names: [starReexportMatch[1] ? `* as ${starReexportMatch[1]}` : '*'],
115
+ from: starReexportMatch[2]
116
+ });
117
+ continue;
118
+ }
119
+
120
+ // Default export
121
+ if (trimmed.startsWith('export default ')) {
122
+ const defaultMatch = trimmed.match(/^export\s+default\s+(?:(class|function|async\s+function)\s+)?(\w+)?/);
123
+ if (defaultMatch) {
124
+ exports.default = defaultMatch[2] || defaultMatch[1] || 'default';
125
+ }
126
+ continue;
127
+ }
128
+
129
+ // Type exports: interface, type, enum
130
+ const typeMatch = trimmed.match(/^export\s+(interface|type|enum|const\s+enum)\s+(\w+)/);
131
+ if (typeMatch) {
132
+ const kind = typeMatch[1].replace('const ', '');
133
+ const name = typeMatch[2];
134
+
135
+ if (withSignatures && kind === 'type') {
136
+ // Get full type definition for type aliases
137
+ const fullDef = extractTypeDefinition(lines, i);
138
+ exports.types.push({ name, kind, signature: fullDef });
139
+ } else {
140
+ exports.types.push({ name, kind });
141
+ }
142
+ continue;
143
+ }
144
+
145
+ // Function exports
146
+ const funcMatch = trimmed.match(/^export\s+(async\s+)?function\s+(\w+)\s*(<[^>]+>)?\s*\(([^)]*)\)(?:\s*:\s*([^{]+))?/);
147
+ if (funcMatch) {
148
+ const name = funcMatch[2];
149
+ const isAsync = !!funcMatch[1];
150
+ const generics = funcMatch[3] || '';
151
+ const params = funcMatch[4] || '';
152
+ const returnType = funcMatch[5]?.trim() || '';
153
+
154
+ if (withSignatures) {
155
+ let sig = `${isAsync ? 'async ' : ''}function ${name}${generics}(${params})`;
156
+ if (returnType) sig += `: ${returnType}`;
157
+ exports.functions.push({ name, signature: sig });
158
+ } else {
159
+ exports.functions.push({ name });
160
+ }
161
+ continue;
162
+ }
163
+
164
+ // Class exports
165
+ const classMatch = trimmed.match(/^export\s+(abstract\s+)?class\s+(\w+)(\s*<[^>]+>)?(\s+extends\s+\w+)?(\s+implements\s+[^{]+)?/);
166
+ if (classMatch) {
167
+ const name = classMatch[2];
168
+ const isAbstract = !!classMatch[1];
169
+ const generics = classMatch[3]?.trim() || '';
170
+ const extendsClause = classMatch[4]?.trim() || '';
171
+ const implementsClause = classMatch[5]?.trim() || '';
172
+
173
+ if (withSignatures) {
174
+ let sig = `${isAbstract ? 'abstract ' : ''}class ${name}${generics}`;
175
+ if (extendsClause) sig += ` ${extendsClause}`;
176
+ if (implementsClause) sig += ` ${implementsClause}`;
177
+ exports.classes.push({ name, signature: sig });
178
+ } else {
179
+ exports.classes.push({ name });
180
+ }
181
+ continue;
182
+ }
183
+
184
+ // Const exports (including arrow functions)
185
+ const constMatch = trimmed.match(/^export\s+const\s+(\w+)(?:\s*:\s*([^=]+))?\s*=/);
186
+ if (constMatch) {
187
+ const name = constMatch[1];
188
+ const typeAnnotation = constMatch[2]?.trim();
189
+
190
+ // Check if it's an arrow function
191
+ const isArrowFunc = trimmed.includes('=>') || (typeAnnotation && typeAnnotation.includes('=>'));
192
+
193
+ if (isArrowFunc) {
194
+ if (withSignatures && typeAnnotation) {
195
+ exports.functions.push({ name, signature: `const ${name}: ${typeAnnotation}` });
196
+ } else {
197
+ exports.functions.push({ name });
198
+ }
199
+ } else {
200
+ if (withSignatures && typeAnnotation) {
201
+ exports.constants.push({ name, type: typeAnnotation });
202
+ } else {
203
+ exports.constants.push({ name });
204
+ }
205
+ }
206
+ continue;
207
+ }
208
+ }
209
+
210
+ return exports;
211
+ }
212
+
213
+ function extractTypeDefinition(lines, startLine) {
214
+ const line = lines[startLine].trim();
215
+ // Simple one-line type
216
+ if (line.endsWith(';')) {
217
+ return line.replace(/^export\s+/, '');
218
+ }
219
+ // Multi-line - just return first line for brevity
220
+ return line.replace(/^export\s+/, '').replace(/\s*=\s*$/, ' = ...');
221
+ }
222
+
223
+ // ─────────────────────────────────────────────────────────────
224
+ // Python Export Extraction
225
+ // ─────────────────────────────────────────────────────────────
226
+
227
+ function extractPythonExports(content) {
228
+ const exports = {
229
+ functions: [],
230
+ classes: [],
231
+ constants: [],
232
+ all: null // __all__ list
233
+ };
234
+
235
+ const lines = content.split('\n');
236
+
237
+ // Check for __all__
238
+ const allMatch = content.match(/__all__\s*=\s*\[([^\]]+)\]/);
239
+ if (allMatch) {
240
+ exports.all = allMatch[1]
241
+ .split(',')
242
+ .map(s => s.trim().replace(/['"]/g, ''))
243
+ .filter(Boolean);
244
+ }
245
+
246
+ for (const line of lines) {
247
+ const trimmed = line.trim();
248
+
249
+ // Top-level functions (not indented)
250
+ if (!line.startsWith(' ') && !line.startsWith('\t')) {
251
+ const funcMatch = trimmed.match(/^(?:async\s+)?def\s+(\w+)/);
252
+ if (funcMatch && !funcMatch[1].startsWith('_')) {
253
+ exports.functions.push({ name: funcMatch[1] });
254
+ }
255
+
256
+ const classMatch = trimmed.match(/^class\s+(\w+)/);
257
+ if (classMatch && !classMatch[1].startsWith('_')) {
258
+ exports.classes.push({ name: classMatch[1] });
259
+ }
260
+
261
+ // Constants (UPPER_CASE at module level)
262
+ const constMatch = trimmed.match(/^([A-Z][A-Z0-9_]*)\s*=/);
263
+ if (constMatch) {
264
+ exports.constants.push({ name: constMatch[1] });
265
+ }
266
+ }
267
+ }
268
+
269
+ return exports;
270
+ }
271
+
272
+ // ─────────────────────────────────────────────────────────────
273
+ // Go Export Extraction
274
+ // ─────────────────────────────────────────────────────────────
275
+
276
+ function extractGoExports(content) {
277
+ const exports = {
278
+ functions: [],
279
+ types: [],
280
+ constants: [],
281
+ variables: []
282
+ };
283
+
284
+ const lines = content.split('\n');
285
+
286
+ for (const line of lines) {
287
+ const trimmed = line.trim();
288
+
289
+ // Exported functions (start with uppercase)
290
+ const funcMatch = trimmed.match(/^func\s+(?:\([^)]+\)\s+)?([A-Z]\w*)\s*\(/);
291
+ if (funcMatch) {
292
+ exports.functions.push({ name: funcMatch[1] });
293
+ }
294
+
295
+ // Exported types
296
+ const typeMatch = trimmed.match(/^type\s+([A-Z]\w*)\s+(struct|interface)/);
297
+ if (typeMatch) {
298
+ exports.types.push({ name: typeMatch[1], kind: typeMatch[2] });
299
+ }
300
+
301
+ // Exported constants
302
+ const constMatch = trimmed.match(/^const\s+([A-Z]\w*)/);
303
+ if (constMatch) {
304
+ exports.constants.push({ name: constMatch[1] });
305
+ }
306
+
307
+ // Exported variables
308
+ const varMatch = trimmed.match(/^var\s+([A-Z]\w*)/);
309
+ if (varMatch) {
310
+ exports.variables.push({ name: varMatch[1] });
311
+ }
312
+ }
313
+
314
+ return exports;
315
+ }
316
+
317
+ // ─────────────────────────────────────────────────────────────
318
+ // File Discovery
319
+ // ─────────────────────────────────────────────────────────────
320
+
321
+ function findSourceFiles(dir, files = []) {
322
+ const entries = readdirSync(dir, { withFileTypes: true });
323
+
324
+ for (const entry of entries) {
325
+ const fullPath = join(dir, entry.name);
326
+
327
+ if (entry.isDirectory()) {
328
+ if (!shouldSkip(entry.name, true)) {
329
+ findSourceFiles(fullPath, files);
330
+ }
331
+ } else if (entry.isFile()) {
332
+ if (!shouldSkip(entry.name, false) && isSourceFile(fullPath)) {
333
+ files.push(fullPath);
334
+ }
335
+ }
336
+ }
337
+
338
+ return files;
339
+ }
340
+
341
+ function findIndexFile(dir) {
342
+ const indexNames = ['index.ts', 'index.tsx', 'index.js', 'index.mjs', 'mod.ts', '__init__.py'];
343
+
344
+ for (const name of indexNames) {
345
+ const indexPath = join(dir, name);
346
+ if (existsSync(indexPath)) {
347
+ return indexPath;
348
+ }
349
+ }
350
+
351
+ return null;
352
+ }
353
+
354
+ // ─────────────────────────────────────────────────────────────
355
+ // Formatting
356
+ // ─────────────────────────────────────────────────────────────
357
+
358
+ function formatExports(exports, out, options = {}) {
359
+ const { typesOnly, valuesOnly, withSignatures, relPath } = options;
360
+
361
+ let count = 0;
362
+
363
+ // Default export
364
+ if (exports.default && !typesOnly) {
365
+ out.add(` default → ${exports.default}`);
366
+ count++;
367
+ }
368
+
369
+ // Types
370
+ if (!valuesOnly && exports.types?.length > 0) {
371
+ for (const t of exports.types) {
372
+ if (withSignatures && t.signature) {
373
+ out.add(` ${t.kind} ${t.name}`);
374
+ out.add(` ${t.signature}`);
375
+ } else {
376
+ out.add(` ${t.kind} ${t.name}`);
377
+ }
378
+ count++;
379
+ }
380
+ }
381
+
382
+ // Functions
383
+ if (!typesOnly && exports.functions?.length > 0) {
384
+ for (const f of exports.functions) {
385
+ if (withSignatures && f.signature) {
386
+ out.add(` fn ${f.name}`);
387
+ out.add(` ${f.signature}`);
388
+ } else {
389
+ out.add(` fn ${f.name}`);
390
+ }
391
+ count++;
392
+ }
393
+ }
394
+
395
+ // Classes
396
+ if (!typesOnly && exports.classes?.length > 0) {
397
+ for (const c of exports.classes) {
398
+ if (withSignatures && c.signature) {
399
+ out.add(` class ${c.name}`);
400
+ out.add(` ${c.signature}`);
401
+ } else {
402
+ out.add(` class ${c.name}`);
403
+ }
404
+ count++;
405
+ }
406
+ }
407
+
408
+ // Constants
409
+ if (!typesOnly && exports.constants?.length > 0) {
410
+ for (const c of exports.constants) {
411
+ if (withSignatures && c.type) {
412
+ out.add(` const ${c.name}: ${c.type}`);
413
+ } else {
414
+ out.add(` const ${c.name}`);
415
+ }
416
+ count++;
417
+ }
418
+ }
419
+
420
+ // Variables (Go)
421
+ if (!typesOnly && exports.variables?.length > 0) {
422
+ for (const v of exports.variables) {
423
+ out.add(` var ${v.name}`);
424
+ count++;
425
+ }
426
+ }
427
+
428
+ // Re-exports
429
+ if (exports.reexports?.length > 0) {
430
+ for (const r of exports.reexports) {
431
+ out.add(` ↳ { ${r.names.join(', ')} } from '${r.from}'`);
432
+ count += r.names.length;
433
+ }
434
+ }
435
+
436
+ return count;
437
+ }
438
+
439
+ function formatAsTree(fileExports, out, projectRoot) {
440
+ // Group by directory
441
+ const byDir = new Map();
442
+
443
+ for (const { file, exports } of fileExports) {
444
+ const dir = dirname(file);
445
+ if (!byDir.has(dir)) {
446
+ byDir.set(dir, []);
447
+ }
448
+ byDir.get(dir).push({ file: basename(file), exports });
449
+ }
450
+
451
+ for (const [dir, files] of byDir) {
452
+ out.add(`📁 ${dir || '.'}/`);
453
+
454
+ for (const { file, exports } of files) {
455
+ const allExports = [];
456
+
457
+ if (exports.default) allExports.push(`default`);
458
+ exports.types?.forEach(t => allExports.push(t.name));
459
+ exports.functions?.forEach(f => allExports.push(f.name));
460
+ exports.classes?.forEach(c => allExports.push(c.name));
461
+ exports.constants?.forEach(c => allExports.push(c.name));
462
+
463
+ if (allExports.length > 0) {
464
+ out.add(` ${file}: { ${allExports.join(', ')} }`);
465
+ }
466
+ }
467
+ out.blank();
468
+ }
469
+ }
470
+
471
+ // ─────────────────────────────────────────────────────────────
472
+ // Main
473
+ // ─────────────────────────────────────────────────────────────
474
+
475
+ const args = process.argv.slice(2);
476
+ const options = parseCommonArgs(args);
477
+ const typesOnly = options.remaining.includes('--types-only') || options.remaining.includes('-t');
478
+ const valuesOnly = options.remaining.includes('--values-only') || options.remaining.includes('-v');
479
+ const withSignatures = options.remaining.includes('--with-signatures');
480
+ const treeMode = options.remaining.includes('--tree');
481
+ const targetPath = options.remaining.find(a => !a.startsWith('-'));
482
+
483
+ if (options.help || !targetPath) {
484
+ console.log(HELP);
485
+ process.exit(options.help ? 0 : 1);
486
+ }
487
+
488
+ if (!existsSync(targetPath)) {
489
+ console.error(`Path not found: ${targetPath}`);
490
+ process.exit(1);
491
+ }
492
+
493
+ const projectRoot = findProjectRoot();
494
+ const out = createOutput(options);
495
+
496
+ const stat = statSync(targetPath);
497
+ let files = [];
498
+
499
+ if (stat.isDirectory()) {
500
+ // Check for index file first
501
+ const indexFile = findIndexFile(targetPath);
502
+ if (indexFile && !treeMode) {
503
+ files = [indexFile];
504
+ } else {
505
+ files = findSourceFiles(targetPath);
506
+ }
507
+ } else {
508
+ files = [targetPath];
509
+ }
510
+
511
+ if (files.length === 0) {
512
+ console.error('No source files found');
513
+ process.exit(1);
514
+ }
515
+
516
+ const allFileExports = [];
517
+ let totalExports = 0;
518
+
519
+ for (const filePath of files) {
520
+ const content = readFileSync(filePath, 'utf-8');
521
+ const lang = detectLanguage(filePath);
522
+ const relPath = relative(projectRoot, filePath);
523
+
524
+ let exports;
525
+ switch (lang) {
526
+ case 'js':
527
+ exports = extractJsExports(content, withSignatures);
528
+ break;
529
+ case 'python':
530
+ exports = extractPythonExports(content);
531
+ break;
532
+ case 'go':
533
+ exports = extractGoExports(content);
534
+ break;
535
+ default:
536
+ continue;
537
+ }
538
+
539
+ // Count exports
540
+ let count = 0;
541
+ if (exports.default) count++;
542
+ count += exports.types?.length || 0;
543
+ count += exports.functions?.length || 0;
544
+ count += exports.classes?.length || 0;
545
+ count += exports.constants?.length || 0;
546
+ count += exports.variables?.length || 0;
547
+ exports.reexports?.forEach(r => count += r.names.length);
548
+
549
+ if (count === 0) continue;
550
+
551
+ totalExports += count;
552
+ allFileExports.push({ file: relPath, exports, count });
553
+ }
554
+
555
+ // Set JSON data
556
+ out.setData('files', allFileExports.map(({ file, exports }) => ({ file, exports })));
557
+ out.setData('totalExports', totalExports);
558
+
559
+ // Output
560
+ if (treeMode) {
561
+ out.header('Export Tree:');
562
+ out.blank();
563
+ formatAsTree(allFileExports, out, projectRoot);
564
+ } else {
565
+ for (const { file, exports, count } of allFileExports) {
566
+ if (allFileExports.length > 1) {
567
+ out.add(`📦 ${file} (${count} exports)`);
568
+ } else {
569
+ out.header(`📦 ${file} (${count} exports)`);
570
+ out.blank();
571
+ }
572
+
573
+ formatExports(exports, out, { typesOnly, valuesOnly, withSignatures, relPath: file });
574
+ out.blank();
575
+ }
576
+ }
577
+
578
+ // Summary
579
+ if (!options.quiet && allFileExports.length > 0) {
580
+ out.add(`Total: ${totalExports} exports from ${allFileExports.length} file(s)`);
581
+ }
582
+
583
+ out.print();