@medplum/eslint-config 5.1.17 → 5.1.21

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/index.mjs CHANGED
@@ -7,13 +7,21 @@ import importPlugin from 'eslint-plugin-import';
7
7
  import jsdoc from 'eslint-plugin-jsdoc';
8
8
  import noOnlyTestsPlugin from 'eslint-plugin-no-only-tests';
9
9
  import reactHooks from 'eslint-plugin-react-hooks';
10
- import reactRefresh from 'eslint-plugin-react-refresh';
10
+ import reactRefreshPlugin from 'eslint-plugin-react-refresh';
11
+ // eslint-disable-next-line import/no-unresolved -- Node resolves this package export; eslint-plugin-import does not.
11
12
  import tseslint from 'typescript-eslint';
13
+ import noTransactionCallbackInvokingRepo from './rules/no-transaction-callback-invoking-repo.mjs';
12
14
 
13
15
  // Workaround for eslint-plugin-header ESLint 9 compatibility issue.
14
16
  // See: https://github.com/Stuk/eslint-plugin-header/issues/57#issuecomment-2378485611
15
17
  headerPlugin.rules.header.meta.schema = false;
16
18
 
19
+ const medplumPlugin = {
20
+ rules: {
21
+ 'no-transaction-callback-invoking-repo': noTransactionCallbackInvokingRepo,
22
+ },
23
+ };
24
+
17
25
  /**
18
26
  * Core config applies to all source files.
19
27
  * TypeScript-specific rules are in the tsConfig below.
@@ -147,7 +155,7 @@ export const tsConfig = {
147
155
  },
148
156
  tseslint.configs.strict,
149
157
  reactHooks.configs.flat.recommended,
150
- reactRefresh.configs.recommended,
158
+ reactRefreshPlugin.configs.recommended,
151
159
  ],
152
160
  plugins: {
153
161
  'no-only-tests': noOnlyTestsPlugin,
@@ -295,13 +303,23 @@ export const medplumEslintConfig = [
295
303
  },
296
304
  coreConfig,
297
305
  tsConfig,
306
+ {
307
+ files: ['packages/fhir-router/**/*.ts', 'packages/server/**/*.ts'],
308
+ plugins: {
309
+ medplum: medplumPlugin,
310
+ },
311
+ rules: {
312
+ 'medplum/no-transaction-callback-invoking-repo': 'error',
313
+ },
314
+ },
298
315
  /**
299
- * vite.config.ts is often outside package tsconfig include (e.g. rootDir src); skip type-aware parsing.
316
+ * vite.config.ts and vitest.config.ts are often outside package tsconfig include (e.g. rootDir src);
317
+ * skip type-aware parsing.
300
318
  *
301
- * we don't need type checking for vite.config.ts files
319
+ * we don't need type checking for vite.config.ts or vitest.config.ts files
302
320
  */
303
321
  {
304
- files: ['**/vite.config.ts'],
322
+ files: ['**/vite.config.ts', '**/vitest.config.ts'],
305
323
  extends: [tseslint.configs.disableTypeChecked],
306
324
  },
307
325
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@medplum/eslint-config",
3
- "version": "5.1.17",
3
+ "version": "5.1.21",
4
4
  "description": "Shared ESLint configuration for Medplum projects",
