exadev-eslint-config 2.21.0 → 2.21.1

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
@@ -348,7 +348,7 @@ Both entry points need `@eslint/json` resolvable (`pnpm add -D @eslint/json`), t
348
348
  | Field | Meaning |
349
349
  | --- | --- |
350
350
  | `root` | The workspace root directory. Defaults to the nearest ancestor of the linted file that owns a `pnpm-workspace.yaml`. |
351
- | `packages` | Workspace package globs (`pnpm-workspace.yaml` dialect: `*`, `**`, `!`-prefixed excludes). Defaults to that file's own top-level `packages:` block sequence. |
351
+ | `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 "."). Defaults to that file's own top-level `packages:` block sequence, quoted or bare. |
352
352
  | `dependencyFields` | `package.json` fields read as a package's declared dependencies. Defaults to `['dependencies']`. |
353
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). |
354
354
  | `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. |
@@ -499,6 +499,7 @@ pnpm build
499
499
  - [Husky](https://github.com/typicode/husky) hooks: `pre-commit` runs [lint-staged](https://github.com/lint-staged/lint-staged#readme) (`eslint --fix` on staged `*.ts`), `commit-msg` runs commitlint, `pre-push` runs typecheck + test + build.
500
500
  - The CI release job sets `HUSKY=0` (commit-msg hook skips the automated release commit) and blanks `NPM_TOKEN`/`NODE_AUTH_TOKEN` explicitly so an inherited token can't win over [OIDC trusted publishing](https://docs.npmjs.com/trusted-publishers).
501
501
  - A consumer who already has `eslint-plugin-react`/`@next/eslint-plugin-next` resolvable for unrelated reasons (e.g. hoisted in a monorepo) and writes `.jsx`/`.tsx` files may see new rule activity the moment they upgrade to a version of this package that ships React/Next.js support — with zero action on their part. See the compatibility note under [Optional React and Next.js support](#optional-react-and-nextjs-support).
502
+ - [Workspace architecture](#workspace-architecture)'s dependency graph is cached for the life of the process, keyed by workspace root plus the resolved options, with no invalidation of its own. A long-running ESLint process (an editor's language server, most notably) that renames a package or edits its declared dependencies after that root/options combination's first lint keeps serving the stale graph built before the edit, until the process restarts. Both prior local implementations this feature replaced (Novus hive's and the monorepo-template's own workspace rule sets) carried the identical limitation.
502
503
 
503
504
  ### Contributing
504
505
 
package/dist/index.cjs CHANGED
@@ -309,7 +309,7 @@ function resolveJsonPlugin(value) {
309
309
  }
310
310
  //#endregion
311
311
  //#region package.json
312
- var version = "2.21.0";
312
+ var version = "2.21.1";
313
313
  //#endregion
314
314
  //#region src/react.ts
315
315
  const JSX_FILE_PATTERNS = ["**/*.jsx", "**/*.tsx"];
@@ -839,9 +839,48 @@ function splitPathSegments(path) {
839
839
  }
840
840
  //#endregion
841
841
  //#region src/rules/workspace-glob.ts
842
+ /** 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. */
843
+ function requireChar$1(text, index) {
844
+ const char = text[index];
845
+ if (char === void 0) throw new Error(`Unreachable: index ${String(index)} is out of bounds for a string of length ${String(text.length)}.`);
846
+ return char;
847
+ }
848
+ const REGEXP_SPECIAL_CHARS = /[.+^${}()|\]\\]/gu;
842
849
  function segmentToRegExp(segment) {
843
- const withWildcards = segment.replace(/[.+^${}()|[\]\\]/gu, "\\$&").replace(/\*/gu, ".*").replace(/\?/gu, ".");
844
- return new RegExp(`^${withWildcards}$`, "u");
850
+ let source = "";
851
+ for (let index = 0; index < segment.length;) {
852
+ const char = requireChar$1(segment, index);
853
+ if (char === "*") {
854
+ source += ".*";
855
+ index += 1;
856
+ continue;
857
+ }
858
+ if (char === "?") {
859
+ source += ".";
860
+ index += 1;
861
+ continue;
862
+ }
863
+ if (char === "[") {
864
+ const closeIndex = segment.indexOf("]", index + 1);
865
+ if (closeIndex === -1) {
866
+ source += "\\[";
867
+ index += 1;
868
+ continue;
869
+ }
870
+ const body = segment.slice(index + 1, closeIndex);
871
+ const negated = body.startsWith("!") || body.startsWith("^");
872
+ const classBody = (negated ? body.slice(1) : body).replace(/\\/gu, "\\\\");
873
+ source += `[${negated ? "^" : ""}${classBody}]`;
874
+ index = closeIndex + 1;
875
+ continue;
876
+ }
877
+ source += char.replace(REGEXP_SPECIAL_CHARS, "\\$&");
878
+ index += 1;
879
+ }
880
+ return new RegExp(`^${source}$`, "u");
881
+ }
882
+ function segmentRequestsDotMatch(segment) {
883
+ return segment.startsWith(".");
845
884
  }
846
885
  function listRealSubdirectories(fs, dir) {
847
886
  return listSubdirectories(fs, dir).filter((name) => name !== "node_modules");
@@ -852,20 +891,66 @@ function walkPattern(fs, root, segments, matchedSoFar) {
852
891
  const currentDir = (0, node_path.join)(root, ...matchedSoFar);
853
892
  if (segment === "**") {
854
893
  const results = walkPattern(fs, root, rest, matchedSoFar);
855
- for (const child of listRealSubdirectories(fs, currentDir)) results.push(...walkPattern(fs, root, segments, [...matchedSoFar, child]));
894
+ for (const child of listRealSubdirectories(fs, currentDir).filter((name) => !name.startsWith("."))) results.push(...walkPattern(fs, root, segments, [...matchedSoFar, child]));
856
895
  return results;
857
896
  }
858
897
  const pattern = segmentToRegExp(segment);
859
- const candidates = listRealSubdirectories(fs, currentDir).filter((name) => pattern.test(name));
898
+ const allowDotMatch = segmentRequestsDotMatch(segment);
899
+ const candidates = listRealSubdirectories(fs, currentDir).filter((name) => (allowDotMatch || !name.startsWith(".")) && pattern.test(name));
860
900
  const results = [];
861
901
  for (const candidate of candidates) results.push(...walkPattern(fs, root, rest, [...matchedSoFar, candidate]));
862
902
  return results;
863
903
  }
904
+ function findMatchingBrace(pattern, openIndex) {
905
+ let depth = 0;
906
+ for (let index = openIndex; index < pattern.length; index += 1) {
907
+ const char = requireChar$1(pattern, index);
908
+ if (char === "{") depth += 1;
909
+ else if (char === "}") {
910
+ depth -= 1;
911
+ if (depth === 0) return index;
912
+ }
913
+ }
914
+ return -1;
915
+ }
916
+ function splitTopLevelAlternatives(text) {
917
+ const alternatives = [];
918
+ let depth = 0;
919
+ let start = 0;
920
+ for (let index = 0; index < text.length; index += 1) {
921
+ const char = requireChar$1(text, index);
922
+ if (char === "{") depth += 1;
923
+ else if (char === "}") depth -= 1;
924
+ else if (char === "," && depth === 0) {
925
+ alternatives.push(text.slice(start, index));
926
+ start = index + 1;
927
+ }
928
+ }
929
+ alternatives.push(text.slice(start));
930
+ return alternatives;
931
+ }
932
+ /**
933
+ * Expands every "\{a,b\}" brace group in `pattern` into its own separate, fully concrete pattern (the cross product of every group's own alternatives, nested groups included), matching fast-glob's own brace expansion, pnpm's underlying glob engine. A pattern with no "\{" at all expands to itself, unchanged. Exported for direct testing of the expansion itself, independent of any real directory tree.
934
+ */
935
+ function expandBraces(pattern) {
936
+ const openIndex = pattern.indexOf("{");
937
+ if (openIndex === -1) return [pattern];
938
+ const closeIndex = findMatchingBrace(pattern, openIndex);
939
+ if (closeIndex === -1) throw new Error(`@exadev/eslint-config: workspace glob pattern "${pattern}" has an unmatched "{" (brace expansion). Rewrite it, or pass the "packages" rule option explicitly to bypass this pattern.`);
940
+ const prefix = pattern.slice(0, openIndex);
941
+ const suffix = pattern.slice(closeIndex + 1);
942
+ const alternatives = splitTopLevelAlternatives(pattern.slice(openIndex + 1, closeIndex));
943
+ const results = [];
944
+ for (const alternative of alternatives) for (const expandedAlternative of expandBraces(alternative)) for (const expandedSuffix of expandBraces(suffix)) results.push(prefix + expandedAlternative + expandedSuffix);
945
+ return results;
946
+ }
864
947
  /**
865
- * Expands one pnpm-workspace.yaml glob pattern against the real directory tree rooted at `root`, returning every matching directory as a path relative to `root` (forward-slash-joined, no leading "./"). A pattern with a leading "!" is not itself special here: negation is a list-level concern (see resolveWorkspacePackageDirs below), so a caller wanting an exclude pattern's own matches strips the "!" before calling this.
948
+ * Expands one pnpm-workspace.yaml glob pattern against the real directory tree rooted at `root`, returning every matching directory as a path relative to `root` (forward-slash-joined, no leading "./"). A pattern with a leading "!" is not itself special here: negation is a list-level concern (see resolveWorkspacePackageDirs below), so a caller wanting an exclude pattern's own matches strips the "!" before calling this. Brace groups are expanded first (a pattern can hold more than one, and each expansion can itself match several directories), so the final result is the union of every expanded pattern's own matches, deduplicated.
866
949
  */
867
950
  function expandGlob(fs, root, pattern) {
868
- return walkPattern(fs, root, splitPathSegments(pattern), []);
951
+ const matches = /* @__PURE__ */ new Set();
952
+ for (const expandedPattern of expandBraces(pattern)) for (const match of walkPattern(fs, root, splitPathSegments(expandedPattern), [])) matches.add(match);
953
+ return [...matches];
869
954
  }
870
955
  /** Whether `pattern` is an exclude entry in pnpm-workspace.yaml's own glob list: a leading "!", never a trailing one. Exported so this exact asymmetry (as opposed to, say, "ends with '!'") is tested directly, independent of resolveWorkspacePackageDirs' own real-filesystem scenarios below. */
871
956
  function isExcludePattern(pattern) {
@@ -885,7 +970,7 @@ function resolveWorkspacePackageDirs(fs, root, patterns) {
885
970
  }
886
971
  //#endregion
887
972
  //#region src/rules/workspace-yaml.ts
888
- const PACKAGES_KEY_PATTERN = /^packages:\s*(.*)$/u;
973
+ const PACKAGES_KEY_PATTERN = /^(?:packages|"packages"|'packages'):\s*(.*)$/u;
889
974
  const SEQUENCE_ITEM_PATTERN = /^(\s*)-\s*(.*)$/u;
890
975
  /** Reads lines[index], throwing rather than silently returning undefined for an index this file only ever derives from Array.prototype.findIndex or a bounded for-loop, both of which already prove it in range. Exported so this throw (unreachable through either real call site below) 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. */
891
976
  function requireLine(lines, index) {
@@ -905,9 +990,41 @@ function requireMatch(pattern, text) {
905
990
  if (match === null) throw new Error(`Unreachable: /${pattern.source}/ was already confirmed to match "${text}".`);
906
991
  return match;
907
992
  }
993
+ /** 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. */
994
+ function requireChar(text, index) {
995
+ const char = text[index];
996
+ if (char === void 0) throw new Error(`Unreachable: index ${String(index)} is out of bounds for a string of length ${String(text.length)}.`);
997
+ return char;
998
+ }
999
+ /** 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. */
1000
+ function quoteCharAt(value, index) {
1001
+ const char = value.charAt(index);
1002
+ return char === "'" || char === "\"" ? char : void 0;
1003
+ }
1004
+ function findMatchingQuoteEnd(value, openIndex, quote) {
1005
+ for (let index = openIndex + 1; index < value.length; index += 1) {
1006
+ if (requireChar(value, index) !== quote) continue;
1007
+ if (quote === "'" && value.charAt(index + 1) === "'") {
1008
+ index += 1;
1009
+ continue;
1010
+ }
1011
+ return index;
1012
+ }
1013
+ return -1;
1014
+ }
1015
+ function commentSearchStart(value) {
1016
+ const contentStart = value.length - value.trimStart().length;
1017
+ const quote = quoteCharAt(value, contentStart);
1018
+ if (quote === void 0) return 0;
1019
+ const closeIndex = findMatchingQuoteEnd(value, contentStart, quote);
1020
+ return closeIndex === -1 ? value.length : closeIndex + 1;
1021
+ }
908
1022
  function stripComment(value) {
909
- const hashIndex = value.indexOf("#");
910
- return (hashIndex === -1 ? value : value.slice(0, hashIndex)).trimEnd();
1023
+ for (let index = commentSearchStart(value); index < value.length; index += 1) {
1024
+ if (requireChar(value, index) !== "#") continue;
1025
+ if (index === 0 || value.charAt(index - 1) === " " || value.charAt(index - 1) === " ") return value.slice(0, index).trimEnd();
1026
+ }
1027
+ return value.trimEnd();
911
1028
  }
912
1029
  function isQuotedWith(value, quote) {
913
1030
  return value.length >= 2 && value.startsWith(quote) && value.endsWith(quote);
@@ -971,7 +1088,10 @@ const workspaceArchitectureOptionsSchema = {
971
1088
  rank: { type: "number" },
972
1089
  slice: { oneOf: [{
973
1090
  type: "object",
974
- properties: { segment: { type: "number" } },
1091
+ properties: { segment: {
1092
+ type: "integer",
1093
+ minimum: 0
1094
+ } },
975
1095
  required: ["segment"],
976
1096
  additionalProperties: false
977
1097
  }, {
@@ -1085,10 +1205,15 @@ function asOptionalStringArray(value) {
1085
1205
  if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) fail();
1086
1206
  return value;
1087
1207
  }
1208
+ const SLICE_BY_SEGMENT_KEYS = ["segment"];
1209
+ const SLICE_BY_NAME_PREFIX_KEYS = ["namePrefix"];
1210
+ function isNonNegativeInteger(value) {
1211
+ return typeof value === "number" && Number.isInteger(value) && value >= 0;
1212
+ }
1088
1213
  function isSliceSpec(value) {
1089
1214
  if (!isRecord$1(value)) return false;
1090
- if (typeof value["segment"] === "number") return true;
1091
- return value["namePrefix"] === true;
1215
+ if ("segment" in value) return hasOnlyKeys(value, SLICE_BY_SEGMENT_KEYS) && isNonNegativeInteger(value["segment"]);
1216
+ return hasOnlyKeys(value, SLICE_BY_NAME_PREFIX_KEYS) && value["namePrefix"] === true;
1092
1217
  }
1093
1218
  function isNamingStrategy(value) {
1094
1219
  return value === "drop-group" || value === "keep-group" || value === "basename";
@@ -1108,6 +1233,16 @@ function asGroupSpecArray(value) {
1108
1233
  if (!Array.isArray(value) || !value.every(isGroupSpec)) fail();
1109
1234
  return value;
1110
1235
  }
1236
+ /**
1237
+ * The first group name declared more than once in `groups`, or undefined when every name is unique. Every one of this package's own by-name lookups (package-name-mirrors-path's own `options.groups.find`, isolatedGroups' own membership check just below, deriveRank's own `group.rank` read via findOwningGroup) assumes a group name identifies exactly one GroupSpec; a duplicate silently lets whichever entry `Array.prototype.find` happens to return first win, checking a package against the WRONG group's own path prefix or naming strategy without either group's author ever being told the name collided. Exported for direct testing independent of readWorkspaceArchitectureOptions' own full validation pipeline.
1238
+ */
1239
+ function findDuplicateGroupName(groups) {
1240
+ const seen = /* @__PURE__ */ new Set();
1241
+ for (const group of groups) {
1242
+ if (seen.has(group.name)) return group.name;
1243
+ seen.add(group.name);
1244
+ }
1245
+ }
1111
1246
  function isRankRule(value) {
1112
1247
  return isRecord$1(value) && hasOnlyKeys(value, RANK_RULE_KEYS) && typeof value["pattern"] === "string" && typeof value["rank"] === "number";
1113
1248
  }
@@ -1149,12 +1284,14 @@ function asOptionalNaming(value) {
1149
1284
  return value;
1150
1285
  }
1151
1286
  /**
1152
- * 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, 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.
1287
+ * 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.
1153
1288
  */
1154
1289
  function readWorkspaceArchitectureOptions(options) {
1155
1290
  if (!isRecord$1(options)) fail();
1156
1291
  if (!hasOnlyKeys(options, TOP_LEVEL_KEYS)) fail();
1157
1292
  const groups = asGroupSpecArray(options["groups"]);
1293
+ const duplicateGroupName = findDuplicateGroupName(groups);
1294
+ if (duplicateGroupName !== void 0) throw new Error(`@exadev/eslint-config: "groups" declares more than one group named "${duplicateGroupName}". Every group name must be unique: package-name-mirrors-path and isolatedGroups both resolve a group by name, and a duplicate would silently pick whichever entry is declared first.`);
1158
1295
  const root = asOptionalString(options["root"]);
1159
1296
  const packages = asOptionalStringArray(options["packages"]);
1160
1297
  const dependencyFields = asOptionalStringArray(options["dependencyFields"]);
@@ -1210,7 +1347,9 @@ function findOwningGroup(relativeDir, groups) {
1210
1347
  * The name-role model's own rank classification: nameRanks (first pattern match, checked in declaration order) takes priority over the package's structural group.rank, which itself takes priority over defaultRank. Throws when none of the three resolves anything at all, since an unranked package is a genuine configuration gap, not a package this rule can silently skip (skipping it would silently stop checking every one of its dependency edges).
1211
1348
  */
1212
1349
  function deriveRank(declaredName, group, options) {
1213
- for (const rule of options.nameRanks ?? []) if (new RegExp(rule.pattern, "u").test(declaredName)) return rule.rank;
1350
+ if (options.nameRanks !== void 0) {
1351
+ for (const rule of options.nameRanks) if (new RegExp(rule.pattern, "u").test(declaredName)) return rule.rank;
1352
+ }
1214
1353
  if (group.rank !== void 0) return group.rank;
1215
1354
  if (options.defaultRank !== void 0) return options.defaultRank;
1216
1355
  throw new Error(`@exadev/eslint-config: no rank could be resolved for workspace package "${declaredName}" (group "${group.name}"). Give the group a "rank", add a matching "nameRanks" pattern, or set "defaultRank".`);
@@ -1269,15 +1408,19 @@ function resolveWorkspaceRoot(fs, filename, rootOption) {
1269
1408
  dir = parent;
1270
1409
  }
1271
1410
  }
1272
- /**
1273
- * Resolves the workspace's own package glob patterns: the given `packages` option verbatim, or, when omitted, pnpm-workspace.yaml's own top-level "packages:" block sequence read from the resolved root.
1274
- */
1275
- function resolveWorkspacePackagePatterns(fs, root, packagesOption) {
1276
- if (packagesOption !== void 0) return packagesOption;
1411
+ function readPackagesFromYaml(fs, root) {
1277
1412
  const yamlPath = (0, node_path.join)(root, "pnpm-workspace.yaml");
1278
1413
  if (!fs.existsSync(yamlPath)) throw new Error(`@exadev/eslint-config: no "pnpm-workspace.yaml" found at workspace root "${root}", and no "packages" option was given.`);
1279
1414
  return readWorkspacePackages(fs.readFileSync(yamlPath));
1280
1415
  }
1416
+ /**
1417
+ * Resolves the workspace's own package glob patterns: the given `packages` option verbatim, or, when omitted, pnpm-workspace.yaml's own top-level "packages:" block sequence read from the resolved root. Throws when that resolution comes back with genuinely zero patterns (an empty "packages" option array, or a "packages:" key with no key at all/an empty sequence and no override), since collectCandidates (below) would then scan nothing at all, quietly turning every workspace-architecture rule into a no-op that reports nothing rather than a real misconfiguration.
1418
+ */
1419
+ function resolveWorkspacePackagePatterns(fs, root, packagesOption) {
1420
+ const patterns = packagesOption ?? readPackagesFromYaml(fs, root);
1421
+ if (patterns.length === 0) throw new Error(`@exadev/eslint-config: the resolved workspace "packages" glob list is empty (root "${root}"), which would make every workspace-architecture rule a silent no-op. Add a "packages:" block sequence to pnpm-workspace.yaml, or pass a non-empty "packages" rule option.`);
1422
+ return patterns;
1423
+ }
1281
1424
  function collectCandidates(fs, root, options) {
1282
1425
  const relativeDirs = resolveWorkspacePackageDirs(fs, root, resolveWorkspacePackagePatterns(fs, root, options.packages));
1283
1426
  const candidates = [];
@@ -1360,6 +1503,7 @@ function loadWorkspaceGraph(filename, options) {
1360
1503
  //#endregion
1361
1504
  //#region src/rules/workspace-checks.ts
1362
1505
  function isIsolatedPair(groupA, groupB, isolatedGroups) {
1506
+ if (isolatedGroups === void 0) return false;
1363
1507
  return isolatedGroups.some(([first, second]) => first === groupA && second === groupB || first === groupB && second === groupA);
1364
1508
  }
1365
1509
  /**
@@ -1374,11 +1518,10 @@ function isIsolatedPair(groupA, groupB, isolatedGroups) {
1374
1518
  */
1375
1519
  function checkDependencies(selfName, self, dependencyNames, context) {
1376
1520
  const violations = [];
1377
- const isolatedGroups = context.isolatedGroups ?? [];
1378
1521
  for (const dependencyName of dependencyNames) {
1379
1522
  const dependency = context.graph.get(dependencyName);
1380
1523
  if (dependency === void 0) continue;
1381
- if (isIsolatedPair(self.group, dependency.group, isolatedGroups)) {
1524
+ if (isIsolatedPair(self.group, dependency.group, context.isolatedGroups)) {
1382
1525
  violations.push({
1383
1526
  dependencyName,
1384
1527
  messageId: "isolatedGroup",
@@ -1461,9 +1604,9 @@ function last(array) {
1461
1604
  * - **'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.
1462
1605
  */
1463
1606
  const NAME_SEGMENTS_BY_STRATEGY = {
1464
- basename: (segments) => [last(segments)],
1465
- "keep-group": (segments) => segments,
1466
- "drop-group": (_segments, rest) => rest
1607
+ basename: ({ segments }) => [last(segments)],
1608
+ "keep-group": ({ segments }) => segments,
1609
+ "drop-group": ({ rest }) => rest
1467
1610
  };
1468
1611
  /**
1469
1612
  * 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 "-").
@@ -1473,7 +1616,10 @@ function expectedPackageName(relativeDir, group, naming) {
1473
1616
  const segments = splitPathSegments(relativeDir);
1474
1617
  const groupPrefixLength = splitPathSegments(group.path ?? group.name).length;
1475
1618
  const rest = segments.slice(groupPrefixLength);
1476
- const joined = NAME_SEGMENTS_BY_STRATEGY[group.naming ?? "drop-group"](segments, rest).join(separator);
1619
+ const joined = NAME_SEGMENTS_BY_STRATEGY[group.naming ?? "drop-group"]({
1620
+ segments,
1621
+ rest
1622
+ }).join(separator);
1477
1623
  return naming.scope === void 0 ? joined : `${naming.scope}/${joined}`;
1478
1624
  }
1479
1625
  //#endregion
@@ -2481,6 +2627,14 @@ const packageJsonKeyOrder = {
2481
2627
  //#endregion
2482
2628
  //#region src/rules/package-name-mirrors-path.ts
2483
2629
  /**
2630
+ * The GroupSpec this rule's own resolved `groupName` always identifies, looked up by name in `groups`. A real `groupName` (`self.group` in the visitor below) is set only by buildWorkspaceGraph itself (workspace-graph.ts), directly from `candidate.group.name`, where `candidate.group` in turn only ever comes from `findOwningGroup(relativeDir, options.groups)`, one of that SAME `options.groups` array the visitor passes back in here as `groups`. So a real graph's group name is provably one of `groups`' own names whenever both come from the identical options object, which loadGraph(context.filename, options) and this lookup always do; failing to find it here means the graph was built from a different options object than the one now inspecting it, not a legitimate absence to skip past. Exported so this throw (unreachable through the real call site below) 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.
2631
+ */
2632
+ function findGroupSpec(groups, groupName) {
2633
+ const group = groups.find((candidate) => candidate.name === groupName);
2634
+ if (group === void 0) throw new Error(`Unreachable: the workspace graph resolved group "${groupName}", which is not among this same rule invocation's own "groups" option.`);
2635
+ return group;
2636
+ }
2637
+ /**
2484
2638
  * Reports a workspace package whose declared name does not mirror its path, per its own group's naming strategy and the workspace's global scope/separator (see expectedPackageName in workspace-checks.ts for the exact derivation). Opt-in: entirely a no-op whenever the shared `naming` option is omitted, exactly as the option's own doc comment (workspace-options.ts) states, since a workspace with its own established naming convention this rule cannot express should not be forced to adopt one that fits.
2485
2639
  *
2486
2640
  * Deliberately not derived from `context.filename`'s own directory the way the hive original was: this rule instead looks its own package up in the already-built graph by its DECLARED name (the same self-identification every other workspace-architecture rule uses), which is what lets `expectedPackageName` be checked against the graph's own recorded relativeDir/group rather than re-deriving them from a path assumed to equal the folder a file happens to be linted from.
@@ -2509,8 +2663,7 @@ function createPackageNameMirrorsPathRule(loadGraph = loadWorkspaceGraph) {
2509
2663
  if (declared === void 0) return;
2510
2664
  const self = graph.packagesByName.get(declared.name);
2511
2665
  if (self === void 0) return;
2512
- const group = options.groups.find((candidate) => candidate.name === self.group);
2513
- if (group === void 0) return;
2666
+ const group = findGroupSpec(options.groups, self.group);
2514
2667
  const expected = expectedPackageName(self.relativeDir, group, naming);
2515
2668
  if (expected === declared.name) return;
2516
2669
  context.report({
package/dist/index.js CHANGED
@@ -275,7 +275,7 @@ function resolveJsonPlugin(value) {
275
275
  }
276
276
  //#endregion
277
277
  //#region package.json
278
- var version = "2.21.0";
278
+ var version = "2.21.1";
279
279
  //#endregion
280
280
  //#region src/react.ts
281
281
  const JSX_FILE_PATTERNS = ["**/*.jsx", "**/*.tsx"];
@@ -805,9 +805,48 @@ function splitPathSegments(path) {
805
805
  }
806
806
  //#endregion
807
807
  //#region src/rules/workspace-glob.ts
808
+ /** 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. */
809
+ function requireChar$1(text, index) {
810
+ const char = text[index];
811
+ if (char === void 0) throw new Error(`Unreachable: index ${String(index)} is out of bounds for a string of length ${String(text.length)}.`);
812
+ return char;
813
+ }
814
+ const REGEXP_SPECIAL_CHARS = /[.+^${}()|\]\\]/gu;
808
815
  function segmentToRegExp(segment) {
809
- const withWildcards = segment.replace(/[.+^${}()|[\]\\]/gu, "\\$&").replace(/\*/gu, ".*").replace(/\?/gu, ".");
810
- return new RegExp(`^${withWildcards}$`, "u");
816
+ let source = "";
817
+ for (let index = 0; index < segment.length;) {
818
+ const char = requireChar$1(segment, index);
819
+ if (char === "*") {
820
+ source += ".*";
821
+ index += 1;
822
+ continue;
823
+ }
824
+ if (char === "?") {
825
+ source += ".";
826
+ index += 1;
827
+ continue;
828
+ }
829
+ if (char === "[") {
830
+ const closeIndex = segment.indexOf("]", index + 1);
831
+ if (closeIndex === -1) {
832
+ source += "\\[";
833
+ index += 1;
834
+ continue;
835
+ }
836
+ const body = segment.slice(index + 1, closeIndex);
837
+ const negated = body.startsWith("!") || body.startsWith("^");
838
+ const classBody = (negated ? body.slice(1) : body).replace(/\\/gu, "\\\\");
839
+ source += `[${negated ? "^" : ""}${classBody}]`;
840
+ index = closeIndex + 1;
841
+ continue;
842
+ }
843
+ source += char.replace(REGEXP_SPECIAL_CHARS, "\\$&");
844
+ index += 1;
845
+ }
846
+ return new RegExp(`^${source}$`, "u");
847
+ }
848
+ function segmentRequestsDotMatch(segment) {
849
+ return segment.startsWith(".");
811
850
  }
812
851
  function listRealSubdirectories(fs, dir) {
813
852
  return listSubdirectories(fs, dir).filter((name) => name !== "node_modules");
@@ -818,20 +857,66 @@ function walkPattern(fs, root, segments, matchedSoFar) {
818
857
  const currentDir = join(root, ...matchedSoFar);
819
858
  if (segment === "**") {
820
859
  const results = walkPattern(fs, root, rest, matchedSoFar);
821
- for (const child of listRealSubdirectories(fs, currentDir)) results.push(...walkPattern(fs, root, segments, [...matchedSoFar, child]));
860
+ for (const child of listRealSubdirectories(fs, currentDir).filter((name) => !name.startsWith("."))) results.push(...walkPattern(fs, root, segments, [...matchedSoFar, child]));
822
861
  return results;
823
862
  }
824
863
  const pattern = segmentToRegExp(segment);
825
- const candidates = listRealSubdirectories(fs, currentDir).filter((name) => pattern.test(name));
864
+ const allowDotMatch = segmentRequestsDotMatch(segment);
865
+ const candidates = listRealSubdirectories(fs, currentDir).filter((name) => (allowDotMatch || !name.startsWith(".")) && pattern.test(name));
826
866
  const results = [];
827
867
  for (const candidate of candidates) results.push(...walkPattern(fs, root, rest, [...matchedSoFar, candidate]));
828
868
  return results;
829
869
  }
870
+ function findMatchingBrace(pattern, openIndex) {
871
+ let depth = 0;
872
+ for (let index = openIndex; index < pattern.length; index += 1) {
873
+ const char = requireChar$1(pattern, index);
874
+ if (char === "{") depth += 1;
875
+ else if (char === "}") {
876
+ depth -= 1;
877
+ if (depth === 0) return index;
878
+ }
879
+ }
880
+ return -1;
881
+ }
882
+ function splitTopLevelAlternatives(text) {
883
+ const alternatives = [];
884
+ let depth = 0;
885
+ let start = 0;
886
+ for (let index = 0; index < text.length; index += 1) {
887
+ const char = requireChar$1(text, index);
888
+ if (char === "{") depth += 1;
889
+ else if (char === "}") depth -= 1;
890
+ else if (char === "," && depth === 0) {
891
+ alternatives.push(text.slice(start, index));
892
+ start = index + 1;
893
+ }
894
+ }
895
+ alternatives.push(text.slice(start));
896
+ return alternatives;
897
+ }
898
+ /**
899
+ * Expands every "\{a,b\}" brace group in `pattern` into its own separate, fully concrete pattern (the cross product of every group's own alternatives, nested groups included), matching fast-glob's own brace expansion, pnpm's underlying glob engine. A pattern with no "\{" at all expands to itself, unchanged. Exported for direct testing of the expansion itself, independent of any real directory tree.
900
+ */
901
+ function expandBraces(pattern) {
902
+ const openIndex = pattern.indexOf("{");
903
+ if (openIndex === -1) return [pattern];
904
+ const closeIndex = findMatchingBrace(pattern, openIndex);
905
+ if (closeIndex === -1) throw new Error(`@exadev/eslint-config: workspace glob pattern "${pattern}" has an unmatched "{" (brace expansion). Rewrite it, or pass the "packages" rule option explicitly to bypass this pattern.`);
906
+ const prefix = pattern.slice(0, openIndex);
907
+ const suffix = pattern.slice(closeIndex + 1);
908
+ const alternatives = splitTopLevelAlternatives(pattern.slice(openIndex + 1, closeIndex));
909
+ const results = [];
910
+ for (const alternative of alternatives) for (const expandedAlternative of expandBraces(alternative)) for (const expandedSuffix of expandBraces(suffix)) results.push(prefix + expandedAlternative + expandedSuffix);
911
+ return results;
912
+ }
830
913
  /**
831
- * Expands one pnpm-workspace.yaml glob pattern against the real directory tree rooted at `root`, returning every matching directory as a path relative to `root` (forward-slash-joined, no leading "./"). A pattern with a leading "!" is not itself special here: negation is a list-level concern (see resolveWorkspacePackageDirs below), so a caller wanting an exclude pattern's own matches strips the "!" before calling this.
914
+ * Expands one pnpm-workspace.yaml glob pattern against the real directory tree rooted at `root`, returning every matching directory as a path relative to `root` (forward-slash-joined, no leading "./"). A pattern with a leading "!" is not itself special here: negation is a list-level concern (see resolveWorkspacePackageDirs below), so a caller wanting an exclude pattern's own matches strips the "!" before calling this. Brace groups are expanded first (a pattern can hold more than one, and each expansion can itself match several directories), so the final result is the union of every expanded pattern's own matches, deduplicated.
832
915
  */
833
916
  function expandGlob(fs, root, pattern) {
834
- return walkPattern(fs, root, splitPathSegments(pattern), []);
917
+ const matches = /* @__PURE__ */ new Set();
918
+ for (const expandedPattern of expandBraces(pattern)) for (const match of walkPattern(fs, root, splitPathSegments(expandedPattern), [])) matches.add(match);
919
+ return [...matches];
835
920
  }
836
921
  /** Whether `pattern` is an exclude entry in pnpm-workspace.yaml's own glob list: a leading "!", never a trailing one. Exported so this exact asymmetry (as opposed to, say, "ends with '!'") is tested directly, independent of resolveWorkspacePackageDirs' own real-filesystem scenarios below. */
837
922
  function isExcludePattern(pattern) {
@@ -851,7 +936,7 @@ function resolveWorkspacePackageDirs(fs, root, patterns) {
851
936
  }
852
937
  //#endregion
853
938
  //#region src/rules/workspace-yaml.ts
854
- const PACKAGES_KEY_PATTERN = /^packages:\s*(.*)$/u;
939
+ const PACKAGES_KEY_PATTERN = /^(?:packages|"packages"|'packages'):\s*(.*)$/u;
855
940
  const SEQUENCE_ITEM_PATTERN = /^(\s*)-\s*(.*)$/u;
856
941
  /** Reads lines[index], throwing rather than silently returning undefined for an index this file only ever derives from Array.prototype.findIndex or a bounded for-loop, both of which already prove it in range. Exported so this throw (unreachable through either real call site below) 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. */
857
942
  function requireLine(lines, index) {
@@ -871,9 +956,41 @@ function requireMatch(pattern, text) {
871
956
  if (match === null) throw new Error(`Unreachable: /${pattern.source}/ was already confirmed to match "${text}".`);
872
957
  return match;
873
958
  }
959
+ /** 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. */
960
+ function requireChar(text, index) {
961
+ const char = text[index];
962
+ if (char === void 0) throw new Error(`Unreachable: index ${String(index)} is out of bounds for a string of length ${String(text.length)}.`);
963
+ return char;
964
+ }
965
+ /** 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. */
966
+ function quoteCharAt(value, index) {
967
+ const char = value.charAt(index);
968
+ return char === "'" || char === "\"" ? char : void 0;
969
+ }
970
+ function findMatchingQuoteEnd(value, openIndex, quote) {
971
+ for (let index = openIndex + 1; index < value.length; index += 1) {
972
+ if (requireChar(value, index) !== quote) continue;
973
+ if (quote === "'" && value.charAt(index + 1) === "'") {
974
+ index += 1;
975
+ continue;
976
+ }
977
+ return index;
978
+ }
979
+ return -1;
980
+ }
981
+ function commentSearchStart(value) {
982
+ const contentStart = value.length - value.trimStart().length;
983
+ const quote = quoteCharAt(value, contentStart);
984
+ if (quote === void 0) return 0;
985
+ const closeIndex = findMatchingQuoteEnd(value, contentStart, quote);
986
+ return closeIndex === -1 ? value.length : closeIndex + 1;
987
+ }
874
988
  function stripComment(value) {
875
- const hashIndex = value.indexOf("#");
876
- return (hashIndex === -1 ? value : value.slice(0, hashIndex)).trimEnd();
989
+ for (let index = commentSearchStart(value); index < value.length; index += 1) {
990
+ if (requireChar(value, index) !== "#") continue;
991
+ if (index === 0 || value.charAt(index - 1) === " " || value.charAt(index - 1) === " ") return value.slice(0, index).trimEnd();
992
+ }
993
+ return value.trimEnd();
877
994
  }
878
995
  function isQuotedWith(value, quote) {
879
996
  return value.length >= 2 && value.startsWith(quote) && value.endsWith(quote);
@@ -937,7 +1054,10 @@ const workspaceArchitectureOptionsSchema = {
937
1054
  rank: { type: "number" },
938
1055
  slice: { oneOf: [{
939
1056
  type: "object",
940
- properties: { segment: { type: "number" } },
1057
+ properties: { segment: {
1058
+ type: "integer",
1059
+ minimum: 0
1060
+ } },
941
1061
  required: ["segment"],
942
1062
  additionalProperties: false
943
1063
  }, {
@@ -1051,10 +1171,15 @@ function asOptionalStringArray(value) {
1051
1171
  if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) fail();
1052
1172
  return value;
1053
1173
  }
1174
+ const SLICE_BY_SEGMENT_KEYS = ["segment"];
1175
+ const SLICE_BY_NAME_PREFIX_KEYS = ["namePrefix"];
1176
+ function isNonNegativeInteger(value) {
1177
+ return typeof value === "number" && Number.isInteger(value) && value >= 0;
1178
+ }
1054
1179
  function isSliceSpec(value) {
1055
1180
  if (!isRecord$1(value)) return false;
1056
- if (typeof value["segment"] === "number") return true;
1057
- return value["namePrefix"] === true;
1181
+ if ("segment" in value) return hasOnlyKeys(value, SLICE_BY_SEGMENT_KEYS) && isNonNegativeInteger(value["segment"]);
1182
+ return hasOnlyKeys(value, SLICE_BY_NAME_PREFIX_KEYS) && value["namePrefix"] === true;
1058
1183
  }
1059
1184
  function isNamingStrategy(value) {
1060
1185
  return value === "drop-group" || value === "keep-group" || value === "basename";
@@ -1074,6 +1199,16 @@ function asGroupSpecArray(value) {
1074
1199
  if (!Array.isArray(value) || !value.every(isGroupSpec)) fail();
1075
1200
  return value;
1076
1201
  }
1202
+ /**
1203
+ * The first group name declared more than once in `groups`, or undefined when every name is unique. Every one of this package's own by-name lookups (package-name-mirrors-path's own `options.groups.find`, isolatedGroups' own membership check just below, deriveRank's own `group.rank` read via findOwningGroup) assumes a group name identifies exactly one GroupSpec; a duplicate silently lets whichever entry `Array.prototype.find` happens to return first win, checking a package against the WRONG group's own path prefix or naming strategy without either group's author ever being told the name collided. Exported for direct testing independent of readWorkspaceArchitectureOptions' own full validation pipeline.
1204
+ */
1205
+ function findDuplicateGroupName(groups) {
1206
+ const seen = /* @__PURE__ */ new Set();
1207
+ for (const group of groups) {
1208
+ if (seen.has(group.name)) return group.name;
1209
+ seen.add(group.name);
1210
+ }
1211
+ }
1077
1212
  function isRankRule(value) {
1078
1213
  return isRecord$1(value) && hasOnlyKeys(value, RANK_RULE_KEYS) && typeof value["pattern"] === "string" && typeof value["rank"] === "number";
1079
1214
  }
@@ -1115,12 +1250,14 @@ function asOptionalNaming(value) {
1115
1250
  return value;
1116
1251
  }
1117
1252
  /**
1118
- * 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, 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.
1253
+ * 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.
1119
1254
  */
1120
1255
  function readWorkspaceArchitectureOptions(options) {
1121
1256
  if (!isRecord$1(options)) fail();
1122
1257
  if (!hasOnlyKeys(options, TOP_LEVEL_KEYS)) fail();
1123
1258
  const groups = asGroupSpecArray(options["groups"]);
1259
+ const duplicateGroupName = findDuplicateGroupName(groups);
1260
+ if (duplicateGroupName !== void 0) throw new Error(`@exadev/eslint-config: "groups" declares more than one group named "${duplicateGroupName}". Every group name must be unique: package-name-mirrors-path and isolatedGroups both resolve a group by name, and a duplicate would silently pick whichever entry is declared first.`);
1124
1261
  const root = asOptionalString(options["root"]);
1125
1262
  const packages = asOptionalStringArray(options["packages"]);
1126
1263
  const dependencyFields = asOptionalStringArray(options["dependencyFields"]);
@@ -1176,7 +1313,9 @@ function findOwningGroup(relativeDir, groups) {
1176
1313
  * The name-role model's own rank classification: nameRanks (first pattern match, checked in declaration order) takes priority over the package's structural group.rank, which itself takes priority over defaultRank. Throws when none of the three resolves anything at all, since an unranked package is a genuine configuration gap, not a package this rule can silently skip (skipping it would silently stop checking every one of its dependency edges).
1177
1314
  */
1178
1315
  function deriveRank(declaredName, group, options) {
1179
- for (const rule of options.nameRanks ?? []) if (new RegExp(rule.pattern, "u").test(declaredName)) return rule.rank;
1316
+ if (options.nameRanks !== void 0) {
1317
+ for (const rule of options.nameRanks) if (new RegExp(rule.pattern, "u").test(declaredName)) return rule.rank;
1318
+ }
1180
1319
  if (group.rank !== void 0) return group.rank;
1181
1320
  if (options.defaultRank !== void 0) return options.defaultRank;
1182
1321
  throw new Error(`@exadev/eslint-config: no rank could be resolved for workspace package "${declaredName}" (group "${group.name}"). Give the group a "rank", add a matching "nameRanks" pattern, or set "defaultRank".`);
@@ -1235,15 +1374,19 @@ function resolveWorkspaceRoot(fs, filename, rootOption) {
1235
1374
  dir = parent;
1236
1375
  }
1237
1376
  }
1238
- /**
1239
- * Resolves the workspace's own package glob patterns: the given `packages` option verbatim, or, when omitted, pnpm-workspace.yaml's own top-level "packages:" block sequence read from the resolved root.
1240
- */
1241
- function resolveWorkspacePackagePatterns(fs, root, packagesOption) {
1242
- if (packagesOption !== void 0) return packagesOption;
1377
+ function readPackagesFromYaml(fs, root) {
1243
1378
  const yamlPath = join(root, "pnpm-workspace.yaml");
1244
1379
  if (!fs.existsSync(yamlPath)) throw new Error(`@exadev/eslint-config: no "pnpm-workspace.yaml" found at workspace root "${root}", and no "packages" option was given.`);
1245
1380
  return readWorkspacePackages(fs.readFileSync(yamlPath));
1246
1381
  }
1382
+ /**
1383
+ * Resolves the workspace's own package glob patterns: the given `packages` option verbatim, or, when omitted, pnpm-workspace.yaml's own top-level "packages:" block sequence read from the resolved root. Throws when that resolution comes back with genuinely zero patterns (an empty "packages" option array, or a "packages:" key with no key at all/an empty sequence and no override), since collectCandidates (below) would then scan nothing at all, quietly turning every workspace-architecture rule into a no-op that reports nothing rather than a real misconfiguration.
1384
+ */
1385
+ function resolveWorkspacePackagePatterns(fs, root, packagesOption) {
1386
+ const patterns = packagesOption ?? readPackagesFromYaml(fs, root);
1387
+ if (patterns.length === 0) throw new Error(`@exadev/eslint-config: the resolved workspace "packages" glob list is empty (root "${root}"), which would make every workspace-architecture rule a silent no-op. Add a "packages:" block sequence to pnpm-workspace.yaml, or pass a non-empty "packages" rule option.`);
1388
+ return patterns;
1389
+ }
1247
1390
  function collectCandidates(fs, root, options) {
1248
1391
  const relativeDirs = resolveWorkspacePackageDirs(fs, root, resolveWorkspacePackagePatterns(fs, root, options.packages));
1249
1392
  const candidates = [];
@@ -1326,6 +1469,7 @@ function loadWorkspaceGraph(filename, options) {
1326
1469
  //#endregion
1327
1470
  //#region src/rules/workspace-checks.ts
1328
1471
  function isIsolatedPair(groupA, groupB, isolatedGroups) {
1472
+ if (isolatedGroups === void 0) return false;
1329
1473
  return isolatedGroups.some(([first, second]) => first === groupA && second === groupB || first === groupB && second === groupA);
1330
1474
  }
1331
1475
  /**
@@ -1340,11 +1484,10 @@ function isIsolatedPair(groupA, groupB, isolatedGroups) {
1340
1484
  */
1341
1485
  function checkDependencies(selfName, self, dependencyNames, context) {
1342
1486
  const violations = [];
1343
- const isolatedGroups = context.isolatedGroups ?? [];
1344
1487
  for (const dependencyName of dependencyNames) {
1345
1488
  const dependency = context.graph.get(dependencyName);
1346
1489
  if (dependency === void 0) continue;
1347
- if (isIsolatedPair(self.group, dependency.group, isolatedGroups)) {
1490
+ if (isIsolatedPair(self.group, dependency.group, context.isolatedGroups)) {
1348
1491
  violations.push({
1349
1492
  dependencyName,
1350
1493
  messageId: "isolatedGroup",
@@ -1427,9 +1570,9 @@ function last(array) {
1427
1570
  * - **'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.
1428
1571
  */
1429
1572
  const NAME_SEGMENTS_BY_STRATEGY = {
1430
- basename: (segments) => [last(segments)],
1431
- "keep-group": (segments) => segments,
1432
- "drop-group": (_segments, rest) => rest
1573
+ basename: ({ segments }) => [last(segments)],
1574
+ "keep-group": ({ segments }) => segments,
1575
+ "drop-group": ({ rest }) => rest
1433
1576
  };
1434
1577
  /**
1435
1578
  * 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 "-").
@@ -1439,7 +1582,10 @@ function expectedPackageName(relativeDir, group, naming) {
1439
1582
  const segments = splitPathSegments(relativeDir);
1440
1583
  const groupPrefixLength = splitPathSegments(group.path ?? group.name).length;
1441
1584
  const rest = segments.slice(groupPrefixLength);
1442
- const joined = NAME_SEGMENTS_BY_STRATEGY[group.naming ?? "drop-group"](segments, rest).join(separator);
1585
+ const joined = NAME_SEGMENTS_BY_STRATEGY[group.naming ?? "drop-group"]({
1586
+ segments,
1587
+ rest
1588
+ }).join(separator);
1443
1589
  return naming.scope === void 0 ? joined : `${naming.scope}/${joined}`;
1444
1590
  }
1445
1591
  //#endregion
@@ -2447,6 +2593,14 @@ const packageJsonKeyOrder = {
2447
2593
  //#endregion
2448
2594
  //#region src/rules/package-name-mirrors-path.ts
2449
2595
  /**
2596
+ * The GroupSpec this rule's own resolved `groupName` always identifies, looked up by name in `groups`. A real `groupName` (`self.group` in the visitor below) is set only by buildWorkspaceGraph itself (workspace-graph.ts), directly from `candidate.group.name`, where `candidate.group` in turn only ever comes from `findOwningGroup(relativeDir, options.groups)`, one of that SAME `options.groups` array the visitor passes back in here as `groups`. So a real graph's group name is provably one of `groups`' own names whenever both come from the identical options object, which loadGraph(context.filename, options) and this lookup always do; failing to find it here means the graph was built from a different options object than the one now inspecting it, not a legitimate absence to skip past. Exported so this throw (unreachable through the real call site below) 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.
2597
+ */
2598
+ function findGroupSpec(groups, groupName) {
2599
+ const group = groups.find((candidate) => candidate.name === groupName);
2600
+ if (group === void 0) throw new Error(`Unreachable: the workspace graph resolved group "${groupName}", which is not among this same rule invocation's own "groups" option.`);
2601
+ return group;
2602
+ }
2603
+ /**
2450
2604
  * Reports a workspace package whose declared name does not mirror its path, per its own group's naming strategy and the workspace's global scope/separator (see expectedPackageName in workspace-checks.ts for the exact derivation). Opt-in: entirely a no-op whenever the shared `naming` option is omitted, exactly as the option's own doc comment (workspace-options.ts) states, since a workspace with its own established naming convention this rule cannot express should not be forced to adopt one that fits.
2451
2605
  *
2452
2606
  * Deliberately not derived from `context.filename`'s own directory the way the hive original was: this rule instead looks its own package up in the already-built graph by its DECLARED name (the same self-identification every other workspace-architecture rule uses), which is what lets `expectedPackageName` be checked against the graph's own recorded relativeDir/group rather than re-deriving them from a path assumed to equal the folder a file happens to be linted from.
@@ -2475,8 +2629,7 @@ function createPackageNameMirrorsPathRule(loadGraph = loadWorkspaceGraph) {
2475
2629
  if (declared === void 0) return;
2476
2630
  const self = graph.packagesByName.get(declared.name);
2477
2631
  if (self === void 0) return;
2478
- const group = options.groups.find((candidate) => candidate.name === self.group);
2479
- if (group === void 0) return;
2632
+ const group = findGroupSpec(options.groups, self.group);
2480
2633
  const expected = expectedPackageName(self.relativeDir, group, naming);
2481
2634
  if (expected === declared.name) return;
2482
2635
  context.report({
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.21.0",
4
+ "version": "2.21.1",
5
5
  "dependencies": {
6
6
  "@eslint/js": "^10.0.1",
7
7
  "@typescript-eslint/utils": "8.67.0",