exadev-eslint-config 2.18.1 → 2.19.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 CHANGED
@@ -16,7 +16,7 @@ Consumers need `eslint >=10.0.0` and `typescript-eslint >=8.0.0` as required pee
16
16
  pnpm add -D @exadev/eslint-config typescript-eslint eslint
17
17
  ```
18
18
 
19
- The default export is the full, type-checked ruleset: typescript-eslint's `strictTypeChecked` + `stylisticTypeChecked` presets (`strictTypeChecked` subsumes `recommendedTypeChecked`, so it already includes `no-deprecated`, `no-misused-spread`, `no-mixed-enums`, `no-unnecessary-condition`, `use-unknown-in-catch-callback-variable`, `return-await`, `related-getter-setter-pairs`, `no-unnecessary-type-parameters`, and more — those are no longer re-listed below), `exadev/barrel-policy` at `mode: 'banned'` (see [Barrel policy](#barrel-policy)), `exadev/no-object-assign`, `exadev/no-mutable-union-array-param`, `exadev/no-array-isarray-mutation`, `exadev/no-enum-number-widening`, `exadev/no-enum-reverse-lookup-widening`, `exadev/no-map-instanceof-mutation`, `exadev/no-set-instanceof-mutation`, `exadev/prefer-readonly-array-param`, `exadev/prefer-readonly-object-param`, `exadev/prefer-numeric-sort-compare`, `exadev/no-pointless-reassignment`, `exadev/test-file-kind`, `linterOptions.noInlineConfig`, `@typescript-eslint/consistent-type-assertions` banning all type assertions, `@typescript-eslint/consistent-type-imports`, `@typescript-eslint/consistent-type-exports`, `@typescript-eslint/consistent-return` (a function that implicitly returns `undefined` on one path and a real value on another — a common real bug, not a deliberate design), `@typescript-eslint/no-non-null-assertion` banning the `!` operator (the same manual-override escape hatch as a type assertion, under a different spelling), `@typescript-eslint/no-redeclare`, `@typescript-eslint/no-shadow`, `@typescript-eslint/no-use-before-define` set to `{ functions: false }` (a genuine temporal-dead-zone crash risk for `let`/`const`/`class`/enum bindings, but exempting function declarations, which are fully hoisted and therefore runtime-safe to call before their point of textual declaration — this codebase's own rule files consistently define their helper functions after the logic that calls them), `@typescript-eslint/ban-ts-comment` banning `@ts-expect-error` outright, `@typescript-eslint/method-signature-style` set to `'property'` (method-shorthand signatures are checked bivariantly under `strictFunctionTypes`, which is unsound), `@typescript-eslint/prefer-readonly`, `@typescript-eslint/promise-function-async`, `@typescript-eslint/require-array-sort-compare`, `@typescript-eslint/strict-void-return` (not yet in any typescript-eslint preset — disallows passing a value-returning function where a void-returning one is expected, e.g. `arr.forEach(x => otherArray.push(x))`, which typechecks today under TS's own void-return contravariance leniency), `@typescript-eslint/switch-exhaustiveness-check`, `@typescript-eslint/strict-boolean-expressions` at the rule's own bare defaults (an unambiguous non-nullable truthy check stays allowed; an ambiguous nullable check does not), `@typescript-eslint/no-magic-numbers` tuned to exempt array indexes, enum members, readonly class properties, default parameter values, numeric literal types (e.g. `type Indent = 2 | 4`), and the handful of universally-idiomatic bare numbers (`-1`, `0`, `1`, `2`), `max-lines` set to `{ max: 800, skipBlankLines: true, skipComments: true }` (counting only real code, so a file isn't pushed over the limit by whitespace or its own WHY-explanation comments), and `no-warning-comments` banning any comment containing `Stryker disable` (Stryker's own mutation-testing suppression directive, invisible to ESLint's `noInlineConfig` above since it isn't an eslint-disable comment at all) — the type-assertion and ts-comment rules are relaxed in test files, and `no-magic-numbers`/`max-lines`/`no-warning-comments` are not (see below). Spread it directly into `tseslint.config(...)`:
19
+ The default export is the full, type-checked ruleset: typescript-eslint's `strictTypeChecked` + `stylisticTypeChecked` presets (`strictTypeChecked` subsumes `recommendedTypeChecked`, so it already includes `no-deprecated`, `no-misused-spread`, `no-mixed-enums`, `no-unnecessary-condition`, `use-unknown-in-catch-callback-variable`, `return-await`, `related-getter-setter-pairs`, `no-unnecessary-type-parameters`, and more — those are no longer re-listed below), `exadev/barrel-policy` at its auto-detecting default (see [Barrel policy](#barrel-policy)), `exadev/no-object-assign`, `exadev/no-mutable-union-array-param`, `exadev/no-array-isarray-mutation`, `exadev/no-enum-number-widening`, `exadev/no-enum-reverse-lookup-widening`, `exadev/no-map-instanceof-mutation`, `exadev/no-set-instanceof-mutation`, `exadev/prefer-readonly-array-param`, `exadev/prefer-readonly-object-param`, `exadev/prefer-numeric-sort-compare`, `exadev/no-pointless-reassignment`, `exadev/test-file-kind`, `linterOptions.noInlineConfig`, `@typescript-eslint/consistent-type-assertions` banning all type assertions, `@typescript-eslint/consistent-type-imports`, `@typescript-eslint/consistent-type-exports`, `@typescript-eslint/consistent-return` (a function that implicitly returns `undefined` on one path and a real value on another — a common real bug, not a deliberate design), `@typescript-eslint/no-non-null-assertion` banning the `!` operator (the same manual-override escape hatch as a type assertion, under a different spelling), `@typescript-eslint/no-redeclare`, `@typescript-eslint/no-shadow`, `@typescript-eslint/no-use-before-define` set to `{ functions: false }` (a genuine temporal-dead-zone crash risk for `let`/`const`/`class`/enum bindings, but exempting function declarations, which are fully hoisted and therefore runtime-safe to call before their point of textual declaration — this codebase's own rule files consistently define their helper functions after the logic that calls them), `@typescript-eslint/ban-ts-comment` banning `@ts-expect-error` outright, `@typescript-eslint/method-signature-style` set to `'property'` (method-shorthand signatures are checked bivariantly under `strictFunctionTypes`, which is unsound), `@typescript-eslint/prefer-readonly`, `@typescript-eslint/promise-function-async`, `@typescript-eslint/require-array-sort-compare`, `@typescript-eslint/strict-void-return` (not yet in any typescript-eslint preset — disallows passing a value-returning function where a void-returning one is expected, e.g. `arr.forEach(x => otherArray.push(x))`, which typechecks today under TS's own void-return contravariance leniency), `@typescript-eslint/switch-exhaustiveness-check`, `@typescript-eslint/strict-boolean-expressions` at the rule's own bare defaults (an unambiguous non-nullable truthy check stays allowed; an ambiguous nullable check does not), `@typescript-eslint/no-magic-numbers` tuned to exempt array indexes, enum members, readonly class properties, default parameter values, numeric literal types (e.g. `type Indent = 2 | 4`), and the handful of universally-idiomatic bare numbers (`-1`, `0`, `1`, `2`), `max-lines` set to `{ max: 800, skipBlankLines: true, skipComments: true }` (counting only real code, so a file isn't pushed over the limit by whitespace or its own WHY-explanation comments), and `no-warning-comments` banning any comment containing `Stryker disable` (Stryker's own mutation-testing suppression directive, invisible to ESLint's `noInlineConfig` above since it isn't an eslint-disable comment at all) — the type-assertion and ts-comment rules are relaxed in test files, and `no-magic-numbers`/`max-lines`/`no-warning-comments` are not (see below). Spread it directly into `tseslint.config(...)`:
20
20
 
21
21
  ```ts
