diff --git a/.changeset/tough-jeans-camp.md b/.changeset/tough-jeans-camp.md new file mode 100644 index 0000000000..b7c08593e1 --- /dev/null +++ b/.changeset/tough-jeans-camp.md @@ -0,0 +1,5 @@ +--- +'@backstage/eslint-plugin': minor +--- + +Added a new ESLint plugin with common rules for Backstage projects. diff --git a/.github/vale/Vocab/Backstage/accept.txt b/.github/vale/Vocab/Backstage/accept.txt index f24b701d36..9e7cf7aa98 100644 --- a/.github/vale/Vocab/Backstage/accept.txt +++ b/.github/vale/Vocab/Backstage/accept.txt @@ -336,6 +336,7 @@ subfolders subheader subheaders subkey +subpath subroutes subtree superfences @@ -418,4 +419,4 @@ Dominik Henneke Kuang Frontside -Kaswell \ No newline at end of file +Kaswell diff --git a/packages/eslint-plugin/README.md b/packages/eslint-plugin/README.md new file mode 100644 index 0000000000..a063d801d3 --- /dev/null +++ b/packages/eslint-plugin/README.md @@ -0,0 +1,40 @@ +# @backstage/eslint-plugin + +A collection of ESLint rules useful to Backstage projects. + +## Usage + +This ESLint plugin is part of the default lint configuration provided by the [Backstage CLI](https://www.npmjs.com/package/@backstage/cli), so you generally do not need to install it manually. + +If you do wish to install this plugin manually, start by adding it as a development dependency to your project: + +```sh +yarn add --dev @backstage/eslint-plugin +``` + +Then add it to your ESLint configuration: + +```js +extends: [ + 'plugin:@backstage/recommended', +], +``` + +Alternatively, if you want to install in individual rules manually: + +```js +plugins: [ + '@backstage', +], +rules: { + '@backstage/no-forbidden-package-imports': 'error', +} +``` + +## Rules + +The following rules are provided by this plugin: + +| Rule | Description | +| ----------------------------------------- | ------------------------------------------------------------------------------- | +| `@backstage/no-forbidden-package-imports` | Disallow internal monorepo imports from package subpaths that are not exported. | diff --git a/packages/eslint-plugin/index.js b/packages/eslint-plugin/index.js new file mode 100644 index 0000000000..11d17b2b8f --- /dev/null +++ b/packages/eslint-plugin/index.js @@ -0,0 +1,29 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +module.exports = { + configs: { + recommended: { + plugins: ['@backstage'], + rules: { + '@backstage/no-forbidden-package-imports': 'error', + }, + }, + }, + rules: { + 'no-forbidden-package-imports': require('./rules/no-forbidden-package-imports'), + }, +}; diff --git a/packages/eslint-plugin/package.json b/packages/eslint-plugin/package.json new file mode 100644 index 0000000000..9724cec32b --- /dev/null +++ b/packages/eslint-plugin/package.json @@ -0,0 +1,16 @@ +{ + "name": "@backstage/eslint-plugin", + "description": "Backstage ESLint plugin", + "version": "0.0.0", + "publishConfig": { + "access": "public" + }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "packages/eslint-plugin" + }, + "license": "Apache-2.0", + "main": "./index.js" +} diff --git a/packages/eslint-plugin/rules/no-forbidden-package-imports.js b/packages/eslint-plugin/rules/no-forbidden-package-imports.js new file mode 100644 index 0000000000..d5cce80368 --- /dev/null +++ b/packages/eslint-plugin/rules/no-forbidden-package-imports.js @@ -0,0 +1,124 @@ +'use strict'; +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// @ts-check + +// Note: this file is ES5, because we want to avoid having to transpile it for local development + +var fs = require('fs'); +var path = require('path'); +/** @type {import('@manypkg/get-packages')} */ +var manypkg = require('@manypkg/get-packages'); + +// Loads all packages in the monorepo once, and caches the result +var getPackageMap = (function () { + var map = undefined; + var lastLoadAt = undefined; + + /** @returns {Record} */ + return function (/** @type {string} */ dir) { + if (map) { + // Only cache for 5 seconds, to avoid the need to reload ESLint servers + if (Date.now() - lastLoadAt > 5000) { + map = undefined; + } else { + return map; + } + } + var packages = manypkg.getPackagesSync(dir).packages; + if (!packages) { + return undefined; + } + map = {}; + lastLoadAt = Date.now(); + packages.forEach(function (pkg) { + map[pkg.packageJson.name] = pkg; + }); + return map; + }; +})(); + +module.exports = { + meta: { + type: 'problem', + messages: { + forbidden: '{{packageName}} does not export {{subPath}}', + }, + }, + create: function (/** @type import('eslint').Rule.RuleContext */ context) { + var packageMap = getPackageMap(context.getCwd()); + if (!packageMap) { + return; + } + + function checkImport(node) { + if (!node.source) { + return; + } + var importPath = node.source.value; + if (importPath[0] === '.') { + return; + } + + var pathParts = importPath.split('/'); + + // Check for match with plain name, then namespaced name + var name = pathParts.shift(); + if (!packageMap[name]) { + name += '/' + pathParts.shift(); + } + if (!packageMap[name]) { + return; + } + + // Empty subpaths are always allowed + var subPath = pathParts.join('/'); + if (!subPath) { + return; + } + + var pkg = packageMap[name]; + + // If the import is listed in the package.json exports field, we allow it + var exp = pkg.packageJson.exports; + if (exp && (exp[subPath] || exp['./' + subPath])) { + return; + } + + // If there's a package.json in the target directory, we allow the import + if (fs.existsSync(path.resolve(pkg.dir, subPath, 'package.json'))) { + return; + } + + context.report({ + node: node, + messageId: 'forbidden', + data: { + packageName: pkg.packageJson.name || '', + subPath: subPath, + }, + }); + } + + return { + ImportDeclaration: checkImport, + ExportAllDeclaration: checkImport, + ExportNamedDeclaration: checkImport, + ImportExpression: checkImport, + }; + }, +}; diff --git a/yarn.lock b/yarn.lock index 3d809a40c8..ac5c6d0541 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4095,6 +4095,12 @@ __metadata: languageName: unknown linkType: soft +"@backstage/eslint-plugin@workspace:packages/eslint-plugin": + version: 0.0.0-use.local + resolution: "@backstage/eslint-plugin@workspace:packages/eslint-plugin" + languageName: unknown + linkType: soft + "@backstage/integration-aws-node@workspace:^, @backstage/integration-aws-node@workspace:packages/integration-aws-node": version: 0.0.0-use.local resolution: "@backstage/integration-aws-node@workspace:packages/integration-aws-node"