@backstage/eslint-plugin 0.1.10 → 0.1.11

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,28 @@
1
1
  # @backstage/eslint-plugin
2
2
 
3
+ ## 0.1.11
4
+
5
+ ### Patch Changes
6
+
7
+ - 098ef95: Fix custom rules package scanning performance.
8
+ - 063b2d3: Added new eslint rule to restrict mixed plugin imports.
9
+
10
+ New rule `@backstage/no-mixed-plugin-imports` disallows mixed imports between plugins that are mixing
11
+ the backstage architecture. This rule forces that:
12
+
13
+ - No imports from frontend plugins to backend plugins or other frontend plugins.
14
+ - No imports from backend plugins to frontend plugins or other backend plugins.
15
+ - No imports from common plugins to frontend or backend plugins.
16
+
17
+ The current recommended configuration is giving a warning for mixed imports. This is to be changed in
18
+ the future to an error so please adjust your workspace accordingly.
19
+
20
+ ## 0.1.11-next.0
21
+
22
+ ### Patch Changes
23
+
24
+ - 098ef95: Fix custom rules package scanning performance.
25
+
3
26
  ## 0.1.10
4
27
 
5
28
  ### Patch Changes
package/README.md CHANGED
@@ -41,3 +41,4 @@ The following rules are provided by this plugin:
41
41
  | [@backstage/no-relative-monorepo-imports](./docs/rules/no-relative-monorepo-imports.md) | Forbid relative imports that reach outside of the package in a monorepo. |
42
42
  | [@backstage/no-undeclared-imports](./docs/rules/no-undeclared-imports.md) | Forbid imports of external packages that have not been declared in the appropriate dependencies field in `package.json`. |
43
43
  | [@backstage/no-top-level-material-ui-4-imports](./docs/rules/no-top-level-material-ui-4-imports.md) | Forbid top level import from Material UI v4 packages. |
44
+ | [@backstage/no-mixed-plugin-imports](./docs/rules/no-mixed-plugin-imports.md) | Disallow mixed plugin imports. |
@@ -0,0 +1,67 @@
1
+ # @backstage/no-mixed-plugin-imports
2
+
3
+ Disallow mixed imports between backstage plugins.
4
+
5
+ ## Usage
6
+
7
+ Add the rules as follows, it has no options:
8
+
9
+ ```js
10
+ "@backstage/no-mixed-plugin-imports": ["error"]
11
+ ```
12
+
13
+ ## Rule Details
14
+
15
+ Given the following two target packages:
16
+
17
+ ```json
18
+ {
19
+ "name": "@backstage/plugin-foo",
20
+ "backstage": {
21
+ "role": "frontend-plugin"
22
+ }
23
+ }
24
+ ```
25
+
26
+ ```json
27
+ {
28
+ "name": "@backstage/plugin-bar",
29
+ "backstage": {
30
+ "role": "frontend-plugin"
31
+ }
32
+ }
33
+ ```
34
+
35
+ ### Fail
36
+
37
+ ```ts
38
+ import { FooCard } from '@backstage/plugin-foo';
39
+ ```
40
+
41
+ ### Pass
42
+
43
+ ```ts
44
+ import { FooCard } from '@backstage/plugin-foo-react';
45
+ ```
46
+
47
+ ## Options
48
+
49
+ You can ignore specific target packages or files by adding them to the options in the `.eslintrc.js` file:
50
+
51
+ ```js
52
+ {
53
+ rules: {
54
+ '@backstage/no-mixed-plugin-imports': [
55
+ 'error',
56
+ {
57
+ excludedTargetPackages: [
58
+ '@backstage/plugin-foo',
59
+ ],
60
+ excludedFiles: [
61
+ '**/*.{test,spec}.[jt]s?(x)'
62
+ ],
63
+ }
64
+ ]
65
+ }
66
+ }
67
+ ```
package/index.js CHANGED
@@ -22,6 +22,7 @@ module.exports = {
22
22
  '@backstage/no-forbidden-package-imports': 'error',
23
23
  '@backstage/no-relative-monorepo-imports': 'error',
24
24
  '@backstage/no-undeclared-imports': 'error',
25
+ '@backstage/no-mixed-plugin-imports': 'warn',
25
26
  },
