@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/CHANGELOG.md +50 -0
- package/README.md +145 -69
- package/async-discipline.mjs +83 -0
- package/authorization.mjs +106 -106
- package/cdc.mjs +150 -150
- package/comments.mjs +340 -389
- package/config-owner.mjs +252 -0
- package/cqrs.mjs +168 -303
- package/data-access.mjs +452 -307
- package/default-deny.mjs +116 -0
- package/e2e-flow.mjs +222 -222
- package/error-handling.mjs +207 -0
- package/event-delivery.mjs +93 -93
- package/idempotency.mjs +101 -0
- package/index.mjs +137 -107
- package/input-bounds.mjs +84 -0
- package/lib/ast.mjs +81 -0
- package/lib/config.mjs +49 -0
- package/lib/path.mjs +17 -0
- package/lib/slots.mjs +60 -0
- package/log-safety.mjs +148 -0
- package/module-layering.mjs +408 -432
- package/module-shape.mjs +202 -0
- package/naming.mjs +219 -220
- package/observability.mjs +275 -275
- package/package.json +4 -2
- package/query-safety.mjs +274 -0
- package/resilience.mjs +178 -0
- package/schema-authority.mjs +187 -0
- package/size-budget.mjs +106 -0
- package/spec-quality.mjs +132 -0
- package/suppression.mjs +60 -0
- package/temporal.mjs +71 -0
- package/testing.mjs +479 -452
- package/transport.mjs +168 -168
- package/type-safety.mjs +551 -511
- package/user-copy.mjs +106 -0
- package/exception-identity.mjs +0 -200
- package/exceptions.mjs +0 -265
|
@@ -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
|
+
}
|
package/event-delivery.mjs
CHANGED
|
@@ -1,93 +1,93 @@
|
|
|
1
|
-
|
|
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
|
+
}
|
package/idempotency.mjs
ADDED
|
@@ -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
|
+
}
|