@starci/eslint-canon-be 1.2.1 → 1.2.2
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 +7 -0
- package/README.md +70 -69
- package/authorization.mjs +106 -106
- package/cdc.mjs +150 -150
- package/comments.mjs +388 -389
- package/cqrs.mjs +302 -303
- package/data-access.mjs +306 -307
- package/e2e-flow.mjs +222 -222
- package/event-delivery.mjs +93 -93
- package/exception-identity.mjs +200 -200
- package/exceptions.mjs +262 -265
- package/index.mjs +107 -107
- package/lib/path.mjs +2 -0
- package/module-layering.mjs +426 -432
- package/naming.mjs +219 -220
- package/observability.mjs +275 -275
- package/package.json +4 -2
- package/testing.mjs +467 -452
- package/transport.mjs +168 -168
- package/type-safety.mjs +510 -511
package/cqrs.mjs
CHANGED
|
@@ -1,303 +1,302 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The rules that hold `cqrs.md`.
|
|
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.
|
|
8
|
-
*
|
|
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.
|
|
25
|
-
*
|
|
26
|
-
* Rule 5 (CQRS-5) is the opposite case: a handler returning `{ success: false }` or `{ error }`
|
|
27
|
-
* instead of throwing is a shape a parser CAN see, narrowly, without guessing at intent - and this
|
|
28
|
-
* reference repository has two real handlers doing exactly that today.
|
|
29
|
-
*/
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
if (expression.type === "
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
|
|
94
|
-
if (node
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
type: "
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
const
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
if (
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
const
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
//
|
|
259
|
-
//
|
|
260
|
-
//
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
"
|
|
274
|
-
"
|
|
275
|
-
"handler-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
*
|
|
283
|
-
*
|
|
284
|
-
*
|
|
285
|
-
*
|
|
286
|
-
*
|
|
287
|
-
*
|
|
288
|
-
*
|
|
289
|
-
*
|
|
290
|
-
*
|
|
291
|
-
*
|
|
292
|
-
*
|
|
293
|
-
*
|
|
294
|
-
*
|
|
295
|
-
*
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
"starci-be/
|
|
300
|
-
"starci-be/
|
|
301
|
-
"starci-be/handler-
|
|
302
|
-
|
|
303
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* The rules that hold `cqrs.md`.
|
|
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.
|
|
8
|
+
*
|
|
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.
|
|
25
|
+
*
|
|
26
|
+
* Rule 5 (CQRS-5) is the opposite case: a handler returning `{ success: false }` or `{ error }`
|
|
27
|
+
* instead of throwing is a shape a parser CAN see, narrowly, without guessing at intent - and this
|
|
28
|
+
* reference repository has two real handlers doing exactly that today.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { normalizePath } from "./lib/path.mjs"
|
|
32
|
+
|
|
33
|
+
/** Decorators that mark a class as a CQRS handler. */
|
|
34
|
+
const HANDLER_DECORATORS = /^(?:Command|Query|Events)Handler$/
|
|
35
|
+
|
|
36
|
+
/** A `<operation>.handler.ts`, and the operation it is named for. */
|
|
37
|
+
const handlerFile = (filename) => {
|
|
38
|
+
const hit = normalizePath(filename).match(/\/([a-z0-9-]+)\.handler\.ts$/)
|
|
39
|
+
return hit ? hit[1] : null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A `<operation>.command.ts` or `<operation>.query.ts`, and the operation it is named for. */
|
|
43
|
+
const messageFile = (filename) => {
|
|
44
|
+
const hit = normalizePath(filename).match(/\/([a-z0-9-]+)\.(command|query)\.ts$/)
|
|
45
|
+
return hit ? { operation: hit[1], kind: hit[2] } : null
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Decorator names carried by a class declaration. */
|
|
49
|
+
const decoratorNames = (node) =>
|
|
50
|
+
(node.decorators || []).map((decorator) => {
|
|
51
|
+
const expression = decorator.expression
|
|
52
|
+
if (expression.type === "CallExpression" && expression.callee.type === "Identifier") return expression.callee.name
|
|
53
|
+
if (expression.type === "Identifier") return expression.name
|
|
54
|
+
return null
|
|
55
|
+
}).filter(Boolean)
|
|
56
|
+
|
|
57
|
+
/** Whether a class body declares a method of the given name. */
|
|
58
|
+
const declaredMethod = (node, name) =>
|
|
59
|
+
(node.body.body || []).find(
|
|
60
|
+
(member) => member.type === "MethodDefinition" && member.key && member.key.name === name,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
// -- CQRS-3 ----------------------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
/** A handler implements the template's `process`, never its public `execute`. */
|
|
66
|
+
export const handlerOverridesProcess = {
|
|
67
|
+
meta: {
|
|
68
|
+
type: "problem",
|
|
69
|
+
docs: { description: "A CQRS handler overrides `process`, not `execute`." },
|
|
70
|
+
schema: [],
|
|
71
|
+
messages: {
|
|
72
|
+
overridesExecute:
|
|
73
|
+
"`{{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(...)`.",
|
|
74
|
+
noProcess:
|
|
75
|
+
"`{{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.",
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
create(context) {
|
|
79
|
+
return {
|
|
80
|
+
ClassDeclaration(node) {
|
|
81
|
+
if (!decoratorNames(node).some((name) => HANDLER_DECORATORS.test(name))) return
|
|
82
|
+
const name = (node.id && node.id.name) || "this handler"
|
|
83
|
+
const execute = declaredMethod(node, "execute")
|
|
84
|
+
if (execute) {
|
|
85
|
+
context.report({ node: execute.key, messageId: "overridesExecute", data: { name } })
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
// A handler that extends anything may inherit `process` from that base, and an
|
|
89
|
+
// intermediate abstract handler is a legitimate shape - a family of suggestion queries
|
|
90
|
+
// that all search the same way implements `process` once and is subclassed. Measured
|
|
91
|
+
// against real source, reporting here regardless of the superclass produced ten false
|
|
92
|
+
// positives and three true ones, so the check applies only to a standalone class.
|
|
93
|
+
if (node.superClass) return
|
|
94
|
+
if (!declaredMethod(node, "process")) {
|
|
95
|
+
context.report({ node: node.id || node, messageId: "noProcess", data: { name } })
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// -- CQRS-2 ----------------------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
/** A message carries request context and nothing else. */
|
|
105
|
+
export const messageCarriesParamsOnly = {
|
|
106
|
+
meta: {
|
|
107
|
+
type: "problem",
|
|
108
|
+
docs: { description: "A command or query holds `params` and declares no logic." },
|
|
109
|
+
schema: [],
|
|
110
|
+
messages: {
|
|
111
|
+
method:
|
|
112
|
+
"`{{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.",
|
|
113
|
+
shape:
|
|
114
|
+
"`{{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.",
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
create(context) {
|
|
118
|
+
const message = messageFile(context.filename || context.getFilename())
|
|
119
|
+
if (!message) return {}
|
|
120
|
+
return {
|
|
121
|
+
ClassDeclaration(node) {
|
|
122
|
+
// `.command.ts` is not one thing. A CLI framework uses the same suffix for a decorated
|
|
123
|
+
// class with a `run` method, which is a DOOR rather than a message - measured against
|
|
124
|
+
// real source, that family produced nineteen of this rule's twenty-one reports. A CQRS
|
|
125
|
+
// message is a plain class; anything decorated here belongs to some other framework.
|
|
126
|
+
if ((node.decorators || []).length > 0) return
|
|
127
|
+
const members = node.body.body || []
|
|
128
|
+
for (const member of members) {
|
|
129
|
+
if (member.type !== "MethodDefinition" || member.kind === "constructor") continue
|
|
130
|
+
context.report({
|
|
131
|
+
node: member.key,
|
|
132
|
+
messageId: "method",
|
|
133
|
+
data: { name: (node.id && node.id.name) || "this message", member: member.key.name || "a method" },
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
const constructor = members.find((member) => member.type === "MethodDefinition" && member.kind === "constructor")
|
|
137
|
+
if (!constructor || !constructor.value) return
|
|
138
|
+
const params = constructor.value.params || []
|
|
139
|
+
const carriesParams = params.length === 1
|
|
140
|
+
&& (params[0].type === "TSParameterProperty" ? params[0].parameter : params[0]).name === "params"
|
|
141
|
+
if (!carriesParams) {
|
|
142
|
+
context.report({
|
|
143
|
+
node: node.id || node,
|
|
144
|
+
messageId: "shape",
|
|
145
|
+
data: { name: (node.id && node.id.name) || "this message" },
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// -- CQRS-7 ----------------------------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* A handler has its twin spec beside it.
|
|
157
|
+
*
|
|
158
|
+
* The check is the FILENAME, not the disk: a rule that stats the filesystem reports differently
|
|
159
|
+
* depending on what else is checked out, and a rule whose answer depends on the working tree is one
|
|
160
|
+
* nobody can reproduce. This rule fires on the handler and names the file that should exist; a gate
|
|
161
|
+
* that walks the tree is what counts them.
|
|
162
|
+
*/
|
|
163
|
+
export const handlerHasTwinSpec = {
|
|
164
|
+
meta: {
|
|
165
|
+
type: "problem",
|
|
166
|
+
docs: { description: "A CQRS handler declares its twin spec filename in the same folder." },
|
|
167
|
+
schema: [
|
|
168
|
+
{
|
|
169
|
+
type: "object",
|
|
170
|
+
properties: { specs: { type: "array", items: { type: "string" } } },
|
|
171
|
+
additionalProperties: false,
|
|
172
|
+
},
|
|
173
|
+
],
|
|
174
|
+
messages: {
|
|
175
|
+
missing:
|
|
176
|
+
"`{{operation}}.handler.ts` has no `{{operation}}.handler.spec.ts` beside it. The decisions live in the handler, so an untested handler is an untested decision - and a spec in the same folder is found by whoever edits the handler rather than by whoever goes looking in a test tree.",
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
create(context) {
|
|
180
|
+
const filename = context.filename || context.getFilename()
|
|
181
|
+
const operation = handlerFile(filename)
|
|
182
|
+
if (!operation) return {}
|
|
183
|
+
const options = context.options && context.options[0]
|
|
184
|
+
const known = options && Array.isArray(options.specs) ? options.specs : null
|
|
185
|
+
if (!known) return {}
|
|
186
|
+
if (known.includes(`${operation}.handler.spec.ts`)) return {}
|
|
187
|
+
return {
|
|
188
|
+
"Program:exit"(node) {
|
|
189
|
+
context.report({ node, messageId: "missing", data: { operation } })
|
|
190
|
+
},
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// -- CQRS-5 ----------------------------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
/** The property name that turns a returned object into an encoded failure, or null. */
|
|
198
|
+
const encodedFailureKey = (node) => {
|
|
199
|
+
if (!node || node.type !== "ObjectExpression") return null
|
|
200
|
+
for (const prop of node.properties || []) {
|
|
201
|
+
if (prop.type !== "Property") continue
|
|
202
|
+
const key = prop.key
|
|
203
|
+
const name = key.type === "Identifier" ? key.name : key.type === "Literal" && typeof key.value === "string" ? key.value : null
|
|
204
|
+
if (!name) continue
|
|
205
|
+
// `error` is the encode regardless of what carries it - a caught exception, a message string,
|
|
206
|
+
// an upstream response. Its presence on a RETURNED object is the tell: the branch that found
|
|
207
|
+
// the failure chose to hand it back instead of throwing it.
|
|
208
|
+
if (name === "error") return name
|
|
209
|
+
// `success`/`ok` only encode failure at `false` - `success: true` on the happy path is not
|
|
210
|
+
// this pattern, and flagging it would report the correct half of every handler that uses this
|
|
211
|
+
// shape at all.
|
|
212
|
+
if ((name === "success" || name === "ok") && prop.value && prop.value.type === "Literal" && prop.value.value === false) {
|
|
213
|
+
return name
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return null
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** The nearest enclosing method named `process`, or null. */
|
|
220
|
+
const enclosingProcessMethod = (node) => {
|
|
221
|
+
for (let current = node.parent; current; current = current.parent) {
|
|
222
|
+
if (current.type === "MethodDefinition" && current.key && current.key.name === "process") return current
|
|
223
|
+
}
|
|
224
|
+
return null
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** The nearest enclosing class declaration, or null. */
|
|
228
|
+
const enclosingClass = (node) => {
|
|
229
|
+
for (let current = node.parent; current; current = current.parent) {
|
|
230
|
+
if (current.type === "ClassDeclaration") return current
|
|
231
|
+
}
|
|
232
|
+
return null
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** A handler that cannot do its work throws the domain exception naming why - it does not return one. */
|
|
236
|
+
export const noHandlerEncodedFailure = {
|
|
237
|
+
meta: {
|
|
238
|
+
type: "problem",
|
|
239
|
+
docs: {
|
|
240
|
+
description:
|
|
241
|
+
"A CQRS handler throws the domain exception that names a failure; it does not return a success/error shape (Law 5, CQRS-5).",
|
|
242
|
+
},
|
|
243
|
+
schema: [],
|
|
244
|
+
messages: {
|
|
245
|
+
encoded:
|
|
246
|
+
"`{{name}}`'s `process` returns an object carrying `{{key}}` instead of throwing. CQRS-5: a handler that cannot do its work throws the domain exception naming why; an encoded `{{key}}` field asks every caller to decode this shape its own way, and the REASON for the refusal never arrives - only the fact that there was one. Throw the exception that names why, in the branch that discovers it, and let the success path return the value itself.",
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
create(context) {
|
|
250
|
+
const filename = context.filename || context.getFilename()
|
|
251
|
+
const operation = handlerFile(filename)
|
|
252
|
+
if (!operation) return {}
|
|
253
|
+
return {
|
|
254
|
+
ReturnStatement(node) {
|
|
255
|
+
const key = encodedFailureKey(node.argument)
|
|
256
|
+
if (!key) return
|
|
257
|
+
// Scoped to `process` on a decorated handler, same as CQRS-3 - a private mapper in the
|
|
258
|
+
// same file returning an unrelated object shaped like `{ error: ... }` is not this
|
|
259
|
+
// handler's refusal path, and a rule that fired anywhere in the file would not be able to
|
|
260
|
+
// tell the two apart.
|
|
261
|
+
if (!enclosingProcessMethod(node)) return
|
|
262
|
+
const klass = enclosingClass(node)
|
|
263
|
+
if (!klass || !decoratorNames(klass).some((name) => HANDLER_DECORATORS.test(name))) return
|
|
264
|
+
context.report({ node: node.argument, messageId: "encoded", data: { name: operation, key } })
|
|
265
|
+
},
|
|
266
|
+
}
|
|
267
|
+
},
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** The rules this law contributes to the plugin. */
|
|
271
|
+
export const rules = {
|
|
272
|
+
"handler-overrides-process": handlerOverridesProcess,
|
|
273
|
+
"message-carries-params-only": messageCarriesParamsOnly,
|
|
274
|
+
"handler-has-twin-spec": handlerHasTwinSpec,
|
|
275
|
+
"no-handler-encoded-failure": noHandlerEncodedFailure,
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* The level this law asks for, as the plugin's own opinion.
|
|
280
|
+
*
|
|
281
|
+
* MEASURED AGAINST THE REFERENCE REPOSITORY, not estimated: 3 of 141 handlers override `execute`,
|
|
282
|
+
* 2 of 138 messages carry something other than a single `params`, and 2 of 152 handlers return an
|
|
283
|
+
* encoded failure (`sync-flashcard-quiz-session-progress.handler.ts` and
|
|
284
|
+
* `sync-mock-interview-session-turns.handler.ts` both `return { success: false }` where a late sync
|
|
285
|
+
* finds its session already closed). Debt above zero means the rule lands at `warn` with the count
|
|
286
|
+
* beside it, gets burned down, and flips to `error` at zero - shipping at `error` with debt
|
|
287
|
+
* outstanding blocks every commit that touches an offender.
|
|
288
|
+
*
|
|
289
|
+
* COUNT ONLY THIS RULE'S REPORTS when measuring. Inline `eslint-disable` comments in the source
|
|
290
|
+
* refer to rules a minimal measuring config never loads, and eslint reports each of those as a
|
|
291
|
+
* problem of its own - which inflated the first measurement of every rule here by the same seven
|
|
292
|
+
* files, and this rule's by two.
|
|
293
|
+
*
|
|
294
|
+
* `handler-has-twin-spec` is off by default because it needs the folder listing passed as an
|
|
295
|
+
* option; a repository that wires it supplies the listing from its own gate.
|
|
296
|
+
*/
|
|
297
|
+
export const recommended = {
|
|
298
|
+
"starci-be/handler-overrides-process": "error", // no=0 of 141 - burned down from 3
|
|
299
|
+
"starci-be/message-carries-params-only": "error", // no=0 of 138 - burned down from 2
|
|
300
|
+
"starci-be/handler-has-twin-spec": "off", // needs the folder listing as an option; a repo that wires it turns this on
|
|
301
|
+
"starci-be/no-handler-encoded-failure": "warn", // no=2 of 152 handlers - debt outstanding, burn down before flipping to error
|
|
302
|
+
}
|