@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.
Files changed (62) hide show
  1. package/CHANGELOG.md +95 -5
  2. package/README.md +41 -52
  3. package/async-discipline.mjs +2 -2
  4. package/authorization.mjs +38 -88
  5. package/builders.mjs +305 -0
  6. package/comments.mjs +19 -34
  7. package/config-owner.mjs +127 -125
  8. package/connections.mjs +254 -0
  9. package/cqrs.mjs +426 -135
  10. package/data-access.mjs +320 -391
  11. package/default-deny.mjs +14 -10
  12. package/e2e-flow.mjs +208 -180
  13. package/error-handling.mjs +254 -128
  14. package/idempotency.mjs +98 -67
  15. package/index.mjs +32 -9
  16. package/infra-owner.mjs +96 -0
  17. package/injection.mjs +602 -0
  18. package/input-bounds.mjs +166 -66
  19. package/lib/config.mjs +70 -33
  20. package/lib/db-writes.mjs +58 -0
  21. package/lib/declared.mjs +154 -0
  22. package/lib/hfs.mjs +61 -0
  23. package/lib/import-source.mjs +104 -0
  24. package/lib/persistence.mjs +160 -0
  25. package/lib/ports.mjs +138 -0
  26. package/lib/transport-slots.mjs +14 -0
  27. package/lib/types.mjs +133 -0
  28. package/lib/unit-spec.mjs +33 -0
  29. package/log-safety.mjs +94 -26
  30. package/module-layering.mjs +180 -297
  31. package/module-shape.mjs +296 -59
  32. package/naming.mjs +44 -12
  33. package/observability.mjs +57 -91
  34. package/operations.mjs +201 -0
  35. package/package.json +10 -7
  36. package/query-safety.mjs +189 -223
  37. package/resilience.mjs +126 -31
  38. package/runtime/engine/plain-object.mjs +5 -0
  39. package/runtime/engine/runtime-root.mjs +32 -0
  40. package/runtime/engine/yaml.mjs +161 -0
  41. package/runtime/knowledge/hfs/slots.yaml +1111 -0
  42. package/runtime/scripts/lib/git.mjs +53 -0
  43. package/runtime/scripts/lib/glob.mjs +23 -0
  44. package/runtime/scripts/lib/hfs-allows.mjs +57 -0
  45. package/runtime/scripts/lib/hfs-slots.mjs +729 -0
  46. package/runtime/scripts/lib/hfs-view.mjs +68 -0
  47. package/runtime/scripts/lib/language.mjs +107 -0
  48. package/runtime/scripts/lib/path-key.mjs +17 -0
  49. package/runtime/scripts/lib/recorded-lines.mjs +27 -0
  50. package/schema-authority.mjs +273 -91
  51. package/size-budget.mjs +19 -61
  52. package/spec-no-skip.mjs +84 -0
  53. package/spec-quality.mjs +76 -35
  54. package/suppression.mjs +34 -16
  55. package/temporal.mjs +101 -36
  56. package/test-world.mjs +204 -0
  57. package/testing.mjs +99 -251
  58. package/transport.mjs +433 -142
  59. package/type-safety.mjs +50 -278
  60. package/unit-spec.mjs +452 -0
  61. package/user-copy.mjs +56 -37
  62. package/lib/slots.mjs +0 -83
@@ -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
- * TWO RULES, ONE HABIT: a failure is either named where its owner lives, or it is visible.
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
- * 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.
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 { 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
- }
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
- const parent = child.parent
46
- if (parent?.type === "MemberExpression" && parent.object === child) return
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, logs through a logger port, or returns an outcome that carries the cause." },
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 logger port, or return a typed outcome carrying it as `cause`.",
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, 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`.",
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
- /** 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"])
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
- /** `<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\//
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, extends `DomainError`, and is never a bare or framework throw. */
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: "Errors live in `<capability>/errors/`, extend DomainError, and no bare or framework error escapes." },
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}}` 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.",
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 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)
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
- 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 } })
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
- } else if (/Error$/.test(parent.name) && !inHome) {
178
- context.report({ node: node.id, messageId: "place", data: { name: node.id.name } })
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
- 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
- }
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
- /** Both start at error: HFS has no baseline, and the migration lanes clear the debt before a repository adopts them. */
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
  }