@isentinel/eslint-config 6.0.1 → 6.2.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
@@ -253,6 +253,22 @@ Other behaviours:
253
253
  re-lint (its own dirty count), rather than eagerly spinning up a worker per
254
254
  CPU. The fast pass is cheap per file, so it packs far more files per worker
255
255
  (see `FAST_FILES_PER_WORKER`) than the type-aware pass.
256
+ - Sizes the heap of every ESLint child that builds a TypeScript program (the
257
+ type-aware pass, the full pass and the fix child). Each worker holds its own
258
+ program, so the limit that matters is workers × heap. By default the runner
259
+ splits half the machine's memory (or its cgroup limit, when lower) across
260
+ those workers, and raises Node's heap limit when the share is larger. When the
261
+ budget cannot give each worker twice Node's default heap, it runs fewer
262
+ workers: every worker loads the program of nearly every project, so fewer,
263
+ larger workers need far less memory in total than more, smaller ones. The flag
264
+ goes in the child's `NODE_OPTIONS`, which every ESLint worker inherits; oxlint
265
+ and the fast pass never get it. `--print` shows the chosen workers, files per
266
+ worker and heap on stderr. If a child still runs out of memory, the runner
267
+ prints a hint that names `LINT_TYPED_HEAP_MB`. A size set in
268
+ `LINT_TYPED_HEAP_MB` also caps the workers at budget ÷ size. An explicit
269
+ `LINT_MAX_WORKERS` or `--concurrency` is never reduced. A
270
+ `--max-old-space-size` already in `NODE_OPTIONS` wins over all of this: the
271
+ runner adds nothing and keeps the worker count.
256
272
  - Keeps a separate ESLint cache per pass so they never invalidate one another:
257
273
  `.eslintcache-fast-<key>` (fast pass), `.eslintcache-typeaware-<key>`
