@ethlete/eslint-plugin 1.0.0-next.17 → 1.0.0-next.18

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 CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.0-next.18
4
+
5
+ ### Minor Changes
6
+
7
+ - [`6a7e76b`](https://github.com/ethlete-io/ethdk/commit/6a7e76b3535412ac3eefdabc1aecb98c2f26552f) Thanks [@TomTomB](https://github.com/TomTomB)! - New rule `no-legacy-prepare-without-injector`: a legacy query `prepare()` called from a deferred callback
8
+ must pass an `injector`, with a fixer that adds the member and threads it.
9
+
3
10
  ## 1.0.0-next.17
4
11
 
5
12
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ethlete/eslint-plugin",
3
- "version": "1.0.0-next.17",
3
+ "version": "1.0.0-next.18",
4
4
  "license": "MIT",
5
5
  "type": "commonjs",
6
6
  "main": "./src/index.js",
@@ -247,6 +247,9 @@ const recommendedTs = {
247
247
  // Observable variables/properties must end with $
248
248
  'ethlete/require-dollar-suffix': 'error',
249
249
 
250
+ // Legacy query creator prepare() calls that run outside an injection context need an explicit injector
251
+ 'ethlete/no-legacy-prepare-without-injector': 'error',
252
+
250
253
  // No .subscribe() inside a .pipe() callback
251
254
  'ethlete/no-subscribe-in-pipe': 'error',
252
255
 
package/src/index.js CHANGED
@@ -54,6 +54,7 @@ const noNativeHtmlInputName = require('./rules/no-native-html-input-name');
54
54
  const preferPresentTenseOutput = require('./rules/prefer-present-tense-output');
55
55
  const preferStaticBooleanProperties = require('./rules/prefer-static-boolean-properties');
56
56
  const noImpureTopLevelProvider = require('./rules/no-impure-top-level-provider');
57
+ const noLegacyPrepareWithoutInjector = require('./rules/no-legacy-prepare-without-injector');
57
58
  const { recommendedTs, recommendedTemplate, recommendedSpec } = require('./configs/recommended');
58
59
 
59
60
  /** @type {import('eslint').ESLint.Plugin} */
@@ -64,6 +65,7 @@ const plugin = {
64
65
  },
65
66
  rules: {
66
67
  'no-inject-chain': noInjectChain,
68
+ 'no-legacy-prepare-without-injector': noLegacyPrepareWithoutInjector,
67
69
  'no-trivial-return-type': noTrivialReturnType,
68
70
  'no-subscribe-in-pipe': noSubscribeInPipe,
69
71
  'no-rxjs-in-effect': noRxjsInEffect,
@@ -0,0 +1,493 @@
1
+ // @ts-check
2
+ 'use strict';
3
+
4
+ /**
5
+ * Requires an explicit `injector` on a legacy query creator's `prepare()` call whenever the call runs
6
+ * outside the injection context that created it.
7
+ *
8
+ * The v2 client needed no injection context, so migrated call sites are full of callbacks that prepare a
9
+ * query long after their component was built - a `computed()` at a class field, an `effect()` in a
10
+ * constructor, an RxJS operator. Those throw `ET950` at runtime, and only on the code path that runs the
11
+ * callback, which is how they reach production.
12
+ *
13
+ * BAD — the callback runs once the field initializer's context is long gone:
14
+ * users = computed(() => legacyGetUsers.prepare({ queryParams: { page: this.page() } })); // ❌ ET950
15
+ *
16
+ * GOOD:
17
+ * private injector = inject(Injector);
18
+ * users = computed(() => legacyGetUsers.prepare({ queryParams: { page: this.page() }, injector: this.injector }));
19
+ *
20
+ * Left alone: calls that really do run inside a context - directly in a constructor or field initializer,
21
+ * inside `runInInjectionContext()`, inside the `queryComputed` family, in a synchronous array callback
22
+ * (`items.map(…)`) that runs before its caller returns, or in a function that calls `inject()` itself and
23
+ * therefore can only be called from a context.
24
+ */
25
+
26
+ /** Helpers that run their callback inside an injection context. */
27
+ const CONTEXT_PROVIDING_CALLEES = new Set([
28
+ 'runInInjectionContext',
29
+ 'queryComputed',
30
+ 'queryComputedTillTruthy',
31
+ 'queryArrayComputed',
32
+ ]);
33
+
34
+ /**
35
+ * Array methods, which call back synchronously in the caller's own context. Matched only on a property
36
+ * access (`items.map(…)`) - the bare-identifier form is an RxJS operator, whose callback runs later.
37
+ */
38
+ const TRANSPARENT_ARRAY_METHODS = new Set([
39
+ 'map',
40
+ 'flatMap',
41
+ 'filter',
42
+ 'forEach',
43
+ 'find',
44
+ 'findLast',
45
+ 'findIndex',
46
+ 'some',
47
+ 'every',
48
+ 'reduce',
49
+ 'reduceRight',
50
+ 'sort',
51
+ 'flat',
52
+ ]);
53
+
54
+ /**
55
+ * @param {any} node
56
+ * @returns {boolean}
57
+ */
58
+ const isFunctionNode = (node) =>
59
+ !!node &&
60
+ (node.type === 'ArrowFunctionExpression' ||
61
+ node.type === 'FunctionExpression' ||
62
+ node.type === 'FunctionDeclaration');
63
+
64
+ /**
65
+ * The call a function literal is being passed to, if it is an argument rather than a value.
66
+ * @param {any} node
67
+ */
68
+ const getCallbackHost = (node) => {
69
+ const parent = node.parent;
70
+
71
+ if (!parent || parent.type !== 'CallExpression' || !parent.arguments.includes(node)) {
72
+ return null;
73
+ }
74
+
75
+ return parent;
76
+ };
77
+
78
+ /**
79
+ * @param {any} host
80
+ * @returns {boolean}
81
+ */
82
+ const isContextProvidingHost = (host) => {
83
+ if (!host) {
84
+ return false;
85
+ }
86
+
87
+ if (host.callee.type === 'Identifier') {
88
+ return CONTEXT_PROVIDING_CALLEES.has(host.callee.name);
89
+ }
90
+
91
+ return (
92
+ host.callee.type === 'MemberExpression' &&
93
+ host.callee.property.type === 'Identifier' &&
94
+ host.callee.property.name === 'runInContext'
95
+ );
96
+ };
97
+
98
+ /**
99
+ * @param {any} host
100
+ * @returns {boolean}
101
+ */
102
+ const isTransparentHost = (host) =>
103
+ !!host &&
104
+ host.callee.type === 'MemberExpression' &&
105
+ host.callee.property.type === 'Identifier' &&
106
+ TRANSPARENT_ARRAY_METHODS.has(host.callee.property.name);
107
+
108
+ /**
109
+ * Whether the function's own body calls `inject()`. Such a function can only be called from an injection
110
+ * context, so anything inside it has one too.
111
+ * @param {any} fn
112
+ * @returns {boolean}
113
+ */
114
+ const callsInject = (fn) => {
115
+ let found = false;
116
+
117
+ /** @param {any} node */
118
+ const visit = (node) => {
119
+ if (found || !node || typeof node.type !== 'string') {
120
+ return;
121
+ }
122
+
123
+ if (node.type === 'CallExpression' && node.callee.type === 'Identifier' && node.callee.name.startsWith('inject')) {
124
+ found = true;
125
+
126
+ return;
127
+ }
128
+
129
+ if (node !== fn && isFunctionNode(node)) {
130
+ return;
131
+ }
132
+
133
+ for (const key of Object.keys(node)) {
134
+ if (key === 'parent') {
135
+ continue;
136
+ }
137
+
138
+ const value = node[key];
139
+
140
+ if (Array.isArray(value)) {
141
+ value.forEach(visit);
142
+ } else if (value && typeof value.type === 'string') {
143
+ visit(value);
144
+ }
145
+ }
146
+ };
147
+
148
+ visit(fn);
149
+
150
+ return found;
151
+ };
152
+
153
+ /**
154
+ * Whether the call needs an injector, decided by the **innermost** function boundary it sits behind - a
155
+ * callback defined in a constructor is not "in the constructor" by the time it runs.
156
+ * @param {any} node
157
+ * @returns {{ needsInjector: boolean; boundary: string }}
158
+ */
159
+ const classifyCallSite = (node) => {
160
+ let current = node;
161
+
162
+ while (current) {
163
+ if (isFunctionNode(current)) {
164
+ const parent = current.parent;
165
+
166
+ if (parent?.type === 'MethodDefinition') {
167
+ return parent.kind === 'constructor'
168
+ ? { needsInjector: false, boundary: 'constructor' }
169
+ : { needsInjector: true, boundary: parent.kind === 'method' ? 'method' : `${parent.kind} accessor` };
170
+ }
171
+
172
+ const host = getCallbackHost(current);
173
+
174
+ if (isContextProvidingHost(host)) {
175
+ return { needsInjector: false, boundary: 'injection context' };
176
+ }
177
+
178
+ if (!isTransparentHost(host)) {
179
+ if (!host && callsInject(current)) {
180
+ return { needsInjector: false, boundary: 'function that injects' };
181
+ }
182
+
183
+ return {
184
+ needsInjector: true,
185
+ boundary: host?.callee.type === 'Identifier' ? `${host.callee.name}() callback` : 'callback',
186
+ };
187
+ }
188
+ }
189
+
190
+ if (current.type === 'PropertyDefinition') {
191
+ return { needsInjector: false, boundary: 'field initializer' };
192
+ }
193
+
194
+ current = current.parent;
195
+ }
196
+
197
+ return { needsInjector: true, boundary: 'call site' };
198
+ };
199
+
200
+ /**
201
+ * @param {any} node
202
+ * @returns {any}
203
+ */
204
+ const findEnclosingClassBody = (node) => {
205
+ let current = node;
206
+
207
+ while (current) {
208
+ if (current.type === 'ClassBody') {
209
+ return current;
210
+ }
211
+
212
+ current = current.parent;
213
+ }
214
+
215
+ return null;
216
+ };
217
+
218
+ /**
219
+ * @param {any} node
220
+ * @returns {boolean}
221
+ */
222
+ const isInjectInjectorCall = (node) =>
223
+ !!node &&
224
+ node.type === 'CallExpression' &&
225
+ node.callee.type === 'Identifier' &&
226
+ node.callee.name === 'inject' &&
227
+ node.arguments[0]?.type === 'Identifier' &&
228
+ node.arguments[0].name === 'Injector';
229
+
230
+ /**
231
+ * @param {any} classBody
232
+ * @returns {string | null}
233
+ */
234
+ const findInjectorMemberName = (classBody) => {
235
+ for (const member of classBody.body) {
236
+ if (
237
+ member.type === 'PropertyDefinition' &&
238
+ isInjectInjectorCall(member.value) &&
239
+ member.key.type === 'Identifier'
240
+ ) {
241
+ return member.key.name;
242
+ }
243
+ }
244
+
245
+ return null;
246
+ };
247
+
248
+ /** @type {import('eslint').Rule.RuleModule} */
249
+ const noLegacyPrepareWithoutInjector = {
250
+ meta: {
251
+ type: 'problem',
252
+ docs: {
253
+ description:
254
+ 'Require an explicit injector on legacy query creator prepare() calls that run outside an injection context.',
255
+ recommended: true,
256
+ },
257
+ messages: {
258
+ missingInjector:
259
+ '{{creator}}.prepare() runs from a {{boundary}}, outside the injection context that created it, so it ' +
260
+ 'needs an explicit injector - without one it throws ET950 the first time that code path runs. Capture an ' +
261
+ 'injector where a context does exist and pass it: {{creator}}.prepare({ …, injector: this.injector }).',
262
+ },
263
+ schema: [
264
+ {
265
+ type: 'object',
266
+ properties: {
267
+ creatorPattern: { type: 'string' },
268
+ },
269
+ additionalProperties: false,
270
+ },
271
+ ],
272
+ fixable: 'code',
273
+ },
274
+ create(context) {
275
+ const sourceCode = context.sourceCode;
276
+ const creatorPattern = new RegExp(context.options[0]?.creatorPattern ?? '^legacy');
277
+ /** Names known to be legacy creators: imported under the naming convention, or declared locally. */
278
+ const creatorNames = new Set();
279
+
280
+ /**
281
+ * @param {any} classBody
282
+ * @param {string} injectorName
283
+ */
284
+ const buildInjectorMemberFix = (classBody, injectorName) => {
285
+ const firstMember = classBody.body[0];
286
+ const indentation = ' '.repeat(firstMember ? firstMember.loc.start.column : 2);
287
+ const declaration = `private ${injectorName} = inject(Injector);`;
288
+
289
+ return firstMember
290
+ ? /** @param {any} fixer */ (fixer) => fixer.insertTextBefore(firstMember, `${declaration}\n\n${indentation}`)
291
+ : /** @param {any} fixer */ (fixer) => fixer.replaceText(classBody, `{\n${indentation}${declaration}\n}`);
292
+ };
293
+
294
+ /**
295
+ * Adds the missing `inject` / `Injector` specifiers to an existing `@angular/core` import, or writes a
296
+ * new import. Returns `null` when the shape is one a fixer should not touch (a namespace import).
297
+ * @param {string[]} needed
298
+ */
299
+ const buildCoreImportFix = (needed) => {
300
+ const coreImport = /** @type {any} */ (
301
+ sourceCode.ast.body.find(
302
+ (node) =>
303
+ node.type === 'ImportDeclaration' &&
304
+ node.source.type === 'Literal' &&
305
+ node.source.value === '@angular/core',
306
+ )
307
+ );
308
+
309
+ if (!coreImport) {
310
+ const imports = sourceCode.ast.body.filter((node) => node.type === 'ImportDeclaration');
311
+ const lastImport = imports[imports.length - 1];
312
+ const text = `import { ${needed.join(', ')} } from '@angular/core';`;
313
+
314
+ return lastImport
315
+ ? /** @param {any} fixer */ (fixer) => fixer.insertTextAfter(lastImport, `\n${text}`)
316
+ : /** @param {any} fixer */ (fixer) => fixer.insertTextBefore(sourceCode.ast.body[0], `${text}\n\n`);
317
+ }
318
+
319
+ const specifiers = coreImport.specifiers ?? [];
320
+
321
+ if (specifiers.some(/** @param {any} specifier */ (specifier) => specifier.type !== 'ImportSpecifier')) {
322
+ return null;
323
+ }
324
+
325
+ const existing = new Set(specifiers.map(/** @param {any} specifier */ (specifier) => specifier.imported.name));
326
+ const missing = needed.filter((name) => !existing.has(name));
327
+ const lastSpecifier = specifiers[specifiers.length - 1];
328
+
329
+ if (missing.length === 0 || !lastSpecifier) {
330
+ return null;
331
+ }
332
+
333
+ return /** @param {any} fixer */ (fixer) => fixer.insertTextAfter(lastSpecifier, `, ${missing.join(', ')}`);
334
+ };
335
+
336
+ /**
337
+ * @param {any} callNode
338
+ * @param {string} injectorReference
339
+ */
340
+ const buildArgumentFix = (callNode, injectorReference) => {
341
+ const argument = callNode.arguments[0];
342
+ const injectorProperty = `injector: ${injectorReference}`;
343
+
344
+ if (!argument) {
345
+ const closingParen = sourceCode.getLastToken(callNode);
346
+
347
+ return closingParen
348
+ ? /** @param {any} fixer */ (fixer) => fixer.insertTextBefore(closingParen, `{ ${injectorProperty} }`)
349
+ : null;
350
+ }
351
+
352
+ if (argument.type === 'ObjectExpression') {
353
+ const lastProperty = argument.properties[argument.properties.length - 1];
354
+
355
+ if (!lastProperty) {
356
+ return /** @param {any} fixer */ (fixer) => fixer.replaceText(argument, `{ ${injectorProperty} }`);
357
+ }
358
+
359
+ const isMultiline = argument.loc.start.line !== argument.loc.end.line;
360
+ const tokenAfter = sourceCode.getTokenAfter(lastProperty);
361
+ const hasTrailingComma = tokenAfter?.type === 'Punctuator' && tokenAfter.value === ',';
362
+ const indentation = isMultiline ? `\n${' '.repeat(lastProperty.loc.start.column)}` : ' ';
363
+
364
+ return /** @param {any} fixer */ (fixer) =>
365
+ fixer.insertTextAfter(
366
+ hasTrailingComma ? tokenAfter : lastProperty,
367
+ `${hasTrailingComma ? '' : ','}${indentation}${injectorProperty}${isMultiline ? ',' : ''}`,
368
+ );
369
+ }
370
+
371
+ // `prepare(args)` - spreading is the only way to keep whatever the variable holds.
372
+ if (argument.type === 'Identifier' || argument.type === 'MemberExpression') {
373
+ return /** @param {any} fixer */ (fixer) =>
374
+ fixer.replaceText(argument, `{ ...${sourceCode.getText(argument)}, ${injectorProperty} }`);
375
+ }
376
+
377
+ return null;
378
+ };
379
+
380
+ return {
381
+ // Collected up front rather than in an `ImportDeclaration` visitor: a class can sit above the
382
+ // `export const legacyX = createLegacyQueryCreator(…)` it uses.
383
+ Program(program) {
384
+ /** @param {any} node */
385
+ const visit = (node) => {
386
+ if (!node || typeof node.type !== 'string') {
387
+ return;
388
+ }
389
+
390
+ if (node.type === 'ImportDeclaration') {
391
+ node.specifiers.forEach(
392
+ /** @param {any} specifier */ (specifier) => {
393
+ if (specifier.type === 'ImportSpecifier' && creatorPattern.test(specifier.local.name)) {
394
+ creatorNames.add(specifier.local.name);
395
+ }
396
+ },
397
+ );
398
+ }
399
+
400
+ if (
401
+ node.type === 'VariableDeclarator' &&
402
+ node.id.type === 'Identifier' &&
403
+ node.init?.type === 'CallExpression' &&
404
+ node.init.callee.type === 'Identifier' &&
405
+ node.init.callee.name === 'createLegacyQueryCreator'
406
+ ) {
407
+ creatorNames.add(node.id.name);
408
+ }
409
+
410
+ for (const key of Object.keys(node)) {
411
+ if (key === 'parent') {
412
+ continue;
413
+ }
414
+
415
+ const value = node[key];
416
+
417
+ if (Array.isArray(value)) {
418
+ value.forEach(visit);
419
+ } else if (value && typeof value.type === 'string') {
420
+ visit(value);
421
+ }
422
+ }
423
+ };
424
+
425
+ visit(program);
426
+ },
427
+ CallExpression(node) {
428
+ const callee = /** @type {any} */ (node.callee);
429
+
430
+ if (
431
+ callee.type !== 'MemberExpression' ||
432
+ callee.property.type !== 'Identifier' ||
433
+ callee.property.name !== 'prepare' ||
434
+ callee.object.type !== 'Identifier' ||
435
+ !creatorNames.has(callee.object.name)
436
+ ) {
437
+ return;
438
+ }
439
+
440
+ const firstArgument = /** @type {any} */ (node.arguments[0]);
441
+ const hasInjector =
442
+ firstArgument?.type === 'ObjectExpression' &&
443
+ firstArgument.properties.some(
444
+ /** @param {any} property */ (property) =>
445
+ property.type === 'Property' && property.key.type === 'Identifier' && property.key.name === 'injector',
446
+ );
447
+
448
+ if (hasInjector) {
449
+ return;
450
+ }
451
+
452
+ const { needsInjector, boundary } = classifyCallSite(node);
453
+
454
+ if (!needsInjector) {
455
+ return;
456
+ }
457
+
458
+ const classBody = findEnclosingClassBody(node);
459
+ const existingInjector = classBody ? findInjectorMemberName(classBody) : null;
460
+ const injectorName = existingInjector ?? 'injector';
461
+ const argumentFix = classBody ? buildArgumentFix(node, `this.${injectorName}`) : null;
462
+
463
+ context.report({
464
+ node,
465
+ messageId: 'missingInjector',
466
+ data: { creator: callee.object.name, boundary },
467
+ // Only the in-class shape is auto-fixed: a standalone function has no obvious injector to reach
468
+ // for, and inventing an `inject()` call there would move the failure rather than fix it.
469
+ fix:
470
+ classBody && argumentFix
471
+ ? (fixer) => {
472
+ const fixes = [argumentFix(fixer)];
473
+
474
+ if (!existingInjector) {
475
+ fixes.push(buildInjectorMemberFix(classBody, injectorName)(fixer));
476
+
477
+ const importFix = buildCoreImportFix(['inject', 'Injector']);
478
+
479
+ if (importFix) {
480
+ fixes.push(importFix(fixer));
481
+ }
482
+ }
483
+
484
+ return fixes;
485
+ }
486
+ : null,
487
+ });
488
+ },
489
+ };
490
+ },
491
+ };
492
+
493
+ module.exports = noLegacyPrepareWithoutInjector;