tokenlean 0.1.0 → 0.3.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.
@@ -23,41 +23,38 @@ if (process.argv.includes('--prompt')) {
23
23
  import { readFileSync, existsSync, readdirSync, statSync } from 'fs';
24
24
  import { join, dirname, basename, relative, extname } from 'path';
25
25
  import { execSync } from 'child_process';
26
- import { shellEscape } from '../src/output.mjs';
27
-
28
- try { execSync('which rg', { stdio: 'ignore' }); } catch {
29
- console.error('⚠️ ripgrep (rg) not found. Install: brew install ripgrep');
26
+ import {
27
+ createOutput,
28
+ parseCommonArgs,
29
+ estimateTokens,
30
+ formatTokens,
31
+ shellEscape,
32
+ COMMON_OPTIONS_HELP
33
+ } from '../src/output.mjs';
34
+ import { findProjectRoot } from '../src/project.mjs';
35
+ import { withCache } from '../src/cache.mjs';
36
+
37
+ const HELP = `
38
+ tl-related - Find related files (tests, types, usages)
39
+
40
+ Usage: tl-related <file> [options]
41
+ ${COMMON_OPTIONS_HELP}
42
+
43
+ Examples:
44
+ tl-related src/Button.tsx # Find tests, types, importers
45
+ tl-related src/api.ts -j # JSON output
46
+ tl-related src/utils.ts -q # Quiet (file paths only)
47
+ `;
48
+
49
+ // Check for ripgrep
50
+ try {
51
+ execSync('which rg', { stdio: 'ignore' });
52
+ } catch {
53
+ console.error('ripgrep (rg) not found. Install: brew install ripgrep');
30
54
  process.exit(1);
31
55
  }
32
56
 
33
- const SKIP_DIRS = new Set([
34
- 'node_modules', '.git', 'android', 'ios', 'dist', 'build', '.expo', '.next'
35
- ]);
36
-
37
- function findProjectRoot() {
38
- let dir = process.cwd();
39
- while (dir !== '/') {
40
- if (existsSync(join(dir, 'package.json'))) return dir;
41
- dir = dirname(dir);
42
- }
43
- return process.cwd();
44
- }
45
-
46
- function estimateTokens(filePath) {
47
- try {
48
- const content = readFileSync(filePath, 'utf-8');
49
- return Math.ceil(content.length / 4);
50
- } catch {
51
- return 0;
52
- }
53
- }
54
-
55
- function formatTokens(tokens) {
56
- if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}k`;
57
- return String(tokens);
58
- }
59
-
60
- function findTestFiles(filePath, projectRoot) {
57
+ function findTestFiles(filePath) {
61
58
  const dir = dirname(filePath);
62
59
  const name = basename(filePath, extname(filePath));
63
60
  const tests = [];
@@ -66,10 +63,15 @@ function findTestFiles(filePath, projectRoot) {
66
63
  const patterns = [
67
64
  join(dir, `${name}.test.ts`),
68
65
  join(dir, `${name}.test.tsx`),
66
+ join(dir, `${name}.test.js`),
67
+ join(dir, `${name}.test.jsx`),
69
68
  join(dir, `${name}.spec.ts`),
70
69
  join(dir, `${name}.spec.tsx`),
70
+ join(dir, `${name}.spec.js`),
71
+ join(dir, `${name}.spec.jsx`),
71
72
  join(dir, '__tests__', `${name}.test.ts`),
72
73
  join(dir, '__tests__', `${name}.test.tsx`),
74
+ join(dir, '__tests__', `${name}.test.js`),
73
75
  join(dir, '__tests__', `${name}.spec.ts`),
74
76
  join(dir, '__tests__', `${name}.spec.tsx`),
75
77
  ];
@@ -104,10 +106,12 @@ function findTypeFiles(filePath, projectRoot) {
104
106
  // Check project-wide types directory
105
107
  const globalTypes = join(projectRoot, 'src', 'types');
106
108
  if (existsSync(globalTypes)) {
107
- const typeFiles = readdirSync(globalTypes).filter(f => f.endsWith('.ts'));
108
- for (const tf of typeFiles.slice(0, 5)) {
109
- types.push(join(globalTypes, tf));
110
- }
109
+ try {
110
+ const typeFiles = readdirSync(globalTypes).filter(f => f.endsWith('.ts'));
111
+ for (const tf of typeFiles.slice(0, 5)) {
112
+ types.push(join(globalTypes, tf));
113
+ }
114
+ } catch { /* permission error */ }
111
115
  }
112
116
 
113
117
  return types;
@@ -115,14 +119,18 @@ function findTypeFiles(filePath, projectRoot) {
115
119
 
116
120
  function findImporters(filePath, projectRoot) {
117
121
  const name = basename(filePath, extname(filePath));
118
-
119
122
  const importers = new Set();
120
123
 
121
- // Search for files that might import this module
124
+ // Search for files that might import this module (with caching)
122
125
  try {
123
- const result = execSync(
124
- `rg -l -g "*.{js,mjs,ts,tsx,jsx}" -e "${shellEscape(name)}" "${shellEscape(projectRoot)}" 2>/dev/null || true`,
125
- { encoding: 'utf-8', maxBuffer: 5 * 1024 * 1024 }
126
+ const cacheKey = { op: 'rg-find-importers', module: name, glob: '*.{js,mjs,ts,tsx,jsx}' };
127
+ const result = withCache(
128
+ cacheKey,
129
+ () => execSync(
130
+ `rg -l -g "*.{js,mjs,ts,tsx,jsx}" -e "${shellEscape(name)}" "${shellEscape(projectRoot)}" 2>/dev/null || true`,
131
+ { encoding: 'utf-8', maxBuffer: 5 * 1024 * 1024 }
132
+ ),
133
+ { projectRoot }
126
134
  );
127
135
 
128
136
  for (const line of result.trim().split('\n')) {
@@ -141,7 +149,7 @@ function findImporters(filePath, projectRoot) {
141
149
  }
142
150
  } catch { /* skip unreadable files */ }
143
151
  }
144
- } catch (e) { /* rg not found or no matches */ }
152
+ } catch { /* rg not found or no matches */ }
145
153
 
146
154
  return Array.from(importers);
147
155
  }
@@ -153,43 +161,48 @@ function findSiblings(filePath) {
153
161
  try {
154
162
  const files = readdirSync(dir).filter(f => {
155
163
  const fullPath = join(dir, f);
156
- return statSync(fullPath).isFile() &&
157
- f !== basename(filePath) &&
158
- !f.includes('.test.') &&
159
- !f.includes('.spec.') &&
160
- (f.endsWith('.ts') || f.endsWith('.tsx'));
164
+ try {
165
+ return statSync(fullPath).isFile() &&
166
+ f !== basename(filePath) &&
167
+ !f.includes('.test.') &&
168
+ !f.includes('.spec.') &&
169
+ (f.endsWith('.ts') || f.endsWith('.tsx') || f.endsWith('.js') || f.endsWith('.jsx'));
170
+ } catch {
171
+ return false;
172
+ }
161
173
  });
162
174
 
163
175
  for (const f of files.slice(0, 5)) {
164
176
  siblings.push(join(dir, f));
165
177
  }
166
- } catch (e) { /* permission error */ }
178
+ } catch { /* permission error */ }
167
179
 
168
180
  return siblings;
169
181
  }
170
182
 
171
- function printSection(title, files, projectRoot) {
172
- if (files.length === 0) return;
173
-
174
- console.log(`\n${title}`);
175
- for (const f of files) {
176
- const rel = relative(projectRoot, f);
177
- const tokens = estimateTokens(f);
178
- console.log(` ${rel} (~${formatTokens(tokens)})`);
183
+ function getFileInfo(filePath) {
184
+ try {
185
+ const content = readFileSync(filePath, 'utf-8');
186
+ return {
187
+ tokens: estimateTokens(content),
188
+ lines: content.split('\n').length
189
+ };
190
+ } catch {
191
+ return { tokens: 0, lines: 0 };
179
192
  }
180
193
  }
181
194
 
182
195
  // Main
183
196
  const args = process.argv.slice(2);
184
- const targetFile = args[0];
197
+ const options = parseCommonArgs(args);
198
+ const targetFile = options.remaining.find(a => !a.startsWith('-'));
185
199
 
186
- if (!targetFile) {
187
- console.log('\nUsage: claude-related <file>\n');
188
- console.log('Finds tests, types, and importers for a given file.');
189
- process.exit(1);
200
+ if (options.help || !targetFile) {
201
+ console.log(HELP);
202
+ process.exit(options.help ? 0 : 1);
190
203
  }
191
204
 
192
- const fullPath = join(process.cwd(), targetFile);
205
+ const fullPath = targetFile.startsWith('/') ? targetFile : join(process.cwd(), targetFile);
193
206
  if (!existsSync(fullPath)) {
194
207
  console.error(`File not found: ${targetFile}`);
195
208
  process.exit(1);
@@ -197,31 +210,65 @@ if (!existsSync(fullPath)) {
197
210
 
198
211
  const projectRoot = findProjectRoot();
199
212
  const relPath = relative(projectRoot, fullPath);
213
+ const out = createOutput(options);
200
214
 
201
- console.log(`\n📎 Related files for: ${relPath}`);
202
-
203
- const tests = findTestFiles(fullPath, projectRoot);
215
+ const tests = findTestFiles(fullPath);
204
216
  const types = findTypeFiles(fullPath, projectRoot);
205
217
  const importers = findImporters(fullPath, projectRoot);
206
218
  const siblings = findSiblings(fullPath);
207
219
 
208
- printSection('🧪 Tests', tests, projectRoot);
209
- printSection('📝 Types', types, projectRoot);
210
- printSection('📥 Imported by', importers.slice(0, 10), projectRoot);
211
- printSection('👥 Siblings', siblings, projectRoot);
220
+ // Collect file info for JSON
221
+ const testsInfo = tests.map(f => ({ path: relative(projectRoot, f), ...getFileInfo(f) }));
222
+ const typesInfo = types.map(f => ({ path: relative(projectRoot, f), ...getFileInfo(f) }));
223
+ const importersInfo = importers.slice(0, 10).map(f => ({ path: relative(projectRoot, f), ...getFileInfo(f) }));
224
+ const siblingsInfo = siblings.map(f => ({ path: relative(projectRoot, f), ...getFileInfo(f) }));
225
+
226
+ // Set JSON data
227
+ out.setData('file', relPath);
228
+ out.setData('tests', testsInfo);
229
+ out.setData('types', typesInfo);
230
+ out.setData('importers', importersInfo);
231
+ out.setData('siblings', siblingsInfo);
232
+ out.setData('totalImporters', importers.length);
233
+
234
+ // Header
235
+ out.header(`Related files for: ${relPath}`);
236
+ out.blank();
237
+
238
+ // Sections
239
+ function addSection(title, files) {
240
+ if (files.length === 0) return;
241
+ out.add(title);
242
+ for (const f of files) {
243
+ const rel = relative(projectRoot, f);
244
+ const info = getFileInfo(f);
245
+ out.add(` ${rel} (~${formatTokens(info.tokens)})`);
246
+ }
247
+ out.blank();
248
+ }
249
+
250
+ addSection('Tests:', tests);
251
+ addSection('Types:', types);
252
+ addSection('Imported by:', importers.slice(0, 10));
253
+ addSection('Siblings:', siblings);
212
254
 
213
255
  const totalFiles = tests.length + types.length + Math.min(importers.length, 10) + siblings.length;
214
256
  if (totalFiles === 0) {
215
- console.log('\n No related files found.');
257
+ out.add(' No related files found.');
258
+ out.blank();
216
259
  }
217
260
 
218
261
  // Summary
219
- const totalTokens = [...tests, ...types, ...importers.slice(0, 10), ...siblings]
220
- .reduce((sum, f) => sum + estimateTokens(f), 0);
262
+ const allFiles = [...tests, ...types, ...importers.slice(0, 10), ...siblings];
263
+ const totalTokens = allFiles.reduce((sum, f) => sum + getFileInfo(f).tokens, 0);
221
264
 
222
- console.log(`\n📊 Total: ${totalFiles} related files, ~${formatTokens(totalTokens)} tokens`);
265
+ out.setData('totalFiles', totalFiles);
266
+ out.setData('totalTokens', totalTokens);
267
+
268
+ out.header(`Total: ${totalFiles} related files, ~${formatTokens(totalTokens)} tokens`);
223
269
 
224
270
  if (importers.length > 10) {
225
- console.log(` (${importers.length - 10} more importers not shown)`);
271
+ out.header(`(${importers.length - 10} more importers not shown)`);
226
272
  }
227
- console.log();
273
+
274
+ out.print();
package/bin/tl-search.mjs CHANGED
@@ -22,7 +22,36 @@ if (process.argv.includes('--prompt')) {
22
22
  }
23
23
 
24
24
  import { spawn, execSync } from 'child_process';
25
- import { loadConfig, getSearchPatterns, CONFIG_FILENAME } from '../src/config.mjs';
25
+ import {
26
+ createOutput,
27
+ parseCommonArgs,
28
+ COMMON_OPTIONS_HELP
29
+ } from '../src/output.mjs';
30
+ import { loadConfig, CONFIG_FILENAME } from '../src/config.mjs';
31
+ import { withCache } from '../src/cache.mjs';
32
+
33
+ const HELP = `
34
+ tl-search - Run pre-defined search patterns
35
+
36
+ Usage: tl-search <pattern-name> [options]
37
+ ${COMMON_OPTIONS_HELP}
38
+
39
+ Configure patterns in ${CONFIG_FILENAME}:
40
+ {
41
+ "searchPatterns": {
42
+ "hooks": {
43
+ "description": "Find lifecycle hooks",
44
+ "pattern": "use(Effect|State|Callback)",
45
+ "glob": "**/*.{ts,tsx}"
46
+ }
47
+ }
48
+ }
49
+
50
+ Examples:
51
+ tl-search # List available patterns
52
+ tl-search hooks # Run the "hooks" pattern
53
+ tl-search todos -j # JSON output
54
+ `;
26
55
 
27
56
  // Check for ripgrep
28
57
  try {
@@ -32,13 +61,14 @@ try {
32
61
  process.exit(1);
33
62
  }
34
63
 
35
- function showHelp(patterns) {
64
+ function showPatterns(patterns, out) {
36
65
  const names = Object.keys(patterns);
37
66
 
38
67
  if (names.length === 0) {
39
- console.log('\nNo search patterns defined.');
40
- console.log(`\nAdd patterns to ${CONFIG_FILENAME}:`);
41
- console.log(`
68
+ out.header('No search patterns defined.');
69
+ out.blank();
70
+ out.add(`Add patterns to ${CONFIG_FILENAME}:`);
71
+ out.add(`
42
72
  {
43
73
  "searchPatterns": {
44
74
  "hooks": {
@@ -48,25 +78,37 @@ function showHelp(patterns) {
48
78
  }
49
79
  }
50
80
  }`);
51
- process.exit(0);
81
+ return;
52
82
  }
53
83
 
54
- console.log('\nAvailable search patterns:\n');
84
+ out.header('Available search patterns:');
85
+ out.blank();
55
86
 
56
87
  const maxLen = Math.max(...names.map(k => k.length));
57
88
 
89
+ // For JSON output, set data
90
+ out.setData('patterns', Object.entries(patterns).map(([name, config]) => ({
91
+ name,
92
+ description: config.description || '',
93
+ pattern: config.pattern,
94
+ glob: config.glob
95
+ })));
96
+
58
97
  for (const [name, config] of Object.entries(patterns)) {
59
98
  const paddedName = name.padEnd(maxLen);
60
- console.log(` ${paddedName} ${config.description || '(no description)'}`);
99
+ out.add(` ${paddedName} ${config.description || '(no description)'}`);
61
100
  }
62
101
 
63
- console.log('\nUsage: tl-search <pattern-name>');
64
- console.log('Example: tl-search hooks\n');
102
+ out.blank();
103
+ out.add('Usage: tl-search <pattern-name>');
104
+ out.add('Example: tl-search hooks');
65
105
  }
