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,494 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * tl-unused - Find potentially unused exports and files
5
+ *
6
+ * Scans the codebase to find exports that aren't imported anywhere
7
+ * and files that aren't referenced by other files.
8
+ *
9
+ * Usage: tl-unused [dir] [--exports-only]
10
+ */
11
+
12
+ // Prompt info for tl-prompt
13
+ if (process.argv.includes('--prompt')) {
14
+ console.log(JSON.stringify({
15
+ name: 'tl-unused',
16
+ desc: 'Find unused exports and unreferenced files',
17
+ when: 'before-modify',
18
+ example: 'tl-unused src/'
19
+ }));
20
+ process.exit(0);
21
+ }
22
+
23
+ import { readFileSync, existsSync, readdirSync, statSync } from 'fs';
24
+ import { join, relative, dirname, basename, extname } from 'path';
25
+ import { spawnSync } from 'child_process';
26
+ import {
27
+ createOutput,
28
+ parseCommonArgs,
29
+ COMMON_OPTIONS_HELP
30
+ } from '../src/output.mjs';
31
+ import { findProjectRoot, shouldSkip, isCodeFile } from '../src/project.mjs';
32
+
33
+ const HELP = `
34
+ tl-unused - Find potentially unused exports and unreferenced files
35
+
36
+ Usage: tl-unused [dir] [options]
37
+
38
+ Options:
39
+ --exports-only, -e Only check for unused exports
40
+ --files-only, -f Only check for unreferenced files
41
+ --ignore <pattern> Ignore files matching pattern (can use multiple times)
42
+ --include-tests Include test files in analysis (default: excluded)
43
+ ${COMMON_OPTIONS_HELP}
44
+
45
+ Examples:
46
+ tl-unused # Full analysis
47
+ tl-unused src/ # Analyze src/ only
48
+ tl-unused -e # Unused exports only
49
+ tl-unused --ignore "*.d.ts" # Ignore type definitions
50
+
51
+ Note: This is a heuristic analysis. Some "unused" exports might be:
52
+ - Used dynamically (require(), dynamic imports)
53
+ - Public API exports
54
+ - Entry points
55
+ - Used by external packages
56
+ `;
57
+
58
+ // ─────────────────────────────────────────────────────────────
59
+ // File Discovery
60
+ // ─────────────────────────────────────────────────────────────
61
+
62
+ const CODE_EXTENSIONS = new Set(['.js', '.mjs', '.cjs', '.jsx', '.ts', '.tsx', '.mts']);
63
+
64
+ function isCodeExtension(filePath) {
65
+ return CODE_EXTENSIONS.has(extname(filePath).toLowerCase());
66
+ }
67
+
68
+ function findCodeFiles(dir, files = [], options = {}) {
69
+ const { includeTests = false, ignorePatterns = [] } = options;
70
+ const entries = readdirSync(dir, { withFileTypes: true });
71
+
72
+ for (const entry of entries) {
73
+ const fullPath = join(dir, entry.name);
74
+
75
+ // Check ignore patterns
76
+ if (ignorePatterns.some(p => entry.name.includes(p) || fullPath.includes(p))) {
77
+ continue;
78
+ }
79
+
80
+ if (entry.isDirectory()) {
81
+ if (!shouldSkip(entry.name, true)) {
82
+ findCodeFiles(fullPath, files, options);
83
+ }
84
+ } else if (entry.isFile() && isCodeExtension(fullPath)) {
85
+ if (!shouldSkip(entry.name, false)) {
86
+ // Skip test files unless includeTests
87
+ if (!includeTests) {
88
+ const lower = entry.name.toLowerCase();
89
+ if (lower.includes('.test.') || lower.includes('.spec.') ||
90
+ lower.includes('__tests__') || lower.includes('__mocks__')) {
91
+ continue;
92
+ }
93
+ }
94
+ files.push(fullPath);
95
+ }
96
+ }
97
+ }
98
+
99
+ return files;
100
+ }
101
+
102
+ // ─────────────────────────────────────────────────────────────
103
+ // Export Extraction
104
+ // ─────────────────────────────────────────────────────────────
105
+
106
+ function extractExports(content, filePath) {
107
+ const exports = [];
108
+ const lines = content.split('\n');
109
+
110
+ for (let i = 0; i < lines.length; i++) {
111
+ const line = lines[i];
112
+ const trimmed = line.trim();
113
+
114
+ if (!trimmed.startsWith('export ')) continue;
115
+
116
+ // Skip re-exports (these are pass-through)
117
+ if (trimmed.includes(' from ')) continue;
118
+
119
+ // Named exports: export { a, b }
120
+ const namedMatch = trimmed.match(/^export\s+\{([^}]+)\}/);
121
+ if (namedMatch) {
122
+ const names = namedMatch[1].split(',').map(n => {
123
+ const parts = n.trim().split(/\s+as\s+/);
124
+ return parts[parts.length - 1].trim(); // Use alias if present
125
+ });
126
+ names.forEach(name => exports.push({ name, line: i + 1 }));
127
+ continue;
128
+ }
129
+
130
+ // Default export
131
+ if (trimmed.startsWith('export default ')) {
132
+ exports.push({ name: 'default', line: i + 1 });
133
+ continue;
134
+ }
135
+
136
+ // export interface/type/enum
137
+ const typeMatch = trimmed.match(/^export\s+(?:interface|type|enum|const\s+enum)\s+(\w+)/);
138
+ if (typeMatch) {
139
+ exports.push({ name: typeMatch[1], line: i + 1, isType: true });
140
+ continue;
141
+ }
142
+
143
+ // export function/class/const
144
+ const valueMatch = trimmed.match(/^export\s+(?:async\s+)?(?:function|class|const|let|var)\s+(\w+)/);
145
+ if (valueMatch) {
146
+ exports.push({ name: valueMatch[1], line: i + 1 });
147
+ continue;
148
+ }
149
+
150
+ // export abstract class
151
+ const abstractMatch = trimmed.match(/^export\s+abstract\s+class\s+(\w+)/);
152
+ if (abstractMatch) {
153
+ exports.push({ name: abstractMatch[1], line: i + 1 });
154
+ continue;
155
+ }
156
+ }
157
+
158
+ return exports;
159
+ }
160
+
161
+ // ─────────────────────────────────────────────────────────────
162
+ // Import/Reference Detection
163
+ // ─────────────────────────────────────────────────────────────
164
+
165
+ function extractImports(content) {
166
+ const imports = {
167
+ named: new Set(), // Named imports
168
+ files: new Set() // Import paths (for file reference checking)
169
+ };
170
+
171
+ // Named imports: import { a, b } from './x'
172
+ const namedRegex = /import\s+\{([^}]+)\}\s+from\s+['"]([^'"]+)['"]/g;
173
+ let match;
174
+ while ((match = namedRegex.exec(content)) !== null) {
175
+ const names = match[1].split(',').map(n => {
176
+ const parts = n.trim().split(/\s+as\s+/);
177
+ return parts[0].trim(); // Use original name, not alias
178
+ });
179
+ names.forEach(name => imports.named.add(name));
180
+ imports.files.add(match[2]);
181
+ }
182
+
183
+ // Default imports: import X from './x'
184
+ const defaultRegex = /import\s+(\w+)\s+from\s+['"]([^'"]+)['"]/g;
185
+ while ((match = defaultRegex.exec(content)) !== null) {
186
+ imports.named.add('default');
187
+ imports.files.add(match[2]);
188
+ }
189
+
190
+ // Namespace imports: import * as X from './x'
191
+ const namespaceRegex = /import\s+\*\s+as\s+\w+\s+from\s+['"]([^'"]+)['"]/g;
192
+ while ((match = namespaceRegex.exec(content)) !== null) {
193
+ imports.files.add(match[1]);
194
+ // Namespace import means all exports are potentially used
195
+ imports.named.add('*');
196
+ }
197
+
198
+ // Dynamic imports: import('./x')
199
+ const dynamicRegex = /import\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
200
+ while ((match = dynamicRegex.exec(content)) !== null) {
201
+ imports.files.add(match[1]);
202
+ imports.named.add('*'); // Dynamic import might use anything
203
+ }
204
+
205
+ // require(): require('./x')
206
+ const requireRegex = /require\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
207
+ while ((match = requireRegex.exec(content)) !== null) {
208
+ imports.files.add(match[1]);
209
+ imports.named.add('*');
210
+ }
211
+
212
+ // Type imports: import type { X } from './x'
213
+ const typeRegex = /import\s+type\s+\{([^}]+)\}\s+from\s+['"]([^'"]+)['"]/g;
214
+ while ((match = typeRegex.exec(content)) !== null) {
215
+ const names = match[1].split(',').map(n => n.trim().split(/\s+as\s+/)[0].trim());
216
+ names.forEach(name => imports.named.add(name));
217
+ imports.files.add(match[2]);
218
+ }
219
+
220
+ return imports;
221
+ }
222
+
223
+ function findReferencesWithGrep(name, projectRoot, excludeFile) {
224
+ // Use ripgrep for fast reference counting
225
+ const args = [
226
+ '-l', // Files only
227
+ '--type', 'js',
228
+ '--type', 'ts',
229
+ '-w', // Word boundary
230
+ name,
231
+ '.'
232
+ ];
233
+
234
+ const result = spawnSync('rg', args, {
235
+ cwd: projectRoot,
236
+ encoding: 'utf-8'
237
+ });
238
+
239
+ if (result.error || result.status !== 0) {
240
+ return 0;
241
+ }
242
+
243
+ const files = result.stdout.trim().split('\n').filter(Boolean);
244
+ // Exclude the file that exports it
245
+ const relExclude = relative(projectRoot, excludeFile);
246
+ const otherFiles = files.filter(f => f !== relExclude && !f.includes(relExclude));
247
+
248
+ return otherFiles.length;
249
+ }
250
+
251
+ // ─────────────────────────────────────────────────────────────
252
+ // Analysis
253
+ // ─────────────────────────────────────────────────────────────
254
+
255
+ function analyzeUnusedExports(files, projectRoot) {
256
+ const allImports = {
257
+ named: new Set(),
258
+ files: new Set()
259
+ };
260
+
261
+ // First pass: collect all imports
262
+ for (const file of files) {
263
+ const content = readFileSync(file, 'utf-8');
264
+ const imports = extractImports(content);
265
+
266
+ imports.named.forEach(n => allImports.named.add(n));
267
+ imports.files.forEach(f => allImports.files.add(f));
268
+ }
269
+
270
+ // Check if namespace imports are used (means everything is potentially used)
271
+ const hasWildcardImport = allImports.named.has('*');
272
+
273
+ // Second pass: find unused exports
274
+ const unused = [];
275
+
276
+ for (const file of files) {
277
+ const content = readFileSync(file, 'utf-8');
278
+ const exports = extractExports(content, file);
279
+ const relPath = relative(projectRoot, file);
280
+
281
+ for (const exp of exports) {
282
+ // Skip default exports (often intentionally exported)
283
+ if (exp.name === 'default') continue;
284
+
285
+ // If there's a wildcard import somewhere, we can't be sure it's unused
286
+ if (hasWildcardImport) {
287
+ // Do a more thorough grep-based check
288
+ const refs = findReferencesWithGrep(exp.name, projectRoot, file);
289
+ if (refs === 0) {
290
+ unused.push({
291
+ file: relPath,
292
+ name: exp.name,
293
+ line: exp.line,
294
+ isType: exp.isType
295
+ });
296
+ }
297
+ } else {
298
+ // Simple check: is the name in our imports set?
299
+ if (!allImports.named.has(exp.name)) {
300
+ // Double-check with grep for common names
301
+ if (exp.name.length <= 3 || /^[A-Z]/.test(exp.name)) {
302
+ const refs = findReferencesWithGrep(exp.name, projectRoot, file);
303
+ if (refs > 0) continue;
304
+ }
305
+
306
+ unused.push({
307
+ file: relPath,
308
+ name: exp.name,
309
+ line: exp.line,
310
+ isType: exp.isType
311
+ });
312
+ }
313
+ }
314
+ }
315
+ }
316
+
317
+ return unused;
318
+ }
319
+
320
+ function analyzeUnreferencedFiles(files, projectRoot) {
321
+ const importedPaths = new Set();
322
+
323
+ // Collect all imported paths
324
+ for (const file of files) {
325
+ const content = readFileSync(file, 'utf-8');
326
+ const imports = extractImports(content);
327
+
328
+ for (const importPath of imports.files) {
329
+ // Resolve relative imports
330
+ if (importPath.startsWith('.')) {
331
+ const resolved = join(dirname(file), importPath);
332
+ // Try with various extensions
333
+ const extensions = ['', '.ts', '.tsx', '.js', '.jsx', '.mjs', '/index.ts', '/index.tsx', '/index.js'];
334
+ for (const ext of extensions) {
335
+ importedPaths.add(resolved + ext);
336
+ }
337
+ }
338
+ }
339
+ }
340
+
341
+ // Find unreferenced files
342
+ const unreferenced = [];
343
+ const entryPatterns = ['index.', 'main.', 'app.', 'server.', 'cli.', 'bin/'];
344
+
345
+ for (const file of files) {
346
+ const relPath = relative(projectRoot, file);
347
+
348
+ // Skip likely entry points
349
+ if (entryPatterns.some(p => relPath.includes(p))) {
350
+ continue;
351
+ }
352
+
353
+ // Check if this file is imported
354
+ const isImported = [...importedPaths].some(p => {
355
+ return file.startsWith(p) || file === p;
356
+ });
357
+
358
+ if (!isImported) {
359
+ // Double-check: look for any reference to the file basename
360
+ const name = basename(file, extname(file));
361
+ const refs = findReferencesWithGrep(name, projectRoot, file);
362
+
363
+ if (refs === 0) {
364
+ unreferenced.push(relPath);
365
+ }
366
+ }
367
+ }
368
+
369
+ return unreferenced;
370
+ }
371
+
372
+ // ─────────────────────────────────────────────────────────────
373
+ // Main
374
+ // ─────────────────────────────────────────────────────────────
375
+
376
+ const args = process.argv.slice(2);
377
+ const options = parseCommonArgs(args);
378
+
379
+ // Parse custom options
380
+ let exportsOnly = false;
381
+ let filesOnly = false;
382
+ let includeTests = false;
383
+ const ignorePatterns = [];
384
+
385
+ const remaining = [];
386
+ for (let i = 0; i < options.remaining.length; i++) {
387
+ const arg = options.remaining[i];
388
+
389
+ if (arg === '--exports-only' || arg === '-e') {
390
+ exportsOnly = true;
391
+ } else if (arg === '--files-only' || arg === '-f') {
392
+ filesOnly = true;
393
+ } else if (arg === '--include-tests') {
394
+ includeTests = true;
395
+ } else if (arg === '--ignore') {
396
+ ignorePatterns.push(options.remaining[++i]);
397
+ } else if (!arg.startsWith('-')) {
398
+ remaining.push(arg);
399
+ }
400
+ }
401
+
402
+ const targetDir = remaining[0] || '.';
403
+
404
+ if (options.help) {
405
+ console.log(HELP);
406
+ process.exit(0);
407
+ }
408
+
409
+ if (!existsSync(targetDir)) {
410
+ console.error(`Directory not found: ${targetDir}`);
411
+ process.exit(1);
412
+ }
413
+
414
+ const projectRoot = findProjectRoot();
415
+ const out = createOutput(options);
416
+
417
+ // Find all code files
418
+ const files = findCodeFiles(targetDir, [], { includeTests, ignorePatterns });
419
+
420
+ if (files.length === 0) {
421
+ console.error('No code files found');
422
+ process.exit(1);
423
+ }
424
+
425
+ out.header(`🔍 Analyzing ${files.length} files for unused code...`);
426
+ out.blank();
427
+
428
+ const results = {
429
+ unusedExports: [],
430
+ unreferencedFiles: []
431
+ };
432
+
433
+ // Analyze unused exports
434
+ if (!filesOnly) {
435
+ results.unusedExports = analyzeUnusedExports(files, projectRoot);
436
+
437
+ if (results.unusedExports.length > 0) {
438
+ out.add(`Potentially unused exports (${results.unusedExports.length}):`);
439
+ out.blank();
440
+
441
+ // Group by file
442
+ const byFile = new Map();
443
+ for (const exp of results.unusedExports) {
444
+ if (!byFile.has(exp.file)) {
445
+ byFile.set(exp.file, []);
446
+ }
447
+ byFile.get(exp.file).push(exp);
448
+ }
449
+
450
+ for (const [file, exports] of byFile) {
451
+ out.add(` ${file}`);
452
+ for (const exp of exports) {
453
+ const typeIndicator = exp.isType ? ' (type)' : '';
454
+ out.add(` L${exp.line}: ${exp.name}${typeIndicator}`);
455
+ }
456
+ }
457
+ out.blank();
458
+ }
459
+ }
460
+
461
+ // Analyze unreferenced files
462
+ if (!exportsOnly) {
463
+ results.unreferencedFiles = analyzeUnreferencedFiles(files, projectRoot);
464
+
465
+ if (results.unreferencedFiles.length > 0) {
466
+ out.add(`Potentially unreferenced files (${results.unreferencedFiles.length}):`);
467
+ out.blank();
468
+
469
+ for (const file of results.unreferencedFiles) {
470
+ out.add(` ${file}`);
471
+ }
472
+ out.blank();
473
+ }
474
+ }
475
+
476
+ // Set JSON data
477
+ out.setData('unusedExports', results.unusedExports);
478
+ out.setData('unreferencedFiles', results.unreferencedFiles);
479
+
480
+ // Summary
481
+ if (!options.quiet) {
482
+ const totalIssues = results.unusedExports.length + results.unreferencedFiles.length;
483
+
484
+ if (totalIssues === 0) {
485
+ out.add('✓ No obviously unused code found');
486
+ } else {
487
+ out.add(`Found ${results.unusedExports.length} potentially unused exports, ${results.unreferencedFiles.length} unreferenced files`);
488
+ }
489
+
490
+ out.blank();
491
+ out.add('Note: Review carefully - some exports may be public API or dynamically used');
492
+ }
493
+
494
+ out.print();
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "tokenlean",
3
+ "version": "0.1.0",
4
+ "description": "Lean CLI tools for AI agents and developers - reduce context, save tokens",
5
+ "type": "module",
6
+ "engines": {
7
+ "node": ">=18.0.0"
8
+ },
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/edimuj/tokenlean.git"
12
+ },
13
+ "homepage": "https://github.com/edimuj/tokenlean#readme",
14
+ "bugs": {
15
+ "url": "https://github.com/edimuj/tokenlean/issues"
16
+ },
17
+ "files": [
18
+ "bin/",
19
+ "src/",
20
+ "README.md"
21
+ ],
22
+ "bin": {
23
+ "tl-api": "./bin/tl-api.mjs",
24
+ "tl-blame": "./bin/tl-blame.mjs",
25
+ "tl-config": "./bin/tl-config.mjs",
26
+ "tl-context": "./bin/tl-context.mjs",
27
+ "tl-coverage": "./bin/tl-coverage.mjs",
28
+ "tl-complexity": "./bin/tl-complexity.mjs",
29
+ "tl-component": "./bin/tl-component.mjs",
30
+ "tl-deps": "./bin/tl-deps.mjs",
31
+ "tl-diff": "./bin/tl-diff.mjs",
32
+ "tl-entry": "./bin/tl-entry.mjs",
33
+ "tl-env": "./bin/tl-env.mjs",
34
+ "tl-exports": "./bin/tl-exports.mjs",
35
+ "tl-flow": "./bin/tl-flow.mjs",
36
+ "tl-history": "./bin/tl-history.mjs",
37
+ "tl-hotspots": "./bin/tl-hotspots.mjs",
38
+ "tl-impact": "./bin/tl-impact.mjs",
39
+ "tl-prompt": "./bin/tl-prompt.mjs",
40
+ "tl-related": "./bin/tl-related.mjs",
41
+ "tl-routes": "./bin/tl-routes.mjs",
42
+ "tl-search": "./bin/tl-search.mjs",
43
+ "tl-structure": "./bin/tl-structure.mjs",
44
+ "tl-symbols": "./bin/tl-symbols.mjs",
45
+ "tl-todo": "./bin/tl-todo.mjs",
46
+ "tl-types": "./bin/tl-types.mjs",
47
+ "tl-unused": "./bin/tl-unused.mjs"
48
+ },
49
+ "scripts": {
50
+ "link": "npm link"
51
+ },
52
+ "keywords": ["ai", "agents", "cli", "tools", "tokens", "context", "developer"],
53
+ "author": "Edin Mujkanovic",
54
+ "license": "MIT"
55
+ }