@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.
Files changed (40) hide show
  1. package/README.md +143 -143
  2. package/dist/agent.d.ts +75 -0
  3. package/dist/agent.d.ts.map +1 -1
  4. package/dist/agent.js +443 -14
  5. package/dist/agent.js.map +1 -1
  6. package/dist/discovery/route-scanner.d.ts +62 -1
  7. package/dist/discovery/route-scanner.d.ts.map +1 -1
  8. package/dist/discovery/route-scanner.js +923 -101
  9. package/dist/discovery/route-scanner.js.map +1 -1
  10. package/dist/identity/index.d.ts +2 -0
  11. package/dist/identity/index.d.ts.map +1 -0
  12. package/dist/identity/index.js +2 -0
  13. package/dist/identity/index.js.map +1 -0
  14. package/dist/identity/install-id.d.ts +22 -0
  15. package/dist/identity/install-id.d.ts.map +1 -0
  16. package/dist/identity/install-id.js +73 -0
  17. package/dist/identity/install-id.js.map +1 -0
  18. package/dist/index.d.ts +2 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +2 -0
  21. package/dist/index.js.map +1 -1
  22. package/dist/instrumentation/tracer.d.ts.map +1 -1
  23. package/dist/instrumentation/tracer.js +40 -4
  24. package/dist/instrumentation/tracer.js.map +1 -1
  25. package/dist/introspection/database-introspector.d.ts +58 -0
  26. package/dist/introspection/database-introspector.d.ts.map +1 -0
  27. package/dist/introspection/database-introspector.js +351 -0
  28. package/dist/introspection/database-introspector.js.map +1 -0
  29. package/dist/logging/log-capture.d.ts.map +1 -1
  30. package/dist/logging/log-capture.js +23 -1
  31. package/dist/logging/log-capture.js.map +1 -1
  32. package/dist/recording/request-recorder.js +73 -73
  33. package/dist/security/posture-analyzer.d.ts.map +1 -1
  34. package/dist/security/posture-analyzer.js +1 -1
  35. package/dist/security/posture-analyzer.js.map +1 -1
  36. package/dist/types/index.d.ts +261 -2
  37. package/dist/types/index.d.ts.map +1 -1
  38. package/dist/types/index.js +2 -0
  39. package/dist/types/index.js.map +1 -1
  40. 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
- /** Regular expressions for parsing TypeScript/JavaScript files */
9
- const PATTERNS = {
10
- // Match @controller decorator with path
11
- controller: /@controller\s*\(\s*['"`]([^'"`]+)['"`]/gi,
12
- // Match HTTP method decorators
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
- // 2) Optional access modifier
117
- // 3) Optional `readonly`
118
- // 4) Parameter name
119
- // 5) `:` then the captured Type (allow `Foo`, `Ns.Foo`, `Foo<...>`)
120
- const PARAM_RE = /(?:@\w+\s*\([^)]*\)\s*)*(?:(?:public|private|protected)\s+)?(?:readonly\s+)?(\w+)\s*:\s*([A-Za-z_$][\w.$]*)/g;
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
- if (!typeName)
396
+ const candidate = injectToken || typeName;
397
+ if (!candidate)
124
398
  continue;
125
- const head = typeName.split('.').pop() || typeName;
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
- const injectableMatch = content.match(/@provide\s*\(/i);
197
- if (injectableMatch) {
198
- const service = this.parseService(content, filePath);
199
- if (service) {
200
- // Determine if it's a service or provider based on naming
201
- if (filePath.includes('provider') ||
202
- service.name.toLowerCase().includes('provider')) {
203
- this.providers.push(service);
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
- this.services.push(service);
766
+ buf += ch;
207
767
  }
208
768
  }
209
- }
210
- // Check for middleware
211
- const middlewareMatch = content.match(/@middleware\s*\(/i);
212
- if (middlewareMatch) {
213
- const classMatch = content.match(/class\s+(\w+)/);
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 constructor dependencies. We use a balanced-paren scanner
229
- // here because parameter decorators (@inject(MyService)) contain
230
- // their own parentheses and a naive `[^)]*` regex would truncate
231
- // the parameter list at the wrong `)`.
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
- // Find HTTP method decorators
237
- for (const [method, regex] of Object.entries(PATTERNS.httpMethods)) {
238
- regex.lastIndex = 0;
239
- let match;
240
- while ((match = regex.exec(content)) !== null) {
241
- const routePath = match[1] || '/';
242
- const lineNumber = this.getLineNumber(content, match.index, lines);
243
- // Find the method name (next function after decorator)
244
- const afterDecorator = content.slice(match.index);
245
- const methodMatch = afterDecorator.match(/(?:async\s+)?(\w+)\s*\([^)]*\)\s*(?::\s*[^{]+)?\s*\{/);
246
- if (methodMatch) {
247
- routes.push({
248
- path: this.normalizePath(basePath, routePath),
249
- method: method.toUpperCase(),
250
- controller: className,
251
- controllerMethod: methodMatch[1],
252
- filePath,
253
- lineNumber,
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
- /** Parse service from file content */
266
- parseService(content, filePath) {
267
- const classMatch = content.match(/class\s+(\w+)/);
268
- if (!classMatch)
269
- return null;
270
- const className = classMatch[1];
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
- // Extract public methods (simplified)
281
- const methodMatches = content.matchAll(/(?:public\s+)?(?:async\s+)?(\w+)\s*\([^)]*\)\s*(?::\s*[^{]+)?\s*\{/g);
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 !== 'constructor' && !methodName.startsWith('_')) {
285
- methods.push(methodName);
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
- if (!app || !app._router) {
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
- const methods = Object.keys(route.methods).filter((m) => route.methods[m]);
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: method.toUpperCase(),
1170
+ method,
364
1171
  controller: 'Unknown',
365
1172
  controllerMethod: 'Unknown',
366
1173
  });
367
1174
  }
368
1175
  }
369
- else if (layer.name === 'router' && layer.handle.stack) {
370
- // This is a nested router
371
- const routerPath = layer.regexp.source
372
- .replace('\\/?(?=\\/|$)', '')
373
- .replace(/\\\//g, '/')
374
- .replace(/\^/g, '')
375
- .replace(/\$/g, '')
376
- .replace(/\(\?:\(\[\^\\\/\]\+\?\)\)/g, ':param');
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(app._router.stack);
1203
+ extractRoutes(router.stack);
382
1204
  return routes;
383
1205
  }
384
1206
  }