@starci/eslint-canon-be 1.8.1 → 2.0.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.
Files changed (62) hide show
  1. package/CHANGELOG.md +95 -9
  2. package/README.md +41 -52
  3. package/async-discipline.mjs +2 -2
  4. package/authorization.mjs +38 -88
  5. package/builders.mjs +305 -0
  6. package/comments.mjs +19 -34
  7. package/config-owner.mjs +127 -125
  8. package/connections.mjs +254 -0
  9. package/cqrs.mjs +426 -135
  10. package/data-access.mjs +320 -391
  11. package/default-deny.mjs +14 -10
  12. package/e2e-flow.mjs +208 -180
  13. package/error-handling.mjs +254 -128
  14. package/idempotency.mjs +98 -67
  15. package/index.mjs +32 -9
  16. package/infra-owner.mjs +96 -0
  17. package/injection.mjs +602 -0
  18. package/input-bounds.mjs +166 -66
  19. package/lib/config.mjs +70 -33
  20. package/lib/db-writes.mjs +58 -0
  21. package/lib/declared.mjs +154 -0
  22. package/lib/hfs.mjs +61 -0
  23. package/lib/import-source.mjs +104 -0
  24. package/lib/persistence.mjs +160 -0
  25. package/lib/ports.mjs +138 -0
  26. package/lib/transport-slots.mjs +14 -0
  27. package/lib/types.mjs +133 -0
  28. package/lib/unit-spec.mjs +33 -0
  29. package/log-safety.mjs +94 -26
  30. package/module-layering.mjs +180 -297
  31. package/module-shape.mjs +296 -59
  32. package/naming.mjs +44 -12
  33. package/observability.mjs +57 -91
  34. package/operations.mjs +201 -0
  35. package/package.json +10 -7
  36. package/query-safety.mjs +189 -223
  37. package/resilience.mjs +126 -31
  38. package/runtime/engine/plain-object.mjs +5 -0
  39. package/runtime/engine/runtime-root.mjs +32 -0
  40. package/runtime/engine/yaml.mjs +161 -0
  41. package/runtime/knowledge/hfs/slots.yaml +1111 -0
  42. package/runtime/scripts/lib/git.mjs +53 -0
  43. package/runtime/scripts/lib/glob.mjs +23 -0
  44. package/runtime/scripts/lib/hfs-allows.mjs +57 -0
  45. package/runtime/scripts/lib/hfs-slots.mjs +729 -0
  46. package/runtime/scripts/lib/hfs-view.mjs +68 -0
  47. package/runtime/scripts/lib/language.mjs +107 -0
  48. package/runtime/scripts/lib/path-key.mjs +17 -0
  49. package/runtime/scripts/lib/recorded-lines.mjs +27 -0
  50. package/schema-authority.mjs +270 -155
  51. package/size-budget.mjs +19 -61
  52. package/spec-no-skip.mjs +84 -0
  53. package/spec-quality.mjs +76 -35
  54. package/suppression.mjs +34 -16
  55. package/temporal.mjs +101 -36
  56. package/test-world.mjs +204 -0
  57. package/testing.mjs +99 -251
  58. package/transport.mjs +433 -142
  59. package/type-safety.mjs +50 -278
  60. package/unit-spec.mjs +452 -0
  61. package/user-copy.mjs +56 -37
  62. package/lib/slots.mjs +0 -83
package/cqrs.mjs CHANGED
@@ -1,168 +1,459 @@
1
1
  /**
2
- * The rules that hold `cqrs.md`.
2
+ * The rules that hold BE-CONVENTION 1.5 (R87 `BE_CQRS_SHAPE`).
3
3
  *
4
- * Four of the eight rules are shapes a parser can see. The other four - where the work lives, how
5
- * thin the service is, whether the folder is split, whether an event is one the caller waits on -
6
- * are judgements, and a rule that guessed at them would fire on correct code often enough that
7
- * everybody would learn to disable it.
4
+ * The application layer is CQRS and nothing else: a typed message (`Command<R>` / `Query<R>` carrying one `params`), a
5
+ * handler that extends the `ICQRSHandler` template and implements `process`, and no use-case class, no forwarder
6
+ * service and no in-process event.
8
7
  *
9
- * The one worth explaining is CQRS-3. A handler that overrides `execute` still compiles and still
10
- * runs; the template method in the base is simply skipped. Nothing goes red, and the file stays
11
- * wrong until the day a cross-cutting concern is added to the base and silently misses it. That is
12
- * exactly the class of mistake a rule is for: invisible at the call site, expensive later.
13
- *
14
- * Rule 2 (CQRS-1, one operation one folder) was measured and NOT added here, and that absence is
15
- * itself a finding. A per-file check can only compare a filename's operation slug to its immediate
16
- * parent directory - and against this reference repository that comparison mismatched on 462 files
17
- * even after allowing an operation-prefixed suffix (`course-enroll-crypto.service.ts` beside
18
- * `course-enroll.handler.ts`). The mismatches were not CQRS-1 violations: multi-step operations
19
- * nested under a shared parent (`sign-in/init/`, `sign-in/resend/`), aggregator modules named for
20
- * their parent (`ai-balancer/balancer.module.ts`), and an unrelated lesson-content tree
21
- * (`features/mock/**`) all failed the comparison while being correct. A rule that guessed here would
22
- * have reported on more correct files than wrong ones, which is precisely the failure mode this law
23
- * warns against - it would have taught everybody to disable it. The law's own "documented" tier for
24
- * CQRS-1 is the honest answer, not a gap to close.
8
+ * Every rule reads its scope from the HFS slot view (`be.feature.application`, `be.transport.*`) and identifies what it
9
+ * judges by its TYPE or by the import that binds it: a handler is a class decorated with `CommandHandler` /
10
+ * `QueryHandler` imported from `@nestjs/cqrs`, its template is the `ICQRSHandler` declared by the `platform/cqrs`
11
+ * capability, a bus is a receiver typed `CommandBus` / `QueryBus`, an entity is a class decorated with `Entity` from
12
+ * `typeorm`. No rule matches a variable name, a class name or a directory spelled in a path pattern.
25
13
  */
