exadev-eslint-config 2.22.1 → 2.22.2

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
@@ -213,6 +213,11 @@ React support pairs `eslint-plugin-react`'s `flat/recommended` with its own `fla
213
213
  plugins: { exadev: plugin },
214
214
  extends: [plugin.configs.react], // throws if eslint-plugin-react isn't installed
215
215
  },
216
+ {
217
+ files: ['**/*.ts', '**/*.tsx'],
218
+ plugins: { exadev: plugin },
219
+ extends: [plugin.configs.nextjs], // throws if @next/eslint-plugin-next isn't installed
220
+ },
216
221
  );
217
222
  ```
218
223
  - **`exadevConfig({ react, nextjs })`** — see the tri-state table below.
@@ -350,7 +355,7 @@ Both entry points need `@eslint/json` resolvable (`pnpm add -D @eslint/json`), t
350
355
  | `root` | The workspace root directory. Defaults to the nearest ancestor of the linted file that owns a `pnpm-workspace.yaml`. |
351
356
  | `packages` | Workspace package globs, matching pnpm's own `pnpm-workspace.yaml` glob support (`*`, `**`, `[...]` character classes, `{...}` brace expansion, `!`-prefixed excludes; a wildcard segment never matches a name starting with "."; a `./` prefix, a `.`/`..` segment, and a repeated slash normalise the same way `path.posix.normalize` would, so `./packages/*`, `packages//*` and `a/../packages/*` all select the same projects). Defaults to that file's own top-level `packages:` block sequence, quoted or bare. A positive pattern that currently matches no directory (an empty group awaiting its first member, say) resolves to no packages, the same as pnpm itself. |
352
357
  | `dependencyFields` | `package.json` fields read as a package's declared dependencies. Defaults to `['dependencies']`. |
353
- | `groups` | The workspace's own directory taxonomy: `{ name, path?, rank?, slice?, naming? }`. `path` defaults to `name` (a group rooted at a directory of the same name). A group's own `naming` (`'drop-group'` \| `'keep-group'` \| `'basename'`, default `'drop-group'`) selects which segments of a package's path (after the group's own root) `package-name-mirrors-path` expects its declared name to derive: `'drop-group'` keeps every remaining path segment; `'keep-group'` additionally keeps the group's own name ahead of them (only the group's own last path segment, when its `path` nests more than one directory deep, never every container segment above it, so `{ path: 'packages/test' }` expects `test-e2e` for `packages/test/e2e`, not `packages-test-e2e`); `'basename'` keeps only the path's own final segment, dropping every intermediate directory. The joined segments are prefixed with `naming.scope` (when given) and separated with `naming.separator` (default `-`). |
358
+ | `groups` | The workspace's own directory taxonomy: `{ name, path?, rank?, slice?, naming? }`. `path` defaults to `name` (a group rooted at a directory of the same name). A group's own `naming` (`'drop-group'` \| `'keep-group'` \| `'basename'`, default `'drop-group'`) selects which segments of a package's path (after the group's own root) `package-name-mirrors-path` expects its declared name to derive: `'drop-group'` keeps every remaining path segment, falling back to the group's own `name` when a package sits exactly at the group's own root (a standalone `docs` package under a `{ name: 'docs' }` group expects `docs`, never an empty or bare-scope name); `'keep-group'` additionally keeps the group's own `name` ahead of them, always, regardless of how deep its `path` nests (a group `{ name: 'test', path: 'packages/tests' }` expects `test-e2e` for `packages/tests/e2e`, never a path segment standing in for the name); `'basename'` keeps only the path's own final segment, dropping every intermediate directory. The joined segments are prefixed with `naming.scope` (when given) and separated with `naming.separator` (default `-`). |
354
359
  | `nameRanks` | The name-role rank model: `{ pattern, rank }[]`, a package's declared name checked against each `pattern` in order, first match wins, ahead of its own group's `rank`. Omit entirely for a pure group-rank model. pnpm allows a workspace package to declare no `name` at all; such a package has nothing for a pattern to match, so `nameRanks` is skipped for it entirely and its rank falls straight through to its own group's `rank` or `defaultRank`. |
355
360
  | `defaultRank` | The rank a package gets when neither `nameRanks` nor its own group resolves one. |
356
361
  | `rankSkip` | `{ maxDistance, exemptRanks }`. A dependency more than `maxDistance` ranks below its dependant, and not itself one of `exemptRanks` (typically a foundational rank 0), is a `rankSkip` violation. Omitted entirely, that check never runs. |
@@ -361,18 +366,15 @@ A group's own `slice` (`{ segment: N }` or `{ namePrefix: true }`) partitions it
361
366
 
362
367
  ### Example: a group-ranked repo
363
368
 
364
- Every group states its own `rank` directly; `test` keeps its own path segment in the derived name (`@novus/test-database`, not `@novus/database`), which every other group drops:
369
+ Every group states its own `rank` directly; `test` keeps its own group name in the derived name (`@novus/test-database`, not `@novus/database`), which every other group drops:
365
370
 
366
371
  ```ts
367
372
  // eslint.config.ts
368
373
  import { defineConfig } from 'eslint/config';
369
- import { plugin, workspaceArchitectureConfig } from '@exadev/eslint-config';
374
+ import { workspaceArchitectureConfig } from '@exadev/eslint-config';
370
375
 
371
376
  export default defineConfig(
372
377
  // ...your own config...
373
- {
374
- plugins: { exadev: plugin },
375
- },
376
378
  ...workspaceArchitectureConfig({
377
379
  groups: [
378
380
  { name: 'core', rank: 0 },
@@ -386,6 +388,8 @@ export default defineConfig(
386
388
  );
387
389
  ```
388
390
 
391
+ `workspaceArchitectureConfig()`'s own returned config block already registers `plugins: { exadev: plugin }` itself (see its own source), so a consumer using only this standalone export never wires the plugin by hand; that manual line is needed only alongside the "lighter option" section above, where nothing else in the array registers the plugin.
392
+
389
393
  ### Example: a name-ranked repo
390
394
 
391
395
  Rank comes from a package's own declared name, not its directory; `packages/targets` infers its slice from whichever feature/product vertical its own name is prefixed with, and `rankSkip` lets a target reach a rank-0 contract directly while still blocking it from skipping straight past `store-application-context` to an adapter:
@@ -446,6 +450,7 @@ pnpm install # requires Node >=20 and the pnpm version pinned in package.json
446
450
  pnpm lint
447
451
  pnpm typecheck
448
452
  pnpm test
453
+ pnpm test:mutation # Stryker, 100% break threshold
449
454
  pnpm build
450
455
  ```
451
456
 
@@ -468,6 +473,7 @@ pnpm build
468
473
  - `recommended` ships `barrel-policy` at `mode: 'banned'`; `barrel` at `mode: 'single'`; `.react`/`.nextjs` call `buildReactConfig`/`buildNextjsConfig` with `enabled: true` (see below).
469
474
  - [`src/config-types.ts`](src/config-types.ts) holds `ConfigValue`/`ConfigArrayValue` (`ConfigArrayValue = Extract<ConfigValue, unknown[]>`, the array-only member of ESLint's own config-value union), shared by every file below rather than redefined per file.
470
475
  - *Why:* annotating a config array with the wider `ConfigValue` union directly broke `...exadev` with `TS2488` ("must have a Symbol.iterator method").
476
+ - It also holds `PublicConfigArray` (the default export's own `@eslint/core`-typed return shape) and `PublicPlugin` (the named `plugin` export's own boundary type, narrowing `configs` down to its four actual literal keys), the two public-boundary types [`src/to-public-config-array.ts`](src/to-public-config-array.ts)/[`src/to-public-plugin.ts`](src/to-public-plugin.ts) each cast into, isolated in their own single-cast modules for the reason each file's own comment gives.
471
477
  - [`src/optional-plugin.ts`](src/optional-plugin.ts) is the lazy-resolution helper behind React/Next.js support.
472
478
  - `tryRequire` wraps [`createRequire(import.meta.url)`](https://nodejs.org/api/module.html#modulecreaterequirefilename) in try/catch, returning `unknown` (never a cast) so every call site narrows explicitly before use.
473
479
  - `readFlatConfig` walks a property path through that `unknown` value via a real type guard, normalizing a stray legacy top-level `parserOptions` key into `languageOptions.parserOptions` along the way.
@@ -476,9 +482,16 @@ pnpm build
476
482
  - `[]` if unresolvable and not explicitly forced on; a thrown `Error` if explicitly forced on (`enabled: true`) and still unresolvable.
477
483
  - `react.ts`'s blocks are scoped to `files: ['**/*.jsx', '**/*.tsx']`; `nextjs.ts`'s is not (see [Optional React and Next.js support](#optional-react-and-nextjs-support) for why).
478
484
  - [`src/create-config.ts`](src/create-config.ts) is config assembly's single source of truth.
479
- - `exadevConfig(options, ...userConfigs)` concatenates `recommendedTypeChecked` with both builders' output (each fed the matching tri-state option) and any trailing user configs.
485
+ - `exadevConfig(options, ...userConfigs)` concatenates, in order: `buildGitignoreConfig`, `recommendedTypeChecked`, `jsdocAndTsdoc`, `jsonCanonicalConfig`, `buildReactConfig`, `buildNextjsConfig`, `buildPackageJsonKeyOrderConfig` (each tri-state builder fed its matching option), `buildWorkspaceArchitectureConfig` (only when `workspaceArchitecture` is given; there is no auto-detected default), and any trailing user configs.
480
486
  - `defaultConfig` is `exadevConfig()` evaluated once, eagerly, at module load.
481
- - [`src/index.ts`](src/index.ts) is the entry point, still a pure re-export barrel: `export { defaultConfig as default, exadevConfig } from './create-config'; export { default as plugin } from './plugin';`.
487
+ - [`src/index.ts`](src/index.ts) is the entry point, still a pure re-export barrel:
488
+ ```ts
489
+ export { defaultConfig as default, exadevConfig } from './create-config';
490
+ export { publicPlugin as plugin } from './plugin';
491
+ export { workspaceArchitectureConfig } from './workspace-architecture';
492
+ export type { GroupSpec, NamingOptions, RankRule, RankSkipOptions, SliceSpec, WorkspaceArchitectureOptions } from './rules/workspace-options';
493
+ ```
494
+ - The named export is `publicPlugin`, not `plugin`'s own internal default export: `plugin` (src/plugin.ts) is typed against `@typescript-eslint/utils`' `TSESLint.FlatConfig.Plugin` for full rule-option checking while this package assembles it, then cast once, at the very end of that same file, to `PublicPlugin` (see `src/to-public-plugin.ts`) before re-export, mirroring how `defaultConfig` is built against the internal `ConfigArrayValue` and cast to `PublicConfigArray` at `exadevConfig`'s own boundary.
482
495
  - Required by `no-side-effects-in-index`/`no-non-barrel-reexport`, both of which assume this file contains nothing but `export ... from ...`.
483
496
  - All exports share one root module, so importing `{ plugin }` alone still resolves `typescript-eslint` via the sibling re-export — an accepted trade-off (an earlier separate-subpath split proved more awkward in practice).
484
497
  - React/Next.js support never adds to this cost: none of the four optional packages are ever statically imported, only passed as a runtime string to `createRequire`'s resolver, so their absence never affects module evaluation for a consumer who doesn't use them.
@@ -503,7 +516,7 @@ pnpm build
503
516
 
504
517
  ### Contributing
505
518
 
506
- Conventional commits are enforced by a husky `commit-msg` hook and re-checked in CI. CI runs commitlint, lint, and typecheck+test+build+attw on every push and pull request; the release job runs only on push to `main`, after all pass.
519
+ Conventional commits are enforced by a husky `commit-msg` hook and re-checked in CI. CI runs commitlint, lint, typecheck+test+build+attw, and mutation testing (Stryker) on every pull request, on push to `main`, and on manual dispatch; the release job runs only on `main` (push or manual dispatch), after all four jobs pass.
507
520
 
508
521
  ### Release
509
522
 
package/dist/index.cjs CHANGED
@@ -251,6 +251,11 @@ const jsonCanonicalConfig = [
251
251
  }
252
252
  ];
253
253
  //#endregion
254
+ //#region src/is-record.ts
255
+ function isRecord(value) {
256
+ return typeof value === "object" && value !== null && !Array.isArray(value);
257
+ }
258
+ //#endregion
254
259
  //#region src/optional-plugin.ts
255
260
  const nodeRequire = (0, node_module.createRequire)(require("url").pathToFileURL(__filename).href);
256
261
  function tryRequire(specifier, requireFn = nodeRequire) {
@@ -260,13 +265,10 @@ function tryRequire(specifier, requireFn = nodeRequire) {
260
265
  return;
261
266
  }
262
267
  }
263
- function isRecord$4(value) {
264
- return typeof value === "object" && value !== null;
265
- }
266
268
  function normalizeLegacyParserOptions(record) {
267
269
  if (!("parserOptions" in record)) return record;
268
270
  const { parserOptions, languageOptions, ...rest } = record;
269
- const existingLanguageOptions = isRecord$4(languageOptions) ? languageOptions : {};
271
+ const existingLanguageOptions = isRecord(languageOptions) ? languageOptions : {};
270
272
  return {
271
273
  ...rest,
272
274
  languageOptions: {
@@ -278,10 +280,10 @@ function normalizeLegacyParserOptions(record) {
278
280
  function readFlatConfig(module, path) {
279
281
  let current = module;
280
282
  for (const key of path) {
281
- if (!isRecord$4(current)) return void 0;
283
+ if (!isRecord(current)) return void 0;
282
284
  current = current[key];
283
285
  }
284
- if (!isRecord$4(current)) return void 0;
286
+ if (!isRecord(current)) return void 0;
285
287
  return normalizeLegacyParserOptions(current);
286
288
  }
287
289
  //#endregion
@@ -295,21 +297,18 @@ function buildNextjsConfig(options = {}) {
295
297
  }
296
298
  //#endregion
297
299
  //#region src/json-plugin.ts
298
- function isRecord$3(value) {
299
- return typeof value === "object" && value !== null;
300
- }
301
300
  function isJsonLanguagePlugin(value) {
302
- if (!isRecord$3(value)) return false;
301
+ if (!isRecord(value)) return false;
303
302
  const languages = value["languages"];
304
- return isRecord$3(languages) && "json" in languages;
303
+ return isRecord(languages) && "json" in languages;
305
304
  }
306
305
  function resolveJsonPlugin(value) {
307
- if (isRecord$3(value) && isJsonLanguagePlugin(value["default"])) return value["default"];
306
+ if (isRecord(value) && isJsonLanguagePlugin(value["default"])) return value["default"];
308
307
  return isJsonLanguagePlugin(value) ? value : void 0;
309
308
  }
310
309
  //#endregion
311
310
  //#region package.json
312
- var version = "2.22.1";
311
+ var version = "2.22.2";
313
312
  //#endregion
314
313
  //#region src/react.ts
315
314
  const JSX_FILE_PATTERNS = ["**/*.jsx", "**/*.tsx"];
@@ -348,6 +347,11 @@ function buildReactConfig(options = {}) {
348
347
  }));
349
348
  }
350
349
  //#endregion
350
+ //#region src/to-public-plugin.ts
351
+ function toPublicPlugin(built) {
352
+ return built;
353
+ }
354
+ //#endregion
351
355
  //#region src/rules/barrel-helpers.ts
352
356
  const INDEX_BASENAME$1 = /^index\.[cm]?[tj]sx?$/;
353
357
  function basenameOf(filename) {
@@ -486,13 +490,10 @@ const barrelDirectSiblingsOnly = {
486
490
  };
487
491
  //#endregion
488
492
  //#region src/rules/barrel-auto-detect.ts
489
- function isRecord$2(value) {
490
- return typeof value === "object" && value !== null;
491
- }
492
493
  function hasRealExports(value) {
493
494
  if (typeof value === "string") return value.length > 0;
494
495
  if (Array.isArray(value)) return value.length > 0;
495
- return isRecord$2(value) && Object.keys(value).length > 0;
496
+ return isRecord(value) && Object.keys(value).length > 0;
496
497
  }
497
498
  function decideAutoBarrelMode(packageJson) {
498
499
  const hasExports = hasRealExports(packageJson["exports"]);
@@ -509,7 +510,7 @@ function findNearestPackageJson(startDir) {
509
510
  const candidate = (0, node_path.join)(dir, "package.json");
510
511
  if ((0, node_fs.existsSync)(candidate)) {
511
512
  const parsed = JSON.parse((0, node_fs.readFileSync)(candidate, "utf8"));
512
- const result = isRecord$2(parsed) ? parsed : void 0;
513
+ const result = isRecord(parsed) ? parsed : void 0;
513
514
  packageJsonByStartDir.set(startDir, result);
514
515
  return result;
515
516
  }
@@ -838,20 +839,20 @@ function listSubdirectories(fs, dir) {
838
839
  function splitPathSegments(path) {
839
840
  return path.split("/").filter((segment) => segment.length > 0);
840
841
  }
841
- //#endregion
842
- //#region src/rules/workspace-glob.ts
843
- /** Reads `text[index]`, throwing rather than reading past the end: every call site in this file only ever derives `index` from a `for` loop whose own condition (`index < text.length`) already guarantees it in range, so an out-of-bounds read here would mean that loop's own invariant broke, not a case to handle quietly. Exported so this throw (unreachable through every real call site) can be tested directly, the same "Unreachable, tested directly rather than trusted on a comment" shape workspace-yaml.ts's own `requireLine` establishes. */
844
- function requireChar$1(text, index) {
842
+ /** Reads `text[index]`, throwing rather than reading past the end: every real call site in workspace-yaml.ts and workspace-glob.ts derives `index` from a `for` loop whose own condition already guarantees it in range, so an out-of-bounds read here would mean that loop's own invariant broke, not a case to handle quietly. Shared by both modules. Exported so this throw (unreachable through every real call site) can be tested directly, the same "Unreachable, tested directly rather than trusted on a comment" shape package-json-key-order.ts's own `at()` helper establishes. */
843
+ function requireChar(text, index) {
845
844
  const char = text[index];
846
845
  if (char === void 0) throw new Error(`Unreachable: index ${String(index)} is out of bounds for a string of length ${String(text.length)}.`);
847
846
  return char;
848
847
  }
848
+ //#endregion
849
+ //#region src/rules/workspace-glob.ts
849
850
  const REGEXP_SPECIAL_CHARS = /[.+^${}()|\]\\]/gu;
