Fix circular self-imports and add no-self-package-imports lint rule
- Fixes the `Cannot access '_AppRootElementBlueprintesm' before initialization` crash in `@backstage/frontend-plugin-api` caused by a self-referential import in the packaged ESM. - Cleans up similar self-imports in `@backstage/catalog-model`, `@backstage/core-plugin-api`, `@backstage/plugin-catalog-node`, `@backstage/plugin-kubernetes-common`, and `@backstage/plugin-kubernetes-node`. Value imports switch to relative paths; type-only imports use `import type` so they're erased at runtime. - Adds a new `@backstage/no-self-package-imports` ESLint rule. It reads each package's `exports` map, traverses the relative import graph from every entry's source file, and only reports imports where the current file is in the same bundle as the target entry (same-entry). Files that aren't reachable from any entry (tests, scripts, orphans) are skipped. `import type`, `package.json` imports, and cross-entry self-imports are allowed by default; cross-entry can be opted into with `allowCrossEntry: false`. Signed-off-by: Marat Dyatko <maratd@spotify.com> Made-with: Cursor
This commit is contained in:
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@internal/self-import-pkg",
|
||||
"backstage": {
|
||||
"role": "node-library"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./alpha": "./src/alpha/index.ts",
|
||||
"./testUtils": "./src/testUtils.ts",
|
||||
"./package.json": "./package.json"
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* Copyright 2026 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.
|
||||
*/
|
||||
|
||||
export * from './refs';
|
||||
export * from '../shared';
|
||||
export * from '../next';
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2026 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.
|
||||
*/
|
||||
|
||||
export const refs = 3;
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright 2026 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.
|
||||
*/
|
||||
|
||||
export * from './util';
|
||||
export * from './shared';
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2026 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.
|
||||
*/
|
||||
|
||||
export const foo = 4;
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2026 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.
|
||||
*/
|
||||
|
||||
export * from './foo';
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2026 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.
|
||||
*/
|
||||
|
||||
export const orphan = 6;
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2026 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.
|
||||
*/
|
||||
|
||||
export const shared = 2;
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2026 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.
|
||||
*/
|
||||
|
||||
export * from './testUtils/helper';
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2026 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.
|
||||
*/
|
||||
|
||||
export const helper = 5;
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2026 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.
|
||||
*/
|
||||
|
||||
export const util = 1;
|
||||
@@ -0,0 +1,206 @@
|
||||
/*
|
||||
* Copyright 2026 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.
|
||||
*/
|
||||
|
||||
import { RuleTester } from 'eslint';
|
||||
import path from 'node:path';
|
||||
import rule from '../rules/no-self-package-imports';
|
||||
|
||||
const RULE = 'no-self-package-imports';
|
||||
const FIXTURE = path.resolve(__dirname, '__fixtures__/monorepo');
|
||||
const PKG_DIR = path.join(FIXTURE, 'packages/self-import-pkg');
|
||||
|
||||
const sameEntryErr = (entry = '.') => ({
|
||||
messageId: 'sameEntrySelfImport',
|
||||
data: { packageName: '@internal/self-import-pkg', entry },
|
||||
});
|
||||
|
||||
const crossEntryErr = (importPath: string) => ({
|
||||
messageId: 'crossEntrySelfImport',
|
||||
data: { packageName: '@internal/self-import-pkg', importPath },
|
||||
});
|
||||
|
||||
const origDir = process.cwd();
|
||||
afterAll(() => {
|
||||
process.chdir(origDir);
|
||||
});
|
||||
process.chdir(FIXTURE);
|
||||
|
||||
const ruleTester = new RuleTester({
|
||||
parser: require.resolve('@typescript-eslint/parser'),
|
||||
parserOptions: {
|
||||
sourceType: 'module',
|
||||
ecmaVersion: 2021,
|
||||
},
|
||||
});
|
||||
|
||||
ruleTester.run(RULE, rule, {
|
||||
valid: [
|
||||
// Relative imports are always fine.
|
||||
{
|
||||
code: `import { foo } from './local'`,
|
||||
filename: path.join(PKG_DIR, 'src/index.ts'),
|
||||
},
|
||||
// Imports of other packages are unaffected.
|
||||
{
|
||||
code: `import { foo } from '@internal/bar'`,
|
||||
filename: path.join(PKG_DIR, 'src/index.ts'),
|
||||
},
|
||||
{
|
||||
code: `import { foo } from 'react'`,
|
||||
filename: path.join(PKG_DIR, 'src/index.ts'),
|
||||
},
|
||||
// `src/alpha/refs.ts` is only in the `./alpha` bundle; importing from the
|
||||
// root entry `.` is a cross-entry reference, which is allowed by default.
|
||||
{
|
||||
code: `import { foo } from '@internal/self-import-pkg'`,
|
||||
filename: path.join(PKG_DIR, 'src/alpha/refs.ts'),
|
||||
},
|
||||
// `src/index.ts` is only in the `.` bundle; importing from `./alpha` is
|
||||
// cross-entry.
|
||||
{
|
||||
code: `import { foo } from '@internal/self-import-pkg/alpha'`,
|
||||
filename: path.join(PKG_DIR, 'src/index.ts'),
|
||||
},
|
||||
{
|
||||
code: `import { foo } from '@internal/self-import-pkg/testUtils'`,
|
||||
filename: path.join(PKG_DIR, 'src/index.ts'),
|
||||
},
|
||||
// `src/next/foo.ts` is physically under `src/` but only re-exported from
|
||||
// `./alpha` (via `src/alpha/index.ts` → `../next`). The rule follows the
|
||||
// actual barrel graph rather than the directory layout, so importing
|
||||
// from the root entry is correctly classified as cross-entry.
|
||||
{
|
||||
code: `import { foo } from '@internal/self-import-pkg'`,
|
||||
filename: path.join(PKG_DIR, 'src/next/foo.ts'),
|
||||
},
|
||||
// `package.json` imports are exempt since they don't go through the
|
||||
// module barrel.
|
||||
{
|
||||
code: `import pkg from '@internal/self-import-pkg/package.json'`,
|
||||
filename: path.join(PKG_DIR, 'src/index.ts'),
|
||||
},
|
||||
// Files that aren't reachable from any entry (tests, scripts, orphans)
|
||||
// can't cause circular-init errors in the published bundle, so they're
|
||||
// skipped entirely.
|
||||
{
|
||||
code: `import { foo } from '@internal/self-import-pkg'`,
|
||||
filename: path.join(PKG_DIR, 'src/index.test.ts'),
|
||||
},
|
||||
{
|
||||
code: `import { foo } from '@internal/self-import-pkg/alpha'`,
|
||||
filename: path.join(PKG_DIR, 'src/index.test.ts'),
|
||||
},
|
||||
{
|
||||
code: `import { foo } from '@internal/self-import-pkg'`,
|
||||
filename: path.join(PKG_DIR, 'src/orphan.ts'),
|
||||
},
|
||||
// Dynamic imports in a test file still count as orphan, since the test
|
||||
// file itself isn't part of any entry's bundle.
|
||||
{
|
||||
code: `const m = import('@internal/self-import-pkg')`,
|
||||
filename: path.join(PKG_DIR, 'src/alpha/refs.test.ts'),
|
||||
},
|
||||
// `import type` is erased at runtime and can't create circular
|
||||
// initialization issues, so it's always allowed.
|
||||
{
|
||||
code: `import type { Foo } from '@internal/self-import-pkg'`,
|
||||
filename: path.join(PKG_DIR, 'src/index.ts'),
|
||||
},
|
||||
{
|
||||
code: `import type { Foo } from '@internal/self-import-pkg/alpha'`,
|
||||
filename: path.join(PKG_DIR, 'src/alpha/refs.ts'),
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
// Same-entry self-imports are always errors because they create circular
|
||||
// module graphs inside a bundle.
|
||||
{
|
||||
code: `import { foo } from '@internal/self-import-pkg'`,
|
||||
filename: path.join(PKG_DIR, 'src/index.ts'),
|
||||
errors: [sameEntryErr('.')],
|
||||
},
|
||||
{
|
||||
code: `import { foo } from '@internal/self-import-pkg'`,
|
||||
filename: path.join(PKG_DIR, 'src/util.ts'),
|
||||
errors: [sameEntryErr('.')],
|
||||
},
|
||||
{
|
||||
code: `export { foo } from '@internal/self-import-pkg'`,
|
||||
filename: path.join(PKG_DIR, 'src/index.ts'),
|
||||
errors: [sameEntryErr('.')],
|
||||
},
|
||||
{
|
||||
code: `const x = require('@internal/self-import-pkg')`,
|
||||
filename: path.join(PKG_DIR, 'src/index.ts'),
|
||||
errors: [sameEntryErr('.')],
|
||||
},
|
||||
{
|
||||
code: `const m = import('@internal/self-import-pkg')`,
|
||||
filename: path.join(PKG_DIR, 'src/util.ts'),
|
||||
errors: [sameEntryErr('.')],
|
||||
},
|
||||
// Files in a non-root entry's bundle importing that same entry are
|
||||
// flagged.
|
||||
{
|
||||
code: `import { foo } from '@internal/self-import-pkg/alpha'`,
|
||||
filename: path.join(PKG_DIR, 'src/alpha/refs.ts'),
|
||||
errors: [sameEntryErr('./alpha')],
|
||||
},
|
||||
{
|
||||
code: `import { foo } from '@internal/self-import-pkg/testUtils'`,
|
||||
filename: path.join(PKG_DIR, 'src/testUtils.ts'),
|
||||
errors: [sameEntryErr('./testUtils')],
|
||||
},
|
||||
{
|
||||
code: `import { foo } from '@internal/self-import-pkg/testUtils'`,
|
||||
filename: path.join(PKG_DIR, 'src/testUtils/helper.ts'),
|
||||
errors: [sameEntryErr('./testUtils')],
|
||||
},
|
||||
// `src/next/foo.ts` is actually in the `./alpha` bundle via barrel
|
||||
// re-exports, so importing `./alpha` from it is same-entry — even though
|
||||
// the directory layout might suggest otherwise.
|
||||
{
|
||||
code: `import { foo } from '@internal/self-import-pkg/alpha'`,
|
||||
filename: path.join(PKG_DIR, 'src/next/foo.ts'),
|
||||
errors: [sameEntryErr('./alpha')],
|
||||
},
|
||||
// `src/shared.ts` is re-exported by both the root and alpha entries, so
|
||||
// either target is same-entry.
|
||||
{
|
||||
code: `import { foo } from '@internal/self-import-pkg'`,
|
||||
filename: path.join(PKG_DIR, 'src/shared.ts'),
|
||||
errors: [sameEntryErr('.')],
|
||||
},
|
||||
{
|
||||
code: `import { foo } from '@internal/self-import-pkg/alpha'`,
|
||||
filename: path.join(PKG_DIR, 'src/shared.ts'),
|
||||
errors: [sameEntryErr('./alpha')],
|
||||
},
|
||||
// With `allowCrossEntry: false`, cross-entry imports are also flagged.
|
||||
{
|
||||
code: `import { foo } from '@internal/self-import-pkg'`,
|
||||
filename: path.join(PKG_DIR, 'src/alpha/refs.ts'),
|
||||
options: [{ allowCrossEntry: false }],
|
||||
errors: [crossEntryErr('@internal/self-import-pkg')],
|
||||
},
|
||||
{
|
||||
code: `import { foo } from '@internal/self-import-pkg/alpha'`,
|
||||
filename: path.join(PKG_DIR, 'src/index.ts'),
|
||||
options: [{ allowCrossEntry: false }],
|
||||
errors: [crossEntryErr('@internal/self-import-pkg/alpha')],
|
||||
},
|
||||
],
|
||||
});
|
||||
Reference in New Issue
Block a user