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 +2 -1
- package/dist/index.cjs +181 -28
- package/dist/index.js +181 -28
- package/package.json +1 -1
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
|
|
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.
|
|
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
|
-
|
|
844
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
910
|
-
|
|
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: {
|
|
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 (
|
|
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
|
-
|
|
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": (
|
|
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"](
|
|
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
|
|
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.
|
|
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
|
-
|
|
810
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
876
|
-
|
|
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: {
|
|
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 (
|
|
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
|
-
|
|
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": (
|
|
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"](
|
|
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
|
|
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