@oliveward-common/eslint-plugin-ddd 0.0.13
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/README.md +33 -0
- package/index.js +374 -0
- package/package.json +26 -0
package/README.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# @oliveward-common/eslint-plugin-ddd
|
|
2
|
+
|
|
3
|
+
ESLint plugin with DDD-focused rules for this repository style.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -D @oliveward-common/eslint-plugin-ddd eslint
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Rules
|
|
12
|
+
|
|
13
|
+
- `ddd/no-external-entity-mutation`
|
|
14
|
+
- `ddd/no-entity-manager-create-assign`
|
|
15
|
+
|
|
16
|
+
## Usage (Flat Config)
|
|
17
|
+
|
|
18
|
+
```js
|
|
19
|
+
import dddPlugin from '@oliveward-common/eslint-plugin-ddd'
|
|
20
|
+
|
|
21
|
+
export default [
|
|
22
|
+
{
|
|
23
|
+
files: ['**/*.{ts,tsx,mts,cts}'],
|
|
24
|
+
plugins: {
|
|
25
|
+
ddd: dddPlugin,
|
|
26
|
+
},
|
|
27
|
+
rules: {
|
|
28
|
+
'ddd/no-external-entity-mutation': 'error',
|
|
29
|
+
'ddd/no-entity-manager-create-assign': 'error',
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
]
|
|
33
|
+
```
|
package/index.js
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
import ts from 'typescript'
|
|
2
|
+
|
|
3
|
+
function getParserServices(context) {
|
|
4
|
+
const services = context.sourceCode.parserServices ?? context.parserServices
|
|
5
|
+
if (!services?.program || !services?.esTreeNodeToTSNodeMap) {
|
|
6
|
+
return null
|
|
7
|
+
}
|
|
8
|
+
return services
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function getResolvedSymbol(symbol, checker) {
|
|
12
|
+
if (!symbol) {
|
|
13
|
+
return null
|
|
14
|
+
}
|
|
15
|
+
if ((symbol.flags & ts.SymbolFlags.Alias) !== 0) {
|
|
16
|
+
return checker.getAliasedSymbol(symbol)
|
|
17
|
+
}
|
|
18
|
+
return symbol
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function getDecoratorCallee(decorator) {
|
|
22
|
+
if (ts.isCallExpression(decorator.expression)) {
|
|
23
|
+
return decorator.expression.expression
|
|
24
|
+
}
|
|
25
|
+
return decorator.expression
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function symbolComesFromMikroOrm(symbol) {
|
|
29
|
+
const declarations = symbol?.getDeclarations?.() ?? []
|
|
30
|
+
return declarations.some((declaration) =>
|
|
31
|
+
declaration.getSourceFile().fileName.includes('@mikro-orm/'),
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isMikroEntityDecorator(decorator, checker) {
|
|
36
|
+
const callee = getDecoratorCallee(decorator)
|
|
37
|
+
const decoratorName =
|
|
38
|
+
ts.isIdentifier(callee) ? callee.text : ts.isPropertyAccessExpression(callee) ? callee.name.text : null
|
|
39
|
+
|
|
40
|
+
if (decoratorName !== 'Entity') {
|
|
41
|
+
return false
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const symbol = getResolvedSymbol(checker.getSymbolAtLocation(callee), checker)
|
|
45
|
+
if (!symbol) {
|
|
46
|
+
return true
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return symbolComesFromMikroOrm(symbol)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function isEntityClassDeclaration(classDeclaration, checker) {
|
|
53
|
+
const decorators = ts.canHaveDecorators(classDeclaration) ? ts.getDecorators(classDeclaration) ?? [] : []
|
|
54
|
+
return decorators.some((decorator) => isMikroEntityDecorator(decorator, checker))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function getEntityInfoFromSymbol(symbol, checker) {
|
|
58
|
+
const resolvedSymbol = getResolvedSymbol(symbol, checker)
|
|
59
|
+
if (!resolvedSymbol) {
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const declarations = resolvedSymbol.getDeclarations() ?? []
|
|
64
|
+
for (const declaration of declarations) {
|
|
65
|
+
if (ts.isClassDeclaration(declaration) && isEntityClassDeclaration(declaration, checker)) {
|
|
66
|
+
return {
|
|
67
|
+
symbol: resolvedSymbol,
|
|
68
|
+
declaration,
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return null
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function getBaseTypes(type, checker) {
|
|
77
|
+
if ((type.flags & ts.TypeFlags.Object) === 0) {
|
|
78
|
+
return []
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const objectType = type
|
|
82
|
+
if ((objectType.objectFlags & ts.ObjectFlags.ClassOrInterface) === 0) {
|
|
83
|
+
return []
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return checker.getBaseTypes(objectType) ?? []
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function getEntityInfoFromType(type, checker, visited = new Set()) {
|
|
90
|
+
if (!type || visited.has(type)) {
|
|
91
|
+
return null
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
visited.add(type)
|
|
95
|
+
|
|
96
|
+
if (type.isUnion?.()) {
|
|
97
|
+
for (const unionType of type.types) {
|
|
98
|
+
const unionEntityInfo = getEntityInfoFromType(unionType, checker, visited)
|
|
99
|
+
if (unionEntityInfo) {
|
|
100
|
+
return unionEntityInfo
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (type.isIntersection?.()) {
|
|
106
|
+
for (const intersectionType of type.types) {
|
|
107
|
+
const intersectionEntityInfo = getEntityInfoFromType(intersectionType, checker, visited)
|
|
108
|
+
if (intersectionEntityInfo) {
|
|
109
|
+
return intersectionEntityInfo
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const directEntityInfo = getEntityInfoFromSymbol(type.getSymbol?.(), checker)
|
|
115
|
+
if (directEntityInfo) {
|
|
116
|
+
return directEntityInfo
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const aliasEntityInfo = getEntityInfoFromSymbol(type.aliasSymbol, checker)
|
|
120
|
+
if (aliasEntityInfo) {
|
|
121
|
+
return aliasEntityInfo
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const baseTypes = getBaseTypes(type, checker)
|
|
125
|
+
for (const baseType of baseTypes) {
|
|
126
|
+
const baseEntityInfo = getEntityInfoFromType(baseType, checker, visited)
|
|
127
|
+
if (baseEntityInfo) {
|
|
128
|
+
return baseEntityInfo
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return null
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function unwrapEstreeExpression(node) {
|
|
136
|
+
if (!node) {
|
|
137
|
+
return node
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (node.type === 'TSAsExpression' || node.type === 'TSTypeAssertion' || node.type === 'TSNonNullExpression') {
|
|
141
|
+
return unwrapEstreeExpression(node.expression)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (node.type === 'ChainExpression') {
|
|
145
|
+
return unwrapEstreeExpression(node.expression)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return node
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function getTsNode(services, estreeNode) {
|
|
152
|
+
try {
|
|
153
|
+
return services.esTreeNodeToTSNodeMap.get(estreeNode)
|
|
154
|
+
} catch {
|
|
155
|
+
return null
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function isInsideClass(tsNode, targetClassSymbol, checker) {
|
|
160
|
+
let current = tsNode
|
|
161
|
+
while (current) {
|
|
162
|
+
if (ts.isClassDeclaration(current) && current.name) {
|
|
163
|
+
const classSymbol = getResolvedSymbol(checker.getSymbolAtLocation(current.name), checker)
|
|
164
|
+
if (classSymbol === targetClassSymbol) {
|
|
165
|
+
return true
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
current = current.parent
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return false
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function getMemberName(memberExpression) {
|
|
176
|
+
if (!memberExpression.computed && memberExpression.property.type === 'Identifier') {
|
|
177
|
+
return memberExpression.property.name
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (memberExpression.computed && memberExpression.property.type === 'Literal') {
|
|
181
|
+
return typeof memberExpression.property.value === 'string' ? memberExpression.property.value : null
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return null
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function isLikelyEntityManagerReference(node) {
|
|
188
|
+
if (!node) {
|
|
189
|
+
return false
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (node.type === 'Identifier') {
|
|
193
|
+
return node.name === 'em'
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (node.type === 'MemberExpression') {
|
|
197
|
+
const memberName = getMemberName(node)
|
|
198
|
+
return memberName === 'em'
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return false
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function isMikroEntityManagerType(type, checker, visited = new Set()) {
|
|
205
|
+
if (!type || visited.has(type)) {
|
|
206
|
+
return false
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
visited.add(type)
|
|
210
|
+
|
|
211
|
+
if (type.isUnion?.()) {
|
|
212
|
+
return type.types.some((unionType) => isMikroEntityManagerType(unionType, checker, visited))
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (type.isIntersection?.()) {
|
|
216
|
+
return type.types.some((intersectionType) => isMikroEntityManagerType(intersectionType, checker, visited))
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const resolvedSymbol = getResolvedSymbol(type.getSymbol?.(), checker)
|
|
220
|
+
if (resolvedSymbol) {
|
|
221
|
+
const name = resolvedSymbol.getName()
|
|
222
|
+
if (name.includes('EntityManager') && symbolComesFromMikroOrm(resolvedSymbol)) {
|
|
223
|
+
return true
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const aliasSymbol = getResolvedSymbol(type.aliasSymbol, checker)
|
|
228
|
+
if (aliasSymbol) {
|
|
229
|
+
const aliasName = aliasSymbol.getName()
|
|
230
|
+
if (aliasName.includes('EntityManager') && symbolComesFromMikroOrm(aliasSymbol)) {
|
|
231
|
+
return true
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const baseTypes = getBaseTypes(type, checker)
|
|
236
|
+
return baseTypes.some((baseType) => isMikroEntityManagerType(baseType, checker, visited))
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const noExternalEntityMutationRule = {
|
|
240
|
+
meta: {
|
|
241
|
+
type: 'problem',
|
|
242
|
+
docs: {
|
|
243
|
+
description: 'Disallow mutating properties of MikroORM @Entity instances outside entity class methods',
|
|
244
|
+
},
|
|
245
|
+
schema: [],
|
|
246
|
+
messages: {
|
|
247
|
+
noExternalMutation:
|
|
248
|
+
'Do not mutate {{entityName}} properties from outside the entity class. Add behavior/transition methods on the entity and call those methods instead.',
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
create(context) {
|
|
252
|
+
const services = getParserServices(context)
|
|
253
|
+
if (!services) {
|
|
254
|
+
return {}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const checker = services.program.getTypeChecker()
|
|
258
|
+
|
|
259
|
+
function reportIfExternalEntityMutation(memberExpressionNode, reportNode) {
|
|
260
|
+
const objectNode = unwrapEstreeExpression(memberExpressionNode.object)
|
|
261
|
+
const tsObjectNode = getTsNode(services, objectNode)
|
|
262
|
+
if (!tsObjectNode) {
|
|
263
|
+
return
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const objectType = checker.getTypeAtLocation(tsObjectNode)
|
|
267
|
+
const entityInfo = getEntityInfoFromType(objectType, checker)
|
|
268
|
+
if (!entityInfo) {
|
|
269
|
+
return
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const tsReportNode = getTsNode(services, reportNode)
|
|
273
|
+
if (!tsReportNode) {
|
|
274
|
+
return
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (isInsideClass(tsReportNode, entityInfo.symbol, checker)) {
|
|
278
|
+
return
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
context.report({
|
|
282
|
+
node: reportNode,
|
|
283
|
+
messageId: 'noExternalMutation',
|
|
284
|
+
data: {
|
|
285
|
+
entityName: entityInfo.symbol.getName(),
|
|
286
|
+
},
|
|
287
|
+
})
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return {
|
|
291
|
+
AssignmentExpression(node) {
|
|
292
|
+
const leftNode = unwrapEstreeExpression(node.left)
|
|
293
|
+
if (!leftNode || leftNode.type !== 'MemberExpression') {
|
|
294
|
+
return
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
reportIfExternalEntityMutation(leftNode, node)
|
|
298
|
+
},
|
|
299
|
+
UpdateExpression(node) {
|
|
300
|
+
const argumentNode = unwrapEstreeExpression(node.argument)
|
|
301
|
+
if (!argumentNode || argumentNode.type !== 'MemberExpression') {
|
|
302
|
+
return
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
reportIfExternalEntityMutation(argumentNode, node)
|
|
306
|
+
},
|
|
307
|
+
}
|
|
308
|
+
},
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const noEntityManagerCreateAssignRule = {
|
|
312
|
+
meta: {
|
|
313
|
+
type: 'problem',
|
|
314
|
+
docs: {
|
|
315
|
+
description: 'Disallow EntityManager.create and EntityManager.assign',
|
|
316
|
+
},
|
|
317
|
+
schema: [],
|
|
318
|
+
messages: {
|
|
319
|
+
disallowedMethod:
|
|
320
|
+
'Do not call EntityManager.{{methodName}}. Construct entities via constructors, then persist with em.persist(...).',
|
|
321
|
+
},
|
|
322
|
+
},
|
|
323
|
+
create(context) {
|
|
324
|
+
const services = getParserServices(context)
|
|
325
|
+
const checker = services?.program.getTypeChecker()
|
|
326
|
+
|
|
327
|
+
return {
|
|
328
|
+
CallExpression(node) {
|
|
329
|
+
const calleeNode = unwrapEstreeExpression(node.callee)
|
|
330
|
+
if (!calleeNode || calleeNode.type !== 'MemberExpression') {
|
|
331
|
+
return
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const methodName = getMemberName(calleeNode)
|
|
335
|
+
if (!methodName || (methodName !== 'create' && methodName !== 'assign')) {
|
|
336
|
+
return
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const objectNode = unwrapEstreeExpression(calleeNode.object)
|
|
340
|
+
|
|
341
|
+
let shouldReport = isLikelyEntityManagerReference(objectNode)
|
|
342
|
+
|
|
343
|
+
if (!shouldReport && services && checker) {
|
|
344
|
+
const tsObjectNode = getTsNode(services, objectNode)
|
|
345
|
+
if (tsObjectNode) {
|
|
346
|
+
const objectType = checker.getTypeAtLocation(tsObjectNode)
|
|
347
|
+
shouldReport = isMikroEntityManagerType(objectType, checker)
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (!shouldReport) {
|
|
352
|
+
return
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
context.report({
|
|
356
|
+
node: calleeNode.property,
|
|
357
|
+
messageId: 'disallowedMethod',
|
|
358
|
+
data: {
|
|
359
|
+
methodName,
|
|
360
|
+
},
|
|
361
|
+
})
|
|
362
|
+
},
|
|
363
|
+
}
|
|
364
|
+
},
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const dddPlugin = {
|
|
368
|
+
rules: {
|
|
369
|
+
'no-external-entity-mutation': noExternalEntityMutationRule,
|
|
370
|
+
'no-entity-manager-create-assign': noEntityManagerCreateAssignRule,
|
|
371
|
+
},
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export default dddPlugin
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@oliveward-common/eslint-plugin-ddd",
|
|
3
|
+
"version": "0.0.13",
|
|
4
|
+
"private": false,
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "public"
|
|
7
|
+
},
|
|
8
|
+
"type": "module",
|
|
9
|
+
"main": "./index.js",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./index.js"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"index.js",
|
|
15
|
+
"README.md"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "node -e \"process.exit(0)\""
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"typescript": "^6.0.3"
|
|
22
|
+
},
|
|
23
|
+
"peerDependencies": {
|
|
24
|
+
"eslint": "^10.3.0"
|
|
25
|
+
}
|
|
26
|
+
}
|