@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
package/cqrs.mjs
CHANGED
|
@@ -22,10 +22,6 @@
|
|
|
22
22
|
* have reported on more correct files than wrong ones, which is precisely the failure mode this law
|
|
23
23
|
* warns against - it would have taught everybody to disable it. The law's own "documented" tier for
|
|
24
24
|
* CQRS-1 is the honest answer, not a gap to close.
|
|
25
|
-
*
|
|
26
|
-
* Rule 5 (CQRS-5) is the opposite case: a handler returning `{ success: false }` or `{ error }`
|
|
27
|
-
* instead of throwing is a shape a parser CAN see, narrowly, without guessing at intent - and this
|
|
28
|
-
* reference repository has two real handlers doing exactly that today.
|
|
29
25
|
*/
|
|
30
26
|
|
|
31
27
|
import { normalizePath } from "./lib/path.mjs"
|
|
@@ -150,153 +146,23 @@ export const messageCarriesParamsOnly = {
|
|
|
150
146
|
},
|
|
151
147
|
}
|
|
152
148
|
|
|
153
|
-
// -- CQRS-7 ----------------------------------------------------------------------------------------
|
|
154
|
-
|
|
155
|
-
/**
|
|
156
|
-
* A handler has its twin spec beside it.
|
|
157
|
-
*
|
|
158
|
-
* The check is the FILENAME, not the disk: a rule that stats the filesystem reports differently
|
|
159
|
-
* depending on what else is checked out, and a rule whose answer depends on the working tree is one
|
|
160
|
-
* nobody can reproduce. This rule fires on the handler and names the file that should exist; a gate
|
|
161
|
-
* that walks the tree is what counts them.
|
|
162
|
-
*/
|
|
163
|
-
export const handlerHasTwinSpec = {
|
|
164
|
-
meta: {
|
|
165
|
-
type: "problem",
|
|
166
|
-
docs: { description: "A CQRS handler declares its twin spec filename in the same folder." },
|
|
167
|
-
schema: [
|
|
168
|
-
{
|
|
169
|
-
type: "object",
|
|
170
|
-
properties: { specs: { type: "array", items: { type: "string" } } },
|
|
171
|
-
additionalProperties: false,
|
|
172
|
-
},
|
|
173
|
-
],
|
|
174
|
-
messages: {
|
|
175
|
-
missing:
|
|
176
|
-
"`{{operation}}.handler.ts` has no `{{operation}}.handler.spec.ts` beside it. The decisions live in the handler, so an untested handler is an untested decision - and a spec in the same folder is found by whoever edits the handler rather than by whoever goes looking in a test tree.",
|
|
177
|
-
},
|
|
178
|
-
},
|
|
179
|
-
create(context) {
|
|
180
|
-
const filename = context.filename || context.getFilename()
|
|
181
|
-
const operation = handlerFile(filename)
|
|
182
|
-
if (!operation) return {}
|
|
183
|
-
const options = context.options && context.options[0]
|
|
184
|
-
const known = options && Array.isArray(options.specs) ? options.specs : null
|
|
185
|
-
if (!known) return {}
|
|
186
|
-
if (known.includes(`${operation}.handler.spec.ts`)) return {}
|
|
187
|
-
return {
|
|
188
|
-
"Program:exit"(node) {
|
|
189
|
-
context.report({ node, messageId: "missing", data: { operation } })
|
|
190
|
-
},
|
|
191
|
-
}
|
|
192
|
-
},
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
// -- CQRS-5 ----------------------------------------------------------------------------------------
|
|
196
|
-
|
|
197
|
-
/** The property name that turns a returned object into an encoded failure, or null. */
|
|
198
|
-
const encodedFailureKey = (node) => {
|
|
199
|
-
if (!node || node.type !== "ObjectExpression") return null
|
|
200
|
-
for (const prop of node.properties || []) {
|
|
201
|
-
if (prop.type !== "Property") continue
|
|
202
|
-
const key = prop.key
|
|
203
|
-
const name = key.type === "Identifier" ? key.name : key.type === "Literal" && typeof key.value === "string" ? key.value : null
|
|
204
|
-
if (!name) continue
|
|
205
|
-
// `error` is the encode regardless of what carries it - a caught exception, a message string,
|
|
206
|
-
// an upstream response. Its presence on a RETURNED object is the tell: the branch that found
|
|
207
|
-
// the failure chose to hand it back instead of throwing it.
|
|
208
|
-
if (name === "error") return name
|
|
209
|
-
// `success`/`ok` only encode failure at `false` - `success: true` on the happy path is not
|
|
210
|
-
// this pattern, and flagging it would report the correct half of every handler that uses this
|
|
211
|
-
// shape at all.
|
|
212
|
-
if ((name === "success" || name === "ok") && prop.value && prop.value.type === "Literal" && prop.value.value === false) {
|
|
213
|
-
return name
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
return null
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
/** The nearest enclosing method named `process`, or null. */
|
|
220
|
-
const enclosingProcessMethod = (node) => {
|
|
221
|
-
for (let current = node.parent; current; current = current.parent) {
|
|
222
|
-
if (current.type === "MethodDefinition" && current.key && current.key.name === "process") return current
|
|
223
|
-
}
|
|
224
|
-
return null
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
/** The nearest enclosing class declaration, or null. */
|
|
228
|
-
const enclosingClass = (node) => {
|
|
229
|
-
for (let current = node.parent; current; current = current.parent) {
|
|
230
|
-
if (current.type === "ClassDeclaration") return current
|
|
231
|
-
}
|
|
232
|
-
return null
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
/** A handler that cannot do its work throws the domain exception naming why - it does not return one. */
|
|
236
|
-
export const noHandlerEncodedFailure = {
|
|
237
|
-
meta: {
|
|
238
|
-
type: "problem",
|
|
239
|
-
docs: {
|
|
240
|
-
description:
|
|
241
|
-
"A CQRS handler throws the domain exception that names a failure; it does not return a success/error shape (Law 5, CQRS-5).",
|
|
242
|
-
},
|
|
243
|
-
schema: [],
|
|
244
|
-
messages: {
|
|
245
|
-
encoded:
|
|
246
|
-
"`{{name}}`'s `process` returns an object carrying `{{key}}` instead of throwing. CQRS-5: a handler that cannot do its work throws the domain exception naming why; an encoded `{{key}}` field asks every caller to decode this shape its own way, and the REASON for the refusal never arrives - only the fact that there was one. Throw the exception that names why, in the branch that discovers it, and let the success path return the value itself.",
|
|
247
|
-
},
|
|
248
|
-
},
|
|
249
|
-
create(context) {
|
|
250
|
-
const filename = context.filename || context.getFilename()
|
|
251
|
-
const operation = handlerFile(filename)
|
|
252
|
-
if (!operation) return {}
|
|
253
|
-
return {
|
|
254
|
-
ReturnStatement(node) {
|
|
255
|
-
const key = encodedFailureKey(node.argument)
|
|
256
|
-
if (!key) return
|
|
257
|
-
// Scoped to `process` on a decorated handler, same as CQRS-3 - a private mapper in the
|
|
258
|
-
// same file returning an unrelated object shaped like `{ error: ... }` is not this
|
|
259
|
-
// handler's refusal path, and a rule that fired anywhere in the file would not be able to
|
|
260
|
-
// tell the two apart.
|
|
261
|
-
if (!enclosingProcessMethod(node)) return
|
|
262
|
-
const klass = enclosingClass(node)
|
|
263
|
-
if (!klass || !decoratorNames(klass).some((name) => HANDLER_DECORATORS.test(name))) return
|
|
264
|
-
context.report({ node: node.argument, messageId: "encoded", data: { name: operation, key } })
|
|
265
|
-
},
|
|
266
|
-
}
|
|
267
|
-
},
|
|
268
|
-
}
|
|
269
|
-
|
|
270
149
|
/** The rules this law contributes to the plugin. */
|
|
271
150
|
export const rules = {
|
|
272
151
|
"handler-overrides-process": handlerOverridesProcess,
|
|
273
152
|
"message-carries-params-only": messageCarriesParamsOnly,
|
|
274
|
-
"handler-has-twin-spec": handlerHasTwinSpec,
|
|
275
|
-
"no-handler-encoded-failure": noHandlerEncodedFailure,
|
|
276
153
|
}
|
|
277
154
|
|
|
278
155
|
/**
|
|
279
156
|
* The level this law asks for, as the plugin's own opinion.
|
|
280
157
|
*
|
|
281
|
-
*
|
|
282
|
-
* 2 of 138 messages
|
|
283
|
-
* encoded failure (`sync-flashcard-quiz-session-progress.handler.ts` and
|
|
284
|
-
* `sync-mock-interview-session-turns.handler.ts` both `return { success: false }` where a late sync
|
|
285
|
-
* finds its session already closed). Debt above zero means the rule lands at `warn` with the count
|
|
286
|
-
* beside it, gets burned down, and flips to `error` at zero - shipping at `error` with debt
|
|
287
|
-
* outstanding blocks every commit that touches an offender.
|
|
288
|
-
*
|
|
289
|
-
* COUNT ONLY THIS RULE'S REPORTS when measuring. Inline `eslint-disable` comments in the source
|
|
290
|
-
* refer to rules a minimal measuring config never loads, and eslint reports each of those as a
|
|
291
|
-
* problem of its own - which inflated the first measurement of every rule here by the same seven
|
|
292
|
-
* files, and this rule's by two.
|
|
158
|
+
* Both measured at zero debt in the reference repository (3 of 141 handlers overrode `execute` and
|
|
159
|
+
* 2 of 138 messages carried more than `params`; both were burned down), so both are `error`.
|
|
293
160
|
*
|
|
294
|
-
*
|
|
295
|
-
*
|
|
161
|
+
* The twin-spec count rule and the encoded-failure rule are gone. Test selection follows behavior and
|
|
162
|
+
* risk, not a filename count; and HFS returns an expected refusal as a typed outcome union, so a
|
|
163
|
+
* handler that returns `{ success: false }` is no longer a defect this canon can call by shape.
|
|
296
164
|
*/
|
|
297
165
|
export const recommended = {
|
|
298
|
-
"starci-be/handler-overrides-process": "error",
|
|
299
|
-
"starci-be/message-carries-params-only": "error",
|
|
300
|
-
"starci-be/handler-has-twin-spec": "off", // needs the folder listing as an option; a repo that wires it turns this on
|
|
301
|
-
"starci-be/no-handler-encoded-failure": "warn", // no=2 of 152 handlers - debt outstanding, burn down before flipping to error
|
|
166
|
+
"starci-be/handler-overrides-process": "error",
|
|
167
|
+
"starci-be/message-carries-params-only": "error",
|
|
302
168
|
}
|
package/data-access.mjs
CHANGED
|
@@ -22,8 +22,20 @@
|
|
|
22
22
|
*
|
|
23
23
|
* Both remaining gaps are read by a person; a rule that guessed at either would fire on correct
|
|
24
24
|
* code, which is how a correct rule gets disabled.
|
|
25
|
+
*
|
|
26
|
+
* `no-external-call-in-transaction` (R82 `BE_TRANSACTION_EXTERNAL_CALL`) refuses `fetch`, an HTTP
|
|
27
|
+
* client call or any method of a `*Client`/`*Sdk` receiver written inside `.transaction(async (tx) =>
|
|
28
|
+
* ...)`. Holding the transaction open for as long as the external call takes means a failure after
|
|
29
|
+
* partial commit, or a success the rollback then reverses with no way back; the fix is to commit
|
|
30
|
+
* first and call out after, or to write an outbox message inside the transaction instead.
|
|
31
|
+
*
|
|
32
|
+
* `named-entity-manager-only` (R83 `BE_UNNAMED_DATA_ACCESS`) holds the one way a use case reaches the
|
|
33
|
+
* database: the shared `EntityManager` injected through a named injector and called directly. It refuses a bare
|
|
34
|
+
* `@InjectEntityManager()`, any `.getRepository(...)` call, and an injected `DataSource` outside the platform
|
|
35
|
+
* database module and `apps/migrate`. `no-injected-repository` refuses `@InjectRepository` and `Repository<T>` in the same spirit.
|
|
25
36
|
*/
|
|
26
37
|
|
|
38
|
+
import { walk } from "./lib/ast.mjs"
|
|
27
39
|
import { normalizePath } from "./lib/path.mjs"
|
|
28
40
|
|
|
29
41
|
/** A parameter property (`private readonly x: T`) wraps the parameter it declares. */
|
|
@@ -97,21 +109,28 @@ export const mustInjectEntityManager = {
|
|
|
97
109
|
export const noInjectedRepository = {
|
|
98
110
|
meta: {
|
|
99
111
|
type: "problem",
|
|
100
|
-
docs: { description: "Persistence goes through `EntityManager`, never an injected repository." },
|
|
112
|
+
docs: { description: "Persistence goes through the shared `EntityManager`, never an injected repository." },
|
|
101
113
|
schema: [],
|
|
102
114
|
messages: {
|
|
103
115
|
repo:
|
|
104
|
-
"Inject
|
|
116
|
+
"Inject the shared EntityManager with the named injector and call it directly. A repository is bound to ONE entity, so it cannot carry a transaction into a second table - and a use case that grows a second write then has to be rewritten rather than extended.",
|
|
105
117
|
},
|
|
106
118
|
},
|
|
107
119
|
create(context) {
|
|
108
120
|
return {
|
|
121
|
+
// `@InjectRepository(...)` is refused wherever it is written: a constructor parameter, a property, a method parameter.
|
|
122
|
+
Decorator(node) {
|
|
123
|
+
const expression = node.expression
|
|
124
|
+
if (expression.type === "CallExpression" && expression.callee.type === "Identifier" && expression.callee.name === "InjectRepository") {
|
|
125
|
+
context.report({ node, messageId: "repo" })
|
|
126
|
+
}
|
|
127
|
+
},
|
|
109
128
|
MethodDefinition(node) {
|
|
110
129
|
for (const original of constructorParams(node)) {
|
|
111
130
|
const param = unwrapParam(original)
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
if (
|
|
131
|
+
// A decorated parameter is reported once, by the Decorator visitor.
|
|
132
|
+
if (paramDecorators(original).includes("InjectRepository")) continue
|
|
133
|
+
if (REPOSITORY_TYPES.test(paramTypeName(param) || "")) context.report({ node: param, messageId: "repo" })
|
|
115
134
|
}
|
|
116
135
|
},
|
|
117
136
|
}
|
|
@@ -274,6 +293,129 @@ export const noEagerRelation = {
|
|
|
274
293
|
},
|
|
275
294
|
}
|
|
276
295
|
|
|
296
|
+
// -- DATA-6 (R82) ------------------------------------------------------------------------------------
|
|
297
|
+
|
|
298
|
+
const HTTP_RECEIVER = /^(?:axios|http|httpService|httpClient|axiosInstance)$/
|
|
299
|
+
const HTTP_METHODS = new Set(["get", "post", "put", "patch", "delete", "head", "request"])
|
|
300
|
+
/** A receiver named for an integration client or SDK: `stripeClient`, `this.paymentSdk`. */
|
|
301
|
+
const EXTERNAL_RECEIVER = /(?:Client|Sdk)$/
|
|
302
|
+
|
|
303
|
+
const calleeMethodName = (callee) =>
|
|
304
|
+
callee.type === "MemberExpression" && !callee.computed && callee.property.type === "Identifier" ? callee.property.name : null
|
|
305
|
+
|
|
306
|
+
const calleeReceiverName = (callee) => {
|
|
307
|
+
if (callee.type !== "MemberExpression") return null
|
|
308
|
+
const object = callee.object
|
|
309
|
+
if (object.type === "Identifier") return object.name
|
|
310
|
+
if (object.type === "MemberExpression" && !object.computed && object.property.type === "Identifier") return object.property.name
|
|
311
|
+
return null
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Whether `node` is a call this law treats as reaching an external system: `fetch`, an HTTP client, or any method of a `*Client`/`*Sdk`. */
|
|
315
|
+
const isExternalCall = (node) => {
|
|
316
|
+
if (node.type !== "CallExpression") return false
|
|
317
|
+
const { callee } = node
|
|
318
|
+
if (callee.type === "Identifier" && callee.name === "fetch") return true
|
|
319
|
+
const method = calleeMethodName(callee)
|
|
320
|
+
const receiver = calleeReceiverName(callee)
|
|
321
|
+
if (!method || !receiver) return false
|
|
322
|
+
if (HTTP_RECEIVER.test(receiver) && HTTP_METHODS.has(method)) return true
|
|
323
|
+
return EXTERNAL_RECEIVER.test(receiver)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** A short label for a reported call: `stripeClient.charge`, `fetch`. */
|
|
327
|
+
const describeCall = (node) => {
|
|
328
|
+
const { callee } = node
|
|
329
|
+
if (callee.type === "Identifier") return callee.name
|
|
330
|
+
const method = calleeMethodName(callee) ?? "?"
|
|
331
|
+
const receiver = calleeReceiverName(callee) ?? "?"
|
|
332
|
+
return `${receiver}.${method}`
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** No transaction spans an external call: commit first, then call out (or write an outbox message inside the transaction). */
|
|
336
|
+
export const noExternalCallInTransaction = {
|
|
337
|
+
meta: {
|
|
338
|
+
type: "problem",
|
|
339
|
+
docs: {
|
|
340
|
+
description: "An HTTP or SDK call does not run inside `.transaction(async (tx) => ...)`.",
|
|
341
|
+
},
|
|
342
|
+
schema: [],
|
|
343
|
+
messages: {
|
|
344
|
+
external:
|
|
345
|
+
"`{{call}}` runs inside `.transaction(...)`. A database transaction held open for as long as an external call takes means: the call fails after the transaction already did its work and now has to be undone by hand, or the call succeeds and the transaction then rolls back, and the two systems disagree with no way back. Commit the transaction, then make the call - or write an outbox message inside the transaction and let a worker deliver it after commit.",
|
|
346
|
+
},
|
|
347
|
+
},
|
|
348
|
+
create(context) {
|
|
349
|
+
return {
|
|
350
|
+
CallExpression(node) {
|
|
351
|
+
const callee = node.callee
|
|
352
|
+
if (callee.type !== "MemberExpression" || callee.computed) return
|
|
353
|
+
if (callee.property.type !== "Identifier" || callee.property.name !== "transaction") return
|
|
354
|
+
const callback = node.arguments[0]
|
|
355
|
+
if (!callback || (callback.type !== "ArrowFunctionExpression" && callback.type !== "FunctionExpression")) return
|
|
356
|
+
walk(callback.body, (child) => {
|
|
357
|
+
if (isExternalCall(child)) context.report({ node: child, messageId: "external", data: { call: describeCall(child) } })
|
|
358
|
+
})
|
|
359
|
+
},
|
|
360
|
+
}
|
|
361
|
+
},
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// -- DATA-7 (R83) ------------------------------------------------------------------------------------
|
|
365
|
+
|
|
366
|
+
/** Files that build the connections: the platform database module and the migrate app. */
|
|
367
|
+
const CONNECTION_BUILDER = /(?:^|\/)(?:platform\/(?:databases?|db|datasource|typeorm)|apps\/migrate)\//
|
|
368
|
+
|
|
369
|
+
/** The one way a use case reaches the database: the shared `EntityManager`, named, then called directly. */
|
|
370
|
+
export const namedEntityManagerOnly = {
|
|
371
|
+
meta: {
|
|
372
|
+
type: "problem",
|
|
373
|
+
docs: {
|
|
374
|
+
description:
|
|
375
|
+
"The shared EntityManager is injected through a named injector and called directly; no bare `@InjectEntityManager()`, no `getRepository`, no injected `DataSource` (data-access.md DATA-7, R83).",
|
|
376
|
+
},
|
|
377
|
+
schema: [],
|
|
378
|
+
messages: {
|
|
379
|
+
unnamed:
|
|
380
|
+
"`@InjectEntityManager()` names no connection, and this application registers more than one - so it points at whichever is default. Inject the shared EntityManager with the named injector (`InjectPrimaryEntityManager()`, `InjectAgentOsEntityManager()`, `InjectExpertAcademyEntityManager()`, or `@InjectEntityManager(<CONNECTION_TOKEN>)`) and call it directly.",
|
|
381
|
+
getRepository:
|
|
382
|
+
"`.getRepository(...)` binds a handle to ONE entity and hides which connection it came from. Inject the shared EntityManager with the named injector and call it directly: `manager.find(Entity, ...)`, `manager.save(Entity, ...)`, `tx.insert(Entity, ...)`.",
|
|
383
|
+
dataSource:
|
|
384
|
+
"`DataSource` is injected here to reach the database. Inject the shared EntityManager with the named injector and call it directly; a `DataSource` is built only by the platform database module.",
|
|
385
|
+
},
|
|
386
|
+
},
|
|
387
|
+
create(context) {
|
|
388
|
+
const filename = normalizePath(context.filename || context.getFilename())
|
|
389
|
+
if (/\.d\.[cm]?ts$/.test(filename) || /\.(?:spec|test)\.[cm]?[jt]sx?$/.test(filename)) return {}
|
|
390
|
+
const mayBuildConnections = CONNECTION_BUILDER.test(filename)
|
|
391
|
+
return {
|
|
392
|
+
Decorator(node) {
|
|
393
|
+
const expression = node.expression
|
|
394
|
+
if (expression.type !== "CallExpression" || expression.callee.type !== "Identifier") return
|
|
395
|
+
if (expression.callee.name === "InjectEntityManager" && expression.arguments.length === 0) {
|
|
396
|
+
context.report({ node, messageId: "unnamed" })
|
|
397
|
+
}
|
|
398
|
+
},
|
|
399
|
+
CallExpression(node) {
|
|
400
|
+
const callee = node.callee
|
|
401
|
+
if (callee.type !== "MemberExpression" || callee.computed) return
|
|
402
|
+
if (callee.property.type === "Identifier" && callee.property.name === "getRepository") {
|
|
403
|
+
context.report({ node, messageId: "getRepository" })
|
|
404
|
+
}
|
|
405
|
+
},
|
|
406
|
+
MethodDefinition(node) {
|
|
407
|
+
if (mayBuildConnections) return
|
|
408
|
+
for (const original of constructorParams(node)) {
|
|
409
|
+
const param = unwrapParam(original)
|
|
410
|
+
const byType = paramTypeName(param) === "DataSource"
|
|
411
|
+
const byDecorator = paramDecorators(original).some((name) => name === "InjectDataSource" || name === "InjectConnection")
|
|
412
|
+
if (byType || byDecorator) context.report({ node: param, messageId: "dataSource" })
|
|
413
|
+
}
|
|
414
|
+
},
|
|
415
|
+
}
|
|
416
|
+
},
|
|
417
|
+
}
|
|
418
|
+
|
|
277
419
|
/** The rules this law contributes to the plugin. */
|
|
278
420
|
export const rules = {
|
|
279
421
|
"must-inject-entity-manager": mustInjectEntityManager,
|
|
@@ -281,6 +423,8 @@ export const rules = {
|
|
|
281
423
|
"require-entity-table-name": requireEntityTableName,
|
|
282
424
|
"no-outer-manager-in-transaction": noOuterManagerInTransaction,
|
|
283
425
|
"no-eager-relation": noEagerRelation,
|
|
426
|
+
"no-external-call-in-transaction": noExternalCallInTransaction,
|
|
427
|
+
"named-entity-manager-only": namedEntityManagerOnly,
|
|
284
428
|
}
|
|
285
429
|
|
|
286
430
|
/**
|
|
@@ -300,6 +444,8 @@ export const recommended = {
|
|
|
300
444
|
"starci-be/require-entity-table-name": "error",
|
|
301
445
|
"starci-be/no-outer-manager-in-transaction": "error",
|
|
302
446
|
"starci-be/no-eager-relation": "error",
|
|
447
|
+
"starci-be/no-external-call-in-transaction": "error",
|
|
448
|
+
"starci-be/named-entity-manager-only": "error",
|
|
303
449
|
}
|
|
304
450
|
|
|
305
451
|
/** Path helper shared with the other backend law modules. */
|
package/default-deny.mjs
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rules that hold the transport half of HFS default-deny (catalog R41 `BE_DEFAULT_DENY`).
|
|
3
|
+
*
|
|
4
|
+
* Every route needs a signed-in principal unless it says otherwise, and every body has a type:
|
|
5
|
+
*
|
|
6
|
+
* - `no-untyped-body` refuses a `@Body()` (or `@Args()`) parameter with no type, `unknown`, `any`, `object`,
|
|
7
|
+
* `{}` or `Record<string, unknown>`; it refuses `GraphQLJSON` as a tunnel around the schema; and it refuses
|
|
8
|
+
* a `switch (input.operation)` in a transport file, because each operation is its own typed operation.
|
|
9
|
+
* - `public-needs-reason` refuses `@Public()` without `{ reason: "<why>" }`. An open door states why it is
|
|
10
|
+
* open, so the list of open doors can be read and reviewed.
|
|
11
|
+
*
|
|
12
|
+
* The `APP_GUARD` registration and the app-level guard live in the composition check, not here: a rule reading
|
|
13
|
+
* one file cannot see which module the app registered.
|
|
14
|
+
*/
|
|
15
|
+
import { decoratorName, keyName, staticText } from "./lib/ast.mjs"
|
|
16
|
+
import { isDeclarationFile, normalizePath } from "./lib/path.mjs"
|
|
17
|
+
|
|
18
|
+
const BODY_DECORATORS = new Set(["Body", "Args"])
|
|
19
|
+
|
|
20
|
+
const isUntyped = (annotation) => {
|
|
21
|
+
if (!annotation) return true
|
|
22
|
+
const type = annotation.typeAnnotation
|
|
23
|
+
if (!type) return true
|
|
24
|
+
if (["TSUnknownKeyword", "TSAnyKeyword", "TSObjectKeyword"].includes(type.type)) return true
|
|
25
|
+
if (type.type === "TSTypeLiteral" && type.members.length === 0) return true
|
|
26
|
+
if (type.type === "TSTypeReference" && type.typeName.type === "Identifier" && type.typeName.name === "Record") {
|
|
27
|
+
const value = type.typeArguments?.params?.[1] ?? type.typeParameters?.params?.[1]
|
|
28
|
+
return value?.type === "TSUnknownKeyword" || value?.type === "TSAnyKeyword"
|
|
29
|
+
}
|
|
30
|
+
return false
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A body or argument carries a declared type, never `unknown`, `any` or GraphQLJSON. */
|
|
34
|
+
export const noUntypedBody = {
|
|
35
|
+
meta: {
|
|
36
|
+
type: "problem",
|
|
37
|
+
docs: { description: "`@Body()` and `@Args()` parameters are typed; no GraphQLJSON tunnel; no `switch (input.operation)`." },
|
|
38
|
+
schema: [],
|
|
39
|
+
messages: {
|
|
40
|
+
untyped: "This `@{{decorator}}()` parameter has no useful type. Declare a DTO class or contract type so the framework validates the input.",
|
|
41
|
+
json: "`GraphQLJSON` tunnels untyped data through the schema. Declare an input type with the fields the operation needs.",
|
|
42
|
+
operationSwitch: "`switch` on an `operation` field dispatches several operations through one untyped door. Make each operation its own typed resolver or controller method.",
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
create(context) {
|
|
46
|
+
const filename = normalizePath(context.filename || context.getFilename())
|
|
47
|
+
if (isDeclarationFile(filename)) return {}
|
|
48
|
+
const inTransport = filename.includes("/transport/")
|
|
49
|
+
return {
|
|
50
|
+
Identifier(node) {
|
|
51
|
+
if (node.name === "GraphQLJSON" && !/^Import.*Specifier$/.test(node.parent?.type ?? "")) {
|
|
52
|
+
context.report({ node, messageId: "json" })
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
SwitchStatement(node) {
|
|
56
|
+
const discriminant = node.discriminant
|
|
57
|
+
if (inTransport && discriminant.type === "MemberExpression" && keyName(discriminant.property) === "operation") {
|
|
58
|
+
context.report({ node, messageId: "operationSwitch" })
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
Decorator(node) {
|
|
62
|
+
const name = decoratorName(node)
|
|
63
|
+
if (!BODY_DECORATORS.has(name)) return
|
|
64
|
+
const parameter = node.parent
|
|
65
|
+
if (!parameter) return
|
|
66
|
+
const bound = parameter.type === "TSParameterProperty" ? parameter.parameter : parameter
|
|
67
|
+
if (bound.type !== "Identifier" && bound.type !== "ObjectPattern" && bound.type !== "AssignmentPattern") return
|
|
68
|
+
const annotated = bound.type === "AssignmentPattern" ? bound.left : bound
|
|
69
|
+
if (isUntyped(annotated.typeAnnotation)) {
|
|
70
|
+
context.report({ node, messageId: "untyped", data: { decorator: name } })
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** An open door states why it is open. */
|
|
78
|
+
export const publicNeedsReason = {
|
|
79
|
+
meta: {
|
|
80
|
+
type: "problem",
|
|
81
|
+
docs: { description: "`@Public()` carries `{ reason: \"<why>\" }`." },
|
|
82
|
+
schema: [],
|
|
83
|
+
messages: {
|
|
84
|
+
reason: "`@Public()` has no reason. Write `@Public({ reason: \"<why this operation is open>\" })` so the open doors can be reviewed.",
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
create(context) {
|
|
88
|
+
const filename = normalizePath(context.filename || context.getFilename())
|
|
89
|
+
if (isDeclarationFile(filename)) return {}
|
|
90
|
+
return {
|
|
91
|
+
Decorator(node) {
|
|
92
|
+
if (decoratorName(node) !== "Public") return
|
|
93
|
+
const expression = node.expression
|
|
94
|
+
const argument = expression.type === "CallExpression" ? expression.arguments[0] : undefined
|
|
95
|
+
let reason = null
|
|
96
|
+
if (argument?.type === "ObjectExpression") {
|
|
97
|
+
const property = argument.properties.find((item) => item.type === "Property" && keyName(item.key) === "reason")
|
|
98
|
+
reason = property ? staticText(property.value) : null
|
|
99
|
+
}
|
|
100
|
+
if (reason === null || reason.trim() === "") context.report({ node, messageId: "reason" })
|
|
101
|
+
},
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The rules this law contributes to the plugin. */
|
|
107
|
+
export const rules = {
|
|
108
|
+
"no-untyped-body": noUntypedBody,
|
|
109
|
+
"public-needs-reason": publicNeedsReason,
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Both start at error: an untyped door and an unexplained open door are the failures this law exists for. */
|
|
113
|
+
export const recommended = {
|
|
114
|
+
"starci-be/no-untyped-body": "error",
|
|
115
|
+
"starci-be/public-needs-reason": "error",
|
|
116
|
+
}
|