@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.
Files changed (3) hide show
  1. package/README.md +33 -0
  2. package/index.js +374 -0
  3. 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
+ }