exadev-eslint-config 2.21.1 → 2.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -289,7 +289,7 @@ Bundled into `exadevConfig()`'s default output the same way React/Next.js auto-d
289
289
  | `package-json-key-order` | ✓ | **Requires `package.json`'s keys to match `syncpack format`'s order.** See [Optional package.json key ordering](#optional-packagejson-key-ordering) — opt-in via `exadevConfig({ packageJsonKeyOrder: true })`, not part of `recommended`/`barrel`. A JSON-language rule (`@eslint/json`'s `json/json`), not a TSESLint one — needs no type information and doesn't apply to any `.ts`/`.js` file. |
290
290
  | `no-uphill-dependency` | | **Enforces a configured workspace's rank, rank-skip, slice and group-isolation boundaries** on every `package.json`'s declared dependencies. See [Workspace architecture](#workspace-architecture). Opt-in via `exadevConfig({ workspaceArchitecture })` or the standalone `workspaceArchitectureConfig()`, not part of `recommended`/`barrel`. A JSON-language rule, needs no type information. |
291
291
  | `no-dependency-cycle` | | **Disallows a workspace dependency that can reach back to the package declaring it.** A same-rank or same-slice dependency passes `no-uphill-dependency` while still forming a cycle, which this rule catches instead. See [Workspace architecture](#workspace-architecture). |
292
- | `package-name-mirrors-path` | | **Requires a workspace package's declared name to match the name its own path derives**, under the configured naming scope and separator. Itself opt-in within workspace architecture: a no-op unless the shared `naming` option is given. See [Workspace architecture](#workspace-architecture). |
292
+ | `package-name-mirrors-path` | | **Requires a workspace package's declared name to match the name its own path derives**, under the configured naming scope and separator. A package that declares no name at all is reported too (it plainly cannot mirror its path when it names nothing). Itself opt-in within workspace architecture: a no-op unless the shared `naming` option is given. See [Workspace architecture](#workspace-architecture). |
293
293
  | `test-file-kind` | | **A test file's name must declare its own test kind.** A filename suffix immediately before `.test`/`.spec` (e.g. `foo.unit.test.ts`), one of a configurable `{ kinds }` set (default: `unit`, `integration`, `e2e`). A naming-discipline rule, not a content classifier — it checks only the filename, never what the file actually tests. Self-scoped to real test/spec files (`context.filename`), so it never misfires when applied unscoped and never relies on a consumer's own `files` config. Requires no type information. |
294
294
 
295
295
  ## Barrel policy
@@ -348,10 +348,10 @@ 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, 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. |
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 "."; 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
352
  | `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). |
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. |
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 `-`). |
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. 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
355
  | `defaultRank` | The rank a package gets when neither `nameRanks` nor its own group resolves one. |
356
356
  | `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. |
357
357
  | `isolatedGroups` | `[groupName, groupName][]` pairs forbidden from depending on each other in either direction, on top of the rank/slice checks (for two groups that share a rank but must still stay separate). |
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.1";
312
+ var version = "2.22.0";
313
313
  //#endregion
314
314
  //#region src/react.ts
315
315
  const JSX_FILE_PATTERNS = ["**/*.jsx", "**/*.tsx"];
@@ -826,7 +826,8 @@ const noControlFlow = {
826
826
  const realWorkspaceFs = {
827
827
  existsSync: node_fs.existsSync,
828
828
  readFileSync: (path) => (0, node_fs.readFileSync)(path, "utf8"),
829
- readdirSync: (path) => (0, node_fs.readdirSync)(path, { withFileTypes: true })
829
+ readdirSync: (path) => (0, node_fs.readdirSync)(path, { withFileTypes: true }),
830
+ realpathSync: (path) => (0, node_fs.realpathSync)(path)
830
831
  };
831
832
  function listSubdirectories(fs, dir) {
832
833
  if (!fs.existsSync(dir)) return [];
@@ -846,10 +847,22 @@ function requireChar$1(text, index) {
846
847
  return char;
847
848
  }
848
849
  const REGEXP_SPECIAL_CHARS = /[.+^${}()|\]\\]/gu;
850
+ const ESCAPE_ANY_REGEXP_CHAR = /[.*+?^${}()|[\]\\]/gu;
849
851
  function segmentToRegExp(segment) {
850
852
  let source = "";
851
853
  for (let index = 0; index < segment.length;) {
852
854
  const char = requireChar$1(segment, index);
855
+ if (char === "\\") {
856
+ const next = segment[index + 1];
857
+ if (next === void 0) {
858
+ source += char.replace(ESCAPE_ANY_REGEXP_CHAR, "\\$&");
859
+ index += 1;
860
+ continue;
861
+ }
862
+ source += next.replace(ESCAPE_ANY_REGEXP_CHAR, "\\$&");
863
+ index += 2;
864
+ continue;
865
+ }
853
866
  if (char === "*") {
854
867
  source += ".*";
855
868
  index += 1;
@@ -861,15 +874,17 @@ function segmentToRegExp(segment) {
861
874
  continue;
862
875
  }
863
876
  if (char === "[") {
864
- const closeIndex = segment.indexOf("]", index + 1);
877
+ let bodyStart = index + 1;
878
+ const marker = segment[bodyStart];
879
+ const negated = marker === "!" || marker === "^";
880
+ if (negated) bodyStart += 1;
881
+ const closeIndex = segment.indexOf("]", bodyStart + 1);
865
882
  if (closeIndex === -1) {
866
883
  source += "\\[";
867
884
  index += 1;
868
885
  continue;
869
886
  }
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, "\\\\");
887
+ const classBody = segment.slice(bodyStart, closeIndex).replace(/\\/gu, "\\\\").replace(/\]/gu, "\\]");
873
888
  source += `[${negated ? "^" : ""}${classBody}]`;
874
889
  index = closeIndex + 1;
875
890
  continue;
@@ -883,7 +898,7 @@ function segmentRequestsDotMatch(segment) {
883
898
  return segment.startsWith(".");
884
899
  }
885
900
  function listRealSubdirectories(fs, dir) {
886
- return listSubdirectories(fs, dir).filter((name) => name !== "node_modules");
901
+ return listSubdirectories(fs, dir).filter((name) => name !== "node_modules" && name !== "bower_components");
887
902
  }
888
903
  function walkPattern(fs, root, segments, matchedSoFar) {
889
904
  const [segment, ...rest] = segments;
@@ -944,12 +959,27 @@ function expandBraces(pattern) {
944
959
  for (const alternative of alternatives) for (const expandedAlternative of expandBraces(alternative)) for (const expandedSuffix of expandBraces(suffix)) results.push(prefix + expandedAlternative + expandedSuffix);
945
960
  return results;
946
961
  }
962
+ function normalizeGlobSegments(segments) {
963
+ const normalized = [];
964
+ for (const segment of segments) {
965
+ if (segment === ".") continue;
966
+ if (segment === ".." && normalized.length > 0 && normalized[normalized.length - 1] !== "..") {
967
+ normalized.pop();
968
+ continue;
969
+ }
970
+ normalized.push(segment);
971
+ }
972
+ return normalized;
973
+ }
947
974
  /**
948
975
  * 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.
949
976
  */
950
977
  function expandGlob(fs, root, pattern) {
951
978
  const matches = /* @__PURE__ */ new Set();
952
- for (const expandedPattern of expandBraces(pattern)) for (const match of walkPattern(fs, root, splitPathSegments(expandedPattern), [])) matches.add(match);
979
+ for (const expandedPattern of expandBraces(pattern)) {
980
+ const segments = normalizeGlobSegments(splitPathSegments(expandedPattern));
981
+ for (const match of walkPattern(fs, root, segments, [])) matches.add(match);
982
+ }
953
983
  return [...matches];
954
984
  }
955
985
  /** 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. */
@@ -957,7 +987,7 @@ function isExcludePattern(pattern) {
957
987
  return pattern.startsWith("!");
958
988
  }
959
989
  /**
960
- * Resolves pnpm-workspace.yaml's own package glob list (positive patterns plus "!"-prefixed excludes) against the real directory tree, returning every matching directory (relative to `root`) that also owns a real package.json. A glob match with no package.json of its own is silently not a package (an empty scaffold directory, a stray folder left behind by a rename), matching pnpm's own behaviour rather than treating it as a workspace member.
990
+ * Resolves pnpm-workspace.yaml's own package glob list (positive patterns plus "!"-prefixed excludes) against the real directory tree, returning every matching directory (relative to `root`) that also owns a real package.json. A glob match with no package.json of its own is silently not a package (an empty scaffold directory, a stray folder left behind by a rename), and a positive pattern matching NO directory at all is silently empty too: pnpm itself accepts a "packages:" glob over a group that currently holds no member directories (a freshly scaffolded group, or one whose only real content so far is a README), so a workspace declaring a two-level nested glob over a "verticals" group before anything lives under it must lint exactly as it did before that glob was added, not abort every workspace-architecture rule with a startup error.
961
991
  */
962
992
  function resolveWorkspacePackageDirs(fs, root, patterns) {
963
993
  const includePatterns = patterns.filter((pattern) => !isExcludePattern(pattern));
@@ -1065,6 +1095,23 @@ function readWorkspacePackages(yamlText) {
1065
1095
  return items;
1066
1096
  }
1067
1097
  //#endregion
1098
+ //#region src/rules/workspace-errors.ts
1099
+ function assertIsError(error, context) {
1100
+ if (!(error instanceof Error)) throw new Error(`Unreachable: ${context} threw a non-Error value.`);
1101
+ }
1102
+ /**
1103
+ * The exact context string readDeclaredManifest (workspace-graph.ts) passes to assertIsError when JSON.parse throws. Extracted into its own function, rather than inlined as a template literal at that call site, because assertIsError's own throw is unreachable there (JSON.parse never throws a non-Error value): a test exercising the real call path can never observe which context string was passed, so this function exists purely to let that exact string be asserted directly against a range of manifest paths, the same "Unreachable, tested directly" treatment this package gives requireChar/findDependencyEntry/findGroupSpec/last.
1104
+ */
1105
+ function jsonParseContext(manifestPath) {
1106
+ return `JSON.parse while parsing "${manifestPath}"`;
1107
+ }
1108
+ /**
1109
+ * The exact context string validateRankRulePatterns (workspace-options.ts) passes to assertIsError when the RegExp constructor throws, extracted for the same reason as jsonParseContext above: that call site's assertIsError throw is equally unreachable (the RegExp constructor never throws a non-Error value), so this function is what makes the string directly testable.
1110
+ */
1111
+ function regExpConstructorContext(pattern) {
1112
+ return `the RegExp constructor while compiling "nameRanks" pattern "${pattern}"`;
1113
+ }
1114
+ //#endregion
1068
1115
  //#region src/rules/workspace-options.ts
1069
1116
  const workspaceArchitectureOptionsSchema = {
1070
1117
  type: "object",
@@ -1085,7 +1132,7 @@ const workspaceArchitectureOptionsSchema = {
1085
1132
  properties: {
1086
1133
  name: { type: "string" },
1087
1134
  path: { type: "string" },
1088
- rank: { type: "number" },
1135
+ rank: { type: "integer" },
1089
1136
  slice: { oneOf: [{
1090
1137
  type: "object",
1091
1138
  properties: { segment: {
@@ -1119,20 +1166,23 @@ const workspaceArchitectureOptionsSchema = {
1119
1166
  type: "object",
1120
1167
  properties: {
1121
1168
  pattern: { type: "string" },
1122
- rank: { type: "number" }
1169
+ rank: { type: "integer" }
1123
1170
  },
1124
1171
  required: ["pattern", "rank"],
1125
1172
  additionalProperties: false
1126
1173
  }
1127
1174
  },
1128
- defaultRank: { type: "number" },
1175
+ defaultRank: { type: "integer" },
1129
1176
  rankSkip: {
1130
1177
  type: "object",
1131
1178
  properties: {
1132
- maxDistance: { type: "number" },
1179
+ maxDistance: {
1180
+ type: "integer",
1181
+ minimum: 0
1182
+ },
1133
1183
  exemptRanks: {
1134
1184
  type: "array",
1135
- items: { type: "number" }
1185
+ items: { type: "integer" }
1136
1186
  }
1137
1187
  },
1138
1188
  required: ["maxDistance", "exemptRanks"],
@@ -1144,7 +1194,8 @@ const workspaceArchitectureOptionsSchema = {
1144
1194
  type: "array",
1145
1195
  items: { type: "string" },
1146
1196
  minItems: 2,
1147
- maxItems: 2
1197
+ maxItems: 2,
1198
+ uniqueItems: true
1148
1199
  }
1149
1200
  },
1150
1201
  naming: {
@@ -1195,9 +1246,12 @@ function asOptionalString(value) {
1195
1246
  if (typeof value !== "string") fail();
1196
1247
  return value;
1197
1248
  }
1198
- function asOptionalNumber(value) {
1249
+ function isInteger(value) {
1250
+ return Number.isInteger(value);
1251
+ }
1252
+ function asOptionalInteger(value) {
1199
1253
  if (value === void 0) return void 0;
1200
- if (typeof value !== "number") fail();
1254
+ if (!isInteger(value)) fail();
1201
1255
  return value;
1202
1256
  }
1203
1257
  function asOptionalStringArray(value) {
@@ -1224,7 +1278,7 @@ function isGroupSpec(value) {
1224
1278
  if (typeof value["name"] !== "string") return false;
1225
1279
  const { path, rank, slice, naming } = value;
1226
1280
  if (path !== void 0 && typeof path !== "string") return false;
1227
- if (rank !== void 0 && typeof rank !== "number") return false;
1281
+ if (rank !== void 0 && !isInteger(rank)) return false;
1228
1282
  if (slice !== void 0 && !isSliceSpec(slice)) return false;
1229
1283
  if (naming !== void 0 && !isNamingStrategy(naming)) return false;
1230
1284
  return true;
@@ -1244,19 +1298,28 @@ function findDuplicateGroupName(groups) {
1244
1298
  }
1245
1299
  }
1246
1300
  function isRankRule(value) {
1247
- return isRecord$1(value) && hasOnlyKeys(value, RANK_RULE_KEYS) && typeof value["pattern"] === "string" && typeof value["rank"] === "number";
1301
+ return isRecord$1(value) && hasOnlyKeys(value, RANK_RULE_KEYS) && typeof value["pattern"] === "string" && isInteger(value["rank"]);
1302
+ }
1303
+ function validateRankRulePatterns(rankRules) {
1304
+ for (const rule of rankRules) try {
1305
+ new RegExp(rule.pattern, "u");
1306
+ } catch (error) {
1307
+ assertIsError(error, regExpConstructorContext(rule.pattern));
1308
+ throw new Error(`@exadev/eslint-config: "nameRanks" pattern "${rule.pattern}" is not a valid regular expression: ${error.message}`, { cause: error });
1309
+ }
1248
1310
  }
1249
1311
  function asOptionalRankRuleArray(value) {
1250
1312
  if (value === void 0) return void 0;
1251
1313
  if (!Array.isArray(value) || !value.every(isRankRule)) fail();
1314
+ validateRankRulePatterns(value);
1252
1315
  return value;
1253
1316
  }
1254
1317
  function isRankSkipOptions(value) {
1255
1318
  if (!isRecord$1(value)) return false;
1256
1319
  if (!hasOnlyKeys(value, RANK_SKIP_KEYS)) return false;
1257
- if (typeof value["maxDistance"] !== "number") return false;
1320
+ if (!isNonNegativeInteger(value["maxDistance"])) return false;
1258
1321
  const { exemptRanks } = value;
1259
- return Array.isArray(exemptRanks) && exemptRanks.every((rank) => typeof rank === "number");
1322
+ return Array.isArray(exemptRanks) && exemptRanks.every(isInteger);
1260
1323
  }
1261
1324
  function asOptionalRankSkip(value) {
1262
1325
  if (value === void 0) return void 0;
@@ -1296,13 +1359,16 @@ function readWorkspaceArchitectureOptions(options) {
1296
1359
  const packages = asOptionalStringArray(options["packages"]);
1297
1360
  const dependencyFields = asOptionalStringArray(options["dependencyFields"]);
1298
1361
  const nameRanks = asOptionalRankRuleArray(options["nameRanks"]);
1299
- const defaultRank = asOptionalNumber(options["defaultRank"]);
1362
+ const defaultRank = asOptionalInteger(options["defaultRank"]);
1300
1363
  const rankSkip = asOptionalRankSkip(options["rankSkip"]);
1301
1364
  const isolatedGroups = asOptionalIsolatedGroups(options["isolatedGroups"]);
1302
1365
  const naming = asOptionalNaming(options["naming"]);
1303
1366
  if (isolatedGroups !== void 0) {
1304
1367
  const groupNames = new Set(groups.map((group) => group.name));
1305
- for (const [first, second] of isolatedGroups) if (!groupNames.has(first) || !groupNames.has(second)) throw new Error(`@exadev/eslint-config: "isolatedGroups" names a group not declared in "groups" (["${first}", "${second}"]). Every isolatedGroups pair must name two of this workspace's own declared groups.`);
1368
+ for (const [first, second] of isolatedGroups) {
1369
+ if (!groupNames.has(first) || !groupNames.has(second)) throw new Error(`@exadev/eslint-config: "isolatedGroups" names a group not declared in "groups" (["${first}", "${second}"]). Every isolatedGroups pair must name two of this workspace's own declared groups.`);
1370
+ if (first === second) throw new Error(`@exadev/eslint-config: "isolatedGroups" pair ["${first}", "${second}"] names the same group twice. A group cannot be isolated from itself.`);
1371
+ }
1306
1372
  }
1307
1373
  return {
1308
1374
  ...root !== void 0 && { root },
@@ -1344,15 +1410,15 @@ function findOwningGroup(relativeDir, groups) {
1344
1410
  return best;
1345
1411
  }
1346
1412
  /**
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).
1413
+ * 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. `declaredName` is the package's own genuine `package.json` "name", never a directory-derived stand-in: pnpm allows a workspace package to declare no name at all, and such a package has nothing for a nameRanks pattern to match (the README states nameRanks checks "a package's declared name", not its directory), so a nameless package skips nameRanks entirely and falls straight through to its group's rank or defaultRank, the same as a package whose name simply matched no pattern. 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).
1348
1414
  */
1349
1415
  function deriveRank(declaredName, group, options) {
1350
- if (options.nameRanks !== void 0) {
1416
+ if (declaredName !== void 0 && options.nameRanks !== void 0) {
1351
1417
  for (const rule of options.nameRanks) if (new RegExp(rule.pattern, "u").test(declaredName)) return rule.rank;
1352
1418
  }
1353
1419
  if (group.rank !== void 0) return group.rank;
1354
1420
  if (options.defaultRank !== void 0) return options.defaultRank;
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".`);
1421
+ throw new Error(`@exadev/eslint-config: no rank could be resolved for workspace package "${declaredName ?? "(no declared name)"}" (group "${group.name}"). Give the group a "rank", add a matching "nameRanks" pattern, or set "defaultRank".`);
1356
1422
  }
1357
1423
  /**
1358
1424
  * The slice value a 'segment' group derives directly from its own package's path: the Nth path segment counting from the group's own root, not from the workspace root, so a group's own internal restructure never shifts every other group's slice numbering.
@@ -1375,23 +1441,29 @@ function isRecord(value) {
1375
1441
  return typeof value === "object" && value !== null;
1376
1442
  }
1377
1443
  /**
1378
- * 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 with no usable string "name" is treated the same way regardless: nothing this graph can identify a package by.
1444
+ * 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).
1379
1445
  *
1380
1446
  * Exported so `dependencyNames`' own exact contents (never a stray extra entry) can be asserted directly: buildWorkspaceGraph's own public output filters dependencyNamesByName down to workspace-internal names only, which would silently absorb an unexpected non-package entry before any graph-level test could ever see it.
1381
1447
  */
1382
1448
  function readDeclaredManifest(fs, absoluteDir, dependencyFields) {
1383
1449
  const manifestPath = (0, node_path.join)(absoluteDir, "package.json");
1384
- const parsed = JSON.parse(fs.readFileSync(manifestPath));
1450
+ const raw = fs.readFileSync(manifestPath);
1451
+ let parsed;
1452
+ try {
1453
+ parsed = JSON.parse(raw);
1454
+ } catch (error) {
1455
+ assertIsError(error, jsonParseContext(manifestPath));
1456
+ throw new Error(`@exadev/eslint-config: could not parse "${manifestPath}" as JSON: ${error.message}`, { cause: error });
1457
+ }
1385
1458
  if (!isRecord(parsed)) return void 0;
1386
1459
  const name = parsed["name"];
1387
- if (typeof name !== "string") return void 0;
1388
1460
  const dependencyNames = [];
1389
1461
  for (const field of dependencyFields) {
1390
1462
  const value = parsed[field];
1391
1463
  if (isRecord(value)) dependencyNames.push(...Object.keys(value));
1392
1464
  }
1393
1465
  return {
1394
- name,
1466
+ name: typeof name === "string" ? name : void 0,
1395
1467
  dependencyNames
1396
1468
  };
1397
1469
  }
@@ -1408,6 +1480,16 @@ function resolveWorkspaceRoot(fs, filename, rootOption) {
1408
1480
  dir = parent;
1409
1481
  }
1410
1482
  }
1483
+ /**
1484
+ * The workspace-root-relative, forward-slash-joined directory the manifest at `filename` (context.filename, the file ESLint is currently linting) sits in: computed directly from the filesystem, never trusted from a declared "name" field. Every workspace-architecture rule uses this to confirm the manifest it is linting is genuinely the SAME package.json buildWorkspaceGraph resolved for whatever graph entry it is about to check, since a name is only ever unique within the graph's own build (buildWorkspaceGraph's own duplicate-name throw enforces that), never across a stale or duplicated copy of a manifest sitting elsewhere in the tree outside the workspace's own package globs (a build output directory that copies its source package's package.json verbatim, say). A rule that skipped this check would check that copy under the real package's own graph entry: reporting edges the real package never declared, or letting a single real violation double-report once per copy.
1485
+ *
1486
+ * Both `root` and `filename`'s own directory are resolved through `fs.realpathSync` before comparing: an explicit `root` option and ESLint's own `context.filename` can spell the identical real directory two different ways (a symlink anywhere on either path, a macOS /tmp vs /private/tmp cwd being the recurring real case), and a purely lexical `relative()` between the two spellings would compute a path nowhere near the graph entry's own relativeDir, silently making every workspace-architecture rule report nothing for a perfectly real, correctly-configured package. Comparing realpaths instead makes the two spellings agree while still catching a genuinely different directory (a stale or duplicated manifest copy) as genuinely different.
1487
+ */
1488
+ function manifestRelativeDir(fs, root, filename) {
1489
+ const realRoot = fs.realpathSync((0, node_path.resolve)(root));
1490
+ const realManifestDir = fs.realpathSync((0, node_path.dirname)((0, node_path.resolve)(filename)));
1491
+ return (0, node_path.relative)(realRoot, realManifestDir).split(node_path.sep).join("/");
1492
+ }
1411
1493
  function readPackagesFromYaml(fs, root) {
1412
1494
  const yamlPath = (0, node_path.join)(root, "pnpm-workspace.yaml");
1413
1495
  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.`);
@@ -1429,10 +1511,12 @@ function collectCandidates(fs, root, options) {
1429
1511
  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.`);
1430
1512
  const manifest = readDeclaredManifest(fs, (0, node_path.join)(root, relativeDir), resolveDependencyFields(options));
1431
1513
  if (manifest === void 0) continue;
1514
+ const declaredName = manifest.name ?? relativeDir;
1432
1515
  candidates.push({
1433
1516
  relativeDir,
1434
1517
  group,
1435
- declaredName: manifest.name,
1518
+ declaredName,
1519
+ name: manifest.name,
1436
1520
  dependencyNames: manifest.dependencyNames
1437
1521
  });
1438
1522
  }
@@ -1451,7 +1535,8 @@ function collectKnownSlices(candidates) {
1451
1535
  function deriveSlice(candidate, knownSlices) {
1452
1536
  const { slice } = candidate.group;
1453
1537
  if (slice === void 0) return void 0;
1454
- return "segment" in slice ? sliceBySegment(candidate.relativeDir, candidate.group, slice.segment) : sliceByNamePrefix(candidate.declaredName, knownSlices);
1538
+ if ("segment" in slice) return sliceBySegment(candidate.relativeDir, candidate.group, slice.segment);
1539
+ return candidate.name === void 0 ? void 0 : sliceByNamePrefix(candidate.name, knownSlices);
1455
1540
  }
1456
1541
  /**
1457
1542
  * Builds the whole-workspace graph from scratch: every declared package's group/rank/slice, and every workspace-internal dependency edge. Scans the real tree once per call; getWorkspaceGraph below is the memoized entry point every rule actually calls.
@@ -1464,7 +1549,7 @@ function buildWorkspaceGraph(fs, root, options) {
1464
1549
  for (const candidate of candidates) {
1465
1550
  const existing = packagesByName.get(candidate.declaredName);
1466
1551
  if (existing !== void 0) throw new Error(`@exadev/eslint-config: two workspace packages both declare the name "${candidate.declaredName}" ("${existing.relativeDir}" and "${candidate.relativeDir}").`);
1467
- const rank = deriveRank(candidate.declaredName, candidate.group, options);
1552
+ const rank = deriveRank(candidate.name, candidate.group, options);
1468
1553
  const slice = deriveSlice(candidate, knownSlices);
1469
1554
  packagesByName.set(candidate.declaredName, {
1470
1555
  name: candidate.declaredName,
@@ -1600,12 +1685,12 @@ function last(array) {
1600
1685
  * 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.
1601
1686
  *
1602
1687
  * - **'drop-group'**: drop the group's own root path segments, keep what remains.
1603
- * - **'keep-group'**: keep every segment of `relativeDir` as-is, including the group's own root segments (a test group whose packages are named "test-<feature>", mirroring the feature they test, needs its own "test" segment kept).
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.
1604
1689
  * - **'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.
1605
1690
  */
1606
1691
  const NAME_SEGMENTS_BY_STRATEGY = {
1607
1692
  basename: ({ segments }) => [last(segments)],
1608
- "keep-group": ({ segments }) => segments,
1693
+ "keep-group": ({ rest, groupNameSegment }) => [groupNameSegment, ...rest],
1609
1694
  "drop-group": ({ rest }) => rest
1610
1695
  };
1611
1696
  /**
@@ -1614,11 +1699,13 @@ const NAME_SEGMENTS_BY_STRATEGY = {
1614
1699
  function expectedPackageName(relativeDir, group, naming) {
1615
1700
  const separator = naming.separator ?? "-";
1616
1701
  const segments = splitPathSegments(relativeDir);
1617
- const groupPrefixLength = splitPathSegments(group.path ?? group.name).length;
1618
- const rest = segments.slice(groupPrefixLength);
1702
+ const groupPathSegments = splitPathSegments(group.path ?? group.name);
1703
+ const rest = segments.slice(groupPathSegments.length);
1704
+ const groupNameSegment = last(groupPathSegments);
1619
1705
  const joined = NAME_SEGMENTS_BY_STRATEGY[group.naming ?? "drop-group"]({
1620
1706
  segments,
1621
- rest
1707
+ rest,
1708
+ groupNameSegment
1622
1709
  }).join(separator);
1623
1710
  return naming.scope === void 0 ? joined : `${naming.scope}/${joined}`;
1624
1711
  }
@@ -1662,9 +1749,10 @@ function collectTopLevelDependencies(rootObject, dependencyFields) {
1662
1749
  /**
1663
1750
  * Reports a workspace dependency that can reach back to the declaring package: a cycle. This is NOT covered by no-uphill-dependency, and the distinction matters: that rule permits a same-rank (or, within a slice, a same-slice) dependency, so two packages at the same rank depending on each other passes it while still being a cycle. Turborepo does reject a cyclic task graph, but only once the whole graph is assembled, by which point a large refactor has already moved everything; reporting it at the manifest, where the edge is declared, names the two packages and the direction to reverse.
1664
1751
  *
1665
- * Identifies "self" by this manifest's own DECLARED name (read via readDeclaredName), not a name derived from its directory: the hive original derived `ownName` from the package's path through its own expectedPackageName, so a package whose declared name did not yet match its path (itself a naming violation, but a distinct one from a cycle) was checked under the WRONG name here, silently missing or misattributing a real cycle. Reading the manifest's own name directly is also strictly simpler: cycle detection has no need of path-derived naming at all.
1752
+ * Identifies "self" by this manifest's own DECLARED name (read via readDeclaredName) when it has one, never a name derived by re-deriving what its path OUGHT to produce: the hive original derived `ownName` from the package's path through its own expectedPackageName, so a package whose declared name did not yet match its path (itself a naming violation, but a distinct one from a cycle) was checked under the WRONG name here, silently missing or misattributing a real cycle. A manifest with no declared name at all (pnpm allows this) falls back to its own directory instead, the same key buildWorkspaceGraph gives such a package in the graph (workspace-graph.ts), so its outgoing edges are still checked rather than silently dropped. Either way, the manifest actually being linted must also be confirmed as the graph entry's own file (see manifestRelativeDir below), not a stale or duplicated copy elsewhere in the tree sharing the same declared name.
1666
1753
  */
1667
- function createNoDependencyCycleRule(loadGraph = loadWorkspaceGraph) {
1754
+ function createNoDependencyCycleRule(deps = {}) {
1755
+ const { loadGraph = loadWorkspaceGraph, fs = realWorkspaceFs } = deps;
1668
1756
  return {
1669
1757
  meta: {
1670
1758
  type: "problem",
@@ -1682,18 +1770,20 @@ function createNoDependencyCycleRule(loadGraph = loadWorkspaceGraph) {
1682
1770
  const graph = loadGraph(context.filename, options);
1683
1771
  return { Object(node, parent) {
1684
1772
  if (parent?.type !== "Document") return;
1773
+ const relativeDir = manifestRelativeDir(fs, graph.root, context.filename);
1685
1774
  const declared = readDeclaredName(node);
1686
- if (declared === void 0) return;
1687
- if (!graph.packagesByName.has(declared.name)) return;
1775
+ const self = graph.packagesByName.get(declared?.name ?? relativeDir);
1776
+ if (self === void 0) return;
1777
+ if (self.relativeDir !== relativeDir) return;
1688
1778
  const dependencies = collectTopLevelDependencies(node, resolveDependencyFields(options));
1689
1779
  for (const dependency of dependencies) {
1690
1780
  if (!graph.packagesByName.has(dependency.name)) continue;
1691
- if (!dependencyPathExists(dependency.name, declared.name, graph.dependencyNamesByName)) continue;
1781
+ if (!dependencyPathExists(dependency.name, self.name, graph.dependencyNamesByName)) continue;
1692
1782
  context.report({
1693
1783
  loc: dependency.node.loc,
1694
1784
  messageId: "cycle",
1695
1785
  data: {
1696
- from: declared.name,
1786
+ from: self.name,
1697
1787
  to: dependency.name
1698
1788
  }
1699
1789
  });
@@ -2376,7 +2466,8 @@ function findDependencyEntry(dependencies, name) {
2376
2466
  /**
2377
2467
  * Enforces the configured workspace's own rank, rank-skip, slice and group-isolation boundaries on every `package.json`'s declared dependencies (see workspace-checks.ts's checkDependencies for the exact rules, and the package README's "Workspace architecture" section for the option shape). A factory, not a plain object, so a test can inject a stubbed LoadWorkspaceGraphFn returning a fabricated graph directly, exercising this rule's own reporting logic (which violation, which message, which location) with zero real filesystem I/O, the same "injectable in tests, defaulted in production" shape createBarrelPolicyRule already establishes.
2378
2468
  */
2379
- function createNoUphillDependencyRule(loadGraph = loadWorkspaceGraph) {
2469
+ function createNoUphillDependencyRule(deps = {}) {
2470
+ const { loadGraph = loadWorkspaceGraph, fs = realWorkspaceFs } = deps;
2380
2471
  return {
2381
2472
  meta: {
2382
2473
  type: "problem",
@@ -2399,13 +2490,14 @@ function createNoUphillDependencyRule(loadGraph = loadWorkspaceGraph) {
2399
2490
  const graph = loadGraph(context.filename, options);
2400
2491
  return { Object(node, parent) {
2401
2492
  if (parent?.type !== "Document") return;
2493
+ const relativeDir = manifestRelativeDir(fs, graph.root, context.filename);
2402
2494
  const declared = readDeclaredName(node);
2403
- if (declared === void 0) return;
2404
- const self = graph.packagesByName.get(declared.name);
2495
+ const self = graph.packagesByName.get(declared?.name ?? relativeDir);
2405
2496
  if (self === void 0) return;
2497
+ if (self.relativeDir !== relativeDir) return;
2406
2498
  const dependencies = collectTopLevelDependencies(node, resolveDependencyFields(options));
2407
2499
  const dependencyNames = [...new Set(dependencies.map((dependency) => dependency.name))];
2408
- const violations = checkDependencies(declared.name, self, dependencyNames, {
2500
+ const violations = checkDependencies(self.name, self, dependencyNames, {
2409
2501
  graph: graph.packagesByName,
2410
2502
  ...options.rankSkip !== void 0 && { rankSkip: options.rankSkip },
2411
2503
  ...options.isolatedGroups !== void 0 && { isolatedGroups: options.isolatedGroups }
@@ -2637,9 +2729,10 @@ function findGroupSpec(groups, groupName) {
2637
2729
  /**
2638
2730
  * 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.
2639
2731
  *
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.
2732
+ * Self-identified by its DECLARED name (the same self-identification every other workspace-architecture rule uses), not derived from `context.filename`'s own directory the way the hive original was: this 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. `context.filename`'s own directory is still consulted, but only afterwards, as a confirmation: it must equal the resolved graph entry's own relativeDir, or the manifest being linted is a stale or duplicated copy of a real package's package.json sitting somewhere else in the tree (a build output directory that copied it verbatim, say), not the genuine article this graph entry was built from.
2641
2733
  */
2642
- function createPackageNameMirrorsPathRule(loadGraph = loadWorkspaceGraph) {
2734
+ function createPackageNameMirrorsPathRule(deps = {}) {
2735
+ const { loadGraph = loadWorkspaceGraph, fs = realWorkspaceFs } = deps;
2643
2736
  return {
2644
2737
  meta: {
2645
2738
  type: "problem",
@@ -2650,7 +2743,10 @@ function createPackageNameMirrorsPathRule(loadGraph = loadWorkspaceGraph) {
2650
2743
  description: "Require a workspace package to declare the name its path derives, under the configured naming scope/separator.",
2651
2744
  url: "https://github.com/ExaDev/eslint-config/blob/main/src/rules/package-name-mirrors-path.ts"
2652
2745
  },
2653
- messages: { mismatch: "Package at \"{{dir}}\" declares \"{{actual}}\" but its path derives \"{{expected}}\"." }
2746
+ messages: {
2747
+ mismatch: "Package at \"{{dir}}\" declares \"{{actual}}\" but its path derives \"{{expected}}\".",
2748
+ missingName: "Package at \"{{dir}}\" declares no name at all, but its path derives \"{{expected}}\"."
2749
+ }
2654
2750
  },
2655
2751
  create(context) {
2656
2752
  const options = readWorkspaceArchitectureOptions(context.options[0]);
@@ -2659,12 +2755,24 @@ function createPackageNameMirrorsPathRule(loadGraph = loadWorkspaceGraph) {
2659
2755
  const graph = loadGraph(context.filename, options);
2660
2756
  return { Object(node, parent) {
2661
2757
  if (parent?.type !== "Document") return;
2758
+ const relativeDir = manifestRelativeDir(fs, graph.root, context.filename);
2662
2759
  const declared = readDeclaredName(node);
2663
- if (declared === void 0) return;
2664
- const self = graph.packagesByName.get(declared.name);
2760
+ const self = graph.packagesByName.get(declared?.name ?? relativeDir);
2665
2761
  if (self === void 0) return;
2762
+ if (self.relativeDir !== relativeDir) return;
2666
2763
  const group = findGroupSpec(options.groups, self.group);
2667
2764
  const expected = expectedPackageName(self.relativeDir, group, naming);
2765
+ if (declared === void 0) {
2766
+ context.report({
2767
+ loc: node.loc,
2768
+ messageId: "missingName",
2769
+ data: {
2770
+ dir: self.relativeDir,
2771
+ expected
2772
+ }
2773
+ });
2774
+ return;
2775
+ }
2668
2776
  if (expected === declared.name) return;
2669
2777
  context.report({
2670
2778
  loc: declared.node.loc,
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
- import fs, { existsSync, readFileSync, readdirSync } from "node:fs";
2
- import path, { dirname, join, posix, resolve } from "node:path";
1
+ import fs, { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs";
2
+ import path, { dirname, join, posix, relative, resolve, sep } from "node:path";
3
3
  import jsdoc from "eslint-plugin-jsdoc";
4
4
  import tsdoc from "eslint-plugin-tsdoc";
5
5
  import jsonCanonical from "eslint-plugin-json-canonical";
@@ -275,7 +275,7 @@ function resolveJsonPlugin(value) {
275
275
  }
276
276
  //#endregion
277
277
  //#region package.json
278
- var version = "2.21.1";
278
+ var version = "2.22.0";
279
279
  //#endregion
280
280
  //#region src/react.ts
281
281
  const JSX_FILE_PATTERNS = ["**/*.jsx", "**/*.tsx"];
@@ -792,7 +792,8 @@ const noControlFlow = {
792
792
  const realWorkspaceFs = {
793
793
  existsSync,
794
794
  readFileSync: (path) => readFileSync(path, "utf8"),
795
- readdirSync: (path) => readdirSync(path, { withFileTypes: true })
795
+ readdirSync: (path) => readdirSync(path, { withFileTypes: true }),
796
+ realpathSync: (path) => realpathSync(path)
796
797
  };
797
798
  function listSubdirectories(fs, dir) {
798
799
  if (!fs.existsSync(dir)) return [];
@@ -812,10 +813,22 @@ function requireChar$1(text, index) {
812
813
  return char;
813
814
  }
814
815
  const REGEXP_SPECIAL_CHARS = /[.+^${}()|\]\\]/gu;
816
+ const ESCAPE_ANY_REGEXP_CHAR = /[.*+?^${}()|[\]\\]/gu;
815
817
  function segmentToRegExp(segment) {
816
818
  let source = "";
817
819
  for (let index = 0; index < segment.length;) {
818
820
  const char = requireChar$1(segment, index);
821
+ if (char === "\\") {
822
+ const next = segment[index + 1];
823
+ if (next === void 0) {
824
+ source += char.replace(ESCAPE_ANY_REGEXP_CHAR, "\\$&");
825
+ index += 1;
826
+ continue;
827
+ }
828
+ source += next.replace(ESCAPE_ANY_REGEXP_CHAR, "\\$&");
829
+ index += 2;
830
+ continue;
831
+ }
819
832
  if (char === "*") {
820
833
  source += ".*";
821
834
  index += 1;
@@ -827,15 +840,17 @@ function segmentToRegExp(segment) {
827
840
  continue;
828
841
  }
829
842
  if (char === "[") {
830
- const closeIndex = segment.indexOf("]", index + 1);
843
+ let bodyStart = index + 1;
844
+ const marker = segment[bodyStart];
845
+ const negated = marker === "!" || marker === "^";
846
+ if (negated) bodyStart += 1;
847
+ const closeIndex = segment.indexOf("]", bodyStart + 1);
831
848
  if (closeIndex === -1) {
832
849
  source += "\\[";
833
850
  index += 1;
834
851
  continue;
835
852
  }
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, "\\\\");
853
+ const classBody = segment.slice(bodyStart, closeIndex).replace(/\\/gu, "\\\\").replace(/\]/gu, "\\]");
839
854
  source += `[${negated ? "^" : ""}${classBody}]`;
840
855
  index = closeIndex + 1;
841
856
  continue;
@@ -849,7 +864,7 @@ function segmentRequestsDotMatch(segment) {
849
864
  return segment.startsWith(".");
850
865
  }
851
866
  function listRealSubdirectories(fs, dir) {
852
- return listSubdirectories(fs, dir).filter((name) => name !== "node_modules");
867
+ return listSubdirectories(fs, dir).filter((name) => name !== "node_modules" && name !== "bower_components");
853
868
  }
854
869
  function walkPattern(fs, root, segments, matchedSoFar) {
855
870
  const [segment, ...rest] = segments;
@@ -910,12 +925,27 @@ function expandBraces(pattern) {
910
925
  for (const alternative of alternatives) for (const expandedAlternative of expandBraces(alternative)) for (const expandedSuffix of expandBraces(suffix)) results.push(prefix + expandedAlternative + expandedSuffix);
911
926
  return results;
912
927
  }
928
+ function normalizeGlobSegments(segments) {
929
+ const normalized = [];
930
+ for (const segment of segments) {
931
+ if (segment === ".") continue;
932
+ if (segment === ".." && normalized.length > 0 && normalized[normalized.length - 1] !== "..") {
933
+ normalized.pop();
934
+ continue;
935
+ }
936
+ normalized.push(segment);
937
+ }
938
+ return normalized;
939
+ }
913
940
  /**
914
941
  * 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.
915
942
  */
916
943
  function expandGlob(fs, root, pattern) {
917
944
  const matches = /* @__PURE__ */ new Set();
918
- for (const expandedPattern of expandBraces(pattern)) for (const match of walkPattern(fs, root, splitPathSegments(expandedPattern), [])) matches.add(match);
945
+ for (const expandedPattern of expandBraces(pattern)) {
946
+ const segments = normalizeGlobSegments(splitPathSegments(expandedPattern));
947
+ for (const match of walkPattern(fs, root, segments, [])) matches.add(match);
948
+ }
919
949
  return [...matches];
920
950
  }
921
951
  /** 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. */
@@ -923,7 +953,7 @@ function isExcludePattern(pattern) {
923
953
  return pattern.startsWith("!");
924
954
  }
925
955
  /**
926
- * Resolves pnpm-workspace.yaml's own package glob list (positive patterns plus "!"-prefixed excludes) against the real directory tree, returning every matching directory (relative to `root`) that also owns a real package.json. A glob match with no package.json of its own is silently not a package (an empty scaffold directory, a stray folder left behind by a rename), matching pnpm's own behaviour rather than treating it as a workspace member.
956
+ * Resolves pnpm-workspace.yaml's own package glob list (positive patterns plus "!"-prefixed excludes) against the real directory tree, returning every matching directory (relative to `root`) that also owns a real package.json. A glob match with no package.json of its own is silently not a package (an empty scaffold directory, a stray folder left behind by a rename), and a positive pattern matching NO directory at all is silently empty too: pnpm itself accepts a "packages:" glob over a group that currently holds no member directories (a freshly scaffolded group, or one whose only real content so far is a README), so a workspace declaring a two-level nested glob over a "verticals" group before anything lives under it must lint exactly as it did before that glob was added, not abort every workspace-architecture rule with a startup error.
927
957
  */
928
958
  function resolveWorkspacePackageDirs(fs, root, patterns) {
929
959
  const includePatterns = patterns.filter((pattern) => !isExcludePattern(pattern));
@@ -1031,6 +1061,23 @@ function readWorkspacePackages(yamlText) {
1031
1061
  return items;
1032
1062
  }
1033
1063
  //#endregion
1064
+ //#region src/rules/workspace-errors.ts
1065
+ function assertIsError(error, context) {
1066
+ if (!(error instanceof Error)) throw new Error(`Unreachable: ${context} threw a non-Error value.`);
1067
+ }
1068
+ /**
1069
+ * The exact context string readDeclaredManifest (workspace-graph.ts) passes to assertIsError when JSON.parse throws. Extracted into its own function, rather than inlined as a template literal at that call site, because assertIsError's own throw is unreachable there (JSON.parse never throws a non-Error value): a test exercising the real call path can never observe which context string was passed, so this function exists purely to let that exact string be asserted directly against a range of manifest paths, the same "Unreachable, tested directly" treatment this package gives requireChar/findDependencyEntry/findGroupSpec/last.
1070
+ */
1071
+ function jsonParseContext(manifestPath) {
1072
+ return `JSON.parse while parsing "${manifestPath}"`;
1073
+ }
1074
+ /**
1075
+ * The exact context string validateRankRulePatterns (workspace-options.ts) passes to assertIsError when the RegExp constructor throws, extracted for the same reason as jsonParseContext above: that call site's assertIsError throw is equally unreachable (the RegExp constructor never throws a non-Error value), so this function is what makes the string directly testable.
1076
+ */
1077
+ function regExpConstructorContext(pattern) {
1078
+ return `the RegExp constructor while compiling "nameRanks" pattern "${pattern}"`;
1079
+ }
1080
+ //#endregion
1034
1081
  //#region src/rules/workspace-options.ts
1035
1082
  const workspaceArchitectureOptionsSchema = {
1036
1083
  type: "object",
@@ -1051,7 +1098,7 @@ const workspaceArchitectureOptionsSchema = {
1051
1098
  properties: {
1052
1099
  name: { type: "string" },
1053
1100
  path: { type: "string" },
1054
- rank: { type: "number" },
1101
+ rank: { type: "integer" },
1055
1102
  slice: { oneOf: [{
1056
1103
  type: "object",
1057
1104
  properties: { segment: {
@@ -1085,20 +1132,23 @@ const workspaceArchitectureOptionsSchema = {
1085
1132
  type: "object",
1086
1133
  properties: {
1087
1134
  pattern: { type: "string" },
1088
- rank: { type: "number" }
1135
+ rank: { type: "integer" }
1089
1136
  },
1090
1137
  required: ["pattern", "rank"],
1091
1138
  additionalProperties: false
1092
1139
  }
1093
1140
  },
1094
- defaultRank: { type: "number" },
1141
+ defaultRank: { type: "integer" },
1095
1142
  rankSkip: {
1096
1143
  type: "object",
1097
1144
  properties: {
1098
- maxDistance: { type: "number" },
1145
+ maxDistance: {
1146
+ type: "integer",
1147
+ minimum: 0
1148
+ },
1099
1149
  exemptRanks: {
1100
1150
  type: "array",
1101
- items: { type: "number" }
1151
+ items: { type: "integer" }
1102
1152
  }
1103
1153
  },
1104
1154
  required: ["maxDistance", "exemptRanks"],
@@ -1110,7 +1160,8 @@ const workspaceArchitectureOptionsSchema = {
1110
1160
  type: "array",
1111
1161
  items: { type: "string" },
1112
1162
  minItems: 2,
1113
- maxItems: 2
1163
+ maxItems: 2,
1164
+ uniqueItems: true
1114
1165
  }
1115
1166
  },
1116
1167
  naming: {
@@ -1161,9 +1212,12 @@ function asOptionalString(value) {
1161
1212
  if (typeof value !== "string") fail();
1162
1213
  return value;
1163
1214
  }
1164
- function asOptionalNumber(value) {
1215
+ function isInteger(value) {
1216
+ return Number.isInteger(value);
1217
+ }
1218
+ function asOptionalInteger(value) {
1165
1219
  if (value === void 0) return void 0;
1166
- if (typeof value !== "number") fail();
1220
+ if (!isInteger(value)) fail();
1167
1221
  return value;
1168
1222
  }
1169
1223
  function asOptionalStringArray(value) {
@@ -1190,7 +1244,7 @@ function isGroupSpec(value) {
1190
1244
  if (typeof value["name"] !== "string") return false;
1191
1245
  const { path, rank, slice, naming } = value;
1192
1246
  if (path !== void 0 && typeof path !== "string") return false;
1193
- if (rank !== void 0 && typeof rank !== "number") return false;
1247
+ if (rank !== void 0 && !isInteger(rank)) return false;
1194
1248
  if (slice !== void 0 && !isSliceSpec(slice)) return false;
1195
1249
  if (naming !== void 0 && !isNamingStrategy(naming)) return false;
1196
1250
  return true;
@@ -1210,19 +1264,28 @@ function findDuplicateGroupName(groups) {
1210
1264
  }
1211
1265
  }
1212
1266
  function isRankRule(value) {
1213
- return isRecord$1(value) && hasOnlyKeys(value, RANK_RULE_KEYS) && typeof value["pattern"] === "string" && typeof value["rank"] === "number";
1267
+ return isRecord$1(value) && hasOnlyKeys(value, RANK_RULE_KEYS) && typeof value["pattern"] === "string" && isInteger(value["rank"]);
1268
+ }
1269
+ function validateRankRulePatterns(rankRules) {
1270
+ for (const rule of rankRules) try {
1271
+ new RegExp(rule.pattern, "u");
1272
+ } catch (error) {
1273
+ assertIsError(error, regExpConstructorContext(rule.pattern));
1274
+ throw new Error(`@exadev/eslint-config: "nameRanks" pattern "${rule.pattern}" is not a valid regular expression: ${error.message}`, { cause: error });
1275
+ }
1214
1276
  }
1215
1277
  function asOptionalRankRuleArray(value) {
1216
1278
  if (value === void 0) return void 0;
1217
1279
  if (!Array.isArray(value) || !value.every(isRankRule)) fail();
1280
+ validateRankRulePatterns(value);
1218
1281
  return value;
1219
1282
  }
1220
1283
  function isRankSkipOptions(value) {
1221
1284
  if (!isRecord$1(value)) return false;
1222
1285
  if (!hasOnlyKeys(value, RANK_SKIP_KEYS)) return false;
1223
- if (typeof value["maxDistance"] !== "number") return false;
1286
+ if (!isNonNegativeInteger(value["maxDistance"])) return false;
1224
1287
  const { exemptRanks } = value;
1225
- return Array.isArray(exemptRanks) && exemptRanks.every((rank) => typeof rank === "number");
1288
+ return Array.isArray(exemptRanks) && exemptRanks.every(isInteger);
1226
1289
  }
1227
1290
  function asOptionalRankSkip(value) {
1228
1291
  if (value === void 0) return void 0;
@@ -1262,13 +1325,16 @@ function readWorkspaceArchitectureOptions(options) {
1262
1325
  const packages = asOptionalStringArray(options["packages"]);
1263
1326
  const dependencyFields = asOptionalStringArray(options["dependencyFields"]);
1264
1327
  const nameRanks = asOptionalRankRuleArray(options["nameRanks"]);
1265
- const defaultRank = asOptionalNumber(options["defaultRank"]);
1328
+ const defaultRank = asOptionalInteger(options["defaultRank"]);
1266
1329
  const rankSkip = asOptionalRankSkip(options["rankSkip"]);
1267
1330
  const isolatedGroups = asOptionalIsolatedGroups(options["isolatedGroups"]);
1268
1331
  const naming = asOptionalNaming(options["naming"]);
1269
1332
  if (isolatedGroups !== void 0) {
1270
1333
  const groupNames = new Set(groups.map((group) => group.name));
1271
- for (const [first, second] of isolatedGroups) if (!groupNames.has(first) || !groupNames.has(second)) throw new Error(`@exadev/eslint-config: "isolatedGroups" names a group not declared in "groups" (["${first}", "${second}"]). Every isolatedGroups pair must name two of this workspace's own declared groups.`);
1334
+ for (const [first, second] of isolatedGroups) {
1335
+ if (!groupNames.has(first) || !groupNames.has(second)) throw new Error(`@exadev/eslint-config: "isolatedGroups" names a group not declared in "groups" (["${first}", "${second}"]). Every isolatedGroups pair must name two of this workspace's own declared groups.`);
1336
+ if (first === second) throw new Error(`@exadev/eslint-config: "isolatedGroups" pair ["${first}", "${second}"] names the same group twice. A group cannot be isolated from itself.`);
1337
+ }
1272
1338
  }
1273
1339
  return {
1274
1340
  ...root !== void 0 && { root },
@@ -1310,15 +1376,15 @@ function findOwningGroup(relativeDir, groups) {
1310
1376
  return best;
1311
1377
  }
1312
1378
  /**
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).
1379
+ * 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. `declaredName` is the package's own genuine `package.json` "name", never a directory-derived stand-in: pnpm allows a workspace package to declare no name at all, and such a package has nothing for a nameRanks pattern to match (the README states nameRanks checks "a package's declared name", not its directory), so a nameless package skips nameRanks entirely and falls straight through to its group's rank or defaultRank, the same as a package whose name simply matched no pattern. 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).
1314
1380
  */
1315
1381
  function deriveRank(declaredName, group, options) {
1316
- if (options.nameRanks !== void 0) {
1382
+ if (declaredName !== void 0 && options.nameRanks !== void 0) {
1317
1383
  for (const rule of options.nameRanks) if (new RegExp(rule.pattern, "u").test(declaredName)) return rule.rank;
1318
1384
  }
1319
1385
  if (group.rank !== void 0) return group.rank;
1320
1386
  if (options.defaultRank !== void 0) return options.defaultRank;
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".`);
1387
+ throw new Error(`@exadev/eslint-config: no rank could be resolved for workspace package "${declaredName ?? "(no declared name)"}" (group "${group.name}"). Give the group a "rank", add a matching "nameRanks" pattern, or set "defaultRank".`);
1322
1388
  }
1323
1389
  /**
1324
1390
  * The slice value a 'segment' group derives directly from its own package's path: the Nth path segment counting from the group's own root, not from the workspace root, so a group's own internal restructure never shifts every other group's slice numbering.
@@ -1341,23 +1407,29 @@ function isRecord(value) {
1341
1407
  return typeof value === "object" && value !== null;
1342
1408
  }
1343
1409
  /**
1344
- * 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 with no usable string "name" is treated the same way regardless: nothing this graph can identify a package by.
1410
+ * 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).
1345
1411
  *
1346
1412
  * Exported so `dependencyNames`' own exact contents (never a stray extra entry) can be asserted directly: buildWorkspaceGraph's own public output filters dependencyNamesByName down to workspace-internal names only, which would silently absorb an unexpected non-package entry before any graph-level test could ever see it.
1347
1413
  */
1348
1414
  function readDeclaredManifest(fs, absoluteDir, dependencyFields) {
1349
1415
  const manifestPath = join(absoluteDir, "package.json");
1350
- const parsed = JSON.parse(fs.readFileSync(manifestPath));
1416
+ const raw = fs.readFileSync(manifestPath);
1417
+ let parsed;
1418
+ try {
1419
+ parsed = JSON.parse(raw);
1420
+ } catch (error) {
1421
+ assertIsError(error, jsonParseContext(manifestPath));
1422
+ throw new Error(`@exadev/eslint-config: could not parse "${manifestPath}" as JSON: ${error.message}`, { cause: error });
1423
+ }
1351
1424
  if (!isRecord(parsed)) return void 0;
1352
1425
  const name = parsed["name"];
1353
- if (typeof name !== "string") return void 0;
1354
1426
  const dependencyNames = [];
1355
1427
  for (const field of dependencyFields) {
1356
1428
  const value = parsed[field];
1357
1429
  if (isRecord(value)) dependencyNames.push(...Object.keys(value));
1358
1430
  }
1359
1431
  return {
1360
- name,
1432
+ name: typeof name === "string" ? name : void 0,
1361
1433
  dependencyNames
1362
1434
  };
1363
1435
  }
@@ -1374,6 +1446,16 @@ function resolveWorkspaceRoot(fs, filename, rootOption) {
1374
1446
  dir = parent;
1375
1447
  }
1376
1448
  }
1449
+ /**
1450
+ * The workspace-root-relative, forward-slash-joined directory the manifest at `filename` (context.filename, the file ESLint is currently linting) sits in: computed directly from the filesystem, never trusted from a declared "name" field. Every workspace-architecture rule uses this to confirm the manifest it is linting is genuinely the SAME package.json buildWorkspaceGraph resolved for whatever graph entry it is about to check, since a name is only ever unique within the graph's own build (buildWorkspaceGraph's own duplicate-name throw enforces that), never across a stale or duplicated copy of a manifest sitting elsewhere in the tree outside the workspace's own package globs (a build output directory that copies its source package's package.json verbatim, say). A rule that skipped this check would check that copy under the real package's own graph entry: reporting edges the real package never declared, or letting a single real violation double-report once per copy.
1451
+ *
1452
+ * Both `root` and `filename`'s own directory are resolved through `fs.realpathSync` before comparing: an explicit `root` option and ESLint's own `context.filename` can spell the identical real directory two different ways (a symlink anywhere on either path, a macOS /tmp vs /private/tmp cwd being the recurring real case), and a purely lexical `relative()` between the two spellings would compute a path nowhere near the graph entry's own relativeDir, silently making every workspace-architecture rule report nothing for a perfectly real, correctly-configured package. Comparing realpaths instead makes the two spellings agree while still catching a genuinely different directory (a stale or duplicated manifest copy) as genuinely different.
1453
+ */
1454
+ function manifestRelativeDir(fs, root, filename) {
1455
+ const realRoot = fs.realpathSync(resolve(root));
1456
+ const realManifestDir = fs.realpathSync(dirname(resolve(filename)));
1457
+ return relative(realRoot, realManifestDir).split(sep).join("/");
1458
+ }
1377
1459
  function readPackagesFromYaml(fs, root) {
1378
1460
  const yamlPath = join(root, "pnpm-workspace.yaml");
1379
1461
  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.`);
@@ -1395,10 +1477,12 @@ function collectCandidates(fs, root, options) {
1395
1477
  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.`);
1396
1478
  const manifest = readDeclaredManifest(fs, join(root, relativeDir), resolveDependencyFields(options));
1397
1479
  if (manifest === void 0) continue;
1480
+ const declaredName = manifest.name ?? relativeDir;
1398
1481
  candidates.push({
1399
1482
  relativeDir,
1400
1483
  group,
1401
- declaredName: manifest.name,
1484
+ declaredName,
1485
+ name: manifest.name,
1402
1486
  dependencyNames: manifest.dependencyNames
1403
1487
  });
1404
1488
  }
@@ -1417,7 +1501,8 @@ function collectKnownSlices(candidates) {
1417
1501
  function deriveSlice(candidate, knownSlices) {
1418
1502
  const { slice } = candidate.group;
1419
1503
  if (slice === void 0) return void 0;
1420
- return "segment" in slice ? sliceBySegment(candidate.relativeDir, candidate.group, slice.segment) : sliceByNamePrefix(candidate.declaredName, knownSlices);
1504
+ if ("segment" in slice) return sliceBySegment(candidate.relativeDir, candidate.group, slice.segment);
1505
+ return candidate.name === void 0 ? void 0 : sliceByNamePrefix(candidate.name, knownSlices);
1421
1506
  }
1422
1507
  /**
1423
1508
  * Builds the whole-workspace graph from scratch: every declared package's group/rank/slice, and every workspace-internal dependency edge. Scans the real tree once per call; getWorkspaceGraph below is the memoized entry point every rule actually calls.
@@ -1430,7 +1515,7 @@ function buildWorkspaceGraph(fs, root, options) {
1430
1515
  for (const candidate of candidates) {
1431
1516
  const existing = packagesByName.get(candidate.declaredName);
1432
1517
  if (existing !== void 0) throw new Error(`@exadev/eslint-config: two workspace packages both declare the name "${candidate.declaredName}" ("${existing.relativeDir}" and "${candidate.relativeDir}").`);
1433
- const rank = deriveRank(candidate.declaredName, candidate.group, options);
1518
+ const rank = deriveRank(candidate.name, candidate.group, options);
1434
1519
  const slice = deriveSlice(candidate, knownSlices);
1435
1520
  packagesByName.set(candidate.declaredName, {
1436
1521
  name: candidate.declaredName,
@@ -1566,12 +1651,12 @@ function last(array) {
1566
1651
  * 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.
1567
1652
  *
1568
1653
  * - **'drop-group'**: drop the group's own root path segments, keep what remains.
1569
- * - **'keep-group'**: keep every segment of `relativeDir` as-is, including the group's own root segments (a test group whose packages are named "test-<feature>", mirroring the feature they test, needs its own "test" segment kept).
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.
1570
1655
  * - **'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.
1571
1656
  */
1572
1657
  const NAME_SEGMENTS_BY_STRATEGY = {
1573
1658
  basename: ({ segments }) => [last(segments)],
1574
- "keep-group": ({ segments }) => segments,
1659
+ "keep-group": ({ rest, groupNameSegment }) => [groupNameSegment, ...rest],
1575
1660
  "drop-group": ({ rest }) => rest
1576
1661
  };
1577
1662
  /**
@@ -1580,11 +1665,13 @@ const NAME_SEGMENTS_BY_STRATEGY = {
1580
1665
  function expectedPackageName(relativeDir, group, naming) {
1581
1666
  const separator = naming.separator ?? "-";
1582
1667
  const segments = splitPathSegments(relativeDir);
1583
- const groupPrefixLength = splitPathSegments(group.path ?? group.name).length;
1584
- const rest = segments.slice(groupPrefixLength);
1668
+ const groupPathSegments = splitPathSegments(group.path ?? group.name);
1669
+ const rest = segments.slice(groupPathSegments.length);
1670
+ const groupNameSegment = last(groupPathSegments);
1585
1671
  const joined = NAME_SEGMENTS_BY_STRATEGY[group.naming ?? "drop-group"]({
1586
1672
  segments,
1587
- rest
1673
+ rest,
1674
+ groupNameSegment
1588
1675
  }).join(separator);
1589
1676
  return naming.scope === void 0 ? joined : `${naming.scope}/${joined}`;
1590
1677
  }
@@ -1628,9 +1715,10 @@ function collectTopLevelDependencies(rootObject, dependencyFields) {
1628
1715
  /**
1629
1716
  * Reports a workspace dependency that can reach back to the declaring package: a cycle. This is NOT covered by no-uphill-dependency, and the distinction matters: that rule permits a same-rank (or, within a slice, a same-slice) dependency, so two packages at the same rank depending on each other passes it while still being a cycle. Turborepo does reject a cyclic task graph, but only once the whole graph is assembled, by which point a large refactor has already moved everything; reporting it at the manifest, where the edge is declared, names the two packages and the direction to reverse.
1630
1717
  *
1631
- * Identifies "self" by this manifest's own DECLARED name (read via readDeclaredName), not a name derived from its directory: the hive original derived `ownName` from the package's path through its own expectedPackageName, so a package whose declared name did not yet match its path (itself a naming violation, but a distinct one from a cycle) was checked under the WRONG name here, silently missing or misattributing a real cycle. Reading the manifest's own name directly is also strictly simpler: cycle detection has no need of path-derived naming at all.
1718
+ * Identifies "self" by this manifest's own DECLARED name (read via readDeclaredName) when it has one, never a name derived by re-deriving what its path OUGHT to produce: the hive original derived `ownName` from the package's path through its own expectedPackageName, so a package whose declared name did not yet match its path (itself a naming violation, but a distinct one from a cycle) was checked under the WRONG name here, silently missing or misattributing a real cycle. A manifest with no declared name at all (pnpm allows this) falls back to its own directory instead, the same key buildWorkspaceGraph gives such a package in the graph (workspace-graph.ts), so its outgoing edges are still checked rather than silently dropped. Either way, the manifest actually being linted must also be confirmed as the graph entry's own file (see manifestRelativeDir below), not a stale or duplicated copy elsewhere in the tree sharing the same declared name.
1632
1719
  */
1633
- function createNoDependencyCycleRule(loadGraph = loadWorkspaceGraph) {
1720
+ function createNoDependencyCycleRule(deps = {}) {
1721
+ const { loadGraph = loadWorkspaceGraph, fs = realWorkspaceFs } = deps;
1634
1722
  return {
1635
1723
  meta: {
1636
1724
  type: "problem",
@@ -1648,18 +1736,20 @@ function createNoDependencyCycleRule(loadGraph = loadWorkspaceGraph) {
1648
1736
  const graph = loadGraph(context.filename, options);
1649
1737
  return { Object(node, parent) {
1650
1738
  if (parent?.type !== "Document") return;
1739
+ const relativeDir = manifestRelativeDir(fs, graph.root, context.filename);
1651
1740
  const declared = readDeclaredName(node);
1652
- if (declared === void 0) return;
1653
- if (!graph.packagesByName.has(declared.name)) return;
1741
+ const self = graph.packagesByName.get(declared?.name ?? relativeDir);
1742
+ if (self === void 0) return;
1743
+ if (self.relativeDir !== relativeDir) return;
1654
1744
  const dependencies = collectTopLevelDependencies(node, resolveDependencyFields(options));
1655
1745
  for (const dependency of dependencies) {
1656
1746
  if (!graph.packagesByName.has(dependency.name)) continue;
1657
- if (!dependencyPathExists(dependency.name, declared.name, graph.dependencyNamesByName)) continue;
1747
+ if (!dependencyPathExists(dependency.name, self.name, graph.dependencyNamesByName)) continue;
1658
1748
  context.report({
1659
1749
  loc: dependency.node.loc,
1660
1750
  messageId: "cycle",
1661
1751
  data: {
1662
- from: declared.name,
1752
+ from: self.name,
1663
1753
  to: dependency.name
1664
1754
  }
1665
1755
  });
@@ -2342,7 +2432,8 @@ function findDependencyEntry(dependencies, name) {
2342
2432
  /**
2343
2433
  * Enforces the configured workspace's own rank, rank-skip, slice and group-isolation boundaries on every `package.json`'s declared dependencies (see workspace-checks.ts's checkDependencies for the exact rules, and the package README's "Workspace architecture" section for the option shape). A factory, not a plain object, so a test can inject a stubbed LoadWorkspaceGraphFn returning a fabricated graph directly, exercising this rule's own reporting logic (which violation, which message, which location) with zero real filesystem I/O, the same "injectable in tests, defaulted in production" shape createBarrelPolicyRule already establishes.
2344
2434
  */
2345
- function createNoUphillDependencyRule(loadGraph = loadWorkspaceGraph) {
2435
+ function createNoUphillDependencyRule(deps = {}) {
2436
+ const { loadGraph = loadWorkspaceGraph, fs = realWorkspaceFs } = deps;
2346
2437
  return {
2347
2438
  meta: {
2348
2439
  type: "problem",
@@ -2365,13 +2456,14 @@ function createNoUphillDependencyRule(loadGraph = loadWorkspaceGraph) {
2365
2456
  const graph = loadGraph(context.filename, options);
2366
2457
  return { Object(node, parent) {
2367
2458
  if (parent?.type !== "Document") return;
2459
+ const relativeDir = manifestRelativeDir(fs, graph.root, context.filename);
2368
2460
  const declared = readDeclaredName(node);
2369
- if (declared === void 0) return;
2370
- const self = graph.packagesByName.get(declared.name);
2461
+ const self = graph.packagesByName.get(declared?.name ?? relativeDir);
2371
2462
  if (self === void 0) return;
2463
+ if (self.relativeDir !== relativeDir) return;
2372
2464
  const dependencies = collectTopLevelDependencies(node, resolveDependencyFields(options));
2373
2465
  const dependencyNames = [...new Set(dependencies.map((dependency) => dependency.name))];
2374
- const violations = checkDependencies(declared.name, self, dependencyNames, {
2466
+ const violations = checkDependencies(self.name, self, dependencyNames, {
2375
2467
  graph: graph.packagesByName,
2376
2468
  ...options.rankSkip !== void 0 && { rankSkip: options.rankSkip },
2377
2469
  ...options.isolatedGroups !== void 0 && { isolatedGroups: options.isolatedGroups }
@@ -2603,9 +2695,10 @@ function findGroupSpec(groups, groupName) {
2603
2695
  /**
2604
2696
  * 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.
2605
2697
  *
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.
2698
+ * Self-identified by its DECLARED name (the same self-identification every other workspace-architecture rule uses), not derived from `context.filename`'s own directory the way the hive original was: this 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. `context.filename`'s own directory is still consulted, but only afterwards, as a confirmation: it must equal the resolved graph entry's own relativeDir, or the manifest being linted is a stale or duplicated copy of a real package's package.json sitting somewhere else in the tree (a build output directory that copied it verbatim, say), not the genuine article this graph entry was built from.
2607
2699
  */
2608
- function createPackageNameMirrorsPathRule(loadGraph = loadWorkspaceGraph) {
2700
+ function createPackageNameMirrorsPathRule(deps = {}) {
2701
+ const { loadGraph = loadWorkspaceGraph, fs = realWorkspaceFs } = deps;
2609
2702
  return {
2610
2703
  meta: {
2611
2704
  type: "problem",
@@ -2616,7 +2709,10 @@ function createPackageNameMirrorsPathRule(loadGraph = loadWorkspaceGraph) {
2616
2709
  description: "Require a workspace package to declare the name its path derives, under the configured naming scope/separator.",
2617
2710
  url: "https://github.com/ExaDev/eslint-config/blob/main/src/rules/package-name-mirrors-path.ts"
2618
2711
  },
2619
- messages: { mismatch: "Package at \"{{dir}}\" declares \"{{actual}}\" but its path derives \"{{expected}}\"." }
2712
+ messages: {
2713
+ mismatch: "Package at \"{{dir}}\" declares \"{{actual}}\" but its path derives \"{{expected}}\".",
2714
+ missingName: "Package at \"{{dir}}\" declares no name at all, but its path derives \"{{expected}}\"."
2715
+ }
2620
2716
  },
2621
2717
  create(context) {
2622
2718
  const options = readWorkspaceArchitectureOptions(context.options[0]);
@@ -2625,12 +2721,24 @@ function createPackageNameMirrorsPathRule(loadGraph = loadWorkspaceGraph) {
2625
2721
  const graph = loadGraph(context.filename, options);
2626
2722
  return { Object(node, parent) {
2627
2723
  if (parent?.type !== "Document") return;
2724
+ const relativeDir = manifestRelativeDir(fs, graph.root, context.filename);
2628
2725
  const declared = readDeclaredName(node);
2629
- if (declared === void 0) return;
2630
- const self = graph.packagesByName.get(declared.name);
2726
+ const self = graph.packagesByName.get(declared?.name ?? relativeDir);
2631
2727
  if (self === void 0) return;
2728
+ if (self.relativeDir !== relativeDir) return;
2632
2729
  const group = findGroupSpec(options.groups, self.group);
2633
2730
  const expected = expectedPackageName(self.relativeDir, group, naming);
2731
+ if (declared === void 0) {
2732
+ context.report({
2733
+ loc: node.loc,
2734
+ messageId: "missingName",
2735
+ data: {
2736
+ dir: self.relativeDir,
2737
+ expected
2738
+ }
2739
+ });
2740
+ return;
2741
+ }
2634
2742
  if (expected === declared.name) return;
2635
2743
  context.report({
2636
2744
  loc: declared.node.loc,
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.1",
4
+ "version": "2.22.0",
5
5
  "dependencies": {
6
6
  "@eslint/js": "^10.0.1",
7
7
  "@typescript-eslint/utils": "8.67.0",