258
274
  (type-aware pass), and `.eslintcache-<key>` (the full config, used by CI,
@@ -383,6 +399,7 @@ or `--`.
383
399
  | `--oxlint-args "<args>"` | Extra arguments forwarded verbatim to oxlint. |
384
400
  | `--files0-from <file\|->` | Read the paths to lint from a NUL-delimited UTF-8 list (`-` for stdin); an empty list exits 0. |
385
401
  | `--print` | Print the composed commands without running them. |
402
+ | `--no-oxfmt` | Skip the oxfmt pass that runs when the project has an `oxfmt.config.*` file (see [Standalone oxfmt](#standalone-oxfmt)). |
386
403
  | `-- <args>` | Forward args to the single selected tool (needs `--eslint`/`--oxlint`). |
387
404
  | `-h`, `--help` | Show help. |
388
405
  | `-v`, `--version` | Show the version. |
@@ -394,6 +411,7 @@ or `--`.
394
411
  | `FILES_PER_WORKER` | Target files a single type-aware ESLint worker handles before adding another (default `350`). |
395
412
  | `FAST_FILES_PER_WORKER` | Same, for the fast (syntactic) pass, which packs far more files per worker (default `800`). |
396
413
  | `LINT_MAX_WORKERS` | Upper bound on ESLint workers (default a quarter of available CPUs). |
414
+ | `LINT_TYPED_HEAP_MB` | Heap per worker for the program-building ESLint children: `auto` (default), a size in MB, or `off` (Node's default). |
397
415
  | `ESLINT_TYPE_AWARE` | `off` or `only`; the type-aware mode `--type-aware` sets for the ESLint child (`false` is a legacy alias for `off`). |
398
416
  | `CI` | When set, the runner uses the single full pass with `--cache-strategy content` (see below). |
399
417
 
@@ -932,6 +950,103 @@ pnpm i -D oxlint oxlint-tsgolint
932
950
  See [docs/oxlint.md](./docs/oxlint.md) for the per-rule mapping, what stays in
933
951
  ESLint and why, and migration notes.
934
952
 
953
+ #### Standalone oxfmt
954
+
955
+ By default, oxfmt runs inside the linter as the `oxfmt/oxfmt` rule. To run it as
956
+ its own tool (`oxfmt`, editor format-on-save through the oxc extension), set
957
+ `formatter: "standalone"` in both factories and add an `oxfmt.config.ts` built
958
+ from the preset:
959
+
960
+ ```ts
961
+ // oxfmt.config.ts
962
+ import { isentinelOxfmt } from "@isentinel/eslint-config/oxfmt";
963
+
964
+ export default isentinelOxfmt({ cwd: import.meta.dirname, printWidth: 100 });
965
+ ```
966
+
967
+ ```ts
968
+ // eslint.config.ts
969
+ import isentinel from "@isentinel/eslint-config";
970
+
971
+ export default isentinel({
972
+ formatter: "standalone",
973
+ });
974
+ ```
975
+
976
+ `isentinelOxfmt` starts from the same options the linter uses (preset defaults,
977
+ then `.editorconfig`, then `.prettierrc`). Each key you pass replaces its base
978
+ value, except `ignorePatterns` and `overrides`, which are appended to the
979
+ preset's. The override order is the one the `oxfmt/oxfmt` rule uses: the
980
+ preset's Markdown, YAML and JSON5 overrides, then the `.editorconfig` sections
981
+ (without keys you set at the root), then yours, then the `.prettierrc`
982
+ `overrides`. Override globs without a slash are anchored to the config directory
983
+ (`*.json` becomes `./*.json`), because the rule matches them only there. `cwd`
984
+ is the directory to read `.prettierrc` and `.editorconfig` from (default
985
+ `process.cwd()`). Pass `import.meta.dirname`, as the wizard does, so the result
986
+ does not depend on where oxfmt runs.
987
+
988
+ `isentinelOxfmt` takes `formatters` and `componentExts`, with the same meaning
989
+ as in the factories, so oxfmt formats the same files the linter would. TOML,
990
+ `.mdx` and other files the linter does not format are ignored.
991
+
992
+ In standalone mode, oxfmt never sees the linter's options. The factories warn
993
+ once per lint run when you set any of these, because they do not change
994
+ formatting: `formatters` (any value except `true`), `componentExts`, `ignores`,
995
+ or `gitignore` (`false` or custom files). Set the equivalent in `isentinelOxfmt`
996
+ instead (`formatters`, `componentExts`, `ignorePatterns`, and format options).
997
+ `stylistic: false` turns off the stylistic lint rules but not oxfmt. The rule
998
+ widths also come from the oxfmt config only, not from
999
+ `formatters.prettierOptions` or `formatters.oxfmtOptions`. The setup wizard
1000
+ copies migrated `.eslintignore` patterns to both configs.
1001
+
1002
+ With `formatter: "standalone"`, the linter enables `oxfmt/oxfmt` nowhere but
1003
+ keeps the rules that turn off formatting conflicts. Lua formatting (Stylua)
1004
+ stays in ESLint, because oxfmt does not format Lua. The setup wizard can write
1005
+ all of this for you.
1006
+
1007
+ `isentinel-lint` runs `oxfmt --check` next to the linters whenever it finds an
1008
+ `oxfmt.config.ts` or `oxfmt.config.mts`, and `oxfmt --write` last with `--fix`.
1009
+ It does this whatever the `formatter` option is, so set
1010
+ `formatter: "standalone"` with such a file, or the files are formatted twice.
1011
+ Pass `--no-oxfmt` to skip the pass.
1012
+
1013
+ Both factories read the oxfmt config that oxfmt itself uses: `.oxfmtrc.json`,
1014
+ `.oxfmtrc.jsonc`, `oxfmt.config.ts` or `oxfmt.config.mts`, in the nearest
1015
+ directory from the working directory up to the filesystem root. More than one of
1016
+ them in one directory is an error, as in oxfmt. Its root options (without
1017
+ `ignorePatterns` and `overrides`) override `formatters.prettierOptions`, and
1018
+ `formatters.oxfmtOptions` overrides them.
1019
+
1020
+ Nested oxfmt configs (a config file in a subdirectory) are not used, in either
1021
+ mode. One root config formats the whole project: the `oxfmt/oxfmt` rule sets
1022
+ `disableNestedConfig`, `isentinel-lint` passes `--config`, the wizard's format
1023
+ scripts pass `--disable-nested-config`, and its VS Code settings set
1024
+ `oxc.fmt.disableNestedConfig`. Use `overrides` for per-folder options. The same
1025
+ holds for `.editorconfig`: only the nearest one at or above the root config is
1026
+ read, so a `.editorconfig` in a subdirectory does not change formatting.
1027
+
1028
+ `flawless/arrow-return-style` uses two widths. `maxLen` is your limit: an arrow
1029
+ becomes implicit only if it fits. It comes from `stylistic.maxLen`, else
1030
+ `printWidth`, and it can be shorter than the formatter width on purpose.
1031
+ `useOxfmt.printWidth` is the width the rule gives oxfmt to predict its output.
1032
+ It always comes from the root `printWidth` of the oxfmt config, so that `--fix`
1033
+ agrees with oxfmt. An override that changes `printWidth` or `tabWidth` for JS or
1034
+ TS files gets a warning, because the linter uses the root values. In Markdown
1035
+ code blocks, `useOxfmt` uses the Markdown widths instead, because oxfmt formats
1036
+ embedded code with the Markdown options. Each oxfmt, `.editorconfig` or
1037
+ `.prettierrc` override that changes the Markdown widths reaches only the code
1038
+ blocks of the files it matches. The comment length rules take their `tabSize`
1039
+ from the same oxfmt widths.
1040
+
1041
+ The stylistic `indent` and `quotes`, and the JSON bracket spacing, follow the
1042
+ oxfmt options (`useTabs`, `tabWidth`, `singleQuote`, `bracketSpacing`) unless
1043
+ you set them in `stylistic`.
1044
+
1045
+ Both modes write the same output, with one known difference: when one import
1046
+ merges into another (`import/no-duplicates`), eslint mode sorts the imports
1047
+ before the merge and standalone mode after it, so comments above the merged
1048
+ import can end in a different order.
1049
+
935
1050
  #### Type-Aware Split
936
1051
 
937
1052
  On large projects the type-aware rules dominate ESLint wall time; an explicit
package/dist/cli.mjs CHANGED
@@ -1,15 +1,89 @@
1
- import { cancel, confirm, group, intro, log, multiselect, note, outro } from "@clack/prompts";
1
+ import { createRequire } from "node:module";
2
+ import { cancel, confirm, group, intro, log, multiselect, note, outro, select } from "@clack/prompts";
2
3
  import ansis from "ansis";
3
4
  import process from "node:process";
4
5
  import yargs from "yargs";
5
6
  import { hideBin } from "yargs/helpers";
7
+ import "find-up-simple";
8
+ import "jsonc-eslint-parser";
9
+ import "local-pkg";
6
10
  import fs from "node:fs";
7
- import fsp from "node:fs/promises";
8
11
  import path from "node:path";
12
+ import "semver";
13
+ import fsp from "node:fs/promises";
9
14
  import { existsSync, readFileSync } from "fs";
10
15
  import { execSync } from "node:child_process";
16
+ //#region src/guards.ts
17
+ /**
18
+ * Internal runtime type guards. Prefer these over `as` assertions so values
19
+ * crossing untyped boundaries (`JSON.parse`, dynamic `import`, plugin objects)
20
+ * are validated at runtime rather than asserted away.
21
+ */
22
+ /**
23
+ * Whether a value is a non-null, non-array object usable as a string-keyed
24
+ * record. Narrows `unknown` without an assertion.
25
+ *
26
+ * @param value - The value to test.
27
+ * @returns Whether the value is a plain object.
28
+ */
29
+ function isRecord(value) {
30
+ return typeof value === "object" && value !== null && !Array.isArray(value);
31
+ }
32
+ /**
33
+ * Whether a value is an array whose every element is a string.
34
+ *
35
+ * @param value - The value to test.
36
+ * @returns Whether the value is a `string` array.
37
+ */
38
+ function isStringArray(value) {
39
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
40
+ }
41
+ createRequire(import.meta.url);
42
+ environmentMatcher("PATH", /\.pi[/\\]agent/), environmentMatcher("EDITOR", /devin/), environmentMatcher("TERM_PROGRAM", /kiro/, { noTty: true });
43
+ /**
44
+ * Build a probe that regex-tests one environment variable, for the agents that
45
+ * announce themselves inside a variable a human also sets.
46
+ *
47
+ * `noTty` marks a variable an interactive user shares with the agent (kiro's
48
+ * `TERM_PROGRAM` is set in its terminal too): an attached stdout TTY means a
49
+ * person is watching, so the probe declines.
50
+ *
51
+ * @param name - The environment variable to test.
52
+ * @param pattern - The pattern the value must match.
53
+ * @param options - Probe options.
54
+ * @param options.noTty - Whether an attached stdout TTY disqualifies the match.
55
+ * @returns The probe.
56
+ */
57
+ function environmentMatcher(name, pattern, { noTty = false } = {}) {
58
+ return (environment) => {
59
+ if (noTty && process.stdout.isTTY) return false;
60
+ const value = environment[name];
61
+ return value !== void 0 && pattern.test(value);
62
+ };
63
+ }
64
+ /** JSON oxfmt config files oxfmt discovers. */
65
+ const OXFMT_RC_FILES = [".oxfmtrc.json", ".oxfmtrc.jsonc"];
66
+ /**
67
+ * Oxfmt config modules oxfmt discovers. It loads other module extensions only
68
+ * through `--config`, never by search.
69
+ */
70
+ const OXFMT_CONFIG_MODULES = ["oxfmt.config.ts", "oxfmt.config.mts"];
71
+ /** The config module the standalone setup writes. */
72
+ const OXFMT_CONFIG_MODULE = OXFMT_CONFIG_MODULES[0];
73
+ /** Every file name oxfmt discovers. One directory may hold only one of them. */
74
+ const OXFMT_CONFIG_NAMES = [...OXFMT_RC_FILES, ...OXFMT_CONFIG_MODULES];
75
+ /**
76
+ * List the oxfmt config files in one directory, without walking up.
77
+ *
78
+ * @param directory - Directory to check.
79
+ * @returns The file names found, sorted.
80
+ */
81
+ function oxfmtConfigNamesIn(directory) {
82
+ return OXFMT_CONFIG_NAMES.filter((name) => fs.existsSync(path.join(directory, name))).sort();
83
+ }
84
+ //#endregion
11
85
  //#region package.json
12
- var version = "6.0.1";
86
+ var version = "6.2.0";
13
87
  var package_default = {
14
88
  name: "@isentinel/eslint-config",
15
89
  version,
@@ -43,6 +117,10 @@ var package_default = {
43
117
  "default": "./dist/index.mjs"
44
118
  },
45
119
  "./formatter-agents": "./dist/formatter-agents.mjs",
120
+ "./oxfmt": {
121
+ "types": "./dist/oxfmt.d.mts",
122
+ "default": "./dist/oxfmt.mjs"
123
+ },
46
124
  "./oxlint": {
47
125
  "types": "./dist/oxlint.d.mts",
48
126
  "default": "./dist/oxlint.mjs"
@@ -73,6 +151,7 @@ var package_default = {
73
151
  "release": "bumpp -r",
74
152
  "stub": "tsdown",
75
153
  "test": "nr gen && vitest run",
154
+ "test:parity": "vitest run --config vitest.parity.config.ts",
76
155
  "test:watch": "vitest",
77
156
  "typecheck": "tsc --noEmit",
78
157
  "validate:oxlint": "node scripts/validate-oxlint.ts",
@@ -122,6 +201,7 @@ var package_default = {
122
201
  "file-entry-cache": "catalog:prod",
123
202
  "find-up-simple": "catalog:prod",
124
203
  "globals": "catalog:prod",
204
+ "jiti": "catalog:prod",
125
205
  "jsonc-eslint-parser": "catalog:prod",
126
206
  "local-pkg": "catalog:prod",
127
207
  "oxfmt": "catalog:prod",
@@ -161,7 +241,6 @@ var package_default = {
161
241
  "eslint-plugin-react-x": "catalog:peer",
162
242
  "eslint-plugin-testing-library": "catalog:peer",
163
243
  "eslint-typegen": "catalog:dev",
164
- "jiti": "catalog:dev",
165
244
  "oxlint": "catalog:peer",
166
245
  "oxlint-tsgolint": "catalog:peer",
167
246
  "parse-gitignore-ts": "catalog:dev",
@@ -208,11 +287,43 @@ var package_default = {
208
287
  };
209
288
  //#endregion
210
289
  //#region src/cli/constants.ts
211
- const vscodeSettingsString = `
290
+ /** The VS Code extension that runs oxfmt. */
291
+ const OXC_VSCODE_EXTENSION = "oxc.oxc-vscode";
292
+ /** Languages oxfmt formats on save in the standalone setup. */
293
+ const OXFMT_LANGUAGES = [
294
+ "css",
295
+ "graphql",
296
+ "html",
297
+ "javascript",
298
+ "javascriptreact",
299
+ "json",
300
+ "jsonc",
301
+ "less",
302
+ "markdown",
303
+ "scss",
304
+ "typescript",
305
+ "typescriptreact",
306
+ "yaml"
307
+ ];
308
+ const eslintFormatterSettings = `
212
309
  // Disable the default formatter, use eslint instead
213
310
  "prettier.enable": false,
214
311
  "editor.formatOnSave": false,
215
-
312
+ `;
313
+ const standaloneFormatterSettings = `
314
+ // Format with oxfmt; eslint only lints
315
+ "prettier.enable": false,
316
+ "eslint.format.enable": false,
317
+ // One root oxfmt config, as the CLI and lint runs use
318
+ "oxc.fmt.disableNestedConfig": true,
319
+ ${OXFMT_LANGUAGES.map((language) => {
320
+ return ` "[${language}]": {
321
+ "editor.defaultFormatter": "${OXC_VSCODE_EXTENSION}",
322
+ "editor.formatOnSave": true
323
+ },`;
324
+ }).join("\n")}
325
+ `;
326
+ const lintSettings = `
216
327
  // Auto fix
217
328
  "editor.codeActionsOnSave": {
218
329
  "source.fixAll.eslint": "explicit",
@@ -246,6 +357,36 @@ const vscodeSettingsString = `
246
357
  "luau"
247
358
  ]
248
359
  `;
360
+ /**
361
+ * The VS Code settings the wizard adds, as the body of a JSON object.
362
+ *
363
+ * @param formatter - Where formatting runs.
364
+ * @returns The settings text.
365
+ */
366
+ function vscodeSettingsString(formatter = "eslint") {
367
+ return `${formatter === "standalone" ? standaloneFormatterSettings : eslintFormatterSettings}${lintSettings}`;
368
+ }
369
+ const formatterOptions = [{
370
+ hint: "oxfmt runs as an ESLint rule",
371
+ label: "ESLint",
372
+ value: "eslint"
373
+ }, {
374
+ hint: `oxfmt runs as its own tool, with ${OXFMT_CONFIG_MODULE}`,
375
+ label: "Standalone oxfmt",
376
+ value: "standalone"
377
+ }];
378
+ /**
379
+ * The {@link OXFMT_CONFIG_MODULE} the standalone setup writes.
380
+ *
381
+ * @param ignorePatterns - Patterns oxfmt should skip, as in ESLint `ignores`.
382
+ * @returns The module source.
383
+ */
384
+ function oxfmtConfigContent(ignorePatterns = []) {
385
+ return `import { isentinelOxfmt } from "@isentinel/eslint-config/oxfmt";
386
+
387
+ export default isentinelOxfmt({ cwd: import.meta.dirname${ignorePatterns.length > 0 ? `, ignorePatterns: ${JSON.stringify(ignorePatterns)}` : ""} });
388
+ `;
389
+ }
249
390
  const frameworkOptions = [{
250
391
  label: ansis.red("Test"),
251
392
  value: "test"
@@ -263,17 +404,6 @@ const dependenciesMap = {
263
404
  test: ["eslint-plugin-jest"]
264
405
  };
265
406
  //#endregion
266
- //#region src/guards.ts
267
- /**
268
- * Whether a value is an array whose every element is a string.
269
- *
270
- * @param value - The value to test.
271
- * @returns Whether the value is a `string` array.
272
- */
273
- function isStringArray(value) {
274
- return Array.isArray(value) && value.every((item) => typeof item === "string");
275
- }
276
- //#endregion
277
407
  //#region ../../../setup-pnpm/node_modules/.bin/store/v11/links/@/parse-gitignore-ts/1.0.0/0cdd402c09c3520e4b8d62a0e9eaf59ef70b71773d70a71b070a2cf3609765b8/node_modules/parse-gitignore-ts/dist/index.mjs
278
408
  var isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
279
409
  var INVALID_PATH_CHARS_REGEX = /[<>:"|?*\n\r\t\f\x00-\x1F]/;
@@ -523,9 +653,18 @@ async function updateEslintFiles(result) {
523
653
  const configLines = [];
524
654
  if (eslintIgnores.length) configLines.push(`ignores: ${JSON.stringify(eslintIgnores)},`);
525
655
  for (const framework of result.frameworks) configLines.push(`${framework}: true,`);
656
+ if (result.formatter === "standalone") configLines.push("formatter: \"standalone\",");
526
657
  const eslintConfigContent = getEslintConfigContent(configLines.map((index) => ` ${index}`).join("\n"), []);
527
658
  await fsp.writeFile(pathFlatConfig, eslintConfigContent);
528
659
  log.success(ansis.green(`Created ${configFileName}`));
660
+ if (result.formatter === "standalone") {
661
+ const [existing] = oxfmtConfigNamesIn(cwd);
662
+ if (existing !== void 0) log.warn(ansis.yellow(`${existing} already exists, left unchanged.`));
663
+ else {
664
+ await fsp.writeFile(path.join(cwd, OXFMT_CONFIG_MODULE), oxfmtConfigContent(eslintIgnores));
665
+ log.success(ansis.green(`Created ${OXFMT_CONFIG_MODULE}`));
666
+ }
667
+ }
529
668
  const files = fs.readdirSync(cwd);
530
669
  const legacyConfig = [];
531
670
  for (const file of files) if (LEGACY_CONFIG_REGEX.test(file) && !file.includes("eslint.config.")) legacyConfig.push(file);
@@ -538,7 +677,8 @@ const versionsMap = {
538
677
  "eslint-plugin-jest": "29.16.0",
539
678
  "eslint-plugin-react-jsx": "5.17.3",
540
679
  "eslint-plugin-react-naming-convention": "5.17.3",
541
- "eslint-plugin-react-x": "5.17.3"
680
+ "eslint-plugin-react-x": "5.17.3",
681
+ "oxfmt": "0.72.0"
542
682
  };
543
683
  //#endregion
544
684
  //#region src/cli/stages/update-package-json.ts
@@ -547,20 +687,28 @@ const LINT_SCRIPTS = {
547
687
  "lint": "isentinel-lint",
548
688
  "lint:fix": "isentinel-lint --fix"
549
689
  };
690
+ /** Scripts the standalone formatter setup adds, so oxfmt runs on its own. */
691
+ const FORMAT_SCRIPTS = {
692
+ "format": "oxfmt --disable-nested-config",
693
+ "format:check": "oxfmt --check --disable-nested-config"
694
+ };
550
695
  /**
551
- * Merge the lint scripts into a `scripts` block in place. Missing scripts are
552
- * always added. An existing script with identical content is left untouched. An
553
- * existing script with different content is preserved in skip-prompt mode, and
554
- * otherwise only overwritten when {@link ConfirmOverwrite} resolves true.
696
+ * Merge the lint scripts (or `options.desired`) into a `scripts` block in
697
+ * place. Missing scripts are always added. An existing script with identical
698
+ * content is left untouched. An existing script with different content is
699
+ * preserved in skip-prompt mode, and otherwise only overwritten when
700
+ * {@link ConfirmOverwrite} resolves true.
555
701
  *
556
702
  * @param scripts - The consumer's `scripts` block, mutated in place.
557
- * @param options - Skip-prompt flag and the overwrite confirmation callback.
703
+ * @param options - Skip-prompt flag, the overwrite confirmation callback and
704
+ * the scripts to merge (default {@link LINT_SCRIPTS}).
558
705
  * @returns Which scripts were added and which were overwritten.
559
706
  */
560
- async function mergeLintScripts(scripts, options) {
707
+ async function mergeScripts(scripts, options) {
561
708
  const added = [];
562
709
  const overwritten = [];
563
- for (const [name, desired] of Object.entries(LINT_SCRIPTS)) {
710
+ const entries = Object.entries(options.desired ?? LINT_SCRIPTS);
711
+ for (const [name, desired] of entries) {
564
712
  const existing = scripts[name];
565
713
  if (existing === void 0) {
566
714
  scripts[name] = desired;
@@ -587,6 +735,7 @@ async function updatePackageJson(result, skipPrompt = false) {
587
735
  parsedPackage.devDependencies ??= {};
588
736
  parsedPackage.devDependencies["@isentinel/eslint-config"] = `^${version}`;
589
737
  parsedPackage.devDependencies["eslint"] ??= versionsMap.eslint;
738
+ if (result.formatter === "standalone") parsedPackage.devDependencies["oxfmt"] ??= versionsMap.oxfmt;
590
739
  const addedPackages = [];
591
740
  for (const framework of result.frameworks) {
592
741
  if (!(framework in dependenciesMap)) continue;
@@ -599,17 +748,21 @@ async function updatePackageJson(result, skipPrompt = false) {
599
748
  if (addedPackages.length) note(ansis.dim(addedPackages.join(", ")), "Added packages");
600
749
  parsedPackage.scripts ??= {};
601
750
  const { scripts = {} } = parsedPackage;
602
- const { added, overwritten } = await mergeLintScripts(scripts, {
751
+ const { added, overwritten } = await mergeScripts(scripts, {
603
752
  confirmOverwrite: async (name, existing, desired) => {
604
753
  return await confirm({
605
754
  initialValue: false,
606
755
  message: `A "${name}" script already exists ("${existing}"). Overwrite it with "${desired}"?`
607
756
  }) === true;
608
757
  },
758
+ desired: result.formatter === "standalone" ? {
759
+ ...LINT_SCRIPTS,
760
+ ...FORMAT_SCRIPTS
761
+ } : LINT_SCRIPTS,
609
762
  skipPrompt
610
763
  });
611
764
  const touched = [...added, ...overwritten];
612
- if (touched.length) note(ansis.dim(touched.map((name) => `${name}: ${scripts[name]}`).join("\n")), "Lint scripts");
765
+ if (touched.length) note(ansis.dim(touched.map((name) => `${name}: ${scripts[name]}`).join("\n")), "Scripts");
613
766
  await fsp.writeFile(pathPackageJSON, JSON.stringify(parsedPackage, null, 2));
614
767
  log.success(ansis.green("Changes wrote to package.json"));
615
768
  }
@@ -617,22 +770,55 @@ async function updatePackageJson(result, skipPrompt = false) {
617
770
  //#region src/cli/stages/update-vscode-settings.ts
618
771
  async function updateVscodeSettings(result) {
619
772
  if (!result.updateVscodeSettings) return;
773
+ const settings = vscodeSettingsString(result.formatter);
620
774
  const cwd = process.cwd();
621
775
  const dotVscodePath = path.join(cwd, ".vscode");
622
776
  const settingsPath = path.join(dotVscodePath, "settings.json");
623
777
  if (!fs.existsSync(dotVscodePath)) await fsp.mkdir(dotVscodePath, { recursive: true });
624
778
  if (!fs.existsSync(settingsPath)) {
625
- await fsp.writeFile(settingsPath, `{${vscodeSettingsString}}\n`, "utf-8");
779
+ await fsp.writeFile(settingsPath, `{${settings}}\n`, "utf-8");
626
780
  log.success(ansis.green("Created .vscode/settings.json"));
781
+ await recommendExtensions(dotVscodePath, result);
627
782
  return;
628
783
  }
629
784
  let settingsContent = await fsp.readFile(settingsPath, "utf8");
630
785
  settingsContent = settingsContent.trim();
631
786
  if (settingsContent.endsWith("}")) settingsContent = settingsContent.slice(0, -1).trimEnd();
632
787
  settingsContent += settingsContent.endsWith(",") || settingsContent.endsWith("{") ? "" : ",";
633
- settingsContent += `${vscodeSettingsString}}\n`;
788
+ settingsContent += `${settings}}\n`;
634
789
  await fsp.writeFile(settingsPath, settingsContent, "utf-8");
635
790
  log.success(ansis.green("Updated .vscode/settings.json"));
791
+ await recommendExtensions(dotVscodePath, result);
792
+ }
793
+ /**
794
+ * Add the oxc extension to `.vscode/extensions.json` for the standalone
795
+ * formatter setup. A file that is not plain JSON is left alone with a warning.
796
+ *
797
+ * @param dotVscodePath - The `.vscode` directory.
798
+ * @param result - The prompt result.
799
+ */
800
+ async function recommendExtensions(dotVscodePath, result) {
801
+ if (result.formatter !== "standalone") return;
802
+ const extensionsPath = path.join(dotVscodePath, "extensions.json");
803
+ let extensions = {};
804
+ if (fs.existsSync(extensionsPath)) {
805
+ let parsed;
806
+ try {
807
+ parsed = JSON.parse(await fsp.readFile(extensionsPath, "utf8"));
808
+ } catch {
809
+ parsed = void 0;
810
+ }
811
+ if (!isRecord(parsed)) {
812
+ log.warn(ansis.yellow(`Could not read .vscode/extensions.json; add "${OXC_VSCODE_EXTENSION}" to its recommendations manually.`));
813
+ return;
814
+ }
815
+ extensions = parsed;
816
+ }
817
+ const recommendations = isStringArray(extensions["recommendations"]) ? extensions["recommendations"] : [];
818
+ if (recommendations.includes("oxc.oxc-vscode")) return;
819
+ extensions["recommendations"] = [...recommendations, OXC_VSCODE_EXTENSION];
820
+ await fsp.writeFile(extensionsPath, `${JSON.stringify(extensions, null, 2)}\n`, "utf-8");
821
+ log.success(ansis.green("Recommended the oxc extension in .vscode/extensions.json"));
636
822
  }
637
823
  //#endregion
638
824
  //#region src/cli/run.ts
@@ -646,13 +832,15 @@ async function run(options = {}) {
646
832
  log.warn(ansis.yellow(`${eslintConfigFiles[0]} already exists, migration wizard exited.`));
647
833
  return process.exit(1);
648
834
  }
835
+ const argumentFormatter = formatterOptions.find(({ value }) => value === options.formatter)?.value;
649
836
  let result = {
837
+ formatter: argumentFormatter ?? "eslint",
650
838
  frameworks: argumentTemplate ?? [],
651
839
  uncommittedConfirmed: false,
652
840
  updateVscodeSettings: true
653
841
  };
654
842
  if (argumentSkipPrompt !== true) {
655
- const { frameworks: groupedFrameworks, uncommittedConfirmed, updateVscodeSettings: shouldUpdateVscode } = await group({
843
+ const { formatter: groupedFormatter, frameworks: groupedFrameworks, uncommittedConfirmed, updateVscodeSettings: shouldUpdateVscode } = await group({
656
844
  uncommittedConfirmed: async () => {
657
845
  if (isGitClean()) return true;
658
846
  return confirm({
@@ -660,6 +848,14 @@ async function run(options = {}) {
660
848
  message: "There are uncommitted changes in the current repository, are you sure to continue?"
661
849
  });
662
850
  },
851
+ formatter: async ({ results }) => {
852
+ if (argumentFormatter !== void 0 || results.uncommittedConfirmed !== true) return;
853
+ return select({
854
+ initialValue: "eslint",
855
+ message: "Where should formatting run?",
856
+ options: formatterOptions
857
+ });
858
+ },
663
859
  frameworks: async ({ results }) => {
664
860
  if ((argumentTemplate && argumentTemplate.length > 0 && argumentTemplate.every((template) => frameworks.includes(template))) === true || results.uncommittedConfirmed !== true) return;
665
861
  const message = argumentTemplate && argumentTemplate.length > 0 ? `"${argumentTemplate.join(", ")}" isn't a valid template. Please choose from below: ` : "Select a framework:";
@@ -681,6 +877,7 @@ async function run(options = {}) {
681
877
  process.exit(0);
682
878
  } });
683
879
  result = {
880
+ formatter: argumentFormatter ?? (groupedFormatter === "standalone" ? "standalone" : "eslint"),
684
881
  frameworks: isStringArray(groupedFrameworks) ? groupedFrameworks.filter((value) => {
685
882
  return frameworks.some((option) => option === value);
686
883
  }) : [],
@@ -708,6 +905,10 @@ yargs(hideBin(process.argv)).scriptName("@isentinel/eslint-config").usage("").co
708
905
  alias: "y",
709
906
  description: "Skip prompts and use default values",
710
907
  type: "boolean"
908
+ }).option("formatter", {
909
+ choices: ["eslint", "standalone"],
910
+ description: "Where formatting runs: in ESLint, or oxfmt on its own",
911
+ type: "string"
711
912
  }).option("template", {
712
913
  alias: "t",
713
914
  description: "Use the framework template for optimal customization: react, jest",