exadev-eslint-config 3.0.2 → 3.1.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/README.md +29 -2
- package/dist/index.cjs +105 -82
- package/dist/index.d.cts +8 -4
- package/dist/index.d.ts +8 -4
- package/dist/index.js +105 -82
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -193,7 +193,7 @@ export default tseslint.config(
|
|
|
193
193
|
);
|
|
194
194
|
```
|
|
195
195
|
|
|
196
|
-
`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.
|
|
196
|
+
`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.
|
|
197
197
|
|
|
198
198
|
## Optional features
|
|
199
199
|
|
|
@@ -376,7 +376,7 @@ Bundled into `exadevConfig()`'s default output the same way React/Next.js auto-d
|
|
|
376
376
|
|
|
377
377
|
| Rule | Fixable | Description |
|
|
378
378
|
| --- | --- | --- |
|
|
379
|
-
| `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). |
|
|
379
|
+
| `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). |
|
|
380
380
|
| `no-index-files` | | **Bans any `index.*` file outright** (mode 1). The strictest policy. |
|
|
381
381
|
| `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. |
|
|
382
382
|
| `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'`:
|
|
|
465
465
|
|
|
466
466
|
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`.
|
|
467
467
|
|
|
468
|
+
### Package entry points
|
|
469
|
+
|
|
470
|
+
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:
|
|
471
|
+
|
|
472
|
+
```ts
|
|
473
|
+
export default defineConfig(...exadevConfig(), {
|
|
474
|
+
rules: {
|
|
475
|
+
'exadev/barrel-policy': ['error', { mode: 'single', entries: ['src/layout.ts', 'src/store/*.ts', 'src/*/index.ts'] }],
|
|
476
|
+
},
|
|
477
|
+
});
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
`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.
|
|
481
|
+
|
|
482
|
+
A matched file is a permitted barrel in the mode's terms:
|
|
483
|
+
|
|
484
|
+
| `mode` | What an entry file may contain | Where its re-exports may come from |
|
|
485
|
+
| --- | --- | --- |
|
|
486
|
+
| `'single'` (and `'auto'` resolved to `single`) | only re-exports | anywhere, as for `src/index.ts` |
|
|
487
|
+
| `'siblings'` | only re-exports | a package (`pkg`, `@scope/pkg/sub`, `node:fs`) or a direct sibling (`./module`) |
|
|
488
|
+
|
|
489
|
+
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.
|
|
490
|
+
|
|
491
|
+
`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.
|
|
492
|
+
|
|
493
|
+
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.
|
|
494
|
+
|
|
468
495
|
## File-level rules
|
|
469
496
|
|
|
470
497
|
`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.
|
package/dist/index.cjs
CHANGED
|
@@ -485,7 +485,7 @@ function buildOptionalPluginConfig(preset, options = {}) {
|
|
|
485
485
|
}
|
|
486
486
|
//#endregion
|
|
487
487
|
//#region package.json
|
|
488
|
-
var version = "3.0
|
|
488
|
+
var version = "3.1.0";
|
|
489
489
|
//#endregion
|
|
490
490
|
//#region src/react.ts
|
|
491
491
|
/**
|
|
@@ -629,6 +629,14 @@ function isDirectSibling(specifier) {
|
|
|
629
629
|
return rest !== "." && rest !== ".." && rest !== "" && !rest.includes("/");
|
|
630
630
|
}
|
|
631
631
|
/**
|
|
632
|
+
* 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).
|
|
633
|
+
*/
|
|
634
|
+
function isPackageSpecifier(specifier) {
|
|
635
|
+
if (specifier.startsWith(".") || specifier.startsWith("/") || specifier.startsWith("#")) return false;
|
|
636
|
+
const scheme = /^[a-z][a-z\d+.-]*:/i.exec(specifier);
|
|
637
|
+
return scheme === null || scheme[0] === "node:";
|
|
638
|
+
}
|
|
639
|
+
/**
|
|
632
640
|
* 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.
|
|
633
641
|
*/
|
|
634
642
|
function isBarrelMode(value) {
|
|
@@ -764,31 +772,41 @@ function decideAutoBarrelMode(packageJson) {
|
|
|
764
772
|
const hasMain = typeof main === "string" && main.length > 0;
|
|
765
773
|
return hasExports || hasMain ? "single" : "banned";
|
|
766
774
|
}
|
|
775
|
+
const packageRootByStartDir = /* @__PURE__ */ new Map();
|
|
767
776
|
const packageJsonByStartDir = /* @__PURE__ */ new Map();
|
|
768
777
|
/**
|
|
769
|
-
* The real, filesystem-backed
|
|
778
|
+
* 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.
|
|
770
779
|
*/
|
|
771
|
-
function
|
|
772
|
-
const cached =
|
|
773
|
-
if (
|
|
780
|
+
function findNearestPackageRoot(startDir) {
|
|
781
|
+
const cached = packageRootByStartDir.get(startDir);
|
|
782
|
+
if (packageRootByStartDir.has(startDir)) return cached;
|
|
774
783
|
let dir = startDir;
|
|
775
784
|
for (;;) {
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
const result = isRecord(parsed) ? parsed : void 0;
|
|
780
|
-
packageJsonByStartDir.set(startDir, result);
|
|
781
|
-
return result;
|
|
785
|
+
if ((0, node_fs.existsSync)((0, node_path.join)(dir, "package.json"))) {
|
|
786
|
+
packageRootByStartDir.set(startDir, dir);
|
|
787
|
+
return dir;
|
|
782
788
|
}
|
|
783
789
|
const parent = (0, node_path.dirname)(dir);
|
|
784
790
|
if (parent === dir) {
|
|
785
|
-
|
|
791
|
+
packageRootByStartDir.set(startDir, void 0);
|
|
786
792
|
return;
|
|
787
793
|
}
|
|
788
794
|
dir = parent;
|
|
789
795
|
}
|
|
790
796
|
}
|
|
791
797
|
/**
|
|
798
|
+
* The real, filesystem-backed ReadPackageJsonFn: finds the nearest ancestor package.json through findNearestPackageRoot (startDir itself included), parses it, and returns undefined when there is none. 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.
|
|
799
|
+
*/
|
|
800
|
+
function findNearestPackageJson(startDir) {
|
|
801
|
+
const cached = packageJsonByStartDir.get(startDir);
|
|
802
|
+
if (packageJsonByStartDir.has(startDir)) return cached;
|
|
803
|
+
const root = findNearestPackageRoot(startDir);
|
|
804
|
+
const parsed = root === void 0 ? void 0 : JSON.parse((0, node_fs.readFileSync)((0, node_path.join)(root, "package.json"), "utf8"));
|
|
805
|
+
const result = isRecord(parsed) ? parsed : void 0;
|
|
806
|
+
packageJsonByStartDir.set(startDir, result);
|
|
807
|
+
return result;
|
|
808
|
+
}
|
|
809
|
+
/**
|
|
792
810
|
* 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.
|
|
793
811
|
*/
|
|
794
812
|
function resolveAutoMode(filename, readPackageJsonFn = findNearestPackageJson) {
|
|
@@ -808,31 +826,52 @@ function readMode(options) {
|
|
|
808
826
|
return options.mode;
|
|
809
827
|
}
|
|
810
828
|
/**
|
|
829
|
+
* 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.
|
|
830
|
+
*/
|
|
831
|
+
function readEntries(options) {
|
|
832
|
+
if (!isRecord(options) || !("entries" in options)) return void 0;
|
|
833
|
+
if (options["mode"] === "banned") throw new Error("exadev/barrel-policy: \"entries\" declares barrels, which mode 'banned' forbids; use 'single', 'siblings' or 'auto' with entries.");
|
|
834
|
+
return readFileGlobs(options["entries"], "exadev/barrel-policy entries");
|
|
835
|
+
}
|
|
836
|
+
/**
|
|
837
|
+
* 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.
|
|
838
|
+
*/
|
|
839
|
+
function isDeclaredEntry(filename, entries, findPackageRoot) {
|
|
840
|
+
const root = findPackageRoot((0, node_path.dirname)(filename));
|
|
841
|
+
if (root === void 0) return false;
|
|
842
|
+
return createPathMatcher(entries)(relativeToCwd(filename, root));
|
|
843
|
+
}
|
|
844
|
+
/**
|
|
811
845
|
* 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":
|
|
812
846
|
*
|
|
813
847
|
* 'banned' — no index files at all; re-exports banned everywhere. 'single' — exactly src/index.ts may be a barrel; it contains only re-exports; re-exports banned everywhere else. 'siblings' — any index file may be a barrel; each contains only re-exports; each re-export comes from a direct sibling (./module); re-exports banned in every non-index file.
|
|
814
848
|
*
|
|
815
849
|
* 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.
|
|
816
850
|
*
|
|
851
|
+
* 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.
|
|
852
|
+
*
|
|
817
853
|
* 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.
|
|
818
854
|
*
|
|
819
|
-
* A factory, not a plain object, specifically so a test can inject
|
|
855
|
+
* 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.
|
|
820
856
|
*/
|
|
821
|
-
function createBarrelPolicyRule(
|
|
857
|
+
function createBarrelPolicyRule({ readPackageJson = findNearestPackageJson, findPackageRoot = findNearestPackageRoot } = {}) {
|
|
822
858
|
return {
|
|
823
859
|
meta: {
|
|
824
860
|
type: "problem",
|
|
825
861
|
schema: [{
|
|
826
862
|
type: "object",
|
|
827
|
-
properties: {
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
863
|
+
properties: {
|
|
864
|
+
mode: {
|
|
865
|
+
type: "string",
|
|
866
|
+
enum: [
|
|
867
|
+
"banned",
|
|
868
|
+
"single",
|
|
869
|
+
"siblings",
|
|
870
|
+
"auto"
|
|
871
|
+
]
|
|
872
|
+
},
|
|
873
|
+
entries: fileGlobsSchema
|
|
874
|
+
},
|
|
836
875
|
additionalProperties: false
|
|
837
876
|
}],
|
|
838
877
|
messages: {
|
|
@@ -840,14 +879,41 @@ function createBarrelPolicyRule(readPackageJsonFn = findNearestPackageJson) {
|
|
|
840
879
|
nonMainIndexFile: "Only src/index.ts may be a barrel in this project — this index file is not it. Move its contents into the module that owns them or give the file a descriptive name.",
|
|
841
880
|
sideEffectInBarrel: "A barrel may contain only re-export statements ('export * from ...' / 'export { x } from ...' / 'export type { x } from ...') — nothing else, so it can never have a side effect at import time by construction. Found: {{ description }}.",
|
|
842
881
|
reexportOutsideBarrel: "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.",
|
|
843
|
-
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."
|
|
882
|
+
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.",
|
|
883
|
+
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."
|
|
844
884
|
}
|
|
845
885
|
},
|
|
846
886
|
create(context) {
|
|
847
887
|
const filename = context.filename;
|
|
848
888
|
const rawMode = readMode(context.options[0]);
|
|
849
|
-
const
|
|
889
|
+
const entries = readEntries(context.options[0]);
|
|
890
|
+
const mode = rawMode === "auto" ? resolveAutoMode(filename, readPackageJson) : rawMode;
|
|
891
|
+
const isEntry = mode !== "banned" && entries !== void 0 && isDeclaredEntry(filename, entries, findPackageRoot);
|
|
892
|
+
const isBarrel = isEntry || isPermittedBarrel(filename, mode);
|
|
850
893
|
const detector = createSplitReexportDetector();
|
|
894
|
+
/**
|
|
895
|
+
* 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).
|
|
896
|
+
*/
|
|
897
|
+
function checkReexportSource(node, source) {
|
|
898
|
+
if (!isBarrel) {
|
|
899
|
+
context.report({
|
|
900
|
+
node,
|
|
901
|
+
messageId: "reexportOutsideBarrel"
|
|
902
|
+
});
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
if (mode !== "siblings" || isDirectSibling(source)) return;
|
|
906
|
+
if (!isEntry) context.report({
|
|
907
|
+
node,
|
|
908
|
+
messageId: "notADirectSibling",
|
|
909
|
+
data: { source }
|
|
910
|
+
});
|
|
911
|
+
else if (!isPackageSpecifier(source)) context.report({
|
|
912
|
+
node,
|
|
913
|
+
messageId: "notAPackageOrSibling",
|
|
914
|
+
data: { source }
|
|
915
|
+
});
|
|
916
|
+
}
|
|
851
917
|
return {
|
|
852
918
|
Program(node) {
|
|
853
919
|
if (mode === "banned") {
|
|
@@ -857,79 +923,36 @@ function createBarrelPolicyRule(readPackageJsonFn = findNearestPackageJson) {
|
|
|
857
923
|
});
|
|
858
924
|
return;
|
|
859
925
|
}
|
|
860
|
-
if (
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
messageId: "nonMainIndexFile"
|
|
865
|
-
});
|
|
866
|
-
return;
|
|
867
|
-
}
|
|
868
|
-
if (isMainBarrel(filename)) {
|
|
869
|
-
for (const statement of node.body) if (!isPureReexport(statement)) context.report({
|
|
870
|
-
node: statement,
|
|
871
|
-
messageId: "sideEffectInBarrel",
|
|
872
|
-
data: { description: statement.type }
|
|
873
|
-
});
|
|
874
|
-
}
|
|
875
|
-
return;
|
|
876
|
-
}
|
|
877
|
-
if (isIndexFile(filename)) {
|
|
878
|
-
for (const statement of node.body) if (!isPureReexport(statement)) context.report({
|
|
879
|
-
node: statement,
|
|
880
|
-
messageId: "sideEffectInBarrel",
|
|
881
|
-
data: { description: statement.type }
|
|
926
|
+
if (isIndexFile(filename) && !isBarrel) {
|
|
927
|
+
context.report({
|
|
928
|
+
node,
|
|
929
|
+
messageId: "nonMainIndexFile"
|
|
882
930
|
});
|
|
931
|
+
return;
|
|
883
932
|
}
|
|
933
|
+
if (!isBarrel) return;
|
|
934
|
+
for (const statement of node.body) if (!isPureReexport(statement)) context.report({
|
|
935
|
+
node: statement,
|
|
936
|
+
messageId: "sideEffectInBarrel",
|
|
937
|
+
data: { description: statement.type }
|
|
938
|
+
});
|
|
884
939
|
},
|
|
885
940
|
ImportDeclaration: (node) => {
|
|
886
941
|
detector.visitImport(node);
|
|
887
942
|
},
|
|
888
943
|
ExportNamedDeclaration(node) {
|
|
889
944
|
detector.visitExportNamed(node);
|
|
890
|
-
if (hasSource(node) && !isInsideAmbientModuleDeclaration(node))
|
|
891
|
-
const source = moduleSpecifierValue(node.source);
|
|
892
|
-
if (!isPermittedBarrel(filename, mode)) context.report({
|
|
893
|
-
node,
|
|
894
|
-
messageId: "reexportOutsideBarrel"
|
|
895
|
-
});
|
|
896
|
-
else if (mode === "siblings" && !isDirectSibling(source)) context.report({
|
|
897
|
-
node,
|
|
898
|
-
messageId: "notADirectSibling",
|
|
899
|
-
data: { source }
|
|
900
|
-
});
|
|
901
|
-
}
|
|
945
|
+
if (hasSource(node) && !isInsideAmbientModuleDeclaration(node)) checkReexportSource(node, moduleSpecifierValue(node.source));
|
|
902
946
|
},
|
|
903
947
|
ExportAllDeclaration(node) {
|
|
904
948
|
if (isInsideAmbientModuleDeclaration(node)) return;
|
|
905
|
-
|
|
906
|
-
if (!isPermittedBarrel(filename, mode)) context.report({
|
|
907
|
-
node,
|
|
908
|
-
messageId: "reexportOutsideBarrel"
|
|
909
|
-
});
|
|
910
|
-
else if (mode === "siblings" && !isDirectSibling(source)) context.report({
|
|
911
|
-
node,
|
|
912
|
-
messageId: "notADirectSibling",
|
|
913
|
-
data: { source }
|
|
914
|
-
});
|
|
949
|
+
checkReexportSource(node, moduleSpecifierValue(node.source));
|
|
915
950
|
},
|
|
916
951
|
ExportDefaultDeclaration: (node) => {
|
|
917
952
|
detector.visitExportDefault(node);
|
|
918
953
|
},
|
|
919
954
|
"Program:exit"() {
|
|
920
|
-
for (const violation of detector.violations())
|
|
921
|
-
if (mode === "siblings") {
|
|
922
|
-
const importSource = moduleSpecifierValue(violation.trackedImport.declaration.source);
|
|
923
|
-
if (!isDirectSibling(importSource)) context.report({
|
|
924
|
-
node: violation.kind === "named" ? violation.specifier : violation.declaration,
|
|
925
|
-
messageId: "notADirectSibling",
|
|
926
|
-
data: { source: importSource }
|
|
927
|
-
});
|
|
928
|
-
}
|
|
929
|
-
} else context.report({
|
|
930
|
-
node: violation.kind === "named" ? violation.specifier : violation.declaration,
|
|
931
|
-
messageId: "reexportOutsideBarrel"
|
|
932
|
-
});
|
|
955
|
+
for (const violation of detector.violations()) checkReexportSource(violation.kind === "named" ? violation.specifier : violation.declaration, moduleSpecifierValue(violation.trackedImport.declaration.source));
|
|
933
956
|
}
|
|
934
957
|
};
|
|
935
958
|
}
|
package/dist/index.d.cts
CHANGED
|
@@ -8,16 +8,20 @@ import "@typescript-eslint/utils/json-schema";
|
|
|
8
8
|
*/
|
|
9
9
|
type PublicConfigArray = Config[];
|
|
10
10
|
/**
|
|
11
|
-
* What
|
|
11
|
+
* 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.
|
|
12
12
|
*/
|
|
13
|
-
type PublicConfigObject = ConfigObject
|
|
13
|
+
type PublicConfigObject = ConfigObject;
|
|
14
|
+
/**
|
|
15
|
+
* 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`.
|
|
16
|
+
*/
|
|
17
|
+
type PublicConfigObjectArray = ConfigObject[];
|
|
14
18
|
/**
|
|
15
19
|
* 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<string, TSESLint.FlatConfig.Config | TSESLint.FlatConfig.ConfigArray>`) is not assignable to `@eslint/core`'s own `Record<string, LegacyConfigObject | ConfigObject | ConfigObject[]>` 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.
|
|
16
20
|
*
|
|
17
|
-
* 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<string, LegacyConfigObject | ConfigObject | ConfigObject[]> | 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<string, Plugin>` an `ExtendsElement`/`InfiniteArray<ConfigWithExtends>` 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).
|
|
21
|
+
* 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<string, LegacyConfigObject | ConfigObject | ConfigObject[]> | 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<string, Plugin>` an `ExtendsElement`/`InfiniteArray<ConfigWithExtends>` 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).
|
|
18
22
|
*/
|
|
19
23
|
type PublicPlugin = Omit<NonNullable<ConfigObject['plugins']>[string], 'configs'> & {
|
|
20
|
-
readonly configs: Readonly<Record<'recommended' | 'barrel'
|
|
24
|
+
readonly configs: Readonly<Record<'recommended' | 'barrel', PublicConfigObject> & Record<'react' | 'nextjs', PublicConfigObjectArray>>;
|
|
21
25
|
};
|
|
22
26
|
//#endregion
|
|
23
27
|
//#region src/agent-skills.d.ts
|
package/dist/index.d.ts
CHANGED
|
@@ -8,16 +8,20 @@ import "@typescript-eslint/utils/json-schema";
|
|
|
8
8
|
*/
|
|
9
9
|
type PublicConfigArray = Config[];
|
|
10
10
|
/**
|
|
11
|
-
* What
|
|
11
|
+
* 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.
|
|
12
12
|
*/
|
|
13
|
-
type PublicConfigObject = ConfigObject
|
|
13
|
+
type PublicConfigObject = ConfigObject;
|
|
14
|
+
/**
|
|
15
|
+
* 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`.
|
|
16
|
+
*/
|
|
17
|
+
type PublicConfigObjectArray = ConfigObject[];
|
|
14
18
|
/**
|
|
15
19
|
* 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<string, TSESLint.FlatConfig.Config | TSESLint.FlatConfig.ConfigArray>`) is not assignable to `@eslint/core`'s own `Record<string, LegacyConfigObject | ConfigObject | ConfigObject[]>` 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.
|
|
16
20
|
*
|
|
17
|
-
* 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<string, LegacyConfigObject | ConfigObject | ConfigObject[]> | 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<string, Plugin>` an `ExtendsElement`/`InfiniteArray<ConfigWithExtends>` 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).
|
|
21
|
+
* 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<string, LegacyConfigObject | ConfigObject | ConfigObject[]> | 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<string, Plugin>` an `ExtendsElement`/`InfiniteArray<ConfigWithExtends>` 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).
|
|
18
22
|
*/
|
|
19
23
|
type PublicPlugin = Omit<NonNullable<ConfigObject['plugins']>[string], 'configs'> & {
|
|
20
|
-
readonly configs: Readonly<Record<'recommended' | 'barrel'
|
|
24
|
+
readonly configs: Readonly<Record<'recommended' | 'barrel', PublicConfigObject> & Record<'react' | 'nextjs', PublicConfigObjectArray>>;
|
|
21
25
|
};
|
|
22
26
|
//#endregion
|
|
23
27
|
//#region src/agent-skills.d.ts
|
package/dist/index.js
CHANGED
|
@@ -450,7 +450,7 @@ function buildOptionalPluginConfig(preset, options = {}) {
|
|
|
450
450
|
}
|
|
451
451
|
//#endregion
|
|
452
452
|
//#region package.json
|
|
453
|
-
var version = "3.0
|
|
453
|
+
var version = "3.1.0";
|
|
454
454
|
//#endregion
|
|
455
455
|
//#region src/react.ts
|
|
456
456
|
/**
|
|
@@ -594,6 +594,14 @@ function isDirectSibling(specifier) {
|
|
|
594
594
|
return rest !== "." && rest !== ".." && rest !== "" && !rest.includes("/");
|
|
595
595
|
}
|
|
596
596
|
/**
|
|
597
|
+
* 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).
|
|
598
|
+
*/
|
|
599
|
+
function isPackageSpecifier(specifier) {
|
|
600
|
+
if (specifier.startsWith(".") || specifier.startsWith("/") || specifier.startsWith("#")) return false;
|
|
601
|
+
const scheme = /^[a-z][a-z\d+.-]*:/i.exec(specifier);
|
|
602
|
+
return scheme === null || scheme[0] === "node:";
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
597
605
|
* 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.
|
|
598
606
|
*/
|
|
599
607
|
function isBarrelMode(value) {
|
|
@@ -729,31 +737,41 @@ function decideAutoBarrelMode(packageJson) {
|
|
|
729
737
|
const hasMain = typeof main === "string" && main.length > 0;
|
|
730
738
|
return hasExports || hasMain ? "single" : "banned";
|
|
731
739
|
}
|
|
740
|
+
const packageRootByStartDir = /* @__PURE__ */ new Map();
|
|
732
741
|
const packageJsonByStartDir = /* @__PURE__ */ new Map();
|
|
733
742
|
/**
|
|
734
|
-
* The real, filesystem-backed
|
|
743
|
+
* 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.
|
|
735
744
|
*/
|
|
736
|
-
function
|
|
737
|
-
const cached =
|
|
738
|
-
if (
|
|
745
|
+
function findNearestPackageRoot(startDir) {
|
|
746
|
+
const cached = packageRootByStartDir.get(startDir);
|
|
747
|
+
if (packageRootByStartDir.has(startDir)) return cached;
|
|
739
748
|
let dir = startDir;
|
|
740
749
|
for (;;) {
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
const result = isRecord(parsed) ? parsed : void 0;
|
|
745
|
-
packageJsonByStartDir.set(startDir, result);
|
|
746
|
-
return result;
|
|
750
|
+
if (existsSync(join(dir, "package.json"))) {
|
|
751
|
+
packageRootByStartDir.set(startDir, dir);
|
|
752
|
+
return dir;
|
|
747
753
|
}
|
|
748
754
|
const parent = dirname(dir);
|
|
749
755
|
if (parent === dir) {
|
|
750
|
-
|
|
756
|
+
packageRootByStartDir.set(startDir, void 0);
|
|
751
757
|
return;
|
|
752
758
|
}
|
|
753
759
|
dir = parent;
|
|
754
760
|
}
|
|
755
761
|
}
|
|
756
762
|
/**
|
|
763
|
+
* The real, filesystem-backed ReadPackageJsonFn: finds the nearest ancestor package.json through findNearestPackageRoot (startDir itself included), parses it, and returns undefined when there is none. 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.
|
|
764
|
+
*/
|
|
765
|
+
function findNearestPackageJson(startDir) {
|
|
766
|
+
const cached = packageJsonByStartDir.get(startDir);
|
|
767
|
+
if (packageJsonByStartDir.has(startDir)) return cached;
|
|
768
|
+
const root = findNearestPackageRoot(startDir);
|
|
769
|
+
const parsed = root === void 0 ? void 0 : JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
|
|
770
|
+
const result = isRecord(parsed) ? parsed : void 0;
|
|
771
|
+
packageJsonByStartDir.set(startDir, result);
|
|
772
|
+
return result;
|
|
773
|
+
}
|
|
774
|
+
/**
|
|
757
775
|
* 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.
|
|
758
776
|
*/
|
|
759
777
|
function resolveAutoMode(filename, readPackageJsonFn = findNearestPackageJson) {
|
|
@@ -773,31 +791,52 @@ function readMode(options) {
|
|
|
773
791
|
return options.mode;
|
|
774
792
|
}
|
|
775
793
|
/**
|
|
794
|
+
* 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.
|
|
795
|
+
*/
|
|
796
|
+
function readEntries(options) {
|
|
797
|
+
if (!isRecord(options) || !("entries" in options)) return void 0;
|
|
798
|
+
if (options["mode"] === "banned") throw new Error("exadev/barrel-policy: \"entries\" declares barrels, which mode 'banned' forbids; use 'single', 'siblings' or 'auto' with entries.");
|
|
799
|
+
return readFileGlobs(options["entries"], "exadev/barrel-policy entries");
|
|
800
|
+
}
|
|
801
|
+
/**
|
|
802
|
+
* 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.
|
|
803
|
+
*/
|
|
804
|
+
function isDeclaredEntry(filename, entries, findPackageRoot) {
|
|
805
|
+
const root = findPackageRoot(dirname(filename));
|
|
806
|
+
if (root === void 0) return false;
|
|
807
|
+
return createPathMatcher(entries)(relativeToCwd(filename, root));
|
|
808
|
+
}
|
|
809
|
+
/**
|
|
776
810
|
* 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":
|
|
777
811
|
*
|
|
778
812
|
* 'banned' — no index files at all; re-exports banned everywhere. 'single' — exactly src/index.ts may be a barrel; it contains only re-exports; re-exports banned everywhere else. 'siblings' — any index file may be a barrel; each contains only re-exports; each re-export comes from a direct sibling (./module); re-exports banned in every non-index file.
|
|
779
813
|
*
|
|
780
814
|
* 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.
|
|
781
815
|
*
|
|
816
|
+
* 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.
|
|
817
|
+
*
|
|
782
818
|
* 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.
|
|
783
819
|
*
|
|
784
|
-
* A factory, not a plain object, specifically so a test can inject
|
|
820
|
+
* 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.
|
|
785
821
|
*/
|
|
786
|
-
function createBarrelPolicyRule(
|
|
822
|
+
function createBarrelPolicyRule({ readPackageJson = findNearestPackageJson, findPackageRoot = findNearestPackageRoot } = {}) {
|
|
787
823
|
return {
|
|
788
824
|
meta: {
|
|
789
825
|
type: "problem",
|
|
790
826
|
schema: [{
|
|
791
827
|
type: "object",
|
|
792
|
-
properties: {
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
828
|
+
properties: {
|
|
829
|
+
mode: {
|
|
830
|
+
type: "string",
|
|
831
|
+
enum: [
|
|
832
|
+
"banned",
|
|
833
|
+
"single",
|
|
834
|
+
"siblings",
|
|
835
|
+
"auto"
|
|
836
|
+
]
|
|
837
|
+
},
|
|
838
|
+
entries: fileGlobsSchema
|
|
839
|
+
},
|
|
801
840
|
additionalProperties: false
|
|
802
841
|
}],
|
|
803
842
|
messages: {
|
|
@@ -805,14 +844,41 @@ function createBarrelPolicyRule(readPackageJsonFn = findNearestPackageJson) {
|
|
|
805
844
|
nonMainIndexFile: "Only src/index.ts may be a barrel in this project — this index file is not it. Move its contents into the module that owns them or give the file a descriptive name.",
|
|
806
845
|
sideEffectInBarrel: "A barrel may contain only re-export statements ('export * from ...' / 'export { x } from ...' / 'export type { x } from ...') — nothing else, so it can never have a side effect at import time by construction. Found: {{ description }}.",
|
|
807
846
|
reexportOutsideBarrel: "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.",
|
|
808
|
-
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."
|
|
847
|
+
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.",
|
|
848
|
+
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."
|
|
809
849
|
}
|
|
810
850
|
},
|
|
811
851
|
create(context) {
|
|
812
852
|
const filename = context.filename;
|
|
813
853
|
const rawMode = readMode(context.options[0]);
|
|
814
|
-
const
|
|
854
|
+
const entries = readEntries(context.options[0]);
|
|
855
|
+
const mode = rawMode === "auto" ? resolveAutoMode(filename, readPackageJson) : rawMode;
|
|
856
|
+
const isEntry = mode !== "banned" && entries !== void 0 && isDeclaredEntry(filename, entries, findPackageRoot);
|
|
857
|
+
const isBarrel = isEntry || isPermittedBarrel(filename, mode);
|
|
815
858
|
const detector = createSplitReexportDetector();
|
|
859
|
+
/**
|
|
860
|
+
* 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).
|
|
861
|
+
*/
|
|
862
|
+
function checkReexportSource(node, source) {
|
|
863
|
+
if (!isBarrel) {
|
|
864
|
+
context.report({
|
|
865
|
+
node,
|
|
866
|
+
messageId: "reexportOutsideBarrel"
|
|
867
|
+
});
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
if (mode !== "siblings" || isDirectSibling(source)) return;
|
|
871
|
+
if (!isEntry) context.report({
|
|
872
|
+
node,
|
|
873
|
+
messageId: "notADirectSibling",
|
|
874
|
+
data: { source }
|
|
875
|
+
});
|
|
876
|
+
else if (!isPackageSpecifier(source)) context.report({
|
|
877
|
+
node,
|
|
878
|
+
messageId: "notAPackageOrSibling",
|
|
879
|
+
data: { source }
|
|
880
|
+
});
|
|
881
|
+
}
|
|
816
882
|
return {
|
|
817
883
|
Program(node) {
|
|
818
884
|
if (mode === "banned") {
|
|
@@ -822,79 +888,36 @@ function createBarrelPolicyRule(readPackageJsonFn = findNearestPackageJson) {
|
|
|
822
888
|
});
|
|
823
889
|
return;
|
|
824
890
|
}
|
|
825
|
-
if (
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
messageId: "nonMainIndexFile"
|
|
830
|
-
});
|
|
831
|
-
return;
|
|
832
|
-
}
|
|
833
|
-
if (isMainBarrel(filename)) {
|
|
834
|
-
for (const statement of node.body) if (!isPureReexport(statement)) context.report({
|
|
835
|
-
node: statement,
|
|
836
|
-
messageId: "sideEffectInBarrel",
|
|
837
|
-
data: { description: statement.type }
|
|
838
|
-
});
|
|
839
|
-
}
|
|
840
|
-
return;
|
|
841
|
-
}
|
|
842
|
-
if (isIndexFile(filename)) {
|
|
843
|
-
for (const statement of node.body) if (!isPureReexport(statement)) context.report({
|
|
844
|
-
node: statement,
|
|
845
|
-
messageId: "sideEffectInBarrel",
|
|
846
|
-
data: { description: statement.type }
|
|
891
|
+
if (isIndexFile(filename) && !isBarrel) {
|
|
892
|
+
context.report({
|
|
893
|
+
node,
|
|
894
|
+
messageId: "nonMainIndexFile"
|
|
847
895
|
});
|
|
896
|
+
return;
|
|
848
897
|
}
|
|
898
|
+
if (!isBarrel) return;
|
|
899
|
+
for (const statement of node.body) if (!isPureReexport(statement)) context.report({
|
|
900
|
+
node: statement,
|
|
901
|
+
messageId: "sideEffectInBarrel",
|
|
902
|
+
data: { description: statement.type }
|
|
903
|
+
});
|
|
849
904
|
},
|
|
850
905
|
ImportDeclaration: (node) => {
|
|
851
906
|
detector.visitImport(node);
|
|
852
907
|
},
|
|
853
908
|
ExportNamedDeclaration(node) {
|
|
854
909
|
detector.visitExportNamed(node);
|
|
855
|
-
if (hasSource(node) && !isInsideAmbientModuleDeclaration(node))
|
|
856
|
-
const source = moduleSpecifierValue(node.source);
|
|
857
|
-
if (!isPermittedBarrel(filename, mode)) context.report({
|
|
858
|
-
node,
|
|
859
|
-
messageId: "reexportOutsideBarrel"
|
|
860
|
-
});
|
|
861
|
-
else if (mode === "siblings" && !isDirectSibling(source)) context.report({
|
|
862
|
-
node,
|
|
863
|
-
messageId: "notADirectSibling",
|
|
864
|
-
data: { source }
|
|
865
|
-
});
|
|
866
|
-
}
|
|
910
|
+
if (hasSource(node) && !isInsideAmbientModuleDeclaration(node)) checkReexportSource(node, moduleSpecifierValue(node.source));
|
|
867
911
|
},
|
|
868
912
|
ExportAllDeclaration(node) {
|
|
869
913
|
if (isInsideAmbientModuleDeclaration(node)) return;
|
|
870
|
-
|
|
871
|
-
if (!isPermittedBarrel(filename, mode)) context.report({
|
|
872
|
-
node,
|
|
873
|
-
messageId: "reexportOutsideBarrel"
|
|
874
|
-
});
|
|
875
|
-
else if (mode === "siblings" && !isDirectSibling(source)) context.report({
|
|
876
|
-
node,
|
|
877
|
-
messageId: "notADirectSibling",
|
|
878
|
-
data: { source }
|
|
879
|
-
});
|
|
914
|
+
checkReexportSource(node, moduleSpecifierValue(node.source));
|
|
880
915
|
},
|
|
881
916
|
ExportDefaultDeclaration: (node) => {
|
|
882
917
|
detector.visitExportDefault(node);
|
|
883
918
|
},
|
|
884
919
|
"Program:exit"() {
|
|
885
|
-
for (const violation of detector.violations())
|
|
886
|
-
if (mode === "siblings") {
|
|
887
|
-
const importSource = moduleSpecifierValue(violation.trackedImport.declaration.source);
|
|
888
|
-
if (!isDirectSibling(importSource)) context.report({
|
|
889
|
-
node: violation.kind === "named" ? violation.specifier : violation.declaration,
|
|
890
|
-
messageId: "notADirectSibling",
|
|
891
|
-
data: { source: importSource }
|
|
892
|
-
});
|
|
893
|
-
}
|
|
894
|
-
} else context.report({
|
|
895
|
-
node: violation.kind === "named" ? violation.specifier : violation.declaration,
|
|
896
|
-
messageId: "reexportOutsideBarrel"
|
|
897
|
-
});
|
|
920
|
+
for (const violation of detector.violations()) checkReexportSource(violation.kind === "named" ? violation.specifier : violation.declaration, moduleSpecifierValue(violation.trackedImport.declaration.source));
|
|
898
921
|
}
|
|
899
922
|
};
|
|
900
923
|
}
|