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,273 @@
/*
* 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.
*/
// @ts-check
const fs = require('node:fs');
const path = require('node:path');
const visitImports = require('../lib/visitImports');
const getPackages = require('../lib/getPackages');
/**
* @typedef EntryInfo
* @type {object}
* @property {string} key - The exports key, e.g. '.' or './alpha'.
* @property {string} sourceFile - The source file for the entry, relative to the package dir.
*/
const SOURCE_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'];
// Cache the per-package analysis across files lint invocations. The key is the
// absolute package dir; the value is a Map from absolute file path to the set
// of entry keys whose bundle reaches that file.
/** @type {Map<string, Map<string, Set<string>>>} */
const bundleCache = new Map();
/**
* Build a list of entries from the package.json exports field. Entries that
* don't point to a script (e.g. `./package.json`) are ignored.
*
* @param {unknown} exportsField
* @returns {EntryInfo[]}
*/
function readEntries(exportsField) {
if (!exportsField || typeof exportsField !== 'object') {
return [{ key: '.', sourceFile: 'src/index.ts' }];
}
/** @type {EntryInfo[]} */
const entries = [];
for (const [key, value] of Object.entries(exportsField)) {
if (typeof value !== 'string') continue;
if (key === './package.json') continue;
const rel = value.replace(/^\.\//, '');
entries.push({ key, sourceFile: rel });
}
return entries;
}
/**
* Resolve a relative module specifier against a containing file.
* Tries source extensions and index files, in that order.
*
* @param {string} fromFile
* @param {string} specifier
* @returns {string | undefined}
*/
function resolveSourcePath(fromFile, specifier) {
const base = path.resolve(path.dirname(fromFile), specifier);
// If the specifier already carries an extension, only try the exact path.
if (/\.[cm]?[jt]sx?$/i.test(specifier)) {
return fs.existsSync(base) ? base : undefined;
}
for (const ext of SOURCE_EXTENSIONS) {
const candidate = base + ext;
if (fs.existsSync(candidate)) return candidate;
}
for (const ext of SOURCE_EXTENSIONS) {
const candidate = path.join(base, 'index' + ext);
if (fs.existsSync(candidate)) return candidate;
}
return undefined;
}
// Matches `from '...'` specifiers in `import`/`export ... from` statements.
// Uses a non-greedy body so multi-line imports match cleanly.
const FROM_SPEC_RE =
/(?:^|[\s;}])(?:import|export)\b[^"';]*?\bfrom\s*["']([^"']+)["']/gm;
// Matches side-effect imports: `import '...';`.
const SIDE_EFFECT_IMPORT_RE = /(?:^|[\s;}])import\s*["']([^"']+)["']/gm;
/**
* Extract relative module specifiers from a source file's contents.
*
* @param {string} contents
* @returns {string[]}
*/
function collectRelativeSpecifiers(contents) {
const specs = new Set();
for (const m of contents.matchAll(FROM_SPEC_RE)) {
if (m[1].startsWith('.')) specs.add(m[1]);
}
for (const m of contents.matchAll(SIDE_EFFECT_IMPORT_RE)) {
if (m[1].startsWith('.')) specs.add(m[1]);
}
return [...specs];
}
/**
* Walk the relative import/export graph of each entry's source file and build
* a map of absolute file paths to the set of entries whose bundles include
* them. Files that aren't reachable from any entry (e.g. tests, scripts)
* aren't present in the map.
*
* @param {string} pkgDir
* @param {EntryInfo[]} entries
* @returns {Map<string, Set<string>>}
*/
function buildFileToEntriesMap(pkgDir, entries) {
/** @type {Map<string, Set<string>>} */
const fileToEntries = new Map();
for (const entry of entries) {
const sourceFile = path.join(pkgDir, entry.sourceFile);
if (!fs.existsSync(sourceFile)) continue;
/** @type {Set<string>} */
const visited = new Set();
const queue = [sourceFile];
while (queue.length > 0) {
const current = /** @type {string} */ (queue.pop());
if (visited.has(current)) continue;
visited.add(current);
let set = fileToEntries.get(current);
if (!set) {
set = new Set();
fileToEntries.set(current, set);
}
set.add(entry.key);
let contents;
try {
contents = fs.readFileSync(current, 'utf8');
} catch {
continue;
}
for (const spec of collectRelativeSpecifiers(contents)) {
const resolved = resolveSourcePath(current, spec);
if (resolved) queue.push(resolved);
}
}
}
return fileToEntries;
}
/**
* @param {string} pkgDir
* @param {EntryInfo[]} entries
* @returns {Map<string, Set<string>>}
*/
function getFileToEntriesMap(pkgDir, entries) {
let cached = bundleCache.get(pkgDir);
if (!cached) {
cached = buildFileToEntriesMap(pkgDir, entries);
bundleCache.set(pkgDir, cached);
}
return cached;
}
/**
* Find which entry an import targets based on its subpath. Returns undefined
* when the import doesn't match any declared entry.
*
* @param {string} subPath - The part after the package name, without leading slash. Empty for the root entry.
* @param {EntryInfo[]} entries
* @returns {EntryInfo | undefined}
*/
function findEntryForImport(subPath, entries) {
const key = subPath ? `./${subPath}` : '.';
return entries.find(e => e.key === key);
}
/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
type: 'problem',
messages: {
sameEntrySelfImport:
"Do not import from your own package '{{packageName}}'. This causes a circular dependency because '{{entry}}' re-exports this file. Switch to a relative import, or use `import type` if only types are needed (type-only imports are erased at runtime).",
crossEntrySelfImport:
"Avoid importing from your own package '{{packageName}}' via '{{importPath}}'. Even across entry points this can lead to subtle circular initialization issues. Prefer a relative import, or use `import type` if only types are needed (type-only imports are erased at runtime).",
},
docs: {
description:
'Disallow a package from importing itself by its own name, which causes circular initialization issues in bundled ESM.',
url: 'https://github.com/backstage/backstage/blob/master/packages/eslint-plugin/docs/rules/no-self-package-imports.md',
},
schema: [
{
type: 'object',
properties: {
allowCrossEntry: {
type: 'boolean',
},
},
additionalProperties: false,
},
],
},
create(context) {
const options = context.options[0] || {};
const allowCrossEntry = options.allowCrossEntry !== false;
const packages = getPackages(context.getCwd());
const filename = context.getFilename();
const selfPkg = packages?.byPath(filename);
if (!selfPkg) {
return {};
}
const selfName = selfPkg.packageJson.name;
const entries = readEntries(selfPkg.packageJson.exports);
const fileToEntries = getFileToEntriesMap(selfPkg.dir, entries);
const fileEntries = fileToEntries.get(filename);
// If the file isn't part of any entry's bundle (tests, scripts, orphans),
// a self-package import from it can't create a circular-initialization
// problem in a published bundle. Skip.
if (!fileEntries || fileEntries.size === 0) {
return {};
}
return visitImports(context, (node, imp) => {
if (imp.type !== 'internal') return;
if (imp.packageName !== selfName) return;
// Type-only imports are erased at runtime and can't cause circular init.
if (imp.kind === 'type') return;
// Importing a non-script asset (e.g. `package.json`) doesn't go through
// the module barrel, so it can't cause circular init issues.
if (imp.path === 'package.json' || imp.path.endsWith('/package.json')) {
return;
}
const importEntry = findEntryForImport(imp.path, entries);
if (!importEntry) return;
const importPath = imp.path ? `${selfName}/${imp.path}` : selfName;
if (fileEntries.has(importEntry.key)) {
context.report({
node,
messageId: 'sameEntrySelfImport',
data: {
packageName: selfName,
entry: importEntry.key,
},
});
return;
}
if (!allowCrossEntry) {
context.report({
node,
messageId: 'crossEntrySelfImport',
data: {
packageName: selfName,
importPath,
},
});
}
});
},
};