@starci/eslint-canon-be 1.2.1 → 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 +50 -0
- package/README.md +145 -69
- package/async-discipline.mjs +83 -0
- package/authorization.mjs +106 -106
- package/cdc.mjs +150 -150
- package/comments.mjs +340 -389
- package/config-owner.mjs +252 -0
- package/cqrs.mjs +168 -303
- package/data-access.mjs +452 -307
- package/default-deny.mjs +116 -0
- package/e2e-flow.mjs +222 -222
- package/error-handling.mjs +207 -0
- package/event-delivery.mjs +93 -93
- package/idempotency.mjs +101 -0
- package/index.mjs +137 -107
- package/input-bounds.mjs +84 -0
- package/lib/ast.mjs +81 -0
- package/lib/config.mjs +49 -0
- package/lib/path.mjs +17 -0
- package/lib/slots.mjs +60 -0
- package/log-safety.mjs +148 -0
- package/module-layering.mjs +408 -432
- package/module-shape.mjs +202 -0
- package/naming.mjs +219 -220
- package/observability.mjs +275 -275
- package/package.json +4 -2
- 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 +479 -452
- package/transport.mjs +168 -168
- package/type-safety.mjs +551 -511
- package/user-copy.mjs +106 -0
- package/exception-identity.mjs +0 -200
- package/exceptions.mjs +0 -265
package/data-access.mjs
CHANGED
|
@@ -1,307 +1,452 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The rules that hold `data-access.md`.
|
|
3
|
-
*
|
|
4
|
-
* The first three read a constructor parameter or a decorator, all exact - there is no heuristic
|
|
5
|
-
* here and no judgement to get wrong, which is why all three can be switched on without a burn-down
|
|
6
|
-
* in a codebase that already follows them.
|
|
7
|
-
*
|
|
8
|
-
* The next two hold a narrower, exact slice of DATA-4 (Rule 5) and DATA-5 (Rule 6) - the slice of
|
|
9
|
-
* each that a single file settles on its own, with no call graph and no knowledge of intent needed.
|
|
10
|
-
*
|
|
11
|
-
* `no-outer-manager-in-transaction` catches the one shape of DATA-4 a parser can see: a
|
|
12
|
-
* `this.<field>.transaction(async (tx) => ...)` callback whose body reaches for `this.<field>`
|
|
13
|
-
* again instead of `tx`. That field held the manager from BEFORE the transaction opened, so a call
|
|
14
|
-
* through it runs and commits on its own - the exact "half of it was written" bug DATA-4 names. What
|
|
15
|
-
* it does NOT see, and cannot: whether a HELPER defined in another file was handed `tx` by ITS
|
|
16
|
-
* caller. That still needs the call graph and stays undecidable, same as the law says.
|
|
17
|
-
*
|
|
18
|
-
* `no-eager-relation` catches the one shape of DATA-5 a parser can see: `eager: true` written on a
|
|
19
|
-
* relation decorator. What it does NOT see: whether a call site correctly asked for a relation it
|
|
20
|
-
* needed. That still needs to know what the answer is for and stays undecidable, same as the law
|
|
21
|
-
* says.
|
|
22
|
-
*
|
|
23
|
-
* Both remaining gaps are read by a person; a rule that guessed at either would fire on correct
|
|
24
|
-
* code, which is how a correct rule gets disabled.
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
1
|
+
/**
|
|
2
|
+
* The rules that hold `data-access.md`.
|
|
3
|
+
*
|
|
4
|
+
* The first three read a constructor parameter or a decorator, all exact - there is no heuristic
|
|
5
|
+
* here and no judgement to get wrong, which is why all three can be switched on without a burn-down
|
|
6
|
+
* in a codebase that already follows them.
|
|
7
|
+
*
|
|
8
|
+
* The next two hold a narrower, exact slice of DATA-4 (Rule 5) and DATA-5 (Rule 6) - the slice of
|
|
9
|
+
* each that a single file settles on its own, with no call graph and no knowledge of intent needed.
|
|
10
|
+
*
|
|
11
|
+
* `no-outer-manager-in-transaction` catches the one shape of DATA-4 a parser can see: a
|
|
12
|
+
* `this.<field>.transaction(async (tx) => ...)` callback whose body reaches for `this.<field>`
|
|
13
|
+
* again instead of `tx`. That field held the manager from BEFORE the transaction opened, so a call
|
|
14
|
+
* through it runs and commits on its own - the exact "half of it was written" bug DATA-4 names. What
|
|
15
|
+
* it does NOT see, and cannot: whether a HELPER defined in another file was handed `tx` by ITS
|
|
16
|
+
* caller. That still needs the call graph and stays undecidable, same as the law says.
|
|
17
|
+
*
|
|
18
|
+
* `no-eager-relation` catches the one shape of DATA-5 a parser can see: `eager: true` written on a
|
|
19
|
+
* relation decorator. What it does NOT see: whether a call site correctly asked for a relation it
|
|
20
|
+
* needed. That still needs to know what the answer is for and stays undecidable, same as the law
|
|
21
|
+
* says.
|
|
22
|
+
*
|
|
23
|
+
* Both remaining gaps are read by a person; a rule that guessed at either would fire on correct
|
|
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.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { walk } from "./lib/ast.mjs"
|
|
39
|
+
import { normalizePath } from "./lib/path.mjs"
|
|
40
|
+
|
|
41
|
+
/** A parameter property (`private readonly x: T`) wraps the parameter it declares. */
|
|
42
|
+
const unwrapParam = (param) => (param.type === "TSParameterProperty" ? param.parameter : param)
|
|
43
|
+
|
|
44
|
+
/** The type name a parameter is annotated with, or null. */
|
|
45
|
+
const paramTypeName = (param) => {
|
|
46
|
+
const annotation = param.typeAnnotation && param.typeAnnotation.typeAnnotation
|
|
47
|
+
if (!annotation || annotation.type !== "TSTypeReference" || annotation.typeName.type !== "Identifier") return null
|
|
48
|
+
return annotation.typeName.name
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Decorator names on a parameter, including ones carried by the parameter property wrapping it. */
|
|
52
|
+
const paramDecorators = (original) => {
|
|
53
|
+
const carriers = [original]
|
|
54
|
+
if (original.type === "TSParameterProperty") carriers.push(original.parameter)
|
|
55
|
+
const names = []
|
|
56
|
+
for (const carrier of carriers) {
|
|
57
|
+
for (const decorator of carrier.decorators || []) {
|
|
58
|
+
const expression = decorator.expression
|
|
59
|
+
if (expression.type === "CallExpression" && expression.callee.type === "Identifier") {
|
|
60
|
+
names.push(expression.callee.name)
|
|
61
|
+
} else if (expression.type === "Identifier") {
|
|
62
|
+
names.push(expression.name)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return names
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Every parameter of a constructor definition. */
|
|
70
|
+
const constructorParams = (node) =>
|
|
71
|
+
node.kind === "constructor" && node.value && node.value.params ? node.value.params : []
|
|
72
|
+
|
|
73
|
+
/** Repository types that bind a handle to one entity. */
|
|
74
|
+
const REPOSITORY_TYPES = /^(?:Repository|TreeRepository|MongoRepository)$/
|
|
75
|
+
|
|
76
|
+
/** The house decorator family that names a datasource. */
|
|
77
|
+
const NAMES_DATASOURCE = /^Inject\w*EntityManager$/
|
|
78
|
+
|
|
79
|
+
// -- DATA-1 ----------------------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
/** An injected manager names the datasource it belongs to. */
|
|
82
|
+
export const mustInjectEntityManager = {
|
|
83
|
+
meta: {
|
|
84
|
+
type: "problem",
|
|
85
|
+
docs: { description: "An injected `EntityManager` names its datasource through a decorator." },
|
|
86
|
+
schema: [],
|
|
87
|
+
messages: {
|
|
88
|
+
undecorated:
|
|
89
|
+
"`EntityManager` is injected without an `@Inject*EntityManager()` decorator. The type alone does not say WHICH datasource this is, and this application has more than one - so this reads correctly while pointing at the wrong database. Name the connection at the injection site.",
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
create(context) {
|
|
93
|
+
return {
|
|
94
|
+
MethodDefinition(node) {
|
|
95
|
+
for (const original of constructorParams(node)) {
|
|
96
|
+
const param = unwrapParam(original)
|
|
97
|
+
if (paramTypeName(param) !== "EntityManager") continue
|
|
98
|
+
if (paramDecorators(original).some((name) => NAMES_DATASOURCE.test(name))) continue
|
|
99
|
+
context.report({ node: param, messageId: "undecorated" })
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// -- DATA-2 ----------------------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
/** Persistence never arrives as a repository. */
|
|
109
|
+
export const noInjectedRepository = {
|
|
110
|
+
meta: {
|
|
111
|
+
type: "problem",
|
|
112
|
+
docs: { description: "Persistence goes through the shared `EntityManager`, never an injected repository." },
|
|
113
|
+
schema: [],
|
|
114
|
+
messages: {
|
|
115
|
+
repo:
|
|
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.",
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
create(context) {
|
|
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
|
+
},
|
|
128
|
+
MethodDefinition(node) {
|
|
129
|
+
for (const original of constructorParams(node)) {
|
|
130
|
+
const param = unwrapParam(original)
|
|
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" })
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// -- DATA-3 ----------------------------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
/** An entity names its table, so a class rename cannot become a dropped table. */
|
|
143
|
+
export const requireEntityTableName = {
|
|
144
|
+
meta: {
|
|
145
|
+
type: "problem",
|
|
146
|
+
docs: { description: "`@Entity()` names its table explicitly." },
|
|
147
|
+
schema: [],
|
|
148
|
+
messages: {
|
|
149
|
+
inferred:
|
|
150
|
+
"`@Entity()` here lets the ORM infer the table name from the class name, so renaming the class renames the table - which `synchronize` performs as a DROP and CREATE rather than a migration. A class rename is a refactor; a dropped table is an outage. Name the table.",
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
create(context) {
|
|
154
|
+
/** Both `@Entity("t")` and `@Entity({ name: "t" })` name the table. */
|
|
155
|
+
const isTableName = (argument) =>
|
|
156
|
+
(argument.type === "Literal" && typeof argument.value === "string") || argument.type === "TemplateLiteral"
|
|
157
|
+
return {
|
|
158
|
+
Decorator(node) {
|
|
159
|
+
const expression = node.expression
|
|
160
|
+
if (expression.type !== "CallExpression") return
|
|
161
|
+
if (expression.callee.type !== "Identifier" || expression.callee.name !== "Entity") return
|
|
162
|
+
// The options form is not a stylistic variant to discourage: it is the ONLY form that can
|
|
163
|
+
// also carry a schema qualifier, so rejecting it would push an author to delete the schema
|
|
164
|
+
// to satisfy the rule - a worse outcome than the inferred name this exists to prevent.
|
|
165
|
+
const named = expression.arguments.some(
|
|
166
|
+
(argument) =>
|
|
167
|
+
isTableName(argument)
|
|
168
|
+
|| (argument.type === "ObjectExpression"
|
|
169
|
+
&& argument.properties.some(
|
|
170
|
+
(property) =>
|
|
171
|
+
property.type === "Property"
|
|
172
|
+
&& !property.computed
|
|
173
|
+
&& (property.key.name === "name" || property.key.value === "name")
|
|
174
|
+
&& isTableName(property.value),
|
|
175
|
+
)),
|
|
176
|
+
)
|
|
177
|
+
if (!named) context.report({ node, messageId: "inferred" })
|
|
178
|
+
},
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// -- DATA-4 (Rule 5) --------------------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
/** Node types that rebind `this` - a `this.<field>` read past one of these belongs to a different call. */
|
|
186
|
+
const REBINDS_THIS = /^(?:FunctionDeclaration|FunctionExpression|ClassDeclaration|ClassExpression)$/
|
|
187
|
+
|
|
188
|
+
/** Walk every descendant of `node` that still runs under the SAME `this`, calling `visit` on each. */
|
|
189
|
+
const walkPreservingThis = (node, visit) => {
|
|
190
|
+
if (!node || typeof node.type !== "string") return
|
|
191
|
+
visit(node)
|
|
192
|
+
for (const key of Object.keys(node)) {
|
|
193
|
+
if (key === "parent") continue
|
|
194
|
+
const value = node[key]
|
|
195
|
+
if (Array.isArray(value)) {
|
|
196
|
+
for (const child of value) {
|
|
197
|
+
if (child && typeof child.type === "string" && !REBINDS_THIS.test(child.type)) walkPreservingThis(child, visit)
|
|
198
|
+
}
|
|
199
|
+
} else if (value && typeof value.type === "string" && !REBINDS_THIS.test(value.type)) {
|
|
200
|
+
walkPreservingThis(value, visit)
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Everything inside a transaction receives the transactional manager, not the outer injected one. */
|
|
206
|
+
export const noOuterManagerInTransaction = {
|
|
207
|
+
meta: {
|
|
208
|
+
type: "problem",
|
|
209
|
+
docs: {
|
|
210
|
+
description:
|
|
211
|
+
"Inside `this.<field>.transaction(async (tx) => ...)`, every call uses `tx` - never `this.<field>` again (data-access.md DATA-4, Rule 5).",
|
|
212
|
+
},
|
|
213
|
+
schema: [],
|
|
214
|
+
messages: {
|
|
215
|
+
outerManager:
|
|
216
|
+
"This is inside `this.{{field}}.transaction(...)`, but it reaches for `this.{{field}}` again instead of `{{param}}`, the manager the callback received. `this.{{field}}` is the manager from BEFORE the transaction opened - a call through it runs and commits on its own, which is how half a write survives a rollback. Use `{{param}}`.",
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
create(context) {
|
|
220
|
+
return {
|
|
221
|
+
CallExpression(node) {
|
|
222
|
+
const callee = node.callee
|
|
223
|
+
if (callee.type !== "MemberExpression" || callee.computed) return
|
|
224
|
+
if (callee.property.type !== "Identifier" || callee.property.name !== "transaction") return
|
|
225
|
+
const object = callee.object
|
|
226
|
+
if (object.type !== "MemberExpression" || object.computed) return
|
|
227
|
+
if (object.object.type !== "ThisExpression") return
|
|
228
|
+
if (object.property.type !== "Identifier") return
|
|
229
|
+
const field = object.property.name
|
|
230
|
+
// Only the arrow-function callback is covered. A plain `function (tx) {}` callback does not
|
|
231
|
+
// inherit `this` from the enclosing method at all - TypeORM invokes it with no receiver, so
|
|
232
|
+
// `this.<field>` inside one throws rather than silently escaping the transaction, and this
|
|
233
|
+
// rule exists for the silent case.
|
|
234
|
+
const callback = node.arguments[0]
|
|
235
|
+
if (!callback || callback.type !== "ArrowFunctionExpression") return
|
|
236
|
+
const param = callback.params[0]
|
|
237
|
+
const paramName = param && param.type === "Identifier" ? param.name : "the transactional manager"
|
|
238
|
+
walkPreservingThis(callback.body, (descendant) => {
|
|
239
|
+
if (
|
|
240
|
+
descendant.type === "MemberExpression"
|
|
241
|
+
&& !descendant.computed
|
|
242
|
+
&& descendant.object.type === "ThisExpression"
|
|
243
|
+
&& descendant.property.type === "Identifier"
|
|
244
|
+
&& descendant.property.name === field
|
|
245
|
+
) {
|
|
246
|
+
context.report({ node: descendant, messageId: "outerManager", data: { field, param: paramName } })
|
|
247
|
+
}
|
|
248
|
+
})
|
|
249
|
+
},
|
|
250
|
+
}
|
|
251
|
+
},
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// -- DATA-5 (Rule 6) --------------------------------------------------------------------------------
|
|
255
|
+
|
|
256
|
+
/** Relation decorators whose `eager` option can grant the relation to every caller. */
|
|
257
|
+
const RELATION_DECORATORS = /^(?:ManyToOne|OneToOne|OneToMany|ManyToMany)$/
|
|
258
|
+
|
|
259
|
+
/** A relation is asked for by the call site that needs it; the entity grants no relation eagerly. */
|
|
260
|
+
export const noEagerRelation = {
|
|
261
|
+
meta: {
|
|
262
|
+
type: "problem",
|
|
263
|
+
docs: {
|
|
264
|
+
description: "A relation decorator carries no `eager: true` (data-access.md DATA-5, Rule 6).",
|
|
265
|
+
},
|
|
266
|
+
schema: [],
|
|
267
|
+
messages: {
|
|
268
|
+
eager:
|
|
269
|
+
"`@{{decorator}}(..., { eager: true })` grants this relation to every caller whether it asked for it or not, so a query that wants one column now pays for the whole tree. State the relation in the `relations` the call site writes, and drop `eager` here.",
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
create(context) {
|
|
273
|
+
return {
|
|
274
|
+
Decorator(node) {
|
|
275
|
+
const expression = node.expression
|
|
276
|
+
if (expression.type !== "CallExpression") return
|
|
277
|
+
if (expression.callee.type !== "Identifier" || !RELATION_DECORATORS.test(expression.callee.name)) return
|
|
278
|
+
for (const argument of expression.arguments) {
|
|
279
|
+
if (argument.type !== "ObjectExpression") continue
|
|
280
|
+
const eagerProperty = argument.properties.find(
|
|
281
|
+
(property) =>
|
|
282
|
+
property.type === "Property"
|
|
283
|
+
&& !property.computed
|
|
284
|
+
&& (property.key.name === "eager" || property.key.value === "eager"),
|
|
285
|
+
)
|
|
286
|
+
if (!eagerProperty) continue
|
|
287
|
+
if (eagerProperty.value.type === "Literal" && eagerProperty.value.value === true) {
|
|
288
|
+
context.report({ node, messageId: "eager", data: { decorator: expression.callee.name } })
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
},
|
|
292
|
+
}
|
|
293
|
+
},
|
|
294
|
+
}
|
|
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
|
+
|
|
419
|
+
/** The rules this law contributes to the plugin. */
|
|
420
|
+
export const rules = {
|
|
421
|
+
"must-inject-entity-manager": mustInjectEntityManager,
|
|
422
|
+
"no-injected-repository": noInjectedRepository,
|
|
423
|
+
"require-entity-table-name": requireEntityTableName,
|
|
424
|
+
"no-outer-manager-in-transaction": noOuterManagerInTransaction,
|
|
425
|
+
"no-eager-relation": noEagerRelation,
|
|
426
|
+
"no-external-call-in-transaction": noExternalCallInTransaction,
|
|
427
|
+
"named-entity-manager-only": namedEntityManagerOnly,
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* The level this law asks for, as the plugin's own opinion.
|
|
432
|
+
*
|
|
433
|
+
* All five measured against the reference repository (`src/**`) before shipping. The original three
|
|
434
|
+
* measured at zero debt and ship at `error`. `no-outer-manager-in-transaction` also measured zero -
|
|
435
|
+
* every `.transaction(async (tx) => ...)` callback in the reference repository already uses only the
|
|
436
|
+
* parameter it was handed - and ships at `error`. `no-eager-relation` also measured zero - no entity
|
|
437
|
+
* in the reference repository declares `eager: true` - and ships at `error` as well; a repository
|
|
438
|
+
* adopting these into a tree that is not already clean measures first and lands anything above zero
|
|
439
|
+
* at `warn` with the count beside it.
|
|
440
|
+
*/
|
|
441
|
+
export const recommended = {
|
|
442
|
+
"starci-be/must-inject-entity-manager": "error",
|
|
443
|
+
"starci-be/no-injected-repository": "error",
|
|
444
|
+
"starci-be/require-entity-table-name": "error",
|
|
445
|
+
"starci-be/no-outer-manager-in-transaction": "error",
|
|
446
|
+
"starci-be/no-eager-relation": "error",
|
|
447
|
+
"starci-be/no-external-call-in-transaction": "error",
|
|
448
|
+
"starci-be/named-entity-manager-only": "error",
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** Path helper shared with the other backend law modules. */
|
|
452
|
+
export { normalizePath }
|