@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/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
- /** 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
+ * 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
+ }