14
+ import ts from "typescript"
15
+ import { decoratorCallee, importOf, isImportedFrom, moduleReferences } from "./lib/import-source.mjs"
16
+ import { hfsOf } from "./lib/hfs.mjs"
17
+ import { walk } from "./lib/ast.mjs"
18
+ import { isLoggerType } from "./lib/ports.mjs"
19
+ import { isTransportSlot } from "./lib/transport-slots.mjs"
20
+ import { isPackageType, typeOrigins, typed } from "./lib/types.mjs"
21
+
22
+ /** The slot that holds messages and handlers. */
23
+ const APPLICATION = "be.feature.application"
24
+
25
+ /** The slots that hold application-layer code (the support folder is the same layer). */
26
+ const APPLICATION_LAYER = new Set([APPLICATION, "be.feature.application.support"])
27
+
28
+ /** The package that owns the CQRS types. */
29
+ const CQRS_PACKAGE = "@nestjs/cqrs"
26
30
 
27
- import { normalizePath } from "./lib/path.mjs"
31
+ /** The base name of a filename. */
32
+ const baseName = (filename) => String(filename).replace(/\\/g, "/").split("/").pop()
28
33
 
29
- /** Decorators that mark a class as a CQRS handler. */
30
- const HANDLER_DECORATORS = /^(?:Command|Query|Events)Handler$/
34
+ /** The bus receivers of `@nestjs/cqrs`. */
35
+ const isBus = (context, node) => isPackageType(context, node, "CommandBus", CQRS_PACKAGE) || isPackageType(context, node, "QueryBus", CQRS_PACKAGE)
31
36
 