5
5
  "keywords": [
6
6
  "eslint",
@@ -19,15 +19,20 @@
19
19
  "author": "Medplum <hello@medplum.com>",
20
20
  "type": "module",
21
21
  "main": "index.mjs",
22
+ "scripts": {
23
+ "lint": "eslint .",
24
+ "lint:fix": "eslint . --fix",
25
+ "test": "node --test rules/*.test.mjs"
26
+ },
22
27
  "devDependencies": {
23
28
  "eslint": "9.39.4",
24
29
  "eslint-plugin-header": "3.1.1",
25
30
  "eslint-plugin-import": "2.32.0",
26
- "eslint-plugin-jsdoc": "63.0.1",
31
+ "eslint-plugin-jsdoc": "63.0.2",
27
32
  "eslint-plugin-no-only-tests": "3.4.0",
28
33
  "eslint-plugin-react-hooks": "7.1.1",
29
34
  "eslint-plugin-react-refresh": "0.5.2",
30
- "typescript-eslint": "8.60.1"
35
+ "typescript-eslint": "8.61.0"
31
36
  },
32
37
  "peerDependencies": {
33
38
  "eslint": "^9",
@@ -0,0 +1,311 @@
1
+ // SPDX-FileCopyrightText: Copyright Orangebot, Inc. and Medplum contributors
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ /**
5
+ * Flags uses of the repository that a `withTransaction`/`ensureInTransaction` callback was
6
+ * invoked on from inside that callback, including aliasing it (`const parent = repo`).
7
+ * Callback code must use the transaction-scoped repository passed as the callback parameter;
8
+ * the invoking repository is locked for the duration of the transaction and throws at runtime.
9
+ *
10
+ * This rule is fast feedback for the common cases, not the enforcement mechanism. The
11
+ * runtime scope guard in RepositoryConnection.assertScope() is authoritative. Known blind
12
+ * spots, deliberately left uncovered because closing them requires transitive variable
13
+ * resolution that is not worth the complexity:
14
+ * - Aliases created before the callback: `const r = repo; repo.withTransaction(() => r.foo())`
15
+ * - Reassignment through a mutable binding: `let r; r = repo;`
16
+ * - The repo escaping through a function argument, property, or field, e.g. the
17
+ * `this.repo` swap in BatchProcessor.withRepo() in fhir-router's batch.ts
18
+ * - When the invoking repo is reached through `this`, the rule stops descending at
19
+ * constructs that rebind `this` (non-arrow functions, classes), so a computed class
20
+ * member key that evaluates with the outer `this` is missed
21
+ */
22
+ const transactionMethodNames = new Set(['withTransaction', 'ensureInTransaction']);
23
+ const thisRebindingNodeTypes = new Set([
24
+ 'ClassDeclaration',
25
+ 'ClassExpression',
26
+ 'FunctionDeclaration',
27
+ 'FunctionExpression',
28
+ 'StaticBlock',
29
+ ]);
30
+ const ignoredTraversalKeys = new Set([
31
+ 'comments',
32
+ 'decorators',
33
+ 'loc',
34
+ 'parent',
35
+ 'range',
36
+ 'returnType',
37
+ 'tokens',
38
+ 'typeAnnotation',
39
+ 'typeArguments',
40
+ 'typeParameters',
41
+ ]);
42
+
43
+ /**
44
+ * @param context - ESLint rule context.
45
+ * @returns ESLint rule listener.
46
+ */
47
+ function create(context) {
48
+ const sourceCode = context.sourceCode;
49
+
50
+ /**
51
+ * @param node - The AST node.
52
+ * @returns The unwrapped expression.
53
+ */
54
+ function unwrapExpression(node) {
55
+ let result = node;
56
+ while (
57
+ result?.type === 'ChainExpression' ||
58
+ result?.type === 'TSAsExpression' ||
59
+ result?.type === 'TSNonNullExpression' ||
60
+ result?.type === 'TSTypeAssertion'
61
+ ) {
62
+ result = result.expression;
63
+ }
64
+ return result;
65
+ }
66
+
67
+ /**
68
+ * @param node - The AST node.
69
+ * @returns The scope for the AST node.
70
+ */
71
+ function getScope(node) {
72
+ return sourceCode.getScope(node);
73
+ }
74
+
75
+ /**
76
+ * @param node - The identifier node.
77
+ * @returns The variable represented by the identifier.
78
+ */
79
+ function findVariable(node) {
80
+ let scope = getScope(node);
81
+ while (scope) {
82
+ const variable = scope.set.get(node.name);
83
+ if (variable) {
84
+ return variable;
85
+ }
86
+ scope = scope.upper;
87
+ }
88
+ return undefined;
89
+ }
90
+
91
+ /**
92
+ * @param node - The identifier node.
93
+ * @returns True if the identifier is an expression reference.
94
+ */
95
+ function isReferenceIdentifier(node) {
96
+ const parent = node.parent;
97
+ if (!parent) {
98
+ return true;
99
+ }
100
+
101
+ switch (parent.type) {
102
+ case 'ArrayPattern':
103
+ case 'ImportDefaultSpecifier':
104
+ case 'ImportNamespaceSpecifier':
105
+ case 'LabeledStatement':
106
+ case 'MetaProperty':
107
+ case 'RestElement':
108
+ case 'TSTypeReference':
109
+ return false;
110
+ case 'VariableDeclarator':
111
+ // The declared name is not a reference, but the initializer is:
112
+ // `const alias = repo` reads the repo to create an alias for it
113
+ return parent.init === node;
114
+ case 'AssignmentPattern':
115
+ return parent.right === node;
116
+ case 'ClassDeclaration':
117
+ case 'ClassExpression':
118
+ case 'FunctionDeclaration':
119
+ case 'FunctionExpression':
120
+ return parent.id !== node;
121
+ case 'ImportSpecifier':
122
+ return parent.local !== node;
123
+ case 'MemberExpression':
124
+ return parent.object === node || parent.computed;
125
+ case 'MethodDefinition':
126
+ case 'Property':
127
+ case 'PropertyDefinition':
128
+ return parent.value === node || parent.computed;
129
+ default:
130
+ return true;
131
+ }
132
+ }
133
+
134
+ /**
135
+ * @param node - The member expression.
136
+ * @returns The static property name.
137
+ */
138
+ function getStaticPropertyName(node) {
139
+ if (node.computed) {
140
+ const property = unwrapExpression(node.property);
141
+ return property?.type === 'Literal' && typeof property.value === 'string' ? property.value : undefined;
142
+ }
143
+ if (node.property.type === 'Identifier' || node.property.type === 'PrivateIdentifier') {
144
+ return node.property.name;
145
+ }
146
+ return undefined;
147
+ }
148
+
149
+ /**
150
+ * @param expected - The expected member expression.
151
+ * @param candidate - The candidate member expression.
152
+ * @returns True if the member expressions use the same property.
153
+ */
154
+ function hasSameProperty(expected, candidate) {
155
+ if (expected.computed || candidate.computed) {
156
+ return (
157
+ expected.computed === candidate.computed &&
158
+ sourceCode.getText(expected.property) === sourceCode.getText(candidate.property)
159
+ );
160
+ }
161
+ return getStaticPropertyName(expected) === getStaticPropertyName(candidate);
162
+ }
163
+
164
+ /**
165
+ * @param expected - The expected expression.
166
+ * @param candidate - The candidate expression.
167
+ * @returns True if both expressions refer to the same expression.
168
+ */
169
+ function isSameExpression(expected, candidate) {
170
+ const unwrappedExpected = unwrapExpression(expected);
171
+ const unwrappedCandidate = unwrapExpression(candidate);
172
+ if (!unwrappedExpected || !unwrappedCandidate || unwrappedExpected.type !== unwrappedCandidate.type) {
173
+ return false;
174
+ }
175
+
176
+ switch (unwrappedExpected.type) {
177
+ case 'Identifier':
178
+ return (
179
+ isReferenceIdentifier(unwrappedCandidate) &&
180
+ unwrappedExpected.name === unwrappedCandidate.name &&
181
+ findVariable(unwrappedExpected) === findVariable(unwrappedCandidate)
182
+ );
183
+ case 'ThisExpression':
184
+ return true;
185
+ case 'Super':
186
+ return true;
187
+ case 'MemberExpression':
188
+ return (
189
+ hasSameProperty(unwrappedExpected, unwrappedCandidate) &&
190
+ isSameExpression(unwrappedExpected.object, unwrappedCandidate.object)
191
+ );
192
+ default:
193
+ return sourceCode.getText(unwrappedExpected) === sourceCode.getText(unwrappedCandidate);
194
+ }
195
+ }
196
+
197
+ /**
198
+ * @param node - The AST node.
199
+ * @returns True if the node is a function expression.
200
+ */
201
+ function isCallbackFunction(node) {
202
+ return node.type === 'ArrowFunctionExpression' || node.type === 'FunctionExpression';
203
+ }
204
+
205
+ /**
206
+ * @param node - The AST node.
207
+ * @param callback - The visitor callback.
208
+ * @param skipThisRebinding - Whether to stop descending at nodes that rebind `this`.
209
+ */
210
+ function visit(node, callback, skipThisRebinding = false) {
211
+ if (callback(node)) {
212
+ return;
213
+ }
214
+ if (skipThisRebinding && thisRebindingNodeTypes.has(node.type)) {
215
+ return;
216
+ }
217
+
218
+ for (const [key, value] of Object.entries(node)) {
219
+ if (ignoredTraversalKeys.has(key)) {
220
+ continue;
221
+ }
222
+ if (Array.isArray(value)) {
223
+ for (const child of value) {
224
+ if (child?.type) {
225
+ visit(child, callback, skipThisRebinding);
226
+ }
227
+ }
228
+ } else if (value?.type) {
229
+ visit(value, callback, skipThisRebinding);
230
+ }
231
+ }
232
+ }
233
+
234
+ /**
235
+ * @param node - The AST node.
236
+ * @returns True if the expression contains a `this` or `super` reference.
237
+ */
238
+ function usesThisBinding(node) {
239
+ let found = false;
240
+ visit(node, (candidate) => {
241
+ if (candidate.type === 'ThisExpression' || candidate.type === 'Super') {
242
+ found = true;
243
+ }
244
+ return found;
245
+ });
246
+ return found;
247
+ }
248
+
249
+ return {
250
+ CallExpression(node) {
251
+ const callee = unwrapExpression(node.callee);
252
+ if (callee?.type !== 'MemberExpression') {
253
+ return;
254
+ }
255
+
256
+ const methodName = getStaticPropertyName(callee);
257
+ if (!methodName || !transactionMethodNames.has(methodName)) {
258
+ return;
259
+ }
260
+
261
+ const invokingRepo = unwrapExpression(callee.object);
262
+ const callback = unwrapExpression(node.arguments[0]);
263
+ if (!invokingRepo || !callback || !isCallbackFunction(callback)) {
264
+ return;
265
+ }
266
+
267
+ const repoUsesThis = usesThisBinding(invokingRepo);
268
+ if (repoUsesThis && callback.type === 'FunctionExpression') {
269
+ // A non-arrow callback rebinds `this`, so the invoking repo is unreachable inside it
270
+ return;
271
+ }
272
+
273
+ visit(
274
+ callback.body,
275
+ (candidate) => {
276
+ if (!isSameExpression(invokingRepo, candidate)) {
277
+ return false;
278
+ }
279
+
280
+ context.report({
281
+ node: candidate,
282
+ messageId: 'useCallbackRepo',
283
+ data: {
284
+ methodName,
285
+ repoName: sourceCode.getText(invokingRepo),
286
+ },
287
+ });
288
+ return true;
289
+ },
290
+ repoUsesThis
291
+ );
292
+ },
293
+ };
294
+ }
295
+
296
+ /** @type {import('eslint').Rule.RuleModule} */
297
+ const noTransactionCallbackInvokingRepoRule = {
298
+ meta: {
299
+ type: 'problem',
300
+ docs: {
301
+ description: 'require transaction callbacks to use the repository parameter instead of the invoking repository',
302
+ },
303
+ messages: {
304
+ useCallbackRepo: 'Use the repository passed to the {{methodName}} callback instead of `{{repoName}}`.',
305
+ },
306
+ schema: [],
307
+ },
308
+ create,
309
+ };
310
+
311
+ export default noTransactionCallbackInvokingRepoRule;
@@ -0,0 +1,146 @@
1
+ // SPDX-FileCopyrightText: Copyright Orangebot, Inc. and Medplum contributors
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import { RuleTester } from 'eslint';
5
+ import rule from './no-transaction-callback-invoking-repo.mjs';
6
+
7
+ const ruleTester = new RuleTester({
8
+ languageOptions: {
9
+ ecmaVersion: 'latest',
10
+ sourceType: 'module',
11
+ },
12
+ });
13
+
14
+ ruleTester.run('no-transaction-callback-invoking-repo', rule, {
15
+ valid: [
16
+ {
17
+ code: 'repo.withTransaction(async (txRepo) => txRepo.createResource(resource));',
18
+ },
19
+ {
20
+ code: 'repo.ensureInTransaction(async (txRepo) => txRepo.createResource(resource));',
21
+ },
22
+ {
23
+ code: 'ctx.repo.withTransaction(async (txRepo) => txRepo.createResource(resource));',
24
+ },
25
+ {
26
+ code: 'this.repo.withTransaction(async (txRepo) => this.withRepo(txRepo, () => this.processBatch()));',
27
+ },
28
+ {
29
+ code: 'repo.withTransaction(async (repo) => repo.createResource(resource));',
30
+ },
31
+ {
32
+ code: 'repo.withTransaction(txFn);',
33
+ },
34
+ {
35
+ code: [
36
+ 'repo.withTransaction(async (txRepo) =>',
37
+ ' txRepo.withTransaction(async (nestedRepo) => nestedRepo.createResource(resource))',
38
+ ');',
39
+ ].join('\n'),
40
+ },
41
+ {
42
+ // Declaring a shadowing variable from the callback repo is fine; only the
43
+ // initializer of a declaration counts as a reference, not the declared name
44
+ code: [
45
+ 'repo.withTransaction(async (txRepo) => {',
46
+ ' const repo = txRepo;',
47
+ ' return repo.createResource(resource);',
48
+ '});',
49
+ ].join('\n'),
50
+ },
51
+ {
52
+ // `this` inside a nested non-arrow function is a different binding
53
+ code: [
54
+ 'this.repo.withTransaction(async (txRepo) => {',
55
+ ' emitter.on("done", function () {',
56
+ ' this.repo.createResource(resource);',
57
+ ' });',
58
+ '});',
59
+ ].join('\n'),
60
+ },
61
+ {
62
+ // `this` inside a nested class method is a different binding
63
+ code: [
64
+ 'this.repo.withTransaction(async (txRepo) => {',
65
+ ' class Helper {',
66
+ ' run() {',
67
+ ' return this.repo.createResource(resource);',
68
+ ' }',
69
+ ' }',
70
+ ' return new Helper().run();',
71
+ '});',
72
+ ].join('\n'),
73
+ },
74
+ {
75
+ // A non-arrow callback rebinds `this`, so the invoking repo is unreachable inside it
76
+ code: 'this.repo.withTransaction(async function (txRepo) { return this.repo.createResource(resource); });',
77
+ },
78
+ ],
79
+ invalid: [
80
+ {
81
+ code: 'repo.withTransaction(async (txRepo) => repo.createResource(resource));',
82
+ errors: [{ messageId: 'useCallbackRepo' }],
83
+ },
84
+ {
85
+ code: 'repo.ensureInTransaction(async (txRepo) => repo.createResource(resource));',
86
+ errors: [{ messageId: 'useCallbackRepo' }],
87
+ },
88
+ {
89
+ code: 'ctx.repo.withTransaction(async (txRepo) => ctx.repo.createResource(resource));',
90
+ errors: [{ messageId: 'useCallbackRepo' }],
91
+ },
92
+ {
93
+ code: 'this.repo.withTransaction(async (txRepo) => this.repo.createResource(resource));',
94
+ errors: [{ messageId: 'useCallbackRepo' }],
95
+ },
96
+ {
97
+ code: [
98
+ 'repo.withTransaction(async (txRepo) =>',
99
+ ' repo.withTransaction(async (nestedRepo) => nestedRepo.createResource(resource))',
100
+ ');',
101
+ ].join('\n'),
102
+ errors: [{ messageId: 'useCallbackRepo' }],
103
+ },
104
+ {
105
+ // Aliasing the invoking repo inside the callback is flagged at the alias declaration
106
+ code: [
107
+ 'repo.withTransaction(async (txRepo) => {',
108
+ ' const parent = repo;',
109
+ ' return parent.createResource(resource);',
110
+ '});',
111
+ ].join('\n'),
112
+ errors: [{ messageId: 'useCallbackRepo' }],
113
+ },
114
+ {
115
+ code: [
116
+ 'ctx.repo.withTransaction(async (txRepo) => {',
117
+ ' const parent = ctx.repo;',
118
+ ' return parent.createResource(resource);',
119
+ '});',
120
+ ].join('\n'),
121
+ errors: [{ messageId: 'useCallbackRepo' }],
122
+ },
123
+ {
124
+ // Arrow functions inherit `this`, so the invoking repo is still reachable
125
+ code: [
126
+ 'this.repo.withTransaction(async (txRepo) => {',
127
+ ' const run = () => this.repo.createResource(resource);',
128
+ ' return run();',
129
+ '});',
130
+ ].join('\n'),
131
+ errors: [{ messageId: 'useCallbackRepo' }],
132
+ },
133
+ {
134
+ // Identifier resolution is unaffected by `this` rebinding in nested functions
135
+ code: [
136
+ 'repo.withTransaction(async (txRepo) => {',
137
+ ' function helper() {',
138
+ ' return repo.createResource(resource);',
139
+ ' }',
140
+ ' return helper();',
141
+ '});',
142
+ ].join('\n'),
143
+ errors: [{ messageId: 'useCallbackRepo' }],
144
+ },
145
+ ],
146
+ });