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,441 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * tl-types - Extract TypeScript types, interfaces, and enums with full definitions
5
+ *
6
+ * Unlike tl-symbols (which shows signatures only), this extracts complete type
7
+ * definitions including all properties. Perfect for understanding data shapes
8
+ * without reading implementation code.
9
+ *
10
+ * Usage: tl-types <file-or-dir> [--exports-only]
11
+ */
12
+
13
+ // Prompt info for tl-prompt
14
+ if (process.argv.includes('--prompt')) {
15
+ console.log(JSON.stringify({
16
+ name: 'tl-types',
17
+ desc: 'Extract full TypeScript type definitions',
18
+ when: 'before-read',
19
+ example: 'tl-types src/types/'
20
+ }));
21
+ process.exit(0);
22
+ }
23
+
24
+ import { readFileSync, existsSync, readdirSync, statSync } from 'fs';
25
+ import { basename, extname, join, relative } from 'path';
26
+ import {
27
+ createOutput,
28
+ parseCommonArgs,
29
+ estimateTokens,
30
+ formatTokens,
31
+ COMMON_OPTIONS_HELP
32
+ } from '../src/output.mjs';
33
+ import { findProjectRoot, shouldSkip } from '../src/project.mjs';
34
+
35
+ const HELP = `
36
+ tl-types - Extract TypeScript types, interfaces, and enums with full definitions
37
+
38
+ Usage: tl-types <file-or-dir> [options]
39
+
40
+ Options:
41
+ --exports-only, -e Show only exported types
42
+ --no-comments Strip comments from type definitions
43
+ --flat Don't show file headers (for single output)
44
+ ${COMMON_OPTIONS_HELP}
45
+
46
+ Examples:
47
+ tl-types src/types.ts # All types from file
48
+ tl-types src/types/ # All types from directory
49
+ tl-types src/ -e # Exported types only
50
+ tl-types src/api.ts -l 50 # Limit to 50 lines
51
+ tl-types src/ -j # JSON output
52
+
53
+ Extracts:
54
+ - interface definitions (with all properties)
55
+ - type aliases (full definition)
56
+ - enum definitions (with all values)
57
+ - Generic type parameters
58
+ `;
59
+
60
+ // ─────────────────────────────────────────────────────────────
61
+ // TypeScript Type Extraction
62
+ // ─────────────────────────────────────────────────────────────
63
+
64
+ function extractTypes(content, options = {}) {
65
+ const { exportsOnly = false, stripComments = false } = options;
66
+ const types = {
67
+ interfaces: [],
68
+ typeAliases: [],
69
+ enums: []
70
+ };
71
+
72
+ const lines = content.split('\n');
73
+ let i = 0;
74
+
75
+ while (i < lines.length) {
76
+ const line = lines[i];
77
+ const trimmed = line.trim();
78
+
79
+ // Skip if exports only and not exported
80
+ const isExported = trimmed.startsWith('export ');
81
+ if (exportsOnly && !isExported) {
82
+ i++;
83
+ continue;
84
+ }
85
+
86
+ // Interface
87
+ const interfaceMatch = trimmed.match(/^(export\s+)?(interface)\s+(\w+)(\s*<[^>]+>)?(\s+extends\s+[^{]+)?/);
88
+ if (interfaceMatch) {
89
+ const extracted = extractBlock(lines, i, stripComments);
90
+ types.interfaces.push({
91
+ name: interfaceMatch[3],
92
+ exported: !!interfaceMatch[1],
93
+ definition: extracted.content
94
+ });
95
+ i = extracted.endLine + 1;
96
+ continue;
97
+ }
98
+
99
+ // Type alias
100
+ const typeMatch = trimmed.match(/^(export\s+)?type\s+(\w+)(\s*<[^>]+>)?\s*=/);
101
+ if (typeMatch) {
102
+ const extracted = extractTypeAlias(lines, i, stripComments);
103
+ types.typeAliases.push({
104
+ name: typeMatch[2],
105
+ exported: !!typeMatch[1],
106
+ definition: extracted.content
107
+ });
108
+ i = extracted.endLine + 1;
109
+ continue;
110
+ }
111
+
112
+ // Enum
113
+ const enumMatch = trimmed.match(/^(export\s+)?(const\s+)?enum\s+(\w+)/);
114
+ if (enumMatch) {
115
+ const extracted = extractBlock(lines, i, stripComments);
116
+ types.enums.push({
117
+ name: enumMatch[3],
118
+ exported: !!enumMatch[1],
119
+ isConst: !!enumMatch[2],
120
+ definition: extracted.content
121
+ });
122
+ i = extracted.endLine + 1;
123
+ continue;
124
+ }
125
+
126
+ i++;
127
+ }
128
+
129
+ return types;
130
+ }
131
+
132
+ function extractBlock(lines, startLine, stripComments) {
133
+ let content = [];
134
+ let braceDepth = 0;
135
+ let started = false;
136
+ let i = startLine;
137
+
138
+ // Collect leading comments if not stripping
139
+ if (!stripComments) {
140
+ let commentStart = startLine;
141
+ while (commentStart > 0) {
142
+ const prevLine = lines[commentStart - 1].trim();
143
+ if (prevLine.startsWith('//') || prevLine.startsWith('*') || prevLine.startsWith('/*') || prevLine === '*/') {
144
+ commentStart--;
145
+ } else if (prevLine === '') {
146
+ // Check if there's a comment block above the empty line
147
+ if (commentStart > 1 && lines[commentStart - 2].trim().startsWith('*/')) {
148
+ commentStart--;
149
+ } else {
150
+ break;
151
+ }
152
+ } else {
153
+ break;
154
+ }
155
+ }
156
+ // Add comments
157
+ for (let j = commentStart; j < startLine; j++) {
158
+ content.push(lines[j]);
159
+ }
160
+ }
161
+
162
+ while (i < lines.length) {
163
+ const line = lines[i];
164
+ const trimmed = line.trim();
165
+
166
+ // Skip comments if stripping
167
+ if (stripComments && (trimmed.startsWith('//') || trimmed.startsWith('/*') || trimmed.startsWith('*'))) {
168
+ i++;
169
+ continue;
170
+ }
171
+
172
+ content.push(line);
173
+
174
+ // Count braces
175
+ for (const char of line) {
176
+ if (char === '{') {
177
+ braceDepth++;
178
+ started = true;
179
+ } else if (char === '}') {
180
+ braceDepth--;
181
+ }
182
+ }
183
+
184
+ // End when we close the opening brace
185
+ if (started && braceDepth === 0) {
186
+ break;
187
+ }
188
+
189
+ i++;
190
+ }
191
+
192
+ return {
193
+ content: content.join('\n'),
194
+ endLine: i
195
+ };
196
+ }
197
+
198
+ function extractTypeAlias(lines, startLine, stripComments) {
199
+ let content = [];
200
+ let i = startLine;
201
+ let braceDepth = 0;
202
+ let parenDepth = 0;
203
+ let angleBracketDepth = 0;
204
+
205
+ // Collect leading comments if not stripping
206
+ if (!stripComments) {
207
+ let commentStart = startLine;
208
+ while (commentStart > 0) {
209
+ const prevLine = lines[commentStart - 1].trim();
210
+ if (prevLine.startsWith('//') || prevLine.startsWith('*') || prevLine.startsWith('/*') || prevLine === '*/') {
211
+ commentStart--;
212
+ } else if (prevLine === '') {
213
+ if (commentStart > 1 && lines[commentStart - 2].trim().startsWith('*/')) {
214
+ commentStart--;
215
+ } else {
216
+ break;
217
+ }
218
+ } else {
219
+ break;
220
+ }
221
+ }
222
+ for (let j = commentStart; j < startLine; j++) {
223
+ content.push(lines[j]);
224
+ }
225
+ }
226
+
227
+ while (i < lines.length) {
228
+ const line = lines[i];
229
+ const trimmed = line.trim();
230
+
231
+ // Skip comments if stripping
232
+ if (stripComments && (trimmed.startsWith('//') || trimmed.startsWith('/*') || trimmed.startsWith('*'))) {
233
+ i++;
234
+ continue;
235
+ }
236
+
237
+ content.push(line);
238
+
239
+ // Track nesting
240
+ for (const char of line) {
241
+ if (char === '{') braceDepth++;
242
+ else if (char === '}') braceDepth--;
243
+ else if (char === '(') parenDepth++;
244
+ else if (char === ')') parenDepth--;
245
+ else if (char === '<') angleBracketDepth++;
246
+ else if (char === '>') angleBracketDepth--;
247
+ }
248
+
249
+ // Type alias ends with semicolon or newline when all brackets closed
250
+ if (braceDepth === 0 && parenDepth === 0 && angleBracketDepth <= 0) {
251
+ if (trimmed.endsWith(';') || trimmed.endsWith('}') || trimmed.endsWith(')') || trimmed.endsWith('>')) {
252
+ break;
253
+ }
254
+ // Check if next line is a new statement
255
+ if (i + 1 < lines.length) {
256
+ const nextTrimmed = lines[i + 1].trim();
257
+ if (nextTrimmed.startsWith('export ') || nextTrimmed.startsWith('interface ') ||
258
+ nextTrimmed.startsWith('type ') || nextTrimmed.startsWith('enum ') ||
259
+ nextTrimmed.startsWith('const ') || nextTrimmed.startsWith('function ') ||
260
+ nextTrimmed.startsWith('class ') || nextTrimmed === '') {
261
+ break;
262
+ }
263
+ }
264
+ }
265
+
266
+ i++;
267
+ }
268
+
269
+ return {
270
+ content: content.join('\n'),
271
+ endLine: i
272
+ };
273
+ }
274
+
275
+ // ─────────────────────────────────────────────────────────────
276
+ // File Discovery
277
+ // ─────────────────────────────────────────────────────────────
278
+
279
+ const TS_EXTENSIONS = new Set(['.ts', '.tsx', '.mts']);
280
+
281
+ function isTypeScriptFile(filePath) {
282
+ return TS_EXTENSIONS.has(extname(filePath).toLowerCase());
283
+ }
284
+
285
+ function findTypeScriptFiles(dir, files = []) {
286
+ const entries = readdirSync(dir, { withFileTypes: true });
287
+
288
+ for (const entry of entries) {
289
+ const fullPath = join(dir, entry.name);
290
+
291
+ if (entry.isDirectory()) {
292
+ if (!shouldSkip(entry.name, true)) {
293
+ findTypeScriptFiles(fullPath, files);
294
+ }
295
+ } else if (entry.isFile() && isTypeScriptFile(entry.name)) {
296
+ if (!shouldSkip(entry.name, false)) {
297
+ files.push(fullPath);
298
+ }
299
+ }
300
+ }
301
+
302
+ return files;
303
+ }
304
+
305
+ // ─────────────────────────────────────────────────────────────
306
+ // Formatting
307
+ // ─────────────────────────────────────────────────────────────
308
+
309
+ function formatTypes(types, out, showFileHeader = true, filePath = '') {
310
+ const hasContent = types.interfaces.length > 0 ||
311
+ types.typeAliases.length > 0 ||
312
+ types.enums.length > 0;
313
+
314
+ if (!hasContent) return 0;
315
+
316
+ let count = 0;
317
+
318
+ if (types.interfaces.length > 0) {
319
+ if (!showFileHeader) out.add('// Interfaces');
320
+ for (const iface of types.interfaces) {
321
+ out.add(iface.definition);
322
+ out.blank();
323
+ count++;
324
+ }
325
+ }
326
+
327
+ if (types.typeAliases.length > 0) {
328
+ if (!showFileHeader) out.add('// Type Aliases');
329
+ for (const alias of types.typeAliases) {
330
+ out.add(alias.definition);
331
+ out.blank();
332
+ count++;
333
+ }
334
+ }
335
+
336
+ if (types.enums.length > 0) {
337
+ if (!showFileHeader) out.add('// Enums');
338
+ for (const enumDef of types.enums) {
339
+ out.add(enumDef.definition);
340
+ out.blank();
341
+ count++;
342
+ }
343
+ }
344
+
345
+ return count;
346
+ }
347
+
348
+ function countTypes(types) {
349
+ return types.interfaces.length + types.typeAliases.length + types.enums.length;
350
+ }
351
+
352
+ // ─────────────────────────────────────────────────────────────
353
+ // Main
354
+ // ─────────────────────────────────────────────────────────────
355
+
356
+ const args = process.argv.slice(2);
357
+ const options = parseCommonArgs(args);
358
+ const exportsOnly = options.remaining.includes('--exports-only') || options.remaining.includes('-e');
359
+ const stripComments = options.remaining.includes('--no-comments');
360
+ const flat = options.remaining.includes('--flat');
361
+ const targetPath = options.remaining.find(a => !a.startsWith('-'));
362
+
363
+ if (options.help || !targetPath) {
364
+ console.log(HELP);
365
+ process.exit(options.help ? 0 : 1);
366
+ }
367
+
368
+ if (!existsSync(targetPath)) {
369
+ console.error(`Path not found: ${targetPath}`);
370
+ process.exit(1);
371
+ }
372
+
373
+ const projectRoot = findProjectRoot();
374
+ const out = createOutput(options);
375
+ const allTypes = [];
376
+ let totalTypes = 0;
377
+ let totalTokens = 0;
378
+
379
+ const stat = statSync(targetPath);
380
+ const files = stat.isDirectory()
381
+ ? findTypeScriptFiles(targetPath)
382
+ : isTypeScriptFile(targetPath) ? [targetPath] : [];
383
+
384
+ if (files.length === 0) {
385
+ console.error('No TypeScript files found');
386
+ process.exit(1);
387
+ }
388
+
389
+ const showFileHeaders = files.length > 1 && !flat;
390
+
391
+ for (const filePath of files) {
392
+ const content = readFileSync(filePath, 'utf-8');
393
+ const types = extractTypes(content, { exportsOnly, stripComments });
394
+ const count = countTypes(types);
395
+
396
+ if (count === 0) continue;
397
+
398
+ const relPath = relative(projectRoot, filePath);
399
+ totalTypes += count;
400
+
401
+ // Add file header
402
+ if (showFileHeaders) {
403
+ out.add(`// ═══════════════════════════════════════════════════════════`);
404
+ out.add(`// ${relPath} (${count} types)`);
405
+ out.add(`// ═══════════════════════════════════════════════════════════`);
406
+ out.blank();
407
+ }
408
+
409
+ formatTypes(types, out, showFileHeaders, filePath);
410
+
411
+ // Collect for JSON
412
+ allTypes.push({
413
+ file: relPath,
414
+ ...types
415
+ });
416
+ }
417
+
418
+ // Calculate token savings
419
+ const outputText = out.render();
420
+ totalTokens = estimateTokens(outputText);
421
+
422
+ // For comparison, estimate full file tokens
423
+ let fullFileTokens = 0;
424
+ for (const filePath of files) {
425
+ fullFileTokens += estimateTokens(readFileSync(filePath, 'utf-8'));
426
+ }
427
+
428
+ // Set JSON data
429
+ out.setData('files', allTypes);
430
+ out.setData('totalTypes', totalTypes);
431
+ out.setData('totalTokens', totalTokens);
432
+ out.setData('fullFileTokens', fullFileTokens);
433
+
434
+ // Add summary
435
+ if (!options.quiet && totalTypes > 0) {
436
+ out.add(`// ───────────────────────────────────────────────────────────`);
437
+ out.add(`// Summary: ${totalTypes} types from ${files.length} file(s)`);
438
+ out.add(`// Tokens: ~${formatTokens(totalTokens)} (full files: ~${formatTokens(fullFileTokens)})`);
439
+ }
440
+
441
+ out.print();