@starci/eslint-canon-be 1.2.1 → 1.7.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/cqrs.mjs CHANGED
@@ -1,303 +1,168 @@
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
- /** Forward-slash form of a filename, so Windows paths compare like every other path. */
32
- const normalizePath = (filename) => String(filename || "").replace(/\\/g, "/")
33
-
34
- /** Decorators that mark a class as a CQRS handler. */
35
- const HANDLER_DECORATORS = /^(?:Command|Query|Events)Handler$/
36
-
37
- /** A `<operation>.handler.ts`, and the operation it is named for. */
38
- const handlerFile = (filename) => {
39
- const hit = normalizePath(filename).match(/\/([a-z0-9-]+)\.handler\.ts$/)
40
- return hit ? hit[1] : null
41
- }
42
-
43
- /** A `<operation>.command.ts` or `<operation>.query.ts`, and the operation it is named for. */
44
- const messageFile = (filename) => {
45
- const hit = normalizePath(filename).match(/\/([a-z0-9-]+)\.(command|query)\.ts$/)
46
- return hit ? { operation: hit[1], kind: hit[2] } : null
47
- }
48
-
49
- /** Decorator names carried by a class declaration. */
50
- const decoratorNames = (node) =>
51
- (node.decorators || []).map((decorator) => {
52
- const expression = decorator.expression
53
- if (expression.type === "CallExpression" && expression.callee.type === "Identifier") return expression.callee.name
54
- if (expression.type === "Identifier") return expression.name
55
- return null
56
- }).filter(Boolean)
57
-
58
- /** Whether a class body declares a method of the given name. */
59
- const declaredMethod = (node, name) =>
60
- (node.body.body || []).find(
61
- (member) => member.type === "MethodDefinition" && member.key && member.key.name === name,
62
- )
63
-
64
- // -- CQRS-3 ----------------------------------------------------------------------------------------
65
-
66
- /** A handler implements the template's `process`, never its public `execute`. */
67
- export const handlerOverridesProcess = {
68
- meta: {
69
- type: "problem",
70
- docs: { description: "A CQRS handler overrides `process`, not `execute`." },
71
- schema: [],
72
- messages: {
73
- overridesExecute:
74
- "`{{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(...)`.",
75
- noProcess:
76
- "`{{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.",
77
- },
78
- },
79
- create(context) {
80
- return {
81
- ClassDeclaration(node) {
82
- if (!decoratorNames(node).some((name) => HANDLER_DECORATORS.test(name))) return
83
- const name = (node.id && node.id.name) || "this handler"
84
- const execute = declaredMethod(node, "execute")
85
- if (execute) {
86
- context.report({ node: execute.key, messageId: "overridesExecute", data: { name } })
87
- return
88
- }
89
- // A handler that extends anything may inherit `process` from that base, and an
90
- // intermediate abstract handler is a legitimate shape - a family of suggestion queries
91
- // that all search the same way implements `process` once and is subclassed. Measured
92
- // against real source, reporting here regardless of the superclass produced ten false
93
- // positives and three true ones, so the check applies only to a standalone class.
94
- if (node.superClass) return
95
- if (!declaredMethod(node, "process")) {
96
- context.report({ node: node.id || node, messageId: "noProcess", data: { name } })
97
- }
98
- },
99
- }
100
- },
101
- }
102
-
103
- // -- CQRS-2 ----------------------------------------------------------------------------------------
104
-
105
- /** A message carries request context and nothing else. */
106
- export const messageCarriesParamsOnly = {
107
- meta: {
108
- type: "problem",
109
- docs: { description: "A command or query holds `params` and declares no logic." },
110
- schema: [],
111
- messages: {
112
- method:
113
- "`{{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.",
114
- shape:
115
- "`{{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.",
116
- },
117
- },
118
- create(context) {
119
- const message = messageFile(context.filename || context.getFilename())
120
- if (!message) return {}
121
- return {
122
- ClassDeclaration(node) {
123
- // `.command.ts` is not one thing. A CLI framework uses the same suffix for a decorated
124
- // class with a `run` method, which is a DOOR rather than a message - measured against
125
- // real source, that family produced nineteen of this rule's twenty-one reports. A CQRS
126
- // message is a plain class; anything decorated here belongs to some other framework.
127
- if ((node.decorators || []).length > 0) return
128
- const members = node.body.body || []
129
- for (const member of members) {
130
- if (member.type !== "MethodDefinition" || member.kind === "constructor") continue
131
- context.report({
132
- node: member.key,
133
- messageId: "method",
134
- data: { name: (node.id && node.id.name) || "this message", member: member.key.name || "a method" },
135
- })
136
- }
137
- const constructor = members.find((member) => member.type === "MethodDefinition" && member.kind === "constructor")
138
- if (!constructor || !constructor.value) return
139
- const params = constructor.value.params || []
140
- const carriesParams = params.length === 1
141
- && (params[0].type === "TSParameterProperty" ? params[0].parameter : params[0]).name === "params"
142
- if (!carriesParams) {
143
- context.report({
144
- node: node.id || node,
145
- messageId: "shape",
146
- data: { name: (node.id && node.id.name) || "this message" },
147
- })
148
- }
149
- },
150
- }
151
- },
152
- }
153
-
154
- // -- CQRS-7 ----------------------------------------------------------------------------------------
155
-
156
- /**
157
- * A handler has its twin spec beside it.
158
- *
159
- * The check is the FILENAME, not the disk: a rule that stats the filesystem reports differently
160
- * depending on what else is checked out, and a rule whose answer depends on the working tree is one
161
- * nobody can reproduce. This rule fires on the handler and names the file that should exist; a gate
162
- * that walks the tree is what counts them.
163
- */
164
- export const handlerHasTwinSpec = {
165
- meta: {
166
- type: "problem",
167
- docs: { description: "A CQRS handler declares its twin spec filename in the same folder." },
168
- schema: [
169
- {
170
- type: "object",
171
- properties: { specs: { type: "array", items: { type: "string" } } },
172
- additionalProperties: false,
173
- },
174
- ],
175
- messages: {
176
- missing:
177
- "`{{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.",
178
- },
179
- },
180
- create(context) {
181
- const filename = context.filename || context.getFilename()
182
- const operation = handlerFile(filename)
183
- if (!operation) return {}
184
- const options = context.options && context.options[0]
185
- const known = options && Array.isArray(options.specs) ? options.specs : null
186
- if (!known) return {}
187
- if (known.includes(`${operation}.handler.spec.ts`)) return {}
188
- return {
189
- "Program:exit"(node) {
190
- context.report({ node, messageId: "missing", data: { operation } })
191
- },
192
- }
193
- },
194
- }
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
-
271
- /** The rules this law contributes to the plugin. */
272
- export const rules = {
273
- "handler-overrides-process": handlerOverridesProcess,
274
- "message-carries-params-only": messageCarriesParamsOnly,
275
- "handler-has-twin-spec": handlerHasTwinSpec,
276
- "no-handler-encoded-failure": noHandlerEncodedFailure,
277
- }
278
-
279
- /**
280
- * The level this law asks for, as the plugin's own opinion.
281
- *
282
- * MEASURED AGAINST THE REFERENCE REPOSITORY, not estimated: 3 of 141 handlers override `execute`,
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.
289
- *
290
- * COUNT ONLY THIS RULE'S REPORTS when measuring. Inline `eslint-disable` comments in the source
291
- * refer to rules a minimal measuring config never loads, and eslint reports each of those as a
292
- * problem of its own - which inflated the first measurement of every rule here by the same seven
293
- * files, and this rule's by two.
294
- *
295
- * `handler-has-twin-spec` is off by default because it needs the folder listing passed as an
296
- * option; a repository that wires it supplies the listing from its own gate.
297
- */
298
- export const recommended = {
299
- "starci-be/handler-overrides-process": "error", // no=0 of 141 - burned down from 3
300
- "starci-be/message-carries-params-only": "error", // no=0 of 138 - burned down from 2
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
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
+
27
+ import { normalizePath } from "./lib/path.mjs"
28
+
29
+ /** Decorators that mark a class as a CQRS handler. */
30
+ const HANDLER_DECORATORS = /^(?:Command|Query|Events)Handler$/
31
+
32
+ /** A `<operation>.handler.ts`, and the operation it is named for. */
33
+ const handlerFile = (filename) => {
34
+ const hit = normalizePath(filename).match(/\/([a-z0-9-]+)\.handler\.ts$/)
35
+ return hit ? hit[1] : null
36
+ }
37
+
38
+ /** A `<operation>.command.ts` or `<operation>.query.ts`, and the operation it is named for. */
39
+ const messageFile = (filename) => {
40
+ const hit = normalizePath(filename).match(/\/([a-z0-9-]+)\.(command|query)\.ts$/)
41
+ return hit ? { operation: hit[1], kind: hit[2] } : null
42
+ }
43
+
44
+ /** Decorator names carried by a class declaration. */
45
+ const decoratorNames = (node) =>
46
+ (node.decorators || []).map((decorator) => {
47
+ const expression = decorator.expression
48
+ if (expression.type === "CallExpression" && expression.callee.type === "Identifier") return expression.callee.name
49
+ if (expression.type === "Identifier") return expression.name
50
+ return null
51
+ }).filter(Boolean)
52
+
53
+ /** Whether a class body declares a method of the given name. */
54
+ const declaredMethod = (node, name) =>
55
+ (node.body.body || []).find(
56
+ (member) => member.type === "MethodDefinition" && member.key && member.key.name === name,
57
+ )
58
+
59
+ // -- CQRS-3 ----------------------------------------------------------------------------------------
60
+
61
+ /** A handler implements the template's `process`, never its public `execute`. */
62
+ export const handlerOverridesProcess = {
63
+ meta: {
64
+ type: "problem",
65
+ docs: { description: "A CQRS handler overrides `process`, not `execute`." },
66
+ schema: [],
67
+ messages: {
68
+ overridesExecute:
69
+ "`{{name}}` overrides `execute`, which takes it OUT of the template method in the base handler. It compiles and it runs, so nothing goes red - and it is the one handler the next cross-cutting change (a timing, a transaction, a retry added to the base) will silently miss. Rename this to `protected override async process(...)`.",
70
+ noProcess:
71
+ "`{{name}}` is a CQRS handler with no `process` method. The base declares `process` abstract and calls it from `execute`; a handler that implements neither has no work to dispatch to.",
72
+ },
73
+ },
74
+ create(context) {
75
+ return {
76
+ ClassDeclaration(node) {
77
+ if (!decoratorNames(node).some((name) => HANDLER_DECORATORS.test(name))) return
78
+ const name = (node.id && node.id.name) || "this handler"
79
+ const execute = declaredMethod(node, "execute")
80
+ if (execute) {
81
+ context.report({ node: execute.key, messageId: "overridesExecute", data: { name } })
82
+ return
83
+ }
84
+ // A handler that extends anything may inherit `process` from that base, and an
85
+ // intermediate abstract handler is a legitimate shape - a family of suggestion queries
86
+ // that all search the same way implements `process` once and is subclassed. Measured
87
+ // against real source, reporting here regardless of the superclass produced ten false
88
+ // positives and three true ones, so the check applies only to a standalone class.
89
+ if (node.superClass) return
90
+ if (!declaredMethod(node, "process")) {
91
+ context.report({ node: node.id || node, messageId: "noProcess", data: { name } })
92
+ }
93
+ },
94
+ }
95
+ },
96
+ }
97
+
98
+ // -- CQRS-2 ----------------------------------------------------------------------------------------
99
+
100
+ /** A message carries request context and nothing else. */
101
+ export const messageCarriesParamsOnly = {
102
+ meta: {
103
+ type: "problem",
104
+ docs: { description: "A command or query holds `params` and declares no logic." },
105
+ schema: [],
106
+ messages: {
107
+ method:
108
+ "`{{name}}` declares `{{member}}`. A message that computes has moved a decision into a file nobody reads for decisions, and two dispatchers of this message would then disagree about what it means. Keep the message to its `params`; compute in the handler.",
109
+ shape:
110
+ "`{{name}}` does not carry a single `params` field. A message is request context - the request, the user, the locale - handed to the handler whole; a message with several fields makes every dispatcher assemble it differently.",
111
+ },
112
+ },
113
+ create(context) {
114
+ const message = messageFile(context.filename || context.getFilename())
115
+ if (!message) return {}
116
+ return {
117
+ ClassDeclaration(node) {
118
+ // `.command.ts` is not one thing. A CLI framework uses the same suffix for a decorated
119
+ // class with a `run` method, which is a DOOR rather than a message - measured against
120
+ // real source, that family produced nineteen of this rule's twenty-one reports. A CQRS
121
+ // message is a plain class; anything decorated here belongs to some other framework.
122
+ if ((node.decorators || []).length > 0) return
123
+ const members = node.body.body || []
124
+ for (const member of members) {
125
+ if (member.type !== "MethodDefinition" || member.kind === "constructor") continue
126
+ context.report({
127
+ node: member.key,
128
+ messageId: "method",
129
+ data: { name: (node.id && node.id.name) || "this message", member: member.key.name || "a method" },
130
+ })
131
+ }
132
+ const constructor = members.find((member) => member.type === "MethodDefinition" && member.kind === "constructor")
133
+ if (!constructor || !constructor.value) return
134
+ const params = constructor.value.params || []
135
+ const carriesParams = params.length === 1
136
+ && (params[0].type === "TSParameterProperty" ? params[0].parameter : params[0]).name === "params"
137
+ if (!carriesParams) {
138
+ context.report({
139
+ node: node.id || node,
140
+ messageId: "shape",
141
+ data: { name: (node.id && node.id.name) || "this message" },
142
+ })
143
+ }
144
+ },
145
+ }
146
+ },
147
+ }
148
+
149
+ /** The rules this law contributes to the plugin. */
150
+ export const rules = {
151
+ "handler-overrides-process": handlerOverridesProcess,
152
+ "message-carries-params-only": messageCarriesParamsOnly,
153
+ }
154
+
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
+ */
165
+ export const recommended = {
166
+ "starci-be/handler-overrides-process": "error",
167
+ "starci-be/message-carries-params-only": "error",
168
+ }