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,324 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * tl-flow - Show call graph for a function
5
+ *
6
+ * Traces what calls a function and what it calls.
7
+ * Helps understand code paths without reading entire files.
8
+ *
9
+ * Usage: tl-flow <function-name> [file]
10
+ */
11
+
12
+ // Prompt info for tl-prompt
13
+ if (process.argv.includes('--prompt')) {
14
+ console.log(JSON.stringify({
15
+ name: 'tl-flow',
16
+ desc: 'Call graph: what calls this, what it calls',
17
+ when: 'before-read',
18
+ example: 'tl-flow handleSubmit'
19
+ }));
20
+ process.exit(0);
21
+ }
22
+
23
+ import { execSync } from 'child_process';
24
+ import { existsSync, readFileSync } from 'fs';
25
+ import { basename, dirname, relative, resolve, extname } from 'path';
26
+ import {
27
+ createOutput,
28
+ parseCommonArgs,
29
+ shellEscape,
30
+ COMMON_OPTIONS_HELP
31
+ } from '../src/output.mjs';
32
+ import { findProjectRoot } from '../src/project.mjs';
33
+
34
+ const HELP = `
35
+ tl-flow - Show call graph for a function
36
+
37
+ Usage: tl-flow <function-name> [options]
38
+
39
+ Options:
40
+ --file, -f <file> Limit search to specific file
41
+ --depth N, -d N Max depth for call tree (default: 2)
42
+ --callers Show only what calls this function
43
+ --callees Show only what this function calls
44
+ ${COMMON_OPTIONS_HELP}
45
+
46
+ Examples:
47
+ tl-flow handleSubmit # Find handleSubmit and trace calls
48
+ tl-flow loadConfig -f src/ # Search only in src/
49
+ tl-flow parseArgs --callers # Only show what calls parseArgs
50
+ tl-flow render --depth 3 # Deeper call tree
51
+ `;
52
+
53
+ // ─────────────────────────────────────────────────────────────
54
+ // Function Finding
55
+ // ─────────────────────────────────────────────────────────────
56
+
57
+ function findFunctionDefinitions(name, projectRoot, limitPath) {
58
+ const searchPath = limitPath || projectRoot;
59
+ const definitions = [];
60
+
61
+ try {
62
+ // Pattern to find function definitions
63
+ const patterns = [
64
+ `function ${name}\\s*\\(`, // function name(
65
+ `(const|let|var)\\s+${name}\\s*=`, // const name =
66
+ `${name}\\s*:\\s*\\(`, // name: ( (object method)
67
+ `${name}\\s*\\([^)]*\\)\\s*\\{`, // name() { (class method)
68
+ `async\\s+${name}\\s*\\(`, // async name(
69
+ ];
70
+
71
+ const pattern = `(${patterns.join('|')})`;
72
+ const cmd = `rg -n -g "*.{ts,tsx,js,jsx,mjs}" --no-heading -e "${shellEscape(pattern)}" "${shellEscape(searchPath)}" 2>/dev/null || true`;
73
+
74
+ const result = execSync(cmd, { encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 });
75
+
76
+ for (const line of result.trim().split('\n')) {
77
+ if (!line) continue;
78
+ const match = line.match(/^([^:]+):(\d+):(.*)$/);
79
+ if (!match) continue;
80
+
81
+ const [, file, lineNum, content] = match;
82
+ if (file.includes('node_modules')) continue;
83
+ if (file.includes('.test.') || file.includes('.spec.')) continue;
84
+
85
+ definitions.push({
86
+ file,
87
+ line: parseInt(lineNum, 10),
88
+ content: content.trim()
89
+ });
90
+ }
91
+ } catch (e) {
92
+ // rg error
93
+ }
94
+
95
+ return definitions;
96
+ }
97
+
98
+ function findCallers(name, projectRoot, excludeFile) {
99
+ const callers = [];
100
+
101
+ try {
102
+ // Find calls to the function
103
+ const pattern = `${name}\\s*\\(`;
104
+ const cmd = `rg -n -g "*.{ts,tsx,js,jsx,mjs}" --no-heading -e "${shellEscape(pattern)}" "${shellEscape(projectRoot)}" 2>/dev/null || true`;
105
+
106
+ const result = execSync(cmd, { encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 });
107
+
108
+ for (const line of result.trim().split('\n')) {
109
+ if (!line) continue;
110
+ const match = line.match(/^([^:]+):(\d+):(.*)$/);
111
+ if (!match) continue;
112
+
113
+ const [, file, lineNum, content] = match;
114
+ if (file.includes('node_modules')) continue;
115
+
116
+ // Skip the definition itself
117
+ if (content.includes(`function ${name}`) ||
118
+ content.includes(`const ${name}`) ||
119
+ content.includes(`let ${name}`) ||
120
+ content.includes(`var ${name}`)) {
121
+ continue;
122
+ }
123
+
124
+ // Find which function contains this call
125
+ const containingFn = findContainingFunction(file, parseInt(lineNum, 10));
126
+
127
+ callers.push({
128
+ file,
129
+ line: parseInt(lineNum, 10),
130
+ content: content.trim(),
131
+ caller: containingFn
132
+ });
133
+ }
134
+ } catch (e) {
135
+ // rg error
136
+ }
137
+
138
+ // Dedupe by file+caller
139
+ const seen = new Set();
140
+ return callers.filter(c => {
141
+ const key = `${c.file}:${c.caller || 'top'}`;
142
+ if (seen.has(key)) return false;
143
+ seen.add(key);
144
+ return true;
145
+ });
146
+ }
147
+
148
+ function findContainingFunction(file, lineNum) {
149
+ try {
150
+ const content = readFileSync(file, 'utf-8');
151
+ const lines = content.split('\n');
152
+
153
+ // Look backwards for function definition
154
+ for (let i = lineNum - 1; i >= 0; i--) {
155
+ const line = lines[i];
156
+
157
+ // Match function definitions
158
+ const fnMatch = line.match(/(?:function|async function)\s+(\w+)|(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s*)?\(|(\w+)\s*\([^)]*\)\s*\{/);
159
+ if (fnMatch) {
160
+ return fnMatch[1] || fnMatch[2] || fnMatch[3];
161
+ }
162
+
163
+ // Match class method
164
+ const methodMatch = line.match(/^\s*(?:async\s+)?(\w+)\s*\([^)]*\)\s*\{/);
165
+ if (methodMatch && !line.includes('if') && !line.includes('while') && !line.includes('for')) {
166
+ return methodMatch[1];
167
+ }
168
+ }
169
+ } catch (e) {
170
+ // Can't read file
171
+ }
172
+
173
+ return null;
174
+ }
175
+
176
+ function findCallees(file, fnName, lineNum) {
177
+ const callees = [];
178
+
179
+ try {
180
+ const content = readFileSync(file, 'utf-8');
181
+ const lines = content.split('\n');
182
+
183
+ // Find the function body
184
+ let braceCount = 0;
185
+ let inFunction = false;
186
+ let startLine = lineNum - 1;
187
+
188
+ // Find start of function
189
+ for (let i = startLine; i < lines.length; i++) {
190
+ const line = lines[i];
191
+
192
+ if (!inFunction && line.includes('{')) {
193
+ inFunction = true;
194
+ }
195
+
196
+ if (inFunction) {
197
+ braceCount += (line.match(/\{/g) || []).length;
198
+ braceCount -= (line.match(/\}/g) || []).length;
199
+
200
+ // Find function calls in this line
201
+ const callMatches = line.matchAll(/(\w+)\s*\(/g);
202
+ for (const match of callMatches) {
203
+ const callee = match[1];
204
+ // Skip common keywords
205
+ if (['if', 'for', 'while', 'switch', 'catch', 'function', 'return', 'typeof', 'new'].includes(callee)) {
206
+ continue;
207
+ }
208
+ // Skip the function's own name (recursion is fine but don't list as primary callee)
209
+ if (callee === fnName) continue;
210
+
211
+ if (!callees.includes(callee)) {
212
+ callees.push(callee);
213
+ }
214
+ }
215
+
216
+ if (braceCount === 0) break;
217
+ }
218
+ }
219
+ } catch (e) {
220
+ // Can't read file
221
+ }
222
+
223
+ return callees;
224
+ }
225
+
226
+ // ─────────────────────────────────────────────────────────────
227
+ // Main
228
+ // ─────────────────────────────────────────────────────────────
229
+
230
+ const args = process.argv.slice(2);
231
+ const options = parseCommonArgs(args);
232
+
233
+ if (options.help) {
234
+ console.log(HELP);
235
+ process.exit(0);
236
+ }
237
+
238
+ // Parse tool-specific options
239
+ let targetFile = null;
240
+ let depth = 2;
241
+ let showCallers = true;
242
+ let showCallees = true;
243
+
244
+ const remaining = [];
245
+ for (let i = 0; i < options.remaining.length; i++) {
246
+ const arg = options.remaining[i];
247
+ if ((arg === '--file' || arg === '-f') && options.remaining[i + 1]) {
248
+ targetFile = options.remaining[++i];
249
+ } else if ((arg === '--depth' || arg === '-d') && options.remaining[i + 1]) {
250
+ depth = parseInt(options.remaining[++i], 10);
251
+ } else if (arg === '--callers') {
252
+ showCallees = false;
253
+ } else if (arg === '--callees') {
254
+ showCallers = false;
255
+ } else if (!arg.startsWith('-')) {
256
+ remaining.push(arg);
257
+ }
258
+ }
259
+
260
+ const fnName = remaining[0];
261
+
262
+ if (!fnName) {
263
+ console.log(HELP);
264
+ process.exit(1);
265
+ }
266
+
267
+ const projectRoot = findProjectRoot();
268
+ const out = createOutput(options);
269
+
270
+ out.header(`\n🔀 Call flow: ${fnName}`);
271
+
272
+ // Find function definitions
273
+ const definitions = findFunctionDefinitions(fnName, projectRoot, targetFile);
274
+
275
+ if (definitions.length === 0) {
276
+ out.add(`\n No definition found for "${fnName}"`);
277
+ out.print();
278
+ process.exit(0);
279
+ }
280
+
281
+ // Show definitions
282
+ out.add('\n📍 Defined in:');
283
+ for (const def of definitions.slice(0, 5)) {
284
+ const rel = relative(projectRoot, def.file);
285
+ out.add(` ${rel}:${def.line}`);
286
+ }
287
+
288
+ // Show callers
289
+ if (showCallers) {
290
+ const callers = findCallers(fnName, projectRoot);
291
+
292
+ if (callers.length > 0) {
293
+ out.add('\n⬅️ Called by:');
294
+ for (const caller of callers.slice(0, 10)) {
295
+ const rel = relative(projectRoot, caller.file);
296
+ const from = caller.caller ? ` (in ${caller.caller})` : '';
297
+ out.add(` ${rel}:${caller.line}${from}`);
298
+ }
299
+ if (callers.length > 10) {
300
+ out.add(` ... and ${callers.length - 10} more`);
301
+ }
302
+ } else {
303
+ out.add('\n⬅️ Called by: (no callers found)');
304
+ }
305
+ }
306
+
307
+ // Show callees
308
+ if (showCallees && definitions.length > 0) {
309
+ const def = definitions[0];
310
+ const callees = findCallees(def.file, fnName, def.line);
311
+
312
+ if (callees.length > 0) {
313
+ out.add('\n➡️ Calls:');
314
+ out.add(` ${callees.slice(0, 15).join(', ')}`);
315
+ if (callees.length > 15) {
316
+ out.add(` ... and ${callees.length - 15} more`);
317
+ }
318
+ } else {
319
+ out.add('\n➡️ Calls: (no function calls found)');
320
+ }
321
+ }
322
+
323
+ out.add('');
324
+ out.print();
@@ -0,0 +1,289 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * tl-history - Recent changes to a file, summarized
5
+ *
6
+ * Shows commit history for a file without full diffs - just commit messages,
7
+ * authors, and dates. Perfect for understanding how a file has evolved.
8
+ *
9
+ * Usage: tl-history <file> [--limit N]
10
+ */
11
+
12
+ // Prompt info for tl-prompt
13
+ if (process.argv.includes('--prompt')) {
14
+ console.log(JSON.stringify({
15
+ name: 'tl-history',
16
+ desc: 'Recent changes to a file (commits only)',
17
+ when: 'before-read',
18
+ example: 'tl-history src/api.ts'
19
+ }));
20
+ process.exit(0);
21
+ }
22
+
23
+ import { existsSync } from 'fs';
24
+ import { spawnSync } from 'child_process';
25
+ import { basename, relative } from 'path';
26
+ import {
27
+ createOutput,
28
+ parseCommonArgs,
29
+ COMMON_OPTIONS_HELP
30
+ } from '../src/output.mjs';
31
+ import { findProjectRoot } from '../src/project.mjs';
32
+
33
+ const HELP = `
34
+ tl-history - Recent changes to a file, summarized
35
+
36
+ Usage: tl-history <file> [options]
37
+
38
+ Options:
39
+ --limit N, -n N Number of commits to show (default: 20)
40
+ --since <date> Only commits after date (e.g., "2 weeks ago", "2024-01-01")
41
+ --author <name> Filter by author
42
+ --stat Show file change stats (+/- lines)
43
+ --oneline Ultra-compact one-line format
44
+ ${COMMON_OPTIONS_HELP}
45
+
46
+ Examples:
47
+ tl-history src/api.ts # Recent commits
48
+ tl-history src/api.ts -n 50 # Last 50 commits
49
+ tl-history src/api.ts --since "1 month ago"
50
+ tl-history src/api.ts --stat # With change stats
51
+ tl-history src/api.ts --oneline # Compact format
52
+
53
+ Output includes:
54
+ - Commit hash (short)
55
+ - Date
56
+ - Author
57
+ - Commit message
58
+ `;
59
+
60
+ // ─────────────────────────────────────────────────────────────
61
+ // Git Operations
62
+ // ─────────────────────────────────────────────────────────────
63
+
64
+ function getFileHistory(filePath, options = {}) {
65
+ const {
66
+ limit = 20,
67
+ since = null,
68
+ author = null,
69
+ stat = false
70
+ } = options;
71
+
72
+ const args = ['log', `--max-count=${limit}`, '--pretty=format:%H|%h|%ai|%an|%s'];
73
+
74
+ if (since) {
75
+ args.push(`--since=${since}`);
76
+ }
77
+
78
+ if (author) {
79
+ args.push(`--author=${author}`);
80
+ }
81
+
82
+ if (stat) {
83
+ args.push('--numstat');
84
+ }
85
+
86
+ args.push('--follow'); // Follow file renames
87
+ args.push('--', filePath);
88
+
89
+ const result = spawnSync('git', args, {
90
+ encoding: 'utf-8',
91
+ maxBuffer: 10 * 1024 * 1024
92
+ });
93
+
94
+ if (result.error) {
95
+ throw result.error;
96
+ }
97
+
98
+ if (result.status !== 0 && result.stderr) {
99
+ if (result.stderr.includes('not a git repository')) {
100
+ throw new Error('Not in a git repository');
101
+ }
102
+ throw new Error(result.stderr);
103
+ }
104
+
105
+ return parseGitLog(result.stdout || '', stat);
106
+ }
107
+
108
+ function parseGitLog(output, includeStat) {
109
+ const commits = [];
110
+ const lines = output.trim().split('\n');
111
+
112
+ let i = 0;
113
+ while (i < lines.length) {
114
+ const line = lines[i];
115
+ if (!line) {
116
+ i++;
117
+ continue;
118
+ }
119
+
120
+ // Parse commit line: hash|shortHash|date|author|message
121
+ const parts = line.split('|');
122
+ if (parts.length >= 5) {
123
+ const commit = {
124
+ hash: parts[0],
125
+ shortHash: parts[1],
126
+ date: parts[2],
127
+ author: parts[3],
128
+ message: parts.slice(4).join('|'), // Message might contain |
129
+ stats: null
130
+ };
131
+
132
+ i++;
133
+
134
+ // If stat mode, parse numstat lines
135
+ if (includeStat) {
136
+ let added = 0;
137
+ let deleted = 0;
138
+
139
+ while (i < lines.length && lines[i] && !lines[i].includes('|')) {
140
+ const statLine = lines[i].trim();
141
+ if (statLine) {
142
+ const statParts = statLine.split('\t');
143
+ if (statParts.length >= 2) {
144
+ const a = parseInt(statParts[0], 10) || 0;
145
+ const d = parseInt(statParts[1], 10) || 0;
146
+ added += a;
147
+ deleted += d;
148
+ }
149
+ }
150
+ i++;
151
+ }
152
+
153
+ if (added > 0 || deleted > 0) {
154
+ commit.stats = { added, deleted };
155
+ }
156
+ }
157
+
158
+ commits.push(commit);
159
+ } else {
160
+ i++;
161
+ }
162
+ }
163
+
164
+ return commits;
165
+ }
166
+
167
+ function formatDate(isoDate) {
168
+ // Convert "2024-01-15 14:30:45 +0000" to "Jan 15"
169
+ const date = new Date(isoDate);
170
+ const now = new Date();
171
+ const diffDays = Math.floor((now - date) / (1000 * 60 * 60 * 24));
172
+
173
+ if (diffDays === 0) return 'today';
174
+ if (diffDays === 1) return 'yesterday';
175
+ if (diffDays < 7) return `${diffDays}d ago`;
176
+
177
+ const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
178
+ const month = months[date.getMonth()];
179
+ const day = date.getDate();
180
+
181
+ if (date.getFullYear() === now.getFullYear()) {
182
+ return `${month} ${day}`;
183
+ }
184
+
185
+ return `${month} ${day}, ${date.getFullYear()}`;
186
+ }
187
+
188
+ function formatStats(stats) {
189
+ if (!stats) return '';
190
+ const { added, deleted } = stats;
191
+ const parts = [];
192
+ if (added > 0) parts.push(`+${added}`);
193
+ if (deleted > 0) parts.push(`-${deleted}`);
194
+ return parts.length > 0 ? ` (${parts.join('/')})` : '';
195
+ }
196
+
197
+ // ─────────────────────────────────────────────────────────────
198
+ // Main
199
+ // ─────────────────────────────────────────────────────────────
200
+
201
+ const args = process.argv.slice(2);
202
+ const options = parseCommonArgs(args);
203
+
204
+ // Parse custom options
205
+ let limit = 20;
206
+ let since = null;
207
+ let author = null;
208
+ let showStat = false;
209
+ let oneline = false;
210
+
211
+ const remaining = [];
212
+ for (let i = 0; i < options.remaining.length; i++) {
213
+ const arg = options.remaining[i];
214
+
215
+ if (arg === '--limit' || arg === '-n') {
216
+ limit = parseInt(options.remaining[++i], 10) || 20;
217
+ } else if (arg === '--since') {
218
+ since = options.remaining[++i];
219
+ } else if (arg === '--author') {
220
+ author = options.remaining[++i];
221
+ } else if (arg === '--stat') {
222
+ showStat = true;
223
+ } else if (arg === '--oneline') {
224
+ oneline = true;
225
+ } else if (!arg.startsWith('-')) {
226
+ remaining.push(arg);
227
+ }
228
+ }
229
+
230
+ const filePath = remaining[0];
231
+
232
+ if (options.help || !filePath) {
233
+ console.log(HELP);
234
+ process.exit(options.help ? 0 : 1);
235
+ }
236
+
237
+ if (!existsSync(filePath)) {
238
+ console.error(`File not found: ${filePath}`);
239
+ process.exit(1);
240
+ }
241
+
242
+ const projectRoot = findProjectRoot();
243
+ const relPath = relative(projectRoot, filePath);
244
+ const out = createOutput(options);
245
+
246
+ try {
247
+ const commits = getFileHistory(filePath, { limit, since, author, stat: showStat });
248
+
249
+ if (commits.length === 0) {
250
+ console.log('No commits found for this file');
251
+ process.exit(0);
252
+ }
253
+
254
+ // Set JSON data
255
+ out.setData('file', relPath);
256
+ out.setData('commits', commits);
257
+ out.setData('totalCommits', commits.length);
258
+
259
+ // Format output
260
+ out.header(`📜 ${relPath} - ${commits.length} recent commits`);
261
+ out.blank();
262
+
263
+ for (const commit of commits) {
264
+ if (oneline) {
265
+ const statsStr = formatStats(commit.stats);
266
+ out.add(`${commit.shortHash} ${formatDate(commit.date)} ${commit.message}${statsStr}`);
267
+ } else {
268
+ const statsStr = formatStats(commit.stats);
269
+ out.add(`${commit.shortHash} ${formatDate(commit.date).padEnd(12)} ${commit.author}`);
270
+ out.add(` ${commit.message}${statsStr}`);
271
+ out.blank();
272
+ }
273
+ }
274
+
275
+ // Summary
276
+ if (!options.quiet && !oneline) {
277
+ const authors = new Set(commits.map(c => c.author));
278
+ const oldestDate = commits[commits.length - 1]?.date;
279
+ const oldest = oldestDate ? formatDate(oldestDate) : '';
280
+
281
+ out.add(`---`);
282
+ out.add(`${commits.length} commits by ${authors.size} author(s), oldest: ${oldest}`);
283
+ }
284
+
285
+ out.print();
286
+ } catch (error) {
287
+ console.error(`Error: ${error.message}`);
288
+ process.exit(1);
289
+ }