26
27
  },
27
28
  },
@@ -30,5 +31,6 @@ module.exports = {
30
31
  'no-relative-monorepo-imports': require('./rules/no-relative-monorepo-imports'),
31
32
  'no-undeclared-imports': require('./rules/no-undeclared-imports'),
32
33
  'no-top-level-material-ui-4-imports': require('./rules/no-top-level-material-ui-4-imports'),
34
+ 'no-mixed-plugin-imports': require('./rules/no-mixed-plugin-imports'),
33
35
  },
34
36
  };
package/knip-report.md CHANGED
@@ -1,9 +1,2 @@
1
1
  # Knip report
2
2
 
3
- ## Unlisted dependencies (2)
4
-
5
- | Name | Location | Severity |
6
- | :----- | :----------------------------- | :------- |
7
- | estree | rules/no-undeclared-imports.js | error |
8
- | estree | lib/visitImports.js | error |
9
-
@@ -21,7 +21,7 @@ const manypkg = require('@manypkg/get-packages');
21
21
 
22
22
  /**
23
23
  * @typedef ExtendedPackage
24
- * @type {import('@manypkg/get-packages').Package & { packageJson: { exports?: Record<string, string>, files?: Array<string>, backstage?: { inline?: boolean } }}} packageJson
24
+ * @type {import('@manypkg/get-packages').Package & { packageJson: { exports?: Record<string, string>, files?: Array<string>, backstage?: { inline?: boolean, role?: string } }}} packageJson
25
25
  */
26
26
 
27
27
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@backstage/eslint-plugin",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
4
4
  "description": "Backstage ESLint plugin",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -22,7 +22,7 @@
22
22
  "minimatch": "^9.0.0"
23
23
  },
24
24
  "devDependencies": {
25
- "@backstage/cli": "^0.28.0",
25
+ "@backstage/cli": "^0.33.0",
26
26
  "@types/estree": "^1.0.5",
27
27
  "eslint": "^8.33.0"
28
28
  }
