@starci/eslint-canon-be 1.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/authorization.mjs +106 -0
- package/cdc.mjs +44 -0
- package/comments.mjs +243 -0
- package/cqrs.mjs +207 -0
- package/data-access.mjs +173 -0
- package/e2e-flow.mjs +213 -0
- package/event-delivery.mjs +40 -0
- package/exception-identity.mjs +200 -0
- package/exceptions.mjs +265 -0
- package/index.mjs +90 -0
- package/module-layering.mjs +171 -0
- package/naming.mjs +143 -0
- package/observability.mjs +141 -0
- package/package.json +35 -0
- package/testing.mjs +353 -0
- package/transport.mjs +103 -0
- package/type-safety.mjs +146 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rule that holds `authorization.md`.
|
|
3
|
+
*
|
|
4
|
+
* ONE rule, and the honest reason is worth stating: authorization is decided against a row, and a
|
|
5
|
+
* parser does not know which row a handler is reaching for or what owning it means. AUTHZ-3, -4 and
|
|
6
|
+
* -5 all turn on that, so a rule aimed at them would fire on shape rather than on meaning - and a
|
|
7
|
+
* rule that fires on shape is one authors learn to disable, which leaves the law worse off than when
|
|
8
|
+
* nothing enforced it.
|
|
9
|
+
*
|
|
10
|
+
* AUTHZ-1 was measured and deliberately left alone. A handler checking `if (!user)` reads like
|
|
11
|
+
* duplication of the door's guard and is not: the guard belongs to one door and the handler belongs
|
|
12
|
+
* to every caller of the bus. A rule refusing it would fire on the majority of correct handlers in
|
|
13
|
+
* this tree, which is a gate contradicting canon rather than holding it.
|
|
14
|
+
*
|
|
15
|
+
* What IS checkable in one file is the door: a resolver method that READS the authenticated user
|
|
16
|
+
* while nothing on that method or its class establishes the identity. That failure looks like
|
|
17
|
+
* nothing at all - the parameter still says `user`, the handler still receives one - and the only
|
|
18
|
+
* missing piece is the line that proved it belonged to the caller.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** Parameter decorators that read an already-authenticated identity off the request. */
|
|
22
|
+
const IDENTITY_PARAM_DECORATORS = new Set([
|
|
23
|
+
"KeycloakGraphQLUser",
|
|
24
|
+
"KeycloakUser",
|
|
25
|
+
"CurrentUser",
|
|
26
|
+
])
|
|
27
|
+
|
|
28
|
+
/** The decorator that establishes the identity a door then reads. */
|
|
29
|
+
const GUARD_DECORATOR = "UseGuards"
|
|
30
|
+
|
|
31
|
+
/** Reads the callee name off a decorator expression, whether or not it is called. */
|
|
32
|
+
const decoratorName = (decorator) => {
|
|
33
|
+
const expression = decorator.expression
|
|
34
|
+
if (!expression) return undefined
|
|
35
|
+
if (expression.type === "CallExpression") {
|
|
36
|
+
return expression.callee && expression.callee.type === "Identifier"
|
|
37
|
+
? expression.callee.name
|
|
38
|
+
: undefined
|
|
39
|
+
}
|
|
40
|
+
return expression.type === "Identifier" ? expression.name : undefined
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Whether any decorator in the list is the guard. */
|
|
44
|
+
const hasGuard = (decorators) =>
|
|
45
|
+
(decorators || []).some((decorator) => decoratorName(decorator) === GUARD_DECORATOR)
|
|
46
|
+
|
|
47
|
+
/** The identity parameter on a method, when it has one. */
|
|
48
|
+
const identityParameter = (node) => {
|
|
49
|
+
for (const parameter of node.value && node.value.params ? node.value.params : []) {
|
|
50
|
+
// a decorated parameter carries its decorators on the parameter node itself, and TS-ESTree
|
|
51
|
+
// wraps a typed+decorated parameter in TSParameterProperty for constructor promotion
|
|
52
|
+
const carrier = parameter.type === "TSParameterProperty" ? parameter.parameter : parameter
|
|
53
|
+
const decorators = parameter.decorators || (carrier && carrier.decorators) || []
|
|
54
|
+
for (const decorator of decorators) {
|
|
55
|
+
if (IDENTITY_PARAM_DECORATORS.has(decoratorName(decorator))) return parameter
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return undefined
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// -- AUTHZ-2 ---------------------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
/** A door that reads an identity carries the guard that establishes it. */
|
|
64
|
+
export const identityNeedsGuard = {
|
|
65
|
+
meta: {
|
|
66
|
+
type: "problem",
|
|
67
|
+
docs: {
|
|
68
|
+
description:
|
|
69
|
+
"A resolver or controller method reading the authenticated user must carry a guard on that method or its class.",
|
|
70
|
+
},
|
|
71
|
+
schema: [],
|
|
72
|
+
messages: {
|
|
73
|
+
unguarded:
|
|
74
|
+
"This door reads the authenticated user through `@{{decorator}}()` while neither the method nor its class carries `@UseGuards(...)`. Nothing established the identity, so what reaches the handler is whatever the pipeline left there - and the failure is silent, because the parameter still says `user` and the handler still receives one. Put the guard on the door that reads the identity.",
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
create(context) {
|
|
78
|
+
return {
|
|
79
|
+
MethodDefinition(node) {
|
|
80
|
+
const parameter = identityParameter(node)
|
|
81
|
+
if (!parameter) return
|
|
82
|
+
if (hasGuard(node.decorators)) return
|
|
83
|
+
|
|
84
|
+
// the guard may sit on the class instead, covering every method on it
|
|
85
|
+
const classBody = node.parent
|
|
86
|
+
const classNode = classBody && classBody.parent
|
|
87
|
+
if (classNode && hasGuard(classNode.decorators)) return
|
|
88
|
+
|
|
89
|
+
const carrier = parameter.type === "TSParameterProperty" ? parameter.parameter : parameter
|
|
90
|
+
const decorators = parameter.decorators || (carrier && carrier.decorators) || []
|
|
91
|
+
const named = decorators.map(decoratorName).find((name) => IDENTITY_PARAM_DECORATORS.has(name))
|
|
92
|
+
context.report({ node: parameter, messageId: "unguarded", data: { decorator: named } })
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Every rule this module ships, by the name a config switches it on under. */
|
|
99
|
+
export const rules = {
|
|
100
|
+
"identity-needs-guard": identityNeedsGuard,
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The level a consuming repository switches this on at. */
|
|
104
|
+
export const recommended = {
|
|
105
|
+
"starci-be/identity-needs-guard": "error",
|
|
106
|
+
}
|
package/cdc.mjs
ADDED
|
@@ -0,0 +1,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 memberName = (member) => member.key && (member.key.name || member.key.value)
|
|
6
|
+
|
|
7
|
+
/** A projection listener declares mapping while AbstractProjectionListener owns Kafka lifecycle. */
|
|
8
|
+
export const projectionListenerContract = {
|
|
9
|
+
meta: {
|
|
10
|
+
type: "problem",
|
|
11
|
+
docs: { description: "Projection CDC listeners share one Kafka lifecycle contract." },
|
|
12
|
+
schema: [],
|
|
13
|
+
messages: {
|
|
14
|
+
base: "A projection CDC listener extends AbstractProjectionListener; the shared base owns subscription, envelope parsing and failure isolation.",
|
|
15
|
+
member: "A projection CDC listener must declare `{{name}}`.",
|
|
16
|
+
lifecycle: "A concrete projection listener must not override `onModuleInit`; Kafka lifecycle belongs to AbstractProjectionListener.",
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
create(context) {
|
|
20
|
+
if (!isProjectionListener(context.filename || context.getFilename())) return {}
|
|
21
|
+
return {
|
|
22
|
+
ClassDeclaration(node) {
|
|
23
|
+
if (!node.superClass || node.superClass.name !== "AbstractProjectionListener") {
|
|
24
|
+
context.report({ node: node.id || node, messageId: "base" })
|
|
25
|
+
}
|
|
26
|
+
const members = node.body.body || []
|
|
27
|
+
const names = new Set(members.map(memberName))
|
|
28
|
+
for (const name of ["groupId", "topics", "deriveTargets", "recomputeTarget"]) {
|
|
29
|
+
if (!names.has(name)) {
|
|
30
|
+
context.report({ node: node.id || node, messageId: "member", data: { name } })
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const lifecycle = members.find((member) => memberName(member) === "onModuleInit")
|
|
34
|
+
if (lifecycle) context.report({ node: lifecycle.key, messageId: "lifecycle" })
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const rules = { "projection-listener-contract": projectionListenerContract }
|
|
41
|
+
|
|
42
|
+
export const recommended = {
|
|
43
|
+
"starci-be/projection-listener-contract": "error",
|
|
44
|
+
}
|
package/comments.mjs
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rules that hold `comments.md`.
|
|
3
|
+
*
|
|
4
|
+
* Three rules, and each is careful about a different false positive:
|
|
5
|
+
*
|
|
6
|
+
* - `require-export-jsdoc` skips plain data constants. `export const MAX_ATTEMPTS = 3` is already
|
|
7
|
+
* fully described by its own name, and demanding a sentence there produces sentences that
|
|
8
|
+
* restate the name - which COMMENT-3 forbids. Only declarations with a surface get the check.
|
|
9
|
+
* - `require-enum-member-jsdoc` can check that a doc EXISTS and never that it states a
|
|
10
|
+
* consequence. That half is read by a person, and the rule says so rather than pretending.
|
|
11
|
+
* - `no-non-ascii-source` exempts a marked line, because text a program matches on or emits is
|
|
12
|
+
* data wearing prose, and translating it breaks the program.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Forward-slash form of a filename, so Windows paths compare like every other path. */
|
|
16
|
+
const normalizePath = (filename) => String(filename || "").replace(/\\/g, "/")
|
|
17
|
+
|
|
18
|
+
/** The marker that keeps a non-ASCII literal the program depends on. */
|
|
19
|
+
const KEEP_MARKER = /\bvn-ok\b/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The character classes this refuses, and why it is NOT simply "ASCII only".
|
|
23
|
+
*
|
|
24
|
+
* A first cut of this rule banned every non-ASCII codepoint. Measured against the reference
|
|
25
|
+
* repository it reported 857 offenders -- and every one was an em dash, a box-drawing run in a
|
|
26
|
+
* comment banner, or a middle dot. The code has used those freely and deliberately for its whole
|
|
27
|
+
* life, so "ASCII only" was not the law being recorded; it was a stricter law being invented, which
|
|
28
|
+
* is the one thing canon must not do.
|
|
29
|
+
*
|
|
30
|
+
* What the law actually refuses is three things, for three different reasons:
|
|
31
|
+
*
|
|
32
|
+
* - VIETNAMESE letters, because a reader who does not share the author's first language loses
|
|
33
|
+
* exactly the half of the reasoning that explains the surprising parts. Matched precisely, so a
|
|
34
|
+
* European loanword (`naive`, `facade`, `Muller`) is never a false positive.
|
|
35
|
+
* - EMOJI, because they carry tone rather than information, and tone reads differently to
|
|
36
|
+
* everybody.
|
|
37
|
+
* - DECORATIVE symbols -- check marks, crosses, arrows used as ornament -- for the same reason.
|
|
38
|
+
*
|
|
39
|
+
* Typographic punctuation is none of those and stays.
|
|
40
|
+
*/
|
|
41
|
+
const VIETNAMESE_LETTER = /[À-ÃÈ-ÊÌÍÒ-ÕÙÚÝà-ãè-êìíò-õùúýĂăĐđĨĩŨũƠơƯưẠ-ỿ]/
|
|
42
|
+
|
|
43
|
+
/** Emoji and pictographs. */
|
|
44
|
+
const EMOJI = /[\u{1F300}-\u{1FAFF}\u{1F000}-\u{1F0FF}\u{2600}-\u{27BF}\u{FE0F}\u{1F1E6}-\u{1F1FF}]/u
|
|
45
|
+
|
|
46
|
+
/** Ornamental marks that stand in for a word. */
|
|
47
|
+
const ORNAMENT = /[✅❌✔✖✗✘⭐⬆⬇➡⬅]/
|
|
48
|
+
|
|
49
|
+
/** The Vietnamese language's own name, which is a label rather than prose. */
|
|
50
|
+
const ENDONYM = /Tiếng Việt/
|
|
51
|
+
|
|
52
|
+
/** Declarations whose surface other files depend on. */
|
|
53
|
+
const DOCUMENTED_KINDS = new Set([
|
|
54
|
+
"TSInterfaceDeclaration",
|
|
55
|
+
"TSTypeAliasDeclaration",
|
|
56
|
+
"TSEnumDeclaration",
|
|
57
|
+
"ClassDeclaration",
|
|
58
|
+
"FunctionDeclaration",
|
|
59
|
+
])
|
|
60
|
+
|
|
61
|
+
/** What a line offends with, or null. */
|
|
62
|
+
const offenceIn = (line) => {
|
|
63
|
+
const withoutEndonym = line.replace(ENDONYM, "")
|
|
64
|
+
if (VIETNAMESE_LETTER.test(withoutEndonym)) return "a Vietnamese letter"
|
|
65
|
+
if (EMOJI.test(withoutEndonym)) return "an emoji"
|
|
66
|
+
if (ORNAMENT.test(withoutEndonym)) return "an ornamental symbol"
|
|
67
|
+
return null
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Whether a JSDoc block sits immediately before a node. */
|
|
71
|
+
const hasJsdocBefore = (sourceCode, node) =>
|
|
72
|
+
sourceCode
|
|
73
|
+
.getCommentsBefore(node)
|
|
74
|
+
.some((comment) => comment.type === "Block" && comment.value.startsWith("*"))
|
|
75
|
+
|
|
76
|
+
/** The declared name, for the message. */
|
|
77
|
+
const nameOf = (declaration) => {
|
|
78
|
+
if (declaration.id) return declaration.id.name
|
|
79
|
+
const first = declaration.declarations && declaration.declarations[0]
|
|
80
|
+
return (first && first.id && first.id.name) || "this export"
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// -- COMMENT-1 -------------------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
/** Every export with a surface opens with a doc block. */
|
|
86
|
+
export const requireExportJsdoc = {
|
|
87
|
+
meta: {
|
|
88
|
+
type: "suggestion",
|
|
89
|
+
docs: { description: "An exported class/interface/type/enum/function opens with JSDoc." },
|
|
90
|
+
schema: [],
|
|
91
|
+
messages: {
|
|
92
|
+
jsdoc:
|
|
93
|
+
"`{{name}}` is exported with no doc block. This is surface other files depend on, and a name plus a signature says what it TAKES - never what it is for, or when to reach for it rather than the thing beside it.",
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
create(context) {
|
|
97
|
+
const sourceCode = context.sourceCode || context.getSourceCode()
|
|
98
|
+
const check = (node) => {
|
|
99
|
+
const declaration = node.declaration
|
|
100
|
+
// a re-export has nothing here to attach a doc to
|
|
101
|
+
if (!declaration) return
|
|
102
|
+
if (declaration.type === "VariableDeclaration") {
|
|
103
|
+
// only a const bound to a function has a surface; a data constant is already described
|
|
104
|
+
const first = declaration.declarations[0]
|
|
105
|
+
const init = first && first.init
|
|
106
|
+
const isFunction = init
|
|
107
|
+
&& (init.type === "ArrowFunctionExpression" || init.type === "FunctionExpression")
|
|
108
|
+
if (!isFunction) return
|
|
109
|
+
} else if (!DOCUMENTED_KINDS.has(declaration.type)) {
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
if (hasJsdocBefore(sourceCode, node)) return
|
|
113
|
+
context.report({
|
|
114
|
+
node: declaration.id || declaration,
|
|
115
|
+
messageId: "jsdoc",
|
|
116
|
+
data: { name: nameOf(declaration) },
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
ExportNamedDeclaration: check,
|
|
121
|
+
ExportDefaultDeclaration: check,
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// -- COMMENT-2 -------------------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
/** Every member of an exported enum carries its own doc. */
|
|
129
|
+
export const requireEnumMemberJsdoc = {
|
|
130
|
+
meta: {
|
|
131
|
+
type: "suggestion",
|
|
132
|
+
docs: { description: "Every member of an exported enum carries its own JSDoc." },
|
|
133
|
+
schema: [],
|
|
134
|
+
messages: {
|
|
135
|
+
jsdoc:
|
|
136
|
+
"Enum member `{{name}}` has no doc. State what CHOOSING it causes, not what it is called - a member is picked at a call site far from the switch that gives it meaning. (A rule can only see that a doc exists; whether it states a consequence is read by a person.)",
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
create(context) {
|
|
140
|
+
const sourceCode = context.sourceCode || context.getSourceCode()
|
|
141
|
+
return {
|
|
142
|
+
TSEnumDeclaration(node) {
|
|
143
|
+
if (!node.parent || node.parent.type !== "ExportNamedDeclaration") return
|
|
144
|
+
for (const member of node.members || []) {
|
|
145
|
+
if (hasJsdocBefore(sourceCode, member)) continue
|
|
146
|
+
const name = member.id && (member.id.name || member.id.value)
|
|
147
|
+
context.report({ node: member, messageId: "jsdoc", data: { name: name || "?" } })
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// -- COMMENT-4 -------------------------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
/** Source is English ASCII, except where a literal is data the program depends on. */
|
|
157
|
+
export const noNonAsciiSource = {
|
|
158
|
+
meta: {
|
|
159
|
+
type: "problem",
|
|
160
|
+
docs: { description: "Source stays English ASCII; a depended-upon literal is marked instead." },
|
|
161
|
+
schema: [],
|
|
162
|
+
messages: {
|
|
163
|
+
nonAscii:
|
|
164
|
+
"This line carries {{offence}}. The bar is a reader who does not share the author's first language: a codebase with two languages in it has somebody for whom half the reasoning is unavailable, and it is the half explaining the surprising parts - and an emoji or an ornament carries tone rather than information, which reads differently to everybody. If this is text the program MATCHES on or EMITS, it is data rather than prose: keep it and mark the line `vn-ok: <reason>` so the next sweep does not turn it into a bug.",
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
create(context) {
|
|
168
|
+
const sourceCode = context.sourceCode || context.getSourceCode()
|
|
169
|
+
const file = normalizePath(context.filename || context.getFilename())
|
|
170
|
+
// locale files are wholly product copy; policing them would be policing the product
|
|
171
|
+
if (/\/(?:messages|locales|i18n)\//.test(file)) return {}
|
|
172
|
+
|
|
173
|
+
/*
|
|
174
|
+
* IN A TEST LANE, A STRING IS A FIXTURE AND A COMMENT IS STILL PROSE.
|
|
175
|
+
*
|
|
176
|
+
* The rule's own reason draws this line already: prose in a second language leaves half the
|
|
177
|
+
* reasoning unavailable to a reader, while "text the program MATCHES on or EMITS is data rather
|
|
178
|
+
* than prose". A spec that feeds an agent the sentence a real customer would type - "khach vua
|
|
179
|
+
* chuyen khoan" - is feeding it data, and translating it would test a system nobody uses.
|
|
180
|
+
*
|
|
181
|
+
* Measured before it was written: of 92 findings in one back end, 89 were fixture strings and 3
|
|
182
|
+
* were comments. Demanding `vn-ok` on all 92 would have put a marker on every line of every
|
|
183
|
+
* conversation fixture, which is noise that teaches a reader to stop seeing the marker.
|
|
184
|
+
*
|
|
185
|
+
* The exemption is for STRINGS ONLY. A Vietnamese comment in a spec is the same problem it is
|
|
186
|
+
* anywhere else - the next reader still cannot follow the reasoning - so it is still refused.
|
|
187
|
+
*/
|
|
188
|
+
const fixtureLane = /\.spec\.ts$|-spec\.ts$|\/src\/tests\//.test(file)
|
|
189
|
+
const commentLines = fixtureLane
|
|
190
|
+
? new Set(sourceCode.getAllComments().flatMap((comment) => {
|
|
191
|
+
const span = []
|
|
192
|
+
for (let line = comment.loc.start.line; line <= comment.loc.end.line; line += 1) span.push(line)
|
|
193
|
+
return span
|
|
194
|
+
}))
|
|
195
|
+
: null
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
"Program:exit"(node) {
|
|
199
|
+
const lines = sourceCode.getLines()
|
|
200
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
201
|
+
const line = lines[index]
|
|
202
|
+
if (KEEP_MARKER.test(line)) continue
|
|
203
|
+
// in a fixture lane only prose is policed; the data the fixture feeds is the point of it
|
|
204
|
+
if (commentLines && !commentLines.has(index + 1)) continue
|
|
205
|
+
const offence = offenceIn(line)
|
|
206
|
+
if (offence === null) continue
|
|
207
|
+
context.report({
|
|
208
|
+
node,
|
|
209
|
+
loc: {
|
|
210
|
+
line: index + 1,
|
|
211
|
+
column: 0,
|
|
212
|
+
},
|
|
213
|
+
messageId: "nonAscii",
|
|
214
|
+
data: {
|
|
215
|
+
offence,
|
|
216
|
+
},
|
|
217
|
+
})
|
|
218
|
+
}
|
|
219
|
+
},
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** The rules this law contributes to the plugin. */
|
|
225
|
+
export const rules = {
|
|
226
|
+
"require-export-jsdoc": requireExportJsdoc,
|
|
227
|
+
"require-enum-member-jsdoc": requireEnumMemberJsdoc,
|
|
228
|
+
"no-non-ascii-source": noNonAsciiSource,
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The level this law asks for, as the plugin's own opinion.
|
|
233
|
+
*
|
|
234
|
+
* `no-non-ascii-source` replaces three separate rules in the reference plugin - one for Vietnamese,
|
|
235
|
+
* one for emoji, one for decorative symbols. They were three character classes answering one
|
|
236
|
+
* question, and a reader who hit the emoji rule learned nothing about the other two. One rule with
|
|
237
|
+
* one reason is easier to obey, and impossible to satisfy by switching alphabets.
|
|
238
|
+
*/
|
|
239
|
+
export const recommended = {
|
|
240
|
+
"starci-be/require-export-jsdoc": "error",
|
|
241
|
+
"starci-be/require-enum-member-jsdoc": "error",
|
|
242
|
+
"starci-be/no-non-ascii-source": "error",
|
|
243
|
+
}
|
package/cqrs.mjs
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rules that hold `cqrs.md`.
|
|
3
|
+
*
|
|
4
|
+
* Three of the seven rules are shapes a parser can see. The other four - where the work lives, how
|
|
5
|
+
* thin the service is, whether an event is one the caller waits on - are judgements, and a rule
|
|
6
|
+
* that guessed at them would fire on correct code often enough that everybody would learn to
|
|
7
|
+
* 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
|
+
|
|
15
|
+
/** Forward-slash form of a filename, so Windows paths compare like every other path. */
|
|
16
|
+
const normalizePath = (filename) => String(filename || "").replace(/\\/g, "/")
|
|
17
|
+
|
|
18
|
+
/** Decorators that mark a class as a CQRS handler. */
|
|
19
|
+
const HANDLER_DECORATORS = /^(?:Command|Query|Events)Handler$/
|
|
20
|
+
|
|
21
|
+
/** A `<operation>.handler.ts`, and the operation it is named for. */
|
|
22
|
+
const handlerFile = (filename) => {
|
|
23
|
+
const hit = normalizePath(filename).match(/\/([a-z0-9-]+)\.handler\.ts$/)
|
|
24
|
+
return hit ? hit[1] : null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A `<operation>.command.ts` or `<operation>.query.ts`, and the operation it is named for. */
|
|
28
|
+
const messageFile = (filename) => {
|
|
29
|
+
const hit = normalizePath(filename).match(/\/([a-z0-9-]+)\.(command|query)\.ts$/)
|
|
30
|
+
return hit ? { operation: hit[1], kind: hit[2] } : null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Decorator names carried by a class declaration. */
|
|
34
|
+
const decoratorNames = (node) =>
|
|
35
|
+
(node.decorators || []).map((decorator) => {
|
|
36
|
+
const expression = decorator.expression
|
|
37
|
+
if (expression.type === "CallExpression" && expression.callee.type === "Identifier") return expression.callee.name
|
|
38
|
+
if (expression.type === "Identifier") return expression.name
|
|
39
|
+
return null
|
|
40
|
+
}).filter(Boolean)
|
|
41
|
+
|
|
42
|
+
/** Whether a class body declares a method of the given name. */
|
|
43
|
+
const declaredMethod = (node, name) =>
|
|
44
|
+
(node.body.body || []).find(
|
|
45
|
+
(member) => member.type === "MethodDefinition" && member.key && member.key.name === name,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
// -- CQRS-3 ----------------------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
/** A handler implements the template's `process`, never its public `execute`. */
|
|
51
|
+
export const handlerOverridesProcess = {
|
|
52
|
+
meta: {
|
|
53
|
+
type: "problem",
|
|
54
|
+
docs: { description: "A CQRS handler overrides `process`, not `execute`." },
|
|
55
|
+
schema: [],
|
|
56
|
+
messages: {
|
|
57
|
+
overridesExecute:
|
|
58
|
+
"`{{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(...)`.",
|
|
59
|
+
noProcess:
|
|
60
|
+
"`{{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.",
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
create(context) {
|
|
64
|
+
return {
|
|
65
|
+
ClassDeclaration(node) {
|
|
66
|
+
if (!decoratorNames(node).some((name) => HANDLER_DECORATORS.test(name))) return
|
|
67
|
+
const name = (node.id && node.id.name) || "this handler"
|
|
68
|
+
const execute = declaredMethod(node, "execute")
|
|
69
|
+
if (execute) {
|
|
70
|
+
context.report({ node: execute.key, messageId: "overridesExecute", data: { name } })
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
// A handler that extends anything may inherit `process` from that base, and an
|
|
74
|
+
// intermediate abstract handler is a legitimate shape - a family of suggestion queries
|
|
75
|
+
// that all search the same way implements `process` once and is subclassed. Measured
|
|
76
|
+
// against real source, reporting here regardless of the superclass produced ten false
|
|
77
|
+
// positives and three true ones, so the check applies only to a standalone class.
|
|
78
|
+
if (node.superClass) return
|
|
79
|
+
if (!declaredMethod(node, "process")) {
|
|
80
|
+
context.report({ node: node.id || node, messageId: "noProcess", data: { name } })
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// -- CQRS-2 ----------------------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
/** A message carries request context and nothing else. */
|
|
90
|
+
export const messageCarriesParamsOnly = {
|
|
91
|
+
meta: {
|
|
92
|
+
type: "problem",
|
|
93
|
+
docs: { description: "A command or query holds `params` and declares no logic." },
|
|
94
|
+
schema: [],
|
|
95
|
+
messages: {
|
|
96
|
+
method:
|
|
97
|
+
"`{{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.",
|
|
98
|
+
shape:
|
|
99
|
+
"`{{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.",
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
create(context) {
|
|
103
|
+
const message = messageFile(context.filename || context.getFilename())
|
|
104
|
+
if (!message) return {}
|
|
105
|
+
return {
|
|
106
|
+
ClassDeclaration(node) {
|
|
107
|
+
// `.command.ts` is not one thing. A CLI framework uses the same suffix for a decorated
|
|
108
|
+
// class with a `run` method, which is a DOOR rather than a message - measured against
|
|
109
|
+
// real source, that family produced nineteen of this rule's twenty-one reports. A CQRS
|
|
110
|
+
// message is a plain class; anything decorated here belongs to some other framework.
|
|
111
|
+
if ((node.decorators || []).length > 0) return
|
|
112
|
+
const members = node.body.body || []
|
|
113
|
+
for (const member of members) {
|
|
114
|
+
if (member.type !== "MethodDefinition" || member.kind === "constructor") continue
|
|
115
|
+
context.report({
|
|
116
|
+
node: member.key,
|
|
117
|
+
messageId: "method",
|
|
118
|
+
data: { name: (node.id && node.id.name) || "this message", member: member.key.name || "a method" },
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
const constructor = members.find((member) => member.type === "MethodDefinition" && member.kind === "constructor")
|
|
122
|
+
if (!constructor || !constructor.value) return
|
|
123
|
+
const params = constructor.value.params || []
|
|
124
|
+
const carriesParams = params.length === 1
|
|
125
|
+
&& (params[0].type === "TSParameterProperty" ? params[0].parameter : params[0]).name === "params"
|
|
126
|
+
if (!carriesParams) {
|
|
127
|
+
context.report({
|
|
128
|
+
node: node.id || node,
|
|
129
|
+
messageId: "shape",
|
|
130
|
+
data: { name: (node.id && node.id.name) || "this message" },
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// -- CQRS-7 ----------------------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* A handler has its twin spec beside it.
|
|
142
|
+
*
|
|
143
|
+
* The check is the FILENAME, not the disk: a rule that stats the filesystem reports differently
|
|
144
|
+
* depending on what else is checked out, and a rule whose answer depends on the working tree is one
|
|
145
|
+
* nobody can reproduce. This rule fires on the handler and names the file that should exist; a gate
|
|
146
|
+
* that walks the tree is what counts them.
|
|
147
|
+
*/
|
|
148
|
+
export const handlerHasTwinSpec = {
|
|
149
|
+
meta: {
|
|
150
|
+
type: "problem",
|
|
151
|
+
docs: { description: "A CQRS handler declares its twin spec filename in the same folder." },
|
|
152
|
+
schema: [
|
|
153
|
+
{
|
|
154
|
+
type: "object",
|
|
155
|
+
properties: { specs: { type: "array", items: { type: "string" } } },
|
|
156
|
+
additionalProperties: false,
|
|
157
|
+
},
|
|
158
|
+
],
|
|
159
|
+
messages: {
|
|
160
|
+
missing:
|
|
161
|
+
"`{{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.",
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
create(context) {
|
|
165
|
+
const filename = context.filename || context.getFilename()
|
|
166
|
+
const operation = handlerFile(filename)
|
|
167
|
+
if (!operation) return {}
|
|
168
|
+
const options = context.options && context.options[0]
|
|
169
|
+
const known = options && Array.isArray(options.specs) ? options.specs : null
|
|
170
|
+
if (!known) return {}
|
|
171
|
+
if (known.includes(`${operation}.handler.spec.ts`)) return {}
|
|
172
|
+
return {
|
|
173
|
+
"Program:exit"(node) {
|
|
174
|
+
context.report({ node, messageId: "missing", data: { operation } })
|
|
175
|
+
},
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** The rules this law contributes to the plugin. */
|
|
181
|
+
export const rules = {
|
|
182
|
+
"handler-overrides-process": handlerOverridesProcess,
|
|
183
|
+
"message-carries-params-only": messageCarriesParamsOnly,
|
|
184
|
+
"handler-has-twin-spec": handlerHasTwinSpec,
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The level this law asks for, as the plugin's own opinion.
|
|
189
|
+
*
|
|
190
|
+
* MEASURED AGAINST THE REFERENCE REPOSITORY, not estimated: 3 of 141 handlers override `execute`,
|
|
191
|
+
* and 2 of 138 messages carry something other than a single `params`. Debt above zero means the
|
|
192
|
+
* rule lands at `warn` with the count beside it, gets burned down, and flips to `error` at zero -
|
|
193
|
+
* shipping at `error` with debt outstanding blocks every commit that touches an offender.
|
|
194
|
+
*
|
|
195
|
+
* COUNT ONLY THIS RULE'S REPORTS when measuring. Inline `eslint-disable` comments in the source
|
|
196
|
+
* refer to rules a minimal measuring config never loads, and eslint reports each of those as a
|
|
197
|
+
* problem of its own - which inflated the first measurement of every rule here by the same seven
|
|
198
|
+
* files, and this rule's by two.
|
|
199
|
+
*
|
|
200
|
+
* `handler-has-twin-spec` is off by default because it needs the folder listing passed as an
|
|
201
|
+
* option; a repository that wires it supplies the listing from its own gate.
|
|
202
|
+
*/
|
|
203
|
+
export const recommended = {
|
|
204
|
+
"starci-be/handler-overrides-process": "error", // no=0 of 141 - burned down from 3
|
|
205
|
+
"starci-be/message-carries-params-only": "error", // no=0 of 138 - burned down from 2
|
|
206
|
+
"starci-be/handler-has-twin-spec": "off", // needs the folder listing as an option; a repo that wires it turns this on
|
|
207
|
+
}
|