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 +25 -0
- package/dist/index.cjs +306 -12
- package/dist/index.d.cts +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +304 -11
- package/package.json +53 -51
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
|
|
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
|
|
5
|
-
import
|
|
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
|
-
"
|
|
6
|
-
"
|
|
7
|
-
|
|
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
|
-
"
|
|
33
|
-
"
|
|
57
|
+
"eslint-plugin",
|
|
58
|
+
"eslintplugin"
|
|
34
59
|
],
|
|
35
|
-
"
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
"
|
|
63
|
-
"
|
|
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
|
-
"
|
|
88
|
-
"
|
|
89
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
103
|
-
"release": "semantic-release"
|
|
105
|
+
"typecheck": "turbo run _typecheck"
|
|
104
106
|
}
|
|
105
107
|
}
|