@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/exceptions.mjs DELETED
@@ -1,262 +0,0 @@
1
- /**
2
- * The rules that hold `exceptions.md`.
3
- *
4
- * FOUR RULES, AND THE PAIRING IS THE POINT. Two watch the throw site and two watch the declaration,
5
- * because either half alone has a hole the other closes: a class that extends a framework base is
6
- * thrown by its own house-shaped name, so a throw-site rule passes it, and one such class stayed
7
- * live and thrown from four call sites while the gate stayed green.
8
- *
9
- * `throw-abstract-exception` is a heuristic and says so: a rule reading one file at a time cannot
10
- * verify that a thrown class extends anything, so it matches `Error` and a named list of framework
11
- * exceptions. `exception-extends-abstract` is what makes that heuristic sound - it guarantees the
12
- * only `*Exception` classes in the tree are house ones.
13
- */
14
-
15
- import { normalizePath } from "./lib/path.mjs"
16
-
17
- /** The house base every exception extends. */
18
- const EXCEPTION_BASE = "AbstractException"
19
-
20
- /** Anything named like an exception. */
21
- const EXCEPTION_NAME = /Exception$/
22
-
23
- /** Framework exceptions that carry an HTTP status and no identity. */
24
- const FRAMEWORK_EXCEPTIONS = new Set([
25
- "BadRequestException",
26
- "NotFoundException",
27
- "UnauthorizedException",
28
- "ForbiddenException",
29
- "InternalServerErrorException",
30
- "HttpException",
31
- "ConflictException",
32
- "NotAcceptableException",
33
- "RequestTimeoutException",
34
- "GoneException",
35
- "PayloadTooLargeException",
36
- "UnsupportedMediaTypeException",
37
- "UnprocessableEntityException",
38
- "NotImplementedException",
39
- "BadGatewayException",
40
- "ServiceUnavailableException",
41
- "GatewayTimeoutException",
42
- ])
43
-
44
- /** A feature or module capability owns its own errors, including nested error groups. */
45
- const ERRORS_FOLDER = /\/src\/(?:modules\/(?:domain|platform|integrations)|features)\/[^/]+\/(?:[^/]+\/)*errors\//
46
-
47
- /**
48
- * HFS puts domain failures with their domain capability and provider failures with their integration.
49
- * A feature can likewise own a transport or orchestration error in its own errors folder. A central
50
- * platform exceptions capability can still own generic errors.
51
- */
52
- const isInErrorsFolder = (filename) => ERRORS_FOLDER.test(normalizePath(filename))
53
-
54
- /** The base class's own file, which is the one class allowed to extend something else. */
55
- const isExceptionBaseFile = (filename) =>
56
- /\/src\/modules\/(?:domain|platform|integrations)\/[^/]+\/(?:errors\/)?abstract\.ts$/.test(normalizePath(filename))
57
-
58
- /**
59
- * The test lanes, where `throw new Error` is a test-runner assertion rather than a domain failure.
60
- *
61
- * THE CARVE-OUT WAS ALREADY GRANTED IN PROSE AND MISSING FROM THE RULE, which is the worst of the
62
- * two places for them to disagree: canon said the lanes may throw, the artifact said they may not,
63
- * and a repository adopting the rule inherited 69 findings its own canon had already excused.
64
- *
65
- * A flow that could not fail its own setup would have to invent a domain exception for "the fixture
66
- * is missing", which is a failure of the test rather than of the business - and naming it as a
67
- * domain failure would put it in the same vocabulary the product uses to describe real ones.
68
- */
69
- const isTestLane = (filename) => {
70
- const path = normalizePath(filename)
71
- return /\.spec\.ts$/.test(path) || /-spec\.ts$/.test(path) || path.includes("/src/tests/")
72
- }
73
-
74
- /**
75
- * A liveness or readiness probe, where the HTTP STATUS is the whole contract.
76
- *
77
- * THE RULE'S OWN REASON IS WHAT GRANTS THIS. A framework exception is refused because it "carries an
78
- * HTTP status and no identity" - the status is not enough to tell two failures apart. A probe is the
79
- * one endpoint where that sentence inverts: kubernetes reads the status code and NOTHING else, never
80
- * the body, so the status is the entire identity and a domain exception carrying a rich code would
81
- * arrive as a 500 and be read as "down" for the wrong reason.
82
- *
83
- * Narrow on purpose. Only a health controller qualifies, so this cannot become the place a service
84
- * goes to avoid naming its failures.
85
- */
86
- const isHealthProbe = (filename) => /\/health(?:z)?\.controller\.ts$|\/health\//.test(normalizePath(filename))
87
-
88
- // -- EXCEPTION-1 -----------------------------------------------------------------------------------
89
-
90
- /** Throw a house exception, never `Error` and never a framework built-in. */
91
- export const throwAbstractException = {
92
- meta: {
93
- type: "problem",
94
- docs: { description: "Throw an `AbstractException` subclass, not `Error` or a framework exception." },
95
- schema: [],
96
- messages: {
97
- bareError:
98
- "`throw new Error(...)` carries a sentence and no code, so nothing downstream can group, match or retry on it without parsing English. Throw the exception class that NAMES this failure.",
99
- framework:
100
- "`throw new {{name}}(...)` carries an HTTP status and no identity - two unrelated failures arrive at a client indistinguishable, and the only thing telling them apart is the message, which is the part that gets reworded. Throw an `AbstractException` subclass.",
101
- },
102
- },
103
- create(context) {
104
- const filename = context.filename || context.getFilename()
105
- // the lanes canon already excuses - see `isTestLane`
106
- if (isTestLane(filename)) return {}
107
- const probe = isHealthProbe(filename)
108
- return {
109
- ThrowStatement(node) {
110
- const thrown = node.argument
111
- if (!thrown || thrown.type !== "NewExpression" || thrown.callee.type !== "Identifier") return
112
- const name = thrown.callee.name
113
- if (name === "Error") {
114
- context.report({ node: thrown, messageId: "bareError" })
115
- return
116
- }
117
- // a probe answers with a status code and nothing else - see `isHealthProbe`. `Error` above
118
- // is still refused here: a probe's status is its contract, an unnamed crash is not.
119
- if (probe) return
120
- if (FRAMEWORK_EXCEPTIONS.has(name)) {
121
- context.report({ node: thrown, messageId: "framework", data: { name } })
122
- }
123
- },
124
- }
125
- },
126
- }
127
-
128
- // -- EXCEPTION-2 -----------------------------------------------------------------------------------
129
-
130
- /** The constructor takes one metadata object, `{}` when there is nothing to say. */
131
- export const requireExceptionObjectArg = {
132
- meta: {
133
- type: "problem",
134
- docs: { description: "`new XException(...)` passes exactly one object-literal argument." },
135
- schema: [],
136
- messages: {
137
- zero:
138
- "`new {{name}}()` - pass the metadata object even when it is empty: `new {{name}}({})`. One spelling for every throw means a reader never has to check whether THIS exception happens to take arguments.",
139
- notObject:
140
- "`new {{name}}(...)` takes a metadata OBJECT, not a positional value. A positional shape cannot grow: the day this failure needs a second field, every throw site is edited and the ones edited wrong still compile.",
141
- extra:
142
- "`new {{name}}(...)` takes exactly ONE metadata object, not several arguments.",
143
- },
144
- },
145
- create(context) {
146
- return {
147
- ThrowStatement(node) {
148
- const thrown = node.argument
149
- if (!thrown || thrown.type !== "NewExpression" || thrown.callee.type !== "Identifier") return
150
- const name = thrown.callee.name
151
- if (name === EXCEPTION_BASE || !EXCEPTION_NAME.test(name)) return
152
- /*
153
- * A FRAMEWORK EXCEPTION IS NOT A HOUSE ONE, AND ITS SHAPE IS NOT OURS TO DICTATE.
154
- *
155
- * This rule says a house exception takes exactly one metadata object. `EXCEPTION_NAME`
156
- * matches anything ending in `Exception`, so it was also demanding that shape from Nest's
157
- * own classes - `new ServiceUnavailableException(body)` is the constructor Nest published,
158
- * and rewriting it to satisfy a house convention would change what the framework sends.
159
- *
160
- * Whether a framework exception may be thrown at all is EXCEPTION-1's question, and it
161
- * answers it. This rule only governs the ones we declare.
162
- */
163
- if (FRAMEWORK_EXCEPTIONS.has(name)) return
164
-
165
- if (thrown.arguments.length === 0) {
166
- context.report({ node: thrown, messageId: "zero", data: { name } })
167
- return
168
- }
169
- if (thrown.arguments.length > 1) {
170
- context.report({ node: thrown, messageId: "extra", data: { name } })
171
- }
172
- if (thrown.arguments[0].type !== "ObjectExpression") {
173
- context.report({ node: thrown.arguments[0], messageId: "notObject", data: { name } })
174
- }
175
- },
176
- }
177
- },
178
- }
179
-
180
- // -- EXCEPTION-3 -----------------------------------------------------------------------------------
181
-
182
- /** The class extends the house base, so the throw site tells the truth about what is thrown. */
183
- export const exceptionExtendsAbstract = {
184
- meta: {
185
- type: "problem",
186
- docs: { description: "An `*Exception` class extends `AbstractException`." },
187
- schema: [],
188
- messages: {
189
- base:
190
- "`{{name}}` extends `{{parent}}`. Every exception extends `{{expected}}` - a framework base slips past the throw-site rule, because the throw then READS as a house exception and nothing looks wrong at any call site.",
191
- },
192
- },
193
- create(context) {
194
- const baseFile = isExceptionBaseFile(context.filename || context.getFilename())
195
- return {
196
- ClassDeclaration(node) {
197
- if (!node.id || !EXCEPTION_NAME.test(node.id.name)) return
198
- if (baseFile && node.id.name === EXCEPTION_BASE) return
199
- const parent = node.superClass
200
- if (!parent || parent.type !== "Identifier" || parent.name === EXCEPTION_BASE) return
201
- context.report({
202
- node: parent,
203
- messageId: "base",
204
- data: { name: node.id.name, parent: parent.name, expected: EXCEPTION_BASE },
205
- })
206
- },
207
- }
208
- },
209
- }
210
-
211
- // -- EXCEPTION-4 -----------------------------------------------------------------------------------
212
-
213
- /** Each capability declares its failures in its own errors folder. */
214
- export const exceptionInErrorsFolder = {
215
- meta: {
216
- type: "problem",
217
- docs: { description: "An `*Exception` class is declared under its owning capability's errors folder." },
218
- schema: [],
219
- messages: {
220
- place:
221
- "`{{name}}` is declared outside its owning feature or module capability's errors folder. Put the failure under that owner's errors directory.",
222
- },
223
- },
224
- create(context) {
225
- const filename = context.filename || context.getFilename()
226
- if (isInErrorsFolder(filename)) return {}
227
- const baseFile = isExceptionBaseFile(filename)
228
- return {
229
- ClassDeclaration(node) {
230
- if (!node.id || !EXCEPTION_NAME.test(node.id.name)) return
231
- if (baseFile && node.id.name === EXCEPTION_BASE) return
232
- // an undecorated class with no base is a shape, not an exception - the declaration this
233
- // rule is after always extends something
234
- if (!node.superClass) return
235
- context.report({ node: node.id, messageId: "place", data: { name: node.id.name } })
236
- },
237
- }
238
- },
239
- }
240
-
241
- /** The rules this law contributes to the plugin. */
242
- export const rules = {
243
- "throw-abstract-exception": throwAbstractException,
244
- "require-exception-object-arg": requireExceptionObjectArg,
245
- "exception-extends-abstract": exceptionExtendsAbstract,
246
- "exception-in-errors-folder": exceptionInErrorsFolder,
247
- }
248
-
249
- /**
250
- * The level this law asks for, as the plugin's own opinion.
251
- *
252
- * All four measured at zero debt in the reference repository. The sanctioned exit is stated where it
253
- * applies rather than carved into the rule: the spec family and the test tree may `throw new Error`,
254
- * because there it means "the runner cannot continue" rather than naming a failure the product can
255
- * produce. A consuming repository turns `throw-abstract-exception` off for those globs.
256
- */
257
- export const recommended = {
258
- "starci-be/throw-abstract-exception": "error",
259
- "starci-be/require-exception-object-arg": "error",
260
- "starci-be/exception-extends-abstract": "error",
261
- "starci-be/exception-in-errors-folder": "error",
262
- }