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/bin/tl-api.mjs ADDED
@@ -0,0 +1,515 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * tl-api - Extract REST/GraphQL API endpoints from code
5
+ *
6
+ * Scans source files for API endpoint definitions in common frameworks
7
+ * (Express, Fastify, Koa, Hono, NestJS, etc.) and GraphQL schemas.
8
+ *
9
+ * Usage: tl-api [dir] [--rest-only] [--graphql-only]
10
+ */
11
+
12
+ // Prompt info for tl-prompt
13
+ if (process.argv.includes('--prompt')) {
14
+ console.log(JSON.stringify({
15
+ name: 'tl-api',
16
+ desc: 'Extract REST/GraphQL API endpoints',
17
+ when: 'before-read',
18
+ example: 'tl-api src/api/'
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
+ COMMON_OPTIONS_HELP
29
+ } from '../src/output.mjs';
30
+ import { findProjectRoot, shouldSkip } from '../src/project.mjs';
31
+
32
+ const HELP = `
33
+ tl-api - Extract REST/GraphQL API endpoints from code
34
+
35
+ Usage: tl-api [dir] [options]
36
+
37
+ Options:
38
+ --rest-only, -r Only show REST endpoints
39
+ --graphql-only, -g Only show GraphQL operations
40
+ --group-by-file Group endpoints by file (default: by method)
41
+ --with-handlers Show handler function names
42
+ ${COMMON_OPTIONS_HELP}
43
+
44
+ Examples:
45
+ tl-api # All endpoints
46
+ tl-api src/routes/ # Scan specific directory
47
+ tl-api -r # REST only
48
+ tl-api --group-by-file # Group by file
49
+
50
+ Detects:
51
+ REST: Express, Fastify, Koa, Hono, NestJS decorators, fetch handlers
52
+ GraphQL: Query/Mutation/Subscription definitions, resolvers
53
+ `;
54
+
55
+ // ─────────────────────────────────────────────────────────────
56
+ // File Discovery
57
+ // ─────────────────────────────────────────────────────────────
58
+
59
+ const CODE_EXTENSIONS = new Set(['.js', '.mjs', '.cjs', '.jsx', '.ts', '.tsx', '.mts']);
60
+
61
+ function findCodeFiles(dir, files = []) {
62
+ const entries = readdirSync(dir, { withFileTypes: true });
63
+
64
+ for (const entry of entries) {
65
+ const fullPath = join(dir, entry.name);
66
+
67
+ if (entry.isDirectory()) {
68
+ if (!shouldSkip(entry.name, true)) {
69
+ findCodeFiles(fullPath, files);
70
+ }
71
+ } else if (entry.isFile()) {
72
+ const ext = extname(entry.name).toLowerCase();
73
+ if (CODE_EXTENSIONS.has(ext) && !shouldSkip(entry.name, false)) {
74
+ files.push(fullPath);
75
+ }
76
+ }
77
+ }
78
+
79
+ return files;
80
+ }
81
+
82
+ // ─────────────────────────────────────────────────────────────
83
+ // REST Endpoint Extraction
84
+ // ─────────────────────────────────────────────────────────────
85
+
86
+ function extractRestEndpoints(content, filePath) {
87
+ const endpoints = [];
88
+ const lines = content.split('\n');
89
+
90
+ // Common HTTP methods
91
+ const methods = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'all'];
92
+ const methodsUpper = methods.map(m => m.toUpperCase());
93
+
94
+ for (let i = 0; i < lines.length; i++) {
95
+ const line = lines[i];
96
+ const trimmed = line.trim();
97
+
98
+ // Express/Koa/Fastify style: app.get('/path', handler) or router.post('/path', ...)
99
+ for (const method of methods) {
100
+ // Match: app.get('/path' or router.get("/path" or .get(`/path`
101
+ const routerPattern = new RegExp(`\\.(${method})\\s*\\(\\s*['"\`]([^'"\`]+)['"\`]`, 'i');
102
+ const match = trimmed.match(routerPattern);
103
+ if (match) {
104
+ const handler = extractHandlerName(trimmed, lines, i);
105
+ endpoints.push({
106
+ method: match[1].toUpperCase(),
107
+ path: match[2],
108
+ line: i + 1,
109
+ handler,
110
+ framework: 'express-like'
111
+ });
112
+ }
113
+ }
114
+
115
+ // NestJS decorators: @Get('/path'), @Post('/path'), etc.
116
+ for (const method of methodsUpper) {
117
+ const decoratorPattern = new RegExp(`@(${method})\\s*\\(\\s*['"\`]?([^'"\`\\)]*)?['"\`]?\\s*\\)`, 'i');
118
+ const match = trimmed.match(decoratorPattern);
119
+ if (match) {
120
+ // Get the method name from next non-decorator line
121
+ let handler = '';
122
+ for (let j = i + 1; j < Math.min(i + 5, lines.length); j++) {
123
+ const nextLine = lines[j].trim();
124
+ if (!nextLine.startsWith('@') && nextLine.includes('(')) {
125
+ const funcMatch = nextLine.match(/(?:async\s+)?(\w+)\s*\(/);
126
+ if (funcMatch) handler = funcMatch[1];
127
+ break;
128
+ }
129
+ }
130
+ endpoints.push({
131
+ method: match[1].toUpperCase(),
132
+ path: match[2] || '/',
133
+ line: i + 1,
134
+ handler,
135
+ framework: 'nestjs'
136
+ });
137
+ }
138
+ }
139
+
140
+ // Hono style: app.get('/path', (c) => ...) or new Hono().get(...)
141
+ const honoPattern = /\.(?:on|get|post|put|patch|delete)\s*\(\s*['"`]([^'"`]+)['"`]/i;
142
+ const honoMatch = trimmed.match(honoPattern);
143
+ if (honoMatch && !endpoints.some(e => e.line === i + 1)) {
144
+ const methodMatch = trimmed.match(/\.(get|post|put|patch|delete|on)\s*\(/i);
145
+ if (methodMatch) {
146
+ endpoints.push({
147
+ method: methodMatch[1].toUpperCase(),
148
+ path: honoMatch[1],
149
+ line: i + 1,
150
+ handler: '',
151
+ framework: 'hono'
152
+ });
153
+ }
154
+ }
155
+
156
+ // Next.js API routes: export async function GET/POST/etc
157
+ const nextPattern = /export\s+(?:async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s*\(/;
158
+ const nextMatch = trimmed.match(nextPattern);
159
+ if (nextMatch) {
160
+ endpoints.push({
161
+ method: nextMatch[1],
162
+ path: '(from filename)',
163
+ line: i + 1,
164
+ handler: nextMatch[1],
165
+ framework: 'nextjs'
166
+ });
167
+ }
168
+
169
+ // Fetch API route handlers: case 'GET': or method === 'POST'
170
+ const fetchPattern = /(?:case\s+['"]|method\s*===?\s*['"])(GET|POST|PUT|PATCH|DELETE)['"]:/i;
171
+ const fetchMatch = trimmed.match(fetchPattern);
172
+ if (fetchMatch) {
173
+ endpoints.push({
174
+ method: fetchMatch[1].toUpperCase(),
175
+ path: '(handler)',
176
+ line: i + 1,
177
+ handler: '',
178
+ framework: 'fetch'
179
+ });
180
+ }
181
+ }
182
+
183
+ return endpoints;
184
+ }
185
+
186
+ function extractHandlerName(line, lines, lineIndex) {
187
+ // Try to find handler name in the same line or next lines
188
+ // Pattern: , handlerName) or , (req, res) =>
189
+ const inlineMatch = line.match(/,\s*(\w+)\s*\)/);
190
+ if (inlineMatch && !['req', 'res', 'ctx', 'c', 'request', 'response'].includes(inlineMatch[1])) {
191
+ return inlineMatch[1];
192
+ }
193
+
194
+ // Check for arrow function or function reference
195
+ const arrowMatch = line.match(/,\s*(?:async\s+)?\(.*?\)\s*=>/);
196
+ if (arrowMatch) return '(inline)';
197
+
198
+ return '';
199
+ }
200
+
201
+ // ─────────────────────────────────────────────────────────────
202
+ // GraphQL Extraction
203
+ // ─────────────────────────────────────────────────────────────
204
+
205
+ function extractGraphqlOperations(content, filePath) {
206
+ const operations = [];
207
+ const lines = content.split('\n');
208
+
209
+ // Track if we're in a type definition
210
+ let currentType = null;
211
+ let braceDepth = 0;
212
+
213
+ for (let i = 0; i < lines.length; i++) {
214
+ const line = lines[i];
215
+ const trimmed = line.trim();
216
+
217
+ // GraphQL SDL: type Query { ... }
218
+ const typeMatch = trimmed.match(/^type\s+(Query|Mutation|Subscription)\s*\{?/);
219
+ if (typeMatch) {
220
+ currentType = typeMatch[1];
221
+ braceDepth = (line.match(/\{/g) || []).length - (line.match(/\}/g) || []).length;
222
+ continue;
223
+ }
224
+
225
+ // Track brace depth
226
+ if (currentType) {
227
+ braceDepth += (line.match(/\{/g) || []).length - (line.match(/\}/g) || []).length;
228
+
229
+ if (braceDepth <= 0) {
230
+ currentType = null;
231
+ continue;
232
+ }
233
+
234
+ // Field definition: fieldName(args): Type
235
+ const fieldMatch = trimmed.match(/^(\w+)\s*(?:\([^)]*\))?\s*:/);
236
+ if (fieldMatch && !trimmed.startsWith('#')) {
237
+ operations.push({
238
+ type: currentType,
239
+ name: fieldMatch[1],
240
+ line: i + 1,
241
+ source: 'schema'
242
+ });
243
+ }
244
+ }
245
+
246
+ // Resolver definitions: Query: { fieldName: ... } or Mutation: { ... }
247
+ const resolverTypeMatch = trimmed.match(/^(Query|Mutation|Subscription)\s*:\s*\{/);
248
+ if (resolverTypeMatch) {
249
+ currentType = resolverTypeMatch[1];
250
+ braceDepth = 1;
251
+ continue;
252
+ }
253
+
254
+ // NestJS GraphQL decorators: @Query(), @Mutation()
255
+ const decoratorMatch = trimmed.match(/@(Query|Mutation|Subscription)\s*\(/);
256
+ if (decoratorMatch) {
257
+ // Get operation name from next line
258
+ let opName = '';
259
+ for (let j = i + 1; j < Math.min(i + 3, lines.length); j++) {
260
+ const nextLine = lines[j].trim();
261
+ if (!nextLine.startsWith('@')) {
262
+ const funcMatch = nextLine.match(/(?:async\s+)?(\w+)\s*\(/);
263
+ if (funcMatch) opName = funcMatch[1];
264
+ break;
265
+ }
266
+ }
267
+ operations.push({
268
+ type: decoratorMatch[1],
269
+ name: opName || '(unknown)',
270
+ line: i + 1,
271
+ source: 'decorator'
272
+ });
273
+ }
274
+
275
+ // gql tagged template: Query { fieldName }
276
+ if (trimmed.includes('gql`') || trimmed.includes('gql(')) {
277
+ // Simple extraction from template literals
278
+ const gqlContent = extractGqlTemplate(lines, i);
279
+ const gqlOps = parseGqlString(gqlContent, i + 1);
280
+ operations.push(...gqlOps);
281
+ }
282
+ }
283
+
284
+ return operations;
285
+ }
286
+
287
+ function extractGqlTemplate(lines, startLine) {
288
+ let content = '';
289
+ let depth = 0;
290
+ let started = false;
291
+
292
+ for (let i = startLine; i < Math.min(startLine + 50, lines.length); i++) {
293
+ const line = lines[i];
294
+
295
+ if (line.includes('`')) {
296
+ if (!started) {
297
+ started = true;
298
+ content += line.split('`')[1] || '';
299
+ } else {
300
+ content += line.split('`')[0] || '';
301
+ break;
302
+ }
303
+ } else if (started) {
304
+ content += line + '\n';
305
+ }
306
+ }
307
+
308
+ return content;
309
+ }
310
+
311
+ function parseGqlString(content, baseLine) {
312
+ const operations = [];
313
+ const lines = content.split('\n');
314
+
315
+ let currentType = null;
316
+
317
+ for (let i = 0; i < lines.length; i++) {
318
+ const trimmed = lines[i].trim();
319
+
320
+ const typeMatch = trimmed.match(/^type\s+(Query|Mutation|Subscription)/);
321
+ if (typeMatch) {
322
+ currentType = typeMatch[1];
323
+ continue;
324
+ }
325
+
326
+ if (currentType && trimmed.match(/^\w+\s*[(:]/)) {
327
+ const fieldMatch = trimmed.match(/^(\w+)/);
328
+ if (fieldMatch) {
329
+ operations.push({
330
+ type: currentType,
331
+ name: fieldMatch[1],
332
+ line: baseLine + i,
333
+ source: 'gql-template'
334
+ });
335
+ }
336
+ }
337
+
338
+ if (trimmed === '}') {
339
+ currentType = null;
340
+ }
341
+ }
342
+
343
+ return operations;
344
+ }
345
+
346
+ // ─────────────────────────────────────────────────────────────
347
+ // Main
348
+ // ─────────────────────────────────────────────────────────────
349
+
350
+ const args = process.argv.slice(2);
351
+ const options = parseCommonArgs(args);
352
+
353
+ // Parse custom options
354
+ let restOnly = false;
355
+ let graphqlOnly = false;
356
+ let groupByFile = false;
357
+ let withHandlers = false;
358
+
359
+ const remaining = [];
360
+ for (let i = 0; i < options.remaining.length; i++) {
361
+ const arg = options.remaining[i];
362
+
363
+ if (arg === '--rest-only' || arg === '-r') {
364
+ restOnly = true;
365
+ } else if (arg === '--graphql-only' || arg === '-g') {
366
+ graphqlOnly = true;
367
+ } else if (arg === '--group-by-file') {
368
+ groupByFile = true;
369
+ } else if (arg === '--with-handlers') {
370
+ withHandlers = true;
371
+ } else if (!arg.startsWith('-')) {
372
+ remaining.push(arg);
373
+ }
374
+ }
375
+
376
+ const targetDir = remaining[0] || '.';
377
+
378
+ if (options.help) {
379
+ console.log(HELP);
380
+ process.exit(0);
381
+ }
382
+
383
+ if (!existsSync(targetDir)) {
384
+ console.error(`Directory not found: ${targetDir}`);
385
+ process.exit(1);
386
+ }
387
+
388
+ const projectRoot = findProjectRoot();
389
+ const out = createOutput(options);
390
+
391
+ // Find all code files
392
+ let files = [];
393
+ const stat = statSync(targetDir);
394
+ if (stat.isFile()) {
395
+ files = [targetDir];
396
+ } else {
397
+ files = findCodeFiles(targetDir);
398
+ }
399
+
400
+ if (files.length === 0) {
401
+ console.error('No code files found');
402
+ process.exit(1);
403
+ }
404
+
405
+ const allRest = [];
406
+ const allGraphql = [];
407
+
408
+ for (const file of files) {
409
+ const content = readFileSync(file, 'utf-8');
410
+ const relPath = relative(projectRoot, file);
411
+
412
+ if (!graphqlOnly) {
413
+ const rest = extractRestEndpoints(content, file);
414
+ rest.forEach(e => allRest.push({ ...e, file: relPath }));
415
+ }
416
+
417
+ if (!restOnly) {
418
+ const graphql = extractGraphqlOperations(content, file);
419
+ graphql.forEach(o => allGraphql.push({ ...o, file: relPath }));
420
+ }
421
+ }
422
+
423
+ // Deduplicate GraphQL operations (same name+type+file)
424
+ const seenGraphql = new Set();
425
+ const dedupedGraphql = allGraphql.filter(op => {
426
+ const key = `${op.type}:${op.name}:${op.file}`;
427
+ if (seenGraphql.has(key)) return false;
428
+ seenGraphql.add(key);
429
+ return true;
430
+ });
431
+
432
+ // Set JSON data
433
+ out.setData('rest', allRest);
434
+ out.setData('graphql', dedupedGraphql);
435
+ out.setData('totalEndpoints', allRest.length + dedupedGraphql.length);
436
+
437
+ // Output REST endpoints
438
+ if (allRest.length > 0) {
439
+ out.header(`🌐 REST Endpoints (${allRest.length})`);
440
+ out.blank();
441
+
442
+ if (groupByFile) {
443
+ const byFile = new Map();
444
+ for (const ep of allRest) {
445
+ if (!byFile.has(ep.file)) byFile.set(ep.file, []);
446
+ byFile.get(ep.file).push(ep);
447
+ }
448
+
449
+ for (const [file, endpoints] of byFile) {
450
+ out.add(` ${file}`);
451
+ for (const ep of endpoints) {
452
+ const handler = withHandlers && ep.handler ? ` → ${ep.handler}` : '';
453
+ out.add(` ${ep.method.padEnd(7)} ${ep.path}${handler}`);
454
+ }
455
+ }
456
+ } else {
457
+ // Group by method
458
+ const byMethod = new Map();
459
+ for (const ep of allRest) {
460
+ if (!byMethod.has(ep.method)) byMethod.set(ep.method, []);
461
+ byMethod.get(ep.method).push(ep);
462
+ }
463
+
464
+ const methodOrder = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS', 'ALL'];
465
+ for (const method of methodOrder) {
466
+ const endpoints = byMethod.get(method);
467
+ if (!endpoints) continue;
468
+
469
+ out.add(` ${method}`);
470
+ for (const ep of endpoints) {
471
+ const handler = withHandlers && ep.handler ? ` → ${ep.handler}` : '';
472
+ const location = groupByFile ? '' : ` (${ep.file}:${ep.line})`;
473
+ out.add(` ${ep.path}${handler}${location}`);
474
+ }
475
+ }
476
+ }
477
+ out.blank();
478
+ }
479
+
480
+ // Output GraphQL operations
481
+ if (dedupedGraphql.length > 0) {
482
+ out.header(`📊 GraphQL Operations (${dedupedGraphql.length})`);
483
+ out.blank();
484
+
485
+ const byType = new Map();
486
+ for (const op of dedupedGraphql) {
487
+ if (!byType.has(op.type)) byType.set(op.type, []);
488
+ byType.get(op.type).push(op);
489
+ }
490
+
491
+ for (const type of ['Query', 'Mutation', 'Subscription']) {
492
+ const ops = byType.get(type);
493
+ if (!ops) continue;
494
+
495
+ out.add(` ${type}`);
496
+ for (const op of ops) {
497
+ out.add(` ${op.name} (${op.file}:${op.line})`);
498
+ }
499
+ }
500
+ out.blank();
501
+ }
502
+
503
+ // Summary
504
+ if (!options.quiet) {
505
+ if (allRest.length === 0 && dedupedGraphql.length === 0) {
506
+ out.add('No API endpoints found');
507
+ } else {
508
+ const parts = [];
509
+ if (allRest.length > 0) parts.push(`${allRest.length} REST`);
510
+ if (dedupedGraphql.length > 0) parts.push(`${dedupedGraphql.length} GraphQL`);
511
+ out.add(`Total: ${parts.join(', ')} endpoints`);
512
+ }
513
+ }
514
+
515
+ out.print();