@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 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 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
- }
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
- * Three rules, and each is careful about a different false positive:
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
- * 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.
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
- * 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.
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
  }