@backstage/eslint-plugin 0.1.2 → 0.1.3

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,17 @@
1
1
  # @backstage/eslint-plugin
2
2
 
3
+ ## 0.1.3
4
+
5
+ ### Patch Changes
6
+
7
+ - 911c25de59c: Add support for auto-fixing missing imports detected by the `no-undeclared-imports` rule.
8
+
9
+ ## 0.1.3-next.0
10
+
11
+ ### Patch Changes
12
+
13
+ - 911c25de59c: Add support for auto-fixing missing imports detected by the `no-undeclared-imports` rule.
14
+
3
15
  ## 0.1.2
4
16
 
5
17
  ### Patch Changes
@@ -31,6 +31,7 @@ const manypkg = require('@manypkg/get-packages');
31
31
  * @property {ExtendedPackage} root
32
32
  * @property {ExtendedPackage[]} list
33
33
  * @property {Map<string, ExtendedPackage>} map
34
+ * @property {() => void} clearCache
34
35
  * @property {(path: string) => ExtendedPackage | undefined} byPath
35
36
  */
36
37
 
@@ -64,6 +65,9 @@ module.exports = (function () {
64
65
  pkg => !path.relative(pkg.dir, filePath).startsWith('..'),
65
66
  );
66
67
  },
68
+ clearCache() {
69
+ result = undefined;
70
+ },
67
71
  };
68
72
  lastLoadAt = Date.now();
69
73
  return result;
@@ -24,6 +24,16 @@ const getPackages = require('./getPackages');
24
24
  * @type {object}
25
25
  * @property {'local'} type
26
26
  * @property {'value' | 'type'} kind
27
+ * @property {import('estree').Node} node
28
+ * @property {string} path
29
+ */
30
+
31
+ /**
32
+ * @typedef ImportDirective
33
+ * @type {object}
34
+ * @property {'directive'} type
35
+ * @property {'value' | 'type'} kind
36
+ * @property {import('estree').Node} node
27
37
  * @property {string} path
28
38
  */
29
39
 
@@ -32,6 +42,7 @@ const getPackages = require('./getPackages');
32
42
  * @type {object}
33
43
  * @property {'internal'} type
34
44
  * @property {'value' | 'type'} kind
45
+ * @property {import('estree').Node} node
35
46
  * @property {string} path
36
47
  * @property {import('./getPackages').ExtendedPackage} package
37
48
  * @property {string} packageName
@@ -42,6 +53,7 @@ const getPackages = require('./getPackages');
42
53
  * @type {object}
43
54
  * @property {'external'} type
44
55
  * @property {'value' | 'type'} kind
56
+ * @property {import('estree').Node} node
45
57
  * @property {string} path
46
58
  * @property {string} packageName
47
59
  */
@@ -51,6 +63,7 @@ const getPackages = require('./getPackages');
51
63
  * @type {object}
52
64
  * @property {'builtin'} type
53
65
  * @property {'value' | 'type'} kind
66
+ * @property {import('estree').Literal} node
54
67
  * @property {string} path
55
68
  * @property {string} packageName
56
69
  */
@@ -58,7 +71,7 @@ const getPackages = require('./getPackages');
58
71
  /**
59
72
  * @callback ImportVisitor
60
73
  * @param {ConsideredNode} node
61
- * @param {LocalImport | InternalImport | ExternalImport | BuiltinImport} import
74
+ * @param {ImportDirective | LocalImport | InternalImport | ExternalImport | BuiltinImport} import
62
75
  */
63
76
 
64
77
  /**
@@ -68,7 +81,7 @@ const getPackages = require('./getPackages');
68
81
 
69
82
  /**
70
83
  * @param {ConsideredNode} node
71
- * @returns {undefined | {path: string, kind: 'type' | 'value'}}
84
+ * @returns {undefined | {path: string, node: import('estree').Literal, kind: 'type' | 'value'}}
72
85
  */
