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,514 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * tl-complexity - Code complexity metrics
5
+ *
6
+ * Calculates cyclomatic and cognitive complexity for functions in your codebase.
7
+ * Helps identify functions that may need refactoring.
8
+ *
9
+ * Usage: tl-complexity [file-or-dir] [--threshold N]
10
+ */
11
+
12
+ // Prompt info for tl-prompt
13
+ if (process.argv.includes('--prompt')) {
14
+ console.log(JSON.stringify({
15
+ name: 'tl-complexity',
16
+ desc: 'Code complexity metrics for functions',
17
+ when: 'before-modify',
18
+ example: 'tl-complexity src/'
19
+ }));
20
+ process.exit(0);
21
+ }
22
+
23
+ import { readFileSync, existsSync, readdirSync, statSync } from 'fs';
24
+ import { join, relative, extname } from 'path';
25
+ import {
26
+ createOutput,
27
+ parseCommonArgs,
28
+ formatTable,
29
+ COMMON_OPTIONS_HELP
30
+ } from '../src/output.mjs';
31
+ import { findProjectRoot, shouldSkip } from '../src/project.mjs';
32
+
33
+ const HELP = `
34
+ tl-complexity - Code complexity metrics
35
+
36
+ Usage: tl-complexity [file-or-dir] [options]
37
+
38
+ Options:
39
+ --threshold N, -t N Only show functions with complexity >= N (default: 0)
40
+ --sort <field> Sort by: cyclomatic, cognitive, name, loc (default: cyclomatic)
41
+ --top N Show only top N most complex functions
42
+ --summary Show only file-level summary, not individual functions
43
+ ${COMMON_OPTIONS_HELP}
44
+
45
+ Examples:
46
+ tl-complexity src/ # All functions
47
+ tl-complexity src/utils.ts # Single file
48
+ tl-complexity src/ --threshold 10 # Only complex functions
49
+ tl-complexity src/ --top 20 # Top 20 most complex
50
+ tl-complexity src/ --summary # File-level only
51
+
52
+ Metrics:
53
+ Cyclomatic: Number of independent paths through code
54
+ (if, for, while, case, catch, &&, ||, ?:)
55
+ Cognitive: How hard code is to understand
56
+ (nesting increases weight of decisions)
57
+
58
+ Thresholds (suggestions):
59
+ 1-10: Simple, low risk
60
+ 11-20: Moderate, some risk
61
+ 21-50: Complex, high risk
62
+ 50+: Very complex, refactor recommended
63
+ `;
64
+
65
+ // ─────────────────────────────────────────────────────────────
66
+ // File Discovery
67
+ // ─────────────────────────────────────────────────────────────
68
+
69
+ const CODE_EXTENSIONS = new Set(['.js', '.mjs', '.cjs', '.jsx', '.ts', '.tsx', '.mts']);
70
+
71
+ function findCodeFiles(dir, files = []) {
72
+ const entries = readdirSync(dir, { withFileTypes: true });
73
+
74
+ for (const entry of entries) {
75
+ const fullPath = join(dir, entry.name);
76
+
77
+ if (entry.isDirectory()) {
78
+ if (!shouldSkip(entry.name, true)) {
79
+ findCodeFiles(fullPath, files);
80
+ }
81
+ } else if (entry.isFile()) {
82
+ const ext = extname(entry.name).toLowerCase();
83
+ if (CODE_EXTENSIONS.has(ext) && !shouldSkip(entry.name, false)) {
84
+ files.push(fullPath);
85
+ }
86
+ }
87
+ }
88
+
89
+ return files;
90
+ }
91
+
92
+ // ─────────────────────────────────────────────────────────────
93
+ // Function Extraction
94
+ // ─────────────────────────────────────────────────────────────
95
+
96
+ function extractFunctions(content, filePath) {
97
+ const functions = [];
98
+ const lines = content.split('\n');
99
+
100
+ let i = 0;
101
+ while (i < lines.length) {
102
+ const line = lines[i];
103
+ const trimmed = line.trim();
104
+
105
+ // Match function declarations
106
+ const funcPatterns = [
107
+ // function name() or async function name()
108
+ /^(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*\(/,
109
+ // const name = function() or const name = async function()
110
+ /^(?:export\s+)?const\s+(\w+)\s*=\s*(?:async\s+)?function\s*\(/,
111
+ // const name = () => or const name = async () =>
112
+ /^(?:export\s+)?const\s+(\w+)\s*=\s*(?:async\s+)?\([^)]*\)\s*(?::\s*[^=]+)?\s*=>/,
113
+ // const name = async () => (without params parens sometimes)
114
+ /^(?:export\s+)?const\s+(\w+)\s*=\s*async\s+\w+\s*=>/,
115
+ // Class method: name() { or async name() { or public name() {
116
+ /^(?:public\s+|private\s+|protected\s+)?(?:static\s+)?(?:async\s+)?(\w+)\s*\([^)]*\)\s*(?::\s*[^{]+)?\s*\{/,
117
+ ];
118
+
119
+ let funcName = null;
120
+ let funcStart = i;
121
+
122
+ for (const pattern of funcPatterns) {
123
+ const match = trimmed.match(pattern);
124
+ if (match) {
125
+ funcName = match[1];
126
+ // Skip constructor, get, set for class methods
127
+ if (['constructor', 'get', 'set', 'if', 'for', 'while', 'switch', 'catch'].includes(funcName)) {
128
+ funcName = null;
129
+ }
130
+ break;
131
+ }
132
+ }
133
+
134
+ if (funcName) {
135
+ // Find the function body
136
+ const funcBody = extractFunctionBody(lines, i);
137
+ if (funcBody) {
138
+ functions.push({
139
+ name: funcName,
140
+ startLine: i + 1,
141
+ endLine: i + funcBody.lines.length,
142
+ body: funcBody.content,
143
+ loc: funcBody.lines.length
144
+ });
145
+ i += funcBody.lines.length;
146
+ continue;
147
+ }
148
+ }
149
+
150
+ i++;
151
+ }
152
+
153
+ return functions;
154
+ }
155
+
156
+ function extractFunctionBody(lines, startLine) {
157
+ let braceDepth = 0;
158
+ let arrowWithoutBrace = false;
159
+ let started = false;
160
+ const bodyLines = [];
161
+
162
+ for (let i = startLine; i < lines.length; i++) {
163
+ const line = lines[i];
164
+ bodyLines.push(line);
165
+
166
+ // Check for arrow function without braces
167
+ if (i === startLine && line.includes('=>') && !line.includes('{')) {
168
+ // Single expression arrow function - ends at semicolon or next statement
169
+ if (line.trim().endsWith(';') || line.trim().endsWith(',')) {
170
+ return { lines: bodyLines, content: bodyLines.join('\n') };
171
+ }
172
+ arrowWithoutBrace = true;
173
+ }
174
+
175
+ // Count braces
176
+ for (const char of line) {
177
+ if (char === '{') {
178
+ braceDepth++;
179
+ started = true;
180
+ } else if (char === '}') {
181
+ braceDepth--;
182
+ }
183
+ }
184
+
185
+ // Handle arrow functions without braces (multi-line)
186
+ if (arrowWithoutBrace && !started) {
187
+ if (line.trim().endsWith(';') || line.trim().endsWith(',') ||
188
+ (i + 1 < lines.length && /^[\s]*(?:const|let|var|function|class|export|import|return|\}|\/\/)/.test(lines[i + 1]))) {
189
+ return { lines: bodyLines, content: bodyLines.join('\n') };
190
+ }
191
+ continue;
192
+ }
193
+
194
+ // End of function body
195
+ if (started && braceDepth === 0) {
196
+ return { lines: bodyLines, content: bodyLines.join('\n') };
197
+ }
198
+
199
+ // Safety limit
200
+ if (bodyLines.length > 1000) {
201
+ return { lines: bodyLines, content: bodyLines.join('\n') };
202
+ }
203
+ }
204
+
205
+ return bodyLines.length > 0 ? { lines: bodyLines, content: bodyLines.join('\n') } : null;
206
+ }
207
+
208
+ // ─────────────────────────────────────────────────────────────
209
+ // Complexity Calculation
210
+ // ─────────────────────────────────────────────────────────────
211
+
212
+ function calculateCyclomaticComplexity(code) {
213
+ // Start with 1 (base path)
214
+ let complexity = 1;
215
+
216
+ // Remove strings and comments to avoid false positives
217
+ const cleaned = removeStringsAndComments(code);
218
+
219
+ // Decision points that add to cyclomatic complexity
220
+ const patterns = [
221
+ /\bif\s*\(/g,
222
+ /\belse\s+if\s*\(/g,
223
+ /\bfor\s*\(/g,
224
+ /\bwhile\s*\(/g,
225
+ /\bdo\s*\{/g,
226
+ /\bcase\s+[^:]+:/g,
227
+ /\bcatch\s*\(/g,
228
+ /\?\s*[^:]/g, // Ternary operator (? but not ?. or ??)
229
+ /\&\&/g, // Logical AND
230
+ /\|\|/g, // Logical OR
231
+ /\?\?/g, // Nullish coalescing
232
+ ];
233
+
234
+ for (const pattern of patterns) {
235
+ const matches = cleaned.match(pattern);
236
+ if (matches) {
237
+ complexity += matches.length;
238
+ }
239
+ }
240
+
241
+ return complexity;
242
+ }
243
+
244
+ function calculateCognitiveComplexity(code) {
245
+ let complexity = 0;
246
+ let nestingLevel = 0;
247
+
248
+ const lines = code.split('\n');
249
+
250
+ for (const line of lines) {
251
+ const trimmed = line.trim();
252
+
253
+ // Skip empty lines and comments
254
+ if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('*')) {
255
+ continue;
256
+ }
257
+
258
+ // Track nesting level
259
+ const openBraces = (line.match(/\{/g) || []).length;
260
+ const closeBraces = (line.match(/\}/g) || []).length;
261
+
262
+ // Check for control flow before adjusting nesting
263
+ // These add 1 + nesting level
264
+ if (/\bif\s*\(/.test(trimmed) && !/\belse\s+if/.test(trimmed)) {
265
+ complexity += 1 + nestingLevel;
266
+ }
267
+ if (/\belse\s+if\s*\(/.test(trimmed)) {
268
+ complexity += 1; // else if doesn't add nesting penalty
269
+ }
270
+ if (/\belse\s*\{/.test(trimmed)) {
271
+ complexity += 1; // else adds 1
272
+ }
273
+ if (/\bfor\s*\(/.test(trimmed)) {
274
+ complexity += 1 + nestingLevel;
275
+ }
276
+ if (/\bwhile\s*\(/.test(trimmed)) {
277
+ complexity += 1 + nestingLevel;
278
+ }
279
+ if (/\bdo\s*\{/.test(trimmed)) {
280
+ complexity += 1 + nestingLevel;
281
+ }
282
+ if (/\bswitch\s*\(/.test(trimmed)) {
283
+ complexity += 1 + nestingLevel;
284
+ }
285
+ if (/\bcatch\s*\(/.test(trimmed)) {
286
+ complexity += 1 + nestingLevel;
287
+ }
288
+ if (/\btry\s*\{/.test(trimmed)) {
289
+ // try doesn't add complexity, just nesting
290
+ }
291
+
292
+ // Logical operators add 1 each (no nesting penalty)
293
+ const andMatches = trimmed.match(/\&\&/g);
294
+ const orMatches = trimmed.match(/\|\|/g);
295
+ const nullishMatches = trimmed.match(/\?\?/g);
296
+ const ternaryMatches = trimmed.match(/\?[^:?]/g);
297
+
298
+ if (andMatches) complexity += andMatches.length;
299
+ if (orMatches) complexity += orMatches.length;
300
+ if (nullishMatches) complexity += nullishMatches.length;
301
+ if (ternaryMatches) complexity += ternaryMatches.length;
302
+
303
+ // Update nesting after processing line
304
+ // Only count structural nesting (if/for/while/etc), not object literals
305
+ if (/\b(if|for|while|do|switch|try|catch|else)\b/.test(trimmed) && openBraces > 0) {
306
+ nestingLevel += openBraces;
307
+ } else {
308
+ nestingLevel += openBraces;
309
+ }
310
+ nestingLevel -= closeBraces;
311
+ nestingLevel = Math.max(0, nestingLevel);
312
+ }
313
+
314
+ return complexity;
315
+ }
316
+
317
+ function removeStringsAndComments(code) {
318
+ // Remove single-line comments
319
+ let result = code.replace(/\/\/.*$/gm, '');
320
+ // Remove multi-line comments
321
+ result = result.replace(/\/\*[\s\S]*?\*\//g, '');
322
+ // Remove template literals (simplified)
323
+ result = result.replace(/`[^`]*`/g, '""');
324
+ // Remove strings
325
+ result = result.replace(/"(?:[^"\\]|\\.)*"/g, '""');
326
+ result = result.replace(/'(?:[^'\\]|\\.)*'/g, "''");
327
+
328
+ return result;
329
+ }
330
+
331
+ // ─────────────────────────────────────────────────────────────
332
+ // Complexity Rating
333
+ // ─────────────────────────────────────────────────────────────
334
+
335
+ function getRating(cyclomatic) {
336
+ if (cyclomatic <= 10) return { label: 'simple', icon: '✓' };
337
+ if (cyclomatic <= 20) return { label: 'moderate', icon: '◐' };
338
+ if (cyclomatic <= 50) return { label: 'complex', icon: '⚠' };
339
+ return { label: 'very complex', icon: '✗' };
340
+ }
341
+
342
+ // ─────────────────────────────────────────────────────────────
343
+ // Main
344
+ // ─────────────────────────────────────────────────────────────
345
+
346
+ const args = process.argv.slice(2);
347
+ const options = parseCommonArgs(args);
348
+
349
+ // Parse custom options
350
+ let threshold = 0;
351
+ let sortBy = 'cyclomatic';
352
+ let topN = Infinity;
353
+ let summaryOnly = false;
354
+
355
+ const remaining = [];
356
+ for (let i = 0; i < options.remaining.length; i++) {
357
+ const arg = options.remaining[i];
358
+
359
+ if (arg === '--threshold' || arg === '-t') {
360
+ threshold = parseInt(options.remaining[++i], 10) || 0;
361
+ } else if (arg === '--sort') {
362
+ sortBy = options.remaining[++i];
363
+ } else if (arg === '--top') {
364
+ topN = parseInt(options.remaining[++i], 10) || 20;
365
+ } else if (arg === '--summary') {
366
+ summaryOnly = true;
367
+ } else if (!arg.startsWith('-')) {
368
+ remaining.push(arg);
369
+ }
370
+ }
371
+
372
+ const targetPath = remaining[0] || '.';
373
+
374
+ if (options.help) {
375
+ console.log(HELP);
376
+ process.exit(0);
377
+ }
378
+
379
+ if (!existsSync(targetPath)) {
380
+ console.error(`Path not found: ${targetPath}`);
381
+ process.exit(1);
382
+ }
383
+
384
+ const projectRoot = findProjectRoot();
385
+ const out = createOutput(options);
386
+
387
+ // Find files
388
+ let files = [];
389
+ const stat = statSync(targetPath);
390
+ if (stat.isFile()) {
391
+ files = [targetPath];
392
+ } else {
393
+ files = findCodeFiles(targetPath);
394
+ }
395
+
396
+ if (files.length === 0) {
397
+ console.error('No code files found');
398
+ process.exit(1);
399
+ }
400
+
401
+ // Analyze all functions
402
+ const allFunctions = [];
403
+ const fileSummaries = [];
404
+
405
+ for (const file of files) {
406
+ const content = readFileSync(file, 'utf-8');
407
+ const relPath = relative(projectRoot, file);
408
+ const functions = extractFunctions(content, file);
409
+
410
+ let fileComplexity = 0;
411
+ let fileCognitive = 0;
412
+
413
+ for (const func of functions) {
414
+ const cyclomatic = calculateCyclomaticComplexity(func.body);
415
+ const cognitive = calculateCognitiveComplexity(func.body);
416
+
417
+ fileComplexity += cyclomatic;
418
+ fileCognitive += cognitive;
419
+
420
+ if (cyclomatic >= threshold) {
421
+ allFunctions.push({
422
+ file: relPath,
423
+ name: func.name,
424
+ line: func.startLine,
425
+ loc: func.loc,
426
+ cyclomatic,
427
+ cognitive,
428
+ rating: getRating(cyclomatic)
429
+ });
430
+ }
431
+ }
432
+
433
+ if (functions.length > 0) {
434
+ fileSummaries.push({
435
+ file: relPath,
436
+ functions: functions.length,
437
+ totalCyclomatic: fileComplexity,
438
+ totalCognitive: fileCognitive,
439
+ avgCyclomatic: Math.round(fileComplexity / functions.length * 10) / 10
440
+ });
441
+ }
442
+ }
443
+
444
+ // Sort functions
445
+ allFunctions.sort((a, b) => {
446
+ if (sortBy === 'cognitive') return b.cognitive - a.cognitive;
447
+ if (sortBy === 'name') return a.name.localeCompare(b.name);
448
+ if (sortBy === 'loc') return b.loc - a.loc;
449
+ return b.cyclomatic - a.cyclomatic; // default: cyclomatic
450
+ });
451
+
452
+ // Apply top N limit
453
+ const displayFunctions = allFunctions.slice(0, topN);
454
+
455
+ // Set JSON data
456
+ out.setData('functions', allFunctions);
457
+ out.setData('fileSummaries', fileSummaries);
458
+ out.setData('totalFunctions', allFunctions.length);
459
+
460
+ // Output
461
+ if (summaryOnly) {
462
+ out.header(`📊 Complexity Summary (${fileSummaries.length} files)`);
463
+ out.blank();
464
+
465
+ // Sort files by total complexity
466
+ fileSummaries.sort((a, b) => b.totalCyclomatic - a.totalCyclomatic);
467
+
468
+ const rows = fileSummaries.map(f => [
469
+ f.file,
470
+ `${f.functions} fn`,
471
+ `avg ${f.avgCyclomatic}`,
472
+ `total ${f.totalCyclomatic}`
473
+ ]);
474
+
475
+ formatTable(rows).forEach(line => out.add(line));
476
+ } else {
477
+ out.header(`📊 Function Complexity (${displayFunctions.length}${allFunctions.length > topN ? ` of ${allFunctions.length}` : ''} functions)`);
478
+ out.blank();
479
+
480
+ if (displayFunctions.length === 0) {
481
+ out.add(`No functions found with complexity >= ${threshold}`);
482
+ } else {
483
+ // Group by file
484
+ const byFile = new Map();
485
+ for (const func of displayFunctions) {
486
+ if (!byFile.has(func.file)) byFile.set(func.file, []);
487
+ byFile.get(func.file).push(func);
488
+ }
489
+
490
+ for (const [file, funcs] of byFile) {
491
+ out.add(`${file}`);
492
+ for (const func of funcs) {
493
+ const rating = func.rating;
494
+ out.add(` ${rating.icon} ${func.name} (L${func.line}): cyclo=${func.cyclomatic} cog=${func.cognitive} loc=${func.loc}`);
495
+ }
496
+ out.blank();
497
+ }
498
+ }
499
+ }
500
+
501
+ // Summary stats
502
+ if (!options.quiet && allFunctions.length > 0) {
503
+ const totalCyclo = allFunctions.reduce((sum, f) => sum + f.cyclomatic, 0);
504
+ const avgCyclo = Math.round(totalCyclo / allFunctions.length * 10) / 10;
505
+ const maxCyclo = Math.max(...allFunctions.map(f => f.cyclomatic));
506
+
507
+ const complex = allFunctions.filter(f => f.cyclomatic > 10).length;
508
+ const veryComplex = allFunctions.filter(f => f.cyclomatic > 20).length;
509
+
510
+ out.add('---');
511
+ out.add(`Avg complexity: ${avgCyclo} | Max: ${maxCyclo} | Complex (>10): ${complex} | Very complex (>20): ${veryComplex}`);
512
+ }
513
+
514
+ out.print();