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 +4 -4
- package/dist/index.cjs +161 -53
- package/dist/index.js +163 -55
- package/package.json +1 -1
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.
|
|
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
|
-
|
|
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
|
|
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))
|
|
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
|
|
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: "
|
|
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: "
|
|
1169
|
+
rank: { type: "integer" }
|
|
1123
1170
|
},
|
|
1124
1171
|
required: ["pattern", "rank"],
|
|
1125
1172
|
additionalProperties: false
|
|
1126
1173
|
}
|
|
1127
1174
|
},
|
|
1128
|
-
defaultRank: { type: "
|
|
1175
|
+
defaultRank: { type: "integer" },
|
|
1129
1176
|
rankSkip: {
|
|
1130
1177
|
type: "object",
|
|
1131
1178
|
properties: {
|
|
1132
|
-
maxDistance: {
|
|
1179
|
+
maxDistance: {
|
|
1180
|
+
type: "integer",
|
|
1181
|
+
minimum: 0
|
|
1182
|
+
},
|
|
1133
1183
|
exemptRanks: {
|
|
1134
1184
|
type: "array",
|
|
1135
|
-
items: { type: "
|
|
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
|
|
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 (
|
|
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 &&
|
|
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" &&
|
|
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 (
|
|
1320
|
+
if (!isNonNegativeInteger(value["maxDistance"])) return false;
|
|
1258
1321
|
const { exemptRanks } = value;
|
|
1259
|
-
return Array.isArray(exemptRanks) && exemptRanks.every(
|
|
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 =
|
|
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)
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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`
|
|
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": ({
|
|
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
|
|
1618
|
-
const rest = segments.slice(
|
|
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),
|
|
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(
|
|
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
|
-
|
|
1687
|
-
if (
|
|
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,
|
|
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:
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
*
|
|
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(
|
|
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: {
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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))
|
|
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
|
|
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: "
|
|
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: "
|
|
1135
|
+
rank: { type: "integer" }
|
|
1089
1136
|
},
|
|
1090
1137
|
required: ["pattern", "rank"],
|
|
1091
1138
|
additionalProperties: false
|
|
1092
1139
|
}
|
|
1093
1140
|
},
|
|
1094
|
-
defaultRank: { type: "
|
|
1141
|
+
defaultRank: { type: "integer" },
|
|
1095
1142
|
rankSkip: {
|
|
1096
1143
|
type: "object",
|
|
1097
1144
|
properties: {
|
|
1098
|
-
maxDistance: {
|
|
1145
|
+
maxDistance: {
|
|
1146
|
+
type: "integer",
|
|
1147
|
+
minimum: 0
|
|
1148
|
+
},
|
|
1099
1149
|
exemptRanks: {
|
|
1100
1150
|
type: "array",
|
|
1101
|
-
items: { type: "
|
|
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
|
|
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 (
|
|
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 &&
|
|
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" &&
|
|
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 (
|
|
1286
|
+
if (!isNonNegativeInteger(value["maxDistance"])) return false;
|
|
1224
1287
|
const { exemptRanks } = value;
|
|
1225
|
-
return Array.isArray(exemptRanks) && exemptRanks.every(
|
|
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 =
|
|
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)
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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`
|
|
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": ({
|
|
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
|
|
1584
|
-
const rest = segments.slice(
|
|
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),
|
|
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(
|
|
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
|
-
|
|
1653
|
-
if (
|
|
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,
|
|
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:
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
*
|
|
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(
|
|
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: {
|
|
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
|
-
|
|
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