exadev-eslint-config 2.13.0 → 2.15.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
@@ -160,6 +160,30 @@ export default tseslint.config(
160
160
 
161
161
  Trailing arguments are arbitrary flat-config objects, appended in order after everything else -- `exadevConfig({}, { rules: { 'no-console': 'warn' } })` is equivalent to spreading the default export plus one more config object.
162
162
 
163
+ ## RFC 8785 canonical JSON formatting
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.
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.
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`'s own key order is excluded from this bundle specifically (its number-format/string-escaping rules still apply) since ordering is the separate, syncpack-aware concern the next section covers.
170
+
171
+ ## Optional package.json key ordering
172
+
173
+ `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).
174
+
175
+ 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.
176
+
177
+ | Value | `options.packageJsonKeyOrder` |
178
+ | --- | --- |
179
+ | `true` | Force on -- throws if `@eslint/json` isn't resolvable |
180
+ | `false` | Force off -- always `[]`, no resolution attempted |
181
+ | `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 |
182
+
183
+ 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).
184
+
185
+ 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.
186
+
163
187
  ## Rules
164
188
 
165
189
  | Rule | Fixable | Description |
@@ -182,6 +206,7 @@ Trailing arguments are arbitrary flat-config objects, appended in order after ev
182
206
  | `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`. |
183
207
  | `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`. |
184
208
  | `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. |
