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:
Marat Dyatko
2026-04-23 14:41:33 +02:00
parent 427d5219a6
commit ab1cdbb9db
29 changed files with 810 additions and 25 deletions
@@ -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"
}
}
@@ -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';
@@ -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;
@@ -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';
@@ -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;
@@ -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';
@@ -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;
@@ -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;
@@ -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';
@@ -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')],
},
],
});