66
106
 
67
- function runSearch(name, config, rootDir) {
68
- console.log(`\nSearching: ${config.description || name}`);
69
- console.log(`Pattern: ${config.pattern}\n`);
107
+ function runSearch(name, config, rootDir, jsonMode) {
108
+ if (!jsonMode) {
109
+ console.log(`\nSearching: ${config.description || name}`);
110
+ console.log(`Pattern: ${config.pattern}\n`);
111
+ }
70
112
 
71
113
  if (config.type === 'glob-only') {
72
114
  const args = ['--files', '-g', config.pattern];
@@ -76,12 +118,39 @@ function runSearch(name, config, rootDir) {
76
118
  }
77
119
  }
78
120
  args.push(rootDir);
79
- const proc = spawn('rg', args, { stdio: 'inherit' });
80
- proc.on('close', code => process.exit(code === 1 ? 0 : code));
121
+
122
+ if (jsonMode) {
123
+ // Capture output for JSON
124
+ try {
125
+ const result = execSync(`rg ${args.map(a => `"${a}"`).join(' ')}`, {
126
+ encoding: 'utf-8',
127
+ cwd: rootDir
128
+ });
129
+ const files = result.trim().split('\n').filter(Boolean);
130
+ console.log(JSON.stringify({
131
+ pattern: name,
132
+ description: config.description,
133
+ type: 'glob-only',
134
+ files,
135
+ count: files.length
136
+ }, null, 2));
137
+ } catch {
138
+ console.log(JSON.stringify({
139
+ pattern: name,
140
+ files: [],
141
+ count: 0
142
+ }, null, 2));
143
+ }
144
+ } else {
145
+ const proc = spawn('rg', args, { stdio: 'inherit' });
146
+ proc.on('close', code => process.exit(code === 1 ? 0 : code));
147
+ }
81
148
  return;
82
149
  }
83
150
 
84
- const args = ['--color=always', '-n', '-e', config.pattern];
151
+ const args = jsonMode
152
+ ? ['-n', '--json', '-e', config.pattern]
153
+ : ['--color=always', '-n', '-e', config.pattern];
85
154
 
86
155
  if (config.glob) {
87
156
  args.push('-g', config.glob);
@@ -95,29 +164,81 @@ function runSearch(name, config, rootDir) {
95
164
 
96
165
  args.push(rootDir);
97
166
 
98
- const proc = spawn('rg', args, { stdio: 'inherit' });
99
- proc.on('close', code => {
100
- if (code === 1) {
101
- console.log('No matches found.');
167
+ if (jsonMode) {
168
+ try {
169
+ const cacheKey = { op: 'rg-search-pattern', name, pattern: config.pattern, glob: config.glob };
170
+ const result = withCache(
171
+ cacheKey,
172
+ () => execSync(`rg ${args.map(a => `"${a}"`).join(' ')} 2>/dev/null || true`, {
173
+ encoding: 'utf-8',
174
+ maxBuffer: 10 * 1024 * 1024
175
+ }),
176
+ { projectRoot: rootDir }
177
+ );
178
+ const matches = result.trim().split('\n')
179
+ .filter(line => line.startsWith('{'))
180
+ .map(line => {
181
+ try { return JSON.parse(line); } catch { return null; }
182
+ })
183
+ .filter(Boolean)
184
+ .filter(m => m.type === 'match')
185
+ .map(m => ({
186
+ file: m.data.path.text,
187
+ line: m.data.line_number,
188
+ text: m.data.lines.text.trim()
189
+ }));
190
+
191
+ console.log(JSON.stringify({
192
+ pattern: name,
193
+ description: config.description,
194
+ searchPattern: config.pattern,
195
+ matches,
196
+ count: matches.length
197
+ }, null, 2));
198
+ } catch {
199
+ console.log(JSON.stringify({
200
+ pattern: name,
201
+ matches: [],
202
+ count: 0
203
+ }, null, 2));
102
204
  }
103
- process.exit(code === 1 ? 0 : code);
104
- });
205
+ } else {
206
+ const proc = spawn('rg', args, { stdio: 'inherit' });
207
+ proc.on('close', code => {
208
+ if (code === 1) {
209
+ console.log('No matches found.');
210
+ }
211
+ process.exit(code === 1 ? 0 : code);
212
+ });
213
+ }
105
214
  }
106
215
 
107
216
  // Main
217
+ const args = process.argv.slice(2);
218
+ const options = parseCommonArgs(args);
219
+
220
+ if (options.help) {
221
+ console.log(HELP);
222
+ process.exit(0);
223
+ }
224
+
108
225
  const { config, projectRoot } = loadConfig();
109
226
  const patterns = config.searchPatterns || {};
110
- const patternName = process.argv[2];
227
+ const patternName = options.remaining.find(a => !a.startsWith('-'));
111
228
 
112
- if (!patternName || patternName === '--help' || patternName === '-h') {
113
- showHelp(patterns);
229
+ if (!patternName) {
230
+ const out = createOutput(options);
231
+ showPatterns(patterns, out);
232
+ out.print();
114
233
  process.exit(0);
115
234
  }
116
235
 
117
236
  if (!patterns[patternName]) {
118
237
  console.error(`\nUnknown pattern: "${patternName}"`);
119
- showHelp(patterns);
238
+ const out = createOutput({ ...options, json: false });
239
+ showPatterns(patterns, out);
240
+ out.print();
120
241
  process.exit(1);
121
242
  }
122
243
 
123
- runSearch(patternName, patterns[patternName], projectRoot);
244
+ runSearch(patternName, patterns[patternName], projectRoot, options.json);