From b3b5e331d84655f5a98763fad0977f1dd53c499f Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Sat, 4 Apr 2026 01:27:53 -0400 Subject: [PATCH 1/4] Add custom ESLint rule to disallow barrel/entrypoint imports Internal source files should import directly from specific lib/ files (e.g., '@ember/-internals/glimmer/lib/renderer') rather than from barrel index files (e.g., '@ember/-internals/glimmer') to enable proper tree-shaking by bundlers. This rule covers @ember/-internals/glimmer and @ember/-internals/environment, and only applies to non-test source files in packages/. Co-Authored-By: Claude Opus 4.6 (1M context) --- eslint.config.mjs | 14 ++++++ eslint/rules/no-barrel-imports.js | 79 +++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 eslint/rules/no-barrel-imports.js diff --git a/eslint.config.mjs b/eslint.config.mjs index 7407d64b580..037d30ca8dd 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -8,6 +8,10 @@ import nodePlugin from 'eslint-plugin-n'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import pluginJs from '@eslint/js'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const noBarrelImports = require('./eslint/rules/no-barrel-imports.js'); const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -41,6 +45,11 @@ export default [ plugins: { 'ember-internal': emberInternal, 'disable-features': disableFeatures, + local: { + rules: { + 'no-barrel-imports': noBarrelImports, + }, + }, }, linterOptions: { @@ -78,6 +87,11 @@ export default [ 'disable-features/disable-generator-functions': 'error', // Doesn't work with package.json#exports 'import/no-unresolved': 'off', + + // Prevent importing from barrel/entrypoint files in internal packages. + // Source files should import directly from the specific lib/ file to + // enable proper tree-shaking. + 'local/no-barrel-imports': 'error', }, }, ...tseslint.configs.recommended.map((config) => ({ diff --git a/eslint/rules/no-barrel-imports.js b/eslint/rules/no-barrel-imports.js new file mode 100644 index 00000000000..ed86ec26182 --- /dev/null +++ b/eslint/rules/no-barrel-imports.js @@ -0,0 +1,79 @@ +/** + * ESLint rule: no-barrel-imports + * + * Disallows importing from barrel/entrypoint files of internal packages. + * Internal source files should import directly from the specific lib/ file + * to enable proper tree-shaking by bundlers. + * + * For example: + * Bad: import { Renderer } from '@ember/-internals/glimmer'; + * Good: import { Renderer } from '@ember/-internals/glimmer/lib/renderer'; + */ + +'use strict'; + +// Barrel packages that should not be imported directly from source files. +// Each entry maps a bare package specifier to a human-readable hint. +const BARREL_PACKAGES = new Map([ + [ + '@ember/-internals/glimmer', + "Import from '@ember/-internals/glimmer/lib/...' instead of the barrel '@ember/-internals/glimmer'.", + ], + [ + '@ember/-internals/environment', + "Import from '@ember/-internals/environment/lib/env' or '@ember/-internals/environment/lib/context' instead of the barrel '@ember/-internals/environment'.", + ], +]); + +/** @type {import('eslint').Rule.RuleModule} */ +module.exports = { + meta: { + type: 'suggestion', + docs: { + description: + 'Disallow imports from barrel/entrypoint files; require direct file imports instead', + }, + messages: { + noBarrelImport: + "Do not import from the barrel '{{source}}'. {{hint}}", + }, + schema: [], // no options + }, + + create(context) { + // Only apply to files inside packages/ that are NOT test or type-test files. + const filename = context.filename || context.getFilename(); + + // Skip files outside packages/ + if (!filename.includes('/packages/')) { + return {}; + } + + // Skip test and type-test files + if (/\/(tests|type-tests)\//.test(filename)) { + return {}; + } + + function check(node) { + const source = node.source && node.source.value; + if (typeof source !== 'string') return; + + const hint = BARREL_PACKAGES.get(source); + if (hint) { + context.report({ + node: node.source, + messageId: 'noBarrelImport', + data: { source, hint }, + }); + } + } + + return { + ImportDeclaration: check, + // Also catch: export { foo } from '@ember/-internals/glimmer'; + ExportNamedDeclaration: check, + // Also catch: export * from '@ember/-internals/glimmer'; + ExportAllDeclaration: check, + }; + }, +}; From 55c5c800885a60d8a8a7188b75fe071f7d24c919 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Sat, 4 Apr 2026 01:35:19 -0400 Subject: [PATCH 2/4] Rewrite no-barrel-imports rule with auto-fix for all barrel imports The rule now: - Detects ALL barrel imports (any import that resolves to an index.ts), not just a hardcoded list of packages - Provides auto-fix that reads the barrel's index.ts, traces each imported name to its actual source file, and rewrites the import - Groups imports by source file when multiple names come from the same file - Reports without fix for cases it can't trace (export *, namespace imports) Co-Authored-By: Claude Opus 4.6 (1M context) --- eslint/rules/no-barrel-imports.js | 266 +++++++++++++++++++++++++++--- 1 file changed, 242 insertions(+), 24 deletions(-) diff --git a/eslint/rules/no-barrel-imports.js b/eslint/rules/no-barrel-imports.js index ed86ec26182..322d1a149bb 100644 --- a/eslint/rules/no-barrel-imports.js +++ b/eslint/rules/no-barrel-imports.js @@ -1,50 +1,150 @@ /** * ESLint rule: no-barrel-imports * - * Disallows importing from barrel/entrypoint files of internal packages. - * Internal source files should import directly from the specific lib/ file - * to enable proper tree-shaking by bundlers. + * Disallows importing from barrel/entrypoint (index.ts) files within + * packages/. Internal source files should import directly from the + * specific file that defines the export, to enable proper tree-shaking. + * + * Provides auto-fix by reading the barrel's index.ts, tracing each + * imported name back to its source file, and rewriting the import. * - * For example: * Bad: import { Renderer } from '@ember/-internals/glimmer'; * Good: import { Renderer } from '@ember/-internals/glimmer/lib/renderer'; */ 'use strict'; -// Barrel packages that should not be imported directly from source files. -// Each entry maps a bare package specifier to a human-readable hint. -const BARREL_PACKAGES = new Map([ - [ - '@ember/-internals/glimmer', - "Import from '@ember/-internals/glimmer/lib/...' instead of the barrel '@ember/-internals/glimmer'.", - ], - [ - '@ember/-internals/environment', - "Import from '@ember/-internals/environment/lib/env' or '@ember/-internals/environment/lib/context' instead of the barrel '@ember/-internals/environment'.", - ], -]); +const fs = require('fs'); +const path = require('path'); + +// Cache parsed barrel export maps so we don't re-read files per lint invocation. +const barrelCache = new Map(); + +/** + * Given the absolute path to a barrel index.ts, parse its re-exports and + * return a Map. + * + * Handles: + * export { Foo, Bar } from './lib/foo'; + * export { Baz as Qux } from './lib/baz'; + * export { default as Foo } from './lib/foo'; + * export type { Foo } from './lib/foo'; + */ +function parseBarrelExports(barrelPath) { + if (barrelCache.has(barrelPath)) return barrelCache.get(barrelPath); + + const map = new Map(); // exportedName -> relativePath (without extension) + + let content; + try { + content = fs.readFileSync(barrelPath, 'utf8'); + } catch { + barrelCache.set(barrelPath, map); + return map; + } + + // Match: export [type] { names } from 'source'; + const re = /export\s+(?:type\s+)?{([^}]+)}\s+from\s+['"]([^'"]+)['"]/g; + let m; + while ((m = re.exec(content)) !== null) { + const names = m[1]; + let sourcePath = m[2]; + + // Parse individual names: "Foo", "Foo as Bar", "default as Foo", "type Foo" + for (let part of names.split(',')) { + part = part.trim(); + if (!part) continue; + + // Strip leading "type " for type-only re-exports + part = part.replace(/^type\s+/, ''); + + const asParts = part.split(/\s+as\s+/); + const exportedName = (asParts[1] || asParts[0]).trim(); + + map.set(exportedName, sourcePath); + } + } + + // Match: export { default } from 'source'; (shorthand) + const reDefault = /export\s+{\s*default\s*}\s+from\s+['"]([^'"]+)['"]/g; + while ((m = reDefault.exec(content)) !== null) { + map.set('default', m[1]); + } + + barrelCache.set(barrelPath, map); + return map; +} + +/** + * Resolve a package specifier to the absolute path of its index.ts barrel. + * Returns null if no barrel is found. + */ +function resolveBarrelPath(packagesRoot, importSource) { + // Try direct: packages//index.ts + const candidates = [ + path.join(packagesRoot, importSource, 'index.ts'), + path.join(packagesRoot, importSource, 'index.js'), + ]; + + for (const candidate of candidates) { + if (fs.existsSync(candidate)) { + return candidate; + } + } + return null; +} + +/** + * Given a relative source from a barrel (e.g. './lib/renderer') and + * the barrel's package specifier (e.g. '@ember/-internals/glimmer'), + * compute the full package import path. + */ +function toPackagePath(barrelPackage, relativeSource) { + // './lib/renderer' -> '@ember/-internals/glimmer/lib/renderer' + // '../foo' -> would be outside package, skip + if (!relativeSource.startsWith('./')) return null; + return barrelPackage + '/' + relativeSource.slice(2); +} + +/** + * Check if an import specifier looks like a barrel import. + * A barrel import is one that points to a package entrypoint (index.ts) + * rather than a specific source file. + * + * Heuristic: if the specifier matches a known package directory that + * contains an index.ts, it's a barrel import. + */ +function isBarrelImport(packagesRoot, importSource) { + // Must start with @ or a package name, not be a relative import + if (importSource.startsWith('.') || importSource.startsWith('/')) return false; + + // Must be inside our packages/ directory + const barrelPath = resolveBarrelPath(packagesRoot, importSource); + return barrelPath !== null; +} /** @type {import('eslint').Rule.RuleModule} */ module.exports = { meta: { type: 'suggestion', + fixable: 'code', docs: { description: 'Disallow imports from barrel/entrypoint files; require direct file imports instead', }, messages: { noBarrelImport: - "Do not import from the barrel '{{source}}'. {{hint}}", + "Do not import from the barrel '{{source}}'. Import directly from the source file instead.", + noBarrelImportNoFix: + "Do not import from the barrel '{{source}}'. Could not auto-fix: {{reason}}", }, schema: [], // no options }, create(context) { - // Only apply to files inside packages/ that are NOT test or type-test files. const filename = context.filename || context.getFilename(); - // Skip files outside packages/ + // Only apply to files inside packages/ if (!filename.includes('/packages/')) { return {}; } @@ -54,25 +154,143 @@ module.exports = { return {}; } + // Find the packages root + const packagesIdx = filename.indexOf('/packages/'); + if (packagesIdx === -1) return {}; + const packagesRoot = filename.substring(0, packagesIdx) + '/packages'; + function check(node) { const source = node.source && node.source.value; if (typeof source !== 'string') return; - const hint = BARREL_PACKAGES.get(source); - if (hint) { + // Check if this is a barrel import + if (!isBarrelImport(packagesRoot, source)) return; + + // Get the imported names from the AST node + const importedNames = []; + + if (node.type === 'ImportDeclaration') { + for (const spec of node.specifiers || []) { + if (spec.type === 'ImportSpecifier') { + importedNames.push({ + imported: spec.imported.name, + local: spec.local.name, + isType: spec.importKind === 'type', + }); + } else if (spec.type === 'ImportDefaultSpecifier') { + importedNames.push({ + imported: 'default', + local: spec.local.name, + isType: false, + }); + } + // ImportNamespaceSpecifier (import *) — can't auto-fix + } + } else if (node.type === 'ExportNamedDeclaration') { + for (const spec of node.specifiers || []) { + importedNames.push({ + imported: spec.local.name, + local: spec.exported.name, + isType: spec.exportKind === 'type', + }); + } + } + // ExportAllDeclaration — can't auto-fix, just report + + if (node.type === 'ExportAllDeclaration' || importedNames.length === 0) { + context.report({ + node: node.source, + messageId: 'noBarrelImportNoFix', + data: { + source, + reason: node.type === 'ExportAllDeclaration' + ? 'export * cannot be auto-fixed' + : 'no named imports to trace', + }, + }); + return; + } + + // Resolve the barrel and trace exports + const barrelPath = resolveBarrelPath(packagesRoot, source); + if (!barrelPath) { context.report({ node: node.source, messageId: 'noBarrelImport', - data: { source, hint }, + data: { source }, }); + return; } + + const exportMap = parseBarrelExports(barrelPath); + + // Group imported names by their source file + const bySource = new Map(); // sourcePath -> [{imported, local, isType}] + const unfixed = []; + + for (const entry of importedNames) { + const relSource = exportMap.get(entry.imported); + if (!relSource) { + unfixed.push(entry.imported); + continue; + } + + const pkgPath = toPackagePath(source, relSource); + if (!pkgPath) { + unfixed.push(entry.imported); + continue; + } + + if (!bySource.has(pkgPath)) bySource.set(pkgPath, []); + bySource.get(pkgPath).push(entry); + } + + if (unfixed.length > 0 || bySource.size === 0) { + // Can't fully auto-fix — report without fix + context.report({ + node: node.source, + messageId: 'noBarrelImportNoFix', + data: { + source, + reason: `could not trace: ${unfixed.join(', ')}`, + }, + }); + return; + } + + // Build the replacement import(s) + context.report({ + node, + messageId: 'noBarrelImport', + data: { source }, + fix(fixer) { + const isExport = node.type === 'ExportNamedDeclaration'; + const keyword = isExport ? 'export' : 'import'; + const typePrefix = node.importKind === 'type' ? ' type' : ''; + + const statements = []; + for (const [pkgPath, entries] of bySource) { + const specifiers = entries.map((e) => { + const typeStr = e.isType && !typePrefix ? 'type ' : ''; + if (e.imported === e.local) { + return `${typeStr}${e.imported}`; + } + return `${typeStr}${e.imported} as ${e.local}`; + }); + + statements.push( + `${keyword}${typePrefix} { ${specifiers.join(', ')} } from '${pkgPath}';` + ); + } + + return fixer.replaceText(node, statements.join('\n')); + }, + }); } return { ImportDeclaration: check, - // Also catch: export { foo } from '@ember/-internals/glimmer'; ExportNamedDeclaration: check, - // Also catch: export * from '@ember/-internals/glimmer'; ExportAllDeclaration: check, }; }, From 3a8ea31dd72f7d5a5e3f1e384eba38e32c791dd1 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Sat, 4 Apr 2026 01:43:15 -0400 Subject: [PATCH 3/4] Include correct import paths in error messages and handle export * - Error messages now show the exact source file paths to import from - export * from barrels are resolved by parsing the source file's exports and expanding to named exports in the auto-fix - export * from single-source barrels are fixed by rewriting the source path - import * as Foo from single-source barrels are also fixable Co-Authored-By: Claude Opus 4.6 (1M context) --- eslint/rules/no-barrel-imports.js | 273 ++++++++++++++++++++---------- 1 file changed, 185 insertions(+), 88 deletions(-) diff --git a/eslint/rules/no-barrel-imports.js b/eslint/rules/no-barrel-imports.js index 322d1a149bb..2a3c51ec79c 100644 --- a/eslint/rules/no-barrel-imports.js +++ b/eslint/rules/no-barrel-imports.js @@ -20,6 +20,66 @@ const path = require('path'); // Cache parsed barrel export maps so we don't re-read files per lint invocation. const barrelCache = new Map(); +/** + * Resolve a source file path (relative, without extension) to an absolute path. + * Tries .ts, .js, /index.ts, /index.js suffixes. + */ +function resolveFile(dir, relativePath) { + const base = path.resolve(dir, relativePath); + for (const suffix of ['', '.ts', '.js', '/index.ts', '/index.js']) { + const candidate = base + suffix; + if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) { + return candidate; + } + } + return null; +} + +/** + * Parse a source file and extract its named exports. + * Returns an array of exported names (strings). + */ +function getFileExports(filePath) { + let content; + try { + content = fs.readFileSync(filePath, 'utf8'); + } catch { + return []; + } + + const names = []; + + // export function foo / export class Foo / export const foo / export let foo / export var foo + const reDeclExport = + /export\s+(?:declare\s+)?(?:function|class|const|let|var|enum|interface|type|abstract\s+class)\s+(\w+)/g; + let m; + while ((m = reDeclExport.exec(content)) !== null) { + names.push(m[1]); + } + + // export { Foo, Bar as Baz } — local exports (no "from") + const reLocalExport = /export\s+(?:type\s+)?{([^}]+)}\s*(?:;|$)/gm; + while ((m = reLocalExport.exec(content)) !== null) { + // Make sure this isn't a re-export (has "from") + const afterBrace = content.slice(m.index, m.index + m[0].length + 30); + if (/from\s+['"]/.test(afterBrace)) continue; + + for (let part of m[1].split(',')) { + part = part.trim().replace(/^type\s+/, ''); + if (!part) continue; + const asParts = part.split(/\s+as\s+/); + names.push((asParts[1] || asParts[0]).trim()); + } + } + + // export default + if (/export\s+default\s/.test(content)) { + names.push('default'); + } + + return names; +} + /** * Given the absolute path to a barrel index.ts, parse its re-exports and * return a Map. @@ -29,11 +89,13 @@ const barrelCache = new Map(); * export { Baz as Qux } from './lib/baz'; * export { default as Foo } from './lib/foo'; * export type { Foo } from './lib/foo'; + * export * from './lib/foo'; */ function parseBarrelExports(barrelPath) { if (barrelCache.has(barrelPath)) return barrelCache.get(barrelPath); const map = new Map(); // exportedName -> relativePath (without extension) + const barrelDir = path.dirname(barrelPath); let content; try { @@ -48,27 +110,32 @@ function parseBarrelExports(barrelPath) { let m; while ((m = re.exec(content)) !== null) { const names = m[1]; - let sourcePath = m[2]; + const sourcePath = m[2]; - // Parse individual names: "Foo", "Foo as Bar", "default as Foo", "type Foo" for (let part of names.split(',')) { part = part.trim(); if (!part) continue; - - // Strip leading "type " for type-only re-exports part = part.replace(/^type\s+/, ''); - const asParts = part.split(/\s+as\s+/); const exportedName = (asParts[1] || asParts[0]).trim(); - map.set(exportedName, sourcePath); } } - // Match: export { default } from 'source'; (shorthand) - const reDefault = /export\s+{\s*default\s*}\s+from\s+['"]([^'"]+)['"]/g; - while ((m = reDefault.exec(content)) !== null) { - map.set('default', m[1]); + // Match: export * from 'source'; + const reStarExport = /export\s+\*\s+from\s+['"]([^'"]+)['"]/g; + while ((m = reStarExport.exec(content)) !== null) { + const sourcePath = m[1]; + const resolvedFile = resolveFile(barrelDir, sourcePath); + if (resolvedFile) { + const exportedNames = getFileExports(resolvedFile); + for (const name of exportedNames) { + // Don't override named re-exports (they take precedence) + if (!map.has(name)) { + map.set(name, sourcePath); + } + } + } } barrelCache.set(barrelPath, map); @@ -77,10 +144,8 @@ function parseBarrelExports(barrelPath) { /** * Resolve a package specifier to the absolute path of its index.ts barrel. - * Returns null if no barrel is found. */ function resolveBarrelPath(packagesRoot, importSource) { - // Try direct: packages//index.ts const candidates = [ path.join(packagesRoot, importSource, 'index.ts'), path.join(packagesRoot, importSource, 'index.js'), @@ -95,32 +160,19 @@ function resolveBarrelPath(packagesRoot, importSource) { } /** - * Given a relative source from a barrel (e.g. './lib/renderer') and - * the barrel's package specifier (e.g. '@ember/-internals/glimmer'), - * compute the full package import path. + * Convert a barrel-relative source to a full package import path. */ function toPackagePath(barrelPackage, relativeSource) { - // './lib/renderer' -> '@ember/-internals/glimmer/lib/renderer' - // '../foo' -> would be outside package, skip if (!relativeSource.startsWith('./')) return null; return barrelPackage + '/' + relativeSource.slice(2); } /** - * Check if an import specifier looks like a barrel import. - * A barrel import is one that points to a package entrypoint (index.ts) - * rather than a specific source file. - * - * Heuristic: if the specifier matches a known package directory that - * contains an index.ts, it's a barrel import. + * Check if an import specifier resolves to a barrel index.ts. */ function isBarrelImport(packagesRoot, importSource) { - // Must start with @ or a package name, not be a relative import if (importSource.startsWith('.') || importSource.startsWith('/')) return false; - - // Must be inside our packages/ directory - const barrelPath = resolveBarrelPath(packagesRoot, importSource); - return barrelPath !== null; + return resolveBarrelPath(packagesRoot, importSource) !== null; } /** @type {import('eslint').Rule.RuleModule} */ @@ -134,27 +186,19 @@ module.exports = { }, messages: { noBarrelImport: - "Do not import from the barrel '{{source}}'. Import directly from the source file instead.", + "Do not import from the barrel '{{source}}'. Import from {{suggestion}} instead.", noBarrelImportNoFix: - "Do not import from the barrel '{{source}}'. Could not auto-fix: {{reason}}", + "Do not import from the barrel '{{source}}'. Could not determine source file for: {{names}}. Manually import from the specific file.", }, - schema: [], // no options + schema: [], }, create(context) { const filename = context.filename || context.getFilename(); - // Only apply to files inside packages/ - if (!filename.includes('/packages/')) { - return {}; - } - - // Skip test and type-test files - if (/\/(tests|type-tests)\//.test(filename)) { - return {}; - } + if (!filename.includes('/packages/')) return {}; + if (/\/(tests|type-tests)\//.test(filename)) return {}; - // Find the packages root const packagesIdx = filename.indexOf('/packages/'); if (packagesIdx === -1) return {}; const packagesRoot = filename.substring(0, packagesIdx) + '/packages'; @@ -162,11 +206,14 @@ module.exports = { function check(node) { const source = node.source && node.source.value; if (typeof source !== 'string') return; - - // Check if this is a barrel import if (!isBarrelImport(packagesRoot, source)) return; - // Get the imported names from the AST node + const barrelPath = resolveBarrelPath(packagesRoot, source); + if (!barrelPath) return; + + const exportMap = parseBarrelExports(barrelPath); + + // Collect imported names from the AST const importedNames = []; if (node.type === 'ImportDeclaration') { @@ -178,13 +225,41 @@ module.exports = { isType: spec.importKind === 'type', }); } else if (spec.type === 'ImportDefaultSpecifier') { - importedNames.push({ - imported: 'default', - local: spec.local.name, - isType: false, - }); + importedNames.push({ imported: 'default', local: spec.local.name, isType: false }); } - // ImportNamespaceSpecifier (import *) — can't auto-fix + // ImportNamespaceSpecifier (import *) handled below + } + + // import * as Foo — can't split, but can suggest the source + const nsStar = (node.specifiers || []).find((s) => s.type === 'ImportNamespaceSpecifier'); + if (nsStar) { + // If the barrel only re-exports from one source, we can fix it + const sources = new Set(exportMap.values()); + if (sources.size === 1) { + const [relSource] = sources; + const pkgPath = toPackagePath(source, relSource); + if (pkgPath) { + context.report({ + node, + messageId: 'noBarrelImport', + data: { source, suggestion: `'${pkgPath}'` }, + fix(fixer) { + return fixer.replaceText( + node.source, + `'${pkgPath}'` + ); + }, + }); + return; + } + } + // Multi-source barrel with import * — can't auto-fix + context.report({ + node: node.source, + messageId: 'noBarrelImportNoFix', + data: { source, names: `* (namespace import)` }, + }); + return; } } else if (node.type === 'ExportNamedDeclaration') { for (const spec of node.specifiers || []) { @@ -194,38 +269,68 @@ module.exports = { isType: spec.exportKind === 'type', }); } - } - // ExportAllDeclaration — can't auto-fix, just report - - if (node.type === 'ExportAllDeclaration' || importedNames.length === 0) { - context.report({ - node: node.source, - messageId: 'noBarrelImportNoFix', - data: { - source, - reason: node.type === 'ExportAllDeclaration' - ? 'export * cannot be auto-fixed' - : 'no named imports to trace', - }, - }); + } else if (node.type === 'ExportAllDeclaration') { + // export * from 'barrel' — rewrite to the barrel's source + const sources = new Set(exportMap.values()); + if (sources.size === 1) { + const [relSource] = sources; + const pkgPath = toPackagePath(source, relSource); + if (pkgPath) { + context.report({ + node, + messageId: 'noBarrelImport', + data: { source, suggestion: `'${pkgPath}'` }, + fix(fixer) { + return fixer.replaceText(node.source, `'${pkgPath}'`); + }, + }); + return; + } + } + // Multi-source barrel — expand export * to named exports + const bySource = new Map(); + for (const [name, relSource] of exportMap) { + const pkgPath = toPackagePath(source, relSource); + if (!pkgPath) continue; + if (!bySource.has(pkgPath)) bySource.set(pkgPath, []); + bySource.get(pkgPath).push(name); + } + if (bySource.size > 0) { + const suggestion = [...bySource.keys()].map((p) => `'${p}'`).join(', '); + context.report({ + node, + messageId: 'noBarrelImport', + data: { source, suggestion }, + fix(fixer) { + const statements = []; + for (const [pkgPath, names] of bySource) { + statements.push(`export { ${names.join(', ')} } from '${pkgPath}';`); + } + return fixer.replaceText(node, statements.join('\n')); + }, + }); + } else { + context.report({ + node: node.source, + messageId: 'noBarrelImportNoFix', + data: { source, names: '* (could not resolve exports)' }, + }); + } return; } - // Resolve the barrel and trace exports - const barrelPath = resolveBarrelPath(packagesRoot, source); - if (!barrelPath) { + if (importedNames.length === 0) { + // Side-effect import: import 'barrel' context.report({ node: node.source, - messageId: 'noBarrelImport', - data: { source }, + messageId: 'noBarrelImportNoFix', + data: { source, names: '(side-effect import)' }, }); return; } - const exportMap = parseBarrelExports(barrelPath); - - // Group imported names by their source file - const bySource = new Map(); // sourcePath -> [{imported, local, isType}] + // Group imported names by source file + const bySource = new Map(); const unfixed = []; for (const entry of importedNames) { @@ -234,35 +339,31 @@ module.exports = { unfixed.push(entry.imported); continue; } - const pkgPath = toPackagePath(source, relSource); if (!pkgPath) { unfixed.push(entry.imported); continue; } - if (!bySource.has(pkgPath)) bySource.set(pkgPath, []); bySource.get(pkgPath).push(entry); } - if (unfixed.length > 0 || bySource.size === 0) { - // Can't fully auto-fix — report without fix + if (unfixed.length > 0) { context.report({ node: node.source, messageId: 'noBarrelImportNoFix', - data: { - source, - reason: `could not trace: ${unfixed.join(', ')}`, - }, + data: { source, names: unfixed.join(', ') }, }); return; } - // Build the replacement import(s) + // Build suggestion string for the error message + const suggestion = [...bySource.keys()].map((p) => `'${p}'`).join(', '); + context.report({ node, messageId: 'noBarrelImport', - data: { source }, + data: { source, suggestion }, fix(fixer) { const isExport = node.type === 'ExportNamedDeclaration'; const keyword = isExport ? 'export' : 'import'; @@ -272,17 +373,13 @@ module.exports = { for (const [pkgPath, entries] of bySource) { const specifiers = entries.map((e) => { const typeStr = e.isType && !typePrefix ? 'type ' : ''; - if (e.imported === e.local) { - return `${typeStr}${e.imported}`; - } + if (e.imported === e.local) return `${typeStr}${e.imported}`; return `${typeStr}${e.imported} as ${e.local}`; }); - statements.push( `${keyword}${typePrefix} { ${specifiers.join(', ')} } from '${pkgPath}';` ); } - return fixer.replaceText(node, statements.join('\n')); }, }); From 714c761984c50dde5425dd6948cac4a1d8d615d4 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Sat, 4 Apr 2026 10:10:38 -0400 Subject: [PATCH 4/4] Only flag actual barrels, not standalone modules with index.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A barrel is a file that re-exports from other files (has export { } from or export * from). A standalone module that defines its own exports (like @ember/template-compilation which defines precompileTemplate directly) is not a barrel and should not be flagged. 979 errors (down from 1298 — 319 false positives eliminated). Co-Authored-By: Claude Opus 4.6 (1M context) --- eslint/rules/no-barrel-imports.js | 35 ++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/eslint/rules/no-barrel-imports.js b/eslint/rules/no-barrel-imports.js index 2a3c51ec79c..875f5a6becc 100644 --- a/eslint/rules/no-barrel-imports.js +++ b/eslint/rules/no-barrel-imports.js @@ -167,12 +167,45 @@ function toPackagePath(barrelPackage, relativeSource) { return barrelPackage + '/' + relativeSource.slice(2); } +// Cache for isBarrel check +const isBarrelCache = new Map(); + +/** + * Check if a file is a barrel (has re-exports from other files) vs. + * a standalone module (defines its own exports). + * + * A barrel contains `export { ... } from '...'` or `export * from '...'`. + * A standalone module only has `export const/function/class/default`. + */ +function isBarrelFile(filePath) { + if (isBarrelCache.has(filePath)) return isBarrelCache.get(filePath); + + let content; + try { + content = fs.readFileSync(filePath, 'utf8'); + } catch { + isBarrelCache.set(filePath, false); + return false; + } + + // Has re-exports? (export { ... } from '...' or export * from '...') + const hasReExports = /export\s+(?:type\s+)?{[^}]+}\s+from\s+['"]/.test(content) || + /export\s+\*\s+from\s+['"]/.test(content); + + isBarrelCache.set(filePath, hasReExports); + return hasReExports; +} + /** * Check if an import specifier resolves to a barrel index.ts. */ function isBarrelImport(packagesRoot, importSource) { if (importSource.startsWith('.') || importSource.startsWith('/')) return false; - return resolveBarrelPath(packagesRoot, importSource) !== null; + + const barrelPath = resolveBarrelPath(packagesRoot, importSource); + if (!barrelPath) return false; + + return isBarrelFile(barrelPath); } /** @type {import('eslint').Rule.RuleModule} */