209
+ | `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. |
185
210
 
186
211
  ## Barrel policy
187
212
 
package/dist/index.cjs CHANGED
@@ -28,16 +28,19 @@ let eslint_plugin_jsdoc = require("eslint-plugin-jsdoc");
28
28
  eslint_plugin_jsdoc = __toESM(eslint_plugin_jsdoc, 1);
29
29
  let eslint_plugin_tsdoc = require("eslint-plugin-tsdoc");
30
30
  eslint_plugin_tsdoc = __toESM(eslint_plugin_tsdoc, 1);
31
+ let eslint_plugin_json_canonical = require("eslint-plugin-json-canonical");
32
+ eslint_plugin_json_canonical = __toESM(eslint_plugin_json_canonical, 1);
31
33
  let node_module = require("node:module");
32
- let _eslint_js = require("@eslint/js");
33
- _eslint_js = __toESM(_eslint_js, 1);
34
- let typescript_eslint = require("typescript-eslint");
35
- typescript_eslint = __toESM(typescript_eslint, 1);
34
+ let node_fs = require("node:fs");
36
35
  let node_path = require("node:path");
37
36
  let _typescript_eslint_utils = require("@typescript-eslint/utils");
38
37
  let typescript = require("typescript");
39
38
  typescript = __toESM(typescript, 1);
40
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);
41
44
  //#region src/jsdoc.ts
42
45
  const JS_TS_FILE_PATTERNS$1 = "**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}";
43
46
  const jsdocConfig = eslint_plugin_jsdoc.default.configs["flat/recommended-tsdoc-error"];
@@ -74,6 +77,22 @@ const jsdocAndTsdoc = [{
74
77
  rules: { "tsdoc/syntax": "error" }
75
78
  }];
76
79
  //#endregion
80
+ //#region src/json-canonical.ts
81
+ function isSingleFlatConfig(value) {
82
+ return typeof value === "object" && value !== null && !Array.isArray(value) && "language" in value;
83
+ }
84
+ const recommended = eslint_plugin_json_canonical.default.configs?.["recommended"];
85
+ 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");
86
+ const jsonCanonicalConfig = [{
87
+ files: ["**/*.json"],
88
+ ignores: [
89
+ "**/tsconfig*.json",
90
+ "**/turbo.json",
91
+ "**/package.json"
92
+ ],
93
+ ...recommended
94
+ }];
95
+ //#endregion
77
96
  //#region src/optional-plugin.ts
78
97
  const nodeRequire = (0, node_module.createRequire)(require("url").pathToFileURL(__filename).href);
79
98
  function tryRequire(specifier, requireFn = nodeRequire) {
@@ -83,16 +102,16 @@ function tryRequire(specifier, requireFn = nodeRequire) {
83
102
  return;
84
103
  }
85
104
  }
86
- function isRecord(value) {
105
+ function isRecord$1(value) {
87
106
  return typeof value === "object" && value !== null;
88
107
  }
89
108
  function isFlatConfig$1(value) {
90
- return isRecord(value);
109
+ return isRecord$1(value);
91
110
  }
92
111
  function normalizeLegacyParserOptions(record) {
93
112
  if (!("parserOptions" in record)) return record;
94
113
  const { parserOptions, languageOptions, ...rest } = record;
95
- const existingLanguageOptions = isRecord(languageOptions) ? languageOptions : {};
114
+ const existingLanguageOptions = isRecord$1(languageOptions) ? languageOptions : {};
96
115
  return {
97
116
  ...rest,
98
117
  languageOptions: {
@@ -104,10 +123,10 @@ function normalizeLegacyParserOptions(record) {
104
123
  function readFlatConfig(module, path) {
105
124
  let current = module;
106
125
  for (const key of path) {
107
- if (!isRecord(current)) return void 0;
126
+ if (!isRecord$1(current)) return void 0;
108
127
  current = current[key];
109
128
  }
110
- if (!isRecord(current)) return void 0;
129
+ if (!isRecord$1(current)) return void 0;
111
130
  const normalized = normalizeLegacyParserOptions(current);
112
131
  return isFlatConfig$1(normalized) ? normalized : void 0;
113
132
  }
@@ -121,6 +140,9 @@ function buildNextjsConfig(options = {}) {
121
140
  return nextConfig === void 0 ? [] : [nextConfig];
122
141
  }
123
142
  //#endregion
143
+ //#region package.json
144
+ var version = "2.15.0";
145
+ //#endregion
124
146
  //#region src/react.ts
125
147
  const JSX_FILE_PATTERNS = ["**/*.jsx", "**/*.tsx"];
126
148
  const INSTALL_COMMAND = "pnpm add -D eslint-plugin-react eslint-plugin-react-hooks eslint-plugin-jsx-a11y";
@@ -158,9 +180,6 @@ function buildReactConfig(options = {}) {
158
180
  }));
159
181
  }
160
182
  //#endregion
161
- //#region package.json
162
- var version = "2.13.0";
163
- //#endregion
164
183
  //#region src/rules/barrel-helpers.ts
165
184
  const INDEX_BASENAME$1 = /^index\.[cm]?[tj]sx?$/;
166
185
  function basenameOf(filename) {
@@ -1216,6 +1235,215 @@ const noSideEffectsInIndex = {
1216
1235
  }
1217
1236
  };
1218
1237
  //#endregion
1238
+ //#region src/rules/package-json-key-order.ts
1239
+ const DEFAULT_SORT_FIRST = [
1240
+ "name",
1241
+ "description",
1242
+ "version",
1243
+ "author"
1244
+ ];
1245
+ const DEFAULT_SORT_AZ = [
1246
+ "bin",
1247
+ "contributors",
1248
+ "dependencies",
1249
+ "devDependencies",
1250
+ "keywords",
1251
+ "peerDependencies",
1252
+ "resolutions",
1253
+ "scripts"
1254
+ ];
1255
+ function categorize(char) {
1256
+ if (char >= "0" && char <= "9") return 1;
1257
+ if (/[a-z]/iu.test(char)) return 2;
1258
+ return 0;
1259
+ }
1260
+ /** 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. */
1261
+ function compareSyncpackKey(a, b) {
1262
+ const length = Math.min(a.length, b.length);
1263
+ for (let index = 0; index < length; index += 1) {
1264
+ const charA = a[index] ?? "";
1265
+ const charB = b[index] ?? "";
1266
+ const categoryA = categorize(charA);
1267
+ const categoryB = categorize(charB);
1268
+ if (categoryA !== categoryB) return categoryA - categoryB;
1269
+ const foldedA = categoryA === 2 ? charA.toLowerCase() : charA;
1270
+ const foldedB = categoryB === 2 ? charB.toLowerCase() : charB;
1271
+ if (foldedA !== foldedB) return foldedA < foldedB ? -1 : 1;
1272
+ }
1273
+ return a.length - b.length;
1274
+ }
1275
+ /**
1276
+ * 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.
1277
+ */
1278
+ function isValidTopLevelOrder(prev, curr, sortFirst) {
1279
+ const prevIndex = sortFirst.indexOf(prev);
1280
+ const currIndex = sortFirst.indexOf(curr);
1281
+ if (prevIndex !== -1 && currIndex !== -1) return prevIndex < currIndex;
1282
+ if (prevIndex !== -1) return true;
1283
+ if (currIndex !== -1) return false;
1284
+ return compareSyncpackKey(prev, curr) <= 0;
1285
+ }
1286
+ /** 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. */
1287
+ function isValidSortAzOrder(prev, curr) {
1288
+ return compareSyncpackKey(prev, curr) <= 0;
1289
+ }
1290
+ /**
1291
+ * 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).
1292
+ *
1293
+ * 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.
1294
+ */
1295
+ function computeOrderPermutation(keys, isValidOrder) {
1296
+ return keys.map((_, index) => index).map((index) => ({
1297
+ key: keys[index] ?? "",
1298
+ index
1299
+ })).sort((a, b) => {
1300
+ if (isValidOrder(a.key, b.key) && isValidOrder(b.key, a.key)) return a.index - b.index;
1301
+ return isValidOrder(a.key, b.key) ? -1 : 1;
1302
+ }).map((entry) => entry.index);
1303
+ }
1304
+ function isIdentityPermutation(permutation) {
1305
+ return permutation.every((originalIndex, position) => originalIndex === position);
1306
+ }
1307
+ function isStringNode(node) {
1308
+ return node.type === "String";
1309
+ }
1310
+ function getMemberKeyName(member) {
1311
+ return member.name.type === "String" ? member.name.value : void 0;
1312
+ }
1313
+ function hasRange(node) {
1314
+ return node.range !== void 0;
1315
+ }
1316
+ const commentTypes = /* @__PURE__ */ new Set(["LineComment", "BlockComment"]);
1317
+ /**
1318
+ * 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).
1319
+ *
1320
+ * 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.
1321
+ *
1322
+ * 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.
1323
+ */
1324
+ const packageJsonKeyOrder = {
1325
+ meta: {
1326
+ type: "layout",
1327
+ fixable: "code",
1328
+ languages: ["json/json", "json/jsonc"],
1329
+ docs: {
1330
+ recommended: false,
1331
+ description: "Require package.json keys to be ordered the same way `syncpack format` would order them.",
1332
+ url: "https://github.com/ExaDev/eslint-config/blob/main/src/rules/package-json-key-order.ts"
1333
+ },
1334
+ schema: [{
1335
+ type: "object",
1336
+ properties: {
1337
+ sortFirst: {
1338
+ type: "array",
1339
+ items: { type: "string" }
1340
+ },
1341
+ sortAz: {
1342
+ type: "array",
1343
+ items: { type: "string" }
1344
+ }
1345
+ },
1346
+ additionalProperties: false
1347
+ }],
1348
+ defaultOptions: [{}],
1349
+ messages: { outOfOrder: "This key order does not match the order \"syncpack format\" would produce: \"{{curr}}\" should come before \"{{prev}}\" here." }
1350
+ },
1351
+ create(context) {
1352
+ const [options] = context.options;
1353
+ const sortFirst = options.sortFirst ?? DEFAULT_SORT_FIRST;
1354
+ const sortAz = new Set(options.sortAz ?? DEFAULT_SORT_AZ);
1355
+ const { sourceCode } = context;
1356
+ function hasAdjacentComment(node) {
1357
+ const before = sourceCode.getTokenBefore(node, { includeComments: true });
1358
+ let after = sourceCode.getTokenAfter(node, { includeComments: true });
1359
+ if (after?.type === "Comma") after = sourceCode.getTokenAfter(after, { includeComments: true });
1360
+ return before !== null && commentTypes.has(before.type) || after !== null && commentTypes.has(after.type);
1361
+ }
1362
+ function buildReorderedText(nodes, permutation) {
1363
+ const separators = nodes.slice(0, -1).map((node, index) => {
1364
+ const next = nodes[index + 1];
1365
+ return hasRange(node) && next !== void 0 && hasRange(next) ? sourceCode.text.slice(node.range[1], next.range[0]) : "";
1366
+ });
1367
+ return permutation.reduce((result, originalIndex, position) => {
1368
+ const text = sourceCode.getText(nodes[originalIndex]);
1369
+ return position === 0 ? text : `${result}${separators[position - 1] ?? ""}${text}`;
1370
+ }, "");
1371
+ }
1372
+ function getNodeRanges(nodes) {
1373
+ const ranges = [];
1374
+ for (const node of nodes) {
1375
+ if (!hasRange(node)) return void 0;
1376
+ ranges.push(node.range);
1377
+ }
1378
+ return ranges;
1379
+ }
1380
+ function reportWholeContainerReorder(nodes, keys, isValidOrder, reportLoc) {
1381
+ const permutation = computeOrderPermutation(keys, isValidOrder);
1382
+ if (isIdentityPermutation(permutation)) return;
1383
+ const firstNode = nodes[0];
1384
+ const lastNode = nodes[nodes.length - 1];
1385
+ if (firstNode === void 0 || lastNode === void 0) return;
1386
+ let violationIndex = 1;
1387
+ for (let index = 1; index < keys.length; index += 1) if (!isValidOrder(keys[index - 1] ?? "", keys[index] ?? "")) {
1388
+ violationIndex = index;
1389
+ break;
1390
+ }
1391
+ context.report({
1392
+ loc: reportLoc(nodes[violationIndex] ?? firstNode),
1393
+ messageId: "outOfOrder",
1394
+ data: {
1395
+ curr: keys[violationIndex] ?? "",
1396
+ prev: keys[violationIndex - 1] ?? ""
1397
+ },
1398
+ fix(fixer) {
1399
+ const ranges = getNodeRanges(nodes);
1400
+ if (ranges === void 0 || nodes.some((node) => hasAdjacentComment(node))) return null;
1401
+ const firstRange = ranges[0];
1402
+ const lastRange = ranges[ranges.length - 1];
1403
+ if (firstRange === void 0 || lastRange === void 0) return null;
1404
+ const rewritten = buildReorderedText(nodes, permutation);
1405
+ return fixer.replaceTextRange([firstRange[0], lastRange[1]], rewritten);
1406
+ }
1407
+ });
1408
+ }
1409
+ function checkMembers(members, isValidOrder) {
1410
+ const keys = [];
1411
+ for (const member of members) {
1412
+ const key = getMemberKeyName(member);
1413
+ if (key === void 0) return;
1414
+ keys.push(key);
1415
+ }
1416
+ reportWholeContainerReorder(members, keys, isValidOrder, (member) => member.name.loc.start);
1417
+ }
1418
+ function checkElements(elements) {
1419
+ const stringValues = [];
1420
+ for (const element of elements) {
1421
+ if (!isStringNode(element.value)) return;
1422
+ stringValues.push(element.value);
1423
+ }
1424
+ reportWholeContainerReorder(stringValues, stringValues.map((value) => value.value), isValidSortAzOrder, (value) => value.loc.start);
1425
+ }
1426
+ return {
1427
+ Object(node, parent) {
1428
+ if (parent === void 0) return;
1429
+ if (parent.type === "Document") {
1430
+ checkMembers(node.members, (prev, curr) => isValidTopLevelOrder(prev, curr, sortFirst));
1431
+ return;
1432
+ }
1433
+ if (parent.type === "Member") {
1434
+ const key = getMemberKeyName(parent);
1435
+ if (key !== void 0 && sortAz.has(key)) checkMembers(node.members, isValidSortAzOrder);
1436
+ }
1437
+ },
1438
+ Array(node, parent) {
1439
+ if (parent?.type !== "Member") return;
1440
+ const key = getMemberKeyName(parent);
1441
+ if (key !== void 0 && sortAz.has(key)) checkElements(node.elements);
1442
+ }
1443
+ };
1444
+ }
1445
+ };
1446
+ //#endregion
1219
1447
  //#region src/rules/prefer-numeric-sort-compare.ts
1220
1448
  const createRule$2 = _typescript_eslint_utils.ESLintUtils.RuleCreator((name) => `https://github.com/ExaDev/eslint-config/blob/main/src/rules/${name}.ts`);
