diff --git a/README.md b/README.md index 7bf5f4f..e3ce027 100644 --- a/README.md +++ b/README.md @@ -193,7 +193,7 @@ export default tseslint.config( ); ``` -`plugin.configs.recommended`/`plugin.configs.barrel` carry no `files`/`ignores` and are safe unscoped — `no-side-effects-in-index` and `no-non-barrel-reexport` each check `context.filename` themselves (self-scoping). For a barrel not at `src/index.ts`, or a project-specific exception, layer an override on top (e.g. `{ files: ['lib/other.ts'], rules: { 'exadev/no-non-barrel-reexport': 'off' } }`) rather than wiring all four rules individually. +`plugin.configs.recommended`/`plugin.configs.barrel` carry no `files`/`ignores` and are safe unscoped — `no-side-effects-in-index` and `no-non-barrel-reexport` each check `context.filename` themselves (self-scoping). For a barrel not at `src/index.ts`, or a project-specific exception, layer an override on top (e.g. `{ files: ['lib/other.ts'], rules: { 'exadev/no-non-barrel-reexport': 'off' } }`) rather than wiring all four rules individually. A package that publishes several entry points declares them through `exadev/barrel-policy`'s `entries` option instead (see [Package entry points](#package-entry-points)), which keeps each entry held to the barrel rules rather than switching them off. ## Optional features @@ -376,7 +376,7 @@ Bundled into `exadevConfig()`'s default output the same way React/Next.js auto-d | Rule | Fixable | Description | | --- | --- | --- | -| `barrel-policy` | | **Umbrella rule selecting a whole index-file barrel policy.** One `{ mode }` option covers the four barrel rules below. See [Barrel policy](#barrel-policy). | +| `barrel-policy` | | **Umbrella rule selecting a whole index-file barrel policy.** One `{ mode }` option covers the four barrel rules below, and an `entries` option declares a package's other entry files as barrels. See [Barrel policy](#barrel-policy). | | `no-index-files` | | **Bans any `index.*` file outright** (mode 1). The strictest policy. | | `no-non-barrel-index` | | **Only `src/index.ts` may be named `index.*`** — any other `index.ts`/`.js`/etc would be silently selected by a consumer's bare directory import. | | `no-non-barrel-reexport` | ✓ | **Re-exports belong only in a barrel.** Catches the split form across two statements (`import { x } from './y'; export { x };` or `export default x;`) which no AST selector alone can match. Autofix deletes the export and the now-pointless import when it was the import's only use. Self-scopes away from any index file. | @@ -465,6 +465,33 @@ Notes on `'auto'`: In every mode, re-exports are banned outside a permitted barrel, and a permitted barrel may contain only re-export statements. The umbrella composes the identical predicates the standalone rules use (shared in [`src/rules/barrel-helpers.ts`](src/rules/barrel-helpers.ts)). It is non-fixable — the autofix lives on `no-non-barrel-reexport`. +### Package entry points + +A package that publishes subpath exports (`"./layout"`, `"./store/node"`) has a module behind each one, and that module is an entry point exactly as `src/index.ts` is: its job is to re-export a surface, usually another package's. `single` treats only `src/index.ts` as a barrel and `siblings` only accepts re-exports from a direct sibling, so neither mode can describe such a package, and turning the rule off for the entry files would also stop checking that they hold nothing but re-exports. The `entries` option lists them instead: + +```ts +export default defineConfig(...exadevConfig(), { + rules: { + 'exadev/barrel-policy': ['error', { mode: 'single', entries: ['src/layout.ts', 'src/store/*.ts', 'src/*/index.ts'] }], + }, +}); +``` + +`entries` is a list of globs in the [file glob form](#file-level-rules), relative to the directory of the `package.json` nearest the linted file rather than to ESLint's working directory, so one shared config serves every package in a workspace. A glob is matched as written, with no widening to any depth: `src/*.ts` selects the files directly in `src`, and `src/*/index.ts` one level of index files below it. A leading `!` excludes. A file with no `package.json` above it is no package's entry point. + +A matched file is a permitted barrel in the mode's terms: + +| `mode` | What an entry file may contain | Where its re-exports may come from | +| --- | --- | --- | +| `'single'` (and `'auto'` resolved to `single`) | only re-exports | anywhere, as for `src/index.ts` | +| `'siblings'` | only re-exports | a package (`pkg`, `@scope/pkg/sub`, `node:fs`) or a direct sibling (`./module`) | + +An entry file that is an index file below `src` is not reported as a second index file in `single` mode. Every file the list does not match keeps the behaviour its mode gives it: a re-export from it is still reported, an index file it does not match is still reported in `single` mode, and a non-entry index file in `siblings` mode still re-exports only from its siblings. In `siblings` mode a relative path that is not a sibling (`../shared`, `./a/b`) and a subpath import (`#internal`, which resolves to a file of this package) are reported from an entry file too. + +`entries` beside `mode: 'banned'` is an error when the rule is created, since that mode permits no barrel. Under `'auto'`, a package with neither `exports` nor `main` resolves to `banned` and publishes no entry point, so the list selects nothing there. + +Why a list rather than reading `exports`: an `exports` target names the built file (`./dist/layout.js`), and which source file produced it depends on the build tool's configuration (tsdown or tsup entries, `tsc`'s `rootDir` and `outDir`, a bundler's input map), which a lint rule cannot read. Deriving the entries would therefore guess, and a wrong guess either reports a real entry point or lets an ordinary module re-export freely; the list states them exactly. + ## File-level rules `required-exports`, `required-imports`, `import-policy` and `filename-pattern` check one file against a convention that depends on its path. Each self-scopes through `context.filename`, so it is a no-op on every other file and can sit in a shared config without a `files` array of its own. None needs type information, and none is in `recommended`, since each takes options only a project can supply. diff --git a/src/config-types.ts b/src/config-types.ts index 0890b70..bb197d1 100644 --- a/src/config-types.ts +++ b/src/config-types.ts @@ -17,15 +17,20 @@ export type ConfigArrayValue = Extract; export type PublicConfigArray = ESLintCoreConfig[]; /** - * What each of PublicPlugin's four bundled configs actually resolves to at runtime (src/plugin.ts's own getters): `recommended`/`barrel` are each a single flat config object; `react`/`nextjs` are each an array, since building React/Next.js support composes more than one upstream config block (src/react.ts/src/nextjs.ts). Never a LegacyConfigObject: `@eslint/core`'s own `Plugin['configs']` allows one, but none of the four getters ever return one, so the public type doesn't need to describe one either. + * What `recommended` and `barrel` (src/plugin.ts's own getters) resolve to at runtime: each a single flat config object. Never a LegacyConfigObject: `@eslint/core`'s own `Plugin['configs']` allows one, but neither getter ever returns one, so the public type doesn't need to describe one either. */ -export type PublicConfigObject = ESLintCoreConfigObject | ESLintCoreConfigObject[]; +export type PublicConfigObject = ESLintCoreConfigObject; + +/** + * What `react` and `nextjs` resolve to at runtime: each an array, since building React/Next.js support composes more than one upstream config block (src/react.ts/src/nextjs.ts). Typed as the array it is, so a consumer can spread it (`defineConfig(...plugin.configs.react)`) as well as pass it to `extends`. + */ +export type PublicConfigObjectArray = ESLintCoreConfigObject[]; /** * Derived from ESLintCoreConfigObject's own `plugins` field (a real library type, not a hand-written interface), matching the derivation style ConfigValue above already uses. The named `plugin` export (this package's public entry point for a consumer wiring `plugins: { exadev: plugin }` straight into `defineConfig()`) needs `@eslint/core`'s own Plugin shape for the same reason PublicConfigArray needs ESLintCoreConfig: `plugin`'s internal build (src/plugin.ts) is typed against `@typescript-eslint/utils`' `TSESLint.FlatConfig.Plugin`, whose `configs` field (`Record`) is not assignable to `@eslint/core`'s own `Record` under `exactOptionalPropertyTypes: true` (confirmed directly: a bare `defineConfig({ plugins: { exadev: plugin } })` fails TS2375 on exactly this property before the cast in src/to-public-plugin.ts is applied). `@eslint/core` itself is deliberately not a direct import here, the same way PublicConfigArray avoids one for Config: indexing through `eslint/config`'s own re-exported `ConfigObject` keeps this package's peer surface at just `eslint`, which every consumer already has. * - * PublicPlugin further narrows Plugin's own `configs` field down to the shape README.md actually documents: exactly the four literal keys src/plugin.ts's own object declares, each typed as PublicConfigObject above, in place of Plugin's own `configs?: Record | undefined`. Without this narrowing, `plugin.configs.recommended` fails three ways at once under a plain `strict: true` consumer tsconfig plus this repo's own `noPropertyAccessFromIndexSignature`: TS4111 (Plugin['configs'] has no literal keys, only an index signature, so dot access is rejected), TS18048 even after switching to index access (`configs` itself is optional on the un-narrowed type), and TS2322 wherever the result is then used as an `extends` element (LegacyConfigObject's own `plugins` field is `string[]`, not the `Record` an `ExtendsElement`/`InfiniteArray` requires): confirmed directly against `tseslint.config({ extends: [plugin.configs.recommended] })` and the equivalent `defineConfig()` React/Next.js examples (README.md's own "lighter option" and "Optional features" sections; see src/readme-examples.ts). + * PublicPlugin further narrows Plugin's own `configs` field down to the shape README.md actually documents: exactly the four literal keys src/plugin.ts's own object declares, each typed as what its getter returns (PublicConfigObject or PublicConfigObjectArray above), in place of Plugin's own `configs?: Record | undefined`. Without this narrowing, `plugin.configs.recommended` fails three ways at once under a plain `strict: true` consumer tsconfig plus this repo's own `noPropertyAccessFromIndexSignature`: TS4111 (Plugin['configs'] has no literal keys, only an index signature, so dot access is rejected), TS18048 even after switching to index access (`configs` itself is optional on the un-narrowed type), and TS2322 wherever the result is then used as an `extends` element (LegacyConfigObject's own `plugins` field is `string[]`, not the `Record` an `ExtendsElement`/`InfiniteArray` requires): confirmed directly against `tseslint.config({ extends: [plugin.configs.recommended] })` and the equivalent `defineConfig()` React/Next.js examples (README.md's own "lighter option" and "Optional features" sections; see src/readme-examples.ts). */ export type PublicPlugin = Omit[string], 'configs'> & { - readonly configs: Readonly>; + readonly configs: Readonly & Record<'react' | 'nextjs', PublicConfigObjectArray>>; }; diff --git a/src/consumer-compatibility.ts b/src/consumer-compatibility.ts index 78c1f8c..2ddbfd7 100644 --- a/src/consumer-compatibility.ts +++ b/src/consumer-compatibility.ts @@ -1,6 +1,7 @@ -import { defineConfig } from 'eslint/config'; +import { defineConfig, type Config } from 'eslint/config'; import tseslint from 'typescript-eslint'; import { defaultConfig, exadevConfig } from './create-config'; +import { publicPlugin } from './plugin'; /** * Not imported by index.ts (see tsdown.config.ts's entry -- only src/index.ts is bundled), so this file contributes nothing to the published package. Its only job is to be included in `tsc -p tsconfig.json` (see tsconfig.json's own `include`), so `pnpm typecheck` fails the moment either consumption pattern below stops compiling -- a real regression test for PublicConfigArray's own compatibility claim (see config-types.ts), not just a comment asserting it. @@ -9,3 +10,10 @@ import { defaultConfig, exadevConfig } from './create-config'; export const viaTseslintConfig = tseslint.config(...defaultConfig); export const viaDefineConfig = defineConfig(defaultConfig); export const viaDefineConfigSpread = defineConfig(...exadevConfig()); + +/** + * Spreads `plugin.configs.react` and `plugin.configs.nextjs` into `defineConfig()`, which the types allow only while they describe the arrays the getters return (a single-object type made this TS2488, ExaDev/eslint-config#134). A function rather than a constant so the getters, which resolve the optional React and Next.js peers, run only when a test calls it. + */ +export function viaSpreadOptionalConfigs(): Config[] { + return defineConfig(...publicPlugin.configs.react, ...publicPlugin.configs.nextjs); +} diff --git a/src/consumer-compatibility.unit.test.ts b/src/consumer-compatibility.unit.test.ts index e622092..35e6b86 100644 --- a/src/consumer-compatibility.unit.test.ts +++ b/src/consumer-compatibility.unit.test.ts @@ -3,7 +3,8 @@ import type { Linter } from 'eslint'; import { Linter as LinterClass } from 'eslint'; import { describe, expect, it } from 'vitest'; import { exadevConfig } from './create-config'; -import { viaDefineConfig, viaDefineConfigSpread, viaTseslintConfig } from './consumer-compatibility'; +import { viaDefineConfig, viaDefineConfigSpread, viaSpreadOptionalConfigs, viaTseslintConfig } from './consumer-compatibility'; +import { publicPlugin } from './plugin'; // This file's job is proving the documented consumer patterns keep working at runtime, not just typecheck — consumer-compatibility.ts is deliberately excluded from the published bundle (see its own comment), so nothing else in this package ever imports it. describe('consumer compatibility patterns', () => { @@ -21,6 +22,24 @@ describe('consumer compatibility patterns', () => { expect(Array.isArray(viaDefineConfigSpread)).toBe(true); expect(viaDefineConfigSpread.length).toBeGreaterThan(0); }); + + it('defineConfig(...plugin.configs.react, ...plugin.configs.nextjs) spreads the blocks both getters return', () => { + // This repository installs the React and Next.js peers as dev dependencies, so both getters resolve and each contributes at least one block. + expect(viaSpreadOptionalConfigs().length).toBeGreaterThanOrEqual(publicPlugin.configs.react.length + publicPlugin.configs.nextjs.length); + expect(publicPlugin.configs.react.length).toBeGreaterThan(0); + expect(publicPlugin.configs.nextjs.length).toBeGreaterThan(0); + }); + + it('types plugin.configs.recommended and plugin.configs.barrel as single config objects, as their getters return them', () => { + expect(Array.isArray(publicPlugin.configs.recommended)).toBe(false); + expect(Array.isArray(publicPlugin.configs.barrel)).toBe(false); + expect(() => { + // @ts-expect-error: a single config object is not iterable, so the type must reject spreading it. + const spread: unknown[] = [...publicPlugin.configs.recommended]; + + return spread; + }).toThrow(TypeError); + }); }); // The README's own @stylistic migration note, pinned by a real Linter run: the bundled config registers @stylistic/eslint-plugin under the '@stylistic' key itself (stylistic-comments.ts), so a consumer appending a block that registers a DIFFERENT copy of the plugin (a separate install of another version resolving its own module instance; a shallow copy of the real one reproduces exactly that distinct identity) hard-crashes ESLint's flat-config plugin normalisation, the identical "Cannot redefine plugin" restriction the README's typescript-eslint note documents. The identical instance is accepted, which is what makes "configure further @stylistic/* rules directly in your own rules block" a real migration path rather than a workaround. A spread-out copy of a plugin object is the standard stand-in for a second install: flat config compares plugin identity by reference, so only the reference differs, exactly as it does for a duplicated package. diff --git a/src/plugin.ts b/src/plugin.ts index f5634bb..75a3140 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -1,5 +1,5 @@ import type { TSESLint } from '@typescript-eslint/utils'; -import type { ConfigValue, PublicPlugin } from './config-types'; +import type { ConfigArrayValue, ConfigValue, PublicPlugin } from './config-types'; import { version } from '../package.json'; import { buildNextjsConfig } from './nextjs'; import { buildReactConfig } from './react'; @@ -183,11 +183,11 @@ const plugin: TSESLint.FlatConfig.Plugin = { }; }, // Explicitly selecting this config is itself an explicit request for React support — unlike recommended/barrel above, which only ever reference this package's own always-present rules and can never fail, `enabled: true` here means a missing eslint-plugin-react throws a clear, actionable error rather than silently returning nothing (see src/react.ts). A consumer who wants silent auto-detection instead uses the exadevConfig() factory (src/create-config.ts), which threads the same tri-state through without ever forcing the choice. - get react(): ConfigValue { + get react(): ConfigArrayValue { return buildReactConfig({ enabled: true }); }, // Mirrors `react` above — explicit selection, throws if @next/eslint-plugin-next isn't installed (see src/nextjs.ts). - get nextjs(): ConfigValue { + get nextjs(): ConfigArrayValue { return buildNextjsConfig({ enabled: true, plugin }); }, }, diff --git a/src/rules/barrel-auto-detect.ts b/src/rules/barrel-auto-detect.ts index 6743f36..7b57003 100644 --- a/src/rules/barrel-auto-detect.ts +++ b/src/rules/barrel-auto-detect.ts @@ -22,29 +22,32 @@ export function decideAutoBarrelMode(packageJson: Record): 'ban export type ReadPackageJsonFn = (startDir: string) => Record | undefined; -// Keyed by the directory the walk started from, not the package.json it eventually found — every file under the same package's src/** starts its own walk from a different directory but ends at the identical manifest, so caching by start directory still collapses the common case (many files, one package) without needing to track which starting directories share a destination. `undefined` is a genuine cached outcome, for either of two distinct causes findNearestPackageJson below collapses to the same result: no ancestor package.json at all, or an ancestor package.json that parses to something other than a usable JSON object (an array, a string, null). So presence is checked with `has`, not a nullish read. +/** + * Resolves the directory of the package a file belongs to: the nearest ancestor of `startDir` (inclusive) that holds a `package.json`, or `undefined` when no ancestor does. barrel-policy matches its `entries` globs against a file's path relative to this directory. + */ +export type FindPackageRootFn = (startDir: string) => string | undefined; + +// Both caches are keyed by the directory the walk started from, not the package.json it eventually found: every file under the same package's src/** starts its own walk from a different directory but ends at the identical manifest, so caching by start directory still collapses the common case (many files, one package) without needing to track which starting directories share a destination. `undefined` is a genuine cached outcome in each (no ancestor package.json at all; and, for the manifest cache, also an ancestor package.json that parses to something other than a usable JSON object, an array, a string or null), so presence is checked with `has`, not a nullish read. +const packageRootByStartDir = new Map(); const packageJsonByStartDir = new Map | undefined>(); /** - * The real, filesystem-backed ReadPackageJsonFn: walks upward from startDir to the nearest ancestor package.json (inclusive of startDir itself), parses it, and returns undefined once the walk reaches the filesystem root with nothing found. A found manifest that is not a usable JSON object (isRecord rejects an array as well as a string, number, boolean or null) collapses to the same undefined, matching workspace-graph.ts's identical readDeclaredManifest: this rule has nothing to read `exports`/`main` off an array-shaped or scalar manifest any more than off a wholly absent one, so the two are indistinguishable to decideAutoBarrelMode's caller. This is the production default threaded through resolveAutoMode/createBarrelPolicyRule below; tests inject a stub instead so they never touch the real filesystem. + * The real, filesystem-backed FindPackageRootFn: walks upward from startDir to the nearest directory holding a package.json (startDir itself included) and returns that directory, or undefined once the walk reaches the filesystem root with nothing found. Only the file's existence is checked, so a manifest that does not parse still marks its directory as the package root. */ -export function findNearestPackageJson(startDir: string): Record | undefined { - const cached = packageJsonByStartDir.get(startDir); - if (packageJsonByStartDir.has(startDir)) return cached; +export function findNearestPackageRoot(startDir: string): string | undefined { + const cached = packageRootByStartDir.get(startDir); + if (packageRootByStartDir.has(startDir)) return cached; let dir = startDir; for (;;) { - const candidate = join(dir, 'package.json'); - if (existsSync(candidate)) { - const parsed: unknown = JSON.parse(readFileSync(candidate, 'utf8')); - const result = isRecord(parsed) ? parsed : undefined; - packageJsonByStartDir.set(startDir, result); + if (existsSync(join(dir, 'package.json'))) { + packageRootByStartDir.set(startDir, dir); - return result; + return dir; } const parent = dirname(dir); if (parent === dir) { - packageJsonByStartDir.set(startDir, undefined); + packageRootByStartDir.set(startDir, undefined); return undefined; } @@ -52,6 +55,21 @@ export function findNearestPackageJson(startDir: string): Record | undefined { + const cached = packageJsonByStartDir.get(startDir); + if (packageJsonByStartDir.has(startDir)) return cached; + + const root = findNearestPackageRoot(startDir); + const parsed: unknown = root === undefined ? undefined : JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')); + const result = isRecord(parsed) ? parsed : undefined; + packageJsonByStartDir.set(startDir, result); + + return result; +} + /** * filename is a file path (e.g. context.filename); the walk starts from its containing directory, not from filename itself. No ancestor package.json (a file outside any real package) resolves to 'banned' — the same conservative default as a package with neither exports nor main. */ diff --git a/src/rules/barrel-auto-detect.unit.test.ts b/src/rules/barrel-auto-detect.unit.test.ts index f7f3766..d49df99 100644 --- a/src/rules/barrel-auto-detect.unit.test.ts +++ b/src/rules/barrel-auto-detect.unit.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; -import { decideAutoBarrelMode, findNearestPackageJson, resolveAutoMode } from './barrel-auto-detect'; +import { decideAutoBarrelMode, findNearestPackageJson, findNearestPackageRoot, resolveAutoMode } from './barrel-auto-detect'; describe('decideAutoBarrelMode', () => { it('resolves single for a non-empty object exports field', () => { @@ -150,3 +150,65 @@ describe('findNearestPackageJson', () => { expect(findNearestPackageJson(deepDir)).toBeUndefined(); }); }); + +describe('findNearestPackageRoot', () => { + let root: string; + + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + }); + + it('returns the nearest ancestor directory holding a package.json, not a more distant one', () => { + root = mkdtempSync(join(tmpdir(), 'barrel-package-root-nearest-')); + const packageDir = join(root, 'packages', 'app'); + const startDir = join(packageDir, 'src', 'store'); + mkdirSync(startDir, { recursive: true }); + writeFileSync(join(root, 'package.json'), JSON.stringify({ name: 'workspace' })); + writeFileSync(join(packageDir, 'package.json'), JSON.stringify({ name: 'app' })); + + expect(findNearestPackageRoot(startDir)).toBe(packageDir); + }); + + it('includes the start directory itself', () => { + root = mkdtempSync(join(tmpdir(), 'barrel-package-root-self-')); + writeFileSync(join(root, 'package.json'), JSON.stringify({ name: 'self' })); + + expect(findNearestPackageRoot(root)).toBe(root); + }); + + it('returns the directory of a package.json whose content is not a usable manifest, since only its existence marks the package', () => { + root = mkdtempSync(join(tmpdir(), 'barrel-package-root-array-')); + const startDir = join(root, 'src'); + mkdirSync(startDir, { recursive: true }); + writeFileSync(join(root, 'package.json'), JSON.stringify(['not', 'an', 'object'])); + + expect(findNearestPackageRoot(startDir)).toBe(root); + }); + + it('returns undefined once the walk reaches the filesystem root with no ancestor package.json', () => { + // tmpdir()'s ancestry owns no package.json up to "/", as the findNearestPackageJson case above relies on too. + root = mkdtempSync(join(tmpdir(), 'barrel-package-root-none-')); + const deepDir = join(root, 'a', 'b'); + mkdirSync(deepDir, { recursive: true }); + + expect(findNearestPackageRoot(deepDir)).toBeUndefined(); + }); + + it('caches both outcomes by start directory, never walking again for a directory it has already resolved', () => { + root = mkdtempSync(join(tmpdir(), 'barrel-package-root-cache-')); + const found = join(root, 'found', 'src'); + const missing = join(root, 'missing'); + mkdirSync(found, { recursive: true }); + mkdirSync(missing, { recursive: true }); + writeFileSync(join(root, 'found', 'package.json'), JSON.stringify({ name: 'found' })); + + expect(findNearestPackageRoot(found)).toBe(join(root, 'found')); + expect(findNearestPackageRoot(missing)).toBeUndefined(); + + // Each change below would alter a fresh walk's answer, so an unchanged answer can only have come from the cache. + writeFileSync(join(found, 'package.json'), JSON.stringify({ name: 'closer' })); + writeFileSync(join(missing, 'package.json'), JSON.stringify({ name: 'appeared' })); + expect(findNearestPackageRoot(found)).toBe(join(root, 'found')); + expect(findNearestPackageRoot(missing)).toBeUndefined(); + }); +}); diff --git a/src/rules/barrel-helpers.ts b/src/rules/barrel-helpers.ts index 5c784e4..0b544d3 100644 --- a/src/rules/barrel-helpers.ts +++ b/src/rules/barrel-helpers.ts @@ -65,6 +65,16 @@ export function isDirectSibling(specifier: string): boolean { return rest !== '.' && rest !== '..' && rest !== '' && !rest.includes('/'); } +/** + * True when a module specifier names a package rather than a file of this one: a bare specifier (`pkg`, `pkg/sub`, `@scope/pkg/sub`) or a Node builtin (`fs`, `node:fs`). Rejects a relative path (`./x`, `../x`, `.`), an absolute path (`/x`), a subpath import (`#x`, which resolves through this package's own `imports` map to one of its own files) and any other URL scheme (`file:`, `https:`, and a Windows drive letter, which has the same shape). + */ +export function isPackageSpecifier(specifier: string): boolean { + if (specifier.startsWith('.') || specifier.startsWith('/') || specifier.startsWith('#')) return false; + const scheme = /^[a-z][a-z\d+.-]*:/i.exec(specifier); + + return scheme === null || scheme[0] === 'node:'; +} + /** * Runtime type guard narrowing ESLint's `any`-typed context.options entry to BarrelMode without an assertion. ESLint validates the enum in a rule's meta.schema before the rule runs, so a well-configured caller never reaches the false branch; the guard exists to satisfy the type-aware lint rules (no-unsafe-argument) that a plain comparison against `any` does not, since comparing `any` to a string literal leaves the value typed as `any` rather than narrowing it. */ diff --git a/src/rules/barrel-helpers.unit.test.ts b/src/rules/barrel-helpers.unit.test.ts index db16216..f9a311b 100644 --- a/src/rules/barrel-helpers.unit.test.ts +++ b/src/rules/barrel-helpers.unit.test.ts @@ -9,6 +9,7 @@ import { isDirectSibling, isIndexFile, isInsideAmbientModuleDeclaration, + isPackageSpecifier, isPermittedBarrel, isPureReexport, moduleSpecifierValue, @@ -86,6 +87,21 @@ describe('isDirectSibling', () => { }); }); +describe('isPackageSpecifier', () => { + it.each(['pkg', 'pkg/sub', '@scope/pkg', '@scope/pkg/sub/path', 'fs', 'node:fs', 'node:fs/promises', 'pkg.js'])('accepts the bare or builtin specifier %s', (specifier) => { + expect(isPackageSpecifier(specifier)).toBe(true); + }); + + it.each(['./sibling', '../up', '.', '..', '/abs/path', '#internal', '#internal/sub', 'file:///x.js', 'https://example.com/x.js', 'C:/x.js', 'data:text/javascript,1'])('rejects %s, which names no package', (specifier) => { + expect(isPackageSpecifier(specifier)).toBe(false); + }); + + it('reads a colon after a path segment as part of a package subpath, not as a URL scheme', () => { + // The scheme pattern is anchored to the start and allows only scheme characters before the colon, so a colon further in (after a slash) is not a scheme. + expect(isPackageSpecifier('pkg/a:b')).toBe(true); + }); +}); + describe('isPermittedBarrel', () => { it("'banned' mode permits no file, even a real index file", () => { expect(isPermittedBarrel('index.ts', 'banned')).toBe(false); diff --git a/src/rules/barrel-policy.ts b/src/rules/barrel-policy.ts index 99253cb..0c4b9f3 100644 --- a/src/rules/barrel-policy.ts +++ b/src/rules/barrel-policy.ts @@ -1,18 +1,21 @@ +import { dirname } from 'node:path'; import type { Rule } from 'eslint'; -import { findNearestPackageJson, resolveAutoMode, type ReadPackageJsonFn } from './barrel-auto-detect'; +import { isRecord } from '../is-record'; +import { findNearestPackageJson, findNearestPackageRoot, resolveAutoMode, type FindPackageRootFn, type ReadPackageJsonFn } from './barrel-auto-detect'; import { createSplitReexportDetector, hasSource, isDirectSibling, isIndexFile, isInsideAmbientModuleDeclaration, - isMainBarrel, + isPackageSpecifier, isPermittedBarrel, isPureReexport, isRawBarrelMode, moduleSpecifierValue, type RawBarrelMode, } from './barrel-helpers'; +import { createPathMatcher, fileGlobsSchema, readFileGlobs, relativeToCwd } from './file-scope'; /** * Extracts and validates the `mode` option, defaulting to 'auto' both when the options array has no item at all and when an item is present but carries no `mode` key — either way there is nothing invalid here, just nothing stated, so this returns rather than throws. A standalone function (not inline in create) so ESLint's `any`-typed `context.options[0]` is funneled through an `unknown` parameter boundary — passing `any` into `unknown` is safe, whereas inline member access on `any` (`options.mode`) propagates `any` through every later use and trips the type-aware lint rules. Inside the function `options` is `unknown`, so the narrowing composes cleanly without an assertion. A `mode` key that IS present but not one of the four raw literals still throws — that is a genuine misconfiguration, not an omission. Exported so that throw — a safety net behind the rule's schema, which ESLint's own Linter/RuleTester already enforce before create() is ever called with invalid options — can be exercised directly rather than left permanently unreachable through real linting. @@ -30,6 +33,41 @@ export function readMode(options: unknown): RawBarrelMode { return options.mode; } +/** + * Extracts and validates the `entries` option: the package's declared entry files, as globs relative to the directory of the nearest `package.json`. Returns `undefined` when the options carry no `entries` key, since an absent list declares no entry files at all. Throws, naming the option, for a list `readFileGlobs` rejects (not an array of non-empty strings, a duplicate glob, no include, or a glob minimatch cannot compile), and for `entries` beside `mode: 'banned'`, which permits no barrel for an entry to be. Call it after readMode, which has already rejected an options value that is not an object. + */ +export function readEntries(options: unknown): readonly string[] | undefined { + if (!isRecord(options) || !('entries' in options)) return undefined; + if (options['mode'] === 'banned') { + throw new Error("exadev/barrel-policy: \"entries\" declares barrels, which mode 'banned' forbids; use 'single', 'siblings' or 'auto' with entries."); + } + + return readFileGlobs(options['entries'], 'exadev/barrel-policy entries'); +} + +/** + * The seams `createBarrelPolicyRule` resolves the linted file's package through, each defaulting to its filesystem-backed implementation in barrel-auto-detect.ts. `readPackageJson` supplies the manifest `'auto'` reads `exports`/`main` from; `findPackageRoot` supplies the directory `entries` globs are relative to. + */ +export interface BarrelPolicySeams { + readonly readPackageJson?: ReadPackageJsonFn; + readonly findPackageRoot?: FindPackageRootFn; +} + +/** + * The node a report is anchored on, derived from ESLint's own report descriptor rather than written by hand: a re-export statement, or the specifier or default export of a split-statement re-export, which as bare ESTree nodes carry no `.parent` and so are not `Rule.Node`. + */ +type ReportedNode = Extract[0], { node: unknown }>['node']; + +/** + * Whether `filename` matches one of the `entries` globs, matched against its forward-slash path relative to the directory of its nearest package.json. A file with no package.json above it belongs to no package, so it declares no entry point. + */ +function isDeclaredEntry(filename: string, entries: readonly string[], findPackageRoot: FindPackageRootFn): boolean { + const root = findPackageRoot(dirname(filename)); + if (root === undefined) return false; + + return createPathMatcher(entries)(relativeToCwd(filename, root)); +} + /** * The convenience layer over this package's barrel rules: one rule id, one `{ mode }` option selecting one of three complete index-file policies, so a consumer writes a single config entry instead of wiring several rules together. The three modes are the orthogonal combinations of "which files may be barrels", "what a barrel may contain", and "where a barrel's re-exports may come from": * @@ -37,18 +75,20 @@ export function readMode(options: unknown): RawBarrelMode { * * Implemented self-contained (its own visitor + message ids) over the shared predicates in barrel-helpers.ts, so the standalone rules and this umbrella share one source of truth for "what is an index file", "what is a pure re-export", "what is a direct sibling", and "what is a split-statement re-export" — no behavioural drift between the granular rules and the convenience one. A consumer uses EITHER this umbrella (one line, opinionated) OR the individual rules (full control, e.g. 'single' plus one extra cross-package re-export exception); not both, since they would double-report the same violations. * + * The `entries` option adds a package's own declared entry files to the permitted barrels of 'single' and 'siblings' (and of 'auto' where it resolves to 'single'): a package that publishes several subpath exports has one module behind each, and each such module is an entry point exactly as src/index.ts is. An entry file is held to the barrel contents rule (only re-exports). In 'single' its re-exports may come from anywhere, as src/index.ts's may; in 'siblings' each must name a package or a direct sibling, since an entry point's job is to publish another package's surface or its own directory's. Every file the list does not match keeps the behaviour its mode gives it. The entries are listed, not derived from package.json's `exports`, because an exports target names the built file (`./dist/layout.js`), and mapping that back to its source depends on the build tool's own configuration, which this rule cannot read. + * * Unlike the standalone no-non-barrel-reexport, this umbrella is non-fixable: the autofix belongs on the granular rule, and a policy-level rule that sometimes fixes and sometimes doesn't would surface that inconsistency under one rule id. Consumers who want the autofix use no-non-barrel-reexport directly. * - * A factory, not a plain object, specifically so a test can inject a stub ReadPackageJsonFn (see barrel-auto-detect.ts) without that resolver becoming part of the rule's public JSON options schema — ESLint validates context.options against meta.schema with additionalProperties: false, so there is no options-based channel to smuggle a function through even if one were wanted. The default export below is this factory called with the real, filesystem-backed default; barrel-policy.unit.test.ts imports createBarrelPolicyRule directly to exercise 'auto' against fabricated package.json data with zero real filesystem I/O. Mirrors the requireFn-as-parameter-with-a-real-default shape optional-plugin.ts's tryRequire already establishes for the same "injectable in tests, defaulted in production" problem. + * A factory, not a plain object, specifically so a test can inject stub BarrelPolicySeams (see barrel-auto-detect.ts) without those resolvers becoming part of the rule's public JSON options schema: ESLint validates context.options against meta.schema with additionalProperties: false, so there is no options-based channel to pass a function through even if one were wanted. The default export below is this factory called with the real, filesystem-backed defaults; barrel-policy.unit.test.ts imports createBarrelPolicyRule directly to exercise 'auto' and `entries` against fabricated package data with no real filesystem I/O. Mirrors the requireFn-as-parameter-with-a-real-default shape optional-plugin.ts's tryRequire already establishes for the same "injectable in tests, defaulted in production" problem. */ -export function createBarrelPolicyRule(readPackageJsonFn: ReadPackageJsonFn = findNearestPackageJson): Rule.RuleModule { +export function createBarrelPolicyRule({ readPackageJson = findNearestPackageJson, findPackageRoot = findNearestPackageRoot }: BarrelPolicySeams = {}): Rule.RuleModule { return { meta: { type: 'problem', schema: [ { type: 'object', - properties: { mode: { type: 'string', enum: ['banned', 'single', 'siblings', 'auto'] } }, + properties: { mode: { type: 'string', enum: ['banned', 'single', 'siblings', 'auto'] }, entries: fileGlobsSchema }, additionalProperties: false, }, ], @@ -63,15 +103,38 @@ export function createBarrelPolicyRule(readPackageJsonFn: ReadPackageJsonFn = fi "Re-exports belong only in a barrel (index) file — import this value directly in the file that uses it instead of re-exporting it through this one.", notADirectSibling: "A barrel may re-export only from a direct sibling file or folder ('./module' or './module.ts') — found '{{ source }}'. Move the source closer, or import it directly at the call site rather than re-exporting it through this barrel.", + notAPackageOrSibling: + "A package entry point may re-export only from a package ('pkg', '@scope/pkg/sub') or a direct sibling file or folder ('./module'); found '{{ source }}'. Re-export it through the package that owns it, move the source beside this entry point, or import it directly at the call site.", }, }, create(context) { const filename = context.filename; const rawMode = readMode(context.options[0]); - // The filesystem walk in resolveAutoMode only ever runs for 'auto' — the three concrete literals pass straight through with zero I/O, exactly as before this option existed. - const mode = rawMode === 'auto' ? resolveAutoMode(filename, readPackageJsonFn) : rawMode; + const entries = readEntries(context.options[0]); + // The filesystem walk in resolveAutoMode only ever runs for 'auto'; the three concrete literals pass straight through with no I/O. + const mode = rawMode === 'auto' ? resolveAutoMode(filename, readPackageJson) : rawMode; + // 'banned' permits no barrel, so under 'auto' resolved to 'banned' (a package with no exports or main, and so no entry points) the list selects nothing; readEntries has already rejected it beside an explicit 'banned'. + const isEntry = mode !== 'banned' && entries !== undefined && isDeclaredEntry(filename, entries, findPackageRoot); + const isBarrel = isEntry || isPermittedBarrel(filename, mode); const detector = createSplitReexportDetector(); + /** + * Reports a re-export whose source `source` this file may not name: any re-export outside a barrel, and, in 'siblings' mode, a barrel's re-export that is not from a direct sibling (or, in a declared entry file, from a package either). + */ + function checkReexportSource(node: ReportedNode, source: string): void { + if (!isBarrel) { + context.report({ node, messageId: 'reexportOutsideBarrel' }); + + return; + } + if (mode !== 'siblings' || isDirectSibling(source)) return; + if (!isEntry) { + context.report({ node, messageId: 'notADirectSibling', data: { source } }); + } else if (!isPackageSpecifier(source)) { + context.report({ node, messageId: 'notAPackageOrSibling', data: { source } }); + } + } + return { Program(node) { if (mode === 'banned') { @@ -79,62 +142,31 @@ export function createBarrelPolicyRule(readPackageJsonFn: ReadPackageJsonFn = fi return; } - if (mode === 'single') { - if (isIndexFile(filename) && !isMainBarrel(filename)) { - context.report({ node, messageId: 'nonMainIndexFile' }); - - return; - } - if (isMainBarrel(filename)) { - for (const statement of node.body) { - if (!isPureReexport(statement)) context.report({ node: statement, messageId: 'sideEffectInBarrel', data: { description: statement.type } }); - } - } + // Only 'single' limits which index files may be barrels; in 'siblings' every index file is one, so isBarrel already holds for it. + if (isIndexFile(filename) && !isBarrel) { + context.report({ node, messageId: 'nonMainIndexFile' }); return; } - // mode === 'siblings': any index file is a barrel; enforce purity on each. - if (isIndexFile(filename)) { - for (const statement of node.body) { - if (!isPureReexport(statement)) context.report({ node: statement, messageId: 'sideEffectInBarrel', data: { description: statement.type } }); - } + if (!isBarrel) return; + for (const statement of node.body) { + if (!isPureReexport(statement)) context.report({ node: statement, messageId: 'sideEffectInBarrel', data: { description: statement.type } }); } }, ImportDeclaration: (node) => { detector.visitImport(node); }, ExportNamedDeclaration(node) { detector.visitExportNamed(node); - if (hasSource(node) && !isInsideAmbientModuleDeclaration(node)) { - const source = moduleSpecifierValue(node.source); - if (!isPermittedBarrel(filename, mode)) { - context.report({ node, messageId: 'reexportOutsideBarrel' }); - } else if (mode === 'siblings' && !isDirectSibling(source)) { - context.report({ node, messageId: 'notADirectSibling', data: { source } }); - } - } + if (hasSource(node) && !isInsideAmbientModuleDeclaration(node)) checkReexportSource(node, moduleSpecifierValue(node.source)); }, ExportAllDeclaration(node) { if (isInsideAmbientModuleDeclaration(node)) return; - const source = moduleSpecifierValue(node.source); - if (!isPermittedBarrel(filename, mode)) { - context.report({ node, messageId: 'reexportOutsideBarrel' }); - } else if (mode === 'siblings' && !isDirectSibling(source)) { - context.report({ node, messageId: 'notADirectSibling', data: { source } }); - } + checkReexportSource(node, moduleSpecifierValue(node.source)); }, ExportDefaultDeclaration: (node) => { detector.visitExportDefault(node); }, 'Program:exit'() { + // Inside a barrel the split-statement re-export is already reported as impure by Program above; checkReexportSource adds what its import source breaks on top of that, and outside a barrel reports the re-export itself. for (const violation of detector.violations()) { - if (isPermittedBarrel(filename, mode)) { - // Inside a barrel the split-statement re-export is allowed, but in 'siblings' mode its import source must still be a direct sibling. - if (mode === 'siblings') { - const importSource = moduleSpecifierValue(violation.trackedImport.declaration.source); - if (!isDirectSibling(importSource)) { - context.report({ node: violation.kind === 'named' ? violation.specifier : violation.declaration, messageId: 'notADirectSibling', data: { source: importSource } }); - } - } - } else { - context.report({ node: violation.kind === 'named' ? violation.specifier : violation.declaration, messageId: 'reexportOutsideBarrel' }); - } + checkReexportSource(violation.kind === 'named' ? violation.specifier : violation.declaration, moduleSpecifierValue(violation.trackedImport.declaration.source)); } }, }; diff --git a/src/rules/barrel-policy.unit.test.ts b/src/rules/barrel-policy.unit.test.ts index bbad780..f8ee1ce 100644 --- a/src/rules/barrel-policy.unit.test.ts +++ b/src/rules/barrel-policy.unit.test.ts @@ -1,7 +1,10 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { Linter, RuleTester } from 'eslint'; -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; import tseslint from 'typescript-eslint'; -import rule, { createBarrelPolicyRule, readMode } from './barrel-policy'; +import rule, { createBarrelPolicyRule, readEntries, readMode } from './barrel-policy'; describe('readMode', () => { it("returns 'auto' for missing options — an omitted mode is nothing stated, not a misconfiguration", () => { @@ -26,9 +29,9 @@ describe('readMode', () => { }); // A fake package.json shaped like a genuine publish-shaped entry point (real exports) — resolves 'auto' to 'single'. -const autoResolvesToSingle = createBarrelPolicyRule(() => ({ exports: { '.': './dist/index.js' } })); +const autoResolvesToSingle = createBarrelPolicyRule({ readPackageJson: () => ({ exports: { '.': './dist/index.js' } }) }); // No ancestor package.json at all — resolves 'auto' to 'banned', the same conservative default a package with neither exports nor main gets. -const autoResolvesToBanned = createBarrelPolicyRule(() => undefined); +const autoResolvesToBanned = createBarrelPolicyRule({ readPackageJson: () => undefined }); const ruleTester = new RuleTester({ languageOptions: { parser: tseslint.parser, sourceType: 'module' }, @@ -73,6 +76,53 @@ describe('barrel-policy schema', () => { lintWithOptions('banned'); }).toThrow(/should be object/); }); + + it('accepts an entries list beside a mode that permits barrels', () => { + expect(() => { + lintWithOptions({ mode: 'single', entries: ['src/*.ts'] }); + }).not.toThrow(); + }); + + it('rejects an empty entries list at the schema level, since it would declare nothing', () => { + expect(() => { + lintWithOptions({ mode: 'single', entries: [] }); + }).toThrow(/should NOT have fewer than 1 items/); + }); + + it('rejects entries beside mode banned when the rule is created, naming both', () => { + expect(() => { + lintWithOptions({ mode: 'banned', entries: ['src/*.ts'] }); + }).toThrow(/"entries" declares barrels, which mode 'banned' forbids/); + }); +}); + +describe('readEntries', () => { + it('returns undefined when the options carry no entries key', () => { + expect(readEntries(undefined)).toBeUndefined(); + expect(readEntries({})).toBeUndefined(); + expect(readEntries({ mode: 'single' })).toBeUndefined(); + }); + + it('returns the list as given', () => { + expect(readEntries({ mode: 'siblings', entries: ['src/*.ts', '!src/internal.ts'] })).toEqual(['src/*.ts', '!src/internal.ts']); + }); + + it("accepts entries with mode 'auto' or with no mode, since auto can resolve to single", () => { + expect(readEntries({ mode: 'auto', entries: ['src/*.ts'] })).toEqual(['src/*.ts']); + expect(readEntries({ entries: ['src/*.ts'] })).toEqual(['src/*.ts']); + }); + + it("throws for entries beside mode 'banned'", () => { + expect(() => readEntries({ mode: 'banned', entries: ['src/*.ts'] })).toThrow(/mode 'banned' forbids/); + }); + + it('throws, naming the option, for a list of exclusions alone', () => { + expect(() => readEntries({ entries: ['!src/a.ts'] })).toThrow(/"exadev\/barrel-policy entries" must contain at least one glob that does not start with "!"/); + }); + + it('throws, naming the option, for a glob that starts with a slash', () => { + expect(() => readEntries({ entries: ['/src/a.ts'] })).toThrow(/"exadev\/barrel-policy entries" has a file glob that starts or ends with a slash/); + }); }); // The umbrella rule selects one of three complete index-file policies via { mode }. Each mode is exercised against the constructs that define it: which files may be barrels, what a barrel may contain, and where a barrel's re-exports may come from. The rule itself needs no type information — it walks plain import/export/declaration nodes — so no projectService/parserOptions.project is configured here. @@ -335,3 +385,198 @@ ruleTester.run('barrel-policy (auto -> banned)', autoResolvesToBanned, { }, ], }); + +// ─── entries: a package's declared entry files are barrels beside src/index.ts. ─── The package root is the test's working directory, so a fixture filename such as './src/layout.ts' is 'src/layout.ts' relative to it. The globs mirror a package that publishes subpath exports: a flat module per subpath, a nested one, and an index file one level down. +const packageRoot = process.cwd(); +const entries = ['src/layout.ts', 'src/store/wasm/worker.ts', 'src/*/index.ts']; +const withEntries = createBarrelPolicyRule({ readPackageJson: () => ({ exports: { '.': './dist/index.js', './layout': './dist/layout.js' } }), findPackageRoot: () => packageRoot }); + +ruleTester.run('barrel-policy (single, entries)', withEntries, { + valid: [ + // An entry file re-exporting a workspace or third-party package, the case that motivated the option. + { code: "export * from '@exadev/process-graph-layout';", filename: './src/layout.ts', options: [{ mode: 'single', entries }] }, + // A nested entry path. + { code: "export { createWorker } from '@exadev/process-graph-store-wasm';", filename: './src/store/wasm/worker.ts', options: [{ mode: 'single', entries }] }, + // An entry that is an index file below src: not reported as a second index file. + { code: "export * from './view';", filename: './src/ui/index.ts', options: [{ mode: 'single', entries }] }, + // 'single' puts no constraint on where a barrel's re-exports come from, for an entry as for src/index.ts. + { code: "export * from '../shared/thing';", filename: './src/store/wasm/worker.ts', options: [{ mode: 'single', entries }] }, + { code: "import { a } from 'pkg';\nexport const b = a;", filename: './src/other.ts', options: [{ mode: 'single', entries }] }, + { code: "export { foo } from './foo';", filename: './src/index.ts', options: [{ mode: 'single', entries }] }, + // The 'auto' default resolves to 'single' for a package with exports, so the entries apply there too. + { code: "export * from '@exadev/process-graph-layout';", filename: './src/layout.ts', options: [{ entries }] }, + ], + invalid: [ + // A file the list does not match keeps the existing behaviour: no re-exports outside a barrel. + { + code: "export * from '@exadev/process-graph-layout';", + filename: './src/layout-helpers.ts', + options: [{ mode: 'single', entries }], + errors: [{ messageId: 'reexportOutsideBarrel' }], + }, + { + code: "import { a } from 'pkg';\nexport { a };", + filename: './src/other.ts', + options: [{ mode: 'single', entries }], + errors: [{ messageId: 'reexportOutsideBarrel' }], + }, + // 'src/*/index.ts' matches one level only, so a deeper index file is still not a permitted barrel. + { + code: "export * from './view';", + filename: './src/ui/deep/index.ts', + options: [{ mode: 'single', entries }], + errors: [{ messageId: 'nonMainIndexFile' }, { messageId: 'reexportOutsideBarrel' }], + }, + // An entry file is a barrel, so logic beside its re-exports is reported, as in src/index.ts. + { + code: "export * from '@exadev/process-graph-layout';\nexport const extra = 1;", + filename: './src/layout.ts', + options: [{ mode: 'single', entries }], + errors: [{ messageId: 'sideEffectInBarrel', data: { description: 'ExportNamedDeclaration' } }], + }, + { + code: "import { a } from 'pkg';\nexport { a };", + filename: './src/layout.ts', + options: [{ mode: 'single', entries }], + errors: [ + { messageId: 'sideEffectInBarrel', data: { description: 'ImportDeclaration' } }, + { messageId: 'sideEffectInBarrel', data: { description: 'ExportNamedDeclaration' } }, + ], + }, + ], +}); + +ruleTester.run('barrel-policy (siblings, entries)', withEntries, { + valid: [ + { code: "export * from '@exadev/process-graph-layout';", filename: './src/layout.ts', options: [{ mode: 'siblings', entries }] }, + { code: "export { a } from 'pkg/sub';\nexport * from 'node:fs';", filename: './src/store/wasm/worker.ts', options: [{ mode: 'siblings', entries }] }, + { code: "export * from './sibling';", filename: './src/layout.ts', options: [{ mode: 'siblings', entries }] }, + // A split-statement re-export of a package from an entry file is impure, but names a permitted source, so only the impurity is reported (see the invalid case below); here the plain form passes. + { code: "export { default } from 'pkg';", filename: './src/layout.ts', options: [{ mode: 'siblings', entries }] }, + // A non-entry index file keeps the siblings behaviour. + { code: "export * from './a';", filename: './src/sub/deeper/index.ts', options: [{ mode: 'siblings', entries }] }, + ], + invalid: [ + { + code: "export * from '../up';", + filename: './src/layout.ts', + options: [{ mode: 'siblings', entries }], + errors: [ + { + message: + "A package entry point may re-export only from a package ('pkg', '@scope/pkg/sub') or a direct sibling file or folder ('./module'); found '../up'. Re-export it through the package that owns it, move the source beside this entry point, or import it directly at the call site.", + }, + ], + }, + { + code: "export { a } from './a/b';", + filename: './src/store/wasm/worker.ts', + options: [{ mode: 'siblings', entries }], + errors: [{ messageId: 'notAPackageOrSibling', data: { source: './a/b' } }], + }, + { + code: "export * from '#internal';", + filename: './src/layout.ts', + options: [{ mode: 'siblings', entries }], + errors: [{ messageId: 'notAPackageOrSibling', data: { source: '#internal' } }], + }, + // A package re-export from an index file the list does not match stays a siblings violation. + { + code: "export * from 'pkg';", + filename: './src/sub/deeper/index.ts', + options: [{ mode: 'siblings', entries }], + errors: [{ messageId: 'notADirectSibling', data: { source: 'pkg' } }], + }, + // A matched index file is an entry, so it gets the entry message. + { + code: "export * from '../up';", + filename: './src/ui/index.ts', + options: [{ mode: 'siblings', entries }], + errors: [{ messageId: 'notAPackageOrSibling', data: { source: '../up' } }], + }, + { + code: "export * from 'pkg';", + filename: './src/layout-helpers.ts', + options: [{ mode: 'siblings', entries }], + errors: [{ messageId: 'reexportOutsideBarrel' }], + }, + { + code: 'export const x = 1;', + filename: './src/layout.ts', + options: [{ mode: 'siblings', entries }], + errors: [{ messageId: 'sideEffectInBarrel', data: { description: 'ExportNamedDeclaration' } }], + }, + // The split-statement form: both statements are impure, and its import source is checked like a single-statement re-export's. + { + code: "import { a } from '../up';\nexport { a };", + filename: './src/layout.ts', + options: [{ mode: 'siblings', entries }], + errors: [ + { messageId: 'sideEffectInBarrel', data: { description: 'ImportDeclaration' } }, + { messageId: 'sideEffectInBarrel', data: { description: 'ExportNamedDeclaration' } }, + { messageId: 'notAPackageOrSibling', data: { source: '../up' } }, + ], + }, + { + code: "import a from 'pkg';\nexport default a;", + filename: './src/layout.ts', + options: [{ mode: 'siblings', entries }], + errors: [ + { messageId: 'sideEffectInBarrel', data: { description: 'ImportDeclaration' } }, + { messageId: 'sideEffectInBarrel', data: { description: 'ExportDefaultDeclaration' } }, + ], + }, + ], +}); + +// Under 'auto', a package with neither exports nor main resolves to 'banned', which has no barrels; the entries then select nothing, rather than making a file a barrel that the package does not publish. +ruleTester.run('barrel-policy (auto -> banned, entries)', createBarrelPolicyRule({ readPackageJson: () => ({ name: 'internal' }), findPackageRoot: () => packageRoot }), { + valid: [{ code: 'export const x = 1;', filename: './src/layout.ts', options: [{ entries }] }], + invalid: [ + { code: "export * from 'pkg';", filename: './src/layout.ts', options: [{ entries }], errors: [{ messageId: 'reexportOutsideBarrel' }] }, + { code: "export * from './view';", filename: './src/ui/index.ts', options: [{ entries }], errors: [{ messageId: 'indexFileBanned' }, { messageId: 'reexportOutsideBarrel' }] }, + ], +}); + +// The globs are relative to the package the file belongs to, not to the working directory. +ruleTester.run('barrel-policy (entries relative to the package)', createBarrelPolicyRule({ findPackageRoot: () => join(packageRoot, 'packages', 'app') }), { + valid: [{ code: "export * from 'pkg';", filename: './packages/app/src/layout.ts', options: [{ mode: 'single', entries }] }], + invalid: [{ code: "export * from 'pkg';", filename: './src/layout.ts', options: [{ mode: 'single', entries }], errors: [{ messageId: 'reexportOutsideBarrel' }] }], +}); + +// A file with no package.json above it belongs to no package, so it is no package's entry point. +ruleTester.run('barrel-policy (entries outside any package)', createBarrelPolicyRule({ findPackageRoot: () => undefined }), { + valid: [], + invalid: [{ code: "export * from 'pkg';", filename: './src/layout.ts', options: [{ mode: 'single', entries }], errors: [{ messageId: 'reexportOutsideBarrel' }] }], +}); + +describe('barrel-policy entries against the real filesystem', () => { + let root: string; + + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + }); + + function lintFile(filename: string): readonly string[] { + const linter = new Linter({ cwd: root }); + + return linter + .verify( + "export * from 'pkg';", + [{ files: ['**/*.ts'], languageOptions: { parser: tseslint.parser }, plugins: { exadev: { rules: { 'barrel-policy': rule } } }, rules: { 'exadev/barrel-policy': ['error', { mode: 'single', entries: ['src/*.ts'] }] } }], + filename, + ) + .map((message) => message.message); + } + + it('matches the globs against the path below the nearest package.json the default rule finds', () => { + root = mkdtempSync(join(tmpdir(), 'barrel-policy-entries-')); + const packageDir = join(root, 'packages', 'app'); + mkdirSync(join(packageDir, 'src', 'nested'), { recursive: true }); + writeFileSync(join(root, 'package.json'), JSON.stringify({ name: 'workspace' })); + writeFileSync(join(packageDir, 'package.json'), JSON.stringify({ name: 'app', exports: { './layout': './dist/layout.js' } })); + + expect(lintFile(join(packageDir, 'src', 'layout.ts'))).toEqual([]); + expect(lintFile(join(packageDir, 'src', 'nested', 'layout.ts'))).toEqual([expect.stringMatching(/^Re-exports belong only in a barrel/)]); + }); +});