@backstage/eslint-plugin 0.2.3-next.0 → 0.3.0-next.0

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,17 @@
1
1
  # @backstage/eslint-plugin
2
2
 
3
+ ## 0.3.0-next.0
4
+
5
+ ### Minor Changes
6
+
7
+ - ab1cdbb: Added a new `no-self-package-imports` lint rule, enabled as `error` in the recommended config, that reports when a package imports itself by its own name instead of using a relative path. This pattern causes circular initialization errors in bundled ESM and with `jest.requireActual`.
8
+
9
+ ## 0.2.3
10
+
11
+ ### Patch Changes
12
+
13
+ - df43b0e: Fixed `no-mixed-plugin-imports` rule to return `null` from non-fixable suggestion handlers and added an explicit `SuggestionReportDescriptor[]` type annotation, matching the stricter type checking in TypeScript 6.0.
14
+
3
15
  ## 0.2.3-next.0
4
16
 
5
17
  ### Patch Changes
@@ -0,0 +1,106 @@
1
+ # @backstage/no-self-package-imports
2
+
3
+ This rule prevents a package from importing itself by its own name when the
4
+ imported entry point bundles the current file. Self-package imports in that
5
+ situation create a circular dependency through the bundled barrel, which can
6
+ surface as runtime errors such as
7
+ `Cannot access 'X' before initialization` when the package is loaded in an
8
+ environment that triggers eager re-evaluation (for example
9
+ `jest.requireActual`, or ESM consumers that follow the cycle).
10
+
11
+ The rule understands your package's `exports` map and follows the
12
+ relative import graph from each entry's source file to determine which files
13
+ are actually bundled into each entry. It then reports:
14
+
15
+ - **Same-entry self-imports**: the current file is part of the same bundle
16
+ as the entry it imports, so the cycle is real.
17
+ - **Cross-entry self-imports** (optional): the file is part of a different
18
+ entry's bundle than the one it imports. Cross-entry self-imports don't
19
+ always cycle, but they still couple unrelated entry points at initialization
20
+ time and are worth avoiding.
21
+
22
+ Files that aren't reachable from any published entry (tests, scripts,
23
+ orphans) are skipped, as self-imports from them can't affect a published
24
+ bundle.
25
+
26
+ Imports declared with `import type` (or `export type`) are erased at runtime
27
+ and are always allowed, since they can't cause circular initialization.
28
+
29
+ ## Usage
30
+
31
+ Add the rule as follows:
32
+
33
+ ```js
34
+ "@backstage/no-self-package-imports": ["error"]
35
+ ```
36
+
37
+ This errors on same-entry self-imports. Cross-entry self-imports are allowed
38
+ by default; opt in to reporting them with `allowCrossEntry: false`:
39
+
40
+ ```js
41
+ "@backstage/no-self-package-imports": ["error", { "allowCrossEntry": false }]
42
+ ```
43
+
44
+ ## Rule Details
45
+
46
+ Given this `package.json`:
47
+
48
+ ```json
49
+ {
50
+ "name": "@backstage/plugin-foo",
51
+ "exports": {
52
+ ".": "./src/index.ts",
53
+ "./alpha": "./src/alpha.ts",
54
+ "./package.json": "./package.json"
55
+ }
56
+ }
57
+ ```
58
+
59
+ and `src/index.ts` that re-exports `./blueprint`:
60
+
61
+ ```ts
62
+ export * from './blueprint';
63
+ ```
64
+
65
+ ### Fail
66
+
67
+ Importing `@backstage/plugin-foo` from a file that is also reachable from
68
+ `src/index.ts` creates a cycle:
69
+
70
+ ```ts
71
+ // src/blueprint.ts
72
+ import { helper } from '@backstage/plugin-foo';
73
+ ```
74
+
75
+ ### Pass
76
+
77
+ Use a relative import instead:
78
+
79
+ ```ts
80
+ // src/blueprint.ts
81
+ import { helper } from './helper';
82
+ ```
83
+
84
+ Or, if only the type is used, `import type` is erased at runtime and is
85
+ always allowed:
86
+
87
+ ```ts
88
+ // src/blueprint.ts
89
+ import type { Helper } from '@backstage/plugin-foo';
90
+ ```
91
+
92
+ Importing `package.json` is always allowed:
93
+
94
+ ```ts
95
+ import { version } from '@backstage/plugin-foo/package.json';
96
+ ```
97
+
98
+ ## Options
99
+
100
+ ### `allowCrossEntry`
101
+
102
+ - Type: `boolean`
103
+ - Default: `true`
104
+
105
+ When `false`, the rule also reports self-imports that target a different
106
+ entry from the one the current file is part of.
package/index.js CHANGED
@@ -24,6 +24,7 @@ module.exports = {
24
24
  '@backstage/no-undeclared-imports': 'error',
25
25
  '@backstage/no-mixed-plugin-imports': 'warn',
26
26
  '@backstage/no-ui-css-imports-in-non-frontend': 'error',
27
+ '@backstage/no-self-package-imports': 'error',
27
28
  },