32
- /** A `<operation>.handler.ts`, and the operation it is named for. */
33
- const handlerFile = (filename) => {
34
- const hit = normalizePath(filename).match(/\/([a-z0-9-]+)\.handler\.ts$/)
35
- return hit ? hit[1] : null
37
+ /** True when a declaration file belongs to the capability `capability` of a platform slot. */
38
+ const declaredByCapability = (hfs, file, capability) => {
39
+ if (hfs.slotOf(file) !== "be.platform") return false
40
+ const owner = hfs.ownerOf(file)
41
+ return owner !== null && owner.split("/").pop() === capability
36
42
  }
37
43
 
38
- /** A `<operation>.command.ts` or `<operation>.query.ts`, and the operation it is named for. */
39
- const messageFile = (filename) => {
40
- const hit = normalizePath(filename).match(/\/([a-z0-9-]+)\.(command|query)\.ts$/)
41
- return hit ? { operation: hit[1], kind: hit[2] } : null
44
+ /** The classes a file declares. */
45
+ const classVisitors = (visit) => ({ ClassDeclaration: visit, ClassExpression: visit })
46
+
47
+ /** The constructor of a class body, or undefined. */
48
+ const constructorOf = (node) => node.body.body.find((member) => member.type === "MethodDefinition" && member.kind === "constructor")
49
+
50
+ /** The message kind (`command` / `query`) of a file in the application slot, or null. */
51
+ const messageKindOf = (context) => {
52
+ const filename = context.filename
53
+ const name = baseName(filename)
54
+ const kind = name.endsWith(".command.ts") ? "command" : name.endsWith(".query.ts") ? "query" : null
55
+ if (kind === null) return null
56
+ return hfsOf(context).slotOf(filename) === APPLICATION ? kind : null
42
57
  }
43
58
 
44
- /** Decorator names carried by a class declaration. */
45
- const decoratorNames = (node) =>
46
- (node.decorators || []).map((decorator) => {
47
- const expression = decorator.expression
48
- if (expression.type === "CallExpression" && expression.callee.type === "Identifier") return expression.callee.name
49
- if (expression.type === "Identifier") return expression.name
50
- return null
51
- }).filter(Boolean)
59
+ // -- handler-overrides-process -----------------------------------------------------------------------------------
52
60
 
53
- /** Whether a class body declares a method of the given name. */
54
- const declaredMethod = (node, name) =>
55
- (node.body.body || []).find(
56
- (member) => member.type === "MethodDefinition" && member.key && member.key.name === name,
57
- )
61
+ /** True when the class (transitively) extends the `ICQRSHandler` of `platform/cqrs`. */
62
+ const extendsTemplate = (context, node, hfs) => {
63
+ const { checker, toTs } = typed(context)
64
+ const seen = new Set()
65
+ const reaches = (type) => {
66
+ if (!type || seen.has(type)) return false
67
+ seen.add(type)
68
+ const symbol = type.getSymbol?.()
69
+ if (symbol?.getName() === "ICQRSHandler" && (symbol.getDeclarations() ?? []).some((declaration) => declaredByCapability(hfs, declaration.getSourceFile().fileName, "cqrs"))) return true
70
+ return (checker.getBaseTypes(type.target ?? type) ?? []).some(reaches)
71
+ }
72
+ return reaches(checker.getTypeAtLocation(toTs(node)))
73
+ }
58
74
 
59
- // -- CQRS-3 ----------------------------------------------------------------------------------------
75
+ /** True when `process` is still the template's abstract member (no class of the chain implemented it). */
76
+ const processIsAbstract = (context, node) => {
77
+ const { checker, toTs } = typed(context)
78
+ const symbol = checker.getPropertyOfType(checker.getTypeAtLocation(toTs(node)), "process")
79
+ const declarations = symbol?.getDeclarations() ?? []
80
+ return declarations.length === 0 || declarations.every((declaration) => (ts.getCombinedModifierFlags(declaration) & ts.ModifierFlags.Abstract) !== 0)
81
+ }
60
82
 
61
- /** A handler implements the template's `process`, never its public `execute`. */
83
+ /** A handler implements the template's `process` and never overrides its public `execute`. */
62
84
  export const handlerOverridesProcess = {
63
- meta: {
64
- type: "problem",
65
- docs: { description: "A CQRS handler overrides `process`, not `execute`." },
66
- schema: [],
67
- messages: {
68
- overridesExecute:
69
- "`{{name}}` overrides `execute`, which takes it OUT of the template method in the base handler. It compiles and it runs, so nothing goes red - and it is the one handler the next cross-cutting change (a timing, a transaction, a retry added to the base) will silently miss. Rename this to `protected override async process(...)`.",
70
- noProcess:
71
- "`{{name}}` is a CQRS handler with no `process` method. The base declares `process` abstract and calls it from `execute`; a handler that implements neither has no work to dispatch to.",
85
+ meta: {
86
+ type: "problem",
87
+ docs: { description: "A CQRS handler extends `ICQRSHandler` and overrides `process`, never `execute`." },
88
+ schema: [],
89
+ messages: {
90
+ overridesExecute: "`{{name}}` overrides `execute`, which takes it out of the template method in `ICQRSHandler` (the one place that logs and counts a failed operation). It compiles and runs, so nothing goes red. Rename it to `protected override async process(...)`.",
91
+ notTemplate: "`{{name}}` is a command or query handler that does not extend the `ICQRSHandler` of `platform/cqrs`. Extend `ICQRSHandler<Message, Result>` so every handler shares one `execute` that logs the failed operation.",
92
+ noProcess: "`{{name}}` extends `ICQRSHandler` but implements no `process`; the template declares it abstract and calls it from `execute`. Add `protected override async process(message): Promise<Result>`.",
93
+ },
72
94
  },
73
- },
74
- create(context) {
75
- return {
76
- ClassDeclaration(node) {
77
- if (!decoratorNames(node).some((name) => HANDLER_DECORATORS.test(name))) return
78
- const name = (node.id && node.id.name) || "this handler"
79
- const execute = declaredMethod(node, "execute")
80
- if (execute) {
81
- context.report({ node: execute.key, messageId: "overridesExecute", data: { name } })
82
- return
83
- }
84
- // A handler that extends anything may inherit `process` from that base, and an
85
- // intermediate abstract handler is a legitimate shape - a family of suggestion queries
86
- // that all search the same way implements `process` once and is subclassed. Measured
87
- // against real source, reporting here regardless of the superclass produced ten false
88
- // positives and three true ones, so the check applies only to a standalone class.
89
- if (node.superClass) return
90
- if (!declaredMethod(node, "process")) {
91
- context.report({ node: node.id || node, messageId: "noProcess", data: { name } })
92
- }
93
- },
95
+ create(context) {
96
+ const hfs = hfsOf(context)
97
+ if (hfs.slotOf(context.filename) !== APPLICATION) return {}
98
+ return classVisitors((node) => {
99
+ if (!(node.decorators ?? []).some((decorator) => isImportedFrom(context, decoratorCallee(decorator), CQRS_PACKAGE, ["CommandHandler", "QueryHandler"]))) return
100
+ const name = node.id?.name ?? "this handler"
101
+ const own = (member) => node.body.body.find((candidate) => candidate.type === "MethodDefinition" && candidate.key?.type === "Identifier" && candidate.key.name === member)
102
+ const execute = own("execute")
103
+ if (execute) {
104
+ context.report({ node: execute.key, messageId: "overridesExecute", data: { name } })
105
+ return
106
+ }
107
+ if (!extendsTemplate(context, node, hfs)) {
108
+ context.report({ node: node.id ?? node, messageId: "notTemplate", data: { name } })
109
+ return
110
+ }
111
+ if (!own("process") && processIsAbstract(context, node)) context.report({ node: node.id ?? node, messageId: "noProcess", data: { name } })
112
+ })
113
+ },
114
+ }
115
+
116
+ // -- handler-is-thin ---------------------------------------------------------------------------------------------
117
+
118
+ /** Statements and expressions that are a decision or a repetition: none of them belongs in a handler. */
119
+ const DECISION_NODES = new Set(["IfStatement", "SwitchStatement", "ConditionalExpression", "LogicalExpression", "ForStatement", "ForInStatement", "ForOfStatement", "WhileStatement", "DoWhileStatement", "TryStatement", "ThrowStatement"])
120
+
121
+ /** True when a type annotation is a domain service: a class named `*Service` declared in a `*.service.ts` file. */
122
+ const isServiceType = (context, annotation) => typeOrigins(context, annotation).some((origin) => origin.module === null && origin.name.endsWith("Service") && origin.file.endsWith(".service.ts"))
123
+
124
+ /** The injected members of a class: constructor parameters and decorated properties, with the type annotation of each. */
125
+ const injectedMembers = (node) => {
126
+ const members = []
127
+ const constructor = constructorOf(node)
128
+ for (const original of constructor?.value.params ?? []) {
129
+ const param = original.type === "TSParameterProperty" ? original.parameter : original
130
+ members.push({ report: original, param, name: original.type === "TSParameterProperty" && param.type === "Identifier" ? param.name : null, annotation: param.typeAnnotation?.typeAnnotation })
94
131
  }
95
- },
132
+ for (const member of node.body.body) {
133
+ if (member.type !== "PropertyDefinition" || (member.decorators ?? []).length === 0) continue
134
+ members.push({ report: member, param: member, name: member.key.type === "Identifier" ? member.key.name : null, annotation: member.typeAnnotation?.typeAnnotation })
135
+ }
136
+ return members
137
+ }
138
+
139
+ /** A handler maps its input, calls exactly one method of an injected service and returns its result. */
140
+ export const handlerIsThin = {
141
+ meta: {
142
+ type: "problem",
143
+ docs: { description: "A CQRS handler injects only `*Service` classes (and the template's Logger) and its `process` is one `return this.<service>.<method>(...)`." },
144
+ schema: [],
145
+ messages: {
146
+ dependency: "`{{what}}` is injected into a handler. A handler holds services (and the Logger the `ICQRSHandler` template needs) and nothing else: an `EntityManager`, a bus, a client or any other infrastructure is the business of a `*.service.ts`, which is the one file that is unit-tested. Move the work into a service and inject that.",
147
+ branch: "`{{what}}` is a decision or a loop in a handler. The `process` of a handler only maps its input and returns what one service method answers; every branch belongs in the service, where it is covered by the service spec.",
148
+ body: "`process` of `{{name}}` must be the single statement `return this.<service>.<method>(<mapped input>)` (with or without `await`). Any other statement is logic that no unit spec covers: move it into the service.",
149
+ call: "`process` of `{{name}}` must call exactly one method of an injected `*Service` and return its result: this makes {{count}} call(s), or calls something that is not an injected service.",
150
+ },
151
+ },
152
+ create(context) {
153
+ const hfs = hfsOf(context)
154
+ if (hfs.slotOf(context.filename) !== APPLICATION) return {}
155
+ const sourceCode = context.sourceCode
156
+ return classVisitors((node) => {
157
+ if (!(node.decorators ?? []).some((decorator) => isImportedFrom(context, decoratorCallee(decorator), CQRS_PACKAGE, ["CommandHandler", "QueryHandler"]))) return
158
+ const name = node.id?.name ?? "this handler"
159
+ const services = new Set()
160
+ for (const injected of injectedMembers(node)) {
161
+ if (injected.annotation && isServiceType(context, injected.annotation)) {
162
+ if (injected.name !== null) services.add(injected.name)
163
+ } else if (!injected.annotation || !isLoggerType(context, injected.annotation)) {
164
+ context.report({ node: injected.report, messageId: "dependency", data: { what: sourceCode.getText(injected.param) } })
165
+ }
166
+ }
167
+ const method = node.body.body.find((member) => member.type === "MethodDefinition" && member.kind === "method" && member.key.type === "Identifier" && member.key.name === "process")
168
+ const body = method?.value.body
169
+ if (!body) return
170
+ let calls = 0
171
+ walk(body, (child) => {
172
+ if (DECISION_NODES.has(child.type)) context.report({ node: child, messageId: "branch", data: { what: child.type === "LogicalExpression" ? child.operator : child.type.replace(/(Statement|Expression)$/, "").toLowerCase() } })
173
+ else if (child.type === "CallExpression") calls += 1
174
+ })
175
+ const only = body.body.length === 1 && body.body[0].type === "ReturnStatement" ? body.body[0].argument : null
176
+ if (only === null) {
177
+ context.report({ node: method.key, messageId: "body", data: { name } })
178
+ return
179
+ }
180
+ const call = only.type === "AwaitExpression" ? only.argument : only
181
+ const callee = call.type === "CallExpression" ? call.callee : null
182
+ const onService = callee?.type === "MemberExpression" && !callee.computed && callee.object.type === "MemberExpression" && !callee.object.computed && callee.object.object.type === "ThisExpression" && callee.object.property.type === "Identifier" && services.has(callee.object.property.name)
183
+ if (calls !== 1 || !onService) context.report({ node: method.key, messageId: "call", data: { name, count: String(calls) } })
184
+ })
185
+ },
96
186
  }
97
187
 
98
- // -- CQRS-2 ----------------------------------------------------------------------------------------
188
+ // -- message-carries-params-only ---------------------------------------------------------------------------------
99
189
 
100
190
  /** A message carries request context and nothing else. */
101
191
  export const messageCarriesParamsOnly = {
102
- meta: {
103
- type: "problem",
104
- docs: { description: "A command or query holds `params` and declares no logic." },
105
- schema: [],
106
- messages: {
107
- method:
108
- "`{{name}}` declares `{{member}}`. A message that computes has moved a decision into a file nobody reads for decisions, and two dispatchers of this message would then disagree about what it means. Keep the message to its `params`; compute in the handler.",
109
- shape:
110
- "`{{name}}` does not carry a single `params` field. A message is request context - the request, the user, the locale - handed to the handler whole; a message with several fields makes every dispatcher assemble it differently.",
192
+ meta: {
193
+ type: "problem",
194
+ docs: { description: "A command or query holds one `params` constructor parameter and declares nothing else." },
195
+ schema: [],
196
+ messages: {
197
+ member: "`{{name}}` declares `{{member}}`. A message that computes has moved a decision into a file nobody reads for decisions, and two dispatchers would then disagree about what it means. Keep the message to its `params`; compute in the handler.",
198
+ shape: "`{{name}}` does not carry exactly one constructor parameter named `params`. A message is the request context handed to the handler whole; several fields make every dispatcher assemble it differently.",
199
+ body: "`{{name}}` runs code in its constructor. The body is `super()` and nothing else: a default or a computed field is a decision the handler should make.",
200
+ },
111
201
  },
112
- },
113
- create(context) {
114
- const message = messageFile(context.filename || context.getFilename())
115
- if (!message) return {}
116
- return {
117
- ClassDeclaration(node) {
118
- // `.command.ts` is not one thing. A CLI framework uses the same suffix for a decorated
119
- // class with a `run` method, which is a DOOR rather than a message - measured against
120
- // real source, that family produced nineteen of this rule's twenty-one reports. A CQRS
121
- // message is a plain class; anything decorated here belongs to some other framework.
122
- if ((node.decorators || []).length > 0) return
123
- const members = node.body.body || []
124
- for (const member of members) {
125
- if (member.type !== "MethodDefinition" || member.kind === "constructor") continue
126
- context.report({
127
- node: member.key,
128
- messageId: "method",
129
- data: { name: (node.id && node.id.name) || "this message", member: member.key.name || "a method" },
130
- })
131
- }
132
- const constructor = members.find((member) => member.type === "MethodDefinition" && member.kind === "constructor")
133
- if (!constructor || !constructor.value) return
134
- const params = constructor.value.params || []
135
- const carriesParams = params.length === 1
136
- && (params[0].type === "TSParameterProperty" ? params[0].parameter : params[0]).name === "params"
137
- if (!carriesParams) {
138
- context.report({
139
- node: node.id || node,
140
- messageId: "shape",
141
- data: { name: (node.id && node.id.name) || "this message" },
142
- })
202
+ create(context) {
203
+ if (messageKindOf(context) === null) return {}
204
+ return classVisitors((node) => {
205
+ const name = node.id?.name ?? "this message"
206
+ const members = node.body.body
207
+ const constructor = constructorOf(node)
208
+ for (const member of members) {
209
+ if (member === constructor) continue
210
+ const key = member.key?.type === "Identifier" ? member.key.name : "a member"
211
+ context.report({ node: member.key ?? member, messageId: "member", data: { name, member: key } })
212
+ }
213
+ if (!constructor) {
214
+ context.report({ node: node.id ?? node, messageId: "shape", data: { name } })
215
+ return
216
+ }
217
+ const params = constructor.value.params
218
+ const first = params[0]?.type === "TSParameterProperty" ? params[0].parameter : params[0]
219
+ if (params.length !== 1 || first?.type !== "Identifier" || first.name !== "params") context.report({ node: constructor, messageId: "shape", data: { name } })
220
+ const statements = constructor.value.body?.body ?? []
221
+ const onlySuper = statements.every((statement) => statement.type === "ExpressionStatement" && statement.expression.type === "CallExpression" && statement.expression.callee.type === "Super")
222
+ if (!onlySuper) context.report({ node: constructor, messageId: "body", data: { name } })
223
+ })
224
+ },
225
+ }
226
+
227
+ // -- message-typed-result ----------------------------------------------------------------------------------------
228
+
229
+ /** A message states the type of its result: `Command<R>` / `Query<R>` from `@nestjs/cqrs`, one readonly `params`. */
230
+ export const messageTypedResult = {
231
+ meta: {
232
+ type: "problem",
233
+ docs: { description: "A message extends `Command<R>` / `Query<R>` from `@nestjs/cqrs` and declares `readonly params`." },
234
+ schema: [],
235
+ messages: {
236
+ base: "`{{name}}` must extend `{{base}}<Result>` imported from `@nestjs/cqrs`. The bus types `execute` from the message, so a message with no declared result hands every dispatcher an untyped answer.",
237
+ result: "`{{name}}` extends `{{base}}` without its result type. Write `{{base}}<{{name}}Result>`; the type argument is what `execute` returns.",
238
+ params: "`{{name}}` must declare its only constructor parameter as `readonly params: ExecuteParams<...>` (a parameter property, public, not optional, no default).",
239
+ },
240
+ },
241
+ create(context) {
242
+ const kind = messageKindOf(context)
243
+ if (kind === null) return {}
244
+ const base = kind === "command" ? "Command" : "Query"
245
+ return classVisitors((node) => {
246
+ const name = node.id?.name ?? "this message"
247
+ if (!node.superClass || !isImportedFrom(context, node.superClass, CQRS_PACKAGE, base)) {
248
+ context.report({ node: node.id ?? node, messageId: "base", data: { name, base } })
249
+ } else if ((node.superTypeArguments ?? node.superTypeParameters)?.params.length !== 1) {
250
+ context.report({ node: node.superClass, messageId: "result", data: { name, base } })
251
+ }
252
+ const constructor = constructorOf(node)
253
+ if (!constructor) return
254
+ const param = constructor.value.params[0]
255
+ const valid = constructor.value.params.length === 1
256
+ && param.type === "TSParameterProperty"
257
+ && param.readonly === true
258
+ && (param.accessibility === undefined || param.accessibility === "public")
259
+ && param.parameter.type === "Identifier"
260
+ && param.parameter.name === "params"
261
+ && param.parameter.optional !== true
262
+ if (!valid) context.report({ node: param ?? constructor, messageId: "params", data: { name } })
263
+ })
264
+ },
265
+ }
266
+
267
+ // -- execute-params-shape ----------------------------------------------------------------------------------------
268
+
269
+ /** True for a class declaration decorated with `Entity` from `typeorm`. */
270
+ const isEntityClass = (checker, declaration) => {
271
+ if (!ts.isClassDeclaration(declaration)) return false
272
+ return (ts.getDecorators(declaration) ?? []).some((decorator) => {
273
+ const call = ts.isCallExpression(decorator.expression) ? decorator.expression.expression : decorator.expression
274
+ if (!ts.isIdentifier(call)) return false
275
+ let symbol = checker.getSymbolAtLocation(call)
276
+ if (symbol && (symbol.flags & ts.SymbolFlags.Alias) !== 0) symbol = checker.getAliasedSymbol(symbol)
277
+ return symbol?.getName() === "Entity" && (symbol.getDeclarations() ?? []).some((entity) => /\/node_modules\/typeorm\//.test(entity.getSourceFile().fileName.replace(/\\/g, "/")))
278
+ })
279
+ }
280
+
281
+ /** The name of an `@Entity` class reachable from `root` through properties, index signatures, unions and type arguments, or null. */
282
+ const reachableEntity = (context, root) => {
283
+ const { checker, program } = typed(context)
284
+ const seen = new Set()
285
+ const stack = [root]
286
+ let steps = 0
287
+ while (stack.length > 0 && steps < 4000) {
288
+ steps += 1
289
+ const type = stack.pop()
290
+ if (seen.has(type)) continue
291
+ seen.add(type)
292
+ if (type.isUnionOrIntersection()) {
293
+ stack.push(...type.types)
294
+ continue
143
295
  }
144
- },
296
+ if ((type.flags & ts.TypeFlags.Object) !== 0 && (type.objectFlags & ts.ObjectFlags.Reference) !== 0) stack.push(...checker.getTypeArguments(type))
297
+ const symbol = type.getSymbol() ?? type.aliasSymbol
298
+ const declarations = symbol?.getDeclarations() ?? []
299
+ const entity = declarations.find((declaration) => isEntityClass(checker, declaration))
300
+ if (entity) return symbol.getName()
301
+ const own = declarations.some((declaration) => {
302
+ const file = declaration.getSourceFile()
303
+ return !program.isSourceFileFromExternalLibrary(file) && !program.isSourceFileDefaultLibrary(file)
304
+ })
305
+ if (!own) continue
306
+ for (const property of checker.getPropertiesOfType(type)) stack.push(checker.getTypeOfSymbol(property))
307
+ for (const info of checker.getIndexInfosOfType(type)) stack.push(info.type)
145
308
  }
146
- },
309
+ return null
310
+ }
311
+
312
+ /** True when a type annotation is a reference to the `ExecuteParams` or `PublicExecuteParams` that `platform/cqrs` declares (the symbol the name resolves to, so a local alias or lookalike is not it). */
313
+ const namesPlatformParams = (context, hfs, annotation) => {
314
+ if (annotation.type !== "TSTypeReference") return false
315
+ const { checker, toTs } = typed(context)
316
+ let symbol = checker.getSymbolAtLocation(toTs(annotation.typeName))
317
+ if (symbol && (symbol.flags & ts.SymbolFlags.Alias) !== 0) symbol = checker.getAliasedSymbol(symbol)
318
+ if (!symbol || (symbol.getName() !== "ExecuteParams" && symbol.getName() !== "PublicExecuteParams")) return false
319
+ return (symbol.getDeclarations() ?? []).some((declaration) => declaredByCapability(hfs, declaration.getSourceFile().fileName, "cqrs"))
320
+ }
321
+
322
+ /** A message's `params` is `ExecuteParams<X>` / `PublicExecuteParams<X>` of `platform/cqrs` and X holds no entity. */
323
+ export const executeParamsShape = {
324
+ meta: {
325
+ type: "problem",
326
+ docs: { description: "A message's `params` is `ExecuteParams<X>` or `PublicExecuteParams<X>` from `platform/cqrs`, and no entity is reachable from X." },
327
+ schema: [],
328
+ messages: {
329
+ shape: "The `params` of `{{name}}` must be typed `ExecuteParams<Request>` (authenticated) or `PublicExecuteParams<Request>` (`@Public`) from `platform/cqrs`. The type states whether the door is public; a hand-written shape or an optional principal hides that.",
330
+ entity: "`{{name}}` carries the ORM entity `{{entity}}` in its params. Entities never travel in a message: map the transport input to a plain request type and let the handler load the entity.",
331
+ },
332
+ },
333
+ create(context) {
334
+ if (messageKindOf(context) === null) return {}
335
+ const hfs = hfsOf(context)
336
+ return classVisitors((node) => {
337
+ const name = node.id?.name ?? "this message"
338
+ const constructor = constructorOf(node)
339
+ const param = constructor?.value.params[0]
340
+ const annotation = (param?.type === "TSParameterProperty" ? param.parameter : param)?.typeAnnotation?.typeAnnotation
341
+ if (!annotation) return
342
+ if (!namesPlatformParams(context, hfs, annotation)) {
343
+ context.report({ node: annotation, messageId: "shape", data: { name } })
344
+ return
345
+ }
346
+ const argument = (annotation.typeArguments ?? annotation.typeParameters)?.params[0]
347
+ if (!argument) return
348
+ const { checker, toTs } = typed(context)
349
+ const entity = reachableEntity(context, checker.getTypeFromTypeNode(toTs(argument)))
350
+ if (entity !== null) context.report({ node: argument, messageId: "entity", data: { name, entity } })
351
+ })
352
+ },
353
+ }
354
+
355
+ // -- no-use-case -------------------------------------------------------------------------------------------------
356
+
357
+ /** True when every method body of the class is `return this.<bus>.execute(...)` (at least one method). */
358
+ const isForwarder = (context, node) => {
359
+ const methods = node.body.body.filter((member) => member.type === "MethodDefinition" && member.kind === "method")
360
+ if (methods.length === 0) return false
361
+ return methods.every((method) => {
362
+ const statements = method.value.body?.body ?? []
363
+ if (statements.length !== 1 || statements[0].type !== "ReturnStatement") return false
364
+ const call = statements[0].argument?.type === "AwaitExpression" ? statements[0].argument.argument : statements[0].argument
365
+ return call?.type === "CallExpression" && call.callee.type === "MemberExpression" && !call.callee.computed && call.callee.property.type === "Identifier" && call.callee.property.name === "execute" && isBus(context, call.callee.object)
366
+ })
367
+ }
368
+
369
+ /** No use-case class and no forwarder service: the transport dispatches on the bus itself. */
370
+ export const noUseCase = {
371
+ meta: {
372
+ type: "problem",
373
+ docs: { description: "The application layer has no `*.use-case.ts`, no `*UseCase` class and no forwarder `<op>.service.ts`." },
374
+ schema: [],
375
+ messages: {
376
+ file: "`{{file}}` is a use-case file. The application layer is CQRS: write `<action>.command.ts` (or `.query.ts`) and `<action>.handler.ts` instead.",
377
+ klass: "`{{name}}` is a use-case class. The application layer is CQRS: write a `Command<R>` / `Query<R>` message and a handler that extends `ICQRSHandler`.",
378
+ forwarder: "`{{name}}` only forwards to a bus. A forwarder service is indirection with no decision in it: inject the bus in the resolver, controller or consumer and call `execute(new Message(...))` there, then delete this file.",
379
+ },
380
+ },
381
+ create(context) {
382
+ const hfs = hfsOf(context)
383
+ const filename = context.filename
384
+ const slot = hfs.slotOf(filename)
385
+ const visitors = {}
386
+ if (APPLICATION_LAYER.has(slot)) {
387
+ const name = baseName(filename)
388
+ visitors.Program = (node) => {
389
+ if (name.endsWith(".use-case.ts")) context.report({ node, loc: { line: 1, column: 0 }, messageId: "file", data: { file: name } })
390
+ }
391
+ visitors.ClassDeclaration = (node) => {
392
+ if (node.id && node.id.name.endsWith("UseCase")) context.report({ node: node.id, messageId: "klass", data: { name: node.id.name } })
393
+ }
394
+ }
395
+ if ((slot === APPLICATION || isTransportSlot(slot)) && baseName(filename).endsWith(".service.ts")) {
396
+ const previous = visitors.ClassDeclaration
397
+ visitors.ClassDeclaration = (node) => {
398
+ previous?.(node)
399
+ if (isForwarder(context, node)) context.report({ node: node.id ?? node, messageId: "forwarder", data: { name: node.id?.name ?? "this service" } })
400
+ }
401
+ }
402
+ return visitors
403
+ },
404
+ }
405
+
406
+ // -- no-event-bus ------------------------------------------------------------------------------------------------
407
+
408
+ /** The in-process event names of `@nestjs/cqrs`. */
409
+ const EVENT_NAMES = new Set(["EventBus", "EventsHandler", "IEventHandler"])
410
+
411
+ /** The package of the second in-process event system. */
412
+ const EVENT_EMITTER_PACKAGE = "@nestjs/event-emitter"
413
+
414
+ /** No in-process events: a side effect that must happen anyway is an outbox message. */
415
+ export const noEventBus = {
416
+ meta: {
417
+ type: "problem",
418
+ docs: { description: "`EventBus`, `@EventsHandler`, `IEventHandler` and `@nestjs/event-emitter` are not used." },
419
+ schema: [],
420
+ messages: {
421
+ event: "`{{name}}` is an in-process event mechanism. Events die with the process and run outside the transaction: write an outbox message in the transaction and consume it in `transport/message/` instead.",
422
+ },
423
+ },
424
+ create(context) {
425
+ const report = (node, name) => context.report({ node, messageId: "event", data: { name } })
426
+ return {
427
+ ...moduleReferences(({ node, source, value, names }) => {
428
+ if (value === EVENT_EMITTER_PACKAGE) report(source, value)
429
+ else if (value === CQRS_PACKAGE) for (const name of names) if (EVENT_NAMES.has(name)) report(node, name)
430
+ }),
431
+ MemberExpression(node) {
432
+ if (node.computed || node.property.type !== "Identifier" || !EVENT_NAMES.has(node.property.name)) return
433
+ if (node.object.type === "Identifier" && importOf(context, node.object)?.source === CQRS_PACKAGE && importOf(context, node.object).imported === "*") report(node, node.property.name)
434
+ },
435
+ }
436
+ },
147
437
  }
148
438
 
149
439
  /** The rules this law contributes to the plugin. */
150
440
  export const rules = {
151
- "handler-overrides-process": handlerOverridesProcess,
152
- "message-carries-params-only": messageCarriesParamsOnly,
441
+ "handler-overrides-process": handlerOverridesProcess,
442
+ "handler-is-thin": handlerIsThin,
443
+ "message-carries-params-only": messageCarriesParamsOnly,
444
+ "message-typed-result": messageTypedResult,
445
+ "execute-params-shape": executeParamsShape,
446
+ "no-use-case": noUseCase,
447
+ "no-event-bus": noEventBus,
153
448
  }
154
449
 
155
- /**
156
- * The level this law asks for, as the plugin's own opinion.
157
- *
158
- * Both measured at zero debt in the reference repository (3 of 141 handlers overrode `execute` and
159
- * 2 of 138 messages carried more than `params`; both were burned down), so both are `error`.
160
- *
161
- * The twin-spec count rule and the encoded-failure rule are gone. Test selection follows behavior and
162
- * risk, not a filename count; and HFS returns an expected refusal as a typed outcome union, so a
163
- * handler that returns `{ success: false }` is no longer a defect this canon can call by shape.
164
- */
450
+ /** Every rule of this law at `error`. */
165
451
  export const recommended = {
166
- "starci-be/handler-overrides-process": "error",
167
- "starci-be/message-carries-params-only": "error",
452
+ "starci-be/handler-overrides-process": "error",
453
+ "starci-be/handler-is-thin": "error",
454
+ "starci-be/message-carries-params-only": "error",
455
+ "starci-be/message-typed-result": "error",
456
+ "starci-be/execute-params-shape": "error",
457
+ "starci-be/no-use-case": "error",
458
+ "starci-be/no-event-bus": "error",
168
459
  }