@expressots/studio-agent 4.0.0-preview.1 → 4.0.0-preview.3
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 +143 -143
- package/dist/agent.d.ts +75 -0
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +443 -14
- package/dist/agent.js.map +1 -1
- package/dist/discovery/route-scanner.d.ts +62 -1
- package/dist/discovery/route-scanner.d.ts.map +1 -1
- package/dist/discovery/route-scanner.js +923 -101
- package/dist/discovery/route-scanner.js.map +1 -1
- package/dist/identity/index.d.ts +2 -0
- package/dist/identity/index.d.ts.map +1 -0
- package/dist/identity/index.js +2 -0
- package/dist/identity/index.js.map +1 -0
- package/dist/identity/install-id.d.ts +22 -0
- package/dist/identity/install-id.d.ts.map +1 -0
- package/dist/identity/install-id.js +73 -0
- package/dist/identity/install-id.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/instrumentation/tracer.d.ts.map +1 -1
- package/dist/instrumentation/tracer.js +40 -4
- package/dist/instrumentation/tracer.js.map +1 -1
- package/dist/introspection/database-introspector.d.ts +58 -0
- package/dist/introspection/database-introspector.d.ts.map +1 -0
- package/dist/introspection/database-introspector.js +351 -0
- package/dist/introspection/database-introspector.js.map +1 -0
- package/dist/logging/log-capture.d.ts.map +1 -1
- package/dist/logging/log-capture.js +23 -1
- package/dist/logging/log-capture.js.map +1 -1
- package/dist/recording/request-recorder.js +73 -73
- package/dist/security/posture-analyzer.d.ts.map +1 -1
- package/dist/security/posture-analyzer.js +1 -1
- package/dist/security/posture-analyzer.js.map +1 -1
- package/dist/types/index.d.ts +261 -2
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js +2 -0
- package/dist/types/index.js.map +1 -1
- package/package.json +18 -15
|
@@ -5,35 +5,11 @@
|
|
|
5
5
|
import * as fs from 'fs';
|
|
6
6
|
import * as path from 'path';
|
|
7
7
|
import { glob } from 'glob';
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
httpMethods: {
|
|
14
|
-
get: /@Get\s*\(\s*['"`]?([^'"`\)]*)?['"`]?\s*\)/gi,
|
|
15
|
-
post: /@Post\s*\(\s*['"`]?([^'"`\)]*)?['"`]?\s*\)/gi,
|
|
16
|
-
put: /@Put\s*\(\s*['"`]?([^'"`\)]*)?['"`]?\s*\)/gi,
|
|
17
|
-
patch: /@Patch\s*\(\s*['"`]?([^'"`\)]*)?['"`]?\s*\)/gi,
|
|
18
|
-
delete: /@Delete\s*\(\s*['"`]?([^'"`\)]*)?['"`]?\s*\)/gi,
|
|
19
|
-
head: /@Head\s*\(\s*['"`]?([^'"`\)]*)?['"`]?\s*\)/gi,
|
|
20
|
-
options: /@Options\s*\(\s*['"`]?([^'"`\)]*)?['"`]?\s*\)/gi,
|
|
21
|
-
},
|
|
22
|
-
// Match class declaration
|
|
23
|
-
classDeclaration: /class\s+(\w+)/g,
|
|
24
|
-
// Match constructor injection
|
|
25
|
-
constructorInjection: /constructor\s*\([^)]*\)/g,
|
|
26
|
-
// Match @inject decorator
|
|
27
|
-
inject: /@inject\s*\(\s*(\w+)\s*\)/gi,
|
|
28
|
-
// Match method declaration
|
|
29
|
-
methodDeclaration: /(?:async\s+)?(\w+)\s*\([^)]*\)\s*(?::\s*[^{]+)?\s*\{/g,
|
|
30
|
-
// Match service/provider decorators
|
|
31
|
-
injectable: /@provide\s*\(\s*(\w+)?\s*\)/gi,
|
|
32
|
-
// Match middleware decorator
|
|
33
|
-
middleware: /@middleware\s*\(/gi,
|
|
34
|
-
// Match scope decorator
|
|
35
|
-
scope: /@scope\s*\(\s*(\w+)\s*\)/gi,
|
|
36
|
-
};
|
|
8
|
+
// NOTE: HTTP method decorators, controller decorators, and the
|
|
9
|
+
// `extends ExpressoMiddleware` pattern are matched inline below — the
|
|
10
|
+
// scanner uses balanced-paren walks rather than fragile capture-group
|
|
11
|
+
// regexes so it can handle decorator arguments like
|
|
12
|
+
// `@Get('/x', AuthMw, RoleGuard("admin"))`.
|
|
37
13
|
/**
|
|
38
14
|
* Type names that look like dependencies syntactically but aren't ones we
|
|
39
15
|
* want to plot on the architecture graph. Lowercased for cheap comparison.
|
|
@@ -105,24 +81,322 @@ function findConstructorParams(content) {
|
|
|
105
81
|
* - multi-modifier combos: `@inject(SYM) private readonly foo: Foo`
|
|
106
82
|
* - multiple params separated by commas
|
|
107
83
|
*
|
|
84
|
+
* For each parameter we return the most useful identifier for the
|
|
85
|
+
* architecture graph — the `@inject(TOKEN)` argument when present (the
|
|
86
|
+
* actual DI binding key, which always has a matching provider node),
|
|
87
|
+
* otherwise the constructor parameter type. This restores edges that
|
|
88
|
+
* previously dangled when controllers depended on an interface (e.g.
|
|
89
|
+
* `IUserService`) bound to a concrete class (`UserService`).
|
|
90
|
+
*
|
|
108
91
|
* The previous implementation captured the access modifier (`private`)
|
|
109
92
|
* instead of the type, so the architecture graph never drew a dependency
|
|
110
93
|
* edge from a controller to its use case.
|
|
111
94
|
*/
|
|
95
|
+
/**
|
|
96
|
+
* Generate a JSON-friendly placeholder for a TypeScript type literal.
|
|
97
|
+
* Best-effort: union types collapse to the first arm, generics fall back
|
|
98
|
+
* to `null`, arrays become `[]`. The result is meant to seed an HTTP
|
|
99
|
+
* client form, not pass strict validation.
|
|
100
|
+
*/
|
|
101
|
+
function sampleForType(typeName) {
|
|
102
|
+
const t = typeName.trim();
|
|
103
|
+
// Strip wrapping `()`s and trailing whitespace.
|
|
104
|
+
const head = t.replace(/^\(+|\)+$/g, '').trim();
|
|
105
|
+
// Array types: `Foo[]` or `Array<Foo>`.
|
|
106
|
+
if (/\[\]$/.test(head) || /^Array\s*</i.test(head)) {
|
|
107
|
+
return [];
|
|
108
|
+
}
|
|
109
|
+
// Union — pick the first non-null/undefined arm.
|
|
110
|
+
if (head.includes('|')) {
|
|
111
|
+
const arms = head
|
|
112
|
+
.split('|')
|
|
113
|
+
.map((a) => a.trim())
|
|
114
|
+
.filter((a) => a && a !== 'null' && a !== 'undefined');
|
|
115
|
+
if (arms.length > 0)
|
|
116
|
+
return sampleForType(arms[0]);
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
// Strip generics: `Promise<Foo>` → `Promise`. Unhandled below ⇒ null.
|
|
120
|
+
const bare = head.replace(/<.*$/, '').toLowerCase();
|
|
121
|
+
switch (bare) {
|
|
122
|
+
case 'string':
|
|
123
|
+
return '';
|
|
124
|
+
case 'number':
|
|
125
|
+
case 'bigint':
|
|
126
|
+
return 0;
|
|
127
|
+
case 'boolean':
|
|
128
|
+
return false;
|
|
129
|
+
case 'date':
|
|
130
|
+
return new Date(0).toISOString();
|
|
131
|
+
case 'object':
|
|
132
|
+
case 'record':
|
|
133
|
+
return {};
|
|
134
|
+
case 'any':
|
|
135
|
+
case 'unknown':
|
|
136
|
+
return null;
|
|
137
|
+
default:
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Starting at `fromIndex` in `content`, walk forward to the next method
|
|
143
|
+
* declaration and return its name + the *balanced* parameter list.
|
|
144
|
+
*
|
|
145
|
+
* The previous implementation used a simple `\(([^)]*)\)` regex which
|
|
146
|
+
* stops at the first `)` it sees — fatal for ExpressoTS controllers
|
|
147
|
+
* because parameter decorators like `@body()` have their own parens.
|
|
148
|
+
* That bug caused every route whose handler accepted `@body() / @param() /
|
|
149
|
+
* @query() / @inject()` to silently disappear from the static scan,
|
|
150
|
+
* which then forced the runtime scanner to fall back to
|
|
151
|
+
* `controller: 'Unknown'` (the "Other" group in the Studio API client).
|
|
152
|
+
*
|
|
153
|
+
* Returns `null` when no method signature is found.
|
|
154
|
+
*/
|
|
155
|
+
function findNextMethodSignature(content, fromIndex) {
|
|
156
|
+
// A method signature looks like:
|
|
157
|
+
// <visibility?> <static?> <async?> name ( … ) <return-type?> {
|
|
158
|
+
// We anchor on `name(` — once located we use balanced paren matching to
|
|
159
|
+
// capture the parameter list, then verify a `{` follows (to skip
|
|
160
|
+
// forward-declared arrow types or call expressions).
|
|
161
|
+
const HEAD = /(?:public\s+|private\s+|protected\s+)?(?:static\s+)?(?:async\s+)?([A-Za-z_$][\w$]*)\s*\(/g;
|
|
162
|
+
HEAD.lastIndex = fromIndex;
|
|
163
|
+
let match;
|
|
164
|
+
while ((match = HEAD.exec(content)) !== null) {
|
|
165
|
+
const name = match[1];
|
|
166
|
+
// Skip control-flow keywords that look like method names.
|
|
167
|
+
if (name === 'if' ||
|
|
168
|
+
name === 'for' ||
|
|
169
|
+
name === 'while' ||
|
|
170
|
+
name === 'switch' ||
|
|
171
|
+
name === 'catch' ||
|
|
172
|
+
name === 'return') {
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
const openParen = match.index + match[0].length - 1; // points at '('
|
|
176
|
+
let depth = 1;
|
|
177
|
+
let i = openParen + 1;
|
|
178
|
+
for (; i < content.length && depth > 0; i++) {
|
|
179
|
+
const ch = content[i];
|
|
180
|
+
if (ch === '(')
|
|
181
|
+
depth++;
|
|
182
|
+
else if (ch === ')')
|
|
183
|
+
depth--;
|
|
184
|
+
}
|
|
185
|
+
if (depth !== 0)
|
|
186
|
+
return null; // unbalanced — give up
|
|
187
|
+
const closeParen = i - 1;
|
|
188
|
+
const params = content.slice(openParen + 1, closeParen);
|
|
189
|
+
// Walk past the optional return-type annotation up to the first
|
|
190
|
+
// non-whitespace character after the `)`. Accept `{` as a method
|
|
191
|
+
// body (skip ahead) and `=>` to also detect arrow assignments
|
|
192
|
+
// (uncommon in controllers but harmless to allow).
|
|
193
|
+
let j = closeParen + 1;
|
|
194
|
+
while (j < content.length && /\s/.test(content[j]))
|
|
195
|
+
j++;
|
|
196
|
+
if (content[j] === ':') {
|
|
197
|
+
// Skip `: ReturnType`
|
|
198
|
+
while (j < content.length && content[j] !== '{' && content[j] !== ';' && content[j] !== '\n') {
|
|
199
|
+
j++;
|
|
200
|
+
}
|
|
201
|
+
while (j < content.length && /\s/.test(content[j]))
|
|
202
|
+
j++;
|
|
203
|
+
}
|
|
204
|
+
if (content[j] === '{') {
|
|
205
|
+
return { name, params, absoluteEndIndex: j };
|
|
206
|
+
}
|
|
207
|
+
// Not a method body — keep scanning.
|
|
208
|
+
}
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Extract dependency identifiers from class-level field injection.
|
|
213
|
+
*
|
|
214
|
+
* ExpressoTS supports two equivalent DI patterns:
|
|
215
|
+
*
|
|
216
|
+
* 1. Constructor injection: `constructor(private foo: Foo) {}`
|
|
217
|
+
* 2. Field injection: `@inject(Foo) private foo: Foo;`
|
|
218
|
+
*
|
|
219
|
+
* (1) is handled by `findConstructorParams` + `extractParamTypes`. (2)
|
|
220
|
+
* needs its own scanner because the `@inject(TOKEN)` decorator sits on
|
|
221
|
+
* a *property* declaration and therefore never appears inside the
|
|
222
|
+
* constructor parameter list. Without this extractor, the architecture
|
|
223
|
+
* map showed orphan controllers / services for any project that picked
|
|
224
|
+
* field injection (the default in the CLI's generated useCase template).
|
|
225
|
+
*
|
|
226
|
+
* Returns the most useful identifier per field — the `@inject(TOKEN)`
|
|
227
|
+
* argument (which is always a real DI binding key) and falls back to
|
|
228
|
+
* the declared type when the token is missing.
|
|
229
|
+
*/
|
|
230
|
+
function extractFieldInjectionTypes(classBody) {
|
|
231
|
+
const out = [];
|
|
232
|
+
const seen = new Set();
|
|
233
|
+
// `@inject(TOKEN) <visibility?> <readonly?> <name><?> : <Type>;|=`
|
|
234
|
+
// Token is captured greedily to support `Symbol.for(...)` and
|
|
235
|
+
// `SYMBOL_TYPES.Foo` style identifiers.
|
|
236
|
+
const FIELD_INJECT_RE = /@inject\s*\(\s*([\w.$]+)\s*\)\s*(?:public\s+|private\s+|protected\s+)?(?:readonly\s+)?\w+\s*\??\s*:\s*([A-Za-z_$][\w.$]*)/g;
|
|
237
|
+
let match;
|
|
238
|
+
while ((match = FIELD_INJECT_RE.exec(classBody)) !== null) {
|
|
239
|
+
const injectToken = match[1];
|
|
240
|
+
const typeName = match[2];
|
|
241
|
+
const candidate = injectToken || typeName;
|
|
242
|
+
if (!candidate)
|
|
243
|
+
continue;
|
|
244
|
+
const head = candidate.split('.').pop() || candidate;
|
|
245
|
+
if (PRIMITIVE_TYPES.has(head.toLowerCase()))
|
|
246
|
+
continue;
|
|
247
|
+
if (seen.has(head))
|
|
248
|
+
continue;
|
|
249
|
+
seen.add(head);
|
|
250
|
+
out.push(head);
|
|
251
|
+
}
|
|
252
|
+
return out;
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Capture the full balanced argument list of a decorator call.
|
|
256
|
+
*
|
|
257
|
+
* Given `content` and the index of the opening `(` (immediately after
|
|
258
|
+
* the decorator name), returns the substring between `(` and the
|
|
259
|
+
* matching `)`. Handles strings, template literals, and nested call
|
|
260
|
+
* expressions so decorators like
|
|
261
|
+
*
|
|
262
|
+
* @controller("/x", isAuthenticated, RoleGuard("admin"))
|
|
263
|
+
*
|
|
264
|
+
* round-trip cleanly. Returns `null` when the parens are unbalanced.
|
|
265
|
+
*/
|
|
266
|
+
function extractBalancedArgs(content, openParenIdx) {
|
|
267
|
+
if (content[openParenIdx] !== '(')
|
|
268
|
+
return null;
|
|
269
|
+
let depth = 1;
|
|
270
|
+
let i = openParenIdx + 1;
|
|
271
|
+
while (i < content.length && depth > 0) {
|
|
272
|
+
const ch = content[i];
|
|
273
|
+
if (ch === '"' || ch === "'" || ch === '`') {
|
|
274
|
+
const close = ch;
|
|
275
|
+
i++;
|
|
276
|
+
while (i < content.length) {
|
|
277
|
+
if (content[i] === '\\') {
|
|
278
|
+
i += 2;
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
if (content[i] === close) {
|
|
282
|
+
i++;
|
|
283
|
+
break;
|
|
284
|
+
}
|
|
285
|
+
i++;
|
|
286
|
+
}
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
if (ch === '(')
|
|
290
|
+
depth++;
|
|
291
|
+
else if (ch === ')')
|
|
292
|
+
depth--;
|
|
293
|
+
i++;
|
|
294
|
+
}
|
|
295
|
+
if (depth !== 0)
|
|
296
|
+
return null;
|
|
297
|
+
return content.slice(openParenIdx + 1, i - 1);
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Split a decorator argument list on top-level commas, respecting
|
|
301
|
+
* strings and bracket / paren nesting. Used to peel off the path arg
|
|
302
|
+
* (always the first entry) from the trailing middleware identifiers.
|
|
303
|
+
*/
|
|
304
|
+
function splitTopLevelArgs(args) {
|
|
305
|
+
const out = [];
|
|
306
|
+
let buf = '';
|
|
307
|
+
let paren = 0;
|
|
308
|
+
let bracket = 0;
|
|
309
|
+
let brace = 0;
|
|
310
|
+
for (let i = 0; i < args.length; i++) {
|
|
311
|
+
const ch = args[i];
|
|
312
|
+
if (ch === '"' || ch === "'" || ch === '`') {
|
|
313
|
+
const close = ch;
|
|
314
|
+
buf += ch;
|
|
315
|
+
i++;
|
|
316
|
+
while (i < args.length) {
|
|
317
|
+
buf += args[i];
|
|
318
|
+
if (args[i] === '\\' && i + 1 < args.length) {
|
|
319
|
+
buf += args[i + 1];
|
|
320
|
+
i += 2;
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
if (args[i] === close)
|
|
324
|
+
break;
|
|
325
|
+
i++;
|
|
326
|
+
}
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
if (ch === '(')
|
|
330
|
+
paren++;
|
|
331
|
+
else if (ch === ')')
|
|
332
|
+
paren--;
|
|
333
|
+
else if (ch === '[')
|
|
334
|
+
bracket++;
|
|
335
|
+
else if (ch === ']')
|
|
336
|
+
bracket--;
|
|
337
|
+
else if (ch === '{')
|
|
338
|
+
brace++;
|
|
339
|
+
else if (ch === '}')
|
|
340
|
+
brace--;
|
|
341
|
+
if (ch === ',' && paren === 0 && bracket === 0 && brace === 0) {
|
|
342
|
+
out.push(buf.trim());
|
|
343
|
+
buf = '';
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
buf += ch;
|
|
347
|
+
}
|
|
348
|
+
const tail = buf.trim();
|
|
349
|
+
if (tail.length > 0)
|
|
350
|
+
out.push(tail);
|
|
351
|
+
return out;
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Extract identifier references from a decorator argument that is
|
|
355
|
+
* supposed to be a middleware reference. Accepts plain identifiers
|
|
356
|
+
* (`MyMiddleware`), dotted access (`Mw.User`), and call expressions
|
|
357
|
+
* (`RoleGuard("admin")`) — the latter still resolves to a single
|
|
358
|
+
* identifier ("RoleGuard"). Returns `null` when the arg is not an
|
|
359
|
+
* identifier reference (e.g. a string token, an arrow function, an
|
|
360
|
+
* object literal). Those forms are real but can't be plotted as an
|
|
361
|
+
* architecture node from the static scan alone.
|
|
362
|
+
*/
|
|
363
|
+
function middlewareIdentifierFromArg(arg) {
|
|
364
|
+
const trimmed = arg.trim();
|
|
365
|
+
if (!trimmed)
|
|
366
|
+
return null;
|
|
367
|
+
// Strip a trailing `(...)` so `RoleGuard("admin")` → `RoleGuard`.
|
|
368
|
+
const match = trimmed.match(/^([A-Za-z_$][\w.$]*)/);
|
|
369
|
+
if (!match)
|
|
370
|
+
return null;
|
|
371
|
+
const head = match[1].split('.').pop();
|
|
372
|
+
if (!head)
|
|
373
|
+
return null;
|
|
374
|
+
// Skip JS reserved words and the path-style identifiers that aren't
|
|
375
|
+
// class references (e.g. `'/path'` would already have failed the
|
|
376
|
+
// regex; this catches `null`, `undefined`, `true`, `false`).
|
|
377
|
+
if (head === 'null' || head === 'undefined' || head === 'true' || head === 'false') {
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
return head;
|
|
381
|
+
}
|
|
112
382
|
function extractParamTypes(params) {
|
|
113
383
|
const out = [];
|
|
114
384
|
// Anchor each iteration to consume exactly one parameter:
|
|
115
385
|
// 1) Optional `@decorator(...)` prefix(es) — args may contain commas
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
|
|
386
|
+
// Capture group 1 isolates the *first* @inject(...) token so we
|
|
387
|
+
// can prefer it over the declared type when both are present.
|
|
388
|
+
// 2) Optional access modifier (and `readonly`)
|
|
389
|
+
// 3) Parameter name (non-capturing, we don't need it)
|
|
390
|
+
// 4) `:` then the captured Type (allow `Foo`, `Ns.Foo`, `Foo<...>`)
|
|
391
|
+
// 5) `?:` is allowed to support optional injection
|
|
392
|
+
const PARAM_RE = /(?:@\w+\s*\(\s*([\w.$]+)?\s*\)\s*)*(?:(?:public|private|protected)\s+)?(?:readonly\s+)?\w+\s*\??\s*:\s*([A-Za-z_$][\w.$]*)/g;
|
|
121
393
|
for (const match of params.matchAll(PARAM_RE)) {
|
|
394
|
+
const injectToken = match[1];
|
|
122
395
|
const typeName = match[2];
|
|
123
|
-
|
|
396
|
+
const candidate = injectToken || typeName;
|
|
397
|
+
if (!candidate)
|
|
124
398
|
continue;
|
|
125
|
-
const head =
|
|
399
|
+
const head = candidate.split('.').pop() || candidate;
|
|
126
400
|
if (PRIMITIVE_TYPES.has(head.toLowerCase()))
|
|
127
401
|
continue;
|
|
128
402
|
out.push(head);
|
|
@@ -136,6 +410,44 @@ export class RouteScanner {
|
|
|
136
410
|
providers = [];
|
|
137
411
|
middleware = [];
|
|
138
412
|
dependencies = [];
|
|
413
|
+
/**
|
|
414
|
+
* Class names of middleware discovered statically. Used to:
|
|
415
|
+
* 1. Suppress the same class from being added to `services[]`
|
|
416
|
+
* (the legacy heuristic promoted any `@provide`-decorated class).
|
|
417
|
+
* 2. Resolve middleware identifiers parsed out of `@controller`/
|
|
418
|
+
* `@Get` arguments to the matching scanner record.
|
|
419
|
+
*/
|
|
420
|
+
middlewareClassNames = new Set();
|
|
421
|
+
/**
|
|
422
|
+
* Bindings extracted from `@controller(path, ...mw)` and route
|
|
423
|
+
* decorators (`@Get(path, ...mw)`). Each entry is the source of a
|
|
424
|
+
* future `middleware → controller` edge.
|
|
425
|
+
*/
|
|
426
|
+
staticMiddlewareBindings = [];
|
|
427
|
+
/**
|
|
428
|
+
* Interface → implementation lookup populated from `class X implements IY`.
|
|
429
|
+
* Used by `buildDependencyGraph` to redirect edges that target an
|
|
430
|
+
* interface name (e.g. `IUserService`) to the concrete class node
|
|
431
|
+
* (`UserService`). Without this fallback the architecture map shows
|
|
432
|
+
* an orphan source node and a dangling edge for every interface-typed
|
|
433
|
+
* constructor parameter, which is the default ExpressoTS pattern.
|
|
434
|
+
*/
|
|
435
|
+
implementsMap = new Map();
|
|
436
|
+
/**
|
|
437
|
+
* Discovered `CreateModule(...)` declarations. Two-pass: we collect
|
|
438
|
+
* raw items per module first, then expand nested module references
|
|
439
|
+
* into their concrete class members so the UI can group nodes by
|
|
440
|
+
* leaf module without re-doing the resolution.
|
|
441
|
+
*/
|
|
442
|
+
modules = [];
|
|
443
|
+
moduleRawItems = new Map();
|
|
444
|
+
/**
|
|
445
|
+
* DTO class name → sample JSON body (e.g. `{ name: "", age: 0 }`).
|
|
446
|
+
* Built from class field declarations during the file pre-pass and
|
|
447
|
+
* consumed when a controller method has an `@Body()` parameter so the
|
|
448
|
+
* Studio API client can auto-fill a working request body.
|
|
449
|
+
*/
|
|
450
|
+
dtoSamples = new Map();
|
|
139
451
|
constructor(srcPath = './src') {
|
|
140
452
|
this.srcPath = path.resolve(srcPath);
|
|
141
453
|
}
|
|
@@ -146,23 +458,112 @@ export class RouteScanner {
|
|
|
146
458
|
this.services = [];
|
|
147
459
|
this.providers = [];
|
|
148
460
|
this.middleware = [];
|
|
461
|
+
this.middlewareClassNames.clear();
|
|
462
|
+
this.staticMiddlewareBindings = [];
|
|
149
463
|
this.dependencies = [];
|
|
464
|
+
this.implementsMap.clear();
|
|
465
|
+
this.dtoSamples.clear();
|
|
466
|
+
this.modules = [];
|
|
467
|
+
this.moduleRawItems.clear();
|
|
150
468
|
// Find all TypeScript files
|
|
151
469
|
const files = await this.findTypeScriptFiles();
|
|
470
|
+
// First pass: harvest middleware class names so the main parse can
|
|
471
|
+
// suppress them from the generic `@provide` → service bucket. This
|
|
472
|
+
// matters because middleware classes are typically also decorated
|
|
473
|
+
// with `@provide(...)` so they can be DI-resolved when added via
|
|
474
|
+
// `Middleware.add(new MyMiddleware())`. Without this pre-pass the
|
|
475
|
+
// architecture map would render every middleware as a "Service"
|
|
476
|
+
// node and then flag it as orphan because nothing `@inject`s it.
|
|
477
|
+
for (const file of files) {
|
|
478
|
+
this.preScanForMiddlewareClasses(file);
|
|
479
|
+
}
|
|
152
480
|
// Parse each file
|
|
153
481
|
for (const file of files) {
|
|
154
482
|
await this.parseFile(file);
|
|
155
483
|
}
|
|
156
484
|
// Build dependency graph
|
|
157
485
|
this.buildDependencyGraph();
|
|
486
|
+
// Resolve nested module references after every file has been parsed
|
|
487
|
+
// (modules can reference other modules declared in different files).
|
|
488
|
+
this.resolveModules();
|
|
158
489
|
return {
|
|
159
490
|
controllers: this.controllers,
|
|
160
491
|
services: this.services,
|
|
161
492
|
providers: this.providers,
|
|
162
493
|
middleware: this.middleware,
|
|
163
494
|
dependencies: this.dependencies,
|
|
495
|
+
modules: this.modules,
|
|
164
496
|
};
|
|
165
497
|
}
|
|
498
|
+
/**
|
|
499
|
+
* Cheap regex scan to find every class that extends `ExpressoMiddleware`.
|
|
500
|
+
* Records the class name and creates a `MiddlewareInfo` placeholder
|
|
501
|
+
* with `scope: 'unknown'`. The scope is upgraded later by
|
|
502
|
+
* `buildDependencyGraph()` (when the class shows up in a controller
|
|
503
|
+
* decorator) and by the agent's runtime merge step (for global
|
|
504
|
+
* middleware added via `Middleware.add()`).
|
|
505
|
+
*/
|
|
506
|
+
preScanForMiddlewareClasses(filePath) {
|
|
507
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
508
|
+
const re = /class\s+(\w+)(?:\s*<[^>]*>)?\s+extends\s+ExpressoMiddleware\b/g;
|
|
509
|
+
let match;
|
|
510
|
+
while ((match = re.exec(content)) !== null) {
|
|
511
|
+
const name = match[1];
|
|
512
|
+
if (this.middlewareClassNames.has(name))
|
|
513
|
+
continue;
|
|
514
|
+
this.middlewareClassNames.add(name);
|
|
515
|
+
this.middleware.push({
|
|
516
|
+
name,
|
|
517
|
+
filePath,
|
|
518
|
+
dependencies: [],
|
|
519
|
+
methods: [],
|
|
520
|
+
scope: 'unknown',
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* Recursively expand `moduleRawItems` into concrete class members.
|
|
526
|
+
* A module item that references another module name is replaced with
|
|
527
|
+
* that module's flattened members. Cycles are guarded with a visited
|
|
528
|
+
* set so a self-referential module never blows the stack.
|
|
529
|
+
*/
|
|
530
|
+
resolveModules() {
|
|
531
|
+
const moduleNames = new Set(this.moduleRawItems.keys());
|
|
532
|
+
const memo = new Map();
|
|
533
|
+
const expand = (moduleName, visiting) => {
|
|
534
|
+
if (memo.has(moduleName))
|
|
535
|
+
return memo.get(moduleName);
|
|
536
|
+
if (visiting.has(moduleName))
|
|
537
|
+
return []; // cycle
|
|
538
|
+
visiting.add(moduleName);
|
|
539
|
+
const raw = this.moduleRawItems.get(moduleName);
|
|
540
|
+
if (!raw) {
|
|
541
|
+
visiting.delete(moduleName);
|
|
542
|
+
return [];
|
|
543
|
+
}
|
|
544
|
+
const out = new Set();
|
|
545
|
+
for (const item of raw.items) {
|
|
546
|
+
if (moduleNames.has(item)) {
|
|
547
|
+
for (const sub of expand(item, visiting))
|
|
548
|
+
out.add(sub);
|
|
549
|
+
}
|
|
550
|
+
else {
|
|
551
|
+
out.add(item);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
visiting.delete(moduleName);
|
|
555
|
+
const result = [...out];
|
|
556
|
+
memo.set(moduleName, result);
|
|
557
|
+
return result;
|
|
558
|
+
};
|
|
559
|
+
for (const [name, raw] of this.moduleRawItems) {
|
|
560
|
+
this.modules.push({
|
|
561
|
+
name,
|
|
562
|
+
filePath: raw.filePath,
|
|
563
|
+
members: expand(name, new Set()),
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
}
|
|
166
567
|
/** Get all discovered routes */
|
|
167
568
|
getRoutes() {
|
|
168
569
|
return this.controllers.flatMap((c) => c.routes);
|
|
@@ -183,6 +584,88 @@ export class RouteScanner {
|
|
|
183
584
|
async parseFile(filePath) {
|
|
184
585
|
const content = fs.readFileSync(filePath, 'utf-8');
|
|
185
586
|
const lines = content.split('\n');
|
|
587
|
+
// Record every `class X implements IY[, IZ]` so the dependency graph
|
|
588
|
+
// can redirect interface-typed edges to the concrete class node.
|
|
589
|
+
// We do this regardless of whether the class is a controller, service
|
|
590
|
+
// or provider — interfaces can be implemented anywhere.
|
|
591
|
+
const implementsRe = /class\s+(\w+)(?:\s+extends\s+[\w.<>,\s]+)?\s+implements\s+([\w.<>,\s]+?)\s*\{/g;
|
|
592
|
+
let implMatch;
|
|
593
|
+
while ((implMatch = implementsRe.exec(content)) !== null) {
|
|
594
|
+
const className = implMatch[1];
|
|
595
|
+
const ifaces = implMatch[2]
|
|
596
|
+
.split(',')
|
|
597
|
+
.map((s) => s.trim().replace(/<.*$/, '').split('.').pop())
|
|
598
|
+
.filter(Boolean);
|
|
599
|
+
for (const iface of ifaces) {
|
|
600
|
+
// Only register if not already mapped — first hit wins, which
|
|
601
|
+
// matches the typical "one impl per interface" convention.
|
|
602
|
+
if (!this.implementsMap.has(iface)) {
|
|
603
|
+
this.implementsMap.set(iface, className);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
// Build DTO samples for every class / interface / type-alias whose
|
|
608
|
+
// name matches the convention (`*Dto`, `*Request`, `*Payload`,
|
|
609
|
+
// `*Input`, `*Body`, including upper-case `*DTO` etc). We register
|
|
610
|
+
// each sample under its declared name *and* — when the name starts
|
|
611
|
+
// with `I` (the interface-prefix convention) — under the
|
|
612
|
+
// I-stripped name too, so a route typed `@body() x: IFooDto` finds
|
|
613
|
+
// the sample built from `interface IFooDto` or class `FooDto`.
|
|
614
|
+
const isDtoLike = (n) => /(Dto|Request|Payload|Input|Body)$/i.test(n);
|
|
615
|
+
const registerSample = (declaredName, sample) => {
|
|
616
|
+
if (Object.keys(sample).length === 0)
|
|
617
|
+
return;
|
|
618
|
+
this.dtoSamples.set(declaredName, sample);
|
|
619
|
+
if (declaredName.startsWith('I') && declaredName.length > 1) {
|
|
620
|
+
const stripped = declaredName.slice(1);
|
|
621
|
+
if (!this.dtoSamples.has(stripped)) {
|
|
622
|
+
this.dtoSamples.set(stripped, sample);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
};
|
|
626
|
+
const fieldRe = /^[ \t]*(?:public\s+|private\s+|protected\s+)?(?:readonly\s+)?(\w+)\s*\??\s*:\s*([A-Za-z_$][\w.<>[\]|\s'"]*?)\s*[;=,]/gm;
|
|
627
|
+
const buildSampleFromBody = (body) => {
|
|
628
|
+
const sample = {};
|
|
629
|
+
fieldRe.lastIndex = 0;
|
|
630
|
+
let fieldMatch;
|
|
631
|
+
while ((fieldMatch = fieldRe.exec(body)) !== null) {
|
|
632
|
+
const fieldName = fieldMatch[1];
|
|
633
|
+
const typeName = fieldMatch[2];
|
|
634
|
+
if (fieldName === 'constructor')
|
|
635
|
+
continue;
|
|
636
|
+
sample[fieldName] = sampleForType(typeName);
|
|
637
|
+
}
|
|
638
|
+
return sample;
|
|
639
|
+
};
|
|
640
|
+
// (a) Classes — same shape as before but with a permissive header.
|
|
641
|
+
const classRe = /class\s+(\w+)(?:\s+extends\s+[\w.<>,\s]+)?(?:\s+implements\s+[\w.<>,\s]+)?\s*\{([\s\S]*?)\n\}/g;
|
|
642
|
+
let classMatch;
|
|
643
|
+
while ((classMatch = classRe.exec(content)) !== null) {
|
|
644
|
+
const declaredName = classMatch[1];
|
|
645
|
+
if (!isDtoLike(declaredName))
|
|
646
|
+
continue;
|
|
647
|
+
registerSample(declaredName, buildSampleFromBody(classMatch[2]));
|
|
648
|
+
}
|
|
649
|
+
// (b) Interfaces — `interface IUserCreateRequestDTO { … }` with
|
|
650
|
+
// optional `extends Foo, Bar`.
|
|
651
|
+
const interfaceRe = /interface\s+(\w+)(?:\s+extends\s+[\w.<>,\s]+)?\s*\{([\s\S]*?)\n\}/g;
|
|
652
|
+
let interfaceMatch;
|
|
653
|
+
while ((interfaceMatch = interfaceRe.exec(content)) !== null) {
|
|
654
|
+
const declaredName = interfaceMatch[1];
|
|
655
|
+
if (!isDtoLike(declaredName))
|
|
656
|
+
continue;
|
|
657
|
+
registerSample(declaredName, buildSampleFromBody(interfaceMatch[2]));
|
|
658
|
+
}
|
|
659
|
+
// (c) Type aliases — `type FooDto = { … }`. Generic + intersection
|
|
660
|
+
// forms are out of scope; we only care about the inline-object case.
|
|
661
|
+
const typeAliasRe = /type\s+(\w+)\s*=\s*\{([\s\S]*?)\n\}/g;
|
|
662
|
+
let typeAliasMatch;
|
|
663
|
+
while ((typeAliasMatch = typeAliasRe.exec(content)) !== null) {
|
|
664
|
+
const declaredName = typeAliasMatch[1];
|
|
665
|
+
if (!isDtoLike(declaredName))
|
|
666
|
+
continue;
|
|
667
|
+
registerSample(declaredName, buildSampleFromBody(typeAliasMatch[2]));
|
|
668
|
+
}
|
|
186
669
|
// Check if this is a controller
|
|
187
670
|
const controllerMatch = content.match(/@controller\s*\(\s*['"`]([^'"`]+)['"`]/i);
|
|
188
671
|
if (controllerMatch) {
|
|
@@ -192,27 +675,102 @@ export class RouteScanner {
|
|
|
192
675
|
this.controllers.push(controller);
|
|
193
676
|
}
|
|
194
677
|
}
|
|
195
|
-
// Check if this is a service/provider
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
678
|
+
// Check if this is a service/provider. We require the file to
|
|
679
|
+
// contain at least one `@provide(<Identifier>) ... class <X>` pair
|
|
680
|
+
// so that arbitrary uses of `provide` (e.g. methods named `provide`,
|
|
681
|
+
// doc-comments, mock objects in tests) don't get promoted to ghost
|
|
682
|
+
// services. The pair is matched with a lookahead that allows the
|
|
683
|
+
// typical `@provide(Foo) @scope(...) export class Foo` formatting.
|
|
684
|
+
//
|
|
685
|
+
// Classes we already classified as middleware (in the pre-pass) are
|
|
686
|
+
// skipped so they don't end up as both a "Service" and a
|
|
687
|
+
// "Middleware" node — `Middleware.add(new MyMw())` requires the
|
|
688
|
+
// class to be DI-bound, so middleware is almost always also
|
|
689
|
+
// `@provide`-decorated.
|
|
690
|
+
const provideClassRe = /@provide\s*\(\s*([\w.$]+)?\s*\)[\s\S]{0,400}?\bclass\s+(\w+)/g;
|
|
691
|
+
const provideMatches = [...content.matchAll(provideClassRe)];
|
|
692
|
+
for (const match of provideMatches) {
|
|
693
|
+
const declaredClass = match[2];
|
|
694
|
+
if (this.middlewareClassNames.has(declaredClass)) {
|
|
695
|
+
// Hydrate the middleware record with constructor / field
|
|
696
|
+
// dependencies and method names. We piggy-back on
|
|
697
|
+
// `parseService` because the extraction logic is identical
|
|
698
|
+
// (DI deps come from the same decorators).
|
|
699
|
+
const enriched = this.parseService(content, filePath, declaredClass);
|
|
700
|
+
if (enriched) {
|
|
701
|
+
const target = this.middleware.find((m) => m.name === declaredClass);
|
|
702
|
+
if (target) {
|
|
703
|
+
target.dependencies = enriched.dependencies;
|
|
704
|
+
target.methods = enriched.methods;
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
continue;
|
|
708
|
+
}
|
|
709
|
+
const service = this.parseService(content, filePath, declaredClass);
|
|
710
|
+
if (!service)
|
|
711
|
+
continue;
|
|
712
|
+
// Determine if it's a service or provider based on naming
|
|
713
|
+
if (filePath.includes('provider') ||
|
|
714
|
+
service.name.toLowerCase().includes('provider')) {
|
|
715
|
+
this.providers.push(service);
|
|
716
|
+
}
|
|
717
|
+
else {
|
|
718
|
+
this.services.push(service);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
// Detect `export const Foo = CreateModule([Bar, Baz])`. Anonymous
|
|
722
|
+
// inline modules (e.g. inside `configContainer([CreateModule([...])])`)
|
|
723
|
+
// are skipped — only named exports get a UI grouping. We capture the
|
|
724
|
+
// bracket contents with a balanced-bracket walk so multi-line arrays
|
|
725
|
+
// and nested `CreateModule(...)` calls aren't truncated.
|
|
726
|
+
const moduleHeadRe = /(?:export\s+)?const\s+(\w+)\s*(?::\s*[\w<>,\s]+)?\s*=\s*CreateModule\s*\(\s*\[/g;
|
|
727
|
+
let mhMatch;
|
|
728
|
+
while ((mhMatch = moduleHeadRe.exec(content)) !== null) {
|
|
729
|
+
const moduleName = mhMatch[1];
|
|
730
|
+
const arrayStart = mhMatch.index + mhMatch[0].length;
|
|
731
|
+
let depth = 1;
|
|
732
|
+
let i = arrayStart;
|
|
733
|
+
for (; i < content.length && depth > 0; i++) {
|
|
734
|
+
const ch = content[i];
|
|
735
|
+
if (ch === '[')
|
|
736
|
+
depth++;
|
|
737
|
+
else if (ch === ']')
|
|
738
|
+
depth--;
|
|
739
|
+
}
|
|
740
|
+
if (depth !== 0)
|
|
741
|
+
continue; // malformed — skip
|
|
742
|
+
const arrayBody = content.slice(arrayStart, i - 1);
|
|
743
|
+
// Split on commas at depth 0 (so `CreateModule([…])` nested
|
|
744
|
+
// expressions stay intact). Then trim whitespace / trailing
|
|
745
|
+
// commas, and accept identifiers only.
|
|
746
|
+
const items = [];
|
|
747
|
+
let bracketDepth = 0;
|
|
748
|
+
let parenDepth = 0;
|
|
749
|
+
let buf = '';
|
|
750
|
+
for (const ch of arrayBody) {
|
|
751
|
+
if (ch === '[' || ch === '{')
|
|
752
|
+
bracketDepth++;
|
|
753
|
+
else if (ch === ']' || ch === '}')
|
|
754
|
+
bracketDepth--;
|
|
755
|
+
else if (ch === '(')
|
|
756
|
+
parenDepth++;
|
|
757
|
+
else if (ch === ')')
|
|
758
|
+
parenDepth--;
|
|
759
|
+
if (ch === ',' && bracketDepth === 0 && parenDepth === 0) {
|
|
760
|
+
const id = buf.trim();
|
|
761
|
+
if (/^[A-Za-z_$][\w.$]*$/.test(id))
|
|
762
|
+
items.push(id.split('.').pop());
|
|
763
|
+
buf = '';
|
|
204
764
|
}
|
|
205
765
|
else {
|
|
206
|
-
|
|
766
|
+
buf += ch;
|
|
207
767
|
}
|
|
208
768
|
}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
if (classMatch) {
|
|
215
|
-
this.middleware.push(classMatch[1]);
|
|
769
|
+
const tail = buf.trim();
|
|
770
|
+
if (/^[A-Za-z_$][\w.$]*$/.test(tail))
|
|
771
|
+
items.push(tail.split('.').pop());
|
|
772
|
+
if (items.length > 0) {
|
|
773
|
+
this.moduleRawItems.set(moduleName, { filePath, items });
|
|
216
774
|
}
|
|
217
775
|
}
|
|
218
776
|
}
|
|
@@ -225,35 +783,121 @@ export class RouteScanner {
|
|
|
225
783
|
const className = classMatch[1];
|
|
226
784
|
const routes = [];
|
|
227
785
|
const dependencies = [];
|
|
228
|
-
// Extract
|
|
229
|
-
//
|
|
230
|
-
//
|
|
231
|
-
|
|
786
|
+
// Extract controller-level middleware from `@controller(path, ...mw)`.
|
|
787
|
+
// Walk the full balanced arg list so call-expression middleware
|
|
788
|
+
// (`RoleGuard("admin")`) and multi-line decorators are handled.
|
|
789
|
+
const ctrlDecoratorIdx = content.search(/@controller\s*\(/i);
|
|
790
|
+
if (ctrlDecoratorIdx >= 0) {
|
|
791
|
+
const openParen = content.indexOf('(', ctrlDecoratorIdx);
|
|
792
|
+
const args = openParen >= 0 ? extractBalancedArgs(content, openParen) : null;
|
|
793
|
+
if (args) {
|
|
794
|
+
const parts = splitTopLevelArgs(args);
|
|
795
|
+
// First entry is always the path string — skip it.
|
|
796
|
+
for (const part of parts.slice(1)) {
|
|
797
|
+
const ident = middlewareIdentifierFromArg(part);
|
|
798
|
+
if (!ident)
|
|
799
|
+
continue;
|
|
800
|
+
this.staticMiddlewareBindings.push({
|
|
801
|
+
middlewareName: ident,
|
|
802
|
+
controllerName: className,
|
|
803
|
+
scope: 'controller',
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
// Extract constructor-style dependencies. Balanced-paren aware so
|
|
809
|
+
// parameter decorators (@inject(MyService)) don't truncate the
|
|
810
|
+
// parameter list at the wrong `)`.
|
|
232
811
|
const params = findConstructorParams(content);
|
|
233
812
|
if (params) {
|
|
234
813
|
dependencies.push(...extractParamTypes(params));
|
|
235
814
|
}
|
|
236
|
-
//
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
815
|
+
// Also extract field-injection dependencies — the default pattern
|
|
816
|
+
// produced by `expressots g controller`. Without this the
|
|
817
|
+
// architecture map shows the controller as an isolated node even
|
|
818
|
+
// though it's wired to a use case via `@inject(UseCase)`.
|
|
819
|
+
const ctrlBodyRe = new RegExp(`class\\s+${className}\\b(?:\\s+extends\\s+[\\w.<>,\\s]+)?(?:\\s+implements\\s+[\\w.<>,\\s]+)?\\s*\\{([\\s\\S]*?)\\n\\}`);
|
|
820
|
+
const ctrlBody = content.match(ctrlBodyRe)?.[1] ?? '';
|
|
821
|
+
if (ctrlBody) {
|
|
822
|
+
for (const dep of extractFieldInjectionTypes(ctrlBody)) {
|
|
823
|
+
if (!dependencies.includes(dep))
|
|
824
|
+
dependencies.push(dep);
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
// Find HTTP method decorators. Each match advances a balanced-paren
|
|
828
|
+
// method-signature scanner so handlers with parameter decorators
|
|
829
|
+
// (`@body()`, `@param()`, `@inject()`, …) are still detected. We
|
|
830
|
+
// ignore the original regex's captured path because it stops at
|
|
831
|
+
// the first `)` — fine for `@Get('/x')` but breaks for
|
|
832
|
+
// `@Get('/x', AuthMw, RoleGuard("admin"))`. Instead we walk the
|
|
833
|
+
// full balanced arg list ourselves and split on top-level commas.
|
|
834
|
+
const httpDecoratorRe = /@(Get|Post|Put|Patch|Delete|Head|Options)\s*\(/gi;
|
|
835
|
+
let match;
|
|
836
|
+
while ((match = httpDecoratorRe.exec(content)) !== null) {
|
|
837
|
+
const method = match[1].toLowerCase();
|
|
838
|
+
const openParen = match.index + match[0].length - 1;
|
|
839
|
+
const args = extractBalancedArgs(content, openParen);
|
|
840
|
+
const parts = args !== null ? splitTopLevelArgs(args) : [];
|
|
841
|
+
// First arg is the path. May be quoted; strip the quotes.
|
|
842
|
+
let routePath = '/';
|
|
843
|
+
if (parts.length > 0) {
|
|
844
|
+
const raw = parts[0].trim();
|
|
845
|
+
const m = raw.match(/^['"`]([^'"`]*)['"`]$/);
|
|
846
|
+
if (m)
|
|
847
|
+
routePath = m[1] || '/';
|
|
848
|
+
}
|
|
849
|
+
const lineNumber = this.getLineNumber(content, match.index, lines);
|
|
850
|
+
const sig = findNextMethodSignature(content, match.index);
|
|
851
|
+
if (!sig)
|
|
852
|
+
continue;
|
|
853
|
+
const route = {
|
|
854
|
+
path: this.normalizePath(basePath, routePath),
|
|
855
|
+
method: method.toUpperCase(),
|
|
856
|
+
controller: className,
|
|
857
|
+
controllerMethod: sig.name,
|
|
858
|
+
filePath,
|
|
859
|
+
lineNumber,
|
|
860
|
+
};
|
|
861
|
+
// Trailing decorator args after the path are middleware refs.
|
|
862
|
+
// We record bindings for every identifier-shaped arg so the
|
|
863
|
+
// architecture map can draw a `middleware → controller` edge
|
|
864
|
+
// labelled with the route. Non-identifier args (string registry
|
|
865
|
+
// names, inline arrow functions) are silently skipped — the
|
|
866
|
+
// adapter's runtime collector picks those up later.
|
|
867
|
+
for (const part of parts.slice(1)) {
|
|
868
|
+
const ident = middlewareIdentifierFromArg(part);
|
|
869
|
+
if (!ident)
|
|
870
|
+
continue;
|
|
871
|
+
this.staticMiddlewareBindings.push({
|
|
872
|
+
middlewareName: ident,
|
|
873
|
+
controllerName: className,
|
|
874
|
+
scope: 'route',
|
|
875
|
+
controllerMethod: sig.name,
|
|
876
|
+
});
|
|
877
|
+
}
|
|
878
|
+
// Detect `@Body() name: DtoType` (case-insensitive — the
|
|
879
|
+
// decorator is exported as both `Body` and `body` from
|
|
880
|
+
// adapter-express). Captured groups:
|
|
881
|
+
// 1 = parameter name (informational)
|
|
882
|
+
// 2 = parameter type (the DTO class / interface name)
|
|
883
|
+
const bodyParamRe = /@body\s*\(\s*\)\s*(\w+)\s*\??\s*:\s*([A-Za-z_$][\w.$]*)/i;
|
|
884
|
+
const bodyMatch = sig.params.match(bodyParamRe);
|
|
885
|
+
if (bodyMatch) {
|
|
886
|
+
const rawDtoName = bodyMatch[2].split('.').pop() || bodyMatch[2];
|
|
887
|
+
route.bodyDto = rawDtoName;
|
|
888
|
+
// Look up the inferred sample. Try the literal name first, then
|
|
889
|
+
// strip a leading `I` (the common interface-prefix convention,
|
|
890
|
+
// e.g. `IUserCreateRequestDTO` → `UserCreateRequestDTO`) so a
|
|
891
|
+
// sample harvested from the implementing class still applies.
|
|
892
|
+
const sample = this.dtoSamples.get(rawDtoName) ??
|
|
893
|
+
(rawDtoName.startsWith('I')
|
|
894
|
+
? this.dtoSamples.get(rawDtoName.slice(1))
|
|
895
|
+
: undefined);
|
|
896
|
+
if (sample) {
|
|
897
|
+
route.bodySample = sample;
|
|
255
898
|
}
|
|
256
899
|
}
|
|
900
|
+
routes.push(route);
|
|
257
901
|
}
|
|
258
902
|
return {
|
|
259
903
|
name: className,
|
|
@@ -262,12 +906,31 @@ export class RouteScanner {
|
|
|
262
906
|
dependencies,
|
|
263
907
|
};
|
|
264
908
|
}
|
|
265
|
-
/**
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
909
|
+
/**
|
|
910
|
+
* Parse service from file content.
|
|
911
|
+
*
|
|
912
|
+
* `declaredClass` is the class name that the caller already verified
|
|
913
|
+
* is paired with an `@provide()` decorator. We use it instead of the
|
|
914
|
+
* first `class \w+` match to avoid mis-tagging unrelated classes (e.g.
|
|
915
|
+
* a test fixture) as services when a single file has more than one.
|
|
916
|
+
*/
|
|
917
|
+
parseService(content, filePath, declaredClass) {
|
|
918
|
+
let className = null;
|
|
919
|
+
if (declaredClass) {
|
|
920
|
+
// Confirm the requested class actually exists in the file before
|
|
921
|
+
// we report it as a service. Belt-and-braces — the caller already
|
|
922
|
+
// matched it against `@provide(...)`.
|
|
923
|
+
const found = new RegExp(`class\\s+${declaredClass}\\b`).test(content);
|
|
924
|
+
if (!found)
|
|
925
|
+
return null;
|
|
926
|
+
className = declaredClass;
|
|
927
|
+
}
|
|
928
|
+
else {
|
|
929
|
+
const classMatch = content.match(/class\s+(\w+)/);
|
|
930
|
+
if (!classMatch)
|
|
931
|
+
return null;
|
|
932
|
+
className = classMatch[1];
|
|
933
|
+
}
|
|
271
934
|
const dependencies = [];
|
|
272
935
|
const methods = [];
|
|
273
936
|
// Extract constructor dependencies (same parser as controllers so the
|
|
@@ -277,13 +940,65 @@ export class RouteScanner {
|
|
|
277
940
|
if (params) {
|
|
278
941
|
dependencies.push(...extractParamTypes(params));
|
|
279
942
|
}
|
|
280
|
-
//
|
|
281
|
-
|
|
943
|
+
// Pull the specific class body once — used for both method
|
|
944
|
+
// discovery and field-injection extraction.
|
|
945
|
+
const classBodyRe = new RegExp(`class\\s+${className}\\b(?:\\s+extends\\s+[\\w.<>,\\s]+)?(?:\\s+implements\\s+[\\w.<>,\\s]+)?\\s*\\{([\\s\\S]*?)\\n\\}`);
|
|
946
|
+
const classBody = content.match(classBodyRe)?.[1] ?? '';
|
|
947
|
+
// Field-injection dependencies — `@inject(Foo) private foo: Foo;`.
|
|
948
|
+
// Required for any service that wires its collaborators via
|
|
949
|
+
// property decorators instead of a constructor.
|
|
950
|
+
if (classBody) {
|
|
951
|
+
for (const dep of extractFieldInjectionTypes(classBody)) {
|
|
952
|
+
if (!dependencies.includes(dep))
|
|
953
|
+
dependencies.push(dep);
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
// Extract public methods. We scan the specific class body (not the
|
|
957
|
+
// first `class { … }` we find, and not the whole file) and skip JS
|
|
958
|
+
// control-flow keywords so statements like `if (x.is(y)) {` don't
|
|
959
|
+
// get reported as methods (which produced bogus services like
|
|
960
|
+
// "is" with 1 method on the architecture map).
|
|
961
|
+
const KEYWORD_BLOCKLIST = new Set([
|
|
962
|
+
'if',
|
|
963
|
+
'else',
|
|
964
|
+
'for',
|
|
965
|
+
'while',
|
|
966
|
+
'do',
|
|
967
|
+
'switch',
|
|
968
|
+
'case',
|
|
969
|
+
'try',
|
|
970
|
+
'catch',
|
|
971
|
+
'finally',
|
|
972
|
+
'return',
|
|
973
|
+
'throw',
|
|
974
|
+
'new',
|
|
975
|
+
'await',
|
|
976
|
+
'yield',
|
|
977
|
+
'typeof',
|
|
978
|
+
'instanceof',
|
|
979
|
+
'in',
|
|
980
|
+
'of',
|
|
981
|
+
'function',
|
|
982
|
+
'class',
|
|
983
|
+
'this',
|
|
984
|
+
'super',
|
|
985
|
+
'is',
|
|
986
|
+
]);
|
|
987
|
+
// Method declarations start at the beginning of a line (modulo
|
|
988
|
+
// indentation). Anchoring to ^/`m` flag prevents matches inside
|
|
989
|
+
// expression bodies.
|
|
990
|
+
const methodMatches = classBody.matchAll(/^[ \t]*(?:public\s+|private\s+|protected\s+)?(?:static\s+)?(?:async\s+)?(\w+)\s*\([^)]*\)\s*(?::\s*[^{;]+)?\s*\{/gm);
|
|
991
|
+
const seenMethods = new Set();
|
|
282
992
|
for (const match of methodMatches) {
|
|
283
993
|
const methodName = match[1];
|
|
284
|
-
if (methodName
|
|
285
|
-
|
|
994
|
+
if (methodName === 'constructor' ||
|
|
995
|
+
methodName.startsWith('_') ||
|
|
996
|
+
KEYWORD_BLOCKLIST.has(methodName) ||
|
|
997
|
+
seenMethods.has(methodName)) {
|
|
998
|
+
continue;
|
|
286
999
|
}
|
|
1000
|
+
seenMethods.add(methodName);
|
|
1001
|
+
methods.push(methodName);
|
|
287
1002
|
}
|
|
288
1003
|
return {
|
|
289
1004
|
name: className,
|
|
@@ -294,12 +1009,37 @@ export class RouteScanner {
|
|
|
294
1009
|
}
|
|
295
1010
|
/** Build the dependency graph */
|
|
296
1011
|
buildDependencyGraph() {
|
|
1012
|
+
// Set of node names that actually exist in the graph. Edges that
|
|
1013
|
+
// target a non-existent node are dangling and will be redirected
|
|
1014
|
+
// through `implementsMap` if possible.
|
|
1015
|
+
const nodeNames = new Set();
|
|
1016
|
+
for (const c of this.controllers)
|
|
1017
|
+
nodeNames.add(c.name);
|
|
1018
|
+
for (const s of this.services)
|
|
1019
|
+
nodeNames.add(s.name);
|
|
1020
|
+
for (const p of this.providers)
|
|
1021
|
+
nodeNames.add(p.name);
|
|
1022
|
+
for (const m of this.middleware)
|
|
1023
|
+
nodeNames.add(m.name);
|
|
1024
|
+
// Resolve a raw dependency identifier (an @inject token or declared
|
|
1025
|
+
// type name) to the concrete class node when possible. This restores
|
|
1026
|
+
// edges for the common pattern where a controller depends on an
|
|
1027
|
+
// interface (`IUserService`) that's implemented by a concrete class
|
|
1028
|
+
// (`UserService`) registered as the provider.
|
|
1029
|
+
const resolveTarget = (raw) => {
|
|
1030
|
+
if (nodeNames.has(raw))
|
|
1031
|
+
return raw;
|
|
1032
|
+
const mapped = this.implementsMap.get(raw);
|
|
1033
|
+
if (mapped && nodeNames.has(mapped))
|
|
1034
|
+
return mapped;
|
|
1035
|
+
return raw;
|
|
1036
|
+
};
|
|
297
1037
|
// Add controller -> service dependencies
|
|
298
1038
|
for (const controller of this.controllers) {
|
|
299
1039
|
for (const dep of controller.dependencies) {
|
|
300
1040
|
this.dependencies.push({
|
|
301
1041
|
source: controller.name,
|
|
302
|
-
target: dep,
|
|
1042
|
+
target: resolveTarget(dep),
|
|
303
1043
|
type: 'controller',
|
|
304
1044
|
});
|
|
305
1045
|
}
|
|
@@ -309,13 +1049,53 @@ export class RouteScanner {
|
|
|
309
1049
|
for (const dep of service.dependencies) {
|
|
310
1050
|
this.dependencies.push({
|
|
311
1051
|
source: service.name,
|
|
312
|
-
target: dep,
|
|
1052
|
+
target: resolveTarget(dep),
|
|
313
1053
|
type: service.name.toLowerCase().includes('provider')
|
|
314
1054
|
? 'provider'
|
|
315
1055
|
: 'service',
|
|
316
1056
|
});
|
|
317
1057
|
}
|
|
318
1058
|
}
|
|
1059
|
+
// Add middleware -> controller edges from the static decorator
|
|
1060
|
+
// scan. Direction is middleware → controller because the
|
|
1061
|
+
// architecture map reads them as "this middleware protects /
|
|
1062
|
+
// wraps that controller", which matches the request flow.
|
|
1063
|
+
//
|
|
1064
|
+
// Bindings whose middleware identifier doesn't resolve to a known
|
|
1065
|
+
// class node are skipped silently. Common reasons:
|
|
1066
|
+
// - imported from a third-party package the scanner doesn't
|
|
1067
|
+
// reach (e.g. `helmet`, `cors`)
|
|
1068
|
+
// - bound by string registry name (`use('auth')`) — the runtime
|
|
1069
|
+
// collector will surface those instead.
|
|
1070
|
+
const seenMiddlewareEdge = new Set();
|
|
1071
|
+
for (const binding of this.staticMiddlewareBindings) {
|
|
1072
|
+
const sourceName = binding.middlewareName;
|
|
1073
|
+
if (!nodeNames.has(sourceName))
|
|
1074
|
+
continue;
|
|
1075
|
+
if (!nodeNames.has(binding.controllerName))
|
|
1076
|
+
continue;
|
|
1077
|
+
const key = `${sourceName}->${binding.controllerName}@${binding.scope}`;
|
|
1078
|
+
if (seenMiddlewareEdge.has(key))
|
|
1079
|
+
continue;
|
|
1080
|
+
seenMiddlewareEdge.add(key);
|
|
1081
|
+
this.dependencies.push({
|
|
1082
|
+
source: sourceName,
|
|
1083
|
+
target: binding.controllerName,
|
|
1084
|
+
type: 'middleware',
|
|
1085
|
+
});
|
|
1086
|
+
// Promote the middleware's scope based on how it's wired.
|
|
1087
|
+
// `controller` overrides `route` because a middleware applied at
|
|
1088
|
+
// both levels is most usefully described as controller-scoped.
|
|
1089
|
+
const mw = this.middleware.find((m) => m.name === sourceName);
|
|
1090
|
+
if (mw) {
|
|
1091
|
+
if (binding.scope === 'controller') {
|
|
1092
|
+
mw.scope = 'controller';
|
|
1093
|
+
}
|
|
1094
|
+
else if (mw.scope !== 'controller') {
|
|
1095
|
+
mw.scope = 'route';
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
319
1099
|
}
|
|
320
1100
|
/** Get line number for a position in content */
|
|
321
1101
|
getLineNumber(_content, position, lines) {
|
|
@@ -348,37 +1128,79 @@ export class RouteScanner {
|
|
|
348
1128
|
/** Scan Express app for routes (runtime) */
|
|
349
1129
|
static scanExpressApp(app) {
|
|
350
1130
|
const routes = [];
|
|
351
|
-
|
|
1131
|
+
// Express 5 renamed `app._router` to `app.router`. Fall back gracefully
|
|
1132
|
+
// so the agent stays compatible with both major versions.
|
|
1133
|
+
const router = app?._router ?? app?.router;
|
|
1134
|
+
if (!app || !router?.stack) {
|
|
352
1135
|
return routes;
|
|
353
1136
|
}
|
|
1137
|
+
// Standard HTTP-7. Anything outside this set (ACL, BIND, CHECKOUT,
|
|
1138
|
+
// M-SEARCH, PROPFIND, SUBSCRIBE, …) is a low-level WebDAV / IETF
|
|
1139
|
+
// extension verb. Express's underlying `methods` library exposes them
|
|
1140
|
+
// all by default, which made the API client's "Discovered routes"
|
|
1141
|
+
// chips list 30+ entries per `app.use(middleware)` layer. Filtering
|
|
1142
|
+
// here keeps the picker focused on what users actually expose.
|
|
1143
|
+
const STANDARD_METHODS = new Set([
|
|
1144
|
+
'GET',
|
|
1145
|
+
'POST',
|
|
1146
|
+
'PUT',
|
|
1147
|
+
'PATCH',
|
|
1148
|
+
'DELETE',
|
|
1149
|
+
'HEAD',
|
|
1150
|
+
'OPTIONS',
|
|
1151
|
+
]);
|
|
354
1152
|
const extractRoutes = (stack, basePath = '') => {
|
|
355
1153
|
for (const layer of stack) {
|
|
356
1154
|
if (layer.route) {
|
|
357
|
-
// This is a route
|
|
358
1155
|
const route = layer.route;
|
|
359
|
-
|
|
1156
|
+
// Skip catch-all paths registered as routes. Those are almost
|
|
1157
|
+
// always framework / middleware artefacts (e.g. `app.use(...)`
|
|
1158
|
+
// promoted to a route in some Express 5 builds), not user
|
|
1159
|
+
// endpoints worth surfacing in the API client.
|
|
1160
|
+
if (!route.path || route.path === '*' || route.path === '/*') {
|
|
1161
|
+
continue;
|
|
1162
|
+
}
|
|
1163
|
+
const methods = Object.keys(route.methods)
|
|
1164
|
+
.filter((m) => route.methods[m])
|
|
1165
|
+
.map((m) => m.toUpperCase())
|
|
1166
|
+
.filter((m) => STANDARD_METHODS.has(m));
|
|
360
1167
|
for (const method of methods) {
|
|
361
1168
|
routes.push({
|
|
362
1169
|
path: basePath + route.path,
|
|
363
|
-
method
|
|
1170
|
+
method,
|
|
364
1171
|
controller: 'Unknown',
|
|
365
1172
|
controllerMethod: 'Unknown',
|
|
366
1173
|
});
|
|
367
1174
|
}
|
|
368
1175
|
}
|
|
369
|
-
else if (layer.name === 'router' && layer.handle
|
|
370
|
-
// This is a nested router
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
1176
|
+
else if (layer.name === 'router' && layer.handle?.stack) {
|
|
1177
|
+
// This is a nested router. Express 5 attaches the mount path
|
|
1178
|
+
// directly as `layer.path` (a literal string like `/api`), so
|
|
1179
|
+
// we prefer that. Older Express 4 layers only exposed
|
|
1180
|
+
// `layer.regexp`, which we still parse as a fallback.
|
|
1181
|
+
//
|
|
1182
|
+
// Both fields can legitimately be missing — e.g. when the
|
|
1183
|
+
// router is mounted at the root (`app.use(router)`), Express
|
|
1184
|
+
// 5 omits `regexp` entirely. Treating that as an empty
|
|
1185
|
+
// prefix is correct; trying to read `.source` blew up the
|
|
1186
|
+
// whole scan with "Cannot read properties of undefined".
|
|
1187
|
+
let routerPath = '';
|
|
1188
|
+
if (typeof layer.path === 'string') {
|
|
1189
|
+
routerPath = layer.path;
|
|
1190
|
+
}
|
|
1191
|
+
else if (layer.regexp?.source) {
|
|
1192
|
+
routerPath = layer.regexp.source
|
|
1193
|
+
.replace('\\/?(?=\\/|$)', '')
|
|
1194
|
+
.replace(/\\\//g, '/')
|
|
1195
|
+
.replace(/\^/g, '')
|
|
1196
|
+
.replace(/\$/g, '')
|
|
1197
|
+
.replace(/\(\?:\(\[\^\\\/\]\+\?\)\)/g, ':param');
|
|
1198
|
+
}
|
|
377
1199
|
extractRoutes(layer.handle.stack, basePath + routerPath);
|
|
378
1200
|
}
|
|
379
1201
|
}
|
|
380
1202
|
};
|
|
381
|
-
extractRoutes(
|
|
1203
|
+
extractRoutes(router.stack);
|
|
382
1204
|
return routes;
|
|
383
1205
|
}
|
|
384
1206
|
}
|