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,345 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * tl-blame - Compact per-line authorship
5
+ *
6
+ * Shows who changed each line recently, in a token-efficient format.
7
+ * Groups consecutive lines by the same author/commit for readability.
8
+ *
9
+ * Usage: tl-blame <file> [--full]
10
+ */
11
+
12
+ // Prompt info for tl-prompt
13
+ if (process.argv.includes('--prompt')) {
14
+ console.log(JSON.stringify({
15
+ name: 'tl-blame',
16
+ desc: 'Compact per-line authorship',
17
+ when: 'before-read',
18
+ example: 'tl-blame src/api.ts'
19
+ }));
20
+ process.exit(0);
21
+ }
22
+
23
+ import { existsSync } from 'fs';
24
+ import { spawnSync } from 'child_process';
25
+ import { 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-blame - Compact per-line authorship
35
+
36
+ Usage: tl-blame <file> [options]
37
+
38
+ Options:
39
+ --full Show every line (default: group consecutive same-author lines)
40
+ --since <date> Only show changes after date
41
+ -L <start>,<end> Blame specific line range (e.g., -L 10,20)
42
+ --summary Show only author summary, no line details
43
+ ${COMMON_OPTIONS_HELP}
44
+
45
+ Examples:
46
+ tl-blame src/api.ts # Grouped blame
47
+ tl-blame src/api.ts --full # Every line
48
+ tl-blame src/api.ts -L 50,100 # Lines 50-100 only
49
+ tl-blame src/api.ts --summary # Just author stats
50
+
51
+ Output format (grouped):
52
+ [hash] author (date) lines X-Y
53
+ line content preview...
54
+ `;
55
+
56
+ // ─────────────────────────────────────────────────────────────
57
+ // Git Blame
58
+ // ─────────────────────────────────────────────────────────────
59
+
60
+ function getBlame(filePath, options = {}) {
61
+ const { since = null, lineRange = null } = options;
62
+
63
+ const args = ['blame', '--porcelain'];
64
+
65
+ if (since) {
66
+ args.push(`--since=${since}`);
67
+ }
68
+
69
+ if (lineRange) {
70
+ args.push(`-L${lineRange}`);
71
+ }
72
+
73
+ args.push('--', filePath);
74
+
75
+ const result = spawnSync('git', args, {
76
+ encoding: 'utf-8',
77
+ maxBuffer: 50 * 1024 * 1024
78
+ });
79
+
80
+ if (result.error) {
81
+ throw result.error;
82
+ }
83
+
84
+ if (result.status !== 0 && result.stderr) {
85
+ if (result.stderr.includes('not a git repository')) {
86
+ throw new Error('Not in a git repository');
87
+ }
88
+ if (result.stderr.includes('no such path')) {
89
+ throw new Error('File not tracked by git');
90
+ }
91
+ throw new Error(result.stderr);
92
+ }
93
+
94
+ return parseBlame(result.stdout || '');
95
+ }
96
+
97
+ function parseBlame(output) {
98
+ const lines = [];
99
+ const commits = new Map();
100
+
101
+ const outputLines = output.split('\n');
102
+ let i = 0;
103
+
104
+ while (i < outputLines.length) {
105
+ const line = outputLines[i];
106
+ if (!line) {
107
+ i++;
108
+ continue;
109
+ }
110
+
111
+ // Commit line: hash origLine finalLine [numLines]
112
+ const commitMatch = line.match(/^([a-f0-9]{40})\s+(\d+)\s+(\d+)(?:\s+(\d+))?$/);
113
+ if (commitMatch) {
114
+ const hash = commitMatch[1];
115
+ const lineNum = parseInt(commitMatch[3], 10);
116
+
117
+ // Read metadata until we hit the content line (starts with \t)
118
+ i++;
119
+ let author = '';
120
+ let date = '';
121
+
122
+ while (i < outputLines.length && !outputLines[i].startsWith('\t')) {
123
+ const metaLine = outputLines[i];
124
+
125
+ if (metaLine.startsWith('author ')) {
126
+ author = metaLine.slice(7);
127
+ } else if (metaLine.startsWith('author-time ')) {
128
+ const timestamp = parseInt(metaLine.slice(12), 10);
129
+ date = new Date(timestamp * 1000).toISOString().slice(0, 10);
130
+ }
131
+
132
+ i++;
133
+ }
134
+
135
+ // Content line (starts with \t)
136
+ let content = '';
137
+ if (i < outputLines.length && outputLines[i].startsWith('\t')) {
138
+ content = outputLines[i].slice(1);
139
+ i++;
140
+ }
141
+
142
+ // Store commit info
143
+ if (!commits.has(hash)) {
144
+ commits.set(hash, { author, date, shortHash: hash.slice(0, 7) });
145
+ }
146
+
147
+ lines.push({
148
+ hash,
149
+ lineNum,
150
+ content
151
+ });
152
+ } else {
153
+ i++;
154
+ }
155
+ }
156
+
157
+ return { lines, commits };
158
+ }
159
+
160
+ function groupBlameLines(lines, commits) {
161
+ const groups = [];
162
+ let currentGroup = null;
163
+
164
+ for (const line of lines) {
165
+ if (!currentGroup || currentGroup.hash !== line.hash) {
166
+ // Start new group
167
+ if (currentGroup) {
168
+ groups.push(currentGroup);
169
+ }
170
+ currentGroup = {
171
+ hash: line.hash,
172
+ startLine: line.lineNum,
173
+ endLine: line.lineNum,
174
+ preview: line.content.trim().slice(0, 60),
175
+ ...commits.get(line.hash)
176
+ };
177
+ } else {
178
+ // Extend current group
179
+ currentGroup.endLine = line.lineNum;
180
+ }
181
+ }
182
+
183
+ if (currentGroup) {
184
+ groups.push(currentGroup);
185
+ }
186
+
187
+ return groups;
188
+ }
189
+
190
+ function getAuthorSummary(lines, commits) {
191
+ const authorStats = new Map();
192
+
193
+ for (const line of lines) {
194
+ const commit = commits.get(line.hash);
195
+ if (!commit) continue;
196
+
197
+ const author = commit.author;
198
+ if (!authorStats.has(author)) {
199
+ authorStats.set(author, { lines: 0, commits: new Set() });
200
+ }
201
+
202
+ const stats = authorStats.get(author);
203
+ stats.lines++;
204
+ stats.commits.add(line.hash);
205
+ }
206
+
207
+ return [...authorStats.entries()]
208
+ .map(([author, stats]) => ({
209
+ author,
210
+ lines: stats.lines,
211
+ commits: stats.commits.size,
212
+ percentage: Math.round((stats.lines / lines.length) * 100)
213
+ }))
214
+ .sort((a, b) => b.lines - a.lines);
215
+ }
216
+
217
+ function formatDate(isoDate) {
218
+ const date = new Date(isoDate);
219
+ const now = new Date();
220
+ const diffDays = Math.floor((now - date) / (1000 * 60 * 60 * 24));
221
+
222
+ if (diffDays < 7) return `${diffDays}d`;
223
+ if (diffDays < 30) return `${Math.floor(diffDays / 7)}w`;
224
+ if (diffDays < 365) return `${Math.floor(diffDays / 30)}mo`;
225
+ return `${Math.floor(diffDays / 365)}y`;
226
+ }
227
+
228
+ // ─────────────────────────────────────────────────────────────
229
+ // Main
230
+ // ─────────────────────────────────────────────────────────────
231
+
232
+ const args = process.argv.slice(2);
233
+ const options = parseCommonArgs(args);
234
+
235
+ // Parse custom options
236
+ let showFull = false;
237
+ let since = null;
238
+ let lineRange = null;
239
+ let summaryOnly = false;
240
+
241
+ const remaining = [];
242
+ for (let i = 0; i < options.remaining.length; i++) {
243
+ const arg = options.remaining[i];
244
+
245
+ if (arg === '--full') {
246
+ showFull = true;
247
+ } else if (arg === '--since') {
248
+ since = options.remaining[++i];
249
+ } else if (arg === '-L') {
250
+ lineRange = options.remaining[++i];
251
+ } else if (arg.startsWith('-L')) {
252
+ lineRange = arg.slice(2);
253
+ } else if (arg === '--summary') {
254
+ summaryOnly = true;
255
+ } else if (!arg.startsWith('-')) {
256
+ remaining.push(arg);
257
+ }
258
+ }
259
+
260
+ const filePath = remaining[0];
261
+
262
+ if (options.help || !filePath) {
263
+ console.log(HELP);
264
+ process.exit(options.help ? 0 : 1);
265
+ }
266
+
267
+ if (!existsSync(filePath)) {
268
+ console.error(`File not found: ${filePath}`);
269
+ process.exit(1);
270
+ }
271
+
272
+ const projectRoot = findProjectRoot();
273
+ const relPath = relative(projectRoot, filePath);
274
+ const out = createOutput(options);
275
+
276
+ try {
277
+ const { lines, commits } = getBlame(filePath, { since, lineRange });
278
+
279
+ if (lines.length === 0) {
280
+ console.log('No blame data found');
281
+ process.exit(0);
282
+ }
283
+
284
+ const authorSummary = getAuthorSummary(lines, commits);
285
+
286
+ // Set JSON data
287
+ out.setData('file', relPath);
288
+ out.setData('totalLines', lines.length);
289
+ out.setData('authors', authorSummary);
290
+
291
+ if (summaryOnly) {
292
+ // Just show author summary
293
+ out.header(`📋 ${relPath} - ${lines.length} lines`);
294
+ out.blank();
295
+
296
+ out.add('Author Summary:');
297
+ for (const { author, lines: lineCount, commits: commitCount, percentage } of authorSummary) {
298
+ out.add(` ${author.padEnd(25)} ${String(lineCount).padStart(5)} lines (${percentage}%) in ${commitCount} commits`);
299
+ }
300
+ } else if (showFull) {
301
+ // Show every line
302
+ out.header(`📋 ${relPath} - ${lines.length} lines`);
303
+ out.blank();
304
+
305
+ for (const line of lines) {
306
+ const commit = commits.get(line.hash);
307
+ const age = formatDate(commit.date);
308
+ const authorShort = commit.author.slice(0, 12).padEnd(12);
309
+ out.add(`${commit.shortHash} ${age.padEnd(4)} ${authorShort} │ ${line.content}`);
310
+ }
311
+ } else {
312
+ // Grouped output (default)
313
+ const groups = groupBlameLines(lines, commits);
314
+
315
+ out.header(`📋 ${relPath} - ${lines.length} lines in ${groups.length} blocks`);
316
+ out.blank();
317
+
318
+ for (const group of groups) {
319
+ const age = formatDate(group.date);
320
+ const lineRange = group.startLine === group.endLine
321
+ ? `L${group.startLine}`
322
+ : `L${group.startLine}-${group.endLine}`;
323
+
324
+ out.add(`${group.shortHash} ${group.author} (${age}) ${lineRange}`);
325
+
326
+ if (group.preview) {
327
+ const preview = group.preview.length > 55 ? group.preview.slice(0, 52) + '...' : group.preview;
328
+ out.add(` ${preview}`);
329
+ }
330
+ out.blank();
331
+ }
332
+
333
+ // Summary at bottom
334
+ if (!options.quiet) {
335
+ out.add('---');
336
+ out.add(`${authorSummary.length} author(s): ${authorSummary.map(a => `${a.author} (${a.percentage}%)`).join(', ')}`);
337
+ }
338
+ }
339
+
340
+ out.setData('groups', showFull ? null : groupBlameLines(lines, commits));
341
+ out.print();
342
+ } catch (error) {
343
+ console.error(`Error: ${error.message}`);
344
+ process.exit(1);
345
+ }