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.
- package/README.md +248 -0
- package/bin/tl-api.mjs +515 -0
- package/bin/tl-blame.mjs +345 -0
- package/bin/tl-complexity.mjs +514 -0
- package/bin/tl-component.mjs +274 -0
- package/bin/tl-config.mjs +135 -0
- package/bin/tl-context.mjs +156 -0
- package/bin/tl-coverage.mjs +456 -0
- package/bin/tl-deps.mjs +474 -0
- package/bin/tl-diff.mjs +183 -0
- package/bin/tl-entry.mjs +256 -0
- package/bin/tl-env.mjs +376 -0
- package/bin/tl-exports.mjs +583 -0
- package/bin/tl-flow.mjs +324 -0
- package/bin/tl-history.mjs +289 -0
- package/bin/tl-hotspots.mjs +321 -0
- package/bin/tl-impact.mjs +345 -0
- package/bin/tl-prompt.mjs +175 -0
- package/bin/tl-related.mjs +227 -0
- package/bin/tl-routes.mjs +627 -0
- package/bin/tl-search.mjs +123 -0
- package/bin/tl-structure.mjs +161 -0
- package/bin/tl-symbols.mjs +430 -0
- package/bin/tl-todo.mjs +341 -0
- package/bin/tl-types.mjs +441 -0
- package/bin/tl-unused.mjs +494 -0
- package/package.json +55 -0
- package/src/config.mjs +271 -0
- package/src/output.mjs +251 -0
- package/src/project.mjs +277 -0
package/bin/tl-blame.mjs
ADDED
|
@@ -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
|
+
}
|