22
22
  // eslint.config.ts
@@ -34,11 +34,11 @@ export default tseslint.config(
34
34
  );
35
35
  ```
36
36
 
37
- **A published package whose `src/index.ts` is its package entry point overrides `banned` to `single` in one line** (flat-config later blocks override earlier rule settings), since deleting its barrel would break every downstream importer:
37
+ **The default export's `barrel-policy` mode is `'auto'`, so a published package's `src/index.ts` is permitted as a barrel with no config change on your part** — it walks up from the linted file to the nearest ancestor `package.json` and checks for a real `exports` or `main`; find one and the file's package resolves to `single`, find neither and it resolves to `banned`, identical to this package's old hardcoded default. Override only when you want a fixed policy regardless of what your own `package.json` looks like — e.g. `siblings` mode, which `auto` never resolves to on its own (flat-config later blocks override earlier rule settings):
38
38
 
39
39
  ```ts
40
40
  ...exadev,
41
- { rules: { 'exadev/barrel-policy': ['error', { mode: 'single' }] } }, // this package keeps its barrel
41
+ { rules: { 'exadev/barrel-policy': ['error', { mode: 'siblings' }] } }, // any index file may be a barrel, not just src/index.ts
42
42
  ```
43
43
 
44
44
  `strictTypeChecked` subsumes both typescript-eslint's plain `recommended` and `recommendedTypeChecked` outright (every rule in each is also present in `strictTypeChecked`), and its base config registers the `@typescript-eslint` plugin and sets `languageOptions.parser` itself. That is why **you must remove your own `...tseslint.configs.recommended`/`recommendedTypeChecked`/`strictTypeChecked`/`stylisticTypeChecked` spreads** — flat config rejects two different plugin object instances registered under the same namespace. You still supply `languageOptions.parserOptions.project`/`projectService` pointing at your own tsconfig(s).
@@ -225,14 +225,17 @@ Bundled into `exadevConfig()`'s default output the same way React/Next.js auto-d
225
225
 
226
226
  ## Barrel policy
227
227
 
228
- `exadev/barrel-policy` is the convenience layer: one rule id, one `{ mode }` option selecting a complete index-file policy. Use EITHER this umbrella OR the individual rules (not both — they double-report).
228
+ `exadev/barrel-policy` is the convenience layer: one rule id, one `{ mode }` option selecting a complete index-file policy. Use EITHER this umbrella OR the individual rules (not both — they double-report). Omitting `mode` entirely, or passing an options object with no `mode` key, means `'auto'`.
229
229
 
230
230
  | `mode` | Which files may be barrels | What a barrel may contain | Where re-exports may come from |
231
231
  | --- | --- | --- | --- |
232
- | `'banned'` (default/recommended) | none | — | |
233
- | `'single'` | exactly `src/index.ts` | only re-exports | anywhere |
232
+ | `'auto'` (what an omitted `mode` means) | detected per file walks up to the nearest ancestor `package.json`; a real `exports`/`main` there resolves to `single`, otherwise `banned` | only re-exports when detected as `single` | anywhere, when detected as `single` |
233
+ | `'banned'` (`plugin.configs.recommended`'s explicit choice) | none | | |
234
+ | `'single'` (`plugin.configs.barrel`'s explicit choice) | exactly `src/index.ts` | only re-exports | anywhere |
234
235
  | `'siblings'` | any `index.ts` | only re-exports | a direct sibling only (`./module`) |
235
236
 
237
+ `'auto'` only ever resolves to `banned` or `single` — there is no single-signal auto-equivalent for `siblings` (which package.json field would suggest "any index file, not just the entry point"?), so a project wanting that policy still states it explicitly. `private: true` in `package.json` is not consulted by the detection: a pnpm workspace package is routinely both `private` and a genuine import target for sibling packages via `exports`, so `private` says nothing about whether a barrel is warranted.
238
+
236
239
  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`). It is non-fixable — the autofix lives on `no-non-barrel-reexport`.
