@starci/eslint-canon-be 1.2.2 → 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 +43 -0
- package/README.md +82 -7
- package/async-discipline.mjs +83 -0
- package/comments.mjs +7 -55
- package/config-owner.mjs +252 -0
- package/cqrs.mjs +7 -141
- package/data-access.mjs +151 -5
- package/default-deny.mjs +116 -0
- package/error-handling.mjs +207 -0
- package/idempotency.mjs +101 -0
- package/index.mjs +34 -4
- package/input-bounds.mjs +84 -0
- package/lib/ast.mjs +81 -0
- package/lib/config.mjs +49 -0
- package/lib/path.mjs +15 -0
- package/lib/slots.mjs +60 -0
- package/log-safety.mjs +148 -0
- package/module-layering.mjs +87 -105
- package/module-shape.mjs +202 -0
- package/package.json +1 -1
- 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 +31 -19
- package/type-safety.mjs +98 -57
- package/user-copy.mjs +106 -0
- package/exception-identity.mjs +0 -200
- package/exceptions.mjs +0 -262
|
@@ -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/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
|
+
}
|
package/index.mjs
CHANGED
|
@@ -16,39 +16,67 @@
|
|
|
16
16
|
* every conversation about the failure. Aliasing one here to match a target's older spelling would
|
|
17
17
|
* leave two names for one rule and no way to tell which a message came from.
|
|
18
18
|
*/
|
|
19
|
+
import { recommended as asyncDisciplineRecommended, rules as asyncDisciplineRules } from "./async-discipline.mjs"
|
|
19
20
|
import { recommended as authorizationRecommended, rules as authorizationRules } from "./authorization.mjs"
|
|
20
21
|
import { recommended as cdcRecommended, rules as cdcRules } from "./cdc.mjs"
|
|
21
22
|
import { recommended as commentsRecommended, rules as commentsRules } from "./comments.mjs"
|
|
23
|
+
import { recommended as configOwnerRecommended, rules as configOwnerRules } from "./config-owner.mjs"
|
|
22
24
|
import { recommended as cqrsRecommended, rules as cqrsRules } from "./cqrs.mjs"
|
|
23
25
|
import { recommended as dataAccessRecommended, rules as dataAccessRules } from "./data-access.mjs"
|
|
26
|
+
import { recommended as defaultDenyRecommended, rules as defaultDenyRules } from "./default-deny.mjs"
|
|
24
27
|
import { recommended as e2eFlowRecommended, rules as e2eFlowRules } from "./e2e-flow.mjs"
|
|
28
|
+
import { recommended as errorHandlingRecommended, rules as errorHandlingRules } from "./error-handling.mjs"
|
|
25
29
|
import { recommended as eventDeliveryRecommended, rules as eventDeliveryRules } from "./event-delivery.mjs"
|
|
26
|
-
import { recommended as
|
|
27
|
-
import { recommended as
|
|
30
|
+
import { recommended as idempotencyRecommended, rules as idempotencyRules } from "./idempotency.mjs"
|
|
31
|
+
import { recommended as inputBoundsRecommended, rules as inputBoundsRules } from "./input-bounds.mjs"
|
|
32
|
+
import { recommended as logSafetyRecommended, rules as logSafetyRules } from "./log-safety.mjs"
|
|
28
33
|
import { recommended as moduleLayeringRecommended, rules as moduleLayeringRules } from "./module-layering.mjs"
|
|
34
|
+
import { recommended as moduleShapeRecommended, rules as moduleShapeRules } from "./module-shape.mjs"
|
|
29
35
|
import { recommended as namingRecommended, rules as namingRules } from "./naming.mjs"
|
|
30
36
|
import { recommended as observabilityRecommended, rules as observabilityRules } from "./observability.mjs"
|
|
37
|
+
import { recommended as querySafetyRecommended, rules as querySafetyRules } from "./query-safety.mjs"
|
|
38
|
+
import { recommended as resilienceRecommended, rules as resilienceRules } from "./resilience.mjs"
|
|
39
|
+
import { recommended as schemaAuthorityRecommended, rules as schemaAuthorityRules } from "./schema-authority.mjs"
|
|
40
|
+
import { recommended as sizeBudgetRecommended, rules as sizeBudgetRules } from "./size-budget.mjs"
|
|
41
|
+
import { recommended as specQualityRecommended, rules as specQualityRules } from "./spec-quality.mjs"
|
|
42
|
+
import { recommended as suppressionRecommended, rules as suppressionRules } from "./suppression.mjs"
|
|
43
|
+
import { recommended as temporalRecommended, rules as temporalRules } from "./temporal.mjs"
|
|
31
44
|
import { recommended as testingRecommended, rules as testingRules } from "./testing.mjs"
|
|
32
45
|
import { recommended as transportRecommended, rules as transportRules } from "./transport.mjs"
|
|
33
46
|
import { recommended as typeSafetyRecommended, rules as typeSafetyRules } from "./type-safety.mjs"
|
|
47
|
+
import { recommended as userCopyRecommended, rules as userCopyRules } from "./user-copy.mjs"
|
|
34
48
|
|
|
35
49
|
/** Each law's contribution, kept separate so a duplicate name is detectable rather than silent. */
|
|
36
50
|
const CONTRIBUTIONS = [
|
|
51
|
+
{ law: "async-discipline", rules: asyncDisciplineRules, recommended: asyncDisciplineRecommended },
|
|
37
52
|
{ law: "authorization", rules: authorizationRules, recommended: authorizationRecommended },
|
|
38
53
|
{ law: "cdc", rules: cdcRules, recommended: cdcRecommended },
|
|
39
54
|
{ law: "comments", rules: commentsRules, recommended: commentsRecommended },
|
|
55
|
+
{ law: "config-owner", rules: configOwnerRules, recommended: configOwnerRecommended },
|
|
40
56
|
{ law: "cqrs", rules: cqrsRules, recommended: cqrsRecommended },
|
|
41
57
|
{ law: "data-access", rules: dataAccessRules, recommended: dataAccessRecommended },
|
|
58
|
+
{ law: "default-deny", rules: defaultDenyRules, recommended: defaultDenyRecommended },
|
|
42
59
|
{ law: "e2e-flow", rules: e2eFlowRules, recommended: e2eFlowRecommended },
|
|
60
|
+
{ law: "error-handling", rules: errorHandlingRules, recommended: errorHandlingRecommended },
|
|
43
61
|
{ law: "event-delivery", rules: eventDeliveryRules, recommended: eventDeliveryRecommended },
|
|
44
|
-
{ law: "
|
|
45
|
-
{ law: "
|
|
62
|
+
{ law: "idempotency", rules: idempotencyRules, recommended: idempotencyRecommended },
|
|
63
|
+
{ law: "input-bounds", rules: inputBoundsRules, recommended: inputBoundsRecommended },
|
|
64
|
+
{ law: "log-safety", rules: logSafetyRules, recommended: logSafetyRecommended },
|
|
46
65
|
{ law: "module-layering", rules: moduleLayeringRules, recommended: moduleLayeringRecommended },
|
|
66
|
+
{ law: "module-shape", rules: moduleShapeRules, recommended: moduleShapeRecommended },
|
|
47
67
|
{ law: "naming", rules: namingRules, recommended: namingRecommended },
|
|
48
68
|
{ law: "observability", rules: observabilityRules, recommended: observabilityRecommended },
|
|
69
|
+
{ law: "query-safety", rules: querySafetyRules, recommended: querySafetyRecommended },
|
|
70
|
+
{ law: "resilience", rules: resilienceRules, recommended: resilienceRecommended },
|
|
71
|
+
{ law: "schema-authority", rules: schemaAuthorityRules, recommended: schemaAuthorityRecommended },
|
|
72
|
+
{ law: "size-budget", rules: sizeBudgetRules, recommended: sizeBudgetRecommended },
|
|
73
|
+
{ law: "spec-quality", rules: specQualityRules, recommended: specQualityRecommended },
|
|
74
|
+
{ law: "suppression", rules: suppressionRules, recommended: suppressionRecommended },
|
|
75
|
+
{ law: "temporal", rules: temporalRules, recommended: temporalRecommended },
|
|
49
76
|
{ law: "testing", rules: testingRules, recommended: testingRecommended },
|
|
50
77
|
{ law: "transport", rules: transportRules, recommended: transportRecommended },
|
|
51
78
|
{ law: "type-safety", rules: typeSafetyRules, recommended: typeSafetyRecommended },
|
|
79
|
+
{ law: "user-copy", rules: userCopyRules, recommended: userCopyRecommended },
|
|
52
80
|
]
|
|
53
81
|
|
|
54
82
|
/** Every gathered law. */
|
|
@@ -105,3 +133,5 @@ export default {
|
|
|
105
133
|
meta: { name: "eslint-plugin-starci-be" },
|
|
106
134
|
rules,
|
|
107
135
|
}
|
|
136
|
+
|
|
137
|
+
export { starciBeConfig, linterOptions } from "./lib/config.mjs"
|
package/input-bounds.mjs
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rules that keep input bounded (R42 `BE_INPUT_BOUNDED`).
|
|
3
|
+
*
|
|
4
|
+
* `dto-needs-validator` refuses an input class whose property carries no `class-validator` decorator. A property with
|
|
5
|
+
* no validator accepts any value the wire delivers: a string of any length, a number of any size, an array of any
|
|
6
|
+
* count. The transport layer states what it accepts; the use case never re-checks shape.
|
|
7
|
+
*
|
|
8
|
+
* An input class is one decorated `@InputType` or `@ArgsType`, or declared in a file named `*.dto.ts`, `*.input.ts`,
|
|
9
|
+
* `*.args.ts` or `*.request.ts`. A class decorated `@ObjectType` is a response and is left alone. A decorator counts as
|
|
10
|
+
* a validator only when it is imported from `class-validator`, so a `@Field` or a `@Type` does not satisfy the rule.
|
|
11
|
+
*/
|
|
12
|
+
import { decoratorName } from "./lib/ast.mjs"
|
|
13
|
+
import { isDeclarationFile, isTestLane, normalizePath } from "./lib/path.mjs"
|
|
14
|
+
|
|
15
|
+
const INPUT_FILE = /\.(?:dto|input|args|request)\.ts$/
|
|
16
|
+
|
|
17
|
+
const decoratorsOf = (node) => node.decorators ?? []
|
|
18
|
+
|
|
19
|
+
/** Names imported from `class-validator`, by local name. */
|
|
20
|
+
const validatorImports = (program) => {
|
|
21
|
+
const names = new Set()
|
|
22
|
+
for (const statement of program.body) {
|
|
23
|
+
if (statement.type !== "ImportDeclaration" || statement.source.value !== "class-validator") continue
|
|
24
|
+
for (const specifier of statement.specifiers) names.add(specifier.local.name)
|
|
25
|
+
}
|
|
26
|
+
return names
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Every property of an input class has a validator. */
|
|
30
|
+
export const dtoNeedsValidator = {
|
|
31
|
+
meta: {
|
|
32
|
+
type: "problem",
|
|
33
|
+
docs: { description: "A property of an input class carries a `class-validator` decorator." },
|
|
34
|
+
schema: [],
|
|
35
|
+
messages: {
|
|
36
|
+
missing:
|
|
37
|
+
"`{{name}}` has no `class-validator` decorator, so the wire may deliver any value here: a string of any length, a number of any size, an array of any count. Add the validators the field needs (`@IsString() @MaxLength(n)`, `@IsInt() @Min(0) @Max(n)`, `@IsArray() @ArrayMaxSize(n)`, `@IsOptional()` for a nullable field).",
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
create(context) {
|
|
41
|
+
const filename = normalizePath(context.filename || context.getFilename())
|
|
42
|
+
if (isTestLane(filename) || isDeclarationFile(filename)) return {}
|
|
43
|
+
const sourceCode = context.sourceCode || context.getSourceCode()
|
|
44
|
+
let validators = null
|
|
45
|
+
return {
|
|
46
|
+
Program(node) {
|
|
47
|
+
validators = validatorImports(node)
|
|
48
|
+
},
|
|
49
|
+
ClassDeclaration(node) {
|
|
50
|
+
checkClass(node)
|
|
51
|
+
},
|
|
52
|
+
ClassExpression(node) {
|
|
53
|
+
checkClass(node)
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
function checkClass(node) {
|
|
57
|
+
const decorators = decoratorsOf(node).map(decoratorName)
|
|
58
|
+
const target = node.parent?.type === "ExportNamedDeclaration" || node.parent?.type === "ExportDefaultDeclaration" ? node.parent : node
|
|
59
|
+
const classDecorators = [...decorators, ...decoratorsOf(target).map(decoratorName)]
|
|
60
|
+
if (classDecorators.includes("ObjectType")) return
|
|
61
|
+
const isInput = classDecorators.includes("InputType") || classDecorators.includes("ArgsType") || INPUT_FILE.test(filename)
|
|
62
|
+
if (!isInput) return
|
|
63
|
+
for (const member of node.body.body) {
|
|
64
|
+
if (member.type !== "PropertyDefinition" || member.static || member.computed) continue
|
|
65
|
+
if (member.key.type !== "Identifier") continue
|
|
66
|
+
const validated = decoratorsOf(member).some((decorator) => {
|
|
67
|
+
const name = decoratorName(decorator)
|
|
68
|
+
return name !== null && validators.has(name)
|
|
69
|
+
})
|
|
70
|
+
if (!validated) context.report({ node: member.key, messageId: "missing", data: { name: sourceCode.getText(member.key) } })
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The rules this law contributes to the plugin. */
|
|
77
|
+
export const rules = {
|
|
78
|
+
"dto-needs-validator": dtoNeedsValidator,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Starts at error: no baseline exists, and the repositories' fix lanes clear the debt. */
|
|
82
|
+
export const recommended = {
|
|
83
|
+
"starci-be/dto-needs-validator": "error",
|
|
84
|
+
}
|
package/lib/ast.mjs
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Small AST helpers the HFS laws share, so each rule states its question instead of its traversal.
|
|
3
|
+
*
|
|
4
|
+
* Nothing here touches disk or the type checker: a rule reads one file's syntax and its path.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const SKIP_KEYS = new Set(["parent", "loc", "range", "tokens", "comments"])
|
|
8
|
+
|
|
9
|
+
const isNode = (value) => value !== null && typeof value === "object" && typeof value.type === "string"
|
|
10
|
+
|
|
11
|
+
const FUNCTION_TYPES = new Set(["FunctionDeclaration", "FunctionExpression", "ArrowFunctionExpression"])
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Visits `node` and every descendant, depth first.
|
|
15
|
+
*
|
|
16
|
+
* @param {object} node - The subtree root.
|
|
17
|
+
* @param {(child: object) => void} visit - Called with each node.
|
|
18
|
+
* @param {{ intoFunctions?: boolean }} [options] - `intoFunctions: false` stops at nested function boundaries.
|
|
19
|
+
* @returns {void}
|
|
20
|
+
*/
|
|
21
|
+
export const walk = (node, visit, { intoFunctions = true } = {}) => {
|
|
22
|
+
if (!isNode(node)) return
|
|
23
|
+
visit(node)
|
|
24
|
+
for (const [key, value] of Object.entries(node)) {
|
|
25
|
+
if (SKIP_KEYS.has(key)) continue
|
|
26
|
+
if (Array.isArray(value)) {
|
|
27
|
+
for (const child of value) {
|
|
28
|
+
if (!isNode(child)) continue
|
|
29
|
+
if (!intoFunctions && FUNCTION_TYPES.has(child.type)) continue
|
|
30
|
+
walk(child, visit, { intoFunctions })
|
|
31
|
+
}
|
|
32
|
+
} else if (isNode(value)) {
|
|
33
|
+
if (!intoFunctions && FUNCTION_TYPES.has(value.type)) continue
|
|
34
|
+
walk(value, visit, { intoFunctions })
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** True when any node of the subtree satisfies `predicate`. */
|
|
40
|
+
export const some = (node, predicate, options) => {
|
|
41
|
+
let found = false
|
|
42
|
+
walk(node, (child) => {
|
|
43
|
+
if (!found && predicate(child)) found = true
|
|
44
|
+
}, options)
|
|
45
|
+
return found
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The name a property key, identifier or string literal spells, else null. */
|
|
49
|
+
export const keyName = (node) => {
|
|
50
|
+
if (!node) return null
|
|
51
|
+
if (node.type === "Identifier") return node.name
|
|
52
|
+
if (node.type === "Literal" && typeof node.value === "string") return node.value
|
|
53
|
+
if (node.type === "PrivateIdentifier") return node.name
|
|
54
|
+
return null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The name of a decorator (`@Foo` or `@Foo(...)`), else null. */
|
|
58
|
+
export const decoratorName = (decorator) => {
|
|
59
|
+
const expression = decorator?.expression
|
|
60
|
+
if (!expression) return null
|
|
61
|
+
if (expression.type === "Identifier") return expression.name
|
|
62
|
+
if (expression.type === "CallExpression" && expression.callee.type === "Identifier") return expression.callee.name
|
|
63
|
+
return null
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** True for a string literal or a template without substitutions, giving its text via `staticText`. */
|
|
67
|
+
export const staticText = (node) => {
|
|
68
|
+
if (!node) return null
|
|
69
|
+
if (node.type === "Literal" && typeof node.value === "string") return node.value
|
|
70
|
+
if (node.type === "TemplateLiteral" && node.expressions.length === 0) return node.quasis.map((q) => q.value.cooked ?? "").join("")
|
|
71
|
+
return null
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Splits `webhookSecret`, `WEBHOOK_SECRET` and `webhook-secret` into lower-case words. */
|
|
75
|
+
export const wordsOf = (name) =>
|
|
76
|
+
String(name)
|
|
77
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
78
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
|
|
79
|
+
.split(/[^A-Za-z0-9]+/)
|
|
80
|
+
.filter(Boolean)
|
|
81
|
+
.map((word) => word.toLowerCase())
|
package/lib/config.mjs
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one flat-config block that attaches the back-end canon to a repository.
|
|
3
|
+
*
|
|
4
|
+
* A consuming `eslint.config.mjs` used to hand-copy three things: the list of rules it switches off, the
|
|
5
|
+
* helper that lifts `warn` to `error`, and the source globs. Copies drift - two repositories held
|
|
6
|
+
* byte-identical files, a third had grown its own variant - and a rule that a copy switched off with the words
|
|
7
|
+
* "replaced by checks" was enforced by nothing when the replacing check ran in no gate. This factory owns the
|
|
8
|
+
* levels, so a repository states only its source globs.
|
|
9
|
+
*
|
|
10
|
+
* NO RULE IS OFF. HFS has no retired-rule list: a rule the standard no longer holds is deleted from the
|
|
11
|
+
* plugin, and a rule that stays is on. The factory refuses a recommendation that carries an `off`, so a rule
|
|
12
|
+
* cannot be published switched off and still look adopted.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** A disable directive is a weakening of lint, and one that suppresses nothing is dead weight. */
|
|
16
|
+
export const linterOptions = Object.freeze({ noInlineConfig: true, reportUnusedDisableDirectives: "error" })
|
|
17
|
+
|
|
18
|
+
/** The zero-warning gate reads a warning as a failure, so the canon's `warn` level is stated as `error`. */
|
|
19
|
+
const asError = (setting) =>
|
|
20
|
+
setting === "warn" ? "error" : Array.isArray(setting) && setting[0] === "warn" ? ["error", ...setting.slice(1)] : setting
|
|
21
|
+
|
|
22
|
+
const levelOf = (setting) => (Array.isArray(setting) ? setting[0] : setting)
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {object} input - Attachment options.
|
|
26
|
+
* @param {string[]} input.sources - The globs of the repository's production and spec source.
|
|
27
|
+
* @param {object} input.plugin - The plugin object, imported from this canon.
|
|
28
|
+
* @param {Record<string, unknown>} input.recommended - The recommendation, imported from this canon.
|
|
29
|
+
* @returns {object} One flat-config block.
|
|
30
|
+
*/
|
|
31
|
+
export const starciBeConfig = ({ sources, plugin, recommended }) => {
|
|
32
|
+
if (!Array.isArray(sources) || sources.length === 0) {
|
|
33
|
+
throw new Error("starciBeConfig needs the repository's source globs - a block that matches no file governs nothing")
|
|
34
|
+
}
|
|
35
|
+
const rules = recommended?.rules ?? recommended
|
|
36
|
+
if (!rules || Object.keys(rules).length === 0) {
|
|
37
|
+
throw new Error("starciBeConfig received an empty recommendation - a config with no rules is not adoption")
|
|
38
|
+
}
|
|
39
|
+
const off = Object.entries(rules)
|
|
40
|
+
.filter(([, setting]) => levelOf(setting) === "off")
|
|
41
|
+
.map(([name]) => name)
|
|
42
|
+
if (off.length > 0) {
|
|
43
|
+
throw new Error(`starciBeConfig refuses a recommendation with rules switched off: ${off.join(", ")}`)
|
|
44
|
+
}
|
|
45
|
+
const configured = Object.fromEntries(
|
|
46
|
+
Object.entries(rules).map(([name, setting]) => [name, asError(setting)]),
|
|
47
|
+
)
|
|
48
|
+
return { files: sources, linterOptions: { ...linterOptions }, plugins: { "starci-be": plugin }, rules: configured }
|
|
49
|
+
}
|
package/lib/path.mjs
CHANGED
|
@@ -1,2 +1,17 @@
|
|
|
1
1
|
/** Forward-slash form of a filename, so Windows paths compare like every other path. */
|
|
2
2
|
export const normalizePath = (filename) => String(filename || "").replace(/\\/g, "/")
|
|
3
|
+
|
|
4
|
+
/** A spec file: the unit lane, where a fixture may build a deliberately wrong value. */
|
|
5
|
+
export const isSpecFile = (filename) => /\.(?:spec|test)\.[cm]?[jt]sx?$|-spec\.[cm]?[jt]sx?$/.test(normalizePath(filename))
|
|
6
|
+
|
|
7
|
+
/** Any file of the test lanes: a spec, or anything under `src/tests/`. */
|
|
8
|
+
export const isTestLane = (filename) => {
|
|
9
|
+
const file = normalizePath(filename)
|
|
10
|
+
return isSpecFile(file) || file.includes("/src/tests/")
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** A migration: the one place schema-changing SQL is allowed to be written. */
|
|
14
|
+
export const isMigrationFile = (filename) => /\/persistence\/migrations\/[^/]+\.[cm]?[jt]s$/.test(normalizePath(filename))
|
|
15
|
+
|
|
16
|
+
/** A declaration file carries types only and no runtime behaviour to police. */
|
|
17
|
+
export const isDeclarationFile = (filename) => /\.d\.[cm]?ts$/.test(normalizePath(filename))
|