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,341 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * tl-todo - Extract TODOs, FIXMEs, and other markers from codebase
5
+ *
6
+ * Quickly find all task markers in your code. Helps prioritize work
7
+ * and find forgotten tasks without reading entire files.
8
+ *
9
+ * Usage: tl-todo [path]
10
+ */
11
+
12
+ // Prompt info for tl-prompt
13
+ if (process.argv.includes('--prompt')) {
14
+ console.log(JSON.stringify({
15
+ name: 'tl-todo',
16
+ desc: 'Find TODOs, FIXMEs in codebase',
17
+ when: 'search',
18
+ example: 'tl-todo --priority'
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 } from 'path';
26
+ import {
27
+ createOutput,
28
+ parseCommonArgs,
29
+ estimateTokens,
30
+ formatTokens,
31
+ shellEscape,
32
+ COMMON_OPTIONS_HELP
33
+ } from '../src/output.mjs';
34
+ import { findProjectRoot, shouldSkip, SKIP_DIRS } from '../src/project.mjs';
35
+
36
+ const HELP = `
37
+ tl-todo - Extract TODOs, FIXMEs, and other markers from codebase
38
+
39
+ Usage: tl-todo [path] [options]
40
+
41
+ Options:
42
+ --type T, -t T Filter by type (todo, fixme, hack, xxx, note)
43
+ --author A Filter by author (e.g., "@john")
44
+ --priority, -p Sort by priority (FIXME > TODO > HACK > NOTE)
45
+ --context N, -c N Show N lines of context (default: 0)
46
+ ${COMMON_OPTIONS_HELP}
47
+
48
+ Examples:
49
+ tl-todo # All markers in project
50
+ tl-todo src/ # Only in src/
51
+ tl-todo -t fixme # Only FIXMEs
52
+ tl-todo -p # Sort by priority
53
+ tl-todo -c 2 # Show 2 lines of context
54
+
55
+ Markers detected:
56
+ šŸ”“ FIXME - Bugs or critical issues
57
+ 🟔 TODO - Tasks to complete
58
+ 🟠 HACK - Temporary workarounds
59
+ ⚪ XXX - Warnings/concerns
60
+ šŸ”µ NOTE - Important information
61
+ `;
62
+
63
+ // Marker types with priority (lower = higher priority)
64
+ const MARKERS = {
65
+ FIXME: { emoji: 'šŸ”“', priority: 1 },
66
+ FIX: { emoji: 'šŸ”“', priority: 1 },
67
+ BUG: { emoji: 'šŸ”“', priority: 1 },
68
+ TODO: { emoji: '🟔', priority: 2 },
69
+ HACK: { emoji: '🟠', priority: 3 },
70
+ WORKAROUND: { emoji: '🟠', priority: 3 },
71
+ XXX: { emoji: '⚪', priority: 4 },
72
+ WARN: { emoji: '⚪', priority: 4 },
73
+ NOTE: { emoji: 'šŸ”µ', priority: 5 },
74
+ INFO: { emoji: 'šŸ”µ', priority: 5 },
75
+ };
76
+
77
+ // ─────────────────────────────────────────────────────────────
78
+ // Todo Extraction
79
+ // ─────────────────────────────────────────────────────────────
80
+
81
+ function findTodos(searchPath, projectRoot) {
82
+ const todos = [];
83
+ const markerPattern = Object.keys(MARKERS).join('|');
84
+
85
+ // Comment prefixes to detect actual comment lines
86
+ // Must be preceded by comment syntax or be in a comment context
87
+ const commentPrefixes = [
88
+ '//', // C-style line comments
89
+ '#', // Shell, Python, Ruby
90
+ '--', // SQL, Lua, Haskell
91
+ '\\*', // Inside /* */ blocks
92
+ '<!--', // HTML comments
93
+ '\\{#', // Jinja/Django templates (escaped brace)
94
+ ];
95
+
96
+ try {
97
+ // Build exclude patterns for ripgrep
98
+ const excludes = [...SKIP_DIRS].map(d => `--glob=!${d}`).join(' ');
99
+
100
+ // Search for markers that look like they're in comments
101
+ // Pattern: comment prefix followed by optional whitespace, then marker with colon/parens
102
+ // Require : or ( after marker to avoid false positives like "Todo Extraction"
103
+ const commentPattern = `(${commentPrefixes.join('|')})\\s*(${markerPattern})[:(]`;
104
+ const cmd = `rg -n --no-heading -i "${commentPattern}" "${shellEscape(searchPath)}" ${excludes} 2>/dev/null || true`;
105
+ const output = execSync(cmd, { encoding: 'utf-8', maxBuffer: 50 * 1024 * 1024 });
106
+
107
+ if (!output.trim()) {
108
+ return todos;
109
+ }
110
+
111
+ const lines = output.trim().split('\n');
112
+
113
+ for (const line of lines) {
114
+ // Format: file:line:content
115
+ const match = line.match(/^([^:]+):(\d+):(.*)$/);
116
+ if (!match) continue;
117
+
118
+ const [, file, lineNum, content] = match;
119
+
120
+ // Skip if file should be skipped
121
+ if (shouldSkip(basename(file), false)) continue;
122
+
123
+ // Extract marker type and message
124
+ const markerMatch = content.match(new RegExp(`(${markerPattern})[:\\s(]+(.*)`, 'i'));
125
+ if (!markerMatch) continue;
126
+
127
+ const type = markerMatch[1].toUpperCase();
128
+ let message = markerMatch[2].trim();
129
+
130
+ // Clean up common comment endings
131
+ message = message.replace(/\*\/\s*$/, '').replace(/-->\s*$/, '').replace(/\)\s*$/, '').trim();
132
+
133
+ // Skip if message is too short or looks like code/section header
134
+ if (message.length < 3) continue;
135
+ if (/^[{(\[;,=]/.test(message)) continue;
136
+ if (/^─+$/.test(message)) continue; // Section dividers
137
+
138
+ // Extract author if present: TODO(@author): message or TODO(author): message
139
+ // Only match if explicitly has @ or parens around author name
140
+ let author = null;
141
+ const authorMatch = message.match(/^[@(](\w+)\)?[:\s]+(.+)$/);
142
+ if (authorMatch && authorMatch[1].length < 15 && authorMatch[2].length > 5) {
143
+ author = authorMatch[1];
144
+ message = authorMatch[2];
145
+ }
146
+
147
+ const relFile = relative(projectRoot, resolve(searchPath, file));
148
+
149
+ todos.push({
150
+ file: relFile,
151
+ line: parseInt(lineNum, 10),
152
+ type: MARKERS[type] ? type : 'TODO',
153
+ message: message.substring(0, 200),
154
+ author,
155
+ priority: MARKERS[type]?.priority || 2
156
+ });
157
+ }
158
+ } catch (e) {
159
+ // ripgrep error
160
+ }
161
+
162
+ return todos;
163
+ }
164
+
165
+ function getContext(file, lineNum, contextLines, projectRoot) {
166
+ if (contextLines <= 0) return null;
167
+
168
+ try {
169
+ const fullPath = resolve(projectRoot, file);
170
+ const content = readFileSync(fullPath, 'utf-8');
171
+ const lines = content.split('\n');
172
+
173
+ const start = Math.max(0, lineNum - 1 - contextLines);
174
+ const end = Math.min(lines.length, lineNum + contextLines);
175
+
176
+ return lines.slice(start, end).map((l, i) => ({
177
+ num: start + i + 1,
178
+ content: l.substring(0, 120),
179
+ isTodo: start + i + 1 === lineNum
180
+ }));
181
+ } catch {
182
+ return null;
183
+ }
184
+ }
185
+
186
+ // ─────────────────────────────────────────────────────────────
187
+ // Output
188
+ // ─────────────────────────────────────────────────────────────
189
+
190
+ function formatTodos(todos, out, contextLines, projectRoot) {
191
+ // Group by file
192
+ const byFile = new Map();
193
+ for (const todo of todos) {
194
+ if (!byFile.has(todo.file)) {
195
+ byFile.set(todo.file, []);
196
+ }
197
+ byFile.get(todo.file).push(todo);
198
+ }
199
+
200
+ for (const [file, fileTodos] of byFile) {
201
+ out.add(`šŸ“„ ${file}`);
202
+
203
+ for (const todo of fileTodos) {
204
+ const marker = MARKERS[todo.type] || MARKERS.TODO;
205
+ let line = ` ${marker.emoji} L${todo.line}: ${todo.message}`;
206
+ if (todo.author) {
207
+ line += ` (@${todo.author})`;
208
+ }
209
+ out.add(line);
210
+
211
+ // Add context if requested
212
+ if (contextLines > 0) {
213
+ const context = getContext(file, todo.line, contextLines, projectRoot);
214
+ if (context) {
215
+ for (const ctx of context) {
216
+ const prefix = ctx.isTodo ? ' → ' : ' ';
217
+ out.add(` ${ctx.num.toString().padStart(4)}${prefix}${ctx.content}`);
218
+ }
219
+ }
220
+ }
221
+ }
222
+ out.blank();
223
+ }
224
+ }
225
+
226
+ function formatSummary(todos, out) {
227
+ const counts = {};
228
+ for (const marker of Object.keys(MARKERS)) {
229
+ counts[marker] = 0;
230
+ }
231
+
232
+ for (const todo of todos) {
233
+ counts[todo.type] = (counts[todo.type] || 0) + 1;
234
+ }
235
+
236
+ const parts = [];
237
+ for (const [type, count] of Object.entries(counts)) {
238
+ if (count > 0) {
239
+ parts.push(`${MARKERS[type].emoji} ${count} ${type}`);
240
+ }
241
+ }
242
+
243
+ return parts.join(' ');
244
+ }
245
+
246
+ // ─────────────────────────────────────────────────────────────
247
+ // Main
248
+ // ─────────────────────────────────────────────────────────────
249
+
250
+ const args = process.argv.slice(2);
251
+ const options = parseCommonArgs(args);
252
+
253
+ // Parse tool-specific options
254
+ let filterType = null;
255
+ let filterAuthor = null;
256
+ let sortByPriority = false;
257
+ let contextLines = 0;
258
+
259
+ const consumedIndices = new Set();
260
+
261
+ for (let i = 0; i < options.remaining.length; i++) {
262
+ const arg = options.remaining[i];
263
+ if ((arg === '--type' || arg === '-t') && options.remaining[i + 1]) {
264
+ filterType = options.remaining[i + 1].toUpperCase();
265
+ consumedIndices.add(i);
266
+ consumedIndices.add(i + 1);
267
+ i++;
268
+ } else if (arg === '--author' && options.remaining[i + 1]) {
269
+ filterAuthor = options.remaining[i + 1].replace(/^@/, '');
270
+ consumedIndices.add(i);
271
+ consumedIndices.add(i + 1);
272
+ i++;
273
+ } else if (arg === '--priority' || arg === '-p') {
274
+ sortByPriority = true;
275
+ consumedIndices.add(i);
276
+ } else if ((arg === '--context' || arg === '-c') && options.remaining[i + 1]) {
277
+ contextLines = parseInt(options.remaining[i + 1], 10);
278
+ consumedIndices.add(i);
279
+ consumedIndices.add(i + 1);
280
+ i++;
281
+ }
282
+ }
283
+
284
+ const targetPath = options.remaining.find((a, i) => !a.startsWith('-') && !consumedIndices.has(i)) || '.';
285
+
286
+ if (options.help) {
287
+ console.log(HELP);
288
+ process.exit(0);
289
+ }
290
+
291
+ const projectRoot = findProjectRoot();
292
+ const resolvedPath = resolve(targetPath);
293
+ const relPath = relative(projectRoot, resolvedPath) || '.';
294
+
295
+ if (!existsSync(resolvedPath)) {
296
+ console.error(`Path not found: ${targetPath}`);
297
+ process.exit(1);
298
+ }
299
+
300
+ const out = createOutput(options);
301
+
302
+ out.header(`\nšŸ“‹ TODOs: ${relPath === '.' ? basename(projectRoot) : relPath}`);
303
+
304
+ let todos = findTodos(resolvedPath, projectRoot);
305
+
306
+ // Apply filters
307
+ if (filterType) {
308
+ todos = todos.filter(t => t.type === filterType);
309
+ }
310
+ if (filterAuthor) {
311
+ todos = todos.filter(t => t.author && t.author.toLowerCase() === filterAuthor.toLowerCase());
312
+ }
313
+
314
+ // Sort
315
+ if (sortByPriority) {
316
+ todos.sort((a, b) => a.priority - b.priority || a.file.localeCompare(b.file));
317
+ } else {
318
+ todos.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
319
+ }
320
+
321
+ if (todos.length === 0) {
322
+ out.header(' No markers found! ✨');
323
+ out.print();
324
+ process.exit(0);
325
+ }
326
+
327
+ out.header(` ${todos.length} markers found`);
328
+ out.blank();
329
+
330
+ formatTodos(todos, out, contextLines, projectRoot);
331
+
332
+ out.stats('─'.repeat(50));
333
+ out.stats(`šŸ“Š ${formatSummary(todos, out)}`);
334
+ out.blank();
335
+
336
+ // JSON data
337
+ out.setData('path', relPath);
338
+ out.setData('count', todos.length);
339
+ out.setData('todos', todos);
340
+
341
+ out.print();