850
851
  const ESCAPE_ANY_REGEXP_CHAR = /[.*+?^${}()|[\]\\]/gu;
851
852
  function segmentToRegExp(segment) {
852
853
  let source = "";
853
854
  for (let index = 0; index < segment.length;) {
854
- const char = requireChar$1(segment, index);
855
+ const char = requireChar(segment, index);
855
856
  if (char === "\\") {
856
857
  const next = segment[index + 1];
857
858
  if (next === void 0) {
@@ -919,7 +920,7 @@ function walkPattern(fs, root, segments, matchedSoFar) {
919
920
  function findMatchingBrace(pattern, openIndex) {
920
921
  let depth = 0;
921
922
  for (let index = openIndex; index < pattern.length; index += 1) {
922
- const char = requireChar$1(pattern, index);
923
+ const char = requireChar(pattern, index);
923
924
  if (char === "{") depth += 1;
924
925
  else if (char === "}") {
925
926
  depth -= 1;
@@ -933,7 +934,7 @@ function splitTopLevelAlternatives(text) {
933
934
  let depth = 0;
934
935
  let start = 0;
935
936
  for (let index = 0; index < text.length; index += 1) {
936
- const char = requireChar$1(text, index);
937
+ const char = requireChar(text, index);
937
938
  if (char === "{") depth += 1;
938
939
  else if (char === "}") depth -= 1;
939
940
  else if (char === "," && depth === 0) {
@@ -1020,12 +1021,6 @@ function requireMatch(pattern, text) {
1020
1021
  if (match === null) throw new Error(`Unreachable: /${pattern.source}/ was already confirmed to match "${text}".`);
1021
1022
  return match;
1022
1023
  }
1023
- /** Reads `text[index]`, throwing rather than reading past the end: both real call sites below derive `index` from a `for` loop whose own condition (`index < text.length`) already guarantees it in range, so an out-of-bounds read here would mean that loop's own invariant broke, not a case to handle quietly. A genuinely maybe-out-of-bounds neighbour check (this file's own "the character right before/after this one, if any" reads) stays a plain `charAt`, deliberately: those are never loop-bound-guaranteed and a missing neighbour is a real, legitimate outcome, not a bug. Exported for the same direct-test reason as requireLine above. */
1024
- function requireChar(text, index) {
1025
- const char = text[index];
1026
- if (char === void 0) throw new Error(`Unreachable: index ${String(index)} is out of bounds for a string of length ${String(text.length)}.`);
1027
- return char;
1028
- }
1029
1024
  /** Whether `value[index]` is a quote character, returning that exact character (not merely `true`) so a caller can go on to search for the SAME quote style's own matching close. Exported for direct testing of its own per-index decision, independent of commentSearchStart's own real-string scenarios below. */
1030
1025
  function quoteCharAt(value, index) {
1031
1026
  const char = value.charAt(index);
@@ -1214,9 +1209,6 @@ const MISCONFIGURATION_MESSAGE = "exadev workspace architecture rules require op
1214
1209
  function fail() {
1215
1210
  throw new Error(MISCONFIGURATION_MESSAGE);
1216
1211
  }
1217
- function isRecord$1(value) {
1218
- return typeof value === "object" && value !== null && !Array.isArray(value);
1219
- }
1220
1212
  const TOP_LEVEL_KEYS = [
1221
1213
  "root",
1222
1214
  "packages",
@@ -1265,7 +1257,7 @@ function isNonNegativeInteger(value) {
1265
1257
  return typeof value === "number" && Number.isInteger(value) && value >= 0;
1266
1258
  }
1267
1259
  function isSliceSpec(value) {
1268
- if (!isRecord$1(value)) return false;
1260
+ if (!isRecord(value)) return false;
1269
1261
  if ("segment" in value) return hasOnlyKeys(value, SLICE_BY_SEGMENT_KEYS) && isNonNegativeInteger(value["segment"]);
1270
1262
  return hasOnlyKeys(value, SLICE_BY_NAME_PREFIX_KEYS) && value["namePrefix"] === true;
1271
1263
  }
@@ -1273,7 +1265,7 @@ function isNamingStrategy(value) {
1273
1265
  return value === "drop-group" || value === "keep-group" || value === "basename";
1274
1266
  }
1275
1267
  function isGroupSpec(value) {
1276
- if (!isRecord$1(value)) return false;
1268
+ if (!isRecord(value)) return false;
1277
1269
  if (!hasOnlyKeys(value, GROUP_KEYS)) return false;
1278
1270
  if (typeof value["name"] !== "string") return false;
1279
1271
  const { path, rank, slice, naming } = value;
@@ -1298,7 +1290,7 @@ function findDuplicateGroupName(groups) {
1298
1290
  }
1299
1291
  }
1300
1292
  function isRankRule(value) {
1301
- return isRecord$1(value) && hasOnlyKeys(value, RANK_RULE_KEYS) && typeof value["pattern"] === "string" && isInteger(value["rank"]);
1293
+ return isRecord(value) && hasOnlyKeys(value, RANK_RULE_KEYS) && typeof value["pattern"] === "string" && isInteger(value["rank"]);
1302
1294
  }
1303
1295
  function validateRankRulePatterns(rankRules) {
1304
1296
  for (const rule of rankRules) try {
@@ -1315,7 +1307,7 @@ function asOptionalRankRuleArray(value) {
1315
1307
  return value;
1316
1308
  }
1317
1309
  function isRankSkipOptions(value) {
1318
- if (!isRecord$1(value)) return false;
1310
+ if (!isRecord(value)) return false;
1319
1311
  if (!hasOnlyKeys(value, RANK_SKIP_KEYS)) return false;
1320
1312
  if (!isNonNegativeInteger(value["maxDistance"])) return false;
1321
1313
  const { exemptRanks } = value;
@@ -1335,7 +1327,7 @@ function asOptionalIsolatedGroups(value) {
1335
1327
  return value;
1336
1328
  }
1337
1329
  function isNamingOptions(value) {
1338
- if (!isRecord$1(value)) return false;
1330
+ if (!isRecord(value)) return false;
1339
1331
  if (!hasOnlyKeys(value, NAMING_KEYS)) return false;
1340
1332
  const { scope, separator } = value;
1341
1333
  if (scope !== void 0 && typeof scope !== "string") return false;
@@ -1350,7 +1342,7 @@ function asOptionalNaming(value) {
1350
1342
  * The runtime safety net behind workspaceArchitectureOptionsSchema above: ESLint's own schema validation does run whenever the config is used in a real lint (rejecting malformed rule options there too, the same division of labour barrel-policy.ts's readMode establishes for its own, much smaller options shape), but workspaceArchitectureConfig() (src/workspace-architecture.ts) builds its rule options by calling this reader on the caller's raw object BEFORE that validation ever inspects it, and previously reconstructed a whitelisted object that silently dropped any unknown or misspelled top-level key rather than rejecting it, leaving ESLint's own schema nothing left to catch. This reader now rejects an unknown key at every level it validates (top level, group, slice, rankSkip, naming, nameRanks entries) itself, so a genuinely malformed options object still fails loudly and specifically here, whichever entry point it arrives through, rather than crashing later with a confusing TypeError deep inside graph construction or being silently ignored.
1351
1343
  */
1352
1344
  function readWorkspaceArchitectureOptions(options) {
1353
- if (!isRecord$1(options)) fail();
1345
+ if (!isRecord(options)) fail();
1354
1346
  if (!hasOnlyKeys(options, TOP_LEVEL_KEYS)) fail();
1355
1347
  const groups = asGroupSpecArray(options["groups"]);
1356
1348
  const duplicateGroupName = findDuplicateGroupName(groups);
@@ -1437,9 +1429,6 @@ function sliceByNamePrefix(declaredName, knownSlices) {
1437
1429
  const unscoped = stripScope(declaredName);
1438
1430
  return [...knownSlices].filter((candidate) => unscoped === candidate || unscoped.startsWith(`${candidate}-`)).sort((a, b) => b.length - a.length)[0];
1439
1431
  }
1440
- function isRecord(value) {
1441
- return typeof value === "object" && value !== null;
1442
- }
1443
1432
  /**
1444
1433
  * Reads a candidate package directory's own package.json directly (plain JSON.parse, not momoa): this is data collection for the graph, not a file being linted, so no AST/location information is needed. No existsSync guard here: every relativeDir this is called with came from resolveWorkspacePackageDirs, which already only returns directories that own a real package.json, so its absence here would mean that guarantee broke, not a case to handle quietly. A manifest that is not a usable JSON object at all (parses to an array, a string, null) returns undefined: nothing this graph can identify a package by, regardless of any "name" field. A manifest that IS a usable object but declares no "name" (or a non-string one) is different: pnpm allows a workspace package with no declared name at all, so its own `name` comes back as undefined rather than dropping the whole manifest, letting collectCandidates below key such a package by its directory instead of silently skipping it (and every dependency it declares along with it).
1445
1434
  *
@@ -1507,6 +1496,7 @@ function collectCandidates(fs, root, options) {
1507
1496
  const relativeDirs = resolveWorkspacePackageDirs(fs, root, resolveWorkspacePackagePatterns(fs, root, options.packages));
1508
1497
  const candidates = [];
1509
1498
  for (const relativeDir of relativeDirs) {
1499
+ if (relativeDir === "") continue;
1510
1500
  const group = findOwningGroup(relativeDir, options.groups);
1511
1501
  if (group === void 0) throw new Error(`@exadev/eslint-config: workspace package directory "${relativeDir}" (matched by the "packages" globs) is not covered by any configured group's own "path". Add a "groups" entry whose path prefixes it, or narrow "packages" to exclude it: an uncovered package would otherwise be silently skipped by every workspace-architecture check.`);
1512
1502
  const manifest = readDeclaredManifest(fs, (0, node_path.join)(root, relativeDir), resolveDependencyFields(options));
@@ -1684,14 +1674,14 @@ function last(array) {
1684
1674
  /**
1685
1675
  * The three per-group naming strategies' own segment selection, keyed by NamingStrategy's own three literal members rather than a chain of ternaries: `Record<NamingStrategy, ...>` requires every member to have its own entry, which is what makes 'drop-group' a genuine, independently-typed branch, not merely "whatever the ternary chain falls through to when nothing else matched". Stryker's own typescript checker rejects a mutant that replaces the `?? 'drop-group'` fallback with some other string, since indexing this record with a value outside NamingStrategy is a type error, caught before any test even runs.
1686
1676
  *
1687
- * - **'drop-group'**: drop the group's own root path segments, keep what remains.
1688
- * - **'keep-group'**: keep the group's OWN name (or, when its root path nests more than one segment deep, only that path's own last segment) ahead of `rest`, not every segment of `relativeDir` including whatever container directories sit above the group's own root: a group declared as `{ path: 'packages/test' }` derives "test-e2e" for `packages/test/e2e`, never "packages-test-e2e", the same way a group with no nested path at all already would. A test group whose packages are named "test-<feature>", mirroring the feature they test, needs its own "test" segment kept, just not any further container segment above it.
1677
+ * - **'drop-group'**: drop the group's own root path segments, keep what remains, falling back to the group's own `name` when nothing remains: a package sitting exactly at its group's own root (`relativeDir` equals the group's `path`, a group that IS a single package rather than a container of them, a standalone `docs` package under a `{ name: 'docs' }` group, say) has no path segments left of its own for `rest` to keep, so the group's declared identity stands in for them, exactly as it always does for 'keep-group'. Without this, such a group could never have a satisfiable expected name at all: every package in it would derive an empty (or bare-scope) name no real `package.json` can declare.
1678
+ * - **'keep-group'**: keep the group's OWN `name` ahead of `rest`, always, regardless of how deep its own `path` nests: a group declared `{ name: 'test', path: 'tests' }` derives "test-e2e" for `tests/e2e`, and one declared `{ name: 'test', path: 'packages/tests' }` derives "test-e2e" for `packages/tests/e2e` too, never "tests-e2e" (the group's declared identity, not whatever path segment sits above it). A test group whose packages are named "test-<feature>", mirroring the feature they test, needs its own "test" name kept, regardless of how many container directories its `path` nests under.
1689
1679
  * - **'basename'**: use only `relativeDir`'s own final segment, ignoring every intermediate directory, for a group whose intermediate structure exists purely for filesystem organisation and carries no naming intent of its own.
1690
1680
  */
1691
1681
  const NAME_SEGMENTS_BY_STRATEGY = {
1692
1682
  basename: ({ segments }) => [last(segments)],
1693
- "keep-group": ({ rest, groupNameSegment }) => [groupNameSegment, ...rest],
1694
- "drop-group": ({ rest }) => rest
1683
+ "keep-group": ({ rest, group }) => [group.name, ...rest],
1684
+ "drop-group": ({ rest, group }) => rest.length === 0 ? [group.name] : rest
1695
1685
  };
1696
1686
  /**
1697
1687
  * The package name package-name-mirrors-path expects a package at `relativeDir` (workspace-root-relative, forward-slash-joined) to declare, given its own matched group and the workspace's global naming options. See NAME_SEGMENTS_BY_STRATEGY above for the three per-group strategies' own segment selection. The joined segments are prefixed with `naming.scope` (when given) and joined to it with "/"; segments themselves join with `naming.separator` (default "-").
@@ -1701,15 +1691,27 @@ function expectedPackageName(relativeDir, group, naming) {
1701
1691
  const segments = splitPathSegments(relativeDir);
1702
1692
  const groupPathSegments = splitPathSegments(group.path ?? group.name);
1703
1693
  const rest = segments.slice(groupPathSegments.length);
1704
- const groupNameSegment = groupPathSegments.length > 1 ? last(groupPathSegments) : group.name;
1705
1694
  const joined = NAME_SEGMENTS_BY_STRATEGY[group.naming ?? "drop-group"]({
1706
1695
  segments,
1707
1696
  rest,
1708
- groupNameSegment
1697
+ group
1709
1698
  }).join(separator);
1710
1699
  return naming.scope === void 0 ? joined : `${naming.scope}/${joined}`;
1711
1700
  }
1712
1701
  //#endregion
1702
+ //#region src/rules/json-member-key.ts
1703
+ function isStringNamed(name) {
1704
+ return name.type === "String";
1705
+ }
1706
+ /**
1707
+ * Shared by package-json-key-order.ts and workspace-json-helpers.ts: momoa's own MemberNode.name is typed StringNode | IdentifierNode because momoa's grammar also covers JSON5 (unquoted identifier keys), but every rule that calls this only ever sets meta.languages to json/json or json/jsonc, where an unquoted key is a genuine parse error, never a value this function is asked to name. Exported so that guarantee is checked directly against a deliberately IdentifierNode-shaped input, rather than trusted on the strength of this comment alone.
1708
+ */
1709
+ function getMemberKeyName(member) {
1710
+ const { name } = member;
1711
+ if (!isStringNamed(name)) throw new Error(`Unreachable: JSON member names are only read under json/json or json/jsonc, where an unquoted (Identifier) member name is a parse error, got a "${name.type}" name instead.`);
1712
+ return name.value;
1713
+ }
1714
+ //#endregion
1713
1715
  //#region src/rules/workspace-json-helpers.ts
1714
1716
  function isStringNode$1(node) {
1715
1717
  return node.type === "String";
@@ -1717,7 +1719,7 @@ function isStringNode$1(node) {
1717
1719
  /** package.json's own declared "name" field, read directly off a confirmed-top-level Object node. Returns undefined for a manifest with no "name" at all, or one whose value is not a plain string (a shape other JSON tooling would already flag elsewhere, not this rule's concern). */
1718
1720
  function readDeclaredName(rootObject) {
1719
1721
  for (const member of rootObject.members) {
1720
- if (!isStringNode$1(member.name) || member.name.value !== "name") continue;
1722
+ if (getMemberKeyName(member) !== "name") continue;
1721
1723
  if (!isStringNode$1(member.value)) return void 0;
1722
1724
  return {
1723
1725
  name: member.value.value,
@@ -1732,15 +1734,12 @@ function collectTopLevelDependencies(rootObject, dependencyFields) {
1732
1734
  const fields = new Set(dependencyFields);
1733
1735
  const results = [];
1734
1736
  for (const member of rootObject.members) {
1735
- if (!isStringNode$1(member.name) || !fields.has(member.name.value)) continue;
1737
+ if (!fields.has(getMemberKeyName(member))) continue;
1736
1738
  if (member.value.type !== "Object") continue;
1737
- for (const dependency of member.value.members) {
1738
- if (!isStringNode$1(dependency.name)) continue;
1739
- results.push({
1740
- name: dependency.name.value,
1741
- node: dependency
1742
- });
1743
- }
1739
+ for (const dependency of member.value.members) results.push({
1740
+ name: getMemberKeyName(dependency),
1741
+ node: dependency
1742
+ });
1744
1743
  }
1745
1744
  return results;
1746
1745
  }
@@ -1763,7 +1762,7 @@ function createNoDependencyCycleRule(deps = {}) {
1763
1762
  description: "Disallow a cyclic workspace dependency.",
1764
1763
  url: "https://github.com/ExaDev/eslint-config/blob/main/src/rules/no-dependency-cycle.ts"
1765
1764
  },
1766
- messages: { cycle: "\"{{from}}\" depends on \"{{to}}\", which depends back on \"{{from}}\": a workspace cycle. Move the shared code into a package both can depend on, or invert one edge behind a contract." }
1765
+ messages: { cycle: "\"{{from}}\" depends on \"{{to}}\", which can reach back to \"{{from}}\": a workspace cycle. Move the shared code into a package both can depend on, or invert one edge behind a contract." }
1767
1766
  },
1768
1767
  create(context) {
1769
1768
  const options = readWorkspaceArchitectureOptions(context.options[0]);
@@ -2595,14 +2594,6 @@ function isIdentityPermutation(permutation) {
2595
2594
  function isStringNode(node) {
2596
2595
  return node.type === "String";
2597
2596
  }
2598
- function isStringNamed(name) {
2599
- return name.type === "String";
2600
- }
2601
- function getMemberKeyName(member) {
2602
- const { name } = member;
2603
- if (!isStringNamed(name)) throw new Error(`Unreachable: package-json-key-order only supports json/json and json/jsonc, where an unquoted (Identifier) member name is a parse error — got a "${name.type}" name instead.`);
2604
- return name.value;
2605
- }
2606
2597
  function rangeOf(node) {
2607
2598
  if (node.range === void 0) throw new Error("Unreachable: every node reaching this function was produced by a JSON language that always requests range tracking.");
2608
2599
  return node.range;
@@ -2854,11 +2845,11 @@ const FUNCTION_LIKE_SELECTOR$2 = [
2854
2845
  function isOptionalParam(param) {
2855
2846
  if (param.type === _typescript_eslint_utils.AST_NODE_TYPES.TSParameterProperty) return isOptionalParam(param.parameter);
2856
2847
  if (param.type === _typescript_eslint_utils.AST_NODE_TYPES.AssignmentPattern) return true;
2857
- if (param.type === _typescript_eslint_utils.AST_NODE_TYPES.Identifier) return param.optional;
2848
+ if (param.type === _typescript_eslint_utils.AST_NODE_TYPES.Identifier || param.type === _typescript_eslint_utils.AST_NODE_TYPES.ObjectPattern || param.type === _typescript_eslint_utils.AST_NODE_TYPES.ArrayPattern) return param.optional;
2858
2849
  return false;
2859
2850
  }
2860
2851
  function getTrailingOptionalRun(params) {
2861
- const hasTrailingRest = params.length > 0 && params[params.length - 1]?.type === _typescript_eslint_utils.AST_NODE_TYPES.RestElement;
2852
+ const hasTrailingRest = params[params.length - 1]?.type === _typescript_eslint_utils.AST_NODE_TYPES.RestElement;
2862
2853
  const run = [];
2863
2854
  for (let i = params.length - (hasTrailingRest ? 2 : 1); i >= 0; i--) {
2864
2855
  const param = params[i];
@@ -2868,7 +2859,6 @@ function getTrailingOptionalRun(params) {
2868
2859
  return run;
2869
2860
  }
2870
2861
  function resolveFixableParam(param) {
2871
- if (param.type === _typescript_eslint_utils.AST_NODE_TYPES.TSParameterProperty) return void 0;
2872
2862
  const identifierNode = param.type === _typescript_eslint_utils.AST_NODE_TYPES.AssignmentPattern ? param.left.type === _typescript_eslint_utils.AST_NODE_TYPES.Identifier ? param.left : void 0 : param.type === _typescript_eslint_utils.AST_NODE_TYPES.Identifier ? param : void 0;
2873
2863
  if (identifierNode === void 0) return void 0;
2874
2864
  const typeNode = identifierNode.typeAnnotation?.typeAnnotation;
@@ -2962,7 +2952,7 @@ const preferOptionsObjectParam = createRule$2({
2962
2952
  const allResolvable = resolvedParams.length === run.length;
2963
2953
  const hasRestParam = node.params.some((param) => param.type === _typescript_eslint_utils.AST_NODE_TYPES.RestElement);
2964
2954
  const body = getBlockBody(node);
2965
- let isFixable = allResolvable && !hasRestParam && body !== void 0;
2955
+ let isFixable = allResolvable && !hasRestParam;
2966
2956
  if (isFixable) {
2967
2957
  const jsdocComment = getLeadingJSDocComment(sourceCode, node);
2968
2958
  const jsDocBail = jsdocComment !== void 0 && resolvedParams.some((info) => jsDocMentionsParam(jsdocComment.value, info.identifierNode.name));
@@ -3305,6 +3295,7 @@ const plugin = {
3305
3295
  }
3306
3296
  }
3307
3297
  };
3298
+ const publicPlugin = toPublicPlugin(plugin);
3308
3299
  //#endregion
3309
3300
  //#region src/package-json-key-order.ts
3310
3301
  const SYNCPACK_CONFIG_FILENAMES = [
@@ -3448,11 +3439,13 @@ function toPublicConfigArray(built) {
3448
3439
  //#endregion
3449
3440
  //#region src/workspace-architecture.ts
3450
3441
  /**
3451
- * Wires the three workspace-architecture rules (no-uphill-dependency, no-dependency-cycle, and, when the shared `naming` option is given, package-name-mirrors-path) onto `**\/package.json`, all three sharing the one options object given here. This is the named export a repo not using the full `exadevConfig()`/default-export bundle (hive, which builds its own `eslint.config.ts` from `plugin` directly) wires in on its own, exactly the same way `plugin`'s own recommended/barrel configs serve a consumer of the lighter `plugin` export.
3442
+ * Wires the three workspace-architecture rules (no-uphill-dependency, no-dependency-cycle, and, when the shared `naming` option is given, package-name-mirrors-path) onto `**\/package.json`, all three sharing the one options object given here. Internal: consumed by create-config.ts as one more `ConfigArrayValue` entry among the rest of `exadevConfig`'s own internally-typed build (see recommendedTypeChecked, buildReactConfig, buildNextjsConfig et al. for the same shape), before that whole array is converted once at `exadevConfig`'s own outer boundary. Never exported directly; a standalone consumer wants `workspaceArchitectureConfig` below instead.
3452
3443
  *
3453
3444
  * Unlike buildPackageJsonKeyOrderConfig, this has no auto-detecting tri-state: workspace architecture rules require real configuration (`groups` has no sensible default), so this is off unless a consumer calls it at all, and, once called, always requires `@eslint/json` to be resolvable (there is no "silently do nothing" case to fall back to the way an unconfigured, purely auto-detected feature can).
3445
+ *
3446
+ * Takes `WorkspaceArchitectureConfigOptions` (the `requireFn` test seam included) rather than the public `workspaceArchitectureConfig` export's own `WorkspaceArchitectureOptions`, so this internal builder's own tests (workspace-architecture.unit.test.ts) can still simulate an unresolvable `@eslint/json` directly, without the seam ever reaching the public function's parameter type or dist/index.d.ts.
3454
3447
  */
3455
- function workspaceArchitectureConfig(options) {
3448
+ function buildWorkspaceArchitectureConfig(options) {
3456
3449
  const { requireFn, ...ruleOptions } = options;
3457
3450
  const validated = readWorkspaceArchitectureOptions(ruleOptions);
3458
3451
  const jsonPlugin = resolveJsonPlugin(tryRequire("@eslint/json", requireFn));
@@ -3471,6 +3464,12 @@ function workspaceArchitectureConfig(options) {
3471
3464
  }
3472
3465
  }];
3473
3466
  }
3467
+ /**
3468
+ * Wires the three workspace-architecture rules (`no-uphill-dependency`, `no-dependency-cycle`, and, when the shared `naming` option is given, `package-name-mirrors-path`) onto `**\/package.json` from one `WorkspaceArchitectureOptions` object, returning ESLint core's own `Config[]` so the result spreads directly into `defineConfig(...)` (`...workspaceArchitectureConfig(options)`, the README's own standalone usage) alongside a consumer's own configuration.
3469
+ */
3470
+ function workspaceArchitectureConfig(options) {
3471
+ return toPublicConfigArray(buildWorkspaceArchitectureConfig(options));
3472
+ }
3474
3473
  //#endregion
3475
3474
  //#region src/create-config.ts
3476
3475
  function exadevConfig(options = {}, ...userConfigs) {
@@ -3482,7 +3481,7 @@ function exadevConfig(options = {}, ...userConfigs) {
3482
3481
  ...buildReactConfig({ enabled: options.react }),
3483
3482
  ...buildNextjsConfig({ enabled: options.nextjs }),
3484
3483
  ...buildPackageJsonKeyOrderConfig({ enabled: options.packageJsonKeyOrder }),
3485
- ...options.workspaceArchitecture !== void 0 ? workspaceArchitectureConfig(options.workspaceArchitecture) : [],
3484
+ ...options.workspaceArchitecture !== void 0 ? buildWorkspaceArchitectureConfig(options.workspaceArchitecture) : [],
3486
3485
  ...userConfigs
3487
3486
  ]);
3488
3487
  }
@@ -3490,5 +3489,5 @@ const defaultConfig = exadevConfig();
3490
3489
  //#endregion
3491
3490
  exports.default = defaultConfig;
3492
3491
  exports.exadevConfig = exadevConfig;
3493
- exports.plugin = plugin;
3492
+ exports.plugin = publicPlugin;
3494
3493
  exports.workspaceArchitectureConfig = workspaceArchitectureConfig;
package/dist/index.d.cts CHANGED
@@ -1,9 +1,11 @@
1
1
  import { TSESLint } from "@typescript-eslint/utils";
2
- import { Config } from "eslint/config";
2
+ import { Config, ConfigObject } from "eslint/config";
3
3
  //#region src/config-types.d.ts
4
- type ConfigValue = NonNullable<TSESLint.FlatConfig.Plugin['configs']>[string];
5
- type ConfigArrayValue = Extract<ConfigValue, unknown[]>;
6
4
  type PublicConfigArray = Config[];
5
+ type PublicConfigObject = ConfigObject | ConfigObject[];
6
+ type PublicPlugin = Omit<NonNullable<ConfigObject['plugins']>[string], 'configs'> & {
7
+ readonly configs: Readonly<Record<'recommended' | 'barrel' | 'react' | 'nextjs', PublicConfigObject>>;
8
+ };
7
9
  //#endregion
8
10
  //#region src/rules/workspace-options.d.ts
9
11
  interface SliceBySegment {
@@ -57,20 +59,12 @@ declare function exadevConfig(options?: ExadevConfigOptions, ...userConfigs: rea
57
59
  declare const defaultConfig: PublicConfigArray;
58
60
  //#endregion
59
61
  //#region src/plugin.d.ts
60
- declare const plugin: TSESLint.FlatConfig.Plugin;
61
- //#endregion
62
- //#region src/optional-plugin.d.ts
63
- type RequireFn = (specifier: string) => unknown;
62
+ declare const publicPlugin: PublicPlugin;
64
63
  //#endregion
65
64
  //#region src/workspace-architecture.d.ts
66
- interface WorkspaceArchitectureConfigOptions extends WorkspaceArchitectureOptions {
67
- readonly requireFn?: RequireFn;
68
- }
69
65
  /**
70
- * Wires the three workspace-architecture rules (no-uphill-dependency, no-dependency-cycle, and, when the shared `naming` option is given, package-name-mirrors-path) onto `**\/package.json`, all three sharing the one options object given here. This is the named export a repo not using the full `exadevConfig()`/default-export bundle (hive, which builds its own `eslint.config.ts` from `plugin` directly) wires in on its own, exactly the same way `plugin`'s own recommended/barrel configs serve a consumer of the lighter `plugin` export.
71
- *
72
- * Unlike buildPackageJsonKeyOrderConfig, this has no auto-detecting tri-state: workspace architecture rules require real configuration (`groups` has no sensible default), so this is off unless a consumer calls it at all, and, once called, always requires `@eslint/json` to be resolvable (there is no "silently do nothing" case to fall back to the way an unconfigured, purely auto-detected feature can).
66
+ * Wires the three workspace-architecture rules (`no-uphill-dependency`, `no-dependency-cycle`, and, when the shared `naming` option is given, `package-name-mirrors-path`) onto `**\/package.json` from one `WorkspaceArchitectureOptions` object, returning ESLint core's own `Config[]` so the result spreads directly into `defineConfig(...)` (`...workspaceArchitectureConfig(options)`, the README's own standalone usage) alongside a consumer's own configuration.
73
67
  */
74
- declare function workspaceArchitectureConfig(options: WorkspaceArchitectureConfigOptions): ConfigArrayValue;
68
+ declare function workspaceArchitectureConfig(options: WorkspaceArchitectureOptions): PublicConfigArray;
75
69
  //#endregion
76
- export { type GroupSpec, type NamingOptions, type RankRule, type RankSkipOptions, type SliceSpec, type WorkspaceArchitectureOptions, defaultConfig as default, exadevConfig, plugin, workspaceArchitectureConfig };
70
+ export { type GroupSpec, type NamingOptions, type RankRule, type RankSkipOptions, type SliceSpec, type WorkspaceArchitectureOptions, defaultConfig as default, exadevConfig, publicPlugin as plugin, workspaceArchitectureConfig };
package/dist/index.d.ts CHANGED
@@ -1,9 +1,11 @@
1
1
  import { TSESLint } from "@typescript-eslint/utils";
2
- import { Config } from "eslint/config";
2
+ import { Config, ConfigObject } from "eslint/config";
3
3
  //#region src/config-types.d.ts
4
- type ConfigValue = NonNullable<TSESLint.FlatConfig.Plugin['configs']>[string];
5
- type ConfigArrayValue = Extract<ConfigValue, unknown[]>;
6
4
  type PublicConfigArray = Config[];
5
+ type PublicConfigObject = ConfigObject | ConfigObject[];
6
+ type PublicPlugin = Omit<NonNullable<ConfigObject['plugins']>[string], 'configs'> & {
7
+ readonly configs: Readonly<Record<'recommended' | 'barrel' | 'react' | 'nextjs', PublicConfigObject>>;
8
+ };
7
9
  //#endregion
8
10
  //#region src/rules/workspace-options.d.ts
9
11
  interface SliceBySegment {
@@ -57,20 +59,12 @@ declare function exadevConfig(options?: ExadevConfigOptions, ...userConfigs: rea
57
59
  declare const defaultConfig: PublicConfigArray;
58
60
  //#endregion
59
61
  //#region src/plugin.d.ts
60
- declare const plugin: TSESLint.FlatConfig.Plugin;
61
- //#endregion
62
- //#region src/optional-plugin.d.ts
63
- type RequireFn = (specifier: string) => unknown;
62
+ declare const publicPlugin: PublicPlugin;
64
63
  //#endregion
65
64
  //#region src/workspace-architecture.d.ts
66
- interface WorkspaceArchitectureConfigOptions extends WorkspaceArchitectureOptions {
67
- readonly requireFn?: RequireFn;
68
- }
69
65
  /**
70
- * Wires the three workspace-architecture rules (no-uphill-dependency, no-dependency-cycle, and, when the shared `naming` option is given, package-name-mirrors-path) onto `**\/package.json`, all three sharing the one options object given here. This is the named export a repo not using the full `exadevConfig()`/default-export bundle (hive, which builds its own `eslint.config.ts` from `plugin` directly) wires in on its own, exactly the same way `plugin`'s own recommended/barrel configs serve a consumer of the lighter `plugin` export.
71
- *
72
- * Unlike buildPackageJsonKeyOrderConfig, this has no auto-detecting tri-state: workspace architecture rules require real configuration (`groups` has no sensible default), so this is off unless a consumer calls it at all, and, once called, always requires `@eslint/json` to be resolvable (there is no "silently do nothing" case to fall back to the way an unconfigured, purely auto-detected feature can).
66
+ * Wires the three workspace-architecture rules (`no-uphill-dependency`, `no-dependency-cycle`, and, when the shared `naming` option is given, `package-name-mirrors-path`) onto `**\/package.json` from one `WorkspaceArchitectureOptions` object, returning ESLint core's own `Config[]` so the result spreads directly into `defineConfig(...)` (`...workspaceArchitectureConfig(options)`, the README's own standalone usage) alongside a consumer's own configuration.
73
67
  */
74
- declare function workspaceArchitectureConfig(options: WorkspaceArchitectureConfigOptions): ConfigArrayValue;
68
+ declare function workspaceArchitectureConfig(options: WorkspaceArchitectureOptions): PublicConfigArray;
75
69
  //#endregion
76
- export { type GroupSpec, type NamingOptions, type RankRule, type RankSkipOptions, type SliceSpec, type WorkspaceArchitectureOptions, defaultConfig as default, exadevConfig, plugin, workspaceArchitectureConfig };
70
+ export { type GroupSpec, type NamingOptions, type RankRule, type RankSkipOptions, type SliceSpec, type WorkspaceArchitectureOptions, defaultConfig as default, exadevConfig, publicPlugin as plugin, workspaceArchitectureConfig };
package/dist/index.js CHANGED
@@ -217,6 +217,11 @@ const jsonCanonicalConfig = [
217
217
  }
218
218
  ];
219
219
  //#endregion
220
+ //#region src/is-record.ts
221
+ function isRecord(value) {
222
+ return typeof value === "object" && value !== null && !Array.isArray(value);
223
+ }
224
+ //#endregion
220
225
  //#region src/optional-plugin.ts
221
226
  const nodeRequire = createRequire(import.meta.url);
222
227
  function tryRequire(specifier, requireFn = nodeRequire) {
@@ -226,13 +231,10 @@ function tryRequire(specifier, requireFn = nodeRequire) {
226
231
  return;
227
232
  }
228
233
  }
229
- function isRecord$4(value) {
230
- return typeof value === "object" && value !== null;
231
- }
232
234
  function normalizeLegacyParserOptions(record) {
233
235
  if (!("parserOptions" in record)) return record;
234
236
  const { parserOptions, languageOptions, ...rest } = record;
235
- const existingLanguageOptions = isRecord$4(languageOptions) ? languageOptions : {};
237
+ const existingLanguageOptions = isRecord(languageOptions) ? languageOptions : {};
236
238
  return {
237
239
  ...rest,
238
240
  languageOptions: {
@@ -244,10 +246,10 @@ function normalizeLegacyParserOptions(record) {
244
246
  function readFlatConfig(module, path) {
245
247
  let current = module;
246
248
  for (const key of path) {
247
- if (!isRecord$4(current)) return void 0;
249
+ if (!isRecord(current)) return void 0;
248
250
  current = current[key];
249
251
  }
250
- if (!isRecord$4(current)) return void 0;
252
+ if (!isRecord(current)) return void 0;
251
253
  return normalizeLegacyParserOptions(current);
252
254
  }
253
255
  //#endregion
@@ -261,21 +263,18 @@ function buildNextjsConfig(options = {}) {
261
263
  }
262
264
  //#endregion
263
265
  //#region src/json-plugin.ts
264
- function isRecord$3(value) {
265
- return typeof value === "object" && value !== null;
266
- }
267
266
  function isJsonLanguagePlugin(value) {
268
- if (!isRecord$3(value)) return false;
267
+ if (!isRecord(value)) return false;
269
268
  const languages = value["languages"];
270
- return isRecord$3(languages) && "json" in languages;
269
+ return isRecord(languages) && "json" in languages;
271
270
  }
272
271
  function resolveJsonPlugin(value) {
273
- if (isRecord$3(value) && isJsonLanguagePlugin(value["default"])) return value["default"];
272
+ if (isRecord(value) && isJsonLanguagePlugin(value["default"])) return value["default"];
274
273
  return isJsonLanguagePlugin(value) ? value : void 0;
275
274
  }
276
275
  //#endregion
277
276
  //#region package.json
278
- var version = "2.22.1";
277
+ var version = "2.22.2";
279
278
  //#endregion
280
279
  //#region src/react.ts
281
280
  const JSX_FILE_PATTERNS = ["**/*.jsx", "**/*.tsx"];
@@ -314,6 +313,11 @@ function buildReactConfig(options = {}) {
314
313
  }));
315
314
  }
316
315
  //#endregion
316
+ //#region src/to-public-plugin.ts
317
+ function toPublicPlugin(built) {
318
+ return built;
319
+ }
320
+ //#endregion
317
321
  //#region src/rules/barrel-helpers.ts
318
322
  const INDEX_BASENAME$1 = /^index\.[cm]?[tj]sx?$/;
319
323
  function basenameOf(filename) {
@@ -452,13 +456,10 @@ const barrelDirectSiblingsOnly = {
452
456
  };
453
457
  //#endregion
454
458
  //#region src/rules/barrel-auto-detect.ts
455
- function isRecord$2(value) {
456
- return typeof value === "object" && value !== null;
457
- }
458
459
  function hasRealExports(value) {
459
460
  if (typeof value === "string") return value.length > 0;
460
461
  if (Array.isArray(value)) return value.length > 0;
461
- return isRecord$2(value) && Object.keys(value).length > 0;
462
+ return isRecord(value) && Object.keys(value).length > 0;
462
463
  }
463
464
  function decideAutoBarrelMode(packageJson) {
464
465
  const hasExports = hasRealExports(packageJson["exports"]);
@@ -475,7 +476,7 @@ function findNearestPackageJson(startDir) {
475
476
  const candidate = join(dir, "package.json");
476
477
  if (existsSync(candidate)) {
477
478
  const parsed = JSON.parse(readFileSync(candidate, "utf8"));
478
- const result = isRecord$2(parsed) ? parsed : void 0;
479
+ const result = isRecord(parsed) ? parsed : void 0;
479
480
  packageJsonByStartDir.set(startDir, result);
480
481
  return result;
481
482
  }
@@ -804,20 +805,20 @@ function listSubdirectories(fs, dir) {
804
805
  function splitPathSegments(path) {
805
806
  return path.split("/").filter((segment) => segment.length > 0);
806
807
  }
807
- //#endregion
808
- //#region src/rules/workspace-glob.ts
809
- /** Reads `text[index]`, throwing rather than reading past the end: every call site in this file only ever derives `index` from a `for` loop whose own condition (`index < text.length`) already guarantees it in range, so an out-of-bounds read here would mean that loop's own invariant broke, not a case to handle quietly. Exported so this throw (unreachable through every real call site) can be tested directly, the same "Unreachable, tested directly rather than trusted on a comment" shape workspace-yaml.ts's own `requireLine` establishes. */
810
- function requireChar$1(text, index) {
808
+ /** Reads `text[index]`, throwing rather than reading past the end: every real call site in workspace-yaml.ts and workspace-glob.ts derives `index` from a `for` loop whose own condition already guarantees it in range, so an out-of-bounds read here would mean that loop's own invariant broke, not a case to handle quietly. Shared by both modules. Exported so this throw (unreachable through every real call site) can be tested directly, the same "Unreachable, tested directly rather than trusted on a comment" shape package-json-key-order.ts's own `at()` helper establishes. */
809
+ function requireChar(text, index) {
811
810
  const char = text[index];
812
811
  if (char === void 0) throw new Error(`Unreachable: index ${String(index)} is out of bounds for a string of length ${String(text.length)}.`);
813
812
  return char;
814
813
  }
814
+ //#endregion
815
+ //#region src/rules/workspace-glob.ts
815
816
  const REGEXP_SPECIAL_CHARS = /[.+^${}()|\]\\]/gu;
816
817
  const ESCAPE_ANY_REGEXP_CHAR = /[.*+?^${}()|[\]\\]/gu;
817
818
  function segmentToRegExp(segment) {
818
819
  let source = "";
819
820
  for (let index = 0; index < segment.length;) {
820
- const char = requireChar$1(segment, index);
821
+ const char = requireChar(segment, index);
821
822
  if (char === "\\") {
822
823
  const next = segment[index + 1];
823
824
  if (next === void 0) {
@@ -885,7 +886,7 @@ function walkPattern(fs, root, segments, matchedSoFar) {
885
886
  function findMatchingBrace(pattern, openIndex) {
886
887
  let depth = 0;
887
888
  for (let index = openIndex; index < pattern.length; index += 1) {
888
- const char = requireChar$1(pattern, index);
889
+ const char = requireChar(pattern, index);
889
890
  if (char === "{") depth += 1;
890
891
  else if (char === "}") {
891
892
  depth -= 1;
@@ -899,7 +900,7 @@ function splitTopLevelAlternatives(text) {
899
900
  let depth = 0;
900
901
  let start = 0;
901
902
  for (let index = 0; index < text.length; index += 1) {
902
- const char = requireChar$1(text, index);
903
+ const char = requireChar(text, index);
903
904
  if (char === "{") depth += 1;
904
905
  else if (char === "}") depth -= 1;
905
906
  else if (char === "," && depth === 0) {
@@ -986,12 +987,6 @@ function requireMatch(pattern, text) {
986
987
  if (match === null) throw new Error(`Unreachable: /${pattern.source}/ was already confirmed to match "${text}".`);
987
988
  return match;
988
989
  }
989
- /** Reads `text[index]`, throwing rather than reading past the end: both real call sites below derive `index` from a `for` loop whose own condition (`index < text.length`) already guarantees it in range, so an out-of-bounds read here would mean that loop's own invariant broke, not a case to handle quietly. A genuinely maybe-out-of-bounds neighbour check (this file's own "the character right before/after this one, if any" reads) stays a plain `charAt`, deliberately: those are never loop-bound-guaranteed and a missing neighbour is a real, legitimate outcome, not a bug. Exported for the same direct-test reason as requireLine above. */
990
- function requireChar(text, index) {
991
- const char = text[index];
992
- if (char === void 0) throw new Error(`Unreachable: index ${String(index)} is out of bounds for a string of length ${String(text.length)}.`);
993
- return char;
994
- }
995
990
  /** Whether `value[index]` is a quote character, returning that exact character (not merely `true`) so a caller can go on to search for the SAME quote style's own matching close. Exported for direct testing of its own per-index decision, independent of commentSearchStart's own real-string scenarios below. */
996
991
  function quoteCharAt(value, index) {
997
992
  const char = value.charAt(index);
@@ -1180,9 +1175,6 @@ const MISCONFIGURATION_MESSAGE = "exadev workspace architecture rules require op
1180
1175
  function fail() {
1181
1176
  throw new Error(MISCONFIGURATION_MESSAGE);
1182
1177
  }
1183
- function isRecord$1(value) {
1184
- return typeof value === "object" && value !== null && !Array.isArray(value);
1185
- }
1186
1178
  const TOP_LEVEL_KEYS = [
1187
1179
  "root",
1188
1180
  "packages",
@@ -1231,7 +1223,7 @@ function isNonNegativeInteger(value) {
1231
1223
  return typeof value === "number" && Number.isInteger(value) && value >= 0;
1232
1224
  }
1233
1225
  function isSliceSpec(value) {
1234
- if (!isRecord$1(value)) return false;
1226
+ if (!isRecord(value)) return false;
1235
1227
  if ("segment" in value) return hasOnlyKeys(value, SLICE_BY_SEGMENT_KEYS) && isNonNegativeInteger(value["segment"]);
1236
1228
  return hasOnlyKeys(value, SLICE_BY_NAME_PREFIX_KEYS) && value["namePrefix"] === true;
1237
1229
  }
@@ -1239,7 +1231,7 @@ function isNamingStrategy(value) {
1239
1231
  return value === "drop-group" || value === "keep-group" || value === "basename";
1240
1232
  }
1241
1233
  function isGroupSpec(value) {
1242
- if (!isRecord$1(value)) return false;
1234
+ if (!isRecord(value)) return false;
1243
1235
  if (!hasOnlyKeys(value, GROUP_KEYS)) return false;
1244
1236
  if (typeof value["name"] !== "string") return false;
1245
1237
  const { path, rank, slice, naming } = value;
@@ -1264,7 +1256,7 @@ function findDuplicateGroupName(groups) {
1264
1256
  }
1265
1257
  }
1266
1258
  function isRankRule(value) {
1267
- return isRecord$1(value) && hasOnlyKeys(value, RANK_RULE_KEYS) && typeof value["pattern"] === "string" && isInteger(value["rank"]);
1259
+ return isRecord(value) && hasOnlyKeys(value, RANK_RULE_KEYS) && typeof value["pattern"] === "string" && isInteger(value["rank"]);
1268
1260
  }
1269
1261
  function validateRankRulePatterns(rankRules) {
1270
1262
  for (const rule of rankRules) try {
@@ -1281,7 +1273,7 @@ function asOptionalRankRuleArray(value) {
1281
1273
  return value;
1282
1274
  }
1283
1275
  function isRankSkipOptions(value) {
1284
- if (!isRecord$1(value)) return false;
1276
+ if (!isRecord(value)) return false;
1285
1277
  if (!hasOnlyKeys(value, RANK_SKIP_KEYS)) return false;
1286
1278
  if (!isNonNegativeInteger(value["maxDistance"])) return false;
1287
1279
  const { exemptRanks } = value;
@@ -1301,7 +1293,7 @@ function asOptionalIsolatedGroups(value) {
1301
1293
  return value;
1302
1294
  }
1303
1295
  function isNamingOptions(value) {
1304
- if (!isRecord$1(value)) return false;
1296
+ if (!isRecord(value)) return false;
1305
1297
  if (!hasOnlyKeys(value, NAMING_KEYS)) return false;
1306
1298
  const { scope, separator } = value;
1307
1299
  if (scope !== void 0 && typeof scope !== "string") return false;
@@ -1316,7 +1308,7 @@ function asOptionalNaming(value) {
1316
1308
  * The runtime safety net behind workspaceArchitectureOptionsSchema above: ESLint's own schema validation does run whenever the config is used in a real lint (rejecting malformed rule options there too, the same division of labour barrel-policy.ts's readMode establishes for its own, much smaller options shape), but workspaceArchitectureConfig() (src/workspace-architecture.ts) builds its rule options by calling this reader on the caller's raw object BEFORE that validation ever inspects it, and previously reconstructed a whitelisted object that silently dropped any unknown or misspelled top-level key rather than rejecting it, leaving ESLint's own schema nothing left to catch. This reader now rejects an unknown key at every level it validates (top level, group, slice, rankSkip, naming, nameRanks entries) itself, so a genuinely malformed options object still fails loudly and specifically here, whichever entry point it arrives through, rather than crashing later with a confusing TypeError deep inside graph construction or being silently ignored.
1317
1309
  */
1318
1310
  function readWorkspaceArchitectureOptions(options) {
1319
- if (!isRecord$1(options)) fail();
1311
+ if (!isRecord(options)) fail();
1320
1312
  if (!hasOnlyKeys(options, TOP_LEVEL_KEYS)) fail();
1321
1313
  const groups = asGroupSpecArray(options["groups"]);
1322
1314
  const duplicateGroupName = findDuplicateGroupName(groups);
@@ -1403,9 +1395,6 @@ function sliceByNamePrefix(declaredName, knownSlices) {
1403
1395
  const unscoped = stripScope(declaredName);
1404
1396
  return [...knownSlices].filter((candidate) => unscoped === candidate || unscoped.startsWith(`${candidate}-`)).sort((a, b) => b.length - a.length)[0];
1405
1397
  }
1406
- function isRecord(value) {
1407
- return typeof value === "object" && value !== null;
1408
- }
1409
1398
  /**
1410
1399
  * Reads a candidate package directory's own package.json directly (plain JSON.parse, not momoa): this is data collection for the graph, not a file being linted, so no AST/location information is needed. No existsSync guard here: every relativeDir this is called with came from resolveWorkspacePackageDirs, which already only returns directories that own a real package.json, so its absence here would mean that guarantee broke, not a case to handle quietly. A manifest that is not a usable JSON object at all (parses to an array, a string, null) returns undefined: nothing this graph can identify a package by, regardless of any "name" field. A manifest that IS a usable object but declares no "name" (or a non-string one) is different: pnpm allows a workspace package with no declared name at all, so its own `name` comes back as undefined rather than dropping the whole manifest, letting collectCandidates below key such a package by its directory instead of silently skipping it (and every dependency it declares along with it).
1411
1400
  *
@@ -1473,6 +1462,7 @@ function collectCandidates(fs, root, options) {
1473
1462
  const relativeDirs = resolveWorkspacePackageDirs(fs, root, resolveWorkspacePackagePatterns(fs, root, options.packages));
1474
1463
  const candidates = [];
1475
1464
  for (const relativeDir of relativeDirs) {
1465
+ if (relativeDir === "") continue;
1476
1466
  const group = findOwningGroup(relativeDir, options.groups);
1477
1467
  if (group === void 0) throw new Error(`@exadev/eslint-config: workspace package directory "${relativeDir}" (matched by the "packages" globs) is not covered by any configured group's own "path". Add a "groups" entry whose path prefixes it, or narrow "packages" to exclude it: an uncovered package would otherwise be silently skipped by every workspace-architecture check.`);
1478
1468
  const manifest = readDeclaredManifest(fs, join(root, relativeDir), resolveDependencyFields(options));
@@ -1650,14 +1640,14 @@ function last(array) {
1650
1640
  /**
1651
1641
  * The three per-group naming strategies' own segment selection, keyed by NamingStrategy's own three literal members rather than a chain of ternaries: `Record<NamingStrategy, ...>` requires every member to have its own entry, which is what makes 'drop-group' a genuine, independently-typed branch, not merely "whatever the ternary chain falls through to when nothing else matched". Stryker's own typescript checker rejects a mutant that replaces the `?? 'drop-group'` fallback with some other string, since indexing this record with a value outside NamingStrategy is a type error, caught before any test even runs.
1652
1642
  *
1653
- * - **'drop-group'**: drop the group's own root path segments, keep what remains.
1654
- * - **'keep-group'**: keep the group's OWN name (or, when its root path nests more than one segment deep, only that path's own last segment) ahead of `rest`, not every segment of `relativeDir` including whatever container directories sit above the group's own root: a group declared as `{ path: 'packages/test' }` derives "test-e2e" for `packages/test/e2e`, never "packages-test-e2e", the same way a group with no nested path at all already would. A test group whose packages are named "test-<feature>", mirroring the feature they test, needs its own "test" segment kept, just not any further container segment above it.
1643
+ * - **'drop-group'**: drop the group's own root path segments, keep what remains, falling back to the group's own `name` when nothing remains: a package sitting exactly at its group's own root (`relativeDir` equals the group's `path`, a group that IS a single package rather than a container of them, a standalone `docs` package under a `{ name: 'docs' }` group, say) has no path segments left of its own for `rest` to keep, so the group's declared identity stands in for them, exactly as it always does for 'keep-group'. Without this, such a group could never have a satisfiable expected name at all: every package in it would derive an empty (or bare-scope) name no real `package.json` can declare.
1644
+ * - **'keep-group'**: keep the group's OWN `name` ahead of `rest`, always, regardless of how deep its own `path` nests: a group declared `{ name: 'test', path: 'tests' }` derives "test-e2e" for `tests/e2e`, and one declared `{ name: 'test', path: 'packages/tests' }` derives "test-e2e" for `packages/tests/e2e` too, never "tests-e2e" (the group's declared identity, not whatever path segment sits above it). A test group whose packages are named "test-<feature>", mirroring the feature they test, needs its own "test" name kept, regardless of how many container directories its `path` nests under.
1655
1645
  * - **'basename'**: use only `relativeDir`'s own final segment, ignoring every intermediate directory, for a group whose intermediate structure exists purely for filesystem organisation and carries no naming intent of its own.
1656
1646
  */
1657
1647
  const NAME_SEGMENTS_BY_STRATEGY = {
1658
1648
  basename: ({ segments }) => [last(segments)],
1659
- "keep-group": ({ rest, groupNameSegment }) => [groupNameSegment, ...rest],
1660
- "drop-group": ({ rest }) => rest
1649
+ "keep-group": ({ rest, group }) => [group.name, ...rest],
1650
+ "drop-group": ({ rest, group }) => rest.length === 0 ? [group.name] : rest
1661
1651
  };
1662
1652
  /**
1663
1653
  * The package name package-name-mirrors-path expects a package at `relativeDir` (workspace-root-relative, forward-slash-joined) to declare, given its own matched group and the workspace's global naming options. See NAME_SEGMENTS_BY_STRATEGY above for the three per-group strategies' own segment selection. The joined segments are prefixed with `naming.scope` (when given) and joined to it with "/"; segments themselves join with `naming.separator` (default "-").
@@ -1667,15 +1657,27 @@ function expectedPackageName(relativeDir, group, naming) {
1667
1657
  const segments = splitPathSegments(relativeDir);
1668
1658
  const groupPathSegments = splitPathSegments(group.path ?? group.name);
1669
1659
  const rest = segments.slice(groupPathSegments.length);
1670
- const groupNameSegment = groupPathSegments.length > 1 ? last(groupPathSegments) : group.name;
1671
1660
  const joined = NAME_SEGMENTS_BY_STRATEGY[group.naming ?? "drop-group"]({
1672
1661
  segments,
1673
1662
  rest,
1674
- groupNameSegment
1663
+ group
1675
1664
  }).join(separator);
1676
1665
  return naming.scope === void 0 ? joined : `${naming.scope}/${joined}`;
1677
1666
  }
1678
1667
  //#endregion
1668
+ //#region src/rules/json-member-key.ts
1669
+ function isStringNamed(name) {
1670
+ return name.type === "String";
1671
+ }
1672
+ /**
1673
+ * Shared by package-json-key-order.ts and workspace-json-helpers.ts: momoa's own MemberNode.name is typed StringNode | IdentifierNode because momoa's grammar also covers JSON5 (unquoted identifier keys), but every rule that calls this only ever sets meta.languages to json/json or json/jsonc, where an unquoted key is a genuine parse error, never a value this function is asked to name. Exported so that guarantee is checked directly against a deliberately IdentifierNode-shaped input, rather than trusted on the strength of this comment alone.
1674
+ */
1675
+ function getMemberKeyName(member) {
1676
+ const { name } = member;
1677
+ if (!isStringNamed(name)) throw new Error(`Unreachable: JSON member names are only read under json/json or json/jsonc, where an unquoted (Identifier) member name is a parse error, got a "${name.type}" name instead.`);
1678
+ return name.value;
1679
+ }
1680
+ //#endregion
1679
1681
  //#region src/rules/workspace-json-helpers.ts
1680
1682
  function isStringNode$1(node) {
1681
1683
  return node.type === "String";
@@ -1683,7 +1685,7 @@ function isStringNode$1(node) {
1683
1685
  /** package.json's own declared "name" field, read directly off a confirmed-top-level Object node. Returns undefined for a manifest with no "name" at all, or one whose value is not a plain string (a shape other JSON tooling would already flag elsewhere, not this rule's concern). */
1684
1686
  function readDeclaredName(rootObject) {
1685
1687
  for (const member of rootObject.members) {
1686
- if (!isStringNode$1(member.name) || member.name.value !== "name") continue;
1688
+ if (getMemberKeyName(member) !== "name") continue;
1687
1689
  if (!isStringNode$1(member.value)) return void 0;
1688
1690
  return {
1689
1691
  name: member.value.value,
@@ -1698,15 +1700,12 @@ function collectTopLevelDependencies(rootObject, dependencyFields) {
1698
1700
  const fields = new Set(dependencyFields);
1699
1701
  const results = [];
1700
1702
  for (const member of rootObject.members) {
1701
- if (!isStringNode$1(member.name) || !fields.has(member.name.value)) continue;
1703
+ if (!fields.has(getMemberKeyName(member))) continue;
1702
1704
  if (member.value.type !== "Object") continue;
1703
- for (const dependency of member.value.members) {
1704
- if (!isStringNode$1(dependency.name)) continue;
1705
- results.push({
1706
- name: dependency.name.value,
1707
- node: dependency
1708
- });
1709
- }
1705
+ for (const dependency of member.value.members) results.push({
1706
+ name: getMemberKeyName(dependency),
1707
+ node: dependency
1708
+ });
1710
1709
  }
1711
1710
  return results;
1712
1711
  }
@@ -1729,7 +1728,7 @@ function createNoDependencyCycleRule(deps = {}) {
1729
1728
  description: "Disallow a cyclic workspace dependency.",
1730
1729
  url: "https://github.com/ExaDev/eslint-config/blob/main/src/rules/no-dependency-cycle.ts"
1731
1730
  },
1732
- messages: { cycle: "\"{{from}}\" depends on \"{{to}}\", which depends back on \"{{from}}\": a workspace cycle. Move the shared code into a package both can depend on, or invert one edge behind a contract." }
1731
+ messages: { cycle: "\"{{from}}\" depends on \"{{to}}\", which can reach back to \"{{from}}\": a workspace cycle. Move the shared code into a package both can depend on, or invert one edge behind a contract." }
1733
1732
  },
1734
1733
  create(context) {
1735
1734
  const options = readWorkspaceArchitectureOptions(context.options[0]);
@@ -2561,14 +2560,6 @@ function isIdentityPermutation(permutation) {
2561
2560
  function isStringNode(node) {
2562
2561
  return node.type === "String";
2563
2562
  }
2564
- function isStringNamed(name) {
2565
- return name.type === "String";
2566
- }
2567
- function getMemberKeyName(member) {
2568
- const { name } = member;
2569
- if (!isStringNamed(name)) throw new Error(`Unreachable: package-json-key-order only supports json/json and json/jsonc, where an unquoted (Identifier) member name is a parse error — got a "${name.type}" name instead.`);
2570
- return name.value;
2571
- }
2572
2563
  function rangeOf(node) {
2573
2564
  if (node.range === void 0) throw new Error("Unreachable: every node reaching this function was produced by a JSON language that always requests range tracking.");
2574
2565
  return node.range;
@@ -2820,11 +2811,11 @@ const FUNCTION_LIKE_SELECTOR$2 = [
2820
2811
  function isOptionalParam(param) {
2821
2812
  if (param.type === AST_NODE_TYPES.TSParameterProperty) return isOptionalParam(param.parameter);
2822
2813
  if (param.type === AST_NODE_TYPES.AssignmentPattern) return true;
2823
- if (param.type === AST_NODE_TYPES.Identifier) return param.optional;
2814
+ if (param.type === AST_NODE_TYPES.Identifier || param.type === AST_NODE_TYPES.ObjectPattern || param.type === AST_NODE_TYPES.ArrayPattern) return param.optional;
2824
2815
  return false;
2825
2816
  }
2826
2817
  function getTrailingOptionalRun(params) {
2827
- const hasTrailingRest = params.length > 0 && params[params.length - 1]?.type === AST_NODE_TYPES.RestElement;
2818
+ const hasTrailingRest = params[params.length - 1]?.type === AST_NODE_TYPES.RestElement;
2828
2819
  const run = [];
2829
2820
  for (let i = params.length - (hasTrailingRest ? 2 : 1); i >= 0; i--) {
2830
2821
  const param = params[i];
@@ -2834,7 +2825,6 @@ function getTrailingOptionalRun(params) {
2834
2825
  return run;
2835
2826
  }
2836
2827
  function resolveFixableParam(param) {
2837
- if (param.type === AST_NODE_TYPES.TSParameterProperty) return void 0;
2838
2828
  const identifierNode = param.type === AST_NODE_TYPES.AssignmentPattern ? param.left.type === AST_NODE_TYPES.Identifier ? param.left : void 0 : param.type === AST_NODE_TYPES.Identifier ? param : void 0;
2839
2829
  if (identifierNode === void 0) return void 0;
2840
2830
  const typeNode = identifierNode.typeAnnotation?.typeAnnotation;
@@ -2928,7 +2918,7 @@ const preferOptionsObjectParam = createRule$2({
2928
2918
  const allResolvable = resolvedParams.length === run.length;
2929
2919
  const hasRestParam = node.params.some((param) => param.type === AST_NODE_TYPES.RestElement);
2930
2920
  const body = getBlockBody(node);
2931
- let isFixable = allResolvable && !hasRestParam && body !== void 0;
2921
+ let isFixable = allResolvable && !hasRestParam;
2932
2922
  if (isFixable) {
2933
2923
  const jsdocComment = getLeadingJSDocComment(sourceCode, node);
2934
2924
  const jsDocBail = jsdocComment !== void 0 && resolvedParams.some((info) => jsDocMentionsParam(jsdocComment.value, info.identifierNode.name));
@@ -3271,6 +3261,7 @@ const plugin = {
3271
3261
  }
3272
3262
  }
3273
3263
  };
3264
+ const publicPlugin = toPublicPlugin(plugin);
3274
3265
  //#endregion
3275
3266
  //#region src/package-json-key-order.ts
3276
3267
  const SYNCPACK_CONFIG_FILENAMES = [
@@ -3414,11 +3405,13 @@ function toPublicConfigArray(built) {
3414
3405
  //#endregion
3415
3406
  //#region src/workspace-architecture.ts
3416
3407
  /**
3417
- * Wires the three workspace-architecture rules (no-uphill-dependency, no-dependency-cycle, and, when the shared `naming` option is given, package-name-mirrors-path) onto `**\/package.json`, all three sharing the one options object given here. This is the named export a repo not using the full `exadevConfig()`/default-export bundle (hive, which builds its own `eslint.config.ts` from `plugin` directly) wires in on its own, exactly the same way `plugin`'s own recommended/barrel configs serve a consumer of the lighter `plugin` export.
3408
+ * Wires the three workspace-architecture rules (no-uphill-dependency, no-dependency-cycle, and, when the shared `naming` option is given, package-name-mirrors-path) onto `**\/package.json`, all three sharing the one options object given here. Internal: consumed by create-config.ts as one more `ConfigArrayValue` entry among the rest of `exadevConfig`'s own internally-typed build (see recommendedTypeChecked, buildReactConfig, buildNextjsConfig et al. for the same shape), before that whole array is converted once at `exadevConfig`'s own outer boundary. Never exported directly; a standalone consumer wants `workspaceArchitectureConfig` below instead.
3418
3409
  *
3419
3410
  * Unlike buildPackageJsonKeyOrderConfig, this has no auto-detecting tri-state: workspace architecture rules require real configuration (`groups` has no sensible default), so this is off unless a consumer calls it at all, and, once called, always requires `@eslint/json` to be resolvable (there is no "silently do nothing" case to fall back to the way an unconfigured, purely auto-detected feature can).
3411
+ *
3412
+ * Takes `WorkspaceArchitectureConfigOptions` (the `requireFn` test seam included) rather than the public `workspaceArchitectureConfig` export's own `WorkspaceArchitectureOptions`, so this internal builder's own tests (workspace-architecture.unit.test.ts) can still simulate an unresolvable `@eslint/json` directly, without the seam ever reaching the public function's parameter type or dist/index.d.ts.
3420
3413
  */
3421
- function workspaceArchitectureConfig(options) {
3414
+ function buildWorkspaceArchitectureConfig(options) {
3422
3415
  const { requireFn, ...ruleOptions } = options;
3423
3416
  const validated = readWorkspaceArchitectureOptions(ruleOptions);
3424
3417
  const jsonPlugin = resolveJsonPlugin(tryRequire("@eslint/json", requireFn));
@@ -3437,6 +3430,12 @@ function workspaceArchitectureConfig(options) {
3437
3430
  }
3438
3431
  }];
3439
3432
  }
3433
+ /**
3434
+ * Wires the three workspace-architecture rules (`no-uphill-dependency`, `no-dependency-cycle`, and, when the shared `naming` option is given, `package-name-mirrors-path`) onto `**\/package.json` from one `WorkspaceArchitectureOptions` object, returning ESLint core's own `Config[]` so the result spreads directly into `defineConfig(...)` (`...workspaceArchitectureConfig(options)`, the README's own standalone usage) alongside a consumer's own configuration.
3435
+ */
3436
+ function workspaceArchitectureConfig(options) {
3437
+ return toPublicConfigArray(buildWorkspaceArchitectureConfig(options));
3438
+ }
3440
3439
  //#endregion
3441
3440
  //#region src/create-config.ts
3442
3441
  function exadevConfig(options = {}, ...userConfigs) {
@@ -3448,10 +3447,10 @@ function exadevConfig(options = {}, ...userConfigs) {
3448
3447
  ...buildReactConfig({ enabled: options.react }),
3449
3448
  ...buildNextjsConfig({ enabled: options.nextjs }),
3450
3449
  ...buildPackageJsonKeyOrderConfig({ enabled: options.packageJsonKeyOrder }),
3451
- ...options.workspaceArchitecture !== void 0 ? workspaceArchitectureConfig(options.workspaceArchitecture) : [],
3450
+ ...options.workspaceArchitecture !== void 0 ? buildWorkspaceArchitectureConfig(options.workspaceArchitecture) : [],
3452
3451
  ...userConfigs
3453
3452
  ]);
3454
3453
  }
3455
3454
  const defaultConfig = exadevConfig();
3456
3455
  //#endregion
3457
- export { defaultConfig as default, exadevConfig, plugin, workspaceArchitectureConfig };
3456
+ export { defaultConfig as default, exadevConfig, publicPlugin as plugin, workspaceArchitectureConfig };
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.22.1",
4
+ "version": "2.22.2",
5
5
  "dependencies": {
6
6
  "@eslint/js": "^10.0.1",
7
7
  "@typescript-eslint/utils": "8.67.0",