@starci/eslint-canon-be 1.1.0 → 1.2.1
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/cdc.mjs +150 -44
- package/comments.mjs +147 -1
- package/cqrs.mjs +103 -7
- package/data-access.mjs +144 -10
- package/e2e-flow.mjs +59 -9
- package/event-delivery.mjs +93 -40
- package/module-layering.mjs +268 -7
- package/naming.mjs +80 -3
- package/observability.mjs +140 -6
- package/package.json +1 -1
- package/testing.mjs +184 -6
- package/transport.mjs +168 -103
- package/type-safety.mjs +373 -8
package/cdc.mjs
CHANGED
|
@@ -1,44 +1,150 @@
|
|
|
1
|
-
const isProjectionListener = (filename) => /projection\.listener\.ts$/.test(
|
|
2
|
-
String(filename || "").replace(/\\/g, "/"),
|
|
3
|
-
) && !String(filename || "").replace(/\\/g, "/").endsWith("/abstract-projection.listener.ts")
|
|
4
|
-
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
1
|
+
const isProjectionListener = (filename) => /projection\.listener\.ts$/.test(
|
|
2
|
+
String(filename || "").replace(/\\/g, "/"),
|
|
3
|
+
) && !String(filename || "").replace(/\\/g, "/").endsWith("/abstract-projection.listener.ts")
|
|
4
|
+
|
|
5
|
+
const isProjectionService = (filename) => /-projection\.service\.ts$/.test(
|
|
6
|
+
String(filename || "").replace(/\\/g, "/"),
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
const memberName = (member) => member.key && (member.key.name || member.key.value)
|
|
10
|
+
|
|
11
|
+
/** A groupId literal is safe: a plain string, or a template with no `${}` substitution. */
|
|
12
|
+
const isStaticStringValue = (node) => {
|
|
13
|
+
if (!node) return false
|
|
14
|
+
if (node.type === "Literal" && typeof node.value === "string") return true
|
|
15
|
+
if (node.type === "TemplateLiteral" && node.expressions.length === 0) return true
|
|
16
|
+
return false
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Flatten a function's parameter list to the names it binds, through destructuring/defaults/rest. */
|
|
20
|
+
const collectParamNames = (params, names = []) => {
|
|
21
|
+
for (const param of params || []) {
|
|
22
|
+
if (!param) continue
|
|
23
|
+
if (param.type === "Identifier") {
|
|
24
|
+
names.push(param.name)
|
|
25
|
+
} else if (param.type === "AssignmentPattern") {
|
|
26
|
+
collectParamNames([param.left], names)
|
|
27
|
+
} else if (param.type === "RestElement") {
|
|
28
|
+
collectParamNames([param.argument], names)
|
|
29
|
+
} else if (param.type === "ObjectPattern") {
|
|
30
|
+
for (const prop of param.properties || []) {
|
|
31
|
+
if (prop.type === "Property" && prop.key) {
|
|
32
|
+
names.push(prop.key.name || prop.key.value)
|
|
33
|
+
} else if (prop.type === "RestElement") {
|
|
34
|
+
collectParamNames([prop.argument], names)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return names
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const DELTA_LIKE_PARAMS = new Set(["amount", "delta", "points", "increment"])
|
|
43
|
+
|
|
44
|
+
/** A projection listener declares mapping while AbstractProjectionListener owns Kafka lifecycle. */
|
|
45
|
+
export const projectionListenerContract = {
|
|
46
|
+
meta: {
|
|
47
|
+
type: "problem",
|
|
48
|
+
docs: { description: "Projection CDC listeners share one Kafka lifecycle contract." },
|
|
49
|
+
schema: [],
|
|
50
|
+
messages: {
|
|
51
|
+
base: "A projection CDC listener extends AbstractProjectionListener; the shared base owns subscription, envelope parsing and failure isolation.",
|
|
52
|
+
member: "A projection CDC listener must declare `{{name}}`.",
|
|
53
|
+
lifecycle: "A concrete projection listener must not override `onModuleInit`; Kafka lifecycle belongs to AbstractProjectionListener.",
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
create(context) {
|
|
57
|
+
if (!isProjectionListener(context.filename || context.getFilename())) return {}
|
|
58
|
+
return {
|
|
59
|
+
ClassDeclaration(node) {
|
|
60
|
+
if (!node.superClass || node.superClass.name !== "AbstractProjectionListener") {
|
|
61
|
+
context.report({ node: node.id || node, messageId: "base" })
|
|
62
|
+
}
|
|
63
|
+
const members = node.body.body || []
|
|
64
|
+
const names = new Set(members.map(memberName))
|
|
65
|
+
for (const name of ["groupId", "topics", "deriveTargets", "recomputeTarget"]) {
|
|
66
|
+
if (!names.has(name)) {
|
|
67
|
+
context.report({ node: node.id || node, messageId: "member", data: { name } })
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const lifecycle = members.find((member) => memberName(member) === "onModuleInit")
|
|
71
|
+
if (lifecycle) context.report({ node: lifecycle.key, messageId: "lifecycle" })
|
|
72
|
+
},
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** CDC Rule 3 (CDC-2): a projection's consumer group id is a constant, not computed at boot. */
|
|
78
|
+
export const noDynamicProjectionGroupId = {
|
|
79
|
+
meta: {
|
|
80
|
+
type: "problem",
|
|
81
|
+
docs: {
|
|
82
|
+
description: "CDC Rule 3 (CDC-2): `groupId` is a stable string literal, not a value computed at boot.",
|
|
83
|
+
},
|
|
84
|
+
schema: [],
|
|
85
|
+
messages: {
|
|
86
|
+
dynamic: "CDC Rule 3 (CDC-2): `groupId` must be a constant string literal. A template substitution, a call (`Date.now()`, `randomUUID()`, `hostname()`), or an env/instance read makes the consumer group unstable across restarts, so each restart replays the whole history in a fresh group instead of resuming from the committed offset.",
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
create(context) {
|
|
90
|
+
if (!isProjectionListener(context.filename || context.getFilename())) return {}
|
|
91
|
+
return {
|
|
92
|
+
PropertyDefinition(node) {
|
|
93
|
+
if (memberName(node) !== "groupId") return
|
|
94
|
+
if (node.value && !isStaticStringValue(node.value)) {
|
|
95
|
+
context.report({ node: node.value, messageId: "dynamic" })
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** CDC Rules 1 and 6 (CDC-4): recompute rebuilds from source by UPSERT, never by applying a delta. */
|
|
103
|
+
export const projectionRecomputeMustUpsert = {
|
|
104
|
+
meta: {
|
|
105
|
+
type: "problem",
|
|
106
|
+
docs: {
|
|
107
|
+
description: "CDC Rules 1 and 6 (CDC-4): a projection recomputes from source rows by UPSERT; it is never updated by applying an event's delta.",
|
|
108
|
+
},
|
|
109
|
+
schema: [],
|
|
110
|
+
messages: {
|
|
111
|
+
delta: "CDC Rules 1 and 6 (CDC-4): `recompute` must not take a `{{name}}` parameter. Recompute takes only the target's identity and rebuilds the value from authoritative source rows -- a delta/amount/points parameter means a duplicate delivery doubles the projection and a lost delivery never heals.",
|
|
112
|
+
upsert: "CDC Rules 1 and 6 (CDC-4): a projection service with a `recompute` method must write through an UPSERT (`ON CONFLICT`). A value assembled without one is not safe to replay, and replay is the one guarantee a broker actually offers.",
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
create(context) {
|
|
116
|
+
if (!isProjectionService(context.filename || context.getFilename())) return {}
|
|
117
|
+
let hasRecompute = false
|
|
118
|
+
return {
|
|
119
|
+
MethodDefinition(node) {
|
|
120
|
+
if (memberName(node) !== "recompute") return
|
|
121
|
+
hasRecompute = true
|
|
122
|
+
const params = collectParamNames(node.value.params)
|
|
123
|
+
for (const name of params) {
|
|
124
|
+
if (DELTA_LIKE_PARAMS.has(String(name).toLowerCase())) {
|
|
125
|
+
context.report({ node, messageId: "delta", data: { name } })
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
"Program:exit"(node) {
|
|
130
|
+
if (!hasRecompute) return
|
|
131
|
+
const source = context.sourceCode || context.getSourceCode()
|
|
132
|
+
if (!/on\s*conflict/i.test(source.getText())) {
|
|
133
|
+
context.report({ node, messageId: "upsert" })
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export const rules = {
|
|
141
|
+
"projection-listener-contract": projectionListenerContract,
|
|
142
|
+
"no-dynamic-projection-group-id": noDynamicProjectionGroupId,
|
|
143
|
+
"projection-recompute-must-upsert": projectionRecomputeMustUpsert,
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export const recommended = {
|
|
147
|
+
"starci-be/projection-listener-contract": "error",
|
|
148
|
+
"starci-be/no-dynamic-projection-group-id": "error",
|
|
149
|
+
"starci-be/projection-recompute-must-upsert": "error",
|
|
150
|
+
}
|
package/comments.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The rules that hold `comments.md`.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Five rules, and each is careful about a different false positive:
|
|
5
5
|
*
|
|
6
6
|
* - `require-export-jsdoc` skips plain data constants. `export const MAX_ATTEMPTS = 3` is already
|
|
7
7
|
* fully described by its own name, and demanding a sentence there produces sentences that
|
|
@@ -10,6 +10,15 @@
|
|
|
10
10
|
* consequence. That half is read by a person, and the rule says so rather than pretending.
|
|
11
11
|
* - `no-non-ascii-source` exempts a marked line, because text a program matches on or emits is
|
|
12
12
|
* data wearing prose, and translating it breaks the program.
|
|
13
|
+
* - `require-vn-ok-reason` (law 6) holds the half `no-non-ascii-source` cannot: that marker is a
|
|
14
|
+
* bare escape hatch unless it says why the line stays, so a bare `vn-ok` is treated the same as
|
|
15
|
+
* no marker at all.
|
|
16
|
+
* - `no-restated-name-jsdoc` (law 7) holds the decidable slice of law 3 - a doc block whose only
|
|
17
|
+
* content is the declared name re-spelled in words teaches nothing beyond the import line, so it
|
|
18
|
+
* is COMMENT-3's violation wearing COMMENT-1's shape. It fires only on an exact match between the
|
|
19
|
+
* doc's content words and the name's own words, so a doc that adds any real information is left
|
|
20
|
+
* alone - the rest of law 3 (whether a sentence explains something outside the line) is not
|
|
21
|
+
* decidable and stays a human read.
|
|
13
22
|
*/
|
|
14
23
|
|
|
15
24
|
/** Forward-slash form of a filename, so Windows paths compare like every other path. */
|
|
@@ -221,11 +230,146 @@ export const noNonAsciiSource = {
|
|
|
221
230
|
},
|
|
222
231
|
}
|
|
223
232
|
|
|
233
|
+
// -- law 6 -------------------------------------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
/** The line-level test for law 6: `vn-ok` present, with a colon and at least one reason character after it. */
|
|
236
|
+
const REASON_AFTER_MARKER = /\bvn-ok\s*:\s*\S/
|
|
237
|
+
|
|
238
|
+
/** Every `vn-ok` marker carries a reason; a bare marker is not an exemption. */
|
|
239
|
+
export const requireVnOkReason = {
|
|
240
|
+
meta: {
|
|
241
|
+
type: "problem",
|
|
242
|
+
docs: {
|
|
243
|
+
description:
|
|
244
|
+
"COMMENT-5, law 6: a `vn-ok` marker carries a reason - a bare marker is not an exemption.",
|
|
245
|
+
},
|
|
246
|
+
schema: [],
|
|
247
|
+
messages: {
|
|
248
|
+
bareMarker:
|
|
249
|
+
"`vn-ok` on this line has no reason after it. The marker exists so the next sweep can read WHY the line stays instead of \"fixing\" it into a bug - a bare marker is only a way of switching that check off. Write `vn-ok: <reason>` and say what the marker is protecting.",
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
create(context) {
|
|
253
|
+
const sourceCode = context.sourceCode || context.getSourceCode()
|
|
254
|
+
return {
|
|
255
|
+
"Program:exit"(node) {
|
|
256
|
+
const lines = sourceCode.getLines()
|
|
257
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
258
|
+
const line = lines[index]
|
|
259
|
+
if (!KEEP_MARKER.test(line)) continue
|
|
260
|
+
if (REASON_AFTER_MARKER.test(line)) continue
|
|
261
|
+
context.report({
|
|
262
|
+
node,
|
|
263
|
+
loc: { line: index + 1, column: 0 },
|
|
264
|
+
messageId: "bareMarker",
|
|
265
|
+
})
|
|
266
|
+
}
|
|
267
|
+
},
|
|
268
|
+
}
|
|
269
|
+
},
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// -- law 7 -------------------------------------------------------------------------------------
|
|
273
|
+
|
|
274
|
+
/** Splits a declared name into its lowercase words, so `readUser` compares against "read user". */
|
|
275
|
+
const wordsOf = (name) =>
|
|
276
|
+
(String(name || "")
|
|
277
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
278
|
+
.replace(/[_-]+/g, " ")
|
|
279
|
+
.toLowerCase()
|
|
280
|
+
.match(/[a-z0-9]+/g) || [])
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Filler a doc contributes no information by using: articles and linking words, plus the two generic
|
|
284
|
+
* nouns the law's own anchor examples restate with ("the pending state", "the read user function").
|
|
285
|
+
* Nothing here strips a word that could carry real content, so a doc that explains anything beyond
|
|
286
|
+
* the name keeps enough leftover words to fail the equality check below.
|
|
287
|
+
*/
|
|
288
|
+
const RESTATEMENT_FILLER = new Set(["a", "an", "the", "is", "of", "for", "to", "this", "that", "function", "state"])
|
|
289
|
+
|
|
290
|
+
/** The content words a doc comment contributes once filler is stripped out. */
|
|
291
|
+
const docContentWords = (comment) =>
|
|
292
|
+
(comment.value.match(/[A-Za-z]+/g) || [])
|
|
293
|
+
.map((word) => word.toLowerCase())
|
|
294
|
+
.filter((word) => !RESTATEMENT_FILLER.has(word))
|
|
295
|
+
|
|
296
|
+
/** Whether a doc's leftover content words are exactly the declared name's own words - nothing added. */
|
|
297
|
+
const isPureRestatement = (comment, declaredName) => {
|
|
298
|
+
const content = docContentWords(comment)
|
|
299
|
+
const identifier = wordsOf(declaredName)
|
|
300
|
+
if (content.length === 0 || identifier.length === 0) return false
|
|
301
|
+
const contentSet = new Set(content)
|
|
302
|
+
const identifierSet = new Set(identifier)
|
|
303
|
+
if (contentSet.size !== identifierSet.size) return false
|
|
304
|
+
for (const word of contentSet) if (!identifierSet.has(word)) return false
|
|
305
|
+
return true
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** The doc block immediately before a node, or null. */
|
|
309
|
+
const jsdocBefore = (sourceCode, node) =>
|
|
310
|
+
sourceCode.getCommentsBefore(node).find((comment) => comment.type === "Block" && comment.value.startsWith("*"))
|
|
311
|
+
|| null
|
|
312
|
+
|
|
313
|
+
/** A doc block that only re-spells the declared name is COMMENT-3's violation wearing COMMENT-1's shape. */
|
|
314
|
+
export const noRestatedNameJsdoc = {
|
|
315
|
+
meta: {
|
|
316
|
+
type: "suggestion",
|
|
317
|
+
docs: {
|
|
318
|
+
description:
|
|
319
|
+
"COMMENT-3, law 7: a doc block that restates the declared name is a COMMENT-3 violation wearing a COMMENT-1 shape, even while the lint gate that checks a doc EXISTS stays green.",
|
|
320
|
+
},
|
|
321
|
+
schema: [],
|
|
322
|
+
messages: {
|
|
323
|
+
restated:
|
|
324
|
+
"This doc block only re-spells `{{name}}` in words. A name plus a signature already says what it TAKES; the doc has to say what it is FOR, or what choosing it causes, or it is a COMMENT-3 violation wearing a COMMENT-1 shape. Delete the restatement and write the reason instead.",
|
|
325
|
+
},
|
|
326
|
+
},
|
|
327
|
+
create(context) {
|
|
328
|
+
const sourceCode = context.sourceCode || context.getSourceCode()
|
|
329
|
+
const checkExport = (node) => {
|
|
330
|
+
const declaration = node.declaration
|
|
331
|
+
// a re-export has nothing here to attach a doc to
|
|
332
|
+
if (!declaration) return
|
|
333
|
+
if (declaration.type === "VariableDeclaration") {
|
|
334
|
+
// only a const bound to a function has a surface; a data constant is already described
|
|
335
|
+
const first = declaration.declarations[0]
|
|
336
|
+
const init = first && first.init
|
|
337
|
+
const isFunction = init
|
|
338
|
+
&& (init.type === "ArrowFunctionExpression" || init.type === "FunctionExpression")
|
|
339
|
+
if (!isFunction) return
|
|
340
|
+
} else if (!DOCUMENTED_KINDS.has(declaration.type)) {
|
|
341
|
+
return
|
|
342
|
+
}
|
|
343
|
+
const doc = jsdocBefore(sourceCode, node)
|
|
344
|
+
if (!doc) return
|
|
345
|
+
const name = nameOf(declaration)
|
|
346
|
+
if (!isPureRestatement(doc, name)) return
|
|
347
|
+
context.report({ node: declaration.id || declaration, messageId: "restated", data: { name } })
|
|
348
|
+
}
|
|
349
|
+
return {
|
|
350
|
+
ExportNamedDeclaration: checkExport,
|
|
351
|
+
ExportDefaultDeclaration: checkExport,
|
|
352
|
+
TSEnumDeclaration(node) {
|
|
353
|
+
if (!node.parent || node.parent.type !== "ExportNamedDeclaration") return
|
|
354
|
+
for (const member of node.members || []) {
|
|
355
|
+
const doc = jsdocBefore(sourceCode, member)
|
|
356
|
+
if (!doc) continue
|
|
357
|
+
const name = member.id && (member.id.name || member.id.value)
|
|
358
|
+
if (!name || !isPureRestatement(doc, name)) continue
|
|
359
|
+
context.report({ node: member, messageId: "restated", data: { name } })
|
|
360
|
+
}
|
|
361
|
+
},
|
|
362
|
+
}
|
|
363
|
+
},
|
|
364
|
+
}
|
|
365
|
+
|
|
224
366
|
/** The rules this law contributes to the plugin. */
|
|
225
367
|
export const rules = {
|
|
226
368
|
"require-export-jsdoc": requireExportJsdoc,
|
|
227
369
|
"require-enum-member-jsdoc": requireEnumMemberJsdoc,
|
|
228
370
|
"no-non-ascii-source": noNonAsciiSource,
|
|
371
|
+
"require-vn-ok-reason": requireVnOkReason,
|
|
372
|
+
"no-restated-name-jsdoc": noRestatedNameJsdoc,
|
|
229
373
|
}
|
|
230
374
|
|
|
231
375
|
/**
|
|
@@ -240,4 +384,6 @@ export const recommended = {
|
|
|
240
384
|
"starci-be/require-export-jsdoc": "error",
|
|
241
385
|
"starci-be/require-enum-member-jsdoc": "error",
|
|
242
386
|
"starci-be/no-non-ascii-source": "error",
|
|
387
|
+
"starci-be/require-vn-ok-reason": "error",
|
|
388
|
+
"starci-be/no-restated-name-jsdoc": "error",
|
|
243
389
|
}
|
package/cqrs.mjs
CHANGED
|
@@ -1,15 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The rules that hold `cqrs.md`.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* thin the service is, whether an event is one the caller waits on -
|
|
6
|
-
* that guessed at them would fire on correct code often enough that
|
|
7
|
-
* disable it.
|
|
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
8
|
*
|
|
9
9
|
* The one worth explaining is CQRS-3. A handler that overrides `execute` still compiles and still
|
|
10
10
|
* runs; the template method in the base is simply skipped. Nothing goes red, and the file stays
|
|
11
11
|
* wrong until the day a cross-cutting concern is added to the base and silently misses it. That is
|
|
12
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.
|
|
13
29
|
*/
|
|
14
30
|
|
|
15
31
|
/** Forward-slash form of a filename, so Windows paths compare like every other path. */
|
|
@@ -177,20 +193,99 @@ export const handlerHasTwinSpec = {
|
|
|
177
193
|
},
|
|
178
194
|
}
|
|
179
195
|
|
|
196
|
+
// -- CQRS-5 ----------------------------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
/** The property name that turns a returned object into an encoded failure, or null. */
|
|
199
|
+
const encodedFailureKey = (node) => {
|
|
200
|
+
if (!node || node.type !== "ObjectExpression") return null
|
|
201
|
+
for (const prop of node.properties || []) {
|
|
202
|
+
if (prop.type !== "Property") continue
|
|
203
|
+
const key = prop.key
|
|
204
|
+
const name = key.type === "Identifier" ? key.name : key.type === "Literal" && typeof key.value === "string" ? key.value : null
|
|
205
|
+
if (!name) continue
|
|
206
|
+
// `error` is the encode regardless of what carries it - a caught exception, a message string,
|
|
207
|
+
// an upstream response. Its presence on a RETURNED object is the tell: the branch that found
|
|
208
|
+
// the failure chose to hand it back instead of throwing it.
|
|
209
|
+
if (name === "error") return name
|
|
210
|
+
// `success`/`ok` only encode failure at `false` - `success: true` on the happy path is not
|
|
211
|
+
// this pattern, and flagging it would report the correct half of every handler that uses this
|
|
212
|
+
// shape at all.
|
|
213
|
+
if ((name === "success" || name === "ok") && prop.value && prop.value.type === "Literal" && prop.value.value === false) {
|
|
214
|
+
return name
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return null
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** The nearest enclosing method named `process`, or null. */
|
|
221
|
+
const enclosingProcessMethod = (node) => {
|
|
222
|
+
for (let current = node.parent; current; current = current.parent) {
|
|
223
|
+
if (current.type === "MethodDefinition" && current.key && current.key.name === "process") return current
|
|
224
|
+
}
|
|
225
|
+
return null
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** The nearest enclosing class declaration, or null. */
|
|
229
|
+
const enclosingClass = (node) => {
|
|
230
|
+
for (let current = node.parent; current; current = current.parent) {
|
|
231
|
+
if (current.type === "ClassDeclaration") return current
|
|
232
|
+
}
|
|
233
|
+
return null
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** A handler that cannot do its work throws the domain exception naming why - it does not return one. */
|
|
237
|
+
export const noHandlerEncodedFailure = {
|
|
238
|
+
meta: {
|
|
239
|
+
type: "problem",
|
|
240
|
+
docs: {
|
|
241
|
+
description:
|
|
242
|
+
"A CQRS handler throws the domain exception that names a failure; it does not return a success/error shape (Law 5, CQRS-5).",
|
|
243
|
+
},
|
|
244
|
+
schema: [],
|
|
245
|
+
messages: {
|
|
246
|
+
encoded:
|
|
247
|
+
"`{{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.",
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
create(context) {
|
|
251
|
+
const filename = context.filename || context.getFilename()
|
|
252
|
+
const operation = handlerFile(filename)
|
|
253
|
+
if (!operation) return {}
|
|
254
|
+
return {
|
|
255
|
+
ReturnStatement(node) {
|
|
256
|
+
const key = encodedFailureKey(node.argument)
|
|
257
|
+
if (!key) return
|
|
258
|
+
// Scoped to `process` on a decorated handler, same as CQRS-3 - a private mapper in the
|
|
259
|
+
// same file returning an unrelated object shaped like `{ error: ... }` is not this
|
|
260
|
+
// handler's refusal path, and a rule that fired anywhere in the file would not be able to
|
|
261
|
+
// tell the two apart.
|
|
262
|
+
if (!enclosingProcessMethod(node)) return
|
|
263
|
+
const klass = enclosingClass(node)
|
|
264
|
+
if (!klass || !decoratorNames(klass).some((name) => HANDLER_DECORATORS.test(name))) return
|
|
265
|
+
context.report({ node: node.argument, messageId: "encoded", data: { name: operation, key } })
|
|
266
|
+
},
|
|
267
|
+
}
|
|
268
|
+
},
|
|
269
|
+
}
|
|
270
|
+
|
|
180
271
|
/** The rules this law contributes to the plugin. */
|
|
181
272
|
export const rules = {
|
|
182
273
|
"handler-overrides-process": handlerOverridesProcess,
|
|
183
274
|
"message-carries-params-only": messageCarriesParamsOnly,
|
|
184
275
|
"handler-has-twin-spec": handlerHasTwinSpec,
|
|
276
|
+
"no-handler-encoded-failure": noHandlerEncodedFailure,
|
|
185
277
|
}
|
|
186
278
|
|
|
187
279
|
/**
|
|
188
280
|
* The level this law asks for, as the plugin's own opinion.
|
|
189
281
|
*
|
|
190
282
|
* MEASURED AGAINST THE REFERENCE REPOSITORY, not estimated: 3 of 141 handlers override `execute`,
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
283
|
+
* 2 of 138 messages carry something other than a single `params`, and 2 of 152 handlers return an
|
|
284
|
+
* encoded failure (`sync-flashcard-quiz-session-progress.handler.ts` and
|
|
285
|
+
* `sync-mock-interview-session-turns.handler.ts` both `return { success: false }` where a late sync
|
|
286
|
+
* finds its session already closed). Debt above zero means the rule lands at `warn` with the count
|
|
287
|
+
* beside it, gets burned down, and flips to `error` at zero - shipping at `error` with debt
|
|
288
|
+
* outstanding blocks every commit that touches an offender.
|
|
194
289
|
*
|
|
195
290
|
* COUNT ONLY THIS RULE'S REPORTS when measuring. Inline `eslint-disable` comments in the source
|
|
196
291
|
* refer to rules a minimal measuring config never loads, and eslint reports each of those as a
|
|
@@ -204,4 +299,5 @@ export const recommended = {
|
|
|
204
299
|
"starci-be/handler-overrides-process": "error", // no=0 of 141 - burned down from 3
|
|
205
300
|
"starci-be/message-carries-params-only": "error", // no=0 of 138 - burned down from 2
|
|
206
301
|
"starci-be/handler-has-twin-spec": "off", // needs the folder listing as an option; a repo that wires it turns this on
|
|
302
|
+
"starci-be/no-handler-encoded-failure": "warn", // no=2 of 152 handlers - debt outstanding, burn down before flipping to error
|
|
207
303
|
}
|