@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.
- package/CHANGELOG.md +95 -9
- package/README.md +41 -52
- package/async-discipline.mjs +2 -2
- package/authorization.mjs +38 -88
- package/builders.mjs +305 -0
- package/comments.mjs +19 -34
- package/config-owner.mjs +127 -125
- package/connections.mjs +254 -0
- package/cqrs.mjs +426 -135
- package/data-access.mjs +320 -391
- package/default-deny.mjs +14 -10
- package/e2e-flow.mjs +208 -180
- package/error-handling.mjs +254 -128
- package/idempotency.mjs +98 -67
- package/index.mjs +32 -9
- package/infra-owner.mjs +96 -0
- package/injection.mjs +602 -0
- package/input-bounds.mjs +166 -66
- package/lib/config.mjs +70 -33
- package/lib/db-writes.mjs +58 -0
- package/lib/declared.mjs +154 -0
- package/lib/hfs.mjs +61 -0
- package/lib/import-source.mjs +104 -0
- package/lib/persistence.mjs +160 -0
- package/lib/ports.mjs +138 -0
- package/lib/transport-slots.mjs +14 -0
- package/lib/types.mjs +133 -0
- package/lib/unit-spec.mjs +33 -0
- package/log-safety.mjs +94 -26
- package/module-layering.mjs +180 -297
- package/module-shape.mjs +296 -59
- package/naming.mjs +44 -12
- package/observability.mjs +57 -91
- package/operations.mjs +201 -0
- package/package.json +10 -7
- package/query-safety.mjs +189 -223
- package/resilience.mjs +126 -31
- package/runtime/engine/plain-object.mjs +5 -0
- package/runtime/engine/runtime-root.mjs +32 -0
- package/runtime/engine/yaml.mjs +161 -0
- package/runtime/knowledge/hfs/slots.yaml +1111 -0
- package/runtime/scripts/lib/git.mjs +53 -0
- package/runtime/scripts/lib/glob.mjs +23 -0
- package/runtime/scripts/lib/hfs-allows.mjs +57 -0
- package/runtime/scripts/lib/hfs-slots.mjs +729 -0
- package/runtime/scripts/lib/hfs-view.mjs +68 -0
- package/runtime/scripts/lib/language.mjs +107 -0
- package/runtime/scripts/lib/path-key.mjs +17 -0
- package/runtime/scripts/lib/recorded-lines.mjs +27 -0
- package/schema-authority.mjs +270 -155
- package/size-budget.mjs +19 -61
- package/spec-no-skip.mjs +84 -0
- package/spec-quality.mjs +76 -35
- package/suppression.mjs +34 -16
- package/temporal.mjs +101 -36
- package/test-world.mjs +204 -0
- package/testing.mjs +99 -251
- package/transport.mjs +433 -142
- package/type-safety.mjs +50 -278
- package/unit-spec.mjs +452 -0
- package/user-copy.mjs +56 -37
- package/lib/slots.mjs +0 -83
package/cqrs.mjs
CHANGED
|
@@ -1,168 +1,459 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The rules that hold `
|
|
2
|
+
* The rules that hold BE-CONVENTION 1.5 (R87 `BE_CQRS_SHAPE`).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
-
|
|
31
|
+
/** The base name of a filename. */
|
|
32
|
+
const baseName = (filename) => String(filename).replace(/\\/g, "/").split("/").pop()
|
|
28
33
|
|
|
29
|
-
/**
|
|
30
|
-
const
|
|
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
|
-
/**
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
/**
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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
|
|
83
|
+
/** A handler implements the template's `process` and never overrides its public `execute`. */
|
|
62
84
|
export const handlerOverridesProcess = {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
// --
|
|
188
|
+
// -- message-carries-params-only ---------------------------------------------------------------------------------
|
|
99
189
|
|
|
100
190
|
/** A message carries request context and nothing else. */
|
|
101
191
|
export const messageCarriesParamsOnly = {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
152
|
-
|
|
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
|
-
|
|
167
|
-
|
|
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
|
}
|