1221
1449
  const SORT_METHOD_NAMES = /* @__PURE__ */ new Set(["sort", "toSorted"]);
@@ -1438,6 +1666,7 @@ const plugin = {
1438
1666
  "no-pointless-reassignment": noPointlessReassignment,
1439
1667
  "no-set-instanceof-mutation": noSetInstanceofMutation,
1440
1668
  "no-side-effects-in-index": noSideEffectsInIndex,
1669
+ "package-json-key-order": packageJsonKeyOrder,
1441
1670
  "prefer-numeric-sort-compare": preferNumericSortCompare,
1442
1671
  "prefer-readonly-array-param": preferReadonlyArrayParam,
1443
1672
  "prefer-readonly-object-param": preferReadonlyObjectParam
@@ -1471,6 +1700,69 @@ const plugin = {
1471
1700
  }
1472
1701
  };
1473
1702
  //#endregion
1703
+ //#region src/package-json-key-order.ts
1704
+ const SYNCPACK_CONFIG_FILENAMES = [
1705
+ ".syncpackrc",
1706
+ ".syncpackrc.json",
1707
+ ".syncpackrc.yaml",
1708
+ ".syncpackrc.yml",
1709
+ ".syncpackrc.js",
1710
+ ".syncpackrc.cjs",
1711
+ ".syncpackrc.mjs",
1712
+ "syncpack.config.js",
1713
+ "syncpack.config.cjs",
1714
+ "syncpack.config.mjs",
1715
+ "syncpack.config.ts"
1716
+ ];
1717
+ function packageJsonHasSyncpackKey(cwd) {
1718
+ try {
1719
+ const raw = JSON.parse((0, node_fs.readFileSync)((0, node_path.join)(cwd, "package.json"), "utf8"));
1720
+ return typeof raw === "object" && raw !== null && "syncpack" in raw;
1721
+ } catch {
1722
+ return false;
1723
+ }
1724
+ }
1725
+ /** 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. */
1726
+ function hasSyncpackConfig(cwd) {
1727
+ return SYNCPACK_CONFIG_FILENAMES.some((filename) => (0, node_fs.existsSync)((0, node_path.join)(cwd, filename))) || packageJsonHasSyncpackKey(cwd);
1728
+ }
1729
+ function isRecord(value) {
1730
+ return typeof value === "object" && value !== null;
1731
+ }
1732
+ function isJsonLanguagePlugin(value) {
1733
+ if (!isRecord(value)) return false;
1734
+ const languages = value["languages"];
1735
+ return isRecord(languages) && "json" in languages;
1736
+ }
1737
+ function resolveJsonPlugin(value) {
1738
+ if (isRecord(value) && isJsonLanguagePlugin(value["default"])) return value["default"];
1739
+ return isJsonLanguagePlugin(value) ? value : void 0;
1740
+ }
1741
+ const JSON_PLUGIN_INSTALL_COMMAND = "pnpm add -D @eslint/json";
1742
+ function buildPackageJsonKeyOrderConfig(options = {}) {
1743
+ const cwd = options.cwd ?? process.cwd();
1744
+ if (options.enabled === false) return [];
1745
+ if (options.enabled === void 0 && hasSyncpackConfig(cwd)) return [];
1746
+ const jsonPlugin = resolveJsonPlugin(tryRequire("@eslint/json", options.requireFn));
1747
+ if (jsonPlugin === void 0) {
1748
+ 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}`);
1749
+ return [];
1750
+ }
1751
+ const ruleOptions = {
1752
+ ...options.sortFirst !== void 0 && { sortFirst: options.sortFirst },
1753
+ ...options.sortAz !== void 0 && { sortAz: options.sortAz }
1754
+ };
1755
+ return [{
1756
+ files: ["**/package.json"],
1757
+ language: "json/json",
1758
+ plugins: {
1759
+ exadev: plugin,
1760
+ json: jsonPlugin
1761
+ },
1762
+ rules: { "exadev/package-json-key-order": ["error", ruleOptions] }
1763
+ }];
1764
+ }
1765
+ //#endregion
1474
1766
  //#region src/recommended-type-checked.ts
1475
1767
  const TEST_FILE_PATTERNS = "**/*.{test,spec}.{ts,tsx,mts,cts,js,jsx,mjs,cjs}";
1476
1768
  const JS_TS_FILE_PATTERNS = "**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}";
@@ -1562,8 +1854,10 @@ function exadevConfig(options = {}, ...userConfigs) {
1562
1854
  return toPublicConfigArray([
1563
1855
  ...recommendedTypeChecked,
1564
1856
  ...jsdocAndTsdoc,
1857
+ ...jsonCanonicalConfig,
1565
1858
  ...buildReactConfig({ enabled: options.react }),
1566
1859
  ...buildNextjsConfig({ enabled: options.nextjs }),
1860
+ ...buildPackageJsonKeyOrderConfig({ enabled: options.packageJsonKeyOrder }),
1567
1861
  ...userConfigs
1568
1862
  ]);
1569
1863
  }
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
@@ -1,12 +1,14 @@
1
1
  import jsdoc from "eslint-plugin-jsdoc";
2
2
  import tsdoc from "eslint-plugin-tsdoc";
3
+ import jsonCanonical from "eslint-plugin-json-canonical";
3
4
  import { createRequire } from "node:module";
4
- import js from "@eslint/js";
5
- import tseslint from "typescript-eslint";
6
- import { posix } from "node:path";
5
+ import { existsSync, readFileSync } from "node:fs";
6
+ import { join, posix } from "node:path";
7
7
  import { AST_NODE_TYPES, ESLintUtils, TSESLint } from "@typescript-eslint/utils";
8
8
  import * as ts from "typescript";
9
9
  import { isPropertyReadonlyInType, isTypeReference } from "ts-api-utils";
10
+ import js from "@eslint/js";
11
+ import tseslint from "typescript-eslint";
10
12
  //#region src/jsdoc.ts
11
13
  const JS_TS_FILE_PATTERNS$1 = "**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}";
12
14
  const jsdocConfig = jsdoc.configs["flat/recommended-tsdoc-error"];
@@ -43,6 +45,22 @@ const jsdocAndTsdoc = [{
43
45
  rules: { "tsdoc/syntax": "error" }
44
46
  }];
45
47
  //#endregion
48
+ //#region src/json-canonical.ts
49
+ function isSingleFlatConfig(value) {
50
+ return typeof value === "object" && value !== null && !Array.isArray(value) && "language" in value;
51
+ }
52
+ const recommended = jsonCanonical.configs?.["recommended"];
53
+ 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");
54
+ const jsonCanonicalConfig = [{
55
+ files: ["**/*.json"],
56
+ ignores: [
57
+ "**/tsconfig*.json",
58
+ "**/turbo.json",
59
+ "**/package.json"
60
+ ],
61
+ ...recommended
62
+ }];
63
+ //#endregion
46
64
  //#region src/optional-plugin.ts
47
65
  const nodeRequire = createRequire(import.meta.url);
48
66
  function tryRequire(specifier, requireFn = nodeRequire) {
@@ -52,16 +70,16 @@ function tryRequire(specifier, requireFn = nodeRequire) {
52
70
  return;
53
71
  }
54
72
  }
55
- function isRecord(value) {
73
+ function isRecord$1(value) {
56
74
  return typeof value === "object" && value !== null;
57
75
  }
58
76
  function isFlatConfig$1(value) {
59
- return isRecord(value);
77
+ return isRecord$1(value);
60
78
  }
61
79
  function normalizeLegacyParserOptions(record) {
62
80
  if (!("parserOptions" in record)) return record;
63
81
  const { parserOptions, languageOptions, ...rest } = record;
64
- const existingLanguageOptions = isRecord(languageOptions) ? languageOptions : {};
82
+ const existingLanguageOptions = isRecord$1(languageOptions) ? languageOptions : {};
65
83
  return {
66
84
  ...rest,
67
85
  languageOptions: {
@@ -73,10 +91,10 @@ function normalizeLegacyParserOptions(record) {
73
91
  function readFlatConfig(module, path) {
74
92
  let current = module;
75
93
  for (const key of path) {
76
- if (!isRecord(current)) return void 0;
94
+ if (!isRecord$1(current)) return void 0;
77
95
  current = current[key];
78
96
  }
79
- if (!isRecord(current)) return void 0;
97
+ if (!isRecord$1(current)) return void 0;
80
98
  const normalized = normalizeLegacyParserOptions(current);
81
99
  return isFlatConfig$1(normalized) ? normalized : void 0;
82
100
  }
@@ -90,6 +108,9 @@ function buildNextjsConfig(options = {}) {
90
108
  return nextConfig === void 0 ? [] : [nextConfig];
91
109
  }
92
110
  //#endregion
111
+ //#region package.json
112
+ var version = "2.15.0";
113
+ //#endregion
93
114
  //#region src/react.ts
94
115
  const JSX_FILE_PATTERNS = ["**/*.jsx", "**/*.tsx"];
95
116
  const INSTALL_COMMAND = "pnpm add -D eslint-plugin-react eslint-plugin-react-hooks eslint-plugin-jsx-a11y";
@@ -127,9 +148,6 @@ function buildReactConfig(options = {}) {
127
148
  }));
128
149
  }
129
150
  //#endregion
130
- //#region package.json
131
- var version = "2.13.0";
132
- //#endregion
133
151
  //#region src/rules/barrel-helpers.ts
134
152
  const INDEX_BASENAME$1 = /^index\.[cm]?[tj]sx?$/;
135
153
  function basenameOf(filename) {
@@ -1185,6 +1203,215 @@ const noSideEffectsInIndex = {
1185
1203
  }
1186
1204
  };
1187
1205
  //#endregion
1206
+ //#region src/rules/package-json-key-order.ts
1207
+ const DEFAULT_SORT_FIRST = [
1208
+ "name",
1209
+ "description",
1210
+ "version",
1211
+ "author"
1212
+ ];
1213
+ const DEFAULT_SORT_AZ = [
1214
+ "bin",
1215
+ "contributors",
1216
+ "dependencies",
1217
+ "devDependencies",
1218
+ "keywords",
1219
+ "peerDependencies",
1220
+ "resolutions",
1221
+ "scripts"
1222
+ ];
1223
+ function categorize(char) {
1224
+ if (char >= "0" && char <= "9") return 1;
1225
+ if (/[a-z]/iu.test(char)) return 2;
1226
+ return 0;
1227
+ }
1228
+ /** 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. */
1229
+ function compareSyncpackKey(a, b) {
1230
+ const length = Math.min(a.length, b.length);
1231
+ for (let index = 0; index < length; index += 1) {
1232
+ const charA = a[index] ?? "";
1233
+ const charB = b[index] ?? "";
1234
+ const categoryA = categorize(charA);
1235
+ const categoryB = categorize(charB);
1236
+ if (categoryA !== categoryB) return categoryA - categoryB;
1237
+ const foldedA = categoryA === 2 ? charA.toLowerCase() : charA;
1238
+ const foldedB = categoryB === 2 ? charB.toLowerCase() : charB;
1239
+ if (foldedA !== foldedB) return foldedA < foldedB ? -1 : 1;
1240
+ }
1241
+ return a.length - b.length;
1242
+ }
1243
+ /**
1244
+ * 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.
1245
+ */
1246
+ function isValidTopLevelOrder(prev, curr, sortFirst) {
1247
+ const prevIndex = sortFirst.indexOf(prev);
1248
+ const currIndex = sortFirst.indexOf(curr);
1249
+ if (prevIndex !== -1 && currIndex !== -1) return prevIndex < currIndex;
1250
+ if (prevIndex !== -1) return true;
1251
+ if (currIndex !== -1) return false;
1252
+ return compareSyncpackKey(prev, curr) <= 0;
1253
+ }
1254
+ /** 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. */
1255
+ function isValidSortAzOrder(prev, curr) {
1256
+ return compareSyncpackKey(prev, curr) <= 0;
1257
+ }
1258
+ /**
1259
+ * 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).
1260
+ *
1261
+ * 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.
1262
+ */
1263
+ function computeOrderPermutation(keys, isValidOrder) {
1264
+ return keys.map((_, index) => index).map((index) => ({
1265
+ key: keys[index] ?? "",
1266
+ index
1267
+ })).sort((a, b) => {
1268
+ if (isValidOrder(a.key, b.key) && isValidOrder(b.key, a.key)) return a.index - b.index;
1269
+ return isValidOrder(a.key, b.key) ? -1 : 1;
1270
+ }).map((entry) => entry.index);
1271
+ }
1272
+ function isIdentityPermutation(permutation) {
1273
+ return permutation.every((originalIndex, position) => originalIndex === position);
1274
+ }
1275
+ function isStringNode(node) {
1276
+ return node.type === "String";
1277
+ }
1278
+ function getMemberKeyName(member) {
1279
+ return member.name.type === "String" ? member.name.value : void 0;
1280
+ }
1281
+ function hasRange(node) {
1282
+ return node.range !== void 0;
1283
+ }
1284
+ const commentTypes = /* @__PURE__ */ new Set(["LineComment", "BlockComment"]);
1285
+ /**
1286
+ * 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).
1287
+ *
1288
+ * 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.
1289
+ *
1290
+ * 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.
1291
+ */
1292
+ const packageJsonKeyOrder = {
1293
+ meta: {
1294
+ type: "layout",
1295
+ fixable: "code",
1296
+ languages: ["json/json", "json/jsonc"],
1297
+ docs: {
1298
+ recommended: false,
1299
+ description: "Require package.json keys to be ordered the same way `syncpack format` would order them.",
1300
+ url: "https://github.com/ExaDev/eslint-config/blob/main/src/rules/package-json-key-order.ts"
1301
+ },
1302
+ schema: [{
1303
+ type: "object",
1304
+ properties: {
1305
+ sortFirst: {
1306
+ type: "array",
1307
+ items: { type: "string" }
1308
+ },
1309
+ sortAz: {
1310
+ type: "array",
1311
+ items: { type: "string" }
1312
+ }
1313
+ },
1314
+ additionalProperties: false
1315
+ }],
1316
+ defaultOptions: [{}],
1317
+ messages: { outOfOrder: "This key order does not match the order \"syncpack format\" would produce: \"{{curr}}\" should come before \"{{prev}}\" here." }
1318
+ },
1319
+ create(context) {
1320
+ const [options] = context.options;
1321
+ const sortFirst = options.sortFirst ?? DEFAULT_SORT_FIRST;
1322
+ const sortAz = new Set(options.sortAz ?? DEFAULT_SORT_AZ);
1323
+ const { sourceCode } = context;
1324
+ function hasAdjacentComment(node) {
1325
+ const before = sourceCode.getTokenBefore(node, { includeComments: true });
1326
+ let after = sourceCode.getTokenAfter(node, { includeComments: true });
1327
+ if (after?.type === "Comma") after = sourceCode.getTokenAfter(after, { includeComments: true });
1328
+ return before !== null && commentTypes.has(before.type) || after !== null && commentTypes.has(after.type);
1329
+ }
1330
+ function buildReorderedText(nodes, permutation) {
1331
+ const separators = nodes.slice(0, -1).map((node, index) => {
1332
+ const next = nodes[index + 1];
1333
+ return hasRange(node) && next !== void 0 && hasRange(next) ? sourceCode.text.slice(node.range[1], next.range[0]) : "";
1334
+ });
1335
+ return permutation.reduce((result, originalIndex, position) => {
1336
+ const text = sourceCode.getText(nodes[originalIndex]);
1337
+ return position === 0 ? text : `${result}${separators[position - 1] ?? ""}${text}`;
1338
+ }, "");
1339
+ }
1340
+ function getNodeRanges(nodes) {
1341
+ const ranges = [];
1342
+ for (const node of nodes) {
1343
+ if (!hasRange(node)) return void 0;
1344
+ ranges.push(node.range);
1345
+ }
1346
+ return ranges;
1347
+ }
1348
+ function reportWholeContainerReorder(nodes, keys, isValidOrder, reportLoc) {
1349
+ const permutation = computeOrderPermutation(keys, isValidOrder);
1350
+ if (isIdentityPermutation(permutation)) return;
1351
+ const firstNode = nodes[0];
1352
+ const lastNode = nodes[nodes.length - 1];
1353
+ if (firstNode === void 0 || lastNode === void 0) return;
1354
+ let violationIndex = 1;
1355
+ for (let index = 1; index < keys.length; index += 1) if (!isValidOrder(keys[index - 1] ?? "", keys[index] ?? "")) {
1356
+ violationIndex = index;
1357
+ break;
1358
+ }
1359
+ context.report({
1360
+ loc: reportLoc(nodes[violationIndex] ?? firstNode),
1361
+ messageId: "outOfOrder",
1362
+ data: {
1363
+ curr: keys[violationIndex] ?? "",
1364
+ prev: keys[violationIndex - 1] ?? ""
1365
+ },
1366
+ fix(fixer) {
1367
+ const ranges = getNodeRanges(nodes);
1368
+ if (ranges === void 0 || nodes.some((node) => hasAdjacentComment(node))) return null;
1369
+ const firstRange = ranges[0];
1370
+ const lastRange = ranges[ranges.length - 1];
1371
+ if (firstRange === void 0 || lastRange === void 0) return null;
1372
+ const rewritten = buildReorderedText(nodes, permutation);
1373
+ return fixer.replaceTextRange([firstRange[0], lastRange[1]], rewritten);
1374
+ }
1375
+ });
1376
+ }
1377
+ function checkMembers(members, isValidOrder) {
1378
+ const keys = [];
1379
+ for (const member of members) {
1380
+ const key = getMemberKeyName(member);
1381
+ if (key === void 0) return;
1382
+ keys.push(key);
1383
+ }
1384
+ reportWholeContainerReorder(members, keys, isValidOrder, (member) => member.name.loc.start);
1385
+ }
1386
+ function checkElements(elements) {
1387
+ const stringValues = [];
1388
+ for (const element of elements) {
1389
+ if (!isStringNode(element.value)) return;
1390
+ stringValues.push(element.value);
1391
+ }
1392
+ reportWholeContainerReorder(stringValues, stringValues.map((value) => value.value), isValidSortAzOrder, (value) => value.loc.start);
1393
+ }
1394
+ return {
1395
+ Object(node, parent) {
1396
+ if (parent === void 0) return;
1397
+ if (parent.type === "Document") {
1398
+ checkMembers(node.members, (prev, curr) => isValidTopLevelOrder(prev, curr, sortFirst));
1399
+ return;
1400
+ }
1401
+ if (parent.type === "Member") {
1402
+ const key = getMemberKeyName(parent);
1403
+ if (key !== void 0 && sortAz.has(key)) checkMembers(node.members, isValidSortAzOrder);
1404
+ }
1405
+ },
1406
+ Array(node, parent) {
1407
+ if (parent?.type !== "Member") return;
1408
+ const key = getMemberKeyName(parent);
1409
+ if (key !== void 0 && sortAz.has(key)) checkElements(node.elements);
1410
+ }
1411
+ };
1412
+ }
1413
+ };
1414
+ //#endregion
1188
1415
  //#region src/rules/prefer-numeric-sort-compare.ts
1189
1416
  const createRule$2 = ESLintUtils.RuleCreator((name) => `https://github.com/ExaDev/eslint-config/blob/main/src/rules/${name}.ts`);
1190
1417
  const SORT_METHOD_NAMES = /* @__PURE__ */ new Set(["sort", "toSorted"]);
@@ -1407,6 +1634,7 @@ const plugin = {
1407
1634
  "no-pointless-reassignment": noPointlessReassignment,
1408
1635
  "no-set-instanceof-mutation": noSetInstanceofMutation,
1409
1636
  "no-side-effects-in-index": noSideEffectsInIndex,
1637
+ "package-json-key-order": packageJsonKeyOrder,
1410
1638
  "prefer-numeric-sort-compare": preferNumericSortCompare,
1411
1639
  "prefer-readonly-array-param": preferReadonlyArrayParam,
1412
1640
  "prefer-readonly-object-param": preferReadonlyObjectParam
@@ -1440,6 +1668,69 @@ const plugin = {
1440
1668
  }
1441
1669
  };
1442
1670
  //#endregion
1671
+ //#region src/package-json-key-order.ts
1672
+ const SYNCPACK_CONFIG_FILENAMES = [
1673
+ ".syncpackrc",
1674
+ ".syncpackrc.json",
1675
+ ".syncpackrc.yaml",
1676
+ ".syncpackrc.yml",
1677
+ ".syncpackrc.js",
1678
+ ".syncpackrc.cjs",
1679
+ ".syncpackrc.mjs",
1680
+ "syncpack.config.js",
1681
+ "syncpack.config.cjs",
1682
+ "syncpack.config.mjs",
1683
+ "syncpack.config.ts"
1684
+ ];
1685
+ function packageJsonHasSyncpackKey(cwd) {
1686
+ try {
1687
+ const raw = JSON.parse(readFileSync(join(cwd, "package.json"), "utf8"));
1688
+ return typeof raw === "object" && raw !== null && "syncpack" in raw;
1689
+ } catch {
1690
+ return false;
1691
+ }
1692
+ }
1693
+ /** 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. */
1694
+ function hasSyncpackConfig(cwd) {
1695
+ return SYNCPACK_CONFIG_FILENAMES.some((filename) => existsSync(join(cwd, filename))) || packageJsonHasSyncpackKey(cwd);
1696
+ }
1697
+ function isRecord(value) {
1698
+ return typeof value === "object" && value !== null;
1699
+ }
1700
+ function isJsonLanguagePlugin(value) {
1701
+ if (!isRecord(value)) return false;
1702
+ const languages = value["languages"];
1703
+ return isRecord(languages) && "json" in languages;
1704
+ }
1705
+ function resolveJsonPlugin(value) {
1706
+ if (isRecord(value) && isJsonLanguagePlugin(value["default"])) return value["default"];
1707
+ return isJsonLanguagePlugin(value) ? value : void 0;
1708
+ }
1709
+ const JSON_PLUGIN_INSTALL_COMMAND = "pnpm add -D @eslint/json";
1710
+ function buildPackageJsonKeyOrderConfig(options = {}) {
1711
+ const cwd = options.cwd ?? process.cwd();
1712
+ if (options.enabled === false) return [];
1713
+ if (options.enabled === void 0 && hasSyncpackConfig(cwd)) return [];
1714
+ const jsonPlugin = resolveJsonPlugin(tryRequire("@eslint/json", options.requireFn));
1715
+ if (jsonPlugin === void 0) {
1716
+ 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}`);
1717
+ return [];
1718
+ }
1719
+ const ruleOptions = {
1720
+ ...options.sortFirst !== void 0 && { sortFirst: options.sortFirst },
1721
+ ...options.sortAz !== void 0 && { sortAz: options.sortAz }
1722
+ };
1723
+ return [{
1724
+ files: ["**/package.json"],
1725
+ language: "json/json",
1726
+ plugins: {
1727
+ exadev: plugin,
1728
+ json: jsonPlugin
1729
+ },
1730
+ rules: { "exadev/package-json-key-order": ["error", ruleOptions] }
1731
+ }];
1732
+ }
1733
+ //#endregion
1443
1734
  //#region src/recommended-type-checked.ts
1444
1735
  const TEST_FILE_PATTERNS = "**/*.{test,spec}.{ts,tsx,mts,cts,js,jsx,mjs,cjs}";
1445
1736
  const JS_TS_FILE_PATTERNS = "**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}";
@@ -1531,8 +1822,10 @@ function exadevConfig(options = {}, ...userConfigs) {
1531
1822
  return toPublicConfigArray([
1532
1823
  ...recommendedTypeChecked,
1533
1824
  ...jsdocAndTsdoc,
1825
+ ...jsonCanonicalConfig,
1534
1826
  ...buildReactConfig({ enabled: options.react }),
1535
1827
  ...buildNextjsConfig({ enabled: options.nextjs }),
1828
+ ...buildPackageJsonKeyOrderConfig({ enabled: options.packageJsonKeyOrder }),
1536
1829
  ...userConfigs
1537
1830
  ]);
1538
1831
  }
package/package.json CHANGED
@@ -1,19 +1,44 @@
1
1
  {
2
2
  "name": "exadev-eslint-config",
3
- "version": "2.13.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.15.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": "1.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,47 +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-tsdoc": "^0.5.2",
92
- "ts-api-utils": "^2.5.0"
89
+ "repository": {
90
+ "type": "git",
91
+ "url": "git+https://github.com/ExaDev/eslint-config.git"
93
92
  },
93
+ "sideEffects": false,
94
+ "type": "module",
95
+ "types": "./dist/index.d.ts",
94
96
  "scripts": {
95
- "build": "turbo run _build",
96
97
  "_build": "tsdown",
97
- "lint": "turbo run _lint",
98
98
  "_lint": "eslint . --fix --cache --max-warnings 0",
99
- "typecheck": "turbo run _typecheck",
99
+ "_test": "vitest run",
100
100
  "_typecheck": "tsc -p tsconfig.json",
101
+ "build": "turbo run _build",
102
+ "lint": "turbo run _lint",
103
+ "release": "semantic-release",
101
104
  "test": "turbo run _test",
102
- "_test": "vitest run",
103
- "release": "semantic-release"
105
+ "typecheck": "turbo run _typecheck"
104
106
  }
105
107
  }