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,456 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * tl-coverage - Quick test coverage info for files
5
+ *
6
+ * Reads coverage data from common formats (lcov, istanbul, c8) and shows
7
+ * coverage percentages for files. No need to read full coverage reports.
8
+ *
9
+ * Usage: tl-coverage [file-or-dir] [--below N]
10
+ */
11
+
12
+ // Prompt info for tl-prompt
13
+ if (process.argv.includes('--prompt')) {
14
+ console.log(JSON.stringify({
15
+ name: 'tl-coverage',
16
+ desc: 'Quick test coverage info for files',
17
+ when: 'before-modify',
18
+ example: 'tl-coverage src/'
19
+ }));
20
+ process.exit(0);
21
+ }
22
+
23
+ import { readFileSync, existsSync, readdirSync, statSync } from 'fs';
24
+ import { join, relative, dirname, basename } from 'path';
25
+ import {
26
+ createOutput,
27
+ parseCommonArgs,
28
+ formatTable,
29
+ COMMON_OPTIONS_HELP
30
+ } from '../src/output.mjs';
31
+ import { findProjectRoot } from '../src/project.mjs';
32
+
33
+ const HELP = `
34
+ tl-coverage - Quick test coverage info for files
35
+
36
+ Usage: tl-coverage [file-or-dir] [options]
37
+
38
+ Options:
39
+ --below N Only show files with coverage below N% (default: show all)
40
+ --above N Only show files with coverage above N%
41
+ --sort <field> Sort by: coverage, lines, name (default: coverage)
42
+ --uncovered Show uncovered line numbers for each file
43
+ ${COMMON_OPTIONS_HELP}
44
+
45
+ Examples:
46
+ tl-coverage # All files coverage
47
+ tl-coverage src/ # Coverage for src/ files
48
+ tl-coverage src/api.ts # Single file coverage
49
+ tl-coverage --below 80 # Files under 80% coverage
50
+ tl-coverage src/ --uncovered # Show uncovered lines
51
+
52
+ Coverage data sources (auto-detected):
53
+ - coverage/lcov.info (lcov format)
54
+ - coverage/coverage-final.json (istanbul/nyc)
55
+ - coverage/coverage-summary.json (jest)
56
+ - .nyc_output/*.json (nyc raw)
57
+ `;
58
+
59
+ // ─────────────────────────────────────────────────────────────
60
+ // Coverage Data Detection
61
+ // ─────────────────────────────────────────────────────────────
62
+
63
+ function findCoverageData(projectRoot) {
64
+ const coverageDir = join(projectRoot, 'coverage');
65
+
66
+ // Try different coverage formats in order of preference
67
+ const sources = [
68
+ { path: join(coverageDir, 'lcov.info'), type: 'lcov' },
69
+ { path: join(coverageDir, 'coverage-final.json'), type: 'istanbul' },
70
+ { path: join(coverageDir, 'coverage-summary.json'), type: 'summary' },
71
+ { path: join(projectRoot, '.nyc_output'), type: 'nyc' }
72
+ ];
73
+
74
+ for (const source of sources) {
75
+ if (existsSync(source.path)) {
76
+ return source;
77
+ }
78
+ }
79
+
80
+ return null;
81
+ }
82
+
83
+ // ─────────────────────────────────────────────────────────────
84
+ // Coverage Parsers
85
+ // ─────────────────────────────────────────────────────────────
86
+
87
+ function parseLcov(content, projectRoot) {
88
+ const coverage = new Map();
89
+ let currentFile = null;
90
+ let currentData = null;
91
+
92
+ for (const line of content.split('\n')) {
93
+ const trimmed = line.trim();
94
+
95
+ if (trimmed.startsWith('SF:')) {
96
+ // Source file
97
+ currentFile = trimmed.slice(3);
98
+ // Make path relative
99
+ if (currentFile.startsWith(projectRoot)) {
100
+ currentFile = relative(projectRoot, currentFile);
101
+ }
102
+ currentData = {
103
+ lines: { found: 0, hit: 0 },
104
+ functions: { found: 0, hit: 0 },
105
+ branches: { found: 0, hit: 0 },
106
+ uncoveredLines: []
107
+ };
108
+ } else if (trimmed.startsWith('DA:')) {
109
+ // Line data: DA:lineNum,hitCount
110
+ const [lineNum, hitCount] = trimmed.slice(3).split(',').map(Number);
111
+ if (currentData) {
112
+ currentData.lines.found++;
113
+ if (hitCount > 0) {
114
+ currentData.lines.hit++;
115
+ } else {
116
+ currentData.uncoveredLines.push(lineNum);
117
+ }
118
+ }
119
+ } else if (trimmed.startsWith('FNF:')) {
120
+ if (currentData) currentData.functions.found = parseInt(trimmed.slice(4), 10);
121
+ } else if (trimmed.startsWith('FNH:')) {
122
+ if (currentData) currentData.functions.hit = parseInt(trimmed.slice(4), 10);
123
+ } else if (trimmed.startsWith('BRF:')) {
124
+ if (currentData) currentData.branches.found = parseInt(trimmed.slice(4), 10);
125
+ } else if (trimmed.startsWith('BRH:')) {
126
+ if (currentData) currentData.branches.hit = parseInt(trimmed.slice(4), 10);
127
+ } else if (trimmed === 'end_of_record') {
128
+ if (currentFile && currentData) {
129
+ coverage.set(currentFile, currentData);
130
+ }
131
+ currentFile = null;
132
+ currentData = null;
133
+ }
134
+ }
135
+
136
+ return coverage;
137
+ }
138
+
139
+ function parseIstanbul(content, projectRoot) {
140
+ const coverage = new Map();
141
+ const data = JSON.parse(content);
142
+
143
+ for (const [filePath, fileData] of Object.entries(data)) {
144
+ let relPath = filePath;
145
+ if (filePath.startsWith(projectRoot)) {
146
+ relPath = relative(projectRoot, filePath);
147
+ }
148
+
149
+ const statementMap = fileData.statementMap || {};
150
+ const s = fileData.s || {};
151
+ const fnMap = fileData.fnMap || {};
152
+ const f = fileData.f || {};
153
+ const branchMap = fileData.branchMap || {};
154
+ const b = fileData.b || {};
155
+
156
+ // Calculate line coverage from statements
157
+ const lineHits = new Map();
158
+ for (const [stmtId, count] of Object.entries(s)) {
159
+ const stmt = statementMap[stmtId];
160
+ if (stmt && stmt.start) {
161
+ const line = stmt.start.line;
162
+ lineHits.set(line, (lineHits.get(line) || 0) + count);
163
+ }
164
+ }
165
+
166
+ const uncoveredLines = [];
167
+ for (const [line, count] of lineHits) {
168
+ if (count === 0) {
169
+ uncoveredLines.push(line);
170
+ }
171
+ }
172
+
173
+ coverage.set(relPath, {
174
+ lines: {
175
+ found: Object.keys(s).length,
176
+ hit: Object.values(s).filter(c => c > 0).length
177
+ },
178
+ functions: {
179
+ found: Object.keys(f).length,
180
+ hit: Object.values(f).filter(c => c > 0).length
181
+ },
182
+ branches: {
183
+ found: Object.values(b).flat().length,
184
+ hit: Object.values(b).flat().filter(c => c > 0).length
185
+ },
186
+ uncoveredLines: uncoveredLines.sort((a, b) => a - b)
187
+ });
188
+ }
189
+
190
+ return coverage;
191
+ }
192
+
193
+ function parseSummary(content, projectRoot) {
194
+ const coverage = new Map();
195
+ const data = JSON.parse(content);
196
+
197
+ for (const [filePath, fileData] of Object.entries(data)) {
198
+ if (filePath === 'total') continue;
199
+
200
+ let relPath = filePath;
201
+ if (filePath.startsWith(projectRoot)) {
202
+ relPath = relative(projectRoot, filePath);
203
+ }
204
+
205
+ coverage.set(relPath, {
206
+ lines: {
207
+ found: fileData.lines?.total || 0,
208
+ hit: fileData.lines?.covered || 0
209
+ },
210
+ functions: {
211
+ found: fileData.functions?.total || 0,
212
+ hit: fileData.functions?.covered || 0
213
+ },
214
+ branches: {
215
+ found: fileData.branches?.total || 0,
216
+ hit: fileData.branches?.covered || 0
217
+ },
218
+ uncoveredLines: [] // Summary doesn't have line details
219
+ });
220
+ }
221
+
222
+ return coverage;
223
+ }
224
+
225
+ function loadCoverage(source, projectRoot) {
226
+ if (source.type === 'lcov') {
227
+ const content = readFileSync(source.path, 'utf-8');
228
+ return parseLcov(content, projectRoot);
229
+ }
230
+
231
+ if (source.type === 'istanbul') {
232
+ const content = readFileSync(source.path, 'utf-8');
233
+ return parseIstanbul(content, projectRoot);
234
+ }
235
+
236
+ if (source.type === 'summary') {
237
+ const content = readFileSync(source.path, 'utf-8');
238
+ return parseSummary(content, projectRoot);
239
+ }
240
+
241
+ if (source.type === 'nyc') {
242
+ // Load all JSON files in .nyc_output
243
+ const combined = new Map();
244
+ const files = readdirSync(source.path).filter(f => f.endsWith('.json'));
245
+
246
+ for (const file of files) {
247
+ const content = readFileSync(join(source.path, file), 'utf-8');
248
+ const fileCoverage = parseIstanbul(content, projectRoot);
249
+ for (const [path, data] of fileCoverage) {
250
+ combined.set(path, data);
251
+ }
252
+ }
253
+
254
+ return combined;
255
+ }
256
+
257
+ return new Map();
258
+ }
259
+
260
+ // ─────────────────────────────────────────────────────────────
261
+ // Helpers
262
+ // ─────────────────────────────────────────────────────────────
263
+
264
+ function calcPercentage(hit, found) {
265
+ if (found === 0) return 100;
266
+ return Math.round((hit / found) * 100);
267
+ }
268
+
269
+ function formatPercentage(pct) {
270
+ if (pct >= 80) return `${pct}%`;
271
+ if (pct >= 50) return `${pct}%`;
272
+ return `${pct}%`;
273
+ }
274
+
275
+ function formatLineRanges(lines) {
276
+ if (lines.length === 0) return '';
277
+
278
+ const ranges = [];
279
+ let start = lines[0];
280
+ let end = lines[0];
281
+
282
+ for (let i = 1; i < lines.length; i++) {
283
+ if (lines[i] === end + 1) {
284
+ end = lines[i];
285
+ } else {
286
+ ranges.push(start === end ? `${start}` : `${start}-${end}`);
287
+ start = lines[i];
288
+ end = lines[i];
289
+ }
290
+ }
291
+ ranges.push(start === end ? `${start}` : `${start}-${end}`);
292
+
293
+ return ranges.join(', ');
294
+ }
295
+
296
+ // ─────────────────────────────────────────────────────────────
297
+ // Main
298
+ // ─────────────────────────────────────────────────────────────
299
+
300
+ const args = process.argv.slice(2);
301
+ const options = parseCommonArgs(args);
302
+
303
+ // Parse custom options
304
+ let belowThreshold = null;
305
+ let aboveThreshold = null;
306
+ let sortBy = 'coverage';
307
+ let showUncovered = false;
308
+
309
+ const remaining = [];
310
+ for (let i = 0; i < options.remaining.length; i++) {
311
+ const arg = options.remaining[i];
312
+
313
+ if (arg === '--below') {
314
+ belowThreshold = parseInt(options.remaining[++i], 10);
315
+ } else if (arg === '--above') {
316
+ aboveThreshold = parseInt(options.remaining[++i], 10);
317
+ } else if (arg === '--sort') {
318
+ sortBy = options.remaining[++i];
319
+ } else if (arg === '--uncovered') {
320
+ showUncovered = true;
321
+ } else if (!arg.startsWith('-')) {
322
+ remaining.push(arg);
323
+ }
324
+ }
325
+
326
+ const targetPath = remaining[0];
327
+
328
+ if (options.help) {
329
+ console.log(HELP);
330
+ process.exit(0);
331
+ }
332
+
333
+ const projectRoot = findProjectRoot();
334
+ const out = createOutput(options);
335
+
336
+ // Find coverage data
337
+ const coverageSource = findCoverageData(projectRoot);
338
+ if (!coverageSource) {
339
+ console.error('No coverage data found. Run your tests with coverage first.');
340
+ console.error('Looked for: coverage/lcov.info, coverage/coverage-final.json, .nyc_output/');
341
+ process.exit(1);
342
+ }
343
+
344
+ // Load coverage
345
+ const coverage = loadCoverage(coverageSource, projectRoot);
346
+
347
+ if (coverage.size === 0) {
348
+ console.error('Coverage data is empty');
349
+ process.exit(1);
350
+ }
351
+
352
+ // Filter by target path if specified
353
+ let filteredCoverage = [...coverage.entries()];
354
+
355
+ if (targetPath) {
356
+ const targetStat = existsSync(targetPath) ? statSync(targetPath) : null;
357
+
358
+ if (targetStat?.isFile()) {
359
+ // Single file
360
+ const relPath = relative(projectRoot, targetPath);
361
+ filteredCoverage = filteredCoverage.filter(([path]) => path === relPath);
362
+ } else {
363
+ // Directory or pattern
364
+ const prefix = targetPath.replace(/\/$/, '');
365
+ filteredCoverage = filteredCoverage.filter(([path]) => path.startsWith(prefix));
366
+ }
367
+ }
368
+
369
+ // Apply thresholds
370
+ if (belowThreshold !== null) {
371
+ filteredCoverage = filteredCoverage.filter(([, data]) => {
372
+ const pct = calcPercentage(data.lines.hit, data.lines.found);
373
+ return pct < belowThreshold;
374
+ });
375
+ }
376
+
377
+ if (aboveThreshold !== null) {
378
+ filteredCoverage = filteredCoverage.filter(([, data]) => {
379
+ const pct = calcPercentage(data.lines.hit, data.lines.found);
380
+ return pct >= aboveThreshold;
381
+ });
382
+ }
383
+
384
+ // Sort
385
+ filteredCoverage.sort((a, b) => {
386
+ if (sortBy === 'name') {
387
+ return a[0].localeCompare(b[0]);
388
+ }
389
+ if (sortBy === 'lines') {
390
+ return b[1].lines.found - a[1].lines.found;
391
+ }
392
+ // Default: coverage (ascending - lowest first)
393
+ const pctA = calcPercentage(a[1].lines.hit, a[1].lines.found);
394
+ const pctB = calcPercentage(b[1].lines.hit, b[1].lines.found);
395
+ return pctA - pctB;
396
+ });
397
+
398
+ // Calculate totals
399
+ let totalLines = 0;
400
+ let totalHit = 0;
401
+ for (const [, data] of filteredCoverage) {
402
+ totalLines += data.lines.found;
403
+ totalHit += data.lines.hit;
404
+ }
405
+
406
+ // Set JSON data
407
+ out.setData('source', basename(coverageSource.path));
408
+ out.setData('files', filteredCoverage.map(([path, data]) => ({
409
+ path,
410
+ coverage: calcPercentage(data.lines.hit, data.lines.found),
411
+ lines: data.lines,
412
+ functions: data.functions,
413
+ branches: data.branches,
414
+ uncoveredLines: data.uncoveredLines
415
+ })));
416
+ out.setData('totalCoverage', calcPercentage(totalHit, totalLines));
417
+
418
+ // Output
419
+ out.header(`📊 Coverage from ${basename(coverageSource.path)}`);
420
+ out.blank();
421
+
422
+ if (filteredCoverage.length === 0) {
423
+ out.add('No files match the criteria');
424
+ } else {
425
+ const rows = [];
426
+
427
+ for (const [path, data] of filteredCoverage) {
428
+ const linePct = calcPercentage(data.lines.hit, data.lines.found);
429
+ const funcPct = calcPercentage(data.functions.hit, data.functions.found);
430
+ const branchPct = calcPercentage(data.branches.hit, data.branches.found);
431
+
432
+ const indicator = linePct >= 80 ? '✓' : linePct >= 50 ? '◐' : '✗';
433
+
434
+ rows.push([
435
+ `${indicator} ${path}`,
436
+ `${linePct}%`,
437
+ `(${data.lines.hit}/${data.lines.found})`
438
+ ]);
439
+
440
+ if (showUncovered && data.uncoveredLines.length > 0) {
441
+ const lineRanges = formatLineRanges(data.uncoveredLines);
442
+ rows.push(['', '', ` uncovered: ${lineRanges}`]);
443
+ }
444
+ }
445
+
446
+ formatTable(rows).forEach(line => out.add(line));
447
+ }
448
+
449
+ // Summary
450
+ if (!options.quiet && filteredCoverage.length > 0) {
451
+ out.blank();
452
+ const totalPct = calcPercentage(totalHit, totalLines);
453
+ out.add(`Total: ${totalPct}% coverage (${totalHit}/${totalLines} lines) across ${filteredCoverage.length} file(s)`);
454
+ }
455
+
456
+ out.print();