exadev-eslint-config 2.14.0 → 2.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -162,11 +162,29 @@ Trailing arguments are arbitrary flat-config objects, appended in order after ev
162
162
 
163
163
  ## RFC 8785 canonical JSON formatting
164
164
 
165
- Every JSON file (`**/*.json`, excluding `**/tsconfig*.json`, `**/turbo.json`, and `**/package.json`) is linted against [`eslint-plugin-json-canonical`](https://github.com/ExaDev/eslint-plugin-json-canonical)'s `configs.recommended` -- plain UTF-16 code-unit key ordering, canonical number formatting, and canonical string escaping, per [RFC 8785](https://www.rfc-editor.org/rfc/rfc8785). This is bundled unconditionally, the same way jsdoc/tsdoc support is: `eslint-plugin-json-canonical` is a plain dependency of this package, so every consumer already has it. There is no option to turn it off.
165
+ Every JSON file is linted against [`eslint-plugin-json-canonical`](https://github.com/ExaDev/eslint-plugin-json-canonical) v2 -- plain UTF-16 code-unit key ordering, canonical number formatting, canonical string escaping, and (as of that plugin's own v2) pretty-printed layout (2-space indentation, one member/element per line, a trailing newline), per [RFC 8785](https://www.rfc-editor.org/rfc/rfc8785). This is bundled unconditionally, the same way jsdoc/tsdoc support is: `eslint-plugin-json-canonical` is a plain dependency of this package, so every consumer already has it. There is no option to turn it off.
166
166
 
167
- The plugin's own whitespace-collapsing rule (`no-insignificant-whitespace`, which reformats a document to a single compacted line) is deliberately not part of `configs.recommended` and is not enabled here either -- it stays available for a consumer to opt into directly for their own genuine canonicalization pass.
167
+ The plugin's own full-canonicalization rule (`no-insignificant-whitespace`, which collapses a document to a single compacted line with no whitespace at all) is deliberately not part of the config this package extends -- it stays available for a consumer to opt into directly for their own genuine canonicalization pass (hashing, signing, byte-for-byte comparison).
168
168
 
169
- `**/tsconfig*.json` and `**/turbo.json` are excluded because they genuinely carry comments (TypeScript and turbo both accept them), which `@eslint/json`'s `json/json` language -- what this plugin's recommended config hard-codes -- has no concept of and fails to parse. `**/package.json` is excluded because its key order is a distinct, separately-optional concern (syncpack-style field-priority pinning, not RFC 8785's plain alphabetical order); a project wanting that instead reaches for `eslint-plugin-json-canonical` directly on its own terms, not through this package.
169
+ `**/*.jsonc`, `**/tsconfig*.json`, and `**/turbo.json` get the plugin's `configs.contentOnlyJsonc` instead of the plain-JSON config: they genuinely carry comments (TypeScript and turbo both accept them), which `@eslint/json`'s `json/json` language has no concept of and fails to parse, and neither pretty-printing nor whitespace-collapsing has a well-defined answer for a comment's own attachment to a member once its surrounding whitespace is rewritten. Content canonicalization (key order, number/string formatting) still applies to these files under `json/jsonc`.
170
+
171
+ `**/package.json` gets everything the plain-JSON config gives every other file -- including pretty-printed layout -- except `json/sort-keys`, turned back off in its own override block, since its key order is the separate, syncpack-aware concern the next section covers.
172
+
173
+ ## Optional package.json key ordering
174
+
175
+ `exadevConfig({ packageJsonKeyOrder: true })` enables `exadev/package-json-key-order` for `**/package.json`, requiring the same key order [`syncpack format`](https://syncpack.dev/command/format) would produce -- `sortFirst` fields (`name`, `description`, `version`, `author` by default) pinned to the top in that exact order, then every other top-level key alphabetically; and, inside each `sortAz`-listed field's own object or array value (`dependencies`, `devDependencies`, `scripts`, `keywords`, and the rest of syncpack's own default list), its members/elements sorted the same way. Confirmed directly against real `syncpack@15` output, not assumed from its docs -- see this rule's own source comment for the exact reverse-engineering method (a symbol-before-digit-before-letter, case-insensitive comparison syncpack's docs don't specify precisely enough to derive from prose alone).
176
+
177
+ This exists for a project that wants real `package.json` canonicalization without installing syncpack, and so a project that already has syncpack never sees the two fight: `eslint --fix` and `syncpack format` converge on the identical output.
178
+
179
+ | Value | `options.packageJsonKeyOrder` |
180
+ | --- | --- |
181
+ | `true` | Force on -- throws if `@eslint/json` isn't resolvable |
182
+ | `false` | Force off -- always `[]`, no resolution attempted |
183
+ | `undefined` / omitted | Auto-detect (the default): on unless the project already has a syncpack config (a `.syncpackrc*`/`syncpack.config.*` file, or a `"syncpack"` key in its own `package.json`), since syncpack already produces this exact order for free |
184
+
185
+ Like React/Next.js support, this needs its own optional peer resolvable -- `pnpm add -D @eslint/json` -- and, unlike them, also needs its `json/json` language registered for the file (this option's own config block does that for you; nothing extra to wire up).
186
+
187
+ Bundled into `exadevConfig()`'s default output the same way React/Next.js auto-detection is (see the table above) -- `packageJsonKeyOrder: true`/`false` only forces the tri-state explicitly, it isn't the only way to reach it. Not part of `plugin.configs.recommended`, and not available as a `plugin.configs.packageJsonKeyOrder` explicit-tier config the way `.react`/`.nextjs` are, since wiring it through `plugin.configs` would need `plugin.ts` and this option's own config builder to import each other.
170
188
 
171
189
  ## Rules
172
190
 
@@ -190,6 +208,7 @@ The plugin's own whitespace-collapsing rule (`no-insignificant-whitespace`, whic
190
208
  | `no-enum-number-widening` | | A bare (non-literal) `number` is accepted anywhere a numeric enum is expected, without checking it is actually one of the enum's members -- only a numeric *literal* gets range-checked by `tsc`. No autofix: the only provably safe fix is a genuine runtime membership check against the enum's own values, which is a behavioural choice a mechanical fix cannot responsibly make. Requires type information -- only in the default (type-checked) export, not `plugin.configs.recommended`. |
191
209
  | `no-enum-reverse-lookup-widening` | suggestion | Indexing a numeric enum's reverse mapping (`Direction[n]`) with a bare (non-literal) `number`, or with a different enum's member, types as plain `string` for any index, including one outside the enum's actual members, where it genuinely returns `undefined` at runtime -- `tsc` does not range-check even a numeric literal index here. When the indexed expression is the init of a variable with an explicit `: string` annotation, a suggestion widens it to `: string \| undefined`, forcing later uses as a bare `string` to surface as real compile errors; every other syntactic position gets a plain report with no fix, and no case gets a full `--fix` autofix. Requires type information -- only in the default (type-checked) export, not `plugin.configs.recommended`. |
192
210
  | `prefer-numeric-sort-compare` | suggestion | A deliberately narrow addition alongside `@typescript-eslint/require-array-sort-compare` (which already flags any bare `.sort()`/`.toSorted()` except on a plain string array, with no fix): when the array's element type is definitively `number`, a suggestion offers an ascending compare function (`(a, b) => a - b`), since the default comparator sorts lexicographically (`[1, 2, 10].sort()` becomes `[1, 10, 2]`). Not a full autofix -- descending order is a real, if less common, alternative intent. Requires type information -- only in the default (type-checked) export, not `plugin.configs.recommended`, since it needs the checker to confirm the array's element type. |
211
+ | `package-json-key-order` | ✓ | Requires `package.json`'s keys to be ordered the same way `syncpack format` would order them. See [Optional package.json key ordering](#optional-package-json-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. |
193
212
 
194
213
  ## Barrel policy
195
214
 
package/dist/index.cjs CHANGED
@@ -31,15 +31,16 @@ eslint_plugin_tsdoc = __toESM(eslint_plugin_tsdoc, 1);
31
31
  let eslint_plugin_json_canonical = require("eslint-plugin-json-canonical");
32
32
  eslint_plugin_json_canonical = __toESM(eslint_plugin_json_canonical, 1);
33
33
  let node_module = require("node:module");
34
- let _eslint_js = require("@eslint/js");
35
- _eslint_js = __toESM(_eslint_js, 1);
36
- let typescript_eslint = require("typescript-eslint");
37
- typescript_eslint = __toESM(typescript_eslint, 1);
34
+ let node_fs = require("node:fs");
38
35
  let node_path = require("node:path");
39
36
  let _typescript_eslint_utils = require("@typescript-eslint/utils");
40
37
  let typescript = require("typescript");
41
38
  typescript = __toESM(typescript, 1);
42
39
  let ts_api_utils = require("ts-api-utils");
40
+ let _eslint_js = require("@eslint/js");
41
+ _eslint_js = __toESM(_eslint_js, 1);
42
+ let typescript_eslint = require("typescript-eslint");
43
+ typescript_eslint = __toESM(typescript_eslint, 1);
43
44
  //#region src/jsdoc.ts
44
45
  const JS_TS_FILE_PATTERNS$1 = "**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}";
45
46
  const jsdocConfig = eslint_plugin_jsdoc.default.configs["flat/recommended-tsdoc-error"];
@@ -80,17 +81,36 @@ const jsdocAndTsdoc = [{
80
81
  function isSingleFlatConfig(value) {
81
82
  return typeof value === "object" && value !== null && !Array.isArray(value) && "language" in value;
82
83
  }
83
- const recommended = eslint_plugin_json_canonical.default.configs?.["recommended"];
84
- if (!isSingleFlatConfig(recommended)) throw new Error("eslint-plugin-json-canonical: expected configs.recommended to be a single flat config object, got an array or undefined");
85
- const jsonCanonicalConfig = [{
86
- files: ["**/*.json"],
87
- ignores: [
88
- "**/tsconfig*.json",
89
- "**/turbo.json",
90
- "**/package.json"
91
- ],
92
- ...recommended
93
- }];
84
+ function requireConfig(name) {
85
+ const config = eslint_plugin_json_canonical.default.configs?.[name];
86
+ if (!isSingleFlatConfig(config)) throw new Error(`eslint-plugin-json-canonical: expected configs.${name} to be a single flat config object, got an array or undefined`);
87
+ return config;
88
+ }
89
+ const recommended = requireConfig("recommended");
90
+ const contentOnlyJsonc = requireConfig("contentOnlyJsonc");
91
+ const jsonCanonicalConfig = [
92
+ {
93
+ files: ["**/*.json"],
94
+ ignores: [
95
+ "**/*.jsonc",
96
+ "**/tsconfig*.json",
97
+ "**/turbo.json"
98
+ ],
99
+ ...recommended
100
+ },
101
+ {
102
+ files: [
103
+ "**/*.jsonc",
104
+ "**/tsconfig*.json",
105
+ "**/turbo.json"
106
+ ],
107
+ ...contentOnlyJsonc
108
+ },
109
+ {
110
+ files: ["**/package.json"],
111
+ rules: { "json/sort-keys": "off" }
112
+ }
113
+ ];
94
114
  //#endregion
95
115
  //#region src/optional-plugin.ts
96
116
  const nodeRequire = (0, node_module.createRequire)(require("url").pathToFileURL(__filename).href);
@@ -101,16 +121,16 @@ function tryRequire(specifier, requireFn = nodeRequire) {
101
121
  return;
102
122
  }
103
123
  }
104
- function isRecord(value) {
124
+ function isRecord$1(value) {
105
125
  return typeof value === "object" && value !== null;
106
126
  }
107
127
  function isFlatConfig$1(value) {
108
- return isRecord(value);
128
+ return isRecord$1(value);
109
129
  }
110
130
  function normalizeLegacyParserOptions(record) {
111
131
  if (!("parserOptions" in record)) return record;
112
132
  const { parserOptions, languageOptions, ...rest } = record;
113
- const existingLanguageOptions = isRecord(languageOptions) ? languageOptions : {};
133
+ const existingLanguageOptions = isRecord$1(languageOptions) ? languageOptions : {};
114
134
  return {
115
135
  ...rest,
116
136
  languageOptions: {
@@ -122,10 +142,10 @@ function normalizeLegacyParserOptions(record) {
122
142
  function readFlatConfig(module, path) {
123
143
  let current = module;
124
144
  for (const key of path) {
125
- if (!isRecord(current)) return void 0;
145
+ if (!isRecord$1(current)) return void 0;
126
146
  current = current[key];
127
147
  }
128
- if (!isRecord(current)) return void 0;
148
+ if (!isRecord$1(current)) return void 0;
129
149
  const normalized = normalizeLegacyParserOptions(current);
130
150
  return isFlatConfig$1(normalized) ? normalized : void 0;
131
151
  }
@@ -139,6 +159,9 @@ function buildNextjsConfig(options = {}) {
139
159
  return nextConfig === void 0 ? [] : [nextConfig];
140
160
  }
141
161
  //#endregion
162
+ //#region package.json
163
+ var version = "2.16.0";
164
+ //#endregion
142
165
  //#region src/react.ts
143
166
  const JSX_FILE_PATTERNS = ["**/*.jsx", "**/*.tsx"];
144
167
  const INSTALL_COMMAND = "pnpm add -D eslint-plugin-react eslint-plugin-react-hooks eslint-plugin-jsx-a11y";
@@ -176,9 +199,6 @@ function buildReactConfig(options = {}) {
176
199
  }));
177
200
  }
178
201
  //#endregion
179
- //#region package.json
180
- var version = "2.14.0";
181
- //#endregion
182
202
  //#region src/rules/barrel-helpers.ts
183
203
  const INDEX_BASENAME$1 = /^index\.[cm]?[tj]sx?$/;
184
204
  function basenameOf(filename) {
@@ -1234,6 +1254,215 @@ const noSideEffectsInIndex = {
1234
1254
  }
1235
1255
  };
1236
1256
  //#endregion
1257
+ //#region src/rules/package-json-key-order.ts
1258
+ const DEFAULT_SORT_FIRST = [
1259
+ "name",
1260
+ "description",
1261
+ "version",
1262
+ "author"
1263
+ ];
1264
+ const DEFAULT_SORT_AZ = [
1265
+ "bin",
1266
+ "contributors",
1267
+ "dependencies",
1268
+ "devDependencies",
1269
+ "keywords",
1270
+ "peerDependencies",
1271
+ "resolutions",
1272
+ "scripts"
1273
+ ];
1274
+ function categorize(char) {
1275
+ if (char >= "0" && char <= "9") return 1;
1276
+ if (/[a-z]/iu.test(char)) return 2;
1277
+ return 0;
1278
+ }
1279
+ /** Case-insensitive, symbol-before-digit-before-letter comparison matching real `syncpack format` output. Returns a negative number if `a` sorts before `b`, positive if after, zero if equal. */
1280
+ function compareSyncpackKey(a, b) {
1281
+ const length = Math.min(a.length, b.length);
1282
+ for (let index = 0; index < length; index += 1) {
1283
+ const charA = a[index] ?? "";
1284
+ const charB = b[index] ?? "";
1285
+ const categoryA = categorize(charA);
1286
+ const categoryB = categorize(charB);
1287
+ if (categoryA !== categoryB) return categoryA - categoryB;
1288
+ const foldedA = categoryA === 2 ? charA.toLowerCase() : charA;
1289
+ const foldedB = categoryB === 2 ? charB.toLowerCase() : charB;
1290
+ if (foldedA !== foldedB) return foldedA < foldedB ? -1 : 1;
1291
+ }
1292
+ return a.length - b.length;
1293
+ }
1294
+ /**
1295
+ * The pure decision for an adjacent pair of top-level keys, independent of ESLint/momoa: given syncpack's own `sortFirst` list, is `curr` allowed to immediately follow `prev`? A pinned key (present in `sortFirst`) must appear in exactly `sortFirst`'s own order; every other key sorts after every pinned key, and among themselves by `compareSyncpackKey`. Kept separate from `create()` below so it can be unit-tested directly, matching the same pattern `no-uphill-dependency`'s `checkDependencies` in the monorepo-template uses.
1296
+ */
1297
+ function isValidTopLevelOrder(prev, curr, sortFirst) {
1298
+ const prevIndex = sortFirst.indexOf(prev);
1299
+ const currIndex = sortFirst.indexOf(curr);
1300
+ if (prevIndex !== -1 && currIndex !== -1) return prevIndex < currIndex;
1301
+ if (prevIndex !== -1) return true;
1302
+ if (currIndex !== -1) return false;
1303
+ return compareSyncpackKey(prev, curr) <= 0;
1304
+ }
1305
+ /** The pure decision for an adjacent pair of keys/elements inside a `sortAz`-listed field's own object or array value: plain `compareSyncpackKey` order, no pinning. */
1306
+ function isValidSortAzOrder(prev, curr) {
1307
+ return compareSyncpackKey(prev, curr) <= 0;
1308
+ }
1309
+ /**
1310
+ * The target permutation for a whole container's own keys, given a comparator over adjacent pairs (either `isValidTopLevelOrder`-shaped or `isValidSortAzOrder`-shaped). Returns, for each output position, the ORIGINAL index of the key that belongs there -- e.g. `[2, 0, 1]` means the key currently at index 2 comes first. A stable sort (ties keep their original relative order), matching real syncpack output for keys that compare equal (see `compareSyncpackKey`'s own case-insensitive-letter test case).
1311
+ *
1312
+ * Reordering by computing this full permutation once, rather than reporting and fixing one out-of-order adjacent pair at a time (the technique `@eslint/json`'s own `sort-keys` rule uses), is deliberate: an adjacent-swap fixer only makes one pass of bubble-sort-style progress per lint pass, and a realistically scrambled `package.json` (many top-level keys all needing to move several positions, several nested objects also needing sorting) was confirmed directly to need up to 11 fix passes to fully converge -- one more than ESLint's own `Linter#verifyAndFix` will ever run (`MAX_AUTOFIX_PASSES` is 10), meaning a single real `eslint --fix` invocation could leave such a file only partially reordered. Computing the whole target order up front and replacing every affected key's position in one combined fix converges in a single pass regardless of how scrambled the input is.
1313
+ */
1314
+ function computeOrderPermutation(keys, isValidOrder) {
1315
+ return keys.map((_, index) => index).map((index) => ({
1316
+ key: keys[index] ?? "",
1317
+ index
1318
+ })).sort((a, b) => {
1319
+ if (isValidOrder(a.key, b.key) && isValidOrder(b.key, a.key)) return a.index - b.index;
1320
+ return isValidOrder(a.key, b.key) ? -1 : 1;
1321
+ }).map((entry) => entry.index);
1322
+ }
1323
+ function isIdentityPermutation(permutation) {
1324
+ return permutation.every((originalIndex, position) => originalIndex === position);
1325
+ }
1326
+ function isStringNode(node) {
1327
+ return node.type === "String";
1328
+ }
1329
+ function getMemberKeyName(member) {
1330
+ return member.name.type === "String" ? member.name.value : void 0;
1331
+ }
1332
+ function hasRange(node) {
1333
+ return node.range !== void 0;
1334
+ }
1335
+ const commentTypes = /* @__PURE__ */ new Set(["LineComment", "BlockComment"]);
1336
+ /**
1337
+ * Enforces the same `package.json` key order `syncpack format` would produce: `sortFirst` fields pinned to the top in that exact order, then every other top-level key alphabetically; and, inside each `sortAz`-listed field's own object or array value, its members/elements sorted the same way (no pinning there -- `sortFirst` only ever applies at the top level).
1338
+ *
1339
+ * This exists so a project using `@exadev/eslint-config` without syncpack still gets real `package.json` canonicalization, and so a project using both never sees them fight: `eslint --fix` and `syncpack format` converge on the identical output, confirmed directly against real `syncpack@15` output rather than assumed from its docs (see `compareSyncpackKey`'s own comment). Deliberately narrower than `@eslint/json`'s own `sort-keys` rule, which only supports a single alphabetical order for every key with no field-specific pinning -- not a fit for syncpack's own two-tier scheme.
1340
+ *
1341
+ * Not part of `plugin.configs.recommended`: opt-in, the same tri-state shape as `react`/`nextjs` (see `docs.md`'s own usage example), since a project without syncpack needs to choose this deliberately, and a project with syncpack needs to confirm the two tools agree before turning it on.
1342
+ */
1343
+ const packageJsonKeyOrder = {
1344
+ meta: {
1345
+ type: "layout",
1346
+ fixable: "code",
1347
+ languages: ["json/json", "json/jsonc"],
1348
+ docs: {
1349
+ recommended: false,
1350
+ description: "Require package.json keys to be ordered the same way `syncpack format` would order them.",
1351
+ url: "https://github.com/ExaDev/eslint-config/blob/main/src/rules/package-json-key-order.ts"
1352
+ },
1353
+ schema: [{
1354
+ type: "object",
1355
+ properties: {
1356
+ sortFirst: {
1357
+ type: "array",
1358
+ items: { type: "string" }
1359
+ },
1360
+ sortAz: {
1361
+ type: "array",
1362
+ items: { type: "string" }
1363
+ }
1364
+ },
1365
+ additionalProperties: false
1366
+ }],
1367
+ defaultOptions: [{}],
1368
+ messages: { outOfOrder: "This key order does not match the order \"syncpack format\" would produce: \"{{curr}}\" should come before \"{{prev}}\" here." }
1369
+ },
1370
+ create(context) {
1371
+ const [options] = context.options;
1372
+ const sortFirst = options.sortFirst ?? DEFAULT_SORT_FIRST;
1373
+ const sortAz = new Set(options.sortAz ?? DEFAULT_SORT_AZ);
1374
+ const { sourceCode } = context;
1375
+ function hasAdjacentComment(node) {
1376
+ const before = sourceCode.getTokenBefore(node, { includeComments: true });
1377
+ let after = sourceCode.getTokenAfter(node, { includeComments: true });
1378
+ if (after?.type === "Comma") after = sourceCode.getTokenAfter(after, { includeComments: true });
1379
+ return before !== null && commentTypes.has(before.type) || after !== null && commentTypes.has(after.type);
1380
+ }
1381
+ function buildReorderedText(nodes, permutation) {
1382
+ const separators = nodes.slice(0, -1).map((node, index) => {
1383
+ const next = nodes[index + 1];
1384
+ return hasRange(node) && next !== void 0 && hasRange(next) ? sourceCode.text.slice(node.range[1], next.range[0]) : "";
1385
+ });
1386
+ return permutation.reduce((result, originalIndex, position) => {
1387
+ const text = sourceCode.getText(nodes[originalIndex]);
1388
+ return position === 0 ? text : `${result}${separators[position - 1] ?? ""}${text}`;
1389
+ }, "");
1390
+ }
1391
+ function getNodeRanges(nodes) {
1392
+ const ranges = [];
1393
+ for (const node of nodes) {
1394
+ if (!hasRange(node)) return void 0;
1395
+ ranges.push(node.range);
1396
+ }
1397
+ return ranges;
1398
+ }
1399
+ function reportWholeContainerReorder(nodes, keys, isValidOrder, reportLoc) {
1400
+ const permutation = computeOrderPermutation(keys, isValidOrder);
1401
+ if (isIdentityPermutation(permutation)) return;
1402
+ const firstNode = nodes[0];
1403
+ const lastNode = nodes[nodes.length - 1];
1404
+ if (firstNode === void 0 || lastNode === void 0) return;
1405
+ let violationIndex = 1;
1406
+ for (let index = 1; index < keys.length; index += 1) if (!isValidOrder(keys[index - 1] ?? "", keys[index] ?? "")) {
1407
+ violationIndex = index;
1408
+ break;
1409
+ }
1410
+ context.report({
1411
+ loc: reportLoc(nodes[violationIndex] ?? firstNode),
1412
+ messageId: "outOfOrder",
1413
+ data: {
1414
+ curr: keys[violationIndex] ?? "",
1415
+ prev: keys[violationIndex - 1] ?? ""
1416
+ },
1417
+ fix(fixer) {
1418
+ const ranges = getNodeRanges(nodes);
1419
+ if (ranges === void 0 || nodes.some((node) => hasAdjacentComment(node))) return null;
1420
+ const firstRange = ranges[0];
1421
+ const lastRange = ranges[ranges.length - 1];
1422
+ if (firstRange === void 0 || lastRange === void 0) return null;
1423
+ const rewritten = buildReorderedText(nodes, permutation);
1424
+ return fixer.replaceTextRange([firstRange[0], lastRange[1]], rewritten);
1425
+ }
1426
+ });
1427
+ }
1428
+ function checkMembers(members, isValidOrder) {
1429
+ const keys = [];
1430
+ for (const member of members) {
1431
+ const key = getMemberKeyName(member);
1432
+ if (key === void 0) return;
1433
+ keys.push(key);
1434
+ }
1435
+ reportWholeContainerReorder(members, keys, isValidOrder, (member) => member.name.loc.start);
1436
+ }
1437
+ function checkElements(elements) {
1438
+ const stringValues = [];
1439
+ for (const element of elements) {
1440
+ if (!isStringNode(element.value)) return;
1441
+ stringValues.push(element.value);
1442
+ }
1443
+ reportWholeContainerReorder(stringValues, stringValues.map((value) => value.value), isValidSortAzOrder, (value) => value.loc.start);
1444
+ }
1445
+ return {
1446
+ Object(node, parent) {
1447
+ if (parent === void 0) return;
1448
+ if (parent.type === "Document") {
1449
+ checkMembers(node.members, (prev, curr) => isValidTopLevelOrder(prev, curr, sortFirst));
1450
+ return;
1451
+ }
1452
+ if (parent.type === "Member") {
1453
+ const key = getMemberKeyName(parent);
1454
+ if (key !== void 0 && sortAz.has(key)) checkMembers(node.members, isValidSortAzOrder);
1455
+ }
1456
+ },
1457
+ Array(node, parent) {
1458
+ if (parent?.type !== "Member") return;
1459
+ const key = getMemberKeyName(parent);
1460
+ if (key !== void 0 && sortAz.has(key)) checkElements(node.elements);
1461
+ }
1462
+ };
1463
+ }
1464
+ };
1465
+ //#endregion
1237
1466
  //#region src/rules/prefer-numeric-sort-compare.ts
1238
1467
  const createRule$2 = _typescript_eslint_utils.ESLintUtils.RuleCreator((name) => `https://github.com/ExaDev/eslint-config/blob/main/src/rules/${name}.ts`);
1239
1468
  const SORT_METHOD_NAMES = /* @__PURE__ */ new Set(["sort", "toSorted"]);
@@ -1456,6 +1685,7 @@ const plugin = {
1456
1685
  "no-pointless-reassignment": noPointlessReassignment,
1457
1686
  "no-set-instanceof-mutation": noSetInstanceofMutation,
1458
1687
  "no-side-effects-in-index": noSideEffectsInIndex,
1688
+ "package-json-key-order": packageJsonKeyOrder,
1459
1689
  "prefer-numeric-sort-compare": preferNumericSortCompare,
1460
1690
  "prefer-readonly-array-param": preferReadonlyArrayParam,
1461
1691
  "prefer-readonly-object-param": preferReadonlyObjectParam
@@ -1489,6 +1719,69 @@ const plugin = {
1489
1719
  }
1490
1720
  };
1491
1721
  //#endregion
1722
+ //#region src/package-json-key-order.ts
1723
+ const SYNCPACK_CONFIG_FILENAMES = [
1724
+ ".syncpackrc",
1725
+ ".syncpackrc.json",
1726
+ ".syncpackrc.yaml",
1727
+ ".syncpackrc.yml",
1728
+ ".syncpackrc.js",
1729
+ ".syncpackrc.cjs",
1730
+ ".syncpackrc.mjs",
1731
+ "syncpack.config.js",
1732
+ "syncpack.config.cjs",
1733
+ "syncpack.config.mjs",
1734
+ "syncpack.config.ts"
1735
+ ];
1736
+ function packageJsonHasSyncpackKey(cwd) {
1737
+ try {
1738
+ const raw = JSON.parse((0, node_fs.readFileSync)((0, node_path.join)(cwd, "package.json"), "utf8"));
1739
+ return typeof raw === "object" && raw !== null && "syncpack" in raw;
1740
+ } catch {
1741
+ return false;
1742
+ }
1743
+ }
1744
+ /** Auto-detection signal for the `enabled: undefined` case: does this project already have syncpack configured? Mirrors the role `tryRequire`'s peer-package resolution plays for `react.ts`/`nextjs.ts`, but for a tool detected by config presence rather than by an installed package, since syncpack's own output is what this rule is emulating, not a peer this rule depends on. */
1745
+ function hasSyncpackConfig(cwd) {
1746
+ return SYNCPACK_CONFIG_FILENAMES.some((filename) => (0, node_fs.existsSync)((0, node_path.join)(cwd, filename))) || packageJsonHasSyncpackKey(cwd);
1747
+ }
1748
+ function isRecord(value) {
1749
+ return typeof value === "object" && value !== null;
1750
+ }
1751
+ function isJsonLanguagePlugin(value) {
1752
+ if (!isRecord(value)) return false;
1753
+ const languages = value["languages"];
1754
+ return isRecord(languages) && "json" in languages;
1755
+ }
1756
+ function resolveJsonPlugin(value) {
1757
+ if (isRecord(value) && isJsonLanguagePlugin(value["default"])) return value["default"];
1758
+ return isJsonLanguagePlugin(value) ? value : void 0;
1759
+ }
1760
+ const JSON_PLUGIN_INSTALL_COMMAND = "pnpm add -D @eslint/json";
1761
+ function buildPackageJsonKeyOrderConfig(options = {}) {
1762
+ const cwd = options.cwd ?? process.cwd();
1763
+ if (options.enabled === false) return [];
1764
+ if (options.enabled === void 0 && hasSyncpackConfig(cwd)) return [];
1765
+ const jsonPlugin = resolveJsonPlugin(tryRequire("@eslint/json", options.requireFn));
1766
+ if (jsonPlugin === void 0) {
1767
+ if (options.enabled === true) throw new Error(`@exadev/eslint-config: package.json key ordering was explicitly requested but '@eslint/json' could not be resolved. Install it with: ${JSON_PLUGIN_INSTALL_COMMAND}`);
1768
+ return [];
1769
+ }
1770
+ const ruleOptions = {
1771
+ ...options.sortFirst !== void 0 && { sortFirst: options.sortFirst },
1772
+ ...options.sortAz !== void 0 && { sortAz: options.sortAz }
1773
+ };
1774
+ return [{
1775
+ files: ["**/package.json"],
1776
+ language: "json/json",
1777
+ plugins: {
1778
+ exadev: plugin,
1779
+ json: jsonPlugin
1780
+ },
1781
+ rules: { "exadev/package-json-key-order": ["error", ruleOptions] }
1782
+ }];
1783
+ }
1784
+ //#endregion
1492
1785
  //#region src/recommended-type-checked.ts
1493
1786
  const TEST_FILE_PATTERNS = "**/*.{test,spec}.{ts,tsx,mts,cts,js,jsx,mjs,cjs}";
1494
1787
  const JS_TS_FILE_PATTERNS = "**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}";
@@ -1583,6 +1876,7 @@ function exadevConfig(options = {}, ...userConfigs) {
1583
1876
  ...jsonCanonicalConfig,
1584
1877
  ...buildReactConfig({ enabled: options.react }),
1585
1878
  ...buildNextjsConfig({ enabled: options.nextjs }),
1879
+ ...buildPackageJsonKeyOrderConfig({ enabled: options.packageJsonKeyOrder }),
1586
1880
  ...userConfigs
1587
1881
  ]);
1588
1882
  }
package/dist/index.d.cts CHANGED
@@ -7,6 +7,7 @@ type PublicConfigArray = Config[];
7
7
  interface ExadevConfigOptions {
8
8
  readonly react?: boolean;
9
9
  readonly nextjs?: boolean;
10
+ readonly packageJsonKeyOrder?: boolean;
10
11
  }
11
12
  declare function exadevConfig(options?: ExadevConfigOptions, ...userConfigs: readonly TSESLint.FlatConfig.Config[]): PublicConfigArray;
12
13
  declare const defaultConfig: PublicConfigArray;
package/dist/index.d.ts CHANGED
@@ -7,6 +7,7 @@ type PublicConfigArray = Config[];
7
7
  interface ExadevConfigOptions {
8
8
  readonly react?: boolean;
9
9
  readonly nextjs?: boolean;
10
+ readonly packageJsonKeyOrder?: boolean;
10
11
  }
11
12
  declare function exadevConfig(options?: ExadevConfigOptions, ...userConfigs: readonly TSESLint.FlatConfig.Config[]): PublicConfigArray;
12
13
  declare const defaultConfig: PublicConfigArray;
package/dist/index.js CHANGED
@@ -2,12 +2,13 @@ import jsdoc from "eslint-plugin-jsdoc";
2
2
  import tsdoc from "eslint-plugin-tsdoc";
3
3
  import jsonCanonical from "eslint-plugin-json-canonical";
4
4
  import { createRequire } from "node:module";
5
- import js from "@eslint/js";
6
- import tseslint from "typescript-eslint";
7
- import { posix } from "node:path";
5
+ import { existsSync, readFileSync } from "node:fs";
6
+ import { join, posix } from "node:path";
8
7
  import { AST_NODE_TYPES, ESLintUtils, TSESLint } from "@typescript-eslint/utils";
9
8
  import * as ts from "typescript";
10
9
  import { isPropertyReadonlyInType, isTypeReference } from "ts-api-utils";
10
+ import js from "@eslint/js";
11
+ import tseslint from "typescript-eslint";
11
12
  //#region src/jsdoc.ts
12
13
  const JS_TS_FILE_PATTERNS$1 = "**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}";
13
14
  const jsdocConfig = jsdoc.configs["flat/recommended-tsdoc-error"];
@@ -48,17 +49,36 @@ const jsdocAndTsdoc = [{
48
49
  function isSingleFlatConfig(value) {
49
50
  return typeof value === "object" && value !== null && !Array.isArray(value) && "language" in value;
50
51
  }
51
- const recommended = jsonCanonical.configs?.["recommended"];
52
- if (!isSingleFlatConfig(recommended)) throw new Error("eslint-plugin-json-canonical: expected configs.recommended to be a single flat config object, got an array or undefined");
53
- const jsonCanonicalConfig = [{
54
- files: ["**/*.json"],
55
- ignores: [
56
- "**/tsconfig*.json",
57
- "**/turbo.json",
58
- "**/package.json"
59
- ],
60
- ...recommended
61
- }];
52
+ function requireConfig(name) {
53
+ const config = jsonCanonical.configs?.[name];
54
+ if (!isSingleFlatConfig(config)) throw new Error(`eslint-plugin-json-canonical: expected configs.${name} to be a single flat config object, got an array or undefined`);
55
+ return config;
56
+ }
57
+ const recommended = requireConfig("recommended");
58
+ const contentOnlyJsonc = requireConfig("contentOnlyJsonc");
59
+ const jsonCanonicalConfig = [
60
+ {
61
+ files: ["**/*.json"],
62
+ ignores: [
63
+ "**/*.jsonc",
64
+ "**/tsconfig*.json",
65
+ "**/turbo.json"
66
+ ],
67
+ ...recommended
68
+ },
69
+ {
70
+ files: [
71
+ "**/*.jsonc",
72
+ "**/tsconfig*.json",
73
+ "**/turbo.json"
74
+ ],
75
+ ...contentOnlyJsonc
76
+ },
77
+ {
78
+ files: ["**/package.json"],
79
+ rules: { "json/sort-keys": "off" }
80
+ }
81
+ ];
62
82
  //#endregion
63
83
  //#region src/optional-plugin.ts
64
84
  const nodeRequire = createRequire(import.meta.url);
@@ -69,16 +89,16 @@ function tryRequire(specifier, requireFn = nodeRequire) {
69
89
  return;
70
90
  }
71
91
  }
72
- function isRecord(value) {
92
+ function isRecord$1(value) {
73
93
  return typeof value === "object" && value !== null;
74
94
  }
75
95
  function isFlatConfig$1(value) {
76
- return isRecord(value);
96
+ return isRecord$1(value);
77
97
  }
78
98
  function normalizeLegacyParserOptions(record) {
79
99
  if (!("parserOptions" in record)) return record;
80
100
  const { parserOptions, languageOptions, ...rest } = record;
81
- const existingLanguageOptions = isRecord(languageOptions) ? languageOptions : {};
101
+ const existingLanguageOptions = isRecord$1(languageOptions) ? languageOptions : {};
82
102
  return {
83
103
  ...rest,
84
104
  languageOptions: {
@@ -90,10 +110,10 @@ function normalizeLegacyParserOptions(record) {
90
110
  function readFlatConfig(module, path) {
91
111
  let current = module;
92
112
  for (const key of path) {
93
- if (!isRecord(current)) return void 0;
113
+ if (!isRecord$1(current)) return void 0;
94
114
  current = current[key];
95
115
  }
96
- if (!isRecord(current)) return void 0;
116
+ if (!isRecord$1(current)) return void 0;
97
117
  const normalized = normalizeLegacyParserOptions(current);
98
118
  return isFlatConfig$1(normalized) ? normalized : void 0;
99
119
  }
@@ -107,6 +127,9 @@ function buildNextjsConfig(options = {}) {
107
127
  return nextConfig === void 0 ? [] : [nextConfig];
108
128
  }
109
129
  //#endregion
130
+ //#region package.json
131
+ var version = "2.16.0";
132
+ //#endregion
110
133
  //#region src/react.ts
111
134
  const JSX_FILE_PATTERNS = ["**/*.jsx", "**/*.tsx"];
112
135
  const INSTALL_COMMAND = "pnpm add -D eslint-plugin-react eslint-plugin-react-hooks eslint-plugin-jsx-a11y";
@@ -144,9 +167,6 @@ function buildReactConfig(options = {}) {
144
167
  }));
145
168
  }
146
169
  //#endregion
147
- //#region package.json
148
- var version = "2.14.0";
149
- //#endregion
150
170
  //#region src/rules/barrel-helpers.ts
151
171
  const INDEX_BASENAME$1 = /^index\.[cm]?[tj]sx?$/;
152
172
  function basenameOf(filename) {
@@ -1202,6 +1222,215 @@ const noSideEffectsInIndex = {
1202
1222
  }
1203
1223
  };
1204
1224
  //#endregion
1225
+ //#region src/rules/package-json-key-order.ts
1226
+ const DEFAULT_SORT_FIRST = [
1227
+ "name",
1228
+ "description",
1229
+ "version",
1230
+ "author"
1231
+ ];
1232
+ const DEFAULT_SORT_AZ = [
1233
+ "bin",
1234
+ "contributors",
1235
+ "dependencies",
1236
+ "devDependencies",
1237
+ "keywords",
1238
+ "peerDependencies",
1239
+ "resolutions",
1240
+ "scripts"
1241
+ ];
1242
+ function categorize(char) {
1243
+ if (char >= "0" && char <= "9") return 1;
1244
+ if (/[a-z]/iu.test(char)) return 2;
1245
+ return 0;
1246
+ }
1247
+ /** Case-insensitive, symbol-before-digit-before-letter comparison matching real `syncpack format` output. Returns a negative number if `a` sorts before `b`, positive if after, zero if equal. */
1248
+ function compareSyncpackKey(a, b) {
1249
+ const length = Math.min(a.length, b.length);
1250
+ for (let index = 0; index < length; index += 1) {
1251
+ const charA = a[index] ?? "";
1252
+ const charB = b[index] ?? "";
1253
+ const categoryA = categorize(charA);
1254
+ const categoryB = categorize(charB);
1255
+ if (categoryA !== categoryB) return categoryA - categoryB;
1256
+ const foldedA = categoryA === 2 ? charA.toLowerCase() : charA;
1257
+ const foldedB = categoryB === 2 ? charB.toLowerCase() : charB;
1258
+ if (foldedA !== foldedB) return foldedA < foldedB ? -1 : 1;
1259
+ }
1260
+ return a.length - b.length;
1261
+ }
1262
+ /**
1263
+ * The pure decision for an adjacent pair of top-level keys, independent of ESLint/momoa: given syncpack's own `sortFirst` list, is `curr` allowed to immediately follow `prev`? A pinned key (present in `sortFirst`) must appear in exactly `sortFirst`'s own order; every other key sorts after every pinned key, and among themselves by `compareSyncpackKey`. Kept separate from `create()` below so it can be unit-tested directly, matching the same pattern `no-uphill-dependency`'s `checkDependencies` in the monorepo-template uses.
1264
+ */
1265
+ function isValidTopLevelOrder(prev, curr, sortFirst) {
1266
+ const prevIndex = sortFirst.indexOf(prev);
1267
+ const currIndex = sortFirst.indexOf(curr);
1268
+ if (prevIndex !== -1 && currIndex !== -1) return prevIndex < currIndex;
1269
+ if (prevIndex !== -1) return true;
1270
+ if (currIndex !== -1) return false;
1271
+ return compareSyncpackKey(prev, curr) <= 0;
1272
+ }
1273
+ /** The pure decision for an adjacent pair of keys/elements inside a `sortAz`-listed field's own object or array value: plain `compareSyncpackKey` order, no pinning. */
1274
+ function isValidSortAzOrder(prev, curr) {
1275
+ return compareSyncpackKey(prev, curr) <= 0;
1276
+ }
1277
+ /**
1278
+ * The target permutation for a whole container's own keys, given a comparator over adjacent pairs (either `isValidTopLevelOrder`-shaped or `isValidSortAzOrder`-shaped). Returns, for each output position, the ORIGINAL index of the key that belongs there -- e.g. `[2, 0, 1]` means the key currently at index 2 comes first. A stable sort (ties keep their original relative order), matching real syncpack output for keys that compare equal (see `compareSyncpackKey`'s own case-insensitive-letter test case).
1279
+ *
1280
+ * Reordering by computing this full permutation once, rather than reporting and fixing one out-of-order adjacent pair at a time (the technique `@eslint/json`'s own `sort-keys` rule uses), is deliberate: an adjacent-swap fixer only makes one pass of bubble-sort-style progress per lint pass, and a realistically scrambled `package.json` (many top-level keys all needing to move several positions, several nested objects also needing sorting) was confirmed directly to need up to 11 fix passes to fully converge -- one more than ESLint's own `Linter#verifyAndFix` will ever run (`MAX_AUTOFIX_PASSES` is 10), meaning a single real `eslint --fix` invocation could leave such a file only partially reordered. Computing the whole target order up front and replacing every affected key's position in one combined fix converges in a single pass regardless of how scrambled the input is.
1281
+ */
1282
+ function computeOrderPermutation(keys, isValidOrder) {
1283
+ return keys.map((_, index) => index).map((index) => ({
1284
+ key: keys[index] ?? "",
1285
+ index
1286
+ })).sort((a, b) => {
1287
+ if (isValidOrder(a.key, b.key) && isValidOrder(b.key, a.key)) return a.index - b.index;
1288
+ return isValidOrder(a.key, b.key) ? -1 : 1;
1289
+ }).map((entry) => entry.index);
1290
+ }
1291
+ function isIdentityPermutation(permutation) {
1292
+ return permutation.every((originalIndex, position) => originalIndex === position);
1293
+ }
1294
+ function isStringNode(node) {
1295
+ return node.type === "String";
1296
+ }
1297
+ function getMemberKeyName(member) {
1298
+ return member.name.type === "String" ? member.name.value : void 0;
1299
+ }
1300
+ function hasRange(node) {
1301
+ return node.range !== void 0;
1302
+ }
1303
+ const commentTypes = /* @__PURE__ */ new Set(["LineComment", "BlockComment"]);
1304
+ /**
1305
+ * Enforces the same `package.json` key order `syncpack format` would produce: `sortFirst` fields pinned to the top in that exact order, then every other top-level key alphabetically; and, inside each `sortAz`-listed field's own object or array value, its members/elements sorted the same way (no pinning there -- `sortFirst` only ever applies at the top level).
1306
+ *
1307
+ * This exists so a project using `@exadev/eslint-config` without syncpack still gets real `package.json` canonicalization, and so a project using both never sees them fight: `eslint --fix` and `syncpack format` converge on the identical output, confirmed directly against real `syncpack@15` output rather than assumed from its docs (see `compareSyncpackKey`'s own comment). Deliberately narrower than `@eslint/json`'s own `sort-keys` rule, which only supports a single alphabetical order for every key with no field-specific pinning -- not a fit for syncpack's own two-tier scheme.
1308
+ *
1309
+ * Not part of `plugin.configs.recommended`: opt-in, the same tri-state shape as `react`/`nextjs` (see `docs.md`'s own usage example), since a project without syncpack needs to choose this deliberately, and a project with syncpack needs to confirm the two tools agree before turning it on.
1310
+ */
1311
+ const packageJsonKeyOrder = {
1312
+ meta: {
1313
+ type: "layout",
1314
+ fixable: "code",
1315
+ languages: ["json/json", "json/jsonc"],
1316
+ docs: {
1317
+ recommended: false,
1318
+ description: "Require package.json keys to be ordered the same way `syncpack format` would order them.",
1319
+ url: "https://github.com/ExaDev/eslint-config/blob/main/src/rules/package-json-key-order.ts"
1320
+ },
1321
+ schema: [{
1322
+ type: "object",
1323
+ properties: {
1324
+ sortFirst: {
1325
+ type: "array",
1326
+ items: { type: "string" }
1327
+ },
1328
+ sortAz: {
1329
+ type: "array",
1330
+ items: { type: "string" }
1331
+ }
1332
+ },
1333
+ additionalProperties: false
1334
+ }],
1335
+ defaultOptions: [{}],
1336
+ messages: { outOfOrder: "This key order does not match the order \"syncpack format\" would produce: \"{{curr}}\" should come before \"{{prev}}\" here." }
1337
+ },
1338
+ create(context) {
1339
+ const [options] = context.options;
1340
+ const sortFirst = options.sortFirst ?? DEFAULT_SORT_FIRST;
1341
+ const sortAz = new Set(options.sortAz ?? DEFAULT_SORT_AZ);
1342
+ const { sourceCode } = context;
1343
+ function hasAdjacentComment(node) {
1344
+ const before = sourceCode.getTokenBefore(node, { includeComments: true });
1345
+ let after = sourceCode.getTokenAfter(node, { includeComments: true });
1346
+ if (after?.type === "Comma") after = sourceCode.getTokenAfter(after, { includeComments: true });
1347
+ return before !== null && commentTypes.has(before.type) || after !== null && commentTypes.has(after.type);
1348
+ }
1349
+ function buildReorderedText(nodes, permutation) {
1350
+ const separators = nodes.slice(0, -1).map((node, index) => {
1351
+ const next = nodes[index + 1];
1352
+ return hasRange(node) && next !== void 0 && hasRange(next) ? sourceCode.text.slice(node.range[1], next.range[0]) : "";
1353
+ });
1354
+ return permutation.reduce((result, originalIndex, position) => {
1355
+ const text = sourceCode.getText(nodes[originalIndex]);
1356
+ return position === 0 ? text : `${result}${separators[position - 1] ?? ""}${text}`;
1357
+ }, "");
1358
+ }
1359
+ function getNodeRanges(nodes) {
1360
+ const ranges = [];
1361
+ for (const node of nodes) {
1362
+ if (!hasRange(node)) return void 0;
1363
+ ranges.push(node.range);
1364
+ }
1365
+ return ranges;
1366
+ }
1367
+ function reportWholeContainerReorder(nodes, keys, isValidOrder, reportLoc) {
1368
+ const permutation = computeOrderPermutation(keys, isValidOrder);
1369
+ if (isIdentityPermutation(permutation)) return;
1370
+ const firstNode = nodes[0];
1371
+ const lastNode = nodes[nodes.length - 1];
1372
+ if (firstNode === void 0 || lastNode === void 0) return;
1373
+ let violationIndex = 1;
1374
+ for (let index = 1; index < keys.length; index += 1) if (!isValidOrder(keys[index - 1] ?? "", keys[index] ?? "")) {
1375
+ violationIndex = index;
1376
+ break;
1377
+ }
1378
+ context.report({
1379
+ loc: reportLoc(nodes[violationIndex] ?? firstNode),
1380
+ messageId: "outOfOrder",
1381
+ data: {
1382
+ curr: keys[violationIndex] ?? "",
1383
+ prev: keys[violationIndex - 1] ?? ""
1384
+ },
1385
+ fix(fixer) {
1386
+ const ranges = getNodeRanges(nodes);
1387
+ if (ranges === void 0 || nodes.some((node) => hasAdjacentComment(node))) return null;
1388
+ const firstRange = ranges[0];
1389
+ const lastRange = ranges[ranges.length - 1];
1390
+ if (firstRange === void 0 || lastRange === void 0) return null;
1391
+ const rewritten = buildReorderedText(nodes, permutation);
1392
+ return fixer.replaceTextRange([firstRange[0], lastRange[1]], rewritten);
1393
+ }
1394
+ });
1395
+ }
1396
+ function checkMembers(members, isValidOrder) {
1397
+ const keys = [];
1398
+ for (const member of members) {
1399
+ const key = getMemberKeyName(member);
1400
+ if (key === void 0) return;
1401
+ keys.push(key);
1402
+ }
1403
+ reportWholeContainerReorder(members, keys, isValidOrder, (member) => member.name.loc.start);
1404
+ }
1405
+ function checkElements(elements) {
1406
+ const stringValues = [];
1407
+ for (const element of elements) {
1408
+ if (!isStringNode(element.value)) return;
1409
+ stringValues.push(element.value);
1410
+ }
1411
+ reportWholeContainerReorder(stringValues, stringValues.map((value) => value.value), isValidSortAzOrder, (value) => value.loc.start);
1412
+ }
1413
+ return {
1414
+ Object(node, parent) {
1415
+ if (parent === void 0) return;
1416
+ if (parent.type === "Document") {
1417
+ checkMembers(node.members, (prev, curr) => isValidTopLevelOrder(prev, curr, sortFirst));
1418
+ return;
1419
+ }
1420
+ if (parent.type === "Member") {
1421
+ const key = getMemberKeyName(parent);
1422
+ if (key !== void 0 && sortAz.has(key)) checkMembers(node.members, isValidSortAzOrder);
1423
+ }
1424
+ },
1425
+ Array(node, parent) {
1426
+ if (parent?.type !== "Member") return;
1427
+ const key = getMemberKeyName(parent);
1428
+ if (key !== void 0 && sortAz.has(key)) checkElements(node.elements);
1429
+ }
1430
+ };
1431
+ }
1432
+ };
1433
+ //#endregion
1205
1434
  //#region src/rules/prefer-numeric-sort-compare.ts
1206
1435
  const createRule$2 = ESLintUtils.RuleCreator((name) => `https://github.com/ExaDev/eslint-config/blob/main/src/rules/${name}.ts`);
1207
1436
  const SORT_METHOD_NAMES = /* @__PURE__ */ new Set(["sort", "toSorted"]);
@@ -1424,6 +1653,7 @@ const plugin = {
1424
1653
  "no-pointless-reassignment": noPointlessReassignment,
1425
1654
  "no-set-instanceof-mutation": noSetInstanceofMutation,
1426
1655
  "no-side-effects-in-index": noSideEffectsInIndex,
1656
+ "package-json-key-order": packageJsonKeyOrder,
1427
1657
  "prefer-numeric-sort-compare": preferNumericSortCompare,
1428
1658
  "prefer-readonly-array-param": preferReadonlyArrayParam,
1429
1659
  "prefer-readonly-object-param": preferReadonlyObjectParam
@@ -1457,6 +1687,69 @@ const plugin = {
1457
1687
  }
1458
1688
  };
1459
1689
  //#endregion
1690
+ //#region src/package-json-key-order.ts
1691
+ const SYNCPACK_CONFIG_FILENAMES = [
1692
+ ".syncpackrc",
1693
+ ".syncpackrc.json",
1694
+ ".syncpackrc.yaml",
1695
+ ".syncpackrc.yml",
1696
+ ".syncpackrc.js",
1697
+ ".syncpackrc.cjs",
1698
+ ".syncpackrc.mjs",
1699
+ "syncpack.config.js",
1700
+ "syncpack.config.cjs",
1701
+ "syncpack.config.mjs",
1702
+ "syncpack.config.ts"
1703
+ ];
1704
+ function packageJsonHasSyncpackKey(cwd) {
1705
+ try {
1706
+ const raw = JSON.parse(readFileSync(join(cwd, "package.json"), "utf8"));
1707
+ return typeof raw === "object" && raw !== null && "syncpack" in raw;
1708
+ } catch {
1709
+ return false;
1710
+ }
1711
+ }
1712
+ /** Auto-detection signal for the `enabled: undefined` case: does this project already have syncpack configured? Mirrors the role `tryRequire`'s peer-package resolution plays for `react.ts`/`nextjs.ts`, but for a tool detected by config presence rather than by an installed package, since syncpack's own output is what this rule is emulating, not a peer this rule depends on. */
1713
+ function hasSyncpackConfig(cwd) {
1714
+ return SYNCPACK_CONFIG_FILENAMES.some((filename) => existsSync(join(cwd, filename))) || packageJsonHasSyncpackKey(cwd);
1715
+ }
1716
+ function isRecord(value) {
1717
+ return typeof value === "object" && value !== null;
1718
+ }
1719
+ function isJsonLanguagePlugin(value) {
1720
+ if (!isRecord(value)) return false;
1721
+ const languages = value["languages"];
1722
+ return isRecord(languages) && "json" in languages;
1723
+ }
1724
+ function resolveJsonPlugin(value) {
1725
+ if (isRecord(value) && isJsonLanguagePlugin(value["default"])) return value["default"];
1726
+ return isJsonLanguagePlugin(value) ? value : void 0;
1727
+ }
1728
+ const JSON_PLUGIN_INSTALL_COMMAND = "pnpm add -D @eslint/json";
1729
+ function buildPackageJsonKeyOrderConfig(options = {}) {
1730
+ const cwd = options.cwd ?? process.cwd();
1731
+ if (options.enabled === false) return [];
1732
+ if (options.enabled === void 0 && hasSyncpackConfig(cwd)) return [];
1733
+ const jsonPlugin = resolveJsonPlugin(tryRequire("@eslint/json", options.requireFn));
1734
+ if (jsonPlugin === void 0) {
1735
+ if (options.enabled === true) throw new Error(`@exadev/eslint-config: package.json key ordering was explicitly requested but '@eslint/json' could not be resolved. Install it with: ${JSON_PLUGIN_INSTALL_COMMAND}`);
1736
+ return [];
1737
+ }
1738
+ const ruleOptions = {
1739
+ ...options.sortFirst !== void 0 && { sortFirst: options.sortFirst },
1740
+ ...options.sortAz !== void 0 && { sortAz: options.sortAz }
1741
+ };
1742
+ return [{
1743
+ files: ["**/package.json"],
1744
+ language: "json/json",
1745
+ plugins: {
1746
+ exadev: plugin,
1747
+ json: jsonPlugin
1748
+ },
1749
+ rules: { "exadev/package-json-key-order": ["error", ruleOptions] }
1750
+ }];
1751
+ }
1752
+ //#endregion
1460
1753
  //#region src/recommended-type-checked.ts
1461
1754
  const TEST_FILE_PATTERNS = "**/*.{test,spec}.{ts,tsx,mts,cts,js,jsx,mjs,cjs}";
1462
1755
  const JS_TS_FILE_PATTERNS = "**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}";
@@ -1551,6 +1844,7 @@ function exadevConfig(options = {}, ...userConfigs) {
1551
1844
  ...jsonCanonicalConfig,
1552
1845
  ...buildReactConfig({ enabled: options.react }),
1553
1846
  ...buildNextjsConfig({ enabled: options.nextjs }),
1847
+ ...buildPackageJsonKeyOrderConfig({ enabled: options.packageJsonKeyOrder }),
1554
1848
  ...userConfigs
1555
1849
  ]);
1556
1850
  }
package/package.json CHANGED
@@ -1,19 +1,44 @@
1
1
  {
2
2
  "name": "exadev-eslint-config",
3
- "version": "2.14.0",
4
3
  "description": "Shared custom ESLint rules and plugin for ExaDev projects",
5
- "type": "module",
6
- "sideEffects": false,
7
- "license": "MIT",
4
+ "version": "2.16.0",
5
+ "dependencies": {
6
+ "@eslint/js": "^10.0.1",
7
+ "@typescript-eslint/utils": "8.67.0",
8
+ "eslint-plugin-jsdoc": "64.3.6",
9
+ "eslint-plugin-json-canonical": "2.0.0",
10
+ "eslint-plugin-tsdoc": "^0.5.2",
11
+ "ts-api-utils": "^2.5.0"
12
+ },
13
+ "devDependencies": {
14
+ "@arethetypeswrong/cli": "^0.18.5",
15
+ "@commitlint/cli": "^21.2.1",
16
+ "@commitlint/config-conventional": "^21.2.0",
17
+ "@eslint/json": "2.1.0",
18
+ "@humanwhocodes/momoa": "^3.3.13",
19
+ "@next/eslint-plugin-next": "^16.3.2",
20
+ "@semantic-release/changelog": "^7.0.0",
21
+ "@semantic-release/git": "^11.0.1",
22
+ "@types/node": "^24.9.2",
23
+ "@typescript-eslint/rule-tester": "^8.67.0",
24
+ "@vitest/coverage-v8": "^4.1.10",
25
+ "eslint": "^10.8.0",
26
+ "eslint-plugin-jsx-a11y": "^6.10.2",
27
+ "eslint-plugin-react": "^7.37.5",
28
+ "eslint-plugin-react-hooks": "^7.1.1",
29
+ "husky": "^9.1.7",
30
+ "lint-staged": "^17.2.0",
31
+ "publint": "^0.3.21",
32
+ "semantic-release": "^25.0.8",
33
+ "tsdown": "^0.22.13",
34
+ "turbo": "^2.10.8",
35
+ "typescript": "^6.0.3",
36
+ "typescript-eslint": "^8.67.0",
37
+ "vitest": "^4.1.10"
38
+ },
8
39
  "engines": {
9
40
  "node": ">=20"
10
41
  },
11
- "publishConfig": {
12
- "access": "public"
13
- },
14
- "main": "./dist/index.cjs",
15
- "module": "./dist/index.js",
16
- "types": "./dist/index.d.ts",
17
42
  "exports": {
18
43
  ".": {
19
44
  "types": {
@@ -29,13 +54,12 @@
29
54
  ],
30
55
  "keywords": [
31
56
  "eslint",
32
- "eslintplugin",
33
- "eslint-plugin"
57
+ "eslint-plugin",
58
+ "eslintplugin"
34
59
  ],
35
- "repository": {
36
- "type": "git",
37
- "url": "git+https://github.com/ExaDev/eslint-config.git"
38
- },
60
+ "license": "MIT",
61
+ "main": "./dist/index.cjs",
62
+ "module": "./dist/index.js",
39
63
  "peerDependencies": {
40
64
  "@next/eslint-plugin-next": "^16.3.2",
41
65
  "eslint": ">=10.0.0",
@@ -59,48 +83,25 @@
59
83
  "optional": true
60
84
  }
61
85
  },
62
- "devDependencies": {
63
- "@arethetypeswrong/cli": "^0.18.5",
64
- "@commitlint/cli": "^21.2.1",
65
- "@commitlint/config-conventional": "^21.2.0",
66
- "@eslint/json": "2.1.0",
67
- "@next/eslint-plugin-next": "^16.3.2",
68
- "@semantic-release/changelog": "^7.0.0",
69
- "@semantic-release/git": "^11.0.1",
70
- "@types/node": "^24.9.2",
71
- "@typescript-eslint/rule-tester": "^8.67.0",
72
- "@vitest/coverage-v8": "^4.1.10",
73
- "eslint": "^10.8.0",
74
- "eslint-plugin-jsx-a11y": "^6.10.2",
75
- "eslint-plugin-react": "^7.37.5",
76
- "eslint-plugin-react-hooks": "^7.1.1",
77
- "husky": "^9.1.7",
78
- "lint-staged": "^17.2.0",
79
- "publint": "^0.3.21",
80
- "semantic-release": "^25.0.8",
81
- "tsdown": "^0.22.13",
82
- "turbo": "^2.10.8",
83
- "typescript": "^6.0.3",
84
- "typescript-eslint": "^8.67.0",
85
- "vitest": "^4.1.10"
86
+ "publishConfig": {
87
+ "access": "public"
86
88
  },
87
- "dependencies": {
88
- "@eslint/js": "^10.0.1",
89
- "@typescript-eslint/utils": "8.67.0",
90
- "eslint-plugin-jsdoc": "64.3.6",
91
- "eslint-plugin-json-canonical": "1.0.0",
92
- "eslint-plugin-tsdoc": "^0.5.2",
93
- "ts-api-utils": "^2.5.0"
89
+ "repository": {
90
+ "type": "git",
91
+ "url": "git+https://github.com/ExaDev/eslint-config.git"
94
92
  },
93
+ "sideEffects": false,
94
+ "type": "module",
95
+ "types": "./dist/index.d.ts",
95
96
  "scripts": {
96
- "build": "turbo run _build",
97
97
  "_build": "tsdown",
98
- "lint": "turbo run _lint",
99
98
  "_lint": "eslint . --fix --cache --max-warnings 0",
100
- "typecheck": "turbo run _typecheck",
99
+ "_test": "vitest run",
101
100
  "_typecheck": "tsc -p tsconfig.json",
101
+ "build": "turbo run _build",
102
+ "lint": "turbo run _lint",
103
+ "release": "semantic-release",
102
104
  "test": "turbo run _test",
103
- "_test": "vitest run",
104
- "release": "semantic-release"
105
+ "typecheck": "turbo run _typecheck"
105
106
  }
106
107
  }