@@ -0,0 +1,249 @@
1
+ /*
2
+ * Copyright 2023 The Backstage Authors
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ // @ts-check
18
+
19
+ const visitImports = require('../lib/visitImports');
20
+ const getPackages = require('../lib/getPackages');
21
+ const minimatch = require('minimatch');
22
+
23
+ /** @typedef {import('../lib/getPackages.js').ExtendedPackage} ExtendedPackage */
24
+
25
+ /**
26
+ * @param {string} pattern
27
+ * @param {string} filePath
28
+ * @returns {boolean}
29
+ */
30
+ const matchesPattern = (pattern, filePath) => {
31
+ return new minimatch.Minimatch(pattern).match(filePath);
32
+ };
33
+
34
+ const roleRules = [
35
+ {
36
+ sourceRole: ['frontend-plugin', 'web-library'],
37
+ targetRole: [
38
+ 'backend-plugin',
39
+ 'node-library',
40
+ 'backend-plugin-module',
41
+ 'frontend-plugin',
42
+ ],
43
+ },
44
+ {
45
+ sourceRole: ['backend-plugin', 'node-library', 'backend-plugin-module'],
46
+ targetRole: ['frontend-plugin', 'web-library', 'backend-plugin'],
47
+ },
48
+ {
49
+ sourceRole: ['common-library'],
50
+ targetRole: [
51
+ 'frontend-plugin',
52
+ 'web-library',
53
+ 'backend-plugin',
54
+ 'node-library',
55
+ 'backend-plugin-module',
56
+ ],
57
+ },
58
+ ];
59
+
60
+ /** @type {import('eslint').Rule.RuleModule} */
61
+ module.exports = {
62
+ meta: {
63
+ type: 'problem',
64
+ messages: {
65
+ forbidden:
66
+ '{{sourcePackage}} ({{sourceRole}}) uses forbidden import from {{targetPackage}} ({{targetRole}}).',
67
+ useReactPlugin:
68
+ 'Use web library {{targetPackage}}-react or common library instead.',
69
+ useNodePlugin:
70
+ 'Use node library {{targetPackage}}-node or common library instead.',
71
+ useCommonPlugin: 'Use common library {{targetPackage}}-common instead.',
72
+ removeImport:
73
+ 'Remove this import to avoid mixed plugin imports. Fix the code by refactoring it to use the correct plugin type.',
74
+ },
75
+ docs: {
76
+ description: 'Disallow mixed plugin imports.',
77
+ url: 'https://github.com/backstage/backstage/blob/master/packages/eslint-plugin/docs/rules/no-mixed-plugin-imports.md',
78
+ },
79
+ hasSuggestions: true,
80
+ schema: [
81
+ {
82
+ type: 'object',
83
+ properties: {
84
+ excludedTargetPackages: {
85
+ type: 'array',
86
+ items: { type: 'string' },
87
+ uniqueItems: true,
88
+ },
89
+ excludedFiles: {
90
+ type: 'array',
91
+ items: { type: 'string' },
92
+ uniqueItems: true,
93
+ },
94
+ includedFiles: {
95
+ type: 'array',
96
+ items: { type: 'string' },
97
+ uniqueItems: true,
98
+ },
99
+ },
100
+ additionalProperties: false,
101
+ },
102
+ ],
103
+ },
104
+ create(context) {
105
+ const packages = getPackages(context.cwd);
106
+ if (!packages) {
107
+ return {};
108
+ }
109
+
110
+ const filePath = context.physicalFilename
111
+ ? context.physicalFilename
112
+ : context.filename;
113
+
114
+ /** @type {ExtendedPackage | undefined} */
115
+ const pkg = packages.byPath(filePath);
116
+ if (!pkg) {
117
+ return {};
118
+ }
119
+
120
+ const options = context.options[0] || {};
121
+ /** @type {string[]} */
122
+ const ignoreTargetPackages = options.excludedTargetPackages || [];
123
+ /** @type {string[]} */
124
+ const excludePatterns = options.excludedFiles || [];
125
+ /** @type {string[]} */
126
+ const includePatterns = options.includedFiles || ['**/src/**'];
127
+
128
+ if (
129
+ !includePatterns.some(pattern => matchesPattern(pattern, filePath)) ||
130
+ excludePatterns.some(pattern => matchesPattern(pattern, filePath))
131
+ ) {
132
+ return {};
133
+ }
134
+
135
+ return visitImports(context, (node, imp) => {
136
+ if (imp.type !== 'internal') {
137
+ return;
138
+ }
139
+
140
+ /** @type {ExtendedPackage | undefined} */
141
+ const targetPackage = imp.package;
142
+ const targetName = targetPackage?.packageJson.name;
143
+ const sourceName = pkg.packageJson.name;
144
+ if (sourceName === targetName) {
145
+ return;
146
+ }
147
+
148
+ const sourceRole = pkg.packageJson.backstage?.role;
149
+ const targetRole = targetPackage.packageJson.backstage?.role;
150
+ if (!sourceRole || !targetRole) {
151
+ return;
152
+ }
153
+
154
+ if (
155
+ roleRules.some(
156
+ rule =>
157
+ rule.sourceRole.includes(sourceRole) &&
158
+ rule.targetRole.includes(targetRole) &&
159
+ !ignoreTargetPackages.includes(targetName),
160
+ )
161
+ ) {
162
+ const suggest = [];
163
+
164
+ if (
165
+ (sourceRole === 'frontend-plugin' || sourceRole === 'web-library') &&
166
+ targetRole === 'frontend-plugin'
167
+ ) {
168
+ suggest.push({
169
+ messageId: 'useReactPlugin',
170
+ data: {
171
+ targetPackage: targetName,
172
+ },
173
+ /** @param {import('eslint').Rule.RuleFixer} fixer */
174
+ fix(fixer) {
175
+ const source = context.sourceCode;
176
+ const nodeSource = source.getText(imp.node);
177
+ const newImport = nodeSource.replace(/'$/, "-react'");
178
+ return fixer.replaceText(imp.node, newImport);
179
+ },
180
+ });
181
+ suggest.push({
182
+ messageId: 'useCommonPlugin',
183
+ data: {
184
+ targetPackage: targetName,
185
+ },
186
+ /** @param {import('eslint').Rule.RuleFixer} fixer */
187
+ fix(fixer) {
188
+ const source = context.sourceCode;
189
+ const nodeSource = source.getText(imp.node);
190
+ const newImport = nodeSource.replace(/'$/, "-common'");
191
+ return fixer.replaceText(imp.node, newImport);
192
+ },
193
+ });
194
+ } else if (
195
+ (sourceRole === 'backend-plugin' ||
196
+ sourceRole === 'backend-plugin-module') &&
197
+ targetRole === 'backend-plugin'
198
+ ) {
199
+ suggest.push({
200
+ messageId: 'useNodePlugin',
201
+ data: {
202
+ targetPackage: targetName,
203
+ },
204
+ /** @param {import('eslint').Rule.RuleFixer} fixer */
205
+ fix(fixer) {
206
+ const source = context.sourceCode;
207
+ const nodeSource = source.getText(imp.node);
208
+ const newImport = nodeSource.replace(/-backend'$/, "-node'");
209
+ return fixer.replaceText(imp.node, newImport);
210
+ },
211
+ });
212
+ suggest.push({
213
+ messageId: 'useCommonPlugin',
214
+ data: {
215
+ targetPackage: targetName,
216
+ },
217
+ /** @param {import('eslint').Rule.RuleFixer} fixer */
218
+ fix(fixer) {
219
+ const source = context.sourceCode;
220
+ const nodeSource = source.getText(imp.node);
221
+ const newImport = nodeSource.replace(/-backend'$/, '-common');
222
+ return fixer.replaceText(imp.node, newImport);
223
+ },
224
+ });
225
+ } else {
226
+ suggest.push({
227
+ messageId: 'removeImport',
228
+ /** @param {import('eslint').Rule.RuleFixer} _fixer */
229
+ fix(_fixer) {
230
+ // Not a fixable case, just give a suggestion to remove the import
231
+ },
232
+ });
233
+ }
234
+
235
+ context.report({
236
+ node: node,
237
+ messageId: 'forbidden',
238
+ data: {
239
+ sourcePackage: pkg.packageJson.name || imp.package.dir,
240
+ sourceRole,
241
+ targetPackage: targetPackage.packageJson.name || imp.package.dir,
242
+ targetRole,
243
+ },
244
+ suggest,
245
+ });
246
+ }
247
+ });
248
+ },
249
+ };
@@ -319,6 +319,7 @@ module.exports = {
319
319
  if (importsToAdd.length > 0) {
320
320
  addMissingImports(importsToAdd, packages, localPkg);
321
321
 
322
+ packages.clearCache();
322
323
  // This switches all import directives back to the original import.
323
324
  for (const added of importsToAdd) {
324
325
  context.report({
@@ -336,6 +337,7 @@ module.exports = {
336
337
  removeInlineImports(importsToInline, localPkg);
337
338
  addForwardedInlineImports(importsToInline, localPkg);
338
339
 
340
+ packages.clearCache();
339
341
  for (const inlined of importsToInline) {
340
342
  context.report({
341
343
  node: inlined.node,
@@ -350,8 +352,6 @@ module.exports = {
350
352
  }
351
353
  importsToInline.length = 0;
352
354
  }
353
-
354
- packages.clearCache();
355
355
  },
356
356
  ...visitImports(context, (node, imp) => {
357
357
  // We leave checking of type imports to the repo-tools check,
@@ -1,5 +1,12 @@
1
1
  {
2
2
  "name": "@internal/foo",
3
+ "backstage": {
4
+ "role": "frontend-plugin"
5
+ },
6
+ "files": [
7
+ "dist",
8
+ "type-utils"
9
+ ],
3
10
  "dependencies": {
4
11
  "@internal/bar": "1.0.0"
5
12
  },
@@ -8,9 +15,5 @@
8
15
  },
9
16
  "peerDependencies": {
10
17
  "react": "*"
11
- },
12
- "files": [
13
- "dist",
14
- "type-utils"
15
- ]
18
+ }
16
19
  }
@@ -0,0 +1,68 @@
1
+ /*
2
+ * Copyright 2023 The Backstage Authors
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ import { RuleTester } from 'eslint';
18
+ import path from 'path';
19
+ import rule from '../rules/no-mixed-plugin-imports';
20
+
21
+ const RULE = 'no-mixed-plugin-imports';
22
+ const FIXTURE = path.resolve(__dirname, '__fixtures__/monorepo');
23
+
24
+ const ERR = (
25
+ sourcePackage: string,
26
+ sourceRole: string,
27
+ targetPackage: string,
28
+ targetRole: string,
29
+ ) => ({
30
+ message: `${sourcePackage} (${sourceRole}) uses forbidden import from ${targetPackage} (${targetRole}).`,
31
+ });
32
+
33
+ // cwd must be restored
34
+ const origDir = process.cwd();
35
+ afterAll(() => {
36
+ process.chdir(origDir);
37
+ });
38
+ process.chdir(FIXTURE);
39
+
40
+ const ruleTester = new RuleTester({
41
+ parserOptions: {
42
+ sourceType: 'module',
43
+ ecmaVersion: 2021,
44
+ },
45
+ });
46
+
47
+ ruleTester.run(RULE, rule, {
48
+ valid: [
49
+ {
50
+ code: `import '@internal/inline'`,
51
+ filename: path.join(FIXTURE, 'packages/bar/src/index.ts'),
52
+ },
53
+ ],
54
+ invalid: [
55
+ {
56
+ code: `import '@internal/foo'`,
57
+ filename: path.join(FIXTURE, 'packages/bar/src/index.ts'),
58
+ errors: [
59
+ ERR(
60
+ '@internal/bar',
61
+ 'frontend-plugin',
62
+ '@internal/foo',
63
+ 'frontend-plugin',
64
+ ),
65
+ ],
66
+ },
67
+ ],
68
+ });
@@ -24,6 +24,7 @@ jest.mock('child_process', () => ({
24
24
 
25
25
  const RULE = 'no-undeclared-imports';
26
26
  const FIXTURE = joinPath(__dirname, '__fixtures__/monorepo');
27
+ //
27
28
 
28
29
  const ERR_UNDECLARED = (
29
30
  name: string,
@@ -246,26 +247,14 @@ ruleTester.run(RULE, rule, {
246
247
  },
247
248
  {
248
249
  code: `import 'react-dom'`,
249
- output: `import 'directive:add-import:dependencies:react-dom'`,
250
+ output: `import 'directive:add-import:peerDependencies:react-dom'`,
250
251
  filename: joinPath(FIXTURE, 'packages/foo/src/index.ts'),
251
252
  errors: [
252
253
  ERR_UNDECLARED(
253
254
  'react-dom',
254
- 'dependencies',
255
- joinPath('packages', 'foo'),
256
- ),
257
- ],
258
- },
259
- {
260
- code: `import 'react-dom'`,
261
- output: `import 'directive:add-import:devDependencies:react-dom'`,
262
- filename: joinPath(FIXTURE, 'packages/foo/src/index.test.ts'),
263
- errors: [
264
- ERR_UNDECLARED(
265
- 'react-dom',
266
- 'devDependencies',
255
+ 'peerDependencies',
267
256
  joinPath('packages', 'foo'),
268
- '--dev',
257
+ '--peer',
269
258
  ),
270
259
  ],
271
260
  },