28
29
  },
29
30
  },
@@ -34,5 +35,6 @@ module.exports = {
34
35
  'no-top-level-material-ui-4-imports': require('./rules/no-top-level-material-ui-4-imports'),
35
36
  'no-mixed-plugin-imports': require('./rules/no-mixed-plugin-imports'),
36
37
  'no-ui-css-imports-in-non-frontend': require('./rules/no-ui-css-imports-in-non-frontend'),
38
+ 'no-self-package-imports': require('./rules/no-self-package-imports'),
37
39
  },
38
40
  };
@@ -111,7 +111,7 @@ function getImportInfo(node) {
111
111
  return {
112
112
  path: pathNode.value,
113
113
  node: pathNode,
114
- kind: anyNode.importKind ?? 'value',
114
+ kind: anyNode.importKind ?? anyNode.exportKind ?? 'value',
115
115
  };
116
116
  }
117
117
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@backstage/eslint-plugin",
3
- "version": "0.2.3-next.0",
3
+ "version": "0.3.0-next.0",
4
4
  "description": "Backstage ESLint plugin",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -22,7 +22,7 @@
22
22
  "minimatch": "^10.2.1"
23
23
  },
24
24
  "devDependencies": {
25
- "@backstage/cli": "0.36.1-next.1",
25
+ "@backstage/cli": "0.36.2-next.1",
26
26
  "@types/estree": "^1.0.5",
27
27
  "eslint": "^8.33.0"
28
28
  }
