@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.
@@ -0,0 +1,207 @@
1
+ /**
2
+ * The rules that hold HFS errors and their handling (catalog R38 `BE_ERROR_HOME`, R40 `BE_LOGGER_REQUIRED`).
3
+ *
4
+ * TWO RULES, ONE HABIT: a failure is either named where its owner lives, or it is visible.
5
+ *
6
+ * - `catch-must-account` refuses a `catch` (and a promise `.catch`) that swallows. A handler passes when it
7
+ * rethrows, when it logs through a logger port, or when it returns an outcome that carries the caught
8
+ * error as its `cause`. An empty handler, and one that returns `null` or `false` and moves on, turns a
9
+ * real failure into "nothing happened" - the payment error that became `unavailable` with no trace.
10
+ * - `error-home` keeps every error where its semantic owner is. A class deriving from `DomainError` lives in
11
+ * `<capability>/errors/`; the retired `exceptions/` capability, the retired `AbstractException` base, a bare
12
+ * `throw new Error(...)`, a framework `HttpException`, and a domain that throws a platform error are all
13
+ * refused. Expected business results are typed unions, not exceptions, and are outside this rule.
14
+ *
15
+ * This file replaces the Academy `AbstractException` laws: those rules pinned a central exception family that
16
+ * HFS no longer has, and a rule that survives its standard as "off" is a rule nobody can trust.
17
+ */
18
+ import { some, walk } from "./lib/ast.mjs"
19
+ import { isDeclarationFile, isTestLane, normalizePath } from "./lib/path.mjs"
20
+
21
+ const LOG_METHODS = new Set(["error", "warn", "info", "debug", "log", "fatal", "verbose", "trace"])
22
+
23
+ /** The object a member call is made on, as the last identifier of its chain (`this.logger.error` gives `logger`). */
24
+ const receiverName = (callee) => {
25
+ const object = callee.object
26
+ if (object.type === "Identifier") return object.name
27
+ if (object.type === "MemberExpression" && !object.computed && object.property.type === "Identifier") return object.property.name
28
+ return null
29
+ }
30
+
31
+ /** `logger.error(...)`, `this.log.warn(...)`: a call on a logger port. `console` is never one. */
32
+ const isLoggerCall = (node) => {
33
+ if (node.type !== "CallExpression" || node.callee.type !== "MemberExpression" || node.callee.computed) return false
34
+ if (node.callee.property.type !== "Identifier" || !LOG_METHODS.has(node.callee.property.name)) return false
35
+ const receiver = receiverName(node.callee)
36
+ return receiver !== null && receiver !== "console" && /log/i.test(receiver)
37
+ }
38
+
39
+ /** True when `name` is used as a value in `node` (not just as the object of `name.message`). */
40
+ const carriesCaught = (node, name) => {
41
+ if (!name) return false
42
+ let carried = false
43
+ walk(node, (child) => {
44
+ if (child.type !== "Identifier" || child.name !== name) return
45
+ const parent = child.parent
46
+ if (parent?.type === "MemberExpression" && parent.object === child) return
47
+ carried = true
48
+ })
49
+ return carried
50
+ }
51
+
52
+ /**
53
+ * Whether a handler block accounts for the failure.
54
+ *
55
+ * @param {object} body - The block of the handler.
56
+ * @param {string | null} param - The caught error's name, when the handler names it.
57
+ * @returns {boolean} True when it rethrows, logs, or returns an outcome carrying the error.
58
+ */
59
+ const accounts = (body, param) =>
60
+ some(
61
+ body,
62
+ (node) =>
63
+ node.type === "ThrowStatement" ||
64
+ isLoggerCall(node) ||
65
+ (node.type === "ReturnStatement" && node.argument !== null && carriesCaught(node.argument, param)),
66
+ { intoFunctions: false },
67
+ )
68
+
69
+ const paramName = (fn) => (fn.params?.[0]?.type === "Identifier" ? fn.params[0].name : null)
70
+
71
+ /** Every failure is rethrown, logged, or returned as an outcome carrying its cause. */
72
+ export const catchMustAccount = {
73
+ meta: {
74
+ type: "problem",
75
+ docs: { description: "A catch rethrows, logs through a logger port, or returns an outcome that carries the cause." },
76
+ schema: [],
77
+ messages: {
78
+ empty: "This `catch` is empty, so the failure vanishes. Rethrow it, log it through the logger port, or return a typed outcome carrying it as `cause`.",
79
+ swallowed:
80
+ "This `catch` neither rethrows, logs, nor returns an outcome carrying the caught error, so the failure leaves no trace. Rethrow it, log it through the logger port, or return a typed outcome with `cause`.",
81
+ },
82
+ },
83
+ create(context) {
84
+ const checkBlock = (reportNode, body, param) => {
85
+ if (body.body.length === 0) context.report({ node: reportNode, messageId: "empty" })
86
+ else if (!accounts(body, param)) context.report({ node: reportNode, messageId: "swallowed" })
87
+ }
88
+ return {
89
+ CatchClause(node) {
90
+ checkBlock(node, node.body, node.param?.type === "Identifier" ? node.param.name : null)
91
+ },
92
+ CallExpression(node) {
93
+ if (node.callee.type !== "MemberExpression" || node.callee.computed) return
94
+ if (node.callee.property.type !== "Identifier" || node.callee.property.name !== "catch") return
95
+ const handler = node.arguments[0]
96
+ if (!handler || (handler.type !== "ArrowFunctionExpression" && handler.type !== "FunctionExpression")) return
97
+ if (handler.body.type === "BlockStatement") {
98
+ checkBlock(handler, handler.body, paramName(handler))
99
+ } else if (!isLoggerCall(handler.body) && !carriesCaught(handler.body, paramName(handler))) {
100
+ context.report({ node: handler, messageId: "swallowed" })
101
+ }
102
+ },
103
+ }
104
+ },
105
+ }
106
+
107
+ /** The framework exceptions that carry an HTTP status and no identity. */
108
+ const FRAMEWORK_EXCEPTIONS = new Set([
109
+ "BadRequestException",
110
+ "NotFoundException",
111
+ "UnauthorizedException",
112
+ "ForbiddenException",
113
+ "InternalServerErrorException",
114
+ "HttpException",
115
+ "ConflictException",
116
+ "NotAcceptableException",
117
+ "RequestTimeoutException",
118
+ "GoneException",
119
+ "PayloadTooLargeException",
120
+ "UnsupportedMediaTypeException",
121
+ "UnprocessableEntityException",
122
+ "NotImplementedException",
123
+ "BadGatewayException",
124
+ "ServiceUnavailableException",
125
+ "GatewayTimeoutException",
126
+ ])
127
+
128
+ const BUILTIN_ERRORS = new Set(["Error", "TypeError", "RangeError", "SyntaxError", "ReferenceError", "EvalError", "URIError"])
129
+
130
+ /** `<capability>/errors/` under a module tier or a feature, or the platform `errors` capability itself. */
131
+ const ERRORS_HOME =
132
+ /\/src\/(?:modules\/(?:domain|platform|integrations)\/[^/]+|features\/[^/]+)\/(?:[^/]+\/)*errors\/|\/src\/modules\/platform\/errors\//
133
+ const PLATFORM_ERRORS = /\/src\/modules\/platform\/errors\//
134
+ const RETIRED_HOME = /\/src\/modules\/(?:domain|platform|integrations)\/exceptions\//
135
+ const DOMAIN_TIER = /\/src\/modules\/domain\//
136
+ const HEALTH_PROBE = /\/health(?:z)?\.controller\.ts$|\/health\//
137
+
138
+ /** An error class is declared at its owner, extends `DomainError`, and is never a bare or framework throw. */
139
+ export const errorHome = {
140
+ meta: {
141
+ type: "problem",
142
+ docs: { description: "Errors live in `<capability>/errors/`, extend DomainError, and no bare or framework error escapes." },
143
+ schema: [],
144
+ messages: {
145
+ place: "`{{name}}` is an error of this capability but is declared outside its `errors/` folder. Move it to the `errors/` folder of the capability that owns its meaning.",
146
+ retiredHome: "This file sits in a retired `exceptions/` capability. Errors belong to the capability that owns their meaning, in its `errors/` folder.",
147
+ retiredBase: "`{{name}}` extends `AbstractException`, the retired central base. Extend `DomainError` from `platform/errors` and keep the class in its capability's `errors/`.",
148
+ mustExtendDomainError: "`{{name}}` extends the built-in `{{parent}}`. Extend `DomainError` so the error carries a stable `code` and a `cause`.",
149
+ bareError: "`throw new Error(...)` carries a sentence and no code, so nothing downstream can group or map it. Throw a `DomainError` subclass of the owning capability.",
150
+ framework: "`throw new {{name}}(...)` carries an HTTP status and no identity. Throw a `DomainError` subclass and let the transport map its code to a status.",
151
+ platformThrown: "A domain capability throws `{{name}}`, a platform error. Translate it into an error of this capability so platform details do not cross the boundary.",
152
+ },
153
+ },
154
+ create(context) {
155
+ const filename = normalizePath(context.filename || context.getFilename())
156
+ if (isDeclarationFile(filename)) return {}
157
+ const listeners = {}
158
+ if (RETIRED_HOME.test(filename)) {
159
+ listeners.Program = (node) => context.report({ node, messageId: "retiredHome" })
160
+ }
161
+ if (isTestLane(filename)) return listeners
162
+ const inHome = ERRORS_HOME.test(filename)
163
+ const platformImports = new Set()
164
+ listeners.ImportDeclaration = (node) => {
165
+ if (typeof node.source.value !== "string" || !/(?:^|\/)platform\//.test(node.source.value)) return
166
+ for (const specifier of node.specifiers) platformImports.add(specifier.local.name)
167
+ }
168
+ listeners.ClassDeclaration = (node) => {
169
+ const parent = node.superClass
170
+ if (!node.id || !parent || parent.type !== "Identifier") return
171
+ if (parent.name === "AbstractException") {
172
+ context.report({ node: parent, messageId: "retiredBase", data: { name: node.id.name } })
173
+ } else if (BUILTIN_ERRORS.has(parent.name)) {
174
+ if (!PLATFORM_ERRORS.test(filename)) {
175
+ context.report({ node: parent, messageId: "mustExtendDomainError", data: { name: node.id.name, parent: parent.name } })
176
+ }
177
+ } else if (/Error$/.test(parent.name) && !inHome) {
178
+ context.report({ node: node.id, messageId: "place", data: { name: node.id.name } })
179
+ }
180
+ }
181
+ listeners.ThrowStatement = (node) => {
182
+ const thrown = node.argument
183
+ if (!thrown || thrown.type !== "NewExpression" || thrown.callee.type !== "Identifier") return
184
+ const name = thrown.callee.name
185
+ if (name === "Error") {
186
+ context.report({ node: thrown, messageId: "bareError" })
187
+ } else if (FRAMEWORK_EXCEPTIONS.has(name) && !HEALTH_PROBE.test(filename)) {
188
+ context.report({ node: thrown, messageId: "framework", data: { name } })
189
+ } else if (DOMAIN_TIER.test(filename) && name !== "DomainError" && /Error$/.test(name) && platformImports.has(name)) {
190
+ context.report({ node: thrown, messageId: "platformThrown", data: { name } })
191
+ }
192
+ }
193
+ return listeners
194
+ },
195
+ }
196
+
197
+ /** The rules this law contributes to the plugin. */
198
+ export const rules = {
199
+ "catch-must-account": catchMustAccount,
200
+ "error-home": errorHome,
201
+ }
202
+
203
+ /** Both start at error: HFS has no baseline, and the migration lanes clear the debt before a repository adopts them. */
204
+ export const recommended = {
205
+ "starci-be/catch-must-account": "error",
206
+ "starci-be/error-home": "error",
207
+ }
@@ -1,93 +1,93 @@
1
- const normalizePath = (filename) => String(filename || "").replace(/\\/g, "/")
2
-
3
- /** The NATS bridge rejects self-echo and claims redelivery before local fan-out. */
4
- export const natsBridgeDeliveryContract = {
5
- meta: {
6
- type: "problem",
7
- docs: { description: "The central NATS bridge preserves cross-instance delivery invariants." },
8
- schema: [],
9
- messages: {
10
- origin: "Drop envelopes whose producer id matches this instance before local emit.",
11
- digest: "Claim/check the envelope digest before local emit so redelivery is idempotent.",
12
- },
13
- },
14
- create(context) {
15
- if (!normalizePath(context.filename || context.getFilename()).endsWith(
16
- "/event/nats/nats-bridge.service.ts",
17
- )) return {}
18
- return {
19
- "Program:exit"(node) {
20
- const source = context.sourceCode || context.getSourceCode()
21
- const text = source.getText()
22
- const originIndex = text.search(/parsed\.id\s*===\s*this\.instanceService\.getId\(\)/)
23
- const digestIndex = text.indexOf("parsed.digest")
24
- const emitIndex = text.indexOf("this.eventEmitter.emit")
25
- if (originIndex < 0 || emitIndex < 0 || originIndex > emitIndex) {
26
- context.report({ node, messageId: "origin" })
27
- }
28
- if (digestIndex < 0 || emitIndex < 0 || digestIndex > emitIndex) {
29
- context.report({ node, messageId: "digest" })
30
- }
31
- },
32
- }
33
- },
34
- }
35
-
36
- const isEmitCallOnEventEmitterService = (node) =>
37
- node.callee.type === "MemberExpression" &&
38
- !node.callee.computed &&
39
- node.callee.property.type === "Identifier" &&
40
- node.callee.property.name === "emit" &&
41
- node.callee.object.type === "MemberExpression" &&
42
- !node.callee.object.computed &&
43
- node.callee.object.property.type === "Identifier" &&
44
- node.callee.object.property.name === "eventEmitterService"
45
-
46
- const findObjectProperty = (objectExpression, name) => {
47
- if (!objectExpression || objectExpression.type !== "ObjectExpression") return null
48
- return objectExpression.properties.find(
49
- (property) => property.type === "Property" &&
50
- !property.computed &&
51
- (property.key.name === name || property.key.value === name),
52
- ) || null
53
- }
54
-
55
- /** DELIVERY Rule 2 (DELIVERY-2): transport is declared per event in one config, never at the call site. */
56
- export const noCallSiteTransportOverride = {
57
- meta: {
58
- type: "problem",
59
- docs: {
60
- description: "DELIVERY Rule 2 (DELIVERY-2): transport is declared per event in the central config; it is never chosen at the call site.",
61
- },
62
- schema: [],
63
- messages: {
64
- callsite: "DELIVERY Rule 2 (DELIVERY-2): do not pass `options.useLocal`/`options.useNats` to `eventEmitterService.emit(...)`. Transport is part of the event's definition in the central config, not a per-call decision -- a call site choosing it is a promise the config never made and a reader has to guess at.",
65
- },
66
- },
67
- create(context) {
68
- return {
69
- CallExpression(node) {
70
- if (!isEmitCallOnEventEmitterService(node)) return
71
- const args = node.arguments[0]
72
- const optionsProperty = findObjectProperty(args, "options")
73
- if (!optionsProperty) return
74
- const optionsValue = optionsProperty.value
75
- const hasUseLocal = findObjectProperty(optionsValue, "useLocal")
76
- const hasUseNats = findObjectProperty(optionsValue, "useNats")
77
- if (hasUseLocal || hasUseNats) {
78
- context.report({ node: optionsProperty, messageId: "callsite" })
79
- }
80
- },
81
- }
82
- },
83
- }
84
-
85
- export const rules = {
86
- "nats-bridge-delivery-contract": natsBridgeDeliveryContract,
87
- "no-call-site-transport-override": noCallSiteTransportOverride,
88
- }
89
-
90
- export const recommended = {
91
- "starci-be/nats-bridge-delivery-contract": "error",
92
- "starci-be/no-call-site-transport-override": "error",
93
- }
1
+ import { normalizePath } from "./lib/path.mjs"
2
+
3
+ /** The NATS bridge rejects self-echo and claims redelivery before local fan-out. */
4
+ export const natsBridgeDeliveryContract = {
5
+ meta: {
6
+ type: "problem",
7
+ docs: { description: "The central NATS bridge preserves cross-instance delivery invariants." },
8
+ schema: [],
9
+ messages: {
10
+ origin: "Drop envelopes whose producer id matches this instance before local emit.",
11
+ digest: "Claim/check the envelope digest before local emit so redelivery is idempotent.",
12
+ },
13
+ },
14
+ create(context) {
15
+ if (!normalizePath(context.filename || context.getFilename()).endsWith(
16
+ "/event/nats/nats-bridge.service.ts",
17
+ )) return {}
18
+ return {
19
+ "Program:exit"(node) {
20
+ const source = context.sourceCode || context.getSourceCode()
21
+ const text = source.getText()
22
+ const originIndex = text.search(/parsed\.id\s*===\s*this\.instanceService\.getId\(\)/)
23
+ const digestIndex = text.indexOf("parsed.digest")
24
+ const emitIndex = text.indexOf("this.eventEmitter.emit")
25
+ if (originIndex < 0 || emitIndex < 0 || originIndex > emitIndex) {
26
+ context.report({ node, messageId: "origin" })
27
+ }
28
+ if (digestIndex < 0 || emitIndex < 0 || digestIndex > emitIndex) {
29
+ context.report({ node, messageId: "digest" })
30
+ }
31
+ },
32
+ }
33
+ },
34
+ }
35
+
36
+ const isEmitCallOnEventEmitterService = (node) =>
37
+ node.callee.type === "MemberExpression" &&
38
+ !node.callee.computed &&
39
+ node.callee.property.type === "Identifier" &&
40
+ node.callee.property.name === "emit" &&
41
+ node.callee.object.type === "MemberExpression" &&
42
+ !node.callee.object.computed &&
43
+ node.callee.object.property.type === "Identifier" &&
44
+ node.callee.object.property.name === "eventEmitterService"
45
+
46
+ const findObjectProperty = (objectExpression, name) => {
47
+ if (!objectExpression || objectExpression.type !== "ObjectExpression") return null
48
+ return objectExpression.properties.find(
49
+ (property) => property.type === "Property" &&
50
+ !property.computed &&
51
+ (property.key.name === name || property.key.value === name),
52
+ ) || null
53
+ }
54
+
55
+ /** DELIVERY Rule 2 (DELIVERY-2): transport is declared per event in one config, never at the call site. */
56
+ export const noCallSiteTransportOverride = {
57
+ meta: {
58
+ type: "problem",
59
+ docs: {
60
+ description: "DELIVERY Rule 2 (DELIVERY-2): transport is declared per event in the central config; it is never chosen at the call site.",
61
+ },
62
+ schema: [],
63
+ messages: {
64
+ callsite: "DELIVERY Rule 2 (DELIVERY-2): do not pass `options.useLocal`/`options.useNats` to `eventEmitterService.emit(...)`. Transport is part of the event's definition in the central config, not a per-call decision -- a call site choosing it is a promise the config never made and a reader has to guess at.",
65
+ },
66
+ },
67
+ create(context) {
68
+ return {
69
+ CallExpression(node) {
70
+ if (!isEmitCallOnEventEmitterService(node)) return
71
+ const args = node.arguments[0]
72
+ const optionsProperty = findObjectProperty(args, "options")
73
+ if (!optionsProperty) return
74
+ const optionsValue = optionsProperty.value
75
+ const hasUseLocal = findObjectProperty(optionsValue, "useLocal")
76
+ const hasUseNats = findObjectProperty(optionsValue, "useNats")
77
+ if (hasUseLocal || hasUseNats) {
78
+ context.report({ node: optionsProperty, messageId: "callsite" })
79
+ }
80
+ },
81
+ }
82
+ },
83
+ }
84
+
85
+ export const rules = {
86
+ "nats-bridge-delivery-contract": natsBridgeDeliveryContract,
87
+ "no-call-site-transport-override": noCallSiteTransportOverride,
88
+ }
89
+
90
+ export const recommended = {
91
+ "starci-be/nats-bridge-delivery-contract": "error",
92
+ "starci-be/no-call-site-transport-override": "error",
93
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * The rule that holds inbox dedupe (catalog R80 `BE_INBOX_DEDUPE_MISSING`).
3
+ *
4
+ * A sender that gets no timely reply resends: a payment provider retries a webhook, a queue
5
+ * redelivers a message it never saw acknowledged. The standard is one inbox table keyed by
6
+ * `(source, event id)` in front of every door delivery can repeat through, so a repeat is a no-op
7
+ * instead of a second charge, a second email or a second state transition.
8
+ *
9
+ * - `inbox-dedupe-required` refuses a `@Public()` webhook controller method, or an outbox/queue
10
+ * consumer, whose file shows no reference to an inbox or dedupe port. A consumer is recognized
11
+ * by more than its filename: `<event>.consumer.ts` under `transport/message/` (the
12
+ * `be.transport.message` slot) still counts, and so does a method decorated `@EventPattern`,
13
+ * `@MessagePattern`, `@OnEvent` or `@Process`, or a class whose name ends `Consumer` or
14
+ * `OutboxConsumer`, wherever that file sits. A class that only PUBLISHES - the producer half of
15
+ * an outbox, typically `*-outbox.service.ts` / `*OutboxService` - carries none of those shapes
16
+ * and is not flagged: it has no repeat delivery to dedupe unless it also consumes.
17
+ *
18
+ * This reads one file's text for the shape of a claim (`inbox`, `dedupe`, `idempoten*` naming a
19
+ * port, a field or a call) - it cannot see whether the call actually runs before the side effect,
20
+ * which needs the call graph and stays a review question, same as `no-outer-manager-in-transaction`
21
+ * says for its own undecidable half.
22
+ */
23
+ import { decoratorName, keyName, staticText } from "./lib/ast.mjs"
24
+ import { isTestLane, normalizePath } from "./lib/path.mjs"
25
+
26
+ const INBOX_REFERENCE = /inbox|dedupe|idempoten/i
27
+ const CONSUMER_FILE = /\/transport\/message\/[^/]+\.consumer\.ts$/
28
+ const WEBHOOK_FILE = /\/transport\/http\/[^/]*webhook[^/]*\.controller\.ts$/i
29
+ /** A class whose name alone says it consumes a delivery, regardless of where the file sits. */
30
+ const CONSUMER_CLASS_NAME = /(?:Outbox)?Consumer$/
31
+ /** A method decorator that binds a handler to an incoming message or event, not just a filename convention. */
32
+ const CONSUMER_DECORATORS = new Set(["EventPattern", "MessagePattern", "OnEvent", "Process"])
33
+
34
+ /** The route or reason text a `@Public()` decorator's argument carries, lower-cased. */
35
+ const publicReason = (node) => {
36
+ const expression = node.expression
37
+ const argument = expression.type === "CallExpression" ? expression.arguments[0] : undefined
38
+ if (argument?.type !== "ObjectExpression") return ""
39
+ const property = argument.properties.find((item) => item.type === "Property" && keyName(item.key) === "reason")
40
+ return (property ? staticText(property.value) : null)?.toLowerCase() ?? ""
41
+ }
42
+
43
+ /** A webhook door: the file is named for one, the open door names its reason, or the route itself does. */
44
+ const isWebhookController = (filename, sourceText, publicDecorators) =>
45
+ WEBHOOK_FILE.test(filename)
46
+ || publicDecorators.some((node) => /webhook/.test(publicReason(node)))
47
+ || /@Controller\(\s*["'`][^"'`]*webhook/i.test(sourceText)
48
+
49
+ /** Delivery this instance can receive more than once shows a claim against the inbox before it acts. */
50
+ export const inboxDedupeRequired = {
51
+ meta: {
52
+ type: "problem",
53
+ docs: { description: "A `@Public()` webhook handler and an outbox/queue consumer claim the event through the shared inbox before they act." },
54
+ schema: [],
55
+ messages: {
56
+ webhook: "This webhook controller has no reference to an inbox or dedupe port. A sender that gets no timely reply resends the same event; claim `(source, event id)` in the shared inbox before acting, so a repeat delivery is a no-op.",
57
+ consumer: "This consumer has no reference to an inbox or dedupe port. A queue redelivers a message it never saw acknowledged; claim `(source, event id)` in the shared inbox before acting, so a redelivery is a no-op.",
58
+ },
59
+ },
60
+ create(context) {
61
+ const filename = normalizePath(context.filename || context.getFilename())
62
+ if (isTestLane(filename)) return {}
63
+ let isConsumer = CONSUMER_FILE.test(filename)
64
+ const publicDecorators = []
65
+ return {
66
+ Decorator(node) {
67
+ const name = decoratorName(node)
68
+ if (name === "Public") publicDecorators.push(node)
69
+ else if (CONSUMER_DECORATORS.has(name)) isConsumer = true
70
+ },
71
+ ClassDeclaration(node) {
72
+ if (node.id && CONSUMER_CLASS_NAME.test(node.id.name)) isConsumer = true
73
+ },
74
+ ClassExpression(node) {
75
+ if (node.id && CONSUMER_CLASS_NAME.test(node.id.name)) isConsumer = true
76
+ },
77
+ "Program:exit"(node) {
78
+ const sourceCode = context.sourceCode || context.getSourceCode()
79
+ const text = sourceCode.getText()
80
+ if (INBOX_REFERENCE.test(text)) return
81
+ if (isConsumer) {
82
+ context.report({ node, messageId: "consumer" })
83
+ return
84
+ }
85
+ if (publicDecorators.length && isWebhookController(filename, text, publicDecorators)) {
86
+ context.report({ node, messageId: "webhook" })
87
+ }
88
+ },
89
+ }
90
+ },
91
+ }
92
+
93
+ /** The rule this law contributes to the plugin. */
94
+ export const rules = {
95
+ "inbox-dedupe-required": inboxDedupeRequired,
96
+ }
97
+
98
+ /** Starts at error: a duplicate charge or a duplicate side effect is never a warning. */
99
+ export const recommended = {
100
+ "starci-be/inbox-dedupe-required": "error",
101
+ }