73
86
  function getImportInfo(node) {
74
87
  /** @type {import('estree').Expression | import('estree').SpreadElement | undefined | null} */
@@ -95,7 +108,11 @@ function getImportInfo(node) {
95
108
 
96
109
  /** @type {any} */
97
110
  const anyNode = node;
98
- return { path: pathNode.value, kind: anyNode.importKind ?? 'value' };
111
+ return {
112
+ path: pathNode.value,
113
+ node: pathNode,
114
+ kind: anyNode.importKind ?? 'value',
115
+ };
99
116
  }
100
117
 
101
118
  /**
@@ -122,6 +139,10 @@ module.exports = function visitImports(context, visitor) {
122
139
  return visitor(node, { type: 'local', ...info });
123
140
  }
124
141
 
142
+ if (info.path.startsWith('directive:')) {
143
+ return visitor(node, { type: 'directive', ...info });
144
+ }
145
+
125
146
  const pathParts = info.path.split('/');
126
147
 
127
148
  // Check for match with plain name, then namespaced name
@@ -143,6 +164,7 @@ module.exports = function visitImports(context, visitor) {
143
164
  return visitor(node, {
144
165
  type: 'builtin',
145
166
  kind: info.kind,
167
+ node: info.node,
146
168
  path: subPath,
147
169
  packageName,
148
170
  });
@@ -151,6 +173,7 @@ module.exports = function visitImports(context, visitor) {
151
173
  return visitor(node, {
152
174
  type: 'external',
153
175
  kind: info.kind,
176
+ node: info.node,
154
177
  path: subPath,
155
178
  packageName,
156
179
  });
@@ -159,6 +182,7 @@ module.exports = function visitImports(context, visitor) {
159
182
  return visitor(node, {
160
183
  type: 'internal',
161
184
  kind: info.kind,
185
+ node: info.node,
162
186
  path: subPath,
163
187
  package: pkg,
164
188
  packageName,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@backstage/eslint-plugin",
3
3
  "description": "Backstage ESLint plugin",
4
- "version": "0.1.2",
4
+ "version": "0.1.3",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -22,7 +22,7 @@
22
22
  "minimatch": "^5.1.2"
23
23
  },
24
24
  "devDependencies": {
25
- "@backstage/cli": "^0.22.4",
25
+ "@backstage/cli": "^0.22.6",
26
26
  "eslint": "^8.33.0"
27
27
  }
28
28
  }
@@ -20,6 +20,7 @@ const path = require('path');
20
20
  const getPackageMap = require('../lib/getPackages');
21
21
  const visitImports = require('../lib/visitImports');
22
22
  const minimatch = require('minimatch');
23
+ const { execFileSync } = require('child_process');
23
24
 
24
25
  const depFields = {
25
26
  dep: 'dependencies',
@@ -124,11 +125,13 @@ function getAddFlagForDepsField(depsField) {
124
125
  module.exports = {
125
126
  meta: {
126
127
  type: 'problem',
128
+ fixable: 'code',
127
129
  messages: {
128
130
  undeclared:
129
131
  "{{ packageName }} must be declared in {{ depsField }} of {{ packageJsonPath }}, run 'yarn --cwd {{ packagePath }} add{{ addFlag }} {{ packageName }}' from the project root.",
130
132
  switch:
131
133
  '{{ packageName }} is declared in {{ oldDepsField }}, but should be moved to {{ depsField }} in {{ packageJsonPath }}.',
134
+ switchBack: 'Switch back to import declaration',
132
135
  },
133
136
  docs: {
134
137
  description:
@@ -150,63 +153,150 @@ module.exports = {
150
153
  return {};
151
154
  }
152
155
 
153
- return visitImports(context, (node, imp) => {
154
- // We leave checking of type imports to the repo-tools check,
155
- // and we skip builtins and local imports
156
- if (
157
- imp.kind === 'type' ||
158
- imp.type === 'builtin' ||
159
- imp.type === 'local'
160
- ) {
161
- return;
162
- }
156
+ /** @type Array<{name: string, flag: string, node: import('estree').Node}> */
157
+ const importsToAdd = [];
163
158
 
164
- // We skip imports for the package itself
165
- if (imp.packageName === localPkg.packageJson.name) {
166
- return;
167
- }
159
+ return {
160
+ // All missing imports that we detect are collected as we traverse, and then we use
161
+ // the program exit to execute all install directives that have been found.
162
+ ['Program:exit']() {
163
+ /** @type Record<string, Set<string>> */
164
+ const byFlag = {};
165
+
166
+ for (const { name, flag } of importsToAdd) {
167
+ byFlag[flag] = byFlag[flag] ?? new Set();
168
+ byFlag[flag].add(name);
169
+ }
170
+
171
+ for (const name of byFlag[''] ?? []) {
172
+ byFlag['--dev']?.delete(name);
173
+ }
174
+ for (const name of byFlag['--peer'] ?? []) {
175
+ byFlag['']?.delete(name);
176
+ byFlag['--dev']?.delete(name);
177
+ }
168
178
 
169
- const modulePath = path.relative(localPkg.dir, filePath);
170
- const expectedType = getExpectedDepType(
171
- localPkg.packageJson,
172
- imp.packageName,
173
- modulePath,
174
- );
175
-
176
- const conflict = findConflict(
177
- localPkg.packageJson,
178
- imp.packageName,
179
- expectedType,
180
- );
181
-
182
- if (conflict) {
183
- try {
184
- const fullImport = imp.path
185
- ? `${imp.packageName}/${imp.path}`
186
- : imp.packageName;
187
- require.resolve(fullImport, {
188
- paths: [localPkg.dir],
179
+ for (const [flag, names] of Object.entries(byFlag)) {
180
+ // The security implication of this is a bit interesting, as crafted add-import
181
+ // directives could be used to install malicious packages. However, the same is true
182
+ // for adding malicious packages to package.json, so there's significant difference.
183
+ execFileSync('yarn', ['add', ...(flag ? [flag] : []), ...names], {
184
+ cwd: localPkg.dir,
185
+ stdio: 'inherit',
189
186
  });
190
- } catch {
191
- // If the dependency doesn't resolve then it's likely a type import, ignore
187
+ }
188
+
189
+ // This switches all import directives back to the original import.
190
+ for (const added of importsToAdd) {
191
+ context.report({
192
+ node: added.node,
193
+ messageId: 'switchBack',
194
+ fix(fixer) {
195
+ return fixer.replaceText(added.node, `'${added.name}'`);
196
+ },
197
+ });
198
+ }
199
+
200
+ importsToAdd.length = 0;
201
+ packages.clearCache();
202
+ },
203
+ ...visitImports(context, (node, imp) => {
204
+ // We leave checking of type imports to the repo-tools check,
205
+ // and we skip builtins and local imports
206
+ if (
207
+ imp.kind === 'type' ||
208
+ imp.type === 'builtin' ||
209
+ imp.type === 'local'
210
+ ) {
192
211
  return;
193
212
  }
194
213
 
195
- const packagePath = path.relative(packages.root.dir, localPkg.dir);
196
- const packageJsonPath = path.join(packagePath, 'package.json');
197
-
198
- context.report({
199
- node,
200
- messageId: conflict.oldDepsField ? 'switch' : 'undeclared',
201
- data: {
202
- ...conflict,
203
- packagePath,
204
- addFlag: getAddFlagForDepsField(conflict.depsField),
205
- packageName: imp.packageName,
206
- packageJsonPath: packageJsonPath,
207
- },
208
- });
209
- }
210
- });
214
+ // Any import directive that is found is collected for processing later
215
+ if (imp.type === 'directive') {
216
+ const parts = imp.path.split(':');
217
+ if (parts[1] !== 'add-import') {
218
+ return;
219
+ }
220
+ const [type, name] = parts.slice(2);
221
+ if (!name.match(/^(@[-\w\.~]+\/)?[-\w\.~]*$/i)) {
222
+ throw new Error(
223
+ `Invalid package name to add as dependency: '${name}'`,
224
+ );
225
+ }
226
+
227
+ importsToAdd.push({
228
+ flag: getAddFlagForDepsField(type).trim(),
229
+ name,
230
+ node: imp.node,
231
+ });
232
+ return;
233
+ }
234
+
235
+ // We skip imports for the package itself
236
+ if (imp.packageName === localPkg.packageJson.name) {
237
+ return;
238
+ }
239
+
240
+ const modulePath = path.relative(localPkg.dir, filePath);
241
+ const expectedType = getExpectedDepType(
242
+ localPkg.packageJson,
243
+ imp.packageName,
244
+ modulePath,
245
+ );
246
+
247
+ const conflict = findConflict(
248
+ localPkg.packageJson,
249
+ imp.packageName,
250
+ expectedType,
251
+ );
252
+
253
+ if (conflict) {
254
+ try {
255
+ const fullImport = imp.path
256
+ ? `${imp.packageName}/${imp.path}`
257
+ : imp.packageName;
258
+ require.resolve(fullImport, {
259
+ paths: [localPkg.dir],
260
+ });
261
+ } catch {
262
+ // If the dependency doesn't resolve then it's likely a type import, ignore
263
+ return;
264
+ }
265
+
266
+ const packagePath = path.relative(packages.root.dir, localPkg.dir);
267
+ const packageJsonPath = path.join(packagePath, 'package.json');
268
+
269
+ context.report({
270
+ node,
271
+ messageId: conflict.oldDepsField ? 'switch' : 'undeclared',
272
+ data: {
273
+ ...conflict,
274
+ packagePath,
275
+ addFlag: getAddFlagForDepsField(conflict.depsField),
276
+ packageName: imp.packageName,
277
+ packageJsonPath: packageJsonPath,
278
+ },
279
+ // This fix callback is always executed, regardless of whether linting is run with
280
+ // fixes enabled or not. There is no way to determine if fixes are being applied, so
281
+ // instead our fix will replace the import with a directive that will be picked up
282
+ // on the next run. When ESLint applies fixes all rules are re-run to make sure the fixes
283
+ // applied correctly, which means that these directives will be picked up, executed,
284
+ // and switched back to the original import immediately.
285
+ // This is not true for all editor integrations. For example, VSCode translates there fixes
286
+ // to native editor commands, and does not re-run ESLint. This means that the import directive
287
+ // will end up in source code, and the import directive fix needs to be applied manually too.
288
+ // There is to my knowledge no way around this that doesn't get very hacky, so it will do for now.
289
+ fix: conflict.oldDepsField
290
+ ? undefined
291
+ : fixer => {
292
+ return fixer.replaceText(
293
+ imp.node,
294
+ `'directive:add-import:${conflict.depsField}:${imp.packageName}'`,
295
+ );
296
+ },
297
+ });
298
+ }
299
+ }),
300
+ };
211
301
  },
212
302
  };
