@starci/eslint-canon-be 1.8.0 → 2.0.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 +95 -5
- package/README.md +41 -52
- package/async-discipline.mjs +2 -2
- package/authorization.mjs +38 -88
- package/builders.mjs +305 -0
- package/comments.mjs +19 -34
- package/config-owner.mjs +127 -125
- package/connections.mjs +254 -0
- package/cqrs.mjs +426 -135
- package/data-access.mjs +320 -391
- package/default-deny.mjs +14 -10
- package/e2e-flow.mjs +208 -180
- package/error-handling.mjs +254 -128
- package/idempotency.mjs +98 -67
- package/index.mjs +32 -9
- package/infra-owner.mjs +96 -0
- package/injection.mjs +602 -0
- package/input-bounds.mjs +166 -66
- package/lib/config.mjs +70 -33
- package/lib/db-writes.mjs +58 -0
- package/lib/declared.mjs +154 -0
- package/lib/hfs.mjs +61 -0
- package/lib/import-source.mjs +104 -0
- package/lib/persistence.mjs +160 -0
- package/lib/ports.mjs +138 -0
- package/lib/transport-slots.mjs +14 -0
- package/lib/types.mjs +133 -0
- package/lib/unit-spec.mjs +33 -0
- package/log-safety.mjs +94 -26
- package/module-layering.mjs +180 -297
- package/module-shape.mjs +296 -59
- package/naming.mjs +44 -12
- package/observability.mjs +57 -91
- package/operations.mjs +201 -0
- package/package.json +10 -7
- package/query-safety.mjs +189 -223
- package/resilience.mjs +126 -31
- package/runtime/engine/plain-object.mjs +5 -0
- package/runtime/engine/runtime-root.mjs +32 -0
- package/runtime/engine/yaml.mjs +161 -0
- package/runtime/knowledge/hfs/slots.yaml +1111 -0
- package/runtime/scripts/lib/git.mjs +53 -0
- package/runtime/scripts/lib/glob.mjs +23 -0
- package/runtime/scripts/lib/hfs-allows.mjs +57 -0
- package/runtime/scripts/lib/hfs-slots.mjs +729 -0
- package/runtime/scripts/lib/hfs-view.mjs +68 -0
- package/runtime/scripts/lib/language.mjs +107 -0
- package/runtime/scripts/lib/path-key.mjs +17 -0
- package/runtime/scripts/lib/recorded-lines.mjs +27 -0
- package/schema-authority.mjs +273 -91
- package/size-budget.mjs +19 -61
- package/spec-no-skip.mjs +84 -0
- package/spec-quality.mjs +76 -35
- package/suppression.mjs +34 -16
- package/temporal.mjs +101 -36
- package/test-world.mjs +204 -0
- package/testing.mjs +99 -251
- package/transport.mjs +433 -142
- package/type-safety.mjs +50 -278
- package/unit-spec.mjs +452 -0
- package/user-copy.mjs +56 -37
- package/lib/slots.mjs +0 -83
package/error-handling.mjs
CHANGED
|
@@ -1,40 +1,35 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The rules that hold HFS errors and their handling (catalog R38 `BE_ERROR_HOME`, R40 `BE_LOGGER_REQUIRED`).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* One habit: a failure is either named where its owner lives, or it is visible. Every question is answered by TYPE and by
|
|
5
|
+
* the owner that declares the type (BE-CONVENTION 1.8, 3.1): no rule here matches a class, receiver or file by its name.
|
|
5
6
|
*
|
|
6
|
-
* - `catch-must-account` refuses a `catch` (and a promise `.catch`) that swallows. A handler passes when it
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* `
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* HFS no longer has, and a rule that survives its standard as "off" is a rule nobody can trust.
|
|
7
|
+
* - `catch-must-account` refuses a `catch` (and a promise `.catch`) that swallows. A handler passes when it rethrows,
|
|
8
|
+
* returns an outcome carrying the caught error, or calls a method on a receiver typed `Logger` from `platform/logging`.
|
|
9
|
+
* - `error-home` keeps every error class where its owner is: a class that derives from `DomainError` is declared only in
|
|
10
|
+
* `errors/<capability>.error.ts` of its own owner, and a class that derives from the built-in `Error` some other way
|
|
11
|
+
* (`AbstractException`, a framework `HttpException` subclass, a bare `extends Error`) is refused.
|
|
12
|
+
* - `throw-domain-error` refuses a `throw` whose type does not derive from `DomainError` (`throw new Error`, the Nest
|
|
13
|
+
* `HttpException` family, a thrown string or object), and a domain owner that throws a platform error outward.
|
|
14
|
+
* - `error-family-shape` holds the one shape of `errors/<c>.error.ts`: a `<C>ErrorCode` string enum, a
|
|
15
|
+
* `<C>_ERROR_KINDS: Record<<C>ErrorCode, ErrorKind>` table, and an empty `<C>Error extends DomainError<<C>ErrorCode>`.
|
|
16
|
+
* Code uniqueness across the repository is the architecture machine's job, not a single file's.
|
|
17
17
|
*/
|
|
18
18
|
import { some, walk } from "./lib/ast.mjs"
|
|
19
|
-
import {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
}
|
|
19
|
+
import {
|
|
20
|
+
derives,
|
|
21
|
+
derivesFromBuiltinError,
|
|
22
|
+
derivesFromDomainError,
|
|
23
|
+
errorHomeOf,
|
|
24
|
+
isOwnedBy,
|
|
25
|
+
isOwnedType,
|
|
26
|
+
ownerName,
|
|
27
|
+
partsOf,
|
|
28
|
+
typeOf,
|
|
29
|
+
} from "./lib/declared.mjs"
|
|
30
|
+
import { hfsOf } from "./lib/hfs.mjs"
|
|
31
|
+
import { normalizePath } from "./lib/path.mjs"
|
|
32
|
+
import { typed } from "./lib/types.mjs"
|
|
38
33
|
|
|
39
34
|
/** True when `name` is used as a value in `node` (not just as the object of `name.message`). */
|
|
40
35
|
const carriesCaught = (node, name) => {
|
|
@@ -42,45 +37,44 @@ const carriesCaught = (node, name) => {
|
|
|
42
37
|
let carried = false
|
|
43
38
|
walk(node, (child) => {
|
|
44
39
|
if (child.type !== "Identifier" || child.name !== name) return
|
|
45
|
-
|
|
46
|
-
|
|
40
|
+
let inner = child
|
|
41
|
+
while (inner.parent && (inner.parent.type === "TSAsExpression" || inner.parent.type === "TSNonNullExpression" || inner.parent.type === "TSTypeAssertion")) inner = inner.parent
|
|
42
|
+
const parent = inner.parent
|
|
43
|
+
if (parent?.type === "MemberExpression" && parent.object === inner) return
|
|
47
44
|
carried = true
|
|
48
45
|
})
|
|
49
46
|
return carried
|
|
50
47
|
}
|
|
51
48
|
|
|
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
49
|
const paramName = (fn) => (fn.params?.[0]?.type === "Identifier" ? fn.params[0].name : null)
|
|
70
50
|
|
|
71
|
-
/** Every failure is rethrown, logged, or returned as an outcome carrying its cause. */
|
|
51
|
+
/** Every failure is rethrown, logged through the logger port, or returned as an outcome carrying its cause. */
|
|
72
52
|
export const catchMustAccount = {
|
|
73
53
|
meta: {
|
|
74
54
|
type: "problem",
|
|
75
|
-
docs: { description: "A catch rethrows,
|
|
55
|
+
docs: { description: "A catch rethrows, calls a method on a `Logger` receiver, or returns an outcome that carries the cause." },
|
|
76
56
|
schema: [],
|
|
77
57
|
messages: {
|
|
78
|
-
empty: "This `catch` is empty, so the failure vanishes. Rethrow it, log it through the
|
|
58
|
+
empty: "This `catch` is empty, so the failure vanishes. Rethrow it, log it through the `Logger` of `platform/logging`, or return a typed outcome carrying it as `cause`.",
|
|
79
59
|
swallowed:
|
|
80
|
-
"This `catch` neither rethrows, logs
|
|
60
|
+
"This `catch` neither rethrows, logs through the `Logger` of `platform/logging`, nor returns an outcome carrying the caught error, so the failure leaves no trace. Rethrow it, log it through the `Logger`, or return a typed outcome with `cause`.",
|
|
81
61
|
},
|
|
82
62
|
},
|
|
83
63
|
create(context) {
|
|
64
|
+
/** A call of a method on a receiver whose type is the `Logger` port declared by `platform/logging`. */
|
|
65
|
+
const isLoggerCall = (node) =>
|
|
66
|
+
node.type === "CallExpression" &&
|
|
67
|
+
node.callee.type === "MemberExpression" &&
|
|
68
|
+
isOwnedType(context, node.callee.object, "Logger", "platform", "logging")
|
|
69
|
+
const accounts = (body, param) =>
|
|
70
|
+
some(
|
|
71
|
+
body,
|
|
72
|
+
(node) =>
|
|
73
|
+
node.type === "ThrowStatement" ||
|
|
74
|
+
isLoggerCall(node) ||
|
|
75
|
+
(node.type === "ReturnStatement" && node.argument !== null && carriesCaught(node.argument, param)),
|
|
76
|
+
{ intoFunctions: false },
|
|
77
|
+
)
|
|
84
78
|
const checkBlock = (reportNode, body, param) => {
|
|
85
79
|
if (body.body.length === 0) context.report({ node: reportNode, messageId: "empty" })
|
|
86
80
|
else if (!accounts(body, param)) context.report({ node: reportNode, messageId: "swallowed" })
|
|
@@ -104,93 +98,221 @@ export const catchMustAccount = {
|
|
|
104
98
|
},
|
|
105
99
|
}
|
|
106
100
|
|
|
107
|
-
/**
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
"
|
|
115
|
-
|
|
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"])
|
|
101
|
+
/**
|
|
102
|
+
* The test world (slot `be.tests.world`) is the TEST COMPOSITION ROOT and owns no capability folder: its one error class
|
|
103
|
+
* `TestWorldError` lives in `test-world.error.ts` at the world root, which is the world's `errors/<capability>.error.ts`.
|
|
104
|
+
* Only that file is a home; a nested `fakes/**` file, a spec and every other owner stay judged by `errors/<c>.error.ts`.
|
|
105
|
+
*/
|
|
106
|
+
const isWorldErrorHome = (hfs, filename) => {
|
|
107
|
+
const placed = hfs.classify(filename)
|
|
108
|
+
return placed.slot === "be.tests.world" && placed.path === `${placed.root}/test-world.error.ts`
|
|
109
|
+
}
|
|
129
110
|
|
|
130
|
-
/**
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
111
|
+
/** True when a class of a unit spec (`<name>.spec.ts`) is local to it: neither exported by declaration nor by an `export { Name }` list. */
|
|
112
|
+
const isSpecLocalClass = (filename, node) => {
|
|
113
|
+
if (!/\.spec\.[cm]?ts$/.test(filename) || !node.id) return false
|
|
114
|
+
if (node.parent?.type === "ExportNamedDeclaration" || node.parent?.type === "ExportDefaultDeclaration") return false
|
|
115
|
+
const program = node.parent?.type === "Program" ? node.parent : null
|
|
116
|
+
if (!program) return node.type === "ClassDeclaration" || node.type === "ClassExpression"
|
|
117
|
+
return !program.body.some((statement) => statement.type === "ExportNamedDeclaration" && !statement.source && statement.specifiers.some((specifier) => (specifier.local.name ?? specifier.local.value) === node.id.name))
|
|
118
|
+
}
|
|
137
119
|
|
|
138
|
-
/** An error class is declared at its owner
|
|
120
|
+
/** An error class is declared at its owner in `errors/<capability>.error.ts` and derives from `DomainError`. */
|
|
139
121
|
export const errorHome = {
|
|
140
122
|
meta: {
|
|
141
123
|
type: "problem",
|
|
142
|
-
docs: { description: "
|
|
124
|
+
docs: { description: "An error class derives from `DomainError` and is declared only in `errors/<capability>.error.ts` of its owner." },
|
|
143
125
|
schema: [],
|
|
144
126
|
messages: {
|
|
145
|
-
place: "`{{name}}`
|
|
146
|
-
|
|
147
|
-
|
|
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.",
|
|
127
|
+
place: "`{{name}}` derives from `DomainError` but is not declared in `errors/{{owner}}.error.ts` of its owner. An owner has one error class, in that file; move it there.",
|
|
128
|
+
mustExtendDomainError:
|
|
129
|
+
"`{{name}}` derives from the built-in `Error` without deriving from `DomainError` (`AbstractException`, a framework exception or a bare `extends Error`). Extend `DomainError` from `platform/errors` in the owner's `errors/<capability>.error.ts`.",
|
|
152
130
|
},
|
|
153
131
|
},
|
|
154
132
|
create(context) {
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
133
|
+
const hfs = hfsOf(context)
|
|
134
|
+
const filename = normalizePath(context.filename)
|
|
135
|
+
const check = (node) => {
|
|
136
|
+
if (!node.id) return
|
|
137
|
+
const type = typeOf(context, node.id)
|
|
138
|
+
if (!type) return
|
|
139
|
+
const isDomainErrorHost = node.id.name === "DomainError" && isOwnedBy(hfs, filename, "platform", "errors")
|
|
140
|
+
if (isDomainErrorHost) return
|
|
141
|
+
if (derivesFromDomainError(context, type)) {
|
|
142
|
+
if (isWorldErrorHome(hfs, filename)) return
|
|
143
|
+
// A spec-local subclass of the abstract base is a test double for code that takes any DomainError, not an error family.
|
|
144
|
+
if (isSpecLocalClass(filename, node)) return
|
|
145
|
+
const home = errorHomeOf(hfs, filename)
|
|
146
|
+
const owner = ownerName(hfs.ownerOf(filename))
|
|
147
|
+
if (!home || home.capability !== owner) context.report({ node: node.id, messageId: "place", data: { name: node.id.name, owner: owner ?? "<capability>" } })
|
|
148
|
+
} else if (derivesFromBuiltinError(context, type)) {
|
|
149
|
+
context.report({ node: node.id, messageId: "mustExtendDomainError", data: { name: node.id.name } })
|
|
150
|
+
}
|
|
167
151
|
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
152
|
+
return { ClassDeclaration: check, ClassExpression: check }
|
|
153
|
+
},
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The variable a thrown identifier names is the parameter of a `catch` clause or of a promise `.catch` handler. */
|
|
157
|
+
const isCaughtValue = (context, identifier) => {
|
|
158
|
+
for (let scope = context.sourceCode.getScope(identifier); scope; scope = scope.upper) {
|
|
159
|
+
const variable = scope.set.get(identifier.name)
|
|
160
|
+
if (!variable) continue
|
|
161
|
+
return variable.defs.some((definition) => {
|
|
162
|
+
if (definition.type === "CatchClause") return true
|
|
163
|
+
if (definition.type !== "Parameter") return false
|
|
164
|
+
const fn = definition.node
|
|
165
|
+
const call = fn.parent
|
|
166
|
+
return (
|
|
167
|
+
(fn.type === "ArrowFunctionExpression" || fn.type === "FunctionExpression") &&
|
|
168
|
+
call?.type === "CallExpression" &&
|
|
169
|
+
call.arguments[0] === fn &&
|
|
170
|
+
call.callee.type === "MemberExpression" &&
|
|
171
|
+
!call.callee.computed &&
|
|
172
|
+
call.callee.property.type === "Identifier" &&
|
|
173
|
+
call.callee.property.name === "catch" &&
|
|
174
|
+
fn.params[0] === definition.name
|
|
175
|
+
)
|
|
176
|
+
})
|
|
177
|
+
}
|
|
178
|
+
return false
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Every thrown value is a `DomainError` of the owner that throws it. */
|
|
182
|
+
export const throwDomainError = {
|
|
183
|
+
meta: {
|
|
184
|
+
type: "problem",
|
|
185
|
+
docs: { description: "A thrown value's type derives from `DomainError`; a domain owner never throws a platform error outward." },
|
|
186
|
+
schema: [],
|
|
187
|
+
messages: {
|
|
188
|
+
bareError: "`throw new Error(...)` carries a sentence and no code, so nothing downstream can group or map it. Throw the `<C>Error` of the owning capability with one of its codes.",
|
|
189
|
+
framework: "`throw new {{name}}(...)` carries an HTTP status and no identity. Throw the `<C>Error` of the owning capability; the one filter of `platform/errors` maps its code to a status.",
|
|
190
|
+
notDomainError: "This `throw` does not throw a `DomainError`. Throw the `<C>Error` of the owning capability with one of its codes, or return an `Outcome` and let the transport unwrap it.",
|
|
191
|
+
platformThrown: "A domain owner throws `{{name}}`, an error of a platform owner. Translate it into the error of this capability so platform details do not cross the boundary.",
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
create(context) {
|
|
195
|
+
const hfs = hfsOf(context)
|
|
196
|
+
const filename = normalizePath(context.filename)
|
|
197
|
+
const { checker } = typed(context)
|
|
198
|
+
const isHttpException = (type) => derives(checker, type, (name, file) => name === "HttpException" && /\/node_modules\/@nestjs\/common\//.test(file))
|
|
199
|
+
return {
|
|
200
|
+
ThrowStatement(node) {
|
|
201
|
+
const thrown = node.argument
|
|
202
|
+
if (!thrown) return
|
|
203
|
+
if (thrown.type === "Identifier" && isCaughtValue(context, thrown)) return
|
|
204
|
+
const type = typeOf(context, thrown)
|
|
205
|
+
const parts = partsOf(type)
|
|
206
|
+
const foreign = parts.find((part) => !derivesFromDomainError(context, part))
|
|
207
|
+
if (parts.length === 0 || foreign) {
|
|
208
|
+
if (foreign && isHttpException(foreign)) {
|
|
209
|
+
context.report({ node: thrown, messageId: "framework", data: { name: foreign.getSymbol()?.name ?? "HttpException" } })
|
|
210
|
+
} else if (foreign && derivesFromBuiltinError(context, foreign)) {
|
|
211
|
+
context.report({ node: thrown, messageId: "bareError" })
|
|
212
|
+
} else {
|
|
213
|
+
context.report({ node: thrown, messageId: "notDomainError" })
|
|
214
|
+
}
|
|
215
|
+
return
|
|
176
216
|
}
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
217
|
+
if (hfs.tierOf(filename) !== "domain") return
|
|
218
|
+
for (const part of parts) {
|
|
219
|
+
const declarations = part.getSymbol()?.declarations ?? []
|
|
220
|
+
const platform = declarations.find((declaration) => hfs.tierOf(String(declaration.getSourceFile().fileName).replace(/\\/g, "/")) === "platform")
|
|
221
|
+
if (platform) {
|
|
222
|
+
context.report({ node: thrown, messageId: "platformThrown", data: { name: part.getSymbol().name } })
|
|
223
|
+
return
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
},
|
|
180
227
|
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
228
|
+
},
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const words = (kebab) => kebab.split("-")
|
|
232
|
+
const pascal = (kebab) => words(kebab).map((word) => word[0].toUpperCase() + word.slice(1)).join("")
|
|
233
|
+
const upperSnake = (kebab) => words(kebab).join("_").toUpperCase()
|
|
234
|
+
|
|
235
|
+
/** The exported declaration inside an `export ...` statement, else null. */
|
|
236
|
+
const exportedDeclaration = (statement) => (statement.type === "ExportNamedDeclaration" ? statement.declaration : null)
|
|
237
|
+
|
|
238
|
+
/** The one shape of an owner's `errors/<capability>.error.ts`. */
|
|
239
|
+
export const errorFamilyShape = {
|
|
240
|
+
meta: {
|
|
241
|
+
type: "problem",
|
|
242
|
+
docs: { description: "`errors/<c>.error.ts` exports one code enum, one `Record<Code, ErrorKind>` table and one empty error class." },
|
|
243
|
+
schema: [],
|
|
244
|
+
messages: {
|
|
245
|
+
noCode: "This file must export `enum {{pascal}}ErrorCode` with one member per code of the capability.",
|
|
246
|
+
extra: "`{{name}}` is an extra export. An error family file exports only `{{pascal}}ErrorCode`, `{{upper}}_ERROR_KINDS` and `{{pascal}}Error`.",
|
|
247
|
+
memberValue: "`{{member}}` must be a string literal `{{upper}}_<WHAT>` in upper snake case with no `_EXCEPTION` or `_ERROR` suffix.",
|
|
248
|
+
noKinds: "This file must export `const {{upper}}_ERROR_KINDS: Record<{{pascal}}ErrorCode, ErrorKind>` so the compiler proves every code has a kind.",
|
|
249
|
+
kindsType: "`{{upper}}_ERROR_KINDS` must be annotated `Record<{{pascal}}ErrorCode, ErrorKind>` with `ErrorKind` from `platform/errors`; only that annotation makes the table exhaustive.",
|
|
250
|
+
noClass: "This file must export `class {{pascal}}Error extends DomainError<{{pascal}}ErrorCode> {}`, the one error class of the capability.",
|
|
251
|
+
classShape: "`{{pascal}}Error` must extend `DomainError<{{pascal}}ErrorCode>` from `platform/errors` and declare nothing beyond its JSDoc: no body, no per-case subclass.",
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
create(context) {
|
|
255
|
+
const hfs = hfsOf(context)
|
|
256
|
+
const home = errorHomeOf(hfs, normalizePath(context.filename))
|
|
257
|
+
if (!home) return {}
|
|
258
|
+
const data = { pascal: pascal(home.capability), upper: upperSnake(home.capability) }
|
|
259
|
+
const codeName = `${data.pascal}ErrorCode`
|
|
260
|
+
const kindsName = `${data.upper}_ERROR_KINDS`
|
|
261
|
+
const className = `${data.pascal}Error`
|
|
262
|
+
return {
|
|
263
|
+
Program(program) {
|
|
264
|
+
const declared = program.body.map(exportedDeclaration).filter(Boolean)
|
|
265
|
+
const enums = declared.filter((declaration) => declaration.type === "TSEnumDeclaration")
|
|
266
|
+
const code = enums.find((declaration) => declaration.id.name === codeName)
|
|
267
|
+
if (!code) context.report({ node: program, loc: { line: 1, column: 0 }, messageId: "noCode", data })
|
|
268
|
+
else {
|
|
269
|
+
const prefix = new RegExp(`^${data.upper}_[A-Z0-9]+(?:_[A-Z0-9]+)*$`)
|
|
270
|
+
for (const member of code.body?.members ?? code.members) {
|
|
271
|
+
const value = member.initializer
|
|
272
|
+
const text = value?.type === "Literal" && typeof value.value === "string" ? value.value : null
|
|
273
|
+
if (text === null || !prefix.test(text) || /_(?:EXCEPTION|ERROR)$/.test(text)) {
|
|
274
|
+
context.report({ node: member, messageId: "memberValue", data: { ...data, member: member.id.name ?? member.id.value } })
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
const kinds = declared.find((declaration) => declaration.type === "VariableDeclaration" && declaration.declarations.some((d) => d.id.name === kindsName))
|
|
279
|
+
if (!kinds) context.report({ node: program, loc: { line: 1, column: 0 }, messageId: "noKinds", data })
|
|
280
|
+
else {
|
|
281
|
+
const declarator = kinds.declarations.find((d) => d.id.name === kindsName)
|
|
282
|
+
const annotation = declarator.id.typeAnnotation?.typeAnnotation
|
|
283
|
+
const args = annotation?.typeArguments?.params ?? annotation?.typeParameters?.params ?? []
|
|
284
|
+
const isRecord = annotation?.type === "TSTypeReference" && annotation.typeName.type === "Identifier" && annotation.typeName.name === "Record"
|
|
285
|
+
const keyOk = args[0]?.type === "TSTypeReference" && args[0].typeName.type === "Identifier" && args[0].typeName.name === codeName
|
|
286
|
+
const valueOk = args.length === 2 && args[1].type === "TSTypeReference" && isOwnedType(context, args[1], "ErrorKind", "platform", "errors")
|
|
287
|
+
if (!isRecord || args.length !== 2 || !keyOk || !valueOk) context.report({ node: declarator.id, messageId: "kindsType", data })
|
|
288
|
+
}
|
|
289
|
+
const cls = declared.find((declaration) => declaration.type === "ClassDeclaration" && declaration.id?.name === className)
|
|
290
|
+
if (!cls) context.report({ node: program, loc: { line: 1, column: 0 }, messageId: "noClass", data })
|
|
291
|
+
else {
|
|
292
|
+
const parent = cls.superClass
|
|
293
|
+
const args = cls.superTypeArguments?.params ?? cls.superTypeParameters?.params ?? []
|
|
294
|
+
const parentOk = parent && isOwnedType(context, parent, "DomainError", "platform", "errors")
|
|
295
|
+
const argOk = args.length === 1 && args[0].type === "TSTypeReference" && args[0].typeName.type === "Identifier" && args[0].typeName.name === codeName
|
|
296
|
+
if (!parentOk || !argOk || cls.body.body.length > 0) context.report({ node: cls.id, messageId: "classShape", data })
|
|
297
|
+
}
|
|
298
|
+
for (const statement of program.body) {
|
|
299
|
+
const declaration = exportedDeclaration(statement)
|
|
300
|
+
const isExport = statement.type === "ExportNamedDeclaration" || statement.type === "ExportDefaultDeclaration" || statement.type === "ExportAllDeclaration"
|
|
301
|
+
if (!isExport) continue
|
|
302
|
+
const name =
|
|
303
|
+
declaration?.type === "VariableDeclaration"
|
|
304
|
+
? declaration.declarations.map((d) => d.id.name).find((n) => n !== kindsName)
|
|
305
|
+
: declaration?.id?.name
|
|
306
|
+
if (declaration === null || declaration === undefined) {
|
|
307
|
+
context.report({ node: statement, messageId: "extra", data: { ...data, name: statement.type } })
|
|
308
|
+
} else if (name !== undefined && name !== codeName && name !== kindsName && name !== className) {
|
|
309
|
+
context.report({ node: declaration, messageId: "extra", data: { ...data, name } })
|
|
310
|
+
} else if (name === undefined && declaration.type !== "VariableDeclaration") {
|
|
311
|
+
context.report({ node: declaration, messageId: "extra", data: { ...data, name: declaration.type } })
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
},
|
|
192
315
|
}
|
|
193
|
-
return listeners
|
|
194
316
|
},
|
|
195
317
|
}
|
|
196
318
|
|
|
@@ -198,10 +320,14 @@ export const errorHome = {
|
|
|
198
320
|
export const rules = {
|
|
199
321
|
"catch-must-account": catchMustAccount,
|
|
200
322
|
"error-home": errorHome,
|
|
323
|
+
"throw-domain-error": throwDomainError,
|
|
324
|
+
"error-family-shape": errorFamilyShape,
|
|
201
325
|
}
|
|
202
326
|
|
|
203
|
-
/**
|
|
327
|
+
/** All start at error: HFS has no baseline, and the migration lanes clear the debt before a repository adopts them. */
|
|
204
328
|
export const recommended = {
|
|
205
329
|
"starci-be/catch-must-account": "error",
|
|
206
330
|
"starci-be/error-home": "error",
|
|
331
|
+
"starci-be/throw-domain-error": "error",
|
|
332
|
+
"starci-be/error-family-shape": "error",
|
|
207
333
|
}
|