@appport/assessment 0.0.0-stage → 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/dist/js.js ADDED
@@ -0,0 +1,681 @@
1
+ /**
2
+ * A reader for the part of JavaScript and TypeScript that registers HTTP routes: a tokenizer and a recognizer over its tokens.
3
+ * It reads text only; it never runs, resolves packages or follows anything outside the repository.
4
+ *
5
+ * What it recognizes (and nothing else):
6
+ * - receivers, by how they are declared: `express()`, `express.Router()`, `Router()`, `new Router()`, `fastify()`, `Fastify()`, `new Hono()`.
7
+ * A call such as `cache.get('key')` is not a route, because `cache` is not a receiver;
8
+ * - routes on a receiver: `r.get('/p', ...)`, `r.route('/p').get(...).post(...)`, Fastify's `r.route({ method, url, handler })`;
9
+ * - NestJS: `@Controller('prefix')` classes with `@Get('p')` and the like on their methods;
10
+ * - mounts: `app.use('/prefix', ...middleware, router)`, where `router` is declared here or imported from a relative module (or a path
11
+ * alias the caller supplies), so a router's routes carry the prefixes that lead to it.
12
+ *
13
+ * What it does not do: evaluate anything. A path that is not a string, a template or a `+` of strings and names has no known value and
14
+ * is reported as `dynamic`; a name inside a path (`/${entity}/create`) is kept as a `{entity}` placeholder, never guessed. Routes
15
+ * registered in a loop, by a helper or by reflection are found only as far as the registration call itself is literal.
16
+ */
17
+ export class JsSyntaxError extends Error {
18
+ line;
19
+ constructor(message, line) {
20
+ super(message);
21
+ this.line = line;
22
+ this.name = "JsSyntaxError";
23
+ }
24
+ }
25
+ const PUNCT3 = ["...", "===", "!==", "**=", "<<=", ">>=", "&&=", "||=", "??=", ">>>"];
26
+ const PUNCT2 = ["=>", "==", "!=", "<=", ">=", "&&", "||", "??", "?.", "++", "--", "+=", "-=", "*=", "/=", "%=", "&=", "|=", "^=", "**", "<<", ">>"];
27
+ const REGEX_AFTER = new Set(["(", ",", "=", ":", "[", "!", "&", "|", "?", "{", "}", ";", "+", "-", "*", "%", "<", ">", "~", "^", "=>", "&&", "||", "??", "==", "===", "!=", "!=="]);
28
+ const REGEX_AFTER_WORDS = new Set(["return", "typeof", "case", "of", "in", "delete", "void", "throw", "new", "else", "do", "yield", "await"]);
29
+ const isIdStart = (c) => /[A-Za-z_$\u0080-￿]/.test(c);
30
+ const isIdPart = (c) => /[A-Za-z0-9_$\u0080-￿]/.test(c);
31
+ /** @throws JsSyntaxError for an unterminated string, template, comment or regular expression. */
32
+ export function tokenize(text) {
33
+ const tokens = [];
34
+ let i = 0;
35
+ let line = 1;
36
+ const n = text.length;
37
+ const last = () => tokens[tokens.length - 1];
38
+ /** Scans a template literal starting after its opening backtick; returns the index after the closing one. */
39
+ const template = (start) => {
40
+ const exprs = [];
41
+ const quasis = [];
42
+ let k = start;
43
+ let quasi = "";
44
+ let lines = 0;
45
+ while (k < n) {
46
+ const c = text[k];
47
+ if (c === "\\") {
48
+ quasi += text.slice(k, k + 2);
49
+ if (text[k + 1] === "\n")
50
+ lines++;
51
+ k += 2;
52
+ continue;
53
+ }
54
+ if (c === "`") {
55
+ quasis.push(quasi);
56
+ return { end: k + 1, exprs, quasis, lines };
57
+ }
58
+ if (c === "$" && text[k + 1] === "{") {
59
+ quasis.push(quasi);
60
+ quasi = "";
61
+ let depth = 1;
62
+ let j = k + 2;
63
+ const exprStart = j;
64
+ while (j < n && depth > 0) {
65
+ const d = text[j];
66
+ if (d === "\n")
67
+ lines++;
68
+ if (d === "{")
69
+ depth++;
70
+ else if (d === "}")
71
+ depth--;
72
+ else if (d === "`") {
73
+ const inner = template(j + 1);
74
+ lines += inner.lines;
75
+ j = inner.end;
76
+ continue;
77
+ }
78
+ else if (d === '"' || d === "'") {
79
+ j++;
80
+ while (j < n && text[j] !== d)
81
+ j += text[j] === "\\" ? 2 : 1;
82
+ }
83
+ j++;
84
+ }
85
+ if (depth > 0)
86
+ throw new JsSyntaxError("unterminated ${…} in a template literal", line);
87
+ exprs.push(text.slice(exprStart, j - 1));
88
+ k = j;
89
+ continue;
90
+ }
91
+ if (c === "\n")
92
+ lines++;
93
+ quasi += c;
94
+ k++;
95
+ }
96
+ throw new JsSyntaxError("unterminated template literal", line);
97
+ };
98
+ while (i < n) {
99
+ const c = text[i];
100
+ if (c === "\n") {
101
+ line++;
102
+ i++;
103
+ continue;
104
+ }
105
+ if (c === " " || c === "\t" || c === "\r" || c === "") {
106
+ i++;
107
+ continue;
108
+ }
109
+ if (c === "#" && i === 0 && text[1] === "!") {
110
+ while (i < n && text[i] !== "\n")
111
+ i++;
112
+ continue;
113
+ }
114
+ if (c === "/" && text[i + 1] === "/") {
115
+ while (i < n && text[i] !== "\n")
116
+ i++;
117
+ continue;
118
+ }
119
+ if (c === "/" && text[i + 1] === "*") {
120
+ const end = text.indexOf("*/", i + 2);
121
+ if (end < 0)
122
+ throw new JsSyntaxError("unterminated comment", line);
123
+ for (let k = i; k < end; k++)
124
+ if (text[k] === "\n")
125
+ line++;
126
+ i = end + 2;
127
+ continue;
128
+ }
129
+ if (c === '"' || c === "'") {
130
+ const start = line;
131
+ let k = i + 1;
132
+ let value = "";
133
+ while (k < n && text[k] !== c) {
134
+ if (text[k] === "\n")
135
+ throw new JsSyntaxError("unterminated string", start);
136
+ if (text[k] === "\\") {
137
+ const next = text[k + 1] ?? "";
138
+ if (next === "\n")
139
+ line++;
140
+ value += next === "n" ? "\n" : next === "t" ? "\t" : next;
141
+ k += 2;
142
+ }
143
+ else
144
+ value += text[k++];
145
+ }
146
+ if (k >= n)
147
+ throw new JsSyntaxError("unterminated string", start);
148
+ tokens.push({ kind: "str", value, line: start, endLine: line });
149
+ i = k + 1;
150
+ continue;
151
+ }
152
+ if (c === "`") {
153
+ const start = line;
154
+ const t = template(i + 1);
155
+ line += t.lines;
156
+ tokens.push({ kind: "tpl", value: text.slice(i + 1, t.end - 1), line: start, endLine: line, exprs: t.exprs, quasis: t.quasis });
157
+ i = t.end;
158
+ continue;
159
+ }
160
+ if (isIdStart(c)) {
161
+ let k = i + 1;
162
+ while (k < n && isIdPart(text[k]))
163
+ k++;
164
+ tokens.push({ kind: "id", value: text.slice(i, k), line, endLine: line });
165
+ i = k;
166
+ continue;
167
+ }
168
+ if (/[0-9]/.test(c) || (c === "." && /[0-9]/.test(text[i + 1] ?? ""))) {
169
+ let k = i + 1;
170
+ while (k < n && /[0-9A-Za-z_.]/.test(text[k]))
171
+ k++;
172
+ tokens.push({ kind: "num", value: text.slice(i, k), line, endLine: line });
173
+ i = k;
174
+ continue;
175
+ }
176
+ if (c === "/") {
177
+ const prev = last();
178
+ const regex = !prev || (prev.kind === "p" && REGEX_AFTER.has(prev.value)) || (prev.kind === "id" && REGEX_AFTER_WORDS.has(prev.value));
179
+ if (regex) {
180
+ let k = i + 1;
181
+ let inClass = false;
182
+ while (k < n) {
183
+ const d = text[k];
184
+ if (d === "\n")
185
+ throw new JsSyntaxError("unterminated regular expression", line);
186
+ if (d === "\\")
187
+ k += 2;
188
+ else {
189
+ if (d === "[")
190
+ inClass = true;
191
+ else if (d === "]")
192
+ inClass = false;
193
+ else if (d === "/" && !inClass)
194
+ break;
195
+ k++;
196
+ }
197
+ }
198
+ if (k >= n)
199
+ throw new JsSyntaxError("unterminated regular expression", line);
200
+ k++;
201
+ while (k < n && /[a-z]/.test(text[k]))
202
+ k++;
203
+ tokens.push({ kind: "re", value: text.slice(i, k), line, endLine: line });
204
+ i = k;
205
+ continue;
206
+ }
207
+ }
208
+ const three = text.slice(i, i + 3);
209
+ const two = text.slice(i, i + 2);
210
+ const punct = PUNCT3.includes(three) ? three : PUNCT2.includes(two) ? two : c;
211
+ tokens.push({ kind: "p", value: punct, line, endLine: line });
212
+ i += punct.length;
213
+ }
214
+ return tokens;
215
+ }
216
+ // ---- recognition --------------------------------------------------------------------------------------------------
217
+ export const HTTP_METHODS = ["get", "post", "put", "patch", "delete", "head", "options", "all"];
218
+ const METHOD_SET = new Set(HTTP_METHODS);
219
+ const NEST_DECORATORS = { Get: "get", Post: "post", Put: "put", Patch: "patch", Delete: "delete", Head: "head", Options: "options", All: "all" };
220
+ const closers = { "(": ")", "[": "]", "{": "}" };
221
+ /** Index of the token that closes the bracket at `open`, or -1. */
222
+ function matching(tokens, open) {
223
+ const stack = [];
224
+ for (let i = open; i < tokens.length; i++) {
225
+ const t = tokens[i];
226
+ if (t.kind !== "p")
227
+ continue;
228
+ if (closers[t.value])
229
+ stack.push(closers[t.value]);
230
+ else if (t.value === ")" || t.value === "]" || t.value === "}") {
231
+ if (stack.pop() !== t.value)
232
+ return -1;
233
+ if (!stack.length)
234
+ return i;
235
+ }
236
+ }
237
+ return -1;
238
+ }
239
+ /** Splits the tokens between `open` (a bracket) and its closer into top-level, comma-separated ranges `[from, to)`. */
240
+ function splitArgs(tokens, open, close) {
241
+ const out = [];
242
+ let depth = 0;
243
+ let from = open + 1;
244
+ for (let i = open + 1; i < close; i++) {
245
+ const t = tokens[i];
246
+ if (t.kind === "p" && closers[t.value])
247
+ depth++;
248
+ else if (t.kind === "p" && (t.value === ")" || t.value === "]" || t.value === "}"))
249
+ depth--;
250
+ else if (t.kind === "p" && t.value === "," && depth === 0) {
251
+ if (i > from)
252
+ out.push([from, i]);
253
+ from = i + 1;
254
+ }
255
+ }
256
+ if (close > from)
257
+ out.push([from, close]);
258
+ return out;
259
+ }
260
+ const isP = (t, v) => t?.kind === "p" && t.value === v;
261
+ const isId = (t, v) => t?.kind === "id" && (v === undefined || t.value === v);
262
+ /** The value of a path argument made of strings, templates and names joined by `+`. */
263
+ function pathValue(tokens, from, to) {
264
+ let value = "";
265
+ let raw = "";
266
+ const placeholders = [];
267
+ let unknown = false;
268
+ let expectOperand = true;
269
+ for (let i = from; i < to; i++) {
270
+ const t = tokens[i];
271
+ if (expectOperand) {
272
+ if (t.kind === "str") {
273
+ value += t.value;
274
+ raw += t.value;
275
+ }
276
+ else if (t.kind === "tpl") {
277
+ raw += t.value;
278
+ t.quasis.forEach((q, k) => {
279
+ value += q;
280
+ const e = t.exprs[k];
281
+ if (e !== undefined) {
282
+ const name = /^\s*([A-Za-z_$][\w$]*)\s*$/.exec(e)?.[1];
283
+ if (name) {
284
+ value += `{${name}}`;
285
+ placeholders.push(name);
286
+ }
287
+ else
288
+ unknown = true;
289
+ }
290
+ });
291
+ }
292
+ else if (t.kind === "id" && !isP(tokens[i + 1], "(") && !isP(tokens[i + 1], ".")) {
293
+ value += `{${t.value}}`;
294
+ raw += t.value;
295
+ placeholders.push(t.value);
296
+ }
297
+ else
298
+ unknown = true;
299
+ expectOperand = false;
300
+ }
301
+ else if (isP(t, "+"))
302
+ expectOperand = true;
303
+ else
304
+ unknown = true;
305
+ }
306
+ if (unknown || expectOperand)
307
+ return { value: "", placeholders: [], resolution: "unknown", raw: raw || "(expression)" };
308
+ return { value, placeholders: [...new Set(placeholders)], resolution: placeholders.length ? "placeholders" : "known", raw };
309
+ }
310
+ /** Text of a token range, for citing a middleware or callee. */
311
+ function textOf(tokens, from, to) {
312
+ let out = "";
313
+ for (let i = from; i < to; i++) {
314
+ const t = tokens[i];
315
+ out += t.kind === "str" ? JSON.stringify(t.value) : t.kind === "tpl" ? `\`${t.value}\`` : t.value;
316
+ }
317
+ return out;
318
+ }
319
+ /** A dotted reference `a.b.c` (or `a['b']`) occupying exactly `[from, to)`. */
320
+ function reference(tokens, from, to) {
321
+ if (!isId(tokens[from]))
322
+ return undefined;
323
+ let out = tokens[from].value;
324
+ let i = from + 1;
325
+ while (i < to) {
326
+ if ((isP(tokens[i], ".") || isP(tokens[i], "?.")) && isId(tokens[i + 1])) {
327
+ out += `.${tokens[i + 1].value}`;
328
+ i += 2;
329
+ }
330
+ else if (isP(tokens[i], "[") && tokens[i + 1]?.kind === "str" && isP(tokens[i + 2], "]")) {
331
+ out += `.${tokens[i + 1].value}`;
332
+ i += 3;
333
+ }
334
+ else
335
+ return undefined;
336
+ }
337
+ return out;
338
+ }
339
+ function classify(tokens, from, to) {
340
+ const first = tokens[from];
341
+ const lastLine = tokens[to - 1].endLine;
342
+ // An inline function: `(a, b) => …`, `async (a) => …`, `function (a) { … }`, `a => …`.
343
+ const head = isId(first, "async") ? from + 1 : from;
344
+ const isFunction = isId(tokens[head], "function") || (isP(tokens[head], "(") && (() => { const close = matching(tokens, head); return close > 0 && isP(tokens[close + 1], "=>"); })()) || (isId(tokens[head]) && isP(tokens[head + 1], "=>"));
345
+ if (isFunction) {
346
+ const reads = new Set();
347
+ for (let i = from; i + 3 < to; i++) {
348
+ if (isId(tokens[i]) && ["req", "request", "ctx"].includes(tokens[i].value) && isP(tokens[i + 1], ".") && isId(tokens[i + 2]) && ["body", "query", "params"].includes(tokens[i + 2].value) && isP(tokens[i + 3], ".") && isId(tokens[i + 4]))
349
+ reads.add(`${tokens[i + 2].value}.${tokens[i + 4].value}`);
350
+ }
351
+ return { handler: { kind: "inline", startLine: first.line, endLine: lastLine, reads: [...reads].sort() } };
352
+ }
353
+ const ref = reference(tokens, from, to);
354
+ if (ref)
355
+ return { handler: { kind: "reference", reference: ref, startLine: first.line, endLine: lastLine, reads: [] }, middleware: { text: ref, line: first.line } };
356
+ // A call: `callee(args)`. A wrapper when its one argument is itself a handler (`catchErrors(controller.read)`).
357
+ const open = (() => { for (let i = from; i < to; i++)
358
+ if (isP(tokens[i], "("))
359
+ return i; return -1; })();
360
+ if (open > from && matching(tokens, open) === to - 1) {
361
+ const callee = reference(tokens, from, open);
362
+ const inner = splitArgs(tokens, open, to - 1);
363
+ const text = `${callee ?? textOf(tokens, from, open)}(…)`;
364
+ if (callee && inner.length === 1) {
365
+ const wrapped = classify(tokens, inner[0][0], inner[0][1]);
366
+ if (wrapped.handler)
367
+ return { handler: { ...wrapped.handler, wrapper: callee, startLine: first.line, endLine: lastLine }, middleware: { text, line: first.line } };
368
+ }
369
+ return { middleware: { text, line: first.line } };
370
+ }
371
+ return { middleware: { text: textOf(tokens, from, Math.min(to, from + 6)), line: first.line } };
372
+ }
373
+ const RECEIVER_FACTORIES = [
374
+ { framework: "express", kind: "app", match: (t, i) => isId(t[i], "express") && isP(t[i + 1], "(") },
375
+ { framework: "express", kind: "router", match: (t, i) => isId(t[i], "express") && isP(t[i + 1], ".") && isId(t[i + 2], "Router") },
376
+ { framework: "express", kind: "router", match: (t, i) => isId(t[i], "Router") && isP(t[i + 1], "(") },
377
+ { framework: "koa-router", kind: "router", match: (t, i) => isId(t[i], "new") && isId(t[i + 1], "Router") },
378
+ { framework: "fastify", kind: "app", match: (t, i) => (isId(t[i], "fastify") || isId(t[i], "Fastify")) && isP(t[i + 1], "(") },
379
+ { framework: "hono", kind: "app", match: (t, i) => isId(t[i], "new") && isId(t[i + 1], "Hono") }
380
+ ];
381
+ export function readJs(tokens) {
382
+ const file = { receivers: new Map(), routes: [], mounts: [], imports: new Map(), unreadable: [] };
383
+ // Imports and receivers first: routes are recognized only on names declared as receivers.
384
+ for (let i = 0; i < tokens.length; i++) {
385
+ const t = tokens[i];
386
+ // const x = require('m') / const { a, b: c } = require('m') / x = require('m').y
387
+ if (isId(t) && ["const", "let", "var"].includes(t.value)) {
388
+ let j = i + 1;
389
+ const names = [];
390
+ if (isP(tokens[j], "{")) {
391
+ const close = matching(tokens, j);
392
+ if (close < 0)
393
+ continue;
394
+ for (const [a, b] of splitArgs(tokens, j, close)) {
395
+ if (isId(tokens[a]) && isP(tokens[a + 1], ":") && isId(tokens[a + 2]))
396
+ names.push({ local: tokens[a + 2].value, imported: tokens[a].value });
397
+ else if (isId(tokens[a]) && b - a === 1)
398
+ names.push({ local: tokens[a].value, imported: tokens[a].value });
399
+ }
400
+ j = close + 1;
401
+ }
402
+ else if (isId(tokens[j])) {
403
+ names.push({ local: tokens[j].value });
404
+ j++;
405
+ }
406
+ else
407
+ continue;
408
+ while (j < tokens.length && !isP(tokens[j], "=") && !isP(tokens[j], ";")) {
409
+ // a type annotation: `const router: Router = …`
410
+ j++;
411
+ if (j - i > 40)
412
+ break;
413
+ }
414
+ if (!isP(tokens[j], "="))
415
+ continue;
416
+ j++;
417
+ if (isId(tokens[j], "require") && isP(tokens[j + 1], "(") && tokens[j + 2]?.kind === "str") {
418
+ const specifier = tokens[j + 2].value;
419
+ for (const n of names)
420
+ file.imports.set(n.local, { specifier, ...(n.imported ? { imported: n.imported } : {}) });
421
+ // `require('express')()`, `require('fastify')({ … })` and `require('express').Router()` are factories too.
422
+ const local = names.length === 1 && !names[0].imported ? names[0].local : undefined;
423
+ if (local && isP(tokens[j + 4], "(") && (specifier === "express" || specifier === "fastify"))
424
+ file.receivers.set(local, { framework: specifier, kind: "app", line: t.line });
425
+ else if (local && isP(tokens[j + 4], ".") && isId(tokens[j + 5], "Router") && specifier === "express")
426
+ file.receivers.set(local, { framework: "express", kind: "router", line: t.line });
427
+ continue;
428
+ }
429
+ const factory = RECEIVER_FACTORIES.find((f) => f.match(tokens, j));
430
+ if (factory && names.length === 1 && !names[0].imported)
431
+ file.receivers.set(names[0].local, { framework: factory.framework, kind: factory.kind, line: t.line });
432
+ continue;
433
+ }
434
+ // import x from 'm' / import { a as b } from 'm' / import * as x from 'm'
435
+ if (isId(t, "import") && i + 1 < tokens.length && !isP(tokens[i + 1], "(") && !isP(tokens[i + 1], ".")) {
436
+ let j = i + 1;
437
+ const names = [];
438
+ if (isId(tokens[j]) && !isId(tokens[j], "type")) {
439
+ names.push({ local: tokens[j].value, imported: "default" });
440
+ j++;
441
+ if (isP(tokens[j], ","))
442
+ j++;
443
+ }
444
+ else if (isId(tokens[j], "type"))
445
+ j++;
446
+ if (isP(tokens[j], "*") && isId(tokens[j + 1], "as") && isId(tokens[j + 2])) {
447
+ names.push({ local: tokens[j + 2].value });
448
+ j += 3;
449
+ }
450
+ else if (isP(tokens[j], "{")) {
451
+ const close = matching(tokens, j);
452
+ if (close < 0)
453
+ continue;
454
+ for (const [a, b] of splitArgs(tokens, j, close)) {
455
+ if (isId(tokens[a], "type"))
456
+ continue;
457
+ if (isId(tokens[a]) && isId(tokens[a + 1], "as") && isId(tokens[a + 2]))
458
+ names.push({ local: tokens[a + 2].value, imported: tokens[a].value });
459
+ else if (isId(tokens[a]) && b - a === 1)
460
+ names.push({ local: tokens[a].value, imported: tokens[a].value });
461
+ }
462
+ j = close + 1;
463
+ }
464
+ if (isId(tokens[j], "from") && tokens[j + 1]?.kind === "str") {
465
+ const specifier = tokens[j + 1].value;
466
+ for (const n of names)
467
+ file.imports.set(n.local, { specifier, ...(n.imported ? { imported: n.imported } : {}) });
468
+ if (specifier === "express")
469
+ for (const n of names)
470
+ if (n.imported === "Router")
471
+ file.imports.set("Router", { specifier, imported: "Router" });
472
+ }
473
+ continue;
474
+ }
475
+ // module.exports = x / export default x
476
+ if (isId(t, "module") && isP(tokens[i + 1], ".") && isId(tokens[i + 2], "exports") && isP(tokens[i + 3], "=") && isId(tokens[i + 4]) && !isP(tokens[i + 5], ".") && !isP(tokens[i + 5], "("))
477
+ file.exports = tokens[i + 4].value;
478
+ if (isId(t, "export") && isId(tokens[i + 1], "default") && isId(tokens[i + 2]) && !isP(tokens[i + 3], "(") && !isP(tokens[i + 3], "."))
479
+ file.exports = tokens[i + 2].value;
480
+ }
481
+ // `Router` imported from express is a factory: `const r = Router()`.
482
+ if (file.imports.get("Router")?.specifier === "express") {
483
+ for (let i = 0; i + 3 < tokens.length; i++) {
484
+ if (isId(tokens[i]) && ["const", "let", "var"].includes(tokens[i].value) && isId(tokens[i + 1]) && isP(tokens[i + 2], "=") && isId(tokens[i + 3], "Router") && isP(tokens[i + 4], "("))
485
+ file.receivers.set(tokens[i + 1].value, { framework: "express", kind: "router", line: tokens[i].line });
486
+ }
487
+ }
488
+ // Routes, route chains and mounts.
489
+ for (let i = 0; i + 3 < tokens.length; i++) {
490
+ const recv = tokens[i];
491
+ if (!isId(recv) || !file.receivers.has(recv.value) || !isP(tokens[i + 1], ".") || !isId(tokens[i + 2]) || !isP(tokens[i + 3], "("))
492
+ continue;
493
+ if (i > 0 && isP(tokens[i - 1], "."))
494
+ continue; // `x.router.get(...)` is not `router.get(...)`
495
+ const { framework } = file.receivers.get(recv.value);
496
+ const verb = tokens[i + 2].value;
497
+ const open = i + 3;
498
+ const close = matching(tokens, open);
499
+ if (close < 0) {
500
+ file.unreadable.push({ line: recv.line, reason: `unbalanced call ${recv.value}.${verb}(` });
501
+ continue;
502
+ }
503
+ const args = splitArgs(tokens, open, close);
504
+ if (verb === "use") {
505
+ // use([prefix,] ...middleware, target)
506
+ let a = 0;
507
+ let prefix = { value: "", placeholders: [], resolution: "known", raw: "" };
508
+ if (args[0] && (tokens[args[0][0]].kind === "str" || tokens[args[0][0]].kind === "tpl")) {
509
+ prefix = pathValue(tokens, args[0][0], args[0][1]);
510
+ a = 1;
511
+ }
512
+ const rest = args.slice(a);
513
+ const targetArg = rest[rest.length - 1];
514
+ if (!targetArg)
515
+ continue;
516
+ const target = reference(tokens, targetArg[0], targetArg[1]);
517
+ // `require('./router')` mounted inline
518
+ const inline = isId(tokens[targetArg[0]], "require") && isP(tokens[targetArg[0] + 1], "(") && tokens[targetArg[0] + 2]?.kind === "str" ? tokens[targetArg[0] + 2].value : undefined;
519
+ if (!target && !inline)
520
+ continue;
521
+ const local = inline ? `require(${JSON.stringify(inline)})` : target;
522
+ if (inline)
523
+ file.imports.set(local, { specifier: inline });
524
+ if (!file.receivers.has(local) && !file.imports.has(local.split(".")[0]))
525
+ continue; // `app.use(cors())`, `app.use(express.json())`
526
+ if (target && target.includes(".") && !file.imports.has(target.split(".")[0]))
527
+ continue;
528
+ const middleware = rest.slice(0, -1).map(([from, to]) => classify(tokens, from, to).middleware).filter((m) => m !== undefined);
529
+ file.mounts.push({ receiver: recv.value, prefix, target: local, middleware, line: recv.line });
530
+ continue;
531
+ }
532
+ if (verb === "route") {
533
+ // route('/p') then .get(h).post(h)… | route({ method, url, handler })
534
+ if (framework === "fastify" || (args.length === 1 && isP(tokens[args[0][0]], "{"))) {
535
+ const [from, to] = args[0] ?? [0, 0];
536
+ const props = new Map();
537
+ const braceClose = matching(tokens, from);
538
+ if (isP(tokens[from], "{") && braceClose > 0)
539
+ for (const [a, b] of splitArgs(tokens, from, braceClose))
540
+ if (isId(tokens[a]) && isP(tokens[a + 1], ":"))
541
+ props.set(tokens[a].value, [a + 2, b]);
542
+ const methodArg = props.get("method");
543
+ const urlArg = props.get("url") ?? props.get("path");
544
+ const handlerArg = props.get("handler");
545
+ if (methodArg && urlArg && handlerArg && tokens[methodArg[0]]?.kind === "str") {
546
+ const middleware = ["preHandler", "onRequest", "preValidation"].flatMap((k) => (props.has(k) ? [classify(tokens, props.get(k)[0], props.get(k)[1]).middleware] : [])).filter((m) => m !== undefined);
547
+ const h = classify(tokens, handlerArg[0], handlerArg[1]).handler;
548
+ file.routes.push({ receiver: recv.value, framework: "fastify", method: tokens[methodArg[0]].value.toLowerCase(), path: pathValue(tokens, urlArg[0], urlArg[1]), startLine: recv.line, endLine: tokens[close].endLine, middleware, ...(h ? { handler: h } : {}) });
549
+ }
550
+ else if (to > from)
551
+ file.unreadable.push({ line: recv.line, reason: "route({…}) without a literal method, url and handler" });
552
+ continue;
553
+ }
554
+ if (args.length !== 1)
555
+ continue;
556
+ const path = pathValue(tokens, args[0][0], args[0][1]);
557
+ let k = close + 1;
558
+ while (isP(tokens[k], ".") && isId(tokens[k + 1]) && isP(tokens[k + 2], "(")) {
559
+ const name = tokens[k + 1].value;
560
+ const cClose = matching(tokens, k + 2);
561
+ if (cClose < 0)
562
+ break;
563
+ if (METHOD_SET.has(name)) {
564
+ const parts = splitArgs(tokens, k + 2, cClose).map(([a, b]) => classify(tokens, a, b));
565
+ const handler = [...parts].reverse().find((p) => p.handler)?.handler;
566
+ const middleware = parts.filter((p) => !p.handler || p.middleware).map((p) => p.middleware).filter((m) => m !== undefined);
567
+ const mids = handler ? middleware.slice(0, Math.max(0, middleware.length - 1)) : middleware;
568
+ file.routes.push({ receiver: recv.value, framework, method: name, path, startLine: tokens[i].line, endLine: tokens[cClose].endLine, middleware: mids, ...(handler ? { handler } : {}) });
569
+ }
570
+ k = cClose + 1;
571
+ }
572
+ continue;
573
+ }
574
+ if (METHOD_SET.has(verb)) {
575
+ // `app.get('setting')` reads a setting; a route has a handler.
576
+ if (args.length < 2)
577
+ continue;
578
+ const first = tokens[args[0][0]];
579
+ if (first.kind !== "str" && first.kind !== "tpl" && first.kind !== "re" && !(first.kind === "id" && args.length >= 2))
580
+ continue;
581
+ if (first.kind === "re") {
582
+ file.unreadable.push({ line: recv.line, reason: `${recv.value}.${verb}(/regex/…): a path given as a regular expression is not read` });
583
+ continue;
584
+ }
585
+ const path = pathValue(tokens, args[0][0], args[0][1]);
586
+ const parts = args.slice(1).map(([a, b]) => classify(tokens, a, b));
587
+ const handler = [...parts].reverse().find((p) => p.handler)?.handler;
588
+ const handlerIndex = handler ? parts.map((p) => p.handler).lastIndexOf(handler) : -1;
589
+ const middleware = parts.filter((_, idx) => idx !== handlerIndex).map((p) => p.middleware).filter((m) => m !== undefined);
590
+ // `cache.get(key, cb)`-style calls on non-receivers never get here; on a receiver a non-literal first argument is a dynamic path.
591
+ file.routes.push({ receiver: recv.value, framework, method: verb, path, startLine: recv.line, endLine: tokens[close].endLine, middleware, ...(handler ? { handler } : {}) });
592
+ }
593
+ }
594
+ // NestJS controllers.
595
+ for (let i = 0; i + 2 < tokens.length; i++) {
596
+ if (!(isP(tokens[i], "@") && isId(tokens[i + 1], "Controller")))
597
+ continue;
598
+ let prefix = "";
599
+ let j = i + 2;
600
+ if (isP(tokens[j], "(")) {
601
+ const close = matching(tokens, j);
602
+ if (close < 0)
603
+ continue;
604
+ const arg = tokens[j + 1];
605
+ if (arg?.kind === "str")
606
+ prefix = arg.value;
607
+ else if (isP(arg, "{"))
608
+ for (let k = j + 1; k < close; k++)
609
+ if (isId(tokens[k], "path") && isP(tokens[k + 1], ":") && tokens[k + 2]?.kind === "str")
610
+ prefix = tokens[k + 2].value;
611
+ j = close + 1;
612
+ }
613
+ while (j < tokens.length && !isId(tokens[j], "class")) {
614
+ if (isP(tokens[j], "@")) {
615
+ // another class decorator
616
+ const next = tokens[j + 2];
617
+ j += isP(next, "(") ? matching(tokens, j + 2) - j + 1 : 2;
618
+ }
619
+ else
620
+ j++;
621
+ if (j - i > 200)
622
+ break;
623
+ }
624
+ if (!isId(tokens[j], "class") || !isId(tokens[j + 1]))
625
+ continue;
626
+ const className = tokens[j + 1].value;
627
+ let body = j + 2;
628
+ while (body < tokens.length && !isP(tokens[body], "{"))
629
+ body++;
630
+ const bodyClose = matching(tokens, body);
631
+ if (bodyClose < 0)
632
+ continue;
633
+ for (let k = body + 1; k < bodyClose; k++) {
634
+ if (!(isP(tokens[k], "@") && isId(tokens[k + 1]) && NEST_DECORATORS[tokens[k + 1].value]))
635
+ continue;
636
+ const method = NEST_DECORATORS[tokens[k + 1].value];
637
+ let sub = "";
638
+ let m = k + 2;
639
+ if (isP(tokens[m], "(")) {
640
+ const close = matching(tokens, m);
641
+ if (close < 0)
642
+ continue;
643
+ if (tokens[m + 1]?.kind === "str")
644
+ sub = tokens[m + 1].value;
645
+ m = close + 1;
646
+ }
647
+ // skip further decorators, then the method's name
648
+ while (isP(tokens[m], "@"))
649
+ m += isP(tokens[m + 2], "(") ? matching(tokens, m + 2) - m + 1 : 2;
650
+ while (isId(tokens[m]) && ["public", "private", "protected", "async", "static"].includes(tokens[m].value))
651
+ m++;
652
+ if (!isId(tokens[m]) || !isP(tokens[m + 1], "("))
653
+ continue;
654
+ const joined = `/${[prefix, sub].map((s) => s.replace(/^\/+|\/+$/g, "")).filter(Boolean).join("/")}`;
655
+ const endClose = matching(tokens, m + 1);
656
+ file.routes.push({
657
+ receiver: className,
658
+ framework: "nest",
659
+ method,
660
+ path: { value: joined, placeholders: [], resolution: "known", raw: sub || prefix },
661
+ startLine: tokens[k].line,
662
+ endLine: tokens[m].line,
663
+ middleware: [],
664
+ handler: { kind: "reference", reference: `${className}.${tokens[m].value}`, startLine: tokens[m].line, endLine: endClose > 0 ? tokens[endClose].endLine : tokens[m].line, reads: [] },
665
+ controller: className
666
+ });
667
+ }
668
+ }
669
+ file.routes.sort((a, b) => a.startLine - b.startLine || (a.method < b.method ? -1 : 1));
670
+ return file;
671
+ }
672
+ /** Joins mount prefixes and a route path into one path: `/api` + `/login` = `/api/login`. */
673
+ export function joinPaths(...parts) {
674
+ const joined = parts.map((p) => p.replace(/^\/+|\/+$/g, "")).filter(Boolean).join("/");
675
+ return `/${joined}`;
676
+ }
677
+ /** Express path parameters of a path: `:id`, `:settingKey?`. */
678
+ export function pathParameters(path) {
679
+ return [...path.matchAll(/:([A-Za-z_][\w]*)(\?)?/g)].map((m) => ({ name: m[1], optional: m[2] === "?" }));
680
+ }
681
+ //# sourceMappingURL=js.js.map