@@ -18,6 +18,10 @@ import { RuleTester } from 'eslint';
18
18
  import { join as joinPath } from 'path';
19
19
  import rule from '../rules/no-undeclared-imports';
20
20
 
21
+ jest.mock('child_process', () => ({
22
+ execFileSync: jest.fn(),
23
+ }));
24
+
21
25
  const RULE = 'no-undeclared-imports';
22
26
  const FIXTURE = joinPath(__dirname, '__fixtures__/monorepo');
23
27
 
@@ -45,6 +49,9 @@ const ERR_SWITCHED = (
45
49
  'package.json',
46
50
  )}.`,
47
51
  });
52
+ const ERR_SWITCH_BACK = () => ({
53
+ message: 'Switch back to import declaration',
54
+ });
48
55
 
49
56
  process.chdir(FIXTURE);
50
57
 
@@ -130,6 +137,7 @@ ruleTester.run(RULE, rule, {
130
137
  },
131
138
  {
132
139
  code: `import 'lodash'`,
140
+ output: `import 'directive:add-import:dependencies:lodash'`,
133
141
  filename: joinPath(FIXTURE, 'packages/bar/src/index.ts'),
134
142
  errors: [
135
143
  ERR_UNDECLARED('lodash', 'dependencies', joinPath('packages', 'bar')),
@@ -137,6 +145,7 @@ ruleTester.run(RULE, rule, {
137
145
  },
138
146
  {
139
147
  code: `import { debounce } from 'lodash'`,
148
+ output: `import { debounce } from 'directive:add-import:dependencies:lodash'`,
140
149
  filename: joinPath(FIXTURE, 'packages/bar/src/index.ts'),
141
150
  errors: [
142
151
  ERR_UNDECLARED('lodash', 'dependencies', joinPath('packages', 'bar')),
@@ -144,6 +153,7 @@ ruleTester.run(RULE, rule, {
144
153
  },
145
154
  {
146
155
  code: `import * as _ from 'lodash'`,
156
+ output: `import * as _ from 'directive:add-import:dependencies:lodash'`,
147
157
  filename: joinPath(FIXTURE, 'packages/bar/src/index.ts'),
148
158
  errors: [
149
159
  ERR_UNDECLARED('lodash', 'dependencies', joinPath('packages', 'bar')),
@@ -151,6 +161,7 @@ ruleTester.run(RULE, rule, {
151
161
  },
152
162
  {
153
163
  code: `import _ from 'lodash'`,
164
+ output: `import _ from 'directive:add-import:dependencies:lodash'`,
154
165
  filename: joinPath(FIXTURE, 'packages/bar/src/index.ts'),
155
166
  errors: [
156
167
  ERR_UNDECLARED('lodash', 'dependencies', joinPath('packages', 'bar')),
@@ -158,6 +169,7 @@ ruleTester.run(RULE, rule, {
158
169
  },
159
170
  {
160
171
  code: `import('lodash')`,
172
+ output: `import('directive:add-import:dependencies:lodash')`,
161
173
  filename: joinPath(FIXTURE, 'packages/bar/src/index.ts'),
162
174
  errors: [
163
175
  ERR_UNDECLARED('lodash', 'dependencies', joinPath('packages', 'bar')),
@@ -165,6 +177,7 @@ ruleTester.run(RULE, rule, {
165
177
  },
166
178
  {
167
179
  code: `require('lodash')`,
180
+ output: `require('directive:add-import:dependencies:lodash')`,
168
181
  filename: joinPath(FIXTURE, 'packages/bar/src/index.ts'),
169
182
  errors: [
170
183
  ERR_UNDECLARED('lodash', 'dependencies', joinPath('packages', 'bar')),
@@ -172,13 +185,7 @@ ruleTester.run(RULE, rule, {
172
185
  },
173
186
  {
174
187
  code: `import 'lodash'`,
175
- filename: joinPath(FIXTURE, 'packages/bar/src/index.ts'),
176
- errors: [
177
- ERR_UNDECLARED('lodash', 'dependencies', joinPath('packages', 'bar')),
178
- ],
179
- },
180
- {
181
- code: `import 'lodash'`,
188
+ output: `import 'directive:add-import:devDependencies:lodash'`,
182
189
  filename: joinPath(FIXTURE, 'packages/bar/src/index.test.ts'),
183
190
  errors: [
184
191
  ERR_UNDECLARED(
@@ -191,6 +198,7 @@ ruleTester.run(RULE, rule, {
191
198
  },
192
199
  {
193
200
  code: `import 'react'`,
201
+ output: `import 'directive:add-import:peerDependencies:react'`,
194
202
  filename: joinPath(FIXTURE, 'packages/bar/src/index.ts'),
195
203
  errors: [
196
204
  ERR_UNDECLARED(
@@ -203,6 +211,7 @@ ruleTester.run(RULE, rule, {
203
211
  },
204
212
  {
205
213
  code: `import 'react'`,
214
+ output: `import 'directive:add-import:peerDependencies:react'`,
206
215
  filename: joinPath(FIXTURE, 'packages/bar/src/index.test.ts'),
207
216
  errors: [
208
217
  ERR_UNDECLARED(
@@ -215,6 +224,7 @@ ruleTester.run(RULE, rule, {
215
224
  },
216
225
  {
217
226
  code: `import 'react-dom'`,
227
+ output: `import 'directive:add-import:dependencies:react-dom'`,
218
228
  filename: joinPath(FIXTURE, 'packages/foo/src/index.ts'),
219
229
  errors: [
220
230
  ERR_UNDECLARED(
@@ -226,6 +236,7 @@ ruleTester.run(RULE, rule, {
226
236
  },
227
237
  {
228
238
  code: `import 'react-dom'`,
239
+ output: `import 'directive:add-import:devDependencies:react-dom'`,
229
240
  filename: joinPath(FIXTURE, 'packages/foo/src/index.test.ts'),
230
241
  errors: [
231
242
  ERR_UNDECLARED(
@@ -238,6 +249,7 @@ ruleTester.run(RULE, rule, {
238
249
  },
239
250
  {
240
251
  code: `import '@internal/foo'`,
252
+ output: `import 'directive:add-import:dependencies:@internal/foo'`,
241
253
  filename: joinPath(FIXTURE, 'packages/bar/src/index.ts'),
242
254
  errors: [
243
255
  ERR_UNDECLARED(
@@ -247,5 +259,43 @@ ruleTester.run(RULE, rule, {
247
259
  ),
248
260
  ],
249
261
  },
262
+
263
+ // Switching back to original import declarations
264
+ {
265
+ code: `import 'directive:add-import:dependencies:lodash'`,
266
+ output: `import 'lodash'`,
267
+ filename: joinPath(FIXTURE, 'packages/bar/src/index.ts'),
268
+ errors: [ERR_SWITCH_BACK()],
269
+ },
270
+ {
271
+ code: `import { debounce } from 'directive:add-import:dependencies:lodash'`,
272
+ output: `import { debounce } from 'lodash'`,
273
+ filename: joinPath(FIXTURE, 'packages/bar/src/index.ts'),
274
+ errors: [ERR_SWITCH_BACK()],
275
+ },
276
+ {
277
+ code: `import * as _ from 'directive:add-import:dependencies:lodash'`,
278
+ output: `import * as _ from 'lodash'`,
279
+ filename: joinPath(FIXTURE, 'packages/bar/src/index.ts'),
280
+ errors: [ERR_SWITCH_BACK()],
281
+ },
282
+ {
283
+ code: `import _ from 'directive:add-import:dependencies:lodash'`,
284
+ output: `import _ from 'lodash'`,
285
+ filename: joinPath(FIXTURE, 'packages/bar/src/index.ts'),
286
+ errors: [ERR_SWITCH_BACK()],
287
+ },
288
+ {
289
+ code: `import('directive:add-import:dependencies:lodash')`,
290
+ output: `import('lodash')`,
291
+ filename: joinPath(FIXTURE, 'packages/bar/src/index.ts'),
292
+ errors: [ERR_SWITCH_BACK()],
293
+ },
294
+ {
295
+ code: `require('directive:add-import:dependencies:lodash')`,
296
+ output: `require('lodash')`,
297
+ filename: joinPath(FIXTURE, 'packages/bar/src/index.ts'),
298
+ errors: [ERR_SWITCH_BACK()],
299
+ },
250
300
  ],
251
301
  });