237
240
 
238
241
  ## Build, test, and lint
package/dist/index.cjs CHANGED
@@ -260,13 +260,13 @@ function tryRequire(specifier, requireFn = nodeRequire) {
260
260
  return;
261
261
  }
262
262
  }
263
- function isRecord$1(value) {
263
+ function isRecord$2(value) {
264
264
  return typeof value === "object" && value !== null;
265
265
  }
266
266
  function normalizeLegacyParserOptions(record) {
267
267
  if (!("parserOptions" in record)) return record;
268
268
  const { parserOptions, languageOptions, ...rest } = record;
269
- const existingLanguageOptions = isRecord$1(languageOptions) ? languageOptions : {};
269
+ const existingLanguageOptions = isRecord$2(languageOptions) ? languageOptions : {};
270
270
  return {
271
271
  ...rest,
272
272
  languageOptions: {
@@ -278,10 +278,10 @@ function normalizeLegacyParserOptions(record) {
278
278
  function readFlatConfig(module, path) {
279
279
  let current = module;
280
280
  for (const key of path) {
281
- if (!isRecord$1(current)) return void 0;
281
+ if (!isRecord$2(current)) return void 0;
282
282
  current = current[key];
283
283
  }
284
- if (!isRecord$1(current)) return void 0;
284
+ if (!isRecord$2(current)) return void 0;
285
285
  return normalizeLegacyParserOptions(current);
286
286
  }
287
287
  //#endregion
@@ -295,7 +295,7 @@ function buildNextjsConfig(options = {}) {
295
295
  }
296
296
  //#endregion
297
297
  //#region package.json
298
- var version = "2.18.1";
298
+ var version = "2.19.0";
299
299
  //#endregion
300
300
  //#region src/react.ts
301
301
  const JSX_FILE_PATTERNS = ["**/*.jsx", "**/*.tsx"];
@@ -362,6 +362,9 @@ function isDirectSibling(specifier) {
362
362
  function isBarrelMode(value) {
363
363
  return value === "banned" || value === "single" || value === "siblings";
364
364
  }
365
+ function isRawBarrelMode(value) {
366
+ return value === "auto" || isBarrelMode(value);
367
+ }
365
368
  function isPermittedBarrel(filename, mode) {
366
369
  if (mode === "banned") return false;
367
370
  if (mode === "single") return isMainBarrel(filename);
@@ -468,79 +471,139 @@ const barrelDirectSiblingsOnly = {
468
471
  }
469
472
  };
470
473
  //#endregion
474
+ //#region src/rules/barrel-auto-detect.ts
475
+ function isRecord$1(value) {
476
+ return typeof value === "object" && value !== null;
477
+ }
478
+ function hasRealExports(value) {
479
+ if (typeof value === "string") return value.length > 0;
480
+ if (Array.isArray(value)) return value.length > 0;
481
+ return isRecord$1(value) && Object.keys(value).length > 0;
482
+ }
483
+ function decideAutoBarrelMode(packageJson) {
484
+ const hasExports = hasRealExports(packageJson["exports"]);
485
+ const main = packageJson["main"];
486
+ const hasMain = typeof main === "string" && main.length > 0;
487
+ return hasExports || hasMain ? "single" : "banned";
488
+ }
489
+ const packageJsonByStartDir = /* @__PURE__ */ new Map();
490
+ function findNearestPackageJson(startDir) {
491
+ const cached = packageJsonByStartDir.get(startDir);
492
+ if (packageJsonByStartDir.has(startDir)) return cached;
493
+ let dir = startDir;
494
+ for (;;) {
495
+ const candidate = (0, node_path.join)(dir, "package.json");
496
+ if ((0, node_fs.existsSync)(candidate)) {
497
+ const parsed = JSON.parse((0, node_fs.readFileSync)(candidate, "utf8"));
498
+ const result = isRecord$1(parsed) ? parsed : void 0;
499
+ packageJsonByStartDir.set(startDir, result);
500
+ return result;
501
+ }
502
+ const parent = (0, node_path.dirname)(dir);
503
+ if (parent === dir) {
504
+ packageJsonByStartDir.set(startDir, void 0);
505
+ return;
506
+ }
507
+ dir = parent;
508
+ }
509
+ }
510
+ function resolveAutoMode(filename, readPackageJsonFn = findNearestPackageJson) {
511
+ const packageJson = readPackageJsonFn((0, node_path.dirname)(filename));
512
+ return packageJson === void 0 ? "banned" : decideAutoBarrelMode(packageJson);
513
+ }
514
+ //#endregion
471
515
  //#region src/rules/barrel-policy.ts
472
516
  function readMode(options) {
473
- if (typeof options !== "object" || options === null || !("mode" in options) || !isBarrelMode(options.mode)) throw new Error("exadev/barrel-policy requires options: { mode: 'banned' | 'single' | 'siblings' }.");
517
+ if (options === void 0) return "auto";
518
+ if (typeof options !== "object" || options === null) throw new Error("exadev/barrel-policy requires options: { mode: 'banned' | 'single' | 'siblings' | 'auto' }.");
519
+ if (!("mode" in options)) return "auto";
520
+ if (!isRawBarrelMode(options.mode)) throw new Error("exadev/barrel-policy requires options: { mode: 'banned' | 'single' | 'siblings' | 'auto' }.");
474
521
  return options.mode;
475
522
  }
476
- const barrelPolicy = {
477
- meta: {
478
- type: "problem",
479
- schema: [{
480
- type: "object",
481
- properties: { mode: {
482
- type: "string",
483
- enum: [
484
- "banned",
485
- "single",
486
- "siblings"
487
- ]
488
- } },
489
- required: ["mode"],
490
- additionalProperties: false
491
- }],
492
- messages: {
493
- indexFileBanned: "Index (barrel) files are banned in this project — import directly from the module that owns the export instead. Rename this file to something descriptive.",
494
- 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.",
495
- 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 }}.",
496
- 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.",
497
- 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."
498
- }
499
- },
500
- create(context) {
501
- const mode = readMode(context.options[0]);
502
- const filename = context.filename;
503
- const detector = createSplitReexportDetector();
504
- return {
505
- Program(node) {
506
- if (mode === "banned") {
507
- if (isIndexFile(filename)) context.report({
508
- node,
509
- messageId: "indexFileBanned"
510
- });
511
- return;
512
- }
513
- if (mode === "single") {
514
- if (isIndexFile(filename) && !isMainBarrel(filename)) {
515
- context.report({
523
+ function createBarrelPolicyRule(readPackageJsonFn = findNearestPackageJson) {
524
+ return {
525
+ meta: {
526
+ type: "problem",
527
+ schema: [{
528
+ type: "object",
529
+ properties: { mode: {
530
+ type: "string",
531
+ enum: [
532
+ "banned",
533
+ "single",
534
+ "siblings",
535
+ "auto"
536
+ ]
537
+ } },
538
+ additionalProperties: false
539
+ }],
540
+ messages: {
541
+ indexFileBanned: "Index (barrel) files are banned in this project — import directly from the module that owns the export instead. Rename this file to something descriptive.",
542
+ 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.",
543
+ 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 }}.",
544
+ 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.",
545
+ 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."
546
+ }
547
+ },
548
+ create(context) {
549
+ const filename = context.filename;
550
+ const rawMode = readMode(context.options[0]);
551
+ const mode = rawMode === "auto" ? resolveAutoMode(filename, readPackageJsonFn) : rawMode;
552
+ const detector = createSplitReexportDetector();
553
+ return {
554
+ Program(node) {
555
+ if (mode === "banned") {
556
+ if (isIndexFile(filename)) context.report({
516
557
  node,
517
- messageId: "nonMainIndexFile"
558
+ messageId: "indexFileBanned"
518
559
  });
519
560
  return;
520
561
  }
521
- if (isMainBarrel(filename)) {
562
+ if (mode === "single") {
563
+ if (isIndexFile(filename) && !isMainBarrel(filename)) {
564
+ context.report({
565
+ node,
566
+ messageId: "nonMainIndexFile"
567
+ });
568
+ return;
569
+ }
570
+ if (isMainBarrel(filename)) {
571
+ for (const statement of node.body) if (!isPureReexport(statement)) context.report({
572
+ node: statement,
573
+ messageId: "sideEffectInBarrel",
574
+ data: { description: statement.type }
575
+ });
576
+ }
577
+ return;
578
+ }
579
+ if (isIndexFile(filename)) {
522
580
  for (const statement of node.body) if (!isPureReexport(statement)) context.report({
523
581
  node: statement,
524
582
  messageId: "sideEffectInBarrel",
525
583
  data: { description: statement.type }
526
584
  });
527
585
  }
528
- return;
529
- }
530
- if (isIndexFile(filename)) {
531
- for (const statement of node.body) if (!isPureReexport(statement)) context.report({
532
- node: statement,
533
- messageId: "sideEffectInBarrel",
534
- data: { description: statement.type }
535
- });
536
- }
537
- },
538
- ImportDeclaration: (node) => {
539
- detector.visitImport(node);
540
- },
541
- ExportNamedDeclaration(node) {
542
- detector.visitExportNamed(node);
543
- if (hasSource(node) && !isInsideAmbientModuleDeclaration(node)) {
586
+ },
587
+ ImportDeclaration: (node) => {
588
+ detector.visitImport(node);
589
+ },
590
+ ExportNamedDeclaration(node) {
591
+ detector.visitExportNamed(node);
592
+ if (hasSource(node) && !isInsideAmbientModuleDeclaration(node)) {
593
+ const source = moduleSpecifierValue(node.source);
594
+ if (!isPermittedBarrel(filename, mode)) context.report({
595
+ node,
596
+ messageId: "reexportOutsideBarrel"
597
+ });
598
+ else if (mode === "siblings" && !isDirectSibling(source)) context.report({
599
+ node,
600
+ messageId: "notADirectSibling",
601
+ data: { source }
602
+ });
603
+ }
604
+ },
605
+ ExportAllDeclaration(node) {
606
+ if (isInsideAmbientModuleDeclaration(node)) return;
544
607
  const source = moduleSpecifierValue(node.source);
545
608
  if (!isPermittedBarrel(filename, mode)) context.report({
546
609
  node,
@@ -551,42 +614,30 @@ const barrelPolicy = {
551
614
  messageId: "notADirectSibling",
552
615
  data: { source }
553
616
  });
617
+ },
618
+ ExportDefaultDeclaration: (node) => {
619
+ detector.visitExportDefault(node);
620
+ },
621
+ "Program:exit"() {
622
+ for (const violation of detector.violations()) if (isPermittedBarrel(filename, mode)) {
623
+ if (mode === "siblings") {
624
+ const importSource = moduleSpecifierValue(violation.trackedImport.declaration.source);
625
+ if (!isDirectSibling(importSource)) context.report({
626
+ node: violation.kind === "named" ? violation.specifier : violation.declaration,
627
+ messageId: "notADirectSibling",
628
+ data: { source: importSource }
629
+ });
630
+ }
631
+ } else context.report({
632
+ node: violation.kind === "named" ? violation.specifier : violation.declaration,
633
+ messageId: "reexportOutsideBarrel"
634
+ });
554
635
  }
555
- },
556
- ExportAllDeclaration(node) {
557
- if (isInsideAmbientModuleDeclaration(node)) return;
558
- const source = moduleSpecifierValue(node.source);
559
- if (!isPermittedBarrel(filename, mode)) context.report({
560
- node,
561
- messageId: "reexportOutsideBarrel"
562
- });
563
- else if (mode === "siblings" && !isDirectSibling(source)) context.report({
564
- node,
565
- messageId: "notADirectSibling",
566
- data: { source }
567
- });
568
- },
569
- ExportDefaultDeclaration: (node) => {
570
- detector.visitExportDefault(node);
571
- },
572
- "Program:exit"() {
573
- for (const violation of detector.violations()) if (isPermittedBarrel(filename, mode)) {
574
- if (mode === "siblings") {
575
- const importSource = moduleSpecifierValue(violation.trackedImport.declaration.source);
576
- if (!isDirectSibling(importSource)) context.report({
577
- node: violation.kind === "named" ? violation.specifier : violation.declaration,
578
- messageId: "notADirectSibling",
579
- data: { source: importSource }
580
- });
581
- }
582
- } else context.report({
583
- node: violation.kind === "named" ? violation.specifier : violation.declaration,
584
- messageId: "reexportOutsideBarrel"
585
- });
586
- }
587
- };
588
- }
589
- };
636
+ };
637
+ }
638
+ };
639
+ }
640
+ var barrel_policy_default = createBarrelPolicyRule();
590
641
  //#endregion
591
642
  //#region src/rules/scope-guards.ts
592
643
  function asIdentifierName(name) {
@@ -1837,7 +1888,7 @@ const plugin = {
1837
1888
  },
1838
1889
  rules: {
1839
1890
  "barrel-direct-siblings-only": barrelDirectSiblingsOnly,
1840
- "barrel-policy": barrelPolicy,
1891
+ "barrel-policy": barrel_policy_default,
1841
1892
  "no-array-isarray-mutation": noArrayIsArrayMutation,
1842
1893
  "no-control-flow": noControlFlow,
1843
1894
  "no-enum-number-widening": noEnumNumberWidening,
@@ -2017,7 +2068,7 @@ const recommendedTypeChecked = [
2017
2068
  plugins: { exadev: plugin },
2018
2069
  linterOptions: { noInlineConfig: true },
2019
2070
  rules: {
2020
- "exadev/barrel-policy": ["error", { mode: "banned" }],
2071
+ "exadev/barrel-policy": "error",
2021
2072
  "exadev/no-array-isarray-mutation": "error",
2022
2073
  "exadev/no-enum-number-widening": "error",
2023
2074
  "exadev/no-enum-reverse-lookup-widening": "error",
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import fs, { existsSync, readFileSync } from "node:fs";
2
- import path, { join, posix } from "node:path";
2
+ import path, { dirname, join, posix } from "node:path";
3
3
  import jsdoc from "eslint-plugin-jsdoc";
4
4
  import tsdoc from "eslint-plugin-tsdoc";
5
5
  import jsonCanonical from "eslint-plugin-json-canonical";
@@ -226,13 +226,13 @@ function tryRequire(specifier, requireFn = nodeRequire) {
226
226
  return;
227
227
  }
228
228
  }
229
- function isRecord$1(value) {
229
+ function isRecord$2(value) {
230
230
  return typeof value === "object" && value !== null;
231
231
  }
232
232
  function normalizeLegacyParserOptions(record) {
233
233
  if (!("parserOptions" in record)) return record;
234
234
  const { parserOptions, languageOptions, ...rest } = record;
235
- const existingLanguageOptions = isRecord$1(languageOptions) ? languageOptions : {};
235
+ const existingLanguageOptions = isRecord$2(languageOptions) ? languageOptions : {};
236
236
  return {
237
237
  ...rest,
238
238
  languageOptions: {
@@ -244,10 +244,10 @@ function normalizeLegacyParserOptions(record) {
244
244
  function readFlatConfig(module, path) {
245
245
  let current = module;
246
246
  for (const key of path) {
247
- if (!isRecord$1(current)) return void 0;
247
+ if (!isRecord$2(current)) return void 0;
248
248
  current = current[key];
249
249
  }
250
- if (!isRecord$1(current)) return void 0;
250
+ if (!isRecord$2(current)) return void 0;
251
251
  return normalizeLegacyParserOptions(current);
252
252
  }
253
253
  //#endregion
@@ -261,7 +261,7 @@ function buildNextjsConfig(options = {}) {
261
261
  }
262
262
  //#endregion
263
263
  //#region package.json
264
- var version = "2.18.1";
264
+ var version = "2.19.0";
265
265
  //#endregion
266
266
  //#region src/react.ts
267
267
  const JSX_FILE_PATTERNS = ["**/*.jsx", "**/*.tsx"];
@@ -328,6 +328,9 @@ function isDirectSibling(specifier) {
328
328
  function isBarrelMode(value) {
329
329
  return value === "banned" || value === "single" || value === "siblings";
330
330
  }
331
+ function isRawBarrelMode(value) {
332
+ return value === "auto" || isBarrelMode(value);
333
+ }
331
334
  function isPermittedBarrel(filename, mode) {
332
335
  if (mode === "banned") return false;
333
336
  if (mode === "single") return isMainBarrel(filename);
@@ -434,79 +437,139 @@ const barrelDirectSiblingsOnly = {
434
437
  }
435
438
  };
436
439
  //#endregion
440
+ //#region src/rules/barrel-auto-detect.ts
441
+ function isRecord$1(value) {
442
+ return typeof value === "object" && value !== null;
443
+ }
444
+ function hasRealExports(value) {
445
+ if (typeof value === "string") return value.length > 0;
446
+ if (Array.isArray(value)) return value.length > 0;
447
+ return isRecord$1(value) && Object.keys(value).length > 0;
448
+ }
449
+ function decideAutoBarrelMode(packageJson) {
450
+ const hasExports = hasRealExports(packageJson["exports"]);
451
+ const main = packageJson["main"];
452
+ const hasMain = typeof main === "string" && main.length > 0;
453
+ return hasExports || hasMain ? "single" : "banned";
454
+ }
455
+ const packageJsonByStartDir = /* @__PURE__ */ new Map();
456
+ function findNearestPackageJson(startDir) {
457
+ const cached = packageJsonByStartDir.get(startDir);
458
+ if (packageJsonByStartDir.has(startDir)) return cached;
459
+ let dir = startDir;
460
+ for (;;) {
461
+ const candidate = join(dir, "package.json");
462
+ if (existsSync(candidate)) {
463
+ const parsed = JSON.parse(readFileSync(candidate, "utf8"));
464
+ const result = isRecord$1(parsed) ? parsed : void 0;
465
+ packageJsonByStartDir.set(startDir, result);
466
+ return result;
467
+ }
468
+ const parent = dirname(dir);
469
+ if (parent === dir) {
470
+ packageJsonByStartDir.set(startDir, void 0);
471
+ return;
472
+ }
473
+ dir = parent;
474
+ }
475
+ }
476
+ function resolveAutoMode(filename, readPackageJsonFn = findNearestPackageJson) {
477
+ const packageJson = readPackageJsonFn(dirname(filename));
478
+ return packageJson === void 0 ? "banned" : decideAutoBarrelMode(packageJson);
479
+ }
480
+ //#endregion
437
481
  //#region src/rules/barrel-policy.ts
438
482
  function readMode(options) {
439
- if (typeof options !== "object" || options === null || !("mode" in options) || !isBarrelMode(options.mode)) throw new Error("exadev/barrel-policy requires options: { mode: 'banned' | 'single' | 'siblings' }.");
483
+ if (options === void 0) return "auto";
484
+ if (typeof options !== "object" || options === null) throw new Error("exadev/barrel-policy requires options: { mode: 'banned' | 'single' | 'siblings' | 'auto' }.");
485
+ if (!("mode" in options)) return "auto";
486
+ if (!isRawBarrelMode(options.mode)) throw new Error("exadev/barrel-policy requires options: { mode: 'banned' | 'single' | 'siblings' | 'auto' }.");
440
487
  return options.mode;
441
488
  }
442
- const barrelPolicy = {
443
- meta: {
444
- type: "problem",
445
- schema: [{
446
- type: "object",
447
- properties: { mode: {
448
- type: "string",
449
- enum: [
450
- "banned",
451
- "single",
452
- "siblings"
453
- ]
454
- } },
455
- required: ["mode"],
456
- additionalProperties: false
457
- }],
458
- messages: {
459
- indexFileBanned: "Index (barrel) files are banned in this project — import directly from the module that owns the export instead. Rename this file to something descriptive.",
460
- 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.",
461
- 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 }}.",
462
- 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.",
463
- 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."
464
- }
465
- },
466
- create(context) {
467
- const mode = readMode(context.options[0]);
468
- const filename = context.filename;
469
- const detector = createSplitReexportDetector();
470
- return {
471
- Program(node) {
472
- if (mode === "banned") {
473
- if (isIndexFile(filename)) context.report({
474
- node,
475
- messageId: "indexFileBanned"
476
- });
477
- return;
478
- }
479
- if (mode === "single") {
480
- if (isIndexFile(filename) && !isMainBarrel(filename)) {
481
- context.report({
489
+ function createBarrelPolicyRule(readPackageJsonFn = findNearestPackageJson) {
490
+ return {
491
+ meta: {
492
+ type: "problem",
493
+ schema: [{
494
+ type: "object",
495
+ properties: { mode: {
496
+ type: "string",
497
+ enum: [
498
+ "banned",
499
+ "single",
500
+ "siblings",
501
+ "auto"
502
+ ]
503
+ } },
504
+ additionalProperties: false
505
+ }],
506
+ messages: {
507
+ indexFileBanned: "Index (barrel) files are banned in this project — import directly from the module that owns the export instead. Rename this file to something descriptive.",
508
+ 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.",
509
+ 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 }}.",
510
+ 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.",
511
+ 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."
512
+ }
513
+ },
514
+ create(context) {
515
+ const filename = context.filename;
516
+ const rawMode = readMode(context.options[0]);
517
+ const mode = rawMode === "auto" ? resolveAutoMode(filename, readPackageJsonFn) : rawMode;
518
+ const detector = createSplitReexportDetector();
519
+ return {
520
+ Program(node) {
521
+ if (mode === "banned") {
522
+ if (isIndexFile(filename)) context.report({
482
523
  node,
483
- messageId: "nonMainIndexFile"
524
+ messageId: "indexFileBanned"
484
525
  });
485
526
  return;
486
527
  }
487
- if (isMainBarrel(filename)) {
528
+ if (mode === "single") {
529
+ if (isIndexFile(filename) && !isMainBarrel(filename)) {
530
+ context.report({
531
+ node,
532
+ messageId: "nonMainIndexFile"
533
+ });
534
+ return;
535
+ }
536
+ if (isMainBarrel(filename)) {
537
+ for (const statement of node.body) if (!isPureReexport(statement)) context.report({
538
+ node: statement,
539
+ messageId: "sideEffectInBarrel",
540
+ data: { description: statement.type }
541
+ });
542
+ }
543
+ return;
544
+ }
545
+ if (isIndexFile(filename)) {
488
546
  for (const statement of node.body) if (!isPureReexport(statement)) context.report({
489
547
  node: statement,
490
548
  messageId: "sideEffectInBarrel",
491
549
  data: { description: statement.type }
492
550
  });
493
551
  }
494
- return;
495
- }
496
- if (isIndexFile(filename)) {
497
- for (const statement of node.body) if (!isPureReexport(statement)) context.report({
498
- node: statement,
499
- messageId: "sideEffectInBarrel",
500
- data: { description: statement.type }
501
- });
502
- }
503
- },
504
- ImportDeclaration: (node) => {
505
- detector.visitImport(node);
506
- },
507
- ExportNamedDeclaration(node) {
508
- detector.visitExportNamed(node);
509
- if (hasSource(node) && !isInsideAmbientModuleDeclaration(node)) {
552
+ },
553
+ ImportDeclaration: (node) => {
554
+ detector.visitImport(node);
555
+ },
556
+ ExportNamedDeclaration(node) {
557
+ detector.visitExportNamed(node);
558
+ if (hasSource(node) && !isInsideAmbientModuleDeclaration(node)) {
559
+ const source = moduleSpecifierValue(node.source);
560
+ if (!isPermittedBarrel(filename, mode)) context.report({
561
+ node,
562
+ messageId: "reexportOutsideBarrel"
563
+ });
564
+ else if (mode === "siblings" && !isDirectSibling(source)) context.report({
565
+ node,
566
+ messageId: "notADirectSibling",
567
+ data: { source }
568
+ });
569
+ }
570
+ },
571
+ ExportAllDeclaration(node) {
572
+ if (isInsideAmbientModuleDeclaration(node)) return;
510
573
  const source = moduleSpecifierValue(node.source);
511
574
  if (!isPermittedBarrel(filename, mode)) context.report({
512
575
  node,
@@ -517,42 +580,30 @@ const barrelPolicy = {
517
580
  messageId: "notADirectSibling",
518
581
  data: { source }
519
582
  });
583
+ },
584
+ ExportDefaultDeclaration: (node) => {
585
+ detector.visitExportDefault(node);
586
+ },
587
+ "Program:exit"() {
588
+ for (const violation of detector.violations()) if (isPermittedBarrel(filename, mode)) {
589
+ if (mode === "siblings") {
590
+ const importSource = moduleSpecifierValue(violation.trackedImport.declaration.source);
591
+ if (!isDirectSibling(importSource)) context.report({
592
+ node: violation.kind === "named" ? violation.specifier : violation.declaration,
593
+ messageId: "notADirectSibling",
594
+ data: { source: importSource }
595
+ });
596
+ }
597
+ } else context.report({
598
+ node: violation.kind === "named" ? violation.specifier : violation.declaration,
599
+ messageId: "reexportOutsideBarrel"
600
+ });
520
601
  }
521
- },
522
- ExportAllDeclaration(node) {
523
- if (isInsideAmbientModuleDeclaration(node)) return;
524
- const source = moduleSpecifierValue(node.source);
525
- if (!isPermittedBarrel(filename, mode)) context.report({
526
- node,
527
- messageId: "reexportOutsideBarrel"
528
- });
529
- else if (mode === "siblings" && !isDirectSibling(source)) context.report({
530
- node,
531
- messageId: "notADirectSibling",
532
- data: { source }
533
- });
534
- },
535
- ExportDefaultDeclaration: (node) => {
536
- detector.visitExportDefault(node);
537
- },
538
- "Program:exit"() {
539
- for (const violation of detector.violations()) if (isPermittedBarrel(filename, mode)) {
540
- if (mode === "siblings") {
541
- const importSource = moduleSpecifierValue(violation.trackedImport.declaration.source);
542
- if (!isDirectSibling(importSource)) context.report({
543
- node: violation.kind === "named" ? violation.specifier : violation.declaration,
544
- messageId: "notADirectSibling",
545
- data: { source: importSource }
546
- });
547
- }
548
- } else context.report({
549
- node: violation.kind === "named" ? violation.specifier : violation.declaration,
550
- messageId: "reexportOutsideBarrel"
551
- });
552
- }
553
- };
554
- }
555
- };
602
+ };
603
+ }
604
+ };
605
+ }
606
+ var barrel_policy_default = createBarrelPolicyRule();
556
607
  //#endregion
557
608
  //#region src/rules/scope-guards.ts
558
609
  function asIdentifierName(name) {
@@ -1803,7 +1854,7 @@ const plugin = {
1803
1854
  },
1804
1855
  rules: {
1805
1856
  "barrel-direct-siblings-only": barrelDirectSiblingsOnly,
1806
- "barrel-policy": barrelPolicy,
1857
+ "barrel-policy": barrel_policy_default,
1807
1858
  "no-array-isarray-mutation": noArrayIsArrayMutation,
1808
1859
  "no-control-flow": noControlFlow,
1809
1860
  "no-enum-number-widening": noEnumNumberWidening,
@@ -1983,7 +2034,7 @@ const recommendedTypeChecked = [
1983
2034
  plugins: { exadev: plugin },
1984
2035
  linterOptions: { noInlineConfig: true },
1985
2036
  rules: {
1986
- "exadev/barrel-policy": ["error", { mode: "banned" }],
2037
+ "exadev/barrel-policy": "error",
1987
2038
  "exadev/no-array-isarray-mutation": "error",
1988
2039
  "exadev/no-enum-number-widening": "error",
1989
2040
  "exadev/no-enum-reverse-lookup-widening": "error",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "exadev-eslint-config",
3
3
  "description": "Shared custom ESLint rules and plugin for ExaDev projects",
4
- "version": "2.18.1",
4
+ "version": "2.19.0",
5
5
  "dependencies": {
6
6
  "@eslint/js": "^10.0.1",
7
7
  "@typescript-eslint/utils": "8.67.0",