nestjs-doctor 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1505 @@
1
+ import { join, resolve } from "node:path";
2
+ import { defineCommand, runMain } from "citty";
3
+ import { performance } from "node:perf_hooks";
4
+ import { Project, SyntaxKind } from "ts-morph";
5
+ import { readFile } from "node:fs/promises";
6
+ import { glob } from "tinyglobby";
7
+ import pc from "picocolors";
8
+ import ora from "ora";
9
+
10
+ //#region src/engine/ast-parser.ts
11
+ function createAstParser(files) {
12
+ const project = new Project({
13
+ compilerOptions: {
14
+ strict: true,
15
+ target: 99,
16
+ module: 99,
17
+ skipFileDependencyResolution: true
18
+ },
19
+ skipAddingFilesFromTsConfig: true
20
+ });
21
+ for (const file of files) project.addSourceFileAtPath(file);
22
+ return project;
23
+ }
24
+
25
+ //#endregion
26
+ //#region src/engine/module-graph.ts
27
+ const FORWARD_REF_REGEX = /=>\s*(\w+)/;
28
+ function buildModuleGraph(project, files) {
29
+ const modules = new Map();
30
+ const edges = new Map();
31
+ for (const filePath of files) {
32
+ const sourceFile = project.getSourceFile(filePath);
33
+ if (!sourceFile) continue;
34
+ for (const cls of sourceFile.getClasses()) {
35
+ const moduleDecorator = cls.getDecorator("Module");
36
+ if (!moduleDecorator) continue;
37
+ const name = cls.getName() ?? "AnonymousModule";
38
+ const args = moduleDecorator.getArguments()[0];
39
+ const node = {
40
+ name,
41
+ filePath,
42
+ classDeclaration: cls,
43
+ imports: [],
44
+ exports: [],
45
+ providers: [],
46
+ controllers: []
47
+ };
48
+ if (args && args.getKind() === SyntaxKind.ObjectLiteralExpression) {
49
+ const obj = args.asKind(SyntaxKind.ObjectLiteralExpression);
50
+ if (obj) {
51
+ node.imports = extractArrayPropertyNames(obj, "imports");
52
+ node.exports = extractArrayPropertyNames(obj, "exports");
53
+ node.providers = extractArrayPropertyNames(obj, "providers");
54
+ node.controllers = extractArrayPropertyNames(obj, "controllers");
55
+ }
56
+ }
57
+ modules.set(name, node);
58
+ }
59
+ }
60
+ for (const [name, node] of modules) {
61
+ const importSet = new Set();
62
+ for (const imp of node.imports) if (modules.has(imp)) importSet.add(imp);
63
+ edges.set(name, importSet);
64
+ }
65
+ return {
66
+ modules,
67
+ edges
68
+ };
69
+ }
70
+ function extractArrayPropertyNames(obj, propertyName) {
71
+ const prop = obj.getProperty(propertyName);
72
+ if (!prop) return [];
73
+ const initializer = prop.getChildrenOfKind(SyntaxKind.ArrayLiteralExpression)[0];
74
+ if (!initializer) return [];
75
+ return initializer.getElements().map((el) => {
76
+ const text = el.getText();
77
+ if (text.startsWith("forwardRef")) {
78
+ const match = text.match(FORWARD_REF_REGEX);
79
+ return match ? match[1] : text;
80
+ }
81
+ if (text.startsWith("...")) return text.slice(3).trim();
82
+ return text;
83
+ });
84
+ }
85
+ function findCircularDeps(graph) {
86
+ const cycles = [];
87
+ const visited = new Set();
88
+ const recursionStack = new Set();
89
+ function dfs(node, path) {
90
+ visited.add(node);
91
+ recursionStack.add(node);
92
+ const neighbors = graph.edges.get(node) ?? new Set();
93
+ for (const neighbor of neighbors) if (!visited.has(neighbor)) dfs(neighbor, [...path, neighbor]);
94
+ else if (recursionStack.has(neighbor)) {
95
+ const cycleStart = path.indexOf(neighbor);
96
+ if (cycleStart !== -1) cycles.push(path.slice(cycleStart));
97
+ else cycles.push([...path, neighbor]);
98
+ }
99
+ recursionStack.delete(node);
100
+ }
101
+ for (const moduleName of graph.modules.keys()) if (!visited.has(moduleName)) dfs(moduleName, [moduleName]);
102
+ return cycles;
103
+ }
104
+
105
+ //#endregion
106
+ //#region src/rules/types.ts
107
+ function isProjectRule(rule) {
108
+ return rule.meta.scope === "project";
109
+ }
110
+
111
+ //#endregion
112
+ //#region src/engine/rule-runner.ts
113
+ function runRules(project, files, rules, options) {
114
+ const diagnostics = [];
115
+ const fileRules = [];
116
+ const projectRules = [];
117
+ for (const rule of rules) if (isProjectRule(rule)) projectRules.push(rule);
118
+ else fileRules.push(rule);
119
+ for (const filePath of files) {
120
+ const sourceFile = project.getSourceFile(filePath);
121
+ if (!sourceFile) continue;
122
+ for (const rule of fileRules) {
123
+ const context = {
124
+ sourceFile,
125
+ filePath,
126
+ report(partial) {
127
+ diagnostics.push({
128
+ ...partial,
129
+ rule: rule.meta.id,
130
+ category: rule.meta.category,
131
+ severity: rule.meta.severity
132
+ });
133
+ }
134
+ };
135
+ try {
136
+ rule.check(context);
137
+ } catch {}
138
+ }
139
+ }
140
+ for (const rule of projectRules) {
141
+ const context = {
142
+ project,
143
+ files,
144
+ moduleGraph: options.moduleGraph,
145
+ providers: options.providers,
146
+ config: options.config,
147
+ report(partial) {
148
+ diagnostics.push({
149
+ ...partial,
150
+ rule: rule.meta.id,
151
+ category: rule.meta.category,
152
+ severity: rule.meta.severity
153
+ });
154
+ }
155
+ };
156
+ try {
157
+ rule.check(context);
158
+ } catch {}
159
+ }
160
+ return diagnostics;
161
+ }
162
+
163
+ //#endregion
164
+ //#region src/engine/type-resolver.ts
165
+ const IMPORT_TYPE_REGEX = /import\([^)]+\)\.(\w+)/;
166
+ const GENERIC_TYPE_REGEX$3 = /^(\w+)</;
167
+ function resolveProviders(project, files) {
168
+ const providers = new Map();
169
+ for (const filePath of files) {
170
+ const sourceFile = project.getSourceFile(filePath);
171
+ if (!sourceFile) continue;
172
+ for (const cls of sourceFile.getClasses()) {
173
+ if (!cls.getDecorator("Injectable")) continue;
174
+ const name = cls.getName();
175
+ if (!name) continue;
176
+ const ctor = cls.getConstructors()[0];
177
+ const dependencies = ctor ? ctor.getParameters().map((p) => {
178
+ const typeText = p.getType().getText();
179
+ return extractSimpleTypeName(typeText);
180
+ }) : [];
181
+ const publicMethodCount = cls.getMethods().filter((m) => {
182
+ const scope = m.getScope();
183
+ return !scope || scope === "public";
184
+ }).length;
185
+ providers.set(name, {
186
+ name,
187
+ filePath,
188
+ classDeclaration: cls,
189
+ dependencies,
190
+ publicMethodCount
191
+ });
192
+ }
193
+ }
194
+ return providers;
195
+ }
196
+ function extractSimpleTypeName(typeText) {
197
+ const importMatch = typeText.match(IMPORT_TYPE_REGEX);
198
+ if (importMatch) return importMatch[1];
199
+ const genericMatch = typeText.match(GENERIC_TYPE_REGEX$3);
200
+ if (genericMatch) return genericMatch[1];
201
+ return typeText;
202
+ }
203
+
204
+ //#endregion
205
+ //#region src/rules/architecture/no-barrel-export-internals.ts
206
+ const INTERNAL_PATTERNS = [
207
+ /Repository$/,
208
+ /\.repository$/,
209
+ /\.entity$/,
210
+ /\.schema$/,
211
+ /\.guard$/,
212
+ /\.interceptor$/,
213
+ /\.pipe$/,
214
+ /\.filter$/,
215
+ /\.strategy$/
216
+ ];
217
+ const noBarrelExportInternals = {
218
+ meta: {
219
+ id: "architecture/no-barrel-export-internals",
220
+ category: "architecture",
221
+ severity: "info",
222
+ description: "Don't re-export internal implementation details from barrel files",
223
+ help: "Only export the module's public API (services, DTOs, interfaces) from index.ts files."
224
+ },
225
+ check(context) {
226
+ if (!context.filePath.endsWith("/index.ts")) return;
227
+ for (const exportDecl of context.sourceFile.getExportDeclarations()) {
228
+ const moduleSpecifier = exportDecl.getModuleSpecifierValue();
229
+ if (!moduleSpecifier) continue;
230
+ const isInternal = INTERNAL_PATTERNS.some((p) => p.test(moduleSpecifier));
231
+ if (isInternal) context.report({
232
+ filePath: context.filePath,
233
+ message: `Barrel file re-exports internal module '${moduleSpecifier}'.`,
234
+ help: this.meta.help,
235
+ line: exportDecl.getStartLineNumber(),
236
+ column: 1
237
+ });
238
+ for (const namedExport of exportDecl.getNamedExports()) {
239
+ const name = namedExport.getName();
240
+ if (name.endsWith("Repository") || name.endsWith("Entity") || name.endsWith("Schema")) context.report({
241
+ filePath: context.filePath,
242
+ message: `Barrel file re-exports internal type '${name}'.`,
243
+ help: this.meta.help,
244
+ line: namedExport.getStartLineNumber(),
245
+ column: 1
246
+ });
247
+ }
248
+ }
249
+ }
250
+ };
251
+
252
+ //#endregion
253
+ //#region src/engine/decorator-utils.ts
254
+ function hasDecorator(cls, name) {
255
+ return cls.getDecorator(name) !== void 0;
256
+ }
257
+ function isController(cls) {
258
+ return hasDecorator(cls, "Controller");
259
+ }
260
+ function isService(cls) {
261
+ return hasDecorator(cls, "Injectable");
262
+ }
263
+
264
+ //#endregion
265
+ //#region src/rules/architecture/no-business-logic-in-controllers.ts
266
+ const HTTP_DECORATORS = new Set([
267
+ "Get",
268
+ "Post",
269
+ "Put",
270
+ "Patch",
271
+ "Delete",
272
+ "Head",
273
+ "Options",
274
+ "All"
275
+ ]);
276
+ const noBusinessLogicInControllers = {
277
+ meta: {
278
+ id: "architecture/no-business-logic-in-controllers",
279
+ category: "architecture",
280
+ severity: "error",
281
+ description: "Controllers should only handle HTTP concerns — move business logic to services",
282
+ help: "Extract branches, loops, and complex calculations into a service method."
283
+ },
284
+ check(context) {
285
+ for (const cls of context.sourceFile.getClasses()) {
286
+ if (!isController(cls)) continue;
287
+ for (const method of cls.getMethods()) {
288
+ const isEndpoint = method.getDecorators().some((d) => HTTP_DECORATORS.has(d.getName()));
289
+ if (!isEndpoint) continue;
290
+ const body = method.getBody();
291
+ if (!body) continue;
292
+ const ifStatements = body.getDescendantsOfKind(SyntaxKind.IfStatement);
293
+ const forStatements = body.getDescendantsOfKind(SyntaxKind.ForStatement);
294
+ const forInStatements = body.getDescendantsOfKind(SyntaxKind.ForInStatement);
295
+ const forOfStatements = body.getDescendantsOfKind(SyntaxKind.ForOfStatement);
296
+ const whileStatements = body.getDescendantsOfKind(SyntaxKind.WhileStatement);
297
+ const switchStatements = body.getDescendantsOfKind(SyntaxKind.SwitchStatement);
298
+ const loopCount = forStatements.length + forInStatements.length + forOfStatements.length + whileStatements.length;
299
+ if (ifStatements.length > 1 || loopCount > 0 || switchStatements.length > 0) context.report({
300
+ filePath: context.filePath,
301
+ message: `Controller method '${method.getName()}' contains business logic (${ifStatements.length} if, ${loopCount} loops, ${switchStatements.length} switch). Move to a service.`,
302
+ help: this.meta.help,
303
+ line: method.getStartLineNumber(),
304
+ column: 1
305
+ });
306
+ const callExpressions = body.getDescendantsOfKind(SyntaxKind.CallExpression);
307
+ const complexArrayOps = callExpressions.filter((call) => {
308
+ const expr = call.getExpression();
309
+ if (expr.getKind() === SyntaxKind.PropertyAccessExpression) {
310
+ const propAccess = expr.asKind(SyntaxKind.PropertyAccessExpression);
311
+ const name = propAccess?.getName();
312
+ return name === "map" || name === "filter" || name === "reduce" || name === "sort" || name === "flatMap";
313
+ }
314
+ return false;
315
+ });
316
+ if (complexArrayOps.length > 1) context.report({
317
+ filePath: context.filePath,
318
+ message: `Controller method '${method.getName()}' contains data transformation logic (${complexArrayOps.length} array operations). Move to a service.`,
319
+ help: this.meta.help,
320
+ line: method.getStartLineNumber(),
321
+ column: 1
322
+ });
323
+ }
324
+ }
325
+ }
326
+ };
327
+
328
+ //#endregion
329
+ //#region src/rules/architecture/no-circular-module-deps.ts
330
+ const noCircularModuleDeps = {
331
+ meta: {
332
+ id: "architecture/no-circular-module-deps",
333
+ category: "architecture",
334
+ severity: "error",
335
+ description: "Circular dependencies in @Module() import graph",
336
+ help: "Break the cycle by extracting shared logic into a separate module or using forwardRef().",
337
+ scope: "project"
338
+ },
339
+ check(context) {
340
+ const cycles = findCircularDeps(context.moduleGraph);
341
+ for (const cycle of cycles) {
342
+ const cycleStr = cycle.join(" -> ");
343
+ const firstModule = context.moduleGraph.modules.get(cycle[0]);
344
+ context.report({
345
+ filePath: firstModule?.filePath ?? "unknown",
346
+ message: `Circular module dependency detected: ${cycleStr}`,
347
+ help: this.meta.help,
348
+ line: firstModule?.classDeclaration.getStartLineNumber() ?? 1,
349
+ column: 1
350
+ });
351
+ }
352
+ }
353
+ };
354
+
355
+ //#endregion
356
+ //#region src/rules/architecture/no-god-module.ts
357
+ const DEFAULT_MAX_PROVIDERS = 10;
358
+ const DEFAULT_MAX_IMPORTS = 15;
359
+ const noGodModule = {
360
+ meta: {
361
+ id: "architecture/no-god-module",
362
+ category: "architecture",
363
+ severity: "warning",
364
+ description: "Modules with too many providers or imports should be split into smaller feature modules",
365
+ help: "Split this module into smaller, focused feature modules.",
366
+ scope: "project"
367
+ },
368
+ check(context) {
369
+ const maxProviders = context.config.thresholds?.godModuleProviders ?? DEFAULT_MAX_PROVIDERS;
370
+ const maxImports = context.config.thresholds?.godModuleImports ?? DEFAULT_MAX_IMPORTS;
371
+ for (const mod of context.moduleGraph.modules.values()) {
372
+ if (mod.providers.length > maxProviders) context.report({
373
+ filePath: mod.filePath,
374
+ message: `Module '${mod.name}' has ${mod.providers.length} providers (max: ${maxProviders}). Consider splitting into smaller modules.`,
375
+ help: this.meta.help,
376
+ line: mod.classDeclaration.getStartLineNumber(),
377
+ column: 1
378
+ });
379
+ if (mod.imports.length > maxImports) context.report({
380
+ filePath: mod.filePath,
381
+ message: `Module '${mod.name}' has ${mod.imports.length} imports (max: ${maxImports}). Consider grouping into feature modules.`,
382
+ help: this.meta.help,
383
+ line: mod.classDeclaration.getStartLineNumber(),
384
+ column: 1
385
+ });
386
+ }
387
+ }
388
+ };
389
+
390
+ //#endregion
391
+ //#region src/rules/architecture/no-god-service.ts
392
+ const DEFAULT_MAX_METHODS = 10;
393
+ const DEFAULT_MAX_DEPS = 8;
394
+ const noGodService = {
395
+ meta: {
396
+ id: "architecture/no-god-service",
397
+ category: "architecture",
398
+ severity: "warning",
399
+ description: "Services with too many public methods or dependencies should be split",
400
+ help: "Split this service into smaller, focused services with single responsibilities.",
401
+ scope: "project"
402
+ },
403
+ check(context) {
404
+ const maxMethods = context.config.thresholds?.godServiceMethods ?? DEFAULT_MAX_METHODS;
405
+ const maxDeps = context.config.thresholds?.godServiceDeps ?? DEFAULT_MAX_DEPS;
406
+ for (const provider of context.providers.values()) {
407
+ if (provider.publicMethodCount > maxMethods) context.report({
408
+ filePath: provider.filePath,
409
+ message: `Service '${provider.name}' has ${provider.publicMethodCount} public methods (max: ${maxMethods}). Consider splitting.`,
410
+ help: this.meta.help,
411
+ line: provider.classDeclaration.getStartLineNumber(),
412
+ column: 1
413
+ });
414
+ if (provider.dependencies.length > maxDeps) context.report({
415
+ filePath: provider.filePath,
416
+ message: `Service '${provider.name}' has ${provider.dependencies.length} dependencies (max: ${maxDeps}). Consider splitting.`,
417
+ help: this.meta.help,
418
+ line: provider.classDeclaration.getStartLineNumber(),
419
+ column: 1
420
+ });
421
+ }
422
+ }
423
+ };
424
+
425
+ //#endregion
426
+ //#region src/rules/architecture/no-manual-instantiation.ts
427
+ const noManualInstantiation = {
428
+ meta: {
429
+ id: "architecture/no-manual-instantiation",
430
+ category: "architecture",
431
+ severity: "error",
432
+ description: "Do not manually instantiate @Injectable classes — use NestJS dependency injection",
433
+ help: "Register the class as a provider in a module and inject it via the constructor."
434
+ },
435
+ check(context) {
436
+ const newExpressions = context.sourceFile.getDescendantsOfKind(SyntaxKind.NewExpression);
437
+ for (const expr of newExpressions) {
438
+ const exprText = expr.getExpression().getText();
439
+ if (exprText.endsWith("Service") || exprText.endsWith("Repository") || exprText.endsWith("Guard") || exprText.endsWith("Interceptor") || exprText.endsWith("Pipe") || exprText.endsWith("Filter") || exprText.endsWith("Gateway") || exprText.endsWith("Resolver")) context.report({
440
+ filePath: context.filePath,
441
+ message: `Manual instantiation of '${exprText}' detected. Use dependency injection instead.`,
442
+ help: this.meta.help,
443
+ line: expr.getStartLineNumber(),
444
+ column: 1
445
+ });
446
+ }
447
+ }
448
+ };
449
+
450
+ //#endregion
451
+ //#region src/rules/architecture/no-orm-in-controllers.ts
452
+ const DOTTED_SUFFIX_REGEX$2 = /\.(\w+)$/;
453
+ const GENERIC_TYPE_REGEX$2 = /^(\w+)</;
454
+ const ORM_TYPES$1 = new Set([
455
+ "PrismaService",
456
+ "PrismaClient",
457
+ "EntityManager",
458
+ "DataSource",
459
+ "Repository",
460
+ "Connection",
461
+ "MongooseModel",
462
+ "InjectModel",
463
+ "InjectRepository",
464
+ "MikroORM",
465
+ "DrizzleService"
466
+ ]);
467
+ const noOrmInControllers = {
468
+ meta: {
469
+ id: "architecture/no-orm-in-controllers",
470
+ category: "architecture",
471
+ severity: "error",
472
+ description: "Controllers must not inject ORM services directly — use a service layer",
473
+ help: "Inject a service that wraps the ORM instead of using the ORM directly in controllers."
474
+ },
475
+ check(context) {
476
+ for (const cls of context.sourceFile.getClasses()) {
477
+ if (!isController(cls)) continue;
478
+ const ctor = cls.getConstructors()[0];
479
+ if (!ctor) continue;
480
+ for (const param of ctor.getParameters()) {
481
+ const typeText = param.getType().getText();
482
+ const typeName = extractTypeName$2(typeText);
483
+ if (ORM_TYPES$1.has(typeName)) {
484
+ const nameNode = param.getNameNode();
485
+ context.report({
486
+ filePath: context.filePath,
487
+ message: `Controller injects ORM type '${typeName}' directly. Use a service layer.`,
488
+ help: this.meta.help,
489
+ line: nameNode.getStartLineNumber(),
490
+ column: nameNode.getStartLinePos() + 1
491
+ });
492
+ }
493
+ }
494
+ for (const param of cls.getConstructors()[0]?.getParameters() ?? []) for (const decorator of param.getDecorators()) {
495
+ const name = decorator.getName();
496
+ if (name === "InjectRepository" || name === "InjectModel") context.report({
497
+ filePath: context.filePath,
498
+ message: `Controller uses @${name}() decorator. Move data access to a service.`,
499
+ help: this.meta.help,
500
+ line: decorator.getStartLineNumber(),
501
+ column: 1
502
+ });
503
+ }
504
+ }
505
+ }
506
+ };
507
+ function extractTypeName$2(typeText) {
508
+ const match = typeText.match(DOTTED_SUFFIX_REGEX$2);
509
+ if (match) return match[1];
510
+ const genericMatch = typeText.match(GENERIC_TYPE_REGEX$2);
511
+ if (genericMatch) return genericMatch[1];
512
+ return typeText;
513
+ }
514
+
515
+ //#endregion
516
+ //#region src/rules/architecture/no-orm-in-services.ts
517
+ const DOTTED_SUFFIX_REGEX$1 = /\.(\w+)$/;
518
+ const GENERIC_TYPE_REGEX$1 = /^(\w+)</;
519
+ const ORM_TYPES = new Set([
520
+ "PrismaService",
521
+ "PrismaClient",
522
+ "EntityManager",
523
+ "DataSource",
524
+ "Connection",
525
+ "MikroORM"
526
+ ]);
527
+ const noOrmInServices = {
528
+ meta: {
529
+ id: "architecture/no-orm-in-services",
530
+ category: "architecture",
531
+ severity: "warning",
532
+ description: "Services should use repository abstractions instead of ORM directly",
533
+ help: "Create a repository class that wraps ORM calls and inject that instead."
534
+ },
535
+ check(context) {
536
+ for (const cls of context.sourceFile.getClasses()) {
537
+ if (!isService(cls)) continue;
538
+ const className = cls.getName() ?? "";
539
+ if (className.endsWith("Repository") || className.endsWith("Repo")) continue;
540
+ const ctor = cls.getConstructors()[0];
541
+ if (!ctor) continue;
542
+ for (const param of ctor.getParameters()) {
543
+ const typeText = param.getType().getText();
544
+ const typeName = extractTypeName$1(typeText);
545
+ if (ORM_TYPES.has(typeName)) {
546
+ const nameNode = param.getNameNode();
547
+ context.report({
548
+ filePath: context.filePath,
549
+ message: `Service injects ORM type '${typeName}' directly. Consider using a repository abstraction.`,
550
+ help: this.meta.help,
551
+ line: nameNode.getStartLineNumber(),
552
+ column: nameNode.getStartLinePos() + 1
553
+ });
554
+ }
555
+ for (const decorator of param.getDecorators()) {
556
+ const name = decorator.getName();
557
+ if (name === "InjectRepository" || name === "InjectModel") context.report({
558
+ filePath: context.filePath,
559
+ message: `Service uses @${name}() directly. Consider wrapping in a repository class.`,
560
+ help: this.meta.help,
561
+ line: decorator.getStartLineNumber(),
562
+ column: 1
563
+ });
564
+ }
565
+ }
566
+ }
567
+ }
568
+ };
569
+ function extractTypeName$1(typeText) {
570
+ const match = typeText.match(DOTTED_SUFFIX_REGEX$1);
571
+ if (match) return match[1];
572
+ const genericMatch = typeText.match(GENERIC_TYPE_REGEX$1);
573
+ if (genericMatch) return genericMatch[1];
574
+ return typeText;
575
+ }
576
+
577
+ //#endregion
578
+ //#region src/rules/architecture/no-repository-in-controllers.ts
579
+ const DOTTED_SUFFIX_REGEX = /\.(\w+)$/;
580
+ const GENERIC_TYPE_REGEX = /^(\w+)</;
581
+ const REPOSITORY_PATTERNS = [/Repository$/, /Repo$/];
582
+ const noRepositoryInControllers = {
583
+ meta: {
584
+ id: "architecture/no-repository-in-controllers",
585
+ category: "architecture",
586
+ severity: "error",
587
+ description: "Controllers must not inject repositories directly — use the service layer",
588
+ help: "Move database access to a service and inject the service into the controller instead."
589
+ },
590
+ check(context) {
591
+ for (const cls of context.sourceFile.getClasses()) {
592
+ if (!isController(cls)) continue;
593
+ const ctor = cls.getConstructors()[0];
594
+ if (!ctor) continue;
595
+ for (const param of ctor.getParameters()) {
596
+ const typeText = param.getType().getText();
597
+ const typeName = extractTypeName(typeText);
598
+ if (REPOSITORY_PATTERNS.some((p) => p.test(typeName))) {
599
+ const nameNode = param.getNameNode();
600
+ context.report({
601
+ filePath: context.filePath,
602
+ message: `Controller injects repository '${typeName}' directly. Use a service layer instead.`,
603
+ help: this.meta.help,
604
+ line: nameNode.getStartLineNumber(),
605
+ column: nameNode.getStartLinePos() + 1
606
+ });
607
+ }
608
+ }
609
+ for (const imp of context.sourceFile.getImportDeclarations()) {
610
+ const moduleSpecifier = imp.getModuleSpecifierValue();
611
+ if (moduleSpecifier.includes("/repositories/") || moduleSpecifier.includes("/repositories")) context.report({
612
+ filePath: context.filePath,
613
+ message: `Controller imports from repository path '${moduleSpecifier}'.`,
614
+ help: this.meta.help,
615
+ line: imp.getStartLineNumber(),
616
+ column: 1
617
+ });
618
+ }
619
+ }
620
+ }
621
+ };
622
+ function extractTypeName(typeText) {
623
+ const match = typeText.match(DOTTED_SUFFIX_REGEX);
624
+ if (match) return match[1];
625
+ const genericMatch = typeText.match(GENERIC_TYPE_REGEX);
626
+ if (genericMatch) return genericMatch[1];
627
+ return typeText;
628
+ }
629
+
630
+ //#endregion
631
+ //#region src/rules/architecture/prefer-constructor-injection.ts
632
+ const preferConstructorInjection = {
633
+ meta: {
634
+ id: "architecture/prefer-constructor-injection",
635
+ category: "architecture",
636
+ severity: "warning",
637
+ description: "Prefer constructor injection over @Inject() property injection",
638
+ help: "Move the dependency to a constructor parameter instead of using property injection."
639
+ },
640
+ check(context) {
641
+ for (const cls of context.sourceFile.getClasses()) {
642
+ if (!(isService(cls) || isController(cls))) continue;
643
+ for (const prop of cls.getProperties()) {
644
+ const hasInjectDecorator = prop.getDecorator("Inject");
645
+ if (!hasInjectDecorator) continue;
646
+ context.report({
647
+ filePath: context.filePath,
648
+ message: `Property '${prop.getName()}' uses @Inject() decorator. Prefer constructor injection.`,
649
+ help: this.meta.help,
650
+ line: prop.getStartLineNumber(),
651
+ column: 1
652
+ });
653
+ }
654
+ }
655
+ }
656
+ };
657
+
658
+ //#endregion
659
+ //#region src/rules/architecture/prefer-interface-injection.ts
660
+ const preferInterfaceInjection = {
661
+ meta: {
662
+ id: "architecture/prefer-interface-injection",
663
+ category: "architecture",
664
+ severity: "info",
665
+ description: "Consider injecting abstract classes instead of concrete implementations for testability",
666
+ help: "Create an abstract class and use a custom provider token for better testability."
667
+ },
668
+ check(context) {
669
+ for (const cls of context.sourceFile.getClasses()) {
670
+ if (!isService(cls)) continue;
671
+ const ctor = cls.getConstructors()[0];
672
+ if (!ctor) continue;
673
+ for (const param of ctor.getParameters()) {
674
+ const typeNode = param.getTypeNode();
675
+ if (!typeNode) continue;
676
+ const typeText = typeNode.getText();
677
+ if (typeText === "ConfigService" || typeText === "Logger" || typeText === "EventEmitter2" || typeText === "HttpService" || typeText === "JwtService" || typeText === "ModuleRef" || typeText.startsWith("Cache")) continue;
678
+ if (typeText.endsWith("Service") && !typeText.startsWith("Abstract") && !typeText.startsWith("I")) {
679
+ const className = cls.getName() ?? "";
680
+ if (className.endsWith("Service") && typeText !== className) context.report({
681
+ filePath: context.filePath,
682
+ message: `Service '${className}' injects concrete '${typeText}'. Consider using an abstract class for testability.`,
683
+ help: this.meta.help,
684
+ line: param.getStartLineNumber(),
685
+ column: 1
686
+ });
687
+ }
688
+ }
689
+ }
690
+ }
691
+ };
692
+
693
+ //#endregion
694
+ //#region src/rules/architecture/require-feature-modules.ts
695
+ const MAX_DIRECT_PROVIDERS = 5;
696
+ const requireFeatureModules = {
697
+ meta: {
698
+ id: "architecture/require-feature-modules",
699
+ category: "architecture",
700
+ severity: "warning",
701
+ description: "AppModule should import feature modules rather than declaring many providers directly",
702
+ help: "Group related providers into feature modules and import them in AppModule.",
703
+ scope: "project"
704
+ },
705
+ check(context) {
706
+ const appModule = context.moduleGraph.modules.get("AppModule");
707
+ if (!appModule) return;
708
+ if (appModule.providers.length > MAX_DIRECT_PROVIDERS && appModule.imports.length < appModule.providers.length) context.report({
709
+ filePath: appModule.filePath,
710
+ message: `AppModule declares ${appModule.providers.length} providers directly. Group them into feature modules.`,
711
+ help: this.meta.help,
712
+ line: appModule.classDeclaration.getStartLineNumber(),
713
+ column: 1
714
+ });
715
+ }
716
+ };
717
+
718
+ //#endregion
719
+ //#region src/rules/architecture/require-module-boundaries.ts
720
+ const INTERNAL_PATHS = [
721
+ "/repositories/",
722
+ "/entities/",
723
+ "/dto/",
724
+ "/guards/",
725
+ "/interceptors/",
726
+ "/pipes/",
727
+ "/strategies/"
728
+ ];
729
+ const requireModuleBoundaries = {
730
+ meta: {
731
+ id: "architecture/require-module-boundaries",
732
+ category: "architecture",
733
+ severity: "info",
734
+ description: "Avoid deep imports into other feature modules' internals",
735
+ help: "Import from the module's public API (barrel export) instead of reaching into its internals."
736
+ },
737
+ check(context) {
738
+ for (const imp of context.sourceFile.getImportDeclarations()) {
739
+ const moduleSpecifier = imp.getModuleSpecifierValue();
740
+ if (!moduleSpecifier.startsWith(".")) continue;
741
+ if (!moduleSpecifier.includes("../")) continue;
742
+ const crossesModuleBoundary = INTERNAL_PATHS.some((p) => moduleSpecifier.includes(p));
743
+ if (crossesModuleBoundary) context.report({
744
+ filePath: context.filePath,
745
+ message: `Import '${moduleSpecifier}' reaches into another module's internals.`,
746
+ help: this.meta.help,
747
+ line: imp.getStartLineNumber(),
748
+ column: 1
749
+ });
750
+ }
751
+ }
752
+ };
753
+
754
+ //#endregion
755
+ //#region src/rules/correctness/prefer-readonly-injection.ts
756
+ const preferReadonlyInjection = {
757
+ meta: {
758
+ id: "correctness/prefer-readonly-injection",
759
+ category: "correctness",
760
+ severity: "warning",
761
+ description: "Constructor DI parameters should be readonly to prevent accidental reassignment",
762
+ help: "Add the 'readonly' modifier to the constructor parameter."
763
+ },
764
+ check(context) {
765
+ for (const cls of context.sourceFile.getClasses()) {
766
+ if (!(isService(cls) || isController(cls))) continue;
767
+ const ctor = cls.getConstructors()[0];
768
+ if (!ctor) continue;
769
+ for (const param of ctor.getParameters()) {
770
+ if (!(param.hasModifier("private") || param.hasModifier("protected") || param.hasModifier("public"))) continue;
771
+ if (!param.isReadonly()) {
772
+ const nameNode = param.getNameNode();
773
+ context.report({
774
+ filePath: context.filePath,
775
+ message: `Constructor parameter '${param.getName()}' should be readonly.`,
776
+ help: this.meta.help,
777
+ line: nameNode.getStartLineNumber(),
778
+ column: nameNode.getStartLinePos() + 1
779
+ });
780
+ }
781
+ }
782
+ }
783
+ }
784
+ };
785
+
786
+ //#endregion
787
+ //#region src/rules/security/no-hardcoded-secrets.ts
788
+ const SECRET_PATTERNS = [
789
+ {
790
+ pattern: /^[A-Za-z0-9+/]{40,}={0,2}$/,
791
+ name: "Base64 key"
792
+ },
793
+ {
794
+ pattern: /^sk[-_][a-zA-Z0-9]{20,}$/,
795
+ name: "Secret key"
796
+ },
797
+ {
798
+ pattern: /^pk[-_][a-zA-Z0-9]{20,}$/,
799
+ name: "Public key (in source)"
800
+ },
801
+ {
802
+ pattern: /^ghp_[a-zA-Z0-9]{36,}$/,
803
+ name: "GitHub personal access token"
804
+ },
805
+ {
806
+ pattern: /^github_pat_[a-zA-Z0-9_]{22,}$/,
807
+ name: "GitHub fine-grained PAT"
808
+ },
809
+ {
810
+ pattern: /^gho_[a-zA-Z0-9]{36,}$/,
811
+ name: "GitHub OAuth token"
812
+ },
813
+ {
814
+ pattern: /^xox[bpras]-[a-zA-Z0-9-]+$/,
815
+ name: "Slack token"
816
+ },
817
+ {
818
+ pattern: /^eyJ[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}\./,
819
+ name: "JWT token"
820
+ },
821
+ {
822
+ pattern: /^AKIA[0-9A-Z]{16}$/,
823
+ name: "AWS Access Key ID"
824
+ },
825
+ {
826
+ pattern: /^[a-f0-9]{64}$/,
827
+ name: "Hex-encoded secret (64 chars)"
828
+ }
829
+ ];
830
+ const VARIABLE_NAME_PATTERNS = [
831
+ /secret/i,
832
+ /password/i,
833
+ /passwd/i,
834
+ /api[_-]?key/i,
835
+ /auth[_-]?token/i,
836
+ /private[_-]?key/i,
837
+ /access[_-]?key/i,
838
+ /client[_-]?secret/i
839
+ ];
840
+ const noHardcodedSecrets = {
841
+ meta: {
842
+ id: "security/no-hardcoded-secrets",
843
+ category: "security",
844
+ severity: "error",
845
+ description: "Detect hardcoded secrets, API keys, and tokens in source code",
846
+ help: "Move secrets to environment variables and access them via ConfigService."
847
+ },
848
+ check(context) {
849
+ const stringLiterals = context.sourceFile.getDescendantsOfKind(SyntaxKind.StringLiteral);
850
+ for (const literal of stringLiterals) {
851
+ const value = literal.getLiteralValue();
852
+ if (value.length < 16) continue;
853
+ if (literal.getParent()?.getKind() === SyntaxKind.ImportDeclaration) continue;
854
+ for (const { pattern, name } of SECRET_PATTERNS) if (pattern.test(value)) {
855
+ context.report({
856
+ filePath: context.filePath,
857
+ message: `Possible hardcoded ${name} detected.`,
858
+ help: this.meta.help,
859
+ line: literal.getStartLineNumber(),
860
+ column: 1
861
+ });
862
+ break;
863
+ }
864
+ }
865
+ const variableDeclarations = context.sourceFile.getDescendantsOfKind(SyntaxKind.VariableDeclaration);
866
+ for (const decl of variableDeclarations) {
867
+ const name = decl.getName();
868
+ const initializer = decl.getInitializer();
869
+ if (!initializer) continue;
870
+ if (initializer.getKind() !== SyntaxKind.StringLiteral) continue;
871
+ const isSuspiciousName = VARIABLE_NAME_PATTERNS.some((p) => p.test(name));
872
+ if (!isSuspiciousName) continue;
873
+ const value = initializer.getText().slice(1, -1);
874
+ if (value.length >= 8 && !value.includes("${") && !value.startsWith("process.env") && value !== "your-secret-here" && value !== "changeme" && value !== "password") context.report({
875
+ filePath: context.filePath,
876
+ message: `Variable '${name}' appears to contain a hardcoded secret.`,
877
+ help: this.meta.help,
878
+ line: decl.getStartLineNumber(),
879
+ column: 1
880
+ });
881
+ }
882
+ const propertyAssignments = context.sourceFile.getDescendantsOfKind(SyntaxKind.PropertyAssignment);
883
+ for (const prop of propertyAssignments) {
884
+ const name = prop.getName();
885
+ const initializer = prop.getInitializer();
886
+ if (!initializer) continue;
887
+ if (initializer.getKind() !== SyntaxKind.StringLiteral) continue;
888
+ const isSuspiciousName = VARIABLE_NAME_PATTERNS.some((p) => p.test(name));
889
+ if (!isSuspiciousName) continue;
890
+ const value = initializer.getText().slice(1, -1);
891
+ if (value.length >= 8 && !value.includes("${") && !value.startsWith("process.env")) context.report({
892
+ filePath: context.filePath,
893
+ message: `Property '${name}' appears to contain a hardcoded secret.`,
894
+ help: this.meta.help,
895
+ line: prop.getStartLineNumber(),
896
+ column: 1
897
+ });
898
+ }
899
+ }
900
+ };
901
+
902
+ //#endregion
903
+ //#region src/rules/index.ts
904
+ const allRules = [
905
+ noBusinessLogicInControllers,
906
+ noRepositoryInControllers,
907
+ noOrmInControllers,
908
+ noOrmInServices,
909
+ noManualInstantiation,
910
+ preferConstructorInjection,
911
+ preferInterfaceInjection,
912
+ requireModuleBoundaries,
913
+ noBarrelExportInternals,
914
+ noCircularModuleDeps,
915
+ noGodModule,
916
+ noGodService,
917
+ requireFeatureModules,
918
+ preferReadonlyInjection,
919
+ noHardcodedSecrets
920
+ ];
921
+
922
+ //#endregion
923
+ //#region src/scorer/labels.ts
924
+ function getScoreLabel(score) {
925
+ if (score >= 90) return "Excellent";
926
+ if (score >= 75) return "Good";
927
+ if (score >= 50) return "Fair";
928
+ if (score >= 25) return "Poor";
929
+ return "Critical";
930
+ }
931
+
932
+ //#endregion
933
+ //#region src/scorer/weights.ts
934
+ const SEVERITY_WEIGHTS = {
935
+ error: 3,
936
+ warning: 1.5,
937
+ info: .5
938
+ };
939
+ const CATEGORY_MULTIPLIERS = {
940
+ security: 1.5,
941
+ correctness: 1.3,
942
+ architecture: 1,
943
+ performance: .8
944
+ };
945
+
946
+ //#endregion
947
+ //#region src/scorer/index.ts
948
+ function calculateScore(diagnostics, fileCount) {
949
+ if (fileCount === 0) return {
950
+ value: 100,
951
+ label: getScoreLabel(100)
952
+ };
953
+ let totalPenalty = 0;
954
+ for (const d of diagnostics) {
955
+ const severityWeight = SEVERITY_WEIGHTS[d.severity];
956
+ const categoryMultiplier = CATEGORY_MULTIPLIERS[d.category];
957
+ totalPenalty += severityWeight * categoryMultiplier;
958
+ }
959
+ const normalizedPenalty = totalPenalty / fileCount;
960
+ const value = Math.max(0, Math.min(100, Math.round(100 - normalizedPenalty * 10)));
961
+ return {
962
+ value,
963
+ label: getScoreLabel(value)
964
+ };
965
+ }
966
+
967
+ //#endregion
968
+ //#region src/types/config.ts
969
+ const DEFAULT_CONFIG = {
970
+ include: ["**/*.ts"],
971
+ exclude: [
972
+ "node_modules/**",
973
+ "dist/**",
974
+ "build/**",
975
+ "coverage/**",
976
+ "**/*.spec.ts",
977
+ "**/*.test.ts",
978
+ "**/*.e2e-spec.ts",
979
+ "**/*.d.ts"
980
+ ]
981
+ };
982
+
983
+ //#endregion
984
+ //#region src/core/config-loader.ts
985
+ const CONFIG_FILENAMES = ["nestjs-doctor.config.json", ".nestjs-doctor.json"];
986
+ async function loadConfig(targetPath, configPath) {
987
+ if (configPath) return readConfigFile(configPath);
988
+ for (const filename of CONFIG_FILENAMES) try {
989
+ return await readConfigFile(join(targetPath, filename));
990
+ } catch {}
991
+ try {
992
+ const pkgRaw = await readFile(join(targetPath, "package.json"), "utf-8");
993
+ const pkg = JSON.parse(pkgRaw);
994
+ if (pkg["nestjs-doctor"] && typeof pkg["nestjs-doctor"] === "object") return mergeConfig(pkg["nestjs-doctor"]);
995
+ } catch {}
996
+ return { ...DEFAULT_CONFIG };
997
+ }
998
+ async function readConfigFile(path) {
999
+ const raw = await readFile(path, "utf-8");
1000
+ const parsed = JSON.parse(raw);
1001
+ return mergeConfig(parsed);
1002
+ }
1003
+ function mergeConfig(userConfig) {
1004
+ return {
1005
+ ...DEFAULT_CONFIG,
1006
+ ...userConfig,
1007
+ exclude: [...DEFAULT_CONFIG.exclude ?? [], ...userConfig.exclude ?? []]
1008
+ };
1009
+ }
1010
+
1011
+ //#endregion
1012
+ //#region src/core/file-collector.ts
1013
+ async function collectFiles(targetPath, config = {}) {
1014
+ const include = config.include ?? DEFAULT_CONFIG.include;
1015
+ const exclude = config.exclude ?? DEFAULT_CONFIG.exclude;
1016
+ const files = await glob(include, {
1017
+ cwd: targetPath,
1018
+ absolute: true,
1019
+ ignore: exclude
1020
+ });
1021
+ return files.sort();
1022
+ }
1023
+
1024
+ //#endregion
1025
+ //#region src/core/match-glob-pattern.ts
1026
+ const REGEX_SPECIAL_CHARACTERS = /[.+^${}()|[\]\\]/g;
1027
+ const compileGlobPattern = (pattern) => {
1028
+ const normalizedPattern = pattern.replace(/\\/g, "/");
1029
+ let regexSource = "^";
1030
+ let characterIndex = 0;
1031
+ while (characterIndex < normalizedPattern.length) if (normalizedPattern[characterIndex] === "*" && normalizedPattern[characterIndex + 1] === "*") if (normalizedPattern[characterIndex + 2] === "/") {
1032
+ regexSource += "(?:.+/)?";
1033
+ characterIndex += 3;
1034
+ } else {
1035
+ regexSource += ".*";
1036
+ characterIndex += 2;
1037
+ }
1038
+ else if (normalizedPattern[characterIndex] === "*") {
1039
+ regexSource += "[^/]*";
1040
+ characterIndex++;
1041
+ } else if (normalizedPattern[characterIndex] === "?") {
1042
+ regexSource += "[^/]";
1043
+ characterIndex++;
1044
+ } else {
1045
+ regexSource += normalizedPattern[characterIndex].replace(REGEX_SPECIAL_CHARACTERS, "\\$&");
1046
+ characterIndex++;
1047
+ }
1048
+ regexSource += "$";
1049
+ return new RegExp(regexSource);
1050
+ };
1051
+
1052
+ //#endregion
1053
+ //#region src/core/filter-diagnostics.ts
1054
+ const filterIgnoredDiagnostics = (diagnostics, config) => {
1055
+ const ignoredRules = new Set(Array.isArray(config.ignore?.rules) ? config.ignore.rules : []);
1056
+ const ignoredFilePatterns = Array.isArray(config.ignore?.files) ? config.ignore.files.map(compileGlobPattern) : [];
1057
+ if (ignoredRules.size === 0 && ignoredFilePatterns.length === 0) return diagnostics;
1058
+ return diagnostics.filter((diagnostic) => {
1059
+ if (ignoredRules.has(diagnostic.rule)) return false;
1060
+ const normalizedPath = diagnostic.filePath.replace(/\\/g, "/");
1061
+ if (ignoredFilePatterns.some((pattern) => pattern.test(normalizedPath))) return false;
1062
+ return true;
1063
+ });
1064
+ };
1065
+
1066
+ //#endregion
1067
+ //#region src/core/project-detector.ts
1068
+ async function detectProject(targetPath) {
1069
+ const pkgPath = join(targetPath, "package.json");
1070
+ let pkg = {};
1071
+ try {
1072
+ const raw = await readFile(pkgPath, "utf-8");
1073
+ pkg = JSON.parse(raw);
1074
+ } catch {}
1075
+ const allDeps = {
1076
+ ...pkg.dependencies,
1077
+ ...pkg.devDependencies
1078
+ };
1079
+ const nestVersion = extractVersion(allDeps["@nestjs/core"]);
1080
+ const orm = detectOrm(allDeps);
1081
+ const framework = detectFramework(allDeps);
1082
+ return {
1083
+ name: pkg.name ?? "unknown",
1084
+ nestVersion,
1085
+ orm,
1086
+ framework,
1087
+ moduleCount: 0,
1088
+ fileCount: 0
1089
+ };
1090
+ }
1091
+ function extractVersion(version) {
1092
+ if (!version) return null;
1093
+ return version.replace(/[\^~>=<]/g, "");
1094
+ }
1095
+ function detectOrm(deps) {
1096
+ if (deps["@prisma/client"]) return "prisma";
1097
+ if (deps.typeorm) return "typeorm";
1098
+ if (deps["@mikro-orm/core"]) return "mikro-orm";
1099
+ if (deps.sequelize) return "sequelize";
1100
+ if (deps.mongoose) return "mongoose";
1101
+ if (deps["drizzle-orm"]) return "drizzle";
1102
+ return null;
1103
+ }
1104
+ function detectFramework(deps) {
1105
+ if (deps["@nestjs/platform-fastify"]) return "fastify";
1106
+ if (deps["@nestjs/platform-express"]) return "express";
1107
+ if (deps["@nestjs/core"]) return "express";
1108
+ return null;
1109
+ }
1110
+
1111
+ //#endregion
1112
+ //#region src/core/scanner.ts
1113
+ async function scan(targetPath, options = {}) {
1114
+ const startTime = performance.now();
1115
+ const config = await loadConfig(targetPath, options.config);
1116
+ const project = await detectProject(targetPath);
1117
+ const files = await collectFiles(targetPath, config);
1118
+ project.fileCount = files.length;
1119
+ const astProject = createAstParser(files);
1120
+ const moduleGraph = buildModuleGraph(astProject, files);
1121
+ const providers = resolveProviders(astProject, files);
1122
+ const rules = filterRules(config);
1123
+ const rawDiagnostics = runRules(astProject, files, rules, {
1124
+ moduleGraph,
1125
+ providers,
1126
+ config
1127
+ });
1128
+ const diagnostics = filterIgnoredDiagnostics(rawDiagnostics, config);
1129
+ project.moduleCount = moduleGraph.modules.size;
1130
+ const score = calculateScore(diagnostics, files.length);
1131
+ const summary = buildSummary(diagnostics);
1132
+ const elapsedMs = performance.now() - startTime;
1133
+ return {
1134
+ score,
1135
+ diagnostics,
1136
+ project,
1137
+ summary,
1138
+ elapsedMs
1139
+ };
1140
+ }
1141
+ function filterRules(config) {
1142
+ return allRules.filter((rule) => {
1143
+ const ruleConfig = config.rules?.[rule.meta.id];
1144
+ if (ruleConfig === false) return false;
1145
+ if (typeof ruleConfig === "object" && ruleConfig.enabled === false) return false;
1146
+ const categoryEnabled = config.categories?.[rule.meta.category];
1147
+ if (categoryEnabled === false) return false;
1148
+ return true;
1149
+ });
1150
+ }
1151
+ function buildSummary(diagnostics) {
1152
+ return {
1153
+ total: diagnostics.length,
1154
+ errors: diagnostics.filter((d) => d.severity === "error").length,
1155
+ warnings: diagnostics.filter((d) => d.severity === "warning").length,
1156
+ info: diagnostics.filter((d) => d.severity === "info").length,
1157
+ byCategory: {
1158
+ security: diagnostics.filter((d) => d.category === "security").length,
1159
+ performance: diagnostics.filter((d) => d.category === "performance").length,
1160
+ correctness: diagnostics.filter((d) => d.category === "correctness").length,
1161
+ architecture: diagnostics.filter((d) => d.category === "architecture").length
1162
+ }
1163
+ };
1164
+ }
1165
+
1166
+ //#endregion
1167
+ //#region src/cli/flags.ts
1168
+ const flags = {
1169
+ verbose: {
1170
+ type: "boolean",
1171
+ description: "Show file paths and line numbers per diagnostic",
1172
+ default: false
1173
+ },
1174
+ score: {
1175
+ type: "boolean",
1176
+ description: "Output only the numeric score (for CI)",
1177
+ default: false
1178
+ },
1179
+ json: {
1180
+ type: "boolean",
1181
+ description: "JSON output",
1182
+ default: false
1183
+ },
1184
+ config: {
1185
+ type: "string",
1186
+ description: "Config file path"
1187
+ }
1188
+ };
1189
+
1190
+ //#endregion
1191
+ //#region src/cli/output/highlighter.ts
1192
+ const highlighter = {
1193
+ error: pc.red,
1194
+ warn: pc.yellow,
1195
+ info: pc.cyan,
1196
+ success: pc.green,
1197
+ dim: pc.dim
1198
+ };
1199
+
1200
+ //#endregion
1201
+ //#region src/cli/output/logger.ts
1202
+ const ANSI_ESCAPE_PATTERN = /\u001B\[[0-9;]*m/g;
1203
+ const stripAnsi = (text) => text.replace(ANSI_ESCAPE_PATTERN, "");
1204
+ const loggerCaptureState = {
1205
+ isEnabled: false,
1206
+ lines: []
1207
+ };
1208
+ const captureLogLine = (text) => {
1209
+ if (!loggerCaptureState.isEnabled) return;
1210
+ loggerCaptureState.lines.push(stripAnsi(text));
1211
+ };
1212
+ const writeLogLine = (text) => {
1213
+ console.log(text);
1214
+ captureLogLine(text);
1215
+ };
1216
+ const logger = {
1217
+ error(...args) {
1218
+ writeLogLine(highlighter.error(args.join(" ")));
1219
+ },
1220
+ warn(...args) {
1221
+ writeLogLine(highlighter.warn(args.join(" ")));
1222
+ },
1223
+ info(...args) {
1224
+ writeLogLine(highlighter.info(args.join(" ")));
1225
+ },
1226
+ success(...args) {
1227
+ writeLogLine(highlighter.success(args.join(" ")));
1228
+ },
1229
+ dim(...args) {
1230
+ writeLogLine(highlighter.dim(args.join(" ")));
1231
+ },
1232
+ log(...args) {
1233
+ writeLogLine(args.join(" "));
1234
+ },
1235
+ break() {
1236
+ writeLogLine("");
1237
+ }
1238
+ };
1239
+
1240
+ //#endregion
1241
+ //#region src/cli/output/console-reporter.ts
1242
+ const PERFECT_SCORE = 100;
1243
+ const SCORE_BAR_WIDTH = 50;
1244
+ const SCORE_GOOD_THRESHOLD = 75;
1245
+ const SCORE_OK_THRESHOLD = 50;
1246
+ const MILLISECONDS_PER_SECOND = 1e3;
1247
+ const BOX_HORIZONTAL_PADDING = 1;
1248
+ const BOX_OUTER_INDENT = 2;
1249
+ const SEVERITY_ORDER = {
1250
+ error: 0,
1251
+ warning: 1,
1252
+ info: 2
1253
+ };
1254
+ const createFramedLine = (plainText, renderedText = plainText) => ({
1255
+ plainText,
1256
+ renderedText
1257
+ });
1258
+ const colorizeByScore = (text, score) => {
1259
+ if (score >= SCORE_GOOD_THRESHOLD) return highlighter.success(text);
1260
+ if (score >= SCORE_OK_THRESHOLD) return highlighter.warn(text);
1261
+ return highlighter.error(text);
1262
+ };
1263
+ const colorizeBySeverity = (text, severity) => {
1264
+ if (severity === "error") return highlighter.error(text);
1265
+ if (severity === "warning") return highlighter.warn(text);
1266
+ return highlighter.info(text);
1267
+ };
1268
+ const getSeverityIcon = (severity) => {
1269
+ if (severity === "error") return "✗";
1270
+ if (severity === "warning") return "⚠";
1271
+ return "●";
1272
+ };
1273
+ const formatElapsedTime = (elapsedMs) => {
1274
+ if (elapsedMs < MILLISECONDS_PER_SECOND) return `${Math.round(elapsedMs)}ms`;
1275
+ return `${(elapsedMs / MILLISECONDS_PER_SECOND).toFixed(1)}s`;
1276
+ };
1277
+ const getNestBirds = (score) => {
1278
+ if (score >= SCORE_GOOD_THRESHOLD) return ["◠ ◠ ◠", "╰───╯"];
1279
+ if (score >= SCORE_OK_THRESHOLD) return ["• • •", "╰───╯"];
1280
+ return ["x x x", "╰───╯"];
1281
+ };
1282
+ const getStarRating = (score) => {
1283
+ if (score >= 90) return "★★★★★";
1284
+ if (score >= 75) return "★★★★☆";
1285
+ if (score >= 50) return "★★★☆☆";
1286
+ if (score >= 25) return "★★☆☆☆";
1287
+ return "★☆☆☆☆";
1288
+ };
1289
+ const buildScoreBarSegments = (score) => {
1290
+ const filledCount = Math.round(score / PERFECT_SCORE * SCORE_BAR_WIDTH);
1291
+ const emptyCount = SCORE_BAR_WIDTH - filledCount;
1292
+ return {
1293
+ filled: "█".repeat(filledCount),
1294
+ empty: "░".repeat(emptyCount)
1295
+ };
1296
+ };
1297
+ const buildPlainScoreBar = (score) => {
1298
+ const { filled, empty } = buildScoreBarSegments(score);
1299
+ return `${filled}${empty}`;
1300
+ };
1301
+ const buildScoreBar = (score) => {
1302
+ const { filled, empty } = buildScoreBarSegments(score);
1303
+ return colorizeByScore(filled, score) + highlighter.dim(empty);
1304
+ };
1305
+ const printFramedBox = (framedLines) => {
1306
+ if (framedLines.length === 0) return;
1307
+ const borderColorizer = highlighter.dim;
1308
+ const outerIndent = " ".repeat(BOX_OUTER_INDENT);
1309
+ const horizontalPadding = " ".repeat(BOX_HORIZONTAL_PADDING);
1310
+ const maxLineLength = Math.max(...framedLines.map((l) => l.plainText.length));
1311
+ const borderLine = "─".repeat(maxLineLength + BOX_HORIZONTAL_PADDING * 2);
1312
+ logger.log(`${outerIndent}${borderColorizer(`┌${borderLine}┐`)}`);
1313
+ for (const line of framedLines) {
1314
+ const trailing = " ".repeat(maxLineLength - line.plainText.length);
1315
+ logger.log(`${outerIndent}${borderColorizer("│")}${horizontalPadding}${line.renderedText}${trailing}${horizontalPadding}${borderColorizer("│")}`);
1316
+ }
1317
+ logger.log(`${outerIndent}${borderColorizer(`└${borderLine}┘`)}`);
1318
+ };
1319
+ const groupByRule = (diagnostics) => {
1320
+ const groups = new Map();
1321
+ for (const d of diagnostics) {
1322
+ const key = d.rule;
1323
+ const group = groups.get(key) ?? [];
1324
+ group.push(d);
1325
+ groups.set(key, group);
1326
+ }
1327
+ return groups;
1328
+ };
1329
+ const sortBySeverity = (groups) => [...groups].sort(([, a], [, b]) => SEVERITY_ORDER[a[0].severity] - SEVERITY_ORDER[b[0].severity]);
1330
+ const buildFileLineMap = (diagnostics) => {
1331
+ const fileLines = new Map();
1332
+ for (const d of diagnostics) {
1333
+ const lines = fileLines.get(d.filePath) ?? [];
1334
+ if (d.line > 0) lines.push(d.line);
1335
+ fileLines.set(d.filePath, lines);
1336
+ }
1337
+ return fileLines;
1338
+ };
1339
+ const collectAffectedFiles = (diagnostics) => new Set(diagnostics.map((d) => d.filePath));
1340
+ const printDiagnostics = (diagnostics, verbose) => {
1341
+ const ruleGroups = groupByRule(diagnostics);
1342
+ const sortedGroups = sortBySeverity([...ruleGroups.entries()]);
1343
+ for (const [, ruleDiagnostics] of sortedGroups) {
1344
+ const first = ruleDiagnostics[0];
1345
+ const icon = colorizeBySeverity(getSeverityIcon(first.severity), first.severity);
1346
+ const count = ruleDiagnostics.length;
1347
+ const countLabel = count > 1 ? colorizeBySeverity(` (${count})`, first.severity) : "";
1348
+ logger.log(` ${icon} ${first.message}${countLabel}`);
1349
+ if (first.help) logger.dim(` ${first.help}`);
1350
+ if (verbose) {
1351
+ const fileLines = buildFileLineMap(ruleDiagnostics);
1352
+ for (const [filePath, lines] of fileLines) {
1353
+ const lineLabel = lines.length > 0 ? `: ${lines.join(", ")}` : "";
1354
+ logger.dim(` ${filePath}${lineLabel}`);
1355
+ }
1356
+ }
1357
+ logger.break();
1358
+ }
1359
+ };
1360
+ function printConsoleReport(result, verbose) {
1361
+ const { score, diagnostics, project, summary, elapsedMs } = result;
1362
+ logger.break();
1363
+ const framedLines = [];
1364
+ const scoreColorizer = (text) => colorizeByScore(text, score.value);
1365
+ const [birds, nest] = getNestBirds(score.value);
1366
+ framedLines.push(createFramedLine("┌───────┐", scoreColorizer("┌───────┐")));
1367
+ framedLines.push(createFramedLine(`│ ${birds} │ NestJS Doctor`, `${scoreColorizer(`│ ${birds} │`)} NestJS Doctor`));
1368
+ framedLines.push(createFramedLine(`│ ${nest} │`, scoreColorizer(`│ ${nest} │`)));
1369
+ framedLines.push(createFramedLine("└───────┘", scoreColorizer("└───────┘")));
1370
+ framedLines.push(createFramedLine(""));
1371
+ const stars = getStarRating(score.value);
1372
+ const scoreLinePlain = `${score.value} / ${PERFECT_SCORE} ${stars} ${score.label}`;
1373
+ const scoreLineRendered = `${colorizeByScore(String(score.value), score.value)} / ${PERFECT_SCORE} ${colorizeByScore(stars, score.value)} ${colorizeByScore(score.label, score.value)}`;
1374
+ framedLines.push(createFramedLine(scoreLinePlain, scoreLineRendered));
1375
+ framedLines.push(createFramedLine(""));
1376
+ framedLines.push(createFramedLine(buildPlainScoreBar(score.value), buildScoreBar(score.value)));
1377
+ framedLines.push(createFramedLine(""));
1378
+ const elapsed = formatElapsedTime(elapsedMs);
1379
+ const affectedFileCount = collectAffectedFiles(diagnostics).size;
1380
+ const summaryParts = [];
1381
+ const summaryPartsPlain = [];
1382
+ if (summary.errors > 0) {
1383
+ const errorText = `✗ ${summary.errors} error${summary.errors === 1 ? "" : "s"}`;
1384
+ summaryPartsPlain.push(errorText);
1385
+ summaryParts.push(highlighter.error(errorText));
1386
+ }
1387
+ if (summary.warnings > 0) {
1388
+ const warnText = `⚠ ${summary.warnings} warning${summary.warnings === 1 ? "" : "s"}`;
1389
+ summaryPartsPlain.push(warnText);
1390
+ summaryParts.push(highlighter.warn(warnText));
1391
+ }
1392
+ if (summary.info > 0) {
1393
+ const infoText = `● ${summary.info} info`;
1394
+ summaryPartsPlain.push(infoText);
1395
+ summaryParts.push(highlighter.info(infoText));
1396
+ }
1397
+ if (diagnostics.length === 0) {
1398
+ const noIssuesText = "No issues found!";
1399
+ summaryPartsPlain.push(noIssuesText);
1400
+ summaryParts.push(highlighter.success(noIssuesText));
1401
+ }
1402
+ const fileCountText = diagnostics.length > 0 ? `across ${affectedFileCount}/${project.fileCount} files` : `${project.fileCount} files scanned`;
1403
+ const elapsedText = `in ${elapsed}`;
1404
+ summaryPartsPlain.push(fileCountText);
1405
+ summaryPartsPlain.push(elapsedText);
1406
+ summaryParts.push(highlighter.dim(fileCountText));
1407
+ summaryParts.push(highlighter.dim(elapsedText));
1408
+ framedLines.push(createFramedLine(summaryPartsPlain.join(" "), summaryParts.join(" ")));
1409
+ printFramedBox(framedLines);
1410
+ logger.break();
1411
+ const projectInfoParts = [`Project: ${project.name}`];
1412
+ if (project.nestVersion) projectInfoParts.push(`NestJS ${project.nestVersion}`);
1413
+ if (project.orm) projectInfoParts.push(project.orm);
1414
+ projectInfoParts.push(`${project.moduleCount} modules`);
1415
+ logger.dim(` ${projectInfoParts.join(" | ")}`);
1416
+ logger.break();
1417
+ if (diagnostics.length === 0) return;
1418
+ printDiagnostics(diagnostics, verbose);
1419
+ if (!verbose) {
1420
+ logger.dim(" Run with --verbose for file paths and line numbers");
1421
+ logger.break();
1422
+ }
1423
+ }
1424
+
1425
+ //#endregion
1426
+ //#region src/cli/output/json-reporter.ts
1427
+ function printJsonReport(result) {
1428
+ console.log(JSON.stringify(result, null, 2));
1429
+ }
1430
+
1431
+ //#endregion
1432
+ //#region src/cli/output/spinner.ts
1433
+ let sharedInstance = null;
1434
+ let activeCount = 0;
1435
+ const pendingTexts = new Set();
1436
+ const finalize = (method, originalText, displayText) => {
1437
+ pendingTexts.delete(originalText);
1438
+ activeCount--;
1439
+ if (activeCount <= 0 || !sharedInstance) {
1440
+ sharedInstance?.[method](displayText);
1441
+ sharedInstance = null;
1442
+ activeCount = 0;
1443
+ return;
1444
+ }
1445
+ sharedInstance.stop();
1446
+ ora(displayText).start()[method](displayText);
1447
+ const [remainingText] = pendingTexts;
1448
+ if (remainingText) sharedInstance.text = remainingText;
1449
+ sharedInstance.start();
1450
+ };
1451
+ const spinner = (text) => ({ start() {
1452
+ activeCount++;
1453
+ pendingTexts.add(text);
1454
+ if (sharedInstance) sharedInstance.text = text;
1455
+ else sharedInstance = ora({ text }).start();
1456
+ return {
1457
+ succeed: (displayText) => finalize("succeed", text, displayText),
1458
+ fail: (displayText) => finalize("fail", text, displayText)
1459
+ };
1460
+ } });
1461
+
1462
+ //#endregion
1463
+ //#region src/cli/index.ts
1464
+ const main = defineCommand({
1465
+ meta: {
1466
+ name: "nestjs-doctor",
1467
+ version: "0.1.0",
1468
+ description: "Diagnostic CLI tool that scans NestJS codebases and produces a health score"
1469
+ },
1470
+ args: {
1471
+ path: {
1472
+ type: "positional",
1473
+ description: "Path to the NestJS project (defaults to current directory)",
1474
+ default: ".",
1475
+ required: false
1476
+ },
1477
+ ...flags
1478
+ },
1479
+ async run({ args }) {
1480
+ const targetPath = resolve(args.path ?? ".");
1481
+ const isSilent = args.score || args.json;
1482
+ const scanSpinner = isSilent ? null : spinner("Scanning...").start();
1483
+ const result = await scan(targetPath, { config: args.config });
1484
+ if (scanSpinner) {
1485
+ const { project } = result;
1486
+ const detailParts = [`Scanned ${highlighter.info(String(project.fileCount))} files`];
1487
+ if (project.nestVersion) detailParts.push(`NestJS ${highlighter.info(project.nestVersion)}`);
1488
+ if (project.orm) detailParts.push(highlighter.info(project.orm));
1489
+ scanSpinner.succeed(detailParts.join(" | "));
1490
+ }
1491
+ if (args.score) {
1492
+ console.log(result.score.value);
1493
+ return;
1494
+ }
1495
+ if (args.json) {
1496
+ printJsonReport(result);
1497
+ return;
1498
+ }
1499
+ printConsoleReport(result, args.verbose ?? false);
1500
+ if (result.summary.errors > 0) process.exit(1);
1501
+ }
1502
+ });
1503
+ runMain(main);
1504
+
1505
+ //#endregion