@@ -0,0 +1,284 @@
1
+ /*
2
+ * Copyright 2026 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 fs = require('node:fs');
20
+ const path = require('node:path');
21
+ const visitImports = require('../lib/visitImports');
22
+ const getPackages = require('../lib/getPackages');
23
+
24
+ /**
25
+ * @typedef EntryInfo
26
+ * @type {object}
27
+ * @property {string} key - The exports key, e.g. '.' or './alpha'.
28
+ * @property {string} sourceFile - The source file for the entry, relative to the package dir.
29
+ */
30
+
31
+ const SOURCE_EXTENSIONS = [
32
+ '.ts',
33
+ '.tsx',
34
+ '.mts',
35
+ '.cts',
36
+ '.js',
37
+ '.jsx',
38
+ '.mjs',
39
+ '.cjs',
40
+ ];
41
+
42
+ // Cache the per-package analysis across files lint invocations. The key is the
43
+ // absolute package dir; the value is a Map from absolute file path to the set
44
+ // of entry keys whose bundle reaches that file.
45
+ /** @type {Map<string, Map<string, Set<string>>>} */
46
+ const bundleCache = new Map();
47
+
48
+ /**
49
+ * Build a list of entries from the package.json exports field. Entries that
50
+ * don't point to a script (e.g. `./package.json`) are ignored.
51
+ *
52
+ * @param {unknown} exportsField
53
+ * @returns {EntryInfo[]}
54
+ */
55
+ function readEntries(exportsField) {
56
+ if (!exportsField || typeof exportsField !== 'object') {
57
+ return [{ key: '.', sourceFile: 'src/index.ts' }];
58
+ }
59
+ /** @type {EntryInfo[]} */
60
+ const entries = [];
61
+ for (const [key, value] of Object.entries(exportsField)) {
62
+ if (typeof value !== 'string') continue;
63
+ if (key === './package.json') continue;
64
+ const rel = value.replace(/^\.\//, '');
65
+ entries.push({ key, sourceFile: rel });
66
+ }
67
+ return entries;
68
+ }
69
+
70
+ /**
71
+ * Resolve a relative module specifier against a containing file.
72
+ * Tries source extensions and index files, in that order.
73
+ *
74
+ * @param {string} fromFile
75
+ * @param {string} specifier
76
+ * @returns {string | undefined}
77
+ */
78
+ function resolveSourcePath(fromFile, specifier) {
79
+ const base = path.resolve(path.dirname(fromFile), specifier);
80
+ // If the specifier already carries an extension, only try the exact path.
81
+ if (/\.[cm]?[jt]sx?$/i.test(specifier)) {
82
+ return fs.existsSync(base) ? base : undefined;
83
+ }
84
+ for (const ext of SOURCE_EXTENSIONS) {
85
+ const candidate = base + ext;
86
+ if (fs.existsSync(candidate)) return candidate;
87
+ }
88
+ for (const ext of SOURCE_EXTENSIONS) {
89
+ const candidate = path.join(base, 'index' + ext);
90
+ if (fs.existsSync(candidate)) return candidate;
91
+ }
92
+ return undefined;
93
+ }
94
+
95
+ // Matches `from '...'` specifiers in `import`/`export ... from` statements.
96
+ // Uses a non-greedy body so multi-line imports match cleanly. The negative
97
+ // lookahead skips `import type` / `export type` statements because type-only
98
+ // edges are erased at runtime and can't pull files into a runtime bundle.
99
+ const FROM_SPEC_RE =
100
+ /(?:^|[\s;}])(?:import|export)\b(?!\s+type\b)[^"';]*?\bfrom\s*["']([^"']+)["']/gm;
101
+ // Matches side-effect imports: `import '...';`.
102
+ const SIDE_EFFECT_IMPORT_RE = /(?:^|[\s;}])import\s*["']([^"']+)["']/gm;
103
+
104
+ /**
105
+ * Extract relative module specifiers from a source file's contents.
106
+ *
107
+ * @param {string} contents
108
+ * @returns {string[]}
109
+ */
110
+ function collectRelativeSpecifiers(contents) {
111
+ const specs = new Set();
112
+ for (const m of contents.matchAll(FROM_SPEC_RE)) {
113
+ if (m[1].startsWith('.')) specs.add(m[1]);
114
+ }
115
+ for (const m of contents.matchAll(SIDE_EFFECT_IMPORT_RE)) {
116
+ if (m[1].startsWith('.')) specs.add(m[1]);
117
+ }
118
+ return [...specs];
119
+ }
120
+
121
+ /**
122
+ * Walk the relative import/export graph of each entry's source file and build
123
+ * a map of absolute file paths to the set of entries whose bundles include
124
+ * them. Files that aren't reachable from any entry (e.g. tests, scripts)
125
+ * aren't present in the map.
126
+ *
127
+ * @param {string} pkgDir
128
+ * @param {EntryInfo[]} entries
129
+ * @returns {Map<string, Set<string>>}
130
+ */
131
+ function buildFileToEntriesMap(pkgDir, entries) {
132
+ /** @type {Map<string, Set<string>>} */
133
+ const fileToEntries = new Map();
134
+ for (const entry of entries) {
135
+ const sourceFile = path.join(pkgDir, entry.sourceFile);
136
+ if (!fs.existsSync(sourceFile)) continue;
137
+ /** @type {Set<string>} */
138
+ const visited = new Set();
139
+ const queue = [sourceFile];
140
+ while (queue.length > 0) {
141
+ const current = /** @type {string} */ (queue.pop());
142
+ if (visited.has(current)) continue;
143
+ visited.add(current);
144
+
145
+ let set = fileToEntries.get(current);
146
+ if (!set) {
147
+ set = new Set();
148
+ fileToEntries.set(current, set);
149
+ }
150
+ set.add(entry.key);
151
+
152
+ let contents;
153
+ try {
154
+ contents = fs.readFileSync(current, 'utf8');
155
+ } catch {
156
+ continue;
157
+ }
158
+
159
+ for (const spec of collectRelativeSpecifiers(contents)) {
160
+ const resolved = resolveSourcePath(current, spec);
161
+ if (resolved) queue.push(resolved);
162
+ }
163
+ }
164
+ }
165
+ return fileToEntries;
166
+ }
167
+
168
+ /**
169
+ * @param {string} pkgDir
170
+ * @param {EntryInfo[]} entries
171
+ * @returns {Map<string, Set<string>>}
172
+ */
173
+ function getFileToEntriesMap(pkgDir, entries) {
174
+ let cached = bundleCache.get(pkgDir);
175
+ if (!cached) {
176
+ cached = buildFileToEntriesMap(pkgDir, entries);
177
+ bundleCache.set(pkgDir, cached);
178
+ }
179
+ return cached;
180
+ }
181
+
182
+ /**
183
+ * Find which entry an import targets based on its subpath. Returns undefined
184
+ * when the import doesn't match any declared entry.
185
+ *
186
+ * @param {string} subPath - The part after the package name, without leading slash. Empty for the root entry.
187
+ * @param {EntryInfo[]} entries
188
+ * @returns {EntryInfo | undefined}
189
+ */
190
+ function findEntryForImport(subPath, entries) {
191
+ const key = subPath ? `./${subPath}` : '.';
192
+ return entries.find(e => e.key === key);
193
+ }
194
+
195
+ /** @type {import('eslint').Rule.RuleModule} */
196
+ module.exports = {
197
+ meta: {
198
+ type: 'problem',
199
+ messages: {
200
+ sameEntrySelfImport:
201
+ "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).",
202
+ crossEntrySelfImport:
203
+ "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).",
204
+ },
205
+ docs: {
206
+ description:
207
+ 'Disallow a package from importing itself by its own name, which causes circular initialization issues in bundled ESM.',
208
+ url: 'https://github.com/backstage/backstage/blob/master/packages/eslint-plugin/docs/rules/no-self-package-imports.md',
209
+ },
210
+ schema: [
211
+ {
212
+ type: 'object',
213
+ properties: {
214
+ allowCrossEntry: {
215
+ type: 'boolean',
216
+ },
217
+ },
218
+ additionalProperties: false,
219
+ },
220
+ ],
221
+ },
222
+ create(context) {
223
+ const options = context.options[0] || {};
224
+ const allowCrossEntry = options.allowCrossEntry !== false;
225
+
226
+ const packages = getPackages(context.getCwd());
227
+ const filename = context.getFilename();
228
+ const selfPkg = packages?.byPath(filename);
229
+ if (!selfPkg) {
230
+ return {};
231
+ }
232
+ const selfName = selfPkg.packageJson.name;
233
+ const entries = readEntries(selfPkg.packageJson.exports);
234
+ const fileToEntries = getFileToEntriesMap(selfPkg.dir, entries);
235
+ const fileEntries = fileToEntries.get(filename);
236
+
237
+ // If the file isn't part of any entry's bundle (tests, scripts, orphans),
238
+ // a self-package import from it can't create a circular-initialization
239
+ // problem in a published bundle. Skip.
240
+ if (!fileEntries || fileEntries.size === 0) {
241
+ return {};
242
+ }
243
+
244
+ return visitImports(context, (node, imp) => {
245
+ if (imp.type !== 'internal') return;
246
+ if (imp.packageName !== selfName) return;
247
+ // Type-only imports are erased at runtime and can't cause circular init.
248
+ if (imp.kind === 'type') return;
249
+ // Importing a non-script asset (e.g. `package.json`) doesn't go through
250
+ // the module barrel, so it can't cause circular init issues.
251
+ if (imp.path === 'package.json' || imp.path.endsWith('/package.json')) {
252
+ return;
253
+ }
254
+
255
+ const importEntry = findEntryForImport(imp.path, entries);
256
+ if (!importEntry) return;
257
+
258
+ const importPath = imp.path ? `${selfName}/${imp.path}` : selfName;
259
+
260
+ if (fileEntries.has(importEntry.key)) {
261
+ context.report({
262
+ node,
263
+ messageId: 'sameEntrySelfImport',
264
+ data: {
265
+ packageName: selfName,
266
+ entry: importEntry.key,
267
+ },
268
+ });
269
+ return;
270
+ }
271
+
272
+ if (!allowCrossEntry) {
273
+ context.report({
274
+ node,
275
+ messageId: 'crossEntrySelfImport',
276
+ data: {
277
+ packageName: selfName,
278
+ importPath,
279
+ },
280
+ });
281
+ }
282
+ });
283
+ },
284
+ };
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "@internal/self-import-pkg",
3
+ "backstage": {
4
+ "role": "node-library"
5
+ },
6
+ "exports": {
7
+ ".": "./src/index.ts",
8
+ "./alpha": "./src/alpha/index.ts",
9
+ "./testUtils": "./src/testUtils.ts",
10
+ "./package.json": "./package.json"
11
+ }
12
+ }
@@ -0,0 +1,20 @@
1
+ /*
2
+ * Copyright 2026 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
+ export * from './refs';
18
+ export * from '../shared';
19
+ export * from '../next';
20
+ export type { TypeOnly } from './typeRef';
@@ -0,0 +1,17 @@
1
+ /*
2
+ * Copyright 2026 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
+ export const refs = 3;
@@ -0,0 +1,17 @@
1
+ /*
2
+ * Copyright 2026 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
+ export type TypeOnly = { value: number };
@@ -0,0 +1,18 @@
1
+ /*
2
+ * Copyright 2026 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
+ export * from './util';
18
+ export * from './shared';
@@ -0,0 +1,17 @@
1
+ /*
2
+ * Copyright 2026 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
+ export const foo = 4;
@@ -0,0 +1,17 @@
1
+ /*
2
+ * Copyright 2026 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
+ export * from './foo';
@@ -0,0 +1,17 @@
1
+ /*
2
+ * Copyright 2026 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
+ export const orphan = 6;
@@ -0,0 +1,17 @@
1
+ /*
2
+ * Copyright 2026 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
+ export const shared = 2;
@@ -0,0 +1,17 @@
1
+ /*
2
+ * Copyright 2026 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
+ export const helper = 5;
@@ -0,0 +1,17 @@
1
+ /*
2
+ * Copyright 2026 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
+ export * from './testUtils/helper';
@@ -0,0 +1,17 @@
1
+ /*
2
+ * Copyright 2026 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
+ export const util = 1;
@@ -0,0 +1,229 @@
1
+ /*
2
+ * Copyright 2026 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 'node:path';
19
+ import rule from '../rules/no-self-package-imports';
20
+
21
+ const RULE = 'no-self-package-imports';
22
+ const FIXTURE = path.resolve(__dirname, '__fixtures__/monorepo');
23
+ const PKG_DIR = path.join(FIXTURE, 'packages/self-import-pkg');
24
+
25
+ const sameEntryErr = (entry = '.') => ({
26
+ messageId: 'sameEntrySelfImport',
27
+ data: { packageName: '@internal/self-import-pkg', entry },
28
+ });
29
+
30
+ const crossEntryErr = (importPath: string) => ({
31
+ messageId: 'crossEntrySelfImport',
32
+ data: { packageName: '@internal/self-import-pkg', importPath },
33
+ });
34
+
35
+ const origDir = process.cwd();
36
+ afterAll(() => {
37
+ process.chdir(origDir);
38
+ });
39
+ process.chdir(FIXTURE);
40
+
41
+ const ruleTester = new RuleTester({
42
+ parser: require.resolve('@typescript-eslint/parser'),
43
+ parserOptions: {
44
+ sourceType: 'module',
45
+ ecmaVersion: 2021,
46
+ },
47
+ });
48
+
49
+ ruleTester.run(RULE, rule, {
50
+ valid: [
51
+ // Relative imports are always fine.
52
+ {
53
+ code: `import { foo } from './local'`,
54
+ filename: path.join(PKG_DIR, 'src/index.ts'),
55
+ },
56
+ // Imports of other packages are unaffected.
57
+ {
58
+ code: `import { foo } from '@internal/bar'`,
59
+ filename: path.join(PKG_DIR, 'src/index.ts'),
60
+ },
61
+ {
62
+ code: `import { foo } from 'react'`,
63
+ filename: path.join(PKG_DIR, 'src/index.ts'),
64
+ },
65
+ // `src/alpha/refs.ts` is only in the `./alpha` bundle; importing from the
66
+ // root entry `.` is a cross-entry reference, which is allowed by default.
67
+ {
68
+ code: `import { foo } from '@internal/self-import-pkg'`,
69
+ filename: path.join(PKG_DIR, 'src/alpha/refs.ts'),
70
+ },
71
+ // `src/index.ts` is only in the `.` bundle; importing from `./alpha` is
72
+ // cross-entry.
73
+ {
74
+ code: `import { foo } from '@internal/self-import-pkg/alpha'`,
75
+ filename: path.join(PKG_DIR, 'src/index.ts'),
76
+ },
77
+ {
78
+ code: `import { foo } from '@internal/self-import-pkg/testUtils'`,
79
+ filename: path.join(PKG_DIR, 'src/index.ts'),
80
+ },
81
+ // `src/next/foo.ts` is physically under `src/` but only re-exported from
82
+ // `./alpha` (via `src/alpha/index.ts` → `../next`). The rule follows the
83
+ // actual barrel graph rather than the directory layout, so importing
84
+ // from the root entry is correctly classified as cross-entry.
85
+ {
86
+ code: `import { foo } from '@internal/self-import-pkg'`,
87
+ filename: path.join(PKG_DIR, 'src/next/foo.ts'),
88
+ },
89
+ // `package.json` imports are exempt since they don't go through the
90
+ // module barrel.
91
+ {
92
+ code: `import pkg from '@internal/self-import-pkg/package.json'`,
93
+ filename: path.join(PKG_DIR, 'src/index.ts'),
94
+ },
95
+ // Files that aren't reachable from any entry (tests, scripts, orphans)
96
+ // can't cause circular-init errors in the published bundle, so they're
97
+ // skipped entirely.
98
+ {
99
+ code: `import { foo } from '@internal/self-import-pkg'`,
100
+ filename: path.join(PKG_DIR, 'src/index.test.ts'),
101
+ },
102
+ {
103
+ code: `import { foo } from '@internal/self-import-pkg/alpha'`,
104
+ filename: path.join(PKG_DIR, 'src/index.test.ts'),
105
+ },
106
+ {
107
+ code: `import { foo } from '@internal/self-import-pkg'`,
108
+ filename: path.join(PKG_DIR, 'src/orphan.ts'),
109
+ },
110
+ // Dynamic imports in a test file still count as orphan, since the test
111
+ // file itself isn't part of any entry's bundle.
112
+ {
113
+ code: `const m = import('@internal/self-import-pkg')`,
114
+ filename: path.join(PKG_DIR, 'src/alpha/refs.test.ts'),
115
+ },
116
+ // `import type` is erased at runtime and can't create circular
117
+ // initialization issues, so it's always allowed.
118
+ {
119
+ code: `import type { Foo } from '@internal/self-import-pkg'`,
120
+ filename: path.join(PKG_DIR, 'src/index.ts'),
121
+ },
122
+ {
123
+ code: `import type { Foo } from '@internal/self-import-pkg/alpha'`,
124
+ filename: path.join(PKG_DIR, 'src/alpha/refs.ts'),
125
+ },
126
+ // `export type { ... } from` is also a type-only statement: the TS AST
127
+ // marks it with `exportKind: 'type'`, and the emitted JS has no runtime
128
+ // edge. Both same-entry and cross-entry forms must be skipped.
129
+ {
130
+ code: `export type { Foo } from '@internal/self-import-pkg'`,
131
+ filename: path.join(PKG_DIR, 'src/index.ts'),
132
+ },
133
+ {
134
+ code: `export type { Foo } from '@internal/self-import-pkg/alpha'`,
135
+ filename: path.join(PKG_DIR, 'src/alpha/refs.ts'),
136
+ },
137
+ // `src/alpha/typeRef.ts` is only reachable from the `./alpha` barrel via
138
+ // an `export type { ... } from './typeRef'` edge. Since type-only edges
139
+ // are erased at runtime, the file isn't part of any entry's bundle and
140
+ // self-imports from it must be skipped as orphans.
141
+ {
142
+ code: `import { foo } from '@internal/self-import-pkg/alpha'`,
143
+ filename: path.join(PKG_DIR, 'src/alpha/typeRef.ts'),
144
+ },
145
+ {
146
+ code: `import { foo } from '@internal/self-import-pkg'`,
147
+ filename: path.join(PKG_DIR, 'src/alpha/typeRef.ts'),
148
+ },
149
+ ],
150
+ invalid: [
151
+ // Same-entry self-imports are always errors because they create circular
152
+ // module graphs inside a bundle.
153
+ {
154
+ code: `import { foo } from '@internal/self-import-pkg'`,
155
+ filename: path.join(PKG_DIR, 'src/index.ts'),
156
+ errors: [sameEntryErr('.')],
157
+ },
158
+ {
159
+ code: `import { foo } from '@internal/self-import-pkg'`,
160
+ filename: path.join(PKG_DIR, 'src/util.ts'),
161
+ errors: [sameEntryErr('.')],
162
+ },
163
+ {
164
+ code: `export { foo } from '@internal/self-import-pkg'`,
165
+ filename: path.join(PKG_DIR, 'src/index.ts'),
166
+ errors: [sameEntryErr('.')],
167
+ },
168
+ {
169
+ code: `const x = require('@internal/self-import-pkg')`,
170
+ filename: path.join(PKG_DIR, 'src/index.ts'),
171
+ errors: [sameEntryErr('.')],
172
+ },
173
+ {
174
+ code: `const m = import('@internal/self-import-pkg')`,
175
+ filename: path.join(PKG_DIR, 'src/util.ts'),
176
+ errors: [sameEntryErr('.')],
177
+ },
178
+ // Files in a non-root entry's bundle importing that same entry are
179
+ // flagged.
180
+ {
181
+ code: `import { foo } from '@internal/self-import-pkg/alpha'`,
182
+ filename: path.join(PKG_DIR, 'src/alpha/refs.ts'),
183
+ errors: [sameEntryErr('./alpha')],
184
+ },
185
+ {
186
+ code: `import { foo } from '@internal/self-import-pkg/testUtils'`,
187
+ filename: path.join(PKG_DIR, 'src/testUtils.ts'),
188
+ errors: [sameEntryErr('./testUtils')],
189
+ },
190
+ {
191
+ code: `import { foo } from '@internal/self-import-pkg/testUtils'`,
192
+ filename: path.join(PKG_DIR, 'src/testUtils/helper.ts'),
193
+ errors: [sameEntryErr('./testUtils')],
194
+ },
195
+ // `src/next/foo.ts` is actually in the `./alpha` bundle via barrel
196
+ // re-exports, so importing `./alpha` from it is same-entry — even though
197
+ // the directory layout might suggest otherwise.
198
+ {
199
+ code: `import { foo } from '@internal/self-import-pkg/alpha'`,
200
+ filename: path.join(PKG_DIR, 'src/next/foo.ts'),
201
+ errors: [sameEntryErr('./alpha')],
202
+ },
203
+ // `src/shared.ts` is re-exported by both the root and alpha entries, so
204
+ // either target is same-entry.
205
+ {
206
+ code: `import { foo } from '@internal/self-import-pkg'`,
207
+ filename: path.join(PKG_DIR, 'src/shared.ts'),
208
+ errors: [sameEntryErr('.')],
209
+ },
210
+ {
211
+ code: `import { foo } from '@internal/self-import-pkg/alpha'`,
212
+ filename: path.join(PKG_DIR, 'src/shared.ts'),
213
+ errors: [sameEntryErr('./alpha')],
214
+ },
215
+ // With `allowCrossEntry: false`, cross-entry imports are also flagged.
216
+ {
217
+ code: `import { foo } from '@internal/self-import-pkg'`,
218
+ filename: path.join(PKG_DIR, 'src/alpha/refs.ts'),
219
+ options: [{ allowCrossEntry: false }],
220
+ errors: [crossEntryErr('@internal/self-import-pkg')],
221
+ },
222
+ {
223
+ code: `import { foo } from '@internal/self-import-pkg/alpha'`,
224
+ filename: path.join(PKG_DIR, 'src/index.ts'),
225
+ options: [{ allowCrossEntry: false }],
226
+ errors: [crossEntryErr('@internal/self-import-pkg/alpha')],
227
+ },
228
+ ],
229
+ });